Use official KaTeX assets for math rendering

This commit is contained in:
Codex
2026-07-08 10:01:17 +00:00
parent 9ddc369536
commit a25dfd4d1e
7 changed files with 181 additions and 30 deletions
+26
View File
@@ -13,6 +13,7 @@
"cheerio": "1.0.0", "cheerio": "1.0.0",
"express": "^4.19.2", "express": "^4.19.2",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"katex": "^0.17.0",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"playwright": "1.61.1", "playwright": "1.61.1",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
@@ -1403,6 +1404,15 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
}, },
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/compress-commons": { "node_modules/compress-commons": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
@@ -2182,6 +2192,22 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/katex": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz",
"integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/lazystream": { "node_modules/lazystream": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
+1
View File
@@ -15,6 +15,7 @@
"cheerio": "1.0.0", "cheerio": "1.0.0",
"express": "^4.19.2", "express": "^4.19.2",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"katex": "^0.17.0",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"playwright": "1.61.1", "playwright": "1.61.1",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
+37 -7
View File
@@ -70,22 +70,52 @@ function contentTypeForAsset(filename: string): string {
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp"; if (ext === ".webp") return "image/webp";
if (ext === ".gif") return "image/gif"; if (ext === ".gif") return "image/gif";
if (ext === ".woff2") return "font/woff2";
if (ext === ".woff") return "font/woff";
return "application/octet-stream"; return "application/octet-stream";
} }
async function assetDataUri(slug: string, assetReference: string): Promise<string | null> {
if (!assetReference || assetReference.startsWith("data:") || /^https?:\/\//i.test(assetReference)) return null;
const normalized = path.normalize(assetReference).replace(/^(\.\.(\/|\\|$))+/, "");
if (!normalized.startsWith("assets/")) return null;
const assetPath = path.join(artifactDir(slug), normalized);
const bytes = await fs.readFile(assetPath);
return `data:${contentTypeForAsset(assetPath)};base64,${bytes.toString("base64")}`;
}
async function inlineCssAssetUrls(slug: string, css: string): Promise<string> {
const urls = [...css.matchAll(/url\((["']?)([^)"']+)\1\)/g)]
.map((match) => match[2])
.filter((url) => url.startsWith("assets/"));
const replacements = new Map<string, string>();
await Promise.all(
[...new Set(urls)].map(async (url) => {
const dataUri = await assetDataUri(slug, url).catch(() => null);
if (dataUri) replacements.set(url, dataUri);
})
);
return css.replace(/url\((["']?)([^)"']+)\1\)/g, (match, quote: string, url: string) => {
const replacement = replacements.get(url);
return replacement ? `url("${replacement}")` : match;
});
}
export async function selfContainedHtml(slug: string): Promise<string> { export async function selfContainedHtml(slug: string): Promise<string> {
const html = await fs.readFile(artifactIndexPath(slug), "utf8"); const html = await fs.readFile(artifactIndexPath(slug), "utf8");
const $ = cheerio.load(html); const $ = cheerio.load(html);
for (const image of $("img").toArray()) { for (const image of $("img").toArray()) {
const src = $(image).attr("src"); const src = $(image).attr("src");
if (!src || src.startsWith("data:") || /^https?:\/\//i.test(src)) continue; if (!src) continue;
const normalized = path.normalize(src).replace(/^(\.\.(\/|\\|$))+/, ""); const data = await assetDataUri(slug, src).catch(() => null);
if (!normalized.startsWith("assets/")) continue; if (data) $(image).attr("src", data);
const assetPath = path.join(artifactDir(slug), normalized); }
const bytes = await fs.readFile(assetPath);
const data = `data:${contentTypeForAsset(assetPath)};base64,${bytes.toString("base64")}`; for (const style of $("style").toArray()) {
$(image).attr("src", data); const css = $(style).html();
if (!css) continue;
$(style).html(await inlineCssAssetUrls(slug, css));
} }
return $.html(); return $.html();
+27 -5
View File
@@ -12,6 +12,7 @@ import { config } from "./config.js";
import { artifactAssetsDir, artifactIndexPath, safeAssetName, screenshotPath, screenshotsDir } from "./artifacts.js"; import { artifactAssetsDir, artifactIndexPath, safeAssetName, screenshotPath, screenshotsDir } from "./artifacts.js";
import { slugify } from "./slug.js"; import { slugify } from "./slug.js";
import { writeConversationArtifact, writeFailureArtifact } from "./render.js"; import { writeConversationArtifact, writeFailureArtifact } from "./render.js";
import { finalizeKatexAssets, prepareKatexAssets } from "./katex-assets.js";
import type { CaptureRow, NormalizedConversation, NormalizedMessage, ScreenshotVariant } from "./types.js"; import type { CaptureRow, NormalizedConversation, NormalizedMessage, ScreenshotVariant } from "./types.js";
interface ExtractedConversation { interface ExtractedConversation {
@@ -619,9 +620,10 @@ async function extractConversation(sourceUrl: string): Promise<ExtractedConversa
} }
} }
async function generateScreenshotVariant(slug: string, variant: ScreenshotVariant): Promise<void> { async function generateScreenshotVariant(slug: string, variant: ScreenshotVariant): Promise<string[]> {
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
try { try {
const katexFonts = new Set<string>();
const isMobile = variant.device === "mobile"; const isMobile = variant.device === "mobile";
const context = await browser.newContext({ const context = await browser.newContext({
viewport: isMobile ? { width: 430, height: 932 } : { width: 1360, height: 900 }, viewport: isMobile ? { width: 430, height: 932 } : { width: 1360, height: 900 },
@@ -630,6 +632,16 @@ async function generateScreenshotVariant(slug: string, variant: ScreenshotVarian
isMobile isMobile
}); });
const page = await context.newPage(); const page = await context.newPage();
page.on("request", (request) => {
try {
const url = new URL(request.url());
if (!url.pathname.includes("/assets/katex/")) return;
const filename = path.basename(url.pathname);
if (/\.woff2$/i.test(filename)) katexFonts.add(filename);
} catch {
return;
}
});
const fileUrl = `file://${artifactIndexPath(slug)}?theme=${variant.theme}`; const fileUrl = `file://${artifactIndexPath(slug)}?theme=${variant.theme}`;
await page.goto(fileUrl, { waitUntil: "load", timeout: 60_000 }); await page.goto(fileUrl, { waitUntil: "load", timeout: 60_000 });
const height = await page.evaluate(() => Math.ceil(document.documentElement.scrollHeight)); const height = await page.evaluate(() => Math.ceil(document.documentElement.scrollHeight));
@@ -643,12 +655,13 @@ async function generateScreenshotVariant(slug: string, variant: ScreenshotVarian
type: "png" type: "png"
}); });
await context.close(); await context.close();
return [...katexFonts].sort();
} finally { } finally {
await browser.close(); await browser.close();
} }
} }
async function generateScreenshots(slug: string): Promise<{ generated: string[]; failed: string[] }> { async function generateScreenshots(slug: string): Promise<{ generated: string[]; failed: string[]; katexFonts: string[] }> {
const variants: ScreenshotVariant[] = [ const variants: ScreenshotVariant[] = [
{ device: "desktop", theme: "light" }, { device: "desktop", theme: "light" },
{ device: "desktop", theme: "dark" }, { device: "desktop", theme: "dark" },
@@ -657,16 +670,18 @@ async function generateScreenshots(slug: string): Promise<{ generated: string[];
]; ];
const generated: string[] = []; const generated: string[] = [];
const failed: string[] = []; const failed: string[] = [];
const katexFonts = new Set<string>();
for (const variant of variants) { for (const variant of variants) {
const name = `${variant.device}-${variant.theme}`; const name = `${variant.device}-${variant.theme}`;
try { try {
await generateScreenshotVariant(slug, variant); const usedFonts = await generateScreenshotVariant(slug, variant);
usedFonts.forEach((fontFile) => katexFonts.add(fontFile));
generated.push(name); generated.push(name);
} catch (error) { } catch (error) {
failed.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); failed.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
} }
} }
return { generated, failed }; return { generated, failed, katexFonts: [...katexFonts].sort() };
} }
export async function runCapture(row: CaptureRow): Promise<void> { export async function runCapture(row: CaptureRow): Promise<void> {
@@ -690,8 +705,11 @@ export async function runCapture(row: CaptureRow): Promise<void> {
store.updateStatus(row.id, "rendering"); store.updateStatus(row.id, "rendering");
conversation.chatGptIconSrc = (await downloadAsset(chatGptIconUrl, slug).catch(() => null)) || chatGptIconUrl; conversation.chatGptIconSrc = (await downloadAsset(chatGptIconUrl, slug).catch(() => null)) || chatGptIconUrl;
const assetResult = await localizeAssets(conversation, slug); const assetResult = await localizeAssets(conversation, slug);
const preparedKatexFonts = await prepareKatexAssets(slug, conversation);
await writeConversationArtifact(slug, conversation); await writeConversationArtifact(slug, conversation);
const screenshots = await generateScreenshots(slug); const screenshots = await generateScreenshots(slug);
const keptKatexFonts = await finalizeKatexAssets(slug, conversation, screenshots.katexFonts);
if (keptKatexFonts.length > 0) await writeConversationArtifact(slug, conversation);
store.completeCapture(row.id, { store.completeCapture(row.id, {
slug, slug,
@@ -699,7 +717,11 @@ export async function runCapture(row: CaptureRow): Promise<void> {
dataJson: JSON.stringify(conversation), dataJson: JSON.stringify(conversation),
metadataJson: JSON.stringify({ metadataJson: JSON.stringify({
assetFailures: assetResult.failures, assetFailures: assetResult.failures,
screenshots screenshots,
katexFonts: {
prepared: preparedKatexFonts.length,
kept: keptKatexFonts
}
}) })
}); });
} catch (error) { } catch (error) {
+85
View File
@@ -0,0 +1,85 @@
import { createRequire } from "node:module";
import path from "node:path";
import fs from "fs-extra";
import { artifactAssetsDir } from "./artifacts.js";
import type { NormalizedConversation } from "./types.js";
const require = createRequire(import.meta.url);
const katexCssPath = require.resolve("katex/dist/katex.min.css");
const katexDistDir = path.dirname(katexCssPath);
const katexFontsDir = path.join(katexDistDir, "fonts");
const fontFacePattern = /@font-face\{[^}]*\}/g;
const woff2Pattern = /url\((?:["']?)fonts\/([^)"']+\.woff2)(?:["']?)\)/;
export function conversationUsesKatex(conversation: NormalizedConversation): boolean {
return conversation.messages.some((message) => /\bkatex(?:-|["'\s>])/.test(message.html));
}
function katexAssetDir(slug: string): string {
return path.join(artifactAssetsDir(slug), "katex");
}
async function readOfficialKatexCss(): Promise<string> {
return await fs.readFile(katexCssPath, "utf8");
}
function rewriteFontFace(block: string): { css: string; fontFile: string | null } {
const fontFile = block.match(woff2Pattern)?.[1] || null;
if (!fontFile) return { css: block, fontFile: null };
return {
css: block.replace(/src:[^;}]+(?=[;}])/, `src:url("assets/katex/${fontFile}") format("woff2")`),
fontFile
};
}
async function buildKatexCss(allowedFonts?: Set<string>): Promise<{ css: string; fonts: string[] }> {
const css = await readOfficialKatexCss();
const fonts = new Set<string>();
const rewritten = css.replace(fontFacePattern, (block) => {
const result = rewriteFontFace(block);
if (!result.fontFile) return block;
if (allowedFonts && !allowedFonts.has(result.fontFile)) return "";
fonts.add(result.fontFile);
return result.css;
});
return { css: rewritten, fonts: [...fonts].sort() };
}
async function copyFonts(slug: string, fontFiles: string[]): Promise<void> {
const destination = katexAssetDir(slug);
await fs.emptyDir(destination);
await Promise.all(
fontFiles.map(async (fontFile) => {
await fs.copyFile(path.join(katexFontsDir, fontFile), path.join(destination, fontFile));
})
);
}
export async function prepareKatexAssets(slug: string, conversation: NormalizedConversation): Promise<string[]> {
if (!conversationUsesKatex(conversation)) {
delete conversation.katexCss;
await fs.remove(katexAssetDir(slug));
return [];
}
const { css, fonts } = await buildKatexCss();
conversation.katexCss = css;
await copyFonts(slug, fonts);
return fonts;
}
export async function finalizeKatexAssets(
slug: string,
conversation: NormalizedConversation,
requestedFonts: Iterable<string>
): Promise<string[]> {
if (!conversation.katexCss) return [];
const available = new Set(await fs.readdir(katexFontsDir));
const used = new Set([...requestedFonts].filter((fontFile) => available.has(fontFile)));
if (used.size === 0) return [];
const { css, fonts } = await buildKatexCss(used);
conversation.katexCss = css;
await copyFonts(slug, fonts);
return fonts;
}
+4 -18
View File
@@ -25,8 +25,8 @@ const icons = {
arrowDown: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 10 6 6 6-6"/><path d="M12 4v12"/></svg>` arrowDown: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 10 6 6 6-6"/><path d="M12 4v12"/></svg>`
}; };
function css(): string { function css(extraCss = ""): string {
return ` return `${extraCss}
:root { :root {
color-scheme: light dark; color-scheme: light dark;
--bg: #ffffff; --bg: #ffffff;
@@ -113,21 +113,7 @@ h1 { margin: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.12; letter-spa
.content math { color: inherit; font-family: math, "STIX Two Math", "Cambria Math", "Noto Sans Math", serif; font-size: 1.02em; } .content math { color: inherit; font-family: math, "STIX Two Math", "Cambria Math", "Noto Sans Math", serif; font-size: 1.02em; }
.content > math { display: block; max-width: 100%; margin: 0.95em 0; overflow-x: auto; overflow-y: hidden; } .content > math { display: block; max-width: 100%; margin: 0.95em 0; overflow-x: auto; overflow-y: hidden; }
.content p math, .content li math { vertical-align: -0.08em; } .content p math, .content li math { vertical-align: -0.08em; }
.content .katex { font: normal 1.18em/1.2 "Times New Roman", "STIX Two Math", "Cambria Math", "Noto Sans Math", serif; text-indent: 0; white-space: nowrap; } .content .katex-display { max-width: 100%; padding: 14px 16px; overflow-x: auto; overflow-y: hidden; border-radius: 8px; background: var(--soft); }
.content .katex-display { display: block; max-width: 100%; margin: 1em 0; padding: 14px 16px; overflow-x: auto; overflow-y: hidden; border-radius: 8px; background: var(--soft); text-align: center; }
.content .katex-display > .katex { display: inline-block; text-align: initial; }
.content .katex-mathml { position: absolute; width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip: rect(1px, 1px, 1px, 1px); white-space: nowrap; }
.content .katex-html { display: inline-block; }
.content .katex .base { position: relative; display: inline-block; white-space: nowrap; }
.content .katex .strut { display: inline-block; }
.content .katex .mord, .content .katex .mop, .content .katex .mbin, .content .katex .mrel, .content .katex .mopen, .content .katex .mclose, .content .katex .mpunct, .content .katex .minner { display: inline-block; }
.content .katex .mspace { display: inline-block; }
.content .katex .vlist-t { display: inline-table; table-layout: fixed; border-collapse: collapse; }
.content .katex .vlist-r { display: table-row; }
.content .katex .vlist { display: table-cell; position: relative; vertical-align: bottom; }
.content .katex .vlist > span { display: block; height: 0; position: relative; }
.content .katex .pstrut { display: block; overflow: hidden; width: 0; }
.content .katex .frac-line { display: inline-block; width: 100%; border-bottom: 0.04em solid currentColor; }
.content a[data-citation] { display: inline-flex; align-items: center; gap: 5px; max-width: min(100%, 260px); min-height: 24px; margin: 0 2px; padding: 2px 8px 2px 5px; border: 1px solid var(--border); border-radius: 999px; background: var(--soft); color: var(--text); font-size: 0.88em; line-height: 1.25; white-space: nowrap; text-decoration: none; vertical-align: -5px; } .content a[data-citation] { display: inline-flex; align-items: center; gap: 5px; max-width: min(100%, 260px); min-height: 24px; margin: 0 2px; padding: 2px 8px 2px 5px; border: 1px solid var(--border); border-radius: 999px; background: var(--soft); color: var(--text); font-size: 0.88em; line-height: 1.25; white-space: nowrap; text-decoration: none; vertical-align: -5px; }
.content a[data-citation]:hover { background: color-mix(in srgb, var(--soft) 82%, var(--text) 18%); } .content a[data-citation]:hover { background: color-mix(in srgb, var(--soft) 82%, var(--text) 18%); }
.content a[data-citation] img { width: 16px; height: 16px; max-width: 16px; max-height: 16px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; } .content a[data-citation] img { width: 16px; height: 16px; max-width: 16px; max-height: 16px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
@@ -298,7 +284,7 @@ export function renderConversationPage(slug: string, conversation: NormalizedCon
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
<title>${title}</title> <title>${title}</title>
<script>${initialThemeScript()}</script> <script>${initialThemeScript()}</script>
<style>${css()}</style> <style>${css(conversation.katexCss || "")}</style>
</head> </head>
<body> <body>
<div class="page"> <div class="page">
+1
View File
@@ -23,6 +23,7 @@ export interface NormalizedConversation {
sourceUrl: string; sourceUrl: string;
capturedAt: string; capturedAt: string;
chatGptIconSrc?: string; chatGptIconSrc?: string;
katexCss?: string;
messages: NormalizedMessage[]; messages: NormalizedMessage[];
} }