univert 0.2.0

Universal file converter (library and CLI)
Documentation
use std::fs;
use std::path::Path;

use image::ImageFormat;

use crate::{Error, Job, Result, extension_of};

pub fn convert(job: &Job) -> Result<()> {
  let img = image::open(job.input)
    .map_err(|e| Error::other(format!("image decode: {e}")))?;
  save_to(&img, job.output)?;
  Ok(())
}

pub fn from_psd(job: &Job) -> Result<()> {
  let img = load_psd(job.input)?;
  save_to(&img, job.output)?;
  Ok(())
}

pub fn to_jp2(_job: &Job) -> Result<()> {
  // TODO: integrate an encoder (e.g. openjpeg-sys). Until then, declare
  // the feature as not yet implemented so the CLI surfaces a clear error
  // instead of silently failing.
  let from = extension_of(_job.input).unwrap_or_default();
  let to = extension_of(_job.output).unwrap_or_default();
  Err(Error::NotImplemented { from, to })
}

pub fn psd_to_jp2(job: &Job) -> Result<()> {
  to_jp2(job)
}

pub fn from_jp2(job: &Job) -> Result<()> {
  let img = load_jp2(job.input)?;
  save_to(&img, job.output)?;
  Ok(())
}

fn save_to(img: &image::DynamicImage, output: &Path) -> Result<()> {
  let ext = extension_of(output)
    .ok_or_else(|| Error::OutputNoExtension(output.to_path_buf()))?;

  let fmt = match ext.as_str() {
    "bmp" => ImageFormat::Bmp,
    "gif" => ImageFormat::Gif,
    "ico" => ImageFormat::Ico,
    "jpeg" | "jpg" => ImageFormat::Jpeg,
    "png" => ImageFormat::Png,
    "tga" => ImageFormat::Tga,
    "tif" | "tiff" => ImageFormat::Tiff,
    "webp" => ImageFormat::WebP,
    other => {
      return Err(Error::other(format!("no image encoder for .{other}")));
    }
  };

  // ICO only supports up to 256x256; downscale if necessary (mirrors
  // the JS sipsWrapper behavior which capped ICO dimensions).
  let img = if matches!(fmt, ImageFormat::Ico) {
    let (w, h) = (img.width(), img.height());
    if w > 256 || h > 256 {
      img.resize(256, 256, image::imageops::FilterType::Lanczos3)
    } else {
      img.clone()
    }
  } else {
    img.clone()
  };

  img
    .save_with_format(output, fmt)
    .map_err(|e| Error::other(format!("image encode: {e}")))?;
  Ok(())
}

fn load_psd(path: &Path) -> Result<image::DynamicImage> {
  let bytes = fs::read(path)?;
  let psd_doc = psd::Psd::from_bytes(&bytes)
    .map_err(|e| Error::other(format!("psd parse: {e}")))?;
  let rgba = psd_doc.rgba();
  let buf = image::RgbaImage::from_raw(psd_doc.width(), psd_doc.height(), rgba)
    .ok_or_else(|| Error::other("psd: failed to construct RGBA image"))?;
  Ok(image::DynamicImage::ImageRgba8(buf))
}

fn load_jp2(path: &Path) -> Result<image::DynamicImage> {
  let bytes = fs::read(path)?;
  let img = jpeg2k::Image::from_bytes(&bytes)
    .map_err(|e| Error::other(format!("jp2 decode: {e}")))?;
  let dyn_img: image::DynamicImage = (&img)
    .try_into()
    .map_err(|e| Error::other(format!("jp2 convert: {e}")))?;
  Ok(dyn_img)
}