325 lines
10 KiB
Python
325 lines
10 KiB
Python
# Copyright (c) 2023, Manfred Moitzi
|
|
# License: MIT License
|
|
#
|
|
# Basic tools for the LibreCAD Font Format:
|
|
# https://github.com/Rallaz/LibreCAD/wiki/lff-definition
|
|
from __future__ import annotations
|
|
from typing import Sequence, Iterator, Iterable, Optional, no_type_check
|
|
from typing_extensions import TypeAlias
|
|
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
|
|
from ezdxf import path
|
|
from .font_measurements import FontMeasurements
|
|
from .glyphs import GlyphPath, Glyphs
|
|
|
|
__all__ = ["loads", "LCFont", "Glyph", "GlyphCache"]
|
|
|
|
|
|
def loads(s: str) -> LCFont:
|
|
lines = s.split("\n")
|
|
name, letter_spacing, word_spacing = parse_properties(lines)
|
|
lcf = LCFont(name, letter_spacing, word_spacing)
|
|
for glyph, parent_code in parse_glyphs(lines):
|
|
lcf.add(glyph, parent_code)
|
|
return lcf
|
|
|
|
|
|
class LCFont:
|
|
"""Low level representation of LibreCAD fonts."""
|
|
def __init__(
|
|
self, name: str = "", letter_spacing: float = 0.0, word_spacing: float = 0.0
|
|
) -> None:
|
|
self.name: str = name
|
|
self.letter_spacing: float = letter_spacing
|
|
self.word_spacing: float = word_spacing
|
|
self._glyphs: dict[int, Glyph] = dict()
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._glyphs)
|
|
|
|
def __getitem__(self, item: int) -> Glyph:
|
|
return self._glyphs[item]
|
|
|
|
def add(self, glyph: Glyph, parent_code: int = 0) -> None:
|
|
if parent_code:
|
|
try:
|
|
parent_glyph = self._glyphs[parent_code]
|
|
except KeyError:
|
|
return
|
|
glyph = parent_glyph.extend(glyph)
|
|
self._glyphs[glyph.code] = glyph
|
|
|
|
def get(self, code: int) -> Optional[Glyph]:
|
|
return self._glyphs.get(code, None)
|
|
|
|
|
|
Polyline: TypeAlias = Sequence[Sequence[float]]
|
|
|
|
|
|
class Glyph:
|
|
"""Low level representation of a LibreCAD glyph."""
|
|
__slots__ = ("code", "polylines")
|
|
|
|
def __init__(self, code: int, polylines: Sequence[Polyline]):
|
|
self.code: int = code
|
|
self.polylines: Sequence[Polyline] = tuple(polylines)
|
|
|
|
def extend(self, glyph: Glyph) -> Glyph:
|
|
polylines = list(self.polylines)
|
|
polylines.extend(glyph.polylines)
|
|
return Glyph(glyph.code, polylines)
|
|
|
|
def to_path(self) -> GlyphPath:
|
|
from ezdxf.math import OCS
|
|
|
|
final_path = path.Path()
|
|
ocs = OCS()
|
|
for polyline in self.polylines:
|
|
p = path.Path() # empty path is required
|
|
path.add_2d_polyline(
|
|
p, convert_bulge_values(polyline), close=False, elevation=0, ocs=ocs
|
|
)
|
|
final_path.extend_multi_path(p)
|
|
return GlyphPath(final_path)
|
|
|
|
|
|
def convert_bulge_values(polyline: Polyline) -> Iterator[Sequence[float]]:
|
|
# In DXF the bulge value is always stored at the start vertex of the arc.
|
|
last_index = len(polyline) - 1
|
|
for index, vertex in enumerate(polyline):
|
|
bulge = 0.0
|
|
if index < last_index:
|
|
next_vertex = polyline[index + 1]
|
|
try:
|
|
bulge = next_vertex[2]
|
|
except IndexError:
|
|
pass
|
|
yield vertex[0], vertex[1], bulge
|
|
|
|
|
|
def parse_properties(lines: list[str]) -> tuple[str, float, float]:
|
|
font_name = ""
|
|
letter_spacing = 0.0
|
|
word_spacing = 0.0
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line.startswith("#"):
|
|
continue
|
|
try:
|
|
name, value = line.split(":")
|
|
except ValueError:
|
|
continue
|
|
|
|
name = name[1:].strip()
|
|
if name == "Name":
|
|
font_name = value.strip()
|
|
elif name == "LetterSpacing":
|
|
try:
|
|
letter_spacing = float(value)
|
|
except ValueError:
|
|
continue
|
|
elif name == "WordSpacing":
|
|
try:
|
|
word_spacing = float(value)
|
|
except ValueError:
|
|
continue
|
|
return font_name, letter_spacing, word_spacing
|
|
|
|
|
|
def scan_glyphs(lines: Iterable[str]) -> Iterator[list[str]]:
|
|
glyph: list[str] = []
|
|
for line in lines:
|
|
if line.startswith("["):
|
|
if glyph:
|
|
yield glyph
|
|
glyph.clear()
|
|
if line:
|
|
glyph.append(line)
|
|
if glyph:
|
|
yield glyph
|
|
|
|
|
|
def strip_clutter(lines: list[str]) -> Iterator[str]:
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line.startswith("#"):
|
|
yield line
|
|
|
|
|
|
def scan_int_ex(s: str) -> int:
|
|
from string import hexdigits
|
|
|
|
if len(s) == 0:
|
|
return 0
|
|
try:
|
|
end = s.index("]")
|
|
except ValueError:
|
|
end = len(s)
|
|
s = s[1:end].lower()
|
|
s = "".join(c for c in s if c in hexdigits)
|
|
try:
|
|
return int(s, 16)
|
|
except ValueError:
|
|
return 0
|
|
|
|
|
|
def parse_glyphs(lines: list[str]) -> Iterator[tuple[Glyph, int]]:
|
|
code: int
|
|
polylines: list[Polyline] = []
|
|
for glyph in scan_glyphs(strip_clutter(lines)):
|
|
parent_code: int = 0
|
|
polylines.clear()
|
|
line = glyph.pop(0)
|
|
if line[0] != "[":
|
|
continue
|
|
try:
|
|
code = int(line[1 : line.index("]")], 16)
|
|
except ValueError:
|
|
code = scan_int_ex(line)
|
|
if code == 0:
|
|
continue
|
|
line = glyph[0]
|
|
if line.startswith("C"):
|
|
glyph.pop(0)
|
|
try:
|
|
parent_code = int(line[1:], 16)
|
|
except ValueError:
|
|
continue
|
|
polylines = list(parse_polylines(glyph))
|
|
yield Glyph(code, polylines), parent_code
|
|
|
|
|
|
def parse_polylines(lines: Iterable[str]) -> Iterator[Polyline]:
|
|
polyline: list[Sequence[float]] = []
|
|
for line in lines:
|
|
polyline.clear()
|
|
for vertex in line.split(";"):
|
|
values = to_floats(vertex.split(","))
|
|
if len(values) > 1:
|
|
polyline.append(values[:3])
|
|
yield tuple(polyline)
|
|
|
|
|
|
def to_floats(values: Iterable[str]) -> Sequence[float]:
|
|
def strip(value: str) -> float:
|
|
if value.startswith("A"):
|
|
value = value[1:]
|
|
try:
|
|
return float(value)
|
|
except ValueError:
|
|
return 0.0
|
|
|
|
return tuple(strip(value) for value in values)
|
|
|
|
|
|
class GlyphCache(Glyphs):
|
|
"""Text render engine for LibreCAD fonts with integrated glyph caching."""
|
|
|
|
def __init__(self, font: LCFont) -> None:
|
|
self.font: LCFont = font
|
|
self._glyph_cache: dict[int, GlyphPath] = dict()
|
|
self._advance_width_cache: dict[int, float] = dict()
|
|
self.space_width: float = self.font.word_spacing
|
|
self.empty_box: GlyphPath = self.get_empty_box()
|
|
self.font_measurements: FontMeasurements = self._get_font_measurements()
|
|
|
|
def get_scaling_factor(self, cap_height: float) -> float:
|
|
try:
|
|
return cap_height / self.font_measurements.cap_height
|
|
except ZeroDivisionError:
|
|
return 1.0
|
|
|
|
def get_empty_box(self) -> GlyphPath:
|
|
glyph_A = self.get_shape(65)
|
|
box = BoundingBox2d(glyph_A.control_vertices())
|
|
height = box.size.y
|
|
width = box.size.x
|
|
start = glyph_A.start
|
|
p = path.Path(start)
|
|
p.line_to(start + Vec2(width, 0))
|
|
p.line_to(start + Vec2(width, height))
|
|
p.line_to(start + Vec2(0, height))
|
|
p.close()
|
|
p.move_to(glyph_A.end)
|
|
return GlyphPath(p)
|
|
|
|
def _render_shape(self, shape_number) -> GlyphPath:
|
|
try:
|
|
glyph = self.font[shape_number]
|
|
except KeyError:
|
|
if shape_number > 32:
|
|
return self.empty_box
|
|
raise ValueError("space and non-printable characters are not glyphs")
|
|
return glyph.to_path()
|
|
|
|
def get_shape(self, shape_number: int) -> GlyphPath:
|
|
if shape_number <= 32:
|
|
raise ValueError("space and non-printable characters are not glyphs")
|
|
try:
|
|
return self._glyph_cache[shape_number].clone()
|
|
except KeyError:
|
|
pass
|
|
glyph = self._render_shape(shape_number)
|
|
self._glyph_cache[shape_number] = glyph
|
|
advance_width = 0.0
|
|
if len(glyph):
|
|
box = glyph.bbox()
|
|
assert box.extmax is not None
|
|
advance_width = box.extmax.x + self.font.letter_spacing
|
|
self._advance_width_cache[shape_number] = advance_width
|
|
return glyph.clone()
|
|
|
|
def get_advance_width(self, shape_number: int) -> float:
|
|
if shape_number < 32:
|
|
return 0.0
|
|
if shape_number == 32:
|
|
return self.space_width
|
|
try:
|
|
return self._advance_width_cache[shape_number]
|
|
except KeyError:
|
|
pass
|
|
_ = self.get_shape(shape_number)
|
|
return self._advance_width_cache[shape_number]
|
|
|
|
@no_type_check
|
|
def _get_font_measurements(self) -> FontMeasurements:
|
|
# ignore last move_to command, which places the pen at the start of the
|
|
# following glyph
|
|
bbox = BoundingBox2d(self.get_shape(ord("x")).control_vertices())
|
|
baseline = bbox.extmin.y
|
|
x_height = bbox.extmax.y - baseline
|
|
|
|
bbox = BoundingBox2d(self.get_shape(ord("A")).control_vertices())
|
|
cap_height = bbox.extmax.y - baseline
|
|
bbox = BoundingBox2d(self.get_shape(ord("p")).control_vertices())
|
|
descender_height = baseline - bbox.extmin.y
|
|
return FontMeasurements(
|
|
baseline=baseline,
|
|
cap_height=cap_height,
|
|
x_height=x_height,
|
|
descender_height=descender_height,
|
|
)
|
|
|
|
def get_text_length(
|
|
self, text: str, cap_height: float, width_factor: float = 1.0
|
|
) -> float:
|
|
scaling_factor = self.get_scaling_factor(cap_height) * width_factor
|
|
return sum(self.get_advance_width(ord(c)) for c in text) * scaling_factor
|
|
|
|
def get_text_glyph_paths(
|
|
self, text: str, cap_height: float, width_factor: float = 1.0
|
|
) -> list[GlyphPath]:
|
|
glyph_paths: list[GlyphPath] = []
|
|
sy = self.get_scaling_factor(cap_height)
|
|
sx = sy * width_factor
|
|
m = Matrix44.scale(sx, sy, 1)
|
|
current_location = 0.0
|
|
for c in text:
|
|
shape_number = ord(c)
|
|
if shape_number > 32:
|
|
glyph = self.get_shape(shape_number)
|
|
m[3, 0] = current_location
|
|
glyph.transform_inplace(m)
|
|
glyph_paths.append(glyph)
|
|
current_location += self.get_advance_width(shape_number) * sx
|
|
return glyph_paths
|