Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2// cards-convert.mjs — Convert two-column papers to cards format
3// Usage: node cards-convert.mjs arxiv-notepat/notepat.tex
4// node cards-convert.mjs all Convert all papers in PAPER_MAP
5//
6// Creates {base}-cards.tex from {base}.tex by:
7// 1. Replacing the preamble with ac-paper-cards setup
8// 2. Reformatting the title block to cards-style centered layout
9// 3. Keeping all body content intact (sections auto-break via cards.sty)
10
11import { readFileSync, writeFileSync, existsSync } from "fs";
12import { join, basename, dirname } from "path";
13import { execSync } from "child_process";
14
15const PAPERS_DIR = new URL(".", import.meta.url).pathname;
16
17const LANG_LABELS = { da: "Dansk", es: "Español", zh: "中文", ja: "日本語" };
18
19const PAPER_MAP = {
20 "arxiv-ac": { base: "ac", title: "\\acrandname{} '26", siteName: "aesthetic-computer-26-arxiv" },
21 "arxiv-api": { base: "api", title: "From \\texttt{setup()} to \\texttt{boot()}", siteName: "piece-api-26-arxiv" },
22 "arxiv-archaeology": { base: "archaeology", title: "Repository Archaeology", siteName: "repo-archaeology-26-arxiv" },
23 "arxiv-dead-ends": { base: "dead-ends", title: "Vestigial Features", siteName: "dead-ends-26-arxiv" },
24 "arxiv-diversity": { base: "diversity", title: "Citation Diversity Audit", siteName: "citation-diversity-audit-26" },
25 "arxiv-folk-songs": { base: "folk-songs", title: "Playable Folk Songs", siteName: "folk-songs-26-arxiv" },
26 "arxiv-goodiepal": { base: "goodiepal", title: "Radical Computer Art", siteName: "radical-computer-art-26-arxiv" },
27 "arxiv-kidlisp": { base: "kidlisp", title: "Kid{\\color{acpurple}Lisp} '26", siteName: "kidlisp-26-arxiv" },
28 "arxiv-kidlisp-reference": { base: "kidlisp-reference", title: "KidLisp Language Reference", siteName: "kidlisp-reference-26-arxiv" },
29 "arxiv-network-audit": { base: "network-audit", title: "Network Audit", siteName: "network-audit-26-arxiv" },
30 "arxiv-notepat": { base: "notepat", title: "notepat{\\color{acpurple}.}{\\color{acpink}com}", siteName: "notepat-26-arxiv" },
31 "arxiv-os": { base: "os", title: "AC Native OS '26", siteName: "ac-native-os-26-arxiv" },
32 "arxiv-pieces": { base: "pieces", title: "Pieces Not Programs", siteName: "pieces-not-programs-26-arxiv" },
33 "arxiv-plork": { base: "plork", title: "PLOrk'ing the Planet", siteName: "plorking-the-planet-26-arxiv" },
34 "arxiv-sustainability": { base: "sustainability", title: "Who Pays for Creative Tools?", siteName: "who-pays-for-creative-tools-26-arxiv" },
35 "arxiv-whistlegraph": { base: "whistlegraph", title: "Whistlegraph", siteName: "whistlegraph-26-arxiv" },
36 "arxiv-complex": { base: "complex", title: "Sucking on the Complex", siteName: "sucking-on-the-complex-26-arxiv" },
37 "arxiv-kidlisp-cards": { base: "kidlisp-cards", title: "Kid{\\color{acpurple}Lisp} Cards", siteName: "kidlisp-cards-26-arxiv" },
38 "arxiv-score-analysis": { base: "score-analysis", title: "Reading the Score", siteName: "reading-the-score-26-arxiv" },
39 "arxiv-calarts": { base: "calarts", title: "CalArts, Callouts, and Papers", siteName: "calarts-callouts-papers-26-arxiv" },
40 "arxiv-open-schools": { base: "open-schools", title: "Get Closed Source Out of Schools", siteName: "open-schools-26-arxiv" },
41 "arxiv-futures": { base: "futures", title: "Five Years from Now", siteName: "five-years-from-now-26-arxiv" },
42 "arxiv-identity": { base: "identity", title: "Handle Identity on the AT Protocol", siteName: "handle-identity-atproto-26-arxiv" },
43 "arxiv-ucla-arts": { base: "ucla-arts", title: "Two Departments, One Building", siteName: "ucla-arts-funding-26-arxiv" },
44 "arxiv-holden": { base: "holden", title: "The Potter and the Prompt", siteName: "potter-and-prompt-26-arxiv" },
45 "arxiv-url-tradition": { base: "url-tradition", title: "The URL Tradition", siteName: "url-tradition-26-arxiv" },
46};
47
48function getAvailableTranslations(dir, info) {
49 return Object.fromEntries(
50 Object.entries(LANG_LABELS).filter(([code]) =>
51 existsSync(join(PAPERS_DIR, dir, `${info.base}-${code}.tex`))
52 )
53 );
54}
55
56// Convert tabularx to plain tabular for cards (adjustbox handles the scaling).
57// tabularx resists all runtime patching, but plain tabular wrapped in adjustbox works.
58function convertTabularxToTabular(body) {
59 // Replace \begin{tabularx}{...}{colspec} with \begin{tabular}{colspec}
60 // Convert X columns to p{0.3\linewidth} for wrapping
61 return body.replace(
62 /\\begin\{tabularx\}\{[^}]*\}\{([^}]*)\}/g,
63 (match, colspec) => {
64 // Replace X with p{} columns, keep l/r/c as-is
65 const newSpec = colspec.replace(/X/g, "p{0.28\\linewidth}");
66 return `\\begin{tabular}{${newSpec}}`;
67 }
68 ).replace(/\\end\{tabularx\}/g, "\\end{tabular}");
69}
70
71function extractFromTex(content) {
72 // Extract pdftitle
73 const pdftitleMatch = content.match(/pdftitle\s*=\s*\{([^}]+)\}/);
74 const pdftitle = pdftitleMatch ? pdftitleMatch[1] : "Untitled";
75
76 // Extract subtitle from \aclight\fontsize line
77 const subtitleMatch = content.match(
78 /\\aclight\\fontsize\{[^}]+\}\{[^}]+\}\\selectfont\\color\{acpink\}\s*(.+?)\s*\}\\par/
79 );
80 const subtitle = subtitleMatch ? subtitleMatch[1].trim() : null;
81
82 // Extract graphicspath
83 const gpMatch = content.match(/\\graphicspath\{\{([^}]+)\}\}/);
84 const graphicspath = gpMatch ? gpMatch[1] : "figures/";
85
86 // Check for listings
87 const hasListings = content.includes("\\usepackage{listings}") || content.includes("\\begin{lstlisting}");
88
89 // Check for CJK
90 const hasCJK = content.includes("\\usepackage{xeCJK}");
91 const cjkFontMatch = content.match(/\\setCJKmainfont\{([^}]+)\}/);
92 const cjkFont = cjkFontMatch ? cjkFontMatch[1] : "Droid Sans Fallback";
93
94 // Check for KidLisp-specific fonts
95 const hasKidlispFonts = content.includes("kidlispbold") || content.includes("kidlispfont");
96
97 // Find body start
98 const bodyStart = content.indexOf("\\begin{document}");
99 const bodyEnd = content.indexOf("\\end{document}");
100
101 if (bodyStart === -1 || bodyEnd === -1) return null;
102
103 // Extract body content after \begin{document}
104 let body = content.substring(bodyStart + "\\begin{document}".length, bodyEnd).trim();
105
106 // Remove the existing title block (everything before the first \section)
107 const firstSection = body.search(/\\section\{/);
108 let titleContent = "";
109 let mainBody = body;
110
111 if (firstSection > 0) {
112 titleContent = body.substring(0, firstSection).trim();
113 mainBody = body.substring(firstSection).trim();
114 }
115
116 // Extract abstract from title content — only use \begin{abstract}...\end{abstract}
117 let abstract = "";
118 const abstractMatch = titleContent.match(
119 /\\begin\{abstract\}([\s\S]*?)\\end\{abstract\}/
120 );
121 if (abstractMatch) {
122 abstract = abstractMatch[1].trim();
123 }
124
125 // Extract extra \newcommand definitions from the preamble (before \begin{document})
126 const preamble = content.substring(0, bodyStart);
127 const extraCommands = [];
128 // Match \newcommand{\acos} and similar custom commands (not \ac, \np, \acrandname which are in the sty)
129 const cmdRe = /^(\\newcommand\{\\(?!ac\b|np\b|acdot\b|acrandletter\b|acrandname\b)[a-zA-Z]+\}.*)$/gm;
130 let cmdMatch;
131 while ((cmdMatch = cmdRe.exec(preamble)) !== null) {
132 extraCommands.push(cmdMatch[1]);
133 }
134
135 // Extract extra \definecolor lines not already in ac-paper-cards.sty
136 // (sty defines: acpink, acpurple, acdark, acgray, aclight-bg, accard, draftcolor)
137 const styColors = new Set(["acpink", "acpurple", "acdark", "acgray", "aclight-bg", "accard", "draftcolor"]);
138 const colorRe = /^(\\definecolor\{([^}]+)\}.*)$/gm;
139 let colorMatch;
140 while ((colorMatch = colorRe.exec(preamble)) !== null) {
141 if (!styColors.has(colorMatch[2])) {
142 extraCommands.push(colorMatch[1]);
143 }
144 }
145
146 // Extract extra graphicspath entries (some papers reference other paper's figures)
147 // Use greedy match to handle nested braces like {{figures/}{../../papers/arxiv-ac/figures/}}
148 const multiGpMatch = content.match(/\\graphicspath\{(.+)\}/);
149 const fullGraphicspath = multiGpMatch ? multiGpMatch[1] : `{${graphicspath}}`;
150
151 // Extract lstdefinestyle and lstset blocks (brace-balanced)
152 const lstStyles = [];
153 const lstBlockRe = /\\(?:lstdefinestyle|lstset|lstdefinelanguage)\b/g;
154 let lstMatch;
155 while ((lstMatch = lstBlockRe.exec(preamble)) !== null) {
156 // Find the opening { of the definition body and balance braces
157 let pos = lstMatch.index + lstMatch[0].length;
158 // For lstdefinestyle/lstdefinelanguage, skip the {name} part first
159 if (lstMatch[0] !== "\\lstset") {
160 const nameStart = preamble.indexOf("{", pos);
161 if (nameStart === -1) continue;
162 const nameEnd = preamble.indexOf("}", nameStart);
163 if (nameEnd === -1) continue;
164 pos = nameEnd + 1;
165 }
166 const bodyStart = preamble.indexOf("{", pos);
167 if (bodyStart === -1) continue;
168 let depth = 1;
169 let i = bodyStart + 1;
170 while (i < preamble.length && depth > 0) {
171 if (preamble[i] === "{") depth++;
172 else if (preamble[i] === "}") depth--;
173 i++;
174 }
175 if (depth === 0) {
176 lstStyles.push(preamble.substring(lstMatch.index, i));
177 }
178 }
179
180 return {
181 pdftitle,
182 subtitle,
183 graphicspath,
184 fullGraphicspath,
185 hasListings,
186 hasCJK,
187 cjkFont,
188 hasKidlispFonts,
189 extraCommands,
190 lstStyles,
191 abstract,
192 mainBody,
193 titleContent,
194 };
195}
196
197function generateCardsTeX(dir, info, parsed) {
198 const extraPackages = [];
199 if (parsed.hasListings) extraPackages.push("\\usepackage{listings}");
200
201 const cjkBlock = parsed.hasCJK
202 ? `\\usepackage{xeCJK}\n\\setCJKmainfont{${parsed.cjkFont}}`
203 : "";
204
205 const kidlispFonts = parsed.hasKidlispFonts
206 ? `\\newfontfamily\\kidlispbold{ywft-processing-bold}[\n Path=../../system/public/type/webfonts/,\n Extension=.ttf\n]\n\\newfontfamily\\kidlispfont{ywft-processing-light}[\n Path=../../system/public/type/webfonts/,\n Extension=.ttf\n]`
207 : "";
208
209 const title = info.title || parsed.pdftitle;
210 const subtitle = parsed.subtitle || "";
211
212 // Git hash for revision stamp
213 let gitHash = "unknown";
214 try { gitHash = execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim(); } catch (_) {}
215
216 // Translation links for title card
217 const cjkLangs = new Set(["zh", "ja", "ko"]);
218 const translations = getAvailableTranslations(dir, info);
219 const translationLinks = Object.keys(translations).length > 0
220 ? Object.entries(translations)
221 .map(([code, label]) => {
222 const displayLabel = cjkLangs.has(code) ? `{\\accjk ${label}}` : label;
223 return `\\href{https://papers.aesthetic.computer/${info.siteName}-${code}.pdf}{${displayLabel}}`;
224 })
225 .join(" · ")
226 : "";
227
228 // Extra custom commands from the base .tex preamble
229 const extraCmds = parsed.extraCommands.length > 0
230 ? "\n% Extra commands from base paper\n" + parsed.extraCommands.join("\n")
231 : "";
232
233 // Custom listing styles from the base .tex preamble
234 const lstStyleBlock = parsed.lstStyles.length > 0
235 ? "\n" + parsed.lstStyles.join("\n")
236 : "";
237
238 const abstractCard = parsed.abstract
239 ? `% ============================================================
240% ABSTRACT CARD
241% ============================================================
242\\clearpage
243\\begin{accentcard}
244\\cardtitle{Abstract}
245
246${parsed.abstract}
247\\end{accentcard}
248
249`
250 : "";
251
252 return `% !TEX program = xelatex
253% Cards format — auto-generated from ${info.base}.tex by cards-convert.mjs
254\\documentclass[11pt]{article}
255
256\\usepackage{fontspec}
257\\usepackage{unicode-math}
258\\setmainfont{Latin Modern Roman}
259\\setsansfont{Latin Modern Sans}
260\\setmonofont{Latin Modern Mono}[Scale=0.88]
261${cjkBlock ? "\n" + cjkBlock : ""}
262
263\\usepackage{graphicx}
264\\graphicspath{${parsed.fullGraphicspath}}
265\\usepackage{booktabs}
266\\usepackage{tabularx}
267\\usepackage{ragged2e}
268\\usepackage{microtype}
269\\usepackage{natbib}
270${extraPackages.join("\n")}
271${kidlispFonts ? "\n" + kidlispFonts : ""}${lstStyleBlock}
272
273\\makeatletter
274\\def\\input@path{{../}}
275\\makeatother
276\\usepackage{ac-paper-cards}
277${extraCmds}
278
279\\hypersetup{
280 pdftitle={${parsed.pdftitle}},
281}
282
283\\renewcommand{\\acpdfbase}{${info.siteName}}
284\\begin{document}
285
286% ============================================================
287% TITLE CARD
288% ============================================================
289\\thispagestyle{empty}
290\\vspace*{\\fill}
291\\begin{center}
292\\href{https://papers.aesthetic.computer}{\\includegraphics[height=9em]{pals}}\\par\\vspace{0.1em}
293{\\acbold\\fontsize{18pt}{22pt}\\selectfont\\color{acdark} ${title}}\\par
294\\vspace{0.1em}
295${subtitle ? `{\\fontsize{9pt}{11pt}\\selectfont\\color{acpink} ${subtitle}}\\par\n\\vspace{0.4em}` : "\\vspace{0.3em}"}
296{\\normalsize\\color{cyan!70!blue}\\href{https://prompt.ac/@jeffrey}{\\textbf{@jeffrey}}}\\par
297{\\small\\color{acgray} Aesthetic.Computer}\\par
298{\\small\\color{acgray} ORCID: \\href{https://orcid.org/0009-0007-4460-4913}{0009-0007-4460-4913}}\\par
299\\vspace{0.4em}
300\\rule{0.5\\textwidth}{0.5pt}\\par
301\\vspace{0.15em}
302\\colorbox{yellow!60}{\\small\\color{red!80!black}\\textbf{\\textit{working draft --- not for citation}}}\\par
303\\vspace{0.1em}
304{\\footnotesize\\color{acgray} March 2026 · \\href{https://github.com/whistlegraph/aesthetic-computer/commit/${gitHash}}{${gitHash}}}\\par${translationLinks ? `
305\\vspace{0.1em}
306{\\footnotesize\\color{acgray}${translationLinks}}\\par` : ""}
307\\end{center}
308\\vspace*{\\fill}
309
310% ============================================================
311% INDEX CARD
312% ============================================================
313\\cardindex
314
315${abstractCard}% ============================================================
316% BODY
317% ============================================================
318${convertTabularxToTabular(parsed.mainBody)}
319
320\\end{document}
321`;
322}
323
324function convertPaper(dirName) {
325 const info = PAPER_MAP[dirName];
326 if (!info) {
327 console.error(` Unknown paper: ${dirName}`);
328 return false;
329 }
330
331 const texPath = join(PAPERS_DIR, dirName, `${info.base}.tex`);
332 const outPath = join(PAPERS_DIR, dirName, `${info.base}-cards.tex`);
333
334 if (!existsSync(texPath)) {
335 console.error(` NOT FOUND: ${texPath}`);
336 return false;
337 }
338
339 const content = readFileSync(texPath, "utf8");
340 const parsed = extractFromTex(content);
341
342 if (!parsed) {
343 console.error(` PARSE FAIL: ${texPath}`);
344 return false;
345 }
346
347 const cardsTeX = generateCardsTeX(dirName, info, parsed);
348 writeFileSync(outPath, cardsTeX, "utf8");
349 console.log(` WROTE ${dirName}/${info.base}-cards.tex`);
350 return true;
351}
352
353// --- CLI ---
354const target = process.argv[2];
355
356if (!target) {
357 console.log("Usage: node cards-convert.mjs <dir-name|all>");
358 console.log(" node cards-convert.mjs arxiv-notepat");
359 console.log(" node cards-convert.mjs all");
360 process.exit(0);
361}
362
363if (target === "all") {
364 console.log("\nConverting all papers to cards format...\n");
365 let ok = 0;
366 for (const dir of Object.keys(PAPER_MAP)) {
367 if (convertPaper(dir)) ok++;
368 }
369 console.log(`\nDone: ${ok}/${Object.keys(PAPER_MAP).length} converted.\n`);
370} else {
371 convertPaper(target);
372}