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:
co-authored by
Claude Opus 5
parent
49fc6ac6a5
commit
f46fda10cb
@@ -30,6 +30,7 @@ from backends.contracts import (
|
|||||||
LayerContract,
|
LayerContract,
|
||||||
LayoutContract,
|
LayoutContract,
|
||||||
LinetypeContract,
|
LinetypeContract,
|
||||||
|
PageSetupContract,
|
||||||
PremiumContract,
|
PremiumContract,
|
||||||
SettingsContract,
|
SettingsContract,
|
||||||
SolidContract,
|
SolidContract,
|
||||||
@@ -168,6 +169,7 @@ class AutoCADBackend(
|
|||||||
IdentityContract,
|
IdentityContract,
|
||||||
DrawingContract,
|
DrawingContract,
|
||||||
LayoutContract,
|
LayoutContract,
|
||||||
|
PageSetupContract,
|
||||||
SolidContract,
|
SolidContract,
|
||||||
EntityCreationContract,
|
EntityCreationContract,
|
||||||
DimensionContract,
|
DimensionContract,
|
||||||
|
|||||||
@@ -811,6 +811,7 @@ class ComBackend(AutoCADBackend):
|
|||||||
"refiner": FeatureCapability(True, "shared"),
|
"refiner": FeatureCapability(True, "shared"),
|
||||||
"delivery": FeatureCapability(True, "shared"),
|
"delivery": FeatureCapability(True, "shared"),
|
||||||
"paper_space": FeatureCapability(True, "native"),
|
"paper_space": FeatureCapability(True, "native"),
|
||||||
|
"dwt_write": FeatureCapability(True, "native"),
|
||||||
"viewport_render": FeatureCapability(True, "native"),
|
"viewport_render": FeatureCapability(True, "native"),
|
||||||
"solid_3d": _solid_3d_capability(),
|
"solid_3d": _solid_3d_capability(),
|
||||||
"lisp": FeatureCapability(True, "sanitized"),
|
"lisp": FeatureCapability(True, "sanitized"),
|
||||||
@@ -1954,6 +1955,183 @@ class ComBackend(AutoCADBackend):
|
|||||||
# CopyObjects-and-delete that would destroy and recreate geometry in the
|
# CopyObjects-and-delete that would destroy and recreate geometry in the
|
||||||
# operator's open drawing when it goes wrong.
|
# 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) ─
|
# ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) ─
|
||||||
|
|
||||||
async def solid_box(
|
async def solid_box(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from backends.contracts.identity import IdentityContract
|
|||||||
from backends.contracts.layers import LayerContract
|
from backends.contracts.layers import LayerContract
|
||||||
from backends.contracts.layouts import LayoutContract
|
from backends.contracts.layouts import LayoutContract
|
||||||
from backends.contracts.linetypes import LinetypeContract
|
from backends.contracts.linetypes import LinetypeContract
|
||||||
|
from backends.contracts.pagesetup import PageSetupContract
|
||||||
from backends.contracts.premium import PremiumContract
|
from backends.contracts.premium import PremiumContract
|
||||||
from backends.contracts.settings import SettingsContract
|
from backends.contracts.settings import SettingsContract
|
||||||
from backends.contracts.solids import SolidContract
|
from backends.contracts.solids import SolidContract
|
||||||
@@ -42,6 +43,7 @@ __all__ = [
|
|||||||
"LayerContract",
|
"LayerContract",
|
||||||
"LayoutContract",
|
"LayoutContract",
|
||||||
"LinetypeContract",
|
"LinetypeContract",
|
||||||
|
"PageSetupContract",
|
||||||
"PremiumContract",
|
"PremiumContract",
|
||||||
"SettingsContract",
|
"SettingsContract",
|
||||||
"SolidContract",
|
"SolidContract",
|
||||||
|
|||||||
@@ -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
@@ -1044,6 +1044,9 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
"refiner": FeatureCapability(True, "shared"),
|
"refiner": FeatureCapability(True, "shared"),
|
||||||
"delivery": FeatureCapability(True, "shared"),
|
"delivery": FeatureCapability(True, "shared"),
|
||||||
"paper_space": FeatureCapability(True, "native"),
|
"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
|
# Measured, not assumed: a sheet holding nothing but a viewport
|
||||||
# renders the model geometry that viewport looks at, at its
|
# renders the model geometry that viewport looks at, at its
|
||||||
# scale and position, clipped to its window. v1.4 declared this
|
# 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 import Frontend, RenderContext
|
||||||
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
|
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])
|
ax = fig.add_axes([0, 0, 1, 1])
|
||||||
ctx = RenderContext(doc)
|
ctx = RenderContext(doc)
|
||||||
out = MatplotlibBackend(ax)
|
out = MatplotlibBackend(ax, adjust_figure=paper is None)
|
||||||
Frontend(ctx, out).draw_layout(target, finalize=True)
|
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)
|
fig.savefig(path, dpi=150)
|
||||||
result = {"ok": True, "path": path}
|
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":
|
if resolved is not None and resolved != "Model":
|
||||||
result["layout"] = resolved
|
result["layout"] = resolved
|
||||||
result["note"] = (
|
result["note"] = (
|
||||||
@@ -2294,6 +2313,182 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
text = f"{float(value):.4f}".rstrip("0").rstrip(".")
|
text = f"{float(value):.4f}".rstrip("0").rstrip(".")
|
||||||
return text or "0"
|
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) ─────
|
# ── 3D solids (unsupported headlessly — honest capability boundary) ─────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user