#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import puppeteer from "puppeteer";
import QRCode from "qrcode";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const codes = process.argv.slice(2);
const cardCodes = codes.length ? codes : ["kl1", "24m", "duv"];
const sourceWrapColumn = 34;
const screenshotSize = 1000;
const ovenFrameDelayMs = 2200;
const browser = await puppeteer.launch({
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || "/usr/sbin/chromium-browser",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
for (const code of cardCodes) {
const cardData = await fetchCardData(code);
await captureScreenshot(browser, cardData);
await writeQrCode(cardData);
await writeCardTex(cardData);
renderPdf(cardData.code);
console.log(`wrote ${cardData.code}.pdf`);
}
await browser.close();
async function fetchCardData(code) {
const response = await fetch(
`https://aesthetic.computer/api/store-kidlisp?code=${encodeURIComponent(code)}`,
);
if (!response.ok) {
throw new Error(`Failed to fetch $${code}: ${response.status} ${response.statusText}`);
}
const payload = await response.json();
if (!payload.source || !payload.ipfsMedia?.thumbnailUri) {
throw new Error(`Missing source or thumbnail metadata for $${code}`);
}
return {
code,
source: payload.source,
};
}
async function captureScreenshot(browser, { code }) {
const screenshotPath = path.join(__dirname, `${code}-screenshot.png`);
const ovenUrl = new URL(
`/grab/webp/${screenshotSize}/${screenshotSize}/${encodeURIComponent(`$${code}`)}`,
"https://oven.aesthetic.computer",
);
ovenUrl.searchParams.set("duration", "2200");
ovenUrl.searchParams.set("fps", "8");
ovenUrl.searchParams.set("quality", "90");
ovenUrl.searchParams.set("density", "1");
ovenUrl.searchParams.set("nowait", "true");
ovenUrl.searchParams.set("source", "sosoft-card");
const page = await browser.newPage();
page.setDefaultTimeout(120000);
page.setDefaultNavigationTimeout(120000);
await page.setViewport({
width: screenshotSize,
height: screenshotSize,
deviceScaleFactor: 1,
});
await page.setContent(
`
`,
{ waitUntil: "domcontentloaded", timeout: 120000 },
);
await page.waitForFunction(() => {
const piece = document.getElementById("piece");
return piece && piece.complete && piece.naturalWidth > 0;
}, { timeout: 120000 });
await new Promise((resolve) => setTimeout(resolve, ovenFrameDelayMs));
await page.screenshot({
path: screenshotPath,
clip: { x: 0, y: 0, width: screenshotSize, height: screenshotSize },
});
await page.close();
}
async function writeQrCode({ code }) {
await QRCode.toFile(
path.join(__dirname, `qr-${code}.png`),
`https://aesthetic.computer/${code}`,
{
margin: 0,
width: 512,
color: {
dark: "#000000",
light: "#ffffff",
},
},
);
}
async function writeCardTex({ code, source }) {
const sourceRows = wrapSource(source)
.map((line) => `\\rule{0pt}{11pt}${escapeTex(line)}\\\\`)
.join("\n");
const tex = `% Generated KidLisp score card for $${code}
\\documentclass[12pt]{article}
\\usepackage[
paperwidth=2.75in,
paperheight=4.75in,
top=0.3in,
bottom=0.3in,
left=0.3in,
right=0.3in,
]{geometry}
\\usepackage{fontspec}
\\usepackage{xcolor}
\\usepackage{tikz}
\\usepackage{graphicx}
\\pagestyle{empty}
\\newfontfamily\\cardcodefont[
Path=fonts/,
Extension=.ttf,
UprightFont=ComicRelief-Regular,
BoldFont=ComicRelief-Bold,
]{ComicRelief-Regular}
\\newfontfamily\\cardlabelfont[
Path=fonts/,
Extension=.ttf,
UprightFont=ComicRelief-Bold,
BoldFont=ComicRelief-Bold,
FakeBold=1.8,
]{ComicRelief-Bold}
\\definecolor{ink}{RGB}{0,0,0}
\\newcommand{\\safetyinset}{0.22in}
\\newcommand{\\safewidth}{2.31in}
\\begin{document}
\\vspace*{-0.26in}
\\noindent\\hspace*{-0.08in}%
\\begin{tikzpicture}
\\clip (0,0) rectangle (\\safewidth,\\safewidth);
\\node[anchor=south west, inner sep=0pt] at (0,0)
{\\includegraphics[width=\\safewidth, height=\\safewidth]{${escapeTex(code)}-screenshot.png}};
\\draw[black, line width=2.4pt] (0,0) rectangle (\\safewidth,\\safewidth);
\\end{tikzpicture}\\par
\\vspace{0.03in}
\\begingroup\\offinterlineskip
\\cardcodefont\\bfseries\\fontsize{9.5pt}{10.5pt}\\selectfont\\color{ink}
\\noindent\\hspace*{-0.08in}\\begin{tabular}{@{}l@{}}
${sourceRows}
\\end{tabular}
\\endgroup
\\vspace*{\\fill}
\\begin{tikzpicture}[remember picture, overlay]
\\node[anchor=south east, inner sep=0pt]
at ([xshift=-0.23in, yshift=0.75in]current page.south east)
{\\colorbox{black}{%
\\rule[-1pt]{0pt}{6.5pt}%
\\kern-1pt{\\cardlabelfont\\bfseries\\fontsize{8pt}{8pt}\\selectfont\\color{white}\\$${escapeTex(code)}}\\kern-0.5pt%
}};
\\node[anchor=south east, inner sep=1pt, fill=black]
at ([xshift=-\\safetyinset, yshift=\\safetyinset]current page.south east)
{\\rule{0.48in}{0.48in}};
\\node[anchor=south east, inner sep=1pt, fill=white]
at ([xshift=-0.24in, yshift=0.24in]current page.south east)
{\\includegraphics[width=0.48in]{qr-${escapeTex(code)}.png}};
\\end{tikzpicture}
\\end{document}
`;
await fs.writeFile(path.join(__dirname, `${code}.tex`), tex);
}
function renderPdf(code) {
const args = ["-interaction=nonstopmode", "-halt-on-error", `${code}.tex`];
execFileSync("xelatex", args, { cwd: __dirname, stdio: "ignore" });
execFileSync("xelatex", args, { cwd: __dirname, stdio: "ignore" });
execFileSync(
"pdftoppm",
["-f", "1", "-singlefile", "-r", "200", "-png", `${code}.pdf`, code],
{ cwd: __dirname, stdio: "ignore" },
);
}
function wrapSource(source) {
return source
.split(/\n+/)
.flatMap((line) => wrapLine(line.trim()))
.filter(Boolean);
}
function wrapLine(line) {
if (line.length <= sourceWrapColumn) {
return [line];
}
const commaParts = line.split(/\s*,\s*/).filter(Boolean);
if (commaParts.length > 1) {
return commaParts.flatMap((part) => wrapWords(part));
}
return wrapWords(line);
}
function wrapWords(line) {
if (line.length <= sourceWrapColumn) {
return [line];
}
const wrapped = [];
let current = "";
for (const word of line.split(/\s+/)) {
const candidate = current ? `${current} ${word}` : word;
if (candidate.length > sourceWrapColumn && current) {
wrapped.push(current);
current = word;
} else {
current = candidate;
}
}
if (current) {
wrapped.push(current);
}
return wrapped;
}
function escapeTex(value) {
return value.replace(/[\\{}#$%&_~^]/g, (char) => {
switch (char) {
case "\\":
return "\\textbackslash{}";
case "{":
return "\\{";
case "}":
return "\\}";
case "#":
return "\\#";
case "$":
return "\\$";
case "%":
return "\\%";
case "&":
return "\\&";
case "_":
return "\\_";
case "~":
return "\\textasciitilde{}";
case "^":
return "\\textasciicircum{}";
default:
return char;
}
});
}