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'))
+})