@nomad-e/bluma-cli 0.8.0 → 0.9.0
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.
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pdf
|
|
3
3
|
description: >
|
|
4
|
-
Use for any PDF task. For
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
OCR/forms. Triggers: .pdf, relatório, report, generate document, merge PDF.
|
|
4
|
+
Use for any PDF task. For reports to mixed audiences (technical + lay readers),
|
|
5
|
+
set audience "mixed" and use plain_language + technical_note callouts per section.
|
|
6
|
+
For new reports prefer create_report.py --from-json with rich JSON (see
|
|
7
|
+
Comunicação técnico-científica in SKILL). Ad-hoc ReportLab = amateur PDFs.
|
|
8
|
+
Also extract/merge/OCR/forms. Triggers: .pdf, relatório, report, documento técnico.
|
|
10
9
|
license: Proprietary. LICENSE.txt has complete terms
|
|
11
10
|
---
|
|
12
11
|
|
|
@@ -33,14 +32,28 @@ the user will share externally.
|
|
|
33
32
|
|
|
34
33
|
**Preferred (deliverable quality locked in):**
|
|
35
34
|
|
|
36
|
-
1.
|
|
35
|
+
1. Read **references/DOCUMENT_TYPES.md** and set **`document_type`** (and
|
|
36
|
+
**`letterhead`** when the user wants papel timbrado / cabeçalho corporativo).
|
|
37
|
+
2. Build a UTF-8 JSON file with `title`, optional `subtitle` / `author`, and
|
|
37
38
|
`sections[]` (`num`, `title`, `blocks[]`). Follow the **JSON quality**
|
|
38
39
|
section below — not only the schema (see **Available Scripts** for types).
|
|
39
|
-
|
|
40
|
+
3. Run: `python /absolute/path/to/scripts/create_report.py --from-json
|
|
40
41
|
your.json --output .bluma/artifacts/your.pdf` (or the path in `artifacts_dir` from `task_boundary`)
|
|
41
|
-
|
|
42
|
+
4. Attach that **absolute** path as the deliverable. Do **not** paste huge
|
|
42
43
|
ReportLab source into the chat unless the user explicitly wants code.
|
|
43
44
|
|
|
45
|
+
### Pick the layout — do not use one template for everything
|
|
46
|
+
|
|
47
|
+
| User asks for | `document_type` | Notes |
|
|
48
|
+
|---------------|-----------------|-------|
|
|
49
|
+
| Relatório executivo, board, entrega formal | `executive_report` | Capa + sumário; quebras só em capítulos densos |
|
|
50
|
+
| Inventário hardware, specs, sistema | `technical` | Fluxo contínuo; tabelas com texto resumido (não IPv6 novel) |
|
|
51
|
+
| Carta, proposta, documento com marca | `letterhead` | Preencher `letterhead.organization`, morada, `logo_path` opcional |
|
|
52
|
+
| Nota curta | `memo` | Sem sumário |
|
|
53
|
+
| Artigo técnico, MCP, whitepaper | `article` | Sem capítulos numerados excessivos |
|
|
54
|
+
|
|
55
|
+
Override any preset with top-level `layout: { "include_toc": false, "page_break_per_section": false }`.
|
|
56
|
+
|
|
44
57
|
**Acceptable alternative:** copy `create_report.py` and edit only data / story
|
|
45
58
|
assembly — keep the same colors, margins, `cover_story`, `section`, `pro_table`,
|
|
46
59
|
`callout`, and `onFirstPage` / `onLaterPages` callbacks.
|
|
@@ -50,6 +63,67 @@ with no cover; tables without a styled header row; margins under 2cm; emoji in
|
|
|
50
63
|
corporate PDFs (font/rendering inconsistency); “nota rodapé” as raw
|
|
51
64
|
`canvas.drawString` for body paragraphs instead of `Paragraph` styles.
|
|
52
65
|
|
|
66
|
+
## Comunicação técnico-científica — público misto (técnico + leigo)
|
|
67
|
+
|
|
68
|
+
Use when the deliverable is a **technical, scientific, or enterprise report** read by
|
|
69
|
+
both specialists and non-specialists (management, clients, audit, mixed teams).
|
|
70
|
+
|
|
71
|
+
Set in JSON: `"audience": "mixed"` (alternatives: `"technical"` = só especialistas;
|
|
72
|
+
`"general"` = priorizar clareza; default implícito em relatórios empresariais:
|
|
73
|
+
**`mixed`** quando o pedido não especificar).
|
|
74
|
+
|
|
75
|
+
### Princípios (obrigatório em `audience: "mixed"`)
|
|
76
|
+
|
|
77
|
+
1. **Integridade dos dados** — números, unidades, nomes de sistemas e conclusões
|
|
78
|
+
factuais **não são simplificados até ficarem errados**. A precisão vem nas
|
|
79
|
+
tabelas e na nota técnica; a clareza vem no resumo em linguagem simples.
|
|
80
|
+
2. **Dupla camada por secção** (ritmo recomendado):
|
|
81
|
+
- 1× **`callout`** com `"variant": "plain_language"` — 2–4 frases: o que isto
|
|
82
|
+
significa para quem não é da área (sem jargão, sem siglas sem explicar).
|
|
83
|
+
- **`paragraph`** ou **`table`** com o detalhe técnico (dados completos mas
|
|
84
|
+
**resumidos** em células — não dumps de log).
|
|
85
|
+
- Opcional: **`callout`** `"variant": "technical_note"` para limitações,
|
|
86
|
+
método, ou definição de um termo (1–3 frases).
|
|
87
|
+
3. **Linguagem** — frases curtas; definir siglas na primeira ocorrência
|
|
88
|
+
(“MCP (Model Context Protocol)”); evitar tom marketing; evitar humor.
|
|
89
|
+
4. **Tabelas** — cabeçalhos claros; na coluna de valor, preferir texto legível
|
|
90
|
+
(“Ubuntu 24.04 LTS”) em vez de strings brutas; mover detalhe excessivo para nota
|
|
91
|
+
ou parágrafo seguinte.
|
|
92
|
+
5. **Não patronizar** — o bloco `plain_language` explica *significado*, não
|
|
93
|
+
repete a tabela palavra por palavra.
|
|
94
|
+
|
|
95
|
+
### Exemplo de blocos numa secção (`audience: "mixed"`)
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"num": 2,
|
|
100
|
+
"title": "Sistema operativo",
|
|
101
|
+
"blocks": [
|
|
102
|
+
{
|
|
103
|
+
"type": "callout",
|
|
104
|
+
"variant": "plain_language",
|
|
105
|
+
"title": "O essencial",
|
|
106
|
+
"text": "O computador corre Ubuntu 24.04, uma versão estável e suportada, em arquitetura de 64 bits. Está ligado há cerca de 10 horas desde o último reinício."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"type": "table",
|
|
110
|
+
"headers": ["Propriedade", "Valor"],
|
|
111
|
+
"rows": [
|
|
112
|
+
["SO", "Ubuntu 24.04.3 LTS"],
|
|
113
|
+
["Kernel", "6.17.x"],
|
|
114
|
+
["Arquitetura", "x64"],
|
|
115
|
+
["Uptime", "~10 h (36 736 s)"]
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Quando NÃO usar `mixed`
|
|
123
|
+
|
|
124
|
+
- Relatório só para engenharia/DevOps interno → `"audience": "technical"`.
|
|
125
|
+
- FAQ ou carta comercial → `memo` / `letterhead`, linguagem já simples.
|
|
126
|
+
|
|
53
127
|
## JSON quality — make the PDF look “designed”, not “filled in”
|
|
54
128
|
|
|
55
129
|
The `--from-json` pipeline already applies a **clean, modern, understated**
|
|
@@ -106,22 +180,31 @@ When you author JSON, treat it like briefing a human designer:
|
|
|
106
180
|
- Omit `col_widths` unless you need to weight columns; the generator normalises
|
|
107
181
|
widths.
|
|
108
182
|
|
|
109
|
-
### `callout` (`variant`: `info` | `warning`)
|
|
183
|
+
### `callout` (`variant`: `info` | `warning` | `plain_language` | `technical_note`)
|
|
110
184
|
|
|
111
185
|
- **Info**: synthesis, definition, or “how to read this document”.
|
|
112
186
|
- **Warning**: real risk, compliance, or “requires action” — not routine stats.
|
|
187
|
+
- **plain_language**: mandatory in `audience: "mixed"` — explains meaning for
|
|
188
|
+
non-specialists (see section above).
|
|
189
|
+
- **technical_note**: precision, caveats, method, or definition — for specialists.
|
|
113
190
|
- **Title**: 2–5 words; **text**: 1–3 sentences. **Never** stack two callouts in
|
|
114
|
-
a row
|
|
191
|
+
a row unless the second is `technical_note` after `plain_language`.
|
|
115
192
|
|
|
116
193
|
### `h2`
|
|
117
194
|
|
|
118
195
|
- Use sparingly inside long sections; keeps hierarchy without visual noise.
|
|
119
196
|
|
|
197
|
+
### `document_type`, `letterhead`, `layout`
|
|
198
|
+
|
|
199
|
+
- **`document_type`**: `executive_report` | `technical` | `letterhead` | `memo` | `article` (see DOCUMENT_TYPES.md).
|
|
200
|
+
- **`letterhead`**: `{ organization, department?, tagline?, address_lines[], contact?, logo_path? }` — obrigatório para timbrado.
|
|
201
|
+
- **`layout`**: `{ include_toc?, page_break_per_section?, cover_style?: corporate|minimal|letterhead }`.
|
|
202
|
+
|
|
120
203
|
### `page_break_per_section`
|
|
121
204
|
|
|
122
|
-
- Default **`
|
|
123
|
-
-
|
|
124
|
-
|
|
205
|
+
- Default **`false`** in the generator (evita páginas quase vazias).
|
|
206
|
+
- `executive_report` preset uses **`true`** only before **dense** sections (tabelas grandes ou muito texto).
|
|
207
|
+
- Inventários técnicos: manter **`false`**.
|
|
125
208
|
|
|
126
209
|
### Restraint checklist (before you run the script)
|
|
127
210
|
|
|
@@ -624,9 +707,9 @@ for i, img in enumerate(images):
|
|
|
624
707
|
- **create_report.py** — default: demo PDF; **production:** `--from-json FILE`
|
|
625
708
|
- Before writing JSON, follow **“JSON quality”** (this file) so content matches
|
|
626
709
|
the clean template — schema alone is not enough.
|
|
627
|
-
- Schema (top-level keys): `title
|
|
628
|
-
`
|
|
629
|
-
`
|
|
710
|
+
- Schema (top-level keys): `title`, `subtitle?`, `author?`, `audience?`
|
|
711
|
+
(`technical` | `mixed` | `general`), `document_type?`, `letterhead?`,
|
|
712
|
+
`layout?`, `page_break_per_section?` (override do preset), `sections[]`.
|
|
630
713
|
- Each section: `num` (int), `title` (str), `blocks` (array).
|
|
631
714
|
- Each block: `type` is one of:
|
|
632
715
|
- `paragraph` — `{ "type": "paragraph", "text": "..." }` (use `\n` for line breaks)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# PDF document types — choose before writing JSON
|
|
2
|
+
|
|
3
|
+
Always run `create_report.py --from-json`. Pick **`document_type`** first; do not improvise ReportLab.
|
|
4
|
+
|
|
5
|
+
| Type | When to use | TOC | Page breaks | Letterhead band |
|
|
6
|
+
|------|-------------|-----|-------------|-----------------|
|
|
7
|
+
| `executive_report` | Relatórios multi-capítulo para gestão (vendas, hardware, auditoria) | yes | yes (secções densas) | no |
|
|
8
|
+
| `technical` | Inventários, specs, dumps estruturados | yes | no (fluxo contínuo) | no |
|
|
9
|
+
| `letterhead` | Cartas, propostas, documentos com marca da empresa | yes | no | yes (cabeçalho em todas as páginas) |
|
|
10
|
+
| `memo` | Notas curtas, 1–2 secções | no | no | no |
|
|
11
|
+
| `article` | Ensaios, MCP, documentação longa sem capítulos executivos | no | no | no |
|
|
12
|
+
|
|
13
|
+
## Letterhead block (required for `document_type: "letterhead"`)
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"document_type": "letterhead",
|
|
18
|
+
"title": "Proposta comercial",
|
|
19
|
+
"subtitle": "Renovação de licenças 2026",
|
|
20
|
+
"author": "Departamento Comercial",
|
|
21
|
+
"letterhead": {
|
|
22
|
+
"organization": "Empresa Exemplo Lda.",
|
|
23
|
+
"department": "Comercial",
|
|
24
|
+
"tagline": "Soluções empresariais",
|
|
25
|
+
"address_lines": ["Rua das Flores, 10", "1000-001 Lisboa"],
|
|
26
|
+
"contact": "comercial@exemplo.pt · +351 210 000 000",
|
|
27
|
+
"logo_path": ".bluma/artifacts/logo.png"
|
|
28
|
+
},
|
|
29
|
+
"sections": [ ... ]
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`logo_path` is optional; file must exist under the workspace.
|
|
34
|
+
|
|
35
|
+
## Público misto (técnico + leigo)
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"document_type": "technical",
|
|
40
|
+
"audience": "mixed",
|
|
41
|
+
"title": "Relatório de Hardware e Sistema",
|
|
42
|
+
"subtitle": "Inventário com leitura acessível",
|
|
43
|
+
...
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Em cada secção: primeiro `callout` `plain_language`, depois tabela/parágrafo técnico.
|
|
48
|
+
Ver secção **Comunicação técnico-científica** em SKILL.md.
|
|
49
|
+
|
|
50
|
+
## Hardware / system inventory → use `technical`
|
|
51
|
+
|
|
52
|
+
- `page_break_per_section`: false (default for technical)
|
|
53
|
+
- 4–6 sections with tables; paraphrase long IPv6/MAC in cells
|
|
54
|
+
- Do not use `executive_report` for raw machine dumps — it wastes pages
|
|
55
|
+
|
|
56
|
+
## MCP / whitepaper → use `article`
|
|
57
|
+
|
|
58
|
+
- No cover theatre; optional short cover via `cover_style: "minimal"` in `layout`
|
|
59
|
+
- Continuous flow; use `h2` inside sections instead of many numbered chapters
|
|
@@ -11,10 +11,16 @@ Usage:
|
|
|
11
11
|
|
|
12
12
|
JSON schema (UTF-8 file):
|
|
13
13
|
{
|
|
14
|
+
"document_type": "technical",
|
|
14
15
|
"title": "Relatório",
|
|
15
16
|
"subtitle": "Subtítulo opcional",
|
|
16
17
|
"author": "Equipe / Cliente (opcional)",
|
|
17
|
-
"
|
|
18
|
+
"letterhead": {
|
|
19
|
+
"organization": "Empresa",
|
|
20
|
+
"address_lines": ["Morada"],
|
|
21
|
+
"logo_path": ".bluma/artifacts/logo.png"
|
|
22
|
+
},
|
|
23
|
+
"layout": { "include_toc": true, "page_break_per_section": false },
|
|
18
24
|
"sections": [
|
|
19
25
|
{
|
|
20
26
|
"num": 1,
|
|
@@ -29,7 +35,8 @@ JSON schema (UTF-8 file):
|
|
|
29
35
|
]
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
variant for callout: "info" | "warning"
|
|
38
|
+
variant for callout: "info" | "warning" | "plain_language" | "technical_note"
|
|
39
|
+
Top-level audience: "technical" | "mixed" | "general"
|
|
33
40
|
Text in JSON is escaped for XML/HTML safety; use plain text, not ReportLab tags.
|
|
34
41
|
|
|
35
42
|
Editorial guidance for agents (structure, copy, restraint): see pdf/SKILL.md
|
|
@@ -200,9 +207,112 @@ S = {
|
|
|
200
207
|
leading=18,
|
|
201
208
|
textColor=COLORS["primary"],
|
|
202
209
|
),
|
|
210
|
+
"cell": ParagraphStyle(
|
|
211
|
+
"Cell",
|
|
212
|
+
fontName=FONT,
|
|
213
|
+
fontSize=8.5,
|
|
214
|
+
leading=11,
|
|
215
|
+
textColor=COLORS["text"],
|
|
216
|
+
alignment=TA_JUSTIFY,
|
|
217
|
+
),
|
|
218
|
+
"letterhead_org": ParagraphStyle(
|
|
219
|
+
"LHOrg",
|
|
220
|
+
fontName=FONT_B,
|
|
221
|
+
fontSize=11,
|
|
222
|
+
leading=14,
|
|
223
|
+
textColor=COLORS["primary"],
|
|
224
|
+
alignment=TA_CENTER,
|
|
225
|
+
spaceAfter=2,
|
|
226
|
+
),
|
|
227
|
+
"letterhead_meta": ParagraphStyle(
|
|
228
|
+
"LHMeta",
|
|
229
|
+
fontName=FONT,
|
|
230
|
+
fontSize=8.5,
|
|
231
|
+
leading=11,
|
|
232
|
+
textColor=COLORS["muted"],
|
|
233
|
+
alignment=TA_CENTER,
|
|
234
|
+
spaceAfter=2,
|
|
235
|
+
),
|
|
203
236
|
}
|
|
204
237
|
|
|
205
238
|
|
|
239
|
+
def _resolve_layout(data: dict[str, Any]) -> dict[str, Any]:
|
|
240
|
+
"""Merge document_type presets with explicit layout / letterhead overrides."""
|
|
241
|
+
doc_type = str(data.get("document_type") or "executive_report").strip().lower()
|
|
242
|
+
layout = dict(data.get("layout") or {})
|
|
243
|
+
letterhead = dict(data.get("letterhead") or {})
|
|
244
|
+
|
|
245
|
+
presets: dict[str, dict[str, Any]] = {
|
|
246
|
+
"executive_report": {
|
|
247
|
+
"include_toc": True,
|
|
248
|
+
"page_break_per_section": True,
|
|
249
|
+
"cover_style": "corporate",
|
|
250
|
+
"use_letterhead_band": False,
|
|
251
|
+
},
|
|
252
|
+
"technical": {
|
|
253
|
+
"include_toc": True,
|
|
254
|
+
"page_break_per_section": False,
|
|
255
|
+
"cover_style": "minimal",
|
|
256
|
+
"use_letterhead_band": False,
|
|
257
|
+
},
|
|
258
|
+
"letterhead": {
|
|
259
|
+
"include_toc": True,
|
|
260
|
+
"page_break_per_section": False,
|
|
261
|
+
"cover_style": "letterhead",
|
|
262
|
+
"use_letterhead_band": True,
|
|
263
|
+
},
|
|
264
|
+
"memo": {
|
|
265
|
+
"include_toc": False,
|
|
266
|
+
"page_break_per_section": False,
|
|
267
|
+
"cover_style": "minimal",
|
|
268
|
+
"use_letterhead_band": False,
|
|
269
|
+
},
|
|
270
|
+
"article": {
|
|
271
|
+
"include_toc": False,
|
|
272
|
+
"page_break_per_section": False,
|
|
273
|
+
"cover_style": "minimal",
|
|
274
|
+
"use_letterhead_band": False,
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
base = dict(presets.get(doc_type, presets["executive_report"]))
|
|
278
|
+
for key in ("include_toc", "page_break_per_section", "cover_style", "use_letterhead_band"):
|
|
279
|
+
if key in layout:
|
|
280
|
+
base[key] = layout[key]
|
|
281
|
+
audience = str(data.get("audience") or layout.get("audience") or "technical").strip().lower()
|
|
282
|
+
if audience not in ("technical", "mixed", "general"):
|
|
283
|
+
audience = "mixed" if audience in ("lay", "public", "mixed_audience") else "technical"
|
|
284
|
+
base["document_type"] = doc_type
|
|
285
|
+
base["letterhead"] = letterhead
|
|
286
|
+
base["audience"] = audience
|
|
287
|
+
return base
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _section_char_count(sec: dict[str, Any]) -> int:
|
|
291
|
+
total = len(str(sec.get("title") or ""))
|
|
292
|
+
for block in sec.get("blocks") or []:
|
|
293
|
+
if not isinstance(block, dict):
|
|
294
|
+
continue
|
|
295
|
+
btype = block.get("type")
|
|
296
|
+
if btype == "paragraph":
|
|
297
|
+
total += len(str(block.get("text") or ""))
|
|
298
|
+
elif btype == "bullets":
|
|
299
|
+
total += sum(len(str(x)) for x in (block.get("items") or []))
|
|
300
|
+
elif btype == "table":
|
|
301
|
+
total += 120 * len(block.get("rows") or [])
|
|
302
|
+
elif btype == "callout":
|
|
303
|
+
total += len(str(block.get("text") or ""))
|
|
304
|
+
return total
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _should_page_break_before_section(
|
|
308
|
+
sec: dict[str, Any], *, page_per_section: bool, index: int
|
|
309
|
+
) -> bool:
|
|
310
|
+
if index == 0 or not page_per_section:
|
|
311
|
+
return False
|
|
312
|
+
# Evita páginas quase vazias: só quebra se a secção anterior tinha conteúdo denso
|
|
313
|
+
return _section_char_count(sec) >= 280 or len(sec.get("blocks") or []) >= 3
|
|
314
|
+
|
|
315
|
+
|
|
206
316
|
def _ptext(s: str) -> str:
|
|
207
317
|
"""Plain text → safe Paragraph markup (preserva quebras como <br/>)."""
|
|
208
318
|
if s is None:
|
|
@@ -271,8 +381,52 @@ def _normalize_col_widths(
|
|
|
271
381
|
# ── Cover (story + canvas) ─────────────────────────────────────
|
|
272
382
|
|
|
273
383
|
|
|
274
|
-
def
|
|
275
|
-
|
|
384
|
+
def _letterhead_block_story(story: list, letterhead: dict[str, Any]) -> None:
|
|
385
|
+
org = str(letterhead.get("organization") or "").strip()
|
|
386
|
+
if org:
|
|
387
|
+
story.append(Paragraph(_ptext(org), S["letterhead_org"]))
|
|
388
|
+
for key in ("department", "tagline"):
|
|
389
|
+
line = str(letterhead.get(key) or "").strip()
|
|
390
|
+
if line:
|
|
391
|
+
story.append(Paragraph(_ptext(line), S["letterhead_meta"]))
|
|
392
|
+
for line in letterhead.get("address_lines") or []:
|
|
393
|
+
if str(line).strip():
|
|
394
|
+
story.append(Paragraph(_ptext(str(line)), S["letterhead_meta"]))
|
|
395
|
+
contact = str(letterhead.get("contact") or "").strip()
|
|
396
|
+
if contact:
|
|
397
|
+
story.append(Paragraph(_ptext(contact), S["letterhead_meta"]))
|
|
398
|
+
logo_path = letterhead.get("logo_path")
|
|
399
|
+
if logo_path and Path(str(logo_path)).is_file():
|
|
400
|
+
try:
|
|
401
|
+
from reportlab.platypus import Image
|
|
402
|
+
|
|
403
|
+
story.append(Spacer(1, 0.3 * cm))
|
|
404
|
+
img = Image(str(logo_path), width=3.2 * cm, height=1.2 * cm, kind="proportional")
|
|
405
|
+
img.hAlign = "CENTER"
|
|
406
|
+
story.append(img)
|
|
407
|
+
except Exception:
|
|
408
|
+
pass
|
|
409
|
+
story.append(Spacer(1, 0.6 * cm))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def cover_story(
|
|
413
|
+
story: list,
|
|
414
|
+
title: str,
|
|
415
|
+
subtitle: str | None,
|
|
416
|
+
author: str | None,
|
|
417
|
+
*,
|
|
418
|
+
cover_style: str = "corporate",
|
|
419
|
+
letterhead: dict[str, Any] | None = None,
|
|
420
|
+
) -> None:
|
|
421
|
+
lh = letterhead or {}
|
|
422
|
+
if cover_style == "letterhead" and lh:
|
|
423
|
+
story.append(Spacer(1, 1.2 * cm))
|
|
424
|
+
_letterhead_block_story(story, lh)
|
|
425
|
+
story.append(Spacer(1, 2.2 * cm))
|
|
426
|
+
elif cover_style == "minimal":
|
|
427
|
+
story.append(Spacer(1, 4.5 * cm))
|
|
428
|
+
else:
|
|
429
|
+
story.append(Spacer(1, 5.5 * cm))
|
|
276
430
|
story.append(_cover_title_paragraph(title))
|
|
277
431
|
if subtitle:
|
|
278
432
|
story.append(Paragraph(_ptext(subtitle), S["subtitle"]))
|
|
@@ -308,6 +462,7 @@ def draw_cover_canvas(canvas_obj, doc) -> None:
|
|
|
308
462
|
|
|
309
463
|
|
|
310
464
|
def toc(story: list, sections: list[dict[str, Any]]) -> None:
|
|
465
|
+
"""Sumário limpo — sem linhas horizontais a atravessar a página inteira."""
|
|
311
466
|
story.append(Paragraph("Sumário", S["h1"]))
|
|
312
467
|
story.append(Spacer(1, 0.35 * cm))
|
|
313
468
|
for sec in sections:
|
|
@@ -320,22 +475,16 @@ def toc(story: list, sections: list[dict[str, Any]]) -> None:
|
|
|
320
475
|
else:
|
|
321
476
|
left = f"{indent}{title}"
|
|
322
477
|
row = Table(
|
|
323
|
-
[
|
|
324
|
-
|
|
325
|
-
Paragraph(left, style),
|
|
326
|
-
Paragraph(str(sec.get("page", "")), style),
|
|
327
|
-
]
|
|
328
|
-
],
|
|
329
|
-
colWidths=[CW - 36, 36],
|
|
478
|
+
[[Paragraph(left, style)]],
|
|
479
|
+
colWidths=[CW],
|
|
330
480
|
)
|
|
331
481
|
row.setStyle(
|
|
332
482
|
TableStyle(
|
|
333
483
|
[
|
|
334
|
-
("ALIGN", (1, 0), (1, 0), "RIGHT"),
|
|
335
484
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
336
|
-
("
|
|
337
|
-
("
|
|
338
|
-
("
|
|
485
|
+
("TOPPADDING", (0, 0), (-1, -1), 3),
|
|
486
|
+
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
|
487
|
+
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
|
339
488
|
]
|
|
340
489
|
)
|
|
341
490
|
)
|
|
@@ -382,10 +531,24 @@ def section(story: list, num: int | str, title: str) -> None:
|
|
|
382
531
|
|
|
383
532
|
|
|
384
533
|
def callout(story: list, title: str, text: str, box_type: str = "info") -> None:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
534
|
+
allowed = ("info", "warning", "plain_language", "technical_note")
|
|
535
|
+
box_type = box_type if box_type in allowed else "info"
|
|
536
|
+
if box_type == "plain_language":
|
|
537
|
+
bg = COLORS["bg_light"]
|
|
538
|
+
border = COLORS["secondary"]
|
|
539
|
+
label = "Em linguagem simples"
|
|
540
|
+
elif box_type == "technical_note":
|
|
541
|
+
bg = HexColor("#F8FAFC")
|
|
542
|
+
border = COLORS["primary"]
|
|
543
|
+
label = "Nota técnica"
|
|
544
|
+
elif box_type == "warning":
|
|
545
|
+
bg = COLORS["bg_light"]
|
|
546
|
+
border = COLORS["accent"]
|
|
547
|
+
label = "Atenção"
|
|
548
|
+
else:
|
|
549
|
+
bg = COLORS["bg_accent"]
|
|
550
|
+
border = COLORS["secondary"]
|
|
551
|
+
label = "Informação"
|
|
389
552
|
content = [
|
|
390
553
|
[
|
|
391
554
|
Paragraph(
|
|
@@ -430,14 +593,30 @@ def callout(story: list, title: str, text: str, box_type: str = "info") -> None:
|
|
|
430
593
|
story.append(Spacer(1, 6))
|
|
431
594
|
|
|
432
595
|
|
|
596
|
+
def _cell_paragraph(text: str, *, header: bool = False) -> Paragraph:
|
|
597
|
+
raw = str(text or "")
|
|
598
|
+
if len(raw) > 120 or "\n" in raw:
|
|
599
|
+
sty = ParagraphStyle(
|
|
600
|
+
"CellLong",
|
|
601
|
+
fontName=FONT_B if header else FONT,
|
|
602
|
+
fontSize=9 if header else 8.5,
|
|
603
|
+
leading=11 if header else 10,
|
|
604
|
+
textColor=COLORS["white"] if header else COLORS["text"],
|
|
605
|
+
)
|
|
606
|
+
return Paragraph(_ptext(raw), sty)
|
|
607
|
+
return Paragraph(_ptext(raw), S["cell"] if not header else ParagraphStyle(
|
|
608
|
+
"CellH", parent=S["cell"], fontName=FONT_B, textColor=COLORS["white"], fontSize=9
|
|
609
|
+
))
|
|
610
|
+
|
|
611
|
+
|
|
433
612
|
def pro_table(
|
|
434
613
|
story: list,
|
|
435
614
|
headers: list[str],
|
|
436
615
|
rows: list[list[str]],
|
|
437
616
|
col_widths: list[float] | None = None,
|
|
438
617
|
) -> None:
|
|
439
|
-
|
|
440
|
-
data
|
|
618
|
+
data = [[_cell_paragraph(h, header=True) for h in headers]]
|
|
619
|
+
data += [[_cell_paragraph(c) for c in row] for row in rows]
|
|
441
620
|
col_widths = _normalize_col_widths(col_widths, len(headers))
|
|
442
621
|
t = Table(data, colWidths=col_widths, repeatRows=1)
|
|
443
622
|
t.setStyle(
|
|
@@ -473,9 +652,31 @@ def pro_table(
|
|
|
473
652
|
story.append(Spacer(1, 10))
|
|
474
653
|
|
|
475
654
|
|
|
476
|
-
def
|
|
655
|
+
def _draw_letterhead_band(canvas_obj, letterhead: dict[str, Any]) -> None:
|
|
656
|
+
org = str(letterhead.get("organization") or "").strip()[:80]
|
|
657
|
+
if not org:
|
|
658
|
+
return
|
|
477
659
|
canvas_obj.saveState()
|
|
478
|
-
|
|
660
|
+
y = PAGE_H - 1.15 * cm
|
|
661
|
+
canvas_obj.setFont(FONT_B, 9)
|
|
662
|
+
canvas_obj.setFillColor(COLORS["primary"])
|
|
663
|
+
canvas_obj.drawString(M, y, org)
|
|
664
|
+
dept = str(letterhead.get("department") or "").strip()[:60]
|
|
665
|
+
if dept:
|
|
666
|
+
canvas_obj.setFont(FONT, 7.5)
|
|
667
|
+
canvas_obj.setFillColor(COLORS["muted"])
|
|
668
|
+
canvas_obj.drawRightString(PAGE_W - M, y, dept)
|
|
669
|
+
canvas_obj.setStrokeColor(COLORS["border"])
|
|
670
|
+
canvas_obj.setLineWidth(0.5)
|
|
671
|
+
canvas_obj.line(M, y - 5, PAGE_W - M, y - 5)
|
|
672
|
+
canvas_obj.restoreState()
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def header_footer(canvas_obj, doc, *, letterhead: dict[str, Any] | None = None) -> None:
|
|
676
|
+
if letterhead and getattr(doc, "_bluma_letterhead", False):
|
|
677
|
+
_draw_letterhead_band(canvas_obj, letterhead)
|
|
678
|
+
canvas_obj.saveState()
|
|
679
|
+
y_h = PAGE_H - (1.75 * cm if getattr(doc, "_bluma_letterhead", False) else 1.35 * cm)
|
|
479
680
|
canvas_obj.setStrokeColor(COLORS["border"])
|
|
480
681
|
canvas_obj.setLineWidth(0.6)
|
|
481
682
|
canvas_obj.line(M, y_h, PAGE_W - M, y_h)
|
|
@@ -499,7 +700,8 @@ def on_first_page(canvas_obj, doc) -> None:
|
|
|
499
700
|
|
|
500
701
|
|
|
501
702
|
def on_later_pages(canvas_obj, doc) -> None:
|
|
502
|
-
|
|
703
|
+
lh = getattr(doc, "_bluma_letterhead_data", None) or {}
|
|
704
|
+
header_footer(canvas_obj, doc, letterhead=lh)
|
|
503
705
|
|
|
504
706
|
|
|
505
707
|
# ── JSON-driven build ──────────────────────────────────────────
|
|
@@ -509,8 +711,12 @@ def build_from_json(data: dict[str, Any], output_path: str) -> None:
|
|
|
509
711
|
title = data.get("title") or "Relatório"
|
|
510
712
|
subtitle = data.get("subtitle")
|
|
511
713
|
author = data.get("author")
|
|
512
|
-
|
|
513
|
-
|
|
714
|
+
layout = _resolve_layout(data)
|
|
715
|
+
letterhead = layout.get("letterhead") or {}
|
|
716
|
+
|
|
717
|
+
page_per_section = layout.get("page_break_per_section", False)
|
|
718
|
+
if "page_break_per_section" in data and data["page_break_per_section"] is not None:
|
|
719
|
+
page_per_section = bool(data["page_break_per_section"])
|
|
514
720
|
if not isinstance(page_per_section, bool):
|
|
515
721
|
page_per_section = bool(page_per_section)
|
|
516
722
|
|
|
@@ -518,37 +724,64 @@ def build_from_json(data: dict[str, Any], output_path: str) -> None:
|
|
|
518
724
|
if not isinstance(sections, list):
|
|
519
725
|
raise ValueError('"sections" must be a list')
|
|
520
726
|
|
|
727
|
+
include_toc = layout.get("include_toc", True)
|
|
728
|
+
if len(sections) < 2:
|
|
729
|
+
include_toc = False
|
|
730
|
+
|
|
521
731
|
toc_rows: list[dict[str, Any]] = []
|
|
522
732
|
for i, sec in enumerate(sections, start=1):
|
|
523
733
|
if not isinstance(sec, dict):
|
|
524
734
|
continue
|
|
525
735
|
num = sec.get("num", i)
|
|
526
|
-
toc_rows.append({"num": num, "title": sec.get("title", f"Seção {i}"), "level": 1
|
|
736
|
+
toc_rows.append({"num": num, "title": sec.get("title", f"Seção {i}"), "level": 1})
|
|
527
737
|
|
|
528
738
|
doc = SimpleDocTemplate(
|
|
529
739
|
output_path,
|
|
530
740
|
pagesize=A4,
|
|
531
741
|
leftMargin=M,
|
|
532
742
|
rightMargin=M,
|
|
533
|
-
topMargin=M,
|
|
743
|
+
topMargin=M + (0.4 * cm if layout.get("use_letterhead_band") else 0),
|
|
534
744
|
bottomMargin=M,
|
|
535
745
|
title=title,
|
|
536
|
-
author="BluMa",
|
|
746
|
+
author=str(author or letterhead.get("organization") or "BluMa"),
|
|
537
747
|
)
|
|
748
|
+
doc._bluma_letterhead = bool(layout.get("use_letterhead_band") and letterhead) # type: ignore[attr-defined]
|
|
749
|
+
doc._bluma_letterhead_data = letterhead # type: ignore[attr-defined]
|
|
538
750
|
|
|
539
751
|
story: list = []
|
|
540
|
-
cover_story(
|
|
541
|
-
|
|
752
|
+
cover_story(
|
|
753
|
+
story,
|
|
754
|
+
title,
|
|
755
|
+
subtitle,
|
|
756
|
+
author,
|
|
757
|
+
cover_style=str(layout.get("cover_style") or "corporate"),
|
|
758
|
+
letterhead=letterhead,
|
|
759
|
+
)
|
|
760
|
+
if include_toc and toc_rows:
|
|
761
|
+
toc(story, toc_rows)
|
|
542
762
|
|
|
543
|
-
|
|
544
|
-
|
|
763
|
+
audience = str(layout.get("audience") or "technical")
|
|
764
|
+
if audience == "mixed":
|
|
765
|
+
story.append(
|
|
766
|
+
Paragraph(
|
|
767
|
+
_ptext(
|
|
768
|
+
"Este documento apresenta dados com rigor técnico e, em cada secção, "
|
|
769
|
+
"resumos em linguagem acessível para leitores sem formação especializada na área."
|
|
770
|
+
),
|
|
771
|
+
S["body"],
|
|
772
|
+
)
|
|
773
|
+
)
|
|
774
|
+
story.append(Spacer(1, 10))
|
|
775
|
+
|
|
776
|
+
for idx, sec in enumerate(sections):
|
|
545
777
|
if not isinstance(sec, dict):
|
|
546
778
|
continue
|
|
547
779
|
num = sec.get("num", 0)
|
|
548
780
|
sec_title = sec.get("title", "")
|
|
549
|
-
if
|
|
781
|
+
if _should_page_break_before_section(
|
|
782
|
+
sec, page_per_section=page_per_section, index=idx
|
|
783
|
+
):
|
|
550
784
|
story.append(PageBreak())
|
|
551
|
-
first_section = False
|
|
552
785
|
|
|
553
786
|
blocks = [b for b in (sec.get("blocks") or []) if isinstance(b, dict)]
|
|
554
787
|
section(story, num, sec_title)
|
package/dist/main.js
CHANGED
|
@@ -24473,6 +24473,7 @@ Timeouts mean the orchestrator should raise the limit \u2014 not that the sandbo
|
|
|
24473
24473
|
- Never create a top-level \`./artifacts/\` folder \u2014 it is auto-remapped to \`.bluma/artifacts/\`.
|
|
24474
24474
|
- **Never invent URLs** or paths outside \`.bluma/artifacts/\` in \`attachments[]\`.
|
|
24475
24475
|
- Live deploy URLs (if any) may appear in \`content\` when returned by \`factorai.sh.deploy_app\`; downloadable files still go through \`attachments[]\`.
|
|
24476
|
+
- **PDFs for mixed audiences:** load skill \`pdf\`; use \`create_report.py --from-json\` with \`audience: "mixed"\` \u2014 rigor in tables, \`plain_language\` callouts per section for non-specialists.
|
|
24476
24477
|
- Remove temp files and generator scripts before finishing.
|
|
24477
24478
|
</sandbox_context>
|
|
24478
24479
|
`;
|