diff --git a/docs/changelog/2026-09-28-briefing-geral-militancia-livretos.md b/docs/changelog/2026-09-28-briefing-geral-militancia-livretos.md new file mode 100644 index 00000000..a80f13c1 --- /dev/null +++ b/docs/changelog/2026-09-28-briefing-geral-militancia-livretos.md @@ -0,0 +1 @@ +**BRIEFING-GERAL — livretos de 1 folha e pesca mínima do Briefing Geral de Militância (2026-09-28):** o corpus interno do briefing ganha o impressor `scripts/build-briefing-militancia-livretos.mjs`: lê os JSONs autorais já existentes em `data/briefing-geral-militancia/` (`.briefing.v2.json` quando houver, senão `.briefing.json`; idem intros e o ledger `research*.json`) e as propostas do próximo mandato em `propostas.json` — a linha "Propostas" é a proposta de Solla para a área, nunca o plano de conversa do militante —, e imprime cada recorte como livreto A5 de 4 páginas imposto em 1 folha A4 paisagem (frentes/versos, dobra no meio), mais a "pesca mínima" que compila os recortes em 8 páginas (2 folhas), a edição completa num documento só e o guia de impressão em 1 página A4. A paginação é medida de verdade no Chromium (Playwright): um probe mede a altura de cada unidade e o packer distribui por página, podando na ordem declarada (qa → defesas → intro no recorte; fez/defesas no compilado) quando não cabe — nunca cortando uma página no meio; o build falha fechado quando uma âncora ou o lastro de uma proposta não resolve (nada é inventado). Saídas em `docs/research/briefing-geral-militancia/livretos-1folha/` (`-.pdf` + frentes/versos, `00-pesca-minima*`, `00-edicao-completa*`, `00-guia-de-impressao.pdf`, `livretos.json` e `LEIA-ME.txt`) — diretório gitignored pelo mesmo motivo do corpus: material interno num repo público; o script é o artefato versionado deste fluxo. diff --git a/scripts/build-briefing-militancia-livretos.mjs b/scripts/build-briefing-militancia-livretos.mjs new file mode 100644 index 00000000..51c0ae58 --- /dev/null +++ b/scripts/build-briefing-militancia-livretos.mjs @@ -0,0 +1,1123 @@ +#!/usr/bin/env node +/** + * Briefing Geral de Militância — livretos de 1 folha (4 páginas) e pesca mínima + * (2 folhas, 8 páginas). + * + * Lê os JSONs autorais já existentes em `data/briefing-geral-militancia/` + * (`.briefing.v2.json` quando houver, senão `.briefing.json`; idem + * para `.intro*.md`), o ledger `.research*.json` e as propostas do + * próximo mandato em `propostas.json` (uma por recorte, com lastro no ledger — + * a linha "Propostas" do compilado é a proposta de Solla para a área, nunca o + * plano de conversa do militante). Cada recorte vira um livreto A5 de 4 páginas + * imposto em 1 folha A4 paisagem (frente/verso, dobra no meio); a pesca mínima + * compila os recortes em 8 páginas (2 folhas). Nada é inventado: todo item + * impresso aponta para um fato do ledger, e o build falha fechado quando uma + * âncora ou um lastro de proposta não resolve. + * + * A paginação é medida de verdade no Chromium (Playwright): um probe mede a + * altura de cada unidade e o packer distribui as unidades nas páginas; se o + * conteúdo não cabe, itens são podados na ordem declarada (qa → defesas → + * intro no recorte; fez/defesas no compilado) até caber — nunca cortando uma + * página no meio. + * + * Saídas em `docs/research/briefing-geral-militancia/livretos-1folha/`: + * -.pdf — todas as faces do livreto (A4 paisagem) + * --frentes.pdf — só as frentes das folhas + * --versos.pdf — só os versos das folhas + * 00-pesca-minima*.pdf — o livreto compilado, mesmo contrato + * 00-edicao-completa*.pdf — todos os livretos num documento só (frentes/versos) + * 00-guia-de-impressao.pdf — guia de impressão/montagem em uma página A4 + * livretos.json — manifesto (páginas e ordem de dobra) + * LEIA-ME.txt — instruções de impressão + * + * Uso: + * NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-briefing-militancia-livretos.mjs + * ... [--only=saude] [--only-pesca] [--skip-pesca] + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { chromium } from '@playwright/test' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const DATA_DIR = 'data/briefing-geral-militancia' +const OUT_DIR = 'docs/research/briefing-geral-militancia/livretos-1folha' +const LABEL = 'build-briefing-militancia-livretos' + +const A5_W = 148.5 +const A5_H = 210 +const A4_W = 297 +const A4_H = 210 +const MAX_FIT_ATTEMPTS = 40 +/** Corpos testados (pt, do maior para o menor) até o conteúdo caber na dobra. */ +const RECORTE_BASE_SIZES = [8.8, 8.5, 8.2, 7.9, 7.6] +const PESCA_BASE_SIZES = [7.9, 7.65, 7.4, 7.15] + +/** Recortes na ordem do documento; `file` difere do slug público quando o arquivo tem outro nome. */ +const RECORTES = [ + { num: '01', slug: 'saude' }, + { num: '02', slug: 'cultura' }, + { num: '03', slug: 'educacao' }, + { num: '04', slug: 'salvador' }, + { num: '05', slug: 'ufba' }, + { num: '06', slug: 'trabalhadores-da-saude' }, + { num: '07', slug: 'agricultura-familiar' }, + { num: '08', slug: 'tecnologia', file: 'ciencia-tecnologia' }, + { num: '09', slug: 'soberania-nacional' }, + { num: '10', slug: 'democracia' }, + { num: '11', slug: 'lgbtqia' }, + { num: '12', slug: 'anti-racista' }, + { num: '13', slug: 'feminista' }, + { num: '14', slug: 'interior' }, + { num: '15', slug: 'transversais' }, + { num: '16', slug: 'seguranca' }, +] + +/** Cores e tipografia do documento original, comprimidas para a folha única. + * Os corpos são em `em` sobre o `font-size` da folha: cada livreto escolhe o + * maior corpo que ainda cabe (ver BASE_SIZES). */ +const BASE_CSS = ` + :root { + --ink: #1b2930; + --soft: #586770; + --accent: #3e6274; + --line: #c3ced4; + --lline: #e3e9ec; + --note: #f3f6f7; + } + * { box-sizing: border-box; } + html, body { margin: 0; padding: 0; } + body { + color: var(--ink); + background: #fff; + font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + } + .run-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 6mm; + padding-bottom: 1mm; + margin-bottom: 1.6mm; + border-bottom: .35mm solid var(--ink); + color: var(--soft); + font-size: .776em; + letter-spacing: .07em; + text-transform: uppercase; + } + .run-head strong { color: var(--ink); } + .sheet-foot { + display: flex; + justify-content: space-between; + gap: 6mm; + padding-top: 1mm; + margin-top: 1.6mm; + border-top: .2mm solid var(--line); + color: var(--soft); + font-size: .763em; + } + .sheet { + display: flex; + width: ${A5_W}mm; + height: ${A5_H}mm; + padding: 7mm 8.5mm 5.5mm; + overflow: hidden; + flex-direction: column; + background: #fff; + font-size: 7.6pt; + line-height: 1.34; + } + .flow { flex: 1 1 auto; min-height: 0; } + .unit { } + .unit-title { padding-bottom: 2mm; } + .sec-kicker { + margin: 0; + color: var(--accent); + font-size: .816em; + font-weight: 700; + letter-spacing: .12em; + text-transform: uppercase; + } + .sec-title { + margin: .8mm 0 0; + font-size: 2.105em; + font-weight: 650; + line-height: 1.03; + letter-spacing: -.025em; + } + .sec-sub { margin: 1mm 0 0; color: var(--soft); font-size: 1em; line-height: 1.3; } + .unit-lede { padding-bottom: 2mm; } + .lede { + margin: 0; + padding: 1.8mm 2.4mm; + border-left: .9mm solid var(--accent); + background: var(--note); + font-size: .987em; + line-height: 1.38; + } + .unit-para { padding-bottom: 1.4mm; } + .para { margin: 0; font-size: 1em; line-height: 1.4; } + .unit-sechead { padding-top: 1.2mm; padding-bottom: 1.2mm; border-top: .3mm solid var(--ink); } + .unit-sechead h2 { margin: 0; font-size: 1.263em; font-weight: 700; letter-spacing: -.01em; } + .sub-note { margin: .5mm 0 0; color: var(--soft); font-size: .829em; line-height: 1.3; } + .unit-item { padding-bottom: 1mm; } + .item { position: relative; margin: 0; padding-left: 3.4mm; font-size: .987em; line-height: 1.34; } + .item::before { + position: absolute; + top: 1.4mm; + left: 0; + width: 1.6mm; + height: .4mm; + background: var(--accent); + content: ''; + } + .item b { font-weight: 700; } + .src { color: var(--accent); font-size: .789em; white-space: nowrap; } + .unit-qa { padding-bottom: 1.4mm; } + .qa-item { display: grid; margin: 0; grid-template-columns: 30mm 1fr; gap: 2.6mm; } + .qa-q { margin: 0; font-size: .974em; font-weight: 700; line-height: 1.3; } + .qa-side { + display: block; + margin-bottom: .5mm; + color: var(--accent); + font-size: .737em; + letter-spacing: .08em; + text-transform: uppercase; + } + .qa-a { margin: 0; font-size: .974em; line-height: 1.36; } + .qa-a p { margin: 0 0 .8mm; } + .qa-a p:last-child { margin-bottom: 0; } + .qa-a b { font-weight: 700; } + .unit-plan { padding-top: 1.2mm; } + .plan { + margin: 0; + padding: 1.8mm 2.4mm; + border: .25mm solid var(--line); + border-left: 1mm solid var(--accent); + background: var(--note); + font-size: .974em; + line-height: 1.34; + } + .plan-tag { + display: block; + margin-bottom: .6mm; + color: var(--accent); + font-size: .763em; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; + } + /* ---- pesca mínima (base 7.15pt) ---- */ + .unit-blk { padding-bottom: 2.2mm; } + .blk { margin: 0; } + .blk h3 { + margin: 0 0 1mm; + padding-bottom: .8mm; + border-bottom: .3mm solid var(--ink); + font-size: 1.483em; + font-weight: 700; + line-height: 1.06; + letter-spacing: -.015em; + } + .blk p { margin: 0 0 .9mm; font-size: 1em; line-height: 1.33; } + .blk p:last-child { margin-bottom: 0; } + .tag { + display: inline-block; + min-width: 14mm; + color: var(--accent); + font-size: .811em; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + vertical-align: 1.5%; + } + .blk b { font-weight: 700; } + .blk .sep { color: var(--soft); } + .blk-lede { color: var(--ink); } + .blk-proposal b { white-space: normal; } +` + +const PROBE_CSS = ` + ${BASE_CSS} + .probe-flow { width: ${A5_W - 17}mm; background: #fff; } + .probe-sheet { + display: flex; + width: ${A5_W}mm; + height: ${A5_H}mm; + padding: 7mm 8.5mm 5.5mm; + flex-direction: column; + background: #fff; + line-height: 1.34; + } + .probe-flow-box { flex: 1 1 auto; min-height: 0; } +` + +const PRINT_CSS = ` + ${BASE_CSS} + @page { size: A4 landscape; margin: 0; } + .a4 { + display: flex; + width: ${A4_W}mm; + height: ${A4_H}mm; + overflow: hidden; + background: #fff; + break-after: page; + } + .a4:last-child { break-after: auto; } + .slot { width: ${A5_W}mm; height: ${A5_H}mm; overflow: hidden; flex: 0 0 ${A5_W}mm; } + .slot .sheet { break-after: auto; } +` + +const die = (message) => { + console.error(`[${LABEL}] ERRO: ${message}`) + process.exit(1) +} + +const readJson = async (path) => { + try { + return JSON.parse(await readFile(resolve(ROOT, path), 'utf8')) + } catch (error) { + if (error?.code === 'ENOENT') return null + die(`JSON inválido em ${path}: ${error instanceof Error ? error.message : error}`) + } +} + +const readText = async (path) => { + try { + return await readFile(resolve(ROOT, path), 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') return null + die(`Falha ao ler ${path}: ${error instanceof Error ? error.message : error}`) + } +} + +const escapeHtml = (value) => + String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + +const formatDate = (iso) => { + if (!iso) return null + const [year, month, day] = iso.slice(0, 10).split('-') + if (!year || !month || !day) return null + return `${day}/${month}/${year}` +} + +/** Corte na primeira fronteira de frase depois da metade do limite; senão reticências. */ +const clipText = (text, max) => { + const clean = text.trim() + if (clean.length <= max) return clean + const cut = clean.slice(0, max) + const boundary = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('; '), cut.lastIndexOf(' — ')) + if (boundary > max * 0.45) return cut.slice(0, boundary + 1).trim() + return `${cut.trimEnd().replace(/[,;:.]$/, '')}…` +} + +const sideLabels = { + direita: 'Objeção à direita', + esquerda: 'Objeção à esquerda', + entrega: 'Dúvida sobre entrega', +} + +/** Carrega o conteúdo efetivo (v2 quando existir) + ledger + intro de um recorte. */ +const loadRecorte = async (entry) => { + const file = entry.file ?? entry.slug + const briefingV2 = await readJson(join(DATA_DIR, `${file}.briefing.v2.json`)) + const briefingV1 = await readJson(join(DATA_DIR, `${file}.briefing.json`)) + const briefing = briefingV2 ?? briefingV1 + if (!briefing) die(`briefing ausente para ${entry.slug} (${file}.briefing[.v2].json)`) + const introV2 = await readText(join(DATA_DIR, `${file}.intro.v2.md`)) + const introV1 = await readText(join(DATA_DIR, `${file}.intro.md`)) + const intro = (introV2 ?? introV1 ?? '') + .split(/\n\s*\n/) + .map((paragraph) => paragraph.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + const research = await readJson(join(DATA_DIR, `${file}.research.json`)) + const research2 = await readJson(join(DATA_DIR, `${file}.research2.json`)) + const ledger = new Map() + for (const source of [research, research2]) { + for (const item of source?.items ?? []) { + if (item?.id && item?.sourceUrl) ledger.set(`${item.id}|${item.sourceUrl}`, item) + } + } + // Toda âncora impressa tem de resolver no ledger: par (factId, sourceUrl) errado é falha fechada. + for (const [listName, list] of [ + ['defenses', briefing.defenses ?? []], + ['essential', briefing.essential ?? []], + ['qa', briefing.qa ?? []], + ]) { + for (const item of list) { + if (item?.gapReason) continue + if (!ledger.has(`${item?.factId}|${item?.sourceUrl}`)) { + die( + `${entry.slug}: âncora de ${listName} não resolve no ledger ` + + `(${item?.factId} + ${item?.sourceUrl}) — pare e corrija o briefing.`, + ) + } + } + } + return { + num: entry.num, + slug: entry.slug, + file, + label: briefing.label ?? entry.slug, + subtitle: briefing.subtitle ?? '', + lede: briefing.lede, + plan: briefing.plan, + defenses: briefing.defenses ?? [], + essential: briefing.essential ?? [], + qa: briefing.qa ?? [], + intro, + ledger, + } +} + +/** + * Propostas autorais do próximo mandato, por recorte (`propostas.json`). Cada + * linha precisa existir e apontar `lastro` em fatos reais do ledger do recorte + * — proposta sem lastro falha fechado. + */ +const loadProposals = async (recortes) => { + const raw = await readJson(join(DATA_DIR, 'propostas.json')) + if (!raw?.propostas?.length) { + die( + 'propostas.json ausente ou sem "propostas" — a pesca mínima exige a proposta de Solla por recorte.', + ) + } + const proposals = new Map() + for (const entry of raw.propostas) { + if (!entry?.recorte || !entry?.proposta) + die('propostas.json: entrada sem "recorte"/"proposta".') + proposals.set(entry.recorte, entry) + } + for (const recorte of recortes) { + const entry = proposals.get(recorte.slug) + if (!entry) die(`propostas.json: falta a proposta do recorte "${recorte.slug}".`) + for (const factId of entry.lastro ?? []) { + const known = [...recorte.ledger.keys()].some((key) => key.startsWith(`${factId}|`)) + if (!known) { + die( + `propostas.json: lastro "${factId}" não existe no ledger de ${recorte.slug} — pare e corrija.`, + ) + } + } + } + return proposals +} + +/** Âncora (factId + sourceUrl) → data da fonte, para o crédito impresso. */ +const anchorDate = (recorte, item) => { + const fact = recorte.ledger.get(`${item.factId}|${item.sourceUrl}`) + return formatDate(fact?.sourceDate) +} + +const sourceCredit = (recorte, item) => { + const date = anchorDate(recorte, item) + return date + ? `fonte · ${escapeHtml(date)}` + : 'fonte no material' +} + +const itemHtml = (recorte, item) => + `
${escapeHtml(item.title)} — ${escapeHtml(item.note)} ${sourceCredit(recorte, item)}
` + +const qaHtml = (recorte, item) => ` +
+

${escapeHtml(sideLabels[item.side] ?? item.side)}${escapeHtml(item.question)}

+
+

Reconhecer: ${escapeHtml(item.acknowledge)}

+

Fato: ${escapeHtml(item.answer)} ${sourceCredit(recorte, item)}

+

Fechar: ${escapeHtml(item.close)}

+
+
` + +/** + * Unidades do livreto do recorte, na ordem de leitura. `keepWithNext` mantém o + * cabeçalho de seção junto do primeiro item; `sheddable` é o que pode cair no + * ajuste de 4 páginas (qas → defesas → parágrafos do intro, nessa ordem). + */ +const buildRecorteUnits = (recorte, content) => { + const units = [] + units.push({ + id: 'title', + kind: 'title', + html: `
+

Recorte

+

${escapeHtml(recorte.label)}

+ ${recorte.subtitle ? `

${escapeHtml(recorte.subtitle)}

` : ''} +
`, + }) + units.push({ id: 'lede', kind: 'lede', html: `

${escapeHtml(content.lede)}

` }) + content.intro.forEach((paragraph, index) => { + units.push({ + id: `intro-${index}`, + kind: 'para', + html: `

${escapeHtml(paragraph)}

`, + }) + }) + units.push({ + id: 'def-head', + kind: 'sechead', + keepWithNext: true, + html: `

O que Solla defende

Princípios e posições com fonte — leitura dos registros

`, + }) + content.defenses.forEach((item, index) => { + units.push({ id: `def-${index}`, kind: 'item', html: itemHtml(recorte, item) }) + }) + units.push({ + id: 'ess-head', + kind: 'sechead', + keepWithNext: true, + html: `

O essencial do recorte

Os fatos-âncora que sustentam a conversa

`, + }) + content.essential.forEach((item, index) => { + units.push({ id: `ess-${index}`, kind: 'item', html: itemHtml(recorte, item) }) + }) + units.push({ + id: 'qa-head', + kind: 'sechead', + keepWithNext: true, + html: `

Perguntas prováveis × melhores respostas

Reconheça sem repetir o ataque → responda com fato e fonte → feche no pedido

`, + }) + content.qa.forEach((item, index) => { + units.push({ id: `qa-${index}`, kind: 'qa', html: qaHtml(recorte, item) }) + }) + units.push({ + id: 'plan', + kind: 'plan', + html: `
Plano de conversa${escapeHtml(content.plan)}
`, + }) + return units +} + +/** + * Uma poda do conteúdo do recorte, na ordem declarada: qa (mantendo os dois + * lados e um mínimo de 2) → defesas (mínimo 4) → parágrafos do intro (mínimo + * 1). Devolve um novo conteúdo ou null quando não há mais o que podar. + */ +const shedRecorte = (content) => { + const qa = [...content.qa] + if (qa.length > 2) { + for (let index = qa.length - 1; index >= 0; index -= 1) { + const sides = new Set( + qa.filter((_item, position) => position !== index).map((item) => item.side), + ) + if (sides.has('direita') && sides.has('esquerda')) { + qa.splice(index, 1) + return { ...content, qa } + } + } + } + if (content.defenses.length > 4) { + return { ...content, defenses: content.defenses.slice(0, -1) } + } + if (content.intro.length > 1) { + return { ...content, intro: content.intro.slice(0, -1) } + } + return null +} + +const unitsProbeHtml = ( + units, + basePt, +) => ` + +
+
RECORTE · XJORGE SOLLA 1313
+
+ ${units.map((unit, index) => `
${unit.html}
`).join('\n')} +
+
material internop. 1/4
+
+` + +const measureUnits = async (page, html) => { + await page.setContent(html, { waitUntil: 'load' }) + await page.emulateMedia({ media: 'print' }) + return page.evaluate(() => ({ + capacity: document.querySelector('[data-probe="flowbox"]').clientHeight, + heights: [...document.querySelectorAll('[data-unit]')].map((element) => element.offsetHeight), + })) +} + +/** Packer guloso: devolve arrays de índices (um por página). */ +const packUnits = (units, heights, capacity) => { + const pages = [] + let current = [] + let used = 0 + units.forEach((_unit, index) => { + const height = heights[index] + if (current.length > 0 && used + height > capacity) { + pages.push(current) + current = [] + used = 0 + } + current.push(index) + used += height + }) + if (current.length > 0) pages.push(current) + // Cabeçalho de seção não fica órfão no pé da página: desce com o próximo item. + for (let position = 0; position < pages.length - 1; position += 1) { + const pageUnits = pages[position] + const lastIndex = pageUnits[pageUnits.length - 1] + if (units[lastIndex]?.keepWithNext) { + pageUnits.pop() + pages[position + 1].unshift(lastIndex) + } + } + return pages +} + +const fitRecorte = async (page, recorte) => { + let content = { + lede: recorte.lede, + intro: [...recorte.intro], + defenses: [...recorte.defenses], + essential: [...recorte.essential], + qa: [...recorte.qa], + plan: recorte.plan, + } + const shed = { qa: 0, defenses: 0, intro: 0 } + // Maior corpo que ainda cabe em 4 páginas com o conteúdo completo. + for (const basePt of RECORTE_BASE_SIZES) { + const units = buildRecorteUnits(recorte, content) + const { capacity, heights } = await measureUnits(page, unitsProbeHtml(units, basePt)) + const pages = packUnits(units, heights, capacity) + const overflow = heights.some((height) => height > capacity) + if (pages.length <= 4 && !overflow) + return { content, pages, units, shed, packedPages: pages.length, basePt } + } + // Sem caber inteiro: poda no menor corpo, como antes. + const basePt = RECORTE_BASE_SIZES[RECORTE_BASE_SIZES.length - 1] + for (let attempt = 0; attempt <= MAX_FIT_ATTEMPTS; attempt += 1) { + const units = buildRecorteUnits(recorte, content) + const { capacity, heights } = await measureUnits(page, unitsProbeHtml(units, basePt)) + const pages = packUnits(units, heights, capacity) + const overflow = heights.some((height) => height > capacity) + if (pages.length <= 4 && !overflow) + return { content, pages, units, shed, packedPages: pages.length, basePt } + const next = shedRecorte(content) + if (!next) { + die( + `Recorte ${recorte.slug} não cabe em 4 páginas mesmo no mínimo ` + + `(${pages.length} páginas, capacidade ${Math.round(capacity)}px).`, + ) + } + if (next.qa.length < content.qa.length) shed.qa += 1 + else if (next.defenses.length < content.defenses.length) shed.defenses += 1 + else if (next.intro.length < content.intro.length) shed.intro += 1 + content = next + } + die(`Recorte ${recorte.slug}: ajuste de páginas não convergiu.`) +} + +const renderSheet = ({ pageIndex, pageCount, units, title, basePt }) => ` +
+
+ ${escapeHtml(title)} + JORGE SOLLA 1313 +
+
+ ${units.map((unit) => `
${unit.html}
`).join('\n')} +
+
+ Material interno de capacitação — não publicar + p. ${pageIndex + 1}/${pageCount} +
+
` + +const sidesFor = (pages) => { + if (pages === 4) + return [ + [4, 1], + [2, 3], + ] + if (pages === 8) + return [ + [8, 1], + [2, 7], + [6, 3], + [4, 5], + ] + die(`Imposição não definida para ${pages} páginas.`) +} + +const blankSheet = () => '
' + +const renderSidesHtml = (sheets, sides) => { + const slots = sides + .map(([left, right]) => { + const leftHtml = left === null ? '' : sheets[left - 1] + const rightHtml = right === null ? '' : sheets[right - 1] + return `
${leftHtml}
${rightHtml}
` + }) + .join('\n') + return `Briefing Geral de Militância — livreto${slots}` +} + +const printPdf = async (page, html, outPath, label) => { + await page.setContent(html, { waitUntil: 'load' }) + await page.emulateMedia({ media: 'print' }) + const overflows = await page.evaluate(() => + [...document.querySelectorAll('.slot, [data-guide]')] + .map((slot, index) => ({ + slot: slot.dataset.guide ? 'guia' : index + 1, + height: slot.scrollHeight, + client: slot.clientHeight, + })) + .filter((row) => row.height > row.client + 1), + ) + if (overflows.length > 0) { + die( + `${label}: slot estourou (${overflows.map((row) => `${row.slot}:${row.height}>${row.client}`).join(', ')})`, + ) + } + await page.pdf({ + path: resolve(ROOT, outPath), + printBackground: true, + preferCSSPageSize: true, + margin: { top: '0', bottom: '0', left: '0', right: '0' }, + }) +} + +/** Blocos da pesca mínima: princípios, defesas, trabalho e propostas de cada recorte. */ +const buildPescaBlock = (recorte, content) => ({ + label: recorte.label, + slug: recorte.slug, + principle: content.principle, + defenses: content.defenses, + work: content.work, + proposal: content.proposal, +}) + +const pescaPairs = (recortes) => { + const pairs = [] + for (let index = 0; index < recortes.length; index += 2) { + pairs.push([recortes[index], recortes[index + 1]].filter(Boolean)) + } + return pairs +} + +const blockHtml = (block) => { + const pair = (items) => + items + .map((item) => `${escapeHtml(item.title)} — ${escapeHtml(item.note)}`) + .join(' • ') + const lines = [] + lines.push( + `

Princípios${escapeHtml(block.principle)}

`, + ) + if (block.defenses.length > 0) { + lines.push(`

Defesas${pair(block.defenses)}

`) + } + if (block.work.length > 0) { + lines.push(`

Trabalho${pair(block.work)}

`) + } + lines.push( + `

Propostas${escapeHtml(block.proposal)}

`, + ) + return `

${escapeHtml(block.label)}

${lines.join('\n')}
` +} + +const pescaUnit = (block) => ({ id: `blk-${block.slug}`, kind: 'blk', html: blockHtml(block) }) + +/** Poda um bloco da pesca: fez[1] → defesas[1] → princípio curto → fez[0] → defesas[0]. */ +const shedPescaBlock = (block) => { + if (block.work.length > 1) return { ...block, work: block.work.slice(0, -1) } + if (block.defenses.length > 1) return { ...block, defenses: block.defenses.slice(0, -1) } + if (!block.shortPrinciple) { + return { ...block, shortPrinciple: true, principle: clipText(block.fullPrinciple, 150) } + } + if (block.work.length > 0) return { ...block, work: [] } + if (block.defenses.length > 0) return { ...block, defenses: [] } + return null +} + +const fitPesca = async (page, recortes, proposals) => { + const fullBlocks = recortes.map((recorte) => { + const principle = clipText(recorte.lede, 260) + const proposal = proposals.get(recorte.slug)?.proposta + if (!proposal) die(`Pesca mínima: sem proposta autoral para ${recorte.slug}.`) + return buildPescaBlock(recorte, { + principle, + fullPrinciple: recorte.lede, + defenses: recorte.defenses.slice(0, 2), + work: recorte.essential.slice(0, 2), + proposal: clipText(proposal, 280), + }) + }) + const measurePairs = async (blocks, basePt) => { + const pairs = pescaPairs(blocks) + const { capacity, heights } = await measureUnits( + page, + unitsProbeHtml(pairs.flat().map(pescaUnit), basePt), + ) + const heightBySlug = new Map(pairs.flat().map((block, index) => [block.slug, heights[index]])) + const overflowing = pairs + .map((pair) => ({ + pair, + total: pair.reduce((sum, block) => sum + (heightBySlug.get(block.slug) ?? 0), 0), + })) + .filter((row) => row.total > capacity) + return { pairs, heightBySlug, overflowing } + } + // Maior corpo que ainda cabe em 8 páginas com os 2+2 itens de cada recorte. + for (const basePt of PESCA_BASE_SIZES) { + const { pairs, overflowing } = await measurePairs(fullBlocks, basePt) + if (overflowing.length === 0) { + return { blocks: fullBlocks, pairs, units: fullBlocks.map(pescaUnit), shed: [], basePt } + } + } + // Sem caber inteiro: poda no menor corpo. + const basePt = PESCA_BASE_SIZES[PESCA_BASE_SIZES.length - 1] + let blocks = fullBlocks + const shed = new Map() + for (let attempt = 0; attempt <= MAX_FIT_ATTEMPTS; attempt += 1) { + const { pairs, heightBySlug, overflowing } = await measurePairs(blocks, basePt) + if (overflowing.length === 0) { + return { blocks, pairs, units: blocks.map(pescaUnit), shed: [...shed.entries()], basePt } + } + const tallest = overflowing + .flatMap((row) => row.pair) + .filter((block) => block.work.length + block.defenses.length > 0 || !block.shortPrinciple) + .sort((a, b) => (heightBySlug.get(b.slug) ?? 0) - (heightBySlug.get(a.slug) ?? 0))[0] + if (!tallest) die('Pesca mínima não cabe em 8 páginas com o conteúdo mínimo.') + const next = shedPescaBlock(tallest) + if (!next) die(`Bloco ${tallest.slug} da pesca mínima não pode mais encolher.`) + shed.set(tallest.slug, (shed.get(tallest.slug) ?? 0) + 1) + const position = blocks.findIndex((block) => block.slug === tallest.slug) + blocks = blocks.map((block, index) => (index === position ? next : block)) + } + die('Pesca mínima: ajuste de páginas não convergiu.') +} + +const renderPescaSheets = (blocks, basePt) => { + const pairs = pescaPairs(blocks) + return pairs.map((pair, pageIndex) => + renderSheet({ + pageIndex, + pageCount: pairs.length, + units: pair.map(pescaUnit), + title: 'Pesca mínima · Jorge Solla 1313', + basePt, + }), + ) +} + +const writeOutputs = async (page, { base, sheets, pages, description }) => { + const sides = sidesFor(pages) + const fronts = sides.filter((_side, index) => index % 2 === 0) + const backs = sides.filter((_side, index) => index % 2 === 1) + await printPdf( + page, + renderSidesHtml(sheets, sides), + `${OUT_DIR}/${base}.pdf`, + `${base} (completo)`, + ) + await printPdf( + page, + renderSidesHtml(sheets, fronts), + `${OUT_DIR}/${base}-frentes.pdf`, + `${base} (frentes)`, + ) + await printPdf( + page, + renderSidesHtml(sheets, backs), + `${OUT_DIR}/${base}-versos.pdf`, + `${base} (versos)`, + ) + return { + slug: base, + label: description, + pages, + padded: pages, + sheets: pages / 4, + sides, + sheetsHtml: sheets, + } +} + +/** + * Edição completa: um único documento com as faces de todos os livretos, na + * ordem do manifesto. `pick` separa frentes (índices pares) dos versos (ímpares) + * — imprimir todas as frentes, virar o maço inteiro e imprimir os versos alinha + * os dois lados de uma vez, sem montar livreto por livreto. + */ +const renderEditionHtml = (booklets, pick) => { + const slots = booklets + .flatMap((booklet) => pick(booklet).map(([left, right]) => ({ booklet, left, right }))) + .map(({ booklet, left, right }) => { + const leftHtml = left === null ? '' : booklet.sheetsHtml[left - 1] + const rightHtml = right === null ? '' : booklet.sheetsHtml[right - 1] + return `
${leftHtml}
${rightHtml}
` + }) + .join('\n') + return `Briefing Geral de Militância — edição completa${slots}` +} + +const writeEditionOutputs = async (page, booklets) => { + if (booklets.length === 0) return + await printPdf( + page, + renderEditionHtml(booklets, (booklet) => booklet.sides), + `${OUT_DIR}/00-edicao-completa.pdf`, + 'Edição completa (todas as faces)', + ) + await printPdf( + page, + renderEditionHtml(booklets, (booklet) => + booklet.sides.filter((_side, index) => index % 2 === 0), + ), + `${OUT_DIR}/00-edicao-completa-frentes.pdf`, + 'Edição completa (frentes)', + ) + await printPdf( + page, + renderEditionHtml(booklets, (booklet) => + booklet.sides.filter((_side, index) => index % 2 === 1), + ), + `${OUT_DIR}/00-edicao-completa-versos.pdf`, + 'Edição completa (versos)', + ) +} + +const GUIDE_CSS = ` + ${BASE_CSS} + @page { size: A4 portrait; margin: 0; } + .guide { + width: 210mm; + height: 297mm; + padding: 16mm 18mm 12mm; + box-sizing: border-box; + font-size: 9.5pt; + line-height: 1.42; + } + .guide h1 { margin: 0; font-size: 21pt; line-height: 1.08; letter-spacing: -.02em; } + .guide .sub { margin: 2mm 0 0; color: var(--soft); font-size: 10pt; } + .guide h2 { margin: 6mm 0 1.4mm; padding-top: 1.6mm; border-top: .35mm solid var(--ink); font-size: 11.5pt; } + .guide ol { margin: 0; padding-left: 6mm; } + .guide li { margin-bottom: 1.2mm; } + .guide table { width: 100%; border-collapse: collapse; font-size: 9pt; } + .guide th, .guide td { padding: 1mm 1.6mm; border-bottom: .15mm solid var(--lline); text-align: left; } + .guide th { border-bottom: .3mm solid var(--ink); font-size: 8pt; letter-spacing: .06em; text-transform: uppercase; } + .guide td.num, .guide th.num { text-align: right; font-variant-numeric: tabular-nums; } + .guide .note { margin: 3mm 0 0; padding: 2mm 2.6mm; border-left: 1mm solid var(--accent); background: var(--note); font-size: 9pt; } + .guide .files { margin: 0; padding-left: 6mm; } + .guide code { font-family: 'DejaVu Sans Mono', monospace; font-size: 8.6pt; } +` + +/** Manifest limpo: nunca deixa o HTML das folhas vazar para o `livretos.json`. */ +const toManifestEntry = ({ slug, label, pages, padded, sheets, sides }) => ({ + slug, + label, + pages, + padded, + sheets, + sides, +}) + +const renderGuideHtml = (manifest) => { + const rows = manifest + .map( + (entry, index) => ` + ${index + 1} + ${escapeHtml(entry.slug)} + ${escapeHtml(entry.label)} + ${entry.pages} + ${entry.sheets} + `, + ) + .join('\n') + return `Guia de impressão — Briefing de Militância +
+

Briefing Geral de Militância — guia de impressão

+

Jorge Solla 1313 · material interno de capacitação · não publicar

+ +

Cada recorte é um livreto A5 de 4 páginas (1 folha A4 paisagem dobrada ao meio); + a pesca mínima é o compilado de 8 páginas (2 folhas). As páginas já vêm na ordem de dobra + (saddle stitch): é imprimir, virar o maço, dobrar e montar.

+ +

Impressão fácil — edição completa

+
    +
  1. Imprima 00-edicao-completa-frentes.pdf (A4, paisagem, escala 100% / print-scaling none). São 18 folhas.
  2. +
  3. Retire o maço sem embaralhar, vire o maço da esquerda para a direita (como virar a página de um livro) e recoloque na bandeja.
  4. +
  5. Imprima 00-edicao-completa-versos.pdf com as mesmas opções (18 folhas).
  6. +
  7. Dobre cada folha no meio e empilhe os livretos na ordem da tabela abaixo.
  8. +
  9. 00-edicao-completa.pdf tem todas as faces em sequência (útil para conferência ou impressão duplex).
  10. +
+ +

Ordem dos livretos

+ + + +${rows} + +
#ArquivoRecortePáginasFolhas
+ +

Se quiser imprimir um livreto só

+
    +
  • <slug>-frentes.pdf → imprime; vira o maço; <slug>-versos.pdf → imprime; dobra.
  • +
  • Páginas em branco no fim de um livreto são o enchimento técnico da dobra (não é erro).
  • +
+
` +} + +const writeGuidePdf = async (page, manifest) => { + await printPdf( + page, + renderGuideHtml(manifest), + `${OUT_DIR}/00-guia-de-impressao.pdf`, + 'Guia de impressão', + ) +} + +const buildReadme = (manifest) => { + const rows = manifest + .map( + (entry) => + `${entry.slug.padEnd(30)} ${entry.label.padEnd(42)} ${String(entry.pages).padStart(2)} pág. ${entry.sheets} folha(s)`, + ) + .join('\n') + return `LIVRETOS — Briefing Geral de Militância (Jorge Solla 1313) — edição 1 folha + +Cada arquivo .pdf é um livreto A5 completo em A4 paisagem, 2 páginas por lado, +na ordem de dobra (saddle stitch): junte as folhas do livreto, dobre no meio e pronto. +Nesta edição cada recorte cabe em 1 folha (4 páginas); a pesca mínima é o compilado +de 2 folhas (8 páginas) com princípios, defesas, trabalho e propostas de cada recorte. + +IMPRESSÃO FÁCIL — EDIÇÃO COMPLETA (todos os livretos num documento só): +1) imprima 00-edicao-completa-frentes.pdf (A4, paisagem, escala 100%/print-scaling none); +2) retire o maço SEM embaralhar, vire o maço da esquerda para a direita (como virar + a página de um livro) e recoloque na bandeja; +3) imprima 00-edicao-completa-versos.pdf com as mesmas opções; +4) dobre cada folha no meio e empilhe os livretos na ordem da lista abaixo. +00-edicao-completa.pdf tem todas as faces em sequência (conferência ou duplex). +00-guia-de-impressao.pdf é este guia numa página A4 para deixar junto do maço. + +IMPRESSORA: HP DeskJet 2800 (sem duplex automático). Para imprimir um livreto só, +use -frentes.pdf e -versos.pdf com os mesmos passos. + +Páginas em branco no fim de cada livreto são o enchimento técnico da dobra (não é erro). + +ORDEM DOS LIVRETOS: +${rows} +` +} + +const run = async () => { + const args = new Map( + process.argv + .slice(2) + .filter((argument) => argument.startsWith('--')) + .map((argument) => argument.replace(/^--/, '').split('=')), + ) + const only = args.get('only') + const skipPesca = args.has('skip-pesca') + const onlyPesca = args.has('only-pesca') + + const all = [] + for (const entry of RECORTES) { + if (only && entry.slug !== only) continue + all.push(await loadRecorte(entry)) + } + if (all.length === 0) die(`--only=${only} não corresponde a nenhum recorte.`) + const proposals = await loadProposals(all) + + await mkdir(resolve(ROOT, OUT_DIR), { recursive: true }) + const browser = await chromium.launch() + const page = await browser.newPage() + const manifest = [] + const booklets = [] + try { + for (const recorte of onlyPesca ? [] : all) { + const fitted = await fitRecorte(page, recorte) + const contentSheets = fitted.pages.map((pageUnits, pageIndex) => + renderSheet({ + pageIndex, + pageCount: 4, + units: pageUnits.map((unitIndex) => fitted.units[unitIndex]), + title: `Recorte · ${recorte.label}`, + basePt: fitted.basePt, + }), + ) + while (contentSheets.length < 4) contentSheets.push(blankSheet()) + const sheets = contentSheets + const base = `${recorte.num}-${recorte.slug}` + const record = await writeOutputs(page, { + base, + sheets, + pages: sheets.length, + description: recorte.label, + }) + manifest.push(toManifestEntry(record)) + booklets.push(record) + const shedTotal = fitted.shed.qa + fitted.shed.defenses + fitted.shed.intro + console.log( + `[${LABEL}] ${base}: conteúdo=${fitted.packedPages}/4 páginas · corpo=${fitted.basePt}pt · defesas=${fitted.content.defenses.length} ` + + `essencial=${fitted.content.essential.length} qa=${fitted.content.qa.length} intro=${fitted.content.intro.length} ` + + `podados=${shedTotal}`, + ) + } + if (!skipPesca && !only) { + const recortes = all.map((recorte) => ({ ...recorte })) + const fitted = await fitPesca(page, recortes, proposals) + const sheets = renderPescaSheets(fitted.blocks, fitted.basePt) + const record = await writeOutputs(page, { + base: '00-pesca-minima', + sheets, + pages: sheets.length, + description: 'Pesca mínima — o essencial de cada recorte', + }) + manifest.push(toManifestEntry(record)) + booklets.push(record) + console.log(`[${LABEL}] 00-pesca-minima: ${sheets.length} páginas · corpo=${fitted.basePt}pt`) + if (fitted.shed.length > 0) { + console.log( + `[${LABEL}] pesca mínima podou: ${fitted.shed.map(([slug, count]) => `${slug}×${count}`).join(', ')}`, + ) + } + } + + // Manifest acumulado: um run parcial (--only/--only-pesca) preserva as entradas + // já publicadas e substitui só o que foi reconstruído. + const manifestPath = resolve(ROOT, OUT_DIR, 'livretos.json') + let previous = [] + try { + previous = JSON.parse(await readFile(manifestPath, 'utf8')) + } catch { + previous = [] + } + const rebuilt = new Map(manifest.map((entry) => [entry.slug, entry])) + const merged = previous.map((entry) => rebuilt.get(entry.slug) ?? entry) + for (const entry of manifest) { + if (!previous.some((old) => old.slug === entry.slug)) merged.push(entry) + } + + // A edição completa só existe com todos os livretos do run: um run parcial + // não sobrescreve os PDFs combinados. + if (!only && !onlyPesca && merged.length > 0) { + const ordered = merged + .map((entry) => booklets.find((booklet) => booklet.slug === entry.slug)) + .filter(Boolean) + await writeEditionOutputs(page, ordered) + await writeGuidePdf(page, merged) + console.log( + `[${LABEL}] edição completa e guia → 00-edicao-completa*.pdf (${ordered.reduce((total, booklet) => total + booklet.sheets, 0)} folhas) e 00-guia-de-impressao.pdf`, + ) + } + + await writeFile(manifestPath, `${JSON.stringify(merged, null, 2)}\n`) + await writeFile(resolve(ROOT, OUT_DIR, 'LEIA-ME.txt'), buildReadme(merged)) + console.log(`[${LABEL}] manifesto e LEIA-ME → ${OUT_DIR}/`) + } finally { + await page.close() + await browser.close() + } +} + +await run()