+
+
diff --git a/src/lib/domain/page-tone.spec.ts b/src/lib/domain/page-tone.spec.ts
new file mode 100644
index 0000000..8f39603
--- /dev/null
+++ b/src/lib/domain/page-tone.spec.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from 'vitest';
+import { parseColor, toneDistance, tonePixels, type Rgb } from './page-tone';
+
+const PAPER_DARK: Rgb = [24, 25, 29];
+const INK_LIGHT: Rgb = [244, 241, 233];
+const PAPER_WARM: Rgb = [255, 250, 241];
+const INK_DARK: Rgb = [35, 31, 25];
+
+/** One pixel through the ramp, as [r, g, b]. */
+function toned(pixel: [number, number, number], paper: Rgb, ink: Rgb): number[] {
+ const data = new Uint8ClampedArray([...pixel, 255]);
+ tonePixels(data, paper, ink);
+ return [data[0], data[1], data[2]];
+}
+
+describe('parseColor', () => {
+ it('reads the forms the theme sheet uses', () => {
+ expect(parseColor('#18191d')).toEqual([24, 25, 29]);
+ expect(parseColor('#FFF')).toEqual([255, 255, 255]);
+ expect(parseColor(' rgb(12, 34, 56) ')).toEqual([12, 34, 56]);
+ expect(parseColor('rgba(12 34 56 / 0.5)')).toEqual([12, 34, 56]);
+ });
+
+ it('refuses what it cannot read rather than guessing', () => {
+ expect(parseColor('hsl(200 20% 30%)')).toBeNull();
+ expect(parseColor('rebeccapurple')).toBeNull();
+ expect(parseColor('#12')).toBeNull();
+ expect(parseColor('rgb(1, 2)')).toBeNull();
+ });
+});
+
+describe('toneDistance', () => {
+ it('is nothing when the paper is already white under black ink', () => {
+ expect(toneDistance([255, 255, 255], [0, 0, 0])).toBeCloseTo(0);
+ });
+
+ it('is at its largest for a full inversion', () => {
+ expect(toneDistance([0, 0, 0], [255, 255, 255])).toBeCloseTo(2);
+ });
+
+ it('separates a warm light theme from a dark one', () => {
+ expect(toneDistance(PAPER_WARM, INK_DARK)).toBeLessThan(0.2);
+ expect(toneDistance(PAPER_DARK, INK_LIGHT)).toBeGreaterThan(1.5);
+ });
+});
+
+describe('tonePixels', () => {
+ it('prints white paper as the theme’s paper', () => {
+ expect(toned([255, 255, 255], PAPER_DARK, INK_LIGHT)).toEqual([...PAPER_DARK]);
+ });
+
+ it('prints black ink as the theme’s ink', () => {
+ expect(toned([0, 0, 0], PAPER_DARK, INK_LIGHT)).toEqual([...INK_LIGHT]);
+ });
+
+ it('carries a mid grey to the middle of the ramp', () => {
+ const [red] = toned([128, 128, 128], PAPER_DARK, INK_LIGHT);
+ expect(red).toBeGreaterThan(Math.min(PAPER_DARK[0], INK_LIGHT[0]));
+ expect(red).toBeLessThan(Math.max(PAPER_DARK[0], INK_LIGHT[0]));
+ });
+
+ it('keeps the greys in order, so a rule stays lighter than the type', () => {
+ const type = toned([20, 20, 20], PAPER_DARK, INK_LIGHT)[0];
+ const rule = toned([160, 160, 160], PAPER_DARK, INK_LIGHT)[0];
+ // Under a dark theme the ramp runs the other way: darker ink prints
+ // lighter, and the order has to survive intact either way.
+ expect(type).toBeGreaterThan(rule);
+ });
+
+ it('leaves the author’s colour alone', () => {
+ expect(toned([220, 30, 30], PAPER_DARK, INK_LIGHT)).toEqual([220, 30, 30]);
+ expect(toned([40, 160, 90], PAPER_WARM, INK_DARK)).toEqual([40, 160, 90]);
+ });
+
+ it('fades the remap out across the edge of a coloured glyph', () => {
+ // A pixel half way between grey and colour comes out between the two,
+ // rather than snapping to one of them and fringing the letter. The grey
+ // it is compared against is the one of the same lightness, so only the
+ // chroma differs.
+ const [red] = toned([150, 128, 128], PAPER_DARK, INK_LIGHT);
+ const fully = toned([139, 139, 139], PAPER_DARK, INK_LIGHT)[0];
+ expect(red).toBeGreaterThan(Math.min(150, fully));
+ expect(red).toBeLessThan(Math.max(150, fully));
+ });
+
+ it('barely touches a page under a paper-white theme', () => {
+ const [red, green, blue] = toned([255, 255, 255], PAPER_WARM, INK_DARK);
+ expect(Math.abs(red - 255)).toBeLessThanOrEqual(1);
+ expect(Math.abs(green - 250)).toBeLessThanOrEqual(1);
+ expect(Math.abs(blue - 241)).toBeLessThanOrEqual(1);
+ });
+
+ it('leaves alpha alone', () => {
+ const data = new Uint8ClampedArray([255, 255, 255, 137]);
+ tonePixels(data, PAPER_DARK, INK_LIGHT);
+ expect(data[3]).toBe(137);
+ });
+
+ it('walks a whole buffer, not just its first pixel', () => {
+ const data = new Uint8ClampedArray([255, 255, 255, 255, 0, 0, 0, 255]);
+ tonePixels(data, PAPER_DARK, INK_LIGHT);
+ expect([data[0], data[1], data[2]]).toEqual([...PAPER_DARK]);
+ expect([data[4], data[5], data[6]]).toEqual([...INK_LIGHT]);
+ });
+});
diff --git a/src/lib/domain/page-tone.ts b/src/lib/domain/page-tone.ts
new file mode 100644
index 0000000..adc06f4
--- /dev/null
+++ b/src/lib/domain/page-tone.ts
@@ -0,0 +1,119 @@
+/**
+ * Printing a page onto the reader's paper.
+ *
+ * A PDF page is white with black ink on it, which is a fact about the paper it
+ * was made for, not about the room it is being read in. The reading view has
+ * always drawn the same document on the theme's paper in the theme's ink; this
+ * puts the original pages on that same paper, so moving between the two views
+ * is a change of typesetting rather than a change of lighting.
+ *
+ * The transform is a duotone ramp: what was white becomes the theme's paper,
+ * what was black becomes its ink, and the greys in between are carried across
+ * proportionally. Under a light theme that is a barely visible warming; under
+ * a dark one it is a full inversion, arrived at by the same arithmetic rather
+ * than by a special case.
+ *
+ * Colour is left alone. Red warning text stays red and a green curve on a plot
+ * stays green — remapping those would be recolouring the author's work rather
+ * than the paper it sits on. The line between "paper and ink" and "colour" is
+ * chroma, crossed gradually so that the anti-aliased edge of a coloured glyph
+ * does not fringe.
+ */
+
+export type Rgb = readonly [number, number, number];
+
+/** Below this chroma a pixel is paper, ink, or a grey between them, and is
+ * remapped in full. */
+const ACHROMATIC = 10;
+/** Above this it is the author's colour and is left as it is. Between the two
+ * the remap fades out, which is what keeps glyph edges clean. */
+const CHROMATIC = 44;
+
+/**
+ * Read a CSS colour into channels. Only the forms the theme sheet actually
+ * uses need to work — hex and `rgb()` — and anything else is refused rather
+ * than guessed at, so a mistyped variable shows up as an untoned page instead
+ * of a mysteriously wrong one.
+ */
+export function parseColor(value: string): Rgb | null {
+ const text = value.trim().toLowerCase();
+ const hex = /^#([0-9a-f]{3,8})$/.exec(text);
+ if (hex) {
+ const digits = hex[1];
+ if (digits.length === 3 || digits.length === 4) {
+ const [red, green, blue] = [...digits.slice(0, 3)].map((digit) =>
+ Number.parseInt(digit + digit, 16)
+ );
+ return [red, green, blue];
+ }
+ if (digits.length === 6 || digits.length === 8) {
+ return [
+ Number.parseInt(digits.slice(0, 2), 16),
+ Number.parseInt(digits.slice(2, 4), 16),
+ Number.parseInt(digits.slice(4, 6), 16)
+ ];
+ }
+ return null;
+ }
+ const rgb = /^rgba?\(([^)]+)\)$/.exec(text);
+ if (!rgb) return null;
+ const parts = rgb[1]
+ .split(/[\s,/]+/)
+ .filter(Boolean)
+ .map(Number);
+ if (parts.length < 3 || parts.slice(0, 3).some((part) => !Number.isFinite(part))) return null;
+ return [parts[0], parts[1], parts[2]];
+}
+
+/**
+ * How far a page's own colouring is from the paper it is being printed onto —
+ * 0 when the ramp would change nothing, 1 at a full inversion. Used to decide
+ * whether the pass is worth running at all.
+ */
+export function toneDistance(paper: Rgb, ink: Rgb): number {
+ const lightness = (color: Rgb) =>
+ (0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2]) / 255;
+ return Math.abs(1 - lightness(paper)) + lightness(ink);
+}
+
+/**
+ * Repaint one page's pixels in place.
+ *
+ * `data` is RGBA as `getImageData` gives it. Every pixel is placed on the ramp
+ * by its own lightness — the midpoint of its lightest and darkest channel,
+ * which is what keeps a mid-grey mid-way rather than dragging it toward
+ * whichever channel happens to dominate — and then mixed back toward its
+ * original colour by how chromatic it is.
+ */
+export function tonePixels(data: Uint8ClampedArray, paper: Rgb, ink: Rgb): void {
+ const span = CHROMATIC - ACHROMATIC;
+ const rampRed = paper[0] - ink[0];
+ const rampGreen = paper[1] - ink[1];
+ const rampBlue = paper[2] - ink[2];
+ for (let index = 0; index < data.length; index += 4) {
+ const red = data[index];
+ const green = data[index + 1];
+ const blue = data[index + 2];
+ const high = red > green ? (red > blue ? red : blue) : green > blue ? green : blue;
+ const low = red < green ? (red < blue ? red : blue) : green < blue ? green : blue;
+ const chroma = high - low;
+ if (chroma >= CHROMATIC) continue;
+ const lightness = (high + low) / 510;
+ const tonedRed = ink[0] + rampRed * lightness;
+ const tonedGreen = ink[1] + rampGreen * lightness;
+ const tonedBlue = ink[2] + rampBlue * lightness;
+ if (chroma <= ACHROMATIC) {
+ data[index] = tonedRed;
+ data[index + 1] = tonedGreen;
+ data[index + 2] = tonedBlue;
+ continue;
+ }
+ // Part way across: fade the remap out so a coloured glyph's soft edge
+ // does not sit in a ring of paper-coloured fringe.
+ const keep = (chroma - ACHROMATIC) / span;
+ const take = 1 - keep;
+ data[index] = red * keep + tonedRed * take;
+ data[index + 1] = green * keep + tonedGreen * take;
+ data[index + 2] = blue * keep + tonedBlue * take;
+ }
+}
diff --git a/src/lib/domain/pdf-layout.spec.ts b/src/lib/domain/pdf-layout.spec.ts
new file mode 100644
index 0000000..29fac74
--- /dev/null
+++ b/src/lib/domain/pdf-layout.spec.ts
@@ -0,0 +1,401 @@
+import { describe, expect, it } from 'vitest';
+import {
+ alignWordStreams,
+ chunkText,
+ matchKey,
+ mergeWordRects,
+ pageWordBoxes,
+ placeSegments,
+ readingOrder,
+ segmentsForPage,
+ type PageWordBox,
+ type PlaceableSegment
+} from './pdf-layout';
+import type { SpeechSegment } from './types';
+
+/** Word boxes for a run of words laid out on one line, 10pt apart per
+ * character — enough geometry for the merge and hit-test rules to bite. */
+function line(text: string, y: number, startX = 0): PageWordBox[] {
+ const boxes: PageWordBox[] = [];
+ let x = startX;
+ for (const word of text.split(' ')) {
+ boxes.push({ text: word, x, y, width: word.length * 10, height: 12 });
+ x += word.length * 10 + 5;
+ }
+ return boxes;
+}
+
+function words(text: string): Array<{ text: string; start: number; end: number }> {
+ return [...text.matchAll(/\S+/g)].map((match) => ({
+ text: match[0],
+ start: match.index ?? 0,
+ end: (match.index ?? 0) + match[0].length
+ }));
+}
+
+function placeable(id: string, text: string, extra: Partial = {}) {
+ return { id, text, words: words(text), page: 1, ...extra };
+}
+
+describe('matchKey', () => {
+ it('folds the differences between a page and its extraction', () => {
+ expect(matchKey('Attention,')).toBe('attention');
+ // The page sets a ligature where the markdown carries two letters.
+ expect(matchKey('figures')).toBe(matchKey('figures'));
+ expect(matchKey('Café')).toBe('cafe');
+ expect(matchKey('“quoted”')).toBe('quoted');
+ });
+
+ it('is empty for text carrying no letters or digits', () => {
+ expect(matchKey('—')).toBe('');
+ expect(matchKey('·')).toBe('');
+ });
+});
+
+describe('pageWordBoxes', () => {
+ it('drops rotated stamps, which are not prose', () => {
+ const boxes = pageWordBoxes([
+ { text: 'arXiv:1706', x: 14, y: 380, width: 22, height: 160, rotation: 270 },
+ { text: 'Abstract', x: 100, y: 70, width: 50, height: 12, rotation: 0 }
+ ]);
+ expect(boxes.map((box) => box.text)).toEqual(['Abstract']);
+ });
+
+ it('falls back to the item box when a parse emitted no word boxes', () => {
+ const boxes = pageWordBoxes([
+ { text: 'one two', x: 10, y: 20, width: 60, height: 12 },
+ { text: ' ', x: 10, y: 40, width: 5, height: 12 }
+ ]);
+ expect(boxes).toEqual([{ text: 'one two', x: 10, y: 20, width: 60, height: 12 }]);
+ });
+
+ it('prefers word boxes when the parse emitted them', () => {
+ const boxes = pageWordBoxes([
+ {
+ text: 'one two',
+ x: 10,
+ y: 20,
+ width: 60,
+ height: 12,
+ words: [
+ { text: 'one', x: 10, y: 20, width: 25, height: 12 },
+ { text: 'two', x: 40, y: 20, width: 25, height: 12 }
+ ]
+ }
+ ]);
+ expect(boxes.map((box) => box.text)).toEqual(['one', 'two']);
+ });
+});
+
+describe('readingOrder', () => {
+ /** A two-column page: a full-width title, then lines that alternate
+ * between the columns exactly as a raster scan hands them over. */
+ function twoColumnPage(): PageWordBox[] {
+ const boxes = [...line('A Title Across The Page', 40, 150)];
+ for (let index = 0; index < 6; index += 1) {
+ const y = 100 + index * 14;
+ boxes.push(...line(`left${index}a left${index}b`, y, 50));
+ boxes.push(...line(`right${index}a right${index}b`, y + 2, 320));
+ }
+ return boxes;
+ }
+
+ it('reads each column through before starting the next', () => {
+ const ordered = readingOrder(twoColumnPage(), 612).map((box) => box.text);
+ const leftEnd = ordered.indexOf('left5b');
+ const rightStart = ordered.indexOf('right0a');
+ expect(ordered[0]).toBe('A');
+ expect(leftEnd).toBeLessThan(rightStart);
+ });
+
+ it('keeps a full-width heading ahead of the columns beneath it', () => {
+ const ordered = readingOrder(twoColumnPage(), 612).map((box) => box.text);
+ expect(ordered.slice(0, 5)).toEqual(['A', 'Title', 'Across', 'The', 'Page']);
+ });
+
+ it('leaves a single column in the order it arrived', () => {
+ const single = [
+ ...line('the first line of the paragraph', 100, 50),
+ ...line('the second line of the paragraph', 114, 50),
+ ...line('and a third', 128, 50)
+ ];
+ expect(readingOrder(single, 612)).toEqual(single);
+ });
+
+ it('reads a line left to right even when its words arrive scrambled', () => {
+ const words = line('alpha beta gamma', 100, 50);
+ const scrambled = [words[2], words[0], words[1]];
+ expect(readingOrder(scrambled, 612).map((box) => box.text)).toEqual(['alpha', 'beta', 'gamma']);
+ });
+
+ it('does not read a running head as two columns', () => {
+ // One line with something at each margin is a header, not a page of
+ // columns — splitting it would put the folio before the title.
+ const header = [
+ { text: 'Chapter', x: 50, y: 40, width: 40, height: 10 },
+ { text: '12', x: 540, y: 40, width: 12, height: 10 }
+ ];
+ expect(readingOrder(header, 612).map((box) => box.text)).toEqual(['Chapter', '12']);
+ });
+
+ it('has nothing to reorder on an empty or single-word page', () => {
+ expect(readingOrder([], 612)).toEqual([]);
+ const one = line('alone', 10, 10);
+ expect(readingOrder(one, 612)).toEqual(one);
+ });
+});
+
+describe('chunkText', () => {
+ it('keys whitespace-separated chunks and remembers where they came from', () => {
+ expect(chunkText('The Transformer, again')).toEqual([
+ { key: 'the', start: 0, end: 3 },
+ { key: 'transformer', start: 4, end: 16 },
+ { key: 'again', start: 17, end: 22 }
+ ]);
+ });
+
+ it('drops chunks that carry no evidence', () => {
+ expect(chunkText('a — b').map((chunk) => chunk.key)).toEqual(['a', 'b']);
+ });
+});
+
+describe('alignWordStreams', () => {
+ it('maps each expected word onto the page word it came from', () => {
+ const alignment = alignWordStreams(
+ ['the', 'dominant', 'sequence', 'transduction', 'models'],
+ ['dominant', 'sequence', 'transduction']
+ );
+ expect([...alignment]).toEqual([1, 2, 3]);
+ });
+
+ it('skips page words the spoken layer never says', () => {
+ const alignment = alignWordStreams(
+ ['figure', '1', 'the', 'transformer', 'model', 'architecture'],
+ ['the', 'transformer']
+ );
+ expect([...alignment]).toEqual([2, 3]);
+ });
+
+ it('reports nothing for expected words the page does not have', () => {
+ const alignment = alignWordStreams(['alpha', 'gamma'], ['alpha', 'beta', 'gamma']);
+ expect([...alignment]).toEqual([0, -1, 1]);
+ });
+
+ it('accepts the fragment a hyphenated line break leaves behind', () => {
+ const alignment = alignWordStreams(
+ ['achieves', 'englishto', 'translation'],
+ ['achieves', 'englishtogerman', 'translation']
+ );
+ expect([...alignment]).toEqual([0, 1, 2]);
+ });
+
+ it('resumes after a long stretch that belongs to only one side', () => {
+ // The page's table, which the spoken layer narrates in its own words —
+ // charged per word, this gap would end the alignment here.
+ const page = [
+ 'opening',
+ 'sentence',
+ ...Array.from({ length: 60 }, (_, index) => `cell${index}`),
+ 'closing',
+ 'sentence'
+ ];
+ const alignment = alignWordStreams(page, ['opening', 'sentence', 'closing', 'sentence']);
+ expect([...alignment]).toEqual([0, 1, 62, 63]);
+ });
+
+ it('never reorders: a repeated phrase matches the run in sequence', () => {
+ const alignment = alignWordStreams(['a', 'b', 'a', 'b'], ['a', 'b']);
+ for (let index = 1; index < alignment.length; index += 1) {
+ expect(alignment[index]).toBeGreaterThan(alignment[index - 1]);
+ }
+ });
+
+ it('gives up rather than guessing on an oversized page', () => {
+ const huge = Array.from({ length: 2100 }, (_, index) => `w${index}`);
+ expect([...alignWordStreams(huge, huge)].every((value) => value === -1)).toBe(true);
+ });
+});
+
+describe('mergeWordRects', () => {
+ it('merges a run of boxes on one line into a single rectangle', () => {
+ const boxes = line('one two three', 100);
+ const last = boxes[boxes.length - 1];
+ expect(mergeWordRects(boxes)).toEqual([
+ { x: 0, y: 100, width: last.x + last.width, height: 12 }
+ ]);
+ });
+
+ it('starts a new rectangle on the next line', () => {
+ const merged = mergeWordRects([...line('one two', 100), ...line('three', 120)]);
+ expect(merged).toHaveLength(2);
+ expect(merged[1]).toEqual({ x: 0, y: 120, width: 50, height: 12 });
+ });
+
+ it('breaks at a column jump rather than sweeping across the gutter', () => {
+ const merged = mergeWordRects([
+ { x: 40, y: 100, width: 40, height: 12 },
+ { x: 300, y: 100, width: 40, height: 12 },
+ { x: 40, y: 100, width: 40, height: 12 }
+ ]);
+ expect(merged).toHaveLength(2);
+ });
+});
+
+describe('placeSegments', () => {
+ const page = [
+ ...line('The dominant sequence transduction models are based on', 100),
+ ...line('complex recurrent or convolutional neural networks.', 120),
+ ...line('We propose a new simple network architecture.', 140)
+ ];
+
+ it('places a passage over the words it was extracted from', () => {
+ const [placement] = placeSegments(
+ page,
+ [placeable('s1', 'We propose a new simple network architecture.')],
+ 1
+ );
+ expect(placement.segmentId).toBe('s1');
+ expect(placement.coverage).toBe(1);
+ const third = line('We propose a new simple network architecture.', 140);
+ const last = third[third.length - 1];
+ expect(placement.rects).toEqual([{ x: 0, y: 140, width: last.x + last.width, height: 12 }]);
+ });
+
+ it('boxes each word of the passage separately', () => {
+ const [placement] = placeSegments(page, [placeable('s1', 'We propose a new')], 1);
+ expect(placement.wordRects.map((rect) => rect?.x)).toEqual(
+ line('We propose a new', 140).map((box) => box.x)
+ );
+ });
+
+ it('leaves a hole for a word the page does not carry', () => {
+ const [placement] = placeSegments(page, [placeable('s1', 'We hereby propose a new')], 1);
+ expect(placement.wordRects[1]).toBeUndefined();
+ expect(placement.wordRects[0]).toBeDefined();
+ expect(placement.wordRects[2]).toBeDefined();
+ });
+
+ it('refuses a passage that only brushed the page', () => {
+ expect(
+ placeSegments(page, [placeable('s1', 'Entirely unrelated prose about a cat')], 1)
+ ).toEqual([]);
+ });
+
+ it('keeps passages in document order across the page', () => {
+ const placements = placeSegments(
+ page,
+ [
+ placeable('s1', 'The dominant sequence transduction models'),
+ placeable('s2', 'We propose a new simple network architecture.')
+ ],
+ 1
+ );
+ expect(placements.map((placement) => placement.segmentId)).toEqual(['s1', 's2']);
+ expect(placements[0].rects[0].y).toBeLessThan(placements[1].rects[0].y);
+ });
+
+ it('looks for what the page prints when that differs from what is spoken', () => {
+ const table = [
+ ...line('Layer Type Complexity per Layer', 200),
+ ...line('Self-Attention Onnd Restricted', 220)
+ ];
+ // The row is narrated with its header labels folded in; only its cells
+ // are on the page.
+ const spoken = 'Layer Type: Self-Attention. Complexity per Layer: Onnd.';
+ // Spoken as-is, the header labels pull the row up onto the header line.
+ const [narrated] = placeSegments(table, [placeable('row', spoken)], 1);
+ expect(narrated.rects[0].y).toBe(200);
+ const [placement] = placeSegments(
+ table,
+ [placeable('row', spoken, { matchText: 'Self-Attention Onnd Restricted' })],
+ 1
+ );
+ expect(placement.rects[0].y).toBe(220);
+ // The words being spoken are not the words on the page.
+ expect(placement.wordRects.every((rect) => rect === undefined)).toBe(true);
+ });
+
+ it('finds a run of rows once its neighbours have staked out the region', () => {
+ const withTable = [
+ ...line('Opening sentence above the table', 100),
+ ...line('Alpha ninety Beta eighty', 130),
+ ...line('Gamma seventy Delta sixty', 150),
+ ...line('Closing sentence below the table', 180)
+ ];
+ const placements = placeSegments(
+ withTable,
+ [
+ placeable('s1', 'Opening sentence above the table'),
+ placeable('r1', 'Alpha ninety Beta eighty'),
+ placeable('r2', 'Gamma seventy Delta sixty'),
+ placeable('s2', 'Closing sentence below the table')
+ ],
+ 1
+ );
+ expect(placements.map((placement) => placement.segmentId)).toEqual(['s1', 'r1', 'r2', 's2']);
+ expect(placements[1].rects[0].y).toBe(130);
+ expect(placements[2].rects[0].y).toBe(150);
+ });
+
+ it('has nothing to say about a page with no words', () => {
+ expect(placeSegments([], [placeable('s1', 'anything')], 1)).toEqual([]);
+ });
+});
+
+describe('segmentsForPage', () => {
+ function segment(id: string, text: string, page: number, blockId = 'b1'): SpeechSegment {
+ return {
+ id,
+ blockId,
+ text,
+ normalizedText: text,
+ start: 0,
+ end: text.length,
+ words: words(text),
+ estimatedDuration: 1,
+ anchor: { page }
+ };
+ }
+
+ it('is empty when nothing is anchored to the page', () => {
+ expect(segmentsForPage([segment('s1', 'one', 1)], 4)).toEqual([]);
+ });
+
+ it('reaches into the neighbouring pages for passages that straddle the break', () => {
+ const segments = [
+ segment('before', 'a sentence ending the previous page', 1),
+ segment('own', 'a sentence of its own', 2),
+ segment('after', 'a sentence opening the next page', 3)
+ ];
+ expect(segmentsForPage(segments, 2).map((entry) => entry.id)).toEqual([
+ 'before',
+ 'own',
+ 'after'
+ ]);
+ });
+
+ it('stops spilling once the word budget runs out', () => {
+ const long = Array.from({ length: 80 }, (_, index) =>
+ segment(`p1-${index}`, 'ten words here that fill up the spill budget fast', 1)
+ );
+ const ids = segmentsForPage([...long, segment('own', 'the page itself', 2)], 2).map(
+ (entry) => entry.id
+ );
+ expect(ids).toContain('own');
+ expect(ids).not.toContain('p1-0');
+ });
+
+ it('carries the construct source through as what to look for on the page', () => {
+ const row = segment('row', 'Layer Type: Self-Attention.', 2);
+ row.start = 4;
+ row.end = 22;
+ const placeables = segmentsForPage([row], 2, new Map([['b1', 'xxx Self-Attention Onnd xxx']]));
+ expect(placeables[0].matchText).toBe('Self-Attention Onn');
+ });
+
+ it('leaves plain prose alone, so its words keep their boxes', () => {
+ const prose = segment('prose', 'a sentence of its own', 2);
+ const placeables = segmentsForPage([prose], 2, new Map([['b1', 'a sentence of its own']]));
+ expect(placeables[0].matchText).toBeUndefined();
+ });
+});
diff --git a/src/lib/domain/pdf-layout.ts b/src/lib/domain/pdf-layout.ts
new file mode 100644
index 0000000..ce3a663
--- /dev/null
+++ b/src/lib/domain/pdf-layout.ts
@@ -0,0 +1,729 @@
+import type { SpeechSegment } from './types';
+
+/**
+ * Placing the spoken layer back onto the original page.
+ *
+ * The reader's passages come from the markdown LiteParse extracted; the page
+ * view draws the PDF itself. To highlight a passage where it actually sits on
+ * paper, the two have to be reconciled — and the only trustworthy bridge is
+ * that LiteParse can emit a box for every word it read (`emitWordBoxes`), so
+ * both sides descend from the same glyphs.
+ *
+ * Reconciliation is a monotonic sequence alignment: the page's words in
+ * reading order against the words of the passages anchored to that page.
+ * Content the spoken layer skips (equations, figure labels, running heads)
+ * falls out as gaps, and so does markdown the page renders differently
+ * (table pipes, heading hashes) — neither derails the words on either side of
+ * it. Nothing here touches the DOM or pdf.js: it is pure geometry over two
+ * token streams, so the hard part is testable.
+ *
+ * Coordinates throughout are PDF points with a **top-left** origin, matching
+ * LiteParse's boxes and the CSS the overlay ends up writing.
+ */
+
+export interface PageWordBox {
+ text: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/** The slice of LiteParse's TextItem this module needs. */
+export interface PageTextItem {
+ text: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ /** Degrees off horizontal; rotated stamps (arXiv spines) are not prose. */
+ rotation?: number;
+ words?: PageWordBox[];
+}
+
+export interface PageRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/** Where one passage landed on one page. */
+export interface SegmentPlacement {
+ segmentId: string;
+ page: number;
+ /** Line-merged rectangles covering the passage — what the overlay paints. */
+ rects: PageRect[];
+ /** Index-aligned to `SpeechSegment.words`; a hole is a word that found no
+ * box (a substituted equation reading, a word the page hyphenated away). */
+ wordRects: Array;
+ /** Share of the passage's words that found a box, 0–1. */
+ coverage: number;
+}
+
+/** A passage reduced to what placement needs, so tests need no full segment. */
+export interface PlaceableSegment {
+ id: string;
+ text: string;
+ words: Array<{ start: number; end: number }>;
+ page: number;
+ /**
+ * What to look for on the page, when that differs from what is spoken. A
+ * table row is narrated as "Layer Type: Self-Attention. Complexity per
+ * Layer: …" — prose assembled from the header labels, which the page never
+ * printed in that form. Its cells, on the other hand, are right there.
+ * Passages placed this way get no per-word boxes: the words being spoken
+ * are not the words on the page, and only the region is meaningful.
+ */
+ matchText?: string;
+}
+
+/**
+ * The comparison form of a word: compatibility-decomposed (so a `fi` ligature
+ * on the page meets the `fi` in the markdown), unaccented, lowercased, and
+ * stripped of everything that is not a letter or a digit. Punctuation must go
+ * — the page hyphenates and the markdown does not, and quotes differ on both
+ * sides of every extraction.
+ */
+export function matchKey(text: string): string {
+ return text
+ .normalize('NFKD')
+ .replace(/\p{M}+/gu, '')
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}]+/gu, '');
+}
+
+/** Every word LiteParse read on a page, in its reading order. Items without
+ * word boxes (older parses, OCR text) contribute their whole box, which still
+ * highlights at line granularity. */
+export function pageWordBoxes(items: PageTextItem[]): PageWordBox[] {
+ const boxes: PageWordBox[] = [];
+ for (const item of items) {
+ if (item.rotation !== undefined && Math.abs(item.rotation) > 1) continue;
+ if (item.words?.length) {
+ for (const word of item.words) if (word.text.trim()) boxes.push(word);
+ } else if (item.text.trim()) {
+ boxes.push({
+ text: item.text,
+ x: item.x,
+ y: item.y,
+ width: item.width,
+ height: item.height
+ });
+ }
+ }
+ return boxes;
+}
+
+/**
+ * Reading order for a page's words.
+ *
+ * LiteParse hands back word boxes in raster order — every line of a
+ * two-column page interleaved with the line beside it. Its *markdown* reads
+ * the columns properly, so the passages arrive in true reading order and the
+ * boxes do not, and a monotonic alignment between the two finds almost
+ * nothing. (Measured on a two-column paper: 57% of passages placed, against
+ * 90% on a single-column one.)
+ *
+ * A recursive XY-cut recovers the order. At each step the region is split at
+ * whitespace that runs all the way across it — vertical gutters first,
+ * because a page whose body is two columns must break into columns before it
+ * breaks into paragraphs, or the halves interleave again. A single-column
+ * page finds no gutter, splits into paragraphs, and comes out in the order it
+ * already had.
+ */
+export function readingOrder(boxes: PageWordBox[], pageWidth: number): PageWordBox[] {
+ if (boxes.length < 2) return boxes;
+ const line = medianHeight(boxes);
+ const columnGap = Math.max(10, pageWidth * 0.025);
+ const rowGap = Math.max(2, line * 0.8);
+ return cut(boxes, columnGap, rowGap, line, 0);
+}
+
+/** Typical line height on the page, used to size the gaps that count as
+ * whitespace. The median shrugs off headings and subscripts. */
+function medianHeight(boxes: PageWordBox[]): number {
+ const heights = boxes.map((box) => box.height).sort((left, right) => left - right);
+ return heights[heights.length >> 1] || 10;
+}
+
+/** Regions this deep are paragraphs; splitting further only costs time. */
+const MAX_CUT_DEPTH = 8;
+/** A vertical split needs a region tall enough to be a column. Without this,
+ * a single line with something at each margin — a running head, a page
+ * number — reads as two columns. */
+const MIN_COLUMN_LINES = 4;
+
+function cut(
+ boxes: PageWordBox[],
+ columnGap: number,
+ rowGap: number,
+ line: number,
+ depth: number
+): PageWordBox[] {
+ if (boxes.length < 2 || depth >= MAX_CUT_DEPTH) return byLine(boxes);
+ const height = extent(boxes, 'y');
+ if (height >= line * MIN_COLUMN_LINES) {
+ const columns = split(boxes, 'x', columnGap);
+ if (columns.length > 1) {
+ return columns.flatMap((column) => cut(column, columnGap, rowGap, line, depth + 1));
+ }
+ }
+ const rows = split(boxes, 'y', rowGap);
+ if (rows.length > 1) {
+ return rows.flatMap((row) => cut(row, columnGap, rowGap, line, depth + 1));
+ }
+ return byLine(boxes);
+}
+
+function extent(boxes: PageWordBox[], axis: 'x' | 'y'): number {
+ const size = axis === 'x' ? 'width' : 'height';
+ let low = Infinity;
+ let high = -Infinity;
+ for (const box of boxes) {
+ low = Math.min(low, box[axis]);
+ high = Math.max(high, box[axis] + box[size]);
+ }
+ return high - low;
+}
+
+/** Split a region wherever whitespace runs all the way across it on one axis,
+ * keeping the pieces in ascending order. */
+function split(boxes: PageWordBox[], axis: 'x' | 'y', minGap: number): PageWordBox[][] {
+ const size = axis === 'x' ? 'width' : 'height';
+ const ordered = [...boxes].sort((left, right) => left[axis] - right[axis]);
+ const groups: PageWordBox[][] = [];
+ let group: PageWordBox[] = [];
+ let reach = -Infinity;
+ for (const box of ordered) {
+ if (group.length && box[axis] - reach > minGap) {
+ groups.push(group);
+ group = [];
+ }
+ group.push(box);
+ reach = Math.max(reach, box[axis] + box[size]);
+ }
+ if (group.length) groups.push(group);
+ return groups;
+}
+
+/** The last word: rows of type, each read left to right. */
+function byLine(boxes: PageWordBox[]): PageWordBox[] {
+ const ordered = [...boxes].sort((left, right) => left.y - right.y || left.x - right.x);
+ const lines: PageWordBox[][] = [];
+ for (const box of ordered) {
+ const current = lines[lines.length - 1];
+ const previous = current?.[0];
+ const sameLine =
+ previous &&
+ Math.abs(box.y + box.height / 2 - (previous.y + previous.height / 2)) <
+ Math.max(box.height, previous.height) / 2;
+ if (sameLine) current.push(box);
+ else lines.push([box]);
+ }
+ return lines.flatMap((entries) => entries.sort((left, right) => left.x - right.x));
+}
+
+interface Chunk {
+ key: string;
+ start: number;
+ end: number;
+}
+
+/** Whitespace-separated chunks with their character ranges, keyed for
+ * comparison. Splitting on whitespace (not on letter runs) keeps both streams
+ * tokenized the same way, so `don't` stays one token against the page's one
+ * box for it. Chunks that key to nothing — a lone bullet, a stray dash — are
+ * dropped: they carry no evidence and would only invite false matches. */
+export function chunkText(text: string): Chunk[] {
+ const chunks: Chunk[] = [];
+ for (const match of text.matchAll(/\S+/gu)) {
+ const start = match.index ?? 0;
+ const key = matchKey(match[0]);
+ if (key) chunks.push({ key, start, end: start + match[0].length });
+ }
+ return chunks;
+}
+
+/** Cheap enough to be exact for a page; the guard only exists so a pathological
+ * page (a word list, a dense table) cannot lock the main thread. */
+const MAX_ALIGNMENT_CELLS = 4_000_000;
+
+const SCORE_MATCH = 1;
+/** A hyphenated break leaves the page holding a fragment of the markdown's
+ * word (`englishto` for `englishtogerman`). Scoring that as most of a match
+ * keeps the alignment on the diagonal, where treating it as a mismatch would
+ * derail it. */
+const SCORE_PARTIAL = 0.5;
+const SCORE_MISMATCH = -1;
+/**
+ * Skips are priced as one decision, not per word: opening a run of unmatched
+ * words costs, continuing it barely does. This is what lets the two streams
+ * survive each other's bulk. A page carries equations, axis labels and
+ * running heads the spoken layer never says; the spoken layer carries whole
+ * paragraphs the page never printed (a table's narration, "A table with
+ * columns: …"). Charged per word, one such stretch would outweigh every match
+ * after it and the alignment would simply stop there — which is exactly what
+ * a flat gap penalty did: everything below Table 1 on a page went unplaced.
+ *
+ * Opening a gap also has to stay cheaper than a single match is worth, or the
+ * free ends win instead: an alignment that starts late and matches three
+ * words cleanly would outscore one that matches five with a skip in the
+ * middle, and the two words before the skip would be dropped for no reason.
+ */
+const SCORE_GAP_OPEN = -0.6;
+const SCORE_GAP_EXTEND = -0.02;
+
+function pairScore(left: string, right: string): number {
+ if (left === right) return SCORE_MATCH;
+ if (left.length >= 4 && right.length >= 4 && (left.startsWith(right) || right.startsWith(left))) {
+ return SCORE_PARTIAL;
+ }
+ return SCORE_MISMATCH;
+}
+
+/** Traceback states: a pair, an unmatched expected word, an unmatched page
+ * word. */
+const PAIRED = 0;
+const EXPECTED_GAP = 1;
+const PAGE_GAP = 2;
+
+/**
+ * Monotonic alignment of the expected words (the spoken layer's) against the
+ * page's, returning the page index each expected word landed on, or -1.
+ *
+ * Gotoh's affine-gap alignment with both ends free. Free ends because neither
+ * stream has to be consumed whole — the expected stream deliberately carries
+ * a little of the neighbouring pages. Affine gaps because both streams are
+ * full of material the other lacks, and skipping it has to stay cheap enough
+ * that the alignment resumes afterwards (see the gap constants above).
+ *
+ * Only pairs that actually agree are reported: the path threads through
+ * mismatches to stay on course, but a mismatch is not evidence of where a
+ * word sits.
+ */
+export function alignWordStreams(pageKeys: string[], expectedKeys: string[]): Int32Array {
+ const rows = expectedKeys.length;
+ const columns = pageKeys.length;
+ const result = new Int32Array(rows).fill(-1);
+ if (!rows || !columns || (rows + 1) * (columns + 1) > MAX_ALIGNMENT_CELLS) return result;
+
+ const width = columns + 1;
+ // Scores roll row by row; only the traceback needs the whole grid.
+ let pairedRow = new Float32Array(width);
+ let expectedGapRow = new Float32Array(width);
+ let pageGapRow = new Float32Array(width);
+ let nextPaired = new Float32Array(width);
+ let nextExpectedGap = new Float32Array(width);
+ let nextPageGap = new Float32Array(width);
+ // Which state each cell was reached from: for a pair, the predecessor
+ // state; for a gap, whether it opened (0) or extended (1).
+ const fromPaired = new Uint8Array((rows + 1) * width);
+ const fromExpectedGap = new Uint8Array((rows + 1) * width);
+ const fromPageGap = new Uint8Array((rows + 1) * width);
+
+ let bestScore = 0;
+ let bestRow = 0;
+ let bestColumn = 0;
+ let bestState = PAIRED;
+
+ for (let row = 1; row <= rows; row += 1) {
+ // Row 0 and column 0 stay at zero: an alignment may start anywhere in
+ // either stream without paying for the prefix it skipped.
+ nextPaired[0] = 0;
+ nextExpectedGap[0] = 0;
+ nextPageGap[0] = 0;
+ const base = row * width;
+ for (let column = 1; column <= columns; column += 1) {
+ const previous = Math.max(
+ pairedRow[column - 1],
+ expectedGapRow[column - 1],
+ pageGapRow[column - 1]
+ );
+ nextPaired[column] = previous + pairScore(expectedKeys[row - 1], pageKeys[column - 1]);
+ fromPaired[base + column] =
+ previous === pairedRow[column - 1]
+ ? PAIRED
+ : previous === expectedGapRow[column - 1]
+ ? EXPECTED_GAP
+ : PAGE_GAP;
+
+ const openExpected = pairedRow[column] + SCORE_GAP_OPEN + SCORE_GAP_EXTEND;
+ const extendExpected = expectedGapRow[column] + SCORE_GAP_EXTEND;
+ nextExpectedGap[column] = Math.max(openExpected, extendExpected);
+ fromExpectedGap[base + column] = extendExpected > openExpected ? 1 : 0;
+
+ const openPage = nextPaired[column - 1] + SCORE_GAP_OPEN + SCORE_GAP_EXTEND;
+ const extendPage = nextPageGap[column - 1] + SCORE_GAP_EXTEND;
+ nextPageGap[column] = Math.max(openPage, extendPage);
+ fromPageGap[base + column] = extendPage > openPage ? 1 : 0;
+
+ // Ending is free as well, so the alignment stops at its best point on
+ // either final edge rather than being dragged into the far corner.
+ if ((row === rows || column === columns) && nextPaired[column] > bestScore) {
+ bestScore = nextPaired[column];
+ bestRow = row;
+ bestColumn = column;
+ bestState = PAIRED;
+ }
+ }
+ [pairedRow, nextPaired] = [nextPaired, pairedRow];
+ [expectedGapRow, nextExpectedGap] = [nextExpectedGap, expectedGapRow];
+ [pageGapRow, nextPageGap] = [nextPageGap, pageGapRow];
+ }
+
+ let row = bestRow;
+ let column = bestColumn;
+ let state = bestState;
+ while (row > 0 && column > 0) {
+ const cell = row * width + column;
+ if (state === PAIRED) {
+ if (pairScore(expectedKeys[row - 1], pageKeys[column - 1]) > 0) {
+ result[row - 1] = column - 1;
+ }
+ state = fromPaired[cell];
+ row -= 1;
+ column -= 1;
+ } else if (state === EXPECTED_GAP) {
+ state = fromExpectedGap[cell] === 1 ? EXPECTED_GAP : PAIRED;
+ row -= 1;
+ } else {
+ state = fromPageGap[cell] === 1 ? PAGE_GAP : PAIRED;
+ column -= 1;
+ }
+ }
+ return result;
+}
+
+function unionRect(rects: PageRect[]): PageRect {
+ let left = Infinity;
+ let top = Infinity;
+ let right = -Infinity;
+ let bottom = -Infinity;
+ for (const rect of rects) {
+ left = Math.min(left, rect.x);
+ top = Math.min(top, rect.y);
+ right = Math.max(right, rect.x + rect.width);
+ bottom = Math.max(bottom, rect.y + rect.height);
+ }
+ return { x: left, y: top, width: right - left, height: bottom - top };
+}
+
+/**
+ * Collapse a passage's word boxes into one rectangle per line, in reading
+ * order. Boxes join a run while they sit on the same line (vertical centres
+ * within half a line height) and do not jump backwards across the page —
+ * a backwards jump is a column break, which has to start a new rectangle or
+ * the highlight would sweep across the gutter.
+ */
+export function mergeWordRects(boxes: PageRect[]): PageRect[] {
+ const merged: PageRect[] = [];
+ let run: PageRect[] = [];
+ const flush = () => {
+ if (run.length) merged.push(unionRect(run));
+ run = [];
+ };
+ for (const box of boxes) {
+ const previous = run[run.length - 1];
+ if (previous) {
+ const sameLine =
+ Math.abs(box.y + box.height / 2 - (previous.y + previous.height / 2)) <
+ Math.max(box.height, previous.height) / 2;
+ if (!sameLine || box.x + box.width < previous.x) flush();
+ }
+ run.push(box);
+ }
+ flush();
+ return merged;
+}
+
+/**
+ * Below this, a placement is guesswork: a couple of stray words matched
+ * somewhere on the page while the passage itself is elsewhere. Painting those
+ * would put the highlight in the wrong place, which is worse than not
+ * painting it. The refinement pass is allowed to be less strict, because by
+ * then the passage is boxed in by its placed neighbours and cannot land far
+ * from where it belongs.
+ */
+const MIN_COVERAGE = 0.35;
+const MIN_REFINED_COVERAGE = 0.2;
+/** One refinement of each unclaimed gap is enough in practice; the limit is
+ * only here so a pathological page cannot recurse indefinitely. */
+const MAX_REFINEMENT_DEPTH = 2;
+
+interface ExpectedChunk extends Chunk {
+ segment: number;
+}
+
+/**
+ * Align a slice of the expected stream against a slice of the page, then look
+ * at what went unplaced.
+ *
+ * A run of passages that all failed together usually failed for one reason:
+ * the page renders them in a form the spoken layer rewrote — a table, whose
+ * rows are narrated as "Layer Type: Self-Attention. Complexity per Layer: …"
+ * against a page holding a grid of bare cells. Whole-page alignment cannot
+ * find those rows among a thousand competing words, but the run is bracketed
+ * by passages that *did* place, and the page words between those two brackets
+ * are exactly the table. Aligning the run against that much smaller stretch
+ * usually lands it.
+ */
+function assignRange(
+ boxes: PageWordBox[],
+ pageKeys: string[],
+ expected: ExpectedChunk[],
+ matches: Array,
+ range: { boxLow: number; boxHigh: number; chunkLow: number; chunkHigh: number },
+ depth: number
+): void {
+ const { boxLow, boxHigh, chunkLow, chunkHigh } = range;
+ if (boxLow > boxHigh || chunkLow > chunkHigh) return;
+ const alignment = alignWordStreams(
+ pageKeys.slice(boxLow, boxHigh + 1),
+ expected.slice(chunkLow, chunkHigh + 1).map((chunk) => chunk.key)
+ );
+ for (let index = 0; index < alignment.length; index += 1) {
+ const box = alignment[index];
+ if (box >= 0) matches[chunkLow + index].push(boxLow + box);
+ }
+
+ const minimum = depth === 0 ? MIN_COVERAGE : MIN_REFINED_COVERAGE;
+ // Which of the passages in this range came out placed, so the gaps between
+ // them can be retried against the page they must lie on.
+ const segmentLow = expected[chunkLow].segment;
+ const segmentHigh = expected[chunkHigh].segment;
+ const placed = new Map();
+ for (let segment = segmentLow; segment <= segmentHigh; segment += 1) {
+ let total = 0;
+ let first = Infinity;
+ let last = -Infinity;
+ for (let index = chunkLow; index <= chunkHigh; index += 1) {
+ if (expected[index].segment !== segment) continue;
+ total += 1;
+ const box = matches[index][matches[index].length - 1];
+ if (box === undefined) continue;
+ first = Math.min(first, box);
+ last = Math.max(last, box);
+ }
+ const covered = countMatched(expected, matches, chunkLow, chunkHigh, segment);
+ if (total && covered / total >= minimum) placed.set(segment, { first, last });
+ }
+ if (depth >= MAX_REFINEMENT_DEPTH) return;
+
+ let runStart: number | undefined;
+ for (let segment = segmentLow; segment <= segmentHigh + 1; segment += 1) {
+ if (segment <= segmentHigh && !placed.has(segment)) {
+ runStart ??= segment;
+ continue;
+ }
+ if (runStart === undefined) continue;
+ const runEnd = segment - 1;
+ const before = previousPlaced(placed, runStart - 1, segmentLow);
+ const after = nextPlaced(placed, runEnd + 1, segmentHigh);
+ const nextRange = {
+ boxLow: before === undefined ? boxLow : before + 1,
+ boxHigh: after === undefined ? boxHigh : after - 1,
+ chunkLow: firstChunkOf(expected, chunkLow, chunkHigh, runStart),
+ chunkHigh: lastChunkOf(expected, chunkLow, chunkHigh, runEnd)
+ };
+ runStart = undefined;
+ // No narrowing happened: retrying the same range would only repeat this
+ // pass's answer.
+ if (
+ nextRange.chunkLow === undefined ||
+ nextRange.chunkHigh === undefined ||
+ (nextRange.boxLow === boxLow && nextRange.boxHigh === boxHigh)
+ ) {
+ continue;
+ }
+ assignRange(
+ boxes,
+ pageKeys,
+ expected,
+ matches,
+ nextRange as { boxLow: number; boxHigh: number; chunkLow: number; chunkHigh: number },
+ depth + 1
+ );
+ }
+}
+
+function countMatched(
+ expected: ExpectedChunk[],
+ matches: Array,
+ low: number,
+ high: number,
+ segment: number
+): number {
+ let count = 0;
+ for (let index = low; index <= high; index += 1) {
+ if (expected[index].segment === segment && matches[index].length) count += 1;
+ }
+ return count;
+}
+
+function previousPlaced(
+ placed: Map,
+ from: number,
+ low: number
+): number | undefined {
+ for (let segment = from; segment >= low; segment -= 1) {
+ const entry = placed.get(segment);
+ if (entry) return entry.last;
+ }
+ return undefined;
+}
+
+function nextPlaced(
+ placed: Map,
+ from: number,
+ high: number
+): number | undefined {
+ for (let segment = from; segment <= high; segment += 1) {
+ const entry = placed.get(segment);
+ if (entry) return entry.first;
+ }
+ return undefined;
+}
+
+function firstChunkOf(
+ expected: ExpectedChunk[],
+ low: number,
+ high: number,
+ segment: number
+): number | undefined {
+ for (let index = low; index <= high; index += 1) {
+ if (expected[index].segment === segment) return index;
+ }
+ return undefined;
+}
+
+function lastChunkOf(
+ expected: ExpectedChunk[],
+ low: number,
+ high: number,
+ segment: number
+): number | undefined {
+ for (let index = high; index >= low; index -= 1) {
+ if (expected[index].segment === segment) return index;
+ }
+ return undefined;
+}
+
+/**
+ * Place every passage anchored to a page onto that page's words.
+ *
+ * `segments` should be given in document order and may include a little of the
+ * neighbouring pages: a paragraph that starts on one page and finishes on the
+ * next is one passage, and the alignment's free end gaps let the part that
+ * belongs elsewhere go unmatched rather than dragging the rest off course.
+ */
+export function placeSegments(
+ boxes: PageWordBox[],
+ segments: PlaceableSegment[],
+ page: number
+): SegmentPlacement[] {
+ const pageKeys = boxes.map((box) => matchKey(box.text));
+ const expected: ExpectedChunk[] = [];
+ segments.forEach((segment, index) => {
+ for (const chunk of chunkText(segment.matchText ?? segment.text)) {
+ expected.push({ ...chunk, segment: index });
+ }
+ });
+ if (!expected.length || !pageKeys.length) return [];
+ const matches: Array = expected.map(() => []);
+ assignRange(
+ boxes,
+ pageKeys,
+ expected,
+ matches,
+ { boxLow: 0, boxHigh: pageKeys.length - 1, chunkLow: 0, chunkHigh: expected.length - 1 },
+ 0
+ );
+
+ const placements: SegmentPlacement[] = [];
+ segments.forEach((segment, index) => {
+ const matched: Array<{ chunk: Chunk; box: PageWordBox }> = [];
+ let total = 0;
+ expected.forEach((chunk, chunkIndex) => {
+ if (chunk.segment !== index) return;
+ total += 1;
+ // The last assignment wins: a refinement pass ran against a narrower
+ // stretch of the page and knows better than the whole-page sweep.
+ const box = matches[chunkIndex][matches[chunkIndex].length - 1];
+ if (box !== undefined) matched.push({ chunk, box: boxes[box] });
+ });
+ const coverage = total ? matched.length / total : 0;
+ if (!matched.length || coverage < MIN_REFINED_COVERAGE) return;
+ matched.sort((left, right) => left.chunk.start - right.chunk.start);
+ const wordRects = segment.matchText
+ ? segment.words.map(() => undefined)
+ : segment.words.map((word) => {
+ const overlapping = matched
+ .filter(({ chunk }) => chunk.start < word.end && chunk.end > word.start)
+ .map(({ box }) => box);
+ return overlapping.length ? unionRect(overlapping) : undefined;
+ });
+ placements.push({
+ segmentId: segment.id,
+ page,
+ rects: mergeWordRects(matched.map(({ box }) => box)),
+ wordRects,
+ coverage
+ });
+ });
+ return placements;
+}
+
+/** How much of each neighbouring page's passages to align alongside a page's
+ * own, in words. A paragraph that straddles a page break is one passage list
+ * anchored to the earlier page, so the later page has to look back far enough
+ * to find the sentences that actually landed on it. */
+const SPILL_WORDS = 400;
+
+/** The passages to align against one page: everything anchored to it, plus a
+ * stretch of each neighbour so a paragraph straddling the page break is placed
+ * from whichever side is looking.
+ *
+ * `blockText` (block id → block text) is what lets a narrated construct be
+ * looked for as it appears on paper rather than as it is spoken: a segment's
+ * `start`/`end` index its block's text, so that slice is the construct's own
+ * source — a table row's cells, an equation's characters — while
+ * `segment.text` is the reading. Without it, tables and equations simply go
+ * unplaced.
+ */
+export function segmentsForPage(
+ segments: SpeechSegment[],
+ page: number,
+ blockText?: ReadonlyMap,
+ spillWords = SPILL_WORDS
+): PlaceableSegment[] {
+ let first = -1;
+ let last = -1;
+ segments.forEach((segment, index) => {
+ if (segment.anchor.page !== page) return;
+ if (first < 0) first = index;
+ last = index;
+ });
+ if (first < 0) return [];
+ let from = first;
+ for (let budget = spillWords; from > 0 && budget > 0; from -= 1) {
+ budget -= segments[from - 1].words.length;
+ }
+ let to = last;
+ for (let budget = spillWords; to < segments.length - 1 && budget > 0; to += 1) {
+ budget -= segments[to + 1].words.length;
+ }
+ const placeable: PlaceableSegment[] = [];
+ for (let index = from; index <= to; index += 1) {
+ const segment = segments[index];
+ const source = blockText?.get(segment.blockId)?.slice(segment.start, segment.end);
+ placeable.push({
+ id: segment.id,
+ text: segment.text,
+ words: segment.words,
+ page: segment.anchor.page ?? page,
+ ...(source && source !== segment.text ? { matchText: source } : {})
+ });
+ }
+ return placeable;
+}
diff --git a/src/lib/services/pdf-layout.spec.ts b/src/lib/services/pdf-layout.spec.ts
new file mode 100644
index 0000000..dbc3d9a
--- /dev/null
+++ b/src/lib/services/pdf-layout.spec.ts
@@ -0,0 +1,167 @@
+import { describe, expect, it, vi } from 'vitest';
+import { DocumentLayout, type PageLayout, type PageWindowReader } from './pdf-layout';
+import type { SpeechSegment } from '../domain/types';
+
+const BYTES = new Uint8Array([1, 2, 3]);
+
+/** A page of three words on one line, positioned so placement has something
+ * to bite on. */
+function page(number: number, words: string[]): PageLayout {
+ let x = 72;
+ return {
+ page: number,
+ width: 612,
+ height: 792,
+ boxes: words.map((text) => {
+ const box = { text, x, y: 100, width: text.length * 6, height: 12 };
+ x += text.length * 6 + 4;
+ return box;
+ })
+ };
+}
+
+function reader(pages: Record): {
+ read: PageWindowReader;
+ calls: Array<[number, number]>;
+} {
+ const calls: Array<[number, number]> = [];
+ const read: PageWindowReader = async (_data, from, to) => {
+ calls.push([from, to]);
+ const out: PageLayout[] = [];
+ for (let number = from; number <= to; number += 1) {
+ if (pages[number]) out.push(page(number, pages[number]));
+ }
+ return out;
+ };
+ return { read, calls };
+}
+
+function layoutFor(pages: Record, source: Uint8Array | null = BYTES) {
+ const { read, calls } = reader(pages);
+ return { layout: new DocumentLayout('doc', async () => source, read), calls };
+}
+
+function segment(id: string, text: string, pageNumber: number): SpeechSegment {
+ return {
+ id,
+ blockId: 'b1',
+ text,
+ normalizedText: text,
+ start: 0,
+ end: text.length,
+ words: [...text.matchAll(/\S+/g)].map((match) => ({
+ text: match[0],
+ start: match.index ?? 0,
+ end: (match.index ?? 0) + match[0].length
+ })),
+ estimatedDuration: 1,
+ anchor: { page: pageNumber }
+ };
+}
+
+describe('DocumentLayout', () => {
+ it('reads a window around the page asked for, leaning ahead', async () => {
+ const { layout, calls } = layoutFor({ 3: ['alpha'] });
+ await layout.pageLayout(3, 20);
+ expect(calls).toEqual([[2, 5]]);
+ });
+
+ it('does not read past the end of the document', async () => {
+ const { layout, calls } = layoutFor({ 5: ['alpha'] });
+ await layout.pageLayout(5, 5);
+ expect(calls).toEqual([[4, 5]]);
+ });
+
+ it('serves later pages of a window without reading again', async () => {
+ const { layout, calls } = layoutFor({ 2: ['alpha'], 3: ['beta'], 4: ['gamma'] });
+ await layout.pageLayout(2, 10);
+ const ahead = await layout.pageLayout(4, 10);
+ expect(ahead?.boxes[0].text).toBe('gamma');
+ expect(calls).toHaveLength(1);
+ });
+
+ it('joins a read already in flight rather than starting a second', async () => {
+ const { layout, calls } = layoutFor({ 1: ['alpha'], 2: ['beta'] });
+ const [first, second] = await Promise.all([layout.pageLayout(1, 10), layout.pageLayout(2, 10)]);
+ expect(first?.page).toBe(1);
+ expect(second?.page).toBe(2);
+ expect(calls).toHaveLength(1);
+ });
+
+ it('remembers a page the read had nothing for, and stops asking', async () => {
+ const { layout, calls } = layoutFor({ 1: ['alpha'] });
+ expect(await layout.pageLayout(2, 10)).toBeNull();
+ expect(await layout.pageLayout(2, 10)).toBeNull();
+ expect(calls).toHaveLength(1);
+ });
+
+ it('has no geometry when the file is gone from this device', async () => {
+ const { layout, calls } = layoutFor({ 1: ['alpha'] }, null);
+ expect(await layout.pageLayout(1, 10)).toBeNull();
+ expect(calls).toHaveLength(0);
+ });
+
+ it('survives a read that throws', async () => {
+ const failing: PageWindowReader = () => Promise.reject(new Error('wasm said no'));
+ const layout = new DocumentLayout('doc', async () => BYTES, failing);
+ expect(await layout.pageLayout(1, 10)).toBeNull();
+ });
+
+ it('places the passages anchored to a page', async () => {
+ const { layout } = layoutFor({ 1: ['the', 'harbour', 'master'] });
+ const placed = await layout.placements(
+ 1,
+ 1,
+ [segment('s1', 'The harbour master', 1)],
+ new Map()
+ );
+ expect(placed.get('s1')?.rects[0].y).toBe(100);
+ });
+
+ it('places each page once and keeps the answer', async () => {
+ const { layout, calls } = layoutFor({ 1: ['the', 'harbour', 'master'] });
+ const segments = [segment('s1', 'The harbour master', 1)];
+ const first = await layout.placements(1, 1, segments, new Map());
+ const again = await layout.placements(1, 1, segments, new Map());
+ expect(again).toBe(first);
+ expect(calls).toHaveLength(1);
+ });
+
+ it('throws its placements away when the passages are rebound', async () => {
+ const { layout, calls } = layoutFor({ 1: ['the', 'harbour', 'master'] });
+ const first = await layout.placements(
+ 1,
+ 1,
+ [segment('s1', 'The harbour master', 1)],
+ new Map()
+ );
+ const rebound = await layout.placements(
+ 1,
+ 1,
+ [segment('s1:n0', 'The harbour master', 1)],
+ new Map()
+ );
+ expect(rebound).not.toBe(first);
+ expect(rebound.has('s1:n0')).toBe(true);
+ // The geometry is still good, though — only the placement was stale.
+ expect(calls).toHaveLength(1);
+ });
+
+ it('does not cache a placement computed against passages already replaced', async () => {
+ const gate = vi.fn();
+ let release: (() => void) | undefined;
+ const read: PageWindowReader = async () =>
+ new Promise((resolve) => {
+ release = () => resolve([page(1, ['the', 'harbour', 'master'])]);
+ gate();
+ });
+ const layout = new DocumentLayout('doc', async () => BYTES, read);
+ const stale = layout.placements(1, 1, [segment('old', 'The harbour master', 1)], new Map());
+ await vi.waitFor(() => expect(gate).toHaveBeenCalled());
+ const fresh = layout.placements(1, 1, [segment('new', 'The harbour master', 1)], new Map());
+ release?.();
+ await stale;
+ const settled = await fresh;
+ expect(settled.has('new')).toBe(true);
+ });
+});
diff --git a/src/lib/services/pdf-layout.ts b/src/lib/services/pdf-layout.ts
new file mode 100644
index 0000000..3fa9029
--- /dev/null
+++ b/src/lib/services/pdf-layout.ts
@@ -0,0 +1,221 @@
+import type { NormalizedDocument, SpeechSegment } from '../domain/types';
+import {
+ pageWordBoxes,
+ placeSegments,
+ readingOrder,
+ segmentsForPage,
+ type PageWordBox,
+ type SegmentPlacement
+} from '../domain/pdf-layout';
+import { getSource } from './repository';
+
+/**
+ * Read-time word geometry for a document's original PDF.
+ *
+ * The import already ran LiteParse over these bytes, but deliberately kept
+ * none of the boxes: storing a rectangle per word for every document in the
+ * library would dwarf the documents themselves (see `DocumentPageInfo`).
+ * Re-reading them is cheap when it is asked for by page — LiteParse's
+ * `targetPages` parses a window in tens of milliseconds — so the page view
+ * recomputes what it needs as the reader scrolls and keeps it in memory for
+ * the session.
+ */
+
+export interface PageLayout {
+ page: number;
+ /** PDF points, as LiteParse read them — the coordinate space of `boxes`. */
+ width: number;
+ height: number;
+ boxes: PageWordBox[];
+}
+
+/** Pages either side of the requested one to parse in the same pass. Reading
+ * is directional, so the window leans forward. */
+const WINDOW_BEHIND = 1;
+const WINDOW_AHEAD = 2;
+
+let liteparse: Promise | undefined;
+
+/** The wasm module, initialized once per session. LiteParse's own init guard
+ * only dedupes after the first init resolves, so the promise is what callers
+ * share. */
+async function loadLiteparse(): Promise {
+ liteparse ??= (async () => {
+ const [glue, wasm] = await Promise.all([
+ import('@llamaindex/liteparse-wasm'),
+ import('@llamaindex/liteparse-wasm/liteparse_wasm_bg.wasm?url')
+ ]);
+ await glue.default({ module_or_path: wasm.default });
+ return glue;
+ })();
+ return liteparse;
+}
+
+/** Reads one window of pages out of the document's bytes. Supplied by the
+ * module below; a parameter so the caching around it can be tested without a
+ * wasm runtime. */
+export type PageWindowReader = (
+ data: Uint8Array,
+ from: number,
+ to: number
+) => Promise;
+
+/**
+ * One document's page geometry, parsed on demand and kept for the session.
+ * Requests for the same page while a parse is in flight join it rather than
+ * starting a second one — scrolling asks for the same page many times.
+ */
+export class DocumentLayout {
+ readonly documentId: string;
+ #source: Promise;
+ #read: PageWindowReader;
+ #pages = new Map();
+ /** The in-flight window read covering each page, shared by every caller
+ * waiting on it. */
+ #pending = new Map>();
+ #placements = new Map>();
+ #segments: SpeechSegment[] = [];
+
+ constructor(
+ documentId: string,
+ loadSource: () => Promise,
+ read: PageWindowReader
+ ) {
+ this.documentId = documentId;
+ this.#read = read;
+ this.#source = loadSource().catch(() => null);
+ }
+
+ /** Word boxes for one page, or null when the source is gone (OPFS
+ * eviction) or LiteParse cannot read it. */
+ pageLayout(page: number, pageCount: number): Promise {
+ const cached = this.#pages.get(page);
+ if (cached !== undefined) return Promise.resolve(cached);
+ // What a waiting caller wants is its OWN page out of the finished read,
+ // not the page whoever started the read had asked for.
+ const settle = (work: Promise) =>
+ work.then(
+ () => this.#pages.get(page) ?? null,
+ () => null
+ );
+ const inFlight = this.#pending.get(page);
+ if (inFlight) return settle(inFlight);
+ const from = Math.max(1, page - WINDOW_BEHIND);
+ const to = Math.max(from, Math.min(pageCount || page + WINDOW_AHEAD, page + WINDOW_AHEAD));
+ const work = this.#parseWindow(from, to);
+ for (let target = from; target <= to; target += 1) {
+ if (!this.#pages.has(target)) this.#pending.set(target, work);
+ }
+ void work
+ .catch(() => undefined)
+ .finally(() => {
+ for (let target = from; target <= to; target += 1) {
+ if (this.#pending.get(target) === work) this.#pending.delete(target);
+ }
+ });
+ return settle(work);
+ }
+
+ async #parseWindow(from: number, to: number): Promise {
+ const data = await this.#source;
+ const parsed = data ? await this.#read(data, from, to) : [];
+ const seen = new Set();
+ for (const layout of parsed) {
+ seen.add(layout.page);
+ this.#pages.set(layout.page, layout);
+ }
+ // A page the read skipped has no geometry and never will; recording the
+ // miss stops every scroll from asking again.
+ for (let page = from; page <= to; page += 1) if (!seen.has(page)) this.#pages.set(page, null);
+ }
+
+ /**
+ * Where each passage sits on one page. Placement is pure but not free
+ * (tens of milliseconds on a dense page), so results are kept per page and
+ * dropped wholesale when the passages themselves change — a listening-mode
+ * switch or a narration swap re-cuts every segment id.
+ */
+ async placements(
+ page: number,
+ pageCount: number,
+ segments: SpeechSegment[],
+ blockText: ReadonlyMap
+ ): Promise