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