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