@nomad-e/bluma-cli 0.1.57 → 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.
- package/README.md +2 -2
- package/dist/config/native_tools.json +3 -3
- package/dist/config/skills/git-pr/SKILL.md +1 -1
- package/dist/config/skills/pdf/SKILL.md +153 -22
- package/dist/config/skills/pdf/scripts/__pycache__/create_report.cpython-312.pyc +0 -0
- package/dist/config/skills/pdf/scripts/create_report.py +607 -209
- package/dist/config/skills/pdf/scripts/merge_pdfs.py +1 -1
- package/dist/config/skills/skill-creator/SKILL.md +1 -1
- package/dist/main.js +117 -126
- package/package.json +1 -1
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
|
53
|
+
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
|
|
20
54
|
from reportlab.platypus import (
|
|
21
|
-
SimpleDocTemplate,
|
|
22
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
43
|
-
FONT_B =
|
|
44
|
-
FONT_I =
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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[
|
|
86
|
-
story.append(Spacer(1,
|
|
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[
|
|
89
|
-
story.append(Paragraph(date.today().strftime(
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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[
|
|
98
|
-
indent =
|
|
298
|
+
style = S["toc_b"] if sec.get("level", 1) == 1 else S["toc"]
|
|
299
|
+
indent = "" if sec.get("level", 1) == 1 else " "
|
|
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
|
-
[
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
[
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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(
|
|
130
|
-
|
|
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=
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
[
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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(
|
|
145
|
-
(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def pro_table(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
163
|
-
(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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,
|
|
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.
|
|
191
|
-
canvas_obj.setStrokeColor(COLORS[
|
|
192
|
-
canvas_obj.setLineWidth(
|
|
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[
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
canvas_obj.
|
|
201
|
-
canvas_obj.
|
|
202
|
-
canvas_obj.
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
|
|
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"• {_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
|
-
"
|
|
268
|
-
"
|
|
269
|
-
"NPS
|
|
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"
|
|
638
|
+
story.append(Paragraph(f"• {_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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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__ ==
|
|
686
|
+
if __name__ == "__main__":
|
|
289
687
|
main()
|