1use lopdf::{content::Content, Document, Object};
7
8use crate::models::bbox::BoundingBox;
9use crate::models::chunks::TextChunk;
10use crate::EdgePdfError;
11
12use super::font::{resolve_page_fonts, FontCache, PdfFont};
13use super::graphics_state::GraphicsStateStack;
14
15pub fn extract_text_chunks(
19 doc: &Document,
20 page_number: u32,
21 page_id: lopdf::ObjectId,
22) -> Result<Vec<TextChunk>, EdgePdfError> {
23 let font_cache = resolve_page_fonts(doc, page_id);
24 let page_dict = doc
25 .get_object(page_id)
26 .map_err(|e| EdgePdfError::PipelineError {
27 stage: 1,
28 message: format!("Failed to get page {}: {}", page_number, e),
29 })?
30 .as_dict()
31 .map_err(|e| EdgePdfError::PipelineError {
32 stage: 1,
33 message: format!("Page {} is not a dictionary: {}", page_number, e),
34 })?
35 .clone();
36
37 let media_box = get_media_box(doc, &page_dict);
39
40 let content_data = get_page_content(doc, &page_dict)?;
42 if content_data.is_empty() {
43 return Ok(Vec::new());
44 }
45
46 let content = Content::decode(&content_data).map_err(|e| EdgePdfError::PipelineError {
48 stage: 1,
49 message: format!(
50 "Failed to decode content stream for page {}: {}",
51 page_number, e
52 ),
53 })?;
54
55 let chunks = process_operations(&content.operations, &font_cache, page_number, &media_box);
57
58 Ok(chunks)
59}
60
61pub(crate) fn get_page_content(
63 doc: &Document,
64 page_dict: &lopdf::Dictionary,
65) -> Result<Vec<u8>, EdgePdfError> {
66 let contents = match page_dict.get(b"Contents") {
67 Ok(c) => c.clone(),
68 Err(_) => return Ok(Vec::new()),
69 };
70
71 fn collect_array(doc: &Document, arr: &[Object]) -> Result<Vec<u8>, EdgePdfError> {
74 let mut data = Vec::new();
75 for item in arr {
76 let obj = match item {
77 Object::Reference(id) => {
78 doc.get_object(*id)
79 .map_err(|e| EdgePdfError::PipelineError {
80 stage: 1,
81 message: format!("Failed to resolve content array item: {}", e),
82 })?
83 }
84 other => other,
85 };
86 if let Object::Stream(ref stream) = obj {
87 if let Ok(content) = get_stream_data(stream) {
88 data.extend_from_slice(&content);
89 data.push(b' '); }
91 }
92 }
93 Ok(data)
94 }
95
96 match contents {
97 Object::Reference(id) => {
98 let obj = doc
99 .get_object(id)
100 .map_err(|e| EdgePdfError::PipelineError {
101 stage: 1,
102 message: format!("Failed to get content object: {}", e),
103 })?;
104 match obj {
105 Object::Stream(ref stream) => get_stream_data(stream),
107 Object::Array(ref arr) => collect_array(doc, arr),
109 _ => Ok(Vec::new()),
110 }
111 }
112 Object::Array(ref arr) => collect_array(doc, arr),
113 _ => Ok(Vec::new()),
114 }
115}
116
117fn get_stream_data(stream: &lopdf::Stream) -> Result<Vec<u8>, EdgePdfError> {
119 if stream.dict.get(b"Filter").is_err() {
121 return Ok(stream.content.clone());
122 }
123 stream
124 .decompressed_content()
125 .map_err(|e| EdgePdfError::PipelineError {
126 stage: 1,
127 message: format!("Failed to decompress content stream: {}", e),
128 })
129}
130
131fn get_media_box(doc: &Document, page_dict: &lopdf::Dictionary) -> BoundingBox {
133 if let Ok(mb) = page_dict.get(b"MediaBox") {
134 if let Ok(arr) = resolve_obj(doc, mb).as_array() {
135 if arr.len() == 4 {
136 let vals: Vec<f64> = arr
137 .iter()
138 .filter_map(|o| obj_to_f64(resolve_obj(doc, o)))
139 .collect();
140 if vals.len() == 4 {
141 return BoundingBox::new(None, vals[0], vals[1], vals[2], vals[3]);
142 }
143 }
144 }
145 }
146 BoundingBox::new(None, 0.0, 0.0, 595.0, 842.0)
148}
149
150fn process_operations(
152 operations: &[lopdf::content::Operation],
153 font_cache: &FontCache,
154 page_number: u32,
155 _media_box: &BoundingBox,
156) -> Vec<TextChunk> {
157 let mut chunks = Vec::new();
158 let mut state = GraphicsStateStack::default();
159 let mut chunk_index: usize = 0;
160 let mut mcid_stack: Vec<Option<i64>> = Vec::new();
162
163 for op in operations {
164 match op.operator.as_str() {
165 "BMC" => {
167 mcid_stack.push(None);
169 }
170 "BDC" => {
171 let mcid = extract_mcid_from_bdc(&op.operands);
173 mcid_stack.push(mcid);
174 }
175 "EMC" => {
176 mcid_stack.pop();
178 }
179
180 "q" => state.save(),
182 "Q" => state.restore(),
183 "cm"
184 if op.operands.len() == 6 => {
185 let vals: Vec<f64> = op
186 .operands
187 .iter()
188 .filter_map(|o| obj_to_f64(o.clone()))
189 .collect();
190 if vals.len() == 6 {
191 state.concat_ctm(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]);
192 }
193 }
194
195 "BT" => state.current.begin_text(),
197 "ET" => {} "Tf"
200 if op.operands.len() == 2 => {
202 if let Object::Name(ref name) = op.operands[0] {
203 state.current.text_state.font_name =
204 String::from_utf8_lossy(name).to_string();
205 }
206 if let Some(size) = obj_to_f64(op.operands[1].clone()) {
207 state.current.text_state.font_size = size;
208 }
209 }
210
211 "Tc" => {
212 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
213 state.current.text_state.char_spacing = v;
214 }
215 }
216
217 "Tw" => {
218 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
219 state.current.text_state.word_spacing = v;
220 }
221 }
222
223 "Tz" => {
224 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
225 state.current.text_state.horizontal_scaling = v;
226 }
227 }
228
229 "TL" => {
230 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
231 state.current.text_state.leading = v;
232 }
233 }
234
235 "Ts" => {
236 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
237 state.current.text_state.rise = v;
238 }
239 }
240
241 "Tr" => {
242 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
243 state.current.text_state.render_mode = v as i32;
244 }
245 }
246
247 "Td"
249 if op.operands.len() == 2 => {
250 let tx = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
251 let ty = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
252 state.current.translate_text(tx, ty);
253 }
254
255 "TD"
256 if op.operands.len() == 2 => {
258 let tx = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
259 let ty = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
260 state.current.text_state.leading = -ty;
261 state.current.translate_text(tx, ty);
262 }
263
264 "Tm"
265 if op.operands.len() == 6 => {
266 let vals: Vec<f64> = op
267 .operands
268 .iter()
269 .filter_map(|o| obj_to_f64(o.clone()))
270 .collect();
271 if vals.len() == 6 {
272 state
273 .current
274 .set_text_matrix(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]);
275 }
276 }
277
278 "T*" => {
279 state.current.next_line();
280 }
281
282 "Tj" => {
284 if let Some(text_bytes) = op.operands.first().and_then(extract_string_bytes) {
285 let font = font_cache
286 .get(&state.current.text_state.font_name)
287 .cloned()
288 .unwrap_or_else(|| {
289 PdfFont::default_font(&state.current.text_state.font_name)
290 });
291 let active_mcid = active_mcid(&mcid_stack);
292
293 if let Some(chunk) = create_text_chunk(
294 &text_bytes,
295 &font,
296 &mut state,
297 page_number,
298 &mut chunk_index,
299 active_mcid,
300 ) {
301 chunks.push(chunk);
302 }
303 }
304 }
305
306 "TJ" => {
307 if let Some(Object::Array(ref arr)) = op.operands.first() {
309 let font = font_cache
310 .get(&state.current.text_state.font_name)
311 .cloned()
312 .unwrap_or_else(|| {
313 PdfFont::default_font(&state.current.text_state.font_name)
314 });
315 let active_mcid = active_mcid(&mcid_stack);
316
317 for item in arr {
318 match item {
319 Object::String(bytes, _) => {
320 if let Some(chunk) = create_text_chunk(
321 bytes,
322 &font,
323 &mut state,
324 page_number,
325 &mut chunk_index,
326 active_mcid,
327 ) {
328 chunks.push(chunk);
329 }
330 }
331 _ => {
332 if let Some(adj) = obj_to_f64(item.clone()) {
334 let displacement =
335 -adj / 1000.0 * state.current.text_state.font_size;
336 state.current.advance_text(displacement);
337 }
338 }
339 }
340 }
341 }
342 }
343
344 "'" => {
345 state.current.next_line();
347 if let Some(text_bytes) = op.operands.first().and_then(extract_string_bytes) {
348 let font = font_cache
349 .get(&state.current.text_state.font_name)
350 .cloned()
351 .unwrap_or_else(|| {
352 PdfFont::default_font(&state.current.text_state.font_name)
353 });
354 let active_mcid = active_mcid(&mcid_stack);
355 if let Some(chunk) = create_text_chunk(
356 &text_bytes,
357 &font,
358 &mut state,
359 page_number,
360 &mut chunk_index,
361 active_mcid,
362 ) {
363 chunks.push(chunk);
364 }
365 }
366 }
367
368 "\""
369 if op.operands.len() == 3 => {
371 if let Some(aw) = obj_to_f64(op.operands[0].clone()) {
372 state.current.text_state.word_spacing = aw;
373 }
374 if let Some(ac) = obj_to_f64(op.operands[1].clone()) {
375 state.current.text_state.char_spacing = ac;
376 }
377 state.current.next_line();
378 if let Some(text_bytes) = extract_string_bytes(&op.operands[2]) {
379 let font = font_cache
380 .get(&state.current.text_state.font_name)
381 .cloned()
382 .unwrap_or_else(|| {
383 PdfFont::default_font(&state.current.text_state.font_name)
384 });
385 let active_mcid = active_mcid(&mcid_stack);
386 if let Some(chunk) = create_text_chunk(
387 &text_bytes,
388 &font,
389 &mut state,
390 page_number,
391 &mut chunk_index,
392 active_mcid,
393 ) {
394 chunks.push(chunk);
395 }
396 }
397 }
398
399 "g" => {
401 if let Some(gray) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
402 state.current.fill_color = vec![gray];
403 state.current.fill_color_space_components = 1;
404 }
405 }
406 "G" => {
407 if let Some(gray) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
408 state.current.stroke_color = vec![gray];
409 state.current.stroke_color_space_components = 1;
410 }
411 }
412 "rg"
413 if op.operands.len() == 3 => {
414 let r = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
415 let g = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
416 let b = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
417 state.current.fill_color = vec![r, g, b];
418 state.current.fill_color_space_components = 3;
419 }
420 "RG"
421 if op.operands.len() == 3 => {
422 let r = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
423 let g = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
424 let b = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
425 state.current.stroke_color = vec![r, g, b];
426 state.current.stroke_color_space_components = 3;
427 }
428 "k"
429 if op.operands.len() == 4 => {
430 let c = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
431 let m = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
432 let y = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
433 let k = obj_to_f64(op.operands[3].clone()).unwrap_or(0.0);
434 state.current.fill_color = vec![c, m, y, k];
435 state.current.fill_color_space_components = 4;
436 }
437 "K"
438 if op.operands.len() == 4 => {
439 let c = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
440 let m = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
441 let y = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
442 let k = obj_to_f64(op.operands[3].clone()).unwrap_or(0.0);
443 state.current.stroke_color = vec![c, m, y, k];
444 state.current.stroke_color_space_components = 4;
445 }
446 "cs" => {
448 if let Some(name) = op.operands.first() {
449 let cs_name = obj_to_name(name);
450 state.current.fill_color_space_components = color_space_components(&cs_name);
451 }
452 }
453 "CS" => {
454 if let Some(name) = op.operands.first() {
455 let cs_name = obj_to_name(name);
456 state.current.stroke_color_space_components = color_space_components(&cs_name);
457 }
458 }
459 "sc" | "scn" => {
460 let components: Vec<f64> = op
461 .operands
462 .iter()
463 .filter_map(|o| obj_to_f64(o.clone()))
464 .collect();
465 if !components.is_empty() {
466 state.current.fill_color = components;
467 }
468 }
469 "SC" | "SCN" => {
470 let components: Vec<f64> = op
471 .operands
472 .iter()
473 .filter_map(|o| obj_to_f64(o.clone()))
474 .collect();
475 if !components.is_empty() {
476 state.current.stroke_color = components;
477 }
478 }
479
480 _ => {
481 }
483 }
484 }
485
486 chunks
487}
488
489fn create_text_chunk(
491 text_bytes: &[u8],
492 font: &PdfFont,
493 state: &mut GraphicsStateStack,
494 page_number: u32,
495 chunk_index: &mut usize,
496 mcid: Option<i64>,
497) -> Option<TextChunk> {
498 if text_bytes.is_empty() {
499 return None;
500 }
501
502 let trm = state.current.text_rendering_matrix();
504 let start_x = trm.e;
505 let font_size = trm.font_size_factor();
506
507 if font_size < 0.1 {
508 return None; }
510
511 let mut text = String::new();
513 let mut total_width = 0.0;
514 let mut symbol_ends = Vec::new();
515
516 let bpc = font.bytes_per_code as usize;
517 let mut pos = 0;
518 while pos + bpc <= text_bytes.len() {
519 let char_code = if bpc == 2 {
520 ((text_bytes[pos] as u32) << 8) | (text_bytes[pos + 1] as u32)
521 } else {
522 text_bytes[pos] as u32
523 };
524 pos += bpc;
525
526 let decoded = font.decode_char(char_code);
527 text.push_str(&decoded);
528
529 let glyph_w = font.glyph_width(char_code) / 1000.0;
531 total_width += glyph_w;
532 symbol_ends.push(start_x + total_width * font_size);
533
534 total_width += state.current.text_state.char_spacing / state.current.text_state.font_size;
536
537 if decoded == " " {
539 total_width +=
540 state.current.text_state.word_spacing / state.current.text_state.font_size;
541 }
542 }
543
544 let displacement = total_width * state.current.text_state.font_size;
546 state.current.advance_text(displacement);
547
548 if text.is_empty() {
549 return None;
550 }
551
552 let trm_after = state.current.text_rendering_matrix();
554
555 let ascent = font.ascent;
559 let descent = font.descent;
560
561 let trm_before = &trm; let (x1, x2) = if trm_before.a >= 0.0 && trm_before.c >= 0.0 {
570 (
571 trm_before.e + descent * trm_before.c / 1000.0,
572 trm_after.e + ascent * trm_after.c / 1000.0,
573 )
574 } else if trm_before.a < 0.0 && trm_before.c < 0.0 {
575 (
576 trm_after.e + ascent * trm_after.c / 1000.0,
577 trm_before.e + descent * trm_before.c / 1000.0,
578 )
579 } else if trm_before.a >= 0.0 {
580 (
581 trm_before.e + ascent * trm_before.c / 1000.0,
582 trm_after.e + descent * trm_after.c / 1000.0,
583 )
584 } else {
585 (
586 trm_after.e + descent * trm_after.c / 1000.0,
587 trm_before.e + ascent * trm_before.c / 1000.0,
588 )
589 };
590
591 let (y1, y2) = if trm_before.d >= 0.0 && trm_before.b >= 0.0 {
592 (
593 trm_before.f + descent * trm_before.d / 1000.0,
594 trm_after.f + ascent * trm_after.d / 1000.0,
595 )
596 } else if trm_before.d < 0.0 && trm_before.b < 0.0 {
597 (
598 trm_after.f + ascent * trm_after.d / 1000.0,
599 trm_before.f + descent * trm_before.d / 1000.0,
600 )
601 } else if trm_before.d >= 0.0 {
602 (
603 trm_after.f + descent * trm_after.d / 1000.0,
604 trm_before.f + ascent * trm_before.d / 1000.0,
605 )
606 } else {
607 (
608 trm_before.f + ascent * trm_before.d / 1000.0,
609 trm_after.f + descent * trm_after.d / 1000.0,
610 )
611 };
612
613 let bbox = BoundingBox::new(Some(page_number), x1, y1, x2, y2);
614
615 let text_format = if state.current.text_state.rise > font_size * 0.1 {
617 crate::models::enums::TextFormat::Superscript
618 } else if state.current.text_state.rise < -font_size * 0.1 {
619 crate::models::enums::TextFormat::Subscript
620 } else {
621 crate::models::enums::TextFormat::Normal
622 };
623
624 *chunk_index += 1;
625
626 let fc = &state.current.fill_color;
631 let font_color = format!(
632 "[{}]",
633 fc.iter()
634 .map(|v| {
635 let f32_val = *v as f32;
636 let f64_repr = f32_val as f64;
637 if f32_val.fract() == 0.0 {
638 format!("{:.1}", f64_repr)
639 } else {
640 format!("{}", f64_repr)
641 }
642 })
643 .collect::<Vec<_>>()
644 .join(", ")
645 );
646
647 Some(TextChunk {
648 value: text,
649 bbox,
650 font_name: font.base_font.clone(),
651 font_size,
652 font_weight: font.weight,
653 italic_angle: font.italic_angle,
654 font_color,
655 contrast_ratio: 21.0, symbol_ends,
657 text_format,
658 text_type: crate::models::enums::TextType::Regular,
659 pdf_layer: crate::models::enums::PdfLayer::Main,
660 ocg_visible: true,
661 index: Some(*chunk_index),
662 page_number: Some(page_number),
663 level: None,
664 mcid,
665 })
666}
667
668fn extract_string_bytes(obj: &Object) -> Option<Vec<u8>> {
670 match obj {
671 Object::String(bytes, _) => Some(bytes.clone()),
672 _ => None,
673 }
674}
675
676fn active_mcid(stack: &[Option<i64>]) -> Option<i64> {
679 stack.iter().rev().find_map(|&mcid| mcid)
680}
681
682fn extract_mcid_from_bdc(operands: &[Object]) -> Option<i64> {
686 if operands.len() < 2 {
688 return None;
689 }
690 match &operands[1] {
691 Object::Dictionary(dict) => {
692 if let Ok(Object::Integer(n)) = dict.get(b"MCID") {
693 return Some(*n);
694 }
695 None
696 }
697 _ => None,
698 }
699}
700
701fn obj_to_f64(obj: Object) -> Option<f64> {
703 match obj {
704 Object::Integer(i) => Some(i as f64),
705 Object::Real(f) => Some(f),
706 _ => None,
707 }
708}
709
710fn obj_to_name(obj: &Object) -> String {
712 match obj {
713 Object::Name(bytes) => String::from_utf8_lossy(bytes).to_string(),
714 _ => String::new(),
715 }
716}
717
718fn color_space_components(name: &str) -> u8 {
720 match name {
721 "DeviceGray" | "CalGray" | "G" => 1,
722 "DeviceRGB" | "CalRGB" | "RGB" => 3,
723 "DeviceCMYK" | "CMYK" => 4,
724 _ => 3, }
726}
727
728fn resolve_obj<'a>(doc: &'a Document, obj: &'a Object) -> lopdf::Object {
730 match obj {
731 Object::Reference(id) => doc.get_object(*id).cloned().unwrap_or(Object::Null),
732 other => other.clone(),
733 }
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739 use lopdf::content::Operation;
740 use lopdf::dictionary;
741
742 fn create_test_pdf() -> Document {
744 let mut doc = Document::with_version("1.5");
745 let pages_id = doc.new_object_id();
746
747 let font_id = doc.add_object(dictionary! {
748 "Type" => "Font",
749 "Subtype" => "Type1",
750 "BaseFont" => "Helvetica",
751 });
752
753 let resources_id = doc.add_object(dictionary! {
754 "Font" => dictionary! {
755 "F1" => font_id,
756 },
757 });
758
759 let content = Content {
760 operations: vec![
761 Operation::new("BT", vec![]),
762 Operation::new("Tf", vec!["F1".into(), 12.into()]),
763 Operation::new("Td", vec![100.into(), 700.into()]),
764 Operation::new("Tj", vec![Object::string_literal("Hello World!")]),
765 Operation::new("ET", vec![]),
766 ],
767 };
768
769 let content_id = doc.add_object(lopdf::Stream::new(
770 dictionary! {},
771 content.encode().unwrap(),
772 ));
773
774 let page_id = doc.add_object(dictionary! {
775 "Type" => "Page",
776 "Parent" => pages_id,
777 "Contents" => content_id,
778 "Resources" => resources_id,
779 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
780 });
781
782 let pages = dictionary! {
783 "Type" => "Pages",
784 "Kids" => vec![page_id.into()],
785 "Count" => 1,
786 };
787 doc.objects.insert(pages_id, Object::Dictionary(pages));
788
789 let catalog_id = doc.add_object(dictionary! {
790 "Type" => "Catalog",
791 "Pages" => pages_id,
792 });
793 doc.trailer.set("Root", catalog_id);
794 doc
795 }
796
797 #[test]
798 fn test_extract_text_from_synthetic_pdf() {
799 let doc = create_test_pdf();
800 let pages = doc.get_pages();
801 let (&page_num, &page_id) = pages.iter().next().unwrap();
802
803 let chunks = extract_text_chunks(&doc, page_num, page_id).unwrap();
804
805 assert!(!chunks.is_empty(), "Expected text chunks from test PDF");
807
808 let first = &chunks[0];
810 assert!(
811 first.value.contains("Hello"),
812 "Expected 'Hello' in chunk, got: '{}'",
813 first.value
814 );
815 }
816
817 #[test]
818 fn test_extract_empty_page() {
819 let mut doc = Document::with_version("1.5");
820 let pages_id = doc.new_object_id();
821
822 let page_id = doc.add_object(dictionary! {
823 "Type" => "Page",
824 "Parent" => pages_id,
825 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
826 });
827
828 let pages = dictionary! {
829 "Type" => "Pages",
830 "Kids" => vec![page_id.into()],
831 "Count" => 1,
832 };
833 doc.objects.insert(pages_id, Object::Dictionary(pages));
834
835 let catalog_id = doc.add_object(dictionary! {
836 "Type" => "Catalog",
837 "Pages" => pages_id,
838 });
839 doc.trailer.set("Root", catalog_id);
840
841 let pages = doc.get_pages();
842 let (&page_num, &page_id) = pages.iter().next().unwrap();
843
844 let chunks = extract_text_chunks(&doc, page_num, page_id).unwrap();
845 assert!(chunks.is_empty());
846 }
847}