Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/fonts/lff.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

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