Files
autocad-mcp-pro/engineering/standards/papers.py
T
Umut CANandClaude Opus 5 aeb27c252b fix(task-17): the headless page setup writes the scale AutoCAD plots by, and reads the code 75 AutoCAD writes
Two measured root causes on the headless engine, both silent:

1. page_setup_apply wrote PLOTSETTINGS group 147 (unit_factor) as 1.0 for
   every scale. Under USE_STANDARD_SCALE AutoCAD's plot engine scales by
   group 147 alone (a 1:10 sheet with code 21 and 147 = 1.0 plotted a
   100-unit line at 99.99 mm while the row, `applied` and 142/143 said
   1:10; patching 147 to 0.1 alone gave 9.99 mm). AutoCAD writes 147 as
   numerator / denominator (measured live on AutoCAD 2026 for fit, 1:1,
   1:2, 1:10, 1:50, 2:1, 10:1: 1.0, 1.0, 0.5, 0.1, 0.02, 2.0, 10.0). The
   writer now stores 75, 142/143 and 147 from the one resolved ratio on
   every route, so no reader can disagree with the plot.

2. DXF_STANDARD_SCALE_TYPE and _plot_scale followed ezdxf's STD_SCALES,
   which has no 19 = 1:5 and numbers everything above 18 one lower than
   the ObjectARX StdScaleType enum AutoCAD actually writes (1:10 = 21,
   1:20 = 23, 1:50 = 26, 1:100 = 27, 2:1 = 28, 10:1 = 31 — measured via COM
   SaveAs DXF). An AutoCAD-authored 1:50 sheet listed as "1:100" and a
   1:10 sheet rendered at 1:16. papers.py now authors the full file enum
   (DXF_STD_SCALES, codes 1-33 with AutoCAD's imperial names), the writer
   uses the same codes ActiveX uses, and nothing goes through ezdxf's
   table. 1:5 stays on the custom route on both engines (the live setter
   refuses 19) while the reader knows code 19 for files AutoCAD wrote.

Tests: the plan's `1:50 -> 25` assertion was the stale numbering and now
pins 26; a pure-data test pins the enum against the measured codes and the
ActiveX numbering; a parametrised writer test checks 75/142/143/147 for
every catalogue scale; a reader test stores the scale exactly as AutoCAD
saves it (including codes 19 and 22 the writer never emits) and checks
page_setup_list and _paper_frame report and render it at its own scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 15:29:47 +03:00

594 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (``<install>/…/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:<code>``."""
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:<n>`` 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