Finding 1: page_setup_list swapped size_mm for plot_rotation 1/3 but reported the margins un-turned, so a 297 x 210 sheet carried its portrait margins ([20, 20, 7.5, 7.5], printable 282 x 170) while ezdxf's Page and _paper_frame plotted [7.5, 7.5, 20, 20] (257 x 195). The mapping now lives once in papers.turned_sheet (cross-checked against Page.from_dxf_layout for all four rotations) and is used by the ezdxf row, _paper_frame and the COM row. page_setup_apply resets plot_rotation to 0; headlessly it now re-writes the turned margins un-turned so the sheet keeps the margins it had (nothing moves, changed says nothing), and on COM, where margins come from the .pc3, the ones that moved show up in changed. Finding 2: both engines forced plot paper units to millimetres with no units field in the row, so an inch-authored ANSI sheet was rescaled 25.4x with ok: True and changed naming only the media. Rows now carry paper_units (mm | inches | pixels, DXF group 72 / AcPlotPaperUnits); resolve_page_setup takes paper_units="mm" (default) | "inches" | None (keep the layout's units, as AutoCAD's own Page Setup does); a move is reported in changed and, with its 25.4x consequence and the way back, in the new warnings list (papers.page_setup_warnings, identical on both engines). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
495 lines
18 KiB
Python
495 lines
18 KiB
Python
"""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
|
||
tables of two enums that do not agree with each other:
|
||
|
||
* DXF group code 75 (``standard_scale_type``, what ezdxf writes): 16=1:1,
|
||
17=1:2, 20=1:10, 22=1:20, 25=1:50, 26=1:100, 27=2:1, 30=10:1 — no 1:5.
|
||
* ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib):
|
||
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 also declares ``ac1_5=19``, 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) — its standard-scale list has no 1:5, the
|
||
same gap as DXF code 75 — so 1:5 is *not* in the table and takes the custom
|
||
route on the live engine too (``SetCustomScale(1, 5)`` reads back ``1:5``).
|
||
|
||
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",
|
||
"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. Labels missing here use the custom numerator/denominator.
|
||
DXF_STANDARD_SCALE_TYPE: dict[str, int] = {
|
||
"fit": 0,
|
||
"1:1": 16,
|
||
"1:2": 17,
|
||
"1:10": 20,
|
||
"1:20": 22,
|
||
"1:50": 25,
|
||
"1:100": 26,
|
||
"2:1": 27,
|
||
"10:1": 30,
|
||
}
|
||
_DXF_SCALE_LABEL = {code: label for label, code in DXF_STANDARD_SCALE_TYPE.items()}
|
||
|
||
#: 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}"
|
||
|
||
|
||
def dxf_scale_label(code, numerator, denominator) -> str:
|
||
"""Read-back for the headless engine: the code-75 label, else the custom ratio."""
|
||
return _DXF_SCALE_LABEL.get(int(code)) or 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
|