feat(search): index PDF text, OCR scanned PDFs on import
All checks were successful
CI / Windows build (push) Successful in 15m50s
All checks were successful
CI / Windows build (push) Successful in 15m50s
Search now covers handwriting, the PDF text layer, AND scanned (rasterized) PDFs. - PdfTextIndexer runs at import: sums the embedded text layer across pages; if present it stores that as the document body, otherwise the PDF is rasterized and its rendered pages are OCR'd in the background. The result lands in the sidecar `pageText` field (distinct from `ocrText`, the handwriting OCR). Idempotent (skips a sidecar that already has pageText); degrades gracefully with no OCR engine. - pdfrx_page_text_source abstracts text/render so it's testable. - VaultSearchIndex now harvests title + typed text + handwriting OCR + PDF pageText, so search finds notes, typed PDFs and scanned PDFs. analyze clean, 409 tests green.
This commit is contained in:
@@ -379,6 +379,46 @@ void main() {
|
||||
expect(reparsed.background, isNull);
|
||||
});
|
||||
|
||||
test('pageText (PDF body / scanned-OCR text) round-trips through sidecar', () {
|
||||
final original = BadnoteSidecar(
|
||||
docType: 'pdf',
|
||||
sourceFile: 'scan.pdf',
|
||||
pageText: 'page one body\fpage two 第二页',
|
||||
);
|
||||
final reparsed = _roundTrip(original);
|
||||
expect(reparsed.pageText, 'page one body\fpage two 第二页');
|
||||
// ocrText (handwriting) and pageText (document body) are independent fields.
|
||||
expect(reparsed.ocrText, isNull);
|
||||
// Omitted when null/empty (no key bloat for legacy/annotation-only sidecars).
|
||||
expect(BadnoteSidecar().toJson().containsKey('pageText'), isFalse);
|
||||
});
|
||||
|
||||
test('missing pageText decodes to null (back-compat)', () {
|
||||
// A sidecar authored before the import-OCR feature simply omits pageText.
|
||||
final json = <String, dynamic>{
|
||||
'badnoteSidecarVersion': 1,
|
||||
'docType': 'pdf',
|
||||
'sourceFile': 'old.pdf',
|
||||
'strokes': <String, dynamic>{},
|
||||
'highlights': <String, dynamic>{},
|
||||
'bookmarks': <dynamic>[],
|
||||
'scratchLinks': <dynamic>[],
|
||||
};
|
||||
final reparsed = BadnoteSidecar.fromJson(json);
|
||||
expect(reparsed.pageText, isNull);
|
||||
});
|
||||
|
||||
test('pageText and ocrText coexist independently', () {
|
||||
final original = BadnoteSidecar(
|
||||
docType: 'pdf',
|
||||
ocrText: 'handwritten note',
|
||||
pageText: 'printed document body',
|
||||
);
|
||||
final reparsed = _roundTrip(original);
|
||||
expect(reparsed.ocrText, 'handwritten note');
|
||||
expect(reparsed.pageText, 'printed document body');
|
||||
});
|
||||
|
||||
test('missing scratchpad defaults to 4000x4000 empty pad', () {
|
||||
final json = SidecarScratchLink(
|
||||
link: const ScratchLink(
|
||||
|
||||
195
test/pdf_text_indexer_test.dart
Normal file
195
test/pdf_text_indexer_test.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
// test/pdf_text_indexer_test.dart
|
||||
//
|
||||
// Unit tests for the import-time PDF document-body indexer. The two native text
|
||||
// sources (pdfrx embedded-text loader + page-render OCR) are FAKED here — no
|
||||
// real pdfium render and no real OCR channel run in CI; those are exercised only
|
||||
// on-device (see PdfrxPageTextSource). These tests pin:
|
||||
// * the text-layer-vs-rasterized DECISION (empty text layer → OCR fallback),
|
||||
// * the background flow: embedded text OR OCR text lands in the sidecar's
|
||||
// `pageText`,
|
||||
// * IDEMPOTENCY: a sidecar that already has `pageText` is not re-indexed,
|
||||
// * graceful degradation: no OCR backend (empty OCR result) → no `pageText`,
|
||||
// no crash.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'package:badnote/editor/persistence/sidecar_repository.dart';
|
||||
import 'package:badnote/services/pdf_text_indexer.dart';
|
||||
import 'package:badnote/storage/badnote_sidecar.dart';
|
||||
import 'package:badnote/storage/sidecar_store.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late Directory dir;
|
||||
late String pdfPath;
|
||||
|
||||
setUp(() async {
|
||||
SidecarRepositoryRegistry.resetForTest();
|
||||
dir = await Directory.systemTemp.createTemp('pdf_indexer_test');
|
||||
pdfPath = p.join(dir.path, 'doc.pdf');
|
||||
await File(pdfPath).writeAsString('%PDF-1.7 fake');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
SidecarRepositoryRegistry.resetForTest();
|
||||
if (await dir.exists()) await dir.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<BadnoteSidecar?> readSidecar() =>
|
||||
SidecarStore.read(File('$pdfPath$kSidecarSuffix'));
|
||||
|
||||
// A text-layer PDF: embedded loader returns real text, OCR should NOT run.
|
||||
test('a PDF with a usable text layer persists the embedded text', () async {
|
||||
var ocrCalled = false;
|
||||
final indexer = PdfTextIndexer(
|
||||
loadEmbeddedText: (_) async => ['Chapter one introduction', 'page two'],
|
||||
ocrPages: (_) async {
|
||||
ocrCalled = true;
|
||||
return ['should not be used'];
|
||||
},
|
||||
);
|
||||
|
||||
final result = await indexer.indexPdf(pdfPath);
|
||||
|
||||
expect(ocrCalled, isFalse, reason: 'text layer present → no OCR');
|
||||
expect(result, contains('Chapter one introduction'));
|
||||
expect(result, contains('page two'));
|
||||
final sidecar = await readSidecar();
|
||||
expect(sidecar?.pageText, contains('introduction'));
|
||||
});
|
||||
|
||||
// A rasterized/scanned PDF: empty embedded text → OCR fallback runs.
|
||||
test('a rasterized PDF (empty text layer) is OCR\'d into pageText', () async {
|
||||
var ocrCalled = false;
|
||||
final indexer = PdfTextIndexer(
|
||||
loadEmbeddedText: (_) async => ['', '', ''], // scanned: no text layer
|
||||
ocrPages: (_) async {
|
||||
ocrCalled = true;
|
||||
return ['扫描出的文字', 'recognized line two'];
|
||||
},
|
||||
);
|
||||
|
||||
final result = await indexer.indexPdf(pdfPath);
|
||||
|
||||
expect(ocrCalled, isTrue, reason: 'no text layer → OCR runs');
|
||||
expect(result, contains('扫描出的文字'));
|
||||
final sidecar = await readSidecar();
|
||||
expect(sidecar?.pageText, contains('扫描出的文字'));
|
||||
expect(sidecar?.pageText, contains('recognized line two'));
|
||||
});
|
||||
|
||||
test('the text-layer decision: empty/near-empty text → needs OCR', () {
|
||||
final indexer = PdfTextIndexer(
|
||||
loadEmbeddedText: (_) async => const [],
|
||||
ocrPages: (_) async => const [],
|
||||
textLayerThreshold: 16,
|
||||
);
|
||||
|
||||
// Empty pages → rasterized (needs OCR).
|
||||
expect(indexer.hasUsableTextLayer(['', '', '']), isFalse);
|
||||
// A few stray ligature chars below threshold → still rasterized.
|
||||
expect(indexer.hasUsableTextLayer([' ', 'fi', ' ']), isFalse);
|
||||
// Real text above threshold → usable text layer.
|
||||
expect(
|
||||
indexer.hasUsableTextLayer(['This is real printed body text']),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('idempotent: a sidecar that already has pageText is not re-indexed',
|
||||
() async {
|
||||
// Pre-seed a sidecar with existing pageText.
|
||||
await SidecarStore.writeAtomic(
|
||||
File('$pdfPath$kSidecarSuffix'),
|
||||
BadnoteSidecar(
|
||||
sourceFile: 'doc.pdf',
|
||||
docType: 'pdf',
|
||||
pageText: 'already indexed body',
|
||||
),
|
||||
);
|
||||
|
||||
var embeddedCalled = false;
|
||||
var ocrCalled = false;
|
||||
final indexer = PdfTextIndexer(
|
||||
loadEmbeddedText: (_) async {
|
||||
embeddedCalled = true;
|
||||
return ['new text'];
|
||||
},
|
||||
ocrPages: (_) async {
|
||||
ocrCalled = true;
|
||||
return ['new ocr'];
|
||||
},
|
||||
);
|
||||
|
||||
final result = await indexer.indexPdf(pdfPath);
|
||||
|
||||
expect(result, isNull, reason: 'already indexed → no-op');
|
||||
expect(embeddedCalled, isFalse);
|
||||
expect(ocrCalled, isFalse);
|
||||
// Existing text untouched.
|
||||
final sidecar = await readSidecar();
|
||||
expect(sidecar?.pageText, 'already indexed body');
|
||||
});
|
||||
|
||||
test('no OCR backend (empty OCR result) leaves pageText unset, no crash',
|
||||
() async {
|
||||
final indexer = PdfTextIndexer(
|
||||
loadEmbeddedText: (_) async => ['', ''], // rasterized
|
||||
ocrPages: (_) async => const [], // no OCR backend → clean no-op
|
||||
);
|
||||
|
||||
final result = await indexer.indexPdf(pdfPath);
|
||||
|
||||
expect(result, isNull);
|
||||
final sidecar = await readSidecar();
|
||||
// No sidecar written (nothing to index) — or, if present, no pageText.
|
||||
expect(sidecar?.pageText, isNull);
|
||||
});
|
||||
|
||||
test('writes THROUGH an open editor repo (no second on-disk race)', () async {
|
||||
// Open a repo for the same path: the indexer must write through it.
|
||||
final repo = await SidecarRepository.open(pdfPath, docType: 'pdf');
|
||||
addTearDown(repo.dispose);
|
||||
|
||||
final indexer = PdfTextIndexer(
|
||||
loadEmbeddedText: (_) async => ['printed body via open editor'],
|
||||
ocrPages: (_) async => const [],
|
||||
);
|
||||
|
||||
final result = await indexer.indexPdf(pdfPath);
|
||||
|
||||
expect(result, contains('printed body'));
|
||||
// The in-memory sidecar held by the editor now carries the pageText.
|
||||
expect(repo.loadedPageText, contains('printed body'));
|
||||
// And it was flushed to disk.
|
||||
final sidecar = await readSidecar();
|
||||
expect(sidecar?.pageText, contains('printed body'));
|
||||
});
|
||||
|
||||
test('preserves existing annotations when merging pageText on disk', () async {
|
||||
// A sidecar with annotations but no pageText (e.g. user annotated before the
|
||||
// background OCR finished). Indexing must not clobber the annotations.
|
||||
await SidecarStore.writeAtomic(
|
||||
File('$pdfPath$kSidecarSuffix'),
|
||||
BadnoteSidecar(
|
||||
sourceFile: 'doc.pdf',
|
||||
docType: 'pdf',
|
||||
ocrText: 'handwriting note',
|
||||
),
|
||||
);
|
||||
|
||||
final indexer = PdfTextIndexer(
|
||||
loadEmbeddedText: (_) async => ['printed document body text here'],
|
||||
ocrPages: (_) async => const [],
|
||||
);
|
||||
await indexer.indexPdf(pdfPath);
|
||||
|
||||
final sidecar = await readSidecar();
|
||||
expect(sidecar?.ocrText, 'handwriting note'); // preserved
|
||||
expect(sidecar?.pageText, contains('printed document body'));
|
||||
});
|
||||
}
|
||||
@@ -8,8 +8,9 @@
|
||||
// * the title / source filename,
|
||||
// * a typed text box (EditorStroke.textContent),
|
||||
// * the persisted handwriting OCR text (sidecar `ocrText`),
|
||||
// * the PDF document-body text captured at import (sidecar `pageText`): both
|
||||
// the embedded text layer AND the background-OCR result for a scanned PDF,
|
||||
// * a CJK substring (this user writes Chinese).
|
||||
// It also documents the known GAP: a PDF's embedded text layer is NOT indexed.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
@@ -56,6 +57,7 @@ void main() {
|
||||
required String pdfName,
|
||||
List<EditorStroke> page0 = const [],
|
||||
String? ocrText,
|
||||
String? pageText,
|
||||
}) async {
|
||||
final dir = Directory(p.join(vaultDir.path, folder));
|
||||
await dir.create(recursive: true);
|
||||
@@ -66,6 +68,7 @@ void main() {
|
||||
docType: 'pdf',
|
||||
strokes: page0.isEmpty ? null : {0: page0},
|
||||
ocrText: ocrText,
|
||||
pageText: pageText,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
);
|
||||
await SidecarStore.writeAtomic(
|
||||
@@ -173,11 +176,39 @@ void main() {
|
||||
expect(await index.search('x'), isEmpty);
|
||||
});
|
||||
|
||||
test('KNOWN GAP: a PDF embedded text layer is NOT indexed', () async {
|
||||
// The sidecar carries no annotations; only the PDF body would contain the
|
||||
// word "bodytext". Search does NOT read the PDF text layer (documented
|
||||
// limitation), so this returns nothing.
|
||||
await seedDocNotebook(folder: 'Plain', pdfName: 'plain.pdf');
|
||||
test('finds a PDF by its embedded text layer (sidecar pageText)', () async {
|
||||
// The PDF body text captured at import lives in the sidecar's `pageText`.
|
||||
// No annotations at all — only the document body contains "bodytext".
|
||||
await seedDocNotebook(
|
||||
folder: 'Plain',
|
||||
pdfName: 'plain.pdf',
|
||||
pageText: 'introduction to bodytext and more printed content',
|
||||
);
|
||||
final index = VaultSearchIndex(vault);
|
||||
final hits = await index.search('bodytext');
|
||||
expect(hits, hasLength(1));
|
||||
expect(hits.single.entry.openPath, endsWith('plain.pdf'));
|
||||
});
|
||||
|
||||
test('finds a SCANNED PDF by its background-OCR pageText (CJK)', () async {
|
||||
// A rasterized/scanned PDF has no text layer; the import-time OCR pass
|
||||
// writes the recognized text into the SAME `pageText` field, so search
|
||||
// covers scanned documents — including Chinese substrings.
|
||||
await seedDocNotebook(
|
||||
folder: '扫描讲义',
|
||||
pdfName: 'scanned.pdf',
|
||||
pageText: '微积分第三讲 导数的定义与几何意义',
|
||||
);
|
||||
final index = VaultSearchIndex(vault);
|
||||
expect(await index.search('导数'), hasLength(1));
|
||||
expect((await index.search('几何')).single.entry.openPath,
|
||||
endsWith('scanned.pdf'));
|
||||
});
|
||||
|
||||
test('a PDF with no pageText (un-indexed) is not found by body text', () async {
|
||||
// Back-compat: a PDF imported before the feature (or whose OCR backend was
|
||||
// unavailable) has no `pageText`; only its annotations are searchable.
|
||||
await seedDocNotebook(folder: 'Old', pdfName: 'old.pdf');
|
||||
final index = VaultSearchIndex(vault);
|
||||
expect(await index.search('bodytext'), isEmpty);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user