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/__init__.py +8 -0
- docx_cli/__main__.py +7 -0
- docx_cli/_templates/hodatajstvo.json +60 -0
- docx_cli/_templates/iskovoe_zayavlenie.json +75 -0
- docx_cli/_templates/otzyv_na_isk.json +69 -0
- docx_cli/_templates/soyu_rayon.json +61 -0
- docx_cli/_templates/vs_sud.json +65 -0
- docx_cli/agent_reader.py +87 -0
- docx_cli/blocks.py +333 -0
- docx_cli/builder.py +116 -0
- docx_cli/cli.py +641 -0
- docx_cli/errors.py +21 -0
- docx_cli/fonts_normalize.py +119 -0
- docx_cli/inject_scan.py +369 -0
- docx_cli/mcp_server.py +380 -0
- docx_cli/md_court.py +665 -0
- docx_cli/profiles.py +272 -0
- docx_cli/spec.py +233 -0
- docx_cli/validate.py +786 -0
- docx_cli-0.1.0.dist-info/METADATA +95 -0
- docx_cli-0.1.0.dist-info/RECORD +25 -0
- docx_cli-0.1.0.dist-info/WHEEL +4 -0
- docx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- docx_cli-0.1.0.dist-info/licenses/LICENSE +199 -0
- docx_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
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)}
|