Files
BadNote/lib/services/pdfrx_page_text_source.dart
Akiba So e939759458
All checks were successful
CI / Windows build (push) Successful in 15m50s
feat(search): index PDF text, OCR scanned PDFs on import
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.
2026-06-25 00:23:19 +08:00

112 lines
3.4 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}
}
}