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

746 lines
25 KiB
Python

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