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
+37 -7
View File
@@ -70,22 +70,52 @@ function contentTypeForAsset(filename: string): string {
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
if (ext === ".gif") return "image/gif";
if (ext === ".woff2") return "font/woff2";
if (ext === ".woff") return "font/woff";
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> {
const html = await fs.readFile(artifactIndexPath(slug), "utf8");
const $ = cheerio.load(html);
for (const image of $("img").toArray()) {
const src = $(image).attr("src");
if (!src || src.startsWith("data:") || /^https?:\/\//i.test(src)) continue;
const normalized = path.normalize(src).replace(/^(\.\.(\/|\\|$))+/, "");
if (!normalized.startsWith("assets/")) continue;
const assetPath = path.join(artifactDir(slug), normalized);
const bytes = await fs.readFile(assetPath);
const data = `data:${contentTypeForAsset(assetPath)};base64,${bytes.toString("base64")}`;
$(image).attr("src", data);
if (!src) continue;
const data = await assetDataUri(slug, src).catch(() => null);
if (data) $(image).attr("src", data);
}
for (const style of $("style").toArray()) {
const css = $(style).html();
if (!css) continue;
$(style).html(await inlineCssAssetUrls(slug, css));
}
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 { slugify } from "./slug.js";
import { writeConversationArtifact, writeFailureArtifact } from "./render.js";
import { finalizeKatexAssets, prepareKatexAssets } from "./katex-assets.js";
import type { CaptureRow, NormalizedConversation, NormalizedMessage, ScreenshotVariant } from "./types.js";
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 });
try {
const katexFonts = new Set<string>();
const isMobile = variant.device === "mobile";
const context = await browser.newContext({
viewport: isMobile ? { width: 430, height: 932 } : { width: 1360, height: 900 },
@@ -630,6 +632,16 @@ async function generateScreenshotVariant(slug: string, variant: ScreenshotVarian
isMobile
});
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}`;
await page.goto(fileUrl, { waitUntil: "load", timeout: 60_000 });
const height = await page.evaluate(() => Math.ceil(document.documentElement.scrollHeight));
@@ -643,12 +655,13 @@ async function generateScreenshotVariant(slug: string, variant: ScreenshotVarian
type: "png"
});
await context.close();
return [...katexFonts].sort();
} finally {
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[] = [
{ device: "desktop", theme: "light" },
{ device: "desktop", theme: "dark" },
@@ -657,16 +670,18 @@ async function generateScreenshots(slug: string): Promise<{ generated: string[];
];
const generated: string[] = [];
const failed: string[] = [];
const katexFonts = new Set<string>();
for (const variant of variants) {
const name = `${variant.device}-${variant.theme}`;
try {
await generateScreenshotVariant(slug, variant);
const usedFonts = await generateScreenshotVariant(slug, variant);
usedFonts.forEach((fontFile) => katexFonts.add(fontFile));
generated.push(name);
} catch (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> {
@@ -690,8 +705,11 @@ export async function runCapture(row: CaptureRow): Promise<void> {
store.updateStatus(row.id, "rendering");
conversation.chatGptIconSrc = (await downloadAsset(chatGptIconUrl, slug).catch(() => null)) || chatGptIconUrl;
const assetResult = await localizeAssets(conversation, slug);
const preparedKatexFonts = await prepareKatexAssets(slug, conversation);
await writeConversationArtifact(slug, conversation);
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, {
slug,
@@ -699,7 +717,11 @@ export async function runCapture(row: CaptureRow): Promise<void> {
dataJson: JSON.stringify(conversation),
metadataJson: JSON.stringify({
assetFailures: assetResult.failures,
screenshots
screenshots,
katexFonts: {
prepared: preparedKatexFonts.length,
kept: keptKatexFonts
}
})
});
} 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>`
};
function css(): string {
return `
function css(extraCss = ""): string {
return `${extraCss}
:root {
color-scheme: light dark;
--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 { 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 .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 { 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 .katex-display { max-width: 100%; padding: 14px 16px; overflow-x: auto; overflow-y: hidden; border-radius: 8px; background: var(--soft); }
.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] 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">
<title>${title}</title>
<script>${initialThemeScript()}</script>
<style>${css()}</style>
<style>${css(conversation.katexCss || "")}</style>
</head>
<body>
<div class="page">
+1
View File
@@ -23,6 +23,7 @@ export interface NormalizedConversation {
sourceUrl: string;
capturedAt: string;
chatGptIconSrc?: string;
katexCss?: string;
messages: NormalizedMessage[];
}