Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


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));
       }),
     );
   });