feat: tagged PDF structure tree support (#4)
* feat: tagged PDF structure tree support for semantic markdown generation Parse /StructTreeRoot from tagged PDFs and use semantic roles (H1-H6, P, LI, BlockQuote, Code, Caption) to improve markdown output. Structure tree headings add to font-size heuristics without suppressing them. Coverage threshold (≥50%) ensures only properly tagged PDFs activate this path. Phase 1: Parse structure tree with role maps, MCID collection, flattening Phase 2: Capture MCIDs from BMC/BDC operators, tag TextItems Phase 3: Structure-aware markdown generation in convert loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: accumulate consecutive code lines into single fenced block Per-line code fencing produced broken markdown for multi-line code blocks (separate ``` open/close per line). Unify struct-tree Code role and font-based monospace detection into a single is_code_line check with in_code_block state for proper accumulation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add tagged PDF fixture with Firecrawl docs content Synthetic 7-page PDF with rich structure tree exercising H1, H2, H3, P, Code, LI, Caption, TH, TD roles. Generated via fpdf2 script. Integration test verifies struct tree parsing and code fence output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove python PDF generator script from repo Keep the generated fixture PDF but don't track the generator script. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: handle malformed bare-name struct types in tagged PDFs Some PDF generators (e.g. fpdf2) write /S Code instead of /S /Code in structure elements. lopdf silently drops these objects since bare tokens are invalid PDF syntax. Add a pre-processor that scans for known bare struct type names and prepends / before loading. Unifies path and memory loading through the same fix pipeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update lopdf dependency to main branch The firecrawl/zlib-checksum-encrypted branch was merged and deleted. Point to main which includes all previously merged fixes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: switch lopdf to upstream repo pinned at 845cd3d Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
76ea52680b
commit
d9c2143c32
@@ -124,10 +124,18 @@ pub(crate) fn extract_page_text_items(
|
||||
let mut line_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
let mut in_text_block = false;
|
||||
|
||||
// Marked content (ActualText) tracking
|
||||
let mut marked_content_stack: Vec<Option<String>> = Vec::new();
|
||||
// Marked content tracking: (ActualText, MCID) per nesting level
|
||||
struct MarkedContentEntry {
|
||||
actual_text: Option<String>,
|
||||
mcid: Option<i64>,
|
||||
}
|
||||
let mut marked_content_stack: Vec<MarkedContentEntry> = Vec::new();
|
||||
let mut suppress_glyph_extraction = false;
|
||||
let mut actual_text_start_tm: Option<[f32; 6]> = None; // text matrix at BDC entry
|
||||
/// Get the innermost MCID from the marked content stack.
|
||||
fn current_mcid(stack: &[MarkedContentEntry]) -> Option<i64> {
|
||||
stack.iter().rev().find_map(|e| e.mcid)
|
||||
}
|
||||
|
||||
for op in &content.operations {
|
||||
trace!("{} {:?}", op.operator, op.operands);
|
||||
@@ -292,6 +300,7 @@ pub(crate) fn extract_page_text_items(
|
||||
is_bold: is_bold_font(base_font),
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
mcid: current_mcid(&marked_content_stack),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -440,6 +449,7 @@ pub(crate) fn extract_page_text_items(
|
||||
is_bold: is_bold_font(base_font),
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
mcid: current_mcid(&marked_content_stack),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -496,6 +506,7 @@ pub(crate) fn extract_page_text_items(
|
||||
is_bold: is_bold_font(base_font),
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
mcid: current_mcid(&marked_content_stack),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -531,11 +542,15 @@ pub(crate) fn extract_page_text_items(
|
||||
}
|
||||
"BMC" => {
|
||||
// Begin Marked Content (no properties)
|
||||
marked_content_stack.push(None);
|
||||
marked_content_stack.push(MarkedContentEntry {
|
||||
actual_text: None,
|
||||
mcid: None,
|
||||
});
|
||||
}
|
||||
"BDC" => {
|
||||
// Begin Marked Content with properties — extract ActualText
|
||||
// Begin Marked Content with properties — extract ActualText and MCID
|
||||
let mut actual_text: Option<String> = None;
|
||||
let mut mcid: Option<i64> = None;
|
||||
if op.operands.len() >= 2 {
|
||||
let dict = match &op.operands[1] {
|
||||
Object::Dictionary(d) => Some(d.clone()),
|
||||
@@ -549,47 +564,56 @@ pub(crate) fn extract_page_text_items(
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if let Ok(Object::Integer(id)) = d.get(b"MCID") {
|
||||
mcid = Some(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if actual_text.is_some() {
|
||||
suppress_glyph_extraction = true;
|
||||
actual_text_start_tm = Some(text_matrix);
|
||||
}
|
||||
marked_content_stack.push(actual_text);
|
||||
marked_content_stack.push(MarkedContentEntry { actual_text, mcid });
|
||||
}
|
||||
"EMC" => {
|
||||
// End Marked Content — emit ActualText item with correct width
|
||||
if let Some(Some(at)) = marked_content_stack.pop() {
|
||||
// Compute width from text matrix advancement during BDC..EMC
|
||||
if let Some(start_tm) = actual_text_start_tm.take() {
|
||||
let combined = multiply_matrices(&start_tm, &ctm);
|
||||
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
// Width in device space from text matrix delta
|
||||
let delta_ts = text_matrix[4] - start_tm[4];
|
||||
let scale_x = start_tm[0] * ctm[0] + start_tm[1] * ctm[2];
|
||||
let width = (delta_ts * scale_x).abs();
|
||||
if !at.trim().is_empty() {
|
||||
let base_font = font_base_names
|
||||
.get(¤t_font)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(¤t_font);
|
||||
items.push(TextItem {
|
||||
text: expand_ligatures(&at),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font),
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
});
|
||||
if let Some(entry) = marked_content_stack.pop() {
|
||||
if let Some(at) = entry.actual_text {
|
||||
// Compute width from text matrix advancement during BDC..EMC
|
||||
if let Some(start_tm) = actual_text_start_tm.take() {
|
||||
let combined = multiply_matrices(&start_tm, &ctm);
|
||||
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
// Width in device space from text matrix delta
|
||||
let delta_ts = text_matrix[4] - start_tm[4];
|
||||
let scale_x = start_tm[0] * ctm[0] + start_tm[1] * ctm[2];
|
||||
let width = (delta_ts * scale_x).abs();
|
||||
if !at.trim().is_empty() {
|
||||
let base_font = font_base_names
|
||||
.get(¤t_font)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(¤t_font);
|
||||
items.push(TextItem {
|
||||
text: expand_ligatures(&at),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font),
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
mcid: entry
|
||||
.mcid
|
||||
.or_else(|| current_mcid(&marked_content_stack)),
|
||||
});
|
||||
}
|
||||
}
|
||||
suppress_glyph_extraction =
|
||||
marked_content_stack.iter().any(|e| e.actual_text.is_some());
|
||||
}
|
||||
suppress_glyph_extraction = marked_content_stack.iter().any(|a| a.is_some());
|
||||
}
|
||||
}
|
||||
"re" => {
|
||||
|
||||
@@ -796,6 +796,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ pub fn extract_page_links(doc: &Document, page_id: ObjectId, page_num: u32) -> V
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Link(url),
|
||||
mcid: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -316,5 +317,6 @@ pub(crate) fn walk_form_fields(
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::FormField,
|
||||
mcid: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -319,6 +319,7 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
|
||||
is_bold: first.is_bold,
|
||||
is_italic: first.is_italic,
|
||||
item_type: first.item_type.clone(),
|
||||
mcid: first.mcid,
|
||||
});
|
||||
|
||||
i = j;
|
||||
@@ -359,6 +360,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
TextItem {
|
||||
text: "World".into(),
|
||||
@@ -372,6 +374,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
TextItem {
|
||||
text: "Next line".into(),
|
||||
@@ -385,6 +388,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -438,6 +442,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
TextItem {
|
||||
text: "Prague".into(),
|
||||
@@ -451,6 +456,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
TextItem {
|
||||
text: "Rules".into(),
|
||||
@@ -464,6 +470,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -488,6 +495,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
TextItem {
|
||||
text: "A".into(),
|
||||
@@ -501,6 +509,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
TextItem {
|
||||
text: "V".into(),
|
||||
@@ -514,6 +523,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -540,6 +550,7 @@ mod tests {
|
||||
is_bold: true,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,6 +584,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +619,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
TextItem {
|
||||
text: "履行義務".into(),
|
||||
@@ -620,6 +633,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
TextItem {
|
||||
text: "を識別す".into(),
|
||||
@@ -633,6 +647,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -654,6 +669,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,6 +781,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
TextItem {
|
||||
text: "\u{05D1}".into(), // bet at x=200 (rightmost)
|
||||
@@ -778,6 +795,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
];
|
||||
sort_line_items(&mut items);
|
||||
@@ -801,6 +819,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
TextItem {
|
||||
text: "World".into(),
|
||||
@@ -814,6 +833,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
},
|
||||
];
|
||||
sort_line_items(&mut items);
|
||||
@@ -853,6 +873,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -896,6 +917,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -939,6 +961,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
@@ -408,6 +408,7 @@ fn extract_form_xobject_text_inner(
|
||||
is_bold: is_bold_font(base_font),
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -549,6 +550,7 @@ fn extract_form_xobject_text_inner(
|
||||
is_bold: is_bold_font(base_font),
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+28
-9
@@ -28,6 +28,7 @@ pub mod extractor;
|
||||
pub mod glyph_names;
|
||||
pub mod markdown;
|
||||
pub mod process_mode;
|
||||
pub mod structure_tree;
|
||||
pub mod tables;
|
||||
pub mod text_utils;
|
||||
pub mod tounicode;
|
||||
@@ -273,20 +274,21 @@ pub fn process_pdf_mem_with_config(
|
||||
/// are combined here, but lopdf loads the full doc in `load()` so we extract
|
||||
/// page count from it directly to avoid the metadata-only round-trip.
|
||||
fn load_document_from_path<P: AsRef<Path>>(path: P) -> Result<(Document, u32), PdfError> {
|
||||
let doc = match Document::load(&path) {
|
||||
Ok(d) => d,
|
||||
Err(ref e) if is_encrypted_lopdf_error(e) => Document::load_with_password(&path, "")?,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let page_count = doc.get_pages().len() as u32;
|
||||
Ok((doc, page_count))
|
||||
let buffer = std::fs::read(&path)?;
|
||||
load_document_from_mem(&buffer)
|
||||
}
|
||||
|
||||
/// Load a PDF from a memory buffer.
|
||||
fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), PdfError> {
|
||||
let doc = match Document::load_mem(buffer) {
|
||||
// Fix malformed struct element names before parsing. Some PDF generators
|
||||
// write bare names (/S Code) instead of proper PDF names (/S /Code), which
|
||||
// causes lopdf to silently drop the entire object.
|
||||
let fixed = structure_tree::fix_bare_struct_names(buffer);
|
||||
let buf = fixed.as_ref();
|
||||
|
||||
let doc = match Document::load_mem(buf) {
|
||||
Ok(d) => d,
|
||||
Err(ref e) if is_encrypted_lopdf_error(e) => Document::load_mem_with_password(buffer, "")?,
|
||||
Err(ref e) if is_encrypted_lopdf_error(e) => Document::load_mem_with_password(buf, "")?,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let page_count = doc.get_pages().len() as u32;
|
||||
@@ -350,6 +352,22 @@ fn process_document(
|
||||
Some(extracted?)
|
||||
};
|
||||
|
||||
// Parse structure tree for tagged PDFs (reuses the loaded document)
|
||||
let struct_roles = structure_tree::StructTree::from_doc(&doc).and_then(|tree| {
|
||||
let page_ids = doc.get_pages();
|
||||
let roles = tree.mcid_to_roles(&page_ids);
|
||||
if roles.is_empty() {
|
||||
None
|
||||
} else {
|
||||
log::debug!(
|
||||
"structure tree: {} pages with MCID roles, {} total MCIDs",
|
||||
roles.len(),
|
||||
tree.mcid_count()
|
||||
);
|
||||
Some(roles)
|
||||
}
|
||||
});
|
||||
|
||||
let (markdown, layout, has_encoding_issues) = match extracted {
|
||||
Some(((items, rects, lines), page_thresholds)) => {
|
||||
let layout = compute_layout_complexity(&items, &rects, &lines);
|
||||
@@ -363,6 +381,7 @@ fn process_document(
|
||||
&rects,
|
||||
&lines,
|
||||
&page_thresholds,
|
||||
struct_roles.as_ref(),
|
||||
))
|
||||
};
|
||||
|
||||
|
||||
+407
-28
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::structure_tree::StructRole;
|
||||
use crate::types::TextLine;
|
||||
|
||||
use super::analysis::{
|
||||
@@ -13,6 +14,50 @@ use super::postprocess::clean_markdown;
|
||||
use super::preprocess::{merge_drop_caps, merge_heading_lines};
|
||||
use super::MarkdownOptions;
|
||||
|
||||
/// Resolve the dominant structure role for a text line by looking up its items' MCIDs.
|
||||
///
|
||||
/// Returns the first non-container role found (skipping Document/Part/Sect/Div/NonStruct/Span).
|
||||
/// These wrapper roles don't carry useful semantic info for markdown generation.
|
||||
fn resolve_line_struct_role(
|
||||
line: &TextLine,
|
||||
struct_roles: &std::collections::HashMap<u32, std::collections::HashMap<i64, StructRole>>,
|
||||
) -> Option<StructRole> {
|
||||
let page_roles = struct_roles.get(&line.page)?;
|
||||
for item in &line.items {
|
||||
if let Some(mcid) = item.mcid {
|
||||
if let Some(role) = page_roles.get(&mcid) {
|
||||
match role {
|
||||
// Skip container/wrapper roles — not useful for line classification
|
||||
StructRole::Document
|
||||
| StructRole::Part
|
||||
| StructRole::Art
|
||||
| StructRole::Sect
|
||||
| StructRole::Div
|
||||
| StructRole::NonStruct
|
||||
| StructRole::Span
|
||||
| StructRole::Private => continue,
|
||||
_ => return Some(role.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Map a StructRole heading variant to a markdown heading level (1–6).
|
||||
fn struct_role_heading_level(role: &StructRole) -> Option<usize> {
|
||||
match role {
|
||||
StructRole::H => Some(1), // Generic heading → H1
|
||||
StructRole::H1 => Some(1),
|
||||
StructRole::H2 => Some(2),
|
||||
StructRole::H3 => Some(3),
|
||||
StructRole::H4 => Some(4),
|
||||
StructRole::H5 => Some(5),
|
||||
StructRole::H6 => Some(6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge continuation tables that span across page breaks.
|
||||
///
|
||||
/// When consecutive pages each have exactly one table with the same number of columns
|
||||
@@ -182,6 +227,9 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
page_tables: std::collections::HashMap<u32, Vec<(f32, String)>>,
|
||||
page_images: std::collections::HashMap<u32, Vec<(f32, String)>>,
|
||||
band_split_pages: &HashSet<u32>,
|
||||
struct_roles: Option<
|
||||
&std::collections::HashMap<u32, std::collections::HashMap<i64, StructRole>>,
|
||||
>,
|
||||
) -> String {
|
||||
if lines.is_empty() && page_tables.is_empty() && page_images.is_empty() {
|
||||
return String::new();
|
||||
@@ -215,6 +263,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
let mut in_list = false;
|
||||
let mut in_paragraph = false;
|
||||
let mut last_list_x: Option<f32> = None;
|
||||
let mut in_code_block = false;
|
||||
let mut prev_had_dot_leaders = false;
|
||||
let mut inserted_tables: HashSet<(u32, usize)> = HashSet::new();
|
||||
let mut inserted_images: HashSet<(u32, usize)> = HashSet::new();
|
||||
@@ -233,6 +282,10 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
if line.page != current_page {
|
||||
// Flush current page's remaining tables and images
|
||||
if current_page > 0 {
|
||||
if in_code_block {
|
||||
output.push_str("```\n");
|
||||
in_code_block = false;
|
||||
}
|
||||
flush_page_tables_and_images(
|
||||
current_page,
|
||||
&page_tables,
|
||||
@@ -352,7 +405,25 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
|
||||
// Detect figure/table captions and source citations
|
||||
// These should be on their own line followed by a paragraph break
|
||||
if is_caption_line(plain_trimmed) {
|
||||
let struct_role = struct_roles.and_then(|roles| resolve_line_struct_role(&line, roles));
|
||||
|
||||
// Determine if this line is code (struct-tree or font-based) for block accumulation
|
||||
let is_code_line = struct_role
|
||||
.as_ref()
|
||||
.is_some_and(|r| matches!(r, StructRole::Code))
|
||||
|| (options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)));
|
||||
|
||||
// Close code block when transitioning to non-code
|
||||
if in_code_block && !is_code_line {
|
||||
output.push_str("```\n");
|
||||
in_code_block = false;
|
||||
}
|
||||
|
||||
if struct_role
|
||||
.as_ref()
|
||||
.is_some_and(|r| matches!(r, StructRole::Caption))
|
||||
|| is_caption_line(plain_trimmed)
|
||||
{
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
@@ -362,27 +433,48 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect headers by font size
|
||||
// Note: Headers typically shouldn't have bold markers since they're already emphasized
|
||||
// Skip very short text (drop caps/labels) and very long text (body paragraphs)
|
||||
if options.detect_headers
|
||||
// Detect headers: structure-tree headings win, then font-size heuristics.
|
||||
// Structure roles ADD headings (e.g. same-size text tagged H2) but do NOT
|
||||
// suppress headings that the font heuristic would detect (some tagged PDFs
|
||||
// mark obvious headings as P or Span).
|
||||
let struct_heading = struct_role.as_ref().and_then(struct_role_heading_level);
|
||||
let heuristic_heading = if options.detect_headers
|
||||
&& plain_trimmed.len() > 3
|
||||
&& plain_trimmed.split_whitespace().count() <= 15
|
||||
{
|
||||
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
|
||||
if let Some(header_level) =
|
||||
detect_header_level(line_font_size, base_size, &heading_tiers)
|
||||
{
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
}
|
||||
let prefix = "#".repeat(header_level);
|
||||
// Use plain text for headers to avoid redundant formatting
|
||||
output.push_str(&format!("{} {}\n\n", prefix, plain_trimmed));
|
||||
in_list = false;
|
||||
continue;
|
||||
detect_header_level(line_font_size, base_size, &heading_tiers)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(level) = struct_heading.or(heuristic_heading) {
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
}
|
||||
let prefix = "#".repeat(level);
|
||||
// Use plain text for headers to avoid redundant formatting
|
||||
output.push_str(&format!("{} {}\n\n", prefix, plain_trimmed));
|
||||
in_list = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Structure-tree list item (LI only — LBody is a continuation, not a new item)
|
||||
if struct_role
|
||||
.as_ref()
|
||||
.is_some_and(|r| matches!(r, StructRole::LI))
|
||||
&& !is_list_item(plain_trimmed)
|
||||
{
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
}
|
||||
output.push_str(&format!("- {}", trimmed));
|
||||
output.push('\n');
|
||||
in_list = true;
|
||||
last_list_x = line.items.first().map(|i| i.x);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect list items
|
||||
@@ -428,18 +520,32 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
}
|
||||
}
|
||||
|
||||
// Detect code blocks by font
|
||||
if options.detect_code {
|
||||
let is_mono = line.items.iter().any(|i| is_monospace_font(&i.font));
|
||||
if is_mono {
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
}
|
||||
// Use plain text for code blocks
|
||||
output.push_str(&format!("```\n{}\n```\n", plain_trimmed));
|
||||
continue;
|
||||
// Structure-tree block quote
|
||||
if struct_role
|
||||
.as_ref()
|
||||
.is_some_and(|r| matches!(r, StructRole::BlockQuote))
|
||||
{
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
}
|
||||
output.push_str(&format!("> {}\n", trimmed));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code block accumulation (struct-tree Code role or monospace font)
|
||||
if is_code_line {
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
}
|
||||
if !in_code_block {
|
||||
output.push_str("```\n");
|
||||
in_code_block = true;
|
||||
}
|
||||
output.push_str(plain_trimmed);
|
||||
output.push('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular text - join lines within same paragraph with space
|
||||
@@ -456,6 +562,11 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
prev_had_dot_leaders = cur_dot_leaders;
|
||||
}
|
||||
|
||||
// Close any trailing code block
|
||||
if in_code_block {
|
||||
output.push_str("```\n");
|
||||
}
|
||||
|
||||
// Flush current page and any remaining pages with tables/images
|
||||
// (handles table-only pages after the last text line, and trailing image-only pages)
|
||||
flush_page_tables_and_images(
|
||||
@@ -680,3 +791,271 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
// Clean up and post-process
|
||||
clean_markdown(output, &options)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::structure_tree::StructRole;
|
||||
use crate::types::TextItem;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_item(text: &str, page: u32, mcid: Option<i64>) -> TextItem {
|
||||
TextItem {
|
||||
text: text.to_string(),
|
||||
x: 72.0,
|
||||
y: 700.0,
|
||||
width: 100.0,
|
||||
height: 12.0,
|
||||
font: "Helvetica".to_string(),
|
||||
font_size: 12.0,
|
||||
page,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: crate::types::ItemType::Text,
|
||||
mcid,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_line(items: Vec<TextItem>) -> TextLine {
|
||||
let y = items.first().map(|i| i.y).unwrap_or(0.0);
|
||||
let page = items.first().map(|i| i.page).unwrap_or(1);
|
||||
TextLine {
|
||||
items,
|
||||
y,
|
||||
page,
|
||||
adaptive_threshold: 0.10,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_struct_role_heading() {
|
||||
let lines = vec![
|
||||
make_line(vec![make_item("Introduction", 1, Some(0))]),
|
||||
make_line(vec![{
|
||||
let mut item = make_item("Body text here.", 1, Some(1));
|
||||
item.y = 680.0;
|
||||
item
|
||||
}]),
|
||||
];
|
||||
|
||||
let mut page_roles = HashMap::new();
|
||||
page_roles.insert(0i64, StructRole::H1);
|
||||
page_roles.insert(1i64, StructRole::P);
|
||||
let mut roles = HashMap::new();
|
||||
roles.insert(1u32, page_roles);
|
||||
|
||||
let md = to_markdown_from_lines_with_tables_and_images(
|
||||
lines,
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
|
||||
assert!(
|
||||
md.contains("# Introduction"),
|
||||
"Should have H1 heading: {md}"
|
||||
);
|
||||
assert!(
|
||||
md.contains("Body text here."),
|
||||
"Should have body text: {md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_struct_role_list_item() {
|
||||
let lines = vec![make_line(vec![make_item("First item", 1, Some(0))])];
|
||||
|
||||
let mut page_roles = HashMap::new();
|
||||
page_roles.insert(0i64, StructRole::LI);
|
||||
let mut roles = HashMap::new();
|
||||
roles.insert(1u32, page_roles);
|
||||
|
||||
let md = to_markdown_from_lines_with_tables_and_images(
|
||||
lines,
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
|
||||
assert!(
|
||||
md.contains("- First item"),
|
||||
"Should format as list item: {md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_struct_role_blockquote() {
|
||||
let lines = vec![make_line(vec![make_item("Quoted text", 1, Some(0))])];
|
||||
|
||||
let mut page_roles = HashMap::new();
|
||||
page_roles.insert(0i64, StructRole::BlockQuote);
|
||||
let mut roles = HashMap::new();
|
||||
roles.insert(1u32, page_roles);
|
||||
|
||||
let md = to_markdown_from_lines_with_tables_and_images(
|
||||
lines,
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
|
||||
assert!(
|
||||
md.contains("> Quoted text"),
|
||||
"Should format as blockquote: {md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_struct_role_heading_levels() {
|
||||
let mcids = vec![
|
||||
(StructRole::H1, "Title"),
|
||||
(StructRole::H2, "Section"),
|
||||
(StructRole::H3, "Subsection"),
|
||||
];
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut page_roles = HashMap::new();
|
||||
for (i, (role, text)) in mcids.iter().enumerate() {
|
||||
let mut item = make_item(text, 1, Some(i as i64));
|
||||
item.y = 700.0 - (i as f32 * 30.0);
|
||||
lines.push(make_line(vec![item]));
|
||||
page_roles.insert(i as i64, role.clone());
|
||||
}
|
||||
|
||||
let mut roles = HashMap::new();
|
||||
roles.insert(1u32, page_roles);
|
||||
|
||||
let md = to_markdown_from_lines_with_tables_and_images(
|
||||
lines,
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
|
||||
assert!(md.contains("# Title"), "H1 → #: {md}");
|
||||
assert!(md.contains("## Section"), "H2 → ##: {md}");
|
||||
assert!(md.contains("### Subsection"), "H3 → ###: {md}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_struct_roles_falls_back_to_heuristics() {
|
||||
let mut item = make_item("Big Title", 1, None);
|
||||
item.font_size = 24.0;
|
||||
item.height = 24.0;
|
||||
|
||||
let lines = vec![
|
||||
make_line(vec![item]),
|
||||
make_line(vec![{
|
||||
let mut body = make_item("Normal body text.", 1, None);
|
||||
body.y = 660.0;
|
||||
body
|
||||
}]),
|
||||
];
|
||||
|
||||
let md = to_markdown_from_lines_with_tables_and_images(
|
||||
lines,
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
md.contains("# Big Title"),
|
||||
"Font heuristic should detect heading: {md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_line_struct_role_skips_containers() {
|
||||
let mut page_roles = HashMap::new();
|
||||
page_roles.insert(0i64, StructRole::Div);
|
||||
page_roles.insert(1i64, StructRole::H2);
|
||||
let mut roles = HashMap::new();
|
||||
roles.insert(1u32, page_roles);
|
||||
|
||||
let line = make_line(vec![
|
||||
make_item("Part ", 1, Some(0)),
|
||||
make_item("Title", 1, Some(1)),
|
||||
]);
|
||||
|
||||
let role = resolve_line_struct_role(&line, &roles);
|
||||
assert_eq!(role, Some(StructRole::H2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_struct_role_code() {
|
||||
let lines = vec![make_line(vec![make_item("fn main() {}", 1, Some(0))])];
|
||||
|
||||
let mut page_roles = HashMap::new();
|
||||
page_roles.insert(0i64, StructRole::Code);
|
||||
let mut roles = HashMap::new();
|
||||
roles.insert(1u32, page_roles);
|
||||
|
||||
let md = to_markdown_from_lines_with_tables_and_images(
|
||||
lines,
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
|
||||
assert!(
|
||||
md.contains("```\nfn main() {}\n```"),
|
||||
"Should format as code block: {md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_struct_role_code_multiline_accumulation() {
|
||||
let mut line1 = make_item("fn main() {", 1, Some(0));
|
||||
line1.y = 700.0;
|
||||
let mut line2 = make_item(" println!(\"hello\");", 1, Some(1));
|
||||
line2.y = 688.0;
|
||||
let mut line3 = make_item("}", 1, Some(2));
|
||||
line3.y = 676.0;
|
||||
|
||||
let lines = vec![
|
||||
make_line(vec![line1]),
|
||||
make_line(vec![line2]),
|
||||
make_line(vec![line3]),
|
||||
];
|
||||
|
||||
let mut page_roles = HashMap::new();
|
||||
page_roles.insert(0i64, StructRole::Code);
|
||||
page_roles.insert(1i64, StructRole::Code);
|
||||
page_roles.insert(2i64, StructRole::Code);
|
||||
let mut roles = HashMap::new();
|
||||
roles.insert(1u32, page_roles);
|
||||
|
||||
let md = to_markdown_from_lines_with_tables_and_images(
|
||||
lines,
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
|
||||
// Should produce a single fenced block, not three separate ones
|
||||
assert!(
|
||||
md.contains("```\nfn main() {\nprintln!(\"hello\");\n}\n```"),
|
||||
"Should accumulate consecutive code lines into one block: {md}"
|
||||
);
|
||||
// Should NOT have adjacent fences
|
||||
assert!(
|
||||
!md.contains("```\n```"),
|
||||
"Should not have adjacent close/open fences: {md}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+38
-1
@@ -452,7 +452,7 @@ pub fn to_markdown_from_items_with_rects(
|
||||
options: MarkdownOptions,
|
||||
rects: &[crate::types::PdfRect],
|
||||
) -> String {
|
||||
to_markdown_from_items_with_rects_and_lines(items, options, rects, &[], &HashMap::new())
|
||||
to_markdown_from_items_with_rects_and_lines(items, options, rects, &[], &HashMap::new(), None)
|
||||
}
|
||||
|
||||
/// Convert positioned text items to markdown, using rectangles and line segments for table detection.
|
||||
@@ -465,6 +465,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
rects: &[crate::types::PdfRect],
|
||||
pdf_lines: &[crate::types::PdfLine],
|
||||
page_thresholds: &HashMap<u32, f32>,
|
||||
struct_roles: Option<&HashMap<u32, HashMap<i64, crate::structure_tree::StructRole>>>,
|
||||
) -> String {
|
||||
use crate::tables::{
|
||||
detect_tables, detect_tables_from_lines, detect_tables_from_rects, table_to_markdown,
|
||||
@@ -757,6 +758,40 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
}
|
||||
}
|
||||
|
||||
// Check structure tree coverage on ALL text items (before table filtering)
|
||||
// to decide whether to use structure-aware markdown generation.
|
||||
let struct_roles_coverage_ok = struct_roles.is_some_and(|roles| {
|
||||
let total = text_items.len();
|
||||
if total == 0 {
|
||||
return false;
|
||||
}
|
||||
let tagged = text_items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.mcid
|
||||
.and_then(|mcid| {
|
||||
roles
|
||||
.get(&item.page)
|
||||
.and_then(|page_roles| page_roles.get(&mcid))
|
||||
})
|
||||
.is_some()
|
||||
})
|
||||
.count();
|
||||
let coverage = tagged as f32 / total as f32;
|
||||
log::debug!(
|
||||
"structure tree coverage: {}/{} items ({:.0}%)",
|
||||
tagged,
|
||||
total,
|
||||
coverage * 100.0
|
||||
);
|
||||
coverage >= 0.5
|
||||
});
|
||||
let effective_struct_roles = if struct_roles_coverage_ok {
|
||||
struct_roles
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Filter out table items and process the rest
|
||||
let non_table_items: Vec<TextItem> = text_items
|
||||
.into_iter()
|
||||
@@ -841,6 +876,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
page_tables,
|
||||
page_images,
|
||||
&band_split_page_set,
|
||||
effective_struct_roles,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -926,6 +962,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: crate::types::ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,7 @@ pub(crate) fn merge_adjacent_items(items: &[TextItem]) -> (Vec<TextItem>, Vec<Ve
|
||||
is_bold: first_item.is_bold,
|
||||
is_italic: first_item.is_italic,
|
||||
item_type: first_item.item_type.clone(),
|
||||
mcid: first_item.mcid,
|
||||
});
|
||||
index_map.push(indices);
|
||||
|
||||
|
||||
@@ -248,6 +248,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1450,6 +1450,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ pub(crate) fn try_split_financial_item(item: &TextItem) -> Option<Vec<TextItem>>
|
||||
is_bold: item.is_bold,
|
||||
is_italic: item.is_italic,
|
||||
item_type: item.item_type.clone(),
|
||||
mcid: item.mcid,
|
||||
});
|
||||
}
|
||||
Some(sub_items)
|
||||
|
||||
@@ -381,6 +381,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,6 +728,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
page: 1,
|
||||
},
|
||||
));
|
||||
@@ -762,6 +764,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
page: 1,
|
||||
},
|
||||
));
|
||||
|
||||
@@ -231,6 +231,7 @@ fn split_merged_numbers(item: &TextItem, col_boundaries: &[f32]) -> Vec<TextItem
|
||||
is_bold: item.is_bold,
|
||||
is_italic: item.is_italic,
|
||||
item_type: item.item_type.clone(),
|
||||
mcid: item.mcid,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,6 +251,7 @@ fn split_merged_numbers(item: &TextItem, col_boundaries: &[f32]) -> Vec<TextItem
|
||||
is_bold: item.is_bold,
|
||||
is_italic: item.is_italic,
|
||||
item_type: item.item_type.clone(),
|
||||
mcid: item.mcid,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,6 +298,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +315,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -879,6 +879,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,6 +998,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
});
|
||||
// Alternate between letter-gap and word-gap to create bimodal distribution
|
||||
x += w + if wi % 3 == 2 { word_gap } else { letter_gap };
|
||||
@@ -1072,6 +1074,7 @@ mod tests {
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,9 @@ pub struct TextItem {
|
||||
pub is_italic: bool,
|
||||
/// Type of item (text, image, link)
|
||||
pub item_type: ItemType,
|
||||
/// Marked Content ID from the content stream's BDC/BMC operator.
|
||||
/// Used to link this item to the PDF structure tree for tagged PDFs.
|
||||
pub mcid: Option<i64>,
|
||||
}
|
||||
|
||||
/// A line of text (grouped text items)
|
||||
|
||||
Reference in New Issue
Block a user