Skip to main content

edgeparse_core/pdf/
text_extractor.rs

1//! Text extraction from PDF content streams.
2//!
3//! Walks content stream operations and produces TextChunks with position,
4//! font, and Unicode text information.
5
6use lopdf::{content::Content, Document, Object};
7
8use crate::models::bbox::BoundingBox;
9use crate::models::chunks::TextChunk;
10use crate::EdgePdfError;
11
12use super::font::{resolve_page_fonts, FontCache, PdfFont};
13use super::graphics_state::GraphicsStateStack;
14
15/// Extract text chunks from a single page.
16///
17/// Returns a vector of positioned text chunks with font and bounding box info.
18pub fn extract_text_chunks(
19    doc: &Document,
20    page_number: u32,
21    page_id: lopdf::ObjectId,
22) -> Result<Vec<TextChunk>, EdgePdfError> {
23    let font_cache = resolve_page_fonts(doc, page_id);
24    let page_dict = doc
25        .get_object(page_id)
26        .map_err(|e| EdgePdfError::PipelineError {
27            stage: 1,
28            message: format!("Failed to get page {}: {}", page_number, e),
29        })?
30        .as_dict()
31        .map_err(|e| EdgePdfError::PipelineError {
32            stage: 1,
33            message: format!("Page {} is not a dictionary: {}", page_number, e),
34        })?
35        .clone();
36
37    // Get MediaBox for page dimensions
38    let media_box = get_media_box(doc, &page_dict);
39
40    // Get content stream(s)
41    let content_data = get_page_content(doc, &page_dict)?;
42    if content_data.is_empty() {
43        return Ok(Vec::new());
44    }
45
46    // Parse content stream operations
47    let content = Content::decode(&content_data).map_err(|e| EdgePdfError::PipelineError {
48        stage: 1,
49        message: format!(
50            "Failed to decode content stream for page {}: {}",
51            page_number, e
52        ),
53    })?;
54
55    // Process operations
56    let chunks = process_operations(&content.operations, &font_cache, page_number, &media_box);
57
58    Ok(chunks)
59}
60
61/// Get page content stream data.
62pub(crate) fn get_page_content(
63    doc: &Document,
64    page_dict: &lopdf::Dictionary,
65) -> Result<Vec<u8>, EdgePdfError> {
66    let contents = match page_dict.get(b"Contents") {
67        Ok(c) => c.clone(),
68        Err(_) => return Ok(Vec::new()),
69    };
70
71    // Helper: collect stream bytes from an array of items (each item may be a
72    // Reference-to-Stream or a direct Stream).
73    fn collect_array(doc: &Document, arr: &[Object]) -> Result<Vec<u8>, EdgePdfError> {
74        let mut data = Vec::new();
75        for item in arr {
76            let obj = match item {
77                Object::Reference(id) => {
78                    doc.get_object(*id)
79                        .map_err(|e| EdgePdfError::PipelineError {
80                            stage: 1,
81                            message: format!("Failed to resolve content array item: {}", e),
82                        })?
83                }
84                other => other,
85            };
86            if let Object::Stream(ref stream) = obj {
87                if let Ok(content) = get_stream_data(stream) {
88                    data.extend_from_slice(&content);
89                    data.push(b' '); // Separate stream contents
90                }
91            }
92        }
93        Ok(data)
94    }
95
96    match contents {
97        Object::Reference(id) => {
98            let obj = doc
99                .get_object(id)
100                .map_err(|e| EdgePdfError::PipelineError {
101                    stage: 1,
102                    message: format!("Failed to get content object: {}", e),
103                })?;
104            match obj {
105                // Most common: Contents → single stream
106                Object::Stream(ref stream) => get_stream_data(stream),
107                // Also valid: Contents → Reference → Array of stream references
108                Object::Array(ref arr) => collect_array(doc, arr),
109                _ => Ok(Vec::new()),
110            }
111        }
112        Object::Array(ref arr) => collect_array(doc, arr),
113        _ => Ok(Vec::new()),
114    }
115}
116
117/// Get data from a stream, handling both compressed and uncompressed cases.
118fn get_stream_data(stream: &lopdf::Stream) -> Result<Vec<u8>, EdgePdfError> {
119    // If no Filter key, the content is already uncompressed
120    if stream.dict.get(b"Filter").is_err() {
121        return Ok(stream.content.clone());
122    }
123    stream
124        .decompressed_content()
125        .map_err(|e| EdgePdfError::PipelineError {
126            stage: 1,
127            message: format!("Failed to decompress content stream: {}", e),
128        })
129}
130
131/// Get page MediaBox.
132fn get_media_box(doc: &Document, page_dict: &lopdf::Dictionary) -> BoundingBox {
133    if let Ok(mb) = page_dict.get(b"MediaBox") {
134        if let Ok(arr) = resolve_obj(doc, mb).as_array() {
135            if arr.len() == 4 {
136                let vals: Vec<f64> = arr
137                    .iter()
138                    .filter_map(|o| obj_to_f64(resolve_obj(doc, o)))
139                    .collect();
140                if vals.len() == 4 {
141                    return BoundingBox::new(None, vals[0], vals[1], vals[2], vals[3]);
142                }
143            }
144        }
145    }
146    // Default A4 page
147    BoundingBox::new(None, 0.0, 0.0, 595.0, 842.0)
148}
149
150/// Process content stream operations and extract text chunks.
151fn process_operations(
152    operations: &[lopdf::content::Operation],
153    font_cache: &FontCache,
154    page_number: u32,
155    _media_box: &BoundingBox,
156) -> Vec<TextChunk> {
157    let mut chunks = Vec::new();
158    let mut state = GraphicsStateStack::default();
159    let mut chunk_index: usize = 0;
160    // Track marked content sequence IDs (BDC/BMC/EMC)
161    let mut mcid_stack: Vec<Option<i64>> = Vec::new();
162
163    for op in operations {
164        match op.operator.as_str() {
165            // Marked content operators — track MCID for structure tree linkage
166            "BMC" => {
167                // Begin Marked Content (no properties) — push None
168                mcid_stack.push(None);
169            }
170            "BDC" => {
171                // Begin Marked Content with properties — extract MCID if present
172                let mcid = extract_mcid_from_bdc(&op.operands);
173                mcid_stack.push(mcid);
174            }
175            "EMC" => {
176                // End Marked Content — pop the stack
177                mcid_stack.pop();
178            }
179
180            // Graphics state operators
181            "q" => state.save(),
182            "Q" => state.restore(),
183            "cm"
184                if op.operands.len() == 6 => {
185                    let vals: Vec<f64> = op
186                        .operands
187                        .iter()
188                        .filter_map(|o| obj_to_f64(o.clone()))
189                        .collect();
190                    if vals.len() == 6 {
191                        state.concat_ctm(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]);
192                    }
193                }
194
195            // Text state operators
196            "BT" => state.current.begin_text(),
197            "ET" => {} // End text object
198
199            "Tf"
200                // Set font and size
201                if op.operands.len() == 2 => {
202                    if let Object::Name(ref name) = op.operands[0] {
203                        state.current.text_state.font_name =
204                            String::from_utf8_lossy(name).to_string();
205                    }
206                    if let Some(size) = obj_to_f64(op.operands[1].clone()) {
207                        state.current.text_state.font_size = size;
208                    }
209                }
210
211            "Tc" => {
212                if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
213                    state.current.text_state.char_spacing = v;
214                }
215            }
216
217            "Tw" => {
218                if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
219                    state.current.text_state.word_spacing = v;
220                }
221            }
222
223            "Tz" => {
224                if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
225                    state.current.text_state.horizontal_scaling = v;
226                }
227            }
228
229            "TL" => {
230                if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
231                    state.current.text_state.leading = v;
232                }
233            }
234
235            "Ts" => {
236                if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
237                    state.current.text_state.rise = v;
238                }
239            }
240
241            "Tr" => {
242                if let Some(v) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
243                    state.current.text_state.render_mode = v as i32;
244                }
245            }
246
247            // Text positioning operators
248            "Td"
249                if op.operands.len() == 2 => {
250                    let tx = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
251                    let ty = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
252                    state.current.translate_text(tx, ty);
253                }
254
255            "TD"
256                // Same as Td but also sets leading
257                if op.operands.len() == 2 => {
258                    let tx = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
259                    let ty = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
260                    state.current.text_state.leading = -ty;
261                    state.current.translate_text(tx, ty);
262                }
263
264            "Tm"
265                if op.operands.len() == 6 => {
266                    let vals: Vec<f64> = op
267                        .operands
268                        .iter()
269                        .filter_map(|o| obj_to_f64(o.clone()))
270                        .collect();
271                    if vals.len() == 6 {
272                        state
273                            .current
274                            .set_text_matrix(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]);
275                    }
276                }
277
278            "T*" => {
279                state.current.next_line();
280            }
281
282            // Text showing operators
283            "Tj" => {
284                if let Some(text_bytes) = op.operands.first().and_then(extract_string_bytes) {
285                    let font = font_cache
286                        .get(&state.current.text_state.font_name)
287                        .cloned()
288                        .unwrap_or_else(|| {
289                            PdfFont::default_font(&state.current.text_state.font_name)
290                        });
291                    let active_mcid = active_mcid(&mcid_stack);
292
293                    if let Some(chunk) = create_text_chunk(
294                        &text_bytes,
295                        &font,
296                        &mut state,
297                        page_number,
298                        &mut chunk_index,
299                        active_mcid,
300                    ) {
301                        chunks.push(chunk);
302                    }
303                }
304            }
305
306            "TJ" => {
307                // Array of strings and positioning adjustments
308                if let Some(Object::Array(ref arr)) = op.operands.first() {
309                    let font = font_cache
310                        .get(&state.current.text_state.font_name)
311                        .cloned()
312                        .unwrap_or_else(|| {
313                            PdfFont::default_font(&state.current.text_state.font_name)
314                        });
315                    let active_mcid = active_mcid(&mcid_stack);
316
317                    for item in arr {
318                        match item {
319                            Object::String(bytes, _) => {
320                                if let Some(chunk) = create_text_chunk(
321                                    bytes,
322                                    &font,
323                                    &mut state,
324                                    page_number,
325                                    &mut chunk_index,
326                                    active_mcid,
327                                ) {
328                                    chunks.push(chunk);
329                                }
330                            }
331                            _ => {
332                                // Numeric adjustment: displacement in thousandths of text space
333                                if let Some(adj) = obj_to_f64(item.clone()) {
334                                    let displacement =
335                                        -adj / 1000.0 * state.current.text_state.font_size;
336                                    state.current.advance_text(displacement);
337                                }
338                            }
339                        }
340                    }
341                }
342            }
343
344            "'" => {
345                // Move to next line, show text
346                state.current.next_line();
347                if let Some(text_bytes) = op.operands.first().and_then(extract_string_bytes) {
348                    let font = font_cache
349                        .get(&state.current.text_state.font_name)
350                        .cloned()
351                        .unwrap_or_else(|| {
352                            PdfFont::default_font(&state.current.text_state.font_name)
353                        });
354                    let active_mcid = active_mcid(&mcid_stack);
355                    if let Some(chunk) = create_text_chunk(
356                        &text_bytes,
357                        &font,
358                        &mut state,
359                        page_number,
360                        &mut chunk_index,
361                        active_mcid,
362                    ) {
363                        chunks.push(chunk);
364                    }
365                }
366            }
367
368            "\""
369                // Set spacing, move to next line, show text
370                if op.operands.len() == 3 => {
371                    if let Some(aw) = obj_to_f64(op.operands[0].clone()) {
372                        state.current.text_state.word_spacing = aw;
373                    }
374                    if let Some(ac) = obj_to_f64(op.operands[1].clone()) {
375                        state.current.text_state.char_spacing = ac;
376                    }
377                    state.current.next_line();
378                    if let Some(text_bytes) = extract_string_bytes(&op.operands[2]) {
379                        let font = font_cache
380                            .get(&state.current.text_state.font_name)
381                            .cloned()
382                            .unwrap_or_else(|| {
383                                PdfFont::default_font(&state.current.text_state.font_name)
384                            });
385                        let active_mcid = active_mcid(&mcid_stack);
386                        if let Some(chunk) = create_text_chunk(
387                            &text_bytes,
388                            &font,
389                            &mut state,
390                            page_number,
391                            &mut chunk_index,
392                            active_mcid,
393                        ) {
394                            chunks.push(chunk);
395                        }
396                    }
397                }
398
399            // Color operators — preserve original color space components (reference approach)
400            "g" => {
401                if let Some(gray) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
402                    state.current.fill_color = vec![gray];
403                    state.current.fill_color_space_components = 1;
404                }
405            }
406            "G" => {
407                if let Some(gray) = op.operands.first().and_then(|o| obj_to_f64(o.clone())) {
408                    state.current.stroke_color = vec![gray];
409                    state.current.stroke_color_space_components = 1;
410                }
411            }
412            "rg"
413                if op.operands.len() == 3 => {
414                    let r = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
415                    let g = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
416                    let b = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
417                    state.current.fill_color = vec![r, g, b];
418                    state.current.fill_color_space_components = 3;
419                }
420            "RG"
421                if op.operands.len() == 3 => {
422                    let r = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
423                    let g = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
424                    let b = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
425                    state.current.stroke_color = vec![r, g, b];
426                    state.current.stroke_color_space_components = 3;
427                }
428            "k"
429                if op.operands.len() == 4 => {
430                    let c = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
431                    let m = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
432                    let y = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
433                    let k = obj_to_f64(op.operands[3].clone()).unwrap_or(0.0);
434                    state.current.fill_color = vec![c, m, y, k];
435                    state.current.fill_color_space_components = 4;
436                }
437            "K"
438                if op.operands.len() == 4 => {
439                    let c = obj_to_f64(op.operands[0].clone()).unwrap_or(0.0);
440                    let m = obj_to_f64(op.operands[1].clone()).unwrap_or(0.0);
441                    let y = obj_to_f64(op.operands[2].clone()).unwrap_or(0.0);
442                    let k = obj_to_f64(op.operands[3].clone()).unwrap_or(0.0);
443                    state.current.stroke_color = vec![c, m, y, k];
444                    state.current.stroke_color_space_components = 4;
445                }
446            // cs/CS — set color space; sc/SC/scn/SCN — set color in current space
447            "cs" => {
448                if let Some(name) = op.operands.first() {
449                    let cs_name = obj_to_name(name);
450                    state.current.fill_color_space_components = color_space_components(&cs_name);
451                }
452            }
453            "CS" => {
454                if let Some(name) = op.operands.first() {
455                    let cs_name = obj_to_name(name);
456                    state.current.stroke_color_space_components = color_space_components(&cs_name);
457                }
458            }
459            "sc" | "scn" => {
460                let components: Vec<f64> = op
461                    .operands
462                    .iter()
463                    .filter_map(|o| obj_to_f64(o.clone()))
464                    .collect();
465                if !components.is_empty() {
466                    state.current.fill_color = components;
467                }
468            }
469            "SC" | "SCN" => {
470                let components: Vec<f64> = op
471                    .operands
472                    .iter()
473                    .filter_map(|o| obj_to_f64(o.clone()))
474                    .collect();
475                if !components.is_empty() {
476                    state.current.stroke_color = components;
477                }
478            }
479
480            _ => {
481                // Ignore unknown operators
482            }
483        }
484    }
485
486    chunks
487}
488
489/// Create a TextChunk from raw text bytes using the current graphics state.
490fn create_text_chunk(
491    text_bytes: &[u8],
492    font: &PdfFont,
493    state: &mut GraphicsStateStack,
494    page_number: u32,
495    chunk_index: &mut usize,
496    mcid: Option<i64>,
497) -> Option<TextChunk> {
498    if text_bytes.is_empty() {
499        return None;
500    }
501
502    // Get text position before rendering
503    let trm = state.current.text_rendering_matrix();
504    let start_x = trm.e;
505    let font_size = trm.font_size_factor();
506
507    if font_size < 0.1 {
508        return None; // Skip invisible text
509    }
510
511    // Decode text to Unicode
512    let mut text = String::new();
513    let mut total_width = 0.0;
514    let mut symbol_ends = Vec::new();
515
516    let bpc = font.bytes_per_code as usize;
517    let mut pos = 0;
518    while pos + bpc <= text_bytes.len() {
519        let char_code = if bpc == 2 {
520            ((text_bytes[pos] as u32) << 8) | (text_bytes[pos + 1] as u32)
521        } else {
522            text_bytes[pos] as u32
523        };
524        pos += bpc;
525
526        let decoded = font.decode_char(char_code);
527        text.push_str(&decoded);
528
529        // Calculate glyph width
530        let glyph_w = font.glyph_width(char_code) / 1000.0;
531        total_width += glyph_w;
532        symbol_ends.push(start_x + total_width * font_size);
533
534        // Add character spacing
535        total_width += state.current.text_state.char_spacing / state.current.text_state.font_size;
536
537        // Add word spacing for space character
538        if decoded == " " {
539            total_width +=
540                state.current.text_state.word_spacing / state.current.text_state.font_size;
541        }
542    }
543
544    // Advance text position
545    let displacement = total_width * state.current.text_state.font_size;
546    state.current.advance_text(displacement);
547
548    if text.is_empty() {
549        return None;
550    }
551
552    // Compute TRM_after (text rendering matrix after text advancement)
553    let trm_after = state.current.text_rendering_matrix();
554
555    // Use font ascent/descent from font descriptor (glyph-space units, per-mille).
556    // The reference implementation: TextChunksHelper.calculateTextBoundingBox uses font.getAscent()/getDescent()
557    // with fallback to font bounding box.
558    let ascent = font.ascent;
559    let descent = font.descent;
560
561    // TRM matrix components:
562    //   a = scaleX,  b = shearY
563    //   c = shearX,  d = scaleY
564    //   e = translateX, f = translateY
565    let trm_before = &trm; // TRM at start of text
566
567    // The reference bbox formula with 4 branches based on text direction/orientation.
568    // scaleX = trm.a, shearX = trm.c, scaleY = trm.d, shearY = trm.b
569    let (x1, x2) = if trm_before.a >= 0.0 && trm_before.c >= 0.0 {
570        (
571            trm_before.e + descent * trm_before.c / 1000.0,
572            trm_after.e + ascent * trm_after.c / 1000.0,
573        )
574    } else if trm_before.a < 0.0 && trm_before.c < 0.0 {
575        (
576            trm_after.e + ascent * trm_after.c / 1000.0,
577            trm_before.e + descent * trm_before.c / 1000.0,
578        )
579    } else if trm_before.a >= 0.0 {
580        (
581            trm_before.e + ascent * trm_before.c / 1000.0,
582            trm_after.e + descent * trm_after.c / 1000.0,
583        )
584    } else {
585        (
586            trm_after.e + descent * trm_after.c / 1000.0,
587            trm_before.e + ascent * trm_before.c / 1000.0,
588        )
589    };
590
591    let (y1, y2) = if trm_before.d >= 0.0 && trm_before.b >= 0.0 {
592        (
593            trm_before.f + descent * trm_before.d / 1000.0,
594            trm_after.f + ascent * trm_after.d / 1000.0,
595        )
596    } else if trm_before.d < 0.0 && trm_before.b < 0.0 {
597        (
598            trm_after.f + ascent * trm_after.d / 1000.0,
599            trm_before.f + descent * trm_before.d / 1000.0,
600        )
601    } else if trm_before.d >= 0.0 {
602        (
603            trm_after.f + descent * trm_after.d / 1000.0,
604            trm_before.f + ascent * trm_before.d / 1000.0,
605        )
606    } else {
607        (
608            trm_before.f + ascent * trm_before.d / 1000.0,
609            trm_after.f + descent * trm_after.d / 1000.0,
610        )
611    };
612
613    let bbox = BoundingBox::new(Some(page_number), x1, y1, x2, y2);
614
615    // Determine text format from text rise (Ts operator).
616    let text_format = if state.current.text_state.rise > font_size * 0.1 {
617        crate::models::enums::TextFormat::Superscript
618    } else if state.current.text_state.rise < -font_size * 0.1 {
619        crate::models::enums::TextFormat::Subscript
620    } else {
621        crate::models::enums::TextFormat::Normal
622    };
623
624    *chunk_index += 1;
625
626    // Format fill color as the reference Arrays.toString() — preserves original color space.
627    // The reference veraPDF stores colors as the reference implementation float (f32), then serializes via double's toString(),
628    // giving full f64 representation of the f32 value. We replicate:
629    // parse as f64 (from lopdf) → round to f32 → back to f64 for full-precision display.
630    let fc = &state.current.fill_color;
631    let font_color = format!(
632        "[{}]",
633        fc.iter()
634            .map(|v| {
635                let f32_val = *v as f32;
636                let f64_repr = f32_val as f64;
637                if f32_val.fract() == 0.0 {
638                    format!("{:.1}", f64_repr)
639                } else {
640                    format!("{}", f64_repr)
641                }
642            })
643            .collect::<Vec<_>>()
644            .join(", ")
645    );
646
647    Some(TextChunk {
648        value: text,
649        bbox,
650        font_name: font.base_font.clone(),
651        font_size,
652        font_weight: font.weight,
653        italic_angle: font.italic_angle,
654        font_color,
655        contrast_ratio: 21.0, // Default: black on white = max contrast
656        symbol_ends,
657        text_format,
658        text_type: crate::models::enums::TextType::Regular,
659        pdf_layer: crate::models::enums::PdfLayer::Main,
660        ocg_visible: true,
661        index: Some(*chunk_index),
662        page_number: Some(page_number),
663        level: None,
664        mcid,
665    })
666}
667
668/// Extract string bytes from a PDF Object.
669fn extract_string_bytes(obj: &Object) -> Option<Vec<u8>> {
670    match obj {
671        Object::String(bytes, _) => Some(bytes.clone()),
672        _ => None,
673    }
674}
675
676/// Get the currently active MCID from the marked content stack.
677/// Returns the most recent non-None MCID (innermost BDC with MCID).
678fn active_mcid(stack: &[Option<i64>]) -> Option<i64> {
679    stack.iter().rev().find_map(|&mcid| mcid)
680}
681
682/// Extract the MCID from BDC operands.
683/// BDC can appear as: `/Tag <</MCID 0>>` or `/Tag /PropertyListName`
684/// We handle the inline dictionary case (most common in tagged PDFs).
685fn extract_mcid_from_bdc(operands: &[Object]) -> Option<i64> {
686    // BDC has 2 operands: tag name and properties
687    if operands.len() < 2 {
688        return None;
689    }
690    match &operands[1] {
691        Object::Dictionary(dict) => {
692            if let Ok(Object::Integer(n)) = dict.get(b"MCID") {
693                return Some(*n);
694            }
695            None
696        }
697        _ => None,
698    }
699}
700
701/// Convert PDF object to f64.
702fn obj_to_f64(obj: Object) -> Option<f64> {
703    match obj {
704        Object::Integer(i) => Some(i as f64),
705        Object::Real(f) => Some(f),
706        _ => None,
707    }
708}
709
710/// Extract a name string from a PDF object (Name type).
711fn obj_to_name(obj: &Object) -> String {
712    match obj {
713        Object::Name(bytes) => String::from_utf8_lossy(bytes).to_string(),
714        _ => String::new(),
715    }
716}
717
718/// Map a PDF color space name to the number of components.
719fn color_space_components(name: &str) -> u8 {
720    match name {
721        "DeviceGray" | "CalGray" | "G" => 1,
722        "DeviceRGB" | "CalRGB" | "RGB" => 3,
723        "DeviceCMYK" | "CMYK" => 4,
724        _ => 3, // Default to RGB for unknown/ICCBased color spaces
725    }
726}
727
728/// Resolve a PDF object via reference.
729fn resolve_obj<'a>(doc: &'a Document, obj: &'a Object) -> lopdf::Object {
730    match obj {
731        Object::Reference(id) => doc.get_object(*id).cloned().unwrap_or(Object::Null),
732        other => other.clone(),
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use lopdf::content::Operation;
740    use lopdf::dictionary;
741
742    /// Create a minimal PDF document with text content for testing.
743    fn create_test_pdf() -> Document {
744        let mut doc = Document::with_version("1.5");
745        let pages_id = doc.new_object_id();
746
747        let font_id = doc.add_object(dictionary! {
748            "Type" => "Font",
749            "Subtype" => "Type1",
750            "BaseFont" => "Helvetica",
751        });
752
753        let resources_id = doc.add_object(dictionary! {
754            "Font" => dictionary! {
755                "F1" => font_id,
756            },
757        });
758
759        let content = Content {
760            operations: vec![
761                Operation::new("BT", vec![]),
762                Operation::new("Tf", vec!["F1".into(), 12.into()]),
763                Operation::new("Td", vec![100.into(), 700.into()]),
764                Operation::new("Tj", vec![Object::string_literal("Hello World!")]),
765                Operation::new("ET", vec![]),
766            ],
767        };
768
769        let content_id = doc.add_object(lopdf::Stream::new(
770            dictionary! {},
771            content.encode().unwrap(),
772        ));
773
774        let page_id = doc.add_object(dictionary! {
775            "Type" => "Page",
776            "Parent" => pages_id,
777            "Contents" => content_id,
778            "Resources" => resources_id,
779            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
780        });
781
782        let pages = dictionary! {
783            "Type" => "Pages",
784            "Kids" => vec![page_id.into()],
785            "Count" => 1,
786        };
787        doc.objects.insert(pages_id, Object::Dictionary(pages));
788
789        let catalog_id = doc.add_object(dictionary! {
790            "Type" => "Catalog",
791            "Pages" => pages_id,
792        });
793        doc.trailer.set("Root", catalog_id);
794        doc
795    }
796
797    #[test]
798    fn test_extract_text_from_synthetic_pdf() {
799        let doc = create_test_pdf();
800        let pages = doc.get_pages();
801        let (&page_num, &page_id) = pages.iter().next().unwrap();
802
803        let chunks = extract_text_chunks(&doc, page_num, page_id).unwrap();
804
805        // Should extract at least one text chunk
806        assert!(!chunks.is_empty(), "Expected text chunks from test PDF");
807
808        // The first chunk should contain "Hello World!"
809        let first = &chunks[0];
810        assert!(
811            first.value.contains("Hello"),
812            "Expected 'Hello' in chunk, got: '{}'",
813            first.value
814        );
815    }
816
817    #[test]
818    fn test_extract_empty_page() {
819        let mut doc = Document::with_version("1.5");
820        let pages_id = doc.new_object_id();
821
822        let page_id = doc.add_object(dictionary! {
823            "Type" => "Page",
824            "Parent" => pages_id,
825            "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
826        });
827
828        let pages = dictionary! {
829            "Type" => "Pages",
830            "Kids" => vec![page_id.into()],
831            "Count" => 1,
832        };
833        doc.objects.insert(pages_id, Object::Dictionary(pages));
834
835        let catalog_id = doc.add_object(dictionary! {
836            "Type" => "Catalog",
837            "Pages" => pages_id,
838        });
839        doc.trailer.set("Root", catalog_id);
840
841        let pages = doc.get_pages();
842        let (&page_num, &page_id) = pages.iter().next().unwrap();
843
844        let chunks = extract_text_chunks(&doc, page_num, page_id).unwrap();
845        assert!(chunks.is_empty());
846    }
847}