Files
autocad-mcp-pro/engineering/standards/papers.py
T
Umut CANandClaude Opus 5 f2386a9fdd fix(task-17): a rotated sheet's margins turn with it, and a paper-units move is named in changed and warnings
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
2026-09-17 14:58:17 +03:00

495 lines
18 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
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