Skip to main content

edgeparse_core/pdf/
chunk_parser.rs

1//! Unified PDF content stream parser — matches the reference ChunkParser architecture.
2//!
3//! Single-pass content stream walker that produces text, image, and line chunks
4//! with shared graphics state. Handles:
5//! - Text operators (BT/ET/Tf/Td/Tm/Tj/TJ/etc.)
6//! - Image extraction via `Do` operator (XObject images with CTM-based bbox)
7//! - Form XObject recursive processing via `Do` operator
8//! - Inline images (BI/ID/EI)
9//! - Path/line operators (m/l/c/re/S/f/B/etc.)
10//! - Graphics state (q/Q/cm/gs)
11//! - Color operators (g/rg/k/cs/sc/etc.)
12//! - Marked content (BMC/BDC/EMC)
13
14use 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
23/// Maximum recursion depth for Form XObject processing (prevents infinite loops).
24const MAX_FORM_RECURSION_DEPTH: u32 = 10;
25
26/// Minimum line width to consider a path segment (in points).
27const MIN_LINE_WIDTH: f64 = 0.1;
28
29/// Aspect ratio threshold: width/height > this means horizontal line.
30const LINE_ASPECT_RATIO: f64 = 3.0;
31
32/// Maximum thickness for a line (vs rectangle classification).
33const MAX_LINE_THICKNESS: f64 = 10.0;
34
35/// How a path was painted — fill vs stroke change what counts as a Line.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum PathPaint {
38    /// `S` / `s` — path edges are stroked geometry (rules, box borders).
39    Stroke,
40    /// `f` / `f*` — path fills a region; only thin strips are rules.
41    Fill,
42}
43
44/// All chunks extracted from a single page.
45#[derive(Debug, Default)]
46pub struct PageChunks {
47    /// Text chunks with position and font info
48    pub text_chunks: Vec<TextChunk>,
49    /// Image chunks with CTM-based bounding boxes
50    pub image_chunks: Vec<ImageChunk>,
51    /// Line segments (horizontal/vertical lines, rectangles)
52    pub line_chunks: Vec<LineChunk>,
53    /// Vector graphics (complex paths)
54    pub line_art_chunks: Vec<LineArtChunk>,
55}
56
57/// Extract all chunks from a single page in one content stream pass.
58pub 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    // Get content stream(s)
79    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    // Parse content stream operations
85    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    // Resolve the Resources dictionary for this page (needed for Do/gs operators)
94    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
102/// Resolve the /Resources dictionary for a page, following references.
103fn 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
113/// Internal state for the unified chunk parser — equivalent to the reference ChunkParser.
114struct ChunkParserState {
115    page_number: u32,
116    font_cache: FontCache,
117    gs_stack: GraphicsStateStack,
118
119    // Chunk accumulators
120    text_chunks: Vec<TextChunk>,
121    image_chunks: Vec<ImageChunk>,
122    line_chunks: Vec<LineChunk>,
123    line_art_chunks: Vec<LineArtChunk>,
124
125    // Indices
126    text_index: usize,
127    image_index: u32,
128    line_index: u32,
129
130    // Marked content tracking
131    mcid_stack: Vec<Option<i64>>,
132
133    // Path construction state
134    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    /// Process all content stream operations — the core parser loop.
175    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                // ── Marked content operators ──
185                "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                // ── Graphics state ──
197                "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                    // Extended Graphics State — look up in /ExtGState resources
214                    if let Some(name) = op.operands.first().and_then(obj_name_bytes) {
215                        self.apply_ext_gstate(doc, resources, &name);
216                    }
217                }
218
219                // ── Text state operators ──
220                "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                // ── Text positioning ──
266                "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                // ── Text showing operators → TextChunk ──
300                "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                // ── Color operators ──
332                "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                        // PDF spec 8.6.5.3: reset color to default for new space
388                        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                        // PDF spec 8.6.5.3: reset color to default for new space
398                        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                    // Pattern color spaces take a name operand (e.g. `/P6 scn`).
404                    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                // ── Line width ──
435                "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                // ── Path construction ──
442                "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                // ── Path painting ──
606                "S" => {
607                    self.classify_and_emit_path(PathPaint::Stroke);
608                }
609                "s" => {
610                    // close and stroke
611                    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                        // Pattern-painted image — drop path so it is not
617                        // misclassified as page-edge line art.
618                    } 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                        // filled+stroked with a pattern fill
628                    } else {
629                        // Stroke edges define geometry; fill is incidental paint.
630                        self.classify_and_emit_path(PathPaint::Stroke);
631                    }
632                }
633                "n" => {
634                    // End path without painting
635                    self.current_path.clear();
636                    self.subpath_start = None;
637                    self.current_point = None;
638                }
639
640                // ── XObject (Do) — Image and Form XObject handling ──
641                "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                // ── Inline image (BI/ID/EI) ──
648                // lopdf parses BI inline images as a special operation;
649                // the operator is "BI" with the image dict + data as operands.
650                // We create an ImageChunk using the current CTM.
651                "BI" => {
652                    self.emit_inline_image();
653                }
654
655                _ => {
656                    // Ignore unknown/unhandled operators
657                }
658            }
659        }
660    }
661
662    // ── Text chunk creation ──
663
664    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    // ── Image handling ──
722
723    /// Handle `Do` operator — dispatches to image or form XObject processing.
724    fn handle_do_operator(
725        &mut self,
726        doc: &Document,
727        resources: &Dictionary,
728        name_bytes: &[u8],
729        recursion_depth: u32,
730    ) {
731        // Look up the XObject in /Resources/XObject
732        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                // Image XObject → create ImageChunk with CTM-based bbox
765                self.emit_image_from_ctm();
766            }
767            Some("Form") => {
768                // Form XObject → recursive content stream processing
769                if recursion_depth < MAX_FORM_RECURSION_DEPTH {
770                    self.process_form_xobject(doc, &stream, resources, recursion_depth);
771                }
772            }
773            _ => {}
774        }
775    }
776
777    /// Create an ImageChunk using the current CTM to compute position.
778    /// Image occupies [0,0] to [1,1] in user space before CTM transform.
779    fn emit_image_from_ctm(&mut self) {
780        let ctm = &self.gs_stack.current.ctm;
781
782        // Transform the image unit square corners through CTM
783        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    /// Emit an ImageChunk with an explicit bbox and optional forced index.
797    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        // Skip degenerate images
807        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    /// If the current fill color is a tiling Pattern that paints an Image
828    /// XObject, emit that image with the current path's bounding box and clear
829    /// the path. Returns true when handled (caller must not emit line art).
830    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            // Pattern fill without a usable image — swallow the path to avoid
851            // page-edge line noise from full-page pattern rects.
852            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    /// Create an ImageChunk for an inline image (BI/ID/EI).
867    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    /// Process a Form XObject — recursively parse its content stream.
881    fn process_form_xobject(
882        &mut self,
883        doc: &Document,
884        stream: &lopdf::Stream,
885        parent_resources: &Dictionary,
886        recursion_depth: u32,
887    ) {
888        // Get the form's /Matrix (default identity)
889        let form_matrix = get_form_matrix(doc, &stream.dict);
890
891        // Concatenate form matrix with current CTM (like the reference implementation: xFormGraphicsState.getCTM().concatenate(matrix))
892        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        // Resolve form's own resources, falling back to parent
897        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        // Decompress the form's content stream
909        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        // Parse the form's content stream
927        if let Ok(content) = Content::decode(&form_content) {
928            // Resolve fonts from form's resources and merge with page fonts
929            let form_font_cache = resolve_form_fonts(doc, &form_resources);
930            let mut merged_cache = FontCache::default();
931            // Copy page fonts first
932            for (name, font) in self.font_cache.iter() {
933                merged_cache.insert(name.clone(), font.clone());
934            }
935            // Override with form fonts
936            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    // ── Extended Graphics State ──
954
955    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        // Apply relevant properties from ExtGState
978        // /Font — set font and size
979        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        // /LW — line width
994        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    // ── Path classification ──
1002
1003    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        // Fill paints a region. Only a thin strip is a geometric rule (table
1027        // border drawn as a 1pt filled rect). Equidimensional fills — including
1028        // Skia soft-circle AA as thousands of 1×1 `re f` — are decoration, not
1029        // lines. Emitting their path edges floods LineChunks and blows JSON.
1030        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        // Try to classify individual segments as line chunks
1043        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        // Rectangle classification (4 line segments forming a box)
1101        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        // Complex path → LineArtChunk
1112        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    // ── Helpers ──
1168
1169    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
1178// ═══════════════════════════════════════════════════════════════════
1179// Helper functions (shared with text_extractor for backward compat)
1180// ═══════════════════════════════════════════════════════════════════
1181
1182/// Create a TextChunk from raw text bytes — same logic as text_extractor.
1183fn 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    // Compute TRM_after (text rendering matrix after text advancement)
1240    let trm_after = state.current.text_rendering_matrix();
1241
1242    // Use font ascent/descent from font descriptor (glyph-space units, per-mille).
1243    let ascent = font.ascent;
1244    let descent = font.descent;
1245
1246    // TRM matrix: a=scaleX, b=shearY, c=shearX, d=scaleY, e=translateX, f=translateY
1247    let trm_before = &trm;
1248
1249    // The reference bbox formula with 4 branches based on text direction/orientation.
1250    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                // The reference veraPDF stores colors as the reference implementation float (f32), then serializes
1312                // via double's toString(), giving full f64 representation of the f32 value.
1313                // We replicate: parse as f64 (from lopdf) → round to f32 → back to f64.
1314                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/// A segment in a path being constructed.
1348#[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
1369/// Try to classify 4 line segments as a rectangle.
1370fn 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    // Filled/stroked boxes and AA dots are regions, not geometric rules.
1401    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
1426/// A filled path is a Line only when the painted region is a thin strip
1427/// (one axis ≪ the other). Used for table rules drawn as filled 1pt rects.
1428fn 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
1492/// Get the /Matrix from a Form XObject dictionary (defaults to identity).
1493fn 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
1516/// Resolve fonts from a resources dictionary (for Form XObjects).
1517fn 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
1541// ── Utility functions ──
1542
1543fn 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
1580/// Extract raw name bytes from a PDF Name object.
1581fn 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
1598/// Default color for a given color space component count (PDF spec 8.6.5.3).
1599fn default_color_for_space(components: u8) -> Vec<f64> {
1600    match components {
1601        0 => Vec::new(),               // Pattern: no numeric default
1602        4 => vec![0.0, 0.0, 0.0, 1.0], // CMYK: default black
1603        3 => vec![0.0, 0.0, 0.0],      // RGB: default black
1604        _ => vec![0.0],                // Gray: default black
1605    }
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
1615/// Axis-aligned bbox of a path in page space.
1616fn 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        // Create image XObject
1715        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        // Content stream: scale image and place at (72, 500)
1735        let content = Content {
1736            operations: vec![
1737                Operation::new("q", vec![]),
1738                Operation::new(
1739                    "cm",
1740                    vec![
1741                        Object::Real(200.0), // a: width
1742                        0.into(),
1743                        0.into(),
1744                        Object::Real(100.0), // d: height
1745                        Object::Real(72.0),  // e: x position
1746                        Object::Real(500.0), // f: y position
1747                    ],
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        // Image should be at (72, 500) with size (200, 100)
1785        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        // Pattern fill a rectangle — Skia/Penpot style page paint.
1846        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        // Skia soft shapes are often thousands of 1×1 filled rects. Those are
1908        // region paint, not geometric rules. A thin filled strip must still
1909        // become a single horizontal Line (table rule drawn as fill).
1910        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        // Thin horizontal rule as filled rect
1932        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        // Create a font
1987        let font_id = doc.add_object(dictionary! {
1988            "Type" => "Font",
1989            "Subtype" => "Type1",
1990            "BaseFont" => "Helvetica",
1991        });
1992
1993        // Create a Form XObject with text inside
1994        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        // Page content stream: invoke the form XObject
2029        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}