port: наш оверлей 1.5.1 → апстрим 1.6.0 (ветка ours-1.6)

Перенос нашей дельты (10 изменённых + 2 новых файла) с v1.5.1 на тег v1.6.0
трёхсторонним слиянием с ветвью ours-1.5 (v1.5.1 + /opt/data/autocad-mcp-build/patch).
Файлы 1.6.0 не перезаписывались копированием: 30 конфликтов разрешены по смыслу.

Что перенесено (наше, чего в 1.6.0 не было):
- инструменты: titleblock_apply_gost_form3, file_put, file_get,
  drawing_open_base64, drawing_save_base64, entity_create_attdef,
  block_get_attdefs, textstyle_ensure, text_apply_style, text_style_report,
  dimstyle_ensure, layout_set_paper (+ таблица алиасов и регистрация в server.py);
- параметр `layout` (адресация в пространство листа) — 36 инструментов, включая
  геометрию (line/polyline/circle/arc/...), block_insert/block_create_from_entities,
  dimension_* и аннотацию; в COM-бэкенде этому служит _activate_layout();
- `inline_font` (ГОСТ-шрифт ISOCPEUR прямо в содержимое MTEXT/таблиц) — 5 инструментов;
- ГОСТ-стиль по умолчанию (_ensure_default_text_style, DEFAULT_TEXT_STYLE=GOST_A)
  при drawing_new/drawing_open;
- ATTDEF → ATTRIB: block_insert создаёт именованную вставку (не анонимный *U-блок),
  block_set_attributes достраивает отсутствующие ATTRIB из определений блока;
- отчётность block_insert/block_set_attributes: filled / unknown_tags / created_tags
  и штамп стиля на ATTRIB (mark_style, attr_style);
- PDF: page_size («layout» — страница равна формату листа 1:1, «extents» — по
  содержимому), настоящий текстовый слой (pdf.fonttype=42 + отрисовка текста
  поверх геометрии, включая текст через видовые экраны) и предупреждения о
  нечитаемо мелких надписях;
- engineering/gost_titleblock.py (основная надпись по ГОСТ Р 21.1101-2013, форма 3)
  и _fonts/isocpeur.ttf.

Что взято у 1.6.0 (наши дубли заменены, чтобы не спорить с апстримом):
- drawing_export_pdf: рамка листа считается его _paper_frame() (масштаб, plot area,
  поворот, поля) вместо нашей _layout_paper_mm(); наш текстовый слой и page_size
  наложены сверху;
- размерные стили: _current_dimstyle_name() вместо нашего _current_dimstyle()
  (наш хелпер оставлен — им пользуется отчёт о стилях);
- таблицы (ezdxf и COM): объединённая строка-заголовок, «span», масштабируемый
  отступ ячеек и SetCellTextHeight() из 1.6.0 + наш inline_font/style;
- block_insert: структура вставки и обработка многострочных ATTRIB из 1.6.0
  + наша отчётность и штамп стиля;
- экспорт DWG/xref/image_attach/профили, группы arch/pid/mech/sheet/understand —
  сохранены целиком.

Три структурные правки после слияния (git сшил код механически, найдены pyflakes
и аудитом дерева):
- com_backend: наша подпись drawing_export_pdf попала в тело xref_attach и
  дублировала метод; вставка убрана, паритетные text_layer/page_size перенесены
  в настоящую COM-функцию;
- _space_name в ezdxf-бэкенде был занят дважды (нашин и 1.6.0): наш переименован
  в _reply_space_name, иначе он затенял хелпер апстрима;
- entity_create_text: ключ "style" в dxfattribs стоял дважды (наш оставлен).

Прод :30015 не затронут.
This commit is contained in:
2026-10-05 18:12:40 +03:00
12 changed files with 3208 additions and 153 deletions
+278 -25
View File
@@ -412,6 +412,35 @@ def _block_names(doc) -> set[str]:
return names
def _activate_layout(name: str | None) -> None:
"""Make the named layout the active tab, so the next COM call draws on it.
The ezdxf backend takes ``layout`` as a per-call target; COM has one active
layout per document, so the equivalent is the same switch
``layout_set_current`` performs — and it stays switched, because the sheet is
where the drafter carries on. Unknown names are refused instead of silently
drawing into model space.
"""
wanted = str(name or "").strip()
if not wanted:
return
doc = _acad_doc()
try:
current = str(doc.ActiveLayout.Name)
except Exception as exc: # a probe must never block a draw
log.debug("ActiveLayout.Name unavailable (%s)", exc)
current = ""
if current and current.lower() == wanted.lower():
return
try:
doc.ActiveLayout = doc.Layouts.Item(wanted)
except Exception as exc:
raise RuntimeError(
f"layout {wanted!r} does not exist: create it with layout_create first"
) from exc
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
#: `drawing_properties_*` field → `IAcadSummaryInfo` property.
@@ -2199,7 +2228,19 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict:
async def drawing_export_pdf(
self,
path: str,
layout: str | None = None,
text_layer: bool | None = None,
page_size: str | None = None,
) -> dict:
# ``text_layer`` and ``page_size`` are accepted for signature parity with
# the ezdxf backend: AutoCAD plots real text natively, and PlotToFile
# plots the layout's own paper at its own scale (that is what a .pc3
# device does), so neither flag has anything to switch here.
del text_layer
del page_size
"""Plot one layout to PDF through ``Plot.PlotToFile`` and confirm the file.
``layout=None`` means model space, as the tool advertises — the Model
@@ -4677,8 +4718,12 @@ class ComBackend(AutoCADBackend):
layer=None,
color=None,
linetype=None,
layout=None,
) -> EntityInfo:
def _sync():
# Layout ровно как у ezdxf: COM рисует в активный лист, поэтому
# вызов сначала переключает вкладку (см. _activate_layout).
_activate_layout(layout)
mspace = _msp()
line = mspace.AddLine(_apoint(x1, y1, z1), _apoint(x2, y2, z2))
_apply_entity_attrs(line, layer, color, linetype)
@@ -4694,8 +4739,10 @@ class ComBackend(AutoCADBackend):
radius,
layer=None,
color=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
circle = mspace.AddCircle(_apoint(cx, cy), float(radius))
_apply_entity_attrs(circle, layer, color, None)
@@ -4713,8 +4760,10 @@ class ComBackend(AutoCADBackend):
end_angle,
layer=None,
color=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
arc = mspace.AddArc(
_apoint(cx, cy),
@@ -4734,8 +4783,10 @@ class ComBackend(AutoCADBackend):
closed=False,
layer=None,
color=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
flat = []
for pt in points:
@@ -4757,11 +4808,25 @@ class ComBackend(AutoCADBackend):
rotation=0.0,
layer=None,
color=None,
style=None,
layout=None,
inline_font=True,
) -> EntityInfo:
def _sync():
# Layout ровно как у ezdxf: COM рисует в активный лист, поэтому
# вызов сначала переключает вкладку (см. _activate_layout).
_activate_layout(layout)
mspace = _msp()
txt = mspace.AddText(text, _apoint(x, y), float(height))
txt.Rotation = deg2rad(rotation)
if style:
# AutoCAD refuses an unknown style name outright; a caller who
# asked for a face the drawing does not carry gets the current
# style instead of a failed call (matching ezdxf's fallback).
try:
txt.StyleName = str(style)
except Exception:
pass
_apply_entity_attrs(txt, layer, color, None)
_regen()
return _entity_info(txt)
@@ -4778,13 +4843,26 @@ class ComBackend(AutoCADBackend):
rotation=0.0,
layer=None,
color=None,
style=None,
layout=None,
inline_font=True,
) -> EntityInfo:
from engineering.annotation import wrap_inline_font
def _sync():
_activate_layout(layout)
mspace = _msp()
mt = mspace.AddMText(_apoint(x, y), float(width), text)
mt = mspace.AddMText(
_apoint(x, y), float(width), wrap_inline_font(text, inline_font)
)
mt.Height = float(height)
if rotation:
mt.Rotation = deg2rad(rotation) # NEW-mtext-1: honor caller rotation
if style:
try:
mt.StyleName = str(style)
except Exception:
pass
_apply_entity_attrs(mt, layer, color, None)
_regen()
return _entity_info(mt)
@@ -4802,31 +4880,44 @@ class ComBackend(AutoCADBackend):
text_height=2.5,
title=None,
layer="TEXT",
style=None,
layout=None,
inline_font=True,
) -> EntityInfo:
from engineering.annotation import prepare_table_layout
from engineering.annotation import prepare_table_layout, wrap_inline_font
layout = prepare_table_layout(rows, headers, column_widths, row_height, text_height, title)
table_layout = prepare_table_layout(
rows, headers, column_widths, row_height, text_height, title
)
def _sync():
_activate_layout(layout)
mspace = _msp()
table = mspace.AddTable(
_apoint(x, y),
layout.row_count,
layout.column_count,
layout.row_height,
layout.column_widths[0],
table_layout.row_count,
table_layout.column_count,
table_layout.row_height,
table_layout.column_widths[0],
)
for column, width in enumerate(layout.column_widths):
for column, width in enumerate(table_layout.column_widths):
table.SetColumnWidth(column, width)
# MEASURED on AutoCAD 2026: IAcadTable has no TextHeight property
# (the old `table.TextHeight = h` raised and was swallowed, leaving
# the Standard style's 6.0 / 4.5 - illegible at 1:50). The member
# is SetCellTextHeight(row, col, h), set per cell.
for row_index, row in enumerate(layout.cells):
table.SetRowHeight(row_index, layout.row_height)
for row_index, row in enumerate(table_layout.cells):
table.SetRowHeight(row_index, table_layout.row_height)
for column_index, value in enumerate(row):
table.SetText(row_index, column_index, value)
table.SetCellTextHeight(row_index, column_index, layout.text_height)
table.SetText(row_index, column_index, wrap_inline_font(value, inline_font))
table.SetCellTextHeight(row_index, column_index, table_layout.text_height)
cell_style = None
if style:
try:
table.TextStyle = str(style)
cell_style = str(style)
except Exception as exc:
log.debug("setting table TextStyle failed: %s", exc)
_apply_entity_attrs(table, layer, None, None)
_regen()
info = _entity_info(table)
@@ -4837,11 +4928,13 @@ class ComBackend(AutoCADBackend):
"representation": "native",
"logical_group_id": f"table:{uuid.uuid4().hex}",
"child_handles": [info.handle],
"rows": layout.row_count,
"columns": layout.column_count,
"rows": table_layout.row_count,
"columns": table_layout.column_count,
"text_style": cell_style,
"layout": str(layout or ""),
"bounds": {
"min": [float(x), float(y) - layout.height],
"max": [float(x) + layout.width, float(y)],
"min": [float(x), float(y) - table_layout.height],
"max": [float(x) + table_layout.width, float(y)],
},
}
)
@@ -4857,8 +4950,10 @@ class ComBackend(AutoCADBackend):
landing_gap=1.0,
arrow_size=2.5,
layer="DIM",
layout=None,
inline_font=True,
) -> EntityInfo:
from engineering.annotation import validate_mleader
from engineering.annotation import validate_mleader, wrap_inline_font
normalized = validate_mleader(points, text)
if text_height <= 0 or landing_gap < 0 or arrow_size <= 0:
@@ -4867,11 +4962,12 @@ class ComBackend(AutoCADBackend):
)
def _sync():
_activate_layout(layout)
flat = []
for px, py in normalized:
flat.extend([px, py, 0.0])
leader = _msp().AddMLeader(_av(flat), 0)
leader.TextString = str(text)
leader.TextString = wrap_inline_font(text, inline_font)
leader.TextHeight = float(text_height)
leader.LandingGap = float(landing_gap)
leader.ArrowheadSize = float(arrow_size)
@@ -4905,8 +5001,10 @@ class ComBackend(AutoCADBackend):
angle=0.0,
layer=None,
color=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
# acPatternTypePreDefined = 0
hatch = mspace.AddHatch(0, pattern, False)
@@ -4933,8 +5031,10 @@ class ComBackend(AutoCADBackend):
fit_points,
layer=None,
color=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
flat = []
for pt in fit_points:
@@ -4959,8 +5059,10 @@ class ComBackend(AutoCADBackend):
ratio=0.5,
layer=None,
color=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
ellipse = mspace.AddEllipse(
_apoint(cx, cy),
@@ -4979,8 +5081,10 @@ class ComBackend(AutoCADBackend):
y,
layer=None,
color=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
pt = mspace.AddPoint(_apoint(x, y))
_apply_entity_attrs(pt, layer, color, None)
@@ -4998,9 +5102,11 @@ class ComBackend(AutoCADBackend):
scale_y=1.0,
rotation=0.0,
layer=None,
layout=None,
) -> EntityInfo:
def _sync():
_require_block_defined(_acad_doc(), name)
_activate_layout(layout)
mspace = _msp()
ref = mspace.InsertBlock(
_apoint(x, y),
@@ -5033,8 +5139,10 @@ class ComBackend(AutoCADBackend):
tol_lower=None,
tol_mode="none",
text_override=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
# The ActiveX API has no AddDimLinear — rotated linear dims are
# created with AddDimRotated (GH issue #3).
@@ -5061,8 +5169,10 @@ class ComBackend(AutoCADBackend):
dim_x,
dim_y,
layer=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
dim = mspace.AddDimAligned(_apoint(x1, y1), _apoint(x2, y2), _apoint(dim_x, dim_y))
if layer:
@@ -5083,8 +5193,10 @@ class ComBackend(AutoCADBackend):
tx,
ty,
layer=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
dim = mspace.AddDimAngular(
_apoint(vx, vy),
@@ -5111,8 +5223,10 @@ class ComBackend(AutoCADBackend):
tol_lower=None,
tol_mode="none",
text_override=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
dim = mspace.AddDimRadial(
_apoint(cx, cy), _apoint(chord_x, chord_y), float(leader_length)
@@ -5137,8 +5251,10 @@ class ComBackend(AutoCADBackend):
tol_lower=None,
tol_mode="none",
text_override=None,
layout=None,
) -> EntityInfo:
def _sync():
_activate_layout(layout)
mspace = _msp()
dim = mspace.AddDimDiametric(_apoint(x1, y1), _apoint(x2, y2), float(leader_length))
if layer:
@@ -5149,6 +5265,73 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def dimstyle_ensure(
self,
name="СПДС",
text_style=None,
base=None,
set_current=True,
text_height=2.5,
arrow_size=2.5,
) -> dict:
"""Создать размерный стиль (по умолчанию СПДС) и/или сделать его текущим.
COM-эквивалент метода ezdxf: текстовый стиль размера переводим на
ГОСТ-начертание (``text_style``), высоту текста и стрелки задаём явно
(ГОСТ 2.304: 2.5 мм при 1:1), при ``set_current`` стиль становится
активным (``ActiveDimStyle``) — следующий размер выйдет им.
"""
def _sync():
doc = _acad_doc()
style_name = str(name or "").strip()
if not style_name:
raise RuntimeError("dimstyle_ensure: name must not be empty")
names = [str(doc.DimStyles.Item(i).Name) for i in range(doc.DimStyles.Count)]
created = False
if style_name in names:
style = doc.DimStyles.Item(style_name)
else:
style = doc.DimStyles.Add(style_name)
created = True
source = str(base or "").strip() or "Standard"
if source in names:
try:
style.CopyFrom(doc.DimStyles.Item(source))
except Exception as exc:
log.debug("dimstyle_ensure: CopyFrom %s failed (%s)", source, exc)
for attr, value in (("TextStyle", text_style), ("TextHeight", text_height),
("ArrowheadSize", arrow_size)):
if value in (None, "", 0):
continue
try:
setattr(style, attr, value)
except Exception as exc:
# AutoCAD отказывает на незнакомое имя стиля — не валим вызов.
log.debug("dimstyle_ensure: %s=%s rejected (%s)", attr, value, exc)
if set_current:
try:
doc.ActiveDimStyle = style
except Exception as exc:
log.debug("dimstyle_ensure: ActiveDimStyle failed (%s)", exc)
_regen()
current = ""
try:
current = str(doc.ActiveDimStyle.Name)
except Exception:
current = ""
return {
"ok": True,
"name": style_name,
"created": created,
"text_style": str(text_style or ""),
"set_current": bool(set_current),
"current": current,
"dimstyles": names + ([] if style_name in names else [style_name]),
}
return await self._run(_sync)
# ── entity modification ───────────────────────────────────────────────────
async def entity_move(self, handle, dx, dy, dz=0.0) -> dict:
@@ -5746,6 +5929,8 @@ class ComBackend(AutoCADBackend):
rotation=0.0,
attributes=None,
layer=None,
style=None,
layout=None,
) -> EntityInfo:
from backends.block_specs import validate_attribute_values
@@ -5753,6 +5938,7 @@ class ComBackend(AutoCADBackend):
def _sync():
_require_block_defined(_acad_doc(), name)
_activate_layout(layout)
mspace = _msp()
ref = mspace.InsertBlock(
_apoint(x, y),
@@ -5773,6 +5959,15 @@ class ComBackend(AutoCADBackend):
attr.TextString = values[tag]
except Exception as exc:
log.debug("Block insert: GetAttributes or attribute setting failed: %s", exc)
if style:
# The ATTRIB inherits its ATTDEF's text style; a definition made
# before the ГОСТ style existed carries ``Standard``, which plots
# the block marks in the wrong face.
try:
for attr in ref.GetAttributes():
attr.StyleName = str(style)
except Exception as exc:
log.debug("Block insert: setting attribute style failed: %s", exc)
return _entity_info(ref)
return await self._run(_sync)
@@ -6165,7 +6360,7 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def block_set_attributes(self, handle, attributes) -> dict:
async def block_set_attributes(self, handle, attributes, style=None) -> dict:
from backends.block_specs import validate_attribute_values
values = validate_attribute_values(attributes)
@@ -6175,12 +6370,26 @@ class ComBackend(AutoCADBackend):
ref = doc.HandleToObject(handle)
attrs = ref.GetAttributes()
updated = []
restyled = 0
for attr in attrs:
tag = attr.TagString
if tag in values:
attr.TextString = values[tag]
updated.append(tag)
return {"ok": True, "updated_tags": updated}
if style:
# The reference's ATTDEF inherited ``Standard`` when there was
# no ГОСТ style yet; a value written into it would still plot
# in the wrong face, so the update carries the style with it.
try:
attr.StyleName = str(style)
restyled += 1
except Exception:
pass
result = {"ok": True, "updated_tags": updated}
if style:
result["style"] = str(style)
result["restyled_tags"] = restyled
return result
return await self._run(_sync)
@@ -6259,26 +6468,70 @@ class ComBackend(AutoCADBackend):
handles,
base_x=0.0,
base_y=0.0,
layout=None,
) -> dict:
"""Build a block definition from entities already in the drawing.
This used to refuse and point at ``system_run_command`` with ``_BLOCK``,
pushing callers through the free-text escape hatch for something ActiveX
does directly: ``Blocks.Add`` creates the definition and
``Document.CopyObjects`` puts entities into it. The originals stay in
model space, matching the headless backend rather than AutoCAD's BLOCK
``Document.CopyObjects`` puts entities into it. The originals stay where
they were, matching the headless backend rather than AutoCAD's BLOCK
command (which consumes them).
``layout`` matches the headless backend: a definition is document-global
(it has no owner sheet), so the parameter constrains the *source* — only
handles whose ``OwnerID`` is that layout's own block are taken, and a
foreign handle is named in a refusal instead of copied silently.
"""
def _sync():
doc = _acad_doc()
objects, skipped = [], []
owner_id = None
space_label = ""
if layout is not None:
wanted = self._layout_name(layout)
if wanted.lower() == "model":
space_label = "Model"
owner_id = int(doc.ModelSpace.ObjectID)
else:
resolved = self._find_layout(doc, wanted)
if resolved is None:
raise RuntimeError(
f"layout {wanted!r} does not exist: create it with "
f"layout_create first"
)
space_label = resolved
owner_id = int(doc.Layouts.Item(resolved).Block.ObjectID)
objects, skipped, foreign = [], [], []
for handle in handles:
try:
objects.append(doc.HandleToObject(str(handle)))
obj = doc.HandleToObject(str(handle))
except Exception as exc:
log.debug("resolving %s for block %s: %s", handle, name, exc)
skipped.append(str(handle))
continue
if owner_id is not None:
# An unreadable OwnerID is refused with the foreign handles
# rather than waved through: the point of layout= is to
# guarantee the definition came off that sheet.
try:
same_space = int(obj.OwnerID) == owner_id
except Exception as exc:
log.debug("OwnerID of %s unreadable (%s)", handle, exc)
same_space = False
if not same_space:
foreign.append(str(handle))
continue
objects.append(obj)
if foreign:
raise RuntimeError(
f"block_create_from_entities: handle(s) {', '.join(foreign)} are not in "
f"space {space_label!r}; a definition is built from one sheet's entities "
f"at a time. Drop layout= to collect drawing-wide, or pass the handles "
f"of that sheet. No definition was created."
)
# Resolve before creating, so a call where every handle was a typo
# does not leave an empty definition behind.
if not objects: