@heylemon/lemonade 0.1.6 → 0.1.7
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.
- package/dist/build-info.json +3 -3
- package/dist/canvas-host/a2ui/.bundle.hash +1 -1
- package/package.json +1 -1
- package/skills/docx/SKILL.md +595 -22
- package/skills/docx/references/templates.md +669 -33
- package/skills/docx/scripts/create_doc.py +289 -52
- package/skills/docx/scripts/validate.py +237 -0
- package/skills/docx/scripts/validate_doc.py +103 -22
- package/skills/pptx/SKILL.md +169 -12
- package/skills/pptx/editing.md +270 -0
- package/skills/pptx/pptxgenjs.md +624 -0
- package/skills/pptx/references/spec-format.md +106 -31
- package/skills/pptx/scripts/create_pptx.js +419 -186
- package/skills/xlsx/SKILL.md +502 -14
- package/skills/xlsx/references/spec-format.md +238 -40
- package/skills/xlsx/scripts/create_xlsx.py +130 -54
- package/skills/xlsx/scripts/recalc.py +157 -147
- package/skills/xlsx/scripts/validate_xlsx.py +31 -6
|
@@ -2,34 +2,79 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Pro Docs — Document Creation Engine
|
|
4
4
|
Takes a JSON document spec and produces a professionally formatted .docx file.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
python create_doc.py spec.json output.docx [--template report|memo|proposal|letter|brief]
|
|
8
|
+
|
|
9
|
+
The JSON spec format:
|
|
10
|
+
{
|
|
11
|
+
"title": "Document Title",
|
|
12
|
+
"subtitle": "Optional subtitle",
|
|
13
|
+
"author": "Author Name",
|
|
14
|
+
"date": "auto", // or "January 2026"
|
|
15
|
+
"template": "report", // report, memo, proposal, letter, brief
|
|
16
|
+
"include_toc": true,
|
|
17
|
+
"include_page_numbers": true,
|
|
18
|
+
"header_text": "Company Name",
|
|
19
|
+
"footer_text": "",
|
|
20
|
+
"brand": {
|
|
21
|
+
"primary_color": "1B3A5C",
|
|
22
|
+
"accent_color": "2E86AB",
|
|
23
|
+
"font_heading": "Arial",
|
|
24
|
+
"font_body": "Arial"
|
|
25
|
+
},
|
|
26
|
+
"sections": [
|
|
27
|
+
{
|
|
28
|
+
"heading": "Section Title",
|
|
29
|
+
"level": 1,
|
|
30
|
+
"content": "Paragraph text...",
|
|
31
|
+
"items": ["bullet 1", "bullet 2"], // optional bullet list
|
|
32
|
+
"numbered": false, // true for numbered list
|
|
33
|
+
"table": { // optional
|
|
34
|
+
"headers": ["Col A", "Col B"],
|
|
35
|
+
"rows": [["val1", "val2"], ["val3", "val4"]]
|
|
36
|
+
},
|
|
37
|
+
"callout": "Important note text", // optional highlighted box
|
|
38
|
+
"page_break_before": false
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
5
42
|
"""
|
|
6
43
|
|
|
7
44
|
import json
|
|
8
45
|
import sys
|
|
46
|
+
import os
|
|
9
47
|
from datetime import datetime
|
|
10
48
|
|
|
11
49
|
try:
|
|
12
50
|
from docx import Document
|
|
13
|
-
from docx.shared import Inches, Pt, RGBColor
|
|
51
|
+
from docx.shared import Inches, Pt, Cm, RGBColor, Emu
|
|
14
52
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
15
53
|
from docx.enum.table import WD_TABLE_ALIGNMENT
|
|
16
|
-
from docx.
|
|
54
|
+
from docx.enum.section import WD_ORIENT
|
|
55
|
+
from docx.oxml.ns import qn, nsdecls
|
|
17
56
|
from docx.oxml import parse_xml
|
|
18
57
|
except ImportError:
|
|
19
58
|
print("ERROR: python-docx not installed. Run: pip install python-docx --break-system-packages")
|
|
20
59
|
sys.exit(1)
|
|
21
60
|
|
|
22
61
|
|
|
62
|
+
# ═══════════════════════════════════════════
|
|
63
|
+
# DEFAULT BRAND — Professional, clean, modern
|
|
64
|
+
# ═══════════════════════════════════════════
|
|
23
65
|
DEFAULT_BRAND = {
|
|
24
|
-
"primary_color": "1B3A5C",
|
|
25
|
-
"accent_color": "2E86AB",
|
|
26
|
-
"light_accent": "E8F4F8",
|
|
27
|
-
"text_color": "2C3E50",
|
|
28
|
-
"muted_color": "7F8C8D",
|
|
66
|
+
"primary_color": "1B3A5C", # Dark navy
|
|
67
|
+
"accent_color": "2E86AB", # Teal blue
|
|
68
|
+
"light_accent": "E8F4F8", # Light teal bg
|
|
69
|
+
"text_color": "2C3E50", # Near-black
|
|
70
|
+
"muted_color": "7F8C8D", # Gray for secondary text
|
|
29
71
|
"font_heading": "Arial",
|
|
30
72
|
"font_body": "Arial",
|
|
31
73
|
}
|
|
32
74
|
|
|
75
|
+
# ═══════════════════════════════════════════
|
|
76
|
+
# TYPOGRAPHY SCALE — Consistent sizing
|
|
77
|
+
# ═══════════════════════════════════════════
|
|
33
78
|
SIZES = {
|
|
34
79
|
"title": Pt(26),
|
|
35
80
|
"subtitle": Pt(14),
|
|
@@ -38,50 +83,83 @@ SIZES = {
|
|
|
38
83
|
"h3": Pt(12),
|
|
39
84
|
"body": Pt(11),
|
|
40
85
|
"small": Pt(9),
|
|
86
|
+
"caption": Pt(8),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
SPACING = {
|
|
90
|
+
"after_title": Pt(6),
|
|
91
|
+
"before_h1": Pt(24),
|
|
92
|
+
"after_h1": Pt(8),
|
|
93
|
+
"before_h2": Pt(18),
|
|
94
|
+
"after_h2": Pt(6),
|
|
95
|
+
"before_h3": Pt(12),
|
|
96
|
+
"after_h3": Pt(4),
|
|
97
|
+
"after_paragraph": Pt(8),
|
|
98
|
+
"line_spacing": Pt(16), # ~1.3x for 11pt body
|
|
41
99
|
}
|
|
42
100
|
|
|
43
101
|
|
|
44
|
-
def hex_to_rgb(hex_color
|
|
45
|
-
|
|
102
|
+
def hex_to_rgb(hex_color):
|
|
103
|
+
"""Convert hex string to RGBColor."""
|
|
104
|
+
h = hex_color.lstrip('#')
|
|
46
105
|
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
|
47
106
|
|
|
48
107
|
|
|
49
|
-
def set_cell_shading(cell, color_hex
|
|
108
|
+
def set_cell_shading(cell, color_hex):
|
|
109
|
+
"""Set table cell background color."""
|
|
50
110
|
shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{color_hex}" w:val="clear"/>')
|
|
51
111
|
cell._tc.get_or_add_tcPr().append(shading)
|
|
52
112
|
|
|
53
113
|
|
|
54
|
-
def set_cell_border(cell, color="D5D8DC")
|
|
114
|
+
def set_cell_border(cell, color="D5D8DC"):
|
|
115
|
+
"""Set thin borders on a table cell."""
|
|
55
116
|
tc = cell._tc
|
|
56
|
-
|
|
117
|
+
tcPr = tc.get_or_add_tcPr()
|
|
57
118
|
borders = parse_xml(
|
|
58
119
|
f'<w:tcBorders {nsdecls("w")}>'
|
|
59
120
|
f' <w:top w:val="single" w:sz="4" w:space="0" w:color="{color}"/>'
|
|
60
121
|
f' <w:left w:val="single" w:sz="4" w:space="0" w:color="{color}"/>'
|
|
61
122
|
f' <w:bottom w:val="single" w:sz="4" w:space="0" w:color="{color}"/>'
|
|
62
123
|
f' <w:right w:val="single" w:sz="4" w:space="0" w:color="{color}"/>'
|
|
63
|
-
f
|
|
124
|
+
f'</w:tcBorders>'
|
|
64
125
|
)
|
|
65
|
-
|
|
126
|
+
tcPr.append(borders)
|
|
66
127
|
|
|
67
128
|
|
|
68
129
|
class ProDocBuilder:
|
|
130
|
+
"""Builds professional documents from a JSON spec."""
|
|
131
|
+
|
|
69
132
|
def __init__(self, spec):
|
|
70
133
|
self.spec = spec
|
|
71
134
|
self.brand = {**DEFAULT_BRAND, **spec.get("brand", {})}
|
|
72
135
|
self.doc = Document()
|
|
136
|
+
self.template = spec.get("template", "report")
|
|
73
137
|
self._setup_styles()
|
|
74
138
|
self._setup_page()
|
|
75
139
|
|
|
76
140
|
def _setup_page(self):
|
|
141
|
+
"""Configure page size, margins, headers, footers."""
|
|
77
142
|
section = self.doc.sections[0]
|
|
143
|
+
# US Letter
|
|
78
144
|
section.page_width = Inches(8.5)
|
|
79
145
|
section.page_height = Inches(11)
|
|
146
|
+
# Professional margins: 1 inch sides, 0.8 top/bottom
|
|
80
147
|
section.top_margin = Inches(0.8)
|
|
81
148
|
section.bottom_margin = Inches(0.8)
|
|
82
149
|
section.left_margin = Inches(1)
|
|
83
150
|
section.right_margin = Inches(1)
|
|
84
151
|
|
|
152
|
+
# Header
|
|
153
|
+
header_text = self.spec.get("header_text", "")
|
|
154
|
+
if header_text:
|
|
155
|
+
header = section.header
|
|
156
|
+
hp = header.paragraphs[0]
|
|
157
|
+
hp.text = header_text
|
|
158
|
+
hp.style.font.size = SIZES["small"]
|
|
159
|
+
hp.style.font.color.rgb = hex_to_rgb(self.brand["muted_color"])
|
|
160
|
+
hp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
|
161
|
+
|
|
162
|
+
# Footer with page numbers
|
|
85
163
|
if self.spec.get("include_page_numbers", True):
|
|
86
164
|
footer = section.footer
|
|
87
165
|
fp = footer.paragraphs[0]
|
|
@@ -89,22 +167,36 @@ class ProDocBuilder:
|
|
|
89
167
|
run = fp.add_run()
|
|
90
168
|
run.font.size = SIZES["small"]
|
|
91
169
|
run.font.color.rgb = hex_to_rgb(self.brand["muted_color"])
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
170
|
+
# Add page number field
|
|
171
|
+
fldChar1 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>')
|
|
172
|
+
run._r.append(fldChar1)
|
|
173
|
+
instrText = parse_xml(f'<w:instrText {nsdecls("w")} xml:space="preserve"> PAGE </w:instrText>')
|
|
174
|
+
run._r.append(instrText)
|
|
175
|
+
fldChar2 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>')
|
|
176
|
+
run._r.append(fldChar2)
|
|
177
|
+
|
|
178
|
+
footer_text = self.spec.get("footer_text", "")
|
|
179
|
+
if footer_text:
|
|
180
|
+
footer = section.footer
|
|
181
|
+
if footer.paragraphs:
|
|
182
|
+
fp = footer.paragraphs[0]
|
|
183
|
+
# Add footer text before page number
|
|
184
|
+
run = fp.runs[0] if fp.runs else fp.add_run()
|
|
185
|
+
fp.insert_paragraph_before(footer_text).alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
100
186
|
|
|
101
187
|
def _setup_styles(self):
|
|
102
|
-
|
|
103
|
-
style
|
|
104
|
-
|
|
105
|
-
|
|
188
|
+
"""Configure document-wide typography."""
|
|
189
|
+
style = self.doc.styles['Normal']
|
|
190
|
+
font = style.font
|
|
191
|
+
font.name = self.brand["font_body"]
|
|
192
|
+
font.size = SIZES["body"]
|
|
193
|
+
font.color.rgb = hex_to_rgb(self.brand["text_color"])
|
|
194
|
+
pf = style.paragraph_format
|
|
195
|
+
pf.space_after = SPACING["after_paragraph"]
|
|
196
|
+
pf.line_spacing = SPACING["line_spacing"]
|
|
106
197
|
|
|
107
198
|
def _add_title_block(self):
|
|
199
|
+
"""Add the title page / title block."""
|
|
108
200
|
title = self.spec.get("title", "Untitled Document")
|
|
109
201
|
subtitle = self.spec.get("subtitle", "")
|
|
110
202
|
author = self.spec.get("author", "")
|
|
@@ -112,126 +204,264 @@ class ProDocBuilder:
|
|
|
112
204
|
if date_str == "auto":
|
|
113
205
|
date_str = datetime.now().strftime("%B %d, %Y")
|
|
114
206
|
|
|
207
|
+
# Title
|
|
115
208
|
p = self.doc.add_paragraph()
|
|
116
209
|
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
210
|
+
p.paragraph_format.space_before = Pt(48)
|
|
211
|
+
p.paragraph_format.space_after = SPACING["after_title"]
|
|
117
212
|
run = p.add_run(title)
|
|
118
213
|
run.font.size = SIZES["title"]
|
|
119
214
|
run.font.bold = True
|
|
120
215
|
run.font.color.rgb = hex_to_rgb(self.brand["primary_color"])
|
|
121
216
|
run.font.name = self.brand["font_heading"]
|
|
122
217
|
|
|
218
|
+
# Accent line under title
|
|
219
|
+
p2 = self.doc.add_paragraph()
|
|
220
|
+
p2.paragraph_format.space_before = Pt(0)
|
|
221
|
+
p2.paragraph_format.space_after = Pt(12)
|
|
222
|
+
# Add a colored horizontal rule via border
|
|
223
|
+
pPr = p2._p.get_or_add_pPr()
|
|
224
|
+
pBdr = parse_xml(
|
|
225
|
+
f'<w:pBdr {nsdecls("w")}>'
|
|
226
|
+
f' <w:bottom w:val="single" w:sz="12" w:space="1" w:color="{self.brand["accent_color"]}"/>'
|
|
227
|
+
f'</w:pBdr>'
|
|
228
|
+
)
|
|
229
|
+
pPr.append(pBdr)
|
|
230
|
+
|
|
231
|
+
# Subtitle
|
|
123
232
|
if subtitle:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
233
|
+
p3 = self.doc.add_paragraph()
|
|
234
|
+
p3.paragraph_format.space_after = Pt(4)
|
|
235
|
+
run = p3.add_run(subtitle)
|
|
236
|
+
run.font.size = SIZES["subtitle"]
|
|
237
|
+
run.font.color.rgb = hex_to_rgb(self.brand["muted_color"])
|
|
238
|
+
run.font.name = self.brand["font_heading"]
|
|
129
239
|
|
|
240
|
+
# Author and date
|
|
130
241
|
if author or date_str:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
242
|
+
meta_parts = []
|
|
243
|
+
if author:
|
|
244
|
+
meta_parts.append(author)
|
|
245
|
+
if date_str:
|
|
246
|
+
meta_parts.append(date_str)
|
|
247
|
+
p4 = self.doc.add_paragraph()
|
|
248
|
+
p4.paragraph_format.space_after = Pt(24)
|
|
249
|
+
run = p4.add_run(" · ".join(meta_parts))
|
|
250
|
+
run.font.size = SIZES["small"]
|
|
251
|
+
run.font.color.rgb = hex_to_rgb(self.brand["muted_color"])
|
|
252
|
+
|
|
253
|
+
def _add_toc(self):
|
|
254
|
+
"""Add a Table of Contents field."""
|
|
255
|
+
p = self.doc.add_paragraph()
|
|
256
|
+
p.paragraph_format.space_before = Pt(12)
|
|
257
|
+
run = p.add_run("Table of Contents")
|
|
258
|
+
run.font.size = SIZES["h2"]
|
|
259
|
+
run.bold = True
|
|
260
|
+
run.font.color.rgb = hex_to_rgb(self.brand["primary_color"])
|
|
261
|
+
run.font.name = self.brand["font_heading"]
|
|
262
|
+
|
|
263
|
+
# TOC field code
|
|
264
|
+
p2 = self.doc.add_paragraph()
|
|
265
|
+
run = p2.add_run()
|
|
266
|
+
fldChar1 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>')
|
|
267
|
+
run._r.append(fldChar1)
|
|
268
|
+
instrText = parse_xml(
|
|
269
|
+
f'<w:instrText {nsdecls("w")} xml:space="preserve"> TOC \\o "1-3" \\h \\z \\u </w:instrText>'
|
|
270
|
+
)
|
|
271
|
+
run._r.append(instrText)
|
|
272
|
+
fldChar2 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="separate"/>')
|
|
273
|
+
run._r.append(fldChar2)
|
|
274
|
+
toc_run = p2.add_run("[Right-click → Update Field to populate]")
|
|
275
|
+
toc_run.font.color.rgb = hex_to_rgb(self.brand["muted_color"])
|
|
276
|
+
toc_run.font.size = SIZES["small"]
|
|
277
|
+
fldChar3 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>')
|
|
278
|
+
run2 = p2.add_run()
|
|
279
|
+
run2._r.append(fldChar3)
|
|
280
|
+
|
|
281
|
+
# Page break after TOC
|
|
282
|
+
self.doc.add_page_break()
|
|
135
283
|
|
|
136
284
|
def _add_heading(self, text, level=1):
|
|
285
|
+
"""Add a styled heading."""
|
|
137
286
|
heading = self.doc.add_heading(text, level=level)
|
|
138
287
|
for run in heading.runs:
|
|
139
288
|
run.font.color.rgb = hex_to_rgb(self.brand["primary_color"])
|
|
140
289
|
run.font.name = self.brand["font_heading"]
|
|
290
|
+
if level == 1:
|
|
291
|
+
run.font.size = SIZES["h1"]
|
|
292
|
+
elif level == 2:
|
|
293
|
+
run.font.size = SIZES["h2"]
|
|
294
|
+
elif level == 3:
|
|
295
|
+
run.font.size = SIZES["h3"]
|
|
296
|
+
|
|
297
|
+
pf = heading.paragraph_format
|
|
298
|
+
if level == 1:
|
|
299
|
+
pf.space_before = SPACING["before_h1"]
|
|
300
|
+
pf.space_after = SPACING["after_h1"]
|
|
301
|
+
elif level == 2:
|
|
302
|
+
pf.space_before = SPACING["before_h2"]
|
|
303
|
+
pf.space_after = SPACING["after_h2"]
|
|
304
|
+
else:
|
|
305
|
+
pf.space_before = SPACING["before_h3"]
|
|
306
|
+
pf.space_after = SPACING["after_h3"]
|
|
307
|
+
|
|
141
308
|
return heading
|
|
142
309
|
|
|
143
310
|
def _add_paragraph(self, text):
|
|
311
|
+
"""Add a body paragraph."""
|
|
144
312
|
p = self.doc.add_paragraph(text)
|
|
313
|
+
p.paragraph_format.space_after = SPACING["after_paragraph"]
|
|
314
|
+
p.paragraph_format.line_spacing = SPACING["line_spacing"]
|
|
145
315
|
for run in p.runs:
|
|
146
316
|
run.font.name = self.brand["font_body"]
|
|
147
317
|
run.font.size = SIZES["body"]
|
|
148
318
|
run.font.color.rgb = hex_to_rgb(self.brand["text_color"])
|
|
319
|
+
return p
|
|
149
320
|
|
|
150
321
|
def _add_bullets(self, items, numbered=False):
|
|
151
|
-
|
|
322
|
+
"""Add a bullet or numbered list."""
|
|
152
323
|
for item in items:
|
|
324
|
+
style = 'List Number' if numbered else 'List Bullet'
|
|
153
325
|
p = self.doc.add_paragraph(item, style=style)
|
|
326
|
+
p.paragraph_format.space_after = Pt(3)
|
|
327
|
+
p.paragraph_format.line_spacing = SPACING["line_spacing"]
|
|
154
328
|
for run in p.runs:
|
|
155
329
|
run.font.name = self.brand["font_body"]
|
|
156
330
|
run.font.size = SIZES["body"]
|
|
157
331
|
|
|
158
332
|
def _add_table(self, table_spec):
|
|
333
|
+
"""Add a professionally formatted table."""
|
|
159
334
|
headers = table_spec.get("headers", [])
|
|
160
335
|
rows = table_spec.get("rows", [])
|
|
161
336
|
if not headers and not rows:
|
|
162
337
|
return
|
|
338
|
+
|
|
163
339
|
num_cols = len(headers) if headers else len(rows[0])
|
|
164
340
|
table = self.doc.add_table(rows=0, cols=num_cols)
|
|
165
341
|
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
166
342
|
|
|
343
|
+
# Header row
|
|
167
344
|
if headers:
|
|
168
345
|
row = table.add_row()
|
|
169
|
-
for i,
|
|
346
|
+
for i, header_text in enumerate(headers):
|
|
170
347
|
cell = row.cells[i]
|
|
171
|
-
cell.text = str(
|
|
172
|
-
|
|
173
|
-
|
|
348
|
+
cell.text = str(header_text)
|
|
349
|
+
# Style header
|
|
350
|
+
for paragraph in cell.paragraphs:
|
|
351
|
+
paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
352
|
+
for run in paragraph.runs:
|
|
174
353
|
run.font.bold = True
|
|
354
|
+
run.font.size = SIZES["body"]
|
|
175
355
|
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
|
|
176
356
|
run.font.name = self.brand["font_heading"]
|
|
177
357
|
set_cell_shading(cell, self.brand["primary_color"])
|
|
178
358
|
set_cell_border(cell, self.brand["primary_color"])
|
|
179
359
|
|
|
360
|
+
# Data rows
|
|
180
361
|
for ri, row_data in enumerate(rows):
|
|
181
362
|
row = table.add_row()
|
|
182
363
|
for ci, cell_text in enumerate(row_data):
|
|
183
364
|
cell = row.cells[ci]
|
|
184
365
|
cell.text = str(cell_text)
|
|
366
|
+
for paragraph in cell.paragraphs:
|
|
367
|
+
for run in paragraph.runs:
|
|
368
|
+
run.font.size = SIZES["body"]
|
|
369
|
+
run.font.name = self.brand["font_body"]
|
|
370
|
+
run.font.color.rgb = hex_to_rgb(self.brand["text_color"])
|
|
371
|
+
# Alternating row shading
|
|
185
372
|
if ri % 2 == 0:
|
|
186
373
|
set_cell_shading(cell, self.brand.get("light_accent", "F8F9FA"))
|
|
187
374
|
set_cell_border(cell, "D5D8DC")
|
|
188
375
|
|
|
376
|
+
# Set column widths evenly
|
|
377
|
+
content_width = Inches(6.5) # 8.5 - 2 margins
|
|
378
|
+
col_width = content_width // num_cols
|
|
379
|
+
for row in table.rows:
|
|
380
|
+
for cell in row.cells:
|
|
381
|
+
cell.width = col_width
|
|
382
|
+
|
|
189
383
|
def _add_callout(self, text):
|
|
384
|
+
"""Add a highlighted callout/note box."""
|
|
190
385
|
p = self.doc.add_paragraph()
|
|
191
|
-
|
|
192
|
-
|
|
386
|
+
p.paragraph_format.space_before = Pt(8)
|
|
387
|
+
p.paragraph_format.space_after = Pt(8)
|
|
388
|
+
|
|
389
|
+
# Left border + background shading
|
|
390
|
+
pPr = p._p.get_or_add_pPr()
|
|
391
|
+
# Left border accent
|
|
392
|
+
pBdr = parse_xml(
|
|
193
393
|
f'<w:pBdr {nsdecls("w")}>'
|
|
194
394
|
f' <w:left w:val="single" w:sz="24" w:space="8" w:color="{self.brand["accent_color"]}"/>'
|
|
195
|
-
f
|
|
395
|
+
f'</w:pBdr>'
|
|
396
|
+
)
|
|
397
|
+
pPr.append(pBdr)
|
|
398
|
+
# Background shading
|
|
399
|
+
shd = parse_xml(
|
|
400
|
+
f'<w:shd {nsdecls("w")} w:fill="{self.brand.get("light_accent", "E8F4F8")}" w:val="clear"/>'
|
|
196
401
|
)
|
|
197
|
-
|
|
402
|
+
pPr.append(shd)
|
|
403
|
+
# Indent to make it feel like a box
|
|
404
|
+
ind = parse_xml(f'<w:ind {nsdecls("w")} w:left="360" w:right="360"/>')
|
|
405
|
+
pPr.append(ind)
|
|
406
|
+
|
|
198
407
|
run = p.add_run(text)
|
|
199
408
|
run.font.size = SIZES["body"]
|
|
200
409
|
run.font.name = self.brand["font_body"]
|
|
201
410
|
run.font.color.rgb = hex_to_rgb(self.brand["text_color"])
|
|
202
411
|
|
|
203
412
|
def _process_section(self, section):
|
|
413
|
+
"""Process a single content section from the spec."""
|
|
414
|
+
# Page break
|
|
204
415
|
if section.get("page_break_before", False):
|
|
205
416
|
self.doc.add_page_break()
|
|
206
417
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
418
|
+
# Heading
|
|
419
|
+
heading_text = section.get("heading", "")
|
|
420
|
+
level = section.get("level", 1)
|
|
421
|
+
if heading_text:
|
|
422
|
+
self._add_heading(heading_text, level)
|
|
210
423
|
|
|
424
|
+
# Body content — handle both string and list of paragraphs
|
|
211
425
|
content = section.get("content", "")
|
|
212
426
|
if isinstance(content, list):
|
|
213
427
|
for para in content:
|
|
214
428
|
self._add_paragraph(para)
|
|
215
429
|
elif content:
|
|
216
|
-
for
|
|
430
|
+
# Split on double newlines for multiple paragraphs
|
|
431
|
+
paragraphs = content.split("\n\n")
|
|
432
|
+
for para in paragraphs:
|
|
217
433
|
self._add_paragraph(para.strip())
|
|
218
434
|
|
|
435
|
+
# Bullet or numbered list
|
|
219
436
|
items = section.get("items", [])
|
|
220
437
|
if items:
|
|
221
|
-
|
|
438
|
+
numbered = section.get("numbered", False)
|
|
439
|
+
self._add_bullets(items, numbered)
|
|
222
440
|
|
|
441
|
+
# Table
|
|
223
442
|
table_spec = section.get("table")
|
|
224
443
|
if table_spec:
|
|
225
444
|
self._add_table(table_spec)
|
|
226
445
|
|
|
446
|
+
# Callout
|
|
227
447
|
callout = section.get("callout", "")
|
|
228
448
|
if callout:
|
|
229
449
|
self._add_callout(callout)
|
|
230
450
|
|
|
231
451
|
def build(self, output_path):
|
|
452
|
+
"""Build the complete document and save."""
|
|
453
|
+
# Title block
|
|
232
454
|
self._add_title_block()
|
|
455
|
+
|
|
456
|
+
# Table of contents
|
|
457
|
+
if self.spec.get("include_toc", False):
|
|
458
|
+
self._add_toc()
|
|
459
|
+
|
|
460
|
+
# Sections
|
|
233
461
|
for section in self.spec.get("sections", []):
|
|
234
462
|
self._process_section(section)
|
|
463
|
+
|
|
464
|
+
# Save
|
|
235
465
|
self.doc.save(output_path)
|
|
236
466
|
return output_path
|
|
237
467
|
|
|
@@ -241,11 +471,18 @@ def main():
|
|
|
241
471
|
print("Usage: python create_doc.py spec.json output.docx")
|
|
242
472
|
sys.exit(1)
|
|
243
473
|
|
|
244
|
-
|
|
474
|
+
spec_path = sys.argv[1]
|
|
475
|
+
output_path = sys.argv[2]
|
|
476
|
+
|
|
477
|
+
with open(spec_path, 'r') as f:
|
|
245
478
|
spec = json.load(f)
|
|
479
|
+
|
|
246
480
|
builder = ProDocBuilder(spec)
|
|
247
|
-
builder.build(
|
|
248
|
-
print(f"Document created: {
|
|
481
|
+
builder.build(output_path)
|
|
482
|
+
print(f"Document created: {output_path}")
|
|
483
|
+
print(f" Title: {spec.get('title', 'Untitled')}")
|
|
484
|
+
print(f" Template: {spec.get('template', 'report')}")
|
|
485
|
+
print(f" Sections: {len(spec.get('sections', []))}")
|
|
249
486
|
|
|
250
487
|
|
|
251
488
|
if __name__ == "__main__":
|