fix(task-17): the headless sheet export plots at the layout's scale and plot area, and reports both

drawing_export_pdf sized the axes window as the sheet at 1:1 whatever
page_setup_apply had written: a 1:2 sheet exported pixel-identical to
the same sheet at 1:1, ok: True, with a MediaBox that read as correct,
so nothing downstream (batch_plot's mediabox gate included) could see it.

_paper_frame now spans the sheet in paper-space units — sheet_mm / unit
x denominator / numerator, the custom ratio for the labels code 75 lacks
(1:5, 5:1) — and honours the plot area: layout (5) from the plot origin;
extents (1) as the paper-space extents (the main viewport excluded, as
AutoCAD excludes it) at the plot origin or centred, scaled to the
printable area under fit. fit under a layout plot is 1:1, the choice
AutoCAD greys out. display/view/limits/window fall back to the sheet
window and say so. The result carries scale, effective_scale,
scale_applied, plot_area, plot_area_applied (and plot_area_note), the
way rotation_applied is already reported.

Tests look at the ink, since the MediaBox cannot see this: an 800-unit
frame ends at (left + 400) / 420 of the width at 1:2 and runs off the
sheet at 1:1; extents + fit spans the printable width exactly; the
custom 1:5 window is 2100 units; an empty extents plot and a display
plot are reported, not hidden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-17 10:01:49 +03:00
co-authored by Claude Opus 5
parent 376dc812a5
commit 4a6591843f
2 changed files with 278 additions and 10 deletions
+152 -10
View File
@@ -1549,6 +1549,13 @@ class EzdxfBackend(AutoCADBackend):
result = {"ok": True, "path": path}
if paper is not None:
result["paper_mm"] = paper["size_mm"]
result["scale"] = paper["scale"]
result["effective_scale"] = paper["effective_scale"]
result["scale_applied"] = paper["scale_applied"]
result["plot_area"] = paper["plot_area"]
result["plot_area_applied"] = paper["plot_area_applied"]
if "plot_area_note" in paper:
result["plot_area_note"] = paper["plot_area_note"]
if not paper["rotation_applied"]:
result["rotation_applied"] = False
if resolved is not None and resolved != "Model":
@@ -2379,10 +2386,71 @@ class EzdxfBackend(AutoCADBackend):
}
@staticmethod
def _paper_frame(layout) -> dict:
def _plot_scale(layout) -> tuple[str, float | None, float | None]:
"""``(label, numerator, denominator)`` of the layout's plot scale; the
terms are ``None`` for *scaled to fit* (code 75 = 0).
Reads the same bits ``_page_setup_row`` reports, so the label here is
the label ``page_setup_list`` shows: a standard code under
``USE_STANDARD_SCALE`` resolves through ezdxf's ``STD_SCALES`` table,
anything else through the stored numerator / denominator.
"""
from ezdxf.lldxf.const import STD_SCALES
from engineering.standards.papers import dxf_scale_label, scale_label
dxf = layout.dxf_layout.dxf
flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags")))
numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator)
if flags & layout.USE_STANDARD_SCALE:
code = int(dxf.standard_scale_type)
if code == 0:
return "fit", None, None
if code in STD_SCALES:
numerator, denominator = (float(v) for v in STD_SCALES[code])
return dxf_scale_label(code, numerator, denominator), numerator, denominator
return scale_label(numerator, denominator), numerator, denominator
@staticmethod
def _plot_extents(layout):
"""Paper-space extents AutoCAD's *extents* plot area means, or ``None``.
``ezdxf.bbox`` counts the main paper-space viewport (id 1, the whole
sheet) as an entity; AutoCAD does not, so it is left out here.
"""
from ezdxf import bbox
entities = [
e for e in layout if not (e.dxftype() == "VIEWPORT" and int(e.dxf.get("id", 0)) == 1)
]
if not entities:
return None
box = bbox.extents(entities, fast=True)
return box if box.has_data else None
@classmethod
def _paper_frame(cls, layout) -> dict:
"""The sheet as a matplotlib figure: size in inches plus the axes window.
Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper.
Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper
and the content lands on it at the layout's plot scale.
**Scale.** The axes window spans the sheet in paper-space units, and
that is ``sheet_mm / unit x denominator / numerator`` — at 1:2 an A3
sheet holds 840 x 594 units, not 420 x 297. It used to be the sheet at
1:1 whatever ``page_setup_apply`` had written (measured: a 1:2 sheet
exported pixel-identical to the same sheet at 1:1, ``ok: True``, and
the MediaBox read as correct, so nothing downstream could see it).
``fit`` on a *layout* plot is 1:1: AutoCAD greys "Fit to paper" out
under that plot area (reported as ``effective_scale``).
**Plot area.** ``layout`` (5) is the sheet from the plot origin.
``extents`` (1) is the paper-space extents (the main viewport excluded,
as AutoCAD excludes it) placed at the plot origin, or centred on the
printable area under ``PLOT_CENTERED``; with ``fit`` the extents are
scaled to the printable area. The plot areas the headless engine cannot
know (display, view) and the ones it does not read (limits, window)
fall back to the sheet window and say so in ``plot_area_applied``.
``plot_rotation`` 1/3 is AutoCAD's landscape-on-portrait-media
convention: the media stays 210 x 297 and the sheet is turned, so
@@ -2396,29 +2464,103 @@ class EzdxfBackend(AutoCADBackend):
the headless renderer still does not do is rotate the *content* on the
media (reported as ``rotation_applied``, not hidden).
"""
from engineering.standards.papers import PLOT_TYPE_NAMES, scale_label
dxf = layout.dxf_layout.dxf
width, height = float(dxf.paper_width), float(dxf.paper_height)
rotation = int(dxf.plot_rotation)
unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0
top, right = float(dxf.top_margin), float(dxf.right_margin)
bottom, left = float(dxf.bottom_margin), float(dxf.left_margin)
# (top, right, bottom, left) after the turn — ezdxf's Page.from_dxf_layout.
if rotation == 1:
sheet_w, sheet_h, left, bottom = height, width, top, left
sheet_w, sheet_h = height, width
top, right, bottom, left = right, bottom, left, top
elif rotation == 2:
sheet_w, sheet_h, left, bottom = width, height, right, top
sheet_w, sheet_h = width, height
top, right, bottom, left = bottom, left, top, right
elif rotation == 3:
sheet_w, sheet_h, left, bottom = height, width, bottom, right
sheet_w, sheet_h = height, width
top, right, bottom, left = left, top, right, bottom
else:
sheet_w, sheet_h = width, height
x0 = -(left + float(dxf.plot_origin_x_offset))
y0 = -(bottom + float(dxf.plot_origin_y_offset))
return {
offset_x = float(dxf.plot_origin_x_offset)
offset_y = float(dxf.plot_origin_y_offset)
printable_w = max(sheet_w - left - right, 1e-9)
printable_h = max(sheet_h - top - bottom, 1e-9)
label, numerator, denominator = cls._plot_scale(layout)
plot_type = int(dxf.plot_type)
plot_area = PLOT_TYPE_NAMES.get(plot_type, str(plot_type))
flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags")))
centered = bool(flags & layout.PLOT_CENTERED)
box = cls._plot_extents(layout) if plot_type == 1 else None
plot_area_applied = plot_type == 5 or box is not None
note = None
if plot_type == 1 and box is None:
note = "extents: the layout holds no entities, so the sheet window was plotted"
elif plot_type not in (1, 5):
note = (
f"plot area {plot_area!r} is not rendered headlessly; the sheet window was plotted"
)
# Paper-space units per millimetre of sheet.
scale_applied = True
effective = label
if (
numerator is not None
and denominator is not None
and (numerator <= 0 or denominator <= 0)
):
scale_applied, effective, units_per_mm = False, "1:1", 1.0 / unit
elif numerator is None: # scaled to fit
if box is not None:
ratios = [
printable_w / box.size.x if box.size.x > 1e-12 else None,
printable_h / box.size.y if box.size.y > 1e-12 else None,
]
usable = [r for r in ratios if r is not None]
mm_per_unit = min(usable) if usable else 1.0
units_per_mm = 1.0 / mm_per_unit
paper_per_unit = mm_per_unit * unit
effective = (
scale_label(paper_per_unit, 1.0)
if paper_per_unit >= 1.0
else scale_label(1.0, 1.0 / paper_per_unit)
)
else:
# Fit under a layout plot is not a choice AutoCAD offers: the
# Page Setup dialog greys it out and the sheet plots at 1:1.
units_per_mm, effective = 1.0 / unit, "1:1"
else:
units_per_mm = denominator / numerator / unit
# Lower-left corner of the *sheet* in paper-space units.
if box is None:
x0 = -(left + offset_x) * units_per_mm
y0 = -(bottom + offset_y) * units_per_mm
elif centered:
x0 = box.center.x - (left + printable_w / 2.0) * units_per_mm
y0 = box.center.y - (bottom + printable_h / 2.0) * units_per_mm
else:
x0 = box.extmin.x - (left + offset_x) * units_per_mm
y0 = box.extmin.y - (bottom + offset_y) * units_per_mm
frame = {
"size_mm": [round(sheet_w, 2), round(sheet_h, 2)],
"figsize": (sheet_w / 25.4, sheet_h / 25.4),
"xlim": (x0 / unit, (x0 + sheet_w) / unit),
"ylim": (y0 / unit, (y0 + sheet_h) / unit),
"xlim": (x0, x0 + sheet_w * units_per_mm),
"ylim": (y0, y0 + sheet_h * units_per_mm),
"rotation_applied": rotation == 0,
"scale": label,
"effective_scale": effective,
"scale_applied": scale_applied,
"plot_area": plot_area,
"plot_area_applied": plot_area_applied,
}
if note:
frame["plot_area_note"] = note
return frame
async def page_setup_list(self, layout: str | None = None) -> list[dict]:
def _sync():
+126
View File
@@ -294,6 +294,132 @@ async def test_rotated_layout_renders_the_whole_sheet(backend, tmp_path):
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."""