"""ГОСТ Р 21.1101-2013 форма 3 — основная надпись и рамка листа. Геометрия взята с официального чертежа Приложения Ж (форма 3): штамп 185 x 55 мм из строк по 5 мм, таблица изменений 65 мм (графы 14-19), правая часть 120 мм (70 + 50), вложенные графы 6-8 «Стадия / Лист / Листов» шириной 15 / 15 / 20 мм. Штамп ставится в правый нижний угол рамки; поля рамки 20/5/5/5 мм. Смысл граф — по 5.2.7 и Приложению Ж ГОСТ Р 21.1101-2013: 1 — обозначение документа, 2 — наименование предприятия (объекта), 3 — наименование здания (сооружения), 4 — наименование изображений листа, 6 — стадия (П/Р), 7 — лист, 8 — листов, 9 — организация, 10-13 — характер работы, фамилия, подпись и дата, 14-19 — таблица изменений, 25 — масштаб, 26 — обозначение формата. """ from __future__ import annotations from dataclasses import asdict, dataclass, field from typing import TYPE_CHECKING if TYPE_CHECKING: from backends.base import AutoCADBackend # ── форматы листов (ГОСТ 2.301), мм ────────────────────────────────────────── SHEETS: dict[str, tuple[float, float]] = { "A4": (297.0, 210.0), "A3": (420.0, 297.0), "A2": (594.0, 420.0), "A1": (841.0, 594.0), "A0": (1189.0, 841.0), } #: Поля рамки по ГОСТ Р 21.1101-2013 (5.2.7, приложение И). MARGIN_LEFT = 20.0 MARGIN_RIGHT = 5.0 MARGIN_TOP = 5.0 MARGIN_BOTTOM = 5.0 # ── геометрия штампа, мм ───────────────────────────────────────────────────── STAMP_W = 185.0 STAMP_H = 55.0 ROW_H = 5.0 # 11 строк по 5 мм LEFT_BLOCK_W = 65.0 RIGHT_BLOCK_W = 120.0 #: графы 14-19 таблицы изменений: (номер, подпись, ширина) #: Ширины подобраны по метрике ISOCPEUR так, чтобы подпись графы вставала в #: свою клетку при высоте 1.8 мм (ГОСТ 2.304): «Кол.уч.» и «№ док.» — 6 знаков, #: то есть 9.1 мм, и в прежние 10 мм они влезали только после уменьшения до #: 1.36/1.59 мм, что ниже нормы читаемости. Сумма остаётся 65 мм. CHANGE_COLUMNS: tuple[tuple[int, str, float], ...] = ( (14, "Изм.", 10.0), (15, "Кол.уч.", 11.0), (16, "Лист", 8.0), (17, "№ док.", 11.0), (18, "Подп.", 10.0), (19, "Дата", 15.0), ) #: графы 6-8: (номер, подпись, ширина) SHEET_COLUMNS: tuple[tuple[int, str, float], ...] = ( (6, "Стадия", 15.0), (7, "Лист", 15.0), (8, "Листов", 20.0), ) #: Наименьшая высота надписи на листе (ГОСТ 2.304 / ISO 3098). Ниже неё #: подпись не читается на печати — этим полом ограничено автоуменьшение #: значений, а `drawing_export_pdf` отдельно сообщает о том, что всё-таки #: оказалось мельче (поле warnings). MIN_TEXT_H = 1.8 LABEL_H = 1.8 # подписи граф VALUE_H = 3.0 # значения TITLE_H = 5.0 # обозначение документа GRAPH_NO_H = 1.8 # номера граф в скобках — не мельче нормы читаемости PAD = 1.0 #: Отступ подписи графы от её линии. Меньше общего PAD: подписи «Кол.уч.» и #: «№ док.» занимают по 9.1 мм, и при 1.0 мм с каждой стороны они не влезали в #: 11-миллиметровую графу с первого раза. TAG_PAD = 0.6 #: Зазор между номером графы в скобках и значением в той же клетке. Номер и #: значение раньше стояли в одной точке, и «(25)Масштаб 1:50» печаталось #: наложением. TAG_GAP = 0.6 #: Графы 10-14: подписи по ГОСТ Р 21.1101-2013 (приложение Ж) — «Разраб.», #: «Пров.», «Т.контр.», «Н.контр.», «Утв.». Полные слова («Разработал.») при #: 1.8 мм занимают 16.6 мм и выходят за 17-миллиметровую графу характера работы. SIGN_ROWS: tuple[tuple[str, int], ...] = ( ("Разраб.", 10), ("Пров.", 11), ("Т.контр.", 12), ("Н.контр.", 13), ("Утв.", 14), ) #: Ширины колонок блока подписей (характер работы / фамилия / подпись / дата), #: сумма 65 мм — ширина левой части штампа. Прежние 20/22/13/10 не вмещали #: «01.10.2026» в 10 мм (дата ужималась до 1.2 мм), а 17 мм под фамилию #: оставляют «Николашкин» читаемым (2.0 мм). SIGN_LABEL_W = 17.0 SIGN_NAME_W = 18.0 SIGN_SIGN_W = 12.0 SIGN_DATE_W = 18.0 #: Ширина знака как доля высоты текста. Измерено по рендеру: «ЭО-01» высотой #: 5 мм занимает по листу 21 мм, то есть ~0.84 высоты на знак у ISOCPEUR / #: DejaVu Sans. Нужна, чтобы значения граф не выезжали за свои колонки — #: `entity_create_text` выравнивает строку по левому краю от точки вставки. CHAR_W_RATIO = 0.84 #: Полезная ширина графы 1, 2, 3, 4, 9 (70 мм минус отступы). BLOCK_INNER_W = 68.0 LAYER_FRAME = "TITLEBLOCK" LAYER_TEXT = "TEXT" DEFAULT_TEXT_STYLE = "GOST_A" DEFAULT_FONT = "isocpeur.ttf" @dataclass class GostTitleBlockMetadata: """Данные основной надписи по ГОСТ Р 21.1101-2013 (форма 3).""" drawing_no: str = "" # графа 1 — обозначение документа enterprise: str = "" # графа 2 — предприятие / объект строительства building: str = "" # графа 3 — здание (сооружение) title: str = "" # графа 4 — наименование изображений листа stage: str = "Р" # графа 6 — стадия sheet: str = "1" # графа 7 — лист total_sheets: str = "1" # графа 8 — листов organization: str = "" # графа 9 — организация scale: str = "1:100" # графа 25 — масштаб format: str = "A3" # графа 26 — формат листа developed_by: str = "" # графа 11 developed_date: str = "" # графа 13 checked_by: str = "" # Проверил checked_date: str = "" norm_control: str = "" # Н. контр. approved_by: str = "" # Утв. change_rows: list[list[str]] = field(default_factory=list) def _fmt(value: str) -> str: return "" if value is None else str(value) def _fit_height(value: str, height: float, max_width: float = BLOCK_INNER_W) -> float: """Уменьшить высоту строки, если она не влезает в графу по ширине. Графы 2, 3, 4 и 9 имеют фиксированную ширину 70 мм, а наименования бывают длинными («План расположения электрооборудования» — 37 знаков). Переносить строку внутри пятимиллиметровой строки штампа некуда, поэтому длинное значение рисуется мельче — так, чтобы остаться в своей графе, а не наезжать на соседние. Ниже ``MIN_TEXT_H`` (1.8 мм) не опускаемся: подпись мельче нормы читаемости не читается на печати, и лучше чуть выйти за клетку, чем отдать лист с нечитаемой основной надписью. """ text = _fmt(value) if not text or not height: return height width = len(text) * height * CHAR_W_RATIO if width <= max_width: return height return max(MIN_TEXT_H, round(max_width / (len(text) * CHAR_W_RATIO), 2)) def _tag_width(number: int, height: float = GRAPH_NO_H) -> float: """Ширина номера графы в скобках — '(14)', '(25)'.""" return len(f"({number})") * height * CHAR_W_RATIO def _value_after_tag(base_x: float, number: int, height: float = GRAPH_NO_H) -> float: """X значения в клетке, где слева уже стоит номер графы. Раньше номер и значение рисовались из одной точки, и «(25)Масштаб 1:50» печаталось наложением друг на друга (претензия Павла «надписи накладываются друг на друга», разобрано 01.10.2026). """ return base_x + _tag_width(number, height) + TAG_GAP async def ensure_layers(backend, names) -> dict: """Создать слои, если их нет (не мешая уже существующим).""" result: dict[str, bool] = {} try: existing = {str(entry.name) for entry in await backend.layer_list()} except Exception: return result for name in names: if name in existing: result[name] = False continue try: await backend.layer_create(name) result[name] = True except Exception: result[name] = False return result async def set_layout_paper(backend, layout: str, sheet_w: float, sheet_h: float) -> dict: """Привести размер листа (страницы) к формату, под который нарисована рамка. `drawing_export_pdf(page_size="layout")` берёт страницу PDF из `Layout.dxf.paper_width/paper_height`. Документ, созданный `drawing_new`, начинается с A3 независимо от формата, переданного в `sheet_format`, поэтому рамка A4 уезжала в угол страницы A3. Бэкенд без `layout_set_paper` (COM) не считается ошибкой: там страница задаётся настройкой печати AutoCAD. """ setter = getattr(backend, "layout_set_paper", None) if setter is None: return {"ok": False, "reason": "backend has no layout_set_paper"} try: return await setter(layout, sheet_w, sheet_h) except Exception as exc: # размер страницы не должен валить построение рамки return {"ok": False, "error": str(exc)} async def ensure_text_style(backend, style: str, font: str, set_default: bool = True) -> dict: """Best-effort: настроить текстовый стиль с поддержкой кириллицы.""" ensure = getattr(backend, "textstyle_ensure", None) if ensure is None: return {"ok": False, "reason": "backend has no textstyle support"} try: return await ensure(style, font=font, set_default=set_default) except Exception as exc: # стиль не должен валить построение штампа return {"ok": False, "error": str(exc)} async def apply_gost_form3_titleblock( backend: "AutoCADBackend", *, metadata: GostTitleBlockMetadata, sheet: str = "A3", origin: tuple[float, float] = (0.0, 0.0), layout: str | None = None, text_style: str = DEFAULT_TEXT_STYLE, text_font: str = DEFAULT_FONT, ) -> dict: """Нарисовать рамку листа и основную надпись формы 3 в её правом нижнем углу. `layout` — имя листа (paper space); без него рисуется в текущем пространстве. `sheet` — формат листа (A3, A4, ...), он же уходит в графу 26. """ sheet_key = str(sheet or "A3").strip().upper() if sheet_key not in SHEETS: raise RuntimeError( f"apply_gost_form3_titleblock: unknown sheet {sheet!r} " f"(known: {', '.join(sorted(SHEETS))})" ) sheet_w, sheet_h = SHEETS[sheet_key] ox, oy = float(origin[0]), float(origin[1]) previous_space = "Model" if layout is not None: listing = await backend.layout_list() names = {str(entry) for entry in (listing.get("layouts") or [])} if layout not in names: raise RuntimeError( f"apply_gost_form3_titleblock: layout {layout!r} does not exist " f"(have: {', '.join(sorted(names))}). Create it with layout_create first." ) previous_space = getattr(backend, "_current_space", "Model") switched = await backend.layout_set_current(layout) if not switched.get("ok", True): raise RuntimeError(f"apply_gost_form3_titleblock: {switched.get('error')}") try: style_status = await ensure_text_style(backend, text_style, text_font, True) layers_status = await ensure_layers(backend, (LAYER_FRAME, LAYER_TEXT)) paper_status = ( await set_layout_paper(backend, layout, sheet_w, sheet_h) if layout is not None else {"ok": False, "reason": "no layout given"} ) drawn = await _draw( backend, metadata=metadata, ox=ox, oy=oy, sheet_key=sheet_key, sheet_w=sheet_w, sheet_h=sheet_h, layout=layout, style_status=style_status, layers_status=layers_status, ) if isinstance(drawn, dict): drawn.setdefault("paper_status", paper_status) return drawn finally: if layout is not None: await backend.layout_set_current(previous_space) async def _draw( backend, *, metadata: GostTitleBlockMetadata, ox: float, oy: float, sheet_key: str, sheet_w: float, sheet_h: float, layout: str | None, style_status: dict, layers_status: dict, ) -> dict: async def line(x1: float, y1: float, x2: float, y2: float) -> str: ent = await backend.entity_create_line(x1, y1, x2, y2, layer=LAYER_FRAME) return ent.handle async def text( value: str, x: float, y: float, height: float = LABEL_H, *, rotation: float = 0.0, ) -> str | None: value = _fmt(value) if not value: return None ent = await backend.entity_create_text( text=value, x=x, y=y, height=height, rotation=rotation, layer=LAYER_TEXT ) return ent.handle handles: list[str] = [] # ── рамка листа: поля 20/5/5/5 ─────────────────────────────────────────── fx0 = ox + MARGIN_LEFT fy0 = oy + MARGIN_BOTTOM fx1 = ox + sheet_w - MARGIN_RIGHT fy1 = oy + sheet_h - MARGIN_TOP frame = await backend.entity_create_polyline( points=[[fx0, fy0], [fx1, fy0], [fx1, fy1], [fx0, fy1]], closed=True, layer=LAYER_FRAME, ) handles.append(frame.handle) # ── штамп: правый нижний угол рамки ────────────────────────────────────── sx0 = fx1 - STAMP_W sy0 = fy0 sx1 = fx1 sy1 = fy0 + STAMP_H stamp_frame = await backend.entity_create_polyline( points=[[sx0, sy0], [sx1, sy0], [sx1, sy1], [sx0, sy1]], closed=True, layer=LAYER_FRAME, ) handles.append(stamp_frame.handle) left_x = sx0 + LEFT_BLOCK_W # 65 мм split_70 = sx0 + LEFT_BLOCK_W + 70.0 # 135 мм split_sheet = split_70 + SHEET_COLUMNS[0][2] # 150 мм split_sheet2 = split_sheet + SHEET_COLUMNS[1][2] # 165 мм # горизонтальные линии: строки по 5 мм row_lines: list[float] = [] for i in range(1, int(STAMP_H / ROW_H)): y = round(sy0 + i * ROW_H, 4) row_lines.append(y) handles.append(await line(sx0, y, sx1, y)) # правая часть штампа: нижняя полоса граф 25/26 отделена, а строки # граф 1-4 идут от sy0+5 вверх handles.append(await line(left_x, sy0, left_x, sy1)) handles.append(await line(split_70, sy0, split_70, sy1)) handles.append(await line(split_70, sy1 - 25.0, sx1, sy1 - 25.0)) # верх блока 6-8 handles.append(await line(split_sheet, sy1 - 25.0, split_sheet, sy1 - 10.0)) handles.append(await line(split_sheet2, sy1 - 25.0, split_sheet2, sy1 - 10.0)) handles.append(await line(split_sheet, sy1 - 30.0, sx1, sy1 - 30.0)) # подписи «Стадия/Лист/Листов» # ── таблица изменений (графы 14-19) ───────────────────────────────────── x = sx0 for number, label, width in CHANGE_COLUMNS: await text(f"({number})", x + TAG_PAD, sy1 - ROW_H + 0.6, GRAPH_NO_H) # «Кол.уч.» и «№ док.» при 1.8 мм занимают по 9.1 мм: подписи # подгоняются по полезной ширине графы, а сами графы расширены до 11 мм, # чтобы подпись не уменьшалась ниже ГОСТовских 1.8 мм. await text(label, x + TAG_PAD, sy1 - 2 * ROW_H + 0.6, _fit_height(label, LABEL_H, width - 2 * TAG_PAD)) x += width if x < left_x - 0.01: handles.append(await line(x, sy1 - 2 * ROW_H, x, sy0)) # пустые строки таблицы изменений: 4 строки (sy1-20 .. sy1-45) for row_index, row in enumerate(list(metadata.change_rows)[:4]): y = sy1 - (2 + row_index + 1) * ROW_H + 0.6 x = sx0 for cell, (_, _, width) in zip(list(row) + [""] * 6, CHANGE_COLUMNS): if _fmt(cell): await text(_fit_height(_fmt(cell), LABEL_H, width - 2 * TAG_PAD), x + TAG_PAD, y) x += width # ── подписи (графы 10-14) ─────────────────────────────────────────────── sign_values = ( (metadata.developed_by, metadata.developed_date), (metadata.checked_by, metadata.checked_date), ("", ""), (metadata.norm_control, ""), (metadata.approved_by, ""), ) sign_rows = [ (label, number, name, date) for (label, number), (name, date) in zip(SIGN_ROWS, sign_values) ] # Пропорции блока подписей: характер работы 17 мм, фамилия 18 мм, подпись # 12 мм, дата 18 мм (сумма 65). Прежние 20/22/13/10 не вмещали ни «01.10.2026» # (дата ужималась до 1.2 мм — вдвое мельче нормы), ни полные подписи. col_name_x = sx0 + SIGN_LABEL_W # Ф.И.О. col_sign_x = col_name_x + SIGN_NAME_W # подпись col_date_x = col_sign_x + SIGN_SIGN_W # дата # Строки 7-11 (индекс 0 = верхняя): таблица изменений занимает строки 1-6 # (две строки подписей граф 14-19 и четыре пустых), подписи 10-14 — строки # 7-11. Смещение считалось от строки 8, поэтому пятая подпись («Утв.») # рисовалась на 5 мм НИЖЕ низа штампа — за пределами рамки листа. for index, (label, number, name, date) in enumerate(sign_rows): top = sy1 - (6 + index) * ROW_H y = top - ROW_H + 0.7 # Номер графы — в верхнем левом углу СВОЕЙ клетки. Он стоял на 1.4 мм # выше верхней линии, то есть в соседней строке: «(11)» печаталось в # клетке «Разраб.», «(14)» — в строке «Масштаб». await text(f"({number})", sx0 + TAG_PAD, top - GRAPH_NO_H - 0.3, GRAPH_NO_H) await text(label, sx0 + 2.6, y, _fit_height(label, LABEL_H, SIGN_LABEL_W - 3.6)) await text(name, col_name_x + TAG_PAD, y, _fit_height(name, VALUE_H, SIGN_NAME_W - 2 * TAG_PAD)) # Дата — графа 13: «01.10.2026» в 1.8 мм занимает 15.1 мм, поэтому # колонка расширена до 18 мм, а высота подгоняется по её полезной ширине. await text(date, col_date_x + TAG_PAD, y, _fit_height(date, LABEL_H, SIGN_DATE_W - 2 * TAG_PAD)) for gx in (col_name_x, col_sign_x, col_date_x): handles.append(await line(gx, sy0, gx, sy1 - 6 * ROW_H)) # ── графа 1: обозначение документа ────────────────────────────────────── # Значение выравнивается по правому краю графы 1: `entity_create_text` # ставит текст по левому краю от точки вставки, поэтому обозначение, # записанное «от правой границы», уезжало в графы 6-8. Ширина считается по # фактической метрике знака (CHAR_W_RATIO), а не «на глаз». await text("(1)", sx0 + LEFT_BLOCK_W + PAD, sy1 - 2 * ROW_H, GRAPH_NO_H) await text( metadata.drawing_no, split_70 - 1.5 - len(_fmt(metadata.drawing_no)) * TITLE_H * CHAR_W_RATIO, sy1 - ROW_H - 3.4, TITLE_H, ) # Значения граф 2-4 рисуются ПОСЛЕ номера графы, а не из той же точки: # раньше «(2)» и «ИП Николашкин П.В.» печатались полностью наложенными # друг на друга (проверено разбором текстового слоя PDF 01.10.2026). tag_x = sx0 + LEFT_BLOCK_W + PAD await text("(2)", tag_x, sy1 - 4 * ROW_H + 0.6, GRAPH_NO_H) await text(metadata.enterprise, _value_after_tag(tag_x, 2), sy1 - 4 * ROW_H + 1.0, _fit_height(metadata.enterprise, VALUE_H, BLOCK_INNER_W - _tag_width(2) - TAG_GAP - PAD)) await text("(3)", tag_x, sy1 - 6 * ROW_H + 0.6, GRAPH_NO_H) await text(metadata.building, _value_after_tag(tag_x, 3), sy1 - 6 * ROW_H + 1.0, _fit_height(metadata.building, VALUE_H, BLOCK_INNER_W - _tag_width(3) - TAG_GAP - PAD)) # графа 4 — наименование изображений листа await text("(4)", tag_x, sy0 + 2 * ROW_H + 0.6, GRAPH_NO_H) await text(metadata.title, _value_after_tag(tag_x, 4), sy0 + 2 * ROW_H, _fit_height(metadata.title, VALUE_H, BLOCK_INNER_W - _tag_width(4) - TAG_GAP - PAD)) # ── графа 9 — организация ─────────────────────────────────────────────── await text("(9)", split_70 + PAD, sy0 + 2 * ROW_H + 0.6, GRAPH_NO_H) # Ниже y = 30 правая часть штампа (365..415) не делится — это графа 9 в # 50 мм; длинное название организации подгоняем по 47 мм полезной ширины. await text(metadata.organization, split_70 + PAD, sy0 + ROW_H + 0.4, _fit_height(metadata.organization, VALUE_H, 47.0)) # ── графы 6-8: Стадия / Лист / Листов ─────────────────────────────────── header_y = sy1 - 25.0 - ROW_H + 1.4 x = split_70 for number, label, width in SHEET_COLUMNS: await text(label, x + PAD, header_y, LABEL_H) await text(f"({number})", x + PAD, sy1 - 25.0 - ROW_H - 1.6, GRAPH_NO_H) x += width await text(metadata.stage, split_70 + PAD, sy1 - 30.0 - ROW_H - 0.6, VALUE_H) await text(metadata.sheet, split_sheet + PAD, sy1 - 30.0 - ROW_H - 0.6, VALUE_H) await text(metadata.total_sheets, split_sheet2 + PAD, sy1 - 30.0 - ROW_H - 0.6, VALUE_H) # ── графы 25 (масштаб) и 26 (формат) ──────────────────────────────────── await text("(25)", tag_x, sy0 + 1.4, GRAPH_NO_H) await text(f"Масштаб {metadata.scale}", _value_after_tag(tag_x, 25), sy0 + 0.7, LABEL_H) await text("(26)", split_70 + PAD, sy0 + 1.4, GRAPH_NO_H) await text(f"Формат {sheet_key}", _value_after_tag(split_70 + PAD, 26), sy0 + 0.7, LABEL_H) return { "ok": True, "standard": "ГОСТ Р 21.1101-2013, форма 3", "sheet": sheet_key, "layout": layout or "Model", "frame": frame.handle, "stamp_frame": stamp_frame.handle, "lines": handles, "text_style": style_status, "layers": layers_status, "frame_margins": { "left": MARGIN_LEFT, "right": MARGIN_RIGHT, "top": MARGIN_TOP, "bottom": MARGIN_BOTTOM, }, "stamp_bbox": { "min": [sx0, sy0], "max": [sx1, sy1], "width": STAMP_W, "height": STAMP_H, }, "frame_bbox": {"min": [fx0, fy0], "max": [fx1, fy1]}, "metadata": asdict(metadata), }