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
29 changes: 29 additions & 0 deletions docs/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ headings = [
for item in pdf_inspector.extract_text_with_positions("tagged.pdf")
if item.mcid is not None and roles.get((item.page, item.mcid), "").startswith("H")
]

# Markdown plus typed layout blocks for citation grounding. Same Full-mode
# pipeline as process_pdf (no layout model); each block carries a normalized
# 0-1 page-space bbox (top-left origin) and an exact [start, end) byte span
# into the returned markdown:
result = pdf_inspector.extract_layout_blocks("document.pdf")
md = result.markdown.encode("utf-8")
for block in result.blocks:
start, end = block.markdown_span
print(block.block_type, block.page, md[start:end].decode("utf-8"))
```

## API reference
Expand All @@ -137,6 +147,8 @@ headings = [
| `extract_pages_markdown_bytes(data, pages=None)` | Per-page Markdown from bytes |
| `extract_structure_elements(path, pages=None)` | Structure-tree elements from tagged PDFs (page, mcid, role) |
| `extract_structure_elements_bytes(data, pages=None)` | Structure-tree elements from bytes |
| `extract_layout_blocks(path)` | Markdown + typed layout blocks with bbox and markdown spans |
| `extract_layout_blocks_bytes(data)` | Layout blocks from bytes |

## Types

Expand Down Expand Up @@ -227,6 +239,23 @@ class StructureElement: # extract_structure_elements
mcid: int
role: str # "H1".."H6", "P", "Table", ... (resolved via /RoleMap)

class LayoutBlocksResult: # extract_layout_blocks
markdown: str # block spans are byte offsets into this string
blocks: list[LayoutBlock] # reading order, non-overlapping ascending spans
pdf_type: str
page_count: int

class LayoutBlock:
block_type: str # "title" | "section_header" | "text" | "list_item"
# | "caption" | "code" | "table" | "picture"
label: str | None # "H2".."H6" for section_header blocks
page: int # 1-indexed
bbox: tuple[float, float, float, float] | None # normalized 0-1, top-left origin
markdown_span: tuple[int, int] # [start, end) byte offsets into markdown
source: str # always "native_text"
layout_confidence: float | None # always None (no layout model runs)
ocr_confidence: float | None # always None (native text, not OCR)

class RegionText: # extract_text_in_regions
text: str
needs_ocr: bool
Expand Down
35 changes: 35 additions & 0 deletions docs/rust-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,36 @@ for item in extract_text_with_positions("tagged.pdf")? {
}
```

Extract Markdown plus typed layout blocks for citation grounding. This runs
the same Full-mode pipeline as `process_pdf` (no layout model) and records
each fragment the Markdown converter emits — headings, paragraphs, list
items, captions, code, tables, images — as a block with a normalized 0–1
page-space bbox (top-left origin) and an exact `[start, end)` byte span into
the returned markdown:

```rust
use pdf_inspector::{extract_layout_blocks, LayoutBlockType};

let result = extract_layout_blocks("document.pdf")?;
for block in &result.blocks {
let (start, end) = block.markdown_span;
let text = &result.markdown[start..end];
// block.block_type: Title | SectionHeader | Text | ListItem | Caption
// | Code | Table | Picture
// block.label: Some("H2".."H6") for SectionHeader blocks
// block.bbox: Some([x0, y0, x1, y1]) normalized to the page box
// block.source: "native_text"; confidence fields stay None (no model)
println!("p{} {}: {}", block.page, block.block_type.as_str(), text);
}
```

Spans are ascending and non-overlapping, so slicing `result.markdown` with
them recovers the document in reading order. Unlike the default
`process_pdf` Markdown, the payload includes image placeholders so figures
surface as `picture` blocks. Scanned/image-based PDFs return the
classification with empty `markdown` and no blocks. The default
`process_pdf` output is unaffected by recording.

## Processing modes

| Mode | What it does | Returns |
Expand Down Expand Up @@ -514,6 +544,8 @@ for item in extract_text_with_positions("tagged.pdf")? {
| `extract_pages_markdown_mem(bytes, pages)` | Per-page Markdown from bytes |
| `extract_structure_elements(path, pages)` | Structure-tree elements from tagged PDFs (page, mcid, role) |
| `extract_structure_elements_mem(bytes, pages)` | Structure-tree elements from bytes |
| `extract_layout_blocks(path)` | Markdown + typed layout blocks with bbox and markdown spans |
| `extract_layout_blocks_mem(bytes)` | Layout blocks from bytes |

Low-level detection functions are also available via the `detector` module (`detect_pdf_type`, `detect_pdf_type_with_config`, etc.) for callers who need `PdfTypeResult` instead of `PdfProcessResult`.

Expand All @@ -531,6 +563,9 @@ Low-level detection functions are also available via the `detector` module (`det
| `LayoutComplexity` | Layout analysis: is_complex, pages_with_tables, pages_with_columns |
| `TextItem` | Text with position, font info, page number, and optional structure-tree `mcid` |
| `StructureElement` | Tagged-PDF structure reference: page (1-indexed), mcid, role (`"H1"`..`"H6"`, `"P"`, …) |
| `LayoutBlocksResult` | Markdown plus typed layout blocks whose byte spans index into it |
| `LayoutBlock` | One block: type, label, page, normalized bbox, markdown_span, source, confidences |
| `LayoutBlockType` | `Title`, `SectionHeader`, `Text`, `ListItem`, `Caption`, `Code`, `Table`, `Picture` |
| `MarkdownOptions` | Configuration for Markdown formatting (page numbers, etc.) |
| `PageMarkdown` | Per-page result: page (0-indexed), markdown, needs_ocr |
| `PagesExtractionResult` | Per-page output + 1-indexed pages_with_tables / pages_with_columns / pages_needing_ocr, is_complex |
Expand Down
39 changes: 38 additions & 1 deletion napi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,27 @@ for (const region of result[0].regions) {
}
```

### `extractLayoutBlocks(buffer: Buffer): LayoutBlocksResult`
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

Extract Markdown plus typed layout blocks for citation grounding. Runs the same Full-mode pipeline as `processPdf` (no layout model) and records each emitted fragment — headings, paragraphs, list items, captions, code, tables, images — as a block with a normalized 0–1 page-space bbox (top-left origin) and an exact `[start, end)` byte span into the returned markdown. Unlike the default `processPdf` markdown, this payload includes image placeholders so figures surface as `picture` blocks.

Synchronous: like `extractText`, `extractStructureElements`, and `extractTextInRegions`, it parses on the calling thread and has no async variant.

```typescript
import { extractLayoutBlocks } from '@firecrawl/pdf-inspector'

const { markdown, blocks } = extractLayoutBlocks(pdf)
const bytes = Buffer.from(markdown, 'utf8')

for (const block of blocks) {
const [start, end] = block.markdownSpan
console.log(block.blockType, block.page, bytes.subarray(start, end).toString('utf8'))
}
```

### Async variants

`processPdf`, `classifyPdf`, and `extractPagesMarkdown` are synchronous and parse on the calling thread — in Node, that's the event loop. For a one-off call in a script that's fine, but in a server a large document can hold the loop for tens to hundreds of milliseconds.
`processPdf`, `classifyPdf`, `extractPagesMarkdown`, and the other extraction functions (including `extractLayoutBlocks`) are synchronous and parse on the calling thread — in Node, that's the event loop. For a one-off call in a script that's fine, but in a server a large document can hold the loop for tens to hundreds of milliseconds.

`processPdfAsync`, `classifyPdfAsync`, and `extractPagesMarkdownAsync` take the same arguments and produce the same results, but run the parse on the libuv thread pool and return a promise, keeping the event loop free. The input buffer is copied before the call returns, so it's safe to reuse or mutate immediately:

Expand Down Expand Up @@ -160,6 +178,25 @@ interface RegionText {
ocrReason?: string // "suspected_garbled_text" when known
}

interface LayoutBlocksResult {
markdown: string // block spans are byte offsets into this string
blocks: LayoutBlock[] // reading order, non-overlapping ascending spans
pdfType: string
pageCount: number
}

interface LayoutBlock {
blockType: string // "title" | "section_header" | "text" | "list_item"
// | "caption" | "code" | "table" | "picture"
label?: string // "H2".."H6" for section_header blocks
page: number // 1-indexed
bbox?: number[] // [x0, y0, x1, y1] normalized 0-1, top-left origin
markdownSpan: number[] // [start, end) byte offsets into markdown
source: string // always "native_text"
layoutConfidence?: number // always undefined (no layout model runs)
ocrConfidence?: number // always undefined (native text, not OCR)
}

interface OcrPdfResult {
markdown: string
pages: OcrPageResult[] // 1-indexed pages + provenance
Expand Down
96 changes: 96 additions & 0 deletions napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,88 @@ pub fn extract_structure_elements(
})
}

/// One typed layout block from the Full-mode Markdown pipeline.
#[napi(object)]
pub struct LayoutBlockJs {
/// Hosted block type: "title", "section_header", "text", "list_item",
/// "caption", "code", "table", or "picture".
pub block_type: String,
/// Heading level label ("H2".."H6") for section_header blocks, `None`
/// otherwise.
pub label: Option<String>,
/// 1-indexed page number the block was emitted from.
pub page: u32,
/// Normalized `[x0, y0, x1, y1]` in 0–1 page space with a top-left
/// origin, or `None` when no positioned geometry is available.
pub bbox: Option<Vec<f64>>,
/// `[start, end)` byte offsets into the result's `markdown`.
pub markdown_span: Vec<f64>,
/// Provenance of the block's text; always `"native_text"`.
pub source: String,
/// Layout-model confidence; always `None` (no layout model runs).
pub layout_confidence: Option<f64>,
/// OCR confidence; always `None` (the text is native, not OCR output).
pub ocr_confidence: Option<f64>,
}

/// Markdown plus typed layout blocks whose spans index into it.
#[napi(object)]
pub struct LayoutBlocksResultJs {
/// Markdown assembled from the recorded fragments; block spans are
/// byte offsets into this string.
pub markdown: String,
/// Typed blocks in reading order with non-overlapping ascending spans.
pub blocks: Vec<LayoutBlockJs>,
pub pdf_type: PdfType,
pub page_count: u32,
}

/// Extract Markdown plus typed layout blocks for citation grounding.
///
/// Runs the same Full-mode extract+convert pipeline as [`processPdf`]
/// (no layout model) and records each emitted fragment as a typed block
/// with a normalized 0–1 page-space bbox (top-left origin) and an exact
/// `[start, end)` byte span into the returned markdown.
///
/// Unlike the default `processPdf` markdown, the payload includes image
/// placeholders so figures surface as `picture` blocks.
///
/// For scanned/image-based PDFs the result carries the classification with
/// empty `markdown` and no blocks.
#[napi]
pub fn extract_layout_blocks(buffer: Buffer) -> Result<LayoutBlocksResultJs> {
let bytes: Vec<u8> = buffer.to_vec();
catch_panic("extract_layout_blocks", move || {
let result = pdf_inspector::extract_layout_blocks_mem(&bytes)
.map_err(|e| to_napi_err(e, "extract_layout_blocks"))?;
Ok(LayoutBlocksResultJs {
markdown: result.markdown,
blocks: result
.blocks
.into_iter()
.map(|block| LayoutBlockJs {
block_type: block.block_type.as_str().to_string(),
label: block.label,
page: block.page,
bbox: block
.bbox
.map(|bbox| bbox.iter().map(|&v| v as f64).collect()),
markdown_span: markdown_span_to_js(block.markdown_span),
source: block.source,
layout_confidence: block.layout_confidence.map(f64::from),
ocr_confidence: block.ocr_confidence.map(f64::from),
})
.collect(),
pdf_type: convert_pdf_type(result.pdf_type),
page_count: result.page_count,
})
})
}

fn markdown_span_to_js((start, end): (usize, usize)) -> Vec<f64> {
vec![start as f64, end as f64]
}

/// Extract text within bounding-box regions from a PDF.
///
/// For hybrid OCR: layout model detects regions in rendered images,
Expand Down Expand Up @@ -1130,3 +1212,17 @@ pub fn extract_pages_markdown_async(
pages,
})
}

#[cfg(all(test, target_pointer_width = "64"))]
mod tests {
use super::markdown_span_to_js;

#[test]
fn markdown_span_does_not_wrap_at_u32_boundary() {
let start = u32::MAX as usize + 1;
assert_eq!(
markdown_span_to_js((start, start + 1)),
vec![4294967296.0, 4294967297.0]
);
}
}
49 changes: 49 additions & 0 deletions napi/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
extractText,
extractTextWithPositions,
extractStructureElements,
extractLayoutBlocks,
extractTextInRegions,
detectVectorGridInRegion,
extractPagesMarkdown,
Expand Down Expand Up @@ -125,6 +126,54 @@ assert.ok(page1Elements.every(e => e.page === 1));
assert.deepEqual(extractStructureElements(fixture), []);
console.log(' extractStructureElements: OK');

// --- extractLayoutBlocks ---
console.log('Testing extractLayoutBlocks...');
const layoutBlocks = extractLayoutBlocks(fixture);
assert.equal(layoutBlocks.pdfType, 'TextBased');
assert.equal(layoutBlocks.pageCount, 3);
assert.ok(layoutBlocks.markdown.length > 0);
assert.ok(layoutBlocks.blocks.length > 0);
const allowedBlockTypes = new Set([
'title',
'section_header',
'text',
'list_item',
'caption',
'code',
'table',
'picture',
]);
assert.ok(layoutBlocks.blocks.every(b => allowedBlockTypes.has(b.blockType)));
assert.ok(layoutBlocks.blocks.every(b => b.source === 'native_text'));
assert.ok(layoutBlocks.blocks.every(b => b.layoutConfidence === undefined));
assert.ok(layoutBlocks.blocks.every(b => b.ocrConfidence === undefined));

// Spans are byte offsets into the payload's own markdown: ascending,
// non-overlapping, on UTF-8 character boundaries (fatal decoder throws on
// a mid-codepoint slice), and each slice is non-empty content.
const markdownBytes = Buffer.from(layoutBlocks.markdown, 'utf8');
const fatalUtf8 = new TextDecoder('utf-8', { fatal: true });
let prevSpanEnd = 0;
for (const block of layoutBlocks.blocks) {
const [start, end] = block.markdownSpan;
assert.ok(Number.isSafeInteger(start) && Number.isSafeInteger(end));
assert.ok(start >= prevSpanEnd && start < end && end <= markdownBytes.length);
assert.ok(fatalUtf8.decode(markdownBytes.subarray(start, end)).trim().length > 0);
prevSpanEnd = end;
}

// Bboxes are normalized 0-1 page space (top-left origin).
assert.ok(layoutBlocks.blocks.some(b => b.bbox !== undefined));
for (const block of layoutBlocks.blocks) {
assert.ok(block.page >= 1 && block.page <= layoutBlocks.pageCount);
if (block.bbox === undefined) continue;
const [x0, y0, x1, y1] = block.bbox;
assert.ok(x0 >= 0 && x0 <= x1 && x1 <= 1);
assert.ok(y0 >= 0 && y0 <= y1 && y1 <= 1);
}
assert.ok(layoutBlocks.blocks.some(b => b.blockType === 'table'));
console.log(' extractLayoutBlocks: OK');

// --- extractTextInRegions ---
console.log('Testing extractTextInRegions...');
const regionResults = extractTextInRegions(fixture, [
Expand Down
Loading