#!/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\S\S) R(?P=point)") REMOVE_POINT_PAIRS = re.compile(r"^(?P(..)*)(?P\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", data) return REMOVE_POINT_PAIRS.sub(r"\g\g", 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")