#!/usr/bin/env node // build-pdf.mjs — render kronikkene som rene avis-PDF-er (uten annoterings-UI). // Bruk: node build-pdf.mjs utkast/01-....md [flere.md ...] // Genererer ren print-HTML i pdf/_html/.html og kjører weasyprint -> pdf/.pdf. // Speiler avis-stilen fra build-html.mjs, men print-tunet (A4, marger, sidetall). // Krever: weasyprint på PATH. Ingen npm-avhengigheter. import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // --------------------------------------------------------------------------- // weasyprint graceful degradation (S1, correction #3) // Detekterer weasyprint på PATH. Returnerer et skip-signal (kaster ALDRI) når // verktøyet mangler, slik at PDF-steget hoppes over med en tydelig install-hint // i stedet for å krasje kjøringen. `probe` er injiserbar for test. // --------------------------------------------------------------------------- const WEASYPRINT_HINT = "weasyprint ikke funnet på PATH — hopper over PDF-steget.\n" + " Install: pipx install weasyprint (alternativt: brew install weasyprint)"; export function resolveWeasyprint(probe = defaultWeasyprintProbe) { if (probe()) return { available: true }; return { available: false, hint: WEASYPRINT_HINT }; } function defaultWeasyprintProbe() { try { execFileSync("weasyprint", ["--version"], { stdio: "ignore" }); return true; } catch { return false; } } // --------------------------------------------------------------------------- // YAML front matter (flate key: "value"-par mellom --- ... ---) — som build-html.mjs // --------------------------------------------------------------------------- function parseFrontMatter(raw) { const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!m) return { meta: {}, body: raw }; const meta = {}; for (const line of m[1].split(/\r?\n/)) { const mm = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); if (!mm) continue; let val = mm[2].trim(); if ( (val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")) ) { val = val.slice(1, -1); } meta[mm[1]] = val; } return { meta, body: m[2].replace(/^\r?\n+/, "") }; } function esc(s) { return s.replace(/&/g, "&").replace(//g, ">"); } function inline(text) { let out = esc(text); out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c}`); out = out.replace(/\*([^*]+)\*/g, (_, c) => `${c}`); return out; } // --------------------------------------------------------------------------- // Kompakt markdown -> HTML (som build-html.mjs). Siste avsnitt-blokk som starter // med Om tilblivelsen: merkes .colophon for diskret metodenote-stil. // --------------------------------------------------------------------------- function markdownToHtml(body) { const lines = body.replace(/\r\n/g, "\n").split("\n"); const blocks = []; let i = 0; let paraCount = 0; function flushPara(buf) { if (!buf.length) return; const text = buf.join(" ").trim(); if (!text) return; paraCount++; let cls = paraCount === 1 ? "lede" : "indent"; if (/^\*Om tilblivelsen:\*/.test(text)) cls = "colophon"; let inner = inline(text); // Drop cap som ekte, floatet (weasyprint krasjer på ::first-letter{float}). if (cls === "lede") { inner = inner.replace( /^(\s*)([A-Za-zÆØÅæøå0-9])/, (_, ws, ch) => `${ws}${ch}` ); } blocks.push(`

${inner}

`); } while (i < lines.length) { const line = lines[i]; const trimmed = line.trim(); if (/^---+$/.test(trimmed)) { blocks.push("
"); i++; continue; } let hm = trimmed.match(/^(#{2,3})\s+(.*)$/); if (hm) { const level = hm[1].length; blocks.push(`${inline(hm[2].trim())}`); i++; continue; } if (/^>\s?/.test(trimmed)) { const qbuf = []; while (i < lines.length && /^>\s?/.test(lines[i].trim())) { qbuf.push(lines[i].trim().replace(/^>\s?/, "")); i++; } blocks.push(`

${inline(qbuf.join(" ").trim())}

`); continue; } if (/^[-*]\s+/.test(trimmed)) { const items = []; while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) { items.push(lines[i].trim().replace(/^[-*]\s+/, "")); i++; } blocks.push("
    " + items.map((it) => `
  • ${inline(it)}
  • `).join("") + "
"); continue; } if (/^\d+\.\s+/.test(trimmed)) { const items = []; while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) { items.push(lines[i].trim().replace(/^\d+\.\s+/, "")); i++; } blocks.push("
    " + items.map((it) => `
  1. ${inline(it)}
  2. `).join("") + "
"); continue; } if (trimmed === "") { i++; continue; } const pbuf = []; while (i < lines.length) { const t = lines[i].trim(); if ( t === "" || /^---+$/.test(t) || /^(#{2,3})\s+/.test(t) || /^>\s?/.test(t) || /^[-*]\s+/.test(t) || /^\d+\.\s+/.test(t) ) { break; } pbuf.push(t); i++; } flushPara(pbuf); } return blocks.join("\n"); } // --------------------------------------------------------------------------- // Print-CSS — avis-identitet (off-white, oxblood, drop cap, serif), A4-tunet. // --------------------------------------------------------------------------- const FONT_DIR = path.join(__dirname, "fonts"); const ff = (f) => `url("file://${path.join(FONT_DIR, f).replace(/ /g, "%20")}")`; const FONT_FACE = ` @font-face{font-family:"Newsreader";font-style:normal;font-weight:400;src:${ff("Newsreader-400.ttf")};} @font-face{font-family:"Newsreader";font-style:italic;font-weight:400;src:${ff("Newsreader-400i.ttf")};} @font-face{font-family:"Newsreader";font-style:normal;font-weight:600;src:${ff("Newsreader-600.ttf")};} @font-face{font-family:"Newsreader";font-style:italic;font-weight:600;src:${ff("Newsreader-600i.ttf")};} @font-face{font-family:"Newsreader";font-style:normal;font-weight:700;src:${ff("Newsreader-700.ttf")};} @font-face{font-family:"Inter";font-style:normal;font-weight:400;src:${ff("Inter-400.ttf")};} @font-face{font-family:"Inter";font-style:normal;font-weight:600;src:${ff("Inter-600.ttf")};} @font-face{font-family:"Inter";font-style:normal;font-weight:700;src:${ff("Inter-700.ttf")};} `; const PRINT_CSS = ` ${FONT_FACE} :root { --bg: #FBFAF7; --ink: #1A1A1A; --muted: #555555; --accent: #9A3324; --rule: #d8d4cb; --serif: "Newsreader",Georgia,"Times New Roman",serif; --sans: "Inter","Helvetica Neue",Helvetica,Arial,sans-serif; } @page { size: A4; margin: 22mm 21mm 20mm; background: #FBFAF7; @bottom-center { content: counter(page); font-family: "Inter","Helvetica Neue",Helvetica,Arial,sans-serif; font-size: 8pt; color: #9a9a9a; } } * { box-sizing: border-box; } html { background: var(--bg); } body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--serif); font-size: 12.5pt; line-height: 1.5; } .kicker { font-family: var(--sans); text-transform: uppercase; letter-spacing: 0.12em; font-size: 8pt; font-weight: 700; color: var(--accent); margin: 0 0 8pt; } h1.title { font-family: var(--serif); font-size: 27pt; line-height: 1.08; font-weight: 700; margin: 0 0 9pt; letter-spacing: -0.01em; } .subtitle { font-family: var(--serif); font-style: italic; font-size: 14.5pt; line-height: 1.4; color: var(--muted); margin: 0 0 14pt; } .byline-wrap { border-top: 1px solid var(--rule); padding-top: 7pt; margin-bottom: 18pt; } .byline { font-family: var(--sans); text-transform: uppercase; letter-spacing: 0.06em; font-size: 8pt; font-weight: 600; color: var(--muted); margin: 0 0 2pt; } .meta { font-family: var(--sans); font-size: 8pt; color: var(--muted); letter-spacing: 0.04em; } .body p { margin: 0; text-align: justify; hyphens: auto; orphans: 3; widows: 3; } .body p.indent { text-indent: 1.4em; } .body .dropcap { float: left; font-size: 3.1em; line-height: 0.74; padding: 0.02em 0.09em 0 0; color: var(--accent); font-weight: 700; } .body h2 { font-size: 16.5pt; font-weight: 700; margin: 18pt 0 6pt; line-height: 1.2; } .body h3 { font-size: 13.5pt; font-weight: 700; margin: 15pt 0 4pt; line-height: 1.25; } .body ul, .body ol { margin: 7pt 0; padding-left: 1.4em; } .body li { margin: 2pt 0; } .body blockquote { margin: 12pt 0; padding-left: 1.1em; border-left: 3px solid var(--accent); font-style: italic; color: #333; } .body hr { border: 0; border-top: 1px solid var(--rule); margin: 16pt auto; width: 38%; } .body strong { font-weight: 600; } .body p.colophon { font-family: var(--sans); font-size: 9.5pt; line-height: 1.45; color: var(--muted); text-align: left; text-indent: 0; } .body h2, .body h3 { break-after: avoid; } .body p.lede { break-after: avoid; } `; function buildPrintHtml(meta, body) { const bodyHtml = markdownToHtml(body); const title = meta.title || "Kronikk"; const metaLine = [meta.serie, meta.lesetid].filter(Boolean).join(" · "); return ` ${esc(title)}
${meta.kicker ? `

${esc(meta.kicker)}

` : ""}

${inline(title)}

${meta.subtitle ? `

${inline(meta.subtitle)}

` : ""}
${bodyHtml}
`; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function main() { const args = process.argv.slice(2); if (!args.length) { console.error("Bruk: node build-pdf.mjs [flere.md ...]"); process.exit(1); } // Output følger serien (kjøres fra serie-mappa); fonts følger scriptet via __dirname. const pdfDir = path.join(process.cwd(), "pdf"); const htmlDir = path.join(pdfDir, "_html"); fs.mkdirSync(htmlDir, { recursive: true }); const wp = resolveWeasyprint(); if (!wp.available) console.warn(wp.hint); for (const arg of args) { const inPath = path.isAbsolute(arg) ? arg : path.join(process.cwd(), arg); if (!fs.existsSync(inPath)) { console.error(`Fant ikke: ${inPath}`); continue; } const raw = fs.readFileSync(inPath, "utf8"); const { meta, body } = parseFrontMatter(raw); const base = path.basename(inPath).replace(/\.md$/i, ""); const html = buildPrintHtml(meta, body); const htmlPath = path.join(htmlDir, base + ".html"); const pdfPath = path.join(pdfDir, base + ".pdf"); fs.writeFileSync(htmlPath, html, "utf8"); if (wp.available) { execFileSync("weasyprint", [htmlPath, pdfPath], { stdio: ["ignore", "ignore", "inherit"] }); const kb = (fs.statSync(pdfPath).size / 1024).toFixed(1); console.log(`PDF: ${pdfPath} (${kb} KB)`); } else { console.warn(` Hoppet over PDF (weasyprint mangler): ${pdfPath}`); } } } if (import.meta.url === `file://${process.argv[1]}`) { main(); }