commit 68f97ca071b360aa1776b9171872cc38df3f9bf2 truex <truex@equwal.com> 2026-09-26 18:14:55 -0700 Grow the side padding of the mark with the line height On the phone the mark ended inside the last letter of a scan line. The view padded each box by 2 CSS pixels. On a phone of 360 CSS pixels that is 9 page pixels, and the phone test measured tesseract symbol boxes that end 10 to 18 page pixels before the ink. padBox now pads the left and the right side by a quarter of the line height when that is more than the padding: 14 page pixels for a line box of 56 pixels. A quarter of the line height is about the width of a word space, so the box does not reach the next word. lineBoxes gives the boxes without padding, and main.ts pads them with padBox.
src/line-boxes.ts | 32 +++++++++++++++++-------- src/main.ts | 4 ++-- src/view.ts | 4 ++-- test/line-boxes.test.ts | 63 +++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 76 insertions(+), 27 deletions(-)
diff --git a/src/line-boxes.ts b/src/line-boxes.ts index 9849e8c..8737fbf 100644 --- a/src/line-boxes.ts +++ b/src/line-boxes.ts @@ -43,22 +43,34 @@ function grow(box: Box, bbox: Box): void { box.y1 = Math.max(box.y1, bbox.y1); } +/** + * The share of the line height that pads the left and the right side of a + * box. Tesseract symbol boxes can end some pixels before the ink of a + * letter, more in a larger font. A quarter of the line height is about the + * width of a word space, so the box does not reach the next word. + */ +export const SIDE_PAD = 0.25; + +/** + * `box` with `pad` pixels on each side. The left and the right side get + * SIDE_PAD of the height of the box when that is more. + */ +export function padBox(box: Box, pad: number): Box { + const side = Math.max(pad, SIDE_PAD * (box.y1 - box.y0)); + return { x0: box.x0 - side, y0: box.y0 - pad, x1: box.x1 + side, y1: box.y1 + pad }; +} + /** * One box for each text line of `span` on `page`: the union of the boxes - * of its tokens on that line, plus `pad` pixels on each side. Tokens on - * other pages do not count. A span of an older alignment can go past the - * end of `tokens`: those indices do not count either. + * of its tokens on that line. Tokens on other pages do not count. A span of + * an older alignment can go past the end of `tokens`: those indices do not + * count either. * * The alignment drops punctuation, and it can drop a noisy letter at the * edge of a cue. So the box of a line grows to the edges of its first and * its last word (see `joins`). */ -export function lineBoxes( - tokens: readonly OcrToken[], - span: TokenSpan, - page: number, - pad = 0, -): Box[] { +export function lineBoxes(tokens: readonly OcrToken[], span: TokenSpan, page: number): Box[] { /** For each line: its box, and the first and the last token of the span on it. */ const lines = new Map<number, { box: Box; first: number; last: number }>(); for (let t = span.start; t < span.end; t++) { @@ -74,6 +86,6 @@ export function lineBoxes( return [...lines.values()].map(({ box, first, last }) => { for (let t = first - 1; joins(tokens[first]!, tokens[t], true); t--) grow(box, tokens[t]!.bbox); for (let t = last + 1; joins(tokens[last]!, tokens[t], false); t++) grow(box, tokens[t]!.bbox); - return { x0: box.x0 - pad, y0: box.y0 - pad, x1: box.x1 + pad, y1: box.y1 + pad }; + return box; }); } diff --git a/src/main.ts b/src/main.ts index e77eb1a..0775630 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,7 @@ import { audioPage, cueParts, cueProgress, pageStartTime, type CueParts } from ' import { setupDrawer } from './drawer'; import { follow, type FollowEvent, type FollowMode } from './follower'; import { lookupText, tapTolerance, tokenAt } from './hit-test'; -import { lineBoxes } from './line-boxes'; +import { lineBoxes, padBox } from './line-boxes'; import { clockText, errorMessage, @@ -619,7 +619,7 @@ function marks(index: number): Marks | null { return { width: entry.width, height: entry.height, - boxes: (pad) => lineBoxes(state.tokens, span, index, pad), + boxes: (pad) => lineBoxes(state.tokens, span, index).map((box) => padBox(box, pad)), }; } diff --git a/src/view.ts b/src/view.ts index 7951c0a..57e342b 100644 --- a/src/view.ts +++ b/src/view.ts @@ -9,7 +9,7 @@ import { scrollTarget, type Extent } from './scroll'; /** The widest canvas, in device pixels. It limits the memory of one page. */ const MAX_RENDER = 2048; -/** The space around a box of the mark, in CSS pixels. */ +/** The least space around a box of the mark, in CSS pixels. See padBox in line-boxes.ts. */ const BOX_PAD = 2; /** After the user scrolls the viewer, the view does not scroll by itself for this many milliseconds. */ @@ -25,7 +25,7 @@ const AUTO_SCROLL_MS = 1000; export interface Marks { width: number; height: number; - /** The boxes, with `pad` page pixels of padding. */ + /** The boxes, with at least `pad` page pixels of padding. */ boxes(pad: number): Box[]; } diff --git a/test/line-boxes.test.ts b/test/line-boxes.test.ts index b88b76d..ba8fc68 100644 --- a/test/line-boxes.test.ts +++ b/test/line-boxes.test.ts @@ -1,7 +1,7 @@ import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; import type { OcrToken, TokenSpan } from '../src/align'; -import { lineBoxes, type Box } from '../src/line-boxes'; +import { lineBoxes, padBox, SIDE_PAD, type Box } from '../src/line-boxes'; function token(page: number, line: number, x0: number, y0: number): OcrToken { return { text: 'a', page, line, bbox: { x0, y0, x1: x0 + 10, y1: y0 + 20 } }; @@ -112,9 +112,19 @@ describe('lineBoxes', () => { expect(lineBoxes(line, spanOf(3, 7), 0)).toEqual([{ x0: 30, y0: 0, x1: 80, y1: 20 }]); }); - it('pads each box on each side', () => { - const line = textTokens(['quiet hills,']); - expect(lineBoxes(line, spanOf(0, 9), 0, 3)).toEqual([{ x0: -3, y0: -3, x1: 123, y1: 23 }]); + it('pads the sides by a quarter of the line height when that is more than the padding', () => { + // The phone case: a page 1600 px wide on a phone of 360 CSS px. 2 CSS px + // are 9 page px, and the line box of a scan is 56 px high. Tesseract + // symbol boxes ended 10 to 18 px before the ink of the last letter. + const line: OcrToken[] = [...'hills,'].map((ch, i) => ({ + text: ch, + page: 0, + line: 0, + word: 0, + bbox: { x0: 100 + 25 * i, y0: 300, x1: 125 + 25 * i, y1: 356 }, + })); + const boxes = lineBoxes(line, spanOf(0, 6), 0).map((b) => padBox(b, 9)); + expect(boxes).toEqual([{ x0: 86, y0: 291, x1: 264, y1: 365 }]); }); it('gives the box of the whole words of the span on each line', () => { @@ -132,11 +142,10 @@ describe('lineBoxes', () => { fc.constant(page), fc.nat({ max: page.length - 1 }), fc.nat({ max: page.length - 1 }), - fc.nat({ max: 5 }), ); }); fc.assert( - fc.property(input, ([page, a, b, pad]) => { + fc.property(input, ([page, a, b]) => { const span = spanOf(Math.min(a, b), Math.max(a, b) + 1); const inSpan = page.slice(span.start, span.end); const expected = new Map<number, Box>(); @@ -152,13 +161,41 @@ describe('lineBoxes', () => { box.y1 = Math.max(box.y1, t.bbox.y1); } } - const padded = [...expected.values()].map((b) => ({ - x0: b.x0 - pad, - y0: b.y0 - pad, - x1: b.x1 + pad, - y1: b.y1 + pad, - })); - expect(lineBoxes(page, span, 0, pad)).toEqual(padded); + expect(lineBoxes(page, span, 0)).toEqual([...expected.values()]); + }), + ); + }); +}); + +describe('padBox', () => { + it('pads each side by the padding when the line is low', () => { + // A quarter of the 20 px line is 5 px, less than the padding. + expect(padBox({ x0: 0, y0: 0, x1: 120, y1: 20 }, 6)).toEqual({ + x0: -6, + y0: -6, + x1: 126, + y1: 26, + }); + }); + + it('holds the box, pads the top and the bottom by the padding, and each side by the larger rule', () => { + const box = fc + .tuple( + fc.integer({ min: -2000, max: 2000 }), + fc.integer({ min: -2000, max: 2000 }), + fc.nat({ max: 2000 }), + fc.nat({ max: 400 }), + ) + .map(([x0, y0, w, h]) => ({ x0, y0, x1: x0 + w, y1: y0 + h })); + // Whole pixels, so the sums are exact. + fc.assert( + fc.property(box, fc.nat({ max: 50 }), (b, pad) => { + const out = padBox(b, pad); + const side = Math.max(pad, SIDE_PAD * (b.y1 - b.y0)); + expect(out).toEqual({ x0: b.x0 - side, y0: b.y0 - pad, x1: b.x1 + side, y1: b.y1 + pad }); + expect(b.x0 - out.x0).toBeGreaterThanOrEqual(pad); + expect(out.x1 - b.x1).toBeGreaterThanOrEqual(pad); + expect(out.x1 - b.x1).toBeGreaterThanOrEqual(SIDE_PAD * (b.y1 - b.y0)); }), ); });