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/validate.py ADDED
@@ -0,0 +1,786 @@
1
+ """Built-in post-build validation: reopen the generated .docx and verify it."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import zipfile
6
+ from typing import Any
7
+
8
+ from docx import Document
9
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
10
+ from docx.oxml import parse_xml
11
+ from docx.oxml.ns import qn
12
+
13
+ from docx_cli.spec import DocSpec, Profile, now_iso
14
+
15
+
16
+ RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
17
+ CONTENT_TYPES = "[Content_Types].xml"
18
+ RELS = "_rels/.rels"
19
+ DOC_RELS = "word/_rels/document.xml.rels"
20
+ DOCUMENT_XML = "word/document.xml"
21
+
22
+
23
+ _REQUISITE_RE = re.compile(r"(паспорт|снилс|инн|кпп|огрн)", re.IGNORECASE)
24
+
25
+
26
+ def _cm(value: Any) -> float:
27
+ return float(value.cm) if value is not None else 0.0
28
+
29
+
30
+ def _pt(value: Any) -> float | None:
31
+ return float(value.pt) if value is not None else None
32
+
33
+
34
+ def _check_package_integrity(path: str) -> list[str]:
35
+ errors: list[str] = []
36
+ required = {CONTENT_TYPES, RELS, DOC_RELS, DOCUMENT_XML}
37
+ try:
38
+ with zipfile.ZipFile(path, "r") as zf:
39
+ names = set(zf.namelist())
40
+ missing = required - names
41
+ if missing:
42
+ errors.append(f"missing_required_parts: {sorted(missing)}")
43
+ return errors
44
+ for rel_file in (RELS, DOC_RELS):
45
+ data = zf.read(rel_file)
46
+ tree = parse_xml(data)
47
+ for rel in tree.findall(f"{{{RELS_NS}}}Relationship"):
48
+ target = rel.get("Target")
49
+ if target is None:
50
+ continue
51
+ if target.startswith("/"):
52
+ target = target[1:]
53
+ else:
54
+ base = "word/" if rel_file == DOC_RELS else ""
55
+ target = base + target
56
+ target = target.replace("\\", "/")
57
+ # Normalize ../ paths
58
+ parts = target.split("/")
59
+ stack: list[str] = []
60
+ for part in parts:
61
+ if part == "..":
62
+ if stack:
63
+ stack.pop()
64
+ elif part and part != ".":
65
+ stack.append(part)
66
+ normalized = "/".join(stack)
67
+ if normalized and normalized not in names:
68
+ errors.append(f"unresolved_relationship: {rel_file} -> {normalized}")
69
+ except zipfile.BadZipFile as exc:
70
+ errors.append(f"bad_zip: {exc}")
71
+ except Exception as exc:
72
+ errors.append(f"package_integrity: {exc}")
73
+ return errors
74
+
75
+
76
+ def _check_margins(doc: Document, profile: Profile) -> list[str]:
77
+ errors: list[str] = []
78
+ section = doc.sections[0]
79
+ pairs = [
80
+ ("left", _cm(section.left_margin), profile.margin_left_cm),
81
+ ("right", _cm(section.right_margin), profile.margin_right_cm),
82
+ ("top", _cm(section.top_margin), profile.margin_top_cm),
83
+ ("bottom", _cm(section.bottom_margin), profile.margin_bottom_cm),
84
+ ]
85
+ for name, actual, expected in pairs:
86
+ if abs(actual - expected) > 0.05:
87
+ errors.append(f"margin_{name}: expected {expected}cm, got {actual:.3f}cm")
88
+ return errors
89
+
90
+
91
+ def _iter_all_paragraphs(doc: Document):
92
+ """Yield every paragraph in the document, including table cells."""
93
+ idx = 0
94
+ for p in doc.paragraphs:
95
+ yield False, idx, p
96
+ idx += 1
97
+ for table in doc.tables:
98
+ for row in table.rows:
99
+ for cell in row.cells:
100
+ for p in cell.paragraphs:
101
+ yield True, idx, p
102
+ idx += 1
103
+
104
+
105
+ def _expected_paragraph_texts(spec: DocSpec, profile: Profile) -> dict[str, set[str]]:
106
+ """Build exact-text sets for paragraphs rendered outside normal body flow."""
107
+ header_texts = {line.strip() for line in spec.header.lines if line != ""}
108
+ title_texts = {spec.title.main.strip()}
109
+ if spec.title.subtitle:
110
+ title_texts.add(spec.title.subtitle.strip())
111
+
112
+ pleading_texts: set[str] = set()
113
+ signature_texts: set[str] = set()
114
+ list_texts: set[str] = set()
115
+
116
+ for block in spec.body:
117
+ if block.type == "pleading_marker":
118
+ pleading_texts.add(block.text.strip())
119
+ elif block.type == "signature_block":
120
+ fmt = block.format if block.format is not None else profile.default_signature_format
121
+ if fmt == "text":
122
+ signature_texts.add(f"{block.left}\t{block.right}".strip())
123
+ elif block.type == "numbered_list":
124
+ for idx, item in enumerate(block.items, start=1):
125
+ list_texts.add(f"{idx}. {item}".strip())
126
+ elif block.type == "attachments_list":
127
+ for item in block.items:
128
+ list_texts.add(item.strip())
129
+
130
+ return {
131
+ "header": header_texts,
132
+ "title": title_texts,
133
+ "pleading": pleading_texts,
134
+ "signature": signature_texts,
135
+ "list": list_texts,
136
+ }
137
+
138
+
139
+ _SIG_HEAD_RE = re.compile(
140
+ r"^(Защитник|Адвокат|Представитель|Ордер|Истец|Ответчик|Заявитель|"
141
+ r"Жалобщик|Подозреваемый|Обвиняемый)\b"
142
+ )
143
+
144
+
145
+ def _is_service_signature(p, text: str) -> bool:
146
+ """v0.5.1 (по внешней рецензии): подписная/служебная строка, набранная
147
+ пробелами без tab+'___'. Признаки: ролевое слово в начале строки И
148
+ отсутствие красной строки — обычный body-абзац («Защитник указал, что…»)
149
+ идёт с отступом 1.25 см и так проверяется дальше."""
150
+ if not _SIG_HEAD_RE.match(text):
151
+ return False
152
+ # Настоящая подпись с tab+___ проверяется отдельной веткой; это —
153
+ # «пробельная» подпись без таба, для неё красная строка не требуется.
154
+ if "\t" in text and "___" in text:
155
+ return False
156
+ fl = p.paragraph_format.first_line_indent
157
+ return fl is None or int(fl) == 0
158
+
159
+
160
+ def _classify_paragraphs(doc: Document, spec: DocSpec | None, profile: Profile | None = None,
161
+ skipped: list | None = None) -> dict[int, str]:
162
+ """Map paragraph index to role: header, title, body, list, pleading, signature, other."""
163
+ roles: dict[int, str] = {}
164
+ if spec is not None:
165
+ profile = profile or Profile(spec.profile)
166
+ expected = _expected_paragraph_texts(spec, profile)
167
+ attachments_seen_spec = False # v0.5.1: зеркало fallback-логики
168
+ for idx, p in enumerate(doc.paragraphs):
169
+ text = p.text.strip()
170
+ if not text:
171
+ attachments_seen_spec = False
172
+ continue
173
+ if text in expected["header"]:
174
+ roles[idx] = "header"
175
+ elif text in expected["title"]:
176
+ roles[idx] = "title"
177
+ elif text in expected["pleading"]:
178
+ roles[idx] = "pleading"
179
+ elif text in expected["signature"]:
180
+ roles[idx] = "signature"
181
+ elif text in expected["list"]:
182
+ roles[idx] = "list"
183
+ elif re.match(r"^Приложения\s*[:.]?\s*$", text):
184
+ attachments_seen_spec = True
185
+ roles[idx] = "other"
186
+ elif attachments_seen_spec or _is_service_signature(p, text):
187
+ # v0.5.1 (по внешней рецензии): симметрично
188
+ # fallback-ветке — подписные/служебные строки и продолжения
189
+ # списка приложений, не совпавшие с exact-текстом spec,
190
+ # не проверяются как body (красная строка).
191
+ if skipped is not None:
192
+ if _is_service_signature(p, text):
193
+ skipped.append({"type": "check_skipped", "zone": idx,
194
+ "reason": "service_signature"})
195
+ elif attachments_seen_spec:
196
+ skipped.append({"type": "check_skipped", "zone": idx,
197
+ "reason": "appendices_fallback"})
198
+ roles[idx] = "signature"
199
+ else:
200
+ roles[idx] = "body"
201
+ return roles
202
+
203
+ # Fallback heuristic when no spec is supplied (standalone validate command).
204
+ title_idx: int | None = None
205
+ for idx, p in enumerate(doc.paragraphs):
206
+ if p.text.strip() and p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER:
207
+ # First centered non-empty paragraph is treated as title
208
+ title_idx = idx
209
+ break
210
+ # Title block = title paragraph + any immediately following centered
211
+ # paragraphs (subtitle chain), e.g. "ОТЗЫВ ИСТЦА" + "на возражения...".
212
+ title_end = title_idx
213
+ if title_idx is not None:
214
+ for idx in range(title_idx + 1, len(doc.paragraphs)):
215
+ p = doc.paragraphs[idx]
216
+ if (
217
+ p.text.strip()
218
+ and p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
219
+ ):
220
+ title_end = idx
221
+ else:
222
+ break
223
+ attachments_seen = False # v0.5.1: всё после «Приложения:» — служебный блок
224
+ for idx, p in enumerate(doc.paragraphs):
225
+ text = p.text.strip()
226
+ if not text:
227
+ attachments_seen = False
228
+ continue
229
+ if title_idx is not None and title_idx <= idx <= title_end:
230
+ roles[idx] = "title"
231
+ elif title_idx is not None and idx < title_idx:
232
+ roles[idx] = "header"
233
+ elif (
234
+ p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
235
+ and (
236
+ p.paragraph_format.first_line_indent is None
237
+ or int(p.paragraph_format.first_line_indent) == 0
238
+ )
239
+ and text.upper().startswith("ПРОШУ")
240
+ ):
241
+ roles[idx] = "pleading"
242
+ elif (
243
+ p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
244
+ and (
245
+ p.paragraph_format.first_line_indent is None
246
+ or int(p.paragraph_format.first_line_indent) == 0
247
+ )
248
+ ):
249
+ # v0.5.3 (фикс 1/2 инвариант): центрированные структурные блоки
250
+ # (подзаголовок «## …», маркеры) — не body: иначе md-build порождает
251
+ # документ, который бракует собственный validate по alignment.
252
+ roles[idx] = "other"
253
+ elif (
254
+ p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.LEFT
255
+ and "\t" in p.text
256
+ and "___" in p.text
257
+ ):
258
+ roles[idx] = "signature"
259
+ elif attachments_seen:
260
+ # v0.5.1 (по внешней рецензии): продолжения
261
+ # списка приложений («Ордер адвоката …») и подпись после «Приложения:»
262
+ # — служебный блок, красная строка к ним не применяется.
263
+ if skipped is not None:
264
+ skipped.append({"type": "check_skipped", "zone": idx,
265
+ "reason": "appendices_fallback"})
266
+ roles[idx] = "other"
267
+ elif _is_service_signature(p, text):
268
+ if skipped is not None:
269
+ skipped.append({"type": "check_skipped", "zone": idx,
270
+ "reason": "service_signature"})
271
+ roles[idx] = "signature"
272
+ elif text == "Приложения:" or re.match(r"^Приложения\s*[:.]?\s*$", text) \
273
+ or re.match(r"^\d+\.\s", text):
274
+ # Numbered-list / attachment-list paragraphs are not GOST body;
275
+ # skip body checks rather than false-fail. In standalone mode we
276
+ # do not have the spec, so any "N. " paragraph is treated as a
277
+ # list item.
278
+ if text.startswith("Приложения"):
279
+ attachments_seen = True
280
+ roles[idx] = "other"
281
+ elif p.style.name.startswith("List") or (
282
+ p.paragraph_format.left_indent is not None
283
+ and int(p.paragraph_format.left_indent) > 0
284
+ ):
285
+ # v0.1.0 B-10 (контроль координатора): style-mode вложенные
286
+ # списки (List Bullet/Number N) и legacy-рендер уровня 1
287
+ # (Normal + левый отступ 35.5pt) — структурные list-параграфы,
288
+ # не body: ГОСТ-красная строка/JUSTIFY к ним не применяется
289
+ # (ровно как уже исключаются "N. " и hanging-indent списки).
290
+ if skipped is not None:
291
+ skipped.append({"type": "check_skipped", "zone": idx,
292
+ "reason": "list_paragraph"})
293
+ roles[idx] = "other"
294
+ elif (
295
+ abs(_cm(p.paragraph_format.left_indent) - 1.25) <= 0.05
296
+ and abs(_cm(p.paragraph_format.first_line_indent) + 0.75) <= 0.05
297
+ ):
298
+ # v0.2.0 fix: hanging-indent paragraphs (lists/attachments rendered
299
+ # by builder with left=1.25cm, first=-0.75cm) are structural list
300
+ # items even when text heuristics miss them (e.g. text-form
301
+ # signature lines, non-numeric list items). Точный маркер
302
+ # builder'а надёжнее текстовых эвристик.
303
+ roles[idx] = "other"
304
+ else:
305
+ roles[idx] = "body"
306
+ return roles
307
+
308
+
309
+ def _all_runs(p):
310
+ from docx.oxml.ns import qn
311
+ from docx.text.run import Run
312
+ for r in p._p.iter(qn("w:r")):
313
+ yield Run(r, p)
314
+
315
+
316
+ def _check_fonts_and_sizes(doc: Document, spec: DocSpec | None, guessed_profile: str = "gost") -> list[str]:
317
+ errors: list[str] = []
318
+ profile = Profile(spec.profile if spec else guessed_profile, line_spacing=spec.line_spacing if spec else None)
319
+ roles = _classify_paragraphs(doc, spec, profile)
320
+
321
+ for is_cell, p_idx, p in _iter_all_paragraphs(doc):
322
+ role = roles.get(p_idx, "cell" if is_cell else "other")
323
+ for run in _all_runs(p):
324
+ text = run.text
325
+ if not text:
326
+ continue
327
+ name = run.font.name
328
+ if name != profile.font_name:
329
+ errors.append(f"font_name: paragraph {p_idx} run uses '{name}'")
330
+ size = _pt(run.font.size)
331
+ if role == "title":
332
+ is_title_main = run.bold and size == profile.title_font_size_pt
333
+ if not is_title_main and size != profile.body_font_size_pt:
334
+ errors.append(f"title_font_size: paragraph {p_idx} run size {size}pt")
335
+ elif role == "cell":
336
+ if size is not None and size != profile.cell_font_size_pt:
337
+ errors.append(f"cell_font_size: paragraph {p_idx} run size {size}pt")
338
+ else:
339
+ if size is not None and size != profile.body_font_size_pt:
340
+ errors.append(f"body_font_size: paragraph {p_idx} run size {size}pt")
341
+ return errors
342
+
343
+
344
+ def _check_body_formatting(doc: Document, spec: DocSpec | None, guessed_profile: str = "gost") -> list[str]:
345
+ errors: list[str] = []
346
+ profile = Profile(spec.profile if spec else guessed_profile, line_spacing=spec.line_spacing if spec else None)
347
+ roles = _classify_paragraphs(doc, spec, profile)
348
+
349
+ for p_idx, p in enumerate(doc.paragraphs):
350
+ role = roles.get(p_idx, "other")
351
+ text = p.text.strip()
352
+ is_svc = _is_service_signature(p, text)
353
+ if role == "body":
354
+ pass
355
+ elif role == "signature" and is_svc:
356
+ pass
357
+ else:
358
+ continue
359
+ pf = p.paragraph_format
360
+ align = pf.alignment
361
+ if align != WD_ALIGN_PARAGRAPH.JUSTIFY:
362
+ errors.append(f"alignment: paragraph {p_idx} expected JUSTIFY, got {align}")
363
+ ls = pf.line_spacing
364
+ if isinstance(ls, (int, float)) and abs(float(ls) - profile.body_line_spacing) > 0.05:
365
+ errors.append(
366
+ f"line_spacing: paragraph {p_idx} expected {profile.body_line_spacing}, got {ls}"
367
+ )
368
+ if role == "body":
369
+ indent = _cm(pf.first_line_indent)
370
+ if abs(indent - profile.first_line_indent_cm) > 0.05:
371
+ errors.append(
372
+ f"first_line_indent: paragraph {p_idx} expected {profile.first_line_indent_cm}cm, got {indent:.3f}cm"
373
+ )
374
+ return errors
375
+
376
+
377
+ def _check_list_formatting(doc: Document, spec: DocSpec | None, profile: Profile) -> list[str]:
378
+ """Hanging indent for numbered_list and attachments_list items."""
379
+ errors: list[str] = []
380
+ if spec is None:
381
+ return errors
382
+
383
+ roles = _classify_paragraphs(doc, spec, profile)
384
+ for p_idx, p in enumerate(doc.paragraphs):
385
+ if roles.get(p_idx) != "list":
386
+ continue
387
+ pf = p.paragraph_format
388
+ left = _cm(pf.left_indent)
389
+ first = _cm(pf.first_line_indent)
390
+ if abs(left - 1.25) > 0.05:
391
+ errors.append(f"list_left_indent: paragraph {p_idx} expected 1.25cm, got {left:.3f}cm")
392
+ if abs(first - (-0.75)) > 0.05:
393
+ errors.append(f"list_first_line_indent: paragraph {p_idx} expected -0.75cm, got {first:.3f}cm")
394
+ return errors
395
+
396
+
397
+ def _check_header_formatting(doc: Document, spec: DocSpec | None, profile: Profile) -> list[str]:
398
+ errors: list[str] = []
399
+ if spec is None or profile.name != "court":
400
+ return errors
401
+
402
+ roles = _classify_paragraphs(doc, spec, profile)
403
+ expected_left = spec.header.left_indent_cm
404
+ for p_idx, p in enumerate(doc.paragraphs):
405
+ if roles.get(p_idx) != "header":
406
+ continue
407
+ pf = p.paragraph_format
408
+ if pf.alignment != WD_ALIGN_PARAGRAPH.LEFT:
409
+ errors.append(f"header_alignment: paragraph {p_idx} expected LEFT, got {pf.alignment}")
410
+ left = _cm(pf.left_indent)
411
+ if abs(left - expected_left) > 0.05:
412
+ errors.append(f"header_left_indent: paragraph {p_idx} expected {expected_left}cm, got {left:.3f}cm")
413
+ ls = pf.line_spacing
414
+ if isinstance(ls, (int, float)) and abs(float(ls) - profile.header_line_spacing) > 0.05:
415
+ errors.append(f"header_line_spacing: paragraph {p_idx} expected {profile.header_line_spacing}, got {ls}")
416
+ return errors
417
+
418
+
419
+ def _check_pleading_marker(doc: Document, spec: DocSpec | None, profile: Profile) -> tuple[list[str], list[str]]:
420
+ errors: list[str] = []
421
+ warnings: list[str] = []
422
+ if spec is None:
423
+ return errors, warnings
424
+
425
+ # Warn when a plain body paragraph starts with ПРОШУ in court profile.
426
+ if profile.name == "court":
427
+ for block in spec.body:
428
+ if block.type == "paragraph" and block.text.strip().upper().startswith("ПРОШУ"):
429
+ warnings.append("pleading_marker: use dedicated 'pleading_marker' block for text starting with 'ПРОШУ'")
430
+ break
431
+
432
+ roles = _classify_paragraphs(doc, spec, profile)
433
+ for p_idx, p in enumerate(doc.paragraphs):
434
+ if roles.get(p_idx) != "pleading":
435
+ continue
436
+ pf = p.paragraph_format
437
+ if pf.alignment != WD_ALIGN_PARAGRAPH.CENTER:
438
+ errors.append(f"pleading_alignment: paragraph {p_idx} expected CENTER, got {pf.alignment}")
439
+ first = _cm(pf.first_line_indent)
440
+ if abs(first - 0.0) > 0.05:
441
+ errors.append(f"pleading_first_line_indent: paragraph {p_idx} expected 0cm, got {first:.3f}cm")
442
+ for run in p.runs:
443
+ if not run.text:
444
+ continue
445
+ if not run.bold:
446
+ errors.append(f"pleading_bold: paragraph {p_idx} run is not bold")
447
+ size = _pt(run.font.size)
448
+ if size is not None and size != profile.body_font_size_pt:
449
+ errors.append(f"pleading_font_size: paragraph {p_idx} run size {size}pt")
450
+ return errors, warnings
451
+
452
+
453
+ def _check_en_dash(doc: Document, spec: DocSpec | None) -> tuple[list[str], list[str]]:
454
+ errors: list[str] = []
455
+ warnings: list[str] = []
456
+ en_dash = "\u2013"
457
+ no_en_dash = spec.checks.no_en_dash if spec else True
458
+ for is_cell, p_idx, p in _iter_all_paragraphs(doc):
459
+ if en_dash in p.text:
460
+ msg = f"en_dash: paragraph {p_idx} contains '–'"
461
+ if no_en_dash:
462
+ errors.append(msg)
463
+ else:
464
+ warnings.append(msg)
465
+ return errors, warnings
466
+
467
+
468
+ def _is_requisite_line(text: str) -> bool:
469
+ return bool(re.search(r"\d{6,}", text) or _REQUISITE_RE.search(text))
470
+
471
+
472
+ def _check_yo(doc: Document, spec: DocSpec | None) -> list[str]:
473
+ warnings: list[str] = []
474
+ if spec is not None and not spec.checks.no_yo:
475
+ return warnings
476
+ whitelist: set[str] = set()
477
+ if spec is not None:
478
+ whitelist = {line.strip() for line in spec.header.lines if _is_requisite_line(line)}
479
+ for is_cell, p_idx, p in _iter_all_paragraphs(doc):
480
+ text = p.text
481
+ if any(text.strip() == wl for wl in whitelist):
482
+ continue
483
+ if "ё" in text or "Ё" in text:
484
+ warnings.append(f"yo_present: paragraph {p_idx} contains 'ё' outside requisites")
485
+ return warnings
486
+
487
+
488
+ def _check_placeholders(doc: Document, spec: DocSpec | None) -> list[str]:
489
+ warnings: list[str] = []
490
+ pattern = re.compile(r"\[УКАЗАТЬ[^\]]*\]")
491
+ for is_cell, p_idx, p in _iter_all_paragraphs(doc):
492
+ if pattern.search(p.text):
493
+ warnings.append(f"placeholder_present: paragraph {p_idx}")
494
+ return warnings
495
+
496
+
497
+ def _check_norm_refs(doc: Document, spec: DocSpec | None) -> list[str]:
498
+ warnings: list[str] = []
499
+ if spec is None or not spec.checks.require_norms_refs:
500
+ return warnings
501
+ full_text = "\n".join(p.text for p in doc.paragraphs)
502
+ if not re.search(
503
+ r"(?:ст\.?\s*\d|Стать[ья]|ГК\s*РФ|КоАП|НК\s*РФ|ТК\s*РФ|ГПК\s*РФ|КАС\s*РФ)",
504
+ full_text,
505
+ ):
506
+ warnings.append("norm_refs_missing: no legal norm references detected")
507
+ return warnings
508
+
509
+
510
+ def _check_numbered_list_sequence(doc: Document, spec: DocSpec | None) -> list[str]:
511
+ errors: list[str] = []
512
+ pattern = re.compile(r"^(\d+)\.\s")
513
+ # v0.5.1 (round-trip приёмка): блок приложений («Приложения:») — отдельный
514
+ # нумерованный список, его нумерация легитимно начинается с 1 после списка
515
+ # просительной части; в v0.5.0 законные [1 | 1] давали «out of order».
516
+ # v0.5.3 (фикс 3): границы сегментов — «ПРОШУ:» и
517
+ # «Приложения:» без учёта регистра и двоеточия/точки (рабочие документы
518
+ # пишут «прошу:», «приложения.», «ПРОШУ СУД:» и т.п.).
519
+ boundary = re.compile(r"^(?:приложения|прошу|дополнительно)(?:\b|\s*[:.])", re.IGNORECASE)
520
+ segments: list[list[int]] = [[]]
521
+ for p in doc.paragraphs:
522
+ t = p.text.strip()
523
+ if boundary.match(t):
524
+ if segments[-1]:
525
+ segments.append([])
526
+ continue
527
+ m = pattern.match(t)
528
+ if m:
529
+ segments[-1].append(int(m.group(1)))
530
+ for numbers in segments:
531
+ if not numbers:
532
+ continue
533
+ groups: list[list[int]] = []
534
+ current: list[int] = []
535
+ for n in numbers:
536
+ # A new list starts at 1 ONLY when the previous group is complete
537
+ # ([1..k]); otherwise a repeated/out-of-order number stays in the
538
+ # current group so the sequence check flags it (fail-closed).
539
+ # v0.5.3 фикс 3: полная группа может иметь ЛЮБУЮ длину, включая
540
+ # [1] — независимые одноэлементные перечни законны (склейка
541
+ # [1|1] в v0.5.2 браковала реальные документы).
542
+ if n == 1 and current:
543
+ groups.append(current)
544
+ current = [n]
545
+ else:
546
+ current.append(n)
547
+ if current:
548
+ groups.append(current)
549
+
550
+ for grp in groups:
551
+ expected = list(range(1, len(grp) + 1))
552
+ if grp != expected:
553
+ errors.append(f"numbered_list_sequence: {grp} out of order")
554
+ return errors
555
+
556
+
557
+ def _guess_profile(doc) -> str:
558
+ """Standalone validate has no spec: guess profile from dominant font size.
559
+ 14pt body => court profile; otherwise gost (12pt)."""
560
+ from collections import Counter
561
+ sizes = Counter()
562
+ for p in doc.paragraphs:
563
+ for run in p.runs:
564
+ if run.text.strip() and run.font.size is not None:
565
+ sizes[round(run.font.size.pt)] += 1
566
+ return "court" if sizes and sizes.most_common(1)[0][0] == 14 else "gost"
567
+
568
+
569
+ # v0.5.3 (фикс 5; согласовано с внешним рецензентом): полосы геометрии для входящих
570
+ # процессуальных актов судов и оппонентов. В --mode review они дают ТОЛЬКО
571
+ # warnings; errors в review — строго контентные дефекты (плейсхолдеры,
572
+ # en dash, norm_refs, склейка нумерации и т.п.).
573
+ REVIEW_MARGIN_BAND_CM = (1.0, 3.0)
574
+ REVIEW_FONT_BAND_PT = (10.0, 14.0)
575
+ REVIEW_LINE_SPACING_BAND = (1.0, 1.5)
576
+ REVIEW_FIRST_LINE_BAND_CM = (0.5, 1.5)
577
+
578
+
579
+ def _in_band(v: float, lo: float, hi: float) -> bool:
580
+ return lo <= v <= hi
581
+
582
+
583
+ def _check_geometry_review(doc: Document, spec: DocSpec | None,
584
+ profile: Profile) -> list[str]:
585
+ """Review-режим: геометрия как warnings (полосы реальной судебной практики)."""
586
+ warnings: list[str] = []
587
+ lo, hi = REVIEW_MARGIN_BAND_CM
588
+ section = doc.sections[0]
589
+ for name, actual in (("left", _cm(section.left_margin)),
590
+ ("right", _cm(section.right_margin)),
591
+ ("top", _cm(section.top_margin)),
592
+ ("bottom", _cm(section.bottom_margin))):
593
+ if not _in_band(actual, lo, hi):
594
+ warnings.append(
595
+ f"review_margin_{name}: {actual:.3f}cm вне полосы {lo}-{hi}cm")
596
+ f_lo, f_hi = REVIEW_FONT_BAND_PT
597
+ for is_cell, p_idx, p in _iter_all_paragraphs(doc):
598
+ for run in p.runs:
599
+ if not run.text:
600
+ continue
601
+ if run.font.name and run.font.name != profile.font_name:
602
+ warnings.append(
603
+ f"review_font_name: paragraph {p_idx} run uses '{run.font.name}'")
604
+ size = _pt(run.font.size)
605
+ if size is not None and not _in_band(size, f_lo, f_hi):
606
+ warnings.append(
607
+ f"review_font_size: paragraph {p_idx} run size {size:.1f}pt "
608
+ f"вне полосы {f_lo:.0f}-{f_hi:.0f}pt")
609
+ s_lo, s_hi = REVIEW_LINE_SPACING_BAND
610
+ i_lo, i_hi = REVIEW_FIRST_LINE_BAND_CM
611
+ roles = _classify_paragraphs(doc, spec)
612
+ for p_idx, p in enumerate(doc.paragraphs):
613
+ if roles.get(p_idx) != "body":
614
+ continue
615
+ pf = p.paragraph_format
616
+ ls = pf.line_spacing
617
+ if isinstance(ls, (int, float)) and not _in_band(float(ls), s_lo, s_hi):
618
+ warnings.append(
619
+ f"review_line_spacing: paragraph {p_idx} = {float(ls):.2f} "
620
+ f"вне полосы {s_lo}-{s_hi}")
621
+ ind = _cm(pf.first_line_indent)
622
+ if _in_band(ind, i_lo, i_hi):
623
+ continue
624
+ if ind == 0.0:
625
+ # Отсутствие красной строки легитимно при явном межпараграфном
626
+ # интервале (полоса, согласованная с внешним рецензентом).
627
+ after = pf.space_after.pt if pf.space_after is not None else 0.0
628
+ before = pf.space_before.pt if pf.space_before is not None else 0.0
629
+ if max(after, before) <= 0:
630
+ warnings.append(
631
+ f"review_first_line_indent: paragraph {p_idx} — отступ "
632
+ f"отсутствует и межпараграфный интервал не задан")
633
+ else:
634
+ warnings.append(
635
+ f"review_first_line_indent: paragraph {p_idx} = {ind:.3f}cm "
636
+ f"вне полосы {i_lo}-{i_hi}cm")
637
+ return warnings
638
+
639
+
640
+ def validate_document(path: str, spec: DocSpec | None = None,
641
+ mode: str = "gost") -> dict[str, Any]:
642
+ """Reopen generated .docx and run all built-in checks.
643
+
644
+ mode='gost' (default) — строгая геометрия по профилю (собственные
645
+ процессуальные документы), прежнее поведение без изменений.
646
+ mode='review' — входящие акты судов/оппонентов: геометрия даёт только
647
+ warnings (полосы реальной практики), errors остаются строго контентными
648
+ (фикс 5, согласовано с внешним рецензентом).
649
+ """
650
+ if mode not in ("gost", "review"):
651
+ raise ValueError(f"unknown validate mode: {mode!r} (expected: gost|review)")
652
+ # Standalone mode: guess the profile from the document itself so court
653
+ # documents are checked against court rules, not gost.
654
+ effective_profile_name = spec.profile if spec else None
655
+ if effective_profile_name is None:
656
+ try:
657
+ from docx import Document as _Document
658
+ effective_profile_name = _guess_profile(_Document(path))
659
+ except Exception:
660
+ effective_profile_name = "gost"
661
+ profile = Profile(
662
+ effective_profile_name,
663
+ line_spacing=spec.line_spacing if spec else None,
664
+ )
665
+ errors: list[str] = []
666
+ warnings: list[str] = []
667
+ checks: dict[str, str] = {}
668
+
669
+ try:
670
+ doc = Document(path)
671
+ except Exception as exc:
672
+ return {
673
+ "checks": {"open": "fail"},
674
+ "warnings": [],
675
+ "errors": [f"open_failed: {exc}"],
676
+ "skipped": [],
677
+ "skips": [],
678
+ "findings": [],
679
+ }
680
+
681
+ skipped: list = []
682
+ try:
683
+ _classify_paragraphs(doc, spec, profile, skipped=skipped)
684
+ except Exception:
685
+ pass
686
+ # de-duplicate identical (zone, reason) records collected across passes
687
+ _seen = set()
688
+ _uniq = []
689
+ for _f in skipped:
690
+ _key = (_f.get("zone"), _f.get("reason"))
691
+ if _key in _seen:
692
+ continue
693
+ _seen.add(_key)
694
+ _uniq.append(_f)
695
+ skipped = _uniq
696
+
697
+ pi_errors = _check_package_integrity(path)
698
+ errors.extend(pi_errors)
699
+ checks["package_integrity"] = "fail" if pi_errors else "pass"
700
+
701
+ if mode == "review":
702
+ # v0.5.3 фикс 5: геометрия — только warnings; строгие профильные
703
+ # проверки (margins/fonts/body/list/header/pleading geometry) не
704
+ # блокируют review входящего документа.
705
+ geom_warnings = _check_geometry_review(doc, spec, profile)
706
+ warnings.extend(geom_warnings)
707
+ checks["margins"] = "warn" if any(
708
+ w.startswith("review_margin") for w in geom_warnings) else "pass"
709
+ checks["fonts"] = "warn" if any(
710
+ w.startswith("review_font") for w in geom_warnings) else "pass"
711
+ checks["body_formatting"] = "warn" if any(
712
+ w.startswith(("review_line_spacing", "review_first_line"))
713
+ for w in geom_warnings) else "pass"
714
+ checks["list_formatting"] = "n/a"
715
+ checks["header_formatting"] = "n/a"
716
+ checks["pleading_marker"] = "n/a"
717
+ else:
718
+ margin_errors = _check_margins(doc, profile)
719
+ errors.extend(margin_errors)
720
+ checks["margins"] = "fail" if margin_errors else "pass"
721
+
722
+ guessed = effective_profile_name
723
+ font_errors = _check_fonts_and_sizes(doc, spec, guessed_profile=guessed)
724
+ errors.extend(font_errors)
725
+ checks["fonts"] = "fail" if font_errors else "pass"
726
+
727
+ fmt_errors = _check_body_formatting(doc, spec, guessed_profile=guessed)
728
+ errors.extend(fmt_errors)
729
+ checks["body_formatting"] = "fail" if fmt_errors else "pass"
730
+
731
+ list_errors = _check_list_formatting(doc, spec, profile)
732
+ errors.extend(list_errors)
733
+ checks["list_formatting"] = "fail" if list_errors else "pass"
734
+
735
+ header_errors = _check_header_formatting(doc, spec, profile)
736
+ errors.extend(header_errors)
737
+ checks["header_formatting"] = "fail" if header_errors else "pass"
738
+
739
+ pleading_errors, pleading_warnings = _check_pleading_marker(doc, spec, profile)
740
+ errors.extend(pleading_errors)
741
+ warnings.extend(pleading_warnings)
742
+ checks["pleading_marker"] = "fail" if pleading_errors else ("warn" if pleading_warnings else "pass")
743
+
744
+ en_errors, en_warnings = _check_en_dash(doc, spec)
745
+ errors.extend(en_errors)
746
+ warnings.extend(en_warnings)
747
+ checks["no_en_dash"] = "fail" if en_errors else ("warn" if en_warnings else "pass")
748
+
749
+ yo_warnings = _check_yo(doc, spec)
750
+ warnings.extend(yo_warnings)
751
+ checks["no_yo"] = "warn" if yo_warnings else "pass"
752
+
753
+ ph_findings = _check_placeholders(doc, spec)
754
+ if mode == "review":
755
+ # Фикс 5: остаточные плейсхолдеры — контентный error и в review.
756
+ errors.extend(ph_findings)
757
+ checks["placeholders"] = "fail" if ph_findings else "pass"
758
+ else:
759
+ warnings.extend(ph_findings)
760
+ checks["placeholders"] = "warn" if ph_findings else "pass"
761
+
762
+ norm_findings = _check_norm_refs(doc, spec)
763
+ if mode == "review":
764
+ # Фикс 5: отсутствие ссылок на нормы (когда проверка активна) —
765
+ # контентный error и в review.
766
+ errors.extend(norm_findings)
767
+ checks["norm_refs"] = "fail" if norm_findings else "pass"
768
+ else:
769
+ warnings.extend(norm_findings)
770
+ checks["norm_refs"] = "warn" if norm_findings else "pass"
771
+
772
+ seq_errors = _check_numbered_list_sequence(doc, spec)
773
+ errors.extend(seq_errors)
774
+ checks["numbered_list_sequence"] = "fail" if seq_errors else "pass"
775
+
776
+ return {
777
+ "validated_at": now_iso(),
778
+ "mode": mode,
779
+ "profile": effective_profile_name,
780
+ "checks": checks,
781
+ "warnings": warnings,
782
+ "errors": errors,
783
+ "skipped": skipped,
784
+ "skips": skipped,
785
+ "findings": skipped,
786
+ }