Skip to main content

edgeparse_core/pdf/
line_extractor.rs

1//! PDF line segment extraction — extract stroked/filled paths as LineChunks.
2//!
3//! Parses content stream path operators (m, l, c, re, S, s, f, h) and
4//! classifies resulting paths as horizontal lines, vertical lines, or shapes.
5
6use 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
13/// Minimum line width to consider a path as a line segment (in points).
14const MIN_LINE_WIDTH: f64 = 0.1;
15
16/// Aspect ratio threshold: if width/height > this, it's horizontal.
17const LINE_ASPECT_RATIO: f64 = 3.0;
18
19/// Maximum thickness for a line to be classified as a line (vs a rectangle).
20const MAX_LINE_THICKNESS: f64 = 10.0;
21
22/// Extract line segments and line art from a PDF page's content stream.
23pub 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    // Current path state
59    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            // Graphics state
70            "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            // Line width
81            "w" => {
82                if let Some(w) = op.operands.first().and_then(get_number) {
83                    line_width = w;
84                }
85            }
86            // Path construction
87            "m" => {
88                // moveto
89                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                // lineto
102                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                // curveto (cubic Bézier)
122                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                // curveto (initial point replicated)
146                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                // curveto (final point replicated)
169                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                // closepath
192                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                // rectangle
206                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            // Path painting — stroke
239            "S" | "s" => {
240                if op.operator == "s" {
241                    // close and stroke — close first
242                    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                    &current_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            // Fill (also acts as implicit close) — only thin strips are lines
266            "f" | "F" | "f*" => {
267                if let Some(mut line) =
268                    thin_filled_strip_line(&current_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            // Fill and stroke
279            "B" | "B*" | "b" | "b*" => {
280                classify_path(
281                    &current_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            // End path without painting
293            "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/// A segment in a path being constructed.
306#[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
327/// Transform a point through the current CTM.
328fn 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
333/// Classify a completed path as line chunks or line art.
334fn 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        // Try to classify individual segments as line chunks
356        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                    // Expand bbox by half the line width
378                    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    // Check for rectangle (4 lines forming a box)
417    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    // Complex path → LineArtChunk
425    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
482/// Try to classify 4 line segments as a rectangle.
483fn 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    // Determine if it's a thin rectangle (line). Equidimensional fills/boxes
513    // are regions, not geometric rules.
514    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
542/// A filled path is a Line only when the painted region is a thin strip.
543fn 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        // Rectangle should be classified as a square-like shape
710        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![]), // close-and-stroke
721        ];
722        let (doc, page_num, pid) = create_doc_with_content(ops);
723        let (lines, arts) = extract_line_chunks(&doc, page_num, pid).unwrap();
724        // Should have classified the triangle/shape
725        assert!(!lines.is_empty() || !arts.is_empty());
726    }
727}