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