diff --git a/backends/com_backend.py b/backends/com_backend.py index 7010cb1..d0373da 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1977,6 +1977,14 @@ class ComBackend(AutoCADBackend): # * GetPaperSize / GetPaperMargins answer in millimetres whatever # PaperUnits says (ISO_A3 under PaperUnits=0 -> (420.0, 297.0); ANSI_B # under 0 and 1 -> (431.8, 279.4) both times). They are never scaled. + # PaperUnits itself still governs what one paper-space unit means at + # the plot scale, so the row reports it (``paper_units``) and an apply + # that moves it says so in ``changed`` and ``warnings``. + # * Both answer in the *media's* frame (the PLOTSETTINGS fields the DXF + # row reads); under PlotRotation 1/3 the row turns size and margins + # together through ``papers.turned_sheet``, the one mapping the headless + # row and renderer use. The rotated read-back is not yet measured live + # (scripts/smoke_settings_com.py, Task 24, is where it gets measured). # * Writing ConfigName replaces CanonicalMediaName with the new device's # default when the current media does not exist on it, and writing the # old device back does *not* bring the old media with it (DWG To PDF / @@ -1989,7 +1997,6 @@ class ComBackend(AutoCADBackend): # rule and replays both measurements); executed live by # scripts/smoke_settings_com.py (Task 24). - _AC_MILLIMETERS = 1 _AC_NO_ROTATION = 0 #: Layout properties a ConfigName write can move as a side effect (measured #: live for CanonicalMediaName; units / rotation are re-applied on the same @@ -2004,7 +2011,9 @@ class ComBackend(AutoCADBackend): activex_scale_label, center_applies, paper_from_size, + paper_units_name, scale_label, + turned_sheet, ) def _get(attr): @@ -2029,7 +2038,11 @@ class ComBackend(AutoCADBackend): except Exception as exc: log.debug("Layout.GetPaperMargins failed: %s", exc) rotation = _get("PlotRotation") or 0 - if size and int(rotation) in (1, 3): + if size and margins: + size, margins = turned_sheet(size[0], size[1], margins, rotation) + size = [round(v, 2) for v in size] + margins = [round(v, 3) for v in margins] + elif size and int(rotation) in (1, 3): size = [size[1], size[0]] if _get("UseStandardScale"): scale = activex_scale_label(_get("StandardScale") or 0) @@ -2055,6 +2068,7 @@ class ComBackend(AutoCADBackend): "plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type), "device": _get("ConfigName"), "margins_mm": margins, + "paper_units": paper_units_name(_get("PaperUnits")), "center": bool(_get("CenterPlot")) if center_applies(plot_type) else None, } @@ -2088,7 +2102,11 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) async def page_setup_apply(self, layout: str, setup: dict) -> dict: - from engineering.standards.papers import require_page_setup + from engineering.standards.papers import ( + PAPER_UNITS, + page_setup_warnings, + require_page_setup, + ) resolved = require_page_setup(setup) if resolved["margins_mm"] is not None: @@ -2149,8 +2167,11 @@ class ComBackend(AutoCADBackend): ) step = "CanonicalMediaName" _set("CanonicalMediaName", media) - step = "PaperUnits" - _set("PaperUnits", self._AC_MILLIMETERS) + if resolved["paper_units"] is not None: + # None keeps the layout's units — what AutoCAD's own Page + # Setup does when a media is chosen. + step = "PaperUnits" + _set("PaperUnits", PAPER_UNITS[resolved["paper_units"]]) step = "PlotRotation" _set("PlotRotation", self._AC_NO_ROTATION) step = "StyleSheet" @@ -2214,6 +2235,7 @@ class ComBackend(AutoCADBackend): "layout": target.Name, "applied": dict(resolved), "changed": changed, + "warnings": page_setup_warnings(changed), "plot_style_known": bool(resolved["plot_style_known"]), "viewports_kept": True, "backend": "com", diff --git a/backends/contracts/pagesetup.py b/backends/contracts/pagesetup.py index 9226d8f..35827c4 100644 --- a/backends/contracts/pagesetup.py +++ b/backends/contracts/pagesetup.py @@ -16,12 +16,20 @@ class PageSetupContract(ABC): 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 + margins_mm: [top, bottom, left, right], paper_units, center}``. + ``size_mm`` and ``margins_mm`` are the sheet *as plotted*: under + ``plot_rotation`` 1/3 (AutoCAD's landscape-on-portrait-media + convention) both are turned together, through the one mapping the + renderer plots by (``papers.turned_sheet``), so the printable area a + row implies is the area the same engine plots. ``paper`` is the catalogue name when the size matches one within 0.5 mm, otherwise the - media name as stored. ``center`` is a bool for an extents plot and - ``None`` for a layout plot, where centring is not applicable (AutoCAD - greys it out; ActiveX refuses ``CenterPlot = True`` under - ``acLayout``). Raises ``ValueError`` for an unknown layout. + media name as stored. ``paper_units`` (``"mm"`` | ``"inches"`` | + ``"pixels"``) is what one paper-space unit means at the plot scale; + sizes and margins are millimetres whatever it says. ``center`` is a + bool for an extents plot and ``None`` for a layout plot, where + centring is not applicable (AutoCAD greys it out; ActiveX refuses + ``CenterPlot = True`` under ``acLayout``). Raises ``ValueError`` for + an unknown layout. ``Model`` is never listed: model space is plotted through ``drawing_export_pdf(layout=None)`` and carries no sheet. """ @@ -33,9 +41,19 @@ class PageSetupContract(ABC): ``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 + warnings: [str], plot_style_known, viewports_kept}``. ``changed`` is + computed from the read-back before and after (the rows + ``page_setup_list`` reports), so re-applying the same setup reports + ``{}``. The write resets ``plot_rotation`` to 0 (orientation rides on + the media's width/height); headlessly, with no ``margins_mm`` asked + for, the turned margins are re-written un-turned so the sheet keeps + the margins it had, and on the live engine (margins come from the + .pc3) the ones that moved show in ``changed``. ``setup["paper_units"]`` + (``"mm"`` default, ``"inches"``, or ``None`` to keep the layout's + units as AutoCAD's own Page Setup does) is written when given; a move + rescales every paper-space unit by 25.4 at the plot scale and is named + in ``changed`` *and* ``warnings`` (``papers.page_setup_warnings``). + Refuses ``Model``, an unknown layout, and a ``setup`` missing a resolved key, before anything is written. A write the engine refuses part-way through is unwound (every landed value put back) before the ``ValueError`` names the refused property. Never deletes viewports. diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 42fbdd8..61c09e6 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2340,23 +2340,39 @@ class EzdxfBackend(AutoCADBackend): return doc.layouts.get(resolved) @staticmethod - def _page_setup_row(layout) -> dict: + def _stored_sheet(dxf) -> tuple[list[float], list[float]]: + """``(size_mm, margins_mm)`` of the sheet *as plotted*, from the media + as stored: ``papers.turned_sheet`` over PLOTSETTINGS 40-47 and + ``plot_rotation``. DXF stores every paper size and margin in + millimetres, whatever ``plot_paper_units`` says (ezdxf's + acdb_plot_settings, verified). ``margins_mm`` is [top, bottom, left, right]. + """ + from engineering.standards.papers import turned_sheet + + return turned_sheet( + dxf.paper_width, + dxf.paper_height, + (dxf.top_margin, dxf.bottom_margin, dxf.left_margin, dxf.right_margin), + dxf.plot_rotation, + ) + + @classmethod + def _page_setup_row(cls, layout) -> dict: from engineering.standards.papers import ( PLOT_TYPE_NAMES, center_applies, dxf_scale_label, paper_from_size, + paper_units_name, 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 + # The sheet as plotted: size *and* margins turned by plot_rotation + # (the same mapping _paper_frame plots by). Swapping only the size + # reported a 297 x 210 sheet with its portrait margins. + (width, height), margins = cls._stored_sheet(dxf) media = str(dxf.paper_size) numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator) if flags & layout.USE_STANDARD_SCALE: @@ -2374,12 +2390,9 @@ class EzdxfBackend(AutoCADBackend): "scale": scale, "plot_area": PLOT_TYPE_NAMES.get(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), - ], + "margins_mm": [round(v, 3) for v in margins], + # What one paper-space unit means at the plot scale (DXF group 72). + "paper_units": paper_units_name(dxf.plot_paper_units), # A layout plot has no centring (AutoCAD greys it out; ActiveX # refuses CenterPlot=True under acLayout): None, not a bit nobody honours. "center": bool(flags & layout.PLOT_CENTERED) if center_applies(plot_type) else None, @@ -2460,30 +2473,18 @@ class EzdxfBackend(AutoCADBackend): shrank the axes box to 0.707 and cut everything past x = 210 minus the margin (measured: a 277 mm frame lost its right 27%, with ``ok: True`` and a MediaBox that read as correct). The margins turn with the sheet, - mapped the way ezdxf's own ``Page.from_dxf_layout`` maps them. What - the headless renderer still does not do is rotate the *content* on the - media (reported as ``rotation_applied``, not hidden). + mapped the way ezdxf's own ``Page.from_dxf_layout`` maps them — + ``papers.turned_sheet``, the one mapping ``page_setup_list`` reports + by, so the row and the plot never disagree. What the headless renderer + still does not do is rotate the *content* on the media (reported as + ``rotation_applied``, not hidden). """ from engineering.standards.papers import PLOT_TYPE_NAMES, scale_label 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 - top, right = float(dxf.top_margin), float(dxf.right_margin) - bottom, left = float(dxf.bottom_margin), float(dxf.left_margin) - # (top, right, bottom, left) after the turn — ezdxf's Page.from_dxf_layout. - if rotation == 1: - sheet_w, sheet_h = height, width - top, right, bottom, left = right, bottom, left, top - elif rotation == 2: - sheet_w, sheet_h = width, height - top, right, bottom, left = bottom, left, top, right - elif rotation == 3: - sheet_w, sheet_h = height, width - top, right, bottom, left = left, top, right, bottom - else: - sheet_w, sheet_h = width, height + (sheet_w, sheet_h), (top, bottom, left, right) = cls._stored_sheet(dxf) offset_x = float(dxf.plot_origin_x_offset) offset_y = float(dxf.plot_origin_y_offset) printable_w = max(sheet_w - left - right, 1e-9) @@ -2576,7 +2577,11 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) async def page_setup_apply(self, layout: str, setup: dict) -> dict: - from engineering.standards.papers import require_page_setup + from engineering.standards.papers import ( + PAPER_UNITS, + page_setup_warnings, + require_page_setup, + ) resolved = require_page_setup(setup) @@ -2592,17 +2597,26 @@ class EzdxfBackend(AutoCADBackend): 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"]) + # Orientation rides on width/height, as AutoCAD names media, so + # plot_rotation goes to 0. The stored margins only meant what + # they meant *through* the old turn: with none asked for, the + # sheet keeps the margins it had (the turned ones are written as + # the unturned ones), instead of silently swapping to the media's + # portrait margins with nothing in ``changed``. + margins = resolved["margins_mm"] + if margins is None: + margins = self._stored_sheet(dxf)[1] # turned, un-rounded 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 + if resolved["paper_units"] is not None: + dxf.plot_paper_units = PAPER_UNITS[resolved["paper_units"]] + dxf.plot_rotation = 0 + top, bottom, left, right = (float(v) for v in margins) + 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 @@ -2639,6 +2653,7 @@ class EzdxfBackend(AutoCADBackend): "layout": target.name, "applied": dict(resolved), "changed": changed, + "warnings": page_setup_warnings(changed), "plot_style_known": bool(resolved["plot_style_known"]), "viewports_kept": True, "backend": "ezdxf", diff --git a/engineering/standards/papers.py b/engineering/standards/papers.py index b3fb960..2da375a 100644 --- a/engineering/standards/papers.py +++ b/engineering/standards/papers.py @@ -42,6 +42,8 @@ __all__ = [ "ORIENTATIONS", "PAPER_INCHES", "PAPER_SIZES", + "PAPER_UNITS", + "PAPER_UNITS_NAMES", "PLOT_AREAS", "PLOT_TYPE_LAYOUT", "PLOT_TYPE_NAMES", @@ -51,13 +53,16 @@ __all__ = [ "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. @@ -262,6 +267,85 @@ 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:`` 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``. @@ -284,6 +368,7 @@ def resolve_page_setup( 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. @@ -292,6 +377,13 @@ def resolve_page_setup( 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)) @@ -337,6 +429,15 @@ def resolve_page_setup( 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, @@ -354,6 +455,7 @@ def resolve_page_setup( "device": device_name, "margins_mm": margins, "center": center if center_applies(plot_type) else None, + "paper_units": units, } @@ -375,6 +477,7 @@ REQUIRED_SETUP_KEYS: tuple[str, ...] = ( "device", "margins_mm", "center", + "paper_units", ) diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py index 0f5232f..6491bbc 100644 --- a/tests/test_page_setup.py +++ b/tests/test_page_setup.py @@ -18,9 +18,11 @@ from engineering.standards.papers import ( SCALES, canonical_media_name, paper_from_size, + paper_units_name, resolve_page_setup, scale_label, scale_ratio, + turned_sheet, ) from engineering.standards.pdfinfo import read_mediabox @@ -146,6 +148,59 @@ def test_unknown_ctb_is_allowed_but_flagged(): assert setup["plot_style"] == "company.ctb" and setup["plot_style_known"] is False +def test_resolve_page_setup_paper_units_default_mm_and_keep(): + assert resolve_page_setup("ISO_A3")["paper_units"] == "mm" + assert resolve_page_setup("ANSI_B", paper_units="inches")["paper_units"] == "inches" + assert resolve_page_setup("ANSI_B", paper_units="inch")["paper_units"] == "inches" + assert resolve_page_setup("ANSI_B", paper_units=None)["paper_units"] is None, "keep" + with pytest.raises(ValueError, match="paper_units"): + resolve_page_setup("ISO_A3", paper_units="cm") + + +def test_paper_units_name_covers_the_three_dxf_and_activex_codes(): + assert paper_units_name(0) == "inches" and paper_units_name(1) == "mm" + assert paper_units_name(2) == "pixels" + assert paper_units_name(None) is None and paper_units_name(7) == "code:7" + + +@pytest.mark.parametrize( + "rotation, size, margins", + [ + # stored media 210 x 297 with (top, bottom, left, right) = (1, 3, 4, 2), + # i.e. ezdxf's clockwise (top, right, bottom, left) = (1, 2, 3, 4). + (0, [210.0, 297.0], [1.0, 3.0, 4.0, 2.0]), + (1, [297.0, 210.0], [2.0, 4.0, 1.0, 3.0]), + (2, [210.0, 297.0], [3.0, 1.0, 2.0, 4.0]), + (3, [297.0, 210.0], [4.0, 2.0, 3.0, 1.0]), + ], +) +def test_turned_sheet_maps_size_and_margins_like_ezdxf_page_from_dxf_layout( + rotation, size, margins +): + """The one mapping ``page_setup_list``, ``_paper_frame`` and the COM row share. + + Cross-checked against ezdxf's ``Page.from_dxf_layout`` (the renderer's own + reading of PLOTSETTINGS 40-43 under ``plot_rotation``), so the row a + client reads is the sheet the same engine plots. + """ + assert turned_sheet(210, 297, [1, 3, 4, 2], rotation) == (size, margins) + + +def test_turned_sheet_agrees_with_ezdxf_page_from_dxf_layout(): + ezdxf = pytest.importorskip("ezdxf") + from ezdxf.addons.drawing.layout import Page + + doc = ezdxf.new() + lay = doc.layouts.new("Turn") + for rotation in (0, 1, 2, 3): + lay.page_setup(size=(210, 297), margins=(1, 2, 3, 4), units="mm", rotation=rotation) + page = Page.from_dxf_layout(lay.dxf_layout) + size, (top, bottom, left, right) = turned_sheet(210, 297, [1, 3, 4, 2], rotation) + assert size == [page.width, page.height], rotation + assert (page.margins.top, page.margins.bottom) == (top, bottom), rotation + assert (page.margins.left, page.margins.right) == (left, right), rotation + + # ── the PDF reader ────────────────────────────────────────────────────────── @@ -525,6 +580,170 @@ async def test_list_covers_every_paper_layout_and_skips_model(backend): assert "Model" not in names and {"A", "B"} <= set(names) +def _rotated_a4(backend, name: str = "Rot", rotation: int = 1): + """AutoCAD's landscape-on-portrait-media convention: media 210 x 297, + ezdxf clockwise margins (top, right, bottom, left) = (20, 7.5, 20, 7.5), + turned by ``plot_rotation``.""" + lay = backend._doc.layouts.get(name) + lay.page_setup( + size=(210, 297), + margins=(20, 7.5, 20, 7.5), + units="mm", + rotation=rotation, + scale=16, + name="ISO_A4", + device="DWG To PDF.pc3", + ) + return lay + + +async def test_list_turns_the_margins_with_the_sheet(backend): + """The row used to swap ``size_mm`` for rotation 1/3 but report the + margins un-turned: [20, 20, 7.5, 7.5] on a 297 x 210 sheet whose true + margins (ezdxf's Page, and the window ``_paper_frame`` plots) are + [7.5, 7.5, 20, 20] — a printable area of 282 x 170 from the row against + 257 x 195 on paper.""" + await _sheet(backend, "Rot") + lay = _rotated_a4(backend) + row = (await backend.page_setup_list("Rot"))[0] + assert row["size_mm"] == [297.0, 210.0] and row["orientation"] == "landscape" + assert row["margins_mm"] == [7.5, 7.5, 20.0, 20.0] + top, bottom, left, right = row["margins_mm"] + assert (297 - left - right, 210 - top - bottom) == (257.0, 195.0) + + # The same sheet the renderer plots: at 1:1 the axes window starts one + # (turned) left margin before x = 0 and one (turned) bottom margin below y = 0. + frame = backend._paper_frame(lay) + assert frame["size_mm"] == row["size_mm"] + assert frame["xlim"][0] == pytest.approx(-left) and frame["ylim"][0] == pytest.approx(-bottom) + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(297.0) + assert frame["ylim"][1] - frame["ylim"][0] == pytest.approx(210.0) + + +@pytest.mark.parametrize("rotation", [1, 2, 3]) +async def test_apply_on_a_turned_sheet_keeps_the_effective_margins(backend, rotation): + """``page_setup_apply`` writes ``plot_rotation`` 0 (orientation rides on + width/height). With no ``margins_mm`` asked for, the stored margins used + to stay as they were while the turn that gave them their meaning went + away, so the effective margins moved from [7.5, 7.5, 20, 20] to + [20, 20, 7.5, 7.5] (printable 257 x 195 -> 282 x 170) and ``changed`` + said nothing. The stored margins are now re-mapped so the sheet keeps + the margins it had, and the row before equals the row after.""" + await _sheet(backend, "Rot") + lay = _rotated_a4(backend, rotation=rotation) + before = (await backend.page_setup_list("Rot"))[0] + result = await backend.page_setup_apply( + "Rot", resolve_page_setup("ISO_A4", before["orientation"], scale="1:1") + ) + after = (await backend.page_setup_list("Rot"))[0] + assert int(lay.dxf_layout.dxf.plot_rotation) == 0 + assert after["margins_mm"] == before["margins_mm"] + assert after["size_mm"] == before["size_mm"] + assert "margins_mm" not in result["changed"] and "size_mm" not in result["changed"] + dxf = lay.dxf_layout.dxf + top, bottom, left, right = after["margins_mm"] + assert (dxf.top_margin, dxf.bottom_margin, dxf.left_margin, dxf.right_margin) == ( + top, + bottom, + left, + right, + ) + + +async def test_apply_with_margins_on_a_turned_sheet_writes_them_as_asked(backend): + await _sheet(backend, "Rot") + _rotated_a4(backend) + result = await backend.page_setup_apply( + "Rot", resolve_page_setup("ISO_A4", "landscape", margins_mm=[5, 6, 7, 8]) + ) + row = (await backend.page_setup_list("Rot"))[0] + assert row["margins_mm"] == [5.0, 6.0, 7.0, 8.0] + assert result["changed"]["margins_mm"] == [[7.5, 7.5, 20.0, 20.0], [5.0, 6.0, 7.0, 8.0]] + + +def _inch_sheet(backend, name: str = "In"): + """An inch-authored ANSI B sheet (what ANSI_A-E exist for): paper-space + units are inches, so at 1:1 a 16-unit frame spans 16 inches of paper.""" + lay = backend._doc.layouts.get(name) + lay.page_setup( + size=(17, 11), + margins=(0.5, 0.5, 0.5, 0.5), + units="inch", + scale=16, + name="ANSI_B", + device="DWG To PDF.pc3", + ) + lay.add_lwpolyline([(0, 0), (16, 0), (16, 10), (0, 10)], close=True) + return lay + + +async def test_row_reports_the_paper_units(backend): + await _sheet(backend, "In") + lay = _inch_sheet(backend) + row = (await backend.page_setup_list("In"))[0] + assert row["paper_units"] == "inches" + assert row["size_mm"] == [431.8, 279.4] and row["paper"] == "ANSI_B" + assert row["margins_mm"] == [12.7, 12.7, 12.7, 12.7] + lay.dxf_layout.dxf.plot_paper_units = 1 + assert (await backend.page_setup_list("In"))[0]["paper_units"] == "mm" + + +async def test_apply_names_a_paper_units_flip_and_its_consequence(backend): + """The default setup plots in millimetres. On an inch-authored sheet that + is a 25.4x rescale of every paper-space unit: the 16-unit frame used to + go from 406.4 mm to 16 mm of paper with ``changed`` naming only the + media and the plot style — ``ok: True``, ``scale_applied: True``.""" + await _sheet(backend, "In") + lay = _inch_sheet(backend) + result = await backend.page_setup_apply( + "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1") + ) + assert result["changed"]["paper_units"] == ["inches", "mm"] + assert int(lay.dxf_layout.dxf.plot_paper_units) == 1 + assert result["warnings"], "a units flip is never silent" + assert any("inches" in w and "mm" in w and "25.4" in w for w in result["warnings"]) + assert result["applied"]["paper_units"] == "mm" + # Re-applying the same setup moves nothing and warns of nothing. + again = await backend.page_setup_apply( + "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1") + ) + assert again["changed"] == {} and again["warnings"] == [] + + +async def test_apply_can_keep_or_set_the_paper_units(backend): + await _sheet(backend, "In") + lay = _inch_sheet(backend) + kept = await backend.page_setup_apply( + "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units=None) + ) + assert int(lay.dxf_layout.dxf.plot_paper_units) == 0 + assert "paper_units" not in kept["changed"] and kept["warnings"] == [] + assert kept["applied"]["paper_units"] is None + # The catalogue's ANSI_B is 432 x 279 mm (ANSI Y14.1's metric value), so + # at 1:1 under inches the sheet holds 432 / 25.4 units across. + frame = backend._paper_frame(lay) + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(432 / 25.4), "inches at 1:1" + + to_mm = await backend.page_setup_apply( + "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units="mm") + ) + assert to_mm["changed"]["paper_units"] == ["inches", "mm"] and to_mm["warnings"] + frame = backend._paper_frame(lay) + assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(432.0), "432 mm at 1:1" + + back = await backend.page_setup_apply( + "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units="inches") + ) + assert back["changed"]["paper_units"] == ["mm", "inches"] and back["warnings"] + assert int(lay.dxf_layout.dxf.plot_paper_units) == 0 + + +async def test_apply_on_a_mm_sheet_warns_of_nothing(backend): + await _sheet(backend) + result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3")) + assert result["warnings"] == [] and "paper_units" not in result["changed"] + + 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) @@ -914,3 +1133,76 @@ async def test_com_declares_dwt_write(): from backends.com_backend import ComBackend assert ComBackend().capabilities().to_dict()["features"]["dwt_write"]["supported"] is True + + +async def test_com_list_reports_the_paper_units(com_backend): + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + assert (await backend.page_setup_list("Layout1"))[0]["paper_units"] == "mm" + layout.PaperUnits = 0 + assert (await backend.page_setup_list("Layout1"))[0]["paper_units"] == "inches" + + +async def test_com_list_turns_the_margins_with_a_rotated_sheet(com_backend): + """GetPaperMargins reads PLOTSETTINGS 40-43 — the media's own frame, the + fields the DXF row reads — so a rotated sheet turns them the same way.""" + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PlotRotation = 1 + row = (await backend.page_setup_list("Layout1"))[0] + assert row["size_mm"] == [297.0, 210.0] + # fake margins: left 7.5, bottom 20, right 7.5, top 20 -> rotation 1: + # top=right, right=bottom, bottom=left, left=top -> [7.5, 7.5, 20, 20]. + assert row["margins_mm"] == [7.5, 7.5, 20.0, 20.0] + layout.PlotRotation = 0 + assert (await backend.page_setup_list("Layout1"))[0]["margins_mm"] == [20.0, 20.0, 7.5, 7.5] + + +async def test_com_apply_names_a_paper_units_flip(com_backend): + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PaperUnits = 0 + layout.log.clear() + result = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ANSI_B", "landscape", scale="1:1") + ) + assert ("set", "PaperUnits", 1) in layout.log + assert result["changed"]["paper_units"] == ["inches", "mm"] + assert any("25.4" in w for w in result["warnings"]) + again = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ANSI_B", "landscape", scale="1:1") + ) + assert again["changed"] == {} and again["warnings"] == [] + + +async def test_com_apply_keeps_or_sets_the_paper_units(com_backend): + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PaperUnits = 0 + layout.log.clear() + kept = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ANSI_B", "landscape", paper_units=None) + ) + assert not [entry for entry in layout.log if entry[:2] == ("set", "PaperUnits")] + assert layout.PaperUnits == 0 and "paper_units" not in kept["changed"] + assert kept["warnings"] == [] + layout.log.clear() + inches = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ANSI_B", "landscape", paper_units="inches") + ) + assert ("set", "PaperUnits", 0) in layout.log and inches["changed"] == {} + assert inches["warnings"] == [] + + +async def test_com_apply_on_a_rotated_sheet_reports_the_margins_that_moved(com_backend): + """COM cannot write margins (they come from the .pc3), so resetting + PlotRotation on a turned sheet moves the effective margins — and the row + now says so instead of listing only the media.""" + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PlotRotation = 1 + result = await backend.page_setup_apply( + "Layout1", resolve_page_setup("ISO_A3", "landscape", scale="1:1") + ) + assert result["changed"]["margins_mm"] == [[7.5, 7.5, 20.0, 20.0], [20.0, 20.0, 7.5, 7.5]] + assert result["changed"]["size_mm"] == [[297.0, 210.0], [420.0, 297.0]]