378 lines
11 KiB
JavaScript
378 lines
11 KiB
JavaScript
#!/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/<navn>.html og kjører weasyprint -> pdf/<navn>.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, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
function inline(text) {
|
|
let out = esc(text);
|
|
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
|
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Kompakt markdown -> HTML (som build-html.mjs). Siste avsnitt-blokk som starter
|
|
// med <em>Om tilblivelsen:</em> 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 <span> (weasyprint krasjer på ::first-letter{float}).
|
|
if (cls === "lede") {
|
|
inner = inner.replace(
|
|
/^(\s*)([A-Za-zÆØÅæøå0-9])/,
|
|
(_, ws, ch) => `${ws}<span class="dropcap">${ch}</span>`
|
|
);
|
|
}
|
|
blocks.push(`<p class="${cls}">${inner}</p>`);
|
|
}
|
|
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
const trimmed = line.trim();
|
|
|
|
if (/^---+$/.test(trimmed)) {
|
|
blocks.push("<hr>");
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
let hm = trimmed.match(/^(#{2,3})\s+(.*)$/);
|
|
if (hm) {
|
|
const level = hm[1].length;
|
|
blocks.push(`<h${level}>${inline(hm[2].trim())}</h${level}>`);
|
|
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(`<blockquote><p>${inline(qbuf.join(" ").trim())}</p></blockquote>`);
|
|
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("<ul>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ul>");
|
|
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("<ol>" + items.map((it) => `<li>${inline(it)}</li>`).join("") + "</ol>");
|
|
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 `<!DOCTYPE html>
|
|
<html lang="nb">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>${esc(title)}</title>
|
|
<style>${PRINT_CSS}</style>
|
|
</head>
|
|
<body>
|
|
<article>
|
|
${meta.kicker ? `<p class="kicker">${esc(meta.kicker)}</p>` : ""}
|
|
<h1 class="title">${inline(title)}</h1>
|
|
${meta.subtitle ? `<p class="subtitle">${inline(meta.subtitle)}</p>` : ""}
|
|
<div class="byline-wrap">
|
|
${meta.byline ? `<p class="byline">${esc(meta.byline)}</p>` : ""}
|
|
${metaLine ? `<p class="meta">${esc(metaLine)}</p>` : ""}
|
|
</div>
|
|
<div class="body">
|
|
${bodyHtml}
|
|
</div>
|
|
</article>
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
if (!args.length) {
|
|
console.error("Bruk: node build-pdf.mjs <fil.md> [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();
|
|
}
|