Three live-measured behaviours of AutoCAD 2026 that the fake did not replay: * GetPaperSize / GetPaperMargins answer in millimetres whatever PaperUnits says; the 25.4 factor listed every imperial layout as [10668.0, 7543.8] and lost the paper name. No factor now; the fake pins PaperUnits=0. * Layout.StandardScale = 19 (ac1_5) is refused with "Invalid input" in every condition; '1:5' is dropped from ACTIVEX_PLOT_SCALE so it goes through SetCustomScale(1, 5) like 5:1 already did. * Writing ConfigName replaces CanonicalMediaName with the new device's default, and writing the old device back does not bring the media back — the unknown-media refusal and a late refusal both left the layout on the old device's default paper while the error claimed it was restored. The device write now snapshots media / units / rotation and journals one combined undo; the read-back after every unwind is compared to the read-back before the first write and the message names what did not go back instead of asserting it did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
392 lines
14 KiB
Python
392 lines
14 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",
|
||
"PLOT_AREAS",
|
||
"PLOT_TYPE_LAYOUT",
|
||
"PLOT_TYPE_NAMES",
|
||
"REQUIRED_SETUP_KEYS",
|
||
"SCALES",
|
||
"activex_scale_label",
|
||
"canonical_media_name",
|
||
"center_applies",
|
||
"dxf_scale_label",
|
||
"paper_from_size",
|
||
"paper_size_mm",
|
||
"plot_style_known",
|
||
"require_page_setup",
|
||
"resolve_page_setup",
|
||
"scale_label",
|
||
"scale_ratio",
|
||
]
|
||
|
||
#: 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}
|
||
|
||
|
||
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,
|
||
) -> 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.
|
||
"""
|
||
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]
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
#: 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",
|
||
)
|
||
|
||
|
||
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
|