1use lopdf::{content::Content, Dictionary, Document, Object, ObjectId};
15
16use crate::models::bbox::{BoundingBox, Vertex};
17use crate::models::chunks::{ImageChunk, ImagePaintSource, LineArtChunk, LineChunk, TextChunk};
18use crate::EdgePdfError;
19
20use super::font::{resolve_page_fonts, FontCache, PdfFont};
21use super::graphics_state::{GraphicsStateStack, Matrix};
22use super::ocg::{load_ocg_visibility, OcgVisibility};
23use super::pdf_string::decode_pdf_string;
24
25const MAX_FORM_RECURSION_DEPTH: u32 = 10;
27
28const MIN_LINE_WIDTH: f64 = 0.1;
30
31const LINE_ASPECT_RATIO: f64 = 3.0;
33
34const MAX_LINE_THICKNESS: f64 = 10.0;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum PathPaint {
40 Stroke,
42 Fill,
44}
45
46#[derive(Debug, Default)]
48pub struct PageChunks {
49 pub text_chunks: Vec<TextChunk>,
51 pub image_chunks: Vec<ImageChunk>,
53 pub line_chunks: Vec<LineChunk>,
55 pub line_art_chunks: Vec<LineArtChunk>,
57}
58
59pub fn extract_page_chunks(
61 doc: &Document,
62 page_number: u32,
63 page_id: ObjectId,
64) -> Result<PageChunks, EdgePdfError> {
65 let font_cache = resolve_page_fonts(doc, page_id);
66
67 let page_dict = doc
68 .get_object(page_id)
69 .map_err(|e| EdgePdfError::PipelineError {
70 stage: 1,
71 message: format!("Failed to get page {}: {}", page_number, e),
72 })?
73 .as_dict()
74 .map_err(|e| EdgePdfError::PipelineError {
75 stage: 1,
76 message: format!("Page {} is not a dictionary: {}", page_number, e),
77 })?
78 .clone();
79
80 let content_data = super::text_extractor::get_page_content(doc, &page_dict)?;
82 if content_data.is_empty() {
83 return Ok(PageChunks::default());
84 }
85
86 let content = Content::decode(&content_data).map_err(|e| EdgePdfError::PipelineError {
88 stage: 1,
89 message: format!(
90 "Failed to decode content stream for page {}: {}",
91 page_number, e
92 ),
93 })?;
94
95 let resources = resolve_page_resources(doc, &page_dict);
97
98 let mut parser = ChunkParserState::new(page_number, font_cache);
99 parser.ocg = load_ocg_visibility(doc);
100 parser.process_operations(doc, &content.operations, &resources, 0);
101
102 Ok(parser.into_page_chunks())
103}
104
105fn resolve_page_resources(doc: &Document, page_dict: &Dictionary) -> Dictionary {
107 match page_dict.get(b"Resources") {
108 Ok(obj) => {
109 let resolved = resolve_obj(doc, obj);
110 resolved.as_dict().cloned().unwrap_or_default()
111 }
112 Err(_) => Dictionary::new(),
113 }
114}
115
116struct ChunkParserState {
118 page_number: u32,
119 font_cache: FontCache,
120 gs_stack: GraphicsStateStack,
121
122 text_chunks: Vec<TextChunk>,
124 image_chunks: Vec<ImageChunk>,
125 line_chunks: Vec<LineChunk>,
126 line_art_chunks: Vec<LineArtChunk>,
127
128 text_index: usize,
130 image_index: u32,
131 line_index: u32,
132
133 mcid_stack: Vec<Option<i64>>,
135 actual_text_stack: Vec<Option<String>>,
139 actual_text_emitted: Vec<bool>,
141 ocg: OcgVisibility,
143 ocg_visible_stack: Vec<bool>,
145
146 current_path: Vec<PathSegment>,
148 subpath_start: Option<(f64, f64)>,
149 current_point: Option<(f64, f64)>,
150 line_width: f64,
151}
152
153impl ChunkParserState {
154 fn new(page_number: u32, font_cache: FontCache) -> Self {
155 Self {
156 page_number,
157 font_cache,
158 gs_stack: GraphicsStateStack::default(),
159
160 text_chunks: Vec::new(),
161 image_chunks: Vec::new(),
162 line_chunks: Vec::new(),
163 line_art_chunks: Vec::new(),
164
165 text_index: 0,
166 image_index: 0,
167 line_index: 0,
168
169 mcid_stack: Vec::new(),
170 actual_text_stack: Vec::new(),
171 actual_text_emitted: Vec::new(),
172 ocg: OcgVisibility::default(),
173 ocg_visible_stack: Vec::new(),
174
175 current_path: Vec::new(),
176 subpath_start: None,
177 current_point: None,
178 line_width: 1.0,
179 }
180 }
181
182 fn into_page_chunks(self) -> PageChunks {
183 PageChunks {
184 text_chunks: self.text_chunks,
185 image_chunks: self.image_chunks,
186 line_chunks: self.line_chunks,
187 line_art_chunks: self.line_art_chunks,
188 }
189 }
190
191 fn process_operations(
193 &mut self,
194 doc: &Document,
195 operations: &[lopdf::content::Operation],
196 resources: &Dictionary,
197 recursion_depth: u32,
198 ) {
199 for op in operations {
200 match op.operator.as_str() {
201 "BMC" => {
203 self.mcid_stack.push(None);
204 self.actual_text_stack.push(None);
205 self.actual_text_emitted.push(false);
206 self.ocg_visible_stack
207 .push(self.current_ocg_visible());
208 }
209 "BDC" => {
210 let props = resolve_bdc_properties(doc, resources, &op.operands);
211 let mcid = props.as_ref().and_then(|d| {
212 d.get(b"MCID").ok().and_then(|o| match o {
213 Object::Integer(n) => Some(*n),
214 _ => None,
215 })
216 });
217 let actual = props.as_ref().and_then(extract_actual_text);
218 let visible = props
219 .as_ref()
220 .and_then(|d| ocg_visibility_from_props(doc, d, &self.ocg))
221 .unwrap_or_else(|| self.current_ocg_visible());
222 self.mcid_stack.push(mcid);
223 self.actual_text_stack.push(actual);
224 self.actual_text_emitted.push(false);
225 self.ocg_visible_stack.push(visible);
226 }
227 "EMC" => {
228 self.mcid_stack.pop();
229 self.actual_text_stack.pop();
230 self.actual_text_emitted.pop();
231 self.ocg_visible_stack.pop();
232 }
233
234 "q" => self.gs_stack.save(),
236 "Q" => self.gs_stack.restore(),
237 "cm"
238 if op.operands.len() == 6 => {
239 let vals: Vec<f64> = op
240 .operands
241 .iter()
242 .filter_map(|o| obj_to_f64(o.clone()))
243 .collect();
244 if vals.len() == 6 {
245 self.gs_stack
246 .concat_ctm(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]);
247 }
248 }
249 "gs" => {
250 if let Some(name) = op.operands.first().and_then(obj_name_bytes) {
252 self.apply_ext_gstate(doc, resources, &name);
253 }
254 }
255
256 "BT" => self.gs_stack.current.begin_text(),
258 "ET" => {}
259
260 "Tf"
261 if op.operands.len() == 2 => {
262 if let Object::Name(ref name) = op.operands[0] {
263 self.gs_stack.current.text_state.font_name =
264 String::from_utf8_lossy(name).to_string();
265 }
266 if let Some(size) = obj_to_f64(op.operands[1].clone()) {
267 self.gs_stack.current.text_state.font_size = size;
268 }
269 }
270 "Tc" => {
271 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
272 self.gs_stack.current.text_state.char_spacing = v;
273 }
274 }
275 "Tw" => {
276 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
277 self.gs_stack.current.text_state.word_spacing = v;
278 }
279 }
280 "Tz" => {
281 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
282 self.gs_stack.current.text_state.horizontal_scaling = v;
283 }
284 }
285 "TL" => {
286 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
287 self.gs_stack.current.text_state.leading = v;
288 }
289 }
290 "Ts" => {
291 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
292 self.gs_stack.current.text_state.rise = v;
293 }
294 }
295 "Tr" => {
296 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
297 self.gs_stack.current.text_state.render_mode = v as i32;
298 }
299 }
300
301 "Td"
303 if op.operands.len() == 2 => {
304 let tx = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
305 let ty = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
306 self.gs_stack.current.translate_text(tx, ty);
307 }
308 "TD"
309 if op.operands.len() == 2 => {
310 let tx = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
311 let ty = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
312 self.gs_stack.current.text_state.leading = -ty;
313 self.gs_stack.current.translate_text(tx, ty);
314 }
315 "Tm"
316 if op.operands.len() == 6 => {
317 let vals: Vec<f64> = op
318 .operands
319 .iter()
320 .filter_map(|o| obj_to_f64(o.clone()))
321 .collect();
322 if vals.len() == 6 {
323 self.gs_stack.current.set_text_matrix(
324 vals[0], vals[1], vals[2], vals[3], vals[4], vals[5],
325 );
326 }
327 }
328 "T*" => {
329 self.gs_stack.current.next_line();
330 }
331
332 "Tj" => {
334 if let Some(text_bytes) = op.operands.first().and_then(extract_string_bytes) {
335 self.emit_text_chunk(&text_bytes);
336 }
337 }
338 "TJ" => {
339 if let Some(Object::Array(ref arr)) = op.operands.first() {
340 self.process_tj_array(arr);
341 }
342 }
343 "'" => {
344 self.gs_stack.current.next_line();
345 if let Some(text_bytes) = op.operands.first().and_then(extract_string_bytes) {
346 self.emit_text_chunk(&text_bytes);
347 }
348 }
349 "\""
350 if op.operands.len() == 3 => {
351 if let Some(aw) = obj_to_f64(op.operands[0].clone()) {
352 self.gs_stack.current.text_state.word_spacing = aw;
353 }
354 if let Some(ac) = obj_to_f64(op.operands[1].clone()) {
355 self.gs_stack.current.text_state.char_spacing = ac;
356 }
357 self.gs_stack.current.next_line();
358 if let Some(text_bytes) = extract_string_bytes(&op.operands[2]) {
359 self.emit_text_chunk(&text_bytes);
360 }
361 }
362
363 "g" => {
365 if let Some(gray) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
366 self.gs_stack.current.fill_color = vec![gray];
367 self.gs_stack.current.fill_color_space_components = 1;
368 }
369 }
370 "G" => {
371 if let Some(gray) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
372 self.gs_stack.current.stroke_color = vec![gray];
373 self.gs_stack.current.stroke_color_space_components = 1;
374 }
375 }
376 "rg"
377 if op.operands.len() == 3 => {
378 let r = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
379 let g = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
380 let b = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
381 self.gs_stack.current.fill_color = vec![r, g, b];
382 self.gs_stack.current.fill_color_space_components = 3;
383 }
384 "RG"
385 if op.operands.len() == 3 => {
386 let r = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
387 let g = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
388 let b = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
389 self.gs_stack.current.stroke_color = vec![r, g, b];
390 self.gs_stack.current.stroke_color_space_components = 3;
391 }
392 "k"
393 if op.operands.len() == 4 => {
394 let c = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
395 let m = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
396 let y = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
397 let kk = obj_to_f64(op.operands[3].clone()).unwrap_or(0.0);
398 self.gs_stack.current.fill_color = vec![c, m, y, kk];
399 self.gs_stack.current.fill_color_space_components = 4;
400 }
401 "K"
402 if op.operands.len() == 4 => {
403 let c = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
404 let m = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
405 let y = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
406 let kk = obj_to_f64(op.operands[3].clone()).unwrap_or(0.0);
407 self.gs_stack.current.stroke_color = vec![c, m, y, kk];
408 self.gs_stack.current.stroke_color_space_components = 4;
409 }
410 "cs" => {
411 if let Some(name) = op.operands.first() {
412 let cs_name = obj_to_name(name);
413 let comps = color_space_components(&cs_name);
414 self.gs_stack.current.fill_color_space_components = comps;
415 self.gs_stack.current.fill_color = default_color_for_space(comps);
417 self.gs_stack.current.fill_pattern = None;
418 }
419 }
420 "CS" => {
421 if let Some(name) = op.operands.first() {
422 let cs_name = obj_to_name(name);
423 let comps = color_space_components(&cs_name);
424 self.gs_stack.current.stroke_color_space_components = comps;
425 self.gs_stack.current.stroke_color = default_color_for_space(comps);
427 self.gs_stack.current.stroke_pattern = None;
428 }
429 }
430 "sc" | "scn" => {
431 if let Some(name) = op.operands.first().and_then(obj_name_bytes) {
433 self.gs_stack.current.fill_pattern = Some(name);
434 } else {
435 let components: Vec<f64> = op
436 .operands
437 .iter()
438 .filter_map(|o| obj_to_f64(o.clone()))
439 .collect();
440 if !components.is_empty() {
441 self.gs_stack.current.fill_color = components;
442 self.gs_stack.current.fill_pattern = None;
443 }
444 }
445 }
446 "SC" | "SCN" => {
447 if let Some(name) = op.operands.first().and_then(obj_name_bytes) {
448 self.gs_stack.current.stroke_pattern = Some(name);
449 } else {
450 let components: Vec<f64> = op
451 .operands
452 .iter()
453 .filter_map(|o| obj_to_f64(o.clone()))
454 .collect();
455 if !components.is_empty() {
456 self.gs_stack.current.stroke_color = components;
457 self.gs_stack.current.stroke_pattern = None;
458 }
459 }
460 }
461
462 "w" => {
464 if let Some(w) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
465 self.line_width = w;
466 }
467 }
468
469 "m"
471 if op.operands.len() >= 2 => {
472 if let (Some(x), Some(y)) = (
473 op.operands.first().and_then(|o| obj_to_f64(o.clone())),
474 op.operands.get(1).and_then(|o| obj_to_f64(o.clone())),
475 ) {
476 let (tx, ty) = self.transform_point(x, y);
477 self.subpath_start = Some((tx, ty));
478 self.current_point = Some((tx, ty));
479 }
480 }
481 "l"
482 if op.operands.len() >= 2 => {
483 if let (Some(x), Some(y)) = (
484 op.operands.first().and_then(|o| obj_to_f64(o.clone())),
485 op.operands.get(1).and_then(|o| obj_to_f64(o.clone())),
486 ) {
487 let (tx, ty) = self.transform_point(x, y);
488 if let Some((cx, cy)) = self.current_point {
489 self.current_path.push(PathSegment::Line {
490 x1: cx,
491 y1: cy,
492 x2: tx,
493 y2: ty,
494 });
495 }
496 self.current_point = Some((tx, ty));
497 }
498 }
499 "c"
500 if op.operands.len() >= 6 => {
501 let vals: Vec<f64> = op
502 .operands
503 .iter()
504 .filter_map(|o| obj_to_f64(o.clone()))
505 .collect();
506 if vals.len() >= 6 {
507 let (tx, ty) = self.transform_point(vals[4], vals[5]);
508 if let Some((cx, cy)) = self.current_point {
509 let (cp1x, cp1y) = self.transform_point(vals[0], vals[1]);
510 let (cp2x, cp2y) = self.transform_point(vals[2], vals[3]);
511 self.current_path.push(PathSegment::Curve {
512 x1: cx,
513 y1: cy,
514 cp1x,
515 cp1y,
516 cp2x,
517 cp2y,
518 x2: tx,
519 y2: ty,
520 });
521 }
522 self.current_point = Some((tx, ty));
523 }
524 }
525 "v"
526 if op.operands.len() >= 4 => {
527 let vals: Vec<f64> = op
528 .operands
529 .iter()
530 .filter_map(|o| obj_to_f64(o.clone()))
531 .collect();
532 if vals.len() >= 4 {
533 let (tx, ty) = self.transform_point(vals[2], vals[3]);
534 if let Some((cx, cy)) = self.current_point {
535 let (cp2x, cp2y) = self.transform_point(vals[0], vals[1]);
536 self.current_path.push(PathSegment::Curve {
537 x1: cx,
538 y1: cy,
539 cp1x: cx,
540 cp1y: cy,
541 cp2x,
542 cp2y,
543 x2: tx,
544 y2: ty,
545 });
546 }
547 self.current_point = Some((tx, ty));
548 }
549 }
550 "y"
551 if op.operands.len() >= 4 => {
552 let vals: Vec<f64> = op
553 .operands
554 .iter()
555 .filter_map(|o| obj_to_f64(o.clone()))
556 .collect();
557 if vals.len() >= 4 {
558 let (tx, ty) = self.transform_point(vals[2], vals[3]);
559 if let Some((cx, cy)) = self.current_point {
560 let (cp1x, cp1y) = self.transform_point(vals[0], vals[1]);
561 self.current_path.push(PathSegment::Curve {
562 x1: cx,
563 y1: cy,
564 cp1x,
565 cp1y,
566 cp2x: tx,
567 cp2y: ty,
568 x2: tx,
569 y2: ty,
570 });
571 }
572 self.current_point = Some((tx, ty));
573 }
574 }
575 "h" => {
576 if let (Some((sx, sy)), Some((cx, cy))) =
577 (self.subpath_start, self.current_point)
578 {
579 if (sx - cx).abs() > 0.01 || (sy - cy).abs() > 0.01 {
580 self.current_path.push(PathSegment::Line {
581 x1: cx,
582 y1: cy,
583 x2: sx,
584 y2: sy,
585 });
586 }
587 self.current_point = self.subpath_start;
588 }
589 }
590 "re"
591 if op.operands.len() >= 4 => {
592 let vals: Vec<f64> = op
593 .operands
594 .iter()
595 .filter_map(|o| obj_to_f64(o.clone()))
596 .collect();
597 if vals.len() >= 4 {
598 let (x, y, w, h) = (vals[0], vals[1], vals[2], vals[3]);
599 let (x1, y1) = self.transform_point(x, y);
600 let (x2, y2) = self.transform_point(x + w, y);
601 let (x3, y3) = self.transform_point(x + w, y + h);
602 let (x4, y4) = self.transform_point(x, y + h);
603 self.current_path.push(PathSegment::Line { x1, y1, x2, y2 });
604 self.current_path.push(PathSegment::Line {
605 x1: x2,
606 y1: y2,
607 x2: x3,
608 y2: y3,
609 });
610 self.current_path.push(PathSegment::Line {
611 x1: x3,
612 y1: y3,
613 x2: x4,
614 y2: y4,
615 });
616 self.current_path.push(PathSegment::Line {
617 x1: x4,
618 y1: y4,
619 x2: x1,
620 y2: y1,
621 });
622 self.subpath_start = Some((x1, y1));
623 self.current_point = Some((x1, y1));
624 }
625 }
626
627 "S" => {
629 self.classify_and_emit_path(PathPaint::Stroke);
630 }
631 "s" => {
632 self.close_subpath();
634 self.classify_and_emit_path(PathPaint::Stroke);
635 }
636 "f" | "F" | "f*" => {
637 if self.try_emit_pattern_fill_image(doc, resources) {
638 } else {
641 self.classify_and_emit_path(PathPaint::Fill);
642 }
643 }
644 "B" | "B*" | "b" | "b*" => {
645 if op.operator.starts_with('b') {
646 self.close_subpath();
647 }
648 if self.try_emit_pattern_fill_image(doc, resources) {
649 } else {
651 self.classify_and_emit_path(PathPaint::Stroke);
653 }
654 }
655 "n" => {
656 self.current_path.clear();
658 self.subpath_start = None;
659 self.current_point = None;
660 }
661
662 "Do" => {
664 if let Some(name_bytes) = op.operands.first().and_then(obj_name_bytes) {
665 self.handle_do_operator(doc, resources, &name_bytes, recursion_depth);
666 }
667 }
668
669 "BI" => {
674 self.emit_inline_image();
675 }
676
677 _ => {
678 }
680 }
681 }
682 }
683
684 fn emit_text_chunk(&mut self, text_bytes: &[u8]) {
687 if text_bytes.is_empty() {
688 return;
689 }
690
691 let font = self
692 .font_cache
693 .get(&self.gs_stack.current.text_state.font_name)
694 .cloned()
695 .unwrap_or_else(|| PdfFont::default_font(&self.gs_stack.current.text_state.font_name));
696 let active_mcid = self.active_mcid();
697
698 if let Some(actual) = self.active_actual_text() {
700 if self.mark_actual_text_emitted() {
701 if let Some(chunk) = create_text_chunk_with_override(
702 text_bytes,
703 Some(&actual),
704 &font,
705 &mut self.gs_stack,
706 self.page_number,
707 &mut self.text_index,
708 active_mcid,
709 ) {
710 let mut chunk = chunk;
711 chunk.ocg_visible = self.current_ocg_visible();
712 self.text_chunks.push(chunk);
713 }
714 } else {
715 advance_text_matrix(text_bytes, &font, &mut self.gs_stack);
717 }
718 return;
719 }
720
721 if let Some(chunk) = create_text_chunk(
722 text_bytes,
723 &font,
724 &mut self.gs_stack,
725 self.page_number,
726 &mut self.text_index,
727 active_mcid,
728 ) {
729 let mut chunk = chunk;
730 chunk.ocg_visible = self.current_ocg_visible();
731 self.text_chunks.push(chunk);
732 }
733 }
734
735 fn process_tj_array(&mut self, arr: &[Object]) {
736 let font = self
737 .font_cache
738 .get(&self.gs_stack.current.text_state.font_name)
739 .cloned()
740 .unwrap_or_else(|| PdfFont::default_font(&self.gs_stack.current.text_state.font_name));
741 let active_mcid = self.active_mcid();
742 let actual = self.active_actual_text();
743 let mut emitted_actual = false;
744
745 for item in arr {
746 match item {
747 Object::String(bytes, _) => {
748 if let Some(ref actual_text) = actual {
749 if !emitted_actual && self.mark_actual_text_emitted() {
750 emitted_actual = true;
751 if let Some(chunk) = create_text_chunk_with_override(
752 bytes,
753 Some(actual_text),
754 &font,
755 &mut self.gs_stack,
756 self.page_number,
757 &mut self.text_index,
758 active_mcid,
759 ) {
760 let mut chunk = chunk;
761 chunk.ocg_visible = self.current_ocg_visible();
762 self.text_chunks.push(chunk);
763 }
764 } else {
765 advance_text_matrix(bytes, &font, &mut self.gs_stack);
766 }
767 } else if let Some(chunk) = create_text_chunk(
768 bytes,
769 &font,
770 &mut self.gs_stack,
771 self.page_number,
772 &mut self.text_index,
773 active_mcid,
774 ) {
775 let mut chunk = chunk;
776 chunk.ocg_visible = self.current_ocg_visible();
777 self.text_chunks.push(chunk);
778 }
779 }
780 _ => {
781 if let Some(adj) = obj_to_f64(item.clone()) {
782 let font_size = self.gs_stack.current.text_state.font_size.max(1.0);
786 if adj <= SpaceThreshold::TJ_WORD_GAP_THOUSANDTHS {
787 if let Some(chunk) = create_synthetic_space_chunk(
790 &mut self.gs_stack,
791 self.page_number,
792 &mut self.text_index,
793 active_mcid,
794 font_size,
795 ) {
796 let mut chunk = chunk;
797 chunk.ocg_visible = self.current_ocg_visible();
798 self.text_chunks.push(chunk);
799 }
800 }
801 let displacement = -adj / 1000.0 * font_size;
802 self.gs_stack.current.advance_text(displacement);
803 }
804 }
805 }
806 }
807 }
808
809 fn handle_do_operator(
813 &mut self,
814 doc: &Document,
815 resources: &Dictionary,
816 name_bytes: &[u8],
817 recursion_depth: u32,
818 ) {
819 let xobject_dict = match resources.get(b"XObject") {
821 Ok(obj) => {
822 let resolved = resolve_obj(doc, obj);
823 match resolved.as_dict() {
824 Ok(d) => d.clone(),
825 Err(_) => return,
826 }
827 }
828 Err(_) => return,
829 };
830
831 let xobj_ref = match xobject_dict.get(name_bytes) {
832 Ok(obj) => resolve_obj(doc, obj),
833 Err(_) => return,
834 };
835
836 let stream = match xobj_ref.as_stream() {
837 Ok(s) => s.clone(),
838 Err(_) => return,
839 };
840
841 let subtype = stream
842 .dict
843 .get(b"Subtype")
844 .ok()
845 .and_then(|o| match resolve_obj(doc, o) {
846 Object::Name(n) => Some(String::from_utf8_lossy(&n).to_string()),
847 _ => None,
848 });
849
850 match subtype.as_deref() {
851 Some("Image") => {
852 self.emit_image_from_ctm();
854 }
855 Some("Form")
856 if recursion_depth < MAX_FORM_RECURSION_DEPTH => {
858 self.process_form_xobject(doc, &stream, resources, recursion_depth);
859 }
860 _ => {}
861 }
862 }
863
864 fn emit_image_from_ctm(&mut self) {
867 let ctm = &self.gs_stack.current.ctm;
868
869 let (x0, y0) = ctm.transform_point(0.0, 0.0);
871 let (x1, y1) = ctm.transform_point(1.0, 0.0);
872 let (x2, y2) = ctm.transform_point(1.0, 1.0);
873 let (x3, y3) = ctm.transform_point(0.0, 1.0);
874
875 let min_x = x0.min(x1).min(x2).min(x3);
876 let max_x = x0.max(x1).max(x2).max(x3);
877 let min_y = y0.min(y1).min(y2).min(y3);
878 let max_y = y0.max(y1).max(y2).max(y3);
879
880 self.emit_image_bbox(min_x, min_y, max_x, max_y, None, ImagePaintSource::Do);
881 }
882
883 fn emit_image_bbox(
885 &mut self,
886 min_x: f64,
887 min_y: f64,
888 max_x: f64,
889 max_y: f64,
890 forced_index: Option<u32>,
891 source: ImagePaintSource,
892 ) {
893 if (max_x - min_x).abs() < 0.1 || (max_y - min_y).abs() < 0.1 {
895 return;
896 }
897
898 let index = if let Some(idx) = forced_index {
899 self.image_index = self.image_index.max(idx);
900 idx
901 } else {
902 self.image_index += 1;
903 self.image_index
904 };
905
906 self.image_chunks.push(ImageChunk {
907 bbox: BoundingBox::new(Some(self.page_number), min_x, min_y, max_x, max_y),
908 index: Some(index),
909 level: None,
910 source,
911 });
912 }
913
914 fn try_emit_pattern_fill_image(&mut self, doc: &Document, resources: &Dictionary) -> bool {
918 let Some(pattern_name) = self.gs_stack.current.fill_pattern.clone() else {
919 return false;
920 };
921
922 let path = std::mem::take(&mut self.current_path);
923 self.subpath_start = None;
924 self.current_point = None;
925 if path.is_empty() {
926 return true;
927 }
928
929 let (min_x, min_y, max_x, max_y) = path_bbox(&path);
930 if (max_x - min_x).abs() < 0.1 || (max_y - min_y).abs() < 0.1 {
931 return true;
932 }
933
934 let Some(index) =
935 super::image_extractor::find_pattern_image_index(doc, resources, &pattern_name)
936 else {
937 return true;
940 };
941
942 self.emit_image_bbox(
943 min_x,
944 min_y,
945 max_x,
946 max_y,
947 Some(index),
948 ImagePaintSource::PatternFill,
949 );
950 true
951 }
952
953 fn emit_inline_image(&mut self) {
955 let ctm = &self.gs_stack.current.ctm;
956 let (x0, y0) = ctm.transform_point(0.0, 0.0);
957 let (x1, y1) = ctm.transform_point(1.0, 0.0);
958 let (x2, y2) = ctm.transform_point(1.0, 1.0);
959 let (x3, y3) = ctm.transform_point(0.0, 1.0);
960 let min_x = x0.min(x1).min(x2).min(x3);
961 let max_x = x0.max(x1).max(x2).max(x3);
962 let min_y = y0.min(y1).min(y2).min(y3);
963 let max_y = y0.max(y1).max(y2).max(y3);
964 self.emit_image_bbox(min_x, min_y, max_x, max_y, None, ImagePaintSource::Inline);
965 }
966
967 fn process_form_xobject(
969 &mut self,
970 doc: &Document,
971 stream: &lopdf::Stream,
972 parent_resources: &Dictionary,
973 recursion_depth: u32,
974 ) {
975 let form_matrix = get_form_matrix(doc, &stream.dict);
977
978 self.gs_stack.save();
980 let m = form_matrix;
981 self.gs_stack.concat_ctm(m.a, m.b, m.c, m.d, m.e, m.f);
982
983 let form_resources = match stream.dict.get(b"Resources") {
985 Ok(obj) => {
986 let resolved = resolve_obj(doc, obj);
987 resolved
988 .as_dict()
989 .cloned()
990 .unwrap_or_else(|_| parent_resources.clone())
991 }
992 Err(_) => parent_resources.clone(),
993 };
994
995 let form_content = if stream.dict.get(b"Filter").is_ok() {
997 match stream.decompressed_content() {
998 Ok(data) => data,
999 Err(_) => {
1000 self.gs_stack.restore();
1001 return;
1002 }
1003 }
1004 } else {
1005 stream.content.clone()
1006 };
1007
1008 if form_content.is_empty() {
1009 self.gs_stack.restore();
1010 return;
1011 }
1012
1013 if let Ok(content) = Content::decode(&form_content) {
1015 let form_font_cache = resolve_form_fonts(doc, &form_resources);
1017 let mut merged_cache = FontCache::default();
1018 for (name, font) in self.font_cache.iter() {
1020 merged_cache.insert(name.clone(), font.clone());
1021 }
1022 for (name, font) in form_font_cache.iter() {
1024 merged_cache.insert(name.clone(), font.clone());
1025 }
1026
1027 let saved_fc = std::mem::replace(&mut self.font_cache, merged_cache);
1028 self.process_operations(
1029 doc,
1030 &content.operations,
1031 &form_resources,
1032 recursion_depth + 1,
1033 );
1034 self.font_cache = saved_fc;
1035 }
1036
1037 self.gs_stack.restore();
1038 }
1039
1040 fn apply_ext_gstate(&mut self, doc: &Document, resources: &Dictionary, name: &[u8]) {
1043 let ext_gstate_dict = match resources.get(b"ExtGState") {
1044 Ok(obj) => {
1045 let resolved = resolve_obj(doc, obj);
1046 match resolved.as_dict() {
1047 Ok(d) => d.clone(),
1048 Err(_) => return,
1049 }
1050 }
1051 Err(_) => return,
1052 };
1053
1054 let gs_obj = match ext_gstate_dict.get(name) {
1055 Ok(obj) => resolve_obj(doc, obj),
1056 Err(_) => return,
1057 };
1058
1059 let gs_dict = match gs_obj.as_dict() {
1060 Ok(d) => d,
1061 Err(_) => return,
1062 };
1063
1064 if let Ok(font_arr) = gs_dict.get(b"Font") {
1067 if let Ok(arr) = resolve_obj(doc, font_arr).as_array() {
1068 if arr.len() >= 2 {
1069 if let Object::Name(ref name) = arr[0] {
1070 self.gs_stack.current.text_state.font_name =
1071 String::from_utf8_lossy(name).to_string();
1072 }
1073 if let Some(size) = obj_to_f64(arr[1].clone()) {
1074 self.gs_stack.current.text_state.font_size = size;
1075 }
1076 }
1077 }
1078 }
1079
1080 if let Ok(lw) = gs_dict.get(b"LW") {
1082 if let Some(w) = obj_to_f64(resolve_obj(doc, lw)) {
1083 self.line_width = w;
1084 }
1085 }
1086 }
1087
1088 fn close_subpath(&mut self) {
1091 if let (Some((sx, sy)), Some((cx, cy))) = (self.subpath_start, self.current_point) {
1092 if (sx - cx).abs() > 0.01 || (sy - cy).abs() > 0.01 {
1093 self.current_path.push(PathSegment::Line {
1094 x1: cx,
1095 y1: cy,
1096 x2: sx,
1097 y2: sy,
1098 });
1099 }
1100 self.current_point = self.subpath_start;
1101 }
1102 }
1103
1104 fn classify_and_emit_path(&mut self, paint: PathPaint) {
1105 let path = std::mem::take(&mut self.current_path);
1106 self.subpath_start = None;
1107 self.current_point = None;
1108
1109 if path.is_empty() || self.line_width < MIN_LINE_WIDTH {
1110 return;
1111 }
1112
1113 if matches!(paint, PathPaint::Fill) {
1118 if let Some(mut line) = thin_filled_strip_line(&path, self.line_width, self.page_number)
1119 {
1120 self.line_index += 1;
1121 line.index = Some(self.line_index);
1122 self.line_chunks.push(line);
1123 }
1124 return;
1125 }
1126
1127 let has_curves = path.iter().any(|s| matches!(s, PathSegment::Curve { .. }));
1128
1129 if !has_curves && path.len() <= 4 {
1131 let mut classified_lines = Vec::new();
1132 for seg in &path {
1133 if let PathSegment::Line { x1, y1, x2, y2 } = seg {
1134 let dx = (x2 - x1).abs();
1135 let dy = (y2 - y1).abs();
1136 let length = (dx * dx + dy * dy).sqrt();
1137
1138 if length < MIN_LINE_WIDTH {
1139 continue;
1140 }
1141
1142 let is_horizontal = dy < MAX_LINE_THICKNESS && dx > dy * LINE_ASPECT_RATIO;
1143 let is_vertical = dx < MAX_LINE_THICKNESS && dy > dx * LINE_ASPECT_RATIO;
1144
1145 if is_horizontal || is_vertical {
1146 self.line_index += 1;
1147 let min_x = x1.min(*x2);
1148 let max_x = x1.max(*x2);
1149 let min_y = y1.min(*y2);
1150 let max_y = y1.max(*y2);
1151 let half_w = self.line_width / 2.0;
1152
1153 classified_lines.push(LineChunk {
1154 bbox: BoundingBox::new(
1155 Some(self.page_number),
1156 min_x - if is_vertical { half_w } else { 0.0 },
1157 min_y - if is_horizontal { half_w } else { 0.0 },
1158 max_x + if is_vertical { half_w } else { 0.0 },
1159 max_y + if is_horizontal { half_w } else { 0.0 },
1160 ),
1161 index: Some(self.line_index),
1162 level: None,
1163 start: Vertex {
1164 x: *x1,
1165 y: *y1,
1166 radius: 0.0,
1167 },
1168 end: Vertex {
1169 x: *x2,
1170 y: *y2,
1171 radius: 0.0,
1172 },
1173 width: self.line_width,
1174 is_horizontal_line: is_horizontal,
1175 is_vertical_line: is_vertical,
1176 is_square: false,
1177 });
1178 }
1179 }
1180 }
1181 if !classified_lines.is_empty() {
1182 self.line_chunks.extend(classified_lines);
1183 return;
1184 }
1185 }
1186
1187 if !has_curves && path.len() == 4 {
1189 if let Some(rect) = try_classify_rectangle(&path, self.line_width, self.page_number) {
1190 self.line_index += 1;
1191 let mut rect = rect;
1192 rect.index = Some(self.line_index);
1193 self.line_chunks.push(rect);
1194 return;
1195 }
1196 }
1197
1198 if path.len() >= 2 {
1200 let mut art_lines = Vec::new();
1201 let mut min_x = f64::MAX;
1202 let mut min_y = f64::MAX;
1203 let mut max_x = f64::MIN;
1204 let mut max_y = f64::MIN;
1205
1206 for seg in &path {
1207 let (sx, sy, ex, ey) = match seg {
1208 PathSegment::Line { x1, y1, x2, y2 } => (*x1, *y1, *x2, *y2),
1209 PathSegment::Curve { x1, y1, x2, y2, .. } => (*x1, *y1, *x2, *y2),
1210 };
1211 min_x = min_x.min(sx).min(ex);
1212 min_y = min_y.min(sy).min(ey);
1213 max_x = max_x.max(sx).max(ex);
1214 max_y = max_y.max(sy).max(ey);
1215
1216 self.line_index += 1;
1217 art_lines.push(LineChunk {
1218 bbox: BoundingBox::new(
1219 Some(self.page_number),
1220 sx.min(ex),
1221 sy.min(ey),
1222 sx.max(ex),
1223 sy.max(ey),
1224 ),
1225 index: Some(self.line_index),
1226 level: None,
1227 start: Vertex {
1228 x: sx,
1229 y: sy,
1230 radius: 0.0,
1231 },
1232 end: Vertex {
1233 x: ex,
1234 y: ey,
1235 radius: 0.0,
1236 },
1237 width: self.line_width,
1238 is_horizontal_line: false,
1239 is_vertical_line: false,
1240 is_square: false,
1241 });
1242 }
1243
1244 self.line_index += 1;
1245 self.line_art_chunks.push(LineArtChunk {
1246 bbox: BoundingBox::new(Some(self.page_number), min_x, min_y, max_x, max_y),
1247 index: Some(self.line_index),
1248 level: None,
1249 line_chunks: art_lines,
1250 });
1251 }
1252 }
1253
1254 fn transform_point(&self, x: f64, y: f64) -> (f64, f64) {
1257 self.gs_stack.current.ctm.transform_point(x, y)
1258 }
1259
1260 fn active_mcid(&self) -> Option<i64> {
1261 self.mcid_stack.iter().rev().find_map(|&mcid| mcid)
1262 }
1263
1264 fn active_actual_text(&self) -> Option<String> {
1266 self.actual_text_stack
1267 .iter()
1268 .rev()
1269 .find_map(|t| t.clone())
1270 }
1271
1272 fn mark_actual_text_emitted(&mut self) -> bool {
1275 if let Some(flag) = self.actual_text_emitted.last_mut() {
1276 if !*flag {
1277 *flag = true;
1278 return true;
1279 }
1280 return false;
1281 }
1282 true
1283 }
1284
1285 fn current_ocg_visible(&self) -> bool {
1287 self.ocg_visible_stack.last().copied().unwrap_or(true)
1288 }
1289}
1290
1291struct SpaceThreshold;
1297impl SpaceThreshold {
1298 const TJ_WORD_GAP_THOUSANDTHS: f64 = -200.0;
1301}
1302
1303fn create_text_chunk(
1305 text_bytes: &[u8],
1306 font: &PdfFont,
1307 state: &mut GraphicsStateStack,
1308 page_number: u32,
1309 chunk_index: &mut usize,
1310 mcid: Option<i64>,
1311) -> Option<TextChunk> {
1312 create_text_chunk_with_override(
1313 text_bytes,
1314 None,
1315 font,
1316 state,
1317 page_number,
1318 chunk_index,
1319 mcid,
1320 )
1321}
1322
1323fn create_text_chunk_with_override(
1326 text_bytes: &[u8],
1327 unicode_override: Option<&str>,
1328 font: &PdfFont,
1329 state: &mut GraphicsStateStack,
1330 page_number: u32,
1331 chunk_index: &mut usize,
1332 mcid: Option<i64>,
1333) -> Option<TextChunk> {
1334 if text_bytes.is_empty() && unicode_override.is_none_or(|s| s.is_empty()) {
1335 return None;
1336 }
1337
1338 let trm = state.current.text_rendering_matrix();
1339 let start_x = trm.e;
1340 let font_size = trm.font_size_factor();
1341
1342 if font_size < 0.1 {
1343 return None;
1344 }
1345
1346 let mut text = String::new();
1347 let mut total_width = 0.0;
1348 let mut symbol_ends = Vec::new();
1349
1350 let bpc = font.bytes_per_code as usize;
1351 let mut pos = 0;
1352 while pos + bpc <= text_bytes.len() {
1353 let char_code = if bpc == 2 {
1354 ((text_bytes[pos] as u32) << 8) | (text_bytes[pos + 1] as u32)
1355 } else {
1356 text_bytes[pos] as u32
1357 };
1358 pos += bpc;
1359
1360 let decoded = font.decode_char(char_code);
1361 if unicode_override.is_none() {
1362 text.push_str(&decoded);
1363 }
1364
1365 let glyph_w = font.glyph_width(char_code) / 1000.0;
1366 total_width += glyph_w;
1367 symbol_ends.push(start_x + total_width * font_size);
1368
1369 total_width += state.current.text_state.char_spacing / state.current.text_state.font_size;
1370
1371 if decoded == " " {
1372 total_width +=
1373 state.current.text_state.word_spacing / state.current.text_state.font_size;
1374 }
1375 }
1376
1377 if let Some(over) = unicode_override {
1378 text = over.to_string();
1379 if symbol_ends.is_empty() {
1380 let est = over.chars().count() as f64 * 0.5;
1382 total_width = est;
1383 symbol_ends.push(start_x + est * font_size);
1384 }
1385 }
1386
1387 let displacement = total_width * state.current.text_state.font_size;
1388 state.current.advance_text(displacement);
1389
1390 if text.is_empty() {
1391 return None;
1392 }
1393
1394 let trm_after = state.current.text_rendering_matrix();
1395 let ascent = font.ascent;
1396 let descent = font.descent;
1397 let trm_before = &trm;
1398
1399 let (x1, x2) = if trm_before.a >= 0.0 && trm_before.c >= 0.0 {
1400 (
1401 trm_before.e + descent * trm_before.c / 1000.0,
1402 trm_after.e + ascent * trm_after.c / 1000.0,
1403 )
1404 } else if trm_before.a < 0.0 && trm_before.c < 0.0 {
1405 (
1406 trm_after.e + ascent * trm_after.c / 1000.0,
1407 trm_before.e + descent * trm_before.c / 1000.0,
1408 )
1409 } else if trm_before.a >= 0.0 {
1410 (
1411 trm_before.e + ascent * trm_before.c / 1000.0,
1412 trm_after.e + descent * trm_after.c / 1000.0,
1413 )
1414 } else {
1415 (
1416 trm_after.e + descent * trm_after.c / 1000.0,
1417 trm_before.e + ascent * trm_before.c / 1000.0,
1418 )
1419 };
1420
1421 let (y1, y2) = if trm_before.d >= 0.0 && trm_before.b >= 0.0 {
1422 (
1423 trm_before.f + descent * trm_before.d / 1000.0,
1424 trm_after.f + ascent * trm_after.d / 1000.0,
1425 )
1426 } else if trm_before.d < 0.0 && trm_before.b < 0.0 {
1427 (
1428 trm_after.f + ascent * trm_after.d / 1000.0,
1429 trm_before.f + descent * trm_before.d / 1000.0,
1430 )
1431 } else if trm_before.d >= 0.0 {
1432 (
1433 trm_after.f + descent * trm_after.d / 1000.0,
1434 trm_before.f + ascent * trm_before.d / 1000.0,
1435 )
1436 } else {
1437 (
1438 trm_before.f + ascent * trm_before.d / 1000.0,
1439 trm_after.f + descent * trm_after.d / 1000.0,
1440 )
1441 };
1442
1443 let bbox = BoundingBox::new(Some(page_number), x1, y1, x2, y2);
1444
1445 let text_format = if state.current.text_state.rise > font_size * 0.1 {
1446 crate::models::enums::TextFormat::Superscript
1447 } else if state.current.text_state.rise < -font_size * 0.1 {
1448 crate::models::enums::TextFormat::Subscript
1449 } else {
1450 crate::models::enums::TextFormat::Normal
1451 };
1452
1453 *chunk_index += 1;
1454
1455 let fc = &state.current.fill_color;
1456 let font_color = format!(
1457 "[{}]",
1458 fc.iter()
1459 .map(|v| {
1460 let f32_val = *v as f32;
1461 let f64_repr = f32_val as f64;
1462 if f32_val.fract() == 0.0 {
1463 format!("{:.1}", f64_repr)
1464 } else {
1465 format!("{}", f64_repr)
1466 }
1467 })
1468 .collect::<Vec<_>>()
1469 .join(", ")
1470 );
1471
1472 Some(TextChunk {
1473 value: text,
1474 bbox,
1475 font_name: font.base_font.clone(),
1476 font_size,
1477 font_weight: font.weight,
1478 italic_angle: font.italic_angle,
1479 font_color,
1480 contrast_ratio: 21.0,
1481 symbol_ends,
1482 text_format,
1483 text_type: crate::models::enums::TextType::Regular,
1484 pdf_layer: crate::models::enums::PdfLayer::Main,
1485 ocg_visible: true,
1486 index: Some(*chunk_index),
1487 page_number: Some(page_number),
1488 level: None,
1489 mcid,
1490 })
1491}
1492
1493fn advance_text_matrix(text_bytes: &[u8], font: &PdfFont, state: &mut GraphicsStateStack) {
1495 let font_size = state.current.text_state.font_size.max(0.1);
1496 let bpc = font.bytes_per_code as usize;
1497 let mut total_width = 0.0;
1498 let mut pos = 0;
1499 while pos + bpc <= text_bytes.len() {
1500 let char_code = if bpc == 2 {
1501 ((text_bytes[pos] as u32) << 8) | (text_bytes[pos + 1] as u32)
1502 } else {
1503 text_bytes[pos] as u32
1504 };
1505 pos += bpc;
1506 let decoded = font.decode_char(char_code);
1507 total_width += font.glyph_width(char_code) / 1000.0;
1508 total_width += state.current.text_state.char_spacing / font_size;
1509 if decoded == " " {
1510 total_width += state.current.text_state.word_spacing / font_size;
1511 }
1512 }
1513 state.current.advance_text(total_width * font_size);
1514}
1515
1516fn create_synthetic_space_chunk(
1518 state: &mut GraphicsStateStack,
1519 page_number: u32,
1520 chunk_index: &mut usize,
1521 mcid: Option<i64>,
1522 font_size: f64,
1523) -> Option<TextChunk> {
1524 let trm = state.current.text_rendering_matrix();
1525 let x = trm.e;
1526 let y = trm.f;
1527 *chunk_index += 1;
1528 Some(TextChunk {
1529 value: " ".to_string(),
1530 bbox: BoundingBox::new(Some(page_number), x, y, x + font_size * 0.25, y + font_size),
1531 font_name: String::new(),
1532 font_size,
1533 font_weight: 400.0,
1534 italic_angle: 0.0,
1535 font_color: "[0.0]".to_string(),
1536 contrast_ratio: 21.0,
1537 symbol_ends: vec![x + font_size * 0.25],
1538 text_format: crate::models::enums::TextFormat::Normal,
1539 text_type: crate::models::enums::TextType::Regular,
1540 pdf_layer: crate::models::enums::PdfLayer::Main,
1541 ocg_visible: true,
1542 index: Some(*chunk_index),
1543 page_number: Some(page_number),
1544 level: None,
1545 mcid,
1546 })
1547}
1548
1549#[derive(Debug, Clone)]
1551enum PathSegment {
1552 Line {
1553 x1: f64,
1554 y1: f64,
1555 x2: f64,
1556 y2: f64,
1557 },
1558 #[allow(dead_code)]
1559 Curve {
1560 x1: f64,
1561 y1: f64,
1562 cp1x: f64,
1563 cp1y: f64,
1564 cp2x: f64,
1565 cp2y: f64,
1566 x2: f64,
1567 y2: f64,
1568 },
1569}
1570
1571fn try_classify_rectangle(
1573 segments: &[PathSegment],
1574 _line_width: f64,
1575 page_number: u32,
1576) -> Option<LineChunk> {
1577 let mut min_x = f64::MAX;
1578 let mut min_y = f64::MAX;
1579 let mut max_x = f64::MIN;
1580 let mut max_y = f64::MIN;
1581
1582 for seg in segments {
1583 if let PathSegment::Line { x1, y1, x2, y2 } = seg {
1584 min_x = min_x.min(*x1).min(*x2);
1585 min_y = min_y.min(*y1).min(*y2);
1586 max_x = max_x.max(*x1).max(*x2);
1587 max_y = max_y.max(*y1).max(*y2);
1588 } else {
1589 return None;
1590 }
1591 }
1592
1593 let w = max_x - min_x;
1594 let h = max_y - min_y;
1595
1596 if w < MIN_LINE_WIDTH || h < MIN_LINE_WIDTH {
1597 return None;
1598 }
1599
1600 let is_horizontal = w > h * LINE_ASPECT_RATIO;
1601 let is_vertical = h > w * LINE_ASPECT_RATIO;
1602 if !is_horizontal && !is_vertical {
1604 return None;
1605 }
1606
1607 Some(LineChunk {
1608 bbox: BoundingBox::new(Some(page_number), min_x, min_y, max_x, max_y),
1609 index: None,
1610 level: None,
1611 start: Vertex {
1612 x: min_x,
1613 y: min_y,
1614 radius: 0.0,
1615 },
1616 end: Vertex {
1617 x: max_x,
1618 y: max_y,
1619 radius: 0.0,
1620 },
1621 width: w.min(h),
1622 is_horizontal_line: is_horizontal,
1623 is_vertical_line: is_vertical,
1624 is_square: false,
1625 })
1626}
1627
1628fn thin_filled_strip_line(
1631 segments: &[PathSegment],
1632 _line_width: f64,
1633 page_number: u32,
1634) -> Option<LineChunk> {
1635 let mut min_x = f64::MAX;
1636 let mut min_y = f64::MAX;
1637 let mut max_x = f64::MIN;
1638 let mut max_y = f64::MIN;
1639
1640 for seg in segments {
1641 let (sx, sy, ex, ey) = match seg {
1642 PathSegment::Line { x1, y1, x2, y2 } => (*x1, *y1, *x2, *y2),
1643 PathSegment::Curve { x1, y1, x2, y2, .. } => (*x1, *y1, *x2, *y2),
1644 };
1645 min_x = min_x.min(sx).min(ex);
1646 min_y = min_y.min(sy).min(ey);
1647 max_x = max_x.max(sx).max(ex);
1648 max_y = max_y.max(sy).max(ey);
1649 }
1650
1651 if !min_x.is_finite() {
1652 return None;
1653 }
1654
1655 let w = max_x - min_x;
1656 let h = max_y - min_y;
1657 if w < MIN_LINE_WIDTH || h < MIN_LINE_WIDTH {
1658 return None;
1659 }
1660
1661 let is_horizontal = h <= MAX_LINE_THICKNESS && w > h * LINE_ASPECT_RATIO;
1662 let is_vertical = w <= MAX_LINE_THICKNESS && h > w * LINE_ASPECT_RATIO;
1663 if !is_horizontal && !is_vertical {
1664 return None;
1665 }
1666
1667 let (sx, sy, ex, ey) = if is_horizontal {
1668 (min_x, (min_y + max_y) * 0.5, max_x, (min_y + max_y) * 0.5)
1669 } else {
1670 ((min_x + max_x) * 0.5, min_y, (min_x + max_x) * 0.5, max_y)
1671 };
1672
1673 Some(LineChunk {
1674 bbox: BoundingBox::new(Some(page_number), min_x, min_y, max_x, max_y),
1675 index: None,
1676 level: None,
1677 start: Vertex {
1678 x: sx,
1679 y: sy,
1680 radius: 0.0,
1681 },
1682 end: Vertex {
1683 x: ex,
1684 y: ey,
1685 radius: 0.0,
1686 },
1687 width: w.min(h),
1688 is_horizontal_line: is_horizontal,
1689 is_vertical_line: is_vertical,
1690 is_square: false,
1691 })
1692}
1693
1694fn get_form_matrix(doc: &Document, dict: &Dictionary) -> Matrix {
1696 match dict.get(b"Matrix") {
1697 Ok(obj) => {
1698 let resolved = resolve_obj(doc, obj);
1699 if let Ok(arr) = resolved.as_array() {
1700 let vals: Vec<f64> = arr.iter().filter_map(|o| obj_to_f64(o.clone())).collect();
1701 if vals.len() == 6 {
1702 return Matrix {
1703 a: vals[0],
1704 b: vals[1],
1705 c: vals[2],
1706 d: vals[3],
1707 e: vals[4],
1708 f: vals[5],
1709 };
1710 }
1711 }
1712 Matrix::identity()
1713 }
1714 Err(_) => Matrix::identity(),
1715 }
1716}
1717
1718fn resolve_form_fonts(doc: &Document, resources: &Dictionary) -> FontCache {
1720 let font_dict = match resources.get(b"Font") {
1721 Ok(obj) => {
1722 let resolved = resolve_obj(doc, obj);
1723 match resolved.as_dict() {
1724 Ok(d) => d.clone(),
1725 Err(_) => return FontCache::default(),
1726 }
1727 }
1728 Err(_) => return FontCache::default(),
1729 };
1730
1731 let mut cache = FontCache::default();
1732 for (name_bytes, font_ref) in font_dict.iter() {
1733 let font_name = String::from_utf8_lossy(name_bytes).to_string();
1734 let font_obj = resolve_obj(doc, font_ref);
1735 if let Ok(font_dict) = font_obj.as_dict() {
1736 let font = super::font::resolve_font_dict(doc, &font_name, font_dict);
1737 cache.insert(font_name, font);
1738 }
1739 }
1740 cache
1741}
1742
1743fn extract_string_bytes(obj: &Object) -> Option<Vec<u8>> {
1746 match obj {
1747 Object::String(bytes, _) => Some(bytes.clone()),
1748 _ => None,
1749 }
1750}
1751
1752fn resolve_bdc_properties(
1754 doc: &Document,
1755 resources: &Dictionary,
1756 operands: &[Object],
1757) -> Option<Dictionary> {
1758 if operands.len() < 2 {
1759 return None;
1760 }
1761 match &operands[1] {
1762 Object::Dictionary(dict) => Some(dict.clone()),
1763 Object::Name(name) => {
1764 let props = resources.get(b"Properties").ok()?;
1765 let props_dict = match resolve_obj(doc, props) {
1766 Object::Dictionary(d) => d,
1767 _ => return None,
1768 };
1769 match props_dict.get(name.as_slice()).ok() {
1770 Some(obj) => match resolve_obj(doc, obj) {
1771 Object::Dictionary(d) => Some(d),
1772 _ => None,
1773 },
1774 None => None,
1775 }
1776 }
1777 Object::Reference(id) => match doc.get_object(*id).ok()? {
1778 Object::Dictionary(d) => Some(d.clone()),
1779 _ => None,
1780 },
1781 _ => None,
1782 }
1783}
1784
1785fn extract_actual_text(props: &Dictionary) -> Option<String> {
1787 let obj = props.get(b"ActualText").ok()?;
1788 match obj {
1789 Object::String(bytes, _) => {
1790 let s = decode_pdf_string(bytes);
1791 if s.is_empty() {
1792 None
1793 } else {
1794 Some(s)
1795 }
1796 }
1797 _ => None,
1798 }
1799}
1800
1801fn ocg_visibility_from_props(
1803 doc: &Document,
1804 props: &Dictionary,
1805 vis: &OcgVisibility,
1806) -> Option<bool> {
1807 let oc = props.get(b"OC").ok()?;
1808 match resolve_obj(doc, oc) {
1809 Object::Reference(id) => Some(vis.is_visible(id)),
1810 Object::Dictionary(d) => {
1811 let _ = d;
1814 Some(true)
1815 }
1816 _ => None,
1817 }
1818}
1819
1820fn obj_to_f64(obj: Object) -> Option<f64> {
1821 match obj {
1822 Object::Integer(i) => Some(i as f64),
1823 Object::Real(f) => Some(f),
1824 _ => None,
1825 }
1826}
1827
1828fn obj_to_name(obj: &Object) -> String {
1829 match obj {
1830 Object::Name(bytes) => String::from_utf8_lossy(bytes).to_string(),
1831 _ => String::new(),
1832 }
1833}
1834
1835fn obj_name_bytes(obj: &Object) -> Option<Vec<u8>> {
1837 match obj {
1838 Object::Name(bytes) => Some(bytes.clone()),
1839 _ => None,
1840 }
1841}
1842
1843fn color_space_components(name: &str) -> u8 {
1844 match name {
1845 "DeviceGray" | "CalGray" | "G" => 1,
1846 "DeviceRGB" | "CalRGB" | "RGB" => 3,
1847 "DeviceCMYK" | "CMYK" => 4,
1848 "Pattern" => 0,
1849 _ => 3,
1850 }
1851}
1852
1853fn default_color_for_space(components: u8) -> Vec<f64> {
1855 match components {
1856 0 => Vec::new(), 4 => vec![0.0, 0.0, 0.0, 1.0], 3 => vec![0.0, 0.0, 0.0], _ => vec![0.0], }
1861}
1862
1863fn resolve_obj(doc: &Document, obj: &Object) -> Object {
1864 match obj {
1865 Object::Reference(id) => doc.get_object(*id).cloned().unwrap_or(Object::Null),
1866 other => other.clone(),
1867 }
1868}
1869
1870fn path_bbox(path: &[PathSegment]) -> (f64, f64, f64, f64) {
1872 let mut min_x = f64::MAX;
1873 let mut min_y = f64::MAX;
1874 let mut max_x = f64::MIN;
1875 let mut max_y = f64::MIN;
1876 for seg in path {
1877 let (sx, sy, ex, ey) = match seg {
1878 PathSegment::Line { x1, y1, x2, y2 } => (*x1, *y1, *x2, *y2),
1879 PathSegment::Curve { x1, y1, x2, y2, .. } => (*x1, *y1, *x2, *y2),
1880 };
1881 min_x = min_x.min(sx).min(ex);
1882 min_y = min_y.min(sy).min(ey);
1883 max_x = max_x.max(sx).max(ex);
1884 max_y = max_y.max(sy).max(ey);
1885 }
1886 if !min_x.is_finite() {
1887 return (0.0, 0.0, 0.0, 0.0);
1888 }
1889 (min_x, min_y, max_x, max_y)
1890}
1891
1892#[cfg(test)]
1893mod tests {
1894 use super::*;
1895 use lopdf::content::Operation;
1896 use lopdf::{dictionary, Stream};
1897
1898 fn create_test_pdf_with_text() -> Document {
1899 let mut doc = Document::with_version("1.5");
1900 let pages_id = doc.new_object_id();
1901
1902 let font_id = doc.add_object(dictionary! {
1903 "Type" => "Font",
1904 "Subtype" => "Type1",
1905 "BaseFont" => "Helvetica",
1906 });
1907
1908 let resources_id = doc.add_object(dictionary! {
1909 "Font" => dictionary! {
1910 "F1" => font_id,
1911 },
1912 });
1913
1914 let content = Content {
1915 operations: vec![
1916 Operation::new("BT", vec![]),
1917 Operation::new("Tf", vec!["F1".into(), 12.into()]),
1918 Operation::new("Td", vec![100.into(), 700.into()]),
1919 Operation::new("Tj", vec![Object::string_literal("Hello World!")]),
1920 Operation::new("ET", vec![]),
1921 ],
1922 };
1923
1924 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
1925
1926 let page_id = doc.add_object(dictionary! {
1927 "Type" => "Page",
1928 "Parent" => pages_id,
1929 "Contents" => content_id,
1930 "Resources" => resources_id,
1931 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
1932 });
1933
1934 let pages = dictionary! {
1935 "Type" => "Pages",
1936 "Kids" => vec![page_id.into()],
1937 "Count" => 1,
1938 };
1939 doc.objects.insert(pages_id, Object::Dictionary(pages));
1940
1941 let catalog_id = doc.add_object(dictionary! {
1942 "Type" => "Catalog",
1943 "Pages" => pages_id,
1944 });
1945 doc.trailer.set("Root", catalog_id);
1946 doc
1947 }
1948
1949 #[test]
1950 fn test_unified_text_extraction() {
1951 let doc = create_test_pdf_with_text();
1952 let pages = doc.get_pages();
1953 let (&page_num, &page_id) = pages.iter().next().unwrap();
1954
1955 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
1956 assert!(!chunks.text_chunks.is_empty(), "Expected text chunks");
1957 assert!(
1958 chunks.text_chunks[0].value.contains("Hello"),
1959 "Expected 'Hello' in text"
1960 );
1961 assert!(chunks.image_chunks.is_empty(), "No images expected");
1962 }
1963
1964 #[test]
1965 fn test_image_from_do_operator() {
1966 let mut doc = Document::with_version("1.5");
1967 let pages_id = doc.new_object_id();
1968
1969 let img_stream = Stream::new(
1971 dictionary! {
1972 "Type" => "XObject",
1973 "Subtype" => "Image",
1974 "Width" => 200,
1975 "Height" => 100,
1976 "ColorSpace" => "DeviceRGB",
1977 "BitsPerComponent" => 8,
1978 },
1979 vec![0u8; 100],
1980 );
1981 let img_id = doc.add_object(img_stream);
1982
1983 let resources_id = doc.add_object(dictionary! {
1984 "XObject" => dictionary! {
1985 "Im1" => img_id,
1986 },
1987 });
1988
1989 let content = Content {
1991 operations: vec![
1992 Operation::new("q", vec![]),
1993 Operation::new(
1994 "cm",
1995 vec![
1996 Object::Real(200.0), 0.into(),
1998 0.into(),
1999 Object::Real(100.0), Object::Real(72.0), Object::Real(500.0), ],
2003 ),
2004 Operation::new("Do", vec!["Im1".into()]),
2005 Operation::new("Q", vec![]),
2006 ],
2007 };
2008
2009 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2010
2011 let page_id = doc.add_object(dictionary! {
2012 "Type" => "Page",
2013 "Parent" => pages_id,
2014 "Contents" => content_id,
2015 "Resources" => resources_id,
2016 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2017 });
2018
2019 let pages = dictionary! {
2020 "Type" => "Pages",
2021 "Kids" => vec![page_id.into()],
2022 "Count" => 1,
2023 };
2024 doc.objects.insert(pages_id, Object::Dictionary(pages));
2025
2026 let catalog_id = doc.add_object(dictionary! {
2027 "Type" => "Catalog",
2028 "Pages" => pages_id,
2029 });
2030 doc.trailer.set("Root", catalog_id);
2031
2032 let pages = doc.get_pages();
2033 let (&page_num, &page_id) = pages.iter().next().unwrap();
2034
2035 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2036 assert_eq!(chunks.image_chunks.len(), 1, "Expected 1 image chunk");
2037
2038 let img = &chunks.image_chunks[0];
2039 assert!(
2041 (img.bbox.left_x - 72.0).abs() < 1.0,
2042 "Expected left_x ~72, got {}",
2043 img.bbox.left_x
2044 );
2045 assert!(
2046 (img.bbox.bottom_y - 500.0).abs() < 1.0,
2047 "Expected bottom_y ~500, got {}",
2048 img.bbox.bottom_y
2049 );
2050 assert!(
2051 (img.bbox.right_x - 272.0).abs() < 1.0,
2052 "Expected right_x ~272, got {}",
2053 img.bbox.right_x
2054 );
2055 assert!(
2056 (img.bbox.top_y - 600.0).abs() < 1.0,
2057 "Expected top_y ~600, got {}",
2058 img.bbox.top_y
2059 );
2060 }
2061
2062 #[test]
2063 fn test_pattern_fill_emits_image_not_lines() {
2064 let mut doc = Document::with_version("1.5");
2065 let pages_id = doc.new_object_id();
2066
2067 let img_id = doc.add_object(Stream::new(
2068 dictionary! {
2069 "Type" => "XObject",
2070 "Subtype" => "Image",
2071 "Width" => 200,
2072 "Height" => 100,
2073 "ColorSpace" => "DeviceRGB",
2074 "BitsPerComponent" => 8,
2075 },
2076 vec![2u8; 200],
2077 ));
2078
2079 let pattern_id = doc.add_object(dictionary! {
2080 "Type" => "Pattern",
2081 "PatternType" => 1,
2082 "PaintType" => 1,
2083 "TilingType" => 1,
2084 "BBox" => vec![0.into(), 0.into(), 200.into(), 100.into()],
2085 "XStep" => 200,
2086 "YStep" => 100,
2087 "Resources" => dictionary! {
2088 "XObject" => dictionary! {
2089 "X1" => img_id,
2090 },
2091 },
2092 });
2093
2094 let resources_id = doc.add_object(dictionary! {
2095 "Pattern" => dictionary! {
2096 "P1" => pattern_id,
2097 },
2098 });
2099
2100 let content = Content {
2102 operations: vec![
2103 Operation::new("cs", vec!["Pattern".into()]),
2104 Operation::new("scn", vec!["P1".into()]),
2105 Operation::new(
2106 "re",
2107 vec![
2108 Object::Real(10.0),
2109 Object::Real(20.0),
2110 Object::Real(400.0),
2111 Object::Real(300.0),
2112 ],
2113 ),
2114 Operation::new("f", vec![]),
2115 ],
2116 };
2117
2118 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2119
2120 let page_id = doc.add_object(dictionary! {
2121 "Type" => "Page",
2122 "Parent" => pages_id,
2123 "Contents" => content_id,
2124 "Resources" => resources_id,
2125 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2126 });
2127
2128 let pages = dictionary! {
2129 "Type" => "Pages",
2130 "Kids" => vec![page_id.into()],
2131 "Count" => 1,
2132 };
2133 doc.objects.insert(pages_id, Object::Dictionary(pages));
2134
2135 let catalog_id = doc.add_object(dictionary! {
2136 "Type" => "Catalog",
2137 "Pages" => pages_id,
2138 });
2139 doc.trailer.set("Root", catalog_id);
2140
2141 let pages = doc.get_pages();
2142 let (&page_num, &page_id) = pages.iter().next().unwrap();
2143
2144 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2145 assert_eq!(chunks.image_chunks.len(), 1, "Expected 1 pattern image");
2146 assert!(
2147 chunks.line_chunks.is_empty(),
2148 "Pattern fill must not emit page-edge lines, got {}",
2149 chunks.line_chunks.len()
2150 );
2151
2152 let img = &chunks.image_chunks[0];
2153 assert_eq!(img.index, Some(1));
2154 assert!((img.bbox.left_x - 10.0).abs() < 1.0);
2155 assert!((img.bbox.bottom_y - 20.0).abs() < 1.0);
2156 assert!((img.bbox.right_x - 410.0).abs() < 1.0);
2157 assert!((img.bbox.top_y - 320.0).abs() < 1.0);
2158 }
2159
2160 #[test]
2161 fn test_filled_aa_dots_do_not_emit_lines() {
2162 let mut doc = Document::with_version("1.5");
2166 let pages_id = doc.new_object_id();
2167
2168 let mut ops = vec![Operation::new(
2169 "rg",
2170 vec![Object::Real(0.8), Object::Real(0.7), Object::Real(0.9)],
2171 )];
2172 for i in 0..50 {
2173 let x = 10.0 + (i % 10) as f64;
2174 let y = 10.0 + (i / 10) as f64;
2175 ops.push(Operation::new(
2176 "re",
2177 vec![
2178 Object::Real(x),
2179 Object::Real(y),
2180 Object::Real(1.0),
2181 Object::Real(1.0),
2182 ],
2183 ));
2184 ops.push(Operation::new("f", vec![]));
2185 }
2186 ops.push(Operation::new(
2188 "re",
2189 vec![
2190 Object::Real(50.0),
2191 Object::Real(200.0),
2192 Object::Real(200.0),
2193 Object::Real(1.0),
2194 ],
2195 ));
2196 ops.push(Operation::new("f", vec![]));
2197
2198 let content = Content { operations: ops };
2199 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2200
2201 let page_id = doc.add_object(dictionary! {
2202 "Type" => "Page",
2203 "Parent" => pages_id,
2204 "Contents" => content_id,
2205 "Resources" => dictionary! {},
2206 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2207 });
2208
2209 let pages = dictionary! {
2210 "Type" => "Pages",
2211 "Kids" => vec![page_id.into()],
2212 "Count" => 1,
2213 };
2214 doc.objects.insert(pages_id, Object::Dictionary(pages));
2215
2216 let catalog_id = doc.add_object(dictionary! {
2217 "Type" => "Catalog",
2218 "Pages" => pages_id,
2219 });
2220 doc.trailer.set("Root", catalog_id);
2221
2222 let pages = doc.get_pages();
2223 let (&page_num, &page_id) = pages.iter().next().unwrap();
2224 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2225
2226 assert_eq!(
2227 chunks.line_chunks.len(),
2228 1,
2229 "expected only the thin filled strip as a Line, got {}",
2230 chunks.line_chunks.len()
2231 );
2232 assert!(chunks.line_chunks[0].is_horizontal_line);
2233 assert!(chunks.line_art_chunks.is_empty());
2234 }
2235
2236 #[test]
2237 fn test_form_xobject_recursive() {
2238 let mut doc = Document::with_version("1.5");
2239 let pages_id = doc.new_object_id();
2240
2241 let font_id = doc.add_object(dictionary! {
2243 "Type" => "Font",
2244 "Subtype" => "Type1",
2245 "BaseFont" => "Helvetica",
2246 });
2247
2248 let form_content = Content {
2250 operations: vec![
2251 Operation::new("BT", vec![]),
2252 Operation::new("Tf", vec!["F1".into(), 10.into()]),
2253 Operation::new("Td", vec![0.into(), 0.into()]),
2254 Operation::new("Tj", vec![Object::string_literal("Form Text")]),
2255 Operation::new("ET", vec![]),
2256 ],
2257 };
2258
2259 let form_stream = Stream::new(
2260 dictionary! {
2261 "Type" => "XObject",
2262 "Subtype" => "Form",
2263 "BBox" => vec![0.into(), 0.into(), 200.into(), 50.into()],
2264 "Resources" => dictionary! {
2265 "Font" => dictionary! {
2266 "F1" => font_id,
2267 },
2268 },
2269 },
2270 form_content.encode().unwrap(),
2271 );
2272 let form_id = doc.add_object(form_stream);
2273
2274 let resources_id = doc.add_object(dictionary! {
2275 "Font" => dictionary! {
2276 "F1" => font_id,
2277 },
2278 "XObject" => dictionary! {
2279 "Fm1" => form_id,
2280 },
2281 });
2282
2283 let page_content = Content {
2285 operations: vec![
2286 Operation::new("q", vec![]),
2287 Operation::new(
2288 "cm",
2289 vec![
2290 1.into(),
2291 0.into(),
2292 0.into(),
2293 1.into(),
2294 Object::Real(50.0),
2295 Object::Real(400.0),
2296 ],
2297 ),
2298 Operation::new("Do", vec!["Fm1".into()]),
2299 Operation::new("Q", vec![]),
2300 ],
2301 };
2302
2303 let content_id =
2304 doc.add_object(Stream::new(dictionary! {}, page_content.encode().unwrap()));
2305
2306 let page_id = doc.add_object(dictionary! {
2307 "Type" => "Page",
2308 "Parent" => pages_id,
2309 "Contents" => content_id,
2310 "Resources" => resources_id,
2311 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2312 });
2313
2314 let pages = dictionary! {
2315 "Type" => "Pages",
2316 "Kids" => vec![page_id.into()],
2317 "Count" => 1,
2318 };
2319 doc.objects.insert(pages_id, Object::Dictionary(pages));
2320
2321 let catalog_id = doc.add_object(dictionary! {
2322 "Type" => "Catalog",
2323 "Pages" => pages_id,
2324 });
2325 doc.trailer.set("Root", catalog_id);
2326
2327 let pages = doc.get_pages();
2328 let (&page_num, &page_id) = pages.iter().next().unwrap();
2329
2330 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2331 assert!(
2332 !chunks.text_chunks.is_empty(),
2333 "Expected text from Form XObject"
2334 );
2335 assert!(
2336 chunks.text_chunks[0].value.contains("Form"),
2337 "Expected 'Form' text, got: '{}'",
2338 chunks.text_chunks[0].value
2339 );
2340 }
2341
2342 #[test]
2343 fn test_line_extraction_unified() {
2344 let mut doc = Document::with_version("1.5");
2345 let pages_id = doc.new_object_id();
2346
2347 let content = Content {
2348 operations: vec![
2349 Operation::new("w", vec![Object::Real(1.0)]),
2350 Operation::new("m", vec![72.into(), 400.into()]),
2351 Operation::new("l", vec![500.into(), 400.into()]),
2352 Operation::new("S", vec![]),
2353 ],
2354 };
2355
2356 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2357
2358 let page_id = doc.add_object(dictionary! {
2359 "Type" => "Page",
2360 "Parent" => pages_id,
2361 "Contents" => content_id,
2362 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2363 });
2364
2365 let pages = dictionary! {
2366 "Type" => "Pages",
2367 "Kids" => vec![page_id.into()],
2368 "Count" => 1,
2369 };
2370 doc.objects.insert(pages_id, Object::Dictionary(pages));
2371
2372 let catalog_id = doc.add_object(dictionary! {
2373 "Type" => "Catalog",
2374 "Pages" => pages_id,
2375 });
2376 doc.trailer.set("Root", catalog_id);
2377
2378 let pages = doc.get_pages();
2379 let (&page_num, &page_id) = pages.iter().next().unwrap();
2380
2381 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2382 assert_eq!(chunks.line_chunks.len(), 1, "Expected 1 horizontal line");
2383 assert!(chunks.line_chunks[0].is_horizontal_line);
2384 }
2385
2386 #[test]
2387 fn test_actual_text_bdc_replaces_glyphs() {
2388 let mut doc = Document::with_version("1.5");
2390 let pages_id = doc.new_object_id();
2391
2392 let font_id = doc.add_object(dictionary! {
2393 "Type" => "Font",
2394 "Subtype" => "Type1",
2395 "BaseFont" => "Helvetica",
2396 });
2397
2398 let actual_bytes: Vec<u8> = vec![0xFE, 0xFF, 0x00, b'4', 0x00, b'2'];
2400 let content = Content {
2401 operations: vec![
2402 Operation::new("BT", vec![]),
2403 Operation::new("Tf", vec!["F1".into(), 12.into()]),
2404 Operation::new("Td", vec![72.into(), 720.into()]),
2405 Operation::new(
2406 "BDC",
2407 vec![
2408 "Span".into(),
2409 Object::Dictionary(dictionary! {
2410 "ActualText" => Object::String(
2411 actual_bytes,
2412 lopdf::StringFormat::Hexadecimal,
2413 ),
2414 }),
2415 ],
2416 ),
2417 Operation::new("Tj", vec![Object::string_literal("xx")]),
2418 Operation::new("EMC", vec![]),
2419 Operation::new("ET", vec![]),
2420 ],
2421 };
2422
2423 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2424 let resources_id = doc.add_object(dictionary! {
2425 "Font" => dictionary! { "F1" => font_id },
2426 });
2427
2428 let page_id = doc.add_object(dictionary! {
2429 "Type" => "Page",
2430 "Parent" => pages_id,
2431 "Contents" => content_id,
2432 "Resources" => resources_id,
2433 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2434 });
2435
2436 let pages = dictionary! {
2437 "Type" => "Pages",
2438 "Kids" => vec![page_id.into()],
2439 "Count" => 1,
2440 };
2441 doc.objects.insert(pages_id, Object::Dictionary(pages));
2442
2443 let catalog_id = doc.add_object(dictionary! {
2444 "Type" => "Catalog",
2445 "Pages" => pages_id,
2446 });
2447 doc.trailer.set("Root", catalog_id);
2448
2449 let pages = doc.get_pages();
2450 let (&page_num, &page_id) = pages.iter().next().unwrap();
2451 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2452
2453 let joined: String = chunks.text_chunks.iter().map(|c| c.value.as_str()).collect();
2454 assert!(
2455 joined.contains("42"),
2456 "Expected ActualText '42', got chunks: {:?}",
2457 chunks
2458 .text_chunks
2459 .iter()
2460 .map(|c| &c.value)
2461 .collect::<Vec<_>>()
2462 );
2463 assert!(
2464 !joined.contains("xx"),
2465 "Glyph text must not leak when ActualText is present, got: {joined:?}"
2466 );
2467 }
2468
2469 #[test]
2470 fn test_tj_large_negative_inserts_space() {
2471 let mut doc = Document::with_version("1.5");
2472 let pages_id = doc.new_object_id();
2473
2474 let font_id = doc.add_object(dictionary! {
2475 "Type" => "Font",
2476 "Subtype" => "Type1",
2477 "BaseFont" => "Helvetica",
2478 });
2479
2480 let content = Content {
2481 operations: vec![
2482 Operation::new("BT", vec![]),
2483 Operation::new("Tf", vec!["F1".into(), 12.into()]),
2484 Operation::new("Td", vec![72.into(), 720.into()]),
2485 Operation::new(
2486 "TJ",
2487 vec![Object::Array(vec![
2488 Object::string_literal("Hello"),
2489 Object::Integer(-250),
2490 Object::string_literal("World"),
2491 ])],
2492 ),
2493 Operation::new("ET", vec![]),
2494 ],
2495 };
2496
2497 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2498 let resources_id = doc.add_object(dictionary! {
2499 "Font" => dictionary! { "F1" => font_id },
2500 });
2501
2502 let page_id = doc.add_object(dictionary! {
2503 "Type" => "Page",
2504 "Parent" => pages_id,
2505 "Contents" => content_id,
2506 "Resources" => resources_id,
2507 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2508 });
2509
2510 let pages = dictionary! {
2511 "Type" => "Pages",
2512 "Kids" => vec![page_id.into()],
2513 "Count" => 1,
2514 };
2515 doc.objects.insert(pages_id, Object::Dictionary(pages));
2516
2517 let catalog_id = doc.add_object(dictionary! {
2518 "Type" => "Catalog",
2519 "Pages" => pages_id,
2520 });
2521 doc.trailer.set("Root", catalog_id);
2522
2523 let pages = doc.get_pages();
2524 let (&page_num, &page_id) = pages.iter().next().unwrap();
2525 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2526 let joined: String = chunks.text_chunks.iter().map(|c| c.value.as_str()).collect();
2527 assert!(
2528 joined.contains("Hello") && joined.contains("World"),
2529 "got: {joined:?}"
2530 );
2531 assert!(
2532 joined.contains(' '),
2533 "large negative TJ must insert space, got: {joined:?}"
2534 );
2535 }
2536}