otf_pixels_core/codec.rs
1//! Container formats and the codec traits every format plugs in behind.
2//!
3//! Ownership per format — implemented from scratch versus wrapped — is
4//! recorded in ADR-0004 and is invisible here on purpose: everything sits
5//! behind [`Decoder`] and [`Encoder`], so a format can be rewritten without an
6//! API change.
7
8use crate::{
9 ImageDescriptor, Orientation, PixelFormat, PixelsError, Region, Result, Sink, TileMut,
10};
11use core::fmt;
12
13/// A container format.
14///
15/// Format is **data**, not a type parameter: `output(format, options)` is the
16/// single encode terminal, which keeps the API surface stable as formats are
17/// added and maps cleanly onto host bindings (ADR-0006). Formats not yet
18/// implemented are still nameable, so an unsupported request is a catchable
19/// [`PixelsError::Unsupported`] rather than a compile error in a host binding.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum Format {
23 /// Uncompressed pixels described entirely by the caller.
24 Raw,
25 /// Portable Network Graphics.
26 Png,
27 /// JPEG.
28 Jpeg,
29 /// Graphics Interchange Format.
30 Gif,
31 /// Tagged Image File Format.
32 Tiff,
33 /// WebP.
34 WebP,
35 /// AV1 Image File Format.
36 Avif,
37}
38
39impl Format {
40 /// A short, stable, lowercase name for this format.
41 #[must_use]
42 pub const fn as_str(self) -> &'static str {
43 match self {
44 Self::Raw => "raw",
45 Self::Png => "png",
46 Self::Jpeg => "jpeg",
47 Self::Gif => "gif",
48 Self::Tiff => "tiff",
49 Self::WebP => "webp",
50 Self::Avif => "avif",
51 }
52 }
53}
54
55impl fmt::Display for Format {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 f.write_str(self.as_str())
58 }
59}
60
61/// What a decoder can produce without a full decode.
62///
63/// The scheduler uses this to decide whether it may request arbitrary regions
64/// from a source or must pull rows in order (ARCHITECTURE §Layer 2).
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66#[non_exhaustive]
67pub enum DecodeCapability {
68 /// Emits rows in top-to-bottom order as bytes arrive.
69 ///
70 /// Out-of-order region requests are not supported; the engine reads
71 /// forward. This covers non-interlaced PNG, baseline JPEG, GIF frames,
72 /// strip TIFF and raw.
73 Sequential,
74 /// Can produce an arbitrary region without decoding the whole image.
75 ///
76 /// This covers tiled TIFF, and is what makes the giant-image thumbnail
77 /// path constant-memory.
78 ///
79 /// A JPEG decoding at a reduced `M/8` scale is *not* this, though an
80 /// earlier version of this comment said it was: scaled decode lowers the
81 /// resolution but still emits rows in order, and every coefficient is
82 /// still entropy-decoded. It is a decoder configuration, not a capability,
83 /// and claiming otherwise would be a lie the scheduler acts on.
84 Regions,
85}
86
87/// How an animated image plays: its frames and their timing.
88///
89/// Reported by [`Decoder::animation`] for a file with more than one frame.
90/// The pipeline processes the first frame; this describes the source, so a
91/// caller can see it is animated and decide what to do (pass it through,
92/// refuse it, or take the still).
93#[derive(Debug, Clone, PartialEq, Eq, Hash)]
94#[non_exhaustive]
95pub struct Animation {
96 /// Frames in the animation, always two or more.
97 pub frame_count: u32,
98 /// How many times it plays; 0 means forever, as in GIF and WebP.
99 pub loop_count: u32,
100 /// How long each frame shows, in milliseconds, in order.
101 pub frame_durations_ms: Vec<u32>,
102}
103
104impl Animation {
105 /// An animation of the given frame durations, playing `loop_count`
106 /// times (0 for forever). `None` for fewer than two frames, which is a
107 /// still image.
108 #[must_use]
109 pub fn new(frame_durations_ms: Vec<u32>, loop_count: u32) -> Option<Self> {
110 let frame_count = u32::try_from(frame_durations_ms.len()).ok()?;
111 (frame_count >= 2).then_some(Self {
112 frame_count,
113 loop_count,
114 frame_durations_ms,
115 })
116 }
117
118 /// The total time one play-through takes, in milliseconds.
119 #[must_use]
120 pub fn duration_ms(&self) -> u64 {
121 self.frame_durations_ms.iter().map(|&d| u64::from(d)).sum()
122 }
123}
124
125/// Header-only facts about an image.
126///
127/// Answering this must not decode pixels (SPEC §Guarantees 3).
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
129#[non_exhaustive]
130pub struct Metadata {
131 /// Width in pixels.
132 pub width: u32,
133 /// Height in pixels.
134 pub height: u32,
135 /// The container format the image was read from.
136 pub format: Format,
137 /// The pixel format the decoder will produce.
138 pub pixel: PixelFormat,
139}
140
141impl Metadata {
142 /// Build metadata from a decoded header descriptor and its container.
143 #[must_use]
144 pub const fn new(desc: &ImageDescriptor, format: Format) -> Self {
145 Self {
146 width: desc.width,
147 height: desc.height,
148 format,
149 pixel: desc.pixel,
150 }
151 }
152}
153
154/// Encoder tuning shared across formats.
155///
156/// Fields that a format has no notion of are ignored by that format rather
157/// than rejected, so the same options value can be handed to any encoder.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159#[non_exhaustive]
160pub struct EncodeOptions {
161 /// Lossy quality, 1–100. Ignored by lossless formats, and by a format
162 /// with both modes when `lossless` is set.
163 pub quality: u8,
164 /// For a format that has both a lossy and a lossless mode (WebP), choose
165 /// lossless. Off by default: such a format encodes lossy at `quality`,
166 /// as `cwebp` and sharp do. Formats with one mode ignore it.
167 pub lossless: bool,
168}
169
170impl EncodeOptions {
171 /// The default quality used when none is given.
172 pub const DEFAULT_QUALITY: u8 = 80;
173
174 /// Options with the given quality.
175 ///
176 /// # Errors
177 ///
178 /// Returns [`PixelsError::InvalidArgument`] unless `quality` is in 1..=100.
179 pub fn with_quality(quality: u8) -> Result<Self> {
180 if !(1..=100).contains(&quality) {
181 return Err(PixelsError::invalid_argument(
182 "quality",
183 format!("must be in 1..=100, got {quality}"),
184 ));
185 }
186 Ok(Self {
187 quality,
188 ..Self::default()
189 })
190 }
191
192 /// These options with `lossless` replaced.
193 #[must_use]
194 pub const fn with_lossless(mut self, lossless: bool) -> Self {
195 self.lossless = lossless;
196 self
197 }
198}
199
200impl Default for EncodeOptions {
201 fn default() -> Self {
202 Self {
203 quality: Self::DEFAULT_QUALITY,
204 lossless: false,
205 }
206 }
207}
208
209/// Format sniffing.
210///
211/// Detection is by **magic bytes only** — extensions and MIME types are
212/// ignored (SPEC §Formats), because they are attacker-controlled hints rather
213/// than facts about the bytes.
214pub trait Codec {
215 /// The format this codec handles.
216 fn format(&self) -> Format;
217
218 /// The number of leading bytes [`Codec::probe`] needs to decide.
219 fn magic_len(&self) -> usize;
220
221 /// Whether `prefix` looks like this format.
222 ///
223 /// `prefix` may be shorter than [`Codec::magic_len`] if the source ended
224 /// early; an implementation must then return `false`, never index blindly.
225 fn probe(&self, prefix: &[u8]) -> bool;
226}
227
228/// Decodes a byte stream into rows of pixels.
229///
230/// Implementations must return [`PixelsError::Malformed`] for **every** input
231/// they cannot parse. Panicking on hostile bytes is a defect, not a caller
232/// error (ARCHITECTURE §Failure model); every parser is fuzzed in CI from the
233/// first codec onward.
234///
235/// Dimension limits are enforced at header parse, before any pixel buffer is
236/// allocated, so a header claiming enormous dimensions costs nothing.
237pub trait Decoder: Send + fmt::Debug {
238 /// The shape of the image, known after the header is parsed.
239 fn descriptor(&self) -> ImageDescriptor;
240
241 /// What this decoder can produce without a full decode.
242 fn capability(&self) -> DecodeCapability {
243 DecodeCapability::Sequential
244 }
245
246 /// How the stored image must be turned to display upright, as its
247 /// metadata declares.
248 ///
249 /// Reported, never applied: rows come out in stored order whatever this
250 /// says. Applying it is the pipeline's `auto_orient` decision (SPEC
251 /// §Safety and limits), which a decoder that rotated its own output would
252 /// take away from the caller. Known after the header is parsed, like
253 /// [`Decoder::descriptor`].
254 fn orientation(&self) -> Orientation {
255 Orientation::Normal
256 }
257
258 /// The source's animation, if it has more than one frame.
259 ///
260 /// Reported, like [`Decoder::orientation`]: rows are always the first
261 /// frame's. Known once the decoder is constructed; a format that must
262 /// read further to count its frames does so up front.
263 fn animation(&self) -> Option<Animation> {
264 None
265 }
266
267 /// The embedded ICC colour profile, if the file carries one.
268 ///
269 /// Reported, never applied, like [`Decoder::orientation`]: samples come
270 /// out as stored, and converting them is the pipeline's decision. Known
271 /// after the header is parsed; a profile stored after the image data is
272 /// not seen.
273 fn icc_profile(&self) -> Option<&[u8]> {
274 None
275 }
276
277 /// Decode the next row, top to bottom, into `out`.
278 ///
279 /// `out` is exactly [`ImageDescriptor::row_bytes`] long. Each call advances
280 /// the cursor by one row; the caller reads `descriptor().height` rows.
281 ///
282 /// # Errors
283 ///
284 /// Returns [`PixelsError::Malformed`] on invalid or truncated input,
285 /// [`PixelsError::Io`] on source failure, or
286 /// [`PixelsError::InvalidArgument`] if `out` is the wrong length or every
287 /// row has already been read.
288 fn read_row(&mut self, out: &mut [u8]) -> Result<()>;
289
290 /// Decode an arbitrary region into `out`.
291 ///
292 /// Only meaningful when [`Decoder::capability`] is
293 /// [`DecodeCapability::Regions`]; the default implementation reports that
294 /// this decoder is sequential.
295 ///
296 /// # Errors
297 ///
298 /// Returns [`PixelsError::Unsupported`] for sequential decoders, and
299 /// otherwise as [`Decoder::read_row`].
300 fn read_region(&mut self, region: Region, out: &mut TileMut<'_>) -> Result<()> {
301 let _ = (region, out);
302 Err(PixelsError::unsupported(
303 "this decoder is sequential; region decode requires DecodeCapability::Regions",
304 ))
305 }
306
307 /// What this decoder would produce if asked for `target` or larger, when
308 /// the format lets it reach that size more cheaply than a full decode.
309 ///
310 /// JPEG is the motivating case — the low-frequency corner of a DCT block
311 /// is a smaller version of that block, so 1/8, 1/4 and 1/2 come almost
312 /// free — but nothing here is JPEG-specific: a pyramidal TIFF or a WebP
313 /// with scaled decode fits the same shape.
314 ///
315 /// **Pure**: nothing is committed. The planner asks before it knows
316 /// whether the reduction is legal for the pipeline as a whole.
317 ///
318 /// The returned descriptor is never smaller than `target` in either axis.
319 fn reduced_descriptor(&self, target: (u32, u32)) -> Option<ImageDescriptor> {
320 let _ = target;
321 None
322 }
323
324 /// Commit to producing `descriptor`, which
325 /// [`Decoder::reduced_descriptor`] must have returned.
326 ///
327 /// # Errors
328 ///
329 /// Returns [`PixelsError::Unsupported`] if this decoder has one
330 /// resolution, or [`PixelsError::InvalidArgument`] if any row has already
331 /// been read — the resolution is fixed from the first row onward.
332 fn reduce_to(&mut self, descriptor: ImageDescriptor) -> Result<()> {
333 let _ = descriptor;
334 Err(PixelsError::unsupported(
335 "this decoder has only one resolution",
336 ))
337 }
338}
339
340/// Encodes rows of pixels into a byte stream.
341///
342/// The sink is passed to each call rather than held by the encoder, which
343/// keeps [`Encoder`] object-safe and lets the caller retain ownership of the
344/// destination. Encoders write incrementally as rows arrive; they must not
345/// buffer the whole image unless the format leaves no choice (ADR-0005).
346pub trait Encoder: Send {
347 /// Embed `profile` as the image's ICC colour profile, or write none.
348 ///
349 /// Called before [`Encoder::write_header`], whose header usually holds
350 /// it. The default ignores it, which is right for a format with nowhere
351 /// to put one (raw pixels, GIF): the pixels are written as given either
352 /// way.
353 ///
354 /// # Errors
355 ///
356 /// Returns [`PixelsError::InvalidArgument`] if the header has already
357 /// been written.
358 fn set_icc_profile(&mut self, profile: Option<&[u8]>) -> Result<()> {
359 let _ = profile;
360 Ok(())
361 }
362
363 /// Begin an image, writing any container header.
364 ///
365 /// Must be called exactly once, before any [`Encoder::write_row`].
366 ///
367 /// # Errors
368 ///
369 /// Returns [`PixelsError::Io`] on sink failure, or
370 /// [`PixelsError::Unsupported`] if the encoder cannot represent `desc`
371 /// (for example an alpha channel in a format without one).
372 fn write_header(&mut self, desc: &ImageDescriptor, sink: &mut dyn Sink) -> Result<()>;
373
374 /// Write the next row, top to bottom.
375 ///
376 /// `row` is exactly [`ImageDescriptor::row_bytes`] long.
377 ///
378 /// # Errors
379 ///
380 /// Returns [`PixelsError::Io`] on sink failure, or
381 /// [`PixelsError::InvalidArgument`] if `row` is the wrong length or more
382 /// rows are written than the header declared.
383 fn write_row(&mut self, row: &[u8], sink: &mut dyn Sink) -> Result<()>;
384
385 /// Finish the image, writing any trailer and flushing the sink.
386 ///
387 /// # Errors
388 ///
389 /// Returns [`PixelsError::Io`] on sink failure, or
390 /// [`PixelsError::Malformed`] if fewer rows were written than the header
391 /// declared — a partial image is never silently emitted
392 /// (ARCHITECTURE §Failure model).
393 fn finish(&mut self, sink: &mut dyn Sink) -> Result<()>;
394}
395
396#[cfg(test)]
397#[allow(
398 clippy::unwrap_used,
399 clippy::indexing_slicing,
400 reason = "tests operate on known-good values and assert shapes directly"
401)]
402mod tests {
403 use super::*;
404 use crate::ErrorCode;
405
406 #[test]
407 fn quality_is_validated_at_the_boundary() {
408 assert_eq!(EncodeOptions::with_quality(1).unwrap().quality, 1);
409 assert_eq!(EncodeOptions::with_quality(100).unwrap().quality, 100);
410 assert_eq!(
411 EncodeOptions::with_quality(0).unwrap_err().code(),
412 ErrorCode::InvalidArgument
413 );
414 assert_eq!(
415 EncodeOptions::with_quality(101).unwrap_err().code(),
416 ErrorCode::InvalidArgument
417 );
418 assert_eq!(EncodeOptions::default().quality, 80);
419 }
420
421 #[test]
422 fn format_names_are_stable_and_unique() {
423 let all = [
424 Format::Raw,
425 Format::Png,
426 Format::Jpeg,
427 Format::Gif,
428 Format::Tiff,
429 Format::WebP,
430 Format::Avif,
431 ];
432 let mut seen = std::collections::HashSet::new();
433 for f in all {
434 assert!(seen.insert(f.as_str()), "duplicate name {f}");
435 }
436 assert_eq!(Format::WebP.as_str(), "webp");
437 }
438
439 #[test]
440 fn metadata_mirrors_the_descriptor() {
441 let desc = ImageDescriptor::new(7, 5, PixelFormat::Rgba8).unwrap();
442 let meta = Metadata::new(&desc, Format::Png);
443 assert_eq!((meta.width, meta.height), (7, 5));
444 assert_eq!(meta.pixel, PixelFormat::Rgba8);
445 assert_eq!(meta.format, Format::Png);
446 }
447
448 #[test]
449 fn sequential_decoders_decline_region_decode() {
450 #[derive(Debug)]
451 struct Seq;
452 impl Decoder for Seq {
453 fn descriptor(&self) -> ImageDescriptor {
454 ImageDescriptor::new(1, 1, PixelFormat::Gray8).unwrap_or(ImageDescriptor {
455 width: 1,
456 height: 1,
457 pixel: PixelFormat::Gray8,
458 color: crate::ColorModel::Srgb,
459 })
460 }
461 fn read_row(&mut self, _: &mut [u8]) -> Result<()> {
462 Ok(())
463 }
464 }
465 let mut buf = crate::TileBuf::zeroed(Region::from_size(1, 1), PixelFormat::Gray8).unwrap();
466 let mut tile = buf.as_tile_mut().unwrap();
467 let err = Seq
468 .read_region(Region::from_size(1, 1), &mut tile)
469 .unwrap_err();
470 assert_eq!(err.code(), ErrorCode::Unsupported);
471 assert_eq!(Seq.capability(), DecodeCapability::Sequential);
472 }
473}