#!/usr/bin/env node // menu-pdf.mjs — render the /pop menu (lib/menu.mjs) as a printable, // color-coded PDF + PNG. A restaurant-style spec sheet of every // instrument, fx, percussion voice, bed, vocal pipeline, scale, and // form that lives in /pop, with a QR + URL to the repo directory. // // Usage: // node pop/bin/menu-pdf.mjs # → ~/Desktop/pop-menu.{pdf,png} // node pop/bin/menu-pdf.mjs --out path.pdf # custom destination (.png sibling) import { writeFileSync, mkdtempSync, rmSync, readdirSync } from "node:fs"; import { resolve, join } from "node:path"; import { tmpdir, homedir } from "node:os"; import { spawnSync } from "node:child_process"; import { MENU } from "../lib/menu.mjs"; // the /pop directory on the public mirror const REPO_URL = "https://github.com/whistlegraph/aesthetic-computer/tree/main/pop"; // ── args ────────────────────────────────────────────────────────────── const argv = process.argv.slice(2); let out = join(homedir(), "Desktop", "pop-menu.pdf"); for (let i = 0; i < argv.length; i++) { if (argv[i] === "--out" && argv[i + 1]) out = resolve(argv[++i]); } const pngOut = out.replace(/\.pdf$/i, "") + ".png"; const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">"); // ── per-section accent colors ───────────────────────────────────────── const SECTION_COLORS = { instruments: "#b5331f", // brick red fx: "#c0631a", // burnt orange percussion: "#9a7d12", // ochre beds: "#1f7a5a", // teal-green master: "#1d5b8a", // steel blue pitch_time: "#6b3fa0", // violet analysis: "#1d7a8a", // deep cyan vocal: "#a8327d", // magenta scales: "#3f7d2e", // leaf green forms: "#8a5223", // sienna modulation: "#2f6f7a", // cyan-slate score: "#5a6b1f", // olive sample_sources: "#6b5546", // taupe }; const SECTION_TITLES = { instruments: "instruments — tonal voices", fx: "fx — buffer transforms", percussion: "percussion — AC one-shot kit", beds: "beds — continuous textures", master: "master — bus & glue chain", pitch_time: "pitch & time — offline transforms", analysis: "analysis — audio → control signals", vocal: "vocal — TTS, alignment, placement", scales: "scales — interval sets", forms: "forms — arrangement templates", modulation: "modulation — envelopes & LFOs", score: "score — notation & converters", sample_sources: "sourced audio — provenance registry", }; // ── section renderers ───────────────────────────────────────────────── function rowsFor(section, items) { if (Array.isArray(items)) { return `
${items.map(esc).join(" · ")}
`; } if (section === "percussion") { return ( `
${items.voices.map(esc).join(" · ")}
` + `
${esc(items.note)}
` ); } if (section === "scales") { return Object.entries(items) .map( ([k, v]) => `
${esc(k)}` + `[${v.join(", ")}]
`, ) .join(""); } if (section === "sample_sources") { const block = (label, obj) => `
${esc(label)}
` + Object.entries(obj) .map(([k, v]) => { const meta = [v.lane, v.license].filter(Boolean).map(esc).join(" · "); return ( `
${esc(k)}` + `${esc(v.blurb || "")}` + (meta ? ` ${meta}` : "") + `
` ); }) .join(""); return block("source repos", items.repos) + block("samples used", items.used); } // generic object-of-defs return Object.entries(items) .map(([name, def]) => { const presets = def.presets ? ` (${def.presets.join(", ")})` : ""; const lane = def.lane ? `${esc(def.lane)}` : ""; const inlineMark = def.inline && !def.file ? `inline` : ""; return ( `
${esc(name + presets)}` + `${esc(def.blurb || "")} ${lane}${inlineMark}
` ); }) .join(""); } const sections = Object.entries(MENU) .filter(([section]) => section !== "proposed") // wishlist — see MENU-WISHLIST.md .map(([section, items]) => { const c = SECTION_COLORS[section] || "#1a1712"; // the longest section may flow across the column/page break const cls = section === "sample_sources" ? ' class="splittable"' : ""; return ( `

${esc(SECTION_TITLES[section] || section)}

` + rowsFor(section, items) + `` ); }) .join("\n"); // ── QR to the /pop repo directory ───────────────────────────────────── const qr = spawnSync( "qrencode", ["-o", "-", "-t", "PNG", "-s", "12", "-m", "1", REPO_URL], { maxBuffer: 1 << 20 }, ); if (qr.status !== 0) { console.error("qrencode failed — is it installed? (brew install qrencode)"); process.exit(1); } const qrDataUri = `data:image/png;base64,${qr.stdout.toString("base64")}`; const today = new Date().toISOString().slice(0, 10); const html = `
QR to /pop
scan for the
/pop source

the pop menu

pickable building blocks across /pop

${sections}
`; // ── render via headless Chrome ──────────────────────────────────────── const work = mkdtempSync(join(tmpdir(), "pop-menu-")); const htmlPath = join(work, "menu.html"); writeFileSync(htmlPath, html); const chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const res = spawnSync( chrome, [ "--headless", "--disable-gpu", "--no-pdf-header-footer", `--print-to-pdf=${out}`, `file://${htmlPath}`, ], { stdio: "inherit" }, ); if (res.status !== 0) { rmSync(work, { recursive: true, force: true }); console.error("chrome failed to render the menu PDF"); process.exit(1); } console.log(`pop menu → ${out}`); // ── PDF → PNG (pages stitched side-by-side into one image) ──────────── const ppm = spawnSync( "pdftoppm", ["-png", "-r", "200", out, join(work, "pg")], { stdio: "inherit" }, ); if (ppm.status === 0) { const pages = readdirSync(work) .filter((f) => f.startsWith("pg") && f.endsWith(".png")) .sort() .map((f) => join(work, f)); // trim each page to its content, then lay panels side-by-side like an // open folded menu, tops aligned, with a uniform cream border. const groups = pages.flatMap((p) => ["(", p, "-trim", "+repage", ")"]); const stitch = spawnSync( "magick", [ ...groups, "-background", "#f4efe2", "-gravity", "north", "+smush", "44", "-bordercolor", "#f4efe2", "-border", "44", pngOut, ], { stdio: "inherit" }, ); if (stitch.status === 0) console.log(`pop menu → ${pngOut}`); else console.error("magick failed to stitch the PNG"); } else { console.error("pdftoppm failed — skipping PNG"); } rmSync(work, { recursive: true, force: true });