"""Text style presets: the four fonts the standards data refers to by name. ``TEXT_PRESETS`` maps a style name to ``(font file, width factor, oblique degrees)``. ISOCP is AutoCAD's ISO 3098 type B lettering as an SHX shape file; ISOCPEUR is the TrueType twin with accented characters; ARIAL is the general-purpose TrueType face; ROMANS is AutoCAD's simplex roman, the face ASME Y14.2 Gothic lettering is usually drawn with. A DXF stores only the font *name*, so an unknown file is never refused here: ``resolve_font`` says whether it is a bundled preset, the backend adds whether its font path has it, and the caller sees ``font_resolved``. """ from __future__ import annotations import math from pathlib import PurePath from typing import Any from engineering.standards.names import check_name __all__ = [ "MAX_OBLIQUE_DEG", "TEXT_PRESETS", "resolve_font", "validate_textstyle", ] TEXT_PRESETS: dict[str, tuple[str, float, float]] = { "ISOCP": ("isocp.shx", 1.0, 0.0), "ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0), "ARIAL": ("arial.ttf", 1.0, 0.0), "ROMANS": ("romans.shx", 1.0, 0.0), } #: AutoCAD's own limit for the STYLE command's obliquing angle. MAX_OBLIQUE_DEG = 85.0 def _number(key: str, value: Any) -> float: """A finite number, or ``TypeError`` / ``ValueError`` naming ``key``. ``nan`` and ``inf`` pass every ``<`` / ``>`` check, and a backend writing them verbatim leaves ``50\\nnan`` in the STYLE record — the silent wrong number the contract forbids. """ if isinstance(value, bool) or not isinstance(value, (int, float)): raise TypeError(f"{key}: expected a number, got {type(value).__name__}") number = float(value) if not math.isfinite(number): raise ValueError(f"{key}: {value!r} is not a finite number") return number def resolve_font(font: str) -> tuple[str, bool]: """``(font_file, known)``: a preset name or file resolves to the bundled file with ``known=True``; anything else is passed through with ``False``. Accepts the style name (``ISOCP``), the file (``isocp.shx``) or the stem (``isocp``), case-insensitively. Refuses an empty string or control characters — the only inputs a DXF cannot hold. """ if not isinstance(font, str): raise TypeError(f"font: expected a string, got {type(font).__name__}") text = font.strip() if not text: raise ValueError("font: a font file name cannot be empty") if any(ord(ch) < 32 for ch in text): raise ValueError("font: control characters are refused") upper = text.upper() if upper in TEXT_PRESETS: return TEXT_PRESETS[upper][0], True for font_file, _width, _oblique in TEXT_PRESETS.values(): if upper in (font_file.upper(), PurePath(font_file).stem.upper()): return font_file, True return text, False def validate_textstyle( name: str, font: str, height: float = 0.0, width_factor: float = 1.0, oblique_deg: float = 0.0, ) -> dict: """The typed request both engines write, or a ``ValueError``/``TypeError`` naming the field. ``height`` 0 is AutoCAD's "prompt per text" and is legal; the width factor must be positive; the oblique angle is AutoCAD's ±85°; every number must be finite. The name obeys the DXF symbol-name rule (``engineering.standards.names``) — this is the only gate on the ``textstyle_create`` path, and a name that passes must be one the drawing can still be saved with.""" clean = check_name("name", name, what="text style name") font_file, known = resolve_font(font) height_value = _number("height", height) width_value = _number("width_factor", width_factor) oblique_value = _number("oblique_deg", oblique_deg) if height_value < 0.0: raise ValueError(f"height: {height!r} is negative; 0 means 'ask per text'") if width_value <= 0.0: raise ValueError(f"width_factor: {width_factor!r} must be greater than 0") if abs(oblique_value) > MAX_OBLIQUE_DEG: raise ValueError( f"oblique_deg: {oblique_deg!r} is outside AutoCAD's +/-{MAX_OBLIQUE_DEG:g} degrees" ) return { "name": clean, "font_file": font_file, "known": known, "height": height_value, "width_factor": width_value, "oblique_deg": oblique_value, }