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:
co-authored by
Claude Opus 4.6
parent
f3c00678b2
commit
8b5010bb3a
+147
-39
@@ -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(¤t_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(¤t_font)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(¤t_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()
|
||||
|
||||
Reference in New Issue
Block a user