#![cfg_attr(feature = "raw", doc = "```")]
#![cfg_attr(not(feature = "raw"), doc = "```ignore")]
#![cfg_attr(all(feature = "png", feature = "webp"), doc = "```")]
#![cfg_attr(not(all(feature = "png", feature = "webp")), doc = "```ignore")]
use otf_pixels_core::{BufferSource, Op, Prefixed, Producer, TileBuf};
use std::sync::Arc;
pub use otf_pixels_core::{
AccessPattern, Animation, ChannelLayout, Codec, ColorModel, Decoder, EncodeOptions, Encoder,
ErrorCode, Format, ImageDescriptor, Limit, Limits, Metadata, Orientation, PixelFormat,
PixelsError, PlanOptions, Region, Result, RunStats, SampleKind, Scheduler, SchedulerOptions,
Sink, Source, TileShape, evaluate as evaluate_reference,
};
pub use otf_pixels_ops::{
Blend, Composite, Conversion, ConvertFormat, Convolve, Crop, ExtractChannel, Filter, Fit,
Flatten, Flip, Flop, Kernel, Modulate, Quarter, Resize, ResizeOptions, Rotate, ToSrgb,
Unconvertible,
};
#[cfg(feature = "raw")]
pub use otf_pixels_codec_raw::{RawCodec, RawDecoder, RawEncoder, RawFormat};
#[cfg(feature = "png")]
pub use otf_pixels_codec_png::{PngCodec, PngDecoder, PngEncoder};
#[cfg(feature = "gif")]
pub use otf_pixels_codec_gif::{GifCodec, GifDecoder, GifEncoder};
#[cfg(feature = "jpeg")]
pub use otf_pixels_codec_jpeg::{JpegCodec, JpegDecoder, JpegEncoder, Scale, Subsampling};
#[cfg(feature = "tiff")]
pub use otf_pixels_codec_tiff::{TiffCodec, TiffDecoder, TiffEncoder, TiffLayout};
#[cfg(feature = "webp")]
pub use otf_pixels_codec_webp::{WebPCodec, WebPDecoder, WebPEncoder};
#[cfg(feature = "avif")]
pub use otf_pixels_codec_avif::{AvifCodec, AvifDecoder, AvifEncoder};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct OpenOptions {
pub auto_orient: bool,
pub to_srgb: bool,
pub animated: bool,
pub limits: Limits,
}
impl OpenOptions {
#[must_use]
pub const fn with_auto_orient(mut self, auto_orient: bool) -> Self {
self.auto_orient = auto_orient;
self
}
#[must_use]
pub const fn with_animated(mut self, animated: bool) -> Self {
self.animated = animated;
self
}
#[must_use]
pub const fn with_limits(mut self, limits: Limits) -> Self {
self.limits = limits;
self
}
#[must_use]
pub const fn with_to_srgb(mut self, to_srgb: bool) -> Self {
self.to_srgb = to_srgb;
self
}
}
impl Default for OpenOptions {
fn default() -> Self {
Self {
auto_orient: true,
to_srgb: true,
animated: false,
limits: Limits::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct Image {
inner: std::result::Result<otf_pixels_core::Image, Arc<PixelsError>>,
icc: Option<Arc<[u8]>>,
animation: Option<Arc<Animation>>,
}
impl Image {
pub fn from_raw(descriptor: ImageDescriptor, bytes: Vec<u8>) -> Result<Self> {
let buffer = TileBuf::from_vec(descriptor.region(), descriptor.pixel, bytes)?;
let source = BufferSource::new(descriptor, Arc::new(buffer))?;
Ok(Self::from_producer(Arc::new(source), Format::Raw))
}
#[cfg(feature = "raw")]
pub fn from_raw_stream(
layout: RawFormat,
source: impl Source + std::fmt::Debug + 'static,
) -> Result<Self> {
let decoder = RawDecoder::new(layout, source)?;
Ok(Self::from_decoder(Box::new(decoder), Format::Raw))
}
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self> {
Self::open_with(path, OpenOptions::default())
}
pub fn open_with(path: impl AsRef<std::path::Path>, options: OpenOptions) -> Result<Self> {
let path = path.as_ref();
let file = std::fs::File::open(path).map_err(|e| {
let kind = e.kind();
let detail = std::io::Error::new(kind, format!("{}: {e}", path.display()));
PixelsError::io("opening image file", detail)
})?;
Self::from_stream_with(std::io::BufReader::new(file), options)
}
pub fn from_stream(source: impl Source + std::fmt::Debug + 'static) -> Result<Self> {
Self::from_stream_with(source, OpenOptions::default())
}
pub fn from_stream_with(
mut source: impl Source + std::fmt::Debug + 'static,
options: OpenOptions,
) -> Result<Self> {
let codecs = sniffing_codecs();
let longest = codecs.iter().map(|c| c.magic_len()).max().unwrap_or(0);
let mut prefix = Vec::with_capacity(longest);
let mut buffer = vec![0_u8; longest];
while prefix.len() < longest {
let Some(rest) = buffer.get_mut(prefix.len()..) else {
break;
};
match source.read(rest)? {
0 => break,
n => {
let Some(read) = rest.get(..n) else { break };
prefix.extend_from_slice(read);
}
}
}
let Some(codec) = codecs.iter().find(|codec| codec.probe(&prefix)) else {
return Err(PixelsError::unsupported(format!(
"no codec recognises this stream; its first {} bytes are {:02x?}",
prefix.len().min(8),
prefix.get(..prefix.len().min(8)).unwrap_or(&[])
)));
};
let stream = Prefixed::new(prefix, source);
let _ = (&stream, options);
match codec.format() {
#[cfg(feature = "png")]
Format::Png => {
let decoder = PngDecoder::new(stream, options.limits)?;
Self::decoded(Box::new(decoder), Format::Png, options)
}
#[cfg(feature = "gif")]
Format::Gif => {
let decoder = GifDecoder::new(stream, options.limits)?;
Self::decoded(Box::new(decoder), Format::Gif, options)
}
#[cfg(feature = "jpeg")]
Format::Jpeg => {
let decoder = JpegDecoder::new(stream, options.limits)?;
Self::decoded(Box::new(decoder), Format::Jpeg, options)
}
#[cfg(feature = "tiff")]
Format::Tiff => {
let decoder = TiffDecoder::new(stream, options.limits)?;
Self::decoded(Box::new(decoder), Format::Tiff, options)
}
#[cfg(feature = "webp")]
Format::WebP => {
let decoder = WebPDecoder::new(stream, options.limits)?;
Self::decoded(Box::new(decoder), Format::WebP, options)
}
#[cfg(feature = "avif")]
Format::Avif => {
let decoder = AvifDecoder::new(stream, options.limits)?;
Self::decoded(Box::new(decoder), Format::Avif, options)
}
other => Err(PixelsError::unsupported(format!(
"{other} was detected but no decoder for it is compiled in"
))),
}
}
#[cfg(any(
feature = "png",
feature = "gif",
feature = "jpeg",
feature = "tiff",
feature = "webp",
feature = "avif"
))]
fn decoded(decoder: Box<dyn Decoder>, format: Format, options: OpenOptions) -> Result<Self> {
let orientation = decoder.orientation();
let icc = decoder.icc_profile().map(Vec::from);
let animation = decoder.animation().map(Arc::new);
if options.animated && animation.is_some() {
return Err(PixelsError::unsupported(format!(
"{format}: multi-frame (animated) pipelines are not implemented yet; \
open with `animated` off to process the first frame"
)));
}
let mut image = Self::from_decoder(decoder, format).with_icc_profile(icc);
image.animation = animation;
if options.to_srgb {
image = image.to_srgb();
}
Ok(if options.auto_orient {
image.orient(orientation)
} else {
image
})
}
#[must_use]
pub fn from_decoder(decoder: Box<dyn Decoder>, format: Format) -> Self {
let source = otf_pixels_core::DecodedSource::new(decoder);
Self::from_producer(Arc::new(source), format)
}
#[must_use]
pub fn from_producer(producer: Arc<dyn Producer>, format: Format) -> Self {
Self {
icc: None,
animation: None,
inner: Ok(otf_pixels_core::Image::from_producer(producer, format)),
}
}
pub fn metadata(&self) -> Result<Metadata> {
self.graph()?.metadata()
}
pub fn descriptor(&self) -> Result<ImageDescriptor> {
Ok(self.graph()?.descriptor())
}
#[must_use]
pub fn crop(self, x: u32, y: u32, width: u32, height: u32) -> Self {
match Crop::at(x, y, width, height) {
Ok(op) => self.apply(Arc::new(op)),
Err(error) => Self::failed(error),
}
}
#[must_use]
pub fn flip(self) -> Self {
self.apply(Arc::new(Flip))
}
#[must_use]
pub fn flop(self) -> Self {
self.apply(Arc::new(Flop))
}
#[must_use]
pub fn resize(self, width: u32, height: u32) -> Self {
self.resize_with(width, height, ResizeOptions::default())
}
#[must_use]
pub fn resize_with(self, width: u32, height: u32, options: ResizeOptions) -> Self {
match Resize::new(width, height, options) {
Ok(op) => self.apply(Arc::new(op)),
Err(error) => Self::failed(error),
}
}
#[must_use]
pub fn thumbnail(self, width: u32, height: u32) -> Self {
let options = ResizeOptions::default()
.with_fit(Fit::Inside)
.without_enlargement(true);
self.resize_with(width, height, options)
}
#[must_use]
pub fn orient(self, orientation: Orientation) -> Self {
let turns = orientation.clockwise_turns();
let image = if turns == 0 {
self
} else {
self.rotate(90 * i32::from(turns))
};
if orientation.mirrored() {
image.flop()
} else {
image
}
}
#[must_use]
pub fn rotate(self, degrees: i32) -> Self {
match Rotate::degrees(degrees) {
Ok(op) => self.apply(Arc::new(op)),
Err(error) => Self::failed(error),
}
}
#[must_use]
pub fn modulate(self, options: Modulate) -> Self {
self.apply(Arc::new(options))
}
#[must_use]
pub fn convolve(self, kernel: Kernel) -> Self {
self.apply(Arc::new(Convolve::new(kernel)))
}
#[must_use]
pub fn blur(self, sigma: f32) -> Self {
match Kernel::gaussian(sigma) {
Ok(kernel) => self.convolve(kernel),
Err(error) => Self::failed(error),
}
}
#[must_use]
pub fn sharpen(self, amount: f32) -> Self {
match Kernel::sharpen(amount) {
Ok(kernel) => self.convolve(kernel),
Err(error) => Self::failed(error),
}
}
#[must_use]
pub fn extract_channel(self, index: usize) -> Self {
self.apply(Arc::new(ExtractChannel::new(index)))
}
#[must_use]
pub fn flatten(self, red: u8, green: u8, blue: u8) -> Self {
self.apply(Arc::new(Flatten::onto(red, green, blue)))
}
#[must_use]
pub fn composite(self, overlay: Self, x: i64, y: i64) -> Self {
self.composite_with(overlay, x, y, Blend::Over)
}
#[must_use]
pub fn composite_with(self, overlay: Self, x: i64, y: i64, blend: Blend) -> Self {
let (icc, animation) = (self.icc, self.animation);
let (base, over) = match (self.inner, overlay.inner) {
(Ok(base), Ok(over)) => (base, over),
(Err(error), _) | (Ok(_), Err(error)) => return Self::failed_shared(error),
};
let op: Arc<dyn Op> = Arc::new(Composite::at(x, y, blend));
Self {
inner: otf_pixels_core::Image::combine(&[base, over], op).map_err(Arc::new),
icc,
animation,
}
}
fn failed(error: PixelsError) -> Self {
Self::failed_shared(Arc::new(error))
}
const fn failed_shared(error: Arc<PixelsError>) -> Self {
Self {
inner: Err(error),
icc: None,
animation: None,
}
}
#[must_use]
pub fn animation(&self) -> Option<&Animation> {
self.animation.as_deref()
}
#[must_use]
pub fn icc_profile(&self) -> Option<&[u8]> {
self.icc.as_deref()
}
#[must_use]
pub fn to_srgb(self) -> Self {
let Some(profile) = self.icc.clone() else {
return self;
};
let Ok(descriptor) = self.descriptor() else {
return self;
};
match ToSrgb::from_profile(&profile) {
Conversion::Convert(op) if op.applies_to(descriptor.pixel) => {
self.apply(Arc::new(op)).with_icc_profile(None)
}
Conversion::AlreadySrgb => self.with_icc_profile(None),
_ => self,
}
}
#[must_use]
pub fn to_pixel_format(self, pixel: PixelFormat) -> Self {
match self.descriptor() {
Ok(descriptor) if descriptor.pixel == pixel => self,
_ => self.apply(Arc::new(ConvertFormat::to(pixel))),
}
}
fn encodable_as(self, format: Format) -> Self {
let Ok(descriptor) = self.descriptor() else {
return self;
};
let pixel = descriptor.pixel;
let kind = match (format, pixel.sample_kind()) {
(
Format::Jpeg | Format::WebP | Format::Avif | Format::Gif,
SampleKind::U16 | SampleKind::F32,
) => SampleKind::U8,
(Format::Png | Format::Tiff, SampleKind::F32) => SampleKind::U16,
_ => return self,
};
match PixelFormat::from_parts(pixel.layout(), kind) {
Some(target) => self.to_pixel_format(target),
None => self,
}
}
#[must_use]
pub fn with_icc_profile(mut self, profile: Option<Vec<u8>>) -> Self {
self.icc = profile.map(Arc::from);
self
}
#[must_use]
pub fn apply(self, op: Arc<dyn Op>) -> Self {
Self {
icc: self.icc,
animation: self.animation,
inner: match self.inner {
Ok(image) => image.apply(op).map_err(Arc::new),
Err(error) => Err(error),
},
}
}
#[must_use]
pub fn output(self, format: Format, options: EncodeOptions) -> Output {
Output {
image: self,
format,
options,
scheduler: None,
shared: None,
}
}
fn graph(&self) -> Result<&otf_pixels_core::Image> {
match &self.inner {
Ok(image) => Ok(image),
Err(error) => Err(rebuild(error)),
}
}
}
fn rebuild(error: &Arc<PixelsError>) -> PixelsError {
let detail = error.to_string();
match error.code() {
ErrorCode::Io => PixelsError::io("running the pipeline", std::io::Error::other(detail)),
ErrorCode::Malformed => PixelsError::malformed("pipeline", detail),
ErrorCode::Unsupported => PixelsError::unsupported(detail),
ErrorCode::InvalidArgument => PixelsError::invalid_argument("pipeline", detail),
ErrorCode::Graph => PixelsError::graph(detail),
ErrorCode::LimitExceeded => match **error {
PixelsError::LimitExceeded {
limit,
requested,
allowed,
} => PixelsError::limit_exceeded(limit, requested, allowed),
_ => PixelsError::graph(detail),
},
_ => PixelsError::graph(detail),
}
}
#[derive(Debug, Clone)]
pub struct Output {
image: Image,
format: Format,
options: EncodeOptions,
scheduler: Option<SchedulerOptions>,
shared: Option<Arc<Scheduler>>,
}
impl Output {
#[must_use]
pub const fn format(&self) -> Format {
self.format
}
#[must_use]
pub const fn options(&self) -> EncodeOptions {
self.options
}
#[must_use]
pub fn threads(mut self, threads: usize) -> Self {
self.scheduler = Some(self.scheduler.unwrap_or_default().with_threads(threads));
self
}
#[must_use]
pub fn with_scheduler(mut self, scheduler: Arc<Scheduler>) -> Self {
self.shared = Some(scheduler);
self
}
#[must_use]
pub const fn scheduler_options(mut self, options: SchedulerOptions) -> Self {
self.scheduler = Some(options);
self
}
pub fn write(self, sink: impl Sink) -> Result<()> {
self.write_with_stats(sink).map(|_| ())
}
pub fn write_with_stats(self, mut sink: impl Sink) -> Result<RunStats> {
let encodable = self.image.clone().encodable_as(self.format);
let (image, reduction) = otf_pixels_core::shrink_on_load(encodable.graph()?)?;
let descriptor = image.descriptor();
let mut encoder = encoder_for(self.format, self.options)?;
encoder.set_icc_profile(self.image.icc.as_deref())?;
encoder.write_header(&descriptor, &mut sink)?;
let scheduler = match (&self.shared, self.scheduler) {
(Some(shared), _) => Arc::clone(shared),
(None, Some(options)) => Arc::new(Scheduler::new(options)?),
(None, None) if otf_pixels_core::ThreadPool::on_worker_thread() => {
Arc::new(Scheduler::with_defaults()?)
}
(None, None) => Scheduler::global()?,
};
let mut rows = RowAssembler::new(descriptor);
let mut stats = scheduler.run(&image, |region, tile| {
rows.accept(region, tile, &mut |row| encoder.write_row(row, &mut sink))
})?;
rows.finish(&mut |row| encoder.write_row(row, &mut sink))?;
encoder.finish(&mut sink)?;
stats.reduction = reduction;
Ok(stats)
}
pub fn bytes_via_reference(self) -> Result<Vec<u8>> {
let encodable = self.image.clone().encodable_as(self.format);
let (image, _) = otf_pixels_core::shrink_on_load(encodable.graph()?)?;
let descriptor = image.descriptor();
let mut encoder = encoder_for(self.format, self.options)?;
encoder.set_icc_profile(self.image.icc.as_deref())?;
let mut sink = Vec::with_capacity(descriptor.byte_len().unwrap_or_default());
encoder.write_header(&descriptor, &mut sink)?;
otf_pixels_core::evaluate_rows(&image, |_, row| encoder.write_row(row, &mut sink))?;
encoder.finish(&mut sink)?;
Ok(sink)
}
pub fn bytes(self) -> Result<Vec<u8>> {
let hint = self
.image
.descriptor()
.ok()
.and_then(|d| d.byte_len())
.unwrap_or_default();
let mut buffer = Vec::with_capacity(hint);
self.write(&mut buffer)?;
Ok(buffer)
}
}
#[derive(Debug)]
struct RowAssembler {
descriptor: ImageDescriptor,
band: Option<otf_pixels_core::TileBuf>,
next_row: u32,
}
impl RowAssembler {
const fn new(descriptor: ImageDescriptor) -> Self {
Self {
descriptor,
band: None,
next_row: 0,
}
}
fn accept(
&mut self,
region: Region,
tile: &otf_pixels_core::Tile<'_>,
emit: &mut impl FnMut(&[u8]) -> Result<()>,
) -> Result<()> {
if region.width == self.descriptor.width && self.band.is_none() {
for y in region.y..region.y.saturating_add(region.height) {
let row = tile
.row(y)
.ok_or_else(|| PixelsError::graph(format!("output tile is missing row {y}")))?;
emit(row)?;
self.next_row = y.saturating_add(1);
}
return Ok(());
}
let band_region = Region::new(0, region.y, self.descriptor.width, region.height);
let starts_new_band = self
.band
.as_ref()
.is_none_or(|band| band.region().y != region.y);
if starts_new_band {
self.flush(emit)?;
self.band = Some(otf_pixels_core::TileBuf::zeroed(
band_region,
self.descriptor.pixel,
)?);
}
let Some(band) = self.band.as_mut() else {
return Err(PixelsError::graph("row band vanished"));
};
otf_pixels_core::copy_region(tile, &mut band.as_tile_mut()?, region)?;
if region.right() >= u64::from(self.descriptor.width) {
self.flush(emit)?;
}
Ok(())
}
fn flush(&mut self, emit: &mut impl FnMut(&[u8]) -> Result<()>) -> Result<()> {
let Some(band) = self.band.take() else {
return Ok(());
};
let region = band.region();
let view = band.as_tile()?;
for y in region.y..region.y.saturating_add(region.height) {
let row = view
.row(y)
.ok_or_else(|| PixelsError::graph(format!("row band is missing row {y}")))?;
emit(row)?;
self.next_row = y.saturating_add(1);
}
Ok(())
}
fn finish(&mut self, emit: &mut impl FnMut(&[u8]) -> Result<()>) -> Result<()> {
self.flush(emit)
}
}
fn sniffing_codecs() -> Vec<Box<dyn Codec>> {
let codecs: Vec<Box<dyn Codec>> = vec![
#[cfg(feature = "png")]
Box::new(PngCodec),
#[cfg(feature = "gif")]
Box::new(GifCodec),
#[cfg(feature = "jpeg")]
Box::new(JpegCodec),
#[cfg(feature = "tiff")]
Box::new(TiffCodec),
#[cfg(feature = "webp")]
Box::new(WebPCodec),
#[cfg(feature = "avif")]
Box::new(AvifCodec),
];
codecs
}
fn encoder_for(format: Format, options: EncodeOptions) -> Result<Box<dyn Encoder>> {
let _ = &options;
match format {
#[cfg(feature = "raw")]
Format::Raw => Ok(Box::new(RawEncoder::new())),
#[cfg(feature = "png")]
Format::Png => Ok(Box::new(PngEncoder::from_options(&options))),
#[cfg(feature = "gif")]
Format::Gif => Ok(Box::new(GifEncoder::from_options(&options))),
#[cfg(feature = "jpeg")]
Format::Jpeg => Ok(Box::new(JpegEncoder::from_options(&options))),
#[cfg(feature = "tiff")]
Format::Tiff => Ok(Box::new(TiffEncoder::from_options(&options))),
#[cfg(feature = "webp")]
Format::WebP => Ok(Box::new(WebPEncoder::from_options(&options))),
#[cfg(feature = "avif")]
Format::Avif => Ok(Box::new(AvifEncoder::from_options(&options))),
#[cfg(not(feature = "raw"))]
Format::Raw => Err(PixelsError::unsupported(
"raw encoding requires the `raw` feature of otf-pixels",
)),
other => Err(PixelsError::unsupported(format!(
"encoding {other} is not implemented yet; \
see the ROADMAP for which milestone lands it"
))),
}
}
#[cfg(all(test, feature = "raw"))]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
reason = "tests operate on known-good values and assert shapes directly"
)]
mod tests {
use super::*;
fn ramp(width: u32, height: u32) -> Image {
let descriptor = ImageDescriptor::new(width, height, PixelFormat::Gray8).unwrap();
let len = descriptor.byte_len().unwrap();
Image::from_raw(descriptor, (0..len).map(|i| i as u8).collect()).unwrap()
}
#[test]
fn from_raw_requires_an_exactly_sized_buffer() {
let descriptor = ImageDescriptor::new(2, 2, PixelFormat::Gray8).unwrap();
assert!(Image::from_raw(descriptor, vec![0; 4]).is_ok());
assert_eq!(
Image::from_raw(descriptor, vec![0; 3]).unwrap_err().code(),
ErrorCode::InvalidArgument
);
assert_eq!(
Image::from_raw(descriptor, vec![0; 5]).unwrap_err().code(),
ErrorCode::InvalidArgument
);
}
#[test]
fn a_chain_error_surfaces_at_the_terminal() {
let result = ramp(4, 4)
.crop(3, 3, 4, 4)
.flip()
.flop()
.output(Format::Raw, EncodeOptions::default())
.bytes();
let err = result.unwrap_err();
assert_eq!(err.code(), ErrorCode::InvalidArgument);
}
#[test]
fn an_error_short_circuits_later_ops() {
let broken = ramp(4, 4).crop(0, 0, 0, 0);
assert!(broken.metadata().is_err());
assert!(broken.clone().flip().descriptor().is_err());
}
#[test]
fn metadata_is_free_and_reports_the_pipeline_shape() {
let meta = ramp(8, 6).metadata().unwrap();
assert_eq!((meta.width, meta.height), (8, 6));
assert_eq!(meta.format, Format::Raw);
assert_eq!(meta.pixel, PixelFormat::Gray8);
let cropped = ramp(8, 6).crop(1, 1, 3, 2).metadata().unwrap();
assert_eq!((cropped.width, cropped.height), (3, 2));
}
#[cfg(not(feature = "avif"))]
#[test]
fn a_format_built_out_is_a_catchable_error() {
let format = Format::Avif;
let err = ramp(2, 2)
.output(format, EncodeOptions::default())
.bytes()
.unwrap_err();
assert_eq!(err.code(), ErrorCode::Unsupported, "{format}");
assert!(err.to_string().contains(format.as_str()), "{err}");
}
#[cfg(feature = "tiff")]
#[test]
fn tiff_round_trips_through_the_facade() {
let bytes = ramp(12, 9)
.output(Format::Tiff, EncodeOptions::default())
.bytes()
.unwrap();
let image = Image::from_stream(std::io::Cursor::new(bytes)).unwrap();
let metadata = image.metadata().unwrap();
assert_eq!(metadata.format, Format::Tiff);
assert_eq!((metadata.width, metadata.height), (12, 9));
let decoded = image
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap();
let expected: Vec<u8> = (0..12_u32 * 9).map(|i| i as u8).collect();
assert_eq!(decoded, expected);
}
#[cfg(feature = "jpeg")]
#[test]
fn jpeg_round_trips_through_the_facade() {
let (width, height) = (32_u32, 24_u32);
let descriptor = ImageDescriptor::new(width, height, PixelFormat::Rgb8).unwrap();
let pixels: Vec<u8> = (0..descriptor.byte_len().unwrap())
.map(|i| {
let pixel = i / 3;
let (x, y) = (pixel as u32 % width, pixel as u32 / width);
match i % 3 {
0 => (x * 255 / width) as u8,
1 => (y * 255 / height) as u8,
_ => 128,
}
})
.collect();
let bytes = Image::from_raw(descriptor, pixels.clone())
.unwrap()
.output(Format::Jpeg, EncodeOptions::with_quality(95).unwrap())
.bytes()
.unwrap();
let image = Image::from_stream(std::io::Cursor::new(bytes.clone())).unwrap();
let metadata = image.metadata().unwrap();
assert_eq!(metadata.format, Format::Jpeg, "sniffing missed the JPEG");
assert_eq!((metadata.width, metadata.height), (width, height));
assert_eq!(metadata.pixel, PixelFormat::Rgb8);
let decoded = image
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap();
assert_eq!(decoded.len(), pixels.len());
let worst = decoded
.iter()
.zip(&pixels)
.map(|(&a, &b)| a.abs_diff(b))
.max()
.unwrap_or(0);
assert!(worst <= 12, "worst sample differs by {worst}");
}
#[cfg(feature = "jpeg")]
#[test]
fn a_streaming_resize_matches_the_reference_evaluator() {
for &(width, height, target) in &[
(512_u32, 512_u32, 300_u32),
(512, 512, 256),
(256, 256, 200),
(320, 240, 129),
(64, 64, 16),
] {
let source = jpeg_source(width, height);
let scheduled = Image::from_stream(std::io::Cursor::new(source.clone()))
.unwrap()
.resize(target, target)
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap_or_else(|e| panic!("{width}x{height} -> {target}: {e}"));
let reference = Image::from_stream(std::io::Cursor::new(source))
.unwrap()
.resize(target, target)
.output(Format::Raw, EncodeOptions::default())
.bytes_via_reference()
.unwrap();
assert_eq!(
scheduled.len(),
(target * target * 3) as usize,
"{width}x{height} -> {target}: size"
);
assert_eq!(
scheduled, reference,
"{width}x{height} -> {target}: the scheduler and the oracle disagree"
);
}
}
#[cfg(feature = "webp")]
#[test]
fn webp_round_trips_through_the_facade() {
let bytes = ramp(20, 12)
.output(Format::WebP, EncodeOptions::default().with_lossless(true))
.bytes()
.unwrap();
let image = Image::from_stream(std::io::Cursor::new(bytes)).unwrap();
let metadata = image.metadata().unwrap();
assert_eq!(metadata.format, Format::WebP, "sniffing missed the WebP");
assert_eq!((metadata.width, metadata.height), (20, 12));
assert_eq!(metadata.pixel, PixelFormat::Rgb8);
let decoded = image
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap();
let expected: Vec<u8> = (0..20_u32 * 12)
.flat_map(|i| {
let value = i as u8;
[value, value, value]
})
.collect();
assert_eq!(decoded, expected, "a lossless round trip lost pixels");
}
#[cfg(feature = "webp")]
#[test]
fn webp_keeps_alpha_through_the_facade() {
let descriptor = ImageDescriptor::new(9, 7, PixelFormat::Rgba8).unwrap();
let pixels: Vec<u8> = (0..(9 * 7))
.flat_map(|i| [(i * 3) as u8, 40, 200, (i * 7) as u8])
.collect();
let bytes = Image::from_raw(descriptor, pixels.clone())
.unwrap()
.output(Format::WebP, EncodeOptions::default().with_lossless(true))
.bytes()
.unwrap();
let image = Image::from_stream(std::io::Cursor::new(bytes)).unwrap();
assert_eq!(image.metadata().unwrap().pixel, PixelFormat::Rgba8);
let decoded = image
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap();
assert_eq!(decoded, pixels);
}
#[cfg(feature = "webp")]
#[test]
fn webp_is_lossy_by_default_and_keeps_alpha_exact() {
let (w, h) = (48_u32, 32_u32);
let descriptor = ImageDescriptor::new(w, h, PixelFormat::Rgba8).unwrap();
let pixels: Vec<u8> = (0..w * h)
.flat_map(|i| {
let (x, y) = (i % w, i / w);
[(x * 5) as u8, (y * 7) as u8, 120, (x * 3 + y) as u8]
})
.collect();
let encode = |options: EncodeOptions| {
Image::from_raw(descriptor, pixels.clone())
.unwrap()
.output(Format::WebP, options)
.bytes()
.unwrap()
};
let good = encode(EncodeOptions::default());
let rough = encode(EncodeOptions::with_quality(10).unwrap());
assert!(
rough.len() < good.len(),
"{} vs {}",
rough.len(),
good.len()
);
let decoded = Image::from_stream(std::io::Cursor::new(good))
.unwrap()
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap();
for (ours, source) in decoded.chunks_exact(4).zip(pixels.chunks_exact(4)) {
assert_eq!(ours[3], source[3], "alpha changed");
for c in 0..3 {
assert!(ours[c].abs_diff(source[c]) <= 12, "{ours:?} vs {source:?}");
}
}
}
#[cfg(feature = "jpeg")]
#[test]
fn a_thumbnail_pipeline_shrinks_the_jpeg_on_load() {
let source = jpeg_source(512, 512);
let image = Image::from_stream(std::io::Cursor::new(source)).unwrap();
assert_eq!(
image.metadata().unwrap().width,
512,
"metadata is full size"
);
let mut bytes = Vec::new();
let stats = image
.resize(32, 32)
.output(Format::Raw, EncodeOptions::default())
.write_with_stats(&mut bytes)
.unwrap();
let reduction = stats
.reduction
.expect("a 512 to 32 resize should shrink the source on load");
assert_eq!(reduction.from, (512, 512));
assert_eq!(
reduction.to,
(64, 64),
"1/8 is the coarsest scale that still covers 32"
);
assert!((reduction.factor() - 64.0).abs() < 0.01);
assert_eq!(bytes.len(), 32 * 32 * 3);
}
#[cfg(feature = "jpeg")]
#[test]
fn a_crop_blocks_shrink_on_load() {
let source = jpeg_source(512, 512);
let stats = Image::from_stream(std::io::Cursor::new(source))
.unwrap()
.crop(256, 256, 128, 128)
.resize(32, 32)
.output(Format::Raw, EncodeOptions::default())
.write_with_stats(&mut Vec::new())
.unwrap();
assert!(
stats.reduction.is_none(),
"a crop must block shrink-on-load: {:?}",
stats.reduction
);
}
#[cfg(feature = "jpeg")]
#[test]
fn a_crop_after_the_resize_still_allows_shrinking() {
let source = jpeg_source(512, 512);
let stats = Image::from_stream(std::io::Cursor::new(source))
.unwrap()
.resize(64, 64)
.crop(0, 0, 32, 32)
.output(Format::Raw, EncodeOptions::default())
.write_with_stats(&mut Vec::new())
.unwrap();
assert!(stats.reduction.is_none());
}
#[cfg(feature = "jpeg")]
#[test]
fn a_mild_resize_shrinks_by_less_or_not_at_all() {
let source = jpeg_source(512, 512);
let stats = Image::from_stream(std::io::Cursor::new(source.clone()))
.unwrap()
.resize(300, 300)
.output(Format::Raw, EncodeOptions::default())
.write_with_stats(&mut Vec::new())
.unwrap();
assert!(stats.reduction.is_none(), "{:?}", stats.reduction);
let stats = Image::from_stream(std::io::Cursor::new(source))
.unwrap()
.resize(200, 200)
.output(Format::Raw, EncodeOptions::default())
.write_with_stats(&mut Vec::new())
.unwrap();
assert_eq!(stats.reduction.map(|r| r.to), Some((256, 256)));
}
#[cfg(feature = "jpeg")]
fn jpeg_source(width: u32, height: u32) -> Vec<u8> {
let descriptor = ImageDescriptor::new(width, height, PixelFormat::Rgb8).unwrap();
let pixels: Vec<u8> = (0..descriptor.byte_len().unwrap())
.map(|i| {
let pixel = i / 3;
let (x, y) = (pixel as u32 % width, pixel as u32 / width);
match i % 3 {
0 => (x * 255 / width) as u8,
1 => (y * 255 / height) as u8,
_ => 96,
}
})
.collect();
Image::from_raw(descriptor, pixels)
.unwrap()
.output(Format::Jpeg, EncodeOptions::with_quality(85).unwrap())
.bytes()
.unwrap()
}
#[cfg(feature = "jpeg")]
#[test]
fn a_jpeg_pipeline_resizes_and_re_encodes() {
let descriptor = ImageDescriptor::new(64, 64, PixelFormat::Rgb8).unwrap();
let pixels: Vec<u8> = (0..descriptor.byte_len().unwrap())
.map(|i| ((i / 3) % 251) as u8)
.collect();
let source = Image::from_raw(descriptor, pixels)
.unwrap()
.output(Format::Jpeg, EncodeOptions::default())
.bytes()
.unwrap();
let thumbnail = Image::from_stream(std::io::Cursor::new(source.clone()))
.unwrap()
.resize(16, 16)
.output(Format::Jpeg, EncodeOptions::with_quality(70).unwrap())
.bytes()
.unwrap();
let metadata = Image::from_stream(std::io::Cursor::new(thumbnail.clone()))
.unwrap()
.metadata()
.unwrap();
assert_eq!((metadata.width, metadata.height), (16, 16));
assert!(
thumbnail.len() < source.len(),
"a 16x16 thumbnail is {} bytes against a 64x64 source's {}",
thumbnail.len(),
source.len()
);
}
#[cfg(feature = "jpeg")]
#[test]
fn grayscale_jpeg_keeps_one_channel_through_the_facade() {
let bytes = ramp(24, 16)
.output(Format::Jpeg, EncodeOptions::with_quality(90).unwrap())
.bytes()
.unwrap();
let metadata = Image::from_stream(std::io::Cursor::new(bytes))
.unwrap()
.metadata()
.unwrap();
assert_eq!(metadata.pixel, PixelFormat::Gray8);
assert_eq!((metadata.width, metadata.height), (24, 16));
}
#[test]
fn write_streams_into_any_sink() {
let mut sink = Vec::new();
ramp(2, 2)
.output(Format::Raw, EncodeOptions::default())
.write(&mut sink)
.unwrap();
assert_eq!(sink, [0, 1, 2, 3]);
}
#[test]
fn output_reports_its_settings() {
let options = EncodeOptions::with_quality(55).unwrap();
let output = ramp(2, 2).output(Format::Raw, options);
assert_eq!(output.format(), Format::Raw);
assert_eq!(output.options().quality, 55);
}
#[cfg(feature = "raw")]
#[test]
fn from_raw_stream_defers_decoding_to_the_terminal() {
let descriptor = ImageDescriptor::new(2, 2, PixelFormat::Gray8).unwrap();
let layout = RawFormat::packed(descriptor);
let cursor = std::io::Cursor::new(vec![1_u8, 2, 3, 4]);
let image = Image::from_raw_stream(layout, cursor).unwrap();
assert_eq!(image.metadata().unwrap().width, 2);
let bytes = image
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap();
assert_eq!(bytes, [1, 2, 3, 4]);
}
#[cfg(feature = "raw")]
#[test]
fn a_truncated_stream_fails_the_terminal_without_panicking() {
let descriptor = ImageDescriptor::new(4, 4, PixelFormat::Gray8).unwrap();
let layout = RawFormat::packed(descriptor);
let cursor = std::io::Cursor::new(vec![1_u8; 5]);
let image = Image::from_raw_stream(layout, cursor).unwrap();
let err = image
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap_err();
assert_eq!(err.code(), ErrorCode::Malformed);
}
#[test]
fn images_are_send_sync_and_cheap_to_clone() {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Image>();
assert_send_sync::<Output>();
let image = ramp(4, 4);
let clone = image.clone();
assert_eq!(
image.metadata().unwrap().width,
clone.metadata().unwrap().width
);
}
#[cfg(feature = "png")]
#[test]
fn a_png_round_trips_through_the_facade() {
let png = ramp(37, 21)
.output(Format::Png, EncodeOptions::default())
.bytes()
.unwrap();
let image = Image::from_stream(std::io::Cursor::new(png)).unwrap();
assert_eq!(image.descriptor().unwrap().width, 37);
assert_eq!(image.metadata().unwrap().format, Format::Png);
let back = image
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap();
let expected = ramp(37, 21)
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap();
assert_eq!(back, expected, "pixels changed across a PNG round trip");
}
#[cfg(feature = "png")]
#[test]
fn a_pipeline_survives_a_png_round_trip() {
let png = ramp(16, 16)
.output(Format::Png, EncodeOptions::default())
.bytes()
.unwrap();
let cropped = Image::from_stream(std::io::Cursor::new(png))
.unwrap()
.crop(2, 3, 8, 5)
.flip()
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap();
let direct = ramp(16, 16)
.crop(2, 3, 8, 5)
.flip()
.output(Format::Raw, EncodeOptions::default())
.bytes()
.unwrap();
assert_eq!(cropped, direct);
}
#[cfg(feature = "png")]
#[test]
fn open_ignores_the_extension_and_reads_the_bytes() {
let dir = std::env::temp_dir().join(format!("otf-pixels-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("actually-a-png.jpg");
let png = ramp(8, 8)
.output(Format::Png, EncodeOptions::default())
.bytes()
.unwrap();
std::fs::write(&path, &png).unwrap();
let image = Image::open(&path).unwrap();
assert_eq!(
image.metadata().unwrap().format,
Format::Png,
"extension won over content"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn open_names_the_file_it_could_not_read() {
let error = Image::open("/nonexistent/otf-pixels/missing.png").unwrap_err();
assert_eq!(error.code(), ErrorCode::Io);
assert!(error.to_string().contains("missing.png"), "{error}");
}
#[test]
fn an_unrecognised_stream_is_unsupported_not_a_guess() {
for bytes in [&b""[..], &b"not an image"[..], &[0_u8; 64][..]] {
let error = Image::from_stream(std::io::Cursor::new(bytes.to_vec())).unwrap_err();
assert_eq!(error.code(), ErrorCode::Unsupported, "{bytes:02x?}");
}
}
#[cfg(feature = "png")]
#[test]
fn a_truncated_png_is_malformed_not_a_panic() {
let png = ramp(8, 8)
.output(Format::Png, EncodeOptions::default())
.bytes()
.unwrap();
for cut in [9, 16, 24, 32, png.len() - 1] {
let truncated = png[..cut].to_vec();
let result = Image::from_stream(std::io::Cursor::new(truncated))
.and_then(|i| i.output(Format::Raw, EncodeOptions::default()).bytes());
assert!(result.is_err(), "truncating to {cut} bytes should fail");
}
}
#[cfg(feature = "png")]
#[test]
fn sniffing_reads_only_the_magic_bytes_before_deciding() {
let error = Image::from_stream(std::io::Cursor::new(
otf_pixels_codec_png::SIGNATURE.to_vec(),
))
.unwrap_err();
assert_eq!(error.code(), ErrorCode::Malformed, "{error}");
}
}
const _: () = {
const fn shareable<T: Send + Sync>() {}
shareable::<Image>();
shareable::<Output>();
shareable::<OpenOptions>();
shareable::<PixelsError>();
shareable::<Scheduler>();
};