Skip to main content

blazediff_png/
encode.rs

1//! PNG encoder covering every color type / bit depth combination, with
2//! optional Adam7 interlacing and real deflate levels.
3//!
4//! The encoder is *lossless by construction*: the requested color mode must
5//! represent the RGBA8 input exactly (validated up front), so
6//! `decode(encode(img)) == img` holds for every accepted combination —
7//! that, plus spng cross-decoding, is the encode verification contract
8//! (byte-identical output to spng is explicitly not a goal; both emit
9//! valid-but-different streams).
10//!
11//! Single-threaded, SIMD-first: sample conversion and row filtering run
12//! sequentially over rows (each row reads only its own raw bytes and the row
13//! above), with the per-byte SAD / filter kernels vectorized (NEON on
14//! aarch64). Level-0 output is a sequential zlib stored-block stream
15//! (generalizing fast_png_io::encode_stored to any sample format).
16
17use std::io::{self, Write};
18
19use crate::container;
20use crate::error::PngError;
21use crate::interlace;
22use crate::meta::{Bkgd, Location, Metadata, Text, TextKind, Trns};
23use crate::{Image16, ImageRef};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ColorMode {
27    /// Smallest lossless mode for the image content.
28    Auto,
29    Gray1,
30    Gray2,
31    Gray4,
32    Gray8,
33    Gray16,
34    GrayAlpha8,
35    GrayAlpha16,
36    /// Palette with the given index depth (1/2/4/8); requires at most
37    /// 2^depth unique RGBA colors.
38    Indexed1,
39    Indexed2,
40    Indexed4,
41    Indexed8,
42    Rgb8,
43    Rgb16,
44    Rgba8,
45    Rgba16,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Filter {
50    None,
51    Sub,
52    Up,
53    Average,
54    Paeth,
55    /// Per-row minimum-sum-of-absolute-differences heuristic over all five
56    /// filters.
57    Adaptive,
58    /// Per-row minimum-sum-of-absolute-differences heuristic restricted to a
59    /// chosen subset of filters — spng's `SPNG_IMG_FILTER_CHOICE`. An empty
60    /// set is rejected at encode time.
61    Choice(FilterSet),
62}
63
64impl Filter {
65    /// The set of filters the adaptive heuristic may pick from; the fixed
66    /// variants resolve to a singleton (forced) set.
67    #[inline]
68    fn allowed(self) -> FilterSet {
69        match self {
70            Filter::None => FilterSet::NONE,
71            Filter::Sub => FilterSet::SUB,
72            Filter::Up => FilterSet::UP,
73            Filter::Average => FilterSet::AVERAGE,
74            Filter::Paeth => FilterSet::PAETH,
75            Filter::Adaptive => FilterSet::ALL,
76            Filter::Choice(set) => set,
77        }
78    }
79}
80
81/// Bitmask over the five PNG row filters the adaptive heuristic may choose
82/// from, mirroring spng's `SPNG_IMG_FILTER_CHOICE`. The encoder picks the
83/// lowest sum-of-absolute-differences filter *within the set* for each row; a
84/// singleton set forces that filter with no scan. Compose with `|`:
85///
86/// ```
87/// use blazediff_png::{Filter, FilterSet};
88/// let up_or_paeth = Filter::Choice(FilterSet::UP | FilterSet::PAETH);
89/// ```
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct FilterSet(u8);
92
93impl FilterSet {
94    pub const NONE: Self = Self(1 << 0);
95    pub const SUB: Self = Self(1 << 1);
96    pub const UP: Self = Self(1 << 2);
97    pub const AVERAGE: Self = Self(1 << 3);
98    pub const PAETH: Self = Self(1 << 4);
99    /// All five filters — equivalent to [`Filter::Adaptive`].
100    pub const ALL: Self = Self(0b1_1111);
101
102    /// Whether the given filter type (0=None..4=Paeth) is in the set.
103    #[inline]
104    pub const fn contains(self, filter_type: u8) -> bool {
105        self.0 & (1 << filter_type) != 0
106    }
107
108    /// The sole filter type if exactly one is set (the forced-filter fast
109    /// path), else `None`.
110    #[inline]
111    const fn single(self) -> Option<u8> {
112        if self.0.is_power_of_two() {
113            Some(self.0.trailing_zeros() as u8)
114        } else {
115            None
116        }
117    }
118}
119
120impl std::ops::BitOr for FilterSet {
121    type Output = Self;
122    #[inline]
123    fn bitor(self, rhs: Self) -> Self {
124        Self(self.0 | rhs.0)
125    }
126}
127
128/// PNG encode tuning. Mirrors the knobs spng exposes via `spng_set_option`
129/// that the libdeflate backend can honor:
130/// - `compression` ↔ `SPNG_IMG_COMPRESSION_LEVEL`,
131/// - `filter` ↔ `SPNG_IMG_FILTER_CHOICE` (via [`Filter::Choice`]).
132///
133/// spng's `SPNG_IMG_WINDOW_BITS`, `SPNG_IMG_MEM_LEVEL`, and
134/// `SPNG_IMG_COMPRESSION_STRATEGY` have **no libdeflate equivalent** —
135/// libdeflate always uses a full 32 KiB window and chooses its own internal
136/// memory/strategy per level — so they are intentionally not exposed rather
137/// than silently accepted and ignored.
138#[derive(Debug, Clone)]
139pub struct EncodeOptions {
140    pub color: ColorMode,
141    /// 0 = uncompressed stored blocks, 1..=12 = libdeflate level.
142    pub compression: u8,
143    pub filter: Filter,
144    pub interlace: bool,
145}
146
147impl Default for EncodeOptions {
148    fn default() -> Self {
149        Self {
150            color: ColorMode::Auto,
151            // Level 4 is the libdeflate speed/size knee for this codec: ~39%
152            // faster to encode than level 6 for only ~2% larger output (levels
153            // 5→6 cost ~30% more time to shave ~1.4% size). Callers wanting the
154            // last sliver of ratio can still request 6..=12 explicitly.
155            compression: 4,
156            filter: Filter::Adaptive,
157            interlace: false,
158        }
159    }
160}
161
162struct Mode {
163    color_type: u8,
164    bit_depth: u8,
165    /// Palette in first-seen order + per-entry alpha (indexed only).
166    palette: Option<(Vec<[u8; 3]>, Vec<u8>)>,
167    /// RGBA -> palette index, for lookup-based conversion (indexed only) —
168    /// used by the interlaced and explicit-palette paths where the row-major
169    /// `indices` buffer doesn't apply.
170    palette_index: Option<ColorMap>,
171    /// Per-pixel palette index in row-major order (indexed only), recorded by
172    /// the palette scan so the non-interlaced hot path skips the per-pixel map
173    /// lookup entirely. `None` for non-indexed modes and whenever an explicit
174    /// palette overrode the scanned first-seen order.
175    indices: Option<Vec<u8>>,
176}
177
178impl Mode {
179    fn new(color_type: u8, bit_depth: u8, palette: Option<(Vec<[u8; 3]>, Vec<u8>)>) -> Self {
180        let palette_index = palette
181            .as_ref()
182            .map(|(rgb, alpha)| ColorMap::from_palette(rgb, alpha));
183        Self {
184            color_type,
185            bit_depth,
186            palette,
187            palette_index,
188            indices: None,
189        }
190    }
191
192    /// Indexed mode from a palette scan: carries the scanned color map (for the
193    /// lookup path) and the row-major per-pixel indices (for the fast path).
194    fn indexed(bit_depth: u8, scan: Scan) -> Self {
195        let palette = palette_from_map(&scan.map);
196        Self {
197            color_type: 3,
198            bit_depth,
199            palette: Some(palette),
200            palette_index: Some(scan.map),
201            indices: Some(scan.indices),
202        }
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Fast RGBA -> palette-index map
208// ---------------------------------------------------------------------------
209
210#[derive(Clone, Copy, Default)]
211struct Slot {
212    key: u32,
213    index: u8,
214    occupied: bool,
215}
216
217/// Open-addressing RGBA -> palette-index map for the indexed encode path.
218/// Keyed on packed big-endian RGBA `u32`; the palette is at most 256 entries,
219/// so a 512-slot table keeps the load factor <= 0.5 and probe chains short.
220/// Pixel data is trusted, so a non-cryptographic multiply hash replaces std's
221/// SipHash — no DoS resistance is needed and the 4-byte key hashes in one op.
222struct ColorMap {
223    slots: Box<[Slot]>,
224    len: u16,
225}
226
227impl ColorMap {
228    const BITS: u32 = 9;
229    const SLOTS: usize = 1 << Self::BITS; // 512
230    const MASK: usize = Self::SLOTS - 1;
231
232    fn new() -> Self {
233        Self {
234            slots: vec![Slot::default(); Self::SLOTS].into_boxed_slice(),
235            len: 0,
236        }
237    }
238
239    /// Fibonacci hash: the top `BITS` bits of a multiply mix the whole key.
240    #[inline]
241    fn slot_for(key: u32) -> usize {
242        (key.wrapping_mul(0x9E37_79B1) >> (32 - Self::BITS)) as usize
243    }
244
245    /// Index for `key`, inserting it with the next first-seen index when
246    /// absent. Returns `None` once `max` distinct colors are already present
247    /// and `key` is new (the caller aborts the palette).
248    #[inline]
249    fn get_or_insert(&mut self, key: u32, max: usize) -> Option<u8> {
250        let mut s = Self::slot_for(key);
251        loop {
252            if !self.slots[s].occupied {
253                if self.len as usize >= max {
254                    return None;
255                }
256                let index = self.len as u8;
257                self.slots[s] = Slot {
258                    key,
259                    index,
260                    occupied: true,
261                };
262                self.len += 1;
263                return Some(index);
264            }
265            if self.slots[s].key == key {
266                return Some(self.slots[s].index);
267            }
268            s = (s + 1) & Self::MASK;
269        }
270    }
271
272    /// Build the map from an explicit palette, binding each color to its
273    /// palette position. Duplicate colors keep the last position (matching the
274    /// prior `HashMap::collect` semantics).
275    fn from_palette(rgb: &[[u8; 3]], alpha: &[u8]) -> Self {
276        let mut map = Self::new();
277        for (i, (c, &a)) in rgb.iter().zip(alpha).enumerate() {
278            let key = u32::from_be_bytes([c[0], c[1], c[2], a]);
279            let mut s = Self::slot_for(key);
280            loop {
281                if !map.slots[s].occupied {
282                    map.slots[s] = Slot {
283                        key,
284                        index: i as u8,
285                        occupied: true,
286                    };
287                    map.len += 1;
288                    break;
289                }
290                if map.slots[s].key == key {
291                    map.slots[s].index = i as u8;
292                    break;
293                }
294                s = (s + 1) & Self::MASK;
295            }
296        }
297        map
298    }
299
300    #[inline]
301    fn get(&self, key: u32) -> Option<u8> {
302        let mut s = Self::slot_for(key);
303        loop {
304            if !self.slots[s].occupied {
305                return None;
306            }
307            if self.slots[s].key == key {
308                return Some(self.slots[s].index);
309            }
310            s = (s + 1) & Self::MASK;
311        }
312    }
313}
314
315/// Result of a single palette scan over the RGBA pixels.
316struct Scan {
317    /// Whether every pixel is opaque (needed for the rgb/rgba fallback).
318    opaque: bool,
319    /// Whether the image stayed within the palette size limit.
320    palette_ok: bool,
321    /// First-seen color -> index map (valid only when `palette_ok`).
322    map: ColorMap,
323    /// Per-pixel index in row-major order (valid only when `palette_ok`).
324    indices: Vec<u8>,
325}
326
327/// Single pass over the RGBA pixels building the first-seen palette and each
328/// pixel's index, while tracking opacity for the fallback decision. The
329/// palette aborts the moment a `max`+1-th distinct color appears, but opacity
330/// keeps accumulating so the fallback mode needs no re-scan.
331fn scan_pixels(data: &[u8], max: usize) -> Scan {
332    let mut opaque = true;
333    let mut map = ColorMap::new();
334    let mut indices = Vec::new();
335    let mut palette_ok = true;
336    for p in data.chunks_exact(4) {
337        opaque &= p[3] == 255;
338        if palette_ok {
339            match map.get_or_insert(u32::from_be_bytes([p[0], p[1], p[2], p[3]]), max) {
340                Some(index) => indices.push(index),
341                None => {
342                    palette_ok = false;
343                    indices = Vec::new();
344                }
345            }
346        }
347    }
348    Scan {
349        opaque,
350        palette_ok,
351        map,
352        indices,
353    }
354}
355
356/// Reconstruct the first-seen palette (RGB + per-entry alpha) from a scan map.
357fn palette_from_map(map: &ColorMap) -> (Vec<[u8; 3]>, Vec<u8>) {
358    let n = map.len as usize;
359    let mut rgb = vec![[0u8; 3]; n];
360    let mut alpha = vec![255u8; n];
361    for slot in map.slots.iter().filter(|s| s.occupied) {
362        let b = slot.key.to_be_bytes();
363        rgb[slot.index as usize] = [b[0], b[1], b[2]];
364        alpha[slot.index as usize] = b[3];
365    }
366    (rgb, alpha)
367}
368
369/// Validate the backend-honored option fields shared by both encoders.
370/// (`FilterSet` is non-empty by construction — only singletons and unions are
371/// expressible — so the filter set needs no runtime check.)
372fn validate_options(options: &EncodeOptions) -> Result<(), PngError> {
373    if options.compression > 12 {
374        return Err(PngError::InvalidOptions("compression level must be 0..=12"));
375    }
376    Ok(())
377}
378
379pub fn encode(
380    image: ImageRef,
381    options: &EncodeOptions,
382    meta: &Metadata,
383) -> Result<Vec<u8>, PngError> {
384    validate_image(image)?;
385    validate_options(options)?;
386
387    if stored_rgba8_applies(options, meta) {
388        // The stored stream's size is analytic, so reserve once and let the
389        // direct writer fill the output Vec in a single pass.
390        let mut out = Vec::with_capacity(stored_rgba8_png_len(image));
391        if write_stored_rgba8(image, &mut out)? {
392            return Ok(out);
393        }
394    }
395    encode_core(image, options, meta, compress)
396}
397
398/// [`encode`] with band-parallel compression on up to `threads` workers. Level
399/// 0 has nothing to compress and takes the [`encode`] path unchanged.
400#[cfg(feature = "parallel")]
401pub fn encode_parallel(
402    image: ImageRef,
403    options: &EncodeOptions,
404    meta: &Metadata,
405    threads: std::num::NonZeroUsize,
406) -> Result<Vec<u8>, PngError> {
407    if options.compression == 0 {
408        return encode(image, options, meta);
409    }
410    validate_image(image)?;
411    validate_options(options)?;
412    encode_core(image, options, meta, |raw, level| {
413        crate::parallel::compress_banded(raw, level, threads)
414    })
415}
416
417/// Stream the encode into `out`. The stored RGBA8 hot path writes directly to
418/// the sink (peak memory ~= input); every other mode materializes the PNG and
419/// writes it through.
420pub fn encode_to<W: Write>(
421    image: ImageRef,
422    options: &EncodeOptions,
423    meta: &Metadata,
424    out: &mut W,
425) -> Result<(), PngError> {
426    validate_image(image)?;
427    validate_options(options)?;
428
429    if stored_rgba8_applies(options, meta) && write_stored_rgba8(image, out)? {
430        return Ok(());
431    }
432    let bytes = encode_core(image, options, meta, compress)?;
433    out.write_all(&bytes)?;
434    Ok(())
435}
436
437/// The generic encode: color-mode resolution, sample conversion, filtering,
438/// compression, and chunk assembly. The fallback for everything the direct
439/// stored path does not handle.
440fn encode_core(
441    image: ImageRef,
442    options: &EncodeOptions,
443    meta: &Metadata,
444    compress: impl FnOnce(&[u8], u8) -> Vec<u8>,
445) -> Result<Vec<u8>, PngError> {
446    let mode = resolve_mode(image, options.color)?;
447    let mode = apply_explicit_palette(image, mode, meta)?;
448    let raw = build_raw_stream(image, &mode, options)?;
449    let zlib = compress(&raw, options.compression);
450
451    Ok(assemble_png(
452        image.width,
453        image.height,
454        &mode,
455        options,
456        meta,
457        &zlib,
458    ))
459}
460
461fn validate_image(image: ImageRef) -> Result<(), PngError> {
462    if image.width == 0 || image.height == 0 {
463        return Err(PngError::InvalidOptions("image dimensions must be nonzero"));
464    }
465    // Checked: a wrapping product could spuriously equal `data.len()` and let an
466    // overflowing image through as "valid" (or panic in debug builds).
467    let expected = rgba_byte_len(image.width, image.height)
468        .ok_or(PngError::InvalidOptions("image dimensions overflow usize"))?;
469    if image.data.len() != expected {
470        return Err(PngError::InvalidOptions(
471            "data length != width * height * 4",
472        ));
473    }
474    Ok(())
475}
476
477/// `width * height * 4` (the RGBA8/RGBA16 sample-byte count), or `None` when the
478/// product overflows `usize`.
479#[inline]
480fn rgba_byte_len(width: u32, height: u32) -> Option<usize> {
481    (width as usize)
482        .checked_mul(height as usize)
483        .and_then(|n| n.checked_mul(4))
484}
485
486// ---------------------------------------------------------------------------
487// Direct stored RGBA8 fast path (the BlazeDiff diff-write hot path)
488// ---------------------------------------------------------------------------
489
490/// Whether the options + metadata select the direct stored RGBA8 writer:
491/// explicit `Rgba8`, no filtering, level 0 (stored), non-interlaced, and no
492/// ancillary chunks to interleave. `Auto` is excluded so it never triggers a
493/// second palette scan — callers wanting this path request `Rgba8` explicitly.
494#[inline]
495fn stored_rgba8_applies(options: &EncodeOptions, meta: &Metadata) -> bool {
496    options.color == ColorMode::Rgba8
497        && options.filter == Filter::None
498        && options.compression == 0
499        && !options.interlace
500        && *meta == Metadata::default()
501}
502
503/// Byte length of the stored zlib stream wrapping `raw_len` logical bytes:
504/// 2-byte zlib header + a 5-byte header per 0xffff block + payload + 4-byte
505/// adler32 trailer. Matches [`stored_zlib`]'s layout exactly.
506#[inline]
507fn stored_zlib_len(raw_len: usize) -> usize {
508    const BLOCK: usize = 0xffff;
509    let n_blocks = raw_len.div_ceil(BLOCK).max(1);
510    2 + n_blocks * 5 + raw_len + 4
511}
512
513/// Logical deflate-stream length for the stored RGBA8 encode: one filter byte
514/// plus `width * 4` pixel bytes per row. `None` on `usize` overflow.
515#[inline]
516fn stored_raw_len(image: ImageRef) -> Option<usize> {
517    let stride = (image.width as usize).checked_mul(4)?.checked_add(1)?;
518    (image.height as usize).checked_mul(stride)
519}
520
521/// Total PNG size of the direct stored RGBA8 encode (signature + IHDR + single
522/// IDAT + IEND), used to size the output `Vec` in one shot. A best-effort
523/// capacity hint, so overflow collapses to 0 and lets the `Vec` grow naturally;
524/// [`write_stored_rgba8`] is the path that actually rejects overflow.
525fn stored_rgba8_png_len(image: ImageRef) -> usize {
526    let Some(raw_len) = stored_raw_len(image) else {
527        return 0;
528    };
529    // sig(8) + IHDR(12+13) + IDAT(12 + zlib) + IEND(12)
530    8 + 25 + 12 + stored_zlib_len(raw_len) + 12
531}
532
533/// Write a complete stored RGBA8 PNG straight into `out` from the borrowed
534/// rows — no intermediate raw or zlib buffer. The logical deflate stream is
535/// `[0u8] ++ row` per row (filter byte + pixels); stored blocks re-chunk it to
536/// 0xffff bytes independent of row boundaries, with adler32 over the logical
537/// stream and the IDAT CRC framed incrementally. The output is byte-identical
538/// to the generic level-0 RGBA8 encoder.
539///
540/// Returns `Ok(false)` without writing when the IDAT payload would exceed the
541/// PNG 2 GB chunk limit, so the caller falls back to the generic (IDAT-
542/// splitting) path.
543fn write_stored_rgba8<W: Write>(image: ImageRef, out: &mut W) -> io::Result<bool> {
544    const BLOCK: usize = 0xffff;
545    let row_len = image.width as usize * 4;
546    let stride = 1 + row_len; // filter byte + pixel bytes
547    let Some(raw_len) = stored_raw_len(image) else {
548        return Ok(false); // dimensions overflow usize; fall back to the generic path
549    };
550    let zlib_len = stored_zlib_len(raw_len);
551    if zlib_len > i32::MAX as usize {
552        return Ok(false);
553    }
554
555    container::write_signature(out)?;
556    let mut ihdr = [0u8; 13];
557    ihdr[0..4].copy_from_slice(&image.width.to_be_bytes());
558    ihdr[4..8].copy_from_slice(&image.height.to_be_bytes());
559    ihdr[8] = 8; // bit depth
560    ihdr[9] = 6; // color type: RGBA (truecolor + alpha)
561                 // ihdr[10..13] (compression / filter / interlace methods) stay 0.
562    container::write_chunk(out, b"IHDR", &ihdr)?;
563
564    let mut idat = container::IdatStreamer::new(out, zlib_len as u32)?;
565    idat.write(&[0x78, 0x01])?; // zlib header
566
567    let mut adler = crate::backend::Adler::new();
568    let mut produced = 0usize; // logical bytes emitted so far
569    let mut row = 0usize; // current source row
570    let mut col = 0usize; // 0 => filter byte pending, else (col-1) into row bytes
571    while produced < raw_len {
572        let n = (raw_len - produced).min(BLOCK);
573        let final_block = produced + n == raw_len;
574        let mut hdr = [0u8; 5];
575        hdr[0] = final_block as u8;
576        hdr[1..3].copy_from_slice(&(n as u16).to_le_bytes());
577        hdr[3..5].copy_from_slice(&(!(n as u16)).to_le_bytes());
578        idat.write(&hdr)?;
579
580        let mut left = n;
581        while left > 0 {
582            if col == 0 {
583                idat.write(&[0u8])?; // filter byte
584                adler.write(&[0u8]);
585                col = 1;
586                left -= 1;
587            } else {
588                let in_row = col - 1;
589                let take = (row_len - in_row).min(left);
590                let base = row * row_len + in_row;
591                let seg = &image.data[base..base + take];
592                idat.write(seg)?;
593                adler.write(seg);
594                left -= take;
595                col += take;
596                if col == stride {
597                    col = 0;
598                    row += 1;
599                }
600            }
601        }
602        produced += n;
603    }
604
605    idat.write(&adler.finish().to_be_bytes())?;
606    idat.finish()?;
607    container::write_chunk(out, b"IEND", &[])?;
608    Ok(true)
609}
610
611/// Encode a true 16-bit RGBA image (host-order `u16` samples). Unlike
612/// [`encode`], the 16-bit color modes carry the full 16-bit precision instead
613/// of byte-replicating an 8-bit source; `decode_with(.., Rgba16)` round-trips
614/// exactly. Only the 16-bit modes (`Gray16`/`GrayAlpha16`/`Rgb16`/`Rgba16`)
615/// and `Auto` are valid — request [`encode`] for narrower output.
616pub fn encode16(
617    image: &Image16,
618    options: &EncodeOptions,
619    meta: &Metadata,
620) -> Result<Vec<u8>, PngError> {
621    if image.width == 0 || image.height == 0 {
622        return Err(PngError::InvalidOptions("image dimensions must be nonzero"));
623    }
624    let expected = rgba_byte_len(image.width, image.height)
625        .ok_or(PngError::InvalidOptions("image dimensions overflow usize"))?;
626    if image.data.len() != expected {
627        return Err(PngError::InvalidOptions(
628            "data length != width * height * 4",
629        ));
630    }
631    validate_options(options)?;
632
633    let mode = resolve_mode16(image, options.color)?;
634    let raw = build_raw_stream16(image, &mode, options);
635    let zlib = compress(&raw, options.compression);
636
637    Ok(assemble_png(
638        image.width,
639        image.height,
640        &mode,
641        options,
642        meta,
643        &zlib,
644    ))
645}
646
647/// Replace an indexed mode's auto-derived palette with the caller's explicit
648/// one (the `spng_set_plte` / `spng_set_trns` path), preserving entry order
649/// and any unused trailing entries. Validates that every pixel maps and that
650/// the palette fits the bit depth. A no-op for non-indexed modes or when no
651/// explicit palette is given.
652fn apply_explicit_palette(image: ImageRef, mode: Mode, meta: &Metadata) -> Result<Mode, PngError> {
653    let Some(palette) = &meta.palette else {
654        return Ok(mode);
655    };
656    if mode.color_type != 3 {
657        return Ok(mode);
658    }
659    let n = palette.entries.len();
660    if n == 0 || n > 256 || n > (1usize << mode.bit_depth) {
661        return Err(PngError::InvalidOptions(
662            "explicit palette size exceeds bit depth",
663        ));
664    }
665    let alpha: Vec<u8> = match &meta.transparency {
666        Some(Trns::Palette(a)) => (0..n).map(|i| a.get(i).copied().unwrap_or(255)).collect(),
667        _ => vec![255; n],
668    };
669    let new = Mode::new(3, mode.bit_depth, Some((palette.entries.clone(), alpha)));
670    let index = new.palette_index.as_ref().unwrap();
671    for p in image.data.chunks_exact(4) {
672        if index
673            .get(u32::from_be_bytes([p[0], p[1], p[2], p[3]]))
674            .is_none()
675        {
676            return Err(PngError::Unrepresentable(
677                "pixel color not present in explicit palette",
678            ));
679        }
680    }
681    Ok(new)
682}
683
684// ---------------------------------------------------------------------------
685// Mode resolution
686// ---------------------------------------------------------------------------
687
688/// Scaled-value representability per gray depth: 1 -> {0,255}, 2 -> k*85,
689/// 4 -> k*17 (left-bit-replication inverses).
690#[inline]
691fn gray_depth_ok(v: u8, depth: u8) -> bool {
692    match depth {
693        1 => v == 0 || v == 255,
694        2 => v.is_multiple_of(85),
695        4 => v.is_multiple_of(17),
696        _ => true,
697    }
698}
699
700fn resolve_mode(image: ImageRef, color: ColorMode) -> Result<Mode, PngError> {
701    use ColorMode::*;
702    let px = image.data.chunks_exact(4);
703
704    let mode = match color {
705        Auto => {
706            // Gray detection short-circuits at the first colored pixel, so the
707            // common (color) path pays only a cheap probe before the palette
708            // scan; gray-opaque images skip the palette build entirely.
709            let gray = image
710                .data
711                .chunks_exact(4)
712                .all(|p| p[0] == p[1] && p[1] == p[2]);
713            if gray && image.data.chunks_exact(4).all(|p| p[3] == 255) {
714                let depth = [1u8, 2, 4]
715                    .into_iter()
716                    .find(|&d| image.data.chunks_exact(4).all(|p| gray_depth_ok(p[0], d)))
717                    .unwrap_or(8);
718                return resolve_mode(
719                    image,
720                    match depth {
721                        1 => Gray1,
722                        2 => Gray2,
723                        4 => Gray4,
724                        _ => Gray8,
725                    },
726                );
727            }
728            // Color, or gray-with-alpha: prefer a palette (preferred over
729            // gray-alpha even when gray), then fall back by opacity.
730            let scan = scan_pixels(image.data, 256);
731            if scan.palette_ok {
732                let depth = match scan.map.len {
733                    0..=2 => 1,
734                    3..=4 => 2,
735                    5..=16 => 4,
736                    _ => 8,
737                };
738                return Ok(Mode::indexed(depth, scan));
739            }
740            if gray {
741                return resolve_mode(image, GrayAlpha8);
742            }
743            if scan.opaque {
744                return resolve_mode(image, Rgb8);
745            }
746            return resolve_mode(image, Rgba8);
747        }
748        Gray1 | Gray2 | Gray4 | Gray8 | Gray16 => {
749            let depth = match color {
750                Gray1 => 1,
751                Gray2 => 2,
752                Gray4 => 4,
753                Gray8 => 8,
754                _ => 16,
755            };
756            for p in px {
757                if p[0] != p[1] || p[1] != p[2] {
758                    return Err(PngError::Unrepresentable("grayscale requires r == g == b"));
759                }
760                if p[3] != 255 {
761                    return Err(PngError::Unrepresentable("grayscale requires opaque alpha"));
762                }
763                if !gray_depth_ok(p[0], depth) {
764                    return Err(PngError::Unrepresentable(
765                        "gray value not representable at this bit depth",
766                    ));
767                }
768            }
769            Mode::new(0, depth, None)
770        }
771        GrayAlpha8 | GrayAlpha16 => {
772            for p in px {
773                if p[0] != p[1] || p[1] != p[2] {
774                    return Err(PngError::Unrepresentable("grayscale requires r == g == b"));
775                }
776            }
777            Mode::new(4, if color == GrayAlpha8 { 8 } else { 16 }, None)
778        }
779        Indexed1 | Indexed2 | Indexed4 | Indexed8 => {
780            let depth = match color {
781                Indexed1 => 1,
782                Indexed2 => 2,
783                Indexed4 => 4,
784                _ => 8,
785            };
786            let scan = scan_pixels(image.data, 1 << depth);
787            if !scan.palette_ok {
788                return Err(PngError::Unrepresentable(
789                    "too many unique colors for the palette depth",
790                ));
791            }
792            Mode::indexed(depth, scan)
793        }
794        Rgb8 | Rgb16 => {
795            for p in px {
796                if p[3] != 255 {
797                    return Err(PngError::Unrepresentable("RGB requires opaque alpha"));
798                }
799            }
800            Mode::new(2, if color == Rgb8 { 8 } else { 16 }, None)
801        }
802        Rgba8 => Mode::new(6, 8, None),
803        Rgba16 => Mode::new(6, 16, None),
804    };
805    Ok(mode)
806}
807
808/// Mode resolution for a true 16-bit source. Only the 16-bit color types are
809/// representable (down-conversion to 8-bit / palette belongs to [`encode`]);
810/// `Auto` picks the smallest lossless one.
811fn resolve_mode16(image: &Image16, color: ColorMode) -> Result<Mode, PngError> {
812    use ColorMode::*;
813    let px = || image.data.chunks_exact(4);
814    let mode =
815        match color {
816            Auto => {
817                let opaque = px().all(|p| p[3] == 65535);
818                let gray = px().all(|p| p[0] == p[1] && p[1] == p[2]);
819                match (gray, opaque) {
820                    (true, true) => Mode::new(0, 16, None),
821                    (true, false) => Mode::new(4, 16, None),
822                    (false, true) => Mode::new(2, 16, None),
823                    (false, false) => Mode::new(6, 16, None),
824                }
825            }
826            Gray16 => {
827                for p in px() {
828                    if p[0] != p[1] || p[1] != p[2] {
829                        return Err(PngError::Unrepresentable("grayscale requires r == g == b"));
830                    }
831                    if p[3] != 65535 {
832                        return Err(PngError::Unrepresentable("grayscale requires opaque alpha"));
833                    }
834                }
835                Mode::new(0, 16, None)
836            }
837            GrayAlpha16 => {
838                for p in px() {
839                    if p[0] != p[1] || p[1] != p[2] {
840                        return Err(PngError::Unrepresentable("grayscale requires r == g == b"));
841                    }
842                }
843                Mode::new(4, 16, None)
844            }
845            Rgb16 => {
846                for p in px() {
847                    if p[3] != 65535 {
848                        return Err(PngError::Unrepresentable("RGB requires opaque alpha"));
849                    }
850                }
851                Mode::new(2, 16, None)
852            }
853            Rgba16 => Mode::new(6, 16, None),
854            _ => return Err(PngError::InvalidOptions(
855                "encode16 supports only 16-bit color modes (Gray16/GrayAlpha16/Rgb16/Rgba16/Auto)",
856            )),
857        };
858    Ok(mode)
859}
860
861// ---------------------------------------------------------------------------
862// Sample conversion + filtering
863// ---------------------------------------------------------------------------
864
865fn channels(color_type: u8) -> usize {
866    match color_type {
867        2 => 3,
868        4 => 2,
869        6 => 4,
870        _ => 1,
871    }
872}
873
874fn filter_bpp(color_type: u8, bit_depth: u8) -> usize {
875    if bit_depth < 8 {
876        1
877    } else {
878        channels(color_type) * (bit_depth as usize / 8)
879    }
880}
881
882fn row_bytes(color_type: u8, bit_depth: u8, width: usize) -> usize {
883    (channels(color_type) * bit_depth as usize * width).div_ceil(8)
884}
885
886/// Convert one scanline of RGBA pixels (gathered by the caller for
887/// interlaced passes) into raw samples of the target format. 8-bit values
888/// widen to 16 bits by byte replication so decoding truncates back exactly.
889fn convert_row(pixels: impl Iterator<Item = [u8; 4]>, mode: &Mode, out: &mut Vec<u8>) {
890    match (mode.color_type, mode.bit_depth) {
891        (0, 16) => {
892            for p in pixels {
893                out.extend_from_slice(&[p[0], p[0]]);
894            }
895        }
896        (0, 8) => out.extend(pixels.map(|p| p[0])),
897        (0, depth) => pack_bits(pixels.map(|p| p[0] >> (8 - depth)), depth, out),
898        (2, 8) => {
899            for p in pixels {
900                out.extend_from_slice(&p[..3]);
901            }
902        }
903        (2, _) => {
904            for p in pixels {
905                out.extend_from_slice(&[p[0], p[0], p[1], p[1], p[2], p[2]]);
906            }
907        }
908        (3, depth) => {
909            let index = mode
910                .palette_index
911                .as_ref()
912                .expect("indexed mode has palette");
913            let index_of = move |p: [u8; 4]| {
914                index
915                    .get(u32::from_be_bytes(p))
916                    .expect("pixel color in palette")
917            };
918            if depth == 8 {
919                out.extend(pixels.map(index_of));
920            } else {
921                pack_bits(pixels.map(index_of), depth, out);
922            }
923        }
924        (4, 8) => {
925            for p in pixels {
926                out.extend_from_slice(&[p[0], p[3]]);
927            }
928        }
929        (4, _) => {
930            for p in pixels {
931                out.extend_from_slice(&[p[0], p[0], p[3], p[3]]);
932            }
933        }
934        (6, 8) => {
935            for p in pixels {
936                out.extend_from_slice(&p);
937            }
938        }
939        _ => {
940            for p in pixels {
941                out.extend_from_slice(&[p[0], p[0], p[1], p[1], p[2], p[2], p[3], p[3]]);
942            }
943        }
944    }
945}
946
947/// Pack sub-byte samples MSB-first, padding the final byte with zeros.
948fn pack_bits(samples: impl Iterator<Item = u8>, depth: u8, out: &mut Vec<u8>) {
949    let mut acc = 0u8;
950    let mut filled = 0u8;
951    for s in samples {
952        acc |= s << (8 - depth - filled);
953        filled += depth;
954        if filled == 8 {
955            out.push(acc);
956            acc = 0;
957            filled = 0;
958        }
959    }
960    if filled > 0 {
961        out.push(acc);
962    }
963}
964
965/// Build the full filtered raw stream (all passes when interlaced).
966fn build_raw_stream(
967    image: ImageRef,
968    mode: &Mode,
969    options: &EncodeOptions,
970) -> Result<Vec<u8>, PngError> {
971    let width = image.width as usize;
972    let bpp = filter_bpp(mode.color_type, mode.bit_depth);
973
974    let passes: Vec<(usize, usize, usize)> = if options.interlace {
975        interlace::pass_dimensions(image.width, image.height)
976            .iter()
977            .enumerate()
978            .filter(|(_, &(w, h))| w > 0 && h > 0)
979            .map(|(i, &(w, h))| (i, w as usize, h as usize))
980            .collect()
981    } else {
982        vec![(usize::MAX, width, image.height as usize)]
983    };
984
985    let mut out = Vec::new();
986    for &(pass, pw, ph) in &passes {
987        let rb = row_bytes(mode.color_type, mode.bit_depth, pw);
988
989        // RGBA8 in == RGBA8 out: the samples are exactly `image.data`, so
990        // filter straight from it — no full-image raw copy at all.
991        if pass == usize::MAX && mode.color_type == 6 && mode.bit_depth == 8 {
992            filter_pass(&mut out, image.data, rb, ph, bpp, options);
993            continue;
994        }
995
996        // Raw (unfiltered) pass samples, appended row by row straight into the
997        // pass buffer — no per-row temp allocation or intermediate copy, and
998        // the non-interlaced hot path skips the `dyn` pixel iterator.
999        let mut raw = Vec::with_capacity(rb * ph);
1000        if pass == usize::MAX {
1001            if let Some(indices) = mode.indices.as_ref() {
1002                // Indexed, non-interlaced: the scan already computed each
1003                // pixel's index in row-major order, so emit it without any
1004                // per-pixel map lookup.
1005                if mode.bit_depth == 8 {
1006                    raw.extend_from_slice(indices);
1007                } else {
1008                    for i in 0..ph {
1009                        pack_bits(
1010                            indices[i * width..(i + 1) * width].iter().copied(),
1011                            mode.bit_depth,
1012                            &mut raw,
1013                        );
1014                    }
1015                }
1016            } else {
1017                for i in 0..ph {
1018                    let base = i * width * 4;
1019                    let pixels = image.data[base..base + width * 4]
1020                        .chunks_exact(4)
1021                        .map(|p| [p[0], p[1], p[2], p[3]]);
1022                    convert_row(pixels, mode, &mut raw);
1023                }
1024            }
1025        } else {
1026            let y_start = interlace::Y_START[pass] as usize;
1027            let y_delta = interlace::Y_DELTA[pass] as usize;
1028            let x0 = interlace::X_START[pass] as usize;
1029            let dx = interlace::X_DELTA[pass] as usize;
1030            for i in 0..ph {
1031                let y = y_start + i * y_delta;
1032                let pixels = (0..pw).map(move |k| {
1033                    let base = (y * width + x0 + k * dx) * 4;
1034                    let p = &image.data[base..base + 4];
1035                    [p[0], p[1], p[2], p[3]]
1036                });
1037                convert_row(pixels, mode, &mut raw);
1038            }
1039        }
1040        debug_assert_eq!(raw.len(), rb * ph);
1041
1042        filter_pass(&mut out, &raw, rb, ph, bpp, options);
1043    }
1044    Ok(out)
1045}
1046
1047/// Filter one pass's raw samples into the output stream (filter byte + payload
1048/// per row): row y depends only on raw rows y and y-1. Shared by the 8-bit
1049/// ([`build_raw_stream`]) and 16-bit ([`build_raw_stream16`]) encoders.
1050fn filter_pass(
1051    out: &mut Vec<u8>,
1052    raw: &[u8],
1053    rb: usize,
1054    ph: usize,
1055    bpp: usize,
1056    options: &EncodeOptions,
1057) {
1058    let set = options.filter.allowed();
1059    let base = out.len();
1060    // Each output byte (filter tag + every filtered payload byte) is written
1061    // below, so grow without the zero-fill `resize` would do.
1062    let added = (1 + rb) * ph;
1063    out.reserve(added);
1064    // SAFETY: u8 needs no init; the chunks_mut loop writes all `added` bytes
1065    // (dst[0] = filter, apply_filter fills dst[1..]) before anyone reads them.
1066    #[allow(clippy::uninit_vec)]
1067    unsafe {
1068        out.set_len(base + added)
1069    };
1070    out[base..]
1071        .chunks_mut(1 + rb)
1072        .enumerate()
1073        .for_each(|(y, dst)| {
1074            let row = &raw[y * rb..(y + 1) * rb];
1075            let prev = if y == 0 {
1076                &[][..]
1077            } else {
1078                &raw[(y - 1) * rb..y * rb]
1079            };
1080            let filter = best_filter(set, row, prev, bpp);
1081            dst[0] = filter;
1082            apply_filter(filter, row, prev, bpp, &mut dst[1..]);
1083        });
1084}
1085
1086/// Convert one scanline of RGBA16 pixels into big-endian samples of the
1087/// target 16-bit format (the only formats `resolve_mode16` produces).
1088fn convert_row16(pixels: impl Iterator<Item = [u16; 4]>, color_type: u8, out: &mut Vec<u8>) {
1089    match color_type {
1090        0 => {
1091            for p in pixels {
1092                out.extend_from_slice(&p[0].to_be_bytes());
1093            }
1094        }
1095        2 => {
1096            for p in pixels {
1097                out.extend_from_slice(&p[0].to_be_bytes());
1098                out.extend_from_slice(&p[1].to_be_bytes());
1099                out.extend_from_slice(&p[2].to_be_bytes());
1100            }
1101        }
1102        4 => {
1103            for p in pixels {
1104                out.extend_from_slice(&p[0].to_be_bytes());
1105                out.extend_from_slice(&p[3].to_be_bytes());
1106            }
1107        }
1108        _ => {
1109            for p in pixels {
1110                for c in p {
1111                    out.extend_from_slice(&c.to_be_bytes());
1112                }
1113            }
1114        }
1115    }
1116}
1117
1118/// 16-bit analogue of [`build_raw_stream`]: gather `u16` pixels (per pass when
1119/// interlaced), convert to big-endian samples, and filter via the shared
1120/// [`filter_pass`].
1121fn build_raw_stream16(image: &Image16, mode: &Mode, options: &EncodeOptions) -> Vec<u8> {
1122    let width = image.width as usize;
1123    let bpp = filter_bpp(mode.color_type, mode.bit_depth);
1124
1125    let passes: Vec<(usize, usize, usize)> = if options.interlace {
1126        interlace::pass_dimensions(image.width, image.height)
1127            .iter()
1128            .enumerate()
1129            .filter(|(_, &(w, h))| w > 0 && h > 0)
1130            .map(|(i, &(w, h))| (i, w as usize, h as usize))
1131            .collect()
1132    } else {
1133        vec![(usize::MAX, width, image.height as usize)]
1134    };
1135
1136    let mut out = Vec::new();
1137    for &(pass, pw, ph) in &passes {
1138        let rb = row_bytes(mode.color_type, mode.bit_depth, pw);
1139
1140        let mut raw = Vec::with_capacity(rb * ph);
1141        if pass == usize::MAX {
1142            for i in 0..ph {
1143                let base = i * width * 4;
1144                let pixels = image.data[base..base + width * 4]
1145                    .chunks_exact(4)
1146                    .map(|p| [p[0], p[1], p[2], p[3]]);
1147                convert_row16(pixels, mode.color_type, &mut raw);
1148            }
1149        } else {
1150            let y_start = interlace::Y_START[pass] as usize;
1151            let y_delta = interlace::Y_DELTA[pass] as usize;
1152            let x0 = interlace::X_START[pass] as usize;
1153            let dx = interlace::X_DELTA[pass] as usize;
1154            for i in 0..ph {
1155                let y = y_start + i * y_delta;
1156                let pixels = (0..pw).map(move |k| {
1157                    let base = (y * width + x0 + k * dx) * 4;
1158                    let p = &image.data[base..base + 4];
1159                    [p[0], p[1], p[2], p[3]]
1160                });
1161                convert_row16(pixels, mode.color_type, &mut raw);
1162            }
1163        }
1164        debug_assert_eq!(raw.len(), rb * ph);
1165
1166        filter_pass(&mut out, &raw, rb, ph, bpp, options);
1167    }
1168    out
1169}
1170
1171#[inline(always)]
1172fn paeth(a: u8, b: u8, c: u8) -> u8 {
1173    let (ia, ib, ic) = (a as i16, b as i16, c as i16);
1174    let pa = (ib - ic).abs();
1175    let pb = (ia - ic).abs();
1176    let pc = (ia + ib - 2 * ic).abs();
1177    if pa <= pb && pa <= pc {
1178        a
1179    } else if pb <= pc {
1180        b
1181    } else {
1182        c
1183    }
1184}
1185
1186#[inline]
1187fn filtered_byte(filter: u8, x: u8, a: u8, b: u8, c: u8) -> u8 {
1188    match filter {
1189        0 => x,
1190        1 => x.wrapping_sub(a),
1191        2 => x.wrapping_sub(b),
1192        3 => x.wrapping_sub(((a as u16 + b as u16) >> 1) as u8),
1193        _ => x.wrapping_sub(paeth(a, b, c)),
1194    }
1195}
1196
1197/// Minimum-sum-of-absolute-differences heuristic (libpng's): each filtered
1198/// byte contributes min(v, 256 - v); the filter with the smallest sum wins,
1199/// earliest on ties. Only filters in `set` are considered; a singleton set is
1200/// the forced-filter fast path (no scan).
1201///
1202/// Filtering is the bulk of a level-0 (stored) encode — ~74% — so the per-byte
1203/// SAD is the encoder hot path; on aarch64 it runs the NEON kernel in
1204/// [`neon::filter_sad`] (the signed-abs + widening reduction the autovectorizer
1205/// leaves on the table — measured ~1.3x; see `benches/simd_kernels.rs`), scalar
1206/// elsewhere. Both produce byte-identical filter choices.
1207fn best_filter(set: FilterSet, row: &[u8], prev: &[u8], bpp: usize) -> u8 {
1208    if let Some(only) = set.single() {
1209        return only;
1210    }
1211    let mut best = 0u8;
1212    let mut best_sum = u64::MAX;
1213    for filter in 0..=4u8 {
1214        if !set.contains(filter) {
1215            continue;
1216        }
1217        let sum = filter_sad(filter, row, prev, bpp, best_sum);
1218        if sum < best_sum {
1219            best_sum = sum;
1220            best = filter;
1221        }
1222    }
1223    best
1224}
1225
1226/// Sum of min(v, 256-v) over the `filter`-filtered row, aborting once the
1227/// running sum reaches `limit` (the current best — any larger sum can't win).
1228#[inline]
1229fn filter_sad(filter: u8, row: &[u8], prev: &[u8], bpp: usize, limit: u64) -> u64 {
1230    #[cfg(target_arch = "aarch64")]
1231    {
1232        neon::filter_sad(filter, row, prev, bpp, limit)
1233    }
1234    #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
1235    {
1236        wasm_simd::filter_sad(filter, row, prev, bpp, limit)
1237    }
1238    #[cfg(not(any(
1239        target_arch = "aarch64",
1240        all(target_arch = "wasm32", target_feature = "simd128")
1241    )))]
1242    {
1243        filter_sad_scalar(filter, row, prev, bpp, limit)
1244    }
1245}
1246
1247/// Scalar SAD reference — the parity twin the NEON kernel is checked against
1248/// (see the `neon_matches_scalar` test) and the path on non-aarch64 targets.
1249fn filter_sad_scalar(filter: u8, row: &[u8], prev: &[u8], bpp: usize, limit: u64) -> u64 {
1250    let mut sum = 0u64;
1251    for i in 0..row.len() {
1252        let a = if i >= bpp { row[i - bpp] } else { 0 };
1253        let b = prev.get(i).copied().unwrap_or(0);
1254        let c = if i >= bpp {
1255            prev.get(i - bpp).copied().unwrap_or(0)
1256        } else {
1257            0
1258        };
1259        let v = filtered_byte(filter, row[i], a, b, c);
1260        sum += (v as i8).unsigned_abs() as u64;
1261        if sum >= limit {
1262            break;
1263        }
1264    }
1265    sum
1266}
1267
1268fn apply_filter(filter: u8, row: &[u8], prev: &[u8], bpp: usize, out: &mut [u8]) {
1269    // None — and Up on the first row — are plain copies; everything else is the
1270    // per-byte filter, NEON-accelerated on aarch64 (same kernel as the SAD).
1271    if filter == 0 || (filter == 2 && prev.is_empty()) {
1272        out.copy_from_slice(row);
1273        return;
1274    }
1275    #[cfg(target_arch = "aarch64")]
1276    {
1277        neon::apply_filter(filter, row, prev, bpp, out);
1278    }
1279    #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
1280    {
1281        wasm_simd::apply_filter(filter, row, prev, bpp, out);
1282    }
1283    #[cfg(not(any(
1284        target_arch = "aarch64",
1285        all(target_arch = "wasm32", target_feature = "simd128")
1286    )))]
1287    apply_filter_scalar(filter, row, prev, bpp, out);
1288}
1289
1290/// Scalar apply reference — parity twin for the NEON kernel (also the NEON
1291/// head pass) and the non-aarch64 path.
1292fn apply_filter_scalar(filter: u8, row: &[u8], prev: &[u8], bpp: usize, out: &mut [u8]) {
1293    for i in 0..row.len() {
1294        let a = if i >= bpp { row[i - bpp] } else { 0 };
1295        let b = prev.get(i).copied().unwrap_or(0);
1296        let c = if i >= bpp {
1297            prev.get(i - bpp).copied().unwrap_or(0)
1298        } else {
1299            0
1300        };
1301        out[i] = filtered_byte(filter, row[i], a, b, c);
1302    }
1303}
1304
1305/// NEON encode-filter kernels (aarch64). Encode filtering reads only *raw*
1306/// neighbor bytes (`a = row[i-bpp]`, `b = prev[i]`, `c = prev[i-bpp]`) with no
1307/// per-pixel recurrence, so the whole row is data-parallel. Every kernel has
1308/// the scalar twin above as its parity reference; `neon_matches_scalar`
1309/// fuzz-checks equality over random rows and all `bpp`, and the encoder's
1310/// spng cross-decode round-trip is the end-to-end tripwire.
1311#[cfg(target_arch = "aarch64")]
1312mod neon {
1313    use core::arch::aarch64::*;
1314
1315    /// Filtered-byte vector for a 16-lane chunk: `x - predictor(filter)`.
1316    /// `a/b/c` are the raw neighbor vectors. SAFETY: NEON is aarch64 baseline.
1317    #[inline(always)]
1318    unsafe fn filt_chunk(
1319        filter: u8,
1320        x: uint8x16_t,
1321        a: uint8x16_t,
1322        b: uint8x16_t,
1323        c: uint8x16_t,
1324    ) -> uint8x16_t {
1325        match filter {
1326            // None: identity. `best_filter` scans filter 0 too, so the kernel
1327            // must return the raw byte here rather than falling through to
1328            // Paeth (which would compute the wrong SAD for the None row).
1329            0 => x,
1330            1 => vsubq_u8(x, a),
1331            2 => vsubq_u8(x, b),
1332            // Average: floor((a+b)/2) is exactly the unsigned halving add.
1333            3 => vsubq_u8(x, vhaddq_u8(a, b)),
1334            _ => vsubq_u8(x, paeth_vec(a, b, c)),
1335        }
1336    }
1337
1338    /// Paeth predictor over 16 lanes, computed in widened s16 (low/high halves)
1339    /// to match the scalar predictor exactly, including its tie-breaking.
1340    #[inline(always)]
1341    unsafe fn paeth_vec(a: uint8x16_t, b: uint8x16_t, c: uint8x16_t) -> uint8x16_t {
1342        let lo = paeth_half(vget_low_u8(a), vget_low_u8(b), vget_low_u8(c));
1343        let hi = paeth_half(vget_high_u8(a), vget_high_u8(b), vget_high_u8(c));
1344        vcombine_u8(lo, hi)
1345    }
1346
1347    #[inline(always)]
1348    unsafe fn paeth_half(a8: uint8x8_t, b8: uint8x8_t, c8: uint8x8_t) -> uint8x8_t {
1349        let a = vreinterpretq_s16_u16(vmovl_u8(a8));
1350        let b = vreinterpretq_s16_u16(vmovl_u8(b8));
1351        let c = vreinterpretq_s16_u16(vmovl_u8(c8));
1352        let pa = vabsq_s16(vsubq_s16(b, c));
1353        let pb = vabsq_s16(vsubq_s16(a, c));
1354        let pc = vabsq_s16(vsubq_s16(vaddq_s16(a, b), vaddq_s16(c, c)));
1355        // pred = (pa<=pb && pa<=pc) ? a : (pb<=pc ? b : c) — matches scalar.
1356        let use_a = vandq_u16(vcleq_s16(pa, pb), vcleq_s16(pa, pc));
1357        let use_b = vcleq_s16(pb, pc);
1358        let pred = vbslq_s16(use_a, a, vbslq_s16(use_b, b, c));
1359        vmovn_u16(vreinterpretq_u16_s16(pred))
1360    }
1361
1362    /// Load the four raw neighbor vectors for the chunk at `i` (`i >= bpp`).
1363    /// `prev` empty (first row) zeroes `b`/`c`.
1364    #[inline(always)]
1365    unsafe fn neighbors(
1366        row: &[u8],
1367        prev: &[u8],
1368        bpp: usize,
1369        i: usize,
1370    ) -> (uint8x16_t, uint8x16_t, uint8x16_t, uint8x16_t) {
1371        let x = vld1q_u8(row.as_ptr().add(i));
1372        let a = vld1q_u8(row.as_ptr().add(i - bpp));
1373        let (b, c) = if prev.is_empty() {
1374            (vdupq_n_u8(0), vdupq_n_u8(0))
1375        } else {
1376            (
1377                vld1q_u8(prev.as_ptr().add(i)),
1378                vld1q_u8(prev.as_ptr().add(i - bpp)),
1379            )
1380        };
1381        (x, a, b, c)
1382    }
1383
1384    pub fn filter_sad(filter: u8, row: &[u8], prev: &[u8], bpp: usize, limit: u64) -> u64 {
1385        let n = row.len();
1386        // Head (i < bpp): a = c = 0; fall back to the scalar reference.
1387        let head = bpp.min(n);
1388        let mut sum =
1389            super::filter_sad_scalar(filter, &row[..head], prev_head(prev, head), bpp, limit);
1390        if sum >= limit {
1391            return sum;
1392        }
1393        // SAFETY: every load below stays in-bounds — i >= bpp and i+16 <= n.
1394        unsafe {
1395            let mut acc = vdupq_n_u32(0);
1396            let mut i = head;
1397            let mut since_drain = 0u32;
1398            while i + 16 <= n {
1399                let (x, a, b, c) = neighbors(row, prev, bpp, i);
1400                let v = filt_chunk(filter, x, a, b, c);
1401                // signed-abs of the wrapping byte, widened-accumulated.
1402                let abs = vreinterpretq_u8_s8(vabsq_s8(vreinterpretq_s8_u8(v)));
1403                acc = vpadalq_u16(acc, vpaddlq_u8(abs));
1404                i += 16;
1405                // Drain to the u64 sum every 4096 chunks (64 KiB): keeps the
1406                // u32 lanes from overflowing on huge rows and doubles as a
1407                // coarse early-out, while the per-chunk loop stays branch-light.
1408                since_drain += 1;
1409                if since_drain == 4096 {
1410                    sum += vaddvq_u32(acc) as u64;
1411                    acc = vdupq_n_u32(0);
1412                    since_drain = 0;
1413                    if sum >= limit {
1414                        return sum;
1415                    }
1416                }
1417            }
1418            sum += vaddvq_u32(acc) as u64;
1419            // Tail.
1420            for j in i..n {
1421                let a = row[j - bpp];
1422                let b = if prev.is_empty() { 0 } else { prev[j] };
1423                let c = if prev.is_empty() { 0 } else { prev[j - bpp] };
1424                let fv = super::filtered_byte(filter, row[j], a, b, c);
1425                sum += (fv as i8).unsigned_abs() as u64;
1426                if sum >= limit {
1427                    break;
1428                }
1429            }
1430        }
1431        sum
1432    }
1433
1434    pub fn apply_filter(filter: u8, row: &[u8], prev: &[u8], bpp: usize, out: &mut [u8]) {
1435        let n = row.len();
1436        let head = bpp.min(n);
1437        super::apply_filter_scalar(
1438            filter,
1439            &row[..head],
1440            prev_head(prev, head),
1441            bpp,
1442            &mut out[..head],
1443        );
1444        // SAFETY: loads/stores stay in-bounds — i >= bpp and i+16 <= n.
1445        unsafe {
1446            let mut i = head;
1447            while i + 16 <= n {
1448                let (x, a, b, c) = neighbors(row, prev, bpp, i);
1449                vst1q_u8(out.as_mut_ptr().add(i), filt_chunk(filter, x, a, b, c));
1450                i += 16;
1451            }
1452            for j in i..n {
1453                let a = row[j - bpp];
1454                let b = if prev.is_empty() { 0 } else { prev[j] };
1455                let c = if prev.is_empty() { 0 } else { prev[j - bpp] };
1456                out[j] = super::filtered_byte(filter, row[j], a, b, c);
1457            }
1458        }
1459    }
1460
1461    /// The first `head` bytes of `prev` for the scalar head pass — empty `prev`
1462    /// (first row) stays empty so the scalar twin sees `b = 0`.
1463    #[inline]
1464    fn prev_head(prev: &[u8], head: usize) -> &[u8] {
1465        if prev.is_empty() {
1466            prev
1467        } else {
1468            &prev[..head]
1469        }
1470    }
1471}
1472
1473/// wasm32 `simd128` encode-filter kernels — the structural twin of [`neon`] for
1474/// the wasm build, where the scalar SAD/apply is the encode hot path and LLVM's
1475/// autovectorizer leaves the signed-abs widening reduction on the table. Same
1476/// data-parallel reads (`a = row[i-bpp]`, `b = prev[i]`, `c = prev[i-bpp]`),
1477/// same scalar head/tail handling, byte-identical filter choices. Verified
1478/// against the scalar build by encoding every fixture with both at a fixed
1479/// level and the same deflate backend: identical filter picks ⇒ identical bytes.
1480#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
1481mod wasm_simd {
1482    use core::arch::wasm32::*;
1483
1484    /// Filtered-byte vector for a 16-lane chunk: `x - predictor(filter)`.
1485    #[inline(always)]
1486    fn filt_chunk(filter: u8, x: v128, a: v128, b: v128, c: v128) -> v128 {
1487        match filter {
1488            // None: identity. `best_filter` scans filter 0 too, so the kernel
1489            // must return the raw byte here rather than falling through to
1490            // Paeth (which would compute the wrong SAD for the None row).
1491            0 => x,
1492            1 => i8x16_sub(x, a),
1493            2 => i8x16_sub(x, b),
1494            // Average: floor((a+b)/2) = (a & b) + ((a ^ b) >> 1) — no widening,
1495            // and unlike `u8x16_avgr` (rounds up) it matches the PNG predictor.
1496            3 => i8x16_sub(x, i8x16_add(v128_and(a, b), u8x16_shr(v128_xor(a, b), 1))),
1497            _ => i8x16_sub(x, paeth_vec(a, b, c)),
1498        }
1499    }
1500
1501    /// Paeth predictor over 16 lanes, computed in widened i16 (low/high halves)
1502    /// to match the scalar predictor exactly, including its tie-breaking. The
1503    /// result is `{a, b, c}` per lane (all in `0..=255`), so the final
1504    /// unsigned-saturating narrow never clamps.
1505    #[inline(always)]
1506    fn paeth_vec(a: v128, b: v128, c: v128) -> v128 {
1507        let lo = paeth_half(
1508            i16x8_extend_low_u8x16(a),
1509            i16x8_extend_low_u8x16(b),
1510            i16x8_extend_low_u8x16(c),
1511        );
1512        let hi = paeth_half(
1513            i16x8_extend_high_u8x16(a),
1514            i16x8_extend_high_u8x16(b),
1515            i16x8_extend_high_u8x16(c),
1516        );
1517        u8x16_narrow_i16x8(lo, hi)
1518    }
1519
1520    #[inline(always)]
1521    fn paeth_half(a: v128, b: v128, c: v128) -> v128 {
1522        let pa = i16x8_abs(i16x8_sub(b, c));
1523        let pb = i16x8_abs(i16x8_sub(a, c));
1524        let pc = i16x8_abs(i16x8_sub(i16x8_add(a, b), i16x8_add(c, c)));
1525        // pred = (pa<=pb && pa<=pc) ? a : (pb<=pc ? b : c) — matches scalar.
1526        let use_a = v128_and(i16x8_le(pa, pb), i16x8_le(pa, pc));
1527        let use_b = i16x8_le(pb, pc);
1528        v128_bitselect(a, v128_bitselect(b, c, use_b), use_a)
1529    }
1530
1531    /// Load the four raw neighbor vectors for the chunk at `i` (`i >= bpp`).
1532    /// `prev` empty (first row) zeroes `b`/`c`. SAFETY: caller guarantees
1533    /// `i >= bpp` and `i + 16 <= row.len()` (and `<= prev.len()` when non-empty).
1534    #[inline(always)]
1535    unsafe fn neighbors(row: &[u8], prev: &[u8], bpp: usize, i: usize) -> (v128, v128, v128, v128) {
1536        let x = v128_load(row.as_ptr().add(i) as *const v128);
1537        let a = v128_load(row.as_ptr().add(i - bpp) as *const v128);
1538        let (b, c) = if prev.is_empty() {
1539            (u8x16_splat(0), u8x16_splat(0))
1540        } else {
1541            (
1542                v128_load(prev.as_ptr().add(i) as *const v128),
1543                v128_load(prev.as_ptr().add(i - bpp) as *const v128),
1544            )
1545        };
1546        (x, a, b, c)
1547    }
1548
1549    /// Horizontal sum of the four u32 lanes.
1550    #[inline(always)]
1551    fn hsum_u32x4(acc: v128) -> u64 {
1552        u32x4_extract_lane::<0>(acc) as u64
1553            + u32x4_extract_lane::<1>(acc) as u64
1554            + u32x4_extract_lane::<2>(acc) as u64
1555            + u32x4_extract_lane::<3>(acc) as u64
1556    }
1557
1558    pub fn filter_sad(filter: u8, row: &[u8], prev: &[u8], bpp: usize, limit: u64) -> u64 {
1559        let n = row.len();
1560        // Head (i < bpp): a = c = 0; fall back to the scalar reference.
1561        let head = bpp.min(n);
1562        let mut sum =
1563            super::filter_sad_scalar(filter, &row[..head], prev_head(prev, head), bpp, limit);
1564        if sum >= limit {
1565            return sum;
1566        }
1567        // SAFETY: every load below stays in-bounds — i >= bpp and i + 16 <= n.
1568        unsafe {
1569            let mut acc = u8x16_splat(0); // u32x4 accumulator, zeroed
1570            let mut i = head;
1571            let mut since_drain = 0u32;
1572            while i + 16 <= n {
1573                let (x, a, b, c) = neighbors(row, prev, bpp, i);
1574                let v = filt_chunk(filter, x, a, b, c);
1575                // signed-abs of the wrapping byte == min(v, 256-v); abs(-128)
1576                // stays 0x80 == 128, matching the scalar metric.
1577                let abs = i8x16_abs(v);
1578                // Widen unsigned to i16 (lanes <= 128) and fold the two halves;
1579                // each summed lane <= 256, well within i16, so the signed
1580                // pairwise widen into u32 is exact.
1581                let sum16 = i16x8_add(i16x8_extend_low_u8x16(abs), i16x8_extend_high_u8x16(abs));
1582                acc = i32x4_add(acc, i32x4_extadd_pairwise_i16x8(sum16));
1583                i += 16;
1584                // Drain to the u64 sum every 4096 chunks: keeps the u32 lanes
1585                // from overflowing on huge rows and doubles as a coarse
1586                // early-out, mirroring the NEON kernel.
1587                since_drain += 1;
1588                if since_drain == 4096 {
1589                    sum += hsum_u32x4(acc);
1590                    acc = u8x16_splat(0);
1591                    since_drain = 0;
1592                    if sum >= limit {
1593                        return sum;
1594                    }
1595                }
1596            }
1597            sum += hsum_u32x4(acc);
1598            // Tail.
1599            for j in i..n {
1600                let a = row[j - bpp];
1601                let b = if prev.is_empty() { 0 } else { prev[j] };
1602                let c = if prev.is_empty() { 0 } else { prev[j - bpp] };
1603                let fv = super::filtered_byte(filter, row[j], a, b, c);
1604                sum += (fv as i8).unsigned_abs() as u64;
1605                if sum >= limit {
1606                    break;
1607                }
1608            }
1609        }
1610        sum
1611    }
1612
1613    pub fn apply_filter(filter: u8, row: &[u8], prev: &[u8], bpp: usize, out: &mut [u8]) {
1614        let n = row.len();
1615        let head = bpp.min(n);
1616        super::apply_filter_scalar(
1617            filter,
1618            &row[..head],
1619            prev_head(prev, head),
1620            bpp,
1621            &mut out[..head],
1622        );
1623        // SAFETY: loads/stores stay in-bounds — i >= bpp and i + 16 <= n.
1624        unsafe {
1625            let mut i = head;
1626            while i + 16 <= n {
1627                let (x, a, b, c) = neighbors(row, prev, bpp, i);
1628                v128_store(
1629                    out.as_mut_ptr().add(i) as *mut v128,
1630                    filt_chunk(filter, x, a, b, c),
1631                );
1632                i += 16;
1633            }
1634            for j in i..n {
1635                let a = row[j - bpp];
1636                let b = if prev.is_empty() { 0 } else { prev[j] };
1637                let c = if prev.is_empty() { 0 } else { prev[j - bpp] };
1638                out[j] = super::filtered_byte(filter, row[j], a, b, c);
1639            }
1640        }
1641    }
1642
1643    /// The first `head` bytes of `prev` for the scalar head pass — empty `prev`
1644    /// (first row) stays empty so the scalar twin sees `b = 0`.
1645    #[inline]
1646    fn prev_head(prev: &[u8], head: usize) -> &[u8] {
1647        if prev.is_empty() {
1648            prev
1649        } else {
1650            &prev[..head]
1651        }
1652    }
1653}
1654
1655// ---------------------------------------------------------------------------
1656// Compression
1657// ---------------------------------------------------------------------------
1658
1659fn compress(raw: &[u8], level: u8) -> Vec<u8> {
1660    if level == 0 {
1661        // Stored (uncompressed) zlib is pure Rust and shared by every
1662        // backend; only real deflate levels go through the backend seam.
1663        return stored_zlib(raw);
1664    }
1665    crate::backend::compress(raw, level)
1666}
1667
1668/// Zlib stream of stored (uncompressed) blocks: each 0xffff-byte block gets a
1669/// 5-byte header (final flag + LEN + ~LEN) followed by its payload, with the
1670/// adler32 of the whole stream appended — the generalization of
1671/// fast_png_io::encode_stored to an arbitrary byte stream. The SIMD adler32
1672/// runs over the contiguous input in one pass.
1673fn stored_zlib(raw: &[u8]) -> Vec<u8> {
1674    const BLOCK: usize = 0xffff;
1675
1676    if raw.is_empty() {
1677        // Single final stored block of length 0 + adler of empty input.
1678        return vec![0x78, 0x01, 1, 0, 0, 0xff, 0xff, 0, 0, 0, 1];
1679    }
1680
1681    let n_blocks = raw.len().div_ceil(BLOCK);
1682    let out_len = 2 + n_blocks * 5 + raw.len() + 4;
1683    let mut out = vec![0u8; out_len];
1684    out[0] = 0x78;
1685    out[1] = 0x01;
1686
1687    let mut pos = 2;
1688    let mut off = 0;
1689    while off < raw.len() {
1690        let n = (raw.len() - off).min(BLOCK);
1691        let final_block = off + n == raw.len();
1692        out[pos] = final_block as u8;
1693        out[pos + 1..pos + 3].copy_from_slice(&(n as u16).to_le_bytes());
1694        out[pos + 3..pos + 5].copy_from_slice(&(!(n as u16)).to_le_bytes());
1695        out[pos + 5..pos + 5 + n].copy_from_slice(&raw[off..off + n]);
1696        pos += 5 + n;
1697        off += n;
1698    }
1699
1700    let mut adler = crate::backend::Adler::new();
1701    adler.write(raw);
1702    out[out_len - 4..].copy_from_slice(&adler.finish().to_be_bytes());
1703    out
1704}
1705
1706// ---------------------------------------------------------------------------
1707// Chunk assembly
1708// ---------------------------------------------------------------------------
1709
1710/// Emit a chunk into an in-memory `Vec` via the container writer. Writing to a
1711/// `Vec` is infallible, so the `io::Result` is unwrapped here.
1712fn write_chunk(out: &mut Vec<u8>, ty: &[u8; 4], payload: &[u8]) {
1713    container::write_chunk(out, ty, payload).expect("Vec write is infallible");
1714}
1715
1716fn assemble_png(
1717    width: u32,
1718    height: u32,
1719    mode: &Mode,
1720    options: &EncodeOptions,
1721    meta: &Metadata,
1722    zlib: &[u8],
1723) -> Vec<u8> {
1724    let mut out = Vec::with_capacity(zlib.len() + 256);
1725    container::write_signature(&mut out).expect("Vec write is infallible");
1726
1727    let mut ihdr = [0u8; 13];
1728    ihdr[0..4].copy_from_slice(&width.to_be_bytes());
1729    ihdr[4..8].copy_from_slice(&height.to_be_bytes());
1730    ihdr[8] = mode.bit_depth;
1731    ihdr[9] = mode.color_type;
1732    ihdr[12] = options.interlace as u8;
1733    write_chunk(&mut out, b"IHDR", &ihdr);
1734
1735    write_meta_before_plte(&mut out, mode.color_type, meta);
1736    write_unknown(&mut out, meta, Location::AfterIhdr);
1737
1738    if let Some((rgb, alpha)) = &mode.palette {
1739        let plte: Vec<u8> = rgb.iter().flatten().copied().collect();
1740        write_chunk(&mut out, b"PLTE", &plte);
1741        if alpha.iter().any(|&a| a != 255) {
1742            let last = alpha.iter().rposition(|&a| a != 255).unwrap();
1743            write_chunk(&mut out, b"tRNS", &alpha[..=last]);
1744        }
1745    }
1746
1747    write_meta_after_plte(&mut out, mode.color_type, meta);
1748    write_unknown(&mut out, meta, Location::AfterPlte);
1749
1750    // 2 GB-safe IDAT splitting (chunk length must stay below 2^31).
1751    for part in zlib.chunks(i32::MAX as usize) {
1752        write_chunk(&mut out, b"IDAT", part);
1753    }
1754
1755    write_unknown(&mut out, meta, Location::AfterIdat);
1756    write_chunk(&mut out, b"IEND", &[]);
1757    out
1758}
1759
1760// ---------------------------------------------------------------------------
1761// Metadata chunk serialization
1762// ---------------------------------------------------------------------------
1763
1764/// Color-space chunks that must precede PLTE.
1765fn write_meta_before_plte(out: &mut Vec<u8>, color_type: u8, meta: &Metadata) {
1766    if let Some(c) = &meta.chrm {
1767        let mut p = [0u8; 32];
1768        for (i, v) in [
1769            c.white_x, c.white_y, c.red_x, c.red_y, c.green_x, c.green_y, c.blue_x, c.blue_y,
1770        ]
1771        .iter()
1772        .enumerate()
1773        {
1774            p[i * 4..i * 4 + 4].copy_from_slice(&v.to_be_bytes());
1775        }
1776        write_chunk(out, b"cHRM", &p);
1777    }
1778    if let Some(g) = meta.gama {
1779        write_chunk(out, b"gAMA", &g.to_be_bytes());
1780    }
1781    if let Some(intent) = meta.srgb {
1782        write_chunk(out, b"sRGB", &[intent]);
1783    }
1784    if let Some(iccp) = &meta.iccp {
1785        let mut p = Vec::with_capacity(iccp.name.len() + 2 + iccp.profile.len());
1786        p.extend_from_slice(&iccp.name);
1787        p.push(0); // name terminator
1788        p.push(0); // compression method: zlib
1789        p.extend_from_slice(&compress(&iccp.profile, 6));
1790        write_chunk(out, b"iCCP", &p);
1791    }
1792    if let Some(s) = &meta.sbit {
1793        let p: &[u8] = match color_type {
1794            0 => &[s.grayscale],
1795            2 | 3 => &[s.red, s.green, s.blue],
1796            4 => &[s.grayscale, s.alpha],
1797            _ => &[s.red, s.green, s.blue, s.alpha],
1798        };
1799        write_chunk(out, b"sBIT", p);
1800    }
1801}
1802
1803/// Chunks that follow PLTE (or, for non-indexed images, simply precede IDAT).
1804fn write_meta_after_plte(out: &mut Vec<u8>, color_type: u8, meta: &Metadata) {
1805    // Gray/truecolor color-key tRNS (indexed tRNS is emitted with PLTE).
1806    match (&meta.transparency, color_type) {
1807        (Some(Trns::Gray(v)), 0) => write_chunk(out, b"tRNS", &v.to_be_bytes()),
1808        (Some(Trns::Rgb(r, g, b)), 2) => {
1809            let mut p = [0u8; 6];
1810            p[0..2].copy_from_slice(&r.to_be_bytes());
1811            p[2..4].copy_from_slice(&g.to_be_bytes());
1812            p[4..6].copy_from_slice(&b.to_be_bytes());
1813            write_chunk(out, b"tRNS", &p);
1814        }
1815        _ => {}
1816    }
1817    if let Some(b) = &meta.bkgd {
1818        let p: Vec<u8> = match b {
1819            Bkgd::Gray(v) => v.to_be_bytes().to_vec(),
1820            Bkgd::Rgb(r, g, bl) => [r.to_be_bytes(), g.to_be_bytes(), bl.to_be_bytes()].concat(),
1821            Bkgd::Palette(i) => vec![*i],
1822        };
1823        write_chunk(out, b"bKGD", &p);
1824    }
1825    if let Some(h) = &meta.hist {
1826        let p: Vec<u8> = h.iter().flat_map(|v| v.to_be_bytes()).collect();
1827        write_chunk(out, b"hIST", &p);
1828    }
1829    if let Some(ph) = &meta.phys {
1830        let mut p = [0u8; 9];
1831        p[0..4].copy_from_slice(&ph.ppu_x.to_be_bytes());
1832        p[4..8].copy_from_slice(&ph.ppu_y.to_be_bytes());
1833        p[8] = ph.unit;
1834        write_chunk(out, b"pHYs", &p);
1835    }
1836    for s in &meta.splt {
1837        let mut p = Vec::with_capacity(s.name.len() + 2 + s.entries.len() * 10);
1838        p.extend_from_slice(&s.name);
1839        p.push(0);
1840        p.push(s.sample_depth);
1841        for e in &s.entries {
1842            if s.sample_depth == 16 {
1843                p.extend_from_slice(&e.red.to_be_bytes());
1844                p.extend_from_slice(&e.green.to_be_bytes());
1845                p.extend_from_slice(&e.blue.to_be_bytes());
1846                p.extend_from_slice(&e.alpha.to_be_bytes());
1847            } else {
1848                p.extend_from_slice(&[e.red as u8, e.green as u8, e.blue as u8, e.alpha as u8]);
1849            }
1850            p.extend_from_slice(&e.frequency.to_be_bytes());
1851        }
1852        write_chunk(out, b"sPLT", &p);
1853    }
1854    if let Some(t) = &meta.time {
1855        let mut p = [0u8; 7];
1856        p[0..2].copy_from_slice(&t.year.to_be_bytes());
1857        p[2..7].copy_from_slice(&[t.month, t.day, t.hour, t.minute, t.second]);
1858        write_chunk(out, b"tIME", &p);
1859    }
1860    if let Some(o) = &meta.offs {
1861        let mut p = [0u8; 9];
1862        p[0..4].copy_from_slice(&o.x.to_be_bytes());
1863        p[4..8].copy_from_slice(&o.y.to_be_bytes());
1864        p[8] = o.unit;
1865        write_chunk(out, b"oFFs", &p);
1866    }
1867    if let Some(e) = &meta.exif {
1868        write_chunk(out, b"eXIf", e);
1869    }
1870    for t in &meta.text {
1871        write_text(out, t);
1872    }
1873}
1874
1875fn write_text(out: &mut Vec<u8>, t: &Text) {
1876    let mut p = Vec::with_capacity(t.keyword.len() + t.text.len() + 8);
1877    p.extend_from_slice(&t.keyword);
1878    p.push(0);
1879    match t.kind {
1880        TextKind::Text => p.extend_from_slice(&t.text),
1881        TextKind::Ztxt => {
1882            p.push(0); // compression method: zlib
1883            p.extend_from_slice(&compress(&t.text, 6));
1884            write_chunk(out, b"zTXt", &p);
1885            return;
1886        }
1887        TextKind::Itxt => {
1888            p.push(t.compressed as u8); // compression flag
1889            p.push(0); // compression method
1890            p.extend_from_slice(&t.language_tag);
1891            p.push(0);
1892            p.extend_from_slice(&t.translated_keyword);
1893            p.push(0);
1894            if t.compressed {
1895                p.extend_from_slice(&compress(&t.text, 6));
1896            } else {
1897                p.extend_from_slice(&t.text);
1898            }
1899            write_chunk(out, b"iTXt", &p);
1900            return;
1901        }
1902    }
1903    write_chunk(out, b"tEXt", &p);
1904}
1905
1906fn write_unknown(out: &mut Vec<u8>, meta: &Metadata, at: Location) {
1907    for u in &meta.unknown {
1908        if u.location == at {
1909            write_chunk(out, &u.kind, &u.data);
1910        }
1911    }
1912}
1913
1914#[cfg(test)]
1915mod tests {
1916    use super::*;
1917
1918    fn lcg(n: usize, mut seed: u32) -> Vec<u8> {
1919        (0..n)
1920            .map(|_| {
1921                seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
1922                (seed >> 24) as u8
1923            })
1924            .collect()
1925    }
1926
1927    /// The NEON encode-filter kernels must be byte-identical to their scalar
1928    /// twins for every filter, bpp, and the first-row (`prev` empty) case —
1929    /// the §4 parity-safety rule. Equality is checked at `limit = u64::MAX` so
1930    /// neither side early-aborts and the *full* SADs (which drive the filter
1931    /// choice) must match exactly.
1932    #[test]
1933    fn neon_matches_scalar() {
1934        for &bpp in &[1usize, 2, 3, 4, 6, 8] {
1935            // Widths around the 16-byte SIMD stride boundary, incl. < bpp.
1936            for &width in &[1usize, 5, 15, 16, 17, 31, 33, 257] {
1937                let len = width * bpp;
1938                let row = lcg(len, (bpp * 31 + width) as u32 + 1);
1939                let full_prev = lcg(len, (bpp * 17 + width) as u32 + 99);
1940                for prev in [&[][..], &full_prev[..]] {
1941                    // Filter 0 (None) included: `best_filter` scans it, so the
1942                    // kernel's SAD for it must match scalar too.
1943                    for filter in 0..=4u8 {
1944                        let scalar_sad = filter_sad_scalar(filter, &row, prev, bpp, u64::MAX);
1945                        let kernel_sad = filter_sad(filter, &row, prev, bpp, u64::MAX);
1946                        assert_eq!(
1947                            scalar_sad,
1948                            kernel_sad,
1949                            "sad mismatch filter={filter} bpp={bpp} width={width} prev_empty={}",
1950                            prev.is_empty()
1951                        );
1952
1953                        let mut a = vec![0u8; len];
1954                        let mut b = vec![0u8; len];
1955                        apply_filter_scalar(filter, &row, prev, bpp, &mut a);
1956                        apply_filter(filter, &row, prev, bpp, &mut b);
1957                        assert_eq!(
1958                            a,
1959                            b,
1960                            "apply mismatch filter={filter} bpp={bpp} width={width} prev_empty={}",
1961                            prev.is_empty()
1962                        );
1963                    }
1964                }
1965            }
1966        }
1967    }
1968}