fix(extractor): Include CTM scaling in font_size to fix two-column merge
effective_font_size() was computed from the text matrix alone, ignoring CTM scaling. In PDFs with a small CTM scale (e.g. 0.24×) and large Tm values (e.g. 58), font_size was inflated (58pt instead of ~14pt), causing the merge threshold to bridge inter-column gaps and merge two-column text into single lines. Now compute the combined matrix (text_matrix × CTM) before calling effective_font_size() at all 6 call sites. Also adds PdfRect extraction from `re` operators for future table-grid detection, and related plumbing changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d35adf22d9
commit
b547e685de
+67
-16
@@ -489,6 +489,16 @@ pub enum ItemType {
|
|||||||
Link(String),
|
Link(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A rectangle from a PDF `re` operator (cell boundary, border, etc.)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PdfRect {
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
pub page: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// A text item with position information
|
/// A text item with position information
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TextItem {
|
pub struct TextItem {
|
||||||
@@ -887,6 +897,15 @@ pub fn extract_text_with_positions_pages<P: AsRef<Path>>(
|
|||||||
path: P,
|
path: P,
|
||||||
page_filter: Option<&HashSet<u32>>,
|
page_filter: Option<&HashSet<u32>>,
|
||||||
) -> Result<Vec<TextItem>, PdfError> {
|
) -> Result<Vec<TextItem>, PdfError> {
|
||||||
|
let (items, _rects) = extract_text_with_positions_and_rects(path, page_filter)?;
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract text with positions and rectangles from a file.
|
||||||
|
pub(crate) fn extract_text_with_positions_and_rects<P: AsRef<Path>>(
|
||||||
|
path: P,
|
||||||
|
page_filter: Option<&HashSet<u32>>,
|
||||||
|
) -> Result<(Vec<TextItem>, Vec<PdfRect>), PdfError> {
|
||||||
// Read the raw PDF bytes for ToUnicode extraction
|
// Read the raw PDF bytes for ToUnicode extraction
|
||||||
let pdf_bytes = std::fs::read(path.as_ref())?;
|
let pdf_bytes = std::fs::read(path.as_ref())?;
|
||||||
crate::validate_pdf_bytes(&pdf_bytes)?;
|
crate::validate_pdf_bytes(&pdf_bytes)?;
|
||||||
@@ -906,6 +925,15 @@ pub fn extract_text_with_positions_mem_pages(
|
|||||||
buffer: &[u8],
|
buffer: &[u8],
|
||||||
page_filter: Option<&HashSet<u32>>,
|
page_filter: Option<&HashSet<u32>>,
|
||||||
) -> Result<Vec<TextItem>, PdfError> {
|
) -> Result<Vec<TextItem>, PdfError> {
|
||||||
|
let (items, _rects) = extract_text_with_positions_mem_and_rects(buffer, page_filter)?;
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract text with positions and rectangles from memory buffer.
|
||||||
|
pub(crate) fn extract_text_with_positions_mem_and_rects(
|
||||||
|
buffer: &[u8],
|
||||||
|
page_filter: Option<&HashSet<u32>>,
|
||||||
|
) -> Result<(Vec<TextItem>, Vec<PdfRect>), PdfError> {
|
||||||
crate::validate_pdf_bytes(buffer)?;
|
crate::validate_pdf_bytes(buffer)?;
|
||||||
// Extract ToUnicode CMaps from raw PDF bytes
|
// Extract ToUnicode CMaps from raw PDF bytes
|
||||||
let font_cmaps = FontCMaps::from_pdf_bytes(buffer);
|
let font_cmaps = FontCMaps::from_pdf_bytes(buffer);
|
||||||
@@ -914,12 +942,12 @@ pub fn extract_text_with_positions_mem_pages(
|
|||||||
extract_positioned_text_from_doc(&doc, &font_cmaps, page_filter)
|
extract_positioned_text_from_doc(&doc, &font_cmaps, page_filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract positioned text from loaded document
|
/// Extract positioned text and rectangles from loaded document
|
||||||
fn extract_positioned_text_from_doc(
|
fn extract_positioned_text_from_doc(
|
||||||
doc: &Document,
|
doc: &Document,
|
||||||
font_cmaps: &FontCMaps,
|
font_cmaps: &FontCMaps,
|
||||||
page_filter: Option<&HashSet<u32>>,
|
page_filter: Option<&HashSet<u32>>,
|
||||||
) -> Result<Vec<TextItem>, PdfError> {
|
) -> Result<(Vec<TextItem>, Vec<PdfRect>), PdfError> {
|
||||||
// If raw byte scanning found no CMaps, populate from the document model.
|
// If raw byte scanning found no CMaps, populate from the document model.
|
||||||
// This handles PDFs with compressed object streams where raw scanning fails.
|
// This handles PDFs with compressed object streams where raw scanning fails.
|
||||||
let mut font_cmaps_owned;
|
let mut font_cmaps_owned;
|
||||||
@@ -933,6 +961,7 @@ fn extract_positioned_text_from_doc(
|
|||||||
|
|
||||||
let pages = doc.get_pages();
|
let pages = doc.get_pages();
|
||||||
let mut all_items = Vec::new();
|
let mut all_items = Vec::new();
|
||||||
|
let mut all_rects = Vec::new();
|
||||||
|
|
||||||
for (page_num, &page_id) in pages.iter() {
|
for (page_num, &page_id) in pages.iter() {
|
||||||
if let Some(filter) = page_filter {
|
if let Some(filter) = page_filter {
|
||||||
@@ -940,15 +969,16 @@ fn extract_positioned_text_from_doc(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let items = extract_page_text_items(doc, page_id, *page_num, font_cmaps)?;
|
let (items, rects) = extract_page_text_items(doc, page_id, *page_num, font_cmaps)?;
|
||||||
all_items.extend(items);
|
all_items.extend(items);
|
||||||
|
all_rects.extend(rects);
|
||||||
|
|
||||||
// Extract hyperlinks from page annotations
|
// Extract hyperlinks from page annotations
|
||||||
let links = extract_page_links(doc, page_id, *page_num);
|
let links = extract_page_links(doc, page_id, *page_num);
|
||||||
all_items.extend(links);
|
all_items.extend(links);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(all_items)
|
Ok((all_items, all_rects))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Populate FontCMaps from the lopdf document model for ToUnicode streams
|
/// Populate FontCMaps from the lopdf document model for ToUnicode streams
|
||||||
@@ -1015,16 +1045,17 @@ fn multiply_matrices(m1: &[f32; 6], m2: &[f32; 6]) -> [f32; 6] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract text items from a single page
|
/// Extract text items and rectangles from a single page
|
||||||
fn extract_page_text_items(
|
fn extract_page_text_items(
|
||||||
doc: &Document,
|
doc: &Document,
|
||||||
page_id: ObjectId,
|
page_id: ObjectId,
|
||||||
page_num: u32,
|
page_num: u32,
|
||||||
font_cmaps: &FontCMaps,
|
font_cmaps: &FontCMaps,
|
||||||
) -> Result<Vec<TextItem>, PdfError> {
|
) -> Result<(Vec<TextItem>, Vec<PdfRect>), PdfError> {
|
||||||
use lopdf::content::Content;
|
use lopdf::content::Content;
|
||||||
|
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
let mut rects: Vec<PdfRect> = Vec::new();
|
||||||
|
|
||||||
// Get fonts for encoding
|
// Get fonts for encoding
|
||||||
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
|
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
|
||||||
@@ -1252,8 +1283,8 @@ fn extract_page_text_items(
|
|||||||
&font_encodings,
|
&font_encodings,
|
||||||
&encoding_cache,
|
&encoding_cache,
|
||||||
) {
|
) {
|
||||||
let rendered_size = effective_font_size(current_font_size, &text_matrix);
|
|
||||||
let combined = multiply_matrices(&text_matrix, &ctm);
|
let combined = multiply_matrices(&text_matrix, &ctm);
|
||||||
|
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||||
let (x, y) = (combined[4], combined[5]);
|
let (x, y) = (combined[4], combined[5]);
|
||||||
let width = if let Some(w_ts) = w_ts_opt {
|
let width = if let Some(w_ts) = w_ts_opt {
|
||||||
text_matrix[4] += w_ts * text_matrix[0];
|
text_matrix[4] += w_ts * text_matrix[0];
|
||||||
@@ -1393,8 +1424,8 @@ fn extract_page_text_items(
|
|||||||
}
|
}
|
||||||
// Emit one TextItem per sub-item
|
// Emit one TextItem per sub-item
|
||||||
if !sub_items.is_empty() {
|
if !sub_items.is_empty() {
|
||||||
let rendered_size =
|
let combined = multiply_matrices(&text_matrix, &ctm);
|
||||||
effective_font_size(current_font_size, &text_matrix);
|
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||||
let base_font = font_base_names
|
let base_font = font_base_names
|
||||||
.get(¤t_font)
|
.get(¤t_font)
|
||||||
.map(|s| s.as_str())
|
.map(|s| s.as_str())
|
||||||
@@ -1464,9 +1495,8 @@ fn extract_page_text_items(
|
|||||||
&encoding_cache,
|
&encoding_cache,
|
||||||
) {
|
) {
|
||||||
if !text.trim().is_empty() {
|
if !text.trim().is_empty() {
|
||||||
let rendered_size =
|
|
||||||
effective_font_size(current_font_size, &text_matrix);
|
|
||||||
let combined = multiply_matrices(&text_matrix, &ctm);
|
let combined = multiply_matrices(&text_matrix, &ctm);
|
||||||
|
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||||
let (x, y) = (combined[4], combined[5]);
|
let (x, y) = (combined[4], combined[5]);
|
||||||
let base_font = font_base_names
|
let base_font = font_base_names
|
||||||
.get(¤t_font)
|
.get(¤t_font)
|
||||||
@@ -1545,8 +1575,8 @@ fn extract_page_text_items(
|
|||||||
if let Some(Some(at)) = marked_content_stack.pop() {
|
if let Some(Some(at)) = marked_content_stack.pop() {
|
||||||
// Compute width from text matrix advancement during BDC..EMC
|
// Compute width from text matrix advancement during BDC..EMC
|
||||||
if let Some(start_tm) = actual_text_start_tm.take() {
|
if let Some(start_tm) = actual_text_start_tm.take() {
|
||||||
let rendered_size = effective_font_size(current_font_size, &start_tm);
|
|
||||||
let combined = multiply_matrices(&start_tm, &ctm);
|
let combined = multiply_matrices(&start_tm, &ctm);
|
||||||
|
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||||
let (x, y) = (combined[4], combined[5]);
|
let (x, y) = (combined[4], combined[5]);
|
||||||
// Width in device space from text matrix delta
|
// Width in device space from text matrix delta
|
||||||
let delta_ts = text_matrix[4] - start_tm[4];
|
let delta_ts = text_matrix[4] - start_tm[4];
|
||||||
@@ -1575,12 +1605,33 @@ fn extract_page_text_items(
|
|||||||
suppress_glyph_extraction = marked_content_stack.iter().any(|a| a.is_some());
|
suppress_glyph_extraction = marked_content_stack.iter().any(|a| a.is_some());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"re" => {
|
||||||
|
// Rectangle operator: collect for table-grid detection
|
||||||
|
if op.operands.len() >= 4 {
|
||||||
|
let rx = get_number(&op.operands[0]).unwrap_or(0.0);
|
||||||
|
let ry = get_number(&op.operands[1]).unwrap_or(0.0);
|
||||||
|
let rw = get_number(&op.operands[2]).unwrap_or(0.0);
|
||||||
|
let rh = get_number(&op.operands[3]).unwrap_or(0.0);
|
||||||
|
// Transform origin to device space
|
||||||
|
let x_dev = rx * ctm[0] + ry * ctm[2] + ctm[4];
|
||||||
|
let y_dev = rx * ctm[1] + ry * ctm[3] + ctm[5];
|
||||||
|
let w_dev = rw * ctm[0];
|
||||||
|
let h_dev = rh * ctm[3];
|
||||||
|
rects.push(PdfRect {
|
||||||
|
x: x_dev,
|
||||||
|
y: y_dev,
|
||||||
|
width: w_dev,
|
||||||
|
height: h_dev,
|
||||||
|
page: page_num,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let items = merge_text_items(items);
|
let items = merge_text_items(items);
|
||||||
Ok(items)
|
Ok((items, rects))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge adjacent single-character TextItems into words.
|
/// Merge adjacent single-character TextItems into words.
|
||||||
@@ -1898,8 +1949,8 @@ fn extract_form_xobject_text(
|
|||||||
&font_encodings,
|
&font_encodings,
|
||||||
&encoding_cache,
|
&encoding_cache,
|
||||||
) {
|
) {
|
||||||
let rendered_size = effective_font_size(current_font_size, &text_matrix);
|
|
||||||
let combined = multiply_matrices(&text_matrix, parent_ctm);
|
let combined = multiply_matrices(&text_matrix, parent_ctm);
|
||||||
|
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||||
let (x, y) = (combined[4], combined[5]);
|
let (x, y) = (combined[4], combined[5]);
|
||||||
let width = if let Some(font_info) = font_widths.get(¤t_font) {
|
let width = if let Some(font_info) = font_widths.get(¤t_font) {
|
||||||
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
||||||
@@ -2043,8 +2094,8 @@ fn extract_form_xobject_text(
|
|||||||
sub_items.push((current_text, sub_start_width_ts, total_width_ts));
|
sub_items.push((current_text, sub_start_width_ts, total_width_ts));
|
||||||
}
|
}
|
||||||
if !sub_items.is_empty() {
|
if !sub_items.is_empty() {
|
||||||
let rendered_size =
|
let combined = multiply_matrices(&text_matrix, parent_ctm);
|
||||||
effective_font_size(current_font_size, &text_matrix);
|
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||||
let base_font = font_base_names
|
let base_font = font_base_names
|
||||||
.get(¤t_font)
|
.get(¤t_font)
|
||||||
.map(|s| s.as_str())
|
.map(|s| s.as_str())
|
||||||
|
|||||||
+33
-18
@@ -17,9 +17,11 @@ pub use detector::{
|
|||||||
detect_pdf_type_with_config, DetectionConfig, PdfType, PdfTypeResult, ScanStrategy,
|
detect_pdf_type_with_config, DetectionConfig, PdfType, PdfTypeResult, ScanStrategy,
|
||||||
};
|
};
|
||||||
pub use extractor::{
|
pub use extractor::{
|
||||||
extract_text, extract_text_with_positions, extract_text_with_positions_pages, TextItem,
|
extract_text, extract_text_with_positions, extract_text_with_positions_pages, PdfRect, TextItem,
|
||||||
|
};
|
||||||
|
pub use markdown::{
|
||||||
|
to_markdown, to_markdown_from_items, to_markdown_from_items_with_rects, MarkdownOptions,
|
||||||
};
|
};
|
||||||
pub use markdown::{to_markdown, to_markdown_from_items, MarkdownOptions};
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -66,8 +68,9 @@ pub fn process_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError
|
|||||||
let result = match pdf_type {
|
let result = match pdf_type {
|
||||||
PdfType::TextBased => {
|
PdfType::TextBased => {
|
||||||
// Step 2: Full extraction with position-aware reading order
|
// Step 2: Full extraction with position-aware reading order
|
||||||
let items = extract_text_with_positions(&path)?;
|
let (items, rects) = extractor::extract_text_with_positions_and_rects(&path, None)?;
|
||||||
let markdown = to_markdown_from_items(items, MarkdownOptions::default());
|
let markdown =
|
||||||
|
to_markdown_from_items_with_rects(items, MarkdownOptions::default(), &rects);
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type,
|
pdf_type,
|
||||||
@@ -95,8 +98,10 @@ pub fn process_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError
|
|||||||
}
|
}
|
||||||
PdfType::Mixed => {
|
PdfType::Mixed => {
|
||||||
// Try to extract what we can with position-aware reading order
|
// Try to extract what we can with position-aware reading order
|
||||||
let items = extract_text_with_positions(&path).ok();
|
let result = extractor::extract_text_with_positions_and_rects(&path, None).ok();
|
||||||
let markdown = items.map(|i| to_markdown_from_items(i, MarkdownOptions::default()));
|
let markdown = result.map(|(items, rects)| {
|
||||||
|
to_markdown_from_items_with_rects(items, MarkdownOptions::default(), &rects)
|
||||||
|
});
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type,
|
pdf_type,
|
||||||
@@ -146,8 +151,9 @@ pub fn process_pdf_with_config_pages<P: AsRef<Path>>(
|
|||||||
|
|
||||||
let result = match pdf_type {
|
let result = match pdf_type {
|
||||||
PdfType::TextBased => {
|
PdfType::TextBased => {
|
||||||
let items = extract_text_with_positions_pages(&path, page_filter)?;
|
let (items, rects) =
|
||||||
let markdown = to_markdown_from_items(items, markdown_options);
|
extractor::extract_text_with_positions_and_rects(&path, page_filter)?;
|
||||||
|
let markdown = to_markdown_from_items_with_rects(items, markdown_options, &rects);
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type,
|
pdf_type,
|
||||||
@@ -171,8 +177,10 @@ pub fn process_pdf_with_config_pages<P: AsRef<Path>>(
|
|||||||
confidence,
|
confidence,
|
||||||
},
|
},
|
||||||
PdfType::Mixed => {
|
PdfType::Mixed => {
|
||||||
let items = extract_text_with_positions_pages(&path, page_filter).ok();
|
let result = extractor::extract_text_with_positions_and_rects(&path, page_filter).ok();
|
||||||
let markdown = items.map(|i| to_markdown_from_items(i, markdown_options.clone()));
|
let markdown = result.map(|(items, rects)| {
|
||||||
|
to_markdown_from_items_with_rects(items, markdown_options.clone(), &rects)
|
||||||
|
});
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type,
|
pdf_type,
|
||||||
@@ -207,8 +215,10 @@ pub fn process_pdf_mem(buffer: &[u8]) -> Result<PdfProcessResult, PdfError> {
|
|||||||
let result = match pdf_type {
|
let result = match pdf_type {
|
||||||
PdfType::TextBased => {
|
PdfType::TextBased => {
|
||||||
// Step 2: Full extraction with position-aware reading order
|
// Step 2: Full extraction with position-aware reading order
|
||||||
let items = extractor::extract_text_with_positions_mem(buffer)?;
|
let (items, rects) =
|
||||||
let markdown = to_markdown_from_items(items, MarkdownOptions::default());
|
extractor::extract_text_with_positions_mem_and_rects(buffer, None)?;
|
||||||
|
let markdown =
|
||||||
|
to_markdown_from_items_with_rects(items, MarkdownOptions::default(), &rects);
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type,
|
pdf_type,
|
||||||
@@ -232,8 +242,10 @@ pub fn process_pdf_mem(buffer: &[u8]) -> Result<PdfProcessResult, PdfError> {
|
|||||||
confidence,
|
confidence,
|
||||||
},
|
},
|
||||||
PdfType::Mixed => {
|
PdfType::Mixed => {
|
||||||
let items = extractor::extract_text_with_positions_mem(buffer).ok();
|
let result = extractor::extract_text_with_positions_mem_and_rects(buffer, None).ok();
|
||||||
let markdown = items.map(|i| to_markdown_from_items(i, MarkdownOptions::default()));
|
let markdown = result.map(|(items, rects)| {
|
||||||
|
to_markdown_from_items_with_rects(items, MarkdownOptions::default(), &rects)
|
||||||
|
});
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type,
|
pdf_type,
|
||||||
@@ -270,8 +282,9 @@ pub fn process_pdf_mem_with_config(
|
|||||||
|
|
||||||
let result = match pdf_type {
|
let result = match pdf_type {
|
||||||
PdfType::TextBased => {
|
PdfType::TextBased => {
|
||||||
let items = extractor::extract_text_with_positions_mem(buffer)?;
|
let (items, rects) =
|
||||||
let markdown = to_markdown_from_items(items, markdown_options);
|
extractor::extract_text_with_positions_mem_and_rects(buffer, None)?;
|
||||||
|
let markdown = to_markdown_from_items_with_rects(items, markdown_options, &rects);
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type,
|
pdf_type,
|
||||||
@@ -295,8 +308,10 @@ pub fn process_pdf_mem_with_config(
|
|||||||
confidence,
|
confidence,
|
||||||
},
|
},
|
||||||
PdfType::Mixed => {
|
PdfType::Mixed => {
|
||||||
let items = extractor::extract_text_with_positions_mem(buffer).ok();
|
let result = extractor::extract_text_with_positions_mem_and_rects(buffer, None).ok();
|
||||||
let markdown = items.map(|i| to_markdown_from_items(i, markdown_options.clone()));
|
let markdown = result.map(|(items, rects)| {
|
||||||
|
to_markdown_from_items_with_rects(items, markdown_options.clone(), &rects)
|
||||||
|
});
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type,
|
pdf_type,
|
||||||
|
|||||||
+69
-8
@@ -117,8 +117,17 @@ pub fn to_markdown(text: &str, options: MarkdownOptions) -> String {
|
|||||||
|
|
||||||
/// Convert positioned text items to markdown with structure detection
|
/// Convert positioned text items to markdown with structure detection
|
||||||
pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) -> String {
|
pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) -> String {
|
||||||
|
to_markdown_from_items_with_rects(items, options, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert positioned text items to markdown, using rectangle data for table detection
|
||||||
|
pub fn to_markdown_from_items_with_rects(
|
||||||
|
items: Vec<TextItem>,
|
||||||
|
options: MarkdownOptions,
|
||||||
|
rects: &[crate::extractor::PdfRect],
|
||||||
|
) -> String {
|
||||||
use crate::extractor::ItemType;
|
use crate::extractor::ItemType;
|
||||||
use crate::tables::{detect_tables, table_to_markdown};
|
use crate::tables::{detect_tables, detect_tables_from_rects, table_to_markdown};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
@@ -193,25 +202,77 @@ pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) ->
|
|||||||
let group = page_groups.get(&page).unwrap();
|
let group = page_groups.get(&page).unwrap();
|
||||||
let page_items: Vec<TextItem> = group.iter().map(|(_, item)| (*item).clone()).collect();
|
let page_items: Vec<TextItem> = group.iter().map(|(_, item)| (*item).clone()).collect();
|
||||||
|
|
||||||
let tables = detect_tables(&page_items, base_size, false);
|
// Track which local indices are claimed by rect-based tables
|
||||||
|
let mut rect_claimed: HashSet<usize> = HashSet::new();
|
||||||
|
|
||||||
for table in tables {
|
// Try rectangle-based table detection first
|
||||||
// Mark items as belonging to a table using pre-computed global indices
|
let rect_tables = detect_tables_from_rects(&page_items, rects, page);
|
||||||
|
for table in &rect_tables {
|
||||||
for &idx in &table.item_indices {
|
for &idx in &table.item_indices {
|
||||||
|
rect_claimed.insert(idx);
|
||||||
if let Some(&(global_idx, _)) = group.get(idx) {
|
if let Some(&(global_idx, _)) = group.get(idx) {
|
||||||
table_items.insert(global_idx);
|
table_items.insert(global_idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Y position for table insertion (use highest Y in table)
|
|
||||||
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
||||||
let table_md = table_to_markdown(&table);
|
let table_md = table_to_markdown(table);
|
||||||
|
|
||||||
page_tables
|
page_tables
|
||||||
.entry(page)
|
.entry(page)
|
||||||
.or_default()
|
.or_default()
|
||||||
.push((table_y, table_md));
|
.push((table_y, table_md));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run heuristic detection on unclaimed items only
|
||||||
|
if rect_claimed.is_empty() {
|
||||||
|
// No rect tables — run heuristic on all items
|
||||||
|
let tables = detect_tables(&page_items, base_size, false);
|
||||||
|
for table in tables {
|
||||||
|
for &idx in &table.item_indices {
|
||||||
|
if let Some(&(global_idx, _)) = group.get(idx) {
|
||||||
|
table_items.insert(global_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
||||||
|
let table_md = table_to_markdown(&table);
|
||||||
|
page_tables
|
||||||
|
.entry(page)
|
||||||
|
.or_default()
|
||||||
|
.push((table_y, table_md));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Rect tables found — run heuristic on unclaimed items
|
||||||
|
let unclaimed_items: Vec<TextItem> = page_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||||||
|
.map(|(_, item)| item.clone())
|
||||||
|
.collect();
|
||||||
|
if unclaimed_items.len() >= 6 {
|
||||||
|
let tables = detect_tables(&unclaimed_items, base_size, false);
|
||||||
|
for table in tables {
|
||||||
|
// Remap indices from unclaimed-space back to page-space
|
||||||
|
let unclaimed_map: Vec<usize> = page_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(idx, _)| !rect_claimed.contains(idx))
|
||||||
|
.map(|(idx, _)| idx)
|
||||||
|
.collect();
|
||||||
|
for &idx in &table.item_indices {
|
||||||
|
if let Some(&page_idx) = unclaimed_map.get(idx) {
|
||||||
|
if let Some(&(global_idx, _)) = group.get(page_idx) {
|
||||||
|
table_items.insert(global_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let table_y = table.rows.first().copied().unwrap_or(0.0);
|
||||||
|
let table_md = table_to_markdown(&table);
|
||||||
|
page_tables
|
||||||
|
.entry(page)
|
||||||
|
.or_default()
|
||||||
|
.push((table_y, table_md));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out table items and process the rest
|
// Filter out table items and process the rest
|
||||||
|
|||||||
+212
-1
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Detects tabular data in PDF text items and converts to markdown tables.
|
//! Detects tabular data in PDF text items and converts to markdown tables.
|
||||||
|
|
||||||
use crate::extractor::TextItem;
|
use crate::extractor::{PdfRect, TextItem};
|
||||||
|
|
||||||
/// Detection mode controls thresholds for table validation
|
/// Detection mode controls thresholds for table validation
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -26,6 +26,217 @@ pub struct Table {
|
|||||||
pub item_indices: Vec<usize>,
|
pub item_indices: Vec<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect tables from explicit rectangle (`re`) operators in the PDF.
|
||||||
|
///
|
||||||
|
/// Many PDFs draw cell borders using `re` (rectangle) operators. Table pages
|
||||||
|
/// typically have 100-200+ rects while non-table pages have < 30. This function
|
||||||
|
/// identifies grids of cell-sized rectangles and assigns text items to cells.
|
||||||
|
pub fn detect_tables_from_rects(items: &[TextItem], rects: &[PdfRect], page: u32) -> Vec<Table> {
|
||||||
|
// Filter rects on this page; normalize negative widths/heights; skip tiny rects.
|
||||||
|
let mut page_rects: Vec<(f32, f32, f32, f32)> = Vec::new(); // (x, y, w, h) normalized
|
||||||
|
for r in rects {
|
||||||
|
if r.page != page {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (mut x, mut y, mut w, mut h) = (r.x, r.y, r.width, r.height);
|
||||||
|
if w < 0.0 {
|
||||||
|
x += w;
|
||||||
|
w = -w;
|
||||||
|
}
|
||||||
|
if h < 0.0 {
|
||||||
|
y += h;
|
||||||
|
h = -h;
|
||||||
|
}
|
||||||
|
// Skip tiny rects (borders, dots, decorations)
|
||||||
|
if w < 5.0 || h < 5.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
page_rects.push((x, y, w, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need a reasonable number of cell rects to form a table
|
||||||
|
if page_rects.len() < 6 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract unique X and Y edges from all rects
|
||||||
|
let mut x_edges: Vec<f32> = Vec::new();
|
||||||
|
let mut y_edges: Vec<f32> = Vec::new();
|
||||||
|
for &(x, y, w, h) in &page_rects {
|
||||||
|
x_edges.push(x);
|
||||||
|
x_edges.push(x + w);
|
||||||
|
y_edges.push(y);
|
||||||
|
y_edges.push(y + h);
|
||||||
|
}
|
||||||
|
|
||||||
|
let x_edges = snap_edges(&x_edges, 2.0);
|
||||||
|
let y_edges = snap_edges(&y_edges, 2.0);
|
||||||
|
|
||||||
|
if x_edges.len() < 3 || y_edges.len() < 4 {
|
||||||
|
// Need at least 2 columns (3 edges) and 3 rows (4 edges)
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort column edges left-to-right, row edges top-to-bottom (highest Y first for PDF)
|
||||||
|
let mut col_edges = x_edges;
|
||||||
|
col_edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let mut row_edges = y_edges;
|
||||||
|
row_edges.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
let num_cols = col_edges.len() - 1;
|
||||||
|
let num_rows = row_edges.len() - 1;
|
||||||
|
|
||||||
|
if num_cols < 2 || num_rows < 2 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that cell-sized rects actually fill the grid
|
||||||
|
// Count how many grid cells have a matching rect
|
||||||
|
let mut filled_cells = 0u32;
|
||||||
|
for row in 0..num_rows {
|
||||||
|
let y_top = row_edges[row];
|
||||||
|
let y_bot = row_edges[row + 1];
|
||||||
|
for col in 0..num_cols {
|
||||||
|
let x_left = col_edges[col];
|
||||||
|
let x_right = col_edges[col + 1];
|
||||||
|
// Check if any rect approximately covers this cell
|
||||||
|
let cell_covered = page_rects.iter().any(|&(rx, ry, rw, rh)| {
|
||||||
|
let tol = 3.0;
|
||||||
|
rx <= x_left + tol
|
||||||
|
&& (rx + rw) >= x_right - tol
|
||||||
|
&& ry <= y_top + tol
|
||||||
|
&& (ry + rh) >= y_bot - tol
|
||||||
|
});
|
||||||
|
if cell_covered {
|
||||||
|
filled_cells += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_cells = (num_cols * num_rows) as f32;
|
||||||
|
let fill_ratio = filled_cells as f32 / total_cells;
|
||||||
|
|
||||||
|
// Require at least 30% of cells to be backed by rects
|
||||||
|
if fill_ratio < 0.3 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build table: assign text items to cells
|
||||||
|
let (cells, item_indices) = assign_items_to_grid(items, &col_edges, &row_edges, page);
|
||||||
|
|
||||||
|
// Compute column centers and row centers for the Table struct
|
||||||
|
let columns: Vec<f32> = (0..num_cols)
|
||||||
|
.map(|c| (col_edges[c] + col_edges[c + 1]) / 2.0)
|
||||||
|
.collect();
|
||||||
|
let rows: Vec<f32> = (0..num_rows)
|
||||||
|
.map(|r| (row_edges[r] + row_edges[r + 1]) / 2.0)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Skip if no text was assigned
|
||||||
|
if item_indices.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip tables with only 1 row of content (header-only)
|
||||||
|
let non_empty_rows = cells
|
||||||
|
.iter()
|
||||||
|
.filter(|row| row.iter().any(|c| !c.trim().is_empty()))
|
||||||
|
.count();
|
||||||
|
if non_empty_rows < 2 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
vec![Table {
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
cells,
|
||||||
|
item_indices,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deduplicate nearby edge values within a tolerance, returning sorted unique edges.
|
||||||
|
fn snap_edges(values: &[f32], tolerance: f32) -> Vec<f32> {
|
||||||
|
let mut sorted: Vec<f32> = values.to_vec();
|
||||||
|
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
let mut snapped: Vec<f32> = Vec::new();
|
||||||
|
for &v in &sorted {
|
||||||
|
if let Some(last) = snapped.last() {
|
||||||
|
if (v - *last).abs() <= tolerance {
|
||||||
|
continue; // Skip — too close to previous edge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapped.push(v);
|
||||||
|
}
|
||||||
|
snapped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign text items to grid cells defined by column/row edges.
|
||||||
|
///
|
||||||
|
/// Returns `(cells, item_indices)` where `cells[row][col]` is the cell text
|
||||||
|
/// and `item_indices` lists the original item indices that were consumed.
|
||||||
|
fn assign_items_to_grid(
|
||||||
|
items: &[TextItem],
|
||||||
|
col_edges: &[f32],
|
||||||
|
row_edges: &[f32],
|
||||||
|
page: u32,
|
||||||
|
) -> (Vec<Vec<String>>, Vec<usize>) {
|
||||||
|
let num_cols = col_edges.len() - 1;
|
||||||
|
let num_rows = row_edges.len() - 1;
|
||||||
|
|
||||||
|
// Collect items per cell for proper sorting before joining
|
||||||
|
let mut cell_items: Vec<Vec<Vec<(usize, &TextItem)>>> =
|
||||||
|
vec![vec![Vec::new(); num_cols]; num_rows];
|
||||||
|
let mut indices = Vec::new();
|
||||||
|
|
||||||
|
for (idx, item) in items.iter().enumerate() {
|
||||||
|
if item.page != page {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Use item center for assignment
|
||||||
|
let cx = item.x + item.width / 2.0;
|
||||||
|
let cy = item.y;
|
||||||
|
|
||||||
|
// Find column: cx must be between col_edges[c] and col_edges[c+1]
|
||||||
|
let col = (0..num_cols).find(|&c| cx >= col_edges[c] - 2.0 && cx <= col_edges[c + 1] + 2.0);
|
||||||
|
// Find row: cy must be between row_edges[r+1] (bottom) and row_edges[r] (top)
|
||||||
|
let row = (0..num_rows).find(|&r| cy >= row_edges[r + 1] - 2.0 && cy <= row_edges[r] + 2.0);
|
||||||
|
|
||||||
|
if let (Some(c), Some(r)) = (col, row) {
|
||||||
|
cell_items[r][c].push((idx, item));
|
||||||
|
indices.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build cell strings: sort items within each cell by Y descending then X ascending
|
||||||
|
let mut cells: Vec<Vec<String>> = Vec::with_capacity(num_rows);
|
||||||
|
for row_items in &mut cell_items {
|
||||||
|
let mut row_cells = Vec::with_capacity(num_cols);
|
||||||
|
for col_items in row_items.iter_mut() {
|
||||||
|
col_items.sort_by(|a, b| {
|
||||||
|
b.1.y
|
||||||
|
.partial_cmp(&a.1.y)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
.then_with(|| {
|
||||||
|
a.1.x
|
||||||
|
.partial_cmp(&b.1.x)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let text: String = col_items
|
||||||
|
.iter()
|
||||||
|
.map(|(_, item)| item.text.trim())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
row_cells.push(text);
|
||||||
|
}
|
||||||
|
cells.push(row_cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
(cells, indices)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a whitespace-separated token looks like a financial number.
|
/// Check if a whitespace-separated token looks like a financial number.
|
||||||
/// Must contain at least one digit; all chars must be `0-9 , . ( ) - + %`.
|
/// Must contain at least one digit; all chars must be `0-9 , . ( ) - + %`.
|
||||||
fn is_numeric_token(tok: &str) -> bool {
|
fn is_numeric_token(tok: &str) -> bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user