Skip to main content

edgeparse_core/pdf/
image_extractor.rs

1//! PDF image extraction — find and extract inline/XObject images.
2
3use lopdf::{Dictionary, Document, Object, Stream};
4
5use crate::models::bbox::BoundingBox;
6use crate::models::chunks::ImageChunk;
7use crate::EdgePdfError;
8
9/// Extracted image data from a PDF page.
10#[derive(Debug, Clone)]
11pub struct ExtractedImage {
12    /// Image chunk with bounding box info
13    pub chunk: ImageChunk,
14    /// Raw image data (decoded from stream)
15    pub data: Vec<u8>,
16    /// Image width in pixels
17    pub width: u32,
18    /// Image height in pixels
19    pub height: u32,
20    /// Color space name
21    pub color_space: String,
22    /// Bits per component
23    pub bits_per_component: u8,
24    /// Filter name (e.g., "DCTDecode" for JPEG, "FlateDecode" for PNG)
25    pub filter: String,
26}
27
28/// One discoverable Image XObject on a page (top-level or Pattern-nested).
29struct ListedImage {
30    /// 1-based index matching [`extract_image_data`].
31    index: u32,
32    width: u32,
33    height: u32,
34    stream: Stream,
35}
36
37/// Extract image chunks from a PDF page.
38///
39/// Scans the page's Resources/XObject dictionary for Image XObjects and
40/// extracts their metadata (position, dimensions). Raw data is extracted lazily.
41pub fn extract_image_chunks(
42    doc: &Document,
43    page_number: u32,
44    page_id: lopdf::ObjectId,
45) -> Result<Vec<ImageChunk>, EdgePdfError> {
46    let page_dict = doc
47        .get_object(page_id)
48        .map_err(|e| EdgePdfError::PipelineError {
49            stage: 1,
50            message: format!("Failed to get page {}: {}", page_number, e),
51        })?
52        .as_dict()
53        .map_err(|e| EdgePdfError::PipelineError {
54            stage: 1,
55            message: format!("Page {} is not a dictionary: {}", page_number, e),
56        })?;
57
58    let resources = match page_dict.get(b"Resources") {
59        Ok(r) => resolve_obj(doc, r),
60        Err(_) => return Ok(Vec::new()),
61    };
62
63    let resources_dict = match resources.as_dict() {
64        Ok(d) => d,
65        Err(_) => return Ok(Vec::new()),
66    };
67
68    let listed = list_page_images(doc, resources_dict);
69    Ok(listed
70        .into_iter()
71        .map(|img| ImageChunk {
72            bbox: BoundingBox::new(
73                Some(page_number),
74                0.0,
75                0.0,
76                img.width as f64,
77                img.height as f64,
78            ),
79            index: Some(img.index),
80            level: None,
81            source: Default::default(),
82        })
83        .collect())
84}
85
86/// Get raw image data for a specific XObject (1-based index over page + Pattern images).
87pub fn extract_image_data(
88    doc: &Document,
89    page_id: lopdf::ObjectId,
90    image_index: u32,
91) -> Result<Option<ExtractedImage>, EdgePdfError> {
92    let page_dict = doc
93        .get_object(page_id)
94        .map_err(|e| EdgePdfError::PipelineError {
95            stage: 1,
96            message: format!("Failed to get page: {}", e),
97        })?
98        .as_dict()
99        .map_err(|e| EdgePdfError::PipelineError {
100            stage: 1,
101            message: format!("Page is not a dictionary: {}", e),
102        })?;
103
104    let resources = match page_dict.get(b"Resources") {
105        Ok(r) => resolve_obj(doc, r),
106        Err(_) => return Ok(None),
107    };
108
109    let resources_dict = match resources.as_dict() {
110        Ok(d) => d,
111        Err(_) => return Ok(None),
112    };
113
114    let listed = list_page_images(doc, resources_dict);
115    let Some(img) = listed.into_iter().find(|i| i.index == image_index) else {
116        return Ok(None);
117    };
118
119    Ok(Some(extracted_from_stream(img.stream, image_index)))
120}
121
122/// Resolve the 1-based image index for the Image XObject painted by a tiling
123/// Pattern.
124///
125/// First principles (ISO 32000 §8.7): a tiling pattern's `/BBox` defines the
126/// pattern cell. Skia/Chromium often also embeds a 1×N or N×1 bleed strip —
127/// those are not the painted cell content. Prefer the Image whose pixel size
128/// matches `/BBox`, otherwise the sole non-strip Image in the pattern resources.
129pub fn find_pattern_image_index(
130    doc: &Document,
131    resources: &Dictionary,
132    pattern_name: &[u8],
133) -> Option<u32> {
134    let listed = list_page_images(doc, resources);
135    let (pattern_images, bbox_w, bbox_h) =
136        list_pattern_images_with_bbox(doc, resources, pattern_name)?;
137    if pattern_images.is_empty() {
138        return None;
139    }
140
141    // Prefer Image whose Width/Height match Pattern /BBox (pattern cell).
142    let cell = pattern_images.iter().find(|img| {
143        !is_bleed_strip(img.width, img.height)
144            && bbox_w
145                .map(|bw| (img.width as f64 - bw).abs() < 1.0)
146                .unwrap_or(false)
147            && bbox_h
148                .map(|bh| (img.height as f64 - bh).abs() < 1.0)
149                .unwrap_or(false)
150    });
151    let best = cell.or_else(|| {
152        // Fall back to the only non-strip image in the pattern cell.
153        let non_strips: Vec<_> = pattern_images
154            .iter()
155            .filter(|img| !is_bleed_strip(img.width, img.height))
156            .collect();
157        if non_strips.len() == 1 {
158            Some(non_strips[0])
159        } else {
160            None
161        }
162    })?;
163
164    listed
165        .into_iter()
166        .find(|img| {
167            img.width == best.width
168                && img.height == best.height
169                && img.stream.content == best.stream.content
170        })
171        .map(|img| img.index)
172}
173
174/// 1-pixel-wide or 1-pixel-tall images are tiling bleed strips, not page content.
175fn is_bleed_strip(width: u32, height: u32) -> bool {
176    width <= 1 || height <= 1
177}
178
179fn list_pattern_images_with_bbox(
180    doc: &Document,
181    resources: &Dictionary,
182    pattern_name: &[u8],
183) -> Option<(Vec<ListedImage>, Option<f64>, Option<f64>)> {
184    let Ok(patterns) = resources.get(b"Pattern") else {
185        return None;
186    };
187    let patterns = resolve_obj(doc, patterns);
188    let Ok(pattern_dict) = patterns.as_dict() else {
189        return None;
190    };
191    let Ok(pref) = pattern_dict.get(pattern_name) else {
192        return None;
193    };
194    let pattern_obj = resolve_obj(doc, pref);
195    let (bbox_w, bbox_h) = pattern_bbox_size(doc, &pattern_obj);
196    let pr = pattern_resources(doc, &pattern_obj)?;
197    let mut out = Vec::new();
198    let mut index = 0u32;
199    append_xobject_images(doc, &pr, &mut out, &mut index);
200    Some((out, bbox_w, bbox_h))
201}
202
203fn pattern_bbox_size(doc: &Document, pattern_obj: &Object) -> (Option<f64>, Option<f64>) {
204    let dict = match pattern_obj {
205        Object::Stream(s) => &s.dict,
206        Object::Dictionary(d) => d,
207        _ => return (None, None),
208    };
209    let Ok(bbox_obj) = dict.get(b"BBox") else {
210        return (None, None);
211    };
212    let resolved = resolve_obj(doc, bbox_obj);
213    let Ok(arr) = resolved.as_array() else {
214        return (None, None);
215    };
216    if arr.len() < 4 {
217        return (None, None);
218    }
219    let nums: Vec<f64> = arr
220        .iter()
221        .filter_map(|o| match resolve_obj(doc, o) {
222            Object::Integer(i) => Some(i as f64),
223            Object::Real(f) => Some(f),
224            _ => None,
225        })
226        .collect();
227    if nums.len() < 4 {
228        return (None, None);
229    }
230    (
231        Some((nums[2] - nums[0]).abs()),
232        Some((nums[3] - nums[1]).abs()),
233    )
234}
235
236/// Enumerate Image XObjects: page Resources/XObject first, then each Pattern's
237/// nested XObject images (stable order for index matching).
238fn list_page_images(doc: &Document, resources: &Dictionary) -> Vec<ListedImage> {
239    let mut out = Vec::new();
240    let mut index = 0u32;
241
242    append_xobject_images(doc, resources, &mut out, &mut index);
243
244    if let Ok(patterns) = resources.get(b"Pattern") {
245        let patterns = resolve_obj(doc, patterns);
246        if let Ok(pattern_dict) = patterns.as_dict() {
247            for (_name, pref) in pattern_dict.iter() {
248                let pattern_obj = resolve_obj(doc, pref);
249                let pattern_resources = pattern_resources(doc, &pattern_obj);
250                if let Some(pr) = pattern_resources.as_ref() {
251                    append_xobject_images(doc, pr, &mut out, &mut index);
252                }
253            }
254        }
255    }
256
257    out
258}
259
260fn pattern_resources(doc: &Document, pattern_obj: &Object) -> Option<Dictionary> {
261    // Pattern can be a dictionary or a stream (tiling pattern with paint content).
262    let dict = match pattern_obj {
263        Object::Stream(s) => &s.dict,
264        Object::Dictionary(d) => d,
265        _ => return None,
266    };
267    let resources = dict.get(b"Resources").ok()?;
268    let resolved = resolve_obj(doc, resources);
269    resolved.as_dict().ok().cloned()
270}
271
272fn append_xobject_images(
273    doc: &Document,
274    resources: &Dictionary,
275    out: &mut Vec<ListedImage>,
276    index: &mut u32,
277) {
278    let Ok(xobjects) = resources.get(b"XObject") else {
279        return;
280    };
281    let xobjects = resolve_obj(doc, xobjects);
282    let Ok(xobject_dict) = xobjects.as_dict() else {
283        return;
284    };
285
286    for (_name, xobj_ref) in xobject_dict.iter() {
287        let xobj = resolve_obj(doc, xobj_ref);
288        let Ok(stream) = xobj.as_stream() else {
289            continue;
290        };
291        let dict = &stream.dict;
292        let subtype = dict.get(b"Subtype").ok().and_then(|o| {
293            if let Object::Name(ref n) = o {
294                Some(String::from_utf8_lossy(n).to_string())
295            } else {
296                None
297            }
298        });
299        if subtype.as_deref() != Some("Image") {
300            continue;
301        }
302        let width = get_int(dict, b"Width").unwrap_or(0) as u32;
303        let height = get_int(dict, b"Height").unwrap_or(0) as u32;
304        if width == 0 || height == 0 {
305            continue;
306        }
307        *index = index.saturating_add(1);
308        out.push(ListedImage {
309            index: *index,
310            width,
311            height,
312            stream: stream.clone(),
313        });
314    }
315}
316
317fn extracted_from_stream(stream: Stream, image_index: u32) -> ExtractedImage {
318    let dict = &stream.dict;
319    let width = get_int(dict, b"Width").unwrap_or(0) as u32;
320    let height = get_int(dict, b"Height").unwrap_or(0) as u32;
321    let bpc = get_int(dict, b"BitsPerComponent").unwrap_or(8) as u8;
322
323    let color_space = dict
324        .get(b"ColorSpace")
325        .ok()
326        .and_then(|o| match o {
327            Object::Name(n) => Some(String::from_utf8_lossy(n).to_string()),
328            _ => None,
329        })
330        .unwrap_or_else(|| "DeviceRGB".to_string());
331
332    let filter = dict
333        .get(b"Filter")
334        .ok()
335        .and_then(|o| match o {
336            Object::Name(n) => Some(String::from_utf8_lossy(n).to_string()),
337            _ => None,
338        })
339        .unwrap_or_default();
340
341    let data = if filter == "DCTDecode" {
342        stream.content.clone()
343    } else {
344        stream
345            .decompressed_content()
346            .unwrap_or_else(|_| stream.content.clone())
347    };
348
349    let bbox = BoundingBox::new(Some(0), 0.0, 0.0, width as f64, height as f64);
350
351    ExtractedImage {
352        chunk: ImageChunk {
353            bbox,
354            index: Some(image_index),
355            level: None,
356            source: Default::default(),
357        },
358        data,
359        width,
360        height,
361        color_space,
362        bits_per_component: bpc,
363        filter,
364    }
365}
366
367fn resolve_obj(doc: &Document, obj: &Object) -> Object {
368    match obj {
369        Object::Reference(id) => doc.get_object(*id).cloned().unwrap_or(Object::Null),
370        other => other.clone(),
371    }
372}
373
374fn get_int(dict: &lopdf::Dictionary, key: &[u8]) -> Option<i64> {
375    dict.get(key).ok().and_then(|o| match o {
376        Object::Integer(i) => Some(*i),
377        Object::Real(f) => Some(*f as i64),
378        _ => None,
379    })
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use lopdf::{dictionary, Stream};
386
387    #[test]
388    fn test_extract_no_images() {
389        let mut doc = Document::with_version("1.5");
390        let pages_id = doc.new_object_id();
391        let page_id = doc.add_object(dictionary! {
392            "Type" => "Page",
393            "Parent" => pages_id,
394            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
395            "Resources" => dictionary! {},
396        });
397        let pages = dictionary! {
398            "Type" => "Pages",
399            "Kids" => vec![page_id.into()],
400            "Count" => 1,
401        };
402        doc.objects.insert(pages_id, Object::Dictionary(pages));
403        let catalog_id = doc.add_object(dictionary! {
404            "Type" => "Catalog",
405            "Pages" => pages_id,
406        });
407        doc.trailer.set("Root", catalog_id);
408
409        let pages = doc.get_pages();
410        let (&page_num, &page_id) = pages.iter().next().unwrap();
411        let chunks = extract_image_chunks(&doc, page_num, page_id).unwrap();
412        assert!(chunks.is_empty());
413    }
414
415    #[test]
416    fn test_extract_image_chunk() {
417        let mut doc = Document::with_version("1.5");
418        let pages_id = doc.new_object_id();
419
420        let img_id = doc.add_object(Stream::new(
421            dictionary! {
422                "Type" => "XObject",
423                "Subtype" => "Image",
424                "Width" => 100,
425                "Height" => 50,
426                "ColorSpace" => "DeviceRGB",
427                "BitsPerComponent" => 8,
428            },
429            vec![0u8; 100],
430        ));
431
432        let resources_id = doc.add_object(dictionary! {
433            "XObject" => dictionary! {
434                "Im1" => img_id,
435            },
436        });
437
438        let page_id = doc.add_object(dictionary! {
439            "Type" => "Page",
440            "Parent" => pages_id,
441            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
442            "Resources" => resources_id,
443        });
444
445        let pages = dictionary! {
446            "Type" => "Pages",
447            "Kids" => vec![page_id.into()],
448            "Count" => 1,
449        };
450        doc.objects.insert(pages_id, Object::Dictionary(pages));
451        let catalog_id = doc.add_object(dictionary! {
452            "Type" => "Catalog",
453            "Pages" => pages_id,
454        });
455        doc.trailer.set("Root", catalog_id);
456
457        let pages = doc.get_pages();
458        let (&page_num, &page_id) = pages.iter().next().unwrap();
459        let chunks = extract_image_chunks(&doc, page_num, page_id).unwrap();
460        assert_eq!(chunks.len(), 1);
461        assert_eq!(chunks[0].index, Some(1));
462    }
463
464    #[test]
465    fn test_pattern_nested_image_index() {
466        let mut doc = Document::with_version("1.5");
467        let pages_id = doc.new_object_id();
468
469        let img_id = doc.add_object(Stream::new(
470            dictionary! {
471                "Type" => "XObject",
472                "Subtype" => "Image",
473                "Width" => 200,
474                "Height" => 100,
475                "ColorSpace" => "DeviceRGB",
476                "BitsPerComponent" => 8,
477            },
478            vec![1u8; 200],
479        ));
480
481        let pattern_id = doc.add_object(dictionary! {
482            "Type" => "Pattern",
483            "PatternType" => 1,
484            "PaintType" => 1,
485            "TilingType" => 1,
486            "BBox" => vec![0.into(), 0.into(), 200.into(), 100.into()],
487            "XStep" => 200,
488            "YStep" => 100,
489            "Resources" => dictionary! {
490                "XObject" => dictionary! {
491                    "X1" => img_id,
492                },
493            },
494        });
495
496        let resources = dictionary! {
497            "Pattern" => dictionary! {
498                "P1" => pattern_id,
499            },
500        };
501
502        let page_id = doc.add_object(dictionary! {
503            "Type" => "Page",
504            "Parent" => pages_id,
505            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
506            "Resources" => resources.clone(),
507        });
508
509        let pages = dictionary! {
510            "Type" => "Pages",
511            "Kids" => vec![page_id.into()],
512            "Count" => 1,
513        };
514        doc.objects.insert(pages_id, Object::Dictionary(pages));
515        let catalog_id = doc.add_object(dictionary! {
516            "Type" => "Catalog",
517            "Pages" => pages_id,
518        });
519        doc.trailer.set("Root", catalog_id);
520
521        let idx = find_pattern_image_index(&doc, &resources, b"P1");
522        assert_eq!(idx, Some(1));
523
524        let pages = doc.get_pages();
525        let (&_page_num, &page_id) = pages.iter().next().unwrap();
526        let extracted = extract_image_data(&doc, page_id, 1).unwrap().unwrap();
527        assert_eq!(extracted.width, 200);
528        assert_eq!(extracted.height, 100);
529    }
530}