From 1f5481c0e362b25beefc5c63d71dccf1f402e76e Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 27 May 2026 21:19:27 +0800 Subject: [PATCH] fix(fxl): align TTS highlight overlay with scaled iframe coords (#4324) Non-PDF fixed-layout EPUBs visually scale the iframe via CSS transform while keeping native dimensions inside, so getClientRects() returns unscaled positions. The SVG overlayer was sized in CSS pixels with no viewBox, leaving annotations and TTS highlights drawn at scale-1 displacement from the actual text. Bumps foliate-js to set a matching viewBox on the overlayer SVG, and adds a regression test covering spread/scroll/PDF/empty frames. Co-authored-by: Claude Opus 4.7 (1M context) --- .../fixed-layout-overlayer-viewbox.test.ts | 75 +++++++++++++++++++ packages/foliate-js | 2 +- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/src/__tests__/document/fixed-layout-overlayer-viewbox.test.ts diff --git a/apps/readest-app/src/__tests__/document/fixed-layout-overlayer-viewbox.test.ts b/apps/readest-app/src/__tests__/document/fixed-layout-overlayer-viewbox.test.ts new file mode 100644 index 00000000..789e1484 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/fixed-layout-overlayer-viewbox.test.ts @@ -0,0 +1,75 @@ +// Regression test for: TTS highlight overlay is off the text boxes in +// fixed-layout EPUBs when the page is scaled up. +// +// In non-PDF fixed-layout, the iframe is visually scaled with `transform: +// scale(N)` while its internal layout keeps native dimensions. The SVG +// overlayer sits next to the iframe at the scaled size, but +// `range.getClientRects()` inside the iframe returns positions in the +// iframe's native coord system. Without a matching `viewBox`, the SVG draws +// rects in CSS pixels and the highlight lands at scale-1 displacement from +// the actual text. +// +// The fix sets `viewBox="0 0 width height"` on the overlayer SVG so its +// coordinate system stays aligned with the iframe contents at any scale. +import { describe, expect, it } from 'vitest'; + +import { applyOverlayerViewBox } from 'foliate-js/fixed-layout.js'; + +const makeOverlayer = () => { + const attrs: Record = {}; + return { + element: { + setAttribute: (name: string, value: string) => { + attrs[name] = value; + }, + removeAttribute: (name: string) => { + delete attrs[name]; + }, + getAttribute: (name: string) => attrs[name] ?? null, + _attrs: attrs, + }, + }; +}; + +describe('applyOverlayerViewBox', () => { + it('sets viewBox to native iframe dimensions for transform-scaled frames', () => { + const overlayer = makeOverlayer(); + applyOverlayerViewBox({ width: 558, height: 711 }, overlayer); + + expect(overlayer.element.getAttribute('viewBox')).toBe('0 0 558 711'); + expect(overlayer.element.getAttribute('preserveAspectRatio')).toBe('none'); + }); + + it('removes viewBox when frame uses onZoom (PDF text-layer rendered at scale)', () => { + const overlayer = makeOverlayer(); + // pre-populate to simulate switching from EPUB-style frame to PDF frame + overlayer.element.setAttribute('viewBox', '0 0 558 711'); + overlayer.element.setAttribute('preserveAspectRatio', 'none'); + + applyOverlayerViewBox({ width: 558, height: 711, onZoom: () => {} }, overlayer); + + expect(overlayer.element.getAttribute('viewBox')).toBeNull(); + expect(overlayer.element.getAttribute('preserveAspectRatio')).toBeNull(); + }); + + it('accepts scroll-mode frames keyed by vpWidth/vpHeight', () => { + const overlayer = makeOverlayer(); + applyOverlayerViewBox({ vpWidth: 800, vpHeight: 1200 }, overlayer); + + expect(overlayer.element.getAttribute('viewBox')).toBe('0 0 800 1200'); + }); + + it('is a no-op when the frame has no dimensions', () => { + const overlayer = makeOverlayer(); + applyOverlayerViewBox({}, overlayer); + + expect(overlayer.element.getAttribute('viewBox')).toBeNull(); + }); + + it('tolerates missing overlayer or element', () => { + expect(() => applyOverlayerViewBox({ width: 100, height: 100 }, null)).not.toThrow(); + expect(() => + applyOverlayerViewBox({ width: 100, height: 100 }, { element: null }), + ).not.toThrow(); + }); +}); diff --git a/packages/foliate-js b/packages/foliate-js index 1ea29964..554b4bf2 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 1ea29964add965a228f8b771b4d22b418fb948d6 +Subproject commit 554b4bf2b908d518877a270d0d3e964603006a6b