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
594 lines
22 KiB
Python
594 lines
22 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
|
||
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
|