feat(pagesetup): SECTION 19 — page_setup_list/apply, plot_style_list, batch_plot with /MediaBox read-back

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-21 18:39:34 +03:00
co-authored by Claude Opus 5
parent 54bbf72312
commit f25421709f
8 changed files with 639 additions and 11 deletions
+3 -3
View File
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 166 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> **v1.5 release snapshot:** 170 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> 166 is the **registered** count; a default install advertises 161 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
@@ -103,7 +103,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| P&ID | catalogue blocks with ports and tags (ISO 10628-2 / ISA-5.1), port-to-port lines with ISA-5.1 classes and line numbers, `pid_graph` reads any P&ID back with confidence, instrument index / line list / equipment list, `pid_from_spec` one-call sheets |
| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog |
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` |
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `page_setup_apply` (ISO 216 / ANSI Y14.1 paper, ctb, scale, device — on both engines), `batch_plot` with every sheet size read back from its PDF's `/MediaBox`, `drawing_export_pdf(layout=…)` |
| Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box |
| Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* |
| Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle |
@@ -113,7 +113,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
<sub>166 tools in 20 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>170 tools in 20 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
+35 -2
View File
@@ -2265,10 +2265,43 @@ class ComBackend(AutoCADBackend):
return failed
async def plot_style_list(self) -> list[dict]:
# Completed in Task 18 (installed ctb scan); the catalogue rows already answer.
"""The ctb catalogue, marked against the files in AutoCAD's plot style path.
``Preferences.Files.PrintStyleSheetPath`` is a ``;``-separated list of
folders. Every read is guarded: a folder that cannot be listed is
skipped, and when the Preferences object is unreachable the catalogue
rows come back with ``installed: None`` rather than a fabricated False.
"""
from engineering.standards.papers import CTB_CATALOG
return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
def _sync():
rows = [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
try:
raw = str(_acad_app().Preferences.Files.PrintStyleSheetPath or "")
except Exception as exc:
log.debug("Preferences.Files.PrintStyleSheetPath unreadable: %s", exc)
return rows
installed: dict[str, str] = {}
for folder in (part.strip() for part in raw.split(";")):
if not folder:
continue
try:
entries = sorted(Path(folder).iterdir())
except OSError as exc:
log.debug("plot style folder %r not listable: %s", folder, exc)
continue
for entry in entries:
if entry.suffix.lower() in (".ctb", ".stb"):
installed.setdefault(entry.name.lower(), entry.name)
catalog_lower = {row["name"].lower() for row in rows}
for row in rows:
row["installed"] = row["name"].lower() in installed
for key, name in installed.items():
if key not in catalog_lower:
rows.append({"name": name, "source": "installed", "installed": True})
return rows
return await self._run(_sync)
async def drawing_template_save(self, path, name=None, description=None) -> dict:
# Completed in Task 20; declared here so the ABC instantiates.
+50
View File
@@ -72,6 +72,10 @@ class ToolAliases:
# PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run
# between two ports (pid_line_draw), which is an LWPOLYLINE with a
# class, a number and markers; a drafter typing it could mean either.
# PAGESETUP -- the Page Setup Manager both shows and edits a sheet's setup
# (page_setup_list vs page_setup_apply).
# PLOT -- one sheet to PDF (drawing_export_pdf) or every sheet in one go
# (batch_plot); a drafter typing it could mean either.
#
# Every other AutoCAD command must map to exactly one tool.
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
@@ -83,7 +87,9 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
"LAYER",
"LAYOUT",
"MEASUREGEOM",
"PAGESETUP",
"PLINE",
"PLOT",
"QSELECT",
"SETVAR",
"ZOOM",
@@ -1427,6 +1433,50 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"loop number",
),
),
# ── Page setup & templates (track E) ────────────────────────────────────
"batch_plot": ToolAliases(
acad=("PUBLISH", "PLOT"),
synonyms=(
"print all sheets to pdf",
"plot every layout",
"publish the sheet set",
"batch print",
"one pdf per sheet",
"plot the whole drawing set",
),
),
"page_setup_apply": ToolAliases(
acad=("PAGESETUP",),
synonyms=(
"page setup",
"set the paper size",
"a3 landscape",
"plot scale",
"monochrome ctb",
"which printer",
"set up the sheet for printing",
),
),
"page_setup_list": ToolAliases(
acad=("PAGESETUP",),
synonyms=(
"what paper is this sheet",
"page setup of each layout",
"plot settings",
"sheet size and orientation",
"which ctb is set",
),
),
"plot_style_list": ToolAliases(
acad=("STYLESMANAGER",),
synonyms=(
"ctb files",
"plot style table",
"pen assignments",
"monochrome or grayscale",
"which plot styles are installed",
),
),
}
+61 -2
View File
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 166,
"tools": 170,
"resources": 8,
"prompts": 5,
"groups": 20
@@ -20,7 +20,7 @@
"entity_modification": 16,
"entity_query": 9,
"layers": 14,
"layouts": 12,
"layouts": 16,
"pid": 9,
"premium": 12,
"solids": 5,
@@ -175,6 +175,21 @@
"readOnlyHint": true
}
},
{
"name": "batch_plot",
"group": "layouts",
"cost": "mutate",
"summary": "Plot every sheet (or the named ones) to PDF; each sheet size is read back from its PDF.",
"tags": [
"export",
"layout",
"plot"
],
"annotations": {
"title": "Batch Plot",
"destructiveHint": false
}
},
{
"name": "block_create_from_entities",
"group": "blocks",
@@ -1717,6 +1732,35 @@
"readOnlyHint": false
}
},
{
"name": "page_setup_apply",
"group": "layouts",
"cost": "safe",
"summary": "Set a sheet's paper, orientation, ctb, plot scale and device (PAGESETUP).",
"tags": [
"layout",
"plot"
],
"annotations": {
"title": "Apply Page Setup",
"destructiveHint": false
}
},
{
"name": "page_setup_list",
"group": "layouts",
"cost": "read",
"summary": "Read each sheet's page setup: paper, orientation, ctb, scale, device.",
"tags": [
"layout",
"plot",
"query"
],
"annotations": {
"title": "List Page Setups",
"readOnlyHint": true
}
},
{
"name": "pid_equipment_list",
"group": "pid",
@@ -1844,6 +1888,21 @@
"readOnlyHint": true
}
},
{
"name": "plot_style_list",
"group": "layouts",
"cost": "read",
"summary": "List the plot style tables (ctb): the AutoCAD catalogue, plus what is installed.",
"tags": [
"layout",
"plot",
"query"
],
"annotations": {
"title": "List Plot Styles",
"readOnlyHint": true
}
},
{
"name": "point_from_snap",
"group": "premium",
+122
View File
@@ -0,0 +1,122 @@
"""batch_plot: every sheet through drawing_export_pdf, each PDF measured back.
Composes the existing per-layout export; the only thing it adds is the
evidence — ``mediabox_mm`` is parsed from the file each sheet produced, so a
caller sees the size that was plotted, not the size that was asked for.
"""
from __future__ import annotations
import re
from pathlib import Path
from engineering.standards.papers import paper_from_size
from engineering.standards.pdfinfo import read_mediabox
__all__ = ["DEFAULT_FILE_PATTERN", "batch_plot", "safe_filename"]
DEFAULT_FILE_PATTERN = "{drawing}-{layout}.pdf"
_UNSAFE = re.compile(r"[<>:\"/\\|?*\s]+")
def safe_filename(text: str) -> str:
"""A layout or drawing name as a filename fragment: no separators, no
reserved characters, runs of them collapsed to one underscore."""
cleaned = _UNSAFE.sub("_", str(text or "").strip()).strip("_")
return cleaned or "untitled"
async def batch_plot(
backend,
layouts: list[str] | None,
output_dir: str,
file_pattern: str = DEFAULT_FILE_PATTERN,
) -> dict:
"""Plot layouts to PDF and read each sheet size back from the file.
``layouts=None`` plots every paper-space layout; ``Model`` is plotted only
when named. Refuses (``ValueError``, before any PDF is written): an empty
list, a layout that does not exist, a pattern with a path separator, and
a pattern without ``{layout}`` when more than one sheet would be written.
The output folder is created, then every output path goes through
``security.validate_path(allow_write=True)`` before the first export.
"""
from security import validate_path
listing = await backend.layout_list()
available = [str(name) for name in (listing.get("layouts") or [])]
lowered = {name.lower(): name for name in available}
if layouts is None:
targets = [name for name in available if name.lower() != "model"]
if not targets:
raise ValueError("batch_plot: the drawing has no paper-space layout to plot")
else:
requested = [str(name).strip() for name in layouts]
if not requested:
raise ValueError("batch_plot: layouts is empty — pass null for every sheet")
unknown = [name for name in requested if name.lower() not in lowered]
if unknown:
raise ValueError(
f"batch_plot: layout(s) not found: {', '.join(unknown)} "
f"(available: {', '.join(available)})"
)
targets = [lowered[name.lower()] for name in requested]
pattern = str(file_pattern or DEFAULT_FILE_PATTERN)
if "/" in pattern or "\\" in pattern:
raise ValueError("batch_plot: file_pattern must be a file name, not a path")
if "{layout}" not in pattern and len(targets) > 1:
raise ValueError(
"batch_plot: file_pattern needs {layout} when more than one sheet is plotted, "
"or every sheet would overwrite the last"
)
try:
pattern.format(drawing="x", layout="y")
except (KeyError, IndexError, ValueError) as exc:
raise ValueError(
f"batch_plot: file_pattern {pattern!r} may only use {{drawing}} and {{layout}}"
) from exc
info = await backend.drawing_info()
drawing = safe_filename(Path(info.name).stem)
destination = Path(validate_path(output_dir, allow_write=True))
planned = [
(name, destination / pattern.format(drawing=drawing, layout=safe_filename(name)))
for name in targets
]
# validate_path(allow_write=True) requires the parent folder to exist, so
# the output folder is created before the per-file fence check; an empty
# folder is the most a refused call leaves behind.
destination.mkdir(parents=True, exist_ok=True)
for _name, path in planned:
validate_path(str(path), allow_write=True)
sheets: list[dict] = []
for name, path in planned:
is_model = name.lower() == "model"
result = await backend.drawing_export_pdf(str(path), layout=None if is_model else name)
if not result.get("ok", False):
sheets.append(
{"layout": name, "ok": False, "path": str(path), "error": result.get("error")}
)
continue
row = {"layout": name, "ok": True, "path": str(path), "bytes": path.stat().st_size}
try:
width, height = read_mediabox(str(path))
row["mediabox_mm"] = [width, height]
row["paper"] = None if is_model else paper_from_size(width, height)
except ValueError as exc:
row["mediabox_mm"] = None
row["paper"] = None
row["error"] = str(exc)
sheets.append(row)
return {
"ok": all(row["ok"] for row in sheets),
"drawing": drawing,
"output_dir": str(destination),
"format": "pdf",
"sheets": sheets,
"count": len(sheets),
}
+159
View File
@@ -6815,6 +6815,165 @@ async def pid_tag_parse(
return parse_tag(tag, kind, equipment_prefixes)
# ---------------------------------------------------------------------------
# ── SECTION 19: Page Setup & Templates (4 tools) ────────────────────────────
# ---------------------------------------------------------------------------
@cad_tool(
summary="Read each sheet's page setup: paper, orientation, ctb, scale, device.",
cost="read",
)
@mcp.tool(
annotations={"title": "List Page Setups", "readOnlyHint": True},
tags={"layout", "plot", "query"},
)
async def page_setup_list(
layout: Annotated[
str | None, "One paper-space layout, or omit for every sheet (Model is never listed)."
] = None,
ctx: Context = None,
) -> dict:
"""The page setup stored on each paper-space layout.
`paper` is the catalogue name (ISO_A3, ANSI_B, …) when the stored size
matches one within 0.5 mm, otherwise the media name as stored; `size_mm`,
`orientation`, `plot_style` (ctb), `scale` ("fit", "1:50", or a custom
ratio), `plot_area`, `device`, `margins_mm` [top, bottom, left, right] and
`center` are read back from the LAYOUT object (headless) or the AutoCAD
Layout (live). Refuses an unknown layout name and `Model` (model space has
no sheet; plot it with `drawing_export_pdf(layout=None)`).
"""
try:
rows = await _backend(ctx).page_setup_list(layout)
except ValueError as exc:
raise ToolError(str(exc)) from exc
return {"ok": True, "layouts": rows, "count": len(rows)}
@cad_tool(
summary="Set a sheet's paper, orientation, ctb, plot scale and device (PAGESETUP).",
cost="safe",
)
@mcp.tool(
annotations={"title": "Apply Page Setup", "destructiveHint": False},
tags={"layout", "plot"},
)
async def page_setup_apply(
layout: Annotated[str, "Paper-space layout to set up (never 'Model')."],
paper: Annotated[str, "ISO_A0…ISO_A4 or ANSI_A…ANSI_E (short forms A3, ansi_b accepted)."],
orientation: Annotated[str, "landscape | portrait"] = "landscape",
plot_style: Annotated[
str, "Plot style table, e.g. monochrome.ctb, acad.ctb, Grayscale.ctb (plot_style_list)."
] = "monochrome.ctb",
scale: Annotated[
str, "fit | 1:1 | 1:2 | 1:5 | 1:10 | 1:20 | 1:50 | 1:100 | 2:1 | 5:1 | 10:1"
] = "fit",
plot_area: Annotated[str, "layout | extents"] = "layout",
device: Annotated[str, "Plotter configuration (.pc3) or printer name."] = "DWG To PDF.pc3",
margins_mm: Annotated[
list[float] | None,
"[top, bottom, left, right] in mm. Headless only: AutoCAD takes margins from the .pc3.",
] = None,
center: Annotated[bool, "Centre the plot on the paper."] = True,
ctx: Context = None,
) -> dict:
"""AutoCAD's PAGESETUP as one call, on both engines.
Paper sizes are ISO 216 / ANSI Y14.1; the media is written in AutoCAD's own
spelling (`ISO_A3_(420.00_x_297.00_MM)`). `changed` lists only the values
that moved — re-applying the same setup reports `{}`. An unknown ctb is
written and reported `plot_style_known: false` (a missing ctb only matters
at plot time). Viewports on the sheet are never touched.
Refused before anything is written: an unknown paper, orientation, scale
or plot_area, an empty device or plot_style, margins that leave no
printable area, `Model`, an unknown layout. On the live engine `margins_mm`
is refused (the .pc3 owns them) and a media the device does not offer is
refused with the device's names for the same paper.
The proof is the PDF: `batch_plot` reads each sheet's `/MediaBox` back.
"""
from engineering.standards.papers import resolve_page_setup
try:
setup = resolve_page_setup(
paper, orientation, plot_style, scale, plot_area, device, margins_mm, center
)
await ctx.info(f"Applying page setup {setup['paper']} {orientation} to {layout}")
return await _backend(ctx).page_setup_apply(layout, setup)
except ValueError as exc:
raise ToolError(str(exc)) from exc
@cad_tool(
summary="List the plot style tables (ctb): the AutoCAD catalogue, plus what is installed.",
cost="read",
)
@mcp.tool(
annotations={"title": "List Plot Styles", "readOnlyHint": True},
tags={"layout", "plot", "query"},
)
async def plot_style_list(ctx: Context = None) -> dict:
"""The ctb files AutoCAD ships (`monochrome.ctb`, `acad.ctb`, `Grayscale.ctb`,
the Screening set, …) and, on the live engine, the files actually present
in `Preferences.Files.PrintStyleSheetPath` (`source: "installed"`,
`installed: true/false` per row). Headlessly `installed` is `null`: there
is no installation to scan, and the catalogue is not evidence of one.
Never refuses; an unreadable Preferences object degrades to the catalogue.
"""
backend = _backend(ctx)
rows = await backend.plot_style_list()
return {"ok": True, "styles": rows, "count": len(rows), "backend": backend.name}
@cad_tool(
summary="Plot every sheet (or the named ones) to PDF; each sheet size is read back from its PDF.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Batch Plot", "destructiveHint": False},
tags={"layout", "plot", "export"},
)
async def batch_plot(
output_dir: Annotated[str, "Folder for the PDFs (created if missing)."],
layouts: Annotated[
list[str] | None,
"Layouts to plot; omit for every paper-space layout. 'Model' only when named.",
] = None,
format: Annotated[str, "pdf (the only format this release plots)."] = "pdf",
file_pattern: Annotated[
str, "File name per sheet; {drawing} and {layout} are substituted."
] = "{drawing}-{layout}.pdf",
ctx: Context = None,
) -> dict:
"""AutoCAD's PUBLISH for a PDF set, through `drawing_export_pdf(layout=…)`.
One row per sheet: `path`, `bytes`, and `mediabox_mm` **parsed from the
PDF that was written** (`/MediaBox`, points → mm), with `paper` naming the
catalogue size it matches within 0.5 mm — so the sheet size is verified,
never assumed. Model space is included only when named.
Refused before any file is written: a format other than pdf, an empty
`layouts` list, a layout that does not exist, a `file_pattern` containing a
path separator or lacking `{layout}` when more than one sheet is plotted,
and any output path outside the allowed directories. A sheet whose PDF has
no readable MediaBox is reported with `mediabox_mm: null` and an `error`.
"""
from engineering.standards.plot import batch_plot as _batch_plot
if str(format).lower() != "pdf":
raise ToolError(
f"batch_plot: format must be 'pdf' (got {format!r}); PDF is the only plot format"
)
destination = validate_path(output_dir, allow_write=True)
await ctx.info(f"Batch plotting {layouts or 'every sheet'} to {destination}")
try:
return await _batch_plot(_backend(ctx), layouts, str(destination), file_pattern)
except ValueError as exc:
raise ToolError(str(exc)) from exc
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+201
View File
@@ -0,0 +1,201 @@
"""batch_plot: every sheet to PDF, each sheet size read back from the PDF.
The composition is exercised on the headless engine end to end; the COM-only
piece of this task (the installed ctb scan) is fake-tested; the SECTION 19
tools are called directly with a minimal context.
"""
from __future__ import annotations
import types
import pytest
from fastmcp.exceptions import ToolError
import server
from engineering.standards.papers import CTB_CATALOG, resolve_page_setup
from engineering.standards.plot import batch_plot, safe_filename
pytestmark = pytest.mark.asyncio
class _Ctx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, message):
pass
async def warning(self, message):
pass
async def report_progress(self, progress, total):
pass
async def _two_sheets(backend):
"""Exactly two paper layouts: the default tab renamed, plus one more."""
await backend.layout_rename("Layout1", "A3")
await backend.layout_create("B")
await backend.page_setup_apply("A3", resolve_page_setup("ISO_A3", "landscape"))
await backend.page_setup_apply("B", resolve_page_setup("ANSI_B", "landscape"))
await backend.entity_create_line(0, 0, 100, 50)
def test_safe_filename_strips_what_a_filesystem_refuses():
assert safe_filename("Sheet 1: A3/rev*B") == "Sheet_1_A3_rev_B"
assert safe_filename("Layout1") == "Layout1"
async def test_every_paper_layout_is_plotted_and_measured(backend, tmp_path):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
result = await batch_plot(backend, None, str(tmp_path / "out"))
assert result["ok"] is True and result["format"] == "pdf"
assert result["count"] == 2
by_layout = {row["layout"]: row for row in result["sheets"]}
assert set(by_layout) == {"A3", "B"}, "Model is not plotted unless named"
a3, b = by_layout["A3"], by_layout["B"]
assert a3["ok"] and a3["bytes"] > 0 and a3["path"].endswith("untitled-A3.pdf")
assert abs(a3["mediabox_mm"][0] - 420) <= 0.5 and abs(a3["mediabox_mm"][1] - 297) <= 0.5
assert a3["paper"] == "ISO_A3"
assert abs(b["mediabox_mm"][0] - 432) <= 0.5 and abs(b["mediabox_mm"][1] - 279) <= 0.5
assert b["paper"] == "ANSI_B"
async def test_model_is_included_only_when_named(backend, tmp_path):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
result = await batch_plot(backend, ["Model", "A3"], str(tmp_path))
names = [row["layout"] for row in result["sheets"]]
assert names == ["Model", "A3"]
model = result["sheets"][0]
assert model["ok"] and model["mediabox_mm"] is not None
assert model["paper"] is None, "model space has no sheet; the size is reported, not named"
async def test_refusals_write_nothing(backend, tmp_path):
await _two_sheets(backend)
out = tmp_path / "nothing"
with pytest.raises(ValueError, match="Nope"):
await batch_plot(backend, ["Nope"], str(out))
with pytest.raises(ValueError, match="layout"):
await batch_plot(backend, None, str(out), file_pattern="all.pdf")
with pytest.raises(ValueError, match="file_pattern"):
await batch_plot(backend, None, str(out), file_pattern="sub/{layout}.pdf")
with pytest.raises(ValueError, match="layouts"):
await batch_plot(backend, [], str(out))
assert not out.exists() or not any(out.iterdir())
async def test_a_fresh_drawing_plots_its_one_default_sheet_at_the_dxf_default_size(
backend, tmp_path
):
"""No page setup ever applied: the sheet still plots, at ezdxf's stored
default (A3, 420 x 297), and the row says so rather than guessing."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
result = await batch_plot(backend, None, str(tmp_path), file_pattern="{layout}.pdf")
assert [row["layout"] for row in result["sheets"]] == ["Layout1"]
row = result["sheets"][0]
assert row["path"].endswith("Layout1.pdf") and row["paper"] == "ISO_A3"
# ── the tools ───────────────────────────────────────────────────────────────
async def test_page_setup_tools_round_trip(backend):
ctx = _Ctx(backend)
# A fresh drawing already carries Layout1; renaming it keeps exactly one
# sheet, so the unfiltered listing has one row and it is the A3.
await backend.layout_rename("Layout1", "A3")
applied = await server.page_setup_apply("A3", "ISO_A3", ctx=ctx)
assert applied["ok"] and applied["applied"]["paper"] == "ISO_A3"
listed = await server.page_setup_list(ctx=ctx)
assert listed["count"] == 1 and listed["layouts"][0]["paper"] == "ISO_A3"
only = await server.page_setup_list("A3", ctx=ctx)
assert only["layouts"][0]["layout"] == "A3"
async def test_page_setup_apply_tool_refuses_before_writing(backend):
ctx = _Ctx(backend)
await backend.layout_create("A3")
with pytest.raises(ToolError, match="scale"):
await server.page_setup_apply("A3", "ISO_A3", scale="1:3", ctx=ctx)
with pytest.raises(ToolError, match="Model"):
await server.page_setup_apply("Model", "ISO_A3", ctx=ctx)
row = (await backend.page_setup_list("A3"))[0]
assert row["device"] == "", "nothing was written"
async def test_plot_style_list_tool(backend):
result = await server.plot_style_list(ctx=_Ctx(backend))
assert result["ok"] and result["count"] == len(CTB_CATALOG)
assert result["backend"] == "ezdxf"
async def test_batch_plot_tool_validates_the_output_directory(backend, tmp_path):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
with pytest.raises(ToolError):
await server.batch_plot("../../outside", ctx=_Ctx(backend))
with pytest.raises(ToolError, match="format"):
await server.batch_plot(str(tmp_path), format="png", ctx=_Ctx(backend))
result = await server.batch_plot(str(tmp_path), layouts=["A3"], ctx=_Ctx(backend))
assert result["count"] == 1 and result["sheets"][0]["paper"] == "ISO_A3"
# ── COM: the installed ctb scan ─────────────────────────────────────────────
@pytest.fixture
def com_backend(monkeypatch, tmp_path):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
styles = tmp_path / "Plot Styles"
styles.mkdir()
(styles / "monochrome.ctb").write_bytes(b"")
(styles / "Anka.ctb").write_bytes(b"")
(styles / "readme.txt").write_bytes(b"")
app = types.SimpleNamespace(
Preferences=types.SimpleNamespace(
Files=types.SimpleNamespace(PrintStyleSheetPath=f"{styles};C:\\does\\not\\exist")
)
)
monkeypatch.setattr(module, "_acad_app", lambda: app)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, app
async def test_com_plot_style_list_marks_installed_and_adds_local_files(com_backend):
backend, _ = com_backend
rows = await backend.plot_style_list()
by_name = {row["name"]: row for row in rows}
assert by_name["monochrome.ctb"] == {
"name": "monochrome.ctb",
"source": "catalog",
"installed": True,
}
assert by_name["acad.ctb"]["installed"] is False
assert by_name["Anka.ctb"] == {"name": "Anka.ctb", "source": "installed", "installed": True}
assert "readme.txt" not in by_name
async def test_com_plot_style_list_survives_an_unreachable_preferences_object(
com_backend, monkeypatch
):
backend, _ = com_backend
from backends import com_backend as module
def _boom():
raise RuntimeError("no application")
monkeypatch.setattr(module, "_acad_app", _boom)
rows = await backend.plot_style_list()
assert [row["name"] for row in rows] == list(CTB_CATALOG)
assert all(row["installed"] is None for row in rows)
+8 -4
View File
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
"""Frozen snapshot of the surface (161 tools, 20 groups).
"""Frozen snapshot of the surface (170 tools, 20 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -517,7 +517,11 @@ async def test_tool_group_sizes_are_unchanged():
when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool)
joined; the `pid`
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
`pid` rather than under their secondary `query` / `create` tags. Every
`pid` rather than under their secondary `query` / `create` tags.
`layouts` moved 12 -> 16 when v1.6's SECTION 19 opened with
`page_setup_list` / `page_setup_apply` / `plot_style_list` / `batch_plot`
— all tagged `layout`, which `_GROUP_TAG_PRIORITY` ranks above
`query`/`export`, so they file with the sheets they set up. Every
other number here has been unchanged since the snapshot was taken.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
@@ -533,7 +537,7 @@ async def test_tool_group_sizes_are_unchanged():
"entity_modification": 16,
"entity_query": 9,
"layers": 14,
"layouts": 12,
"layouts": 16,
"pid": 9,
"premium": 12,
"solids": 5,
@@ -543,4 +547,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 166
assert sum(sizes.values()) == 170