@nomad-e/bluma-cli 0.8.0 → 0.9.1

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 **new business/executive reports**, prefer
5
- `create_report.py --from-json`: invest effort in **rich, well-structured JSON**
6
- (clear sections, tight copy, sparse callouts, scannable tables) the template
7
- already supplies a **modern, restrained** layout; shallow JSON wastes that
8
- design. Ad-hoc ReportLab snippets produce amateur PDFs. Also: extract/merge/
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. Build a UTF-8 JSON file with `title`, optional `subtitle` / `author`, and
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
- 2. Run: `python /absolute/path/to/scripts/create_report.py --from-json
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
- 3. Attach that **absolute** path as the deliverable. Do **not** paste huge
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 **`true`** for multi-topic reports (calm, one chapter per page).
123
- - Set **`false`** only for a short, single-thread document where breaks would
124
- feel wasteful.
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` (str), `subtitle` (optional str),
628
- `author` (optional str), `page_break_per_section` (optional bool, default
629
- `true` cada capítulo começa em página nova), `sections` (array).
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
- "page_break_per_section": true,
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 cover_story(story: list, title: str, subtitle: str | None, author: str | None) -> None:
275
- story.append(Spacer(1, 5.5 * cm))
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
- ("LINEBELOW", (0, 0), (-1, -1), 0.35, COLORS["border"]),
337
- ("TOPPADDING", (0, 0), (-1, -1), 5),
338
- ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
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
- box_type = box_type if box_type in ("info", "warning") else "info"
386
- bg = COLORS["bg_accent"] if box_type == "info" else COLORS["bg_light"]
387
- border = COLORS["secondary"] if box_type == "info" else COLORS["accent"]
388
- label = "Informação" if box_type == "info" else "Atenção"
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
- h_esc = [_ptext(h) for h in headers]
440
- data = [h_esc] + [[_ptext(c) for c in row] for row in rows]
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 header_footer(canvas_obj, doc) -> None:
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
- y_h = PAGE_H - 1.35 * cm
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
- header_footer(canvas_obj, doc)
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
- # True = cada capítulo em página nova (relatório executivo); False = fluxo contínuo
513
- page_per_section = data.get("page_break_per_section", True)
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, "page": ""})
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(story, title, subtitle, author)
541
- toc(story, toc_rows)
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
- first_section = True
544
- for sec in sections:
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 page_per_section and not first_section:
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
  `;
@@ -44483,6 +44484,26 @@ function writeAgentEvent(sessionId, event) {
44483
44484
  appendSessionLog(sessionId, event);
44484
44485
  }
44485
44486
  }
44487
+ function resolveResultUserMessage(lastAssistantMessage, lastAttachments) {
44488
+ if (typeof lastAssistantMessage === "string" && lastAssistantMessage.trim()) {
44489
+ return lastAssistantMessage.trim();
44490
+ }
44491
+ const paths2 = Array.isArray(lastAttachments) ? lastAttachments.filter((p) => typeof p === "string" && p.trim()) : [];
44492
+ if (paths2.length > 0) {
44493
+ return `Entreg\xE1vel dispon\xEDvel em: ${paths2.join(", ")}`;
44494
+ }
44495
+ return "Tarefa conclu\xEDda.";
44496
+ }
44497
+ function buildSuccessResultData(envelope, sessionId, lastAssistantMessage, lastAttachments) {
44498
+ const message2 = resolveResultUserMessage(lastAssistantMessage, lastAttachments);
44499
+ return {
44500
+ message_id: envelope.message_id || sessionId,
44501
+ action: envelope.action || "unknown",
44502
+ last_assistant_message: message2,
44503
+ message: message2,
44504
+ attachments: lastAttachments
44505
+ };
44506
+ }
44486
44507
  function finalizeSession(sessionId, status, metadata) {
44487
44508
  updateSession(sessionId, {
44488
44509
  status,
@@ -44665,12 +44686,12 @@ async function runAgentMode() {
44665
44686
  event_type: "result",
44666
44687
  status: "success",
44667
44688
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
44668
- data: {
44669
- message_id: envelope.message_id || sessionId,
44670
- action: envelope.action || "unknown",
44671
- last_assistant_message: lastAssistantMessage,
44672
- attachments: lastAttachments
44673
- }
44689
+ data: buildSuccessResultData(
44690
+ envelope,
44691
+ sessionId,
44692
+ lastAssistantMessage,
44693
+ lastAttachments
44694
+ )
44674
44695
  });
44675
44696
  finalizeSession(sessionId, "completed", { finishedBy: "done-event" });
44676
44697
  process.exit(0);
@@ -44719,12 +44740,12 @@ async function runAgentMode() {
44719
44740
  event_type: "result",
44720
44741
  status: "success",
44721
44742
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
44722
- data: {
44723
- message_id: envelope.message_id || sessionId,
44724
- action: envelope.action || "unknown",
44725
- last_assistant_message: lastAssistantMessage,
44726
- attachments: lastAttachments
44727
- }
44743
+ data: buildSuccessResultData(
44744
+ envelope,
44745
+ sessionId,
44746
+ lastAssistantMessage,
44747
+ lastAttachments
44748
+ )
44728
44749
  });
44729
44750
  finalizeSession(sessionId, "completed", { finishedBy: "post-turn-fallback" });
44730
44751
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomad-e/bluma-cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "BluMa independent agent for automation and advanced software engineering.",
5
5
  "author": "Alex Fonseca",
6
6
  "license": "Apache-2.0",