diff --git a/build.mjs b/build.mjs index fd6d7c053..5fd6f7146 100644 --- a/build.mjs +++ b/build.mjs @@ -540,10 +540,33 @@ async function ensureDevCssPlaceholders(cssFiles) { ) } +/** Remove unused font resources from a variant without math rendering. */ +async function removeKatexResources(outputDir) { + const manifestPath = path.join(outputDir, 'manifest.json') + const manifest = await fs.readJson(manifestPath) + const keepResource = (resource) => resource !== 'katex-fonts/*.woff2' + if (manifest.manifest_version === 3) { + manifest.web_accessible_resources = manifest.web_accessible_resources + .map((entry) => ({ ...entry, resources: entry.resources.filter(keepResource) })) + .filter((entry) => entry.resources.length > 0) + } else { + manifest.web_accessible_resources = manifest.web_accessible_resources.filter(keepResource) + } + await fs.writeJson(manifestPath, manifest, { spaces: 2 }) +} + +/** Copy and package the shared assets for Chromium and Firefox variants. */ async function finishOutput(outputDirSuffix, sourceBuildDir = outdir) { + const includesKatex = !outputDirSuffix.includes('without-katex') const commonFiles = [ { src: 'src/logo.png', dst: 'logo.png' }, { src: 'src/rules.json', dst: 'rules.json' }, + ...(includesKatex + ? [ + { src: 'src/components/MarkdownRender/katex-fonts.css', dst: 'katex-fonts.css' }, + { src: 'node_modules/katex/dist/fonts', dst: 'katex-fonts' }, + ] + : []), { src: `${sourceBuildDir}/shared.js`, dst: 'shared.js' }, { src: `${sourceBuildDir}/content-script.css`, dst: 'content-script.css' }, // shared @@ -585,6 +608,7 @@ async function finishOutput(outputDirSuffix, sourceBuildDir = outdir) { ), ), ) + if (!includesKatex) await removeKatexResources(chromiumOutputDir) if (isProduction) await zipFolder(chromiumOutputDir) // firefox @@ -602,6 +626,7 @@ async function finishOutput(outputDirSuffix, sourceBuildDir = outdir) { ), ), ) + if (!includesKatex) await removeKatexResources(firefoxOutputDir) if (isProduction) await zipFolder(firefoxOutputDir) } diff --git a/src/components/MarkdownRender/katex-fonts.css b/src/components/MarkdownRender/katex-fonts.css new file mode 100644 index 000000000..b9bf47033 --- /dev/null +++ b/src/components/MarkdownRender/katex-fonts.css @@ -0,0 +1,181 @@ +/* Loaded only when ChatGPTBox renders math. */ +@font-face { + font-display: swap; + font-family: KaTeX_AMS; + font-style: normal; + font-weight: 400; + src: url('katex-fonts/KaTeX_AMS-Regular.woff2') format('woff2'), + url('katex-fonts/KaTeX_AMS-Regular.woff') format('woff'), + url('katex-fonts/KaTeX_AMS-Regular.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Caligraphic; + font-style: normal; + font-weight: 700; + src: url('katex-fonts/KaTeX_Caligraphic-Bold.woff2') format('woff2'), + url('katex-fonts/KaTeX_Caligraphic-Bold.woff') format('woff'), + url('katex-fonts/KaTeX_Caligraphic-Bold.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Caligraphic; + font-style: normal; + font-weight: 400; + src: url('katex-fonts/KaTeX_Caligraphic-Regular.woff2') format('woff2'), + url('katex-fonts/KaTeX_Caligraphic-Regular.woff') format('woff'), + url('katex-fonts/KaTeX_Caligraphic-Regular.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Fraktur; + font-style: normal; + font-weight: 700; + src: url('katex-fonts/KaTeX_Fraktur-Bold.woff2') format('woff2'), + url('katex-fonts/KaTeX_Fraktur-Bold.woff') format('woff'), + url('katex-fonts/KaTeX_Fraktur-Bold.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Fraktur; + font-style: normal; + font-weight: 400; + src: url('katex-fonts/KaTeX_Fraktur-Regular.woff2') format('woff2'), + url('katex-fonts/KaTeX_Fraktur-Regular.woff') format('woff'), + url('katex-fonts/KaTeX_Fraktur-Regular.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Main; + font-style: normal; + font-weight: 700; + src: url('katex-fonts/KaTeX_Main-Bold.woff2') format('woff2'), + url('katex-fonts/KaTeX_Main-Bold.woff') format('woff'), + url('katex-fonts/KaTeX_Main-Bold.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Main; + font-style: italic; + font-weight: 700; + src: url('katex-fonts/KaTeX_Main-BoldItalic.woff2') format('woff2'), + url('katex-fonts/KaTeX_Main-BoldItalic.woff') format('woff'), + url('katex-fonts/KaTeX_Main-BoldItalic.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Main; + font-style: italic; + font-weight: 400; + src: url('katex-fonts/KaTeX_Main-Italic.woff2') format('woff2'), + url('katex-fonts/KaTeX_Main-Italic.woff') format('woff'), + url('katex-fonts/KaTeX_Main-Italic.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Main; + font-style: normal; + font-weight: 400; + src: url('katex-fonts/KaTeX_Main-Regular.woff2') format('woff2'), + url('katex-fonts/KaTeX_Main-Regular.woff') format('woff'), + url('katex-fonts/KaTeX_Main-Regular.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Math; + font-style: italic; + font-weight: 700; + src: url('katex-fonts/KaTeX_Math-BoldItalic.woff2') format('woff2'), + url('katex-fonts/KaTeX_Math-BoldItalic.woff') format('woff'), + url('katex-fonts/KaTeX_Math-BoldItalic.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Math; + font-style: italic; + font-weight: 400; + src: url('katex-fonts/KaTeX_Math-Italic.woff2') format('woff2'), + url('katex-fonts/KaTeX_Math-Italic.woff') format('woff'), + url('katex-fonts/KaTeX_Math-Italic.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_SansSerif; + font-style: normal; + font-weight: 700; + src: url('katex-fonts/KaTeX_SansSerif-Bold.woff2') format('woff2'), + url('katex-fonts/KaTeX_SansSerif-Bold.woff') format('woff'), + url('katex-fonts/KaTeX_SansSerif-Bold.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_SansSerif; + font-style: italic; + font-weight: 400; + src: url('katex-fonts/KaTeX_SansSerif-Italic.woff2') format('woff2'), + url('katex-fonts/KaTeX_SansSerif-Italic.woff') format('woff'), + url('katex-fonts/KaTeX_SansSerif-Italic.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_SansSerif; + font-style: normal; + font-weight: 400; + src: url('katex-fonts/KaTeX_SansSerif-Regular.woff2') format('woff2'), + url('katex-fonts/KaTeX_SansSerif-Regular.woff') format('woff'), + url('katex-fonts/KaTeX_SansSerif-Regular.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Script; + font-style: normal; + font-weight: 400; + src: url('katex-fonts/KaTeX_Script-Regular.woff2') format('woff2'), + url('katex-fonts/KaTeX_Script-Regular.woff') format('woff'), + url('katex-fonts/KaTeX_Script-Regular.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Size1; + font-style: normal; + font-weight: 400; + src: url('katex-fonts/KaTeX_Size1-Regular.woff2') format('woff2'), + url('katex-fonts/KaTeX_Size1-Regular.woff') format('woff'), + url('katex-fonts/KaTeX_Size1-Regular.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Size2; + font-style: normal; + font-weight: 400; + src: url('katex-fonts/KaTeX_Size2-Regular.woff2') format('woff2'), + url('katex-fonts/KaTeX_Size2-Regular.woff') format('woff'), + url('katex-fonts/KaTeX_Size2-Regular.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Size3; + font-style: normal; + font-weight: 400; + src: url('katex-fonts/KaTeX_Size3-Regular.woff2') format('woff2'), + url('katex-fonts/KaTeX_Size3-Regular.woff') format('woff'), + url('katex-fonts/KaTeX_Size3-Regular.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Size4; + font-style: normal; + font-weight: 400; + src: url('katex-fonts/KaTeX_Size4-Regular.woff2') format('woff2'), + url('katex-fonts/KaTeX_Size4-Regular.woff') format('woff'), + url('katex-fonts/KaTeX_Size4-Regular.ttf') format('truetype'); +} +@font-face { + font-display: swap; + font-family: KaTeX_Typewriter; + font-style: normal; + font-weight: 400; + src: url('katex-fonts/KaTeX_Typewriter-Regular.woff2') format('woff2'), + url('katex-fonts/KaTeX_Typewriter-Regular.woff') format('woff'), + url('katex-fonts/KaTeX_Typewriter-Regular.ttf') format('truetype'); +} diff --git a/src/components/MarkdownRender/katex-fonts.mjs b/src/components/MarkdownRender/katex-fonts.mjs new file mode 100644 index 000000000..b8d4a1d63 --- /dev/null +++ b/src/components/MarkdownRender/katex-fonts.mjs @@ -0,0 +1,135 @@ +import Browser from 'webextension-polyfill' + +const fontFiles = [ + 'KaTeX_AMS-Regular', + 'KaTeX_Caligraphic-Bold', + 'KaTeX_Caligraphic-Regular', + 'KaTeX_Fraktur-Bold', + 'KaTeX_Fraktur-Regular', + 'KaTeX_Main-Bold', + 'KaTeX_Main-BoldItalic', + 'KaTeX_Main-Italic', + 'KaTeX_Main-Regular', + 'KaTeX_Math-BoldItalic', + 'KaTeX_Math-Italic', + 'KaTeX_SansSerif-Bold', + 'KaTeX_SansSerif-Italic', + 'KaTeX_SansSerif-Regular', + 'KaTeX_Script-Regular', + 'KaTeX_Size1-Regular', + 'KaTeX_Size2-Regular', + 'KaTeX_Size3-Regular', + 'KaTeX_Size4-Regular', + 'KaTeX_Typewriter-Regular', +].map((filename) => { + const [family, variant] = filename.split('-') + return { + filename, + family, + style: variant.includes('Italic') ? 'italic' : 'normal', + weight: variant.includes('Bold') ? '700' : '400', + } +}) +const fontLoads = new WeakMap() + +/** Watch actual formula changes instead of rebuilt React child identities. */ +export function observeKatexFonts(root) { + let content + const update = () => { + const nextContent = root.outerHTML + if (nextContent === content) return + content = nextContent + loadKatexFonts(root) + } + const observer = new root.ownerDocument.defaultView.MutationObserver(update) + observer.observe(root, { + childList: true, + characterData: true, + attributes: true, + attributeFilter: ['class', 'style'], + subtree: true, + }) + update() + return () => observer.disconnect() +} + +function usedFonts(root) { + const document = root.ownerDocument + const html = root.querySelector('.katex-html') + const fonts = new Set() + if (!html) return fonts + + // Inspect rendered glyphs, not the hidden MathML accessibility copy. + const walker = document.createTreeWalker(html, 4 /* NodeFilter.SHOW_TEXT */) + while (walker.nextNode()) { + const text = walker.currentNode + if (!text.data.trim()) continue + const style = document.defaultView.getComputedStyle(text.parentElement) + const families = style.fontFamily.split(',').map((family) => family.trim().replace(/['"]/g, '')) + const family = families.find((name) => fontFiles.some((font) => font.family === name)) + const faces = fontFiles.filter((font) => font.family === family) + if (!faces.length) continue + + const requestedStyle = /italic|oblique/.test(style.fontStyle) ? 'italic' : 'normal' + const styledFaces = faces.filter((font) => font.style === requestedStyle) + const candidates = styledFaces.length ? styledFaces : faces + const weight = Number(style.fontWeight) > 500 || style.fontWeight === 'bold' ? '700' : '400' + fonts.add(candidates.find((font) => font.weight === weight) || candidates[0]) + } + return fonts +} + +async function loadFont(font, document) { + // Retry a transient failure once, even if the mounted formula does not change. + for (let attempt = 0; attempt < 2; attempt++) { + try { + const response = await fetch(Browser.runtime.getURL(`katex-fonts/${font.filename}.woff2`)) + if (!response.ok) throw new Error(`Failed to fetch ${font.filename}: ${response.status}`) + // Binary sources avoid the host page's font-src restriction. + const face = new FontFace(font.family, await response.arrayBuffer(), { + style: font.style, + weight: font.weight, + }) + await face.load() + document.fonts.add(face) + return + } catch (error) { + if (attempt === 1) throw error + await new Promise((resolve) => setTimeout(resolve, 250)) + } + } +} + +/** Load only faces used by a rendered KaTeX subtree; cache each face independently. */ +export function loadKatexFonts(root) { + if (!root) return Promise.resolve() + const document = root.ownerDocument + if (document.location.origin === new URL(Browser.runtime.getURL('/')).origin) { + if (!document.getElementById('chatgptbox-katex-fonts')) { + const stylesheet = document.createElement('link') + stylesheet.id = 'chatgptbox-katex-fonts' + stylesheet.rel = 'stylesheet' + stylesheet.href = Browser.runtime.getURL('katex-fonts.css') + document.head.appendChild(stylesheet) + } + return Promise.resolve() + } + + let cache = fontLoads.get(document) + if (!cache) { + cache = new Map() + fontLoads.set(document, cache) + } + return Promise.all( + [...usedFonts(root)].map((font) => { + if (!cache.has(font.filename)) { + const loading = loadFont(font, document).catch((error) => { + cache.delete(font.filename) + console.warn('[markdown] Failed to load KaTeX font', error) + }) + cache.set(font.filename, loading) + } + return cache.get(font.filename) + }), + ) +} diff --git a/src/components/MarkdownRender/markdown.jsx b/src/components/MarkdownRender/markdown.jsx index f9cb8eb3a..f2a9fc026 100644 --- a/src/components/MarkdownRender/markdown.jsx +++ b/src/components/MarkdownRender/markdown.jsx @@ -8,8 +8,29 @@ import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { Pre } from './Pre' import { Hyperlink } from './Hyperlink' -import { memo, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import PropTypes from 'prop-types' +import { observeKatexFonts } from './katex-fonts.mjs' + +/** Request fonts when KaTeX has produced a math span. */ +// eslint-disable-next-line no-unused-vars +function KatexSpan({ node, className, ...props }) { + const ref = useRef(null) + useEffect(() => { + if (className?.split(/\s+/).includes('katex')) { + return observeKatexFonts(ref.current) + } + }, [className]) + + return +} + +KatexSpan.propTypes = { + node: PropTypes.object, + className: PropTypes.string, + children: PropTypes.node, +} // eslint-disable-next-line const ThinkComponent = ({ node, children, ...props }) => { @@ -111,6 +132,7 @@ const ThinkComponent = ({ node, children, ...props }) => { ) } +/** Render Markdown with on-demand KaTeX fonts. */ export function MarkdownRender(props) { return (
@@ -194,6 +216,7 @@ export function MarkdownRender(props) { components={{ a: Hyperlink, pre: Pre, + span: KatexSpan, think: ThinkComponent, }} {...props} diff --git a/src/components/MarkdownRender/mykatex.min.css b/src/components/MarkdownRender/mykatex.min.css index cacdd2e94..6d2c7eca9 100644 --- a/src/components/MarkdownRender/mykatex.min.css +++ b/src/components/MarkdownRender/mykatex.min.css @@ -1,163 +1,3 @@ -@font-face { - font-family: KaTeX_AMS; - font-style: normal; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_AMS-Regular.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_AMS-Regular.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_AMS-Regular.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Caligraphic; - font-style: normal; - font-weight: 700; - src: url(/node_modules/katex/dist/fonts/KaTeX_Caligraphic-Bold.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Caligraphic-Bold.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Caligraphic-Bold.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Caligraphic; - font-style: normal; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_Caligraphic-Regular.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Caligraphic-Regular.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Caligraphic-Regular.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Fraktur; - font-style: normal; - font-weight: 700; - src: url(/node_modules/katex/dist/fonts/KaTeX_Fraktur-Bold.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Fraktur-Bold.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Fraktur-Bold.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Fraktur; - font-style: normal; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_Fraktur-Regular.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Fraktur-Regular.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Fraktur-Regular.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Main; - font-style: normal; - font-weight: 700; - src: url(/node_modules/katex/dist/fonts/KaTeX_Main-Bold.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Main-Bold.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Main-Bold.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Main; - font-style: italic; - font-weight: 700; - src: url(/node_modules/katex/dist/fonts/KaTeX_Main-BoldItalic.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Main-BoldItalic.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Main-BoldItalic.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Main; - font-style: italic; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_Main-Italic.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Main-Italic.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Main-Italic.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Main; - font-style: normal; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_Main-Regular.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Main-Regular.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Main-Regular.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Math; - font-style: italic; - font-weight: 700; - src: url(/node_modules/katex/dist/fonts/KaTeX_Math-BoldItalic.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Math-BoldItalic.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Math-BoldItalic.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Math; - font-style: italic; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_Math-Italic.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Math-Italic.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Math-Italic.ttf) format('truetype'); -} -@font-face { - font-family: 'KaTeX_SansSerif'; - font-style: normal; - font-weight: 700; - src: url(/node_modules/katex/dist/fonts/KaTeX_SansSerif-Bold.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_SansSerif-Bold.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_SansSerif-Bold.ttf) format('truetype'); -} -@font-face { - font-family: 'KaTeX_SansSerif'; - font-style: italic; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_SansSerif-Italic.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_SansSerif-Italic.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_SansSerif-Italic.ttf) format('truetype'); -} -@font-face { - font-family: 'KaTeX_SansSerif'; - font-style: normal; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_SansSerif-Regular.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_SansSerif-Regular.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_SansSerif-Regular.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Script; - font-style: normal; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_Script-Regular.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Script-Regular.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Script-Regular.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Size1; - font-style: normal; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_Size1-Regular.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Size1-Regular.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Size1-Regular.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Size2; - font-style: normal; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_Size2-Regular.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Size2-Regular.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Size2-Regular.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Size3; - font-style: normal; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_Size3-Regular.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Size3-Regular.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Size3-Regular.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Size4; - font-style: normal; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_Size4-Regular.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Size4-Regular.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Size4-Regular.ttf) format('truetype'); -} -@font-face { - font-family: KaTeX_Typewriter; - font-style: normal; - font-weight: 400; - src: url(/node_modules/katex/dist/fonts/KaTeX_Typewriter-Regular.woff2) format('woff2'), - url(/node_modules/katex/dist/fonts/KaTeX_Typewriter-Regular.woff) format('woff'), - url(/node_modules/katex/dist/fonts/KaTeX_Typewriter-Regular.ttf) format('truetype'); -} .katex { text-rendering: auto; font: normal 1.21em KaTeX_Main, Times New Roman, serif; diff --git a/src/manifest.json b/src/manifest.json index 85171df26..6a1c27d22 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -72,7 +72,8 @@ "web_accessible_resources": [ { "resources": [ - "logo.png" + "logo.png", + "katex-fonts/*.woff2" ], "matches": [ "" @@ -111,4 +112,4 @@ "description": "Close all chats in this page" } } -} \ No newline at end of file +} diff --git a/src/manifest.v2.json b/src/manifest.v2.json index 94f0d22b7..16afc0b91 100644 --- a/src/manifest.v2.json +++ b/src/manifest.v2.json @@ -56,7 +56,8 @@ } ], "web_accessible_resources": [ - "logo.png" + "logo.png", + "katex-fonts/*.woff2" ], "commands": { "newChat": { @@ -87,4 +88,4 @@ "description": "Close all chats in this page" } } -} \ No newline at end of file +} diff --git a/tests/unit/components/katex-fonts.test.mjs b/tests/unit/components/katex-fonts.test.mjs new file mode 100644 index 000000000..fbcc9ab2b --- /dev/null +++ b/tests/unit/components/katex-fonts.test.mjs @@ -0,0 +1,255 @@ +/* eslint-env node */ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { JSDOM } from 'jsdom' +import { + loadKatexFonts, + observeKatexFonts, +} from '../../../src/components/MarkdownRender/katex-fonts.mjs' + +const glyph = (family, text = 'x', style = 'normal', weight = '400') => { + const css = `font-family: ${family}; font-style: ${style}; font-weight: ${weight}` + return `${text}` +} +const simpleMath = glyph('KaTeX_Math', 'x', 'italic') + glyph('KaTeX_Main', '+1') + +function fixture(t, html = simpleMath, options = {}) { + const dom = new JSDOM( + `${glyph( + 'KaTeX_AMS', + )}${html}`, + { url: options.url || 'https://example.com' }, + ) + t.after(() => dom.window.close()) + const document = dom.window.document + const root = document.querySelector('.katex') + const requests = [] + const registered = [] + const warnings = [] + Object.defineProperty(document, 'fonts', { value: { add: (font) => registered.push(font) } }) + t.mock.method(globalThis, 'fetch', async (url) => { + const filename = url.split('/').at(-1) + requests.push(filename) + if (options.fetch) await options.fetch(filename) + const status = options.status?.(filename) ?? 200 + return { + ok: status >= 200 && status < 300, + status, + arrayBuffer: async () => new ArrayBuffer(1), + } + }) + const originalFontFace = Object.getOwnPropertyDescriptor(globalThis, 'FontFace') + globalThis.FontFace = class { + constructor(family, source, descriptors) { + this.family = family + this.source = source + Object.assign(this, descriptors) + } + async load() { + if (options.load) await options.load(this) + return this + } + } + t.after(() => { + if (originalFontFace) Object.defineProperty(globalThis, 'FontFace', originalFontFace) + else delete globalThis.FontFace + }) + t.mock.method(console, 'warn', (...args) => warnings.push(args)) + return { root, document, requests, registered, warnings } +} + +test('plain content and MathML-only output do not request fonts', async (t) => { + const f = fixture(t) + await loadKatexFonts(f.document.createElement('span')) + f.root.querySelector('.katex-html').remove() + await loadKatexFonts(f.root) + assert.deepEqual(f.requests, []) +}) + +test('simple math loads only the two visible faces, cached across renders', async (t) => { + const f = fixture(t, simpleMath + glyph('KaTeX_Main', '=2')) + await loadKatexFonts(f.root) + await loadKatexFonts(f.root) + assert.deepEqual(f.requests.sort(), ['KaTeX_Main-Regular.woff2', 'KaTeX_Math-Italic.woff2']) + assert.equal(f.registered.length, 2) + assert.ok(f.registered.every((font) => font.source instanceof ArrayBuffer)) +}) + +test('changed math adds new faces without fetching successful faces again', async (t) => { + const f = fixture(t) + await loadKatexFonts(f.root) + f.root.querySelector('.katex-html').innerHTML = + simpleMath + glyph('KaTeX_Size2', '∑') + glyph('KaTeX_Main', 'y', 'italic', '700') + await loadKatexFonts(f.root) + assert.deepEqual(f.requests.slice(2).sort(), [ + 'KaTeX_Main-BoldItalic.woff2', + 'KaTeX_Size2-Regular.woff2', + ]) + assert.equal(f.registered.length, 4) +}) + +test('concurrent formulas deduplicate requests and register a fast face immediately', async (t) => { + let finishSlowFont + let fastFontAdded + const slowFont = new Promise((resolve) => (finishSlowFont = resolve)) + const fastFont = new Promise((resolve) => (fastFontAdded = resolve)) + const f = fixture(t, simpleMath, { + load: async (font) => { + if (font.family === 'KaTeX_Math') await slowFont + }, + }) + const originalAdd = f.document.fonts.add + f.document.fonts.add = (font) => { + originalAdd(font) + if (font.family === 'KaTeX_Main') fastFontAdded() + } + const first = loadKatexFonts(f.root) + const second = loadKatexFonts(f.root) + await fastFont + assert.deepEqual( + f.registered.map((font) => font.family), + ['KaTeX_Main'], + ) + assert.equal(f.requests.length, 2) + finishSlowFont() + await Promise.all([first, second]) + assert.equal(f.registered.length, 2) +}) + +test('a transient fetch failure retries the failed face without another render', async (t) => { + let failed = false + const f = fixture(t, simpleMath, { + fetch: async (filename) => { + if (filename === 'KaTeX_Math-Italic.woff2' && !failed) { + failed = true + throw new Error('Transient fetch failure') + } + }, + }) + await loadKatexFonts(f.root) + assert.equal(f.requests.filter((name) => name === 'KaTeX_Main-Regular.woff2').length, 1) + assert.equal(f.requests.filter((name) => name === 'KaTeX_Math-Italic.woff2').length, 2) + assert.equal(f.registered.length, 2) + assert.deepEqual(f.warnings, []) +}) + +test('decode failures have bounded retries and allow independent recovery', async (t) => { + let fail = true + const f = fixture(t, simpleMath, { + load: async (font) => { + if (font.family === 'KaTeX_Math' && fail) throw new Error('Decode failed') + }, + }) + await loadKatexFonts(f.root) + assert.equal(f.requests.length, 3) + assert.deepEqual( + f.registered.map((font) => font.family), + ['KaTeX_Main'], + ) + assert.equal(f.warnings.length, 1) + fail = false + await loadKatexFonts(f.root) + assert.equal(f.requests.length, 4) + assert.equal(f.registered.length, 2) +}) + +test('face matching handles quoted families, bold italic and synthetic styles', async (t) => { + const f = fixture( + t, + glyph(`'KaTeX_Main', serif`, 'x', 'italic', '700') + + glyph('KaTeX_Script', 'y', 'italic', '700') + + glyph('KaTeX_Math', 'z') + + glyph('serif', 'plain'), + ) + await loadKatexFonts(f.root) + assert.deepEqual(f.requests.sort(), [ + 'KaTeX_Main-BoldItalic.woff2', + 'KaTeX_Math-Italic.woff2', + 'KaTeX_Script-Regular.woff2', + ]) +}) + +test('extension pages attach one stylesheet and keep native per-face loading', async (t) => { + const f = fixture(t, simpleMath, { url: 'chrome-extension://test/popup.html' }) + await loadKatexFonts(f.root) + await loadKatexFonts(f.root) + const links = f.document.querySelectorAll('#chatgptbox-katex-fonts') + assert.equal(links.length, 1) + assert.ok(links[0].href.endsWith('/katex-fonts.css')) + assert.deepEqual(f.requests, []) +}) + +test('a transient HTTP 503 retries successfully without refetching other faces', async (t) => { + let attempts = 0 + const f = fixture(t, simpleMath, { + status: (filename) => { + if (filename === 'KaTeX_Math-Italic.woff2' && ++attempts === 1) return 503 + return 200 + }, + }) + await loadKatexFonts(f.root) + assert.equal(attempts, 2) + assert.equal(f.requests.length, 3) + assert.equal(f.registered.length, 2) + assert.deepEqual(f.warnings, []) +}) + +test('persistent HTTP errors retry once and preserve successful faces', async (t) => { + for (const status of [404, 503]) { + const f = fixture(t, simpleMath, { + status: (filename) => (filename === 'KaTeX_Math-Italic.woff2' ? status : 200), + }) + await loadKatexFonts(f.root) + assert.equal(f.requests.length, 3) + assert.deepEqual( + f.registered.map((font) => font.family), + ['KaTeX_Main'], + ) + assert.equal(f.warnings.length, 1) + assert.match(f.warnings[0][1].message, new RegExp(`KaTeX_Math-Italic: ${status}`)) + } +}) + +test('identical replacement DOM avoids formula style scans', async (t) => { + const f = fixture(t) + const styles = t.mock.method(f.document.defaultView, 'getComputedStyle') + const stop = observeKatexFonts(f.root) + t.after(stop) + await loadKatexFonts(f.root) + const initialScans = styles.mock.callCount() + const original = f.root.innerHTML + const outside = f.document.createElement('p') + f.document.body.appendChild(outside) + for (let token = 0; token < 5; token++) { + outside.textContent += 'token' + f.root.innerHTML = original + await new Promise((resolve) => setTimeout(resolve, 0)) + } + assert.equal(styles.mock.callCount(), initialScans) + assert.equal(f.requests.length, 2) +}) + +test('content and style changes load faces; disconnect stops observation', async (t) => { + const f = fixture(t) + const styles = t.mock.method(f.document.defaultView, 'getComputedStyle') + const stop = observeKatexFonts(f.root) + t.after(stop) + await loadKatexFonts(f.root) + const html = f.root.querySelector('.katex-html') + html.insertAdjacentHTML('beforeend', glyph('KaTeX_AMS', 'R')) + await new Promise((resolve) => setTimeout(resolve, 0)) + assert.ok(f.requests.includes('KaTeX_AMS-Regular.woff2')) + html.querySelector('span').style.fontWeight = '700' + await new Promise((resolve) => setTimeout(resolve, 0)) + assert.ok(f.requests.includes('KaTeX_Math-BoldItalic.woff2')) + const scans = styles.mock.callCount() + html.querySelector('span').firstChild.data = 'y' + await new Promise((resolve) => setTimeout(resolve, 0)) + assert.ok(styles.mock.callCount() > scans) + stop() + const beforeDisconnect = styles.mock.callCount() + html.insertAdjacentHTML('beforeend', glyph('KaTeX_Size3', '∑')) + await new Promise((resolve) => setTimeout(resolve, 0)) + assert.equal(styles.mock.callCount(), beforeDisconnect) + assert.ok(!f.requests.includes('KaTeX_Size3-Regular.woff2')) +})