mirror of
https://gitlab.com/kicad/code/kicad.git
synced 2025-09-14 02:03:12 +02:00
This means you don't have to keep referring to a Unicode table to know which character is where - you can see it in the list and use the symbol search system. No change to generated output (such suffixes are stripped by the fontconv.py script).
545 lines
16 KiB
Python
545 lines
16 KiB
Python
#!/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}")
|
|
|
|
# If the name ends with a double underscore suffix, it's a suffix with the
|
|
# codepoint of the glyph as a readable glyph, and we don't need it here.
|
|
name = re.sub(r"__.*$", "", 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")
|