Skip to content

Commit 422a2ff

Browse files
feat(extractor): geometric underline detection on TextItem (#116)
* feat(extractor): geometric underline detection on TextItem (ENG-5015) PDFs carry no underline font flag — underlines are stroked horizontal lines or thin filled rects drawn under the baseline. Correlate those graphics (already parsed from the content stream) with text items in a post-pass: a rule within ~0.35em below the baseline covering >=60% of an item's width marks is_underline. Exposed through the napi and python bindings. Verified on real docs: 4/4 underlined sentences flagged on a Japanese report, links/headings flagged on 8 of 10 underline-bearing eval docs, zero flags on docs without underlines. Known FP source (table cell borders) documented — downstream applies inline styling only to plain-text regions. napi 1.9.8 -> 1.9.9. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(extractor): underline rules only from painted rects, normalized extents (review) Two review fixes: (1) normalize rect extents before the thickness/width checks — `re` operands pass through the CTM so width/height can be negative, which missed negative-width rules and let negative-height bands pass as thin; (2) only feed painted rects to underline detection — `re` rects now wait in a pending list until a paint operator (S/s, f/F/ f*, B/B*/b/b*) confirms them, and `re W n` clip-only paths are discarded at `n`, so invisible clip boundaries no longer underline nearby text. Marking moved into content_stream where paint state lives (pre-rotation, consistent device space). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(extractor): harden underline detection * feat(cli): export positioned text item json --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 30eddad commit 422a2ff

26 files changed

Lines changed: 978 additions & 16 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ pdf2md document.pdf
111111
# JSON output (for piping)
112112
pdf2md document.pdf --json
113113

114+
# Positioned TextItem JSON, including is_underline metadata
115+
pdf2md document.pdf --items-json
116+
114117
# Raw markdown only (no headers)
115118
pdf2md document.pdf --raw
116119

napi/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@firecrawl/pdf-inspector",
3-
"version": "1.9.8",
3+
"version": "1.9.9",
44
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
55
"main": "index.js",
66
"types": "index.d.ts",

napi/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ pub struct TextItem {
8080
pub page: u32,
8181
pub is_bold: bool,
8282
pub is_italic: bool,
83+
/// Underline detected geometrically (drawn rule/thin rect under the
84+
/// baseline) — PDFs carry no underline font flag.
85+
pub is_underline: bool,
8386
pub item_type: ItemType,
8487
/// URL for link items, `None` for other types.
8588
pub link_url: Option<String>,
@@ -290,6 +293,7 @@ pub fn extract_text_with_positions(
290293
page: item.page,
291294
is_bold: item.is_bold,
292295
is_italic: item.is_italic,
296+
is_underline: item.is_underline,
293297
item_type,
294298
link_url,
295299
}

pdf_inspector.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class TextItem:
3838
page: int
3939
is_bold: bool
4040
is_italic: bool
41+
is_underline: bool
4142
item_type: str
4243

4344
class RegionText:

src/bin/pdf2md.rs

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
//! CLI tool for PDF to Markdown conversion
22
3-
use pdf_inspector::{process_pdf_with_options, LayoutComplexity, PdfOptions, PdfType, ProcessMode};
3+
use pdf_inspector::extractor::ItemType;
4+
use pdf_inspector::{
5+
extract_text_with_positions_pages, process_pdf_with_options, LayoutComplexity, PdfOptions,
6+
PdfType, ProcessMode, TextItem,
7+
};
48
use std::collections::HashSet;
59
use std::env;
610
use std::fmt::Write;
@@ -47,6 +51,92 @@ fn format_ocr_reasons_by_page(reasons: &[pdf_inspector::PageOcrReasons]) -> Stri
4751
.join(",")
4852
}
4953

54+
fn item_type_label(item_type: &ItemType) -> &'static str {
55+
match item_type {
56+
ItemType::Text => "text",
57+
ItemType::Image => "image",
58+
ItemType::Link(_) => "link",
59+
ItemType::FormField => "form_field",
60+
}
61+
}
62+
63+
fn format_items_json(items: &[TextItem]) -> String {
64+
let underlined_count = items.iter().filter(|item| item.is_underline).count();
65+
let items_json = items
66+
.iter()
67+
.map(|item| {
68+
let mcid = item
69+
.mcid
70+
.map(|value| value.to_string())
71+
.unwrap_or_else(|| "null".to_string());
72+
let link_url = match &item.item_type {
73+
ItemType::Link(url) => format!(r#","url":"{}""#, json_escape(url)),
74+
_ => String::new(),
75+
};
76+
format!(
77+
r#"{{"text":"{}","page":{},"x":{:.2},"y":{:.2},"width":{:.2},"height":{:.2},"font":"{}","font_size":{:.2},"is_bold":{},"is_italic":{},"is_underline":{},"item_type":"{}","mcid":{}{}}}"#,
78+
json_escape(&item.text),
79+
item.page,
80+
item.x,
81+
item.y,
82+
item.width,
83+
item.height,
84+
json_escape(&item.font),
85+
item.font_size,
86+
item.is_bold,
87+
item.is_italic,
88+
item.is_underline,
89+
item_type_label(&item.item_type),
90+
mcid,
91+
link_url,
92+
)
93+
})
94+
.collect::<Vec<_>>()
95+
.join(",");
96+
97+
format!(
98+
r#"{{"total_items":{},"underlined_count":{},"items":[{}]}}"#,
99+
items.len(),
100+
underlined_count,
101+
items_json
102+
)
103+
}
104+
105+
#[cfg(test)]
106+
mod tests {
107+
use super::format_items_json;
108+
use pdf_inspector::extractor::ItemType;
109+
use pdf_inspector::TextItem;
110+
111+
#[test]
112+
fn items_json_includes_position_and_underline_metadata() {
113+
let items = vec![TextItem {
114+
text: "A \"quoted\" item".to_string(),
115+
x: 12.345,
116+
y: 67.891,
117+
width: 23.456,
118+
height: 9.876,
119+
font: "F1".to_string(),
120+
font_size: 10.0,
121+
page: 2,
122+
is_bold: false,
123+
is_italic: true,
124+
is_underline: true,
125+
item_type: ItemType::Text,
126+
mcid: Some(7),
127+
}];
128+
129+
let json = format_items_json(&items);
130+
131+
assert!(json.contains(r#""text":"A \"quoted\" item""#));
132+
assert!(json.contains(r#""page":2"#));
133+
assert!(json.contains(r#""x":12.35"#));
134+
assert!(json.contains(r#""is_underline":true"#));
135+
assert!(json.contains(r#""item_type":"text""#));
136+
assert!(json.contains(r#""mcid":7"#));
137+
}
138+
}
139+
50140
/// Parse a page specification like "1,3,5-10,20" into a HashSet of page numbers.
51141
fn parse_page_spec(spec: &str) -> Result<HashSet<u32>, String> {
52142
let mut pages = HashSet::new();
@@ -104,13 +194,15 @@ fn main() {
104194
if args.len() < 2 {
105195
eprintln!("Usage: {} <pdf_file> [output_file]", args[0]);
106196
eprintln!(" {} <pdf_file> --json", args[0]);
197+
eprintln!(" {} <pdf_file> --items-json", args[0]);
107198
eprintln!(" {} <pdf_file> --raw", args[0]);
108199
eprintln!();
109200
eprintln!("Converts PDF to Markdown with smart type detection.");
110201
eprintln!("Returns early if PDF is scanned (OCR needed).");
111202
eprintln!();
112203
eprintln!("Options:");
113204
eprintln!(" --json Output result as JSON");
205+
eprintln!(" --items-json Output positioned TextItem JSON");
114206
eprintln!(" --raw Output only markdown (no headers)");
115207
eprintln!(" --pages Insert page break markers (<!-- Page N -->)");
116208
eprintln!(" --select-pages N Only process specified pages (e.g. 1,3,5-10)");
@@ -121,6 +213,7 @@ fn main() {
121213

122214
let pdf_path = &args[1];
123215
let json_output = args.iter().any(|a| a == "--json");
216+
let items_json_output = args.iter().any(|a| a == "--items-json");
124217
let raw_output = args.iter().any(|a| a == "--raw");
125218
let page_numbers = args.iter().any(|a| a == "--pages");
126219
let detect_only = args.iter().any(|a| a == "--detect-only");
@@ -145,6 +238,17 @@ fn main() {
145238
})
146239
});
147240

241+
if items_json_output {
242+
match extract_text_with_positions_pages(pdf_path, page_filter.as_ref()) {
243+
Ok(items) => println!("{}", format_items_json(&items)),
244+
Err(e) => {
245+
println!(r#"{{"error":"{}"}}"#, json_escape(&e.to_string()));
246+
process::exit(1);
247+
}
248+
}
249+
return;
250+
}
251+
148252
let output_file = args
149253
.get(2)
150254
.filter(|a| !a.starts_with("--"))

0 commit comments

Comments
 (0)