univert 0.4.0

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

use image::ImageFormat;

use crate::converters::scad::which_tool;
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<()> {
  let img = image::open(job.input)
    .map_err(|e| Error::other(format!("image decode: {e}")))?;
  encode_jp2(&img, job.output)
}

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

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

/// Encode any DynamicImage to a JPEG 2000 file by shelling out to
/// `opj_compress`. The openjpeg tools read PNG natively, so we stage the
/// source through a temp PNG rather than implementing the low-level FFI
/// encoder ourselves.
fn encode_jp2(img: &image::DynamicImage, output: &Path) -> Result<()> {
  if !which_tool("opj_compress") {
    return Err(Error::MissingTool {
      from: String::new(),
      to: "jp2".to_string(),
      tool: "opj_compress",
    });
  }

  let staged = tempfile::Builder::new()
    .prefix("univert-jp2-")
    .suffix(".png")
    .tempfile()?;
  img
    .save_with_format(staged.path(), ImageFormat::Png)
    .map_err(|e| Error::other(format!("jp2 stage png: {e}")))?;

  let status = Command::new("opj_compress")
    .arg("-i")
    .arg(staged.path())
    .arg("-o")
    .arg(output)
    .status()
    .map_err(|e| Error::ToolFailed {
      tool: "opj_compress",
      message: e.to_string(),
    })?;
  if !status.success() {
    return Err(Error::ToolFailed {
      tool: "opj_compress",
      message: format!("opj_compress exited with status {status}"),
    });
  }
  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,
    "avif" => ImageFormat::Avif,
    "qoi" => ImageFormat::Qoi,
    "pnm" | "pbm" | "pgm" | "ppm" => ImageFormat::Pnm,
    "hdr" => ImageFormat::Hdr,
    "exr" => ImageFormat::OpenExr,
    "ff" | "farbfeld" => ImageFormat::Farbfeld,
    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)
}