#!/usr/bin/env python
"""
K.NAKADA, kengo.nakada@gmail.com
KanjiVG の SVG ファイルから筆順付き stroke データを読み込む。
[oai_citation:1‡KanjiVG](https://kanjivg.tagaini.net/svg-format.html?utm_source=chatgpt.com)つ SVG path を読み取り、FontStrokeGlyph に変換する。
KanjiVG の StrokePaths グループには、漢字の各筆画が SVG path として
筆順順に格納されている。この順序をそのままロボット筆記順として使う。
このモジュールは、漢字用の本命ルートである。
KanjiVG SVG
->
FontStrokeGlyph
->
FontMotionBuilder
という流れで使う。
"""
import re
import xml.etree.ElementTree as ElementTree
from pathlib import Path
from typing import List, Optional, Tuple
from glyph_motion.lib.font_geometry import FontPoint
from glyph_motion.lib.font_geometry import FontStrokeGlyph
from glyph_motion.lib.font_geometry import FontStrokePath
SvgCommand = Tuple[str, List[float]]
[ドキュメント]
class FontKanjiVGStrokeLoader:
"""KanjiVG SVG から筆順付き FontStrokeGlyph を生成するクラスである。"""
def __init__(
self,
kanjivg_directory: Path,
height: float,
spacing: float,
curve_division: int = 16,
) -> None:
"""KanjiVG ディレクトリ、出力文字高さ、文字間隔、曲線分割数を設定する。"""
if height <= 0.0:
raise ValueError("height は正の値である必要がある。")
if spacing < 0.0:
raise ValueError("spacing は0以上である必要がある。")
if curve_division < 1:
raise ValueError("curve_division は1以上である必要がある。")
self._kanjivg_directory = kanjivg_directory
self._height = height
self._spacing = spacing
self._curve_division = curve_division
self._path_sampler = SvgPathSampler(
curve_division=curve_division,
)
[ドキュメント]
def load_text(
self,
text: str,
) -> FontStrokeGlyph:
"""文字列を KanjiVG 由来の筆順付き stroke データとして読み込む。"""
stroke_paths: List[FontStrokePath] = []
x_offset = 0.0
for character in text:
if character == " ":
x_offset = x_offset + self._height + self._spacing
continue
character_glyph, advance_width = self.load_character(
character=character,
)
for stroke_path in character_glyph:
shifted_points: List[FontPoint] = []
for point in stroke_path:
shifted_points.append(
FontPoint(
x=point.x + x_offset,
y=point.y,
)
)
stroke_paths.append(
FontStrokePath(
points=shifted_points,
)
)
x_offset = x_offset + advance_width + self._spacing
return FontStrokeGlyph(
text=text,
paths=stroke_paths,
)
[ドキュメント]
def load_character(
self,
character: str,
) -> Tuple[FontStrokeGlyph, float]:
"""一文字の KanjiVG SVG を読み込み、筆順付き stroke データを返す。"""
if len(character) != 1:
raise ValueError("character は1文字である必要がある。")
svg_file = self._find_svg_file(
character=character,
)
tree = ElementTree.parse(str(svg_file))
root = tree.getroot()
view_box = self._read_view_box(
root=root,
)
raw_paths = self._collect_stroke_paths(
root=root,
)
if len(raw_paths) == 0:
raise ValueError("KanjiVG SVG 内に stroke path が存在しない。")
scale = self._height / view_box[3]
advance_width = view_box[2] * scale
stroke_paths: List[FontStrokePath] = []
for raw_path in raw_paths:
svg_points = self._path_sampler.sample_path(
path_data=raw_path,
)
points: List[FontPoint] = []
for point in svg_points:
points.append(
self._convert_svg_point(
point=point,
view_box=view_box,
scale=scale,
)
)
if len(points) > 0:
stroke_paths.append(
FontStrokePath(
points=points,
)
)
return (
FontStrokeGlyph(
text=character,
paths=stroke_paths,
),
advance_width,
)
[ドキュメント]
def _find_svg_file(
self,
character: str,
) -> Path:
"""文字に対応する KanjiVG SVG ファイルを探す。"""
code_point = ord(character)
file_name = "{:05x}.svg".format(code_point)
svg_file = self._kanjivg_directory / file_name
if svg_file.exists() is False:
raise FileNotFoundError("KanjiVG SVG が見つからない: {}".format(svg_file))
return svg_file
[ドキュメント]
def _read_view_box(
self,
root,
) -> Tuple[float, float, float, float]:
"""SVG の viewBox を読み取る。"""
view_box_text = root.attrib.get("viewBox")
if view_box_text is None:
return (
0.0,
0.0,
109.0,
109.0,
)
values = view_box_text.replace(",", " ").split()
if len(values) != 4:
raise ValueError("SVG viewBox の形式が不正である。")
return (
float(values[0]),
float(values[1]),
float(values[2]),
float(values[3]),
)
[ドキュメント]
def _collect_stroke_paths(
self,
root,
) -> List[str]:
"""StrokePaths グループ内の path d 属性を筆順順に集める。"""
stroke_paths_group = self._find_stroke_paths_group(
root=root,
)
if stroke_paths_group is None:
raise ValueError("KanjiVG SVG に StrokePaths グループが存在しない。")
raw_paths: List[str] = []
for element in stroke_paths_group.iter():
tag_name = self._strip_namespace(
tag=element.tag,
)
if tag_name != "path":
continue
path_data = element.attrib.get("d")
if path_data is None:
continue
raw_paths.append(path_data)
return raw_paths
[ドキュメント]
def _find_stroke_paths_group(
self,
root,
):
"""id が kvg:StrokePaths で始まる SVG group を探す。"""
for element in root.iter():
tag_name = self._strip_namespace(
tag=element.tag,
)
if tag_name != "g":
continue
element_id = element.attrib.get("id", "")
if element_id.startswith("kvg:StrokePaths"):
return element
return None
[ドキュメント]
def _strip_namespace(
self,
tag: str,
) -> str:
"""XML 名前空間を除いたタグ名を返す。"""
if "}" in tag:
return tag.split("}", 1)[1]
return tag
[ドキュメント]
def _convert_svg_point(
self,
point: FontPoint,
view_box: Tuple[float, float, float, float],
scale: float,
) -> FontPoint:
"""SVG 座標を表示・ロボット処理用のXY座標へ変換する。"""
view_x_min = view_box[0]
view_y_min = view_box[1]
view_height = view_box[3]
x = (point.x - view_x_min) * scale
# SVG は下向きが +Y である。
# 既存の viewer と motion 系では上向きを +Y として扱うため反転する。
y = (view_height - (point.y - view_y_min)) * scale
return FontPoint(
x=x,
y=y,
)
[ドキュメント]
class SvgPathSampler:
"""SVG path の M/L/H/V/Q/T/C/S/Z 命令を点列へサンプリングする。"""
def __init__(
self,
curve_division: int,
) -> None:
"""曲線命令の分割数を設定する。"""
if curve_division < 1:
raise ValueError("curve_division は1以上である必要がある。")
self._curve_division = curve_division
[ドキュメント]
def sample_path(
self,
path_data: str,
) -> List[FontPoint]:
"""SVG path data を点列へ変換する。"""
tokens = self._tokenize_path(
path_data=path_data,
)
points: List[FontPoint] = []
current_point = FontPoint(
x=0.0,
y=0.0,
)
start_point = FontPoint(
x=0.0,
y=0.0,
)
previous_command = ""
previous_cubic_control: Optional[FontPoint] = None
previous_quadratic_control: Optional[FontPoint] = None
index = 0
command = ""
while index < len(tokens):
token = tokens[index]
if self._is_command_token(token):
command = token
index = index + 1
elif command == "":
raise ValueError("SVG path が command 以外から始まっている。")
command_upper = command.upper()
relative = command.islower()
if command_upper == "M":
first_point = True
while self._has_number(tokens=tokens, index=index):
x = float(tokens[index])
y = float(tokens[index + 1])
index = index + 2
next_point = self._make_point(
x=x,
y=y,
relative=relative,
current_point=current_point,
)
current_point = next_point
if first_point:
points.append(current_point)
start_point = current_point
first_point = False
else:
points.append(current_point)
previous_cubic_control = None
previous_quadratic_control = None
previous_command = command
elif command_upper == "L":
while self._has_number(tokens=tokens, index=index):
x = float(tokens[index])
y = float(tokens[index + 1])
index = index + 2
current_point = self._make_point(
x=x,
y=y,
relative=relative,
current_point=current_point,
)
points.append(current_point)
previous_cubic_control = None
previous_quadratic_control = None
previous_command = command
elif command_upper == "H":
while self._has_number(tokens=tokens, index=index):
x = float(tokens[index])
index = index + 1
if relative:
current_point = FontPoint(
x=current_point.x + x,
y=current_point.y,
)
else:
current_point = FontPoint(
x=x,
y=current_point.y,
)
points.append(current_point)
previous_cubic_control = None
previous_quadratic_control = None
previous_command = command
elif command_upper == "V":
while self._has_number(tokens=tokens, index=index):
y = float(tokens[index])
index = index + 1
if relative:
current_point = FontPoint(
x=current_point.x,
y=current_point.y + y,
)
else:
current_point = FontPoint(
x=current_point.x,
y=y,
)
points.append(current_point)
previous_cubic_control = None
previous_quadratic_control = None
previous_command = command
elif command_upper == "C":
while self._has_number(tokens=tokens, index=index):
control_1 = self._make_point(
x=float(tokens[index]),
y=float(tokens[index + 1]),
relative=relative,
current_point=current_point,
)
control_2 = self._make_point(
x=float(tokens[index + 2]),
y=float(tokens[index + 3]),
relative=relative,
current_point=current_point,
)
end_point = self._make_point(
x=float(tokens[index + 4]),
y=float(tokens[index + 5]),
relative=relative,
current_point=current_point,
)
index = index + 6
curve_points = self._sample_cubic_bezier(
p0=current_point,
p1=control_1,
p2=control_2,
p3=end_point,
)
for point in curve_points:
points.append(point)
current_point = end_point
previous_cubic_control = control_2
previous_quadratic_control = None
previous_command = command
elif command_upper == "S":
while self._has_number(tokens=tokens, index=index):
if previous_command.upper() in ["C", "S"]:
control_1 = self._reflect_point(
point=previous_cubic_control,
center=current_point,
)
else:
control_1 = current_point
control_2 = self._make_point(
x=float(tokens[index]),
y=float(tokens[index + 1]),
relative=relative,
current_point=current_point,
)
end_point = self._make_point(
x=float(tokens[index + 2]),
y=float(tokens[index + 3]),
relative=relative,
current_point=current_point,
)
index = index + 4
curve_points = self._sample_cubic_bezier(
p0=current_point,
p1=control_1,
p2=control_2,
p3=end_point,
)
for point in curve_points:
points.append(point)
current_point = end_point
previous_cubic_control = control_2
previous_quadratic_control = None
previous_command = command
elif command_upper == "Q":
while self._has_number(tokens=tokens, index=index):
control = self._make_point(
x=float(tokens[index]),
y=float(tokens[index + 1]),
relative=relative,
current_point=current_point,
)
end_point = self._make_point(
x=float(tokens[index + 2]),
y=float(tokens[index + 3]),
relative=relative,
current_point=current_point,
)
index = index + 4
curve_points = self._sample_quadratic_bezier(
p0=current_point,
p1=control,
p2=end_point,
)
for point in curve_points:
points.append(point)
current_point = end_point
previous_quadratic_control = control
previous_cubic_control = None
previous_command = command
elif command_upper == "T":
while self._has_number(tokens=tokens, index=index):
if previous_command.upper() in ["Q", "T"]:
control = self._reflect_point(
point=previous_quadratic_control,
center=current_point,
)
else:
control = current_point
end_point = self._make_point(
x=float(tokens[index]),
y=float(tokens[index + 1]),
relative=relative,
current_point=current_point,
)
index = index + 2
curve_points = self._sample_quadratic_bezier(
p0=current_point,
p1=control,
p2=end_point,
)
for point in curve_points:
points.append(point)
current_point = end_point
previous_quadratic_control = control
previous_cubic_control = None
previous_command = command
elif command_upper == "Z":
current_point = start_point
points.append(current_point)
previous_cubic_control = None
previous_quadratic_control = None
previous_command = command
else:
raise ValueError("未対応の SVG path command である: {}".format(command))
return points
[ドキュメント]
def _tokenize_path(
self,
path_data: str,
) -> List[str]:
"""SVG path data を command と数値 token に分解する。"""
pattern = r"[AaCcHhLlMmQqSsTtVvZz]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?"
return re.findall(pattern, path_data)
[ドキュメント]
def _is_command_token(
self,
token: str,
) -> bool:
"""token が SVG path command か判定する。"""
return bool(re.fullmatch(r"[AaCcHhLlMmQqSsTtVvZz]", token))
[ドキュメント]
def _has_number(
self,
tokens: List[str],
index: int,
) -> bool:
"""現在位置に数値 token があるか判定する。"""
if index >= len(tokens):
return False
return not self._is_command_token(
token=tokens[index],
)
[ドキュメント]
def _make_point(
self,
x: float,
y: float,
relative: bool,
current_point: FontPoint,
) -> FontPoint:
"""絶対または相対座標から FontPoint を作る。"""
if relative:
return FontPoint(
x=current_point.x + x,
y=current_point.y + y,
)
return FontPoint(
x=x,
y=y,
)
[ドキュメント]
def _reflect_point(
self,
point: Optional[FontPoint],
center: FontPoint,
) -> FontPoint:
"""制御点を現在点に対して反転する。"""
if point is None:
return center
return FontPoint(
x=2.0 * center.x - point.x,
y=2.0 * center.y - point.y,
)
[ドキュメント]
def _sample_quadratic_bezier(
self,
p0: FontPoint,
p1: FontPoint,
p2: FontPoint,
) -> List[FontPoint]:
"""2次ベジェ曲線を点列化する。"""
points: List[FontPoint] = []
for index in range(1, self._curve_division + 1):
t = float(index) / float(self._curve_division)
one_minus_t = 1.0 - t
x = one_minus_t * one_minus_t * p0.x
x = x + 2.0 * one_minus_t * t * p1.x
x = x + t * t * p2.x
y = one_minus_t * one_minus_t * p0.y
y = y + 2.0 * one_minus_t * t * p1.y
y = y + t * t * p2.y
points.append(
FontPoint(
x=x,
y=y,
)
)
return points
[ドキュメント]
def _sample_cubic_bezier(
self,
p0: FontPoint,
p1: FontPoint,
p2: FontPoint,
p3: FontPoint,
) -> List[FontPoint]:
"""3次ベジェ曲線を点列化する。"""
points: List[FontPoint] = []
for index in range(1, self._curve_division + 1):
t = float(index) / float(self._curve_division)
one_minus_t = 1.0 - t
x = one_minus_t * one_minus_t * one_minus_t * p0.x
x = x + 3.0 * one_minus_t * one_minus_t * t * p1.x
x = x + 3.0 * one_minus_t * t * t * p2.x
x = x + t * t * t * p3.x
y = one_minus_t * one_minus_t * one_minus_t * p0.y
y = y + 3.0 * one_minus_t * one_minus_t * t * p1.y
y = y + 3.0 * one_minus_t * t * t * p2.y
y = y + t * t * t * p3.y
points.append(
FontPoint(
x=x,
y=y,
)
)
return points
[ドキュメント]
def main() -> None:
"""KanjiVG stroke 読み込みを単体確認する。"""
from glyph_motion.lib.run_font_viewer import FontViewer
loader = FontKanjiVGStrokeLoader(
kanjivg_directory=Path("kanjivg/kanji"),
height=40.0,
spacing=5.0,
curve_division=16,
)
stroke_glyph = loader.load_text(
text="島",
)
print(stroke_glyph)
print("stroke_count =", len(stroke_glyph))
viewer = FontViewer()
viewer.show_stroke(
stroke_glyph=stroke_glyph,
)
if __name__ == "__main__":
main()