Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,33 @@ 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;
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.

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.
Expand Down
132 changes: 132 additions & 0 deletions lib/src/engine/epub/epub_page_layout.dart
Original file line number Diff line number Diff line change
@@ -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<EpubRichPage> source, {
Stream<EpubRichPage> Function()? countSource,
}) : _iterator = StreamIterator(source),
_countSource = countSource;

final StreamIterator<EpubRichPage> _iterator;
final Stream<EpubRichPage> Function()? _countSource;
StreamIterator<EpubRichPage>? _counter;
Future<void>? _countPending;
int _countedPages = 0;
int? _exactCount;
bool _countFailed = false;
final Map<Timer, Completer<void>> _yieldTimers = {};
final List<EpubRichPage> pages = [];
bool complete = false;
bool _disposed = false;
Object? _failure;
StackTrace? _failureStack;

Future<void> yieldWork() {
if (_disposed) return Future.value();
final ready = Completer<void>();
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<void> countPages({bool Function()? cancelled}) async {
if (_countSource == null || _countFailed) return;

while (!_disposed && pageCount == null && !(cancelled?.call() ?? false)) {
await (_countPending ??= _countNext().whenComplete(
() => _countPending = null,
));
}
}

Future<void> _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<void> 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<void>? _pending;

Future<void> _advance() {
return _pending ??= _readNext().whenComplete(() => _pending = null);
}

Future<void> _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());
}
}
86 changes: 66 additions & 20 deletions lib/src/engine/epub/epub_reader_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ final class EpubReaderEngine extends ReaderEngine {
int _documentRevision = 0;
EpubProgressIndex? _progressIndex;
final Map<int, Map<String, Object?>> _chapterCache = {};
final Map<int, Future<Map<String, Object?>>> _chapterRequests = {};
ReaderReadySnapshot? _displayed;
Future<void> Function(int index, List<EpubContentBlock> blocks)?
prepareChapter;
Future<void> Function(
EpubReaderLocator target,
List<EpubContentBlock> blocks,
)?
prepareLocation;
Future<void> _turnQueue = Future<void>.value();
int _contentLength = 0;
int _restorationRevision = 0;
Expand All @@ -56,14 +60,15 @@ final class EpubReaderEngine extends ReaderEngine {
bool _disposed = false;
int _visiblePage = 1;
int _pageCount = 0;
bool _pageCountEstimated = false;
List<ReaderContentCoverage> _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<bool> 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<bool> Function(int direction)? moveViewport;

String get currentChapterHtml => _currentChapterHtml;
List<EpubContentBlock> get blocks => _blocks;
Expand Down Expand Up @@ -133,6 +138,10 @@ final class EpubReaderEngine extends ReaderEngine {
(metadata['lengths'] as List?)?.cast<int>() ?? 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)
Expand Down Expand Up @@ -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<String, Object?>.from(block as Map)),
];
_blocks = EpubContentBlocks(chapter['blocks'] as List);
_anchors = (chapter['anchors'] as Map).cast<String, int>();
_contentLength = chapter['length'] as int;
_toc = [
Expand Down Expand Up @@ -263,22 +269,32 @@ 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;
_restorationRevision++;
} else {
final chapter = await chapterContent(locator.spineIndex);
_requireCurrent(generation);
await prepareChapter?.call(locator.spineIndex, [
for (final block in chapter['blocks'] as List)
EpubContentBlock(Map<String, Object?>.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);
}
Expand Down Expand Up @@ -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<Map<String, Object?>> _readChapter(int index) async {
final worker = _worker!;
final chapter = await worker.chapter(index);

Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}
Loading
Loading