/** * Renderers for the city report (C163): the block tree goes to print HTML * (Chromium `page.pdf`, A4) and to the companion `.md` with the same reading. * Pure and unit-tested — the two outputs come from one content model, so the * companion cannot drift from the PDF. */ import { formatDateBr, formatDateTimeBr } from './cityReportFormat.mjs' const htmlEscape = (value) => String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') const URL_PATTERN = /(https?:\/\/[^\s<>"')]+)/g /** Escapes the text and turns every bare URL into a clickable anchor (PDF links). */ const htmlWithLinks = (value) => htmlEscape(value).replace(URL_PATTERN, (url) => `${url}`) const sourceKindLabels = { teqo: 'base Teqo', web: 'pesquisa web', official: 'fonte oficial', } const sourceLabel = (source) => { const parts = [sourceKindLabels[source.kind] ?? 'fonte'] if (source.label) parts.push(source.label) if (source.date) parts.push(formatDateBr(source.date)) if (source.url) parts.push(source.url) return parts.join(' · ') } const PRINT_CSS = ` * { box-sizing: border-box; } html, body { margin: 0; padding: 0; } body { font-family: 'Fira Sans', 'DejaVu Sans', sans-serif; color: #18181b; font-size: 9.4pt; line-height: 1.35; } .summary { min-height: 250mm; display: flex; flex-direction: column; gap: 3mm; } .summary > .summary-body { display: flex; flex-direction: column; gap: 2.2mm; } .summary > * { flex-shrink: 0; } .summary .block + .block { margin-top: 2mm; } .summary .stats .stat { padding: .7mm 0; } .summary .callout { padding: 1.8mm 2.4mm; } .summary .pair .panel { padding: 1.8mm 2.2mm; } header.report-head { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #18181b; padding-bottom: 2.5mm; } header.report-head .kicker { font-size: 7.6pt; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: #52525b; margin: 0; } header.report-head h1 { font-size: 16pt; margin: .8mm 0 0; } header.report-head h2 { font-size: 13pt; margin: .8mm 0 0; } header.report-head .sub { color: #52525b; font-size: 8.4pt; margin: .6mm 0 0; } header.report-head .meta { text-align: right; color: #52525b; font-size: 7.8pt; margin: 0; } header.report-head .meta p { margin: 0 0 .4mm; } .block-title { font-size: 7.6pt; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: #52525b; margin: 0 0 1.2mm; } .strip { display: flex; flex-wrap: wrap; gap: 1.5mm 6mm; border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 1.8mm 2.4mm; background: #fafafa; } .strip .strip-item { display: flex; gap: 1.4mm; align-items: baseline; } .strip .strip-label { color: #71717a; font-size: 7pt; text-transform: uppercase; letter-spacing: .05em; } .strip .strip-value { font-weight: 600; font-size: 8.4pt; } .grid { display: grid; gap: 3mm; } .grid-2 { grid-template-columns: 1fr 1fr; } .grid-3 { grid-template-columns: repeat(3, 1fr); } .grid .cell { border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 2.4mm; } .grid .cell > h4.cell-title { margin: 0 0 1.6mm; font-size: 8.6pt; } .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2.5mm; } .cards .card { border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 2.2mm; } .cards .card h5 { margin: 0 0 .8mm; font-size: 8pt; } .cards .card p { margin: 0; color: #3f3f46; font-size: 7.8pt; } .kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2.5mm; } .grid .kpis { gap: 1.6mm; } .grid .kpi { padding: 1.8mm; } .grid .kpi-value { font-size: 11pt; } .grid .stats { grid-template-columns: 1fr; } .kpi { border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 2.4mm; background: #fafafa; } .kpi-value { display: block; font-size: 13pt; font-weight: 700; } .kpi-label { display: block; font-size: 7.4pt; color: #52525b; } .kpi-hint { display: block; font-size: 7.2pt; color: #71717a; margin-top: .8mm; } .stats { display: grid; grid-template-columns: 1fr 1fr; gap: 1mm 6mm; } .stats .stat { display: flex; justify-content: space-between; gap: 3mm; border-bottom: .6px solid #e4e4e7; padding: 1mm 0; } .stats .stat-label { color: #52525b; } .stats .stat-value { font-weight: 600; text-align: right; } .stats .stat-hint { display: block; font-weight: 400; color: #71717a; font-size: 7.4pt; } .source { color: #71717a; font-size: 7.2pt; margin: 1.4mm 0 0; } .source a { color: #1d4ed8; word-break: break-all; } a { color: #1d4ed8; text-decoration: underline; text-decoration-color: #bfdbfe; text-underline-offset: 1.5px; } .callout { border-left: 2.2mm solid #b45309; background: #fffbeb; padding: 2.4mm 3mm; border-radius: 0 1.6mm 1.6mm 0; } .callout.gap { border-left-color: #71717a; background: #f4f4f5; } .callout.decision { border-left-color: #15803d; background: #f0fdf4; } .callout.risk { border-left-color: #b45309; background: #fffbeb; } .callout h4 { margin: 0 0 1mm; font-size: 8.6pt; } .callout p { margin: 0 0 .8mm; } .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; } .pair .panel { border: .8px solid #d4d4d8; border-radius: 1.6mm; padding: 2.4mm; } .pair .panel.decision { border-color: #15803d; } .pair .panel.risk { border-color: #b45309; background: #fffbeb; } .pair .panel h4 { margin: 0 0 1.2mm; font-size: 8.4pt; } .pair .panel ul, .bullets { margin: 0; padding-left: 4mm; } .pair .panel li, .bullets li { margin-bottom: .8mm; } .pair-note { color: #92400e; font-size: 7.6pt; margin: 1.2mm 0 0; } table { width: 100%; border-collapse: collapse; font-size: 8pt; } table.table-fixed { table-layout: fixed; } table.table-fixed td, table.table-fixed th { overflow-wrap: anywhere; } caption { caption-side: top; text-align: left; font-weight: 600; font-size: 9.4pt; padding-bottom: 1.8mm; color: #18181b; } th { text-align: left; background: #f4f4f5; border-bottom: .8px solid #d4d4d8; padding: 1.6mm 1.4mm; font-size: 7pt; text-transform: uppercase; letter-spacing: .03em; color: #52525b; } td { border-bottom: .6px solid #e4e4e7; padding: 1.6mm 1.4mm; vertical-align: top; } tbody tr:nth-child(even) { background: #fafafa; } td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .table-note { color: #71717a; font-size: 7.2pt; margin: 1mm 0 0; } .prose p { margin: 0 0 1.4mm; } .page-break { break-before: page; } .index ol { columns: 2; margin: 0; padding-left: 0; list-style: none; color: #3f3f46; font-size: 8.6pt; } .report-section { margin-bottom: 7mm; } .report-section > h3 { font-size: 12pt; margin: 0 0 2.6mm; padding-top: 2.4mm; border-top: .8px solid #d4d4d8; break-after: avoid; } .report-section:first-of-type > h3 { border-top: none; padding-top: 0; } .block + .block { margin-top: 3mm; } .block { break-inside: avoid; } .block.table-block { break-inside: auto; } .footer-note { margin-top: auto; border-top: .8px solid #d4d4d8; padding-top: 1.6mm; color: #71717a; font-size: 7.2pt; } .row-source { display: block; font-weight: 400; color: #71717a; font-size: 7pt; } .row-source a { color: #1d4ed8; word-break: break-all; } ` const renderStrip = (block) => `
${htmlEscape(item.body)}
${htmlEscape(block.note)}
` : '' }${htmlEscape(block.note)}
` : '' }` } const renderCallout = (block) => `${htmlEscape(paragraph)}
`) .join('')}${htmlEscape(block.text)}
` break case 'sources': body = renderSources(block) break case 'footerNote': return `` default: throw new Error(`Bloco desconhecido no render HTML: ${block.kind}`) } const sources = block.sources?.length ? `Fontes: ${block.sources.map((source) => htmlWithLinks(sourceLabel(source))).join(' · ')}
` : '' const className = block.kind === 'table' ? 'block table-block' : 'block' return `${htmlEscape(report.meta.title)}
${htmlEscape(report.meta.region)} · prioridade e classe na leitura da base
${htmlEscape(report.meta.title)} — aprofundamento