diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index e09115416c..e119e22548 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -306,42 +306,73 @@ fn is_latin1_keyword(k: &str) -> bool { pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); let (w, h) = img.dimensions(); - let color = match img.layout() { + let bpp = img.layout().channels() * img.sample_type().bytes(); + // Big-endian sample bytes. + let mut data = std::borrow::Cow::Borrowed(img.data()); + if img.sample_type() == SampleType::U16 { + to_big_endian(data.to_mut()); + } + let mut out = Vec::new(); + { + let mut writer = start(&mut out, (w, h), (img.layout(), img.sample_type()), img.icc.as_ref(), &img.meta, opts)?; + if opts.png_interlaced { + let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?; + write_idat(&mut writer, &idat, MAX_IDAT)?; + } else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, bpp, opts.png_compression) { + write_idat(&mut writer, &idat, MAX_IDAT)?; + } else { + writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?; + } + writer.finish().map_err(|e| CodecError::encode(F, e))?; + } + Ok(out) +} + +/// 16-bit native-endian samples to PNG's big-endian order, in place. +fn to_big_endian(data: &mut [u8]) { + for c in data.as_chunks_mut::<2>().0 { + let v = u16::from_ne_bytes([c[0], c[1]]); + c.copy_from_slice(&v.to_be_bytes()); + } +} + +/// Writes the signature and every chunk ahead of the image data for a `w` × `h` PNG of +/// `layout` and `sample` (already the encode plan's), with its profile and metadata. +fn start( + out: W, + (w, h): (u32, u32), + (layout, sample): (ChannelLayout, SampleType), + icc: Option<&Vec>, + meta: &Metadata, + opts: &EncodeOptions, +) -> Result, CodecError> { + let color = match layout { ChannelLayout::Gray => png::ColorType::Grayscale, ChannelLayout::GrayA => png::ColorType::GrayscaleAlpha, ChannelLayout::Rgb => png::ColorType::Rgb, ChannelLayout::Rgba => png::ColorType::Rgba, l => return Err(CodecError::encode(F, format!("unsupported layout {l:?}"))), }; - let depth = match img.sample_type() { + let depth = match sample { SampleType::U8 => png::BitDepth::Eight, SampleType::U16 => png::BitDepth::Sixteen, s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))), }; - // Big-endian sample bytes. - let mut data = std::borrow::Cow::Borrowed(img.data()); - if depth == png::BitDepth::Sixteen { - for c in data.to_mut().as_chunks_mut::<2>().0 { - let v = u16::from_ne_bytes([c[0], c[1]]); - c.copy_from_slice(&v.to_be_bytes()); - } - } - let mut info = png::Info::with_size(w, h); info.color_type = color; info.bit_depth = depth; info.interlaced = opts.png_interlaced; if opts.embed_icc - && let Some(icc) = &img.icc + && let Some(icc) = icc { info.icc_profile = Some(icc.clone().into()); } if opts.embed_metadata { - if let Some(exif) = &img.meta.exif { + if let Some(exif) = &meta.exif { // The pixels are written as they are shown: never let a viewer rotate them again. - info.exif_metadata = Some(crate::resolution::export_exif(exif, img.meta.dpi).into_owned().into()); + info.exif_metadata = Some(crate::resolution::export_exif(exif, meta.dpi).into_owned().into()); } - if let Some((x, y)) = img.meta.dpi + if let Some((x, y)) = meta.dpi && x > 0.0 && y > 0.0 { @@ -349,51 +380,150 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result png::Compression::NoCompression, - PngCompression::Fast => png::Compression::Fast, - PngCompression::Default => png::Compression::Balanced, - PngCompression::Best => png::Compression::High, - }); - if opts.embed_metadata { - for (k, v) in &img.meta.text { - let latin1_text = v.chars().all(|c| (c as u32) < 256); - let res = if is_latin1_keyword(k) && latin1_text { - encoder.add_text_chunk(k.clone(), v.clone()) - } else if is_latin1_keyword(k) { - encoder.add_itxt_chunk(k.clone(), v.clone()) - } else { - continue; - }; - res.map_err(|e| CodecError::encode(F, e))?; - } - if let Some(xmp) = &img.meta.xmp { - encoder - .add_itxt_chunk(XMP_KEYWORD.into(), crate::resolution::export_xmp(xmp, img.meta.dpi).into_owned()) - .map_err(|e| CodecError::encode(F, e))?; - } + let mut encoder = png::Encoder::with_info(out, info).map_err(|e| CodecError::encode(F, e))?; + encoder.set_compression(match opts.png_compression { + PngCompression::None => png::Compression::NoCompression, + PngCompression::Fast => png::Compression::Fast, + PngCompression::Default => png::Compression::Balanced, + PngCompression::Best => png::Compression::High, + }); + if opts.embed_metadata { + for (k, v) in &meta.text { + let latin1_text = v.chars().all(|c| (c as u32) < 256); + let res = if is_latin1_keyword(k) && latin1_text { + encoder.add_text_chunk(k.clone(), v.clone()) + } else if is_latin1_keyword(k) { + encoder.add_itxt_chunk(k.clone(), v.clone()) + } else { + continue; + }; + res.map_err(|e| CodecError::encode(F, e))?; } - let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?; - if let Some(stereo) = opts.png_stereo { - // Ancillary chunk before IDAT, as the spec recommends for `sTER`. - writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?; + if let Some(xmp) = &meta.xmp { + encoder.add_itxt_chunk(XMP_KEYWORD.into(), crate::resolution::export_xmp(xmp, meta.dpi).into_owned()).map_err(|e| CodecError::encode(F, e))?; } - if opts.png_interlaced { - let bpp = img.layout().channels() * img.sample_type().bytes(); - let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?; - writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?; - } else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, img.layout().channels() * img.sample_type().bytes(), opts.png_compression) { - writer.write_chunk(png::chunk::IDAT, &idat).map_err(|e| CodecError::encode(F, e))?; - } else { - writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?; - } - writer.finish().map_err(|e| CodecError::encode(F, e))?; } - Ok(out) + let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?; + if let Some(stereo) = opts.png_stereo { + // Ancillary chunk before IDAT, as the spec recommends for `sTER`. + writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?; + } + Ok(writer) +} + +/// Largest IDAT chunk written. A chunk holds at most 2^31 - 1 bytes, so a zlib stream past that +/// (a multi-gigapixel image) is split over several IDAT chunks, which readers join back. +const MAX_IDAT: usize = 1 << 30; + +/// `zlib` as IDAT chunks of at most `max` bytes. +fn write_idat(writer: &mut png::Writer, zlib: &[u8], max: usize) -> Result<(), CodecError> { + for part in zlib.chunks(max.max(1)) { + writer.write_chunk(png::chunk::IDAT, part).map_err(|e| CodecError::encode(F, e))?; + } + Ok(()) +} + +/// A PNG encoded a band of rows at a time, for images too large to hold whole (a multi-gigapixel +/// print): each band is converted to the encode plan, filtered and deflated on all cores as it +/// arrives and written as IDAT chunks, so only about one band is ever held. The deflate bands are +/// [`parallel_idat`]'s, so the zlib stream is the one [`crate::encode`] writes for a large image. +/// Never interlaced (Adam7 needs the whole image). +pub struct PngRows { + writer: png::Writer, + plan: Plan, + width: u32, + height: u32, + /// Rows received so far. + rows: u32, + level: PngCompression, + /// Converted, big-endian rows that don't fill a deflate band yet. + pending: Vec, + /// The last row deflated: the row above `pending` for the filters. + prev: Vec, + adler: u32, + /// Whether the zlib header is written. + started: bool, +} + +impl PngRows { + /// Writes the header of a `first.width()` × `height` PNG to `out`. The layout, sample type, + /// profile and metadata come from `first`, the first band (not written yet: [`Self::push`] + /// it like the others), through the same plan as [`crate::encode`]. + pub fn new(out: W, first: &Image, height: u32, opts: &EncodeOptions) -> Result { + if opts.png_interlaced { + return Err(CodecError::unsupported(F, "an interlaced PNG can't be written a band of rows at a time")); + } + let width = first.width(); + if width == 0 || height == 0 { + return Err(CodecError::InvalidImage("cannot encode an empty image".into())); + } + let plan = crate::fidelity::plan(first, F, opts); + let writer = start(out, (width, height), (plan.layout, plan.sample), first.icc.as_ref(), &first.meta, opts)?; + Ok(Self { writer, plan, width, height, rows: 0, level: opts.png_compression, pending: Vec::new(), prev: Vec::new(), adler: 1, started: false }) + } + + /// Encodes the next rows of the image: `band` is as wide as the image, in any layout and + /// sample type (converted like the first band). + pub fn push(&mut self, band: &Image) -> Result<(), CodecError> { + if band.width() != self.width { + return Err(CodecError::InvalidImage(format!("a {} px wide band for a {} px wide image", band.width(), self.width))); + } + let rows = self.rows.checked_add(band.height()).filter(|&r| r <= self.height); + let Some(rows) = rows else { + return Err(CodecError::InvalidImage(format!("more than the image's {} rows", self.height))); + }; + let img = band.converted(self.plan.layout, self.plan.sample); + let at = self.pending.len(); + self.pending.try_reserve(img.data().len()).map_err(|_| CodecError::encode(F, "not enough memory for a band of rows"))?; + self.pending.extend_from_slice(img.data()); + if self.plan.sample == SampleType::U16 { + to_big_endian(self.pending.get_mut(at..).unwrap_or_default()); + } + self.rows = rows; + self.deflate_pending() + } + + /// Deflates the whole bands held, and the rest once every row is in. + fn deflate_pending(&mut self) -> Result<(), CodecError> { + let bpp = self.plan.layout.channels() * self.plan.sample.bytes(); + let stride = self.width as usize * bpp; + let per_band = band_rows(stride); + let held = self.pending.len() / stride.max(1); + let finish = self.rows == self.height; + let take = if finish { held } else { held / per_band * per_band }; + if take == 0 { + return Ok(()); + } + let rows = self.pending.get(..take * stride).unwrap_or_default(); + let prev = (!self.prev.is_empty()).then_some(self.prev.as_slice()); + let parts = deflate_rows(rows, prev, stride, bpp, per_band, deflate_level(self.level), finish, encode_threads()) + .ok_or_else(|| CodecError::encode(F, "a band of rows could not be compressed"))?; + let mut zlib = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::() + 6); + if !self.started { + zlib.extend_from_slice(&zlib_header(self.level)); + self.started = true; + } + for p in &parts { + zlib.extend_from_slice(&p.z); + self.adler = adler32_combine(self.adler, p.adler, p.len); + } + if finish { + zlib.extend_from_slice(&self.adler.to_be_bytes()); + } + write_idat(&mut self.writer, &zlib, MAX_IDAT)?; + self.prev.clear(); + self.prev.extend_from_slice(rows.get(rows.len() - stride..).unwrap_or_default()); + self.pending.drain(..take * stride); + Ok(()) + } + + /// Ends the file. Fails when fewer rows than the image's height were pushed. + pub fn finish(self) -> Result<(), CodecError> { + if self.rows != self.height || !self.pending.is_empty() { + return Err(CodecError::InvalidImage(format!("{} of the image's {} rows were written", self.rows, self.height))); + } + self.writer.finish().map_err(|e| CodecError::encode(F, e)) + } } /// Build the zlib stream for an Adam7-interlaced image (filter type 0). @@ -417,13 +547,7 @@ fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression y += dy; } } - let lvl = match level { - PngCompression::None => flate2::Compression::none(), - PngCompression::Fast => FAST, - PngCompression::Default => flate2::Compression::default(), - PngCompression::Best => flate2::Compression::best(), - }; - let mut z = flate2::write::ZlibEncoder::new(Vec::new(), lvl); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), deflate_level(level)); z.write_all(&raw).map_err(|e| CodecError::encode(F, e))?; z.finish().map_err(|e| CodecError::encode(F, e)) } @@ -447,44 +571,108 @@ fn parallel_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompress if cfg!(target_arch = "wasm32") || data.len() < PARALLEL_MIN_BYTES || level == PngCompression::None || w == 0 || h == 0 { return None; } - let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32); - banded_idat(data, w, h, bpp, level, threads) + banded_idat(data, w, h, bpp, level, encode_threads()) } /// Filtered bytes per deflate band. The bands depend only on the image, never on the number of /// threads, so the same image and options give the same bytes whatever the machine's core count. const BAND_BYTES: usize = 512 << 10; +/// The deflate effort for `level`. +fn deflate_level(level: PngCompression) -> flate2::Compression { + match level { + PngCompression::None => flate2::Compression::none(), + PngCompression::Fast => FAST, + PngCompression::Default => flate2::Compression::default(), + PngCompression::Best => flate2::Compression::best(), + } +} + +/// The zlib stream header: deflate, 32 K window; FLEVEL only advertises the effort. +fn zlib_header(level: PngCompression) -> [u8; 2] { + match level { + PngCompression::None | PngCompression::Fast => [0x78, 0x01], + PngCompression::Best => [0x78, 0xDA], + PngCompression::Default => [0x78, 0x9C], + } +} + +/// Rows per deflate band for rows of `stride` bytes. +fn band_rows(stride: usize) -> usize { + (BAND_BYTES / stride.saturating_add(1)).max(1) +} + +/// Worker threads for the banded encoders: one on wasm (no threads), else the machine's cores. +fn encode_threads() -> usize { + if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32) } +} + /// [`parallel_idat`] on `threads` worker threads. fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression, threads: usize) -> Option> { let stride = w * bpp; - let band_rows = (BAND_BYTES / stride.saturating_add(1)).max(1); - let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows).map(|y| (y, (y + band_rows).min(h))).collect(); - let lvl = match level { - PngCompression::Fast => FAST, - PngCompression::Best => flate2::Compression::best(), - _ => flate2::Compression::default(), + let parts = deflate_rows(data.get(..h * stride)?, None, stride, bpp, band_rows(stride), deflate_level(level), true, threads)?; + let mut out = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::() + 6); + out.extend_from_slice(&zlib_header(level)); + let mut adler = 1u32; + for p in &parts { + out.extend_from_slice(&p.z); + adler = adler32_combine(adler, p.adler, p.len); + } + out.extend_from_slice(&adler.to_be_bytes()); + Some(out) +} + +/// One band of rows filtered and deflated: the raw deflate bytes, and the Adler-32 and length of +/// the filtered bytes they hold. +struct Deflated { + z: Vec, + adler: u32, + len: usize, +} + +/// `rows` (whole rows of `stride` bytes; `prev` is the row above the first, if any) filtered and +/// deflated in independent bands of `band_rows` rows on up to `threads` workers (inline for one). +/// The last band finishes the deflate stream when `finish`; every other band ends on a sync flush. +/// `None` when a band did not deflate in one call ([`deflate_band`]). +#[allow(clippy::too_many_arguments)] +fn deflate_rows( + rows: &[u8], + prev: Option<&[u8]>, + stride: usize, + bpp: usize, + band_rows: usize, + lvl: flate2::Compression, + finish: bool, + threads: usize, +) -> Option> { + let h = rows.len() / stride.max(1); + let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows.max(1)).map(|y| (y, (y + band_rows).min(h))).collect(); + let band = |&(y0, y1): &(usize, usize)| -> Option { + let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1)); + let mut scratch = vec![0u8; stride]; + for y in y0..y1 { + let row = rows.get(y * stride..(y + 1) * stride)?; + let above = if y > 0 { rows.get((y - 1) * stride..y * stride) } else { prev }; + filter_row(row, above, bpp, &mut scratch, &mut filtered); + } + let adler = adler32(1, &filtered); + let z = deflate_band(&filtered, lvl, finish && y1 == h)?; + Some(Deflated { z, adler, len: filtered.len() }) }; + if threads <= 1 || bands.len() <= 1 { + return bands.iter().map(band).collect(); + } let next = std::sync::atomic::AtomicUsize::new(0); - type Band = (usize, Vec, u32, usize); - let mut parts: Vec = std::thread::scope(|sc| { - let workers: Vec<_> = (0..threads.min(bands.len()).max(1)) + let mut parts: Vec<(usize, Deflated)> = std::thread::scope(|sc| { + let workers: Vec<_> = (0..threads.min(bands.len())) .map(|_| { sc.spawn(|| { - let mut out: Vec = Vec::new(); + let mut out = Vec::new(); loop { let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let Some(&(y0, y1)) = bands.get(i) else { break }; - let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1)); - let mut scratch = vec![0u8; stride]; - for y in y0..y1 { - let row = &data[y * stride..(y + 1) * stride]; - let prev = (y > 0).then(|| &data[(y - 1) * stride..y * stride]); - filter_row(row, prev, bpp, &mut scratch, &mut filtered); - } - let adler = adler32(1, &filtered); - let Some(z) = deflate_band(&filtered, lvl, y1 == h) else { return Vec::new() }; - out.push((i, z, adler, filtered.len())); + let Some(b) = bands.get(i) else { break }; + let Some(d) = band(b) else { return Vec::new() }; + out.push((i, d)); } out }) @@ -496,20 +684,7 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio return None; } parts.sort_by_key(|p| p.0); - let mut out = Vec::with_capacity(parts.iter().map(|p| p.1.len()).sum::() + 6); - // zlib header: deflate, 32 K window; FLEVEL only advertises the effort. - out.extend_from_slice(match level { - PngCompression::Fast => &[0x78, 0x5E], - PngCompression::Best => &[0x78, 0xDA], - _ => &[0x78, 0x9C], - }); - let mut adler = 1u32; - for (_, z, a, len) in &parts { - out.extend_from_slice(z); - adler = adler32_combine(adler, *a, *len); - } - out.extend_from_slice(&adler.to_be_bytes()); - Some(out) + Some(parts.into_iter().map(|p| p.1).collect()) } /// Raw deflate of one band: ends on a sync flush, or finishes the stream for the `last` band. @@ -978,3 +1153,132 @@ mod parallel_tests { } } } + +#[cfg(test)] +mod streamed_tests { + use super::*; + + /// (bytes ahead of the first IDAT, the IDAT payloads joined, how many IDAT chunks). + fn split(png: &[u8]) -> (Vec, Vec, usize) { + let (mut at, mut head, mut zlib, mut count) = (8, None, Vec::new(), 0); + while at + 12 <= png.len() { + let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize; + if &png[at + 4..at + 8] == b"IDAT" { + head.get_or_insert_with(|| png[..at].to_vec()); + zlib.extend_from_slice(&png[at + 8..at + 8 + len]); + count += 1; + } + at += 12 + len; + } + (head.unwrap(), zlib, count) + } + + fn noise(n: usize, seed: u32) -> Vec { + (0..n).map(|i| ((i / 3) as u32 ^ seed).wrapping_mul(2_654_435_761).rotate_left(i as u32 % 7) as u8 / 3 + (i % 97) as u8).collect() + } + + /// `img` pushed in bands of `heights` rows (the last one takes the rest). + fn streamed(img: &Image, heights: &[u32], opts: &EncodeOptions) -> Vec { + let (w, h) = img.dimensions(); + let row = img.data().len() / h as usize; + let mut bands = Vec::new(); + let mut y = 0; + for &n in heights.iter().chain(std::iter::once(&u32::MAX)) { + let n = n.min(h - y); + if n == 0 { + continue; + } + let data = img.data()[y as usize * row..(y + n) as usize * row].to_vec(); + bands.push(Image::from_raw(w, n, img.layout(), img.sample_type(), data).unwrap().with_icc(img.icc.clone()).with_meta(img.meta.clone())); + y += n; + } + let mut out = Vec::new(); + let mut rows = PngRows::new(&mut out, &bands[0], h, opts).unwrap(); + for b in &bands { + rows.push(b).unwrap(); + } + rows.finish().unwrap(); + out + } + + /// Bands of any height give the zlib stream, header chunks and pixels the whole-image encoder + /// gives, at 8 and 16 bits, with a profile, resolution, text and XMP. + #[test] + fn streamed_rows_match_the_whole_image_encoder() { + for (layout, sample, ch, bytes) in [(ChannelLayout::Rgba, SampleType::U8, 4usize, 1usize), (ChannelLayout::Rgb, SampleType::U16, 3, 2)] { + let (w, h) = (1201u32, 1013u32); + let meta = Metadata { + dpi: Some((300.0, 300.0)), + text: vec![("Title".into(), "Tent".into())], + xmp: Some("".into()), + ..Default::default() + }; + let img = Image::from_raw(w, h, layout, sample, noise(w as usize * h as usize * ch * bytes, 7)) + .unwrap() + .with_icc(Some((0..600u32).map(|i| (i % 251) as u8).collect())) + .with_meta(meta); + for level in [PngCompression::Fast, PngCompression::Default] { + let opts = EncodeOptions { png_compression: level, ..Default::default() }; + let whole = crate::encode(&img, Format::Png, &opts).unwrap(); + let (head, zlib, _) = split(&whole); + for heights in [&[1u32, 77, 300][..], &[256, 256, 256], &[1013]] { + let png = streamed(&img, heights, &opts); + let (h2, z2, _) = split(&png); + assert!(h2 == head, "{layout:?} {level:?} {heights:?}: header chunks differ"); + assert!(z2 == zlib, "{layout:?} {level:?} {heights:?}: zlib stream differs"); + assert_eq!(decode(&png, &Limits::default()).unwrap().data(), img.data()); + } + } + } + } + + /// Small images, no compression and bands in another sample type (converted by the plan). + #[test] + fn small_uncompressed_and_converted_bands_round_trip() { + let img = Image::from_raw(3, 5, ChannelLayout::GrayA, SampleType::U8, noise(30, 3)).unwrap(); + for level in [PngCompression::None, PngCompression::Best] { + let opts = EncodeOptions { png_compression: level, ..Default::default() }; + let png = streamed(&img, &[2, 1], &opts); + assert_eq!(decode(&png, &Limits::default()).unwrap().data(), img.data(), "{level:?}"); + } + // Float bands are written with 16 bits, as `encode` writes them. + let f = img.convert(ChannelLayout::GrayA, SampleType::F32); + let png = streamed(&f, &[4], &EncodeOptions::default()); + let back = decode(&png, &Limits::default()).unwrap(); + assert_eq!(back.sample_type(), SampleType::U16); + assert_eq!(back.convert(ChannelLayout::GrayA, SampleType::U8).data(), img.data()); + } + + /// A zlib stream longer than one chunk may hold is split over IDAT chunks that read back. + #[test] + fn long_streams_split_over_several_idat_chunks() { + let img = Image::from_raw(64, 40, ChannelLayout::Rgb, SampleType::U8, noise(64 * 40 * 3, 9)).unwrap(); + let whole = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let (_, zlib, _) = split(&whole); + let mut out = Vec::new(); + let mut w = start(&mut out, (64, 40), (ChannelLayout::Rgb, SampleType::U8), None, &Metadata::default(), &EncodeOptions::default()).unwrap(); + write_idat(&mut w, &zlib, 100).unwrap(); + w.finish().unwrap(); + let (_, joined, count) = split(&out); + assert_eq!(count, zlib.len().div_ceil(100)); + assert!(joined == zlib); + assert_eq!(decode(&out, &Limits::default()).unwrap().data(), img.data()); + } + + /// Bands that don't fit the image, missing rows and interlacing are errors, not panics. + #[test] + fn bands_that_dont_fit_are_errors() { + let band = Image::from_raw(4, 2, ChannelLayout::Rgb, SampleType::U8, vec![0; 24]).unwrap(); + let opts = EncodeOptions::default(); + assert!(PngRows::new(Vec::new(), &band, 0, &opts).is_err()); + assert!(PngRows::new(Vec::new(), &band, 3, &EncodeOptions { png_interlaced: true, ..Default::default() }).is_err()); + let mut rows = PngRows::new(Vec::new(), &band, 3, &opts).unwrap(); + rows.push(&band).unwrap(); + // Two more rows than the image holds. + assert!(rows.push(&band).is_err()); + let narrow = Image::from_raw(3, 1, ChannelLayout::Rgb, SampleType::U8, vec![0; 9]).unwrap(); + assert!(rows.push(&narrow).is_err()); + // Two of three rows. + assert!(rows.finish().is_err()); + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 1ae285e7ce..44ec622ef4 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -29,7 +29,7 @@ pub mod web; pub use crate::cicp::Cicp; pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES}; -pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo}; +pub use crate::codecs::png::{PngPalette, PngRows, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo}; pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian}; pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind}; pub use crate::error::CodecError; diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 55f602a6e9..35190a9880 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -334,7 +334,16 @@ pub(crate) fn image_surface(img: &Image, target_layout: ChannelLayout, csample: /// (keeping CMYK / depth exactly) instead of going through the compositor. pub(crate) fn single_layer(doc: &Document) -> Option<&Surface> { let [l] = &doc.layers[..] else { return None }; - let ok = l.visible + match &l.content { + LayerContent::Raster(s) if plain_layer(l, doc.mode) && s.format() == doc.pixel_format() => Some(s), + _ => None, + } +} + +/// A visible, unmasked, normal, fully opaque layer with no effects or blending options: on its +/// own, its pixels are the composite. +fn plain_layer(l: &Layer, mode: ColorMode) -> bool { + l.visible && l.opacity >= 1.0 && l.fill_opacity >= 1.0 && l.mask.is_none() @@ -342,12 +351,8 @@ pub(crate) fn single_layer(doc: &Document) -> Option<&Surface> { && l.effects.items.is_empty() && l.effects.psd_raw.is_none() && matches!(l.blend, BlendMode::Normal | BlendMode::PassThrough) - && photocraft_compose::channel_weights(l, doc.mode).is_none() - && !photocraft_compose::blend_if_active(l, doc.mode); - match (&l.content, ok) { - (LayerContent::Raster(s), true) if s.format() == doc.pixel_format() => Some(s), - _ => None, - } + && photocraft_compose::channel_weights(l, mode).is_none() + && !photocraft_compose::blend_if_active(l, mode) } pub(crate) fn layout_for(mode: ColorMode, alpha: bool) -> ChannelLayout { @@ -378,45 +383,64 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result, cmyk_target: bool, lab_target: bool) -> Result { + let src = FlatSource::new(doc, warnings, cmyk_target, lab_target)?; let (w, h) = (doc.size.width, doc.size.height); - let canvas = doc.bounds(); - let fmt = doc.pixel_format(); - let n = (w as usize) * (h as usize); - // A Lab document keeps its pixels only for a format that stores Lab (TIFF); for the others it - // goes through the sRGB composite, which flattens nothing when there is a single plain layer. - let single = single_layer(doc); - let native = single.filter(|_| fmt.mode != ColorMode::Lab || lab_target); - let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); - let img = if let Some(s) = native { - // Native path: keep model and depth. The surface's encoded samples are the codec's raw - // native-endian samples, copied a band of rows at a time (alpha dropped when opaque). - let ch = fmt.channels(); - let opaque = opaque_surface(s, canvas); - let layout = layout_for(fmt.mode, !opaque); - let bps = fmt.sample.bytes(); - let keep = if opaque { (ch - 1) * bps } else { ch * bps }; - let mut data = try_buffer(n, keep)?; - let rows = (BAND_BYTES / (w as usize * ch * bps).max(1)).max(1) as i32; - let mut y = 0; - while y < h as i32 { - let y1 = y.saturating_add(rows).min(h as i32); - let band = s.to_interleaved(Rect::new(0, y, w as i32, y1)); - if opaque { - for px in band.chunks_exact(ch * bps) { - data.extend_from_slice(&px[..keep]); - } - } else { - data.extend_from_slice(&band); - } - y = y1; + // The single layer's alpha is dropped up front when it is opaque. A composite is rendered + // with alpha, dropped afterwards, in place, when every pixel turned out opaque. + let alpha = src.native.is_none_or(|s| !opaque_surface(s, doc.bounds())); + let bytes = src.sample.bytes(); + let mut data = try_buffer((w as usize) * (h as usize), src.layout(alpha).channels() * bytes)?; + let mut opaque = true; + src.bands(alpha, |_, band, translucent| { + opaque &= !translucent; + data.extend_from_slice(&band); + Ok(()) + })?; + let mut layout = src.layout(alpha); + if src.native.is_none() && opaque { + drop_alpha(&mut data, layout.color_channels() * bytes, bytes); + layout = src.layout(false); + } + Ok(Image::from_raw(w, h, layout, src.sample, data)?.with_icc(src.icc.clone()).with_meta(src.meta())) +} + +/// How a document becomes flat codec pixels, a band of rows at a time: [`document_to_image`] +/// gathers the bands into one image, a streamed PNG export ([`export_png_streamed`]) encodes each +/// band as it is rendered. +struct FlatSource<'a> { + doc: &'a Document, + /// The single plain layer, written in its own model and depth ([`single_layer`]). + native: Option<&'a Surface>, + /// The model of the pixels: the document's for the single layer, else CMYK, gray or RGB. + mode: ColorMode, + sample: CSample, + /// A CMYK composite mixed on ink values by the native CMYK compositor, so opaque pixels keep + /// their exact inks ([`photocraft_compose::cmyk::renders_inks`]). + inks: bool, + /// The CMYK profile any other CMYK composite is converted back through, for a CMYK target. + space: Option>, + icc: Option>, + /// Rows per band; 0 picks them from the size (tests ask for small bands). + rows: i32, +} + +impl<'a> FlatSource<'a> { + /// `cmyk_target` / `lab_target`: the format stores CMYK / Lab pixels. + fn new(doc: &'a Document, warnings: &mut Vec, cmyk_target: bool, lab_target: bool) -> Result { + let fmt = doc.pixel_format(); + let sample = csample(fmt.sample); + // A Lab document keeps its pixels only for a format that stores Lab (TIFF); for the others it + // goes through the sRGB composite, which flattens nothing when there is a single plain layer. + let single = single_layer(doc); + let native = single.filter(|_| fmt.mode != ColorMode::Lab || lab_target); + let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); + if native.is_some() { + return Ok(Self { doc, native, mode: fmt.mode, sample, inks: false, space: None, icc, rows: 0 }); } - Image::from_raw(w, h, layout, csample(fmt.sample), data)? - } else { if single.is_none() { let count = doc.layer_count(); warnings.push(format!("{count} layer(s) flattened; layers, masks and blend modes are not kept")); } - let cs = csample(fmt.sample); if fmt.mode == ColorMode::Cmyk && cmyk_target { // A CMYK target is written as CMYK with the document's own profile (#2220), which it // therefore needs; RGB-only targets keep the compositor's own handling (built-in @@ -435,130 +459,141 @@ fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target if icc.is_none() { icc = Some(profile.to_bytes().to_vec()); } - let (data, opaque) = cmyk_composite(doc, canvas, cs, n, warnings)?; - Image::from_raw(w, h, layout_for(ColorMode::Cmyk, !opaque), cs, data)? - } else { - let gray = fmt.mode == ColorMode::Grayscale; - if matches!(fmt.mode, ColorMode::Cmyk | ColorMode::Lab) { - // RGB-only targets use the sRGB composite directly, without separating CMYK; the - // compositor renders Lab documents in sRGB. Either way the file is tagged sRGB. - warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode)); - icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec()); + let inks = photocraft_compose::cmyk::renders_inks(doc); + let space = if inks { + None + } else { + // Through RGB and back: the colour model is kept, exact ink values are not. + warnings.push("CMYK composite converted back through the document's CMYK profile; ink values can differ from the layers'".to_string()); + photocraft_compose::cmyk_space(doc) + }; + return Ok(Self { doc, native: None, mode: ColorMode::Cmyk, sample, inks, space, icc, rows: 0 }); + } + if matches!(fmt.mode, ColorMode::Cmyk | ColorMode::Lab) { + // RGB-only targets use the sRGB composite directly, without separating CMYK; the + // compositor renders Lab documents in sRGB. Either way the file is tagged sRGB. + warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode)); + icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec()); + } + let mode = if fmt.mode == ColorMode::Grayscale { ColorMode::Grayscale } else { ColorMode::Rgb }; + Ok(Self { doc, native: None, mode, sample, inks: false, space: None, icc, rows: 0 }) + } + + fn layout(&self, alpha: bool) -> ChannelLayout { + layout_for(self.mode, alpha) + } + + /// The document's EXIF, XMP, text and resolution. + fn meta(&self) -> codecs::Metadata { + let doc = self.doc; + codecs::Metadata { + exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()), + xmp: doc.metadata.xmp.as_deref().map(str::to_owned), + text: doc.metadata.text.clone(), + dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), + ..Default::default() + } + } + + /// Every row of the document, top to bottom, as bands of interleaved native-endian samples + /// in [`Self::layout`]`(alpha)`: `sink(rows, samples, translucent)`, where `translucent` says + /// whether a composite band held a pixel that isn't fully opaque (its alpha is dropped all + /// the same when `alpha` is false). A single layer's band is never translucent without + /// `alpha`: drop it only when [`opaque_surface`] says so. + fn bands(&self, alpha: bool, mut sink: impl FnMut(u32, Vec, bool) -> Result<(), IoError>) -> Result<(), IoError> { + let doc = self.doc; + let (w, h) = (doc.size.width, doc.size.height); + let fmt = doc.pixel_format(); + if let Some(s) = self.native { + // Native path: keep model and depth. The surface's encoded samples are the codec's raw + // native-endian samples, copied a band of rows at a time (alpha dropped when opaque). + let ch = fmt.channels(); + let bps = fmt.sample.bytes(); + let keep = (ch - 1) * bps; + let rows = if self.rows > 0 { self.rows } else { (BAND_BYTES / (w as usize * ch * bps).max(1)).max(1) as i32 }; + let mut y = 0; + while y < h as i32 { + let y1 = y.saturating_add(rows).min(h as i32); + let mut band = s.to_interleaved(Rect::new(0, y, w as i32, y1)); + if !alpha { + compact_alpha(&mut band, keep, bps); + } + sink((y1 - y) as u32, band, alpha)?; + y = y1; } - let colors = if gray { 1 } else { 3 }; - // Rendered and quantised in bands (no full-size float composite), with alpha; the - // alpha is dropped afterwards, in place, when every pixel turned out opaque. - let mut data = try_buffer(n, (colors + 1) * cs.bytes())?; - let mut opaque = true; - let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { - opaque &= band.px.iter().all(|p| p[3] >= 1.0); + return Ok(()); + } + let cs = self.sample; + let put = move |out: &mut Vec, v: f32| { + let v = if v.is_nan() { 0.0 } else { v }; + match cs { + CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), + CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), + CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + } + }; + let colors = self.layout(false).channels(); + // Bands of quantised samples with alpha; the alpha is dropped here when not wanted. + let mut emit = |rows: u32, parts: Vec>, translucent: bool| -> Result<(), IoError> { + let mut data = Vec::with_capacity(parts.iter().map(Vec::len).sum()); + for part in parts { + data.extend_from_slice(&part); + } + if !alpha { + compact_alpha(&mut data, colors * cs.bytes(), cs.bytes()); + } + sink(rows, data, translucent) + }; + if self.inks { + // Mixed on ink values, rendered and quantised in bands. + let rendered = photocraft_compose::cmyk::render_cmyk_bands(doc, doc.bounds(), self.rows, |band| -> Result<(), IoError> { + let translucent = band.px.iter().any(|p| p[4] < 1.0 || p[4].is_nan()); let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); - let mut put = |v: f32| { - let v = if v.is_nan() { 0.0 } else { v }; - match cs { - CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), - CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), - CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + let mut out = Vec::with_capacity(range.len() * 5 * cs.bytes()); + for p in band.px.get(range).unwrap_or_default() { + for v in p { + put(&mut out, *v); } - }; - for p in &band.px[range] { - if gray { - put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); - } else { - put(p[0]); - put(p[1]); - put(p[2]); - } - put(p[3]); } out }); - for part in parts { - data.extend_from_slice(&part); - } - Ok(()) + emit(band.rect.height(), parts, translucent) }); - if opaque { - drop_alpha(&mut data, colors * cs.bytes(), cs.bytes()); - } - let layout = layout_for(if gray { ColorMode::Grayscale } else { ColorMode::Rgb }, !opaque); - Image::from_raw(w, h, layout, cs, data)? + return rendered.unwrap_or_else(|| Err(IoError::Unsupported("the CMYK compositor can't mix this document on its inks".into()))); } - }; - let meta = codecs::Metadata { - exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()), - xmp: doc.metadata.xmp.as_deref().map(str::to_owned), - text: doc.metadata.text.clone(), - dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), - ..Default::default() - }; - Ok(img.with_icc(icc).with_meta(meta)) -} - -/// A CMYK document's composite as interleaved CMYK + alpha samples (`cs`), with whether every -/// pixel came out opaque (the caller then drops the alpha). Stacks the native CMYK compositor -/// handles are mixed on their ink values, so opaque pixels keep their exact inks; any other -/// document is composited in RGB and converted back through its own CMYK profile, as PSD -/// export's merged image is. Rendered and quantised in bands on every core. -fn cmyk_composite(doc: &Document, canvas: Rect, cs: CSample, n: usize, warnings: &mut Vec) -> Result<(Vec, bool), IoError> { - let mut data = try_buffer(n, 5 * cs.bytes())?; - let mut opaque = true; - let put = |out: &mut Vec, v: f32| { - let v = if v.is_finite() { v } else { 0.0 }; - match cs { - CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), - CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), - CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), - } - }; - let native = photocraft_compose::cmyk::render_cmyk_bands(doc, canvas, 0, |band| -> Result<(), ()> { - opaque &= band.px.iter().all(|p| p[4] >= 1.0); - let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - let mut out = Vec::with_capacity(range.len() * 5 * cs.bytes()); - for p in band.px.get(range).unwrap_or_default() { - for v in p { - put(&mut out, *v); - } - } - out - }); - for part in parts { - data.extend_from_slice(&part); - } - Ok(()) - }); - if native.is_none() { - // Through RGB and back: the colour model is kept, exact ink values are not. - warnings.push("CMYK composite converted back through the document's CMYK profile; ink values can differ from the layers'".to_string()); - let space = photocraft_compose::cmyk_space(doc); - let to = PixelFormat::new(ColorMode::Cmyk, SampleType::F32, true); - let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { - opaque &= band.px.iter().all(|p| p[3] >= 1.0); + let cmyk = self.mode == ColorMode::Cmyk; + let gray = self.mode == ColorMode::Grayscale; + let space = &self.space; + // Rendered and quantised in bands (no full-size float composite), with alpha. + photocraft_compose::render_bands(doc, doc.bounds(), self.rows, |band| -> Result<(), IoError> { + let translucent = band.px.iter().any(|p| p[3] < 1.0 || p[3].is_nan()); let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - // The composite came through the document's CMYK profile: convert back through it. + // A CMYK composite came through the document's CMYK profile: convert back through it. photocraft_color::convert::with_cmyk_space(space.as_ref(), || { - let mut out = Vec::with_capacity(range.len() * 5 * cs.bytes()); - let mut v = [0.0f32; 5]; + let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); + let mut converted = [0.0; 5]; for p in band.px.get(range).unwrap_or_default() { - photocraft_raster::from_rgba_into(&to, *p, &mut v); - for x in v { - put(&mut out, x); + if cmyk { + let channels = photocraft_raster::from_rgba_into(&fmt, *p, &mut converted); + for value in converted.iter().take(channels) { + put(&mut out, *value); + } + continue; + } else if gray { + put(&mut out, photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); + } else { + put(&mut out, p[0]); + put(&mut out, p[1]); + put(&mut out, p[2]); } + put(&mut out, p[3]); } out }) }); - for part in parts { - data.extend_from_slice(&part); - } - Ok(()) - }); + emit(band.rect.height(), parts, translucent) + }) } - if opaque { - drop_alpha(&mut data, 4 * cs.bytes(), cs.bytes()); - } - Ok((data, opaque)) } /// An empty buffer with room for `pixels × bytes_per_pixel` bytes, or an error (not an abort) @@ -570,15 +605,21 @@ pub(crate) fn try_buffer(pixels: usize, bytes_per_pixel: usize) -> Result, color: usize, alpha: usize) { + compact_alpha(data, color, alpha); + data.shrink_to_fit(); +} + +/// [`drop_alpha`] keeping the allocation (a band about to be handed on). +fn compact_alpha(data: &mut Vec, color: usize, alpha: usize) { let stride = color + alpha; let n = data.len() / stride; for i in 0..n { data.copy_within(i * stride..i * stride + color, i * color); } data.truncate(n * color); - data.shrink_to_fit(); } /// Whether every pixel of `s` over `r` is fully opaque (read from the tiles, no copy). @@ -620,23 +661,47 @@ pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Resu /// [`export_flat`] reporting a coarse step through `ctl` once the composite is rendered; the /// encoder does the rest (#3389). pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { - use photocraft_cms::{Builtin, Intent}; if let Some(r) = export_mode_specific(doc, format, opts)? { return Ok(r); } + if format == Format::Png && streams_png(doc) { + return export_png_streamed(doc, opts, ctl, 0); + } let mut warnings = Vec::new(); let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk()); let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab()); let mut img = document_to_image_for(doc, &mut warnings, cmyk_target, lab_target)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(composite_share(format)); - if opts.xmp == XmpEmbed::None { - // Export As's Metadata: None: the packet lists the text of every type layer and one id - // per placed document (#647). - img.meta.xmp = None; - // Free-form descriptions can contain the same sensitive text as an XMP packet. - img.meta.text.clear(); + strip_xmp(&mut img.meta, opts); + let mut img = prepare_flat(img, format, &mut warnings)?; + if format == Format::Tga { + img = tga_image(doc, img, opts.tga_bits, &mut warnings)?; } + if format == Format::Dds { + img = dds_image(doc, img, &mut warnings)?; + } + if format == Format::Tiff { + warnings.extend(crate::tiff_layers::stamp_flat_channels(doc, &mut img)); + } + encode_image(&img, format, opts, warnings) +} + +/// Export As's Metadata: None drops the XMP packet, which lists the text of every type layer and +/// one id per placed document (#647), and the free-form descriptions, which can hold the same +/// sensitive text. +fn strip_xmp(meta: &mut codecs::Metadata, opts: &ExportOptions) { + if opts.xmp == XmpEmbed::None { + meta.xmp = None; + meta.text.clear(); + } +} + +/// `img` made storable as `format`: matted over white without alpha support, CMYK converted for a +/// format without CMYK, colours converted to what an untagged file means. Pixel by pixel, so a +/// band of rows gets what the whole image would (and the same warnings). +fn prepare_flat(mut img: Image, format: Format, warnings: &mut Vec) -> Result { + use photocraft_cms::{Builtin, Intent}; if img.layout().has_alpha() && !format.caps().alpha { // Flattened over white, as saving a transparent document without transparency does. img = matte_over_white(&img)?; @@ -666,16 +731,120 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti warnings.push(format!("colours converted to sGray; {format:?} can't embed the document's colour profile")); } } - if format == Format::Tga { - img = tga_image(doc, img, opts.tga_bits, &mut warnings)?; + Ok(img) +} + +/// Decoded pixel bytes past which a PNG export is written a band of rows at a time +/// ([`export_png_streamed`]) instead of from one image buffer (about 90 MP of 8-bit RGB). +const STREAM_PNG_BYTES: u64 = 256 << 20; + +/// Whether a PNG export of `doc` streams: its pixels would take more than [`STREAM_PNG_BYTES`]. +fn streams_png(doc: &Document) -> bool { + let fmt = doc.pixel_format(); + doc.size.area().saturating_mul((fmt.channels() * fmt.sample.bytes()) as u64) > STREAM_PNG_BYTES +} + +/// A PNG export rendered, converted and encoded a band of rows at a time, so no full-size image +/// is held: the only way a multi-gigapixel print (a 117504 × 22464 px banner is 7.9 GB of 8-bit +/// RGB) can be written as PNG (#8456). The pixels are the ones [`export_flat`] writes for a +/// smaller document, with these differences: Interlaced is turned off (Adam7 needs the whole +/// image), and a composite whose bottom layer isn't an opaque pixel layer keeps its alpha even +/// when every pixel turns out opaque (that is only known once the last row is written). +/// +/// `rows`: rows per band, 0 to size them to the document. +fn export_png_streamed(doc: &Document, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt, rows: i32) -> Result { + // A wrong guess (a knockout or a blend mode the guess doesn't foresee) shows in a band and + // the export starts again with alpha. + let opaque = match single_layer(doc) { + Some(s) => opaque_surface(s, doc.bounds()), + None => opaque_bottom(doc), + }; + // Progress never goes back: a second pass reports nothing new until it passes the first. + let mut progress = 0.0; + match stream_png(doc, opts, ctl, !opaque, rows, &mut progress)? { + Some(r) => Ok(r), + None => stream_png(doc, opts, ctl, true, rows, &mut progress)?.ok_or_else(|| IoError::Unsupported("PNG export lost its alpha channel".into())), } - if format == Format::Dds { - img = dds_image(doc, img, &mut warnings)?; +} + +/// Whether the bottom layer is a plain pixel layer opaque over the whole canvas (a Background +/// layer): the layers above it add coverage, so the composite is (most likely) opaque too. +fn opaque_bottom(doc: &Document) -> bool { + doc.layers.first().is_some_and(|l| plain_layer(l, doc.mode) && matches!(&l.content, LayerContent::Raster(s) if opaque_surface(s, doc.bounds()))) +} + +/// [`export_png_streamed`] with or without `alpha`, in bands of `rows` rows (0: sized to the +/// document). `None` when a translucent pixel shows up without `alpha`. `progress` is the most +/// reported so far, by this pass or an earlier one. +fn stream_png( + doc: &Document, + opts: &ExportOptions, + ctl: &photocraft_raster::Interrupt, + alpha: bool, + rows: i32, + progress: &mut f32, +) -> Result, IoError> { + let format = Format::Png; + let (w, h) = (doc.size.width, doc.size.height); + let mut warnings = Vec::new(); + let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk()); + let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab()); + let src = FlatSource { rows, ..FlatSource::new(doc, &mut warnings, cmyk_target, lab_target)? }; + let mut encode = opts.encode.clone(); + let mut late = Vec::new(); + if encode.png_interlaced { + encode.png_interlaced = false; + late.push(format!("written without interlacing: a {w} × {h} px PNG is written a band of rows at a time")); } - if format == Format::Tiff { - warnings.extend(crate::tiff_layers::stamp_flat_channels(doc, &mut img)); + // One blank row through the same steps as the bands gives the file's layout, profile and + // metadata (and their warnings) before anything is rendered. + let mut meta = src.meta(); + strip_xmp(&mut meta, opts); + let row = try_buffer(w as usize, src.layout(alpha).channels() * src.sample.bytes()).map(|mut v| { + v.resize(v.capacity(), 0); + v + })?; + let first = prepare_flat(Image::from_raw(w, 1, src.layout(alpha), src.sample, row)?.with_icc(src.icc.clone()).with_meta(meta), format, &mut warnings)?; + let mut fidelity: Vec = Vec::new(); + let mut note = |img: &Image| -> Result<(), IoError> { + for f in codecs::fidelity_warnings_with(img, format, &encode) { + if f.is_fatal() { + return Err(IoError::Unsupported(f.to_string())); + } + let f = f.to_string(); + if !fidelity.contains(&f) { + fidelity.push(f); + } + } + Ok(()) + }; + note(&first)?; + let mut bytes = Vec::new(); + let mut png = codecs::PngRows::new(&mut bytes, &first, h, &encode)?; + let (mut done, mut translucent) = (0u64, false); + let streamed = src.bands(alpha, |rows, data, see_through| { + if see_through && !alpha { + translucent = true; + return Err(IoError::Cancelled); + } + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut band_warnings = Vec::new(); + let band = prepare_flat(Image::from_raw(w, rows, src.layout(alpha), src.sample, data)?.with_icc(src.icc.clone()), format, &mut band_warnings)?; + note(&band)?; + png.push(&band)?; + done += u64::from(rows); + *progress = progress.max(done as f32 / h as f32); + ctl.progress(*progress); + Ok(()) + }); + if translucent { + return Ok(None); } - encode_image(&img, format, opts, warnings) + streamed?; + png.finish()?; + warnings.extend(fidelity); + warnings.extend(late); + Ok(Some(ExportResult { bytes, warnings })) } /// What Targa Options offers first, as Photoshop does: 32 bits per pixel when the document has an @@ -1065,3 +1234,154 @@ mod cmyk_band_tests { assert_eq!(first[3], 255); } } + +#[cfg(test)] +mod streamed_png_tests { + use super::*; + use photocraft_raster::Interrupt; + + fn layer(doc: &Document, fills: &[(Rect, &[f32])]) -> Layer { + let mut s = Surface::new(doc.pixel_format()); + for (r, px) in fills { + s.fill_rect(*r, px); + } + Layer::new("Layer", LayerContent::Raster(s)) + } + + fn decoded(bytes: &[u8]) -> Image { + codecs::decode(bytes).unwrap() + } + + /// The bytes ahead of the first IDAT: signature, header, profile and metadata chunks. + fn head(png: &[u8]) -> &[u8] { + &png[..png.windows(4).position(|w| w == b"IDAT").unwrap() - 4] + } + + /// Streamed in bands of `rows`, `doc` gives the pixels, warnings and header chunks of the + /// whole-image export. + fn same_as_whole(doc: &Document, rows: i32, opts: &ExportOptions) -> ExportResult { + let whole = export_flat(doc, Format::Png, opts).unwrap(); + let streamed = export_png_streamed(doc, opts, &Interrupt::NONE, rows).unwrap(); + let (a, b) = (decoded(&whole.bytes), decoded(&streamed.bytes)); + assert_eq!((b.dimensions(), b.layout(), b.sample_type()), (a.dimensions(), a.layout(), a.sample_type())); + assert!(a.data() == b.data(), "{}: pixels differ", doc.name); + assert_eq!(streamed.warnings, whole.warnings); + assert!(head(&streamed.bytes) == head(&whole.bytes), "{}: header chunks differ", doc.name); + streamed + } + + #[test] + fn a_composite_over_an_opaque_bottom_layer_streams_as_rgb() { + let mut doc = Document::new("composite", Size::new(300, 700), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = 300.0; + doc.metadata.xmp = Some("".into()); + doc.metadata.text = vec![("Title".into(), "Tent".into())]; + let bottom = layer(&doc, &[(doc.bounds(), &[0.9, 0.5, 0.1, 1.0]), (Rect::new(0, 300, 300, 420), &[0.1, 0.2, 0.8, 1.0])]); + let mut top = layer(&doc, &[(Rect::new(40, 100, 260, 650), &[0.2, 0.7, 0.3, 0.5])]); + top.opacity = 0.7; + top.blend = BlendMode::Multiply; + doc.layers = vec![bottom, top]; + assert!(opaque_bottom(&doc)); + for xmp in [XmpEmbed::All, XmpEmbed::None] { + let r = same_as_whole(&doc, 256, &ExportOptions { xmp, ..Default::default() }); + assert_eq!(decoded(&r.bytes).layout(), ChannelLayout::Rgb); + } + } + + #[test] + fn single_layers_stream_in_their_own_model_and_depth() { + // Transparent RGBA, opaque CMYK (written as sRGB through its profile), 16-bit gray and + // float RGB out of range (written with 16 bits, clipped, with the warning). + let mut rgba = Document::new("rgba", Size::new(130, 333), ColorMode::Rgb, SampleType::U8); + rgba.layers = vec![layer(&rgba, &[(Rect::new(10, 20, 120, 300), &[0.3, 0.6, 0.9, 0.4])])]; + let mut cmyk = Document::new("cmyk", Size::new(97, 211), ColorMode::Cmyk, SampleType::U8); + cmyk.icc_profile = Some(photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes()); + cmyk.layers = vec![layer(&cmyk, &[(cmyk.bounds(), &[0.3, 0.6, 0.1, 0.2, 1.0]), (Rect::new(0, 50, 97, 99), &[0.0, 0.9, 0.9, 0.0, 1.0])])]; + let mut gray = Document::new("gray", Size::new(64, 190), ColorMode::Grayscale, SampleType::U16); + gray.layers = vec![layer(&gray, &[(gray.bounds(), &[0.25, 1.0]), (Rect::new(0, 0, 64, 77), &[0.6123, 1.0])])]; + let mut hdr = Document::new("hdr", Size::new(50, 120), ColorMode::Rgb, SampleType::F32); + hdr.layers = vec![layer(&hdr, &[(hdr.bounds(), &[1.5, 0.25, -0.1, 1.0])])]; + for doc in [&rgba, &cmyk, &gray, &hdr] { + assert!(single_layer(doc).is_some(), "{}", doc.name); + same_as_whole(doc, 37, &ExportOptions::default()); + } + let r = same_as_whole(&hdr, 37, &ExportOptions::default()); + assert!(r.warnings.iter().any(|w| w.contains("clip")), "{:?}", r.warnings); + } + + /// Without an opaque bottom layer the composite keeps its alpha, even when every pixel is + /// opaque (the whole-image export finds that out and drops it). + #[test] + fn a_composite_without_an_opaque_bottom_layer_keeps_its_alpha() { + let mut doc = Document::new("masked", Size::new(80, 300), ColorMode::Rgb, SampleType::U8); + let mut bottom = layer(&doc, &[(doc.bounds(), &[0.2, 0.4, 0.6, 1.0])]); + bottom.mask = Some(photocraft_doc::LayerMask { + surface: Surface::with_default(PixelFormat::GRAY8, &[1.0]), + enabled: true, + linked: true, + density: 1.0, + feather: 0.0, + }); + doc.layers = vec![bottom, layer(&doc, &[(Rect::new(0, 0, 40, 40), &[1.0, 0.0, 0.0, 1.0])])]; + assert!(!opaque_bottom(&doc)); + let whole = decoded(&export_flat(&doc, Format::Png, &ExportOptions::default()).unwrap().bytes); + let streamed = decoded(&export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::NONE, 256).unwrap().bytes); + assert_eq!((whole.layout(), streamed.layout()), (ChannelLayout::Rgb, ChannelLayout::Rgba)); + assert!(whole.convert(ChannelLayout::Rgba, CSample::U8).data() == streamed.data()); + } + + /// A knockout through an opaque bottom layer makes the guess wrong: the export starts again + /// with alpha and keeps the hole, and its progress never goes back. + #[test] + fn a_wrong_opacity_guess_starts_again_with_alpha() { + // The hole is in the fourth band: three are written before the guess turns out wrong. + let mut doc = Document::new("knockout", Size::new(64, 1000), ColorMode::Rgb, SampleType::U8); + let bottom = layer(&doc, &[(doc.bounds(), &[0.2, 0.4, 0.6, 1.0])]); + let mut hole = layer(&doc, &[(Rect::new(8, 800, 56, 960), &[1.0, 1.0, 1.0, 1.0])]); + hole.fill_opacity = 0.0; + hole.advanced.knockout = photocraft_doc::Knockout::Deep; + doc.layers = vec![bottom, hole]; + assert!(opaque_bottom(&doc)); + assert!(stream_png(&doc, &ExportOptions::default(), &Interrupt::NONE, false, 256, &mut 0.0).unwrap().is_none()); + let r = same_as_whole(&doc, 256, &ExportOptions::default()); + assert_eq!(decoded(&r.bytes).layout(), ChannelLayout::Rgba); + + let seen = std::sync::Mutex::new(Vec::new()); + let report = |f: f32| seen.lock().unwrap().push(f); + let never = || false; + export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::new(&never, &report), 256).unwrap(); + let seen = seen.into_inner().unwrap(); + assert!(seen.windows(2).all(|w| w[0] <= w[1]), "progress went back: {seen:?}"); + assert_eq!(seen.last(), Some(&1.0)); + } + + #[test] + fn interlacing_is_turned_off_with_a_warning() { + let mut doc = Document::new("interlaced", Size::new(40, 30), ColorMode::Rgb, SampleType::U8); + doc.layers = vec![layer(&doc, &[(doc.bounds(), &[0.5, 0.5, 0.5, 1.0])])]; + let mut opts = ExportOptions::default(); + opts.encode.png_interlaced = true; + let r = export_png_streamed(&doc, &opts, &Interrupt::NONE, 7).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("without interlacing")), "{:?}", r.warnings); + let whole = decoded(&export_flat(&doc, Format::Png, &opts).unwrap().bytes); + assert!(decoded(&r.bytes).data() == whole.data()); + } + + #[test] + fn a_cancelled_stream_stops_with_cancelled() { + let mut doc = Document::new("cancel", Size::new(40, 30), ColorMode::Rgb, SampleType::U8); + doc.layers = vec![layer(&doc, &[(doc.bounds(), &[0.5, 0.5, 0.5, 1.0])])]; + let yes = || true; + let r = export_png_streamed(&doc, &ExportOptions::default(), &Interrupt::cancel_only(&yes), 7); + assert!(matches!(r, Err(IoError::Cancelled)), "{:?}", r.map(|_| ())); + } + + #[test] + fn only_large_documents_stream() { + let doc = |w, h, depth| Document::new("size", Size::new(w, h), ColorMode::Rgb, depth); + assert!(!streams_png(&doc(6000, 4000, SampleType::U8))); + assert!(streams_png(&doc(10_000, 8000, SampleType::U8))); + assert!(streams_png(&doc(8000, 6000, SampleType::U16))); + assert!(streams_png(&doc(117_504, 22_464, SampleType::U8))); + } +} diff --git a/crates/io/tests/large_png_export.rs b/crates/io/tests/large_png_export.rs new file mode 100644 index 0000000000..797efab049 --- /dev/null +++ b/crates/io/tests/large_png_export.rs @@ -0,0 +1,68 @@ +//! PNG export of a multi-gigapixel print (#8456): a 136 ft × 26 ft banner at 72 ppi is +//! 117504 × 22464 px (2.64 GP, 7.9 GB of 8-bit RGB), past the 2^30 px a whole-image export could +//! hold. It is written a band of rows at a time, its zlib stream split over IDAT chunks. + +use std::io::Read; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::{ExportOptions, export}; +use photocraft_raster::Surface; + +/// The IHDR fields and the IDAT payloads of `png`. +fn chunks(png: &[u8]) -> ((u32, u32, u8, u8), Vec<&[u8]>) { + let (mut at, mut ihdr, mut idat) = (8, None, Vec::new()); + while at + 12 <= png.len() { + let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize; + let data = &png[at + 8..at + 8 + len]; + match &png[at + 4..at + 8] { + b"IHDR" => ihdr = Some((u32::from_be_bytes(data[0..4].try_into().unwrap()), u32::from_be_bytes(data[4..8].try_into().unwrap()), data[8], data[9])), + b"IDAT" => idat.push(data), + _ => {} + } + at += 12 + len; + } + (ihdr.unwrap(), idat) +} + +/// Run with `cargo test --release -p photocraft-io --test large_png_export -- --ignored`; with +/// `COMPOSITE=1` a translucent layer on top makes the export composite the banner. +/// Measured 2026-10-22 (20 threads): 12.3 s and 0.49 GB peak RSS, composited 29 s and 1.2 GB; the +/// whole-image export took 12.9 s and 8.0 GB, composited 43 s and 10.3 GB. +#[test] +#[ignore = "encodes 7.9 GB of pixels; run in release"] +fn a_banner_over_two_gigapixels_exports_as_png() { + const W: u32 = 117_504; + const H: u32 = 22_464; + let mut doc = Document::new("Tent banner", Size::new(W, H), ColorMode::Rgb, SampleType::U8); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(doc.bounds(), &[1.0, 1.0, 1.0, 1.0]); + s.fill_rect(Rect::new(0, 15_000, W as i32, H as i32), &[0.1, 0.45, 0.8, 1.0]); + s.fill_rect(Rect::new(40_000, 2_000, 77_504, 14_000), &[0.55, 0.05, 0.08, 1.0]); + doc.layers.push(Layer::new("Background", LayerContent::Raster(s))); + if std::env::var_os("COMPOSITE").is_some() { + let mut t = Surface::new(doc.pixel_format()); + t.fill_rect(Rect::new(10_000, 5_000, 100_000, 20_000), &[0.9, 0.7, 0.1, 0.6]); + doc.layers.push(Layer::new("Text", LayerContent::Raster(t))); + } + + let started = std::time::Instant::now(); + let r = export(&doc, "banner.png", &ExportOptions::default()).unwrap(); + eprintln!("exported {} MB in {:.1} s; warnings {:?}", r.bytes.len() >> 20, started.elapsed().as_secs_f64(), r.warnings); + + let ((w, h, depth, color), idat) = chunks(&r.bytes); + assert_eq!((w, h, depth, color), (W, H, 8, 2), "8-bit RGB"); + // The zlib stream inflates (Adler-32 checked) to a filter byte and W RGB pixels per row. + let joined = idat.iter().flat_map(|c| c.iter().copied()).collect::>(); + let mut z = flate2::read::ZlibDecoder::new(joined.as_slice()); + let (mut buf, mut total) = (vec![0u8; 1 << 20], 0u64); + loop { + let n = z.read(&mut buf).unwrap(); + if n == 0 { + break; + } + total += n as u64; + } + assert_eq!(total, u64::from(H) * (1 + u64::from(W) * 3)); +}