Use official KaTeX assets for math rendering
This commit is contained in:
+37
-7
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface NormalizedConversation {
|
||||
sourceUrl: string;
|
||||
capturedAt: string;
|
||||
chatGptIconSrc?: string;
|
||||
katexCss?: string;
|
||||
messages: NormalizedMessage[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user