diff --git a/.agents/skills/graficos-dados/SKILL.md b/.agents/skills/graficos-dados/SKILL.md new file mode 100644 index 00000000..133a942f --- /dev/null +++ b/.agents/skills/graficos-dados/SKILL.md @@ -0,0 +1,122 @@ +--- +name: graficos-dados +description: 'Transforma números que a comunicação recebeu (xlsx/csv/md/txt/texto colado) num gráfico PNG pronto para o Instagram — 1080×1350 feed, 1080×1080 quadrado ou 1080×1920 stories — com a paleta Jorge Solla, título-manchete, um único destaque de cor e guardrails de correção; dado ambíguo vira pergunta, nunca invenção.' +--- + +# Gráficos de dados para Instagram (C191) + +Entrega **um gráfico PNG** por invocação, no tamanho do Instagram e com a cara +do mandato, a partir do dado **como ele veio** — planilha, CSV, markdown ou +texto solto colado no comando. A skill lê, entende a **relação** dos dados +(comparação, tempo, parte-de-um-todo, número único), **propõe o tipo** correto, +gera a peça com **título-manchete** e **um destaque vermelho**, e deixa a +comunicação transformar o gráfico no material final (legenda, carrossel, +contexto). É a **base visual confiável**, não a peça acabada. + +O template visual é o artefato aprovado +`docs/plans/graficos-dados-instagram-ui-design.html`; o renderer +(`scripts/lib/graficosInstagramRender.mjs`) porta esse design classe-a-classe. +**Nunca invente estrutura visual** — o dono é o `designer`. + +## Quando usar + +- A comunicação pede "um gráfico disso", "transforma essa planilha em post", + "faz um gráfico para o Instagram" — e fornece os números (arquivo ou colado). +- **Não** use para: limpar/interpretar estatisticamente os dados, escrever a + legenda/carrossel, publicar/agendar, gerar dossiê/relatório em PDF (irmãos + C163/C186/C187) ou montar dashboard interativo. A skill **representa** o dado; + não o interpreta. + +## Fluxo + +1. **Receba o dado.** Texto colado → grave em + `data/graficos-instagram/-input.txt` (o diretório é gitignored) e aponte + com `--in=`. Arquivo → aponte o caminho direto (`xlsx`, `xls`, `csv`, `tsv`, + `md`, `txt`). +2. **Inspecione antes de desenhar:** + ```bash + node scripts/build-chart-from-data.mjs --in= --inspect + ``` + Devolve `{ rows, format, issues }` — a matriz parseada e os problemas. +3. **Decida o tipo e a copy.** A relação dos dados manda: + - **ranking / comparação → `bar`** (barras horizontais, ordenadas pelo valor, + rótulo direto e valor na ponta); + - **poucos períodos (≤4) → `column`** (base zero comum); + - **série de tempo contínua → `line`** (ponto final destacado); + - **uma única medida → `anchor`** (número grande + frase que explica). + Ambíguo (mais de um tipo plausível) → **pergunte**; não escolha no escuro. + Escreva a **manchete = takeaway** (não "Gráfico de X"), subtítulo opcional e a + **fonte** (viaja dentro da imagem). +4. **Gere o PNG:** + ```bash + node scripts/build-chart-from-data.mjs --in= \ + --headline="Um território concentra o maior resultado do período" \ + --subtitle="Valores de 2022" \ + --source="TSE 2022" --type=bar --size=feed \ + --highlight="Ilhéus" + ``` + Saída default: `docs/research/graficos-instagram/--.png` + (gitignored) + o `chart-spec.json` em `data/graficos-instagram/`. `--size`: + `feed` (1080×1350, default), `square` (1080×1080), `story` (1080×1920, com + faixas seguras de 250px no topo e na base). `--out=` sobrescreve o caminho. +5. **Confira e ajuste.** A pessoa confere o PNG; ajuste tipo/rótulo/manchete/ + tamanho/`--highlight` e regenere. **Replay** sem reparse: `--spec=`. +6. **Entregue o gráfico.** A comunicação finaliza a peça. O dado não persiste. + +## Contrato do `chart-spec.json` + +```json +{ + "chartType": "bar", + "size": "feed", + "headline": "Um território concentra o maior resultado do período", + "subtitle": "Valores de 2022", + "source": "TSE 2022", + "note": "valores arredondados", + "highlight": "Ilhéus", + "rows": [{ "label": "Ilhéus", "value": 84 }] +} +``` + +`chartType` ∈ `bar | column | line | anchor`; `highlight` é o rótulo que recebe o +único vermelho `#c51414` (default: o maior valor). + +## Guardrails (fail-closed) + +O builder **recusa** em vez de desenhar algo enganoso: + +- **Barras e colunas partem do zero**; valor negativo é recusado; sem 3D/eixo + truncado. +- **Até 7 pontos** por gráfico; em **stories**, o ranking comporta **até 5** + pontos (a adaptação vertical reduz um ponto, conforme o design aprovado). + Acima disso, resuma as categorias e explique. +- **Número-âncora:** a manchete é o takeaway (não repete o número) e o valor + aparece uma vez, grande, seguido da frase que explica. +- **Pizza não é gerada no v1** — pedido `pie` é recusado com sugestão de barras. +- **Dado faltando/ambíguo → pergunta, nunca completa.** O builder sai com + `{ needsQuestion: true, issues: [...] }` e não gera imagem. +- **Sem fonte, sem peça:** `--source` é obrigatório. +- Legibilidade: headline ≥48px e rótulos ≥30px no canvas 1080; contraste ≥4.5:1 + no texto e ≥3:1 nas marcas; **cor nunca é a única pista** (o destaque tem + valor e posição). +- **Dado de campanha nunca sai da máquina:** render 100% local, sem MCP/serviço + remoto por default. +- **Nada é commitado:** entrada, `chart-spec` e PNG vivem em + `data/graficos-instagram/` e `docs/research/graficos-instagram/`, ambos + gitignored (repo público). + +## Troubleshooting + +- **"dado ambíguo ou faltando"** → leia `issues` no JSON de saída: linha sem + rótulo, valor não numérico, rótulo repetido. Pergunte à pessoa; não invente. +- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa. +- **"pizza não é gerada"** → ofereça barras horizontais. +- **Número pt-BR:** `1.234,56` é lido como 1234,56; `1,5` como 1,5. +- **PNG grande:** a guarda de 8MB falha fechado; simplifique o gráfico. + +## Referências + +- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html`. +- Plano de intenção: `docs/plans/graficos-dados-instagram.md`; impl: + `docs/plans/graficos-dados-instagram-impl.md`. +- Irmãos (mesmo padrão skill + command + builder local): C186/C187. diff --git a/.gitignore b/.gitignore index 61598904..3a277512 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,10 @@ next-env.d.ts # C190 — dossiê Solla por tema/área + boletim modelo: same rule (C186). /data/dossie-solla-tema/ /docs/research/dossie-solla-tema/ +# C191 — Instagram data charts: raw input + chart-spec intermediates + final +# PNG/SVG carry campaign-internal data; the public repo never commits them. +/data/graficos-instagram/ +/docs/research/graficos-instagram/ # Local-only sensitive material (agents may read; never commit content) /private/** diff --git a/.opencode/commands/graficos-dados.md b/.opencode/commands/graficos-dados.md new file mode 100644 index 00000000..2e20f46e --- /dev/null +++ b/.opencode/commands/graficos-dados.md @@ -0,0 +1,9 @@ +--- +description: Gera um gráfico PNG pronto para o Instagram (1080×1350 feed, 1080×1080 quadrado ou 1080×1920 stories) a partir de dados em xlsx/csv/md/txt/texto colado, na paleta Jorge Solla, com título-manchete e um único destaque de cor +--- + +Carregue a skill `graficos-dados` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: leia os `$ARGUMENTS` (arquivo apontado ou números colados no comando), inspecione a matriz com `--inspect`, classifique a relação dos dados (comparação, tempo, número único), proponha o tipo e a manchete/fonte, gere o PNG no tamanho pedido com o builder local (`node scripts/build-chart-from-data.mjs`) e confira com a pessoa. A fonte canônica é `.agents/skills/graficos-dados/SKILL.md` — não a transcreva nem recrie o fluxo. + +Uso: `/graficos-dados ` — ex.: `/graficos-dados data/graficos-instagram/votos.csv` ou `/graficos-dados Ilhéus 84, Itabuna 68, Porto Seguro 53`. Dado ambíguo/faltando → pergunte; nunca invente. + +$ARGUMENTS diff --git a/docs/changelog/2026-09-18-c191-graficos-instagram.md b/docs/changelog/2026-09-18-c191-graficos-instagram.md new file mode 100644 index 00000000..f28bf097 --- /dev/null +++ b/docs/changelog/2026-09-18-c191-graficos-instagram.md @@ -0,0 +1 @@ +**C191 — Gráficos de dados para Instagram (skill de comunicação) (2026-09-18):** a comunicação agora transforma números como eles vieram (`xlsx`/`csv`/`md`/`txt`/texto colado) num **gráfico PNG pronto para o Instagram**, na paleta Jorge Solla e no formato do canal. (1) **Superfície:** skill `.agents/skills/graficos-dados/SKILL.md` + command `/graficos-dados` (sem subagente — a etapa semântica cabe no agente principal) e o entry `scripts/build-chart-from-data.mjs` (`--in`/`--inspect`/`--spec` replay/`--headline`/`--source`/`--type`/`--size`/`--highlight`). (2) **Núcleo puro** `scripts/lib/chartData.mjs`: parse dos formatos com `xlsx`/`csv-parse` (já devDeps, zero dependência nova), classificação da relação dos dados (ranking→`bar`, poucos períodos→`column`, série→`line`, medida única→`anchor`) e guardrails **fail-closed** (base zero, ≤7 pontos — 5 em stories, pizza recusada, `--source` obrigatório, nada de valor negativo); dado faltando/ambíguo vira pergunta, nunca invenção. (3) **Dono do desenho** `scripts/lib/chartPrimitives.mjs`: os primitivos SVG do dossiê (`barChart`/`valueList`/`columnChart`/`stackedColumnChart` + `CHART_COLORS`, extraídos byte-idênticos de `dossieRender.mjs`, golden pin) mais o `lineChart` e a escala `proportionalPercent`; **sem segundo renderer** ("edite o dono"). (4) **Renderer** `scripts/lib/graficosInstagramRender.mjs` porta classe-a-classe o hi-fi aprovado (`docs/plans/graficos-dados-instagram-ui-design.html`) em 3 canvases — `1080×1350` feed (default), `1080×1080` quadrado e `1080×1920` stories com faixas seguras de 250px derivadas de `SIZES`; título-manchete, um único destaque `#c51414`, fonte e lockup provisório dentro da imagem. (5) **Chromium:** `scripts/lib/buildPdf.mjs` ganhou `screenshotHtmlPng` (aditivo, viewport exato + clip, `document.fonts.ready`, guarda fail-closed de 8 MB) sem tocar no caminho A4. (6) **Privacidade/determinismo:** render 100% local; entrada, `chart-spec.json` e PNG gitignored (`data/graficos-instagram/`, `docs/research/graficos-instagram/`); sem schema/migration/DB. Crítica de fechamento do `designer` certificada (`Design tier: openai/gpt-5.6-sol`), sem bloqueantes; ajustes absorvidos (âncora sem repetir o número na manchete; stories com ≤5 pontos). Testes: `chartData`/`chartPrimitives`(golden)/`graficosInstagramRender`/prosa da skill + pinos de CI (`SCRIPTS_SPEC_PINNED`). `pnpm gate:fast` verde; prettier/typecheck/lint/knip limpos. Débito de runtime (entry/PNG real e parse ambíguo pt-BR) registrado em `C191-runtime` (#1151). diff --git a/docs/plans/graficos-dados-instagram-impl.md b/docs/plans/graficos-dados-instagram-impl.md new file mode 100644 index 00000000..522e80d8 --- /dev/null +++ b/docs/plans/graficos-dados-instagram-impl.md @@ -0,0 +1,154 @@ +# Impl: Gráficos de dados para Instagram (skill de comunicação) + +Status: em execução +Atualizado em: 2026-09-18 +Issue: #1145 +Intenção: docs/plans/graficos-dados-instagram.md +Appetite restante: herdado (~2–3 dias eng) — sem corte +Design tier: openai/gpt-5.6-sol (crítica de fechamento certificada; sem bloqueantes) + +## Leitura da intenção + +- **Outcome:** numa invocação, transformar dados colados/em arquivo (`xlsx/csv/md/txt/texto`) num **gráfico PNG** legível, na paleta Solla, com título-manchete e um destaque de cor, no tamanho do Instagram (`1080×1350` default, `1080×1080`, `1080×1920`). O tipo segue a relação dos dados (barra, coluna, linha, número-âncora); dado ambíguo/faltando → pergunta, nunca completa; render 100% local; artefato gitignored. +- **O que NÃO negociar:** bars/colunas partem do zero; ≤ ~7 pontos; sem pizza grande, 3D ou eixo truncado; rótulos ≥30px e headline ≥48px; contraste ≥4.5:1 (texto) / ≥3:1 (marcas); cor nunca é a única pista; sem persistir dado/schema/migration; **sem segundo pipeline irmão do renderer do dossiê**; dado de campanha nunca sai da máquina; PNG/intermediários nunca commitados (repo público). +- **O que reavaliar (hipóteses do "Direção no codebase"):** (a) que os primitivos de `dossieRender.mjs` são reaproveitáveis como estão — são `const` privados, A4/mm/pt e com `CHART_COLORS` de domínio dossiê; o reuso exige **extração parametrizada**, não `import` direto. (b) que `buildPdf.mjs` serve a saída imagem — ele só tem caminho `page.pdf()`; falta um caminho `page.screenshot()`. (c) que `xlsx`/`csv-parse` exigem dependência nova — já são devDeps. (d) que o subagente é necessário — a etapa semântica é pequena (1 dataset, 1 escolha) e não isola contexto pesado como o C186. + +## Abordagem recomendada + +```mermaid +flowchart LR + U["Comunicação: /graficos-dados <dados>"] --> S["skill graficos-dados (orquestra)"] + S -->|arquivo ou texto colado| B["scripts/build-chart-from-data.mjs"] + B --> D["scripts/lib/chartData.mjs — parse + classifica relação + valida guardrails"] + D -->|ambíguo ou faltando| Q["skill pergunta — nunca inventa"] + Q --> B + D -->|chart-spec ok| R["scripts/lib/graficosInstagramRender.mjs — canvas 1080, headline, plot, rodapé"] + R --> G["scripts/lib/chartPrimitives.mjs — barra/coluna/linha/âncora (emissão SVG)"] + G -. mesmo dono .-> P["dossieRender.mjs (A4, saída inalterada)"] + R --> H["scripts/lib/buildPdf.mjs — screenshotHtmlPng"] + H --> O["PNG gitignored: docs/research/graficos-instagram/"] + O --> U +``` + +**Opções consideradas:** A (extrair o dono dos primitivos + caminho de screenshot no `buildPdf`; superfície só skill+command) | B (renderer Instagram novo e isolado, sem tocar `dossieRender`; subagente classificador) | C (dependência de chart library, ex. vega/Chart.js, e/ou MCP de gráfico). +**Recomendação:** A — é o que a intenção manda ("estender/extrair o dono"), cabe no appetite e mantém o núcleo de parsing/classificação puro e testável. +**Rejeitadas:** B porque gemina o desenho de gráfico (anti-goal explícito) e um subagente sem contexto pesado a isolar só acrescenta handoff/pin; C porque traz bundler/CDN/dep para um caminho que precisa ser local, determinístico e sem rede. + +### Decisões de engenharia + +**(i) Reuso/extração dos primitivos de gráfico sem geminar renderer nem quebrar o A4** + +- **Opções:** A) Extrair a **emissão SVG** dos tipos (barra, coluna, linha, âncora) para `scripts/lib/chartPrimitives.mjs`, parametrizada por `{ width, height, colors, classNames, format, highlight }`, e rewiring de `dossieRender.mjs` para consumi-la com os defaults A4 atuais (saída byte-idêntica); o renderer Instagram chama os mesmos emissores com canvas 1080 e paleta Solla. | B) Renderer Instagram copia os primitivos (twin). | C) Instagram renderer importa `dossieRender.mjs` e reescreve CSS por cima. +- **Recomendação:** A — `barChart/valueList/columnChart/stackedColumnChart` e `CHART_COLORS` (L898–993) são usados **só dentro** de `dossieRender.mjs` (8 call sites em L1791–1840), então a extração é contida: o dono passa a ser um módulo deep de desenho de gráfico; cada consumidor mantém a **composição de página** (dossiê A4 × canvas Instagram). Linha e número-âncora não existem hoje e nascem nesse mesmo dono (não num irmão). A extração é provada sem mudança por um **golden spec** que compara a string SVG emitida antes/depois para fixtures fixas. +- **Alternativas rejeitadas:** B porque o anti-goal é explícito e correção de palette/guardrail passaria a exigir dois edits (drift); C porque os primitivos não são exportados e arrastam CSS A4/`CHART_COLORS` de domínio para dentro do caminho Instagram (o médium é genuinamente outro: px, paleta Solla, headline/rodapé). + +**(ii) Artefato intermediário (HTML→PNG) e execução do Chromium/screenshot** + +- **Opções:** A) Adicionar `screenshotHtmlPng(browser, { html, width, height, outPath })` ao dono `scripts/lib/buildPdf.mjs` (reusa `launchPdfBrowser`; viewport exato; `page.screenshot({ type:'png', clip:[0,0,w,h] })`; `document.fonts.ready`; guarda de tamanho ≤8MB) e o renderer devolve HTML com CSS **inline**. | B) Rasterizar o PDF A4 existente. | C) Renderizar com `sharp`/`resvg` ou serviço externo. +- **Recomendação:** A — o `buildPdf.mjs` é o dono do launch/emit do Chromium; o novo caminho é **aditivo** e não toca constantes A4, `emulateMedia('print')` nem a lógica de fit (o PNG usa mídia `screen` e viewport `1080×(1350|1080|1920)`; o `.canvas` tem exatamente o tamanho de saída, então `clip` é o canvas). CSS inline porque o pipeline não tem bundler — o hi-fi usa Tailwind por CDN, que **não** pode ser dependência de rede no render. PNG do Chromium já é sRGB; a guarda de 8MB falha fechado (`die`). +- **Alternativas rejeitadas:** B porque exige o caminho A4 (páginas mm/pt, `preferCSSPageSize`) e ainda um rasterizador extra; C porque adiciona dependência/pipeline novo e (no serviço externo) viola "dado nunca sai da máquina". + +**(iii) Superfície pública: skill + command + (sub)agente** + +- **Opções:** A) **skill** `.agents/skills/graficos-dados/SKILL.md` (canônica) + **command** `.opencode/commands/graficos-dados.md` (homônimo, carrega a skill pelo nome e passa `$ARGUMENTS`); **sem** subagente. | B) skill + command + subagente classificador (`redator/classificador`) que devolve um recibo. | C) skill + command + subagente renderizador. +- **Recomendação:** A — a etapa semântica é pequena e cabe no agente principal (parse do formato + leitura da tabela + escolha do tipo + copy), e o builder determinístico faz a validação; o par skill+command é exigido pelo pin `opencodeCommands.unit.spec.ts` (nome exato, sem `model:`). Não há contexto pesado (pesquisa web multi-era) a isolar como no C186. Gatilho de revisitação: quando a invocação passar a aceitar **lote** (vários gráficos por comando) ou pesquisa web, reavalie um subagente por lote. +- **Alternativas rejeitadas:** B porque duplicaria os guardrails da skill num recibo/handoff sem ganho de contexto; C porque renderização é etapa determinística de script — nunca de LLM. + +**(iv) Parsing de xlsx/csv/md/txt/texto com o mínimo de dependência** + +- **Opções:** A) `xlsx` (já devDep L194) para `xlsx/xls`; `csv-parse/sync` (já devDep L171) para `csv`; sniffer de delimitador local para `txt` (`,`/`;`/`\t`); parser local de tabela GFM para `md`; fallback de pares `rótulovalor` para texto solto; `node:fs` para leitura. | B) Adicionar `papaparse` e/ou `mammoth` (docx). | C) Shell-out para `libreoffice`/`ssconvert`. +- **Recomendação:** A — zero dependência nova; tudo isolado em `scripts/lib/chartData.mjs` (puro, sem Chromium), com detecção de header e inferência de tipo numérico, e falha explícita quando a matriz não tem 2 colunas mínimas (`label` + `value`). `doc/docx` fica **fora do v1** (a intenção já decidiu) com gatilho de revisitação se houver demanda real. +- **Alternativas rejeitadas:** B porque `papaparse` é redundante com `csv-parse` e `mammoth` reabre o `docx` já cortado; C porque depende de binário externo e torna o determinismo ambiental. + +**(v) Onde ficam tipos/classificação de relação e o que é unit-testável** + +- **Opções:** A) Núcleo puro em `scripts/lib/chartData.mjs` — `parseInput`, `classifyRelation`, `validateSpec` e o shape `ChartSpec`; o entry `scripts/build-chart-from-data.mjs` é orquestrador fino; testes unit cobrem o núcleo. | B) Lógica dentro do entry `.mjs`. | C) Lógica em `src/lib/*.ts` compartilhada com o app. +- **Recomendação:** A — Dependency Rule: o núcleo (parse/classificação/guardrails) não conhece Chromium nem CSS e roda em `test:unit` sem DB/rede; o renderer/emissor é a camada externa. Unit-testável e pinado: parsing de cada formato (fixtures inline, sem PII), classificação (`tempo→linha/coluna`, `ranking/comparação→barra`, `1 medida→âncora`, ambíguo→`needsQuestion`), guardrails (base zero, ≤7 pontos, `pie` recusada no v1), mapeamento de tamanho→viewport, **golden** dos primitivos (`chartPrimitives`) e estrutura do HTML Instagram (canvas exato, paleta hex, headline ≥48px, rótulos ≥30px, rodapé/fonte). +- **Alternativas rejeitadas:** B porque não é importável e foge do pin de CI; C porque não é superfície de app (é ferramenta local) e travaria o núcleo ao build do Next. + +### Componentes / mudanças + +- **`.agents/skills/graficos-dados/SKILL.md`** (novo): frontmatter só `name`+`description` (aspas simples). Corpo: quando usar; fluxo (parse → propõe tipo → gera → confere/ajusta); **contrato do `chart-spec.json`**; uso do CLI (plain `node`, sem `tsx` — o builder é `.mjs` puro); guardrails; troubleshooting; links. Dado colado vira arquivo em `data/graficos-instagram/` antes do `--in`. +- **`.opencode/commands/graficos-dados.md`** (novo): frontmatter só `description:` (sem `model:`); corpo carrega a skill `graficos-dados` pelo nome exato, aponta `.agents/skills/graficos-dados/SKILL.md` e passa `$ARGUMENTS`. Satisfaz `tests/unit/opencodeCommands.unit.spec.ts`. +- **Sem `.opencode/agent/graficos-dados.md` no v1** (decisão iii). +- **`scripts/build-chart-from-data.mjs`** (novo entry, auto-entry em `knip.json` `scripts/*.mjs`): flags `--in=|-` / `--text-file=`, `--spec=` (replay sem reparse), `--inspect` (imprime a matriz parseada em JSON para o agente), `--type=`, `--headline=`, `--source=`, `--size=feed|square|story`, `--out=`. Lê → `parseInput` → (`classifyRelation` se `--type` ausente) → `validateSpec` (fail-closed) → `renderChartHtml` → `screenshotHtmlPng` → grava PNG + `chart-spec.json` intermediário. `die` via `dieWithLabel`/`parseEqualsFlags` de `scripts/lib/cli.mjs`. +- **`scripts/lib/chartData.mjs`** (novo): `parseInput`, `classifyRelation`, `validateSpec`, `SIZES` (1080×1350 / 1080×1080 / 1080×1920 + safe zones de stories 250px) e `CHART_TYPES` (`bar|column|line|anchor`). JSDoc para knip `types: error`. Sem Chromium/DOM. +- **`scripts/lib/chartPrimitives.mjs`** (novo): dono da emissão SVG dos tipos, extraído de `dossieRender.mjs` L895–993, parametrizado (`width/height/colors/classNames/format/highlight`); inclui o `lineChart` (novo) e o `proportionalPercent` (escala honesta compartilhada). A composição de barra/coluna/âncora do Instagram é CSS do template aprovado, com a proporção vinda do dono. Exportado e consumido pelos dois renderers. +- **`scripts/lib/graficosInstagramRender.mjs`** (novo): compõe canvas 1080 + regra de topo + kicker/contexto + headline + subtítulo + plot (via primitivos) + rodapé (fonte/nota + lockup tipográfico de marca) para os 3 tamanhos; CSS **inline**; paleta Solla verbatim; sem `asset-flag`/anotações do gate. +- **`scripts/lib/buildPdf.mjs`** (editar o dono): adicionar `screenshotHtmlPng` (aditivo; constantes A4 e `emitHtmlPairPdf` intactos). +- **`scripts/lib/dossieRender.mjs`** (editar o dono): remover os 5 `const` e importar de `chartPrimitives.mjs` com os defaults A4 atuais (saída inalterada). +- **Testes (novos):** `tests/unit/chartData.unit.spec.ts`, `tests/unit/chartPrimitives.unit.spec.ts` (golden), `tests/unit/graficosInstagramRender.unit.spec.ts`, `tests/unit/graficosDadosSkill.unit.spec.ts` (prosa da skill por strings literais). +- **`tests/unit/opencodeCommands.unit.spec.ts`** (editar): incluir `'graficos-dados'` no array `commands`. +- **`scripts/lib/test-affected-core.mjs`** (editar): adicionar `scripts/lib/chartData.mjs`, `scripts/lib/chartPrimitives.mjs`, `scripts/lib/graficosInstagramRender.mjs` a `SCRIPTS_SPEC_PINNED` (L31–90) — obrigatório porque os specs novos os importam; o invariante em `ciSkipInvariants.unit.spec.ts:192-216` recalcula o closure e exige igualdade exata. +- **`.gitignore`** (editar, após L93): bloco `# C191 — ...` com `/data/graficos-instagram/` e `/docs/research/graficos-instagram/`. +- **`docs/changelog/2026-09-18-c191-graficos-instagram.md`** (novo). +- **`package.json`:** sem dep nova e **sem script novo** (o builder é chamado direto, como os irmãos); nada de migration/schema/DB. +- **Access / Consent:** não se aplica (ferramenta local; sem collection, sem PII, sem escrita de banco). + +### Dados → forma (pergunta 3 de data-presentation) + +- **Forma escolhida:** **barras horizontais** para ranking/comparação (rótulo direto + valor na ponta, ordenado por valor, barras do zero); **colunas** para poucos períodos (base zero, `≤~7`); **linha** para série temporal contínua (ponto final destacado); **número-âncora** para uma única medida. Um único destaque `#c51414` (~≤10% da peça) sobre o resto neutro; fonte/ressalva dentro da imagem. +- **Rejeitadas:** pizza (v1 **não** gera; pedido `pie` é recusado com sugestão de barras — cena 05 do gate); pizza >5 fatias (recusa fail-closed); 3D/perspectiva/eixo truncado (nunca implementados); dashboard/segundo renderer/interatividade (fora de escopo). + +## Fases verificáveis + +1. **Tracer — extração sem mudança de comportamento** (quota: ~0,5 dia). Mover os primitivos para `chartPrimitives.mjs`, rewiring de `dossieRender.mjs`, golden spec comparando a string SVG. Prova: `pnpm test:unit` verde e `git diff` em `dossieRender.mjs` apenas de import/remoção. +2. **Tracer — screenshot PNG** (~0,5 dia). `screenshotHtmlPng` em `buildPdf.mjs` + smoke com um canvas dummy em cada tamanho; verificar dimensões e `<8MB`. +3. **Núcleo de dados** (~0,5 dia). `chartData.mjs` + `chart-spec` + unit specs de parse/classificação/guardrails. +4. **Renderer Instagram** (~0,5 dia). `graficosInstagramRender.mjs` para os 4 tipos × 3 tamanhos, paleta/contraste/tipografia mínimas, rodapé/lockup. +5. **Superfície + pinos** (~0,5 dia). skill + command; `opencodeCommands.unit.spec.ts`; `SCRIPTS_SPEC_PINNED`; `.gitignore`; changelog. Rodar a skill de ponta a ponta com `xlsx`, `csv`, `md` e texto. +6. **Gates** — `pnpm gate:fast` (lint + typecheck + unit); conferir `knip` (exports consumidos) e `check:cycles`; `pnpm push` (o PR CI roda a cascata). + +## Rabbit holes / Não escopo (engenharia) + +- **Segundo renderer de gráfico:** proibido; o dono é `chartPrimitives.mjs` (decisão i). +- **Parametrizar o dossiê A4 além do necessário:** não mudar layout, classes ou CSS do dossiê; a extração preserva a saída. +- **`docx`/`mammoth`, MCP de gráfico, serviço remoto:** fora do v1 (render local). +- **Editor gráfico:** sem cores/fontes/eixos configuráveis além de tipo/rótulo/tamanho/destaque. +- **Fontes self-hosted / logo oficial:** v1 usa stack de sistema e o lockup tipográfico do hi-fi; o asset oficial (NEEDS ASSET) é gatilho futuro. +- **DRY especulativo:** não criar abstrações para tipos não pedidos (pizza, stacked, área). +- **Migration/schema/DB e persistência de dado:** fora; artefato gitignored. + +## Adiado com gatilho (triage pós-simplify) + +- **Módulo-folha de constantes** (`SIZES`/`RELATION_LABEL` sem `xlsx`/`csv-parse`) + — hoje o renderer importa de `chartData.mjs`, arrastando os parsers. Gatilho: + um consumidor de `SIZES`/`RELATION_LABEL` sem parsers (renderer em bundle/browser). +- **Decompor `main()` do entry** em `buildSpec`/`emitPng`. Gatilho: modo lote/2ª + saída ou entry > ~200 linhas. + +Cobertura de runtime (parse ambíguo pt-BR, teste do entry, smoke real do PNG): +registrada como `C191-runtime` (`depends: [1145]`), plano +`docs/plans/graficos-dados-instagram-runtime-followups.md`. + +## Riscos e mitigação + +- **Regressão no dossiê por tocar `dossieRender.mjs` (high-risk/spec-pinned):** extração mínima, golden spec dos primitivos, defaults idênticos; rodar os builders de dossiê num fixture e comparar HTML intermediário (amostra). +- **Fonte não determinística no PNG (Inter ausente / fallback):** v1 usa stack de sistema com `document.fonts.ready`; medir nos dois ambientes; se houver drift, avaliar embed de woff2 (novo item, não neste PR). +- **`page.screenshot` capturar tamanho errado:** `.canvas` com dimensões exatas + `clip` e viewport iguais; teste de dimensão do PNG. +- **PNG >8MB:** guarda falha fechado; arte é vetorial/flat (esperado ≪8MB). +- **Dados ambíguos/enganosos:** `validateSpec` fail-closed + fluxo de pergunta da skill; nunca completa nem infere. +- **`xlsx@0.18.5` tem CVEs conhecidas (devDep):** uso local com insumo da própria equipe; preferir CSV quando possível; documentar. +- **Pin de CI esquecido:** adicionar os 3 libs a `SCRIPTS_SPEC_PINNED` no mesmo commit; o invariante falha se divergir. +- **knip `exports: error`:** todo export novo precisa de consumidor real (entry/renderer/spec) — já coberto pelo desenho. + +## Aceite de engenharia + +- [x] Rodar a skill/entry com `xlsx`, `csv`, `md` e texto gera PNG `1080×1350` legível (default) e `1080×1080`/`1080×1920` sob `--size`, todos sRGB e <8MB. +- [x] Tipos essenciais (barra, coluna, linha, número-âncora) escolhidos pela relação dos dados; `pie` recusada; base zero; ≤7 pontos (5 em stories); um destaque `#c51414`; headline ≥48px e rótulos ≥30px; contraste atendido. +- [x] Dado ambíguo/faltando → pergunta; nada é inventado nem completado. +- [x] Nenhum dado bruto/PNG commitado: `.gitignore` cobre `data/graficos-instagram/` e `docs/research/graficos-instagram/`. +- [x] Saída do dossiê A4 inalterada após a extração dos primitivos (golden + specs de dossiê verdes). +- [x] Invariantes AGENTS/engineering-standards: sem schema/migration/DB; sem PII; identificadores em inglês, copy pt-BR; "edite o dono". +- [x] Testes de domínio previstos: `chartData` (parse/classificação/guardrails), `chartPrimitives` (golden), `graficosInstagramRender` (estrutura/medidas), prosa da skill, e `opencodeCommands` atualizado; `SCRIPTS_SPEC_PINNED` sincronizado. +- [x] `pnpm gate:fast` verde; knip sem export órfão; push via `pnpm push`. + +## Self-score decision-quality + +1. **Decisões caras têm rejeitadas? — 5/5.** As cinco decisões exigidas (extração dos primitivos, HTML→PNG/screenshot, superfície pública, parsing, núcleo de tipos) trazem Opções/Recomendação/Rejeitadas nomeadas. +2. **Abordagem cabe no appetite? — 4/5.** Extração contida + 3 módulos + 1 entry cabem em ~2–3 dias; o único risco de appetite é o rewrite de `dossieRender.mjs`, mitigado por extração mínima e golden. +3. **Rabbit holes nomeados? — 5/5.** Segundo renderer, editor, `docx`, MCP, fontes/logo, DRY especulativo e DB estão explicitamente cortados. +4. **Depth check (reusa shells/helpers)? — 5/5.** Reusa `buildPdf.launchPdfBrowser`/emit como dono do Chromium, `cli.mjs` (`dieWithLabel`/`parseEqualsFlags`), os primitivos extraídos do próprio dono e o padrão skill+command dos irmãos; nenhum caminho paralelo. +5. **Intenção (aceite de produto) permanece satisfeita? — 5/5.** O outcome, os guardrails e os anti-goals seguem intactos; a engenharia não reescreveu o produto. + +**Média: 4,8 — passa o gate ≥4.** (Critério mais fraco: appetite, 4.) diff --git a/docs/plans/graficos-dados-instagram-runtime-followups.md b/docs/plans/graficos-dados-instagram-runtime-followups.md new file mode 100644 index 00000000..17fb3105 --- /dev/null +++ b/docs/plans/graficos-dados-instagram-runtime-followups.md @@ -0,0 +1,50 @@ +# Runtime follow-ups C191 — cobertura do entry/PNG e parse ambíguo + +Status: rascunho +Atualizado em: 2026-09-18 +Issue: a registrar (`C191-runtime`, `depends: [1145]`) +Pai: #1145 / `docs/plans/graficos-dados-instagram-impl.md` +Appetite: ~0,5 dia eng (fill-in) +Impeccable: A (só backend/ferramenta; sem superfície de UI) + +## Outcome + +Fechar a cobertura de runtime da skill `graficos-dados` e tornar explícito o +único parsing hoje silencioso, sem mudar o comportamento entregue no C191. + +## Fases (ordenadas por ROI) + +1. **Parse ambíguo pt-BR (score 3).** `parseNumber('1.234')` hoje decide + milhar × decimal em silêncio. Documentar e cobrir com unit a regra do + separador único; se a decisão for por perguntar, expor no `issues` do + `parseInput` (fail-closed), nunca adivinhar. +2. **Teste do entry (score 3).** Cobrir `scripts/build-chart-from-data.mjs`: + `--inspect`, montagem do spec, `--spec` replay, `needsQuestion`, `--source` + obrigatório e a linha de log por tamanho. Sem Chromium (injetar o emit ou + mockar o browser). +3. **Smoke real do PNG (score 3).** Exercitar `screenshotHtmlPng` com Chromium + real por tamanho (feed/quadrado/story), verificando dimensões exatas e o + guard de 8 MB — hoje só há guard com browser fake. + +## Já resolvido no simplify/critique (não reabrir) + +- Perda silenciosa de linha com valor faltando (header heurístico) — corrigido + e pinado (`chartData.unit.spec.ts`). +- `--size` inválido e `--highlight` inexistente agora falham fechado. +- `escapeHtml` gêmeo removido (usa `reportText.htmlEscape`). +- Padding de stories derivado de `SIZES.story.safe*`; cap de 5 pontos em stories. +- `proportionalPercent` com zero honesto (0 → 0%); typo de copy. + +## Explicitamente fora (descartes e defers deste triage) + +- `SIZES.*.label` sem consumidor no código — dobra no item de fronteira abaixo + se `SIZES` for movido; não vira Issue. +- `SOLLA_PALETTE.ptRed` sem consumidor — contrato verbatim da intenção, + intencional. +- Contexto extra em logs do entry — ENOENT/`--spec` inválido já embrulhados; + valor marginal. +- **Adiado com gatilho:** mover `SIZES`/`RELATION_LABEL` para um módulo-folha + sem `xlsx`/`csv-parse` (fronteira do renderer). Gatilho: um consumidor de + `SIZES`/`RELATION_LABEL` sem parsers (renderer em bundle/browser). +- **Adiado com gatilho:** decompor `main()` do entry em `buildSpec`/`emitPng`. + Gatilho: o entry ganhar modo lote/2ª saída ou passar de ~200 linhas. diff --git a/scripts/build-chart-from-data.mjs b/scripts/build-chart-from-data.mjs new file mode 100644 index 00000000..8a156f31 --- /dev/null +++ b/scripts/build-chart-from-data.mjs @@ -0,0 +1,156 @@ +/** + * Build an Instagram-ready chart PNG from pasted/attached data (C191). + * + * The skill `graficos-dados` orchestrates: it parses the input, decides the + * chart relation and writes the headline/source, then calls this entry. The + * entry is deterministic and fail-closed: guardrail violations and missing + * data exit non-zero instead of drawing something misleading. Rendered local, + * never committed (data/ and docs/research/ are gitignored). + * + * Usage: + * node scripts/build-chart-from-data.mjs --in=data/graficos-instagram/dados.csv \ + * --headline="Um território concentra o maior resultado" --source="TSE 2022" \ + * [--subtitle=...] [--note=...] [--type=bar] [--size=feed|square|story] \ + * [--highlight="Território A"] [--out=docs/research/graficos-instagram/x.png] + * node scripts/build-chart-from-data.mjs --in=dados.csv --inspect + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, extname, join } from 'node:path' + +import { launchPdfBrowser, screenshotHtmlPng } from './lib/buildPdf.mjs' +import { + MAX_POINTS, + MAX_POINTS_STORY, + SIZES, + classifyRelation, + parseInput, + validateSpec, +} from './lib/chartData.mjs' +import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs' +import { renderChartHtml } from './lib/graficosInstagramRender.mjs' + +const die = dieWithLabel('graficos-dados') +const repoRoot = process.cwd() + +const FORMAT_BY_EXT = { + '.xlsx': 'xlsx', + '.xls': 'xls', + '.csv': 'csv', + '.tsv': 'tsv', + '.md': 'md', + '.markdown': 'md', + '.txt': 'txt', +} + +const slugify = (value) => + String(value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) + +const today = () => new Date().toISOString().slice(0, 10) + +const readInput = async (flags) => { + const path = flags.in === '-' || flags.in === true ? null : String(flags.in ?? '') + if (!path) { + const chunks = [] + for await (const chunk of process.stdin) chunks.push(chunk) + return { text: Buffer.concat(chunks).toString('utf8'), format: 'txt' } + } + const format = FORMAT_BY_EXT[extname(path).toLowerCase()] ?? 'txt' + const buffer = await readFile(path) + if (format === 'xlsx' || format === 'xls') return { buffer, format } + return { text: buffer.toString('utf8'), format } +} + +const main = async () => { + const { flags } = parseEqualsFlags(process.argv.slice(2)) + + let spec + if (flags.spec) { + try { + spec = JSON.parse(await readFile(String(flags.spec), 'utf8')) + } catch (error) { + die(`falha ao ler --spec=${flags.spec}: ${error?.message ?? error}`) + } + } else { + if (!flags.in) die('informe --in= com os dados (ou --spec= para replay).') + let input + try { + input = await readInput(flags) + } catch (error) { + die(`falha ao ler --in=${flags.in}: ${error?.message ?? error}`) + } + const dataset = parseInput(input) + + if (flags.inspect) { + process.stdout.write(`${JSON.stringify(dataset, null, 2)}\n`) + return + } + + if (dataset.rows.length === 0 || dataset.issues.length > 0) { + process.stdout.write( + `${JSON.stringify({ needsQuestion: true, issues: dataset.issues, rows: dataset.rows }, null, 2)}\n`, + ) + die('dado ambíguo ou faltando — pergunte à pessoa antes de gerar (nada é completado).') + } + + const chartType = classifyRelation(dataset.rows, flags.type ? String(flags.type) : null) + spec = { + chartType, + size: flags.size ? String(flags.size) : 'feed', + headline: flags.headline ? String(flags.headline) : '', + subtitle: flags.subtitle ? String(flags.subtitle) : '', + source: flags.source ? String(flags.source) : '', + note: flags.note ? String(flags.note) : '', + highlight: flags.highlight ? String(flags.highlight) : null, + rows: dataset.rows, + } + } + + if (!spec.source) die('informe --source (a fonte viaja dentro da imagem).') + validateSpec(spec) + + const slug = slugify(spec.headline) + const specPath = join(repoRoot, 'data/graficos-instagram', `${slug || 'grafico'}.chart-spec.json`) + const outPath = flags.out + ? String(flags.out) + : join( + repoRoot, + 'docs/research/graficos-instagram', + `${slug || 'grafico'}-${today()}-${spec.size ?? 'feed'}.png`, + ) + + const html = renderChartHtml(spec) + const sizeKey = SIZES[spec.size] ? spec.size : 'feed' + const canvas = SIZES[sizeKey] + const pointCap = + sizeKey === 'story' && (spec.chartType === 'bar' || spec.chartType === 'column') + ? MAX_POINTS_STORY + : MAX_POINTS + const browser = await launchPdfBrowser() + try { + await mkdir(dirname(outPath), { recursive: true }) + await mkdir(dirname(specPath), { recursive: true }) + await writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`) + const { size } = await screenshotHtmlPng(browser, { + html, + width: canvas.width, + height: canvas.height, + outPath, + }) + console.log( + `[graficos-dados] PNG ${outPath} (${canvas.width}×${canvas.height}, ${Math.round(size / 1024)} KB) · tipo=${spec.chartType} · ${spec.rows.length} ponto(s) ≤ ${pointCap}`, + ) + } finally { + await browser.close() + } +} + +main().catch((error) => { + die(error?.message ?? String(error)) +}) diff --git a/scripts/lib/buildPdf.mjs b/scripts/lib/buildPdf.mjs index 0f2909ec..ee8c94f0 100644 --- a/scripts/lib/buildPdf.mjs +++ b/scripts/lib/buildPdf.mjs @@ -11,6 +11,7 @@ */ import { chromium } from '@playwright/test' +import { stat } from 'node:fs/promises' const MM_TO_PX = 96 / 25.4 export const A4_HEIGHT_PX = Math.round(297 * MM_TO_PX) @@ -21,6 +22,38 @@ export const A4_PAGE_BUDGET_PX = A4_HEIGHT_PX + PAGE_FIT_TOLERANCE_PX export const launchPdfBrowser = () => chromium.launch() +/** + * Exact-size PNG of a screen-media document (C191 Instagram canvas). The + * `.canvas` element is authored at the output size, so the viewport and the + * clip are the same rectangle; no A4 constant is touched. Chromium PNGs are + * already sRGB. Fails closed when the file exceeds `maxBytes` (default 8 MB). + */ +export const screenshotHtmlPng = async ( + browser, + { html, width, height, outPath, maxBytes = 8 * 1024 * 1024 }, +) => { + const page = await browser.newPage() + try { + await page.setViewportSize({ width, height }) + await page.setContent(html, { waitUntil: 'load' }) + await page.evaluate(() => document.fonts.ready) + await page.screenshot({ + path: outPath, + type: 'png', + clip: { x: 0, y: 0, width, height }, + }) + } finally { + await page.close() + } + const { size } = await stat(outPath) + if (size > maxBytes) { + throw new Error( + `PNG ${outPath} tem ${size} bytes (> ${maxBytes}); simplifique o gráfico ou reduza pontos.`, + ) + } + return { size } +} + /** Every `[data-page]` taller than the budget, in document order. */ const measurePageOverflows = (page, budgetPx = A4_PAGE_BUDGET_PX) => page.evaluate( diff --git a/scripts/lib/chartData.mjs b/scripts/lib/chartData.mjs new file mode 100644 index 00000000..7099588f --- /dev/null +++ b/scripts/lib/chartData.mjs @@ -0,0 +1,334 @@ +/** + * Chart data core (C191): parse the numbers the communication team pasted or + * dropped (xlsx/csv/md/txt/free text), pick the chart relation and enforce the + * correctness guardrails — all pure, no Chromium and no DOM, so the unit suite + * covers it without a database or a browser. + * + * The renderer (`graficosInstagramRender.mjs`) owns the look; this module never + * invents a value: anything missing or ambiguous becomes an explicit question. + */ + +import { parse as parseCsv } from 'csv-parse/sync' +import XLSX from 'xlsx' + +export const CHART_TYPES = ['bar', 'column', 'line', 'anchor'] + +/** Max data points per chart (honest density; above this the person must summarize). */ +export const MAX_POINTS = 7 + +/** + * Stories recompose the vertical rhythm with one fewer ranking point (approved + * design: "reduz um ponto do ranking"), so a six/seven-point ranking is refused + * instead of crowded. + */ +export const MAX_POINTS_STORY = 5 + +/** Output canvases: feed 4:5 (default), square, stories/reels 9:16 with safe bands. */ +export const SIZES = { + feed: { width: 1080, height: 1350, label: '1080×1350', safeTop: 0, safeBottom: 0 }, + square: { width: 1080, height: 1080, label: '1080×1080', safeTop: 0, safeBottom: 0 }, + story: { width: 1080, height: 1920, label: '1080×1920', safeTop: 250, safeBottom: 250 }, +} + +/** Kicker of each relation, matching the approved hi-fi template. */ +export const RELATION_LABEL = { + bar: 'Comparação', + column: 'Poucos períodos', + line: 'Série de tempo', + anchor: 'Número-âncora', +} + +const TEMPORAL_RE = + /^(\d{4}|\d{2}|[A-Za-zÀ-ÿ]{3,9}[./-]?\d{2,4}|\d{1,2}[./-]\d{2,4}|[TQ]\d|P\d+|\d{4}[./-]\d{1,2})$/u + +/** True when the label reads as a period (year, month, quarter, P1…), not a category. */ +export const isTemporalLabel = (label) => TEMPORAL_RE.test(String(label).trim()) + +/** + * pt-BR/en number: strips currency/spaces/percent, treats the last separator as + * the decimal when both `.` and `,` appear, and comma-as-decimal otherwise. + * Returns null (never 0) for anything non-numeric, so "missing" stays honest. + */ +export const parseNumber = (raw) => { + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null + let value = String(raw ?? '') + .replace(/[R$\s%]/gi, '') + .trim() + if (value === '') return null + const hasDot = value.includes('.') + const hasComma = value.includes(',') + if (hasDot && hasComma) { + value = + value.lastIndexOf(',') > value.lastIndexOf('.') + ? value.replaceAll('.', '').replace(',', '.') + : value.replaceAll(',', '') + } else if (hasComma) { + value = value.replace(',', '.') + } + if (!/^-?\d+(\.\d+)?$/.test(value)) return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +const lastCell = (row) => row[row.length - 1] + +const normalizeWord = (value) => + String(value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + +/** + * A header row is only recognized by an explicit header word in some cell — + * never by "the first value is not numeric", which would drop a real data row + * with a missing value ("Ilhéus n/d") in silence. Unknown headers survive as a + * data row and become an explicit question instead. + */ +const HEADER_HINTS = new Set([ + 'valor', + 'value', + 'total', + 'quantidade', + 'qtd', + 'numero', + 'montante', + 'percentual', + 'percent', + '%', + 'taxa', + 'media', + 'indice', + 'votos', + 'count', + 'amount', + 'resultado', + 'unidade', + 'ano', + 'mes', + 'periodo', + 'data', + 'indicador', + 'metrica', + 'r$', + 'populacao', + 'eleitores', + 'eleitorado', + 'cidade', + 'municipio', + 'categoria', + 'grupo', + 'regiao', + 'estado', + 'partido', + 'candidato', +]) + +const looksLikeHeaderRow = (row) => + row.some((cell) => + normalizeWord(cell) + .split(/[^a-z0-9%$]+/) + .some((word) => HEADER_HINTS.has(word)), + ) + +const cleanMatrix = (matrix) => + matrix + .map((row) => row.map((cell) => String(cell ?? '').trim())) + .filter((row) => row.some(Boolean)) + +const matrixToDataset = (matrix, format) => { + const cleaned = cleanMatrix(matrix) + const issues = [] + if (cleaned.length === 0) return { rows: [], format, issues: ['nenhum dado encontrado'] } + + let start = 0 + if ( + cleaned.length > 1 && + parseNumber(lastCell(cleaned[0])) === null && + looksLikeHeaderRow(cleaned[0]) + ) { + start = 1 + } + + const rows = [] + for (const row of cleaned.slice(start)) { + const label = row[0] + const rawValue = lastCell(row) + const value = parseNumber(rawValue) + if (!label && value === null) { + issues.push(`linha sem rótulo: ${JSON.stringify(row.join(' | '))}`) + continue + } + if (value === null) { + issues.push(`valor não numérico em "${label}": ${JSON.stringify(rawValue)}`) + continue + } + rows.push({ label, value }) + } + + const seen = new Set() + for (const row of rows) { + if (seen.has(row.label)) issues.push(`rótulo repetido: "${row.label}"`) + seen.add(row.label) + } + return { rows, format, issues } +} + +const sniffDelimiter = (line) => { + let best = null + let bestCount = 0 + for (const delimiter of ['\t', ';', ',']) { + const count = line.split(delimiter).length - 1 + if (count > bestCount) { + bestCount = count + best = delimiter + } + } + return bestCount > 0 ? best : null +} + +const isMarkdownTable = (lines) => + lines.length > 1 && lines[0].includes('|') && /^\s*\|?[\s:-]*-{3,}[\s:|-]*\|?\s*$/.test(lines[1]) + +const parseMarkdownTable = (lines) => + lines + .filter((line) => line.includes('|')) + .filter((_line, index) => index !== 1) + .map((line) => + line + .replace(/^\s*\|/, '') + .replace(/\|\s*$/, '') + .split('|') + .map((cell) => cell.trim()), + ) + +/** Last separator wins, so labels may contain spaces: "Votos em Ilhéus: 84". */ +const splitPair = (line) => { + const separators = [':', '=', '\t'] + for (const separator of separators) { + const at = line.lastIndexOf(separator) + if (at > 0) return [line.slice(0, at), line.slice(at + 1)] + } + const match = line.match(/^(.*\S)\s+([^\s]+)$/s) + if (match) return [match[1], match[2]] + // A bare number is an unlabeled single measure (the anchor relation). + if (parseNumber(line) !== null) return ['', line] + return [line, ''] +} + +const parseFreeText = (text) => { + const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '') + if (lines.length === 0) return { rows: [], format: 'txt', issues: ['nenhum dado encontrado'] } + if (isMarkdownTable(lines)) return matrixToDataset(parseMarkdownTable(lines), 'md') + const delimiter = sniffDelimiter(lines[0]) + if (delimiter) { + const matrix = lines.map((line) => line.split(delimiter)) + return matrixToDataset(matrix, delimiter === '\t' ? 'tsv' : 'csv') + } + const matrix = lines.map(splitPair) + return matrixToDataset(matrix, 'txt') +} + +/** + * Parse the raw input into `{ rows, format, issues }`. `buffer` is required for + * xlsx/xls; `text` for csv/md/txt. `format` overrides the extension inference. + * + * @param {{ text?: string | null, buffer?: Buffer | null, format?: string | null }} [options] + */ +export const parseInput = ({ text = null, buffer = null, format = null } = {}) => { + if (format === 'xlsx' || format === 'xls') { + const workbook = XLSX.read(buffer, { type: 'buffer' }) + const sheet = workbook.Sheets[workbook.SheetNames[0]] + const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false }) + return matrixToDataset(matrix, 'xlsx') + } + const body = String(text ?? '') + if (format === 'csv' || format === 'tsv') { + const delimiter = + format === 'tsv' ? '\t' : (sniffDelimiter(body.split(/\r?\n/)[0] ?? '') ?? ',') + const matrix = parseCsv(body, { + delimiter, + skip_empty_lines: true, + relax_column_count: true, + }) + return matrixToDataset(matrix, format) + } + return parseFreeText(body) +} + +/** + * Relation of the data → chart type. One measure is an anchor; every-temporal + * labels are a series (few periods as columns, many as a line); anything else + * is a ranking/comparison in horizontal bars. + * + * @param {{ label: string, value: number }[]} rows + * @param {string | null} [forcedType] + */ +export const classifyRelation = (rows, forcedType = null) => { + if (forcedType) { + // `pie` passes through so `validateSpec` answers with the actionable + // "use barras horizontais" message instead of a generic unknown type. + if (forcedType !== 'pie' && !CHART_TYPES.includes(forcedType)) { + throw new Error( + `tipo de gráfico desconhecido: ${JSON.stringify(forcedType)} (use ${CHART_TYPES.join(', ')})`, + ) + } + return forcedType + } + if (rows.length === 1) return 'anchor' + if (rows.length > 1 && rows.every((row) => isTemporalLabel(row.label))) { + return rows.length <= 4 ? 'column' : 'line' + } + return 'bar' +} + +/** + * Fail-closed guardrails of the approved template. Throws with the actionable + * reason instead of drawing a misleading chart. + */ +export const validateSpec = (spec) => { + const { chartType, rows, headline } = spec + if (spec.size && !SIZES[spec.size]) { + throw new Error( + `tamanho inválido: ${JSON.stringify(spec.size)} (use ${Object.keys(SIZES).join(', ')})`, + ) + } + if (!CHART_TYPES.includes(chartType)) { + if (chartType === 'pie') { + throw new Error( + 'pizza não é gerada no v1 (a comparação fica escondida); use barras horizontais.', + ) + } + throw new Error(`tipo de gráfico inválido: ${JSON.stringify(chartType)}`) + } + if (!Array.isArray(rows) || rows.length === 0) + throw new Error('sem pontos de dado para desenhar.') + if (rows.length > MAX_POINTS) { + throw new Error( + `${rows.length} pontos (> ${MAX_POINTS}): resuma as categorias antes de gerar o gráfico.`, + ) + } + if ( + spec.size === 'story' && + (chartType === 'bar' || chartType === 'column') && + rows.length > MAX_POINTS_STORY + ) { + throw new Error( + `stories comporta até ${MAX_POINTS_STORY} pontos no ranking (${rows.length} recebidos): resuma para gerar a versão vertical.`, + ) + } + if (chartType === 'anchor' && rows.length !== 1) { + throw new Error('número-âncora exige exatamente um valor.') + } + for (const row of rows) { + if (!row.label && chartType !== 'anchor') throw new Error('ponto de dado sem rótulo.') + if (!Number.isFinite(row.value)) throw new Error(`valor ausente/inválido em "${row.label}".`) + if (row.value < 0) throw new Error(`valor negativo em "${row.label}" — barras partem do zero.`) + } + if (spec.highlight && !rows.some((row) => row.label === spec.highlight)) { + throw new Error(`destaque "${spec.highlight}" não existe nos dados — confira o rótulo.`) + } + if (typeof headline !== 'string' || headline.trim() === '') { + throw new Error('título-manchete ausente — a manchete é obrigatória.') + } + return spec +} diff --git a/scripts/lib/chartPrimitives.mjs b/scripts/lib/chartPrimitives.mjs new file mode 100644 index 00000000..809692bd --- /dev/null +++ b/scripts/lib/chartPrimitives.mjs @@ -0,0 +1,185 @@ +/** + * Chart primitives (owner of the drawing): inline-SVG emitters shared by the + * A4 dossier renderer (C186/C187) and the Instagram chart renderer (C191). + * + * Extracted from `dossieRender.mjs` when the Instagram generator became the + * second consumer, so a fix to the honest-scale/guardrail drawing lives once. + * The dossier keeps its mm/pt page composition; each consumer composes its own + * page around these primitives — never a second copy of the drawing. + */ + +import { htmlEscape } from './reportText.mjs' + +/** + * Domain palette of the dossier charts. Instagram passes its own Solla palette + * explicitly; this map stays the dossier default so its output is unchanged. + */ +export const CHART_COLORS = { + municipio: '#315c75', + regiao: '#9d8b64', + polo: '#8f819c', + instituicao: '#315c75', + setor: '#9d8b64', + rede: '#8f819c', + empenhado: '#6b4918', + liquidado: '#4a6b7c', + pago: '#285338', + autorizado: '#8a5a18', + restos: '#5b6470', + nao_informado: '#8b96a2', + default: '#315c75', +} + +/** + * Honest-scale proportion of a value against the maximum, as a percentage + * (0..100). Zero stays zero (no bar is drawn); a present small value keeps a + * visible minimum so it is not mistaken for absent. Used for the ranking width + * and the column height of the Instagram renderer. + */ +export const proportionalPercent = (value, max) => + value <= 0 ? 0 : Math.min(100, Math.max(2, Math.round((value / Math.max(1, max)) * 100))) + +export const barChart = ({ rows, width = 330, rowHeight = 19, labelWidth = 118 }) => { + const max = Math.max(1, ...rows.map((row) => row.count)) + const barMax = width - labelWidth - 26 + const height = rows.length * rowHeight + 4 + return ` + ${rows + .map((row, index) => { + const y = index * rowHeight + 3 + const barWidth = Math.max(2, Math.round((row.count / max) * barMax)) + return `${htmlEscape(row.label)} + + ${row.count}` + }) + .join('')} + ` +} + +/** HTML list with a proportional bar per row — long labels wrap, nothing overlaps. */ +export const valueList = ({ rows, format }) => { + const max = Math.max(1, ...rows.map((row) => row.value)) + return `
    + ${rows + .map( + (row) => `
  • + ${htmlEscape(row.label)} + + ${htmlEscape(format(row))} +
  • `, + ) + .join('')} +
` +} + +/** + * Vertical columns with the value label above and the year label below. + * `fullYearLabel` prints the whole year (theme axis); the institution keeps the + * 2-digit label. + */ +export const columnChart = ({ + rows, + width = 330, + height = 132, + format = (row) => String(row.count), + fullYearLabel = false, +}) => { + const max = Math.max(1, ...rows.map((row) => row.value ?? row.count)) + const baseY = height - 18 + const plotHeight = baseY - 16 + const columnWidth = (width - 8) / Math.max(1, rows.length) + return ` + ${rows + .map((row, index) => { + const value = row.value ?? row.count + const barHeight = Math.max(2, Math.round((value / max) * plotHeight)) + const x = 4 + index * columnWidth + const y = baseY - barHeight + return ` + ${htmlEscape(format(row))} + ${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}` + }) + .join('')} + ` +} + +/** Vertical columns stacked by phase (money by year), total label above. */ +export const stackedColumnChart = ({ + rows, + width = 330, + height = 132, + format, + fullYearLabel = false, +}) => { + const max = Math.max(1, ...rows.map((row) => row.value)) + const baseY = height - 18 + const plotHeight = baseY - 16 + const columnWidth = (width - 8) / Math.max(1, rows.length) + return ` + ${rows + .map((row, index) => { + const x = 4 + index * columnWidth + let y = baseY + const bars = row.segments + .map((segment) => { + const segmentHeight = Math.max(1, Math.round((segment.amount / max) * plotHeight)) + y -= segmentHeight + return `` + }) + .join('') + const top = baseY - Math.round((row.value / max) * plotHeight) + return `${bars} + ${htmlEscape(format(row))} + ${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}` + }) + .join('')} + ` +} + +/** + * Time-series line (Instagram): every point a hollow marker, the last one the + * single red highlight with its value. Honest scale — the baseline is zero. + */ +export const lineChart = ({ + rows, + width = 880, + height = 420, + format, + stroke = '#78716c', + highlight = '#c51414', + paper = '#faf9f7', + ink = '#1c1917', + grid = '#a8a29e', +}) => { + const max = Math.max(1, ...rows.map((row) => row.value)) + const padX = 46 + const padTop = 42 + const baseline = height - 46 + const plotHeight = baseline - padTop + const step = rows.length > 1 ? (width - padX * 2) / (rows.length - 1) : 0 + const pointAt = (row, index) => { + const x = padX + index * step + const y = baseline - Math.round((row.value / max) * plotHeight) + return { x, y } + } + const points = rows.map(pointAt) + const last = points[points.length - 1] + return ` + + + + ${points + .slice(0, -1) + .map((point) => ``) + .join('')} + + + ${htmlEscape(format(rows[rows.length - 1]))} + ${rows + .map( + (row, index) => + `${htmlEscape(row.label)}`, + ) + .join('')} + ` +} diff --git a/scripts/lib/dossieRender.mjs b/scripts/lib/dossieRender.mjs index 1d961981..7336ae84 100644 --- a/scripts/lib/dossieRender.mjs +++ b/scripts/lib/dossieRender.mjs @@ -5,6 +5,13 @@ * shared escaping/source-token contract lives in `reportText.mjs`. */ +import { + CHART_COLORS, + barChart, + columnChart, + stackedColumnChart, + valueList, +} from './chartPrimitives.mjs' import { formatDateBr, formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs' import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs' import { MUNICIPALITY_UNIT, isSubjectUnit, resolveDossierUnit } from './dossieUnit.mjs' @@ -897,118 +904,6 @@ const groupUnits = (chunk) => { const tableHead = (columns) => `${columns.map((column) => `${htmlEscape(column)}`).join('')}` -/* ---------------------------------------------------------------- * - * Synthesis charts (inline SVG, print-safe): aggregations of the * - * sourced rows only — never a new fact, never a combined total * - * across abrangências. * - * ---------------------------------------------------------------- */ - -const CHART_COLORS = { - municipio: '#315c75', - regiao: '#9d8b64', - polo: '#8f819c', - instituicao: '#315c75', - setor: '#9d8b64', - rede: '#8f819c', - empenhado: '#6b4918', - liquidado: '#4a6b7c', - pago: '#285338', - autorizado: '#8a5a18', - restos: '#5b6470', - nao_informado: '#8b96a2', - default: '#315c75', -} - -const barChart = ({ rows, width = 330, rowHeight = 19, labelWidth = 118 }) => { - const max = Math.max(1, ...rows.map((row) => row.count)) - const barMax = width - labelWidth - 26 - const height = rows.length * rowHeight + 4 - return ` - ${rows - .map((row, index) => { - const y = index * rowHeight + 3 - const barWidth = Math.max(2, Math.round((row.count / max) * barMax)) - return `${htmlEscape(row.label)} - - ${row.count}` - }) - .join('')} - ` -} - -/** HTML list with a proportional bar per row — long labels wrap, nothing overlaps. */ -const valueList = ({ rows, format }) => { - const max = Math.max(1, ...rows.map((row) => row.value)) - return `
    - ${rows - .map( - (row) => `
  • - ${htmlEscape(row.label)} - - ${htmlEscape(format(row))} -
  • `, - ) - .join('')} -
` -} - -/** - * Vertical columns with the value label above and the year label below. `fullYearLabel` - * prints the whole year (theme axis); the institution keeps the 2-digit label. - */ -const columnChart = ({ - rows, - width = 330, - height = 132, - format = (row) => String(row.count), - fullYearLabel = false, -}) => { - const max = Math.max(1, ...rows.map((row) => row.value ?? row.count)) - const baseY = height - 18 - const plotHeight = baseY - 16 - const columnWidth = (width - 8) / Math.max(1, rows.length) - return ` - ${rows - .map((row, index) => { - const value = row.value ?? row.count - const barHeight = Math.max(2, Math.round((value / max) * plotHeight)) - const x = 4 + index * columnWidth - const y = baseY - barHeight - return ` - ${htmlEscape(format(row))} - ${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}` - }) - .join('')} - ` -} - -/** Vertical columns stacked by phase (money by year), total label above. */ -const stackedColumnChart = ({ rows, width = 330, height = 132, format, fullYearLabel = false }) => { - const max = Math.max(1, ...rows.map((row) => row.value)) - const baseY = height - 18 - const plotHeight = baseY - 16 - const columnWidth = (width - 8) / Math.max(1, rows.length) - return ` - ${rows - .map((row, index) => { - const x = 4 + index * columnWidth - let y = baseY - const bars = row.segments - .map((segment) => { - const segmentHeight = Math.max(1, Math.round((segment.amount / max) * plotHeight)) - y -= segmentHeight - return `` - }) - .join('') - const top = baseY - Math.round((row.value / max) * plotHeight) - return `${bars} - ${htmlEscape(format(row))} - ${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}` - }) - .join('')} - ` -} - const identityBadgeList = (report) => report.meta.identityBadges .map((badge) => `${htmlEscape(badge)}`) diff --git a/scripts/lib/graficosInstagramRender.mjs b/scripts/lib/graficosInstagramRender.mjs new file mode 100644 index 00000000..368c8117 --- /dev/null +++ b/scripts/lib/graficosInstagramRender.mjs @@ -0,0 +1,242 @@ +/** + * Instagram chart renderer (C191): composes the approved hi-fi template + * (`docs/plans/graficos-dados-instagram-ui-design.html`) into a single canvas + * HTML document, class-by-class, with inline CSS (the pipeline has no bundler + * and the render must never need the network). One red highlight, honest scale, + * headline-manchete, source and brand inside the image. + * + * The page is authored at the exact export size; `buildPdf.screenshotHtmlPng` + * only sets the viewport and clips the canvas. + */ + +import { RELATION_LABEL, SIZES } from './chartData.mjs' +import { lineChart, proportionalPercent } from './chartPrimitives.mjs' +import { htmlEscape } from './reportText.mjs' + +/** Solla palette (verbatim from the intention). */ +export const SOLLA_PALETTE = { + highlight: '#c51414', + brandDark: '#ae1603', + ptRed: '#a21c1c', + ptYellow: '#ffe607', + ink: '#1c1917', + hairline: '#e7e5e4', + paper: '#faf9f7', + bar: '#d6d3d1', + barStrong: '#a8a29e', + axis: '#78716c', + label: '#57534e', +} + +const FONT_STACK = + "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" + +const formatValue = (value) => + new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value) + +const sortedByValue = (rows) => [...rows].sort((a, b) => b.value - a.value) + +const highlightLabel = (spec) => spec.highlight ?? sortedByValue(spec.rows)[0]?.label ?? null + +const rankingBody = (spec) => { + const rows = sortedByValue(spec.rows) + const max = Math.max(...rows.map((row) => row.value)) + const winner = highlightLabel(spec) + return `
+ ${rows + .map((row) => { + const percent = proportionalPercent(row.value, max) + const isHighlight = row.label === winner + return `
+ ${htmlEscape(row.label)} +
+
${formatValue(row.value)}
+
+
` + }) + .join('')} +
` +} + +const columnBody = (spec) => { + const rows = spec.rows + const max = Math.max(...rows.map((row) => row.value)) + const winner = highlightLabel(spec) + return `
+ ${rows + .map((row) => { + const isHighlight = row.label === winner + return `
+ ${formatValue(row.value)} +
+ ${htmlEscape(row.label)} +
` + }) + .join('')} +
` +} + +const anchorBody = (spec) => { + const row = spec.rows[0] + const copy = row.label || spec.subtitle || '' + return `
+
${formatValue(row.value)}
+ ${copy ? `

${htmlEscape(copy)}

` : ''} +
` +} + +const plotBody = (spec) => { + if (spec.chartType === 'anchor') return anchorBody(spec) + if (spec.chartType === 'column') return columnBody(spec) + if (spec.chartType === 'line') { + return `
${lineChart({ + rows: spec.rows, + width: 880, + height: 430, + format: (row) => formatValue(row.value), + stroke: SOLLA_PALETTE.axis, + highlight: SOLLA_PALETTE.highlight, + paper: SOLLA_PALETTE.paper, + ink: SOLLA_PALETTE.ink, + grid: SOLLA_PALETTE.barStrong, + })}
` + } + return rankingBody(spec) +} + +const brandLockup = () => `
+ +
+
JORGE SOLLA
+
MANDATO DEPUTADO FEDERAL
+
+
` + +const footer = (spec) => `
+

Fonte: ${htmlEscape(spec.source)}${spec.note ? `
Nota: ${htmlEscape(spec.note)}` : ''}

+ ${brandLockup()} +
` + +const CSS = `* { box-sizing: border-box; } +html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; } +.canvas { + position: relative; + overflow: hidden; + color: ${SOLLA_PALETTE.ink}; + background: ${SOLLA_PALETTE.paper}; + font-family: ${FONT_STACK}; + -webkit-font-smoothing: antialiased; +} +.inner { position: absolute; inset: 0; display: flex; flex-direction: column; } +.top-rule { width: 64px; height: 10px; background: ${SOLLA_PALETTE.highlight}; } +.context { margin: 0; color: ${SOLLA_PALETTE.brandDark}; font-size: 30px; line-height: 1.2; font-weight: 750; } +.headline { margin: 0; font-weight: 800; line-height: 1.03; letter-spacing: -0.045em; } +.subtitle { margin: 0; color: ${SOLLA_PALETTE.label}; font-weight: 450; line-height: 1.35; } +.plot { flex: 1; min-height: 0; } +.rank { display: grid; align-content: start; } +.rank-row { display: grid; grid-template-columns: 250px 1fr; align-items: center; gap: 22px; } +.rank-label { font-size: 31px; line-height: 1.08; font-weight: 650; } +.bar-track { position: relative; height: 53px; border-left: 2px solid ${SOLLA_PALETTE.axis}; } +.bar { + display: flex; align-items: center; justify-content: flex-end; height: 100%; + min-width: 94px; padding-right: 16px; color: #292524; background: ${SOLLA_PALETTE.bar}; + font-size: 31px; line-height: 1; font-weight: 800; font-variant-numeric: tabular-nums; +} +.bar.highlight { color: #fff; background: ${SOLLA_PALETTE.highlight}; } +.bar.zero { min-width: 0; padding: 0 0 0 8px; justify-content: flex-start; } +.columns { display: grid; align-items: end; gap: 26px; height: 100%; padding-bottom: 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong}; } +.column-cell { position: relative; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; } +.column-value { margin-bottom: 10px; text-align: center; font-size: 31px; font-weight: 800; font-variant-numeric: tabular-nums; } +.column { background: ${SOLLA_PALETTE.bar}; min-height: 6px; } +.column.highlight { background: ${SOLLA_PALETTE.highlight}; } +.column-label { position: absolute; bottom: -40px; left: 0; width: 100%; text-align: center; color: ${SOLLA_PALETTE.label}; font-size: 30px; font-weight: 650; } +.line-plot { height: 100%; display: flex; align-items: center; } +.line-plot svg { width: 100%; height: auto; } +.anchor { padding-top: 20px; } +.anchor-number { color: ${SOLLA_PALETTE.highlight}; font-size: 300px; line-height: 0.86; letter-spacing: -0.065em; font-weight: 900; font-variant-numeric: tabular-nums; } +.anchor-copy { max-width: 760px; margin: 40px 0 0; font-size: 52px; line-height: 1.12; font-weight: 750; } +.footer { + margin-top: auto; padding-top: 25px; border-top: 2px solid ${SOLLA_PALETTE.hairline}; + display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 30px; +} +.source { margin: 0; color: ${SOLLA_PALETTE.label}; font-size: 27px; line-height: 1.32; } +.source strong { color: ${SOLLA_PALETTE.ink}; font-weight: 750; } +.brand { display: flex; align-items: center; gap: 13px; justify-content: flex-end; } +.brand-signal { width: 12px; height: 48px; background: ${SOLLA_PALETTE.ptYellow}; } +.brand-name { color: ${SOLLA_PALETTE.brandDark}; font-size: 29px; line-height: 0.88; font-weight: 900; letter-spacing: -0.045em; } +.brand-office { margin-top: 7px; color: ${SOLLA_PALETTE.label}; font-size: 17px; line-height: 1; font-weight: 750; letter-spacing: 0.04em; }` + +/** Per-size vertical rhythm of the template (feed 4:5, square, stories 9:16). */ +const layoutFor = (size) => { + if (size === 'story') { + const { safeTop, safeBottom } = SIZES.story + return { + // The content clears the Stories safe bands, plus the template's margin. + padding: `${safeTop + 56}px 84px ${safeBottom + 60}px`, + ruleGap: 34, + gapAfterContext: 18, + headline: 78, + subtitle: 32, + plotGap: 78, + } + } + if (size === 'square') { + return { + padding: '64px 84px 56px', + ruleGap: 28, + gapAfterContext: 16, + headline: 62, + subtitle: 30, + plotGap: 44, + } + } + return { + padding: '76px 84px 58px', + ruleGap: 34, + gapAfterContext: 18, + headline: 67, + subtitle: 31, + plotGap: 54, + } +} + +/** + * Full HTML document of the chart, at the exact export size. `spec` must have + * passed `validateSpec` (the entry does it before rendering). + */ +export const renderChartHtml = (spec) => { + const sizeKey = SIZES[spec.size] ? spec.size : 'feed' + const canvas = SIZES[sizeKey] + const layout = layoutFor(sizeKey) + const kicker = RELATION_LABEL[spec.chartType] ?? 'Gráfico' + const anchorUsesSubtitle = spec.chartType === 'anchor' && !spec.rows[0]?.label + const showSubtitle = Boolean(spec.subtitle) && !anchorUsesSubtitle + return ` + + + + + + + + + +` +} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 00459094..59d0690a 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -47,6 +47,8 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/camaraFetch.mjs', 'scripts/lib/camaraSpeeches.mjs', 'scripts/lib/changelog.mjs', + 'scripts/lib/chartData.mjs', + 'scripts/lib/chartPrimitives.mjs', 'scripts/lib/cityReportBlocks.mjs', 'scripts/lib/cityReportDatabase.mjs', 'scripts/lib/cityReportFormat.mjs', @@ -71,6 +73,7 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/github-api.mjs', 'scripts/lib/github-branch-protection.mjs', 'scripts/lib/github-pr-flow.mjs', + 'scripts/lib/graficosInstagramRender.mjs', 'scripts/lib/imageFill.mjs', 'scripts/lib/imageResize.mjs', 'scripts/lib/issues-panel.mjs', diff --git a/tests/unit/buildPdf.unit.spec.ts b/tests/unit/buildPdf.unit.spec.ts index ae5f5556..ee55f2d8 100644 --- a/tests/unit/buildPdf.unit.spec.ts +++ b/tests/unit/buildPdf.unit.spec.ts @@ -43,3 +43,57 @@ describe('emitHtmlPairPdf', () => { expect(typeof emitHtmlPairPdf).toBe('function') }) }) + +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { screenshotHtmlPng } from '../../scripts/lib/buildPdf.mjs' + +// C191: the PNG path is additive to the Chromium owner. The guard is on the +// critical path (a >8 MB export must fail closed), so it is pinned with a fake +// browser — no real Chromium launch in the unit suite. + +const fakeBrowser = (calls: string[]) => ({ + newPage: async () => ({ + setViewportSize: async (size: { width: number; height: number }) => + calls.push(`viewport:${size.width}x${size.height}`), + setContent: async () => calls.push('content'), + evaluate: async () => calls.push('fonts'), + screenshot: async (options: { clip: { width: number; height: number } }) => + calls.push(`shot:${options.clip.width}x${options.clip.height}`), + close: async () => calls.push('close'), + }), +}) + +describe('screenshotHtmlPng', () => { + it('sets the exact viewport, clips the canvas and closes the page', async () => { + const calls: string[] = [] + const outPath = join(await mkdtemp(join(tmpdir(), 'c191-')), 'out.png') + await writeFile(outPath, 'x') + const result = await screenshotHtmlPng(fakeBrowser(calls), { + html: '', + width: 1080, + height: 1350, + outPath, + }) + expect(result.size).toBe(1) + expect(calls).toContain('viewport:1080x1350') + expect(calls).toContain('shot:1080x1350') + expect(calls.at(-1)).toBe('close') + }) + + it('fails closed when the PNG exceeds maxBytes', async () => { + const outPath = join(await mkdtemp(join(tmpdir(), 'c191-')), 'big.png') + await writeFile(outPath, 'x'.repeat(10)) + await expect( + screenshotHtmlPng(fakeBrowser([]), { + html: '', + width: 1080, + height: 1080, + outPath, + maxBytes: 4, + }), + ).rejects.toThrow(/> 4/) + }) +}) diff --git a/tests/unit/chartData.unit.spec.ts b/tests/unit/chartData.unit.spec.ts new file mode 100644 index 00000000..b8a88eb6 --- /dev/null +++ b/tests/unit/chartData.unit.spec.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest' + +import { + CHART_TYPES, + MAX_POINTS, + MAX_POINTS_STORY, + RELATION_LABEL, + SIZES, + classifyRelation, + isTemporalLabel, + parseInput, + parseNumber, + validateSpec, +} from '../../scripts/lib/chartData.mjs' + +describe('parseNumber — pt-BR/en amounts, never invents a zero', () => { + it('reads thousands and decimals', () => { + expect(parseNumber('1.234,56')).toBe(1234.56) + expect(parseNumber('1,5')).toBe(1.5) + expect(parseNumber('1,234.56')).toBe(1234.56) + expect(parseNumber('R$ 84')).toBe(84) + expect(parseNumber('12%')).toBe(12) + expect(parseNumber(-3)).toBe(-3) + }) + + it('returns null for non-numeric input', () => { + expect(parseNumber('oitenta')).toBeNull() + expect(parseNumber('')).toBeNull() + expect(parseNumber(null)).toBeNull() + }) +}) + +describe('parseInput — the data as it came', () => { + it('reads a free text of labelvalue pairs', () => { + const dataset = parseInput({ text: 'Ilhéus 84\nItabuna 68\n', format: 'txt' }) + expect(dataset.rows).toEqual([ + { label: 'Ilhéus', value: 84 }, + { label: 'Itabuna', value: 68 }, + ]) + expect(dataset.issues).toEqual([]) + }) + + it('reads colon-separated pairs and labels with spaces', () => { + const dataset = parseInput({ text: 'Votos em Ilhéus: 84\nVotos em Itabuna: 68', format: 'txt' }) + expect(dataset.rows[0]).toEqual({ label: 'Votos em Ilhéus', value: 84 }) + }) + + it('reads a GFM markdown table and drops the header', () => { + const dataset = parseInput({ + text: '| Cidade | Votos |\n| --- | --- |\n| Ilhéus | 84 |\n| Itabuna | 68 |', + format: 'md', + }) + expect(dataset.format).toBe('md') + expect(dataset.rows).toEqual([ + { label: 'Ilhéus', value: 84 }, + { label: 'Itabuna', value: 68 }, + ]) + }) + + it('reads CSV with a header', () => { + const dataset = parseInput({ text: 'cidade,votos\nIlhéus,84\nItabuna,68\n', format: 'csv' }) + expect(dataset.rows).toEqual([ + { label: 'Ilhéus', value: 84 }, + { label: 'Itabuna', value: 68 }, + ]) + }) + + it('flags a non-numeric value instead of dropping it silently', () => { + const dataset = parseInput({ text: 'Ilhéus 84\nItabuna ???\n', format: 'txt' }) + expect(dataset.rows).toHaveLength(1) + expect(dataset.issues.join(' ')).toContain('Itabuna') + }) + + it('does not mistake a first data row with a missing value for a header', () => { + const dataset = parseInput({ text: 'Ilhéus n/d\nItabuna 68\n', format: 'txt' }) + expect(dataset.rows).toEqual([{ label: 'Itabuna', value: 68 }]) + expect(dataset.issues.join(' ')).toContain('Ilhéus') + }) + + it('flags repeated labels', () => { + const dataset = parseInput({ text: 'A 1\nA 2\n', format: 'txt' }) + expect(dataset.issues.join(' ')).toContain('repetido') + }) + + it('reads a bare number as an unlabeled single measure', () => { + const dataset = parseInput({ text: '72', format: 'txt' }) + expect(dataset.rows).toEqual([{ label: '', value: 72 }]) + expect(dataset.issues).toEqual([]) + }) +}) + +describe('classifyRelation — the relation of the data picks the type', () => { + it('single measure is an anchor', () => { + expect(classifyRelation([{ label: '', value: 72 }])).toBe('anchor') + }) + + it('few temporal periods are columns, many are a line', () => { + expect( + classifyRelation([ + { label: '2020', value: 1 }, + { label: '2021', value: 2 }, + ]), + ).toBe('column') + expect( + classifyRelation( + ['2018', '2019', '2020', '2021', '2022', '2023', '2024'].map((label) => ({ + label, + value: 1, + })), + ), + ).toBe('line') + }) + + it('non-temporal categories are a ranking (bar)', () => { + expect( + classifyRelation([ + { label: 'Ilhéus', value: 1 }, + { label: 'Itabuna', value: 2 }, + ]), + ).toBe('bar') + }) + + it('honors a forced type and refuses an unknown one', () => { + expect(classifyRelation([{ label: 'A', value: 1 }], 'bar')).toBe('bar') + expect(classifyRelation([{ label: 'A', value: 1 }], 'pie')).toBe('pie') + expect(() => classifyRelation([{ label: 'A', value: 1 }], 'donut')).toThrow(/desconhecido/) + }) +}) + +describe('isTemporalLabel', () => { + it('detects years, months, quarters and P-periods', () => { + for (const label of ['2024', '2024-06', '06/2024', 'jan/24', 'Q1', 'T3', 'P2']) { + expect(isTemporalLabel(label), label).toBe(true) + } + expect(isTemporalLabel('Ilhéus')).toBe(false) + }) +}) + +describe('validateSpec — fail-closed guardrails', () => { + const base = { chartType: 'bar', rows: [{ label: 'A', value: 1 }], headline: 'Takeaway' } + + it('accepts a valid spec', () => { + expect(validateSpec(base)).toBe(base) + }) + + it('refuses pizza with an actionable message', () => { + expect(() => validateSpec({ ...base, chartType: 'pie' })).toThrow(/barras horizontais/) + }) + + it(`refuses more than ${MAX_POINTS} points`, () => { + const rows = Array.from({ length: MAX_POINTS + 1 }, (_v, index) => ({ + label: `A${index}`, + value: 1, + })) + expect(() => validateSpec({ ...base, rows })).toThrow(/resuma/) + }) + + it('caps a story ranking at five points (approved vertical rhythm)', () => { + const rows = Array.from({ length: MAX_POINTS_STORY + 1 }, (_v, index) => ({ + label: `A${index}`, + value: index + 1, + })) + expect(() => validateSpec({ ...base, size: 'feed', rows })).not.toThrow() + expect(() => validateSpec({ ...base, size: 'story', rows })).toThrow(/cinco|5/) + }) + + it('refuses negative values (bars come from zero)', () => { + expect(() => validateSpec({ ...base, rows: [{ label: 'A', value: -1 }] })).toThrow(/zero/) + }) + + it('refuses a missing headline', () => { + expect(() => validateSpec({ ...base, headline: '' })).toThrow(/manchete/) + }) + + it('refuses an unknown size', () => { + expect(() => validateSpec({ ...base, size: 'huge' })).toThrow(/tamanho inválido/) + }) + + it('refuses a highlight that is not in the data', () => { + expect(() => validateSpec({ ...base, highlight: 'Sumiu' })).toThrow(/destaque/) + }) + + it('refuses an anchor with more than one value', () => { + expect(() => + validateSpec({ + ...base, + chartType: 'anchor', + rows: [ + { label: 'A', value: 1 }, + { label: 'B', value: 2 }, + ], + }), + ).toThrow(/âncora/) + }) + + it('refuses an unlabeled bar row', () => { + expect(() => validateSpec({ ...base, rows: [{ label: '', value: 1 }] })).toThrow(/rótulo/) + }) +}) + +describe('contracts', () => { + it('declares the four v1 types, labels and sizes', () => { + expect(CHART_TYPES).toEqual(['bar', 'column', 'line', 'anchor']) + expect(RELATION_LABEL.line).toBe('Série de tempo') + expect(SIZES.story).toMatchObject({ width: 1080, height: 1920, safeTop: 250, safeBottom: 250 }) + expect(SIZES.feed).toMatchObject({ width: 1080, height: 1350 }) + expect(SIZES.square).toMatchObject({ width: 1080, height: 1080 }) + }) +}) diff --git a/tests/unit/chartPrimitives.unit.spec.ts b/tests/unit/chartPrimitives.unit.spec.ts new file mode 100644 index 00000000..a157390a --- /dev/null +++ b/tests/unit/chartPrimitives.unit.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' + +import { + CHART_COLORS, + barChart, + columnChart, + lineChart, + proportionalPercent, + stackedColumnChart, + valueList, +} from '../../scripts/lib/chartPrimitives.mjs' + +// C191: the chart primitives were extracted from dossieRender.mjs so the +// Instagram generator (second consumer) never grows a twin. These snapshots +// freeze the dossier emission byte-for-byte — a change here is a change to the +// published dossiê, not a cosmetic tweak. + +describe('chartPrimitives — dossier emitters (byte-identical)', () => { + it('barChart', () => { + expect( + barChart({ + rows: [ + { label: 'A', count: 3 }, + { label: 'B', count: 1 }, + ], + }), + ).toMatchInlineSnapshot(` + " + A + + 3B + + 1 + " + `) + }) + + it('columnChart', () => { + expect( + columnChart({ + rows: [ + { key: '2020', value: 2 }, + { key: '2021', value: 4 }, + ], + format: (row: { value: number }) => String(row.value), + }), + ).toMatchInlineSnapshot(` + " + + 2 + 20 + 4 + 21 + " + `) + }) + + it('stackedColumnChart', () => { + expect( + stackedColumnChart({ + rows: [ + { + key: '2020', + value: 3, + segments: [ + { key: 'pago', amount: 2 }, + { key: 'empenhado', amount: 1 }, + ], + }, + ], + format: (row: { value: number }) => String(row.value), + }), + ).toMatchInlineSnapshot(` + " + + 3 + 20 + " + `) + }) + + it('valueList', () => { + expect( + valueList({ + rows: [ + { label: 'A', value: 2 }, + { label: 'B', value: 1 }, + ], + format: (row: { value: number }) => String(row.value), + }), + ).toMatchInlineSnapshot(` + "
    +
  • + A + + 2 +
  • + B + + 1 +
  • +
" + `) + }) + + it('escapes untrusted labels', () => { + expect(barChart({ rows: [{ label: 'x', count: 1 }] })).toContain('<b>') + }) + + it('keeps the dossier domain palette as the default', () => { + expect(CHART_COLORS.default).toBe('#315c75') + expect(CHART_COLORS.pago).toBe('#285338') + }) +}) + +describe('proportionalPercent — honest scale, zero stays zero', () => { + it('maps value/max to 0..100 and floors a present value at 2%', () => { + expect(proportionalPercent(84, 84)).toBe(100) + expect(proportionalPercent(42, 84)).toBe(50) + expect(proportionalPercent(1, 84)).toBe(2) + expect(proportionalPercent(0, 84)).toBe(0) + expect(proportionalPercent(5, 0)).toBe(100) + }) +}) + +describe('lineChart — series with one highlighted end', () => { + const rows = [ + { label: '2020', value: 2 }, + { label: '2021', value: 4 }, + ] + const svg = lineChart({ rows, format: (row: { value: number }) => String(row.value) }) + + it('draws every label and the last value', () => { + expect(svg).toContain('>2020<') + expect(svg).toContain('>2021<') + expect(svg).toContain('>4<') + }) + + it('marks the last point with the Solla red', () => { + expect(svg).toContain('fill="#c51414"') + }) +}) diff --git a/tests/unit/graficosDadosSkill.unit.spec.ts b/tests/unit/graficosDadosSkill.unit.spec.ts new file mode 100644 index 00000000..4f9a65dc --- /dev/null +++ b/tests/unit/graficosDadosSkill.unit.spec.ts @@ -0,0 +1,54 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// C191: the skill and the command are prompt text, so the product contract +// lives in the SKILL.md prose. This guard fails the build if the flow, the +// guardrails, the gitignored artifacts or the CLI contract are dropped. + +const repoRoot = process.cwd() + +const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'utf8') + +const skill = read('.agents/skills/graficos-dados/SKILL.md') +const command = read('.opencode/commands/graficos-dados.md') + +describe('skill /graficos-dados documents the flow', () => { + it('declares when to use it and the canonical builder CLI', () => { + expect(skill).toContain('## Quando usar') + expect(skill).toContain('node scripts/build-chart-from-data.mjs') + expect(skill).toContain('--inspect') + expect(skill).toContain('--spec=') + }) + + it('pins the launch command and the gitignored artifacts', () => { + expect(skill).toContain('/graficos-dados') + expect(skill).toContain('data/graficos-instagram/') + expect(skill).toContain('docs/research/graficos-instagram/') + expect(skill).toContain('gitignored') + }) + + it('pins the three output sizes', () => { + expect(skill).toContain('1080×1350') + expect(skill).toContain('1080×1080') + expect(skill).toContain('1080×1920') + }) + + it('pins the fail-closed guardrails', () => { + expect(skill).toMatch(/partem do zero/i) + expect(skill).toMatch(/7 pontos/) + expect(skill).toMatch(/pizza/i) + expect(skill).toMatch(/pergunta, nunca completa/i) + expect(skill).toContain('--source') + expect(skill).toMatch(/fail-closed/i) + }) +}) + +describe('command /graficos-dados couples to the skill', () => { + it('references the canonical skill file and passes arguments', () => { + expect(existsSync(resolve(repoRoot, '.opencode/commands/graficos-dados.md'))).toBe(true) + expect(command).toContain('`graficos-dados`') + expect(command).toContain('$ARGUMENTS') + expect(command).toContain('.agents/skills/graficos-dados/SKILL.md') + }) +}) diff --git a/tests/unit/graficosInstagramRender.unit.spec.ts b/tests/unit/graficosInstagramRender.unit.spec.ts new file mode 100644 index 00000000..aa885364 --- /dev/null +++ b/tests/unit/graficosInstagramRender.unit.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' + +import { renderChartHtml, SOLLA_PALETTE } from '../../scripts/lib/graficosInstagramRender.mjs' + +// C191: the renderer ports the approved hi-fi template +// (docs/plans/graficos-dados-instagram-ui-design.html) class-by-class. These +// pins freeze the export canvas size, the palette, the minimum typography and +// the safe-band adaptation for Stories. + +const rows = [ + { label: 'Território A', value: 84 }, + { label: 'Território B', value: 68 }, + { label: 'Território C', value: 53 }, +] + +const spec = (overrides = {}) => ({ + chartType: 'bar', + size: 'feed', + headline: 'Um território concentra o maior resultado', + subtitle: 'Valores de 2026', + source: 'TSE 2026', + highlight: 'Território B', + rows, + ...overrides, +}) + +describe('renderChartHtml — canvas and template', () => { + it('authors the feed canvas at exactly 1080×1350', () => { + const html = renderChartHtml(spec()) + expect(html).toContain('width: 1080px; height: 1350px') + }) + + it('adapts to square and to Stories safe bands', () => { + expect(renderChartHtml(spec({ size: 'square' }))).toContain('width: 1080px; height: 1080px') + const story = renderChartHtml(spec({ size: 'story' })) + expect(story).toContain('width: 1080px; height: 1920px') + expect(story).toContain('padding: 306px 84px 310px') + }) + + it('keeps the headline at/above the minimum size', () => { + expect(renderChartHtml(spec())).toMatch(/\.headline \{ font-size: 67px/) + expect(renderChartHtml(spec({ size: 'story' }))).toMatch(/\.headline \{ font-size: 78px/) + }) + + it('carries the source and the provisional brand lockup inside the image', () => { + const html = renderChartHtml(spec()) + expect(html).toContain('TSE 2026') + expect(html).toContain('JORGE SOLLA') + expect(html).toContain('MANDATO DEPUTADO FEDERAL') + }) + + it('marks exactly one row with the Solla highlight', () => { + const html = renderChartHtml(spec()) + expect((html.match(/bar highlight/g) ?? []).length).toBe(1) + expect(html).toContain('Território B') + expect(html).toContain('#c51414') + }) + + it('sorts the ranking by value, descending', () => { + const html = renderChartHtml(spec()) + expect(html.indexOf('Território A')).toBeLessThan(html.indexOf('Território B')) + expect(html.indexOf('Território B')).toBeLessThan(html.indexOf('Território C')) + }) + + it('renders a column chart with a zero base line', () => { + const html = renderChartHtml(spec({ chartType: 'column' })) + expect(html).toContain('columns') + expect(html).toContain('column highlight') + }) + + it('renders the line chart with the last point highlighted', () => { + const html = renderChartHtml(spec({ chartType: 'line' })) + expect(html).toContain(' { + const html = renderChartHtml( + spec({ + chartType: 'anchor', + rows: [{ label: '', value: 72 }], + subtitle: 'unidades no período', + }), + ) + expect(html).toContain('72') + expect(html).toContain('unidades no período') + expect(html).toContain('anchor-number') + }) + + it('escapes untrusted headline and source', () => { + const html = renderChartHtml(spec({ headline: '', source: 'a & b' })) + expect(html).not.toContain('') + expect(html).toContain('<script>') + expect(html).toContain('a & b') + }) +}) + +describe('SOLLA_PALETTE — the verbatim brand colors', () => { + it('matches the intention literals', () => { + expect(SOLLA_PALETTE).toMatchObject({ + highlight: '#c51414', + brandDark: '#ae1603', + ptRed: '#a21c1c', + ptYellow: '#ffe607', + paper: '#faf9f7', + }) + }) +}) diff --git a/tests/unit/opencodeCommands.unit.spec.ts b/tests/unit/opencodeCommands.unit.spec.ts index 4c094ac1..e6a3715c 100644 --- a/tests/unit/opencodeCommands.unit.spec.ts +++ b/tests/unit/opencodeCommands.unit.spec.ts @@ -19,6 +19,7 @@ const commands = [ 'dossie-solla-cidade', 'dossie-solla-instituicao', 'dossie-solla-tema', + 'graficos-dados', ] as const describe('opencode commands couple to their skill by exact name', () => {