From 4a6591843ff8260bfae8501fa338974e49fedb88 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 10:01:49 +0300 Subject: [PATCH] fix(task-17): the headless sheet export plots at the layout's scale and plot area, and reports both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 162 +++++++++++++++++++++++++++++++++++--- tests/test_page_setup.py | 126 +++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 10 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 61f01e9..42fbdd8 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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(): diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py index 0e8fd8c..0f5232f 100644 --- a/tests/test_page_setup.py +++ b/tests/test_page_setup.py @@ -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."""