1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
//! Character clustering into spans, including ligature decisions.
//!
//! Split out of the parent's single 5,806-line `impl TextExtractor`, which made
//! `extractors/text.rs` 673 KiB — over the repository's 500 KiB file-safety limit.
//! A child module's `impl` is the same inherent impl and sees the parent's private
//! items unchanged. ~keep
use super::*;
impl<'doc> TextExtractor<'doc> {
/// Partition character array into clusters at boundary positions.
///
/// # Arguments
/// * `characters` - Full character array from TJ processing
/// * `boundaries` - Boundary indices (positions where word boundaries occur)
///
/// # Returns
/// Vector of character clusters, where boundaries separate clusters
pub(super) fn partition_characters_by_boundaries(
&self,
characters: &[CharacterInfo],
boundaries: Vec<usize>,
) -> Vec<Vec<CharacterInfo>> {
if boundaries.is_empty() {
return vec![characters.to_vec()];
}
let mut clusters = Vec::new();
let mut prev = 0;
for boundary_idx in boundaries {
if boundary_idx > prev {
clusters.push(characters[prev..boundary_idx].to_vec());
}
prev = boundary_idx;
}
if prev < characters.len() {
clusters.push(characters[prev..].to_vec());
}
clusters
}
/// Convert a character cluster to a TextSpan.
///
/// Calculates bounding box from character positions and creates
/// a single TextSpan marked with primary_detected flag.
///
/// # Arguments
/// * `cluster` - Character cluster from partitioning
pub(super) fn cluster_to_span(&mut self, cluster: &[CharacterInfo]) -> Result<()> {
if cluster.is_empty() {
return Ok(());
}
// Snapshot the current MCID scope before borrowing graphics
// state so the borrow checker doesn't reject the
// `current_mcid_scope()` call at span construction time. ~keep
let mcid_scope = self.current_mcid_scope();
let state = self.state_stack.current();
let text_min_x = cluster[0].x_position;
// Safety: caller checks cluster.is_empty() above and returns early ~keep
let last = cluster.last().expect("cluster verified non-empty above");
let text_max_x = last.x_position + last.width;
let text_width = (text_max_x - text_min_x).max(0.0);
let height = cluster[0].font_size.abs() * state.text_matrix.d.abs().max(1.0);
// Step 2: Apply CTM to convert from text space to user space
// Per PDF Spec ISO 32000-1:2008 Section 9.4.4 ~keep
let text_matrix = state.text_matrix;
let ctm = state.ctm;
let text_pos = text_matrix.transform_point(text_min_x, 0.0);
let user_pos = ctm.transform_point(text_pos.x, text_pos.y);
let user_width = text_width * text_matrix.a.abs() * ctm.a.abs();
let bbox = Rect {
x: user_pos.x,
y: user_pos.y,
width: user_width.max(text_width), // Use larger of the two for safety ~keep
height,
};
let mut unicode_text = if let Some(font_name) = state.font_name.as_ref() {
if let Some(font) = self.fonts.get(font_name) {
let mut text = String::new();
for char_info in cluster {
if let Some(decoded) = font.char_to_unicode(char_info.code) {
text.push_str(&decoded);
}
}
text
} else {
String::new()
}
} else {
String::new()
};
// Step 3b: RTL text correction — reverse visual-order characters to logical order.
//
// PDF stores characters in content-stream order. For RTL scripts
// (Arabic / Hebrew), the producer may emit text in either:
// * **visual order** — glyphs drawn left-to-right in user space
// even though the script reads right-to-left (legacy Acrobat
// output, pre-shaped Arabic), OR
// * **logical order** — glyphs drawn right-to-left in user space
// because the producer ran its own bidi pass before drawing
// (modern Word with bidi, the pdfium `hebrew_mirrored.pdf`
// test fixture).
//
// We use the confidence-gated geometric detector
// [`text::bidi::detect_visual_order_run`] when the
// cluster has ≥4 RTL letters with clear X-monotonicity. For
// shorter clusters (or `Ambiguous` verdict) we fall back to a
// simpler `last_x > first_x` heuristic — keeps the
// existing 2-3-char RTL run behaviour byte-identical so the
// upstream invariants (Arabic CID-TrueType samples, the
// `right_to_left_02` fixture) still pass. ~keep
if unicode_text.len() > 1 && cluster.len() >= 2 {
let has_rtl = unicode_text
.chars()
.any(|c| crate::text::rtl_detector::is_rtl_text(c as u32));
if has_rtl {
// Build (char, user_x) pairs for the geometric detector.
// One pair per source character — when the decoded
// string has more chars than the cluster (e.g. ligature
// expansion `fi` → "fi"), use the first decoded char as
// a representative since they share the same source x. ~keep
let font_for_cluster = state.font_name.as_ref().and_then(|n| self.fonts.get(n));
let mut chars_with_x: Vec<(char, f32)> = Vec::with_capacity(cluster.len());
for ci in cluster {
let decoded_first = font_for_cluster
.and_then(|f| f.char_to_unicode(ci.code))
.and_then(|s| s.chars().next());
if let Some(c) = decoded_first {
let p = text_matrix.transform_point(ci.x_position, 0.0);
let user_x = ctm.transform_point(p.x, p.y).x;
chars_with_x.push((c, user_x));
}
}
let verdict = crate::text::bidi::detect_visual_order_run(&chars_with_x);
// The simpler heuristic — used only as the
// `Ambiguous` fallback (short cluster or mixed signal) so
// existing 2-3-char RTL runs keep working; the pdfium
// `hebrew_mirrored.pdf` fixture and similar land on
// `Logical` above and are left alone regardless. ~keep
let first_x = {
let p = text_matrix.transform_point(cluster[0].x_position, 0.0);
ctm.transform_point(p.x, p.y).x
};
let last_x = {
let p = text_matrix.transform_point(last.x_position, 0.0);
ctm.transform_point(p.x, p.y).x
};
unicode_text = crate::text::bidi::apply_rtl_verdict(
&unicode_text,
verdict,
last_x > first_x,
matches!(state.render_mode, 3 | 7),
);
}
}
let font_weight = if let Some(font_name) = state.font_name.as_ref() {
if let Some(font) = self.fonts.get(font_name) {
if font.is_bold() {
FontWeight::Bold
} else {
FontWeight::Normal
}
} else {
FontWeight::Normal
}
} else {
FontWeight::Normal
};
let is_italic = state
.font_name
.as_ref()
.and_then(|name| self.fonts.get(name))
.map(|font| font.is_italic())
.unwrap_or(false);
let span = TextSpan {
provenance: None,
text: unicode_text,
bbox,
font_name: state.font_name.clone().unwrap_or_else(|| "Unknown".to_string()),
font_size: cluster[0].font_size,
font_weight,
color: Color::new(state.fill_color_rgb.0, state.fill_color_rgb.1, state.fill_color_rgb.2),
mcid: self.current_mcid,
mcid_scope: Some(mcid_scope),
sequence: self.span_sequence_counter,
split_boundary_before: false,
offset_semantic: false,
char_spacing: state.char_space,
word_spacing: state.word_space,
horizontal_scaling: state.horizontal_scaling,
is_italic,
is_monospace: false,
primary_detected: true,
artifact_type: None,
char_widths: vec![],
char_x_offsets: Vec::new(),
heading_level: None,
rotation_degrees: snap_run_rotation(&state.ctm.multiply(&state.text_matrix)),
wmode: state.text_wmode,
text_rise: if state.font_size > 0.0 {
state.text_rise / state.font_size
} else {
0.0
},
rtl_draw_logical: false,
mirrored: run_is_mirrored(&state.ctm.multiply(&state.text_matrix)),
page_rotation_applied: 0,
};
self.span_sequence_counter += 1;
if !self.is_content_suppressed() {
self.spans.push(span);
}
Ok(())
}
/// Check if a character code is a ligature (U+FB00-U+FB04).
///
/// Standard ligatures supported:
/// - U+FB00: ff (LATIN SMALL LIGATURE FF)
/// - U+FB01: fi (LATIN SMALL LIGATURE FI)
/// - U+FB02: fl (LATIN SMALL LIGATURE FL)
/// - U+FB03: ffi (LATIN SMALL LIGATURE FFI)
/// - U+FB04: ffl (LATIN SMALL LIGATURE FFL)
pub(super) fn is_ligature_code(code: u32) -> bool {
matches!(code, 0xFB00..=0xFB04)
}
/// Apply ligature expansion decisions after word boundary detection.
///
/// This method processes the character array after boundary detection,
/// making intelligent decisions about whether to split ligatures.
///
/// Algorithm:
/// 1. Iterate through character array
/// 2. For each ligature character:
/// - Get next character (if exists)
/// - Call LigatureDecisionMaker::decide()
/// - If Split: expand to component characters with proportional widths
/// - If Keep: leave as-is
/// 3. Recalculate x_positions for all following characters after splits
pub(super) fn apply_ligature_decisions(&mut self) -> Result<()> {
use crate::text::ligature_processor::{LigatureDecision, LigatureDecisionMaker, expand_ligature_to_chars};
let context = self.create_boundary_context();
let mut result = Vec::new();
let mut i = 0;
// OPTIMIZATION: Single-pass reconstruction instead of Vec::insert() in loop
// This fixes O(n²) complexity to O(n) by avoiding repeated insertions
// Avoiding Vec::insert fixes a 50× slowdown for ligature-heavy PDFs ~keep
while i < self.tj_character_array.len() {
let char_info = &self.tj_character_array[i];
if !char_info.is_ligature {
result.push(char_info.clone());
i += 1;
continue;
}
// Get next character without cloning (eliminates unnecessary clones) ~keep
let next_char = if i + 1 < self.tj_character_array.len() {
Some(&self.tj_character_array[i + 1])
} else {
None
};
let decision = LigatureDecisionMaker::decide(char_info, &context, next_char);
if decision == LigatureDecision::Split {
let ligature_char = char::from_u32(char_info.code).unwrap_or('?');
let original_width = char_info.width;
let original_x = char_info.x_position;
let font_size = char_info.font_size;
let components = expand_ligature_to_chars(ligature_char, original_width);
if !components.is_empty() {
let mut x_offset = 0.0;
result.push(CharacterInfo {
code: components[0].0 as u32,
glyph_id: char_info.glyph_id,
width: components[0].1,
x_position: original_x,
tj_offset: char_info.tj_offset,
font_size,
is_ligature: false,
original_ligature: Some(ligature_char),
protected_from_split: char_info.protected_from_split,
});
x_offset += components[0].1;
for (comp_char, comp_width) in components.iter().skip(1) {
result.push(CharacterInfo {
code: *comp_char as u32,
glyph_id: None,
width: *comp_width,
x_position: original_x + x_offset,
tj_offset: None,
font_size,
is_ligature: false,
original_ligature: Some(ligature_char),
protected_from_split: false,
});
x_offset += comp_width;
}
} else {
result.push(char_info.clone());
}
} else {
result.push(char_info.clone());
}
i += 1;
}
// OPTIMIZATION: Replace entire array once instead of multiple insertions ~keep
self.tj_character_array = result;
Ok(())
}
}