feat(pagesetup): page_setup_list/apply on both engines — the PDF's own /MediaBox is the evidence

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:13:48 +03:00
co-authored by Claude Opus 5
parent 49fc6ac6a5
commit f46fda10cb
8 changed files with 1345 additions and 2 deletions
+2
View File
@@ -30,6 +30,7 @@ from backends.contracts import (
LayerContract,
LayoutContract,
LinetypeContract,
PageSetupContract,
PremiumContract,
SettingsContract,
SolidContract,
@@ -168,6 +169,7 @@ class AutoCADBackend(
IdentityContract,
DrawingContract,
LayoutContract,
PageSetupContract,
SolidContract,
EntityCreationContract,
DimensionContract,
+178
View File
@@ -811,6 +811,7 @@ class ComBackend(AutoCADBackend):
"refiner": FeatureCapability(True, "shared"),
"delivery": FeatureCapability(True, "shared"),
"paper_space": FeatureCapability(True, "native"),
"dwt_write": FeatureCapability(True, "native"),
"viewport_render": FeatureCapability(True, "native"),
"solid_3d": _solid_3d_capability(),
"lisp": FeatureCapability(True, "sanitized"),
@@ -1954,6 +1955,183 @@ class ComBackend(AutoCADBackend):
# CopyObjects-and-delete that would destroy and recreate geometry in the
# operator's open drawing when it goes wrong.
# ── page setup (track E) ─────────────────────────────────────────────────
#
# Layout is an IAcadPlotConfiguration. Constants from the AutoCAD 2026
# typelib: AcPlotPaperUnits acMillimeters=1; AcPlotRotation ac0degrees=0;
# AcPlotType acExtents=1 / acLayout=5; AcPlotScale acScaleToFit=0, ac1_1=16
# (the full table is engineering.standards.papers.ACTIVEX_PLOT_SCALE).
# Fake-tested in tests/test_page_setup.py; executed live by
# scripts/smoke_settings_com.py (Task 24).
_AC_MILLIMETERS = 1
_AC_NO_ROTATION = 0
@staticmethod
def _com_page_setup_row(layout) -> dict:
from engineering.standards.papers import (
PLOT_TYPE_NAMES,
activex_scale_label,
paper_from_size,
scale_label,
)
def _get(attr):
try:
return getattr(layout, attr)
except Exception as exc:
log.debug("Layout.%s read failed: %s", attr, exc)
return None
units = _get("PaperUnits")
factor = 25.4 if units == 0 else 1.0
size = None
try:
width, height = layout.GetPaperSize()
size = [round(float(width) * factor, 2), round(float(height) * factor, 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)]
except Exception as exc:
log.debug("Layout.GetPaperMargins failed: %s", exc)
rotation = _get("PlotRotation") or 0
if size and int(rotation) in (1, 3):
size = [size[1], size[0]]
if _get("UseStandardScale"):
scale = activex_scale_label(_get("StandardScale") or 0)
else:
try:
numerator, denominator = layout.GetCustomScale()
scale = scale_label(float(numerator), float(denominator))
except Exception as exc:
log.debug("Layout.GetCustomScale failed: %s", exc)
scale = None
media = _get("CanonicalMediaName")
plot_type = _get("PlotType")
return {
"layout": layout.Name,
"paper": (paper_from_size(*size) if size else None) or media,
"canonical_media_name": media,
"size_mm": size,
"orientation": (
None if not size else ("landscape" if size[0] >= size[1] else "portrait")
),
"plot_style": _get("StyleSheet"),
"scale": scale,
"plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type),
"device": _get("ConfigName"),
"margins_mm": margins,
"center": bool(_get("CenterPlot")),
}
def _com_paper_layout(self, doc, raw: str, operation: str):
resolved = self._find_layout(doc, raw)
if resolved is None:
available = [n for n in self._layout_names(doc) if n != "Model"]
raise ValueError(
f"{operation}: layout {raw!r} not found (paper-space layouts: "
f"{', '.join(available) or 'none'})"
)
if resolved == "Model":
raise ValueError(
f"{operation}: Model space has no sheet; plot it with "
"drawing_export_pdf(layout=None) or apply the setup to a paper-space layout"
)
return doc.Layouts.Item(resolved)
async def page_setup_list(self, layout: str | None = None) -> list[dict]:
def _sync():
doc = _acad_doc()
if layout is not None and self._layout_name(layout):
target = self._com_paper_layout(doc, layout, "page_setup_list")
return [self._com_page_setup_row(target)]
return [
self._com_page_setup_row(doc.Layouts.Item(name))
for name in self._layout_names(doc)
if name != "Model"
]
return await self._run(_sync)
async def page_setup_apply(self, layout: str, setup: dict) -> dict:
from engineering.standards.papers import require_page_setup
resolved = require_page_setup(setup)
if resolved["margins_mm"] is not None:
raise ValueError(
"page_setup_apply: margins_mm cannot be written on the COM backend — AutoCAD "
"takes the printable margins from the plotter configuration (.pc3). Omit "
"margins_mm; the device's margins are read back in the result."
)
def _sync():
doc = _acad_doc()
target = self._com_paper_layout(doc, layout, "page_setup_apply")
before = self._com_page_setup_row(target)
previous_device = before["device"]
target.RefreshPlotDeviceInfo()
target.ConfigName = resolved["device"]
target.RefreshPlotDeviceInfo()
media = resolved["canonical_media_name"]
names = [str(n) for n in (target.GetCanonicalMediaNames() or ())]
if media not in names:
# Roll the one write back before refusing, so a refusal leaves
# the layout as it was.
target.ConfigName = previous_device
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: {', '.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.UseStandardScale = False
target.CenterPlot = bool(resolved["center"])
after = self._com_page_setup_row(target)
changed = {
key: [before[key], after[key]]
for key in after
if key != "layout" and before.get(key) != after[key]
}
return {
"ok": True,
"layout": target.Name,
"applied": dict(resolved),
"changed": changed,
"plot_style_known": bool(resolved["plot_style_known"]),
"viewports_kept": True,
"backend": "com",
}
return await self._run(_sync)
async def plot_style_list(self) -> list[dict]:
# Completed in Task 18 (installed ctb scan); the catalogue rows already answer.
from engineering.standards.papers import CTB_CATALOG
return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
async def drawing_template_save(self, path, name=None, description=None) -> dict:
# Completed in Task 20; declared here so the ABC instantiates.
raise UnsupportedCapabilityError(
"dwt_write", "drawing_template_save: not implemented yet (Task 20)"
)
# ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) ─
async def solid_box(
+2
View File
@@ -21,6 +21,7 @@ from backends.contracts.identity import IdentityContract
from backends.contracts.layers import LayerContract
from backends.contracts.layouts import LayoutContract
from backends.contracts.linetypes import LinetypeContract
from backends.contracts.pagesetup import PageSetupContract
from backends.contracts.premium import PremiumContract
from backends.contracts.settings import SettingsContract
from backends.contracts.solids import SolidContract
@@ -42,6 +43,7 @@ __all__ = [
"LayerContract",
"LayoutContract",
"LinetypeContract",
"PageSetupContract",
"PremiumContract",
"SettingsContract",
"SolidContract",
+59
View File
@@ -0,0 +1,59 @@
"""Page setup, plot styles and templates (v1.6 track E, group P).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class PageSetupContract(ABC):
@abstractmethod
async def page_setup_list(self, layout: str | None = None) -> list[dict]:
"""Page setup of one paper-space layout, or of every one.
Rows: ``{layout, paper, canonical_media_name, size_mm: [w, h],
orientation, plot_style, scale, plot_area, device,
margins_mm: [top, bottom, left, right], center}``. ``paper`` is the
catalogue name when the size matches one within 0.5 mm, otherwise the
media name as stored. Raises ``ValueError`` for an unknown layout.
``Model`` is never listed: model space is plotted through
``drawing_export_pdf(layout=None)`` and carries no sheet.
"""
...
@abstractmethod
async def page_setup_apply(self, layout: str, setup: dict) -> dict:
"""Write a page setup resolved by
``engineering.standards.papers.resolve_page_setup`` to a layout.
Returns ``{ok, layout, applied: {...setup}, changed: {key: [old, new]},
plot_style_known, viewports_kept}``. ``changed`` is computed from the
read-back before and after, so re-applying the same setup reports
``{}``. Refuses ``Model``, an unknown layout, and a ``setup`` missing a
resolved key, before anything is written. Never deletes viewports.
"""
...
@abstractmethod
async def plot_style_list(self) -> list[dict]:
"""``[{name, source: "catalog"|"installed", installed: bool|None}]`` —
the authored ctb catalogue plus, on a live engine, the files present
in the plot style search path. ``installed`` is ``None`` where no
installation can be scanned."""
...
@abstractmethod
async def drawing_template_save(
self, path: str, name: str | None = None, description: str | None = None
) -> dict:
"""Save the current drawing as a template.
``.dxf`` writes on both engines; ``.dwt`` writes on the live engine
(``SaveAs(path, ac2018_Template)``) and raises
``UnsupportedCapabilityError("dwt_write")`` headlessly. Returns
``{ok, path, format: "dxf"|"dwt", backend, name, description_written}``.
"""
...
+197 -2
View File
@@ -1044,6 +1044,9 @@ class EzdxfBackend(AutoCADBackend):
"refiner": FeatureCapability(True, "shared"),
"delivery": FeatureCapability(True, "shared"),
"paper_space": FeatureCapability(True, "native"),
"dwt_write": FeatureCapability(
False, reason="dwt_is_a_dwg_container_requires_live_autocad"
),
# Measured, not assumed: a sheet holding nothing but a viewport
# renders the model geometry that viewport looks at, at its
# scale and position, clipped to its window. v1.4 declared this
@@ -1525,13 +1528,29 @@ class EzdxfBackend(AutoCADBackend):
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
fig = _new_agg_figure()
# Track E: for a sheet, the figure *is* the paper. Sized from
# the layout's page setup and left unadjusted, so the PDF's
# /MediaBox is the paper size — the evidence page_setup_apply
# is judged by. Measured: adjust_figure=True gave 691.2 x 345.6
# pt whatever the sheet was.
paper = None
if resolved is not None and resolved != "Model":
paper = self._paper_frame(target)
fig = _new_agg_figure(figsize=paper["figsize"] if paper else None)
ax = fig.add_axes([0, 0, 1, 1])
ctx = RenderContext(doc)
out = MatplotlibBackend(ax)
out = MatplotlibBackend(ax, adjust_figure=paper is None)
Frontend(ctx, out).draw_layout(target, finalize=True)
if paper is not None:
ax.set_xlim(*paper["xlim"])
ax.set_ylim(*paper["ylim"])
ax.set_aspect("equal", adjustable="box")
fig.savefig(path, dpi=150)
result = {"ok": True, "path": path}
if paper is not None:
result["paper_mm"] = paper["size_mm"]
if not paper["rotation_applied"]:
result["rotation_applied"] = False
if resolved is not None and resolved != "Model":
result["layout"] = resolved
result["note"] = (
@@ -2294,6 +2313,182 @@ class EzdxfBackend(AutoCADBackend):
text = f"{float(value):.4f}".rstrip("0").rstrip(".")
return text or "0"
# ── page setup (track E) ─────────────────────────────────────────────────
def _paper_layout(self, raw: str, operation: str):
"""Resolve a paper-space layout by name, refusing Model and unknowns."""
doc = self._require_doc()
resolved = self._find_layout(raw)
if resolved is None:
available = [n for n in doc.layouts.names_in_taborder() if n != "Model"]
raise ValueError(
f"{operation}: layout {raw!r} not found (paper-space layouts: "
f"{', '.join(available) or 'none'})"
)
if resolved == "Model":
raise ValueError(
f"{operation}: Model space has no sheet; plot it with "
"drawing_export_pdf(layout=None) or apply the setup to a paper-space layout"
)
return doc.layouts.get(resolved)
@staticmethod
def _page_setup_row(layout) -> dict:
from engineering.standards.papers import (
PLOT_TYPE_NAMES,
dxf_scale_label,
paper_from_size,
scale_label,
)
dxf = layout.dxf_layout.dxf
flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags")))
# DXF stores every paper size and margin in millimetres, whatever
# plot_paper_units says (ezdxf's acdb_plot_settings, verified).
width, height = float(dxf.paper_width), float(dxf.paper_height)
rotation = int(dxf.plot_rotation)
if rotation in (1, 3):
width, height = height, width
media = str(dxf.paper_size)
numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator)
if flags & layout.USE_STANDARD_SCALE:
scale = dxf_scale_label(int(dxf.standard_scale_type), numerator, denominator)
else:
scale = scale_label(numerator, denominator)
return {
"layout": layout.name,
"paper": paper_from_size(width, height) or media,
"canonical_media_name": media,
"size_mm": [round(width, 2), round(height, 2)],
"orientation": "landscape" if width >= height else "portrait",
"plot_style": str(dxf.current_style_sheet),
"scale": scale,
"plot_area": PLOT_TYPE_NAMES.get(int(dxf.plot_type), str(dxf.plot_type)),
"device": str(dxf.plot_configuration_file),
"margins_mm": [
round(float(dxf.top_margin), 3),
round(float(dxf.bottom_margin), 3),
round(float(dxf.left_margin), 3),
round(float(dxf.right_margin), 3),
],
"center": bool(flags & layout.PLOT_CENTERED),
}
@staticmethod
def _paper_frame(layout) -> dict:
"""The sheet as a matplotlib figure: size in inches plus the axes window.
Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper.
``plot_rotation`` 1/3 swaps the figure; the content is not rotated by
the headless renderer (reported, not hidden).
"""
dxf = layout.dxf_layout.dxf
width, height = float(dxf.paper_width), float(dxf.paper_height)
rotation = int(dxf.plot_rotation)
unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0
x0 = -(float(dxf.left_margin) + float(dxf.plot_origin_x_offset))
y0 = -(float(dxf.bottom_margin) + float(dxf.plot_origin_y_offset))
fig_w, fig_h = (height, width) if rotation in (1, 3) else (width, height)
return {
"size_mm": [round(fig_w, 2), round(fig_h, 2)],
"figsize": (fig_w / 25.4, fig_h / 25.4),
"xlim": (x0 / unit, (x0 + width) / unit),
"ylim": (y0 / unit, (y0 + height) / unit),
"rotation_applied": rotation == 0,
}
async def page_setup_list(self, layout: str | None = None) -> list[dict]:
def _sync():
doc = self._require_doc()
if layout is not None and self._layout_name(layout):
return [self._page_setup_row(self._paper_layout(layout, "page_setup_list"))]
return [
self._page_setup_row(doc.layouts.get(name))
for name in doc.layouts.names_in_taborder()
if name != "Model"
]
return await self._async(_sync)
async def page_setup_apply(self, layout: str, setup: dict) -> dict:
from engineering.standards.papers import require_page_setup
resolved = require_page_setup(setup)
def _sync():
from ezdxf.lldxf.const import STD_SCALES
target = self._paper_layout(layout, "page_setup_apply")
before = self._page_setup_row(target)
dxf = target.dxf_layout.dxf
# Measured: a fresh layout has no stored plot_layout_flags, and
# set_flag_state starts from 0 — toggling one bit would drop
# plot-styles (32), lineweights (128) and viewports-first (512).
if not dxf.hasattr("plot_layout_flags"):
dxf.plot_layout_flags = dxf.get_default("plot_layout_flags")
width, height = (float(v) for v in resolved["size_mm"])
dxf.page_setup_name = ""
dxf.plot_configuration_file = resolved["device"]
dxf.paper_size = resolved["canonical_media_name"]
dxf.paper_width = width
dxf.paper_height = height
dxf.plot_paper_units = 1 # millimetres
dxf.plot_rotation = 0 # orientation rides on width/height, as AutoCAD names media
if resolved["margins_mm"] is not None:
top, bottom, left, right = (float(v) for v in resolved["margins_mm"])
dxf.top_margin, dxf.bottom_margin = top, bottom
dxf.left_margin, dxf.right_margin = left, right
dxf.plot_origin_x_offset = 0.0
dxf.plot_origin_y_offset = 0.0
dxf.unit_factor = 1.0
dxf.current_style_sheet = resolved["plot_style"]
target.use_plot_styles(True)
target.set_plot_type(int(resolved["plot_type"]))
code = resolved["dxf_standard_scale_type"]
if code is not None:
dxf.standard_scale_type = int(code)
numerator, denominator = STD_SCALES.get(int(code), (1.0, 1.0))
dxf.scale_numerator, dxf.scale_denominator = float(numerator), float(denominator)
target.use_standard_scale(True)
else:
numerator, denominator = (float(v) for v in resolved["scale_ratio"])
dxf.standard_scale_type = 16
dxf.scale_numerator, dxf.scale_denominator = numerator, denominator
target.use_standard_scale(False)
target.plot_centered(bool(resolved["center"]))
# Limits follow the paper; viewports are deliberately left alone
# (Paperspace.page_setup() would delete them).
target.reset_paper_limits()
self._mark_dirty()
after = self._page_setup_row(target)
changed = {
key: [before[key], after[key]]
for key in after
if key != "layout" and before.get(key) != after[key]
}
return {
"ok": True,
"layout": target.name,
"applied": dict(resolved),
"changed": changed,
"plot_style_known": bool(resolved["plot_style_known"]),
"viewports_kept": True,
"backend": "ezdxf",
}
return await self._async(_sync)
async def plot_style_list(self) -> list[dict]:
from engineering.standards.papers import CTB_CATALOG
return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
async def drawing_template_save(self, path, name=None, description=None) -> dict:
# Completed in Task 20; declared here so the ABC instantiates.
raise UnsupportedCapabilityError(
"dwt_write", "drawing_template_save: not implemented yet (Task 20)"
)
# ── 3D solids (unsupported headlessly — honest capability boundary) ─────
@staticmethod