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
1209 lines
53 KiB
Python
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]]
|