merge: track-e group P

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 11:14:02 +03:00
co-authored by Claude Opus 5
28 changed files with 24378 additions and 46 deletions
+593
View File
@@ -0,0 +1,593 @@
"""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
+59
View File
@@ -0,0 +1,59 @@
"""Read a PDF's page size back from the file — no dependency, no rendering.
The evidence rule for page setup (spec §8.3): the sheet size a plot produced is
what the PDF says it is, not what the layout was asked for. ``/MediaBox`` is the
one PDF object that carries it. matplotlib and AutoCAD's DWG To PDF driver both
write it in clear text; a PDF 1.5+ writer may put the page dictionary inside a
FlateDecode object stream, so when the plain scan finds nothing every stream is
inflated and searched. Anything else raises rather than guessing.
"""
from __future__ import annotations
import re
import zlib
from pathlib import Path
__all__ = ["PT_TO_MM", "read_mediabox"]
#: PostScript points to millimetres.
PT_TO_MM = 25.4 / 72.0
_NUMBER = rb"([-+]?\d*\.?\d+)"
_MEDIABOX_RE = re.compile(
rb"/MediaBox\s*\[\s*"
+ _NUMBER
+ rb"\s+"
+ _NUMBER
+ rb"\s+"
+ _NUMBER
+ rb"\s+"
+ _NUMBER
+ rb"\s*\]"
)
_STREAM_RE = re.compile(rb"stream\r?\n(.*?)\r?\nendstream", re.DOTALL)
def read_mediabox(path: str) -> tuple[float, float]:
"""Return the first page ``/MediaBox`` as ``(width_mm, height_mm)``.
Raises ``ValueError`` when the file is not a PDF or carries no MediaBox.
``/Rotate`` is not applied: the box is reported as written.
"""
data = Path(path).read_bytes()
if not data.startswith(b"%PDF"):
raise ValueError(f"read_mediabox: {path} is not a PDF (no %PDF header)")
match = _MEDIABOX_RE.search(data)
if match is None:
for stream in _STREAM_RE.finditer(data):
try:
inflated = zlib.decompress(stream.group(1))
except zlib.error:
continue
match = _MEDIABOX_RE.search(inflated)
if match is not None:
break
if match is None:
raise ValueError(f"read_mediabox: no /MediaBox found in {path}")
x0, y0, x1, y1 = (float(value) for value in match.groups())
return (round(abs(x1 - x0) * PT_TO_MM, 3), round(abs(y1 - y0) * PT_TO_MM, 3))
+147
View File
@@ -0,0 +1,147 @@
"""batch_plot: every sheet through drawing_export_pdf, each PDF measured back.
Composes the existing per-layout export; the only thing it adds is the
evidence — ``mediabox_mm`` is parsed from the file each sheet produced, so a
caller sees the size that was plotted, not the size that was asked for.
"""
from __future__ import annotations
import re
from pathlib import Path
from engineering.standards.papers import paper_from_size
from engineering.standards.pdfinfo import read_mediabox
__all__ = ["DEFAULT_FILE_PATTERN", "batch_plot", "safe_filename"]
DEFAULT_FILE_PATTERN = "{drawing}-{layout}.pdf"
_UNSAFE = re.compile(r"[<>:\"/\\|?*\s]+")
def safe_filename(text: str) -> str:
"""A layout or drawing name as a filename fragment: no separators, no
reserved characters, runs of them collapsed to one underscore."""
cleaned = _UNSAFE.sub("_", str(text or "").strip()).strip("_")
return cleaned or "untitled"
async def batch_plot(
backend,
layouts: list[str] | None,
output_dir: str,
file_pattern: str = DEFAULT_FILE_PATTERN,
) -> dict:
"""Plot layouts to PDF and read each sheet size back from the file.
``layouts=None`` plots every paper-space layout; ``Model`` is plotted only
when named, and is passed to the engine by name so the model row is model
space whichever tab is current — both engines honour the name (COM plots
the Model tab; ezdxf renders ``doc.modelspace()``, not the current tab;
``tests/test_batch_plot.py`` proves it by content on the headless engine).
A sheet the engine cannot plot, or reports
plotted without a file, is a per-row ``ok: False`` with its ``error``; the
rows before it stay. Refuses (``ValueError``, before any PDF is written): an empty
list, a layout that does not exist, a pattern with a path separator, and
a pattern without ``{layout}`` when more than one sheet would be written.
The output folder is created, then every output path goes through
``security.validate_path(allow_write=True)`` before the first export.
"""
from security import validate_path
listing = await backend.layout_list()
available = [str(name) for name in (listing.get("layouts") or [])]
lowered = {name.lower(): name for name in available}
if layouts is None:
targets = [name for name in available if name.lower() != "model"]
if not targets:
raise ValueError("batch_plot: the drawing has no paper-space layout to plot")
else:
requested = [str(name).strip() for name in layouts]
if not requested:
raise ValueError("batch_plot: layouts is empty — pass null for every sheet")
unknown = [name for name in requested if name.lower() not in lowered]
if unknown:
raise ValueError(
f"batch_plot: layout(s) not found: {', '.join(unknown)} "
f"(available: {', '.join(available)})"
)
targets = [lowered[name.lower()] for name in requested]
pattern = str(file_pattern or DEFAULT_FILE_PATTERN)
if "/" in pattern or "\\" in pattern:
raise ValueError("batch_plot: file_pattern must be a file name, not a path")
if "{layout}" not in pattern and len(targets) > 1:
raise ValueError(
"batch_plot: file_pattern needs {layout} when more than one sheet is plotted, "
"or every sheet would overwrite the last"
)
try:
pattern.format(drawing="x", layout="y")
except (KeyError, IndexError, ValueError) as exc:
raise ValueError(
f"batch_plot: file_pattern {pattern!r} may only use {{drawing}} and {{layout}}"
) from exc
info = await backend.drawing_info()
drawing = safe_filename(Path(info.name).stem)
destination = Path(validate_path(output_dir, allow_write=True))
planned = [
(name, destination / pattern.format(drawing=drawing, layout=safe_filename(name)))
for name in targets
]
# validate_path(allow_write=True) requires the parent folder to exist, so
# the output folder is created before the per-file fence check; an empty
# folder is the most a refused call leaves behind.
destination.mkdir(parents=True, exist_ok=True)
for _name, path in planned:
validate_path(str(path), allow_write=True)
sheets: list[dict] = []
for name, path in planned:
is_model = name.lower() == "model"
# The layout is always named — "Model" included — so the engine plots
# the tab this row is labelled with, never the one that happens to be
# current. A plot that fails inside the engine (COM raises RuntimeError
# for an AutoCAD error) or that reports ok without leaving a file is a
# failed row, not a lost batch: the sheets already plotted are kept.
try:
result = await backend.drawing_export_pdf(str(path), layout=name)
except RuntimeError as exc:
sheets.append({"layout": name, "ok": False, "path": str(path), "error": str(exc)})
continue
if not result.get("ok", False):
sheets.append(
{"layout": name, "ok": False, "path": str(path), "error": result.get("error")}
)
continue
if not path.is_file():
sheets.append(
{
"layout": name,
"ok": False,
"path": str(path),
"error": "the engine reported the plot ok but no file was written",
}
)
continue
row = {"layout": name, "ok": True, "path": str(path), "bytes": path.stat().st_size}
try:
width, height = read_mediabox(str(path))
row["mediabox_mm"] = [width, height]
row["paper"] = None if is_model else paper_from_size(width, height)
except ValueError as exc:
row["mediabox_mm"] = None
row["paper"] = None
row["error"] = str(exc)
sheets.append(row)
return {
"ok": all(row["ok"] for row in sheets),
"drawing": drawing,
"output_dir": str(destination),
"format": "pdf",
"sheets": sheets,
"count": len(sheets),
}
+190
View File
@@ -0,0 +1,190 @@
"""The five bundled drawing templates: what each contains, where its files live.
Built by ``scripts/build_templates.py`` through the server's own backend
(layers, styles, settings, the ISO 5457 A3 frame where the sheet is A3, page
setup) and saved as DXF; the ``.dwt`` twins are produced on a live AutoCAD by
``scripts/smoke_settings_com.py --build-dwt`` and committed once.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from pathlib import Path
__all__ = [
"TEMPLATES_DIR",
"TEMPLATE_CATALOG",
"TemplateSpec",
"resolve_template",
"template_rows",
]
#: ``<repo>/templates`` — shipped in the wheel (pyproject ``only-include``) and the Docker image.
TEMPLATES_DIR = Path(__file__).resolve().parents[2] / "templates"
_ISO_SETTINGS = {
"units": "mm",
"ltscale": 1.0,
"dimscale": 1.0,
"linear_precision": 2,
"angular_precision": 0,
"decimal_separator": ",",
}
_ANSI_SETTINGS = {**_ISO_SETTINGS, "decimal_separator": "."}
@dataclass(frozen=True)
class TemplateSpec:
name: str
standard: str # "ISO" | "ANSI"
sheet: str # human label, e.g. "A3 landscape"
paper: str # engineering.standards.papers.PAPER_SIZES key
orientation: str
layout: str # the paper-space tab's name
layer_set: str # engineering.layers.LAYER_SET_REGISTRY key
dimstyle_preset: str # group S preset id: "iso-25" | "ansi"
dimstyle: str # style name in the drawing
textstyle: str # "ISOCP" | "ROMANS"
scale: str # page setup scale label
title_block: bool
settings: dict = field(default_factory=dict) # drawing_settings facade keys
description: str = ""
TEMPLATE_CATALOG: dict[str, TemplateSpec] = {
spec.name: spec
for spec in (
TemplateSpec(
name="iso_a3_mech",
standard="ISO",
sheet="A3 landscape",
paper="ISO_A3",
orientation="landscape",
layout="A3",
layer_set="mech",
dimstyle_preset="iso-25",
dimstyle="ISO-25",
textstyle="ISOCP",
scale="1:1",
title_block=True,
settings=dict(_ISO_SETTINGS),
description="ISO A3 mechanical part drawing",
),
TemplateSpec(
name="iso_a1_arch",
standard="ISO",
sheet="A1 landscape",
paper="ISO_A1",
orientation="landscape",
layout="A1",
layer_set="iso13567",
dimstyle_preset="iso-25",
dimstyle="ISO-25",
textstyle="ISOCP",
scale="fit",
title_block=False,
settings={**_ISO_SETTINGS, "annotation_scale": "1:50"},
description="ISO A1 architectural sheet, ISO 13567 layers, 1:50 annotation",
),
TemplateSpec(
name="iso_a3_pid",
standard="ISO",
sheet="A3 landscape",
paper="ISO_A3",
orientation="landscape",
layout="A3",
layer_set="pid",
dimstyle_preset="iso-25",
dimstyle="ISO-25",
textstyle="ISOCP",
scale="1:1",
title_block=True,
settings=dict(_ISO_SETTINGS),
description="ISO A3 P&ID sheet",
),
TemplateSpec(
name="ansi_b_mech",
standard="ANSI",
sheet="ANSI B landscape",
paper="ANSI_B",
orientation="landscape",
layout="B",
layer_set="mech",
dimstyle_preset="ansi",
dimstyle="ANSI",
textstyle="ROMANS",
scale="1:1",
title_block=False,
settings=dict(_ANSI_SETTINGS),
description="ANSI B mechanical part drawing (metric)",
),
TemplateSpec(
name="ansi_d_arch",
standard="ANSI",
sheet="ANSI D landscape",
paper="ANSI_D",
orientation="landscape",
layout="D",
layer_set="iso13567",
dimstyle_preset="ansi",
dimstyle="ANSI",
textstyle="ROMANS",
scale="fit",
title_block=False,
settings=dict(_ANSI_SETTINGS),
description="ANSI D architectural sheet (metric)",
),
)
}
def _file(name: str, suffix: str) -> dict:
path = TEMPLATES_DIR / f"{name}{suffix}"
return {"path": str(path), "present": path.is_file()}
def template_rows() -> list[dict]:
"""Catalogue rows for ``drawing_template_list``: the spec plus its files."""
rows = []
for spec in TEMPLATE_CATALOG.values():
row = asdict(spec)
row["files"] = {"dxf": _file(spec.name, ".dxf"), "dwt": _file(spec.name, ".dwt")}
rows.append(row)
return rows
def resolve_template(name_or_path: str, engine: str) -> tuple[str, str]:
"""``(path, source)`` for a catalogue name or a template file path.
A catalogue name resolves to ``templates/<name>.dxf`` (headless) or
``templates/<name>.dwt`` (live), falling back to the DXF with
``source="bundled_dxf"`` when the ``.dwt`` twin is missing — the live
engine then converts that DXF into a real ``.dwt`` itself before
``Documents.Add`` (``ComBackend.drawing_new``), because AutoCAD ignores a
DXF handed to ``Add`` and silently returns the default drawing. Anything
with a path separator or a ``.dwt``/``.dxf`` suffix is a path
(``source="path"``, not checked here). A bare word that is not in the
catalogue is refused with the catalogue names.
"""
raw = str(name_or_path or "").strip()
if not raw:
raise ValueError("template: pass a bundled template name or a path to a .dwt/.dxf file")
key = raw.lower()
if key in TEMPLATE_CATALOG:
dxf = TEMPLATES_DIR / f"{key}.dxf"
dwt = TEMPLATES_DIR / f"{key}.dwt"
if engine == "com" and dwt.is_file():
return str(dwt), "bundled"
if dxf.is_file():
return str(dxf), "bundled" if engine != "com" else "bundled_dxf"
raise ValueError(
f"template {key!r} is in the catalogue but {dxf} is missing — run "
"`uv run --frozen python scripts/build_templates.py`"
)
looks_like_path = ("/" in raw or "\\" in raw) or Path(raw).suffix.lower() in (".dwt", ".dxf")
if looks_like_path:
return raw, "path"
raise ValueError(
f"unknown template {raw!r}. Bundled templates: {', '.join(TEMPLATE_CATALOG)}; "
"or pass a path to a .dwt/.dxf file"
)