PNG-to-JPG in Rust is straightforward with the image crate — it handles both formats natively with no C dependencies. The main subtlety is alpha channels: PNGs may have transparency, but JPEG doesn't support it. Always composite onto a white (or custom) background before saving as JPEG. For performance-critical batch pipelines, turbojpeg's libjpeg-turbo binding is 2-3x faster.

Method 1: image crate (pure Rust, no C deps)

The image crate handles PNG loading and JPEG encoding natively in Rust — no system libraries needed.

# Cargo.toml
[dependencies]
image = "0.25"
use image::{DynamicImage, GenericImageView, RgbImage, Rgba};
use std::path::Path;
use std::fs;

/// Convert PNG to JPEG, compositing transparent pixels onto a white background.
fn png_to_jpg(
    src: &str,
    dst: &str,
    quality: u8,  // 1-100; 85 is a good default
) -> Result<(), Box> {
    let img = image::open(src)?;

    // Composite onto white background to handle alpha
    let (width, height) = img.dimensions();
    let mut rgb = RgbImage::new(width, height);

    let rgba = img.to_rgba8();
    for (x, y, pixel) in rgba.enumerate_pixels() {
        let Rgba([r, g, b, a]) = *pixel;
        let alpha = a as f32 / 255.0;
        let bg = (1.0 - alpha) * 255.0;
        rgb.put_pixel(x, y, image::Rgb([
            (r as f32 * alpha + bg) as u8,
            (g as f32 * alpha + bg) as u8,
            (b as f32 * alpha + bg) as u8,
        ]));
    }

    // Save as JPEG with quality
    let mut out = std::io::BufWriter::new(fs::File::create(dst)?);
    let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality);
    encoder.encode_image(&DynamicImage::ImageRgb8(rgb))?;

    Ok(())
}

fn main() -> Result<(), Box> {
    png_to_jpg("screenshot.png", "screenshot.jpg", 85)?;
    println!("Done");
    Ok(())
}
// Simpler version if you don't need alpha handling
// (safe when you know the PNG has no transparency)
fn png_to_jpg_simple(src: &str, dst: &str) -> Result<(), Box> {
    let img = image::open(src)?.to_rgb8();
    img.save(dst)?;  // auto-detects JPEG from .jpg extension
    Ok(())
}

The to_rgb8() method drops the alpha channel by discarding transparency. For PNGs with actual transparent areas, this turns them black — use the compositing approach above for correct results.

Method 2: turbojpeg crate (libjpeg-turbo, 2-3x faster encoding)

turbojpeg wraps libjpeg-turbo, the most optimized JPEG encoder available. Better for high-throughput batch pipelines.

# Cargo.toml
[dependencies]
image = "0.25"
turbojpeg = "1"
# Needs libjpeg-turbo: apt install libjpeg-turbo-dev
use image::{GenericImageView, RgbImage, Rgba};
use std::fs;

fn png_to_jpg_turbo(
    src: &str,
    dst: &str,
    quality: i32,  // 1-100
) -> Result<(), Box> {
    let img = image::open(src)?;
    let (width, height) = img.dimensions();

    // Composite onto white
    let mut rgb = RgbImage::new(width, height);
    for (x, y, Rgba([r, g, b, a])) in img.to_rgba8().enumerate_pixels() {
        let alpha = *a as f32 / 255.0;
        let bg = (1.0 - alpha) * 255.0;
        rgb.put_pixel(x, y, image::Rgb([
            (*r as f32 * alpha + bg) as u8,
            (*g as f32 * alpha + bg) as u8,
            (*b as f32 * alpha + bg) as u8,
        ]));
    }

    let raw = rgb.as_raw();
    let jpeg_data = turbojpeg::compress_image(
        raw,
        width as usize,
        height as usize,
        turbojpeg::PixelFormat::RGB,
        quality,
        turbojpeg::Subsamp::Sub2x2,  // 4:2:0 chrominance subsampling (standard)
    )?;

    fs::write(dst, jpeg_data.as_slice())?;
    Ok(())
}

fn main() -> Result<(), Box> {
    png_to_jpg_turbo("photo.png", "photo.jpg", 85)?;
    println!("Done");
    Ok(())
}

turbojpeg is 2-3x faster than the image crate's JPEG encoder for large images. For processing 1000+ images, this difference is significant. The downside: it requires the libjpeg-turbo system library.

Method 3: ChangeThisFile API (reqwest, no C deps)

The API handles PNG-to-JPG server-side. Source auto-detected from filename — pass target=jpg. Free tier: 1,000 conversions/month, no card needed.

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["multipart", "blocking"] }
use reqwest::blocking::{Client, multipart};
use std::fs;
use std::path::Path;

const API_KEY: &str = "ctf_sk_your_key_here";

fn png_to_jpg_api(src: &str, dst: &str) -> Result<(), Box> {
    let client = Client::builder()
        .timeout(std::time::Duration::from_secs(60))
        .build()?;

    let file_bytes = fs::read(src)?;
    let filename = Path::new(src).file_name().unwrap().to_string_lossy().to_string();

    let form = multipart::Form::new()
        .part(
            "file",
            multipart::Part::bytes(file_bytes)
                .file_name(filename)
                .mime_str("image/png")?,
        )
        .text("target", "jpg");

    let resp = client
        .post("https://changethisfile.com/v1/convert")
        .header("Authorization", format!("Bearer {}", API_KEY))
        .multipart(form)
        .send()?;

    if !resp.status().is_success() {
        return Err(format!("API error: {}", resp.status()).into());
    }

    fs::write(dst, resp.bytes()?)?;
    Ok(())
}

fn main() -> Result<(), Box> {
    png_to_jpg_api("image.png", "image.jpg")?;
    println!("Done");
    Ok(())
}

When to use each

ApproachBest forTradeoff
image cratePure Rust, WASM targets, cross-compilationSlower JPEG encoding than turbojpeg
turbojpegHigh-throughput batch, performance-critical pipelinesRequires libjpeg-turbo system library
ChangeThisFile APINo image libraries, minimal binary, serverlessNetwork call per image; 25MB free tier limit

Production tips

  • Always handle alpha channels before JPEG encoding. to_rgb8() drops alpha by truncating — transparent pixels become black. Composite onto a white or custom-colored background first.
  • Use BufWriter for JPEG output. The JPEG encoder makes many small writes — wrapping the file in BufWriter significantly reduces I/O syscalls.
  • Quality 85 is a good JPEG default. Quality 95+ adds file size with minimal visual benefit. Quality 75-80 is often indistinguishable and 25-40% smaller.
  • Parallelize with Rayon. PNG-to-JPEG conversion is CPU-bound. par_iter() across a Vec of input paths scales linearly with core count with zero changes to the conversion logic.
  • turbojpeg Sub2x2 is standard for photos. 4:2:0 subsampling (Sub2x2) reduces chroma resolution, which human vision is less sensitive to. Use Sub1x1 (4:4:4) for screenshots or text images.

The image crate is the right default for most Rust projects — pure Rust, zero C deps, and handles quality correctly. turbojpeg is worth adding for high-throughput batch pipelines. For scripts where you don't want any image crate dependency, the API handles it via reqwest. Free tier: 1,000 conversions/month.