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/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()