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:
Abimael Martell
2026-02-11 08:31:20 -08:00
co-authored by Claude Opus 4.5
parent 5afbb67b90
commit cd68515028
5 changed files with 370 additions and 13 deletions
+246 -1
View File
@@ -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,
},
];