1use lopdf::{Document, Object};
7
8use crate::models::bbox::{BoundingBox, Vertex};
9use crate::models::chunks::{LineArtChunk, LineChunk};
10use crate::pdf::graphics_state::GraphicsStateStack;
11use crate::EdgePdfError;
12
13const MIN_LINE_WIDTH: f64 = 0.1;
15
16const LINE_ASPECT_RATIO: f64 = 3.0;
18
19const MAX_LINE_THICKNESS: f64 = 10.0;
21
22pub fn extract_line_chunks(
24 doc: &Document,
25 page_number: u32,
26 page_id: lopdf::ObjectId,
27) -> Result<(Vec<LineChunk>, Vec<LineArtChunk>), EdgePdfError> {
28 let page_dict = doc
29 .get_object(page_id)
30 .map_err(|e| EdgePdfError::PipelineError {
31 stage: 1,
32 message: format!("Failed to get page {}: {}", page_number, e),
33 })?
34 .as_dict()
35 .map_err(|e| EdgePdfError::PipelineError {
36 stage: 1,
37 message: format!("Page {} is not a dictionary: {}", page_number, e),
38 })?;
39
40 let content_bytes = crate::pdf::text_extractor::get_page_content(doc, page_dict)?;
41 if content_bytes.is_empty() {
42 return Ok((Vec::new(), Vec::new()));
43 }
44
45 let content = lopdf::content::Content::decode(&content_bytes).map_err(|e| {
46 EdgePdfError::PipelineError {
47 stage: 1,
48 message: format!(
49 "Failed to decode content stream for page {}: {}",
50 page_number, e
51 ),
52 }
53 })?;
54
55 let mut gs_stack = GraphicsStateStack::default();
56 let mut line_width: f64 = 1.0;
57
58 let mut current_path: Vec<PathSegment> = Vec::new();
60 let mut subpath_start: Option<(f64, f64)> = None;
61 let mut current_point: Option<(f64, f64)> = None;
62
63 let mut line_chunks: Vec<LineChunk> = Vec::new();
64 let mut line_art_chunks: Vec<LineArtChunk> = Vec::new();
65 let mut line_index = 0u32;
66
67 for op in &content.operations {
68 match op.operator.as_str() {
69 "q" => gs_stack.save(),
71 "Q" => gs_stack.restore(),
72 "cm"
73 if op.operands.len() >= 6 => {
74 let vals: Vec<f64> = op.operands.iter().filter_map(get_number).collect();
75 if vals.len() >= 6 {
76 gs_stack.concat_ctm(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]);
77 }
78 }
79 "w" => {
81 if let Some(w) = op.operands.first().and_then(get_number) {
82 line_width = w;
83 }
84 }
85 "m"
87 if op.operands.len() >= 2 => {
89 if let (Some(x), Some(y)) = (
90 op.operands.first().and_then(get_number),
91 op.operands.get(1).and_then(get_number),
92 ) {
93 let (tx, ty) = transform_point(&gs_stack, x, y);
94 subpath_start = Some((tx, ty));
95 current_point = Some((tx, ty));
96 }
97 }
98 "l"
99 if op.operands.len() >= 2 => {
101 if let (Some(x), Some(y)) = (
102 op.operands.first().and_then(get_number),
103 op.operands.get(1).and_then(get_number),
104 ) {
105 let (tx, ty) = transform_point(&gs_stack, x, y);
106 if let Some((cx, cy)) = current_point {
107 current_path.push(PathSegment::Line {
108 x1: cx,
109 y1: cy,
110 x2: tx,
111 y2: ty,
112 });
113 }
114 current_point = Some((tx, ty));
115 }
116 }
117 "c"
118 if op.operands.len() >= 6 => {
120 let vals: Vec<f64> = op.operands.iter().filter_map(get_number).collect();
121 if vals.len() >= 6 {
122 let (tx, ty) = transform_point(&gs_stack, vals[4], vals[5]);
123 if let Some((cx, cy)) = current_point {
124 let (cp1x, cp1y) = transform_point(&gs_stack, vals[0], vals[1]);
125 let (cp2x, cp2y) = transform_point(&gs_stack, vals[2], vals[3]);
126 current_path.push(PathSegment::Curve {
127 x1: cx,
128 y1: cy,
129 cp1x,
130 cp1y,
131 cp2x,
132 cp2y,
133 x2: tx,
134 y2: ty,
135 });
136 }
137 current_point = Some((tx, ty));
138 }
139 }
140 "v"
141 if op.operands.len() >= 4 => {
143 let vals: Vec<f64> = op.operands.iter().filter_map(get_number).collect();
144 if vals.len() >= 4 {
145 let (tx, ty) = transform_point(&gs_stack, vals[2], vals[3]);
146 if let Some((cx, cy)) = current_point {
147 let (cp2x, cp2y) = transform_point(&gs_stack, vals[0], vals[1]);
148 current_path.push(PathSegment::Curve {
149 x1: cx,
150 y1: cy,
151 cp1x: cx,
152 cp1y: cy,
153 cp2x,
154 cp2y,
155 x2: tx,
156 y2: ty,
157 });
158 }
159 current_point = Some((tx, ty));
160 }
161 }
162 "y"
163 if op.operands.len() >= 4 => {
165 let vals: Vec<f64> = op.operands.iter().filter_map(get_number).collect();
166 if vals.len() >= 4 {
167 let (tx, ty) = transform_point(&gs_stack, vals[2], vals[3]);
168 if let Some((cx, cy)) = current_point {
169 let (cp1x, cp1y) = transform_point(&gs_stack, vals[0], vals[1]);
170 current_path.push(PathSegment::Curve {
171 x1: cx,
172 y1: cy,
173 cp1x,
174 cp1y,
175 cp2x: tx,
176 cp2y: ty,
177 x2: tx,
178 y2: ty,
179 });
180 }
181 current_point = Some((tx, ty));
182 }
183 }
184 "h" => {
185 if let (Some((sx, sy)), Some((cx, cy))) = (subpath_start, current_point) {
187 if (sx - cx).abs() > 0.01 || (sy - cy).abs() > 0.01 {
188 current_path.push(PathSegment::Line {
189 x1: cx,
190 y1: cy,
191 x2: sx,
192 y2: sy,
193 });
194 }
195 current_point = subpath_start;
196 }
197 }
198 "re"
199 if op.operands.len() >= 4 => {
201 let vals: Vec<f64> = op.operands.iter().filter_map(get_number).collect();
202 if vals.len() >= 4 {
203 let (x, y, w, h) = (vals[0], vals[1], vals[2], vals[3]);
204 let (x1, y1) = transform_point(&gs_stack, x, y);
205 let (x2, y2) = transform_point(&gs_stack, x + w, y);
206 let (x3, y3) = transform_point(&gs_stack, x + w, y + h);
207 let (x4, y4) = transform_point(&gs_stack, x, y + h);
208 current_path.push(PathSegment::Line { x1, y1, x2, y2 });
209 current_path.push(PathSegment::Line {
210 x1: x2,
211 y1: y2,
212 x2: x3,
213 y2: y3,
214 });
215 current_path.push(PathSegment::Line {
216 x1: x3,
217 y1: y3,
218 x2: x4,
219 y2: y4,
220 });
221 current_path.push(PathSegment::Line {
222 x1: x4,
223 y1: y4,
224 x2: x1,
225 y2: y1,
226 });
227 subpath_start = Some((x1, y1));
228 current_point = Some((x1, y1));
229 }
230 }
231 "S" | "s" => {
233 if op.operator == "s" {
234 if let (Some((sx, sy)), Some((cx, cy))) = (subpath_start, current_point) {
236 if (sx - cx).abs() > 0.01 || (sy - cy).abs() > 0.01 {
237 current_path.push(PathSegment::Line {
238 x1: cx,
239 y1: cy,
240 x2: sx,
241 y2: sy,
242 });
243 }
244 }
245 }
246 classify_path(
247 ¤t_path,
248 line_width,
249 page_number,
250 &mut line_chunks,
251 &mut line_art_chunks,
252 &mut line_index,
253 );
254 current_path.clear();
255 subpath_start = None;
256 current_point = None;
257 }
258 "f" | "F" | "f*" => {
260 if let Some(mut line) =
261 thin_filled_strip_line(¤t_path, line_width, page_number)
262 {
263 line_index += 1;
264 line.index = Some(line_index);
265 line_chunks.push(line);
266 }
267 current_path.clear();
268 subpath_start = None;
269 current_point = None;
270 }
271 "B" | "B*" | "b" | "b*" => {
273 classify_path(
274 ¤t_path,
275 line_width,
276 page_number,
277 &mut line_chunks,
278 &mut line_art_chunks,
279 &mut line_index,
280 );
281 current_path.clear();
282 subpath_start = None;
283 current_point = None;
284 }
285 "n" => {
287 current_path.clear();
288 subpath_start = None;
289 current_point = None;
290 }
291 _ => {}
292 }
293 }
294
295 Ok((line_chunks, line_art_chunks))
296}
297
298#[derive(Debug, Clone)]
300enum PathSegment {
301 Line {
302 x1: f64,
303 y1: f64,
304 x2: f64,
305 y2: f64,
306 },
307 #[allow(dead_code)]
308 Curve {
309 x1: f64,
310 y1: f64,
311 cp1x: f64,
312 cp1y: f64,
313 cp2x: f64,
314 cp2y: f64,
315 x2: f64,
316 y2: f64,
317 },
318}
319
320fn transform_point(gs_stack: &GraphicsStateStack, x: f64, y: f64) -> (f64, f64) {
322 let ctm = &gs_stack.current.ctm;
323 ctm.transform_point(x, y)
324}
325
326fn classify_path(
328 segments: &[PathSegment],
329 line_width: f64,
330 page_number: u32,
331 line_chunks: &mut Vec<LineChunk>,
332 line_art_chunks: &mut Vec<LineArtChunk>,
333 index: &mut u32,
334) {
335 if segments.is_empty() {
336 return;
337 }
338
339 if line_width < MIN_LINE_WIDTH {
340 return;
341 }
342
343 let has_curves = segments
344 .iter()
345 .any(|s| matches!(s, PathSegment::Curve { .. }));
346
347 if !has_curves && segments.len() <= 4 {
348 let mut classified_lines = Vec::new();
350 for seg in segments {
351 if let PathSegment::Line { x1, y1, x2, y2 } = seg {
352 let dx = (x2 - x1).abs();
353 let dy = (y2 - y1).abs();
354 let length = (dx * dx + dy * dy).sqrt();
355
356 if length < MIN_LINE_WIDTH {
357 continue;
358 }
359
360 let is_horizontal = dy < MAX_LINE_THICKNESS && dx > dy * LINE_ASPECT_RATIO;
361 let is_vertical = dx < MAX_LINE_THICKNESS && dy > dx * LINE_ASPECT_RATIO;
362
363 if is_horizontal || is_vertical {
364 *index += 1;
365 let min_x = x1.min(*x2);
366 let max_x = x1.max(*x2);
367 let min_y = y1.min(*y2);
368 let max_y = y1.max(*y2);
369
370 let half_w = line_width / 2.0;
372 let bbox = BoundingBox::new(
373 Some(page_number),
374 min_x - if is_vertical { half_w } else { 0.0 },
375 min_y - if is_horizontal { half_w } else { 0.0 },
376 max_x + if is_vertical { half_w } else { 0.0 },
377 max_y + if is_horizontal { half_w } else { 0.0 },
378 );
379
380 classified_lines.push(LineChunk {
381 bbox,
382 index: Some(*index),
383 level: None,
384 start: Vertex {
385 x: *x1,
386 y: *y1,
387 radius: 0.0,
388 },
389 end: Vertex {
390 x: *x2,
391 y: *y2,
392 radius: 0.0,
393 },
394 width: line_width,
395 is_horizontal_line: is_horizontal,
396 is_vertical_line: is_vertical,
397 is_square: false,
398 });
399 }
400 }
401 }
402
403 if !classified_lines.is_empty() {
404 line_chunks.extend(classified_lines);
405 return;
406 }
407 }
408
409 if !has_curves && segments.len() == 4 {
411 if let Some(rect) = try_classify_rectangle(segments, line_width, page_number, index) {
412 line_chunks.push(rect);
413 return;
414 }
415 }
416
417 if segments.len() >= 2 {
419 let mut art_lines = Vec::new();
420 let mut min_x = f64::MAX;
421 let mut min_y = f64::MAX;
422 let mut max_x = f64::MIN;
423 let mut max_y = f64::MIN;
424
425 for seg in segments {
426 let (sx, sy, ex, ey) = match seg {
427 PathSegment::Line { x1, y1, x2, y2 } => (*x1, *y1, *x2, *y2),
428 PathSegment::Curve { x1, y1, x2, y2, .. } => (*x1, *y1, *x2, *y2),
429 };
430 min_x = min_x.min(sx).min(ex);
431 min_y = min_y.min(sy).min(ey);
432 max_x = max_x.max(sx).max(ex);
433 max_y = max_y.max(sy).max(ey);
434
435 *index += 1;
436 let lbbox = BoundingBox::new(
437 Some(page_number),
438 sx.min(ex),
439 sy.min(ey),
440 sx.max(ex),
441 sy.max(ey),
442 );
443 art_lines.push(LineChunk {
444 bbox: lbbox,
445 index: Some(*index),
446 level: None,
447 start: Vertex {
448 x: sx,
449 y: sy,
450 radius: 0.0,
451 },
452 end: Vertex {
453 x: ex,
454 y: ey,
455 radius: 0.0,
456 },
457 width: line_width,
458 is_horizontal_line: false,
459 is_vertical_line: false,
460 is_square: false,
461 });
462 }
463
464 *index += 1;
465 let art_bbox = BoundingBox::new(Some(page_number), min_x, min_y, max_x, max_y);
466 line_art_chunks.push(LineArtChunk {
467 bbox: art_bbox,
468 index: Some(*index),
469 level: None,
470 line_chunks: art_lines,
471 });
472 }
473}
474
475fn try_classify_rectangle(
477 segments: &[PathSegment],
478 _line_width: f64,
479 page_number: u32,
480 index: &mut u32,
481) -> Option<LineChunk> {
482 let mut min_x = f64::MAX;
483 let mut min_y = f64::MAX;
484 let mut max_x = f64::MIN;
485 let mut max_y = f64::MIN;
486
487 for seg in segments {
488 if let PathSegment::Line { x1, y1, x2, y2 } = seg {
489 min_x = min_x.min(*x1).min(*x2);
490 min_y = min_y.min(*y1).min(*y2);
491 max_x = max_x.max(*x1).max(*x2);
492 max_y = max_y.max(*y1).max(*y2);
493 } else {
494 return None;
495 }
496 }
497
498 let w = max_x - min_x;
499 let h = max_y - min_y;
500
501 if w < MIN_LINE_WIDTH || h < MIN_LINE_WIDTH {
502 return None;
503 }
504
505 let is_horizontal = w > h * LINE_ASPECT_RATIO;
508 let is_vertical = h > w * LINE_ASPECT_RATIO;
509 if !is_horizontal && !is_vertical {
510 return None;
511 }
512
513 *index += 1;
514 Some(LineChunk {
515 bbox: BoundingBox::new(Some(page_number), min_x, min_y, max_x, max_y),
516 index: Some(*index),
517 level: None,
518 start: Vertex {
519 x: min_x,
520 y: min_y,
521 radius: 0.0,
522 },
523 end: Vertex {
524 x: max_x,
525 y: max_y,
526 radius: 0.0,
527 },
528 width: w.min(h),
529 is_horizontal_line: is_horizontal,
530 is_vertical_line: is_vertical,
531 is_square: false,
532 })
533}
534
535fn thin_filled_strip_line(
537 segments: &[PathSegment],
538 _line_width: f64,
539 page_number: u32,
540) -> Option<LineChunk> {
541 let mut min_x = f64::MAX;
542 let mut min_y = f64::MAX;
543 let mut max_x = f64::MIN;
544 let mut max_y = f64::MIN;
545
546 for seg in segments {
547 let (sx, sy, ex, ey) = match seg {
548 PathSegment::Line { x1, y1, x2, y2 } => (*x1, *y1, *x2, *y2),
549 PathSegment::Curve { x1, y1, x2, y2, .. } => (*x1, *y1, *x2, *y2),
550 };
551 min_x = min_x.min(sx).min(ex);
552 min_y = min_y.min(sy).min(ey);
553 max_x = max_x.max(sx).max(ex);
554 max_y = max_y.max(sy).max(ey);
555 }
556
557 if !min_x.is_finite() {
558 return None;
559 }
560
561 let w = max_x - min_x;
562 let h = max_y - min_y;
563 if w < MIN_LINE_WIDTH || h < MIN_LINE_WIDTH {
564 return None;
565 }
566
567 let is_horizontal = h <= MAX_LINE_THICKNESS && w > h * LINE_ASPECT_RATIO;
568 let is_vertical = w <= MAX_LINE_THICKNESS && h > w * LINE_ASPECT_RATIO;
569 if !is_horizontal && !is_vertical {
570 return None;
571 }
572
573 let (sx, sy, ex, ey) = if is_horizontal {
574 (min_x, (min_y + max_y) * 0.5, max_x, (min_y + max_y) * 0.5)
575 } else {
576 ((min_x + max_x) * 0.5, min_y, (min_x + max_x) * 0.5, max_y)
577 };
578
579 Some(LineChunk {
580 bbox: BoundingBox::new(Some(page_number), min_x, min_y, max_x, max_y),
581 index: None,
582 level: None,
583 start: Vertex {
584 x: sx,
585 y: sy,
586 radius: 0.0,
587 },
588 end: Vertex {
589 x: ex,
590 y: ey,
591 radius: 0.0,
592 },
593 width: w.min(h),
594 is_horizontal_line: is_horizontal,
595 is_vertical_line: is_vertical,
596 is_square: false,
597 })
598}
599
600fn get_number(obj: &Object) -> Option<f64> {
601 match obj {
602 Object::Integer(i) => Some(*i as f64),
603 Object::Real(f) => Some(*f),
604 _ => None,
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use lopdf::{content::Content, content::Operation, dictionary, Stream};
612
613 fn create_doc_with_content(operations: Vec<Operation>) -> (Document, u32, lopdf::ObjectId) {
614 let mut doc = Document::with_version("1.5");
615 let pages_id = doc.new_object_id();
616
617 let content = Content { operations };
618 let encoded = content.encode().unwrap();
619 let content_id = doc.add_object(Stream::new(dictionary! {}, encoded));
620
621 let page_id = doc.add_object(dictionary! {
622 "Type" => "Page",
623 "Parent" => pages_id,
624 "Contents" => content_id,
625 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
626 });
627
628 let pages = dictionary! {
629 "Type" => "Pages",
630 "Kids" => vec![page_id.into()],
631 "Count" => 1,
632 };
633 doc.objects.insert(pages_id, Object::Dictionary(pages));
634
635 let catalog_id = doc.add_object(dictionary! {
636 "Type" => "Catalog",
637 "Pages" => pages_id,
638 });
639 doc.trailer.set("Root", catalog_id);
640
641 let pages_map = doc.get_pages();
642 let (&page_num, &pid) = pages_map.iter().next().unwrap();
643 (doc, page_num, pid)
644 }
645
646 #[test]
647 fn test_empty_page_no_lines() {
648 let (doc, page_num, pid) = create_doc_with_content(vec![]);
649 let (lines, arts) = extract_line_chunks(&doc, page_num, pid).unwrap();
650 assert!(lines.is_empty());
651 assert!(arts.is_empty());
652 }
653
654 #[test]
655 fn test_horizontal_line() {
656 let ops = vec![
657 Operation::new("w", vec![Object::Real(1.0)]),
658 Operation::new("m", vec![72.into(), 400.into()]),
659 Operation::new("l", vec![500.into(), 400.into()]),
660 Operation::new("S", vec![]),
661 ];
662 let (doc, page_num, pid) = create_doc_with_content(ops);
663 let (lines, arts) = extract_line_chunks(&doc, page_num, pid).unwrap();
664 assert_eq!(lines.len(), 1);
665 assert!(lines[0].is_horizontal_line);
666 assert!(!lines[0].is_vertical_line);
667 assert!(arts.is_empty());
668 }
669
670 #[test]
671 fn test_vertical_line() {
672 let ops = vec![
673 Operation::new("w", vec![Object::Real(1.0)]),
674 Operation::new("m", vec![200.into(), 100.into()]),
675 Operation::new("l", vec![200.into(), 700.into()]),
676 Operation::new("S", vec![]),
677 ];
678 let (doc, page_num, pid) = create_doc_with_content(ops);
679 let (lines, arts) = extract_line_chunks(&doc, page_num, pid).unwrap();
680 assert_eq!(lines.len(), 1);
681 assert!(!lines[0].is_horizontal_line);
682 assert!(lines[0].is_vertical_line);
683 }
684
685 #[test]
686 fn test_rectangle() {
687 let ops = vec![
688 Operation::new("w", vec![Object::Real(1.0)]),
689 Operation::new(
690 "re",
691 vec![
692 Object::Real(100.0),
693 Object::Real(200.0),
694 Object::Real(300.0),
695 Object::Real(400.0),
696 ],
697 ),
698 Operation::new("S", vec![]),
699 ];
700 let (doc, page_num, pid) = create_doc_with_content(ops);
701 let (lines, _arts) = extract_line_chunks(&doc, page_num, pid).unwrap();
702 assert!(!lines.is_empty());
704 }
705
706 #[test]
707 fn test_close_and_stroke() {
708 let ops = vec![
709 Operation::new("w", vec![Object::Real(1.0)]),
710 Operation::new("m", vec![72.into(), 400.into()]),
711 Operation::new("l", vec![500.into(), 400.into()]),
712 Operation::new("l", vec![500.into(), 410.into()]),
713 Operation::new("s", vec![]), ];
715 let (doc, page_num, pid) = create_doc_with_content(ops);
716 let (lines, arts) = extract_line_chunks(&doc, page_num, pid).unwrap();
717 assert!(!lines.is_empty() || !arts.is_empty());
719 }
720}