/**
* 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, formatMoneyCompact } from './cityReportFormat.mjs'
import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs'
import { MUNICIPALITY_UNIT, isSubjectUnit, resolveDossierUnit } from './dossieUnit.mjs'
import { htmlEscape, moreItemsLabel, showingLabel, stripInlineSources } from './reportText.mjs'
/** Markdown column headers capitalize the sphere label (sentence case, as C187 shipped). */
const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1)
const SOURCE_ICON =
' '
const WARNING_ICON =
' '
const sourceLink = (url, label = '(fonte)') =>
url
? `${SOURCE_ICON}${htmlEscape(label)} `
: ''
const scopeBadge = (sphere, label = null, unit = MUNICIPALITY_UNIT) => {
const badgeClass = dossierSphereBadgeClass(sphere, unit)
return `${htmlEscape(label ?? dossierSphereLabel(sphere, unit))} `
}
/** `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 `${htmlEscape(dossierPhaseLabel(phase))} `
}
const placeholder = (label) =>
` `
const valueCell = (value, widthLabel = 'Valor desconhecido') =>
value === null || value === undefined || value === ''
? placeholder(widthLabel)
: `${htmlEscape(value)} `
const assetBox = (title, lines) =>
`
${htmlEscape(title)}
${lines
.map((line) => htmlEscape(line))
.join(' ')}
`
/** Total of sheets rendered so far — the shell's `pág. N/M` reads it. */
const pageCount = (report) => report.meta.pageTotal
const timelineGrid = (rows) => `
${rows
.map(
(row) => `
${htmlEscape(row.period)}
${htmlEscape(row.role)}
${htmlEscape(row.recovery)}${row.uncertain ? ` · ${htmlEscape(row.uncertain)}` : ''}
`,
)
.join('')}
`
const sheetHeader = (report, title, subtitle, pageNo) => `
`
const sheetFooter = (report, left, pageNo) => `
`
const renderCover = (report) => `
${htmlEscape(report.cover.kicker)}
${htmlEscape(report.cover.series)}
${htmlEscape(report.cover.internalNote)} ${htmlEscape(report.cover.internalNoteSub)}
${htmlEscape(report.cover.eyebrow)}
${htmlEscape(report.cover.title)}
${htmlEscape(report.cover.subtitle)}
Território de Identidade ${htmlEscape(report.cover.territory)}
Região / polo adotado ${htmlEscape(report.cover.pole)}
Data de geração ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}
Como usar
${htmlEscape(report.cover.howToUse)}
${assetBox('NEEDS ASSET', ['selo institucional autorizado', 'opcional — nunca marca de campanha'])}
`
const renderTimeline = (report) => `
01 · trajetória
Linha do tempo da carreira
Período + papel + trilha de recuperação
${timelineGrid(report.page1.timeline)}
`
const renderTrajectory = (report, pageNo) => `
${sheetHeader(report, 'Trajetória completa', 'Todos os períodos da carreira e como cada um foi recuperado', pageNo)}
${timelineGrid(report.trajectory)}
Períodos incertos ficam marcados na própria linha; o acervo interno de falas cobre 2011+ e o que falta vira lacuna explícita.
${sheetFooter(report, 'Trajetória', pageNo)}
`
const renderDeliveries = (report) => {
const deliveries = report.page1.deliveries
if (deliveries.items.length === 0) return ''
return `
02 · destaques com lastro
Principais entregas localizadas
${deliveries.items
.map(
(row) => `
${scopeBadge(row.sphere)}
Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}
${copyHtml(briefOr(row.brief, row.title))}
${noteOr(row.brief, row.detail) ? `
${copyHtml(noteOr(row.brief, row.detail))}
` : ''}
${valueCell(row.value, 'Valor não informado')}
${row.phase ? phaseBadge(row.phase) : ''}
${sourceLink(row.sourceUrl)}
`,
)
.join('')}
${
deliveries.remaining > 0
? `${htmlEscape(showingLabel(deliveries.items.length, deliveries.total, 'entrega localizada', 'entregas localizadas'))} — o restante está nas páginas das eras.
`
: ''
}
Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.
`
}
const renderHooksAndPending = (report) => {
const hooks = report.page1.hooks
const pending = report.page1.pending
if (hooks.items.length === 0 && pending.items.length === 0) return ''
return `
${
hooks.items.length
? `
03 · ganchos para o boletim
${hooks.items
.map(
(hook) =>
`Tema: ${htmlEscape(hook.topic)}. Ângulo: ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)} `,
)
.join('')}
${hooks.remaining > 0 ? `
${htmlEscape(moreItemsLabel(hooks.remaining, 'gancho', 'ganchos'))} nas eras.
` : ''}
`
: '
'
}
${
pending.items.length
? `
04 · o que falta
${pending.items.map((item) => `${htmlEscape(item)} `).join('')}
${pending.remaining > 0 ? `
${htmlEscape(moreItemsLabel(pending.remaining, 'ponto', 'pontos'))} sem leitura na tabela de lacunas.
` : ''}
Ausência é resultado: não completar por inferência.
`
: '
'
}
`
}
const renderSummary = (report, pageNo) => `
${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)}
`
/** Region cards (fixed sheet): counts per recorte, no combined total. */
const renderRegion = (report, pageNo) => `
${sheetHeader(report, 'Região / polo', 'Impacto compartilhado, apresentado fora da conta municipal', pageNo)}
${WARNING_ICON}
${htmlEscape(report.region.ruleTitle)}
${htmlEscape(report.region.ruleBody)}
${
report.region.context.length
? `Contexto municipal (IBGE): ${report.region.context
.map(
(item) => `${copyHtml(item.detail)} ${sourceLink(item.sourceUrl, `(${item.topic})`)}`,
)
.join(' · ')} — leitura relativa, nunca % estadual absoluto.
`
: ''
}
Leitura lado a lado
Duas listas, dois totais, nenhuma soma
${scopeBadge('municipio', null, report.unit)}${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte · DIRETO
lista completa nas páginas seguintes
≠
${scopeBadge('regiao', 'região / polo', report.unit)}SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}
lista completa nas páginas seguintes
${
report.region.hook || report.region.priorityGap
? `
${
report.region.hook
? `Gancho possível
Tema: ${htmlEscape(report.region.hook.topic)}. Ângulo: ${copyHtml(briefOr(report.region.hook.brief, report.region.hook.angle))} ${sourceLink(report.region.hook.sourceUrl)}
`
: '
'
}
${
report.region.priorityGap
? `Lacuna prioritária
${htmlEscape(report.region.priorityGap)}
`
: '
'
}
`
: ''
}
${sheetFooter(report, 'Região/polo · recortes não somáveis', pageNo)}
`
/** Full lists of the region recorte, uncapped: each one flows across sheets. */
const regionListUnits = (report, list, probe) =>
list.items.map((item, index) => ({
layout: 'tr',
html: `
${copyHtml(briefOr(item.brief, item.answer))}
${scopeBadge(item.sphere, null, report.unit)}
${copyHtml(item.details ?? item.brief?.note ?? '—')}
${sourceLink(item.sourceUrl)}
`,
}))
const renderRegionListSheet = ({ report, list, entry, probe, pageNo }) => `
${sheetHeader(
report,
`Região / polo · ${list.label}`,
entry.index === 0
? `${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte`
: `continuação ${entry.index + 1} · ${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte`,
pageNo,
)}
${
entry.index === 0
? `
${WARNING_ICON}
${htmlEscape(report.region.ruleTitle)}
Cada linha informa sua esfera; região e polo não são somados ao município.
`
: ''
}
Itens do recorte com evidência de alcance e fonte
${tableHead(['Item', 'Esfera', 'Evidência de alcance', 'Fonte'])}
${entry.chunk.map((unit) => unit.html).join('')}
${sheetFooter(report, `Região/polo · ${list.label}`, pageNo)}
`
const renderRegionListSheets = ({ report, key, anchor, list, pack, probe, nextPage }) => {
if (list.items.length === 0) return []
const units = regionListUnits(report, list, probe)
return packedSection({ key, anchor, units, pack, probe }).map((entry) =>
renderRegionListSheet({ report, list, entry, probe, pageNo: nextPage() }),
)
}
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; }
.chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin-top: 3mm; }
.chart { display: block; width: 100%; height: auto; }
.chart-card { border: .25mm solid #cbd5dc; border-radius: .25rem; padding: 3mm; break-inside: avoid; }
.chart-title { margin: 0 0 2mm; font-size: 9.5pt; font-weight: 600; }
.chart-label { font-size: 7.4pt; fill: #435264; }
.chart-value { font-size: 7.6pt; fill: #1f2933; font-weight: 700; }
.chart-legend { margin: 1.5mm 0 0; font-size: 7.6pt; line-height: 1.3; color: #435264; }
.synthesis-lines { margin: 0; padding-left: 4.5mm; font-size: 9.5pt; line-height: 1.35; }
.synthesis-lines li { margin: 0 0 1.8mm; }
.synthesis-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin-top: 3mm; }
.opening-paragraph { margin: 0 0 3.4mm; font-size: 11pt; line-height: 1.45; }
.opening-paragraph:last-child { margin-bottom: 0; }
.era-summary { margin-top: 3.4mm; border-left: 1.2mm solid #315c75; background: #f7fafb; padding: 2.6mm 3mm; }
.era-summary p { margin: 0; font-size: 10pt; line-height: 1.4; }
.value-list { list-style: none; margin: 0; padding: 0; }
.value-list li { display: grid; grid-template-columns: 1fr 20mm 18mm; gap: 1.6mm; align-items: center; padding: 0.9mm 0; border-bottom: .25mm solid #e3e9ec; font-size: 8.2pt; line-height: 1.22; }
.value-list li:last-child { border-bottom: 0; }
.value-list-bar { display: block; height: 2.2mm; background: #eef2f5; border-radius: 1mm; overflow: hidden; }
.value-list-bar span { display: block; height: 100%; background: #315c75; }
.value-list-value { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
`
const renderMunicipalityDossierHtml = (report, { pack = null, probe = false } = {}) => {
const buildParts = () => {
let page = 0
const nextPage = () => ++page
const parts = []
parts.push(renderCover(report))
page += 1
const opening = renderUnitOpeningPage(report, page + 1)
if (opening) {
parts.push(opening)
page += 1
}
parts.push(renderTrajectory(report, nextPage()))
parts.push(renderSummary(report, nextPage()))
parts.push(renderUnitSynthesisPage(report, nextPage()))
parts.push(renderUnitChartsPage(report, nextPage()))
for (const era of report.eras) {
parts.push(...renderEraSheets({ report, era, pack, probe, nextPage, includeHonors: true }))
}
parts.push(renderRegion(report, nextPage()))
parts.push(
...renderRegionListSheets({
report,
key: 'region:municipio',
anchor: 'regiao-municipio',
list: report.region.municipal,
pack,
probe,
nextPage,
}),
)
parts.push(
...renderRegionListSheets({
report,
key: 'region:regional',
anchor: 'regiao-regional',
list: report.region.regional,
pack,
probe,
nextPage,
}),
)
parts.push(...renderGapSheets({ report, pack, probe, nextPage }))
parts.push(...renderNewsSheets({ report, pack, probe, nextPage }))
parts.push(...renderAcervoSheets({ report, pack, probe, nextPage }))
parts.push(renderUnitSourcesPage(report, nextPage()))
return parts
}
// Two passes: the shell's `pág. N/M` needs the final sheet count, and the
// count only exists after the pack plan is applied.
report.meta.pageTotal = 0
let parts = buildParts()
report.meta.pageTotal = parts.length
parts = buildParts()
return `
${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.municipalityName)}
${parts.join('\n')}
`
}
const mdEra = (era) => {
const lines = [
`## ${era.label} (${era.period})`,
'',
`_${era.method}_`,
'',
`Trilha: ${era.recovery}`,
]
if (era.narrative || era.summary) {
lines.push('', `> ${era.narrative ?? era.summary}`)
}
if (listOf(era.numbers).length) {
lines.push(
'',
'| Objeto | Valor | Ano | Fase | Esfera | Fonte |',
'| --- | --- | --- | --- | --- | --- |',
)
for (const row of listOf(era.numbers)) {
lines.push(
`| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`,
)
}
}
for (const action of listOf(era.actions)) {
lines.push(
'',
`- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`,
)
}
for (const honor of listOf(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.',
'',
]
if (report.opening?.paragraphs?.length) {
lines.push(`## ${report.opening.title}`, '')
for (const paragraph of report.opening.paragraphs) lines.push(paragraph, '')
lines.push(
`_Redação de síntese sobre os pontos com fonte; ${
report.opening.authored
? 'escrita a partir dos itens datados do dossiê'
: 'repetição da leitura dos números (sem redação autoral disponível)'
} — nada aqui acrescenta fato novo._`,
'',
)
}
lines.push('## 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))
}
if (report.synthesis?.lines?.length) {
lines.push('', '## Síntese do que foi localizado', '')
for (const line of report.synthesis.lines) lines.push(`- ${line}`)
lines.push('', '### Números por recorte', '')
lines.push('| Era | Município | Região/polo | Total |', '| --- | --- | --- | --- |')
for (const row of report.synthesis.byEraSphere) {
const countFor = (key) => row.segments.find((segment) => segment.key === key)?.count ?? 0
lines.push(
`| ${row.label} | ${countFor('municipio')} | ${countFor('regiao') + countFor('polo')} | ${row.segments.reduce((sum, segment) => sum + segment.count, 0)} |`,
)
}
if (report.synthesis.moneyByPhase.length) {
lines.push('', '### Recursos por fase', '')
for (const row of report.synthesis.moneyByPhase) {
lines.push(`- ${row.label}: ${row.count} · ${formatMoneyCompact(row.amount)}`)
}
}
if (report.synthesis.byArea.length) {
lines.push('', '### Temas com mais registros', '')
for (const row of report.synthesis.byArea) lines.push(`- ${row.key}: ${row.count}`)
}
}
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(briefOr(item.brief, item.answer))}** (${item.sphere})${item.details ? ` — ${stripInlineSources(item.details)}` : ''} [fonte](${item.sourceUrl})`,
)
}
lines.push('', '### Região / polo (não somável)', '')
for (const item of report.region.regional.items) {
lines.push(
`- **${stripInlineSources(briefOr(item.brief, item.answer))}** (${item.sphere})${item.details ? ` — ${stripInlineSources(item.details)}` : ''} [fonte](${item.sourceUrl})`,
)
}
if (report.acervo?.items?.length) {
lines.push(
'',
'## Acervo interno (read-only)',
'',
`_Amostra de ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte, com link; o acervo completo fica na base Teqo. ${report.unit?.acervoNote ?? ''}_`,
'',
)
for (const row of report.acervo.items) {
lines.push(
`- **${row.period}** — ${stripInlineSources(row.text)}${row.excerpt ? ` _(${stripInlineSources(row.excerpt)})_` : ''} [fonte](${row.sourceUrl})`,
)
}
}
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; }
.acervo-stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 3mm; margin-top: 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; }
`
/**
* Anchor → pack-section key of the flowing sheets. The builder maps an
* overflowing sheet back to its pack entry and shrinks that chunk (fallback of
* the measured pack plan).
*/
const SHARED_PACK_ANCHORS = {
'era-a': 'era:A',
'era-b': 'era:B',
'era-c': 'era:C',
lacunas: 'gaps',
noticias: 'news',
acervo: 'acervo',
}
const MUNICIPALITY_PACK_ANCHORS = {
...SHARED_PACK_ANCHORS,
'regiao-municipio': 'region:municipio',
'regiao-regional': 'region:regional',
}
export const INSTITUTION_PACK_ANCHORS = {
...SHARED_PACK_ANCHORS,
titulos: 'honors',
'abrangencia-instituicao': 'scope:instituicao',
'abrangencia-setor': 'scope:setor',
'abrangencia-rede': 'scope:rede',
}
const THEME_PACK_ANCHORS = {
...SHARED_PACK_ANCHORS,
'abrangencia-area': 'scope:area',
'abrangencia-segmento': 'scope:segmento',
'abrangencia-rede': 'scope:rede',
}
/**
* Probe-only CSS: the packed sections measure their true content height, with
* no page clamp and no inter-row gaps (the packer adds the card-row gap back).
*/
const INSTITUTION_PROBE_CSS = `
.sheet { height: auto; min-height: 0; overflow: visible; }
[data-pack-section] .block,
[data-pack-section] .action-grid,
[data-pack-section] .document-table,
[data-pack-section] .tight-list,
[data-pack-section] .era-method,
[data-pack-section] .page1-grid { margin-top: 0; }
[data-pack-section] .action-grid,
[data-pack-section] .era-method { gap: 0; }
[data-pack-section] .tight-list li { margin-bottom: 0; }
`
const instHeader = (report, title, subtitle, pageNo) => `
`
/** Shared sheets (síntese, gráficos, carta, eras correntes) use the unit's shell. */
const unitHeader = (report, title, subtitle, pageNo) =>
isSubjectUnit(report.unit)
? instHeader(report, title, subtitle, pageNo)
: sheetHeader(report, title, subtitle, pageNo)
const unitFooter = (report, left, pageNo) =>
isSubjectUnit(report.unit) ? instFooter(report, left, pageNo) : sheetFooter(report, left, pageNo)
const instFooter = (report, left, pageNo) => `
`
/** 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',
hint = 'com fonte fora desta página — nada foi descartado.',
) => {
if (cappedList.omitted <= 0) return ''
const label = cappedList.omitted === 1 ? (CAPPED_NOUNS[noun] ?? noun) : noun
return `e mais ${cappedList.omitted} ${label} ${htmlEscape(hint)}
`
}
/* ---------------------------------------------------------------- *
* Flowing sheets (C187): packed sections render as many sheets as *
* the content needs — nothing is capped. In probe mode a section *
* renders one sheet with every unit tagged (`data-pack-unit`), so *
* the builder measures real heights and returns a pack plan. *
* ---------------------------------------------------------------- */
const splitUnits = (units, sizes) => {
const chunks = []
let offset = 0
for (const size of sizes) {
chunks.push(units.slice(offset, offset + size))
offset += size
}
if (offset < units.length) chunks.push(units.slice(offset))
return chunks
}
/** Sheet plans of one section: `[{ key, anchor, chunk, index }]`. */
const packedSection = ({ key, anchor, units, pack, probe }) => {
const sizes = probe ? null : pack?.[key]
const chunks = sizes ? splitUnits(units, sizes) : [units]
return chunks.map((chunk, index) => ({
key,
chunk,
index,
anchor: index === 0 ? anchor : `${anchor}-${index + 1}`,
}))
}
const unitAttrs = (probe, index, layout) =>
probe ? ` data-pack-unit="${index}" data-pack-layout="${layout}"` : ''
const groupUnits = (chunk) => {
const groups = []
for (const unit of chunk) {
const last = groups[groups.length - 1]
if (last && last.layout === unit.layout) last.units.push(unit)
else groups.push({ layout: unit.layout, units: [unit] })
}
return groups
}
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)} `)
.join(' ')
const coverRowValue = (report, row) => {
if (row.badges) return `${identityBadgeList(report)} `
if (row.code) {
return `${htmlEscape(row.code)}${row.note ? ` · ${htmlEscape(row.note)}` : ''} `
}
return `${htmlEscape(row.text)} `
}
const renderSubjectCover = (report) => {
const rows = [
...report.unit.coverRows(report),
{ label: 'Data de geração', text: formatDateTimeBr(report.meta.generatedAt), tabular: true },
{
label: 'Escopo e versão',
text: `Eras A / B / C · ${report.unit.copy.coverScopeWords} · versão 01`,
},
]
return `
${htmlEscape(report.cover.kicker)}
${htmlEscape(report.cover.series)}
${htmlEscape(report.cover.internalNote)} ${htmlEscape(report.cover.internalNoteSub)}
${htmlEscape(report.cover.eyebrow)}
Dossiê Solla —${htmlEscape(report.cover.title)}
${htmlEscape(report.cover.subtitle)}
${rows.map((row) => `${htmlEscape(row.label)} ${coverRowValue(report, row)}`).join('\n ')}
Como usar
${htmlEscape(report.cover.howToUse)}
${assetBox('NEEDS ASSET', report.unit.copy.coverAsset)}
`
}
const renderSubjectIdentity = (report) => {
const rows = report.unit.identityRows(report.meta.identity ?? {})
return `
Identificação
${rows.map(([key, value]) => `${htmlEscape(key)} ${htmlEscape(value)} `).join('')}
`
}
const renderSubjectVincTimeline = (report) => {
const timeline = report.page1.timeline
if (timeline.items.length === 0) {
return `${htmlEscape(report.unit.copy.timelineEmpty)}
`
}
return `
${timeline.items
.map(
(row) => `
${htmlEscape(row.period || '—')}
${copyHtml(briefOr(row.brief, row.role))}
${htmlEscape(row.source ?? '')} ${sourceLink(row.url)}
`,
)
.join('')}
${moreNote(timeline, 'vínculos', 'com fonte — lista completa nas seções por era.')}`
}
const renderSubjectDeliveries = (report) => {
const deliveries = report.page1.deliveries
if (deliveries.items.length === 0) return ''
return `
02 · destaques com lastro
Principais entregas localizadas
Cada valor mantém a fase e a abrangência
${deliveries.items
.map(
(row) => `
${scopeBadge(row.sphere, null, report.unit)}
Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}
${copyHtml(briefOr(row.brief, row.title))}
${noteOr(row.brief, row.detail) ? `
${copyHtml(noteOr(row.brief, row.detail))}
` : ''}
${valueCell(row.value, 'Valor não informado')}
${row.phase ? phaseBadge(row.phase) : ''}
${sourceLink(row.sourceUrl)}
`,
)
.join('')}
${moreNote(deliveries, 'entregas', 'com fonte — lista completa nas seções por era e de abrangência.')}
Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.
`
}
const renderSubjectHooksAndPending = (report) => {
const hooks = report.page1.hooks
const pending = report.page1.pending
if (hooks.items.length === 0 && pending.items.length === 0) return ''
return `
${
hooks.items.length
? `
Gancho para a agenda
${hooks.items
.map(
(hook) =>
`${htmlEscape(hook.topic)}: ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)} `,
)
.join('')}
`
: '
'
}
${
pending.items.length
? `
O que falta
${pending.items.map((item) => `${htmlEscape(item)} `).join('')}
Ausência é resultado: não completar por inferência.
`
: '
'
}
`
}
const renderSubjectSummary = (report, pageNo) => `
${instHeader(report, 'Resumo de uma olhada', 'Informação datada e pronta para conferência', pageNo)}
${renderSubjectIdentity(report)}
${htmlEscape(report.unit.summaryLinkEyebrow)}
Linha do tempo documentada
${renderSubjectVincTimeline(report)}
${renderSubjectDeliveries(report)}
${renderSubjectHooksAndPending(report)}
${instFooter(report, 'Resumo · INSUMO INTERNO', pageNo)}
`
const renderEraEmptySheet = (report, era, pageNo) => {
const empty = report.unit.copy.eraEmpty
return `
${unitHeader(report, `${era.label} · Pesquisa sem evidência suficiente`, era.period, pageNo)}
Recorte previsto
${htmlEscape(era.method)}
Trilha tentada ${htmlEscape(era.recovery)}
${WARNING_ICON}
Lacuna explícita · não é “zero”
Nenhuma fonte suficiente foi localizada para esta era.
${htmlEscape(empty.body)}
O que foi feito
${empty.work.map((item) => `${htmlEscape(item)} `).join('\n ')}
O que falta fazer
${empty.todo.map((item) => `${htmlEscape(item)} `).join('\n ')}
${unitFooter(report, `${era.label} · lacuna explícita`, pageNo)}
`
}
/** Accepts the institution's capped objects and the city's plain arrays. */
const listOf = (value) => (Array.isArray(value) ? value : (value?.items ?? []))
/**
* Era units: value rows (table), action cards (2-col grid) and — city only, which
* keeps honors in the era sheet — the honor list items. Honors have their own
* sheet in the institution dossiê, so `includeHonors` stays false there.
*/
const eraUnits = (report, era, probe, { includeHonors = false } = {}) => {
const unit = report.unit ?? MUNICIPALITY_UNIT
const units = []
for (const row of listOf(era.numbers)) {
units.push({
layout: 'tr',
html: `
${htmlEscape(row.object)}
${valueCell(row.value, 'Valor a recuperar')}
${htmlEscape(row.year ?? '—')}
${phaseBadge(row.phase)}
${scopeBadge(row.sphere, null, unit)}
${sourceLink(row.sourceUrl)}${
row.sphere !== unit.defaultSphere
? `${htmlEscape(unit.notSummedInline ?? 'não somar')} `
: ''
}
`,
})
}
for (const action of listOf(era.actions)) {
units.push({
layout: 'card',
html: `
${scopeBadge(action.sphere, null, unit)}${htmlEscape(action.year ?? '—')}
${copyHtml(briefOr(action.brief, action.title))}
${noteOr(action.brief, action.detail) ? `${copyHtml(noteOr(action.brief, action.detail))}
` : ''}
${sourceLink(action.sourceUrl)}
`,
})
}
if (includeHonors) {
for (const honor of listOf(era.honors)) {
units.push({
layout: 'li',
html: `${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)} `,
})
}
}
return units
}
const eraNumbersTable = (units, unit) => `
Valores por ano, fase, esfera e fonte
${tableHead(['Objeto', 'Valor', 'Ano', 'Fase', unit.sphereColumnLabel ?? 'Esfera', 'Fonte'])}
${units.map((entry) => entry.html).join('')}
`
const eraHonorsPanel = (units, unit) => `
${htmlEscape(unit.honorsTitle ?? 'Títulos, honrarias e vínculos')}
${units.map((entry) => entry.html).join('')}
${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])}
`
const renderEraSheet = ({ report, era, entry, probe, pageNo }) => {
const unit = report.unit ?? MUNICIPALITY_UNIT
const body = groupUnits(entry.chunk)
.map((group) => {
if (group.layout === 'tr') {
return `
Entregas localizadas
${htmlEscape(unit.eraNumbersTitle ?? 'Objeto, valor, fase e alcance')}
${eraNumbersTable(group.units, unit)}
Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.
`
}
if (group.layout === 'li') return eraHonorsPanel(group.units, unit)
return `
Atuação registrada
${htmlEscape(unit.eraActionsTitle ?? 'Papéis com evidência visível')}
${group.units.map((item) => item.html).join('')}
`
})
.join('')
return `
${unitHeader(report, era.label, entry.index === 0 ? `${era.period} · ${era.subtitle}` : `continuação ${entry.index + 1} · ${era.period}`, pageNo)}
${
entry.index === 0
? `
Recorte e método
${htmlEscape(era.method)}
Trilha de recuperação ${htmlEscape(era.recovery)}
`
: ''
}
${
entry.index === 0 && (era.narrative || era.summary)
? `
O que esta era entrega
${copyHtml(era.narrative ?? era.summary)}
`
: ''
}
${body}
${unitFooter(report, era.label, pageNo)}
`
}
const renderEraSheets = ({ report, era, pack, probe, nextPage, includeHonors = false }) => {
if (era.empty) return [renderEraEmptySheet(report, era, nextPage())]
const units = eraUnits(report, era, probe, { includeHonors })
return packedSection({
key: `era:${era.id}`,
anchor: `era-${era.id.toLowerCase()}`,
units,
pack,
probe,
}).map((entry) => renderEraSheet({ report, era, entry, probe, pageNo: nextPage() }))
}
const PACK_ANCHORS_BY_UNIT = {
municipality: MUNICIPALITY_PACK_ANCHORS,
institution: INSTITUTION_PACK_ANCHORS,
theme: THEME_PACK_ANCHORS,
}
/** Anchor map of the unit's packed sections (the builder inverts it). */
export const dossierPackAnchors = (unit) =>
PACK_ANCHORS_BY_UNIT[resolveDossierUnit(unit).id] ?? MUNICIPALITY_PACK_ANCHORS
const listYearRange = (items) => {
const years = items
.map((item) => item.year)
.filter(Boolean)
.sort()
return years.length ? `${years[0]}–${years[years.length - 1]}` : null
}
const renderSubjectScope = (report, pageNo) => {
const reach = report.reach
const sphereTitle = report.unit.spheres
.map((sphere) => report.unit.sphereLabels[sphere])
.join(' × ')
return `
${instHeader(report, `Abrangência: ${sphereTitle}`, 'Três recortes, três leituras, nenhuma soma combinada', pageNo)}
${WARNING_ICON}
${htmlEscape(reach.ruleTitle)}
${htmlEscape(reach.ruleBody)}
Leitura lado a lado
O alcance acompanha cada evidência
${reach.lists
.map(
(list) => `
${scopeBadge(list.sphere, null, report.unit)}${list.sphere === report.unit.defaultSphere ? 'DIRETO' : 'NÃO SOMAR'}
${list.total} ${list.total === 1 ? 'item' : 'itens'}
com fonte${listYearRange(list.items) ? ` · ${listYearRange(list.items)}` : ''} · lista completa nas páginas seguintes
${list.items
.slice(0, 2)
.map(
(item) =>
`${copyHtml(briefOr(item.brief, item.title))} ${sourceLink(item.sourceUrl)} `,
)
.join(
'',
)}${list.items.length === 0 ? 'Nenhum item com fonte. ' : ''}
`,
)
.join('')}
${
reach.hook || reach.priorityGap
? `
${
reach.hook
? `Gancho possível
${htmlEscape(reach.hook.topic)}: ${copyHtml(briefOr(reach.hook.brief, reach.hook.angle))} ${sourceLink(reach.hook.sourceUrl)}
`
: '
'
}
${
reach.priorityGap
? `Lacuna prioritária
${htmlEscape(reach.priorityGap)}
`
: '
'
}
`
: ''
}
${instFooter(report, 'Abrangência · recortes não somáveis', pageNo)}
`
}
/** Sphere lists are uncapped: each one flows across as many sheets as it needs. */
const subjectScopeUnits = (report, list, probe) =>
list.items.map((item, index) => {
const attrs = unitAttrs(probe, index, 'tr')
if (!report.unit.reachScene) {
return {
layout: 'tr',
html: `
${copyHtml(briefOr(item.brief, item.title))}
${scopeBadge(item.sphere, null, report.unit)}
${copyHtml(noteOr(item.brief, item.evidence))}
${sourceLink(item.sourceUrl)}
`,
}
}
return {
layout: 'tr',
html: `
${copyHtml(briefOr(item.brief, item.title))}
Era ${htmlEscape(item.era ?? '—')}${item.year ? ` · ${htmlEscape(item.year)}` : ''}
${valueCell(item.value)}${item.phase ? ` ${phaseBadge(item.phase)}` : ''}
${copyHtml(noteOr(item.brief, item.evidence))}
${sourceLink(item.sourceUrl)}
`,
}
})
const scopeListWord = (report, list) => report.unit.scopeListWords[list.key] ?? list.key
const renderSubjectScopeListSheet = ({ report, list, entry, probe, pageNo }) => {
const scene = report.unit.reachScene
const word = scopeListWord(report, list)
const notSummable = list.sphere !== report.unit.defaultSphere
const columns = scene ? scene.columns : ['Item', 'Abrangência', 'Evidência de alcance', 'Fonte']
const tableClass = scene ? `document-table ${scene.tableClass}` : 'document-table'
const colgroup = scene
? ' '
: ' '
return `
${instHeader(
report,
`Abrangência · ${list.label}`,
entry.index === 0
? `${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte · ${notSummable ? `não somar ${report.unit.notSummedTo}` : 'recorte direto'}`
: `continuação ${entry.index + 1} · ${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte`,
pageNo,
)}
${
entry.index === 0
? `
${WARNING_ICON}
${htmlEscape(report.reach.ruleTitle)}
Cada linha informa sua abrangência; ${htmlEscape(report.unit.sumGuardNote)}
`
: ''
}
Itens do recorte com evidência de alcance e fonte
${colgroup}
${tableHead(columns)}
${entry.chunk.map((unit) => unit.html).join('')}
${instFooter(report, `Abrangência · ${list.label}`, pageNo)}
`
}
const renderSubjectScopeListSheets = ({ report, list, pack, probe, nextPage }) => {
if (list.items.length === 0) return []
const word = scopeListWord(report, list)
const units = subjectScopeUnits(report, list, probe)
return packedSection({
key: `scope:${word}`,
anchor: `abrangencia-${word}`,
units,
pack,
probe,
}).map((entry) => renderSubjectScopeListSheet({ report, list, entry, probe, pageNo: nextPage() }))
}
const honorDate = (honor) => {
if (honor.date) return formatDateBr(honor.date)
if (honor.year) return String(honor.year)
return '—'
}
const subjectHonors = (report) => report.eras.find((era) => era.id === 'C')?.honors?.items ?? []
const subjectHonorUnits = (probe) =>
subjectHonors(probe.report).map((honor, index) => ({
layout: 'tr',
html: `
${htmlEscape(honorDate(honor))}
reconhecimento
${copyHtml(briefOr(honor.brief, honor.text))} ${noteOr(honor.brief, honor.detail) ? `${copyHtml(noteOr(honor.brief, honor.detail))} ` : ''}
${sourceLink(honor.sourceUrl)}
`,
}))
const renderSubjectHonorSheet = ({ report, entry, probe, pageNo }) => {
const gap = report.gaps?.[0] ?? null
const hook = report.reach.hook ?? report.page1.hooks.items[0] ?? null
return `
${instHeader(
report,
'Títulos, honrarias e vínculos',
entry.index === 0
? 'Reconhecimento não substitui entrega; vínculo não prova resultado'
: `continuação ${entry.index + 1}`,
pageNo,
)}
Registros confirmados
Data, natureza e fonte primária
Títulos, honrarias e vínculos por data e fonte
${tableHead(['Data', 'Natureza', 'Registro', 'Fonte'])}
${entry.chunk.map((unit) => unit.html).join('')}
${
entry.index === 0
? `
Como ler
Vínculo documentado
Informa papel, unidade e período. Não autoriza atribuir toda ação posterior da instituição.
Honraria documentada
Registra reconhecimento e órgão concedente. Não vira prova de recurso ou entrega.
${assetBox('NEEDS ASSET', ['reprodução do ato, diploma ou clipping', 'origem, autoria e licença'])}
Gancho para a agenda
${hook ? `${htmlEscape(hook.topic)}: ${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.'}
Pendência documental
${gap ? `${htmlEscape(gap.label)}: ${htmlEscape(gap.reason)}` : 'Nenhuma pendência declarada para os registros desta página.'}
`
: ''
}
${instFooter(report, 'Títulos e vínculos · INSUMO INTERNO', pageNo)}
`
}
const renderSubjectHonorSheets = ({ report, pack, probe, nextPage }) => {
const units = subjectHonorUnits({ report, probe })
if (units.length === 0) return []
return packedSection({ key: 'honors', anchor: 'titulos', units, pack, probe }).map((entry) =>
renderSubjectHonorSheet({ report, entry, probe, pageNo: nextPage() }),
)
}
/**
* Gaps and news are long full-text tables: packed in continuation sheets, so
* the sources sheet keeps only the acervo, limits and defeso.
*/
const gapUnits = (report, probe) =>
report.gaps.map((gap, index) => {
const attrs = unitAttrs(probe, index, 'tr')
if (!report.unit.gapsScene) {
return {
layout: 'tr',
html: `
${htmlEscape(gap.label)}
${htmlEscape(gap.reason)}
Não completar por inferência.
${htmlEscape(gap.nextStep)}
`,
}
}
const era = gap.era && gap.era !== 'Acervo' ? `Era ${gap.era}` : 'Acervo'
const status =
index === 0
? 'prioridade '
: 'aberta '
return {
layout: 'tr',
html: `
${htmlEscape(era)}
${htmlEscape(gap.label)}
${htmlEscape(gap.reason)}
${htmlEscape(gap.nextStep)}
${status}
`,
}
})
const renderGapSheet = ({ report, entry, probe, pageNo, isLast = false }) => {
const scene = report.unit.gapsScene
const columns = scene
? scene.columns
: ['Lacuna', 'Situação da busca', 'Limite editorial', 'Próximo passo']
const colgroup = scene
? ' '
: ' '
return `
${unitHeader(report, 'Lacunas explícitas', `O que não foi encontrado${entry.index > 0 ? ` · continuação ${entry.index + 1}` : ''}`, pageNo)}
O que não foi encontrado
Lacunas explícitas Não completar por memória ou inferência
Tabela de lacunas explícitas e próximos passos de apuração
${colgroup}
${tableHead(columns)}
${
entry.chunk.length
? entry.chunk.map((unit) => unit.html).join('')
: `Nenhuma lacuna declarada — confira a cobertura das eras. `
}
${scene && isLast ? `${htmlEscape(scene.alert)}
` : ''}
${unitFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)}
`
}
const renderGapSheets = ({ report, pack, probe, nextPage }) => {
const units = gapUnits(report, probe)
if (units.length === 0) {
return [
renderGapSheet({
report,
entry: { anchor: 'lacunas', chunk: [], index: 0 },
probe: false,
pageNo: nextPage(),
isLast: true,
}),
]
}
const entries = packedSection({ key: 'gaps', anchor: 'lacunas', units, pack, probe })
return entries.map((entry, index) =>
renderGapSheet({
report,
entry,
probe,
pageNo: nextPage(),
isLast: index === entries.length - 1,
}),
)
}
const newsUnits = (report, probe) =>
report.news.map((row, index) => ({
layout: 'tr',
html: `
${htmlEscape(formatShortDateBr(row.date))}
${htmlEscape(row.outlet)}
${htmlEscape(row.title)}
${sourceLink(row.url, '(fonte)')}
`,
}))
const renderNewsSheet = ({ report, entry, probe, pageNo }) => `
${unitHeader(report, 'Notícias e documentos consultados', `Fontes externas datadas${entry.index > 0 ? ` · continuação ${entry.index + 1}` : ''}`, pageNo)}
Lista clicável
Notícias e documentos consultados
Fontes consultadas: data 12%, veículo 13%, título 47% e link 28%
${tableHead(['Data', 'Veículo', 'Título', 'Link'])}
${entry.chunk.map((unit) => unit.html).join('')}
${unitFooter(report, 'Notícias e documentos · INSUMO INTERNO', pageNo)}
`
const renderNewsSheets = ({ report, pack, probe, nextPage }) => {
const units = newsUnits(report, probe)
if (units.length === 0) return []
return packedSection({ key: 'news', anchor: 'noticias', units, pack, probe }).map((entry) =>
renderNewsSheet({ report, entry, probe, pageNo: nextPage() }),
)
}
/** Acervo sample (source panel): declared sample with the total carried by the header. */
const acervoUnits = (report, probe) =>
report.acervo.items.map((row, index) => {
const attrs = unitAttrs(probe, index, 'tr')
if (!report.unit.acervoScene) {
return {
layout: 'tr',
html: `${htmlEscape(row.period)} — ${copyHtml(row.text)} ${sourceLink(row.sourceUrl)} `,
}
}
const identity = report.meta.identity ?? {}
const canonical = [
identity.label,
identity.value ? `${htmlEscape(identity.value)}` : null,
]
.filter(Boolean)
.join(' ')
return {
layout: 'tr',
html: `
${htmlEscape(row.period)}
${canonical}
${copyHtml(row.text)}${row.excerpt ? `${copyHtml(row.excerpt)} ` : ''}
${scopeBadge('area', null, report.unit)}
${sourceLink(row.sourceUrl)}
`,
}
})
const acervoStatLabel = (scene, key, fallback) =>
scene.stats?.find((stat) => stat.key === key)?.label ?? fallback
const renderAcervoSheet = ({ report, entry, probe, pageNo, isLast }) => {
const scene = report.unit.acervoScene
return `
${unitHeader(
report,
'Acervo interno de falas (amostra)',
entry.index === 0
? `Base Teqo 2011+ · ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte, com link · ${htmlEscape(report.unit.acervoNote ?? '')}`
: `continuação ${entry.index + 1}`,
pageNo,
)}
${
scene && entry.index === 0
? `
${htmlEscape(acervoStatLabel(scene, 'universe', 'Universo recuperado'))}
${report.acervo.recorte}
Falas classificadas no tema canônico do recorte (2011+).
${htmlEscape(acervoStatLabel(scene, 'sample', 'Amostra exibida'))}
${report.acervo.items.length} de ${report.acervo.recorte}
Quantidade e critério explícitos; a amostra não finge exaustividade.
`
: ''
}
Falas do mandato sobre o recorte
Amostra com link direto
${
scene
? `
Amostra do acervo por data, tema canônico, trecho e abrangência
${tableHead(scene.columns)}
${entry.chunk.map((unit) => unit.html).join('')}
`
: `${entry.chunk.map((unit) => unit.html).join('')} `
}
${
isLast
? `Amostra das falas mais recentes do recorte (${report.acervo.items.length} de ${report.acervo.recorte}); o acervo completo fica na base Teqo. ${htmlEscape(report.unit.acervoNote ?? '')}
`
: ''
}
${scene && isLast ? `${htmlEscape(scene.alert)}
` : ''}
${unitFooter(report, 'Acervo interno · amostra', pageNo)}
`
}
const renderAcervoSheets = ({ report, pack, probe, nextPage }) => {
const units = acervoUnits(report, probe)
if (units.length === 0) return []
const entries = packedSection({ key: 'acervo', anchor: 'acervo', units, pack, probe })
return entries.map((entry, index) =>
renderAcervoSheet({
report,
entry,
probe,
pageNo: nextPage(),
isLast: index === entries.length - 1,
}),
)
}
const renderUnitSourcesPage = (report, pageNo) => `
${unitHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
${
report.unit.sourcesHierarchy
? `
Hierarquia de fontes
Lastro visível, item a item
Camadas de fonte, seu uso e seu limite
${tableHead(['Camada', 'Uso', 'Limite'])}
${report.unit.sourcesHierarchy
.map(
(row) =>
`${htmlEscape(row.layer)} ${htmlEscape(row.use)} ${htmlEscape(row.limit)} `,
)
.join('')}
`
: ''
}
${
report.acervo?.recorte
? `
${WARNING_ICON}
Acervo interno: ${report.acervo.recorte} falas no recorte.
A amostra com link (${report.acervo.items.length} de ${report.acervo.recorte}) está na seção anterior; o acervo completo fica na base Teqo. ${htmlEscape(report.unit.acervoNote ?? '')}
`
: ''
}
Limites de cobertura
${report.limits.coverage
.map((item) => `${htmlEscape(item)} `)
.join('')}
Regras para uso editorial
${report.limits.editorial
.map((item) => `${htmlEscape(item)} `)
.join('')}
${WARNING_ICON}
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. Este dossiê organiza evidências; não substitui análise jurídica nem editorial.
${unitFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)}
`
const renderUnitSynthesisPage = (report, pageNo) => `
${unitHeader(report, 'Síntese do que foi localizado', 'Leitura dos números com lastro — contagens por recorte, nunca soma entre eles', pageNo)}
Leitura rápida
O que os pontos com fonte mostram
${report.synthesis.lines.map((line) => `${htmlEscape(line)} `).join('')}
Cobertura das eras
${report.eras
.map(
(era) =>
`${htmlEscape(era.label)} — ${
era.empty
? 'sem evidência suficiente (lacuna explícita)'
: report.unit.eraCoverageFromLedger
? `${report.synthesis.byEra.find((row) => row.key === era.id)?.count ?? 0} pontos com fonte`
: `${listOf(era.numbers).length + listOf(era.actions).length} pontos nesta seção`
} `,
)
.join('')}
Guardas de leitura
Contagens por recorte: ${htmlEscape(report.unit.copy?.synthesisGuard ?? 'setor e rede nunca somados à instituição.')}
Cada valor mantém a fase — empenho ≠ pagamento.
Sem fonte, não publica: ausência vira lacuna explícita.
${
report.synthesis.moneyByPhase.length
? `
Recursos por fase
Valores localizados, sem consolidar fases
Valores localizados por fase de execução
${tableHead(['Fase', 'Itens', 'Valor'])}
${report.synthesis.moneyByPhase
.map(
(row) =>
`${phaseBadge(row.key)} ${row.count} ${htmlEscape(formatMoneyCompact(row.amount))} `,
)
.join('')}
Soma por fase apenas; fases não são equivalentes entre si.
`
: ''
}
${unitFooter(report, 'Síntese · INSUMO INTERNO', pageNo)}
`
const renderUnitChartsPage = (report, pageNo) => {
const synthesis = report.synthesis
const executedPhases = new Set(['pago', 'liquidado', 'autorizado', 'empenhado'])
const executedYears = synthesis.moneyByYear.filter((row) =>
row.segments.some((segment) => executedPhases.has(segment.key)),
)
return `
${unitHeader(report, 'Gráficos consolidados', `Como os recursos e as entregas chegaram ${report.unit.notSummedTo ?? 'à instituição'} — valores sempre com a fase`, pageNo)}
Recursos com execução por ano (R$)
${
executedYears.length
? stackedColumnChart({
rows: executedYears.map((row) => ({ ...row, value: row.amount })),
height: 136,
format: (row) => formatMoneyCompact(row.value),
fullYearLabel: Boolean(report.unit.fullYearAxis),
})
: '
Nenhum valor com fase de execução informada.
'
}
verde: pago/liquidado · âmbar: autorizado/empenhado — empenho não é pagamento.
Propostas e articulações sem fase informada (R$)
${
synthesis.moneyProposals.length
? valueList({
rows: synthesis.moneyProposals.map((row) => ({
label: `${row.year ?? '—'} · ${row.label}`,
value: row.amount,
})),
format: (row) => formatMoneyCompact(row.value),
})
: '
Nenhuma proposta sem fase informada.
'
}
propostas e convênios citados nas fontes, sem fase de execução declarada.
Abrangência: ${report.unit.spheres
.map((sphere) => htmlEscape(report.unit.sphereLabels[sphere]))
.join(' × ')}
${barChart({
rows: synthesis.bySphere.map((row) => ({
label: row.label,
count: row.count,
color: CHART_COLORS[row.key],
})),
})}
${htmlEscape(report.unit.sumGuardNote ?? '')}
${htmlEscape(report.unit.byYearTitle ?? 'Trajetória: pontos com fonte por ano (20xx)')}
${columnChart({
rows: synthesis.byYear,
height: 156,
fullYearLabel: Boolean(report.unit.fullYearAxis),
})}
${htmlEscape(report.unit.byAreaTitle ?? 'Áreas com mais registros')}
${barChart({
rows: synthesis.byArea.slice(0, 6).map((row) => ({ label: row.key, count: row.count })),
})}
Lacunas declaradas por era
${barChart({
rows: synthesis.gapsByEra.map((row) => ({ label: row.label, count: row.count })),
})}
ausência é resultado: cada lacuna é uma tarefa de apuração.
Acervo interno (read-only)
${synthesis.totals.acervoRecorte}
falas no recorte temático (2011+)
Amostra de ${synthesis.totals.acervo} falas com link direto (seção do acervo).
Temas: ${report.acervo?.topics?.join(', ') || '—'} — ${htmlEscape(report.unit.acervoNote ?? '')}
${unitFooter(report, 'Gráficos · INSUMO INTERNO', pageNo)}
`
}
const renderUnitOpeningPage = (report, pageNo) => {
const opening = report.opening
if (!opening?.paragraphs?.length) return ''
return `
${unitHeader(report, opening.title, 'Redação de síntese sobre os pontos com fonte — nada aqui acrescenta fato novo', pageNo)}
${opening.paragraphs.map((paragraph) => `${copyHtml(paragraph)}
`).join('')}
${WARNING_ICON}
Como ler
Cada afirmação desta redação tem lastro em um item datado do dossiê (seções por era e de abrangência). ${
opening.authored
? 'A redação foi escrita a partir dos itens com fonte.'
: 'Sem redação autoral disponível: o texto repete a leitura dos números.'
} Onde não há fonte, o dossiê registra lacuna — a redação não preenche por inferência.
${unitFooter(report, 'A contribuição · INSUMO INTERNO', pageNo)}
`
}
const renderSubjectDossierHtml = (report, { pack = null, probe = false } = {}) => {
const buildParts = () => {
let page = 0
const nextPage = () => ++page
const parts = []
parts.push(renderSubjectCover(report))
page += 1
const opening = renderUnitOpeningPage(report, page + 1)
if (opening) {
parts.push(opening)
page += 1
}
parts.push(renderSubjectSummary(report, ++page))
parts.push(renderUnitSynthesisPage(report, nextPage()))
parts.push(renderUnitChartsPage(report, nextPage()))
for (const era of report.eras) {
parts.push(
...renderEraSheets({
report,
era,
pack,
probe,
nextPage,
includeHonors: !report.unit.honorsSheet,
}),
)
}
parts.push(renderSubjectScope(report, nextPage()))
for (const list of report.reach.lists) {
parts.push(...renderSubjectScopeListSheets({ report, list, pack, probe, nextPage }))
}
if (report.unit.honorsSheet) {
parts.push(...renderSubjectHonorSheets({ report, pack, probe, nextPage }))
}
parts.push(...renderGapSheets({ report, pack, probe, nextPage }))
parts.push(...renderNewsSheets({ report, pack, probe, nextPage }))
parts.push(...renderAcervoSheets({ report, pack, probe, nextPage }))
parts.push(renderUnitSourcesPage(report, nextPage()))
return parts
}
// Two passes: the shell's `pág. N/M` needs the final sheet count, and the
// count only exists after the pack plan is applied.
report.meta.pageTotal = 0
let parts = buildParts()
report.meta.pageTotal = parts.length
parts = buildParts()
return `
${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.subjectName)} ${probe ? `` : ''}
${parts.join('\n')}
`
}
const subjectMdEra = (era) => {
const lines = [
`## ${era.label} (${era.period})`,
'',
`_${era.method}_`,
'',
`Trilha: ${era.recovery}`,
]
if (era.narrative || era.summary) {
lines.push('', `> ${era.narrative ?? era.summary}`)
}
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 ?? '—'} |`,
)
}
if (era.numbers.omitted > 0)
lines.push('', `_e mais ${era.numbers.omitted} item(ns) de valor com fonte._`)
}
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})`,
)
}
if (era.actions.omitted > 0)
lines.push('', `_e mais ${era.actions.omitted} item(ns) de atuação com fonte._`)
for (const honor of era.honors.items)
lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`)
if (era.honors.omitted > 0)
lines.push('', `_e mais ${era.honors.omitted} honraria(s) com fonte._`)
return lines.join('\n')
}
const renderSubjectDossierMd = (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`,
'',
...report.unit
.identityRows(report.meta.identity ?? {})
.map(([key, value]) => `- **${key}:** ${value}`),
'',
]
if (report.opening?.paragraphs?.length) {
lines.push(`## ${report.opening.title}`, '')
for (const paragraph of report.opening.paragraphs) lines.push(paragraph, '')
lines.push(
`_Redação de síntese sobre os pontos com fonte; ${
report.opening.authored
? 'escrita a partir dos itens datados do dossiê'
: 'repetição da leitura dos números (sem redação autoral disponível)'
} — nada aqui acrescenta fato novo._`,
'',
)
}
lines.push('## 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('', subjectMdEra(era))
if (report.synthesis?.lines?.length) {
lines.push('', '## Síntese do que foi localizado', '')
for (const line of report.synthesis.lines) lines.push(`- ${line}`)
lines.push('', '### Números por recorte', '')
lines.push(
`| Era | ${report.unit.spheres.map((sphere) => capitalize(report.unit.sphereLabels[sphere])).join(' | ')} | Total |`,
`| --- | ${report.unit.spheres.map(() => '---').join(' | ')} | --- |`,
)
for (const row of report.synthesis.byEraSphere) {
const countFor = (key) => row.segments.find((segment) => segment.key === key)?.count ?? 0
lines.push(
`| ${row.label} | ${report.unit.spheres.map((sphere) => countFor(sphere)).join(' | ')} | ${row.segments.reduce((sum, segment) => sum + segment.count, 0)} |`,
)
}
if (report.synthesis.byPhase.length) {
lines.push('', '### Itens com valor por fase', '')
for (const row of report.synthesis.byPhase) lines.push(`- ${row.label}: ${row.count}`)
}
if (report.synthesis.byArea.length) {
lines.push('', '### Temas com mais registros', '')
for (const row of report.synthesis.byArea) lines.push(`- ${row.key}: ${row.count}`)
}
if (report.synthesis.acervoByYear.length) {
lines.push('', '### Acervo interno por ano', '')
lines.push(report.synthesis.acervoByYear.map((row) => `${row.key}: ${row.count}`).join(' · '))
}
}
lines.push(
'',
`## Abrangência: ${report.unit.spheres.map((sphere) => report.unit.sphereLabels[sphere]).join(' × ')}`,
'',
)
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(briefOr(item.brief, item.title))}** (${item.sphere}${item.year ? ` · ${item.year}` : ''})${noteOr(item.brief, item.evidence) ? ` — ${stripInlineSources(noteOr(item.brief, item.evidence))}` : ''} [fonte](${item.sourceUrl})`,
)
}
if (list.omitted > 0) lines.push(`- _e mais ${list.omitted} item(ns) com fonte._`)
lines.push('')
}
if (report.acervo?.items?.length) {
lines.push(
'',
`## Acervo interno (read-only)`,
'',
`_Amostra de ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte temático, com link; o acervo completo fica na base Teqo. ${report.unit.copy.mdAcervoNote}_`,
'',
)
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 na base._`)
}
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: subject-shaped reports (institution/theme) render with the shared subject sections. */
export const renderDossierHtml = (report, options) =>
isSubjectUnit(report.unit)
? renderSubjectDossierHtml(report, options)
: renderMunicipalityDossierHtml(report, options)
export const renderDossierMd = (report) =>
isSubjectUnit(report.unit) ? renderSubjectDossierMd(report) : renderMunicipalityDossierMd(report)