diff --git a/docs/architecture.md b/docs/architecture.md index 8f4ad3e..5f6e792 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,13 @@ 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, 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. 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/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 341c281..7392d4f 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,32 @@ 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}:$_contentLength', + start: math.max(start, page.start) / _contentLength, + end: (math.min(end, page.end) / _contentLength).clamp(0, 1), + ), + ]; 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_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 e86132f..891c8cf 100644 --- a/test/presentation/epub_reading_flow_test.dart +++ b/test/presentation/epub_reading_flow_test.dart @@ -7,6 +7,62 @@ 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); + expect(first.every((page) => page.chapterCount == null), 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 {