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:
@@ -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
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user