diff --git a/lib/editor/persistence/sidecar_repository.dart b/lib/editor/persistence/sidecar_repository.dart index 637ff2c..73c909e 100644 --- a/lib/editor/persistence/sidecar_repository.dart +++ b/lib/editor/persistence/sidecar_repository.dart @@ -183,6 +183,18 @@ class SidecarRepository { /// The OCR text loaded from the sidecar, or null. String? get loadedOcrText => _sidecar.ocrText; + /// Replace the document-body search text (PDF embedded text layer, or + /// background OCR of a rasterized PDF — see [PdfTextIndexer]) and schedule a + /// save. No-op if unchanged. An empty string is normalized to null. + void schedulePageTextSave(String? pageText) { + final next = (pageText != null && pageText.isEmpty) ? null : pageText; + if (_sidecar.pageText == next) return; + _replace(pageText: next, clearPageText: next == null); + } + + /// The document-body search text loaded from the sidecar, or null. + String? get loadedPageText => _sidecar.pageText; + /// Replace the committed strokes for [pageIndex] and schedule a save. void scheduleStrokeSave(int pageIndex, List strokes) { final next = Map>.from(_sidecar.strokes); @@ -321,6 +333,8 @@ class SidecarRepository { List? scratchLinks, String? ocrText, bool clearOcrText = false, + String? pageText, + bool clearPageText = false, String? background, }) { if (_disposed) return; @@ -339,6 +353,7 @@ class SidecarRepository { bookmarks: bookmarks ?? _sidecar.bookmarks, scratchLinks: scratchLinks ?? _sidecar.scratchLinks, ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText), + pageText: clearPageText ? null : (pageText ?? _sidecar.pageText), background: background ?? _sidecar.background, ); _timer?.cancel(); diff --git a/lib/providers/ocr_provider.dart b/lib/providers/ocr_provider.dart index 82888e4..8f43cc3 100644 --- a/lib/providers/ocr_provider.dart +++ b/lib/providers/ocr_provider.dart @@ -1,11 +1,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../services/ocr_service.dart'; +import '../services/pdf_text_indexer.dart'; +import '../services/pdfrx_page_text_source.dart'; enum OcrStatus { none, processing, done, failed } final ocrServiceProvider = Provider((ref) => OcrService()); +/// The import-time PDF document-body indexer, wired to the pdfrx-backed embedded +/// text + page-render OCR sources (see [PdfrxPageTextSource]). The import flow +/// fires [PdfTextIndexer.indexPdf] (fire-and-forget) so a scanned PDF's text +/// becomes searchable in the background without blocking the editor opening. +final pdfTextIndexerProvider = Provider( + (ref) => PdfTextIndexer( + loadEmbeddedText: PdfrxPageTextSource.loadEmbeddedText, + ocrPages: PdfrxPageTextSource.ocrPages, + ), +); + /// Tracks local OCR processing status per note ID. /// /// This map only ever holds an entry per note that has had OCR triggered in diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index dc5680d..dea205c 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -8,6 +10,7 @@ import '../models/note.dart'; import '../providers/document_provider.dart'; import '../providers/note_provider.dart'; import '../providers/ocr_provider.dart'; +import '../providers/search_provider.dart'; import '../editor/canvas/pen_editor_screen.dart'; import '../services/pptx_service.dart'; import '../services/vault_service.dart'; @@ -250,6 +253,10 @@ class HomeScreen extends ConsumerWidget { final vaultPath = await vault.createNotebook(pickedPath); // Refresh the documents list so the new notebook shows on return. await ref.read(documentListProvider.notifier).loadDocuments(); + // For a PDF, index its document body (embedded text layer, or background + // OCR of a rasterized/scanned PDF) into the sidecar so search covers it. + // Fire-and-forget: import returns and opens the editor immediately. + _indexPdfInBackground(ref, vaultPath); if (!context.mounted) return; await _openVaultFile(context, ref, vaultPath); } catch (e) { @@ -257,6 +264,25 @@ class HomeScreen extends ConsumerWidget { } } + /// Kick off background document-body indexing for an in-vault PDF (no-op for + /// other types). Runs detached from the import await chain so the editor opens + /// immediately; on completion it bumps the search-index epoch so the newly + /// indexed text is searchable. Idempotency and graceful OCR degradation live in + /// [PdfTextIndexer]; failures here are swallowed (search just misses the body). + void _indexPdfInBackground(WidgetRef ref, String vaultPath) { + final ext = p.extension(vaultPath).replaceFirst('.', '').toLowerCase(); + if (ext != 'pdf') return; + final indexer = ref.read(pdfTextIndexerProvider); + unawaited(() async { + final indexed = await indexer.indexPdf(vaultPath); + if (indexed != null && indexed.isNotEmpty) { + // Force the next search to re-scan the vault (picks up the new pageText). + final epoch = ref.read(searchIndexEpochProvider.notifier); + epoch.state = epoch.state + 1; + } + }()); + } + /// Route an in-vault [filePath] to the correct editor by extension: /// pdf → [PenEditorScreen]; pptx/ppt → [PenSlideScreen]; docx → convert to /// PDF (best-effort, LibreOffice) then open as PDF. Unsupported / failed diff --git a/lib/services/pdf_text_indexer.dart b/lib/services/pdf_text_indexer.dart new file mode 100644 index 0000000..e249248 --- /dev/null +++ b/lib/services/pdf_text_indexer.dart @@ -0,0 +1,198 @@ +// lib/services/pdf_text_indexer.dart +// +// Import-time document-body text indexing for file-backed PDF notebooks, so the +// vault-scan search index (VaultSearchIndex) covers the underlying document — +// not just the user's annotations. Three text sources now feed search: +// (a) handwriting → OCR'd into the sidecar's `ocrText` (OcrService), +// (b) a PDF text layer → its embedded printed text, captured here, +// (c) a RASTERIZED PDF → background OCR of the rendered pages, captured here. +// +// Flow (kicked off after VaultService.createNotebook, fire-and-forget): +// 1. IDEMPOTENCY: if the sidecar already carries `pageText`, do nothing. +// 2. EXTRACT the embedded text layer per page (pdfrx `loadText`). +// 3. DECIDE text-layer vs rasterized: sum the embedded text length across all +// pages; if it clears [textLayerThreshold] the PDF has a usable text layer +// and we persist that. Otherwise the PDF is rasterized (scanned image, no +// text) and we OCR each rendered page. +// 4. PERSIST the per-page text (joined by form-feed) into the sidecar's +// `pageText` field — writing THROUGH an open SidecarRepository when the +// editor already has the doc open (no race), else a transient handle. +// +// GRACEFUL DEGRADATION / HONESTY: +// * The OCR engine is whatever OcrService/OcrEngine resolves to: the bundled +// ONNX PP-OCR recognizer if present, else the native Windows OCR +// MethodChannel, else NONE. On a platform/CI without any backend, OCR +// returns null and a rasterized PDF simply gets no `pageText` — no crash, +// and the embedded-text path still works. +// * pdfrx render + native OCR can only be exercised on-device. The pdfrx- +// backed loaders are injected through [PdfTextIndexer] so unit tests fake +// them; the production wiring lives in [PdfrxPageTextSource]. + +import 'dart:async'; +import 'dart:io'; + +import '../editor/persistence/sidecar_repository.dart'; +import '../storage/badnote_sidecar.dart'; +import '../storage/sidecar_store.dart'; + +/// Extracts a PDF's embedded text layer, one entry per page (page order). An +/// empty list (or all-empty entries) means "no usable text layer". +typedef PdfEmbeddedTextLoader = Future> Function(String pdfPath); + +/// OCRs the rendered pages of a rasterized PDF, returning one entry per page +/// (page order). Entries may be empty where a page yielded nothing. Returns an +/// empty list when no OCR backend is available (a clean no-op). +typedef PdfPageOcrRunner = Future> Function(String pdfPath); + +/// Indexes a PDF's document body into its sidecar's `pageText` at import time. +/// +/// Stateless apart from the two injected text sources; safe to construct per +/// import. All disk/native work is awaited internally — call [indexPdf] without +/// awaiting (fire-and-forget) from the import handler to keep import snappy. +class PdfTextIndexer { + PdfTextIndexer({ + required PdfEmbeddedTextLoader loadEmbeddedText, + required PdfPageOcrRunner ocrPages, + this.textLayerThreshold = 16, + }) : _loadEmbeddedText = loadEmbeddedText, + _ocrPages = ocrPages; + + final PdfEmbeddedTextLoader _loadEmbeddedText; + final PdfPageOcrRunner _ocrPages; + + /// Minimum total embedded-text length (across all pages, after trimming) for a + /// PDF to count as having a usable text layer. Below this it is treated as + /// rasterized and routed to OCR. Small on purpose: a scanned PDF typically + /// yields zero or a few stray ligature chars, while any real text page clears + /// it easily. + final int textLayerThreshold; + + /// The page separator stored inside `pageText` (form feed). The search index + /// treats `pageText` as a flat blob, so this is purely cosmetic / future-proof. + static const String pageSeparator = '\f'; + + /// Index the PDF at [pdfPath] (an in-vault copy) into its sidecar's `pageText`. + /// + /// Idempotent: returns immediately if the sidecar already has non-empty + /// `pageText`. Never throws — any failure (unreadable PDF, missing OCR backend) + /// degrades to leaving `pageText` unset. Returns the text it persisted (for + /// tests), or null when nothing was indexed. + Future indexPdf(String pdfPath) async { + try { + // 1. Idempotency: skip a doc whose body has already been indexed. + final existing = await _currentPageText(pdfPath); + if (existing != null && existing.trim().isNotEmpty) return null; + + // 2. Embedded text layer. + final embedded = await _loadEmbeddedText(pdfPath); + final embeddedLen = embedded.fold( + 0, + (sum, page) => sum + page.trim().length, + ); + + // 3. Text-layer vs rasterized decision. + List pages; + if (embeddedLen >= textLayerThreshold) { + pages = embedded; + } else { + // Rasterized (scanned, no text layer) → background OCR. + pages = await _ocrPages(pdfPath); + } + + final joined = _joinPages(pages); + if (joined.isEmpty) return null; + + // 4. Persist into the sidecar (through an open repo if the editor holds it). + await _persistPageText(pdfPath, joined); + return joined; + } catch (_) { + // Background indexing must never surface an error to the import flow. + return null; + } + } + + /// Whether the embedded text in [embedded] clears [textLayerThreshold], i.e. + /// the PDF has a usable text layer (false → rasterized, needs OCR). Exposed for + /// unit-testing the decision in isolation. + bool hasUsableTextLayer(List embedded) { + final len = embedded.fold(0, (sum, p) => sum + p.trim().length); + return len >= textLayerThreshold; + } + + static String _joinPages(List pages) { + final nonEmpty = pages.map((p) => p.trim()).where((p) => p.isNotEmpty); + return nonEmpty.join(pageSeparator).trim(); + } + + /// Read the sidecar's current `pageText` (for the idempotency check), from the + /// open repo if present else from disk. Null when no sidecar exists yet. + Future _currentPageText(String pdfPath) async { + final open = SidecarRepositoryRegistry.forPath(pdfPath); + if (open != null) return open.loadedPageText; + final sidecar = await SidecarStore.read( + File('$pdfPath$kSidecarSuffix'), + ); + return sidecar?.pageText; + } + + /// Write [pageText] into the sidecar. Prefer the editor's already-open repo + /// (same in-memory sidecar — no race); otherwise merge into the on-disk + /// sidecar (creating one if the editor hasn't yet). + Future _persistPageText(String pdfPath, String pageText) async { + final open = SidecarRepositoryRegistry.forPath(pdfPath); + if (open != null) { + open.schedulePageTextSave(pageText); + await open.flush(); + return; + } + + final file = File('$pdfPath$kSidecarSuffix'); + final current = await SidecarStore.read(file); + final merged = _withPageText(current, pdfPath, pageText); + await SidecarStore.writeAtomic(file, merged); + } + + /// Build a sidecar carrying [pageText], preserving every other field of + /// [current] (or a fresh minimal sidecar when none exists yet). + static BadnoteSidecar _withPageText( + BadnoteSidecar? current, + String pdfPath, + String pageText, + ) { + if (current == null) { + return BadnoteSidecar( + sourceFile: _basename(pdfPath), + docType: 'pdf', + createdAt: DateTime.now().toUtc(), + updatedAt: DateTime.now().toUtc(), + pageText: pageText, + ); + } + return BadnoteSidecar( + version: current.version, + sourceFile: current.sourceFile, + docType: current.docType, + title: current.title, + pageCount: current.pageCount, + rotation: current.rotation, + createdAt: current.createdAt, + updatedAt: DateTime.now().toUtc(), + strokes: current.strokes, + highlights: current.highlights, + texts: current.texts, + bookmarks: current.bookmarks, + scratchLinks: current.scratchLinks, + legacyAnnotations: current.legacyAnnotations, + ocrText: current.ocrText, + pageText: pageText, + legacyId: current.legacyId, + background: current.background, + ); + } + + static String _basename(String path) { + final norm = path.replaceAll('\\', '/'); + final i = norm.lastIndexOf('/'); + return i == -1 ? norm : norm.substring(i + 1); + } +} diff --git a/lib/services/pdfrx_page_text_source.dart b/lib/services/pdfrx_page_text_source.dart new file mode 100644 index 0000000..de9b66a --- /dev/null +++ b/lib/services/pdfrx_page_text_source.dart @@ -0,0 +1,111 @@ +// lib/services/pdfrx_page_text_source.dart +// +// Production wiring for [PdfTextIndexer]'s two injected text sources, backed by +// pdfrx (the same engine the editor renders with). Kept SEPARATE from +// PdfTextIndexer so the indexer's logic (the threshold decision, idempotency, +// sidecar persistence) is unit-testable without the native pdfium/OCR stack — +// only this file touches pdfrx, dart:ui, and the OCR engine, and it is exercised +// on-device, not in CI. + +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:pdfrx/pdfrx.dart'; + +import 'ocr_engine.dart'; + +/// pdfrx-backed loaders for [PdfTextIndexer]. +class PdfrxPageTextSource { + const PdfrxPageTextSource._(); + + /// Load the embedded text layer of every page (page order). Each entry is a + /// page's raw text (possibly empty). Returns an empty list on any failure, so + /// the indexer treats the PDF as having no text layer (→ OCR fallback). + static Future> loadEmbeddedText(String pdfPath) async { + PdfDocument? doc; + try { + doc = await PdfDocument.openFile(pdfPath); + final out = []; + for (final page in doc.pages) { + final raw = await page.loadText(); + out.add(raw?.fullText ?? ''); + } + return out; + } catch (_) { + return const []; + } finally { + await doc?.dispose(); + } + } + + /// Render each page and OCR it (page order). Returns one entry per page + /// (empty where nothing was recognized), or an empty list when the PDF can't + /// be opened. Honours the OCR engine's own graceful no-op: when no backend is + /// available every page comes back empty. + /// + /// Rendering is done at [renderScale]× the page's native 72-dpi size to give + /// the recognizer enough resolution on scanned scans without exploding memory. + static Future> ocrPages( + String pdfPath, { + double renderScale = 2.0, + }) async { + PdfDocument? doc; + try { + doc = await PdfDocument.openFile(pdfPath); + final out = []; + for (final page in doc.pages) { + final text = await _ocrOnePage(page, renderScale); + out.add(text ?? ''); + } + return out; + } catch (_) { + return const []; + } finally { + await doc?.dispose(); + } + } + + static Future _ocrOnePage(PdfPage page, double renderScale) async { + PdfImage? image; + try { + final fullWidth = page.width * renderScale; + final fullHeight = page.height * renderScale; + image = await page.render( + fullWidth: fullWidth, + fullHeight: fullHeight, + ); + if (image == null) return null; + final png = await _bgraToPng(image.pixels, image.width, image.height); + if (png == null) return null; + return OcrEngine.recognizeImage(png); + } catch (_) { + return null; + } finally { + image?.dispose(); + } + } + + /// Encode pdfrx's BGRA8888 raw pixels as PNG (the format [OcrEngine] expects). + static Future _bgraToPng( + Uint8List bgra, + int width, + int height, + ) async { + final completer = Completer(); + ui.decodeImageFromPixels( + bgra, + width, + height, + ui.PixelFormat.bgra8888, + completer.complete, + ); + final image = await completer.future; + try { + final data = await image.toByteData(format: ui.ImageByteFormat.png); + return data?.buffer.asUint8List(); + } finally { + image.dispose(); + } + } +} diff --git a/lib/services/vault_search_index.dart b/lib/services/vault_search_index.dart index 3557bde..851dd62 100644 --- a/lib/services/vault_search_index.dart +++ b/lib/services/vault_search_index.dart @@ -10,9 +10,12 @@ // pages, // * the handwriting OCR text persisted in the sidecar's `ocrText` field. // -// It does NOT (yet) index a PDF's embedded text layer — that is a known gap (see -// the REPORT in the task / the class doc below). Matching uses the existing pure -// search primitives (normalize / rank / snippet), so CJK substring search works. +// It ALSO indexes a file-backed PDF's document body text, captured once at +// import into the sidecar's `pageText` field by [PdfTextIndexer]: the embedded +// (printed) text layer for a normal PDF, or background OCR of the rendered pages +// for a RASTERIZED / scanned PDF that has no text layer. Matching uses the +// existing pure search primitives (normalize / rank / snippet), so CJK substring +// search works. import 'dart:io'; @@ -71,13 +74,14 @@ class VaultSearchHit { /// source of truth). /// /// HONEST SCOPE (what search covers / does NOT): -/// * COVERS: note/doc titles, typed text boxes, and handwriting OCR text that -/// has been persisted into a sidecar's `ocrText` field. -/// * DOES NOT cover: a PDF's embedded (printed) text layer — only the user's -/// annotations are indexed, not the underlying document body. Indexing the -/// PDF text layer would require rendering each page through pdfrx at scan -/// time; deferred. OCR is only present where it has already been run and -/// written back to the sidecar. +/// * COVERS: note/doc titles, typed text boxes, handwriting OCR text persisted +/// into a sidecar's `ocrText` field, AND a PDF's document body text persisted +/// into `pageText` at import — the embedded text layer, or background OCR of +/// a rasterized/scanned PDF (see [PdfTextIndexer]). +/// * CAVEAT: `pageText` is only present once import-time indexing has run and +/// written it back to the sidecar. A PDF imported before this feature (or +/// whose OCR backend was unavailable) has no `pageText`, so only its +/// annotations are searchable until it is re-indexed. class VaultSearchIndex { VaultSearchIndex(this._vault); @@ -174,6 +178,11 @@ class VaultSearchIndex { } final ocr = sidecar.ocrText; if (ocr != null && ocr.trim().isNotEmpty) parts.add(ocr.trim()); + // Document body text captured at import: the PDF's embedded text layer, + // or background OCR of a rasterized/scanned PDF. Covers the underlying + // document, not just the user's annotations. + final body = sidecar.pageText; + if (body != null && body.trim().isNotEmpty) parts.add(body.trim()); } return parts.join('\n'); } diff --git a/lib/storage/badnote_sidecar.dart b/lib/storage/badnote_sidecar.dart index 3b43a84..b14c4c2 100644 --- a/lib/storage/badnote_sidecar.dart +++ b/lib/storage/badnote_sidecar.dart @@ -313,6 +313,7 @@ class BadnoteSidecar { List? scratchLinks, Map? legacyAnnotations, this.ocrText, + this.pageText, this.legacyId, this.background, }) : strokes = strokes ?? >{}, @@ -368,6 +369,17 @@ class BadnoteSidecar { /// the notebook has no handwriting or OCR hasn't run. final String? ocrText; + /// Searchable text of the underlying DOCUMENT BODY for a file-backed notebook + /// (a PDF), captured ONCE at import time so the vault-scan search index covers + /// the document — not just the user's annotations. It is either the PDF's + /// embedded (printed) text layer, or — for a RASTERIZED / scanned PDF with no + /// text layer — the result of a background OCR pass over the rendered pages. + /// Pages are joined with `\f` (form feed) but the index treats it as a flat + /// blob. Null when the document has not been indexed yet (back-compat: an old + /// sidecar simply omits the field) or has no extractable/recognized text. This + /// is distinct from [ocrText], which holds ONLY handwriting OCR. + final String? pageText; + /// The legacy SQLite row id this sidecar was migrated from (a `documents.id` /// or `notes.id`). Set ONLY by the one-time migration; it makes the migration /// idempotent (a re-run recognizes an already-migrated item by this id even if @@ -413,6 +425,7 @@ class BadnoteSidecar { entry.key.toString(): entry.value, }, if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText, + if (pageText != null && pageText!.isNotEmpty) 'pageText': pageText, if (legacyId != null) 'legacyId': legacyId, if (background != null) 'background': background, }; @@ -470,6 +483,7 @@ class BadnoteSidecar { return out; }(), ocrText: json['ocrText'] as String?, + pageText: json['pageText'] as String?, legacyId: json['legacyId'] as String?, background: json['background'] as String?, ); diff --git a/test/badnote_sidecar_test.dart b/test/badnote_sidecar_test.dart index 260742b..70e16a5 100644 --- a/test/badnote_sidecar_test.dart +++ b/test/badnote_sidecar_test.dart @@ -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 = { + 'badnoteSidecarVersion': 1, + 'docType': 'pdf', + 'sourceFile': 'old.pdf', + 'strokes': {}, + 'highlights': {}, + 'bookmarks': [], + 'scratchLinks': [], + }; + 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( diff --git a/test/pdf_text_indexer_test.dart b/test/pdf_text_indexer_test.dart new file mode 100644 index 0000000..e0ecb4f --- /dev/null +++ b/test/pdf_text_indexer_test.dart @@ -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 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')); + }); +} diff --git a/test/vault_search_index_test.dart b/test/vault_search_index_test.dart index 87364c6..63a6ccd 100644 --- a/test/vault_search_index_test.dart +++ b/test/vault_search_index_test.dart @@ -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 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); });