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