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:
@@ -183,6 +183,18 @@ class SidecarRepository {
|
|||||||
/// The OCR text loaded from the sidecar, or null.
|
/// The OCR text loaded from the sidecar, or null.
|
||||||
String? get loadedOcrText => _sidecar.ocrText;
|
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.
|
/// Replace the committed strokes for [pageIndex] and schedule a save.
|
||||||
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
|
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
|
||||||
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
|
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
|
||||||
@@ -321,6 +333,8 @@ class SidecarRepository {
|
|||||||
List<SidecarScratchLink>? scratchLinks,
|
List<SidecarScratchLink>? scratchLinks,
|
||||||
String? ocrText,
|
String? ocrText,
|
||||||
bool clearOcrText = false,
|
bool clearOcrText = false,
|
||||||
|
String? pageText,
|
||||||
|
bool clearPageText = false,
|
||||||
String? background,
|
String? background,
|
||||||
}) {
|
}) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
@@ -339,6 +353,7 @@ class SidecarRepository {
|
|||||||
bookmarks: bookmarks ?? _sidecar.bookmarks,
|
bookmarks: bookmarks ?? _sidecar.bookmarks,
|
||||||
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
|
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
|
||||||
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
|
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
|
||||||
|
pageText: clearPageText ? null : (pageText ?? _sidecar.pageText),
|
||||||
background: background ?? _sidecar.background,
|
background: background ?? _sidecar.background,
|
||||||
);
|
);
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../services/ocr_service.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 }
|
enum OcrStatus { none, processing, done, failed }
|
||||||
|
|
||||||
final ocrServiceProvider = Provider<OcrService>((ref) => OcrService());
|
final ocrServiceProvider = Provider<OcrService>((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<PdfTextIndexer>(
|
||||||
|
(ref) => PdfTextIndexer(
|
||||||
|
loadEmbeddedText: PdfrxPageTextSource.loadEmbeddedText,
|
||||||
|
ocrPages: PdfrxPageTextSource.ocrPages,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// Tracks local OCR processing status per note ID.
|
/// Tracks local OCR processing status per note ID.
|
||||||
///
|
///
|
||||||
/// This map only ever holds an entry per note that has had OCR triggered in
|
/// This map only ever holds an entry per note that has had OCR triggered in
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -8,6 +10,7 @@ import '../models/note.dart';
|
|||||||
import '../providers/document_provider.dart';
|
import '../providers/document_provider.dart';
|
||||||
import '../providers/note_provider.dart';
|
import '../providers/note_provider.dart';
|
||||||
import '../providers/ocr_provider.dart';
|
import '../providers/ocr_provider.dart';
|
||||||
|
import '../providers/search_provider.dart';
|
||||||
import '../editor/canvas/pen_editor_screen.dart';
|
import '../editor/canvas/pen_editor_screen.dart';
|
||||||
import '../services/pptx_service.dart';
|
import '../services/pptx_service.dart';
|
||||||
import '../services/vault_service.dart';
|
import '../services/vault_service.dart';
|
||||||
@@ -250,6 +253,10 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
final vaultPath = await vault.createNotebook(pickedPath);
|
final vaultPath = await vault.createNotebook(pickedPath);
|
||||||
// Refresh the documents list so the new notebook shows on return.
|
// Refresh the documents list so the new notebook shows on return.
|
||||||
await ref.read(documentListProvider.notifier).loadDocuments();
|
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;
|
if (!context.mounted) return;
|
||||||
await _openVaultFile(context, ref, vaultPath);
|
await _openVaultFile(context, ref, vaultPath);
|
||||||
} catch (e) {
|
} 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:
|
/// Route an in-vault [filePath] to the correct editor by extension:
|
||||||
/// pdf → [PenEditorScreen]; pptx/ppt → [PenSlideScreen]; docx → convert to
|
/// pdf → [PenEditorScreen]; pptx/ppt → [PenSlideScreen]; docx → convert to
|
||||||
/// PDF (best-effort, LibreOffice) then open as PDF. Unsupported / failed
|
/// PDF (best-effort, LibreOffice) then open as PDF. Unsupported / failed
|
||||||
|
|||||||
198
lib/services/pdf_text_indexer.dart
Normal file
198
lib/services/pdf_text_indexer.dart
Normal file
@@ -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<List<String>> 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<List<String>> 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<String?> 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<int>(
|
||||||
|
0,
|
||||||
|
(sum, page) => sum + page.trim().length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Text-layer vs rasterized decision.
|
||||||
|
List<String> 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<String> embedded) {
|
||||||
|
final len = embedded.fold<int>(0, (sum, p) => sum + p.trim().length);
|
||||||
|
return len >= textLayerThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _joinPages(List<String> 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<String?> _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<void> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
lib/services/pdfrx_page_text_source.dart
Normal file
111
lib/services/pdfrx_page_text_source.dart
Normal file
@@ -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<List<String>> loadEmbeddedText(String pdfPath) async {
|
||||||
|
PdfDocument? doc;
|
||||||
|
try {
|
||||||
|
doc = await PdfDocument.openFile(pdfPath);
|
||||||
|
final out = <String>[];
|
||||||
|
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<List<String>> ocrPages(
|
||||||
|
String pdfPath, {
|
||||||
|
double renderScale = 2.0,
|
||||||
|
}) async {
|
||||||
|
PdfDocument? doc;
|
||||||
|
try {
|
||||||
|
doc = await PdfDocument.openFile(pdfPath);
|
||||||
|
final out = <String>[];
|
||||||
|
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<String?> _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<Uint8List?> _bgraToPng(
|
||||||
|
Uint8List bgra,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
) async {
|
||||||
|
final completer = Completer<ui.Image>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,12 @@
|
|||||||
// pages,
|
// pages,
|
||||||
// * the handwriting OCR text persisted in the sidecar's `ocrText` field.
|
// * 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
|
// It ALSO indexes a file-backed PDF's document body text, captured once at
|
||||||
// the REPORT in the task / the class doc below). Matching uses the existing pure
|
// import into the sidecar's `pageText` field by [PdfTextIndexer]: the embedded
|
||||||
// search primitives (normalize / rank / snippet), so CJK substring search works.
|
// (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';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -71,13 +74,14 @@ class VaultSearchHit {
|
|||||||
/// source of truth).
|
/// source of truth).
|
||||||
///
|
///
|
||||||
/// HONEST SCOPE (what search covers / does NOT):
|
/// HONEST SCOPE (what search covers / does NOT):
|
||||||
/// * COVERS: note/doc titles, typed text boxes, and handwriting OCR text that
|
/// * COVERS: note/doc titles, typed text boxes, handwriting OCR text persisted
|
||||||
/// has been persisted into a sidecar's `ocrText` field.
|
/// into a sidecar's `ocrText` field, AND a PDF's document body text persisted
|
||||||
/// * DOES NOT cover: a PDF's embedded (printed) text layer — only the user's
|
/// into `pageText` at import — the embedded text layer, or background OCR of
|
||||||
/// annotations are indexed, not the underlying document body. Indexing the
|
/// a rasterized/scanned PDF (see [PdfTextIndexer]).
|
||||||
/// PDF text layer would require rendering each page through pdfrx at scan
|
/// * CAVEAT: `pageText` is only present once import-time indexing has run and
|
||||||
/// time; deferred. OCR is only present where it has already been run and
|
/// written it back to the sidecar. A PDF imported before this feature (or
|
||||||
/// written back to the sidecar.
|
/// whose OCR backend was unavailable) has no `pageText`, so only its
|
||||||
|
/// annotations are searchable until it is re-indexed.
|
||||||
class VaultSearchIndex {
|
class VaultSearchIndex {
|
||||||
VaultSearchIndex(this._vault);
|
VaultSearchIndex(this._vault);
|
||||||
|
|
||||||
@@ -174,6 +178,11 @@ class VaultSearchIndex {
|
|||||||
}
|
}
|
||||||
final ocr = sidecar.ocrText;
|
final ocr = sidecar.ocrText;
|
||||||
if (ocr != null && ocr.trim().isNotEmpty) parts.add(ocr.trim());
|
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');
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ class BadnoteSidecar {
|
|||||||
List<SidecarScratchLink>? scratchLinks,
|
List<SidecarScratchLink>? scratchLinks,
|
||||||
Map<int, String>? legacyAnnotations,
|
Map<int, String>? legacyAnnotations,
|
||||||
this.ocrText,
|
this.ocrText,
|
||||||
|
this.pageText,
|
||||||
this.legacyId,
|
this.legacyId,
|
||||||
this.background,
|
this.background,
|
||||||
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
|
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
|
||||||
@@ -368,6 +369,17 @@ class BadnoteSidecar {
|
|||||||
/// the notebook has no handwriting or OCR hasn't run.
|
/// the notebook has no handwriting or OCR hasn't run.
|
||||||
final String? ocrText;
|
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`
|
/// 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
|
/// 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
|
/// 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,
|
entry.key.toString(): entry.value,
|
||||||
},
|
},
|
||||||
if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText,
|
if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText,
|
||||||
|
if (pageText != null && pageText!.isNotEmpty) 'pageText': pageText,
|
||||||
if (legacyId != null) 'legacyId': legacyId,
|
if (legacyId != null) 'legacyId': legacyId,
|
||||||
if (background != null) 'background': background,
|
if (background != null) 'background': background,
|
||||||
};
|
};
|
||||||
@@ -470,6 +483,7 @@ class BadnoteSidecar {
|
|||||||
return out;
|
return out;
|
||||||
}(),
|
}(),
|
||||||
ocrText: json['ocrText'] as String?,
|
ocrText: json['ocrText'] as String?,
|
||||||
|
pageText: json['pageText'] as String?,
|
||||||
legacyId: json['legacyId'] as String?,
|
legacyId: json['legacyId'] as String?,
|
||||||
background: json['background'] as String?,
|
background: json['background'] as String?,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -379,6 +379,46 @@ void main() {
|
|||||||
expect(reparsed.background, isNull);
|
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', () {
|
test('missing scratchpad defaults to 4000x4000 empty pad', () {
|
||||||
final json = SidecarScratchLink(
|
final json = SidecarScratchLink(
|
||||||
link: const ScratchLink(
|
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,
|
// * the title / source filename,
|
||||||
// * a typed text box (EditorStroke.textContent),
|
// * a typed text box (EditorStroke.textContent),
|
||||||
// * the persisted handwriting OCR text (sidecar `ocrText`),
|
// * 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).
|
// * 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';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ void main() {
|
|||||||
required String pdfName,
|
required String pdfName,
|
||||||
List<EditorStroke> page0 = const [],
|
List<EditorStroke> page0 = const [],
|
||||||
String? ocrText,
|
String? ocrText,
|
||||||
|
String? pageText,
|
||||||
}) async {
|
}) async {
|
||||||
final dir = Directory(p.join(vaultDir.path, folder));
|
final dir = Directory(p.join(vaultDir.path, folder));
|
||||||
await dir.create(recursive: true);
|
await dir.create(recursive: true);
|
||||||
@@ -66,6 +68,7 @@ void main() {
|
|||||||
docType: 'pdf',
|
docType: 'pdf',
|
||||||
strokes: page0.isEmpty ? null : {0: page0},
|
strokes: page0.isEmpty ? null : {0: page0},
|
||||||
ocrText: ocrText,
|
ocrText: ocrText,
|
||||||
|
pageText: pageText,
|
||||||
createdAt: DateTime.now().toUtc(),
|
createdAt: DateTime.now().toUtc(),
|
||||||
);
|
);
|
||||||
await SidecarStore.writeAtomic(
|
await SidecarStore.writeAtomic(
|
||||||
@@ -173,11 +176,39 @@ void main() {
|
|||||||
expect(await index.search('x'), isEmpty);
|
expect(await index.search('x'), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('KNOWN GAP: a PDF embedded text layer is NOT indexed', () async {
|
test('finds a PDF by its embedded text layer (sidecar pageText)', () async {
|
||||||
// The sidecar carries no annotations; only the PDF body would contain the
|
// The PDF body text captured at import lives in the sidecar's `pageText`.
|
||||||
// word "bodytext". Search does NOT read the PDF text layer (documented
|
// No annotations at all — only the document body contains "bodytext".
|
||||||
// limitation), so this returns nothing.
|
await seedDocNotebook(
|
||||||
await seedDocNotebook(folder: 'Plain', pdfName: 'plain.pdf');
|
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);
|
final index = VaultSearchIndex(vault);
|
||||||
expect(await index.search('bodytext'), isEmpty);
|
expect(await index.search('bodytext'), isEmpty);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user