kicad/tools/newstroke/fontconv.py

541 lines
16 KiB
Python
Raw Normal View History

#!/usr/bin/python
"""
Generates newstroke_font.cpp from .kicad_sym font libraries.
Usage: fontconv.py
"""
from io import TextIOBase
from typing import Any, NamedTuple
import re
import sys
global_duplicate_point_removal = True
input_fonts = ['symbol', 'font', 'hiragana',
'katakana', 'half_full', 'CJK_symbol',
'CJK_wide_U+4E00',
'CJK_wide_U+5AE6',
'CJK_wide_U+66B9',
'CJK_wide_U+7212',
'CJK_wide_U+7D2A',
'CJK_wide_U+8814',
'CJK_wide_U+92B4',
'CJK_wide_U+9C60']
input_charlist = 'charlist.txt'
input_header = 'font_header.cpp'
output_cpp = '../../common/newstroke_font.cpp'
FONT_BASE = 9
FONT_SCALE = 50
FONT_C_BIAS = ord("R")
FONT_NEWSTROKE = " R"
C_ESC_TRANS = str.maketrans({'"': '\\"', '\\': '\\\\'})
REMOVE_REDUNDANT_STROKES = re.compile(r"(?P<point>\S\S) R(?P=point)")
REMOVE_POINT_PAIRS = re.compile(r"^(?P<prefix>(..)*)(?P<point>\S\S)(?P=point)")
def mm_to_mil_scaled(mm: float) -> int:
return round(mm / 0.0254 - 0.1) // FONT_SCALE
def c_encode(a: int, b: int) -> str:
return chr(a + FONT_C_BIAS) + chr(b + FONT_C_BIAS)
def remove_duplicate_points(data: str) -> str:
data = REMOVE_REDUNDANT_STROKES.sub(r"\g<point>", data)
return REMOVE_POINT_PAIRS.sub(r"\g<prefix>\g<point>", data)
def cesc(s: str):
return s.translate(C_ESC_TRANS)
###
# S-Expressions
###
# Sexpr code extracted from: http://rosettacode.org/wiki/S-Expressions
term_regex = r"""(?mx)
\s*(?:
(\()|
(\))|
([+-]?\d+\.\d+(?=[\ \)\n]))|
(\-?\d+(?=[\ \)\n]))|
"((?:[^"]|(?<=\\)")*)"|
([^(^)\s]+)
)"""
class SexprError(ValueError):
pass
def parse_sexp(sexp: str) -> Any:
re_iter = re.finditer(term_regex, sexp)
rv = list(_parse_sexp_internal(re_iter))
for leftover in re_iter:
lparen, rparen, *rest = leftover.groups()
if lparen or any(rest):
raise SexprError(f'Leftover garbage after end of expression at position {leftover.start()}') # noqa: E501
elif rparen:
raise SexprError(
f'Unbalanced closing parenthesis at position {leftover.start()}')
if len(rv) == 0:
raise SexprError('No or empty expression')
if len(rv) > 1:
raise SexprError('Missing initial opening parenthesis')
return rv[0]
def _parse_sexp_internal(re_iter) -> Any:
for match in re_iter:
lparen, rparen, float_num, integer_num, quoted_str, bare_str = match.groups()
if lparen:
yield list(_parse_sexp_internal(re_iter))
elif rparen:
break
elif bare_str is not None:
yield bare_str
elif quoted_str is not None:
yield quoted_str.replace('\\"', '"')
elif float_num:
yield float(float_num)
elif integer_num:
yield int(integer_num)
###
# Primitives
###
class Transform(NamedTuple):
SX: int = +1
SY: int = +1
OY: int = 0
class Point(NamedTuple):
x: int
y: int
@staticmethod
def from_mm(sx: str, sy: str) -> 'Point':
x = mm_to_mil_scaled(float(sx))
y = mm_to_mil_scaled(-float(sy))
return Point(x, y)
def transformed(self, tr: Transform, ofs: 'Point') -> 'Point':
return Point(self.x * tr.SX + ofs.x, self.y * tr.SY + tr.OY + ofs.y)
def as_data(self) -> str:
return c_encode(self.x, self.y + FONT_BASE)
def __add__(self, other):
return Point(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return Point(self.x - other.x, self.y - other.y)
POINT0 = Point(0, 0)
DEFAULT_TRANSFORM = Transform()
class Metrics(NamedTuple):
l: int
r: int
def transformed(self, tr: Transform, ofs: Point) -> 'Metrics':
a = self.l * tr.SX + ofs.x
b = self.r * tr.SX + ofs.x
return Metrics(min(a, b), max(a, b))
def as_data(self):
return c_encode(self.l, self.r)
def __and__(self, other):
if self.l == self.r:
return other
elif other.l == other.r:
return self
left = min(self.l, self.r, other.l, other.r)
right = max(self.l, self.r, other.l, other.r)
return Metrics(left, right)
###
# Glyph Input
###
class KicadSymError(ValueError):
pass
class Glyph(NamedTuple):
"""The immutable shape and metrics of a single glyph.
Carries the shape of a single glyph.
Can parse the data from the .kicad_sym symbol sexp.
The `data` of the glyph eventually ends up in the glyph array
newstroke_font.cpp`
Attributes:
name: The name of the symbol library component this glyph came from
metrics: Left & right extents
anchors: Named anchor points
strokes: Strokes in this glyph
width: Computed width of the glyph
"""
name: str
metrics: Metrics
anchors: dict[str, Point]
strokes: list[tuple[Point, ...]]
def as_data(self, tr: Transform, ofs: Point) -> str:
def stroke_gen(s):
return "".join(map(lambda p: p.transformed(tr, ofs).as_data(), s))
data = FONT_NEWSTROKE.join(map(stroke_gen, self.strokes))
if global_duplicate_point_removal:
return data
else:
return remove_duplicate_points(data)
@property
def width(self):
return self.metrics.r - self.metrics.l
@classmethod
def from_sexpr(cls, sexp: Any) -> 'Glyph':
if sexp[0] != "symbol":
raise KicadSymError(f"Expected a symbol sexpr: {sexp}")
name = sexp[1]
if name[0] in Compositions.transforms:
raise KicadSymError(f"Invalid glyph name {name}")
anchors = {'-': POINT0}
strokes: list[tuple[Point, ...]] = []
for s1 in sexp[2:]:
if s1[0] == "symbol":
for s2 in s1[1:]:
if s2[0] == "polyline":
strokes.append(Glyph._parse_polyline(s2))
elif s2[0] == "pin":
anchor, point = Glyph._parse_pin(s2)
anchors[anchor] = point
P, S = anchors.get("P", POINT0), anchors.get("S", POINT0)
if P.x > S.x:
raise KicadSymError(
f"P/S anchors are right-to-left: P={P.x}, S={S.x}")
if P is POINT0:
print(f" Warning: missing P anchor in glyph {name}")
if S is POINT0:
print(f" Warning: missing S anchor in glyph {name}")
return Glyph(name, Metrics(P.x, S.x), anchors, strokes)
@staticmethod
def _parse_polyline(sexp: Any) -> tuple[Point, ...]:
if sexp[0] != "polyline":
raise KicadSymError(f"Expected a polyline sexpr: {sexp}")
for s in sexp[1:]:
if s[0] == "pts":
points = s[1:]
break
stroke: list[Point] = []
for key, x, y in points:
if key != "xy":
raise KicadSymError(f"Expected a point sexpr: {points}")
stroke.append(Point.from_mm(x, y))
return tuple(stroke)
@staticmethod
def _parse_pin(sexp: Any) -> tuple[str, Point]:
# pins are used for metrics and anchors
if sexp[0] != "pin":
raise KicadSymError(f"Expected a pin sexpr: {sexp}")
for s in sexp[1:]:
if s[0] == "at":
point = Point.from_mm(s[1], s[2])
elif s[0] == "name":
pname = s[1]
if pname == "~":
pname = "P" if point.x <= 0 else "S"
return pname, point
def parse_symfile(filename: str) -> dict[str, Glyph]:
with open(filename, 'r', encoding='utf-8') as f:
sexp = parse_sexp(f.read())
glyphs: dict[str, Glyph] = {}
for s in sexp:
if s[0] == 'symbol':
glyph = Glyph.from_sexpr(s)
glyphs[glyph.name] = glyph
return glyphs
###
# Compositor
###
def _make_transforms() -> dict[str, Transform]:
cap_height = -21
x_height = -14
sym_height = -16
sup_offset = -13
sub_offset = 6
# transformation prefixes used in charlist.txt
# SX SY OY
return {
"!": Transform(-1, +1, 0), # revert
"-": Transform(+1, -1, x_height), # invert small
"=": Transform(+1, -1, cap_height), # invert cap
"~": Transform(+1, -1, sym_height), # invert symbol
"+": Transform(-1, -1, x_height), # rotate small
"%": Transform(-1, -1, cap_height), # rotate cap
"*": Transform(-1, -1, sym_height), # rotate symbol
"^": Transform(+1, +1, sup_offset), # superscript
"`": Transform(-1, +1, sup_offset), # superscript reversed
".": Transform(+1, +1, sub_offset), # subscript
",": Transform(-1, +1, sub_offset), # subscript reversed
}
class SubGlyph(NamedTuple):
glyph: Glyph
tname: str
transform: Transform = DEFAULT_TRANSFORM
offset: Point = POINT0
def as_data(self):
return self.glyph.as_data(self.transform, self.offset)
class Composition(list[SubGlyph]):
__slots__ = ["metrics"]
def __init__(self, *args):
super().__init__(self, *args)
self.metrics = Metrics(0, 0)
def append(self, sg: SubGlyph, in_metrics=True):
if in_metrics:
self.metrics &= sg.glyph.metrics.transformed(
sg.transform, sg.offset)
super().append(sg)
def as_data(self) -> str:
mdata = self.metrics.as_data()
gdata = FONT_NEWSTROKE.join(map(SubGlyph.as_data, self))
if global_duplicate_point_removal:
return mdata + remove_duplicate_points(gdata)
else:
return mdata + gdata
class Compositions:
"""Compositions of glyphs for every code point described in the character list file"""
transforms = _make_transforms()
def __init__(self, glyphs: dict[str, Glyph]):
self.glyphs = glyphs
self.default_subglyph = SubGlyph(glyphs["DEL"], "")
self.empty_subglyph = SubGlyph(glyphs["0"], "")
self.missed: set[str] = set()
self.used: set[str] = set()
self._skip = POINT0
self.font_name = "default_font"
self.codepoints: list[Composition] = []
self.comments: dict[int, str] = {}
@classmethod
def from_read(cls, sin: TextIOBase, glyphs: dict[str, Glyph]) -> 'Compositions':
charlist = Compositions(glyphs)
try:
for line in sin:
line = line[:line.find("#")] # remove comments
charlist._parse_command(line)
except BaseException:
print(f"Error parsing line '{line}'", file=sys.stderr)
raise
return charlist
@property
def _codepoint(self) -> int:
return len(self.codepoints)-1
def _new_composition(self) -> None:
self.codepoints.append(Composition())
self._skip = POINT0
def _gl_tr(self, glyphname: str) -> tuple[Glyph, Transform]:
transform = Compositions.transforms.get(
glyphname[0], DEFAULT_TRANSFORM)
if transform is not DEFAULT_TRANSFORM:
glyphname = glyphname[1:]
glyph = self.glyphs.get(glyphname, None)
if glyph:
self.used.add(glyphname)
else:
self.missed.add(glyphname)
glyph = self.default_subglyph.glyph
return glyph, transform
def _parse_command(self, line) -> None:
tokens = line.split()
tokens.reverse()
if not tokens:
return
cmd = tokens.pop()
if cmd in {"+", "+w", "+p", "+(", "+|", "+)"}:
if cmd != "+|" and cmd != "+)":
self._new_composition()
self._parse_entry(tokens, cmd == "+w" or cmd == "+p")
elif cmd.startswith("//"):
self.comments[self._codepoint] = line
elif cmd == "skipcodes":
numskip = int(tokens.pop())
subglyph = self.default_subglyph if self._codepoint < 0x9000 else self.empty_subglyph
for _ in range(numskip):
self._new_composition()
self.codepoints[-1].append(subglyph)
elif cmd == "startchar":
codepoint = int(tokens.pop())
self.codepoints = [Composition()] * codepoint
elif cmd == "font":
self.font_name = tokens.pop()
else:
raise ValueError(f"Invalid charlist command '{cmd}'")
def _parse_entry(self, tokens, sub_metrics) -> None:
composition = self.codepoints[-1]
bname = tokens.pop()
base, btr = self._gl_tr(bname)
if self._skip is not POINT0:
self._skip += Point(-base.metrics.l, 0)
composition.append(SubGlyph(base, bname, btr, self._skip))
while tokens:
name = tokens.pop()
sub, tr = self._gl_tr(name)
offset = self._skip
if tokens:
parts = tokens.pop().split("=")
if len(parts) == 2:
n_from, n_to = parts
a_from = base.anchors[n_from].transformed(btr, POINT0)
a_to = sub.anchors[n_to].transformed(tr, POINT0)
offset += (a_from - a_to)
composition.append(SubGlyph(sub, name, tr, offset), sub_metrics)
self._skip += Point(base.metrics.r, 0)
class CFontWriter:
"""Processes the compositions to generate a C file with the font."""
def __init__(self, comps: Compositions):
self.comps = comps
def print_stats(self, sout: TextIOBase):
glyphs = set(self.comps.glyphs.keys())
unused_glyphs = list(glyphs - self.comps.used)
if self.comps.missed:
print(
f"/* --- {len(self.comps.missed)} missed glyphs --- */", file=sout)
for m in self.comps.missed:
print(f"/* {m} */", file=sout)
if unused_glyphs:
print("/* --- unused glyphs --- */", file=sout)
unused_glyphs.sort()
for u in unused_glyphs:
print(f"/* {u} */", file=sout)
def generate_c_output(self, start_cp: int, sout: TextIOBase) -> None:
fname = self.comps.font_name
print(f"\n\n\nconst char* const {fname}[] =\n{{", file=sout)
cmt_iter = iter(self.comps.comments)
while (cmt_point := next(cmt_iter, start_cp)) < start_cp:
self._print_comment(cmt_point, sout)
for cp, composition in enumerate(self.comps.codepoints[start_cp:], start_cp):
CFontWriter._print_row(cp, composition, sout)
if cmt_point == cp:
self._print_comment(cmt_point, sout)
cmt_point = next(cmt_iter, 0)
end = f"}};\nconst int {fname}_bufsize = sizeof({fname})/sizeof({fname}[0]);\n"
print(end, file=sout)
def _print_comment(self, codepoint, sout):
print(f" /* {self.comps.comments[codepoint]} */", file=sout)
@staticmethod
def _print_row(codepoint, composition: Composition, sout: TextIOBase):
data = cesc(composition.as_data())
if codepoint % 16:
print(f' "{data}",', file=sout)
else:
name1 = composition[0].tname
name2 = composition[1].tname if len(composition) > 1 else ""
if name1:
print(
f' "{data}", /* U+{codepoint:X} {name1} {name2} */', file=sout)
else:
print(f' "{data}", /* U+{codepoint:X} */', file=sout)
if __name__ == "__main__":
print('** Reading glyphs from fonts:')
all_glyphs: dict[str, Glyph] = {}
for basename in input_fonts:
print(f' - Reading {basename}.kicad_sym...')
all_glyphs |= parse_symfile(f'{basename}.kicad_sym')
print(f"** Reading {input_header}")
header = open(input_header, encoding='utf-8').read()
print(f"** Reading {input_charlist}")
with open(input_charlist, encoding='utf-8') as src:
compositions = Compositions.from_read(src, all_glyphs)
print(f"** Writing {output_cpp}")
font = CFontWriter(compositions)
with open(output_cpp, "w", encoding='utf-8') as dst:
dst.write(header)
font.generate_c_output(0x20, dst)
font.print_stats(dst)
print("** Done")