/** * 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, dossierSphereLabel } from './dossieBlocks.mjs' import { htmlEscape, stripInlineSources } from './reportText.mjs' const SOURCE_ICON = '' const WARNING_ICON = '' const sourceLink = (url, label = '(fonte)') => url ? `${SOURCE_ICON}${htmlEscape(label)}` : '' const scopeBadge = (sphere, label = null) => `${htmlEscape(label ?? dossierSphereLabel(sphere))}` /** `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 phaseBadge = (phase) => `${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('
')}

` /** 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) => `
${rows .map( (row) => `

${htmlEscape(row.period)}

${htmlEscape(row.role)}

${htmlEscape(row.recovery)}${row.uncertain ? ` · ${htmlEscape(row.uncertain)}` : ''}

`, ) .join('')}
` const sheetHeader = (report, title, subtitle, pageNo) => `

Dossiê de atuação pública · ${htmlEscape(report.meta.municipalityName)}

${htmlEscape(title)}

${subtitle ? `

${htmlEscape(subtitle)}

` : ''}

Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}

INSUMO INTERNO

pág. ${pageNo}/${pageCount(report)}

` 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'])}

${htmlEscape(report.cover.scope)}

${htmlEscape(report.cover.version)}

` 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) => { if (report.page1.deliveries.length === 0) return '' return `

02 · destaques com lastro

Principais entregas localizadas

${report.page1.deliveries .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('')}

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.length === 0 && pending.length === 0) return '' return `
${ hooks.length ? `

03 · ganchos para o boletim

` : '
' } ${ pending.length ? `

04 · o que falta

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)}
` const renderActionGrid = (actions) => `
${actions .map( (action) => `
${scopeBadge(action.sphere)}${htmlEscape(action.year ?? '—')}

${copyHtml(briefOr(action.brief, action.title))}

${noteOr(action.brief, action.detail) ? `

${copyHtml(noteOr(action.brief, action.detail))}

` : ''} ${sourceLink(action.sourceUrl)}
`, ) .join('')}
` 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 `
${sheetHeader(report, era.label, subtitle, pageNo)} ${ first ? `

Recorte e método

${htmlEscape(era.method)}

${ era.numbers.length ? `

Números com fase

Recursos e entregas quantificáveis

${era.numbers .map( (row) => ``, ) .join('')}
Valores por ano, fase, esfera e fonte
ObjetoValorAnoFaseEsferaFonte
${htmlEscape(row.object)} ${valueCell(row.value, 'Valor a recuperar')} ${htmlEscape(row.year ?? '—')} ${phaseBadge(row.phase)} ${scopeBadge(row.sphere)} ${sourceLink(row.sourceUrl)}${row.sphere !== 'municipio' ? '
não somar à cidade' : ''}
` : '' }` : '' } ${ actions.length ? `

Atuação registrada

O que fez — item, alcance e lastro

${renderActionGrid(actions)}
` : '' } ${ first && era.honors.length ? `

Títulos, honrarias e vínculos locais

    ${era.honors .map( (honor) => `
  • ${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}
  • `, ) .join('')}
${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])}
` : '' } ${sheetFooter(report, era.label, pageNo)}
` } const renderScopeLists = (report) => `

Leitura lado a lado

Duas listas, dois totais, nenhuma soma

${scopeBadge('municipio')}${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte
    ${report.region.municipal.items .map( (item) => `
  • ${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}
  • `, ) .join( '', )}${report.region.municipal.items.length === 0 ? '
  • Nenhum item municipal com fonte.
  • ' : ''}
${scopeBadge('regiao', 'região / polo')}SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}
    ${report.region.regional.items .map( (item) => `
  • ${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}
  • `, ) .join( '', )}${report.region.regional.items.length === 0 ? '
  • Nenhum item regional com fonte.
  • ' : ''}
` 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.

` : '' } ${renderScopeLists(report)} ${ report.region.items.length ? `

Itens regionais recuperados

Evidência de alcance, sem apropriação local

${scopeBadge('regiao')}
${report.region.items .map( (item) => ``, ) .join('')}
Itens do recorte regional com evidência e fonte
ItemEsferaEvidência de alcanceFonte
${copyHtml(briefOr(item.brief, item.item))} ${scopeBadge(item.sphere)} ${copyHtml(noteOr(item.brief, item.evidence))} ${sourceLink(item.sourceUrl)}
` : '' } ${ 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)}
` const renderGapsPage = (report, chunk, index, pageNo) => { const rows = chunk.length ? chunk .map( (gap) => ` ${htmlEscape(gap.label)} ${htmlEscape(gap.reason)} Não completar por inferência. ${htmlEscape(gap.nextStep)} `, ) .join('') : 'Nenhuma lacuna declarada — confira a cobertura das eras.' return `
${sheetHeader(report, 'Lacunas explícitas', `O que não foi encontrado${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)}

O que não foi encontrado

Lacunas explícitas

Não completar por memória ou inferência

${rows}
Tabela de lacunas explícitas e próximos passos de apuração
LacunaSituação da buscaLimite editorialPróximo passo
${sheetFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)}
` } const renderNewsPage = (report, chunk, index, pageNo) => `
${sheetHeader(report, 'Notícias e documentos consultados', `Fontes externas datadas${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)}

Lista clicável

Notícias e documentos consultados

${chunk .map( (row) => ``, ) .join('')}
Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%
DataVeículoTítuloLink
${htmlEscape(formatShortDateBr(row.date))} ${htmlEscape(row.outlet)} ${htmlEscape(row.title)} ${sourceLink(row.url, '(fonte)')}
${sheetFooter(report, 'Notícias e documentos consultados · INSUMO INTERNO', pageNo)}
` const renderSourcesPage = (report, pageNo) => `
${sheetHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}

Limites de cobertura

    ${report.limits.coverage .map((item) => `
  • ${htmlEscape(item)}
  • `) .join('')}

Regras para uso editorial

    ${report.limits.editorial .map((item) => `
  • ${htmlEscape(item)}
  • `) .join('')}
${sheetFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)}
` 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; } .defeso { margin-top: auto; } ` export const renderDossierHtml = (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 ` ${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.numbers.length) { lines.push( '', '| Objeto | Valor | Ano | Fase | Esfera | Fonte |', '| --- | --- | --- | --- | --- | --- |', ) for (const row of era.numbers) { lines.push( `| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`, ) } } 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})`, ) } for (const honor of era.honors) lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`) return lines.join('\n') } export const renderDossierMd = (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.length) { lines.push('', '## Principais entregas localizadas', '') for (const row of report.page1.deliveries) { lines.push( `- **${stripInlineSources(row.title)}** · _${row.sphere}_${row.value ? ` · ${row.value}` : ''}${row.phase ? ` (${dossierPhaseLabel(row.phase)})` : ''}${row.detail ? ` — ${stripInlineSources(row.detail)}` : ''} [fonte](${row.sourceUrl})`, ) } 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})`) } 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.items.length) { lines.push('', '| Item | Esfera | Evidência | Fonte |', '| --- | --- | --- | --- |') for (const item of report.region.items) { lines.push( `| ${stripInlineSources(item.item)} | ${item.sphere} | ${stripInlineSources(item.evidence)} | ${item.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` }