refactor: excel parse
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
@@ -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]
|
||||
@@ -0,0 +1,530 @@
|
||||
# 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)}")
|
||||
try:
|
||||
glyph_cache = shapefile.GlyphCache(file)
|
||||
except Exception:
|
||||
raise UnsupportedFont(f"can't create glyph-cache for font '{file_path}'.")
|
||||
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")
|
||||
try:
|
||||
glyph_cache = lff.GlyphCache(font)
|
||||
except Exception:
|
||||
raise UnsupportedFont(f"can't create glyph-cache for font '{file_path}'.")
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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]:
|
||||
...
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user