initial
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
78
.venv/lib/python3.12/site-packages/ezdxf/fonts/font_face.py
Normal file
78
.venv/lib/python3.12/site-packages/ezdxf/fonts/font_face.py
Normal 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]
|
||||
526
.venv/lib/python3.12/site-packages/ezdxf/fonts/font_manager.py
Normal file
526
.venv/lib/python3.12/site-packages/ezdxf/fonts/font_manager.py
Normal 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,
|
||||
)
|
||||
@@ -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",
|
||||
}
|
||||
745
.venv/lib/python3.12/site-packages/ezdxf/fonts/fonts.py
Normal file
745
.venv/lib/python3.12/site-packages/ezdxf/fonts/fonts.py
Normal 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()
|
||||
39
.venv/lib/python3.12/site-packages/ezdxf/fonts/glyphs.py
Normal file
39
.venv/lib/python3.12/site-packages/ezdxf/fonts/glyphs.py
Normal 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]:
|
||||
...
|
||||
324
.venv/lib/python3.12/site-packages/ezdxf/fonts/lff.py
Normal file
324
.venv/lib/python3.12/site-packages/ezdxf/fonts/lff.py
Normal 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
|
||||
1045
.venv/lib/python3.12/site-packages/ezdxf/fonts/shapefile.py
Normal file
1045
.venv/lib/python3.12/site-packages/ezdxf/fonts/shapefile.py
Normal file
File diff suppressed because it is too large
Load Diff
206
.venv/lib/python3.12/site-packages/ezdxf/fonts/ttfonts.py
Normal file
206
.venv/lib/python3.12/site-packages/ezdxf/fonts/ttfonts.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user