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};
|
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(¤t_font) {
|
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[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(¤t_font)
|
.get(¤t_font)
|
||||||
.map(|s| s.as_str())
|
.map(|s| s.as_str())
|
||||||
.unwrap_or(¤t_font);
|
.unwrap_or(¤t_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()
|
||||||
|
|||||||
Reference in New Issue
Block a user