@heylemon/lemonade 0.1.5 → 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 +485 -356
- package/skills/docx/references/templates.md +682 -0
- package/skills/docx/scripts/create_doc.py +489 -0
- package/skills/docx/scripts/validate.py +237 -0
- package/skills/docx/scripts/validate_doc.py +156 -0
- package/skills/pptx/SKILL.md +133 -184
- package/skills/pptx/editing.md +197 -132
- package/skills/pptx/pptxgenjs.md +484 -280
- package/skills/pptx/references/spec-format.md +129 -0
- package/skills/pptx/scripts/create_pptx.js +451 -0
- package/skills/xlsx/SKILL.md +426 -207
- package/skills/xlsx/references/spec-format.md +251 -0
- package/skills/xlsx/scripts/create_xlsx.py +298 -0
- package/skills/xlsx/scripts/recalc.py +157 -147
- package/skills/xlsx/scripts/validate_xlsx.py +98 -0
- package/skills/pptx/scripts/add_slide.py +0 -195
- package/skills/pptx/scripts/clean.py +0 -286
- package/skills/pptx/scripts/thumbnail.py +0 -289
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Pro Docs — Document Creation Engine
|
|
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
|
+
}
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import sys
|
|
46
|
+
import os
|
|
47
|
+
from datetime import datetime
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from docx import Document
|
|
51
|
+
from docx.shared import Inches, Pt, Cm, RGBColor, Emu
|
|
52
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
53
|
+
from docx.enum.table import WD_TABLE_ALIGNMENT
|
|
54
|
+
from docx.enum.section import WD_ORIENT
|
|
55
|
+
from docx.oxml.ns import qn, nsdecls
|
|
56
|
+
from docx.oxml import parse_xml
|
|
57
|
+
except ImportError:
|
|
58
|
+
print("ERROR: python-docx not installed. Run: pip install python-docx --break-system-packages")
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ═══════════════════════════════════════════
|
|
63
|
+
# DEFAULT BRAND — Professional, clean, modern
|
|
64
|
+
# ═══════════════════════════════════════════
|
|
65
|
+
DEFAULT_BRAND = {
|
|
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
|
|
71
|
+
"font_heading": "Arial",
|
|
72
|
+
"font_body": "Arial",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# ═══════════════════════════════════════════
|
|
76
|
+
# TYPOGRAPHY SCALE — Consistent sizing
|
|
77
|
+
# ═══════════════════════════════════════════
|
|
78
|
+
SIZES = {
|
|
79
|
+
"title": Pt(26),
|
|
80
|
+
"subtitle": Pt(14),
|
|
81
|
+
"h1": Pt(18),
|
|
82
|
+
"h2": Pt(14),
|
|
83
|
+
"h3": Pt(12),
|
|
84
|
+
"body": Pt(11),
|
|
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
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def hex_to_rgb(hex_color):
|
|
103
|
+
"""Convert hex string to RGBColor."""
|
|
104
|
+
h = hex_color.lstrip('#')
|
|
105
|
+
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def set_cell_shading(cell, color_hex):
|
|
109
|
+
"""Set table cell background color."""
|
|
110
|
+
shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{color_hex}" w:val="clear"/>')
|
|
111
|
+
cell._tc.get_or_add_tcPr().append(shading)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def set_cell_border(cell, color="D5D8DC"):
|
|
115
|
+
"""Set thin borders on a table cell."""
|
|
116
|
+
tc = cell._tc
|
|
117
|
+
tcPr = tc.get_or_add_tcPr()
|
|
118
|
+
borders = parse_xml(
|
|
119
|
+
f'<w:tcBorders {nsdecls("w")}>'
|
|
120
|
+
f' <w:top w:val="single" w:sz="4" w:space="0" w:color="{color}"/>'
|
|
121
|
+
f' <w:left w:val="single" w:sz="4" w:space="0" w:color="{color}"/>'
|
|
122
|
+
f' <w:bottom w:val="single" w:sz="4" w:space="0" w:color="{color}"/>'
|
|
123
|
+
f' <w:right w:val="single" w:sz="4" w:space="0" w:color="{color}"/>'
|
|
124
|
+
f'</w:tcBorders>'
|
|
125
|
+
)
|
|
126
|
+
tcPr.append(borders)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ProDocBuilder:
|
|
130
|
+
"""Builds professional documents from a JSON spec."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, spec):
|
|
133
|
+
self.spec = spec
|
|
134
|
+
self.brand = {**DEFAULT_BRAND, **spec.get("brand", {})}
|
|
135
|
+
self.doc = Document()
|
|
136
|
+
self.template = spec.get("template", "report")
|
|
137
|
+
self._setup_styles()
|
|
138
|
+
self._setup_page()
|
|
139
|
+
|
|
140
|
+
def _setup_page(self):
|
|
141
|
+
"""Configure page size, margins, headers, footers."""
|
|
142
|
+
section = self.doc.sections[0]
|
|
143
|
+
# US Letter
|
|
144
|
+
section.page_width = Inches(8.5)
|
|
145
|
+
section.page_height = Inches(11)
|
|
146
|
+
# Professional margins: 1 inch sides, 0.8 top/bottom
|
|
147
|
+
section.top_margin = Inches(0.8)
|
|
148
|
+
section.bottom_margin = Inches(0.8)
|
|
149
|
+
section.left_margin = Inches(1)
|
|
150
|
+
section.right_margin = Inches(1)
|
|
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
|
|
163
|
+
if self.spec.get("include_page_numbers", True):
|
|
164
|
+
footer = section.footer
|
|
165
|
+
fp = footer.paragraphs[0]
|
|
166
|
+
fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
167
|
+
run = fp.add_run()
|
|
168
|
+
run.font.size = SIZES["small"]
|
|
169
|
+
run.font.color.rgb = hex_to_rgb(self.brand["muted_color"])
|
|
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
|
|
186
|
+
|
|
187
|
+
def _setup_styles(self):
|
|
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"]
|
|
197
|
+
|
|
198
|
+
def _add_title_block(self):
|
|
199
|
+
"""Add the title page / title block."""
|
|
200
|
+
title = self.spec.get("title", "Untitled Document")
|
|
201
|
+
subtitle = self.spec.get("subtitle", "")
|
|
202
|
+
author = self.spec.get("author", "")
|
|
203
|
+
date_str = self.spec.get("date", "auto")
|
|
204
|
+
if date_str == "auto":
|
|
205
|
+
date_str = datetime.now().strftime("%B %d, %Y")
|
|
206
|
+
|
|
207
|
+
# Title
|
|
208
|
+
p = self.doc.add_paragraph()
|
|
209
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
210
|
+
p.paragraph_format.space_before = Pt(48)
|
|
211
|
+
p.paragraph_format.space_after = SPACING["after_title"]
|
|
212
|
+
run = p.add_run(title)
|
|
213
|
+
run.font.size = SIZES["title"]
|
|
214
|
+
run.font.bold = True
|
|
215
|
+
run.font.color.rgb = hex_to_rgb(self.brand["primary_color"])
|
|
216
|
+
run.font.name = self.brand["font_heading"]
|
|
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
|
|
232
|
+
if subtitle:
|
|
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"]
|
|
239
|
+
|
|
240
|
+
# Author and date
|
|
241
|
+
if author or date_str:
|
|
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()
|
|
283
|
+
|
|
284
|
+
def _add_heading(self, text, level=1):
|
|
285
|
+
"""Add a styled heading."""
|
|
286
|
+
heading = self.doc.add_heading(text, level=level)
|
|
287
|
+
for run in heading.runs:
|
|
288
|
+
run.font.color.rgb = hex_to_rgb(self.brand["primary_color"])
|
|
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
|
+
|
|
308
|
+
return heading
|
|
309
|
+
|
|
310
|
+
def _add_paragraph(self, text):
|
|
311
|
+
"""Add a body paragraph."""
|
|
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"]
|
|
315
|
+
for run in p.runs:
|
|
316
|
+
run.font.name = self.brand["font_body"]
|
|
317
|
+
run.font.size = SIZES["body"]
|
|
318
|
+
run.font.color.rgb = hex_to_rgb(self.brand["text_color"])
|
|
319
|
+
return p
|
|
320
|
+
|
|
321
|
+
def _add_bullets(self, items, numbered=False):
|
|
322
|
+
"""Add a bullet or numbered list."""
|
|
323
|
+
for item in items:
|
|
324
|
+
style = 'List Number' if numbered else 'List Bullet'
|
|
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"]
|
|
328
|
+
for run in p.runs:
|
|
329
|
+
run.font.name = self.brand["font_body"]
|
|
330
|
+
run.font.size = SIZES["body"]
|
|
331
|
+
|
|
332
|
+
def _add_table(self, table_spec):
|
|
333
|
+
"""Add a professionally formatted table."""
|
|
334
|
+
headers = table_spec.get("headers", [])
|
|
335
|
+
rows = table_spec.get("rows", [])
|
|
336
|
+
if not headers and not rows:
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
num_cols = len(headers) if headers else len(rows[0])
|
|
340
|
+
table = self.doc.add_table(rows=0, cols=num_cols)
|
|
341
|
+
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
342
|
+
|
|
343
|
+
# Header row
|
|
344
|
+
if headers:
|
|
345
|
+
row = table.add_row()
|
|
346
|
+
for i, header_text in enumerate(headers):
|
|
347
|
+
cell = row.cells[i]
|
|
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:
|
|
353
|
+
run.font.bold = True
|
|
354
|
+
run.font.size = SIZES["body"]
|
|
355
|
+
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
|
|
356
|
+
run.font.name = self.brand["font_heading"]
|
|
357
|
+
set_cell_shading(cell, self.brand["primary_color"])
|
|
358
|
+
set_cell_border(cell, self.brand["primary_color"])
|
|
359
|
+
|
|
360
|
+
# Data rows
|
|
361
|
+
for ri, row_data in enumerate(rows):
|
|
362
|
+
row = table.add_row()
|
|
363
|
+
for ci, cell_text in enumerate(row_data):
|
|
364
|
+
cell = row.cells[ci]
|
|
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
|
|
372
|
+
if ri % 2 == 0:
|
|
373
|
+
set_cell_shading(cell, self.brand.get("light_accent", "F8F9FA"))
|
|
374
|
+
set_cell_border(cell, "D5D8DC")
|
|
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
|
+
|
|
383
|
+
def _add_callout(self, text):
|
|
384
|
+
"""Add a highlighted callout/note box."""
|
|
385
|
+
p = self.doc.add_paragraph()
|
|
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(
|
|
393
|
+
f'<w:pBdr {nsdecls("w")}>'
|
|
394
|
+
f' <w:left w:val="single" w:sz="24" w:space="8" w:color="{self.brand["accent_color"]}"/>'
|
|
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"/>'
|
|
401
|
+
)
|
|
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
|
+
|
|
407
|
+
run = p.add_run(text)
|
|
408
|
+
run.font.size = SIZES["body"]
|
|
409
|
+
run.font.name = self.brand["font_body"]
|
|
410
|
+
run.font.color.rgb = hex_to_rgb(self.brand["text_color"])
|
|
411
|
+
|
|
412
|
+
def _process_section(self, section):
|
|
413
|
+
"""Process a single content section from the spec."""
|
|
414
|
+
# Page break
|
|
415
|
+
if section.get("page_break_before", False):
|
|
416
|
+
self.doc.add_page_break()
|
|
417
|
+
|
|
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)
|
|
423
|
+
|
|
424
|
+
# Body content — handle both string and list of paragraphs
|
|
425
|
+
content = section.get("content", "")
|
|
426
|
+
if isinstance(content, list):
|
|
427
|
+
for para in content:
|
|
428
|
+
self._add_paragraph(para)
|
|
429
|
+
elif content:
|
|
430
|
+
# Split on double newlines for multiple paragraphs
|
|
431
|
+
paragraphs = content.split("\n\n")
|
|
432
|
+
for para in paragraphs:
|
|
433
|
+
self._add_paragraph(para.strip())
|
|
434
|
+
|
|
435
|
+
# Bullet or numbered list
|
|
436
|
+
items = section.get("items", [])
|
|
437
|
+
if items:
|
|
438
|
+
numbered = section.get("numbered", False)
|
|
439
|
+
self._add_bullets(items, numbered)
|
|
440
|
+
|
|
441
|
+
# Table
|
|
442
|
+
table_spec = section.get("table")
|
|
443
|
+
if table_spec:
|
|
444
|
+
self._add_table(table_spec)
|
|
445
|
+
|
|
446
|
+
# Callout
|
|
447
|
+
callout = section.get("callout", "")
|
|
448
|
+
if callout:
|
|
449
|
+
self._add_callout(callout)
|
|
450
|
+
|
|
451
|
+
def build(self, output_path):
|
|
452
|
+
"""Build the complete document and save."""
|
|
453
|
+
# Title block
|
|
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
|
|
461
|
+
for section in self.spec.get("sections", []):
|
|
462
|
+
self._process_section(section)
|
|
463
|
+
|
|
464
|
+
# Save
|
|
465
|
+
self.doc.save(output_path)
|
|
466
|
+
return output_path
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def main():
|
|
470
|
+
if len(sys.argv) < 3:
|
|
471
|
+
print("Usage: python create_doc.py spec.json output.docx")
|
|
472
|
+
sys.exit(1)
|
|
473
|
+
|
|
474
|
+
spec_path = sys.argv[1]
|
|
475
|
+
output_path = sys.argv[2]
|
|
476
|
+
|
|
477
|
+
with open(spec_path, 'r') as f:
|
|
478
|
+
spec = json.load(f)
|
|
479
|
+
|
|
480
|
+
builder = ProDocBuilder(spec)
|
|
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', []))}")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
if __name__ == "__main__":
|
|
489
|
+
main()
|