From e8e79cccc171bb0552bce10680f848b9dd751f56 Mon Sep 17 00:00:00 2001 From: Wei Hung Date: Sun, 27 Sep 2026 15:53:29 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(web):=20render=20$=E2=80=A6$=20and=20$?= =?UTF-8?q?$=E2=80=A6$$=20math=20with=20KaTeX=20in=20notes=20and=20the=20l?= =?UTF-8?q?ive=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 1 + .../components/live-markdown/decorations.ts | 35 +++++- .../src/components/live-markdown/math.test.ts | 54 +++++++++ .../src/components/live-markdown/widgets.ts | 21 ++++ apps/web/src/lib/markdown.ts | 3 +- apps/web/src/lib/math.test.ts | 73 +++++++++++ apps/web/src/lib/math.ts | 114 ++++++++++++++++++ apps/web/src/main.tsx | 2 + apps/web/src/math.css | 4 + pnpm-lock.yaml | 3 + 10 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/live-markdown/math.test.ts create mode 100644 apps/web/src/lib/math.test.ts create mode 100644 apps/web/src/lib/math.ts create mode 100644 apps/web/src/math.css diff --git a/apps/web/package.json b/apps/web/package.json index cc09ade..7f7caa7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "graphology": "^0.26.0", "graphology-communities-louvain": "^2.0.2", "graphology-layout-forceatlas2": "^0.10.1", + "katex": "^0.16.47", "lucide-react": "^0.441.0", "marked": "^18.0.13", "marked-cjk-friendly": "^0.1.2", diff --git a/apps/web/src/components/live-markdown/decorations.ts b/apps/web/src/components/live-markdown/decorations.ts index 20b336e..90293aa 100644 --- a/apps/web/src/components/live-markdown/decorations.ts +++ b/apps/web/src/components/live-markdown/decorations.ts @@ -9,13 +9,16 @@ import { DONE_EMOJI, DUE_EMOJI, findToken, isTaskLine, START_EMOJI, TIMESTAMP_EM import type { I18nContextValue } from '../../lib/i18n/index.js'; import { LiveMarkdownDirective } from '../LiveMarkdownDirective.js'; import { LiveMarkdownTable, tableUIState } from '../LiveMarkdownTable.js'; -import { BulletMarker, DateAdder, ExternalLink, MdxImportWidget, MermaidDiagram, PageBreak, PageFooter, RenderedMarkdown, TaskCheckbox, TokenChip, TokenEditor, YouTubeWidget } from './widgets.js'; +import { findMath } from '../../lib/math.js'; +import { BulletMarker, DateAdder, ExternalLink, MathFormula, MdxImportWidget, MermaidDiagram, PageBreak, PageFooter, RenderedMarkdown, TaskCheckbox, TokenChip, TokenEditor, YouTubeWidget } from './widgets.js'; import { chipEditState } from './chip-editing.js'; import { mermaidFenceAt } from './mermaid-fence.js'; export function liveDecorations(state: EditorState, focused: boolean, notePath: string, linkLabel: string, tableLabel: string, pageLabel: string, youtubeOwner: string, t: I18nContextValue['t']): DecorationSet { const marks: Range[] = []; const mermaidRanges: { from: number; to: number; }[] = []; + // Source the math scan must not read: code keeps its dollars, and tables and directives render their own math. + const nonMath: { from: number; to: number; }[] = []; let pageNumber = 1; const references = marked.lexer(state.doc.toString()).links; const active = (from: number, to: number) => focused && !state.readOnly && state.selection.ranges.some(range => state.doc.lineAt(range.from).from <= to && state.doc.lineAt(range.to).to >= from); @@ -71,6 +74,7 @@ export function liveDecorations(state: EditorState, focused: boolean, notePath: return false; } } + if (name === 'FencedCode' || name === 'CodeBlock' || name === 'InlineCode' || name === 'HTMLBlock' || name === 'CommentBlock') nonMath.push({ from, to }); if (name === 'FencedCode' || name === 'CodeBlock') { for (let line = state.doc.lineAt(from); line.from < to; line = state.doc.line(line.number + 1)) { marks.push(Decoration.line({ class: 'live-md-codeblock' }).range(line.from)); @@ -100,6 +104,7 @@ export function liveDecorations(state: EditorState, focused: boolean, notePath: } } if (name === 'Table' && node.node.parent?.name === 'Document') { + nonMath.push({ from, to }); marks.push(Decoration.replace({ widget: new LiveMarkdownTable(state.sliceDoc(from, to), notePath, from, state.readOnly, t, state.field(tableUIState).get(from)), block: true }).range(from, to)); return false; } @@ -170,10 +175,22 @@ export function liveDecorations(state: EditorState, focused: boolean, notePath: }, }); flushQuoteReveal(); - // The prefix hiding and list bullet decorations of a drawn fence's opening line sit inside its widget's range. + const mathRanges: { from: number; to: number; }[] = []; + for (const span of findMath(maskRanges(docText, [...nonMath, ...mermaidRanges, ...collapsedDirectives]))) { + if (active(span.from, span.to)) continue; + const first = state.doc.lineAt(span.from), last = state.doc.lineAt(span.to); + // A display formula alone on its lines is drawn as a block in their place; any other formula stays in the text flow. + const block = span.display && !first.text.slice(0, span.from - first.from).trim() && !last.text.slice(span.to - last.from).trim(); + const from = block ? first.from : span.from, to = block ? last.to : span.to; + mathRanges.push({ from, to }); + marks.push(Decoration.replace({ widget: new MathFormula(span.tex, span.display, block), block }).range(from, to)); + } + // The prefix hiding and list bullet decorations of a drawn fence's opening line sit inside its widget's range, and so + // do the emphasis or escape marks the Markdown parser finds inside a formula's TeX. for (let i = marks.length - 1; i >= 0; i--) { const { from, to, value } = marks[i]; if (!(value.spec.widget instanceof MermaidDiagram) && mermaidRanges.some(range => from >= range.from && to <= range.to)) marks.splice(i, 1); + else if (!(value.spec.widget instanceof MathFormula) && mathRanges.some(range => from < to ? from >= range.from && to <= range.to : from > range.from && from < range.to)) marks.splice(i, 1); } for (const block of collapsedDirectives) { @@ -218,3 +235,17 @@ export function liveDecorations(state: EditorState, focused: boolean, notePath: marks.push(Decoration.widget({ widget: new PageFooter(pageNumber, lastPageLabel), side: 1, block: true }).range(state.doc.length)); return Decoration.set(marks, true); } + +/** `text` with every character in `ranges` except line breaks turned into a space, so positions stay the same. */ +function maskRanges(text: string, ranges: { from: number; to: number; }[]): string { + const parts: string[] = []; + let pos = 0; + for (const { from, to } of [...ranges].sort((a, b) => a.from - b.from)) { + if (to <= pos) continue; + const start = Math.max(from, pos); + parts.push(text.slice(pos, start), text.slice(start, to).replace(/[^\n]/g, ' ')); + pos = to; + } + parts.push(text.slice(pos)); + return parts.join(''); +} diff --git a/apps/web/src/components/live-markdown/math.test.ts b/apps/web/src/components/live-markdown/math.test.ts new file mode 100644 index 0000000..82bb0bf --- /dev/null +++ b/apps/web/src/components/live-markdown/math.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { EditorState, StateField } from '@codemirror/state'; +import { Decoration, type DecorationSet, EditorView } from '@codemirror/view'; +import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; +import { cjkEmphasis } from './cjk-emphasis.js'; +import { chipEditState } from './chip-editing.js'; +import { tableUIState } from '../LiveMarkdownTable.js'; +import { liveDecorations } from './decorations.js'; +import { MathFormula } from './widgets.js'; + +const t = (key: string) => key; +// Focused, so the line holding the cursor shows its source the way it does while the user edits. +const decorations = StateField.define({ create: state => liveDecorations(state, true, 'n.md', 'link', 'table', 'page', 'owner', t as never), update: (_value, tr) => liveDecorations(tr.state, true, 'n.md', 'link', 'table', 'page', 'owner', t as never), provide: field => EditorView.decorations.from(field) }); + +const views: EditorView[] = []; +function editor(doc: string, cursor = doc.length) { + const view = new EditorView({ parent: document.body, state: EditorState.create({ doc, selection: { anchor: cursor }, extensions: [markdown({ base: markdownLanguage, extensions: [cjkEmphasis] }), chipEditState, tableUIState, decorations] }) }); + views.push(view); + return view; +} +afterEach(() => { + while (views.length) views.pop()!.destroy(); +}); + +/** The formulas the live editor draws, with the source range each one replaces. */ +function formulas(view: EditorView) { + const found: { tex: string; display: boolean; block: boolean; source: string; }[] = []; + view.state.field(decorations).between(0, view.state.doc.length, (from, to, value: Decoration) => { + if (value.spec.widget instanceof MathFormula) found.push({ tex: value.spec.widget.tex, display: value.spec.widget.display, block: value.spec.widget.block, source: view.state.sliceDoc(from, to) }); + }); + return found; +} + +const note = 'Inline $p(9)$ and $|\\{\\text{Socrates}\\}|$ here.\n\n$$\n\\mathrm{ref}_a(t_1 + t_2) = \\mathrm{ref}_a(t_1) + \\mathrm{ref}_a(t_2)\n$$\n\nCode `$x$` and $5 or $10.\n\n```\n$$y$$\n```\n\nend'; + +describe('math in the live editor', () => { + it('draws inline and display formulas in place of their source', () => { + const view = editor(note); + expect(formulas(view)).toEqual([{ tex: 'p(9)', display: false, block: false, source: '$p(9)$' }, { tex: '|\\{\\text{Socrates}\\}|', display: false, block: false, source: '$|\\{\\text{Socrates}\\}|$' }, { tex: '\\mathrm{ref}_a(t_1 + t_2) = \\mathrm{ref}_a(t_1) + \\mathrm{ref}_a(t_2)', display: true, block: true, source: '$$\n\\mathrm{ref}_a(t_1 + t_2) = \\mathrm{ref}_a(t_1) + \\mathrm{ref}_a(t_2)\n$$' }]); + expect(view.dom.querySelectorAll('.live-md-math .katex')).toHaveLength(3); + expect(view.dom.querySelector('.live-md-math-block .katex-display')).not.toBeNull(); + }); + + it('shows the source of a formula while the cursor is on its lines', () => { + const inside = note.indexOf('ref}_a(t_1)'); + expect(formulas(editor(note, inside)).map(formula => formula.tex)).toEqual(['p(9)', '|\\{\\text{Socrates}\\}|']); + expect(formulas(editor(note, 3)).map(formula => formula.display)).toEqual([true]); + }); + + it('leaves formulas in a table to the rendered table', () => { + expect(formulas(editor('| a |\n| - |\n| $x$ |\n\nend'))).toEqual([]); + }); +}); diff --git a/apps/web/src/components/live-markdown/widgets.ts b/apps/web/src/components/live-markdown/widgets.ts index 72fcf28..fed7289 100644 --- a/apps/web/src/components/live-markdown/widgets.ts +++ b/apps/web/src/components/live-markdown/widgets.ts @@ -7,6 +7,7 @@ import { setTaskChecked, setTokenValue } from '../../lib/task-tokens.js'; import { TASK_TOKEN_ICON_SVG } from '../../lib/task-icons.js'; import { activateYouTubeEmbed, populateYouTubeEmbed, type YouTubeLabels } from '../../lib/youtube-embed.js'; import { createMermaidBlock, hydrateMermaid } from '../../lib/mermaid.js'; +import { drawMath } from '../../lib/math.js'; import { type MermaidEditorLabels, openMermaidEditor } from '../../lib/mermaid-editor.js'; import { chipEditChanged } from './chip-editing.js'; import { countMermaidTwins, findMermaidFences, relocateMermaidFence } from './mermaid-fence.js'; @@ -68,6 +69,26 @@ export class RenderedMarkdown extends WidgetType { return this.block ? 100 : 160; } } +/** A `$…$` or `$$…$$` formula drawn by KaTeX; the source returns when the cursor enters its lines. */ +export class MathFormula extends WidgetType { + constructor(readonly tex: string, readonly display: boolean, readonly block: boolean) { + super(); + } + eq(other: MathFormula) { + return this.tex === other.tex && this.display === other.display && this.block === other.block; + } + toDOM(view: EditorView) { + const dom = document.createElement(this.block ? 'div' : 'span'); + dom.className = this.block ? 'live-md-math live-md-math-block' : 'live-md-math'; + drawMath(dom, this.tex, this.display); + dom.addEventListener('mousedown', event => { + event.preventDefault(); + view.dispatch({ selection: { anchor: view.posAtDOM(dom) } }); + view.focus(); + }); + return dom; + } +} export class PageBreak extends WidgetType { constructor(readonly from: number, readonly label: string, readonly pageNumber: number) { super(); diff --git a/apps/web/src/lib/markdown.ts b/apps/web/src/lib/markdown.ts index aa5dbb1..6335ca1 100644 --- a/apps/web/src/lib/markdown.ts +++ b/apps/web/src/lib/markdown.ts @@ -6,11 +6,12 @@ import { parseR2Reference, r2AssetUrl, r2PreviewType } from '@mygitnotes/core/r2 import { headingSlug, resolveWorkspaceHref } from './workspace-links.js'; import { escapeHtml, stripMdxImports, transformDirectives, transformMdxComponents } from './directives.js'; import { isMermaidInfo } from './mermaid.js'; +import { markedMath } from './math.js'; import { DEFAULT_YOUTUBE_LABELS, type YouTubeDisplayMode, type YouTubeLabels } from './youtube-embed.js'; // CommonMark cannot close emphasis when a full-width punctuation mark sits before the delimiter and // a CJK character after it, so `**二口女(ふたくちおんな)**意象` renders as literal asterisks. -const md = new Marked(markedCjkFriendly()); +const md = new Marked(markedCjkFriendly(), markedMath); // A mermaid fence renders to a placeholder holding its source as text; sanitized HTML cannot carry the SVG, so each // surface draws the diagram once the HTML is mounted (see hydrateMermaid). The source stays in the
 because
diff --git a/apps/web/src/lib/math.test.ts b/apps/web/src/lib/math.test.ts
new file mode 100644
index 0000000..6854108
--- /dev/null
+++ b/apps/web/src/lib/math.test.ts
@@ -0,0 +1,73 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest';
+import { renderNote } from './markdown.js';
+import { findMath } from './math.js';
+
+function mount(source: string) {
+  return new DOMParser().parseFromString(renderNote(source, 'notes/n.md'), 'text/html').body;
+}
+
+/** The formulas KaTeX drew, with the TeX source their accessible label carries. */
+function formulas(root: HTMLElement) {
+  return [...root.querySelectorAll('.note-math')].filter(node => node.querySelector('.katex')).map(node => ({ display: !!node.querySelector('.katex-display'), tex: node.getAttribute('aria-label') }));
+}
+
+describe('findMath', () => {
+  it('finds inline and display math with their positions', () => {
+    expect(findMath('a $x$ b')).toEqual([{ from: 2, to: 5, tex: 'x', display: false }]);
+    expect(findMath('$$\ny = 1\n$$')).toEqual([{ from: 0, to: 11, tex: 'y = 1', display: true }]);
+    expect(findMath('see $$x$$ here')).toEqual([{ from: 4, to: 9, tex: 'x', display: true }]);
+  });
+
+  it('leaves prices, escaped dollars, and unclosed dollars as text', () => {
+    expect(findMath('It costs $5 and $10 today.')).toEqual([]);
+    expect(findMath('Pay $5, not $ 6$.')).toEqual([]);
+    expect(findMath('literal \\$x\\$ here')).toEqual([]);
+    expect(findMath('just one $ sign')).toEqual([]);
+    expect(findMath('$$\na\n\nb\n$$')).toEqual([]);
+  });
+
+  it('keeps an escaped dollar inside a formula', () => {
+    expect(findMath('$a \\$ b$')).toEqual([{ from: 0, to: 8, tex: 'a \\$ b', display: false }]);
+  });
+});
+
+describe('math in rendered notes', () => {
+  it('draws inline and display formulas instead of the dollar source', () => {
+    const root = mount('Inline $x^2$ here.\n\n$$\n\\mathrm{ref}_a(t_1 + t_2) = \\mathrm{ref}_a(t_1) + \\mathrm{ref}_a(t_2)\n$$\n\nAfter $$y$$ too.');
+    expect(root.textContent).not.toContain('$');
+    expect(formulas(root)).toEqual([{ display: false, tex: 'x^2' }, { display: true, tex: '\\mathrm{ref}_a(t_1 + t_2) = \\mathrm{ref}_a(t_1) + \\mathrm{ref}_a(t_2)' }, { display: true, tex: 'y' }]);
+  });
+
+  it('renders the sample note formulas next to CJK punctuation and escapes', () => {
+    const root = mount('「$p(9)$」由函數符號 $p$ 組成。於是 $|\\{\\text{Socrates}\\}| = \\text{Socrates}$。\n\n- (5\\*) 它是 $\\Box\\exists x Fx$ 與 $\\#(F)$。');
+    expect(formulas(root).map(formula => formula.tex)).toEqual(['p(9)', 'p', '|\\{\\text{Socrates}\\}| = \\text{Socrates}', '\\Box\\exists x Fx', '\\#(F)']);
+    expect(root.querySelector('.katex-error')).toBeNull();
+  });
+
+  it('keeps code and prices literal', () => {
+    const root = mount('Costs $5 and $10.\n\n`$x$` in code\n\n```\n$$y$$\n```');
+    expect(formulas(root)).toEqual([]);
+    expect(root.textContent).toContain('Costs $5 and $10.');
+    expect(root.querySelector('code')?.textContent).toBe('$x$');
+    expect(root.querySelector('pre')?.textContent).toContain('$$y$$');
+  });
+
+  it('renders formulas inside tables and directive bodies', () => {
+    expect(formulas(mount('| a | b |\n| - | - |\n| $x$ | 1 |'))).toEqual([{ display: false, tex: 'x' }]);
+    expect(formulas(mount(':::info\nSee $y$.\n:::'))).toEqual([{ display: false, tex: 'y' }]);
+  });
+
+  it('stays safe through the sanitizer', () => {
+    const root = mount('$\\href{javascript:alert(1)}{x}$ and $$ and $$\\htmlClass{x}{y}$$');
+    expect(root.querySelector('a, img, [href], [src], [onerror]')).toBeNull();
+    expect(root.querySelector('.katex [class~="x"]')).toBeNull();
+    expect(formulas(root).map(formula => formula.tex)).toEqual(['\\href{javascript:alert(1)}{x}', '', '\\htmlClass{x}{y}']);
+  });
+
+  it('shows a bad formula as an error instead of breaking the note', () => {
+    const root = mount('Broken $\\frac{1}$ formula, then more text.');
+    expect(root.textContent).toContain('then more text.');
+    expect(root.querySelector('.katex-error')?.textContent).toBe('\\frac{1}');
+  });
+});
diff --git a/apps/web/src/lib/math.ts b/apps/web/src/lib/math.ts
new file mode 100644
index 0000000..5551aea
--- /dev/null
+++ b/apps/web/src/lib/math.ts
@@ -0,0 +1,114 @@
+import katex, { type KatexOptions } from 'katex';
+import type { MarkedExtension, Tokens } from 'marked';
+import { escapeHtml } from './directives.js';
+
+export interface MathSpan {
+  from: number;
+  to: number;
+  tex: string;
+  display: boolean;
+}
+
+// `trust` stays off so \href, \url and \html* cannot emit links or attributes; a bad formula shows as a red error in place.
+// HTML output only: DOMPurify drops MathML's  and  but keeps their text, which would leak the TeX
+// source into the formula, so the wrapper's aria-label carries the source for assistive technology instead.
+const KATEX_OPTIONS: KatexOptions = { output: 'html', throwOnError: false, strict: false, trust: false };
+
+/**
+ * The formula whose opening dollar sits at `start`, following Pandoc's dollar rules so prices stay text: inline `$…$`
+ * opens before a non-space, closes after a non-space where no digit follows, and stays on one line; display `$$…$$`
+ * may span lines but not a blank line. A backslash escapes a dollar.
+ */
+export function mathAt(text: string, start: number): MathSpan | null {
+  if (text[start] !== '$') return null;
+  const display = text[start + 1] === '$';
+  const open = display ? start + 2 : start + 1;
+  if (!display && (open >= text.length || /\s/.test(text[open]))) return null;
+  for (let i = open; i < text.length; i++) {
+    const char = text[i];
+    if (char === '\\') {
+      i++;
+      continue;
+    }
+    if (char === '\n' && (!display || /^[ \t]*(?:\n|$)/.test(text.slice(i + 1, i + 200)))) return null;
+    if (char !== '$') continue;
+    if (display) {
+      if (text[i + 1] !== '$') continue;
+      const tex = text.slice(open, i).trim();
+      return tex ? { from: start, to: i + 2, tex, display } : null;
+    }
+    if (text[i + 1] === '$' || /\s/.test(text[i - 1]) || /\d/.test(text[i + 1] ?? '')) return null;
+    return { from: start, to: i + 1, tex: text.slice(open, i), display };
+  }
+  return null;
+}
+
+/** Every formula in `text`, in order; a dollar that opens no formula stays text. */
+export function findMath(text: string): MathSpan[] {
+  const spans: MathSpan[] = [];
+  for (let i = 0; i < text.length; i++) {
+    if (text[i] === '\\') {
+      i++;
+      continue;
+    }
+    if (text[i] !== '$') continue;
+    const span = mathAt(text, i);
+    if (span) {
+      spans.push(span);
+      i = span.to - 1;
+    } else if (text[i + 1] === '$') i++;
+  }
+  return spans;
+}
+
+export function renderMath(tex: string, display: boolean): string {
+  return `${katex.renderToString(tex, { ...KATEX_OPTIONS, displayMode: display })}`;
+}
+
+/** The same formula as renderMath, drawn into `element`. */
+export function drawMath(element: HTMLElement, tex: string, display: boolean): void {
+  element.classList.add('note-math');
+  element.setAttribute('role', 'math');
+  element.setAttribute('aria-label', tex);
+  katex.render(tex, element, { ...KATEX_OPTIONS, displayMode: display });
+}
+
+interface MathToken extends Tokens.Generic {
+  tex: string;
+  display: boolean;
+}
+
+function firstDollar(src: string): number | undefined {
+  for (let i = 0; i < src.length; i++) {
+    if (src[i] === '\\') i++;
+    else if (src[i] === '$') return i;
+  }
+  return undefined;
+}
+
+/** Marked tokens for `$$…$$` on their own lines (a display block) and `$…$` or `$$…$$` within text. */
+export const markedMath: MarkedExtension = {
+  extensions: [{
+    name: 'mathBlock',
+    level: 'block',
+    start: src => src.match(/^ {0,3}\$\$/m)?.index,
+    tokenizer(src): MathToken | undefined {
+      const indent = /^ {0,3}(?=\$\$)/.exec(src)?.[0];
+      if (indent === undefined) return undefined;
+      const span = mathAt(src, indent.length);
+      const tail = span && /^[ \t]*(?:\n|$)/.exec(src.slice(span.to));
+      if (!span || !tail) return undefined;
+      return { type: 'mathBlock', raw: src.slice(0, span.to + tail[0].length), tex: span.tex, display: true };
+    },
+    renderer: token => `${renderMath((token as MathToken).tex, true)}\n`,
+  }, {
+    name: 'mathInline',
+    level: 'inline',
+    start: firstDollar,
+    tokenizer(src): MathToken | undefined {
+      const span = mathAt(src, 0);
+      return span ? { type: 'mathInline', raw: src.slice(0, span.to), tex: span.tex, display: span.display } : undefined;
+    },
+    renderer: token => renderMath((token as MathToken).tex, (token as MathToken).display),
+  }],
+};
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index ee26e72..015a65c 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -10,6 +10,8 @@ import './index.css';
 import './workspace.css';
 import './ui-buttons.css';
 import './mermaid.css';
+import 'katex/dist/katex.min.css';
+import './math.css';
 import './directives/general-containers.css';
 import './directives/character-stat-blocks.css';
 import './directives/document-cards.css';
diff --git a/apps/web/src/math.css b/apps/web/src/math.css
new file mode 100644
index 0000000..de5e1be
--- /dev/null
+++ b/apps/web/src/math.css
@@ -0,0 +1,4 @@
+/* KaTeX's own stylesheet lays out the formulas; these rules fit them into notes and the live editor. */
+.note-math .katex-display { overflow-x: auto; overflow-y: hidden; }
+.live-md-math { cursor: text; }
+.live-md-math-block { display: flow-root; }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 16635b7..f95bbeb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -171,6 +171,9 @@ importers:
       graphology-layout-forceatlas2:
         specifier: ^0.10.1
         version: 0.10.1(graphology-types@0.24.8)
+      katex:
+        specifier: ^0.16.47
+        version: 0.16.47
       lucide-react:
         specifier: ^0.441.0
         version: 0.441.0(react@18.3.1)

From b284aea48e98a76de551d33b9a2396cba691e860 Mon Sep 17 00:00:00 2001
From: Wei Hung 
Date: Sun, 27 Sep 2026 15:58:34 +0800
Subject: [PATCH 2/2] fix(web): carry KaTeX styles and fonts into the print/PDF
 export

---
 apps/web/src/lib/note-export.math.test.ts | 22 ++++++++++++++++++++++
 apps/web/src/lib/note-export.ts           | 16 +++++++++++++---
 vitest.config.ts                          |  4 ++++
 3 files changed, 39 insertions(+), 3 deletions(-)
 create mode 100644 apps/web/src/lib/note-export.math.test.ts
 create mode 100644 vitest.config.ts

diff --git a/apps/web/src/lib/note-export.math.test.ts b/apps/web/src/lib/note-export.math.test.ts
new file mode 100644
index 0000000..64ba427
--- /dev/null
+++ b/apps/web/src/lib/note-export.math.test.ts
@@ -0,0 +1,22 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest';
+import { printDocument, renderPrintableNote } from './note-export.js';
+
+describe('math in the print document', () => {
+  it('carries the KaTeX stylesheet and fonts the drawn formulas need', async () => {
+    const body = await renderPrintableNote('Inline $x^2$.\n\n$$\ny = 1\n$$', 'notes/n.md');
+    const doc = new DOMParser().parseFromString(printDocument('Title', body), 'text/html');
+    expect(doc.querySelectorAll('.katex')).toHaveLength(2);
+    const style = [...doc.querySelectorAll('style')].map(node => node.textContent).join('\n');
+    expect(style).toMatch(/\.katex\s*\{/);
+    expect(style).toMatch(/\.katex-display\s*\{/);
+    // Root-absolute font URLs, since the srcdoc frame resolves them against the app's URL rather than the stylesheet's.
+    expect(style).toMatch(/@font-face\s*\{[^}]*font-family:\s*KaTeX_Main;[^}]*url\(\/[^)]*KaTeX_Main-Regular[^)]*\.woff2\)/);
+  });
+
+  it('escapes the title', () => {
+    const doc = new DOMParser().parseFromString(printDocument('a & b', '

x

'), 'text/html'); + expect(doc.title).toBe('a & b'); + expect(doc.head.querySelector('b')).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/note-export.ts b/apps/web/src/lib/note-export.ts index 299db08..be0e10d 100644 --- a/apps/web/src/lib/note-export.ts +++ b/apps/web/src/lib/note-export.ts @@ -1,5 +1,8 @@ import { renderNote } from './markdown.js'; import { currentAppearance, renderMermaidBlocks } from './mermaid.js'; +// Inlined because the print frame loads none of the app's stylesheets; Vite rewrites its font url()s to the served +// asset paths, which the srcdoc frame resolves against the app's own URL. +import KATEX_STYLE from 'katex/dist/katex.min.css?inline'; const PRINT_STYLE = ` body { margin: 0; padding: 0; font: 11pt/1.6 -apple-system, 'Noto Sans TC', 'PingFang TC', 'Microsoft JhengHei', sans-serif; color: CanvasText; } @@ -16,6 +19,7 @@ const PRINT_STYLE = ` .note-mermaid-error { text-align: left; border: 1px solid currentColor; border-radius: 4px; padding: 0.5em 0.8em; } .note-mermaid-error pre { margin: 0.4em 0 0; background: none; padding: 0; } .markdown-table-scroll { overflow: visible; } + .katex-display { break-inside: avoid; } @page { margin: 18mm; } `; @@ -38,14 +42,18 @@ export async function renderPrintableNote(content: string, notePath: string, err return printable.body.innerHTML; } +/** The standalone HTML document the print frame shows for a rendered note body. */ +export function printDocument(title: string, body: string): string { + const escapedTitle = title.replace(/[&<>"]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[char] ?? char); + return `${escapedTitle}${body}`; +} + /** Opens the browser's print dialog on the rendered note, where "Save as PDF" produces the file. */ export async function printNoteAsPdf(title: string, content: string, notePath: string, errorLabel?: string): Promise { const frame = document.createElement('iframe'); frame.setAttribute('aria-hidden', 'true'); frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;'; - const escapedTitle = title.replace(/[&<>"]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[char] ?? char); - const body = await renderPrintableNote(content, notePath, errorLabel); - frame.srcdoc = `${escapedTitle}${body}`; + frame.srcdoc = printDocument(title, await renderPrintableNote(content, notePath, errorLabel)); const remove = () => frame.remove(); frame.addEventListener('load', async () => { const view = frame.contentWindow; @@ -56,6 +64,8 @@ export async function printNoteAsPdf(title: string, content: string, notePath: s image.addEventListener('load', () => resolve(), { once: true }); image.addEventListener('error', () => resolve(), { once: true }); }))); + // KaTeX fonts load on first use; printing before they arrive lays formulas out in fallback fonts. + await view.document.fonts?.ready; view.addEventListener('afterprint', remove); view.focus(); view.print(); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ab1d6bc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from 'vitest/config'; + +// Vitest empties imported CSS by default; the print export inlines KaTeX's stylesheet, so its tests read the real one. +export default defineConfig({ test: { css: { include: [/[\\/]katex[\\/]/] } } });