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};
22
23const MAX_FORM_RECURSION_DEPTH: u32 = 10;
25
26const MIN_LINE_WIDTH: f64 = 0.1;
28
29const LINE_ASPECT_RATIO: f64 = 3.0;
31
32const MAX_LINE_THICKNESS: f64 = 10.0;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum PathPaint {
38 Stroke,
40 Fill,
42}
43
44#[derive(Debug, Default)]
46pub struct PageChunks {
47 pub text_chunks: Vec<TextChunk>,
49 pub image_chunks: Vec<ImageChunk>,
51 pub line_chunks: Vec<LineChunk>,
53 pub line_art_chunks: Vec<LineArtChunk>,
55}
56
57pub fn extract_page_chunks(
59 doc: &Document,
60 page_number: u32,
61 page_id: ObjectId,
62) -> Result<PageChunks, EdgePdfError> {
63 let font_cache = resolve_page_fonts(doc, page_id);
64
65 let page_dict = doc
66 .get_object(page_id)
67 .map_err(|e| EdgePdfError::PipelineError {
68 stage: 1,
69 message: format!("Failed to get page {}: {}", page_number, e),
70 })?
71 .as_dict()
72 .map_err(|e| EdgePdfError::PipelineError {
73 stage: 1,
74 message: format!("Page {} is not a dictionary: {}", page_number, e),
75 })?
76 .clone();
77
78 let content_data = super::text_extractor::get_page_content(doc, &page_dict)?;
80 if content_data.is_empty() {
81 return Ok(PageChunks::default());
82 }
83
84 let content = Content::decode(&content_data).map_err(|e| EdgePdfError::PipelineError {
86 stage: 1,
87 message: format!(
88 "Failed to decode content stream for page {}: {}",
89 page_number, e
90 ),
91 })?;
92
93 let resources = resolve_page_resources(doc, &page_dict);
95
96 let mut parser = ChunkParserState::new(page_number, font_cache);
97 parser.process_operations(doc, &content.operations, &resources, 0);
98
99 Ok(parser.into_page_chunks())
100}
101
102fn resolve_page_resources(doc: &Document, page_dict: &Dictionary) -> Dictionary {
104 match page_dict.get(b"Resources") {
105 Ok(obj) => {
106 let resolved = resolve_obj(doc, obj);
107 resolved.as_dict().cloned().unwrap_or_default()
108 }
109 Err(_) => Dictionary::new(),
110 }
111}
112
113struct ChunkParserState {
115 page_number: u32,
116 font_cache: FontCache,
117 gs_stack: GraphicsStateStack,
118
119 text_chunks: Vec<TextChunk>,
121 image_chunks: Vec<ImageChunk>,
122 line_chunks: Vec<LineChunk>,
123 line_art_chunks: Vec<LineArtChunk>,
124
125 text_index: usize,
127 image_index: u32,
128 line_index: u32,
129
130 mcid_stack: Vec<Option<i64>>,
132
133 current_path: Vec<PathSegment>,
135 subpath_start: Option<(f64, f64)>,
136 current_point: Option<(f64, f64)>,
137 line_width: f64,
138}
139
140impl ChunkParserState {
141 fn new(page_number: u32, font_cache: FontCache) -> Self {
142 Self {
143 page_number,
144 font_cache,
145 gs_stack: GraphicsStateStack::default(),
146
147 text_chunks: Vec::new(),
148 image_chunks: Vec::new(),
149 line_chunks: Vec::new(),
150 line_art_chunks: Vec::new(),
151
152 text_index: 0,
153 image_index: 0,
154 line_index: 0,
155
156 mcid_stack: Vec::new(),
157
158 current_path: Vec::new(),
159 subpath_start: None,
160 current_point: None,
161 line_width: 1.0,
162 }
163 }
164
165 fn into_page_chunks(self) -> PageChunks {
166 PageChunks {
167 text_chunks: self.text_chunks,
168 image_chunks: self.image_chunks,
169 line_chunks: self.line_chunks,
170 line_art_chunks: self.line_art_chunks,
171 }
172 }
173
174 fn process_operations(
176 &mut self,
177 doc: &Document,
178 operations: &[lopdf::content::Operation],
179 resources: &Dictionary,
180 recursion_depth: u32,
181 ) {
182 for op in operations {
183 match op.operator.as_str() {
184 "BMC" => {
186 self.mcid_stack.push(None);
187 }
188 "BDC" => {
189 let mcid = extract_mcid_from_bdc(&op.operands);
190 self.mcid_stack.push(mcid);
191 }
192 "EMC" => {
193 self.mcid_stack.pop();
194 }
195
196 "q" => self.gs_stack.save(),
198 "Q" => self.gs_stack.restore(),
199 "cm" => {
200 if op.operands.len() == 6 {
201 let vals: Vec<f64> = op
202 .operands
203 .iter()
204 .filter_map(|o| obj_to_f64(o.clone()))
205 .collect();
206 if vals.len() == 6 {
207 self.gs_stack
208 .concat_ctm(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]);
209 }
210 }
211 }
212 "gs" => {
213 if let Some(name) = op.operands.first().and_then(obj_name_bytes) {
215 self.apply_ext_gstate(doc, resources, &name);
216 }
217 }
218
219 "BT" => self.gs_stack.current.begin_text(),
221 "ET" => {}
222
223 "Tf" => {
224 if op.operands.len() == 2 {
225 if let Object::Name(ref name) = op.operands[0] {
226 self.gs_stack.current.text_state.font_name =
227 String::from_utf8_lossy(name).to_string();
228 }
229 if let Some(size) = obj_to_f64(op.operands[1].clone()) {
230 self.gs_stack.current.text_state.font_size = size;
231 }
232 }
233 }
234 "Tc" => {
235 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
236 self.gs_stack.current.text_state.char_spacing = v;
237 }
238 }
239 "Tw" => {
240 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
241 self.gs_stack.current.text_state.word_spacing = v;
242 }
243 }
244 "Tz" => {
245 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
246 self.gs_stack.current.text_state.horizontal_scaling = v;
247 }
248 }
249 "TL" => {
250 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
251 self.gs_stack.current.text_state.leading = v;
252 }
253 }
254 "Ts" => {
255 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
256 self.gs_stack.current.text_state.rise = v;
257 }
258 }
259 "Tr" => {
260 if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
261 self.gs_stack.current.text_state.render_mode = v as i32;
262 }
263 }
264
265 "Td" => {
267 if op.operands.len() == 2 {
268 let tx = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
269 let ty = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
270 self.gs_stack.current.translate_text(tx, ty);
271 }
272 }
273 "TD" => {
274 if op.operands.len() == 2 {
275 let tx = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
276 let ty = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
277 self.gs_stack.current.text_state.leading = -ty;
278 self.gs_stack.current.translate_text(tx, ty);
279 }
280 }
281 "Tm" => {
282 if op.operands.len() == 6 {
283 let vals: Vec<f64> = op
284 .operands
285 .iter()
286 .filter_map(|o| obj_to_f64(o.clone()))
287 .collect();
288 if vals.len() == 6 {
289 self.gs_stack.current.set_text_matrix(
290 vals[0], vals[1], vals[2], vals[3], vals[4], vals[5],
291 );
292 }
293 }
294 }
295 "T*" => {
296 self.gs_stack.current.next_line();
297 }
298
299 "Tj" => {
301 if let Some(text_bytes) = op.operands.first().and_then(extract_string_bytes) {
302 self.emit_text_chunk(&text_bytes);
303 }
304 }
305 "TJ" => {
306 if let Some(Object::Array(ref arr)) = op.operands.first() {
307 self.process_tj_array(arr);
308 }
309 }
310 "'" => {
311 self.gs_stack.current.next_line();
312 if let Some(text_bytes) = op.operands.first().and_then(extract_string_bytes) {
313 self.emit_text_chunk(&text_bytes);
314 }
315 }
316 "\"" => {
317 if op.operands.len() == 3 {
318 if let Some(aw) = obj_to_f64(op.operands[0].clone()) {
319 self.gs_stack.current.text_state.word_spacing = aw;
320 }
321 if let Some(ac) = obj_to_f64(op.operands[1].clone()) {
322 self.gs_stack.current.text_state.char_spacing = ac;
323 }
324 self.gs_stack.current.next_line();
325 if let Some(text_bytes) = extract_string_bytes(&op.operands[2]) {
326 self.emit_text_chunk(&text_bytes);
327 }
328 }
329 }
330
331 "g" => {
333 if let Some(gray) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
334 self.gs_stack.current.fill_color = vec![gray];
335 self.gs_stack.current.fill_color_space_components = 1;
336 }
337 }
338 "G" => {
339 if let Some(gray) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
340 self.gs_stack.current.stroke_color = vec![gray];
341 self.gs_stack.current.stroke_color_space_components = 1;
342 }
343 }
344 "rg" => {
345 if op.operands.len() == 3 {
346 let r = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
347 let g = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
348 let b = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
349 self.gs_stack.current.fill_color = vec![r, g, b];
350 self.gs_stack.current.fill_color_space_components = 3;
351 }
352 }
353 "RG" => {
354 if op.operands.len() == 3 {
355 let r = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
356 let g = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
357 let b = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
358 self.gs_stack.current.stroke_color = vec![r, g, b];
359 self.gs_stack.current.stroke_color_space_components = 3;
360 }
361 }
362 "k" => {
363 if op.operands.len() == 4 {
364 let c = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
365 let m = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
366 let y = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
367 let kk = obj_to_f64(op.operands[3].clone()).unwrap_or(0.0);
368 self.gs_stack.current.fill_color = vec![c, m, y, kk];
369 self.gs_stack.current.fill_color_space_components = 4;
370 }
371 }
372 "K" => {
373 if op.operands.len() == 4 {
374 let c = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
375 let m = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
376 let y = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
377 let kk = obj_to_f64(op.operands[3].clone()).unwrap_or(0.0);
378 self.gs_stack.current.stroke_color = vec![c, m, y, kk];
379 self.gs_stack.current.stroke_color_space_components = 4;
380 }
381 }
382 "cs" => {
383 if let Some(name) = op.operands.first() {
384 let cs_name = obj_to_name(name);
385 let comps = color_space_components(&cs_name);
386 self.gs_stack.current.fill_color_space_components = comps;
387 self.gs_stack.current.fill_color = default_color_for_space(comps);
389 self.gs_stack.current.fill_pattern = None;
390 }
391 }
392 "CS" => {
393 if let Some(name) = op.operands.first() {
394 let cs_name = obj_to_name(name);
395 let comps = color_space_components(&cs_name);
396 self.gs_stack.current.stroke_color_space_components = comps;
397 self.gs_stack.current.stroke_color = default_color_for_space(comps);
399 self.gs_stack.current.stroke_pattern = None;
400 }
401 }
402 "sc" | "scn" => {
403 if let Some(name) = op.operands.first().and_then(obj_name_bytes) {
405 self.gs_stack.current.fill_pattern = Some(name);
406 } else {
407 let components: Vec<f64> = op
408 .operands
409 .iter()
410 .filter_map(|o| obj_to_f64(o.clone()))
411 .collect();
412 if !components.is_empty() {
413 self.gs_stack.current.fill_color = components;
414 self.gs_stack.current.fill_pattern = None;
415 }
416 }
417 }
418 "SC" | "SCN" => {
419 if let Some(name) = op.operands.first().and_then(obj_name_bytes) {
420 self.gs_stack.current.stroke_pattern = Some(name);
421 } else {
422 let components: Vec<f64> = op
423 .operands
424 .iter()
425 .filter_map(|o| obj_to_f64(o.clone()))
426 .collect();
427 if !components.is_empty() {
428 self.gs_stack.current.stroke_color = components;
429 self.gs_stack.current.stroke_pattern = None;
430 }
431 }
432 }
433
434 "w" => {
436 if let Some(w) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
437 self.line_width = w;
438 }
439 }
440
441 "m" => {
443 if op.operands.len() >= 2 {
444 if let (Some(x), Some(y)) = (
445 op.operands.first().and_then(|o| obj_to_f64(o.clone())),
446 op.operands.get(1).and_then(|o| obj_to_f64(o.clone())),
447 ) {
448 let (tx, ty) = self.transform_point(x, y);
449 self.subpath_start = Some((tx, ty));
450 self.current_point = Some((tx, ty));
451 }
452 }
453 }
454 "l" => {
455 if op.operands.len() >= 2 {
456 if let (Some(x), Some(y)) = (
457 op.operands.first().and_then(|o| obj_to_f64(o.clone())),
458 op.operands.get(1).and_then(|o| obj_to_f64(o.clone())),
459 ) {
460 let (tx, ty) = self.transform_point(x, y);
461 if let Some((cx, cy)) = self.current_point {
462 self.current_path.push(PathSegment::Line {
463 x1: cx,
464 y1: cy,
465 x2: tx,
466 y2: ty,
467 });
468 }
469 self.current_point = Some((tx, ty));
470 }
471 }
472 }
473 "c" => {
474 if op.operands.len() >= 6 {
475 let vals: Vec<f64> = op
476 .operands
477 .iter()
478 .filter_map(|o| obj_to_f64(o.clone()))
479 .collect();
480 if vals.len() >= 6 {
481 let (tx, ty) = self.transform_point(vals[4], vals[5]);
482 if let Some((cx, cy)) = self.current_point {
483 let (cp1x, cp1y) = self.transform_point(vals[0], vals[1]);
484 let (cp2x, cp2y) = self.transform_point(vals[2], vals[3]);
485 self.current_path.push(PathSegment::Curve {
486 x1: cx,
487 y1: cy,
488 cp1x,
489 cp1y,
490 cp2x,
491 cp2y,
492 x2: tx,
493 y2: ty,
494 });
495 }
496 self.current_point = Some((tx, ty));
497 }
498 }
499 }
500 "v" => {
501 if op.operands.len() >= 4 {
502 let vals: Vec<f64> = op
503 .operands
504 .iter()
505 .filter_map(|o| obj_to_f64(o.clone()))
506 .collect();
507 if vals.len() >= 4 {
508 let (tx, ty) = self.transform_point(vals[2], vals[3]);
509 if let Some((cx, cy)) = self.current_point {
510 let (cp2x, cp2y) = self.transform_point(vals[0], vals[1]);
511 self.current_path.push(PathSegment::Curve {
512 x1: cx,
513 y1: cy,
514 cp1x: cx,
515 cp1y: cy,
516 cp2x,
517 cp2y,
518 x2: tx,
519 y2: ty,
520 });
521 }
522 self.current_point = Some((tx, ty));
523 }
524 }
525 }
526 "y" => {
527 if op.operands.len() >= 4 {
528 let vals: Vec<f64> = op
529 .operands
530 .iter()
531 .filter_map(|o| obj_to_f64(o.clone()))
532 .collect();
533 if vals.len() >= 4 {
534 let (tx, ty) = self.transform_point(vals[2], vals[3]);
535 if let Some((cx, cy)) = self.current_point {
536 let (cp1x, cp1y) = self.transform_point(vals[0], vals[1]);
537 self.current_path.push(PathSegment::Curve {
538 x1: cx,
539 y1: cy,
540 cp1x,
541 cp1y,
542 cp2x: tx,
543 cp2y: ty,
544 x2: tx,
545 y2: ty,
546 });
547 }
548 self.current_point = Some((tx, ty));
549 }
550 }
551 }
552 "h" => {
553 if let (Some((sx, sy)), Some((cx, cy))) =
554 (self.subpath_start, self.current_point)
555 {
556 if (sx - cx).abs() > 0.01 || (sy - cy).abs() > 0.01 {
557 self.current_path.push(PathSegment::Line {
558 x1: cx,
559 y1: cy,
560 x2: sx,
561 y2: sy,
562 });
563 }
564 self.current_point = self.subpath_start;
565 }
566 }
567 "re" => {
568 if op.operands.len() >= 4 {
569 let vals: Vec<f64> = op
570 .operands
571 .iter()
572 .filter_map(|o| obj_to_f64(o.clone()))
573 .collect();
574 if vals.len() >= 4 {
575 let (x, y, w, h) = (vals[0], vals[1], vals[2], vals[3]);
576 let (x1, y1) = self.transform_point(x, y);
577 let (x2, y2) = self.transform_point(x + w, y);
578 let (x3, y3) = self.transform_point(x + w, y + h);
579 let (x4, y4) = self.transform_point(x, y + h);
580 self.current_path.push(PathSegment::Line { x1, y1, x2, y2 });
581 self.current_path.push(PathSegment::Line {
582 x1: x2,
583 y1: y2,
584 x2: x3,
585 y2: y3,
586 });
587 self.current_path.push(PathSegment::Line {
588 x1: x3,
589 y1: y3,
590 x2: x4,
591 y2: y4,
592 });
593 self.current_path.push(PathSegment::Line {
594 x1: x4,
595 y1: y4,
596 x2: x1,
597 y2: y1,
598 });
599 self.subpath_start = Some((x1, y1));
600 self.current_point = Some((x1, y1));
601 }
602 }
603 }
604
605 "S" => {
607 self.classify_and_emit_path(PathPaint::Stroke);
608 }
609 "s" => {
610 self.close_subpath();
612 self.classify_and_emit_path(PathPaint::Stroke);
613 }
614 "f" | "F" | "f*" => {
615 if self.try_emit_pattern_fill_image(doc, resources) {
616 } else {
619 self.classify_and_emit_path(PathPaint::Fill);
620 }
621 }
622 "B" | "B*" | "b" | "b*" => {
623 if op.operator.starts_with('b') {
624 self.close_subpath();
625 }
626 if self.try_emit_pattern_fill_image(doc, resources) {
627 } else {
629 self.classify_and_emit_path(PathPaint::Stroke);
631 }
632 }
633 "n" => {
634 self.current_path.clear();
636 self.subpath_start = None;
637 self.current_point = None;
638 }
639
640 "Do" => {
642 if let Some(name_bytes) = op.operands.first().and_then(obj_name_bytes) {
643 self.handle_do_operator(doc, resources, &name_bytes, recursion_depth);
644 }
645 }
646
647 "BI" => {
652 self.emit_inline_image();
653 }
654
655 _ => {
656 }
658 }
659 }
660 }
661
662 fn emit_text_chunk(&mut self, text_bytes: &[u8]) {
665 if text_bytes.is_empty() {
666 return;
667 }
668
669 let font = self
670 .font_cache
671 .get(&self.gs_stack.current.text_state.font_name)
672 .cloned()
673 .unwrap_or_else(|| PdfFont::default_font(&self.gs_stack.current.text_state.font_name));
674 let active_mcid = self.active_mcid();
675
676 if let Some(chunk) = create_text_chunk(
677 text_bytes,
678 &font,
679 &mut self.gs_stack,
680 self.page_number,
681 &mut self.text_index,
682 active_mcid,
683 ) {
684 self.text_chunks.push(chunk);
685 }
686 }
687
688 fn process_tj_array(&mut self, arr: &[Object]) {
689 let font = self
690 .font_cache
691 .get(&self.gs_stack.current.text_state.font_name)
692 .cloned()
693 .unwrap_or_else(|| PdfFont::default_font(&self.gs_stack.current.text_state.font_name));
694 let active_mcid = self.active_mcid();
695
696 for item in arr {
697 match item {
698 Object::String(bytes, _) => {
699 if let Some(chunk) = create_text_chunk(
700 bytes,
701 &font,
702 &mut self.gs_stack,
703 self.page_number,
704 &mut self.text_index,
705 active_mcid,
706 ) {
707 self.text_chunks.push(chunk);
708 }
709 }
710 _ => {
711 if let Some(adj) = obj_to_f64(item.clone()) {
712 let displacement =
713 -adj / 1000.0 * self.gs_stack.current.text_state.font_size;
714 self.gs_stack.current.advance_text(displacement);
715 }
716 }
717 }
718 }
719 }
720
721 fn handle_do_operator(
725 &mut self,
726 doc: &Document,
727 resources: &Dictionary,
728 name_bytes: &[u8],
729 recursion_depth: u32,
730 ) {
731 let xobject_dict = match resources.get(b"XObject") {
733 Ok(obj) => {
734 let resolved = resolve_obj(doc, obj);
735 match resolved.as_dict() {
736 Ok(d) => d.clone(),
737 Err(_) => return,
738 }
739 }
740 Err(_) => return,
741 };
742
743 let xobj_ref = match xobject_dict.get(name_bytes) {
744 Ok(obj) => resolve_obj(doc, obj),
745 Err(_) => return,
746 };
747
748 let stream = match xobj_ref.as_stream() {
749 Ok(s) => s.clone(),
750 Err(_) => return,
751 };
752
753 let subtype = stream
754 .dict
755 .get(b"Subtype")
756 .ok()
757 .and_then(|o| match resolve_obj(doc, o) {
758 Object::Name(n) => Some(String::from_utf8_lossy(&n).to_string()),
759 _ => None,
760 });
761
762 match subtype.as_deref() {
763 Some("Image") => {
764 self.emit_image_from_ctm();
766 }
767 Some("Form") => {
768 if recursion_depth < MAX_FORM_RECURSION_DEPTH {
770 self.process_form_xobject(doc, &stream, resources, recursion_depth);
771 }
772 }
773 _ => {}
774 }
775 }
776
777 fn emit_image_from_ctm(&mut self) {
780 let ctm = &self.gs_stack.current.ctm;
781
782 let (x0, y0) = ctm.transform_point(0.0, 0.0);
784 let (x1, y1) = ctm.transform_point(1.0, 0.0);
785 let (x2, y2) = ctm.transform_point(1.0, 1.0);
786 let (x3, y3) = ctm.transform_point(0.0, 1.0);
787
788 let min_x = x0.min(x1).min(x2).min(x3);
789 let max_x = x0.max(x1).max(x2).max(x3);
790 let min_y = y0.min(y1).min(y2).min(y3);
791 let max_y = y0.max(y1).max(y2).max(y3);
792
793 self.emit_image_bbox(min_x, min_y, max_x, max_y, None, ImagePaintSource::Do);
794 }
795
796 fn emit_image_bbox(
798 &mut self,
799 min_x: f64,
800 min_y: f64,
801 max_x: f64,
802 max_y: f64,
803 forced_index: Option<u32>,
804 source: ImagePaintSource,
805 ) {
806 if (max_x - min_x).abs() < 0.1 || (max_y - min_y).abs() < 0.1 {
808 return;
809 }
810
811 let index = if let Some(idx) = forced_index {
812 self.image_index = self.image_index.max(idx);
813 idx
814 } else {
815 self.image_index += 1;
816 self.image_index
817 };
818
819 self.image_chunks.push(ImageChunk {
820 bbox: BoundingBox::new(Some(self.page_number), min_x, min_y, max_x, max_y),
821 index: Some(index),
822 level: None,
823 source,
824 });
825 }
826
827 fn try_emit_pattern_fill_image(&mut self, doc: &Document, resources: &Dictionary) -> bool {
831 let Some(pattern_name) = self.gs_stack.current.fill_pattern.clone() else {
832 return false;
833 };
834
835 let path = std::mem::take(&mut self.current_path);
836 self.subpath_start = None;
837 self.current_point = None;
838 if path.is_empty() {
839 return true;
840 }
841
842 let (min_x, min_y, max_x, max_y) = path_bbox(&path);
843 if (max_x - min_x).abs() < 0.1 || (max_y - min_y).abs() < 0.1 {
844 return true;
845 }
846
847 let Some(index) =
848 super::image_extractor::find_pattern_image_index(doc, resources, &pattern_name)
849 else {
850 return true;
853 };
854
855 self.emit_image_bbox(
856 min_x,
857 min_y,
858 max_x,
859 max_y,
860 Some(index),
861 ImagePaintSource::PatternFill,
862 );
863 true
864 }
865
866 fn emit_inline_image(&mut self) {
868 let ctm = &self.gs_stack.current.ctm;
869 let (x0, y0) = ctm.transform_point(0.0, 0.0);
870 let (x1, y1) = ctm.transform_point(1.0, 0.0);
871 let (x2, y2) = ctm.transform_point(1.0, 1.0);
872 let (x3, y3) = ctm.transform_point(0.0, 1.0);
873 let min_x = x0.min(x1).min(x2).min(x3);
874 let max_x = x0.max(x1).max(x2).max(x3);
875 let min_y = y0.min(y1).min(y2).min(y3);
876 let max_y = y0.max(y1).max(y2).max(y3);
877 self.emit_image_bbox(min_x, min_y, max_x, max_y, None, ImagePaintSource::Inline);
878 }
879
880 fn process_form_xobject(
882 &mut self,
883 doc: &Document,
884 stream: &lopdf::Stream,
885 parent_resources: &Dictionary,
886 recursion_depth: u32,
887 ) {
888 let form_matrix = get_form_matrix(doc, &stream.dict);
890
891 self.gs_stack.save();
893 let m = form_matrix;
894 self.gs_stack.concat_ctm(m.a, m.b, m.c, m.d, m.e, m.f);
895
896 let form_resources = match stream.dict.get(b"Resources") {
898 Ok(obj) => {
899 let resolved = resolve_obj(doc, obj);
900 resolved
901 .as_dict()
902 .cloned()
903 .unwrap_or_else(|_| parent_resources.clone())
904 }
905 Err(_) => parent_resources.clone(),
906 };
907
908 let form_content = if stream.dict.get(b"Filter").is_ok() {
910 match stream.decompressed_content() {
911 Ok(data) => data,
912 Err(_) => {
913 self.gs_stack.restore();
914 return;
915 }
916 }
917 } else {
918 stream.content.clone()
919 };
920
921 if form_content.is_empty() {
922 self.gs_stack.restore();
923 return;
924 }
925
926 if let Ok(content) = Content::decode(&form_content) {
928 let form_font_cache = resolve_form_fonts(doc, &form_resources);
930 let mut merged_cache = FontCache::default();
931 for (name, font) in self.font_cache.iter() {
933 merged_cache.insert(name.clone(), font.clone());
934 }
935 for (name, font) in form_font_cache.iter() {
937 merged_cache.insert(name.clone(), font.clone());
938 }
939
940 let saved_fc = std::mem::replace(&mut self.font_cache, merged_cache);
941 self.process_operations(
942 doc,
943 &content.operations,
944 &form_resources,
945 recursion_depth + 1,
946 );
947 self.font_cache = saved_fc;
948 }
949
950 self.gs_stack.restore();
951 }
952
953 fn apply_ext_gstate(&mut self, doc: &Document, resources: &Dictionary, name: &[u8]) {
956 let ext_gstate_dict = match resources.get(b"ExtGState") {
957 Ok(obj) => {
958 let resolved = resolve_obj(doc, obj);
959 match resolved.as_dict() {
960 Ok(d) => d.clone(),
961 Err(_) => return,
962 }
963 }
964 Err(_) => return,
965 };
966
967 let gs_obj = match ext_gstate_dict.get(name) {
968 Ok(obj) => resolve_obj(doc, obj),
969 Err(_) => return,
970 };
971
972 let gs_dict = match gs_obj.as_dict() {
973 Ok(d) => d,
974 Err(_) => return,
975 };
976
977 if let Ok(font_arr) = gs_dict.get(b"Font") {
980 if let Ok(arr) = resolve_obj(doc, font_arr).as_array() {
981 if arr.len() >= 2 {
982 if let Object::Name(ref name) = arr[0] {
983 self.gs_stack.current.text_state.font_name =
984 String::from_utf8_lossy(name).to_string();
985 }
986 if let Some(size) = obj_to_f64(arr[1].clone()) {
987 self.gs_stack.current.text_state.font_size = size;
988 }
989 }
990 }
991 }
992
993 if let Ok(lw) = gs_dict.get(b"LW") {
995 if let Some(w) = obj_to_f64(resolve_obj(doc, lw)) {
996 self.line_width = w;
997 }
998 }
999 }
1000
1001 fn close_subpath(&mut self) {
1004 if let (Some((sx, sy)), Some((cx, cy))) = (self.subpath_start, self.current_point) {
1005 if (sx - cx).abs() > 0.01 || (sy - cy).abs() > 0.01 {
1006 self.current_path.push(PathSegment::Line {
1007 x1: cx,
1008 y1: cy,
1009 x2: sx,
1010 y2: sy,
1011 });
1012 }
1013 self.current_point = self.subpath_start;
1014 }
1015 }
1016
1017 fn classify_and_emit_path(&mut self, paint: PathPaint) {
1018 let path = std::mem::take(&mut self.current_path);
1019 self.subpath_start = None;
1020 self.current_point = None;
1021
1022 if path.is_empty() || self.line_width < MIN_LINE_WIDTH {
1023 return;
1024 }
1025
1026 if matches!(paint, PathPaint::Fill) {
1031 if let Some(mut line) = thin_filled_strip_line(&path, self.line_width, self.page_number)
1032 {
1033 self.line_index += 1;
1034 line.index = Some(self.line_index);
1035 self.line_chunks.push(line);
1036 }
1037 return;
1038 }
1039
1040 let has_curves = path.iter().any(|s| matches!(s, PathSegment::Curve { .. }));
1041
1042 if !has_curves && path.len() <= 4 {
1044 let mut classified_lines = Vec::new();
1045 for seg in &path {
1046 if let PathSegment::Line { x1, y1, x2, y2 } = seg {
1047 let dx = (x2 - x1).abs();
1048 let dy = (y2 - y1).abs();
1049 let length = (dx * dx + dy * dy).sqrt();
1050
1051 if length < MIN_LINE_WIDTH {
1052 continue;
1053 }
1054
1055 let is_horizontal = dy < MAX_LINE_THICKNESS && dx > dy * LINE_ASPECT_RATIO;
1056 let is_vertical = dx < MAX_LINE_THICKNESS && dy > dx * LINE_ASPECT_RATIO;
1057
1058 if is_horizontal || is_vertical {
1059 self.line_index += 1;
1060 let min_x = x1.min(*x2);
1061 let max_x = x1.max(*x2);
1062 let min_y = y1.min(*y2);
1063 let max_y = y1.max(*y2);
1064 let half_w = self.line_width / 2.0;
1065
1066 classified_lines.push(LineChunk {
1067 bbox: BoundingBox::new(
1068 Some(self.page_number),
1069 min_x - if is_vertical { half_w } else { 0.0 },
1070 min_y - if is_horizontal { half_w } else { 0.0 },
1071 max_x + if is_vertical { half_w } else { 0.0 },
1072 max_y + if is_horizontal { half_w } else { 0.0 },
1073 ),
1074 index: Some(self.line_index),
1075 level: None,
1076 start: Vertex {
1077 x: *x1,
1078 y: *y1,
1079 radius: 0.0,
1080 },
1081 end: Vertex {
1082 x: *x2,
1083 y: *y2,
1084 radius: 0.0,
1085 },
1086 width: self.line_width,
1087 is_horizontal_line: is_horizontal,
1088 is_vertical_line: is_vertical,
1089 is_square: false,
1090 });
1091 }
1092 }
1093 }
1094 if !classified_lines.is_empty() {
1095 self.line_chunks.extend(classified_lines);
1096 return;
1097 }
1098 }
1099
1100 if !has_curves && path.len() == 4 {
1102 if let Some(rect) = try_classify_rectangle(&path, self.line_width, self.page_number) {
1103 self.line_index += 1;
1104 let mut rect = rect;
1105 rect.index = Some(self.line_index);
1106 self.line_chunks.push(rect);
1107 return;
1108 }
1109 }
1110
1111 if path.len() >= 2 {
1113 let mut art_lines = Vec::new();
1114 let mut min_x = f64::MAX;
1115 let mut min_y = f64::MAX;
1116 let mut max_x = f64::MIN;
1117 let mut max_y = f64::MIN;
1118
1119 for seg in &path {
1120 let (sx, sy, ex, ey) = match seg {
1121 PathSegment::Line { x1, y1, x2, y2 } => (*x1, *y1, *x2, *y2),
1122 PathSegment::Curve { x1, y1, x2, y2, .. } => (*x1, *y1, *x2, *y2),
1123 };
1124 min_x = min_x.min(sx).min(ex);
1125 min_y = min_y.min(sy).min(ey);
1126 max_x = max_x.max(sx).max(ex);
1127 max_y = max_y.max(sy).max(ey);
1128
1129 self.line_index += 1;
1130 art_lines.push(LineChunk {
1131 bbox: BoundingBox::new(
1132 Some(self.page_number),
1133 sx.min(ex),
1134 sy.min(ey),
1135 sx.max(ex),
1136 sy.max(ey),
1137 ),
1138 index: Some(self.line_index),
1139 level: None,
1140 start: Vertex {
1141 x: sx,
1142 y: sy,
1143 radius: 0.0,
1144 },
1145 end: Vertex {
1146 x: ex,
1147 y: ey,
1148 radius: 0.0,
1149 },
1150 width: self.line_width,
1151 is_horizontal_line: false,
1152 is_vertical_line: false,
1153 is_square: false,
1154 });
1155 }
1156
1157 self.line_index += 1;
1158 self.line_art_chunks.push(LineArtChunk {
1159 bbox: BoundingBox::new(Some(self.page_number), min_x, min_y, max_x, max_y),
1160 index: Some(self.line_index),
1161 level: None,
1162 line_chunks: art_lines,
1163 });
1164 }
1165 }
1166
1167 fn transform_point(&self, x: f64, y: f64) -> (f64, f64) {
1170 self.gs_stack.current.ctm.transform_point(x, y)
1171 }
1172
1173 fn active_mcid(&self) -> Option<i64> {
1174 self.mcid_stack.iter().rev().find_map(|&mcid| mcid)
1175 }
1176}
1177
1178fn create_text_chunk(
1184 text_bytes: &[u8],
1185 font: &PdfFont,
1186 state: &mut GraphicsStateStack,
1187 page_number: u32,
1188 chunk_index: &mut usize,
1189 mcid: Option<i64>,
1190) -> Option<TextChunk> {
1191 if text_bytes.is_empty() {
1192 return None;
1193 }
1194
1195 let trm = state.current.text_rendering_matrix();
1196 let start_x = trm.e;
1197 let font_size = trm.font_size_factor();
1198
1199 if font_size < 0.1 {
1200 return None;
1201 }
1202
1203 let mut text = String::new();
1204 let mut total_width = 0.0;
1205 let mut symbol_ends = Vec::new();
1206
1207 let bpc = font.bytes_per_code as usize;
1208 let mut pos = 0;
1209 while pos + bpc <= text_bytes.len() {
1210 let char_code = if bpc == 2 {
1211 ((text_bytes[pos] as u32) << 8) | (text_bytes[pos + 1] as u32)
1212 } else {
1213 text_bytes[pos] as u32
1214 };
1215 pos += bpc;
1216
1217 let decoded = font.decode_char(char_code);
1218 text.push_str(&decoded);
1219
1220 let glyph_w = font.glyph_width(char_code) / 1000.0;
1221 total_width += glyph_w;
1222 symbol_ends.push(start_x + total_width * font_size);
1223
1224 total_width += state.current.text_state.char_spacing / state.current.text_state.font_size;
1225
1226 if decoded == " " {
1227 total_width +=
1228 state.current.text_state.word_spacing / state.current.text_state.font_size;
1229 }
1230 }
1231
1232 let displacement = total_width * state.current.text_state.font_size;
1233 state.current.advance_text(displacement);
1234
1235 if text.is_empty() {
1236 return None;
1237 }
1238
1239 let trm_after = state.current.text_rendering_matrix();
1241
1242 let ascent = font.ascent;
1244 let descent = font.descent;
1245
1246 let trm_before = &trm;
1248
1249 let (x1, x2) = if trm_before.a >= 0.0 && trm_before.c >= 0.0 {
1251 (
1252 trm_before.e + descent * trm_before.c / 1000.0,
1253 trm_after.e + ascent * trm_after.c / 1000.0,
1254 )
1255 } else if trm_before.a < 0.0 && trm_before.c < 0.0 {
1256 (
1257 trm_after.e + ascent * trm_after.c / 1000.0,
1258 trm_before.e + descent * trm_before.c / 1000.0,
1259 )
1260 } else if trm_before.a >= 0.0 {
1261 (
1262 trm_before.e + ascent * trm_before.c / 1000.0,
1263 trm_after.e + descent * trm_after.c / 1000.0,
1264 )
1265 } else {
1266 (
1267 trm_after.e + descent * trm_after.c / 1000.0,
1268 trm_before.e + ascent * trm_before.c / 1000.0,
1269 )
1270 };
1271
1272 let (y1, y2) = if trm_before.d >= 0.0 && trm_before.b >= 0.0 {
1273 (
1274 trm_before.f + descent * trm_before.d / 1000.0,
1275 trm_after.f + ascent * trm_after.d / 1000.0,
1276 )
1277 } else if trm_before.d < 0.0 && trm_before.b < 0.0 {
1278 (
1279 trm_after.f + ascent * trm_after.d / 1000.0,
1280 trm_before.f + descent * trm_before.d / 1000.0,
1281 )
1282 } else if trm_before.d >= 0.0 {
1283 (
1284 trm_after.f + descent * trm_after.d / 1000.0,
1285 trm_before.f + ascent * trm_before.d / 1000.0,
1286 )
1287 } else {
1288 (
1289 trm_before.f + ascent * trm_before.d / 1000.0,
1290 trm_after.f + descent * trm_after.d / 1000.0,
1291 )
1292 };
1293
1294 let bbox = BoundingBox::new(Some(page_number), x1, y1, x2, y2);
1295
1296 let text_format = if state.current.text_state.rise > font_size * 0.1 {
1297 crate::models::enums::TextFormat::Superscript
1298 } else if state.current.text_state.rise < -font_size * 0.1 {
1299 crate::models::enums::TextFormat::Subscript
1300 } else {
1301 crate::models::enums::TextFormat::Normal
1302 };
1303
1304 *chunk_index += 1;
1305
1306 let fc = &state.current.fill_color;
1307 let font_color = format!(
1308 "[{}]",
1309 fc.iter()
1310 .map(|v| {
1311 let f32_val = *v as f32;
1315 let f64_repr = f32_val as f64;
1316 if f32_val.fract() == 0.0 {
1317 format!("{:.1}", f64_repr)
1318 } else {
1319 format!("{}", f64_repr)
1320 }
1321 })
1322 .collect::<Vec<_>>()
1323 .join(", ")
1324 );
1325
1326 Some(TextChunk {
1327 value: text,
1328 bbox,
1329 font_name: font.base_font.clone(),
1330 font_size,
1331 font_weight: font.weight,
1332 italic_angle: font.italic_angle,
1333 font_color,
1334 contrast_ratio: 21.0,
1335 symbol_ends,
1336 text_format,
1337 text_type: crate::models::enums::TextType::Regular,
1338 pdf_layer: crate::models::enums::PdfLayer::Main,
1339 ocg_visible: true,
1340 index: Some(*chunk_index),
1341 page_number: Some(page_number),
1342 level: None,
1343 mcid,
1344 })
1345}
1346
1347#[derive(Debug, Clone)]
1349enum PathSegment {
1350 Line {
1351 x1: f64,
1352 y1: f64,
1353 x2: f64,
1354 y2: f64,
1355 },
1356 #[allow(dead_code)]
1357 Curve {
1358 x1: f64,
1359 y1: f64,
1360 cp1x: f64,
1361 cp1y: f64,
1362 cp2x: f64,
1363 cp2y: f64,
1364 x2: f64,
1365 y2: f64,
1366 },
1367}
1368
1369fn try_classify_rectangle(
1371 segments: &[PathSegment],
1372 _line_width: f64,
1373 page_number: u32,
1374) -> Option<LineChunk> {
1375 let mut min_x = f64::MAX;
1376 let mut min_y = f64::MAX;
1377 let mut max_x = f64::MIN;
1378 let mut max_y = f64::MIN;
1379
1380 for seg in segments {
1381 if let PathSegment::Line { x1, y1, x2, y2 } = seg {
1382 min_x = min_x.min(*x1).min(*x2);
1383 min_y = min_y.min(*y1).min(*y2);
1384 max_x = max_x.max(*x1).max(*x2);
1385 max_y = max_y.max(*y1).max(*y2);
1386 } else {
1387 return None;
1388 }
1389 }
1390
1391 let w = max_x - min_x;
1392 let h = max_y - min_y;
1393
1394 if w < MIN_LINE_WIDTH || h < MIN_LINE_WIDTH {
1395 return None;
1396 }
1397
1398 let is_horizontal = w > h * LINE_ASPECT_RATIO;
1399 let is_vertical = h > w * LINE_ASPECT_RATIO;
1400 if !is_horizontal && !is_vertical {
1402 return None;
1403 }
1404
1405 Some(LineChunk {
1406 bbox: BoundingBox::new(Some(page_number), min_x, min_y, max_x, max_y),
1407 index: None,
1408 level: None,
1409 start: Vertex {
1410 x: min_x,
1411 y: min_y,
1412 radius: 0.0,
1413 },
1414 end: Vertex {
1415 x: max_x,
1416 y: max_y,
1417 radius: 0.0,
1418 },
1419 width: w.min(h),
1420 is_horizontal_line: is_horizontal,
1421 is_vertical_line: is_vertical,
1422 is_square: false,
1423 })
1424}
1425
1426fn thin_filled_strip_line(
1429 segments: &[PathSegment],
1430 _line_width: f64,
1431 page_number: u32,
1432) -> Option<LineChunk> {
1433 let mut min_x = f64::MAX;
1434 let mut min_y = f64::MAX;
1435 let mut max_x = f64::MIN;
1436 let mut max_y = f64::MIN;
1437
1438 for seg in segments {
1439 let (sx, sy, ex, ey) = match seg {
1440 PathSegment::Line { x1, y1, x2, y2 } => (*x1, *y1, *x2, *y2),
1441 PathSegment::Curve { x1, y1, x2, y2, .. } => (*x1, *y1, *x2, *y2),
1442 };
1443 min_x = min_x.min(sx).min(ex);
1444 min_y = min_y.min(sy).min(ey);
1445 max_x = max_x.max(sx).max(ex);
1446 max_y = max_y.max(sy).max(ey);
1447 }
1448
1449 if !min_x.is_finite() {
1450 return None;
1451 }
1452
1453 let w = max_x - min_x;
1454 let h = max_y - min_y;
1455 if w < MIN_LINE_WIDTH || h < MIN_LINE_WIDTH {
1456 return None;
1457 }
1458
1459 let is_horizontal = h <= MAX_LINE_THICKNESS && w > h * LINE_ASPECT_RATIO;
1460 let is_vertical = w <= MAX_LINE_THICKNESS && h > w * LINE_ASPECT_RATIO;
1461 if !is_horizontal && !is_vertical {
1462 return None;
1463 }
1464
1465 let (sx, sy, ex, ey) = if is_horizontal {
1466 (min_x, (min_y + max_y) * 0.5, max_x, (min_y + max_y) * 0.5)
1467 } else {
1468 ((min_x + max_x) * 0.5, min_y, (min_x + max_x) * 0.5, max_y)
1469 };
1470
1471 Some(LineChunk {
1472 bbox: BoundingBox::new(Some(page_number), min_x, min_y, max_x, max_y),
1473 index: None,
1474 level: None,
1475 start: Vertex {
1476 x: sx,
1477 y: sy,
1478 radius: 0.0,
1479 },
1480 end: Vertex {
1481 x: ex,
1482 y: ey,
1483 radius: 0.0,
1484 },
1485 width: w.min(h),
1486 is_horizontal_line: is_horizontal,
1487 is_vertical_line: is_vertical,
1488 is_square: false,
1489 })
1490}
1491
1492fn get_form_matrix(doc: &Document, dict: &Dictionary) -> Matrix {
1494 match dict.get(b"Matrix") {
1495 Ok(obj) => {
1496 let resolved = resolve_obj(doc, obj);
1497 if let Ok(arr) = resolved.as_array() {
1498 let vals: Vec<f64> = arr.iter().filter_map(|o| obj_to_f64(o.clone())).collect();
1499 if vals.len() == 6 {
1500 return Matrix {
1501 a: vals[0],
1502 b: vals[1],
1503 c: vals[2],
1504 d: vals[3],
1505 e: vals[4],
1506 f: vals[5],
1507 };
1508 }
1509 }
1510 Matrix::identity()
1511 }
1512 Err(_) => Matrix::identity(),
1513 }
1514}
1515
1516fn resolve_form_fonts(doc: &Document, resources: &Dictionary) -> FontCache {
1518 let font_dict = match resources.get(b"Font") {
1519 Ok(obj) => {
1520 let resolved = resolve_obj(doc, obj);
1521 match resolved.as_dict() {
1522 Ok(d) => d.clone(),
1523 Err(_) => return FontCache::default(),
1524 }
1525 }
1526 Err(_) => return FontCache::default(),
1527 };
1528
1529 let mut cache = FontCache::default();
1530 for (name_bytes, font_ref) in font_dict.iter() {
1531 let font_name = String::from_utf8_lossy(name_bytes).to_string();
1532 let font_obj = resolve_obj(doc, font_ref);
1533 if let Ok(font_dict) = font_obj.as_dict() {
1534 let font = super::font::resolve_font_dict(doc, &font_name, font_dict);
1535 cache.insert(font_name, font);
1536 }
1537 }
1538 cache
1539}
1540
1541fn extract_string_bytes(obj: &Object) -> Option<Vec<u8>> {
1544 match obj {
1545 Object::String(bytes, _) => Some(bytes.clone()),
1546 _ => None,
1547 }
1548}
1549
1550fn extract_mcid_from_bdc(operands: &[Object]) -> Option<i64> {
1551 if operands.len() < 2 {
1552 return None;
1553 }
1554 match &operands[1] {
1555 Object::Dictionary(dict) => {
1556 if let Ok(Object::Integer(n)) = dict.get(b"MCID") {
1557 return Some(*n);
1558 }
1559 None
1560 }
1561 _ => None,
1562 }
1563}
1564
1565fn obj_to_f64(obj: Object) -> Option<f64> {
1566 match obj {
1567 Object::Integer(i) => Some(i as f64),
1568 Object::Real(f) => Some(f),
1569 _ => None,
1570 }
1571}
1572
1573fn obj_to_name(obj: &Object) -> String {
1574 match obj {
1575 Object::Name(bytes) => String::from_utf8_lossy(bytes).to_string(),
1576 _ => String::new(),
1577 }
1578}
1579
1580fn obj_name_bytes(obj: &Object) -> Option<Vec<u8>> {
1582 match obj {
1583 Object::Name(bytes) => Some(bytes.clone()),
1584 _ => None,
1585 }
1586}
1587
1588fn color_space_components(name: &str) -> u8 {
1589 match name {
1590 "DeviceGray" | "CalGray" | "G" => 1,
1591 "DeviceRGB" | "CalRGB" | "RGB" => 3,
1592 "DeviceCMYK" | "CMYK" => 4,
1593 "Pattern" => 0,
1594 _ => 3,
1595 }
1596}
1597
1598fn default_color_for_space(components: u8) -> Vec<f64> {
1600 match components {
1601 0 => Vec::new(), 4 => vec![0.0, 0.0, 0.0, 1.0], 3 => vec![0.0, 0.0, 0.0], _ => vec![0.0], }
1606}
1607
1608fn resolve_obj(doc: &Document, obj: &Object) -> Object {
1609 match obj {
1610 Object::Reference(id) => doc.get_object(*id).cloned().unwrap_or(Object::Null),
1611 other => other.clone(),
1612 }
1613}
1614
1615fn path_bbox(path: &[PathSegment]) -> (f64, f64, f64, f64) {
1617 let mut min_x = f64::MAX;
1618 let mut min_y = f64::MAX;
1619 let mut max_x = f64::MIN;
1620 let mut max_y = f64::MIN;
1621 for seg in path {
1622 let (sx, sy, ex, ey) = match seg {
1623 PathSegment::Line { x1, y1, x2, y2 } => (*x1, *y1, *x2, *y2),
1624 PathSegment::Curve { x1, y1, x2, y2, .. } => (*x1, *y1, *x2, *y2),
1625 };
1626 min_x = min_x.min(sx).min(ex);
1627 min_y = min_y.min(sy).min(ey);
1628 max_x = max_x.max(sx).max(ex);
1629 max_y = max_y.max(sy).max(ey);
1630 }
1631 if !min_x.is_finite() {
1632 return (0.0, 0.0, 0.0, 0.0);
1633 }
1634 (min_x, min_y, max_x, max_y)
1635}
1636
1637#[cfg(test)]
1638mod tests {
1639 use super::*;
1640 use lopdf::content::Operation;
1641 use lopdf::{dictionary, Stream};
1642
1643 fn create_test_pdf_with_text() -> Document {
1644 let mut doc = Document::with_version("1.5");
1645 let pages_id = doc.new_object_id();
1646
1647 let font_id = doc.add_object(dictionary! {
1648 "Type" => "Font",
1649 "Subtype" => "Type1",
1650 "BaseFont" => "Helvetica",
1651 });
1652
1653 let resources_id = doc.add_object(dictionary! {
1654 "Font" => dictionary! {
1655 "F1" => font_id,
1656 },
1657 });
1658
1659 let content = Content {
1660 operations: vec![
1661 Operation::new("BT", vec![]),
1662 Operation::new("Tf", vec!["F1".into(), 12.into()]),
1663 Operation::new("Td", vec![100.into(), 700.into()]),
1664 Operation::new("Tj", vec![Object::string_literal("Hello World!")]),
1665 Operation::new("ET", vec![]),
1666 ],
1667 };
1668
1669 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
1670
1671 let page_id = doc.add_object(dictionary! {
1672 "Type" => "Page",
1673 "Parent" => pages_id,
1674 "Contents" => content_id,
1675 "Resources" => resources_id,
1676 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
1677 });
1678
1679 let pages = dictionary! {
1680 "Type" => "Pages",
1681 "Kids" => vec![page_id.into()],
1682 "Count" => 1,
1683 };
1684 doc.objects.insert(pages_id, Object::Dictionary(pages));
1685
1686 let catalog_id = doc.add_object(dictionary! {
1687 "Type" => "Catalog",
1688 "Pages" => pages_id,
1689 });
1690 doc.trailer.set("Root", catalog_id);
1691 doc
1692 }
1693
1694 #[test]
1695 fn test_unified_text_extraction() {
1696 let doc = create_test_pdf_with_text();
1697 let pages = doc.get_pages();
1698 let (&page_num, &page_id) = pages.iter().next().unwrap();
1699
1700 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
1701 assert!(!chunks.text_chunks.is_empty(), "Expected text chunks");
1702 assert!(
1703 chunks.text_chunks[0].value.contains("Hello"),
1704 "Expected 'Hello' in text"
1705 );
1706 assert!(chunks.image_chunks.is_empty(), "No images expected");
1707 }
1708
1709 #[test]
1710 fn test_image_from_do_operator() {
1711 let mut doc = Document::with_version("1.5");
1712 let pages_id = doc.new_object_id();
1713
1714 let img_stream = Stream::new(
1716 dictionary! {
1717 "Type" => "XObject",
1718 "Subtype" => "Image",
1719 "Width" => 200,
1720 "Height" => 100,
1721 "ColorSpace" => "DeviceRGB",
1722 "BitsPerComponent" => 8,
1723 },
1724 vec![0u8; 100],
1725 );
1726 let img_id = doc.add_object(img_stream);
1727
1728 let resources_id = doc.add_object(dictionary! {
1729 "XObject" => dictionary! {
1730 "Im1" => img_id,
1731 },
1732 });
1733
1734 let content = Content {
1736 operations: vec![
1737 Operation::new("q", vec![]),
1738 Operation::new(
1739 "cm",
1740 vec![
1741 Object::Real(200.0), 0.into(),
1743 0.into(),
1744 Object::Real(100.0), Object::Real(72.0), Object::Real(500.0), ],
1748 ),
1749 Operation::new("Do", vec!["Im1".into()]),
1750 Operation::new("Q", vec![]),
1751 ],
1752 };
1753
1754 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
1755
1756 let page_id = doc.add_object(dictionary! {
1757 "Type" => "Page",
1758 "Parent" => pages_id,
1759 "Contents" => content_id,
1760 "Resources" => resources_id,
1761 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
1762 });
1763
1764 let pages = dictionary! {
1765 "Type" => "Pages",
1766 "Kids" => vec![page_id.into()],
1767 "Count" => 1,
1768 };
1769 doc.objects.insert(pages_id, Object::Dictionary(pages));
1770
1771 let catalog_id = doc.add_object(dictionary! {
1772 "Type" => "Catalog",
1773 "Pages" => pages_id,
1774 });
1775 doc.trailer.set("Root", catalog_id);
1776
1777 let pages = doc.get_pages();
1778 let (&page_num, &page_id) = pages.iter().next().unwrap();
1779
1780 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
1781 assert_eq!(chunks.image_chunks.len(), 1, "Expected 1 image chunk");
1782
1783 let img = &chunks.image_chunks[0];
1784 assert!(
1786 (img.bbox.left_x - 72.0).abs() < 1.0,
1787 "Expected left_x ~72, got {}",
1788 img.bbox.left_x
1789 );
1790 assert!(
1791 (img.bbox.bottom_y - 500.0).abs() < 1.0,
1792 "Expected bottom_y ~500, got {}",
1793 img.bbox.bottom_y
1794 );
1795 assert!(
1796 (img.bbox.right_x - 272.0).abs() < 1.0,
1797 "Expected right_x ~272, got {}",
1798 img.bbox.right_x
1799 );
1800 assert!(
1801 (img.bbox.top_y - 600.0).abs() < 1.0,
1802 "Expected top_y ~600, got {}",
1803 img.bbox.top_y
1804 );
1805 }
1806
1807 #[test]
1808 fn test_pattern_fill_emits_image_not_lines() {
1809 let mut doc = Document::with_version("1.5");
1810 let pages_id = doc.new_object_id();
1811
1812 let img_id = doc.add_object(Stream::new(
1813 dictionary! {
1814 "Type" => "XObject",
1815 "Subtype" => "Image",
1816 "Width" => 200,
1817 "Height" => 100,
1818 "ColorSpace" => "DeviceRGB",
1819 "BitsPerComponent" => 8,
1820 },
1821 vec![2u8; 200],
1822 ));
1823
1824 let pattern_id = doc.add_object(dictionary! {
1825 "Type" => "Pattern",
1826 "PatternType" => 1,
1827 "PaintType" => 1,
1828 "TilingType" => 1,
1829 "BBox" => vec![0.into(), 0.into(), 200.into(), 100.into()],
1830 "XStep" => 200,
1831 "YStep" => 100,
1832 "Resources" => dictionary! {
1833 "XObject" => dictionary! {
1834 "X1" => img_id,
1835 },
1836 },
1837 });
1838
1839 let resources_id = doc.add_object(dictionary! {
1840 "Pattern" => dictionary! {
1841 "P1" => pattern_id,
1842 },
1843 });
1844
1845 let content = Content {
1847 operations: vec![
1848 Operation::new("cs", vec!["Pattern".into()]),
1849 Operation::new("scn", vec!["P1".into()]),
1850 Operation::new(
1851 "re",
1852 vec![
1853 Object::Real(10.0),
1854 Object::Real(20.0),
1855 Object::Real(400.0),
1856 Object::Real(300.0),
1857 ],
1858 ),
1859 Operation::new("f", vec![]),
1860 ],
1861 };
1862
1863 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
1864
1865 let page_id = doc.add_object(dictionary! {
1866 "Type" => "Page",
1867 "Parent" => pages_id,
1868 "Contents" => content_id,
1869 "Resources" => resources_id,
1870 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
1871 });
1872
1873 let pages = dictionary! {
1874 "Type" => "Pages",
1875 "Kids" => vec![page_id.into()],
1876 "Count" => 1,
1877 };
1878 doc.objects.insert(pages_id, Object::Dictionary(pages));
1879
1880 let catalog_id = doc.add_object(dictionary! {
1881 "Type" => "Catalog",
1882 "Pages" => pages_id,
1883 });
1884 doc.trailer.set("Root", catalog_id);
1885
1886 let pages = doc.get_pages();
1887 let (&page_num, &page_id) = pages.iter().next().unwrap();
1888
1889 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
1890 assert_eq!(chunks.image_chunks.len(), 1, "Expected 1 pattern image");
1891 assert!(
1892 chunks.line_chunks.is_empty(),
1893 "Pattern fill must not emit page-edge lines, got {}",
1894 chunks.line_chunks.len()
1895 );
1896
1897 let img = &chunks.image_chunks[0];
1898 assert_eq!(img.index, Some(1));
1899 assert!((img.bbox.left_x - 10.0).abs() < 1.0);
1900 assert!((img.bbox.bottom_y - 20.0).abs() < 1.0);
1901 assert!((img.bbox.right_x - 410.0).abs() < 1.0);
1902 assert!((img.bbox.top_y - 320.0).abs() < 1.0);
1903 }
1904
1905 #[test]
1906 fn test_filled_aa_dots_do_not_emit_lines() {
1907 let mut doc = Document::with_version("1.5");
1911 let pages_id = doc.new_object_id();
1912
1913 let mut ops = vec![Operation::new(
1914 "rg",
1915 vec![Object::Real(0.8), Object::Real(0.7), Object::Real(0.9)],
1916 )];
1917 for i in 0..50 {
1918 let x = 10.0 + (i % 10) as f64;
1919 let y = 10.0 + (i / 10) as f64;
1920 ops.push(Operation::new(
1921 "re",
1922 vec![
1923 Object::Real(x),
1924 Object::Real(y),
1925 Object::Real(1.0),
1926 Object::Real(1.0),
1927 ],
1928 ));
1929 ops.push(Operation::new("f", vec![]));
1930 }
1931 ops.push(Operation::new(
1933 "re",
1934 vec![
1935 Object::Real(50.0),
1936 Object::Real(200.0),
1937 Object::Real(200.0),
1938 Object::Real(1.0),
1939 ],
1940 ));
1941 ops.push(Operation::new("f", vec![]));
1942
1943 let content = Content { operations: ops };
1944 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
1945
1946 let page_id = doc.add_object(dictionary! {
1947 "Type" => "Page",
1948 "Parent" => pages_id,
1949 "Contents" => content_id,
1950 "Resources" => dictionary! {},
1951 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
1952 });
1953
1954 let pages = dictionary! {
1955 "Type" => "Pages",
1956 "Kids" => vec![page_id.into()],
1957 "Count" => 1,
1958 };
1959 doc.objects.insert(pages_id, Object::Dictionary(pages));
1960
1961 let catalog_id = doc.add_object(dictionary! {
1962 "Type" => "Catalog",
1963 "Pages" => pages_id,
1964 });
1965 doc.trailer.set("Root", catalog_id);
1966
1967 let pages = doc.get_pages();
1968 let (&page_num, &page_id) = pages.iter().next().unwrap();
1969 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
1970
1971 assert_eq!(
1972 chunks.line_chunks.len(),
1973 1,
1974 "expected only the thin filled strip as a Line, got {}",
1975 chunks.line_chunks.len()
1976 );
1977 assert!(chunks.line_chunks[0].is_horizontal_line);
1978 assert!(chunks.line_art_chunks.is_empty());
1979 }
1980
1981 #[test]
1982 fn test_form_xobject_recursive() {
1983 let mut doc = Document::with_version("1.5");
1984 let pages_id = doc.new_object_id();
1985
1986 let font_id = doc.add_object(dictionary! {
1988 "Type" => "Font",
1989 "Subtype" => "Type1",
1990 "BaseFont" => "Helvetica",
1991 });
1992
1993 let form_content = Content {
1995 operations: vec![
1996 Operation::new("BT", vec![]),
1997 Operation::new("Tf", vec!["F1".into(), 10.into()]),
1998 Operation::new("Td", vec![0.into(), 0.into()]),
1999 Operation::new("Tj", vec![Object::string_literal("Form Text")]),
2000 Operation::new("ET", vec![]),
2001 ],
2002 };
2003
2004 let form_stream = Stream::new(
2005 dictionary! {
2006 "Type" => "XObject",
2007 "Subtype" => "Form",
2008 "BBox" => vec![0.into(), 0.into(), 200.into(), 50.into()],
2009 "Resources" => dictionary! {
2010 "Font" => dictionary! {
2011 "F1" => font_id,
2012 },
2013 },
2014 },
2015 form_content.encode().unwrap(),
2016 );
2017 let form_id = doc.add_object(form_stream);
2018
2019 let resources_id = doc.add_object(dictionary! {
2020 "Font" => dictionary! {
2021 "F1" => font_id,
2022 },
2023 "XObject" => dictionary! {
2024 "Fm1" => form_id,
2025 },
2026 });
2027
2028 let page_content = Content {
2030 operations: vec![
2031 Operation::new("q", vec![]),
2032 Operation::new(
2033 "cm",
2034 vec![
2035 1.into(),
2036 0.into(),
2037 0.into(),
2038 1.into(),
2039 Object::Real(50.0),
2040 Object::Real(400.0),
2041 ],
2042 ),
2043 Operation::new("Do", vec!["Fm1".into()]),
2044 Operation::new("Q", vec![]),
2045 ],
2046 };
2047
2048 let content_id =
2049 doc.add_object(Stream::new(dictionary! {}, page_content.encode().unwrap()));
2050
2051 let page_id = doc.add_object(dictionary! {
2052 "Type" => "Page",
2053 "Parent" => pages_id,
2054 "Contents" => content_id,
2055 "Resources" => resources_id,
2056 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2057 });
2058
2059 let pages = dictionary! {
2060 "Type" => "Pages",
2061 "Kids" => vec![page_id.into()],
2062 "Count" => 1,
2063 };
2064 doc.objects.insert(pages_id, Object::Dictionary(pages));
2065
2066 let catalog_id = doc.add_object(dictionary! {
2067 "Type" => "Catalog",
2068 "Pages" => pages_id,
2069 });
2070 doc.trailer.set("Root", catalog_id);
2071
2072 let pages = doc.get_pages();
2073 let (&page_num, &page_id) = pages.iter().next().unwrap();
2074
2075 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2076 assert!(
2077 !chunks.text_chunks.is_empty(),
2078 "Expected text from Form XObject"
2079 );
2080 assert!(
2081 chunks.text_chunks[0].value.contains("Form"),
2082 "Expected 'Form' text, got: '{}'",
2083 chunks.text_chunks[0].value
2084 );
2085 }
2086
2087 #[test]
2088 fn test_line_extraction_unified() {
2089 let mut doc = Document::with_version("1.5");
2090 let pages_id = doc.new_object_id();
2091
2092 let content = Content {
2093 operations: vec![
2094 Operation::new("w", vec![Object::Real(1.0)]),
2095 Operation::new("m", vec![72.into(), 400.into()]),
2096 Operation::new("l", vec![500.into(), 400.into()]),
2097 Operation::new("S", vec![]),
2098 ],
2099 };
2100
2101 let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2102
2103 let page_id = doc.add_object(dictionary! {
2104 "Type" => "Page",
2105 "Parent" => pages_id,
2106 "Contents" => content_id,
2107 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2108 });
2109
2110 let pages = dictionary! {
2111 "Type" => "Pages",
2112 "Kids" => vec![page_id.into()],
2113 "Count" => 1,
2114 };
2115 doc.objects.insert(pages_id, Object::Dictionary(pages));
2116
2117 let catalog_id = doc.add_object(dictionary! {
2118 "Type" => "Catalog",
2119 "Pages" => pages_id,
2120 });
2121 doc.trailer.set("Root", catalog_id);
2122
2123 let pages = doc.get_pages();
2124 let (&page_num, &page_id) = pages.iter().next().unwrap();
2125
2126 let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2127 assert_eq!(chunks.line_chunks.len(), 1, "Expected 1 horizontal line");
2128 assert!(chunks.line_chunks[0].is_horizontal_line);
2129 }
2130}