Two measured root causes on the headless engine, both silent: 1. page_setup_apply wrote PLOTSETTINGS group 147 (unit_factor) as 1.0 for every scale. Under USE_STANDARD_SCALE AutoCAD's plot engine scales by group 147 alone (a 1:10 sheet with code 21 and 147 = 1.0 plotted a 100-unit line at 99.99 mm while the row, `applied` and 142/143 said 1:10; patching 147 to 0.1 alone gave 9.99 mm). AutoCAD writes 147 as numerator / denominator (measured live on AutoCAD 2026 for fit, 1:1, 1:2, 1:10, 1:50, 2:1, 10:1: 1.0, 1.0, 0.5, 0.1, 0.02, 2.0, 10.0). The writer now stores 75, 142/143 and 147 from the one resolved ratio on every route, so no reader can disagree with the plot. 2. DXF_STANDARD_SCALE_TYPE and _plot_scale followed ezdxf's STD_SCALES, which has no 19 = 1:5 and numbers everything above 18 one lower than the ObjectARX StdScaleType enum AutoCAD actually writes (1:10 = 21, 1:20 = 23, 1:50 = 26, 1:100 = 27, 2:1 = 28, 10:1 = 31 — measured via COM SaveAs DXF). An AutoCAD-authored 1:50 sheet listed as "1:100" and a 1:10 sheet rendered at 1:16. papers.py now authors the full file enum (DXF_STD_SCALES, codes 1-33 with AutoCAD's imperial names), the writer uses the same codes ActiveX uses, and nothing goes through ezdxf's table. 1:5 stays on the custom route on both engines (the live setter refuses 19) while the reader knows code 19 for files AutoCAD wrote. Tests: the plan's `1:50 -> 25` assertion was the stale numbering and now pins 26; a pure-data test pins the enum against the measured codes and the ActiveX numbering; a parametrised writer test checks 75/142/143/147 for every catalogue scale; a reader test stores the scale exactly as AutoCAD saves it (including codes 19 and 22 the writer never emits) and checks page_setup_list and _paper_frame report and render it at its own scale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
1347 lines
59 KiB
Python
1347 lines
59 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 (
|
|
ACTIVEX_PLOT_SCALE,
|
|
CTB_CATALOG,
|
|
DXF_STANDARD_SCALE_TYPE,
|
|
DXF_STD_SCALES,
|
|
PAPER_SIZES,
|
|
SCALES,
|
|
canonical_media_name,
|
|
dxf_scale_label,
|
|
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")
|
|
# Code 19 is 1:5 in the file enum, but the typelib's ac1_5 = 19 is refused
|
|
# by AutoCAD 2026 ("Invalid input", measured live), so both engines store
|
|
# 1:5 the same way: the custom route, 142/143 = 1/5.
|
|
assert one_to_five["dxf_standard_scale_type"] is None
|
|
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]
|
|
# One numbering: AutoCAD 2026 saves StandardScale = 26 (ac1_50) as
|
|
# group 75 = 26 (measured, COM SaveAs DXF) — not ezdxf's 25.
|
|
assert resolve_page_setup("ISO_A3", scale="1:50")["dxf_standard_scale_type"] == 26
|
|
assert resolve_page_setup("ISO_A3", scale="1:50")["activex_standard_scale"] == 26
|
|
|
|
|
|
def test_dxf_standard_scale_codes_are_the_enum_autocad_writes():
|
|
"""Group 75 is the ObjectARX ``StdScaleType`` enum, the same numbering as
|
|
ActiveX ``AcPlotScale`` — measured on AutoCAD 2026 (``StandardScale = n``
|
|
then ``SaveAs(..., 61)``): 1:2→17, 1:10→21, 1:50→26, 2:1→28, 10:1→31.
|
|
ezdxf's ``STD_SCALES`` has no 19 = 1:5 and is one lower above 18; read
|
|
through it, an AutoCAD 1:50 sheet is "1:100" and a 1:10 sheet plots 1:16.
|
|
"""
|
|
measured = {"fit": 0, "1:1": 16, "1:2": 17, "1:10": 21, "1:50": 26, "2:1": 28, "10:1": 31}
|
|
for label, code in measured.items():
|
|
assert DXF_STANDARD_SCALE_TYPE[label] == code, label
|
|
# Every code the writer uses is the ActiveX code for the same label …
|
|
for label, code in DXF_STANDARD_SCALE_TYPE.items():
|
|
assert ACTIVEX_PLOT_SCALE[label] == code, label
|
|
# … and its authored ratio is the catalogue factor.
|
|
for label, code in DXF_STANDARD_SCALE_TYPE.items():
|
|
if code:
|
|
numerator, denominator = DXF_STD_SCALES[code]
|
|
assert numerator / denominator == pytest.approx(SCALES[label]), label
|
|
# The full file enum, including what the writer never emits.
|
|
assert DXF_STD_SCALES[19] == (1.0, 5.0)
|
|
assert DXF_STD_SCALES[20] == (1.0, 8.0)
|
|
assert DXF_STD_SCALES[22] == (1.0, 16.0)
|
|
assert DXF_STD_SCALES[25] == (1.0, 40.0)
|
|
assert DXF_STD_SCALES[33] == (1000.0, 1.0)
|
|
assert set(DXF_STD_SCALES) == set(range(1, 34))
|
|
# Read-back labels come from the code, never from a stale 142/143.
|
|
assert dxf_scale_label(0, 3.0, 7.0) == "fit"
|
|
assert dxf_scale_label(26, 1.0, 100.0) == "1:50"
|
|
assert dxf_scale_label(21, 1.0, 1.0) == "1:10"
|
|
assert dxf_scale_label(19, 1.0, 1.0) == "1:5"
|
|
assert dxf_scale_label(22, 1.0, 1.0) == "1:16"
|
|
assert dxf_scale_label(28, 1.0, 0.5) == "2:1"
|
|
assert dxf_scale_label(8, 1.0, 1.0) == "1/4\"=1'"
|
|
assert dxf_scale_label(99, 1.0, 3.0) == "1:3" # unknown code: the stored ratio
|
|
|
|
|
|
@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 takes the custom route (as on the live engine), 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 float(dxf.unit_factor) == pytest.approx(0.2)
|
|
assert (await backend.page_setup_list(SHEET))[0]["scale"] == "1:5"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"label, code, factor",
|
|
[
|
|
("fit", 0, 1.0),
|
|
("1:1", 16, 1.0),
|
|
("1:2", 17, 0.5),
|
|
("1:10", 21, 0.1),
|
|
("1:20", 23, 0.05),
|
|
("1:50", 26, 0.02),
|
|
("1:100", 27, 0.01),
|
|
("2:1", 28, 2.0),
|
|
("10:1", 31, 10.0),
|
|
],
|
|
)
|
|
async def test_standard_scale_writes_the_code_and_the_factor_autocad_plots_by(
|
|
backend, label, code, factor
|
|
):
|
|
"""Under USE_STANDARD_SCALE AutoCAD's plot engine scales by group 147
|
|
alone (measured, AutoCAD 2026: a headless 1:10 sheet with code 21 and
|
|
147 = 1.0 plotted a 100-unit line at 99.99 mm — 1:1 — while the row,
|
|
``applied`` and 142/143 all said 1:10; patching 147 to 0.1 alone gave
|
|
9.99 mm). AutoCAD writes 147 = numerator / denominator, 1.0 for fit; so
|
|
does the headless writer, on every route, with the code AutoCAD uses.
|
|
"""
|
|
await _sheet(backend)
|
|
result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale=label))
|
|
assert result["ok"] is True and result["applied"]["scale"] == label
|
|
dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf
|
|
assert int(dxf.plot_layout_flags) & 16
|
|
assert int(dxf.standard_scale_type) == code
|
|
assert float(dxf.unit_factor) == pytest.approx(factor)
|
|
ratio = [1.0, 1.0] if label == "fit" else list(scale_ratio(label))
|
|
assert [float(dxf.scale_numerator), float(dxf.scale_denominator)] == ratio
|
|
assert (await backend.page_setup_list(SHEET))[0]["scale"] == label
|
|
frame = backend._paper_frame(backend._doc.layouts.get(SHEET))
|
|
assert frame["scale"] == label and frame["scale_applied"] is True
|
|
if label != "fit":
|
|
assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(420.0 / factor)
|
|
|
|
|
|
def _autocad_authored_scale(layout, code, numerator, denominator, factor):
|
|
"""Store the plot scale exactly as AutoCAD 2026 saves it (measured via COM
|
|
``SaveAs(..., 61)``): group 75 from the ObjectARX enum, 142/143 as the
|
|
ratio (AutoCAD normalises 2:1 to 1 / 0.5), 147 as the factor, bit 16 set.
|
|
"""
|
|
dxf = layout.dxf_layout.dxf
|
|
if not dxf.hasattr("plot_layout_flags"):
|
|
dxf.plot_layout_flags = dxf.get_default("plot_layout_flags")
|
|
dxf.paper_size = "ISO_A3_(420.00_x_297.00_MM)"
|
|
dxf.paper_width, dxf.paper_height = 420.0, 297.0
|
|
dxf.plot_paper_units = 1
|
|
dxf.plot_rotation = 0
|
|
dxf.standard_scale_type = code
|
|
dxf.scale_numerator, dxf.scale_denominator = numerator, denominator
|
|
dxf.unit_factor = factor
|
|
layout.use_standard_scale(True)
|
|
layout.set_plot_type(5)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"code, numerator, denominator, factor, label, units_across",
|
|
[
|
|
(21, 1.0, 10.0, 0.1, "1:10", 4200.0),
|
|
(23, 1.0, 20.0, 0.05, "1:20", 8400.0),
|
|
(26, 1.0, 50.0, 0.02, "1:50", 21000.0),
|
|
(27, 1.0, 100.0, 0.01, "1:100", 42000.0),
|
|
(28, 1.0, 0.5, 2.0, "2:1", 210.0),
|
|
(31, 1.0, 0.1, 10.0, "10:1", 42.0),
|
|
(19, 1.0, 5.0, 0.2, "1:5", 2100.0),
|
|
(22, 1.0, 16.0, 0.0625, "1:16", 6720.0),
|
|
],
|
|
)
|
|
async def test_list_and_render_read_an_autocad_authored_sheet_at_its_own_scale(
|
|
backend, code, numerator, denominator, factor, label, units_across
|
|
):
|
|
"""A sheet AutoCAD saved at 1:50 (group 75 = 26) reads back as 1:50 and
|
|
renders 21000 paper-space units across the 420 mm — not "1:100" / 42000
|
|
(what ezdxf's one-off ``STD_SCALES`` made of it: 1:10 rendered at 1:16,
|
|
1:20 at 1:30, 1:50 at 1:100, 1:100 at 2:1, 2:1 at 4:1, 10:1 at 100:1,
|
|
with the row and the plot disagreeing). Codes 19 and 22 are ones this
|
|
server never writes, so this reads the file enum, not the writer's table.
|
|
"""
|
|
await _sheet(backend)
|
|
lay = backend._doc.layouts.get(SHEET)
|
|
_autocad_authored_scale(lay, code, numerator, denominator, factor)
|
|
row = (await backend.page_setup_list(SHEET))[0]
|
|
assert row["scale"] == label
|
|
frame = backend._paper_frame(lay)
|
|
assert frame["scale"] == label and frame["effective_scale"] == label
|
|
assert frame["scale_applied"] is True
|
|
assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(units_across)
|
|
|
|
|
|
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]]
|