commit d6dc842ff2d66752f473d33eeae1548a5726db41 truex <truex@equwal.com> 2026-09-26 21:35:19 -0700 Keep the spans on their tokens when an earlier page is read The reader reads the current page first, then the pages after it, then the pages before it. Each page before the current page puts its tokens in front of the tokens that the spans index. Until the next alignment (300 ms after the last page), the spans then pointed at other tokens: the mark could show on a wrong line, and a long press could give the Anki card the sentence of a wrong cue. shiftSpans (align.ts) moves each matched span after the new tokens, so that it keeps its first and its last token. readBook calls it after it puts the tokens of a page in place. A property test checks that each span keeps its first and its last token for any insert.
src/align.ts | 20 +++++++++++++ src/main.ts | 7 +++-- test/align.property.test.ts | 71 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-)
diff --git a/src/align.ts b/src/align.ts index 3809f66..846b9d7 100644 --- a/src/align.ts +++ b/src/align.ts @@ -356,6 +356,26 @@ export function risingChain(values: ArrayLike<number>): number[] { return chain; } +/** + * The spans after `count` tokens go into the tokens at index `at`: each + * matched span keeps its first and its last token. The reader reads the + * pages after the current page first. A page before it then puts its tokens + * in front of the tokens that the spans index, and without this shift the + * spans point at other tokens until the next alignment. A span across `at` + * also holds the new tokens. An unmatched span stays as it is. + */ +export function shiftSpans(spans: readonly TokenSpan[], at: number, count: number): TokenSpan[] { + return spans.map((span) => + span.matched + ? { + start: span.start < at ? span.start : span.start + count, + end: span.end <= at ? span.end : span.end + count, + matched: true, + } + : span, + ); +} + /** * Align cues to tokens in one call. The tokens must be in page order, the * order that the reader keeps them in. The spans index `tokens`. diff --git a/src/main.ts b/src/main.ts index 087bb77..4cb13e2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ // (the strip) and view.ts (the page). import { App } from '@capacitor/app'; -import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './align'; +import { createAligner, shiftSpans, type Aligner, type OcrToken, type TokenSpan } from './align'; import { ankiCard, cardSource } from './anki-card'; import { backStep, keepScreenOn } from './app-state'; import { resultName, srtName, subreadConcerns, subtitlesOnOpen } from './book-subtitles'; @@ -638,9 +638,12 @@ async function readBook(retry = false): Promise<void> { if (seq !== state.readSeq) return; // Another book or another setting took over. counts[entry.source]++; state.pages[page] = entry; - // Put the tokens of the page at their place in page order. + // Put the tokens of the page at their place in page order. The spans + // keep their tokens until the next alignment: the mark and the Anki + // card use them. const at = state.pages.slice(0, page).reduce((n, p) => n + (p?.tokens.length ?? 0), 0); state.tokens.splice(at, 0, ...entry.tokens); + state.spans = shiftSpans(state.spans, at, entry.tokens.length); const how = entry.source === 'text' ? 'text layer' : 'OCR'; setReading(readingText(read(), total, `page ${page + 1}: ${how}`)); if (page === state.currentPage) view.mark(); diff --git a/test/align.property.test.ts b/test/align.property.test.ts index 7f51019..42d8717 100644 --- a/test/align.property.test.ts +++ b/test/align.property.test.ts @@ -5,6 +5,7 @@ import { createAligner, normalizeText, risingChain, + shiftSpans, type TokenSpan, } from '../src/align'; import { books, char, grams, pagesOfCue, readPages, word, type Book, type Token } from './book'; @@ -247,6 +248,76 @@ describe('createAligner properties', () => { }); }); +describe('shiftSpans', () => { + const unmatched: TokenSpan = { start: 0, end: 0, matched: false }; + + /** A span of `n` tokens: matched and inside [0, n], or unmatched. */ + function span(n: number): fc.Arbitrary<TokenSpan> { + if (n === 0) return fc.constant(unmatched); + const matched = fc + .nat({ max: n - 1 }) + .chain((start) => + fc.integer({ min: start + 1, max: n }).map((end) => ({ start, end, matched: true })), + ); + return fc.oneof(fc.constant(unmatched), matched); + } + + it('keeps the first and the last token of each span when tokens go in', () => { + // The finding: page 19, read after page 20, put its tokens in front of + // the tokens of page 20, and the spans pointed at other tokens until + // the next alignment. + const input = fc + .nat({ max: 40 }) + .chain((n) => + fc.tuple(fc.constant(n), fc.array(span(n)), fc.nat({ max: n }), fc.nat({ max: 20 })), + ); + fc.assert( + fc.property(input, ([n, spans, at, count]) => { + const before = Array.from({ length: n }, (_, i) => `old ${i}`); + const after = [...before]; + after.splice(at, 0, ...Array.from({ length: count }, (_, i) => `new ${i}`)); + const shifted = shiftSpans(spans, at, count); + expect(shifted).toHaveLength(spans.length); + spans.forEach((s, i) => { + const moved = shifted[i]!; + if (!s.matched) { + expect(moved).toEqual(unmatched); + return; + } + expect(moved.matched).toBe(true); + expect(after[moved.start]).toBe(before[s.start]); + expect(after[moved.end - 1]).toBe(before[s.end - 1]); + }); + }), + ); + }); + + it('moves the spans of page 20 behind the tokens of page 19', () => { + // Page 20 has 5 tokens and two cues. Page 19 puts 4 tokens in front. + const spans = [ + { start: 0, end: 3, matched: true }, + { start: 3, end: 5, matched: true }, + unmatched, + ]; + expect(shiftSpans(spans, 0, 4)).toEqual([ + { start: 4, end: 7, matched: true }, + { start: 7, end: 9, matched: true }, + unmatched, + ]); + }); + + it('keeps a span before the new tokens, and stretches a span across them', () => { + const spans = [ + { start: 0, end: 2, matched: true }, + { start: 2, end: 6, matched: true }, + ]; + expect(shiftSpans(spans, 4, 3)).toEqual([ + { start: 0, end: 2, matched: true }, + { start: 2, end: 9, matched: true }, + ]); + }); +}); + describe('alignCuesToTokens on one page', () => { it('maps exact text to exact spans, for short cues too', () => { fc.assert(