@nomad-e/bluma-cli 0.1.58 → 0.1.59

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.
@@ -3,287 +3,685 @@
3
3
  Professional PDF Report Generator — BluMa Template
4
4
 
5
5
  Usage:
6
+ Demo (sample content):
6
7
  python create_report.py [--title TITLE] [--subtitle SUBTITLE] [--output FILE]
7
8
 
8
- Generates a professional report PDF with cover page, table of contents,
9
- styled sections, tables, and callout boxes.
9
+ Production (your content, same visual system):
10
+ python create_report.py --from-json report.json --output .bluma/artifacts/relatorio.pdf
11
+
12
+ JSON schema (UTF-8 file):
13
+ {
14
+ "title": "Relatório",
15
+ "subtitle": "Subtítulo opcional",
16
+ "author": "Equipe / Cliente (opcional)",
17
+ "page_break_per_section": true,
18
+ "sections": [
19
+ {
20
+ "num": 1,
21
+ "title": "Introdução",
22
+ "blocks": [
23
+ { "type": "paragraph", "text": "Texto com quebras\\nlinha." },
24
+ { "type": "callout", "title": "Destaque", "text": "...", "variant": "info" },
25
+ { "type": "bullets", "items": ["Item A", "Item B"] },
26
+ { "type": "table", "headers": ["Col1", "Col2"], "rows": [["a", "b"]], "col_widths": null }
27
+ ]
28
+ }
29
+ ]
30
+ }
31
+
32
+ variant for callout: "info" | "warning"
33
+ Text in JSON is escaped for XML/HTML safety; use plain text, not ReportLab tags.
34
+
35
+ Editorial guidance for agents (structure, copy, restraint): see pdf/SKILL.md
36
+ section "JSON quality — make the PDF look designed".
10
37
  """
11
38
 
39
+ from __future__ import annotations
40
+
12
41
  import argparse
42
+ import json
43
+ import sys
13
44
  from datetime import date
45
+ from pathlib import Path
46
+ from typing import Any
47
+ from xml.sax.saxutils import escape
14
48
 
15
49
  from reportlab.lib.pagesizes import A4
16
50
  from reportlab.lib.units import cm
17
51
  from reportlab.lib.colors import HexColor
18
52
  from reportlab.lib.styles import ParagraphStyle
19
- from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
53
+ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
20
54
  from reportlab.platypus import (
21
- SimpleDocTemplate, Paragraph, Spacer, PageBreak,
22
- Table, TableStyle, HRFlowable,
55
+ SimpleDocTemplate,
56
+ Paragraph,
57
+ Spacer,
58
+ PageBreak,
59
+ Table,
60
+ TableStyle,
61
+ HRFlowable,
23
62
  )
24
63
 
25
64
 
26
65
  # ── Color Palette ──────────────────────────────────────────────
27
66
 
28
67
  COLORS = {
29
- 'primary': HexColor('#1B2A4A'),
30
- 'secondary': HexColor('#2C5F8A'),
31
- 'accent': HexColor('#E67E22'),
32
- 'text': HexColor('#2D3436'),
33
- 'text_light': HexColor('#636E72'),
34
- 'bg_light': HexColor('#F8F9FA'),
35
- 'bg_accent': HexColor('#EBF5FB'),
36
- 'border': HexColor('#BDC3C7'),
37
- 'white': HexColor('#FFFFFF'),
68
+ "primary": HexColor("#0F172A"), # slate-900 — mais editorial que navy genérico
69
+ "secondary": HexColor("#334155"), # slate-700
70
+ "accent": HexColor("#C2410C"), # laranja queimado — menos “banner de site”
71
+ "muted": HexColor("#64748B"),
72
+ "text": HexColor("#1E293B"),
73
+ "text_light": HexColor("#64748B"),
74
+ "bg_light": HexColor("#F1F5F9"),
75
+ "bg_accent": HexColor("#E0F2FE"),
76
+ "border": HexColor("#CBD5E1"),
77
+ "white": HexColor("#FFFFFF"),
38
78
  }
39
79
 
40
80
  # ── Typography ─────────────────────────────────────────────────
41
81
 
42
- FONT = 'Helvetica'
43
- FONT_B = 'Helvetica-Bold'
44
- FONT_I = 'Helvetica-Oblique'
82
+ FONT = "Helvetica"
83
+ FONT_B = "Helvetica-Bold"
84
+ FONT_I = "Helvetica-Oblique"
45
85
 
46
86
  PAGE_W, PAGE_H = A4
47
87
  M = 2.5 * cm
48
88
  CW = PAGE_W - 2 * M
49
89
 
50
90
  S = {
51
- 'title': ParagraphStyle('T', fontName=FONT_B, fontSize=28, leading=34,
52
- textColor=COLORS['primary'], alignment=TA_CENTER, spaceAfter=6),
53
- 'subtitle': ParagraphStyle('ST', fontName=FONT, fontSize=14, leading=18,
54
- textColor=COLORS['secondary'], alignment=TA_CENTER, spaceAfter=30),
55
- 'h1': ParagraphStyle('H1', fontName=FONT_B, fontSize=20, leading=26,
56
- textColor=COLORS['primary'], spaceBefore=28, spaceAfter=12),
57
- 'h2': ParagraphStyle('H2', fontName=FONT_B, fontSize=15, leading=20,
58
- textColor=COLORS['secondary'], spaceBefore=20, spaceAfter=8),
59
- 'body': ParagraphStyle('B', fontName=FONT, fontSize=10.5, leading=15,
60
- textColor=COLORS['text'], alignment=TA_JUSTIFY,
61
- spaceBefore=2, spaceAfter=8),
62
- 'bullet': ParagraphStyle('BL', fontName=FONT, fontSize=10.5, leading=15,
63
- textColor=COLORS['text'], leftIndent=24, bulletIndent=12,
64
- spaceBefore=2, spaceAfter=4),
65
- 'meta': ParagraphStyle('M', fontName=FONT, fontSize=11,
66
- textColor=COLORS['text_light'], alignment=TA_CENTER, spaceAfter=4),
67
- 'date': ParagraphStyle('D', fontName=FONT, fontSize=10,
68
- textColor=COLORS['secondary'], alignment=TA_CENTER),
69
- 'toc': ParagraphStyle('TOC', fontName=FONT, fontSize=11, leading=20,
70
- textColor=COLORS['text']),
71
- 'toc_b': ParagraphStyle('TOCB', fontName=FONT_B, fontSize=11, leading=20,
72
- textColor=COLORS['primary']),
91
+ "title": ParagraphStyle(
92
+ "T",
93
+ fontName=FONT_B,
94
+ fontSize=26,
95
+ leading=31,
96
+ textColor=COLORS["primary"],
97
+ alignment=TA_CENTER,
98
+ spaceAfter=8,
99
+ ),
100
+ "subtitle": ParagraphStyle(
101
+ "ST",
102
+ fontName=FONT,
103
+ fontSize=13,
104
+ leading=17,
105
+ textColor=COLORS["secondary"],
106
+ alignment=TA_CENTER,
107
+ spaceAfter=6,
108
+ ),
109
+ "h1": ParagraphStyle(
110
+ "H1",
111
+ fontName=FONT_B,
112
+ fontSize=18,
113
+ leading=24,
114
+ textColor=COLORS["primary"],
115
+ spaceBefore=22,
116
+ spaceAfter=10,
117
+ ),
118
+ # Cabeçalho de capítulo (sem spaceBefore — evita “buraco” dentro da tabela)
119
+ "h1_sec": ParagraphStyle(
120
+ "H1Sec",
121
+ fontName=FONT_B,
122
+ fontSize=17,
123
+ leading=22,
124
+ textColor=COLORS["primary"],
125
+ spaceBefore=0,
126
+ spaceAfter=0,
127
+ ),
128
+ "h2": ParagraphStyle(
129
+ "H2",
130
+ fontName=FONT_B,
131
+ fontSize=13,
132
+ leading=18,
133
+ textColor=COLORS["secondary"],
134
+ spaceBefore=16,
135
+ spaceAfter=6,
136
+ ),
137
+ "body": ParagraphStyle(
138
+ "B",
139
+ fontName=FONT,
140
+ fontSize=10.5,
141
+ leading=15,
142
+ textColor=COLORS["text"],
143
+ alignment=TA_JUSTIFY,
144
+ spaceBefore=2,
145
+ spaceAfter=8,
146
+ ),
147
+ "bullet": ParagraphStyle(
148
+ "BL",
149
+ fontName=FONT,
150
+ fontSize=10.5,
151
+ leading=15,
152
+ textColor=COLORS["text"],
153
+ leftIndent=18,
154
+ bulletIndent=8,
155
+ spaceBefore=1,
156
+ spaceAfter=3,
157
+ ),
158
+ "meta": ParagraphStyle(
159
+ "M",
160
+ fontName=FONT,
161
+ fontSize=10,
162
+ textColor=COLORS["text_light"],
163
+ alignment=TA_CENTER,
164
+ spaceAfter=4,
165
+ ),
166
+ "date": ParagraphStyle(
167
+ "D",
168
+ fontName=FONT,
169
+ fontSize=9.5,
170
+ textColor=COLORS["muted"],
171
+ alignment=TA_CENTER,
172
+ ),
173
+ "toc": ParagraphStyle(
174
+ "TOC",
175
+ fontName=FONT,
176
+ fontSize=10.5,
177
+ leading=18,
178
+ textColor=COLORS["text"],
179
+ ),
180
+ "toc_b": ParagraphStyle(
181
+ "TOCB",
182
+ fontName=FONT_B,
183
+ fontSize=10.5,
184
+ leading=18,
185
+ textColor=COLORS["primary"],
186
+ ),
73
187
  }
74
188
 
75
189
 
76
- # ── Components ─────────────────────────────────────────────────
77
-
78
- def cover(story, title, subtitle=None, author=None):
79
- story.append(Spacer(1, 6 * cm))
80
- story.append(Paragraph(title, S['title']))
81
- story.append(Spacer(1, 0.5 * cm))
82
- story.append(HRFlowable(width='40%', thickness=2, color=COLORS['accent'],
83
- spaceAfter=20, spaceBefore=10))
190
+ def _ptext(s: str) -> str:
191
+ """Plain text → safe Paragraph markup (preserva quebras como <br/>)."""
192
+ if s is None:
193
+ return ""
194
+ return escape(str(s)).replace("\n", "<br/>")
195
+
196
+
197
+ def _cover_title_paragraph(title: str) -> Paragraph:
198
+ """Título da capa: tamanho adaptável + quebra em duas linhas equilibradas."""
199
+ raw = (title or "").strip()
200
+ if not raw:
201
+ return Paragraph("", S["title"])
202
+ if "\n" in raw:
203
+ return Paragraph(_ptext(raw), S["title"])
204
+ n = len(raw)
205
+ if n > 56:
206
+ fs, ld = 17, 21
207
+ elif n > 42:
208
+ fs, ld = 20, 25
209
+ else:
210
+ fs, ld = 26, 31
211
+ sty = ParagraphStyle(
212
+ "TCoverDyn",
213
+ fontName=FONT_B,
214
+ fontSize=fs,
215
+ leading=ld,
216
+ textColor=COLORS["primary"],
217
+ alignment=TA_CENTER,
218
+ spaceAfter=8,
219
+ )
220
+ # Duas linhas com comprimento o mais equilibrado possível (evita órfãos)
221
+ if n > 28 and " " in raw:
222
+ words = raw.split()
223
+ if len(words) >= 2:
224
+ best_cut, best_diff = 1, 10**9
225
+ for cut in range(1, len(words)):
226
+ a = len(" ".join(words[:cut]))
227
+ b = len(" ".join(words[cut:]))
228
+ diff = abs(a - b)
229
+ if diff < best_diff:
230
+ best_diff = diff
231
+ best_cut = cut
232
+ last_w = words[best_cut]
233
+ if best_cut > 1 and len(words[best_cut:]) == 1 and len(last_w) <= 10:
234
+ best_cut -= 1
235
+ if 1 <= best_cut < len(words):
236
+ line1 = " ".join(words[:best_cut])
237
+ line2 = " ".join(words[best_cut:])
238
+ markup = _ptext(line1) + "<br/>" + _ptext(line2)
239
+ return Paragraph(markup, sty)
240
+ return Paragraph(_ptext(raw), sty)
241
+
242
+
243
+ def _normalize_col_widths(
244
+ widths: list[float] | None, ncols: int
245
+ ) -> list[float]:
246
+ if not widths or len(widths) != ncols:
247
+ return [CW / ncols] * ncols
248
+ s = sum(float(w) for w in widths)
249
+ if s <= 0:
250
+ return [CW / ncols] * ncols
251
+ scale = CW / s
252
+ return [float(w) * scale for w in widths]
253
+
254
+
255
+ # ── Cover (story + canvas) ─────────────────────────────────────
256
+
257
+
258
+ def cover_story(story: list, title: str, subtitle: str | None, author: str | None) -> None:
259
+ story.append(Spacer(1, 5.5 * cm))
260
+ story.append(_cover_title_paragraph(title))
84
261
  if subtitle:
85
- story.append(Paragraph(subtitle, S['subtitle']))
86
- story.append(Spacer(1, 3 * cm))
262
+ story.append(Paragraph(_ptext(subtitle), S["subtitle"]))
263
+ story.append(Spacer(1, 0.4 * cm))
264
+ story.append(
265
+ HRFlowable(
266
+ width="32%",
267
+ thickness=1.5,
268
+ color=COLORS["accent"],
269
+ spaceAfter=22,
270
+ spaceBefore=6,
271
+ )
272
+ )
273
+ story.append(Spacer(1, 2.8 * cm))
87
274
  if author:
88
- story.append(Paragraph(author, S['meta']))
89
- story.append(Paragraph(date.today().strftime('%B %Y'), S['date']))
275
+ story.append(Paragraph(_ptext(author), S["meta"]))
276
+ story.append(Paragraph(_ptext(date.today().strftime("%d %B %Y")), S["date"]))
90
277
  story.append(PageBreak())
91
278
 
92
279
 
93
- def toc(story, sections):
94
- story.append(Paragraph("Table of Contents", S['h1']))
95
- story.append(Spacer(1, 0.5 * cm))
280
+ def draw_cover_canvas(canvas_obj, doc) -> None:
281
+ """Barra lateral de marca + filete superior — só na capa (texto segue no fluxo)."""
282
+ canvas_obj.saveState()
283
+ canvas_obj.setFillColor(COLORS["primary"])
284
+ canvas_obj.rect(0, 0, 0.55 * cm, PAGE_H, fill=1, stroke=0)
285
+ canvas_obj.setStrokeColor(COLORS["accent"])
286
+ canvas_obj.setLineWidth(2.5)
287
+ canvas_obj.line(M, PAGE_H - M, PAGE_W - M, PAGE_H - M)
288
+ canvas_obj.restoreState()
289
+
290
+
291
+ # ── TOC & body ─────────────────────────────────────────────────
292
+
293
+
294
+ def toc(story: list, sections: list[dict[str, Any]]) -> None:
295
+ story.append(Paragraph("Sumário", S["h1"]))
296
+ story.append(Spacer(1, 0.35 * cm))
96
297
  for sec in sections:
97
- style = S['toc_b'] if sec.get('level', 1) == 1 else S['toc']
98
- indent = '' if sec.get('level', 1) == 1 else '&nbsp;&nbsp;&nbsp;&nbsp;'
298
+ style = S["toc_b"] if sec.get("level", 1) == 1 else S["toc"]
299
+ indent = "" if sec.get("level", 1) == 1 else "&nbsp;&nbsp;&nbsp;&nbsp;"
300
+ num = sec.get("num", "")
301
+ title = sec["title"]
302
+ if sec.get("level", 1) == 1 and num != "":
303
+ left = f"{indent}{num}. {title}"
304
+ else:
305
+ left = f"{indent}{title}"
99
306
  row = Table(
100
- [[Paragraph(f"{indent}{sec['num']}. {sec['title']}" if sec.get('level', 1) == 1
101
- else f"{indent}{sec['title']}", style),
102
- Paragraph(str(sec.get('page', '')), style)]],
103
- colWidths=[CW - 40, 40],
307
+ [
308
+ [
309
+ Paragraph(left, style),
310
+ Paragraph(str(sec.get("page", "")), style),
311
+ ]
312
+ ],
313
+ colWidths=[CW - 36, 36],
314
+ )
315
+ row.setStyle(
316
+ TableStyle(
317
+ [
318
+ ("ALIGN", (1, 0), (1, 0), "RIGHT"),
319
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
320
+ ("LINEBELOW", (0, 0), (-1, -1), 0.35, COLORS["border"]),
321
+ ("TOPPADDING", (0, 0), (-1, -1), 5),
322
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
323
+ ]
324
+ )
104
325
  )
105
- row.setStyle(TableStyle([
106
- ('ALIGN', (1, 0), (1, 0), 'RIGHT'),
107
- ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
108
- ('LINEBELOW', (0, 0), (-1, -1), 0.5, COLORS['bg_light']),
109
- ('TOPPADDING', (0, 0), (-1, -1), 4),
110
- ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
111
- ]))
112
326
  story.append(row)
113
327
  story.append(PageBreak())
114
328
 
115
329
 
116
- def section(story, num, title):
330
+ def section(story: list, num: int | str, title: str) -> None:
117
331
  header = Table(
118
- [[Paragraph(f"{num}.", ParagraphStyle('SN', fontName=FONT_B, fontSize=20,
119
- textColor=COLORS['accent'])),
120
- Paragraph(title, S['h1'])]],
121
- colWidths=[35, CW - 35],
332
+ [
333
+ [
334
+ Paragraph(
335
+ f"{num}.",
336
+ ParagraphStyle(
337
+ "SN",
338
+ fontName=FONT_B,
339
+ fontSize=18,
340
+ textColor=COLORS["accent"],
341
+ ),
342
+ ),
343
+ Paragraph(_ptext(title), S["h1_sec"]),
344
+ ]
345
+ ],
346
+ colWidths=[32, CW - 32],
347
+ )
348
+ header.setStyle(
349
+ TableStyle(
350
+ [
351
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
352
+ ("TOPPADDING", (0, 0), (-1, -1), 0),
353
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
354
+ ]
355
+ )
122
356
  )
123
- header.setStyle(TableStyle([
124
- ('VALIGN', (0, 0), (-1, -1), 'TOP'),
125
- ('TOPPADDING', (0, 0), (-1, -1), 0),
126
- ('BOTTOMPADDING', (0, 0), (-1, -1), 0),
127
- ]))
128
357
  story.append(header)
129
- story.append(HRFlowable(width='100%', thickness=1, color=COLORS['secondary'],
130
- spaceAfter=12))
358
+ story.append(
359
+ HRFlowable(
360
+ width="100%",
361
+ thickness=0.75,
362
+ color=COLORS["border"],
363
+ spaceAfter=12,
364
+ )
365
+ )
131
366
 
132
367
 
133
- def callout(story, title, text, box_type='info'):
134
- bg = COLORS['bg_accent'] if box_type == 'info' else COLORS['bg_light']
135
- border = COLORS['secondary'] if box_type == 'info' else COLORS['accent']
368
+ def callout(story: list, title: str, text: str, box_type: str = "info") -> None:
369
+ box_type = box_type if box_type in ("info", "warning") else "info"
370
+ bg = COLORS["bg_accent"] if box_type == "info" else COLORS["bg_light"]
371
+ border = COLORS["secondary"] if box_type == "info" else COLORS["accent"]
372
+ label = "Informação" if box_type == "info" else "Atenção"
136
373
  content = [
137
- [Paragraph(f"<b>{title}</b>",
138
- ParagraphStyle('CT', fontName=FONT_B, fontSize=10,
139
- textColor=COLORS['primary'], spaceAfter=4))],
140
- [Paragraph(text, ParagraphStyle('CB', fontName=FONT, fontSize=9.5,
141
- leading=14, textColor=COLORS['text']))],
374
+ [
375
+ Paragraph(
376
+ f"<b>{_ptext(label)} — {_ptext(title)}</b>",
377
+ ParagraphStyle(
378
+ "CT",
379
+ fontName=FONT_B,
380
+ fontSize=9.5,
381
+ textColor=COLORS["primary"],
382
+ spaceAfter=4,
383
+ ),
384
+ )
385
+ ],
386
+ [
387
+ Paragraph(
388
+ _ptext(text),
389
+ ParagraphStyle(
390
+ "CB",
391
+ fontName=FONT,
392
+ fontSize=9.5,
393
+ leading=14,
394
+ textColor=COLORS["text"],
395
+ ),
396
+ )
397
+ ],
142
398
  ]
143
399
  t = Table(content, colWidths=[CW - 20])
144
- t.setStyle(TableStyle([
145
- ('BACKGROUND', (0, 0), (-1, -1), bg),
146
- ('BOX', (0, 0), (-1, -1), 1.5, border),
147
- ('LEFTPADDING', (0, 0), (-1, -1), 14),
148
- ('RIGHTPADDING', (0, 0), (-1, -1), 14),
149
- ('TOPPADDING', (0, 0), (0, 0), 10),
150
- ('BOTTOMPADDING', (-1, -1), (-1, -1), 10),
151
- ]))
152
- story.append(Spacer(1, 8))
400
+ t.setStyle(
401
+ TableStyle(
402
+ [
403
+ ("BACKGROUND", (0, 0), (-1, -1), bg),
404
+ ("BOX", (0, 0), (-1, -1), 0.85, border),
405
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
406
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
407
+ ("TOPPADDING", (0, 0), (0, 0), 9),
408
+ ("BOTTOMPADDING", (-1, -1), (-1, -1), 9),
409
+ ]
410
+ )
411
+ )
412
+ story.append(Spacer(1, 6))
153
413
  story.append(t)
154
- story.append(Spacer(1, 8))
155
-
156
-
157
- def pro_table(story, headers, rows, col_widths=None):
158
- data = [headers] + rows
159
- if not col_widths:
160
- col_widths = [CW / len(headers)] * len(headers)
414
+ story.append(Spacer(1, 6))
415
+
416
+
417
+ def pro_table(
418
+ story: list,
419
+ headers: list[str],
420
+ rows: list[list[str]],
421
+ col_widths: list[float] | None = None,
422
+ ) -> None:
423
+ h_esc = [_ptext(h) for h in headers]
424
+ data = [h_esc] + [[_ptext(c) for c in row] for row in rows]
425
+ col_widths = _normalize_col_widths(col_widths, len(headers))
161
426
  t = Table(data, colWidths=col_widths, repeatRows=1)
162
- t.setStyle(TableStyle([
163
- ('BACKGROUND', (0, 0), (-1, 0), COLORS['primary']),
164
- ('TEXTCOLOR', (0, 0), (-1, 0), COLORS['white']),
165
- ('FONTNAME', (0, 0), (-1, 0), FONT_B),
166
- ('FONTSIZE', (0, 0), (-1, 0), 10),
167
- ('BOTTOMPADDING', (0, 0), (-1, 0), 10),
168
- ('TOPPADDING', (0, 0), (-1, 0), 10),
169
- ('FONTNAME', (0, 1), (-1, -1), FONT),
170
- ('FONTSIZE', (0, 1), (-1, -1), 9.5),
171
- ('TEXTCOLOR', (0, 1), (-1, -1), COLORS['text']),
172
- ('TOPPADDING', (0, 1), (-1, -1), 7),
173
- ('BOTTOMPADDING', (0, 1), (-1, -1), 7),
174
- *[('BACKGROUND', (0, i), (-1, i), COLORS['bg_light'])
175
- for i in range(1, len(data), 2)],
176
- ('LINEBELOW', (0, 0), (-1, 0), 1.5, COLORS['secondary']),
177
- ('LINEBELOW', (0, 1), (-1, -2), 0.5, COLORS['border']),
178
- ('LINEBELOW', (0, -1), (-1, -1), 1, COLORS['secondary']),
179
- ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
180
- ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
181
- ('LEFTPADDING', (0, 0), (-1, -1), 10),
182
- ('RIGHTPADDING', (0, 0), (-1, -1), 10),
183
- ]))
427
+ t.setStyle(
428
+ TableStyle(
429
+ [
430
+ ("BACKGROUND", (0, 0), (-1, 0), COLORS["primary"]),
431
+ ("TEXTCOLOR", (0, 0), (-1, 0), COLORS["white"]),
432
+ ("FONTNAME", (0, 0), (-1, 0), FONT_B),
433
+ ("FONTSIZE", (0, 0), (-1, 0), 9.5),
434
+ ("BOTTOMPADDING", (0, 0), (-1, 0), 9),
435
+ ("TOPPADDING", (0, 0), (-1, 0), 9),
436
+ ("FONTNAME", (0, 1), (-1, -1), FONT),
437
+ ("FONTSIZE", (0, 1), (-1, -1), 9),
438
+ ("TEXTCOLOR", (0, 1), (-1, -1), COLORS["text"]),
439
+ ("TOPPADDING", (0, 1), (-1, -1), 6),
440
+ ("BOTTOMPADDING", (0, 1), (-1, -1), 6),
441
+ *[
442
+ ("BACKGROUND", (0, i), (-1, i), COLORS["bg_light"])
443
+ for i in range(1, len(data), 2)
444
+ ],
445
+ ("LINEBELOW", (0, 0), (-1, 0), 1, COLORS["accent"]),
446
+ ("LINEBELOW", (0, 1), (-1, -2), 0.25, COLORS["border"]),
447
+ ("LINEBELOW", (0, -1), (-1, -1), 0.75, COLORS["secondary"]),
448
+ ("ALIGN", (0, 0), (-1, -1), "LEFT"),
449
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
450
+ ("LEFTPADDING", (0, 0), (-1, -1), 9),
451
+ ("RIGHTPADDING", (0, 0), (-1, -1), 9),
452
+ ]
453
+ )
454
+ )
455
+ story.append(Spacer(1, 4))
184
456
  story.append(t)
185
- story.append(Spacer(1, 12))
457
+ story.append(Spacer(1, 10))
186
458
 
187
459
 
188
- def header_footer(canvas_obj, doc):
460
+ def header_footer(canvas_obj, doc) -> None:
189
461
  canvas_obj.saveState()
190
- y_h = PAGE_H - 1.5 * cm
191
- canvas_obj.setStrokeColor(COLORS['secondary'])
192
- canvas_obj.setLineWidth(1.5)
462
+ y_h = PAGE_H - 1.35 * cm
463
+ canvas_obj.setStrokeColor(COLORS["border"])
464
+ canvas_obj.setLineWidth(0.6)
193
465
  canvas_obj.line(M, y_h, PAGE_W - M, y_h)
194
466
  canvas_obj.setFont(FONT, 8)
195
- canvas_obj.setFillColor(COLORS['text_light'])
196
- canvas_obj.drawString(M, y_h + 4, doc.title or '')
197
-
198
- y_f = 1.2 * cm
199
- canvas_obj.setStrokeColor(COLORS['border'])
200
- canvas_obj.setLineWidth(0.5)
201
- canvas_obj.line(M, y_f + 8, PAGE_W - M, y_f + 8)
202
- canvas_obj.drawCentredString(PAGE_W / 2, y_f, f"Page {doc.page}")
467
+ canvas_obj.setFillColor(COLORS["text_light"])
468
+ title = (doc.title or "")[:90]
469
+ canvas_obj.drawString(M, y_h + 3, title)
470
+
471
+ y_f = 1.05 * cm
472
+ canvas_obj.setStrokeColor(COLORS["border"])
473
+ canvas_obj.setLineWidth(0.35)
474
+ canvas_obj.line(M, y_f + 10, PAGE_W - M, y_f + 10)
475
+ canvas_obj.setFont(FONT, 8)
476
+ canvas_obj.setFillColor(COLORS["muted"])
477
+ canvas_obj.drawCentredString(PAGE_W / 2, y_f, f"Página {doc.page}")
203
478
  canvas_obj.restoreState()
204
479
 
205
480
 
206
- # ── Main ───────────────────────────────────────────────────────
481
+ def on_first_page(canvas_obj, doc) -> None:
482
+ draw_cover_canvas(canvas_obj, doc)
483
+
484
+
485
+ def on_later_pages(canvas_obj, doc) -> None:
486
+ header_footer(canvas_obj, doc)
207
487
 
208
- def main():
209
- parser = argparse.ArgumentParser(description='Generate professional PDF report')
210
- parser.add_argument('--title', default='Sample Report')
211
- parser.add_argument('--subtitle', default='A Professional Document')
212
- parser.add_argument('--output', default='report.pdf')
213
- args = parser.parse_args()
488
+
489
+ # ── JSON-driven build ──────────────────────────────────────────
490
+
491
+
492
+ def build_from_json(data: dict[str, Any], output_path: str) -> None:
493
+ title = data.get("title") or "Relatório"
494
+ subtitle = data.get("subtitle")
495
+ author = data.get("author")
496
+ # True = cada capítulo em página nova (relatório executivo); False = fluxo contínuo
497
+ page_per_section = data.get("page_break_per_section", True)
498
+ if not isinstance(page_per_section, bool):
499
+ page_per_section = bool(page_per_section)
500
+
501
+ sections = data.get("sections") or []
502
+ if not isinstance(sections, list):
503
+ raise ValueError('"sections" must be a list')
504
+
505
+ toc_rows: list[dict[str, Any]] = []
506
+ for i, sec in enumerate(sections, start=1):
507
+ if not isinstance(sec, dict):
508
+ continue
509
+ num = sec.get("num", i)
510
+ toc_rows.append({"num": num, "title": sec.get("title", f"Seção {i}"), "level": 1, "page": ""})
214
511
 
215
512
  doc = SimpleDocTemplate(
216
- args.output, pagesize=A4,
217
- leftMargin=M, rightMargin=M, topMargin=M, bottomMargin=M,
218
- title=args.title, author='BluMa',
513
+ output_path,
514
+ pagesize=A4,
515
+ leftMargin=M,
516
+ rightMargin=M,
517
+ topMargin=M,
518
+ bottomMargin=M,
519
+ title=title,
520
+ author="BluMa",
219
521
  )
220
522
 
221
- story = []
222
-
223
- cover(story, args.title, args.subtitle, "Generated by BluMa")
224
-
225
- toc(story, [
226
- {'num': 1, 'title': 'Introduction', 'level': 1, 'page': 3},
227
- {'num': 2, 'title': 'Key Findings', 'level': 1, 'page': 4},
228
- {'num': 3, 'title': 'Conclusion', 'level': 1, 'page': 5},
229
- ])
230
-
231
- section(story, 1, "Introduction")
232
- story.append(Paragraph(
233
- "This document demonstrates the professional PDF generation capabilities "
234
- "of BluMa. Every element — from typography to color palette — follows a "
235
- "consistent design system that ensures readability and visual appeal.",
236
- S['body'],
237
- ))
238
- story.append(Paragraph(
239
- "The design system uses a navy and steel blue palette with warm orange "
240
- "accents, Helvetica typography with a clear hierarchy, and generous "
241
- "whitespace for a clean, modern look.",
242
- S['body'],
243
- ))
244
-
245
- callout(story, "Key Insight",
246
- "Professional documents communicate credibility before the reader "
247
- "processes a single word. Design is the first impression.")
248
-
249
- section(story, 2, "Key Findings")
250
- story.append(Paragraph("Analysis results are summarized in the table below:", S['body']))
251
- pro_table(story,
252
- ['Metric', 'Q1 2026', 'Q2 2026', 'Change'],
253
- [
254
- ['Revenue', '$1.2M', '$1.5M', '+25%'],
255
- ['Active Users', '45,000', '58,000', '+29%'],
256
- ['Retention', '82%', '87%', '+5pp'],
257
- ['NPS Score', '42', '56', '+14'],
258
- ])
259
- story.append(Paragraph(
260
- "All metrics show significant improvement quarter-over-quarter, "
261
- "driven primarily by the new onboarding flow and retention campaigns.",
262
- S['body'],
263
- ))
264
-
265
- story.append(Paragraph("Key highlights:", S['body']))
523
+ story: list = []
524
+ cover_story(story, title, subtitle, author)
525
+ toc(story, toc_rows)
526
+
527
+ first_section = True
528
+ for sec in sections:
529
+ if not isinstance(sec, dict):
530
+ continue
531
+ num = sec.get("num", 0)
532
+ sec_title = sec.get("title", "")
533
+ if page_per_section and not first_section:
534
+ story.append(PageBreak())
535
+ first_section = False
536
+
537
+ blocks = [b for b in (sec.get("blocks") or []) if isinstance(b, dict)]
538
+ section(story, num, sec_title)
539
+ for block in blocks:
540
+ btype = block.get("type")
541
+ if btype == "paragraph":
542
+ story.append(Paragraph(_ptext(block.get("text", "")), S["body"]))
543
+ elif btype == "h2":
544
+ story.append(Paragraph(_ptext(block.get("text", "")), S["h2"]))
545
+ elif btype == "callout":
546
+ callout(
547
+ story,
548
+ block.get("title", ""),
549
+ block.get("text", ""),
550
+ block.get("variant", "info"),
551
+ )
552
+ elif btype == "bullets":
553
+ for item in block.get("items") or []:
554
+ story.append(Paragraph(f"&bull;&nbsp; {_ptext(item)}", S["bullet"]))
555
+ elif btype == "table":
556
+ headers = block.get("headers") or []
557
+ rows = block.get("rows") or []
558
+ cw = block.get("col_widths")
559
+ pro_table(story, list(headers), [list(r) for r in rows], cw)
560
+ elif btype == "spacer":
561
+ story.append(Spacer(1, float(block.get("pt", 12))))
562
+
563
+ story.append(Spacer(1, 14))
564
+
565
+ doc.build(story, onFirstPage=on_first_page, onLaterPages=on_later_pages)
566
+ print(f"Report generated: {output_path}", file=sys.stderr)
567
+
568
+
569
+ def demo_report(args: argparse.Namespace) -> None:
570
+ doc = SimpleDocTemplate(
571
+ args.output,
572
+ pagesize=A4,
573
+ leftMargin=M,
574
+ rightMargin=M,
575
+ topMargin=M,
576
+ bottomMargin=M,
577
+ title=args.title,
578
+ author="BluMa",
579
+ )
580
+
581
+ story: list = []
582
+ cover_story(story, args.title, args.subtitle, "Gerado por BluMa")
583
+
584
+ toc(
585
+ story,
586
+ [
587
+ {"num": 1, "title": "Introdução", "level": 1, "page": ""},
588
+ {"num": 2, "title": "Achados", "level": 1, "page": ""},
589
+ {"num": 3, "title": "Conclusão", "level": 1, "page": ""},
590
+ ],
591
+ )
592
+
593
+ section(story, 1, "Introdução")
594
+ story.append(
595
+ Paragraph(
596
+ _ptext(
597
+ "Este modelo define tipografia, margens, tabelas e caixas de destaque "
598
+ "para que o PDF pareça um relatório corporativo, não um print de terminal."
599
+ ),
600
+ S["body"],
601
+ )
602
+ )
603
+ story.append(
604
+ Paragraph(
605
+ _ptext(
606
+ "Paleta slate + laranja queimado, hierarquia clara e espaço em branco "
607
+ "intencional reduzem a sensação de documento “gerado por script”."
608
+ ),
609
+ S["body"],
610
+ )
611
+ )
612
+ callout(
613
+ story,
614
+ "Leitura rápida",
615
+ "Use o modo --from-json para manter este visual com o seu conteúdo, "
616
+ "sem reimplementar estilos no improviso.",
617
+ "info",
618
+ )
619
+
620
+ story.append(PageBreak())
621
+ section(story, 2, "Achados")
622
+ story.append(Paragraph(_ptext("Resumo numérico:"), S["body"]))
623
+ pro_table(
624
+ story,
625
+ ["Métrica", "Q1 2026", "Q2 2026", "Var."],
626
+ [
627
+ ["Receita", "R$ 1,2M", "R$ 1,5M", "+25%"],
628
+ ["Usuários ativos", "45.000", "58.000", "+29%"],
629
+ ["Retenção", "82%", "87%", "+5pp"],
630
+ ],
631
+ )
632
+ story.append(Paragraph(_ptext("Destaques:"), S["body"]))
266
633
  for item in [
267
- "Revenue growth exceeded targets by 10 percentage points",
268
- "User retention improved across all cohorts",
269
- "NPS score moved from 'good' to 'excellent' territory",
634
+ "Crescimento acima da meta em onboarding",
635
+ "Retenção estável em todas as coortes",
636
+ "NPS em faixa “excelente”",
270
637
  ]:
271
- story.append(Paragraph(f"{item}", S['bullet']))
638
+ story.append(Paragraph(f"&bull;&nbsp; {_ptext(item)}", S["bullet"]))
639
+ callout(
640
+ story,
641
+ "Metodologia",
642
+ "Comparativos homólogos, ajustados por sazonalidade quando aplicável.",
643
+ "warning",
644
+ )
272
645
 
273
- callout(story, "Note", "Growth rates are compared to the same period in the "
274
- "prior fiscal year, adjusted for seasonality.", box_type='warning')
646
+ story.append(PageBreak())
647
+ section(story, 3, "Conclusão")
648
+ story.append(
649
+ Paragraph(
650
+ _ptext(
651
+ "Recomenda-se manter as iniciativas em curso e acompanhar CAC com "
652
+ "corte mensal nos canais de aquisição."
653
+ ),
654
+ S["body"],
655
+ )
656
+ )
657
+
658
+ doc.build(story, onFirstPage=on_first_page, onLaterPages=on_later_pages)
659
+ print(f"Report generated: {args.output}", file=sys.stderr)
275
660
 
276
- section(story, 3, "Conclusion")
277
- story.append(Paragraph(
278
- "The data confirms that the strategic initiatives launched in Q4 2025 "
279
- "are delivering measurable results. We recommend continuing the current "
280
- "trajectory while monitoring customer acquisition costs closely.",
281
- S['body'],
282
- ))
283
661
 
284
- doc.build(story, onFirstPage=lambda c, d: None, onLaterPages=header_footer)
285
- print(f"Report generated: {args.output}")
662
+ def main() -> None:
663
+ parser = argparse.ArgumentParser(description="Generate professional PDF report (BluMa)")
664
+ parser.add_argument("--title", default="Relatório")
665
+ parser.add_argument("--subtitle", default="Documento executivo")
666
+ parser.add_argument("--output", default="report.pdf")
667
+ parser.add_argument(
668
+ "--from-json",
669
+ dest="from_json",
670
+ metavar="FILE",
671
+ help="Build from structured JSON (recommended for real deliverables)",
672
+ )
673
+ args = parser.parse_args()
674
+
675
+ if args.from_json:
676
+ path = Path(args.from_json)
677
+ if not path.is_file():
678
+ print(f"File not found: {path}", file=sys.stderr)
679
+ sys.exit(1)
680
+ data = json.loads(path.read_text(encoding="utf-8"))
681
+ build_from_json(data, args.output)
682
+ else:
683
+ demo_report(args)
286
684
 
287
685
 
288
- if __name__ == '__main__':
686
+ if __name__ == "__main__":
289
687
  main()