Add image and hyperlink extraction, improve line grouping
- Add ItemType enum to distinguish Text, Image, and Link items - Extract XObject images from page resources with position/dimensions - Parse Link annotations to extract hyperlinks with URLs - Add include_images and include_links options to MarkdownOptions - Fix line grouping to better detect new lines vs same-line items - Add samples/ and scripts/ to .gitignore Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
5afbb67b90
commit
cd68515028
+246
-1
@@ -7,6 +7,18 @@ use crate::PdfError;
|
||||
use lopdf::{Document, Object, ObjectId};
|
||||
use std::path::Path;
|
||||
|
||||
/// Type of content item
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub enum ItemType {
|
||||
/// Regular text content
|
||||
#[default]
|
||||
Text,
|
||||
/// Image placeholder
|
||||
Image,
|
||||
/// Hyperlink (with URL)
|
||||
Link(String),
|
||||
}
|
||||
|
||||
/// A text item with position information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextItem {
|
||||
@@ -30,6 +42,8 @@ pub struct TextItem {
|
||||
pub is_bold: bool,
|
||||
/// Whether the font is italic
|
||||
pub is_italic: bool,
|
||||
/// Type of item (text, image, link)
|
||||
pub item_type: ItemType,
|
||||
}
|
||||
|
||||
/// A line of text (grouped text items)
|
||||
@@ -297,6 +311,10 @@ fn extract_positioned_text_from_doc(
|
||||
for (page_num, &page_id) in pages.iter() {
|
||||
let items = extract_page_text_items(doc, page_id, *page_num, font_cmaps)?;
|
||||
all_items.extend(items);
|
||||
|
||||
// Extract hyperlinks from page annotations
|
||||
let links = extract_page_links(doc, page_id, *page_num);
|
||||
all_items.extend(links);
|
||||
}
|
||||
|
||||
Ok(all_items)
|
||||
@@ -353,6 +371,9 @@ fn extract_page_text_items(
|
||||
}
|
||||
}
|
||||
|
||||
// Get XObjects (images) from page resources
|
||||
let xobjects = get_page_xobjects(doc, page_id);
|
||||
|
||||
// Get content
|
||||
let content_data = doc
|
||||
.get_page_content(page_id)
|
||||
@@ -479,6 +500,7 @@ fn extract_page_text_items(
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font),
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -524,6 +546,7 @@ fn extract_page_text_items(
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font),
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -565,6 +588,37 @@ fn extract_page_text_items(
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font),
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"Do" => {
|
||||
// XObject invocation - could be an image
|
||||
if !op.operands.is_empty() {
|
||||
if let Ok(name) = op.operands[0].as_name() {
|
||||
let xobj_name = String::from_utf8_lossy(name).to_string();
|
||||
// Check if this XObject is an image
|
||||
if xobjects.contains(&xobj_name) {
|
||||
// Get position from CTM
|
||||
let (x, y) = (ctm[4], ctm[5]);
|
||||
// Get dimensions from CTM scale factors
|
||||
let width = ctm[0].abs();
|
||||
let height = ctm[3].abs();
|
||||
|
||||
items.push(TextItem {
|
||||
text: format!("[Image: {}]", xobj_name),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
font: String::new(),
|
||||
font_size: 0.0,
|
||||
page: page_num,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Image,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -586,6 +640,167 @@ fn get_number(obj: &Object) -> Option<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get XObject names that are images from page resources
|
||||
fn get_page_xobjects(doc: &Document, page_id: ObjectId) -> std::collections::HashSet<String> {
|
||||
let mut image_names = std::collections::HashSet::new();
|
||||
|
||||
// Try to get the page dictionary
|
||||
if let Ok(page_dict) = doc.get_dictionary(page_id) {
|
||||
// Get Resources dictionary
|
||||
let resources = if let Ok(res_ref) = page_dict.get(b"Resources") {
|
||||
if let Ok(obj_ref) = res_ref.as_reference() {
|
||||
doc.get_dictionary(obj_ref).ok()
|
||||
} else {
|
||||
res_ref.as_dict().ok()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(resources) = resources {
|
||||
// Get XObject dictionary from Resources
|
||||
if let Ok(xobjects_ref) = resources.get(b"XObject") {
|
||||
let xobjects = if let Ok(obj_ref) = xobjects_ref.as_reference() {
|
||||
doc.get_dictionary(obj_ref).ok()
|
||||
} else {
|
||||
xobjects_ref.as_dict().ok()
|
||||
};
|
||||
|
||||
if let Some(xobjects) = xobjects {
|
||||
for (name, value) in xobjects.iter() {
|
||||
let name_str = String::from_utf8_lossy(name).to_string();
|
||||
|
||||
// Check if this XObject is an Image
|
||||
// XObjects are typically Stream objects, not Dictionary
|
||||
if let Ok(obj_ref) = value.as_reference() {
|
||||
if let Ok(Object::Stream(stream)) = doc.get_object(obj_ref) {
|
||||
if let Ok(subtype) = stream.dict.get(b"Subtype") {
|
||||
if let Ok(subtype_name) = subtype.as_name() {
|
||||
if subtype_name == b"Image" {
|
||||
image_names.insert(name_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
image_names
|
||||
}
|
||||
|
||||
/// Extract hyperlinks from page annotations
|
||||
pub fn extract_page_links(doc: &Document, page_id: ObjectId, page_num: u32) -> Vec<TextItem> {
|
||||
let mut links = Vec::new();
|
||||
|
||||
// Try to get the page dictionary
|
||||
if let Ok(page_dict) = doc.get_dictionary(page_id) {
|
||||
// Get Annots array
|
||||
let annots = if let Ok(annots_ref) = page_dict.get(b"Annots") {
|
||||
if let Ok(obj_ref) = annots_ref.as_reference() {
|
||||
doc.get_object(obj_ref)
|
||||
.ok()
|
||||
.and_then(|o| o.as_array().ok().cloned())
|
||||
} else {
|
||||
annots_ref.as_array().ok().cloned()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(annots) = annots {
|
||||
for annot_ref in annots {
|
||||
// Get annotation dictionary
|
||||
let annot_dict = if let Ok(obj_ref) = annot_ref.as_reference() {
|
||||
doc.get_dictionary(obj_ref).ok()
|
||||
} else {
|
||||
annot_ref.as_dict().ok()
|
||||
};
|
||||
|
||||
if let Some(annot_dict) = annot_dict {
|
||||
// Check if this is a Link annotation
|
||||
if let Ok(subtype) = annot_dict.get(b"Subtype") {
|
||||
if let Ok(subtype_name) = subtype.as_name() {
|
||||
if subtype_name != b"Link" {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the Rect (position)
|
||||
let rect = if let Ok(rect_obj) = annot_dict.get(b"Rect") {
|
||||
if let Ok(rect_array) = rect_obj.as_array() {
|
||||
if rect_array.len() >= 4 {
|
||||
let x1 = get_number(&rect_array[0]).unwrap_or(0.0);
|
||||
let y1 = get_number(&rect_array[1]).unwrap_or(0.0);
|
||||
let x2 = get_number(&rect_array[2]).unwrap_or(0.0);
|
||||
let y2 = get_number(&rect_array[3]).unwrap_or(0.0);
|
||||
Some((x1, y1, x2 - x1, y2 - y1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get the action (A dictionary) or Dest
|
||||
let uri = extract_link_uri(doc, annot_dict);
|
||||
|
||||
if let (Some((x, y, width, height)), Some(url)) = (rect, uri) {
|
||||
links.push(TextItem {
|
||||
text: url.clone(),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
font: String::new(),
|
||||
font_size: 0.0,
|
||||
page: page_num,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Link(url),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
links
|
||||
}
|
||||
|
||||
/// Extract URI from a link annotation
|
||||
fn extract_link_uri(doc: &Document, annot_dict: &lopdf::Dictionary) -> Option<String> {
|
||||
// Try to get the A (Action) dictionary
|
||||
if let Ok(action_ref) = annot_dict.get(b"A") {
|
||||
let action_dict = if let Ok(obj_ref) = action_ref.as_reference() {
|
||||
doc.get_dictionary(obj_ref).ok()
|
||||
} else {
|
||||
action_ref.as_dict().ok()
|
||||
};
|
||||
|
||||
if let Some(action_dict) = action_dict {
|
||||
// Check for URI action
|
||||
if let Ok(uri_obj) = action_dict.get(b"URI") {
|
||||
if let Ok(uri_str) = uri_obj.as_str() {
|
||||
return Some(String::from_utf8_lossy(uri_str).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try Dest (named destination) - less common for external links
|
||||
// We'll skip this for now as it requires looking up named destinations
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Compute effective font size from base size and text matrix
|
||||
/// Text matrix is [a, b, c, d, tx, ty] where a,d are scale factors
|
||||
fn effective_font_size(base_size: f32, text_matrix: &[f32; 6]) -> f32 {
|
||||
@@ -970,7 +1185,34 @@ fn group_single_column(items: Vec<TextItem>) -> Vec<TextLine> {
|
||||
for item in items {
|
||||
// Only check the most recent line for merging
|
||||
let should_merge = lines.last().is_some_and(|last_line| {
|
||||
last_line.page == item.page && (last_line.y - item.y).abs() < y_tolerance
|
||||
if last_line.page != item.page {
|
||||
return false;
|
||||
}
|
||||
let y_diff = (last_line.y - item.y).abs();
|
||||
if y_diff >= y_tolerance {
|
||||
return false;
|
||||
}
|
||||
// Check if this looks like a new line despite similar Y:
|
||||
// If items are at the same X position (left margin) but different Y,
|
||||
// they're vertically stacked lines, not the same line
|
||||
let has_y_change = y_diff > 0.5;
|
||||
if has_y_change {
|
||||
if let Some(first_item) = last_line.items.first() {
|
||||
let at_same_x = (item.x - first_item.x).abs() < 5.0;
|
||||
// If at same X (left margin) with Y change, it's likely a new line
|
||||
if at_same_x {
|
||||
return false;
|
||||
}
|
||||
// If new item starts significantly to the left with Y change,
|
||||
// it's a new line (not just out-of-order items on same line)
|
||||
if let Some(last_item) = last_line.items.last() {
|
||||
if item.x < last_item.x - 10.0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
|
||||
if should_merge {
|
||||
@@ -1015,6 +1257,7 @@ mod tests {
|
||||
page: 1,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
},
|
||||
TextItem {
|
||||
text: "World".into(),
|
||||
@@ -1027,6 +1270,7 @@ mod tests {
|
||||
page: 1,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
},
|
||||
TextItem {
|
||||
text: "Next line".into(),
|
||||
@@ -1039,6 +1283,7 @@ mod tests {
|
||||
page: 1,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: ItemType::Text,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+111
-11
@@ -32,6 +32,10 @@ pub struct MarkdownOptions {
|
||||
pub detect_bold: bool,
|
||||
/// Detect and format italic text from font names
|
||||
pub detect_italic: bool,
|
||||
/// Include image placeholders in output
|
||||
pub include_images: bool,
|
||||
/// Include extracted hyperlinks
|
||||
pub include_links: bool,
|
||||
}
|
||||
|
||||
impl Default for MarkdownOptions {
|
||||
@@ -46,6 +50,8 @@ impl Default for MarkdownOptions {
|
||||
fix_hyphenation: true,
|
||||
detect_bold: true,
|
||||
detect_italic: true,
|
||||
include_images: true,
|
||||
include_links: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +114,7 @@ pub fn to_markdown(text: &str, options: MarkdownOptions) -> String {
|
||||
|
||||
/// Convert positioned text items to markdown with structure detection
|
||||
pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) -> String {
|
||||
use crate::extractor::ItemType;
|
||||
use crate::tables::{detect_tables, table_to_markdown};
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -115,8 +122,31 @@ pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) ->
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// Separate images and links from text items
|
||||
let mut images: Vec<TextItem> = Vec::new();
|
||||
let mut links: Vec<TextItem> = Vec::new();
|
||||
let mut text_items: Vec<TextItem> = Vec::new();
|
||||
|
||||
for item in items {
|
||||
match &item.item_type {
|
||||
ItemType::Image => {
|
||||
if options.include_images {
|
||||
images.push(item);
|
||||
}
|
||||
}
|
||||
ItemType::Link(_) => {
|
||||
if options.include_links {
|
||||
links.push(item);
|
||||
}
|
||||
}
|
||||
ItemType::Text => {
|
||||
text_items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate base font size for table detection
|
||||
let font_stats = calculate_font_stats_from_items(&items);
|
||||
let font_stats = calculate_font_stats_from_items(&text_items);
|
||||
let base_size = options
|
||||
.base_font_size
|
||||
.unwrap_or(font_stats.most_common_size);
|
||||
@@ -126,13 +156,35 @@ pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) ->
|
||||
let mut page_tables: std::collections::HashMap<u32, Vec<(f32, String)>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
// Store images by page and Y position for insertion
|
||||
let mut page_images: std::collections::HashMap<u32, Vec<(f32, String)>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for img in &images {
|
||||
// Extract image name from "[Image: Im0]" format
|
||||
let img_name = img
|
||||
.text
|
||||
.strip_prefix("[Image: ")
|
||||
.and_then(|s| s.strip_suffix(']'))
|
||||
.unwrap_or(&img.text);
|
||||
let img_md = format!("\n", img_name);
|
||||
page_images
|
||||
.entry(img.page)
|
||||
.or_default()
|
||||
.push((img.y, img_md));
|
||||
}
|
||||
|
||||
// Group items by page for table detection
|
||||
let mut pages: Vec<u32> = items.iter().map(|i| i.page).collect();
|
||||
let mut pages: Vec<u32> = text_items.iter().map(|i| i.page).collect();
|
||||
pages.sort();
|
||||
pages.dedup();
|
||||
|
||||
for page in pages {
|
||||
let page_items: Vec<TextItem> = items.iter().filter(|i| i.page == page).cloned().collect();
|
||||
let page_items: Vec<TextItem> = text_items
|
||||
.iter()
|
||||
.filter(|i| i.page == page)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let tables = detect_tables(&page_items, base_size);
|
||||
|
||||
@@ -140,7 +192,7 @@ pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) ->
|
||||
// Mark items as belonging to a table
|
||||
for &idx in &table.item_indices {
|
||||
// Find the global index
|
||||
let global_idx = items
|
||||
let global_idx = text_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, i)| i.page == page)
|
||||
@@ -163,7 +215,7 @@ pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) ->
|
||||
}
|
||||
|
||||
// Filter out table items and process the rest
|
||||
let non_table_items: Vec<TextItem> = items
|
||||
let non_table_items: Vec<TextItem> = text_items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !table_items.contains(idx))
|
||||
@@ -172,8 +224,8 @@ pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) ->
|
||||
|
||||
let lines = group_into_lines(non_table_items);
|
||||
|
||||
// Convert to markdown, inserting tables at appropriate positions
|
||||
to_markdown_from_lines_with_tables(lines, options, page_tables)
|
||||
// Convert to markdown, inserting tables and images at appropriate positions
|
||||
to_markdown_from_lines_with_tables_and_images(lines, options, page_tables, page_images)
|
||||
}
|
||||
|
||||
/// Calculate font stats directly from items (before grouping into lines)
|
||||
@@ -196,13 +248,14 @@ fn calculate_font_stats_from_items(items: &[TextItem]) -> FontStats {
|
||||
FontStats { most_common_size }
|
||||
}
|
||||
|
||||
/// Convert text lines to markdown, inserting tables at appropriate Y positions
|
||||
fn to_markdown_from_lines_with_tables(
|
||||
/// Convert text lines to markdown, inserting tables and images at appropriate Y positions
|
||||
fn to_markdown_from_lines_with_tables_and_images(
|
||||
lines: Vec<TextLine>,
|
||||
options: MarkdownOptions,
|
||||
page_tables: std::collections::HashMap<u32, Vec<(f32, String)>>,
|
||||
page_images: std::collections::HashMap<u32, Vec<(f32, String)>>,
|
||||
) -> String {
|
||||
if lines.is_empty() && page_tables.is_empty() {
|
||||
if lines.is_empty() && page_tables.is_empty() && page_images.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
@@ -222,11 +275,12 @@ fn to_markdown_from_lines_with_tables(
|
||||
let mut in_paragraph = false;
|
||||
let mut last_list_x: Option<f32> = None;
|
||||
let mut inserted_tables: HashSet<(u32, usize)> = HashSet::new();
|
||||
let mut inserted_images: HashSet<(u32, usize)> = HashSet::new();
|
||||
|
||||
for line in lines {
|
||||
// Page break
|
||||
if line.page != current_page {
|
||||
// Before leaving the current page, insert any remaining tables for that page
|
||||
// Before leaving the current page, insert any remaining tables and images
|
||||
if current_page > 0 {
|
||||
if let Some(tables) = page_tables.get(¤t_page) {
|
||||
for (idx, (_, table_md)) in tables.iter().enumerate() {
|
||||
@@ -242,6 +296,20 @@ fn to_markdown_from_lines_with_tables(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(images) = page_images.get(¤t_page) {
|
||||
for (idx, (_, image_md)) in images.iter().enumerate() {
|
||||
if !inserted_images.contains(&(current_page, idx)) {
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
}
|
||||
output.push('\n');
|
||||
output.push_str(image_md);
|
||||
output.push('\n');
|
||||
inserted_images.insert((current_page, idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
@@ -269,6 +337,23 @@ fn to_markdown_from_lines_with_tables(
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should insert an image before this line
|
||||
if let Some(images) = page_images.get(¤t_page) {
|
||||
for (idx, (image_y, image_md)) in images.iter().enumerate() {
|
||||
// Insert image when we pass its Y position
|
||||
if *image_y > line.y && !inserted_images.contains(&(current_page, idx)) {
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
}
|
||||
output.push('\n');
|
||||
output.push_str(image_md);
|
||||
output.push('\n');
|
||||
inserted_images.insert((current_page, idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Paragraph break (large Y gap)
|
||||
let y_gap = prev_y - line.y;
|
||||
let is_para_break = y_gap > base_size * 1.8; // Slightly lower threshold
|
||||
@@ -401,6 +486,21 @@ fn to_markdown_from_lines_with_tables(
|
||||
}
|
||||
}
|
||||
|
||||
// Insert any remaining images for the last page
|
||||
if let Some(images) = page_images.get(¤t_page) {
|
||||
for (idx, (_, image_md)) in images.iter().enumerate() {
|
||||
if !inserted_images.contains(&(current_page, idx)) {
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
}
|
||||
output.push('\n');
|
||||
output.push_str(image_md);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close final paragraph
|
||||
if in_paragraph {
|
||||
output.push('\n');
|
||||
|
||||
@@ -928,6 +928,7 @@ mod tests {
|
||||
page: 1,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
item_type: crate::extractor::ItemType::Text,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user