527 lines
18 KiB
Python
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,
|
|
)
|