"""Paper sizes, plot-style catalogue and plot scales — authored data. Sources: ISO 216 (A series), ASME/ANSI Y14.1 (A–E, metric millimetre values as AutoCAD's ``ANSI_*`` media measure, inch values as the media are *named*), the ctb files AutoCAD ships in its Plot Styles folder, and the standard-scale enum — one numbering, shared by the file and by ActiveX: * DXF group code 75 (``standard_scale_type``) is the ObjectARX ``AcDbPlotSettings::StdScaleType`` enum: 0=fit, 1..15 the imperial architectural scales, 16=1:1, 17=1:2, 18=1:4, 19=1:5, 20=1:8, 21=1:10, 22=1:16, 23=1:20, 24=1:30, 25=1:40, 26=1:50, 27=1:100, 28=2:1, 29=4:1, 30=8:1, 31=10:1, 32=100:1, 33=1000:1 (``DXF_STD_SCALES``). Measured live on AutoCAD 2026 (COM ``SaveAs(..., 61)`` after ``StandardScale = n``): 1:10 saves as 21, 1:50 as 26, 2:1 as 28, 10:1 as 31. ezdxf's ``STD_SCALES`` table (and the DXF Reference page it follows) has no 19=1:5 and numbers everything above 18 one lower (20=1:10, 25=1:50, 30=10:1) — reading an AutoCAD-authored 1:50 sheet through it reports 1:100, and a 1:10 sheet renders at 1:16 — so nothing here goes through that table. * DXF group code 147 (``unit_factor`` in ezdxf; "a floating point scale factor that represents the standard scale value specified in code 75" in the DXF Reference — the Reference is right) is the factor AutoCAD's plot engine *uses* under ``USE_STANDARD_SCALE`` (bit 16): AutoCAD writes it as numerator / denominator (0.1 for 1:10, 2.0 for 2:1, 1.0 for fit and 1:1), and a file with code 21 but 147 = 1.0 plots at 1:1 while claiming 1:10 (measured: a 100-unit line spans 99.99 mm of paper). Under the custom route (bit 16 cleared) AutoCAD honours 142/143 and leaves 75 and 147 stale. The headless writer therefore writes 147 alongside 142/143 on every route. * ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib) is the same numbering (ac1_1=16, ac1_2=17, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27, ac2_1=28, ac10_1=31, acScaleToFit=0). The typelib declares ``ac1_5=19`` too, but AutoCAD 2026 refuses ``Layout.StandardScale = 19`` with "Invalid input" in every condition tried (PaperUnits mm and inches, UseStandardScale True and False; measured live 2026-09-17), so 1:5 is not in ``ACTIVEX_PLOT_SCALE`` and takes the custom route on the live engine (``SetCustomScale(1, 5)`` reads back ``1:5``). The headless writer mirrors that — 1:5 is not in ``DXF_STANDARD_SCALE_TYPE`` either, so both engines store the same 1:5 (custom, 142/143 = 1/5) — while the reader knows code 19 for files AutoCAD wrote. A scale without a code on an engine goes through the custom numerator / denominator on that engine, and the read-back reports the same label either way. Centring belongs to an *extents* plot only. The ActiveX Reference (CenterPlot Property, acadauto.chm, AutoCAD 2026): "This property cannot be set to True on a layout object whose PlotType property is set to acLayout" — a layout plot is the whole sheet from its own origin, and the Page Setup dialog greys the box out. ``resolve_page_setup`` therefore resolves ``center`` to ``None`` (not applicable, never written, read back as ``None``) when ``plot_area`` is ``layout``; the bool is kept only for ``extents``. Both engines follow the same resolved value, so the DXF never carries a centre bit AutoCAD would ignore and the live engine never issues the write AutoCAD refuses. """ from __future__ import annotations import math import re __all__ = [ "ACTIVEX_PLOT_SCALE", "CTB_CATALOG", "DXF_STANDARD_SCALE_TYPE", "DXF_STD_SCALES", "ORIENTATIONS", "PAPER_INCHES", "PAPER_SIZES", "PAPER_UNITS", "PAPER_UNITS_NAMES", "PLOT_AREAS", "PLOT_TYPE_LAYOUT", "PLOT_TYPE_NAMES", "REQUIRED_SETUP_KEYS", "SCALES", "activex_scale_label", "canonical_media_name", "center_applies", "dxf_scale_label", "page_setup_warnings", "paper_from_size", "paper_size_mm", "paper_units_name", "plot_style_known", "require_page_setup", "resolve_page_setup", "scale_label", "scale_ratio", "turned_sheet", ] #: Portrait width × height in millimetres. Landscape swaps. PAPER_SIZES: dict[str, tuple[int, int]] = { "ISO_A0": (841, 1189), "ISO_A1": (594, 841), "ISO_A2": (420, 594), "ISO_A3": (297, 420), "ISO_A4": (210, 297), "ANSI_A": (216, 279), "ANSI_B": (279, 432), "ANSI_C": (432, 559), "ANSI_D": (559, 864), "ANSI_E": (864, 1118), } #: ANSI media are *named* in inches by AutoCAD's DWG To PDF.pc3 #: (``ANSI_B_(17.00_x_11.00_Inches)``); the millimetre table above is what the #: sheet measures. Portrait width × height. PAPER_INCHES: dict[str, tuple[float, float]] = { "ANSI_A": (8.5, 11.0), "ANSI_B": (11.0, 17.0), "ANSI_C": (17.0, 22.0), "ANSI_D": (22.0, 34.0), "ANSI_E": (34.0, 44.0), } #: Spellings resolve_page_setup accepts, mapped to the canonical key. _PAPER_ALIASES: dict[str, str] = { **{name.lower(): name for name in PAPER_SIZES}, **{name.split("_", 1)[1].lower(): name for name in PAPER_SIZES if name.startswith("ISO_")}, **{f"ansi {name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")}, **{f"ansi-{name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")}, } #: Plot style tables AutoCAD ships (``/…/Plot Styles``). CTB_CATALOG: tuple[str, ...] = ( "monochrome.ctb", "acad.ctb", "Grayscale.ctb", "Screening 100%.ctb", "Screening 75%.ctb", "Screening 50%.ctb", "Screening 25%.ctb", "Fill Patterns.ctb", "DWF Virtual Pens.ctb", ) #: Label → paper:drawing factor (``None`` = scaled to fit). SCALES: dict[str, float | None] = { "fit": None, "1:1": 1.0, "1:2": 0.5, "1:5": 0.2, "1:10": 0.1, "1:20": 0.05, "1:50": 0.02, "1:100": 0.01, "2:1": 2.0, "5:1": 5.0, "10:1": 10.0, } #: DXF group code 75 → (numerator, denominator): the ObjectARX #: ``AcDbPlotSettings::StdScaleType`` enum, which is what AutoCAD writes and #: reads (module docstring — not ezdxf's ``STD_SCALES``, which is one off #: above 18). Code 0 is *scaled to fit* and has no ratio. DXF_STD_SCALES: dict[int, tuple[float, float]] = { 1: (1.0 / 128.0, 12.0), 2: (1.0 / 64.0, 12.0), 3: (1.0 / 32.0, 12.0), 4: (1.0 / 16.0, 12.0), 5: (3.0 / 32.0, 12.0), 6: (1.0 / 8.0, 12.0), 7: (3.0 / 16.0, 12.0), 8: (1.0 / 4.0, 12.0), 9: (3.0 / 8.0, 12.0), 10: (1.0 / 2.0, 12.0), 11: (3.0 / 4.0, 12.0), 12: (1.0, 12.0), 13: (3.0, 12.0), 14: (6.0, 12.0), 15: (12.0, 12.0), 16: (1.0, 1.0), 17: (1.0, 2.0), 18: (1.0, 4.0), 19: (1.0, 5.0), 20: (1.0, 8.0), 21: (1.0, 10.0), 22: (1.0, 16.0), 23: (1.0, 20.0), 24: (1.0, 30.0), 25: (1.0, 40.0), 26: (1.0, 50.0), 27: (1.0, 100.0), 28: (2.0, 1.0), 29: (4.0, 1.0), 30: (8.0, 1.0), 31: (10.0, 1.0), 32: (100.0, 1.0), 33: (1000.0, 1.0), } #: AutoCAD's names for the imperial codes (inches of paper = feet of drawing). _DXF_IMPERIAL_LABELS: dict[int, str] = { 1: "1/128\"=1'", 2: "1/64\"=1'", 3: "1/32\"=1'", 4: "1/16\"=1'", 5: "3/32\"=1'", 6: "1/8\"=1'", 7: "3/16\"=1'", 8: "1/4\"=1'", 9: "3/8\"=1'", 10: "1/2\"=1'", 11: "3/4\"=1'", 12: "1\"=1'", 13: "3\"=1'", 14: "6\"=1'", 15: "1'=1'", } #: DXF group code 75 the headless *writer* uses per catalogue label. Labels #: missing here use the custom numerator/denominator: ``1:5`` (code 19 exists #: in the file enum, but the live engine cannot set it, so both engines store #: 1:5 the same custom way) and ``5:1`` (no code anywhere). DXF_STANDARD_SCALE_TYPE: dict[str, int] = { "fit": 0, "1:1": 16, "1:2": 17, "1:10": 21, "1:20": 23, "1:50": 26, "1:100": 27, "2:1": 28, "10:1": 31, } #: ActiveX ``AcPlotScale`` (AutoCAD 2026 typelib). Labels missing here use SetCustomScale. #: No ``"1:5"``: the typelib's ``ac1_5 = 19`` is refused by AutoCAD 2026 (module docstring). ACTIVEX_PLOT_SCALE: dict[str, int] = { "fit": 0, "1:1": 16, "1:2": 17, "1:10": 21, "1:20": 23, "1:50": 26, "1:100": 27, "2:1": 28, "10:1": 31, } _ACTIVEX_SCALE_LABEL = {code: label for label, code in ACTIVEX_PLOT_SCALE.items()} #: Plot area → DXF/ActiveX plot type (``AcPlotType``: acExtents=1, acLayout=5). PLOT_AREAS: dict[str, int] = {"layout": 5, "extents": 1} #: ``AcPlotType.acLayout`` — the plot type under which centring does not apply. PLOT_TYPE_LAYOUT = 5 PLOT_TYPE_NAMES: dict[int, str] = { 0: "display", 1: "extents", 2: "limits", 3: "view", 4: "window", 5: "layout", } ORIENTATIONS: tuple[str, ...] = ("landscape", "portrait") _SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$") def _paper_key(paper) -> str: key = str(paper or "").strip().lower().replace(" ", "_") resolved = _PAPER_ALIASES.get(key) or _PAPER_ALIASES.get(key.replace("_", " ")) if resolved is None: raise ValueError( f"paper: unknown paper {paper!r}. Valid papers: {', '.join(PAPER_SIZES)} " "(short forms A0–A4 and ANSI_A–ANSI_E are accepted)" ) return resolved def _orientation(orientation) -> str: if orientation not in ORIENTATIONS: raise ValueError(f"orientation: must be one of {ORIENTATIONS}, got {orientation!r}") return orientation def paper_size_mm(paper: str, orientation: str = "landscape") -> tuple[float, float]: """(width, height) in millimetres for ``paper`` in ``orientation``.""" name = _paper_key(paper) short, long = PAPER_SIZES[name] if _orientation(orientation) == "landscape": return float(long), float(short) return float(short), float(long) def canonical_media_name(paper: str, orientation: str = "landscape") -> str: """AutoCAD's spelling of the media: ``ISO_A3_(420.00_x_297.00_MM)`` for landscape A3, ``ANSI_B_(17.00_x_11.00_Inches)`` for landscape ANSI B.""" name = _paper_key(paper) if name in PAPER_INCHES: short, long = PAPER_INCHES[name] unit = "Inches" else: short, long = PAPER_SIZES[name] unit = "MM" width, height = (long, short) if _orientation(orientation) == "landscape" else (short, long) return f"{name}_({width:.2f}_x_{height:.2f}_{unit})" def paper_from_size(width_mm, height_mm, tolerance: float = 0.5) -> str | None: """The catalogue paper whose size (either orientation) is within ``tolerance`` mm.""" try: w, h = float(width_mm), float(height_mm) except (TypeError, ValueError): return None for name, (short, long) in PAPER_SIZES.items(): if (abs(w - long) <= tolerance and abs(h - short) <= tolerance) or ( abs(w - short) <= tolerance and abs(h - long) <= tolerance ): return name return None def scale_ratio(label: str) -> tuple[float, float]: """``"1:50"`` → ``(1.0, 50.0)`` (paper units : drawing units). ``fit`` has no ratio.""" match = _SCALE_RE.match(str(label or "")) if match is None: raise ValueError(f"scale: {label!r} is not an N:M ratio") numerator, denominator = float(match.group(1)), float(match.group(2)) if numerator <= 0 or denominator <= 0: raise ValueError(f"scale: {label!r} must have positive terms") return numerator, denominator def scale_label(numerator, denominator) -> str: """The ``N:M`` label for a ratio: a catalogue label when the factor matches one, otherwise the reduced integer ratio, otherwise ``N:M`` with the floats.""" num, den = float(numerator), float(denominator) if num <= 0 or den <= 0: return f"{num:g}:{den:g}" for label, factor in SCALES.items(): if factor is not None and math.isclose(num / den, factor, rel_tol=1e-9): return label if num.is_integer() and den.is_integer(): divisor = math.gcd(int(num), int(den)) return f"{int(num) // divisor}:{int(den) // divisor}" return f"{num:g}:{den:g}" _DXF_SCALE_LABEL: dict[int, str] = { 0: "fit", **_DXF_IMPERIAL_LABELS, **{ code: scale_label(num, den) for code, (num, den) in DXF_STD_SCALES.items() if code not in _DXF_IMPERIAL_LABELS }, } def dxf_scale_label(code, numerator, denominator) -> str: """Read-back for the headless engine: the code-75 label (``fit`` for 0, the ratio ``DXF_STD_SCALES`` gives the code, an AutoCAD-style name for an imperial code), else the custom ratio.""" label = _DXF_SCALE_LABEL.get(int(code)) if label is not None: return label return scale_label(numerator, denominator) def activex_scale_label(code) -> str: """Read-back for the live engine: the AcPlotScale label, else ``standard:``.""" return _ACTIVEX_SCALE_LABEL.get(int(code), f"standard:{int(code)}") def plot_style_known(name) -> bool: return str(name or "").strip().lower() in {entry.lower() for entry in CTB_CATALOG} #: Plot paper units — the same codes in DXF group 72 (``plot_paper_units``) and #: ActiveX ``AcPlotPaperUnits`` (acInches=0, acMillimeters=1, acPixels=2). The #: paper size and margins are stored in millimetres whatever this says (ezdxf's #: acdb_plot_settings; GetPaperSize / GetPaperMargins measured live): the units #: govern what one paper-space unit *means* at the plot scale — under inches a #: 1:1 sheet holds 17 units across ANSI B, under millimetres 431.8. PAPER_UNITS: dict[str, int] = {"inches": 0, "mm": 1} PAPER_UNITS_NAMES: dict[int, str] = {0: "inches", 1: "mm", 2: "pixels"} _PAPER_UNITS_ALIASES = {"in": "inches", "inch": "inches", "inches": "inches", "mm": "mm"} def paper_units_name(code) -> str | None: """Read-back for both engines: ``inches`` / ``mm`` / ``pixels``, ``None`` for an unreadable value, ``code:`` for one neither enum names.""" if code is None: return None try: value = int(code) except (TypeError, ValueError): return None return PAPER_UNITS_NAMES.get(value, f"code:{value}") def turned_sheet(width_mm, height_mm, margins_mm, rotation) -> tuple[list[float], list[float]]: """The sheet as plotted from the media as stored: ``(size_mm, margins_mm)``. ``margins_mm`` in and out is ``[top, bottom, left, right]`` — the row's order. ``plot_rotation`` 1/3 is AutoCAD's landscape-on-portrait-media convention (media 210 x 297, sheet 297 x 210): the size swaps *and* the margins turn with it, mapped the way ezdxf's ``Page.from_dxf_layout`` maps them (rotation 1: top<-right, right<-bottom, bottom<-left, left<-top; 2: opposite sides; 3: the inverse of 1). Every read-back and the headless renderer go through this one function, so a row's printable area is the area the same engine plots — swapping only the size reported a 297 x 210 sheet with [20, 20, 7.5, 7.5] (282 x 170 printable) whose true margins were [7.5, 7.5, 20, 20] (257 x 195). """ width, height = float(width_mm), float(height_mm) top, bottom, left, right = (float(v) for v in margins_mm) turn = int(rotation or 0) if turn == 1: width, height = height, width top, right, bottom, left = right, bottom, left, top elif turn == 2: top, right, bottom, left = bottom, left, top, right elif turn == 3: width, height = height, width top, right, bottom, left = left, top, right, bottom return [width, height], [top, bottom, left, right] def page_setup_warnings(changed: dict) -> list[str]: """What a ``page_setup_apply`` moved that ``changed`` alone under-states. A paper-units move is a 25.4x rescale of every paper-space unit at the plot scale (a 16-unit frame on an inch sheet spans 406.4 mm of paper; after a move to millimetres, 16 mm) with ``ok: True`` and the scale reported as applied — so it is named here, on both engines, with the way back. Both engines build the same list from the same ``changed``. """ warnings: list[str] = [] move = changed.get("paper_units") if move and move[0] is not None and move[0] != move[1]: old, new = move if (old, new) == ("inches", "mm"): effect = "plots 25.4x smaller" elif (old, new) == ("mm", "inches"): effect = "plots 25.4x larger" else: effect = "plots at a different size" warnings.append( f"paper units moved from {old} to {new}: one paper-space unit now plots as one " f"{new} at the plot scale, so paper-space geometry authored in {old} {effect} " f"at the same scale; pass paper_units=None to keep a layout's units or " f"paper_units={old!r} to put them back" ) return warnings def center_applies(plot_type) -> bool: """Whether "centre the plot" means anything under this ``AcPlotType``. False for ``acLayout`` (5): the sheet is plotted from its origin, AutoCAD greys the option out and ActiveX refuses ``CenterPlot = True``. Read-backs report ``center: None`` there instead of a stored flag nothing honours. """ try: return int(plot_type) != PLOT_TYPE_LAYOUT except (TypeError, ValueError): return False def resolve_page_setup( paper: str, orientation: str = "landscape", plot_style: str = "monochrome.ctb", scale: str = "fit", plot_area: str = "layout", device: str = "DWG To PDF.pc3", margins_mm=None, center: bool = True, paper_units: str | None = "mm", ) -> dict: """Validate a page setup request and resolve it to what both engines write. Raises ``ValueError`` naming the offending field; nothing here touches a drawing. ``margins_mm`` is ``[top, bottom, left, right]``. ``center`` is validated as a bool and resolved to ``None`` for ``plot_area="layout"``, where centring is not applicable (see the module docstring) — the default call (layout, centre) is therefore a layout plot with no centring write. ``paper_units`` is what one paper-space unit means at the plot scale: ``"mm"`` (the default — this server draws in millimetres), ``"inches"``, or ``None`` to keep the layout's current units, which is what AutoCAD's own Page Setup does when a media is chosen. A write that moves the units rescales every paper-space unit by 25.4; both engines report it in ``changed`` and ``warnings``. """ name = _paper_key(paper) width, height = paper_size_mm(name, _orientation(orientation)) style = str(plot_style or "").strip() if not style: raise ValueError("plot_style: must name a .ctb/.stb file (e.g. monochrome.ctb)") label = str(scale or "").strip() if label not in SCALES: raise ValueError(f"scale: must be one of {', '.join(SCALES)}, got {scale!r}") ratio = (1.0, 1.0) if label == "fit" else scale_ratio(label) if plot_area not in PLOT_AREAS: raise ValueError(f"plot_area: must be one of {', '.join(PLOT_AREAS)}, got {plot_area!r}") device_name = str(device or "").strip() if not device_name: raise ValueError("device: must name a plotter configuration (e.g. 'DWG To PDF.pc3')") margins = None if margins_mm is not None: try: values = [float(v) for v in margins_mm] except (TypeError, ValueError): raise ValueError( "margins_mm: must be four numbers [top, bottom, left, right]" ) from None if len(values) != 4: raise ValueError( f"margins_mm: expected 4 values [top, bottom, left, right], got {len(values)}" ) if any(v < 0 for v in values): raise ValueError("margins_mm: margins cannot be negative") top, bottom, left, right = values if top + bottom >= height or left + right >= width: raise ValueError( f"margins_mm: {values} leave no printable area on a {width:g} x {height:g} mm sheet" ) margins = values if not isinstance(center, bool): raise ValueError(f"center: must be true or false, got {center!r}") plot_type = PLOT_AREAS[plot_area] units = None if paper_units is not None: units = _PAPER_UNITS_ALIASES.get(str(paper_units).strip().lower()) if units is None: raise ValueError( f"paper_units: must be 'mm', 'inches' or None (keep the layout's units), " f"got {paper_units!r}" ) return { "paper": name, "orientation": orientation, "size_mm": [width, height], "canonical_media_name": canonical_media_name(name, orientation), "plot_style": style, "plot_style_known": plot_style_known(style), "scale": label, "scale_factor": SCALES[label], "scale_ratio": [ratio[0], ratio[1]], "dxf_standard_scale_type": DXF_STANDARD_SCALE_TYPE.get(label), "activex_standard_scale": ACTIVEX_PLOT_SCALE.get(label), "plot_area": plot_area, "plot_type": plot_type, "device": device_name, "margins_mm": margins, "center": center if center_applies(plot_type) else None, "paper_units": units, } #: Every key resolve_page_setup emits that a backend reads. A backend refuses #: a dict missing any of them before it writes. REQUIRED_SETUP_KEYS: tuple[str, ...] = ( "paper", "orientation", "size_mm", "canonical_media_name", "plot_style", "plot_style_known", "scale", "scale_ratio", "dxf_standard_scale_type", "activex_standard_scale", "plot_area", "plot_type", "device", "margins_mm", "center", "paper_units", ) def require_page_setup(setup) -> dict: """The backend-side check: ``setup`` is a complete resolve_page_setup dict.""" if not isinstance(setup, dict): raise ValueError("page_setup_apply: setup must be the dict resolve_page_setup returns") missing = [key for key in REQUIRED_SETUP_KEYS if key not in setup] if missing: raise ValueError( f"page_setup_apply: setup is missing {', '.join(missing)} — build it with " "engineering.standards.papers.resolve_page_setup" ) return setup