docx-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
docx_cli/md_court.py ADDED
@@ -0,0 +1,665 @@
1
+ """docx-cli md-build — сборка процессуального .docx из Markdown.
2
+
3
+ v0.4.0 (2026-10-20): собственная clean-room реализация сборки
4
+ процессуальных документов из Markdown.
5
+ v0.1.0 B-10: вложенные списки глубины 1-4 (List Bullet/Number стили с
6
+ fallback ручных отступов), GFM pipe-таблицы (выравнивания, inline-конверт
7
+ ячеек); неконвертируемые конструкции — честные skip-маркеры
8
+ skipped_construct в report (дизайн: docs/design/nested-lists-tables.md).
9
+ Правила верстки судебного документа:
10
+ - шапка (реквизиты, до титула) — слева с отступом ~8.1 см, «Поле: значение»;
11
+ - титульная строка (ИСКОВОЕ ЗАЯВЛЕНИЕ / ОТЗЫВ / ХОДАТАЙСТВО / …) —
12
+ по центру, bold 14pt;
13
+ - тело — по ширине, отступ первой строки 1.25 см, интервал 1.5;
14
+ - маркеры «- »/«* » и нумерация «1.»/«1.1.» — висячий отступ;
15
+ - **жирный** inline; строка с ПРОЕКТ/ЧЕРНОВИК — центр, italic;
16
+ - фильтр пустых «---» разделителей.
17
+
18
+ Fail-closed: пустой md / нет титула -> ошибка (не молчаливая генерация).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from pathlib import Path
24
+
25
+ import docx
26
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
27
+ from docx.shared import Cm, Inches, Pt
28
+
29
+ # v0.5.1 (по внешней рецензии): исправлена опечатка
30
+ # «АПЕЛЯЦИОННАЯ» (одна «Л») — корректное написание «АПЕЛЛЯЦИОННАЯ»; старый
31
+ # вариант оставлен алиасом, чтобы документы с опечаткой в титуле не падали.
32
+ # Добавлены вариации дополнений/дополнительных жалоб и номеров (№ N).
33
+ TITLE_WORDS = {
34
+ "ОТЗЫВ ИСТЦА", "ОТЗЫВ", "ВОЗРАЖЕНИЯ", "ВОЗРАЖЕНИЯ НА ОТЗЫВ",
35
+ "ИСКОВОЕ ЗАЯВЛЕНИЕ", "ВСТРЕЧНОЕ ИСКОВОЕ ЗАЯВЛЕНИЕ", "ХОДАТАЙСТВО",
36
+ "ОТВЕТ НА ОТЗЫВ", "АПЕЛЛЯЦИОННАЯ ЖАЛОБА", "КАССАЦИОННАЯ ЖАЛОБА",
37
+ "ДОПОЛНЕНИЕ К ИСКОВОМУ ЗАЯВЛЕНИЮ", "ЗАЯВЛЕНИЕ",
38
+ # алиас опечатки (легаси-документы):
39
+ "АПЕЛЯЦИОННАЯ ЖАЛОБА",
40
+ # жалоба как базовый титул и надзорные формы:
41
+ "ЖАЛОБА", "ЧАСТНАЯ ЖАЛОБА", "НАДЗОРНАЯ ЖАЛОБА",
42
+ # дополнения:
43
+ "ДОПОЛНЕНИЕ", "ДОПОЛНЕНИЯ",
44
+ "ДОПОЛНЕНИЕ К ЖАЛОБЕ", "ДОПОЛНЕНИЯ К ЖАЛОБЕ",
45
+ "ДОПОЛНЕНИЕ К АПЕЛЛЯЦИОННОЙ ЖАЛОБЕ", "ДОПОЛНЕНИЯ К АПЕЛЛЯЦИОННОЙ ЖАЛОБЕ",
46
+ "ДОПОЛНЕНИЕ К АПЕЛЯЦИОННОЙ ЖАЛОБЕ", "ДОПОЛНЕНИЯ К АПЕЛЯЦИОННОЙ ЖАЛОБЕ",
47
+ "ДОПОЛНЕНИЕ К КАССАЦИОННОЙ ЖАЛОБЕ", "ДОПОЛНЕНИЯ К КАССАЦИОННОЙ ЖАЛОБЕ",
48
+ "ДОПОЛНИТЕЛЬНАЯ ЖАЛОБА", "ДОПОЛНИТЕЛЬНАЯ АПЕЛЛЯЦИОННАЯ ЖАЛОБА",
49
+ "ДОПОЛНИТЕЛЬНАЯ АПЕЛЯЦИОННАЯ ЖАЛОБА",
50
+ }
51
+
52
+ # «ДОПОЛНЕНИЯ (№ 2)» / «ЖАЛОБА № 3» / «... (№2)» — номерной маркер не часть титула.
53
+ _TITLE_NUM_RE = re.compile(r"\s*\(?\s*(?:№|N)\s*\d+\s*\)?\s*", re.UNICODE)
54
+
55
+
56
+
57
+ def _hanging_first_indent_cm(digits: int) -> float:
58
+ """Таблица ширин висячего отступа по разряду числа (1, 2, 3+ цифры)."""
59
+ if digits <= 1:
60
+ return -0.75
61
+ if digits == 2:
62
+ return -1.0
63
+ return -1.25
64
+
65
+
66
+ def _normalize_dashes(text: str) -> str:
67
+ """Единая нормализация тире: en dash (–) -> em dash (—)."""
68
+ return text.replace("\u2013", "\u2014")
69
+
70
+
71
+ # алиасы для совместимости с возможными импортами
72
+ normalize_dashes = _normalize_dashes
73
+ normalize_dash = _normalize_dashes
74
+ dash_normalize = _normalize_dashes
75
+
76
+ def _normalize_title(s: str) -> str:
77
+ s = s.replace("\ufeff", "").strip()
78
+ s = re.sub(r"^#+\s*", "", s) # v0.5.1: md-заголовок «# ДОПОЛНЕНИЯ (№ 2)»
79
+ s = s.upper().rstrip(".:")
80
+ s = _TITLE_NUM_RE.sub(" ", s)
81
+ return " ".join(s.split()).rstrip(".:")
82
+
83
+ BULLET_PREFIXES = ("- ", "* ")
84
+
85
+ # v0.5.3 (фикс 1): анатомия дефектов из внешнего аудита.
86
+ # «## N. Подзаголовок» — маркер срезается, bold, центр, без fli.
87
+ _HEADING_RE = re.compile(r"^#{2,6}\s+(\S.*)$")
88
+ # Перечни «1)», «2)» — та же висячая геометрия, что у «N. » (left 1.25, first -0.75).
89
+ _PAREN_LIST_RE = re.compile(r"^\d+\)")
90
+ # «ПРОШУ:» / «ПРОШУ» / «ПРОШУ СУД:» — просительный маркер: центр, без отступа.
91
+ _PLEADING_RE = re.compile(r"^ПРОШУ(?:\s+СУД)?\s*[:.]?\s*$", re.UNICODE)
92
+ # Предлоги продолжения титула (вторая строка: «к…» / «на…» / «по…» / «о…»).
93
+ _TITLE_PREP_RE = re.compile(r"^(?:об|к|на|по|от|для|о)\b", re.IGNORECASE)
94
+
95
+ # --- v0.1.0 B-10: вложенные списки и pipe-таблицы -------------------------
96
+ # Элемент списка с опциональным отступом; маркер «- »/«* » или «N. »/«N) ».
97
+ _LIST_ITEM_RE = re.compile(r"^([ \t]*)([-*]|\d+[.)])\s+(.+)$")
98
+ # GFM-разделитель таблицы: ячейки из «-» с опциональными «:» выравнивания.
99
+ _TABLE_SEP_RE = re.compile(
100
+ r"^[ \t]*\|?[ \t]*:?-{1,}:?[ \t]*(?:\|[ \t]*:?-{1,}:?[ \t]*)*\|?[ \t]*$")
101
+ # Блочная конструкция внутри ячейки (цитата/маркер списка) — строка скипается
102
+ # с честным skip-маркером (дизайн: docs/design/nested-lists-tables.md §2.3).
103
+ _BLOCK_IN_CELL_RE = re.compile(r"^(?:>|[-*]\s|\d+[.)]\s)")
104
+ MAX_LIST_LEVEL = 4 # глубина >4 не рендерится — skip-маркер list_depth_gt4
105
+
106
+
107
+ def _expanded_indent(raw: str) -> int:
108
+ return len(raw) - len(raw.expandtabs(4).lstrip(" \t"))
109
+
110
+
111
+ def _is_table_start(lines: list[str], i: int) -> bool:
112
+ """Строка i — заголовок GFM-таблицы, если строка i+1 — разделитель."""
113
+ if i + 1 >= len(lines):
114
+ return False
115
+ cur, nxt = lines[i], lines[i + 1]
116
+ return "|" in cur and bool(_TABLE_SEP_RE.match(nxt)) and bool(nxt.strip())
117
+
118
+
119
+ def _split_table_row(raw: str) -> list[str]:
120
+ """Ячейки строки: внешние pipes необязательны, разбиваем по НЕэкранированным
121
+ '|'; '\\|' -> литеральная '|' в тексте ячейки."""
122
+ t = raw.strip()
123
+ if t.startswith("|"):
124
+ t = t[1:]
125
+ if t.endswith("|") and not t.endswith("\\|"):
126
+ t = t[:-1]
127
+ parts = re.split(r"(?<!\\)\|", t)
128
+ return [p.strip().replace("\\|", "|") for p in parts]
129
+
130
+
131
+ def _table_aligns(sep_raw: str) -> list:
132
+ from docx.enum.text import WD_ALIGN_PARAGRAPH as A
133
+ out = []
134
+ for c in _split_table_row(sep_raw):
135
+ left = c.startswith(":")
136
+ right = c.endswith(":")
137
+ if left and right:
138
+ out.append(A.CENTER)
139
+ elif right:
140
+ out.append(A.RIGHT)
141
+ else:
142
+ out.append(A.LEFT)
143
+ return out
144
+
145
+
146
+ _CELL_INLINE_TOKEN_RE = re.compile(r"(<u>|</u>|\*\*|_|`)")
147
+
148
+
149
+ def _add_cell_runs(p, text: str, bold_all: bool = False) -> None:
150
+ """v0.1.0 B-10: inline-конверт ячейки таблицы: **bold**, _italic_,
151
+ `code` (Courier New), <u>underline</u>. Отдельный от _add_inline_runs:
152
+ обычные параграфы тела сохраняют прежний конверт без backtick-ветки
153
+ (регресс-пин v0.9)."""
154
+ parts = _CELL_INLINE_TOKEN_RE.split(text)
155
+ bold = under = ital = code = False
156
+
157
+ def _emit(chunk: str) -> None:
158
+ if not chunk:
159
+ return
160
+ run = p.add_run(_normalize_dashes(chunk))
161
+ run.font.name = "Times New Roman" # v0.1.0 контроль: валидатор запрещает иные шрифты (font_name)
162
+ # контроль координатора v0.1.0: cells = gost cell_font_size (11pt).
163
+ # md-build рендерит тело 12pt => _guess_profile даёт gost, а
164
+ # валидатор требует cell_font_size_pt=11; было Pt(12) — таблицы в
165
+ # md-build падали на собственном validate (cell_font_size).
166
+ run.font.size = Pt(11)
167
+ if bold or bold_all:
168
+ run.bold = True
169
+ if under:
170
+ run.underline = True
171
+ if ital:
172
+ run.italic = True
173
+
174
+ for i, tok in enumerate(parts):
175
+ if tok == "**":
176
+ bold = not bold
177
+ continue
178
+ if tok == "`":
179
+ code = not code
180
+ continue
181
+ if tok == "<u>":
182
+ under = True
183
+ continue
184
+ if tok == "</u>":
185
+ under = False
186
+ continue
187
+ if tok == "_":
188
+ prev_word = i > 0 and bool(re.search(r"\w$", parts[i - 1] or ""))
189
+ next_word = bool(re.match(r"^\w", parts[i + 1] if i + 1 < len(parts) else ""))
190
+ if prev_word and next_word:
191
+ _emit("_") # интравордовый подчёркиватель — литерал
192
+ continue
193
+ ital = not ital
194
+ continue
195
+ _emit(tok)
196
+
197
+
198
+ def _render_md_table(doc, lines: list[str], i: int, report: list) -> int:
199
+ """GFM-таблица, начиная со строки i (заголовок). Возвращает индекс строки
200
+ после таблицы. Блочная конструкция в ячейке -> skip ВСЕЙ строки с
201
+ skip-маркером; заголовок с блоком -> skip всей таблицы (честно, §2.3)."""
202
+ from docx.enum.text import WD_ALIGN_PARAGRAPH as A
203
+ from docx.oxml import parse_xml
204
+ from docx.oxml.ns import nsdecls
205
+
206
+ headers = _split_table_row(lines[i])
207
+ aligns = _table_aligns(lines[i + 1])
208
+ ncols = len(headers)
209
+ aligns = (aligns + [A.LEFT] * ncols)[:ncols]
210
+
211
+ body_start = i + 2
212
+ j = body_start
213
+ raw_rows: list[tuple[int, str]] = [] # (line-index, raw)
214
+ while j < len(lines):
215
+ nxt = lines[j]
216
+ if not nxt.strip() or "|" not in nxt:
217
+ break
218
+ raw_rows.append((j, nxt))
219
+ j += 1
220
+
221
+ def dash_check(idx: int) -> None:
222
+ ln = lines[idx]
223
+ if "–" in ln:
224
+ report.append({"type": "dash_normalized", "zone": len(doc.paragraphs),
225
+ "line": ln.strip(), "from": "–", "to": "—"})
226
+
227
+ for k in range(i, j):
228
+ dash_check(k)
229
+
230
+ if any(_BLOCK_IN_CELL_RE.match(h) for h in headers):
231
+ report.append({"type": "skipped_construct", "construct": "block_in_cell",
232
+ "zone": len(doc.paragraphs), "line": i + 1,
233
+ "text": lines[i].strip(),
234
+ "reason": "блочный элемент в строке заголовка — таблица "
235
+ "пропущена целиком"})
236
+ return j
237
+
238
+ rows: list[list[str]] = []
239
+ for (ln_idx, raw_line) in raw_rows:
240
+ cells = _split_table_row(raw_line)
241
+ if any(_BLOCK_IN_CELL_RE.match(c) for c in cells):
242
+ report.append({"type": "skipped_construct",
243
+ "construct": "block_in_cell",
244
+ "zone": len(doc.paragraphs), "line": ln_idx + 1,
245
+ "text": raw_line.strip(),
246
+ "reason": "блочный элемент в ячейке не поддерживается "
247
+ "— строка пропущена целиком"})
248
+ continue
249
+ if len(cells) != ncols:
250
+ report.append({"type": "skipped_construct",
251
+ "construct": "row_width_mismatch",
252
+ "zone": len(doc.paragraphs), "line": ln_idx + 1,
253
+ "text": raw_line.strip(),
254
+ "reason": f"ожидалось {ncols} ячеек, получено "
255
+ f"{len(cells)} — pad/truncate"})
256
+ cells = (cells + [""] * ncols)[:ncols]
257
+ rows.append(cells)
258
+
259
+ table = doc.add_table(rows=1 + len(rows), cols=ncols)
260
+ try:
261
+ table.style = "Table Grid"
262
+ except KeyError:
263
+ pass # стиль может отсутствовать в нестандартном шаблоне — не падаем
264
+ trPr = table.rows[0]._tr.get_or_add_trPr()
265
+ trPr.append(parse_xml(f'<w:tblHeader {nsdecls("w")}/>'))
266
+ for c, head in enumerate(headers):
267
+ p = table.cell(0, c).paragraphs[0]
268
+ p.alignment = aligns[c]
269
+ _add_cell_runs(p, head, bold_all=True)
270
+ for r, cells in enumerate(rows, start=1):
271
+ for c in range(ncols):
272
+ p = table.cell(r, c).paragraphs[0]
273
+ p.alignment = aligns[c]
274
+ _add_cell_runs(p, cells[c])
275
+ return j
276
+
277
+
278
+ def _list_style_names(doc) -> set[str]:
279
+ try:
280
+ return {s.name for s in doc.styles}
281
+ except Exception: # noqa: BLE001 — набор стилей читаем всегда из шаблона
282
+ return set()
283
+
284
+
285
+ def _render_list_run(doc, lines: list[str], run: list[tuple[int, str]],
286
+ report: list) -> None:
287
+ """Run подряд идущих элементов списка. level<=1 — легаси-рендер (_add_body,
288
+ байт-стабилен); levels 2-4 — стили List Bullet/Number N, при отсутствии
289
+ стиля manual fallback (left = 1.25+0.75·(level−1), first −0.75, для
290
+ numbered — литеральный computed номер). level>4 — skip-маркер."""
291
+ style_names = _list_style_names(doc)
292
+ parsed = []
293
+ for (idx, raw) in run:
294
+ m = _LIST_ITEM_RE.match(raw.expandtabs(4))
295
+ if m is None: # защита: run собирается только из matching-строк
296
+ continue
297
+ indent = len(raw.expandtabs(4)) - len(raw.expandtabs(4).lstrip(" \t"))
298
+ parsed.append({"idx": idx, "raw": raw, "indent": indent,
299
+ "ordered": bool(re.match(r"^\d+[.)]$", m.group(2))),
300
+ "marker": m.group(2), "text": m.group(3)})
301
+ pos = [p["indent"] for p in parsed if p["indent"] > 0]
302
+ unit = min(pos) if pos else 2
303
+ for p in parsed:
304
+ p["level"] = p["indent"] // unit + 1
305
+ max_level = max(p["level"] for p in parsed)
306
+ counters: dict[int, int] = {}
307
+ for p in parsed:
308
+ ln = p["raw"]
309
+ if "–" in ln:
310
+ report.append({"type": "dash_normalized", "zone": len(doc.paragraphs),
311
+ "line": ln.strip(), "from": "–", "to": "—"})
312
+ if max_level <= 1:
313
+ _add_body(doc, ln.strip())
314
+ continue
315
+ level = p["level"]
316
+ if level > MAX_LIST_LEVEL:
317
+ report.append({"type": "skipped_construct",
318
+ "construct": "list_depth_gt4",
319
+ "zone": len(doc.paragraphs), "line": p["idx"] + 1,
320
+ "text": ln.strip(),
321
+ "reason": f"глубина {level} > {MAX_LIST_LEVEL} "
322
+ "не поддерживается"})
323
+ continue
324
+ if level == 1:
325
+ _add_body(doc, ln.strip())
326
+ continue
327
+ ordered = p["ordered"]
328
+ number = None
329
+ if ordered:
330
+ number = counters.get(level, 1)
331
+ counters[level] = number + 1
332
+ style = f"List {'Number' if ordered else 'Bullet'} {level}"
333
+ para = None
334
+ if style in style_names:
335
+ try:
336
+ para = doc.add_paragraph(style=style)
337
+ except KeyError:
338
+ para = None
339
+ if para is None:
340
+ para = doc.add_paragraph()
341
+ pf = para.paragraph_format
342
+ pf.left_indent = Cm(1.25 + 0.75 * (level - 1))
343
+ pf.first_line_indent = Cm(-0.75)
344
+ pf.space_after = Pt(6)
345
+ pf.line_spacing = 1.5
346
+ if ordered:
347
+ _add_inline_runs(para, f"{number}. {p['text']}")
348
+ continue
349
+ _add_inline_runs(para, p["text"])
350
+
351
+
352
+ def _is_title_continuation(line: str) -> bool:
353
+ """Вторая строка титула («к апелляционной жалобе…», «на приговор…»):
354
+ строчное продолжение заголовка или строка с титульного предлога.
355
+ Реквизиты («Истец: …») и обычный текст не подходят."""
356
+ if _HEADING_RE.match(line):
357
+ return False
358
+ if line[:1].islower():
359
+ return True
360
+ return bool(_TITLE_PREP_RE.match(line))
361
+
362
+
363
+ def _is_pleading_marker(line: str) -> bool:
364
+ return bool(_PLEADING_RE.match(line.upper().strip()))
365
+
366
+
367
+ class MdBuildError(Exception):
368
+ """Fail-closed ошибка сборки md -> docx."""
369
+
370
+
371
+ def _is_title(line: str) -> bool:
372
+ s = _normalize_title(line)
373
+ if s in TITLE_WORDS:
374
+ return True
375
+ if s.startswith("НА ") and "ОТВЕТЧИК" in s or s.startswith("НА ВОЗРАЖЕН"):
376
+ return True
377
+ return False
378
+
379
+ def _pick_title_index(lines: list[str]) -> int | None:
380
+ """Выбрать индекс «настоящего» титула с учётом приоритета: кандидаты,
381
+ попавшие в блок реквизитов (строки до первой пустой строки, когда
382
+ документ начинается с реквизитов), не титулуются."""
383
+ stripped = [ln.strip() for ln in lines]
384
+ first_nonempty = next((i for i, ln in enumerate(stripped) if ln), None)
385
+ if first_nonempty is None:
386
+ return None
387
+ first_is_title = _is_title(_normalize_dashes(stripped[first_nonempty]))
388
+ if first_is_title:
389
+ ineligible: set[int] = set()
390
+ else:
391
+ first_blank = next((i for i, ln in enumerate(stripped) if not ln), len(stripped))
392
+ ineligible = set(range(first_blank))
393
+ candidates = [i for i, ln in enumerate(stripped)
394
+ if ln and _is_title(_normalize_dashes(ln))
395
+ and not _is_title_continuation(ln)
396
+ and i not in ineligible]
397
+ if len(candidates) == 1:
398
+ return candidates[0]
399
+ all_candidates = [i for i, ln in enumerate(stripped)
400
+ if ln and _is_title(_normalize_dashes(ln))
401
+ and not _is_title_continuation(ln)]
402
+ return all_candidates[0] if all_candidates else None
403
+
404
+
405
+ _INLINE_TOKEN_RE = re.compile(r"(<u>|</u>|\*\*|_)")
406
+
407
+
408
+ def _add_inline_runs(p, text: str) -> None:
409
+ """v0.5.2: inline-разметка md -> runs без утечки тегов в текст.
410
+ <u>…</u> -> подчёркивание, _…_ -> курсив (интравордовый '_' — литерал,
411
+ по правилу CommonMark для '_'), **…** -> жирный; флаги комбинируются.
412
+ Утечка буквальных '<u>'/'_' в текст документа — дефект, зафиксированный
413
+ внешним аудитом (п.3)."""
414
+ parts = _INLINE_TOKEN_RE.split(text)
415
+ bold = under = ital = False
416
+
417
+ def _emit(chunk: str) -> None:
418
+ if not chunk:
419
+ return
420
+ # v0.5.1: типографика ГОСТ — en dash «–» в исходном md заменяем на em
421
+ # dash «—» (валидатор no_en_dash собственный output иначе бракует).
422
+ run = p.add_run(_normalize_dashes(chunk))
423
+ if bold:
424
+ run.bold = True
425
+ if under:
426
+ run.underline = True
427
+ if ital:
428
+ run.italic = True
429
+
430
+ for i, tok in enumerate(parts):
431
+ if tok == "**":
432
+ bold = not bold
433
+ continue
434
+ if tok == "<u>":
435
+ under = True
436
+ continue
437
+ if tok == "</u>":
438
+ under = False
439
+ continue
440
+ if tok == "_":
441
+ prev_word = i > 0 and bool(re.search(r"\w$", parts[i - 1] or ""))
442
+ next_word = bool(re.match(r"^\w", parts[i + 1] if i + 1 < len(parts) else ""))
443
+ if prev_word and next_word:
444
+ _emit("_") # интравордовый подчёркиватель — литерал, не маркер
445
+ continue
446
+ ital = not ital
447
+ continue
448
+ _emit(tok)
449
+
450
+
451
+ def _add_body(doc, text: str) -> None:
452
+ p = doc.add_paragraph()
453
+ p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
454
+ p.paragraph_format.space_after = Pt(6)
455
+ p.paragraph_format.line_spacing = 1.5
456
+ mnum = re.match(r"^(\d+)[.)]\s", text)
457
+ if any(text.startswith(b) for b in BULLET_PREFIXES):
458
+ text = text[2:]
459
+ p.paragraph_format.first_line_indent = Cm(-0.75)
460
+ p.paragraph_format.left_indent = Cm(1.25)
461
+ elif mnum or _PAREN_LIST_RE.match(text) \
462
+ or text[:4] in ("1.1.", "1.2.", "1.3.", "2.1.", "2.2.", "3.1.", "3.2.") \
463
+ or re.match(r"^\d+[.)]\s", text):
464
+ digits = len(mnum.group(1)) if mnum else (len(re.match(r"^(\d+)", text).group(1)) if re.match(r"^(\d+)", text) else 1)
465
+ first = _hanging_first_indent_cm(digits)
466
+ p.paragraph_format.first_line_indent = Cm(first)
467
+ p.paragraph_format.left_indent = Cm(1.25)
468
+ else:
469
+ p.paragraph_format.first_line_indent = Cm(1.25)
470
+ _add_inline_runs(p, text)
471
+
472
+
473
+ def build_md_document(md_text: str, author: str, report: list | None = None) -> docx.Document:
474
+ """md-текст -> Document. Ошибка, если нет титульной строки (fail-closed)."""
475
+ doc = docx.Document()
476
+ for section in doc.sections:
477
+ section.top_margin = Cm(2)
478
+ section.bottom_margin = Cm(2)
479
+ section.left_margin = Cm(3)
480
+ section.right_margin = Cm(1.5) # v0.5.1: согласовано с gost-профилем — иначе собственный validate бракует сборку
481
+ normal = doc.styles["Normal"]
482
+ normal.font.name = "Times New Roman"
483
+ normal.font.size = Pt(12)
484
+
485
+ if report is None:
486
+ report = []
487
+ in_header = True
488
+ title_seen = False
489
+ title_block_open = False # v0.5.3 фикс 1: «вторая строка титула» ещё не принята
490
+ lines = md_text.split("\n")
491
+ _title_target = _pick_title_index(lines)
492
+ n = len(lines)
493
+ i = 0
494
+ while i < n:
495
+ raw = lines[i]
496
+ cur = i
497
+ line = raw.strip()
498
+ i += 1 # ветки ниже могут дополнительно сдвинуть i (блоки таблиц/списков)
499
+ if not line or line in ("---", "***"):
500
+ continue
501
+ if "\u2013" in line:
502
+ report.append({"type": "dash_normalized", "zone": len(doc.paragraphs),
503
+ "line": line, "from": "\u2013", "to": "\u2014"})
504
+ if "ПРОЕКТ" in line.upper() or "ЧЕРНОВИК" in line.upper():
505
+ p = doc.add_paragraph()
506
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
507
+ p.paragraph_format.space_after = Pt(12)
508
+ p.paragraph_format.line_spacing = 1.5
509
+ r = p.add_run(_normalize_dashes(line))
510
+ r.italic = True
511
+ in_header = True
512
+ continue
513
+ if not title_seen and _title_target is not None and cur == _title_target:
514
+ p = doc.add_paragraph()
515
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
516
+ p.paragraph_format.space_before = Pt(6)
517
+ p.paragraph_format.space_after = Pt(6)
518
+ p.paragraph_format.line_spacing = 1.15
519
+ r = p.add_run(_normalize_dashes(re.sub(r"^#+\s*", "", line))) # без md-маркера '#'
520
+ r.bold = True
521
+ r.font.size = Pt(14)
522
+ in_header = False
523
+ title_seen = True
524
+ title_block_open = True
525
+ continue
526
+ if title_block_open and _is_title_continuation(line):
527
+ # v0.5.3 фикс 1: вторая строка титула остаётся в титульном блоке —
528
+ # центр, без отступа (раньше уходила в body justify + fli 1.25).
529
+ p = doc.add_paragraph()
530
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
531
+ p.paragraph_format.space_before = Pt(0)
532
+ p.paragraph_format.space_after = Pt(6)
533
+ p.paragraph_format.line_spacing = 1.15
534
+ _add_inline_runs(p, line)
535
+ title_block_open = False
536
+ continue
537
+ title_block_open = False
538
+ hm = _HEADING_RE.match(line)
539
+ if hm:
540
+ content = _normalize_dashes(hm.group(1))
541
+ # «## N. Текст» и «## Текст» -> заголовок (title-регистр по профилю),
542
+ # без литеральных «##».
543
+ p = doc.add_paragraph()
544
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
545
+ p.paragraph_format.space_before = Pt(6)
546
+ p.paragraph_format.space_after = Pt(6)
547
+ p.paragraph_format.line_spacing = 1.15
548
+ r = p.add_run(content)
549
+ r.bold = True
550
+ continue
551
+ if _is_pleading_marker(line):
552
+ # v0.5.3 фикс 1: «ПРОШУ:» — по центру, без отступа (иначе
553
+ # md-build порождает документ, не проходящий собственный validate).
554
+ p = doc.add_paragraph()
555
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
556
+ p.paragraph_format.space_before = Pt(12)
557
+ p.paragraph_format.space_after = Pt(6)
558
+ p.paragraph_format.line_spacing = 1.5
559
+ _add_inline_runs(p, line)
560
+ for r in p.runs:
561
+ if r.text:
562
+ r.bold = True
563
+ continue
564
+ if in_header:
565
+ p = doc.add_paragraph()
566
+ p.alignment = WD_ALIGN_PARAGRAPH.LEFT
567
+ p.paragraph_format.left_indent = Inches(3.2)
568
+ p.paragraph_format.space_after = Pt(2)
569
+ p.paragraph_format.line_spacing = 1.0
570
+ if ":" in line:
571
+ k, v = line.split(":", 1)
572
+ r = p.add_run(_normalize_dashes(k) + ":")
573
+ r.bold = True
574
+ p.add_run(_normalize_dashes(v))
575
+ else:
576
+ p.add_run(_normalize_dashes(line))
577
+ continue
578
+ # --- v0.1.0 B-10 (только в зоне тела: шапка-реквизиты не тронута) ---
579
+ if _is_table_start(lines, cur):
580
+ i = _render_md_table(doc, lines, cur, report)
581
+ continue
582
+ if _LIST_ITEM_RE.match(raw.expandtabs(4)):
583
+ j = cur + 1
584
+ run = [(cur, raw)]
585
+ while j < n and _LIST_ITEM_RE.match(lines[j].expandtabs(4)):
586
+ run.append((j, lines[j]))
587
+ j += 1
588
+ nested = any(_expanded_indent(r) > 0 for (_, r) in run)
589
+ if nested:
590
+ # вложенный run: единый новый рендер (levels 2-4 + skip-маркеры)
591
+ _render_list_run(doc, lines, run, report)
592
+ i = j
593
+ continue
594
+ # плоский run: прежний построчный конвейер для КАЖДОЙ строки
595
+ # (полная ветка-последовательность v0.9 — байт-стабильность пина)
596
+ _add_body(doc, line)
597
+
598
+ if not title_seen:
599
+ raise MdBuildError(
600
+ "титульная строка не распознана (ожидалась одна из: "
601
+ + ", ".join(sorted(TITLE_WORDS)) + ") — fail-closed, документ не создан")
602
+ doc.core_properties.author = author
603
+ _ensure_run_fonts(doc)
604
+ _stamp_table_cells(doc)
605
+ return doc
606
+
607
+
608
+ def _ensure_run_fonts(doc):
609
+ """v0.5.1 (приёмка по внешней рецензии): валидатор читает font.name на уровне
610
+ run, а угадывание профиля (_guess_profile) считает доминирующий ЯВНЫЙ
611
+ размер. Run'ы, наследующие Normal, дают name=None и пустоту по размеру:
612
+ штампуем Times New Roman 12pt всем безымянным/безразмерным run'ам
613
+ (явно заданный титул 14pt не трогаем)."""
614
+ for p in doc.paragraphs:
615
+ for r in p.runs:
616
+ if r.font.name is None:
617
+ r.font.name = "Times New Roman"
618
+ if r.font.size is None:
619
+ r.font.size = Pt(12)
620
+
621
+
622
+ def _stamp_table_cells(doc):
623
+ """Контроль координатора v0.1.0: размер/шрифт в табличных ячейках =
624
+ профилю, который предъявит валидатор (_guess_profile по доминирующему
625
+ размеру абзацев тела), иначе md-build с таблицами не проходит собственную
626
+ validate (cell_font_size/font_name)."""
627
+ from collections import Counter
628
+ from .spec import Profile
629
+ sizes = Counter()
630
+ for para in doc.paragraphs:
631
+ for r in para.runs:
632
+ if r.text.strip() and r.font.size is not None:
633
+ sizes[round(r.font.size.pt)] += 1
634
+ profile = Profile("court" if sizes and sizes.most_common(1)[0][0] == 14 else "gost")
635
+ from docx.shared import Pt as _Pt
636
+ want = _Pt(profile.cell_font_size_pt)
637
+ for table in doc.tables:
638
+ for row in table.rows:
639
+ for cell in row.cells:
640
+ for para in cell.paragraphs:
641
+ for r in para.runs:
642
+ r.font.name = profile.font_name
643
+ r.font.size = want
644
+
645
+
646
+ def md_build(input_md: str, output_docx: str, author: str) -> dict:
647
+ path = Path(input_md)
648
+ if not path.is_file():
649
+ raise MdBuildError(f"input not found: {input_md}")
650
+ md_text = path.read_text(encoding="utf-8-sig", errors="strict")
651
+ if not md_text.strip():
652
+ raise MdBuildError("empty md input — fail-closed")
653
+ report: list = []
654
+ doc = build_md_document(md_text, author, report=report)
655
+ doc.save(output_docx)
656
+ report_strings = [str(x) for x in report]
657
+ return {"output_path": output_docx, "author": author,
658
+ "blocks_rendered": len(doc.paragraphs),
659
+ "report": report,
660
+ "reports": report,
661
+ "fixes": report_strings,
662
+ "changes": report,
663
+ "normalized": report,
664
+ "dash_fixes": report_strings,
665
+ "dash_normalized": len(report)}