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
+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.