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
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Many reading applications offer partial solutions but fall short on essential fe

| Category | Features |
|----------|----------|
| **Reading** | EPUB/PDF reader with pagination/scrolling, appearance settings and saved positions |
| **Reading** | EPUB, PDF, MOBI, AZW3, TXT, CBZ and CBR reader with pagination/scrolling and saved positions |
| **Organization** | Shelves, tags/topics and library filters |
| **Annotations** | Book-level bookmarks, notes and annotations; reader text selection/export is future work |
| **Progress** | Saved progress, reading sessions and statistics |
Expand All @@ -46,10 +46,15 @@ Many reading applications offer partial solutions but fall short on essential fe
| **Catalogs** | OPDS 1.2/2.0 browsing, search and imports through the backend relay |
| **Accessibility** | E-ink, light/dark themes and reader typography controls |

The built-in reader opens EPUB and PDF. Native metadata import also accepts MOBI,
AZW3, TXT, CBR and CBZ; these formats do not have reading engines yet. The web
file picker accepts EPUB and PDF. Additional cloud storage providers and
selection-based reader annotations remain future work.
The built-in reader and native/web file pickers accept EPUB, PDF, MOBI, AZW3,
TXT, CBR and CBZ. Text and Kindle books support reflow and typography controls;
comic archives support page navigation, zoom and scrolling. Files must be
DRM-free; encrypted archives and fixed-layout EPUBs are unsupported. Web imports
of TXT, Kindle and comic files initially use the filename as the library title.

Book parsing runs in native isolates or browser workers. Integrity checks use a
native isolate or Web Crypto, and local book reads are asynchronous. Original
book files remain unchanged; the client persists the reader's saved position.

## Supported platforms

Expand Down
1 change: 1 addition & 0 deletions app/lib/media/book_file_hash.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'book_file_hash_native.dart' if (dart.library.js_interop) 'book_file_hash_web.dart';
6 changes: 6 additions & 0 deletions app/lib/media/book_file_hash_native.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';

Future<String> hashBookFile(Uint8List bytes) => compute(_hash, bytes, debugLabel: 'book-file-integrity');

String _hash(Uint8List bytes) => sha256.convert(bytes).toString();
9 changes: 9 additions & 0 deletions app/lib/media/book_file_hash_web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import 'dart:js_interop';
import 'dart:typed_data';

import 'package:web/web.dart' as web;

Future<String> hashBookFile(Uint8List bytes) async {
final digest = await web.window.crypto.subtle.digest('SHA-256'.toJS, bytes.toJS).toDart;
return (digest as JSArrayBuffer).toDart.asUint8List().map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
13 changes: 9 additions & 4 deletions app/lib/media/media_cache_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import 'package:crypto/crypto.dart';
import 'package:papyrus/media/media_storage_scope.dart';
import 'package:papyrus/models/book.dart';

import 'book_file_hash.dart';

typedef LocalBookFileReader = Future<Uint8List?> Function(String bookId);
typedef LocalBookFileWriter = Future<void> Function(String bookId, String extension, Uint8List bytes);
typedef MediaDownloader = Future<Uint8List> Function(String assetId);
Expand All @@ -12,6 +14,9 @@ typedef LocalCoverWriter = Future<void> Function(MediaStorageScope scope, String

/// Coordinates lazy download and platform-local caching for private media.
class MediaCacheService {
MediaCacheService({Future<String> Function(Uint8List) hashFile = hashBookFile}) : _hashFile = hashFile;

final Future<String> Function(Uint8List) _hashFile;
final Map<String, Future<Uint8List>> _coverDownloads = {};

/// Returns a cached book file when present and, if the book has a stored
Expand All @@ -23,7 +28,7 @@ class MediaCacheService {
return null;
}

return _matchesExpectedHash(cached, book.fileHash) ? cached : null;
return await _matchesExpectedHash(cached, book.fileHash) ? cached : null;
}

/// Returns local book bytes, downloading and caching private server media
Expand All @@ -48,7 +53,7 @@ class MediaCacheService {

final downloaded = await downloadMedia(mediaId);

if (!_matchesExpectedHash(downloaded, book.fileHash)) {
if (!await _matchesExpectedHash(downloaded, book.fileHash)) {
throw StateError('Downloaded book file did not match the expected hash.');
}

Expand Down Expand Up @@ -115,12 +120,12 @@ class MediaCacheService {

String sha256Hex(Uint8List bytes) => sha256.convert(bytes).toString();

bool _matchesExpectedHash(Uint8List bytes, String? expectedHash) {
Future<bool> _matchesExpectedHash(Uint8List bytes, String? expectedHash) async {
if (expectedHash == null || expectedHash.isEmpty) {
return true;
}

return sha256Hex(bytes) == expectedHash;
return await _hashFile(bytes) == expectedHash;
}

String _extensionFor(Book book) {
Expand Down
20 changes: 17 additions & 3 deletions app/lib/pages/reader_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ class _ReaderPageState extends State<ReaderPage> {
if (book == null || document == null || preferences == null) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: const SafeArea(child: _ReaderLoading()),
body: SafeArea(child: _ReaderLoading(onBack: _close)),
);
}

Expand Down Expand Up @@ -398,10 +398,24 @@ class _ReaderPageState extends State<ReaderPage> {
}

class _ReaderLoading extends StatelessWidget {
const _ReaderLoading();
const _ReaderLoading({this.onBack});

final VoidCallback? onBack;

@override
Widget build(BuildContext context) {
return const Center(child: AppCircularProgressIndicator(semanticsLabel: 'Opening book'));
const indicator = Center(child: AppCircularProgressIndicator(semanticsLabel: 'Opening book'));

if (onBack == null) {
return indicator;
}

return Stack(
fit: StackFit.expand,
children: [
indicator,
PositionedDirectional(top: 0, start: 0, child: BackButton(onPressed: onBack)),
],
);
}
}
15 changes: 10 additions & 5 deletions app/lib/reader/reader_book_adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ final class ReaderBookAdapter {
return switch (format) {
BookFormat.epub => ReaderFormat.epub,
BookFormat.pdf => ReaderFormat.pdf,
BookFormat.mobi => ReaderFormat.mobi,
BookFormat.azw3 => ReaderFormat.azw3,
BookFormat.txt => ReaderFormat.txt,
BookFormat.cbr => ReaderFormat.cbr,
BookFormat.cbz => ReaderFormat.cbz,
_ => null,
};
}
Expand All @@ -29,11 +34,11 @@ final class ReaderBookAdapter {
final locator = ReaderLocator.fromJson(stored.map((key, value) => MapEntry(key.toString(), value)));
final format = formatFor(book.fileFormat);

if (format == ReaderFormat.epub && locator is! EpubReaderLocator) {
if (format?.isReflowable == true && locator is! EpubReaderLocator) {
return null;
}

if (format == ReaderFormat.pdf && locator is! PdfReaderLocator) {
if (format?.isReflowable == false && locator is! PdfReaderLocator) {
return null;
}

Expand All @@ -47,7 +52,7 @@ final class ReaderBookAdapter {
final format = formatFor(book.fileFormat);

if (anchor == null) {
if (format == ReaderFormat.pdf && pageNumber != null && pageNumber > 0) {
if (format?.isReflowable == false && pageNumber != null && pageNumber > 0) {
return PdfReaderLocator(
pageIndex: pageNumber - 1,
pageOffset: 0,
Expand All @@ -62,8 +67,8 @@ final class ReaderBookAdapter {

final locator = ReaderLocator.fromJson(anchor);
final matchesFormat = switch (locator) {
EpubReaderLocator() => format == ReaderFormat.epub,
PdfReaderLocator() => format == ReaderFormat.pdf,
EpubReaderLocator() => format?.isReflowable == true,
PdfReaderLocator() => format?.isReflowable == false,
};

if (!matchesFormat) {
Expand Down
12 changes: 9 additions & 3 deletions app/lib/services/book_import_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ class BookImportService {

/// Processes a book file and stores it in OPFS via the web worker.
///
/// Supports EPUB and PDF files.
/// Supports all library book formats.
/// Throws [UnsupportedError] when called on non-web platforms.
/// Throws [ArgumentError] for unsupported file formats.
Future<BookImportResult> importBook(Uint8List bytes, String filename) =>
Expand All @@ -162,12 +162,18 @@ class BookImportService {

final ext = filename.toLowerCase().split('.').last;

if (ext != 'epub' && ext != 'pdf') {
throw ArgumentError('Unsupported format: $ext. Only EPUB and PDF are supported.');
if (!const ['epub', 'pdf', 'mobi', 'azw3', 'txt', 'cbr', 'cbz'].contains(ext)) {
throw ArgumentError('Unsupported format: $ext.');
}

JSObject? metadata;

if (ext != 'epub' && ext != 'pdf') {
metadata = JSObject();
metadata['title'] = p.basenameWithoutExtension(filename).toJS;
metadata['author'] = 'Unknown Author'.toJS;
}

if (ext == 'pdf') {
try {
final extracted = extractPdfMetadata(bytes);
Expand Down
4 changes: 1 addition & 3 deletions app/lib/services/book_import_service_stub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,7 @@ class BookImportService {
/// Returns null if the file does not exist.
Future<Uint8List?> getBookFile(String bookId) async {
final booksDir = await _getBooksDirectory();
final files = booksDir.listSync();

for (final entity in files) {
await for (final entity in booksDir.list()) {
if (entity is File && p.basenameWithoutExtension(entity.path) == bookId) {
return entity.readAsBytes();
}
Expand Down
2 changes: 1 addition & 1 deletion app/lib/widgets/add_book/book_import_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ typedef BookImportProcessor = Future<BookImportResult> Function(Uint8List bytes,
typedef ImportedBookFileDeleter = Future<void> Function(String bookId);
typedef ImportedBookCommitter = Future<Book> Function(BookImportResult result, String sourceFilename);

const bookImportWebExtensions = ['epub', 'pdf'];
const bookImportWebExtensions = bookImportNativeExtensions;
const bookImportNativeExtensions = ['epub', 'pdf', 'mobi', 'azw3', 'txt', 'cbr', 'cbz'];

enum BookImportPhase { selecting, processing, summary }
Expand Down
77 changes: 71 additions & 6 deletions app/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ packages:
dependency: "direct main"
description:
name: archive
sha256: de5ac590b8155e4e676088d714bd41ea620449a6adc0f7950c5073874b287ddb
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.6.0"
version: "4.0.9"
args:
dependency: transitive
description:
Expand All @@ -33,6 +33,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
barcode:
dependency: transitive
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
url: "https://pub.dev"
source: hosted
version: "2.2.9"
bidi:
dependency: transitive
description:
name: bidi
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
url: "https://pub.dev"
source: hosted
version: "2.0.13"
boolean_selector:
dependency: transitive
description:
Expand Down Expand Up @@ -475,10 +491,10 @@ packages:
dependency: "direct main"
description:
name: image
sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a"
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
version: "4.9.2"
integration_test:
dependency: "direct dev"
description: flutter
Expand Down Expand Up @@ -524,6 +540,39 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.12.0"
kindle_unpack:
dependency: transitive
description:
path: "third_party/kindle_unpack"
ref: b4aafafcb4d80afe5f140960ac91dcc251dac70e
resolved-ref: b4aafafcb4d80afe5f140960ac91dcc251dac70e
url: "https://github.com/PapyrusReader/reader.git"
source: git
version: "0.2.0"
koni_archive_core:
dependency: transitive
description:
name: koni_archive_core
sha256: f323296866d6224725839e2b8ef25dfeba5e0c2476877fa7726f4b966a4c7a84
url: "https://pub.dev"
source: hosted
version: "0.9.0"
koni_codecs:
dependency: transitive
description:
name: koni_codecs
sha256: "0a0ffa23f62f627bc50db6de79e88a6a41e019d3a21c6a455ac5ae7c1677a8bd"
url: "https://pub.dev"
source: hosted
version: "0.9.0"
koni_rar:
dependency: transitive
description:
name: koni_rar
sha256: c8f1a5b2f1b9d8f6cfd981c67e8c34298f722806f7288f06faabb40004362f56
url: "https://pub.dev"
source: hosted
version: "0.9.0"
leak_tracker:
dependency: transitive
description:
Expand Down Expand Up @@ -672,8 +721,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: a52d0e4f3b78f37234b917cec8ef11cec71a914b
resolved-ref: a52d0e4f3b78f37234b917cec8ef11cec71a914b
ref: b4aafafcb4d80afe5f140960ac91dcc251dac70e
resolved-ref: b4aafafcb4d80afe5f140960ac91dcc251dac70e
url: "https://github.com/PapyrusReader/reader.git"
source: git
version: "0.0.1"
Expand Down Expand Up @@ -741,6 +790,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
pdf:
dependency: transitive
description:
name: pdf
sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b
url: "https://pub.dev"
source: hosted
version: "3.12.0"
pdfium_dart:
dependency: transitive
description:
Expand Down Expand Up @@ -853,6 +910,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.7.0"
qr:
dependency: transitive
description:
name: qr
sha256: "2c0189dca64a3ab08dda7acd7911c89af2374f08eb4eaded3e8ef4609f3b9c8b"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
record_use:
dependency: transitive
description:
Expand Down
2 changes: 1 addition & 1 deletion app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ dependencies:
papyrus_reader:
git:
url: https://github.com/PapyrusReader/reader.git
ref: a52d0e4f3b78f37234b917cec8ef11cec71a914b
ref: b4aafafcb4d80afe5f140960ac91dcc251dac70e
epub_pro: ^5.6.0
syncfusion_flutter_pdf: ^33.2.13
dart_mobi: ^1.0.2
Expand Down
17 changes: 17 additions & 0 deletions app/test/media/media_cache_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,28 @@ import 'dart:async';
import 'dart:typed_data';

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:papyrus/media/media_cache_service.dart';
import 'package:papyrus/media/media_storage_scope.dart';
import 'package:papyrus/models/book.dart';

void main() {
test('validating a large cached book leaves the host event loop running', () async {
final service = MediaCacheService();
final bytes = Uint8List(4 * 1024 * 1024);
final book = _book(fileHash: service.sha256Hex(bytes));
var ticks = 0;
final timer = Timer.periodic(Duration.zero, (_) => ticks++);
addTearDown(timer.cancel);
final cached = await service.getValidCachedBookFile(book, readLocalBookFile: (_) async => bytes);
expect(cached, same(bytes));
// Background browser test tabs can throttle timers; their checksum path
// still runs here, while foreground browser checks measure actual frames.
if (!kIsWeb) {
expect(ticks, greaterThan(1));
}
});

late MediaCacheService service;

setUp(() {
Expand Down
Loading
Loading