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 e55aaadd29648971154f711d9b903a596f638b8a
truex <truex@equwal.com>
2026-09-26 19:26:19 -0700

Render the page again at the pinch zoom

On the phone the canvas did not render again after a pinch zoom, so the
text was soft at 2.5x. The landscape canvas also stopped at 2048 px for
2237 device px.

renderWidth gives one canvas pixel for each device pixel at the zoom:
the width of the page times devicePixelRatio times visualViewport.scale,
at most 4096 px wide and 12 million px in area, at least 300 px. The
view renders the page again 0.3 s after the zoom stops, and after a
change of width. The marks are in page pixels, so they stay correct.
In the pane, a zoom of 2.5 gave a canvas of 1720 px for a page of
344 CSS px at a device pixel ratio of 2, and 689 px again at no zoom.

 README.md                |  6 ++++-
 src/render-size.ts       | 36 ++++++++++++++++++++++++++++++
 src/view.ts              | 57 +++++++++++++++++++++++++++++++-----------------
 test/render-size.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 135 insertions(+), 21 deletions(-)
diff --git a/README.md b/README.md
index e7f04b8..5a6e0ba 100644
--- a/README.md
+++ b/README.md
@@ -370,7 +370,9 @@ Notes:
 - The pdf.js worker is part of the bundle.
 - Pinch zoom is on (`zoomEnabled` in `capacitor.config.ts`). The page
   fits the screen width in each orientation, up to 1000 CSS pixels, and
-  it renders again at the resolution of the screen after a rotation.
+  it renders again at the resolution of the screen after a rotation, and
+  at the zoom 0.3 s after a pinch zoom stops, so the text stays sharp.
+  The canvas is at most 4096 pixels wide and 12 million pixels in area.
   Zoom in and scroll to read small print. On a zoomed page a swipe moves
   the page and does not turn it.
 - The pages, the copy of the last book and the meta data of each book
@@ -436,6 +438,8 @@ in a browser bundle.
 - `src/paging.ts`: pure page turns from keys, swipes and the arrows, the
   page jump, the reading direction, and the limits of the long press.
 - `src/scroll.ts`: pure scroll position that brings the mark into view.
+- `src/render-size.ts`: pure width of the canvas of a page, for the
+  screen and the pinch zoom.
 - `src/messages.ts`: pure texts of the status strip.
 - `src/read-order.ts`: pure order in which the pages are read.
 - `src/book-text.ts`: pure tokens to the book text for SubRead.
diff --git a/src/render-size.ts b/src/render-size.ts
new file mode 100644
index 0000000..b1026d4
--- /dev/null
+++ b/src/render-size.ts
@@ -0,0 +1,36 @@
+// The width of the canvas of a page, in device pixels. Pure: no DOM.
+
+/** The widest canvas. */
+export const MAX_RENDER_WIDTH = 4096;
+
+/**
+ * The largest canvas, in pixels: about 48 MB of RGBA. A larger canvas can
+ * fail to draw, or take the memory of the WebView on a phone.
+ */
+export const MAX_RENDER_AREA = 12_000_000;
+
+/** The narrowest canvas. */
+export const MIN_RENDER_WIDTH = 300;
+
+export interface RenderInput {
+  /** The width of the page on the screen, in CSS pixels. */
+  cssWidth: number;
+  /** Device pixels per CSS pixel. */
+  dpr: number;
+  /** The pinch zoom of the whole app (visualViewport.scale). 1 is no zoom. */
+  zoom: number;
+  /** The height of the page divided by its width. */
+  aspect: number;
+}
+
+/**
+ * The width of the canvas for a page: one canvas pixel for each device
+ * pixel at the pinch zoom, so the text is sharp also when the user zooms
+ * in. The width stays within MAX_RENDER_WIDTH and MAX_RENDER_AREA, and is
+ * at least MIN_RENDER_WIDTH.
+ */
+export function renderWidth(s: RenderInput): number {
+  const sharp = s.cssWidth * s.dpr * Math.max(1, s.zoom);
+  const byArea = Math.sqrt(MAX_RENDER_AREA / Math.max(s.aspect, 0.01));
+  return Math.round(Math.max(MIN_RENDER_WIDTH, Math.min(sharp, MAX_RENDER_WIDTH, byArea)));
+}
diff --git a/src/view.ts b/src/view.ts
index f2ca041..84f7689 100644
--- a/src/view.ts
+++ b/src/view.ts
@@ -1,13 +1,17 @@
 // The page view. It renders a page to fit the width of the viewer, draws
 // the boxes of the mark, keeps the mark in view, and renders the page
-// again when the width of the viewer changes.
+// again when the width of the viewer or the pinch zoom changes.
 
 import type { Box } from './line-boxes';
 import type { PdfDoc } from './pdf';
+import { renderWidth } from './render-size';
 import { scrollTarget, type Extent } from './scroll';
 
-/** The widest canvas, in device pixels. It limits the memory of one page. */
-const MAX_RENDER = 2048;
+/** The height of a page divided by its width before the first render: A4. */
+const DEFAULT_ASPECT = 842 / 595;
+
+/** Milliseconds after the last change of the pinch zoom before the page renders again. */
+const ZOOM_MS = 300;
 
 /** The least space around a box of the mark, in CSS pixels. See padBox in line-boxes.ts. */
 const BOX_PAD = 2;
@@ -55,14 +59,29 @@ export function createPageView(
   let pdf: PdfDoc | null = null;
   /** The page to show. */
   let index = -1;
-  /** The page on the canvas, and the size that it was rendered for. */
-  let shown = { pdf: null as PdfDoc | null, index: -1, cssWidth: 0, dpr: 0 };
+  /** The page on the canvas, and the width of the canvas in device pixels. */
+  let shown = { pdf: null as PdfDoc | null, index: -1, width: 0 };
   let seq = 0;
   let autoUntil = 0;
   let userAt = -Infinity;
 
   const canvas = (): HTMLCanvasElement | null => ui.page.querySelector('canvas');
 
+  /**
+   * The width of the canvas for the page now: sharp for the screen and the
+   * pinch zoom, within the limits of renderWidth. The shape of the page on
+   * the canvas stands for the shape of the next page.
+   */
+  function targetWidth(): number {
+    const c = canvas();
+    return renderWidth({
+      cssWidth: ui.page.clientWidth,
+      dpr: window.devicePixelRatio || 1,
+      zoom: window.visualViewport?.scale ?? 1,
+      aspect: c && c.width > 0 ? c.height / c.width : DEFAULT_ASPECT,
+    });
+  }
+
   /** Draws the boxes of the mark. Gives their extent in the scroll space of the viewer, or null. */
   function draw(): Extent | null {
     ui.overlay.replaceChildren();
@@ -114,9 +133,7 @@ export function createPageView(
     if (!doc || index < 0) return;
     const my = ++seq;
     const at = index;
-    const cssWidth = ui.page.clientWidth;
-    const dpr = window.devicePixelRatio || 1;
-    const width = Math.round(Math.min(MAX_RENDER, Math.max(300, cssWidth * dpr)));
+    const width = targetWidth();
     const c = await doc.renderPage(at, width);
     if (my !== seq) return; // A newer render replaced this one.
     const turn = doc !== shown.pdf || at !== shown.index;
@@ -125,7 +142,7 @@ export function createPageView(
     autoUntil = performance.now() + AUTO_SCROLL_MS;
     canvas()?.remove();
     ui.page.prepend(c);
-    shown = { pdf: doc, index: at, cssWidth, dpr };
+    shown = { pdf: doc, index: at, width };
     const extent = draw();
     const v = ui.viewer;
     if (turn) {
@@ -159,20 +176,20 @@ export function createPageView(
     );
   }
 
-  // Render again when the width of the page changes, for example after a
-  // rotation. The canvas stretches at once. The new render makes it sharp.
+  // Render again when the canvas needs another width: after a rotation, or
+  // when a pinch zoom settles. The canvas stretches at once. The new render
+  // makes it sharp. The marks are in page pixels, so they stay correct.
   let resizeTimer: ReturnType<typeof setTimeout> | undefined;
-  new ResizeObserver(() => {
+  function renderAgainSoon(ms: number): void {
     clearTimeout(resizeTimer);
     resizeTimer = setTimeout(() => {
-      const dpr = window.devicePixelRatio || 1;
-      const width = ui.page.clientWidth;
-      if (width > 0 && (Math.abs(width - shown.cssWidth) >= 1 || dpr !== shown.dpr)) {
-        // A render of a closed document fails. The stretched canvas stays.
-        render().catch(() => undefined);
-      }
-    }, RESIZE_MS);
-  }).observe(ui.viewer);
+      if (shown.index < 0 || ui.page.clientWidth === 0 || targetWidth() === shown.width) return;
+      // A render of a closed document fails. The stretched canvas stays.
+      render().catch(() => undefined);
+    }, ms);
+  }
+  new ResizeObserver(() => renderAgainSoon(RESIZE_MS)).observe(ui.viewer);
+  window.visualViewport?.addEventListener('resize', () => renderAgainSoon(ZOOM_MS));
 
   return {
     show(doc, i) {
diff --git a/test/render-size.test.ts b/test/render-size.test.ts
new file mode 100644
index 0000000..19d8fc4
--- /dev/null
+++ b/test/render-size.test.ts
@@ -0,0 +1,57 @@
+import fc from 'fast-check';
+import { describe, expect, it } from 'vitest';
+import {
+  MAX_RENDER_AREA,
+  MAX_RENDER_WIDTH,
+  MIN_RENDER_WIDTH,
+  renderWidth,
+} from '../src/render-size';
+
+/** A4: the height is 1.414 times the width. */
+const A4 = 842 / 595;
+
+describe('renderWidth', () => {
+  it('renders the phone page sharp at a pinch zoom of 2.5', () => {
+    // The phone finding: after a pinch zoom the canvas stayed at the width
+    // of no zoom, and the text was soft.
+    const phone = { cssWidth: 352, dpr: 2.625, aspect: A4 };
+    expect(renderWidth({ ...phone, zoom: 1 })).toBe(924);
+    expect(renderWidth({ ...phone, zoom: 2.5 })).toBe(2310);
+  });
+
+  it('renders a landscape page wider than 2048 px', () => {
+    // The phone finding: the landscape canvas stopped at 2048 px for 2237 device px.
+    expect(renderWidth({ cssWidth: 852.2, dpr: 2.625, zoom: 1, aspect: A4 })).toBe(2237);
+  });
+
+  it('stops at the largest width and area', () => {
+    expect(renderWidth({ cssWidth: 852, dpr: 2.625, zoom: 3, aspect: A4 })).toBe(
+      Math.round(Math.sqrt(MAX_RENDER_AREA / A4)),
+    );
+    expect(renderWidth({ cssWidth: 3000, dpr: 2, zoom: 1, aspect: 0.5 })).toBe(MAX_RENDER_WIDTH);
+    expect(renderWidth({ cssWidth: 50, dpr: 1, zoom: 1, aspect: A4 })).toBe(MIN_RENDER_WIDTH);
+  });
+
+  it('never goes over the limits, and grows with the zoom', () => {
+    const input = fc.record({
+      cssWidth: fc.double({ min: 1, max: 5000, noNaN: true }),
+      dpr: fc.double({ min: 0.5, max: 4, noNaN: true }),
+      zoom: fc.double({ min: 0.5, max: 10, noNaN: true }),
+      aspect: fc.double({ min: 0.2, max: 5, noNaN: true }),
+    });
+    fc.assert(
+      fc.property(input, fc.double({ min: 1, max: 3, noNaN: true }), (s, more) => {
+        const w = renderWidth(s);
+        expect(w).toBeGreaterThanOrEqual(MIN_RENDER_WIDTH);
+        expect(w).toBeLessThanOrEqual(MAX_RENDER_WIDTH);
+        // Rounding adds at most half a pixel to the width. The factor allows for floating point.
+        if (w > MIN_RENDER_WIDTH) {
+          expect((w - 0.5) ** 2 * s.aspect).toBeLessThanOrEqual(MAX_RENDER_AREA * (1 + 1e-9));
+        }
+        const sharp = s.cssWidth * s.dpr * Math.max(1, s.zoom);
+        if (sharp <= w) expect(w).toBe(Math.max(MIN_RENDER_WIDTH, Math.round(sharp)));
+        expect(renderWidth({ ...s, zoom: s.zoom * more })).toBeGreaterThanOrEqual(w);
+      }),
+    );
+  });
+});