diff --git a/backends/com_backend.py b/backends/com_backend.py index 17809bf..7010cb1 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -1968,12 +1968,34 @@ class ComBackend(AutoCADBackend): # over None), an extents plot writes it *after* PlotType, and a stale True # is cleared *before* PlotType moves to acLayout. Every write is journaled # and unwound in reverse when AutoCAD refuses one, so a failed apply leaves - # the layout as it was. Fake-tested in tests/test_page_setup.py (the fake - # enforces the CenterPlot rule); executed live by + # the layout as it was — and the read-back after the unwind is compared to + # the read-back before the first write, so a value that did not go back is + # named rather than claimed restored. + # + # Two behaviours measured live (AutoCAD 2026, 2026-09-17) that the ActiveX + # Reference does not document: + # * 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. + # * 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 / + # ISO_A3 -> Microsoft Print to PDF -> 'psk:ISOA4' -> DWG To PDF -> + # 'ANSI_A_(11.00_x_8.50_Inches)'). The device write therefore journals + # one combined undo: device back, then the media / units / rotation + # snapshotted before the switch re-applied. + # + # Fake-tested in tests/test_page_setup.py (the fake enforces the CenterPlot + # 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 + #: evidence-before-trust basis) — snapshotted before the device switch and + #: put back by its undo. + _DEVICE_SIDE_EFFECTS = ("CanonicalMediaName", "PaperUnits", "PlotRotation") @staticmethod def _com_page_setup_row(layout) -> dict: @@ -1992,18 +2014,18 @@ class ComBackend(AutoCADBackend): log.debug("Layout.%s read failed: %s", attr, exc) return None - units = _get("PaperUnits") - factor = 25.4 if units == 0 else 1.0 + # GetPaperSize / GetPaperMargins are millimetres regardless of + # PaperUnits (measured live; see the block comment above) — no factor. size = None try: width, height = layout.GetPaperSize() - size = [round(float(width) * factor, 2), round(float(height) * factor, 2)] + size = [round(float(width), 2), round(float(height), 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)] + margins = [round(float(v), 3) for v in (top, bottom, left, right)] except Exception as exc: log.debug("Layout.GetPaperMargins failed: %s", exc) rotation = _get("PlotRotation") or 0 @@ -2094,10 +2116,27 @@ class ComBackend(AutoCADBackend): target.SetCustomScale(numerator, denominator) journal.append((lambda: target.SetCustomScale(*previous), "SetCustomScale")) + def _set_device(device: str): + # A device switch moves the media (and can move units / + # rotation) as a side effect, and switching back does not + # move them back — so the undo re-applies what was there. + snapshot = {attr: getattr(target, attr) for attr in self._DEVICE_SIDE_EFFECTS} + previous = target.ConfigName + target.ConfigName = device + + def _undo(): + target.ConfigName = previous + target.RefreshPlotDeviceInfo() + for attr, value in snapshot.items(): + if getattr(target, attr) != value: + setattr(target, attr, value) + + journal.append((_undo, "ConfigName")) + step = "ConfigName" try: target.RefreshPlotDeviceInfo() - _set("ConfigName", resolved["device"]) + _set_device(resolved["device"]) target.RefreshPlotDeviceInfo() media = resolved["canonical_media_name"] names = [str(n) for n in (target.GetCanonicalMediaNames() or ())] @@ -2144,14 +2183,23 @@ class ComBackend(AutoCADBackend): _set("CenterPlot", bool(center)) except Exception as exc: failed = self._com_unwind_page_setup(journal) + # The unwind's own word is not evidence: read the layout back + # and name every field that is not what it was. + failed += [ + key + for key, value in self._com_page_setup_row(target).items() + if key != "layout" and value != before.get(key) and key not in failed + ] if isinstance(exc, ValueError) and not failed: - raise + raise # our own refusal (unknown media), nothing left changed restored = ( f"layout {target.Name!r} restored to its previous page setup" if not failed else f"layout {target.Name!r} could NOT be fully restored " f"(still changed: {', '.join(failed)})" ) + if isinstance(exc, ValueError): + raise ValueError(f"{exc}; {restored}") from exc raise ValueError( f"page_setup_apply: AutoCAD refused Layout.{step} ({exc}); {restored}" ) from exc diff --git a/engineering/standards/papers.py b/engineering/standards/papers.py index 0f97c6c..b3fb960 100644 --- a/engineering/standards/papers.py +++ b/engineering/standards/papers.py @@ -8,8 +8,13 @@ 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. + ac1_1=16, ac1_2=17, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27, ac2_1=28, + ac10_1=31, acScaleToFit=0. The typelib also declares ``ac1_5=19``, but + AutoCAD 2026 refuses ``Layout.StandardScale = 19`` with "Invalid input" in + every condition tried (PaperUnits mm and inches, UseStandardScale True and + False; measured live 2026-09-17) — its standard-scale list has no 1:5, the + same gap as DXF code 75 — so 1:5 is *not* in the table and takes the custom + route on the live engine too (``SetCustomScale(1, 5)`` reads back ``1:5``). A scale without a code on an engine goes through the custom numerator / denominator on that engine, and the read-back reports the same label either way. @@ -131,11 +136,11 @@ DXF_STANDARD_SCALE_TYPE: dict[str, int] = { _DXF_SCALE_LABEL = {code: label for label, code in DXF_STANDARD_SCALE_TYPE.items()} #: ActiveX ``AcPlotScale`` (AutoCAD 2026 typelib). Labels missing here use SetCustomScale. +#: No ``"1:5"``: the typelib's ``ac1_5 = 19`` is refused by AutoCAD 2026 (module docstring). ACTIVEX_PLOT_SCALE: dict[str, int] = { "fit": 0, "1:1": 16, "1:2": 17, - "1:5": 19, "1:10": 21, "1:20": 23, "1:50": 26, diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py index 1e93940..0e8fd8c 100644 --- a/tests/test_page_setup.py +++ b/tests/test_page_setup.py @@ -109,7 +109,9 @@ def test_resolve_page_setup_accepts_lowercase_and_short_paper_names(): 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 + # The typelib declares ac1_5 = 19, but AutoCAD 2026 refuses + # StandardScale = 19 ("Invalid input", measured live): custom route. + assert one_to_five["activex_standard_scale"] is None 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 @@ -420,13 +422,24 @@ class _FakeLayout: be set to True on a layout object whose PlotType property is set to acLayout." ``refuse`` names properties whose write raises, the way a live AutoCAD refuses a value it cannot take. + + Replays two behaviours measured live on AutoCAD 2026 (2026-09-17): + + * ``GetPaperSize`` / ``GetPaperMargins`` answer in millimetres whatever + ``PaperUnits`` says (ANSI_B under PaperUnits 0 and 1 -> (431.8, 279.4)). + * 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 back. + ``media_names`` is therefore either one tuple (every device carries it) + or ``{device: tuple}``; a device missing from the dict carries nothing. """ AC_LAYOUT = 5 def __init__(self, name, media_names, refuse=()): object.__setattr__(self, "log", []) - object.__setattr__(self, "_media", tuple(media_names)) + media = media_names if isinstance(media_names, dict) else {None: tuple(media_names)} + object.__setattr__(self, "_media", {k: tuple(v) for k, v in media.items()}) object.__setattr__(self, "_refuse", tuple(refuse)) object.__setattr__( self, @@ -459,6 +472,14 @@ class _FakeLayout: raise RuntimeError("CenterPlot cannot be True while PlotType is acLayout") self._props[attr] = value self.log.append(("set", attr, value)) + if attr == "ConfigName": + names = self._device_media() + if names and self._props["CanonicalMediaName"] not in names: + self._props["CanonicalMediaName"] = names[0] # the device's default + + def _device_media(self): + media = object.__getattribute__(self, "_media") + return media.get(self._props["ConfigName"], media.get(None, ())) def snapshot(self) -> dict: return dict(object.__getattribute__(self, "_props")) @@ -468,13 +489,14 @@ class _FakeLayout: def GetCanonicalMediaNames(self): self.log.append(("call", "GetCanonicalMediaNames")) - return self._media + return self._device_media() def GetPaperSize(self): + # Millimetres whatever PaperUnits says (measured live, see the class docstring). 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 + width, height = round(width * 25.4, 2), round(height * 25.4, 2) return (width, height) def GetPaperMargins(self): @@ -631,13 +653,90 @@ async def test_com_apply_refuses_margins_before_any_write(com_backend): 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)",)) + object.__setattr__(layout, "_media", {None: ("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)" +PDF_DEVICE = "DWG To PDF.pc3" +MS_DEVICE = "Microsoft Print to PDF" +ISO_A3_MEDIA = "ISO_A3_(420.00_x_297.00_MM)" +#: Two devices whose media do not overlap, the way the two PDF drivers on the +#: machine measured: switching to MS moves an ISO_A3 layout to psk:ISOA4, and +#: switching back lands on DWG To PDF's default, not on ISO_A3. +TWO_DEVICES = { + PDF_DEVICE: ("ANSI_A_(11.00_x_8.50_Inches)", ISO_A3_MEDIA), + MS_DEVICE: ("psk:ISOA4", "ISO_A0_(1189.00_x_841.00_MM)"), +} + + +def _layout_on_pdf_device(document, **kwargs): + layout = _FakeLayout("Layout1", TWO_DEVICES, **kwargs) + layout.ConfigName = PDF_DEVICE + layout.CanonicalMediaName = ISO_A3_MEDIA + del layout.log[:] + document.Layouts.layouts[1] = layout + return layout + + +async def test_com_apply_puts_the_media_back_when_the_device_switch_replaced_it(com_backend): + """Measured live: refusing a media unknown on the new device used to leave + the layout on the old device with that device's *default* media (ANSI_A), + not the ISO_A3 it had — while the error said it was restored.""" + backend, document = com_backend + layout = _layout_on_pdf_device(document) + before = layout.snapshot() + with pytest.raises(ValueError, match="has no media") as info: + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A2", device=MS_DEVICE)) + assert "could NOT" not in str(info.value) + assert layout.snapshot() == before, "device back, then the original media re-applied" + assert (await backend.page_setup_list("Layout1"))[0]["paper"] == "ISO_A3" + + +async def test_com_apply_late_refusal_unwinds_through_the_device_switch(com_backend): + """The media journaled *after* the device switch is the new device's + default; only the pre-switch snapshot can put ISO_A3 back.""" + backend, document = com_backend + layout = _layout_on_pdf_device(document, refuse=("PlotWithPlotStyles",)) + before = layout.snapshot() + with pytest.raises(ValueError, match="PlotWithPlotStyles") as info: + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A0", device=MS_DEVICE)) + assert "restored to its previous page setup" in str(info.value) + assert layout.snapshot() == before + sets = [entry for entry in layout.log if entry[0] == "set"] + assert sets[-2:] == [ + ("set", "ConfigName", PDF_DEVICE), + ("set", "CanonicalMediaName", ISO_A3_MEDIA), + ] + + +class _StickyMediaLayout(_FakeLayout): + """Accepts every write but silently drops CanonicalMediaName once the device + has been switched back — what a driver that ignores the write looks like.""" + + def __setattr__(self, attr, value): + if attr == "CanonicalMediaName" and self._props["ConfigName"] == PDF_DEVICE and self.log: + return + super().__setattr__(attr, value) + + +async def test_com_apply_reports_what_the_unwind_could_not_put_back(com_backend): + backend, document = com_backend + layout = _StickyMediaLayout("Layout1", TWO_DEVICES) + layout.ConfigName = PDF_DEVICE + object.__getattribute__(layout, "_props")["CanonicalMediaName"] = ISO_A3_MEDIA + del layout.log[:] + document.Layouts.layouts[1] = layout + with pytest.raises(ValueError, match="has no media") as info: + await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A2", device=MS_DEVICE)) + message = str(info.value) + assert "could NOT be fully restored" in message + assert "canonical_media_name" in message and "paper" in message + assert layout.ConfigName == PDF_DEVICE and layout.CanonicalMediaName != ISO_A3_MEDIA + + async def test_com_apply_refuses_model_and_unknown_layouts(com_backend): backend, _ = com_backend with pytest.raises(ValueError, match="Model"): @@ -659,6 +758,22 @@ async def test_com_list_reads_the_properties(com_backend): assert row["center"] is None, "acLayout: CenterPlot is not applicable" +async def test_com_list_never_scales_the_paper_by_paper_units(com_backend): + """GetPaperSize / GetPaperMargins are millimetres under PaperUnits=0 too + (measured live) — the 25.4 factor listed every imperial layout as + [10668.0, 7543.8] and lost the paper name.""" + backend, document = com_backend + layout = document.Layouts.Item("Layout1") + layout.PaperUnits = 0 + layout.CanonicalMediaName = "ANSI_B_(17.00_x_11.00_Inches)" + row = (await backend.page_setup_list("Layout1"))[0] + assert row["size_mm"] == [431.8, 279.4] and row["paper"] == "ANSI_B" + assert row["orientation"] == "landscape" + assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5] + layout.CanonicalMediaName = "ISO_A3_(420.00_x_297.00_MM)" + assert (await backend.page_setup_list("Layout1"))[0]["size_mm"] == [420.0, 297.0] + + async def test_com_list_reports_center_for_an_extents_plot(com_backend): backend, document = com_backend layout = document.Layouts.Item("Layout1")