fix(task-17): COM page setup reads mm as mm, 1:5 takes the custom route, and a refused apply puts the media back
Three live-measured behaviours of AutoCAD 2026 that the fake did not replay: * GetPaperSize / GetPaperMargins answer in millimetres whatever PaperUnits says; the 25.4 factor listed every imperial layout as [10668.0, 7543.8] and lost the paper name. No factor now; the fake pins PaperUnits=0. * Layout.StandardScale = 19 (ac1_5) is refused with "Invalid input" in every condition; '1:5' is dropped from ACTIVEX_PLOT_SCALE so it goes through SetCustomScale(1, 5) like 5:1 already did. * Writing ConfigName replaces CanonicalMediaName with the new device's default, and writing the old device back does not bring the media back — the unknown-media refusal and a late refusal both left the layout on the old device's default paper while the error claimed it was restored. The device write now snapshots media / units / rotation and journals one combined undo; the read-back after every unwind is compared to the read-back before the first write and the message names what did not go back instead of asserting it did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
20ca39d28a
commit
376dc812a5
+56
-8
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+120
-5
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user