diff --git a/.gitattributes b/.gitattributes
index dfe0770..ada0244 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,4 @@
# Auto detect text files and perform LF normalization
* text=auto
+templates/*.dxf text eol=crlf
+templates/*.dwt binary
diff --git a/.gitignore b/.gitignore
index d46dc2e..13517c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,6 +47,9 @@ benchmarks/results/*
!benchmarks/results/published/**
benchmarks/.competitors/
*.dxf
+# The bundled templates are the one committed DXF set: built by
+# scripts/build_templates.py and pinned reproducible by its --check.
+!templates/*.dxf
*.dwg
*.bak
diff --git a/Dockerfile b/Dockerfile
index aac0f9b..e43dfab 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,6 +9,7 @@ COPY server.py config.py security.py version.py ./
COPY backends/ ./backends/
COPY engineering/ ./engineering/
COPY discovery/ ./discovery/
+COPY templates/ ./templates/
RUN pip install --no-cache-dir -e .
diff --git a/README.md b/README.md
index 7da9f77..5993c66 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:** 179 tools · 8 resources · 5 prompt templates · 2423 collected tests.
+> **v1.5 release snapshot:** 185 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,8 @@ 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=…)` |
+| Templates | five bundled templates built by the server's own tools and pinned reproducible (`iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`) — `drawing_new(template="iso_a3_mech")`, `drawing_template_list`, `drawing_template_save` (.dwt on live AutoCAD, `dwt_write` refused headlessly) |
| 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 +114,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.
+172 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/base.py b/backends/base.py
index 65be4ce..b024f48 100644
--- a/backends/base.py
+++ b/backends/base.py
@@ -30,6 +30,7 @@ from backends.contracts import (
LayerContract,
LayoutContract,
LinetypeContract,
+ PageSetupContract,
PremiumContract,
SettingsContract,
SolidContract,
@@ -169,6 +170,7 @@ class AutoCADBackend(
IdentityContract,
DrawingContract,
LayoutContract,
+ PageSetupContract,
SolidContract,
EntityCreationContract,
DimensionContract,
diff --git a/backends/com_backend.py b/backends/com_backend.py
index 8955885..3030e34 100644
--- a/backends/com_backend.py
+++ b/backends/com_backend.py
@@ -14,6 +14,7 @@ import os
import sys
import time
import uuid
+from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
@@ -170,6 +171,71 @@ def _acad_app():
return _COM_STATE["app"]
+#: ``AcSaveAsType`` template constants, newest first: ac2018_Template,
+#: ac2013_Template, ac2010_Template. A seat older than the first refuses it
+#: with a COM error and the next one is tried.
+_TEMPLATE_SAVE_FORMATS = (66, 62, 50)
+
+
+def _template_cache_dir() -> Path:
+ import tempfile
+
+ return Path(tempfile.gettempdir()) / "acadmcp-templates"
+
+
+def _template_as_dwt(app, source: Path) -> tuple[Path, bool]:
+ """A real ``.dwt`` for ``source``, built once through AutoCAD and cached.
+
+ ``Documents.Add()`` accepts only a genuine DWT: measured on AutoCAD
+ 2026, ``Add(templates/iso_a3_mech.dxf)`` returned the default acadiso
+ drawing (layers ``['0']``, tabs ``Layout1``/``Layout2``) exactly as
+ ``Add()`` and ``Add()`` did, and the DXF bytes renamed
+ ``.dwt`` fared no better. Opening the file and saving it as a template
+ (``SaveAs(path, ac2018_Template)``) is what yields the eleven layers and
+ the ``A3`` tab, so that is done here — once per file content, keyed on the
+ path, size and mtime, under the temp folder — and the ``.dwt`` is what
+ ``Add`` is handed. Returns ``(dwt_path, cached)``.
+
+ Must only be called from the COM executor thread. The conversion document
+ is opened read-only and closed without saving in a ``finally``; the
+ ``.dwt`` on disk is the only thing it leaves behind.
+ """
+ import hashlib
+
+ source = Path(source).resolve()
+ stat = source.stat()
+ digest = hashlib.sha1(
+ f"{source}|{stat.st_size}|{stat.st_mtime_ns}".encode("utf-8", "surrogateescape")
+ ).hexdigest()[:12]
+ cache_dir = _template_cache_dir()
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ dwt = cache_dir / f"{source.stem}-{digest}.dwt"
+ if dwt.is_file() and dwt.stat().st_size > 0:
+ return dwt, True
+ doc = app.Documents.Open(str(source), True)
+ try:
+ last_exc: Exception | None = None
+ for fmt in _TEMPLATE_SAVE_FORMATS:
+ try:
+ doc.SaveAs(str(dwt), fmt)
+ break
+ except _COM_ERROR as exc:
+ last_exc = exc
+ log.debug("SaveAs(%s, %d) refused: %s", dwt, fmt, exc)
+ else:
+ raise RuntimeError(
+ f"could not save {source.name} as a .dwt template: {last_exc}"
+ ) from last_exc
+ finally:
+ try:
+ doc.Close(False)
+ except Exception as exc: # the temp document must never stay open
+ log.warning("template conversion document did not close: %s", exc)
+ if not dwt.is_file() or dwt.stat().st_size == 0:
+ raise RuntimeError(f"AutoCAD reported the template saved but {dwt} is not on disk")
+ return dwt, False
+
+
def _acad_doc():
"""Return active AutoCAD document."""
app = _acad_app()
@@ -1349,6 +1415,7 @@ class ComBackend(AutoCADBackend):
"refiner": FeatureCapability(True, "shared"),
"delivery": FeatureCapability(True, "shared"),
"paper_space": FeatureCapability(True, "native"),
+ "dwt_write": FeatureCapability(True, "native"),
"viewport_render": FeatureCapability(True, "native"),
"solid_3d": _solid_3d_capability(),
"lisp": FeatureCapability(True, "sanitized"),
@@ -1531,13 +1598,36 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def drawing_new(self, template: str | None = None) -> dict:
+ """``Documents.Add()``, or ``Documents.Add()`` from a template file.
+
+ A template that is not already a ``.dwt`` (the bundled ``.dxf`` files,
+ an explicit ``.dxf`` path) is first converted into one through AutoCAD
+ itself — see ``_template_as_dwt`` for the measurement that makes this
+ necessary — and the result reports ``template_dwt: {path, cached}``.
+ A template path that is not a file is refused here rather than handed
+ to ``Add``, which would silently create the default drawing instead.
+ """
+
def _sync():
app = _acad_app()
- if template:
- doc = app.Documents.Add(template)
- else:
+ if not template:
doc = app.Documents.Add()
- return {"ok": True, "name": doc.Name}
+ return {"ok": True, "name": doc.Name}
+ source = Path(template)
+ if not source.is_file():
+ raise FileNotFoundError(
+ f"template file not found: {template} (Documents.Add would silently "
+ "create the default drawing instead)"
+ )
+ result: dict[str, Any] = {"ok": True}
+ if source.suffix.lower() == ".dwt":
+ dwt = source
+ else:
+ dwt, cached = _template_as_dwt(app, source)
+ result["template_dwt"] = {"path": str(dwt), "cached": cached}
+ doc = app.Documents.Add(str(dwt))
+ result["name"] = doc.Name
+ return result
result = await self._run(_sync)
await self._ensure_document_state()
@@ -1567,8 +1657,10 @@ class ComBackend(AutoCADBackend):
async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict:
def _sync():
doc = _acad_doc()
- # AutoCAD SaveAs format constants: 12=DWG R2010, 61=DXF R2010
- fmt_map = {"dwg": 12, "dxf": 61, "dwt": 5}
+ # AutoCAD AcSaveAsType: 12 = ac2000_dwg, 61 = ac2013_dxf, 66 = ac2018_Template.
+ # "dwt" was 5 (acR13_dxf) until v1.6: a DWT request wrote an R13 DXF
+ # under a .dwt name.
+ fmt_map = {"dwg": 12, "dxf": 61, "dwt": 66}
acad_fmt = fmt_map.get(fmt.lower(), 12)
doc.SaveAs(path, acad_fmt)
return {"ok": True, "path": path, "format": fmt}
@@ -1579,23 +1671,73 @@ class ComBackend(AutoCADBackend):
return await self.drawing_save_as(path, "dxf")
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict:
+ """Plot one layout to PDF through ``Plot.PlotToFile`` and confirm the file.
+
+ ``layout=None`` means model space, as the tool advertises — the Model
+ tab is made current for the plot (and the previous tab restored), so
+ the row is never silently the sheet that happened to be active.
+
+ Measured on AutoCAD 2026 with the operator's default BACKGROUNDPLOT=2:
+ ``PlotToFile`` returns True immediately, the job is queued to the
+ background plotter, and no file exists when the call comes back —
+ seconds later the folder was still empty, and a foreground plot
+ attempted after such a stranded job raised E_FAIL. So the plot is
+ forced to the foreground (``BACKGROUNDPLOT=0``, restored afterwards),
+ the method's boolean is honoured, and ``ok`` is reported only once the
+ file is on disk with a size.
+ """
+
def _sync():
doc = _acad_doc()
- previous = None
- if layout:
- previous = doc.ActiveLayout.Name
- if previous != layout:
- doc.ActiveLayout = doc.Layouts.Item(layout)
+ target = layout.strip() if layout and layout.strip() else "Model"
+ previous = doc.ActiveLayout.Name
+ switched = False
+ if previous.lower() != target.lower():
+ doc.ActiveLayout = doc.Layouts.Item(target)
+ switched = True
+ old_background = None
try:
- plot = doc.Plot
- plot.PlotToFile(path, "DWG To PDF.pc3")
- result = {"ok": True, "path": path}
- if layout:
- result["layout"] = layout
- return result
+ try:
+ old_background = int(doc.GetVariable("BACKGROUNDPLOT"))
+ except Exception as exc:
+ log.debug("BACKGROUNDPLOT unreadable (%s); plotting as configured", exc)
+ if old_background not in (None, 0):
+ doc.SetVariable("BACKGROUNDPLOT", 0)
+ plotted = doc.Plot.PlotToFile(path, "DWG To PDF.pc3")
+ written = Path(path)
+ if not plotted:
+ return {
+ "ok": False,
+ "path": path,
+ "layout": target,
+ "error": "PlotToFile returned False: AutoCAD refused the plot "
+ "(check the layout's plot area and the 'DWG To PDF.pc3' device)",
+ }
+ if not written.is_file():
+ return {
+ "ok": False,
+ "path": path,
+ "layout": target,
+ "error": "PlotToFile reported success but wrote no file at "
+ f"{path} (BACKGROUNDPLOT was {old_background})",
+ }
+ return {
+ "ok": True,
+ "path": path,
+ "layout": target,
+ "bytes": written.stat().st_size,
+ }
finally:
- if previous is not None and previous != layout:
- doc.ActiveLayout = doc.Layouts.Item(previous)
+ if old_background not in (None, 0):
+ try:
+ doc.SetVariable("BACKGROUNDPLOT", old_background)
+ except Exception as exc:
+ log.warning("could not restore BACKGROUNDPLOT=%s: %s", old_background, exc)
+ if switched:
+ try:
+ doc.ActiveLayout = doc.Layouts.Item(previous)
+ except Exception as exc:
+ log.warning("could not restore the active layout %r: %s", previous, exc)
return await self._run(_sync)
@@ -2494,6 +2636,393 @@ class ComBackend(AutoCADBackend):
# CopyObjects-and-delete that would destroy and recreate geometry in the
# operator's open drawing when it goes wrong.
+ # ── page setup (track E) ─────────────────────────────────────────────────
+ #
+ # Layout is an IAcadPlotConfiguration. Constants from the AutoCAD 2026
+ # typelib: AcPlotPaperUnits acMillimeters=1; AcPlotRotation ac0degrees=0;
+ # AcPlotType acExtents=1 / acLayout=5; AcPlotScale acScaleToFit=0, ac1_1=16
+ # (the full table is engineering.standards.papers.ACTIVEX_PLOT_SCALE).
+ # CenterPlot (ActiveX Reference, acadauto.chm): "This property cannot be
+ # set to True on a layout object whose PlotType property is set to
+ # acLayout" — so a layout plot never writes it (resolve_page_setup hands
+ # over None), an extents plot writes it *after* PlotType, and a stale True
+ # is cleared *before* PlotType moves to acLayout. Every write is journaled
+ # and unwound in reverse when AutoCAD refuses one, so a failed apply leaves
+ # the layout as it was — and the read-back after the unwind is compared to
+ # the read-back before the first write, so a value that did not go back is
+ # named rather than claimed restored.
+ #
+ # Two behaviours measured live (AutoCAD 2026, 2026-09-17) that the ActiveX
+ # Reference does not document:
+ # * GetPaperSize / GetPaperMargins answer in millimetres whatever
+ # PaperUnits says (ISO_A3 under PaperUnits=0 -> (420.0, 297.0); ANSI_B
+ # under 0 and 1 -> (431.8, 279.4) both times). They are never scaled.
+ # PaperUnits itself still governs what one paper-space unit means at
+ # the plot scale, so the row reports it (``paper_units``) and an apply
+ # that moves it says so in ``changed`` and ``warnings``.
+ # * Both answer in the *media's* frame (the PLOTSETTINGS fields the DXF
+ # row reads); under PlotRotation 1/3 the row turns size and margins
+ # together through ``papers.turned_sheet``, the one mapping the headless
+ # row and renderer use. The rotated read-back is not yet measured live
+ # (scripts/smoke_settings_com.py, Task 24, is where it gets measured).
+ # * Writing ConfigName replaces CanonicalMediaName with the new device's
+ # default when the current media does not exist on it, and writing the
+ # old device back does *not* bring the old media with it (DWG To PDF /
+ # ISO_A3 -> Microsoft Print to PDF -> 'psk:ISOA4' -> DWG To PDF ->
+ # 'ANSI_A_(11.00_x_8.50_Inches)'). The device write therefore journals
+ # one combined undo: device back, then the media / units / rotation
+ # snapshotted before the switch re-applied.
+ #
+ # Fake-tested in tests/test_page_setup.py (the fake enforces the CenterPlot
+ # rule and replays both measurements); executed live by
+ # scripts/smoke_settings_com.py (Task 24).
+
+ _AC_NO_ROTATION = 0
+ #: AcSaveAsType, AutoCAD 2026 typelib: ac2018_Template = 66 (acNative = 64,
+ #: ac2013_dxf = 61). The spec's "acTemplateDwg" is not a member of the enum.
+ _AC_SAVEAS_TEMPLATE = 66
+ _AC_SAVEAS_DXF = 61
+ #: Layout properties a ConfigName write can move as a side effect (measured
+ #: live for CanonicalMediaName; units / rotation are re-applied on the same
+ #: evidence-before-trust basis) — snapshotted before the device switch and
+ #: put back by its undo.
+ _DEVICE_SIDE_EFFECTS = ("CanonicalMediaName", "PaperUnits", "PlotRotation")
+
+ @staticmethod
+ def _com_page_setup_row(layout) -> dict:
+ from engineering.standards.papers import (
+ PLOT_TYPE_NAMES,
+ activex_scale_label,
+ center_applies,
+ paper_from_size,
+ paper_units_name,
+ scale_label,
+ turned_sheet,
+ )
+
+ def _get(attr):
+ try:
+ return getattr(layout, attr)
+ except Exception as exc:
+ log.debug("Layout.%s read failed: %s", attr, exc)
+ return None
+
+ # GetPaperSize / GetPaperMargins are millimetres regardless of
+ # PaperUnits (measured live; see the block comment above) — no factor.
+ size = None
+ try:
+ width, height = layout.GetPaperSize()
+ size = [round(float(width), 2), round(float(height), 2)]
+ except Exception as exc:
+ log.debug("Layout.GetPaperSize failed: %s", exc)
+ margins = None
+ try:
+ (left, bottom), (right, top) = layout.GetPaperMargins()
+ margins = [round(float(v), 3) for v in (top, bottom, left, right)]
+ except Exception as exc:
+ log.debug("Layout.GetPaperMargins failed: %s", exc)
+ rotation = _get("PlotRotation") or 0
+ if size and margins:
+ size, margins = turned_sheet(size[0], size[1], margins, rotation)
+ size = [round(v, 2) for v in size]
+ margins = [round(v, 3) for v in margins]
+ elif size and int(rotation) in (1, 3):
+ size = [size[1], size[0]]
+ if _get("UseStandardScale"):
+ scale = activex_scale_label(_get("StandardScale") or 0)
+ else:
+ try:
+ numerator, denominator = layout.GetCustomScale()
+ scale = scale_label(float(numerator), float(denominator))
+ except Exception as exc:
+ log.debug("Layout.GetCustomScale failed: %s", exc)
+ scale = None
+ media = _get("CanonicalMediaName")
+ plot_type = _get("PlotType")
+ return {
+ "layout": layout.Name,
+ "paper": (paper_from_size(*size) if size else None) or media,
+ "canonical_media_name": media,
+ "size_mm": size,
+ "orientation": (
+ None if not size else ("landscape" if size[0] >= size[1] else "portrait")
+ ),
+ "plot_style": _get("StyleSheet"),
+ "scale": scale,
+ "plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type),
+ "device": _get("ConfigName"),
+ "margins_mm": margins,
+ "paper_units": paper_units_name(_get("PaperUnits")),
+ "center": bool(_get("CenterPlot")) if center_applies(plot_type) else None,
+ }
+
+ def _com_paper_layout(self, doc, raw: str, operation: str):
+ resolved = self._find_layout(doc, raw)
+ if resolved is None:
+ available = [n for n in self._layout_names(doc) if n != "Model"]
+ raise ValueError(
+ f"{operation}: layout {raw!r} not found (paper-space layouts: "
+ f"{', '.join(available) or 'none'})"
+ )
+ if resolved == "Model":
+ raise ValueError(
+ f"{operation}: Model space has no sheet; plot it with "
+ "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout"
+ )
+ return doc.Layouts.Item(resolved)
+
+ async def page_setup_list(self, layout: str | None = None) -> list[dict]:
+ def _sync():
+ doc = _acad_doc()
+ if layout is not None and self._layout_name(layout):
+ target = self._com_paper_layout(doc, layout, "page_setup_list")
+ return [self._com_page_setup_row(target)]
+ return [
+ self._com_page_setup_row(doc.Layouts.Item(name))
+ for name in self._layout_names(doc)
+ if name != "Model"
+ ]
+
+ return await self._run(_sync)
+
+ async def page_setup_apply(self, layout: str, setup: dict) -> dict:
+ from engineering.standards.papers import (
+ PAPER_UNITS,
+ page_setup_warnings,
+ require_page_setup,
+ )
+
+ resolved = require_page_setup(setup)
+ if resolved["margins_mm"] is not None:
+ raise ValueError(
+ "page_setup_apply: margins_mm cannot be written on the COM backend — AutoCAD "
+ "takes the printable margins from the plotter configuration (.pc3). Omit "
+ "margins_mm; the device's margins are read back in the result."
+ )
+
+ def _sync():
+ doc = _acad_doc()
+ target = self._com_paper_layout(doc, layout, "page_setup_apply")
+ before = self._com_page_setup_row(target)
+ # Journal of (undo callable, label) for every write that landed,
+ # unwound in reverse when a later one is refused.
+ journal: list[tuple[Callable[[], None], str]] = []
+
+ def _set(attr: str, value):
+ previous = getattr(target, attr)
+ setattr(target, attr, value)
+ journal.append((lambda: setattr(target, attr, previous), attr))
+
+ def _set_custom_scale(numerator: float, denominator: float):
+ previous = tuple(float(v) for v in target.GetCustomScale())
+ target.SetCustomScale(numerator, denominator)
+ journal.append((lambda: target.SetCustomScale(*previous), "SetCustomScale"))
+
+ def _set_device(device: str):
+ # A device switch moves the media (and can move units /
+ # rotation) as a side effect, and switching back does not
+ # move them back — so the undo re-applies what was there.
+ snapshot = {attr: getattr(target, attr) for attr in self._DEVICE_SIDE_EFFECTS}
+ previous = target.ConfigName
+ target.ConfigName = device
+
+ def _undo():
+ target.ConfigName = previous
+ target.RefreshPlotDeviceInfo()
+ for attr, value in snapshot.items():
+ if getattr(target, attr) != value:
+ setattr(target, attr, value)
+
+ journal.append((_undo, "ConfigName"))
+
+ step = "ConfigName"
+ try:
+ target.RefreshPlotDeviceInfo()
+ _set_device(resolved["device"])
+ target.RefreshPlotDeviceInfo()
+ media = resolved["canonical_media_name"]
+ names = [str(n) for n in (target.GetCanonicalMediaNames() or ())]
+ if media not in names:
+ nearest = [n for n in names if n.split("_(")[0] == media.split("_(")[0]]
+ raise ValueError(
+ f"page_setup_apply: device {resolved['device']!r} has no media "
+ f"{media!r} (same paper on this device: "
+ f"{', '.join(nearest) or 'none'}; {len(names)} media in total)"
+ )
+ step = "CanonicalMediaName"
+ _set("CanonicalMediaName", media)
+ if resolved["paper_units"] is not None:
+ # None keeps the layout's units — what AutoCAD's own Page
+ # Setup does when a media is chosen.
+ step = "PaperUnits"
+ _set("PaperUnits", PAPER_UNITS[resolved["paper_units"]])
+ step = "PlotRotation"
+ _set("PlotRotation", self._AC_NO_ROTATION)
+ step = "StyleSheet"
+ _set("StyleSheet", resolved["plot_style"])
+ step = "PlotWithPlotStyles"
+ _set("PlotWithPlotStyles", True)
+ center = resolved["center"]
+ if center is None and self._com_center_plot_is_set(target):
+ # Clearing is legal under any PlotType; the layout plot
+ # then carries the flag AutoCAD stores for acLayout.
+ step = "CenterPlot"
+ _set("CenterPlot", False)
+ step = "PlotType"
+ _set("PlotType", int(resolved["plot_type"]))
+ code = resolved["activex_standard_scale"]
+ if code is not None:
+ step = "StandardScale"
+ _set("StandardScale", int(code))
+ step = "UseStandardScale"
+ _set("UseStandardScale", True)
+ else:
+ numerator, denominator = (float(v) for v in resolved["scale_ratio"])
+ step = "SetCustomScale"
+ _set_custom_scale(numerator, denominator)
+ step = "UseStandardScale"
+ _set("UseStandardScale", False)
+ if center is not None:
+ # Extents plot: PlotType is already acExtents, so True is legal.
+ step = "CenterPlot"
+ _set("CenterPlot", bool(center))
+ except Exception as exc:
+ failed = self._com_unwind_page_setup(journal)
+ # The unwind's own word is not evidence: read the layout back
+ # and name every field that is not what it was.
+ failed += [
+ key
+ for key, value in self._com_page_setup_row(target).items()
+ if key != "layout" and value != before.get(key) and key not in failed
+ ]
+ if isinstance(exc, ValueError) and not failed:
+ raise # our own refusal (unknown media), nothing left changed
+ restored = (
+ f"layout {target.Name!r} restored to its previous page setup"
+ if not failed
+ else f"layout {target.Name!r} could NOT be fully restored "
+ f"(still changed: {', '.join(failed)})"
+ )
+ if isinstance(exc, ValueError):
+ raise ValueError(f"{exc}; {restored}") from exc
+ raise ValueError(
+ f"page_setup_apply: AutoCAD refused Layout.{step} ({exc}); {restored}"
+ ) from exc
+ after = self._com_page_setup_row(target)
+ changed = {
+ key: [before[key], after[key]]
+ for key in after
+ if key != "layout" and before.get(key) != after[key]
+ }
+ return {
+ "ok": True,
+ "layout": target.Name,
+ "applied": dict(resolved),
+ "changed": changed,
+ "warnings": page_setup_warnings(changed),
+ "plot_style_known": bool(resolved["plot_style_known"]),
+ "viewports_kept": True,
+ "backend": "com",
+ }
+
+ return await self._run(_sync)
+
+ @staticmethod
+ def _com_center_plot_is_set(layout) -> bool:
+ """Whether the layout carries CenterPlot=True (a failed read counts as no)."""
+ try:
+ return bool(layout.CenterPlot)
+ except Exception as exc:
+ log.debug("Layout.CenterPlot read failed: %s", exc)
+ return False
+
+ @staticmethod
+ def _com_unwind_page_setup(journal: list[tuple[Callable[[], None], str]]) -> list[str]:
+ """Undo every journaled page-setup write in reverse; return what would not go back."""
+ failed: list[str] = []
+ for undo, label in reversed(journal):
+ try:
+ undo()
+ except Exception as exc:
+ log.warning("page_setup_apply: could not restore Layout.%s: %s", label, exc)
+ failed.append(label)
+ return failed
+
+ async def plot_style_list(self) -> list[dict]:
+ """The ctb catalogue, marked against the files in AutoCAD's plot style path.
+
+ ``Preferences.Files.PrinterStyleSheetPath`` 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
+
+ def _sync():
+ rows = [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
+ try:
+ raw = str(_acad_app().Preferences.Files.PrinterStyleSheetPath or "")
+ except Exception as exc:
+ log.debug("Preferences.Files.PrinterStyleSheetPath 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:
+ """``SaveAs(path, ac2018_Template)`` for ``.dwt``, the DXF code for ``.dxf``.
+
+ AutoCAD's template description is its summary-info Comments field, so
+ ``description`` is written there first (``description_written`` says
+ whether it took). SaveAs rebinds the active document to the new file,
+ as the SAVEAS command does — reported as ``document_rebound``.
+ Fake-tested; executed live by ``scripts/smoke_settings_com.py --build-dwt``.
+ """
+ suffix = Path(path).suffix.lower()
+ if suffix not in (".dwt", ".dxf"):
+ raise ValueError(f"drawing_template_save: path must end in .dwt or .dxf, got {path!r}")
+
+ def _sync():
+ doc = _acad_doc()
+ description_written = False
+ if description is not None:
+ try:
+ doc.SummaryInfo.Comments = str(description)
+ description_written = True
+ except Exception as exc:
+ log.debug("SummaryInfo.Comments write failed: %s", exc)
+ code = self._AC_SAVEAS_TEMPLATE if suffix == ".dwt" else self._AC_SAVEAS_DXF
+ doc.SaveAs(path, code)
+ return {
+ "ok": True,
+ "path": path,
+ "format": suffix[1:],
+ "backend": "com",
+ "name": name or Path(path).stem,
+ "description_written": description_written,
+ "document_rebound": True,
+ }
+
+ return await self._run(_sync)
+
# ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) ─
async def solid_box(
diff --git a/backends/contracts/__init__.py b/backends/contracts/__init__.py
index 36d2f56..1fc45cb 100644
--- a/backends/contracts/__init__.py
+++ b/backends/contracts/__init__.py
@@ -21,6 +21,7 @@ from backends.contracts.identity import IdentityContract
from backends.contracts.layers import LayerContract
from backends.contracts.layouts import LayoutContract
from backends.contracts.linetypes import LinetypeContract
+from backends.contracts.pagesetup import PageSetupContract
from backends.contracts.premium import PremiumContract
from backends.contracts.settings import SettingsContract
from backends.contracts.solids import SolidContract
@@ -43,6 +44,7 @@ __all__ = [
"LayerContract",
"LayoutContract",
"LinetypeContract",
+ "PageSetupContract",
"PremiumContract",
"SettingsContract",
"SolidContract",
diff --git a/backends/contracts/pagesetup.py b/backends/contracts/pagesetup.py
new file mode 100644
index 0000000..35827c4
--- /dev/null
+++ b/backends/contracts/pagesetup.py
@@ -0,0 +1,82 @@
+"""Page setup, plot styles and templates (v1.6 track E, group P).
+
+``AutoCADBackend`` composes every contract in this package, so importing
+``backends.base.AutoCADBackend`` is unchanged for callers.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+
+class PageSetupContract(ABC):
+ @abstractmethod
+ async def page_setup_list(self, layout: str | None = None) -> list[dict]:
+ """Page setup of one paper-space layout, or of every one.
+
+ Rows: ``{layout, paper, canonical_media_name, size_mm: [w, h],
+ orientation, plot_style, scale, plot_area, device,
+ margins_mm: [top, bottom, left, right], paper_units, center}``.
+ ``size_mm`` and ``margins_mm`` are the sheet *as plotted*: under
+ ``plot_rotation`` 1/3 (AutoCAD's landscape-on-portrait-media
+ convention) both are turned together, through the one mapping the
+ renderer plots by (``papers.turned_sheet``), so the printable area a
+ row implies is the area the same engine plots. ``paper`` is the
+ catalogue name when the size matches one within 0.5 mm, otherwise the
+ media name as stored. ``paper_units`` (``"mm"`` | ``"inches"`` |
+ ``"pixels"``) is what one paper-space unit means at the plot scale;
+ sizes and margins are millimetres whatever it says. ``center`` is a
+ bool for an extents plot and ``None`` for a layout plot, where
+ centring is not applicable (AutoCAD greys it out; ActiveX refuses
+ ``CenterPlot = True`` under ``acLayout``). Raises ``ValueError`` for
+ an unknown layout.
+ ``Model`` is never listed: model space is plotted through
+ ``drawing_export_pdf(layout=None)`` and carries no sheet.
+ """
+ ...
+
+ @abstractmethod
+ async def page_setup_apply(self, layout: str, setup: dict) -> dict:
+ """Write a page setup resolved by
+ ``engineering.standards.papers.resolve_page_setup`` to a layout.
+
+ Returns ``{ok, layout, applied: {...setup}, changed: {key: [old, new]},
+ warnings: [str], plot_style_known, viewports_kept}``. ``changed`` is
+ computed from the read-back before and after (the rows
+ ``page_setup_list`` reports), so re-applying the same setup reports
+ ``{}``. The write resets ``plot_rotation`` to 0 (orientation rides on
+ the media's width/height); headlessly, with no ``margins_mm`` asked
+ for, the turned margins are re-written un-turned so the sheet keeps
+ the margins it had, and on the live engine (margins come from the
+ .pc3) the ones that moved show in ``changed``. ``setup["paper_units"]``
+ (``"mm"`` default, ``"inches"``, or ``None`` to keep the layout's
+ units as AutoCAD's own Page Setup does) is written when given; a move
+ rescales every paper-space unit by 25.4 at the plot scale and is named
+ in ``changed`` *and* ``warnings`` (``papers.page_setup_warnings``).
+ Refuses ``Model``, an unknown layout, and a ``setup`` missing a
+ resolved key, before anything is written. A write the engine refuses
+ part-way through is unwound (every landed value put back) before the
+ ``ValueError`` names the refused property. Never deletes viewports.
+ """
+ ...
+
+ @abstractmethod
+ async def plot_style_list(self) -> list[dict]:
+ """``[{name, source: "catalog"|"installed", installed: bool|None}]`` —
+ the authored ctb catalogue plus, on a live engine, the files present
+ in the plot style search path. ``installed`` is ``None`` where no
+ installation can be scanned."""
+ ...
+
+ @abstractmethod
+ async def drawing_template_save(
+ self, path: str, name: str | None = None, description: str | None = None
+ ) -> dict:
+ """Save the current drawing as a template.
+
+ ``.dxf`` writes on both engines; ``.dwt`` writes on the live engine
+ (``SaveAs(path, ac2018_Template)``) and raises
+ ``UnsupportedCapabilityError("dwt_write")`` headlessly. Returns
+ ``{ok, path, format: "dxf"|"dwt", backend, name, description_written}``.
+ """
+ ...
diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py
index 51aa61e..59ae718 100644
--- a/backends/ezdxf_backend.py
+++ b/backends/ezdxf_backend.py
@@ -178,6 +178,18 @@ _DWG_WRITE_REFUSAL = (
)
+# A DWT is a DWG container with a template flag. ezdxf cannot write it, and a
+# DXF under a .dwt name is exactly the mislabelled file the DWG refusal exists
+# to prevent. The DXF route works headlessly end to end: drawing_new(template=)
+# opens a .dxf template.
+_DWT_WRITE_REFUSAL = (
+ "drawing_template_save: the headless ezdxf backend cannot write DWT — a .dwt is a DWG "
+ "container, and ezdxf has no DWG writer. Save the template as .dxf instead "
+ "(drawing_new(template='{stem}.dxf') opens it headlessly), or switch to the live COM "
+ "backend (AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD) for a real .dwt."
+)
+
+
def _is_dwg_path(path: str) -> bool:
"""True when `path` names a DWG file. The extension is authoritative (N2)."""
return Path(path).suffix.lower() == ".dwg"
@@ -1402,6 +1414,9 @@ class EzdxfBackend(AutoCADBackend):
"refiner": FeatureCapability(True, "shared"),
"delivery": FeatureCapability(True, "shared"),
"paper_space": FeatureCapability(True, "native"),
+ "dwt_write": FeatureCapability(
+ False, reason="dwt_is_a_dwg_container_requires_live_autocad"
+ ),
# Measured, not assumed: a sheet holding nothing but a viewport
# renders the model geometry that viewport looks at, at its
# scale and position, clipped to its window. v1.4 declared this
@@ -1897,6 +1912,16 @@ class EzdxfBackend(AutoCADBackend):
the geometry that viewport shows, and geometry outside the window is
correctly left out. What is genuinely missing is the viewport *border*,
which the headless renderer does not draw.
+
+ ``layout=None`` and ``layout="Model"`` both mean model space, as the
+ tool advertises and as the COM engine plots. This used to route them
+ through ``_msp()``, which follows the *current* tab, so with a sheet
+ current the "Model" PDF was that sheet's paper-space content — a wrong
+ page with no refusal. Measured: circle in model space, 390 x 270
+ rectangle on an A3 sheet, sheet current — the Model row's /MediaBox
+ was 176.1 x 121.9 mm (the rectangle's aspect) instead of the circle's
+ 121.9 x 121.9. The target is now ``doc.modelspace()`` whichever tab
+ is current.
"""
def _sync():
@@ -1907,18 +1932,41 @@ class EzdxfBackend(AutoCADBackend):
return {"ok": False, "error": f"Layout not found: {layout}"}
target = doc.layouts.get(resolved)
else:
- target = self._msp()
+ target = doc.modelspace()
try:
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
- fig = _new_agg_figure()
+ # Track E: for a sheet, the figure *is* the paper. Sized from
+ # the layout's page setup and left unadjusted, so the PDF's
+ # /MediaBox is the paper size — the evidence page_setup_apply
+ # is judged by. Measured: adjust_figure=True gave 691.2 x 345.6
+ # pt whatever the sheet was.
+ paper = None
+ if resolved is not None and resolved != "Model":
+ paper = self._paper_frame(target)
+ fig = _new_agg_figure(figsize=paper["figsize"] if paper else None)
ax = fig.add_axes([0, 0, 1, 1])
ctx = RenderContext(doc)
- out = MatplotlibBackend(ax)
+ out = MatplotlibBackend(ax, adjust_figure=paper is None)
Frontend(ctx, out).draw_layout(target, finalize=True)
+ if paper is not None:
+ ax.set_xlim(*paper["xlim"])
+ ax.set_ylim(*paper["ylim"])
+ ax.set_aspect("equal", adjustable="box")
fig.savefig(path, dpi=150)
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":
result["layout"] = resolved
result["note"] = (
@@ -2681,6 +2729,397 @@ class EzdxfBackend(AutoCADBackend):
text = f"{float(value):.4f}".rstrip("0").rstrip(".")
return text or "0"
+ # ── page setup (track E) ─────────────────────────────────────────────────
+
+ def _paper_layout(self, raw: str, operation: str):
+ """Resolve a paper-space layout by name, refusing Model and unknowns."""
+ doc = self._require_doc()
+ resolved = self._find_layout(raw)
+ if resolved is None:
+ available = [n for n in doc.layouts.names_in_taborder() if n != "Model"]
+ raise ValueError(
+ f"{operation}: layout {raw!r} not found (paper-space layouts: "
+ f"{', '.join(available) or 'none'})"
+ )
+ if resolved == "Model":
+ raise ValueError(
+ f"{operation}: Model space has no sheet; plot it with "
+ "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout"
+ )
+ return doc.layouts.get(resolved)
+
+ @staticmethod
+ def _stored_sheet(dxf) -> tuple[list[float], list[float]]:
+ """``(size_mm, margins_mm)`` of the sheet *as plotted*, from the media
+ as stored: ``papers.turned_sheet`` over PLOTSETTINGS 40-47 and
+ ``plot_rotation``. DXF stores every paper size and margin in
+ millimetres, whatever ``plot_paper_units`` says (ezdxf's
+ acdb_plot_settings, verified). ``margins_mm`` is [top, bottom, left, right].
+ """
+ from engineering.standards.papers import turned_sheet
+
+ return turned_sheet(
+ dxf.paper_width,
+ dxf.paper_height,
+ (dxf.top_margin, dxf.bottom_margin, dxf.left_margin, dxf.right_margin),
+ dxf.plot_rotation,
+ )
+
+ @classmethod
+ def _page_setup_row(cls, layout) -> dict:
+ from engineering.standards.papers import (
+ PLOT_TYPE_NAMES,
+ center_applies,
+ dxf_scale_label,
+ paper_from_size,
+ paper_units_name,
+ scale_label,
+ )
+
+ dxf = layout.dxf_layout.dxf
+ flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags")))
+ # The sheet as plotted: size *and* margins turned by plot_rotation
+ # (the same mapping _paper_frame plots by). Swapping only the size
+ # reported a 297 x 210 sheet with its portrait margins.
+ (width, height), margins = cls._stored_sheet(dxf)
+ media = str(dxf.paper_size)
+ numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator)
+ if flags & layout.USE_STANDARD_SCALE:
+ scale = dxf_scale_label(int(dxf.standard_scale_type), numerator, denominator)
+ else:
+ scale = scale_label(numerator, denominator)
+ plot_type = int(dxf.plot_type)
+ return {
+ "layout": layout.name,
+ "paper": paper_from_size(width, height) or media,
+ "canonical_media_name": media,
+ "size_mm": [round(width, 2), round(height, 2)],
+ "orientation": "landscape" if width >= height else "portrait",
+ "plot_style": str(dxf.current_style_sheet),
+ "scale": scale,
+ "plot_area": PLOT_TYPE_NAMES.get(plot_type, str(dxf.plot_type)),
+ "device": str(dxf.plot_configuration_file),
+ "margins_mm": [round(v, 3) for v in margins],
+ # What one paper-space unit means at the plot scale (DXF group 72).
+ "paper_units": paper_units_name(dxf.plot_paper_units),
+ # A layout plot has no centring (AutoCAD greys it out; ActiveX
+ # refuses CenterPlot=True under acLayout): None, not a bit nobody honours.
+ "center": bool(flags & layout.PLOT_CENTERED) if center_applies(plot_type) else None,
+ }
+
+ @staticmethod
+ 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 the authored ``DXF_STD_SCALES``
+ (the ObjectARX enum AutoCAD writes — ezdxf's ``STD_SCALES`` is one off
+ above 18 and would render an AutoCAD-authored 1:10 sheet at 1:16),
+ anything else through the stored numerator / denominator.
+ """
+ from engineering.standards.papers import DXF_STD_SCALES, 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 DXF_STD_SCALES:
+ numerator, denominator = (float(v) for v in DXF_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
+ 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
+ paper-space X runs along the 297 mm side. The figure *and* the axes
+ window both take the turned size — sizing only the figure left the
+ window 210 wide inside a 297 mm page, and ``set_aspect("equal")`` then
+ shrank the axes box to 0.707 and cut everything past x = 210 minus the
+ margin (measured: a 277 mm frame lost its right 27%, with ``ok: True``
+ and a MediaBox that read as correct). The margins turn with the sheet,
+ mapped the way ezdxf's own ``Page.from_dxf_layout`` maps them —
+ ``papers.turned_sheet``, the one mapping ``page_setup_list`` reports
+ by, so the row and the plot never disagree. What 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
+ rotation = int(dxf.plot_rotation)
+ unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0
+ (sheet_w, sheet_h), (top, bottom, left, right) = cls._stored_sheet(dxf)
+ 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
+ # ``unit`` is millimetres per paper unit (25.4 under inches),
+ # so paper units per drawing unit *divides* by it. Multiplying
+ # reported a 1:1.06 inch fit as ``607.06:1`` (off by 25.4^2)
+ # while the render itself was right, ``units_per_mm`` being
+ # unit-free; the mm sheet hid it because there ``unit`` is 1.
+ 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, 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():
+ doc = self._require_doc()
+ if layout is not None and self._layout_name(layout):
+ return [self._page_setup_row(self._paper_layout(layout, "page_setup_list"))]
+ return [
+ self._page_setup_row(doc.layouts.get(name))
+ for name in doc.layouts.names_in_taborder()
+ if name != "Model"
+ ]
+
+ return await self._async(_sync)
+
+ async def page_setup_apply(self, layout: str, setup: dict) -> dict:
+ from engineering.standards.papers import (
+ PAPER_UNITS,
+ page_setup_warnings,
+ require_page_setup,
+ )
+
+ resolved = require_page_setup(setup)
+
+ def _sync():
+ target = self._paper_layout(layout, "page_setup_apply")
+ before = self._page_setup_row(target)
+ dxf = target.dxf_layout.dxf
+ # Measured: a fresh layout has no stored plot_layout_flags, and
+ # set_flag_state starts from 0 — toggling one bit would drop
+ # plot-styles (32), lineweights (128) and viewports-first (512).
+ if not dxf.hasattr("plot_layout_flags"):
+ dxf.plot_layout_flags = dxf.get_default("plot_layout_flags")
+ width, height = (float(v) for v in resolved["size_mm"])
+ # Orientation rides on width/height, as AutoCAD names media, so
+ # plot_rotation goes to 0. The stored margins only meant what
+ # they meant *through* the old turn: with none asked for, the
+ # sheet keeps the margins it had (the turned ones are written as
+ # the unturned ones), instead of silently swapping to the media's
+ # portrait margins with nothing in ``changed``.
+ margins = resolved["margins_mm"]
+ if margins is None:
+ margins = self._stored_sheet(dxf)[1] # turned, un-rounded
+ dxf.page_setup_name = ""
+ dxf.plot_configuration_file = resolved["device"]
+ dxf.paper_size = resolved["canonical_media_name"]
+ dxf.paper_width = width
+ dxf.paper_height = height
+ if resolved["paper_units"] is not None:
+ dxf.plot_paper_units = PAPER_UNITS[resolved["paper_units"]]
+ dxf.plot_rotation = 0
+ top, bottom, left, right = (float(v) for v in margins)
+ dxf.top_margin, dxf.bottom_margin = top, bottom
+ dxf.left_margin, dxf.right_margin = left, right
+ dxf.plot_origin_x_offset = 0.0
+ dxf.plot_origin_y_offset = 0.0
+ dxf.current_style_sheet = resolved["plot_style"]
+ target.use_plot_styles(True)
+ target.set_plot_type(int(resolved["plot_type"]))
+ # The scale is stored three ways and AutoCAD reads a different
+ # one per route (measured, AutoCAD 2026 — papers.py docstring):
+ # under USE_STANDARD_SCALE its plot engine scales by group 147
+ # alone (a sheet with code 21 and 147 = 1.0 plots 1:10 geometry
+ # at 1:1 while page_setup_list says 1:10); under the custom
+ # route it scales by 142/143. All three are written, on every
+ # route, from the one resolved ratio, so no reader can disagree
+ # with the plot. ``fit`` has ratio 1/1 and 147 = 1.0, as AutoCAD
+ # writes it.
+ code = resolved["dxf_standard_scale_type"]
+ numerator, denominator = (float(v) for v in resolved["scale_ratio"])
+ if code is not None:
+ dxf.standard_scale_type = int(code)
+ target.use_standard_scale(True)
+ else:
+ dxf.standard_scale_type = 16
+ target.use_standard_scale(False)
+ dxf.scale_numerator, dxf.scale_denominator = numerator, denominator
+ dxf.unit_factor = numerator / denominator
+ # resolve_page_setup hands over None for a layout plot: the bit is
+ # cleared, never set — the state AutoCAD itself stores for acLayout,
+ # and the mirror of the live engine never writing CenterPlot there.
+ target.plot_centered(bool(resolved["center"]))
+ # Limits follow the paper; viewports are deliberately left alone
+ # (Paperspace.page_setup() would delete them).
+ target.reset_paper_limits()
+ self._mark_dirty()
+ after = self._page_setup_row(target)
+ changed = {
+ key: [before[key], after[key]]
+ for key in after
+ if key != "layout" and before.get(key) != after[key]
+ }
+ return {
+ "ok": True,
+ "layout": target.name,
+ "applied": dict(resolved),
+ "changed": changed,
+ "warnings": page_setup_warnings(changed),
+ "plot_style_known": bool(resolved["plot_style_known"]),
+ "viewports_kept": True,
+ "backend": "ezdxf",
+ }
+
+ return await self._async(_sync)
+
+ async def plot_style_list(self) -> list[dict]:
+ from engineering.standards.papers import CTB_CATALOG
+
+ return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
+
+ async def drawing_template_save(self, path, name=None, description=None) -> dict:
+ """Save the current drawing as a template — DXF only, headlessly.
+
+ ``.dwt`` is refused with capability ``dwt_write`` before anything is
+ written; ``.dxf`` goes through ``drawing_save_as`` (the document is
+ rebound to the new path, as AutoCAD's SAVEAS does). A description has
+ no home in a DXF header — it is a DWG/DWT summary property — so it is
+ reported ``description_written: False`` rather than dropped silently.
+ """
+ suffix = Path(path).suffix.lower()
+ if suffix == ".dwt":
+ raise UnsupportedCapabilityError(
+ "dwt_write", _DWT_WRITE_REFUSAL.format(stem=Path(path).stem)
+ )
+ if suffix != ".dxf":
+ raise ValueError(
+ f"drawing_template_save: path must end in .dxf (headless) or .dwt (live "
+ f"AutoCAD), got {path!r}"
+ )
+ saved = await self.drawing_save_as(path, "dxf")
+ result = {
+ "ok": True,
+ "path": saved["path"],
+ "format": "dxf",
+ "backend": "ezdxf",
+ "name": name or Path(path).stem,
+ "description_written": False,
+ }
+ if description is not None:
+ result["description_note"] = (
+ "DXF has no template description field; it is a DWG/DWT summary property "
+ "(capability 'dwgprops')"
+ )
+ return result
+
# ── 3D solids (unsupported headlessly — honest capability boundary) ─────
@staticmethod
diff --git a/discovery/aliases.py b/discovery/aliases.py
index e67976e..559b6a3 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -78,6 +78,12 @@ class ToolAliases:
# destination for every one of them.
# DWGPROPS -- one dialog, three tabs: General (drawing_info), Summary and
# Custom (drawing_properties_get / drawing_properties_set).
+# 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.
+# SAVEAS -- the same command saves a copy (drawing_save_as) or a template
+# (drawing_template_save, its "Drawing Template" file type).
#
# Every other AutoCAD command must map to exactly one tool.
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
@@ -92,8 +98,11 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
"LAYOUT",
"MEASUREGEOM",
"MLEADERSTYLE",
+ "PAGESETUP",
"PLINE",
+ "PLOT",
"QSELECT",
+ "SAVEAS",
"SETVAR",
"STYLE",
"ZOOM",
@@ -451,7 +460,13 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
),
"drawing_new": ToolAliases(
acad=("NEW", "QNEW"),
- synonyms=("new drawing", "start a drawing", "blank sheet", "create a file"),
+ synonyms=(
+ "new drawing",
+ "start a drawing",
+ "blank sheet",
+ "create a file",
+ "start from a bundled template",
+ ),
),
"drawing_open": ToolAliases(
acad=("OPEN",),
@@ -1592,6 +1607,71 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"active text style",
),
),
+ # ── 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",
+ ),
+ ),
+ "drawing_template_list": ToolAliases(
+ acad=(),
+ synonyms=(
+ "which templates are there",
+ "bundled templates",
+ "iso a3 template",
+ "ansi b sheet",
+ "start from a standard sheet",
+ "dwt list",
+ ),
+ ),
+ "drawing_template_save": ToolAliases(
+ acad=("SAVEAS",),
+ synonyms=(
+ "save as template",
+ "make a dwt",
+ "template from this drawing",
+ "save as dwt",
+ "reuse this sheet setup",
+ ),
+ ),
+ "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 63962dc..58a28b1 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": 179,
+ "tools": 185,
"resources": 8,
"prompts": 5,
"groups": 21
@@ -20,13 +20,13 @@
"entity_modification": 16,
"entity_query": 9,
"layers": 14,
- "layouts": 12,
+ "layouts": 16,
"pid": 9,
"premium": 12,
"solids": 5,
"styles": 10,
"system": 8,
- "templates": 2,
+ "templates": 4,
"transactions": 3,
"validation": 1,
"view": 4
@@ -176,6 +176,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",
@@ -652,7 +667,7 @@
"name": "drawing_new",
"group": "drawing",
"cost": "mutate",
- "summary": "Start a blank drawing, pre-seeded with the standard engineering layers.",
+ "summary": "Start a drawing: blank with the engineering layers, or from a bundled/own template.",
"tags": [
"drawing"
],
@@ -810,6 +825,35 @@
"destructiveHint": false
}
},
+ {
+ "name": "drawing_template_list",
+ "group": "templates",
+ "cost": "read",
+ "summary": "List the five bundled drawing templates: standard, sheet, layers, styles, page setup.",
+ "tags": [
+ "drawing",
+ "query",
+ "template"
+ ],
+ "annotations": {
+ "title": "List Drawing Templates",
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "drawing_template_save",
+ "group": "templates",
+ "cost": "mutate",
+ "summary": "Save the current drawing as a template: .dwt on live AutoCAD, .dxf headlessly.",
+ "tags": [
+ "drawing",
+ "template"
+ ],
+ "annotations": {
+ "title": "Save As Template",
+ "destructiveHint": false
+ }
+ },
{
"name": "drawing_undo",
"group": "drawing",
@@ -1844,6 +1888,35 @@
"readOnlyHint": true
}
},
+ {
+ "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",
@@ -1971,6 +2044,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/papers.py b/engineering/standards/papers.py
new file mode 100644
index 0000000..d9279d2
--- /dev/null
+++ b/engineering/standards/papers.py
@@ -0,0 +1,593 @@
+"""Paper sizes, plot-style catalogue and plot scales — authored data.
+
+Sources: ISO 216 (A series), ASME/ANSI Y14.1 (A–E, metric millimetre values as
+AutoCAD's ``ANSI_*`` media measure, inch values as the media are *named*),
+the ctb files AutoCAD ships in its Plot Styles folder, and the standard-scale
+enum — one numbering, shared by the file and by ActiveX:
+
+* DXF group code 75 (``standard_scale_type``) is the ObjectARX
+ ``AcDbPlotSettings::StdScaleType`` enum: 0=fit, 1..15 the imperial
+ architectural scales, 16=1:1, 17=1:2, 18=1:4, 19=1:5, 20=1:8, 21=1:10,
+ 22=1:16, 23=1:20, 24=1:30, 25=1:40, 26=1:50, 27=1:100, 28=2:1, 29=4:1,
+ 30=8:1, 31=10:1, 32=100:1, 33=1000:1 (``DXF_STD_SCALES``). Measured live
+ on AutoCAD 2026 (COM ``SaveAs(..., 61)`` after ``StandardScale = n``):
+ 1:10 saves as 21, 1:50 as 26, 2:1 as 28, 10:1 as 31. ezdxf's
+ ``STD_SCALES`` table (and the DXF Reference page it follows) has no 19=1:5
+ and numbers everything above 18 one lower (20=1:10, 25=1:50, 30=10:1) —
+ reading an AutoCAD-authored 1:50 sheet through it reports 1:100, and a
+ 1:10 sheet renders at 1:16 — so nothing here goes through that table.
+* DXF group code 147 (``unit_factor`` in ezdxf; "a floating point scale
+ factor that represents the standard scale value specified in code 75" in
+ the DXF Reference — the Reference is right) is the factor AutoCAD's plot
+ engine *uses* under ``USE_STANDARD_SCALE`` (bit 16): AutoCAD writes it as
+ numerator / denominator (0.1 for 1:10, 2.0 for 2:1, 1.0 for fit and 1:1),
+ and a file with code 21 but 147 = 1.0 plots at 1:1 while claiming 1:10
+ (measured: a 100-unit line spans 99.99 mm of paper). Under the custom
+ route (bit 16 cleared) AutoCAD honours 142/143 and leaves 75 and 147
+ stale. The headless writer therefore writes 147 alongside 142/143 on
+ every route.
+* ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib)
+ is the same numbering (ac1_1=16, ac1_2=17, ac1_10=21, ac1_20=23,
+ ac1_50=26, ac1_100=27, ac2_1=28, ac10_1=31, acScaleToFit=0). The typelib
+ declares ``ac1_5=19`` too, but AutoCAD 2026 refuses
+ ``Layout.StandardScale = 19`` with "Invalid input" in every condition tried
+ (PaperUnits mm and inches, UseStandardScale True and False; measured live
+ 2026-09-17), so 1:5 is not in ``ACTIVEX_PLOT_SCALE`` and takes the custom
+ route on the live engine (``SetCustomScale(1, 5)`` reads back ``1:5``).
+ The headless writer mirrors that — 1:5 is not in ``DXF_STANDARD_SCALE_TYPE``
+ either, so both engines store the same 1:5 (custom, 142/143 = 1/5) — while
+ the reader knows code 19 for files AutoCAD wrote.
+
+A scale without a code on an engine goes through the custom numerator /
+denominator on that engine, and the read-back reports the same label either way.
+
+Centring belongs to an *extents* plot only. The ActiveX Reference (CenterPlot
+Property, acadauto.chm, AutoCAD 2026): "This property cannot be set to True on
+a layout object whose PlotType property is set to acLayout" — a layout plot is
+the whole sheet from its own origin, and the Page Setup dialog greys the box
+out. ``resolve_page_setup`` therefore resolves ``center`` to ``None`` (not
+applicable, never written, read back as ``None``) when ``plot_area`` is
+``layout``; the bool is kept only for ``extents``. Both engines follow the
+same resolved value, so the DXF never carries a centre bit AutoCAD would
+ignore and the live engine never issues the write AutoCAD refuses.
+"""
+
+from __future__ import annotations
+
+import math
+import re
+
+__all__ = [
+ "ACTIVEX_PLOT_SCALE",
+ "CTB_CATALOG",
+ "DXF_STANDARD_SCALE_TYPE",
+ "DXF_STD_SCALES",
+ "ORIENTATIONS",
+ "PAPER_INCHES",
+ "PAPER_SIZES",
+ "PAPER_UNITS",
+ "PAPER_UNITS_NAMES",
+ "PLOT_AREAS",
+ "PLOT_TYPE_LAYOUT",
+ "PLOT_TYPE_NAMES",
+ "REQUIRED_SETUP_KEYS",
+ "SCALES",
+ "activex_scale_label",
+ "canonical_media_name",
+ "center_applies",
+ "dxf_scale_label",
+ "page_setup_warnings",
+ "paper_from_size",
+ "paper_size_mm",
+ "paper_units_name",
+ "plot_style_known",
+ "require_page_setup",
+ "resolve_page_setup",
+ "scale_label",
+ "scale_ratio",
+ "turned_sheet",
+]
+
+#: Portrait width × height in millimetres. Landscape swaps.
+PAPER_SIZES: dict[str, tuple[int, int]] = {
+ "ISO_A0": (841, 1189),
+ "ISO_A1": (594, 841),
+ "ISO_A2": (420, 594),
+ "ISO_A3": (297, 420),
+ "ISO_A4": (210, 297),
+ "ANSI_A": (216, 279),
+ "ANSI_B": (279, 432),
+ "ANSI_C": (432, 559),
+ "ANSI_D": (559, 864),
+ "ANSI_E": (864, 1118),
+}
+
+#: ANSI media are *named* in inches by AutoCAD's DWG To PDF.pc3
+#: (``ANSI_B_(17.00_x_11.00_Inches)``); the millimetre table above is what the
+#: sheet measures. Portrait width × height.
+PAPER_INCHES: dict[str, tuple[float, float]] = {
+ "ANSI_A": (8.5, 11.0),
+ "ANSI_B": (11.0, 17.0),
+ "ANSI_C": (17.0, 22.0),
+ "ANSI_D": (22.0, 34.0),
+ "ANSI_E": (34.0, 44.0),
+}
+
+#: Spellings resolve_page_setup accepts, mapped to the canonical key.
+_PAPER_ALIASES: dict[str, str] = {
+ **{name.lower(): name for name in PAPER_SIZES},
+ **{name.split("_", 1)[1].lower(): name for name in PAPER_SIZES if name.startswith("ISO_")},
+ **{f"ansi {name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")},
+ **{f"ansi-{name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")},
+}
+
+#: Plot style tables AutoCAD ships (``/…/Plot Styles``).
+CTB_CATALOG: tuple[str, ...] = (
+ "monochrome.ctb",
+ "acad.ctb",
+ "Grayscale.ctb",
+ "Screening 100%.ctb",
+ "Screening 75%.ctb",
+ "Screening 50%.ctb",
+ "Screening 25%.ctb",
+ "Fill Patterns.ctb",
+ "DWF Virtual Pens.ctb",
+)
+
+#: Label → paper:drawing factor (``None`` = scaled to fit).
+SCALES: dict[str, float | None] = {
+ "fit": None,
+ "1:1": 1.0,
+ "1:2": 0.5,
+ "1:5": 0.2,
+ "1:10": 0.1,
+ "1:20": 0.05,
+ "1:50": 0.02,
+ "1:100": 0.01,
+ "2:1": 2.0,
+ "5:1": 5.0,
+ "10:1": 10.0,
+}
+
+#: DXF group code 75 → (numerator, denominator): the ObjectARX
+#: ``AcDbPlotSettings::StdScaleType`` enum, which is what AutoCAD writes and
+#: reads (module docstring — not ezdxf's ``STD_SCALES``, which is one off
+#: above 18). Code 0 is *scaled to fit* and has no ratio.
+DXF_STD_SCALES: dict[int, tuple[float, float]] = {
+ 1: (1.0 / 128.0, 12.0),
+ 2: (1.0 / 64.0, 12.0),
+ 3: (1.0 / 32.0, 12.0),
+ 4: (1.0 / 16.0, 12.0),
+ 5: (3.0 / 32.0, 12.0),
+ 6: (1.0 / 8.0, 12.0),
+ 7: (3.0 / 16.0, 12.0),
+ 8: (1.0 / 4.0, 12.0),
+ 9: (3.0 / 8.0, 12.0),
+ 10: (1.0 / 2.0, 12.0),
+ 11: (3.0 / 4.0, 12.0),
+ 12: (1.0, 12.0),
+ 13: (3.0, 12.0),
+ 14: (6.0, 12.0),
+ 15: (12.0, 12.0),
+ 16: (1.0, 1.0),
+ 17: (1.0, 2.0),
+ 18: (1.0, 4.0),
+ 19: (1.0, 5.0),
+ 20: (1.0, 8.0),
+ 21: (1.0, 10.0),
+ 22: (1.0, 16.0),
+ 23: (1.0, 20.0),
+ 24: (1.0, 30.0),
+ 25: (1.0, 40.0),
+ 26: (1.0, 50.0),
+ 27: (1.0, 100.0),
+ 28: (2.0, 1.0),
+ 29: (4.0, 1.0),
+ 30: (8.0, 1.0),
+ 31: (10.0, 1.0),
+ 32: (100.0, 1.0),
+ 33: (1000.0, 1.0),
+}
+#: AutoCAD's names for the imperial codes (inches of paper = feet of drawing).
+_DXF_IMPERIAL_LABELS: dict[int, str] = {
+ 1: "1/128\"=1'",
+ 2: "1/64\"=1'",
+ 3: "1/32\"=1'",
+ 4: "1/16\"=1'",
+ 5: "3/32\"=1'",
+ 6: "1/8\"=1'",
+ 7: "3/16\"=1'",
+ 8: "1/4\"=1'",
+ 9: "3/8\"=1'",
+ 10: "1/2\"=1'",
+ 11: "3/4\"=1'",
+ 12: "1\"=1'",
+ 13: "3\"=1'",
+ 14: "6\"=1'",
+ 15: "1'=1'",
+}
+
+#: DXF group code 75 the headless *writer* uses per catalogue label. Labels
+#: missing here use the custom numerator/denominator: ``1:5`` (code 19 exists
+#: in the file enum, but the live engine cannot set it, so both engines store
+#: 1:5 the same custom way) and ``5:1`` (no code anywhere).
+DXF_STANDARD_SCALE_TYPE: dict[str, int] = {
+ "fit": 0,
+ "1:1": 16,
+ "1:2": 17,
+ "1:10": 21,
+ "1:20": 23,
+ "1:50": 26,
+ "1:100": 27,
+ "2:1": 28,
+ "10:1": 31,
+}
+
+#: ActiveX ``AcPlotScale`` (AutoCAD 2026 typelib). Labels missing here use SetCustomScale.
+#: No ``"1:5"``: the typelib's ``ac1_5 = 19`` is refused by AutoCAD 2026 (module docstring).
+ACTIVEX_PLOT_SCALE: dict[str, int] = {
+ "fit": 0,
+ "1:1": 16,
+ "1:2": 17,
+ "1:10": 21,
+ "1:20": 23,
+ "1:50": 26,
+ "1:100": 27,
+ "2:1": 28,
+ "10:1": 31,
+}
+_ACTIVEX_SCALE_LABEL = {code: label for label, code in ACTIVEX_PLOT_SCALE.items()}
+
+#: Plot area → DXF/ActiveX plot type (``AcPlotType``: acExtents=1, acLayout=5).
+PLOT_AREAS: dict[str, int] = {"layout": 5, "extents": 1}
+#: ``AcPlotType.acLayout`` — the plot type under which centring does not apply.
+PLOT_TYPE_LAYOUT = 5
+PLOT_TYPE_NAMES: dict[int, str] = {
+ 0: "display",
+ 1: "extents",
+ 2: "limits",
+ 3: "view",
+ 4: "window",
+ 5: "layout",
+}
+
+ORIENTATIONS: tuple[str, ...] = ("landscape", "portrait")
+
+_SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$")
+
+
+def _paper_key(paper) -> str:
+ key = str(paper or "").strip().lower().replace(" ", "_")
+ resolved = _PAPER_ALIASES.get(key) or _PAPER_ALIASES.get(key.replace("_", " "))
+ if resolved is None:
+ raise ValueError(
+ f"paper: unknown paper {paper!r}. Valid papers: {', '.join(PAPER_SIZES)} "
+ "(short forms A0–A4 and ANSI_A–ANSI_E are accepted)"
+ )
+ return resolved
+
+
+def _orientation(orientation) -> str:
+ if orientation not in ORIENTATIONS:
+ raise ValueError(f"orientation: must be one of {ORIENTATIONS}, got {orientation!r}")
+ return orientation
+
+
+def paper_size_mm(paper: str, orientation: str = "landscape") -> tuple[float, float]:
+ """(width, height) in millimetres for ``paper`` in ``orientation``."""
+ name = _paper_key(paper)
+ short, long = PAPER_SIZES[name]
+ if _orientation(orientation) == "landscape":
+ return float(long), float(short)
+ return float(short), float(long)
+
+
+def canonical_media_name(paper: str, orientation: str = "landscape") -> str:
+ """AutoCAD's spelling of the media: ``ISO_A3_(420.00_x_297.00_MM)`` for
+ landscape A3, ``ANSI_B_(17.00_x_11.00_Inches)`` for landscape ANSI B."""
+ name = _paper_key(paper)
+ if name in PAPER_INCHES:
+ short, long = PAPER_INCHES[name]
+ unit = "Inches"
+ else:
+ short, long = PAPER_SIZES[name]
+ unit = "MM"
+ width, height = (long, short) if _orientation(orientation) == "landscape" else (short, long)
+ return f"{name}_({width:.2f}_x_{height:.2f}_{unit})"
+
+
+def paper_from_size(width_mm, height_mm, tolerance: float = 0.5) -> str | None:
+ """The catalogue paper whose size (either orientation) is within ``tolerance`` mm."""
+ try:
+ w, h = float(width_mm), float(height_mm)
+ except (TypeError, ValueError):
+ return None
+ for name, (short, long) in PAPER_SIZES.items():
+ if (abs(w - long) <= tolerance and abs(h - short) <= tolerance) or (
+ abs(w - short) <= tolerance and abs(h - long) <= tolerance
+ ):
+ return name
+ return None
+
+
+def scale_ratio(label: str) -> tuple[float, float]:
+ """``"1:50"`` → ``(1.0, 50.0)`` (paper units : drawing units). ``fit`` has no ratio."""
+ match = _SCALE_RE.match(str(label or ""))
+ if match is None:
+ raise ValueError(f"scale: {label!r} is not an N:M ratio")
+ numerator, denominator = float(match.group(1)), float(match.group(2))
+ if numerator <= 0 or denominator <= 0:
+ raise ValueError(f"scale: {label!r} must have positive terms")
+ return numerator, denominator
+
+
+def scale_label(numerator, denominator) -> str:
+ """The ``N:M`` label for a ratio: a catalogue label when the factor matches
+ one, otherwise the reduced integer ratio, otherwise ``N:M`` with the floats."""
+ num, den = float(numerator), float(denominator)
+ if num <= 0 or den <= 0:
+ return f"{num:g}:{den:g}"
+ for label, factor in SCALES.items():
+ if factor is not None and math.isclose(num / den, factor, rel_tol=1e-9):
+ return label
+ if num.is_integer() and den.is_integer():
+ divisor = math.gcd(int(num), int(den))
+ return f"{int(num) // divisor}:{int(den) // divisor}"
+ return f"{num:g}:{den:g}"
+
+
+_DXF_SCALE_LABEL: dict[int, str] = {
+ 0: "fit",
+ **_DXF_IMPERIAL_LABELS,
+ **{
+ code: scale_label(num, den)
+ for code, (num, den) in DXF_STD_SCALES.items()
+ if code not in _DXF_IMPERIAL_LABELS
+ },
+}
+
+
+def dxf_scale_label(code, numerator, denominator) -> str:
+ """Read-back for the headless engine: the code-75 label (``fit`` for 0,
+ the ratio ``DXF_STD_SCALES`` gives the code, an AutoCAD-style name for an
+ imperial code), else the custom ratio."""
+ label = _DXF_SCALE_LABEL.get(int(code))
+ if label is not None:
+ return label
+ return scale_label(numerator, denominator)
+
+
+def activex_scale_label(code) -> str:
+ """Read-back for the live engine: the AcPlotScale label, else ``standard:``."""
+ return _ACTIVEX_SCALE_LABEL.get(int(code), f"standard:{int(code)}")
+
+
+def plot_style_known(name) -> bool:
+ return str(name or "").strip().lower() in {entry.lower() for entry in CTB_CATALOG}
+
+
+#: Plot paper units — the same codes in DXF group 72 (``plot_paper_units``) and
+#: ActiveX ``AcPlotPaperUnits`` (acInches=0, acMillimeters=1, acPixels=2). The
+#: paper size and margins are stored in millimetres whatever this says (ezdxf's
+#: acdb_plot_settings; GetPaperSize / GetPaperMargins measured live): the units
+#: govern what one paper-space unit *means* at the plot scale — under inches a
+#: 1:1 sheet holds 17 units across ANSI B, under millimetres 431.8.
+PAPER_UNITS: dict[str, int] = {"inches": 0, "mm": 1}
+PAPER_UNITS_NAMES: dict[int, str] = {0: "inches", 1: "mm", 2: "pixels"}
+_PAPER_UNITS_ALIASES = {"in": "inches", "inch": "inches", "inches": "inches", "mm": "mm"}
+
+
+def paper_units_name(code) -> str | None:
+ """Read-back for both engines: ``inches`` / ``mm`` / ``pixels``, ``None``
+ for an unreadable value, ``code:`` for one neither enum names."""
+ if code is None:
+ return None
+ try:
+ value = int(code)
+ except (TypeError, ValueError):
+ return None
+ return PAPER_UNITS_NAMES.get(value, f"code:{value}")
+
+
+def turned_sheet(width_mm, height_mm, margins_mm, rotation) -> tuple[list[float], list[float]]:
+ """The sheet as plotted from the media as stored: ``(size_mm, margins_mm)``.
+
+ ``margins_mm`` in and out is ``[top, bottom, left, right]`` — the row's
+ order. ``plot_rotation`` 1/3 is AutoCAD's landscape-on-portrait-media
+ convention (media 210 x 297, sheet 297 x 210): the size swaps *and* the
+ margins turn with it, mapped the way ezdxf's ``Page.from_dxf_layout``
+ maps them (rotation 1: top<-right, right<-bottom, bottom<-left, left<-top;
+ 2: opposite sides; 3: the inverse of 1). Every read-back and the headless
+ renderer go through this one function, so a row's printable area is the
+ area the same engine plots — swapping only the size reported a 297 x 210
+ sheet with [20, 20, 7.5, 7.5] (282 x 170 printable) whose true margins
+ were [7.5, 7.5, 20, 20] (257 x 195).
+ """
+ width, height = float(width_mm), float(height_mm)
+ top, bottom, left, right = (float(v) for v in margins_mm)
+ turn = int(rotation or 0)
+ if turn == 1:
+ width, height = height, width
+ top, right, bottom, left = right, bottom, left, top
+ elif turn == 2:
+ top, right, bottom, left = bottom, left, top, right
+ elif turn == 3:
+ width, height = height, width
+ top, right, bottom, left = left, top, right, bottom
+ return [width, height], [top, bottom, left, right]
+
+
+def page_setup_warnings(changed: dict) -> list[str]:
+ """What a ``page_setup_apply`` moved that ``changed`` alone under-states.
+
+ A paper-units move is a 25.4x rescale of every paper-space unit at the
+ plot scale (a 16-unit frame on an inch sheet spans 406.4 mm of paper;
+ after a move to millimetres, 16 mm) with ``ok: True`` and the scale
+ reported as applied — so it is named here, on both engines, with the
+ way back. Both engines build the same list from the same ``changed``.
+ """
+ warnings: list[str] = []
+ move = changed.get("paper_units")
+ if move and move[0] is not None and move[0] != move[1]:
+ old, new = move
+ if (old, new) == ("inches", "mm"):
+ effect = "plots 25.4x smaller"
+ elif (old, new) == ("mm", "inches"):
+ effect = "plots 25.4x larger"
+ else:
+ effect = "plots at a different size"
+ warnings.append(
+ f"paper units moved from {old} to {new}: one paper-space unit now plots as one "
+ f"{new} at the plot scale, so paper-space geometry authored in {old} {effect} "
+ f"at the same scale; pass paper_units=None to keep a layout's units or "
+ f"paper_units={old!r} to put them back"
+ )
+ return warnings
+
+
+def center_applies(plot_type) -> bool:
+ """Whether "centre the plot" means anything under this ``AcPlotType``.
+
+ False for ``acLayout`` (5): the sheet is plotted from its origin, AutoCAD
+ greys the option out and ActiveX refuses ``CenterPlot = True``. Read-backs
+ report ``center: None`` there instead of a stored flag nothing honours.
+ """
+ try:
+ return int(plot_type) != PLOT_TYPE_LAYOUT
+ except (TypeError, ValueError):
+ return False
+
+
+def resolve_page_setup(
+ paper: str,
+ orientation: str = "landscape",
+ plot_style: str = "monochrome.ctb",
+ scale: str = "fit",
+ plot_area: str = "layout",
+ device: str = "DWG To PDF.pc3",
+ margins_mm=None,
+ center: bool = True,
+ paper_units: str | None = "mm",
+) -> dict:
+ """Validate a page setup request and resolve it to what both engines write.
+
+ Raises ``ValueError`` naming the offending field; nothing here touches a
+ drawing. ``margins_mm`` is ``[top, bottom, left, right]``. ``center`` is
+ validated as a bool and resolved to ``None`` for ``plot_area="layout"``,
+ where centring is not applicable (see the module docstring) — the default
+ call (layout, centre) is therefore a layout plot with no centring write.
+
+ ``paper_units`` is what one paper-space unit means at the plot scale:
+ ``"mm"`` (the default — this server draws in millimetres), ``"inches"``,
+ or ``None`` to keep the layout's current units, which is what AutoCAD's
+ own Page Setup does when a media is chosen. A write that moves the units
+ rescales every paper-space unit by 25.4; both engines report it in
+ ``changed`` and ``warnings``.
+ """
+ name = _paper_key(paper)
+ width, height = paper_size_mm(name, _orientation(orientation))
+
+ style = str(plot_style or "").strip()
+ if not style:
+ raise ValueError("plot_style: must name a .ctb/.stb file (e.g. monochrome.ctb)")
+
+ label = str(scale or "").strip()
+ if label not in SCALES:
+ raise ValueError(f"scale: must be one of {', '.join(SCALES)}, got {scale!r}")
+ ratio = (1.0, 1.0) if label == "fit" else scale_ratio(label)
+
+ if plot_area not in PLOT_AREAS:
+ raise ValueError(f"plot_area: must be one of {', '.join(PLOT_AREAS)}, got {plot_area!r}")
+
+ device_name = str(device or "").strip()
+ if not device_name:
+ raise ValueError("device: must name a plotter configuration (e.g. 'DWG To PDF.pc3')")
+
+ margins = None
+ if margins_mm is not None:
+ try:
+ values = [float(v) for v in margins_mm]
+ except (TypeError, ValueError):
+ raise ValueError(
+ "margins_mm: must be four numbers [top, bottom, left, right]"
+ ) from None
+ if len(values) != 4:
+ raise ValueError(
+ f"margins_mm: expected 4 values [top, bottom, left, right], got {len(values)}"
+ )
+ if any(v < 0 for v in values):
+ raise ValueError("margins_mm: margins cannot be negative")
+ top, bottom, left, right = values
+ if top + bottom >= height or left + right >= width:
+ raise ValueError(
+ f"margins_mm: {values} leave no printable area on a {width:g} x {height:g} mm sheet"
+ )
+ margins = values
+
+ if not isinstance(center, bool):
+ raise ValueError(f"center: must be true or false, got {center!r}")
+ plot_type = PLOT_AREAS[plot_area]
+
+ units = None
+ if paper_units is not None:
+ units = _PAPER_UNITS_ALIASES.get(str(paper_units).strip().lower())
+ if units is None:
+ raise ValueError(
+ f"paper_units: must be 'mm', 'inches' or None (keep the layout's units), "
+ f"got {paper_units!r}"
+ )
+
+ return {
+ "paper": name,
+ "orientation": orientation,
+ "size_mm": [width, height],
+ "canonical_media_name": canonical_media_name(name, orientation),
+ "plot_style": style,
+ "plot_style_known": plot_style_known(style),
+ "scale": label,
+ "scale_factor": SCALES[label],
+ "scale_ratio": [ratio[0], ratio[1]],
+ "dxf_standard_scale_type": DXF_STANDARD_SCALE_TYPE.get(label),
+ "activex_standard_scale": ACTIVEX_PLOT_SCALE.get(label),
+ "plot_area": plot_area,
+ "plot_type": plot_type,
+ "device": device_name,
+ "margins_mm": margins,
+ "center": center if center_applies(plot_type) else None,
+ "paper_units": units,
+ }
+
+
+#: Every key resolve_page_setup emits that a backend reads. A backend refuses
+#: a dict missing any of them before it writes.
+REQUIRED_SETUP_KEYS: tuple[str, ...] = (
+ "paper",
+ "orientation",
+ "size_mm",
+ "canonical_media_name",
+ "plot_style",
+ "plot_style_known",
+ "scale",
+ "scale_ratio",
+ "dxf_standard_scale_type",
+ "activex_standard_scale",
+ "plot_area",
+ "plot_type",
+ "device",
+ "margins_mm",
+ "center",
+ "paper_units",
+)
+
+
+def require_page_setup(setup) -> dict:
+ """The backend-side check: ``setup`` is a complete resolve_page_setup dict."""
+ if not isinstance(setup, dict):
+ raise ValueError("page_setup_apply: setup must be the dict resolve_page_setup returns")
+ missing = [key for key in REQUIRED_SETUP_KEYS if key not in setup]
+ if missing:
+ raise ValueError(
+ f"page_setup_apply: setup is missing {', '.join(missing)} — build it with "
+ "engineering.standards.papers.resolve_page_setup"
+ )
+ return setup
diff --git a/engineering/standards/pdfinfo.py b/engineering/standards/pdfinfo.py
new file mode 100644
index 0000000..629d90d
--- /dev/null
+++ b/engineering/standards/pdfinfo.py
@@ -0,0 +1,59 @@
+"""Read a PDF's page size back from the file — no dependency, no rendering.
+
+The evidence rule for page setup (spec §8.3): the sheet size a plot produced is
+what the PDF says it is, not what the layout was asked for. ``/MediaBox`` is the
+one PDF object that carries it. matplotlib and AutoCAD's DWG To PDF driver both
+write it in clear text; a PDF 1.5+ writer may put the page dictionary inside a
+FlateDecode object stream, so when the plain scan finds nothing every stream is
+inflated and searched. Anything else raises rather than guessing.
+"""
+
+from __future__ import annotations
+
+import re
+import zlib
+from pathlib import Path
+
+__all__ = ["PT_TO_MM", "read_mediabox"]
+
+#: PostScript points to millimetres.
+PT_TO_MM = 25.4 / 72.0
+
+_NUMBER = rb"([-+]?\d*\.?\d+)"
+_MEDIABOX_RE = re.compile(
+ rb"/MediaBox\s*\[\s*"
+ + _NUMBER
+ + rb"\s+"
+ + _NUMBER
+ + rb"\s+"
+ + _NUMBER
+ + rb"\s+"
+ + _NUMBER
+ + rb"\s*\]"
+)
+_STREAM_RE = re.compile(rb"stream\r?\n(.*?)\r?\nendstream", re.DOTALL)
+
+
+def read_mediabox(path: str) -> tuple[float, float]:
+ """Return the first page ``/MediaBox`` as ``(width_mm, height_mm)``.
+
+ Raises ``ValueError`` when the file is not a PDF or carries no MediaBox.
+ ``/Rotate`` is not applied: the box is reported as written.
+ """
+ data = Path(path).read_bytes()
+ if not data.startswith(b"%PDF"):
+ raise ValueError(f"read_mediabox: {path} is not a PDF (no %PDF header)")
+ match = _MEDIABOX_RE.search(data)
+ if match is None:
+ for stream in _STREAM_RE.finditer(data):
+ try:
+ inflated = zlib.decompress(stream.group(1))
+ except zlib.error:
+ continue
+ match = _MEDIABOX_RE.search(inflated)
+ if match is not None:
+ break
+ if match is None:
+ raise ValueError(f"read_mediabox: no /MediaBox found in {path}")
+ x0, y0, x1, y1 = (float(value) for value in match.groups())
+ return (round(abs(x1 - x0) * PT_TO_MM, 3), round(abs(y1 - y0) * PT_TO_MM, 3))
diff --git a/engineering/standards/plot.py b/engineering/standards/plot.py
new file mode 100644
index 0000000..3fe29cd
--- /dev/null
+++ b/engineering/standards/plot.py
@@ -0,0 +1,147 @@
+"""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, and is passed to the engine by name so the model row is model
+ space whichever tab is current — both engines honour the name (COM plots
+ the Model tab; ezdxf renders ``doc.modelspace()``, not the current tab;
+ ``tests/test_batch_plot.py`` proves it by content on the headless engine).
+ A sheet the engine cannot plot, or reports
+ plotted without a file, is a per-row ``ok: False`` with its ``error``; the
+ rows before it stay. 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"
+ # The layout is always named — "Model" included — so the engine plots
+ # the tab this row is labelled with, never the one that happens to be
+ # current. A plot that fails inside the engine (COM raises RuntimeError
+ # for an AutoCAD error) or that reports ok without leaving a file is a
+ # failed row, not a lost batch: the sheets already plotted are kept.
+ try:
+ result = await backend.drawing_export_pdf(str(path), layout=name)
+ except RuntimeError as exc:
+ sheets.append({"layout": name, "ok": False, "path": str(path), "error": str(exc)})
+ continue
+ if not result.get("ok", False):
+ sheets.append(
+ {"layout": name, "ok": False, "path": str(path), "error": result.get("error")}
+ )
+ continue
+ if not path.is_file():
+ sheets.append(
+ {
+ "layout": name,
+ "ok": False,
+ "path": str(path),
+ "error": "the engine reported the plot ok but no file was written",
+ }
+ )
+ 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/engineering/standards/templates.py b/engineering/standards/templates.py
new file mode 100644
index 0000000..056b8cb
--- /dev/null
+++ b/engineering/standards/templates.py
@@ -0,0 +1,190 @@
+"""The five bundled drawing templates: what each contains, where its files live.
+
+Built by ``scripts/build_templates.py`` through the server's own backend
+(layers, styles, settings, the ISO 5457 A3 frame where the sheet is A3, page
+setup) and saved as DXF; the ``.dwt`` twins are produced on a live AutoCAD by
+``scripts/smoke_settings_com.py --build-dwt`` and committed once.
+"""
+
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+
+__all__ = [
+ "TEMPLATES_DIR",
+ "TEMPLATE_CATALOG",
+ "TemplateSpec",
+ "resolve_template",
+ "template_rows",
+]
+
+#: ``/templates`` — shipped in the wheel (pyproject ``only-include``) and the Docker image.
+TEMPLATES_DIR = Path(__file__).resolve().parents[2] / "templates"
+
+_ISO_SETTINGS = {
+ "units": "mm",
+ "ltscale": 1.0,
+ "dimscale": 1.0,
+ "linear_precision": 2,
+ "angular_precision": 0,
+ "decimal_separator": ",",
+}
+_ANSI_SETTINGS = {**_ISO_SETTINGS, "decimal_separator": "."}
+
+
+@dataclass(frozen=True)
+class TemplateSpec:
+ name: str
+ standard: str # "ISO" | "ANSI"
+ sheet: str # human label, e.g. "A3 landscape"
+ paper: str # engineering.standards.papers.PAPER_SIZES key
+ orientation: str
+ layout: str # the paper-space tab's name
+ layer_set: str # engineering.layers.LAYER_SET_REGISTRY key
+ dimstyle_preset: str # group S preset id: "iso-25" | "ansi"
+ dimstyle: str # style name in the drawing
+ textstyle: str # "ISOCP" | "ROMANS"
+ scale: str # page setup scale label
+ title_block: bool
+ settings: dict = field(default_factory=dict) # drawing_settings facade keys
+ description: str = ""
+
+
+TEMPLATE_CATALOG: dict[str, TemplateSpec] = {
+ spec.name: spec
+ for spec in (
+ TemplateSpec(
+ name="iso_a3_mech",
+ standard="ISO",
+ sheet="A3 landscape",
+ paper="ISO_A3",
+ orientation="landscape",
+ layout="A3",
+ layer_set="mech",
+ dimstyle_preset="iso-25",
+ dimstyle="ISO-25",
+ textstyle="ISOCP",
+ scale="1:1",
+ title_block=True,
+ settings=dict(_ISO_SETTINGS),
+ description="ISO A3 mechanical part drawing",
+ ),
+ TemplateSpec(
+ name="iso_a1_arch",
+ standard="ISO",
+ sheet="A1 landscape",
+ paper="ISO_A1",
+ orientation="landscape",
+ layout="A1",
+ layer_set="iso13567",
+ dimstyle_preset="iso-25",
+ dimstyle="ISO-25",
+ textstyle="ISOCP",
+ scale="fit",
+ title_block=False,
+ settings={**_ISO_SETTINGS, "annotation_scale": "1:50"},
+ description="ISO A1 architectural sheet, ISO 13567 layers, 1:50 annotation",
+ ),
+ TemplateSpec(
+ name="iso_a3_pid",
+ standard="ISO",
+ sheet="A3 landscape",
+ paper="ISO_A3",
+ orientation="landscape",
+ layout="A3",
+ layer_set="pid",
+ dimstyle_preset="iso-25",
+ dimstyle="ISO-25",
+ textstyle="ISOCP",
+ scale="1:1",
+ title_block=True,
+ settings=dict(_ISO_SETTINGS),
+ description="ISO A3 P&ID sheet",
+ ),
+ TemplateSpec(
+ name="ansi_b_mech",
+ standard="ANSI",
+ sheet="ANSI B landscape",
+ paper="ANSI_B",
+ orientation="landscape",
+ layout="B",
+ layer_set="mech",
+ dimstyle_preset="ansi",
+ dimstyle="ANSI",
+ textstyle="ROMANS",
+ scale="1:1",
+ title_block=False,
+ settings=dict(_ANSI_SETTINGS),
+ description="ANSI B mechanical part drawing (metric)",
+ ),
+ TemplateSpec(
+ name="ansi_d_arch",
+ standard="ANSI",
+ sheet="ANSI D landscape",
+ paper="ANSI_D",
+ orientation="landscape",
+ layout="D",
+ layer_set="iso13567",
+ dimstyle_preset="ansi",
+ dimstyle="ANSI",
+ textstyle="ROMANS",
+ scale="fit",
+ title_block=False,
+ settings=dict(_ANSI_SETTINGS),
+ description="ANSI D architectural sheet (metric)",
+ ),
+ )
+}
+
+
+def _file(name: str, suffix: str) -> dict:
+ path = TEMPLATES_DIR / f"{name}{suffix}"
+ return {"path": str(path), "present": path.is_file()}
+
+
+def template_rows() -> list[dict]:
+ """Catalogue rows for ``drawing_template_list``: the spec plus its files."""
+ rows = []
+ for spec in TEMPLATE_CATALOG.values():
+ row = asdict(spec)
+ row["files"] = {"dxf": _file(spec.name, ".dxf"), "dwt": _file(spec.name, ".dwt")}
+ rows.append(row)
+ return rows
+
+
+def resolve_template(name_or_path: str, engine: str) -> tuple[str, str]:
+ """``(path, source)`` for a catalogue name or a template file path.
+
+ A catalogue name resolves to ``templates/.dxf`` (headless) or
+ ``templates/.dwt`` (live), falling back to the DXF with
+ ``source="bundled_dxf"`` when the ``.dwt`` twin is missing — the live
+ engine then converts that DXF into a real ``.dwt`` itself before
+ ``Documents.Add`` (``ComBackend.drawing_new``), because AutoCAD ignores a
+ DXF handed to ``Add`` and silently returns the default drawing. Anything
+ with a path separator or a ``.dwt``/``.dxf`` suffix is a path
+ (``source="path"``, not checked here). A bare word that is not in the
+ catalogue is refused with the catalogue names.
+ """
+ raw = str(name_or_path or "").strip()
+ if not raw:
+ raise ValueError("template: pass a bundled template name or a path to a .dwt/.dxf file")
+ key = raw.lower()
+ if key in TEMPLATE_CATALOG:
+ dxf = TEMPLATES_DIR / f"{key}.dxf"
+ dwt = TEMPLATES_DIR / f"{key}.dwt"
+ if engine == "com" and dwt.is_file():
+ return str(dwt), "bundled"
+ if dxf.is_file():
+ return str(dxf), "bundled" if engine != "com" else "bundled_dxf"
+ raise ValueError(
+ f"template {key!r} is in the catalogue but {dxf} is missing — run "
+ "`uv run --frozen python scripts/build_templates.py`"
+ )
+ looks_like_path = ("/" in raw or "\\" in raw) or Path(raw).suffix.lower() in (".dwt", ".dxf")
+ if looks_like_path:
+ return raw, "path"
+ raise ValueError(
+ f"unknown template {raw!r}. Bundled templates: {', '.join(TEMPLATE_CATALOG)}; "
+ "or pass a path to a .dwt/.dxf file"
+ )
diff --git a/pyproject.toml b/pyproject.toml
index 09cd8d9..5243780 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -138,6 +138,7 @@ only-include = [
"backends",
"engineering",
"discovery",
+ "templates",
]
[tool.ruff]
diff --git a/scripts/build_templates.py b/scripts/build_templates.py
new file mode 100644
index 0000000..447dae3
--- /dev/null
+++ b/scripts/build_templates.py
@@ -0,0 +1,289 @@
+"""Build the bundled templates through the server's own backend, reproducibly.
+
+ uv run --frozen python scripts/build_templates.py # write templates/*.dxf
+ uv run --frozen python scripts/build_templates.py --check # exit 1 if a rebuild differs
+ uv run --frozen python scripts/build_templates.py --out DIR # build elsewhere
+
+Each template is: drawing_new -> apply_layer_set -> styles -> drawing_settings
+-> layout renamed to the sheet -> ISO 5457 frame (A3 sheets) -> page_setup_apply
+-> drawing_save_as DXF. Nothing is hand-authored, so what the tools produce is
+what ships.
+
+Styles: once group S's ``dimstyle_create`` / ``textstyle_create`` are on the
+backend they are used (ISO-25 / ISOCP, ANSI / ROMANS, set current). Until then
+the same values (spec §4.1) are written twice — as header DIM variables, which
+is what a live seat dimensions with, *and* onto the ``Standard`` dimstyle
+entry, which is what the headless renderer reads (it never consults the
+header; ``_with_header_dimvars`` folds six of the variables, so the other
+eight — DIMTAD, DIMTIH, DIMTOH, DIMEXE, DIMEXO, DIMGAP, DIMLUNIT, DIMLWD/E —
+were a header-only no-op: measured, ``ansi_b_mech`` dimensioned with ISO text
+placement headlessly while AutoCAD honoured the header). Both engines now
+dimension the same template the same way; the final task re-runs this script
+after the S merge and re-commits the five files.
+
+``--check`` rebuilds into a temporary folder and compares line by line after
+masking the values ezdxf cannot keep constant — measured on 2026-09-16, exactly
+six lines differ between two builds: $TDCREATE, $TDUPDATE, $FINGERPRINTGUID,
+$VERSIONGUID and the two EZDXF_META DICTIONARYVAR stamps. One more source of
+drift is removed before the save rather than masked: the CLASSES section, which
+ezdxf otherwise orders by set iteration (see ``_pin_class_order``).
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import re
+import sys
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from backends.ezdxf_backend import EzdxfBackend # noqa: E402
+from engineering import TitleBlockMetadata, apply_iso_a3_titleblock # noqa: E402
+from engineering.layers import apply_layer_set # noqa: E402
+from engineering.standards.papers import resolve_page_setup # noqa: E402
+from engineering.standards.templates import ( # noqa: E402
+ TEMPLATE_CATALOG,
+ TEMPLATES_DIR,
+ TemplateSpec,
+)
+
+#: Header variables whose values change on every save.
+VOLATILE_HEADER_VARS = frozenset(
+ {
+ "$TDCREATE",
+ "$TDUPDATE",
+ "$TDUCREATE",
+ "$TDUUPDATE",
+ "$TDINDWG",
+ "$TDUSRTIMER",
+ "$FINGERPRINTGUID",
+ "$VERSIONGUID",
+ }
+)
+#: The EZDXF_META stamps: ``1.4.4 @ 2026-09-16T12:33:38.192529+00:00``.
+_EZDXF_STAMP = re.compile(r"^\d+\.\d+\.\d+\S* @ \d{4}-\d{2}-\d{2}T")
+
+#: Spec §4.1 as header variables — the fallback until dimstyle_create lands.
+#: DIMDSEP goes through the facade (decimal_separator); DIMTXSTY is omitted
+#: because the text style it names is created by textstyle_create.
+_FALLBACK_DIMVARS: dict[str, dict[str, float | int]] = {
+ "iso-25": {
+ "DIMTXT": 2.5,
+ "DIMASZ": 2.5,
+ "DIMEXE": 1.25,
+ "DIMEXO": 0.625,
+ "DIMGAP": 0.625,
+ "DIMTAD": 1,
+ "DIMTIH": 0,
+ "DIMTOH": 0,
+ "DIMDEC": 2,
+ "DIMLUNIT": 2,
+ "DIMZIN": 8,
+ "DIMLWD": -2,
+ "DIMLWE": -2,
+ "DIMSCALE": 1.0,
+ },
+ "ansi": {
+ "DIMTXT": 3.0,
+ "DIMASZ": 3.0,
+ "DIMEXE": 1.5,
+ "DIMEXO": 1.5,
+ "DIMGAP": 1.0,
+ "DIMTAD": 0,
+ "DIMTIH": 1,
+ "DIMTOH": 1,
+ "DIMDEC": 2,
+ "DIMLUNIT": 2,
+ "DIMZIN": 8,
+ "DIMLWD": -2,
+ "DIMLWE": -2,
+ "DIMSCALE": 1.0,
+ },
+}
+
+
+async def _apply_styles(backend, spec: TemplateSpec) -> dict:
+ if hasattr(backend, "dimstyle_create") and hasattr(backend, "textstyle_create"):
+ from engineering.standards.dimstyles import resolve_dimstyle
+ from engineering.standards.textstyles import TEXT_PRESETS
+
+ font, width_factor, oblique = TEXT_PRESETS[spec.textstyle]
+ text = await backend.textstyle_create(
+ spec.textstyle, font, 0.0, width_factor, oblique, set_current=True
+ )
+ dim = await backend.dimstyle_create(
+ spec.dimstyle, resolve_dimstyle(spec.dimstyle_preset, None), set_current=True
+ )
+ return {"mode": "styles", "dimstyle": dim["name"], "textstyle": text["name"]}
+ applied = {}
+ standard = backend._doc.dimstyles.get("Standard") # what the headless renderer reads
+ for variable, value in _FALLBACK_DIMVARS[spec.dimstyle_preset].items():
+ applied[variable] = (await backend.system_set_variable(variable, value))["ok"]
+ standard.dxf.set(variable.lower(), value)
+ return {
+ "mode": "header_dimvars",
+ "applied": applied,
+ "dimstyle": standard.dxf.name,
+ "pending": "dimstyle_create/textstyle_create not on this backend yet (group S)",
+ }
+
+
+def _pin_class_order(doc) -> list[str]:
+ """Put the CLASSES section in a stable order before the save.
+
+ ezdxf fills CLASSES from ``entitydb.dxf_types_in_use()`` — a ``set[str]``,
+ so its order follows the process's string-hash seed and two builds in
+ different processes put ``LAYOUT`` / ``ACDBPLACEHOLDER`` in different slots
+ (measured 2026-09-21: the same-process double build agreed, ``--check``
+ from a fresh process did not). The backend's undo baseline has already
+ written the document once by now, so the section is already populated in
+ that random order; it is re-keyed here as ezdxf's required list first, then
+ every other class by name.
+ """
+ from ezdxf.sections.classes import REQ_R2004, REQUIRED_CLASSES
+
+ section = doc.classes
+ section.add_required_classes(doc.dxfversion)
+ for name in sorted(doc.entitydb.dxf_types_in_use()):
+ section.add_class(name)
+ rank = {name: i for i, name in enumerate(REQUIRED_CLASSES.get(doc.dxfversion, REQ_R2004))}
+ ordered = sorted(
+ section.classes.items(),
+ key=lambda item: (rank.get(item[1].dxf.name, len(rank)), item[1].dxf.name, item[0]),
+ )
+ section.classes = dict(ordered)
+ return [cls.dxf.name for cls in section.classes.values()]
+
+
+async def build_template(spec: TemplateSpec, out_dir: Path) -> dict:
+ """Build one template into ``out_dir/.dxf`` and report what went in."""
+ out_dir = Path(out_dir)
+ out_dir.mkdir(parents=True, exist_ok=True)
+ backend = EzdxfBackend()
+ await backend.connect()
+ try:
+ await backend.drawing_new()
+ layers = await apply_layer_set(backend, spec.layer_set)
+ styles = await _apply_styles(backend, spec)
+ settings = await backend.drawing_settings(dict(spec.settings))
+ pending = sorted(settings.get("errors") or {}) # keys the facade does not know yet
+ renamed = await backend.layout_rename("Layout1", spec.layout)
+ if not renamed.get("ok"):
+ raise RuntimeError(f"{spec.name}: layout rename failed: {renamed}")
+ title_block = None
+ if spec.title_block:
+ title_block = await apply_iso_a3_titleblock(
+ backend,
+ metadata=TitleBlockMetadata(
+ title=spec.description.upper(),
+ drawing_no="TEMPLATE",
+ scale=spec.scale if spec.scale != "fit" else "1:1",
+ ),
+ layout=spec.layout,
+ )
+ setup = resolve_page_setup(
+ spec.paper,
+ spec.orientation,
+ "monochrome.ctb",
+ spec.scale,
+ "layout",
+ "DWG To PDF.pc3",
+ [0.0, 0.0, 0.0, 0.0], # the frame is drawn at the paper corner; no unprintable band
+ True,
+ )
+ page_setup = await backend.page_setup_apply(spec.layout, setup)
+ await backend.layout_set_current("Model")
+ classes = _pin_class_order(backend._doc)
+ path = out_dir / f"{spec.name}.dxf"
+ await backend.drawing_save_as(str(path), "dxf")
+ return {
+ "name": spec.name,
+ "path": str(path),
+ "layers": layers,
+ "styles": styles,
+ "settings_applied": settings.get("applied", {}),
+ "settings_pending": pending,
+ "title_block": bool(title_block),
+ "page_setup": {"ok": page_setup["ok"], "paper": page_setup["applied"]["paper"]},
+ "classes": classes,
+ }
+ finally:
+ await backend.disconnect()
+
+
+async def build_all(out_dir: Path = TEMPLATES_DIR) -> list[dict]:
+ return [await build_template(spec, out_dir) for spec in TEMPLATE_CATALOG.values()]
+
+
+def normalised_lines(path: Path) -> list[str]:
+ """The DXF's lines with every value that legitimately changes per save masked."""
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
+ out: list[str] = []
+ mask_at = -1
+ for index, line in enumerate(lines):
+ if index == mask_at:
+ out.append("")
+ continue
+ stripped = line.strip()
+ if stripped in VOLATILE_HEADER_VARS:
+ mask_at = index + 2 # " 9" / "$TDCREATE" / " 40" /
+ if _EZDXF_STAMP.match(stripped):
+ out.append("")
+ continue
+ out.append(line)
+ return out
+
+
+def drifted_names(rebuilt: Path) -> list[str]:
+ """Names whose committed DXF differs from the build in ``rebuilt`` (masked compare)."""
+ drifted = []
+ for name in TEMPLATE_CATALOG:
+ committed = TEMPLATES_DIR / f"{name}.dxf"
+ if not committed.is_file():
+ drifted.append(f"{name} (missing)")
+ continue
+ fresh = normalised_lines(Path(rebuilt) / f"{name}.dxf")
+ current = normalised_lines(committed)
+ if fresh != current:
+ first = next(
+ (i for i, (a, b) in enumerate(zip(fresh, current, strict=False)) if a != b),
+ min(len(fresh), len(current)),
+ )
+ drifted.append(f"{name} (first difference at line {first + 1})")
+ return drifted
+
+
+def check_templates() -> list[str]:
+ """CLI ``--check``: rebuild into a temporary folder and report drift."""
+ with tempfile.TemporaryDirectory() as folder:
+ asyncio.run(build_all(Path(folder)))
+ return drifted_names(Path(folder))
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
+ )
+ parser.add_argument("--check", action="store_true", help="rebuild to a temp dir and diff")
+ parser.add_argument("--out", type=Path, default=TEMPLATES_DIR, help="output folder")
+ args = parser.parse_args(argv)
+ if args.check:
+ drifted = check_templates()
+ if drifted:
+ print("templates drifted from scripts/build_templates.py: " + ", ".join(drifted))
+ return 1
+ print(f"{len(TEMPLATE_CATALOG)} templates reproduce byte-for-byte (volatile stamps masked)")
+ return 0
+ for row in asyncio.run(build_all(args.out)):
+ pending = f" pending={row['settings_pending']}" if row["settings_pending"] else ""
+ print(f"{row['name']}: {row['path']} styles={row['styles']['mode']}{pending}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/server.py b/server.py
index e8698c0..d803cf1 100644
--- a/server.py
+++ b/server.py
@@ -1090,7 +1090,7 @@ async def drawing_info(ctx: Context) -> dict:
@cad_tool(
- summary="Start a blank drawing, pre-seeded with the standard engineering layers.",
+ summary="Start a drawing: blank with the engineering layers, or from a bundled/own template.",
cost="mutate",
)
@mcp.tool(
@@ -1098,7 +1098,11 @@ async def drawing_info(ctx: Context) -> dict:
tags={"drawing"},
)
async def drawing_new(
- template: Annotated[str | None, "Optional path to .dwt template file"] = None,
+ template: Annotated[
+ str | None,
+ "Bundled template name (drawing_template_list: iso_a3_mech, iso_a1_arch, iso_a3_pid, "
+ "ansi_b_mech, ansi_d_arch) or a path to a .dwt/.dxf template file.",
+ ] = None,
bootstrap: Annotated[
bool,
Field(
@@ -1109,19 +1113,54 @@ async def drawing_new(
] = True,
ctx: Context = None,
) -> dict:
- """Create a new empty drawing, optionally from a template (.dwt).
+ """Create a new drawing, optionally from a template.
- When ``bootstrap=True`` (default), the drawing is also seeded with the
- standard engineering linetypes (CENTER/HIDDEN/PHANTOM) and layers
- (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK).
+ A bundled name resolves to `templates/.dxf` headlessly and
+ `templates/.dwt` on the live engine (falling back to the DXF with
+ `source: "bundled_dxf"` when no `.dwt` twin is committed); a path is used
+ as given. The result carries `template: {name|path, path, source}`.
+
+ On the live engine only a genuine `.dwt` reaches `Documents.Add` —
+ measured on AutoCAD 2026, `Add()` silently returns the default
+ drawing — so a `.dxf` template (bundled or a path) is first converted
+ through AutoCAD into a cached `.dwt` and `template.dwt` names it
+ (`template.dwt_cached` says whether the conversion was reused). The
+ drawing then really carries the template's layers, tabs and page setup.
+
+ Refused before anything is replaced: a bare name that is not in the
+ catalogue (the message lists the five), a template path that does not
+ exist, and a path outside the allowed directories. With `bootstrap=True`
+ (default) the standard engineering linetypes and layers are ensured
+ afterwards — idempotent on a template that already has them.
"""
- if template is not None:
- validated_template = validate_path(template, allow_write=False)
- template = str(validated_template)
- await ctx.info(f"Creating new drawing (template={template}, bootstrap={bootstrap})")
+ from pathlib import Path as _P
+
+ from engineering.standards.templates import resolve_template
+
backend = _backend(ctx)
+ template_info: dict | None = None
+ if template is not None:
+ try:
+ resolved, source = resolve_template(template, backend.name)
+ except ValueError as exc:
+ raise ToolError(f"drawing_new: {exc}") from exc
+ if source == "path":
+ resolved = str(validate_path(resolved, allow_write=False))
+ if not _P(resolved).is_file():
+ raise ToolError(f"drawing_new: template file not found: {resolved}")
+ template_info = {"path": resolved, "source": source}
+ else:
+ template_info = {"name": template.strip().lower(), "path": resolved, "source": source}
+ template = resolved
+ await ctx.info(f"Creating new drawing (template={template}, bootstrap={bootstrap})")
raw = await backend.drawing_new(template)
result = dict(raw) if isinstance(raw, dict) else {"result": raw}
+ converted = result.pop("template_dwt", None)
+ if template_info is not None:
+ if converted: # the live engine built a .dwt from the .dxf (see ComBackend.drawing_new)
+ template_info["dwt"] = converted["path"]
+ template_info["dwt_cached"] = bool(converted.get("cached"))
+ result["template"] = template_info
if bootstrap:
try:
from engineering import (
@@ -7230,6 +7269,234 @@ async def drawing_apply_standard(
return await apply_standard(_backend(ctx), standard, layers, units)
+# ---------------------------------------------------------------------------
+# ── SECTION 19: Page Setup & Templates (6 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.PrinterStyleSheetPath` (`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
+
+
+@cad_tool(
+ summary="List the five bundled drawing templates: standard, sheet, layers, styles, page setup.",
+ cost="read",
+)
+@mcp.tool(
+ annotations={"title": "List Drawing Templates", "readOnlyHint": True},
+ tags={"template", "drawing", "query"},
+)
+async def drawing_template_list(ctx: Context = None) -> dict:
+ """The templates `drawing_new(template=)` can start from.
+
+ Each row: `standard` (ISO/ANSI), `sheet`, `paper`, `layout` (the sheet tab's
+ name), `layer_set` (mech / pid / iso13567), `dimstyle` / `textstyle`
+ (ISO-25 / ISOCP or ANSI / ROMANS), `scale`, `title_block` (the ISO 5457 A3
+ frame, A3 sheets only), the `settings` applied, and `files.dxf` /
+ `files.dwt` with `present` flags — the `.dwt` twins exist only once built
+ on a live AutoCAD (`scripts/smoke_settings_com.py --build-dwt`). Never
+ refuses; it reads a catalogue.
+ """
+ from engineering.standards.templates import TEMPLATES_DIR, template_rows
+
+ rows = template_rows()
+ return {
+ "ok": True,
+ "templates": rows,
+ "count": len(rows),
+ "templates_dir": str(TEMPLATES_DIR),
+ "engine": _backend(ctx).name,
+ }
+
+
+@cad_tool(
+ summary="Save the current drawing as a template: .dwt on live AutoCAD, .dxf headlessly.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Save As Template", "destructiveHint": False},
+ tags={"template", "drawing"},
+)
+async def drawing_template_save(
+ path: Annotated[str, "Destination .dwt (live AutoCAD) or .dxf (either engine)."],
+ name: Annotated[str | None, "Template name for the report (default: the file stem)."] = None,
+ description: Annotated[
+ str | None, "Template description → DWT summary Comments on the live engine."
+ ] = None,
+ ctx: Context = None,
+) -> dict:
+ """AutoCAD's SAVEAS → Drawing Template, on both engines.
+
+ Live AutoCAD writes a real `.dwt` (`SaveAs(path, ac2018_Template)`) and
+ puts `description` into the template's summary Comments; the active
+ document is rebound to the new file, as SAVEAS does. Headlessly a `.dxf`
+ template is written — `drawing_new(template=)` opens it — and a
+ `.dwt` request is refused with capability `dwt_write` (the message names
+ the DXF route and the COM route); a description is reported
+ `description_written: false` because DXF has nowhere to keep it. Any other
+ suffix, and any path outside the allowed directories, is refused before
+ a byte is written.
+
+ Pack: core · lean: no (`drawing_save_as` is not lean either).
+ """
+ validated = validate_path(path, allow_write=True)
+ await ctx.info(f"Saving template: {validated}")
+ try:
+ return await _backend(ctx).drawing_template_save(str(validated), name, description)
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
# ---------------------------------------------------------------------------
# ── SECTION 20: Environment (3 tools) ───────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/templates/README.md b/templates/README.md
new file mode 100644
index 0000000..9528d5a
--- /dev/null
+++ b/templates/README.md
@@ -0,0 +1,45 @@
+# Bundled drawing templates
+
+Five templates, built by the server's own tools — never hand-edited — and
+listed by `drawing_template_list`. Start from one with
+`drawing_new(template="iso_a3_mech")`.
+
+| name | standard | sheet | layer set | styles | page setup |
+|---|---|---|---|---|---|
+| `iso_a3_mech` | ISO | A3 landscape, ISO 5457 frame + ISO 7200 title block | mech | ISO-25 / ISOCP | ISO_A3, monochrome.ctb, 1:1 |
+| `iso_a1_arch` | ISO | A1 landscape | iso13567 | ISO-25 / ISOCP, annotation scale 1:50 | ISO_A1, monochrome.ctb, fit |
+| `iso_a3_pid` | ISO | A3 landscape, ISO 5457 frame + title block | pid | ISO-25 / ISOCP | ISO_A3, monochrome.ctb, 1:1 |
+| `ansi_b_mech` | ANSI (metric) | ANSI B landscape | mech | ANSI / ROMANS | ANSI_B, monochrome.ctb, 1:1 |
+| `ansi_d_arch` | ANSI (metric) | ANSI D landscape | iso13567 | ANSI / ROMANS | ANSI_D, monochrome.ctb, fit |
+
+## The two files per template
+
+- `.dxf` — the headless twin. Produced by
+ `uv run --frozen python scripts/build_templates.py`, which runs
+ `drawing_new → apply_layer_set → styles → drawing_settings → layout rename →
+ titleblock_apply_iso_a3 (A3 sheets) → page_setup_apply → drawing_save_as`.
+ `scripts/build_templates.py --check` rebuilds into a temporary folder and
+ fails if any committed DXF differs (the save-time stamps `$TDCREATE`,
+ `$TDUPDATE`, `$FINGERPRINTGUID`, `$VERSIONGUID` and the two `EZDXF_META`
+ entries are masked; everything else must be byte-identical).
+ `tests/test_templates.py` runs the same check.
+- `.dwt` — the live twin, used by `drawing_new(template=…)` on the COM
+ backend. AutoCAD's DWT is a DWG container, which no headless library
+ writes, so these are produced **on the live machine** by
+ `uv run --frozen python scripts/smoke_settings_com.py --build-dwt` (Task 24's
+ smoke): it opens each DXF over COM and calls `drawing_template_save(…dwt)`
+ (`SaveAs(path, ac2018_Template)`), then the files are committed once. While
+ a `.dwt` is missing, `resolve_template` falls back to the DXF and reports
+ `source: "bundled_dxf"`; `drawing_template_list` shows `files.dwt.present`.
+ The live engine does not open that DXF blank: `Documents.Add` accepts only
+ a genuine DWT (measured on AutoCAD 2026), so the COM `drawing_new` converts
+ the DXF through `SaveAs(…, ac2018_Template)` once, caches the result under
+ the temp folder keyed on path, size and mtime, and reports
+ `template_dwt: {path, cached}`.
+
+Line endings: ezdxf writes CRLF and `.gitattributes` pins `templates/*.dxf`
+to CRLF so a checkout on any platform equals a fresh build.
+
+Rebuild after changing anything the build depends on (layer sets, the title
+block, the dimension presets, the settings facade) and commit the result
+together with the change — the reproducibility test is the gate.
diff --git a/templates/ansi_b_mech.dxf b/templates/ansi_b_mech.dxf
new file mode 100644
index 0000000..fa99f5d
--- /dev/null
+++ b/templates/ansi_b_mech.dxf
@@ -0,0 +1,3444 @@
+ 0
+SECTION
+ 2
+HEADER
+ 9
+$ACADVER
+ 1
+AC1024
+ 9
+$ACADMAINTVER
+ 70
+6
+ 9
+$DWGCODEPAGE
+ 3
+ANSI_1252
+ 9
+$LASTSAVEDBY
+ 1
+ezdxf
+ 9
+$INSBASE
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$EXTMIN
+ 10
+1e+20
+ 20
+1e+20
+ 30
+1e+20
+ 9
+$EXTMAX
+ 10
+-1e+20
+ 20
+-1e+20
+ 30
+-1e+20
+ 9
+$LIMMIN
+ 10
+0.0
+ 20
+0.0
+ 9
+$LIMMAX
+ 10
+420.0
+ 20
+297.0
+ 9
+$ORTHOMODE
+ 70
+0
+ 9
+$REGENMODE
+ 70
+1
+ 9
+$FILLMODE
+ 70
+1
+ 9
+$QTEXTMODE
+ 70
+0
+ 9
+$MIRRTEXT
+ 70
+1
+ 9
+$LTSCALE
+ 40
+1.0
+ 9
+$ATTMODE
+ 70
+1
+ 9
+$TEXTSIZE
+ 40
+2.5
+ 9
+$TRACEWID
+ 40
+1.0
+ 9
+$TEXTSTYLE
+ 7
+Standard
+ 9
+$CLAYER
+ 8
+0
+ 9
+$CELTYPE
+ 6
+ByLayer
+ 9
+$CECOLOR
+ 62
+256
+ 9
+$CELTSCALE
+ 40
+1.0
+ 9
+$DISPSILH
+ 70
+0
+ 9
+$DIMSCALE
+ 40
+1.0
+ 9
+$DIMASZ
+ 40
+3.0
+ 9
+$DIMEXO
+ 40
+1.5
+ 9
+$DIMDLI
+ 40
+3.75
+ 9
+$DIMRND
+ 40
+0.0
+ 9
+$DIMDLE
+ 40
+0.0
+ 9
+$DIMEXE
+ 40
+1.5
+ 9
+$DIMTP
+ 40
+0.0
+ 9
+$DIMTM
+ 40
+0.0
+ 9
+$DIMTXT
+ 40
+3.0
+ 9
+$DIMCEN
+ 40
+2.5
+ 9
+$DIMTSZ
+ 40
+0.0
+ 9
+$DIMTOL
+ 70
+0
+ 9
+$DIMLIM
+ 70
+0
+ 9
+$DIMTIH
+ 70
+1
+ 9
+$DIMTOH
+ 70
+1
+ 9
+$DIMSE1
+ 70
+0
+ 9
+$DIMSE2
+ 70
+0
+ 9
+$DIMTAD
+ 70
+0
+ 9
+$DIMZIN
+ 70
+8
+ 9
+$DIMBLK
+ 1
+
+ 9
+$DIMASO
+ 70
+1
+ 9
+$DIMSHO
+ 70
+1
+ 9
+$DIMPOST
+ 1
+
+ 9
+$DIMAPOST
+ 1
+
+ 9
+$DIMALT
+ 70
+0
+ 9
+$DIMALTD
+ 70
+3
+ 9
+$DIMALTF
+ 40
+0.03937007874
+ 9
+$DIMLFAC
+ 40
+1.0
+ 9
+$DIMTOFL
+ 70
+1
+ 9
+$DIMTVP
+ 40
+0.0
+ 9
+$DIMTIX
+ 70
+0
+ 9
+$DIMSOXD
+ 70
+0
+ 9
+$DIMSAH
+ 70
+0
+ 9
+$DIMBLK1
+ 1
+
+ 9
+$DIMBLK2
+ 1
+
+ 9
+$DIMSTYLE
+ 2
+ISO-25
+ 9
+$DIMCLRD
+ 70
+0
+ 9
+$DIMCLRE
+ 70
+0
+ 9
+$DIMCLRT
+ 70
+0
+ 9
+$DIMTFAC
+ 40
+1.0
+ 9
+$DIMGAP
+ 40
+1.0
+ 9
+$DIMJUST
+ 70
+0
+ 9
+$DIMSD1
+ 70
+0
+ 9
+$DIMSD2
+ 70
+0
+ 9
+$DIMTOLJ
+ 70
+0
+ 9
+$DIMTZIN
+ 70
+8
+ 9
+$DIMALTZ
+ 70
+0
+ 9
+$DIMALTTZ
+ 70
+0
+ 9
+$DIMUPT
+ 70
+0
+ 9
+$DIMDEC
+ 70
+2
+ 9
+$DIMTDEC
+ 70
+2
+ 9
+$DIMALTU
+ 70
+2
+ 9
+$DIMALTTD
+ 70
+3
+ 9
+$DIMTXSTY
+ 7
+Standard
+ 9
+$DIMAUNIT
+ 70
+0
+ 9
+$DIMADEC
+ 70
+0
+ 9
+$DIMALTRND
+ 40
+0.0
+ 9
+$DIMAZIN
+ 70
+0
+ 9
+$DIMDSEP
+ 70
+46
+ 9
+$DIMATFIT
+ 70
+3
+ 9
+$DIMFRAC
+ 70
+0
+ 9
+$DIMLDRBLK
+ 1
+
+ 9
+$DIMLUNIT
+ 70
+2
+ 9
+$DIMLWD
+ 70
+-2
+ 9
+$DIMLWE
+ 70
+-2
+ 9
+$DIMTMOVE
+ 70
+0
+ 9
+$DIMFXL
+ 40
+1.0
+ 9
+$DIMFXLON
+ 70
+0
+ 9
+$DIMJOGANG
+ 40
+0.785398163397
+ 9
+$DIMTFILL
+ 70
+0
+ 9
+$DIMTFILLCLR
+ 70
+0
+ 9
+$DIMARCSYM
+ 70
+0
+ 9
+$DIMLTYPE
+ 6
+
+ 9
+$DIMLTEX1
+ 6
+
+ 9
+$DIMLTEX2
+ 6
+
+ 9
+$DIMTXTDIRECTION
+ 70
+0
+ 9
+$LUNITS
+ 70
+2
+ 9
+$LUPREC
+ 70
+2
+ 9
+$SKETCHINC
+ 40
+1.0
+ 9
+$FILLETRAD
+ 40
+10.0
+ 9
+$AUNITS
+ 70
+0
+ 9
+$AUPREC
+ 70
+0
+ 9
+$MENU
+ 1
+.
+ 9
+$ELEVATION
+ 40
+0.0
+ 9
+$PELEVATION
+ 40
+0.0
+ 9
+$THICKNESS
+ 40
+0.0
+ 9
+$LIMCHECK
+ 70
+0
+ 9
+$CHAMFERA
+ 40
+0.0
+ 9
+$CHAMFERB
+ 40
+0.0
+ 9
+$CHAMFERC
+ 40
+0.0
+ 9
+$CHAMFERD
+ 40
+0.0
+ 9
+$SKPOLY
+ 70
+0
+ 9
+$TDCREATE
+ 40
+2461305.9345486113
+ 9
+$TDUCREATE
+ 40
+2458532.153996898
+ 9
+$TDUPDATE
+ 40
+2461305.9345486113
+ 9
+$TDUUPDATE
+ 40
+2458532.1544311
+ 9
+$TDINDWG
+ 40
+0.0
+ 9
+$TDUSRTIMER
+ 40
+0.0
+ 9
+$USRTIMER
+ 70
+1
+ 9
+$ANGBASE
+ 50
+0.0
+ 9
+$ANGDIR
+ 70
+0
+ 9
+$PDMODE
+ 70
+0
+ 9
+$PDSIZE
+ 40
+0.0
+ 9
+$PLINEWID
+ 40
+0.0
+ 9
+$SPLFRAME
+ 70
+0
+ 9
+$SPLINETYPE
+ 70
+6
+ 9
+$SPLINESEGS
+ 70
+8
+ 9
+$HANDSEED
+ 5
+40
+ 9
+$SURFTAB1
+ 70
+6
+ 9
+$SURFTAB2
+ 70
+6
+ 9
+$SURFTYPE
+ 70
+6
+ 9
+$SURFU
+ 70
+6
+ 9
+$SURFV
+ 70
+6
+ 9
+$UCSBASE
+ 2
+
+ 9
+$UCSNAME
+ 2
+
+ 9
+$UCSORG
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSXDIR
+ 10
+1.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSYDIR
+ 10
+0.0
+ 20
+1.0
+ 30
+0.0
+ 9
+$UCSORTHOREF
+ 2
+
+ 9
+$UCSORTHOVIEW
+ 70
+0
+ 9
+$UCSORGTOP
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGBOTTOM
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGLEFT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGRIGHT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGFRONT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGBACK
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSBASE
+ 2
+
+ 9
+$PUCSNAME
+ 2
+
+ 9
+$PUCSORG
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSXDIR
+ 10
+1.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSYDIR
+ 10
+0.0
+ 20
+1.0
+ 30
+0.0
+ 9
+$PUCSORTHOREF
+ 2
+
+ 9
+$PUCSORTHOVIEW
+ 70
+0
+ 9
+$PUCSORGTOP
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGBOTTOM
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGLEFT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGRIGHT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGFRONT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGBACK
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$USERI1
+ 70
+0
+ 9
+$USERI2
+ 70
+0
+ 9
+$USERI3
+ 70
+0
+ 9
+$USERI4
+ 70
+0
+ 9
+$USERI5
+ 70
+0
+ 9
+$USERR1
+ 40
+0.0
+ 9
+$USERR2
+ 40
+0.0
+ 9
+$USERR3
+ 40
+0.0
+ 9
+$USERR4
+ 40
+0.0
+ 9
+$USERR5
+ 40
+0.0
+ 9
+$WORLDVIEW
+ 70
+1
+ 9
+$SHADEDGE
+ 70
+3
+ 9
+$SHADEDIF
+ 70
+70
+ 9
+$TILEMODE
+ 70
+1
+ 9
+$MAXACTVP
+ 70
+64
+ 9
+$PINSBASE
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PLIMCHECK
+ 70
+0
+ 9
+$PEXTMIN
+ 10
+1e+20
+ 20
+1e+20
+ 30
+1e+20
+ 9
+$PEXTMAX
+ 10
+-1e+20
+ 20
+-1e+20
+ 30
+-1e+20
+ 9
+$PLIMMIN
+ 10
+-0.0
+ 20
+-0.0
+ 9
+$PLIMMAX
+ 10
+432.0
+ 20
+279.0
+ 9
+$UNITMODE
+ 70
+0
+ 9
+$VISRETAIN
+ 70
+1
+ 9
+$PLINEGEN
+ 70
+0
+ 9
+$PSLTSCALE
+ 70
+1
+ 9
+$TREEDEPTH
+ 70
+3020
+ 9
+$CMLSTYLE
+ 2
+Standard
+ 9
+$CMLJUST
+ 70
+0
+ 9
+$CMLSCALE
+ 40
+20.0
+ 9
+$PROXYGRAPHICS
+ 70
+1
+ 9
+$MEASUREMENT
+ 70
+1
+ 9
+$CELWEIGHT
+370
+-1
+ 9
+$ENDCAPS
+280
+0
+ 9
+$JOINSTYLE
+280
+0
+ 9
+$LWDISPLAY
+290
+0
+ 9
+$INSUNITS
+ 70
+4
+ 9
+$HYPERLINKBASE
+ 1
+
+ 9
+$STYLESHEET
+ 1
+
+ 9
+$XEDIT
+290
+1
+ 9
+$CEPSNTYPE
+380
+0
+ 9
+$PSTYLEMODE
+290
+1
+ 9
+$FINGERPRINTGUID
+ 2
+{C3A95304-315D-4482-8B26-4A154C8AC81B}
+ 9
+$VERSIONGUID
+ 2
+{020A53F4-F6E6-4A10-988E-58908DDBD5B7}
+ 9
+$EXTNAMES
+290
+1
+ 9
+$PSVPSCALE
+ 40
+0.0
+ 9
+$OLESTARTUP
+290
+0
+ 9
+$SORTENTS
+280
+127
+ 9
+$INDEXCTL
+280
+0
+ 9
+$HIDETEXT
+280
+1
+ 9
+$XCLIPFRAME
+280
+1
+ 9
+$HALOGAP
+280
+0
+ 9
+$OBSCOLOR
+ 70
+257
+ 9
+$OBSLTYPE
+280
+0
+ 9
+$INTERSECTIONDISPLAY
+280
+0
+ 9
+$INTERSECTIONCOLOR
+ 70
+257
+ 9
+$DIMASSOC
+280
+2
+ 9
+$PROJECTNAME
+ 1
+
+ 9
+$CAMERADISPLAY
+290
+0
+ 9
+$LENSLENGTH
+ 40
+50.0
+ 9
+$CAMERAHEIGHT
+ 40
+0.0
+ 9
+$STEPSPERSEC
+ 40
+24.0
+ 9
+$STEPSIZE
+ 40
+100.0
+ 9
+$3DDWFPREC
+ 40
+2.0
+ 9
+$PSOLWIDTH
+ 40
+0.005
+ 9
+$PSOLHEIGHT
+ 40
+0.08
+ 9
+$LOFTANG1
+ 40
+1.570796326795
+ 9
+$LOFTANG2
+ 40
+1.570796326795
+ 9
+$LOFTMAG1
+ 40
+0.0
+ 9
+$LOFTMAG2
+ 40
+0.0
+ 9
+$LOFTPARAM
+ 70
+7
+ 9
+$LOFTNORMALS
+280
+1
+ 9
+$LATITUDE
+ 40
+37.795
+ 9
+$LONGITUDE
+ 40
+-122.394
+ 9
+$NORTHDIRECTION
+ 40
+0.0
+ 9
+$TIMEZONE
+ 70
+-8000
+ 9
+$LIGHTGLYPHDISPLAY
+280
+1
+ 9
+$TILEMODELIGHTSYNCH
+280
+1
+ 9
+$CMATERIAL
+347
+20
+ 9
+$SOLIDHIST
+280
+0
+ 9
+$SHOWHIST
+280
+1
+ 9
+$DWFFRAME
+280
+2
+ 9
+$DGNFRAME
+280
+2
+ 9
+$REALWORLDSCALE
+290
+1
+ 9
+$INTERFERECOLOR
+ 62
+256
+ 9
+$CSHADOW
+280
+0
+ 9
+$SHADOWPLANELOCATION
+ 40
+0.0
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+CLASSES
+ 0
+CLASS
+ 1
+ACDBDICTIONARYWDFLT
+ 2
+AcDbDictionaryWithDefault
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+SUN
+ 2
+AcDbSun
+ 3
+SCENEOE
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+VISUALSTYLE
+ 2
+AcDbVisualStyle
+ 3
+ObjectDBX Classes
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MATERIAL
+ 2
+AcDbMaterial
+ 3
+ObjectDBX Classes
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+SCALE
+ 2
+AcDbScale
+ 3
+ObjectDBX Classes
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+TABLESTYLE
+ 2
+AcDbTableStyle
+ 3
+ObjectDBX Classes
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MLEADERSTYLE
+ 2
+AcDbMLeaderStyle
+ 3
+ACDB_MLEADERSTYLE_CLASS
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+DICTIONARYVAR
+ 2
+AcDbDictionaryVar
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+CELLSTYLEMAP
+ 2
+AcDbCellStyleMap
+ 3
+ObjectDBX Classes
+ 90
+1152
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MENTALRAYRENDERSETTINGS
+ 2
+AcDbMentalRayRenderSettings
+ 3
+SCENEOE
+ 90
+1024
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBDETAILVIEWSTYLE
+ 2
+AcDbDetailViewStyle
+ 3
+ObjectDBX Classes
+ 90
+1025
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBSECTIONVIEWSTYLE
+ 2
+AcDbSectionViewStyle
+ 3
+ObjectDBX Classes
+ 90
+1025
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+RASTERVARIABLES
+ 2
+AcDbRasterVariables
+ 3
+ISM
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBPLACEHOLDER
+ 2
+AcDbPlaceHolder
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+LAYOUT
+ 2
+AcDbLayout
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+TABLES
+ 0
+TABLE
+ 2
+VPORT
+ 5
+8
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+ 0
+VPORT
+ 5
+23
+330
+8
+100
+AcDbSymbolTableRecord
+100
+AcDbViewportTableRecord
+ 2
+*Active
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 11
+1.0
+ 21
+1.0
+ 12
+0.0
+ 22
+0.0
+ 13
+0.0
+ 23
+0.0
+ 14
+0.5
+ 24
+0.5
+ 15
+0.5
+ 25
+0.5
+ 16
+0.0
+ 26
+0.0
+ 36
+1.0
+ 17
+0.0
+ 27
+0.0
+ 37
+0.0
+ 40
+1000.0
+ 41
+1.34
+ 42
+50.0
+ 43
+0.0
+ 44
+0.0
+ 50
+0.0
+ 51
+0.0
+ 71
+0
+ 72
+1000
+ 73
+1
+ 74
+3
+ 75
+0
+ 76
+0
+ 77
+0
+ 78
+0
+281
+0
+ 65
+0
+146
+0.0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+LTYPE
+ 5
+2
+330
+0
+100
+AcDbSymbolTable
+ 70
+8
+ 0
+LTYPE
+ 5
+24
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+ByBlock
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+25
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+ByLayer
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+26
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+Continuous
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+2F
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+CENTER
+ 70
+0
+ 3
+Center ____ _ ____ _ ____ _ ____ _ ____ _ ____
+ 72
+65
+ 73
+4
+ 40
+2.0
+ 49
+1.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 0
+LTYPE
+ 5
+30
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+HIDDEN
+ 70
+0
+ 3
+Hidden __ __ __ __ __ __ __ __ __
+ 72
+65
+ 73
+2
+ 40
+7.5
+ 49
+5.0
+ 74
+0
+ 49
+-2.5
+ 74
+0
+ 0
+LTYPE
+ 5
+31
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+PHANTOM
+ 70
+0
+ 3
+Phantom ______ __ __ ______ __ __ ______
+ 72
+65
+ 73
+6
+ 40
+2.5
+ 49
+1.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 0
+LTYPE
+ 5
+32
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+DASHED
+ 70
+0
+ 3
+Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _
+ 72
+65
+ 73
+2
+ 40
+0.6
+ 49
+0.5
+ 74
+0
+ 49
+-0.1
+ 74
+0
+ 0
+LTYPE
+ 5
+33
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+DASHDOT
+ 70
+0
+ 3
+Dash dot __ . __ . __ . __ . __ . __ . __ . __
+ 72
+65
+ 73
+4
+ 40
+1.4
+ 49
+1.0
+ 74
+0
+ 49
+-0.2
+ 74
+0
+ 49
+0.0
+ 74
+0
+ 49
+-0.2
+ 74
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+LAYER
+ 5
+1
+330
+0
+100
+AcDbSymbolTable
+ 70
+11
+ 0
+LAYER
+ 5
+27
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+0
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+-3
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+28
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+Defpoints
+ 70
+0
+ 62
+7
+ 6
+Continuous
+290
+0
+370
+-3
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+34
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+GEOMETRY
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+50
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+35
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+HIDDEN
+ 70
+0
+ 62
+3
+ 6
+HIDDEN
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+36
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+CENTER
+ 70
+0
+ 62
+1
+ 6
+CENTER
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+37
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+PHANTOM
+ 70
+0
+ 62
+6
+ 6
+PHANTOM
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+38
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+DIM
+ 70
+0
+ 62
+2
+ 6
+Continuous
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+39
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+TEXT
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3A
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+HATCH
+ 70
+0
+ 62
+8
+ 6
+Continuous
+370
+13
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3B
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+TITLEBLOCK
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+50
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3C
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+CONSTRUCTION
+ 70
+0
+ 62
+250
+ 6
+Continuous
+370
+5
+390
+13
+347
+21
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+STYLE
+ 5
+5
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+ 0
+STYLE
+ 5
+29
+330
+5
+100
+AcDbSymbolTableRecord
+100
+AcDbTextStyleTableRecord
+ 2
+Standard
+ 70
+0
+ 40
+0.0
+ 41
+1.0
+ 50
+0.0
+ 71
+0
+ 42
+2.5
+ 3
+txt
+ 4
+
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+VIEW
+ 5
+7
+330
+0
+100
+AcDbSymbolTable
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+UCS
+ 5
+6
+330
+0
+100
+AcDbSymbolTable
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+APPID
+ 5
+3
+330
+0
+100
+AcDbSymbolTable
+ 70
+3
+ 0
+APPID
+ 5
+2A
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+ACAD
+ 70
+0
+ 0
+APPID
+ 5
+3D
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+HATCHBACKGROUNDCOLOR
+ 70
+0
+ 0
+APPID
+ 5
+3E
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+EZDXF
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+DIMSTYLE
+ 5
+4
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+100
+AcDbDimStyleTable
+ 0
+DIMSTYLE
+105
+2B
+330
+4
+100
+AcDbSymbolTableRecord
+100
+AcDbDimStyleTableRecord
+ 2
+Standard
+ 70
+0
+ 40
+1.0
+ 41
+3.0
+ 42
+1.5
+ 43
+3.75
+ 44
+1.5
+ 45
+0.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+2.5
+140
+3.0
+141
+2.5
+142
+0.0
+143
+0.03937007874
+144
+1.0
+145
+0.0
+146
+1.0
+147
+1.0
+148
+0.0
+ 69
+0
+ 70
+0
+ 71
+0
+ 72
+0
+ 73
+1
+ 74
+1
+ 75
+0
+ 76
+0
+ 77
+0
+ 78
+8
+ 79
+3
+170
+0
+171
+3
+172
+1
+173
+0
+174
+0
+175
+0
+176
+0
+177
+0
+178
+0
+179
+2
+271
+2
+272
+2
+273
+2
+274
+3
+275
+0
+276
+0
+277
+2
+278
+46
+279
+0
+280
+0
+281
+0
+282
+0
+283
+0
+284
+8
+285
+0
+286
+0
+288
+0
+289
+3
+290
+0
+371
+-2
+372
+-2
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+BLOCK_RECORD
+ 5
+9
+330
+0
+100
+AcDbSymbolTable
+ 70
+2
+ 0
+BLOCK_RECORD
+ 5
+17
+330
+9
+100
+AcDbSymbolTableRecord
+100
+AcDbBlockTableRecord
+ 2
+*Model_Space
+340
+1A
+ 70
+0
+280
+1
+281
+0
+ 0
+BLOCK_RECORD
+ 5
+1B
+330
+9
+100
+AcDbSymbolTableRecord
+100
+AcDbBlockTableRecord
+ 2
+*Paper_Space
+340
+1E
+ 70
+0
+280
+1
+281
+0
+ 0
+ENDTAB
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+BLOCKS
+ 0
+BLOCK
+ 5
+18
+330
+17
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockBegin
+ 2
+*Model_Space
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 3
+*Model_Space
+ 1
+
+ 0
+ENDBLK
+ 5
+19
+330
+17
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockEnd
+ 0
+BLOCK
+ 5
+1C
+330
+1B
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockBegin
+ 2
+*Paper_Space
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 3
+*Paper_Space
+ 1
+
+ 0
+ENDBLK
+ 5
+1D
+330
+1B
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockEnd
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+ENTITIES
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+OBJECTS
+ 0
+DICTIONARY
+ 5
+A
+330
+0
+100
+AcDbDictionary
+281
+1
+ 3
+ACAD_COLOR
+350
+B
+ 3
+ACAD_GROUP
+350
+C
+ 3
+ACAD_LAYOUT
+350
+D
+ 3
+ACAD_MATERIAL
+350
+E
+ 3
+ACAD_MLEADERSTYLE
+350
+F
+ 3
+ACAD_MLINESTYLE
+350
+10
+ 3
+ACAD_PLOTSETTINGS
+350
+11
+ 3
+ACAD_PLOTSTYLENAME
+350
+12
+ 3
+ACAD_SCALELIST
+350
+14
+ 3
+ACAD_TABLESTYLE
+350
+15
+ 3
+ACAD_VISUALSTYLE
+350
+16
+ 3
+EZDXF_META
+350
+2D
+ 0
+DICTIONARY
+ 5
+B
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+C
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+D
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Model
+350
+1A
+ 3
+B
+350
+1E
+ 0
+DICTIONARY
+ 5
+E
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+ByBlock
+350
+1F
+ 3
+ByLayer
+350
+20
+ 3
+Global
+350
+21
+ 0
+DICTIONARY
+ 5
+F
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Standard
+350
+2C
+ 0
+DICTIONARY
+ 5
+10
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Standard
+350
+22
+ 0
+DICTIONARY
+ 5
+11
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+ACDBDICTIONARYWDFLT
+ 5
+12
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Normal
+350
+13
+100
+AcDbDictionaryWithDefault
+340
+13
+ 0
+ACDBPLACEHOLDER
+ 5
+13
+330
+12
+ 0
+DICTIONARY
+ 5
+14
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+15
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+16
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+LAYOUT
+ 5
+1A
+330
+D
+100
+AcDbPlotSettings
+ 1
+
+ 4
+A3
+ 6
+
+ 40
+7.5
+ 41
+20.0
+ 42
+7.5
+ 43
+20.0
+ 44
+420.0
+ 45
+297.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+0.0
+140
+0.0
+141
+0.0
+142
+1.0
+143
+1.0
+ 70
+1024
+ 72
+1
+ 73
+0
+ 74
+5
+ 7
+
+ 75
+16
+ 76
+0
+ 77
+2
+ 78
+300
+147
+1.0
+148
+0.0
+149
+0.0
+100
+AcDbLayout
+ 1
+Model
+ 70
+1
+ 71
+0
+ 10
+0.0
+ 20
+0.0
+ 11
+420.0
+ 21
+297.0
+ 12
+0.0
+ 22
+0.0
+ 32
+0.0
+ 14
+1e+20
+ 24
+1e+20
+ 34
+1e+20
+ 15
+-1e+20
+ 25
+-1e+20
+ 35
+-1e+20
+146
+0.0
+ 13
+0.0
+ 23
+0.0
+ 33
+0.0
+ 16
+1.0
+ 26
+0.0
+ 36
+0.0
+ 17
+0.0
+ 27
+1.0
+ 37
+0.0
+ 76
+1
+330
+17
+ 0
+LAYOUT
+ 5
+1E
+330
+D
+100
+AcDbPlotSettings
+ 1
+
+ 2
+DWG To PDF.pc3
+ 4
+ANSI_B_(17.00_x_11.00_Inches)
+ 6
+
+ 40
+0.0
+ 41
+0.0
+ 42
+0.0
+ 43
+0.0
+ 44
+432.0
+ 45
+279.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+0.0
+140
+0.0
+141
+0.0
+142
+1.0
+143
+1.0
+ 70
+688
+ 72
+1
+ 73
+0
+ 74
+5
+ 7
+monochrome.ctb
+ 75
+16
+ 76
+0
+ 77
+2
+ 78
+300
+147
+1.0
+148
+0.0
+149
+0.0
+100
+AcDbLayout
+ 1
+B
+ 70
+1
+ 71
+1
+ 10
+-0.0
+ 20
+-0.0
+ 11
+432.0
+ 21
+279.0
+ 12
+0.0
+ 22
+0.0
+ 32
+0.0
+ 14
+1e+20
+ 24
+1e+20
+ 34
+1e+20
+ 15
+-1e+20
+ 25
+-1e+20
+ 35
+-1e+20
+146
+0.0
+ 13
+0.0
+ 23
+0.0
+ 33
+0.0
+ 16
+1.0
+ 26
+0.0
+ 36
+0.0
+ 17
+0.0
+ 27
+1.0
+ 37
+0.0
+ 76
+1
+330
+1B
+ 0
+MATERIAL
+ 5
+1F
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+ByBlock
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MATERIAL
+ 5
+20
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+ByLayer
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MATERIAL
+ 5
+21
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+Global
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MLINESTYLE
+ 5
+22
+102
+{ACAD_REACTORS
+330
+10
+102
+}
+330
+10
+100
+AcDbMlineStyle
+ 2
+Standard
+ 70
+0
+ 3
+
+ 62
+256
+ 51
+90.0
+ 52
+90.0
+ 71
+2
+ 49
+0.5
+ 62
+256
+ 6
+BYLAYER
+ 49
+-0.5
+ 62
+256
+ 6
+BYLAYER
+ 0
+MLEADERSTYLE
+ 5
+2C
+102
+{ACAD_REACTORS
+330
+F
+102
+}
+330
+F
+100
+AcDbMLeaderStyle
+179
+2
+170
+2
+171
+1
+172
+0
+ 90
+2
+ 40
+0.0
+ 41
+0.0
+173
+1
+ 91
+-1056964608
+ 92
+-2
+290
+1
+ 42
+2.0
+291
+1
+ 43
+8.0
+ 3
+Standard
+ 44
+4.0
+300
+
+342
+29
+174
+1
+175
+1
+176
+0
+178
+1
+ 93
+-1056964608
+ 45
+4.0
+292
+0
+297
+0
+ 46
+4.0
+ 94
+-1056964608
+ 47
+1.0
+ 49
+1.0
+140
+1.0
+294
+1
+141
+0.0
+177
+0
+142
+1.0
+295
+0
+296
+0
+143
+3.75
+271
+0
+272
+9
+273
+9
+ 0
+DICTIONARY
+ 5
+2D
+330
+A
+100
+AcDbDictionary
+280
+1
+281
+1
+ 3
+CREATED_BY_EZDXF
+350
+2E
+ 3
+WRITTEN_BY_EZDXF
+350
+3F
+ 0
+DICTIONARYVAR
+ 5
+2E
+330
+2D
+100
+DictionaryVariables
+280
+0
+ 1
+1.4.4 @ 2026-09-21T19:25:45.807766+00:00
+ 0
+DICTIONARYVAR
+ 5
+3F
+330
+2D
+100
+DictionaryVariables
+280
+0
+ 1
+1.4.4 @ 2026-09-21T19:25:45.814791+00:00
+ 0
+ENDSEC
+ 0
+EOF
diff --git a/templates/ansi_d_arch.dxf b/templates/ansi_d_arch.dxf
new file mode 100644
index 0000000..2a5042d
--- /dev/null
+++ b/templates/ansi_d_arch.dxf
@@ -0,0 +1,3444 @@
+ 0
+SECTION
+ 2
+HEADER
+ 9
+$ACADVER
+ 1
+AC1024
+ 9
+$ACADMAINTVER
+ 70
+6
+ 9
+$DWGCODEPAGE
+ 3
+ANSI_1252
+ 9
+$LASTSAVEDBY
+ 1
+ezdxf
+ 9
+$INSBASE
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$EXTMIN
+ 10
+1e+20
+ 20
+1e+20
+ 30
+1e+20
+ 9
+$EXTMAX
+ 10
+-1e+20
+ 20
+-1e+20
+ 30
+-1e+20
+ 9
+$LIMMIN
+ 10
+0.0
+ 20
+0.0
+ 9
+$LIMMAX
+ 10
+420.0
+ 20
+297.0
+ 9
+$ORTHOMODE
+ 70
+0
+ 9
+$REGENMODE
+ 70
+1
+ 9
+$FILLMODE
+ 70
+1
+ 9
+$QTEXTMODE
+ 70
+0
+ 9
+$MIRRTEXT
+ 70
+1
+ 9
+$LTSCALE
+ 40
+1.0
+ 9
+$ATTMODE
+ 70
+1
+ 9
+$TEXTSIZE
+ 40
+2.5
+ 9
+$TRACEWID
+ 40
+1.0
+ 9
+$TEXTSTYLE
+ 7
+Standard
+ 9
+$CLAYER
+ 8
+0
+ 9
+$CELTYPE
+ 6
+ByLayer
+ 9
+$CECOLOR
+ 62
+256
+ 9
+$CELTSCALE
+ 40
+1.0
+ 9
+$DISPSILH
+ 70
+0
+ 9
+$DIMSCALE
+ 40
+1.0
+ 9
+$DIMASZ
+ 40
+3.0
+ 9
+$DIMEXO
+ 40
+1.5
+ 9
+$DIMDLI
+ 40
+3.75
+ 9
+$DIMRND
+ 40
+0.0
+ 9
+$DIMDLE
+ 40
+0.0
+ 9
+$DIMEXE
+ 40
+1.5
+ 9
+$DIMTP
+ 40
+0.0
+ 9
+$DIMTM
+ 40
+0.0
+ 9
+$DIMTXT
+ 40
+3.0
+ 9
+$DIMCEN
+ 40
+2.5
+ 9
+$DIMTSZ
+ 40
+0.0
+ 9
+$DIMTOL
+ 70
+0
+ 9
+$DIMLIM
+ 70
+0
+ 9
+$DIMTIH
+ 70
+1
+ 9
+$DIMTOH
+ 70
+1
+ 9
+$DIMSE1
+ 70
+0
+ 9
+$DIMSE2
+ 70
+0
+ 9
+$DIMTAD
+ 70
+0
+ 9
+$DIMZIN
+ 70
+8
+ 9
+$DIMBLK
+ 1
+
+ 9
+$DIMASO
+ 70
+1
+ 9
+$DIMSHO
+ 70
+1
+ 9
+$DIMPOST
+ 1
+
+ 9
+$DIMAPOST
+ 1
+
+ 9
+$DIMALT
+ 70
+0
+ 9
+$DIMALTD
+ 70
+3
+ 9
+$DIMALTF
+ 40
+0.03937007874
+ 9
+$DIMLFAC
+ 40
+1.0
+ 9
+$DIMTOFL
+ 70
+1
+ 9
+$DIMTVP
+ 40
+0.0
+ 9
+$DIMTIX
+ 70
+0
+ 9
+$DIMSOXD
+ 70
+0
+ 9
+$DIMSAH
+ 70
+0
+ 9
+$DIMBLK1
+ 1
+
+ 9
+$DIMBLK2
+ 1
+
+ 9
+$DIMSTYLE
+ 2
+ISO-25
+ 9
+$DIMCLRD
+ 70
+0
+ 9
+$DIMCLRE
+ 70
+0
+ 9
+$DIMCLRT
+ 70
+0
+ 9
+$DIMTFAC
+ 40
+1.0
+ 9
+$DIMGAP
+ 40
+1.0
+ 9
+$DIMJUST
+ 70
+0
+ 9
+$DIMSD1
+ 70
+0
+ 9
+$DIMSD2
+ 70
+0
+ 9
+$DIMTOLJ
+ 70
+0
+ 9
+$DIMTZIN
+ 70
+8
+ 9
+$DIMALTZ
+ 70
+0
+ 9
+$DIMALTTZ
+ 70
+0
+ 9
+$DIMUPT
+ 70
+0
+ 9
+$DIMDEC
+ 70
+2
+ 9
+$DIMTDEC
+ 70
+2
+ 9
+$DIMALTU
+ 70
+2
+ 9
+$DIMALTTD
+ 70
+3
+ 9
+$DIMTXSTY
+ 7
+Standard
+ 9
+$DIMAUNIT
+ 70
+0
+ 9
+$DIMADEC
+ 70
+0
+ 9
+$DIMALTRND
+ 40
+0.0
+ 9
+$DIMAZIN
+ 70
+0
+ 9
+$DIMDSEP
+ 70
+46
+ 9
+$DIMATFIT
+ 70
+3
+ 9
+$DIMFRAC
+ 70
+0
+ 9
+$DIMLDRBLK
+ 1
+
+ 9
+$DIMLUNIT
+ 70
+2
+ 9
+$DIMLWD
+ 70
+-2
+ 9
+$DIMLWE
+ 70
+-2
+ 9
+$DIMTMOVE
+ 70
+0
+ 9
+$DIMFXL
+ 40
+1.0
+ 9
+$DIMFXLON
+ 70
+0
+ 9
+$DIMJOGANG
+ 40
+0.785398163397
+ 9
+$DIMTFILL
+ 70
+0
+ 9
+$DIMTFILLCLR
+ 70
+0
+ 9
+$DIMARCSYM
+ 70
+0
+ 9
+$DIMLTYPE
+ 6
+
+ 9
+$DIMLTEX1
+ 6
+
+ 9
+$DIMLTEX2
+ 6
+
+ 9
+$DIMTXTDIRECTION
+ 70
+0
+ 9
+$LUNITS
+ 70
+2
+ 9
+$LUPREC
+ 70
+2
+ 9
+$SKETCHINC
+ 40
+1.0
+ 9
+$FILLETRAD
+ 40
+10.0
+ 9
+$AUNITS
+ 70
+0
+ 9
+$AUPREC
+ 70
+0
+ 9
+$MENU
+ 1
+.
+ 9
+$ELEVATION
+ 40
+0.0
+ 9
+$PELEVATION
+ 40
+0.0
+ 9
+$THICKNESS
+ 40
+0.0
+ 9
+$LIMCHECK
+ 70
+0
+ 9
+$CHAMFERA
+ 40
+0.0
+ 9
+$CHAMFERB
+ 40
+0.0
+ 9
+$CHAMFERC
+ 40
+0.0
+ 9
+$CHAMFERD
+ 40
+0.0
+ 9
+$SKPOLY
+ 70
+0
+ 9
+$TDCREATE
+ 40
+2461305.9345486113
+ 9
+$TDUCREATE
+ 40
+2458532.153996898
+ 9
+$TDUPDATE
+ 40
+2461305.9345486113
+ 9
+$TDUUPDATE
+ 40
+2458532.1544311
+ 9
+$TDINDWG
+ 40
+0.0
+ 9
+$TDUSRTIMER
+ 40
+0.0
+ 9
+$USRTIMER
+ 70
+1
+ 9
+$ANGBASE
+ 50
+0.0
+ 9
+$ANGDIR
+ 70
+0
+ 9
+$PDMODE
+ 70
+0
+ 9
+$PDSIZE
+ 40
+0.0
+ 9
+$PLINEWID
+ 40
+0.0
+ 9
+$SPLFRAME
+ 70
+0
+ 9
+$SPLINETYPE
+ 70
+6
+ 9
+$SPLINESEGS
+ 70
+8
+ 9
+$HANDSEED
+ 5
+40
+ 9
+$SURFTAB1
+ 70
+6
+ 9
+$SURFTAB2
+ 70
+6
+ 9
+$SURFTYPE
+ 70
+6
+ 9
+$SURFU
+ 70
+6
+ 9
+$SURFV
+ 70
+6
+ 9
+$UCSBASE
+ 2
+
+ 9
+$UCSNAME
+ 2
+
+ 9
+$UCSORG
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSXDIR
+ 10
+1.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSYDIR
+ 10
+0.0
+ 20
+1.0
+ 30
+0.0
+ 9
+$UCSORTHOREF
+ 2
+
+ 9
+$UCSORTHOVIEW
+ 70
+0
+ 9
+$UCSORGTOP
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGBOTTOM
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGLEFT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGRIGHT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGFRONT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGBACK
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSBASE
+ 2
+
+ 9
+$PUCSNAME
+ 2
+
+ 9
+$PUCSORG
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSXDIR
+ 10
+1.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSYDIR
+ 10
+0.0
+ 20
+1.0
+ 30
+0.0
+ 9
+$PUCSORTHOREF
+ 2
+
+ 9
+$PUCSORTHOVIEW
+ 70
+0
+ 9
+$PUCSORGTOP
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGBOTTOM
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGLEFT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGRIGHT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGFRONT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGBACK
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$USERI1
+ 70
+0
+ 9
+$USERI2
+ 70
+0
+ 9
+$USERI3
+ 70
+0
+ 9
+$USERI4
+ 70
+0
+ 9
+$USERI5
+ 70
+0
+ 9
+$USERR1
+ 40
+0.0
+ 9
+$USERR2
+ 40
+0.0
+ 9
+$USERR3
+ 40
+0.0
+ 9
+$USERR4
+ 40
+0.0
+ 9
+$USERR5
+ 40
+0.0
+ 9
+$WORLDVIEW
+ 70
+1
+ 9
+$SHADEDGE
+ 70
+3
+ 9
+$SHADEDIF
+ 70
+70
+ 9
+$TILEMODE
+ 70
+1
+ 9
+$MAXACTVP
+ 70
+64
+ 9
+$PINSBASE
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PLIMCHECK
+ 70
+0
+ 9
+$PEXTMIN
+ 10
+1e+20
+ 20
+1e+20
+ 30
+1e+20
+ 9
+$PEXTMAX
+ 10
+-1e+20
+ 20
+-1e+20
+ 30
+-1e+20
+ 9
+$PLIMMIN
+ 10
+-0.0
+ 20
+-0.0
+ 9
+$PLIMMAX
+ 10
+864.0
+ 20
+559.0
+ 9
+$UNITMODE
+ 70
+0
+ 9
+$VISRETAIN
+ 70
+1
+ 9
+$PLINEGEN
+ 70
+0
+ 9
+$PSLTSCALE
+ 70
+1
+ 9
+$TREEDEPTH
+ 70
+3020
+ 9
+$CMLSTYLE
+ 2
+Standard
+ 9
+$CMLJUST
+ 70
+0
+ 9
+$CMLSCALE
+ 40
+20.0
+ 9
+$PROXYGRAPHICS
+ 70
+1
+ 9
+$MEASUREMENT
+ 70
+1
+ 9
+$CELWEIGHT
+370
+-1
+ 9
+$ENDCAPS
+280
+0
+ 9
+$JOINSTYLE
+280
+0
+ 9
+$LWDISPLAY
+290
+0
+ 9
+$INSUNITS
+ 70
+4
+ 9
+$HYPERLINKBASE
+ 1
+
+ 9
+$STYLESHEET
+ 1
+
+ 9
+$XEDIT
+290
+1
+ 9
+$CEPSNTYPE
+380
+0
+ 9
+$PSTYLEMODE
+290
+1
+ 9
+$FINGERPRINTGUID
+ 2
+{EA8DAB3B-221B-4973-A128-0F97D36F69C3}
+ 9
+$VERSIONGUID
+ 2
+{41F085E9-E128-43F7-931E-FEE570A4F621}
+ 9
+$EXTNAMES
+290
+1
+ 9
+$PSVPSCALE
+ 40
+0.0
+ 9
+$OLESTARTUP
+290
+0
+ 9
+$SORTENTS
+280
+127
+ 9
+$INDEXCTL
+280
+0
+ 9
+$HIDETEXT
+280
+1
+ 9
+$XCLIPFRAME
+280
+1
+ 9
+$HALOGAP
+280
+0
+ 9
+$OBSCOLOR
+ 70
+257
+ 9
+$OBSLTYPE
+280
+0
+ 9
+$INTERSECTIONDISPLAY
+280
+0
+ 9
+$INTERSECTIONCOLOR
+ 70
+257
+ 9
+$DIMASSOC
+280
+2
+ 9
+$PROJECTNAME
+ 1
+
+ 9
+$CAMERADISPLAY
+290
+0
+ 9
+$LENSLENGTH
+ 40
+50.0
+ 9
+$CAMERAHEIGHT
+ 40
+0.0
+ 9
+$STEPSPERSEC
+ 40
+24.0
+ 9
+$STEPSIZE
+ 40
+100.0
+ 9
+$3DDWFPREC
+ 40
+2.0
+ 9
+$PSOLWIDTH
+ 40
+0.005
+ 9
+$PSOLHEIGHT
+ 40
+0.08
+ 9
+$LOFTANG1
+ 40
+1.570796326795
+ 9
+$LOFTANG2
+ 40
+1.570796326795
+ 9
+$LOFTMAG1
+ 40
+0.0
+ 9
+$LOFTMAG2
+ 40
+0.0
+ 9
+$LOFTPARAM
+ 70
+7
+ 9
+$LOFTNORMALS
+280
+1
+ 9
+$LATITUDE
+ 40
+37.795
+ 9
+$LONGITUDE
+ 40
+-122.394
+ 9
+$NORTHDIRECTION
+ 40
+0.0
+ 9
+$TIMEZONE
+ 70
+-8000
+ 9
+$LIGHTGLYPHDISPLAY
+280
+1
+ 9
+$TILEMODELIGHTSYNCH
+280
+1
+ 9
+$CMATERIAL
+347
+20
+ 9
+$SOLIDHIST
+280
+0
+ 9
+$SHOWHIST
+280
+1
+ 9
+$DWFFRAME
+280
+2
+ 9
+$DGNFRAME
+280
+2
+ 9
+$REALWORLDSCALE
+290
+1
+ 9
+$INTERFERECOLOR
+ 62
+256
+ 9
+$CSHADOW
+280
+0
+ 9
+$SHADOWPLANELOCATION
+ 40
+0.0
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+CLASSES
+ 0
+CLASS
+ 1
+ACDBDICTIONARYWDFLT
+ 2
+AcDbDictionaryWithDefault
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+SUN
+ 2
+AcDbSun
+ 3
+SCENEOE
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+VISUALSTYLE
+ 2
+AcDbVisualStyle
+ 3
+ObjectDBX Classes
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MATERIAL
+ 2
+AcDbMaterial
+ 3
+ObjectDBX Classes
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+SCALE
+ 2
+AcDbScale
+ 3
+ObjectDBX Classes
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+TABLESTYLE
+ 2
+AcDbTableStyle
+ 3
+ObjectDBX Classes
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MLEADERSTYLE
+ 2
+AcDbMLeaderStyle
+ 3
+ACDB_MLEADERSTYLE_CLASS
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+DICTIONARYVAR
+ 2
+AcDbDictionaryVar
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+CELLSTYLEMAP
+ 2
+AcDbCellStyleMap
+ 3
+ObjectDBX Classes
+ 90
+1152
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MENTALRAYRENDERSETTINGS
+ 2
+AcDbMentalRayRenderSettings
+ 3
+SCENEOE
+ 90
+1024
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBDETAILVIEWSTYLE
+ 2
+AcDbDetailViewStyle
+ 3
+ObjectDBX Classes
+ 90
+1025
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBSECTIONVIEWSTYLE
+ 2
+AcDbSectionViewStyle
+ 3
+ObjectDBX Classes
+ 90
+1025
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+RASTERVARIABLES
+ 2
+AcDbRasterVariables
+ 3
+ISM
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBPLACEHOLDER
+ 2
+AcDbPlaceHolder
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+LAYOUT
+ 2
+AcDbLayout
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+TABLES
+ 0
+TABLE
+ 2
+VPORT
+ 5
+8
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+ 0
+VPORT
+ 5
+23
+330
+8
+100
+AcDbSymbolTableRecord
+100
+AcDbViewportTableRecord
+ 2
+*Active
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 11
+1.0
+ 21
+1.0
+ 12
+0.0
+ 22
+0.0
+ 13
+0.0
+ 23
+0.0
+ 14
+0.5
+ 24
+0.5
+ 15
+0.5
+ 25
+0.5
+ 16
+0.0
+ 26
+0.0
+ 36
+1.0
+ 17
+0.0
+ 27
+0.0
+ 37
+0.0
+ 40
+1000.0
+ 41
+1.34
+ 42
+50.0
+ 43
+0.0
+ 44
+0.0
+ 50
+0.0
+ 51
+0.0
+ 71
+0
+ 72
+1000
+ 73
+1
+ 74
+3
+ 75
+0
+ 76
+0
+ 77
+0
+ 78
+0
+281
+0
+ 65
+0
+146
+0.0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+LTYPE
+ 5
+2
+330
+0
+100
+AcDbSymbolTable
+ 70
+8
+ 0
+LTYPE
+ 5
+24
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+ByBlock
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+25
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+ByLayer
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+26
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+Continuous
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+2F
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+CENTER
+ 70
+0
+ 3
+Center ____ _ ____ _ ____ _ ____ _ ____ _ ____
+ 72
+65
+ 73
+4
+ 40
+2.0
+ 49
+1.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 0
+LTYPE
+ 5
+30
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+HIDDEN
+ 70
+0
+ 3
+Hidden __ __ __ __ __ __ __ __ __
+ 72
+65
+ 73
+2
+ 40
+7.5
+ 49
+5.0
+ 74
+0
+ 49
+-2.5
+ 74
+0
+ 0
+LTYPE
+ 5
+31
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+PHANTOM
+ 70
+0
+ 3
+Phantom ______ __ __ ______ __ __ ______
+ 72
+65
+ 73
+6
+ 40
+2.5
+ 49
+1.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 0
+LTYPE
+ 5
+32
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+DASHED
+ 70
+0
+ 3
+Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _
+ 72
+65
+ 73
+2
+ 40
+0.6
+ 49
+0.5
+ 74
+0
+ 49
+-0.1
+ 74
+0
+ 0
+LTYPE
+ 5
+33
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+DASHDOT
+ 70
+0
+ 3
+Dash dot __ . __ . __ . __ . __ . __ . __ . __
+ 72
+65
+ 73
+4
+ 40
+1.4
+ 49
+1.0
+ 74
+0
+ 49
+-0.2
+ 74
+0
+ 49
+0.0
+ 74
+0
+ 49
+-0.2
+ 74
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+LAYER
+ 5
+1
+330
+0
+100
+AcDbSymbolTable
+ 70
+11
+ 0
+LAYER
+ 5
+27
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+0
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+-3
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+28
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+Defpoints
+ 70
+0
+ 62
+7
+ 6
+Continuous
+290
+0
+370
+-3
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+34
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-GEOMET-E-N
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+50
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+35
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-HIDDEN-E-N
+ 70
+0
+ 62
+3
+ 6
+HIDDEN
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+36
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-CENTL-E-N
+ 70
+0
+ 62
+1
+ 6
+CENTER
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+37
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-PHANT-E-N
+ 70
+0
+ 62
+6
+ 6
+PHANTOM
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+38
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-DIMEN-T-N
+ 70
+0
+ 62
+2
+ 6
+Continuous
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+39
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-TEXT-T-N
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3A
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-HATCH-H-N
+ 70
+0
+ 62
+8
+ 6
+Continuous
+370
+13
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3B
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-TITLE-T-N
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+50
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3C
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-CONST-E-N
+ 70
+0
+ 62
+250
+ 6
+Continuous
+370
+5
+390
+13
+347
+21
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+STYLE
+ 5
+5
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+ 0
+STYLE
+ 5
+29
+330
+5
+100
+AcDbSymbolTableRecord
+100
+AcDbTextStyleTableRecord
+ 2
+Standard
+ 70
+0
+ 40
+0.0
+ 41
+1.0
+ 50
+0.0
+ 71
+0
+ 42
+2.5
+ 3
+txt
+ 4
+
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+VIEW
+ 5
+7
+330
+0
+100
+AcDbSymbolTable
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+UCS
+ 5
+6
+330
+0
+100
+AcDbSymbolTable
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+APPID
+ 5
+3
+330
+0
+100
+AcDbSymbolTable
+ 70
+3
+ 0
+APPID
+ 5
+2A
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+ACAD
+ 70
+0
+ 0
+APPID
+ 5
+3D
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+HATCHBACKGROUNDCOLOR
+ 70
+0
+ 0
+APPID
+ 5
+3E
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+EZDXF
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+DIMSTYLE
+ 5
+4
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+100
+AcDbDimStyleTable
+ 0
+DIMSTYLE
+105
+2B
+330
+4
+100
+AcDbSymbolTableRecord
+100
+AcDbDimStyleTableRecord
+ 2
+Standard
+ 70
+0
+ 40
+1.0
+ 41
+3.0
+ 42
+1.5
+ 43
+3.75
+ 44
+1.5
+ 45
+0.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+2.5
+140
+3.0
+141
+2.5
+142
+0.0
+143
+0.03937007874
+144
+1.0
+145
+0.0
+146
+1.0
+147
+1.0
+148
+0.0
+ 69
+0
+ 70
+0
+ 71
+0
+ 72
+0
+ 73
+1
+ 74
+1
+ 75
+0
+ 76
+0
+ 77
+0
+ 78
+8
+ 79
+3
+170
+0
+171
+3
+172
+1
+173
+0
+174
+0
+175
+0
+176
+0
+177
+0
+178
+0
+179
+2
+271
+2
+272
+2
+273
+2
+274
+3
+275
+0
+276
+0
+277
+2
+278
+46
+279
+0
+280
+0
+281
+0
+282
+0
+283
+0
+284
+8
+285
+0
+286
+0
+288
+0
+289
+3
+290
+0
+371
+-2
+372
+-2
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+BLOCK_RECORD
+ 5
+9
+330
+0
+100
+AcDbSymbolTable
+ 70
+2
+ 0
+BLOCK_RECORD
+ 5
+17
+330
+9
+100
+AcDbSymbolTableRecord
+100
+AcDbBlockTableRecord
+ 2
+*Model_Space
+340
+1A
+ 70
+0
+280
+1
+281
+0
+ 0
+BLOCK_RECORD
+ 5
+1B
+330
+9
+100
+AcDbSymbolTableRecord
+100
+AcDbBlockTableRecord
+ 2
+*Paper_Space
+340
+1E
+ 70
+0
+280
+1
+281
+0
+ 0
+ENDTAB
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+BLOCKS
+ 0
+BLOCK
+ 5
+18
+330
+17
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockBegin
+ 2
+*Model_Space
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 3
+*Model_Space
+ 1
+
+ 0
+ENDBLK
+ 5
+19
+330
+17
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockEnd
+ 0
+BLOCK
+ 5
+1C
+330
+1B
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockBegin
+ 2
+*Paper_Space
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 3
+*Paper_Space
+ 1
+
+ 0
+ENDBLK
+ 5
+1D
+330
+1B
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockEnd
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+ENTITIES
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+OBJECTS
+ 0
+DICTIONARY
+ 5
+A
+330
+0
+100
+AcDbDictionary
+281
+1
+ 3
+ACAD_COLOR
+350
+B
+ 3
+ACAD_GROUP
+350
+C
+ 3
+ACAD_LAYOUT
+350
+D
+ 3
+ACAD_MATERIAL
+350
+E
+ 3
+ACAD_MLEADERSTYLE
+350
+F
+ 3
+ACAD_MLINESTYLE
+350
+10
+ 3
+ACAD_PLOTSETTINGS
+350
+11
+ 3
+ACAD_PLOTSTYLENAME
+350
+12
+ 3
+ACAD_SCALELIST
+350
+14
+ 3
+ACAD_TABLESTYLE
+350
+15
+ 3
+ACAD_VISUALSTYLE
+350
+16
+ 3
+EZDXF_META
+350
+2D
+ 0
+DICTIONARY
+ 5
+B
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+C
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+D
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Model
+350
+1A
+ 3
+D
+350
+1E
+ 0
+DICTIONARY
+ 5
+E
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+ByBlock
+350
+1F
+ 3
+ByLayer
+350
+20
+ 3
+Global
+350
+21
+ 0
+DICTIONARY
+ 5
+F
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Standard
+350
+2C
+ 0
+DICTIONARY
+ 5
+10
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Standard
+350
+22
+ 0
+DICTIONARY
+ 5
+11
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+ACDBDICTIONARYWDFLT
+ 5
+12
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Normal
+350
+13
+100
+AcDbDictionaryWithDefault
+340
+13
+ 0
+ACDBPLACEHOLDER
+ 5
+13
+330
+12
+ 0
+DICTIONARY
+ 5
+14
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+15
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+16
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+LAYOUT
+ 5
+1A
+330
+D
+100
+AcDbPlotSettings
+ 1
+
+ 4
+A3
+ 6
+
+ 40
+7.5
+ 41
+20.0
+ 42
+7.5
+ 43
+20.0
+ 44
+420.0
+ 45
+297.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+0.0
+140
+0.0
+141
+0.0
+142
+1.0
+143
+1.0
+ 70
+1024
+ 72
+1
+ 73
+0
+ 74
+5
+ 7
+
+ 75
+16
+ 76
+0
+ 77
+2
+ 78
+300
+147
+1.0
+148
+0.0
+149
+0.0
+100
+AcDbLayout
+ 1
+Model
+ 70
+1
+ 71
+0
+ 10
+0.0
+ 20
+0.0
+ 11
+420.0
+ 21
+297.0
+ 12
+0.0
+ 22
+0.0
+ 32
+0.0
+ 14
+1e+20
+ 24
+1e+20
+ 34
+1e+20
+ 15
+-1e+20
+ 25
+-1e+20
+ 35
+-1e+20
+146
+0.0
+ 13
+0.0
+ 23
+0.0
+ 33
+0.0
+ 16
+1.0
+ 26
+0.0
+ 36
+0.0
+ 17
+0.0
+ 27
+1.0
+ 37
+0.0
+ 76
+1
+330
+17
+ 0
+LAYOUT
+ 5
+1E
+330
+D
+100
+AcDbPlotSettings
+ 1
+
+ 2
+DWG To PDF.pc3
+ 4
+ANSI_D_(34.00_x_22.00_Inches)
+ 6
+
+ 40
+0.0
+ 41
+0.0
+ 42
+0.0
+ 43
+0.0
+ 44
+864.0
+ 45
+559.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+0.0
+140
+0.0
+141
+0.0
+142
+1.0
+143
+1.0
+ 70
+688
+ 72
+1
+ 73
+0
+ 74
+5
+ 7
+monochrome.ctb
+ 75
+0
+ 76
+0
+ 77
+2
+ 78
+300
+147
+1.0
+148
+0.0
+149
+0.0
+100
+AcDbLayout
+ 1
+D
+ 70
+1
+ 71
+1
+ 10
+-0.0
+ 20
+-0.0
+ 11
+864.0
+ 21
+559.0
+ 12
+0.0
+ 22
+0.0
+ 32
+0.0
+ 14
+1e+20
+ 24
+1e+20
+ 34
+1e+20
+ 15
+-1e+20
+ 25
+-1e+20
+ 35
+-1e+20
+146
+0.0
+ 13
+0.0
+ 23
+0.0
+ 33
+0.0
+ 16
+1.0
+ 26
+0.0
+ 36
+0.0
+ 17
+0.0
+ 27
+1.0
+ 37
+0.0
+ 76
+1
+330
+1B
+ 0
+MATERIAL
+ 5
+1F
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+ByBlock
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MATERIAL
+ 5
+20
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+ByLayer
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MATERIAL
+ 5
+21
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+Global
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MLINESTYLE
+ 5
+22
+102
+{ACAD_REACTORS
+330
+10
+102
+}
+330
+10
+100
+AcDbMlineStyle
+ 2
+Standard
+ 70
+0
+ 3
+
+ 62
+256
+ 51
+90.0
+ 52
+90.0
+ 71
+2
+ 49
+0.5
+ 62
+256
+ 6
+BYLAYER
+ 49
+-0.5
+ 62
+256
+ 6
+BYLAYER
+ 0
+MLEADERSTYLE
+ 5
+2C
+102
+{ACAD_REACTORS
+330
+F
+102
+}
+330
+F
+100
+AcDbMLeaderStyle
+179
+2
+170
+2
+171
+1
+172
+0
+ 90
+2
+ 40
+0.0
+ 41
+0.0
+173
+1
+ 91
+-1056964608
+ 92
+-2
+290
+1
+ 42
+2.0
+291
+1
+ 43
+8.0
+ 3
+Standard
+ 44
+4.0
+300
+
+342
+29
+174
+1
+175
+1
+176
+0
+178
+1
+ 93
+-1056964608
+ 45
+4.0
+292
+0
+297
+0
+ 46
+4.0
+ 94
+-1056964608
+ 47
+1.0
+ 49
+1.0
+140
+1.0
+294
+1
+141
+0.0
+177
+0
+142
+1.0
+295
+0
+296
+0
+143
+3.75
+271
+0
+272
+9
+273
+9
+ 0
+DICTIONARY
+ 5
+2D
+330
+A
+100
+AcDbDictionary
+280
+1
+281
+1
+ 3
+CREATED_BY_EZDXF
+350
+2E
+ 3
+WRITTEN_BY_EZDXF
+350
+3F
+ 0
+DICTIONARYVAR
+ 5
+2E
+330
+2D
+100
+DictionaryVariables
+280
+0
+ 1
+1.4.4 @ 2026-09-21T19:25:45.818106+00:00
+ 0
+DICTIONARYVAR
+ 5
+3F
+330
+2D
+100
+DictionaryVariables
+280
+0
+ 1
+1.4.4 @ 2026-09-21T19:25:45.824109+00:00
+ 0
+ENDSEC
+ 0
+EOF
diff --git a/templates/iso_a1_arch.dxf b/templates/iso_a1_arch.dxf
new file mode 100644
index 0000000..e43499c
--- /dev/null
+++ b/templates/iso_a1_arch.dxf
@@ -0,0 +1,3444 @@
+ 0
+SECTION
+ 2
+HEADER
+ 9
+$ACADVER
+ 1
+AC1024
+ 9
+$ACADMAINTVER
+ 70
+6
+ 9
+$DWGCODEPAGE
+ 3
+ANSI_1252
+ 9
+$LASTSAVEDBY
+ 1
+ezdxf
+ 9
+$INSBASE
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$EXTMIN
+ 10
+1e+20
+ 20
+1e+20
+ 30
+1e+20
+ 9
+$EXTMAX
+ 10
+-1e+20
+ 20
+-1e+20
+ 30
+-1e+20
+ 9
+$LIMMIN
+ 10
+0.0
+ 20
+0.0
+ 9
+$LIMMAX
+ 10
+420.0
+ 20
+297.0
+ 9
+$ORTHOMODE
+ 70
+0
+ 9
+$REGENMODE
+ 70
+1
+ 9
+$FILLMODE
+ 70
+1
+ 9
+$QTEXTMODE
+ 70
+0
+ 9
+$MIRRTEXT
+ 70
+1
+ 9
+$LTSCALE
+ 40
+1.0
+ 9
+$ATTMODE
+ 70
+1
+ 9
+$TEXTSIZE
+ 40
+2.5
+ 9
+$TRACEWID
+ 40
+1.0
+ 9
+$TEXTSTYLE
+ 7
+Standard
+ 9
+$CLAYER
+ 8
+0
+ 9
+$CELTYPE
+ 6
+ByLayer
+ 9
+$CECOLOR
+ 62
+256
+ 9
+$CELTSCALE
+ 40
+1.0
+ 9
+$DISPSILH
+ 70
+0
+ 9
+$DIMSCALE
+ 40
+1.0
+ 9
+$DIMASZ
+ 40
+2.5
+ 9
+$DIMEXO
+ 40
+0.625
+ 9
+$DIMDLI
+ 40
+3.75
+ 9
+$DIMRND
+ 40
+0.0
+ 9
+$DIMDLE
+ 40
+0.0
+ 9
+$DIMEXE
+ 40
+1.25
+ 9
+$DIMTP
+ 40
+0.0
+ 9
+$DIMTM
+ 40
+0.0
+ 9
+$DIMTXT
+ 40
+2.5
+ 9
+$DIMCEN
+ 40
+2.5
+ 9
+$DIMTSZ
+ 40
+0.0
+ 9
+$DIMTOL
+ 70
+0
+ 9
+$DIMLIM
+ 70
+0
+ 9
+$DIMTIH
+ 70
+0
+ 9
+$DIMTOH
+ 70
+0
+ 9
+$DIMSE1
+ 70
+0
+ 9
+$DIMSE2
+ 70
+0
+ 9
+$DIMTAD
+ 70
+1
+ 9
+$DIMZIN
+ 70
+8
+ 9
+$DIMBLK
+ 1
+
+ 9
+$DIMASO
+ 70
+1
+ 9
+$DIMSHO
+ 70
+1
+ 9
+$DIMPOST
+ 1
+
+ 9
+$DIMAPOST
+ 1
+
+ 9
+$DIMALT
+ 70
+0
+ 9
+$DIMALTD
+ 70
+3
+ 9
+$DIMALTF
+ 40
+0.03937007874
+ 9
+$DIMLFAC
+ 40
+1.0
+ 9
+$DIMTOFL
+ 70
+1
+ 9
+$DIMTVP
+ 40
+0.0
+ 9
+$DIMTIX
+ 70
+0
+ 9
+$DIMSOXD
+ 70
+0
+ 9
+$DIMSAH
+ 70
+0
+ 9
+$DIMBLK1
+ 1
+
+ 9
+$DIMBLK2
+ 1
+
+ 9
+$DIMSTYLE
+ 2
+ISO-25
+ 9
+$DIMCLRD
+ 70
+0
+ 9
+$DIMCLRE
+ 70
+0
+ 9
+$DIMCLRT
+ 70
+0
+ 9
+$DIMTFAC
+ 40
+1.0
+ 9
+$DIMGAP
+ 40
+0.625
+ 9
+$DIMJUST
+ 70
+0
+ 9
+$DIMSD1
+ 70
+0
+ 9
+$DIMSD2
+ 70
+0
+ 9
+$DIMTOLJ
+ 70
+0
+ 9
+$DIMTZIN
+ 70
+8
+ 9
+$DIMALTZ
+ 70
+0
+ 9
+$DIMALTTZ
+ 70
+0
+ 9
+$DIMUPT
+ 70
+0
+ 9
+$DIMDEC
+ 70
+2
+ 9
+$DIMTDEC
+ 70
+2
+ 9
+$DIMALTU
+ 70
+2
+ 9
+$DIMALTTD
+ 70
+3
+ 9
+$DIMTXSTY
+ 7
+Standard
+ 9
+$DIMAUNIT
+ 70
+0
+ 9
+$DIMADEC
+ 70
+0
+ 9
+$DIMALTRND
+ 40
+0.0
+ 9
+$DIMAZIN
+ 70
+0
+ 9
+$DIMDSEP
+ 70
+44
+ 9
+$DIMATFIT
+ 70
+3
+ 9
+$DIMFRAC
+ 70
+0
+ 9
+$DIMLDRBLK
+ 1
+
+ 9
+$DIMLUNIT
+ 70
+2
+ 9
+$DIMLWD
+ 70
+-2
+ 9
+$DIMLWE
+ 70
+-2
+ 9
+$DIMTMOVE
+ 70
+0
+ 9
+$DIMFXL
+ 40
+1.0
+ 9
+$DIMFXLON
+ 70
+0
+ 9
+$DIMJOGANG
+ 40
+0.785398163397
+ 9
+$DIMTFILL
+ 70
+0
+ 9
+$DIMTFILLCLR
+ 70
+0
+ 9
+$DIMARCSYM
+ 70
+0
+ 9
+$DIMLTYPE
+ 6
+
+ 9
+$DIMLTEX1
+ 6
+
+ 9
+$DIMLTEX2
+ 6
+
+ 9
+$DIMTXTDIRECTION
+ 70
+0
+ 9
+$LUNITS
+ 70
+2
+ 9
+$LUPREC
+ 70
+2
+ 9
+$SKETCHINC
+ 40
+1.0
+ 9
+$FILLETRAD
+ 40
+10.0
+ 9
+$AUNITS
+ 70
+0
+ 9
+$AUPREC
+ 70
+0
+ 9
+$MENU
+ 1
+.
+ 9
+$ELEVATION
+ 40
+0.0
+ 9
+$PELEVATION
+ 40
+0.0
+ 9
+$THICKNESS
+ 40
+0.0
+ 9
+$LIMCHECK
+ 70
+0
+ 9
+$CHAMFERA
+ 40
+0.0
+ 9
+$CHAMFERB
+ 40
+0.0
+ 9
+$CHAMFERC
+ 40
+0.0
+ 9
+$CHAMFERD
+ 40
+0.0
+ 9
+$SKPOLY
+ 70
+0
+ 9
+$TDCREATE
+ 40
+2461305.9345486113
+ 9
+$TDUCREATE
+ 40
+2458532.153996898
+ 9
+$TDUPDATE
+ 40
+2461305.9345486113
+ 9
+$TDUUPDATE
+ 40
+2458532.1544311
+ 9
+$TDINDWG
+ 40
+0.0
+ 9
+$TDUSRTIMER
+ 40
+0.0
+ 9
+$USRTIMER
+ 70
+1
+ 9
+$ANGBASE
+ 50
+0.0
+ 9
+$ANGDIR
+ 70
+0
+ 9
+$PDMODE
+ 70
+0
+ 9
+$PDSIZE
+ 40
+0.0
+ 9
+$PLINEWID
+ 40
+0.0
+ 9
+$SPLFRAME
+ 70
+0
+ 9
+$SPLINETYPE
+ 70
+6
+ 9
+$SPLINESEGS
+ 70
+8
+ 9
+$HANDSEED
+ 5
+40
+ 9
+$SURFTAB1
+ 70
+6
+ 9
+$SURFTAB2
+ 70
+6
+ 9
+$SURFTYPE
+ 70
+6
+ 9
+$SURFU
+ 70
+6
+ 9
+$SURFV
+ 70
+6
+ 9
+$UCSBASE
+ 2
+
+ 9
+$UCSNAME
+ 2
+
+ 9
+$UCSORG
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSXDIR
+ 10
+1.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSYDIR
+ 10
+0.0
+ 20
+1.0
+ 30
+0.0
+ 9
+$UCSORTHOREF
+ 2
+
+ 9
+$UCSORTHOVIEW
+ 70
+0
+ 9
+$UCSORGTOP
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGBOTTOM
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGLEFT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGRIGHT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGFRONT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGBACK
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSBASE
+ 2
+
+ 9
+$PUCSNAME
+ 2
+
+ 9
+$PUCSORG
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSXDIR
+ 10
+1.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSYDIR
+ 10
+0.0
+ 20
+1.0
+ 30
+0.0
+ 9
+$PUCSORTHOREF
+ 2
+
+ 9
+$PUCSORTHOVIEW
+ 70
+0
+ 9
+$PUCSORGTOP
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGBOTTOM
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGLEFT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGRIGHT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGFRONT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGBACK
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$USERI1
+ 70
+0
+ 9
+$USERI2
+ 70
+0
+ 9
+$USERI3
+ 70
+0
+ 9
+$USERI4
+ 70
+0
+ 9
+$USERI5
+ 70
+0
+ 9
+$USERR1
+ 40
+0.0
+ 9
+$USERR2
+ 40
+0.0
+ 9
+$USERR3
+ 40
+0.0
+ 9
+$USERR4
+ 40
+0.0
+ 9
+$USERR5
+ 40
+0.0
+ 9
+$WORLDVIEW
+ 70
+1
+ 9
+$SHADEDGE
+ 70
+3
+ 9
+$SHADEDIF
+ 70
+70
+ 9
+$TILEMODE
+ 70
+1
+ 9
+$MAXACTVP
+ 70
+64
+ 9
+$PINSBASE
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PLIMCHECK
+ 70
+0
+ 9
+$PEXTMIN
+ 10
+1e+20
+ 20
+1e+20
+ 30
+1e+20
+ 9
+$PEXTMAX
+ 10
+-1e+20
+ 20
+-1e+20
+ 30
+-1e+20
+ 9
+$PLIMMIN
+ 10
+-0.0
+ 20
+-0.0
+ 9
+$PLIMMAX
+ 10
+841.0
+ 20
+594.0
+ 9
+$UNITMODE
+ 70
+0
+ 9
+$VISRETAIN
+ 70
+1
+ 9
+$PLINEGEN
+ 70
+0
+ 9
+$PSLTSCALE
+ 70
+1
+ 9
+$TREEDEPTH
+ 70
+3020
+ 9
+$CMLSTYLE
+ 2
+Standard
+ 9
+$CMLJUST
+ 70
+0
+ 9
+$CMLSCALE
+ 40
+20.0
+ 9
+$PROXYGRAPHICS
+ 70
+1
+ 9
+$MEASUREMENT
+ 70
+1
+ 9
+$CELWEIGHT
+370
+-1
+ 9
+$ENDCAPS
+280
+0
+ 9
+$JOINSTYLE
+280
+0
+ 9
+$LWDISPLAY
+290
+0
+ 9
+$INSUNITS
+ 70
+4
+ 9
+$HYPERLINKBASE
+ 1
+
+ 9
+$STYLESHEET
+ 1
+
+ 9
+$XEDIT
+290
+1
+ 9
+$CEPSNTYPE
+380
+0
+ 9
+$PSTYLEMODE
+290
+1
+ 9
+$FINGERPRINTGUID
+ 2
+{569A5E5D-D8AE-4EE2-8CC8-222F1BC5A1B6}
+ 9
+$VERSIONGUID
+ 2
+{CA16A478-EDAC-4921-A9AD-1AD1114D5AB2}
+ 9
+$EXTNAMES
+290
+1
+ 9
+$PSVPSCALE
+ 40
+0.0
+ 9
+$OLESTARTUP
+290
+0
+ 9
+$SORTENTS
+280
+127
+ 9
+$INDEXCTL
+280
+0
+ 9
+$HIDETEXT
+280
+1
+ 9
+$XCLIPFRAME
+280
+1
+ 9
+$HALOGAP
+280
+0
+ 9
+$OBSCOLOR
+ 70
+257
+ 9
+$OBSLTYPE
+280
+0
+ 9
+$INTERSECTIONDISPLAY
+280
+0
+ 9
+$INTERSECTIONCOLOR
+ 70
+257
+ 9
+$DIMASSOC
+280
+2
+ 9
+$PROJECTNAME
+ 1
+
+ 9
+$CAMERADISPLAY
+290
+0
+ 9
+$LENSLENGTH
+ 40
+50.0
+ 9
+$CAMERAHEIGHT
+ 40
+0.0
+ 9
+$STEPSPERSEC
+ 40
+24.0
+ 9
+$STEPSIZE
+ 40
+100.0
+ 9
+$3DDWFPREC
+ 40
+2.0
+ 9
+$PSOLWIDTH
+ 40
+0.005
+ 9
+$PSOLHEIGHT
+ 40
+0.08
+ 9
+$LOFTANG1
+ 40
+1.570796326795
+ 9
+$LOFTANG2
+ 40
+1.570796326795
+ 9
+$LOFTMAG1
+ 40
+0.0
+ 9
+$LOFTMAG2
+ 40
+0.0
+ 9
+$LOFTPARAM
+ 70
+7
+ 9
+$LOFTNORMALS
+280
+1
+ 9
+$LATITUDE
+ 40
+37.795
+ 9
+$LONGITUDE
+ 40
+-122.394
+ 9
+$NORTHDIRECTION
+ 40
+0.0
+ 9
+$TIMEZONE
+ 70
+-8000
+ 9
+$LIGHTGLYPHDISPLAY
+280
+1
+ 9
+$TILEMODELIGHTSYNCH
+280
+1
+ 9
+$CMATERIAL
+347
+20
+ 9
+$SOLIDHIST
+280
+0
+ 9
+$SHOWHIST
+280
+1
+ 9
+$DWFFRAME
+280
+2
+ 9
+$DGNFRAME
+280
+2
+ 9
+$REALWORLDSCALE
+290
+1
+ 9
+$INTERFERECOLOR
+ 62
+256
+ 9
+$CSHADOW
+280
+0
+ 9
+$SHADOWPLANELOCATION
+ 40
+0.0
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+CLASSES
+ 0
+CLASS
+ 1
+ACDBDICTIONARYWDFLT
+ 2
+AcDbDictionaryWithDefault
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+SUN
+ 2
+AcDbSun
+ 3
+SCENEOE
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+VISUALSTYLE
+ 2
+AcDbVisualStyle
+ 3
+ObjectDBX Classes
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MATERIAL
+ 2
+AcDbMaterial
+ 3
+ObjectDBX Classes
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+SCALE
+ 2
+AcDbScale
+ 3
+ObjectDBX Classes
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+TABLESTYLE
+ 2
+AcDbTableStyle
+ 3
+ObjectDBX Classes
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MLEADERSTYLE
+ 2
+AcDbMLeaderStyle
+ 3
+ACDB_MLEADERSTYLE_CLASS
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+DICTIONARYVAR
+ 2
+AcDbDictionaryVar
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+CELLSTYLEMAP
+ 2
+AcDbCellStyleMap
+ 3
+ObjectDBX Classes
+ 90
+1152
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MENTALRAYRENDERSETTINGS
+ 2
+AcDbMentalRayRenderSettings
+ 3
+SCENEOE
+ 90
+1024
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBDETAILVIEWSTYLE
+ 2
+AcDbDetailViewStyle
+ 3
+ObjectDBX Classes
+ 90
+1025
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBSECTIONVIEWSTYLE
+ 2
+AcDbSectionViewStyle
+ 3
+ObjectDBX Classes
+ 90
+1025
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+RASTERVARIABLES
+ 2
+AcDbRasterVariables
+ 3
+ISM
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBPLACEHOLDER
+ 2
+AcDbPlaceHolder
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+LAYOUT
+ 2
+AcDbLayout
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+TABLES
+ 0
+TABLE
+ 2
+VPORT
+ 5
+8
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+ 0
+VPORT
+ 5
+23
+330
+8
+100
+AcDbSymbolTableRecord
+100
+AcDbViewportTableRecord
+ 2
+*Active
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 11
+1.0
+ 21
+1.0
+ 12
+0.0
+ 22
+0.0
+ 13
+0.0
+ 23
+0.0
+ 14
+0.5
+ 24
+0.5
+ 15
+0.5
+ 25
+0.5
+ 16
+0.0
+ 26
+0.0
+ 36
+1.0
+ 17
+0.0
+ 27
+0.0
+ 37
+0.0
+ 40
+1000.0
+ 41
+1.34
+ 42
+50.0
+ 43
+0.0
+ 44
+0.0
+ 50
+0.0
+ 51
+0.0
+ 71
+0
+ 72
+1000
+ 73
+1
+ 74
+3
+ 75
+0
+ 76
+0
+ 77
+0
+ 78
+0
+281
+0
+ 65
+0
+146
+0.0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+LTYPE
+ 5
+2
+330
+0
+100
+AcDbSymbolTable
+ 70
+8
+ 0
+LTYPE
+ 5
+24
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+ByBlock
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+25
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+ByLayer
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+26
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+Continuous
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+2F
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+CENTER
+ 70
+0
+ 3
+Center ____ _ ____ _ ____ _ ____ _ ____ _ ____
+ 72
+65
+ 73
+4
+ 40
+2.0
+ 49
+1.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 0
+LTYPE
+ 5
+30
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+HIDDEN
+ 70
+0
+ 3
+Hidden __ __ __ __ __ __ __ __ __
+ 72
+65
+ 73
+2
+ 40
+7.5
+ 49
+5.0
+ 74
+0
+ 49
+-2.5
+ 74
+0
+ 0
+LTYPE
+ 5
+31
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+PHANTOM
+ 70
+0
+ 3
+Phantom ______ __ __ ______ __ __ ______
+ 72
+65
+ 73
+6
+ 40
+2.5
+ 49
+1.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 0
+LTYPE
+ 5
+32
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+DASHED
+ 70
+0
+ 3
+Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _
+ 72
+65
+ 73
+2
+ 40
+0.6
+ 49
+0.5
+ 74
+0
+ 49
+-0.1
+ 74
+0
+ 0
+LTYPE
+ 5
+33
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+DASHDOT
+ 70
+0
+ 3
+Dash dot __ . __ . __ . __ . __ . __ . __ . __
+ 72
+65
+ 73
+4
+ 40
+1.4
+ 49
+1.0
+ 74
+0
+ 49
+-0.2
+ 74
+0
+ 49
+0.0
+ 74
+0
+ 49
+-0.2
+ 74
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+LAYER
+ 5
+1
+330
+0
+100
+AcDbSymbolTable
+ 70
+11
+ 0
+LAYER
+ 5
+27
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+0
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+-3
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+28
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+Defpoints
+ 70
+0
+ 62
+7
+ 6
+Continuous
+290
+0
+370
+-3
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+34
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-GEOMET-E-N
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+50
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+35
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-HIDDEN-E-N
+ 70
+0
+ 62
+3
+ 6
+HIDDEN
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+36
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-CENTL-E-N
+ 70
+0
+ 62
+1
+ 6
+CENTER
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+37
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-PHANT-E-N
+ 70
+0
+ 62
+6
+ 6
+PHANTOM
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+38
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-DIMEN-T-N
+ 70
+0
+ 62
+2
+ 6
+Continuous
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+39
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-TEXT-T-N
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3A
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-HATCH-H-N
+ 70
+0
+ 62
+8
+ 6
+Continuous
+370
+13
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3B
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-TITLE-T-N
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+50
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3C
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+M-CONST-E-N
+ 70
+0
+ 62
+250
+ 6
+Continuous
+370
+5
+390
+13
+347
+21
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+STYLE
+ 5
+5
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+ 0
+STYLE
+ 5
+29
+330
+5
+100
+AcDbSymbolTableRecord
+100
+AcDbTextStyleTableRecord
+ 2
+Standard
+ 70
+0
+ 40
+0.0
+ 41
+1.0
+ 50
+0.0
+ 71
+0
+ 42
+2.5
+ 3
+txt
+ 4
+
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+VIEW
+ 5
+7
+330
+0
+100
+AcDbSymbolTable
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+UCS
+ 5
+6
+330
+0
+100
+AcDbSymbolTable
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+APPID
+ 5
+3
+330
+0
+100
+AcDbSymbolTable
+ 70
+3
+ 0
+APPID
+ 5
+2A
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+ACAD
+ 70
+0
+ 0
+APPID
+ 5
+3D
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+HATCHBACKGROUNDCOLOR
+ 70
+0
+ 0
+APPID
+ 5
+3E
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+EZDXF
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+DIMSTYLE
+ 5
+4
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+100
+AcDbDimStyleTable
+ 0
+DIMSTYLE
+105
+2B
+330
+4
+100
+AcDbSymbolTableRecord
+100
+AcDbDimStyleTableRecord
+ 2
+Standard
+ 70
+0
+ 40
+1.0
+ 41
+2.5
+ 42
+0.625
+ 43
+3.75
+ 44
+1.25
+ 45
+0.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+2.5
+140
+2.5
+141
+2.5
+142
+0.0
+143
+0.03937007874
+144
+1.0
+145
+0.0
+146
+1.0
+147
+0.625
+148
+0.0
+ 69
+0
+ 70
+0
+ 71
+0
+ 72
+0
+ 73
+0
+ 74
+0
+ 75
+0
+ 76
+0
+ 77
+1
+ 78
+8
+ 79
+3
+170
+0
+171
+3
+172
+1
+173
+0
+174
+0
+175
+0
+176
+0
+177
+0
+178
+0
+179
+2
+271
+2
+272
+2
+273
+2
+274
+3
+275
+0
+276
+0
+277
+2
+278
+46
+279
+0
+280
+0
+281
+0
+282
+0
+283
+0
+284
+8
+285
+0
+286
+0
+288
+0
+289
+3
+290
+0
+371
+-2
+372
+-2
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+BLOCK_RECORD
+ 5
+9
+330
+0
+100
+AcDbSymbolTable
+ 70
+2
+ 0
+BLOCK_RECORD
+ 5
+17
+330
+9
+100
+AcDbSymbolTableRecord
+100
+AcDbBlockTableRecord
+ 2
+*Model_Space
+340
+1A
+ 70
+0
+280
+1
+281
+0
+ 0
+BLOCK_RECORD
+ 5
+1B
+330
+9
+100
+AcDbSymbolTableRecord
+100
+AcDbBlockTableRecord
+ 2
+*Paper_Space
+340
+1E
+ 70
+0
+280
+1
+281
+0
+ 0
+ENDTAB
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+BLOCKS
+ 0
+BLOCK
+ 5
+18
+330
+17
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockBegin
+ 2
+*Model_Space
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 3
+*Model_Space
+ 1
+
+ 0
+ENDBLK
+ 5
+19
+330
+17
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockEnd
+ 0
+BLOCK
+ 5
+1C
+330
+1B
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockBegin
+ 2
+*Paper_Space
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 3
+*Paper_Space
+ 1
+
+ 0
+ENDBLK
+ 5
+1D
+330
+1B
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockEnd
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+ENTITIES
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+OBJECTS
+ 0
+DICTIONARY
+ 5
+A
+330
+0
+100
+AcDbDictionary
+281
+1
+ 3
+ACAD_COLOR
+350
+B
+ 3
+ACAD_GROUP
+350
+C
+ 3
+ACAD_LAYOUT
+350
+D
+ 3
+ACAD_MATERIAL
+350
+E
+ 3
+ACAD_MLEADERSTYLE
+350
+F
+ 3
+ACAD_MLINESTYLE
+350
+10
+ 3
+ACAD_PLOTSETTINGS
+350
+11
+ 3
+ACAD_PLOTSTYLENAME
+350
+12
+ 3
+ACAD_SCALELIST
+350
+14
+ 3
+ACAD_TABLESTYLE
+350
+15
+ 3
+ACAD_VISUALSTYLE
+350
+16
+ 3
+EZDXF_META
+350
+2D
+ 0
+DICTIONARY
+ 5
+B
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+C
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+D
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Model
+350
+1A
+ 3
+A1
+350
+1E
+ 0
+DICTIONARY
+ 5
+E
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+ByBlock
+350
+1F
+ 3
+ByLayer
+350
+20
+ 3
+Global
+350
+21
+ 0
+DICTIONARY
+ 5
+F
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Standard
+350
+2C
+ 0
+DICTIONARY
+ 5
+10
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Standard
+350
+22
+ 0
+DICTIONARY
+ 5
+11
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+ACDBDICTIONARYWDFLT
+ 5
+12
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Normal
+350
+13
+100
+AcDbDictionaryWithDefault
+340
+13
+ 0
+ACDBPLACEHOLDER
+ 5
+13
+330
+12
+ 0
+DICTIONARY
+ 5
+14
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+15
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+16
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+LAYOUT
+ 5
+1A
+330
+D
+100
+AcDbPlotSettings
+ 1
+
+ 4
+A3
+ 6
+
+ 40
+7.5
+ 41
+20.0
+ 42
+7.5
+ 43
+20.0
+ 44
+420.0
+ 45
+297.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+0.0
+140
+0.0
+141
+0.0
+142
+1.0
+143
+1.0
+ 70
+1024
+ 72
+1
+ 73
+0
+ 74
+5
+ 7
+
+ 75
+16
+ 76
+0
+ 77
+2
+ 78
+300
+147
+1.0
+148
+0.0
+149
+0.0
+100
+AcDbLayout
+ 1
+Model
+ 70
+1
+ 71
+0
+ 10
+0.0
+ 20
+0.0
+ 11
+420.0
+ 21
+297.0
+ 12
+0.0
+ 22
+0.0
+ 32
+0.0
+ 14
+1e+20
+ 24
+1e+20
+ 34
+1e+20
+ 15
+-1e+20
+ 25
+-1e+20
+ 35
+-1e+20
+146
+0.0
+ 13
+0.0
+ 23
+0.0
+ 33
+0.0
+ 16
+1.0
+ 26
+0.0
+ 36
+0.0
+ 17
+0.0
+ 27
+1.0
+ 37
+0.0
+ 76
+1
+330
+17
+ 0
+LAYOUT
+ 5
+1E
+330
+D
+100
+AcDbPlotSettings
+ 1
+
+ 2
+DWG To PDF.pc3
+ 4
+ISO_A1_(841.00_x_594.00_MM)
+ 6
+
+ 40
+0.0
+ 41
+0.0
+ 42
+0.0
+ 43
+0.0
+ 44
+841.0
+ 45
+594.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+0.0
+140
+0.0
+141
+0.0
+142
+1.0
+143
+1.0
+ 70
+688
+ 72
+1
+ 73
+0
+ 74
+5
+ 7
+monochrome.ctb
+ 75
+0
+ 76
+0
+ 77
+2
+ 78
+300
+147
+1.0
+148
+0.0
+149
+0.0
+100
+AcDbLayout
+ 1
+A1
+ 70
+1
+ 71
+1
+ 10
+-0.0
+ 20
+-0.0
+ 11
+841.0
+ 21
+594.0
+ 12
+0.0
+ 22
+0.0
+ 32
+0.0
+ 14
+1e+20
+ 24
+1e+20
+ 34
+1e+20
+ 15
+-1e+20
+ 25
+-1e+20
+ 35
+-1e+20
+146
+0.0
+ 13
+0.0
+ 23
+0.0
+ 33
+0.0
+ 16
+1.0
+ 26
+0.0
+ 36
+0.0
+ 17
+0.0
+ 27
+1.0
+ 37
+0.0
+ 76
+1
+330
+1B
+ 0
+MATERIAL
+ 5
+1F
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+ByBlock
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MATERIAL
+ 5
+20
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+ByLayer
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MATERIAL
+ 5
+21
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+Global
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MLINESTYLE
+ 5
+22
+102
+{ACAD_REACTORS
+330
+10
+102
+}
+330
+10
+100
+AcDbMlineStyle
+ 2
+Standard
+ 70
+0
+ 3
+
+ 62
+256
+ 51
+90.0
+ 52
+90.0
+ 71
+2
+ 49
+0.5
+ 62
+256
+ 6
+BYLAYER
+ 49
+-0.5
+ 62
+256
+ 6
+BYLAYER
+ 0
+MLEADERSTYLE
+ 5
+2C
+102
+{ACAD_REACTORS
+330
+F
+102
+}
+330
+F
+100
+AcDbMLeaderStyle
+179
+2
+170
+2
+171
+1
+172
+0
+ 90
+2
+ 40
+0.0
+ 41
+0.0
+173
+1
+ 91
+-1056964608
+ 92
+-2
+290
+1
+ 42
+2.0
+291
+1
+ 43
+8.0
+ 3
+Standard
+ 44
+4.0
+300
+
+342
+29
+174
+1
+175
+1
+176
+0
+178
+1
+ 93
+-1056964608
+ 45
+4.0
+292
+0
+297
+0
+ 46
+4.0
+ 94
+-1056964608
+ 47
+1.0
+ 49
+1.0
+140
+1.0
+294
+1
+141
+0.0
+177
+0
+142
+1.0
+295
+0
+296
+0
+143
+3.75
+271
+0
+272
+9
+273
+9
+ 0
+DICTIONARY
+ 5
+2D
+330
+A
+100
+AcDbDictionary
+280
+1
+281
+1
+ 3
+CREATED_BY_EZDXF
+350
+2E
+ 3
+WRITTEN_BY_EZDXF
+350
+3F
+ 0
+DICTIONARYVAR
+ 5
+2E
+330
+2D
+100
+DictionaryVariables
+280
+0
+ 1
+1.4.4 @ 2026-09-21T19:25:45.777979+00:00
+ 0
+DICTIONARYVAR
+ 5
+3F
+330
+2D
+100
+DictionaryVariables
+280
+0
+ 1
+1.4.4 @ 2026-09-21T19:25:45.783975+00:00
+ 0
+ENDSEC
+ 0
+EOF
diff --git a/templates/iso_a3_mech.dxf b/templates/iso_a3_mech.dxf
new file mode 100644
index 0000000..d0332f7
--- /dev/null
+++ b/templates/iso_a3_mech.dxf
@@ -0,0 +1,4352 @@
+ 0
+SECTION
+ 2
+HEADER
+ 9
+$ACADVER
+ 1
+AC1024
+ 9
+$ACADMAINTVER
+ 70
+6
+ 9
+$DWGCODEPAGE
+ 3
+ANSI_1252
+ 9
+$LASTSAVEDBY
+ 1
+ezdxf
+ 9
+$INSBASE
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$EXTMIN
+ 10
+1e+20
+ 20
+1e+20
+ 30
+1e+20
+ 9
+$EXTMAX
+ 10
+-1e+20
+ 20
+-1e+20
+ 30
+-1e+20
+ 9
+$LIMMIN
+ 10
+0.0
+ 20
+0.0
+ 9
+$LIMMAX
+ 10
+420.0
+ 20
+297.0
+ 9
+$ORTHOMODE
+ 70
+0
+ 9
+$REGENMODE
+ 70
+1
+ 9
+$FILLMODE
+ 70
+1
+ 9
+$QTEXTMODE
+ 70
+0
+ 9
+$MIRRTEXT
+ 70
+1
+ 9
+$LTSCALE
+ 40
+1.0
+ 9
+$ATTMODE
+ 70
+1
+ 9
+$TEXTSIZE
+ 40
+2.5
+ 9
+$TRACEWID
+ 40
+1.0
+ 9
+$TEXTSTYLE
+ 7
+Standard
+ 9
+$CLAYER
+ 8
+0
+ 9
+$CELTYPE
+ 6
+ByLayer
+ 9
+$CECOLOR
+ 62
+256
+ 9
+$CELTSCALE
+ 40
+1.0
+ 9
+$DISPSILH
+ 70
+0
+ 9
+$DIMSCALE
+ 40
+1.0
+ 9
+$DIMASZ
+ 40
+2.5
+ 9
+$DIMEXO
+ 40
+0.625
+ 9
+$DIMDLI
+ 40
+3.75
+ 9
+$DIMRND
+ 40
+0.0
+ 9
+$DIMDLE
+ 40
+0.0
+ 9
+$DIMEXE
+ 40
+1.25
+ 9
+$DIMTP
+ 40
+0.0
+ 9
+$DIMTM
+ 40
+0.0
+ 9
+$DIMTXT
+ 40
+2.5
+ 9
+$DIMCEN
+ 40
+2.5
+ 9
+$DIMTSZ
+ 40
+0.0
+ 9
+$DIMTOL
+ 70
+0
+ 9
+$DIMLIM
+ 70
+0
+ 9
+$DIMTIH
+ 70
+0
+ 9
+$DIMTOH
+ 70
+0
+ 9
+$DIMSE1
+ 70
+0
+ 9
+$DIMSE2
+ 70
+0
+ 9
+$DIMTAD
+ 70
+1
+ 9
+$DIMZIN
+ 70
+8
+ 9
+$DIMBLK
+ 1
+
+ 9
+$DIMASO
+ 70
+1
+ 9
+$DIMSHO
+ 70
+1
+ 9
+$DIMPOST
+ 1
+
+ 9
+$DIMAPOST
+ 1
+
+ 9
+$DIMALT
+ 70
+0
+ 9
+$DIMALTD
+ 70
+3
+ 9
+$DIMALTF
+ 40
+0.03937007874
+ 9
+$DIMLFAC
+ 40
+1.0
+ 9
+$DIMTOFL
+ 70
+1
+ 9
+$DIMTVP
+ 40
+0.0
+ 9
+$DIMTIX
+ 70
+0
+ 9
+$DIMSOXD
+ 70
+0
+ 9
+$DIMSAH
+ 70
+0
+ 9
+$DIMBLK1
+ 1
+
+ 9
+$DIMBLK2
+ 1
+
+ 9
+$DIMSTYLE
+ 2
+ISO-25
+ 9
+$DIMCLRD
+ 70
+0
+ 9
+$DIMCLRE
+ 70
+0
+ 9
+$DIMCLRT
+ 70
+0
+ 9
+$DIMTFAC
+ 40
+1.0
+ 9
+$DIMGAP
+ 40
+0.625
+ 9
+$DIMJUST
+ 70
+0
+ 9
+$DIMSD1
+ 70
+0
+ 9
+$DIMSD2
+ 70
+0
+ 9
+$DIMTOLJ
+ 70
+0
+ 9
+$DIMTZIN
+ 70
+8
+ 9
+$DIMALTZ
+ 70
+0
+ 9
+$DIMALTTZ
+ 70
+0
+ 9
+$DIMUPT
+ 70
+0
+ 9
+$DIMDEC
+ 70
+2
+ 9
+$DIMTDEC
+ 70
+2
+ 9
+$DIMALTU
+ 70
+2
+ 9
+$DIMALTTD
+ 70
+3
+ 9
+$DIMTXSTY
+ 7
+Standard
+ 9
+$DIMAUNIT
+ 70
+0
+ 9
+$DIMADEC
+ 70
+0
+ 9
+$DIMALTRND
+ 40
+0.0
+ 9
+$DIMAZIN
+ 70
+0
+ 9
+$DIMDSEP
+ 70
+44
+ 9
+$DIMATFIT
+ 70
+3
+ 9
+$DIMFRAC
+ 70
+0
+ 9
+$DIMLDRBLK
+ 1
+
+ 9
+$DIMLUNIT
+ 70
+2
+ 9
+$DIMLWD
+ 70
+-2
+ 9
+$DIMLWE
+ 70
+-2
+ 9
+$DIMTMOVE
+ 70
+0
+ 9
+$DIMFXL
+ 40
+1.0
+ 9
+$DIMFXLON
+ 70
+0
+ 9
+$DIMJOGANG
+ 40
+0.785398163397
+ 9
+$DIMTFILL
+ 70
+0
+ 9
+$DIMTFILLCLR
+ 70
+0
+ 9
+$DIMARCSYM
+ 70
+0
+ 9
+$DIMLTYPE
+ 6
+
+ 9
+$DIMLTEX1
+ 6
+
+ 9
+$DIMLTEX2
+ 6
+
+ 9
+$DIMTXTDIRECTION
+ 70
+0
+ 9
+$LUNITS
+ 70
+2
+ 9
+$LUPREC
+ 70
+2
+ 9
+$SKETCHINC
+ 40
+1.0
+ 9
+$FILLETRAD
+ 40
+10.0
+ 9
+$AUNITS
+ 70
+0
+ 9
+$AUPREC
+ 70
+0
+ 9
+$MENU
+ 1
+.
+ 9
+$ELEVATION
+ 40
+0.0
+ 9
+$PELEVATION
+ 40
+0.0
+ 9
+$THICKNESS
+ 40
+0.0
+ 9
+$LIMCHECK
+ 70
+0
+ 9
+$CHAMFERA
+ 40
+0.0
+ 9
+$CHAMFERB
+ 40
+0.0
+ 9
+$CHAMFERC
+ 40
+0.0
+ 9
+$CHAMFERD
+ 40
+0.0
+ 9
+$SKPOLY
+ 70
+0
+ 9
+$TDCREATE
+ 40
+2461305.9345486113
+ 9
+$TDUCREATE
+ 40
+2458532.153996898
+ 9
+$TDUPDATE
+ 40
+2461305.9345486113
+ 9
+$TDUUPDATE
+ 40
+2458532.1544311
+ 9
+$TDINDWG
+ 40
+0.0
+ 9
+$TDUSRTIMER
+ 40
+0.0
+ 9
+$USRTIMER
+ 70
+1
+ 9
+$ANGBASE
+ 50
+0.0
+ 9
+$ANGDIR
+ 70
+0
+ 9
+$PDMODE
+ 70
+0
+ 9
+$PDSIZE
+ 40
+0.0
+ 9
+$PLINEWID
+ 40
+0.0
+ 9
+$SPLFRAME
+ 70
+0
+ 9
+$SPLINETYPE
+ 70
+6
+ 9
+$SPLINESEGS
+ 70
+8
+ 9
+$HANDSEED
+ 5
+62
+ 9
+$SURFTAB1
+ 70
+6
+ 9
+$SURFTAB2
+ 70
+6
+ 9
+$SURFTYPE
+ 70
+6
+ 9
+$SURFU
+ 70
+6
+ 9
+$SURFV
+ 70
+6
+ 9
+$UCSBASE
+ 2
+
+ 9
+$UCSNAME
+ 2
+
+ 9
+$UCSORG
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSXDIR
+ 10
+1.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSYDIR
+ 10
+0.0
+ 20
+1.0
+ 30
+0.0
+ 9
+$UCSORTHOREF
+ 2
+
+ 9
+$UCSORTHOVIEW
+ 70
+0
+ 9
+$UCSORGTOP
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGBOTTOM
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGLEFT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGRIGHT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGFRONT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGBACK
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSBASE
+ 2
+
+ 9
+$PUCSNAME
+ 2
+
+ 9
+$PUCSORG
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSXDIR
+ 10
+1.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSYDIR
+ 10
+0.0
+ 20
+1.0
+ 30
+0.0
+ 9
+$PUCSORTHOREF
+ 2
+
+ 9
+$PUCSORTHOVIEW
+ 70
+0
+ 9
+$PUCSORGTOP
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGBOTTOM
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGLEFT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGRIGHT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGFRONT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGBACK
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$USERI1
+ 70
+0
+ 9
+$USERI2
+ 70
+0
+ 9
+$USERI3
+ 70
+0
+ 9
+$USERI4
+ 70
+0
+ 9
+$USERI5
+ 70
+0
+ 9
+$USERR1
+ 40
+0.0
+ 9
+$USERR2
+ 40
+0.0
+ 9
+$USERR3
+ 40
+0.0
+ 9
+$USERR4
+ 40
+0.0
+ 9
+$USERR5
+ 40
+0.0
+ 9
+$WORLDVIEW
+ 70
+1
+ 9
+$SHADEDGE
+ 70
+3
+ 9
+$SHADEDIF
+ 70
+70
+ 9
+$TILEMODE
+ 70
+1
+ 9
+$MAXACTVP
+ 70
+64
+ 9
+$PINSBASE
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PLIMCHECK
+ 70
+0
+ 9
+$PEXTMIN
+ 10
+1e+20
+ 20
+1e+20
+ 30
+1e+20
+ 9
+$PEXTMAX
+ 10
+-1e+20
+ 20
+-1e+20
+ 30
+-1e+20
+ 9
+$PLIMMIN
+ 10
+-0.0
+ 20
+-0.0
+ 9
+$PLIMMAX
+ 10
+420.0
+ 20
+297.0
+ 9
+$UNITMODE
+ 70
+0
+ 9
+$VISRETAIN
+ 70
+1
+ 9
+$PLINEGEN
+ 70
+0
+ 9
+$PSLTSCALE
+ 70
+1
+ 9
+$TREEDEPTH
+ 70
+3020
+ 9
+$CMLSTYLE
+ 2
+Standard
+ 9
+$CMLJUST
+ 70
+0
+ 9
+$CMLSCALE
+ 40
+20.0
+ 9
+$PROXYGRAPHICS
+ 70
+1
+ 9
+$MEASUREMENT
+ 70
+1
+ 9
+$CELWEIGHT
+370
+-1
+ 9
+$ENDCAPS
+280
+0
+ 9
+$JOINSTYLE
+280
+0
+ 9
+$LWDISPLAY
+290
+0
+ 9
+$INSUNITS
+ 70
+4
+ 9
+$HYPERLINKBASE
+ 1
+
+ 9
+$STYLESHEET
+ 1
+
+ 9
+$XEDIT
+290
+1
+ 9
+$CEPSNTYPE
+380
+0
+ 9
+$PSTYLEMODE
+290
+1
+ 9
+$FINGERPRINTGUID
+ 2
+{6791163C-4461-4A1B-8F3F-5AD608DBCCCE}
+ 9
+$VERSIONGUID
+ 2
+{9A1CD7BF-015F-4598-B3DB-2BDBFBCC12FA}
+ 9
+$EXTNAMES
+290
+1
+ 9
+$PSVPSCALE
+ 40
+0.0
+ 9
+$OLESTARTUP
+290
+0
+ 9
+$SORTENTS
+280
+127
+ 9
+$INDEXCTL
+280
+0
+ 9
+$HIDETEXT
+280
+1
+ 9
+$XCLIPFRAME
+280
+1
+ 9
+$HALOGAP
+280
+0
+ 9
+$OBSCOLOR
+ 70
+257
+ 9
+$OBSLTYPE
+280
+0
+ 9
+$INTERSECTIONDISPLAY
+280
+0
+ 9
+$INTERSECTIONCOLOR
+ 70
+257
+ 9
+$DIMASSOC
+280
+2
+ 9
+$PROJECTNAME
+ 1
+
+ 9
+$CAMERADISPLAY
+290
+0
+ 9
+$LENSLENGTH
+ 40
+50.0
+ 9
+$CAMERAHEIGHT
+ 40
+0.0
+ 9
+$STEPSPERSEC
+ 40
+24.0
+ 9
+$STEPSIZE
+ 40
+100.0
+ 9
+$3DDWFPREC
+ 40
+2.0
+ 9
+$PSOLWIDTH
+ 40
+0.005
+ 9
+$PSOLHEIGHT
+ 40
+0.08
+ 9
+$LOFTANG1
+ 40
+1.570796326795
+ 9
+$LOFTANG2
+ 40
+1.570796326795
+ 9
+$LOFTMAG1
+ 40
+0.0
+ 9
+$LOFTMAG2
+ 40
+0.0
+ 9
+$LOFTPARAM
+ 70
+7
+ 9
+$LOFTNORMALS
+280
+1
+ 9
+$LATITUDE
+ 40
+37.795
+ 9
+$LONGITUDE
+ 40
+-122.394
+ 9
+$NORTHDIRECTION
+ 40
+0.0
+ 9
+$TIMEZONE
+ 70
+-8000
+ 9
+$LIGHTGLYPHDISPLAY
+280
+1
+ 9
+$TILEMODELIGHTSYNCH
+280
+1
+ 9
+$CMATERIAL
+347
+20
+ 9
+$SOLIDHIST
+280
+0
+ 9
+$SHOWHIST
+280
+1
+ 9
+$DWFFRAME
+280
+2
+ 9
+$DGNFRAME
+280
+2
+ 9
+$REALWORLDSCALE
+290
+1
+ 9
+$INTERFERECOLOR
+ 62
+256
+ 9
+$CSHADOW
+280
+0
+ 9
+$SHADOWPLANELOCATION
+ 40
+0.0
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+CLASSES
+ 0
+CLASS
+ 1
+ACDBDICTIONARYWDFLT
+ 2
+AcDbDictionaryWithDefault
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+SUN
+ 2
+AcDbSun
+ 3
+SCENEOE
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+VISUALSTYLE
+ 2
+AcDbVisualStyle
+ 3
+ObjectDBX Classes
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MATERIAL
+ 2
+AcDbMaterial
+ 3
+ObjectDBX Classes
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+SCALE
+ 2
+AcDbScale
+ 3
+ObjectDBX Classes
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+TABLESTYLE
+ 2
+AcDbTableStyle
+ 3
+ObjectDBX Classes
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MLEADERSTYLE
+ 2
+AcDbMLeaderStyle
+ 3
+ACDB_MLEADERSTYLE_CLASS
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+DICTIONARYVAR
+ 2
+AcDbDictionaryVar
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+CELLSTYLEMAP
+ 2
+AcDbCellStyleMap
+ 3
+ObjectDBX Classes
+ 90
+1152
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MENTALRAYRENDERSETTINGS
+ 2
+AcDbMentalRayRenderSettings
+ 3
+SCENEOE
+ 90
+1024
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBDETAILVIEWSTYLE
+ 2
+AcDbDetailViewStyle
+ 3
+ObjectDBX Classes
+ 90
+1025
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBSECTIONVIEWSTYLE
+ 2
+AcDbSectionViewStyle
+ 3
+ObjectDBX Classes
+ 90
+1025
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+RASTERVARIABLES
+ 2
+AcDbRasterVariables
+ 3
+ISM
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBPLACEHOLDER
+ 2
+AcDbPlaceHolder
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+LAYOUT
+ 2
+AcDbLayout
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+TABLES
+ 0
+TABLE
+ 2
+VPORT
+ 5
+8
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+ 0
+VPORT
+ 5
+23
+330
+8
+100
+AcDbSymbolTableRecord
+100
+AcDbViewportTableRecord
+ 2
+*Active
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 11
+1.0
+ 21
+1.0
+ 12
+0.0
+ 22
+0.0
+ 13
+0.0
+ 23
+0.0
+ 14
+0.5
+ 24
+0.5
+ 15
+0.5
+ 25
+0.5
+ 16
+0.0
+ 26
+0.0
+ 36
+1.0
+ 17
+0.0
+ 27
+0.0
+ 37
+0.0
+ 40
+1000.0
+ 41
+1.34
+ 42
+50.0
+ 43
+0.0
+ 44
+0.0
+ 50
+0.0
+ 51
+0.0
+ 71
+0
+ 72
+1000
+ 73
+1
+ 74
+3
+ 75
+0
+ 76
+0
+ 77
+0
+ 78
+0
+281
+0
+ 65
+0
+146
+0.0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+LTYPE
+ 5
+2
+330
+0
+100
+AcDbSymbolTable
+ 70
+8
+ 0
+LTYPE
+ 5
+24
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+ByBlock
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+25
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+ByLayer
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+26
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+Continuous
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+2F
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+CENTER
+ 70
+0
+ 3
+Center ____ _ ____ _ ____ _ ____ _ ____ _ ____
+ 72
+65
+ 73
+4
+ 40
+2.0
+ 49
+1.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 0
+LTYPE
+ 5
+30
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+HIDDEN
+ 70
+0
+ 3
+Hidden __ __ __ __ __ __ __ __ __
+ 72
+65
+ 73
+2
+ 40
+7.5
+ 49
+5.0
+ 74
+0
+ 49
+-2.5
+ 74
+0
+ 0
+LTYPE
+ 5
+31
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+PHANTOM
+ 70
+0
+ 3
+Phantom ______ __ __ ______ __ __ ______
+ 72
+65
+ 73
+6
+ 40
+2.5
+ 49
+1.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 0
+LTYPE
+ 5
+32
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+DASHED
+ 70
+0
+ 3
+Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _
+ 72
+65
+ 73
+2
+ 40
+0.6
+ 49
+0.5
+ 74
+0
+ 49
+-0.1
+ 74
+0
+ 0
+LTYPE
+ 5
+33
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+DASHDOT
+ 70
+0
+ 3
+Dash dot __ . __ . __ . __ . __ . __ . __ . __
+ 72
+65
+ 73
+4
+ 40
+1.4
+ 49
+1.0
+ 74
+0
+ 49
+-0.2
+ 74
+0
+ 49
+0.0
+ 74
+0
+ 49
+-0.2
+ 74
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+LAYER
+ 5
+1
+330
+0
+100
+AcDbSymbolTable
+ 70
+11
+ 0
+LAYER
+ 5
+27
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+0
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+-3
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+28
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+Defpoints
+ 70
+0
+ 62
+7
+ 6
+Continuous
+290
+0
+370
+-3
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+34
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+GEOMETRY
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+50
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+35
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+HIDDEN
+ 70
+0
+ 62
+3
+ 6
+HIDDEN
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+36
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+CENTER
+ 70
+0
+ 62
+1
+ 6
+CENTER
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+37
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+PHANTOM
+ 70
+0
+ 62
+6
+ 6
+PHANTOM
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+38
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+DIM
+ 70
+0
+ 62
+2
+ 6
+Continuous
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+39
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+TEXT
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3A
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+HATCH
+ 70
+0
+ 62
+8
+ 6
+Continuous
+370
+13
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3B
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+TITLEBLOCK
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+50
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3C
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+CONSTRUCTION
+ 70
+0
+ 62
+250
+ 6
+Continuous
+370
+5
+390
+13
+347
+21
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+STYLE
+ 5
+5
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+ 0
+STYLE
+ 5
+29
+330
+5
+100
+AcDbSymbolTableRecord
+100
+AcDbTextStyleTableRecord
+ 2
+Standard
+ 70
+0
+ 40
+0.0
+ 41
+1.0
+ 50
+0.0
+ 71
+0
+ 42
+2.5
+ 3
+txt
+ 4
+
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+VIEW
+ 5
+7
+330
+0
+100
+AcDbSymbolTable
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+UCS
+ 5
+6
+330
+0
+100
+AcDbSymbolTable
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+APPID
+ 5
+3
+330
+0
+100
+AcDbSymbolTable
+ 70
+3
+ 0
+APPID
+ 5
+2A
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+ACAD
+ 70
+0
+ 0
+APPID
+ 5
+5F
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+HATCHBACKGROUNDCOLOR
+ 70
+0
+ 0
+APPID
+ 5
+60
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+EZDXF
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+DIMSTYLE
+ 5
+4
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+100
+AcDbDimStyleTable
+ 0
+DIMSTYLE
+105
+2B
+330
+4
+100
+AcDbSymbolTableRecord
+100
+AcDbDimStyleTableRecord
+ 2
+Standard
+ 70
+0
+ 40
+1.0
+ 41
+2.5
+ 42
+0.625
+ 43
+3.75
+ 44
+1.25
+ 45
+0.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+2.5
+140
+2.5
+141
+2.5
+142
+0.0
+143
+0.03937007874
+144
+1.0
+145
+0.0
+146
+1.0
+147
+0.625
+148
+0.0
+ 69
+0
+ 70
+0
+ 71
+0
+ 72
+0
+ 73
+0
+ 74
+0
+ 75
+0
+ 76
+0
+ 77
+1
+ 78
+8
+ 79
+3
+170
+0
+171
+3
+172
+1
+173
+0
+174
+0
+175
+0
+176
+0
+177
+0
+178
+0
+179
+2
+271
+2
+272
+2
+273
+2
+274
+3
+275
+0
+276
+0
+277
+2
+278
+46
+279
+0
+280
+0
+281
+0
+282
+0
+283
+0
+284
+8
+285
+0
+286
+0
+288
+0
+289
+3
+290
+0
+371
+-2
+372
+-2
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+BLOCK_RECORD
+ 5
+9
+330
+0
+100
+AcDbSymbolTable
+ 70
+2
+ 0
+BLOCK_RECORD
+ 5
+17
+330
+9
+100
+AcDbSymbolTableRecord
+100
+AcDbBlockTableRecord
+ 2
+*Model_Space
+340
+1A
+ 70
+0
+280
+1
+281
+0
+ 0
+BLOCK_RECORD
+ 5
+1B
+330
+9
+100
+AcDbSymbolTableRecord
+100
+AcDbBlockTableRecord
+ 2
+*Paper_Space
+340
+1E
+ 70
+0
+280
+1
+281
+0
+ 0
+ENDTAB
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+BLOCKS
+ 0
+BLOCK
+ 5
+18
+330
+17
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockBegin
+ 2
+*Model_Space
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 3
+*Model_Space
+ 1
+
+ 0
+ENDBLK
+ 5
+19
+330
+17
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockEnd
+ 0
+BLOCK
+ 5
+1C
+330
+1B
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockBegin
+ 2
+*Paper_Space
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 3
+*Paper_Space
+ 1
+
+ 0
+ENDBLK
+ 5
+1D
+330
+1B
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockEnd
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+ENTITIES
+ 0
+LWPOLYLINE
+ 5
+3D
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbPolyline
+ 90
+4
+ 70
+1
+ 10
+0.0
+ 20
+0.0
+ 10
+420.0
+ 20
+0.0
+ 10
+420.0
+ 20
+297.0
+ 10
+0.0
+ 20
+297.0
+ 0
+LWPOLYLINE
+ 5
+3E
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbPolyline
+ 90
+4
+ 70
+1
+ 10
+20.0
+ 20
+10.0
+ 10
+410.0
+ 20
+10.0
+ 10
+410.0
+ 20
+287.0
+ 10
+20.0
+ 20
+287.0
+ 0
+LWPOLYLINE
+ 5
+3F
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbPolyline
+ 90
+4
+ 70
+1
+ 10
+230.0
+ 20
+10.0
+ 10
+410.0
+ 20
+10.0
+ 10
+410.0
+ 20
+70.0
+ 10
+230.0
+ 20
+70.0
+ 0
+LINE
+ 5
+40
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+230.0
+ 20
+20.0
+ 30
+0.0
+ 11
+410.0
+ 21
+20.0
+ 31
+0.0
+ 0
+LINE
+ 5
+41
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+230.0
+ 20
+35.0
+ 30
+0.0
+ 11
+410.0
+ 21
+35.0
+ 31
+0.0
+ 0
+LINE
+ 5
+42
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+230.0
+ 20
+50.0
+ 30
+0.0
+ 11
+410.0
+ 21
+50.0
+ 31
+0.0
+ 0
+LINE
+ 5
+43
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+320.0
+ 20
+35.0
+ 30
+0.0
+ 11
+320.0
+ 21
+50.0
+ 31
+0.0
+ 0
+LINE
+ 5
+44
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+365.0
+ 20
+35.0
+ 30
+0.0
+ 11
+365.0
+ 21
+50.0
+ 31
+0.0
+ 0
+LINE
+ 5
+45
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+290.0
+ 20
+20.0
+ 30
+0.0
+ 11
+290.0
+ 21
+35.0
+ 31
+0.0
+ 0
+LINE
+ 5
+46
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+350.0
+ 20
+20.0
+ 30
+0.0
+ 11
+350.0
+ 21
+35.0
+ 31
+0.0
+ 0
+LINE
+ 5
+47
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+275.0
+ 20
+10.0
+ 30
+0.0
+ 11
+275.0
+ 21
+20.0
+ 31
+0.0
+ 0
+LINE
+ 5
+48
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+320.0
+ 20
+10.0
+ 30
+0.0
+ 11
+320.0
+ 21
+20.0
+ 31
+0.0
+ 0
+LINE
+ 5
+49
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+365.0
+ 20
+10.0
+ 30
+0.0
+ 11
+365.0
+ 21
+20.0
+ 31
+0.0
+ 0
+TEXT
+ 5
+4A
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+275.0
+ 20
+57.5
+ 30
+0.0
+ 40
+5.0
+ 1
+ISO A3 MECHANICAL PART DRAWING
+100
+AcDbText
+ 0
+TEXT
+ 5
+4B
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+231.5
+ 20
+46.5
+ 30
+0.0
+ 40
+2.0
+ 1
+DWG NO
+100
+AcDbText
+ 0
+TEXT
+ 5
+4C
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+233.0
+ 20
+40.0
+ 30
+0.0
+ 40
+3.5
+ 1
+TEMPLATE
+100
+AcDbText
+ 0
+TEXT
+ 5
+4D
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+321.5
+ 20
+46.5
+ 30
+0.0
+ 40
+2.0
+ 1
+REV
+100
+AcDbText
+ 0
+TEXT
+ 5
+4E
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+323.0
+ 20
+40.0
+ 30
+0.0
+ 40
+3.5
+ 1
+A
+100
+AcDbText
+ 0
+TEXT
+ 5
+4F
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+366.5
+ 20
+46.5
+ 30
+0.0
+ 40
+2.0
+ 1
+SHEET
+100
+AcDbText
+ 0
+TEXT
+ 5
+50
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+368.0
+ 20
+40.0
+ 30
+0.0
+ 40
+3.5
+ 1
+1/1
+100
+AcDbText
+ 0
+TEXT
+ 5
+51
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+231.5
+ 20
+31.5
+ 30
+0.0
+ 40
+2.0
+ 1
+PART NO
+100
+AcDbText
+ 0
+TEXT
+ 5
+52
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+233.0
+ 20
+25.0
+ 30
+0.0
+ 40
+3.5
+ 1
+
+100
+AcDbText
+ 0
+TEXT
+ 5
+53
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+291.5
+ 20
+31.5
+ 30
+0.0
+ 40
+2.0
+ 1
+MATERIAL
+100
+AcDbText
+ 0
+TEXT
+ 5
+54
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+293.0
+ 20
+25.0
+ 30
+0.0
+ 40
+3.5
+ 1
+
+100
+AcDbText
+ 0
+TEXT
+ 5
+55
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+351.5
+ 20
+31.5
+ 30
+0.0
+ 40
+2.0
+ 1
+SCALE
+100
+AcDbText
+ 0
+TEXT
+ 5
+56
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+353.0
+ 20
+25.0
+ 30
+0.0
+ 40
+3.5
+ 1
+1:1
+100
+AcDbText
+ 0
+TEXT
+ 5
+57
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+231.5
+ 20
+16.5
+ 30
+0.0
+ 40
+2.0
+ 1
+DRAWN
+100
+AcDbText
+ 0
+TEXT
+ 5
+58
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+233.0
+ 20
+11.5
+ 30
+0.0
+ 40
+3.5
+ 1
+
+100
+AcDbText
+ 0
+TEXT
+ 5
+59
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+276.5
+ 20
+16.5
+ 30
+0.0
+ 40
+2.0
+ 1
+CHECKED
+100
+AcDbText
+ 0
+TEXT
+ 5
+5A
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+278.0
+ 20
+11.5
+ 30
+0.0
+ 40
+3.5
+ 1
+
+100
+AcDbText
+ 0
+TEXT
+ 5
+5B
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+321.5
+ 20
+16.5
+ 30
+0.0
+ 40
+2.0
+ 1
+DATE
+100
+AcDbText
+ 0
+TEXT
+ 5
+5C
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+323.0
+ 20
+11.5
+ 30
+0.0
+ 40
+3.5
+ 1
+
+100
+AcDbText
+ 0
+TEXT
+ 5
+5D
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+366.5
+ 20
+16.5
+ 30
+0.0
+ 40
+2.0
+ 1
+COMPANY
+100
+AcDbText
+ 0
+TEXT
+ 5
+5E
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+368.0
+ 20
+11.5
+ 30
+0.0
+ 40
+3.5
+ 1
+Anka-Makine
+100
+AcDbText
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+OBJECTS
+ 0
+DICTIONARY
+ 5
+A
+330
+0
+100
+AcDbDictionary
+281
+1
+ 3
+ACAD_COLOR
+350
+B
+ 3
+ACAD_GROUP
+350
+C
+ 3
+ACAD_LAYOUT
+350
+D
+ 3
+ACAD_MATERIAL
+350
+E
+ 3
+ACAD_MLEADERSTYLE
+350
+F
+ 3
+ACAD_MLINESTYLE
+350
+10
+ 3
+ACAD_PLOTSETTINGS
+350
+11
+ 3
+ACAD_PLOTSTYLENAME
+350
+12
+ 3
+ACAD_SCALELIST
+350
+14
+ 3
+ACAD_TABLESTYLE
+350
+15
+ 3
+ACAD_VISUALSTYLE
+350
+16
+ 3
+EZDXF_META
+350
+2D
+ 0
+DICTIONARY
+ 5
+B
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+C
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+D
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Model
+350
+1A
+ 3
+A3
+350
+1E
+ 0
+DICTIONARY
+ 5
+E
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+ByBlock
+350
+1F
+ 3
+ByLayer
+350
+20
+ 3
+Global
+350
+21
+ 0
+DICTIONARY
+ 5
+F
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Standard
+350
+2C
+ 0
+DICTIONARY
+ 5
+10
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Standard
+350
+22
+ 0
+DICTIONARY
+ 5
+11
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+ACDBDICTIONARYWDFLT
+ 5
+12
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Normal
+350
+13
+100
+AcDbDictionaryWithDefault
+340
+13
+ 0
+ACDBPLACEHOLDER
+ 5
+13
+330
+12
+ 0
+DICTIONARY
+ 5
+14
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+15
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+16
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+LAYOUT
+ 5
+1A
+330
+D
+100
+AcDbPlotSettings
+ 1
+
+ 4
+A3
+ 6
+
+ 40
+7.5
+ 41
+20.0
+ 42
+7.5
+ 43
+20.0
+ 44
+420.0
+ 45
+297.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+0.0
+140
+0.0
+141
+0.0
+142
+1.0
+143
+1.0
+ 70
+1024
+ 72
+1
+ 73
+0
+ 74
+5
+ 7
+
+ 75
+16
+ 76
+0
+ 77
+2
+ 78
+300
+147
+1.0
+148
+0.0
+149
+0.0
+100
+AcDbLayout
+ 1
+Model
+ 70
+1
+ 71
+0
+ 10
+0.0
+ 20
+0.0
+ 11
+420.0
+ 21
+297.0
+ 12
+0.0
+ 22
+0.0
+ 32
+0.0
+ 14
+1e+20
+ 24
+1e+20
+ 34
+1e+20
+ 15
+-1e+20
+ 25
+-1e+20
+ 35
+-1e+20
+146
+0.0
+ 13
+0.0
+ 23
+0.0
+ 33
+0.0
+ 16
+1.0
+ 26
+0.0
+ 36
+0.0
+ 17
+0.0
+ 27
+1.0
+ 37
+0.0
+ 76
+1
+330
+17
+ 0
+LAYOUT
+ 5
+1E
+330
+D
+100
+AcDbPlotSettings
+ 1
+
+ 2
+DWG To PDF.pc3
+ 4
+ISO_A3_(420.00_x_297.00_MM)
+ 6
+
+ 40
+0.0
+ 41
+0.0
+ 42
+0.0
+ 43
+0.0
+ 44
+420.0
+ 45
+297.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+0.0
+140
+0.0
+141
+0.0
+142
+1.0
+143
+1.0
+ 70
+688
+ 72
+1
+ 73
+0
+ 74
+5
+ 7
+monochrome.ctb
+ 75
+16
+ 76
+0
+ 77
+2
+ 78
+300
+147
+1.0
+148
+0.0
+149
+0.0
+100
+AcDbLayout
+ 1
+A3
+ 70
+1
+ 71
+1
+ 10
+-0.0
+ 20
+-0.0
+ 11
+420.0
+ 21
+297.0
+ 12
+0.0
+ 22
+0.0
+ 32
+0.0
+ 14
+1e+20
+ 24
+1e+20
+ 34
+1e+20
+ 15
+-1e+20
+ 25
+-1e+20
+ 35
+-1e+20
+146
+0.0
+ 13
+0.0
+ 23
+0.0
+ 33
+0.0
+ 16
+1.0
+ 26
+0.0
+ 36
+0.0
+ 17
+0.0
+ 27
+1.0
+ 37
+0.0
+ 76
+1
+330
+1B
+ 0
+MATERIAL
+ 5
+1F
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+ByBlock
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MATERIAL
+ 5
+20
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+ByLayer
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MATERIAL
+ 5
+21
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+Global
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MLINESTYLE
+ 5
+22
+102
+{ACAD_REACTORS
+330
+10
+102
+}
+330
+10
+100
+AcDbMlineStyle
+ 2
+Standard
+ 70
+0
+ 3
+
+ 62
+256
+ 51
+90.0
+ 52
+90.0
+ 71
+2
+ 49
+0.5
+ 62
+256
+ 6
+BYLAYER
+ 49
+-0.5
+ 62
+256
+ 6
+BYLAYER
+ 0
+MLEADERSTYLE
+ 5
+2C
+102
+{ACAD_REACTORS
+330
+F
+102
+}
+330
+F
+100
+AcDbMLeaderStyle
+179
+2
+170
+2
+171
+1
+172
+0
+ 90
+2
+ 40
+0.0
+ 41
+0.0
+173
+1
+ 91
+-1056964608
+ 92
+-2
+290
+1
+ 42
+2.0
+291
+1
+ 43
+8.0
+ 3
+Standard
+ 44
+4.0
+300
+
+342
+29
+174
+1
+175
+1
+176
+0
+178
+1
+ 93
+-1056964608
+ 45
+4.0
+292
+0
+297
+0
+ 46
+4.0
+ 94
+-1056964608
+ 47
+1.0
+ 49
+1.0
+140
+1.0
+294
+1
+141
+0.0
+177
+0
+142
+1.0
+295
+0
+296
+0
+143
+3.75
+271
+0
+272
+9
+273
+9
+ 0
+DICTIONARY
+ 5
+2D
+330
+A
+100
+AcDbDictionary
+280
+1
+281
+1
+ 3
+CREATED_BY_EZDXF
+350
+2E
+ 3
+WRITTEN_BY_EZDXF
+350
+61
+ 0
+DICTIONARYVAR
+ 5
+2E
+330
+2D
+100
+DictionaryVariables
+280
+0
+ 1
+1.4.4 @ 2026-09-21T19:25:45.743145+00:00
+ 0
+DICTIONARYVAR
+ 5
+61
+330
+2D
+100
+DictionaryVariables
+280
+0
+ 1
+1.4.4 @ 2026-09-21T19:25:45.772937+00:00
+ 0
+ENDSEC
+ 0
+EOF
diff --git a/templates/iso_a3_pid.dxf b/templates/iso_a3_pid.dxf
new file mode 100644
index 0000000..1bed4fb
--- /dev/null
+++ b/templates/iso_a3_pid.dxf
@@ -0,0 +1,4520 @@
+ 0
+SECTION
+ 2
+HEADER
+ 9
+$ACADVER
+ 1
+AC1024
+ 9
+$ACADMAINTVER
+ 70
+6
+ 9
+$DWGCODEPAGE
+ 3
+ANSI_1252
+ 9
+$LASTSAVEDBY
+ 1
+ezdxf
+ 9
+$INSBASE
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$EXTMIN
+ 10
+1e+20
+ 20
+1e+20
+ 30
+1e+20
+ 9
+$EXTMAX
+ 10
+-1e+20
+ 20
+-1e+20
+ 30
+-1e+20
+ 9
+$LIMMIN
+ 10
+0.0
+ 20
+0.0
+ 9
+$LIMMAX
+ 10
+420.0
+ 20
+297.0
+ 9
+$ORTHOMODE
+ 70
+0
+ 9
+$REGENMODE
+ 70
+1
+ 9
+$FILLMODE
+ 70
+1
+ 9
+$QTEXTMODE
+ 70
+0
+ 9
+$MIRRTEXT
+ 70
+1
+ 9
+$LTSCALE
+ 40
+1.0
+ 9
+$ATTMODE
+ 70
+1
+ 9
+$TEXTSIZE
+ 40
+2.5
+ 9
+$TRACEWID
+ 40
+1.0
+ 9
+$TEXTSTYLE
+ 7
+Standard
+ 9
+$CLAYER
+ 8
+0
+ 9
+$CELTYPE
+ 6
+ByLayer
+ 9
+$CECOLOR
+ 62
+256
+ 9
+$CELTSCALE
+ 40
+1.0
+ 9
+$DISPSILH
+ 70
+0
+ 9
+$DIMSCALE
+ 40
+1.0
+ 9
+$DIMASZ
+ 40
+2.5
+ 9
+$DIMEXO
+ 40
+0.625
+ 9
+$DIMDLI
+ 40
+3.75
+ 9
+$DIMRND
+ 40
+0.0
+ 9
+$DIMDLE
+ 40
+0.0
+ 9
+$DIMEXE
+ 40
+1.25
+ 9
+$DIMTP
+ 40
+0.0
+ 9
+$DIMTM
+ 40
+0.0
+ 9
+$DIMTXT
+ 40
+2.5
+ 9
+$DIMCEN
+ 40
+2.5
+ 9
+$DIMTSZ
+ 40
+0.0
+ 9
+$DIMTOL
+ 70
+0
+ 9
+$DIMLIM
+ 70
+0
+ 9
+$DIMTIH
+ 70
+0
+ 9
+$DIMTOH
+ 70
+0
+ 9
+$DIMSE1
+ 70
+0
+ 9
+$DIMSE2
+ 70
+0
+ 9
+$DIMTAD
+ 70
+1
+ 9
+$DIMZIN
+ 70
+8
+ 9
+$DIMBLK
+ 1
+
+ 9
+$DIMASO
+ 70
+1
+ 9
+$DIMSHO
+ 70
+1
+ 9
+$DIMPOST
+ 1
+
+ 9
+$DIMAPOST
+ 1
+
+ 9
+$DIMALT
+ 70
+0
+ 9
+$DIMALTD
+ 70
+3
+ 9
+$DIMALTF
+ 40
+0.03937007874
+ 9
+$DIMLFAC
+ 40
+1.0
+ 9
+$DIMTOFL
+ 70
+1
+ 9
+$DIMTVP
+ 40
+0.0
+ 9
+$DIMTIX
+ 70
+0
+ 9
+$DIMSOXD
+ 70
+0
+ 9
+$DIMSAH
+ 70
+0
+ 9
+$DIMBLK1
+ 1
+
+ 9
+$DIMBLK2
+ 1
+
+ 9
+$DIMSTYLE
+ 2
+ISO-25
+ 9
+$DIMCLRD
+ 70
+0
+ 9
+$DIMCLRE
+ 70
+0
+ 9
+$DIMCLRT
+ 70
+0
+ 9
+$DIMTFAC
+ 40
+1.0
+ 9
+$DIMGAP
+ 40
+0.625
+ 9
+$DIMJUST
+ 70
+0
+ 9
+$DIMSD1
+ 70
+0
+ 9
+$DIMSD2
+ 70
+0
+ 9
+$DIMTOLJ
+ 70
+0
+ 9
+$DIMTZIN
+ 70
+8
+ 9
+$DIMALTZ
+ 70
+0
+ 9
+$DIMALTTZ
+ 70
+0
+ 9
+$DIMUPT
+ 70
+0
+ 9
+$DIMDEC
+ 70
+2
+ 9
+$DIMTDEC
+ 70
+2
+ 9
+$DIMALTU
+ 70
+2
+ 9
+$DIMALTTD
+ 70
+3
+ 9
+$DIMTXSTY
+ 7
+Standard
+ 9
+$DIMAUNIT
+ 70
+0
+ 9
+$DIMADEC
+ 70
+0
+ 9
+$DIMALTRND
+ 40
+0.0
+ 9
+$DIMAZIN
+ 70
+0
+ 9
+$DIMDSEP
+ 70
+44
+ 9
+$DIMATFIT
+ 70
+3
+ 9
+$DIMFRAC
+ 70
+0
+ 9
+$DIMLDRBLK
+ 1
+
+ 9
+$DIMLUNIT
+ 70
+2
+ 9
+$DIMLWD
+ 70
+-2
+ 9
+$DIMLWE
+ 70
+-2
+ 9
+$DIMTMOVE
+ 70
+0
+ 9
+$DIMFXL
+ 40
+1.0
+ 9
+$DIMFXLON
+ 70
+0
+ 9
+$DIMJOGANG
+ 40
+0.785398163397
+ 9
+$DIMTFILL
+ 70
+0
+ 9
+$DIMTFILLCLR
+ 70
+0
+ 9
+$DIMARCSYM
+ 70
+0
+ 9
+$DIMLTYPE
+ 6
+
+ 9
+$DIMLTEX1
+ 6
+
+ 9
+$DIMLTEX2
+ 6
+
+ 9
+$DIMTXTDIRECTION
+ 70
+0
+ 9
+$LUNITS
+ 70
+2
+ 9
+$LUPREC
+ 70
+2
+ 9
+$SKETCHINC
+ 40
+1.0
+ 9
+$FILLETRAD
+ 40
+10.0
+ 9
+$AUNITS
+ 70
+0
+ 9
+$AUPREC
+ 70
+0
+ 9
+$MENU
+ 1
+.
+ 9
+$ELEVATION
+ 40
+0.0
+ 9
+$PELEVATION
+ 40
+0.0
+ 9
+$THICKNESS
+ 40
+0.0
+ 9
+$LIMCHECK
+ 70
+0
+ 9
+$CHAMFERA
+ 40
+0.0
+ 9
+$CHAMFERB
+ 40
+0.0
+ 9
+$CHAMFERC
+ 40
+0.0
+ 9
+$CHAMFERD
+ 40
+0.0
+ 9
+$SKPOLY
+ 70
+0
+ 9
+$TDCREATE
+ 40
+2461305.9345486113
+ 9
+$TDUCREATE
+ 40
+2458532.153996898
+ 9
+$TDUPDATE
+ 40
+2461305.9345486113
+ 9
+$TDUUPDATE
+ 40
+2458532.1544311
+ 9
+$TDINDWG
+ 40
+0.0
+ 9
+$TDUSRTIMER
+ 40
+0.0
+ 9
+$USRTIMER
+ 70
+1
+ 9
+$ANGBASE
+ 50
+0.0
+ 9
+$ANGDIR
+ 70
+0
+ 9
+$PDMODE
+ 70
+0
+ 9
+$PDSIZE
+ 40
+0.0
+ 9
+$PLINEWID
+ 40
+0.0
+ 9
+$SPLFRAME
+ 70
+0
+ 9
+$SPLINETYPE
+ 70
+6
+ 9
+$SPLINESEGS
+ 70
+8
+ 9
+$HANDSEED
+ 5
+69
+ 9
+$SURFTAB1
+ 70
+6
+ 9
+$SURFTAB2
+ 70
+6
+ 9
+$SURFTYPE
+ 70
+6
+ 9
+$SURFU
+ 70
+6
+ 9
+$SURFV
+ 70
+6
+ 9
+$UCSBASE
+ 2
+
+ 9
+$UCSNAME
+ 2
+
+ 9
+$UCSORG
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSXDIR
+ 10
+1.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSYDIR
+ 10
+0.0
+ 20
+1.0
+ 30
+0.0
+ 9
+$UCSORTHOREF
+ 2
+
+ 9
+$UCSORTHOVIEW
+ 70
+0
+ 9
+$UCSORGTOP
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGBOTTOM
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGLEFT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGRIGHT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGFRONT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$UCSORGBACK
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSBASE
+ 2
+
+ 9
+$PUCSNAME
+ 2
+
+ 9
+$PUCSORG
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSXDIR
+ 10
+1.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSYDIR
+ 10
+0.0
+ 20
+1.0
+ 30
+0.0
+ 9
+$PUCSORTHOREF
+ 2
+
+ 9
+$PUCSORTHOVIEW
+ 70
+0
+ 9
+$PUCSORGTOP
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGBOTTOM
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGLEFT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGRIGHT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGFRONT
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PUCSORGBACK
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$USERI1
+ 70
+0
+ 9
+$USERI2
+ 70
+0
+ 9
+$USERI3
+ 70
+0
+ 9
+$USERI4
+ 70
+0
+ 9
+$USERI5
+ 70
+0
+ 9
+$USERR1
+ 40
+0.0
+ 9
+$USERR2
+ 40
+0.0
+ 9
+$USERR3
+ 40
+0.0
+ 9
+$USERR4
+ 40
+0.0
+ 9
+$USERR5
+ 40
+0.0
+ 9
+$WORLDVIEW
+ 70
+1
+ 9
+$SHADEDGE
+ 70
+3
+ 9
+$SHADEDIF
+ 70
+70
+ 9
+$TILEMODE
+ 70
+1
+ 9
+$MAXACTVP
+ 70
+64
+ 9
+$PINSBASE
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 9
+$PLIMCHECK
+ 70
+0
+ 9
+$PEXTMIN
+ 10
+1e+20
+ 20
+1e+20
+ 30
+1e+20
+ 9
+$PEXTMAX
+ 10
+-1e+20
+ 20
+-1e+20
+ 30
+-1e+20
+ 9
+$PLIMMIN
+ 10
+-0.0
+ 20
+-0.0
+ 9
+$PLIMMAX
+ 10
+420.0
+ 20
+297.0
+ 9
+$UNITMODE
+ 70
+0
+ 9
+$VISRETAIN
+ 70
+1
+ 9
+$PLINEGEN
+ 70
+0
+ 9
+$PSLTSCALE
+ 70
+1
+ 9
+$TREEDEPTH
+ 70
+3020
+ 9
+$CMLSTYLE
+ 2
+Standard
+ 9
+$CMLJUST
+ 70
+0
+ 9
+$CMLSCALE
+ 40
+20.0
+ 9
+$PROXYGRAPHICS
+ 70
+1
+ 9
+$MEASUREMENT
+ 70
+1
+ 9
+$CELWEIGHT
+370
+-1
+ 9
+$ENDCAPS
+280
+0
+ 9
+$JOINSTYLE
+280
+0
+ 9
+$LWDISPLAY
+290
+0
+ 9
+$INSUNITS
+ 70
+4
+ 9
+$HYPERLINKBASE
+ 1
+
+ 9
+$STYLESHEET
+ 1
+
+ 9
+$XEDIT
+290
+1
+ 9
+$CEPSNTYPE
+380
+0
+ 9
+$PSTYLEMODE
+290
+1
+ 9
+$FINGERPRINTGUID
+ 2
+{B5F8B467-F9F9-4DC6-8A95-A266D8246CA8}
+ 9
+$VERSIONGUID
+ 2
+{F759C60A-BBAF-4155-A187-77BF78127E81}
+ 9
+$EXTNAMES
+290
+1
+ 9
+$PSVPSCALE
+ 40
+0.0
+ 9
+$OLESTARTUP
+290
+0
+ 9
+$SORTENTS
+280
+127
+ 9
+$INDEXCTL
+280
+0
+ 9
+$HIDETEXT
+280
+1
+ 9
+$XCLIPFRAME
+280
+1
+ 9
+$HALOGAP
+280
+0
+ 9
+$OBSCOLOR
+ 70
+257
+ 9
+$OBSLTYPE
+280
+0
+ 9
+$INTERSECTIONDISPLAY
+280
+0
+ 9
+$INTERSECTIONCOLOR
+ 70
+257
+ 9
+$DIMASSOC
+280
+2
+ 9
+$PROJECTNAME
+ 1
+
+ 9
+$CAMERADISPLAY
+290
+0
+ 9
+$LENSLENGTH
+ 40
+50.0
+ 9
+$CAMERAHEIGHT
+ 40
+0.0
+ 9
+$STEPSPERSEC
+ 40
+24.0
+ 9
+$STEPSIZE
+ 40
+100.0
+ 9
+$3DDWFPREC
+ 40
+2.0
+ 9
+$PSOLWIDTH
+ 40
+0.005
+ 9
+$PSOLHEIGHT
+ 40
+0.08
+ 9
+$LOFTANG1
+ 40
+1.570796326795
+ 9
+$LOFTANG2
+ 40
+1.570796326795
+ 9
+$LOFTMAG1
+ 40
+0.0
+ 9
+$LOFTMAG2
+ 40
+0.0
+ 9
+$LOFTPARAM
+ 70
+7
+ 9
+$LOFTNORMALS
+280
+1
+ 9
+$LATITUDE
+ 40
+37.795
+ 9
+$LONGITUDE
+ 40
+-122.394
+ 9
+$NORTHDIRECTION
+ 40
+0.0
+ 9
+$TIMEZONE
+ 70
+-8000
+ 9
+$LIGHTGLYPHDISPLAY
+280
+1
+ 9
+$TILEMODELIGHTSYNCH
+280
+1
+ 9
+$CMATERIAL
+347
+20
+ 9
+$SOLIDHIST
+280
+0
+ 9
+$SHOWHIST
+280
+1
+ 9
+$DWFFRAME
+280
+2
+ 9
+$DGNFRAME
+280
+2
+ 9
+$REALWORLDSCALE
+290
+1
+ 9
+$INTERFERECOLOR
+ 62
+256
+ 9
+$CSHADOW
+280
+0
+ 9
+$SHADOWPLANELOCATION
+ 40
+0.0
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+CLASSES
+ 0
+CLASS
+ 1
+ACDBDICTIONARYWDFLT
+ 2
+AcDbDictionaryWithDefault
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+SUN
+ 2
+AcDbSun
+ 3
+SCENEOE
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+VISUALSTYLE
+ 2
+AcDbVisualStyle
+ 3
+ObjectDBX Classes
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MATERIAL
+ 2
+AcDbMaterial
+ 3
+ObjectDBX Classes
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+SCALE
+ 2
+AcDbScale
+ 3
+ObjectDBX Classes
+ 90
+1153
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+TABLESTYLE
+ 2
+AcDbTableStyle
+ 3
+ObjectDBX Classes
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MLEADERSTYLE
+ 2
+AcDbMLeaderStyle
+ 3
+ACDB_MLEADERSTYLE_CLASS
+ 90
+4095
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+DICTIONARYVAR
+ 2
+AcDbDictionaryVar
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+CELLSTYLEMAP
+ 2
+AcDbCellStyleMap
+ 3
+ObjectDBX Classes
+ 90
+1152
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+MENTALRAYRENDERSETTINGS
+ 2
+AcDbMentalRayRenderSettings
+ 3
+SCENEOE
+ 90
+1024
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBDETAILVIEWSTYLE
+ 2
+AcDbDetailViewStyle
+ 3
+ObjectDBX Classes
+ 90
+1025
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBSECTIONVIEWSTYLE
+ 2
+AcDbSectionViewStyle
+ 3
+ObjectDBX Classes
+ 90
+1025
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+RASTERVARIABLES
+ 2
+AcDbRasterVariables
+ 3
+ISM
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+ACDBPLACEHOLDER
+ 2
+AcDbPlaceHolder
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+CLASS
+ 1
+LAYOUT
+ 2
+AcDbLayout
+ 3
+ObjectDBX Classes
+ 90
+0
+ 91
+0
+280
+0
+281
+0
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+TABLES
+ 0
+TABLE
+ 2
+VPORT
+ 5
+8
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+ 0
+VPORT
+ 5
+23
+330
+8
+100
+AcDbSymbolTableRecord
+100
+AcDbViewportTableRecord
+ 2
+*Active
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 11
+1.0
+ 21
+1.0
+ 12
+0.0
+ 22
+0.0
+ 13
+0.0
+ 23
+0.0
+ 14
+0.5
+ 24
+0.5
+ 15
+0.5
+ 25
+0.5
+ 16
+0.0
+ 26
+0.0
+ 36
+1.0
+ 17
+0.0
+ 27
+0.0
+ 37
+0.0
+ 40
+1000.0
+ 41
+1.34
+ 42
+50.0
+ 43
+0.0
+ 44
+0.0
+ 50
+0.0
+ 51
+0.0
+ 71
+0
+ 72
+1000
+ 73
+1
+ 74
+3
+ 75
+0
+ 76
+0
+ 77
+0
+ 78
+0
+281
+0
+ 65
+0
+146
+0.0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+LTYPE
+ 5
+2
+330
+0
+100
+AcDbSymbolTable
+ 70
+8
+ 0
+LTYPE
+ 5
+24
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+ByBlock
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+25
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+ByLayer
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+26
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+Continuous
+ 70
+0
+ 3
+
+ 72
+65
+ 73
+0
+ 40
+0.0
+ 0
+LTYPE
+ 5
+2F
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+CENTER
+ 70
+0
+ 3
+Center ____ _ ____ _ ____ _ ____ _ ____ _ ____
+ 72
+65
+ 73
+4
+ 40
+2.0
+ 49
+1.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 0
+LTYPE
+ 5
+30
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+HIDDEN
+ 70
+0
+ 3
+Hidden __ __ __ __ __ __ __ __ __
+ 72
+65
+ 73
+2
+ 40
+7.5
+ 49
+5.0
+ 74
+0
+ 49
+-2.5
+ 74
+0
+ 0
+LTYPE
+ 5
+31
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+PHANTOM
+ 70
+0
+ 3
+Phantom ______ __ __ ______ __ __ ______
+ 72
+65
+ 73
+6
+ 40
+2.5
+ 49
+1.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 49
+0.25
+ 74
+0
+ 49
+-0.25
+ 74
+0
+ 0
+LTYPE
+ 5
+32
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+DASHED
+ 70
+0
+ 3
+Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _
+ 72
+65
+ 73
+2
+ 40
+0.6
+ 49
+0.5
+ 74
+0
+ 49
+-0.1
+ 74
+0
+ 0
+LTYPE
+ 5
+33
+330
+2
+100
+AcDbSymbolTableRecord
+100
+AcDbLinetypeTableRecord
+ 2
+DASHDOT
+ 70
+0
+ 3
+Dash dot __ . __ . __ . __ . __ . __ . __ . __
+ 72
+65
+ 73
+4
+ 40
+1.4
+ 49
+1.0
+ 74
+0
+ 49
+-0.2
+ 74
+0
+ 49
+0.0
+ 74
+0
+ 49
+-0.2
+ 74
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+LAYER
+ 5
+1
+330
+0
+100
+AcDbSymbolTable
+ 70
+18
+ 0
+LAYER
+ 5
+27
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+0
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+-3
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+28
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+Defpoints
+ 70
+0
+ 62
+7
+ 6
+Continuous
+290
+0
+370
+-3
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+34
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+PROCESS-PIPING-MAIN
+ 70
+0
+ 62
+1
+ 6
+Continuous
+370
+70
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+35
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+PROCESS-PIPING-SECONDARY
+ 70
+0
+ 62
+2
+ 6
+Continuous
+370
+50
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+36
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+PROCESS-EQUIPMENT
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+50
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+37
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+PROCESS-VALVES
+ 70
+0
+ 62
+3
+ 6
+Continuous
+370
+35
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+38
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+INSTRUMENT-SYMBOL
+ 70
+0
+ 62
+4
+ 6
+Continuous
+370
+35
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+39
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+INSTRUMENT-LINE-SIGNAL
+ 70
+0
+ 62
+4
+ 6
+DASHED
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3A
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+INSTRUMENT-TAG-TEXT
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3B
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+PROCESS-LINE-TEXT
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3C
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+ELECTRICAL-LINE
+ 70
+0
+ 62
+6
+ 6
+DASHDOT
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3D
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+UTILITY-LINE
+ 70
+0
+ 62
+5
+ 6
+PHANTOM
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3E
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+INSULATION-HATCH
+ 70
+0
+ 62
+8
+ 6
+Continuous
+370
+13
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+3F
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+DIM
+ 70
+0
+ 62
+2
+ 6
+Continuous
+370
+18
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+40
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+TEXT-NOTES
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+25
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+41
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+TITLEBLOCK
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+50
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+42
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+CONSTRUCTION
+ 70
+0
+ 62
+250
+ 6
+Continuous
+370
+5
+390
+13
+347
+21
+ 0
+LAYER
+ 5
+51
+330
+1
+100
+AcDbSymbolTableRecord
+100
+AcDbLayerTableRecord
+ 2
+TEXT
+ 70
+0
+ 62
+7
+ 6
+Continuous
+370
+-3
+390
+13
+347
+21
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+STYLE
+ 5
+5
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+ 0
+STYLE
+ 5
+29
+330
+5
+100
+AcDbSymbolTableRecord
+100
+AcDbTextStyleTableRecord
+ 2
+Standard
+ 70
+0
+ 40
+0.0
+ 41
+1.0
+ 50
+0.0
+ 71
+0
+ 42
+2.5
+ 3
+txt
+ 4
+
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+VIEW
+ 5
+7
+330
+0
+100
+AcDbSymbolTable
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+UCS
+ 5
+6
+330
+0
+100
+AcDbSymbolTable
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+APPID
+ 5
+3
+330
+0
+100
+AcDbSymbolTable
+ 70
+3
+ 0
+APPID
+ 5
+2A
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+ACAD
+ 70
+0
+ 0
+APPID
+ 5
+66
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+HATCHBACKGROUNDCOLOR
+ 70
+0
+ 0
+APPID
+ 5
+67
+330
+3
+100
+AcDbSymbolTableRecord
+100
+AcDbRegAppTableRecord
+ 2
+EZDXF
+ 70
+0
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+DIMSTYLE
+ 5
+4
+330
+0
+100
+AcDbSymbolTable
+ 70
+1
+100
+AcDbDimStyleTable
+ 0
+DIMSTYLE
+105
+2B
+330
+4
+100
+AcDbSymbolTableRecord
+100
+AcDbDimStyleTableRecord
+ 2
+Standard
+ 70
+0
+ 40
+1.0
+ 41
+2.5
+ 42
+0.625
+ 43
+3.75
+ 44
+1.25
+ 45
+0.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+2.5
+140
+2.5
+141
+2.5
+142
+0.0
+143
+0.03937007874
+144
+1.0
+145
+0.0
+146
+1.0
+147
+0.625
+148
+0.0
+ 69
+0
+ 70
+0
+ 71
+0
+ 72
+0
+ 73
+0
+ 74
+0
+ 75
+0
+ 76
+0
+ 77
+1
+ 78
+8
+ 79
+3
+170
+0
+171
+3
+172
+1
+173
+0
+174
+0
+175
+0
+176
+0
+177
+0
+178
+0
+179
+2
+271
+2
+272
+2
+273
+2
+274
+3
+275
+0
+276
+0
+277
+2
+278
+46
+279
+0
+280
+0
+281
+0
+282
+0
+283
+0
+284
+8
+285
+0
+286
+0
+288
+0
+289
+3
+290
+0
+371
+-2
+372
+-2
+ 0
+ENDTAB
+ 0
+TABLE
+ 2
+BLOCK_RECORD
+ 5
+9
+330
+0
+100
+AcDbSymbolTable
+ 70
+2
+ 0
+BLOCK_RECORD
+ 5
+17
+330
+9
+100
+AcDbSymbolTableRecord
+100
+AcDbBlockTableRecord
+ 2
+*Model_Space
+340
+1A
+ 70
+0
+280
+1
+281
+0
+ 0
+BLOCK_RECORD
+ 5
+1B
+330
+9
+100
+AcDbSymbolTableRecord
+100
+AcDbBlockTableRecord
+ 2
+*Paper_Space
+340
+1E
+ 70
+0
+280
+1
+281
+0
+ 0
+ENDTAB
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+BLOCKS
+ 0
+BLOCK
+ 5
+18
+330
+17
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockBegin
+ 2
+*Model_Space
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 3
+*Model_Space
+ 1
+
+ 0
+ENDBLK
+ 5
+19
+330
+17
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockEnd
+ 0
+BLOCK
+ 5
+1C
+330
+1B
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockBegin
+ 2
+*Paper_Space
+ 70
+0
+ 10
+0.0
+ 20
+0.0
+ 30
+0.0
+ 3
+*Paper_Space
+ 1
+
+ 0
+ENDBLK
+ 5
+1D
+330
+1B
+100
+AcDbEntity
+ 8
+0
+100
+AcDbBlockEnd
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+ENTITIES
+ 0
+LWPOLYLINE
+ 5
+43
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbPolyline
+ 90
+4
+ 70
+1
+ 10
+0.0
+ 20
+0.0
+ 10
+420.0
+ 20
+0.0
+ 10
+420.0
+ 20
+297.0
+ 10
+0.0
+ 20
+297.0
+ 0
+LWPOLYLINE
+ 5
+44
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbPolyline
+ 90
+4
+ 70
+1
+ 10
+20.0
+ 20
+10.0
+ 10
+410.0
+ 20
+10.0
+ 10
+410.0
+ 20
+287.0
+ 10
+20.0
+ 20
+287.0
+ 0
+LWPOLYLINE
+ 5
+45
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbPolyline
+ 90
+4
+ 70
+1
+ 10
+230.0
+ 20
+10.0
+ 10
+410.0
+ 20
+10.0
+ 10
+410.0
+ 20
+70.0
+ 10
+230.0
+ 20
+70.0
+ 0
+LINE
+ 5
+46
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+230.0
+ 20
+20.0
+ 30
+0.0
+ 11
+410.0
+ 21
+20.0
+ 31
+0.0
+ 0
+LINE
+ 5
+47
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+230.0
+ 20
+35.0
+ 30
+0.0
+ 11
+410.0
+ 21
+35.0
+ 31
+0.0
+ 0
+LINE
+ 5
+48
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+230.0
+ 20
+50.0
+ 30
+0.0
+ 11
+410.0
+ 21
+50.0
+ 31
+0.0
+ 0
+LINE
+ 5
+49
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+320.0
+ 20
+35.0
+ 30
+0.0
+ 11
+320.0
+ 21
+50.0
+ 31
+0.0
+ 0
+LINE
+ 5
+4A
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+365.0
+ 20
+35.0
+ 30
+0.0
+ 11
+365.0
+ 21
+50.0
+ 31
+0.0
+ 0
+LINE
+ 5
+4B
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+290.0
+ 20
+20.0
+ 30
+0.0
+ 11
+290.0
+ 21
+35.0
+ 31
+0.0
+ 0
+LINE
+ 5
+4C
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+350.0
+ 20
+20.0
+ 30
+0.0
+ 11
+350.0
+ 21
+35.0
+ 31
+0.0
+ 0
+LINE
+ 5
+4D
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+275.0
+ 20
+10.0
+ 30
+0.0
+ 11
+275.0
+ 21
+20.0
+ 31
+0.0
+ 0
+LINE
+ 5
+4E
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+320.0
+ 20
+10.0
+ 30
+0.0
+ 11
+320.0
+ 21
+20.0
+ 31
+0.0
+ 0
+LINE
+ 5
+4F
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TITLEBLOCK
+100
+AcDbLine
+ 10
+365.0
+ 20
+10.0
+ 30
+0.0
+ 11
+365.0
+ 21
+20.0
+ 31
+0.0
+ 0
+TEXT
+ 5
+50
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+294.5
+ 20
+57.5
+ 30
+0.0
+ 40
+5.0
+ 1
+ISO A3 P&ID SHEET
+100
+AcDbText
+ 0
+TEXT
+ 5
+52
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+231.5
+ 20
+46.5
+ 30
+0.0
+ 40
+2.0
+ 1
+DWG NO
+100
+AcDbText
+ 0
+TEXT
+ 5
+53
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+233.0
+ 20
+40.0
+ 30
+0.0
+ 40
+3.5
+ 1
+TEMPLATE
+100
+AcDbText
+ 0
+TEXT
+ 5
+54
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+321.5
+ 20
+46.5
+ 30
+0.0
+ 40
+2.0
+ 1
+REV
+100
+AcDbText
+ 0
+TEXT
+ 5
+55
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+323.0
+ 20
+40.0
+ 30
+0.0
+ 40
+3.5
+ 1
+A
+100
+AcDbText
+ 0
+TEXT
+ 5
+56
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+366.5
+ 20
+46.5
+ 30
+0.0
+ 40
+2.0
+ 1
+SHEET
+100
+AcDbText
+ 0
+TEXT
+ 5
+57
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+368.0
+ 20
+40.0
+ 30
+0.0
+ 40
+3.5
+ 1
+1/1
+100
+AcDbText
+ 0
+TEXT
+ 5
+58
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+231.5
+ 20
+31.5
+ 30
+0.0
+ 40
+2.0
+ 1
+PART NO
+100
+AcDbText
+ 0
+TEXT
+ 5
+59
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+233.0
+ 20
+25.0
+ 30
+0.0
+ 40
+3.5
+ 1
+
+100
+AcDbText
+ 0
+TEXT
+ 5
+5A
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+291.5
+ 20
+31.5
+ 30
+0.0
+ 40
+2.0
+ 1
+MATERIAL
+100
+AcDbText
+ 0
+TEXT
+ 5
+5B
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+293.0
+ 20
+25.0
+ 30
+0.0
+ 40
+3.5
+ 1
+
+100
+AcDbText
+ 0
+TEXT
+ 5
+5C
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+351.5
+ 20
+31.5
+ 30
+0.0
+ 40
+2.0
+ 1
+SCALE
+100
+AcDbText
+ 0
+TEXT
+ 5
+5D
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+353.0
+ 20
+25.0
+ 30
+0.0
+ 40
+3.5
+ 1
+1:1
+100
+AcDbText
+ 0
+TEXT
+ 5
+5E
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+231.5
+ 20
+16.5
+ 30
+0.0
+ 40
+2.0
+ 1
+DRAWN
+100
+AcDbText
+ 0
+TEXT
+ 5
+5F
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+233.0
+ 20
+11.5
+ 30
+0.0
+ 40
+3.5
+ 1
+
+100
+AcDbText
+ 0
+TEXT
+ 5
+60
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+276.5
+ 20
+16.5
+ 30
+0.0
+ 40
+2.0
+ 1
+CHECKED
+100
+AcDbText
+ 0
+TEXT
+ 5
+61
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+278.0
+ 20
+11.5
+ 30
+0.0
+ 40
+3.5
+ 1
+
+100
+AcDbText
+ 0
+TEXT
+ 5
+62
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+321.5
+ 20
+16.5
+ 30
+0.0
+ 40
+2.0
+ 1
+DATE
+100
+AcDbText
+ 0
+TEXT
+ 5
+63
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+323.0
+ 20
+11.5
+ 30
+0.0
+ 40
+3.5
+ 1
+
+100
+AcDbText
+ 0
+TEXT
+ 5
+64
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+366.5
+ 20
+16.5
+ 30
+0.0
+ 40
+2.0
+ 1
+COMPANY
+100
+AcDbText
+ 0
+TEXT
+ 5
+65
+330
+1B
+100
+AcDbEntity
+ 67
+1
+ 8
+TEXT
+100
+AcDbText
+ 10
+368.0
+ 20
+11.5
+ 30
+0.0
+ 40
+3.5
+ 1
+Anka-Makine
+100
+AcDbText
+ 0
+ENDSEC
+ 0
+SECTION
+ 2
+OBJECTS
+ 0
+DICTIONARY
+ 5
+A
+330
+0
+100
+AcDbDictionary
+281
+1
+ 3
+ACAD_COLOR
+350
+B
+ 3
+ACAD_GROUP
+350
+C
+ 3
+ACAD_LAYOUT
+350
+D
+ 3
+ACAD_MATERIAL
+350
+E
+ 3
+ACAD_MLEADERSTYLE
+350
+F
+ 3
+ACAD_MLINESTYLE
+350
+10
+ 3
+ACAD_PLOTSETTINGS
+350
+11
+ 3
+ACAD_PLOTSTYLENAME
+350
+12
+ 3
+ACAD_SCALELIST
+350
+14
+ 3
+ACAD_TABLESTYLE
+350
+15
+ 3
+ACAD_VISUALSTYLE
+350
+16
+ 3
+EZDXF_META
+350
+2D
+ 0
+DICTIONARY
+ 5
+B
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+C
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+D
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Model
+350
+1A
+ 3
+A3
+350
+1E
+ 0
+DICTIONARY
+ 5
+E
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+ByBlock
+350
+1F
+ 3
+ByLayer
+350
+20
+ 3
+Global
+350
+21
+ 0
+DICTIONARY
+ 5
+F
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Standard
+350
+2C
+ 0
+DICTIONARY
+ 5
+10
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Standard
+350
+22
+ 0
+DICTIONARY
+ 5
+11
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+ACDBDICTIONARYWDFLT
+ 5
+12
+330
+A
+100
+AcDbDictionary
+281
+1
+ 3
+Normal
+350
+13
+100
+AcDbDictionaryWithDefault
+340
+13
+ 0
+ACDBPLACEHOLDER
+ 5
+13
+330
+12
+ 0
+DICTIONARY
+ 5
+14
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+15
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+DICTIONARY
+ 5
+16
+330
+A
+100
+AcDbDictionary
+281
+1
+ 0
+LAYOUT
+ 5
+1A
+330
+D
+100
+AcDbPlotSettings
+ 1
+
+ 4
+A3
+ 6
+
+ 40
+7.5
+ 41
+20.0
+ 42
+7.5
+ 43
+20.0
+ 44
+420.0
+ 45
+297.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+0.0
+140
+0.0
+141
+0.0
+142
+1.0
+143
+1.0
+ 70
+1024
+ 72
+1
+ 73
+0
+ 74
+5
+ 7
+
+ 75
+16
+ 76
+0
+ 77
+2
+ 78
+300
+147
+1.0
+148
+0.0
+149
+0.0
+100
+AcDbLayout
+ 1
+Model
+ 70
+1
+ 71
+0
+ 10
+0.0
+ 20
+0.0
+ 11
+420.0
+ 21
+297.0
+ 12
+0.0
+ 22
+0.0
+ 32
+0.0
+ 14
+1e+20
+ 24
+1e+20
+ 34
+1e+20
+ 15
+-1e+20
+ 25
+-1e+20
+ 35
+-1e+20
+146
+0.0
+ 13
+0.0
+ 23
+0.0
+ 33
+0.0
+ 16
+1.0
+ 26
+0.0
+ 36
+0.0
+ 17
+0.0
+ 27
+1.0
+ 37
+0.0
+ 76
+1
+330
+17
+ 0
+LAYOUT
+ 5
+1E
+330
+D
+100
+AcDbPlotSettings
+ 1
+
+ 2
+DWG To PDF.pc3
+ 4
+ISO_A3_(420.00_x_297.00_MM)
+ 6
+
+ 40
+0.0
+ 41
+0.0
+ 42
+0.0
+ 43
+0.0
+ 44
+420.0
+ 45
+297.0
+ 46
+0.0
+ 47
+0.0
+ 48
+0.0
+ 49
+0.0
+140
+0.0
+141
+0.0
+142
+1.0
+143
+1.0
+ 70
+688
+ 72
+1
+ 73
+0
+ 74
+5
+ 7
+monochrome.ctb
+ 75
+16
+ 76
+0
+ 77
+2
+ 78
+300
+147
+1.0
+148
+0.0
+149
+0.0
+100
+AcDbLayout
+ 1
+A3
+ 70
+1
+ 71
+1
+ 10
+-0.0
+ 20
+-0.0
+ 11
+420.0
+ 21
+297.0
+ 12
+0.0
+ 22
+0.0
+ 32
+0.0
+ 14
+1e+20
+ 24
+1e+20
+ 34
+1e+20
+ 15
+-1e+20
+ 25
+-1e+20
+ 35
+-1e+20
+146
+0.0
+ 13
+0.0
+ 23
+0.0
+ 33
+0.0
+ 16
+1.0
+ 26
+0.0
+ 36
+0.0
+ 17
+0.0
+ 27
+1.0
+ 37
+0.0
+ 76
+1
+330
+1B
+ 0
+MATERIAL
+ 5
+1F
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+ByBlock
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MATERIAL
+ 5
+20
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+ByLayer
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MATERIAL
+ 5
+21
+102
+{ACAD_REACTORS
+330
+E
+102
+}
+330
+E
+100
+AcDbMaterial
+ 1
+Global
+ 2
+
+ 70
+0
+ 40
+1.0
+ 71
+1
+ 41
+1.0
+ 91
+-1023410177
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 44
+0.5
+ 73
+0
+ 45
+1.0
+ 46
+1.0
+ 77
+1
+ 4
+
+ 78
+1
+ 79
+1
+170
+1
+ 48
+1.0
+171
+1
+ 6
+
+172
+1
+173
+1
+174
+1
+140
+1.0
+141
+1.0
+175
+1
+ 7
+
+176
+1
+177
+1
+178
+1
+143
+1.0
+179
+1
+ 8
+
+270
+1
+271
+1
+272
+1
+145
+1.0
+146
+1.0
+273
+1
+ 9
+
+274
+1
+275
+1
+276
+1
+ 42
+1.0
+ 72
+1
+ 3
+
+ 73
+1
+ 74
+1
+ 75
+1
+ 94
+63
+ 0
+MLINESTYLE
+ 5
+22
+102
+{ACAD_REACTORS
+330
+10
+102
+}
+330
+10
+100
+AcDbMlineStyle
+ 2
+Standard
+ 70
+0
+ 3
+
+ 62
+256
+ 51
+90.0
+ 52
+90.0
+ 71
+2
+ 49
+0.5
+ 62
+256
+ 6
+BYLAYER
+ 49
+-0.5
+ 62
+256
+ 6
+BYLAYER
+ 0
+MLEADERSTYLE
+ 5
+2C
+102
+{ACAD_REACTORS
+330
+F
+102
+}
+330
+F
+100
+AcDbMLeaderStyle
+179
+2
+170
+2
+171
+1
+172
+0
+ 90
+2
+ 40
+0.0
+ 41
+0.0
+173
+1
+ 91
+-1056964608
+ 92
+-2
+290
+1
+ 42
+2.0
+291
+1
+ 43
+8.0
+ 3
+Standard
+ 44
+4.0
+300
+
+342
+29
+174
+1
+175
+1
+176
+0
+178
+1
+ 93
+-1056964608
+ 45
+4.0
+292
+0
+297
+0
+ 46
+4.0
+ 94
+-1056964608
+ 47
+1.0
+ 49
+1.0
+140
+1.0
+294
+1
+141
+0.0
+177
+0
+142
+1.0
+295
+0
+296
+0
+143
+3.75
+271
+0
+272
+9
+273
+9
+ 0
+DICTIONARY
+ 5
+2D
+330
+A
+100
+AcDbDictionary
+280
+1
+281
+1
+ 3
+CREATED_BY_EZDXF
+350
+2E
+ 3
+WRITTEN_BY_EZDXF
+350
+68
+ 0
+DICTIONARYVAR
+ 5
+2E
+330
+2D
+100
+DictionaryVariables
+280
+0
+ 1
+1.4.4 @ 2026-09-21T19:25:45.787707+00:00
+ 0
+DICTIONARYVAR
+ 5
+68
+330
+2D
+100
+DictionaryVariables
+280
+0
+ 1
+1.4.4 @ 2026-09-21T19:25:45.803766+00:00
+ 0
+ENDSEC
+ 0
+EOF
diff --git a/tests/test_batch_plot.py b/tests/test_batch_plot.py
new file mode 100644
index 0000000..7092e69
--- /dev/null
+++ b/tests/test_batch_plot.py
@@ -0,0 +1,434 @@
+"""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
+pieces of this task (the installed ctb scan, and the plot call's shape —
+foreground, by layout name, confirmed on disk) are fake-tested; the SECTION 19
+tools are called directly with a minimal context.
+"""
+
+from __future__ import annotations
+
+import types
+from pathlib import Path
+
+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"
+
+
+async def _model_circle_and_sheet_rectangle(backend):
+ """Distinct geometry per space, told apart by the PDF's aspect ratio: a
+ circle in model space (a square page) and a 390 x 270 rectangle on the A3
+ sheet (a landscape page)."""
+ await backend.entity_create_circle(50, 50, 25)
+ await backend.layout_rename("Layout1", "A3")
+ await backend.page_setup_apply("A3", resolve_page_setup("ISO_A3", "landscape"))
+ await backend.layout_set_current("A3")
+ await backend.entity_create_polyline([[10, 10], [400, 10], [400, 280], [10, 280]], closed=True)
+
+
+def _aspect(mediabox_mm):
+ width, height = mediabox_mm
+ return width / height
+
+
+async def test_the_model_row_is_model_space_even_when_a_sheet_is_current(backend, tmp_path):
+ """The Model row must be model space whichever tab is current — proven by
+ content, not by the row's shape. The headless export used to route the
+ model target through ``_msp()`` (the *current* tab), so with a sheet
+ current the "Model" PDF was that sheet's paper-space content: measured
+ 176.1 x 121.9 mm (the rectangle's aspect) against 121.9 x 121.9 (the
+ circle) with Model current. COM had the mirror defect (``ActiveLayout``
+ for ``layout=None``). batch_plot names the layout for every row, Model
+ included, and the engine honours the name."""
+ pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
+ await _model_circle_and_sheet_rectangle(backend)
+ seen: list[str | None] = []
+ original = backend.drawing_export_pdf
+
+ async def _recording(path, layout=None):
+ seen.append(layout)
+ return await original(path, layout=layout)
+
+ backend.drawing_export_pdf = _recording
+ with_sheet_current = await batch_plot(backend, ["Model", "A3"], str(tmp_path / "sheet"))
+ assert seen == ["Model", "A3"]
+ await backend.layout_set_current("Model")
+ with_model_current = await batch_plot(backend, ["Model"], str(tmp_path / "model"))
+
+ model_row = with_sheet_current["sheets"][0]
+ assert with_sheet_current["ok"] and model_row["paper"] is None
+ assert model_row["mediabox_mm"] == pytest.approx(
+ with_model_current["sheets"][0]["mediabox_mm"], abs=0.05
+ ), "the Model row changed with the current tab"
+ assert _aspect(model_row["mediabox_mm"]) == pytest.approx(1.0, abs=0.02), (
+ "the Model row is the circle (square page), not the sheet's rectangle"
+ )
+ a3_row = with_sheet_current["sheets"][1]
+ assert a3_row["paper"] == "ISO_A3"
+ assert _aspect(a3_row["mediabox_mm"]) == pytest.approx(420 / 297, abs=0.02)
+
+
+async def test_export_pdf_without_a_layout_is_model_space_even_when_a_sheet_is_current(
+ backend, tmp_path
+):
+ """``drawing_export_pdf(path)`` advertises model space as its default; a
+ current sheet must not turn it into that sheet."""
+ pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
+ from engineering.standards.plot import read_mediabox
+
+ await _model_circle_and_sheet_rectangle(backend)
+ default = tmp_path / "default.pdf"
+ named = tmp_path / "named.pdf"
+ assert (await backend.drawing_export_pdf(str(default)))["ok"]
+ assert (await backend.drawing_export_pdf(str(named), layout="Model"))["ok"]
+ assert read_mediabox(str(default)) == pytest.approx(read_mediabox(str(named)), abs=0.05)
+ assert _aspect(read_mediabox(str(default))) == pytest.approx(1.0, abs=0.02)
+
+
+async def test_a_sheet_the_engine_cannot_plot_is_a_failed_row_not_a_lost_batch(backend, tmp_path):
+ """COM raises RuntimeError for an AutoCAD error (E_FAIL after a stranded
+ background job, say). That is one row's failure; the sheets already
+ written stay in the report with their evidence."""
+ pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
+ await _two_sheets(backend)
+ original = backend.drawing_export_pdf
+
+ async def _flaky(path, layout=None):
+ if layout == "B":
+ raise RuntimeError("AutoCAD COM error (-0x7ffdfff7): Unspecified error")
+ return await original(path, layout=layout)
+
+ backend.drawing_export_pdf = _flaky
+ result = await batch_plot(backend, None, str(tmp_path))
+ assert result["ok"] is False and result["count"] == 2
+ a3, b = result["sheets"]
+ assert a3["ok"] and a3["paper"] == "ISO_A3"
+ assert b == {
+ "layout": "B",
+ "ok": False,
+ "path": str(tmp_path / "untitled-B.pdf"),
+ "error": "AutoCAD COM error (-0x7ffdfff7): Unspecified error",
+ }
+
+
+async def test_a_plot_reported_ok_without_a_file_is_a_failed_row(backend, tmp_path):
+ """A background plot answers ok before any file exists; the row says so
+ instead of dying on ``path.stat()`` with an unhandled FileNotFoundError."""
+ await _two_sheets(backend)
+
+ async def _phantom(path, layout=None):
+ return {"ok": True, "path": path}
+
+ backend.drawing_export_pdf = _phantom
+ result = await batch_plot(backend, ["A3"], str(tmp_path))
+ assert result["ok"] is False
+ row = result["sheets"][0]
+ assert row["ok"] is False and "no file" in row["error"]
+ assert "bytes" not in row and "mediabox_mm" not in row
+
+
+# ── 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(PrinterStyleSheetPath=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)
+
+
+# ── COM: the plot call's shape ──────────────────────────────────────────────
+
+
+@pytest.fixture
+def com_plot(monkeypatch):
+ """A fake document whose ``Plot.PlotToFile`` records the active layout and
+ BACKGROUNDPLOT at the moment of the plot — the two things AutoCAD acts on
+ and the reviewer measured wrong: with a sheet current, ``layout=None``
+ plotted that sheet; with the operator's BACKGROUNDPLOT=2 the file did not
+ exist when the call returned."""
+ pytest.importorskip("win32com.client", reason="pywin32 not installed")
+ from backends import com_backend as module
+
+ state = {
+ "vars": {"BACKGROUNDPLOT": 2},
+ "sets": [],
+ "active": "Layout1",
+ "plots": [],
+ "write_file": True,
+ "plotted": True,
+ }
+ names = ["Model", "Layout1", "Layout2"]
+
+ class _Layout:
+ def __init__(self, name):
+ self.Name = name
+
+ class _Layouts:
+ def Item(self, key):
+ for name in names:
+ if name.lower() == str(key).lower():
+ return _Layout(name)
+ raise KeyError(key)
+
+ class _Plot:
+ def PlotToFile(self, path, config):
+ state["plots"].append((state["active"], config, state["vars"]["BACKGROUNDPLOT"]))
+ if state["write_file"]:
+ Path(path).write_bytes(b"%PDF-1.4 fake")
+ return state["plotted"]
+
+ class _Doc:
+ Layouts = _Layouts()
+ Plot = _Plot()
+
+ @property
+ def ActiveLayout(self):
+ return _Layout(state["active"])
+
+ @ActiveLayout.setter
+ def ActiveLayout(self, layout):
+ state["active"] = layout.Name
+
+ def GetVariable(self, name):
+ return state["vars"][name]
+
+ def SetVariable(self, name, value):
+ state["sets"].append((name, value))
+ state["vars"][name] = value
+
+ monkeypatch.setattr(module, "_acad_doc", lambda: _Doc())
+ backend = module.ComBackend()
+
+ async def _run_inline(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ monkeypatch.setattr(backend, "_run", _run_inline)
+ return backend, state
+
+
+async def test_com_export_pdf_plots_model_space_by_name_in_the_foreground(com_plot, tmp_path):
+ backend, state = com_plot
+ out = tmp_path / "model.pdf"
+ result = await backend.drawing_export_pdf(str(out))
+ assert state["plots"] == [("Model", "DWG To PDF.pc3", 0)], (
+ "layout=None is model space, plotted with BACKGROUNDPLOT forced to 0"
+ )
+ assert result == {"ok": True, "path": str(out), "layout": "Model", "bytes": out.stat().st_size}
+ assert state["active"] == "Layout1", "the sheet that was current is current again"
+ assert state["vars"]["BACKGROUNDPLOT"] == 2, "the operator's setting is restored"
+ assert state["sets"] == [("BACKGROUNDPLOT", 0), ("BACKGROUNDPLOT", 2)]
+
+
+async def test_com_export_pdf_plots_the_named_sheet_and_restores_the_tab(com_plot, tmp_path):
+ backend, state = com_plot
+ result = await backend.drawing_export_pdf(str(tmp_path / "l2.pdf"), layout="layout2")
+ assert [row[0] for row in state["plots"]] == ["Layout2"]
+ assert result["ok"] and result["layout"] == "layout2"
+ assert state["active"] == "Layout1"
+
+
+async def test_com_export_pdf_leaves_backgroundplot_alone_when_already_foreground(
+ com_plot, tmp_path
+):
+ backend, state = com_plot
+ state["vars"]["BACKGROUNDPLOT"] = 0
+ await backend.drawing_export_pdf(str(tmp_path / "m.pdf"))
+ assert state["sets"] == []
+
+
+async def test_com_export_pdf_reports_a_refused_plot(com_plot, tmp_path):
+ backend, state = com_plot
+ state["plotted"] = False
+ state["write_file"] = False
+ result = await backend.drawing_export_pdf(str(tmp_path / "no.pdf"), layout="Layout1")
+ assert result["ok"] is False and "PlotToFile returned False" in result["error"]
+ assert state["vars"]["BACKGROUNDPLOT"] == 2
+
+
+async def test_com_export_pdf_reports_a_plot_that_left_no_file(com_plot, tmp_path):
+ """PlotToFile answering True with nothing on disk is the background-plot
+ signature; it is a failure, not an ok."""
+ backend, state = com_plot
+ state["write_file"] = False
+ result = await backend.drawing_export_pdf(str(tmp_path / "gone.pdf"))
+ assert result["ok"] is False and "wrote no file" in result["error"]
+ assert "bytes" not in result
diff --git a/tests/test_page_setup.py b/tests/test_page_setup.py
new file mode 100644
index 0000000..0ccd473
--- /dev/null
+++ b/tests/test_page_setup.py
@@ -0,0 +1,1380 @@
+"""Page setup on both engines; the PDF's own /MediaBox is the evidence.
+
+ezdxf: apply a paper, export the layout, parse the PDF back and compare to the
+paper size. COM: a fake ActiveX layout records the property sequence.
+"""
+
+from __future__ import annotations
+
+import re
+import types
+import zlib
+
+import pytest
+
+from engineering.standards.papers import (
+ ACTIVEX_PLOT_SCALE,
+ CTB_CATALOG,
+ DXF_STANDARD_SCALE_TYPE,
+ DXF_STD_SCALES,
+ PAPER_SIZES,
+ SCALES,
+ canonical_media_name,
+ dxf_scale_label,
+ paper_from_size,
+ paper_units_name,
+ resolve_page_setup,
+ scale_label,
+ scale_ratio,
+ turned_sheet,
+)
+from engineering.standards.pdfinfo import read_mediabox
+
+pytestmark = pytest.mark.asyncio
+
+SHEET = "Sheet"
+
+
+# ── pure data ───────────────────────────────────────────────────────────────
+
+
+def test_paper_sizes_are_iso_216_and_ansi_y14_1():
+ assert PAPER_SIZES == {
+ "ISO_A0": (841, 1189),
+ "ISO_A1": (594, 841),
+ "ISO_A2": (420, 594),
+ "ISO_A3": (297, 420),
+ "ISO_A4": (210, 297),
+ "ANSI_A": (216, 279),
+ "ANSI_B": (279, 432),
+ "ANSI_C": (432, 559),
+ "ANSI_D": (559, 864),
+ "ANSI_E": (864, 1118),
+ }
+ assert CTB_CATALOG[0] == "monochrome.ctb" and "acad.ctb" in CTB_CATALOG
+ assert SCALES["fit"] is None and SCALES["1:50"] == 0.02 and SCALES["10:1"] == 10.0
+
+
+def test_canonical_media_name_uses_autocad_spelling():
+ assert canonical_media_name("ISO_A3", "landscape") == "ISO_A3_(420.00_x_297.00_MM)"
+ assert canonical_media_name("ISO_A3", "portrait") == "ISO_A3_(297.00_x_420.00_MM)"
+ assert canonical_media_name("ANSI_B", "landscape") == "ANSI_B_(17.00_x_11.00_Inches)"
+
+
+def test_paper_from_size_matches_either_orientation_within_half_a_millimetre():
+ assert paper_from_size(420, 297) == "ISO_A3"
+ assert paper_from_size(297, 420) == "ISO_A3"
+ assert paper_from_size(431.8, 279.4) == "ANSI_B"
+ assert paper_from_size(400, 300) is None
+
+
+def test_scale_ratio_and_label_round_trip():
+ assert scale_ratio("1:50") == (1.0, 50.0)
+ assert scale_ratio("2:1") == (2.0, 1.0)
+ assert scale_label(1.0, 50.0) == "1:50"
+ assert scale_label(1.0, 3.0) == "1:3"
+ with pytest.raises(ValueError, match="scale"):
+ scale_ratio("fit")
+
+
+def test_resolve_page_setup_defaults():
+ setup = resolve_page_setup("ISO_A3")
+ assert setup["paper"] == "ISO_A3"
+ assert setup["orientation"] == "landscape"
+ assert setup["size_mm"] == [420.0, 297.0]
+ assert setup["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)"
+ assert setup["plot_style"] == "monochrome.ctb" and setup["plot_style_known"] is True
+ assert setup["scale"] == "fit"
+ assert setup["dxf_standard_scale_type"] == 0 and setup["activex_standard_scale"] == 0
+ assert setup["scale_ratio"] == [1.0, 1.0]
+ assert setup["plot_area"] == "layout" and setup["plot_type"] == 5
+ assert setup["device"] == "DWG To PDF.pc3"
+ assert setup["margins_mm"] is None
+ assert setup["center"] is None, "a layout plot has no centring (AutoCAD greys it out)"
+
+
+def test_resolve_page_setup_center_applies_to_an_extents_plot_only():
+ """ActiveX Reference, CenterPlot: 'This property cannot be set to True on a
+ layout object whose PlotType property is set to acLayout.' The resolver
+ carries the rule so both engines write the same thing: a bool for an
+ extents plot, ``None`` (not applicable, never written) for a layout plot."""
+ assert resolve_page_setup("ISO_A3", plot_area="extents")["center"] is True
+ assert resolve_page_setup("ISO_A3", plot_area="extents", center=False)["center"] is False
+ assert resolve_page_setup("ISO_A3", plot_area="layout", center=True)["center"] is None
+ assert resolve_page_setup("ISO_A3", plot_area="layout", center=False)["center"] is None
+ with pytest.raises(ValueError, match="center"):
+ resolve_page_setup("ISO_A3", plot_area="layout", center="yes")
+
+
+def test_resolve_page_setup_accepts_lowercase_and_short_paper_names():
+ assert resolve_page_setup("iso_a4", "portrait")["size_mm"] == [210.0, 297.0]
+ assert resolve_page_setup("A3")["paper"] == "ISO_A3"
+ assert resolve_page_setup("ansi_b")["canonical_media_name"] == "ANSI_B_(17.00_x_11.00_Inches)"
+
+
+def test_resolve_page_setup_scale_codes():
+ one_to_five = resolve_page_setup("ISO_A3", scale="1:5")
+ # Code 19 is 1:5 in the file enum, but the typelib's ac1_5 = 19 is refused
+ # by AutoCAD 2026 ("Invalid input", measured live), so both engines store
+ # 1:5 the same way: the custom route, 142/143 = 1/5.
+ assert one_to_five["dxf_standard_scale_type"] is None
+ assert one_to_five["activex_standard_scale"] is None
+ assert one_to_five["scale_ratio"] == [1.0, 5.0]
+ five_to_one = resolve_page_setup("ISO_A3", scale="5:1")
+ assert five_to_one["dxf_standard_scale_type"] is None
+ assert five_to_one["activex_standard_scale"] is None
+ assert five_to_one["scale_ratio"] == [5.0, 1.0]
+ # One numbering: AutoCAD 2026 saves StandardScale = 26 (ac1_50) as
+ # group 75 = 26 (measured, COM SaveAs DXF) — not ezdxf's 25.
+ assert resolve_page_setup("ISO_A3", scale="1:50")["dxf_standard_scale_type"] == 26
+ assert resolve_page_setup("ISO_A3", scale="1:50")["activex_standard_scale"] == 26
+
+
+def test_dxf_standard_scale_codes_are_the_enum_autocad_writes():
+ """Group 75 is the ObjectARX ``StdScaleType`` enum, the same numbering as
+ ActiveX ``AcPlotScale`` — measured on AutoCAD 2026 (``StandardScale = n``
+ then ``SaveAs(..., 61)``): 1:2→17, 1:10→21, 1:50→26, 2:1→28, 10:1→31.
+ ezdxf's ``STD_SCALES`` has no 19 = 1:5 and is one lower above 18; read
+ through it, an AutoCAD 1:50 sheet is "1:100" and a 1:10 sheet plots 1:16.
+ """
+ measured = {"fit": 0, "1:1": 16, "1:2": 17, "1:10": 21, "1:50": 26, "2:1": 28, "10:1": 31}
+ for label, code in measured.items():
+ assert DXF_STANDARD_SCALE_TYPE[label] == code, label
+ # Every code the writer uses is the ActiveX code for the same label …
+ for label, code in DXF_STANDARD_SCALE_TYPE.items():
+ assert ACTIVEX_PLOT_SCALE[label] == code, label
+ # … and its authored ratio is the catalogue factor.
+ for label, code in DXF_STANDARD_SCALE_TYPE.items():
+ if code:
+ numerator, denominator = DXF_STD_SCALES[code]
+ assert numerator / denominator == pytest.approx(SCALES[label]), label
+ # The full file enum, including what the writer never emits.
+ assert DXF_STD_SCALES[19] == (1.0, 5.0)
+ assert DXF_STD_SCALES[20] == (1.0, 8.0)
+ assert DXF_STD_SCALES[22] == (1.0, 16.0)
+ assert DXF_STD_SCALES[25] == (1.0, 40.0)
+ assert DXF_STD_SCALES[33] == (1000.0, 1.0)
+ assert set(DXF_STD_SCALES) == set(range(1, 34))
+ # Read-back labels come from the code, never from a stale 142/143.
+ assert dxf_scale_label(0, 3.0, 7.0) == "fit"
+ assert dxf_scale_label(26, 1.0, 100.0) == "1:50"
+ assert dxf_scale_label(21, 1.0, 1.0) == "1:10"
+ assert dxf_scale_label(19, 1.0, 1.0) == "1:5"
+ assert dxf_scale_label(22, 1.0, 1.0) == "1:16"
+ assert dxf_scale_label(28, 1.0, 0.5) == "2:1"
+ assert dxf_scale_label(8, 1.0, 1.0) == "1/4\"=1'"
+ assert dxf_scale_label(99, 1.0, 3.0) == "1:3" # unknown code: the stored ratio
+
+
+@pytest.mark.parametrize(
+ "kwargs, field",
+ [
+ ({"paper": "ISO_A9"}, "paper"),
+ ({"paper": "ISO_A3", "orientation": "sideways"}, "orientation"),
+ ({"paper": "ISO_A3", "scale": "1:3"}, "scale"),
+ ({"paper": "ISO_A3", "plot_area": "window"}, "plot_area"),
+ ({"paper": "ISO_A3", "device": ""}, "device"),
+ ({"paper": "ISO_A3", "plot_style": ""}, "plot_style"),
+ ({"paper": "ISO_A3", "margins_mm": [1, 2, 3]}, "margins_mm"),
+ ({"paper": "ISO_A3", "margins_mm": [-1, 0, 0, 0]}, "margins_mm"),
+ ({"paper": "ISO_A3", "margins_mm": [150, 150, 0, 0]}, "margins_mm"),
+ ({"paper": "ISO_A3", "center": "yes"}, "center"),
+ ],
+)
+def test_resolve_page_setup_refuses_naming_the_field(kwargs, field):
+ with pytest.raises(ValueError, match=field):
+ resolve_page_setup(**kwargs)
+
+
+def test_unknown_ctb_is_allowed_but_flagged():
+ setup = resolve_page_setup("ISO_A3", plot_style="company.ctb")
+ assert setup["plot_style"] == "company.ctb" and setup["plot_style_known"] is False
+
+
+def test_resolve_page_setup_paper_units_default_mm_and_keep():
+ assert resolve_page_setup("ISO_A3")["paper_units"] == "mm"
+ assert resolve_page_setup("ANSI_B", paper_units="inches")["paper_units"] == "inches"
+ assert resolve_page_setup("ANSI_B", paper_units="inch")["paper_units"] == "inches"
+ assert resolve_page_setup("ANSI_B", paper_units=None)["paper_units"] is None, "keep"
+ with pytest.raises(ValueError, match="paper_units"):
+ resolve_page_setup("ISO_A3", paper_units="cm")
+
+
+def test_paper_units_name_covers_the_three_dxf_and_activex_codes():
+ assert paper_units_name(0) == "inches" and paper_units_name(1) == "mm"
+ assert paper_units_name(2) == "pixels"
+ assert paper_units_name(None) is None and paper_units_name(7) == "code:7"
+
+
+@pytest.mark.parametrize(
+ "rotation, size, margins",
+ [
+ # stored media 210 x 297 with (top, bottom, left, right) = (1, 3, 4, 2),
+ # i.e. ezdxf's clockwise (top, right, bottom, left) = (1, 2, 3, 4).
+ (0, [210.0, 297.0], [1.0, 3.0, 4.0, 2.0]),
+ (1, [297.0, 210.0], [2.0, 4.0, 1.0, 3.0]),
+ (2, [210.0, 297.0], [3.0, 1.0, 2.0, 4.0]),
+ (3, [297.0, 210.0], [4.0, 2.0, 3.0, 1.0]),
+ ],
+)
+def test_turned_sheet_maps_size_and_margins_like_ezdxf_page_from_dxf_layout(
+ rotation, size, margins
+):
+ """The one mapping ``page_setup_list``, ``_paper_frame`` and the COM row share.
+
+ Cross-checked against ezdxf's ``Page.from_dxf_layout`` (the renderer's own
+ reading of PLOTSETTINGS 40-43 under ``plot_rotation``), so the row a
+ client reads is the sheet the same engine plots.
+ """
+ assert turned_sheet(210, 297, [1, 3, 4, 2], rotation) == (size, margins)
+
+
+def test_turned_sheet_agrees_with_ezdxf_page_from_dxf_layout():
+ ezdxf = pytest.importorskip("ezdxf")
+ from ezdxf.addons.drawing.layout import Page
+
+ doc = ezdxf.new()
+ lay = doc.layouts.new("Turn")
+ for rotation in (0, 1, 2, 3):
+ lay.page_setup(size=(210, 297), margins=(1, 2, 3, 4), units="mm", rotation=rotation)
+ page = Page.from_dxf_layout(lay.dxf_layout)
+ size, (top, bottom, left, right) = turned_sheet(210, 297, [1, 3, 4, 2], rotation)
+ assert size == [page.width, page.height], rotation
+ assert (page.margins.top, page.margins.bottom) == (top, bottom), rotation
+ assert (page.margins.left, page.margins.right) == (left, right), rotation
+
+
+# ── the PDF reader ──────────────────────────────────────────────────────────
+
+
+def test_read_mediabox_refuses_a_non_pdf(tmp_path):
+ bogus = tmp_path / "x.pdf"
+ bogus.write_bytes(b"not a pdf")
+ with pytest.raises(ValueError, match="PDF"):
+ read_mediabox(str(bogus))
+
+
+def test_read_mediabox_finds_a_box_inside_a_flate_stream(tmp_path):
+ body = zlib.compress(b"<< /Type /Page /MediaBox [0 0 595.276 841.89] >>")
+ pdf = tmp_path / "c.pdf"
+ pdf.write_bytes(
+ b"%PDF-1.5\n1 0 obj\n<< /Type /ObjStm /Filter /FlateDecode >>\nstream\n"
+ + body
+ + b"\nendstream\nendobj\n%%EOF"
+ )
+ width, height = read_mediabox(str(pdf))
+ assert abs(width - 210.0) < 0.05 and abs(height - 297.0) < 0.05
+
+
+def test_read_mediabox_reports_a_missing_box(tmp_path):
+ pdf = tmp_path / "n.pdf"
+ pdf.write_bytes(b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF")
+ with pytest.raises(ValueError, match="MediaBox"):
+ read_mediabox(str(pdf))
+
+
+# ── headless engine ─────────────────────────────────────────────────────────
+
+
+async def _sheet(backend, name: str = SHEET) -> str:
+ result = await backend.layout_create(name)
+ assert result["ok"], result
+ return name
+
+
+async def _mediabox_after_export(backend, tmp_path, layout: str):
+ pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
+ pdf = tmp_path / f"{layout}.pdf"
+ result = await backend.drawing_export_pdf(str(pdf), layout=layout)
+ assert result["ok"] is True, result
+ return read_mediabox(str(pdf))
+
+
+async def test_apply_iso_a3_landscape_and_the_pdf_mediabox_agrees(backend, tmp_path):
+ await _sheet(backend)
+ result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", "landscape"))
+ assert result["ok"] is True and result["layout"] == SHEET
+ assert result["applied"]["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)"
+ assert result["plot_style_known"] is True
+ assert result["viewports_kept"] is True
+
+ rows = await backend.page_setup_list(SHEET)
+ assert len(rows) == 1
+ row = rows[0]
+ assert row["layout"] == SHEET
+ assert row["paper"] == "ISO_A3"
+ assert row["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)"
+ assert row["size_mm"] == [420.0, 297.0]
+ assert row["orientation"] == "landscape"
+ assert row["plot_style"] == "monochrome.ctb"
+ assert row["scale"] == "fit"
+ assert row["plot_area"] == "layout"
+ assert row["device"] == "DWG To PDF.pc3"
+ assert row["center"] is None, "layout plot: centring is not applicable"
+ assert len(row["margins_mm"]) == 4
+
+ width, height = await _mediabox_after_export(backend, tmp_path, SHEET)
+ assert abs(width - 420.0) <= 0.5 and abs(height - 297.0) <= 0.5
+
+
+async def test_apply_ansi_b(backend, tmp_path):
+ await _sheet(backend)
+ await backend.page_setup_apply(SHEET, resolve_page_setup("ANSI_B", "landscape", scale="1:1"))
+ row = (await backend.page_setup_list(SHEET))[0]
+ assert row["paper"] == "ANSI_B" and row["size_mm"] == [432.0, 279.0]
+ assert row["scale"] == "1:1"
+ width, height = await _mediabox_after_export(backend, tmp_path, SHEET)
+ assert abs(width - 432.0) <= 0.5 and abs(height - 279.0) <= 0.5
+
+
+async def test_apply_portrait_a4(backend, tmp_path):
+ await _sheet(backend)
+ await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A4", "portrait"))
+ row = (await backend.page_setup_list(SHEET))[0]
+ assert row["orientation"] == "portrait" and row["size_mm"] == [210.0, 297.0]
+ width, height = await _mediabox_after_export(backend, tmp_path, SHEET)
+ assert abs(width - 210.0) <= 0.5 and abs(height - 297.0) <= 0.5
+
+
+def _ink_columns(png_path: str) -> tuple[int, int, int]:
+ """(first inked column, last inked column, image width) of a rendered PNG."""
+ from PIL import Image
+
+ image = Image.open(png_path).convert("L")
+ width, height = image.size
+ pixels = image.load()
+ inked = [x for x in range(width) if any(pixels[x, y] < 128 for y in range(height))]
+ assert inked, "the render is blank"
+ return inked[0], inked[-1], width
+
+
+async def test_rotated_layout_renders_the_whole_sheet(backend, tmp_path):
+ """plot_rotation 1 is AutoCAD's landscape-on-portrait-media convention.
+
+ A drawing authored in AutoCAD stores ISO A4 landscape as paper 210 x 297
+ with ``plot_rotation`` 1; paper-space X then runs along the 297 mm side.
+ The MediaBox alone cannot see a frame that has been clipped inside a
+ correctly sized page, so this test looks at the ink: the frame's right
+ edge (x = 277, the printable width) must reach the right of the sheet.
+ """
+ pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
+ pytest.importorskip("PIL", reason="pixel check needs Pillow")
+ await _sheet(backend, "Rot")
+ lay = backend._doc.layouts.get("Rot")
+ lay.page_setup(
+ size=(210, 297),
+ margins=(20, 7.5, 20, 7.5),
+ units="mm",
+ rotation=1,
+ scale=16,
+ name="ISO_A4",
+ device="DWG To PDF.pc3",
+ )
+ lay.add_lwpolyline([(0, 0), (277, 0), (277, 190), (0, 190)], close=True)
+
+ row = (await backend.page_setup_list("Rot"))[0]
+ assert row["size_mm"] == [297.0, 210.0] and row["orientation"] == "landscape"
+
+ png = tmp_path / "rot.png"
+ result = await backend.drawing_export_pdf(str(png), layout="Rot")
+ assert result["ok"] is True, result
+ assert result["paper_mm"] == [297.0, 210.0]
+ assert result["rotation_applied"] is False
+
+ first, last, width = _ink_columns(str(png))
+ # x = 277 sits at 277 / 297 of the sheet from the left margin, i.e. well
+ # past the 0.707-scaled box the unrotated window produced (col 1314/1753).
+ assert last >= int(width * 0.9), (first, last, width)
+
+ pdf = tmp_path / "rot.pdf"
+ await backend.drawing_export_pdf(str(pdf), layout="Rot")
+ mm_w, mm_h = read_mediabox(str(pdf))
+ 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 takes the custom route (as on the live engine), 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)
+
+
+@pytest.mark.parametrize(
+ ("paper", "paper_units", "sheet_mm", "frame_units"),
+ [
+ pytest.param("ISO_A3", "mm", (420.0, 297.0), (800.0, 500.0), id="mm"),
+ pytest.param("ANSI_B", "inches", (432.0, 279.0), (16.0, 10.0), id="inches"),
+ ],
+)
+async def test_extents_plot_fits_the_printable_area_and_centres(
+ backend, tmp_path, paper, paper_units, sheet_mm, frame_units
+):
+ """extents + fit: the paper-space extents (not the main viewport, which
+ ezdxf's bbox would count) scale to the printable area and sit centred.
+
+ The inch sheet is the label's regression: ``effective_scale`` is paper
+ units per drawing unit, and 25.4 mm per paper unit *divides* the mm ratio.
+ Measured before the fix: the ANSI B fit reported ``607.06:1`` for a fit
+ that is ``1:1.06276`` (off by 25.4 squared) while the ink landed right,
+ and the mm sheet could not see it because there the unit is 1.
+ """
+ await _sheet(backend)
+ await backend.page_setup_apply(
+ SHEET,
+ resolve_page_setup(
+ paper,
+ "landscape",
+ scale="fit",
+ plot_area="extents",
+ center=True,
+ paper_units=paper_units,
+ ),
+ )
+ lay = backend._doc.layouts.get(SHEET)
+ sheet_w, sheet_h = sheet_mm
+ frame_w, frame_h = frame_units
+ lay.add_lwpolyline([(0, 0), (frame_w, 0), (frame_w, frame_h), (0, frame_h)], close=True)
+ row = (await backend.page_setup_list(SHEET))[0]
+ assert row["paper_units"] == paper_units and row["size_mm"] == list(sheet_mm)
+ top, bottom, left_mm, right_mm = row["margins_mm"]
+ printable_w, printable_h = sheet_w - left_mm - right_mm, sheet_h - top - bottom
+ mm_per_unit = min(printable_w / frame_w, printable_h / frame_h)
+ mm_per_paper_unit = 25.4 if paper_units == "inches" else 1.0
+
+ frame = backend._paper_frame(lay)
+ assert frame["plot_area"] == "extents" and frame["plot_area_applied"] is True
+ assert frame["scale"] == "fit"
+ # The axes window is unit-free: the sheet in drawing units.
+ assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(sheet_w / mm_per_unit)
+
+ result, (first, last, width) = await _export_png(backend, tmp_path, SHEET, "extents")
+ # Paper units per drawing unit, not millimetres per drawing unit.
+ assert result["effective_scale"] == scale_label(1.0, mm_per_paper_unit / mm_per_unit)
+ # The frame sits centred in the printable area; the mm case is width-bound
+ # (it spans the printable width exactly), the inch case is height-bound.
+ frame_mm = frame_w * mm_per_unit
+ ink_left_mm = left_mm + (printable_w - frame_mm) / 2.0
+ assert abs(first - int(width * ink_left_mm / sheet_w)) <= 3, (first, last, width)
+ assert abs(last - int(width * (ink_left_mm + frame_mm) / sheet_w)) <= 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."""
+ await _sheet(backend)
+ extents = resolve_page_setup("ISO_A3", plot_area="extents", center=True)
+ result = await backend.page_setup_apply(SHEET, extents)
+ flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
+ assert flags & 4, "centre-plot bit"
+ assert flags & 32, "plot with plot styles"
+ assert flags & 128, "plot entity lineweights"
+ assert flags & 512, "draw viewports first"
+ assert flags & 16, "use standard scale (fit)"
+ assert result["applied"]["center"] is True
+ assert (await backend.page_setup_list(SHEET))[0]["center"] is True
+ await backend.page_setup_apply(
+ SHEET, resolve_page_setup("ISO_A3", plot_area="extents", center=False)
+ )
+ flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
+ assert not flags & 4 and flags & 32 and flags & 128 and flags & 512
+ assert (await backend.page_setup_list(SHEET))[0]["center"] is False
+
+
+async def test_layout_plot_never_sets_the_centre_bit_and_reports_it_not_applicable(backend):
+ """A layout plot is the whole sheet from its origin: AutoCAD disables
+ 'Center the plot' (ActiveX refuses CenterPlot=True under acLayout). The
+ headless engine mirrors that: bit 4 is not written, and the read-back says
+ None rather than a bool AutoCAD would ignore."""
+ await _sheet(backend)
+ result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=True))
+ flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
+ assert not flags & 4, "centre bit must not be set for a layout plot"
+ assert result["applied"]["center"] is None
+ assert "center" not in result["changed"], "None before (fresh layout plot) and None after"
+ assert (await backend.page_setup_list(SHEET))[0]["center"] is None
+ # Switching an extents+centred sheet to a layout plot clears the stale bit
+ # (what AutoCAD stores for a layout plot) and reports the move honestly.
+ await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", plot_area="extents"))
+ assert (await backend.page_setup_list(SHEET))[0]["center"] is True
+ result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
+ flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
+ assert not flags & 4
+ assert result["changed"]["center"] == [True, None]
+ assert result["changed"]["plot_area"] == ["extents", "layout"]
+
+
+async def test_custom_scale_clears_the_standard_scale_bit_and_writes_the_ratio(backend):
+ await _sheet(backend)
+ await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:5"))
+ dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf
+ assert not int(dxf.plot_layout_flags) & 16
+ assert (float(dxf.scale_numerator), float(dxf.scale_denominator)) == (1.0, 5.0)
+ assert float(dxf.unit_factor) == pytest.approx(0.2)
+ assert (await backend.page_setup_list(SHEET))[0]["scale"] == "1:5"
+
+
+@pytest.mark.parametrize(
+ "label, code, factor",
+ [
+ ("fit", 0, 1.0),
+ ("1:1", 16, 1.0),
+ ("1:2", 17, 0.5),
+ ("1:10", 21, 0.1),
+ ("1:20", 23, 0.05),
+ ("1:50", 26, 0.02),
+ ("1:100", 27, 0.01),
+ ("2:1", 28, 2.0),
+ ("10:1", 31, 10.0),
+ ],
+)
+async def test_standard_scale_writes_the_code_and_the_factor_autocad_plots_by(
+ backend, label, code, factor
+):
+ """Under USE_STANDARD_SCALE AutoCAD's plot engine scales by group 147
+ alone (measured, AutoCAD 2026: a headless 1:10 sheet with code 21 and
+ 147 = 1.0 plotted a 100-unit line at 99.99 mm — 1:1 — while the row,
+ ``applied`` and 142/143 all said 1:10; patching 147 to 0.1 alone gave
+ 9.99 mm). AutoCAD writes 147 = numerator / denominator, 1.0 for fit; so
+ does the headless writer, on every route, with the code AutoCAD uses.
+ """
+ await _sheet(backend)
+ result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale=label))
+ assert result["ok"] is True and result["applied"]["scale"] == label
+ dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf
+ assert int(dxf.plot_layout_flags) & 16
+ assert int(dxf.standard_scale_type) == code
+ assert float(dxf.unit_factor) == pytest.approx(factor)
+ ratio = [1.0, 1.0] if label == "fit" else list(scale_ratio(label))
+ assert [float(dxf.scale_numerator), float(dxf.scale_denominator)] == ratio
+ assert (await backend.page_setup_list(SHEET))[0]["scale"] == label
+ frame = backend._paper_frame(backend._doc.layouts.get(SHEET))
+ assert frame["scale"] == label and frame["scale_applied"] is True
+ if label != "fit":
+ assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(420.0 / factor)
+
+
+def _autocad_authored_scale(layout, code, numerator, denominator, factor):
+ """Store the plot scale exactly as AutoCAD 2026 saves it (measured via COM
+ ``SaveAs(..., 61)``): group 75 from the ObjectARX enum, 142/143 as the
+ ratio (AutoCAD normalises 2:1 to 1 / 0.5), 147 as the factor, bit 16 set.
+ """
+ dxf = layout.dxf_layout.dxf
+ if not dxf.hasattr("plot_layout_flags"):
+ dxf.plot_layout_flags = dxf.get_default("plot_layout_flags")
+ dxf.paper_size = "ISO_A3_(420.00_x_297.00_MM)"
+ dxf.paper_width, dxf.paper_height = 420.0, 297.0
+ dxf.plot_paper_units = 1
+ dxf.plot_rotation = 0
+ dxf.standard_scale_type = code
+ dxf.scale_numerator, dxf.scale_denominator = numerator, denominator
+ dxf.unit_factor = factor
+ layout.use_standard_scale(True)
+ layout.set_plot_type(5)
+
+
+@pytest.mark.parametrize(
+ "code, numerator, denominator, factor, label, units_across",
+ [
+ (21, 1.0, 10.0, 0.1, "1:10", 4200.0),
+ (23, 1.0, 20.0, 0.05, "1:20", 8400.0),
+ (26, 1.0, 50.0, 0.02, "1:50", 21000.0),
+ (27, 1.0, 100.0, 0.01, "1:100", 42000.0),
+ (28, 1.0, 0.5, 2.0, "2:1", 210.0),
+ (31, 1.0, 0.1, 10.0, "10:1", 42.0),
+ (19, 1.0, 5.0, 0.2, "1:5", 2100.0),
+ (22, 1.0, 16.0, 0.0625, "1:16", 6720.0),
+ ],
+)
+async def test_list_and_render_read_an_autocad_authored_sheet_at_its_own_scale(
+ backend, code, numerator, denominator, factor, label, units_across
+):
+ """A sheet AutoCAD saved at 1:50 (group 75 = 26) reads back as 1:50 and
+ renders 21000 paper-space units across the 420 mm — not "1:100" / 42000
+ (what ezdxf's one-off ``STD_SCALES`` made of it: 1:10 rendered at 1:16,
+ 1:20 at 1:30, 1:50 at 1:100, 1:100 at 2:1, 2:1 at 4:1, 10:1 at 100:1,
+ with the row and the plot disagreeing). Codes 19 and 22 are ones this
+ server never writes, so this reads the file enum, not the writer's table.
+ """
+ await _sheet(backend)
+ lay = backend._doc.layouts.get(SHEET)
+ _autocad_authored_scale(lay, code, numerator, denominator, factor)
+ row = (await backend.page_setup_list(SHEET))[0]
+ assert row["scale"] == label
+ frame = backend._paper_frame(lay)
+ assert frame["scale"] == label and frame["effective_scale"] == label
+ assert frame["scale_applied"] is True
+ assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(units_across)
+
+
+async def test_apply_keeps_the_viewports(backend):
+ await _sheet(backend)
+ await backend.viewport_create(SHEET, 210, 148.5, 260, 180, 0, 0, scale=1.0)
+ before = (await backend.viewport_list(SHEET))["count"]
+ await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
+ assert (await backend.viewport_list(SHEET))["count"] == before
+
+
+async def test_changed_reports_only_what_moved(backend):
+ await _sheet(backend)
+ first = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
+ assert first["changed"], "a fresh layout differs from an A3 setup somewhere"
+ second = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
+ assert second["changed"] == {}
+ third = await backend.page_setup_apply(
+ SHEET, resolve_page_setup("ISO_A3", plot_style="acad.ctb")
+ )
+ assert third["changed"] == {"plot_style": ["monochrome.ctb", "acad.ctb"]}
+
+
+async def test_unknown_ctb_is_written_and_flagged(backend):
+ await _sheet(backend)
+ result = await backend.page_setup_apply(
+ SHEET, resolve_page_setup("ISO_A3", plot_style="company.ctb")
+ )
+ assert result["plot_style_known"] is False
+ assert (await backend.page_setup_list(SHEET))[0]["plot_style"] == "company.ctb"
+
+
+async def test_refusals_happen_before_any_write(backend):
+ await _sheet(backend)
+ dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf
+ before = dxf.get("paper_size")
+ with pytest.raises(ValueError, match="Model"):
+ await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3"))
+ with pytest.raises(ValueError, match="Nope"):
+ await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3"))
+ with pytest.raises(ValueError, match="canonical_media_name"):
+ await backend.page_setup_apply(SHEET, {"paper": "ISO_A3"})
+ assert dxf.get("paper_size") == before
+ with pytest.raises(ValueError, match="Nope"):
+ await backend.page_setup_list("Nope")
+
+
+async def test_list_covers_every_paper_layout_and_skips_model(backend):
+ await _sheet(backend, "A")
+ await _sheet(backend, "B")
+ names = [row["layout"] for row in await backend.page_setup_list()]
+ assert "Model" not in names and {"A", "B"} <= set(names)
+
+
+def _rotated_a4(backend, name: str = "Rot", rotation: int = 1):
+ """AutoCAD's landscape-on-portrait-media convention: media 210 x 297,
+ ezdxf clockwise margins (top, right, bottom, left) = (20, 7.5, 20, 7.5),
+ turned by ``plot_rotation``."""
+ lay = backend._doc.layouts.get(name)
+ lay.page_setup(
+ size=(210, 297),
+ margins=(20, 7.5, 20, 7.5),
+ units="mm",
+ rotation=rotation,
+ scale=16,
+ name="ISO_A4",
+ device="DWG To PDF.pc3",
+ )
+ return lay
+
+
+async def test_list_turns_the_margins_with_the_sheet(backend):
+ """The row used to swap ``size_mm`` for rotation 1/3 but report the
+ margins un-turned: [20, 20, 7.5, 7.5] on a 297 x 210 sheet whose true
+ margins (ezdxf's Page, and the window ``_paper_frame`` plots) are
+ [7.5, 7.5, 20, 20] — a printable area of 282 x 170 from the row against
+ 257 x 195 on paper."""
+ await _sheet(backend, "Rot")
+ lay = _rotated_a4(backend)
+ row = (await backend.page_setup_list("Rot"))[0]
+ assert row["size_mm"] == [297.0, 210.0] and row["orientation"] == "landscape"
+ assert row["margins_mm"] == [7.5, 7.5, 20.0, 20.0]
+ top, bottom, left, right = row["margins_mm"]
+ assert (297 - left - right, 210 - top - bottom) == (257.0, 195.0)
+
+ # The same sheet the renderer plots: at 1:1 the axes window starts one
+ # (turned) left margin before x = 0 and one (turned) bottom margin below y = 0.
+ frame = backend._paper_frame(lay)
+ assert frame["size_mm"] == row["size_mm"]
+ assert frame["xlim"][0] == pytest.approx(-left) and frame["ylim"][0] == pytest.approx(-bottom)
+ assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(297.0)
+ assert frame["ylim"][1] - frame["ylim"][0] == pytest.approx(210.0)
+
+
+@pytest.mark.parametrize("rotation", [1, 2, 3])
+async def test_apply_on_a_turned_sheet_keeps_the_effective_margins(backend, rotation):
+ """``page_setup_apply`` writes ``plot_rotation`` 0 (orientation rides on
+ width/height). With no ``margins_mm`` asked for, the stored margins used
+ to stay as they were while the turn that gave them their meaning went
+ away, so the effective margins moved from [7.5, 7.5, 20, 20] to
+ [20, 20, 7.5, 7.5] (printable 257 x 195 -> 282 x 170) and ``changed``
+ said nothing. The stored margins are now re-mapped so the sheet keeps
+ the margins it had, and the row before equals the row after."""
+ await _sheet(backend, "Rot")
+ lay = _rotated_a4(backend, rotation=rotation)
+ before = (await backend.page_setup_list("Rot"))[0]
+ result = await backend.page_setup_apply(
+ "Rot", resolve_page_setup("ISO_A4", before["orientation"], scale="1:1")
+ )
+ after = (await backend.page_setup_list("Rot"))[0]
+ assert int(lay.dxf_layout.dxf.plot_rotation) == 0
+ assert after["margins_mm"] == before["margins_mm"]
+ assert after["size_mm"] == before["size_mm"]
+ assert "margins_mm" not in result["changed"] and "size_mm" not in result["changed"]
+ dxf = lay.dxf_layout.dxf
+ top, bottom, left, right = after["margins_mm"]
+ assert (dxf.top_margin, dxf.bottom_margin, dxf.left_margin, dxf.right_margin) == (
+ top,
+ bottom,
+ left,
+ right,
+ )
+
+
+async def test_apply_with_margins_on_a_turned_sheet_writes_them_as_asked(backend):
+ await _sheet(backend, "Rot")
+ _rotated_a4(backend)
+ result = await backend.page_setup_apply(
+ "Rot", resolve_page_setup("ISO_A4", "landscape", margins_mm=[5, 6, 7, 8])
+ )
+ row = (await backend.page_setup_list("Rot"))[0]
+ assert row["margins_mm"] == [5.0, 6.0, 7.0, 8.0]
+ assert result["changed"]["margins_mm"] == [[7.5, 7.5, 20.0, 20.0], [5.0, 6.0, 7.0, 8.0]]
+
+
+def _inch_sheet(backend, name: str = "In"):
+ """An inch-authored ANSI B sheet (what ANSI_A-E exist for): paper-space
+ units are inches, so at 1:1 a 16-unit frame spans 16 inches of paper."""
+ lay = backend._doc.layouts.get(name)
+ lay.page_setup(
+ size=(17, 11),
+ margins=(0.5, 0.5, 0.5, 0.5),
+ units="inch",
+ scale=16,
+ name="ANSI_B",
+ device="DWG To PDF.pc3",
+ )
+ lay.add_lwpolyline([(0, 0), (16, 0), (16, 10), (0, 10)], close=True)
+ return lay
+
+
+async def test_row_reports_the_paper_units(backend):
+ await _sheet(backend, "In")
+ lay = _inch_sheet(backend)
+ row = (await backend.page_setup_list("In"))[0]
+ assert row["paper_units"] == "inches"
+ assert row["size_mm"] == [431.8, 279.4] and row["paper"] == "ANSI_B"
+ assert row["margins_mm"] == [12.7, 12.7, 12.7, 12.7]
+ lay.dxf_layout.dxf.plot_paper_units = 1
+ assert (await backend.page_setup_list("In"))[0]["paper_units"] == "mm"
+
+
+async def test_apply_names_a_paper_units_flip_and_its_consequence(backend):
+ """The default setup plots in millimetres. On an inch-authored sheet that
+ is a 25.4x rescale of every paper-space unit: the 16-unit frame used to
+ go from 406.4 mm to 16 mm of paper with ``changed`` naming only the
+ media and the plot style — ``ok: True``, ``scale_applied: True``."""
+ await _sheet(backend, "In")
+ lay = _inch_sheet(backend)
+ result = await backend.page_setup_apply(
+ "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1")
+ )
+ assert result["changed"]["paper_units"] == ["inches", "mm"]
+ assert int(lay.dxf_layout.dxf.plot_paper_units) == 1
+ assert result["warnings"], "a units flip is never silent"
+ assert any("inches" in w and "mm" in w and "25.4" in w for w in result["warnings"])
+ assert result["applied"]["paper_units"] == "mm"
+ # Re-applying the same setup moves nothing and warns of nothing.
+ again = await backend.page_setup_apply(
+ "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1")
+ )
+ assert again["changed"] == {} and again["warnings"] == []
+
+
+async def test_apply_can_keep_or_set_the_paper_units(backend):
+ await _sheet(backend, "In")
+ lay = _inch_sheet(backend)
+ kept = await backend.page_setup_apply(
+ "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units=None)
+ )
+ assert int(lay.dxf_layout.dxf.plot_paper_units) == 0
+ assert "paper_units" not in kept["changed"] and kept["warnings"] == []
+ assert kept["applied"]["paper_units"] is None
+ # The catalogue's ANSI_B is 432 x 279 mm (ANSI Y14.1's metric value), so
+ # at 1:1 under inches the sheet holds 432 / 25.4 units across.
+ frame = backend._paper_frame(lay)
+ assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(432 / 25.4), "inches at 1:1"
+
+ to_mm = await backend.page_setup_apply(
+ "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units="mm")
+ )
+ assert to_mm["changed"]["paper_units"] == ["inches", "mm"] and to_mm["warnings"]
+ frame = backend._paper_frame(lay)
+ assert frame["xlim"][1] - frame["xlim"][0] == pytest.approx(432.0), "432 mm at 1:1"
+
+ back = await backend.page_setup_apply(
+ "In", resolve_page_setup("ANSI_B", "landscape", scale="1:1", paper_units="inches")
+ )
+ assert back["changed"]["paper_units"] == ["mm", "inches"] and back["warnings"]
+ assert int(lay.dxf_layout.dxf.plot_paper_units) == 0
+
+
+async def test_apply_on_a_mm_sheet_warns_of_nothing(backend):
+ await _sheet(backend)
+ result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
+ assert result["warnings"] == [] and "paper_units" not in result["changed"]
+
+
+async def test_plot_style_list_headless_is_the_catalog(backend):
+ rows = await backend.plot_style_list()
+ assert [row["name"] for row in rows] == list(CTB_CATALOG)
+ assert all(row["source"] == "catalog" and row["installed"] is None for row in rows)
+
+
+async def test_dwt_write_capability_is_declared_false_headlessly(backend):
+ assert backend.capabilities().to_dict()["features"]["dwt_write"]["supported"] is False
+
+
+# ── live engine, against a fake ActiveX surface ──────────────────────────────
+
+_MEDIA_RE = re.compile(r"\((\d+\.\d+)_x_(\d+\.\d+)_(MM|Inches)\)")
+
+
+class _FakeLayout:
+ """Records every property set and method call, in order.
+
+ Enforces the one rule the ActiveX Reference documents for this surface
+ (CenterPlot Property, acadauto.chm, AutoCAD 2026): "This property cannot
+ be set to True on a layout object whose PlotType property is set to
+ acLayout." ``refuse`` names properties whose write raises, the way a live
+ AutoCAD refuses a value it cannot take.
+
+ Replays two behaviours measured live on AutoCAD 2026 (2026-09-17):
+
+ * ``GetPaperSize`` / ``GetPaperMargins`` answer in millimetres whatever
+ ``PaperUnits`` says (ANSI_B under PaperUnits 0 and 1 -> (431.8, 279.4)).
+ * Writing ``ConfigName`` replaces ``CanonicalMediaName`` with the new
+ device's default when the current media does not exist on it, and
+ writing the old device back does not bring the old media back.
+ ``media_names`` is therefore either one tuple (every device carries it)
+ or ``{device: tuple}``; a device missing from the dict carries nothing.
+ """
+
+ AC_LAYOUT = 5
+
+ def __init__(self, name, media_names, refuse=()):
+ object.__setattr__(self, "log", [])
+ media = media_names if isinstance(media_names, dict) else {None: tuple(media_names)}
+ object.__setattr__(self, "_media", {k: tuple(v) for k, v in media.items()})
+ object.__setattr__(self, "_refuse", tuple(refuse))
+ object.__setattr__(
+ self,
+ "_props",
+ {
+ "Name": name,
+ "ConfigName": "None",
+ "CanonicalMediaName": "ISO_A4_(210.00_x_297.00_MM)",
+ "StyleSheet": "",
+ "PlotType": 5,
+ "StandardScale": 16,
+ "UseStandardScale": True,
+ "PlotRotation": 0,
+ "PaperUnits": 1,
+ "CenterPlot": False,
+ "PlotWithPlotStyles": True,
+ },
+ )
+
+ def __getattr__(self, attr):
+ props = object.__getattribute__(self, "_props")
+ if attr in props:
+ return props[attr]
+ raise AttributeError(attr)
+
+ def __setattr__(self, attr, value):
+ if attr in self._refuse:
+ raise RuntimeError(f"AutoCAD refused Layout.{attr} = {value!r}")
+ if attr == "CenterPlot" and value and self._props["PlotType"] == self.AC_LAYOUT:
+ raise RuntimeError("CenterPlot cannot be True while PlotType is acLayout")
+ self._props[attr] = value
+ self.log.append(("set", attr, value))
+ if attr == "ConfigName":
+ names = self._device_media()
+ if names and self._props["CanonicalMediaName"] not in names:
+ self._props["CanonicalMediaName"] = names[0] # the device's default
+
+ def _device_media(self):
+ media = object.__getattribute__(self, "_media")
+ return media.get(self._props["ConfigName"], media.get(None, ()))
+
+ def snapshot(self) -> dict:
+ return dict(object.__getattribute__(self, "_props"))
+
+ def RefreshPlotDeviceInfo(self):
+ self.log.append(("call", "RefreshPlotDeviceInfo"))
+
+ def GetCanonicalMediaNames(self):
+ self.log.append(("call", "GetCanonicalMediaNames"))
+ return self._device_media()
+
+ def GetPaperSize(self):
+ # Millimetres whatever PaperUnits says (measured live, see the class docstring).
+ match = _MEDIA_RE.search(self._props["CanonicalMediaName"])
+ width, height = float(match.group(1)), float(match.group(2))
+ if match.group(3) == "Inches":
+ width, height = round(width * 25.4, 2), round(height * 25.4, 2)
+ return (width, height)
+
+ def GetPaperMargins(self):
+ return ((7.5, 20.0), (7.5, 20.0))
+
+ def GetCustomScale(self):
+ return (1.0, 1.0)
+
+ def SetCustomScale(self, numerator, denominator):
+ self.log.append(("call", "SetCustomScale", numerator, denominator))
+
+
+class _FakeLayouts:
+ def __init__(self, layouts):
+ self.layouts = list(layouts)
+
+ @property
+ def Count(self):
+ return len(self.layouts)
+
+ def Item(self, key):
+ if isinstance(key, int):
+ return self.layouts[key]
+ for layout in self.layouts:
+ if layout.Name == key:
+ return layout
+ raise RuntimeError(f"no layout {key}")
+
+
+MEDIA = (
+ "ISO_A3_(420.00_x_297.00_MM)",
+ "ISO_A4_(210.00_x_297.00_MM)",
+ "ANSI_B_(17.00_x_11.00_Inches)",
+)
+
+
+@pytest.fixture
+def com_backend(monkeypatch):
+ pytest.importorskip("win32com.client", reason="pywin32 not installed")
+ from backends import com_backend as module
+
+ document = types.SimpleNamespace(
+ Layouts=_FakeLayouts([_FakeLayout("Model", MEDIA), _FakeLayout("Layout1", MEDIA)])
+ )
+ monkeypatch.setattr(module, "_acad_doc", lambda: document)
+ backend = module.ComBackend()
+
+ async def _run_inline(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ monkeypatch.setattr(backend, "_run", _run_inline)
+ return backend, document
+
+
+async def test_com_apply_issues_the_property_sequence(com_backend):
+ backend, document = com_backend
+ result = await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", "landscape"))
+ assert result["ok"] is True and result["plot_style_known"] is True
+ layout = document.Layouts.Item("Layout1")
+ assert layout.log == [
+ ("call", "RefreshPlotDeviceInfo"),
+ ("set", "ConfigName", "DWG To PDF.pc3"),
+ ("call", "RefreshPlotDeviceInfo"),
+ ("call", "GetCanonicalMediaNames"),
+ ("set", "CanonicalMediaName", "ISO_A3_(420.00_x_297.00_MM)"),
+ ("set", "PaperUnits", 1),
+ ("set", "PlotRotation", 0),
+ ("set", "StyleSheet", "monochrome.ctb"),
+ ("set", "PlotWithPlotStyles", True),
+ ("set", "PlotType", 5),
+ ("set", "StandardScale", 0),
+ ("set", "UseStandardScale", True),
+ ], "a layout plot never writes CenterPlot (ActiveX refuses True under acLayout)"
+ assert result["changed"]["paper"] == ["ISO_A4", "ISO_A3"]
+ assert result["changed"]["device"] == ["None", "DWG To PDF.pc3"]
+ assert result["applied"]["center"] is None
+ assert "center" not in result["changed"]
+
+
+async def test_com_apply_extents_sets_plot_type_before_center_plot(com_backend):
+ backend, document = com_backend
+ result = await backend.page_setup_apply(
+ "Layout1", resolve_page_setup("ISO_A3", plot_area="extents", center=True)
+ )
+ log = document.Layouts.Item("Layout1").log
+ assert log.index(("set", "PlotType", 1)) < log.index(("set", "CenterPlot", True))
+ assert result["changed"]["center"] == [None, True]
+ assert result["changed"]["plot_area"] == ["layout", "extents"]
+ assert (await backend.page_setup_list("Layout1"))[0]["center"] is True
+
+
+async def test_com_apply_clears_a_stale_center_plot_before_switching_to_layout(com_backend):
+ backend, document = com_backend
+ layout = document.Layouts.Item("Layout1")
+ layout.PlotType = 1
+ layout.CenterPlot = True
+ del layout.log[:]
+ result = await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3"))
+ log = layout.log
+ assert log.index(("set", "CenterPlot", False)) < log.index(("set", "PlotType", 5))
+ assert layout.CenterPlot is False and layout.PlotType == 5
+ assert result["changed"]["center"] == [True, None]
+
+
+async def test_com_apply_restores_every_write_when_autocad_refuses_a_property(com_backend):
+ """Nine writes had landed before CenterPlot in the shipped sequence, with
+ nothing put back. Any refusal now unwinds the journal in reverse."""
+ backend, document = com_backend
+ layout = _FakeLayout("Layout1", MEDIA, refuse=("PlotWithPlotStyles",))
+ document.Layouts.layouts[1] = layout
+ before = layout.snapshot()
+ with pytest.raises(ValueError, match="PlotWithPlotStyles") as info:
+ await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3"))
+ assert "restored" in str(info.value)
+ assert layout.snapshot() == before, "every property AutoCAD accepted is put back"
+ restores = [entry for entry in layout.log if entry[0] == "set"][5:]
+ assert restores == [
+ ("set", "StyleSheet", ""),
+ ("set", "PlotRotation", 0),
+ ("set", "PaperUnits", 1),
+ ("set", "CanonicalMediaName", "ISO_A4_(210.00_x_297.00_MM)"),
+ ("set", "ConfigName", "None"),
+ ], "unwound in reverse write order"
+
+
+async def test_com_apply_restores_the_custom_scale_too(com_backend):
+ backend, document = com_backend
+ layout = _FakeLayout("Layout1", MEDIA, refuse=("UseStandardScale",))
+ document.Layouts.layouts[1] = layout
+ with pytest.raises(ValueError, match="UseStandardScale"):
+ await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", scale="5:1"))
+ calls = [entry for entry in layout.log if entry[0] == "call" and entry[1] == "SetCustomScale"]
+ assert calls == [("call", "SetCustomScale", 5.0, 1.0), ("call", "SetCustomScale", 1.0, 1.0)]
+
+
+async def test_com_apply_custom_scale_uses_set_custom_scale(com_backend):
+ backend, document = com_backend
+ await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", scale="5:1"))
+ log = document.Layouts.Item("Layout1").log
+ assert ("call", "SetCustomScale", 5.0, 1.0) in log
+ assert ("set", "UseStandardScale", False) in log
+ assert not any(entry[:2] == ("set", "StandardScale") for entry in log)
+
+
+async def test_com_apply_refuses_margins_before_any_write(com_backend):
+ backend, document = com_backend
+ with pytest.raises(ValueError, match="margins_mm"):
+ await backend.page_setup_apply(
+ "Layout1", resolve_page_setup("ISO_A3", margins_mm=[10, 10, 10, 10])
+ )
+ assert document.Layouts.Item("Layout1").log == []
+
+
+async def test_com_apply_restores_the_device_when_the_media_name_is_unknown(com_backend):
+ backend, document = com_backend
+ layout = document.Layouts.Item("Layout1")
+ object.__setattr__(layout, "_media", {None: ("ISO_A4_(210.00_x_297.00_MM)",)})
+ with pytest.raises(ValueError, match=r"ISO_A3_\(420.00_x_297.00_MM\)"):
+ await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3"))
+ assert layout.ConfigName == "None", "the device write is rolled back"
+ assert layout.CanonicalMediaName == "ISO_A4_(210.00_x_297.00_MM)"
+
+
+PDF_DEVICE = "DWG To PDF.pc3"
+MS_DEVICE = "Microsoft Print to PDF"
+ISO_A3_MEDIA = "ISO_A3_(420.00_x_297.00_MM)"
+#: Two devices whose media do not overlap, the way the two PDF drivers on the
+#: machine measured: switching to MS moves an ISO_A3 layout to psk:ISOA4, and
+#: switching back lands on DWG To PDF's default, not on ISO_A3.
+TWO_DEVICES = {
+ PDF_DEVICE: ("ANSI_A_(11.00_x_8.50_Inches)", ISO_A3_MEDIA),
+ MS_DEVICE: ("psk:ISOA4", "ISO_A0_(1189.00_x_841.00_MM)"),
+}
+
+
+def _layout_on_pdf_device(document, **kwargs):
+ layout = _FakeLayout("Layout1", TWO_DEVICES, **kwargs)
+ layout.ConfigName = PDF_DEVICE
+ layout.CanonicalMediaName = ISO_A3_MEDIA
+ del layout.log[:]
+ document.Layouts.layouts[1] = layout
+ return layout
+
+
+async def test_com_apply_puts_the_media_back_when_the_device_switch_replaced_it(com_backend):
+ """Measured live: refusing a media unknown on the new device used to leave
+ the layout on the old device with that device's *default* media (ANSI_A),
+ not the ISO_A3 it had — while the error said it was restored."""
+ backend, document = com_backend
+ layout = _layout_on_pdf_device(document)
+ before = layout.snapshot()
+ with pytest.raises(ValueError, match="has no media") as info:
+ await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A2", device=MS_DEVICE))
+ assert "could NOT" not in str(info.value)
+ assert layout.snapshot() == before, "device back, then the original media re-applied"
+ assert (await backend.page_setup_list("Layout1"))[0]["paper"] == "ISO_A3"
+
+
+async def test_com_apply_late_refusal_unwinds_through_the_device_switch(com_backend):
+ """The media journaled *after* the device switch is the new device's
+ default; only the pre-switch snapshot can put ISO_A3 back."""
+ backend, document = com_backend
+ layout = _layout_on_pdf_device(document, refuse=("PlotWithPlotStyles",))
+ before = layout.snapshot()
+ with pytest.raises(ValueError, match="PlotWithPlotStyles") as info:
+ await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A0", device=MS_DEVICE))
+ assert "restored to its previous page setup" in str(info.value)
+ assert layout.snapshot() == before
+ sets = [entry for entry in layout.log if entry[0] == "set"]
+ assert sets[-2:] == [
+ ("set", "ConfigName", PDF_DEVICE),
+ ("set", "CanonicalMediaName", ISO_A3_MEDIA),
+ ]
+
+
+class _StickyMediaLayout(_FakeLayout):
+ """Accepts every write but silently drops CanonicalMediaName once the device
+ has been switched back — what a driver that ignores the write looks like."""
+
+ def __setattr__(self, attr, value):
+ if attr == "CanonicalMediaName" and self._props["ConfigName"] == PDF_DEVICE and self.log:
+ return
+ super().__setattr__(attr, value)
+
+
+async def test_com_apply_reports_what_the_unwind_could_not_put_back(com_backend):
+ backend, document = com_backend
+ layout = _StickyMediaLayout("Layout1", TWO_DEVICES)
+ layout.ConfigName = PDF_DEVICE
+ object.__getattribute__(layout, "_props")["CanonicalMediaName"] = ISO_A3_MEDIA
+ del layout.log[:]
+ document.Layouts.layouts[1] = layout
+ with pytest.raises(ValueError, match="has no media") as info:
+ await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A2", device=MS_DEVICE))
+ message = str(info.value)
+ assert "could NOT be fully restored" in message
+ assert "canonical_media_name" in message and "paper" in message
+ assert layout.ConfigName == PDF_DEVICE and layout.CanonicalMediaName != ISO_A3_MEDIA
+
+
+async def test_com_apply_refuses_model_and_unknown_layouts(com_backend):
+ backend, _ = com_backend
+ with pytest.raises(ValueError, match="Model"):
+ await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3"))
+ with pytest.raises(ValueError, match="Nope"):
+ await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3"))
+
+
+async def test_com_list_reads_the_properties(com_backend):
+ backend, _ = com_backend
+ rows = await backend.page_setup_list()
+ assert [row["layout"] for row in rows] == ["Layout1"]
+ row = rows[0]
+ assert row["paper"] == "ISO_A4" and row["size_mm"] == [210.0, 297.0]
+ assert row["orientation"] == "portrait"
+ assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5]
+ assert row["scale"] == "1:1" and row["plot_area"] == "layout"
+ assert row["device"] == "None"
+ assert row["center"] is None, "acLayout: CenterPlot is not applicable"
+
+
+async def test_com_list_never_scales_the_paper_by_paper_units(com_backend):
+ """GetPaperSize / GetPaperMargins are millimetres under PaperUnits=0 too
+ (measured live) — the 25.4 factor listed every imperial layout as
+ [10668.0, 7543.8] and lost the paper name."""
+ backend, document = com_backend
+ layout = document.Layouts.Item("Layout1")
+ layout.PaperUnits = 0
+ layout.CanonicalMediaName = "ANSI_B_(17.00_x_11.00_Inches)"
+ row = (await backend.page_setup_list("Layout1"))[0]
+ assert row["size_mm"] == [431.8, 279.4] and row["paper"] == "ANSI_B"
+ assert row["orientation"] == "landscape"
+ assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5]
+ layout.CanonicalMediaName = "ISO_A3_(420.00_x_297.00_MM)"
+ assert (await backend.page_setup_list("Layout1"))[0]["size_mm"] == [420.0, 297.0]
+
+
+async def test_com_list_reports_center_for_an_extents_plot(com_backend):
+ backend, document = com_backend
+ layout = document.Layouts.Item("Layout1")
+ layout.PlotType = 1
+ assert (await backend.page_setup_list("Layout1"))[0]["center"] is False
+ layout.CenterPlot = True
+ assert (await backend.page_setup_list("Layout1"))[0]["center"] is True
+
+
+async def test_com_declares_dwt_write():
+ pytest.importorskip("win32com.client", reason="pywin32 not installed")
+ from backends.com_backend import ComBackend
+
+ assert ComBackend().capabilities().to_dict()["features"]["dwt_write"]["supported"] is True
+
+
+async def test_com_list_reports_the_paper_units(com_backend):
+ backend, document = com_backend
+ layout = document.Layouts.Item("Layout1")
+ assert (await backend.page_setup_list("Layout1"))[0]["paper_units"] == "mm"
+ layout.PaperUnits = 0
+ assert (await backend.page_setup_list("Layout1"))[0]["paper_units"] == "inches"
+
+
+async def test_com_list_turns_the_margins_with_a_rotated_sheet(com_backend):
+ """GetPaperMargins reads PLOTSETTINGS 40-43 — the media's own frame, the
+ fields the DXF row reads — so a rotated sheet turns them the same way."""
+ backend, document = com_backend
+ layout = document.Layouts.Item("Layout1")
+ layout.PlotRotation = 1
+ row = (await backend.page_setup_list("Layout1"))[0]
+ assert row["size_mm"] == [297.0, 210.0]
+ # fake margins: left 7.5, bottom 20, right 7.5, top 20 -> rotation 1:
+ # top=right, right=bottom, bottom=left, left=top -> [7.5, 7.5, 20, 20].
+ assert row["margins_mm"] == [7.5, 7.5, 20.0, 20.0]
+ layout.PlotRotation = 0
+ assert (await backend.page_setup_list("Layout1"))[0]["margins_mm"] == [20.0, 20.0, 7.5, 7.5]
+
+
+async def test_com_apply_names_a_paper_units_flip(com_backend):
+ backend, document = com_backend
+ layout = document.Layouts.Item("Layout1")
+ layout.PaperUnits = 0
+ layout.log.clear()
+ result = await backend.page_setup_apply(
+ "Layout1", resolve_page_setup("ANSI_B", "landscape", scale="1:1")
+ )
+ assert ("set", "PaperUnits", 1) in layout.log
+ assert result["changed"]["paper_units"] == ["inches", "mm"]
+ assert any("25.4" in w for w in result["warnings"])
+ again = await backend.page_setup_apply(
+ "Layout1", resolve_page_setup("ANSI_B", "landscape", scale="1:1")
+ )
+ assert again["changed"] == {} and again["warnings"] == []
+
+
+async def test_com_apply_keeps_or_sets_the_paper_units(com_backend):
+ backend, document = com_backend
+ layout = document.Layouts.Item("Layout1")
+ layout.PaperUnits = 0
+ layout.log.clear()
+ kept = await backend.page_setup_apply(
+ "Layout1", resolve_page_setup("ANSI_B", "landscape", paper_units=None)
+ )
+ assert not [entry for entry in layout.log if entry[:2] == ("set", "PaperUnits")]
+ assert layout.PaperUnits == 0 and "paper_units" not in kept["changed"]
+ assert kept["warnings"] == []
+ layout.log.clear()
+ inches = await backend.page_setup_apply(
+ "Layout1", resolve_page_setup("ANSI_B", "landscape", paper_units="inches")
+ )
+ assert ("set", "PaperUnits", 0) in layout.log and inches["changed"] == {}
+ assert inches["warnings"] == []
+
+
+async def test_com_apply_on_a_rotated_sheet_reports_the_margins_that_moved(com_backend):
+ """COM cannot write margins (they come from the .pc3), so resetting
+ PlotRotation on a turned sheet moves the effective margins — and the row
+ now says so instead of listing only the media."""
+ backend, document = com_backend
+ layout = document.Layouts.Item("Layout1")
+ layout.PlotRotation = 1
+ result = await backend.page_setup_apply(
+ "Layout1", resolve_page_setup("ISO_A3", "landscape", scale="1:1")
+ )
+ assert result["changed"]["margins_mm"] == [[7.5, 7.5, 20.0, 20.0], [20.0, 20.0, 7.5, 7.5]]
+ assert result["changed"]["size_mm"] == [[297.0, 210.0], [420.0, 297.0]]
diff --git a/tests/test_templates.py b/tests/test_templates.py
new file mode 100644
index 0000000..2ba62ac
--- /dev/null
+++ b/tests/test_templates.py
@@ -0,0 +1,487 @@
+"""Bundled templates: catalogue, resolution, reproducible build, drawing_new(template=)."""
+
+from __future__ import annotations
+
+import importlib.util
+import types
+from pathlib import Path
+
+import pytest
+from fastmcp.exceptions import ToolError
+
+import server
+from backends.base import UnsupportedCapabilityError
+from engineering.standards.templates import (
+ TEMPLATE_CATALOG,
+ TEMPLATES_DIR,
+ resolve_template,
+ template_rows,
+)
+
+pytestmark = pytest.mark.asyncio
+
+ROOT = Path(__file__).resolve().parents[1]
+NAMES = ("iso_a3_mech", "iso_a1_arch", "iso_a3_pid", "ansi_b_mech", "ansi_d_arch")
+
+
+def _build_module():
+ spec = importlib.util.spec_from_file_location(
+ "build_templates", ROOT / "scripts" / "build_templates.py"
+ )
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+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
+
+
+# ── catalogue ───────────────────────────────────────────────────────────────
+
+
+def test_catalog_has_the_five_spec_rows():
+ assert tuple(TEMPLATE_CATALOG) == NAMES
+ rows = {spec.name: spec for spec in TEMPLATE_CATALOG.values()}
+ assert (rows["iso_a3_mech"].paper, rows["iso_a3_mech"].layer_set) == ("ISO_A3", "mech")
+ assert (rows["iso_a1_arch"].paper, rows["iso_a1_arch"].layer_set) == ("ISO_A1", "iso13567")
+ assert rows["iso_a1_arch"].settings["annotation_scale"] == "1:50"
+ assert (rows["iso_a3_pid"].paper, rows["iso_a3_pid"].layer_set) == ("ISO_A3", "pid")
+ assert (rows["ansi_b_mech"].paper, rows["ansi_b_mech"].dimstyle) == ("ANSI_B", "ANSI")
+ assert (rows["ansi_d_arch"].paper, rows["ansi_d_arch"].scale) == ("ANSI_D", "fit")
+ assert [spec.title_block for spec in TEMPLATE_CATALOG.values()] == [
+ True,
+ False,
+ True,
+ False,
+ False,
+ ]
+ assert all(spec.orientation == "landscape" for spec in TEMPLATE_CATALOG.values())
+ assert {spec.textstyle for spec in TEMPLATE_CATALOG.values()} == {"ISOCP", "ROMANS"}
+
+
+def test_template_rows_report_files():
+ rows = template_rows()
+ assert [row["name"] for row in rows] == list(NAMES)
+ for row in rows:
+ assert set(row["files"]) == {"dxf", "dwt"}
+ assert row["files"]["dxf"]["path"].endswith(f"{row['name']}.dxf")
+ assert isinstance(row["files"]["dxf"]["present"], bool)
+
+
+def test_resolve_template_bundled_path_and_unknown(tmp_path):
+ path, source = resolve_template("iso_a3_mech", "ezdxf")
+ assert source == "bundled" and path == str(TEMPLATES_DIR / "iso_a3_mech.dxf")
+ path, source = resolve_template("ISO_A3_MECH", "ezdxf")
+ assert source == "bundled"
+ # COM prefers the .dwt twin and says when it fell back to the DXF
+ path, source = resolve_template("iso_a3_mech", "com")
+ assert source in ("bundled", "bundled_dxf")
+ assert path.endswith(".dwt" if source == "bundled" else ".dxf")
+ explicit = tmp_path / "mine.dwt"
+ assert resolve_template(str(explicit), "ezdxf") == (str(explicit), "path")
+ assert resolve_template("sheet.dxf", "com") == ("sheet.dxf", "path")
+ with pytest.raises(
+ ValueError, match="iso_a3_mech, iso_a1_arch, iso_a3_pid, ansi_b_mech, ansi_d_arch"
+ ):
+ resolve_template("iso_a2_mech", "ezdxf")
+ with pytest.raises(ValueError, match="template"):
+ resolve_template("", "ezdxf")
+
+
+# ── the build ───────────────────────────────────────────────────────────────
+
+
+async def test_build_is_deterministic_run_to_run(tmp_path):
+ build = _build_module()
+ first = await build.build_all(tmp_path / "one")
+ second = await build.build_all(tmp_path / "two")
+ assert [row["name"] for row in first] == list(NAMES)
+ for name in NAMES:
+ a = build.normalised_lines(tmp_path / "one" / f"{name}.dxf")
+ b = build.normalised_lines(tmp_path / "two" / f"{name}.dxf")
+ assert a == b, f"{name} differs between two builds"
+ assert all(row["page_setup"]["ok"] for row in first)
+ assert all(row["layers"] for row in first)
+ assert second # the second build is only there to be compared with the first
+
+
+async def test_committed_templates_match_a_fresh_build(tmp_path):
+ """The spec's reproducibility claim — the same comparison the script's --check runs."""
+ build = _build_module()
+ missing = [name for name in NAMES if not (TEMPLATES_DIR / f"{name}.dxf").is_file()]
+ assert not missing, f"bundled templates missing: {missing} — run scripts/build_templates.py"
+ await build.build_all(tmp_path)
+ drifted = build.drifted_names(tmp_path)
+ assert drifted == [], f"templates drifted from the script: {drifted}"
+
+
+# ── drawing_new(template=) ──────────────────────────────────────────────────
+
+
+async def test_drawing_new_from_a_bundled_template(backend):
+ result = await server.drawing_new(template="iso_a3_mech", ctx=_Ctx(backend))
+ assert result["ok"] is True
+ assert result["template"] == {
+ "name": "iso_a3_mech",
+ "path": str(TEMPLATES_DIR / "iso_a3_mech.dxf"),
+ "source": "bundled",
+ }
+ layers = {layer.name for layer in await backend.layer_list()}
+ assert {"GEOMETRY", "DIM", "HIDDEN", "CENTER", "TITLEBLOCK"} <= layers
+ row = (await backend.page_setup_list("A3"))[0]
+ assert row["paper"] == "ISO_A3" and row["orientation"] == "landscape"
+ assert row["plot_style"] == "monochrome.ctb" and row["scale"] == "1:1"
+ sheet = backend._doc.layouts.get("A3")
+ assert any(e.dxftype() == "LWPOLYLINE" for e in sheet), "the ISO 5457 frame is on the sheet"
+ assert float(await backend.system_get_variable("DIMTXT")) == 2.5
+ if hasattr(backend, "dimstyle_create"): # after group S merges
+ assert await backend.system_get_variable("DIMSTYLE") == "ISO-25"
+
+
+async def test_drawing_new_from_the_pid_and_ansi_templates(backend):
+ await server.drawing_new(template="iso_a3_pid", ctx=_Ctx(backend))
+ assert "PROCESS-PIPING-MAIN" in {layer.name for layer in await backend.layer_list()}
+ await server.drawing_new(template="ansi_d_arch", ctx=_Ctx(backend))
+ assert "M-GEOMET-E-N" in {layer.name for layer in await backend.layer_list()}
+ row = (await backend.page_setup_list("D"))[0]
+ assert row["paper"] == "ANSI_D" and row["size_mm"] == [864.0, 559.0] and row["scale"] == "fit"
+ assert (await backend.drawing_settings())["settings"]["decimal_separator"] == "."
+
+
+#: Spec §4.1, the eight variables `_with_header_dimvars` does not fold — the
+#: ones a header-only write silently loses on the headless renderer.
+_PLACEMENT = ("dimtad", "dimtih", "dimtoh", "dimexo", "dimexe", "dimgap", "dimlunit", "dimlwd")
+
+
+async def _effective_dimvars(backend) -> dict:
+ """What the next dimension is really rendered with, read off the entity."""
+ from ezdxf.entities import DimStyleOverride
+
+ info = await backend.dimension_linear(0, 0, 100, 0, 50, 20)
+ override = DimStyleOverride(backend._doc.entitydb[info.handle])
+ return {name: override.get(name) for name in _PLACEMENT + ("dimtxt", "dimasz")}
+
+
+async def test_templates_dimension_with_their_own_preset_headlessly(backend):
+ """The build's fallback lands on the ``Standard`` dimstyle, not only the header.
+
+ Measured before the fix: ``ansi_b_mech`` reported DIMTAD 0 / DIMTIH 1 from
+ ``system_get_variable`` while the dimension it drew used dimtad=1 / dimtih=0
+ (the ISO values on ``Standard``), so the same template dimensioned
+ differently per engine. The header and the style must agree.
+ """
+ await server.drawing_new(template="ansi_b_mech", ctx=_Ctx(backend))
+ ansi = await _effective_dimvars(backend)
+ assert ansi == {
+ "dimtad": 0,
+ "dimtih": 1,
+ "dimtoh": 1,
+ "dimexo": 1.5,
+ "dimexe": 1.5,
+ "dimgap": 1.0,
+ "dimlunit": 2,
+ "dimlwd": -2,
+ "dimtxt": 3.0,
+ "dimasz": 3.0,
+ }
+ for name, value in ansi.items():
+ header = await backend.system_get_variable(name.upper())
+ assert float(header) == float(value), f"{name}: header {header} != style {value}"
+
+ await server.drawing_new(template="iso_a3_mech", ctx=_Ctx(backend))
+ iso = await _effective_dimvars(backend)
+ assert (iso["dimtad"], iso["dimtih"], iso["dimtoh"]) == (1, 0, 0)
+ assert (iso["dimexo"], iso["dimexe"], iso["dimgap"]) == (0.625, 1.25, 0.625)
+ assert (iso["dimtxt"], iso["dimasz"]) == (2.5, 2.5)
+
+
+async def test_drawing_new_unknown_template_is_refused_with_the_names(backend):
+ await backend.entity_create_line(0, 0, 1, 1)
+ before = (await backend.drawing_info()).entity_count
+ with pytest.raises(ToolError, match="iso_a3_mech"):
+ await server.drawing_new(template="iso_a2_mech", ctx=_Ctx(backend))
+ assert (await backend.drawing_info()).entity_count == before, "the refusal replaced nothing"
+
+
+async def test_drawing_new_missing_template_path_is_refused(backend, tmp_path):
+ with pytest.raises(ToolError, match="not found"):
+ await server.drawing_new(template=str(tmp_path / "gone.dxf"), ctx=_Ctx(backend))
+
+
+async def test_drawing_new_from_an_explicit_dxf_path_reports_source_path(backend, tmp_path):
+ await backend.layer_create("MINE", color=3)
+ saved = tmp_path / "mine.dxf"
+ await backend.drawing_save_as(str(saved), "dxf")
+ result = await server.drawing_new(template=str(saved), ctx=_Ctx(backend))
+ assert result["template"]["source"] == "path"
+ assert result["template"]["path"] == str(saved.resolve())
+ assert "MINE" in {layer.name for layer in await backend.layer_list()}
+
+
+async def test_drawing_template_list_tool(backend):
+ result = await server.drawing_template_list(ctx=_Ctx(backend))
+ assert result["ok"] and result["count"] == 5
+ assert [row["name"] for row in result["templates"]] == list(NAMES)
+ assert result["engine"] == "ezdxf"
+
+
+# ── COM: only a real .dwt reaches Documents.Add ─────────────────────────────
+
+
+class _FakeDocuments:
+ """Records the ActiveX calls; ``Add`` on a non-``.dwt`` answers blank, as
+ AutoCAD 2026 does (measured: ``Add()`` == ``Add()`` == ``Add()``)."""
+
+ def __init__(self, dwt_bytes: bytes = b"AC1032\x00template"):
+ self.calls: list[tuple] = []
+ self.dwt_bytes = dwt_bytes
+ self.open_docs: list[_FakeDoc] = []
+
+ def Add(self, template=None):
+ self.calls.append(("Add", template))
+ real = template is not None and str(template).lower().endswith(".dwt")
+ real = real and Path(template).is_file() and Path(template).read_bytes()[:6] == b"AC1032"
+ return _FakeDoc(self, "Drawing1.dwg", layers=["0", "GEOMETRY"] if real else ["0"])
+
+ def Open(self, path, read_only=False, password=None):
+ self.calls.append(("Open", path, read_only))
+ doc = _FakeDoc(self, Path(path).name, layers=["0", "GEOMETRY"])
+ self.open_docs.append(doc)
+ return doc
+
+
+class _FakeDoc:
+ def __init__(self, documents, name, layers):
+ self._documents = documents
+ self.Name = name
+ self.layers = layers
+ self.closed = None
+
+ def SaveAs(self, path, fmt=None):
+ self._documents.calls.append(("SaveAs", path, fmt))
+ if fmt != 66:
+ raise RuntimeError(f"fake AutoCAD 2026 refuses SaveAs format {fmt}")
+ Path(path).write_bytes(self._documents.dwt_bytes)
+
+ def Close(self, save=True):
+ self._documents.calls.append(("Close", self.Name, save))
+ self.closed = save
+ self._documents.open_docs.remove(self)
+
+
+@pytest.fixture
+def com_new(monkeypatch, tmp_path):
+ pytest.importorskip("win32com.client", reason="pywin32 not installed")
+ from backends import com_backend as module
+
+ documents = _FakeDocuments()
+ app = types.SimpleNamespace(Documents=documents)
+ monkeypatch.setattr(module, "_acad_app", lambda: app)
+ monkeypatch.setattr(module, "_template_cache_dir", lambda: tmp_path / "cache")
+ monkeypatch.setattr(module, "_COM_ERROR", (RuntimeError,))
+ backend = module.ComBackend()
+
+ async def _run_inline(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ async def _no_state():
+ pass
+
+ monkeypatch.setattr(backend, "_run", _run_inline)
+ monkeypatch.setattr(backend, "_ensure_document_state", _no_state)
+ return backend, documents
+
+
+async def test_com_drawing_new_converts_a_dxf_into_a_real_dwt_once(com_new, tmp_path):
+ backend, documents = com_new
+ dxf = TEMPLATES_DIR / "iso_a3_pid.dxf"
+ result = await backend.drawing_new(str(dxf))
+ assert result["ok"] and result["name"] == "Drawing1.dwg"
+ dwt = Path(result["template_dwt"]["path"])
+ assert result["template_dwt"]["cached"] is False
+ assert dwt.suffix == ".dwt" and dwt.is_file() and dwt.parent == tmp_path / "cache"
+ assert documents.calls == [
+ ("Open", str(dxf), True),
+ ("SaveAs", str(dwt), 66),
+ ("Close", "iso_a3_pid.dxf", False),
+ ("Add", str(dwt)),
+ ], "open read-only, save as ac2018_Template, close without saving, then Add the .dwt"
+ assert documents.open_docs == [], "the conversion document never stays open"
+
+ documents.calls.clear()
+ again = await backend.drawing_new(str(dxf))
+ assert again["template_dwt"] == {"path": str(dwt), "cached": True}
+ assert documents.calls == [("Add", str(dwt))], "the second call reuses the cached .dwt"
+
+
+async def test_com_drawing_new_hands_a_dwt_straight_to_add_and_refuses_a_missing_file(
+ com_new, tmp_path
+):
+ backend, documents = com_new
+ mine = tmp_path / "mine.dwt"
+ mine.write_bytes(documents.dwt_bytes)
+ result = await backend.drawing_new(str(mine))
+ assert result == {"ok": True, "name": "Drawing1.dwg"}
+ assert documents.calls == [("Add", str(mine))]
+
+ documents.calls.clear()
+ with pytest.raises(FileNotFoundError, match="not found"):
+ await backend.drawing_new(str(tmp_path / "gone.dwt"))
+ assert documents.calls == [], "Documents.Add() would silently create the default"
+
+
+async def test_com_drawing_new_falls_back_to_an_older_template_format(com_new, monkeypatch):
+ backend, documents = com_new
+ accepted = []
+
+ def _save_as(self, path, fmt=None):
+ documents.calls.append(("SaveAs", path, fmt))
+ if fmt == 66:
+ raise RuntimeError("older seat: unknown SaveAs type")
+ accepted.append(fmt)
+ Path(path).write_bytes(documents.dwt_bytes)
+
+ monkeypatch.setattr(_FakeDoc, "SaveAs", _save_as)
+ result = await backend.drawing_new(str(TEMPLATES_DIR / "ansi_b_mech.dxf"))
+ assert result["ok"] and accepted == [62]
+ assert documents.open_docs == []
+
+
+async def test_com_drawing_new_tool_reports_the_converted_dwt(com_new):
+ backend, documents = com_new
+ result = await server.drawing_new(template="iso_a3_pid", bootstrap=False, ctx=_Ctx(backend))
+ assert result["ok"] is True
+ assert "template_dwt" not in result
+ template = result["template"]
+ assert template["name"] == "iso_a3_pid"
+ assert template["source"] in ("bundled", "bundled_dxf")
+ if template["source"] == "bundled_dxf": # no .dwt twin committed: the live engine built one
+ assert template["path"] == str(TEMPLATES_DIR / "iso_a3_pid.dxf")
+ assert template["dwt"].endswith(".dwt") and template["dwt_cached"] is False
+ assert documents.calls[-1] == ("Add", template["dwt"])
+ else:
+ assert "dwt" not in template and documents.calls == [("Add", template["path"])]
+
+
+# ── drawing_template_save ───────────────────────────────────────────────────
+
+
+async def test_template_save_dxf_headless_round_trips(backend, tmp_path):
+ await backend.layer_create("MINE", color=3)
+ out = tmp_path / "mine_template.dxf"
+ result = await backend.drawing_template_save(str(out), name="Mine", description="my sheet")
+ assert result == {
+ "ok": True,
+ "path": str(out),
+ "format": "dxf",
+ "backend": "ezdxf",
+ "name": "Mine",
+ "description_written": False,
+ "description_note": (
+ "DXF has no template description field; it is a DWG/DWT summary property "
+ "(capability 'dwgprops')"
+ ),
+ }
+ assert out.is_file()
+ await server.drawing_new(template=str(out), ctx=_Ctx(backend))
+ assert "MINE" in {layer.name for layer in await backend.layer_list()}
+
+
+async def test_template_save_dwt_headless_is_refused_with_the_capability(backend, tmp_path):
+ out = tmp_path / "mine.dwt"
+ with pytest.raises(UnsupportedCapabilityError) as excinfo:
+ await backend.drawing_template_save(str(out))
+ assert excinfo.value.capability == "dwt_write"
+ message = str(excinfo.value)
+ assert ".dxf" in message and "com" in message.lower()
+ assert not out.exists()
+
+
+async def test_template_save_refuses_other_suffixes(backend, tmp_path):
+ with pytest.raises(ValueError, match=r"\.dxf"):
+ await backend.drawing_template_save(str(tmp_path / "mine.txt"))
+
+
+async def test_template_save_tool_validates_the_path(backend, tmp_path):
+ with pytest.raises(ToolError):
+ await server.drawing_template_save("../../outside.dxf", ctx=_Ctx(backend))
+ result = await server.drawing_template_save(str(tmp_path / "t.dxf"), ctx=_Ctx(backend))
+ assert result["ok"] and result["name"] == "t"
+
+
+class _SaveRecorder:
+ def __init__(self):
+ self.calls = []
+ self.SummaryInfo = types.SimpleNamespace()
+ self.Name = "Drawing1.dwg"
+
+ def SaveAs(self, path, fmt=None):
+ self.calls.append(("SaveAs", path, fmt))
+ self.Name = Path(path).name
+
+
+@pytest.fixture
+def com_backend(monkeypatch):
+ pytest.importorskip("win32com.client", reason="pywin32 not installed")
+ from backends import com_backend as module
+
+ document = _SaveRecorder()
+ monkeypatch.setattr(module, "_acad_doc", lambda: document)
+ backend = module.ComBackend()
+
+ async def _run_inline(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ monkeypatch.setattr(backend, "_run", _run_inline)
+ return backend, document
+
+
+async def test_com_template_save_dwt_uses_ac2018_template(com_backend, tmp_path):
+ backend, document = com_backend
+ out = str(tmp_path / "mine.dwt")
+ result = await backend.drawing_template_save(out, description="A3 mech")
+ assert document.calls == [("SaveAs", out, 66)]
+ assert document.SummaryInfo.Comments == "A3 mech"
+ assert result == {
+ "ok": True,
+ "path": out,
+ "format": "dwt",
+ "backend": "com",
+ "name": "mine",
+ "description_written": True,
+ "document_rebound": True,
+ }
+
+
+async def test_com_template_save_dxf_uses_the_dxf_code(com_backend, tmp_path):
+ backend, document = com_backend
+ out = str(tmp_path / "mine.dxf")
+ result = await backend.drawing_template_save(out)
+ assert document.calls == [("SaveAs", out, 61)]
+ assert result["format"] == "dxf" and result["description_written"] is False
+
+
+async def test_com_save_as_dwt_no_longer_writes_an_r13_dxf(com_backend, tmp_path):
+ """drawing_save_as mapped 'dwt' to 5, which is AcSaveAsType.acR13_dxf."""
+ backend, document = com_backend
+ out = str(tmp_path / "x.dwt")
+ await backend.drawing_save_as(out, "dwt")
+ assert document.calls == [("SaveAs", out, 66)]
+
+
+async def test_com_template_save_refuses_other_suffixes_before_saving(com_backend, tmp_path):
+ backend, document = com_backend
+ with pytest.raises(ValueError, match=r"\.dwt"):
+ await backend.drawing_template_save(str(tmp_path / "x.dwg"))
+ assert document.calls == []
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index 5e0c77c..26b1980 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 (172 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
@@ -528,8 +528,15 @@ async def test_tool_group_sizes_are_unchanged():
as a read-only tool) opened SECTION 20. `drawing` moved 11 -> 13 when
v1.6's `drawing_properties_get` / `drawing_properties_set` (DWGPROPS on
both engines) joined SECTION 20 — tagged `drawing` and `settings`, and
- `settings` is not a group tag, so they file under drawing. Every other
- number here has been unchanged since the snapshot was taken.
+ `settings` is not a group tag, so they file under drawing.
+ `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. `templates`
+ moved 2 -> 3 when v1.6's `drawing_template_list` joined SECTION 19
+ (tagged `template`, which ranks above `drawing`), then 3 -> 4 when
+ `drawing_template_save` joined. 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()}
assert sizes == {
@@ -544,15 +551,15 @@ 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,
"styles": 10,
"system": 8,
- "templates": 2,
+ "templates": 4,
"transactions": 3,
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 179
+ assert sum(sizes.values()) == 185