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
+355
View File
@@ -0,0 +1,355 @@
"""Paper sizes, plot-style catalogue and plot scales — authored data.
Sources: ISO 216 (A series), ASME/ANSI Y14.1 (A–E, metric millimetre values as
AutoCAD's ``ANSI_*`` media measure, inch values as the media are *named*),
the ctb files AutoCAD ships in its Plot Styles folder, and the standard-scale
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.
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.
"""
from __future__ import annotations
import math
import re
__all__ = [
"ACTIVEX_PLOT_SCALE",
"CTB_CATALOG",
"DXF_STANDARD_SCALE_TYPE",
"ORIENTATIONS",
"PAPER_INCHES",
"PAPER_SIZES",
"PLOT_AREAS",
"PLOT_TYPE_NAMES",
"REQUIRED_SETUP_KEYS",
"SCALES",
"activex_scale_label",
"canonical_media_name",
"dxf_scale_label",
"paper_from_size",
"paper_size_mm",
"plot_style_known",
"require_page_setup",
"resolve_page_setup",
"scale_label",
"scale_ratio",
]
#: Portrait width × height in millimetres. Landscape swaps.
PAPER_SIZES: dict[str, tuple[int, int]] = {
"ISO_A0": (841, 1189),
"ISO_A1": (594, 841),
"ISO_A2": (420, 594),
"ISO_A3": (297, 420),
"ISO_A4": (210, 297),
"ANSI_A": (216, 279),
"ANSI_B": (279, 432),
"ANSI_C": (432, 559),
"ANSI_D": (559, 864),
"ANSI_E": (864, 1118),
}
#: ANSI media are *named* in inches by AutoCAD's DWG To PDF.pc3
#: (``ANSI_B_(17.00_x_11.00_Inches)``); the millimetre table above is what the
#: sheet measures. Portrait width × height.
PAPER_INCHES: dict[str, tuple[float, float]] = {
"ANSI_A": (8.5, 11.0),
"ANSI_B": (11.0, 17.0),
"ANSI_C": (17.0, 22.0),
"ANSI_D": (22.0, 34.0),
"ANSI_E": (34.0, 44.0),
}
#: Spellings resolve_page_setup accepts, mapped to the canonical key.
_PAPER_ALIASES: dict[str, str] = {
**{name.lower(): name for name in PAPER_SIZES},
**{name.split("_", 1)[1].lower(): name for name in PAPER_SIZES if name.startswith("ISO_")},
**{f"ansi {name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")},
**{f"ansi-{name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")},
}
#: Plot style tables AutoCAD ships (``<install>/…/Plot Styles``).
CTB_CATALOG: tuple[str, ...] = (
"monochrome.ctb",
"acad.ctb",
"Grayscale.ctb",
"Screening 100%.ctb",
"Screening 75%.ctb",
"Screening 50%.ctb",
"Screening 25%.ctb",
"Fill Patterns.ctb",
"DWF Virtual Pens.ctb",
)
#: Label → paper:drawing factor (``None`` = scaled to fit).
SCALES: dict[str, float | None] = {
"fit": None,
"1:1": 1.0,
"1:2": 0.5,
"1:5": 0.2,
"1:10": 0.1,
"1:20": 0.05,
"1:50": 0.02,
"1:100": 0.01,
"2:1": 2.0,
"5:1": 5.0,
"10:1": 10.0,
}
#: DXF group code 75. Labels missing here use the custom numerator/denominator.
DXF_STANDARD_SCALE_TYPE: dict[str, int] = {
"fit": 0,
"1:1": 16,
"1:2": 17,
"1:10": 20,
"1:20": 22,
"1:50": 25,
"1:100": 26,
"2:1": 27,
"10:1": 30,
}
_DXF_SCALE_LABEL = {code: label for label, code in DXF_STANDARD_SCALE_TYPE.items()}
#: ActiveX ``AcPlotScale`` (AutoCAD 2026 typelib). Labels missing here use SetCustomScale.
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,
"1:100": 27,
"2:1": 28,
"10:1": 31,
}
_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_AREAS: dict[str, int] = {"layout": 5, "extents": 1}
PLOT_TYPE_NAMES: dict[int, str] = {
0: "display",
1: "extents",
2: "limits",
3: "view",
4: "window",
5: "layout",
}
ORIENTATIONS: tuple[str, ...] = ("landscape", "portrait")
_SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$")
def _paper_key(paper) -> str:
key = str(paper or "").strip().lower().replace(" ", "_")
resolved = _PAPER_ALIASES.get(key) or _PAPER_ALIASES.get(key.replace("_", " "))
if resolved is None:
raise ValueError(
f"paper: unknown paper {paper!r}. Valid papers: {', '.join(PAPER_SIZES)} "
"(short forms A0–A4 and ANSI_A–ANSI_E are accepted)"
)
return resolved
def _orientation(orientation) -> str:
if orientation not in ORIENTATIONS:
raise ValueError(f"orientation: must be one of {ORIENTATIONS}, got {orientation!r}")
return orientation
def paper_size_mm(paper: str, orientation: str = "landscape") -> tuple[float, float]:
"""(width, height) in millimetres for ``paper`` in ``orientation``."""
name = _paper_key(paper)
short, long = PAPER_SIZES[name]
if _orientation(orientation) == "landscape":
return float(long), float(short)
return float(short), float(long)
def canonical_media_name(paper: str, orientation: str = "landscape") -> str:
"""AutoCAD's spelling of the media: ``ISO_A3_(420.00_x_297.00_MM)`` for
landscape A3, ``ANSI_B_(17.00_x_11.00_Inches)`` for landscape ANSI B."""
name = _paper_key(paper)
if name in PAPER_INCHES:
short, long = PAPER_INCHES[name]
unit = "Inches"
else:
short, long = PAPER_SIZES[name]
unit = "MM"
width, height = (long, short) if _orientation(orientation) == "landscape" else (short, long)
return f"{name}_({width:.2f}_x_{height:.2f}_{unit})"
def paper_from_size(width_mm, height_mm, tolerance: float = 0.5) -> str | None:
"""The catalogue paper whose size (either orientation) is within ``tolerance`` mm."""
try:
w, h = float(width_mm), float(height_mm)
except (TypeError, ValueError):
return None
for name, (short, long) in PAPER_SIZES.items():
if (abs(w - long) <= tolerance and abs(h - short) <= tolerance) or (
abs(w - short) <= tolerance and abs(h - long) <= tolerance
):
return name
return None
def scale_ratio(label: str) -> tuple[float, float]:
"""``"1:50"`` → ``(1.0, 50.0)`` (paper units : drawing units). ``fit`` has no ratio."""
match = _SCALE_RE.match(str(label or ""))
if match is None:
raise ValueError(f"scale: {label!r} is not an N:M ratio")
numerator, denominator = float(match.group(1)), float(match.group(2))
if numerator <= 0 or denominator <= 0:
raise ValueError(f"scale: {label!r} must have positive terms")
return numerator, denominator
def scale_label(numerator, denominator) -> str:
"""The ``N:M`` label for a ratio: a catalogue label when the factor matches
one, otherwise the reduced integer ratio, otherwise ``N:M`` with the floats."""
num, den = float(numerator), float(denominator)
if num <= 0 or den <= 0:
return f"{num:g}:{den:g}"
for label, factor in SCALES.items():
if factor is not None and math.isclose(num / den, factor, rel_tol=1e-9):
return label
if num.is_integer() and den.is_integer():
divisor = math.gcd(int(num), int(den))
return f"{int(num) // divisor}:{int(den) // divisor}"
return f"{num:g}:{den:g}"
def dxf_scale_label(code, numerator, denominator) -> str:
"""Read-back for the headless engine: the code-75 label, else the custom ratio."""
return _DXF_SCALE_LABEL.get(int(code)) or scale_label(numerator, denominator)
def activex_scale_label(code) -> str:
"""Read-back for the live engine: the AcPlotScale label, else ``standard:<code>``."""
return _ACTIVEX_SCALE_LABEL.get(int(code), f"standard:{int(code)}")
def plot_style_known(name) -> bool:
return str(name or "").strip().lower() in {entry.lower() for entry in CTB_CATALOG}
def resolve_page_setup(
paper: str,
orientation: str = "landscape",
plot_style: str = "monochrome.ctb",
scale: str = "fit",
plot_area: str = "layout",
device: str = "DWG To PDF.pc3",
margins_mm=None,
center: bool = True,
) -> dict:
"""Validate a page setup request and resolve it to what both engines write.
Raises ``ValueError`` naming the offending field; nothing here touches a
drawing. ``margins_mm`` is ``[top, bottom, left, right]``.
"""
name = _paper_key(paper)
width, height = paper_size_mm(name, _orientation(orientation))
style = str(plot_style or "").strip()
if not style:
raise ValueError("plot_style: must name a .ctb/.stb file (e.g. monochrome.ctb)")
label = str(scale or "").strip()
if label not in SCALES:
raise ValueError(f"scale: must be one of {', '.join(SCALES)}, got {scale!r}")
ratio = (1.0, 1.0) if label == "fit" else scale_ratio(label)
if plot_area not in PLOT_AREAS:
raise ValueError(f"plot_area: must be one of {', '.join(PLOT_AREAS)}, got {plot_area!r}")
device_name = str(device or "").strip()
if not device_name:
raise ValueError("device: must name a plotter configuration (e.g. 'DWG To PDF.pc3')")
margins = None
if margins_mm is not None:
try:
values = [float(v) for v in margins_mm]
except (TypeError, ValueError):
raise ValueError(
"margins_mm: must be four numbers [top, bottom, left, right]"
) from None
if len(values) != 4:
raise ValueError(
f"margins_mm: expected 4 values [top, bottom, left, right], got {len(values)}"
)
if any(v < 0 for v in values):
raise ValueError("margins_mm: margins cannot be negative")
top, bottom, left, right = values
if top + bottom >= height or left + right >= width:
raise ValueError(
f"margins_mm: {values} leave no printable area on a {width:g} x {height:g} mm sheet"
)
margins = values
if not isinstance(center, bool):
raise ValueError(f"center: must be true or false, got {center!r}")
return {
"paper": name,
"orientation": orientation,
"size_mm": [width, height],
"canonical_media_name": canonical_media_name(name, orientation),
"plot_style": style,
"plot_style_known": plot_style_known(style),
"scale": label,
"scale_factor": SCALES[label],
"scale_ratio": [ratio[0], ratio[1]],
"dxf_standard_scale_type": DXF_STANDARD_SCALE_TYPE.get(label),
"activex_standard_scale": ACTIVEX_PLOT_SCALE.get(label),
"plot_area": plot_area,
"plot_type": PLOT_AREAS[plot_area],
"device": device_name,
"margins_mm": margins,
"center": center,
}
#: Every key resolve_page_setup emits that a backend reads. A backend refuses
#: a dict missing any of them before it writes.
REQUIRED_SETUP_KEYS: tuple[str, ...] = (
"paper",
"orientation",
"size_mm",
"canonical_media_name",
"plot_style",
"plot_style_known",
"scale",
"scale_ratio",
"dxf_standard_scale_type",
"activex_standard_scale",
"plot_area",
"plot_type",
"device",
"margins_mm",
"center",
)
def require_page_setup(setup) -> dict:
"""The backend-side check: ``setup`` is a complete resolve_page_setup dict."""
if not isinstance(setup, dict):
raise ValueError("page_setup_apply: setup must be the dict resolve_page_setup returns")
missing = [key for key in REQUIRED_SETUP_KEYS if key not in setup]
if missing:
raise ValueError(
f"page_setup_apply: setup is missing {', '.join(missing)} — build it with "
"engineering.standards.papers.resolve_page_setup"
)
return setup
+59
View File
@@ -0,0 +1,59 @@
"""Read a PDF's page size back from the file — no dependency, no rendering.
The evidence rule for page setup (spec §8.3): the sheet size a plot produced is
what the PDF says it is, not what the layout was asked for. ``/MediaBox`` is the
one PDF object that carries it. matplotlib and AutoCAD's DWG To PDF driver both
write it in clear text; a PDF 1.5+ writer may put the page dictionary inside a
FlateDecode object stream, so when the plain scan finds nothing every stream is
inflated and searched. Anything else raises rather than guessing.
"""
from __future__ import annotations
import re
import zlib
from pathlib import Path
__all__ = ["PT_TO_MM", "read_mediabox"]
#: PostScript points to millimetres.
PT_TO_MM = 25.4 / 72.0
_NUMBER = rb"([-+]?\d*\.?\d+)"
_MEDIABOX_RE = re.compile(
rb"/MediaBox\s*\[\s*"
+ _NUMBER
+ rb"\s+"
+ _NUMBER
+ rb"\s+"
+ _NUMBER
+ rb"\s+"
+ _NUMBER
+ rb"\s*\]"
)
_STREAM_RE = re.compile(rb"stream\r?\n(.*?)\r?\nendstream", re.DOTALL)
def read_mediabox(path: str) -> tuple[float, float]:
"""Return the first page ``/MediaBox`` as ``(width_mm, height_mm)``.
Raises ``ValueError`` when the file is not a PDF or carries no MediaBox.
``/Rotate`` is not applied: the box is reported as written.
"""
data = Path(path).read_bytes()
if not data.startswith(b"%PDF"):
raise ValueError(f"read_mediabox: {path} is not a PDF (no %PDF header)")
match = _MEDIABOX_RE.search(data)
if match is None:
for stream in _STREAM_RE.finditer(data):
try:
inflated = zlib.decompress(stream.group(1))
except zlib.error:
continue
match = _MEDIABOX_RE.search(inflated)
if match is not None:
break
if match is None:
raise ValueError(f"read_mediabox: no /MediaBox found in {path}")
x0, y0, x1, y1 = (float(value) for value in match.groups())
return (round(abs(x1 - x0) * PT_TO_MM, 3), round(abs(y1 - y0) * PT_TO_MM, 3))
+493
View File
@@ -0,0 +1,493 @@
"""Page setup on both engines; the PDF's own /MediaBox is the evidence.
ezdxf: apply a paper, export the layout, parse the PDF back and compare to the
paper size. COM: a fake ActiveX layout records the property sequence.
"""
from __future__ import annotations
import re
import types
import zlib
import pytest
from engineering.standards.papers import (
CTB_CATALOG,
PAPER_SIZES,
SCALES,
canonical_media_name,
paper_from_size,
resolve_page_setup,
scale_label,
scale_ratio,
)
from engineering.standards.pdfinfo import read_mediabox
pytestmark = pytest.mark.asyncio
SHEET = "Sheet"
# ── pure data ───────────────────────────────────────────────────────────────
def test_paper_sizes_are_iso_216_and_ansi_y14_1():
assert PAPER_SIZES == {
"ISO_A0": (841, 1189),
"ISO_A1": (594, 841),
"ISO_A2": (420, 594),
"ISO_A3": (297, 420),
"ISO_A4": (210, 297),
"ANSI_A": (216, 279),
"ANSI_B": (279, 432),
"ANSI_C": (432, 559),
"ANSI_D": (559, 864),
"ANSI_E": (864, 1118),
}
assert CTB_CATALOG[0] == "monochrome.ctb" and "acad.ctb" in CTB_CATALOG
assert SCALES["fit"] is None and SCALES["1:50"] == 0.02 and SCALES["10:1"] == 10.0
def test_canonical_media_name_uses_autocad_spelling():
assert canonical_media_name("ISO_A3", "landscape") == "ISO_A3_(420.00_x_297.00_MM)"
assert canonical_media_name("ISO_A3", "portrait") == "ISO_A3_(297.00_x_420.00_MM)"
assert canonical_media_name("ANSI_B", "landscape") == "ANSI_B_(17.00_x_11.00_Inches)"
def test_paper_from_size_matches_either_orientation_within_half_a_millimetre():
assert paper_from_size(420, 297) == "ISO_A3"
assert paper_from_size(297, 420) == "ISO_A3"
assert paper_from_size(431.8, 279.4) == "ANSI_B"
assert paper_from_size(400, 300) is None
def test_scale_ratio_and_label_round_trip():
assert scale_ratio("1:50") == (1.0, 50.0)
assert scale_ratio("2:1") == (2.0, 1.0)
assert scale_label(1.0, 50.0) == "1:50"
assert scale_label(1.0, 3.0) == "1:3"
with pytest.raises(ValueError, match="scale"):
scale_ratio("fit")
def test_resolve_page_setup_defaults():
setup = resolve_page_setup("ISO_A3")
assert setup["paper"] == "ISO_A3"
assert setup["orientation"] == "landscape"
assert setup["size_mm"] == [420.0, 297.0]
assert setup["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)"
assert setup["plot_style"] == "monochrome.ctb" and setup["plot_style_known"] is True
assert setup["scale"] == "fit"
assert setup["dxf_standard_scale_type"] == 0 and setup["activex_standard_scale"] == 0
assert setup["scale_ratio"] == [1.0, 1.0]
assert setup["plot_area"] == "layout" and setup["plot_type"] == 5
assert setup["device"] == "DWG To PDF.pc3"
assert setup["margins_mm"] is None and setup["center"] is True
def test_resolve_page_setup_accepts_lowercase_and_short_paper_names():
assert resolve_page_setup("iso_a4", "portrait")["size_mm"] == [210.0, 297.0]
assert resolve_page_setup("A3")["paper"] == "ISO_A3"
assert resolve_page_setup("ansi_b")["canonical_media_name"] == "ANSI_B_(17.00_x_11.00_Inches)"
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
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
assert five_to_one["activex_standard_scale"] is None
assert five_to_one["scale_ratio"] == [5.0, 1.0]
assert resolve_page_setup("ISO_A3", scale="1:50")["dxf_standard_scale_type"] == 25
assert resolve_page_setup("ISO_A3", scale="1:50")["activex_standard_scale"] == 26
@pytest.mark.parametrize(
"kwargs, field",
[
({"paper": "ISO_A9"}, "paper"),
({"paper": "ISO_A3", "orientation": "sideways"}, "orientation"),
({"paper": "ISO_A3", "scale": "1:3"}, "scale"),
({"paper": "ISO_A3", "plot_area": "window"}, "plot_area"),
({"paper": "ISO_A3", "device": ""}, "device"),
({"paper": "ISO_A3", "plot_style": ""}, "plot_style"),
({"paper": "ISO_A3", "margins_mm": [1, 2, 3]}, "margins_mm"),
({"paper": "ISO_A3", "margins_mm": [-1, 0, 0, 0]}, "margins_mm"),
({"paper": "ISO_A3", "margins_mm": [150, 150, 0, 0]}, "margins_mm"),
({"paper": "ISO_A3", "center": "yes"}, "center"),
],
)
def test_resolve_page_setup_refuses_naming_the_field(kwargs, field):
with pytest.raises(ValueError, match=field):
resolve_page_setup(**kwargs)
def test_unknown_ctb_is_allowed_but_flagged():
setup = resolve_page_setup("ISO_A3", plot_style="company.ctb")
assert setup["plot_style"] == "company.ctb" and setup["plot_style_known"] is False
# ── the PDF reader ──────────────────────────────────────────────────────────
def test_read_mediabox_refuses_a_non_pdf(tmp_path):
bogus = tmp_path / "x.pdf"
bogus.write_bytes(b"not a pdf")
with pytest.raises(ValueError, match="PDF"):
read_mediabox(str(bogus))
def test_read_mediabox_finds_a_box_inside_a_flate_stream(tmp_path):
body = zlib.compress(b"<< /Type /Page /MediaBox [0 0 595.276 841.89] >>")
pdf = tmp_path / "c.pdf"
pdf.write_bytes(
b"%PDF-1.5\n1 0 obj\n<< /Type /ObjStm /Filter /FlateDecode >>\nstream\n"
+ body
+ b"\nendstream\nendobj\n%%EOF"
)
width, height = read_mediabox(str(pdf))
assert abs(width - 210.0) < 0.05 and abs(height - 297.0) < 0.05
def test_read_mediabox_reports_a_missing_box(tmp_path):
pdf = tmp_path / "n.pdf"
pdf.write_bytes(b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF")
with pytest.raises(ValueError, match="MediaBox"):
read_mediabox(str(pdf))
# ── headless engine ─────────────────────────────────────────────────────────
async def _sheet(backend, name: str = SHEET) -> str:
result = await backend.layout_create(name)
assert result["ok"], result
return name
async def _mediabox_after_export(backend, tmp_path, layout: str):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
pdf = tmp_path / f"{layout}.pdf"
result = await backend.drawing_export_pdf(str(pdf), layout=layout)
assert result["ok"] is True, result
return read_mediabox(str(pdf))
async def test_apply_iso_a3_landscape_and_the_pdf_mediabox_agrees(backend, tmp_path):
await _sheet(backend)
result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", "landscape"))
assert result["ok"] is True and result["layout"] == SHEET
assert result["applied"]["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)"
assert result["plot_style_known"] is True
assert result["viewports_kept"] is True
rows = await backend.page_setup_list(SHEET)
assert len(rows) == 1
row = rows[0]
assert row["layout"] == SHEET
assert row["paper"] == "ISO_A3"
assert row["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)"
assert row["size_mm"] == [420.0, 297.0]
assert row["orientation"] == "landscape"
assert row["plot_style"] == "monochrome.ctb"
assert row["scale"] == "fit"
assert row["plot_area"] == "layout"
assert row["device"] == "DWG To PDF.pc3"
assert row["center"] is True
assert len(row["margins_mm"]) == 4
width, height = await _mediabox_after_export(backend, tmp_path, SHEET)
assert abs(width - 420.0) <= 0.5 and abs(height - 297.0) <= 0.5
async def test_apply_ansi_b(backend, tmp_path):
await _sheet(backend)
await backend.page_setup_apply(SHEET, resolve_page_setup("ANSI_B", "landscape", scale="1:1"))
row = (await backend.page_setup_list(SHEET))[0]
assert row["paper"] == "ANSI_B" and row["size_mm"] == [432.0, 279.0]
assert row["scale"] == "1:1"
width, height = await _mediabox_after_export(backend, tmp_path, SHEET)
assert abs(width - 432.0) <= 0.5 and abs(height - 279.0) <= 0.5
async def test_apply_portrait_a4(backend, tmp_path):
await _sheet(backend)
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A4", "portrait"))
row = (await backend.page_setup_list(SHEET))[0]
assert row["orientation"] == "portrait" and row["size_mm"] == [210.0, 297.0]
width, height = await _mediabox_after_export(backend, tmp_path, SHEET)
assert abs(width - 210.0) <= 0.5 and abs(height - 297.0) <= 0.5
async def test_apply_materialises_the_default_flags_before_setting_centre(backend):
"""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."""
await _sheet(backend)
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 flags & 4, "centre-plot bit"
assert flags & 32, "plot with plot styles"
assert flags & 128, "plot entity lineweights"
assert flags & 512, "draw viewports first"
assert flags & 16, "use standard scale (fit)"
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=False))
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
async def test_custom_scale_clears_the_standard_scale_bit_and_writes_the_ratio(backend):
await _sheet(backend)
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:5"))
dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf
assert not int(dxf.plot_layout_flags) & 16
assert (float(dxf.scale_numerator), float(dxf.scale_denominator)) == (1.0, 5.0)
assert (await backend.page_setup_list(SHEET))[0]["scale"] == "1:5"
async def test_apply_keeps_the_viewports(backend):
await _sheet(backend)
await backend.viewport_create(SHEET, 210, 148.5, 260, 180, 0, 0, scale=1.0)
before = (await backend.viewport_list(SHEET))["count"]
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
assert (await backend.viewport_list(SHEET))["count"] == before
async def test_changed_reports_only_what_moved(backend):
await _sheet(backend)
first = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
assert first["changed"], "a fresh layout differs from an A3 setup somewhere"
second = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
assert second["changed"] == {}
third = await backend.page_setup_apply(
SHEET, resolve_page_setup("ISO_A3", plot_style="acad.ctb")
)
assert third["changed"] == {"plot_style": ["monochrome.ctb", "acad.ctb"]}
async def test_unknown_ctb_is_written_and_flagged(backend):
await _sheet(backend)
result = await backend.page_setup_apply(
SHEET, resolve_page_setup("ISO_A3", plot_style="company.ctb")
)
assert result["plot_style_known"] is False
assert (await backend.page_setup_list(SHEET))[0]["plot_style"] == "company.ctb"
async def test_refusals_happen_before_any_write(backend):
await _sheet(backend)
dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf
before = dxf.get("paper_size")
with pytest.raises(ValueError, match="Model"):
await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3"))
with pytest.raises(ValueError, match="Nope"):
await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3"))
with pytest.raises(ValueError, match="canonical_media_name"):
await backend.page_setup_apply(SHEET, {"paper": "ISO_A3"})
assert dxf.get("paper_size") == before
with pytest.raises(ValueError, match="Nope"):
await backend.page_setup_list("Nope")
async def test_list_covers_every_paper_layout_and_skips_model(backend):
await _sheet(backend, "A")
await _sheet(backend, "B")
names = [row["layout"] for row in await backend.page_setup_list()]
assert "Model" not in names and {"A", "B"} <= set(names)
async def test_plot_style_list_headless_is_the_catalog(backend):
rows = await backend.plot_style_list()
assert [row["name"] for row in rows] == list(CTB_CATALOG)
assert all(row["source"] == "catalog" and row["installed"] is None for row in rows)
async def test_dwt_write_capability_is_declared_false_headlessly(backend):
assert backend.capabilities().to_dict()["features"]["dwt_write"]["supported"] is False
# ── live engine, against a fake ActiveX surface ──────────────────────────────
_MEDIA_RE = re.compile(r"\((\d+\.\d+)_x_(\d+\.\d+)_(MM|Inches)\)")
class _FakeLayout:
"""Records every property set and method call, in order."""
def __init__(self, name, media_names):
object.__setattr__(self, "log", [])
object.__setattr__(self, "_media", tuple(media_names))
object.__setattr__(
self,
"_props",
{
"Name": name,
"ConfigName": "None",
"CanonicalMediaName": "ISO_A4_(210.00_x_297.00_MM)",
"StyleSheet": "",
"PlotType": 5,
"StandardScale": 16,
"UseStandardScale": True,
"PlotRotation": 0,
"PaperUnits": 1,
"CenterPlot": False,
"PlotWithPlotStyles": True,
},
)
def __getattr__(self, attr):
props = object.__getattribute__(self, "_props")
if attr in props:
return props[attr]
raise AttributeError(attr)
def __setattr__(self, attr, value):
self._props[attr] = value
self.log.append(("set", attr, value))
def RefreshPlotDeviceInfo(self):
self.log.append(("call", "RefreshPlotDeviceInfo"))
def GetCanonicalMediaNames(self):
self.log.append(("call", "GetCanonicalMediaNames"))
return self._media
def GetPaperSize(self):
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
return (width, height)
def GetPaperMargins(self):
return ((7.5, 20.0), (7.5, 20.0))
def GetCustomScale(self):
return (1.0, 1.0)
def SetCustomScale(self, numerator, denominator):
self.log.append(("call", "SetCustomScale", numerator, denominator))
class _FakeLayouts:
def __init__(self, layouts):
self.layouts = list(layouts)
@property
def Count(self):
return len(self.layouts)
def Item(self, key):
if isinstance(key, int):
return self.layouts[key]
for layout in self.layouts:
if layout.Name == key:
return layout
raise RuntimeError(f"no layout {key}")
MEDIA = (
"ISO_A3_(420.00_x_297.00_MM)",
"ISO_A4_(210.00_x_297.00_MM)",
"ANSI_B_(17.00_x_11.00_Inches)",
)
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
document = types.SimpleNamespace(
Layouts=_FakeLayouts([_FakeLayout("Model", MEDIA), _FakeLayout("Layout1", MEDIA)])
)
monkeypatch.setattr(module, "_acad_doc", lambda: document)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, document
async def test_com_apply_issues_the_property_sequence(com_backend):
backend, document = com_backend
result = await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", "landscape"))
assert result["ok"] is True and result["plot_style_known"] is True
layout = document.Layouts.Item("Layout1")
assert layout.log == [
("call", "RefreshPlotDeviceInfo"),
("set", "ConfigName", "DWG To PDF.pc3"),
("call", "RefreshPlotDeviceInfo"),
("call", "GetCanonicalMediaNames"),
("set", "CanonicalMediaName", "ISO_A3_(420.00_x_297.00_MM)"),
("set", "PaperUnits", 1),
("set", "PlotRotation", 0),
("set", "StyleSheet", "monochrome.ctb"),
("set", "PlotWithPlotStyles", True),
("set", "PlotType", 5),
("set", "StandardScale", 0),
("set", "UseStandardScale", True),
("set", "CenterPlot", True),
]
assert result["changed"]["paper"] == ["ISO_A4", "ISO_A3"]
assert result["changed"]["device"] == ["None", "DWG To PDF.pc3"]
assert result["changed"]["center"] == [False, True]
async def test_com_apply_custom_scale_uses_set_custom_scale(com_backend):
backend, document = com_backend
await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", scale="5:1"))
log = document.Layouts.Item("Layout1").log
assert ("call", "SetCustomScale", 5.0, 1.0) in log
assert ("set", "UseStandardScale", False) in log
assert not any(entry[:2] == ("set", "StandardScale") for entry in log)
async def test_com_apply_refuses_margins_before_any_write(com_backend):
backend, document = com_backend
with pytest.raises(ValueError, match="margins_mm"):
await backend.page_setup_apply(
"Layout1", resolve_page_setup("ISO_A3", margins_mm=[10, 10, 10, 10])
)
assert document.Layouts.Item("Layout1").log == []
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)",))
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)"
async def test_com_apply_refuses_model_and_unknown_layouts(com_backend):
backend, _ = com_backend
with pytest.raises(ValueError, match="Model"):
await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3"))
with pytest.raises(ValueError, match="Nope"):
await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3"))
async def test_com_list_reads_the_properties(com_backend):
backend, _ = com_backend
rows = await backend.page_setup_list()
assert [row["layout"] for row in rows] == ["Layout1"]
row = rows[0]
assert row["paper"] == "ISO_A4" and row["size_mm"] == [210.0, 297.0]
assert row["orientation"] == "portrait"
assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5]
assert row["scale"] == "1:1" and row["plot_area"] == "layout"
assert row["device"] == "None" and row["center"] is False
async def test_com_declares_dwt_write():
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends.com_backend import ComBackend
assert ComBackend().capabilities().to_dict()["features"]["dwt_write"]["supported"] is True