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