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

527 lines
18 KiB
Python

# 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,
)