From a25dfd4d1e7038b04f9b2ff2bff42c076de8eff2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 8 Jul 2026 10:01:17 +0000 Subject: [PATCH] Use official KaTeX assets for math rendering --- package-lock.json | 26 ++++++++++++++ package.json | 1 + src/artifacts.ts | 44 +++++++++++++++++++---- src/capture.ts | 32 ++++++++++++++--- src/katex-assets.ts | 85 +++++++++++++++++++++++++++++++++++++++++++++ src/render.ts | 22 +++--------- src/types.ts | 1 + 7 files changed, 181 insertions(+), 30 deletions(-) create mode 100644 src/katex-assets.ts diff --git a/package-lock.json b/package-lock.json index e2a5af9..1e1773c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "cheerio": "1.0.0", "express": "^4.19.2", "fs-extra": "^11.2.0", + "katex": "^0.17.0", "nanoid": "^5.0.7", "playwright": "1.61.1", "sanitize-filename": "^1.6.3", @@ -1403,6 +1404,15 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "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": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -2182,6 +2192,22 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", diff --git a/package.json b/package.json index 976514a..5f56522 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "cheerio": "1.0.0", "express": "^4.19.2", "fs-extra": "^11.2.0", + "katex": "^0.17.0", "nanoid": "^5.0.7", "playwright": "1.61.1", "sanitize-filename": "^1.6.3", diff --git a/src/artifacts.ts b/src/artifacts.ts index 2dd9f2b..e203e85 100644 --- a/src/artifacts.ts +++ b/src/artifacts.ts @@ -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 { + 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 { + const urls = [...css.matchAll(/url\((["']?)([^)"']+)\1\)/g)] + .map((match) => match[2]) + .filter((url) => url.startsWith("assets/")); + const replacements = new Map(); + 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 { 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(); diff --git a/src/capture.ts b/src/capture.ts index 5effb2c..b4fa27c 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -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 { +async function generateScreenshotVariant(slug: string, variant: ScreenshotVariant): Promise { const browser = await chromium.launch({ headless: true }); try { + const katexFonts = new Set(); 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(); 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 { @@ -690,8 +705,11 @@ export async function runCapture(row: CaptureRow): Promise { 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 { dataJson: JSON.stringify(conversation), metadataJson: JSON.stringify({ assetFailures: assetResult.failures, - screenshots + screenshots, + katexFonts: { + prepared: preparedKatexFonts.length, + kept: keptKatexFonts + } }) }); } catch (error) { diff --git a/src/katex-assets.ts b/src/katex-assets.ts new file mode 100644 index 0000000..220f896 --- /dev/null +++ b/src/katex-assets.ts @@ -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 { + 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): Promise<{ css: string; fonts: string[] }> { + const css = await readOfficialKatexCss(); + const fonts = new Set(); + 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 { + 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 { + 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 +): Promise { + 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; +} diff --git a/src/render.ts b/src/render.ts index dc3b8d8..9263a20 100644 --- a/src/render.ts +++ b/src/render.ts @@ -25,8 +25,8 @@ const icons = { arrowDown: `` }; -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 ${title} - +
diff --git a/src/types.ts b/src/types.ts index d056d99..346d8cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,7 @@ export interface NormalizedConversation { sourceUrl: string; capturedAt: string; chatGptIconSrc?: string; + katexCss?: string; messages: NormalizedMessage[]; }