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/blocks.py ADDED
@@ -0,0 +1,333 @@
1
+ """Block-level rendering helpers for the DOCX builder."""
2
+ from __future__ import annotations
3
+
4
+ from docx import Document
5
+ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING, WD_TAB_ALIGNMENT
6
+ from docx.oxml import parse_xml
7
+ from docx.oxml.ns import qn
8
+ from docx.shared import Cm, Pt
9
+ from docx.table import Table
10
+
11
+ from docx_cli.spec import (
12
+ AttachmentsListBlock,
13
+ HeaderSpec,
14
+ NumberedListBlock,
15
+ ParagraphBlock,
16
+ PleadingBlock,
17
+ Profile,
18
+ SignatureBlock,
19
+ TableBlock,
20
+ TitleSpec,
21
+ )
22
+
23
+
24
+ # --------------------------------------------------------------------------------------
25
+ # Low-level helpers
26
+ # --------------------------------------------------------------------------------------
27
+
28
+ def _set_run_font(run, profile: Profile, size_pt: int, bold: bool = False, italic: bool = False) -> None:
29
+ run.font.name = profile.font_name
30
+ run.font.size = Pt(size_pt)
31
+ run.bold = bold
32
+ run.italic = italic
33
+ run.font.rtl = False
34
+ run._element.rPr.rFonts.set(qn("w:eastAsia"), profile.font_name)
35
+
36
+
37
+ def _body_paragraph_format(p, profile: Profile) -> None:
38
+ pf = p.paragraph_format
39
+ pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
40
+ pf.line_spacing = profile.body_line_spacing
41
+ pf.first_line_indent = Cm(profile.first_line_indent_cm)
42
+ pf.space_after = Pt(0)
43
+ pf.space_before = Pt(0)
44
+ pf.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
45
+
46
+
47
+ def _header_paragraph_format(p, profile: Profile) -> None:
48
+ pf = p.paragraph_format
49
+ pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
50
+ pf.line_spacing = profile.header_line_spacing
51
+ pf.space_after = Pt(0)
52
+ pf.space_before = Pt(0)
53
+
54
+
55
+ def _title_paragraph_format(p, profile: Profile) -> None:
56
+ pf = p.paragraph_format
57
+ pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
58
+ pf.line_spacing = profile.header_line_spacing
59
+ if profile.name == "court":
60
+ pf.space_after = Pt(12)
61
+ pf.space_before = Pt(12)
62
+ else:
63
+ pf.space_after = Pt(6)
64
+ pf.space_before = Pt(0)
65
+ pf.alignment = WD_ALIGN_PARAGRAPH.CENTER
66
+
67
+
68
+ def _cell_paragraph_format(p, profile: Profile) -> None:
69
+ pf = p.paragraph_format
70
+ pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
71
+ pf.line_spacing = profile.header_line_spacing
72
+ pf.space_after = Pt(0)
73
+ pf.space_before = Pt(0)
74
+
75
+
76
+ # --------------------------------------------------------------------------------------
77
+ # Inline markdown
78
+ # --------------------------------------------------------------------------------------
79
+
80
+ def _split_inline(text: str) -> list[tuple[str, bool, bool]]:
81
+ """Parse **bold** and *italic* markers, returning (text, bold, italic) segments."""
82
+ parts: list[tuple[str, bool, bool]] = []
83
+ i = 0
84
+ bold = False
85
+ italic = False
86
+ buf: list[str] = []
87
+ n = len(text)
88
+ while i < n:
89
+ if text.startswith("**", i):
90
+ parts.append(("".join(buf), bold, italic))
91
+ buf = []
92
+ bold = not bold
93
+ i += 2
94
+ elif text.startswith("*", i):
95
+ parts.append(("".join(buf), bold, italic))
96
+ buf = []
97
+ italic = not italic
98
+ i += 1
99
+ elif text.startswith("\\", i) and i + 1 < n:
100
+ buf.append(text[i + 1])
101
+ i += 2
102
+ else:
103
+ buf.append(text[i])
104
+ i += 1
105
+ parts.append(("".join(buf), bold, italic))
106
+ # Drop empty leading segment
107
+ if parts and parts[0][0] == "":
108
+ parts.pop(0)
109
+ return parts
110
+
111
+
112
+ def _add_inline_text(p, text: str, profile: Profile, size_pt: int) -> None:
113
+ for segment, bold, italic in _split_inline(text):
114
+ run = p.add_run(segment)
115
+ _set_run_font(run, profile, size_pt, bold=bold, italic=italic)
116
+
117
+
118
+ # --------------------------------------------------------------------------------------
119
+ # Public block renderers
120
+ # --------------------------------------------------------------------------------------
121
+
122
+ def render_header(doc: Document, spec: HeaderSpec, profile: Profile) -> None:
123
+ if profile.name == "court":
124
+ last_p = None
125
+ for line in spec.lines:
126
+ if line == "":
127
+ if last_p is not None:
128
+ last_p.paragraph_format.space_after = Pt(3)
129
+ continue
130
+ p = doc.add_paragraph()
131
+ _header_paragraph_format(p, profile)
132
+ p.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
133
+ p.paragraph_format.left_indent = Cm(spec.left_indent_cm)
134
+ run = p.add_run(line)
135
+ _set_run_font(run, profile, profile.body_font_size_pt)
136
+ last_p = p
137
+ return
138
+
139
+ align_map = {
140
+ "left": WD_ALIGN_PARAGRAPH.LEFT,
141
+ "right": WD_ALIGN_PARAGRAPH.RIGHT,
142
+ "center": WD_ALIGN_PARAGRAPH.CENTER,
143
+ }
144
+ for line in spec.lines:
145
+ p = doc.add_paragraph()
146
+ _header_paragraph_format(p, profile)
147
+ p.paragraph_format.alignment = align_map[spec.align]
148
+ run = p.add_run(line)
149
+ _set_run_font(run, profile, profile.body_font_size_pt)
150
+
151
+
152
+ def render_title(doc: Document, spec: TitleSpec, profile: Profile) -> None:
153
+ p = doc.add_paragraph()
154
+ _title_paragraph_format(p, profile)
155
+ run = p.add_run(spec.main)
156
+ _set_run_font(run, profile, profile.title_font_size_pt, bold=True)
157
+ if spec.subtitle:
158
+ p2 = doc.add_paragraph()
159
+ _title_paragraph_format(p2, profile)
160
+ run2 = p2.add_run(spec.subtitle)
161
+ _set_run_font(run2, profile, profile.body_font_size_pt, bold=False)
162
+
163
+
164
+ def render_paragraph(doc: Document, block: ParagraphBlock, profile: Profile) -> None:
165
+ p = doc.add_paragraph()
166
+ _body_paragraph_format(p, profile)
167
+ text = block.text
168
+ if block.inline_markdown:
169
+ _add_inline_text(p, text, profile, profile.body_font_size_pt)
170
+ else:
171
+ run = p.add_run(text)
172
+ _set_run_font(run, profile, profile.body_font_size_pt)
173
+
174
+
175
+ def render_numbered_list(doc: Document, block: NumberedListBlock, profile: Profile) -> None:
176
+ for idx, item in enumerate(block.items, start=1):
177
+ p = doc.add_paragraph()
178
+ _body_paragraph_format(p, profile)
179
+ # Explicit "1. Text" format (GOST requirement)
180
+ text = f"{idx}. {item}"
181
+ # Hanging indent: number stays on the left margin, text wraps aligned
182
+ pf = p.paragraph_format
183
+ pf.left_indent = Cm(1.25)
184
+ pf.first_line_indent = Cm(-0.75)
185
+ run = p.add_run(text)
186
+ _set_run_font(run, profile, profile.body_font_size_pt)
187
+
188
+
189
+ def render_table(doc: Document, block: TableBlock, profile: Profile) -> None:
190
+ headers = block.headers
191
+ rows = block.rows
192
+ col_count = len(headers) if headers else len(rows[0])
193
+ table: Table = doc.add_table(rows=1 + len(rows), cols=col_count)
194
+ table.style = "Table Grid"
195
+ table.autofit = False
196
+ table.allow_autofit = False
197
+
198
+ # Header row
199
+ hdr_cells = table.rows[0].cells
200
+ for col_idx, header in enumerate(headers):
201
+ cell = hdr_cells[col_idx]
202
+ cell.text = ""
203
+ p = cell.paragraphs[0]
204
+ _cell_paragraph_format(p, profile)
205
+ p.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
206
+ run = p.add_run(header)
207
+ _set_run_font(run, profile, profile.cell_font_size_pt, bold=True)
208
+
209
+ # Data rows
210
+ for row_idx, row_data in enumerate(rows, start=1):
211
+ cells = table.rows[row_idx].cells
212
+ for col_idx, value in enumerate(row_data):
213
+ cell = cells[col_idx]
214
+ cell.text = ""
215
+ p = cell.paragraphs[0]
216
+ _cell_paragraph_format(p, profile)
217
+ p.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
218
+ run = p.add_run(value)
219
+ _set_run_font(run, profile, profile.cell_font_size_pt)
220
+
221
+ # Column widths
222
+ if block.widths_cm:
223
+ for row in table.rows:
224
+ for col_idx, width_cm in enumerate(block.widths_cm):
225
+ if col_idx < col_count:
226
+ row.cells[col_idx].width = Cm(width_cm)
227
+
228
+ # Explicit cell margins: small padding, deterministic
229
+ for row in table.rows:
230
+ for cell in row.cells:
231
+ tcPr = cell._tc.get_or_add_tcPr()
232
+ tcMar = tcPr.first_child_found_in("w:tcMar")
233
+ if tcMar is None:
234
+ tcMar = parse_xml(r'<w:tcMar xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>')
235
+ tcPr.append(tcMar)
236
+ for edge, val in (("top", 100), ("start", 100), ("bottom", 100), ("end", 100)):
237
+ node = tcMar.find(qn(f"w:{edge}"))
238
+ if node is None:
239
+ node = parse_xml(f'<w:{edge} xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:w="{val}" w:type="dxa"/>')
240
+ tcMar.append(node)
241
+ else:
242
+ node.set(qn("w:w"), str(val))
243
+
244
+
245
+ def _clear_table_borders(table: Table) -> None:
246
+ for row in table.rows:
247
+ for cell in row.cells:
248
+ tcPr = cell._tc.get_or_add_tcPr()
249
+ tcBorders = tcPr.first_child_found_in("w:tcBorders")
250
+ if tcBorders is None:
251
+ tcBorders = parse_xml(r'<w:tcBorders xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>')
252
+ tcPr.append(tcBorders)
253
+ for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
254
+ node = tcBorders.find(qn(f"w:{edge}"))
255
+ if node is None:
256
+ node = parse_xml(f'<w:{edge} xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:val="none"/>')
257
+ tcBorders.append(node)
258
+ else:
259
+ node.set(qn("w:val"), "none")
260
+
261
+
262
+ def render_signature_block(doc: Document, block: SignatureBlock, profile: Profile) -> None:
263
+ fmt = block.format if block.format is not None else profile.default_signature_format
264
+
265
+ if fmt == "text":
266
+ p = doc.add_paragraph()
267
+ pf = p.paragraph_format
268
+ pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
269
+ pf.line_spacing = profile.body_line_spacing
270
+ pf.space_after = Pt(0)
271
+ pf.space_before = Pt(0)
272
+ pf.alignment = WD_ALIGN_PARAGRAPH.LEFT
273
+ pf.tab_stops.add_tab_stop(Cm(16.5), WD_TAB_ALIGNMENT.RIGHT)
274
+ run_left = p.add_run(block.left)
275
+ _set_run_font(run_left, profile, profile.body_font_size_pt)
276
+ run_tab = p.add_run("\t")
277
+ _set_run_font(run_tab, profile, profile.body_font_size_pt)
278
+ run_right = p.add_run(block.right)
279
+ _set_run_font(run_right, profile, profile.body_font_size_pt)
280
+ return
281
+
282
+ table: Table = doc.add_table(rows=1, cols=2)
283
+ table.autofit = False
284
+ table.allow_autofit = False
285
+ table.style = "Table Grid"
286
+ left_cell, right_cell = table.rows[0].cells
287
+
288
+ left_cell.text = ""
289
+ p = left_cell.paragraphs[0]
290
+ _cell_paragraph_format(p, profile)
291
+ p.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
292
+ run = p.add_run(block.left)
293
+ _set_run_font(run, profile, profile.cell_font_size_pt)
294
+
295
+ right_cell.text = ""
296
+ p2 = right_cell.paragraphs[0]
297
+ _cell_paragraph_format(p2, profile)
298
+ p2.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.RIGHT
299
+ run2 = p2.add_run(block.right)
300
+ _set_run_font(run2, profile, profile.cell_font_size_pt)
301
+
302
+ # Hide grid lines by clearing borders for signature block
303
+ _clear_table_borders(table)
304
+
305
+
306
+ def render_pleading_marker(doc: Document, block: PleadingBlock, profile: Profile) -> None:
307
+ p = doc.add_paragraph()
308
+ pf = p.paragraph_format
309
+ pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
310
+ pf.line_spacing = profile.body_line_spacing
311
+ pf.space_before = Pt(12)
312
+ pf.space_after = Pt(6)
313
+ pf.alignment = WD_ALIGN_PARAGRAPH.CENTER
314
+ pf.first_line_indent = Cm(0)
315
+ run = p.add_run(block.text)
316
+ _set_run_font(run, profile, profile.body_font_size_pt, bold=True)
317
+
318
+
319
+ def render_attachments_list(doc: Document, block: AttachmentsListBlock, profile: Profile) -> None:
320
+ p = doc.add_paragraph()
321
+ _body_paragraph_format(p, profile)
322
+ run = p.add_run("Приложения:")
323
+ _set_run_font(run, profile, profile.body_font_size_pt, bold=True)
324
+
325
+ for item in block.items:
326
+ p = doc.add_paragraph()
327
+ _body_paragraph_format(p, profile)
328
+ pf = p.paragraph_format
329
+ pf.left_indent = Cm(1.25)
330
+ pf.first_line_indent = Cm(-0.75)
331
+ run = p.add_run(item)
332
+ _set_run_font(run, profile, profile.body_font_size_pt)
333
+
docx_cli/builder.py ADDED
@@ -0,0 +1,116 @@
1
+ """Core document builder."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from docx import Document
9
+ from docx.shared import Cm
10
+
11
+ from docx_cli import blocks, validate
12
+ from docx_cli.errors import BuildError, ValidationError
13
+ from docx_cli.spec import (
14
+ AttachmentsListBlock,
15
+ DocSpec,
16
+ NumberedListBlock,
17
+ ParagraphBlock,
18
+ Profile,
19
+ SignatureBlock,
20
+ TableBlock,
21
+ now_iso,
22
+ )
23
+
24
+
25
+ BLOCK_RENDERERS = {
26
+ "paragraph": blocks.render_paragraph,
27
+ "numbered_list": blocks.render_numbered_list,
28
+ "table": blocks.render_table,
29
+ "signature_block": blocks.render_signature_block,
30
+ "attachments_list": blocks.render_attachments_list,
31
+ "pleading_marker": blocks.render_pleading_marker,
32
+ }
33
+
34
+
35
+ def _configure_section(doc: Document, profile: Profile) -> None:
36
+ section = doc.sections[0]
37
+ section.page_width = Cm(profile.paper_width_cm)
38
+ section.page_height = Cm(profile.paper_height_cm)
39
+ section.left_margin = Cm(profile.margin_left_cm)
40
+ section.right_margin = Cm(profile.margin_right_cm)
41
+ section.top_margin = Cm(profile.margin_top_cm)
42
+ section.bottom_margin = Cm(profile.margin_bottom_cm)
43
+
44
+
45
+ def build_document(spec: DocSpec, output_path: str) -> dict[str, Any]:
46
+ """Generate a .docx and run built-in validation.
47
+
48
+ Returns the standard envelope. On validation error or any unexpected
49
+ failure the partial output file is deleted and an error envelope is
50
+ returned.
51
+ """
52
+ profile = Profile(spec.profile, line_spacing=spec.line_spacing)
53
+ doc = Document()
54
+ _configure_section(doc, profile)
55
+
56
+ doc.core_properties.author = spec.output.author
57
+ doc.core_properties.title = spec.title.main
58
+
59
+ blocks.render_header(doc, spec.header, profile)
60
+ blocks.render_title(doc, spec.title, profile)
61
+
62
+ for block in spec.body:
63
+ renderer = BLOCK_RENDERERS[block.type]
64
+ renderer(doc, block, profile)
65
+
66
+ out = Path(output_path)
67
+ out.parent.mkdir(parents=True, exist_ok=True)
68
+
69
+ saved = False
70
+ validation: dict[str, Any] | None = None
71
+ try:
72
+ doc.save(str(out))
73
+ saved = True
74
+ validation = validate.validate_document(str(out), spec)
75
+ except Exception as exc:
76
+ if saved:
77
+ try:
78
+ os.remove(str(out))
79
+ except FileNotFoundError:
80
+ pass
81
+ return {
82
+ "ok": False,
83
+ "status": "error",
84
+ "reason": f"build_failed: {exc}",
85
+ "parsed_at": now_iso(),
86
+ "data": {"output_path": str(out.resolve())},
87
+ "metadata": {},
88
+ }
89
+
90
+ result: dict[str, Any] = {
91
+ "ok": True,
92
+ "status": "ok",
93
+ "reason": None,
94
+ "parsed_at": now_iso(),
95
+ "data": {
96
+ "output_path": str(out.resolve()),
97
+ "profile": profile.name,
98
+ "blocks_rendered": len(spec.body),
99
+ },
100
+ "metadata": validation,
101
+ }
102
+
103
+ if validation["errors"]:
104
+ try:
105
+ os.remove(str(out))
106
+ except FileNotFoundError:
107
+ pass
108
+ result["ok"] = False
109
+ result["status"] = "error"
110
+ result["reason"] = "validation_failed: " + "; ".join(validation["errors"])
111
+ return result
112
+
113
+ if validation["warnings"]:
114
+ result["status"] = "degraded"
115
+
116
+ return result