mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 20:32:31 +00:00
Test PNG export conflict resolution on upstream main
This commit is contained in:
1 parent
5f9b439187
commit
f5dfe82a53
4 files changed
+957
-265
No files matched your search
+405
-101
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user