* fix(extractor): supply built-in metrics for non-embedded base-14 fonts PDFs may legally omit /Widths for non-embedded standard fonts (Times, Helvetica, Courier, Symbol, ZapfDingbats) — the spec requires the reader to supply the metrics. We returned None, so every glyph advanced 0 and each text item got width 0, silently breaking every gap-based heuristic downstream: space synthesis, sub/superscript detection, table column detection, heading merging. - src/extractor/base14.rs: Adobe Core-14 AFM width tables keyed by Unicode char, plus the standard Symbol/ZapfDingbats encoding vectors (their glyphs sit at byte positions unrelated to Latin text, so widths must resolve through the built-in encoding, not cp1252) - Width resolution order: Differences -> built-in encoding -> the same cp1252-style fallback the text decoder uses, so a code's advance always matches the character we emit for it - Type3 visual sizing: PK bitmap fonts (dvips) use FontMatrix [1 0 0 -1 0 0] with nominal sizes like 0.12pt; scale by FontBBox height x |matrix_y|. Applied in the page-stream and Form XObject paths. Indirect numeric array elements are resolved before use. Effect on Shannon's 'A Mathematical Theory of Communication' (1998 dvips/Distiller, the reported case): glued sentences 95 -> 5. Corpus impact: 12 of 184 eval documents, e.g. Data-Processing-Agreement recovers a paragraph that a phantom table had shredded into cells. Layout heuristics tuned on the same document (indent-based paragraph breaks, heading reclassification, table script filtering) are held back for a separate PR — they change ~98 further documents and need to be justified against the corpus, not against one PDF. * review: narrow Type3 rescaling to self-inconsistent fonts; dedup + test all width tables Addresses cubic review on #241, plus a follow-up from a local cubic run. - Type3 visual scaling was applied to every Type3 font whose FontBBox height x |matrix_y| deviated >5% from 1.0. FontBBox is the glyph box, not the em box, so a conventional 1/1000-matrix font with a descender..ascender bbox (~700 units) computed 0.7 and had every reported size shrunk by 30% — corrupting the drop-cap, heading-tier, sub/superscript and table heuristics this is meant to fix. First attempt gated on the matrix being unit-scale, but a local cubic run pointed out that wrongly excludes valid non-standard matrices (a 0.005 matrix with a full-em bbox legitimately needs a 5x scale). The product is the right discriminator, not the matrix: a self-consistent font lands near 1.0 because the matrix is the reciprocal of the glyph-space em, so only a wildly inconsistent one (dvips/PK bitmap fonts sit at ~159) is renormalized. Band widened to [0.25, 4.0]. Corpus effect: 12 -> 7 documents change. The 5 that drop out were being wrongly rescaled — including Data-Processing-Agreement, whose phantom-table fix turned out to come from this bug rather than from the width fallback, so it is correctly given up. - base14: all 14 width tables now covered by the sort-invariant test via an ALL_TABLES registry, not a hand-picked subset. - base14: identical tables share one static (all four Courier variants are monospace 600; the oblique Helvetica variants match their upright forms), removing 5 duplicate copies. * test: refresh Shannon snapshot after merging main CI checks out a merge of the PR head with main, and main advanced 8 commits since this branch was cut — including #201 (contextual digit runs), #240 and #253 (markdown fixes). Those change extraction output, so a snapshot generated on the unmerged branch could not match; the Test job failed on the merge commit while passing on the branch itself. The merged behaviour is better: the footnote marker '2' before 'Hartley, R. V. L.' is now recovered instead of dropped. 950 tests pass on the merged tree, clippy clean.
686 lines
29 KiB
Rust
686 lines
29 KiB
Rust
//! Form XObject and image XObject extraction.
|
|
|
|
use super::fonts::descriptor_style_flags;
|
|
use crate::text_utils::{effective_font_size, expand_ligatures, is_bold_font, is_italic_font};
|
|
use crate::tounicode::FontCMaps;
|
|
use crate::types::{ItemType, TextItem};
|
|
use lopdf::{Document, Encoding, Object, ObjectId};
|
|
use std::collections::HashMap;
|
|
|
|
use super::fonts::{
|
|
build_font_encodings, build_font_widths, build_type3_scales, compute_string_width_ts,
|
|
extract_text_from_operand, get_font_file2_obj_num, get_operand_bytes, CMapDecisionCache,
|
|
FontStyleCache,
|
|
};
|
|
use super::{get_number, image_bbox_from_ctm, multiply_matrices};
|
|
|
|
const MAX_FORM_XOBJECT_DEPTH: u8 = 5;
|
|
|
|
pub(crate) enum XObjectType {
|
|
Image,
|
|
Form(ObjectId),
|
|
}
|
|
|
|
/// Get XObjects from page resources, categorized by type
|
|
pub(crate) fn get_page_xobjects(
|
|
doc: &Document,
|
|
page_id: ObjectId,
|
|
) -> std::collections::HashMap<String, XObjectType> {
|
|
let mut xobject_types = std::collections::HashMap::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 {
|
|
collect_xobjects_from_dict(doc, resources, &mut xobject_types);
|
|
}
|
|
}
|
|
|
|
xobject_types
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract text items from a Form XObject
|
|
pub(crate) fn extract_form_xobject_text(
|
|
doc: &Document,
|
|
form_id: ObjectId,
|
|
page_num: u32,
|
|
font_cmaps: &FontCMaps,
|
|
parent_ctm: &[f32; 6],
|
|
cmap_decisions: &mut CMapDecisionCache,
|
|
style_cache: &mut FontStyleCache,
|
|
) -> Vec<TextItem> {
|
|
extract_form_xobject_text_inner(
|
|
doc,
|
|
form_id,
|
|
page_num,
|
|
font_cmaps,
|
|
parent_ctm,
|
|
cmap_decisions,
|
|
style_cache,
|
|
0,
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
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,
|
|
style_cache: &mut FontStyleCache,
|
|
depth: u8,
|
|
) -> Vec<TextItem> {
|
|
use lopdf::content::Content;
|
|
|
|
let mut items = Vec::new();
|
|
|
|
// Get the Form XObject stream
|
|
let Ok(Object::Stream(stream)) = doc.get_object(form_id) else {
|
|
return items;
|
|
};
|
|
|
|
// Decompress the content stream (fall back to raw bytes for uncompressed streams)
|
|
let content_data = match stream.decompressed_content() {
|
|
Ok(data) => data,
|
|
Err(_) => stream.content.clone(),
|
|
};
|
|
|
|
// Decode the content stream
|
|
let Ok(content) = Content::decode(&content_data) else {
|
|
return items;
|
|
};
|
|
|
|
// Get fonts from the Form's Resources
|
|
let form_fonts = get_form_fonts(doc, &stream.dict);
|
|
let (font_encodings, _has_gid_fonts) = build_font_encodings(doc, &form_fonts, font_cmaps);
|
|
|
|
// Build font width info for the form
|
|
let font_widths = build_font_widths(doc, &form_fonts);
|
|
let type3_scales = build_type3_scales(doc, &form_fonts);
|
|
|
|
// Build font base names and ToUnicode refs for the form
|
|
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();
|
|
|
|
let mut font_style_flags: HashMap<String, (bool, bool)> = HashMap::new();
|
|
for (font_name, font_dict) in &form_fonts {
|
|
let resource_name = String::from_utf8_lossy(font_name).to_string();
|
|
if let Ok(base_font) = font_dict.get(b"BaseFont") {
|
|
if let Ok(name) = base_font.as_name() {
|
|
let base_name = String::from_utf8_lossy(name).to_string();
|
|
font_base_names.insert(resource_name.clone(), base_name);
|
|
}
|
|
}
|
|
let style = descriptor_style_flags(doc, font_dict, style_cache);
|
|
if style != (false, false) {
|
|
font_style_flags.insert(resource_name.clone(), style);
|
|
}
|
|
match font_dict.get(b"ToUnicode") {
|
|
Ok(tounicode) => {
|
|
if let Ok(obj_ref) = tounicode.as_reference() {
|
|
font_tounicode_refs.insert(resource_name, obj_ref.0);
|
|
} else if let Object::Stream(s) = tounicode {
|
|
let data = s
|
|
.decompressed_content()
|
|
.unwrap_or_else(|_| s.content.clone());
|
|
if let Some(entry) =
|
|
crate::tounicode::build_cmap_entry_from_stream(&data, font_dict, doc, 0)
|
|
{
|
|
inline_cmaps.insert(resource_name, entry);
|
|
}
|
|
}
|
|
}
|
|
Err(_) => {
|
|
if let Some(ff2_obj_num) = get_font_file2_obj_num(doc, font_dict) {
|
|
font_tounicode_refs.insert(resource_name, ff2_obj_num);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cache font encodings for form fonts
|
|
let mut encoding_cache: HashMap<String, Encoding<'_>> = HashMap::new();
|
|
for (font_name, font_dict) in &form_fonts {
|
|
let name = String::from_utf8_lossy(font_name).to_string();
|
|
if let Ok(enc) = font_dict.get_font_encoding(doc) {
|
|
encoding_cache.insert(name, enc);
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
match form_xobjects.get(&xobj_name) {
|
|
Some(XObjectType::Form(nested_id)) => {
|
|
if depth < MAX_FORM_XOBJECT_DEPTH {
|
|
let nested_items = extract_form_xobject_text_inner(
|
|
doc,
|
|
*nested_id,
|
|
page_num,
|
|
font_cmaps,
|
|
&ctm,
|
|
cmap_decisions,
|
|
style_cache,
|
|
depth + 1,
|
|
);
|
|
items.extend(nested_items);
|
|
}
|
|
}
|
|
Some(XObjectType::Image) => {
|
|
// Mirror the top-level Image-XObject emission
|
|
// in content_stream.rs so figures embedded
|
|
// inside Form XObjects (common in print-to-PDF
|
|
// workflows) aren't silently dropped.
|
|
let (x, y, width, height) = image_bbox_from_ctm(&ctm);
|
|
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,
|
|
is_underline: false,
|
|
is_strikeout: false,
|
|
item_type: ItemType::Image,
|
|
mcid: None,
|
|
});
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"BT" => {
|
|
in_text_block = true;
|
|
text_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
|
}
|
|
"ET" => {
|
|
in_text_block = false;
|
|
}
|
|
"Tf" => {
|
|
if op.operands.len() >= 2 {
|
|
if let Ok(name) = op.operands[0].as_name() {
|
|
current_font = String::from_utf8_lossy(name).to_string();
|
|
}
|
|
current_font_size = get_number(&op.operands[1]).unwrap_or(12.0);
|
|
}
|
|
}
|
|
"Td" | "TD" => {
|
|
if op.operands.len() >= 2 {
|
|
let tx = get_number(&op.operands[0]).unwrap_or(0.0);
|
|
let ty = get_number(&op.operands[1]).unwrap_or(0.0);
|
|
text_matrix[4] += tx * text_matrix[0] + ty * text_matrix[2];
|
|
text_matrix[5] += tx * text_matrix[1] + ty * text_matrix[3];
|
|
}
|
|
}
|
|
"Tm" => {
|
|
if op.operands.len() >= 6 {
|
|
for (i, operand) in op.operands.iter().take(6).enumerate() {
|
|
text_matrix[i] =
|
|
get_number(operand).unwrap_or(if i == 0 || i == 3 { 1.0 } else { 0.0 });
|
|
}
|
|
}
|
|
}
|
|
"g" => {
|
|
if let Some(gray) = op.operands.first().and_then(get_number) {
|
|
fill_is_white = gray > 0.95;
|
|
}
|
|
}
|
|
"rg" => {
|
|
if op.operands.len() >= 3 {
|
|
let r = get_number(&op.operands[0]).unwrap_or(0.0);
|
|
let g = get_number(&op.operands[1]).unwrap_or(0.0);
|
|
let b = get_number(&op.operands[2]).unwrap_or(0.0);
|
|
fill_is_white = r > 0.95 && g > 0.95 && b > 0.95;
|
|
}
|
|
}
|
|
"k" => {
|
|
if op.operands.len() >= 4 {
|
|
let c = get_number(&op.operands[0]).unwrap_or(1.0);
|
|
let m = get_number(&op.operands[1]).unwrap_or(1.0);
|
|
let y = get_number(&op.operands[2]).unwrap_or(1.0);
|
|
let k = get_number(&op.operands[3]).unwrap_or(1.0);
|
|
fill_is_white = c < 0.05 && m < 0.05 && y < 0.05 && k < 0.05;
|
|
}
|
|
}
|
|
"sc" | "scn" => {
|
|
let nums: Vec<f32> = op.operands.iter().filter_map(get_number).collect();
|
|
match nums.len() {
|
|
3 => {
|
|
fill_is_white = nums[0] > 0.95 && nums[1] > 0.95 && nums[2] > 0.95;
|
|
}
|
|
4 => {
|
|
fill_is_white =
|
|
nums[0] < 0.05 && nums[1] < 0.05 && nums[2] < 0.05 && nums[3] < 0.05;
|
|
}
|
|
_ => fill_is_white = false,
|
|
}
|
|
}
|
|
"Tj" => {
|
|
if in_text_block && !op.operands.is_empty() {
|
|
if fill_is_white {
|
|
if let Some(font_info) = font_widths.get(¤t_font) {
|
|
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
|
let w_ts = compute_string_width_ts(
|
|
raw_bytes,
|
|
font_info,
|
|
current_font_size,
|
|
0.0,
|
|
0.0,
|
|
);
|
|
text_matrix[4] += w_ts * text_matrix[0];
|
|
text_matrix[5] += w_ts * text_matrix[1];
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if let Some(text) = extract_text_from_operand(
|
|
&op.operands[0],
|
|
¤t_font,
|
|
font_base_names.get(¤t_font).map(|s| s.as_str()),
|
|
font_cmaps,
|
|
&font_tounicode_refs,
|
|
&inline_cmaps,
|
|
&font_encodings,
|
|
&encoding_cache,
|
|
cmap_decisions,
|
|
&font_widths,
|
|
) {
|
|
let combined = multiply_matrices(&text_matrix, &ctm);
|
|
let rendered_size = effective_font_size(current_font_size, &combined)
|
|
* type3_scales.get(¤t_font).copied().unwrap_or(1.0);
|
|
let (x, y) = (combined[4], combined[5]);
|
|
let width = if let Some(font_info) = font_widths.get(¤t_font) {
|
|
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
|
let w_ts = compute_string_width_ts(
|
|
raw_bytes,
|
|
font_info,
|
|
current_font_size,
|
|
0.0,
|
|
0.0,
|
|
);
|
|
text_matrix[4] += w_ts * text_matrix[0];
|
|
text_matrix[5] += w_ts * text_matrix[1];
|
|
(w_ts * (text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2])).abs()
|
|
} else {
|
|
0.0
|
|
}
|
|
} else {
|
|
0.0
|
|
};
|
|
// Only create text item for non-whitespace; whitespace
|
|
// still advances the text matrix above so gap detection works
|
|
if !text.trim().is_empty() {
|
|
let base_font = font_base_names
|
|
.get(¤t_font)
|
|
.map(|s| s.as_str())
|
|
.unwrap_or(¤t_font);
|
|
let (desc_italic, desc_bold) = font_style_flags
|
|
.get(¤t_font)
|
|
.copied()
|
|
.unwrap_or((false, false));
|
|
items.push(TextItem {
|
|
text: expand_ligatures(&text),
|
|
x,
|
|
y,
|
|
width,
|
|
height: rendered_size,
|
|
font: current_font.clone(),
|
|
font_size: rendered_size,
|
|
page: page_num,
|
|
is_bold: is_bold_font(base_font) || desc_bold,
|
|
is_italic: is_italic_font(base_font) || desc_italic,
|
|
is_underline: false,
|
|
is_strikeout: false,
|
|
item_type: ItemType::Text,
|
|
mcid: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"TJ" => {
|
|
// Show text with positioning — split at column-sized gaps
|
|
if in_text_block && !op.operands.is_empty() {
|
|
if let Ok(array) = op.operands[0].as_array() {
|
|
let font_info = font_widths.get(¤t_font);
|
|
|
|
let space_threshold = if let Some(fi) = font_info {
|
|
let space_em = fi.space_width as f32 * fi.units_scale;
|
|
let threshold = space_em * 1000.0 * 0.4;
|
|
threshold.max(80.0)
|
|
} else {
|
|
120.0
|
|
};
|
|
let column_gap_threshold = space_threshold * 4.0;
|
|
|
|
let mut sub_items: Vec<(String, f32, f32)> = Vec::new();
|
|
let mut current_text = String::new();
|
|
let mut sub_start_width_ts: f32 = 0.0;
|
|
let mut total_width_ts: f32 = 0.0;
|
|
for element in array {
|
|
match element {
|
|
Object::Integer(n) => {
|
|
let n_val = *n as f32;
|
|
let displacement = -n_val / 1000.0 * current_font_size;
|
|
if !fill_is_white
|
|
&& n_val < -column_gap_threshold
|
|
&& !current_text.is_empty()
|
|
{
|
|
sub_items.push((
|
|
std::mem::take(&mut current_text),
|
|
sub_start_width_ts,
|
|
total_width_ts,
|
|
));
|
|
total_width_ts += displacement;
|
|
sub_start_width_ts = total_width_ts;
|
|
} else {
|
|
total_width_ts += displacement;
|
|
if !fill_is_white
|
|
&& n_val < -space_threshold
|
|
&& !current_text.is_empty()
|
|
&& !current_text.ends_with(' ')
|
|
{
|
|
current_text.push(' ');
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
Object::Real(n) => {
|
|
let n_val = *n;
|
|
let displacement = -n_val / 1000.0 * current_font_size;
|
|
if !fill_is_white
|
|
&& n_val < -column_gap_threshold
|
|
&& !current_text.is_empty()
|
|
{
|
|
sub_items.push((
|
|
std::mem::take(&mut current_text),
|
|
sub_start_width_ts,
|
|
total_width_ts,
|
|
));
|
|
total_width_ts += displacement;
|
|
sub_start_width_ts = total_width_ts;
|
|
} else {
|
|
total_width_ts += displacement;
|
|
if !fill_is_white
|
|
&& n_val < -space_threshold
|
|
&& !current_text.is_empty()
|
|
&& !current_text.ends_with(' ')
|
|
{
|
|
current_text.push(' ');
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
_ => {}
|
|
}
|
|
if let Some(fi) = font_info {
|
|
if let Some(raw_bytes) = get_operand_bytes(element) {
|
|
total_width_ts += compute_string_width_ts(
|
|
raw_bytes,
|
|
fi,
|
|
current_font_size,
|
|
0.0,
|
|
0.0,
|
|
);
|
|
}
|
|
}
|
|
if !fill_is_white {
|
|
if let Some(text) = extract_text_from_operand(
|
|
element,
|
|
¤t_font,
|
|
font_base_names.get(¤t_font).map(|s| s.as_str()),
|
|
font_cmaps,
|
|
&font_tounicode_refs,
|
|
&inline_cmaps,
|
|
&font_encodings,
|
|
&encoding_cache,
|
|
cmap_decisions,
|
|
&font_widths,
|
|
) {
|
|
current_text.push_str(&text);
|
|
}
|
|
}
|
|
}
|
|
if !fill_is_white && !current_text.trim().is_empty() {
|
|
sub_items.push((current_text, sub_start_width_ts, total_width_ts));
|
|
}
|
|
if !sub_items.is_empty() {
|
|
let combined = multiply_matrices(&text_matrix, &ctm);
|
|
let rendered_size = effective_font_size(current_font_size, &combined)
|
|
* type3_scales.get(¤t_font).copied().unwrap_or(1.0);
|
|
let base_font = font_base_names
|
|
.get(¤t_font)
|
|
.map(|s| s.as_str())
|
|
.unwrap_or(¤t_font);
|
|
let (desc_italic, desc_bold) = font_style_flags
|
|
.get(¤t_font)
|
|
.copied()
|
|
.unwrap_or((false, false));
|
|
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],
|
|
text_matrix[1],
|
|
text_matrix[2],
|
|
text_matrix[3],
|
|
text_matrix[4] + start_w * text_matrix[0],
|
|
text_matrix[5] + start_w * text_matrix[1],
|
|
];
|
|
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()
|
|
} else {
|
|
0.0
|
|
};
|
|
items.push(TextItem {
|
|
text: expand_ligatures(text),
|
|
x,
|
|
y,
|
|
width,
|
|
height: rendered_size,
|
|
font: current_font.clone(),
|
|
font_size: rendered_size,
|
|
page: page_num,
|
|
is_bold: is_bold_font(base_font) || desc_bold,
|
|
is_italic: is_italic_font(base_font) || desc_italic,
|
|
is_underline: false,
|
|
is_strikeout: false,
|
|
item_type: ItemType::Text,
|
|
mcid: None,
|
|
});
|
|
}
|
|
}
|
|
// Always advance text matrix
|
|
if font_info.is_some() {
|
|
text_matrix[4] += total_width_ts * text_matrix[0];
|
|
text_matrix[5] += total_width_ts * text_matrix[1];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
items
|
|
}
|
|
|
|
/// Get fonts from a Form XObject's Resources
|
|
pub(crate) fn get_form_fonts<'a>(
|
|
doc: &'a Document,
|
|
form_dict: &lopdf::Dictionary,
|
|
) -> std::collections::BTreeMap<Vec<u8>, &'a lopdf::Dictionary> {
|
|
let mut fonts = std::collections::BTreeMap::new();
|
|
|
|
// Get Resources from Form dictionary
|
|
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 fonts;
|
|
};
|
|
|
|
let Some(resources) = resources else {
|
|
return fonts;
|
|
};
|
|
|
|
// Get Font dictionary
|
|
let font_dict = if let Ok(font_ref) = resources.get(b"Font") {
|
|
if let Ok(obj_ref) = font_ref.as_reference() {
|
|
doc.get_dictionary(obj_ref).ok()
|
|
} else {
|
|
font_ref.as_dict().ok()
|
|
}
|
|
} else {
|
|
return fonts;
|
|
};
|
|
|
|
let Some(font_dict) = font_dict else {
|
|
return fonts;
|
|
};
|
|
|
|
// Collect fonts
|
|
for (name, value) in font_dict.iter() {
|
|
if let Ok(obj_ref) = value.as_reference() {
|
|
if let Ok(dict) = doc.get_dictionary(obj_ref) {
|
|
fonts.insert(name.clone(), dict);
|
|
}
|
|
}
|
|
}
|
|
|
|
fonts
|
|
}
|