1use std::collections::HashSet;
29
30use lopdf::{Dictionary, Document, Object, ObjectId, Stream};
31
32use crate::images::EncodedImage;
33
34const MAX_DECODED_BYTES: usize = 64 * 1024 * 1024;
36
37#[derive(Debug, Clone)]
39pub struct ExportedImage {
40 pub object_id: ObjectId,
42 pub page: u32,
44 pub width: u32,
46 pub height: u32,
48 pub image: EncodedImage,
50}
51
52#[derive(Debug, Clone)]
54pub struct SkippedImage {
55 pub object_id: ObjectId,
57 pub page: u32,
59 pub width: u32,
61 pub height: u32,
63 pub reason: SkipReason,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69#[non_exhaustive]
70pub enum SkipReason {
71 UnsupportedFilter(String),
74 Transparency,
76 DecodeArray,
79 CmykJpeg,
82 ColorSpace(String),
85 BitDepth(u32),
87 ImageMask,
89 Undecodable,
92}
93
94impl SkipReason {
95 #[must_use]
97 pub fn code(&self) -> &'static str {
98 match self {
99 Self::UnsupportedFilter(_) => "unsupported_filter",
100 Self::Transparency => "transparency",
101 Self::DecodeArray => "decode_array",
102 Self::CmykJpeg => "cmyk_jpeg",
103 Self::ColorSpace(_) => "color_space",
104 Self::BitDepth(_) => "bit_depth",
105 Self::ImageMask => "image_mask",
106 Self::Undecodable => "undecodable",
107 }
108 }
109
110 #[must_use]
112 pub fn detail(&self) -> String {
113 match self {
114 Self::UnsupportedFilter(f) => format!("compression {f} is not supported"),
115 Self::Transparency => "image has a transparency mask (SMask/Mask)".into(),
116 Self::DecodeArray => "image has a non-default Decode array".into(),
117 Self::CmykJpeg => "CMYK JPEG would show with wrong colours".into(),
118 Self::ColorSpace(cs) => format!("colour space {cs} is not supported"),
119 Self::BitDepth(b) => format!("{b} bits per component is not supported here"),
120 Self::ImageMask => "stencil mask has no colours of its own".into(),
121 Self::Undecodable => "image data could not be decoded".into(),
122 }
123 }
124}
125
126#[derive(Debug, Clone, Default)]
128pub struct ImageExport {
129 pub images: Vec<ExportedImage>,
131 pub skipped: Vec<SkippedImage>,
133}
134
135#[must_use]
141pub fn export_images(doc: &Document) -> ImageExport {
142 let mut out = ImageExport::default();
143 let mut seen: HashSet<ObjectId> = HashSet::new();
144 for (page, page_id) in doc.get_pages() {
145 let mut ids = Vec::new();
146 if let Some(resources) = page_resources(doc, page_id) {
147 collect_images(doc, resources, &mut ids, &mut HashSet::new(), 0);
148 }
149 for id in ids {
150 if !seen.insert(id) {
151 continue;
152 }
153 let Ok(Object::Stream(stream)) = doc.get_object(id) else {
154 continue;
155 };
156 let width = int(doc, &stream.dict, b"Width").unwrap_or(0).max(0) as u32;
157 let height = int(doc, &stream.dict, b"Height").unwrap_or(0).max(0) as u32;
158 match export_one(doc, stream, width, height) {
159 Ok(image) => out.images.push(ExportedImage {
160 object_id: id,
161 page,
162 width,
163 height,
164 image,
165 }),
166 Err(reason) => out.skipped.push(SkippedImage {
167 object_id: id,
168 page,
169 width,
170 height,
171 reason,
172 }),
173 }
174 }
175 }
176 out
177}
178
179#[derive(Debug, Clone, PartialEq)]
181enum ColorModel {
182 Gray,
183 Rgb,
184 Cmyk,
185 Indexed {
186 base: Box<ColorModel>,
187 hival: usize,
188 lookup: Vec<u8>,
189 },
190 IccUnsupported(String),
193 Unsupported(String),
194}
195
196impl ColorModel {
197 fn components(&self) -> usize {
198 match self {
199 Self::Gray | Self::Indexed { .. } => 1,
200 Self::Rgb => 3,
201 Self::Cmyk => 4,
202 Self::Unsupported(_) | Self::IccUnsupported(_) => 0,
203 }
204 }
205}
206
207fn export_one(
208 doc: &Document,
209 stream: &Stream,
210 width: u32,
211 height: u32,
212) -> Result<EncodedImage, SkipReason> {
213 let dict = &stream.dict;
214 if matches!(
215 resolve(doc, dict.get(b"ImageMask").ok()),
216 Some(Object::Boolean(true))
217 ) {
218 return Err(SkipReason::ImageMask);
219 }
220 if dict.has(b"SMask") || dict.has(b"Mask") {
221 return Err(SkipReason::Transparency);
222 }
223 if width == 0 || height == 0 {
224 return Err(SkipReason::Undecodable);
225 }
226
227 let filters = filter_names(doc, dict);
228 for f in &filters {
229 match f.as_str() {
230 "FlateDecode" | "Fl" | "LZWDecode" | "LZW" | "ASCII85Decode" | "A85"
231 | "ASCIIHexDecode" | "AHx" | "RunLengthDecode" | "RL" => {}
232 "DCTDecode" | "DCT" => {}
233 other => return Err(SkipReason::UnsupportedFilter(other.to_string())),
234 }
235 }
236 let is_jpeg = filters
237 .last()
238 .is_some_and(|f| f == "DCTDecode" || f == "DCT");
239 if filters[..filters.len().saturating_sub(1)]
240 .iter()
241 .any(|f| f == "DCTDecode" || f == "DCT")
242 {
243 return Err(SkipReason::UnsupportedFilter("DCTDecode".into()));
244 }
245 if let Some(reason) = unsupported_decode_parms(doc, dict) {
246 return Err(reason);
247 }
248
249 let model = match resolve(doc, dict.get(b"ColorSpace").ok()) {
250 Some(cs) => color_model(doc, cs),
251 None => ColorModel::Unsupported("none".into()),
252 };
253 let bpc = int(doc, dict, b"BitsPerComponent").unwrap_or(0);
254
255 if let Some(decode) = resolve(doc, dict.get(b"Decode").ok()) {
256 if !is_default_decode(decode, &model, bpc) {
257 return Err(SkipReason::DecodeArray);
258 }
259 }
260
261 let data = stream
262 .decompressed_content_with_limit(MAX_DECODED_BYTES)
263 .map_err(|_| SkipReason::Undecodable)?;
264
265 if is_jpeg {
266 match &model {
267 ColorModel::Gray | ColorModel::Rgb | ColorModel::Cmyk => {}
268 ColorModel::Indexed { .. } => return Err(SkipReason::ColorSpace("Indexed".into())),
269 ColorModel::IccUnsupported(name) => return Err(SkipReason::ColorSpace(name.clone())),
270 ColorModel::Unsupported(name) => return Err(SkipReason::ColorSpace(name.clone())),
271 }
272 if !data.starts_with(&[0xFF, 0xD8]) {
273 return Err(SkipReason::Undecodable);
274 }
275 return match jpeg_components(&data) {
276 Some(4) => Err(SkipReason::CmykJpeg),
277 Some(1 | 3) => Ok(EncodedImage {
278 data,
279 mime: "image/jpeg",
280 ext: "jpeg",
281 }),
282 _ => Err(SkipReason::Undecodable),
283 };
284 }
285
286 let (w, h) = (width as usize, height as usize);
287 let (samples, color) = match &model {
288 ColorModel::Unsupported(name) | ColorModel::IccUnsupported(name) => {
289 return Err(SkipReason::ColorSpace(name.clone()))
290 }
291 ColorModel::Cmyk => return Err(SkipReason::ColorSpace("DeviceCMYK".into())),
294 ColorModel::Indexed {
295 base,
296 hival,
297 lookup,
298 } => {
299 let indices = unpack(&data, w, h, bpc).ok_or(SkipReason::Undecodable)?;
300 expand_palette(&indices, base, *hival, lookup)?
301 }
302 ColorModel::Gray => {
303 let gray = if bpc == 8 {
304 let n = w.checked_mul(h).ok_or(SkipReason::Undecodable)?;
305 data.get(..n).ok_or(SkipReason::Undecodable)?.to_vec()
306 } else {
307 let raw = unpack(&data, w, h, bpc).ok_or(SkipReason::BitDepth(bpc as u32))?;
308 let max = (1u32 << bpc) - 1;
309 raw.iter()
310 .map(|&v| (u32::from(v) * 255 / max) as u8)
311 .collect()
312 };
313 (gray, image::ExtendedColorType::L8)
314 }
315 ColorModel::Rgb => {
316 if bpc != 8 {
317 return Err(SkipReason::BitDepth(bpc.max(0) as u32));
318 }
319 let n = w
320 .checked_mul(h)
321 .and_then(|p| p.checked_mul(model.components()))
322 .ok_or(SkipReason::Undecodable)?;
323 let samples = data.get(..n).ok_or(SkipReason::Undecodable)?;
324 (samples.to_vec(), image::ExtendedColorType::Rgb8)
325 }
326 };
327 encode_png(&samples, width, height, color).ok_or(SkipReason::Undecodable)
328}
329
330fn resolve<'a>(doc: &'a Document, obj: Option<&'a Object>) -> Option<&'a Object> {
331 match obj? {
332 Object::Reference(id) => doc.get_object(*id).ok(),
333 other => Some(other),
334 }
335}
336
337fn int(doc: &Document, dict: &Dictionary, key: &[u8]) -> Option<i64> {
338 match resolve(doc, dict.get(key).ok())? {
339 Object::Integer(i) => Some(*i),
340 Object::Real(r) => Some(*r as i64),
341 _ => None,
342 }
343}
344
345fn number(obj: &Object) -> Option<f64> {
346 match obj {
347 Object::Integer(i) => Some(*i as f64),
348 Object::Real(r) => Some(f64::from(*r)),
349 _ => None,
350 }
351}
352
353fn filter_names(doc: &Document, dict: &Dictionary) -> Vec<String> {
354 match resolve(doc, dict.get(b"Filter").ok()) {
355 Some(Object::Name(n)) => vec![String::from_utf8_lossy(n).into_owned()],
356 Some(Object::Array(arr)) => arr
357 .iter()
358 .map(|o| match resolve(doc, Some(o)) {
359 Some(Object::Name(n)) => String::from_utf8_lossy(n).into_owned(),
360 _ => "unknown".into(),
361 })
362 .collect(),
363 Some(_) => vec!["unknown".into()],
364 None => Vec::new(),
365 }
366}
367
368fn unsupported_decode_parms(doc: &Document, dict: &Dictionary) -> Option<SkipReason> {
376 let predictor = |p: &Object| match resolve(doc, Some(p)) {
377 Some(Object::Dictionary(d)) => int(doc, d, b"Predictor").unwrap_or(1),
378 _ => 1,
379 };
380 match resolve(doc, dict.get(b"DecodeParms").ok())? {
381 Object::Array(arr) => arr
382 .iter()
383 .any(|p| predictor(p) > 1)
384 .then(|| SkipReason::UnsupportedFilter("predictor in a filter chain".into())),
385 single => {
386 (predictor(single) == 2).then(|| SkipReason::UnsupportedFilter("TIFF predictor".into()))
387 }
388 }
389}
390
391fn page_resources(doc: &Document, page_id: ObjectId) -> Option<&Dictionary> {
394 let mut node_id = page_id;
395 for _ in 0..64 {
396 let node = doc.get_dictionary(node_id).ok()?;
397 if let Some(Object::Dictionary(d)) = resolve(doc, node.get(b"Resources").ok()) {
398 return Some(d);
399 }
400 node_id = node.get(b"Parent").ok()?.as_reference().ok()?;
401 }
402 None
403}
404
405fn collect_images(
408 doc: &Document,
409 resources: &Dictionary,
410 out: &mut Vec<ObjectId>,
411 forms: &mut HashSet<ObjectId>,
412 depth: usize,
413) {
414 let Some(Object::Dictionary(xobjects)) = resolve(doc, resources.get(b"XObject").ok()) else {
415 return;
416 };
417 for (_, obj) in xobjects.iter() {
418 let Object::Reference(id) = obj else {
419 continue;
420 };
421 let Ok(Object::Stream(stream)) = doc.get_object(*id) else {
422 continue;
423 };
424 match stream.dict.get(b"Subtype").and_then(Object::as_name) {
425 Ok(b"Image") => {
426 if !out.contains(id) {
427 out.push(*id);
428 }
429 }
430 Ok(b"Form") if depth < 32 && forms.insert(*id) => {
433 if let Some(Object::Dictionary(inner)) =
434 resolve(doc, stream.dict.get(b"Resources").ok())
435 {
436 collect_images(doc, inner, out, forms, depth + 1);
437 }
438 }
439 _ => {}
440 }
441 }
442}
443
444fn color_model(doc: &Document, cs: &Object) -> ColorModel {
445 match cs {
446 Object::Name(n) => match n.as_slice() {
447 b"DeviceGray" | b"G" | b"CalGray" => ColorModel::Gray,
448 b"DeviceRGB" | b"RGB" | b"CalRGB" => ColorModel::Rgb,
449 b"DeviceCMYK" | b"CMYK" => ColorModel::Cmyk,
450 other => ColorModel::Unsupported(String::from_utf8_lossy(other).into_owned()),
451 },
452 Object::Array(arr) => {
453 let family = match arr.first().and_then(|o| resolve(doc, Some(o))) {
454 Some(Object::Name(n)) => n.clone(),
455 _ => return ColorModel::Unsupported("unknown".into()),
456 };
457 match family.as_slice() {
458 b"CalGray" => ColorModel::Gray,
459 b"CalRGB" => ColorModel::Rgb,
460 b"ICCBased" => match arr.get(1).and_then(|o| resolve(doc, Some(o))) {
461 Some(Object::Stream(s)) => icc_model(doc, s),
462 _ => ColorModel::Unsupported("ICCBased".into()),
463 },
464 b"Indexed" | b"I" => indexed_model(doc, arr),
465 other => ColorModel::Unsupported(String::from_utf8_lossy(other).into_owned()),
466 }
467 }
468 _ => ColorModel::Unsupported("unknown".into()),
469 }
470}
471
472fn indexed_model(doc: &Document, arr: &[Object]) -> ColorModel {
473 let unsupported = || ColorModel::Unsupported("Indexed".into());
474 let Some(base) = arr.get(1).and_then(|o| resolve(doc, Some(o))) else {
475 return unsupported();
476 };
477 let base = color_model(doc, base);
478 if !matches!(base, ColorModel::Gray | ColorModel::Rgb) {
479 return match base {
480 ColorModel::Unsupported(name) | ColorModel::IccUnsupported(name) => {
481 ColorModel::Unsupported(format!("Indexed/{name}"))
482 }
483 ColorModel::Cmyk => ColorModel::Unsupported("Indexed/DeviceCMYK".into()),
484 _ => unsupported(),
485 };
486 }
487 let Some(hival) = arr
488 .get(2)
489 .and_then(|o| resolve(doc, Some(o)))
490 .and_then(number)
491 .filter(|h| (0.0..=255.0).contains(h))
492 else {
493 return unsupported();
494 };
495 let lookup = match arr.get(3).and_then(|o| resolve(doc, Some(o))) {
496 Some(Object::String(bytes, _)) => bytes.clone(),
497 Some(Object::Stream(s)) => match s.decompressed_content_with_limit(MAX_DECODED_BYTES) {
498 Ok(b) => b,
499 Err(_) => return unsupported(),
500 },
501 _ => return unsupported(),
502 };
503 ColorModel::Indexed {
504 base: Box::new(base),
505 hival: hival as usize,
506 lookup,
507 }
508}
509
510fn is_default_decode(decode: &Object, model: &ColorModel, bpc: i64) -> bool {
512 let Ok(arr) = decode.as_array() else {
513 return false;
514 };
515 let values: Option<Vec<f64>> = arr.iter().map(number).collect();
516 let Some(values) = values else {
517 return false;
518 };
519 let expected: Vec<f64> = match model {
520 ColorModel::Indexed { .. } => {
521 if !(1..=8).contains(&bpc) {
522 return false;
523 }
524 vec![0.0, f64::from((1u32 << bpc) - 1)]
525 }
526 ColorModel::Unsupported(_) | ColorModel::IccUnsupported(_) => return false,
527 m => [0.0, 1.0].repeat(m.components()),
528 };
529 values.len() == expected.len()
530 && values
531 .iter()
532 .zip(&expected)
533 .all(|(a, b)| (a - b).abs() < 1e-6)
534}
535
536fn unpack(data: &[u8], w: usize, h: usize, bpc: i64) -> Option<Vec<u8>> {
539 if !matches!(bpc, 1 | 2 | 4 | 8) {
540 return None;
541 }
542 let bpc = bpc as usize;
543 let row_bytes = w.checked_mul(bpc)?.div_ceil(8);
544 if data.len() < row_bytes.checked_mul(h)? {
545 return None;
546 }
547 let mut out = Vec::with_capacity(w.checked_mul(h)?);
548 let mask = ((1u16 << bpc) - 1) as u8;
549 for row in data.chunks(row_bytes).take(h) {
550 for x in 0..w {
551 let bit = x * bpc;
552 let byte = row[bit / 8];
553 let shift = 8 - bpc - (bit % 8);
554 out.push((byte >> shift) & mask);
555 }
556 }
557 Some(out)
558}
559
560fn expand_palette(
561 indices: &[u8],
562 base: &ColorModel,
563 hival: usize,
564 lookup: &[u8],
565) -> Result<(Vec<u8>, image::ExtendedColorType), SkipReason> {
566 let n = base.components();
567 if n == 0 || lookup.len() < (hival + 1) * n {
568 return Err(SkipReason::Undecodable);
569 }
570 let mut out = Vec::with_capacity(indices.len() * n);
571 for &i in indices {
572 let i = usize::from(i).min(hival);
574 out.extend_from_slice(&lookup[i * n..i * n + n]);
575 }
576 Ok(match base {
577 ColorModel::Gray => (out, image::ExtendedColorType::L8),
578 _ => (out, image::ExtendedColorType::Rgb8),
579 })
580}
581
582fn icc_model(doc: &Document, profile: &Stream) -> ColorModel {
590 match int(doc, &profile.dict, b"N") {
591 Some(1) => ColorModel::Gray,
592 Some(3) => {
593 let bytes = profile
594 .decompressed_content_with_limit(MAX_DECODED_BYTES)
595 .unwrap_or_default();
596 if bytes.windows(4).any(|w| w == b"sRGB") {
597 ColorModel::Rgb
598 } else {
599 ColorModel::IccUnsupported("ICCBased (non-sRGB RGB profile)".into())
600 }
601 }
602 Some(4) => ColorModel::IccUnsupported("ICCBased (CMYK)".into()),
603 _ => ColorModel::Unsupported("ICCBased".into()),
604 }
605}
606
607fn jpeg_components(data: &[u8]) -> Option<u8> {
610 let mut pos = 2;
611 while pos + 4 <= data.len() {
612 if data[pos] != 0xFF {
613 return None;
614 }
615 let marker = data[pos + 1];
616 if marker == 0xFF {
617 pos += 1;
618 continue;
619 }
620 if marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
621 pos += 2;
622 continue;
623 }
624 let len = usize::from(u16::from_be_bytes([data[pos + 2], data[pos + 3]]));
625 if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
627 return data.get(pos + 9).copied();
628 }
629 if marker == 0xDA {
630 return None;
631 }
632 pos += 2 + len;
633 }
634 None
635}
636
637fn encode_png(
638 samples: &[u8],
639 width: u32,
640 height: u32,
641 color: image::ExtendedColorType,
642) -> Option<EncodedImage> {
643 use image::codecs::png::{CompressionType, FilterType, PngEncoder};
644 use image::ImageEncoder;
645
646 let mut out = Vec::new();
647 PngEncoder::new_with_quality(&mut out, CompressionType::Fast, FilterType::Adaptive)
648 .write_image(samples, width, height, color)
649 .ok()?;
650 Some(EncodedImage {
651 data: out,
652 mime: "image/png",
653 ext: "png",
654 })
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use lopdf::{dictionary, StringFormat};
661 use std::io::Write;
662
663 fn doc_with(images: Vec<Stream>) -> Document {
665 let mut doc = Document::with_version("1.7");
666 let mut xobjects = Dictionary::new();
667 for (i, img) in images.into_iter().enumerate() {
668 let id = doc.add_object(Object::Stream(img));
669 xobjects.set(format!("Im{i}"), Object::Reference(id));
670 }
671 let pages_id = doc.new_object_id();
672 let page_id = doc.add_object(dictionary! {
673 "Type" => "Page",
674 "Parent" => pages_id,
675 "MediaBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
676 "Resources" => dictionary! { "XObject" => xobjects },
677 });
678 doc.objects.insert(
679 pages_id,
680 Object::Dictionary(dictionary! {
681 "Type" => "Pages",
682 "Kids" => vec![page_id.into()],
683 "Count" => 1,
684 }),
685 );
686 let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
687 doc.trailer.set("Root", catalog);
688 doc
689 }
690
691 fn image(w: i64, h: i64, cs: Object, bpc: i64, data: Vec<u8>) -> Stream {
692 Stream::new(
693 dictionary! {
694 "Type" => "XObject",
695 "Subtype" => "Image",
696 "Width" => w,
697 "Height" => h,
698 "ColorSpace" => cs,
699 "BitsPerComponent" => bpc,
700 },
701 data,
702 )
703 }
704
705 fn only_skip(doc: &Document) -> SkipReason {
706 let out = export_images(doc);
707 assert!(out.images.is_empty(), "expected a skip, got an export");
708 assert_eq!(out.skipped.len(), 1);
709 out.skipped[0].reason.clone()
710 }
711
712 fn only_png(doc: &Document) -> image::DynamicImage {
713 let out = export_images(doc);
714 assert!(out.skipped.is_empty(), "unexpected skip: {:?}", out.skipped);
715 assert_eq!(out.images.len(), 1);
716 assert_eq!(out.images[0].image.mime, "image/png");
717 image::load_from_memory(&out.images[0].image.data).expect("a real PNG")
718 }
719
720 fn jpeg_header(n: u8) -> Vec<u8> {
722 let mut v = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00];
723 v.extend_from_slice(&[0xFF, 0xC0, 0x00, 8 + 3 * n, 8, 0, 1, 0, 1, n]);
724 for c in 0..n {
725 v.extend_from_slice(&[c + 1, 0x11, 0]);
726 }
727 v.extend_from_slice(&[0xFF, 0xD9]);
728 v
729 }
730
731 #[test]
732 fn an_indexed_image_comes_out_in_its_palette_colours_not_grey() {
733 let cs = Object::Array(vec![
735 "Indexed".into(),
736 "DeviceRGB".into(),
737 1.into(),
738 Object::String(vec![255, 0, 0, 0, 0, 255], StringFormat::Hexadecimal),
739 ]);
740 let doc = doc_with(vec![image(2, 1, cs, 8, vec![0, 1])]);
741 let png = only_png(&doc).to_rgb8();
742 assert_eq!(png.get_pixel(0, 0).0, [255, 0, 0]);
743 assert_eq!(png.get_pixel(1, 0).0, [0, 0, 255]);
744 }
745
746 #[test]
747 fn a_four_bit_indexed_image_unpacks_its_indices() {
748 let cs = Object::Array(vec![
749 "Indexed".into(),
750 "DeviceGray".into(),
751 1.into(),
752 Object::String(vec![10, 200], StringFormat::Literal),
753 ]);
754 let doc = doc_with(vec![image(3, 1, cs, 4, vec![0x10, 0x10])]);
756 let png = only_png(&doc).to_luma8();
757 let px: Vec<u8> = png.pixels().map(|p| p.0[0]).collect();
758 assert_eq!(px, vec![200, 10, 200]);
759 }
760
761 #[test]
762 fn a_soft_masked_image_is_skipped_not_flattened() {
763 let mut img = image(1, 1, "DeviceRGB".into(), 8, vec![1, 2, 3]);
764 img.dict.set("SMask", Object::Reference((99, 0)));
765 assert_eq!(only_skip(&doc_with(vec![img])), SkipReason::Transparency);
766 }
767
768 #[test]
769 fn an_inverting_decode_array_is_skipped() {
770 let mut img = image(1, 1, "DeviceGray".into(), 8, vec![0]);
771 img.dict.set("Decode", vec![1.into(), 0.into()]);
772 assert_eq!(only_skip(&doc_with(vec![img])), SkipReason::DecodeArray);
773 }
774
775 #[test]
776 fn an_identity_decode_array_changes_nothing() {
777 let mut img = image(1, 1, "DeviceGray".into(), 8, vec![77]);
778 img.dict.set("Decode", vec![0.into(), 1.into()]);
779 assert_eq!(
780 only_png(&doc_with(vec![img])).to_luma8().get_pixel(0, 0).0,
781 [77]
782 );
783 }
784
785 #[test]
786 fn a_cmyk_jpeg_is_skipped_and_an_rgb_jpeg_passes_through() {
787 let mut cmyk = image(1, 1, "DeviceCMYK".into(), 8, jpeg_header(4));
788 cmyk.dict.set("Filter", "DCTDecode");
789 assert_eq!(only_skip(&doc_with(vec![cmyk])), SkipReason::CmykJpeg);
790
791 let jpeg = jpeg_header(3);
792 let mut rgb = image(1, 1, "DeviceRGB".into(), 8, jpeg.clone());
793 rgb.dict.set("Filter", "DCTDecode");
794 let out = export_images(&doc_with(vec![rgb]));
795 assert_eq!(out.images.len(), 1);
796 assert_eq!(out.images[0].image.mime, "image/jpeg");
797 assert_eq!(out.images[0].image.data, jpeg);
798 }
799
800 #[test]
801 fn jpeg_2000_is_skipped_with_its_filter_name() {
802 let mut img = image(1, 1, "DeviceRGB".into(), 8, vec![0; 16]);
803 img.dict.set("Filter", "JPXDecode");
804 assert_eq!(
805 only_skip(&doc_with(vec![img])),
806 SkipReason::UnsupportedFilter("JPXDecode".into())
807 );
808 }
809
810 #[test]
811 fn a_png_predicted_flate_image_is_unpredicted() {
812 let raw = [0u8, 255, 0, 0, 0, 255, 0];
814 let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
815 z.write_all(&raw).unwrap();
816 let mut img = image(2, 1, "DeviceRGB".into(), 8, z.finish().unwrap());
817 img.dict.set("Filter", "FlateDecode");
818 img.dict.set(
819 "DecodeParms",
820 dictionary! { "Predictor" => 15, "Colors" => 3, "Columns" => 2 },
821 );
822 let png = only_png(&doc_with(vec![img])).to_rgb8();
823 assert_eq!(png.get_pixel(0, 0).0, [255, 0, 0]);
824 assert_eq!(png.get_pixel(1, 0).0, [0, 255, 0]);
825 }
826
827 #[test]
828 fn a_lab_image_is_skipped_by_colour_space() {
829 let cs = Object::Array(vec!["Lab".into(), Object::Dictionary(Dictionary::new())]);
830 let img = image(1, 1, cs, 8, vec![0, 0, 0]);
831 assert_eq!(
832 only_skip(&doc_with(vec![img])),
833 SkipReason::ColorSpace("Lab".into())
834 );
835 }
836
837 #[test]
838 fn a_short_stream_is_undecodable_not_a_truncated_picture() {
839 let img = image(4, 4, "DeviceRGB".into(), 8, vec![0; 5]);
840 assert_eq!(only_skip(&doc_with(vec![img])), SkipReason::Undecodable);
841 }
842
843 #[test]
844 fn a_stencil_mask_is_skipped() {
845 let mut img = image(8, 1, "DeviceGray".into(), 1, vec![0xAA]);
846 img.dict.remove(b"ColorSpace");
847 img.dict.set("ImageMask", true);
848 assert_eq!(only_skip(&doc_with(vec![img])), SkipReason::ImageMask);
849 }
850
851 fn doc_with_resources_on(parent: bool, via_form: bool, img: Stream) -> Document {
854 let mut doc = Document::with_version("1.7");
855 let img_id = doc.add_object(Object::Stream(img));
856 let mut xobjects = dictionary! { "Im0" => img_id };
857 if via_form {
858 let form = Stream::new(
859 dictionary! {
860 "Type" => "XObject",
861 "Subtype" => "Form",
862 "BBox" => vec![0.into(), 0.into(), 10.into(), 10.into()],
863 "Resources" => dictionary! { "XObject" => xobjects },
864 },
865 b"/Im0 Do".to_vec(),
866 );
867 let form_id = doc.add_object(Object::Stream(form));
868 xobjects = dictionary! { "Fm0" => form_id };
869 }
870 let resources = dictionary! { "XObject" => xobjects };
871 let pages_id = doc.new_object_id();
872 let mut page = dictionary! {
873 "Type" => "Page",
874 "Parent" => pages_id,
875 "MediaBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
876 };
877 let mut pages = dictionary! { "Type" => "Pages", "Count" => 1 };
878 if parent {
879 pages.set("Resources", resources);
880 } else {
881 page.set("Resources", resources);
882 }
883 let page_id = doc.add_object(page);
884 pages.set("Kids", vec![page_id.into()]);
885 doc.objects.insert(pages_id, Object::Dictionary(pages));
886 let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
887 doc.trailer.set("Root", catalog);
888 doc
889 }
890
891 #[test]
892 fn an_image_in_inherited_resources_is_found() {
893 let img = image(1, 1, "DeviceGray".into(), 8, vec![9]);
894 assert_eq!(
895 export_images(&doc_with_resources_on(true, false, img))
896 .images
897 .len(),
898 1
899 );
900 }
901
902 #[test]
903 fn an_image_drawn_inside_a_form_xobject_is_found() {
904 let img = image(1, 1, "DeviceGray".into(), 8, vec![9]);
905 assert_eq!(
906 export_images(&doc_with_resources_on(false, true, img))
907 .images
908 .len(),
909 1
910 );
911 }
912
913 #[test]
914 fn a_predictor_in_array_decode_parms_is_skipped_not_left_in_the_pixels() {
915 let mut img = image(2, 1, "DeviceRGB".into(), 8, vec![0; 7]);
916 img.dict.set(
917 "DecodeParms",
918 vec![Object::Dictionary(
919 dictionary! { "Predictor" => 15, "Colors" => 3, "Columns" => 2 },
920 )],
921 );
922 assert_eq!(
923 only_skip(&doc_with(vec![img])),
924 SkipReason::UnsupportedFilter("predictor in a filter chain".into())
925 );
926 }
927
928 #[test]
929 fn cmyk_and_non_srgb_icc_images_are_skipped_but_srgb_is_kept() {
930 let raw_cmyk = image(1, 1, "DeviceCMYK".into(), 8, vec![0, 0, 0, 0]);
931 assert!(matches!(
932 only_skip(&doc_with(vec![raw_cmyk])),
933 SkipReason::ColorSpace(_)
934 ));
935
936 let with = |profile: &[u8]| {
937 let mut doc = doc_with(vec![image(1, 1, "DeviceRGB".into(), 8, vec![1, 2, 3])]);
938 let pid = doc.add_object(Object::Stream(Stream::new(
939 dictionary! { "N" => 3 },
940 profile.to_vec(),
941 )));
942 for obj in doc.objects.values_mut() {
943 if let Object::Stream(st) = obj {
944 if st.dict.has(b"Width") {
945 st.dict
946 .set("ColorSpace", vec!["ICCBased".into(), pid.into()]);
947 }
948 }
949 }
950 doc
951 };
952 assert!(matches!(
953 only_skip(&with(b"....Adobe RGB (1998)")),
954 SkipReason::ColorSpace(_)
955 ));
956 assert_eq!(
957 only_png(&with(b"....sRGB IEC61966-2.1"))
958 .to_rgb8()
959 .get_pixel(0, 0)
960 .0,
961 [1, 2, 3]
962 );
963 }
964}