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            // Line width
80            "w" => {
81                if let Some(w) = op.operands.first().and_then(get_number) {
82                    line_width = w;
83                }
84            }
85            // Path construction
86            "m"
87                // moveto
88                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                // lineto
100                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                // curveto (cubic Bézier)
119                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                // curveto (initial point replicated)
142                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                // curveto (final point replicated)
164                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                // closepath
186                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                // rectangle
200                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            // Path painting — stroke
232            "S" | "s" => {
233                if op.operator == "s" {
234                    // close and stroke — close first
235                    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                    &current_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            // Fill (also acts as implicit close) — only thin strips are lines
259            "f" | "F" | "f*" => {
260                if let Some(mut line) =
261                    thin_filled_strip_line(&current_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            // Fill and stroke
272            "B" | "B*" | "b" | "b*" => {
273                classify_path(
274                    &current_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            // End path without painting
286            "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/// A segment in a path being constructed.
299#[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
320/// Transform a point through the current CTM.
321fn 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
326/// Classify a completed path as line chunks or line art.
327fn 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        // Try to classify individual segments as line chunks
349        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                    // Expand bbox by half the line width
371                    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    // Check for rectangle (4 lines forming a box)
410    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    // Complex path → LineArtChunk
418    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
475/// Try to classify 4 line segments as a rectangle.
476fn 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    // Determine if it's a thin rectangle (line). Equidimensional fills/boxes
506    // are regions, not geometric rules.
507    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
535/// A filled path is a Line only when the painted region is a thin strip.
536fn 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        // Rectangle should be classified as a square-like shape
703        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![]), // close-and-stroke
714        ];
715        let (doc, page_num, pid) = create_doc_with_content(ops);
716        let (lines, arts) = extract_line_chunks(&doc, page_num, pid).unwrap();
717        // Should have classified the triangle/shape
718        assert!(!lines.is_empty() || !arts.is_empty());
719    }
720}