diff --git a/README.md b/README.md
index 0864612..3dec326 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
-> **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 |
-166 tools in 20 groups; TOOL_PACKS=core hides the nine pid_* tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
+170 tools in 20 groups; TOOL_PACKS=core hides the nine pid_* tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
**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.
diff --git a/backends/com_backend.py b/backends/com_backend.py
index d0373da..0967b7d 100644
--- a/backends/com_backend.py
+++ b/backends/com_backend.py
@@ -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.
diff --git a/discovery/aliases.py b/discovery/aliases.py
index e8b9af9..a59e657 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -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",
+ ),
+ ),
}
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index 264451c..401303e 100644
--- a/docs/tool-inventory.json
+++ b/docs/tool-inventory.json
@@ -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",
diff --git a/engineering/standards/plot.py b/engineering/standards/plot.py
new file mode 100644
index 0000000..ac035c5
--- /dev/null
+++ b/engineering/standards/plot.py
@@ -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),
+ }
diff --git a/server.py b/server.py
index 27a06b2..1200130 100644
--- a/server.py
+++ b/server.py
@@ -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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/tests/test_batch_plot.py b/tests/test_batch_plot.py
new file mode 100644
index 0000000..91e8631
--- /dev/null
+++ b/tests/test_batch_plot.py
@@ -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)
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index 13ab419..b09f14c 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -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