@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.
@@ -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()