feat(refs): xrefs, raster underlays, data extract and real DWG output

SECTION 24 completes at eleven tools with xref_attach, xref_manage,
image_attach, data_extract and drawing_export_dwg, on the four contract members
that genuinely differ per engine -- backends/contracts/refs.py. Everything else
in the sheet track composes the generic members and is written once.

The ActiveX members were read off the AutoCAD 2026 seat's own type library
(C:\Program Files\Common Files\Autodesk Shared\acax25enu.tlb, "AutoCAD 2025
Type Library" 1.0, the library the running 25.x seat registers) before a line of
the COM path was written, and the measurement is in the code beside the calls:

  IAcadModelSpace.AttachExternalReference(PathName, Name, InsertionPoint,
      Xscale, Yscale, Zscale, Rotation, bOverlay, Password)
  IAcadModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor,
      RotationAngle)
  IAcadBlock: IsXRef (get), Path (get AND put -- so the repath is an assignment,
      not a method), Reload(), Unload(), Bind(bPrefixName), Detach(),
      XRefDatabase (get)
  IAcadDocument.SaveAs(FullFileName, SaveAsType, vSecurityParams)
  AcSaveAsType: ac2000_dwg=12 ac2004_dwg=24 ac2007_dwg=36 ac2010_dwg=48
      ac2013_dwg=60 ac2018_dwg=64 (=acNative)

The running seat itself was not answering COM while this landed (its window was
Not Responding and GetActiveObject never returned), so the type library -- which
is the same seat's declared surface and reads without a call into the process --
is what the members are measured from; no document of the operator's was opened
or touched. This repository has been bitten four times by a fake that invented a
member, so the COM fakes in the tests model those names and nothing else.
ActiveX angles are radians, so the degree-to-radian conversion happens at that
boundary and nowhere else.

Headlessly, ezdxf.xref.attach carries the attachment (measured on ezdxf 1.4.4:
block flags 20 for an attachment and 24 for an overlay, so "is this an xref" is
flags & (BLK_XREF | BLK_XREF_OVERLAY) -- masking BLK_XREF alone misses every
overlay), add_image_def + add_image the raster, with Pillow reading the pixel
size so the placed image keeps the file's aspect.

Three capability keys, declared in both maps:

  dwg_write  -- measured absent here (odafc.is_installed() is False), so the
                ezdxf entry is a runtime re-evaluation like viewport_render's
                matplotlib check rather than a hardcoded False: installing the
                ODA File Converter starts the call working with no edit to the
                map. The refusal names the install route and drawing_export_dxf.
  xref_live  -- reload and bind need a live seat's xref manager and are refused
                headlessly; list, detach and path are headless and are not
                behind the key.
  xlsx_write -- openpyxl is optional and absent here; CSV is always available,
                so the refusal never blocks the deliverable and says so.

Two numbers the plan predicted wrongly are corrected to what the registry
actually does. `block` outranks `drawing` in _GROUP_TAG_PRIORITY, so the two
xref tools file under `blocks` (9 -> 11) beside block_insert -- which is where
an xref belongs, being a block definition that lives in another file -- and
`drawing` moves 11 -> 12, not 11 -> 14. And `data_extract` must not say "parts
list" in its own text: that is the golden discovery query for
entity_create_table, and a file writer outranking the tool that draws the thing
is a regression the tuning bar catches. It is described by its destination --
a file -- instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 23:03:57 +03:00
co-authored by Claude Opus 5
parent de8dc8f813
commit 3ef316d520
14 changed files with 1449 additions and 16 deletions
+2
View File
@@ -33,6 +33,7 @@ from backends.contracts import (
LinetypeContract,
PageSetupContract,
PremiumContract,
RefsContract,
SettingsContract,
SolidContract,
StylesContract,
@@ -190,6 +191,7 @@ class AutoCADBackend(
SettingsContract,
StylesContract,
EnvironmentContract,
RefsContract,
):
"""All backends implement this interface.
+203
View File
@@ -16,6 +16,7 @@ import time
import uuid
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from importlib.util import find_spec
from pathlib import Path
from typing import Any
@@ -157,6 +158,28 @@ def _apoint(x: float, y: float, z: float = 0.0):
)
def _image_pixels(path: str) -> tuple[int, int]:
"""Pixel size of a raster; the placed size is pixels x scale on both engines.
Copied from the ezdxf backend rather than imported: the two backends must
not depend on each other.
"""
try:
from PIL import Image
except ImportError as exc: # pragma: no cover - Pillow ships with [pdf]
raise ValueError(
"image_attach needs Pillow to read the image's pixel size; "
'install it with: pip install -e ".[pdf]"'
) from exc
try:
with Image.open(path) as image:
return int(image.width), int(image.height)
except Exception as exc:
raise ValueError(
f"image_attach: {path} is not an image this server can read ({exc})"
) from exc
def _av(values: list[float]):
"""Create a VARIANT double-array from a flat list."""
return win32com.client.VARIANT(
@@ -1536,6 +1559,16 @@ class ComBackend(AutoCADBackend):
"interactive_prompt": FeatureCapability(
True, "native", reason="cancel_returns_cancelled_true;timeout_returns_timed_out"
),
# Track G keys.
"dwg_write": FeatureCapability(True, "native", reason="document_saveas"),
"xref_live": FeatureCapability(
True, "native", reason="block_reload_bind_detach_on_the_live_seat"
),
"xlsx_write": (
FeatureCapability(True, "openpyxl")
if find_spec("openpyxl") is not None
else FeatureCapability(False, reason="optional_dependency_missing:openpyxl")
),
},
)
@@ -1805,6 +1838,176 @@ class ComBackend(AutoCADBackend):
async def drawing_export_dxf(self, path: str) -> dict:
return await self.drawing_save_as(path, "dxf")
# ── external references, rasters and DWG (v1.6 track G) ──────────────────
#
# Measured on AutoCAD 2026 (AutoCAD.Application 25.1s), from the live
# seat's type library:
# ModelSpace.AttachExternalReference(PathName, Name, InsertionPoint,
# Xscale, Yscale, Zscale, Rotation, bOverlay, Password)
# ModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor,
# RotationAngle)
# Block: IsXRef, Path, Name, Reload(), Unload(), Bind(bPrefixName),
# Detach(), XRefDatabase
# Document.SaveAs(FullFileName, SaveAsType, vSecurityParams)
# ActiveX angles are radians; the tool boundary is degrees, so every
# rotation crosses here and nowhere else.
#: AcSaveAsType, read off the same type library.
_DWG_SAVE_AS = {
"R2000": 12,
"R2004": 24,
"R2007": 36,
"R2010": 48,
"R2013": 60,
"R2018": 64,
}
async def xref_attach(self, path, at, scale=1.0, rotation=0.0, kind="attach") -> dict:
from backends.contracts.refs import XREF_KINDS
if kind not in XREF_KINDS:
raise ValueError(f"xref kind must be one of {XREF_KINDS}, got {kind!r}")
source = Path(path)
if not source.is_file():
raise ValueError(f"xref_attach: {path} does not exist")
x, y = float(at[0]), float(at[1])
for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)):
if not math.isfinite(float(value)):
raise ValueError(f"xref_attach: {label} must be finite, got {value!r}")
if float(scale) <= 0:
raise ValueError(f"xref_attach: scale must be positive, got {scale!r}")
name = source.stem.upper()
factor = float(scale)
radians = math.radians(float(rotation))
def _sync():
doc = _acad_doc()
reference = doc.ModelSpace.AttachExternalReference(
str(source),
name,
_apoint(x, y),
factor,
factor,
factor,
radians,
kind == "overlay",
)
return {
"ok": True,
"name": name,
"path": str(source),
"kind": kind,
"handle": reference.Handle,
"at": [x, y],
"scale": factor,
"rotation": float(rotation),
"backend": self.name,
}
return await self._run(_sync)
async def xref_manage(self, name, action, new_path=None) -> dict:
from backends.contracts.refs import XREF_ACTIONS
if action not in XREF_ACTIONS:
raise ValueError(f"xref action must be one of {XREF_ACTIONS}, got {action!r}")
if action == "path" and not (new_path or "").strip():
raise ValueError("xref_manage(action='path') needs new_path")
def _sync():
doc = _acad_doc()
def _xref_blocks():
found = []
for index in range(doc.Blocks.Count):
block = doc.Blocks.Item(index)
try:
if block.IsXRef:
found.append(block)
except Exception: # noqa: BLE001 - a block that cannot answer is not an xref
continue
return found
if action == "list":
rows = [
{"name": b.Name, "path": str(b.Path or ""), "kind": "attach", "inserts": None}
for b in _xref_blocks()
]
rows.sort(key=lambda row: row["name"])
return {"ok": True, "xrefs": rows, "backend": self.name}
key = str(name).strip()
block = next((b for b in _xref_blocks() if b.Name == key), None)
if block is None:
raise ValueError(
f"xref_manage: {key!r} is not an external reference in this drawing"
)
if action == "reload":
block.Reload()
return {"ok": True, "name": key, "action": action, "backend": self.name}
if action == "bind":
block.Bind(False)
return {"ok": True, "name": key, "action": action, "backend": self.name}
if action == "detach":
block.Detach()
return {"ok": True, "name": key, "inserts_removed": None, "backend": self.name}
block.Path = str(new_path)
return {"ok": True, "name": key, "path": str(new_path), "backend": self.name}
return await self._run(_sync)
async def image_attach(self, path, at, scale=1.0, rotation=0.0) -> dict:
source = Path(path)
if not source.is_file():
raise ValueError(f"image_attach: {path} does not exist")
x, y = float(at[0]), float(at[1])
for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)):
if not math.isfinite(float(value)):
raise ValueError(f"image_attach: {label} must be finite, got {value!r}")
if float(scale) <= 0:
raise ValueError(f"image_attach: scale must be positive, got {scale!r}")
px, py = _image_pixels(str(source))
factor = float(scale)
radians = math.radians(float(rotation))
def _sync():
doc = _acad_doc()
raster = doc.ModelSpace.AddRaster(str(source), _apoint(x, y), factor, radians)
return {
"ok": True,
"path": str(source),
"handle": raster.Handle,
"at": [x, y],
"scale": factor,
"rotation": float(rotation),
"pixels": [px, py],
"size_mm": [px * factor, py * factor],
"backend": self.name,
}
return await self._run(_sync)
async def drawing_export_dwg(self, path, version="R2018") -> dict:
from backends.contracts.refs import DWG_VERSIONS
if version not in DWG_VERSIONS:
raise ValueError(f"DWG version must be one of {DWG_VERSIONS}, got {version!r}")
save_as_type = self._DWG_SAVE_AS[version]
def _sync():
doc = _acad_doc()
doc.SaveAs(path, save_as_type)
target = Path(path)
return {
"ok": True,
"path": path,
"version": version,
"bytes": target.stat().st_size if target.exists() else None,
"backend": self.name,
}
return await self._run(_sync)
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.
+2
View File
@@ -24,6 +24,7 @@ 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.refs import RefsContract
from backends.contracts.settings import SettingsContract
from backends.contracts.solids import SolidContract
from backends.contracts.styles import StylesContract
@@ -48,6 +49,7 @@ __all__ = [
"LinetypeContract",
"PageSetupContract",
"PremiumContract",
"RefsContract",
"SettingsContract",
"SolidContract",
"StylesContract",
+98
View File
@@ -0,0 +1,98 @@
"""External references, raster images and DWG output (v1.6 tracks B+G, group G).
These are the only four operations of the sheet track that genuinely differ per
engine, so they are the only four that become contract members. Everything else
in `engineering/sheet/` composes the generic members and is written once.
``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
#: An xref is attached (its own xrefs come with it) or overlaid (they do not).
XREF_KINDS = ("attach", "overlay")
#: `list`, `detach` and `path` are headless; `reload` and `bind` need a live
#: seat and are refused with the `xref_live` capability on the ezdxf engine.
XREF_ACTIONS = ("list", "reload", "bind", "detach", "path")
#: DWG formats both engines name the same way. COM maps them to AcSaveAsType
#: (measured on AutoCAD 2026: 12/24/36/48/60/64); ezdxf maps them through
#: `ezdxf.addons.odafc.VERSION_MAP`, which covers exactly these six and more.
DWG_VERSIONS = ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018")
class RefsContract(ABC):
@abstractmethod
async def xref_attach(
self,
path: str,
at,
scale: float,
rotation: float,
kind: str,
) -> dict:
"""Attach an external reference and insert it once.
The block name is the referenced file's stem, which is what both
engines do. Returns ``{ok, name, path, kind, handle, at, scale,
rotation, backend}``.
Refuses, before anything is written: a `kind` outside
:data:`XREF_KINDS`, a file that does not exist, a non-finite
coordinate/scale/rotation, a non-positive scale, and a block name the
drawing already holds (re-attaching would either silently replace the
definition or raise mid-write).
"""
...
@abstractmethod
async def xref_manage(self, name: str, action: str, new_path: str | None) -> dict:
"""One of :data:`XREF_ACTIONS` on one external reference.
``list`` ignores `name` and returns
``{ok, xrefs: [{name, path, kind, inserts}], backend}``. ``path``
rewrites the saved path to `new_path` and returns the new one.
``detach`` removes every insert of the xref and its definition, and
reports ``inserts_removed``. ``reload`` and ``bind`` need a live seat's
xref manager: the headless engine raises
``UnsupportedCapabilityError("xref_live", ...)`` rather than pretending,
which is why the key exists; ``list``/``detach``/``path`` are *not*
behind it.
Refuses an action outside the vocabulary, an unknown xref name, and a
``path`` action with no `new_path`.
"""
...
@abstractmethod
async def image_attach(self, path: str, at, scale: float, rotation: float) -> dict:
"""Attach a raster image as an underlay.
`scale` is drawing units per pixel, so the placed size is the image's
pixel size times `scale` and the aspect ratio is the file's. Returns
``{ok, path, handle, at, scale, rotation, pixels: [w, h],
size_mm: [w, h], backend}``.
Refuses a file that does not exist, a file no image reader can open
(naming it), a non-positive scale and a non-finite number -- all before
anything is written.
"""
...
@abstractmethod
async def drawing_export_dwg(self, path: str, version: str) -> dict:
"""Write the current drawing as a real DWG.
Returns ``{ok, path, version, bytes, backend}``. Refuses a `version`
outside :data:`DWG_VERSIONS` by name. Headlessly this needs the ODA
File Converter and otherwise raises
``UnsupportedCapabilityError("dwg_write", ...)`` naming the install
route; on a live seat it is ``Document.SaveAs`` with the matching
``AcSaveAsType``, which -- as AutoCAD's own SaveAs does -- leaves the
session bound to the file it just wrote.
"""
...
+250
View File
@@ -202,6 +202,39 @@ def _refuse_dwg(operation: str, path: str) -> UnsupportedCapabilityError:
)
def _odafc_available() -> bool:
"""Is the ODA File Converter on this machine right now?
Re-evaluated per call, the way the matplotlib check behind
``viewport_render`` is: installing the converter must change the answer
without editing the map.
"""
try:
from ezdxf.addons import odafc
return bool(odafc.is_installed())
except Exception: # noqa: BLE001 - an import failure is simply "not installed"
return False
def _image_pixels(path: str) -> tuple[int, int]:
"""Pixel size of a raster, so the placed image keeps the file's aspect."""
try:
from PIL import Image
except ImportError as exc: # pragma: no cover - Pillow ships with [pdf]
raise ValueError(
"image_attach needs Pillow to read the image's pixel size; "
'install it with: pip install -e ".[pdf]"'
) from exc
try:
with Image.open(path) as image:
return int(image.width), int(image.height)
except Exception as exc:
raise ValueError(
f"image_attach: {path} is not an image this server can read ({exc})"
) from exc
def _audit_entry(entry) -> dict:
"""Flatten an ezdxf ``ErrorEntry`` into something a client can read.
@@ -1676,6 +1709,30 @@ class EzdxfBackend(AutoCADBackend):
"interactive_prompt": FeatureCapability(
False, reason="no_operator_to_prompt_headlessly"
),
# Track G keys. `dwg_write` is a runtime re-evaluation, not a
# hardcoded False: installing the converter must change the
# answer without editing this map, exactly as `viewport_render`
# follows matplotlib.
"dwg_write": (
FeatureCapability(True, "odafc", reason="oda_file_converter_installed")
if _odafc_available()
else FeatureCapability(
False,
reason=(
"requires_oda_file_converter:"
"https://www.opendesign.com/guestfiles/oda_file_converter"
),
)
),
"xref_live": FeatureCapability(
False,
reason="reload_and_bind_need_a_live_seat;list_detach_path_are_headless",
),
"xlsx_write": (
FeatureCapability(True, "openpyxl")
if find_spec("openpyxl") is not None
else FeatureCapability(False, reason="optional_dependency_missing:openpyxl")
),
},
)
@@ -2322,6 +2379,199 @@ class EzdxfBackend(AutoCADBackend):
async def drawing_export_dxf(self, path: str) -> dict:
return await self.drawing_save_as(path, "dxf")
# ── external references, rasters and DWG (v1.6 track G) ──────────────────
async def xref_attach(self, path, at, scale=1.0, rotation=0.0, kind="attach") -> dict:
from backends.contracts.refs import XREF_KINDS
if kind not in XREF_KINDS:
raise ValueError(f"xref kind must be one of {XREF_KINDS}, got {kind!r}")
source = Path(path)
if not source.is_file():
raise ValueError(f"xref_attach: {path} does not exist")
x, y = float(at[0]), float(at[1])
for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)):
if not math.isfinite(float(value)):
raise ValueError(f"xref_attach: {label} must be finite, got {value!r}")
if float(scale) <= 0:
raise ValueError(f"xref_attach: scale must be positive, got {scale!r}")
name = source.stem.upper()
def _sync():
from ezdxf import xref
doc = self._require_doc()
if name in doc.blocks:
raise ValueError(
f"xref_attach: the drawing already holds a block named {name!r}; "
"detach it first with xref_manage(action='detach')"
)
insert = xref.attach(
doc,
block_name=name,
filename=str(source),
insert=(x, y),
scale=float(scale),
rotation=float(rotation),
overlay=(kind == "overlay"),
)
self._mark_dirty()
return {
"ok": True,
"name": name,
"path": str(source),
"kind": kind,
"handle": insert.dxf.handle,
"at": [x, y],
"scale": float(scale),
"rotation": float(rotation),
"backend": self.name,
}
return await self._async(_sync)
async def xref_manage(self, name, action, new_path=None) -> dict:
from backends.contracts.refs import XREF_ACTIONS
if action not in XREF_ACTIONS:
raise ValueError(f"xref action must be one of {XREF_ACTIONS}, got {action!r}")
if action in ("reload", "bind"):
raise UnsupportedCapabilityError(
"xref_live",
f"xref_manage(action={action!r}): the {self.name} backend has no live xref "
"manager. list, detach and path work headlessly; reload and bind need the "
"com backend (a running AutoCAD seat).",
)
if action == "path" and not (new_path or "").strip():
raise ValueError("xref_manage(action='path') needs new_path")
def _sync():
from ezdxf.lldxf import const
doc = self._require_doc()
# Measured on ezdxf 1.4.4: `xref.attach` leaves an attachment's
# block flags at 20 and an overlay's at 24, so the question "is
# this an xref" is the *pair* of bits. Masking BLK_XREF alone
# misses every overlay.
xref_mask = const.BLK_XREF | const.BLK_XREF_OVERLAY
def _rows():
rows = []
for block in doc.blocks:
flags = int(block.block.dxf.flags)
if not flags & xref_mask:
continue
rows.append(
{
"name": block.name,
"path": str(block.block.dxf.xref_path or ""),
"kind": "overlay" if flags & const.BLK_XREF_OVERLAY else "attach",
"inserts": sum(
1
for entity in doc.modelspace()
if entity.dxftype() == "INSERT" and entity.dxf.name == block.name
),
}
)
rows.sort(key=lambda row: row["name"])
return rows
if action == "list":
return {"ok": True, "xrefs": _rows(), "backend": self.name}
key = str(name).strip()
row = next((r for r in _rows() if r["name"] == key), None)
if row is None:
raise ValueError(
f"xref_manage: {key!r} is not an external reference in this drawing "
f"(have: {', '.join(r['name'] for r in _rows()) or 'none'})"
)
if action == "path":
doc.blocks.get(key).block.dxf.xref_path = str(new_path)
self._mark_dirty()
return {"ok": True, "name": key, "path": str(new_path), "backend": self.name}
victims = [
entity
for entity in doc.modelspace()
if entity.dxftype() == "INSERT" and entity.dxf.name == key
]
for entity in victims:
doc.modelspace().delete_entity(entity)
doc.blocks.delete_block(key, safe=False)
self._mark_dirty()
return {
"ok": True,
"name": key,
"inserts_removed": len(victims),
"backend": self.name,
}
return await self._async(_sync)
async def image_attach(self, path, at, scale=1.0, rotation=0.0) -> dict:
source = Path(path)
if not source.is_file():
raise ValueError(f"image_attach: {path} does not exist")
x, y = float(at[0]), float(at[1])
for label, value in (("at.x", x), ("at.y", y), ("scale", scale), ("rotation", rotation)):
if not math.isfinite(float(value)):
raise ValueError(f"image_attach: {label} must be finite, got {value!r}")
if float(scale) <= 0:
raise ValueError(f"image_attach: scale must be positive, got {scale!r}")
px, py = _image_pixels(str(source))
width, height = px * float(scale), py * float(scale)
def _sync():
doc = self._require_doc()
image_def = doc.add_image_def(str(source), size_in_pixel=(px, py))
image = self._msp().add_image(image_def, (x, y), (width, height), float(rotation))
self._mark_dirty()
return {
"ok": True,
"path": str(source),
"handle": image.dxf.handle,
"at": [x, y],
"scale": float(scale),
"rotation": float(rotation),
"pixels": [px, py],
"size_mm": [width, height],
"backend": self.name,
}
return await self._async(_sync)
async def drawing_export_dwg(self, path, version="R2018") -> dict:
from backends.contracts.refs import DWG_VERSIONS
if version not in DWG_VERSIONS:
raise ValueError(f"DWG version must be one of {DWG_VERSIONS}, got {version!r}")
if not _odafc_available():
raise UnsupportedCapabilityError(
"dwg_write",
f"drawing_export_dwg: the {self.name} backend writes DWG only through the "
"ODA File Converter, which is not installed here. Install it from "
"https://www.opendesign.com/guestfiles/oda_file_converter and this call "
"starts working, or export from the com backend (a running AutoCAD seat). "
"drawing_export_dxf writes a DXF that AutoCAD opens unchanged.",
)
def _sync():
from ezdxf.addons import odafc
doc = self._require_doc()
self._refuse_path_held_elsewhere("drawing_export_dwg", path)
odafc.export_dwg(doc, path, version=version, replace=True)
return {
"ok": True,
"path": path,
"version": version,
"bytes": Path(path).stat().st_size,
"backend": self.name,
}
return await self._async(_sync)
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict:
"""Export to PDF via ezdxf's matplotlib backend.