@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.
@@ -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.oxml.ns import nsdecls
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: str) -> RGBColor:
45
- h = hex_color.lstrip("#")
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: str) -> None:
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") -> None:
114
+ def set_cell_border(cell, color="D5D8DC"):
115
+ """Set thin borders on a table cell."""
55
116
  tc = cell._tc
56
- tc_pr = tc.get_or_add_tcPr()
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"</w:tcBorders>"
124
+ f'</w:tcBorders>'
64
125
  )
65
- tc_pr.append(borders)
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
- fld_char1 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>')
93
- run._r.append(fld_char1)
94
- instr = parse_xml(
95
- f'<w:instrText {nsdecls("w")} xml:space="preserve"> PAGE </w:instrText>'
96
- )
97
- run._r.append(instr)
98
- fld_char2 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>')
99
- run._r.append(fld_char2)
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
- style = self.doc.styles["Normal"]
103
- style.font.name = self.brand["font_body"]
104
- style.font.size = SIZES["body"]
105
- style.font.color.rgb = hex_to_rgb(self.brand["text_color"])
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
- p2 = self.doc.add_paragraph()
125
- r2 = p2.add_run(subtitle)
126
- r2.font.size = SIZES["subtitle"]
127
- r2.font.color.rgb = hex_to_rgb(self.brand["muted_color"])
128
- r2.font.name = self.brand["font_heading"]
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
- p3 = self.doc.add_paragraph()
132
- r3 = p3.add_run(" · ".join([x for x in [author, date_str] if x]))
133
- r3.font.size = SIZES["small"]
134
- r3.font.color.rgb = hex_to_rgb(self.brand["muted_color"])
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
- style = "List Number" if numbered else "List Bullet"
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, h in enumerate(headers):
346
+ for i, header_text in enumerate(headers):
170
347
  cell = row.cells[i]
171
- cell.text = str(h)
172
- for p in cell.paragraphs:
173
- for run in p.runs:
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
- p_pr = p._p.get_or_add_pPr()
192
- p_bdr = parse_xml(
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"</w:pBdr>"
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
- p_pr.append(p_bdr)
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
- heading = section.get("heading", "")
208
- if heading:
209
- self._add_heading(heading, section.get("level", 1))
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 para in content.split("\n\n"):
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
- self._add_bullets(items, section.get("numbered", False))
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
- with open(sys.argv[1], "r", encoding="utf-8") as f:
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(sys.argv[2])
248
- print(f"Document created: {sys.argv[2]}")
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__":