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:
co-authored by
Claude Opus 5
parent
70f18efb4b
commit
20ca39d28a
+106
-32
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user