Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ version and date. Earlier releases are described in their

## [Unreleased]

### Added

- `processPdf(buffer, pages, { readInvisibleTextLayer: true })`, and the same
option on `processPdfAsync`, `DetectionConfig::read_invisible_text_layer`
and `pdf2md --read-invisible-text-layer`, read the invisible OCR text layer
(render mode 3) of a scanned page as its text. A layer of at least 40
letters or digits that is not garbage is read, and its page is not
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
flagged `invisible_text_layer`; a shorter layer keeps the page on the OCR
path. A page whose only visible text is a small stamp over such a layer,
like an archive's download footer, returns the layer too. Off by
default; output without the option is unchanged. Rust code that builds
`DetectionConfig` with a struct literal must add
`read_invisible_text_layer` or end with `..Default::default()`.

### Fixed

- A TrueType subset with no `cmap`, no glyph names and no ToUnicode that
Expand Down
38 changes: 33 additions & 5 deletions napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -675,11 +675,18 @@ where
// Shared implementations (single body behind sync and async entry points)
// ---------------------------------------------------------------------------

fn process_pdf_impl(bytes: &[u8], pages: Option<Vec<u32>>) -> Result<PdfResult> {
fn process_pdf_impl(
bytes: &[u8],
pages: Option<Vec<u32>>,
options: Option<ProcessOptions>,
) -> Result<PdfResult> {
let mut opts = pdf_inspector::PdfOptions::new();
if let Some(p) = pages {
opts = opts.pages(p);
}
opts.detection.read_invisible_text_layer = options
.and_then(|o| o.read_invisible_text_layer)
.unwrap_or(false);
let result = pdf_inspector::process_pdf_mem_with_options(bytes, opts)
.map_err(|e| to_napi_err(e, "process_pdf"))?;
Ok(to_napi_result(result))
Expand Down Expand Up @@ -709,9 +716,23 @@ fn classify_pdf_impl(bytes: &[u8]) -> Result<PdfClassification> {

/// Process a PDF from a Buffer: detect type, extract text, and convert to Markdown.
#[napi]
pub fn process_pdf(buffer: Buffer, pages: Option<Vec<u32>>) -> Result<PdfResult> {
pub fn process_pdf(
buffer: Buffer,
pages: Option<Vec<u32>>,
options: Option<ProcessOptions>,
) -> Result<PdfResult> {
let bytes: Vec<u8> = buffer.to_vec();
catch_panic("process_pdf", move || process_pdf_impl(&bytes, pages))
catch_panic("process_pdf", move || process_pdf_impl(&bytes, pages, options))
}

/// Options for `processPdf`.
#[napi(object)]
#[derive(Clone, Default)]
pub struct ProcessOptions {
/// Read a page whose only text is an invisible layer under a covering
/// image (the OCR layer of a scan) as text, instead of flagging it
/// `invisible_text_layer` in `pagesNeedingOcr`. `false` by default.
pub read_invisible_text_layer: Option<bool>,
}

/// Fast detection only — no text extraction or markdown.
Expand Down Expand Up @@ -1421,6 +1442,7 @@ fn to_page_region_texts(results: Vec<pdf_inspector::PageRegionResult>) -> Vec<Pa
pub struct ProcessPdfTask {
bytes: Vec<u8>,
pages: Option<Vec<u32>>,
options: Option<ProcessOptions>,
}

impl Task for ProcessPdfTask {
Expand All @@ -1430,11 +1452,12 @@ impl Task for ProcessPdfTask {
fn compute(&mut self) -> Result<Self::Output> {
let bytes = std::mem::take(&mut self.bytes);
let pages = self.pages.take();
let options = self.options.take();
// AssertUnwindSafe: `bytes`/`pages` are moved into the closure and
// dropped on unwind — no shared state can be observed broken.
catch_panic(
"process_pdf",
panic::AssertUnwindSafe(move || process_pdf_impl(&bytes, pages)),
panic::AssertUnwindSafe(move || process_pdf_impl(&bytes, pages, options)),
)
}

Expand All @@ -1450,10 +1473,15 @@ impl Task for ProcessPdfTask {
// ts_return_type is required: napi-rs emits `Promise<unknown>` for
// `AsyncTask<T>` returns without it.
#[napi(ts_return_type = "Promise<PdfResult>")]
pub fn process_pdf_async(buffer: Buffer, pages: Option<Vec<u32>>) -> AsyncTask<ProcessPdfTask> {
pub fn process_pdf_async(
buffer: Buffer,
pages: Option<Vec<u32>>,
options: Option<ProcessOptions>,
) -> AsyncTask<ProcessPdfTask> {
AsyncTask::new(ProcessPdfTask {
bytes: buffer.to_vec(),
pages,
options,
})
}

Expand Down
3 changes: 3 additions & 0 deletions src/bin/pdf2md.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ fn print_usage(argv0: &str) {
eprintln!(" --pages Insert page break markers (<!-- Page N -->)");
eprintln!(" --select-pages N Only process specified pages (e.g. 1,3,5-10)");
eprintln!(" --password PW Password for an encrypted PDF");
eprintln!(" --read-invisible-text-layer Read a scan's invisible OCR text layer as its text");
eprintln!(" --detect-only Only detect PDF type (no extraction)");
eprintln!(" --analyze Detect + extract + layout analysis (no markdown)");
eprintln!(" --ocr MODE OCR mode: off, auto, or force (requires feature `ocr`)");
Expand Down Expand Up @@ -821,6 +822,8 @@ fn main() {
options.page_filter = Some(pages);
}
options.password = password;
options.detection.read_invisible_text_layer =
args.iter().any(|a| a == "--read-invisible-text-layer");

match process_pdf_with_options(pdf_path, options) {
Ok(result) => {
Expand Down
49 changes: 47 additions & 2 deletions src/detector.rs
Comment thread
logancyang marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ pub struct DetectionConfig {
pub min_text_ops_per_page: u32,
/// Threshold ratio of text pages to total pages for classification
pub text_page_ratio_threshold: f32,
/// Read a page whose only text is an invisible layer under a covering
/// image (an OCR layer) as a text page, and extract that layer, instead
/// of flagging the page `invisible_text_layer` for OCR. Off by default.
pub read_invisible_text_layer: bool,
}

impl Default for DetectionConfig {
Expand All @@ -117,6 +121,7 @@ impl Default for DetectionConfig {
strategy: ScanStrategy::Sample(8),
min_text_ops_per_page: 3,
text_page_ratio_threshold: 0.6,
read_invisible_text_layer: false,
}
}
}
Expand Down Expand Up @@ -251,10 +256,13 @@ pub(crate) fn detect_from_document(
let mut pages_actually_sampled = 0u32;
// Pages read whose form content ran past the scan's byte budget.
let mut pages_past_form_budget = 0u32;
// Reads hidden layers as `read_invisible_text_layer` extraction does.
let mut layer_probe = crate::extractor::InvisibleLayerProbe::default();

for page_num in &sample_indices {
if let Some(&page_id) = pages.get(page_num) {
let analysis = analyze_page_content(doc, page_id);
let analysis =
analyze_page_content_with(doc, *page_num, page_id, config, &mut layer_probe);
pages_actually_sampled += 1;
log::debug!(
"page {}: text_ops={} executed_text_ops={} hidden_text_ops={} images={} image_count={} template={} covering_image={} form_bytes={} form_bytes_exceeded={} unique_chars={} alphanum={} path_ops={} vector_text={} image_area={} identity_h_no_tounicode={} type3_only={} font_changes={} decodable_fonts={}",
Expand Down Expand Up @@ -309,9 +317,13 @@ pub(crate) fn detect_from_document(
// A page whose text is all invisible under an image covering
// the page is a scan however many operators the layer has:
// the page shows its raster, and the layer only describes it.
// A layer read as text keeps the page a scan here, so the
// document is Mixed and each page, sampled or not, is judged
// on its own below.
if (analysis.has_template_image
&& (analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_ok))
|| analysis.has_invisible_text_layer
|| analysis.has_readable_invisible_text_layer
Comment thread
logancyang marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
{
pages_with_template_images += 1;
}
Expand Down Expand Up @@ -439,7 +451,8 @@ pub(crate) fn detect_from_document(
// Cache the fresh analysis so the reason-classification pass
// below sees the real signals (vector_text, etc.) instead of
// defaulting to "scanned".
let a = analyze_page_content(doc, page_id);
let a =
analyze_page_content_with(doc, page_num, page_id, config, &mut layer_probe);
analysis_cache.insert(page_num, a.clone());
a
} else {
Expand Down Expand Up @@ -639,6 +652,10 @@ struct PageAnalysis {
/// sees. What the layer says is not what the page shows, so the page
/// is read from its raster.
has_invisible_text_layer: bool,
/// With `read_invisible_text_layer`, the page's invisible layer is one
/// extraction keeps, so the page is read as text and
/// `has_invisible_text_layer` is cleared.
has_readable_invisible_text_layer: bool,
/// Bytes of Form XObject and pattern-cell content the page's content
/// executed, and whether a form went unread because the page's budget
/// of them ran out — the page then keeps the classification it had
Expand Down Expand Up @@ -866,6 +883,33 @@ fn resolve_with_shadowing(
}
}

/// [`analyze_page_content`] with the invisible-layer signal moved to
/// `has_readable_invisible_text_layer` when the configuration reads such
/// layers as text and extraction keeps the page's layer.
fn analyze_page_content_with(
doc: &Document,
page_num: u32,
page_id: ObjectId,
config: &DetectionConfig,
layer_probe: &mut crate::extractor::InvisibleLayerProbe,
) -> PageAnalysis {
let mut analysis = analyze_page_content(doc, page_id);
if config.read_invisible_text_layer
&& analysis.has_invisible_text_layer
&& layer_probe.keeps_layer(doc, page_id, page_num)
{
analysis.has_invisible_text_layer = false;
Comment thread
logancyang marked this conversation as resolved.
Outdated
Comment thread
logancyang marked this conversation as resolved.
Outdated
analysis.has_readable_invisible_text_layer = true;
}
analysis
}

/// Whether the images a page's content draws cover the page — see
/// `PageAnalysis::has_covering_image`.
pub(crate) fn page_has_covering_image(doc: &Document, page_id: ObjectId) -> bool {
content_scan::page_executed_content(doc, page_id).covers_page
}

/// Analyze a page's content stream for text operators and images
fn analyze_page_content(doc: &Document, page_id: ObjectId) -> PageAnalysis {
let mut counts = ContentCounts::default();
Expand Down Expand Up @@ -1015,6 +1059,7 @@ fn analyze_page_content(doc: &Document, page_id: ObjectId) -> PageAnalysis {
has_template_image,
has_covering_image,
has_invisible_text_layer,
has_readable_invisible_text_layer: false,
executed_form_bytes,
form_bytes_exceeded,
total_image_area,
Expand Down
5 changes: 5 additions & 0 deletions src/extractor/content_stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,10 @@ impl ActualTextBounds {
pub(crate) struct TextExtractionOptions {
/// Keep invisible (Tr 3) text instead of skipping it.
pub(crate) include_invisible: bool,
/// Per page of a document extraction: when the visible pass skipped
/// invisible text, read the page again with its invisible layer (the
/// OCR layer of a scan) and keep that when it dominates the page.
pub(crate) invisible_layer_fallback: bool,
/// Read bold from the weight class too — see
/// `PositionOptions::bold_from_weight`.
pub(crate) bold_from_weight: bool,
Expand All @@ -366,6 +370,7 @@ impl Default for TextExtractionOptions {
fn default() -> Self {
Self {
include_invisible: false,
invisible_layer_fallback: false,
bold_from_weight: false,
bold_weight_threshold: DEFAULT_BOLD_WEIGHT_THRESHOLD,
cmap_coverage: false,
Expand Down
Loading