diff --git a/src/lib/domain/segmenter.spec.ts b/src/lib/domain/segmenter.spec.ts index bc50b5c..685664c 100644 --- a/src/lib/domain/segmenter.spec.ts +++ b/src/lib/domain/segmenter.spec.ts @@ -457,6 +457,38 @@ describe('narrated construct segmentation', () => { } }); + it('terminates when a math span opens on the only break token in the window', () => { + // A web article whose figure caption is one long sentence puts the + // comma INSIDE the equation: "…an author sees$, which is why…$". The + // hard cut lands inside that span, so the next window starts exactly + // at the span and its only break token sits at index 0 — where + // `lastIndexOf(token, -1)` used to keep finding the same token for + // ever and spin the main thread. (Regression: importing + // neovand.github.io/Moire/paper hung the tab.) + const prose = 'the heterodyne ratio is drawn live beside the envelope so an author sees '; + const tex = + ', which is why they stop short of the two lobes flanking the centres and the fringe system appears in neither layer'; + const tail = + ' and the prose keeps going past the equation for a good long stretch so that the splitter still has more than one window of text left to walk through after the equation ends'; + // Whether the hard cut lands inside the span depends on where the + // equation starts, so sweep the openers that put it near the boundary. + for (let length = 120; length <= MAX_SEGMENT_CHARS; length += 1) { + const opener = prose.repeat(4).slice(0, length); + const paragraph = block({ + id: 'b9', + text: `${opener}${tex}${tail}`, + inlines: [{ text: opener }, { text: tex, math: true }, { text: tail }] + }); + const segments = segmentBlocks([paragraph]); + expect(segments.length).toBeGreaterThan(1); + const mathEnd = length + tex.length; + for (const segment of segments) { + expect(segment.start > length && segment.start < mathEnd).toBe(false); + expect(segment.end > length && segment.end < mathEnd).toBe(false); + } + } + }); + it('leaves sentences with single-letter math as plain word-highlighted text', () => { const paragraph = block({ id: 'b4', diff --git a/src/lib/domain/segmenter.ts b/src/lib/domain/segmenter.ts index bda59fb..d597ff0 100644 --- a/src/lib/domain/segmenter.ts +++ b/src/lib/domain/segmenter.ts @@ -135,8 +135,11 @@ function splitLongSentence( let breakAt = -1; for (const token of ['; ', ', ', ' — ']) { let index = candidate.lastIndexOf(token); + // `lastIndexOf(token, -1)` searches from 0, not before it, so a + // protected token sitting at index 0 would be found again for + // ever: once there is nothing left of it, there is no break. while (index >= 0 && spanAt(absoluteStart + cursor + index + 1)) { - index = candidate.lastIndexOf(token, index - 1); + index = index > 0 ? candidate.lastIndexOf(token, index - 1) : -1; } breakAt = Math.max(breakAt, index); } diff --git a/src/lib/domain/tex-macros.spec.ts b/src/lib/domain/tex-macros.spec.ts new file mode 100644 index 0000000..7fc3df8 --- /dev/null +++ b/src/lib/domain/tex-macros.spec.ts @@ -0,0 +1,167 @@ +import { DOMParser } from 'linkedom'; +import { describe, expect, it } from 'vitest'; +import { + balancedBraces, + expandMarkdownMacros, + expandTexMacros, + texMacrosFromDocument, + texMacrosFromScripts +} from './tex-macros'; + +function dom(html: string): Document { + return new DOMParser().parseFromString( + `
${html}`, + 'text/html' + ) as unknown as Document; +} + +describe('balancedBraces', () => { + it('spans nested braces and ignores braces inside strings', () => { + expect(balancedBraces('x = {"a": "{{{"} tail', 4)).toBe('{"a": "{{{"}'); + expect(balancedBraces('{ a: { b: 1 } }', 0)).toBe('{ a: { b: 1 } }'); + }); + + it('returns null when the group never closes or does not start here', () => { + expect(balancedBraces('{ unterminated', 0)).toBeNull(); + expect(balancedBraces('not a group', 0)).toBeNull(); + }); +}); + +describe('texMacrosFromScripts', () => { + it('reads a KaTeX auto-render table, backslashes and all', () => { + const script = `renderMathInElement(document.body, { + delimiters: [{ left: '$', right: '$', display: false }], + macros: {"\\\\R":"\\\\mathbb{R}","\\\\idx":"\\\\phi","\\\\Rot":"\\\\mathbf{R}_{#1}"}, + strict: false + });`; + expect(texMacrosFromScripts([script])).toEqual({ + R: '\\mathbb{R}', + idx: '\\phi', + Rot: '\\mathbf{R}_{#1}' + }); + }); + + it('reads a MathJax table: bare keys, single quotes, [body, arity] values', () => { + const script = `window.MathJax = { tex: { macros: { + RR: '{\\\\bf R}', + bold: ['{\\\\bf #1}', 1], + } } };`; + expect(texMacrosFromScripts([script])).toEqual({ RR: '{\\bf R}', bold: '{\\bf #1}' }); + }); + + it('ignores tables it cannot read and names it could never match back', () => { + expect(texMacrosFromScripts(['macros: { broken'])).toEqual({}); + expect(texMacrosFromScripts(['macros: [1, 2]'])).toEqual({}); + expect(texMacrosFromScripts([`macros: {"\\\\two words": "x", "\\\\ok": "y"}`])).toEqual({ + ok: 'y' + }); + }); + + it('takes the page scripts in order, later definitions winning', () => { + expect( + texMacrosFromScripts([`macros: {"\\\\R": "first"}`, `macros: {"\\\\R": "second"}`]) + ).toEqual({ R: 'second' }); + }); +}); + +describe('texMacrosFromDocument', () => { + it('reads inline scripts and skips external ones', () => { + const document = dom( + `` + + `` + ); + expect(texMacrosFromDocument(document)).toEqual({ het: '\\eta' }); + }); + + it('is empty for a page with no macro table', () => { + expect(texMacrosFromDocument(dom(''))).toEqual({}); + }); +}); + +describe('expandTexMacros', () => { + const macros = { + R: '\\mathbb{R}', + idx: '\\phi', + ph: '\\psi', + Rot: '\\mathbf{R}_{#1}', + pair: '(#1,\\;#2)' + }; + + it('rewrites bare macros and leaves longer names that merely start alike', () => { + expect(expandTexMacros('p \\in \\R^2', macros)).toBe('p \\in \\mathbb{R}^2'); + expect(expandTexMacros('\\idx(p) - \\ph(p)', macros)).toBe('\\phi(p) - \\psi(p)'); + // \Rotate is a different command, not \Rot followed by "ate". + expect(expandTexMacros('\\Rotate', macros)).toBe('\\Rotate'); + }); + + it('takes braced groups and single tokens as arguments', () => { + expect(expandTexMacros('\\Rot{\\theta}', macros)).toBe('\\mathbf{R}_{\\theta}'); + expect(expandTexMacros('\\Rot n', macros)).toBe('\\mathbf{R}_{n}'); + expect(expandTexMacros('\\Rot\\alpha', macros)).toBe('\\mathbf{R}_{\\alpha}'); + expect(expandTexMacros('\\pair{a}{b}', macros)).toBe('(a,\\;b)'); + }); + + it('reads a group whose contents carry primes and escaped braces', () => { + expect(expandTexMacros("\\Rot{-n'\\theta}", macros)).toBe("\\mathbf{R}_{-n'\\theta}"); + expect(expandTexMacros('\\Rot{\\{a\\}}', macros)).toBe('\\mathbf{R}_{\\{a\\}}'); + }); + + it('expands macros written in terms of other macros', () => { + expect(expandTexMacros('\\field', { ...macros, field: '\\R \\times \\R' })).toBe( + '\\mathbb{R} \\times \\mathbb{R}' + ); + }); + + it('leaves a macro alone when its arguments are not there', () => { + expect(expandTexMacros('\\Rot', macros)).toBe('\\Rot'); + expect(expandTexMacros('\\Rot{unterminated', macros)).toBe('\\Rot{unterminated'); + }); + + it('terminates on a self-referential table', () => { + expect(expandTexMacros('\\loop', { loop: '\\loop' })).toBe('\\loop'); + expect(expandTexMacros('\\fan', { fan: '\\fan\\fan' }).length).toBeGreaterThan(0); + }); + + it('passes through text with no macros to expand', () => { + expect(expandTexMacros('plain words', macros)).toBe('plain words'); + expect(expandTexMacros('\\alpha + \\beta', {})).toBe('\\alpha + \\beta'); + }); +}); + +describe('expandMarkdownMacros', () => { + const macros = { het: '\\eta', R: '\\mathbb{R}' }; + + it('expands inside every maths delimiter family', () => { + expect(expandMarkdownMacros('ratio $\\het$ here', macros)).toBe('ratio $\\eta$ here'); + expect(expandMarkdownMacros('$$\n\\het \\in \\R\n$$', macros)).toBe( + '$$\n\\eta \\in \\mathbb{R}\n$$' + ); + expect(expandMarkdownMacros('\\[\\het\\]', macros)).toBe('\\[\\eta\\]'); + expect(expandMarkdownMacros('\\(\\het\\)', macros)).toBe('\\(\\eta\\)'); + }); + + it('follows inline maths across a wrapped line but not past a blank one', () => { + expect(expandMarkdownMacros('lines: $\\het =\n\\R/s$ here', macros)).toBe( + 'lines: $\\eta =\n\\mathbb{R}/s$ here' + ); + // An unbalanced dollar must not swallow the next paragraph's macros. + expect(expandMarkdownMacros('costs $5\n\nprose \\het and $\\het$', macros)).toBe( + 'costs $5\n\nprose \\het and $\\eta$' + ); + }); + + it('leaves prose and fenced code untouched', () => { + expect(expandMarkdownMacros('a path C:\\het and $\\het$', macros)).toBe( + 'a path C:\\het and $\\eta$' + ); + const fenced = '```tex\n$\\het$\n```\n\nand $\\het$ in prose'; + expect(expandMarkdownMacros(fenced, macros)).toBe( + '```tex\n$\\het$\n```\n\nand $\\eta$ in prose' + ); + }); + + it('is a no-op without macros or without backslashes', () => { + expect(expandMarkdownMacros('$\\het$', {})).toBe('$\\het$'); + expect(expandMarkdownMacros('no maths here', macros)).toBe('no maths here'); + }); +}); diff --git a/src/lib/domain/tex-macros.ts b/src/lib/domain/tex-macros.ts new file mode 100644 index 0000000..d13871f --- /dev/null +++ b/src/lib/domain/tex-macros.ts @@ -0,0 +1,299 @@ +/** + * TeX macro expansion for imported pages. + * + * A paper published as HTML usually renders its own maths in the browser, and + * it hands KaTeX (or MathJax) a table of the shorthands its author writes in: + * `\idx` for `\phi`, `\R` for `\mathbb{R}`, `\Rot{\theta}` for + * `\mathbf{R}_{\theta}`. That table lives in the page's own script, so a + * reader that fetches the HTML and renders the TeX itself has never seen it — + * every custom command comes out as literal `\idx` on the page and as raw + * source in the spoken reading. + * + * Expanding the shorthands into the source at import time fixes all of that at + * once: the equations render, the deterministic verbaliser reads them, and the + * language model rewriting them sees ordinary TeX. Everything here is a pure + * string transform — a page's macro table is data, never code, and is never + * evaluated. + */ + +/** Macro name (without the leading backslash) to its replacement body. */ +export type TexMacros = Record


'
diff --git a/src/lib/domain/web-article.ts b/src/lib/domain/web-article.ts
index 49a95e6..06bb4d7 100644
--- a/src/lib/domain/web-article.ts
+++ b/src/lib/domain/web-article.ts
@@ -44,6 +44,11 @@ function normalizedText(value: string | null | undefined): string {
return (value ?? '').replace(/\s+/g, ' ').trim();
}
+/** A caption written in TeX. Deliberately Defuddle's own `LOOKS_LIKE_LATEX_RE`:
+ * this is the test it applies to an alt attribute before deciding the image is
+ * a rendered equation. */
+const TEX_CAPTION = /\\[a-zA-Z]{2,}/;
+
/**
* DOM fixes before extraction, for structures the extractor keeps but
* mishandles:
@@ -60,7 +65,15 @@ function normalizedText(value: string | null | undefined): string {
* the caption text (the footnote pass only reaches body refs) — drop them;
* - images whose alt is empty adopt their figcaption, so the narration layer
* has a caption to describe (the local engine never invents what it cannot
- * see) while the visible caption paragraph reads as usual.
+ * see) while the visible caption paragraph reads as usual;
+ * - a figure whose caption is written in TeX takes a different route. The
+ * extractor reads an alt matching `\command` as a rendered equation image —
+ * it drops the picture and re-emits the caption as one enormous bogus
+ * formula — and, left inside the figure, it rebuilds the caption from plain
+ * text, which strips the emphasis, the links, and the delimiters that made
+ * the equations equations. So the caption is moved out to a paragraph of its
+ * own and the alt is left empty: the figure survives, and a paper's captions
+ * read with their maths intact. Captions with no TeX in them are untouched.
*/
export function prepareArticleDom(document: Document): void {
for (const math of Array.from(document.querySelectorAll('math'))) {
@@ -80,10 +93,17 @@ export function prepareArticleDom(document: Document): void {
}
for (const figure of Array.from(document.querySelectorAll('figure'))) {
const image = figure.querySelector('img');
- const caption = normalizedText(figure.querySelector('figcaption')?.textContent);
- if (image && caption && !normalizedText(image.getAttribute('alt'))) {
- image.setAttribute('alt', caption);
+ const element = Array.from(figure.children).find((child) => child.tagName === 'FIGCAPTION');
+ const caption = normalizedText(element?.textContent);
+ if (!image || !element || !caption) continue;
+ if (TEX_CAPTION.test(caption)) {
+ const paragraph = document.createElement('p');
+ while (element.firstChild) paragraph.appendChild(element.firstChild);
+ element.remove();
+ figure.after(paragraph);
+ continue;
}
+ if (!normalizedText(image.getAttribute('alt'))) image.setAttribute('alt', caption);
}
}
diff --git a/src/lib/services/article-fetch.ts b/src/lib/services/article-fetch.ts
index 66627ff..b9871ed 100644
--- a/src/lib/services/article-fetch.ts
+++ b/src/lib/services/article-fetch.ts
@@ -15,6 +15,7 @@ import {
prepareArticleDom,
wikipediaRestUrl
} from '$lib/domain/web-article';
+import { expandMarkdownMacros, texMacrosFromDocument } from '$lib/domain/tex-macros';
export class ArticleFetchError extends Error {
constructor(
@@ -155,10 +156,13 @@ export async function fetchWebArticle(
options.onStage?.('extracting');
const dom = new DOMParser().parseFromString(html, 'text/html');
+ // Read before the prepass: the page's own script is the only place its TeX
+ // shorthands are defined, and extraction drops scripts.
+ const macros = texMacrosFromDocument(dom);
prepareArticleDom(dom);
const Defuddle = await loadDefuddle();
const result = await new Defuddle(dom, { url: url.href, markdown: true }).parseAsync();
- const body = polishArticleMarkdown(result.content ?? '');
+ const body = expandMarkdownMacros(polishArticleMarkdown(result.content ?? ''), macros);
assertReadable(body);
return {
url: url.href,
diff --git a/vite.config.ts b/vite.config.ts
index aef449b..c93b778 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -82,6 +82,7 @@ export default defineConfig({
'src/lib/domain/pdf-markdown.ts',
'src/lib/domain/segmenter.ts',
'src/lib/domain/study-tree.ts',
+ 'src/lib/domain/tex-macros.ts',
'src/lib/services/web-research.ts',
'src/lib/domain/speech-words.ts',
'src/lib/domain/web-article.ts',