unify NAPI and Python binding APIs for consistent surface
Both bindings now expose the same 6 function families: process, detect, classify, extractText, extractTextWithPositions, and extractTextInRegions. Bumps PyO3 from 0.22 to 0.25 for Python 3.14 support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5f829f5258
commit
506b2a0c70
+1
-1
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
napi_build::setup();
|
||||
}
|
||||
|
||||
Vendored
+48
-3
@@ -1,11 +1,18 @@
|
||||
/* auto-generated by NAPI-RS */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Classify a PDF: detect type (TextBased/Scanned/Mixed/ImageBased),
|
||||
* page count, and which pages need OCR. Takes PDF bytes as Buffer.
|
||||
* Lightweight PDF classification — returns type, page count, and OCR pages.
|
||||
* Faster than detectPdf as it skips building the full PdfResult.
|
||||
* Pages in pagesNeedingOcr are 0-indexed.
|
||||
*/
|
||||
export declare function classifyPdf(buffer: Buffer): PdfClassification
|
||||
|
||||
/** Fast detection only — no text extraction or markdown. */
|
||||
export declare function detectPdf(buffer: Buffer): PdfResult
|
||||
|
||||
/** Extract plain text from a PDF Buffer. */
|
||||
export declare function extractText(buffer: Buffer): string
|
||||
|
||||
/**
|
||||
* Extract text within bounding-box regions from a PDF.
|
||||
*
|
||||
@@ -13,13 +20,16 @@ export declare function classifyPdf(buffer: Buffer): PdfClassification
|
||||
* this extracts PDF text within those regions — skipping GPU OCR
|
||||
* for text-based pages.
|
||||
*
|
||||
* Each region result includes `needs_ocr` — set when the extracted text
|
||||
* Each region result includes `needsOcr` — set when the extracted text
|
||||
* is unreliable (empty, GID-encoded fonts, garbage, encoding issues).
|
||||
*
|
||||
* Coordinates are PDF points with top-left origin.
|
||||
*/
|
||||
export declare function extractTextInRegions(buffer: Buffer, pageRegions: Array<PageRegions>): Array<PageRegionTexts>
|
||||
|
||||
/** Extract text with position information from a PDF Buffer. */
|
||||
export declare function extractTextWithPositions(buffer: Buffer, pages?: Array<number> | undefined | null): Array<TextItem>
|
||||
|
||||
/** A page's regions for text extraction: (page_index_0based, bboxes). */
|
||||
export interface PageRegions {
|
||||
page: number
|
||||
@@ -37,13 +47,48 @@ export interface PageRegionTexts {
|
||||
export interface PdfClassification {
|
||||
pdfType: string
|
||||
pageCount: number
|
||||
/** 0-indexed page numbers that need OCR. */
|
||||
pagesNeedingOcr: Array<number>
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/** Full PDF processing result with markdown and metadata. */
|
||||
export interface PdfResult {
|
||||
pdfType: string
|
||||
markdown?: string
|
||||
pageCount: number
|
||||
processingTimeMs: number
|
||||
/** 1-indexed page numbers that need OCR. */
|
||||
pagesNeedingOcr: Array<number>
|
||||
title?: string
|
||||
confidence: number
|
||||
isComplexLayout: boolean
|
||||
pagesWithTables: Array<number>
|
||||
pagesWithColumns: Array<number>
|
||||
hasEncodingIssues: boolean
|
||||
}
|
||||
|
||||
/** Process a PDF from a Buffer: detect type, extract text, and convert to Markdown. */
|
||||
export declare function processPdf(buffer: Buffer, pages?: Array<number> | undefined | null): PdfResult
|
||||
|
||||
/** Extracted text for a single region. */
|
||||
export interface RegionText {
|
||||
text: string
|
||||
/** `true` when the text should not be trusted (empty, GID fonts, garbage, encoding issues). */
|
||||
needsOcr: boolean
|
||||
}
|
||||
|
||||
/** A positioned text item extracted from a PDF. */
|
||||
export interface TextItem {
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
font: string
|
||||
fontSize: number
|
||||
page: number
|
||||
isBold: boolean
|
||||
isItalic: boolean
|
||||
itemType: string
|
||||
}
|
||||
|
||||
@@ -577,4 +577,8 @@ if (!nativeBinding) {
|
||||
|
||||
module.exports = nativeBinding
|
||||
module.exports.classifyPdf = nativeBinding.classifyPdf
|
||||
module.exports.detectPdf = nativeBinding.detectPdf
|
||||
module.exports.extractText = nativeBinding.extractText
|
||||
module.exports.extractTextInRegions = nativeBinding.extractTextInRegions
|
||||
module.exports.extractTextWithPositions = nativeBinding.extractTextWithPositions
|
||||
module.exports.processPdf = nativeBinding.processPdf
|
||||
|
||||
+204
-68
@@ -2,58 +2,201 @@
|
||||
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full PDF processing result with markdown and metadata.
|
||||
#[napi(object)]
|
||||
pub struct PdfResult {
|
||||
pub pdf_type: String,
|
||||
pub markdown: Option<String>,
|
||||
pub page_count: u32,
|
||||
pub processing_time_ms: u32,
|
||||
/// 1-indexed page numbers that need OCR.
|
||||
pub pages_needing_ocr: Vec<u32>,
|
||||
pub title: Option<String>,
|
||||
pub confidence: f64,
|
||||
pub is_complex_layout: bool,
|
||||
pub pages_with_tables: Vec<u32>,
|
||||
pub pages_with_columns: Vec<u32>,
|
||||
pub has_encoding_issues: bool,
|
||||
}
|
||||
|
||||
/// Lightweight PDF classification result.
|
||||
#[napi(object)]
|
||||
pub struct PdfClassification {
|
||||
pub pdf_type: String,
|
||||
pub page_count: u32,
|
||||
pub pages_needing_ocr: Vec<u32>,
|
||||
pub confidence: f64,
|
||||
pub pdf_type: String,
|
||||
pub page_count: u32,
|
||||
/// 0-indexed page numbers that need OCR.
|
||||
pub pages_needing_ocr: Vec<u32>,
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
/// A positioned text item extracted from a PDF.
|
||||
#[napi(object)]
|
||||
pub struct TextItem {
|
||||
pub text: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub font: String,
|
||||
pub font_size: f64,
|
||||
pub page: u32,
|
||||
pub is_bold: bool,
|
||||
pub is_italic: bool,
|
||||
pub item_type: String,
|
||||
}
|
||||
|
||||
/// A page's regions for text extraction: (page_index_0based, bboxes).
|
||||
#[napi(object)]
|
||||
pub struct PageRegions {
|
||||
pub page: u32,
|
||||
/// Each bbox is [x1, y1, x2, y2] in PDF points, top-left origin.
|
||||
pub regions: Vec<Vec<f64>>,
|
||||
pub page: u32,
|
||||
/// Each bbox is [x1, y1, x2, y2] in PDF points, top-left origin.
|
||||
pub regions: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
/// Extracted text for a single region.
|
||||
#[napi(object)]
|
||||
pub struct RegionText {
|
||||
pub text: String,
|
||||
/// `true` when the text should not be trusted (empty, GID fonts, garbage, encoding issues).
|
||||
pub needs_ocr: bool,
|
||||
pub text: String,
|
||||
/// `true` when the text should not be trusted (empty, GID fonts, garbage, encoding issues).
|
||||
pub needs_ocr: bool,
|
||||
}
|
||||
|
||||
/// Extracted text for one page's regions.
|
||||
#[napi(object)]
|
||||
pub struct PageRegionTexts {
|
||||
pub page: u32,
|
||||
pub regions: Vec<RegionText>,
|
||||
pub page: u32,
|
||||
pub regions: Vec<RegionText>,
|
||||
}
|
||||
|
||||
/// Classify a PDF: detect type (TextBased/Scanned/Mixed/ImageBased),
|
||||
/// page count, and which pages need OCR. Takes PDF bytes as Buffer.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn pdf_type_string(t: pdf_inspector::PdfType) -> String {
|
||||
match t {
|
||||
pdf_inspector::PdfType::TextBased => "TextBased".to_string(),
|
||||
pdf_inspector::PdfType::Scanned => "Scanned".to_string(),
|
||||
pdf_inspector::PdfType::ImageBased => "ImageBased".to_string(),
|
||||
pdf_inspector::PdfType::Mixed => "Mixed".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_napi_result(r: pdf_inspector::PdfProcessResult) -> PdfResult {
|
||||
PdfResult {
|
||||
pdf_type: pdf_type_string(r.pdf_type),
|
||||
markdown: r.markdown,
|
||||
page_count: r.page_count,
|
||||
processing_time_ms: r.processing_time_ms as u32,
|
||||
pages_needing_ocr: r.pages_needing_ocr,
|
||||
title: r.title,
|
||||
confidence: r.confidence as f64,
|
||||
is_complex_layout: r.layout.is_complex,
|
||||
pages_with_tables: r.layout.pages_with_tables,
|
||||
pages_with_columns: r.layout.pages_with_columns,
|
||||
has_encoding_issues: r.has_encoding_issues,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_type_string(t: &pdf_inspector::types::ItemType) -> String {
|
||||
match t {
|
||||
pdf_inspector::types::ItemType::Text => "text".into(),
|
||||
pdf_inspector::types::ItemType::Image => "image".into(),
|
||||
pdf_inspector::types::ItemType::Link(url) => format!("link:{url}"),
|
||||
pdf_inspector::types::ItemType::FormField => "form_field".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_napi_err(e: impl std::fmt::Display, ctx: &str) -> Error {
|
||||
Error::new(Status::GenericFailure, format!("{ctx}: {e}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public NAPI API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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> {
|
||||
let mut opts = pdf_inspector::PdfOptions::new();
|
||||
if let Some(p) = pages {
|
||||
opts = opts.pages(p);
|
||||
}
|
||||
let result = pdf_inspector::process_pdf_mem_with_options(&buffer, opts)
|
||||
.map_err(|e| to_napi_err(e, "process_pdf"))?;
|
||||
Ok(to_napi_result(result))
|
||||
}
|
||||
|
||||
/// Fast detection only — no text extraction or markdown.
|
||||
#[napi]
|
||||
pub fn detect_pdf(buffer: Buffer) -> Result<PdfResult> {
|
||||
let result =
|
||||
pdf_inspector::detect_pdf_mem(&buffer).map_err(|e| to_napi_err(e, "detect_pdf"))?;
|
||||
Ok(to_napi_result(result))
|
||||
}
|
||||
|
||||
/// Lightweight PDF classification — returns type, page count, and OCR pages.
|
||||
/// Faster than detectPdf as it skips building the full PdfResult.
|
||||
/// Pages in pagesNeedingOcr are 0-indexed.
|
||||
#[napi]
|
||||
pub fn classify_pdf(buffer: Buffer) -> Result<PdfClassification> {
|
||||
let result = pdf_inspector::classify_pdf_mem(&buffer).map_err(|e| {
|
||||
Error::new(Status::GenericFailure, format!("classify_pdf failed: {e}"))
|
||||
})?;
|
||||
let result =
|
||||
pdf_inspector::classify_pdf_mem(&buffer).map_err(|e| to_napi_err(e, "classify_pdf"))?;
|
||||
|
||||
Ok(PdfClassification {
|
||||
pdf_type: match result.pdf_type {
|
||||
pdf_inspector::PdfType::TextBased => "TextBased".to_string(),
|
||||
pdf_inspector::PdfType::Scanned => "Scanned".to_string(),
|
||||
pdf_inspector::PdfType::ImageBased => "ImageBased".to_string(),
|
||||
pdf_inspector::PdfType::Mixed => "Mixed".to_string(),
|
||||
},
|
||||
page_count: result.page_count,
|
||||
pages_needing_ocr: result.pages_needing_ocr,
|
||||
confidence: result.confidence as f64,
|
||||
})
|
||||
Ok(PdfClassification {
|
||||
pdf_type: pdf_type_string(result.pdf_type),
|
||||
page_count: result.page_count,
|
||||
pages_needing_ocr: result.pages_needing_ocr,
|
||||
confidence: result.confidence as f64,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract plain text from a PDF Buffer.
|
||||
#[napi]
|
||||
pub fn extract_text(buffer: Buffer) -> Result<String> {
|
||||
pdf_inspector::extractor::extract_text_mem(&buffer).map_err(|e| to_napi_err(e, "extract_text"))
|
||||
}
|
||||
|
||||
/// Extract text with position information from a PDF Buffer.
|
||||
#[napi]
|
||||
pub fn extract_text_with_positions(
|
||||
buffer: Buffer,
|
||||
pages: Option<Vec<u32>>,
|
||||
) -> Result<Vec<TextItem>> {
|
||||
let items = match pages {
|
||||
Some(p) => {
|
||||
let page_set: HashSet<u32> = p.into_iter().collect();
|
||||
pdf_inspector::extractor::extract_text_with_positions_mem_pages(
|
||||
&buffer,
|
||||
Some(&page_set),
|
||||
)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))?
|
||||
}
|
||||
None => pdf_inspector::extractor::extract_text_with_positions_mem(&buffer)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))?,
|
||||
};
|
||||
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.map(|item| TextItem {
|
||||
text: item.text,
|
||||
x: item.x as f64,
|
||||
y: item.y as f64,
|
||||
width: item.width as f64,
|
||||
height: item.height as f64,
|
||||
font: item.font,
|
||||
font_size: item.font_size as f64,
|
||||
page: item.page,
|
||||
is_bold: item.is_bold,
|
||||
is_italic: item.is_italic,
|
||||
item_type: item_type_string(&item.item_type),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Extract text within bounding-box regions from a PDF.
|
||||
@@ -62,55 +205,48 @@ pub fn classify_pdf(buffer: Buffer) -> Result<PdfClassification> {
|
||||
/// this extracts PDF text within those regions — skipping GPU OCR
|
||||
/// for text-based pages.
|
||||
///
|
||||
/// Each region result includes `needs_ocr` — set when the extracted text
|
||||
/// Each region result includes `needsOcr` — set when the extracted text
|
||||
/// is unreliable (empty, GID-encoded fonts, garbage, encoding issues).
|
||||
///
|
||||
/// Coordinates are PDF points with top-left origin.
|
||||
#[napi]
|
||||
pub fn extract_text_in_regions(
|
||||
buffer: Buffer,
|
||||
page_regions: Vec<PageRegions>,
|
||||
buffer: Buffer,
|
||||
page_regions: Vec<PageRegions>,
|
||||
) -> Result<Vec<PageRegionTexts>> {
|
||||
// Convert from napi types to the Rust API's expected format
|
||||
let regions: Vec<(u32, Vec<[f32; 4]>)> = page_regions
|
||||
.iter()
|
||||
.map(|pr| {
|
||||
let bboxes: Vec<[f32; 4]> = pr
|
||||
.regions
|
||||
let regions: Vec<(u32, Vec<[f32; 4]>)> = page_regions
|
||||
.iter()
|
||||
.map(|r| {
|
||||
if r.len() != 4 {
|
||||
[0.0, 0.0, 0.0, 0.0]
|
||||
} else {
|
||||
[r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]
|
||||
}
|
||||
.map(|pr| {
|
||||
let bboxes: Vec<[f32; 4]> = pr
|
||||
.regions
|
||||
.iter()
|
||||
.map(|r| {
|
||||
if r.len() != 4 {
|
||||
[0.0, 0.0, 0.0, 0.0]
|
||||
} else {
|
||||
[r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(pr.page, bboxes)
|
||||
})
|
||||
.collect();
|
||||
(pr.page, bboxes)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = pdf_inspector::extract_text_in_regions_mem(&buffer, ®ions).map_err(|e| {
|
||||
Error::new(
|
||||
Status::GenericFailure,
|
||||
format!("extract_text_in_regions failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
let results = pdf_inspector::extract_text_in_regions_mem(&buffer, ®ions)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_in_regions"))?;
|
||||
|
||||
Ok(
|
||||
results
|
||||
.into_iter()
|
||||
.map(|page_result| PageRegionTexts {
|
||||
page: page_result.page,
|
||||
regions: page_result
|
||||
.regions
|
||||
.into_iter()
|
||||
.map(|r| RegionText {
|
||||
text: r.text,
|
||||
needs_ocr: r.needs_ocr,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|page_result| PageRegionTexts {
|
||||
page: page_result.page,
|
||||
regions: page_result
|
||||
.regions
|
||||
.into_iter()
|
||||
.map(|r| RegionText {
|
||||
text: r.text,
|
||||
needs_ocr: r.needs_ocr,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { strict as assert } from 'assert';
|
||||
import {
|
||||
processPdf,
|
||||
detectPdf,
|
||||
classifyPdf,
|
||||
extractText,
|
||||
extractTextWithPositions,
|
||||
extractTextInRegions,
|
||||
} from './index.js';
|
||||
|
||||
const fixture = readFileSync('../tests/fixtures/thermo-freon12.pdf');
|
||||
|
||||
// --- processPdf ---
|
||||
console.log('Testing processPdf...');
|
||||
const result = processPdf(fixture);
|
||||
assert.equal(result.pdfType, 'TextBased');
|
||||
assert.equal(result.pageCount, 3);
|
||||
assert.ok(result.confidence > 0);
|
||||
assert.ok(result.markdown && result.markdown.length > 0);
|
||||
assert.equal(typeof result.isComplexLayout, 'boolean');
|
||||
assert.ok(Array.isArray(result.pagesWithTables));
|
||||
assert.ok(Array.isArray(result.pagesWithColumns));
|
||||
assert.equal(typeof result.hasEncodingIssues, 'boolean');
|
||||
console.log(' processPdf: OK');
|
||||
|
||||
// processPdf with pages
|
||||
const result2 = processPdf(fixture, [1]);
|
||||
assert.ok(result2.markdown && result2.markdown.length > 0);
|
||||
console.log(' processPdf with pages: OK');
|
||||
|
||||
// --- detectPdf ---
|
||||
console.log('Testing detectPdf...');
|
||||
const detected = detectPdf(fixture);
|
||||
assert.equal(detected.pdfType, 'TextBased');
|
||||
assert.equal(detected.pageCount, 3);
|
||||
assert.equal(detected.markdown, undefined);
|
||||
console.log(' detectPdf: OK');
|
||||
|
||||
// --- classifyPdf ---
|
||||
console.log('Testing classifyPdf...');
|
||||
const classified = classifyPdf(fixture);
|
||||
assert.equal(classified.pdfType, 'TextBased');
|
||||
assert.equal(classified.pageCount, 3);
|
||||
assert.ok(classified.confidence > 0);
|
||||
assert.ok(Array.isArray(classified.pagesNeedingOcr));
|
||||
console.log(' classifyPdf: OK');
|
||||
|
||||
// --- extractText ---
|
||||
console.log('Testing extractText...');
|
||||
const text = extractText(fixture);
|
||||
assert.equal(typeof text, 'string');
|
||||
assert.ok(text.length > 0);
|
||||
console.log(' extractText: OK');
|
||||
|
||||
// --- extractTextWithPositions ---
|
||||
console.log('Testing extractTextWithPositions...');
|
||||
const items = extractTextWithPositions(fixture);
|
||||
assert.ok(items.length > 0);
|
||||
const item = items[0];
|
||||
assert.equal(typeof item.text, 'string');
|
||||
assert.equal(typeof item.x, 'number');
|
||||
assert.equal(typeof item.y, 'number');
|
||||
assert.equal(typeof item.width, 'number');
|
||||
assert.equal(typeof item.height, 'number');
|
||||
assert.equal(typeof item.font, 'string');
|
||||
assert.equal(typeof item.fontSize, 'number');
|
||||
assert.equal(typeof item.page, 'number');
|
||||
assert.equal(typeof item.isBold, 'boolean');
|
||||
assert.equal(typeof item.isItalic, 'boolean');
|
||||
assert.equal(typeof item.itemType, 'string');
|
||||
console.log(' extractTextWithPositions: OK');
|
||||
|
||||
// with pages filter
|
||||
const page1Items = extractTextWithPositions(fixture, [1]);
|
||||
assert.ok(page1Items.length > 0);
|
||||
assert.ok(page1Items.every(i => i.page === 1));
|
||||
console.log(' extractTextWithPositions with pages: OK');
|
||||
|
||||
// --- extractTextInRegions ---
|
||||
console.log('Testing extractTextInRegions...');
|
||||
const regionResults = extractTextInRegions(fixture, [
|
||||
{ page: 0, regions: [[0, 0, 600, 100]] },
|
||||
]);
|
||||
assert.equal(regionResults.length, 1);
|
||||
assert.equal(regionResults[0].page, 0);
|
||||
assert.equal(regionResults[0].regions.length, 1);
|
||||
assert.equal(typeof regionResults[0].regions[0].text, 'string');
|
||||
assert.equal(typeof regionResults[0].regions[0].needsOcr, 'boolean');
|
||||
console.log(' extractTextInRegions: OK');
|
||||
|
||||
// --- Error handling ---
|
||||
console.log('Testing error handling...');
|
||||
assert.throws(() => processPdf(Buffer.from('not a pdf')), /process_pdf/);
|
||||
assert.throws(() => classifyPdf(Buffer.from('')), /classify_pdf/);
|
||||
console.log(' error handling: OK');
|
||||
|
||||
console.log('\nAll NAPI tests passed!');
|
||||
Reference in New Issue
Block a user