halkyon/halkyon_learn_flutter/test/support/fake_pdf_engine.dart
Tizian 「ティツィアーン」 b96db731c1
Some checks failed
CI / check (push) Failing after 26s
CI / deploy (push) Has been skipped
feat: PDF sources: read, take notes and cards from pages, deadlines
PDFs live in a studyset's new Files tab: uploaded from Add material
(a file the studyset holds already asks first), each row with its
pages, cards, notes and size, how far it is read and its reading
deadline; renamed, given a deadline (a page or a chapter by a day),
marked as read, started afresh, deleted with a moment to undo.

The reader (/pdfs/:id) opens where reading left off: on wide windows
a rail of tools (search, fit, zoom, fullscreen), a page counter with
the chapter, and a sidebar; on phones a top bar, a page scrubber and
a sheet. The sidebar holds the PDF's flashcards, by page or chapter,
made from selected text or written there, each tied to its page and
tagged with its chapter; and the page's note, an ordinary note tied
to the page. Screenshots of a region become images that know their
page. Cards and screenshots lead back to their page; reading on past
a chapter with cards suggests studying it. Home shows the deadlines
running and to come, and a timeline of them.

Server: pdf_source rows (content in the payload, last writer wins)
and an append-only pdf_reading log, the furthest page derived from
it so devices never conflict. Files up to 50 MB for PDFs (20 MB for
others; a larger file that isn't a PDF is deleted once stored). A
deleted PDF takes its file's asset row along unless another PDF has
the same file. Card payload 4 and note payload 2 keep the page.

The reader draws with pdfrx (PDFium) behind PdfEngine in
Dependencies; widget tests use a fake, and
integration_test/pdf_engine_test.dart checks the real engine on a
device or the desktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-03 14:40:08 +02:00

170 lines
4.8 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
import 'package:material_ui/material_ui.dart';
/// A [PdfEngine] without PDFium, which `flutter test` can't load. A PDF's
/// bytes say what it holds: `pages:12` its page count (10 without), and
/// `chapter:<page>:<title>;` each chapter. Its viewer shows "Page N of M"
/// and does what it is asked at once; [select] plays a selection.
class FakePdfEngine() implements PdfEngine {
/// The viewers built, the last one showing.
final viewers = <FakePdfViewer>[];
/// The regions drawn, in order.
final rendered = <({int page, Rect region})>[];
/// What [PdfDocumentView.renderRegion] gives: a PNG's bytes.
Uint8List image = Uint8List.fromList(const [137, 80, 78, 71, 1, 2, 3]);
/// Whether the next PDF opened can't be read.
bool failNext = false;
/// Selects [text] in the viewer showing.
void select(String text) =>
viewers.last.controller.report(selectedText: text);
@override
Future<PdfDocumentView> open(Uint8List bytes) async {
final text = latin1.decode(bytes, allowInvalid: true);
if (failNext || !text.startsWith('%PDF-')) {
failNext = false;
throw const FormatException('Not a PDF.');
}
final pages =
int.tryParse(
RegExp(r'pages:(\d+)').firstMatch(text)?.group(1) ?? '',
) ??
10;
return _FakeDocument(this, pages, [
for (final match in RegExp(r'chapter:(\d+):([^;]+);').allMatches(text))
(title: match[2]!, page: int.parse(match[1]!)),
]);
}
}
class _FakeDocument(
final FakePdfEngine _engine,
final int _pages,
final List<PdfChapter> _chapters,
) implements PdfDocumentView {
@override
int get pageCount => _pages;
@override
Future<List<PdfChapter>> chapters() async => _chapters;
@override
Future<Uint8List?> renderRegion(int page, Rect region) async {
_engine.rendered.add((page: page, region: region));
return _engine.image;
}
final _viewers = <PdfReaderController, FakePdfViewer>{};
@override
Widget viewer(PdfReaderController controller) {
// One viewer per controller, however often the reader is built.
final viewer = _viewers.putIfAbsent(controller, () {
final viewer = FakePdfViewer(controller, _pages);
_engine.viewers.add(viewer);
return viewer;
});
return _FakeViewerWidget(viewer);
}
@override
Future<void> dispose() async {}
}
/// The fake viewer's handle: pages 1000 high, 700 wide, one below the other.
class FakePdfViewer(final PdfReaderController controller, final int pages)
implements PdfViewerHandle {
/// The texts searched for, in order.
final searched = <String>[];
@override
Future<void> goToPage(int page) async =>
controller.report(page: page.clamp(1, pages));
@override
Future<void> zoomIn() async =>
controller.report(zoom: controller.zoom * 1.25);
@override
Future<void> zoomOut() async =>
controller.report(zoom: controller.zoom / 1.25);
@override
Future<void> fitWidth() async => controller.report(zoom: 1);
@override
Future<void> fitHeight() async => controller.report(zoom: 0.5);
@override
Future<void> setZoom(double zoom) async => controller.report(zoom: zoom);
@override
void search(String text) {
searched.add(text);
controller.report(matchCount: 3, currentMatch: 0);
}
@override
Future<void> nextMatch() async => controller.report(
currentMatch: ((controller.currentMatch ?? -1) + 1) % 3,
);
@override
Future<void> previousMatch() async => controller.report(
currentMatch: ((controller.currentMatch ?? 1) + 2) % 3,
);
@override
void clearSearch() => controller.report(matchCount: 0, noMatch: true);
@override
Rect? pageRect(int page) =>
Rect.fromLTWH(0, (page - controller.page) * 1000.0, 700, 1000);
}
class const _FakeViewerWidget(final FakePdfViewer _viewer)
extends StatefulWidget {
@override
State<_FakeViewerWidget> createState() => _FakeViewerWidgetState();
}
class _FakeViewerWidgetState() extends State<_FakeViewerWidget> {
@override
void initState() {
super.initState();
widget._viewer.controller.addListener(_changed);
// As pdfrx's viewer, ready once laid out.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
widget._viewer.controller.attach(widget._viewer);
}
});
}
@override
void dispose() {
widget._viewer.controller
..removeListener(_changed)
..attach(null);
super.dispose();
}
void _changed() => setState(() {});
@override
Widget build(BuildContext context) => ColoredBox(
color: const Color(0xffdddddd),
child: Center(
child: Text(
'Page ${widget._viewer.controller.page} of ${widget._viewer.pages}',
),
),
);
}