Skip to main content

otf_pixels/
lib.rs

1//! A streaming, demand-driven image processing engine.
2//!
3//! Pixels is a libvips-class pipeline engine: images are lazy operation
4//! graphs, pixels are pulled through the graph on demand, and memory stays
5//! bounded regardless of image size. This crate is the facade — the chainable
6//! [`Image`] API and the [`Image::output`] terminal — over
7//! [`otf_pixels_core`]'s engine, [`otf_pixels_ops`]' kernels and the codec
8//! crates.
9//!
10// The example writes raw bytes, so it only runs when that codec is compiled
11// in. `raw` is a default feature, so docs.rs and an ordinary build both run it.
12#![cfg_attr(feature = "raw", doc = "```")]
13#![cfg_attr(not(feature = "raw"), doc = "```ignore")]
14//! use otf_pixels::{Format, Image, ImageDescriptor, PixelFormat};
15//!
16//! # fn main() -> Result<(), otf_pixels::PixelsError> {
17//! let descriptor = ImageDescriptor::new(4, 4, PixelFormat::Gray8)?;
18//! let pixels: Vec<u8> = (0..16).collect();
19//!
20//! // Construction and chaining do no pixel work.
21//! let bytes = Image::from_raw(descriptor, pixels)?
22//!     .crop(1, 1, 2, 2)
23//!     .flip()
24//!     .output(Format::Raw, Default::default())
25//!     .bytes()?;
26//!
27//! assert_eq!(bytes, [9, 10, 5, 6]);
28//! # Ok(())
29//! # }
30//! ```
31//!
32//! # Errors are deferred, not swallowed
33//!
34//! Chaining methods take and return `Self` rather than [`Result`], so a
35//! pipeline reads as one expression. An error raised mid-chain — a crop window
36//! outside the image, say — is *captured* and carried to the terminal, where
37//! it surfaces from [`Output::write`] or [`Output::bytes`]. Nothing is
38//! silently ignored, and no operation runs after a failed one.
39//!
40//! # Evaluation
41//!
42//! Terminals run the pipeline on the demand-driven tile scheduler: output
43//! tiles are evaluated in parallel and delivered to the sink in order, with
44//! peak memory bounded by tiles in flight rather than image size. Tune it with
45//! [`Output::threads`] or [`Output::scheduler_options`].
46//!
47//! [`Output::bytes_via_reference`] runs the same pipeline through the M1
48//! whole-image evaluator instead. That path is slow and holds every
49//! intermediate in full, but it is obviously correct, so it is the oracle the
50//! scheduler is verified against.
51//!
52//! # Serving images
53//!
54//! What an image endpoint or a runtime's image API does: bytes in, a
55//! thumbnail out. Opening identifies the format from its bytes, turns the
56//! image upright, converts its colours to sRGB and enforces input limits;
57//! nothing is decoded until the output is pulled, and a JPEG source decodes
58//! at a reduced scale when the thumbnail allows it.
59//!
60#![cfg_attr(all(feature = "png", feature = "webp"), doc = "```")]
61#![cfg_attr(not(all(feature = "png", feature = "webp")), doc = "```ignore")]
62//! use otf_pixels::{
63//!     EncodeOptions, Fit, Format, Image, Limits, OpenOptions, ResizeOptions,
64//! };
65//!
66//! # fn main() -> Result<(), otf_pixels::PixelsError> {
67//! # let upload = Image::from_raw(
68//! #     otf_pixels::ImageDescriptor::new(64, 48, otf_pixels::PixelFormat::Rgb8)?,
69//! #     vec![90; 64 * 48 * 3],
70//! # )?
71//! # .output(Format::Png, EncodeOptions::default())
72//! # .bytes()?;
73//! // Per request: bound what an untrusted upload may allocate.
74//! let options = OpenOptions::default()
75//!     .with_limits(Limits::default().with_max_pixels(50_000_000));
76//! let image = Image::from_stream_with(std::io::Cursor::new(upload), options)?;
77//!
78//! let meta = image.metadata()?; // free: no pixels decoded
79//! assert_eq!((meta.width, meta.height), (64, 48));
80//! if let Some(animation) = image.animation() {
81//!     // v1 processes the first frame; the caller decides whether that is
82//!     // acceptable for this animation.
83//!     let _ = animation.frame_count;
84//! }
85//!
86//! let webp = image
87//!     .resize_with(32, 32, ResizeOptions::default().with_fit(Fit::Cover))
88//!     .output(Format::WebP, EncodeOptions::with_quality(80)?)
89//!     .bytes()?;
90//! assert_eq!(&webp[8..12], b"WEBP");
91//! # Ok(())
92//! # }
93//! ```
94//!
95//! # Scope (v1)
96//!
97//! - **Formats**, all implemented in this workspace and checked against
98//!   their reference implementations: PNG, GIF, baseline JPEG (progressive
99//!   decode is wrapped), TIFF, WebP (lossy and lossless) and AVIF, read and
100//!   written, plus raw pixels.
101//! - **Ops**: crop, flip/flop, quarter-turn rotation and orientation,
102//!   resize with sharp's five fit modes, modulate, convolve/blur/sharpen,
103//!   composite, flatten, channel extraction, pixel-format and sRGB
104//!   conversion.
105//! - **Metadata**: EXIF/HEIF orientation applied on open; ICC profiles
106//!   converted to sRGB or carried to the output; animation reported, with
107//!   the first frame processed. Other metadata (EXIF, XMP) is not written
108//!   out, which also strips location data from uploads.
109//!
110//! Not yet: multi-frame (animated) pipelines, arbitrary-angle rotation and
111//! progressive JPEG output. Each will arrive as an addition, not a change:
112//! [`OpenOptions::animated`] is already reserved for the first.
113
114use otf_pixels_core::{BufferSource, Op, Prefixed, Producer, TileBuf};
115use std::sync::Arc;
116
117pub use otf_pixels_core::{
118    AccessPattern, Animation, ChannelLayout, Codec, ColorModel, Decoder, EncodeOptions, Encoder,
119    ErrorCode, Format, ImageDescriptor, Limit, Limits, Metadata, Orientation, PixelFormat,
120    PixelsError, PlanOptions, Region, Result, RunStats, SampleKind, Scheduler, SchedulerOptions,
121    Sink, Source, TileShape, evaluate as evaluate_reference,
122};
123pub use otf_pixels_ops::{
124    Blend, Composite, Conversion, ConvertFormat, Convolve, Crop, ExtractChannel, Filter, Fit,
125    Flatten, Flip, Flop, Kernel, Modulate, Quarter, Resize, ResizeOptions, Rotate, ToSrgb,
126    Unconvertible,
127};
128
129#[cfg(feature = "raw")]
130pub use otf_pixels_codec_raw::{RawCodec, RawDecoder, RawEncoder, RawFormat};
131
132#[cfg(feature = "png")]
133pub use otf_pixels_codec_png::{PngCodec, PngDecoder, PngEncoder};
134
135#[cfg(feature = "gif")]
136pub use otf_pixels_codec_gif::{GifCodec, GifDecoder, GifEncoder};
137
138#[cfg(feature = "jpeg")]
139pub use otf_pixels_codec_jpeg::{JpegCodec, JpegDecoder, JpegEncoder, Scale, Subsampling};
140
141#[cfg(feature = "tiff")]
142pub use otf_pixels_codec_tiff::{TiffCodec, TiffDecoder, TiffEncoder, TiffLayout};
143
144#[cfg(feature = "webp")]
145pub use otf_pixels_codec_webp::{WebPCodec, WebPDecoder, WebPEncoder};
146
147#[cfg(feature = "avif")]
148pub use otf_pixels_codec_avif::{AvifCodec, AvifDecoder, AvifEncoder};
149
150/// How [`Image::open_with`] and [`Image::from_stream_with`] read an image.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152#[non_exhaustive]
153pub struct OpenOptions {
154    /// Turn the image upright as its metadata declares — EXIF `Orientation`
155    /// in JPEG, TIFF and WebP, `irot`/`imir` in AVIF — before any op.
156    ///
157    /// On by default (SPEC §Safety and limits): a phone photograph is stored
158    /// sideways far more often than anyone wants it processed that way. Off,
159    /// pixels arrive as stored, and [`Image::orient`] applies an orientation
160    /// read some other way.
161    pub auto_orient: bool,
162    /// Convert pixels in an embedded ICC profile's colour space to sRGB,
163    /// the space every op and most consumers assume (SPEC §Pixel formats).
164    ///
165    /// On by default, so a Display P3 phone photo or an Adobe RGB export
166    /// does not come out dull or garish. Off, pixels arrive as stored with
167    /// the profile attached ([`Image::icc_profile`]) and written into the
168    /// output, and [`Image::to_srgb`] converts later. A profile this cannot
169    /// convert is kept either way.
170    pub to_srgb: bool,
171    /// Ask for every frame of an animated image rather than the first.
172    ///
173    /// Reserved for the multi-frame pipeline, which is not implemented yet:
174    /// set, opening an animated GIF or WebP fails with
175    /// [`PixelsError::Unsupported`] rather than quietly processing one
176    /// frame, so code written against it today states its intent and starts
177    /// working when frames do. Off by default, as in sharp: the first frame
178    /// is the image, and [`Image::animation`] says what was left out.
179    pub animated: bool,
180    /// Bounds on what a file may make the decoder allocate. A runtime facing
181    /// untrusted uploads sets these per request; the default refuses images
182    /// over 268 megapixels, as sharp does.
183    pub limits: Limits,
184}
185
186impl OpenOptions {
187    /// The defaults with `auto_orient` replaced.
188    ///
189    /// [`OpenOptions`] is `#[non_exhaustive]`, so outside this crate a setter
190    /// is the only way to change a field.
191    #[must_use]
192    pub const fn with_auto_orient(mut self, auto_orient: bool) -> Self {
193        self.auto_orient = auto_orient;
194        self
195    }
196
197    /// The defaults with `animated` replaced.
198    #[must_use]
199    pub const fn with_animated(mut self, animated: bool) -> Self {
200        self.animated = animated;
201        self
202    }
203
204    /// The defaults with `limits` replaced.
205    #[must_use]
206    pub const fn with_limits(mut self, limits: Limits) -> Self {
207        self.limits = limits;
208        self
209    }
210
211    /// The defaults with `to_srgb` replaced.
212    #[must_use]
213    pub const fn with_to_srgb(mut self, to_srgb: bool) -> Self {
214        self.to_srgb = to_srgb;
215        self
216    }
217}
218
219impl Default for OpenOptions {
220    fn default() -> Self {
221        Self {
222            auto_orient: true,
223            to_srgb: true,
224            animated: false,
225            limits: Limits::default(),
226        }
227    }
228}
229
230/// A lazily evaluated image pipeline.
231///
232/// Cheap to clone: clones share graph nodes rather than pixels. Chaining
233/// builds graph structure and executes nothing (SPEC §Guarantees 3).
234#[derive(Debug, Clone)]
235pub struct Image {
236    /// The graph so far, or the first error that occurred while building it.
237    inner: std::result::Result<otf_pixels_core::Image, Arc<PixelsError>>,
238    /// The ICC profile the pixels are in, carried to the output; `None` is
239    /// sRGB, as SPEC §Pixel formats assumes.
240    icc: Option<Arc<[u8]>>,
241    /// The source's animation, when it has more than one frame.
242    animation: Option<Arc<Animation>>,
243}
244
245impl Image {
246    /// Build an image from raw pixels already in memory.
247    ///
248    /// `bytes` must be exactly the packed byte length of `descriptor`.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`PixelsError::InvalidArgument`] if `bytes` is not exactly the
253    /// packed length `descriptor` implies.
254    pub fn from_raw(descriptor: ImageDescriptor, bytes: Vec<u8>) -> Result<Self> {
255        let buffer = TileBuf::from_vec(descriptor.region(), descriptor.pixel, bytes)?;
256        let source = BufferSource::new(descriptor, Arc::new(buffer))?;
257        Ok(Self::from_producer(Arc::new(source), Format::Raw))
258    }
259
260    /// Build an image by decoding a raw pixel stream.
261    ///
262    /// The header parse is trivial for raw — the layout *is* the header — so
263    /// this reads no bytes from `source`. Pixels are pulled at the terminal.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`PixelsError::InvalidArgument`] if the layout is not
268    /// representable on this platform.
269    #[cfg(feature = "raw")]
270    pub fn from_raw_stream(
271        layout: RawFormat,
272        source: impl Source + std::fmt::Debug + 'static,
273    ) -> Result<Self> {
274        let decoder = RawDecoder::new(layout, source)?;
275        Ok(Self::from_decoder(Box::new(decoder), Format::Raw))
276    }
277
278    /// Open an image file, identifying its format from its contents, with
279    /// default [`OpenOptions`] — so it is turned upright.
280    ///
281    /// The path's extension is **ignored**. Detection is by magic bytes only
282    /// (SPEC §Formats), because a name is an attacker-controlled hint while
283    /// the bytes are a fact.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`PixelsError::Io`] if the file cannot be opened,
288    /// [`PixelsError::Unsupported`] if no built-in codec recognises it, and
289    /// [`PixelsError::Malformed`] if the header is invalid for the format its
290    /// magic bytes claim.
291    pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self> {
292        Self::open_with(path, OpenOptions::default())
293    }
294
295    /// Open an image file with explicit [`OpenOptions`].
296    ///
297    /// # Errors
298    ///
299    /// As [`Image::open`].
300    pub fn open_with(path: impl AsRef<std::path::Path>, options: OpenOptions) -> Result<Self> {
301        let path = path.as_ref();
302        let file = std::fs::File::open(path).map_err(|e| {
303            // `PixelsError::io` takes a static context, so the path goes into
304            // the wrapped error instead. Losing which file failed would make
305            // the error useless in exactly the case it fires.
306            let kind = e.kind();
307            let detail = std::io::Error::new(kind, format!("{}: {e}", path.display()));
308            PixelsError::io("opening image file", detail)
309        })?;
310        Self::from_stream_with(std::io::BufReader::new(file), options)
311    }
312
313    /// Build an image from a byte stream, identifying its format from the
314    /// leading bytes.
315    ///
316    /// Sniffing reads only the longest magic prefix any known codec needs, and
317    /// replays it to the decoder rather than seeking — a [`Source`] is
318    /// forward-only (ADR-0005), so a pipe or socket works here exactly as a
319    /// file does. A stream shorter than that prefix is not an error at this
320    /// stage: it simply matches nothing.
321    ///
322    /// # Errors
323    ///
324    /// Returns [`PixelsError::Unsupported`] if no built-in codec recognises
325    /// the stream, [`PixelsError::Io`] on read failure, or
326    /// [`PixelsError::Malformed`] if the header is invalid for the format its
327    /// magic bytes claim.
328    pub fn from_stream(source: impl Source + std::fmt::Debug + 'static) -> Result<Self> {
329        Self::from_stream_with(source, OpenOptions::default())
330    }
331
332    /// Build an image from a byte stream with explicit [`OpenOptions`].
333    ///
334    /// # Errors
335    ///
336    /// As [`Image::from_stream`].
337    pub fn from_stream_with(
338        mut source: impl Source + std::fmt::Debug + 'static,
339        options: OpenOptions,
340    ) -> Result<Self> {
341        let codecs = sniffing_codecs();
342        let longest = codecs.iter().map(|c| c.magic_len()).max().unwrap_or(0);
343
344        // Short reads are normal, and a stream shorter than `longest` is a
345        // legitimate no-match rather than a failure, so this loop stops at end
346        // of input instead of demanding a full prefix.
347        let mut prefix = Vec::with_capacity(longest);
348        let mut buffer = vec![0_u8; longest];
349        while prefix.len() < longest {
350            let Some(rest) = buffer.get_mut(prefix.len()..) else {
351                break;
352            };
353            match source.read(rest)? {
354                0 => break,
355                n => {
356                    let Some(read) = rest.get(..n) else { break };
357                    prefix.extend_from_slice(read);
358                }
359            }
360        }
361
362        let Some(codec) = codecs.iter().find(|codec| codec.probe(&prefix)) else {
363            return Err(PixelsError::unsupported(format!(
364                "no codec recognises this stream; its first {} bytes are {:02x?}",
365                prefix.len().min(8),
366                prefix.get(..prefix.len().min(8)).unwrap_or(&[])
367            )));
368        };
369        let stream = Prefixed::new(prefix, source);
370        // Unused when no decoding codec is compiled in, which is a legitimate
371        // if degenerate build rather than a mistake.
372        let _ = (&stream, options);
373
374        match codec.format() {
375            #[cfg(feature = "png")]
376            Format::Png => {
377                let decoder = PngDecoder::new(stream, options.limits)?;
378                Self::decoded(Box::new(decoder), Format::Png, options)
379            }
380            #[cfg(feature = "gif")]
381            Format::Gif => {
382                let decoder = GifDecoder::new(stream, options.limits)?;
383                Self::decoded(Box::new(decoder), Format::Gif, options)
384            }
385            #[cfg(feature = "jpeg")]
386            Format::Jpeg => {
387                let decoder = JpegDecoder::new(stream, options.limits)?;
388                Self::decoded(Box::new(decoder), Format::Jpeg, options)
389            }
390            #[cfg(feature = "tiff")]
391            Format::Tiff => {
392                let decoder = TiffDecoder::new(stream, options.limits)?;
393                Self::decoded(Box::new(decoder), Format::Tiff, options)
394            }
395            #[cfg(feature = "webp")]
396            Format::WebP => {
397                let decoder = WebPDecoder::new(stream, options.limits)?;
398                Self::decoded(Box::new(decoder), Format::WebP, options)
399            }
400            #[cfg(feature = "avif")]
401            Format::Avif => {
402                let decoder = AvifDecoder::new(stream, options.limits)?;
403                Self::decoded(Box::new(decoder), Format::Avif, options)
404            }
405            other => Err(PixelsError::unsupported(format!(
406                "{other} was detected but no decoder for it is compiled in"
407            ))),
408        }
409    }
410
411    /// Wrap a sniffed decoder, turning it upright if `options` say so.
412    ///
413    /// The orientation is read before the decoder disappears into a source,
414    /// which is the last point it is reachable.
415    #[cfg(any(
416        feature = "png",
417        feature = "gif",
418        feature = "jpeg",
419        feature = "tiff",
420        feature = "webp",
421        feature = "avif"
422    ))]
423    fn decoded(decoder: Box<dyn Decoder>, format: Format, options: OpenOptions) -> Result<Self> {
424        let orientation = decoder.orientation();
425        let icc = decoder.icc_profile().map(Vec::from);
426        let animation = decoder.animation().map(Arc::new);
427        if options.animated && animation.is_some() {
428            return Err(PixelsError::unsupported(format!(
429                "{format}: multi-frame (animated) pipelines are not implemented yet; \
430                 open with `animated` off to process the first frame"
431            )));
432        }
433        let mut image = Self::from_decoder(decoder, format).with_icc_profile(icc);
434        image.animation = animation;
435        if options.to_srgb {
436            image = image.to_srgb();
437        }
438        Ok(if options.auto_orient {
439            image.orient(orientation)
440        } else {
441            image
442        })
443    }
444
445    /// Build an image from any decoder whose header has already been parsed.
446    ///
447    /// This is the extension point for codecs living outside this crate.
448    /// Pixels arrive as stored: [`Decoder::orientation`] is not applied here,
449    /// so pass it to [`Image::orient`] to turn the result upright.
450    #[must_use]
451    pub fn from_decoder(decoder: Box<dyn Decoder>, format: Format) -> Self {
452        let source = otf_pixels_core::DecodedSource::new(decoder);
453        Self::from_producer(Arc::new(source), format)
454    }
455
456    /// Build an image from any pixel producer.
457    #[must_use]
458    pub fn from_producer(producer: Arc<dyn Producer>, format: Format) -> Self {
459        Self {
460            icc: None,
461            animation: None,
462            inner: Ok(otf_pixels_core::Image::from_producer(producer, format)),
463        }
464    }
465
466    /// Header-only facts about this image: dimensions, format, pixel format.
467    ///
468    /// Free — descriptors are resolved as the graph is built, so this decodes
469    /// nothing (SPEC §Guarantees 3).
470    ///
471    /// # Errors
472    ///
473    /// Returns the first error captured while building the pipeline, if any.
474    pub fn metadata(&self) -> Result<Metadata> {
475        self.graph()?.metadata()
476    }
477
478    /// The shape of this image at this point in the pipeline.
479    ///
480    /// # Errors
481    ///
482    /// Returns the first error captured while building the pipeline, if any.
483    pub fn descriptor(&self) -> Result<ImageDescriptor> {
484        Ok(self.graph()?.descriptor())
485    }
486
487    /// Extract the rectangular window at `(x, y)` of size `width` × `height`.
488    ///
489    /// A window outside the image is an error, surfaced at the terminal.
490    #[must_use]
491    pub fn crop(self, x: u32, y: u32, width: u32, height: u32) -> Self {
492        match Crop::at(x, y, width, height) {
493            Ok(op) => self.apply(Arc::new(op)),
494            Err(error) => Self::failed(error),
495        }
496    }
497
498    /// Mirror vertically: the top row becomes the bottom row.
499    #[must_use]
500    pub fn flip(self) -> Self {
501        self.apply(Arc::new(Flip))
502    }
503
504    /// Mirror horizontally: the left column becomes the right column.
505    #[must_use]
506    pub fn flop(self) -> Self {
507        self.apply(Arc::new(Flop))
508    }
509
510    /// Resample to `width` by `height` with the default filter (Lanczos3).
511    #[must_use]
512    pub fn resize(self, width: u32, height: u32) -> Self {
513        self.resize_with(width, height, ResizeOptions::default())
514    }
515
516    /// Resample to `width` by `height` with explicit options.
517    #[must_use]
518    pub fn resize_with(self, width: u32, height: u32, options: ResizeOptions) -> Self {
519        match Resize::new(width, height, options) {
520            Ok(op) => self.apply(Arc::new(op)),
521            Err(error) => Self::failed(error),
522        }
523    }
524
525    /// Scale to fit inside `width` by `height`, preserving aspect ratio.
526    #[must_use]
527    pub fn thumbnail(self, width: u32, height: u32) -> Self {
528        let options = ResizeOptions::default()
529            .with_fit(Fit::Inside)
530            .without_enlargement(true);
531        self.resize_with(width, height, options)
532    }
533
534    /// Apply `orientation`: the stored image becomes the upright one.
535    ///
536    /// [`Image::open`] and [`Image::from_stream`] already do this with the
537    /// orientation the file declares; this is for pixels opened with
538    /// `auto_orient` off or through [`Image::from_decoder`]. It is a quarter
539    /// turn and a mirror at most, and both rescale, so an oriented JPEG
540    /// keeps its shrink-on-load fast path.
541    #[must_use]
542    pub fn orient(self, orientation: Orientation) -> Self {
543        let turns = orientation.clockwise_turns();
544        let image = if turns == 0 {
545            self
546        } else {
547            self.rotate(90 * i32::from(turns))
548        };
549        if orientation.mirrored() {
550            image.flop()
551        } else {
552            image
553        }
554    }
555
556    /// Rotate by `degrees`, which must be a multiple of 90.
557    #[must_use]
558    pub fn rotate(self, degrees: i32) -> Self {
559        match Rotate::degrees(degrees) {
560            Ok(op) => self.apply(Arc::new(op)),
561            Err(error) => Self::failed(error),
562        }
563    }
564
565    /// Adjust brightness, saturation and hue.
566    #[must_use]
567    pub fn modulate(self, options: Modulate) -> Self {
568        self.apply(Arc::new(options))
569    }
570
571    /// Convolve with `kernel`.
572    #[must_use]
573    pub fn convolve(self, kernel: Kernel) -> Self {
574        self.apply(Arc::new(Convolve::new(kernel)))
575    }
576
577    /// Blur with a Gaussian of the given sigma.
578    #[must_use]
579    pub fn blur(self, sigma: f32) -> Self {
580        match Kernel::gaussian(sigma) {
581            Ok(kernel) => self.convolve(kernel),
582            Err(error) => Self::failed(error),
583        }
584    }
585
586    /// Sharpen by `amount`, a 3x3 unsharp-style kernel.
587    #[must_use]
588    pub fn sharpen(self, amount: f32) -> Self {
589        match Kernel::sharpen(amount) {
590            Ok(kernel) => self.convolve(kernel),
591            Err(error) => Self::failed(error),
592        }
593    }
594
595    /// Extract one channel as a greyscale image.
596    #[must_use]
597    pub fn extract_channel(self, index: usize) -> Self {
598        self.apply(Arc::new(ExtractChannel::new(index)))
599    }
600
601    /// Composite this image against an opaque background, discarding alpha.
602    #[must_use]
603    pub fn flatten(self, red: u8, green: u8, blue: u8) -> Self {
604        self.apply(Arc::new(Flatten::onto(red, green, blue)))
605    }
606
607    /// Draw `overlay` over this image at `(x, y)`.
608    ///
609    /// This is the join point for two branches of a graph: both pipelines stay
610    /// lazy, and neither is evaluated until a terminal pulls on the result.
611    #[must_use]
612    pub fn composite(self, overlay: Self, x: i64, y: i64) -> Self {
613        self.composite_with(overlay, x, y, Blend::Over)
614    }
615
616    /// Draw `overlay` over this image with an explicit blend mode.
617    #[must_use]
618    pub fn composite_with(self, overlay: Self, x: i64, y: i64, blend: Blend) -> Self {
619        // The base's profile describes the result; the overlay's pixels are
620        // composited as they are.
621        let (icc, animation) = (self.icc, self.animation);
622        let (base, over) = match (self.inner, overlay.inner) {
623            (Ok(base), Ok(over)) => (base, over),
624            // The first error wins, matching how a single chain behaves.
625            (Err(error), _) | (Ok(_), Err(error)) => return Self::failed_shared(error),
626        };
627        let op: Arc<dyn Op> = Arc::new(Composite::at(x, y, blend));
628        Self {
629            inner: otf_pixels_core::Image::combine(&[base, over], op).map_err(Arc::new),
630            icc,
631            animation,
632        }
633    }
634
635    /// A pipeline carrying an error, surfaced at the terminal.
636    fn failed(error: PixelsError) -> Self {
637        Self::failed_shared(Arc::new(error))
638    }
639
640    /// [`Image::failed`] with an error already shared.
641    const fn failed_shared(error: Arc<PixelsError>) -> Self {
642        Self {
643            inner: Err(error),
644            icc: None,
645            animation: None,
646        }
647    }
648
649    /// The source file's animation, if it has more than one frame.
650    ///
651    /// The pipeline processes the first frame, so this describes what the
652    /// file holds rather than what will be written: frame count, loop count
653    /// and per-frame durations, for a caller to decide whether a still is
654    /// what it wants (see [`OpenOptions::animated`]).
655    #[must_use]
656    pub fn animation(&self) -> Option<&Animation> {
657        self.animation.as_deref()
658    }
659
660    /// The ICC profile this image's pixels are in, if it is not sRGB.
661    ///
662    /// A file's embedded profile, unless the pixels were converted to sRGB
663    /// on open (see [`OpenOptions`]). It is written into the output where
664    /// the format has a place for one, so colours survive the round trip.
665    #[must_use]
666    pub fn icc_profile(&self) -> Option<&[u8]> {
667        self.icc.as_deref()
668    }
669
670    /// Convert the pixels from their ICC profile's colour space to sRGB, and
671    /// drop the profile.
672    ///
673    /// [`Image::open`] already does this unless told not to
674    /// ([`OpenOptions::to_srgb`]). Matrix/TRC RGB and grey profiles convert,
675    /// relative colorimetric with out-of-gamut colours clipped, as lcms2
676    /// does; a profile that is sRGB in all but name is just dropped. Any
677    /// other profile (LUT-based, CMYK, one that does not match the pixels)
678    /// is kept, unconverted, so the output still carries it.
679    #[must_use]
680    pub fn to_srgb(self) -> Self {
681        let Some(profile) = self.icc.clone() else {
682            return self;
683        };
684        let Ok(descriptor) = self.descriptor() else {
685            return self;
686        };
687        match ToSrgb::from_profile(&profile) {
688            Conversion::Convert(op) if op.applies_to(descriptor.pixel) => {
689                self.apply(Arc::new(op)).with_icc_profile(None)
690            }
691            Conversion::AlreadySrgb => self.with_icc_profile(None),
692            _ => self,
693        }
694    }
695
696    /// Convert to `pixel`: depth (8-bit, 16-bit, float) and layout (grey,
697    /// grey with alpha, RGB, RGBA). Grey widens to RGB by repetition and RGB
698    /// narrows to grey by BT.601 luma; alpha is added opaque or dropped
699    /// (use [`Image::flatten`] to composite against a colour instead).
700    ///
701    /// Outputs need not ask for this: [`Image::output`] narrows to what the
702    /// format holds by itself.
703    #[must_use]
704    pub fn to_pixel_format(self, pixel: PixelFormat) -> Self {
705        match self.descriptor() {
706            Ok(descriptor) if descriptor.pixel == pixel => self,
707            _ => self.apply(Arc::new(ConvertFormat::to(pixel))),
708        }
709    }
710
711    /// This image in a pixel format `format`'s encoder accepts: 8 bits for
712    /// JPEG, WebP, AVIF and GIF, and integers for PNG and TIFF. A 16-bit PNG
713    /// written as WebP is thereby narrowed rather than refused.
714    fn encodable_as(self, format: Format) -> Self {
715        let Ok(descriptor) = self.descriptor() else {
716            return self;
717        };
718        let pixel = descriptor.pixel;
719        let kind = match (format, pixel.sample_kind()) {
720            (
721                Format::Jpeg | Format::WebP | Format::Avif | Format::Gif,
722                SampleKind::U16 | SampleKind::F32,
723            ) => SampleKind::U8,
724            (Format::Png | Format::Tiff, SampleKind::F32) => SampleKind::U16,
725            _ => return self,
726        };
727        match PixelFormat::from_parts(pixel.layout(), kind) {
728            Some(target) => self.to_pixel_format(target),
729            None => self,
730        }
731    }
732
733    /// Declare the ICC profile the pixels are in, or with `None` drop it and
734    /// call them sRGB. Only the label changes, never a pixel.
735    #[must_use]
736    pub fn with_icc_profile(mut self, profile: Option<Vec<u8>>) -> Self {
737        self.icc = profile.map(Arc::from);
738        self
739    }
740
741    /// Chain an arbitrary op onto this pipeline.
742    ///
743    /// The escape hatch for ops defined outside this crate. Errors are
744    /// deferred to the terminal, like every other chaining method.
745    #[must_use]
746    pub fn apply(self, op: Arc<dyn Op>) -> Self {
747        Self {
748            icc: self.icc,
749            animation: self.animation,
750            inner: match self.inner {
751                Ok(image) => image.apply(op).map_err(Arc::new),
752                // An earlier failure short-circuits: later ops never run.
753                Err(error) => Err(error),
754            },
755        }
756    }
757
758    /// Choose the encoder and options for this pipeline's output.
759    ///
760    /// This is the single encode terminal, with format as data (ADR-0006):
761    /// requesting a format that is not yet implemented is a catchable
762    /// [`PixelsError::Unsupported`], not a compile error.
763    #[must_use]
764    pub fn output(self, format: Format, options: EncodeOptions) -> Output {
765        Output {
766            image: self,
767            format,
768            options,
769            scheduler: SchedulerOptions::default(),
770            shared: None,
771        }
772    }
773
774    /// The underlying graph, or the first captured error.
775    fn graph(&self) -> Result<&otf_pixels_core::Image> {
776        match &self.inner {
777            Ok(image) => Ok(image),
778            // The error is shared, so it is rebuilt rather than moved out.
779            Err(error) => Err(rebuild(error)),
780        }
781    }
782}
783
784/// Reconstruct an owned error from a shared one.
785///
786/// [`PixelsError`] is not [`Clone`] — [`std::io::Error`] is not — so a captured
787/// error is rebuilt preserving its code and message. The [`ErrorCode`], which
788/// is the part under semver (SPEC §Guarantees 4), is exact.
789fn rebuild(error: &Arc<PixelsError>) -> PixelsError {
790    let detail = error.to_string();
791    match error.code() {
792        ErrorCode::Io => PixelsError::io("running the pipeline", std::io::Error::other(detail)),
793        ErrorCode::Malformed => PixelsError::malformed("pipeline", detail),
794        ErrorCode::Unsupported => PixelsError::unsupported(detail),
795        ErrorCode::InvalidArgument => PixelsError::invalid_argument("pipeline", detail),
796        ErrorCode::Graph => PixelsError::graph(detail),
797        ErrorCode::LimitExceeded => match **error {
798            PixelsError::LimitExceeded {
799                limit,
800                requested,
801                allowed,
802            } => PixelsError::limit_exceeded(limit, requested, allowed),
803            _ => PixelsError::graph(detail),
804        },
805        // A code added in a later version still round-trips as an error.
806        _ => PixelsError::graph(detail),
807    }
808}
809
810/// A pipeline with its output format chosen, ready to be pulled.
811///
812/// Nothing has executed yet: the terminals on this type are what pull pixels
813/// through the graph.
814#[derive(Debug, Clone)]
815pub struct Output {
816    image: Image,
817    format: Format,
818    options: EncodeOptions,
819    scheduler: SchedulerOptions,
820    /// A scheduler shared with other pipelines, if the caller supplied one.
821    shared: Option<Arc<Scheduler>>,
822}
823
824impl Output {
825    /// The format this output will be encoded as.
826    #[must_use]
827    pub const fn format(&self) -> Format {
828        self.format
829    }
830
831    /// The encoder options in effect.
832    #[must_use]
833    pub const fn options(&self) -> EncodeOptions {
834        self.options
835    }
836
837    /// Run this pipeline on `threads` worker threads.
838    ///
839    /// Zero, the default, means one per available core. Setting it to one
840    /// gives a fully deterministic serial run, which is what the differential
841    /// tests against the reference evaluator use.
842    #[must_use]
843    pub const fn threads(mut self, threads: usize) -> Self {
844        self.scheduler.threads = threads;
845        self
846    }
847
848    /// Run on `scheduler`, shared with other pipelines, instead of a pool of
849    /// this run's own.
850    ///
851    /// A server or a runtime should build one [`Scheduler`] at startup and
852    /// pass it to every output: otherwise each run spawns a thread per core
853    /// and joins them when it ends, and concurrent requests each bring their
854    /// own, oversubscribing the machine. Many pipelines may run on one
855    /// scheduler at once, from any threads. [`Output::threads`] and
856    /// [`Output::scheduler_options`] are ignored when one is set: the
857    /// scheduler was configured when it was built.
858    #[must_use]
859    pub fn with_scheduler(mut self, scheduler: Arc<Scheduler>) -> Self {
860        self.shared = Some(scheduler);
861        self
862    }
863
864    /// Tune the scheduler directly.
865    #[must_use]
866    pub const fn scheduler_options(mut self, options: SchedulerOptions) -> Self {
867        self.scheduler = options;
868        self
869    }
870
871    /// Run the pipeline, streaming encoded bytes into `sink`.
872    ///
873    /// Rows are encoded and written in order as they are produced. A failure
874    /// anywhere fails the whole call; partial output is never reported as
875    /// success (ARCHITECTURE §Failure model), though bytes already handed to
876    /// `sink` are of course already gone — a caller needing all-or-nothing
877    /// should write to a buffer or a temporary and commit on success.
878    ///
879    /// # Errors
880    ///
881    /// Returns [`PixelsError::Unsupported`] if the format has no encoder in
882    /// this build, and otherwise any error from the pipeline or the sink.
883    pub fn write(self, sink: impl Sink) -> Result<()> {
884        self.write_with_stats(sink).map(|_| ())
885    }
886
887    /// Stream to `sink`, reporting what the run did.
888    ///
889    /// The same work as [`Output::write`], with [`RunStats`] returned instead
890    /// of discarded. Use it to confirm a pipeline streamed rather than
891    /// materialized, or that a JPEG thumbnail took the shrink-on-load path:
892    /// `stats.reduction` is `None` when the source decoded at full size, which
893    /// is the difference between a fast thumbnail and a slow one.
894    ///
895    /// # Errors
896    ///
897    /// As [`Output::write`].
898    pub fn write_with_stats(self, mut sink: impl Sink) -> Result<RunStats> {
899        // Rewritten before an evaluator is chosen, so the scheduler and the
900        // reference evaluator are handed the same graph and keep agreeing.
901        let encodable = self.image.clone().encodable_as(self.format);
902        let (image, reduction) = otf_pixels_core::shrink_on_load(encodable.graph()?)?;
903        let descriptor = image.descriptor();
904        let mut encoder = encoder_for(self.format, self.options)?;
905        encoder.set_icc_profile(self.image.icc.as_deref())?;
906        encoder.write_header(&descriptor, &mut sink)?;
907
908        // The scheduler delivers tiles; an encoder wants whole rows in order.
909        // A shared scheduler's threads are reused; otherwise this run gets
910        // its own pool, torn down when it finishes.
911        let owned;
912        let scheduler = match &self.shared {
913            Some(shared) => shared.as_ref(),
914            None => {
915                owned = Scheduler::new(self.scheduler)?;
916                &owned
917            }
918        };
919        let mut rows = RowAssembler::new(descriptor);
920        let mut stats = scheduler.run(&image, |region, tile| {
921            rows.accept(region, tile, &mut |row| encoder.write_row(row, &mut sink))
922        })?;
923        rows.finish(&mut |row| encoder.write_row(row, &mut sink))?;
924        encoder.finish(&mut sink)?;
925        stats.reduction = reduction;
926        Ok(stats)
927    }
928
929    /// Run the pipeline through the **reference** evaluator instead of the
930    /// tile scheduler, collecting encoded bytes.
931    ///
932    /// The reference evaluator is single-threaded and whole-image: it holds
933    /// every intermediate in full, so it is slow and its memory scales with
934    /// the image. It exists because it is *obviously* correct, which makes it
935    /// the oracle the scheduler is verified against — the two must produce
936    /// byte-identical output for every pipeline (ROADMAP M2).
937    ///
938    /// Use it to verify, to debug a suspected scheduler bug, or where an image
939    /// is small and determinism matters more than throughput. Prefer
940    /// [`Output::bytes`] otherwise.
941    ///
942    /// # Errors
943    ///
944    /// As [`Output::bytes`].
945    pub fn bytes_via_reference(self) -> Result<Vec<u8>> {
946        // The same rewrite the scheduled path applies. Without it the oracle
947        // would evaluate a different graph and the two would disagree wherever
948        // shrink-on-load fired — which would look like a scheduler bug.
949        let encodable = self.image.clone().encodable_as(self.format);
950        let (image, _) = otf_pixels_core::shrink_on_load(encodable.graph()?)?;
951        let descriptor = image.descriptor();
952        let mut encoder = encoder_for(self.format, self.options)?;
953        encoder.set_icc_profile(self.image.icc.as_deref())?;
954        let mut sink = Vec::with_capacity(descriptor.byte_len().unwrap_or_default());
955        encoder.write_header(&descriptor, &mut sink)?;
956        otf_pixels_core::evaluate_rows(&image, |_, row| encoder.write_row(row, &mut sink))?;
957        encoder.finish(&mut sink)?;
958        Ok(sink)
959    }
960
961    /// Run the pipeline, collecting encoded bytes into a [`Vec`].
962    ///
963    /// # Errors
964    ///
965    /// As [`Output::write`].
966    pub fn bytes(self) -> Result<Vec<u8>> {
967        // Sizing the buffer up front avoids repeated growth for raw output,
968        // where the encoded length is exactly the packed pixel length.
969        let hint = self
970            .image
971            .descriptor()
972            .ok()
973            .and_then(|d| d.byte_len())
974            .unwrap_or_default();
975        let mut buffer = Vec::with_capacity(hint);
976        self.write(&mut buffer)?;
977        Ok(buffer)
978    }
979}
980
981/// Reassembles scheduler tiles into whole rows for an encoder.
982///
983/// Sequential pipelines deliver full-width strips, so rows pass straight
984/// through untouched — the common case costs nothing. Where a spatial op puts
985/// the output on square tiles (ADR-0003), tiles arrive left-to-right within a
986/// band, and a row is only complete once its band is. Those are buffered one
987/// band at a time, so the cost is a band rather than an image.
988#[derive(Debug)]
989struct RowAssembler {
990    descriptor: ImageDescriptor,
991    /// The band being assembled, when tiles are narrower than the image.
992    band: Option<otf_pixels_core::TileBuf>,
993    /// Next row not yet emitted.
994    next_row: u32,
995}
996
997impl RowAssembler {
998    const fn new(descriptor: ImageDescriptor) -> Self {
999        Self {
1000            descriptor,
1001            band: None,
1002            next_row: 0,
1003        }
1004    }
1005
1006    /// Take one tile, emitting whatever rows it completes.
1007    fn accept(
1008        &mut self,
1009        region: Region,
1010        tile: &otf_pixels_core::Tile<'_>,
1011        emit: &mut impl FnMut(&[u8]) -> Result<()>,
1012    ) -> Result<()> {
1013        // Fast path: a full-width tile completes its own rows.
1014        if region.width == self.descriptor.width && self.band.is_none() {
1015            for y in region.y..region.y.saturating_add(region.height) {
1016                let row = tile
1017                    .row(y)
1018                    .ok_or_else(|| PixelsError::graph(format!("output tile is missing row {y}")))?;
1019                emit(row)?;
1020                self.next_row = y.saturating_add(1);
1021            }
1022            return Ok(());
1023        }
1024
1025        // Narrow tile: accumulate into a full-width band, flushing the
1026        // previous one when a new band starts.
1027        let band_region = Region::new(0, region.y, self.descriptor.width, region.height);
1028        let starts_new_band = self
1029            .band
1030            .as_ref()
1031            .is_none_or(|band| band.region().y != region.y);
1032        if starts_new_band {
1033            self.flush(emit)?;
1034            self.band = Some(otf_pixels_core::TileBuf::zeroed(
1035                band_region,
1036                self.descriptor.pixel,
1037            )?);
1038        }
1039        let Some(band) = self.band.as_mut() else {
1040            return Err(PixelsError::graph("row band vanished"));
1041        };
1042        otf_pixels_core::copy_region(tile, &mut band.as_tile_mut()?, region)?;
1043
1044        // A band is complete once its rightmost column has arrived.
1045        if region.right() >= u64::from(self.descriptor.width) {
1046            self.flush(emit)?;
1047        }
1048        Ok(())
1049    }
1050
1051    /// Emit any buffered band's rows and drop it.
1052    fn flush(&mut self, emit: &mut impl FnMut(&[u8]) -> Result<()>) -> Result<()> {
1053        let Some(band) = self.band.take() else {
1054            return Ok(());
1055        };
1056        let region = band.region();
1057        let view = band.as_tile()?;
1058        for y in region.y..region.y.saturating_add(region.height) {
1059            let row = view
1060                .row(y)
1061                .ok_or_else(|| PixelsError::graph(format!("row band is missing row {y}")))?;
1062            emit(row)?;
1063            self.next_row = y.saturating_add(1);
1064        }
1065        Ok(())
1066    }
1067
1068    /// Emit anything still buffered at the end of a run.
1069    fn finish(&mut self, emit: &mut impl FnMut(&[u8]) -> Result<()>) -> Result<()> {
1070        self.flush(emit)
1071    }
1072}
1073
1074/// Every codec that can be sniffed, in probe order.
1075///
1076/// Raw is deliberately absent: it has no magic bytes, so it can only be
1077/// requested, never detected. Adding it here would make it match everything.
1078fn sniffing_codecs() -> Vec<Box<dyn Codec>> {
1079    let codecs: Vec<Box<dyn Codec>> = vec![
1080        #[cfg(feature = "png")]
1081        Box::new(PngCodec),
1082        #[cfg(feature = "gif")]
1083        Box::new(GifCodec),
1084        #[cfg(feature = "jpeg")]
1085        Box::new(JpegCodec),
1086        #[cfg(feature = "tiff")]
1087        Box::new(TiffCodec),
1088        #[cfg(feature = "webp")]
1089        Box::new(WebPCodec),
1090        #[cfg(feature = "avif")]
1091        Box::new(AvifCodec),
1092    ];
1093    codecs
1094}
1095
1096/// Build the encoder for `format`, or report that it is not available.
1097fn encoder_for(format: Format, options: EncodeOptions) -> Result<Box<dyn Encoder>> {
1098    // Only read by codecs that have something to tune; see the comment in
1099    // `from_stream` for why a build with none of them still has to compile.
1100    let _ = &options;
1101    match format {
1102        #[cfg(feature = "raw")]
1103        Format::Raw => Ok(Box::new(RawEncoder::new())),
1104        #[cfg(feature = "png")]
1105        Format::Png => Ok(Box::new(PngEncoder::from_options(&options))),
1106        #[cfg(feature = "gif")]
1107        Format::Gif => Ok(Box::new(GifEncoder::from_options(&options))),
1108        #[cfg(feature = "jpeg")]
1109        Format::Jpeg => Ok(Box::new(JpegEncoder::from_options(&options))),
1110        #[cfg(feature = "tiff")]
1111        Format::Tiff => Ok(Box::new(TiffEncoder::from_options(&options))),
1112        #[cfg(feature = "webp")]
1113        Format::WebP => Ok(Box::new(WebPEncoder::from_options(&options))),
1114        #[cfg(feature = "avif")]
1115        Format::Avif => Ok(Box::new(AvifEncoder::from_options(&options))),
1116        #[cfg(not(feature = "raw"))]
1117        Format::Raw => Err(PixelsError::unsupported(
1118            "raw encoding requires the `raw` feature of otf-pixels",
1119        )),
1120        other => Err(PixelsError::unsupported(format!(
1121            "encoding {other} is not implemented yet; \
1122             see the ROADMAP for which milestone lands it"
1123        ))),
1124    }
1125}
1126
1127// Gated on `raw` because almost every test here reaches pixels through the
1128// raw encoder, which is the only format that can express "these exact bytes".
1129// A build without it has nothing to compare against, so the suite compiles out
1130// rather than asserting less.
1131#[cfg(all(test, feature = "raw"))]
1132#[allow(
1133    clippy::unwrap_used,
1134    clippy::expect_used,
1135    clippy::indexing_slicing,
1136    clippy::panic,
1137    reason = "tests operate on known-good values and assert shapes directly"
1138)]
1139mod tests {
1140    use super::*;
1141
1142    fn ramp(width: u32, height: u32) -> Image {
1143        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Gray8).unwrap();
1144        let len = descriptor.byte_len().unwrap();
1145        Image::from_raw(descriptor, (0..len).map(|i| i as u8).collect()).unwrap()
1146    }
1147
1148    #[test]
1149    fn from_raw_requires_an_exactly_sized_buffer() {
1150        let descriptor = ImageDescriptor::new(2, 2, PixelFormat::Gray8).unwrap();
1151        assert!(Image::from_raw(descriptor, vec![0; 4]).is_ok());
1152        assert_eq!(
1153            Image::from_raw(descriptor, vec![0; 3]).unwrap_err().code(),
1154            ErrorCode::InvalidArgument
1155        );
1156        assert_eq!(
1157            Image::from_raw(descriptor, vec![0; 5]).unwrap_err().code(),
1158            ErrorCode::InvalidArgument
1159        );
1160    }
1161
1162    #[test]
1163    fn a_chain_error_surfaces_at_the_terminal() {
1164        // A crop window outside the image, with more ops chained after it.
1165        let result = ramp(4, 4)
1166            .crop(3, 3, 4, 4)
1167            .flip()
1168            .flop()
1169            .output(Format::Raw, EncodeOptions::default())
1170            .bytes();
1171        let err = result.unwrap_err();
1172        assert_eq!(err.code(), ErrorCode::InvalidArgument);
1173    }
1174
1175    #[test]
1176    fn an_error_short_circuits_later_ops() {
1177        // `crop` fails; `metadata` on the resulting pipeline reports it rather
1178        // than describing a shape that was never built.
1179        let broken = ramp(4, 4).crop(0, 0, 0, 0);
1180        assert!(broken.metadata().is_err());
1181        assert!(broken.clone().flip().descriptor().is_err());
1182    }
1183
1184    #[test]
1185    fn metadata_is_free_and_reports_the_pipeline_shape() {
1186        let meta = ramp(8, 6).metadata().unwrap();
1187        assert_eq!((meta.width, meta.height), (8, 6));
1188        assert_eq!(meta.format, Format::Raw);
1189        assert_eq!(meta.pixel, PixelFormat::Gray8);
1190        // After a crop the shape reflects the crop, still without decoding.
1191        let cropped = ramp(8, 6).crop(1, 1, 3, 2).metadata().unwrap();
1192        assert_eq!((cropped.width, cropped.height), (3, 2));
1193    }
1194
1195    /// Every format encodes once its feature is on (each checked by its own
1196    /// round-trip tests); a build without a format's feature must fail
1197    /// cleanly rather than produce something.
1198    #[cfg(not(feature = "avif"))]
1199    #[test]
1200    fn a_format_built_out_is_a_catchable_error() {
1201        let format = Format::Avif;
1202        let err = ramp(2, 2)
1203            .output(format, EncodeOptions::default())
1204            .bytes()
1205            .unwrap_err();
1206        assert_eq!(err.code(), ErrorCode::Unsupported, "{format}");
1207        assert!(err.to_string().contains(format.as_str()), "{err}");
1208    }
1209
1210    /// TIFF shipped an encoder in M5 but was never added to `encoder_for`, so
1211    /// `output(Format::Tiff, ..)` reported the format unimplemented while the
1212    /// encoder sat in the crate unreachable. This is the test that would have
1213    /// caught it.
1214    #[cfg(feature = "tiff")]
1215    #[test]
1216    fn tiff_round_trips_through_the_facade() {
1217        let bytes = ramp(12, 9)
1218            .output(Format::Tiff, EncodeOptions::default())
1219            .bytes()
1220            .unwrap();
1221        let image = Image::from_stream(std::io::Cursor::new(bytes)).unwrap();
1222        let metadata = image.metadata().unwrap();
1223        assert_eq!(metadata.format, Format::Tiff);
1224        assert_eq!((metadata.width, metadata.height), (12, 9));
1225
1226        // TIFF is lossless, so the pixels must come back exactly.
1227        let decoded = image
1228            .output(Format::Raw, EncodeOptions::default())
1229            .bytes()
1230            .unwrap();
1231        let expected: Vec<u8> = (0..12_u32 * 9).map(|i| i as u8).collect();
1232        assert_eq!(decoded, expected);
1233    }
1234
1235    /// A JPEG written through the facade must be readable back through it,
1236    /// found by sniffing rather than by being told what it is.
1237    #[cfg(feature = "jpeg")]
1238    #[test]
1239    fn jpeg_round_trips_through_the_facade() {
1240        let (width, height) = (32_u32, 24_u32);
1241        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Rgb8).unwrap();
1242        let pixels: Vec<u8> = (0..descriptor.byte_len().unwrap())
1243            .map(|i| {
1244                // A smooth gradient, which survives quantization well enough
1245                // for a tolerance this tight to mean something.
1246                let pixel = i / 3;
1247                let (x, y) = (pixel as u32 % width, pixel as u32 / width);
1248                match i % 3 {
1249                    0 => (x * 255 / width) as u8,
1250                    1 => (y * 255 / height) as u8,
1251                    _ => 128,
1252                }
1253            })
1254            .collect();
1255
1256        let bytes = Image::from_raw(descriptor, pixels.clone())
1257            .unwrap()
1258            .output(Format::Jpeg, EncodeOptions::with_quality(95).unwrap())
1259            .bytes()
1260            .unwrap();
1261
1262        let image = Image::from_stream(std::io::Cursor::new(bytes.clone())).unwrap();
1263        let metadata = image.metadata().unwrap();
1264        assert_eq!(metadata.format, Format::Jpeg, "sniffing missed the JPEG");
1265        assert_eq!((metadata.width, metadata.height), (width, height));
1266        assert_eq!(metadata.pixel, PixelFormat::Rgb8);
1267
1268        let decoded = image
1269            .output(Format::Raw, EncodeOptions::default())
1270            .bytes()
1271            .unwrap();
1272        assert_eq!(decoded.len(), pixels.len());
1273        let worst = decoded
1274            .iter()
1275            .zip(&pixels)
1276            .map(|(&a, &b)| a.abs_diff(b))
1277            .max()
1278            .unwrap_or(0);
1279        assert!(worst <= 12, "worst sample differs by {worst}");
1280    }
1281
1282    /// Overlapping band demand, which a resize always produces, must give the
1283    /// same pixels through the scheduler as through the reference evaluator.
1284    ///
1285    /// This is the regression test for a bug that made every streaming decode
1286    /// resized to more than one tile column fail outright: consecutive
1287    /// requests to a forward-only source overlap by the resize filter's
1288    /// support, and those rows are already past the stream cursor. Getting an
1289    /// answer at all is half of it; the other half is that the rows carried
1290    /// over from the retained band are the right ones, which only a comparison
1291    /// against the oracle can show.
1292    #[cfg(feature = "jpeg")]
1293    #[test]
1294    fn a_streaming_resize_matches_the_reference_evaluator() {
1295        for &(width, height, target) in &[
1296            (512_u32, 512_u32, 300_u32),
1297            (512, 512, 256),
1298            (256, 256, 200),
1299            (320, 240, 129),
1300            (64, 64, 16),
1301        ] {
1302            let source = jpeg_source(width, height);
1303            let scheduled = Image::from_stream(std::io::Cursor::new(source.clone()))
1304                .unwrap()
1305                .resize(target, target)
1306                .output(Format::Raw, EncodeOptions::default())
1307                .bytes()
1308                .unwrap_or_else(|e| panic!("{width}x{height} -> {target}: {e}"));
1309            let reference = Image::from_stream(std::io::Cursor::new(source))
1310                .unwrap()
1311                .resize(target, target)
1312                .output(Format::Raw, EncodeOptions::default())
1313                .bytes_via_reference()
1314                .unwrap();
1315
1316            assert_eq!(
1317                scheduled.len(),
1318                (target * target * 3) as usize,
1319                "{width}x{height} -> {target}: size"
1320            );
1321            assert_eq!(
1322                scheduled, reference,
1323                "{width}x{height} -> {target}: the scheduler and the oracle disagree"
1324            );
1325        }
1326    }
1327
1328    /// WebP round-trips through the facade, found by sniffing rather than by
1329    /// being told the format.
1330    ///
1331    /// Exact, because our WebP encoder is lossless — the one place in the
1332    /// codec set where a wrapped encoder still permits an exact assertion.
1333    #[cfg(feature = "webp")]
1334    #[test]
1335    fn webp_round_trips_through_the_facade() {
1336        let bytes = ramp(20, 12)
1337            .output(Format::WebP, EncodeOptions::default().with_lossless(true))
1338            .bytes()
1339            .unwrap();
1340        let image = Image::from_stream(std::io::Cursor::new(bytes)).unwrap();
1341        let metadata = image.metadata().unwrap();
1342        assert_eq!(metadata.format, Format::WebP, "sniffing missed the WebP");
1343        assert_eq!((metadata.width, metadata.height), (20, 12));
1344        // WebP has no greyscale mode, so one channel in comes back as three.
1345        assert_eq!(metadata.pixel, PixelFormat::Rgb8);
1346
1347        let decoded = image
1348            .output(Format::Raw, EncodeOptions::default())
1349            .bytes()
1350            .unwrap();
1351        let expected: Vec<u8> = (0..20_u32 * 12)
1352            .flat_map(|i| {
1353                let value = i as u8;
1354                [value, value, value]
1355            })
1356            .collect();
1357        assert_eq!(decoded, expected, "a lossless round trip lost pixels");
1358    }
1359
1360    /// Alpha survives a WebP round trip, which is the reason to reach for the
1361    /// format over JPEG in the first place.
1362    #[cfg(feature = "webp")]
1363    #[test]
1364    fn webp_keeps_alpha_through_the_facade() {
1365        let descriptor = ImageDescriptor::new(9, 7, PixelFormat::Rgba8).unwrap();
1366        let pixels: Vec<u8> = (0..(9 * 7))
1367            .flat_map(|i| [(i * 3) as u8, 40, 200, (i * 7) as u8])
1368            .collect();
1369        let bytes = Image::from_raw(descriptor, pixels.clone())
1370            .unwrap()
1371            .output(Format::WebP, EncodeOptions::default().with_lossless(true))
1372            .bytes()
1373            .unwrap();
1374
1375        let image = Image::from_stream(std::io::Cursor::new(bytes)).unwrap();
1376        assert_eq!(image.metadata().unwrap().pixel, PixelFormat::Rgba8);
1377        let decoded = image
1378            .output(Format::Raw, EncodeOptions::default())
1379            .bytes()
1380            .unwrap();
1381        assert_eq!(decoded, pixels);
1382    }
1383
1384    /// WebP output is lossy by default, at the requested quality: close to
1385    /// the source, smaller as the quality drops, and with its alpha intact,
1386    /// since WebP codes alpha losslessly even in a lossy file.
1387    #[cfg(feature = "webp")]
1388    #[test]
1389    fn webp_is_lossy_by_default_and_keeps_alpha_exact() {
1390        let (w, h) = (48_u32, 32_u32);
1391        let descriptor = ImageDescriptor::new(w, h, PixelFormat::Rgba8).unwrap();
1392        let pixels: Vec<u8> = (0..w * h)
1393            .flat_map(|i| {
1394                let (x, y) = (i % w, i / w);
1395                [(x * 5) as u8, (y * 7) as u8, 120, (x * 3 + y) as u8]
1396            })
1397            .collect();
1398        let encode = |options: EncodeOptions| {
1399            Image::from_raw(descriptor, pixels.clone())
1400                .unwrap()
1401                .output(Format::WebP, options)
1402                .bytes()
1403                .unwrap()
1404        };
1405        let good = encode(EncodeOptions::default());
1406        let rough = encode(EncodeOptions::with_quality(10).unwrap());
1407        assert!(
1408            rough.len() < good.len(),
1409            "{} vs {}",
1410            rough.len(),
1411            good.len()
1412        );
1413
1414        let decoded = Image::from_stream(std::io::Cursor::new(good))
1415            .unwrap()
1416            .output(Format::Raw, EncodeOptions::default())
1417            .bytes()
1418            .unwrap();
1419        for (ours, source) in decoded.chunks_exact(4).zip(pixels.chunks_exact(4)) {
1420            assert_eq!(ours[3], source[3], "alpha changed");
1421            for c in 0..3 {
1422                assert!(ours[c].abs_diff(source[c]) <= 12, "{ours:?} vs {source:?}");
1423            }
1424        }
1425    }
1426
1427    /// Shrink-on-load, end to end: a thumbnail pipeline must decode the JPEG
1428    /// at a reduced scale rather than at full size and then throw it away.
1429    #[cfg(feature = "jpeg")]
1430    #[test]
1431    fn a_thumbnail_pipeline_shrinks_the_jpeg_on_load() {
1432        // 512x512 down to 32x32: 1/8 covers it exactly.
1433        let source = jpeg_source(512, 512);
1434
1435        let image = Image::from_stream(std::io::Cursor::new(source)).unwrap();
1436        assert_eq!(
1437            image.metadata().unwrap().width,
1438            512,
1439            "metadata is full size"
1440        );
1441
1442        // Evaluated once: a stream-backed graph cannot be run twice, because
1443        // the bytes are gone the first time.
1444        let mut bytes = Vec::new();
1445        let stats = image
1446            .resize(32, 32)
1447            .output(Format::Raw, EncodeOptions::default())
1448            .write_with_stats(&mut bytes)
1449            .unwrap();
1450
1451        let reduction = stats
1452            .reduction
1453            .expect("a 512 to 32 resize should shrink the source on load");
1454        assert_eq!(reduction.from, (512, 512));
1455        assert_eq!(
1456            reduction.to,
1457            (64, 64),
1458            "1/8 is the coarsest scale that still covers 32"
1459        );
1460        assert!((reduction.factor() - 64.0).abs() < 0.01);
1461        // And the pixels are still there, at the size that was asked for.
1462        assert_eq!(bytes.len(), 32 * 32 * 3);
1463    }
1464
1465    /// A crop names coordinates in source pixels, so shrinking underneath it
1466    /// would return a different part of the picture.
1467    #[cfg(feature = "jpeg")]
1468    #[test]
1469    fn a_crop_blocks_shrink_on_load() {
1470        let source = jpeg_source(512, 512);
1471        let stats = Image::from_stream(std::io::Cursor::new(source))
1472            .unwrap()
1473            .crop(256, 256, 128, 128)
1474            .resize(32, 32)
1475            .output(Format::Raw, EncodeOptions::default())
1476            .write_with_stats(&mut Vec::new())
1477            .unwrap();
1478        assert!(
1479            stats.reduction.is_none(),
1480            "a crop must block shrink-on-load: {:?}",
1481            stats.reduction
1482        );
1483    }
1484
1485    /// Cropping *after* a resize is fine — the crop is then in the resized
1486    /// image's coordinates, which the reduction does not move.
1487    #[cfg(feature = "jpeg")]
1488    #[test]
1489    fn a_crop_after_the_resize_still_allows_shrinking() {
1490        let source = jpeg_source(512, 512);
1491        let stats = Image::from_stream(std::io::Cursor::new(source))
1492            .unwrap()
1493            .resize(64, 64)
1494            .crop(0, 0, 32, 32)
1495            .output(Format::Raw, EncodeOptions::default())
1496            .write_with_stats(&mut Vec::new())
1497            .unwrap();
1498        // The crop is still not scale-covariant, so this conservatively does
1499        // not fire. Recorded as the current behaviour rather than asserted as
1500        // desirable: a crop below a resize could be allowed, and is not.
1501        assert!(stats.reduction.is_none());
1502    }
1503
1504    /// A JPEG whose pipeline only resizes a little must not be shrunk past
1505    /// what it needs.
1506    #[cfg(feature = "jpeg")]
1507    #[test]
1508    fn a_mild_resize_shrinks_by_less_or_not_at_all() {
1509        let source = jpeg_source(512, 512);
1510        // 300 needs more than half of 512, so no scale fits.
1511        let stats = Image::from_stream(std::io::Cursor::new(source.clone()))
1512            .unwrap()
1513            .resize(300, 300)
1514            .output(Format::Raw, EncodeOptions::default())
1515            .write_with_stats(&mut Vec::new())
1516            .unwrap();
1517        assert!(stats.reduction.is_none(), "{:?}", stats.reduction);
1518
1519        // 200 fits inside 1/2 (256) but not 1/4 (128).
1520        let stats = Image::from_stream(std::io::Cursor::new(source))
1521            .unwrap()
1522            .resize(200, 200)
1523            .output(Format::Raw, EncodeOptions::default())
1524            .write_with_stats(&mut Vec::new())
1525            .unwrap();
1526        assert_eq!(stats.reduction.map(|r| r.to), Some((256, 256)));
1527    }
1528
1529    /// A JPEG of `size` square, as bytes.
1530    #[cfg(feature = "jpeg")]
1531    fn jpeg_source(width: u32, height: u32) -> Vec<u8> {
1532        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Rgb8).unwrap();
1533        let pixels: Vec<u8> = (0..descriptor.byte_len().unwrap())
1534            .map(|i| {
1535                let pixel = i / 3;
1536                let (x, y) = (pixel as u32 % width, pixel as u32 / width);
1537                match i % 3 {
1538                    0 => (x * 255 / width) as u8,
1539                    1 => (y * 255 / height) as u8,
1540                    _ => 96,
1541                }
1542            })
1543            .collect();
1544        Image::from_raw(descriptor, pixels)
1545            .unwrap()
1546            .output(Format::Jpeg, EncodeOptions::with_quality(85).unwrap())
1547            .bytes()
1548            .unwrap()
1549    }
1550
1551    /// The pipeline a thumbnail actually is: decode, resize, encode.
1552    #[cfg(feature = "jpeg")]
1553    #[test]
1554    fn a_jpeg_pipeline_resizes_and_re_encodes() {
1555        let descriptor = ImageDescriptor::new(64, 64, PixelFormat::Rgb8).unwrap();
1556        let pixels: Vec<u8> = (0..descriptor.byte_len().unwrap())
1557            .map(|i| ((i / 3) % 251) as u8)
1558            .collect();
1559        let source = Image::from_raw(descriptor, pixels)
1560            .unwrap()
1561            .output(Format::Jpeg, EncodeOptions::default())
1562            .bytes()
1563            .unwrap();
1564
1565        let thumbnail = Image::from_stream(std::io::Cursor::new(source.clone()))
1566            .unwrap()
1567            .resize(16, 16)
1568            .output(Format::Jpeg, EncodeOptions::with_quality(70).unwrap())
1569            .bytes()
1570            .unwrap();
1571
1572        let metadata = Image::from_stream(std::io::Cursor::new(thumbnail.clone()))
1573            .unwrap()
1574            .metadata()
1575            .unwrap();
1576        assert_eq!((metadata.width, metadata.height), (16, 16));
1577        assert!(
1578            thumbnail.len() < source.len(),
1579            "a 16x16 thumbnail is {} bytes against a 64x64 source's {}",
1580            thumbnail.len(),
1581            source.len()
1582        );
1583    }
1584
1585    /// Greyscale must stay greyscale through the facade rather than being
1586    /// widened to RGB on the way out.
1587    #[cfg(feature = "jpeg")]
1588    #[test]
1589    fn grayscale_jpeg_keeps_one_channel_through_the_facade() {
1590        let bytes = ramp(24, 16)
1591            .output(Format::Jpeg, EncodeOptions::with_quality(90).unwrap())
1592            .bytes()
1593            .unwrap();
1594        let metadata = Image::from_stream(std::io::Cursor::new(bytes))
1595            .unwrap()
1596            .metadata()
1597            .unwrap();
1598        assert_eq!(metadata.pixel, PixelFormat::Gray8);
1599        assert_eq!((metadata.width, metadata.height), (24, 16));
1600    }
1601
1602    #[test]
1603    fn write_streams_into_any_sink() {
1604        let mut sink = Vec::new();
1605        ramp(2, 2)
1606            .output(Format::Raw, EncodeOptions::default())
1607            .write(&mut sink)
1608            .unwrap();
1609        assert_eq!(sink, [0, 1, 2, 3]);
1610    }
1611
1612    #[test]
1613    fn output_reports_its_settings() {
1614        let options = EncodeOptions::with_quality(55).unwrap();
1615        let output = ramp(2, 2).output(Format::Raw, options);
1616        assert_eq!(output.format(), Format::Raw);
1617        assert_eq!(output.options().quality, 55);
1618    }
1619
1620    #[cfg(feature = "raw")]
1621    #[test]
1622    fn from_raw_stream_defers_decoding_to_the_terminal() {
1623        let descriptor = ImageDescriptor::new(2, 2, PixelFormat::Gray8).unwrap();
1624        let layout = RawFormat::packed(descriptor);
1625        let cursor = std::io::Cursor::new(vec![1_u8, 2, 3, 4]);
1626        let image = Image::from_raw_stream(layout, cursor).unwrap();
1627        // Header facts are available without reading pixels.
1628        assert_eq!(image.metadata().unwrap().width, 2);
1629        let bytes = image
1630            .output(Format::Raw, EncodeOptions::default())
1631            .bytes()
1632            .unwrap();
1633        assert_eq!(bytes, [1, 2, 3, 4]);
1634    }
1635
1636    #[cfg(feature = "raw")]
1637    #[test]
1638    fn a_truncated_stream_fails_the_terminal_without_panicking() {
1639        let descriptor = ImageDescriptor::new(4, 4, PixelFormat::Gray8).unwrap();
1640        let layout = RawFormat::packed(descriptor);
1641        let cursor = std::io::Cursor::new(vec![1_u8; 5]);
1642        let image = Image::from_raw_stream(layout, cursor).unwrap();
1643        let err = image
1644            .output(Format::Raw, EncodeOptions::default())
1645            .bytes()
1646            .unwrap_err();
1647        assert_eq!(err.code(), ErrorCode::Malformed);
1648    }
1649
1650    #[test]
1651    fn images_are_send_sync_and_cheap_to_clone() {
1652        const fn assert_send_sync<T: Send + Sync>() {}
1653        assert_send_sync::<Image>();
1654        assert_send_sync::<Output>();
1655        let image = ramp(4, 4);
1656        let clone = image.clone();
1657        assert_eq!(
1658            image.metadata().unwrap().width,
1659            clone.metadata().unwrap().width
1660        );
1661    }
1662
1663    #[cfg(feature = "png")]
1664    #[test]
1665    fn a_png_round_trips_through_the_facade() {
1666        let png = ramp(37, 21)
1667            .output(Format::Png, EncodeOptions::default())
1668            .bytes()
1669            .unwrap();
1670        let image = Image::from_stream(std::io::Cursor::new(png)).unwrap();
1671        assert_eq!(image.descriptor().unwrap().width, 37);
1672        assert_eq!(image.metadata().unwrap().format, Format::Png);
1673
1674        let back = image
1675            .output(Format::Raw, EncodeOptions::default())
1676            .bytes()
1677            .unwrap();
1678        let expected = ramp(37, 21)
1679            .output(Format::Raw, EncodeOptions::default())
1680            .bytes()
1681            .unwrap();
1682        assert_eq!(back, expected, "pixels changed across a PNG round trip");
1683    }
1684
1685    #[cfg(feature = "png")]
1686    #[test]
1687    fn a_pipeline_survives_a_png_round_trip() {
1688        // The point of sniffing is that a decoded image is an ordinary graph
1689        // source, so ops compose over it exactly as over raw pixels.
1690        let png = ramp(16, 16)
1691            .output(Format::Png, EncodeOptions::default())
1692            .bytes()
1693            .unwrap();
1694        let cropped = Image::from_stream(std::io::Cursor::new(png))
1695            .unwrap()
1696            .crop(2, 3, 8, 5)
1697            .flip()
1698            .output(Format::Raw, EncodeOptions::default())
1699            .bytes()
1700            .unwrap();
1701        let direct = ramp(16, 16)
1702            .crop(2, 3, 8, 5)
1703            .flip()
1704            .output(Format::Raw, EncodeOptions::default())
1705            .bytes()
1706            .unwrap();
1707        assert_eq!(cropped, direct);
1708    }
1709
1710    #[cfg(feature = "png")]
1711    #[test]
1712    fn open_ignores_the_extension_and_reads_the_bytes() {
1713        let dir = std::env::temp_dir().join(format!("otf-pixels-open-{}", std::process::id()));
1714        std::fs::create_dir_all(&dir).unwrap();
1715        let path = dir.join("actually-a-png.jpg");
1716        let png = ramp(8, 8)
1717            .output(Format::Png, EncodeOptions::default())
1718            .bytes()
1719            .unwrap();
1720        std::fs::write(&path, &png).unwrap();
1721
1722        let image = Image::open(&path).unwrap();
1723        assert_eq!(
1724            image.metadata().unwrap().format,
1725            Format::Png,
1726            "extension won over content"
1727        );
1728        std::fs::remove_dir_all(&dir).ok();
1729    }
1730
1731    #[test]
1732    fn open_names_the_file_it_could_not_read() {
1733        let error = Image::open("/nonexistent/otf-pixels/missing.png").unwrap_err();
1734        assert_eq!(error.code(), ErrorCode::Io);
1735        assert!(error.to_string().contains("missing.png"), "{error}");
1736    }
1737
1738    #[test]
1739    fn an_unrecognised_stream_is_unsupported_not_a_guess() {
1740        for bytes in [&b""[..], &b"not an image"[..], &[0_u8; 64][..]] {
1741            let error = Image::from_stream(std::io::Cursor::new(bytes.to_vec())).unwrap_err();
1742            assert_eq!(error.code(), ErrorCode::Unsupported, "{bytes:02x?}");
1743        }
1744    }
1745
1746    #[cfg(feature = "png")]
1747    #[test]
1748    fn a_truncated_png_is_malformed_not_a_panic() {
1749        let png = ramp(8, 8)
1750            .output(Format::Png, EncodeOptions::default())
1751            .bytes()
1752            .unwrap();
1753        // Past the signature, so sniffing succeeds and the header parse is
1754        // what has to fail cleanly.
1755        for cut in [9, 16, 24, 32, png.len() - 1] {
1756            let truncated = png[..cut].to_vec();
1757            let result = Image::from_stream(std::io::Cursor::new(truncated))
1758                .and_then(|i| i.output(Format::Raw, EncodeOptions::default()).bytes());
1759            assert!(result.is_err(), "truncating to {cut} bytes should fail");
1760        }
1761    }
1762
1763    #[cfg(feature = "png")]
1764    #[test]
1765    fn sniffing_reads_only_the_magic_bytes_before_deciding() {
1766        // A stream that is exactly the signature and nothing else must be
1767        // recognised as PNG and then fail on its missing header, proving the
1768        // sniff does not need — or read — more than the magic.
1769        let error = Image::from_stream(std::io::Cursor::new(
1770            otf_pixels_codec_png::SIGNATURE.to_vec(),
1771        ))
1772        .unwrap_err();
1773        assert_eq!(error.code(), ErrorCode::Malformed, "{error}");
1774    }
1775}
1776
1777/// The handles an embedder shares across threads must stay shareable: a
1778/// runtime builds a pipeline on one thread and runs it on a worker.
1779const _: () = {
1780    const fn shareable<T: Send + Sync>() {}
1781    shareable::<Image>();
1782    shareable::<Output>();
1783    shareable::<OpenOptions>();
1784    shareable::<PixelsError>();
1785    shareable::<Scheduler>();
1786};