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
8 changes: 7 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions lib/src/domain/reader_activity.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
43 changes: 26 additions & 17 deletions lib/src/engine/epub/epub_reader_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
Expand All @@ -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 = <ReaderContentCoverage>[
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) {
Expand Down
20 changes: 20 additions & 0 deletions lib/src/engine/epub/epub_viewport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,13 @@ final class _EpubViewportState extends State<EpubViewport> {
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,
Comment thread
Eoic marked this conversation as resolved.
Resolved
),
],
atEnd: complete && after == _pages.length,
);
if (schedulePrefetch) {
Expand Down Expand Up @@ -608,6 +615,19 @@ final class _EpubViewportState extends State<EpubViewport> {
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,
);
}
Expand Down
7 changes: 7 additions & 0 deletions test/presentation/epub_prefetch_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ void main() {
),
preferences: const ReaderPreferences(pageTurnAnimation: false),
);
final coverage = <ReaderContentCoverage>[];
engine.addListener(() => coverage.addAll(engine.snapshot.coverage));
await tester.pumpWidget(
MaterialApp(home: Builder(builder: engine.buildViewport)),
);
Expand All @@ -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));
},
);

Expand Down
56 changes: 56 additions & 0 deletions test/presentation/epub_reading_flow_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading