This commit is contained in:
Christian Anetzberger
2026-01-22 20:23:51 +01:00
commit a197de9456
4327 changed files with 1235205 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License

View File

@@ -0,0 +1,78 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import NamedTuple
class FontFace(NamedTuple):
# filename without parent directories e.g. "OpenSans-Regular.ttf"
filename: str = ""
family: str = "sans-serif"
style: str = "Regular"
weight: int = 400 # Normal - usWeightClass
width: int = 5 # Medium(Normal) - usWidthClass
@property
def is_italic(self) -> bool:
"""Returns ``True`` if font face is italic."""
return self.style.lower().find("italic") > -1
@property
def is_oblique(self) -> bool:
"""Returns ``True`` if font face is oblique."""
return self.style.lower().find("oblique") > -1
@property
def is_bold(self) -> bool:
"""Returns ``True`` if font face weight > 400."""
return self.weight > 400
@property
def weight_str(self) -> str:
"""Returns the :attr:`weight` as string e.g. "Thin", "Normal", "Bold", ..."""
return get_weight_str(self.weight)
@property
def width_str(self) -> str:
"""Returns the :attr:`width` as string e.g. "Condensed", "Expanded", ..."""
return get_width_str(self.width)
def distance(self, font_face: FontFace) -> tuple[int, int]:
return self.weight - font_face.weight, self.width - font_face.width
WEIGHT_STR = {
100: "Thin",
200: "ExtraLight",
300: "Light",
400: "Normal",
500: "Medium",
600: "SemiBold",
700: "Bold",
800: "ExtraBold",
900: "Black",
}
WIDTH_STR = {
1: "UltraCondensed",
2: "ExtraCondensed",
3: "Condensed",
4: "SemiCondensed",
5: "Medium", # Normal
6: "SemiExpanded",
7: "Expanded",
8: "ExtraExpanded",
9: "UltraExpanded",
}
def get_weight_str(weight: int) -> str:
"""Returns the :attr:`weight` as string e.g. "Thin", "Normal", "Bold", ..."""
key = max(min(round((weight + 1) / 100) * 100, 900), 100)
return WEIGHT_STR[key]
def get_width_str(width: int) -> str:
"""Returns the :attr:`width` as string e.g. "Condensed", "Expanded", ..."""
key = max(min(width, 9), 1)
return WIDTH_STR[key]

View File

@@ -0,0 +1,526 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
import pathlib
from typing import Iterable, NamedTuple, Optional, Sequence
import os
import platform
import json
import logging
from pathlib import Path
from fontTools.ttLib import TTFont, TTLibError
from .font_face import FontFace
from . import shapefile, lff
logger = logging.getLogger("ezdxf")
WINDOWS = "Windows"
LINUX = "Linux"
MACOS = "Darwin"
WIN_SYSTEM_ROOT = os.environ.get("SystemRoot", "C:/Windows")
WIN_FONT_DIRS = [
# AutoCAD and BricsCAD do not support fonts installed in the user directory:
"~/AppData/Local/Microsoft/Windows/Fonts",
f"{WIN_SYSTEM_ROOT}/Fonts",
]
LINUX_FONT_DIRS = [
"/usr/share/fonts",
"/usr/local/share/fonts",
"~/.fonts",
"~/.local/share/fonts",
"~/.local/share/texmf/fonts",
]
MACOS_FONT_DIRS = [
"/Library/Fonts/",
"/System/Library/Fonts/"
]
FONT_DIRECTORIES = {
WINDOWS: WIN_FONT_DIRS,
LINUX: LINUX_FONT_DIRS,
MACOS: MACOS_FONT_DIRS,
}
DEFAULT_FONTS = [
"ArialUni.ttf", # for Windows
"Arial Unicode.ttf", # for macOS
"Arial.ttf", # for the case "Arial Unicode" does not exist
"DejaVuSansCondensed.ttf", # widths of glyphs is similar to Arial
"DejaVuSans.ttf",
"LiberationSans-Regular.ttf",
"OpenSans-Regular.ttf",
]
CURRENT_CACHE_VERSION = 2
class CacheEntry(NamedTuple):
file_path: Path # full file path e.g. "C:\Windows\Fonts\DejaVuSans.ttf"
font_face: FontFace
GENERIC_FONT_FAMILY = {
"serif": "DejaVu Serif",
"sans-serif": "DejaVu Sans",
"monospace": "DejaVu Sans Mono",
}
class FontCache:
def __init__(self) -> None:
# cache key is the lowercase ttf font name without parent directories
# e.g. "arial.ttf" for "C:\Windows\Fonts\Arial.ttf"
self._cache: dict[str, CacheEntry] = dict()
def __contains__(self, font_name: str) -> bool:
return self.key(font_name) in self._cache
def __getitem__(self, item: str) -> CacheEntry:
return self._cache[self.key(item)]
def __setitem__(self, item: str, entry: CacheEntry) -> None:
self._cache[self.key(item)] = entry
def __len__(self):
return len(self._cache)
def clear(self) -> None:
self._cache.clear()
@staticmethod
def key(font_name: str) -> str:
return str(font_name).lower()
def add_entry(self, font_path: Path, font_face: FontFace) -> None:
self._cache[self.key(font_path.name)] = CacheEntry(font_path, font_face)
def get(self, font_name: str, fallback: str) -> CacheEntry:
try:
return self._cache[self.key(font_name)]
except KeyError:
entry = self._cache.get(self.key(fallback))
if entry is not None:
return entry
else: # no fallback font available
raise FontNotFoundError("no fonts available, not even fallback fonts")
def find_best_match(self, font_face: FontFace) -> Optional[FontFace]:
entry = self._cache.get(self.key(font_face.filename), None)
if entry:
return entry.font_face
return self.find_best_match_ex(
family=font_face.family,
style=font_face.style,
weight=font_face.weight,
width=font_face.width,
italic=font_face.is_italic,
)
def find_best_match_ex(
self,
family: str = "sans-serif",
style: str = "Regular",
weight: int = 400,
width: int = 5,
italic: Optional[bool] = False,
) -> Optional[FontFace]:
# italic == None ... ignore italic flag
family = GENERIC_FONT_FAMILY.get(family, family)
entries = filter_family(family, self._cache.values())
if len(entries) == 0:
return None
elif len(entries) == 1:
return entries[0].font_face
entries_ = filter_style(style, entries)
if len(entries_) == 1:
return entries_[0].font_face
elif len(entries_):
entries = entries_
# best match by weight, italic, width
# Note: the width property is used to prioritize shapefile types:
# 1st .shx; 2nd: .shp; 3rd: .lff
result = sorted(
entries,
key=lambda e: (
abs(e.font_face.weight - weight),
e.font_face.is_italic is not italic,
abs(e.font_face.width - width),
),
)
return result[0].font_face
def loads(self, s: str) -> None:
cache: dict[str, CacheEntry] = dict()
try:
content = json.loads(s)
except json.JSONDecodeError:
raise IOError("invalid JSON file format")
try:
version = content["version"]
content = content["font-faces"]
except KeyError:
raise IOError("invalid cache file format")
if version == CURRENT_CACHE_VERSION:
for entry in content:
try:
file_path, family, style, weight, width = entry
except ValueError:
raise IOError("invalid cache file format")
path = Path(file_path) # full path, e.g. "C:\Windows\Fonts\Arial.ttf"
font_face = FontFace(
filename=path.name, # file name without parent dirs, e.g. "Arial.ttf"
family=family, # Arial
style=style, # Regular
weight=weight, # 400 (Normal)
width=width, # 5 (Normal)
)
cache[self.key(path.name)] = CacheEntry(path, font_face)
else:
raise IOError("invalid cache file version")
self._cache = cache
def dumps(self) -> str:
faces = [
(
str(entry.file_path),
entry.font_face.family,
entry.font_face.style,
entry.font_face.weight,
entry.font_face.width,
)
for entry in self._cache.values()
]
data = {"version": CURRENT_CACHE_VERSION, "font-faces": faces}
return json.dumps(data, indent=2)
def print_available_fonts(self, verbose=False) -> None:
for entry in self._cache.values():
print(f"{entry.file_path}")
if not verbose:
continue
font_type = entry.file_path.suffix.lower()
ff = entry.font_face
if font_type in (".shx", ".shp"):
print(f" Shape font file: '{ff.filename}'")
elif font_type == ".lff":
print(f" LibreCAD font file: '{ff.filename}'")
else:
print(f" TrueType/OpenType font file: '{ff.filename}'")
print(f" family: {ff.family}")
print(f" style: {ff.style}")
print(f" weight: {ff.weight}, {ff.weight_str}")
print(f" width: {ff.width}, {ff.width_str}")
print(f"\nfound {len(self._cache)} fonts")
def filter_family(family: str, entries: Iterable[CacheEntry]) -> list[CacheEntry]:
key = str(family).lower()
return [e for e in entries if e.font_face.family.lower().startswith(key)]
def filter_style(style: str, entries: Iterable[CacheEntry]) -> list[CacheEntry]:
key = str(style).lower()
return [e for e in entries if key in e.font_face.style.lower()]
# TrueType and OpenType fonts:
# Note: CAD applications like AutoCAD/BricsCAD do not support OpenType fonts!
SUPPORTED_TTF_TYPES = {".ttf", ".ttc", ".otf"}
# Basic stroke-fonts included in CAD applications:
SUPPORTED_SHAPE_FILES = {".shx", ".shp", ".lff"}
NO_FONT_FACE = FontFace()
FALLBACK_SHAPE_FILES = ["txt.shx", "txt.shp", "iso.shx", "iso.shp"]
FALLBACK_LFF = ["standard.lff", "iso.lff", "simplex.lff"]
class FontNotFoundError(Exception):
pass
class UnsupportedFont(Exception):
pass
class FontManager:
def __init__(self) -> None:
self.platform = platform.system()
self._font_cache: FontCache = FontCache()
self._match_cache: dict[int, Optional[FontFace]] = dict()
self._loaded_ttf_fonts: dict[str, TTFont] = dict()
self._loaded_shape_file_glyph_caches: dict[str, shapefile.GlyphCache] = dict()
self._loaded_lff_glyph_caches: dict[str, lff.GlyphCache] = dict()
self._fallback_font_name = ""
self._fallback_shape_file = ""
self._fallback_lff = ""
def print_available_fonts(self, verbose=False) -> None:
self._font_cache.print_available_fonts(verbose=verbose)
def has_font(self, font_name: str) -> bool:
return font_name in self._font_cache
def clear(self) -> None:
self._font_cache = FontCache()
self._loaded_ttf_fonts.clear()
self._fallback_font_name = ""
def fallback_font_name(self) -> str:
fallback_name = self._fallback_font_name
if fallback_name:
return fallback_name
fallback_name = DEFAULT_FONTS[0]
for name in DEFAULT_FONTS:
try:
cache_entry = self._font_cache.get(name, fallback_name)
fallback_name = cache_entry.file_path.name
break
except FontNotFoundError:
pass
self._fallback_font_name = fallback_name
return fallback_name
def fallback_shapefile(self) -> str:
fallback_shape_file = self._fallback_shape_file
if fallback_shape_file:
return fallback_shape_file
for name in FALLBACK_SHAPE_FILES:
if name in self._font_cache:
self._fallback_shape_file = name
return name
return ""
def fallback_lff(self) -> str:
fallback_lff = self._fallback_lff
if fallback_lff:
return fallback_lff
for name in FALLBACK_SHAPE_FILES:
if name in self._font_cache:
self._fallback_shape_file = name
return name
return ""
def get_ttf_font(self, font_name: str, font_number: int = 0) -> TTFont:
try:
return self._loaded_ttf_fonts[font_name]
except KeyError:
pass
fallback_name = self.fallback_font_name()
try:
font = TTFont(
self._font_cache.get(font_name, fallback_name).file_path,
fontNumber=font_number,
)
except IOError as e:
raise FontNotFoundError(str(e))
except TTLibError as e:
raise FontNotFoundError(str(e))
self._loaded_ttf_fonts[font_name] = font
return font
def ttf_font_from_font_face(self, font_face: FontFace) -> TTFont:
return self.get_ttf_font(Path(font_face.filename).name)
def get_shapefile_glyph_cache(self, font_name: str) -> shapefile.GlyphCache:
try:
return self._loaded_shape_file_glyph_caches[font_name]
except KeyError:
pass
fallback_name = self.fallback_shapefile()
try:
file_path = self._font_cache.get(font_name, fallback_name).file_path
except KeyError:
raise FontNotFoundError(f"shape font '{font_name}' not found")
try:
file = shapefile.readfile(str(file_path))
except IOError:
raise FontNotFoundError(f"shape file '{file_path}' not found")
except shapefile.UnsupportedShapeFile as e:
raise UnsupportedFont(f"unsupported font'{file_path}': {str(e)}")
glyph_cache = shapefile.GlyphCache(file)
self._loaded_shape_file_glyph_caches[font_name] = glyph_cache
return glyph_cache
def get_lff_glyph_cache(self, font_name: str) -> lff.GlyphCache:
try:
return self._loaded_lff_glyph_caches[font_name]
except KeyError:
pass
fallback_name = self.fallback_lff()
try:
file_path = self._font_cache.get(font_name, fallback_name).file_path
except KeyError:
raise FontNotFoundError(f"LibreCAD font '{font_name}' not found")
try:
s = pathlib.Path(file_path).read_text(encoding="utf8")
font = lff.loads(s)
except IOError:
raise FontNotFoundError(f"LibreCAD font file '{file_path}' not found")
glyph_cache = lff.GlyphCache(font)
self._loaded_lff_glyph_caches[font_name] = glyph_cache
return glyph_cache
def get_font_face(self, font_name: str) -> FontFace:
cache_entry = self._font_cache.get(font_name, self.fallback_font_name())
return cache_entry.font_face
def find_best_match(
self,
family: str = "sans-serif",
style: str = "Regular",
weight=400,
width=5,
italic: Optional[bool] = False,
) -> Optional[FontFace]:
key = hash((family, style, weight, width, italic))
try:
return self._match_cache[key]
except KeyError:
pass
font_face = self._font_cache.find_best_match_ex(
family, style, weight, width, italic
)
self._match_cache[key] = font_face
return font_face
def find_font_name(self, font_face: FontFace) -> str:
"""Returns the font file name of the font without parent directories
e.g. "LiberationSans-Regular.ttf".
"""
font_face = self._font_cache.find_best_match(font_face) # type: ignore
if font_face is None:
font_face = self.get_font_face(self.fallback_font_name())
return font_face.filename
else:
return font_face.filename
def build(self, folders: Optional[Sequence[str]] = None, support_dirs=True) -> None:
"""Adds all supported font types located in the given `folders` to the font
manager. If no directories are specified, the known font folders for Windows,
Linux and macOS are searched by default, except `support_dirs` is ``False``.
Searches recursively all subdirectories.
The folders stored in the config SUPPORT_DIRS option are scanned recursively for
.shx, .shp and .lff fonts, the basic stroke fonts included in CAD applications.
"""
from ezdxf._options import options
if folders:
dirs = list(folders)
else:
dirs = FONT_DIRECTORIES.get(self.platform, LINUX_FONT_DIRS)
if support_dirs:
dirs = dirs + list(options.support_dirs)
self.scan_all(dirs)
def add_synonyms(self, synonyms: dict[str, str], reverse=True) -> None:
font_cache = self._font_cache
for font_name, synonym in synonyms.items():
if not font_name in font_cache:
continue
if synonym in font_cache:
continue
cache_entry = font_cache[font_name]
font_cache[synonym] = cache_entry
if reverse:
self.add_synonyms({v: k for k, v in synonyms.items()}, reverse=False)
def scan_all(self, folders: Iterable[str]) -> None:
for folder in folders:
folder = folder.strip("'\"") # strip quotes
if not folder:
continue
try:
self.scan_folder(Path(folder).expanduser())
except PermissionError as e:
print(str(e))
continue
def scan_folder(self, folder: Path):
if not folder.exists():
return
for file in folder.iterdir():
if file.is_dir():
self.scan_folder(file)
continue
ext = file.suffix.lower()
if ext in SUPPORTED_TTF_TYPES:
try:
font_face = get_ttf_font_face(file)
except Exception as e:
logger.warning(f"cannot open font '{file}': {str(e)}")
else:
self._font_cache.add_entry(file, font_face)
elif ext in SUPPORTED_SHAPE_FILES:
font_face = get_shape_file_font_face(file)
self._font_cache.add_entry(file, font_face)
def dumps(self) -> str:
return self._font_cache.dumps()
def loads(self, s: str) -> None:
self._font_cache.loads(s)
def normalize_style(style: str) -> str:
if style in {"Book"}:
style = "Regular"
return style
def get_ttf_font_face(font_path: Path) -> FontFace:
"""The caller should catch ALL exception (see scan_folder function above) - strange
things can happen when reading TTF files.
"""
ttf = TTFont(font_path, fontNumber=0)
names = ttf["name"].names
family = ""
style = ""
for record in names:
if record.nameID == 1:
family = record.string.decode(record.getEncoding())
elif record.nameID == 2:
style = record.string.decode(record.getEncoding())
if family and style:
break
try:
os2_table = ttf["OS/2"]
except Exception: # e.g. ComickBook_Simple.ttf has an invalid "OS/2" table
logger.info(f"cannot load OS/2 table of font '{font_path.name}'")
weight = 400
width = 5
else:
weight = os2_table.usWeightClass
width = os2_table.usWidthClass
return FontFace(
filename=font_path.name,
family=family,
style=normalize_style(style),
width=width,
weight=weight,
)
def get_shape_file_font_face(font_path: Path) -> FontFace:
ext = font_path.suffix.lower()
# Note: the width property is not defined in shapefiles and is used to
# prioritize the shapefile types for find_best_match():
# 1st .shx; 2nd: .shp; 3rd: .lff
width = 5
if ext == ".shp":
width = 6
if ext == ".lff":
width = 7
return FontFace(
filename=font_path.name, # "txt.shx", "simplex.shx", ...
family=font_path.stem.lower(), # "txt", "simplex", ...
style=font_path.suffix.lower(), # ".shx", ".shp" or ".lff"
width=width,
weight=400,
)

View File

@@ -0,0 +1,54 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import NamedTuple
# A Visual Guide to the Anatomy of Typography: https://visme.co/blog/type-anatomy/
# Anatomy of a Character: https://www.fonts.com/content/learning/fontology/level-1/type-anatomy/anatomy
class FontMeasurements(NamedTuple):
baseline: float
cap_height: float
x_height: float
descender_height: float
def scale(self, factor: float = 1.0) -> FontMeasurements:
return FontMeasurements(
self.baseline * factor,
self.cap_height * factor,
self.x_height * factor,
self.descender_height * factor,
)
def shift(self, distance: float = 0.0) -> FontMeasurements:
return FontMeasurements(
self.baseline + distance,
self.cap_height,
self.x_height,
self.descender_height,
)
def scale_from_baseline(self, desired_cap_height: float) -> FontMeasurements:
factor = desired_cap_height / self.cap_height
return FontMeasurements(
self.baseline,
desired_cap_height,
self.x_height * factor,
self.descender_height * factor,
)
@property
def cap_top(self) -> float:
return self.baseline + self.cap_height
@property
def x_top(self) -> float:
return self.baseline + self.x_height
@property
def bottom(self) -> float:
return self.baseline - self.descender_height
@property
def total_height(self) -> float:
return self.cap_height + self.descender_height

View File

@@ -0,0 +1,10 @@
# Copyright (c) 2024, Manfred Moitzi
# License: MIT License
FONT_SYNONYMS = {
"ariblk.ttf": "Arial Black.ttf",
"comic.ttf": "Comic Sans MS.ttf",
"arialuni.ttf": "Arial Unicode.ttf",
"times.ttf": "Times New Roman.ttf",
"trebuc.ttf": "Trebuchet MS.ttf",
}

View File

@@ -0,0 +1,745 @@
# Copyright (c) 2021-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Optional, TYPE_CHECKING, cast
import abc
import enum
import logging
import os
import pathlib
from ezdxf import options
from .font_face import FontFace
from .font_manager import (
FontManager,
SUPPORTED_TTF_TYPES,
FontNotFoundError,
UnsupportedFont,
)
from .font_synonyms import FONT_SYNONYMS
from .font_measurements import FontMeasurements
from .glyphs import GlyphPath, Glyphs
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import DXFEntity, Textstyle
logger = logging.getLogger("ezdxf")
FONT_MANAGER_CACHE_FILE = "font_manager_cache.json"
# SUT = System Under Test, see build_sut_font_manager_cache() function
SUT_FONT_MANAGER_CACHE = False
CACHE_DIRECTORY = ".cache"
font_manager = FontManager()
SHX_FONTS = {
# See examples in: CADKitSamples/Shapefont.dxf
# Shape file structure is not documented, therefore, replace these fonts by
# true type fonts.
# `None` is for: use the default font.
#
# All these replacement TTF fonts have a copyright remark:
# "(c) Copyright 1996 by Autodesk Inc., All rights reserved"
# and therefore can not be included in ezdxf or the associated repository!
# You got them if you install any Autodesk product, like the free available
# DWG/DXF viewer "TrueView" : https://www.autodesk.com/viewers
"AMGDT": "amgdt___.ttf", # Tolerance symbols
"AMGDT.SHX": "amgdt___.ttf",
"COMPLEX": "complex_.ttf",
"COMPLEX.SHX": "complex_.ttf",
"ISOCP": "isocp.ttf",
"ISOCP.SHX": "isocp.ttf",
"ITALIC": "italicc_.ttf",
"ITALIC.SHX": "italicc_.ttf",
"GOTHICG": "gothicg_.ttf",
"GOTHICG.SHX": "gothicg_.ttf",
"GREEKC": "greekc.ttf",
"GREEKC.SHX": "greekc.ttf",
"ROMANS": "romans__.ttf",
"ROMANS.SHX": "romans__.ttf",
"SCRIPTS": "scripts_.ttf",
"SCRIPTS.SHX": "scripts_.ttf",
"SCRIPTC": "scriptc_.ttf",
"SCRIPTC.SHX": "scriptc_.ttf",
"SIMPLEX": "simplex_.ttf",
"SIMPLEX.SHX": "simplex_.ttf",
"SYMATH": "symath__.ttf",
"SYMATH.SHX": "symath__.ttf",
"SYMAP": "symap___.ttf",
"SYMAP.SHX": "symap___.ttf",
"SYMETEO": "symeteo_.ttf",
"SYMETEO.SHX": "symeteo_.ttf",
"TXT": "txt_____.ttf", # Default AutoCAD font
"TXT.SHX": "txt_____.ttf",
}
LFF_FONTS = {
"TXT": "standard.lff",
"TXT.SHX": "standard.lff",
}
TTF_TO_SHX = {v: k for k, v in SHX_FONTS.items() if k.endswith("SHX")}
DESCENDER_FACTOR = 0.333 # from TXT SHX font - just guessing
X_HEIGHT_FACTOR = 0.666 # from TXT SHX font - just guessing
MONOSPACE = "*monospace" # last resort fallback font only for measurement
def map_shx_to_ttf(font_name: str) -> str:
"""Map .shx font names to .ttf file names. e.g. "TXT" -> "txt_____.ttf" """
# Map SHX fonts to True Type Fonts:
font_upper = font_name.upper()
if font_upper in SHX_FONTS:
font_name = SHX_FONTS[font_upper]
return font_name
def map_shx_to_lff(font_name: str) -> str:
"""Map .shx font names to .lff file names. e.g. "TXT" -> "standard.lff" """
font_upper = font_name.upper()
name = LFF_FONTS.get(font_upper, "")
if font_manager.has_font(name):
return name
if not font_upper.endswith(".SHX"):
lff_name = font_name + ".lff"
else:
lff_name = font_name[:-4] + ".lff"
if font_manager.has_font(lff_name):
return lff_name
return font_name
def is_shx_font_name(font_name: str) -> bool:
name = font_name.lower()
if name.endswith(".shx"):
return True
if "." not in name:
return True
return False
def map_ttf_to_shx(ttf: str) -> Optional[str]:
"""Maps .ttf filenames to .shx font names. e.g. "txt_____.ttf" -> "TXT" """
return TTF_TO_SHX.get(ttf.lower())
def build_system_font_cache() -> None:
"""Builds or rebuilds the font manager cache. The font manager cache has a fixed
location in the cache directory of the users home directory "~/.cache/ezdxf" or the
directory specified by the environment variable "XDG_CACHE_HOME".
"""
build_font_manager_cache(_get_font_manager_path())
def find_font_face(font_name: str) -> FontFace:
"""Returns the :class:`FontFace` definition for the given font filename
e.g. "LiberationSans-Regular.ttf".
"""
return font_manager.get_font_face(font_name)
def get_font_face(font_name: str, map_shx=True) -> FontFace:
"""Returns the :class:`FontFace` definition for the given font filename
e.g. "LiberationSans-Regular.ttf".
This function translates a DXF font definition by the TTF font file name into a
:class:`FontFace` object. Returns the :class:`FontFace` of the default font when a
font is not available on the current system.
Args:
font_name: raw font file name as stored in the
:class:`~ezdxf.entities.Textstyle` entity
map_shx: maps SHX font names to TTF replacement fonts,
e.g. "TXT" -> "txt_____.ttf"
"""
if not isinstance(font_name, str):
raise TypeError("font_name has invalid type")
if map_shx:
font_name = map_shx_to_ttf(font_name)
return find_font_face(font_name)
def resolve_shx_font_name(font_name: str, order: str) -> str:
"""Resolves a .shx font name, the argument `order` defines the resolve order:
- "t" = map .shx fonts to TrueType fonts (.ttf, .ttc, .otf)
- "s" = use shapefile fonts (.shx, .shp)
- "l" = map .shx fonts to LibreCAD fonts (.lff)
"""
if len(order) == 0:
return font_name
order = order.lower()
for type_str in order:
if type_str == "t":
name = map_shx_to_ttf(font_name)
if font_manager.has_font(name):
return name
elif type_str == "s":
if not font_name.lower().endswith(".shx"):
font_name += ".shx"
if font_manager.has_font(font_name):
return font_name
elif type_str == "l":
name = map_shx_to_lff(font_name)
if font_manager.has_font(name):
return name
return font_name
def resolve_font_face(font_name: str, order="tsl") -> FontFace:
"""Returns the :class:`FontFace` definition for the given font filename
e.g. "LiberationSans-Regular.ttf".
This function translates a DXF font definition by the TTF font file name into a
:class:`FontFace` object. Returns the :class:`FontFace` of the default font when a
font is not available on the current system.
The order argument defines the resolve order for .shx fonts:
- "t" = map .shx fonts to TrueType fonts (.ttf, .ttc, .otf)
- "s" = use shapefile fonts (.shx, .shp)
- "l" = map .shx fonts to LibreCAD fonts (.lff)
Args:
font_name: raw font file name as stored in the
:class:`~ezdxf.entities.Textstyle` entity
order: resolving order
"""
if not isinstance(font_name, str):
raise TypeError("font_name has invalid type")
if is_shx_font_name(font_name):
font_name = resolve_shx_font_name(font_name, order)
return find_font_face(font_name)
def get_font_measurements(font_name: str, map_shx=True) -> FontMeasurements:
"""Get :class:`FontMeasurements` for the given font filename
e.g. "LiberationSans-Regular.ttf".
Args:
font_name: raw font file name as stored in the
:class:`~ezdxf.entities.Textstyle` entity
map_shx: maps SHX font names to TTF replacement fonts,
e.g. "TXT" -> "txt_____.ttf"
"""
if map_shx:
font_name = map_shx_to_ttf(font_name)
elif is_shx_font_name(font_name):
return FontMeasurements(
baseline=0,
cap_height=1,
x_height=X_HEIGHT_FACTOR,
descender_height=DESCENDER_FACTOR,
)
font = TrueTypeFont(font_name, cap_height=1)
return font.measurements
def find_best_match(
*,
family: str = "sans-serif",
style: str = "Regular",
weight: int = 400,
width: int = 5,
italic: Optional[bool] = False,
) -> Optional[FontFace]:
"""Returns a :class:`FontFace` that matches the given properties best. The search
is based the descriptive properties and not on comparing glyph shapes. Returns
``None`` if no font was found.
Args:
family: font family name e.g. "sans-serif", "Liberation Sans"
style: font style e.g. "Regular", "Italic", "Bold"
weight: weight in the range from 1-1000 (usWeightClass)
width: width in the range from 1-9 (usWidthClass)
italic: ``True``, ``False`` or ``None`` to ignore this flag
"""
return font_manager.find_best_match(family, style, weight, width, italic)
def find_font_file_name(font_face: FontFace) -> str:
"""Returns the true type font file name without parent directories e.g. "Arial.ttf"."""
return font_manager.find_font_name(font_face)
def load():
"""Reload all cache files. The cache files are loaded automatically at the import
of `ezdxf`.
"""
_load_font_manager()
# Add font name synonyms, see discussion #1002
# Find macOS fonts on Windows/Linux and vice versa.
font_manager.add_synonyms(FONT_SYNONYMS, reverse=True)
def _get_font_manager_path():
cache_path = options.xdg_path("XDG_CACHE_HOME", CACHE_DIRECTORY)
return cache_path / FONT_MANAGER_CACHE_FILE
def _load_font_manager() -> None:
fm_path = _get_font_manager_path()
if fm_path.exists():
try:
font_manager.loads(fm_path.read_text())
return
except IOError as e:
logger.info(f"Error loading cache file: {str(e)}")
build_font_manager_cache(fm_path)
def build_sut_font_manager_cache(repo_font_path: pathlib.Path) -> None:
"""Load font manger cache for system under test (sut).
Load the fonts included in the repository folder "./fonts" to guarantee the tests
have the same fonts available on all systems.
This function should be called from "conftest.py".
"""
global SUT_FONT_MANAGER_CACHE
SUT_FONT_MANAGER_CACHE = True
font_manager.clear()
cache_file = repo_font_path / "font_manager_cache.json"
if cache_file.exists():
try:
font_manager.loads(cache_file.read_text())
return
except IOError as e:
print(f"Error loading cache file: {str(e)}")
font_manager.build([str(repo_font_path)], support_dirs=False)
s = font_manager.dumps()
try:
cache_file.write_text(s)
except IOError as e:
print(f"Error writing cache file: {str(e)}")
def make_cache_directory(path: pathlib.Path) -> None:
if not path.exists():
try:
path.mkdir(parents=True)
except IOError:
pass
def build_font_manager_cache(path: pathlib.Path) -> None:
font_manager.clear()
font_manager.build()
s = font_manager.dumps()
cache_dir = path.parent
make_cache_directory(cache_dir)
if not cache_dir.exists():
logger.warning(
f"Cannot create cache home directory: '{str(cache_dir)}', cache files will "
f"not be saved.\nSee also issue https://github.com/mozman/ezdxf/issues/923."
)
return
try:
path.write_text(s)
except IOError as e:
logger.warning(f"Error writing cache file: '{str(e)}'")
class FontRenderType(enum.Enum):
# render glyphs as filled paths: TTF, OTF
OUTLINE = enum.auto()
# render glyphs as line strokes: SHX, SHP
STROKE = enum.auto
class AbstractFont:
"""The `ezdxf` font abstraction for text measurement and text path rendering."""
font_render_type = FontRenderType.STROKE
name: str = "undefined"
def __init__(self, measurements: FontMeasurements):
self.measurements = measurements
@abc.abstractmethod
def text_width(self, text: str) -> float:
"""Returns the text width in drawing units for the given `text` string."""
pass
@abc.abstractmethod
def text_width_ex(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> float:
"""Returns the text width in drawing units, bypasses the stored `cap_height` and
`width_factor`.
"""
pass
@abc.abstractmethod
def space_width(self) -> float:
"""Returns the width of a "space" character a.k.a. word spacing."""
pass
@abc.abstractmethod
def text_path(self, text: str) -> GlyphPath:
"""Returns the 2D text path for the given text."""
...
@abc.abstractmethod
def text_path_ex(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> GlyphPath:
"""Returns the 2D text path for the given text, bypasses the stored `cap_height`
and `width_factor`."""
...
@abc.abstractmethod
def text_glyph_paths(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> list[GlyphPath]:
"""Returns a list of 2D glyph paths for the given text, bypasses the stored
`cap_height` and `width_factor`."""
...
class MonospaceFont(AbstractFont):
"""Represents a monospaced font where each letter has the same cap- and descender
height and the same width. The given cap height and width factor are the default
values for measurements and rendering. The extended methods can override these
default values.
This font exists only for generic text measurement in tests and does not render any
glyphs!
"""
font_render_type = FontRenderType.STROKE
name = MONOSPACE
def __init__(
self,
cap_height: float,
width_factor: float = 1.0,
baseline: float = 0,
descender_factor: float = DESCENDER_FACTOR,
x_height_factor: float = X_HEIGHT_FACTOR,
):
super().__init__(
FontMeasurements(
baseline=baseline,
cap_height=cap_height,
x_height=cap_height * x_height_factor,
descender_height=cap_height * descender_factor,
)
)
self._width_factor: float = abs(width_factor)
self._space_width = self.measurements.cap_height * self._width_factor
def text_width(self, text: str) -> float:
"""Returns the text width in drawing units for the given `text`."""
return self.text_width_ex(
text, self.measurements.cap_height, self._width_factor
)
def text_width_ex(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> float:
"""Returns the text width in drawing units, bypasses the stored `cap_height` and
`width_factor`.
"""
return len(text) * cap_height * width_factor
def text_path(self, text: str) -> GlyphPath:
"""Returns the rectangle text width x cap height as :class:`NumpyPath2d` instance."""
return self.text_path_ex(text, self.measurements.cap_height, self._width_factor)
def text_path_ex(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> GlyphPath:
"""Returns the rectangle text width x cap height as :class:`NumpyPath2d`
instance, bypasses the stored `cap_height` and `width_factor`.
"""
from ezdxf.path import Path
text_width = self.text_width_ex(text, cap_height, width_factor)
p = Path((0, 0))
p.line_to((text_width, 0))
p.line_to((text_width, cap_height))
p.line_to((0, cap_height))
p.close()
return GlyphPath(p)
def text_glyph_paths(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> list[GlyphPath]:
"""Returns the same rectangle as the method :meth:`text_path_ex` in a list."""
return [self.text_path_ex(text, cap_height, width_factor)]
def space_width(self) -> float:
"""Returns the width of a "space" char."""
return self._space_width
class _CachedFont(AbstractFont, abc.ABC):
"""Abstract font with caching support."""
_glyph_caches: dict[str, Glyphs] = dict()
def __init__(self, font_name: str, cap_height: float, width_factor: float = 1.0):
self.name = font_name
cache = self.create_cache(font_name)
self.glyph_cache = cache
self.cap_height = float(cap_height)
self.width_factor = float(width_factor)
scale_factor: float = cache.get_scaling_factor(self.cap_height)
super().__init__(cache.font_measurements.scale(scale_factor))
self._space_width: float = (
self.glyph_cache.space_width * scale_factor * width_factor
)
@abc.abstractmethod
def create_cache(self, font_name: str) -> Glyphs:
...
def text_width(self, text: str) -> float:
"""Returns the text width in drawing units for the given `text` string."""
return self.text_width_ex(text, self.cap_height, self.width_factor)
def text_width_ex(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> float:
"""Returns the text width in drawing units, bypasses the stored `cap_height` and
`width_factor`.
"""
if not text.strip():
return 0
return self.glyph_cache.get_text_length(text, cap_height, width_factor)
def text_path(self, text: str) -> GlyphPath:
"""Returns the 2D text path for the given text."""
return self.text_path_ex(text, self.cap_height, self.width_factor)
def text_path_ex(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> GlyphPath:
"""Returns the 2D text path for the given text, bypasses the stored `cap_height`
and `width_factor`."""
return self.glyph_cache.get_text_path(text, cap_height, width_factor)
def text_glyph_paths(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> list[GlyphPath]:
"""Returns a list of 2D glyph paths for the given text, bypasses the stored
`cap_height` and `width_factor`."""
return self.glyph_cache.get_text_glyph_paths(text, cap_height, width_factor)
def space_width(self) -> float:
"""Returns the width of a "space" char."""
return self._space_width
class TrueTypeFont(_CachedFont):
"""Represents a TrueType font. Font measurement and glyph rendering is done by the
`fontTools` package. The given cap height and width factor are the default values
for measurements and glyph rendering. The extended methods can override these
default values.
"""
font_render_type = FontRenderType.OUTLINE
def create_cache(self, ttf: str) -> Glyphs:
from .ttfonts import TTFontRenderer
key = pathlib.Path(ttf).name.lower()
try:
return self._glyph_caches[key]
except KeyError:
pass
try:
tt_font = font_manager.get_ttf_font(ttf)
try: # see issue #990
cache = TTFontRenderer(tt_font)
except Exception:
raise UnsupportedFont
except UnsupportedFont:
fallback_font_name = font_manager.fallback_font_name()
logger.info(f"replacing unsupported font '{ttf}' by '{fallback_font_name}'")
cache = TTFontRenderer(font_manager.get_ttf_font(fallback_font_name))
self._glyph_caches[key] = cache
return cache
class _UnmanagedTrueTypeFont(_CachedFont):
font_render_type = FontRenderType.OUTLINE
def create_cache(self, ttf: str) -> Glyphs:
from .ttfonts import TTFontRenderer
from fontTools.ttLib import TTFont
key = ttf.lower()
try:
return self._glyph_caches[key]
except KeyError:
pass
cache = TTFontRenderer(TTFont(ttf, fontNumber=0))
self._glyph_caches[key] = cache
return cache
def sideload_ttf(font_path: str | os.PathLike, cap_height) -> AbstractFont:
"""This function bypasses the FontManager and loads the TrueType font straight from
the file system, requires the absolute font file path e.g. "C:/Windows/Fonts/Arial.ttf".
.. warning::
Expert feature, use with care: no fallback font and no error handling.
"""
return _UnmanagedTrueTypeFont(str(font_path), cap_height)
class ShapeFileFont(_CachedFont):
"""Represents a shapefile font (.shx, .shp). Font measurement and glyph rendering is
done by the ezdxf.fonts.shapefile module. The given cap height and width factor are
the default values for measurements and glyph rendering. The extended methods can
override these default values.
"""
font_render_type = FontRenderType.STROKE
def create_cache(self, font_name: str) -> Glyphs:
key = font_name.lower()
try:
return self._glyph_caches[key]
except KeyError:
pass
glyph_cache = font_manager.get_shapefile_glyph_cache(font_name)
self._glyph_caches[key] = glyph_cache
return glyph_cache
class LibreCadFont(_CachedFont):
"""Represents a LibreCAD font (.shx, .shp). Font measurement and glyph rendering is
done by the ezdxf.fonts.lff module. The given cap height and width factor are the
default values for measurements and glyph rendering. The extended methods can
override these default values.
"""
font_render_type = FontRenderType.STROKE
def create_cache(self, font_name: str) -> Glyphs:
key = font_name.lower()
try:
return self._glyph_caches[key]
except KeyError:
pass
glyph_cache = font_manager.get_lff_glyph_cache(font_name)
self._glyph_caches[key] = glyph_cache
return glyph_cache
def make_font(
font_name: str, cap_height: float, width_factor: float = 1.0
) -> AbstractFont:
r"""Returns a font abstraction based on class :class:`AbstractFont`.
Supported font types:
- .ttf, .ttc and .otf - TrueType fonts
- .shx, .shp - Autodesk® shapefile fonts
- .lff - LibreCAD font format
The special name "\*monospace" returns the fallback font :class:`MonospaceFont` for
testing and basic measurements.
.. note:: The font definition files are not included in `ezdxf`.
Args:
font_name: font file name as stored in the :class:`~ezdxf.entities.Textstyle`
entity e.g. "OpenSans-Regular.ttf"
cap_height: desired cap height in drawing units.
width_factor: horizontal text stretch factor
"""
if font_name == MONOSPACE:
return MonospaceFont(cap_height, width_factor)
ext = pathlib.Path(font_name).suffix.lower()
last_resort = MonospaceFont(cap_height, width_factor)
if ext in SUPPORTED_TTF_TYPES:
try:
return TrueTypeFont(font_name, cap_height, width_factor)
except FontNotFoundError as e:
logger.warning(f"no default font found: {str(e)}")
return last_resort
elif ext == ".shx" or ext == ".shp":
try:
return ShapeFileFont(font_name, cap_height, width_factor)
except FontNotFoundError:
pass
except UnsupportedFont:
# change name - the font exists but is not supported
font_name = font_manager.fallback_font_name()
elif ext == ".lff":
try:
return LibreCadFont(font_name, cap_height, width_factor)
except FontNotFoundError:
pass
elif ext == "": # e.g. "TXT"
font_face = font_manager.find_best_match(
family=font_name, style=".shx", italic=None
)
if font_face is not None:
return make_font(font_face.filename, cap_height, width_factor)
else:
logger.warning(f"unsupported font-name suffix: {font_name}")
font_name = font_manager.fallback_font_name()
# return default TrueType font
try:
return TrueTypeFont(font_name, cap_height, width_factor)
except FontNotFoundError as e:
logger.warning(f"no default font found: {str(e)}")
return last_resort
def get_entity_font_face(entity: DXFEntity, doc: Optional[Drawing] = None) -> FontFace:
"""Returns the :class:`FontFace` defined by the associated text style.
Returns the default font face if the `entity` does not have or support
the DXF attribute "style". Supports the extended font information stored in
:class:`~ezdxf.entities.Textstyle` table entries.
Pass a DXF document as argument `doc` to resolve text styles for virtual
entities which are not assigned to a DXF document. The argument `doc`
always overrides the DXF document to which the `entity` is assigned to.
"""
if entity.doc and doc is None:
doc = entity.doc
if doc is None:
return FontFace()
style_name = ""
# This works also for entities which do not support "style",
# where style_name = entity.dxf.get("style") would fail.
if entity.dxf.is_supported("style"):
style_name = entity.dxf.style
font_face = FontFace()
if style_name:
style = cast("Textstyle", doc.styles.get(style_name))
family, italic, bold = style.get_extended_font_data()
if family:
text_style = "Italic" if italic else "Regular"
text_weight = 700 if bold else 400
font_face = FontFace(family=family, style=text_style, weight=text_weight)
else:
ttf = style.dxf.font
if ttf:
font_face = get_font_face(ttf)
return font_face
load()

View File

@@ -0,0 +1,39 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing_extensions import TypeAlias
import abc
from ezdxf.npshapes import NumpyPath2d
from .font_measurements import FontMeasurements
GlyphPath: TypeAlias = NumpyPath2d
class Glyphs(abc.ABC):
font_measurements: FontMeasurements # of the raw font
space_width: float # word spacing of the raw font
@abc.abstractmethod
def get_scaling_factor(self, cap_height: float) -> float:
...
@abc.abstractmethod
def get_text_length(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> float:
...
def get_text_path(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> GlyphPath:
glyph_paths = self.get_text_glyph_paths(text, cap_height, width_factor)
if len(glyph_paths) == 0:
return GlyphPath(None)
return NumpyPath2d.concatenate(glyph_paths)
@abc.abstractmethod
def get_text_glyph_paths(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> list[GlyphPath]:
...

View File

@@ -0,0 +1,324 @@
# 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,206 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Any, no_type_check
from fontTools.pens.basePen import BasePen
from fontTools.ttLib import TTFont
from ezdxf.math import Matrix44, UVec, BoundingBox2d
from ezdxf.path import Path
from .font_manager import FontManager, UnsupportedFont
from .font_measurements import FontMeasurements
from .glyphs import GlyphPath, Glyphs
UNICODE_WHITE_SQUARE = 9633 # U+25A1
UNICODE_REPLACEMENT_CHAR = 65533 # U+FFFD
font_manager = FontManager()
class PathPen(BasePen):
def __init__(self, glyph_set) -> None:
super().__init__(glyph_set)
self._path = Path()
@property
def path(self) -> GlyphPath:
return GlyphPath(self._path)
def _moveTo(self, pt: UVec) -> None:
self._path.move_to(pt)
def _lineTo(self, pt: UVec) -> None:
self._path.line_to(pt)
def _curveToOne(self, pt1: UVec, pt2: UVec, pt3: UVec) -> None:
self._path.curve4_to(pt3, pt1, pt2)
def _qCurveToOne(self, pt1: UVec, pt2: UVec) -> None:
self._path.curve3_to(pt2, pt1)
def _closePath(self) -> None:
self._path.close_sub_path()
class NoKerning:
def get(self, c0: str, c1: str) -> float:
return 0.0
class KerningTable(NoKerning):
__slots__ = ("_cmap", "_kern_table")
def __init__(self, font: TTFont, cmap, fmt: int = 0):
self._cmap = cmap
self._kern_table = font["kern"].getkern(fmt)
def get(self, c0: str, c1: str) -> float:
try:
return self._kern_table[(self._cmap[ord(c0)], self._cmap[ord(c1)])]
except (KeyError, TypeError):
return 0.0
def get_fontname(font: TTFont) -> str:
names = font["name"].names
for record in names:
if record.nameID == 1:
return record.string.decode(record.getEncoding())
return "unknown"
class TTFontRenderer(Glyphs):
def __init__(self, font: TTFont, kerning=False):
self._glyph_path_cache: dict[str, GlyphPath] = dict()
self._generic_glyph_cache: dict[str, Any] = dict()
self._glyph_width_cache: dict[str, float] = dict()
self.font = font
self.cmap = self.font.getBestCmap()
if self.cmap is None:
raise UnsupportedFont(f"font '{self.font_name}' has no character map.")
self.glyph_set = self.font.getGlyphSet()
self.kerning = NoKerning()
if kerning:
try:
self.kerning = KerningTable(self.font, self.cmap)
except KeyError: # kerning table does not exist
pass
self.undefined_generic_glyph = self.glyph_set[".notdef"]
self.font_measurements = self._get_font_measurements()
self.space_width = self.detect_space_width()
@property
def font_name(self) -> str:
return get_fontname(self.font)
@no_type_check
def _get_font_measurements(self) -> FontMeasurements:
bbox = BoundingBox2d(self.get_glyph_path("x").control_vertices())
baseline = bbox.extmin.y
x_height = bbox.extmax.y - baseline
bbox = BoundingBox2d(self.get_glyph_path("A").control_vertices())
cap_height = bbox.extmax.y - baseline
bbox = BoundingBox2d(self.get_glyph_path("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_scaling_factor(self, cap_height: float) -> float:
return 1.0 / self.font_measurements.cap_height * cap_height
def get_generic_glyph(self, char: str):
try:
return self._generic_glyph_cache[char]
except KeyError:
pass
try:
generic_glyph = self.glyph_set[self.cmap[ord(char)]]
except KeyError:
generic_glyph = self.undefined_generic_glyph
self._generic_glyph_cache[char] = generic_glyph
return generic_glyph
def get_glyph_path(self, char: str) -> GlyphPath:
"""Returns the raw glyph path, without any scaling applied."""
try:
return self._glyph_path_cache[char].clone()
except KeyError:
pass
pen = PathPen(self.glyph_set)
self.get_generic_glyph(char).draw(pen)
glyph_path = pen.path
self._glyph_path_cache[char] = glyph_path
return glyph_path.clone()
def get_glyph_width(self, char: str) -> float:
"""Returns the raw glyph width, without any scaling applied."""
try:
return self._glyph_width_cache[char]
except KeyError:
pass
width = 0.0
try:
width = self.get_generic_glyph(char).width
except KeyError:
pass
self._glyph_width_cache[char] = width
return width
def get_text_glyph_paths(
self, s: str, cap_height: float = 1.0, width_factor: float = 1.0
) -> list[GlyphPath]:
"""Returns the glyph paths of string `s` as a list, scaled to cap height."""
glyph_paths: list[GlyphPath] = []
x_offset: float = 0
requires_kerning = isinstance(self.kerning, KerningTable)
resize_factor = self.get_scaling_factor(cap_height)
y_factor = resize_factor
x_factor = resize_factor * width_factor
# set scaling factor:
m = Matrix44.scale(x_factor, y_factor, 1.0)
# set vertical offset:
m[3, 1] = -self.font_measurements.baseline * y_factor
prev_char = ""
for char in s:
if requires_kerning:
x_offset += self.kerning.get(prev_char, char) * x_factor
# set horizontal offset:
m[3, 0] = x_offset
glyph_path = self.get_glyph_path(char)
glyph_path.transform_inplace(m)
if len(glyph_path):
glyph_paths.append(glyph_path)
x_offset += self.get_glyph_width(char) * x_factor
prev_char = char
return glyph_paths
def detect_space_width(self) -> float:
"""Returns the space width for the raw (unscaled) font."""
return self.get_glyph_width(" ")
def _get_text_length_with_kerning(self, s: str, cap_height: float = 1.0) -> float:
length = 0.0
c0 = ""
kern = self.kerning.get
width = self.get_glyph_width
for c1 in s:
length += kern(c0, c1) + width(c1)
c0 = c1
return length * self.get_scaling_factor(cap_height)
def get_text_length(
self, s: str, cap_height: float = 1.0, width_factor: float = 1.0
) -> float:
if isinstance(self.kerning, KerningTable):
return self._get_text_length_with_kerning(s, cap_height) * width_factor
width = self.get_glyph_width
return (
sum(width(c) for c in s)
* self.get_scaling_factor(cap_height)
* width_factor
)