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>
This commit is contained in:
parent
ec34bb2334
commit
b96db731c1
1 changed files with 34926 additions and 58 deletions
135
halkyon_learn_flutter/integration_test/pdf_engine_test.dart
Normal file
135
halkyon_learn_flutter/integration_test/pdf_engine_test.dart
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdfrx_engine.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
// The app's PDF engine for real, PDFium included, which `flutter test`
|
||||
// can't load: on a device or the desktop,
|
||||
//
|
||||
// flutter test integration_test/pdf_engine_test.dart -d linux
|
||||
//
|
||||
// on a PDF of three pages, "Hello page N" on each, with an outline of two
|
||||
// chapters.
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(PdfrxEngine.initialize);
|
||||
|
||||
testWidgets('opens a PDF, reads its chapters and draws a region', (
|
||||
tester,
|
||||
) async {
|
||||
final document = await tester.runAsync(
|
||||
() => PdfrxEngine().open(_threePages()),
|
||||
);
|
||||
addTearDown(() => tester.runAsync(document!.dispose));
|
||||
|
||||
expect(document!.pageCount, 3);
|
||||
expect(await tester.runAsync(document.chapters), [
|
||||
(title: 'One', page: 1),
|
||||
(title: 'Two', page: 3),
|
||||
]);
|
||||
final png = await tester.runAsync(
|
||||
() => document.renderRegion(1, const Rect.fromLTWH(0, 0, 0.5, 0.5)),
|
||||
);
|
||||
expect(png!.sublist(0, 4), [0x89, 0x50, 0x4e, 0x47]);
|
||||
});
|
||||
|
||||
testWidgets('refuses a file that is no PDF', (tester) async {
|
||||
// runAsync reports what is thrown inside instead of passing it on.
|
||||
final refused = await tester.runAsync(() async {
|
||||
try {
|
||||
await PdfrxEngine().open(Uint8List.fromList(utf8.encode('text')));
|
||||
return false;
|
||||
} on FormatException {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
expect(refused, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('its viewer turns pages and finds text', (tester) async {
|
||||
final document = await tester.runAsync(
|
||||
() => PdfrxEngine().open(_threePages()),
|
||||
);
|
||||
final controller = PdfReaderController();
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 600,
|
||||
height: 800,
|
||||
child: document!.viewer(controller),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
Future<void> until(bool Function() test) async {
|
||||
final waited = Stopwatch()..start();
|
||||
while (!test() && waited.elapsed < const Duration(seconds: 20)) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
await until(() => controller.isReady);
|
||||
expect(controller.isReady, isTrue);
|
||||
|
||||
// The page turns over an animation, whose frames the test drives: it
|
||||
// is started, then pumped until it arrives.
|
||||
unawaited(controller.goToPage(3));
|
||||
await until(() => controller.page == 3);
|
||||
expect(controller.page, 3);
|
||||
|
||||
controller.search('hello');
|
||||
await until(() => controller.matchCount == 3);
|
||||
expect(controller.matchCount, 3);
|
||||
expect(controller.pageRect(3), isNotNull);
|
||||
});
|
||||
}
|
||||
|
||||
/// A PDF of three pages, each saying "Hello page N", with an outline of
|
||||
/// chapter "One" at page 1 and "Two" at page 3.
|
||||
Uint8List _threePages() {
|
||||
String text(int page) {
|
||||
final stream = 'BT /F1 24 Tf 40 700 Td (Hello page $page) Tj ET';
|
||||
return '<< /Length ${stream.length} >>\nstream\n$stream\nendstream';
|
||||
}
|
||||
|
||||
String pageOf(int page) =>
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] '
|
||||
'/Contents ${5 + page} 0 R /Resources << /Font << /F1 9 0 R >> >> >>';
|
||||
|
||||
final objects = [
|
||||
'<< /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>',
|
||||
for (final page in [1, 2, 3]) pageOf(page),
|
||||
for (final page in [1, 2, 3]) text(page),
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
'<< /Type /Outlines /First 11 0 R /Last 12 0 R /Count 2 >>',
|
||||
'<< /Title (One) /Parent 10 0 R /Next 12 0 R /Dest [3 0 R /Fit] >>',
|
||||
'<< /Title (Two) /Parent 10 0 R /Prev 11 0 R /Dest [5 0 R /Fit] >>',
|
||||
];
|
||||
final out = StringBuffer('%PDF-1.7\n');
|
||||
final offsets = <int>[];
|
||||
for (final (index, object) in objects.indexed) {
|
||||
offsets.add(out.length);
|
||||
out.write('${index + 1} 0 obj\n$object\nendobj\n');
|
||||
}
|
||||
final xref = out.length;
|
||||
out
|
||||
..write('xref\n0 ${objects.length + 1}\n0000000000 65535 f \n')
|
||||
..writeAll([
|
||||
for (final offset in offsets)
|
||||
'${offset.toString().padLeft(10, '0')} 00000 n \n',
|
||||
])
|
||||
..write(
|
||||
'trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n'
|
||||
'startxref\n$xref\n%%EOF\n',
|
||||
);
|
||||
return Uint8List.fromList(latin1.encode(out.toString()));
|
||||
}
|
||||
Loading…
Reference in a new issue