fix(task-17): a layout plot never writes CenterPlot, and a refused COM write unwinds every landed one

The ActiveX Reference (CenterPlot Property, acadauto.chm, AutoCAD 2026):
"This property cannot be set to True on a layout object whose PlotType
property is set to acLayout." The shipped sequence wrote PlotType=5 and
then CenterPlot=True for the *default* call, so page_setup_apply(layout,
resolve_page_setup("ISO_A3")) could not succeed live — either COM raised
with nine writes landed and nothing put back, or the result claimed
applied.center=True over a read-back of False.

- resolve_page_setup resolves `center` to None for plot_area="layout"
  (validated as a bool first; kept as the bool for "extents"), and
  `center_applies(plot_type)` carries the rule for both read-backs.
- Both engines: page_setup_list reports `center: None` under acLayout
  instead of a stored flag nothing honours; apply never sets the centre
  bit / CenterPlot for a layout plot (ezdxf clears bit 4, the state
  AutoCAD stores for acLayout; COM clears a stale CenterPlot=True *before*
  PlotType moves to acLayout, and writes CenterPlot for an extents plot
  only *after* PlotType=acExtents).
- COM: every write is journaled and unwound in reverse when AutoCAD
  refuses one; the ValueError names the refused property and says the
  layout was restored (or what could not be). The old device-only
  rollback is the same mechanism now.
- tests: the fake enforces the documented CenterPlot rule (the shipped
  sequence fails under it, which is the reviewer's repro), and can refuse
  a named property; new cases pin the no-write, the ordering both ways,
  the reverse unwind including SetCustomScale, and the None read-backs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-16 21:55:14 +03:00
co-authored by Claude Opus 5
parent 70f18efb4b
commit 20ca39d28a
5 changed files with 292 additions and 49 deletions
+106 -32
View File
@@ -13,6 +13,7 @@ import math
import sys import sys
import time import time
import uuid import uuid
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -1961,7 +1962,14 @@ class ComBackend(AutoCADBackend):
# typelib: AcPlotPaperUnits acMillimeters=1; AcPlotRotation ac0degrees=0; # typelib: AcPlotPaperUnits acMillimeters=1; AcPlotRotation ac0degrees=0;
# AcPlotType acExtents=1 / acLayout=5; AcPlotScale acScaleToFit=0, ac1_1=16 # AcPlotType acExtents=1 / acLayout=5; AcPlotScale acScaleToFit=0, ac1_1=16
# (the full table is engineering.standards.papers.ACTIVEX_PLOT_SCALE). # (the full table is engineering.standards.papers.ACTIVEX_PLOT_SCALE).
# Fake-tested in tests/test_page_setup.py; executed live by # CenterPlot (ActiveX Reference, acadauto.chm): "This property cannot be
# set to True on a layout object whose PlotType property is set to
# acLayout" — so a layout plot never writes it (resolve_page_setup hands
# 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
# scripts/smoke_settings_com.py (Task 24). # scripts/smoke_settings_com.py (Task 24).
_AC_MILLIMETERS = 1 _AC_MILLIMETERS = 1
@@ -1972,6 +1980,7 @@ class ComBackend(AutoCADBackend):
from engineering.standards.papers import ( from engineering.standards.papers import (
PLOT_TYPE_NAMES, PLOT_TYPE_NAMES,
activex_scale_label, activex_scale_label,
center_applies,
paper_from_size, paper_from_size,
scale_label, scale_label,
) )
@@ -2024,7 +2033,7 @@ class ComBackend(AutoCADBackend):
"plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type), "plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type),
"device": _get("ConfigName"), "device": _get("ConfigName"),
"margins_mm": margins, "margins_mm": margins,
"center": bool(_get("CenterPlot")), "center": bool(_get("CenterPlot")) if center_applies(plot_type) else None,
} }
def _com_paper_layout(self, doc, raw: str, operation: str): def _com_paper_layout(self, doc, raw: str, operation: str):
@@ -2071,37 +2080,81 @@ class ComBackend(AutoCADBackend):
doc = _acad_doc() doc = _acad_doc()
target = self._com_paper_layout(doc, layout, "page_setup_apply") target = self._com_paper_layout(doc, layout, "page_setup_apply")
before = self._com_page_setup_row(target) before = self._com_page_setup_row(target)
previous_device = before["device"] # Journal of (undo callable, label) for every write that landed,
target.RefreshPlotDeviceInfo() # unwound in reverse when a later one is refused.
target.ConfigName = resolved["device"] journal: list[tuple[Callable[[], None], str]] = []
target.RefreshPlotDeviceInfo()
media = resolved["canonical_media_name"] def _set(attr: str, value):
names = [str(n) for n in (target.GetCanonicalMediaNames() or ())] previous = getattr(target, attr)
if media not in names: setattr(target, attr, value)
# Roll the one write back before refusing, so a refusal leaves journal.append((lambda: setattr(target, attr, previous), attr))
# the layout as it was.
target.ConfigName = previous_device def _set_custom_scale(numerator: float, denominator: float):
nearest = [n for n in names if n.split("_(")[0] == media.split("_(")[0]] previous = tuple(float(v) for v in target.GetCustomScale())
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.SetCustomScale(numerator, denominator)
target.UseStandardScale = False journal.append((lambda: target.SetCustomScale(*previous), "SetCustomScale"))
target.CenterPlot = bool(resolved["center"])
step = "ConfigName"
try:
target.RefreshPlotDeviceInfo()
_set("ConfigName", resolved["device"])
target.RefreshPlotDeviceInfo()
media = resolved["canonical_media_name"]
names = [str(n) for n in (target.GetCanonicalMediaNames() or ())]
if media not in names:
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: "
f"{', '.join(nearest) or 'none'}; {len(names)} media in total)"
)
step = "CanonicalMediaName"
_set("CanonicalMediaName", media)
step = "PaperUnits"
_set("PaperUnits", self._AC_MILLIMETERS)
step = "PlotRotation"
_set("PlotRotation", self._AC_NO_ROTATION)
step = "StyleSheet"
_set("StyleSheet", resolved["plot_style"])
step = "PlotWithPlotStyles"
_set("PlotWithPlotStyles", True)
center = resolved["center"]
if center is None and self._com_center_plot_is_set(target):
# Clearing is legal under any PlotType; the layout plot
# then carries the flag AutoCAD stores for acLayout.
step = "CenterPlot"
_set("CenterPlot", False)
step = "PlotType"
_set("PlotType", int(resolved["plot_type"]))
code = resolved["activex_standard_scale"]
if code is not None:
step = "StandardScale"
_set("StandardScale", int(code))
step = "UseStandardScale"
_set("UseStandardScale", True)
else:
numerator, denominator = (float(v) for v in resolved["scale_ratio"])
step = "SetCustomScale"
_set_custom_scale(numerator, denominator)
step = "UseStandardScale"
_set("UseStandardScale", False)
if center is not None:
# Extents plot: PlotType is already acExtents, so True is legal.
step = "CenterPlot"
_set("CenterPlot", bool(center))
except Exception as exc:
failed = self._com_unwind_page_setup(journal)
if isinstance(exc, ValueError) and not failed:
raise
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)})"
)
raise ValueError(
f"page_setup_apply: AutoCAD refused Layout.{step} ({exc}); {restored}"
) from exc
after = self._com_page_setup_row(target) after = self._com_page_setup_row(target)
changed = { changed = {
key: [before[key], after[key]] key: [before[key], after[key]]
@@ -2120,6 +2173,27 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync) return await self._run(_sync)
@staticmethod
def _com_center_plot_is_set(layout) -> bool:
"""Whether the layout carries CenterPlot=True (a failed read counts as no)."""
try:
return bool(layout.CenterPlot)
except Exception as exc:
log.debug("Layout.CenterPlot read failed: %s", exc)
return False
@staticmethod
def _com_unwind_page_setup(journal: list[tuple[Callable[[], None], str]]) -> list[str]:
"""Undo every journaled page-setup write in reverse; return what would not go back."""
failed: list[str] = []
for undo, label in reversed(journal):
try:
undo()
except Exception as exc:
log.warning("page_setup_apply: could not restore Layout.%s: %s", label, exc)
failed.append(label)
return failed
async def plot_style_list(self) -> list[dict]: async def plot_style_list(self) -> list[dict]:
# Completed in Task 18 (installed ctb scan); the catalogue rows already answer. # Completed in Task 18 (installed ctb scan); the catalogue rows already answer.
from engineering.standards.papers import CTB_CATALOG from engineering.standards.papers import CTB_CATALOG
+7 -2
View File
@@ -18,7 +18,10 @@ class PageSetupContract(ABC):
orientation, plot_style, scale, plot_area, device, orientation, plot_style, scale, plot_area, device,
margins_mm: [top, bottom, left, right], center}``. ``paper`` is the margins_mm: [top, bottom, left, right], center}``. ``paper`` is the
catalogue name when the size matches one within 0.5 mm, otherwise the catalogue name when the size matches one within 0.5 mm, otherwise the
media name as stored. Raises ``ValueError`` for an unknown layout. 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.
``Model`` is never listed: model space is plotted through ``Model`` is never listed: model space is plotted through
``drawing_export_pdf(layout=None)`` and carries no sheet. ``drawing_export_pdf(layout=None)`` and carries no sheet.
""" """
@@ -33,7 +36,9 @@ class PageSetupContract(ABC):
plot_style_known, viewports_kept}``. ``changed`` is computed from the plot_style_known, viewports_kept}``. ``changed`` is computed from the
read-back before and after, so re-applying the same setup reports read-back before and after, so re-applying the same setup reports
``{}``. Refuses ``Model``, an unknown layout, and a ``setup`` missing a ``{}``. Refuses ``Model``, an unknown layout, and a ``setup`` missing a
resolved key, before anything is written. Never deletes viewports. 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.
""" """
... ...
+9 -2
View File
@@ -2336,6 +2336,7 @@ class EzdxfBackend(AutoCADBackend):
def _page_setup_row(layout) -> dict: def _page_setup_row(layout) -> dict:
from engineering.standards.papers import ( from engineering.standards.papers import (
PLOT_TYPE_NAMES, PLOT_TYPE_NAMES,
center_applies,
dxf_scale_label, dxf_scale_label,
paper_from_size, paper_from_size,
scale_label, scale_label,
@@ -2355,6 +2356,7 @@ class EzdxfBackend(AutoCADBackend):
scale = dxf_scale_label(int(dxf.standard_scale_type), numerator, denominator) scale = dxf_scale_label(int(dxf.standard_scale_type), numerator, denominator)
else: else:
scale = scale_label(numerator, denominator) scale = scale_label(numerator, denominator)
plot_type = int(dxf.plot_type)
return { return {
"layout": layout.name, "layout": layout.name,
"paper": paper_from_size(width, height) or media, "paper": paper_from_size(width, height) or media,
@@ -2363,7 +2365,7 @@ class EzdxfBackend(AutoCADBackend):
"orientation": "landscape" if width >= height else "portrait", "orientation": "landscape" if width >= height else "portrait",
"plot_style": str(dxf.current_style_sheet), "plot_style": str(dxf.current_style_sheet),
"scale": scale, "scale": scale,
"plot_area": PLOT_TYPE_NAMES.get(int(dxf.plot_type), str(dxf.plot_type)), "plot_area": PLOT_TYPE_NAMES.get(plot_type, str(dxf.plot_type)),
"device": str(dxf.plot_configuration_file), "device": str(dxf.plot_configuration_file),
"margins_mm": [ "margins_mm": [
round(float(dxf.top_margin), 3), round(float(dxf.top_margin), 3),
@@ -2371,7 +2373,9 @@ class EzdxfBackend(AutoCADBackend):
round(float(dxf.left_margin), 3), round(float(dxf.left_margin), 3),
round(float(dxf.right_margin), 3), round(float(dxf.right_margin), 3),
], ],
"center": bool(flags & layout.PLOT_CENTERED), # 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,
} }
@staticmethod @staticmethod
@@ -2474,6 +2478,9 @@ class EzdxfBackend(AutoCADBackend):
dxf.standard_scale_type = 16 dxf.standard_scale_type = 16
dxf.scale_numerator, dxf.scale_denominator = numerator, denominator dxf.scale_numerator, dxf.scale_denominator = numerator, denominator
target.use_standard_scale(False) target.use_standard_scale(False)
# resolve_page_setup hands over None for a layout plot: the bit is
# cleared, never set — the state AutoCAD itself stores for acLayout,
# and the mirror of the live engine never writing CenterPlot there.
target.plot_centered(bool(resolved["center"])) target.plot_centered(bool(resolved["center"]))
# Limits follow the paper; viewports are deliberately left alone # Limits follow the paper; viewports are deliberately left alone
# (Paperspace.page_setup() would delete them). # (Paperspace.page_setup() would delete them).
+34 -3
View File
@@ -13,6 +13,16 @@ tables of two enums that do not agree with each other:
A scale without a code on an engine goes through the custom numerator / 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. denominator on that engine, and the read-back reports the same label either way.
Centring belongs to an *extents* plot only. The ActiveX Reference (CenterPlot
Property, acadauto.chm, AutoCAD 2026): "This property cannot be set to True on
a layout object whose PlotType property is set to acLayout" — a layout plot is
the whole sheet from its own origin, and the Page Setup dialog greys the box
out. ``resolve_page_setup`` therefore resolves ``center`` to ``None`` (not
applicable, never written, read back as ``None``) when ``plot_area`` is
``layout``; the bool is kept only for ``extents``. Both engines follow the
same resolved value, so the DXF never carries a centre bit AutoCAD would
ignore and the live engine never issues the write AutoCAD refuses.
""" """
from __future__ import annotations from __future__ import annotations
@@ -28,11 +38,13 @@ __all__ = [
"PAPER_INCHES", "PAPER_INCHES",
"PAPER_SIZES", "PAPER_SIZES",
"PLOT_AREAS", "PLOT_AREAS",
"PLOT_TYPE_LAYOUT",
"PLOT_TYPE_NAMES", "PLOT_TYPE_NAMES",
"REQUIRED_SETUP_KEYS", "REQUIRED_SETUP_KEYS",
"SCALES", "SCALES",
"activex_scale_label", "activex_scale_label",
"canonical_media_name", "canonical_media_name",
"center_applies",
"dxf_scale_label", "dxf_scale_label",
"paper_from_size", "paper_from_size",
"paper_size_mm", "paper_size_mm",
@@ -135,6 +147,8 @@ _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 area → DXF/ActiveX plot type (``AcPlotType``: acExtents=1, acLayout=5).
PLOT_AREAS: dict[str, int] = {"layout": 5, "extents": 1} PLOT_AREAS: dict[str, int] = {"layout": 5, "extents": 1}
#: ``AcPlotType.acLayout`` — the plot type under which centring does not apply.
PLOT_TYPE_LAYOUT = 5
PLOT_TYPE_NAMES: dict[int, str] = { PLOT_TYPE_NAMES: dict[int, str] = {
0: "display", 0: "display",
1: "extents", 1: "extents",
@@ -243,6 +257,19 @@ def plot_style_known(name) -> bool:
return str(name or "").strip().lower() in {entry.lower() for entry in CTB_CATALOG} return str(name or "").strip().lower() in {entry.lower() for entry in CTB_CATALOG}
def center_applies(plot_type) -> bool:
"""Whether "centre the plot" means anything under this ``AcPlotType``.
False for ``acLayout`` (5): the sheet is plotted from its origin, AutoCAD
greys the option out and ActiveX refuses ``CenterPlot = True``. Read-backs
report ``center: None`` there instead of a stored flag nothing honours.
"""
try:
return int(plot_type) != PLOT_TYPE_LAYOUT
except (TypeError, ValueError):
return False
def resolve_page_setup( def resolve_page_setup(
paper: str, paper: str,
orientation: str = "landscape", orientation: str = "landscape",
@@ -256,7 +283,10 @@ def resolve_page_setup(
"""Validate a page setup request and resolve it to what both engines write. """Validate a page setup request and resolve it to what both engines write.
Raises ``ValueError`` naming the offending field; nothing here touches a Raises ``ValueError`` naming the offending field; nothing here touches a
drawing. ``margins_mm`` is ``[top, bottom, left, right]``. drawing. ``margins_mm`` is ``[top, bottom, left, right]``. ``center`` is
validated as a bool and resolved to ``None`` for ``plot_area="layout"``,
where centring is not applicable (see the module docstring) — the default
call (layout, centre) is therefore a layout plot with no centring write.
""" """
name = _paper_key(paper) name = _paper_key(paper)
width, height = paper_size_mm(name, _orientation(orientation)) width, height = paper_size_mm(name, _orientation(orientation))
@@ -300,6 +330,7 @@ def resolve_page_setup(
if not isinstance(center, bool): if not isinstance(center, bool):
raise ValueError(f"center: must be true or false, got {center!r}") raise ValueError(f"center: must be true or false, got {center!r}")
plot_type = PLOT_AREAS[plot_area]
return { return {
"paper": name, "paper": name,
@@ -314,10 +345,10 @@ def resolve_page_setup(
"dxf_standard_scale_type": DXF_STANDARD_SCALE_TYPE.get(label), "dxf_standard_scale_type": DXF_STANDARD_SCALE_TYPE.get(label),
"activex_standard_scale": ACTIVEX_PLOT_SCALE.get(label), "activex_standard_scale": ACTIVEX_PLOT_SCALE.get(label),
"plot_area": plot_area, "plot_area": plot_area,
"plot_type": PLOT_AREAS[plot_area], "plot_type": plot_type,
"device": device_name, "device": device_name,
"margins_mm": margins, "margins_mm": margins,
"center": center, "center": center if center_applies(plot_type) else None,
} }
+136 -10
View File
@@ -83,7 +83,21 @@ def test_resolve_page_setup_defaults():
assert setup["scale_ratio"] == [1.0, 1.0] assert setup["scale_ratio"] == [1.0, 1.0]
assert setup["plot_area"] == "layout" and setup["plot_type"] == 5 assert setup["plot_area"] == "layout" and setup["plot_type"] == 5
assert setup["device"] == "DWG To PDF.pc3" assert setup["device"] == "DWG To PDF.pc3"
assert setup["margins_mm"] is None and setup["center"] is True assert setup["margins_mm"] is None
assert setup["center"] is None, "a layout plot has no centring (AutoCAD greys it out)"
def test_resolve_page_setup_center_applies_to_an_extents_plot_only():
"""ActiveX Reference, CenterPlot: 'This property cannot be set to True on a
layout object whose PlotType property is set to acLayout.' The resolver
carries the rule so both engines write the same thing: a bool for an
extents plot, ``None`` (not applicable, never written) for a layout plot."""
assert resolve_page_setup("ISO_A3", plot_area="extents")["center"] is True
assert resolve_page_setup("ISO_A3", plot_area="extents", center=False)["center"] is False
assert resolve_page_setup("ISO_A3", plot_area="layout", center=True)["center"] is None
assert resolve_page_setup("ISO_A3", plot_area="layout", center=False)["center"] is None
with pytest.raises(ValueError, match="center"):
resolve_page_setup("ISO_A3", plot_area="layout", center="yes")
def test_resolve_page_setup_accepts_lowercase_and_short_paper_names(): def test_resolve_page_setup_accepts_lowercase_and_short_paper_names():
@@ -196,7 +210,7 @@ async def test_apply_iso_a3_landscape_and_the_pdf_mediabox_agrees(backend, tmp_p
assert row["scale"] == "fit" assert row["scale"] == "fit"
assert row["plot_area"] == "layout" assert row["plot_area"] == "layout"
assert row["device"] == "DWG To PDF.pc3" assert row["device"] == "DWG To PDF.pc3"
assert row["center"] is True assert row["center"] is None, "layout plot: centring is not applicable"
assert len(row["margins_mm"]) == 4 assert len(row["margins_mm"]) == 4
width, height = await _mediabox_after_export(backend, tmp_path, SHEET) width, height = await _mediabox_after_export(backend, tmp_path, SHEET)
@@ -282,16 +296,45 @@ async def test_apply_materialises_the_default_flags_before_setting_centre(backen
"""Measured: set_flag_state starts from 0 on a fresh layout, so a naive """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.""" plot_centered(True) writes 4 and drops lineweights/plot-styles/viewports-first."""
await _sheet(backend) await _sheet(backend)
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=True)) extents = resolve_page_setup("ISO_A3", plot_area="extents", center=True)
result = await backend.page_setup_apply(SHEET, extents)
flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
assert flags & 4, "centre-plot bit" assert flags & 4, "centre-plot bit"
assert flags & 32, "plot with plot styles" assert flags & 32, "plot with plot styles"
assert flags & 128, "plot entity lineweights" assert flags & 128, "plot entity lineweights"
assert flags & 512, "draw viewports first" assert flags & 512, "draw viewports first"
assert flags & 16, "use standard scale (fit)" assert flags & 16, "use standard scale (fit)"
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=False)) assert result["applied"]["center"] is True
assert (await backend.page_setup_list(SHEET))[0]["center"] is True
await backend.page_setup_apply(
SHEET, resolve_page_setup("ISO_A3", plot_area="extents", center=False)
)
flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags) 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 assert not flags & 4 and flags & 32 and flags & 128 and flags & 512
assert (await backend.page_setup_list(SHEET))[0]["center"] is False
async def test_layout_plot_never_sets_the_centre_bit_and_reports_it_not_applicable(backend):
"""A layout plot is the whole sheet from its origin: AutoCAD disables
'Center the plot' (ActiveX refuses CenterPlot=True under acLayout). The
headless engine mirrors that: bit 4 is not written, and the read-back says
None rather than a bool AutoCAD would ignore."""
await _sheet(backend)
result = 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 not flags & 4, "centre bit must not be set for a layout plot"
assert result["applied"]["center"] is None
assert "center" not in result["changed"], "None before (fresh layout plot) and None after"
assert (await backend.page_setup_list(SHEET))[0]["center"] is None
# Switching an extents+centred sheet to a layout plot clears the stale bit
# (what AutoCAD stores for a layout plot) and reports the move honestly.
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", plot_area="extents"))
assert (await backend.page_setup_list(SHEET))[0]["center"] is True
result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
assert not flags & 4
assert result["changed"]["center"] == [True, None]
assert result["changed"]["plot_area"] == ["extents", "layout"]
async def test_custom_scale_clears_the_standard_scale_bit_and_writes_the_ratio(backend): async def test_custom_scale_clears_the_standard_scale_bit_and_writes_the_ratio(backend):
@@ -370,11 +413,21 @@ _MEDIA_RE = re.compile(r"\((\d+\.\d+)_x_(\d+\.\d+)_(MM|Inches)\)")
class _FakeLayout: class _FakeLayout:
"""Records every property set and method call, in order.""" """Records every property set and method call, in order.
def __init__(self, name, media_names): Enforces the one rule the ActiveX Reference documents for this surface
(CenterPlot Property, acadauto.chm, AutoCAD 2026): "This property cannot
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.
"""
AC_LAYOUT = 5
def __init__(self, name, media_names, refuse=()):
object.__setattr__(self, "log", []) object.__setattr__(self, "log", [])
object.__setattr__(self, "_media", tuple(media_names)) object.__setattr__(self, "_media", tuple(media_names))
object.__setattr__(self, "_refuse", tuple(refuse))
object.__setattr__( object.__setattr__(
self, self,
"_props", "_props",
@@ -400,9 +453,16 @@ class _FakeLayout:
raise AttributeError(attr) raise AttributeError(attr)
def __setattr__(self, attr, value): def __setattr__(self, attr, value):
if attr in self._refuse:
raise RuntimeError(f"AutoCAD refused Layout.{attr} = {value!r}")
if attr == "CenterPlot" and value and self._props["PlotType"] == self.AC_LAYOUT:
raise RuntimeError("CenterPlot cannot be True while PlotType is acLayout")
self._props[attr] = value self._props[attr] = value
self.log.append(("set", attr, value)) self.log.append(("set", attr, value))
def snapshot(self) -> dict:
return dict(object.__getattribute__(self, "_props"))
def RefreshPlotDeviceInfo(self): def RefreshPlotDeviceInfo(self):
self.log.append(("call", "RefreshPlotDeviceInfo")) self.log.append(("call", "RefreshPlotDeviceInfo"))
@@ -487,11 +547,67 @@ async def test_com_apply_issues_the_property_sequence(com_backend):
("set", "PlotType", 5), ("set", "PlotType", 5),
("set", "StandardScale", 0), ("set", "StandardScale", 0),
("set", "UseStandardScale", True), ("set", "UseStandardScale", True),
("set", "CenterPlot", True), ], "a layout plot never writes CenterPlot (ActiveX refuses True under acLayout)"
]
assert result["changed"]["paper"] == ["ISO_A4", "ISO_A3"] assert result["changed"]["paper"] == ["ISO_A4", "ISO_A3"]
assert result["changed"]["device"] == ["None", "DWG To PDF.pc3"] assert result["changed"]["device"] == ["None", "DWG To PDF.pc3"]
assert result["changed"]["center"] == [False, True] assert result["applied"]["center"] is None
assert "center" not in result["changed"]
async def test_com_apply_extents_sets_plot_type_before_center_plot(com_backend):
backend, document = com_backend
result = await backend.page_setup_apply(
"Layout1", resolve_page_setup("ISO_A3", plot_area="extents", center=True)
)
log = document.Layouts.Item("Layout1").log
assert log.index(("set", "PlotType", 1)) < log.index(("set", "CenterPlot", True))
assert result["changed"]["center"] == [None, True]
assert result["changed"]["plot_area"] == ["layout", "extents"]
assert (await backend.page_setup_list("Layout1"))[0]["center"] is True
async def test_com_apply_clears_a_stale_center_plot_before_switching_to_layout(com_backend):
backend, document = com_backend
layout = document.Layouts.Item("Layout1")
layout.PlotType = 1
layout.CenterPlot = True
del layout.log[:]
result = await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3"))
log = layout.log
assert log.index(("set", "CenterPlot", False)) < log.index(("set", "PlotType", 5))
assert layout.CenterPlot is False and layout.PlotType == 5
assert result["changed"]["center"] == [True, None]
async def test_com_apply_restores_every_write_when_autocad_refuses_a_property(com_backend):
"""Nine writes had landed before CenterPlot in the shipped sequence, with
nothing put back. Any refusal now unwinds the journal in reverse."""
backend, document = com_backend
layout = _FakeLayout("Layout1", MEDIA, refuse=("PlotWithPlotStyles",))
document.Layouts.layouts[1] = layout
before = layout.snapshot()
with pytest.raises(ValueError, match="PlotWithPlotStyles") as info:
await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3"))
assert "restored" in str(info.value)
assert layout.snapshot() == before, "every property AutoCAD accepted is put back"
restores = [entry for entry in layout.log if entry[0] == "set"][5:]
assert restores == [
("set", "StyleSheet", ""),
("set", "PlotRotation", 0),
("set", "PaperUnits", 1),
("set", "CanonicalMediaName", "ISO_A4_(210.00_x_297.00_MM)"),
("set", "ConfigName", "None"),
], "unwound in reverse write order"
async def test_com_apply_restores_the_custom_scale_too(com_backend):
backend, document = com_backend
layout = _FakeLayout("Layout1", MEDIA, refuse=("UseStandardScale",))
document.Layouts.layouts[1] = layout
with pytest.raises(ValueError, match="UseStandardScale"):
await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", scale="5:1"))
calls = [entry for entry in layout.log if entry[0] == "call" and entry[1] == "SetCustomScale"]
assert calls == [("call", "SetCustomScale", 5.0, 1.0), ("call", "SetCustomScale", 1.0, 1.0)]
async def test_com_apply_custom_scale_uses_set_custom_scale(com_backend): async def test_com_apply_custom_scale_uses_set_custom_scale(com_backend):
@@ -539,7 +655,17 @@ async def test_com_list_reads_the_properties(com_backend):
assert row["orientation"] == "portrait" assert row["orientation"] == "portrait"
assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5] assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5]
assert row["scale"] == "1:1" and row["plot_area"] == "layout" assert row["scale"] == "1:1" and row["plot_area"] == "layout"
assert row["device"] == "None" and row["center"] is False assert row["device"] == "None"
assert row["center"] is None, "acLayout: CenterPlot is not applicable"
async def test_com_list_reports_center_for_an_extents_plot(com_backend):
backend, document = com_backend
layout = document.Layouts.Item("Layout1")
layout.PlotType = 1
assert (await backend.page_setup_list("Layout1"))[0]["center"] is False
layout.CenterPlot = True
assert (await backend.page_setup_list("Layout1"))[0]["center"] is True
async def test_com_declares_dwt_write(): async def test_com_declares_dwt_write():