//! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages //! use, as files. JPEG images are written unchanged; other images, and JPEG images with //! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour //! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, separations) are //! reported, never silently left out. use std::collections::HashSet; use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_DECODED, ObjRef, Object, Stream}; use pdfcraft_filters::FilterError; /// One image, ready to write. #[derive(Clone, Debug, PartialEq)] pub struct ExtractedImage { /// 0-based page the image was first found on. pub page: usize, pub object: ObjRef, pub width: u32, pub height: u32, /// "jpg" or "png". pub extension: &'static str, pub data: Vec, } #[derive(Clone, Debug, Default, PartialEq)] pub struct ImageExport { pub images: Vec, /// Images left out: (page, object, why). pub skipped: Vec<(usize, ObjRef, String)>, } /// Images larger than this many pixels are skipped (memory). const MAX_PIXELS: u64 = 100_000_000; /// What one export may hold of the files it makes: they are all in memory until it ends, and a file /// of a few KB can make gigabytes of PNG. Images past it are left out, with the reason. const MAX_EXPORT_BYTES: usize = 2 << 30; /// What an export of a file of `file_bytes` may hold: twice the file if that is more than /// [`MAX_EXPORT_BYTES`], so that a file of large images is exported whole. pub(crate) fn export_bytes(file_bytes: usize) -> usize { MAX_EXPORT_BYTES.max(file_bytes.saturating_mul(2)) } /// The images of `pages` (0-based; each image once, on the first page using it), skipping /// those with fewer than `min_side` pixels on their shorter side. pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageExport { extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), export_bytes(doc.bytes().len())) } /// [`extract_images`] within `budget`, and holding `max_bytes` of files. An image past either is /// left out, with the reason. pub(crate) fn extract_images_within(doc: &Document, pages: &[usize], min_side: u32, budget: &mut ImageBudget, max_bytes: usize) -> ImageExport { let all = pdfcraft_model::pages(doc); let mut out = ImageExport::default(); let mut seen = HashSet::new(); let mut held = 0usize; for &p in pages { let Some(page) = all.get(p) else { continue }; let mut refs = Vec::new(); if let Some(res) = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()) { collect(doc, &res, 0, &mut HashSet::new(), &mut refs); } for r in refs { if !seen.insert(r) { continue; } let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; let (w, h) = (s.dict.int(b"Width").unwrap_or(0), s.dict.int(b"Height").unwrap_or(0)); if w <= 0 || h <= 0 || (w.min(h) as u64) < min_side as u64 { continue; } if held >= max_bytes { out.skipped.push((p, r, "more images than one export can hold".into())); continue; } match image(doc, s, budget) { Ok((extension, data)) => { held = held.saturating_add(data.len()); out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data }); } Err(why) => out.skipped.push((p, r, why)), } } } out } /// One image XObject as a file: JPEG as is, else PNG ("Save image as"). pub fn image_file(doc: &Document, image: ObjRef) -> Result<(&'static str, Vec), String> { image_file_within(doc, image, &mut ImageBudget::new(doc)) } /// [`image_file`] within `budget`, for a caller making a file of many images. pub fn image_file_within(doc: &Document, image: ObjRef, budget: &mut ImageBudget) -> Result<(&'static str, Vec), String> { match &*doc.get(image) { Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s, budget), _ => Err("not an image".into()), } } /// The image XObjects reachable from `res`, through form XObjects, in resource order. fn collect(doc: &Document, res: &Dict, depth: usize, forms: &mut HashSet, out: &mut Vec) { if depth > 12 { return; } let Some(xobjects) = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { return }; for (_, v) in xobjects.iter() { let Some(r) = v.as_ref() else { continue }; let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; match s.dict.name(b"Subtype") { Some(b"Image") => out.push(r), Some(b"Form") if forms.insert(r) => { if let Some(inner) = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) { collect(doc, &inner, depth + 1, forms, out); } } _ => {} } } } fn filters(s: &Stream) -> Vec> { match s.dict.get(b"Filter") { Some(Object::Name(n)) => vec![n.to_vec()], Some(Object::Array(a)) => a.iter().filter_map(|f| f.as_name().map(<[u8]>::to_vec)).collect(), _ => Vec::new(), } } /// The samples of image `s`, decoded ([`ImageBudget::decode`]): through its filters, with a /// `/DecodeParms` dictionary given by reference, or as an array's entry, or with numbers inside by /// reference, read too (a predictor given so would otherwise be lost); a CCITT fax or JBIG2 image /// through its decoder too, behind other filters as well, a JBIG2 image with its `/JBIG2Globals`. /// A JBIG2 page has to be the image's size, which the caller checked against the export's limit, /// before the page is decoded. fn decoded(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result, String> { budget.decode(doc, s, MAX_DECODED).map_err(|e| e.to_string()) } /// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft /// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as /// alpha, JPEG images included, so cut-out images keep their transparent background. fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'static str, Vec), String> { let f = filters(s); let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1; match f.last().map(Vec::as_slice) { Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()), Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()), _ => {} } let as_is = || Ok(("jpg", s.raw.to_vec())); let (w, h) = (s.dict.int(b"Width").unwrap_or(0), s.dict.int(b"Height").unwrap_or(0)); let Some((w, h)) = size(w, h) else { return match () { _ if jpeg => as_is(), _ if w <= 0 || h <= 0 => Err("the image has no size".into()), _ => Err(format!("{w} × {h} is too large to export")), }; }; let mask = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true))); // A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2). let refused = budget.refused(); let mut alpha = if mask { None } else { soft_mask(doc, s, w, h, budget).or_else(|| stencil_mask(doc, s, w, h, budget)) }; // Without its mask the image would come out opaque, showing what the mask hides. if budget.refused() > refused { return Err(FilterError::Spent.to_string()); } let key = if mask { None } else { colour_key(doc, s) }; // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. let endpoint = |value: Option<&Object>| value.map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).filter(|v| v.is_finite()); let inverted = (jpeg || mask) && s.dict .get(b"Decode") .map(|d| doc.resolve(d)) // Both ends have to be finite numbers, as for lossless images, or the default mapping stands. .and_then(|d| { let a = d.as_array()?; Some(endpoint(a.first())? > endpoint(a.get(1))?) }) .unwrap_or(false); // In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as // a negative. let as_is_negative = jpeg && !inverted && matches!(space(doc, s.dict.get(b"ColorSpace")), Ok(Space::Cmyk)); if jpeg && alpha.is_none() && key.is_none() && !as_is_negative { return as_is(); } let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? }; let (samples, bpc) = if jpeg { if !budget.pay_jpeg((w as u64).saturating_mul(h as u64).saturating_mul(space.components() as u64), &s.raw) { return Err(FilterError::Spent.to_string()); } // Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an // indexed one (not valid), is written as is, without its transparency. let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() }; space = decoded; (samples, 8) } else { let data = decoded(doc, s, budget)?; let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { 1 => 1, 2 => 2, 4 => 4, 8 => 8, 16 => 16, other => return Err(format!("{other} bits per component isn't supported")), }; (unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc) }; let n = space.components(); let decode = if jpeg || mask { None } else { decode(doc, s, n) }; if alpha.is_none() { alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k)); } if jpeg && alpha.is_none() && !as_is_negative { return as_is(); } let invert = inverted ^ mask; // a stencil mask paints its 0 samples // `unpack` keeps only the high byte at 16 bits, so Decode uses an effective maximum of 255 too. let max = (1u32 << bpc.min(8)) - 1; let mut rgb = Vec::with_capacity(w * h * 3); for px in samples.chunks_exact(n) { let decoded = |i: usize| { decode.as_ref().and_then(|pairs| pairs.get(i)).map(|&[lo, hi]| { let t = f64::from(px.get(i).copied().unwrap_or(0)) / f64::from(max); // Avoid overflowing hi - lo for finite endpoints with opposite signs. (1.0 - t) * lo + t * hi }) }; let v = |i: usize| { if let Some(value) = decoded(i) { return (value.clamp(0.0, 1.0) * 255.0).round() as u8; } let x = (px.get(i).copied().unwrap_or(0) as u32 * 255 / max) as u8; if invert { 255 - x } else { x } }; match &space { Space::Gray => rgb.extend_from_slice(&[v(0); 3]), Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]), Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))), Space::Indexed(base, hival, table) => { // Explicit Decode maps into index units, rounded then clipped (8.6.6.3). // Without Decode, retain raw indices rather than normalizing to hival. let i = match decoded(0) { Some(value) => value.round().clamp(0.0, f64::from(*hival)) as usize, None => px.first().copied().unwrap_or(0).min(*hival) as usize, }; let k = base.components(); let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]); rgb.extend_from_slice(&match **base { Space::Gray => [e[0]; 3], Space::Cmyk => cmyk(e[0], e[1], e[2], e[3]), _ => [e[0], e[1], e[2]], }); } } } if let (Some(a), Some(matte)) = (alpha.as_deref(), matte(doc, s, &space)) { unpremultiply(&mut rgb, a, matte); } png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p)) } /// The explicit Decode intervals for a lossless colour image (8.9.5.2, Table 90). /// /// A malformed array (not an array, the wrong length, a non-number or non-finite entry) gives /// the default mapping, so the image still exports. fn decode(doc: &Document, s: &Stream, n: usize) -> Option> { let value = doc.resolve(s.dict.get(b"Decode")?); let values = value.as_array()?; if Some(values.len()) != n.checked_mul(2) { return None; } let endpoint = |value: &Object| doc.resolve(value).as_f64().filter(|v| v.is_finite()); let mut pairs = Vec::with_capacity(n); for [lo, hi] in values.as_chunks::<2>().0 { pairs.push([endpoint(lo)?, endpoint(hi)?]); } Some(pairs) } /// Width and height as sizes, when both are positive and the image is at most `MAX_PIXELS`. fn size(w: i64, h: i64) -> Option<(usize, usize)> { let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); let pixels = (w as u64).checked_mul(h as u64)?; (w > 0 && h > 0 && pixels <= MAX_PIXELS).then_some((w, h)) } #[cfg(test)] thread_local! { /// JPEGs decoded whole, for the tests that keep one from being decoded for nothing. pub(crate) static JPEGS_DECODED: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The size a JPEG's header gives, which is what decoding it goes by, whatever its dictionary /// says: an image of one pixel can hold a header of 500 megapixels. fn jpeg_size(data: &[u8]) -> Option<(usize, usize)> { use zune_jpeg::zune_core::{bytestream::ZCursor, options::DecoderOptions}; let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); dec.decode_headers().ok()?; dec.dimensions() } /// JPEG `data`, decoded, if its header says it is `w` × `h`. fn jpeg_of_size(data: &[u8], w: usize, h: usize) -> Option { if jpeg_size(data)? != (w, h) { return None; } #[cfg(test)] JPEGS_DECODED.with(|n| n.set(n.get() + 1)); image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok() } /// A JPEG's samples (8 bits): grey for a grey image, CMYK as stored for a CMYK one, else RGB, /// with the space they are in. fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec, Space)> { match space { Space::Indexed(..) => return None, Space::Cmyk => return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)), _ => {} } let img = jpeg_of_size(data, w, h)?; Some(match space { Space::Gray => (img.into_luma8().into_raw(), Space::Gray), _ => (img.into_rgb8().into_raw(), Space::Rgb), }) } /// A four-component JPEG's CMYK samples as the page reads them: as stored, not inverted the way /// a decoder converting to RGB inverts them, with YCCK converted to CMYK. fn cmyk_jpeg_samples(data: &[u8], w: usize, h: usize) -> Option> { use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); dec.decode_headers().ok()?; let input = dec.input_colorspace()?; if !matches!(input, ColorSpace::CMYK | ColorSpace::YCCK) || dec.dimensions()? != (w, h) { return None; } dec.set_options(options.jpeg_set_out_colorspace(input)); let mut px = dec.decode().ok()?; if px.len() != w.checked_mul(h)?.checked_mul(4)? { return None; } if input == ColorSpace::YCCK { for p in px.as_chunks_mut::<4>().0 { let (y, cb, cr) = (f32::from(p[0]), f32::from(p[1]) - 128.0, f32::from(p[2]) - 128.0); let ink = |rgb: f32| (255.0 - rgb).round().clamp(0.0, 255.0) as u8; p[0] = ink(y + 1.402 * cr); p[1] = ink(y - 0.344_136 * cb - 0.714_136 * cr); p[2] = ink(y + 1.772 * cb); } } Some(px) } /// The colour-key `/Mask` (8.9.6.4): a [min max] pair per colour component, in sample values. fn colour_key(doc: &Document, s: &Stream) -> Option> { let m = doc.resolve(s.dict.get(b"Mask")?); let a = m.as_array()?; let v: Vec = a.iter().map(|o| doc.resolve(o).as_f64().map(|f| f.clamp(0.0, 65_535.0) as u32)).collect::>()?; (!v.is_empty() && v.len().is_multiple_of(2)).then(|| v.as_chunks::<2>().0.iter().map(|&[lo, hi]| (lo, hi)).collect()) } /// Alpha from a colour key: pixels whose every component falls in its range are transparent. fn keyed_alpha(samples: &[u8], n: usize, bpc: usize, key: &[(u32, u32)]) -> Option> { if key.len() != n { return None; } // 16-bit samples keep their high byte (see `unpack`), so compare the ranges' high bytes. let shift = if bpc == 16 { 8 } else { 0 }; Some( samples .chunks_exact(n) .map(|px| { let hidden = px.iter().zip(key).all(|(&c, &(lo, hi))| (lo >> shift..=hi >> shift).contains(&(c as u32))); if hidden { 0 } else { 255 } }) .collect(), ) } /// The `/Matte` colour of the image's soft mask (11.6.5.3), for grey and RGB images: the colour /// the image samples were pre-blended with. fn matte(doc: &Document, s: &Stream, space: &Space) -> Option> { let r = s.dict.get(b"SMask")?.as_ref()?; let obj = doc.get(r); let Object::Stream(m) = &*obj else { return None }; let a = doc.resolve(m.dict.get(b"Matte")?); let v: Vec = a.as_array()?.iter().map(|o| doc.resolve(o).as_f64().map(|f| (f.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::>()?; match (space, v.len()) { (Space::Gray, 1) => Some(vec![v[0]; 3]), (Space::Rgb, 3) => Some(v), _ => None, } } /// Undo a pre-blend with `matte`: c = m + (c′ − m) / α. fn unpremultiply(rgb: &mut [u8], alpha: &[u8], matte: Vec) { for (px, &a) in rgb.as_chunks_mut::<3>().0.iter_mut().zip(alpha) { if a == 0 { continue; } for (c, &m) in px.iter_mut().zip(&matte) { let v = m as i32 + (*c as i32 - m as i32) * 255 / a as i32; *c = v.clamp(0, 255) as u8; } } } #[derive(Clone, Debug)] enum Space { Gray, Rgb, Cmyk, /// Base space, highest valid palette index, and lookup table. Indexed(Box, u8, Vec), } impl Space { fn components(&self) -> usize { match self { Space::Gray | Space::Indexed(..) => 1, Space::Rgb => 3, Space::Cmyk => 4, } } } /// A lookup table holds at most 256 entries of 4 bytes; decoding stops well past that. const MAX_LOOKUP: usize = 64 << 10; fn space(doc: &Document, cs: Option<&Object>) -> Result { let Some(cs) = cs else { return Err("the image has no colour space".into()) }; let cs = doc.resolve(cs); let unsupported = |n: &[u8]| Err(format!("{} images can't be exported yet", String::from_utf8_lossy(n))); match &*cs { Object::Name(n) => match n.as_slice() { b"DeviceGray" | b"G" | b"CalGray" => Ok(Space::Gray), b"DeviceRGB" | b"RGB" | b"CalRGB" => Ok(Space::Rgb), b"DeviceCMYK" | b"CMYK" => Ok(Space::Cmyk), other => unsupported(other), }, Object::Array(a) => match a.first().and_then(Object::as_name) { Some(b"ICCBased") => match a.get(1).map(|s| doc.resolve(s)).as_deref() { Some(Object::Stream(icc)) => match icc.dict.int(b"N") { Some(1) => Ok(Space::Gray), Some(3) => Ok(Space::Rgb), Some(4) => Ok(Space::Cmyk), _ => Err("an ICC colour space with an unusual number of components".into()), }, _ => Err("a damaged ICC colour space".into()), }, Some(b"CalGray") => Ok(Space::Gray), Some(b"CalRGB") => Ok(Space::Rgb), Some(b"Indexed" | b"I") => { // Checked before recursing: Indexed is the only space that recurses, so refusing // an Indexed base here also ends a cyclic base reference. let nested = match a.get(1).map(|b| doc.resolve(b)).as_deref() { Some(Object::Array(b)) => matches!(b.first().and_then(Object::as_name), Some(b"Indexed" | b"I")), Some(Object::Name(n)) => matches!(n.as_slice(), b"Indexed" | b"I"), _ => false, }; if nested { return Err("nested indexed colour spaces aren't valid".into()); } let base = space(doc, a.get(1))?; let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() { Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8, _ => return Err("an indexed colour space with an invalid hival".into()), }; let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { Some(Object::String(s)) => s.bytes.clone(), // Through its parameters given by reference too, as the image's samples. Some(Object::Stream(t)) => doc .with_direct_parms(t) .decoded_within(MAX_LOOKUP) .map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?, _ => return Err("an indexed colour space without a lookup table".into()), }; Ok(Space::Indexed(Box::new(base), hival, table)) } Some(other) => unsupported(other), None => Err("a damaged colour space".into()), }, _ => Err("a damaged colour space".into()), } } /// Samples as one byte each (16-bit samples keep their high byte). fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Option> { let row_bits = w * n * bpc; let row = row_bits.div_ceil(8); if data.len() < row * h { return None; } let mut out = Vec::with_capacity(w * h * n); for y in 0..h { let r = &data[y * row..(y + 1) * row]; match bpc { 8 => out.extend_from_slice(&r[..w * n]), 16 => out.extend(r.as_chunks::<2>().0.iter().take(w * n).map(|c| c[0])), _ => { let per = 8 / bpc; let m = (1u8 << bpc) - 1; for i in 0..w * n { let shift = 8 - bpc * (i % per + 1); out.push((r[i / per] >> shift) & m); } } } } Some(out) } fn cmyk(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] { let f = |x: u8| ((255 - x as u32) * (255 - k as u32) / 255) as u8; [f(c), f(m), f(y)] } /// The soft mask (`/SMask`) as 8-bit alpha, scaled to the image's size when it has another. fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option> { let r = s.dict.get(b"SMask")?.as_ref()?; let obj = doc.get(r); let Object::Stream(m) = &*obj else { return None }; gray_plane(doc, m, w, h, budget) } /// A stencil `/Mask` (an image mask stream, 8.9.6.3) as 8-bit alpha: its 1 samples (0 with /// `/Decode [1 0]`) hide the image. fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option> { let r = s.dict.get(b"Mask")?.as_ref()?; let obj = doc.get(r); let Object::Stream(m) = &*obj else { return None }; Some(gray_plane(doc, m, w, h, budget)?.into_iter().map(|v| 255 - v).collect()) } /// A one-component mask image as 8-bit values (its `/Decode` interval applied), scaled to /// `w` × `h`. `None` when it can't be decoded (JPEG 2000 masks). fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option> { let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?; let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true))); let f = filters(m); let mut px = match f.last().map(Vec::as_slice) { Some(b"DCTDecode" | b"DCT") if f.len() == 1 => { // Past the allowance the image goes too: `image` sees this one refused. if !budget.pay_jpeg((mw as u64).saturating_mul(mh as u64), &m.raw) { return None; } jpeg_of_size(&m.raw, mw, mh)?.into_luma8().into_raw() } Some(b"DCTDecode" | b"DCT" | b"JPXDecode") => return None, _ => { let bpc = if stencil { 1 } else { m.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { 1 => 1, 2 => 2, 4 => 4, 8 => 8, 16 => 16, _ => return None, }; let max = (1u32 << bpc.min(8)) - 1; unpack(&decoded(doc, m, budget).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() } }; if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) { for value in &mut px { let t = f64::from(*value) / 255.0; *value = (((1.0 - t) * lo + t * hi).clamp(0.0, 1.0) * 255.0).round() as u8; } } if (mw, mh) == (w, h) { return Some(px); } let img = image::GrayImage::from_raw(mw as u32, mh as u32, px)?; Some(scaled(&img, w as u32, h as u32).into_raw()) } /// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at /// 16 bytes a pixel, which a mask of other proportions than its image can make larger than any /// machine: 100,000 × 1 on an image of 1 × 100,000 asks for 160 GB, and that aborts the process. /// Such a mask is scaled across first, which holds no more pixels than one of the two has. Past a /// quarter over the larger of them, so that rounding leaves a mask of its image's shape as it was. pub(crate) fn scaled(img: &image::GrayImage, w: u32, h: u32) -> image::GrayImage { use image::imageops::{FilterType, resize}; let (mw, mh) = (u64::from(img.width()), u64::from(img.height())); let largest = (mw * mh).max(u64::from(w) * u64::from(h)); if (mw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) { resize(&resize(img, w, img.height(), FilterType::Triangle), w, h, FilterType::Triangle) } else { resize(img, w, h, FilterType::Triangle) } } fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result, String> { let mut out = Vec::new(); { let mut enc = png::Encoder::new(&mut out, w, h); enc.set_depth(png::BitDepth::Eight); enc.set_compression(png::Compression::Fast); let data: Vec = match alpha { Some(a) => { enc.set_color(png::ColorType::Rgba); rgb.as_chunks::<3>().0.iter().zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], *a]).collect() } None => { enc.set_color(png::ColorType::Rgb); rgb.to_vec() } }; let mut wr = enc.write_header().map_err(|e| e.to_string())?; wr.write_image_data(&data).map_err(|e| e.to_string())?; } Ok(out) }