Test PNG export conflict resolution on upstream main

This commit is contained in:
Khawar Zamman Wani committed 2026-10-29 13:11:59 +05:30
1 parent 5f9b439187
commit f5dfe82a53
4 files changed
+957 -265

No files matched your search

+405 -101
View File
@@ -306,42 +306,73 @@ fn is_latin1_keyword(k: &str) -> bool {
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, 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<W: Write>(
out: W,
(w, h): (u32, u32),
(layout, sample): (ChannelLayout, SampleType),
icc: Option<&Vec<u8>>,
meta: &Metadata,
opts: &EncodeOptions,
) -> Result<png::Writer<W>, 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<Ve
Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter });
}
}
let mut out = Vec::new();
{
let mut encoder = png::Encoder::with_info(&mut 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 &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<W: Write>(writer: &mut png::Writer<W>, 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<W: Write> {
writer: png::Writer<W>,
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<u8>,
/// The last row deflated: the row above `pending` for the filters.
prev: Vec<u8>,
adler: u32,
/// Whether the zlib header is written.
started: bool,
}
impl<W: Write> PngRows<W> {
/// 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<Self, CodecError> {
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::<usize>() + 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<Vec<u8>> {
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::<usize>() + 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<u8>,
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<Vec<Deflated>> {
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<Deflated> {
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<u8>, u32, usize);
let mut parts: Vec<Band> = 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<Band> = 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::<usize>() + 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<u8>, Vec<u8>, 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<u8> {
(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<u8> {
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("<x:xmpmeta xmlns:x='adobe:ns:meta/'/>".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());
}
}
+1 -1
View File
@@ -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;
+483 -163
View File
@@ -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<String>) -> Result<I
/// `cmyk_target` / `lab_target`: the format stores CMYK / Lab pixels.
fn document_to_image_for(doc: &Document, warnings: &mut Vec<String>, cmyk_target: bool, lab_target: bool) -> Result<Image, IoError> {
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<Arc<photocraft_color::convert::CmykSpace>>,
icc: Option<Vec<u8>>,
/// 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<String>, cmyk_target: bool, lab_target: bool) -> Result<Self, IoError> {
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<String>, 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<u8>, 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<u8>, 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<Vec<u8>>, 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<String>) -> Result<(Vec<u8>, bool), IoError> {
let mut data = try_buffer(n, 5 * cs.bytes())?;
let mut opaque = true;
let put = |out: &mut Vec<u8>, 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<Vec<u8
Ok(v)
}
/// Remove the trailing `alpha` bytes of every `color + alpha`-byte pixel, in place.
/// Remove the trailing `alpha` bytes of every `color + alpha`-byte pixel, in place, and give the
/// freed memory back.
fn drop_alpha(data: &mut Vec<u8>, 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<u8>, 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<ExportResult, IoError> {
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<String>) -> Result<Image, IoError> {
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<ExportResult, IoError> {
// 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<Option<ExportResult>, 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<String> = 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("<x:xmpmeta xmlns:x='adobe:ns:meta/'/>".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)));
}
}
+68
View File
@@ -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::<Vec<u8>>();
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));
}