From 6593205b13b4342718e9c7d266f003cb38768db3 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Mon, 2 Nov 2026 21:57:36 +0200 Subject: [PATCH 1/2] fix: expose rendered EPUB pages in activity coverage --- docs/architecture.md | 5 +- lib/src/engine/epub/epub_reader_engine.dart | 45 +++++++++------ lib/src/engine/epub/epub_viewport.dart | 20 +++++++ test/presentation/epub_reading_flow_test.dart | 55 +++++++++++++++++++ 4 files changed, 107 insertions(+), 18 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 8f4ad3e..95fcbda 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,10 @@ Chapter preparation keeps the committed page and snapshot visible until the replacement layout is ready. Reflow retains the previous layout while measuring; appearance changes reuse geometry. Full-page loading is limited to initial opening, with a small delayed indicator for subsequent slow operations. Failed preparation -keeps readable content and offers retry. Coverage describes committed viewports. +keeps readable content and offers retry. Coverage describes committed viewports as separate rendered pages. EPUB coverage +keys include the spine index, page boundary offsets and chapter content length. +Scrolling clips the visible content extents while retaining the rendered page key. +Hosts can count exposed pages independently of publisher page-count metadata. The viewport owns page and scroll controllers. Paginated commands use the same slide duration within and across spine items. At a boundary it retains the outgoing diff --git a/lib/src/engine/epub/epub_reader_engine.dart b/lib/src/engine/epub/epub_reader_engine.dart index 341c281..f4ccb5d 100644 --- a/lib/src/engine/epub/epub_reader_engine.dart +++ b/lib/src/engine/epub/epub_reader_engine.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart' show listEquals; import '../../domain/reader_capabilities.dart'; import '../../domain/reader_activity.dart'; @@ -461,6 +462,7 @@ final class EpubReaderEngine extends ReaderEngine { bool pageCountEstimated = false, required int coverageStart, required int coverageEnd, + required List<({int start, int end})> pageRanges, required bool atEnd, }) { _batchingViewport = true; @@ -471,7 +473,12 @@ final class EpubReaderEngine extends ReaderEngine { textOffset: textOffset, pageNumber: pageNumber, ); - viewportCoverageChanged(coverageStart, coverageEnd, atEnd: atEnd); + viewportCoverageChanged( + coverageStart, + coverageEnd, + pageRanges: pageRanges, + atEnd: atEnd, + ); _batchingViewport = false; _publish(); } @@ -493,30 +500,34 @@ final class EpubReaderEngine extends ReaderEngine { _publish(); } - void viewportCoverageChanged(int start, int end, {bool atEnd = false}) { + void viewportCoverageChanged( + int start, + int end, { + List<({int start, int end})> pageRanges = const [], + bool atEnd = false, + }) { if (_disposed || _locator == null) return; - final coverage = _contentLength > 0 && end > start - ? ReaderContentCoverage( - key: 'epub:${_locator!.spineIndex}:$_contentLength', - start: start / _contentLength, - end: (end / _contentLength).clamp(0, 1), - chapterCount: _chapterCount, - ) - : null; + final coverage = [ + if (_contentLength > 0 && end > start) + for (final page in pageRanges) + if (page.end > start && page.start < end && page.end > page.start) + ReaderContentCoverage( + key: + 'epub:${_locator!.spineIndex}:${page.start}:${page.end}:$_contentLength', + start: math.max(start, page.start) / _contentLength, + end: (math.min(end, page.end) / _contentLength).clamp(0, 1), + chapterCount: _chapterCount, + ), + ]; final nextEnd = atEnd && _locator!.spineIndex == _chapterCount - 1; if (_viewportReady && (!nextEnd || _locator!.totalProgression == 1) && _atEnd == nextEnd && - (coverage == null - ? _coverage.isEmpty - : _coverage.length == 1 && - _coverage.first.key == coverage.key && - _coverage.first.start == coverage.start && - _coverage.first.end == coverage.end)) { + listEquals(_coverage, coverage)) { return; } _viewportReady = true; - _coverage = coverage == null ? const [] : [coverage]; + _coverage = coverage; _atEnd = nextEnd; if (nextEnd) { diff --git a/lib/src/engine/epub/epub_viewport.dart b/lib/src/engine/epub/epub_viewport.dart index b09bfd6..2fda631 100644 --- a/lib/src/engine/epub/epub_viewport.dart +++ b/lib/src/engine/epub/epub_viewport.dart @@ -560,6 +560,13 @@ final class _EpubViewportState extends State { pageCountEstimated: layout != null && layout.pageCount == null, coverageStart: _pages[page].offset, coverageEnd: coverageEnd, + pageRanges: [ + for (var index = page; index < after; index++) + ( + start: _pages[index].offset, + end: index + 1 < after ? _pages[index + 1].offset : coverageEnd, + ), + ], atEnd: complete && after == _pages.length, ); if (schedulePrefetch) { @@ -608,6 +615,19 @@ final class _EpubViewportState extends State { pageCount: _pages.length, coverageStart: offset, coverageEnd: lastOffset.clamp(0, widget.engine.contentLength), + pageRanges: [ + for ( + var page = index; + page < _pages.length && _scrollOffsets[page] < bottom; + page++ + ) + ( + start: _pages[page].offset, + end: page + 1 < _pages.length + ? _pages[page + 1].offset + : widget.engine.contentLength, + ), + ], atEnd: pixels >= max - 1, ); } diff --git a/test/presentation/epub_reading_flow_test.dart b/test/presentation/epub_reading_flow_test.dart index e86132f..49b72bb 100644 --- a/test/presentation/epub_reading_flow_test.dart +++ b/test/presentation/epub_reading_flow_test.dart @@ -7,6 +7,61 @@ import '../support/in_process_epub_worker.dart'; import '../support/synthetic_epub.dart'; void main() { + for (final columns in [ReaderColumnMode.single, ReaderColumnMode.double]) { + testWidgets( + 'reading coverage identifies each rendered page in $columns layout', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 850); + addTearDown(tester.view.reset); + final engine = testEpubEngine(); + await tester.pumpWidget( + MaterialApp( + home: PapyrusReader( + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () => engine, + ), + ]), + initialPreferences: ReaderPreferences( + columnMode: columns, + pageTurnAnimation: false, + ), + document: ReaderDocument( + id: 'coverage', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(longChapter: true), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final first = engine.snapshot.coverage; + expect(first.length, columns == ReaderColumnMode.double ? 2 : 1); + expect(first.map((page) => page.key).toSet().length, first.length); + expect(first.every((page) => page.end > page.start), isTrue); + if (first.length == 2) { + expect(first.first.end, first.last.start); + } + await pumpReaderCommand(tester, engine.goNext()); + await tester.pumpAndSettle(); + expect(engine.snapshot.coverage.first.start, first.last.end); + expect( + engine.snapshot.coverage + .map((page) => page.key) + .toSet() + .intersection(first.map((page) => page.key).toSet()), + isEmpty, + ); + await pumpReaderCommand(tester, engine.goPrevious()); + await tester.pumpAndSettle(); + expect(engine.snapshot.coverage, first); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('hiding chrome keeps a long EPUB on its current content offset', ( tester, ) async { From 0b666837782e4d7d71282e94ac74102dcdca9140 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Mon, 2 Nov 2026 23:32:55 +0200 Subject: [PATCH 2/2] fix: retain EPUB page identity as prefetch completes --- docs/architecture.md | 5 ++++- lib/src/domain/reader_activity.dart | 3 +++ lib/src/engine/epub/epub_reader_engine.dart | 4 +--- test/presentation/epub_prefetch_test.dart | 7 +++++++ test/presentation/epub_reading_flow_test.dart | 1 + 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 95fcbda..5f6e792 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,7 +65,10 @@ replacement layout is ready. Reflow retains the previous layout while measuring; appearance changes reuse geometry. Full-page loading is limited to initial opening, with a small delayed indicator for subsequent slow operations. Failed preparation keeps readable content and offers retry. Coverage describes committed viewports as separate rendered pages. EPUB coverage -keys include the spine index, page boundary offsets and chapter content length. +keys include the spine index, stable page start offset and chapter content length. +The page end is excluded because background pagination can extend it over a +paragraph separator. Page-scoped coverage omits `chapterCount`; chapter-scoped +coverage supplies it for hosts that calibrate estimates from publisher metadata. Scrolling clips the visible content extents while retaining the rendered page key. Hosts can count exposed pages independently of publisher page-count metadata. diff --git a/lib/src/domain/reader_activity.dart b/lib/src/domain/reader_activity.dart index 6302b1c..aa1516b 100644 --- a/lib/src/domain/reader_activity.dart +++ b/lib/src/domain/reader_activity.dart @@ -19,6 +19,9 @@ final class ReaderContentCoverage { final String key; final double start; final double end; + + /// Present for chapter-scoped coverage that needs a host page-count estimate. + /// Absent when the key identifies an individual rendered page. final int? chapterCount; final int? pdfPageIndex; diff --git a/lib/src/engine/epub/epub_reader_engine.dart b/lib/src/engine/epub/epub_reader_engine.dart index f4ccb5d..7392d4f 100644 --- a/lib/src/engine/epub/epub_reader_engine.dart +++ b/lib/src/engine/epub/epub_reader_engine.dart @@ -512,11 +512,9 @@ final class EpubReaderEngine extends ReaderEngine { for (final page in pageRanges) if (page.end > start && page.start < end && page.end > page.start) ReaderContentCoverage( - key: - 'epub:${_locator!.spineIndex}:${page.start}:${page.end}:$_contentLength', + key: 'epub:${_locator!.spineIndex}:${page.start}:$_contentLength', start: math.max(start, page.start) / _contentLength, end: (math.min(end, page.end) / _contentLength).clamp(0, 1), - chapterCount: _chapterCount, ), ]; final nextEnd = atEnd && _locator!.spineIndex == _chapterCount - 1; diff --git a/test/presentation/epub_prefetch_test.dart b/test/presentation/epub_prefetch_test.dart index e24ab49..174780c 100644 --- a/test/presentation/epub_prefetch_test.dart +++ b/test/presentation/epub_prefetch_test.dart @@ -24,6 +24,8 @@ void main() { ), preferences: const ReaderPreferences(pageTurnAnimation: false), ); + final coverage = []; + engine.addListener(() => coverage.addAll(engine.snapshot.coverage)); await tester.pumpWidget( MaterialApp(home: Builder(builder: engine.buildViewport)), ); @@ -33,6 +35,11 @@ void main() { expect(engine.locator.totalProgression, 1); expect(engine.snapshot.coverage.single.end, 1); expect(engine.locator.textOffset, 0); + expect( + coverage.map((extent) => extent.end).toSet().length, + greaterThan(1), + ); + expect(coverage.map((extent) => extent.key).toSet(), hasLength(1)); }, ); diff --git a/test/presentation/epub_reading_flow_test.dart b/test/presentation/epub_reading_flow_test.dart index 49b72bb..891c8cf 100644 --- a/test/presentation/epub_reading_flow_test.dart +++ b/test/presentation/epub_reading_flow_test.dart @@ -41,6 +41,7 @@ void main() { expect(first.length, columns == ReaderColumnMode.double ? 2 : 1); expect(first.map((page) => page.key).toSet().length, first.length); expect(first.every((page) => page.end > page.start), isTrue); + expect(first.every((page) => page.chapterCount == null), isTrue); if (first.length == 2) { expect(first.first.end, first.last.start); }