diff --git a/tools/web-to-pdf.mjs b/tools/web-to-pdf.mjs new file mode 100644 --- /dev/null +++ b/tools/web-to-pdf.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +// web-to-pdf — turn a client-rendered Cargo.site article into a clean, +// typeset, application-grade PDF (selectable text, full-resolution images). +// +// Why this exists: Cargo.site pages (4nmag.com, many artist/press sites, +// fiabenitez.com, etc.) are JS-rendered shells. Printing them directly +// clips text off the page edge and the artwork lazy-loads as background +// elements that never appear in a capture. But the *initial* HTML embeds +// `window.__PRELOADED_STATE__` — the full content tree + media manifest — +// before the client app nulls it. We pull the article straight from there, +// rebuild it as semantic HTML, fetch the images from Cargo's freight CDN +// at print resolution, and let headless Chrome paginate our own clean +// print stylesheet. +// +// Usage: +// tools/web-to-pdf.mjs [--out DIR] [--name BASENAME] +// [--img-width PX] [--kicker "TEXT"] +// +// Example: +// tools/web-to-pdf.mjs https://4nmag.com/fia-benitez --out ~/Desktop +// +// Output: /.pdf (+ /-img/ with the source images) +// +// Dependencies: node + the monorepo's puppeteer-core (for Chrome only). +// No ImageMagick needed — Cargo's freight CDN resizes server-side. + +import { writeFile, mkdir } from "node:fs/promises"; +import { existsSync, readdirSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(fileURLToPath(import.meta.url), "../.."); + +// ---- args ---------------------------------------------------------------- +const argv = process.argv.slice(2); +const url = argv.find((a) => !a.startsWith("--")); +const opt = (k, d) => { + const i = argv.indexOf(`--${k}`); + return i !== -1 && argv[i + 1] ? argv[i + 1] : d; +}; +if (!url || argv.includes("--help")) { + console.log( + "usage: web-to-pdf [--out DIR] [--name BASENAME] [--img-width PX] [--kicker TEXT]" + ); + process.exit(url ? 0 : 1); +} +const outDir = resolve(opt("out", ".").replace(/^~/, homedir())); +const imgWidth = parseInt(opt("img-width", "1800"), 10); +const kickerOverride = opt("kicker", null); +const purl = new URL(url).pathname.split("/").filter(Boolean).pop() || "index"; +const baseName = opt("name", null); + +const UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"; + +// ---- 1. fetch raw HTML, extract __PRELOADED_STATE__ ---------------------- +const html = await (await fetch(url, { headers: { "User-Agent": UA } })).text(); +const anchor = html.indexOf("window.__PRELOADED_STATE__"); +if (anchor === -1) { + console.error( + "No __PRELOADED_STATE__ found — this tool targets client-rendered Cargo.site pages." + ); + process.exit(2); +} +let j = html.indexOf("{", anchor); +let depth = 0; +let k = j; +for (; k < html.length; k++) { + const c = html[k]; + if (c === "{") depth++; + else if (c === "}" && --depth === 0) break; +} +const state = JSON.parse(html.slice(j, k + 1)); + +const page = Object.values(state.pages?.byId || {}).find((p) => p.purl === purl); +if (!page) { + console.error(`No page with purl "${purl}" in preloaded state.`); + process.exit(3); +} +const siteTitle = state.site?.website_title || ""; +const name = baseName || page.title || purl; + +// Freight serves heavy PNGs; recompress to JPEG with ImageMagick if present. +const magick = ["magick", "convert"].find( + (c) => spawnSync(c, ["-version"], { stdio: "ignore" }).status === 0 +); +const ext = magick ? "jpg" : "png"; + +// ---- 2. clean Cargo content → semantic HTML ------------------------------ +let raw = page.content || ""; +const figures = []; +raw = raw.replace( + /]*\bhash="(\w+)"[^>]*>([\s\S]*?)<\/media-item>/g, + (_, hash, inner) => { + const cap = (inner.match(/]*>([\s\S]*?)<\/figcaption>/) || [, ""])[1].trim(); + figures.push({ hash, cap }); + return `\n\n[[FIG:${figures.length - 1}]]\n\n`; + } +); +raw = raw + .split('')[0] + .replace(/<\/?column-set[^>]*>/g, "") + .replace(/<\/?column-unit[^>]*>/g, "") + .replace(/]*><\/text-icon>/g, "") + .replace(/\sstyle="[^"]*"/g, "") + .replace(/\sclass="[^"]*"/g, "") + .replace(/<\/?span[^>]*>/g, "") + .replace(/<\/?div[^>]*>/g, "") + .replace(//g, "") + .replace(/ /g, " ") + .replace(/\s*\s*/g, "\n") + .replace(/[ \t]+/g, " ") + // force a paragraph break before short bold labels ("KR:", "Fía Benítez:") + .replace(/\s*([^<:]{1,42}:)/g, "\n\n$1") + .replace(/\n{3,}/g, "\n\n"); + +const h1 = (raw.match(/

([\s\S]*?)<\/h1>/) || [, ""])[1].replace(/\s+/g, " ").trim(); +raw = raw.replace(/

[\s\S]*?<\/h1>/, "").trim(); + +// Balance b/i/a within each block so styles never leak across

bounds +// (HTML's active-formatting reconstruction otherwise drags an unclosed +// through every following paragraph). +function balance(s) { + const out = []; + const stack = []; + const re = /<(\/?)(b|i|a)\b([^>]*)>|([^<]+)/g; + let m; + while ((m = re.exec(s))) { + if (m[4] !== undefined) { + out.push(m[4]); + continue; + } + const tag = m[2]; + if (m[1] !== "/") { + out.push(`<${tag}${m[3]}>`); + stack.push(tag); + } else { + const i = stack.lastIndexOf(tag); + if (i === -1) continue; + for (let x = stack.length - 1; x >= i; x--) out.push(``); + const reopen = stack.splice(i); + reopen.shift(); + for (const t of reopen) { + out.push(`<${t}>`); + stack.push(t); + } + } + } + while (stack.length) out.push(``); + return out.join(""); +} + +let body = ""; +let intro = true; +let bylineDone = false; +for (let b of raw.split(/\n{2,}/).map((x) => x.trim()).filter(Boolean)) { + const fig = b.match(/^\[\[FIG:(\d+)\]\]$/); + if (fig) { + const { hash, cap } = figures[+fig[1]]; + body += `

${balance(cap)}
\n`; + continue; + } + b = b.replace(/\[\[FIG:\d+\]\]/g, "").trim(); + const flat = b.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(); + if (!flat) continue; + if (!bylineDone && /^[A-Z][a-z]+ \d{1,2},? \d{4}/.test(flat)) { + const m = flat.match(/^(.*?\d{4})\s*by\s+(.+)$/i); + body += `\n`; + bylineDone = true; + continue; + } + if (/:\s*$/.test(flat) === false && /^[A-Z][A-Za-z. ]+:/.test(flat)) intro = false; + const cls = !bylineDone ? "lede" : intro ? "lede" : "copy"; + body += `

${balance(b).replace(/\s+/g, " ").trim()}

\n`; +} + +// ---- 3. download images from the freight CDN at print resolution -------- +// Freight always serves the master's format (often heavy PNG). Recompress +// to JPEG with ImageMagick when available; otherwise keep the PNG. +const imgDir = join(outDir, `${name}-img`); +await mkdir(imgDir, { recursive: true }); +const mediaByHash = Object.fromEntries((page.media || []).map((m) => [m.hash, m])); +for (const { hash } of figures) { + const m = mediaByHash[hash]; + const fname = m?.name || hash; + const src = `https://freight.cargo.site/w/${imgWidth}/i/${hash}/${fname}`; + const buf = Buffer.from( + await (await fetch(src, { headers: { "User-Agent": UA } })).arrayBuffer() + ); + const dest = join(imgDir, `${hash}.${ext}`); + if (magick) { + const orig = join(imgDir, `${hash}.orig`); + await writeFile(orig, buf); + spawnSync(magick, [orig, "-resize", `${imgWidth}x`, "-quality", "88", "-strip", dest], { + stdio: "ignore", + }); + spawnSync("rm", ["-f", orig]); + } else { + await writeFile(dest, buf); + } +} +if (!magick) + console.warn("note: ImageMagick not found — images kept as PNG (larger PDF)."); + +// ---- 4. typeset -------------------------------------------------------- +const kicker = kickerOverride || (siteTitle ? `${siteTitle}` : ""); +const docHtml = ` +${kicker ? `

${kicker}

` : ""} +

${h1 || page.title}

+${body} +

Originally published at ${url}

+`; + +const htmlPath = join(outDir, `${name}.html`); +await writeFile(htmlPath, docHtml); + +// ---- 5. render via headless Chrome ------------------------------------- +const { default: puppeteer } = await import( + join(ROOT, "node_modules/puppeteer-core/lib/esm/puppeteer/puppeteer-core.js") +); +const chromeCandidates = [ + process.env.CHROME_PATH, + ...[`${homedir()}/.cache/puppeteer/chrome`].flatMap((d) => + existsSync(d) + ? readdirSync(d).map( + (v) => + `${d}/${v}/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing` + ) + : [] + ), + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", +].filter(Boolean); +const executablePath = chromeCandidates.find((p) => p && existsSync(p)); +if (!executablePath) { + console.error("No Chrome found. Set CHROME_PATH=… and retry."); + process.exit(4); +} +const browser = await puppeteer.launch({ + executablePath, + headless: true, + args: ["--no-sandbox", "--force-color-profile=srgb"], +}); +const tab = await browser.newPage(); +await tab.goto(`file://${htmlPath}`, { waitUntil: "networkidle0" }); +const pdfPath = join(outDir, `${name}.pdf`); +await tab.pdf({ + path: pdfPath, + format: "Letter", + printBackground: true, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + preferCSSPageSize: true, +}); +await browser.close(); + +console.log(`✓ ${pdfPath}`); +console.log(` ${figures.length} images · source: ${url}`);