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:
co-authored by
Claude Opus 5
parent
376dc812a5
commit
4a6591843f
+152
-10
@@ -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():
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user