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