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};
const MAX_FORM_XOBJECT_DEPTH: u8 = 5;
pub(crate) enum XObjectType {
Image,
Form(ObjectId),
@@ -38,30 +40,62 @@ pub(crate) fn get_page_xobjects(
};
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()
};
collect_xobjects_from_dict(doc, resources, &mut xobject_types);
}
}
if let Some(xobjects) = xobjects {
for (name, value) in xobjects.iter() {
let name_str = String::from_utf8_lossy(name).to_string();
xobject_types
}
// Check XObject subtype
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));
}
}
/// Get XObjects from a Form XObject's Resources
fn get_form_xobjects(
doc: &Document,
form_dict: &lopdf::Dictionary,
) -> HashMap<String, XObjectType> {
let mut xobject_types = HashMap::new();
let resources = if let Ok(res_ref) = form_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 {
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
@@ -82,6 +114,26 @@ pub(crate) fn extract_form_xobject_text(
font_cmaps: &FontCMaps,
parent_ctm: &[f32; 6],
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> {
use lopdf::content::Content;
@@ -110,12 +162,9 @@ pub(crate) fn extract_form_xobject_text(
let font_widths = build_font_widths(doc, &form_fonts);
// Build font base names and ToUnicode refs for the form
let mut font_base_names: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut font_tounicode_refs: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
let mut inline_cmaps: std::collections::HashMap<String, crate::tounicode::CMapEntry> =
std::collections::HashMap::new();
let mut font_base_names: HashMap<String, String> = HashMap::new();
let mut font_tounicode_refs: HashMap<String, u32> = HashMap::new();
let mut inline_cmaps: HashMap<String, crate::tounicode::CMapEntry> = HashMap::new();
for (font_name, font_dict) in &form_fonts {
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
let mut current_font = String::new();
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 in_text_block = 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 {
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" => {
in_text_block = true;
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,
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 (x, y) = (combined[4], combined[5]);
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[5] += w_ts * text_matrix[1];
(w_ts
* (text_matrix[0] * parent_ctm[0]
+ text_matrix[1] * parent_ctm[2]))
.abs()
(w_ts * (text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2])).abs()
} else {
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));
}
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 base_font = font_base_names
.get(&current_font)
.map(|s| s.as_str())
.unwrap_or(&current_font);
let scale_x =
text_matrix[0] * parent_ctm[0] + text_matrix[1] * parent_ctm[2];
let scale_x = text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2];
for (text, start_w, end_w) in &sub_items {
let offset_tm = [
text_matrix[0],
@@ -422,7 +530,7 @@ pub(crate) fn extract_form_xobject_text(
text_matrix[4] + start_w * text_matrix[0],
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 width = if font_info.is_some() {
((end_w - start_w) * scale_x).abs()