teqo-backup/scripts/lib/dossieRender.mjs
Francisco Solla 4b00a8778a feat(C188): relatórios e dossiês sem truncamento mecânico
Dono único de orçamento de texto em reportText.mjs (capList,
excerptDeepDive, joinWithRemainder, summaryText/summarySurfaceText,
summaryListText), reusado por C163 e C186. Contrato summary por item nos
dois arquivos de pesquisa; superfície de resumo nunca corta com "…" e
aponta o aprofundamento; toda lista capada mostra contador; builders com
2 passadas guard-gated (integral → ponteiro → die fail-closed).

Closes #1136
2026-09-17 19:59:02 -03:00

1640 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Dossiê renderer (C186): the block model goes to print HTML (Chromium
* `page.pdf`, A4, one `.sheet` per page) and to the companion `.md`. Ports the
* approved hi-fi classes (`dossie-solla-cidade-ui-design.html`) class-by-class;
* shared escaping/source-token contract lives in `reportText.mjs`.
*/
import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs'
import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs'
import { MUNICIPALITY_UNIT, isInstitutionUnit } from './dossieUnit.mjs'
import { htmlEscape, moreItemsLabel, showingLabel, stripInlineSources } from './reportText.mjs'
const SOURCE_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'
const WARNING_ICON =
'<svg class="warn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10.3 2.9 1.8 17a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 2.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/></svg>'
const sourceLink = (url, label = '(fonte)') =>
url
? `<a class="source-link" href="${htmlEscape(url)}" aria-label="Abrir fonte">${SOURCE_ICON}${htmlEscape(label)}</a>`
: ''
const scopeBadge = (sphere, label = null, unit = MUNICIPALITY_UNIT) => {
const badgeClass = dossierSphereBadgeClass(sphere, unit)
return `<span class="scope${badgeClass ? ` ${badgeClass}` : ''}">${htmlEscape(label ?? dossierSphereLabel(sphere, unit))}</span>`
}
/** `dd/mm/aa` — the news table column is 9% wide (no room for a 4-digit year). */
const formatShortDateBr = (value) => {
const full = formatDateBr(value)
const parts = full.split('/')
return parts.length === 3 ? `${parts[0]}/${parts[1]}/${parts[2].slice(-2)}` : full
}
/**
* Print surfaces prefer the reformulated `brief` (short headline + note that fit
* the fixed A4 with no ellipsis) and fall back to the full `answer`/`details`
* record when an item has none — the builder's A4 fit guard then fails closed
* instead of silently dropping information.
*/
const copyHtml = (value) => htmlEscape(stripInlineSources(value))
const briefOr = (brief, fallback) => brief?.title ?? fallback
const noteOr = (brief, fallback) => brief?.note ?? fallback
const PHASE_MODIFIER = {
autorizado: 'phase-pending',
empenhado: 'phase-pending',
liquidado: 'phase-paid',
pago: 'phase-paid',
}
const phaseBadge = (phase) => {
const modifier = PHASE_MODIFIER[phase]
return `<span class="phase${modifier ? ` ${modifier}` : ''}">${htmlEscape(dossierPhaseLabel(phase))}</span>`
}
const placeholder = (label) =>
`<span class="placeholder-bar" aria-label="${htmlEscape(label)}"></span>`
const valueCell = (value, widthLabel = 'Valor desconhecido') =>
value === null || value === undefined || value === ''
? placeholder(widthLabel)
: `<strong>${htmlEscape(value)}</strong>`
const assetBox = (title, lines) =>
`<div class="asset-box"><p class="asset-title">${htmlEscape(title)}</p><p>${lines
.map((line) => htmlEscape(line))
.join('<br />')}</p></div>`
/** cover + trajetória + resumo + região + fontes (eras and gap sheets added separately). */
const SHEET_COUNT_BASE = 5
const pageCount = (report) => report.meta.pageTotal
/** Gaps are paginated, never truncated: 7 full-text rows per A4 sheet. */
const GAPS_PER_PAGE = 7
/** News are paginated, never truncated: 10 full-title rows per A4 sheet. */
const NEWS_PER_PAGE = 10
/** Era "Atuação registrada" cards: 2 on the first sheet, 6 per continuation sheet. */
const ERA_ACTIONS_FIRST_SHEET = 2
const ERA_ACTIONS_PER_SHEET = 6
const chunkGaps = (gaps) => {
const list = Array.isArray(gaps) ? gaps : []
if (list.length === 0) return [[]]
const chunks = []
for (let index = 0; index < list.length; index += GAPS_PER_PAGE) {
chunks.push(list.slice(index, index + GAPS_PER_PAGE))
}
return chunks
}
const chunkNews = (news) => {
const list = Array.isArray(news) ? news : []
const chunks = []
for (let index = 0; index < list.length; index += NEWS_PER_PAGE) {
chunks.push(list.slice(index, index + NEWS_PER_PAGE))
}
return chunks
}
/**
* Era action cards, paginated so no sourced fact is dropped: the first sheet
* carries the design's 2 cards (with method + numbers + honors), and the rest
* flow to continuation sheets.
*/
const chunkEraActions = (era) => {
const actions = Array.isArray(era.actions) ? era.actions : []
const first = actions.slice(0, ERA_ACTIONS_FIRST_SHEET)
const rest = actions.slice(ERA_ACTIONS_FIRST_SHEET)
const chunks = [first]
for (let index = 0; index < rest.length; index += ERA_ACTIONS_PER_SHEET) {
chunks.push(rest.slice(index, index + ERA_ACTIONS_PER_SHEET))
}
return chunks
}
const timelineGrid = (rows) => `
<div class="timeline-grid">
${rows
.map(
(row) => `<div class="timeline-cell">
<p class="timeline-period">${htmlEscape(row.period)}</p>
<p class="timeline-role">${htmlEscape(row.role)}</p>
<p class="timeline-recovery">${htmlEscape(row.recovery)}${row.uncertain ? ` · ${htmlEscape(row.uncertain)}` : ''}</p>
</div>`,
)
.join('')}
</div>`
const sheetHeader = (report, title, subtitle, pageNo) => `
<header class="report-header">
<div>
<p class="report-kicker">Dossiê de atuação pública · ${htmlEscape(report.meta.municipalityName)}</p>
<h2 class="sheet-title">${htmlEscape(title)}</h2>
${subtitle ? `<p class="sheet-sub">${htmlEscape(subtitle)}</p>` : ''}
</div>
<div class="report-meta">
<p><strong>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</strong></p>
<p>INSUMO INTERNO</p>
<p>pág. ${pageNo}/${pageCount(report)}</p>
</div>
</header>`
const sheetFooter = (report, left, pageNo) => `
<footer class="report-footer">
<span>Dossiê Solla por cidade · ${htmlEscape(report.meta.municipalityName)} · ${htmlEscape(left)}</span>
<span>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))} · pág. ${pageNo}/${pageCount(report)}</span>
</footer>`
const renderCover = (report) => `
<article class="sheet sheet-cover" data-page="capa" aria-label="Capa do dossiê">
<div class="accent-bar"></div>
<header class="cover-head">
<div>
<p class="report-kicker">${htmlEscape(report.cover.kicker)}</p>
<p class="cover-series">${htmlEscape(report.cover.series)}</p>
</div>
<div class="internal-note"><strong>${htmlEscape(report.cover.internalNote)}</strong><br />${htmlEscape(report.cover.internalNoteSub)}</div>
</header>
<div class="cover-block">
<p class="eyebrow">${htmlEscape(report.cover.eyebrow)}</p>
<h1 class="cover-title">${htmlEscape(report.cover.title)}</h1>
<p class="cover-sub">${htmlEscape(report.cover.subtitle)}</p>
</div>
<dl class="cover-dl">
<dt>Território de Identidade</dt><dd>${htmlEscape(report.cover.territory)}</dd>
<dt>Região / polo adotado</dt><dd>${htmlEscape(report.cover.pole)}</dd>
<dt>Data de geração</dt><dd class="tabular">${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</dd>
</dl>
<div class="cover-grid">
<div class="how-to"><p class="how-to-title">Como usar</p><p>${htmlEscape(report.cover.howToUse)}</p></div>
${assetBox('NEEDS ASSET', ['selo institucional autorizado', 'opcional — nunca marca de campanha'])}
</div>
<div class="cover-foot">
<div class="ink-rule"></div>
<div class="cover-foot-row">
<p class="cover-scope">${htmlEscape(report.cover.scope)}</p>
<p class="cover-version">${htmlEscape(report.cover.version)}</p>
</div>
</div>
</article>`
const renderTimeline = (report) => `
<section class="block">
<div class="block-head">
<div><p class="eyebrow">01 · trajetória</p><h3 class="section-title">Linha do tempo da carreira</h3></div>
<p class="meta">Período + papel + trilha de recuperação</p>
</div>
${timelineGrid(report.page1.timeline)}
</section>`
const renderTrajectory = (report, pageNo) => `
<article class="sheet" data-page="trajetoria" aria-label="Trajetória completa">
${sheetHeader(report, 'Trajetória completa', 'Todos os períodos da carreira e como cada um foi recuperado', pageNo)}
${timelineGrid(report.trajectory)}
<p class="meta">Períodos incertos ficam marcados na própria linha; o acervo interno de falas cobre 2011+ e o que falta vira lacuna explícita.</p>
${sheetFooter(report, 'Trajetória', pageNo)}
</article>`
const renderDeliveries = (report) => {
const deliveries = report.page1.deliveries
if (deliveries.items.length === 0) return ''
return `
<section class="block">
<div class="block-head">
<div><p class="eyebrow">02 · destaques com lastro</p><h3 class="section-title">Principais entregas localizadas</h3></div>
</div>
<div class="deliveries">
${deliveries.items
.map(
(row) => `<div class="delivery-row">
<div class="delivery-scope">${scopeBadge(row.sphere)}<p class="meta">Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}</p></div>
<div>
<p class="delivery-title">${copyHtml(briefOr(row.brief, row.title))}</p>
${noteOr(row.brief, row.detail) ? `<p class="muted small">${copyHtml(noteOr(row.brief, row.detail))}</p>` : ''}
</div>
<div class="delivery-value">
<p class="value-big tabular">${valueCell(row.value, 'Valor não informado')}</p>
${row.phase ? phaseBadge(row.phase) : ''}
${sourceLink(row.sourceUrl)}
</div>
</div>`,
)
.join('')}
</div>
${
deliveries.remaining > 0
? `<p class="muted small strong">${htmlEscape(showingLabel(deliveries.items.length, deliveries.total, 'entrega localizada', 'entregas localizadas'))} — o restante está nas páginas das eras.</p>`
: ''
}
<p class="muted small strong">Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.</p>
</section>`
}
const renderHooksAndPending = (report) => {
const hooks = report.page1.hooks
const pending = report.page1.pending
if (hooks.items.length === 0 && pending.items.length === 0) return ''
return `
<section class="page1-grid">
${
hooks.items.length
? `<div class="panel">
<p class="eyebrow">03 · ganchos para o boletim</p>
<ul class="tight-list">${hooks.items
.map(
(hook) =>
`<li><strong>Tema:</strong> ${htmlEscape(hook.topic)}. <strong>Ângulo:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}</li>`,
)
.join('')}</ul>
${hooks.remaining > 0 ? `<p class="muted small">${htmlEscape(moreItemsLabel(hooks.remaining, 'gancho', 'ganchos'))} nas eras.</p>` : ''}
</div>`
: '<div></div>'
}
${
pending.items.length
? `<div class="panel panel-warning">
<p class="eyebrow warning-text">04 · o que falta</p>
<ul class="tight-list">${pending.items.map((item) => `<li>${htmlEscape(item)}</li>`).join('')}</ul>
${pending.remaining > 0 ? `<p class="warning-text small">${htmlEscape(moreItemsLabel(pending.remaining, 'ponto', 'pontos'))} sem leitura na tabela de lacunas.</p>` : ''}
<p class="warning-text small strong">Ausência é resultado: não completar por inferência.</p>
</div>`
: '<div></div>'
}
</section>`
}
const renderSummary = (report, pageNo) => `
<article class="sheet" data-page="resumo" aria-label="Resumo de uma olhada">
${sheetHeader(report, 'Resumo de uma olhada', `${report.meta.region} · informação datada e pronta para conferência`, pageNo)}
${renderTimeline(report)}
${renderDeliveries(report)}
${renderHooksAndPending(report)}
${sheetFooter(report, 'Resumo', pageNo)}
</article>`
const renderActionGrid = (actions) => `
<div class="action-grid">
${actions
.map(
(action) => `<article class="panel">
<div class="action-head">${scopeBadge(action.sphere)}<span class="meta">${htmlEscape(action.year ?? '—')}</span></div>
<h4 class="action-title">${copyHtml(briefOr(action.brief, action.title))}</h4>
${noteOr(action.brief, action.detail) ? `<p class="muted small">${copyHtml(noteOr(action.brief, action.detail))}</p>` : ''}
${sourceLink(action.sourceUrl)}
</article>`,
)
.join('')}
</div>`
const renderEraPage = (report, era, actions, index, pageNo) => {
const first = index === 0
const slug = era.id.toLowerCase()
const anchor = first ? `era-${slug}` : `era-${slug}-${index + 1}`
const subtitle = first
? `${era.period} · ${era.subtitle}`
: `Atuação registrada · continuação ${index + 1}`
return `
<article class="sheet" data-page="${anchor}" aria-label="${htmlEscape(era.label)}">
${sheetHeader(report, era.label, subtitle, pageNo)}
${
first
? `<div class="era-method">
<div>
<p class="eyebrow">Recorte e método</p>
<p class="muted">${htmlEscape(era.method)}</p>
</div>
<aside class="aside-accent"><strong>Trilha de recuperação</strong><br />${htmlEscape(era.recovery)}</aside>
</div>
${
era.numbers.items.length
? `<section class="block">
<div class="block-head">
<div><p class="eyebrow">Números com fase</p><h3 class="section-title">Recursos e entregas quantificáveis</h3></div>
</div>
<table class="document-table">
<caption class="sr-only">Valores por ano, fase, esfera e fonte</caption>
<colgroup><col style="width:26%" /><col style="width:15%" /><col style="width:10%" /><col style="width:17%" /><col style="width:12%" /><col style="width:20%" /></colgroup>
<thead><tr><th>Objeto</th><th>Valor</th><th>Ano</th><th>Fase</th><th>Esfera</th><th>Fonte</th></tr></thead>
<tbody>${era.numbers.items
.map(
(row) => `<tr>
<td>${htmlEscape(row.object)}</td>
<td class="numeric">${valueCell(row.value, 'Valor a recuperar')}</td>
<td class="numeric">${htmlEscape(row.year ?? '—')}</td>
<td>${phaseBadge(row.phase)}</td>
<td>${scopeBadge(row.sphere)}</td>
<td>${sourceLink(row.sourceUrl)}${row.sphere !== 'municipio' ? '<br /><span class="meta">não somar à cidade</span>' : ''}</td>
</tr>`,
)
.join('')}</tbody>
</table>
${era.numbers.remaining > 0 ? `<p class="muted small">${htmlEscape(moreItemsLabel(era.numbers.remaining, 'número com fase', 'números com fase'))} — a lista completa está no arquivo de pesquisa da era.</p>` : ''}
<p class="warning-text small strong">Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.</p>
</section>`
: ''
}`
: ''
}
${
actions.length
? `<section class="block">
<p class="eyebrow">Atuação registrada</p>
<h3 class="section-title">O que fez — item, alcance e lastro</h3>
${renderActionGrid(actions)}
${era.camaraActionsRemaining > 0 ? `<p class="muted small">${htmlEscape(moreItemsLabel(era.camaraActionsRemaining, 'ação da Câmara', 'ações da Câmara'))} — a coleta é limitada às páginas consultadas; o restante está no acervo da Câmara.</p>` : ''}
</section>`
: ''
}
${
first && era.honors.length
? `<section class="block honors">
<div class="panel">
<p class="eyebrow">Títulos, honrarias e vínculos locais</p>
<ul class="tight-list">${era.honors
.map(
(honor) =>
`<li>${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}</li>`,
)
.join('')}</ul>
</div>
${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])}
</section>`
: ''
}
${sheetFooter(report, era.label, pageNo)}
</article>`
}
const renderScopeLists = (report) => `
<section class="block">
<p class="eyebrow">Leitura lado a lado</p>
<h3 class="section-title">Duas listas, dois totais, nenhuma soma</h3>
<div class="scope-compare">
<div class="panel">
<div class="action-head">${scopeBadge('municipio')}<span class="meta strong">${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte</span></div>
<ul class="tight-list">${report.region.municipal.items
.map(
(item) =>
`<li>${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}</li>`,
)
.join(
'',
)}${report.region.municipal.items.length === 0 ? '<li class="muted">Nenhum item municipal com fonte.</li>' : ''}</ul>
${report.region.municipal.remaining > 0 ? `<p class="muted small">${htmlEscape(moreItemsLabel(report.region.municipal.remaining, 'item municipal com fonte', 'itens municipais com fonte'))} — ver a tabela de evidências abaixo.</p>` : ''}
</div>
<div class="compare-mid" aria-hidden="true"><span>≠</span></div>
<div class="panel panel-region">
<div class="action-head">${scopeBadge('regiao', 'região / polo')}<span class="warning-text meta strong">SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}</span></div>
<ul class="tight-list">${report.region.regional.items
.map(
(item) =>
`<li>${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}</li>`,
)
.join(
'',
)}${report.region.regional.items.length === 0 ? '<li class="muted">Nenhum item regional com fonte.</li>' : ''}</ul>
${report.region.regional.remaining > 0 ? `<p class="muted small">${htmlEscape(moreItemsLabel(report.region.regional.remaining, 'item regional com fonte', 'itens regionais com fonte'))} — ver a tabela de evidências abaixo.</p>` : ''}
</div>
</div>
</section>`
const renderRegion = (report, pageNo) => `
<article class="sheet" data-page="regiao" aria-label="Recorte regional e polo">
${sheetHeader(report, 'Região / polo', 'Impacto compartilhado, apresentado fora da conta municipal', pageNo)}
<section class="rule-warning">
${WARNING_ICON}
<div>
<p class="rule-title">${htmlEscape(report.region.ruleTitle)}</p>
<p class="muted">${htmlEscape(report.region.ruleBody)}</p>
</div>
</section>
${
report.region.context.length
? `<p class="meta">Contexto municipal (IBGE): ${report.region.context
.map(
(item) => `${copyHtml(item.detail)} ${sourceLink(item.sourceUrl, `(${item.topic})`)}`,
)
.join(' · ')} — leitura relativa, nunca % estadual absoluto.</p>`
: ''
}
${renderScopeLists(report)}
${
report.region.evidence.items.length
? `<section class="block">
<div class="block-head"><div><p class="eyebrow">Itens regionais recuperados</p><h3 class="section-title">Evidência de alcance, sem apropriação local</h3></div>${scopeBadge('regiao')}</div>
<table class="document-table">
<caption class="sr-only">Itens do recorte regional com evidência e fonte</caption>
<colgroup><col style="width:30%" /><col style="width:15%" /><col style="width:34%" /><col style="width:21%" /></colgroup>
<thead><tr><th>Item</th><th>Esfera</th><th>Evidência de alcance</th><th>Fonte</th></tr></thead>
<tbody>${report.region.evidence.items
.map(
(item) => `<tr>
<td>${copyHtml(briefOr(item.brief, item.item))}</td>
<td>${scopeBadge(item.sphere)}</td>
<td>${copyHtml(noteOr(item.brief, item.evidence))}</td>
<td>${sourceLink(item.sourceUrl)}</td>
</tr>`,
)
.join('')}</tbody>
</table>
${report.region.evidence.remaining > 0 ? `<p class="muted small">${htmlEscape(showingLabel(report.region.evidence.items.length, report.region.evidence.total, 'item regional recuperado', 'itens regionais recuperados'))} em evidência; o restante está no arquivo de pesquisa da era.</p>` : ''}
</section>`
: ''
}
${
report.region.hook || report.region.priorityGap
? `<section class="page1-grid">
${
report.region.hook
? `<div class="aside-accent"><p class="eyebrow">Gancho possível</p><p><strong>Tema:</strong> ${htmlEscape(report.region.hook.topic)}. <strong>Ângulo:</strong> ${copyHtml(briefOr(report.region.hook.brief, report.region.hook.angle))} ${sourceLink(report.region.hook.sourceUrl)}</p></div>`
: '<div></div>'
}
${
report.region.priorityGap
? `<div class="panel panel-warning"><p class="eyebrow warning-text">Lacuna prioritária</p><p>${htmlEscape(report.region.priorityGap)}</p></div>`
: '<div></div>'
}
</section>`
: ''
}
${sheetFooter(report, 'Região/polo · recortes não somáveis', pageNo)}
</article>`
const renderGapsPage = (report, chunk, index, pageNo) => {
const rows = chunk.length
? chunk
.map(
(gap) => `<tr>
<td><strong>${htmlEscape(gap.label)}</strong></td>
<td>${htmlEscape(gap.reason)}</td>
<td>Não completar por inferência.</td>
<td>${htmlEscape(gap.nextStep)}</td>
</tr>`,
)
.join('')
: '<tr><td colspan="4" class="muted">Nenhuma lacuna declarada — confira a cobertura das eras.</td></tr>'
return `
<article class="sheet" data-page="lacunas${index > 0 ? `-${index + 1}` : ''}" aria-label="Lacunas explícitas">
${sheetHeader(report, 'Lacunas explícitas', `O que não foi encontrado${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)}
<section class="block">
<div class="block-head"><div><p class="eyebrow">O que não foi encontrado</p><h3 class="section-title">Lacunas explícitas</h3></div><p class="warning-text meta strong">Não completar por memória ou inferência</p></div>
<table class="document-table">
<caption class="sr-only">Tabela de lacunas explícitas e próximos passos de apuração</caption>
<colgroup><col style="width:26%" /><col style="width:30%" /><col style="width:24%" /><col style="width:20%" /></colgroup>
<thead><tr><th>Lacuna</th><th>Situação da busca</th><th>Limite editorial</th><th>Próximo passo</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</section>
${sheetFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)}
</article>`
}
const renderNewsPage = (report, chunk, index, pageNo) => `
<article class="sheet" data-page="noticias${index > 0 ? `-${index + 1}` : ''}" aria-label="Notícias e documentos consultados">
${sheetHeader(report, 'Notícias e documentos consultados', `Fontes externas datadas${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)}
<section class="block">
<p class="eyebrow">Lista clicável</p>
<h3 class="section-title">Notícias e documentos consultados</h3>
<table class="document-table">
<caption class="sr-only">Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%</caption>
<colgroup><col style="width:12%" /><col style="width:13%" /><col style="width:47%" /><col style="width:28%" /></colgroup>
<thead><tr><th>Data</th><th>Veículo</th><th>Título</th><th>Link</th></tr></thead>
<tbody>${chunk
.map(
(row) => `<tr>
<td class="numeric nowrap">${htmlEscape(formatShortDateBr(row.date))}</td>
<td>${htmlEscape(row.outlet)}</td>
<td>${htmlEscape(row.title)}</td>
<td>${sourceLink(row.url, '(fonte)')}</td>
</tr>`,
)
.join('')}</tbody>
</table>
</section>
${sheetFooter(report, 'Notícias e documentos consultados · INSUMO INTERNO', pageNo)}
</article>`
const renderSourcesPage = (report, pageNo) => `
<article class="sheet" data-page="fontes" aria-label="Fontes e limites do dossiê">
${sheetHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
<section class="page1-grid">
<div class="panel"><p class="eyebrow">Limites de cobertura</p><ul class="tight-list">${report.limits.coverage
.map((item) => `<li>${htmlEscape(item)}</li>`)
.join('')}</ul></div>
<div class="panel"><p class="eyebrow">Regras para uso editorial</p><ul class="tight-list">${report.limits.editorial
.map((item) => `<li>${htmlEscape(item)}</li>`)
.join('')}</ul></div>
</section>
<aside class="rule-warning defeso">
${WARNING_ICON}
<div>
<p class="eyebrow warning-text">Nota de defeso eleitoral 2026</p>
<h3 class="rule-title">Insumo interno não é autorização para publicar.</h3>
<p class="muted">Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável. Este dossiê organiza evidências; não substitui análise jurídica nem editorial.</p>
</div>
</aside>
${sheetFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)}
</article>`
const PRINT_CSS = `
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; color: #172033; font-size: 10.5pt; line-height: 1.32; background: #fff; }
@page { size: A4; margin: 0; }
.sheet {
position: relative;
width: 210mm;
min-height: 297mm;
height: 297mm;
padding: 12mm 14mm 10mm;
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
break-after: page;
}
.sheet:last-child { break-after: auto; }
.sheet p, .sheet ul, .sheet ol, .sheet dl { margin-top: 0; }
.sheet a { color: #245b78; text-decoration: underline; text-underline-offset: 1.5px; }
.accent-bar { position: absolute; left: 0; top: 0; width: 4mm; height: 297mm; background: #315c75; }
.report-header { display: flex; justify-content: space-between; gap: 8mm; align-items: flex-start; padding-bottom: 3mm; border-bottom: .45mm solid #172033; }
.report-kicker { margin: 0; color: #315c75; font-size: 8pt; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
.sheet-title { margin: 1.2mm 0 0; font-size: 18pt; font-weight: 700; line-height: 1.1; letter-spacing: -.02em; }
.sheet-sub { margin: .8mm 0 0; font-size: 10.5pt; color: #435264; }
.report-meta { min-width: 43mm; margin: 0; color: #435264; font-size: 8pt; line-height: 1.45; text-align: right; font-variant-numeric: tabular-nums; }
.report-meta p { margin: 0; }
.report-footer { margin-top: auto; padding-top: 2.5mm; border-top: .25mm solid #cbd5dc; display: flex; justify-content: space-between; gap: 5mm; color: #435264; font-size: 8pt; line-height: 1.3; }
.section-title { margin: 0; font-size: 13.5pt; line-height: 1.18; letter-spacing: -.01em; }
.eyebrow { margin: 0 0 1.4mm; color: #315c75; font-size: 8pt; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.eyebrow.warning-text, .warning-text { color: #8a5a18; }
.muted { color: #435264; }
.small { font-size: 10.5pt; line-height: 1.28; }
.meta { font-size: 7.7pt; line-height: 1.2; color: #435264; }
.strong { font-weight: 600; }
.tabular { font-variant-numeric: tabular-nums; }
.nowrap { white-space: nowrap; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.block { margin-top: 3.4mm; break-inside: avoid; }
.block-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 4mm; }
.source-link { display: inline-flex; align-items: baseline; gap: 1mm; white-space: nowrap; font-size: 9pt; font-weight: 600; }
.source-link svg { width: 3mm; height: 3mm; transform: translateY(.35mm); }
.scope { display: inline-flex; align-items: center; min-height: 5mm; padding: .4mm 1.6mm; border: .25mm solid #9aacb7; border-radius: 99px; color: #304758; background: #f7fafb; font-size: 7.5pt; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.scope-region { border-color: #aa8752; color: #6b4918; background: #fff9ee; }
.phase { display: inline-block; padding: .5mm 1.4mm; border-radius: 1mm; background: #e9eef1; color: #334553; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: .035em; }
.document-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 10.5pt; line-height: 1.22; margin-top: 2.5mm; }
.document-table th { padding: 1.7mm 1.6mm; border-top: .25mm solid #cbd5dc; border-bottom: .35mm solid #9caab3; background: #f3f6f8; color: #435264; font-size: 7.5pt; font-weight: 700; letter-spacing: .045em; text-align: left; text-transform: uppercase; }
.document-table td { padding: 2mm 1.6mm; border-bottom: .25mm solid #dce3e7; vertical-align: top; overflow-wrap: anywhere; }
.document-table tbody tr:nth-child(even) { background: #fafcfd; }
.document-table .numeric { font-variant-numeric: tabular-nums; white-space: nowrap; }
.placeholder-bar { display: inline-block; width: 22mm; height: 2.6mm; border-radius: 1mm; background: #d8e0e5; vertical-align: middle; }
.asset-box { border: .35mm dashed #80919d; color: #4c5c68; background: #f7f9fa; padding: 3mm; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; min-height: 30mm; border-radius: .25rem; font-size: 8pt; font-weight: 600; }
.asset-box p { margin: 0; }
.asset-box .asset-title { text-transform: uppercase; letter-spacing: .08em; }
.timeline-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.8mm; margin-top: 2.5mm; }
.timeline-cell { border-top: 1.6px solid #315c75; background: #f3f6f8; padding: 2mm; }
.timeline-period { margin: 0; font-size: 7.6pt; font-weight: 700; color: #315c75; }
.timeline-role { margin: .8mm 0 0; font-size: 10.5pt; font-weight: 600; line-height: 1.16; }
.timeline-recovery { margin: .7mm 0 0; font-size: 7.5pt; line-height: 1.16; color: #435264; }
.deliveries { border-top: .25mm solid #cbd5dc; border-bottom: .25mm solid #cbd5dc; margin-top: 1.8mm; }
.delivery-row { display: grid; grid-template-columns: 26mm 1fr 42mm; gap: 3mm; padding: 2mm 0; border-bottom: .25mm solid #cbd5dc; }
.delivery-row:last-child { border-bottom: none; }
.delivery-title { margin: 0; font-weight: 600; }
.delivery-value { text-align: right; }
.value-big { margin: 0; font-size: 14pt; font-weight: 600; }
.page1-grid { display: grid; grid-template-columns: 1.15fr .85fr; gap: 3mm; margin-top: 3.2mm; break-inside: avoid; }
.panel { border: .25mm solid #cbd5dc; border-radius: .25rem; padding: 3mm; break-inside: avoid; }
.panel-warning { border: none; border-left: 3mm solid #8a5a18; background: #fff7e8; }
.panel-region { border: .35mm solid #c9a66d; background: #fffbf3; }
.tight-list { margin: 0; padding-left: 4mm; }
.tight-list li { margin-bottom: 1mm; }
.aside-accent { border-radius: .25rem; background: #e8f0f4; padding: 3mm; font-size: 10.5pt; line-height: 1.35; }
.era-method { display: grid; grid-template-columns: 1fr 55mm; gap: 4mm; margin-top: 4mm; }
.action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin-top: 3mm; }
.action-head { display: flex; align-items: center; justify-content: space-between; gap: 3mm; }
.action-title { margin: 2mm 0 0; font-size: 10.5pt; font-weight: 600; }
.honors { display: grid; grid-template-columns: 1fr 58mm; gap: 4mm; }
.rule-warning { display: flex; align-items: flex-start; gap: 3mm; border: .45mm solid #8a5a18; background: #fff7e8; border-radius: .25rem; padding: 4mm; margin-top: 4mm; }
.rule-warning .warn-icon { width: 5mm; height: 5mm; color: #8a5a18; flex-shrink: 0; margin-top: .5mm; }
.rule-title { margin: 0; font-size: 12pt; font-weight: 700; color: #8a5a18; }
.rule-warning .muted { margin-bottom: 0; }
.scope-compare { display: grid; grid-template-columns: 1fr 12mm 1fr; align-items: stretch; gap: 3mm; margin-top: 3mm; }
.compare-mid { display: flex; flex-direction: column; align-items: center; justify-content: center; color: #8a5a18; }
.compare-mid::before, .compare-mid::after { content: ''; width: 1px; flex: 1; background: #d8c09a; }
.compare-mid span { font-size: 18pt; font-weight: 700; margin: 2mm 0; }
.cover-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8mm; border-bottom: .25mm solid #cbd5dc; padding-bottom: 4mm; }
.cover-series { margin: 2mm 0 0; font-size: 10.5pt; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: #435264; }
.internal-note { border: .25mm solid #cbd5dc; background: #f3f6f8; padding: 2mm 3mm; text-align: right; font-size: 8pt; line-height: 1.5; color: #435264; border-radius: .25rem; }
.cover-block { margin-top: 28mm; max-width: 150mm; }
.cover-title { margin: 0; font-size: 34pt; font-weight: 700; line-height: 1.03; letter-spacing: -.035em; }
.cover-sub { margin: 5mm 0 0; max-width: 125mm; font-size: 15pt; line-height: 1.4; color: #435264; }
.cover-dl { display: grid; grid-template-columns: 42mm 1fr; border-top: .25mm solid #cbd5dc; border-bottom: .25mm solid #cbd5dc; margin: 22mm 0 0; font-size: 11pt; }
.cover-dl dt { border-bottom: .25mm solid #cbd5dc; padding: 3mm 0; font-weight: 600; color: #435264; }
.cover-dl dd { margin: 0; border-bottom: .25mm solid #cbd5dc; padding: 3mm 0; font-weight: 600; }
.cover-dl dt:last-of-type, .cover-dl dd:last-of-type { border-bottom: none; }
.cover-grid { display: grid; grid-template-columns: 1fr 56mm; gap: 5mm; margin-top: 6mm; }
.how-to { border-radius: .25rem; background: #e8f0f4; padding: 4mm; }
.how-to .how-to-title { margin: 0; font-size: 10.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: #315c75; }
.how-to p { margin: 2mm 0 0; font-size: 10.5pt; line-height: 1.35; color: #435264; }
.cover-foot { margin-top: auto; }
.cover-foot .ink-rule { height: .5mm; background: #172033; margin-bottom: 5mm; }
.cover-foot-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 8mm; }
.cover-scope { margin: 0; max-width: 118mm; font-size: 10.5pt; line-height: 1.4; color: #435264; }
.cover-version { margin: 0; text-align: right; font-size: 8pt; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #315c75; }
.cover-accent { color: #315c75; }
.defeso { margin-top: auto; }
`
const renderMunicipalityDossierHtml = (report) => {
const gapChunks = chunkGaps(report.gaps)
const newsChunks = chunkNews(report.news)
const eraSheets = report.eras.map((era) => ({ era, chunks: chunkEraActions(era) }))
const eraSheetCount = eraSheets.reduce((sum, entry) => sum + entry.chunks.length, 0)
report.meta.pageTotal = SHEET_COUNT_BASE + eraSheetCount + gapChunks.length + newsChunks.length
let page = 0
const parts = []
parts.push(renderCover(report))
page += 1
parts.push(renderTrajectory(report, ++page))
parts.push(renderSummary(report, ++page))
for (const { era, chunks } of eraSheets) {
chunks.forEach((actions, index) =>
parts.push(renderEraPage(report, era, actions, index, ++page)),
)
}
parts.push(renderRegion(report, ++page))
gapChunks.forEach((chunk, index) => parts.push(renderGapsPage(report, chunk, index, ++page)))
newsChunks.forEach((chunk, index) => parts.push(renderNewsPage(report, chunk, index, ++page)))
parts.push(renderSourcesPage(report, ++page))
return `<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.municipalityName)}</title><style>${PRINT_CSS}</style></head>
<body>
${parts.join('\n')}
</body>
</html>`
}
const mdEra = (era) => {
const lines = [
`## ${era.label} (${era.period})`,
'',
`_${era.method}_`,
'',
`Trilha: ${era.recovery}`,
]
if (era.numbers.items.length) {
lines.push(
'',
'| Objeto | Valor | Ano | Fase | Esfera | Fonte |',
'| --- | --- | --- | --- | --- | --- |',
)
for (const row of era.numbers.items) {
lines.push(
`| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`,
)
}
if (era.numbers.remaining > 0) {
lines.push(
'',
`_${moreItemsLabel(era.numbers.remaining, 'número com fase', 'números com fase')} não exibidos — ver as fontes._`,
)
}
}
for (const action of era.actions) {
lines.push(
'',
`- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`,
)
}
if (era.camaraActionsRemaining > 0) {
lines.push(
'',
`_${moreItemsLabel(era.camaraActionsRemaining, 'ação da Câmara', 'ações da Câmara')} — a coleta é limitada às páginas consultadas._`,
)
}
for (const honor of era.honors)
lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`)
return lines.join('\n')
}
const renderMunicipalityDossierMd = (report) => {
const lines = [
`# ${report.meta.title} — ${report.meta.municipalityName}`,
'',
`**Território de identidade:** ${report.meta.region}`,
`**Gerado em:** ${formatDateTimeBr(report.meta.generatedAt)} · **Base Teqo (read-only) lida em:** ${report.meta.readAtLabel}`,
'',
'> INSUMO INTERNO — não circular, não publicar. Documento de trabalho.',
'',
'## Linha do tempo da carreira',
'',
]
for (const row of report.trajectory) {
lines.push(
`- **${row.period}** — ${row.role}${row.uncertain ? ` _(${row.uncertain})_` : ''} · ${row.recovery}`,
)
}
if (report.page1.deliveries.items.length) {
lines.push('', '## Principais entregas localizadas', '')
for (const row of report.page1.deliveries.items) {
lines.push(
`- **${stripInlineSources(row.title)}** · _${row.sphere}_${row.value ? ` · ${row.value}` : ''}${row.phase ? ` (${dossierPhaseLabel(row.phase)})` : ''}${row.detail ? ` — ${stripInlineSources(row.detail)}` : ''} [fonte](${row.sourceUrl})`,
)
}
if (report.page1.deliveries.remaining > 0) {
lines.push(
'',
`_${showingLabel(report.page1.deliveries.items.length, report.page1.deliveries.total, 'entrega localizada', 'entregas localizadas')} — o restante está nas páginas das eras._`,
)
}
lines.push('', '_Autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor._')
}
for (const era of report.eras) {
lines.push('', mdEra(era))
}
lines.push(
'',
'## Região / polo',
'',
`> ${report.region.ruleTitle}`,
`> ${report.region.ruleBody}`,
'',
)
lines.push('### Município', '')
for (const item of report.region.municipal.items) {
lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`)
}
if (report.region.municipal.remaining > 0) {
lines.push(
`- _${moreItemsLabel(report.region.municipal.remaining, 'item municipal com fonte', 'itens municipais com fonte')} — ver a tabela de evidências._`,
)
}
lines.push('', '### Região / polo (não somável)', '')
for (const item of report.region.regional.items) {
lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`)
}
if (report.region.regional.remaining > 0) {
lines.push(
`- _${moreItemsLabel(report.region.regional.remaining, 'item regional com fonte', 'itens regionais com fonte')} — ver a tabela de evidências._`,
)
}
if (report.region.evidence.items.length) {
lines.push('', '| Item | Esfera | Evidência | Fonte |', '| --- | --- | --- | --- |')
for (const item of report.region.evidence.items) {
lines.push(
`| ${stripInlineSources(item.item)} | ${item.sphere} | ${stripInlineSources(item.evidence)} | ${item.sourceUrl} |`,
)
}
if (report.region.evidence.remaining > 0) {
lines.push(
'',
`_${showingLabel(report.region.evidence.items.length, report.region.evidence.total, 'item regional recuperado', 'itens regionais recuperados')} em evidência; o restante está no arquivo de pesquisa da era._`,
)
}
}
lines.push('', '## Lacunas explícitas', '')
if (report.gaps.length === 0) lines.push('_Nenhuma lacuna declarada._')
for (const gap of report.gaps) {
lines.push(`- **${gap.label}:** ${gap.reason} → ${gap.nextStep}`)
}
if (report.news.length) {
lines.push('', '## Notícias e documentos consultados', '')
for (const row of report.news) {
lines.push(`- ${formatDateBr(row.date)} · ${row.outlet} — ${row.title} — ${row.url}`)
}
}
lines.push(
'',
'## Limites de cobertura',
'',
...report.limits.coverage.map((item) => `- ${item}`),
'',
'## Regras para uso editorial',
'',
...report.limits.editorial.map((item) => `- ${item}`),
'',
'## Nota de defeso eleitoral 2026',
'',
'Insumo interno não é autorização para publicar. Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável.',
'',
`_Documento interno de campanha, datado (${formatDateTimeBr(report.meta.generatedAt)}). O dado envelhece: confira a data de leitura da base._`,
)
return `${lines.join('\n')}\n`
}
/* ------------------------------------------------------------------ *
* Institution renderer (C187) — ports the approved institutional *
* hi-fi (`dossie-solla-instituicao-ui-design.html`) class-by-class; *
* shares the print shell, source tokens and table CSS of the city *
* renderer (one owner). *
* ------------------------------------------------------------------ */
const INSTITUTION_PRINT_CSS = `
${PRINT_CSS}
.identity-badge { display: inline-flex; align-items: center; min-height: 5mm; padding: .45mm 1.6mm; border: .25mm solid #cbd5dc; border-radius: 99px; color: #435264; background: #f3f6f8; font-size: 7.4pt; line-height: 1; font-weight: 700; letter-spacing: .035em; text-transform: uppercase; }
.scope-sector { border-color: #9d8b64; color: #65501f; background: #fffaf0; }
.scope-network { border-color: #8f819c; color: #554362; background: #faf7fc; }
.phase-paid { color: #285338; background: #e6f2e9; }
.phase-pending { color: #6b4918; background: #fff2d9; }
.empty-panel { border: .45mm dashed #b18549; color: #684819; background: #fff7e8; border-radius: .25rem; padding: 5mm 8mm; text-align: center; }
.empty-panel .rule-title { margin-top: 1.5mm; }
.empty-panel .warn-icon { width: 8mm; height: 8mm; margin: 0 auto; color: #8a5a18; }
.timeline-item { position: relative; padding-left: 5mm; }
.timeline-item::before { content: ''; position: absolute; top: 1.5mm; left: 0; width: 2.6mm; height: 2.6mm; border: .5mm solid #fff; border-radius: 99px; background: #315c75; box-shadow: 0 0 0 .35mm #315c75; }
.id-card { border: .25mm solid #cbd5dc; border-radius: .25rem; background: #f3f6f8; padding: 3mm; }
.id-card dl { display: grid; grid-template-columns: 22mm 1fr; gap: 1mm 2mm; font-size: 9pt; line-height: 1.3; }
.id-card dt { font-weight: 600; color: #435264; }
.id-card dd { margin: 0; }
.summary-top { display: grid; grid-template-columns: 58mm 1fr; gap: 4mm; margin-top: 4mm; }
.vinc-timeline { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2mm; border-top: .25mm solid #cbd5dc; padding-top: 3mm; margin-top: 3mm; }
.vinc-period { margin: 0; font-size: 8pt; font-weight: 700; color: #315c75; }
.vinc-text { margin: 1mm 0 0; font-size: 9pt; font-weight: 600; line-height: 1.25; }
.vinc-source { margin: 1mm 0 0; font-size: 8pt; line-height: 1.2; color: #435264; }
.scope-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3mm; margin-top: 4mm; }
.scope-card { border: .25mm solid #cbd5dc; border-radius: .25rem; padding: 3mm; }
.scope-card.card-sector { border-color: #c9b483; background: #fffcf6; }
.scope-card.card-network { border-color: #b9acc3; background: #fcf9fd; }
.scope-count { margin: 3mm 0 0; font-size: 16pt; font-weight: 600; font-variant-numeric: tabular-nums; }
.scope-cards .tight-list { margin-top: 2mm; }
.more-note { margin: 1.5mm 0 0; font-size: 8pt; font-weight: 600; color: #8a5a18; }
`
const instHeader = (report, title, subtitle, pageNo) => `
<header class="report-header">
<div>
<p class="report-kicker">Dossiê institucional · ${htmlEscape(report.meta.subjectName)}</p>
<h2 class="sheet-title">${htmlEscape(title)}</h2>
${subtitle ? `<p class="sheet-sub">${htmlEscape(subtitle)}</p>` : ''}
</div>
<div class="report-meta">
<p><strong class="warning-text">INSUMO INTERNO — defeso 2026</strong></p>
<p>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</p>
<p>pág. ${pageNo}/${report.meta.pageTotal}</p>
</div>
</header>`
const instFooter = (report, left, pageNo) => `
<footer class="report-footer">
<span>Dossiê institucional · ${htmlEscape(report.meta.subjectName)} · ${htmlEscape(left)}</span>
<span>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))} · pág. ${pageNo}/${report.meta.pageTotal}</span>
</footer>`
/** Singular of the capped-list nouns (never derive it by chopping the "s"). */
const CAPPED_NOUNS = {
itens: 'item',
entregas: 'entrega',
vínculos: 'vínculo',
}
const moreNote = (cappedList, noun = 'itens') => {
if (cappedList.omitted <= 0) return ''
const label = cappedList.omitted === 1 ? (CAPPED_NOUNS[noun] ?? noun) : noun
return `<p class="more-note">e mais ${cappedList.omitted} ${label} com fonte fora desta página — nada foi descartado.</p>`
}
const identityBadgeList = (report) =>
report.meta.identityBadges
.map((badge) => `<span class="identity-badge">${htmlEscape(badge)}</span>`)
.join(' ')
const renderInstitutionCover = (report) => `
<article class="sheet sheet-cover" data-page="capa" aria-label="Capa do dossiê institucional">
<div class="accent-bar"></div>
<header class="cover-head">
<div>
<p class="report-kicker">${htmlEscape(report.cover.kicker)}</p>
<p class="cover-series">${htmlEscape(report.cover.series)}</p>
</div>
<div class="internal-note"><strong>${htmlEscape(report.cover.internalNote)}</strong><br />${htmlEscape(report.cover.internalNoteSub)}</div>
</header>
<div class="cover-block">
<p class="eyebrow">${htmlEscape(report.cover.eyebrow)}</p>
<h1 class="cover-title">Dossiê Solla —<br /><span class="cover-accent">${htmlEscape(report.cover.title)}</span></h1>
<p class="cover-sub">${htmlEscape(report.cover.subtitle)}</p>
</div>
<dl class="cover-dl">
<dt>Identificação</dt><dd>${identityBadgeList(report)}</dd>
<dt>Data de geração</dt><dd class="tabular">${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</dd>
<dt>Escopo e versão</dt><dd>Eras A / B / C · instituição, setor e rede separados · versão 01</dd>
</dl>
<div class="cover-grid">
<div class="how-to"><p class="how-to-title">Como usar</p><p>${htmlEscape(report.cover.howToUse)}</p></div>
${assetBox('NEEDS ASSET', ['selo da instituição', 'uso autorizado e origem'])}
</div>
<div class="cover-foot">
<div class="ink-rule"></div>
<div class="cover-foot-row">
<p class="cover-scope">${htmlEscape(report.cover.scope)}</p>
<p class="cover-version">Documento de trabalho<br />versão 01</p>
</div>
</div>
</article>`
const renderInstitutionIdentity = (report) => {
const identity = report.meta.identity ?? {}
const rows = [
['Nome', identity.name],
['Tipo', identity.kindLabel],
['Esfera', identity.sphereLabel],
['Alcance', identity.scopeLabel],
['Alias', (identity.aliases ?? []).join(' · ') || '—'],
].filter(([, value]) => Boolean(value))
return `<div class="id-card">
<p class="eyebrow">Identificação</p>
<dl>${rows.map(([key, value]) => `<dt>${htmlEscape(key)}</dt><dd>${htmlEscape(value)}</dd>`).join('')}</dl>
</div>`
}
const renderInstitutionVincTimeline = (report) => {
const timeline = report.page1.timeline
if (timeline.items.length === 0) {
return '<p class="muted small">Nenhum vínculo datado com fonte — ver lacunas explícitas.</p>'
}
return `
<div class="vinc-timeline">
${timeline.items
.map(
(row) => `<div class="timeline-item">
<p class="vinc-period">${htmlEscape(row.period || '—')}</p>
<p class="vinc-text">${copyHtml(briefOr(row.brief, row.role))}</p>
<p class="vinc-source">${htmlEscape(row.source ?? '')} ${sourceLink(row.url)}</p>
</div>`,
)
.join('')}
</div>
${moreNote(timeline, 'vínculos')}`
}
const renderInstitutionDeliveries = (report) => {
const deliveries = report.page1.deliveries
if (deliveries.items.length === 0) return ''
return `
<section class="block">
<div class="block-head">
<div><p class="eyebrow">02 · destaques com lastro</p><h3 class="section-title">Principais entregas localizadas</h3></div>
<p class="warning-text meta strong">Cada valor mantém a fase e a abrangência</p>
</div>
<div class="deliveries">
${deliveries.items
.map(
(row) => `<div class="delivery-row">
<div class="delivery-scope">${scopeBadge(row.sphere, null, report.unit)}<p class="meta">Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}</p></div>
<div>
<p class="delivery-title">${copyHtml(briefOr(row.brief, row.title))}</p>
${noteOr(row.brief, row.detail) ? `<p class="muted small">${copyHtml(noteOr(row.brief, row.detail))}</p>` : ''}
</div>
<div class="delivery-value">
<p class="value-big tabular">${valueCell(row.value, 'Valor não informado')}</p>
${row.phase ? phaseBadge(row.phase) : ''}
${sourceLink(row.sourceUrl)}
</div>
</div>`,
)
.join('')}
</div>
${moreNote(deliveries, 'entregas')}
<p class="muted small strong">Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.</p>
</section>`
}
const renderInstitutionHooksAndPending = (report) => {
const hooks = report.page1.hooks
const pending = report.page1.pending
if (hooks.items.length === 0 && pending.items.length === 0) return ''
return `
<section class="page1-grid">
${
hooks.items.length
? `<div class="panel">
<p class="eyebrow">Gancho para a agenda</p>
<ul class="tight-list">${hooks.items
.map(
(hook) =>
`<li><strong>${htmlEscape(hook.topic)}:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}</li>`,
)
.join('')}</ul>
</div>`
: '<div></div>'
}
${
pending.items.length
? `<div class="panel panel-warning">
<p class="eyebrow warning-text">O que falta</p>
<ul class="tight-list">${pending.items.map((item) => `<li>${htmlEscape(item)}</li>`).join('')}</ul>
<p class="warning-text small strong">Ausência é resultado: não completar por inferência.</p>
</div>`
: '<div></div>'
}
</section>`
}
const renderInstitutionSummary = (report, pageNo) => `
<article class="sheet" data-page="resumo" aria-label="Resumo de uma olhada do dossiê institucional">
${instHeader(report, 'Resumo de uma olhada', 'Informação datada e pronta para conferência', pageNo)}
<section class="summary-top">
${renderInstitutionIdentity(report)}
<div>
<p class="eyebrow">01 · vínculo com a instituição</p>
<h3 class="section-title">Linha do tempo documentada</h3>
${renderInstitutionVincTimeline(report)}
</div>
</section>
${renderInstitutionDeliveries(report)}
${renderInstitutionHooksAndPending(report)}
${instFooter(report, 'Resumo · INSUMO INTERNO', pageNo)}
</article>`
const renderInstitutionEraEmpty = (report, era, pageNo) => `
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
${instHeader(report, `${era.label} · Pesquisa sem evidência suficiente`, era.period, pageNo)}
<div class="era-method">
<div>
<p class="eyebrow">Recorte previsto</p>
<p class="muted">${htmlEscape(era.method)}</p>
</div>
<aside class="aside-accent"><strong>Trilha tentada</strong><br />${htmlEscape(era.recovery)}</aside>
</div>
<section class="empty-panel" style="margin-top: 5mm">
${WARNING_ICON}
<p class="eyebrow warning-text">Lacuna explícita · não é “zero”</p>
<h3 class="rule-title">Nenhuma fonte suficiente foi localizada para esta era.</h3>
<p class="muted">A pesquisa não encontrou documento que sustente uma afirmação institucional. Isso não prova que não houve vínculo ou atuação; prova apenas que o dossiê ainda não pode afirmá-los.</p>
</section>
<section class="page1-grid">
<div class="panel">
<p class="eyebrow">O que foi feito</p>
<ul class="tight-list">
<li>Busca nominal por instituição e aliases.</li>
<li>Consulta a fontes oficiais e acervos datados.</li>
<li>Triagem de resultados por vínculo e abrangência.</li>
<li>Descarte de menções sem lastro suficiente.</li>
</ul>
</div>
<div class="panel">
<p class="eyebrow">O que falta fazer</p>
<ul class="tight-list">
<li>Solicitar consulta ao arquivo físico.</li>
<li>Validar nome histórico e unidade da instituição.</li>
<li>Recuperar ato, ata ou documento contemporâneo.</li>
<li>Repetir busca com alias confirmado.</li>
</ul>
</div>
</section>
<aside class="rule-warning">
${WARNING_ICON}
<div>
<p class="eyebrow warning-text">Regra editorial</p>
<p><strong>Não preencher a seção por memória, cargo provável ou texto de outra era.</strong> Registrar “${htmlEscape(era.label)}: evidência institucional não localizada nas fontes consultadas”.</p>
</div>
</aside>
${instFooter(report, `${era.label} · lacuna explícita`, pageNo)}
</article>`
const renderInstitutionEra = (report, era, pageNo) => {
if (era.empty) return renderInstitutionEraEmpty(report, era, pageNo)
return `
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
${instHeader(report, era.label, `${era.period} · ${era.subtitle}`, pageNo)}
<div class="era-method">
<div>
<p class="eyebrow">Recorte e método</p>
<p class="muted">${htmlEscape(era.method)}</p>
</div>
<aside class="aside-accent"><strong>Trilha de recuperação</strong><br />${htmlEscape(era.recovery)}</aside>
</div>
${
era.numbers.items.length
? `<section class="block">
<div class="block-head">
<div><p class="eyebrow">Entregas localizadas</p><h3 class="section-title">Objeto, valor, fase e alcance</h3></div>
</div>
<table class="document-table">
<caption class="sr-only">Valores por ano, fase, abrangência e fonte</caption>
<colgroup><col style="width:26%" /><col style="width:14%" /><col style="width:9%" /><col style="width:16%" /><col style="width:15%" /><col style="width:20%" /></colgroup>
<thead><tr><th>Objeto</th><th>Valor</th><th>Ano</th><th>Fase</th><th>Abrangência</th><th>Fonte</th></tr></thead>
<tbody>${era.numbers.items
.map(
(row) => `<tr>
<td>${htmlEscape(row.object)}</td>
<td class="numeric">${valueCell(row.value, 'Valor a recuperar')}</td>
<td class="numeric">${htmlEscape(row.year ?? '—')}</td>
<td>${phaseBadge(row.phase)}</td>
<td>${scopeBadge(row.sphere, null, report.unit)}</td>
<td>${sourceLink(row.sourceUrl)}${row.sphere !== 'instituicao' ? '<br /><span class="meta">não somar à instituição</span>' : ''}</td>
</tr>`,
)
.join('')}</tbody>
</table>
<p class="warning-text small strong">Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.</p>
</section>`
: ''
}
${
era.actions.items.length
? `<section class="block">
<p class="eyebrow">Atuação / vínculo registrado</p>
<h3 class="section-title">Papéis com evidência visível</h3>
<div class="action-grid">
${era.actions.items
.map(
(action) => `<article class="panel">
<div class="action-head">${scopeBadge(action.sphere, null, report.unit)}<span class="meta">${htmlEscape(action.year ?? '—')}</span></div>
<h4 class="action-title">${copyHtml(briefOr(action.brief, action.title))}</h4>
${noteOr(action.brief, action.detail) ? `<p class="muted small">${copyHtml(noteOr(action.brief, action.detail))}</p>` : ''}
${sourceLink(action.sourceUrl)}
</article>`,
)
.join('')}
</div>
</section>`
: ''
}
${
era.honors.length
? `<section class="block honors">
<div class="panel">
<p class="eyebrow">Títulos, honrarias e vínculos institucionais</p>
<ul class="tight-list">${era.honors
.map(
(honor) =>
`<li>${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}</li>`,
)
.join('')}</ul>
</div>
${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])}
</section>`
: ''
}
${instFooter(report, era.label, pageNo)}
</article>`
}
const renderInstitutionScope = (report, pageNo) => {
const reach = report.reach
const cardClass = { institution: '', sector: 'card-sector', network: 'card-network' }
return `
<article class="sheet" data-page="abrangencia" aria-label="Painel de abrangência institucional">
${instHeader(report, 'Abrangência: instituição × setor × rede', 'Três recortes, três leituras, nenhuma soma combinada', pageNo)}
<section class="rule-warning">
${WARNING_ICON}
<div>
<p class="rule-title">${htmlEscape(reach.ruleTitle)}</p>
<p class="muted">${htmlEscape(reach.ruleBody)}</p>
</div>
</section>
<section class="block">
<p class="eyebrow">Leitura lado a lado</p>
<h3 class="section-title">O alcance acompanha cada evidência</h3>
<div class="scope-cards">
${reach.lists
.map(
(list) => `<article class="scope-card ${cardClass[list.key] ?? ''}">
<div class="action-head">${scopeBadge(list.sphere, null, report.unit)}<span class="meta strong">${list.sphere === 'instituicao' ? 'DIRETO' : 'NÃO SOMAR'}</span></div>
<p class="scope-count tabular">${list.total} ${list.total === 1 ? 'item' : 'itens'}</p>
<p class="meta">com fonte</p>
<ul class="tight-list">${list.items
.map(
(item) =>
`<li>${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}</li>`,
)
.join(
'',
)}${list.items.length === 0 ? '<li class="muted">Nenhum item com fonte.</li>' : ''}</ul>
${moreNote(list, 'itens')}
</article>`,
)
.join('')}
</div>
</section>
${
reach.evidence.items.length
? `<section class="block">
<div class="block-head"><div><p class="eyebrow">Evidência de alcance</p><h3 class="section-title">Por que cada item está neste recorte</h3></div><p class="warning-text meta strong">Sem evidência, vira lacuna</p></div>
<table class="document-table">
<caption class="sr-only">Itens de setor e rede com evidência de alcance</caption>
<colgroup><col style="width:26%" /><col style="width:16%" /><col style="width:38%" /><col style="width:20%" /></colgroup>
<thead><tr><th>Item</th><th>Abrangência</th><th>Evidência de alcance</th><th>Fonte</th></tr></thead>
<tbody>${reach.evidence.items
.map(
(row) => `<tr>
<td>${copyHtml(briefOr(row.brief, row.item))}</td>
<td>${scopeBadge(row.sphere, null, report.unit)}</td>
<td>${copyHtml(row.evidence)}</td>
<td>${sourceLink(row.sourceUrl)}</td>
</tr>`,
)
.join('')}</tbody>
</table>
${moreNote(reach.evidence, 'itens')}
</section>`
: ''
}
${
reach.hook || reach.priorityGap
? `<section class="page1-grid">
${
reach.hook
? `<div class="aside-accent"><p class="eyebrow">Gancho possível</p><p><strong>${htmlEscape(reach.hook.topic)}:</strong> ${copyHtml(briefOr(reach.hook.brief, reach.hook.angle))} ${sourceLink(reach.hook.sourceUrl)}</p></div>`
: '<div></div>'
}
${
reach.priorityGap
? `<div class="panel panel-warning"><p class="eyebrow warning-text">Lacuna prioritária</p><p>${htmlEscape(reach.priorityGap)}</p></div>`
: '<div></div>'
}
</section>`
: ''
}
${instFooter(report, 'Abrangência · recortes não somáveis', pageNo)}
</article>`
}
const honorDate = (honor) => {
if (honor.date) return formatDateBr(honor.date)
if (honor.year) return String(honor.year)
return '—'
}
const institutionHonors = (report) => report.eras.find((era) => era.id === 'C')?.honors ?? []
const hasInstitutionHonors = (report) => institutionHonors(report).length > 0
const renderInstitutionHonors = (report, pageNo) => {
const honors = institutionHonors(report)
if (honors.length === 0) return ''
const gap = report.gaps?.[0] ?? null
const hook = report.reach.hook ?? report.page1.hooks.items[0] ?? null
return `
<article class="sheet" data-page="titulos" aria-label="Títulos, honrarias e vínculos institucionais">
${instHeader(report, 'Títulos, honrarias e vínculos', 'Reconhecimento não substitui entrega; vínculo não prova resultado', pageNo)}
<section class="block">
<p class="eyebrow">Registros confirmados</p>
<h3 class="section-title">Data, natureza e fonte primária</h3>
<table class="document-table">
<caption class="sr-only">Títulos, honrarias e vínculos por data e fonte</caption>
<colgroup><col style="width:14%" /><col style="width:20%" /><col style="width:35%" /><col style="width:31%" /></colgroup>
<thead><tr><th>Data</th><th>Natureza</th><th>Registro</th><th>Fonte</th></tr></thead>
<tbody>${honors
.map(
(honor) => `<tr>
<td class="numeric">${htmlEscape(honorDate(honor))}</td>
<td><span class="identity-badge">reconhecimento</span></td>
<td><strong>${copyHtml(briefOr(honor.brief, honor.text))}</strong>${noteOr(honor.brief, honor.detail) ? `<br /><span class="meta">${copyHtml(noteOr(honor.brief, honor.detail))}</span>` : ''}</td>
<td>${sourceLink(honor.sourceUrl)}</td>
</tr>`,
)
.join('')}</tbody>
</table>
</section>
<section class="block honors">
<div>
<p class="eyebrow">Como ler</p>
<div class="action-grid">
<div class="panel">
<h3 class="action-title">Vínculo documentado</h3>
<p class="muted small">Informa papel, unidade e período. Não autoriza atribuir toda ação posterior da instituição.</p>
</div>
<div class="panel">
<h3 class="action-title">Honraria documentada</h3>
<p class="muted small">Registra reconhecimento e órgão concedente. Não vira prova de recurso ou entrega.</p>
</div>
</div>
</div>
${assetBox('NEEDS ASSET', ['reprodução do ato, diploma ou clipping', 'origem, autoria e licença'])}
</section>
<section class="page1-grid">
<div class="aside-accent">
<p class="eyebrow">Gancho para a agenda</p>
<p>${hook ? `<strong>${htmlEscape(hook.topic)}:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))}` : 'Mencionar um vínculo só na formulação que a fonte sustenta e conectá-lo a uma entrega comprovada, se houver.'}</p>
</div>
<div class="panel panel-warning">
<p class="eyebrow warning-text">Pendência documental</p>
<p>${gap ? `${htmlEscape(gap.label)}: ${htmlEscape(gap.reason)}` : 'Nenhuma pendência declarada para os registros desta página.'}</p>
</div>
</section>
${instFooter(report, 'Títulos e vínculos · INSUMO INTERNO', pageNo)}
</article>`
}
const renderInstitutionGapsPage = (report, chunk, index, pageNo) => {
const rows = chunk.length
? chunk
.map(
(gap) => `<tr>
<td><strong>${htmlEscape(gap.label)}</strong></td>
<td>${htmlEscape(gap.reason)}</td>
<td>Não completar por inferência.</td>
<td>${htmlEscape(gap.nextStep)}</td>
</tr>`,
)
.join('')
: '<tr><td colspan="4" class="muted">Nenhuma lacuna declarada — confira a cobertura das eras.</td></tr>'
return `
<article class="sheet" data-page="lacunas${index > 0 ? `-${index + 1}` : ''}" aria-label="Lacunas explícitas">
${instHeader(report, 'Lacunas explícitas', `O que não foi encontrado${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)}
<section class="block">
<div class="block-head"><div><p class="eyebrow">O que não foi encontrado</p><h3 class="section-title">Lacunas explícitas</h3></div><p class="warning-text meta strong">Não completar por memória ou inferência</p></div>
<table class="document-table">
<caption class="sr-only">Tabela de lacunas explícitas e próximos passos de apuração</caption>
<colgroup><col style="width:26%" /><col style="width:30%" /><col style="width:24%" /><col style="width:20%" /></colgroup>
<thead><tr><th>Lacuna</th><th>Situação da busca</th><th>Limite editorial</th><th>Próximo passo</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</section>
${instFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)}
</article>`
}
const renderInstitutionSourcesPage = (report, pageNo) => `
<article class="sheet" data-page="fontes" aria-label="Fontes e limites do dossiê institucional">
${instHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
${
report.news.length
? `<section class="block">
<p class="eyebrow">Lista clicável</p>
<h3 class="section-title">Notícias e documentos consultados</h3>
<table class="document-table">
<caption class="sr-only">Fontes consultadas: data 12%, veículo 13%, título 47% e link 28%</caption>
<colgroup><col style="width:12%" /><col style="width:13%" /><col style="width:47%" /><col style="width:28%" /></colgroup>
<thead><tr><th>Data</th><th>Veículo</th><th>Título</th><th>Link</th></tr></thead>
<tbody>${report.news
.map(
(row) => `<tr>
<td class="numeric nowrap">${htmlEscape(formatShortDateBr(row.date))}</td>
<td>${htmlEscape(row.outlet)}</td>
<td>${htmlEscape(row.title)}</td>
<td>${sourceLink(row.url, '(fonte)')}</td>
</tr>`,
)
.join('')}</tbody>
</table>
</section>`
: ''
}
${
report.acervo?.items?.length
? `<section class="block">
<div class="block-head"><div><p class="eyebrow">Acervo interno (read-only)</p><h3 class="section-title">Falas do mandato sobre o recorte</h3></div><p class="meta">Base Teqo, 2011+</p></div>
<ul class="tight-list">${report.acervo.items
.map(
(row) =>
`<li><strong>${htmlEscape(row.period)}</strong> — ${copyHtml(row.text)}${row.excerpt ? ` <span class="muted small">(${copyHtml(row.excerpt)})</span>` : ''} ${sourceLink(row.sourceUrl)}</li>`,
)
.join('')}</ul>
${moreNote(report.acervo, 'itens')}
</section>`
: ''
}
<section class="page1-grid">
<div class="panel"><p class="eyebrow">Limites de cobertura</p><ul class="tight-list">${report.limits.coverage
.map((item) => `<li>${htmlEscape(item)}</li>`)
.join('')}</ul></div>
<div class="panel"><p class="eyebrow">Regras para uso editorial</p><ul class="tight-list">${report.limits.editorial
.map((item) => `<li>${htmlEscape(item)}</li>`)
.join('')}</ul></div>
</section>
<aside class="rule-warning defeso">
${WARNING_ICON}
<div>
<p class="eyebrow warning-text">Nota de defeso eleitoral 2026</p>
<h3 class="rule-title">Insumo interno não é autorização para publicar.</h3>
<p class="muted">Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável. Este dossiê organiza evidências; não substitui análise jurídica nem editorial.</p>
</div>
</aside>
${instFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)}
</article>`
const renderInstitutionDossierHtml = (report) => {
const gapChunks = chunkGaps(report.gaps)
report.meta.pageTotal =
1 + // capa
1 + // resumo
report.eras.length +
1 + // abrangência
(hasInstitutionHonors(report) ? 1 : 0) +
gapChunks.length +
1 // fontes
let page = 0
const parts = []
parts.push(renderInstitutionCover(report))
page += 1
parts.push(renderInstitutionSummary(report, ++page))
for (const era of report.eras) parts.push(renderInstitutionEra(report, era, ++page))
parts.push(renderInstitutionScope(report, ++page))
if (hasInstitutionHonors(report)) parts.push(renderInstitutionHonors(report, ++page))
gapChunks.forEach((chunk, index) =>
parts.push(renderInstitutionGapsPage(report, chunk, index, ++page)),
)
parts.push(renderInstitutionSourcesPage(report, ++page))
return `<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.subjectName)}</title><style>${INSTITUTION_PRINT_CSS}</style></head>
<body>
${parts.join('\n')}
</body>
</html>`
}
const institutionMdEra = (era) => {
const lines = [
`## ${era.label} (${era.period})`,
'',
`_${era.method}_`,
'',
`Trilha: ${era.recovery}`,
]
if (era.empty) {
lines.push(
'',
`> Lacuna explícita: nenhuma fonte suficiente foi localizada para a ${era.label}. Isso não prova ausência — prova que o dossiê ainda não pode afirmar.`,
)
return lines.join('\n')
}
if (era.numbers.items.length) {
lines.push(
'',
'| Objeto | Valor | Ano | Fase | Abrangência | Fonte |',
'| --- | --- | --- | --- | --- | --- |',
)
for (const row of era.numbers.items) {
lines.push(
`| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`,
)
}
}
for (const action of era.actions.items) {
lines.push(
'',
`- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`,
)
}
for (const honor of era.honors)
lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`)
return lines.join('\n')
}
const renderInstitutionDossierMd = (report) => {
const lines = [
`# ${report.meta.title} — ${report.meta.subjectName}`,
'',
`**Identificação:** ${report.meta.identityBadges.join(' · ')}`,
`**Gerado em:** ${formatDateTimeBr(report.meta.generatedAt)} · **Base Teqo (read-only) lida em:** ${report.meta.readAtLabel}`,
'',
'> INSUMO INTERNO — não circular, não publicar. Documento de trabalho.',
'',
`## Identificação`,
'',
`- **Nome:** ${report.meta.identity?.name ?? '—'}`,
`- **Tipo:** ${report.meta.identity?.kindLabel ?? '—'}`,
`- **Esfera:** ${report.meta.identity?.sphereLabel ?? '—'}`,
`- **Alcance:** ${report.meta.identity?.scopeLabel ?? '—'}`,
`- **Alias:** ${(report.meta.identity?.aliases ?? []).join(' · ') || '—'}`,
'',
'## Linha do tempo documentada',
'',
]
if (report.page1.timeline.items.length === 0) lines.push('_Nenhum vínculo datado com fonte._')
for (const row of report.page1.timeline.items) {
lines.push(
`- **${row.period || '—'}** — ${stripInlineSources(row.role)} (${row.source ?? ''}) [fonte](${row.url})`,
)
}
if (report.page1.timeline.omitted > 0)
lines.push(`- _e mais ${report.page1.timeline.omitted} vínculo(s) com fonte._`)
if (report.page1.deliveries.items.length) {
lines.push('', '## Principais entregas localizadas', '')
for (const row of report.page1.deliveries.items) {
lines.push(
`- **${stripInlineSources(row.title)}** · _${row.sphere}_${row.value ? ` · ${row.value}` : ''}${row.phase ? ` (${dossierPhaseLabel(row.phase)})` : ''}${row.detail ? ` — ${stripInlineSources(row.detail)}` : ''} [fonte](${row.sourceUrl})`,
)
}
if (report.page1.deliveries.omitted > 0)
lines.push(`- _e mais ${report.page1.deliveries.omitted} entrega(s) com fonte._`)
lines.push('', '_Autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor._')
}
for (const era of report.eras) lines.push('', institutionMdEra(era))
lines.push('', '## Abrangência: instituição × setor × rede', '')
lines.push(`> ${report.reach.ruleTitle}`, `> ${report.reach.ruleBody}`, '')
for (const list of report.reach.lists) {
lines.push(`### ${list.label}`, '')
if (list.items.length === 0) lines.push('- Nenhum item com fonte.')
for (const item of list.items) {
lines.push(`- ${stripInlineSources(item.answer)} — [fonte](${item.sourceUrl})`)
}
if (list.omitted > 0) lines.push(`- _e mais ${list.omitted} item(ns) com fonte._`)
lines.push('')
}
if (report.reach.evidence.items.length) {
lines.push('| Item | Abrangência | Evidência | Fonte |', '| --- | --- | --- | --- |')
for (const item of report.reach.evidence.items) {
lines.push(
`| ${stripInlineSources(item.item)} | ${item.sphere} | ${stripInlineSources(item.evidence)} | ${item.sourceUrl} |`,
)
}
}
if (report.acervo?.items?.length) {
lines.push('', '## Acervo interno (read-only)', '')
for (const row of report.acervo.items) {
lines.push(
`- **${row.period}** — ${stripInlineSources(row.text)}${row.excerpt ? ` _(${stripInlineSources(row.excerpt)})_` : ''} [fonte](${row.sourceUrl})`,
)
}
if (report.acervo.omitted > 0)
lines.push(`- _e mais ${report.acervo.omitted} fala(s) com fonte._`)
}
lines.push('', '## Lacunas explícitas', '')
if (report.gaps.length === 0) lines.push('_Nenhuma lacuna declarada._')
for (const gap of report.gaps) {
lines.push(`- **${gap.label}:** ${gap.reason} → ${gap.nextStep}`)
}
if (report.news.length) {
lines.push('', '## Notícias e documentos consultados', '')
for (const row of report.news) {
lines.push(`- ${formatDateBr(row.date)} · ${row.outlet} — ${row.title} — ${row.url}`)
}
}
lines.push(
'',
'## Limites de cobertura',
'',
...report.limits.coverage.map((item) => `- ${item}`),
'',
'## Regras para uso editorial',
'',
...report.limits.editorial.map((item) => `- ${item}`),
'',
'## Nota de defeso eleitoral 2026',
'',
'Insumo interno não é autorização para publicar. Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável.',
'',
`_Documento interno de campanha, datado (${formatDateTimeBr(report.meta.generatedAt)}). O dado envelhece: confira a data de leitura da base._`,
)
return `${lines.join('\n')}\n`
}
/** Dispatch: institution reports render with the institutional sections. */
export const renderDossierHtml = (report) =>
isInstitutionUnit(report.unit)
? renderInstitutionDossierHtml(report)
: renderMunicipalityDossierHtml(report)
export const renderDossierMd = (report) =>
isInstitutionUnit(report.unit)
? renderInstitutionDossierMd(report)
: renderMunicipalityDossierMd(report)