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};
22use super::ocg::{load_ocg_visibility, OcgVisibility};
23use super::pdf_string::decode_pdf_string;
24
25/// Maximum recursion depth for Form XObject processing (prevents infinite loops).
26const MAX_FORM_RECURSION_DEPTH: u32 = 10;
27
28/// Minimum line width to consider a path segment (in points).
29const MIN_LINE_WIDTH: f64 = 0.1;
30
31/// Aspect ratio threshold: width/height > this means horizontal line.
32const LINE_ASPECT_RATIO: f64 = 3.0;
33
34/// Maximum thickness for a line (vs rectangle classification).
35const MAX_LINE_THICKNESS: f64 = 10.0;
36
37/// How a path was painted — fill vs stroke change what counts as a Line.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum PathPaint {
40    /// `S` / `s` — path edges are stroked geometry (rules, box borders).
41    Stroke,
42    /// `f` / `f*` — path fills a region; only thin strips are rules.
43    Fill,
44}
45
46/// All chunks extracted from a single page.
47#[derive(Debug, Default)]
48pub struct PageChunks {
49    /// Text chunks with position and font info
50    pub text_chunks: Vec<TextChunk>,
51    /// Image chunks with CTM-based bounding boxes
52    pub image_chunks: Vec<ImageChunk>,
53    /// Line segments (horizontal/vertical lines, rectangles)
54    pub line_chunks: Vec<LineChunk>,
55    /// Vector graphics (complex paths)
56    pub line_art_chunks: Vec<LineArtChunk>,
57}
58
59/// Extract all chunks from a single page in one content stream pass.
60pub fn extract_page_chunks(
61    doc: &Document,
62    page_number: u32,
63    page_id: ObjectId,
64) -> Result<PageChunks, EdgePdfError> {
65    let font_cache = resolve_page_fonts(doc, page_id);
66
67    let page_dict = doc
68        .get_object(page_id)
69        .map_err(|e| EdgePdfError::PipelineError {
70            stage: 1,
71            message: format!("Failed to get page {}: {}", page_number, e),
72        })?
73        .as_dict()
74        .map_err(|e| EdgePdfError::PipelineError {
75            stage: 1,
76            message: format!("Page {} is not a dictionary: {}", page_number, e),
77        })?
78        .clone();
79
80    // Get content stream(s)
81    let content_data = super::text_extractor::get_page_content(doc, &page_dict)?;
82    if content_data.is_empty() {
83        return Ok(PageChunks::default());
84    }
85
86    // Parse content stream operations
87    let content = Content::decode(&content_data).map_err(|e| EdgePdfError::PipelineError {
88        stage: 1,
89        message: format!(
90            "Failed to decode content stream for page {}: {}",
91            page_number, e
92        ),
93    })?;
94
95    // Resolve the Resources dictionary for this page (needed for Do/gs operators)
96    let resources = resolve_page_resources(doc, &page_dict);
97
98    let mut parser = ChunkParserState::new(page_number, font_cache);
99    parser.ocg = load_ocg_visibility(doc);
100    parser.process_operations(doc, &content.operations, &resources, 0);
101
102    Ok(parser.into_page_chunks())
103}
104
105/// Resolve the /Resources dictionary for a page, following references.
106fn resolve_page_resources(doc: &Document, page_dict: &Dictionary) -> Dictionary {
107    match page_dict.get(b"Resources") {
108        Ok(obj) => {
109            let resolved = resolve_obj(doc, obj);
110            resolved.as_dict().cloned().unwrap_or_default()
111        }
112        Err(_) => Dictionary::new(),
113    }
114}
115
116/// Internal state for the unified chunk parser — equivalent to the reference ChunkParser.
117struct ChunkParserState {
118    page_number: u32,
119    font_cache: FontCache,
120    gs_stack: GraphicsStateStack,
121
122    // Chunk accumulators
123    text_chunks: Vec<TextChunk>,
124    image_chunks: Vec<ImageChunk>,
125    line_chunks: Vec<LineChunk>,
126    line_art_chunks: Vec<LineArtChunk>,
127
128    // Indices
129    text_index: usize,
130    image_index: u32,
131    line_index: u32,
132
133    // Marked content tracking
134    mcid_stack: Vec<Option<i64>>,
135    /// Stack of `/ActualText` replacements from BDC spans (ISO 32000-1 §14.9.4).
136    /// `Some(text)` means glyph shows in this span must be replaced; the text
137    /// is emitted once on the first show operator, then suppressed until EMC.
138    actual_text_stack: Vec<Option<String>>,
139    /// True after we've emitted the ActualText for the current innermost span.
140    actual_text_emitted: Vec<bool>,
141    /// Catalog OCG default visibility.
142    ocg: OcgVisibility,
143    /// Per marked-content span: whether content is OCG-visible (innermost wins).
144    ocg_visible_stack: Vec<bool>,
145
146    // Path construction state
147    current_path: Vec<PathSegment>,
148    subpath_start: Option<(f64, f64)>,
149    current_point: Option<(f64, f64)>,
150    line_width: f64,
151}
152
153impl ChunkParserState {
154    fn new(page_number: u32, font_cache: FontCache) -> Self {
155        Self {
156            page_number,
157            font_cache,
158            gs_stack: GraphicsStateStack::default(),
159
160            text_chunks: Vec::new(),
161            image_chunks: Vec::new(),
162            line_chunks: Vec::new(),
163            line_art_chunks: Vec::new(),
164
165            text_index: 0,
166            image_index: 0,
167            line_index: 0,
168
169            mcid_stack: Vec::new(),
170            actual_text_stack: Vec::new(),
171            actual_text_emitted: Vec::new(),
172            ocg: OcgVisibility::default(),
173            ocg_visible_stack: Vec::new(),
174
175            current_path: Vec::new(),
176            subpath_start: None,
177            current_point: None,
178            line_width: 1.0,
179        }
180    }
181
182    fn into_page_chunks(self) -> PageChunks {
183        PageChunks {
184            text_chunks: self.text_chunks,
185            image_chunks: self.image_chunks,
186            line_chunks: self.line_chunks,
187            line_art_chunks: self.line_art_chunks,
188        }
189    }
190
191    /// Process all content stream operations — the core parser loop.
192    fn process_operations(
193        &mut self,
194        doc: &Document,
195        operations: &[lopdf::content::Operation],
196        resources: &Dictionary,
197        recursion_depth: u32,
198    ) {
199        for op in operations {
200            match op.operator.as_str() {
201                // ── Marked content operators ──
202                "BMC" => {
203                    self.mcid_stack.push(None);
204                    self.actual_text_stack.push(None);
205                    self.actual_text_emitted.push(false);
206                    self.ocg_visible_stack
207                        .push(self.current_ocg_visible());
208                }
209                "BDC" => {
210                    let props = resolve_bdc_properties(doc, resources, &op.operands);
211                    let mcid = props.as_ref().and_then(|d| {
212                        d.get(b"MCID").ok().and_then(|o| match o {
213                            Object::Integer(n) => Some(*n),
214                            _ => None,
215                        })
216                    });
217                    let actual = props.as_ref().and_then(extract_actual_text);
218                    let visible = props
219                        .as_ref()
220                        .and_then(|d| ocg_visibility_from_props(doc, d, &self.ocg))
221                        .unwrap_or_else(|| self.current_ocg_visible());
222                    self.mcid_stack.push(mcid);
223                    self.actual_text_stack.push(actual);
224                    self.actual_text_emitted.push(false);
225                    self.ocg_visible_stack.push(visible);
226                }
227                "EMC" => {
228                    self.mcid_stack.pop();
229                    self.actual_text_stack.pop();
230                    self.actual_text_emitted.pop();
231                    self.ocg_visible_stack.pop();
232                }
233
234                // ── Graphics state ──
235                "q" => self.gs_stack.save(),
236                "Q" => self.gs_stack.restore(),
237                "cm"
238                    if op.operands.len() == 6 => {
239                        let vals: Vec<f64> = op
240                            .operands
241                            .iter()
242                            .filter_map(|o| obj_to_f64(o.clone()))
243                            .collect();
244                        if vals.len() == 6 {
245                            self.gs_stack
246                                .concat_ctm(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]);
247                        }
248                    }
249                "gs" => {
250                    // Extended Graphics State — look up in /ExtGState resources
251                    if let Some(name) = op.operands.first().and_then(obj_name_bytes) {
252                        self.apply_ext_gstate(doc, resources, &name);
253                    }
254                }
255
256                // ── Text state operators ──
257                "BT" => self.gs_stack.current.begin_text(),
258                "ET" => {}
259
260                "Tf"
261                    if op.operands.len() == 2 => {
262                        if let Object::Name(ref name) = op.operands[0] {
263                            self.gs_stack.current.text_state.font_name =
264                                String::from_utf8_lossy(name).to_string();
265                        }
266                        if let Some(size) = obj_to_f64(op.operands[1].clone()) {
267                            self.gs_stack.current.text_state.font_size = size;
268                        }
269                    }
270                "Tc" => {
271                    if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
272                        self.gs_stack.current.text_state.char_spacing = v;
273                    }
274                }
275                "Tw" => {
276                    if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
277                        self.gs_stack.current.text_state.word_spacing = v;
278                    }
279                }
280                "Tz" => {
281                    if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
282                        self.gs_stack.current.text_state.horizontal_scaling = v;
283                    }
284                }
285                "TL" => {
286                    if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
287                        self.gs_stack.current.text_state.leading = v;
288                    }
289                }
290                "Ts" => {
291                    if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
292                        self.gs_stack.current.text_state.rise = v;
293                    }
294                }
295                "Tr" => {
296                    if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
297                        self.gs_stack.current.text_state.render_mode = v as i32;
298                    }
299                }
300
301                // ── Text positioning ──
302                "Td"
303                    if op.operands.len() == 2 => {
304                        let tx = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
305                        let ty = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
306                        self.gs_stack.current.translate_text(tx, ty);
307                    }
308                "TD"
309                    if op.operands.len() == 2 => {
310                        let tx = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
311                        let ty = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
312                        self.gs_stack.current.text_state.leading = -ty;
313                        self.gs_stack.current.translate_text(tx, ty);
314                    }
315                "Tm"
316                    if op.operands.len() == 6 => {
317                        let vals: Vec<f64> = op
318                            .operands
319                            .iter()
320                            .filter_map(|o| obj_to_f64(o.clone()))
321                            .collect();
322                        if vals.len() == 6 {
323                            self.gs_stack.current.set_text_matrix(
324                                vals[0], vals[1], vals[2], vals[3], vals[4], vals[5],
325                            );
326                        }
327                    }
328                "T*" => {
329                    self.gs_stack.current.next_line();
330                }
331
332                // ── Text showing operators → TextChunk ──
333                "Tj" => {
334                    if let Some(text_bytes) = op.operands.first().and_then(extract_string_bytes) {
335                        self.emit_text_chunk(&text_bytes);
336                    }
337                }
338                "TJ" => {
339                    if let Some(Object::Array(ref arr)) = op.operands.first() {
340                        self.process_tj_array(arr);
341                    }
342                }
343                "'" => {
344                    self.gs_stack.current.next_line();
345                    if let Some(text_bytes) = op.operands.first().and_then(extract_string_bytes) {
346                        self.emit_text_chunk(&text_bytes);
347                    }
348                }
349                "\""
350                    if op.operands.len() == 3 => {
351                        if let Some(aw) = obj_to_f64(op.operands[0].clone()) {
352                            self.gs_stack.current.text_state.word_spacing = aw;
353                        }
354                        if let Some(ac) = obj_to_f64(op.operands[1].clone()) {
355                            self.gs_stack.current.text_state.char_spacing = ac;
356                        }
357                        self.gs_stack.current.next_line();
358                        if let Some(text_bytes) = extract_string_bytes(&op.operands[2]) {
359                            self.emit_text_chunk(&text_bytes);
360                        }
361                    }
362
363                // ── Color operators ──
364                "g" => {
365                    if let Some(gray) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
366                        self.gs_stack.current.fill_color = vec![gray];
367                        self.gs_stack.current.fill_color_space_components = 1;
368                    }
369                }
370                "G" => {
371                    if let Some(gray) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
372                        self.gs_stack.current.stroke_color = vec![gray];
373                        self.gs_stack.current.stroke_color_space_components = 1;
374                    }
375                }
376                "rg"
377                    if op.operands.len() == 3 => {
378                        let r = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
379                        let g = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
380                        let b = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
381                        self.gs_stack.current.fill_color = vec![r, g, b];
382                        self.gs_stack.current.fill_color_space_components = 3;
383                    }
384                "RG"
385                    if op.operands.len() == 3 => {
386                        let r = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
387                        let g = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
388                        let b = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
389                        self.gs_stack.current.stroke_color = vec![r, g, b];
390                        self.gs_stack.current.stroke_color_space_components = 3;
391                    }
392                "k"
393                    if op.operands.len() == 4 => {
394                        let c = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
395                        let m = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
396                        let y = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
397                        let kk = obj_to_f64(op.operands[3].clone()).unwrap_or(0.0);
398                        self.gs_stack.current.fill_color = vec![c, m, y, kk];
399                        self.gs_stack.current.fill_color_space_components = 4;
400                    }
401                "K"
402                    if op.operands.len() == 4 => {
403                        let c = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
404                        let m = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
405                        let y = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
406                        let kk = obj_to_f64(op.operands[3].clone()).unwrap_or(0.0);
407                        self.gs_stack.current.stroke_color = vec![c, m, y, kk];
408                        self.gs_stack.current.stroke_color_space_components = 4;
409                    }
410                "cs" => {
411                    if let Some(name) = op.operands.first() {
412                        let cs_name = obj_to_name(name);
413                        let comps = color_space_components(&cs_name);
414                        self.gs_stack.current.fill_color_space_components = comps;
415                        // PDF spec 8.6.5.3: reset color to default for new space
416                        self.gs_stack.current.fill_color = default_color_for_space(comps);
417                        self.gs_stack.current.fill_pattern = None;
418                    }
419                }
420                "CS" => {
421                    if let Some(name) = op.operands.first() {
422                        let cs_name = obj_to_name(name);
423                        let comps = color_space_components(&cs_name);
424                        self.gs_stack.current.stroke_color_space_components = comps;
425                        // PDF spec 8.6.5.3: reset color to default for new space
426                        self.gs_stack.current.stroke_color = default_color_for_space(comps);
427                        self.gs_stack.current.stroke_pattern = None;
428                    }
429                }
430                "sc" | "scn" => {
431                    // Pattern color spaces take a name operand (e.g. `/P6 scn`).
432                    if let Some(name) = op.operands.first().and_then(obj_name_bytes) {
433                        self.gs_stack.current.fill_pattern = Some(name);
434                    } else {
435                        let components: Vec<f64> = op
436                            .operands
437                            .iter()
438                            .filter_map(|o| obj_to_f64(o.clone()))
439                            .collect();
440                        if !components.is_empty() {
441                            self.gs_stack.current.fill_color = components;
442                            self.gs_stack.current.fill_pattern = None;
443                        }
444                    }
445                }
446                "SC" | "SCN" => {
447                    if let Some(name) = op.operands.first().and_then(obj_name_bytes) {
448                        self.gs_stack.current.stroke_pattern = Some(name);
449                    } else {
450                        let components: Vec<f64> = op
451                            .operands
452                            .iter()
453                            .filter_map(|o| obj_to_f64(o.clone()))
454                            .collect();
455                        if !components.is_empty() {
456                            self.gs_stack.current.stroke_color = components;
457                            self.gs_stack.current.stroke_pattern = None;
458                        }
459                    }
460                }
461
462                // ── Line width ──
463                "w" => {
464                    if let Some(w) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
465                        self.line_width = w;
466                    }
467                }
468
469                // ── Path construction ──
470                "m"
471                    if op.operands.len() >= 2 => {
472                        if let (Some(x), Some(y)) = (
473                            op.operands.first().and_then(|o| obj_to_f64(o.clone())),
474                            op.operands.get(1).and_then(|o| obj_to_f64(o.clone())),
475                        ) {
476                            let (tx, ty) = self.transform_point(x, y);
477                            self.subpath_start = Some((tx, ty));
478                            self.current_point = Some((tx, ty));
479                        }
480                    }
481                "l"
482                    if op.operands.len() >= 2 => {
483                        if let (Some(x), Some(y)) = (
484                            op.operands.first().and_then(|o| obj_to_f64(o.clone())),
485                            op.operands.get(1).and_then(|o| obj_to_f64(o.clone())),
486                        ) {
487                            let (tx, ty) = self.transform_point(x, y);
488                            if let Some((cx, cy)) = self.current_point {
489                                self.current_path.push(PathSegment::Line {
490                                    x1: cx,
491                                    y1: cy,
492                                    x2: tx,
493                                    y2: ty,
494                                });
495                            }
496                            self.current_point = Some((tx, ty));
497                        }
498                    }
499                "c"
500                    if op.operands.len() >= 6 => {
501                        let vals: Vec<f64> = op
502                            .operands
503                            .iter()
504                            .filter_map(|o| obj_to_f64(o.clone()))
505                            .collect();
506                        if vals.len() >= 6 {
507                            let (tx, ty) = self.transform_point(vals[4], vals[5]);
508                            if let Some((cx, cy)) = self.current_point {
509                                let (cp1x, cp1y) = self.transform_point(vals[0], vals[1]);
510                                let (cp2x, cp2y) = self.transform_point(vals[2], vals[3]);
511                                self.current_path.push(PathSegment::Curve {
512                                    x1: cx,
513                                    y1: cy,
514                                    cp1x,
515                                    cp1y,
516                                    cp2x,
517                                    cp2y,
518                                    x2: tx,
519                                    y2: ty,
520                                });
521                            }
522                            self.current_point = Some((tx, ty));
523                        }
524                    }
525                "v"
526                    if op.operands.len() >= 4 => {
527                        let vals: Vec<f64> = op
528                            .operands
529                            .iter()
530                            .filter_map(|o| obj_to_f64(o.clone()))
531                            .collect();
532                        if vals.len() >= 4 {
533                            let (tx, ty) = self.transform_point(vals[2], vals[3]);
534                            if let Some((cx, cy)) = self.current_point {
535                                let (cp2x, cp2y) = self.transform_point(vals[0], vals[1]);
536                                self.current_path.push(PathSegment::Curve {
537                                    x1: cx,
538                                    y1: cy,
539                                    cp1x: cx,
540                                    cp1y: cy,
541                                    cp2x,
542                                    cp2y,
543                                    x2: tx,
544                                    y2: ty,
545                                });
546                            }
547                            self.current_point = Some((tx, ty));
548                        }
549                    }
550                "y"
551                    if op.operands.len() >= 4 => {
552                        let vals: Vec<f64> = op
553                            .operands
554                            .iter()
555                            .filter_map(|o| obj_to_f64(o.clone()))
556                            .collect();
557                        if vals.len() >= 4 {
558                            let (tx, ty) = self.transform_point(vals[2], vals[3]);
559                            if let Some((cx, cy)) = self.current_point {
560                                let (cp1x, cp1y) = self.transform_point(vals[0], vals[1]);
561                                self.current_path.push(PathSegment::Curve {
562                                    x1: cx,
563                                    y1: cy,
564                                    cp1x,
565                                    cp1y,
566                                    cp2x: tx,
567                                    cp2y: ty,
568                                    x2: tx,
569                                    y2: ty,
570                                });
571                            }
572                            self.current_point = Some((tx, ty));
573                        }
574                    }
575                "h" => {
576                    if let (Some((sx, sy)), Some((cx, cy))) =
577                        (self.subpath_start, self.current_point)
578                    {
579                        if (sx - cx).abs() > 0.01 || (sy - cy).abs() > 0.01 {
580                            self.current_path.push(PathSegment::Line {
581                                x1: cx,
582                                y1: cy,
583                                x2: sx,
584                                y2: sy,
585                            });
586                        }
587                        self.current_point = self.subpath_start;
588                    }
589                }
590                "re"
591                    if op.operands.len() >= 4 => {
592                        let vals: Vec<f64> = op
593                            .operands
594                            .iter()
595                            .filter_map(|o| obj_to_f64(o.clone()))
596                            .collect();
597                        if vals.len() >= 4 {
598                            let (x, y, w, h) = (vals[0], vals[1], vals[2], vals[3]);
599                            let (x1, y1) = self.transform_point(x, y);
600                            let (x2, y2) = self.transform_point(x + w, y);
601                            let (x3, y3) = self.transform_point(x + w, y + h);
602                            let (x4, y4) = self.transform_point(x, y + h);
603                            self.current_path.push(PathSegment::Line { x1, y1, x2, y2 });
604                            self.current_path.push(PathSegment::Line {
605                                x1: x2,
606                                y1: y2,
607                                x2: x3,
608                                y2: y3,
609                            });
610                            self.current_path.push(PathSegment::Line {
611                                x1: x3,
612                                y1: y3,
613                                x2: x4,
614                                y2: y4,
615                            });
616                            self.current_path.push(PathSegment::Line {
617                                x1: x4,
618                                y1: y4,
619                                x2: x1,
620                                y2: y1,
621                            });
622                            self.subpath_start = Some((x1, y1));
623                            self.current_point = Some((x1, y1));
624                        }
625                    }
626
627                // ── Path painting ──
628                "S" => {
629                    self.classify_and_emit_path(PathPaint::Stroke);
630                }
631                "s" => {
632                    // close and stroke
633                    self.close_subpath();
634                    self.classify_and_emit_path(PathPaint::Stroke);
635                }
636                "f" | "F" | "f*" => {
637                    if self.try_emit_pattern_fill_image(doc, resources) {
638                        // Pattern-painted image — drop path so it is not
639                        // misclassified as page-edge line art.
640                    } else {
641                        self.classify_and_emit_path(PathPaint::Fill);
642                    }
643                }
644                "B" | "B*" | "b" | "b*" => {
645                    if op.operator.starts_with('b') {
646                        self.close_subpath();
647                    }
648                    if self.try_emit_pattern_fill_image(doc, resources) {
649                        // filled+stroked with a pattern fill
650                    } else {
651                        // Stroke edges define geometry; fill is incidental paint.
652                        self.classify_and_emit_path(PathPaint::Stroke);
653                    }
654                }
655                "n" => {
656                    // End path without painting
657                    self.current_path.clear();
658                    self.subpath_start = None;
659                    self.current_point = None;
660                }
661
662                // ── XObject (Do) — Image and Form XObject handling ──
663                "Do" => {
664                    if let Some(name_bytes) = op.operands.first().and_then(obj_name_bytes) {
665                        self.handle_do_operator(doc, resources, &name_bytes, recursion_depth);
666                    }
667                }
668
669                // ── Inline image (BI/ID/EI) ──
670                // lopdf parses BI inline images as a special operation;
671                // the operator is "BI" with the image dict + data as operands.
672                // We create an ImageChunk using the current CTM.
673                "BI" => {
674                    self.emit_inline_image();
675                }
676
677                _ => {
678                    // Ignore unknown/unhandled operators
679                }
680            }
681        }
682    }
683
684    // ── Text chunk creation ──
685
686    fn emit_text_chunk(&mut self, text_bytes: &[u8]) {
687        if text_bytes.is_empty() {
688            return;
689        }
690
691        let font = self
692            .font_cache
693            .get(&self.gs_stack.current.text_state.font_name)
694            .cloned()
695            .unwrap_or_else(|| PdfFont::default_font(&self.gs_stack.current.text_state.font_name));
696        let active_mcid = self.active_mcid();
697
698        // ISO 32000-1 §14.9.4: /ActualText replaces glyph text for extraction.
699        if let Some(actual) = self.active_actual_text() {
700            if self.mark_actual_text_emitted() {
701                if let Some(chunk) = create_text_chunk_with_override(
702                    text_bytes,
703                    Some(&actual),
704                    &font,
705                    &mut self.gs_stack,
706                    self.page_number,
707                    &mut self.text_index,
708                    active_mcid,
709                ) {
710                    let mut chunk = chunk;
711                    chunk.ocg_visible = self.current_ocg_visible();
712                    self.text_chunks.push(chunk);
713                }
714            } else {
715                // Span already emitted ActualText — advance matrix only.
716                advance_text_matrix(text_bytes, &font, &mut self.gs_stack);
717            }
718            return;
719        }
720
721        if let Some(chunk) = create_text_chunk(
722            text_bytes,
723            &font,
724            &mut self.gs_stack,
725            self.page_number,
726            &mut self.text_index,
727            active_mcid,
728        ) {
729            let mut chunk = chunk;
730            chunk.ocg_visible = self.current_ocg_visible();
731            self.text_chunks.push(chunk);
732        }
733    }
734
735    fn process_tj_array(&mut self, arr: &[Object]) {
736        let font = self
737            .font_cache
738            .get(&self.gs_stack.current.text_state.font_name)
739            .cloned()
740            .unwrap_or_else(|| PdfFont::default_font(&self.gs_stack.current.text_state.font_name));
741        let active_mcid = self.active_mcid();
742        let actual = self.active_actual_text();
743        let mut emitted_actual = false;
744
745        for item in arr {
746            match item {
747                Object::String(bytes, _) => {
748                    if let Some(ref actual_text) = actual {
749                        if !emitted_actual && self.mark_actual_text_emitted() {
750                            emitted_actual = true;
751                            if let Some(chunk) = create_text_chunk_with_override(
752                                bytes,
753                                Some(actual_text),
754                                &font,
755                                &mut self.gs_stack,
756                                self.page_number,
757                                &mut self.text_index,
758                                active_mcid,
759                            ) {
760                                let mut chunk = chunk;
761                                chunk.ocg_visible = self.current_ocg_visible();
762                                self.text_chunks.push(chunk);
763                            }
764                        } else {
765                            advance_text_matrix(bytes, &font, &mut self.gs_stack);
766                        }
767                    } else if let Some(chunk) = create_text_chunk(
768                        bytes,
769                        &font,
770                        &mut self.gs_stack,
771                        self.page_number,
772                        &mut self.text_index,
773                        active_mcid,
774                    ) {
775                        let mut chunk = chunk;
776                        chunk.ocg_visible = self.current_ocg_visible();
777                        self.text_chunks.push(chunk);
778                    }
779                }
780                _ => {
781                    if let Some(adj) = obj_to_f64(item.clone()) {
782                        // Large negative TJ adjustments open visual gaps that
783                        // often encode word spaces (TeX / InDesign). Insert an
784                        // explicit space glyph-width advance threshold.
785                        let font_size = self.gs_stack.current.text_state.font_size.max(1.0);
786                        if adj <= SpaceThreshold::TJ_WORD_GAP_THOUSANDTHS {
787                            // Record a synthetic space chunk so needs_space /
788                            // whitespace filters see an explicit boundary.
789                            if let Some(chunk) = create_synthetic_space_chunk(
790                                &mut self.gs_stack,
791                                self.page_number,
792                                &mut self.text_index,
793                                active_mcid,
794                                font_size,
795                            ) {
796                                let mut chunk = chunk;
797                                chunk.ocg_visible = self.current_ocg_visible();
798                                self.text_chunks.push(chunk);
799                            }
800                        }
801                        let displacement = -adj / 1000.0 * font_size;
802                        self.gs_stack.current.advance_text(displacement);
803                    }
804                }
805            }
806        }
807    }
808
809    // ── Image handling ──
810
811    /// Handle `Do` operator — dispatches to image or form XObject processing.
812    fn handle_do_operator(
813        &mut self,
814        doc: &Document,
815        resources: &Dictionary,
816        name_bytes: &[u8],
817        recursion_depth: u32,
818    ) {
819        // Look up the XObject in /Resources/XObject
820        let xobject_dict = match resources.get(b"XObject") {
821            Ok(obj) => {
822                let resolved = resolve_obj(doc, obj);
823                match resolved.as_dict() {
824                    Ok(d) => d.clone(),
825                    Err(_) => return,
826                }
827            }
828            Err(_) => return,
829        };
830
831        let xobj_ref = match xobject_dict.get(name_bytes) {
832            Ok(obj) => resolve_obj(doc, obj),
833            Err(_) => return,
834        };
835
836        let stream = match xobj_ref.as_stream() {
837            Ok(s) => s.clone(),
838            Err(_) => return,
839        };
840
841        let subtype = stream
842            .dict
843            .get(b"Subtype")
844            .ok()
845            .and_then(|o| match resolve_obj(doc, o) {
846                Object::Name(n) => Some(String::from_utf8_lossy(&n).to_string()),
847                _ => None,
848            });
849
850        match subtype.as_deref() {
851            Some("Image") => {
852                // Image XObject → create ImageChunk with CTM-based bbox
853                self.emit_image_from_ctm();
854            }
855            Some("Form")
856                // Form XObject → recursive content stream processing
857                if recursion_depth < MAX_FORM_RECURSION_DEPTH => {
858                    self.process_form_xobject(doc, &stream, resources, recursion_depth);
859                }
860            _ => {}
861        }
862    }
863
864    /// Create an ImageChunk using the current CTM to compute position.
865    /// Image occupies [0,0] to [1,1] in user space before CTM transform.
866    fn emit_image_from_ctm(&mut self) {
867        let ctm = &self.gs_stack.current.ctm;
868
869        // Transform the image unit square corners through CTM
870        let (x0, y0) = ctm.transform_point(0.0, 0.0);
871        let (x1, y1) = ctm.transform_point(1.0, 0.0);
872        let (x2, y2) = ctm.transform_point(1.0, 1.0);
873        let (x3, y3) = ctm.transform_point(0.0, 1.0);
874
875        let min_x = x0.min(x1).min(x2).min(x3);
876        let max_x = x0.max(x1).max(x2).max(x3);
877        let min_y = y0.min(y1).min(y2).min(y3);
878        let max_y = y0.max(y1).max(y2).max(y3);
879
880        self.emit_image_bbox(min_x, min_y, max_x, max_y, None, ImagePaintSource::Do);
881    }
882
883    /// Emit an ImageChunk with an explicit bbox and optional forced index.
884    fn emit_image_bbox(
885        &mut self,
886        min_x: f64,
887        min_y: f64,
888        max_x: f64,
889        max_y: f64,
890        forced_index: Option<u32>,
891        source: ImagePaintSource,
892    ) {
893        // Skip degenerate images
894        if (max_x - min_x).abs() < 0.1 || (max_y - min_y).abs() < 0.1 {
895            return;
896        }
897
898        let index = if let Some(idx) = forced_index {
899            self.image_index = self.image_index.max(idx);
900            idx
901        } else {
902            self.image_index += 1;
903            self.image_index
904        };
905
906        self.image_chunks.push(ImageChunk {
907            bbox: BoundingBox::new(Some(self.page_number), min_x, min_y, max_x, max_y),
908            index: Some(index),
909            level: None,
910            source,
911        });
912    }
913
914    /// If the current fill color is a tiling Pattern that paints an Image
915    /// XObject, emit that image with the current path's bounding box and clear
916    /// the path. Returns true when handled (caller must not emit line art).
917    fn try_emit_pattern_fill_image(&mut self, doc: &Document, resources: &Dictionary) -> bool {
918        let Some(pattern_name) = self.gs_stack.current.fill_pattern.clone() else {
919            return false;
920        };
921
922        let path = std::mem::take(&mut self.current_path);
923        self.subpath_start = None;
924        self.current_point = None;
925        if path.is_empty() {
926            return true;
927        }
928
929        let (min_x, min_y, max_x, max_y) = path_bbox(&path);
930        if (max_x - min_x).abs() < 0.1 || (max_y - min_y).abs() < 0.1 {
931            return true;
932        }
933
934        let Some(index) =
935            super::image_extractor::find_pattern_image_index(doc, resources, &pattern_name)
936        else {
937            // Pattern fill without a usable image — swallow the path to avoid
938            // page-edge line noise from full-page pattern rects.
939            return true;
940        };
941
942        self.emit_image_bbox(
943            min_x,
944            min_y,
945            max_x,
946            max_y,
947            Some(index),
948            ImagePaintSource::PatternFill,
949        );
950        true
951    }
952
953    /// Create an ImageChunk for an inline image (BI/ID/EI).
954    fn emit_inline_image(&mut self) {
955        let ctm = &self.gs_stack.current.ctm;
956        let (x0, y0) = ctm.transform_point(0.0, 0.0);
957        let (x1, y1) = ctm.transform_point(1.0, 0.0);
958        let (x2, y2) = ctm.transform_point(1.0, 1.0);
959        let (x3, y3) = ctm.transform_point(0.0, 1.0);
960        let min_x = x0.min(x1).min(x2).min(x3);
961        let max_x = x0.max(x1).max(x2).max(x3);
962        let min_y = y0.min(y1).min(y2).min(y3);
963        let max_y = y0.max(y1).max(y2).max(y3);
964        self.emit_image_bbox(min_x, min_y, max_x, max_y, None, ImagePaintSource::Inline);
965    }
966
967    /// Process a Form XObject — recursively parse its content stream.
968    fn process_form_xobject(
969        &mut self,
970        doc: &Document,
971        stream: &lopdf::Stream,
972        parent_resources: &Dictionary,
973        recursion_depth: u32,
974    ) {
975        // Get the form's /Matrix (default identity)
976        let form_matrix = get_form_matrix(doc, &stream.dict);
977
978        // Concatenate form matrix with current CTM (like the reference implementation: xFormGraphicsState.getCTM().concatenate(matrix))
979        self.gs_stack.save();
980        let m = form_matrix;
981        self.gs_stack.concat_ctm(m.a, m.b, m.c, m.d, m.e, m.f);
982
983        // Resolve form's own resources, falling back to parent
984        let form_resources = match stream.dict.get(b"Resources") {
985            Ok(obj) => {
986                let resolved = resolve_obj(doc, obj);
987                resolved
988                    .as_dict()
989                    .cloned()
990                    .unwrap_or_else(|_| parent_resources.clone())
991            }
992            Err(_) => parent_resources.clone(),
993        };
994
995        // Decompress the form's content stream
996        let form_content = if stream.dict.get(b"Filter").is_ok() {
997            match stream.decompressed_content() {
998                Ok(data) => data,
999                Err(_) => {
1000                    self.gs_stack.restore();
1001                    return;
1002                }
1003            }
1004        } else {
1005            stream.content.clone()
1006        };
1007
1008        if form_content.is_empty() {
1009            self.gs_stack.restore();
1010            return;
1011        }
1012
1013        // Parse the form's content stream
1014        if let Ok(content) = Content::decode(&form_content) {
1015            // Resolve fonts from form's resources and merge with page fonts
1016            let form_font_cache = resolve_form_fonts(doc, &form_resources);
1017            let mut merged_cache = FontCache::default();
1018            // Copy page fonts first
1019            for (name, font) in self.font_cache.iter() {
1020                merged_cache.insert(name.clone(), font.clone());
1021            }
1022            // Override with form fonts
1023            for (name, font) in form_font_cache.iter() {
1024                merged_cache.insert(name.clone(), font.clone());
1025            }
1026
1027            let saved_fc = std::mem::replace(&mut self.font_cache, merged_cache);
1028            self.process_operations(
1029                doc,
1030                &content.operations,
1031                &form_resources,
1032                recursion_depth + 1,
1033            );
1034            self.font_cache = saved_fc;
1035        }
1036
1037        self.gs_stack.restore();
1038    }
1039
1040    // ── Extended Graphics State ──
1041
1042    fn apply_ext_gstate(&mut self, doc: &Document, resources: &Dictionary, name: &[u8]) {
1043        let ext_gstate_dict = match resources.get(b"ExtGState") {
1044            Ok(obj) => {
1045                let resolved = resolve_obj(doc, obj);
1046                match resolved.as_dict() {
1047                    Ok(d) => d.clone(),
1048                    Err(_) => return,
1049                }
1050            }
1051            Err(_) => return,
1052        };
1053
1054        let gs_obj = match ext_gstate_dict.get(name) {
1055            Ok(obj) => resolve_obj(doc, obj),
1056            Err(_) => return,
1057        };
1058
1059        let gs_dict = match gs_obj.as_dict() {
1060            Ok(d) => d,
1061            Err(_) => return,
1062        };
1063
1064        // Apply relevant properties from ExtGState
1065        // /Font — set font and size
1066        if let Ok(font_arr) = gs_dict.get(b"Font") {
1067            if let Ok(arr) = resolve_obj(doc, font_arr).as_array() {
1068                if arr.len() >= 2 {
1069                    if let Object::Name(ref name) = arr[0] {
1070                        self.gs_stack.current.text_state.font_name =
1071                            String::from_utf8_lossy(name).to_string();
1072                    }
1073                    if let Some(size) = obj_to_f64(arr[1].clone()) {
1074                        self.gs_stack.current.text_state.font_size = size;
1075                    }
1076                }
1077            }
1078        }
1079
1080        // /LW — line width
1081        if let Ok(lw) = gs_dict.get(b"LW") {
1082            if let Some(w) = obj_to_f64(resolve_obj(doc, lw)) {
1083                self.line_width = w;
1084            }
1085        }
1086    }
1087
1088    // ── Path classification ──
1089
1090    fn close_subpath(&mut self) {
1091        if let (Some((sx, sy)), Some((cx, cy))) = (self.subpath_start, self.current_point) {
1092            if (sx - cx).abs() > 0.01 || (sy - cy).abs() > 0.01 {
1093                self.current_path.push(PathSegment::Line {
1094                    x1: cx,
1095                    y1: cy,
1096                    x2: sx,
1097                    y2: sy,
1098                });
1099            }
1100            self.current_point = self.subpath_start;
1101        }
1102    }
1103
1104    fn classify_and_emit_path(&mut self, paint: PathPaint) {
1105        let path = std::mem::take(&mut self.current_path);
1106        self.subpath_start = None;
1107        self.current_point = None;
1108
1109        if path.is_empty() || self.line_width < MIN_LINE_WIDTH {
1110            return;
1111        }
1112
1113        // Fill paints a region. Only a thin strip is a geometric rule (table
1114        // border drawn as a 1pt filled rect). Equidimensional fills — including
1115        // Skia soft-circle AA as thousands of 1×1 `re f` — are decoration, not
1116        // lines. Emitting their path edges floods LineChunks and blows JSON.
1117        if matches!(paint, PathPaint::Fill) {
1118            if let Some(mut line) = thin_filled_strip_line(&path, self.line_width, self.page_number)
1119            {
1120                self.line_index += 1;
1121                line.index = Some(self.line_index);
1122                self.line_chunks.push(line);
1123            }
1124            return;
1125        }
1126
1127        let has_curves = path.iter().any(|s| matches!(s, PathSegment::Curve { .. }));
1128
1129        // Try to classify individual segments as line chunks
1130        if !has_curves && path.len() <= 4 {
1131            let mut classified_lines = Vec::new();
1132            for seg in &path {
1133                if let PathSegment::Line { x1, y1, x2, y2 } = seg {
1134                    let dx = (x2 - x1).abs();
1135                    let dy = (y2 - y1).abs();
1136                    let length = (dx * dx + dy * dy).sqrt();
1137
1138                    if length < MIN_LINE_WIDTH {
1139                        continue;
1140                    }
1141
1142                    let is_horizontal = dy < MAX_LINE_THICKNESS && dx > dy * LINE_ASPECT_RATIO;
1143                    let is_vertical = dx < MAX_LINE_THICKNESS && dy > dx * LINE_ASPECT_RATIO;
1144
1145                    if is_horizontal || is_vertical {
1146                        self.line_index += 1;
1147                        let min_x = x1.min(*x2);
1148                        let max_x = x1.max(*x2);
1149                        let min_y = y1.min(*y2);
1150                        let max_y = y1.max(*y2);
1151                        let half_w = self.line_width / 2.0;
1152
1153                        classified_lines.push(LineChunk {
1154                            bbox: BoundingBox::new(
1155                                Some(self.page_number),
1156                                min_x - if is_vertical { half_w } else { 0.0 },
1157                                min_y - if is_horizontal { half_w } else { 0.0 },
1158                                max_x + if is_vertical { half_w } else { 0.0 },
1159                                max_y + if is_horizontal { half_w } else { 0.0 },
1160                            ),
1161                            index: Some(self.line_index),
1162                            level: None,
1163                            start: Vertex {
1164                                x: *x1,
1165                                y: *y1,
1166                                radius: 0.0,
1167                            },
1168                            end: Vertex {
1169                                x: *x2,
1170                                y: *y2,
1171                                radius: 0.0,
1172                            },
1173                            width: self.line_width,
1174                            is_horizontal_line: is_horizontal,
1175                            is_vertical_line: is_vertical,
1176                            is_square: false,
1177                        });
1178                    }
1179                }
1180            }
1181            if !classified_lines.is_empty() {
1182                self.line_chunks.extend(classified_lines);
1183                return;
1184            }
1185        }
1186
1187        // Rectangle classification (4 line segments forming a box)
1188        if !has_curves && path.len() == 4 {
1189            if let Some(rect) = try_classify_rectangle(&path, self.line_width, self.page_number) {
1190                self.line_index += 1;
1191                let mut rect = rect;
1192                rect.index = Some(self.line_index);
1193                self.line_chunks.push(rect);
1194                return;
1195            }
1196        }
1197
1198        // Complex path → LineArtChunk
1199        if path.len() >= 2 {
1200            let mut art_lines = Vec::new();
1201            let mut min_x = f64::MAX;
1202            let mut min_y = f64::MAX;
1203            let mut max_x = f64::MIN;
1204            let mut max_y = f64::MIN;
1205
1206            for seg in &path {
1207                let (sx, sy, ex, ey) = match seg {
1208                    PathSegment::Line { x1, y1, x2, y2 } => (*x1, *y1, *x2, *y2),
1209                    PathSegment::Curve { x1, y1, x2, y2, .. } => (*x1, *y1, *x2, *y2),
1210                };
1211                min_x = min_x.min(sx).min(ex);
1212                min_y = min_y.min(sy).min(ey);
1213                max_x = max_x.max(sx).max(ex);
1214                max_y = max_y.max(sy).max(ey);
1215
1216                self.line_index += 1;
1217                art_lines.push(LineChunk {
1218                    bbox: BoundingBox::new(
1219                        Some(self.page_number),
1220                        sx.min(ex),
1221                        sy.min(ey),
1222                        sx.max(ex),
1223                        sy.max(ey),
1224                    ),
1225                    index: Some(self.line_index),
1226                    level: None,
1227                    start: Vertex {
1228                        x: sx,
1229                        y: sy,
1230                        radius: 0.0,
1231                    },
1232                    end: Vertex {
1233                        x: ex,
1234                        y: ey,
1235                        radius: 0.0,
1236                    },
1237                    width: self.line_width,
1238                    is_horizontal_line: false,
1239                    is_vertical_line: false,
1240                    is_square: false,
1241                });
1242            }
1243
1244            self.line_index += 1;
1245            self.line_art_chunks.push(LineArtChunk {
1246                bbox: BoundingBox::new(Some(self.page_number), min_x, min_y, max_x, max_y),
1247                index: Some(self.line_index),
1248                level: None,
1249                line_chunks: art_lines,
1250            });
1251        }
1252    }
1253
1254    // ── Helpers ──
1255
1256    fn transform_point(&self, x: f64, y: f64) -> (f64, f64) {
1257        self.gs_stack.current.ctm.transform_point(x, y)
1258    }
1259
1260    fn active_mcid(&self) -> Option<i64> {
1261        self.mcid_stack.iter().rev().find_map(|&mcid| mcid)
1262    }
1263
1264    /// Innermost non-empty `/ActualText` on the marked-content stack.
1265    fn active_actual_text(&self) -> Option<String> {
1266        self.actual_text_stack
1267            .iter()
1268            .rev()
1269            .find_map(|t| t.clone())
1270    }
1271
1272    /// Mark the innermost ActualText span as emitted. Returns true if this
1273    /// call is the first emission for that span.
1274    fn mark_actual_text_emitted(&mut self) -> bool {
1275        if let Some(flag) = self.actual_text_emitted.last_mut() {
1276            if !*flag {
1277                *flag = true;
1278                return true;
1279            }
1280            return false;
1281        }
1282        true
1283    }
1284
1285    /// Current OCG visibility (innermost marked-content span, default true).
1286    fn current_ocg_visible(&self) -> bool {
1287        self.ocg_visible_stack.last().copied().unwrap_or(true)
1288    }
1289}
1290
1291// ═══════════════════════════════════════════════════════════════════
1292// Helper functions (shared with text_extractor for backward compat)
1293// ═══════════════════════════════════════════════════════════════════
1294
1295/// Named thresholds for gap→space decisions (no scattered magic numbers).
1296struct SpaceThreshold;
1297impl SpaceThreshold {
1298    /// TJ adjustment (thousandths of em) at or below which we insert a word space.
1299    /// −200 ≈ 0.2 em visual gap, typical for justified word separators.
1300    const TJ_WORD_GAP_THOUSANDTHS: f64 = -200.0;
1301}
1302
1303/// Create a TextChunk from raw text bytes — same logic as text_extractor.
1304fn create_text_chunk(
1305    text_bytes: &[u8],
1306    font: &PdfFont,
1307    state: &mut GraphicsStateStack,
1308    page_number: u32,
1309    chunk_index: &mut usize,
1310    mcid: Option<i64>,
1311) -> Option<TextChunk> {
1312    create_text_chunk_with_override(
1313        text_bytes,
1314        None,
1315        font,
1316        state,
1317        page_number,
1318        chunk_index,
1319        mcid,
1320    )
1321}
1322
1323/// Like [`create_text_chunk`], but optionally override the decoded Unicode
1324/// (used for `/ActualText`). Glyph widths still come from `text_bytes`.
1325fn create_text_chunk_with_override(
1326    text_bytes: &[u8],
1327    unicode_override: Option<&str>,
1328    font: &PdfFont,
1329    state: &mut GraphicsStateStack,
1330    page_number: u32,
1331    chunk_index: &mut usize,
1332    mcid: Option<i64>,
1333) -> Option<TextChunk> {
1334    if text_bytes.is_empty() && unicode_override.is_none_or(|s| s.is_empty()) {
1335        return None;
1336    }
1337
1338    let trm = state.current.text_rendering_matrix();
1339    let start_x = trm.e;
1340    let font_size = trm.font_size_factor();
1341
1342    if font_size < 0.1 {
1343        return None;
1344    }
1345
1346    let mut text = String::new();
1347    let mut total_width = 0.0;
1348    let mut symbol_ends = Vec::new();
1349
1350    let bpc = font.bytes_per_code as usize;
1351    let mut pos = 0;
1352    while pos + bpc <= text_bytes.len() {
1353        let char_code = if bpc == 2 {
1354            ((text_bytes[pos] as u32) << 8) | (text_bytes[pos + 1] as u32)
1355        } else {
1356            text_bytes[pos] as u32
1357        };
1358        pos += bpc;
1359
1360        let decoded = font.decode_char(char_code);
1361        if unicode_override.is_none() {
1362            text.push_str(&decoded);
1363        }
1364
1365        let glyph_w = font.glyph_width(char_code) / 1000.0;
1366        total_width += glyph_w;
1367        symbol_ends.push(start_x + total_width * font_size);
1368
1369        total_width += state.current.text_state.char_spacing / state.current.text_state.font_size;
1370
1371        if decoded == " " {
1372            total_width +=
1373                state.current.text_state.word_spacing / state.current.text_state.font_size;
1374        }
1375    }
1376
1377    if let Some(over) = unicode_override {
1378        text = over.to_string();
1379        if symbol_ends.is_empty() {
1380            // No glyph bytes — estimate width from character count.
1381            let est = over.chars().count() as f64 * 0.5;
1382            total_width = est;
1383            symbol_ends.push(start_x + est * font_size);
1384        }
1385    }
1386
1387    let displacement = total_width * state.current.text_state.font_size;
1388    state.current.advance_text(displacement);
1389
1390    if text.is_empty() {
1391        return None;
1392    }
1393
1394    let trm_after = state.current.text_rendering_matrix();
1395    let ascent = font.ascent;
1396    let descent = font.descent;
1397    let trm_before = &trm;
1398
1399    let (x1, x2) = if trm_before.a >= 0.0 && trm_before.c >= 0.0 {
1400        (
1401            trm_before.e + descent * trm_before.c / 1000.0,
1402            trm_after.e + ascent * trm_after.c / 1000.0,
1403        )
1404    } else if trm_before.a < 0.0 && trm_before.c < 0.0 {
1405        (
1406            trm_after.e + ascent * trm_after.c / 1000.0,
1407            trm_before.e + descent * trm_before.c / 1000.0,
1408        )
1409    } else if trm_before.a >= 0.0 {
1410        (
1411            trm_before.e + ascent * trm_before.c / 1000.0,
1412            trm_after.e + descent * trm_after.c / 1000.0,
1413        )
1414    } else {
1415        (
1416            trm_after.e + descent * trm_after.c / 1000.0,
1417            trm_before.e + ascent * trm_before.c / 1000.0,
1418        )
1419    };
1420
1421    let (y1, y2) = if trm_before.d >= 0.0 && trm_before.b >= 0.0 {
1422        (
1423            trm_before.f + descent * trm_before.d / 1000.0,
1424            trm_after.f + ascent * trm_after.d / 1000.0,
1425        )
1426    } else if trm_before.d < 0.0 && trm_before.b < 0.0 {
1427        (
1428            trm_after.f + ascent * trm_after.d / 1000.0,
1429            trm_before.f + descent * trm_before.d / 1000.0,
1430        )
1431    } else if trm_before.d >= 0.0 {
1432        (
1433            trm_after.f + descent * trm_after.d / 1000.0,
1434            trm_before.f + ascent * trm_before.d / 1000.0,
1435        )
1436    } else {
1437        (
1438            trm_before.f + ascent * trm_before.d / 1000.0,
1439            trm_after.f + descent * trm_after.d / 1000.0,
1440        )
1441    };
1442
1443    let bbox = BoundingBox::new(Some(page_number), x1, y1, x2, y2);
1444
1445    let text_format = if state.current.text_state.rise > font_size * 0.1 {
1446        crate::models::enums::TextFormat::Superscript
1447    } else if state.current.text_state.rise < -font_size * 0.1 {
1448        crate::models::enums::TextFormat::Subscript
1449    } else {
1450        crate::models::enums::TextFormat::Normal
1451    };
1452
1453    *chunk_index += 1;
1454
1455    let fc = &state.current.fill_color;
1456    let font_color = format!(
1457        "[{}]",
1458        fc.iter()
1459            .map(|v| {
1460                let f32_val = *v as f32;
1461                let f64_repr = f32_val as f64;
1462                if f32_val.fract() == 0.0 {
1463                    format!("{:.1}", f64_repr)
1464                } else {
1465                    format!("{}", f64_repr)
1466                }
1467            })
1468            .collect::<Vec<_>>()
1469            .join(", ")
1470    );
1471
1472    Some(TextChunk {
1473        value: text,
1474        bbox,
1475        font_name: font.base_font.clone(),
1476        font_size,
1477        font_weight: font.weight,
1478        italic_angle: font.italic_angle,
1479        font_color,
1480        contrast_ratio: 21.0,
1481        symbol_ends,
1482        text_format,
1483        text_type: crate::models::enums::TextType::Regular,
1484        pdf_layer: crate::models::enums::PdfLayer::Main,
1485        ocg_visible: true,
1486        index: Some(*chunk_index),
1487        page_number: Some(page_number),
1488        level: None,
1489        mcid,
1490    })
1491}
1492
1493/// Advance the text matrix for `text_bytes` without emitting a chunk.
1494fn advance_text_matrix(text_bytes: &[u8], font: &PdfFont, state: &mut GraphicsStateStack) {
1495    let font_size = state.current.text_state.font_size.max(0.1);
1496    let bpc = font.bytes_per_code as usize;
1497    let mut total_width = 0.0;
1498    let mut pos = 0;
1499    while pos + bpc <= text_bytes.len() {
1500        let char_code = if bpc == 2 {
1501            ((text_bytes[pos] as u32) << 8) | (text_bytes[pos + 1] as u32)
1502        } else {
1503            text_bytes[pos] as u32
1504        };
1505        pos += bpc;
1506        let decoded = font.decode_char(char_code);
1507        total_width += font.glyph_width(char_code) / 1000.0;
1508        total_width += state.current.text_state.char_spacing / font_size;
1509        if decoded == " " {
1510            total_width += state.current.text_state.word_spacing / font_size;
1511        }
1512    }
1513    state.current.advance_text(total_width * font_size);
1514}
1515
1516/// Emit a tiny whitespace chunk so word-boundary detectors see an explicit space.
1517fn create_synthetic_space_chunk(
1518    state: &mut GraphicsStateStack,
1519    page_number: u32,
1520    chunk_index: &mut usize,
1521    mcid: Option<i64>,
1522    font_size: f64,
1523) -> Option<TextChunk> {
1524    let trm = state.current.text_rendering_matrix();
1525    let x = trm.e;
1526    let y = trm.f;
1527    *chunk_index += 1;
1528    Some(TextChunk {
1529        value: " ".to_string(),
1530        bbox: BoundingBox::new(Some(page_number), x, y, x + font_size * 0.25, y + font_size),
1531        font_name: String::new(),
1532        font_size,
1533        font_weight: 400.0,
1534        italic_angle: 0.0,
1535        font_color: "[0.0]".to_string(),
1536        contrast_ratio: 21.0,
1537        symbol_ends: vec![x + font_size * 0.25],
1538        text_format: crate::models::enums::TextFormat::Normal,
1539        text_type: crate::models::enums::TextType::Regular,
1540        pdf_layer: crate::models::enums::PdfLayer::Main,
1541        ocg_visible: true,
1542        index: Some(*chunk_index),
1543        page_number: Some(page_number),
1544        level: None,
1545        mcid,
1546    })
1547}
1548
1549/// A segment in a path being constructed.
1550#[derive(Debug, Clone)]
1551enum PathSegment {
1552    Line {
1553        x1: f64,
1554        y1: f64,
1555        x2: f64,
1556        y2: f64,
1557    },
1558    #[allow(dead_code)]
1559    Curve {
1560        x1: f64,
1561        y1: f64,
1562        cp1x: f64,
1563        cp1y: f64,
1564        cp2x: f64,
1565        cp2y: f64,
1566        x2: f64,
1567        y2: f64,
1568    },
1569}
1570
1571/// Try to classify 4 line segments as a rectangle.
1572fn try_classify_rectangle(
1573    segments: &[PathSegment],
1574    _line_width: f64,
1575    page_number: u32,
1576) -> Option<LineChunk> {
1577    let mut min_x = f64::MAX;
1578    let mut min_y = f64::MAX;
1579    let mut max_x = f64::MIN;
1580    let mut max_y = f64::MIN;
1581
1582    for seg in segments {
1583        if let PathSegment::Line { x1, y1, x2, y2 } = seg {
1584            min_x = min_x.min(*x1).min(*x2);
1585            min_y = min_y.min(*y1).min(*y2);
1586            max_x = max_x.max(*x1).max(*x2);
1587            max_y = max_y.max(*y1).max(*y2);
1588        } else {
1589            return None;
1590        }
1591    }
1592
1593    let w = max_x - min_x;
1594    let h = max_y - min_y;
1595
1596    if w < MIN_LINE_WIDTH || h < MIN_LINE_WIDTH {
1597        return None;
1598    }
1599
1600    let is_horizontal = w > h * LINE_ASPECT_RATIO;
1601    let is_vertical = h > w * LINE_ASPECT_RATIO;
1602    // Filled/stroked boxes and AA dots are regions, not geometric rules.
1603    if !is_horizontal && !is_vertical {
1604        return None;
1605    }
1606
1607    Some(LineChunk {
1608        bbox: BoundingBox::new(Some(page_number), min_x, min_y, max_x, max_y),
1609        index: None,
1610        level: None,
1611        start: Vertex {
1612            x: min_x,
1613            y: min_y,
1614            radius: 0.0,
1615        },
1616        end: Vertex {
1617            x: max_x,
1618            y: max_y,
1619            radius: 0.0,
1620        },
1621        width: w.min(h),
1622        is_horizontal_line: is_horizontal,
1623        is_vertical_line: is_vertical,
1624        is_square: false,
1625    })
1626}
1627
1628/// A filled path is a Line only when the painted region is a thin strip
1629/// (one axis ≪ the other). Used for table rules drawn as filled 1pt rects.
1630fn thin_filled_strip_line(
1631    segments: &[PathSegment],
1632    _line_width: f64,
1633    page_number: u32,
1634) -> Option<LineChunk> {
1635    let mut min_x = f64::MAX;
1636    let mut min_y = f64::MAX;
1637    let mut max_x = f64::MIN;
1638    let mut max_y = f64::MIN;
1639
1640    for seg in segments {
1641        let (sx, sy, ex, ey) = match seg {
1642            PathSegment::Line { x1, y1, x2, y2 } => (*x1, *y1, *x2, *y2),
1643            PathSegment::Curve { x1, y1, x2, y2, .. } => (*x1, *y1, *x2, *y2),
1644        };
1645        min_x = min_x.min(sx).min(ex);
1646        min_y = min_y.min(sy).min(ey);
1647        max_x = max_x.max(sx).max(ex);
1648        max_y = max_y.max(sy).max(ey);
1649    }
1650
1651    if !min_x.is_finite() {
1652        return None;
1653    }
1654
1655    let w = max_x - min_x;
1656    let h = max_y - min_y;
1657    if w < MIN_LINE_WIDTH || h < MIN_LINE_WIDTH {
1658        return None;
1659    }
1660
1661    let is_horizontal = h <= MAX_LINE_THICKNESS && w > h * LINE_ASPECT_RATIO;
1662    let is_vertical = w <= MAX_LINE_THICKNESS && h > w * LINE_ASPECT_RATIO;
1663    if !is_horizontal && !is_vertical {
1664        return None;
1665    }
1666
1667    let (sx, sy, ex, ey) = if is_horizontal {
1668        (min_x, (min_y + max_y) * 0.5, max_x, (min_y + max_y) * 0.5)
1669    } else {
1670        ((min_x + max_x) * 0.5, min_y, (min_x + max_x) * 0.5, max_y)
1671    };
1672
1673    Some(LineChunk {
1674        bbox: BoundingBox::new(Some(page_number), min_x, min_y, max_x, max_y),
1675        index: None,
1676        level: None,
1677        start: Vertex {
1678            x: sx,
1679            y: sy,
1680            radius: 0.0,
1681        },
1682        end: Vertex {
1683            x: ex,
1684            y: ey,
1685            radius: 0.0,
1686        },
1687        width: w.min(h),
1688        is_horizontal_line: is_horizontal,
1689        is_vertical_line: is_vertical,
1690        is_square: false,
1691    })
1692}
1693
1694/// Get the /Matrix from a Form XObject dictionary (defaults to identity).
1695fn get_form_matrix(doc: &Document, dict: &Dictionary) -> Matrix {
1696    match dict.get(b"Matrix") {
1697        Ok(obj) => {
1698            let resolved = resolve_obj(doc, obj);
1699            if let Ok(arr) = resolved.as_array() {
1700                let vals: Vec<f64> = arr.iter().filter_map(|o| obj_to_f64(o.clone())).collect();
1701                if vals.len() == 6 {
1702                    return Matrix {
1703                        a: vals[0],
1704                        b: vals[1],
1705                        c: vals[2],
1706                        d: vals[3],
1707                        e: vals[4],
1708                        f: vals[5],
1709                    };
1710                }
1711            }
1712            Matrix::identity()
1713        }
1714        Err(_) => Matrix::identity(),
1715    }
1716}
1717
1718/// Resolve fonts from a resources dictionary (for Form XObjects).
1719fn resolve_form_fonts(doc: &Document, resources: &Dictionary) -> FontCache {
1720    let font_dict = match resources.get(b"Font") {
1721        Ok(obj) => {
1722            let resolved = resolve_obj(doc, obj);
1723            match resolved.as_dict() {
1724                Ok(d) => d.clone(),
1725                Err(_) => return FontCache::default(),
1726            }
1727        }
1728        Err(_) => return FontCache::default(),
1729    };
1730
1731    let mut cache = FontCache::default();
1732    for (name_bytes, font_ref) in font_dict.iter() {
1733        let font_name = String::from_utf8_lossy(name_bytes).to_string();
1734        let font_obj = resolve_obj(doc, font_ref);
1735        if let Ok(font_dict) = font_obj.as_dict() {
1736            let font = super::font::resolve_font_dict(doc, &font_name, font_dict);
1737            cache.insert(font_name, font);
1738        }
1739    }
1740    cache
1741}
1742
1743// ── Utility functions ──
1744
1745fn extract_string_bytes(obj: &Object) -> Option<Vec<u8>> {
1746    match obj {
1747        Object::String(bytes, _) => Some(bytes.clone()),
1748        _ => None,
1749    }
1750}
1751
1752/// Resolve the BDC properties dictionary (inline or named via `/Properties`).
1753fn resolve_bdc_properties(
1754    doc: &Document,
1755    resources: &Dictionary,
1756    operands: &[Object],
1757) -> Option<Dictionary> {
1758    if operands.len() < 2 {
1759        return None;
1760    }
1761    match &operands[1] {
1762        Object::Dictionary(dict) => Some(dict.clone()),
1763        Object::Name(name) => {
1764            let props = resources.get(b"Properties").ok()?;
1765            let props_dict = match resolve_obj(doc, props) {
1766                Object::Dictionary(d) => d,
1767                _ => return None,
1768            };
1769            match props_dict.get(name.as_slice()).ok() {
1770                Some(obj) => match resolve_obj(doc, obj) {
1771                    Object::Dictionary(d) => Some(d),
1772                    _ => None,
1773                },
1774                None => None,
1775            }
1776        }
1777        Object::Reference(id) => match doc.get_object(*id).ok()? {
1778            Object::Dictionary(d) => Some(d.clone()),
1779            _ => None,
1780        },
1781        _ => None,
1782    }
1783}
1784
1785/// Extract `/ActualText` from a marked-content properties dictionary.
1786fn extract_actual_text(props: &Dictionary) -> Option<String> {
1787    let obj = props.get(b"ActualText").ok()?;
1788    match obj {
1789        Object::String(bytes, _) => {
1790            let s = decode_pdf_string(bytes);
1791            if s.is_empty() {
1792                None
1793            } else {
1794                Some(s)
1795            }
1796        }
1797        _ => None,
1798    }
1799}
1800
1801/// Resolve `/OC` on a BDC properties dict against catalog OCG defaults.
1802fn ocg_visibility_from_props(
1803    doc: &Document,
1804    props: &Dictionary,
1805    vis: &OcgVisibility,
1806) -> Option<bool> {
1807    let oc = props.get(b"OC").ok()?;
1808    match resolve_obj(doc, oc) {
1809        Object::Reference(id) => Some(vis.is_visible(id)),
1810        Object::Dictionary(d) => {
1811            // Optional content membership dict — honor /Type /OCG by name lookup
1812            // when we only have an inline dict without a stable id (treat as ON).
1813            let _ = d;
1814            Some(true)
1815        }
1816        _ => None,
1817    }
1818}
1819
1820fn obj_to_f64(obj: Object) -> Option<f64> {
1821    match obj {
1822        Object::Integer(i) => Some(i as f64),
1823        Object::Real(f) => Some(f),
1824        _ => None,
1825    }
1826}
1827
1828fn obj_to_name(obj: &Object) -> String {
1829    match obj {
1830        Object::Name(bytes) => String::from_utf8_lossy(bytes).to_string(),
1831        _ => String::new(),
1832    }
1833}
1834
1835/// Extract raw name bytes from a PDF Name object.
1836fn obj_name_bytes(obj: &Object) -> Option<Vec<u8>> {
1837    match obj {
1838        Object::Name(bytes) => Some(bytes.clone()),
1839        _ => None,
1840    }
1841}
1842
1843fn color_space_components(name: &str) -> u8 {
1844    match name {
1845        "DeviceGray" | "CalGray" | "G" => 1,
1846        "DeviceRGB" | "CalRGB" | "RGB" => 3,
1847        "DeviceCMYK" | "CMYK" => 4,
1848        "Pattern" => 0,
1849        _ => 3,
1850    }
1851}
1852
1853/// Default color for a given color space component count (PDF spec 8.6.5.3).
1854fn default_color_for_space(components: u8) -> Vec<f64> {
1855    match components {
1856        0 => Vec::new(),               // Pattern: no numeric default
1857        4 => vec![0.0, 0.0, 0.0, 1.0], // CMYK: default black
1858        3 => vec![0.0, 0.0, 0.0],      // RGB: default black
1859        _ => vec![0.0],                // Gray: default black
1860    }
1861}
1862
1863fn resolve_obj(doc: &Document, obj: &Object) -> Object {
1864    match obj {
1865        Object::Reference(id) => doc.get_object(*id).cloned().unwrap_or(Object::Null),
1866        other => other.clone(),
1867    }
1868}
1869
1870/// Axis-aligned bbox of a path in page space.
1871fn path_bbox(path: &[PathSegment]) -> (f64, f64, f64, f64) {
1872    let mut min_x = f64::MAX;
1873    let mut min_y = f64::MAX;
1874    let mut max_x = f64::MIN;
1875    let mut max_y = f64::MIN;
1876    for seg in path {
1877        let (sx, sy, ex, ey) = match seg {
1878            PathSegment::Line { x1, y1, x2, y2 } => (*x1, *y1, *x2, *y2),
1879            PathSegment::Curve { x1, y1, x2, y2, .. } => (*x1, *y1, *x2, *y2),
1880        };
1881        min_x = min_x.min(sx).min(ex);
1882        min_y = min_y.min(sy).min(ey);
1883        max_x = max_x.max(sx).max(ex);
1884        max_y = max_y.max(sy).max(ey);
1885    }
1886    if !min_x.is_finite() {
1887        return (0.0, 0.0, 0.0, 0.0);
1888    }
1889    (min_x, min_y, max_x, max_y)
1890}
1891
1892#[cfg(test)]
1893mod tests {
1894    use super::*;
1895    use lopdf::content::Operation;
1896    use lopdf::{dictionary, Stream};
1897
1898    fn create_test_pdf_with_text() -> Document {
1899        let mut doc = Document::with_version("1.5");
1900        let pages_id = doc.new_object_id();
1901
1902        let font_id = doc.add_object(dictionary! {
1903            "Type" => "Font",
1904            "Subtype" => "Type1",
1905            "BaseFont" => "Helvetica",
1906        });
1907
1908        let resources_id = doc.add_object(dictionary! {
1909            "Font" => dictionary! {
1910                "F1" => font_id,
1911            },
1912        });
1913
1914        let content = Content {
1915            operations: vec![
1916                Operation::new("BT", vec![]),
1917                Operation::new("Tf", vec!["F1".into(), 12.into()]),
1918                Operation::new("Td", vec![100.into(), 700.into()]),
1919                Operation::new("Tj", vec![Object::string_literal("Hello World!")]),
1920                Operation::new("ET", vec![]),
1921            ],
1922        };
1923
1924        let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
1925
1926        let page_id = doc.add_object(dictionary! {
1927            "Type" => "Page",
1928            "Parent" => pages_id,
1929            "Contents" => content_id,
1930            "Resources" => resources_id,
1931            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
1932        });
1933
1934        let pages = dictionary! {
1935            "Type" => "Pages",
1936            "Kids" => vec![page_id.into()],
1937            "Count" => 1,
1938        };
1939        doc.objects.insert(pages_id, Object::Dictionary(pages));
1940
1941        let catalog_id = doc.add_object(dictionary! {
1942            "Type" => "Catalog",
1943            "Pages" => pages_id,
1944        });
1945        doc.trailer.set("Root", catalog_id);
1946        doc
1947    }
1948
1949    #[test]
1950    fn test_unified_text_extraction() {
1951        let doc = create_test_pdf_with_text();
1952        let pages = doc.get_pages();
1953        let (&page_num, &page_id) = pages.iter().next().unwrap();
1954
1955        let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
1956        assert!(!chunks.text_chunks.is_empty(), "Expected text chunks");
1957        assert!(
1958            chunks.text_chunks[0].value.contains("Hello"),
1959            "Expected 'Hello' in text"
1960        );
1961        assert!(chunks.image_chunks.is_empty(), "No images expected");
1962    }
1963
1964    #[test]
1965    fn test_image_from_do_operator() {
1966        let mut doc = Document::with_version("1.5");
1967        let pages_id = doc.new_object_id();
1968
1969        // Create image XObject
1970        let img_stream = Stream::new(
1971            dictionary! {
1972                "Type" => "XObject",
1973                "Subtype" => "Image",
1974                "Width" => 200,
1975                "Height" => 100,
1976                "ColorSpace" => "DeviceRGB",
1977                "BitsPerComponent" => 8,
1978            },
1979            vec![0u8; 100],
1980        );
1981        let img_id = doc.add_object(img_stream);
1982
1983        let resources_id = doc.add_object(dictionary! {
1984            "XObject" => dictionary! {
1985                "Im1" => img_id,
1986            },
1987        });
1988
1989        // Content stream: scale image and place at (72, 500)
1990        let content = Content {
1991            operations: vec![
1992                Operation::new("q", vec![]),
1993                Operation::new(
1994                    "cm",
1995                    vec![
1996                        Object::Real(200.0), // a: width
1997                        0.into(),
1998                        0.into(),
1999                        Object::Real(100.0), // d: height
2000                        Object::Real(72.0),  // e: x position
2001                        Object::Real(500.0), // f: y position
2002                    ],
2003                ),
2004                Operation::new("Do", vec!["Im1".into()]),
2005                Operation::new("Q", vec![]),
2006            ],
2007        };
2008
2009        let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2010
2011        let page_id = doc.add_object(dictionary! {
2012            "Type" => "Page",
2013            "Parent" => pages_id,
2014            "Contents" => content_id,
2015            "Resources" => resources_id,
2016            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2017        });
2018
2019        let pages = dictionary! {
2020            "Type" => "Pages",
2021            "Kids" => vec![page_id.into()],
2022            "Count" => 1,
2023        };
2024        doc.objects.insert(pages_id, Object::Dictionary(pages));
2025
2026        let catalog_id = doc.add_object(dictionary! {
2027            "Type" => "Catalog",
2028            "Pages" => pages_id,
2029        });
2030        doc.trailer.set("Root", catalog_id);
2031
2032        let pages = doc.get_pages();
2033        let (&page_num, &page_id) = pages.iter().next().unwrap();
2034
2035        let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2036        assert_eq!(chunks.image_chunks.len(), 1, "Expected 1 image chunk");
2037
2038        let img = &chunks.image_chunks[0];
2039        // Image should be at (72, 500) with size (200, 100)
2040        assert!(
2041            (img.bbox.left_x - 72.0).abs() < 1.0,
2042            "Expected left_x ~72, got {}",
2043            img.bbox.left_x
2044        );
2045        assert!(
2046            (img.bbox.bottom_y - 500.0).abs() < 1.0,
2047            "Expected bottom_y ~500, got {}",
2048            img.bbox.bottom_y
2049        );
2050        assert!(
2051            (img.bbox.right_x - 272.0).abs() < 1.0,
2052            "Expected right_x ~272, got {}",
2053            img.bbox.right_x
2054        );
2055        assert!(
2056            (img.bbox.top_y - 600.0).abs() < 1.0,
2057            "Expected top_y ~600, got {}",
2058            img.bbox.top_y
2059        );
2060    }
2061
2062    #[test]
2063    fn test_pattern_fill_emits_image_not_lines() {
2064        let mut doc = Document::with_version("1.5");
2065        let pages_id = doc.new_object_id();
2066
2067        let img_id = doc.add_object(Stream::new(
2068            dictionary! {
2069                "Type" => "XObject",
2070                "Subtype" => "Image",
2071                "Width" => 200,
2072                "Height" => 100,
2073                "ColorSpace" => "DeviceRGB",
2074                "BitsPerComponent" => 8,
2075            },
2076            vec![2u8; 200],
2077        ));
2078
2079        let pattern_id = doc.add_object(dictionary! {
2080            "Type" => "Pattern",
2081            "PatternType" => 1,
2082            "PaintType" => 1,
2083            "TilingType" => 1,
2084            "BBox" => vec![0.into(), 0.into(), 200.into(), 100.into()],
2085            "XStep" => 200,
2086            "YStep" => 100,
2087            "Resources" => dictionary! {
2088                "XObject" => dictionary! {
2089                    "X1" => img_id,
2090                },
2091            },
2092        });
2093
2094        let resources_id = doc.add_object(dictionary! {
2095            "Pattern" => dictionary! {
2096                "P1" => pattern_id,
2097            },
2098        });
2099
2100        // Pattern fill a rectangle — Skia/Penpot style page paint.
2101        let content = Content {
2102            operations: vec![
2103                Operation::new("cs", vec!["Pattern".into()]),
2104                Operation::new("scn", vec!["P1".into()]),
2105                Operation::new(
2106                    "re",
2107                    vec![
2108                        Object::Real(10.0),
2109                        Object::Real(20.0),
2110                        Object::Real(400.0),
2111                        Object::Real(300.0),
2112                    ],
2113                ),
2114                Operation::new("f", vec![]),
2115            ],
2116        };
2117
2118        let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2119
2120        let page_id = doc.add_object(dictionary! {
2121            "Type" => "Page",
2122            "Parent" => pages_id,
2123            "Contents" => content_id,
2124            "Resources" => resources_id,
2125            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2126        });
2127
2128        let pages = dictionary! {
2129            "Type" => "Pages",
2130            "Kids" => vec![page_id.into()],
2131            "Count" => 1,
2132        };
2133        doc.objects.insert(pages_id, Object::Dictionary(pages));
2134
2135        let catalog_id = doc.add_object(dictionary! {
2136            "Type" => "Catalog",
2137            "Pages" => pages_id,
2138        });
2139        doc.trailer.set("Root", catalog_id);
2140
2141        let pages = doc.get_pages();
2142        let (&page_num, &page_id) = pages.iter().next().unwrap();
2143
2144        let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2145        assert_eq!(chunks.image_chunks.len(), 1, "Expected 1 pattern image");
2146        assert!(
2147            chunks.line_chunks.is_empty(),
2148            "Pattern fill must not emit page-edge lines, got {}",
2149            chunks.line_chunks.len()
2150        );
2151
2152        let img = &chunks.image_chunks[0];
2153        assert_eq!(img.index, Some(1));
2154        assert!((img.bbox.left_x - 10.0).abs() < 1.0);
2155        assert!((img.bbox.bottom_y - 20.0).abs() < 1.0);
2156        assert!((img.bbox.right_x - 410.0).abs() < 1.0);
2157        assert!((img.bbox.top_y - 320.0).abs() < 1.0);
2158    }
2159
2160    #[test]
2161    fn test_filled_aa_dots_do_not_emit_lines() {
2162        // Skia soft shapes are often thousands of 1×1 filled rects. Those are
2163        // region paint, not geometric rules. A thin filled strip must still
2164        // become a single horizontal Line (table rule drawn as fill).
2165        let mut doc = Document::with_version("1.5");
2166        let pages_id = doc.new_object_id();
2167
2168        let mut ops = vec![Operation::new(
2169            "rg",
2170            vec![Object::Real(0.8), Object::Real(0.7), Object::Real(0.9)],
2171        )];
2172        for i in 0..50 {
2173            let x = 10.0 + (i % 10) as f64;
2174            let y = 10.0 + (i / 10) as f64;
2175            ops.push(Operation::new(
2176                "re",
2177                vec![
2178                    Object::Real(x),
2179                    Object::Real(y),
2180                    Object::Real(1.0),
2181                    Object::Real(1.0),
2182                ],
2183            ));
2184            ops.push(Operation::new("f", vec![]));
2185        }
2186        // Thin horizontal rule as filled rect
2187        ops.push(Operation::new(
2188            "re",
2189            vec![
2190                Object::Real(50.0),
2191                Object::Real(200.0),
2192                Object::Real(200.0),
2193                Object::Real(1.0),
2194            ],
2195        ));
2196        ops.push(Operation::new("f", vec![]));
2197
2198        let content = Content { operations: ops };
2199        let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2200
2201        let page_id = doc.add_object(dictionary! {
2202            "Type" => "Page",
2203            "Parent" => pages_id,
2204            "Contents" => content_id,
2205            "Resources" => dictionary! {},
2206            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2207        });
2208
2209        let pages = dictionary! {
2210            "Type" => "Pages",
2211            "Kids" => vec![page_id.into()],
2212            "Count" => 1,
2213        };
2214        doc.objects.insert(pages_id, Object::Dictionary(pages));
2215
2216        let catalog_id = doc.add_object(dictionary! {
2217            "Type" => "Catalog",
2218            "Pages" => pages_id,
2219        });
2220        doc.trailer.set("Root", catalog_id);
2221
2222        let pages = doc.get_pages();
2223        let (&page_num, &page_id) = pages.iter().next().unwrap();
2224        let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2225
2226        assert_eq!(
2227            chunks.line_chunks.len(),
2228            1,
2229            "expected only the thin filled strip as a Line, got {}",
2230            chunks.line_chunks.len()
2231        );
2232        assert!(chunks.line_chunks[0].is_horizontal_line);
2233        assert!(chunks.line_art_chunks.is_empty());
2234    }
2235
2236    #[test]
2237    fn test_form_xobject_recursive() {
2238        let mut doc = Document::with_version("1.5");
2239        let pages_id = doc.new_object_id();
2240
2241        // Create a font
2242        let font_id = doc.add_object(dictionary! {
2243            "Type" => "Font",
2244            "Subtype" => "Type1",
2245            "BaseFont" => "Helvetica",
2246        });
2247
2248        // Create a Form XObject with text inside
2249        let form_content = Content {
2250            operations: vec![
2251                Operation::new("BT", vec![]),
2252                Operation::new("Tf", vec!["F1".into(), 10.into()]),
2253                Operation::new("Td", vec![0.into(), 0.into()]),
2254                Operation::new("Tj", vec![Object::string_literal("Form Text")]),
2255                Operation::new("ET", vec![]),
2256            ],
2257        };
2258
2259        let form_stream = Stream::new(
2260            dictionary! {
2261                "Type" => "XObject",
2262                "Subtype" => "Form",
2263                "BBox" => vec![0.into(), 0.into(), 200.into(), 50.into()],
2264                "Resources" => dictionary! {
2265                    "Font" => dictionary! {
2266                        "F1" => font_id,
2267                    },
2268                },
2269            },
2270            form_content.encode().unwrap(),
2271        );
2272        let form_id = doc.add_object(form_stream);
2273
2274        let resources_id = doc.add_object(dictionary! {
2275            "Font" => dictionary! {
2276                "F1" => font_id,
2277            },
2278            "XObject" => dictionary! {
2279                "Fm1" => form_id,
2280            },
2281        });
2282
2283        // Page content stream: invoke the form XObject
2284        let page_content = Content {
2285            operations: vec![
2286                Operation::new("q", vec![]),
2287                Operation::new(
2288                    "cm",
2289                    vec![
2290                        1.into(),
2291                        0.into(),
2292                        0.into(),
2293                        1.into(),
2294                        Object::Real(50.0),
2295                        Object::Real(400.0),
2296                    ],
2297                ),
2298                Operation::new("Do", vec!["Fm1".into()]),
2299                Operation::new("Q", vec![]),
2300            ],
2301        };
2302
2303        let content_id =
2304            doc.add_object(Stream::new(dictionary! {}, page_content.encode().unwrap()));
2305
2306        let page_id = doc.add_object(dictionary! {
2307            "Type" => "Page",
2308            "Parent" => pages_id,
2309            "Contents" => content_id,
2310            "Resources" => resources_id,
2311            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2312        });
2313
2314        let pages = dictionary! {
2315            "Type" => "Pages",
2316            "Kids" => vec![page_id.into()],
2317            "Count" => 1,
2318        };
2319        doc.objects.insert(pages_id, Object::Dictionary(pages));
2320
2321        let catalog_id = doc.add_object(dictionary! {
2322            "Type" => "Catalog",
2323            "Pages" => pages_id,
2324        });
2325        doc.trailer.set("Root", catalog_id);
2326
2327        let pages = doc.get_pages();
2328        let (&page_num, &page_id) = pages.iter().next().unwrap();
2329
2330        let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2331        assert!(
2332            !chunks.text_chunks.is_empty(),
2333            "Expected text from Form XObject"
2334        );
2335        assert!(
2336            chunks.text_chunks[0].value.contains("Form"),
2337            "Expected 'Form' text, got: '{}'",
2338            chunks.text_chunks[0].value
2339        );
2340    }
2341
2342    #[test]
2343    fn test_line_extraction_unified() {
2344        let mut doc = Document::with_version("1.5");
2345        let pages_id = doc.new_object_id();
2346
2347        let content = Content {
2348            operations: vec![
2349                Operation::new("w", vec![Object::Real(1.0)]),
2350                Operation::new("m", vec![72.into(), 400.into()]),
2351                Operation::new("l", vec![500.into(), 400.into()]),
2352                Operation::new("S", vec![]),
2353            ],
2354        };
2355
2356        let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2357
2358        let page_id = doc.add_object(dictionary! {
2359            "Type" => "Page",
2360            "Parent" => pages_id,
2361            "Contents" => content_id,
2362            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2363        });
2364
2365        let pages = dictionary! {
2366            "Type" => "Pages",
2367            "Kids" => vec![page_id.into()],
2368            "Count" => 1,
2369        };
2370        doc.objects.insert(pages_id, Object::Dictionary(pages));
2371
2372        let catalog_id = doc.add_object(dictionary! {
2373            "Type" => "Catalog",
2374            "Pages" => pages_id,
2375        });
2376        doc.trailer.set("Root", catalog_id);
2377
2378        let pages = doc.get_pages();
2379        let (&page_num, &page_id) = pages.iter().next().unwrap();
2380
2381        let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2382        assert_eq!(chunks.line_chunks.len(), 1, "Expected 1 horizontal line");
2383        assert!(chunks.line_chunks[0].is_horizontal_line);
2384    }
2385
2386    #[test]
2387    fn test_actual_text_bdc_replaces_glyphs() {
2388        // Glyph shows "xx" but /ActualText says "42" — extraction must prefer ActualText.
2389        let mut doc = Document::with_version("1.5");
2390        let pages_id = doc.new_object_id();
2391
2392        let font_id = doc.add_object(dictionary! {
2393            "Type" => "Font",
2394            "Subtype" => "Type1",
2395            "BaseFont" => "Helvetica",
2396        });
2397
2398        // UTF-16BE BOM + "42"
2399        let actual_bytes: Vec<u8> = vec![0xFE, 0xFF, 0x00, b'4', 0x00, b'2'];
2400        let content = Content {
2401            operations: vec![
2402                Operation::new("BT", vec![]),
2403                Operation::new("Tf", vec!["F1".into(), 12.into()]),
2404                Operation::new("Td", vec![72.into(), 720.into()]),
2405                Operation::new(
2406                    "BDC",
2407                    vec![
2408                        "Span".into(),
2409                        Object::Dictionary(dictionary! {
2410                            "ActualText" => Object::String(
2411                                actual_bytes,
2412                                lopdf::StringFormat::Hexadecimal,
2413                            ),
2414                        }),
2415                    ],
2416                ),
2417                Operation::new("Tj", vec![Object::string_literal("xx")]),
2418                Operation::new("EMC", vec![]),
2419                Operation::new("ET", vec![]),
2420            ],
2421        };
2422
2423        let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2424        let resources_id = doc.add_object(dictionary! {
2425            "Font" => dictionary! { "F1" => font_id },
2426        });
2427
2428        let page_id = doc.add_object(dictionary! {
2429            "Type" => "Page",
2430            "Parent" => pages_id,
2431            "Contents" => content_id,
2432            "Resources" => resources_id,
2433            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2434        });
2435
2436        let pages = dictionary! {
2437            "Type" => "Pages",
2438            "Kids" => vec![page_id.into()],
2439            "Count" => 1,
2440        };
2441        doc.objects.insert(pages_id, Object::Dictionary(pages));
2442
2443        let catalog_id = doc.add_object(dictionary! {
2444            "Type" => "Catalog",
2445            "Pages" => pages_id,
2446        });
2447        doc.trailer.set("Root", catalog_id);
2448
2449        let pages = doc.get_pages();
2450        let (&page_num, &page_id) = pages.iter().next().unwrap();
2451        let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2452
2453        let joined: String = chunks.text_chunks.iter().map(|c| c.value.as_str()).collect();
2454        assert!(
2455            joined.contains("42"),
2456            "Expected ActualText '42', got chunks: {:?}",
2457            chunks
2458                .text_chunks
2459                .iter()
2460                .map(|c| &c.value)
2461                .collect::<Vec<_>>()
2462        );
2463        assert!(
2464            !joined.contains("xx"),
2465            "Glyph text must not leak when ActualText is present, got: {joined:?}"
2466        );
2467    }
2468
2469    #[test]
2470    fn test_tj_large_negative_inserts_space() {
2471        let mut doc = Document::with_version("1.5");
2472        let pages_id = doc.new_object_id();
2473
2474        let font_id = doc.add_object(dictionary! {
2475            "Type" => "Font",
2476            "Subtype" => "Type1",
2477            "BaseFont" => "Helvetica",
2478        });
2479
2480        let content = Content {
2481            operations: vec![
2482                Operation::new("BT", vec![]),
2483                Operation::new("Tf", vec!["F1".into(), 12.into()]),
2484                Operation::new("Td", vec![72.into(), 720.into()]),
2485                Operation::new(
2486                    "TJ",
2487                    vec![Object::Array(vec![
2488                        Object::string_literal("Hello"),
2489                        Object::Integer(-250),
2490                        Object::string_literal("World"),
2491                    ])],
2492                ),
2493                Operation::new("ET", vec![]),
2494            ],
2495        };
2496
2497        let content_id = doc.add_object(Stream::new(dictionary! {}, content.encode().unwrap()));
2498        let resources_id = doc.add_object(dictionary! {
2499            "Font" => dictionary! { "F1" => font_id },
2500        });
2501
2502        let page_id = doc.add_object(dictionary! {
2503            "Type" => "Page",
2504            "Parent" => pages_id,
2505            "Contents" => content_id,
2506            "Resources" => resources_id,
2507            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
2508        });
2509
2510        let pages = dictionary! {
2511            "Type" => "Pages",
2512            "Kids" => vec![page_id.into()],
2513            "Count" => 1,
2514        };
2515        doc.objects.insert(pages_id, Object::Dictionary(pages));
2516
2517        let catalog_id = doc.add_object(dictionary! {
2518            "Type" => "Catalog",
2519            "Pages" => pages_id,
2520        });
2521        doc.trailer.set("Root", catalog_id);
2522
2523        let pages = doc.get_pages();
2524        let (&page_num, &page_id) = pages.iter().next().unwrap();
2525        let chunks = extract_page_chunks(&doc, page_num, page_id).unwrap();
2526        let joined: String = chunks.text_chunks.iter().map(|c| c.value.as_str()).collect();
2527        assert!(
2528            joined.contains("Hello") && joined.contains("World"),
2529            "got: {joined:?}"
2530        );
2531        assert!(
2532            joined.contains(' '),
2533            "large negative TJ must insert space, got: {joined:?}"
2534        );
2535    }
2536}