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/profiles.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""docx-cli profile-*. — профили оформления: снять с образца / проверить / починить.
|
|
2
|
+
|
|
3
|
+
v0.5.0 (clean-room: собственный профиль-цикл — снять профиль с образца,
|
|
4
|
+
проверить, починить). Контракт: наш JSON-конверт, честные exit-коды.
|
|
5
|
+
|
|
6
|
+
Профиль — JSON с параметрами страницы и тела:
|
|
7
|
+
{"page": {"w_cm":21,"h_cm":29.7}, "margins": {"left":3,"right":1.5,"top":2,"bottom":2},
|
|
8
|
+
"font": {"name":"Times New Roman","size":12},
|
|
9
|
+
"body": {"first_line_indent_cm":1.25,"line_spacing":1.5}}
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from docx import Document
|
|
17
|
+
import zipfile
|
|
18
|
+
from xml.etree import ElementTree as ET
|
|
19
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
20
|
+
from docx.shared import Cm, Pt
|
|
21
|
+
|
|
22
|
+
# допуск сравнения в см (вещественные EMU шумят на 4-м знаке)
|
|
23
|
+
TOL_CM = 0.05
|
|
24
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _all_paragraphs(doc):
|
|
28
|
+
out = []
|
|
29
|
+
def add_table(t):
|
|
30
|
+
for row in t.rows:
|
|
31
|
+
for cell in row.cells:
|
|
32
|
+
out.extend(cell.paragraphs)
|
|
33
|
+
for nt in cell.tables:
|
|
34
|
+
add_table(nt)
|
|
35
|
+
out.extend(doc.paragraphs)
|
|
36
|
+
for t in doc.tables:
|
|
37
|
+
add_table(t)
|
|
38
|
+
for section in doc.sections:
|
|
39
|
+
for part in (section.header, section.footer,
|
|
40
|
+
getattr(section, "first_page_header", None),
|
|
41
|
+
getattr(section, "first_page_footer", None),
|
|
42
|
+
getattr(section, "even_page_header", None),
|
|
43
|
+
getattr(section, "even_page_footer", None)):
|
|
44
|
+
if part is None:
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
out.extend(part.paragraphs)
|
|
48
|
+
for t in part.tables:
|
|
49
|
+
add_table(t)
|
|
50
|
+
except Exception: # noqa: BLE001
|
|
51
|
+
pass
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _labeled_paragraphs(doc):
|
|
56
|
+
out = []
|
|
57
|
+
for i, p in enumerate(doc.paragraphs):
|
|
58
|
+
out.append((f"paragraph {i}", p))
|
|
59
|
+
for ti, t in enumerate(doc.tables):
|
|
60
|
+
for ri, row in enumerate(t.rows):
|
|
61
|
+
for ci, cell in enumerate(row.cells):
|
|
62
|
+
for pi, p in enumerate(cell.paragraphs):
|
|
63
|
+
out.append((f"table {ti} cell {ci} paragraph {pi}", p))
|
|
64
|
+
for si, section in enumerate(doc.sections):
|
|
65
|
+
for name, part in (("header", section.header), ("footer", section.footer)):
|
|
66
|
+
for pi, p in enumerate(part.paragraphs):
|
|
67
|
+
out.append((f"{name} paragraph {pi}", p))
|
|
68
|
+
for ti, t in enumerate(part.tables):
|
|
69
|
+
for ri, row in enumerate(t.rows):
|
|
70
|
+
for ci, cell in enumerate(row.cells):
|
|
71
|
+
for pi, p in enumerate(cell.paragraphs):
|
|
72
|
+
out.append((f"{name} table {ti} cell {ci} paragraph {pi}", p))
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ProfileError(Exception):
|
|
77
|
+
"""Fail-closed ошибка профиля."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_profile(path: str | Path) -> dict:
|
|
81
|
+
p = Path(path)
|
|
82
|
+
if not p.is_file():
|
|
83
|
+
raise ProfileError(f"profile not found: {p}")
|
|
84
|
+
data = json.loads(p.read_text(encoding="utf-8-sig"))
|
|
85
|
+
for key in ("margins", "font"):
|
|
86
|
+
if key not in data:
|
|
87
|
+
raise ProfileError(f"profile missing key: {key}")
|
|
88
|
+
return data
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def extract(sample_path: str | Path) -> dict:
|
|
92
|
+
"""Снять профиль оформления с образца .docx."""
|
|
93
|
+
d = Document(str(sample_path))
|
|
94
|
+
s = d.sections[0]
|
|
95
|
+
normal = d.styles["Normal"]
|
|
96
|
+
body_indent = None
|
|
97
|
+
body_spacing = None
|
|
98
|
+
for p in d.paragraphs:
|
|
99
|
+
pf = p.paragraph_format
|
|
100
|
+
if p.text.strip() and pf.first_line_indent and pf.first_line_indent > 0:
|
|
101
|
+
body_indent = pf.first_line_indent.cm
|
|
102
|
+
body_spacing = pf.line_spacing
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
# шрифт — только факт из rPr/styles (без фолбэка TNR)
|
|
106
|
+
name = normal.font.name
|
|
107
|
+
size = normal.font.size.pt if normal.font.size else None
|
|
108
|
+
if name is None or size is None:
|
|
109
|
+
for p in _all_paragraphs(d):
|
|
110
|
+
for run in p.runs:
|
|
111
|
+
if name is None and run.font.name:
|
|
112
|
+
name = run.font.name
|
|
113
|
+
if size is None and run.font.size:
|
|
114
|
+
size = run.font.size.pt
|
|
115
|
+
if name is None or size is None:
|
|
116
|
+
try:
|
|
117
|
+
with zipfile.ZipFile(str(sample_path)) as zf:
|
|
118
|
+
for n in sorted(zf.namelist()):
|
|
119
|
+
if not (n.startswith("word/") and n.endswith(".xml")):
|
|
120
|
+
continue
|
|
121
|
+
base = n.split("/")[-1]
|
|
122
|
+
if base in ("styles.xml", "numbering.xml", "fontTable.xml", "settings.xml", "webSettings.xml"):
|
|
123
|
+
continue
|
|
124
|
+
if not (base == "document.xml" or base.startswith("header") or base.startswith("footer")
|
|
125
|
+
or base.startswith("footnote") or base.startswith("endnote")):
|
|
126
|
+
continue
|
|
127
|
+
root = ET.fromstring(zf.read(n))
|
|
128
|
+
for rpr in root.iter(f"{{{W}}}rPr"):
|
|
129
|
+
if name is None:
|
|
130
|
+
rf = rpr.find(f"{{{W}}}rFonts")
|
|
131
|
+
if rf is not None:
|
|
132
|
+
for attr in ("ascii", "hAnsi"):
|
|
133
|
+
v = rf.get(f"{{{W}}}{attr}")
|
|
134
|
+
if v:
|
|
135
|
+
name = v
|
|
136
|
+
break
|
|
137
|
+
if size is None:
|
|
138
|
+
sz = rpr.find(f"{{{W}}}sz")
|
|
139
|
+
if sz is not None:
|
|
140
|
+
val = sz.get(f"{{{W}}}val")
|
|
141
|
+
if val and val.isdigit():
|
|
142
|
+
size = int(val) / 2
|
|
143
|
+
if name is not None and size is not None:
|
|
144
|
+
break
|
|
145
|
+
if name is not None and size is not None:
|
|
146
|
+
break
|
|
147
|
+
except Exception: # noqa: BLE001
|
|
148
|
+
pass
|
|
149
|
+
guessed = name is None
|
|
150
|
+
if size is None:
|
|
151
|
+
size = 12
|
|
152
|
+
prof = {
|
|
153
|
+
"page": {"w_cm": round(s.page_width.cm, 3), "h_cm": round(s.page_height.cm, 3)},
|
|
154
|
+
"margins": {"left": round(s.left_margin.cm, 3), "right": round(s.right_margin.cm, 3),
|
|
155
|
+
"top": round(s.top_margin.cm, 3), "bottom": round(s.bottom_margin.cm, 3)},
|
|
156
|
+
"font": {"name": name, "size": size, "guessed": guessed},
|
|
157
|
+
"body": {"first_line_indent_cm": round(body_indent, 3) if body_indent else None,
|
|
158
|
+
"line_spacing": body_spacing if isinstance(body_spacing, float) else None},
|
|
159
|
+
"guessed_font": guessed,
|
|
160
|
+
"font_guessed": guessed,
|
|
161
|
+
}
|
|
162
|
+
return prof
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _cm(v) -> float | None:
|
|
166
|
+
return round(v.cm, 3) if v is not None else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def validate(docx_path: str | Path, profile: dict) -> list[dict]:
|
|
170
|
+
"""Список проблем (не чинит). Проблема: {where, param, got, want}."""
|
|
171
|
+
d = Document(str(docx_path))
|
|
172
|
+
problems: list[dict] = []
|
|
173
|
+
s = d.sections[0]
|
|
174
|
+
m, pg = profile["margins"], profile.get("page", {})
|
|
175
|
+
checks = [
|
|
176
|
+
("section", "margin_left", _cm(s.left_margin), m["left"]),
|
|
177
|
+
("section", "margin_right", _cm(s.right_margin), m["right"]),
|
|
178
|
+
("section", "margin_top", _cm(s.top_margin), m["top"]),
|
|
179
|
+
("section", "margin_bottom", _cm(s.bottom_margin), m["bottom"]),
|
|
180
|
+
]
|
|
181
|
+
if pg.get("w_cm"):
|
|
182
|
+
checks.append(("section", "page_width", _cm(s.page_width), pg["w_cm"]))
|
|
183
|
+
if pg.get("h_cm"):
|
|
184
|
+
checks.append(("section", "page_height", _cm(s.page_height), pg["h_cm"]))
|
|
185
|
+
f = d.styles["Normal"].font
|
|
186
|
+
checks.append(("styles/Normal", "font_name", f.name, profile["font"]["name"]))
|
|
187
|
+
if f.size is not None:
|
|
188
|
+
checks.append(("styles/Normal", "font_size", f.size.pt, profile["font"]["size"]))
|
|
189
|
+
for where, param, got, want in checks:
|
|
190
|
+
if got is None or want is None:
|
|
191
|
+
continue
|
|
192
|
+
if isinstance(got, str):
|
|
193
|
+
if got.lower() != str(want).lower():
|
|
194
|
+
problems.append({"where": where, "param": param, "got": got, "want": want})
|
|
195
|
+
elif abs(got - float(want)) > TOL_CM:
|
|
196
|
+
problems.append({"where": where, "param": param, "got": got, "want": want})
|
|
197
|
+
# run-level шрифты: если задан явный шрифт, не совпадающий с профилем — проблема
|
|
198
|
+
prof_font_name = profile["font"].get("name")
|
|
199
|
+
if prof_font_name:
|
|
200
|
+
want_name = str(prof_font_name).lower()
|
|
201
|
+
for where, p in _labeled_paragraphs(d):
|
|
202
|
+
for run in p.runs:
|
|
203
|
+
if run.font.name and run.font.name.lower() != want_name:
|
|
204
|
+
problems.append({"where": where, "param": "run_font_name",
|
|
205
|
+
"got": run.font.name, "want": prof_font_name})
|
|
206
|
+
body = profile.get("body", {})
|
|
207
|
+
want_ind = body.get("first_line_indent_cm")
|
|
208
|
+
want_sp = body.get("line_spacing")
|
|
209
|
+
for i, p in enumerate(d.paragraphs):
|
|
210
|
+
pf = p.paragraph_format
|
|
211
|
+
if not p.text.strip():
|
|
212
|
+
continue
|
|
213
|
+
# body-абзацы: не трогаем проверкой заголовки (bold/центр) и шапку (явный левый отступ)
|
|
214
|
+
runs_bold = any(r.bold for r in p.runs if r.bold)
|
|
215
|
+
is_center = p.alignment == WD_ALIGN_PARAGRAPH.CENTER
|
|
216
|
+
if runs_bold or is_center or (pf.left_indent and pf.left_indent > 0):
|
|
217
|
+
continue
|
|
218
|
+
if want_ind and pf.first_line_indent and abs(pf.first_line_indent.cm - want_ind) > TOL_CM:
|
|
219
|
+
problems.append({"where": f"paragraph {i}", "param": "first_line_indent_cm",
|
|
220
|
+
"got": round(pf.first_line_indent.cm, 3), "want": want_ind})
|
|
221
|
+
if want_sp and pf.line_spacing and isinstance(pf.line_spacing, float) \
|
|
222
|
+
and abs(pf.line_spacing - want_sp) > 0.01:
|
|
223
|
+
problems.append({"where": f"paragraph {i}", "param": "line_spacing",
|
|
224
|
+
"got": pf.line_spacing, "want": want_sp})
|
|
225
|
+
return problems
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def repair(docx_path: str | Path, profile: dict, out_path: str | Path) -> dict:
|
|
229
|
+
"""Починить документ в профиль -> новый файл. before/after счётчики."""
|
|
230
|
+
d = Document(str(docx_path))
|
|
231
|
+
before = len(validate(docx_path, profile))
|
|
232
|
+
s = d.sections[0]
|
|
233
|
+
m = profile["margins"]
|
|
234
|
+
s.left_margin = Cm(m["left"])
|
|
235
|
+
s.right_margin = Cm(m["right"])
|
|
236
|
+
s.top_margin = Cm(m["top"])
|
|
237
|
+
s.bottom_margin = Cm(m["bottom"])
|
|
238
|
+
pg = profile.get("page", {})
|
|
239
|
+
if pg.get("w_cm"):
|
|
240
|
+
s.page_width = Cm(pg["w_cm"])
|
|
241
|
+
if pg.get("h_cm"):
|
|
242
|
+
s.page_height = Cm(pg["h_cm"])
|
|
243
|
+
fn = d.styles["Normal"].font
|
|
244
|
+
prof_name = profile["font"].get("name")
|
|
245
|
+
prof_size = profile["font"].get("size")
|
|
246
|
+
if prof_name:
|
|
247
|
+
fn.name = prof_name
|
|
248
|
+
if prof_size:
|
|
249
|
+
fn.size = Pt(prof_size)
|
|
250
|
+
if prof_name:
|
|
251
|
+
for where, p in _labeled_paragraphs(d):
|
|
252
|
+
for run in p.runs:
|
|
253
|
+
run.font.name = prof_name
|
|
254
|
+
body = profile.get("body", {})
|
|
255
|
+
want_ind = body.get("first_line_indent_cm")
|
|
256
|
+
want_sp = body.get("line_spacing")
|
|
257
|
+
for p in d.paragraphs:
|
|
258
|
+
pf = p.paragraph_format
|
|
259
|
+
if not p.text.strip():
|
|
260
|
+
continue
|
|
261
|
+
runs_bold = any(r.bold for r in p.runs if r.bold)
|
|
262
|
+
if runs_bold or p.alignment == WD_ALIGN_PARAGRAPH.CENTER or \
|
|
263
|
+
(pf.left_indent and pf.left_indent > 0):
|
|
264
|
+
continue
|
|
265
|
+
if want_ind:
|
|
266
|
+
pf.first_line_indent = Cm(want_ind)
|
|
267
|
+
if want_sp:
|
|
268
|
+
pf.line_spacing = want_sp
|
|
269
|
+
d.save(str(out_path))
|
|
270
|
+
after = len(validate(out_path, profile))
|
|
271
|
+
return {"before": before, "after": after,
|
|
272
|
+
"fixed": max(before - after, 0), "output": str(out_path)}
|
docx_cli/spec.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""DocSpec pydantic model and profile defaults."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
8
|
+
|
|
9
|
+
from docx_cli.errors import ProfileError, SpecError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ProfileName = Literal["gost", "compact", "court", "vs_sud", "soyu_rayon"]
|
|
13
|
+
Align = Literal["left", "right", "center", "justify"]
|
|
14
|
+
BlockType = Literal[
|
|
15
|
+
"paragraph",
|
|
16
|
+
"numbered_list",
|
|
17
|
+
"table",
|
|
18
|
+
"signature_block",
|
|
19
|
+
"attachments_list",
|
|
20
|
+
"pleading_marker",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OutputSpec(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
|
|
27
|
+
file_name: str
|
|
28
|
+
author: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HeaderSpec(BaseModel):
|
|
32
|
+
model_config = ConfigDict(extra="forbid")
|
|
33
|
+
|
|
34
|
+
align: Literal["left", "right", "center"] = "right"
|
|
35
|
+
lines: list[str] = Field(..., min_length=1)
|
|
36
|
+
left_indent_cm: float = 7.0
|
|
37
|
+
|
|
38
|
+
@field_validator("left_indent_cm")
|
|
39
|
+
@classmethod
|
|
40
|
+
def left_indent_in_range(cls, v: float) -> float:
|
|
41
|
+
if v < 6.0 or v > 8.0:
|
|
42
|
+
raise ValueError("header.left_indent_cm must be between 6.0 and 8.0")
|
|
43
|
+
return v
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TitleSpec(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
|
|
49
|
+
main: str
|
|
50
|
+
subtitle: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ParagraphBlock(BaseModel):
|
|
54
|
+
model_config = ConfigDict(extra="forbid")
|
|
55
|
+
|
|
56
|
+
type: Literal["paragraph"]
|
|
57
|
+
text: str
|
|
58
|
+
inline_markdown: bool = True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class NumberedListBlock(BaseModel):
|
|
62
|
+
model_config = ConfigDict(extra="forbid")
|
|
63
|
+
|
|
64
|
+
type: Literal["numbered_list"]
|
|
65
|
+
items: list[str] = Field(..., min_length=1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TableBlock(BaseModel):
|
|
69
|
+
model_config = ConfigDict(extra="forbid")
|
|
70
|
+
|
|
71
|
+
type: Literal["table"]
|
|
72
|
+
headers: list[str]
|
|
73
|
+
rows: list[list[str]] = Field(..., min_length=1)
|
|
74
|
+
widths_cm: list[float] | None = None
|
|
75
|
+
|
|
76
|
+
@field_validator("rows")
|
|
77
|
+
@classmethod
|
|
78
|
+
def rows_valid(cls, rows: list[list[str]]) -> list[list[str]]:
|
|
79
|
+
if len({len(r) for r in rows}) != 1:
|
|
80
|
+
raise ValueError("all table rows must have the same number of cells")
|
|
81
|
+
width = len(rows[0])
|
|
82
|
+
if width == 0:
|
|
83
|
+
raise ValueError("table rows must contain at least one cell")
|
|
84
|
+
for r_idx, row in enumerate(rows):
|
|
85
|
+
for c_idx, cell in enumerate(row):
|
|
86
|
+
if cell == "":
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"empty cell at row {r_idx}, column {c_idx}"
|
|
89
|
+
)
|
|
90
|
+
return rows
|
|
91
|
+
|
|
92
|
+
@field_validator("widths_cm")
|
|
93
|
+
@classmethod
|
|
94
|
+
def widths_match_headers(
|
|
95
|
+
cls, widths: list[float] | None, info: Any
|
|
96
|
+
) -> list[float] | None:
|
|
97
|
+
if widths is None:
|
|
98
|
+
return None
|
|
99
|
+
headers = info.data.get("headers")
|
|
100
|
+
header_count = len(headers) if headers else 0
|
|
101
|
+
if len(widths) != header_count:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"widths_cm length ({len(widths)}) must match headers count ({header_count})"
|
|
104
|
+
)
|
|
105
|
+
return widths
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SignatureBlock(BaseModel):
|
|
109
|
+
model_config = ConfigDict(extra="forbid")
|
|
110
|
+
|
|
111
|
+
type: Literal["signature_block"]
|
|
112
|
+
left: str
|
|
113
|
+
right: str
|
|
114
|
+
format: Literal["table", "text"] | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PleadingBlock(BaseModel):
|
|
118
|
+
model_config = ConfigDict(extra="forbid")
|
|
119
|
+
|
|
120
|
+
type: Literal["pleading_marker"]
|
|
121
|
+
text: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class AttachmentsListBlock(BaseModel):
|
|
125
|
+
model_config = ConfigDict(extra="forbid")
|
|
126
|
+
|
|
127
|
+
type: Literal["attachments_list"]
|
|
128
|
+
items: list[str] = Field(..., min_length=1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
BodyBlock = (
|
|
132
|
+
ParagraphBlock
|
|
133
|
+
| NumberedListBlock
|
|
134
|
+
| TableBlock
|
|
135
|
+
| SignatureBlock
|
|
136
|
+
| AttachmentsListBlock
|
|
137
|
+
| PleadingBlock
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ChecksSpec(BaseModel):
|
|
142
|
+
model_config = ConfigDict(extra="forbid")
|
|
143
|
+
|
|
144
|
+
no_yo: bool = True
|
|
145
|
+
no_en_dash: bool = True
|
|
146
|
+
require_norms_refs: bool = False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class DocSpec(BaseModel):
|
|
150
|
+
model_config = ConfigDict(extra="forbid")
|
|
151
|
+
|
|
152
|
+
profile: ProfileName = "gost"
|
|
153
|
+
line_spacing: float | None = None
|
|
154
|
+
output: OutputSpec
|
|
155
|
+
header: HeaderSpec
|
|
156
|
+
title: TitleSpec
|
|
157
|
+
body: list[BodyBlock]
|
|
158
|
+
checks: ChecksSpec = Field(default_factory=ChecksSpec)
|
|
159
|
+
|
|
160
|
+
@field_validator("line_spacing")
|
|
161
|
+
@classmethod
|
|
162
|
+
def line_spacing_allowed(cls, v: float | None) -> float | None:
|
|
163
|
+
if v is not None and v not in (1.0, 1.15):
|
|
164
|
+
raise ValueError("line_spacing must be 1.0 or 1.15")
|
|
165
|
+
return v
|
|
166
|
+
|
|
167
|
+
@field_validator("body")
|
|
168
|
+
@classmethod
|
|
169
|
+
def body_not_empty(cls, body: list[BodyBlock]) -> list[BodyBlock]:
|
|
170
|
+
if not body:
|
|
171
|
+
raise ValueError("body must contain at least one block")
|
|
172
|
+
return body
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_dict(cls, raw: dict[str, Any]) -> "DocSpec":
|
|
176
|
+
try:
|
|
177
|
+
return cls.model_validate(raw)
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
raise SpecError(f"spec_error: {exc}") from exc
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Profile:
|
|
183
|
+
"""Default GOST legal profile."""
|
|
184
|
+
|
|
185
|
+
name: ProfileName = "gost"
|
|
186
|
+
paper_width_cm: float = 21.0
|
|
187
|
+
paper_height_cm: float = 29.7
|
|
188
|
+
margin_left_cm: float = 3.0
|
|
189
|
+
margin_right_cm: float = 1.5
|
|
190
|
+
margin_top_cm: float = 2.0
|
|
191
|
+
margin_bottom_cm: float = 2.0
|
|
192
|
+
font_name: str = "Times New Roman"
|
|
193
|
+
body_font_size_pt: int = 12
|
|
194
|
+
title_font_size_pt: int = 14
|
|
195
|
+
body_line_spacing: float = 1.5
|
|
196
|
+
header_line_spacing: float = 1.15
|
|
197
|
+
first_line_indent_cm: float = 1.25
|
|
198
|
+
body_alignment: int = 3 # WD_ALIGN_PARAGRAPH.JUSTIFY
|
|
199
|
+
cell_font_size_pt: int = 11
|
|
200
|
+
default_signature_format: str = "table"
|
|
201
|
+
|
|
202
|
+
def __init__(self, name: ProfileName = "gost", line_spacing: float | None = None) -> None:
|
|
203
|
+
if name == "compact":
|
|
204
|
+
self.name = "compact"
|
|
205
|
+
self.margin_left_cm = 2.0
|
|
206
|
+
self.margin_right_cm = 1.0
|
|
207
|
+
self.margin_top_cm = 1.5
|
|
208
|
+
self.margin_bottom_cm = 1.5
|
|
209
|
+
self.body_line_spacing = 1.15
|
|
210
|
+
elif name == "court":
|
|
211
|
+
self.name = "court"
|
|
212
|
+
self.body_font_size_pt = 14
|
|
213
|
+
self.title_font_size_pt = 14
|
|
214
|
+
self.cell_font_size_pt = 14
|
|
215
|
+
self.body_line_spacing = 1.0
|
|
216
|
+
self.header_line_spacing = 1.0
|
|
217
|
+
self.default_signature_format = "text"
|
|
218
|
+
elif name in ("vs_sud", "soyu_rayon"):
|
|
219
|
+
# v0.1.0 T-05: форматтерские профили ВС / СОЮ первой инстанции.
|
|
220
|
+
# Геометрия идентична gost (профиль = имя + пути); никакого
|
|
221
|
+
# юридического текста в профиле — нормативную часть вносит
|
|
222
|
+
# legal-профиль через spec (плейсхолдеры в шаблонах).
|
|
223
|
+
self.name = name
|
|
224
|
+
elif name != "gost":
|
|
225
|
+
raise ProfileError(f"unknown profile: {name}")
|
|
226
|
+
|
|
227
|
+
if line_spacing is not None:
|
|
228
|
+
self.body_line_spacing = line_spacing
|
|
229
|
+
self.header_line_spacing = line_spacing
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def now_iso() -> str:
|
|
233
|
+
return datetime.now(timezone.utc).isoformat()
|