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,
},
];
+111 -11
View File
@@ -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!("![Image: {}](image)\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(&current_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(&current_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(&current_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(&current_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');
+1
View File
@@ -928,6 +928,7 @@ mod tests {
page: 1,
is_bold: false,
is_italic: false,
item_type: crate::extractor::ItemType::Text,
}
}