diff --git a/.gitignore b/.gitignore index 6c5f3c9..e503f4a 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ skills-lock.json # Local test fixtures (user-provided, not committed) test_documents/ +.probe/ diff --git a/src/lib/components/PdfPagePeek.svelte b/src/lib/components/PdfPagePeek.svelte index 7ac445f..3eb22a0 100644 --- a/src/lib/components/PdfPagePeek.svelte +++ b/src/lib/components/PdfPagePeek.svelte @@ -33,7 +33,11 @@ return; } try { - await renderer.renderPage(target, canvas!, cssWidth); + // The peek sizes its canvas from the drawing itself — it has no + // layout of its own to stretch into. + const size = await renderer.renderPage(target, canvas!, cssWidth); + canvas!.style.width = `${size.width}px`; + canvas!.style.height = `${size.height}px`; if (token === renderToken) status = 'ready'; } catch { if (token === renderToken) status = 'unavailable'; diff --git a/src/lib/components/PdfPageView.svelte b/src/lib/components/PdfPageView.svelte new file mode 100644 index 0000000..fa18c82 --- /dev/null +++ b/src/lib/components/PdfPageView.svelte @@ -0,0 +1,716 @@ + + + + +
+ {#each sizes as size (size.page)} + {@const scale = pageScale(size.page)} + {@const placed = placements.get(size.page)} +
+ + + +
handleActivate(event, size.page)} + onpointermove={(event) => handleHover(event, size.page)} + onpointerleave={() => (hovered = undefined)} + > + + {#if live.has(size.page)} + + {/if} +
+
+ {/each} +
+ + 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> { + if (segments !== this.#segments) { + this.#segments = segments; + this.#placements.clear(); + } + const key = String(page); + const cached = this.#placements.get(key); + if (cached) return cached; + const layout = await this.pageLayout(page, pageCount); + const placed = new Map(); + if (layout?.boxes.length) { + const placeable = segmentsForPage(segments, page, blockText); + for (const placement of placeSegments(layout.boxes, placeable, page)) { + placed.set(placement.segmentId, placement); + } + } + // The passages may have been rebound while this page was being parsed; + // caching against a stale set would pin placements for ids nothing uses. + if (segments === this.#segments) this.#placements.set(key, placed); + return placed; + } +} + +/** The real reader: LiteParse over a page range, asked for geometry only. */ +const readWithLiteparse: PageWindowReader = async (data, from, to) => { + const glue = await loadLiteparse(); + const parser = new glue.LiteParse({ + ocrEnabled: false, + outputFormat: 'markdown', + // Only the geometry is wanted here; images and links are the import's + // business and decoding them again would dominate the parse. + imageMode: 'off', + extractLinks: false, + skipDiagonalText: true, + emitWordBoxes: true, + quiet: true, + targetPages: from === to ? String(from) : `${from}-${to}` + }); + let parsed: Awaited>; + try { + // LiteParse transfers the buffer to wasm; hand it a copy so the next + // window still has bytes to read. + parsed = await parser.parse(data.slice()); + } finally { + parser.free(); + } + return parsed.pages.map((page) => ({ + page: page.pageNum, + width: page.width, + height: page.height, + // Raster order as read; reading order as written. + boxes: readingOrder(pageWordBoxes(page.textItems ?? []), page.width) + })); +}; + +let open: DocumentLayout | undefined; + +/** The layout source for a document, kept warm one at a time — opening + * another document releases the previous one, matching the page renderer. */ +export function openPdfLayout(document: NormalizedDocument): DocumentLayout { + if (open?.documentId !== document.id) { + open = new DocumentLayout( + document.id, + async () => { + const blob = await getSource(document); + return blob ? new Uint8Array(await blob.arrayBuffer()) : null; + }, + readWithLiteparse + ); + } + return open; +} + +/** Frees the warm layout source. Safe to call twice or with nothing open. */ +export function releasePdfLayout(documentId?: string): void { + if (!open) return; + if (documentId && open.documentId !== documentId) return; + open = undefined; +} diff --git a/src/lib/services/pdf-pages.ts b/src/lib/services/pdf-pages.ts index 74414a4..37a1183 100644 --- a/src/lib/services/pdf-pages.ts +++ b/src/lib/services/pdf-pages.ts @@ -1,4 +1,5 @@ import type { NormalizedDocument } from '../domain/types'; +import { toneDistance, tonePixels, type Rgb } from '../domain/page-tone'; import { getSource } from './repository'; /** Safari caps canvases around 4096×4096 / 16M pixels; render within that. */ @@ -7,9 +8,30 @@ const MAX_PIXELS = 16 * 1024 * 1024; export interface PageRasterizer { readonly pageCount: number; - /** Draws a page (1-based) into `canvas` sized for `cssWidth` CSS pixels at - * the device pixel ratio. Sets the canvas buffer and style sizes. */ - renderPage(page: number, canvas: HTMLCanvasElement, cssWidth: number): Promise; + /** Draws a page (1-based) into `canvas` at the device pixel ratio, for a + * display width of `cssWidth`, and reports the CSS size the result wants to + * be shown at. It does NOT set that size on the canvas: a caller whose + * canvas is sized by its own layout (the page view stretches one to fill its + * sheet) must be free to resize the drawing between draws, and pinning it in + * pixels here left the picture at its old size while everything around it + * had already grown. + * + * The page is painted off-screen and handed over whole, so `canvas` keeps + * whatever it was showing until the new picture is finished. Painting into + * it directly means clearing it first and then filling it in over however + * many frames the page takes — which reads as a flash on every redraw, and + * as a strobe while a zoom slider is being dragged. + * + * Aborting `signal` abandons the draw — while it is still queued, or partway + * through. A page of dense vector art can take seconds to paint, and renders + * run one at a time, so a reader who has scrolled past one must not leave it + * holding the queue against every page they are actually looking at. */ + renderPage( + page: number, + canvas: HTMLCanvasElement, + cssWidth: number, + options?: { signal?: AbortSignal; tone?: { paper: Rgb; ink: Rgb } } + ): Promise<{ width: number; height: number }>; /** Renders a page (1-based) to an OffscreenCanvas at `scale`× the page's * natural point size — the OCR path's rasterizer. */ rasterize(page: number, scale: number): Promise; @@ -46,11 +68,30 @@ export async function createPageRasterizer(data: Uint8Array): Promise>; + const renderPageInto = async ( + page: Page, + canvas: OffscreenCanvas, + viewport: ReturnType, + signal?: AbortSignal + ) => { + const task = page.render({ canvas: canvas as unknown as HTMLCanvasElement, viewport }); + const abort = () => task.cancel(); + signal?.addEventListener('abort', abort, { once: true }); + try { + await task.promise; + } finally { + signal?.removeEventListener('abort', abort); + } + }; + const renderInto = async ( pageNumber: number, canvas: HTMLCanvasElement | OffscreenCanvas, - requestedScale: number + requestedScale: number, + signal?: AbortSignal ) => { + signal?.throwIfAborted(); const page = await pdf.getPage(pageNumber); try { const base = page.getViewport({ scale: 1 }); @@ -59,10 +100,17 @@ export async function createPageRasterizer(data: Uint8Array): Promise task.cancel(); + signal?.addEventListener('abort', abort, { once: true }); + try { + await task.promise; + } finally { + signal?.removeEventListener('abort', abort); + } return viewport; } finally { page.cleanup(); @@ -71,15 +119,34 @@ export async function createPageRasterizer(data: Uint8Array): Promise + renderPage: (pageNumber, canvas, cssWidth, options = {}) => serialize(async () => { + const { signal, tone } = options; + signal?.throwIfAborted(); const page = await pdf.getPage(pageNumber); - const width = page.getViewport({ scale: 1 }).width; - page.cleanup(); - const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); - const viewport = await renderInto(pageNumber, canvas, (cssWidth / width) * ratio); - canvas.style.width = `${Math.round(viewport.width / ratio)}px`; - canvas.style.height = `${Math.round(viewport.height / ratio)}px`; + try { + const base = page.getViewport({ scale: 1 }); + const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); + const viewport = page.getViewport({ + scale: clampedScale(base.width, base.height, (cssWidth / base.width) * ratio) + }); + const offscreen = new OffscreenCanvas( + Math.floor(viewport.width), + Math.floor(viewport.height) + ); + await renderPageInto(page, offscreen, viewport, signal); + // Abandoned while it painted: the finished picture is no longer + // the one anybody asked for, so leave the canvas as it was. + signal?.throwIfAborted(); + if (tone) await printOnto(pdfjs, page, offscreen, viewport, tone); + present(canvas, offscreen); + return { + width: Math.round(viewport.width / ratio), + height: Math.round(viewport.height / ratio) + }; + } finally { + page.cleanup(); + } }), rasterize: (pageNumber, scale) => serialize(async () => { @@ -95,6 +162,157 @@ export async function createPageRasterizer(data: Uint8Array): Promise Promise<{ fnArray: number[]; argsArray: unknown[][] }> }, + surface: OffscreenCanvas, + viewport: { transform: number[] }, + tone: { paper: Rgb; ink: Rgb } +): Promise { + // Under a theme whose paper is already white under black ink there is + // nothing to do, and a full-page pixel pass is not free. + if (toneDistance(tone.paper, tone.ink) < 0.06) return; + if (!isLightPage(surface)) return; + const context = surface.getContext('2d', { willReadFrequently: true }); + if (!context) return; + const pictures = (await imageRects(pdfjs, page, viewport, surface)).map((rect) => ({ + rect, + pixels: context.getImageData(rect.x, rect.y, rect.width, rect.height) + })); + const page_ = context.getImageData(0, 0, surface.width, surface.height); + tonePixels(page_.data, tone.paper, tone.ink); + context.putImageData(page_, 0, 0); + for (const picture of pictures) + context.putImageData(picture.pixels, picture.rect.x, picture.rect.y); +} + +/** Sampled down to a thumbnail: paper is overwhelmingly the lightest thing on + * a printed page, so a mean this high means there is paper to invert. */ +function isLightPage(source: OffscreenCanvas): boolean { + const size = 24; + const thumbnail = new OffscreenCanvas(size, size); + const context = thumbnail.getContext('2d', { willReadFrequently: true }); + if (!context) return true; + context.drawImage(source, 0, 0, size, size); + const { data } = context.getImageData(0, 0, size, size); + let total = 0; + for (let index = 0; index < data.length; index += 4) { + total += 0.2126 * data[index] + 0.7152 * data[index + 1] + 0.0722 * data[index + 2]; + } + return total / (size * size) > 140; +} + +/** [a, b, c, d, e, f] ∘ [a, b, c, d, e, f]. */ +function concat(outer: number[], inner: number[]): number[] { + return [ + outer[0] * inner[0] + outer[2] * inner[1], + outer[1] * inner[0] + outer[3] * inner[1], + outer[0] * inner[2] + outer[2] * inner[3], + outer[1] * inner[2] + outer[3] * inner[3], + outer[0] * inner[4] + outer[2] * inner[5] + outer[4], + outer[1] * inner[4] + outer[3] * inner[5] + outer[5] + ]; +} + +function apply(x: number, y: number, matrix: number[]): [number, number] { + return [matrix[0] * x + matrix[2] * y + matrix[4], matrix[1] * x + matrix[3] * y + matrix[5]]; +} + +/** Where each image object on the page landed, in canvas pixels. Images are + * drawn into the unit square, so the current transform is the placement; the + * operator list has to be walked with a transform stack to know what it was. */ +async function imageRects( + pdfjs: typeof import('pdfjs-dist'), + page: { getOperatorList: () => Promise<{ fnArray: number[]; argsArray: unknown[][] }> }, + viewport: { transform: number[] }, + source: OffscreenCanvas +): Promise> { + const { OPS } = pdfjs; + const paints = new Set([ + OPS.paintImageXObject, + OPS.paintImageXObjectRepeat, + OPS.paintInlineImageXObject, + OPS.paintImageMaskXObject + ]); + let operators: { fnArray: number[]; argsArray: unknown[][] }; + try { + operators = await page.getOperatorList(); + } catch { + return []; + } + const rects: Array<{ x: number; y: number; width: number; height: number }> = []; + const stack: number[][] = []; + let transform = viewport.transform; + for (let index = 0; index < operators.fnArray.length; index += 1) { + const operator = operators.fnArray[index]; + if (operator === OPS.save) stack.push(transform); + else if (operator === OPS.restore) transform = stack.pop() ?? transform; + else if (operator === OPS.transform) { + transform = concat(transform, operators.argsArray[index] as number[]); + } else if (paints.has(operator)) { + const corners = [ + [0, 0], + [1, 0], + [0, 1], + [1, 1] + ].map(([x, y]) => apply(x, y, transform)); + const left = Math.floor(Math.min(...corners.map((point) => point[0]))); + const top = Math.floor(Math.min(...corners.map((point) => point[1]))); + const right = Math.ceil(Math.max(...corners.map((point) => point[0]))); + const bottom = Math.ceil(Math.max(...corners.map((point) => point[1]))); + const x = Math.max(0, left); + const y = Math.max(0, top); + const width = Math.min(source.width, right) - x; + const height = Math.min(source.height, bottom) - y; + if (width > 1 && height > 1) rects.push({ x, y, width, height }); + } + } + return rects; +} + +/** + * Put a finished off-screen page onto a visible canvas in one step. A bitmap + * renderer takes ownership of the pixels outright; where that context is not + * available the pixels are copied instead, which costs a blit but still swaps + * the whole page at once. + */ +function present(canvas: HTMLCanvasElement, source: OffscreenCanvas): void { + const bitmap = source.transferToImageBitmap(); + try { + const renderer = canvas.getContext('bitmaprenderer'); + if (renderer) { + // Sized explicitly: transferring a bitmap swaps what the canvas shows + // without touching its width and height attributes, and those are what + // the page view measures its memory against. + canvas.width = bitmap.width; + canvas.height = bitmap.height; + renderer.transferFromImageBitmap(bitmap); + return; + } + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d')?.drawImage(bitmap, 0, 0); + } finally { + bitmap.close(); + } +} + let openRenderer: { documentId: string; rasterizer: Promise } | undefined; /** diff --git a/src/lib/state/reader-chrome.svelte.ts b/src/lib/state/reader-chrome.svelte.ts index 0ae902c..7734a4d 100644 --- a/src/lib/state/reader-chrome.svelte.ts +++ b/src/lib/state/reader-chrome.svelte.ts @@ -1,11 +1,28 @@ import { DEFAULT_LISTENING_MODE, isListeningMode } from '$lib/domain/listening-modes'; import type { ListeningMode } from '$lib/domain/types'; +/** + * How a document is shown. 'reading' is the reflowed markdown — the only + * option for anything that was never a page. 'page' draws the original PDF + * and paints the spoken passage onto it, for readers who want the paper as + * the authors set it. + */ +export type ReaderView = 'reading' | 'page'; + +export const READER_VIEWS: ReaderView[] = ['reading', 'page']; + +function isReaderView(value: unknown): value is ReaderView { + return READER_VIEWS.includes(value as ReaderView); +} + class ReaderChromeState { /** Contents starts closed — the document is the point. */ outlineOpen = $state(false); menuOpen = $state(false); documentZoom = $state(1); + /** The preferred view, remembered across documents. A document with no + * original pages falls back to reading without changing this. */ + readerView = $state('reading'); /** The listening mode new imports start in. Per-document overrides live on * the document itself and take precedence in the reader. */ defaultListeningMode = $state(DEFAULT_LISTENING_MODE); @@ -30,10 +47,26 @@ class ReaderChromeState { if (Number.isFinite(stored) && stored >= 0.8 && stored <= 1.6) this.documentZoom = stored; const mode = window.localStorage.getItem('voicebook:listening-mode'); if (isListeningMode(mode)) this.defaultListeningMode = mode; + const view = window.localStorage.getItem('voicebook:reader-view'); + if (isReaderView(view)) this.readerView = view; this.assistantCaptions = window.localStorage.getItem('voicebook:assistant-captions') !== '0'; this.spokenChatReplies = window.localStorage.getItem('voicebook:spoken-chat-replies') !== '0'; } + /** Steps to the next view. A cycle rather than a flip, so a third view can + * join without the control changing shape. */ + cycleReaderView(): void { + const next = READER_VIEWS[(READER_VIEWS.indexOf(this.readerView) + 1) % READER_VIEWS.length]; + this.setReaderView(next); + } + + setReaderView(view: ReaderView): void { + this.readerView = view; + if (typeof window !== 'undefined') { + window.localStorage.setItem('voicebook:reader-view', view); + } + } + setAssistantCaptions(on: boolean): void { this.assistantCaptions = on; if (typeof window !== 'undefined') { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 70124ff..955ff4d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import { PanelLeftClose, PanelLeftOpen, + BookOpenText, BrainCircuit, CircleHelp, Fullscreen, @@ -72,6 +73,13 @@ let readerBook = $derived( isReader ? appState.documents.find((document) => document.id === readerDocumentId) : undefined ); + /** The page view needs the original file, which only a PDF import keeps — + * and only while the bytes are still on this device. */ + let originalPagesAvailable = $derived( + readerBook?.sourceKind === 'pdf' && Boolean(readerBook.sourcePath || readerBook.sourceBlob) + ); + const viewLabels = { reading: 'Reading view', page: 'Original pages' } as const; + let tourContext = $derived( isReader ? 'reader' @@ -278,6 +286,23 @@ > + {#if originalPagesAvailable} + + {/if}
{/if} -
-
- {book.sourceKind.toUpperCase()} · Local library -

- {#if titleBlock} - {#each segmentsByBlock.get(titleBlock.id) ?? [] as segment (segment.id)} - {@render renderSegment(titleBlock, segment)} - {/each} - {:else} - {book.title} - {/if} -

-

- {Math.max(1, Math.round(player.totalDuration / 60))} min read · {book.segments.length} - passages{#if documentPageCount} · {documentPageCount} pages{/if} -

-
+ {#if pageViewActive && documentPageCount} + (player.autoFollow = false)} + /> + {:else} +
+
+ {book.sourceKind.toUpperCase()} · Local library +

+ {#if titleBlock} + {#each segmentsByBlock.get(titleBlock.id) ?? [] as segment (segment.id)} + {@render renderSegment(titleBlock, segment)} + {/each} + {:else} + {book.title} + {/if} +

+

+ {Math.max(1, Math.round(player.totalDuration / 60))} min read · {book.segments.length} + passages{#if documentPageCount} · {documentPageCount} pages{/if} +

+
+ +
+ {#each rootBlocks as block (block.id)} + {@const markerPage = pageStartsById.get(block.id)} + + {#if markerPage !== undefined && block.id !== rootBlocks[0]?.id} + + {/if} + {#if block.kind === 'list-item'} +
    {@render renderBlock(block)}
+ {:else} + {@render renderBlock(block)} + {/if} + {/each} +
-
- {#each rootBlocks as block (block.id)} - {@const markerPage = pageStartsById.get(block.id)} - - {#if markerPage !== undefined && block.id !== rootBlocks[0]?.id} - - {/if} - {#if block.kind === 'list-item'} -
    {@render renderBlock(block)}
- {:else} - {@render renderBlock(block)} + {#if narrationStartAction} + {@const selectedSegment = book.segments.find( + (segment) => segment.id === narrationStartAction?.segmentId + )} + {#if selectedSegment} +
+ + + + + + + +
{/if} + {/if} + + {#each annotationMarkers as marker (marker.id)} + {/each} -
- {#if narrationStartAction} - {@const selectedSegment = book.segments.find( - (segment) => segment.id === narrationStartAction?.segmentId - )} - {#if selectedSegment} + {#if annotationEditor} + {@const editor = annotationEditor}
- - - - - - - -
- {/if} - {/if} - - {#each annotationMarkers as marker (marker.id)} - - {/each} - - {#if annotationEditor} - {@const editor = annotationEditor} - - {/if} - - {#if explainBox} - - {/if} -
+ {/if} +
+ {/if}

{narrationAnnouncement}

{#if !player.autoFollow} diff --git a/tests/reader.e2e.ts b/tests/reader.e2e.ts index ec32946..329ee74 100644 --- a/tests/reader.e2e.ts +++ b/tests/reader.e2e.ts @@ -1785,6 +1785,309 @@ test('imports a PDF with page markers, page navigation, and the original-page vi await expect(page.getByRole('dialog')).toHaveCount(0); }); +test('reads a PDF as its own pages, with the spoken passage painted on', async ({ page }) => { + await openReadyLibrary(page); + const pdf = await PDFDocument.create(); + const font = await pdf.embedFont(StandardFonts.Helvetica); + const sourcePages = [ + [ + 'Ledgers of the Coast', + 'The harbour master kept a ledger of every tide that entered the bay.', + 'Each entry recorded the hour, the depth, and the weather over the water.' + ], + [ + 'The Second Winter', + 'By the second winter the ledger had outgrown its binding entirely.', + 'A cooper in the town sewed the loose pages into a heavier cover.' + ] + ]; + for (const [heading, first, second] of sourcePages) { + const sheet = pdf.addPage([612, 792]); + sheet.drawText(heading, { x: 72, y: 720, size: 22, font }); + sheet.drawText(first, { x: 72, y: 660, size: 12, font }); + sheet.drawText(second, { x: 72, y: 640, size: 12, font }); + } + await page.locator('#document-upload').setInputFiles({ + name: 'ledgers.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from(await pdf.save()) + }); + await expect(page.getByRole('heading', { name: 'Ledgers of the Coast' })).toBeVisible(); + + // The view control only appears for a document that still has its file. + const viewSwitch = page.getByRole('button', { name: /Switch view/ }); + await expect(viewSwitch).toHaveAccessibleName(/Reading view/); + await viewSwitch.click(); + await expect(page.locator('.reading-canvas')).toHaveCount(0); + await expect(viewSwitch).toHaveAccessibleName(/Original pages/); + + // Every page is laid out up front, and the near ones actually draw. + await expect(page.locator('.page-slot')).toHaveCount(2); + const firstPage = page.locator('.page-slot[data-page="1"]'); + await expect + .poll( + async () => firstPage.locator('canvas').evaluate((node: HTMLCanvasElement) => node.width), + { timeout: 30_000 } + ) + .toBeGreaterThan(400); + + // Double-clicking a sentence on the paper plays it, and it lights up where + // it was printed rather than anywhere the reflowed text would have put it. + const sheet = firstPage.locator('.page-sheet'); + const sheetBox = await sheet.boundingBox(); + expect(sheetBox).not.toBeNull(); + // The first body line's baseline sits at 660pt from the foot of a 792pt + // page, so its type occupies roughly 123–135pt from the head. + const scale = sheetBox!.width / 612; + await sheet.dblclick({ position: { x: 200 * scale, y: 129 * scale } }); + const marks = firstPage.locator('.mark.passage'); + await expect.poll(async () => marks.count(), { timeout: 30_000 }).toBeGreaterThan(0); + const painted = await marks.first().boundingBox(); + expect(painted).not.toBeNull(); + // Painted over the line that was clicked, not over the heading above it. + expect(painted!.y - sheetBox!.y).toBeGreaterThan(100 * scale); + expect(painted!.y - sheetBox!.y).toBeLessThan(160 * scale); + expect(painted!.x - sheetBox!.x).toBeGreaterThan(60 * scale); + + // Reading ahead of the narration must stay where it was put. Pages place + // as they scroll into view, and re-following the playhead on each of those + // updates snapped the document back to it every time. + const stack = page.locator('.page-stack'); + await page.mouse.move(700, 400); + await page.mouse.wheel(0, 2400); + await expect.poll(async () => stack.evaluate((node) => node.scrollTop)).toBeGreaterThan(1200); + const settledAt = await stack.evaluate((node) => node.scrollTop); + // Long enough for the pages now in view to finish placing. + await page.waitForTimeout(2500); + expect(await stack.evaluate((node) => node.scrollTop)).toBe(settledAt); + + // Following resumes on request, and brings the passage being spoken back + // into view. + await page.getByRole('button', { name: 'Follow narration' }).click(); + await expect + .poll( + async () => { + const port = await stack.boundingBox(); + const mark = await page.locator('.mark.passage').first().boundingBox(); + if (!port || !mark) return false; + return mark.y >= port.y && mark.y + mark.height <= port.y + port.height; + }, + { timeout: 10_000 } + ) + .toBe(true); + + const pageViewA11y = await new AxeBuilder({ page }).analyze(); + expect( + pageViewA11y.violations.filter((item) => ['critical', 'serious'].includes(item.impact ?? '')) + ).toEqual([]); + + // The contents panel addresses a page rather than an element here, so the + // heading it names still moves the stack. + await page.getByRole('button', { name: /Open document outline/ }).click(); + await page.getByRole('button', { name: 'The Second Winter', exact: true }).click(); + await expect + .poll(async () => page.locator('.page-stack').evaluate((node) => node.scrollTop), { + timeout: 10_000 + }) + .toBeGreaterThan(sheetBox!.height / 2); + + // The preference survives leaving and reopening the document. + await page.getByRole('link', { name: 'Library' }).first().click(); + await page.getByRole('link', { name: 'Ledgers of the Coast' }).first().click(); + await expect(page.locator('.page-slot')).toHaveCount(2); + await page.getByRole('button', { name: /Switch view/ }).click(); + await expect(page.locator('.reading-canvas')).toBeVisible(); +}); + +/** Enough clicks of the header's theme button to reach any theme from any + * other — it steps through them one at a time. */ +const THEME_COUNT = 12; + +test('draws each original page once while the reader scrolls back and forth', async ({ page }) => { + await openReadyLibrary(page); + const pdf = await PDFDocument.create(); + const font = await pdf.embedFont(StandardFonts.Helvetica); + // Each page says something of its own: pages that differ only by a number + // read as a running header, and the importer strips those. + const bodies = [ + 'The harbour master kept a ledger of every tide that entered the bay.', + 'A cooper in the town sewed the loose pages into a heavier cover.', + 'Winter storms are recorded in a hand that grows steadily smaller.', + 'By spring the entries return to their usual patient width again.', + 'The last volume breaks off in the middle of an ordinary morning.', + 'Nobody wrote down why the record stops where it does.' + ]; + for (const [index, body] of bodies.entries()) { + const sheet = pdf.addPage([612, 792]); + sheet.drawText(`Section ${index + 1}`, { x: 72, y: 720, size: 20, font }); + sheet.drawText(body, { x: 72, y: 680, size: 12, font }); + } + await page.locator('#document-upload').setInputFiles({ + name: 'survey.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from(await pdf.save()) + }); + await expect(page.locator('.reading-canvas')).toBeVisible({ timeout: 60_000 }); + await page.getByRole('button', { name: /Switch view/ }).click(); + await expect(page.locator('.page-slot')).toHaveCount(6); + await expect + .poll( + async () => + page + .locator('.page-slot[data-page="1"] canvas') + .evaluate((node: HTMLCanvasElement) => node.width), + { timeout: 30_000 } + ) + .toBeGreaterThan(400); + + // Count the moments a page's bitmap is thrown away. Releasing one is only + // meant to happen under memory pressure, which six letter pages come + // nowhere near — releasing them as they leave the scrollport instead meant + // every sweep redrew the lot, and a page of dense vector art could not + // survive that. + await page.evaluate(() => { + let released = 0; + const widths = new Map(); + (window as unknown as { __released: () => number }).__released = () => released; + setInterval(() => { + for (const canvas of document.querySelectorAll('.page-slot canvas')) { + const number = Number(canvas.closest('.page-slot')?.dataset.page); + const was = widths.get(number); + if (was && was > 1 && canvas.width <= 1) released += 1; + widths.set(number, canvas.width); + } + }, 40); + }); + + await page.mouse.move(700, 400); + for (let sweep = 0; sweep < 3; sweep += 1) { + for (let step = 0; step < 12; step += 1) await page.mouse.wheel(0, 800); + await page.waitForTimeout(250); + for (let step = 0; step < 12; step += 1) await page.mouse.wheel(0, -800); + await page.waitForTimeout(250); + } + await page.waitForTimeout(1500); + expect( + await page.evaluate(() => (window as unknown as { __released: () => number }).__released()) + ).toBe(0); + // And every page is drawn, at the size it is shown. + const drawn = await page.evaluate( + () => + [...document.querySelectorAll('.page-slot canvas')].filter( + (canvas) => canvas.width > 400 + ).length + ); + expect(drawn).toBe(6); + + // Zooming abandons whatever draws were in flight for the old size. A page in + // view must never be left blank while that happens: the slider reports every + // percent it passes through, and clearing the pages on each one reads as a + // strobe. + await page.evaluate(() => { + let blank = 0; + let samples = 0; + (window as unknown as { __blank: () => number[] }).__blank = () => [blank, samples]; + setInterval(() => { + for (const slot of document.querySelectorAll('.page-slot')) { + if (!slot.querySelector('.page-marks')) continue; + samples += 1; + const canvas = slot.querySelector('canvas'); + if (!canvas || canvas.width <= 1) blank += 1; + } + }, 40); + }); + await page.getByRole('button', { name: /Document zoom/ }).click(); + const zoom = page.getByRole('slider', { name: 'Document zoom' }); + // The drawing has to grow with its sheet on every step of the drag. Pinned + // to the pixel size it was last drawn at, it stayed put while the sheet grew + // around it and then jumped when the redraw landed — with the highlights + // hanging in the gap, since those follow the sheet. + const lag: number[] = []; + for (let percent = 100; percent <= 140; percent += 4) { + await zoom.fill(String(percent)); + await page.waitForTimeout(30); + lag.push( + await page.evaluate(() => { + const slot = document.querySelector('.page-slot[data-page="1"]'); + const sheet = slot?.querySelector('.page-sheet')?.getBoundingClientRect().width ?? 0; + const canvas = slot?.querySelector('canvas')?.getBoundingClientRect().width ?? 0; + return Math.round(sheet - canvas); + }) + ); + } + // Only the sheet's own border sits between the two. + expect(Math.max(...lag)).toBeLessThanOrEqual(2); + await page.keyboard.press('Escape'); + const [blank, samples] = await page.evaluate(() => + (window as unknown as { __blank: () => number[] }).__blank() + ); + expect(samples).toBeGreaterThan(0); + expect(blank).toBe(0); + // The paper is turned dark under a dark theme and left as printed under a + // light one, following the theme the reader already chose rather than a + // control of its own. + const stack = page.locator('.page-stack'); + await expect(stack).toHaveClass(/\bdarkened\b/); + // And the paper really is the theme's paper: a pixel of margin, well away + // from any type, comes back as the colour the reading view uses. + const marginPixel = async () => + page.evaluate(() => { + const canvas = document.querySelector('.page-slot[data-page="1"] canvas'); + const stack = document.querySelector('.page-stack'); + if (!canvas || !stack) return null; + const scratch = document.createElement('canvas'); + scratch.width = 1; + scratch.height = 1; + scratch.getContext('2d')?.drawImage(canvas, 8, 8, 1, 1, 0, 0, 1, 1); + const [red, green, blue] = scratch.getContext('2d')!.getImageData(0, 0, 1, 1).data; + return { + paper: [red, green, blue], + theme: getComputedStyle(stack).getPropertyValue('--reader') + }; + }); + const printed = await marginPixel(); + expect(printed).not.toBeNull(); + const themePaper = /#(\w{2})(\w{2})(\w{2})/.exec(printed!.theme.trim()); + expect(themePaper).not.toBeNull(); + for (let channel = 0; channel < 3; channel += 1) { + expect( + Math.abs(printed!.paper[channel] - Number.parseInt(themePaper![channel + 1], 16)) + ).toBeLessThanOrEqual(6); + } + const theme = page.getByRole('button', { name: /Switch to .* theme/ }); + for (let click = 0; click < THEME_COUNT; click += 1) { + if ((await theme.getAttribute('aria-label'))?.includes('Theme: Sunny')) break; + await theme.click(); + } + await expect(theme).toHaveAccessibleName(/Theme: Sunny/); + await expect(stack).not.toHaveClass(/\bdarkened\b/); + + await expect + .poll( + async () => + page.evaluate(() => { + const slots = [...document.querySelectorAll('.page-slot')]; + // A page renders its marks layer only while it is in play. + const inPlay = slots.filter((slot) => slot.querySelector('.page-marks')); + if (!inPlay.length) return null; + const widths = inPlay.map( + (slot) => slot.querySelector('canvas')?.width ?? 0 + ); + const sheet = inPlay[0].querySelector('.page-sheet'); + const shown = Math.round(sheet?.getBoundingClientRect().width ?? 0); + const distinct = [...new Set(widths)]; + return { + // One size, and it is the size the page is being shown at + // (the bitmap floors to a whole pixel). + sizes: distinct.length, + matchesSheet: distinct.every((width) => Math.abs(width - shown) <= 1) + }; + }), + { timeout: 20_000 } + ) + .toEqual({ sizes: 1, matchesSheet: true }); +}); + test('recognizes a scanned PDF with on-device text recognition', async ({ page }) => { // Import covers a ~7 MB engine download (local assets) plus recognition. test.setTimeout(180_000); diff --git a/vite.config.ts b/vite.config.ts index 09f400c..aef449b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -77,6 +77,8 @@ export default defineConfig({ 'src/lib/domain/importers.ts', 'src/lib/domain/model-catalog.ts', 'src/lib/domain/pages.ts', + 'src/lib/domain/page-tone.ts', + 'src/lib/domain/pdf-layout.ts', 'src/lib/domain/pdf-markdown.ts', 'src/lib/domain/segmenter.ts', 'src/lib/domain/study-tree.ts',