Files
autocad-mcp-pro/tests/test_page_setup.py
T
Umut CANandClaude Opus 5 f2386a9fdd fix(task-17): a rotated sheet's margins turn with it, and a paper-units move is named in changed and warnings
Finding 1: page_setup_list swapped size_mm for plot_rotation 1/3 but
reported the margins un-turned, so a 297 x 210 sheet carried its portrait
margins ([20, 20, 7.5, 7.5], printable 282 x 170) while ezdxf's Page and
_paper_frame plotted [7.5, 7.5, 20, 20] (257 x 195). The mapping now lives
once in papers.turned_sheet (cross-checked against Page.from_dxf_layout
for all four rotations) and is used by the ezdxf row, _paper_frame and the
COM row. page_setup_apply resets plot_rotation to 0; headlessly it now
re-writes the turned margins un-turned so the sheet keeps the margins it
had (nothing moves, changed says nothing), and on COM, where margins come
from the .pc3, the ones that moved show up in changed.

Finding 2: both engines forced plot paper units to millimetres with no
units field in the row, so an inch-authored ANSI sheet was rescaled 25.4x
with ok: True and changed naming only the media. Rows now carry
paper_units (mm | inches | pixels, DXF group 72 / AcPlotPaperUnits);
resolve_page_setup takes paper_units="mm" (default) | "inches" | None
(keep the layout's units, as AutoCAD's own Page Setup does); a move is
reported in changed and, with its 25.4x consequence and the way back, in
the new warnings list (papers.page_setup_warnings, identical on both
engines).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 14:58:17 +03:00

1209 lines
53 KiB
Python

"""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,
paper_units_name,
resolve_page_setup,
scale_label,
scale_ratio,
turned_sheet,
)
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
assert setup["center"] is None, "a layout plot has no centring (AutoCAD greys it out)"
def test_resolve_page_setup_center_applies_to_an_extents_plot_only():
"""ActiveX Reference, CenterPlot: 'This property cannot be set to True on a
layout object whose PlotType property is set to acLayout.' The resolver
carries the rule so both engines write the same thing: a bool for an
extents plot, ``None`` (not applicable, never written) for a layout plot."""
assert resolve_page_setup("ISO_A3", plot_area="extents")["center"] is True
assert resolve_page_setup("ISO_A3", plot_area="extents", center=False)["center"] is False
assert resolve_page_setup("ISO_A3", plot_area="layout", center=True)["center"] is None
assert resolve_page_setup("ISO_A3", plot_area="layout", center=False)["center"] is None
with pytest.raises(ValueError, match="center"):
resolve_page_setup("ISO_A3", plot_area="layout", center="yes")
def test_resolve_page_setup_accepts_lowercase_and_short_paper_names():
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
# The typelib declares ac1_5 = 19, but AutoCAD 2026 refuses
# StandardScale = 19 ("Invalid input", measured live): custom route.
assert one_to_five["activex_standard_scale"] is None
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
def test_resolve_page_setup_paper_units_default_mm_and_keep():
assert resolve_page_setup("ISO_A3")["paper_units"] == "mm"
assert resolve_page_setup("ANSI_B", paper_units="inches")["paper_units"] == "inches"
assert resolve_page_setup("ANSI_B", paper_units="inch")["paper_units"] == "inches"
assert resolve_page_setup("ANSI_B", paper_units=None)["paper_units"] is None, "keep"
with pytest.raises(ValueError, match="paper_units"):
resolve_page_setup("ISO_A3", paper_units="cm")
def test_paper_units_name_covers_the_three_dxf_and_activex_codes():
assert paper_units_name(0) == "inches" and paper_units_name(1) == "mm"
assert paper_units_name(2) == "pixels"
assert paper_units_name(None) is None and paper_units_name(7) == "code:7"
@pytest.mark.parametrize(
"rotation, size, margins",
[
# stored media 210 x 297 with (top, bottom, left, right) = (1, 3, 4, 2),
# i.e. ezdxf's clockwise (top, right, bottom, left) = (1, 2, 3, 4).
(0, [210.0, 297.0], [1.0, 3.0, 4.0, 2.0]),
(1, [297.0, 210.0], [2.0, 4.0, 1.0, 3.0]),
(2, [210.0, 297.0], [3.0, 1.0, 2.0, 4.0]),
(3, [297.0, 210.0], [4.0, 2.0, 3.0, 1.0]),
],
)
def test_turned_sheet_maps_size_and_margins_like_ezdxf_page_from_dxf_layout(
rotation, size, margins
):
"""The one mapping ``page_setup_list``, ``_paper_frame`` and the COM row share.
Cross-checked against ezdxf's ``Page.from_dxf_layout`` (the renderer's own
reading of PLOTSETTINGS 40-43 under ``plot_rotation``), so the row a
client reads is the sheet the same engine plots.
"""
assert turned_sheet(210, 297, [1, 3, 4, 2], rotation) == (size, margins)
def test_turned_sheet_agrees_with_ezdxf_page_from_dxf_layout():
ezdxf = pytest.importorskip("ezdxf")
from ezdxf.addons.drawing.layout import Page
doc = ezdxf.new()
lay = doc.layouts.new("Turn")
for rotation in (0, 1, 2, 3):
lay.page_setup(size=(210, 297), margins=(1, 2, 3, 4), units="mm", rotation=rotation)
page = Page.from_dxf_layout(lay.dxf_layout)
size, (top, bottom, left, right) = turned_sheet(210, 297, [1, 3, 4, 2], rotation)
assert size == [page.width, page.height], rotation
assert (page.margins.top, page.margins.bottom) == (top, bottom), rotation
assert (page.margins.left, page.margins.right) == (left, right), rotation
# ── 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 None, "layout plot: centring is not applicable"
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
def _ink_columns(png_path: str) -> tuple[int, int, int]:
"""(first inked column, last inked column, image width) of a rendered PNG."""
from PIL import Image
image = Image.open(png_path).convert("L")
width, height = image.size
pixels = image.load()
inked = [x for x in range(width) if any(pixels[x, y] < 128 for y in range(height))]
assert inked, "the render is blank"
return inked[0], inked[-1], width
async def test_rotated_layout_renders_the_whole_sheet(backend, tmp_path):
"""plot_rotation 1 is AutoCAD's landscape-on-portrait-media convention.
A drawing authored in AutoCAD stores ISO A4 landscape as paper 210 x 297
with ``plot_rotation`` 1; paper-space X then runs along the 297 mm side.
The MediaBox alone cannot see a frame that has been clipped inside a
correctly sized page, so this test looks at the ink: the frame's right
edge (x = 277, the printable width) must reach the right of the sheet.
"""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
pytest.importorskip("PIL", reason="pixel check needs Pillow")
await _sheet(backend, "Rot")
lay = backend._doc.layouts.get("Rot")
lay.page_setup(
size=(210, 297),
margins=(20, 7.5, 20, 7.5),
units="mm",
rotation=1,
scale=16,
name="ISO_A4",
device="DWG To PDF.pc3",
)
lay.add_lwpolyline([(0, 0), (277, 0), (277, 190), (0, 190)], close=True)
row = (await backend.page_setup_list("Rot"))[0]
assert row["size_mm"] == [297.0, 210.0] and row["orientation"] == "landscape"
png = tmp_path / "rot.png"
result = await backend.drawing_export_pdf(str(png), layout="Rot")
assert result["ok"] is True, result
assert result["paper_mm"] == [297.0, 210.0]
assert result["rotation_applied"] is False
first, last, width = _ink_columns(str(png))
# x = 277 sits at 277 / 297 of the sheet from the left margin, i.e. well
# past the 0.707-scaled box the unrotated window produced (col 1314/1753).
assert last >= int(width * 0.9), (first, last, width)
pdf = tmp_path / "rot.pdf"
await backend.drawing_export_pdf(str(pdf), layout="Rot")
mm_w, mm_h = read_mediabox(str(pdf))
assert abs(mm_w - 297.0) <= 0.5 and abs(mm_h - 210.0) <= 0.5
async def _export_png(backend, tmp_path, layout: str, stem: str):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
pytest.importorskip("PIL", reason="pixel check needs Pillow")
png = tmp_path / f"{stem}.png"
result = await backend.drawing_export_pdf(str(png), layout=layout)
assert result["ok"] is True, result
return result, _ink_columns(str(png))
async def test_layout_plot_honours_the_plot_scale(backend, tmp_path):
"""A 1:2 sheet holds twice the paper-space units a 1:1 sheet holds.
The MediaBox is the same 420 x 297 either way, so it cannot see this;
the ink can. An 800-unit-wide frame at 1:2 spans 400 mm and ends inside
the sheet; at 1:1 it runs off the right edge. Before the fix the two
exports were pixel-identical (both clipped at the edge) with ``ok: True``.
"""
await _sheet(backend)
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:2"))
lay = backend._doc.layouts.get(SHEET)
lay.add_lwpolyline([(0, 0), (800, 0), (800, 500), (0, 500)], close=True)
_, _, left_mm, _ = (await backend.page_setup_list(SHEET))[0]["margins_mm"]
frame = backend._paper_frame(lay)
assert frame["scale"] == "1:2" and frame["scale_applied"] is True
assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(840.0)
assert frame["ylim"][1] - frame["ylim"][0] == pytest.approx(594.0)
result, (first, last, width) = await _export_png(backend, tmp_path, SHEET, "half")
assert result["scale"] == "1:2" and result["effective_scale"] == "1:2"
assert result["scale_applied"] is True
assert result["plot_area"] == "layout" and result["plot_area_applied"] is True
expected_last = int(width * (left_mm + 400.0) / 420.0)
assert abs(last - expected_last) <= 3, (first, last, width, expected_last)
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:1"))
result, (_, last_full, _) = await _export_png(backend, tmp_path, SHEET, "full")
assert result["scale"] == "1:1"
assert last_full >= width - 2, "at 1:1 the 800-unit frame runs off the 420 mm sheet"
assert last < last_full - 20, "1:2 and 1:1 must not render the same"
async def test_custom_scale_sizes_the_window_too(backend):
"""1:5 has no DXF code 75, so it rides on the numerator / denominator."""
await _sheet(backend)
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:5"))
frame = backend._paper_frame(backend._doc.layouts.get(SHEET))
assert frame["scale"] == "1:5" and frame["scale_applied"] is True
assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(2100.0)
async def test_fit_on_a_layout_plot_is_one_to_one(backend):
"""AutoCAD greys "Fit to paper" out under the layout plot area."""
await _sheet(backend)
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="fit"))
frame = backend._paper_frame(backend._doc.layouts.get(SHEET))
assert frame["scale"] == "fit" and frame["effective_scale"] == "1:1"
assert frame["scale_applied"] is True
assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(420.0)
async def test_extents_plot_fits_the_printable_area_and_centres(backend, tmp_path):
"""extents + fit: the paper-space extents (not the main viewport, which
ezdxf's bbox would count) scale to the printable area and sit centred."""
await _sheet(backend)
await backend.page_setup_apply(
SHEET, resolve_page_setup("ISO_A3", scale="fit", plot_area="extents", center=True)
)
lay = backend._doc.layouts.get(SHEET)
lay.add_lwpolyline([(0, 0), (800, 0), (800, 500), (0, 500)], close=True)
top, bottom, left_mm, right_mm = (await backend.page_setup_list(SHEET))[0]["margins_mm"]
printable_w, printable_h = 420.0 - left_mm - right_mm, 297.0 - top - bottom
mm_per_unit = min(printable_w / 800.0, printable_h / 500.0)
frame = backend._paper_frame(lay)
assert frame["plot_area"] == "extents" and frame["plot_area_applied"] is True
assert frame["scale"] == "fit"
assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(420.0 / mm_per_unit)
result, (first, last, width) = await _export_png(backend, tmp_path, SHEET, "extents")
assert result["effective_scale"] == scale_label(1.0, 1.0 / mm_per_unit)
# The frame is the wider of the two: it spans the printable width exactly.
assert abs(first - int(width * left_mm / 420.0)) <= 3, (first, last, width)
assert abs(last - int(width * (420.0 - right_mm) / 420.0)) <= 3, (first, last, width)
async def test_extents_plot_at_a_fixed_scale_starts_at_the_plot_origin(backend):
await _sheet(backend)
await backend.page_setup_apply(
SHEET, resolve_page_setup("ISO_A3", scale="1:2", plot_area="extents", center=False)
)
lay = backend._doc.layouts.get(SHEET)
lay.add_lwpolyline([(100, 50), (900, 50), (900, 550), (100, 550)], close=True)
_, _, left_mm, _ = (await backend.page_setup_list(SHEET))[0]["margins_mm"]
frame = backend._paper_frame(lay)
assert frame["effective_scale"] == "1:2" and frame["plot_area_applied"] is True
# The extents' lower-left corner lands on the printable corner: sheet x0
# is that corner less the left margin, in paper-space units at 1:2.
assert frame["xlim"][0] == pytest.approx(100.0 - left_mm * 2.0)
assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(840.0)
async def test_extents_plot_of_an_empty_layout_falls_back_to_the_sheet(backend):
await _sheet(backend)
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", plot_area="extents"))
frame = backend._paper_frame(backend._doc.layouts.get(SHEET))
assert frame["plot_area_applied"] is False
assert "no entities" in frame["plot_area_note"]
assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(420.0)
async def test_unrenderable_plot_area_is_reported_not_hidden(backend, tmp_path):
"""display / view need a screen; the headless engine plots the sheet and says so."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _sheet(backend)
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:1"))
lay = backend._doc.layouts.get(SHEET)
lay.dxf_layout.dxf.plot_type = 0 # display
lay.add_line((0, 0), (100, 100))
result = await backend.drawing_export_pdf(str(tmp_path / "display.pdf"), layout=SHEET)
assert result["ok"] is True
assert result["plot_area"] == "display" and result["plot_area_applied"] is False
assert "display" in result["plot_area_note"]
assert result["scale_applied"] is True
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)
extents = resolve_page_setup("ISO_A3", plot_area="extents", center=True)
result = await backend.page_setup_apply(SHEET, extents)
flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
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)"
assert result["applied"]["center"] is True
assert (await backend.page_setup_list(SHEET))[0]["center"] is True
await backend.page_setup_apply(
SHEET, resolve_page_setup("ISO_A3", plot_area="extents", center=False)
)
flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
assert not flags & 4 and flags & 32 and flags & 128 and flags & 512
assert (await backend.page_setup_list(SHEET))[0]["center"] is False
async def test_layout_plot_never_sets_the_centre_bit_and_reports_it_not_applicable(backend):
"""A layout plot is the whole sheet from its origin: AutoCAD disables
'Center the plot' (ActiveX refuses CenterPlot=True under acLayout). The
headless engine mirrors that: bit 4 is not written, and the read-back says
None rather than a bool AutoCAD would ignore."""
await _sheet(backend)
result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=True))
flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
assert not flags & 4, "centre bit must not be set for a layout plot"
assert result["applied"]["center"] is None
assert "center" not in result["changed"], "None before (fresh layout plot) and None after"
assert (await backend.page_setup_list(SHEET))[0]["center"] is None
# Switching an extents+centred sheet to a layout plot clears the stale bit
# (what AutoCAD stores for a layout plot) and reports the move honestly.
await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", plot_area="extents"))
assert (await backend.page_setup_list(SHEET))[0]["center"] is True
result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
assert not flags & 4
assert result["changed"]["center"] == [True, None]
assert result["changed"]["plot_area"] == ["extents", "layout"]
async def test_custom_scale_clears_the_standard_scale_bit_and_writes_the_ratio(backend):
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)
def _rotated_a4(backend, name: str = "Rot", rotation: int = 1):
"""AutoCAD's landscape-on-portrait-media convention: media 210 x 297,
ezdxf clockwise margins (top, right, bottom, left) = (20, 7.5, 20, 7.5),
turned by ``plot_rotation``."""
lay = backend._doc.layouts.get(name)
lay.page_setup(
size=(210, 297),
margins=(20, 7.5, 20, 7.5),
units="mm",
rotation=rotation,
scale=16,
name="ISO_A4",
device="DWG To PDF.pc3",
)
return lay
async def test_list_turns_the_margins_with_the_sheet(backend):
"""The row used to swap ``size_mm`` for rotation 1/3 but report the
margins un-turned: [20, 20, 7.5, 7.5] on a 297 x 210 sheet whose true
margins (ezdxf's Page, and the window ``_paper_frame`` plots) are
[7.5, 7.5, 20, 20] — a printable area of 282 x 170 from the row against
257 x 195 on paper."""
await _sheet(backend, "Rot")
lay = _rotated_a4(backend)
row = (await backend.page_setup_list("Rot"))[0]
assert row["size_mm"] == [297.0, 210.0] and row["orientation"] == "landscape"
assert row["margins_mm"] == [7.5, 7.5, 20.0, 20.0]
top, bottom, left, right = row["margins_mm"]
assert (297 - left - right, 210 - top - bottom) == (257.0, 195.0)
# The same sheet the renderer plots: at 1:1 the axes window starts one
# (turned) left margin before x = 0 and one (turned) bottom margin below y = 0.
frame = backend._paper_frame(lay)
assert frame["size_mm"] == row["size_mm"]
assert frame["xlim"][0] == pytest.approx(-left) and frame["ylim"][0] == pytest.approx(-bottom)
assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(297.0)
assert frame["ylim"][1] - frame["ylim"][0] == pytest.approx(210.0)
@pytest.mark.parametrize("rotation", [1, 2, 3])
async def test_apply_on_a_turned_sheet_keeps_the_effective_margins(backend, rotation):
"""``page_setup_apply`` writes ``plot_rotation`` 0 (orientation rides on
width/height). With no ``margins_mm`` asked for, the stored margins used
to stay as they were while the turn that gave them their meaning went
away, so the effective margins moved from [7.5, 7.5, 20, 20] to
[20, 20, 7.5, 7.5] (printable 257 x 195 -> 282 x 170) and ``changed``
said nothing. The stored margins are now re-mapped so the sheet keeps
the margins it had, and the row before equals the row after."""
await _sheet(backend, "Rot")
lay = _rotated_a4(backend, rotation=rotation)
before = (await backend.page_setup_list("Rot"))[0]
result = await backend.page_setup_apply(
"Rot", resolve_page_setup("ISO_A4", before["orientation"], scale="1:1")
)
after = (await backend.page_setup_list("Rot"))[0]
assert int(lay.dxf_layout.dxf.plot_rotation) == 0
assert after["margins_mm"] == before["margins_mm"]
assert after["size_mm"] == before["size_mm"]
assert "margins_mm" not in result["changed"] and "size_mm" not in result["changed"]
dxf = lay.dxf_layout.dxf
top, bottom, left, right = after["margins_mm"]
assert (dxf.top_margin, dxf.bottom_margin, dxf.left_margin, dxf.right_margin) == (
top,
bottom,
left,
right,
)
async def test_apply_with_margins_on_a_turned_sheet_writes_them_as_asked(backend):
await _sheet(backend, "Rot")
_rotated_a4(backend)
result = await backend.page_setup_apply(
"Rot", resolve_page_setup("ISO_A4", "landscape", margins_mm=[5, 6, 7, 8])
)
row = (await backend.page_setup_list("Rot"))[0]
assert row["margins_mm"] == [5.0, 6.0, 7.0, 8.0]
assert result["changed"]["margins_mm"] == [[7.5, 7.5, 20.0, 20.0], [5.0, 6.0, 7.0, 8.0]]
def _inch_sheet(backend, name: str = "In"):
"""An inch-authored ANSI B sheet (what ANSI_A-E exist for): paper-space
units are inches, so at 1:1 a 16-unit frame spans 16 inches of paper."""
lay = backend._doc.layouts.get(name)
lay.page_setup(
size=(17, 11),
margins=(0.5, 0.5, 0.5, 0.5),
units="inch",
scale=16,
name="ANSI_B",
device="DWG To PDF.pc3",
)
lay.add_lwpolyline([(0, 0), (16, 0), (16, 10), (0, 10)], close=True)
return lay
async def test_row_reports_the_paper_units(backend):
await _sheet(backend, "In")
lay = _inch_sheet(backend)
row = (await backend.page_setup_list("In"))[0]
assert row["paper_units"] == "inches"
assert row["size_mm"] == [431.8, 279.4] and row["paper"] == "ANSI_B"
assert row["margins_mm"] == [12.7, 12.7, 12.7, 12.7]
lay.dxf_layout.dxf.plot_paper_units = 1
assert (await backend.page_setup_list("In"))[0]["paper_units"] == "mm"
async def test_apply_names_a_paper_units_flip_and_its_consequence(backend):
"""The default setup plots in millimetres. On an inch-authored sheet that
is a 25.4x rescale of every paper-space unit: the 16-unit frame used to
go from 406.4 mm to 16 mm of paper with ``changed`` naming only the
media and the plot style — ``ok: True``, ``scale_applied: True``."""
await _sheet(backend, "In")
lay = _inch_sheet(backend)
result = await backend.page_setup_apply(
"In", resolve_page_setup("ANSI_B", "landscape", scale="1:1")
)
assert result["changed"]["paper_units"] == ["inches", "mm"]
assert int(lay.dxf_layout.dxf.plot_paper_units) == 1
assert result["warnings"], "a units flip is never silent"
assert any("inches" in w and "mm" in w and "25.4" in w for w in result["warnings"])
assert result["applied"]["paper_units"] == "mm"
# Re-applying the same setup moves nothing and warns of nothing.
again = await backend.page_setup_apply(
"In", resolve_page_setup("ANSI_B", "landscape", scale="1:1")
)
assert again["changed"] == {} and again["warnings"] == []
async def test_apply_can_keep_or_set_the_paper_units(backend):
await _sheet(backend, "In")
lay = _inch_sheet(backend)
kept = await backend.page_setup_apply(
"In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units=None)
)
assert int(lay.dxf_layout.dxf.plot_paper_units) == 0
assert "paper_units" not in kept["changed"] and kept["warnings"] == []
assert kept["applied"]["paper_units"] is None
# The catalogue's ANSI_B is 432 x 279 mm (ANSI Y14.1's metric value), so
# at 1:1 under inches the sheet holds 432 / 25.4 units across.
frame = backend._paper_frame(lay)
assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(432 / 25.4), "inches at 1:1"
to_mm = await backend.page_setup_apply(
"In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units="mm")
)
assert to_mm["changed"]["paper_units"] == ["inches", "mm"] and to_mm["warnings"]
frame = backend._paper_frame(lay)
assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(432.0), "432 mm at 1:1"
back = await backend.page_setup_apply(
"In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units="inches")
)
assert back["changed"]["paper_units"] == ["mm", "inches"] and back["warnings"]
assert int(lay.dxf_layout.dxf.plot_paper_units) == 0
async def test_apply_on_a_mm_sheet_warns_of_nothing(backend):
await _sheet(backend)
result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
assert result["warnings"] == [] and "paper_units" not in result["changed"]
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.
Enforces the one rule the ActiveX Reference documents for this surface
(CenterPlot Property, acadauto.chm, AutoCAD 2026): "This property cannot
be set to True on a layout object whose PlotType property is set to
acLayout." ``refuse`` names properties whose write raises, the way a live
AutoCAD refuses a value it cannot take.
Replays two behaviours measured live on AutoCAD 2026 (2026-09-17):
* ``GetPaperSize`` / ``GetPaperMargins`` answer in millimetres whatever
``PaperUnits`` says (ANSI_B under PaperUnits 0 and 1 -> (431.8, 279.4)).
* Writing ``ConfigName`` replaces ``CanonicalMediaName`` with the new
device's default when the current media does not exist on it, and
writing the old device back does not bring the old media back.
``media_names`` is therefore either one tuple (every device carries it)
or ``{device: tuple}``; a device missing from the dict carries nothing.
"""
AC_LAYOUT = 5
def __init__(self, name, media_names, refuse=()):
object.__setattr__(self, "log", [])
media = media_names if isinstance(media_names, dict) else {None: tuple(media_names)}
object.__setattr__(self, "_media", {k: tuple(v) for k, v in media.items()})
object.__setattr__(self, "_refuse", tuple(refuse))
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):
if attr in self._refuse:
raise RuntimeError(f"AutoCAD refused Layout.{attr} = {value!r}")
if attr == "CenterPlot" and value and self._props["PlotType"] == self.AC_LAYOUT:
raise RuntimeError("CenterPlot cannot be True while PlotType is acLayout")
self._props[attr] = value
self.log.append(("set", attr, value))
if attr == "ConfigName":
names = self._device_media()
if names and self._props["CanonicalMediaName"] not in names:
self._props["CanonicalMediaName"] = names[0] # the device's default
def _device_media(self):
media = object.__getattribute__(self, "_media")
return media.get(self._props["ConfigName"], media.get(None, ()))
def snapshot(self) -> dict:
return dict(object.__getattribute__(self, "_props"))
def RefreshPlotDeviceInfo(self):
self.log.append(("call", "RefreshPlotDeviceInfo"))
def GetCanonicalMediaNames(self):
self.log.append(("call", "GetCanonicalMediaNames"))
return self._device_media()
def GetPaperSize(self):
# Millimetres whatever PaperUnits says (measured live, see the class docstring).
match = _MEDIA_RE.search(self._props["CanonicalMediaName"])
width, height = float(match.group(1)), float(match.group(2))
if match.group(3) == "Inches":
width, height = round(width * 25.4, 2), round(height * 25.4, 2)
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),
], "a layout plot never writes CenterPlot (ActiveX refuses True under acLayout)"
assert result["changed"]["paper"] == ["ISO_A4", "ISO_A3"]
assert result["changed"]["device"] == ["None", "DWG To PDF.pc3"]
assert result["applied"]["center"] is None
assert "center" not in result["changed"]
async def test_com_apply_extents_sets_plot_type_before_center_plot(com_backend):
backend, document = com_backend
result = await backend.page_setup_apply(
"Layout1", resolve_page_setup("ISO_A3", plot_area="extents", center=True)
)
log = document.Layouts.Item("Layout1").log
assert log.index(("set", "PlotType", 1)) < log.index(("set", "CenterPlot", True))
assert result["changed"]["center"] == [None, True]
assert result["changed"]["plot_area"] == ["layout", "extents"]
assert (await backend.page_setup_list("Layout1"))[0]["center"] is True
async def test_com_apply_clears_a_stale_center_plot_before_switching_to_layout(com_backend):
backend, document = com_backend
layout = document.Layouts.Item("Layout1")
layout.PlotType = 1
layout.CenterPlot = True
del layout.log[:]
result = await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3"))
log = layout.log
assert log.index(("set", "CenterPlot", False)) < log.index(("set", "PlotType", 5))
assert layout.CenterPlot is False and layout.PlotType == 5
assert result["changed"]["center"] == [True, None]
async def test_com_apply_restores_every_write_when_autocad_refuses_a_property(com_backend):
"""Nine writes had landed before CenterPlot in the shipped sequence, with
nothing put back. Any refusal now unwinds the journal in reverse."""
backend, document = com_backend
layout = _FakeLayout("Layout1", MEDIA, refuse=("PlotWithPlotStyles",))
document.Layouts.layouts[1] = layout
before = layout.snapshot()
with pytest.raises(ValueError, match="PlotWithPlotStyles") as info:
await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3"))
assert "restored" in str(info.value)
assert layout.snapshot() == before, "every property AutoCAD accepted is put back"
restores = [entry for entry in layout.log if entry[0] == "set"][5:]
assert restores == [
("set", "StyleSheet", ""),
("set", "PlotRotation", 0),
("set", "PaperUnits", 1),
("set", "CanonicalMediaName", "ISO_A4_(210.00_x_297.00_MM)"),
("set", "ConfigName", "None"),
], "unwound in reverse write order"
async def test_com_apply_restores_the_custom_scale_too(com_backend):
backend, document = com_backend
layout = _FakeLayout("Layout1", MEDIA, refuse=("UseStandardScale",))
document.Layouts.layouts[1] = layout
with pytest.raises(ValueError, match="UseStandardScale"):
await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", scale="5:1"))
calls = [entry for entry in layout.log if entry[0] == "call" and entry[1] == "SetCustomScale"]
assert calls == [("call", "SetCustomScale", 5.0, 1.0), ("call", "SetCustomScale", 1.0, 1.0)]
async def test_com_apply_custom_scale_uses_set_custom_scale(com_backend):
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", {None: ("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)"
PDF_DEVICE = "DWG To PDF.pc3"
MS_DEVICE = "Microsoft Print to PDF"
ISO_A3_MEDIA = "ISO_A3_(420.00_x_297.00_MM)"
#: Two devices whose media do not overlap, the way the two PDF drivers on the
#: machine measured: switching to MS moves an ISO_A3 layout to psk:ISOA4, and
#: switching back lands on DWG To PDF's default, not on ISO_A3.
TWO_DEVICES = {
PDF_DEVICE: ("ANSI_A_(11.00_x_8.50_Inches)", ISO_A3_MEDIA),
MS_DEVICE: ("psk:ISOA4", "ISO_A0_(1189.00_x_841.00_MM)"),
}
def _layout_on_pdf_device(document, **kwargs):
layout = _FakeLayout("Layout1", TWO_DEVICES, **kwargs)
layout.ConfigName = PDF_DEVICE
layout.CanonicalMediaName = ISO_A3_MEDIA
del layout.log[:]
document.Layouts.layouts[1] = layout
return layout
async def test_com_apply_puts_the_media_back_when_the_device_switch_replaced_it(com_backend):
"""Measured live: refusing a media unknown on the new device used to leave
the layout on the old device with that device's *default* media (ANSI_A),
not the ISO_A3 it had — while the error said it was restored."""
backend, document = com_backend
layout = _layout_on_pdf_device(document)
before = layout.snapshot()
with pytest.raises(ValueError, match="has no media") as info:
await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A2", device=MS_DEVICE))
assert "could NOT" not in str(info.value)
assert layout.snapshot() == before, "device back, then the original media re-applied"
assert (await backend.page_setup_list("Layout1"))[0]["paper"] == "ISO_A3"
async def test_com_apply_late_refusal_unwinds_through_the_device_switch(com_backend):
"""The media journaled *after* the device switch is the new device's
default; only the pre-switch snapshot can put ISO_A3 back."""
backend, document = com_backend
layout = _layout_on_pdf_device(document, refuse=("PlotWithPlotStyles",))
before = layout.snapshot()
with pytest.raises(ValueError, match="PlotWithPlotStyles") as info:
await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A0", device=MS_DEVICE))
assert "restored to its previous page setup" in str(info.value)
assert layout.snapshot() == before
sets = [entry for entry in layout.log if entry[0] == "set"]
assert sets[-2:] == [
("set", "ConfigName", PDF_DEVICE),
("set", "CanonicalMediaName", ISO_A3_MEDIA),
]
class _StickyMediaLayout(_FakeLayout):
"""Accepts every write but silently drops CanonicalMediaName once the device
has been switched back — what a driver that ignores the write looks like."""
def __setattr__(self, attr, value):
if attr == "CanonicalMediaName" and self._props["ConfigName"] == PDF_DEVICE and self.log:
return
super().__setattr__(attr, value)
async def test_com_apply_reports_what_the_unwind_could_not_put_back(com_backend):
backend, document = com_backend
layout = _StickyMediaLayout("Layout1", TWO_DEVICES)
layout.ConfigName = PDF_DEVICE
object.__getattribute__(layout, "_props")["CanonicalMediaName"] = ISO_A3_MEDIA
del layout.log[:]
document.Layouts.layouts[1] = layout
with pytest.raises(ValueError, match="has no media") as info:
await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A2", device=MS_DEVICE))
message = str(info.value)
assert "could NOT be fully restored" in message
assert "canonical_media_name" in message and "paper" in message
assert layout.ConfigName == PDF_DEVICE and layout.CanonicalMediaName != ISO_A3_MEDIA
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"
assert row["center"] is None, "acLayout: CenterPlot is not applicable"
async def test_com_list_never_scales_the_paper_by_paper_units(com_backend):
"""GetPaperSize / GetPaperMargins are millimetres under PaperUnits=0 too
(measured live) — the 25.4 factor listed every imperial layout as
[10668.0, 7543.8] and lost the paper name."""
backend, document = com_backend
layout = document.Layouts.Item("Layout1")
layout.PaperUnits = 0
layout.CanonicalMediaName = "ANSI_B_(17.00_x_11.00_Inches)"
row = (await backend.page_setup_list("Layout1"))[0]
assert row["size_mm"] == [431.8, 279.4] and row["paper"] == "ANSI_B"
assert row["orientation"] == "landscape"
assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5]
layout.CanonicalMediaName = "ISO_A3_(420.00_x_297.00_MM)"
assert (await backend.page_setup_list("Layout1"))[0]["size_mm"] == [420.0, 297.0]
async def test_com_list_reports_center_for_an_extents_plot(com_backend):
backend, document = com_backend
layout = document.Layouts.Item("Layout1")
layout.PlotType = 1
assert (await backend.page_setup_list("Layout1"))[0]["center"] is False
layout.CenterPlot = True
assert (await backend.page_setup_list("Layout1"))[0]["center"] is True
async def test_com_declares_dwt_write():
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
async def test_com_list_reports_the_paper_units(com_backend):
backend, document = com_backend
layout = document.Layouts.Item("Layout1")
assert (await backend.page_setup_list("Layout1"))[0]["paper_units"] == "mm"
layout.PaperUnits = 0
assert (await backend.page_setup_list("Layout1"))[0]["paper_units"] == "inches"
async def test_com_list_turns_the_margins_with_a_rotated_sheet(com_backend):
"""GetPaperMargins reads PLOTSETTINGS 40-43 — the media's own frame, the
fields the DXF row reads — so a rotated sheet turns them the same way."""
backend, document = com_backend
layout = document.Layouts.Item("Layout1")
layout.PlotRotation = 1
row = (await backend.page_setup_list("Layout1"))[0]
assert row["size_mm"] == [297.0, 210.0]
# fake margins: left 7.5, bottom 20, right 7.5, top 20 -> rotation 1:
# top=right, right=bottom, bottom=left, left=top -> [7.5, 7.5, 20, 20].
assert row["margins_mm"] == [7.5, 7.5, 20.0, 20.0]
layout.PlotRotation = 0
assert (await backend.page_setup_list("Layout1"))[0]["margins_mm"] == [20.0, 20.0, 7.5, 7.5]
async def test_com_apply_names_a_paper_units_flip(com_backend):
backend, document = com_backend
layout = document.Layouts.Item("Layout1")
layout.PaperUnits = 0
layout.log.clear()
result = await backend.page_setup_apply(
"Layout1", resolve_page_setup("ANSI_B", "landscape", scale="1:1")
)
assert ("set", "PaperUnits", 1) in layout.log
assert result["changed"]["paper_units"] == ["inches", "mm"]
assert any("25.4" in w for w in result["warnings"])
again = await backend.page_setup_apply(
"Layout1", resolve_page_setup("ANSI_B", "landscape", scale="1:1")
)
assert again["changed"] == {} and again["warnings"] == []
async def test_com_apply_keeps_or_sets_the_paper_units(com_backend):
backend, document = com_backend
layout = document.Layouts.Item("Layout1")
layout.PaperUnits = 0
layout.log.clear()
kept = await backend.page_setup_apply(
"Layout1", resolve_page_setup("ANSI_B", "landscape", paper_units=None)
)
assert not [entry for entry in layout.log if entry[:2] == ("set", "PaperUnits")]
assert layout.PaperUnits == 0 and "paper_units" not in kept["changed"]
assert kept["warnings"] == []
layout.log.clear()
inches = await backend.page_setup_apply(
"Layout1", resolve_page_setup("ANSI_B", "landscape", paper_units="inches")
)
assert ("set", "PaperUnits", 0) in layout.log and inches["changed"] == {}
assert inches["warnings"] == []
async def test_com_apply_on_a_rotated_sheet_reports_the_margins_that_moved(com_backend):
"""COM cannot write margins (they come from the .pc3), so resetting
PlotRotation on a turned sheet moves the effective margins — and the row
now says so instead of listing only the media."""
backend, document = com_backend
layout = document.Layouts.Item("Layout1")
layout.PlotRotation = 1
result = await backend.page_setup_apply(
"Layout1", resolve_page_setup("ISO_A3", "landscape", scale="1:1")
)
assert result["changed"]["margins_mm"] == [[7.5, 7.5, 20.0, 20.0], [20.0, 20.0, 7.5, 7.5]]
assert result["changed"]["size_mm"] == [[297.0, 210.0], [420.0, 297.0]]