feat(extractor): recurse into nested Form XObjects for text extraction

Pages using nested Form XObject chains (page → wrapper Form → text Form)
were returning 0 text items because extract_form_xobject_text() didn't
handle the Do operator. Add Do/q/Q/cm handling with recursive descent
(max depth 5) and proper CTM tracking through the nesting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-03-09 15:09:20 -07:00
co-authored by Claude Opus 4.6
parent f3c00678b2
commit 8b5010bb3a
+147 -39
View File
@@ -12,6 +12,8 @@ use super::fonts::{
}; };
use super::{get_number, multiply_matrices}; use super::{get_number, multiply_matrices};
const MAX_FORM_XOBJECT_DEPTH: u8 = 5;
pub(crate) enum XObjectType { pub(crate) enum XObjectType {
Image, Image,
Form(ObjectId), Form(ObjectId),
@@ -38,30 +40,62 @@ pub(crate) fn get_page_xobjects(
}; };
if let Some(resources) = resources { if let Some(resources) = resources {
// Get XObject dictionary from Resources collect_xobjects_from_dict(doc, resources, &mut xobject_types);
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 { xobject_types
for (name, value) in xobjects.iter() { }
let name_str = String::from_utf8_lossy(name).to_string();
// Check XObject subtype /// Get XObjects from a Form XObject's Resources
if let Ok(obj_ref) = value.as_reference() { fn get_form_xobjects(
if let Ok(Object::Stream(stream)) = doc.get_object(obj_ref) { doc: &Document,
if let Ok(subtype) = stream.dict.get(b"Subtype") { form_dict: &lopdf::Dictionary,
if let Ok(subtype_name) = subtype.as_name() { ) -> HashMap<String, XObjectType> {
if subtype_name == b"Image" { let mut xobject_types = HashMap::new();
xobject_types.insert(name_str, XObjectType::Image);
} else if subtype_name == b"Form" { let resources = if let Ok(res_ref) = form_dict.get(b"Resources") {
xobject_types if let Ok(obj_ref) = res_ref.as_reference() {
.insert(name_str, XObjectType::Form(obj_ref)); doc.get_dictionary(obj_ref).ok()
} } else {
} res_ref.as_dict().ok()
}
} else {
return xobject_types;
};
if let Some(resources) = resources {
collect_xobjects_from_dict(doc, resources, &mut xobject_types);
}
xobject_types
}
/// Collect XObject entries from a Resources dictionary
fn collect_xobjects_from_dict(
doc: &Document,
resources: &lopdf::Dictionary,
xobject_types: &mut HashMap<String, XObjectType>,
) {
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();
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" {
xobject_types.insert(name_str, XObjectType::Image);
} else if subtype_name == b"Form" {
xobject_types.insert(name_str, XObjectType::Form(obj_ref));
} }
} }
} }
@@ -70,8 +104,6 @@ pub(crate) fn get_page_xobjects(
} }
} }
} }
xobject_types
} }
/// Extract text items from a Form XObject /// Extract text items from a Form XObject
@@ -82,6 +114,26 @@ pub(crate) fn extract_form_xobject_text(
font_cmaps: &FontCMaps, font_cmaps: &FontCMaps,
parent_ctm: &[f32; 6], parent_ctm: &[f32; 6],
cmap_decisions: &mut CMapDecisionCache, cmap_decisions: &mut CMapDecisionCache,
) -> Vec<TextItem> {
extract_form_xobject_text_inner(
doc,
form_id,
page_num,
font_cmaps,
parent_ctm,
cmap_decisions,
0,
)
}
fn extract_form_xobject_text_inner(
doc: &Document,
form_id: ObjectId,
page_num: u32,
font_cmaps: &FontCMaps,
parent_ctm: &[f32; 6],
cmap_decisions: &mut CMapDecisionCache,
depth: u8,
) -> Vec<TextItem> { ) -> Vec<TextItem> {
use lopdf::content::Content; use lopdf::content::Content;
@@ -110,12 +162,9 @@ pub(crate) fn extract_form_xobject_text(
let font_widths = build_font_widths(doc, &form_fonts); let font_widths = build_font_widths(doc, &form_fonts);
// Build font base names and ToUnicode refs for the form // Build font base names and ToUnicode refs for the form
let mut font_base_names: std::collections::HashMap<String, String> = let mut font_base_names: HashMap<String, String> = HashMap::new();
std::collections::HashMap::new(); let mut font_tounicode_refs: HashMap<String, u32> = HashMap::new();
let mut font_tounicode_refs: std::collections::HashMap<String, u32> = let mut inline_cmaps: HashMap<String, crate::tounicode::CMapEntry> = HashMap::new();
std::collections::HashMap::new();
let mut inline_cmaps: std::collections::HashMap<String, crate::tounicode::CMapEntry> =
std::collections::HashMap::new();
for (font_name, font_dict) in &form_fonts { for (font_name, font_dict) in &form_fonts {
let resource_name = String::from_utf8_lossy(font_name).to_string(); let resource_name = String::from_utf8_lossy(font_name).to_string();
@@ -156,15 +205,78 @@ pub(crate) fn extract_form_xobject_text(
} }
} }
// Build XObject map from the Form's own Resources for nested Do
let form_xobjects = get_form_xobjects(doc, &stream.dict);
// Apply the Form XObject's own Matrix (if any) to the parent CTM
let form_matrix = if let Ok(matrix_obj) = stream.dict.get(b"Matrix") {
if let Ok(arr) = matrix_obj.as_array() {
if arr.len() >= 6 {
let mut m = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
for (i, v) in arr.iter().take(6).enumerate() {
m[i] = get_number(v).unwrap_or(if i == 0 || i == 3 { 1.0 } else { 0.0 });
}
m
} else {
[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
}
} else {
[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
}
} else {
[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
};
let base_ctm = multiply_matrices(&form_matrix, parent_ctm);
// Process the content stream // Process the content stream
let mut current_font = String::new(); let mut current_font = String::new();
let mut current_font_size: f32 = 12.0; let mut current_font_size: f32 = 12.0;
let mut text_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0]; let mut text_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
let mut in_text_block = false; let mut in_text_block = false;
let mut fill_is_white = false; let mut fill_is_white = false;
let mut ctm = base_ctm;
let mut ctm_stack: Vec<[f32; 6]> = Vec::new();
for op in &content.operations { for op in &content.operations {
match op.operator.as_str() { match op.operator.as_str() {
"q" => {
ctm_stack.push(ctm);
}
"Q" => {
if let Some(saved) = ctm_stack.pop() {
ctm = saved;
}
}
"cm" => {
if op.operands.len() >= 6 {
let mut m = [0.0f32; 6];
for (i, operand) in op.operands.iter().take(6).enumerate() {
m[i] = get_number(operand).unwrap_or(0.0);
}
ctm = multiply_matrices(&m, &ctm);
}
}
"Do" => {
if !op.operands.is_empty() {
if let Ok(name) = op.operands[0].as_name() {
let xobj_name = String::from_utf8_lossy(name).to_string();
if let Some(XObjectType::Form(nested_id)) = form_xobjects.get(&xobj_name) {
if depth < MAX_FORM_XOBJECT_DEPTH {
let nested_items = extract_form_xobject_text_inner(
doc,
*nested_id,
page_num,
font_cmaps,
&ctm,
cmap_decisions,
depth + 1,
);
items.extend(nested_items);
}
}
}
}
}
"BT" => { "BT" => {
in_text_block = true; in_text_block = true;
text_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; text_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
@@ -258,7 +370,7 @@ pub(crate) fn extract_form_xobject_text(
&encoding_cache, &encoding_cache,
cmap_decisions, cmap_decisions,
) { ) {
let combined = multiply_matrices(&text_matrix, parent_ctm); let combined = multiply_matrices(&text_matrix, &ctm);
let rendered_size = effective_font_size(current_font_size, &combined); 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(&current_font) { let width = if let Some(font_info) = font_widths.get(&current_font) {
@@ -270,10 +382,7 @@ pub(crate) fn extract_form_xobject_text(
); );
text_matrix[4] += w_ts * text_matrix[0]; text_matrix[4] += w_ts * text_matrix[0];
text_matrix[5] += w_ts * text_matrix[1]; text_matrix[5] += w_ts * text_matrix[1];
(w_ts (w_ts * (text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2])).abs()
* (text_matrix[0] * parent_ctm[0]
+ text_matrix[1] * parent_ctm[2]))
.abs()
} else { } else {
0.0 0.0
} }
@@ -405,14 +514,13 @@ pub(crate) 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 combined = multiply_matrices(&text_matrix, parent_ctm); let combined = multiply_matrices(&text_matrix, &ctm);
let rendered_size = effective_font_size(current_font_size, &combined); let rendered_size = effective_font_size(current_font_size, &combined);
let base_font = font_base_names let base_font = font_base_names
.get(&current_font) .get(&current_font)
.map(|s| s.as_str()) .map(|s| s.as_str())
.unwrap_or(&current_font); .unwrap_or(&current_font);
let scale_x = let scale_x = text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2];
text_matrix[0] * parent_ctm[0] + text_matrix[1] * parent_ctm[2];
for (text, start_w, end_w) in &sub_items { for (text, start_w, end_w) in &sub_items {
let offset_tm = [ let offset_tm = [
text_matrix[0], text_matrix[0],
@@ -422,7 +530,7 @@ pub(crate) fn extract_form_xobject_text(
text_matrix[4] + start_w * text_matrix[0], text_matrix[4] + start_w * text_matrix[0],
text_matrix[5] + start_w * text_matrix[1], text_matrix[5] + start_w * text_matrix[1],
]; ];
let combined_mat = multiply_matrices(&offset_tm, parent_ctm); let combined_mat = multiply_matrices(&offset_tm, &ctm);
let (x, y) = (combined_mat[4], combined_mat[5]); let (x, y) = (combined_mat[4], combined_mat[5]);
let width = if font_info.is_some() { let width = if font_info.is_some() {
((end_w - start_w) * scale_x).abs() ((end_w - start_w) * scale_x).abs()