diff --git a/backends/base.py b/backends/base.py index 0adbddc..27fed19 100644 --- a/backends/base.py +++ b/backends/base.py @@ -30,6 +30,7 @@ from backends.contracts import ( LayerContract, LayoutContract, LinetypeContract, + PageSetupContract, PremiumContract, SettingsContract, SolidContract, @@ -168,6 +169,7 @@ class AutoCADBackend( IdentityContract, DrawingContract, LayoutContract, + PageSetupContract, SolidContract, EntityCreationContract, DimensionContract, diff --git a/backends/com_backend.py b/backends/com_backend.py index b0aa28d..227dea0 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -811,6 +811,7 @@ class ComBackend(AutoCADBackend): "refiner": FeatureCapability(True, "shared"), "delivery": FeatureCapability(True, "shared"), "paper_space": FeatureCapability(True, "native"), + "dwt_write": FeatureCapability(True, "native"), "viewport_render": FeatureCapability(True, "native"), "solid_3d": _solid_3d_capability(), "lisp": FeatureCapability(True, "sanitized"), @@ -1954,6 +1955,183 @@ class ComBackend(AutoCADBackend): # CopyObjects-and-delete that would destroy and recreate geometry in the # operator's open drawing when it goes wrong. + # ── page setup (track E) ───────────────────────────────────────────────── + # + # Layout is an IAcadPlotConfiguration. Constants from the AutoCAD 2026 + # typelib: AcPlotPaperUnits acMillimeters=1; AcPlotRotation ac0degrees=0; + # AcPlotType acExtents=1 / acLayout=5; AcPlotScale acScaleToFit=0, ac1_1=16 + # (the full table is engineering.standards.papers.ACTIVEX_PLOT_SCALE). + # Fake-tested in tests/test_page_setup.py; executed live by + # scripts/smoke_settings_com.py (Task 24). + + _AC_MILLIMETERS = 1 + _AC_NO_ROTATION = 0 + + @staticmethod + def _com_page_setup_row(layout) -> dict: + from engineering.standards.papers import ( + PLOT_TYPE_NAMES, + activex_scale_label, + paper_from_size, + scale_label, + ) + + def _get(attr): + try: + return getattr(layout, attr) + except Exception as exc: + log.debug("Layout.%s read failed: %s", attr, exc) + return None + + units = _get("PaperUnits") + factor = 25.4 if units == 0 else 1.0 + size = None + try: + width, height = layout.GetPaperSize() + size = [round(float(width) * factor, 2), round(float(height) * factor, 2)] + except Exception as exc: + log.debug("Layout.GetPaperSize failed: %s", exc) + margins = None + try: + (left, bottom), (right, top) = layout.GetPaperMargins() + margins = [round(float(v) * factor, 3) for v in (top, bottom, left, right)] + except Exception as exc: + log.debug("Layout.GetPaperMargins failed: %s", exc) + rotation = _get("PlotRotation") or 0 + if size and int(rotation) in (1, 3): + size = [size[1], size[0]] + if _get("UseStandardScale"): + scale = activex_scale_label(_get("StandardScale") or 0) + else: + try: + numerator, denominator = layout.GetCustomScale() + scale = scale_label(float(numerator), float(denominator)) + except Exception as exc: + log.debug("Layout.GetCustomScale failed: %s", exc) + scale = None + media = _get("CanonicalMediaName") + plot_type = _get("PlotType") + return { + "layout": layout.Name, + "paper": (paper_from_size(*size) if size else None) or media, + "canonical_media_name": media, + "size_mm": size, + "orientation": ( + None if not size else ("landscape" if size[0] >= size[1] else "portrait") + ), + "plot_style": _get("StyleSheet"), + "scale": scale, + "plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type), + "device": _get("ConfigName"), + "margins_mm": margins, + "center": bool(_get("CenterPlot")), + } + + def _com_paper_layout(self, doc, raw: str, operation: str): + resolved = self._find_layout(doc, raw) + if resolved is None: + available = [n for n in self._layout_names(doc) if n != "Model"] + raise ValueError( + f"{operation}: layout {raw!r} not found (paper-space layouts: " + f"{', '.join(available) or 'none'})" + ) + if resolved == "Model": + raise ValueError( + f"{operation}: Model space has no sheet; plot it with " + "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout" + ) + return doc.Layouts.Item(resolved) + + async def page_setup_list(self, layout: str | None = None) -> list[dict]: + def _sync(): + doc = _acad_doc() + if layout is not None and self._layout_name(layout): + target = self._com_paper_layout(doc, layout, "page_setup_list") + return [self._com_page_setup_row(target)] + return [ + self._com_page_setup_row(doc.Layouts.Item(name)) + for name in self._layout_names(doc) + if name != "Model" + ] + + return await self._run(_sync) + + async def page_setup_apply(self, layout: str, setup: dict) -> dict: + from engineering.standards.papers import require_page_setup + + resolved = require_page_setup(setup) + if resolved["margins_mm"] is not None: + raise ValueError( + "page_setup_apply: margins_mm cannot be written on the COM backend — AutoCAD " + "takes the printable margins from the plotter configuration (.pc3). Omit " + "margins_mm; the device's margins are read back in the result." + ) + + def _sync(): + doc = _acad_doc() + target = self._com_paper_layout(doc, layout, "page_setup_apply") + before = self._com_page_setup_row(target) + previous_device = before["device"] + target.RefreshPlotDeviceInfo() + target.ConfigName = resolved["device"] + target.RefreshPlotDeviceInfo() + media = resolved["canonical_media_name"] + names = [str(n) for n in (target.GetCanonicalMediaNames() or ())] + if media not in names: + # Roll the one write back before refusing, so a refusal leaves + # the layout as it was. + target.ConfigName = previous_device + nearest = [n for n in names if n.split("_(")[0] == media.split("_(")[0]] + raise ValueError( + f"page_setup_apply: device {resolved['device']!r} has no media " + f"{media!r} (same paper on this device: {', '.join(nearest) or 'none'}; " + f"{len(names)} media in total)" + ) + target.CanonicalMediaName = media + target.PaperUnits = self._AC_MILLIMETERS + target.PlotRotation = self._AC_NO_ROTATION + target.StyleSheet = resolved["plot_style"] + target.PlotWithPlotStyles = True + target.PlotType = int(resolved["plot_type"]) + code = resolved["activex_standard_scale"] + if code is not None: + target.StandardScale = int(code) + target.UseStandardScale = True + else: + numerator, denominator = (float(v) for v in resolved["scale_ratio"]) + target.SetCustomScale(numerator, denominator) + target.UseStandardScale = False + target.CenterPlot = bool(resolved["center"]) + after = self._com_page_setup_row(target) + changed = { + key: [before[key], after[key]] + for key in after + if key != "layout" and before.get(key) != after[key] + } + return { + "ok": True, + "layout": target.Name, + "applied": dict(resolved), + "changed": changed, + "plot_style_known": bool(resolved["plot_style_known"]), + "viewports_kept": True, + "backend": "com", + } + + return await self._run(_sync) + + async def plot_style_list(self) -> list[dict]: + # Completed in Task 18 (installed ctb scan); the catalogue rows already answer. + from engineering.standards.papers import CTB_CATALOG + + return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG] + + async def drawing_template_save(self, path, name=None, description=None) -> dict: + # Completed in Task 20; declared here so the ABC instantiates. + raise UnsupportedCapabilityError( + "dwt_write", "drawing_template_save: not implemented yet (Task 20)" + ) + # ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) ─ async def solid_box( diff --git a/backends/contracts/__init__.py b/backends/contracts/__init__.py index fb04be8..f23c208 100644 --- a/backends/contracts/__init__.py +++ b/backends/contracts/__init__.py @@ -21,6 +21,7 @@ from backends.contracts.identity import IdentityContract from backends.contracts.layers import LayerContract from backends.contracts.layouts import LayoutContract from backends.contracts.linetypes import LinetypeContract +from backends.contracts.pagesetup import PageSetupContract from backends.contracts.premium import PremiumContract from backends.contracts.settings import SettingsContract from backends.contracts.solids import SolidContract @@ -42,6 +43,7 @@ __all__ = [ "LayerContract", "LayoutContract", "LinetypeContract", + "PageSetupContract", "PremiumContract", "SettingsContract", "SolidContract", diff --git a/backends/contracts/pagesetup.py b/backends/contracts/pagesetup.py new file mode 100644 index 0000000..464e00e --- /dev/null +++ b/backends/contracts/pagesetup.py @@ -0,0 +1,59 @@ +"""Page setup, plot styles and templates (v1.6 track E, group P). + +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class PageSetupContract(ABC): + @abstractmethod + async def page_setup_list(self, layout: str | None = None) -> list[dict]: + """Page setup of one paper-space layout, or of every one. + + Rows: ``{layout, paper, canonical_media_name, size_mm: [w, h], + orientation, plot_style, scale, plot_area, device, + margins_mm: [top, bottom, left, right], center}``. ``paper`` is the + catalogue name when the size matches one within 0.5 mm, otherwise the + media name as stored. Raises ``ValueError`` for an unknown layout. + ``Model`` is never listed: model space is plotted through + ``drawing_export_pdf(layout=None)`` and carries no sheet. + """ + ... + + @abstractmethod + async def page_setup_apply(self, layout: str, setup: dict) -> dict: + """Write a page setup resolved by + ``engineering.standards.papers.resolve_page_setup`` to a layout. + + Returns ``{ok, layout, applied: {...setup}, changed: {key: [old, new]}, + plot_style_known, viewports_kept}``. ``changed`` is computed from the + read-back before and after, so re-applying the same setup reports + ``{}``. Refuses ``Model``, an unknown layout, and a ``setup`` missing a + resolved key, before anything is written. Never deletes viewports. + """ + ... + + @abstractmethod + async def plot_style_list(self) -> list[dict]: + """``[{name, source: "catalog"|"installed", installed: bool|None}]`` — + the authored ctb catalogue plus, on a live engine, the files present + in the plot style search path. ``installed`` is ``None`` where no + installation can be scanned.""" + ... + + @abstractmethod + async def drawing_template_save( + self, path: str, name: str | None = None, description: str | None = None + ) -> dict: + """Save the current drawing as a template. + + ``.dxf`` writes on both engines; ``.dwt`` writes on the live engine + (``SaveAs(path, ac2018_Template)``) and raises + ``UnsupportedCapabilityError("dwt_write")`` headlessly. Returns + ``{ok, path, format: "dxf"|"dwt", backend, name, description_written}``. + """ + ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 465d1d8..80a1311 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1044,6 +1044,9 @@ class EzdxfBackend(AutoCADBackend): "refiner": FeatureCapability(True, "shared"), "delivery": FeatureCapability(True, "shared"), "paper_space": FeatureCapability(True, "native"), + "dwt_write": FeatureCapability( + False, reason="dwt_is_a_dwg_container_requires_live_autocad" + ), # Measured, not assumed: a sheet holding nothing but a viewport # renders the model geometry that viewport looks at, at its # scale and position, clipped to its window. v1.4 declared this @@ -1525,13 +1528,29 @@ class EzdxfBackend(AutoCADBackend): from ezdxf.addons.drawing import Frontend, RenderContext from ezdxf.addons.drawing.matplotlib import MatplotlibBackend - fig = _new_agg_figure() + # Track E: for a sheet, the figure *is* the paper. Sized from + # the layout's page setup and left unadjusted, so the PDF's + # /MediaBox is the paper size — the evidence page_setup_apply + # is judged by. Measured: adjust_figure=True gave 691.2 x 345.6 + # pt whatever the sheet was. + paper = None + if resolved is not None and resolved != "Model": + paper = self._paper_frame(target) + fig = _new_agg_figure(figsize=paper["figsize"] if paper else None) ax = fig.add_axes([0, 0, 1, 1]) ctx = RenderContext(doc) - out = MatplotlibBackend(ax) + out = MatplotlibBackend(ax, adjust_figure=paper is None) Frontend(ctx, out).draw_layout(target, finalize=True) + if paper is not None: + ax.set_xlim(*paper["xlim"]) + ax.set_ylim(*paper["ylim"]) + ax.set_aspect("equal", adjustable="box") fig.savefig(path, dpi=150) result = {"ok": True, "path": path} + if paper is not None: + result["paper_mm"] = paper["size_mm"] + if not paper["rotation_applied"]: + result["rotation_applied"] = False if resolved is not None and resolved != "Model": result["layout"] = resolved result["note"] = ( @@ -2294,6 +2313,182 @@ class EzdxfBackend(AutoCADBackend): text = f"{float(value):.4f}".rstrip("0").rstrip(".") return text or "0" + # ── page setup (track E) ───────────────────────────────────────────────── + + def _paper_layout(self, raw: str, operation: str): + """Resolve a paper-space layout by name, refusing Model and unknowns.""" + doc = self._require_doc() + resolved = self._find_layout(raw) + if resolved is None: + available = [n for n in doc.layouts.names_in_taborder() if n != "Model"] + raise ValueError( + f"{operation}: layout {raw!r} not found (paper-space layouts: " + f"{', '.join(available) or 'none'})" + ) + if resolved == "Model": + raise ValueError( + f"{operation}: Model space has no sheet; plot it with " + "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout" + ) + return doc.layouts.get(resolved) + + @staticmethod + def _page_setup_row(layout) -> dict: + from engineering.standards.papers import ( + PLOT_TYPE_NAMES, + dxf_scale_label, + paper_from_size, + scale_label, + ) + + dxf = layout.dxf_layout.dxf + flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags"))) + # DXF stores every paper size and margin in millimetres, whatever + # plot_paper_units says (ezdxf's acdb_plot_settings, verified). + width, height = float(dxf.paper_width), float(dxf.paper_height) + rotation = int(dxf.plot_rotation) + if rotation in (1, 3): + width, height = height, width + media = str(dxf.paper_size) + numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator) + if flags & layout.USE_STANDARD_SCALE: + scale = dxf_scale_label(int(dxf.standard_scale_type), numerator, denominator) + else: + scale = scale_label(numerator, denominator) + return { + "layout": layout.name, + "paper": paper_from_size(width, height) or media, + "canonical_media_name": media, + "size_mm": [round(width, 2), round(height, 2)], + "orientation": "landscape" if width >= height else "portrait", + "plot_style": str(dxf.current_style_sheet), + "scale": scale, + "plot_area": PLOT_TYPE_NAMES.get(int(dxf.plot_type), str(dxf.plot_type)), + "device": str(dxf.plot_configuration_file), + "margins_mm": [ + round(float(dxf.top_margin), 3), + round(float(dxf.bottom_margin), 3), + round(float(dxf.left_margin), 3), + round(float(dxf.right_margin), 3), + ], + "center": bool(flags & layout.PLOT_CENTERED), + } + + @staticmethod + def _paper_frame(layout) -> dict: + """The sheet as a matplotlib figure: size in inches plus the axes window. + + Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper. + ``plot_rotation`` 1/3 swaps the figure; the content is not rotated by + the headless renderer (reported, not hidden). + """ + dxf = layout.dxf_layout.dxf + width, height = float(dxf.paper_width), float(dxf.paper_height) + rotation = int(dxf.plot_rotation) + unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0 + x0 = -(float(dxf.left_margin) + float(dxf.plot_origin_x_offset)) + y0 = -(float(dxf.bottom_margin) + float(dxf.plot_origin_y_offset)) + fig_w, fig_h = (height, width) if rotation in (1, 3) else (width, height) + return { + "size_mm": [round(fig_w, 2), round(fig_h, 2)], + "figsize": (fig_w / 25.4, fig_h / 25.4), + "xlim": (x0 / unit, (x0 + width) / unit), + "ylim": (y0 / unit, (y0 + height) / unit), + "rotation_applied": rotation == 0, + } + + async def page_setup_list(self, layout: str | None = None) -> list[dict]: + def _sync(): + doc = self._require_doc() + if layout is not None and self._layout_name(layout): + return [self._page_setup_row(self._paper_layout(layout, "page_setup_list"))] + return [ + self._page_setup_row(doc.layouts.get(name)) + for name in doc.layouts.names_in_taborder() + if name != "Model" + ] + + return await self._async(_sync) + + async def page_setup_apply(self, layout: str, setup: dict) -> dict: + from engineering.standards.papers import require_page_setup + + resolved = require_page_setup(setup) + + def _sync(): + from ezdxf.lldxf.const import STD_SCALES + + target = self._paper_layout(layout, "page_setup_apply") + before = self._page_setup_row(target) + dxf = target.dxf_layout.dxf + # Measured: a fresh layout has no stored plot_layout_flags, and + # set_flag_state starts from 0 — toggling one bit would drop + # plot-styles (32), lineweights (128) and viewports-first (512). + if not dxf.hasattr("plot_layout_flags"): + dxf.plot_layout_flags = dxf.get_default("plot_layout_flags") + width, height = (float(v) for v in resolved["size_mm"]) + dxf.page_setup_name = "" + dxf.plot_configuration_file = resolved["device"] + dxf.paper_size = resolved["canonical_media_name"] + dxf.paper_width = width + dxf.paper_height = height + dxf.plot_paper_units = 1 # millimetres + dxf.plot_rotation = 0 # orientation rides on width/height, as AutoCAD names media + if resolved["margins_mm"] is not None: + top, bottom, left, right = (float(v) for v in resolved["margins_mm"]) + dxf.top_margin, dxf.bottom_margin = top, bottom + dxf.left_margin, dxf.right_margin = left, right + dxf.plot_origin_x_offset = 0.0 + dxf.plot_origin_y_offset = 0.0 + dxf.unit_factor = 1.0 + dxf.current_style_sheet = resolved["plot_style"] + target.use_plot_styles(True) + target.set_plot_type(int(resolved["plot_type"])) + code = resolved["dxf_standard_scale_type"] + if code is not None: + dxf.standard_scale_type = int(code) + numerator, denominator = STD_SCALES.get(int(code), (1.0, 1.0)) + dxf.scale_numerator, dxf.scale_denominator = float(numerator), float(denominator) + target.use_standard_scale(True) + else: + numerator, denominator = (float(v) for v in resolved["scale_ratio"]) + dxf.standard_scale_type = 16 + dxf.scale_numerator, dxf.scale_denominator = numerator, denominator + target.use_standard_scale(False) + target.plot_centered(bool(resolved["center"])) + # Limits follow the paper; viewports are deliberately left alone + # (Paperspace.page_setup() would delete them). + target.reset_paper_limits() + self._mark_dirty() + after = self._page_setup_row(target) + changed = { + key: [before[key], after[key]] + for key in after + if key != "layout" and before.get(key) != after[key] + } + return { + "ok": True, + "layout": target.name, + "applied": dict(resolved), + "changed": changed, + "plot_style_known": bool(resolved["plot_style_known"]), + "viewports_kept": True, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def plot_style_list(self) -> list[dict]: + from engineering.standards.papers import CTB_CATALOG + + return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG] + + async def drawing_template_save(self, path, name=None, description=None) -> dict: + # Completed in Task 20; declared here so the ABC instantiates. + raise UnsupportedCapabilityError( + "dwt_write", "drawing_template_save: not implemented yet (Task 20)" + ) + # ── 3D solids (unsupported headlessly — honest capability boundary) ───── @staticmethod diff --git a/engineering/standards/papers.py b/engineering/standards/papers.py new file mode 100644 index 0000000..54e7248 --- /dev/null +++ b/engineering/standards/papers.py @@ -0,0 +1,355 @@ +"""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_5=19, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27, + ac2_1=28, ac10_1=31, acScaleToFit=0. + +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. +""" + +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_NAMES", + "REQUIRED_SETUP_KEYS", + "SCALES", + "activex_scale_label", + "canonical_media_name", + "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 (``/…/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. +ACTIVEX_PLOT_SCALE: dict[str, int] = { + "fit": 0, + "1:1": 16, + "1:2": 17, + "1:5": 19, + "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} +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:``.""" + 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 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]``. + """ + 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}") + + 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_AREAS[plot_area], + "device": device_name, + "margins_mm": margins, + "center": center, + } + + +#: 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 diff --git a/engineering/standards/pdfinfo.py b/engineering/standards/pdfinfo.py new file mode 100644 index 0000000..629d90d --- /dev/null +++ b/engineering/standards/pdfinfo.py @@ -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)) diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py new file mode 100644 index 0000000..427764e --- /dev/null +++ b/tests/test_page_setup.py @@ -0,0 +1,493 @@ +"""Page setup on both engines; the PDF's own /MediaBox is the evidence. + +ezdxf: apply a paper, export the layout, parse the PDF back and compare to the +paper size. COM: a fake ActiveX layout records the property sequence. +""" + +from __future__ import annotations + +import re +import types +import zlib + +import pytest + +from engineering.standards.papers import ( + CTB_CATALOG, + PAPER_SIZES, + SCALES, + canonical_media_name, + paper_from_size, + resolve_page_setup, + scale_label, + scale_ratio, +) +from engineering.standards.pdfinfo import read_mediabox + +pytestmark = pytest.mark.asyncio + +SHEET = "Sheet" + + +# ── pure data ─────────────────────────────────────────────────────────────── + + +def test_paper_sizes_are_iso_216_and_ansi_y14_1(): + assert PAPER_SIZES == { + "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), + } + assert CTB_CATALOG[0] == "monochrome.ctb" and "acad.ctb" in CTB_CATALOG + assert SCALES["fit"] is None and SCALES["1:50"] == 0.02 and SCALES["10:1"] == 10.0 + + +def test_canonical_media_name_uses_autocad_spelling(): + assert canonical_media_name("ISO_A3", "landscape") == "ISO_A3_(420.00_x_297.00_MM)" + assert canonical_media_name("ISO_A3", "portrait") == "ISO_A3_(297.00_x_420.00_MM)" + assert canonical_media_name("ANSI_B", "landscape") == "ANSI_B_(17.00_x_11.00_Inches)" + + +def test_paper_from_size_matches_either_orientation_within_half_a_millimetre(): + assert paper_from_size(420, 297) == "ISO_A3" + assert paper_from_size(297, 420) == "ISO_A3" + assert paper_from_size(431.8, 279.4) == "ANSI_B" + assert paper_from_size(400, 300) is None + + +def test_scale_ratio_and_label_round_trip(): + assert scale_ratio("1:50") == (1.0, 50.0) + assert scale_ratio("2:1") == (2.0, 1.0) + assert scale_label(1.0, 50.0) == "1:50" + assert scale_label(1.0, 3.0) == "1:3" + with pytest.raises(ValueError, match="scale"): + scale_ratio("fit") + + +def test_resolve_page_setup_defaults(): + setup = resolve_page_setup("ISO_A3") + assert setup["paper"] == "ISO_A3" + assert setup["orientation"] == "landscape" + assert setup["size_mm"] == [420.0, 297.0] + assert setup["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)" + assert setup["plot_style"] == "monochrome.ctb" and setup["plot_style_known"] is True + assert setup["scale"] == "fit" + assert setup["dxf_standard_scale_type"] == 0 and setup["activex_standard_scale"] == 0 + assert setup["scale_ratio"] == [1.0, 1.0] + assert setup["plot_area"] == "layout" and setup["plot_type"] == 5 + assert setup["device"] == "DWG To PDF.pc3" + assert setup["margins_mm"] is None and setup["center"] is True + + +def test_resolve_page_setup_accepts_lowercase_and_short_paper_names(): + assert resolve_page_setup("iso_a4", "portrait")["size_mm"] == [210.0, 297.0] + assert resolve_page_setup("A3")["paper"] == "ISO_A3" + assert resolve_page_setup("ansi_b")["canonical_media_name"] == "ANSI_B_(17.00_x_11.00_Inches)" + + +def test_resolve_page_setup_scale_codes(): + one_to_five = resolve_page_setup("ISO_A3", scale="1:5") + assert one_to_five["dxf_standard_scale_type"] is None # DXF code 75 has no 1:5 + assert one_to_five["activex_standard_scale"] == 19 # AcPlotScale.ac1_5 + assert one_to_five["scale_ratio"] == [1.0, 5.0] + five_to_one = resolve_page_setup("ISO_A3", scale="5:1") + assert five_to_one["dxf_standard_scale_type"] is None + assert five_to_one["activex_standard_scale"] is None + assert five_to_one["scale_ratio"] == [5.0, 1.0] + assert resolve_page_setup("ISO_A3", scale="1:50")["dxf_standard_scale_type"] == 25 + assert resolve_page_setup("ISO_A3", scale="1:50")["activex_standard_scale"] == 26 + + +@pytest.mark.parametrize( + "kwargs, field", + [ + ({"paper": "ISO_A9"}, "paper"), + ({"paper": "ISO_A3", "orientation": "sideways"}, "orientation"), + ({"paper": "ISO_A3", "scale": "1:3"}, "scale"), + ({"paper": "ISO_A3", "plot_area": "window"}, "plot_area"), + ({"paper": "ISO_A3", "device": ""}, "device"), + ({"paper": "ISO_A3", "plot_style": ""}, "plot_style"), + ({"paper": "ISO_A3", "margins_mm": [1, 2, 3]}, "margins_mm"), + ({"paper": "ISO_A3", "margins_mm": [-1, 0, 0, 0]}, "margins_mm"), + ({"paper": "ISO_A3", "margins_mm": [150, 150, 0, 0]}, "margins_mm"), + ({"paper": "ISO_A3", "center": "yes"}, "center"), + ], +) +def test_resolve_page_setup_refuses_naming_the_field(kwargs, field): + with pytest.raises(ValueError, match=field): + resolve_page_setup(**kwargs) + + +def test_unknown_ctb_is_allowed_but_flagged(): + setup = resolve_page_setup("ISO_A3", plot_style="company.ctb") + assert setup["plot_style"] == "company.ctb" and setup["plot_style_known"] is False + + +# ── the PDF reader ────────────────────────────────────────────────────────── + + +def test_read_mediabox_refuses_a_non_pdf(tmp_path): + bogus = tmp_path / "x.pdf" + bogus.write_bytes(b"not a pdf") + with pytest.raises(ValueError, match="PDF"): + read_mediabox(str(bogus)) + + +def test_read_mediabox_finds_a_box_inside_a_flate_stream(tmp_path): + body = zlib.compress(b"<< /Type /Page /MediaBox [0 0 595.276 841.89] >>") + pdf = tmp_path / "c.pdf" + pdf.write_bytes( + b"%PDF-1.5\n1 0 obj\n<< /Type /ObjStm /Filter /FlateDecode >>\nstream\n" + + body + + b"\nendstream\nendobj\n%%EOF" + ) + width, height = read_mediabox(str(pdf)) + assert abs(width - 210.0) < 0.05 and abs(height - 297.0) < 0.05 + + +def test_read_mediabox_reports_a_missing_box(tmp_path): + pdf = tmp_path / "n.pdf" + pdf.write_bytes(b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF") + with pytest.raises(ValueError, match="MediaBox"): + read_mediabox(str(pdf)) + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def _sheet(backend, name: str = SHEET) -> str: + result = await backend.layout_create(name) + assert result["ok"], result + return name + + +async def _mediabox_after_export(backend, tmp_path, layout: str): + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + pdf = tmp_path / f"{layout}.pdf" + result = await backend.drawing_export_pdf(str(pdf), layout=layout) + assert result["ok"] is True, result + return read_mediabox(str(pdf)) + + +async def test_apply_iso_a3_landscape_and_the_pdf_mediabox_agrees(backend, tmp_path): + await _sheet(backend) + result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", "landscape")) + assert result["ok"] is True and result["layout"] == SHEET + assert result["applied"]["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)" + assert result["plot_style_known"] is True + assert result["viewports_kept"] is True + + rows = await backend.page_setup_list(SHEET) + assert len(rows) == 1 + row = rows[0] + assert row["layout"] == SHEET + assert row["paper"] == "ISO_A3" + assert row["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)" + assert row["size_mm"] == [420.0, 297.0] + assert row["orientation"] == "landscape" + assert row["plot_style"] == "monochrome.ctb" + assert row["scale"] == "fit" + assert row["plot_area"] == "layout" + assert row["device"] == "DWG To PDF.pc3" + assert row["center"] is True + assert len(row["margins_mm"]) == 4 + + width, height = await _mediabox_after_export(backend, tmp_path, SHEET) + assert abs(width - 420.0) <= 0.5 and abs(height - 297.0) <= 0.5 + + +async def test_apply_ansi_b(backend, tmp_path): + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ANSI_B", "landscape", scale="1:1")) + row = (await backend.page_setup_list(SHEET))[0] + assert row["paper"] == "ANSI_B" and row["size_mm"] == [432.0, 279.0] + assert row["scale"] == "1:1" + width, height = await _mediabox_after_export(backend, tmp_path, SHEET) + assert abs(width - 432.0) <= 0.5 and abs(height - 279.0) <= 0.5 + + +async def test_apply_portrait_a4(backend, tmp_path): + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A4", "portrait")) + row = (await backend.page_setup_list(SHEET))[0] + assert row["orientation"] == "portrait" and row["size_mm"] == [210.0, 297.0] + width, height = await _mediabox_after_export(backend, tmp_path, SHEET) + assert abs(width - 210.0) <= 0.5 and abs(height - 297.0) <= 0.5 + + +async def test_apply_materialises_the_default_flags_before_setting_centre(backend): + """Measured: set_flag_state starts from 0 on a fresh layout, so a naive + plot_centered(True) writes 4 and drops lineweights/plot-styles/viewports-first.""" + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=True)) + flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) + assert flags & 4, "centre-plot bit" + assert flags & 32, "plot with plot styles" + assert flags & 128, "plot entity lineweights" + assert flags & 512, "draw viewports first" + assert flags & 16, "use standard scale (fit)" + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=False)) + flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) + assert not flags & 4 and flags & 32 and flags & 128 and flags & 512 + + +async def test_custom_scale_clears_the_standard_scale_bit_and_writes_the_ratio(backend): + await _sheet(backend) + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:5")) + dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf + assert not int(dxf.plot_layout_flags) & 16 + assert (float(dxf.scale_numerator), float(dxf.scale_denominator)) == (1.0, 5.0) + assert (await backend.page_setup_list(SHEET))[0]["scale"] == "1:5" + + +async def test_apply_keeps_the_viewports(backend): + await _sheet(backend) + await backend.viewport_create(SHEET, 210, 148.5, 260, 180, 0, 0, scale=1.0) + before = (await backend.viewport_list(SHEET))["count"] + await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + assert (await backend.viewport_list(SHEET))["count"] == before + + +async def test_changed_reports_only_what_moved(backend): + await _sheet(backend) + first = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + assert first["changed"], "a fresh layout differs from an A3 setup somewhere" + second = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + assert second["changed"] == {} + third = await backend.page_setup_apply( + SHEET, resolve_page_setup("ISO_A3", plot_style="acad.ctb") + ) + assert third["changed"] == {"plot_style": ["monochrome.ctb", "acad.ctb"]} + + +async def test_unknown_ctb_is_written_and_flagged(backend): + await _sheet(backend) + result = await backend.page_setup_apply( + SHEET, resolve_page_setup("ISO_A3", plot_style="company.ctb") + ) + assert result["plot_style_known"] is False + assert (await backend.page_setup_list(SHEET))[0]["plot_style"] == "company.ctb" + + +async def test_refusals_happen_before_any_write(backend): + await _sheet(backend) + dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf + before = dxf.get("paper_size") + with pytest.raises(ValueError, match="Model"): + await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3")) + with pytest.raises(ValueError, match="Nope"): + await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3")) + with pytest.raises(ValueError, match="canonical_media_name"): + await backend.page_setup_apply(SHEET, {"paper": "ISO_A3"}) + assert dxf.get("paper_size") == before + with pytest.raises(ValueError, match="Nope"): + await backend.page_setup_list("Nope") + + +async def test_list_covers_every_paper_layout_and_skips_model(backend): + await _sheet(backend, "A") + await _sheet(backend, "B") + names = [row["layout"] for row in await backend.page_setup_list()] + assert "Model" not in names and {"A", "B"} <= set(names) + + +async def test_plot_style_list_headless_is_the_catalog(backend): + rows = await backend.plot_style_list() + assert [row["name"] for row in rows] == list(CTB_CATALOG) + assert all(row["source"] == "catalog" and row["installed"] is None for row in rows) + + +async def test_dwt_write_capability_is_declared_false_headlessly(backend): + assert backend.capabilities().to_dict()["features"]["dwt_write"]["supported"] is False + + +# ── live engine, against a fake ActiveX surface ────────────────────────────── + +_MEDIA_RE = re.compile(r"\((\d+\.\d+)_x_(\d+\.\d+)_(MM|Inches)\)") + + +class _FakeLayout: + """Records every property set and method call, in order.""" + + def __init__(self, name, media_names): + object.__setattr__(self, "log", []) + object.__setattr__(self, "_media", tuple(media_names)) + object.__setattr__( + self, + "_props", + { + "Name": name, + "ConfigName": "None", + "CanonicalMediaName": "ISO_A4_(210.00_x_297.00_MM)", + "StyleSheet": "", + "PlotType": 5, + "StandardScale": 16, + "UseStandardScale": True, + "PlotRotation": 0, + "PaperUnits": 1, + "CenterPlot": False, + "PlotWithPlotStyles": True, + }, + ) + + def __getattr__(self, attr): + props = object.__getattribute__(self, "_props") + if attr in props: + return props[attr] + raise AttributeError(attr) + + def __setattr__(self, attr, value): + self._props[attr] = value + self.log.append(("set", attr, value)) + + def RefreshPlotDeviceInfo(self): + self.log.append(("call", "RefreshPlotDeviceInfo")) + + def GetCanonicalMediaNames(self): + self.log.append(("call", "GetCanonicalMediaNames")) + return self._media + + def GetPaperSize(self): + match = _MEDIA_RE.search(self._props["CanonicalMediaName"]) + width, height = float(match.group(1)), float(match.group(2)) + if match.group(3) == "Inches": + width, height = width * 25.4, height * 25.4 + return (width, height) + + def GetPaperMargins(self): + return ((7.5, 20.0), (7.5, 20.0)) + + def GetCustomScale(self): + return (1.0, 1.0) + + def SetCustomScale(self, numerator, denominator): + self.log.append(("call", "SetCustomScale", numerator, denominator)) + + +class _FakeLayouts: + def __init__(self, layouts): + self.layouts = list(layouts) + + @property + def Count(self): + return len(self.layouts) + + def Item(self, key): + if isinstance(key, int): + return self.layouts[key] + for layout in self.layouts: + if layout.Name == key: + return layout + raise RuntimeError(f"no layout {key}") + + +MEDIA = ( + "ISO_A3_(420.00_x_297.00_MM)", + "ISO_A4_(210.00_x_297.00_MM)", + "ANSI_B_(17.00_x_11.00_Inches)", +) + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + document = types.SimpleNamespace( + Layouts=_FakeLayouts([_FakeLayout("Model", MEDIA), _FakeLayout("Layout1", MEDIA)]) + ) + monkeypatch.setattr(module, "_acad_doc", lambda: document) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, document + + +async def test_com_apply_issues_the_property_sequence(com_backend): + backend, document = com_backend + result = await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", "landscape")) + assert result["ok"] is True and result["plot_style_known"] is True + layout = document.Layouts.Item("Layout1") + assert layout.log == [ + ("call", "RefreshPlotDeviceInfo"), + ("set", "ConfigName", "DWG To PDF.pc3"), + ("call", "RefreshPlotDeviceInfo"), + ("call", "GetCanonicalMediaNames"), + ("set", "CanonicalMediaName", "ISO_A3_(420.00_x_297.00_MM)"), + ("set", "PaperUnits", 1), + ("set", "PlotRotation", 0), + ("set", "StyleSheet", "monochrome.ctb"), + ("set", "PlotWithPlotStyles", True), + ("set", "PlotType", 5), + ("set", "StandardScale", 0), + ("set", "UseStandardScale", True), + ("set", "CenterPlot", True), + ] + assert result["changed"]["paper"] == ["ISO_A4", "ISO_A3"] + assert result["changed"]["device"] == ["None", "DWG To PDF.pc3"] + assert result["changed"]["center"] == [False, True] + + +async def test_com_apply_custom_scale_uses_set_custom_scale(com_backend): + backend, document = com_backend + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", scale="5:1")) + log = document.Layouts.Item("Layout1").log + assert ("call", "SetCustomScale", 5.0, 1.0) in log + assert ("set", "UseStandardScale", False) in log + assert not any(entry[:2] == ("set", "StandardScale") for entry in log) + + +async def test_com_apply_refuses_margins_before_any_write(com_backend): + backend, document = com_backend + with pytest.raises(ValueError, match="margins_mm"): + await backend.page_setup_apply( + "Layout1", resolve_page_setup("ISO_A3", margins_mm=[10, 10, 10, 10]) + ) + assert document.Layouts.Item("Layout1").log == [] + + +async def test_com_apply_restores_the_device_when_the_media_name_is_unknown(com_backend): + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + object.__setattr__(layout, "_media", ("ISO_A4_(210.00_x_297.00_MM)",)) + with pytest.raises(ValueError, match=r"ISO_A3_\(420.00_x_297.00_MM\)"): + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3")) + assert layout.ConfigName == "None", "the device write is rolled back" + assert layout.CanonicalMediaName == "ISO_A4_(210.00_x_297.00_MM)" + + +async def test_com_apply_refuses_model_and_unknown_layouts(com_backend): + backend, _ = com_backend + with pytest.raises(ValueError, match="Model"): + await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3")) + with pytest.raises(ValueError, match="Nope"): + await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3")) + + +async def test_com_list_reads_the_properties(com_backend): + backend, _ = com_backend + rows = await backend.page_setup_list() + assert [row["layout"] for row in rows] == ["Layout1"] + row = rows[0] + assert row["paper"] == "ISO_A4" and row["size_mm"] == [210.0, 297.0] + assert row["orientation"] == "portrait" + assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5] + assert row["scale"] == "1:1" and row["plot_area"] == "layout" + assert row["device"] == "None" and row["center"] is False + + +async def test_com_declares_dwt_write(): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends.com_backend import ComBackend + + assert ComBackend().capabilities().to_dict()["features"]["dwt_write"]["supported"] is True