Monorepo for Aesthetic.Computer aesthetic.computer
at main 372 lines 15 kB view raw
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}