diff --git a/docs/architecture.md b/docs/architecture.md index 7175b8f..e7f462e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,10 +37,20 @@ terminate their sessions on disposal. The browser must permit `worker-src blob:` Flutter's `TextPainter` measures styled runs at the actual column width, text scale and direction. Pagination cuts at line boundaries, keeps run styles and uses content offsets for restoration. Work yields between bounded batches. -Adjacent chapters are prefetched after the current viewport commits. The viewport -retains at most three layouts for the current geometry; document, font, viewport -or typography changes invalidate them. Requested navigation cancels speculative -measurement. Only visible pages are built. +Paginated reading consumes a resumable page stream, preparing a few spreads ahead +instead of waiting for an entire spine item. Worker blocks are converted lazily, +including image decoding. Adjacent spine items share in-flight loading and page +cursors with foreground navigation, so turning does not restart prefetch work. +The viewport retains at most three layout cursors for the current geometry. +Document, font, viewport or typography changes dispose them. Only visible pages +are built. Already measured pages remain available for backward turns. The location +bar always includes a current page and total. Until the exact total is available, +it marks a text-offset-based estimate with `≈`. A separate yielding counter finishes +the current and next item's totals without retaining their pages or advancing the +reading cursor. Geometry changes invalidate both the layouts and their totals. Seeking +to an unmeasured offset or the end of a spine item still requires measuring the +preceding content in yielding batches. Continuous scrolling collects a complete +layout to determine its scroll extent. Chapter preparation keeps the committed page and snapshot visible until the replacement layout is ready. Reflow retains the previous layout while measuring; @@ -48,8 +58,12 @@ appearance changes reuse geometry. Full-page loading is limited to initial openi with a small delayed indicator for subsequent slow operations. Failed preparation keeps readable content and offers retry. Coverage describes committed viewports. -The viewport owns page and scroll controllers and responds to local turns before -the engine crosses chapter boundaries. Single-column content has a maximum line +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 +spread, animates to the prepared destination, then commits reading coverage and +the visible locator. Reduced motion skips the animation. Rapid commands wait for +each transition to finish. Spine indices identify content and saved positions, +independently of any table-of-contents chapter divisions. Single-column content has a maximum line width; spreads collapse on narrow or highly scaled viewports. Scrolling uses the same rich content and measured extents. A supplied `EpubContentRenderer` remains available for custom HTML scrolling, with progression-based restoration. diff --git a/lib/src/engine/epub/epub_page_layout.dart b/lib/src/engine/epub/epub_page_layout.dart new file mode 100644 index 0000000..44b521a --- /dev/null +++ b/lib/src/engine/epub/epub_page_layout.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'epub_rich_layout.dart'; + +/// A resumable page cursor. Reading ahead consumes pages, not whole spine items. +/// Requests share the same cursor so foreground navigation reuses prefetch work. +final class EpubPageLayout { + EpubPageLayout( + Stream source, { + Stream Function()? countSource, + }) : _iterator = StreamIterator(source), + _countSource = countSource; + + final StreamIterator _iterator; + final Stream Function()? _countSource; + StreamIterator? _counter; + Future? _countPending; + int _countedPages = 0; + int? _exactCount; + bool _countFailed = false; + final Map> _yieldTimers = {}; + final List pages = []; + bool complete = false; + bool _disposed = false; + Object? _failure; + StackTrace? _failureStack; + + Future yieldWork() { + if (_disposed) return Future.value(); + final ready = Completer(); + late final Timer timer; + timer = Timer(Duration.zero, () { + _yieldTimers.remove(timer); + ready.complete(); + }); + _yieldTimers[timer] = ready; + return ready.future; + } + + int? get pageCount => complete ? pages.length : _exactCount; + + int estimatedPageCount(int contentLength) { + final exact = pageCount; + if (exact != null) return exact; + if (pages.length < 2 || pages.last.offset <= 0) { + return math.max(1, pages.length); + } + + final estimate = (contentLength * (pages.length - 1) / pages.last.offset) + .ceil(); + return math.max(pages.length, estimate); + } + + /// Counts without retaining the remaining pages or advancing the reading cursor. + /// A cancelled background request can resume from the same counting cursor. + Future countPages({bool Function()? cancelled}) async { + if (_countSource == null || _countFailed) return; + + while (!_disposed && pageCount == null && !(cancelled?.call() ?? false)) { + await (_countPending ??= _countNext().whenComplete( + () => _countPending = null, + )); + } + } + + Future _countNext() async { + final counter = _counter ??= StreamIterator(_countSource!()); + try { + if (await counter.moveNext()) { + _countedPages++; + } else if (!_disposed) { + _exactCount = _countedPages; + _counter = null; + } + } catch (_) { + _countFailed = true; + rethrow; + } + } + + Future ensure({ + int count = 1, + int? offset, + bool toEnd = false, + bool Function()? cancelled, + }) async { + while (!_disposed && !complete && !(cancelled?.call() ?? false)) { + if (_failure case final failure?) { + Error.throwWithStackTrace(failure, _failureStack!); + } + + final hasOffset = + offset == null || (pages.isNotEmpty && pages.last.offset > offset); + + if (!toEnd && pages.length >= count && hasOffset) return; + await _advance(); + } + } + + Future? _pending; + + Future _advance() { + return _pending ??= _readNext().whenComplete(() => _pending = null); + } + + Future _readNext() async { + try { + if (await _iterator.moveNext()) { + if (!_disposed) pages.add(_iterator.current); + } else { + complete = true; + } + } catch (error, stack) { + _failure = error; + _failureStack = stack; + rethrow; + } + } + + void dispose() { + if (_disposed) return; + _disposed = true; + for (final entry in _yieldTimers.entries) { + entry.key.cancel(); + entry.value.complete(); + } + _yieldTimers.clear(); + unawaited(_iterator.cancel()); + unawaited(_counter?.cancel()); + } +} diff --git a/lib/src/engine/epub/epub_reader_engine.dart b/lib/src/engine/epub/epub_reader_engine.dart index 29517f2..6b3d683 100644 --- a/lib/src/engine/epub/epub_reader_engine.dart +++ b/lib/src/engine/epub/epub_reader_engine.dart @@ -45,9 +45,13 @@ final class EpubReaderEngine extends ReaderEngine { int _documentRevision = 0; EpubProgressIndex? _progressIndex; final Map> _chapterCache = {}; + final Map>> _chapterRequests = {}; ReaderReadySnapshot? _displayed; - Future Function(int index, List blocks)? - prepareChapter; + Future Function( + EpubReaderLocator target, + List blocks, + )? + prepareLocation; Future _turnQueue = Future.value(); int _contentLength = 0; int _restorationRevision = 0; @@ -56,14 +60,15 @@ final class EpubReaderEngine extends ReaderEngine { bool _disposed = false; int _visiblePage = 1; int _pageCount = 0; + bool _pageCountEstimated = false; List _coverage = const []; bool _viewportReady = false; bool _atEnd = false; bool _batchingViewport = false; - /// The mounted viewport handles local page/scroll turns. The engine crosses - /// the spine only when that viewport reports a boundary. - Future Function(int direction)? moveWithinChapter; + /// The viewport owns page turns, including transitions across spine items. + /// The engine handles boundaries when no paginated viewport is mounted. + Future Function(int direction)? moveViewport; String get currentChapterHtml => _currentChapterHtml; List get blocks => _blocks; @@ -133,6 +138,10 @@ final class EpubReaderEngine extends ReaderEngine { (metadata['lengths'] as List?)?.cast() ?? List.filled(count, 1), ); _chapterCache.clear(); + _chapterRequests.clear(); + if ((chapter['html'] as String).length <= 4 * 1024 * 1024) { + _chapterCache[target.spineIndex] = chapter; + } _displayed = null; _toc = [ for (final entry in metadata['toc'] as List) @@ -194,10 +203,7 @@ final class EpubReaderEngine extends ReaderEngine { _viewportReady = false; _atEnd = false; _currentChapterHtml = chapter['html'] as String; - _blocks = [ - for (final block in chapter['blocks'] as List) - EpubContentBlock(Map.from(block as Map)), - ]; + _blocks = EpubContentBlocks(chapter['blocks'] as List); _anchors = (chapter['anchors'] as Map).cast(); _contentLength = chapter['length'] as int; _toc = [ @@ -263,11 +269,14 @@ final class EpubReaderEngine extends ReaderEngine { final offset = locator.anchor == null ? locator.textOffset : _anchors[locator.anchor] ?? locator.textOffset; - _locator = _locatorFor( + final target = _locatorFor( locator.spineIndex, local: locator.localProgression, textOffset: offset?.clamp(0, _contentLength), ); + await prepareLocation?.call(target, _blocks); + _requireCurrent(generation); + _locator = target; _coverage = const []; _viewportReady = false; _atEnd = false; @@ -275,10 +284,17 @@ final class EpubReaderEngine extends ReaderEngine { } else { final chapter = await chapterContent(locator.spineIndex); _requireCurrent(generation); - await prepareChapter?.call(locator.spineIndex, [ - for (final block in chapter['blocks'] as List) - EpubContentBlock(Map.from(block as Map)), - ]); + await prepareLocation?.call( + _locatorFor( + locator.spineIndex, + local: locator.localProgression, + textOffset: locator.anchor == null + ? locator.textOffset + : (chapter['anchors'] as Map)[locator.anchor] as int? ?? + locator.textOffset, + ), + EpubContentBlocks(chapter['blocks'] as List), + ); _requireCurrent(generation); _setChapter(chapter, locator); } @@ -329,6 +345,25 @@ final class EpubReaderEngine extends ReaderEngine { return cached; } + final pending = _chapterRequests[index]; + + if (pending != null) { + return pending; + } + + final request = _readChapter(index); + _chapterRequests[index] = request; + + try { + return await request; + } finally { + if (identical(_chapterRequests[index], request)) { + _chapterRequests.remove(index); + } + } + } + + Future> _readChapter(int index) async { final worker = _worker!; final chapter = await worker.chapter(index); @@ -360,7 +395,7 @@ final class EpubReaderEngine extends ReaderEngine { 'No EPUB document is loaded.', ); } - if (await moveWithinChapter?.call(direction) ?? false) return; + if (await moveViewport?.call(direction) ?? false) return; final index = _locator!.spineIndex + direction; if (index < 0 || index >= _chapterCount) { throw const ReaderException( @@ -376,12 +411,14 @@ final class EpubReaderEngine extends ReaderEngine { required int textOffset, required int pageNumber, required int pageCount, + bool pageCountEstimated = false, required int coverageStart, required int coverageEnd, required bool atEnd, }) { _batchingViewport = true; _pageCount = pageCount; + _pageCountEstimated = pageCountEstimated; viewportPositionChanged( local, textOffset: textOffset, @@ -455,11 +492,19 @@ final class EpubReaderEngine extends ReaderEngine { _publish(); } - void viewportPaginationChanged(int count, int visiblePage) { - if (_disposed || (_pageCount == count && _visiblePage == visiblePage)) { + void viewportPaginationChanged( + int count, + int visiblePage, { + bool estimated = false, + }) { + if (_disposed || + (_pageCount == count && + _visiblePage == visiblePage && + _pageCountEstimated == estimated)) { return; } _pageCount = count; + _pageCountEstimated = estimated; _visiblePage = visiblePage; _publish(); } @@ -508,7 +553,7 @@ final class EpubReaderEngine extends ReaderEngine { locationLabel: !_viewportReady && _displayed != null ? _displayed!.locationLabel : 'Chapter ${_locator!.spineIndex + 1} of $_chapterCount' - '${_pageCount > 0 ? ' · Page $_visiblePage of $_pageCount' : ''}', + '${_pageCount > 0 ? ' · Page $_visiblePage of ${_pageCountEstimated ? '≈' : ''}$_pageCount' : ''}', ); if (_viewportReady) { _displayed = snapshot as ReaderReadySnapshot; @@ -537,9 +582,10 @@ final class EpubReaderEngine extends ReaderEngine { _worker?.dispose(); _worker = null; _openingWorker = null; - moveWithinChapter = null; - prepareChapter = null; + moveViewport = null; + prepareLocation = null; _chapterCache.clear(); + _chapterRequests.clear(); super.dispose(); } } diff --git a/lib/src/engine/epub/epub_rich_layout.dart b/lib/src/engine/epub/epub_rich_layout.dart index 842f655..b7387e5 100644 --- a/lib/src/engine/epub/epub_rich_layout.dart +++ b/lib/src/engine/epub/epub_rich_layout.dart @@ -1,3 +1,4 @@ +import 'dart:collection'; import 'dart:math' as math; import 'dart:typed_data'; @@ -9,6 +10,27 @@ import '../../domain/reader_selection.dart'; import '../../domain/reader_locator.dart'; import '../../presentation/reader_interaction_scope.dart'; +/// Converts worker blocks only when the page cursor reaches them. In particular, +/// a large spine item must not decode all of its illustrations on the UI thread. +final class EpubContentBlocks extends ListBase { + EpubContentBlocks(this._blocks); + final List _blocks; + + @override + int get length => _blocks.length; + + @override + EpubContentBlock operator [](int index) => + EpubContentBlock(Map.from(_blocks[index] as Map)); + + @override + set length(int value) => throw UnsupportedError('Read-only content'); + + @override + void operator []=(int index, EpubContentBlock value) => + throw UnsupportedError('Read-only content'); +} + final class EpubContentBlock { EpubContentBlock(Map json) : kind = json['kind'] as String, @@ -143,35 +165,56 @@ final class EpubRichPage { int get offset => fragments.isEmpty ? 0 : fragments.first.offset; } +/// Collects a complete layout for export and layout verification. +Future> layoutEpubChapter( + List blocks, { + required Size contentSize, + required ReaderPreferences preferences, + required TextScaler textScaler, + required TextDirection direction, + required bool Function() cancelled, +}) => paginateEpub( + blocks, + contentSize: contentSize, + preferences: preferences, + textScaler: textScaler, + direction: direction, + cancelled: cancelled, +).toList(); + /// Measures each paragraph once and cuts at actual line boundaries. Long /// paragraphs never trigger repeated binary-search layouts or surrogate splits. -Future> layoutEpubChapter( +Stream paginateEpub( List blocks, { required Size contentSize, required ReaderPreferences preferences, required TextScaler textScaler, required TextDirection direction, required bool Function() cancelled, -}) async { + Future Function()? yieldWork, +}) async* { final width = math.max(1.0, contentSize.width); final height = math.max(1.0, contentSize.height); - final pages = []; + var emitted = false; var fragments = []; var used = 0.0; final stopwatch = Stopwatch()..start(); - void finish() { - if (fragments.isEmpty) return; - pages.add(EpubRichPage(List.unmodifiable(fragments))); + EpubRichPage finish() { + emitted = true; + final page = EpubRichPage(List.unmodifiable(fragments)); fragments = []; used = 0; + return page; } for (final block in blocks) { - if (cancelled()) return const []; + if (cancelled()) return; final spacing = block.paragraphEnd ? preferences.paragraphSpacing : 0.0; if (block.image != null) { final imageHeight = math.min(width / block.aspect, height * .65); - if (used + imageHeight + spacing > height) finish(); + if (used + imageHeight + spacing > height && fragments.isNotEmpty) { + yield finish(); + } fragments.add(EpubPageFragment(block, 0, 1, imageHeight)); used += imageHeight + spacing; } else { @@ -181,60 +224,72 @@ Future> layoutEpubChapter( textDirection: direction, textScaler: textScaler, )..layout(maxWidth: width); - final lines = painter.computeLineMetrics(); - final text = span.toPlainText(); - var start = 0; - var segmentHeight = 0.0; - for (var lineIndex = 0; lineIndex < lines.length; lineIndex++) { - final line = lines[lineIndex]; - final top = line.baseline - line.ascent; - final bottom = line.baseline + line.descent; - // Ascent/descent exclude part of the line-height leading on Skia fonts. - // Count the complete line box so several paragraphs cannot overfill a - // page even when the glyph bounds are shorter than their line boxes. - final lineHeight = line.height; - if (used + segmentHeight + lineHeight + spacing > height && - (used > 0 || segmentHeight > 0)) { - final position = painter.getPositionForOffset( - Offset(line.left + line.width / 2, (top + bottom) / 2), - ); - // Query inside the line: x=0 can resolve to the previous line's end - // on Skia, even when the y coordinate belongs to the following line. - final boundary = painter - .getLineBoundary( - TextPosition( - offset: position.offset, - affinity: TextAffinity.downstream, - ), - ) - .start; - if (boundary > start) { - fragments.add( - EpubPageFragment(block, start, boundary, segmentHeight), + try { + final lines = painter.computeLineMetrics(); + final text = span.toPlainText(); + var start = 0; + var segmentHeight = 0.0; + for (var lineIndex = 0; lineIndex < lines.length; lineIndex++) { + final line = lines[lineIndex]; + final top = line.baseline - line.ascent; + final bottom = line.baseline + line.descent; + // Ascent/descent exclude part of the line-height leading on Skia fonts. + // Count the complete line box so several paragraphs cannot overfill a + // page even when the glyph bounds are shorter than their line boxes. + final lineHeight = line.height; + if (used + segmentHeight + lineHeight + spacing > height && + (used > 0 || segmentHeight > 0)) { + final position = painter.getPositionForOffset( + Offset(line.left + line.width / 2, (top + bottom) / 2), ); - start = boundary; + // Query inside the line: x=0 can resolve to the previous line's end + // on Skia, even when the y coordinate belongs to the following line. + final boundary = painter + .getLineBoundary( + TextPosition( + offset: position.offset, + affinity: TextAffinity.downstream, + ), + ) + .start; + if (boundary > start) { + fragments.add( + EpubPageFragment(block, start, boundary, segmentHeight), + ); + start = boundary; + } + if (fragments.isNotEmpty) { + yield finish(); + if (cancelled()) return; + } + segmentHeight = 0; } - finish(); - segmentHeight = 0; + segmentHeight += lineHeight; } - segmentHeight += lineHeight; - } - if (start < text.length) { - fragments.add( - EpubPageFragment(block, start, text.length, segmentHeight), - ); - used += segmentHeight + spacing; + if (start < text.length) { + fragments.add( + EpubPageFragment(block, start, text.length, segmentHeight), + ); + used += segmentHeight + spacing; + } + } finally { + painter.dispose(); } - painter.dispose(); } if (stopwatch.elapsedMilliseconds >= 4) { - await Future.delayed(Duration.zero); + if (yieldWork == null) { + await Future.delayed(Duration.zero); + } else { + await yieldWork(); + } stopwatch.reset(); } } - finish(); - if (pages.isEmpty) pages.add(const EpubRichPage([])); - return List.unmodifiable(pages); + if (fragments.isNotEmpty) { + yield finish(); + } else if (!emitted) { + yield const EpubRichPage([]); + } } Widget buildEpubFragment( diff --git a/lib/src/engine/epub/epub_viewport.dart b/lib/src/engine/epub/epub_viewport.dart index dffcc1d..6735515 100644 --- a/lib/src/engine/epub/epub_viewport.dart +++ b/lib/src/engine/epub/epub_viewport.dart @@ -14,6 +14,7 @@ import '../../presentation/reader_highlight_interaction.dart'; import '../../presentation/reader_text_toolbar.dart'; import 'epub_reader_engine.dart'; import 'epub_rich_layout.dart'; +import 'epub_page_layout.dart'; import 'epub_content_renderer.dart'; final class EpubViewport extends StatefulWidget { @@ -34,7 +35,11 @@ final class _EpubViewportState extends State { TextScaler _scaler = TextScaler.noScaling; TextDirection _direction = TextDirection.ltr; int _fontRevision = 0; - final Map> _layoutCache = {}; + final Map _layoutCache = {}; + List? _turnPages; + int _turnDirection = 0; + int? _turnSpine; + static const _turnDuration = Duration(milliseconds: 220); Timer? _prefetchTimer; int _prefetchGeneration = 0; ReaderPreferences? _displayPreferences; @@ -237,8 +242,8 @@ final class _EpubViewportState extends State { void initState() { super.initState(); widget.engine.addListener(_changed); - widget.engine.moveWithinChapter = _move; - widget.engine.prepareChapter = _prepareChapter; + widget.engine.moveViewport = _move; + widget.engine.prepareLocation = _prepareLocation; _scrollController.addListener(_scrollChanged); PaintingBinding.instance.systemFonts.addListener(_fontsChanged); } @@ -256,13 +261,13 @@ final class _EpubViewportState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.engine != widget.engine) { oldWidget.engine.removeListener(_changed); - oldWidget.engine.moveWithinChapter = null; - oldWidget.engine.prepareChapter = null; - _layoutCache.clear(); + oldWidget.engine.moveViewport = null; + oldWidget.engine.prepareLocation = null; + _clearLayouts(); _pages = const []; widget.engine.addListener(_changed); - widget.engine.moveWithinChapter = _move; - widget.engine.prepareChapter = _prepareChapter; + widget.engine.moveViewport = _move; + widget.engine.prepareLocation = _prepareLocation; _layoutKey = null; } } @@ -298,7 +303,9 @@ final class _EpubViewportState extends State { if (widget.engine.preferences.layoutMode == ReaderLayoutMode.paginated) { final previous = _pagesController; _pagesController = PageController( - initialPage: _pageIndex, + initialPage: _turnPages == null + ? _pageIndex + : (_turnDirection > 0 ? 0 : 1), keepPage: false, ); WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose()); @@ -336,12 +343,36 @@ final class _EpubViewportState extends State { )..addListener(_scrollChanged); WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose()); } - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted || !identical(_pages, pages)) return; _restoring = true; if (widget.engine.preferences.layoutMode == ReaderLayoutMode.paginated) { if (_pagesController.hasClients) { - _pagesController.jumpToPage(_pageIndex); + if (_turnPages != null) { + final controller = _pagesController; + await controller.animateToPage( + _turnDirection > 0 ? 1 : 0, + duration: _turnDuration, + curve: Curves.easeOutCubic, + ); + + if (!mounted || !identical(_pagesController, controller)) return; + final replacement = PageController( + initialPage: _pageIndex, + keepPage: false, + ); + setState(() { + _turnPages = null; + _pagesController = replacement; + }); + WidgetsBinding.instance.addPostFrameCallback( + (_) => controller.dispose(), + ); + await WidgetsBinding.instance.endOfFrame; + if (!mounted || !identical(_pagesController, replacement)) return; + } else { + _pagesController.jumpToPage(_pageIndex); + } } } else if (_scrollController.hasClients && _scrollOffsets.isNotEmpty) { var pixels = _scrollOffsets[target]; @@ -401,12 +432,74 @@ final class _EpubViewportState extends State { _scrollController.jumpTo(next); return true; } - final count = (_pages.length / _columns).ceil(); final next = _pageIndex + direction; - if (next < 0 || next >= count) return false; - _pageIndex = next; - if (_pagesController.hasClients) _pagesController.jumpToPage(next); - _publishPage(next); + final layout = _layoutCache[_layoutKey]; + + if (next >= 0 && layout != null && !layout.complete) { + await layout.ensure(count: (next + 1) * _columns + 1); + if (!mounted || !identical(layout, _layoutCache[_layoutKey])) return true; + setState(() {}); + } + + final count = (_pages.length / _columns).ceil(); + + if (next < 0 || next >= count) { + final index = widget.engine.locator.spineIndex + direction; + if (index < 0 || index >= widget.engine.chapterCount) return false; + final animate = + !(MediaQuery.maybeOf(context)?.disableAnimations ?? false); + final from = _pageIndex * _columns; + final outgoing = _pages.sublist( + from, + math.min(from + _columns, _pages.length), + ); + final spine = widget.engine.locator.spineIndex; + + // Prepare first, keeping the committed page interactive while work yields. + await widget.engine.goTo( + EpubReaderLocator( + spineIndex: index, + localProgression: direction < 0 ? 1 : 0, + totalProgression: widget.engine.locator.totalProgression, + ), + ); + if (!mounted) return true; + _turnPages = animate ? outgoing : null; + _turnDirection = direction; + _turnSpine = spine; + await WidgetsBinding.instance.endOfFrame; + await _layoutReady?.future; + return true; + } + + final controller = _pagesController; + + if (!controller.hasClients) { + return true; + } + + if (MediaQuery.maybeOf(context)?.disableAnimations ?? false) { + controller.jumpToPage(next); + } else { + await controller.animateToPage( + next, + duration: _turnDuration, + curve: Curves.easeOutCubic, + ); + } + + // Touch input can complete the animation future before its target is reached. + // Subsequent drag/settling updates continue through onPageChanged. + if (mounted && + identical(controller, _pagesController) && + controller.hasClients) { + final page = controller.page; + + if (page != null) { + _publishPage(page.round()); + } + } + return true; } @@ -419,17 +512,34 @@ final class _EpubViewportState extends State { final page = (index * _columns).clamp(0, _pages.length - 1); final after = math.min(page + _columns, _pages.length); final spreads = (_pages.length / _columns).ceil(); + final layout = _layoutCache[_layoutKey]; + final complete = layout?.complete ?? true; + final local = complete + ? (spreads <= 1 ? 0.0 : index / (spreads - 1)) + : _pages[page].offset / math.max(1, widget.engine.contentLength); + final int coverageEnd; + if (after < _pages.length) { + coverageEnd = _pages[after].offset; + } else if (complete) { + coverageEnd = widget.engine.contentLength; + } else { + final fragment = _pages.last.fragments.last; + coverageEnd = fragment.block.offset + fragment.end; + } + widget.engine.viewportChanged( - spreads <= 1 ? 0 : index / (spreads - 1), + local, textOffset: restoredOffset ?? _pages[page].offset, pageNumber: page + 1, - pageCount: _pages.length, + pageCount: + layout?.estimatedPageCount(widget.engine.contentLength) ?? + _pages.length, + pageCountEstimated: layout != null && layout.pageCount == null, coverageStart: _pages[page].offset, - coverageEnd: after < _pages.length - ? _pages[after].offset - : widget.engine.contentLength, - atEnd: after == _pages.length, + coverageEnd: coverageEnd, + atEnd: complete && after == _pages.length, ); + _schedulePrefetch(); } void _scrollChanged() { @@ -477,7 +587,7 @@ final class _EpubViewportState extends State { ); } - void _beyondChapter(int direction) { + void _requestTurn(int direction) { final request = ReaderInteractionScope.maybeOf(context)?.onTurn; if (request != null) { @@ -499,89 +609,163 @@ final class _EpubViewportState extends State { ); } + void _clearLayouts() { + for (final layout in _layoutCache.values) { + layout.dispose(); + } + _layoutCache.clear(); + _turnPages = null; + } + Future> _measure( int index, List blocks, { + EpubReaderLocator? target, + bool toEnd = false, int? prefetch, }) async { final geometry = _geometryKey; final key = (index, geometry); - final cached = _layoutCache[key]; + final layout = _layoutCache.putIfAbsent(key, () { + final contentSize = _contentSize; + final preferences = widget.engine.preferences; + final scaler = _scaler; + final direction = _direction; + late final EpubPageLayout cursor; + Stream paginate() => paginateEpub( + blocks, + contentSize: contentSize, + preferences: preferences, + textScaler: scaler, + direction: direction, + cancelled: () => !mounted || geometry != _geometryKey, + yieldWork: () => cursor.yieldWork(), + ); + cursor = EpubPageLayout(paginate(), countSource: paginate); + return cursor; + }); - if (cached != null) { - return cached; + while (_layoutCache.length > 3) { + final oldest = _layoutCache.keys.firstWhere( + (candidate) => candidate != _layoutKey && candidate != key, + ); + _layoutCache.remove(oldest)!.dispose(); } - bool cancelled() => - !mounted || - geometry != _geometryKey || - (prefetch != null && prefetch != _prefetchGeneration); - final pages = await layoutEpubChapter( - blocks, - contentSize: _contentSize, - preferences: widget.engine.preferences, - textScaler: _scaler, - direction: _direction, - cancelled: cancelled, - ); - - if (!cancelled() && pages.isNotEmpty) { - _layoutCache[key] = pages; - - while (_layoutCache.length > 3) { - _layoutCache.remove(_layoutCache.keys.first); + try { + await layout.ensure( + count: _columns * 3 + 1, + offset: target?.textOffset, + toEnd: + toEnd || + widget.engine.preferences.layoutMode == ReaderLayoutMode.scroll || + (target != null && + target.textOffset == null && + target.localProgression > 0), + cancelled: () => prefetch != null && prefetch != _prefetchGeneration, + ); + final offset = target?.textOffset; + if (offset != null && !layout.complete) { + final targetPage = layout.pages.lastIndexWhere( + (page) => page.offset <= offset, + ); + await layout.ensure( + count: ((targetPage ~/ _columns) + 2) * _columns + 1, + ); } + } catch (_) { + if (identical(_layoutCache[key], layout)) _layoutCache.remove(key); + layout.dispose(); + rethrow; } - return pages; + return layout.pages; } - Future _prepareChapter(int index, List blocks) async { + Future _prepareLocation( + EpubReaderLocator target, + List blocks, + ) async { if (widget.engine.renderer != null && widget.engine.preferences.layoutMode == ReaderLayoutMode.scroll) { return; } - _prefetchGeneration++; - _prefetchTimer?.cancel(); - // A resize can supersede measurement while navigation is waiting. Object? geometry; do { geometry = _geometryKey; - await _measure(index, blocks); + await _measure(target.spineIndex, blocks, target: target); } while (mounted && geometry != _geometryKey); } + void _publishPageCount(EpubPageLayout layout) { + if (_busy || + _restoring || + widget.engine.preferences.layoutMode != ReaderLayoutMode.paginated || + !identical(layout, _layoutCache[_layoutKey])) { + return; + } + + widget.engine.viewportPaginationChanged( + layout.estimatedPageCount(widget.engine.contentLength), + _pageIndex * _columns + 1, + estimated: layout.pageCount == null, + ); + } + void _schedulePrefetch() { _prefetchTimer?.cancel(); final generation = ++_prefetchGeneration; final index = widget.engine.locator.spineIndex; - _prefetchTimer = Timer(const Duration(milliseconds: 150), () async { - for (final neighbor in [index + 1, index - 1]) { - if (!mounted || generation != _prefetchGeneration) { - return; - } - - if (neighbor < 0 || neighbor >= widget.engine.chapterCount) { - continue; + _prefetchTimer = Timer(Duration.zero, () async { + try { + final current = _layoutCache[_layoutKey]; + if (current != null) { + final previousCount = current.pages.length; + final wasComplete = current.complete; + await current.ensure( + count: (_pageIndex + 4) * _columns + 1, + cancelled: () => generation != _prefetchGeneration, + ); + if (!mounted || generation != _prefetchGeneration) return; + if (previousCount != current.pages.length || + wasComplete != current.complete) { + setState(() {}); + } + _publishPageCount(current); } - try { - final chapter = await widget.engine.chapterContent(neighbor); - - if (!mounted || generation != _prefetchGeneration) { - return; + for (final neighbor in [index + 1, index - 1]) { + if (!mounted || generation != _prefetchGeneration) return; + if (neighbor < 0 || neighbor >= widget.engine.chapterCount) continue; + try { + final chapter = await widget.engine.chapterContent(neighbor); + if (!mounted || generation != _prefetchGeneration) return; + await _measure( + neighbor, + EpubContentBlocks(chapter['blocks'] as List), + toEnd: neighbor < index, + prefetch: generation, + ); + } catch (_) { + // An unreadable neighbor must not prevent counting the current pages. } + } - await _measure(neighbor, [ - for (final block in chapter['blocks'] as List) - EpubContentBlock(Map.from(block as Map)), - ], prefetch: generation); - } catch (_) { - // Speculative work must not interrupt the readable current page. - // Requested navigation will surface a chapter error with retry. + final geometry = _geometryKey; + for (final item in [index, index + 1]) { + if (!mounted || generation != _prefetchGeneration) return; + final layout = _layoutCache[(item, geometry)]; + if (layout == null) continue; + await layout.countPages( + cancelled: () => generation != _prefetchGeneration, + ); + if (!mounted || generation != _prefetchGeneration) return; + _publishPageCount(layout); } + } catch (_) { + // Foreground navigation surfaces errors. Speculation preserves the page. } }); } @@ -590,12 +774,13 @@ final class _EpubViewportState extends State { void dispose() { _prefetchGeneration++; _prefetchTimer?.cancel(); + _clearLayouts(); _loadingTimer?.cancel(); if (_layoutReady?.isCompleted == false) _layoutReady!.complete(); widget.engine.removeListener(_changed); PaintingBinding.instance.systemFonts.removeListener(_fontsChanged); - widget.engine.moveWithinChapter = null; - widget.engine.prepareChapter = null; + widget.engine.moveViewport = null; + widget.engine.prepareLocation = null; _selectionMenuController.remove(); _pagesController.dispose(); _scrollController.dispose(); @@ -667,7 +852,7 @@ final class _EpubViewportState extends State { _contentSize = contentSize; _scaler = scaler; _direction = direction; - _layoutCache.clear(); + _clearLayouts(); _prefetchGeneration++; } @@ -701,8 +886,9 @@ final class _EpubViewportState extends State { ? _measure( widget.engine.locator.spineIndex, widget.engine.blocks, + target: widget.engine.locator, ) - : SynchronousFuture(cached); + : SynchronousFuture(cached.pages); } return Center( child: SizedBox( @@ -720,7 +906,7 @@ final class _EpubViewportState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - 'Could not lay out this chapter.', + 'Could not prepare these pages.', style: TextStyle( color: preferences.foregroundColor, ), @@ -806,25 +992,32 @@ final class _EpubViewportState extends State { ), ); } else { + final outgoing = _turnPages; content = PageView.builder( key: ValueKey(_pagesController), controller: _pagesController, onPageChanged: _publishPage, - itemCount: (pages.length / columns).ceil(), - itemBuilder: (context, index) => Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var column = 0; column < columns; column++) - Expanded( - child: index * columns + column < pages.length - ? _page( - pages[index * columns + column], - displayPreferences, - ) - : const SizedBox.shrink(), - ), - ], - ), + itemCount: outgoing == null + ? (pages.length / columns).ceil() + : 2, + itemBuilder: (context, index) { + if (outgoing != null) { + final oldPage = index == (_turnDirection > 0 ? 0 : 1); + return _spread( + oldPage ? outgoing : pages, + oldPage ? 0 : _pageIndex, + columns, + displayPreferences, + spine: oldPage ? _turnSpine : null, + ); + } + return _spread( + pages, + index, + columns, + displayPreferences, + ); + }, ); } final surface = ReaderHighlightInteraction( @@ -862,21 +1055,21 @@ final class _EpubViewportState extends State { if (drag < 0 && _startScroll >= position.maxScrollExtent - 1) { - _beyondChapter(1); + _requestTurn(1); } if (drag > 0 && _startScroll <= 1) { - _beyondChapter(-1); + _requestTurn(-1); } } else { if (drag < 0 && _startPage == (pages.length / columns).ceil() - 1) { - _beyondChapter(1); + _requestTurn(1); } if (drag > 0 && _startPage == 0) { - _beyondChapter(-1); + _requestTurn(-1); } } }, @@ -914,7 +1107,10 @@ final class _EpubViewportState extends State { return Stack( fit: StackFit.expand, children: [ - IgnorePointer(ignoring: !ready, child: surface), + IgnorePointer( + ignoring: !ready || _turnPages != null, + child: surface, + ), if (!ready && _showLoading && !snapshot.hasError) const PositionedDirectional( top: 8, @@ -946,6 +1142,28 @@ final class _EpubViewportState extends State { ); } + Widget _spread( + List pages, + int index, + int columns, + ReaderPreferences preferences, { + int? spine, + }) => Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var column = 0; column < columns; column++) + Expanded( + child: index * columns + column < pages.length + ? _page( + pages[index * columns + column], + preferences, + spine: spine, + ) + : const SizedBox.shrink(), + ), + ], + ); + double _pageHeight(EpubRichPage page, ReaderPreferences preferences) => preferences.pageMargins.vertical + page.fragments.fold( @@ -958,12 +1176,13 @@ final class _EpubViewportState extends State { EpubRichPage page, ReaderPreferences preferences, { bool scroll = false, + int? spine, }) { final highlights = ReaderInteractionScope.maybeOf(context)?.highlights.where((highlight) { final start = highlight.selection.start; return start is EpubReaderLocator && - start.spineIndex == widget.engine.locator.spineIndex; + start.spineIndex == (spine ?? widget.engine.locator.spineIndex); }).toList() ?? const []; diff --git a/test/engine/epub_page_layout_test.dart b/test/engine/epub_page_layout_test.dart new file mode 100644 index 0000000..52e75c3 --- /dev/null +++ b/test/engine/epub_page_layout_test.dart @@ -0,0 +1,155 @@ +import 'dart:collection'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/engine/epub/epub_page_layout.dart'; +import 'package:papyrus_reader/src/engine/epub/epub_rich_layout.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('a huge unchaptered stream measures only the requested pages', () async { + final source = _CountingBlocks(10000); + final layout = EpubPageLayout( + paginateEpub( + EpubContentBlocks(source), + contentSize: const Size(280, 60), + preferences: const ReaderPreferences(), + textScaler: TextScaler.noScaling, + direction: TextDirection.ltr, + cancelled: () => false, + ), + ); + addTearDown(layout.dispose); + await layout.ensure(count: 4); + expect(layout.pages, hasLength(4)); + expect(layout.complete, isFalse); + expect(source.reads, lessThan(12)); + final first = layout.pages.first; + + await Future.wait([layout.ensure(count: 8), layout.ensure(count: 10)]); + expect(layout.pages, hasLength(10)); + expect(identical(first, layout.pages.first), isTrue); + expect(layout.pages.map((page) => page.offset).toSet(), hasLength(10)); + expect(source.reads, lessThan(30)); + + await layout.ensure(offset: 4000); + expect(layout.pages.last.offset, greaterThan(4000)); + expect(source.reads, lessThan(100)); + }); + + test( + 'background totals do not expand or consume the reading window', + () async { + Stream source() async* { + for (var index = 0; index < 100; index++) { + final block = EpubContentBlock({ + 'kind': 'p', + 'offset': index * 100, + 'length': 90, + 'runs': [ + {'text': 'A page'}, + ], + }); + yield EpubRichPage([EpubPageFragment(block, 0, 90, 30)]); + } + } + + final layout = EpubPageLayout(source(), countSource: source); + addTearDown(layout.dispose); + await layout.ensure(count: 4); + expect(layout.pageCount, isNull); + expect(layout.estimatedPageCount(10000), 100); + await layout.countPages(); + expect(layout.pageCount, 100); + expect(layout.pages, hasLength(4)); + expect(layout.complete, isFalse); + await layout.ensure(count: 5); + expect(layout.pages.last.offset, 400); + expect(layout.pageCount, 100); + }, + ); + + test('a failed background count cannot publish a truncated total', () async { + Stream source() async* { + yield const EpubRichPage([]); + throw StateError('Unreadable content'); + } + + final layout = EpubPageLayout(source(), countSource: source); + addTearDown(layout.dispose); + await layout.ensure(count: 1); + await expectLater(layout.countPages(), throwsStateError); + await layout.countPages(); + expect(layout.pageCount, isNull); + }); + + testWidgets('disposing a cursor cancels pending background yields', ( + tester, + ) async { + final layout = EpubPageLayout(const Stream.empty()); + final waiting = layout.yieldWork(); + layout.dispose(); + await waiting; + }); + + test('a layout failure must not become a false end of the book', () async { + Stream source() async* { + yield const EpubRichPage([]); + throw StateError('Unreadable content'); + } + + final layout = EpubPageLayout(source()); + addTearDown(layout.dispose); + await layout.ensure(count: 1); + await expectLater(layout.ensure(count: 2), throwsStateError); + await expectLater(layout.ensure(count: 2), throwsStateError); + expect(layout.complete, isFalse); + expect(layout.pages, hasLength(1)); + }); + + test('cancelling speculation does not discard a foreground cursor', () async { + final layout = EpubPageLayout( + Stream.fromIterable([ + for (var i = 0; i < 100; i++) const EpubRichPage([]), + ]), + ); + addTearDown(layout.dispose); + await layout.ensure(count: 3); + await layout.ensure(toEnd: true, cancelled: () => true); + expect(layout.pages, hasLength(3)); + await layout.ensure(count: 5); + expect(layout.pages, hasLength(5)); + layout.dispose(); + await layout.ensure(toEnd: true); + expect(layout.pages, hasLength(5)); + }); +} + +final class _CountingBlocks extends ListBase { + _CountingBlocks(this.length); + @override + final int length; + int reads = 0; + + @override + Object? operator [](int index) { + reads++; + final text = 'Passage $index continues without any chapter break. '; + return { + 'kind': 'p', + 'offset': index * 100, + 'length': text.length, + 'runs': [ + {'text': text}, + ], + }; + } + + @override + set length(int value) => throw UnsupportedError('Read-only'); + @override + void operator []=(int index, Object? value) => + throw UnsupportedError('Read-only'); +} diff --git a/test/engine/epub_page_loading_test.dart b/test/engine/epub_page_loading_test.dart new file mode 100644 index 0000000..b3c37ea --- /dev/null +++ b/test/engine/epub_page_loading_test.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/engine/epub/worker/epub_worker.dart'; + +import '../support/in_process_epub_worker.dart'; +import '../support/synthetic_epub.dart'; + +void main() { + test( + 'navigation shares an in-flight prefetch and reuses the initial content', + () async { + final worker = _GatedWorker(); + final engine = EpubReaderEngine(workerFactory: () async => worker); + addTearDown(engine.dispose); + await engine.load( + ReaderDocument( + id: 'shared', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(), + ), + preferences: const ReaderPreferences(), + ); + await engine.chapterContent(0); + expect(worker.reads[0], 1); + + final prefetch = engine.chapterContent(1); + final turn = engine.goNext(); + await Future.delayed(Duration.zero); + expect(worker.reads[1], 1); + expect(engine.locator.spineIndex, 0); + worker.gate.complete(); + await Future.wait([prefetch, turn]); + expect(engine.locator.spineIndex, 1); + expect(worker.reads[1], 1); + }, + ); +} + +final class _GatedWorker implements EpubWorker { + final delegate = InProcessEpubWorker(); + final gate = Completer(); + final reads = {}; + + @override + Future> request(String command, Object? argument) async { + if (command == 'chapter') { + final index = argument as int; + reads.update(index, (count) => count + 1, ifAbsent: () => 1); + if (index == 1) await gate.future; + } + return delegate.request(command, argument); + } + + @override + void dispose() => delegate.dispose(); +} diff --git a/test/presentation/epub_page_turn_test.dart b/test/presentation/epub_page_turn_test.dart new file mode 100644 index 0000000..6266aad --- /dev/null +++ b/test/presentation/epub_page_turn_test.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +import '../support/in_process_epub_worker.dart'; +import '../support/synthetic_epub.dart'; + +void main() { + for (final reducedMotion in [false, true]) { + testWidgets( + 'boundary turns preserve the committed location until visible, reduced motion $reducedMotion', + (tester) async { + final engine = testEpubEngine(); + await engine.load( + ReaderDocument( + id: 'boundaries', + format: ReaderFormat.epub, + loadBytes: () async => + syntheticEpub(firstChapterBody: '

First page.

'), + ), + preferences: const ReaderPreferences(), + ); + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reducedMotion), + child: Builder(builder: engine.buildViewport), + ), + ), + ); + await tester.pumpAndSettle(); + final turn = engine.goNext(); + await tester.pump(); + await tester.pump(); + for (var frame = 0; frame < 6; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + if (!reducedMotion) { + final controller = tester + .widget(find.byType(PageView)) + .controller!; + expect(controller.page, greaterThan(0)); + expect(controller.page, lessThan(1)); + expect((engine.snapshot.locator as EpubReaderLocator).spineIndex, 0); + } + + await pumpReaderCommand(tester, turn); + expect((engine.snapshot.locator as EpubReaderLocator).spineIndex, 1); + expect(engine.snapshot.contentReady, isTrue); + final previous = engine.goPrevious(); + await tester.pump(); + await tester.pump(); + for (var frame = 0; frame < 6; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + if (!reducedMotion) { + final controller = tester + .widget(find.byType(PageView)) + .controller!; + expect(controller.page, greaterThan(0)); + expect(controller.page, lessThan(1)); + expect((engine.snapshot.locator as EpubReaderLocator).spineIndex, 1); + } + + await pumpReaderCommand(tester, previous); + expect((engine.snapshot.locator as EpubReaderLocator).spineIndex, 0); + await pumpReaderCommand( + tester, + Future.wait([engine.goNext(), engine.goNext(), engine.goPrevious()]), + ); + expect((engine.snapshot.locator as EpubReaderLocator).spineIndex, 1); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final direction in [-1, 1]) { + testWidgets( + 'interrupted page animation records the visible page (direction $direction)', + (tester) async { + final engine = testEpubEngine(); + await engine.load( + ReaderDocument( + id: 'interrupted-turn', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(longChapter: true), + ), + preferences: const ReaderPreferences(), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + await tester.pumpAndSettle(); + await pumpReaderCommand(tester, engine.goNext()); + final controller = tester + .widget(find.byType(PageView)) + .controller!; + expect(controller.page, 1); + final before = engine.snapshot; + final turn = direction > 0 ? engine.goNext() : engine.goPrevious(); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + expect(controller.page, isNot(1)); + expect((controller.page! - 1).abs(), lessThan(.5)); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(PageView)), + ); + await gesture.moveBy(Offset(40.0 * direction, 0)); + await tester.pump(const Duration(milliseconds: 200)); + await gesture.up(); + await pumpReaderCommand(tester, turn); + expect(controller.page, 1); + expect(engine.snapshot.locator, before.locator); + expect(engine.snapshot.locationLabel, before.locationLabel); + expect( + engine.snapshot.coverage.single.start, + before.coverage.single.start, + ); + expect(engine.snapshot.coverage.single.end, before.coverage.single.end); + await pumpReaderCommand(tester, engine.goNext()); + expect( + controller.page, + 2, + reason: 'The next command must not skip a page', + ); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('queued turns continue visually into a multipage destination', ( + tester, + ) async { + final engine = testEpubEngine(); + await engine.load( + ReaderDocument( + id: 'queued', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub( + firstChapterBody: '

Start.

', + secondChapterBody: List.filled( + 100, + '

A longer passage fills the following pages with continuous text.

', + ).join(), + ), + ), + preferences: const ReaderPreferences(), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + await tester.pumpAndSettle(); + await pumpReaderCommand( + tester, + Future.wait([engine.goNext(), engine.goNext(), engine.goNext()]), + ); + expect(engine.locator.spineIndex, 1); + expect(engine.locator.textOffset, greaterThan(0)); + expect(tester.widget(find.byType(PageView)).controller!.page, 2); + expect(tester.takeException(), isNull); + }); + + testWidgets('page counts stay present and settle without moving the reader', ( + tester, + ) async { + final engine = testEpubEngine(); + final labels = []; + engine.addListener(() { + if (engine.snapshot.contentReady && + engine.snapshot.locationLabel != null) { + labels.add(engine.snapshot.locationLabel!); + } + }); + await engine.load( + ReaderDocument( + id: 'page-counts', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(longChapter: true), + ), + preferences: const ReaderPreferences(), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + await tester.pumpAndSettle(); + final location = engine.locator; + for ( + var frame = 0; + frame < 100 && engine.snapshot.locationLabel!.contains('≈'); + frame++ + ) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect( + engine.snapshot.locationLabel, + matches(r'Chapter 1 of 3 · Page 1 of [0-9]+$'), + ); + expect(engine.locator, location); + expect(labels, isNotEmpty); + expect( + labels.every( + (label) => RegExp(r' · Page [0-9]+ of ≈?[0-9]+$').hasMatch(label), + ), + isTrue, + ); + await pumpReaderCommand( + tester, + engine.goTo( + EpubReaderLocator( + spineIndex: 1, + localProgression: 0, + totalProgression: 0, + ), + ), + ); + expect( + engine.snapshot.locationLabel, + matches(r'Chapter 2 of 3 · Page 1 of ≈?[0-9]+$'), + ); + await engine.updatePreferences(const ReaderPreferences(fontSize: 28)); + await tester.pumpAndSettle(); + expect( + engine.snapshot.locationLabel, + matches(r'Chapter 2 of 3 · Page 1 of ≈?[0-9]+$'), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'a single long spine item reads ahead by pages and restores distant offsets', + (tester) async { + final engine = testEpubEngine(); + await engine.load( + ReaderDocument( + id: 'unchaptered', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub( + singleSpine: true, + firstChapterBody: List.generate( + 2000, + (index) => + '

Passage $index. A continuous book with no chapter divisions keeps turning smoothly.

', + ).join(), + ), + ), + preferences: const ReaderPreferences(), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + await tester.pumpAndSettle(); + expect(engine.chapterCount, 1); + expect(engine.snapshot.contentReady, isTrue); + expect(engine.snapshot.atEnd, isFalse); + expect( + tester + .widget(find.byType(PageView)) + .childrenDelegate + .estimatedChildCount, + lessThanOrEqualTo(5), + ); + + for (var i = 0; i < 8; i++) { + await pumpReaderCommand(tester, engine.goNext()); + } + + expect(engine.locator.spineIndex, 0); + expect(engine.locator.textOffset, greaterThan(0)); + expect(engine.snapshot.atEnd, isFalse); + await pumpReaderCommand( + tester, + engine.goTo( + EpubReaderLocator( + spineIndex: 0, + textOffset: 60000, + localProgression: 0, + totalProgression: 0, + ), + ), + ); + expect(engine.locator.textOffset, 60000); + final page = tester + .widget(find.byType(PageView)) + .controller! + .page!; + expect(page, greaterThan(8)); + await engine.updatePreferences(const ReaderPreferences(fontSize: 22)); + await tester.pumpAndSettle(); + expect(engine.locator.textOffset, 60000); + await pumpReaderCommand(tester, engine.goToProgress(1)); + expect(engine.snapshot.atEnd, isTrue); + await pumpReaderCommand(tester, engine.goPrevious()); + expect(engine.snapshot.atEnd, isFalse); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/support/synthetic_epub.dart b/test/support/synthetic_epub.dart index 3adc89b..2517e1d 100644 --- a/test/support/synthetic_epub.dart +++ b/test/support/synthetic_epub.dart @@ -5,6 +5,7 @@ Uint8List syntheticEpub({ bool fixedLayout = false, bool malicious = false, bool longChapter = false, + bool singleSpine = false, String? firstChapterBody, String? secondChapterBody, bool reversedToc = false, @@ -81,8 +82,7 @@ Uint8List syntheticEpub({ - - + ${singleSpine ? '' : ''} '''), 'OEBPS/toc.ncx': utf8.encode(''' @@ -91,6 +91,7 @@ Uint8List syntheticEpub({ Synthetic Book +${singleSpine ? '' : ''' Chapter One @@ -103,6 +104,7 @@ Uint8List syntheticEpub({ Chapter Two +'''} '''), 'OEBPS/text/chapter1.xhtml': utf8.encode(