Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ version and date. Earlier releases are described in their

### Added

- `OcrModelSet`: selects which pinned, checksum-verified model set the OCR
engine loads. `PpOcrV6Small` is the default and unchanged; the new
`PpOcrV5Korean` set pairs the script-agnostic PP-OCRv5 mobile detector with
the Korean PP-OCRv5 mobile recogniser and its dictionary (all 11,172 Hangul
syllables plus Latin letters and digits, ~18 MB in total). Exposed as
`OcrOptions::model_set(...)`, the `PP_OCR_V5_KOREAN` manifest, and
`pdf2md --ocr-model-set pp-ocrv5-korean`. Each set resolves, downloads, and
caches under its own manifest id and revision, and the in-process engine
cache is keyed by the selected set, so switching sets never mixes artifacts.

- `TextItem::baseline_shift`: signed offset, in points, of a superscript or
subscript glyph run from the baseline of the body text it is attached to
(positive = raised, negative = lowered, `0` for normal text). Exposed as
Expand Down Expand Up @@ -103,6 +113,11 @@ version and date. Earlier releases are described in their

### Changed

- `OcrOptions` gained the public `model_set` field. Construct it through
`OcrOptions::new()`/`Default` and the builder methods, as the docs show;
a struct literal that spells out every field needs the new field. This
follows the crate's existing practice for public structs (`TextItem`
gained `rotation`, `advance_known`, and `baseline_shift` in this cycle).
- **Coordinate frame of positioned output — consumer action may be required.**
`extract_text_with_positions*` (Rust), `extractTextWithPositions` (Node),
`extract_text_with_positions[_bytes]` (Python) and `pdf2md --items-json` now
Expand Down
31 changes: 31 additions & 0 deletions docs/ocr-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,37 @@ The reproducible runtime path uses these builds:
Use these versions for the reproducible path. Other compatible shared-library
builds may work, but are not part of the release smoke test.

## Model sets

The recogniser and its character dictionary decide which scripts OCR can
read; detection is script-agnostic. `OcrModelSet` selects one pinned,
checksum-verified set. The default is unchanged.

| `OcrModelSet` | Identifier | Files (revision) | Scripts | Download size |
|---|---|---|---|---|
| `PpOcrV6Small` (default) | `pp-ocrv6-small` | `pp-ocrv6_small_det.onnx`, `pp-ocrv6_small_rec.onnx`, `ppocrv6_dict.txt` (`oar-ocr-v0.7.0`) | Latin scripts, Simplified and Traditional Chinese, Japanese | about 31 MB |
| `PpOcrV5Korean` | `pp-ocrv5-korean` | `pp-ocrv5_mobile_det.onnx`, `korean_pp-ocrv5_mobile_rec.onnx`, `ppocrv5_korean_dict.txt` (`oar-ocr-v0.3.0`) | Korean (all 11,172 Hangul syllables), Latin letters, digits | about 18 MB |

```bash
pdf2md scan-ko.pdf --ocr auto --ocr-model-set pp-ocrv5-korean --json
```

```rust
use pdf_inspector::vision::{OcrMode, OcrModelSet, OcrOptions, OcrPdfOptions};

let ocr = OcrOptions::new()
.mode(OcrMode::Auto)
.model_set(OcrModelSet::PpOcrV5Korean);
let options = OcrPdfOptions::new().ocr(ocr);
```

Each set is cached under its own `<id>/<revision>` directory, and the
in-process engine cache is keyed by the selected set, so switching sets in a
long-lived worker replaces the loaded sessions instead of mixing artifacts.
For offline packaging, populate the model directory with the three files of
the chosen set (`--ocr-offline --ocr-model-dir`); the directory is verified
against that set's manifest.

## Install the shared libraries

Download and extract the matching archives:
Expand Down
12 changes: 9 additions & 3 deletions docs/rust-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ separate `model-cache` feature adds pinned artifact management:

- `PageRenderer` and `OcrEngine` traits;
- renderer-neutral owned page buffers and affine pixel↔PDF transforms;
- `OcrOptions` and opt-in `Off`/`Auto`/`Force` routing modes;
- `OcrOptions` and opt-in `Off`/`Auto`/`Force` routing modes, plus
`OcrModelSet` to choose the pinned model set (default PP-OCRv6 Small, or
PP-OCRv5 Korean);
- positioned OCR results and per-page provenance types; and
- a versioned PP-OCRv6 Small manifest with checksum-verified, locked, atomic
model-cache installation and explicit offline-directory overrides.
Expand Down Expand Up @@ -431,8 +433,12 @@ pdf2md document.pdf --ocr auto --ocr-offline --ocr-model-dir /opt/models/pp-ocrv
```

CLI controls include `--ocr-dpi`, `--ocr-min-confidence`,
`--ocr-hosted-threshold`, `--select-pages`, and the existing encrypted-PDF
`--password` option. JSON output has `schema_version: 1` and includes per-page Markdown, source/model
`--ocr-hosted-threshold`, `--ocr-model-set`, `--select-pages`, and the
existing encrypted-PDF `--password` option. `--ocr-model-set` (Rust:
`OcrOptions::model_set`, see `OcrModelSet`) picks the pinned model set;
`pp-ocrv6-small` is the default and `pp-ocrv5-korean` adds Korean. The
[OCR runtime setup guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md#model-sets)
lists the files and scripts of every set. JSON output has `schema_version: 1` and includes per-page Markdown, source/model
provenance, confidence, timings, warnings, routed pages, and hosted-fallback
recommendations. Page numbers in `OcrPdfResult` and its per-page provenance
are 1-indexed, matching the PDF page numbers accepted by
Expand Down
29 changes: 27 additions & 2 deletions src/bin/pdf2md.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
use pdf_inspector::extractor::ItemType;
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
use pdf_inspector::vision::{
process_pdf_with_ocr, ModelDownloadPolicy, OcrMode, OcrOptions, OcrPdfOptions, OcrPdfResult,
PageContentSource, RenderOptions,
process_pdf_with_ocr, ModelDownloadPolicy, OcrMode, OcrModelSet, OcrOptions, OcrPdfOptions,
OcrPdfResult, PageContentSource, RenderOptions,
};
use pdf_inspector::{
extract_text_with_positions_pages_with_password, process_pdf_with_options, LayoutComplexity,
Expand Down Expand Up @@ -426,6 +426,9 @@ fn main() {
eprintln!(" --ocr-min-confidence N Drop OCR spans below N (default: 0)");
eprintln!(" --ocr-hosted-threshold N Recommend hosted parsing below N (default: 0.5)");
eprintln!(" --ocr-model-dir DIR Use a package-managed local model directory");
eprintln!(
" --ocr-model-set ID Pinned model set: pp-ocrv6-small (default) or pp-ocrv5-korean"
);
eprintln!(" --ocr-offline Never download missing OCR models");
process::exit(1);
}
Expand Down Expand Up @@ -482,6 +485,7 @@ fn main() {
"--ocr-min-confidence",
"--ocr-hosted-threshold",
"--ocr-model-dir",
"--ocr-model-set",
"--ocr-offline",
]
.iter()
Expand Down Expand Up @@ -539,12 +543,33 @@ fn main() {
exit_ocr_error(&error, json_output);
});

let model_set = argument_value(&args, "--ocr-model-set")
.unwrap_or_else(|error| {
exit_ocr_error(&error, json_output);
})
.map(|value| {
OcrModelSet::parse(value).unwrap_or_else(|| {
let expected: Vec<&str> =
OcrModelSet::ALL.iter().map(|set| set.id()).collect();
exit_ocr_error(
&format!(
"invalid --ocr-model-set {value:?}; expected one of: {}",
expected.join(", ")
),
json_output,
);
})
});

let mut ocr = OcrOptions::new()
.mode(mode)
.minimum_confidence(minimum_confidence);
if let Some(directory) = model_directory {
ocr = ocr.model_directory(directory);
}
if let Some(model_set) = model_set {
ocr = ocr.model_set(model_set);
}
if args.iter().any(|argument| argument == "--ocr-offline") {
ocr = ocr.model_downloads(ModelDownloadPolicy::Offline);
}
Expand Down
86 changes: 86 additions & 0 deletions src/vision/contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,57 @@ pub enum ModelDownloadPolicy {
Offline,
}

/// Selects the pinned model set an OCR engine loads.
///
/// Every variant maps to exactly one checksum-verified model manifest, so
/// downloads, offline directories, and the in-process engine cache stay
/// deterministic per set. Detection models are script-agnostic; the variants
/// differ in the recognition model and its character dictionary.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum OcrModelSet {
/// PP-OCRv6 Small: Latin scripts, Simplified and Traditional Chinese, and
/// Japanese. This is the default and preserves existing behavior.
#[default]
PpOcrV6Small,
/// PP-OCRv5 Korean: all 11,172 Hangul syllables plus Latin letters and
/// digits, paired with the PP-OCRv5 mobile detector.
PpOcrV5Korean,
}

impl OcrModelSet {
/// Every supported model set, in identifier order.
pub const ALL: &'static [OcrModelSet] =
&[OcrModelSet::PpOcrV6Small, OcrModelSet::PpOcrV5Korean];

/// Stable identifier, equal to the `id` of the manifest the set resolves to.
pub fn id(self) -> &'static str {
match self {
OcrModelSet::PpOcrV6Small => "pp-ocrv6-small",
OcrModelSet::PpOcrV5Korean => "pp-ocrv5-korean",
}
}

/// Parses an identifier as printed by [`OcrModelSet::id`].
///
/// Matching ignores case and surrounding whitespace and accepts `_` in
/// place of `-`, so `PP_OCRv5_Korean` resolves to
/// [`OcrModelSet::PpOcrV5Korean`].
pub fn parse(value: &str) -> Option<Self> {
let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
OcrModelSet::ALL
.iter()
.copied()
.find(|set| set.id() == normalized)
}
}

impl std::fmt::Display for OcrModelSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.id())
}
}

/// OCR engine configuration independent of a particular runtime.
#[derive(Debug, Clone, PartialEq)]
pub struct OcrOptions {
Expand All @@ -40,6 +91,8 @@ pub struct OcrOptions {
pub model_directory: Option<PathBuf>,
/// Whether a missing pinned artifact may be downloaded.
pub model_downloads: ModelDownloadPolicy,
/// Which pinned model set the engine loads.
pub model_set: OcrModelSet,

@cubic-dev-ai cubic-dev-ai Bot Sep 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Adding this field breaks downstream OcrOptions { ... } struct literals because OcrOptions is public and not #[non_exhaustive]. Preserve source compatibility with a separate versioned options type/API, or treat this as a semver-breaking change and release it under the appropriate major-version policy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/vision/contracts.rs, line 95:

<comment>Adding this field breaks downstream `OcrOptions { ... }` struct literals because `OcrOptions` is public and not `#[non_exhaustive]`. Preserve source compatibility with a separate versioned options type/API, or treat this as a semver-breaking change and release it under the appropriate major-version policy.</comment>

<file context>
@@ -40,6 +91,8 @@ pub struct OcrOptions {
     /// Whether a missing pinned artifact may be downloaded.
     pub model_downloads: ModelDownloadPolicy,
+    /// Which pinned model set the engine loads.
+    pub model_set: OcrModelSet,
 }
 
</file context>
Fix with cubic

}

impl Default for OcrOptions {
Expand All @@ -49,6 +102,7 @@ impl Default for OcrOptions {
minimum_confidence: 0.0,
model_directory: None,
model_downloads: ModelDownloadPolicy::IfMissing,
model_set: OcrModelSet::PpOcrV6Small,
}
}
}
Expand Down Expand Up @@ -82,6 +136,12 @@ impl OcrOptions {
self.model_downloads = policy;
self
}

/// Selects the pinned model set the OCR engine loads.
pub fn model_set(mut self, model_set: OcrModelSet) -> Self {
self.model_set = model_set;
self
}
}

/// A point in bitmap space, measured from the top-left in pixels.
Expand Down Expand Up @@ -269,4 +329,30 @@ mod tests {
Some(PathBuf::from("/models/pp-ocr"))
);
}

#[test]
fn model_set_defaults_to_pp_ocr_v6_small() {
assert_eq!(OcrOptions::default().model_set, OcrModelSet::PpOcrV6Small);
assert_eq!(OcrModelSet::default(), OcrModelSet::PpOcrV6Small);
let options = OcrOptions::new().model_set(OcrModelSet::PpOcrV5Korean);
assert_eq!(options.model_set, OcrModelSet::PpOcrV5Korean);
}

#[test]
fn model_set_identifiers_round_trip() {
for set in OcrModelSet::ALL {
assert_eq!(OcrModelSet::parse(set.id()), Some(*set));
assert_eq!(set.to_string(), set.id());
}
assert_eq!(
OcrModelSet::parse(" PP_OCRv5_Korean "),
Some(OcrModelSet::PpOcrV5Korean)
);
assert_eq!(
OcrModelSet::parse("pp-ocrv6-small"),
Some(OcrModelSet::PpOcrV6Small)
);
assert_eq!(OcrModelSet::parse("korean"), None);
assert_eq!(OcrModelSet::parse(""), None);
}
}
6 changes: 3 additions & 3 deletions src/vision/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ mod pdfium;

#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
pub use contracts::{
ImagePoint, ImageQuad, ModelDownloadPolicy, ModelIdentity, OcrEngine, OcrMode, OcrOptions,
OcrPage, OcrSpan, PageContentSource, PageProvenance, PageRenderer, VisionTimings,
ImagePoint, ImageQuad, ModelDownloadPolicy, ModelIdentity, OcrEngine, OcrMode, OcrModelSet,
OcrOptions, OcrPage, OcrSpan, PageContentSource, PageProvenance, PageRenderer, VisionTimings,
};
#[cfg(all(feature = "model-download", not(target_arch = "wasm32")))]
pub use download::{HttpModelDownloadError, HttpModelDownloader, DEFAULT_MODEL_DOWNLOAD_TIMEOUT};
Expand All @@ -45,7 +45,7 @@ pub use fusion::{
#[cfg(all(feature = "model-cache", not(target_arch = "wasm32")))]
pub use models::{
ModelAcquireError, ModelArtifact, ModelArtifactKind, ModelDownloader, ModelManifest,
ModelPaths, ModelStore, ModelStoreError, PP_OCR_V6_SMALL,
ModelPaths, ModelStore, ModelStoreError, PP_OCR_V5_KOREAN, PP_OCR_V6_SMALL,
};
#[cfg(all(feature = "ocr-oar", not(target_arch = "wasm32")))]
pub use oar::{OarOcrEngine, OarOcrError, ONNX_RUNTIME_LIBRARY_ENV};
Expand Down
Loading