use std::fs::{self, File};
use std::io::{BufReader, Cursor};
use std::path::Path;
use ::image::codecs::jpeg::JpegEncoder;
use ::image::codecs::png::{CompressionType, FilterType as PngFilter, PngEncoder};
use ::image::imageops::FilterType;
use ::image::metadata::Orientation;
use ::image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader};
use serde::{Deserialize, Serialize};
use super::read::{random_hex, read_head, sniff, HEAD_BYTES};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Deserialize)]
pub struct ImageOpts {
pub original: bool,
pub max_side: u32,
pub quality: f64,
pub max_bytes: u64,
pub max_pixels: u64,
}
#[derive(Debug, Serialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ImageOutcome {
Image { path: String, temp: bool, size: u64, width: u32, height: u32, format: String, content_type: String },
File { downgraded: &'static str },
}
const TOO_LARGE: ImageOutcome = ImageOutcome::File { downgraded: "image_too_large" };
const UNSUPPORTED: ImageOutcome = ImageOutcome::File { downgraded: "unsupported_type" };
fn image_format(format: &str) -> Option<ImageFormat> {
Some(match format {
"jpeg" => ImageFormat::Jpeg,
"png" => ImageFormat::Png,
"gif" => ImageFormat::Gif,
"webp" => ImageFormat::WebP,
"bmp" => ImageFormat::Bmp,
"tiff" => ImageFormat::Tiff,
_ => return None,
})
}
fn open_decoder(real: &Path, format: ImageFormat) -> Option<impl ImageDecoder> {
let file = File::open(real).ok()?;
let mut reader = ImageReader::new(BufReader::new(file));
reader.set_format(format);
reader.into_decoder().ok()
}
fn header(real: &Path, format: ImageFormat) -> Option<(u32, u32, Orientation)> {
let mut decoder = open_decoder(real, format)?;
let (w, h) = decoder.dimensions();
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
Some((w, h, orientation))
}
fn swaps_sides(orientation: Orientation) -> bool {
matches!(orientation, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH)
}
pub fn prepare_image(path: &str, real: &Path, temp: &Path, opts: &ImageOpts) -> Result<ImageOutcome> {
let meta = fs::metadata(real).map_err(|e| Error::from_io_read(&e))?;
let mut file = File::open(real).map_err(|e| Error::from_io_read(&e))?;
let head = read_head(&mut file, HEAD_BYTES).map_err(|e| Error::from_io_read(&e))?;
drop(file);
let format = sniff(&head);
let accepted = matches!(format, "jpeg" | "png" | "gif" | "webp");
let head_info = image_format(format).and_then(|f| header(real, f));
if let Some((w, h, _)) = head_info {
if w as u64 * h as u64 > opts.max_pixels {
return Ok(TOO_LARGE);
}
}
if format == "gif" || (opts.original && accepted) {
if meta.len() > opts.max_bytes {
return Ok(TOO_LARGE);
}
let Some((w, h, orientation)) = head_info else { return Ok(UNSUPPORTED) };
let (width, height) = if format == "jpeg" && swaps_sides(orientation) { (h, w) } else { (w, h) };
let content_type = super::read::content_type(format).unwrap_or("application/octet-stream").to_string();
if format == "jpeg" || format == "png" {
let bytes = fs::read(real).map_err(|e| Error::from_io_read(&e))?;
let stripped = strip_metadata(&bytes, format);
let out = write_temp(temp, if format == "jpeg" { "jpg" } else { "png" }, &stripped)?;
return Ok(ImageOutcome::Image { path: out, temp: true, size: stripped.len() as u64, width, height, format: format.into(), content_type });
}
return Ok(ImageOutcome::Image { path: path.to_string(), temp: false, size: meta.len(), width, height, format: format.into(), content_type });
}
let image = match decode(real, format, opts.max_pixels) {
Ok(image) => image,
Err(outcome) => return Ok(outcome),
};
let (w, h) = (image.width(), image.height());
if w as u64 * h as u64 > opts.max_pixels {
return Ok(TOO_LARGE);
}
let scale = if opts.original { 1.0 } else { (opts.max_side as f64 / w.max(h) as f64).min(1.0) };
let width = ((w as f64 * scale).round() as u32).max(1);
let height = ((h as f64 * scale).round() as u32).max(1);
let image = if (width, height) == (w, h) { image } else { image.resize_exact(width, height, FilterType::Lanczos3) };
let keep_png = (format == "png" || format == "webp") && image.color().has_alpha();
let bytes = encode(&image, keep_png, opts.quality)?;
if bytes.len() as u64 > opts.max_bytes {
return Ok(TOO_LARGE);
}
let (out_format, ext, content_type) = if keep_png { ("png", "png", "image/png") } else { ("jpeg", "jpg", "image/jpeg") };
let out = write_temp(temp, ext, &bytes)?;
Ok(ImageOutcome::Image { path: out, temp: true, size: bytes.len() as u64, width, height, format: out_format.into(), content_type: content_type.into() })
}
fn decode(real: &Path, format: &str, max_pixels: u64) -> std::result::Result<DynamicImage, ImageOutcome> {
if format == "heic" || format == "avif" {
return heic::decode(real, max_pixels);
}
let Some(image_format) = image_format(format) else { return Err(UNSUPPORTED) };
let mut decoder = open_decoder(real, image_format).ok_or(UNSUPPORTED)?;
let (w, h) = decoder.dimensions();
if w as u64 * h as u64 > max_pixels {
return Err(TOO_LARGE);
}
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
let mut image = DynamicImage::from_decoder(decoder).map_err(|err| {
log::debug!("图片解码失败:{err}");
UNSUPPORTED
})?;
image.apply_orientation(orientation);
Ok(image)
}
fn encode(image: &DynamicImage, png: bool, quality: f64) -> Result<Vec<u8>> {
let mut out = Cursor::new(Vec::new());
let result = if png {
let encoder = PngEncoder::new_with_quality(&mut out, CompressionType::Default, PngFilter::Adaptive);
DynamicImage::ImageRgba8(image.to_rgba8()).write_with_encoder(encoder)
} else {
let q = (quality * 100.0).round().clamp(1.0, 100.0) as u8;
DynamicImage::ImageRgb8(image.to_rgb8()).write_with_encoder(JpegEncoder::new_with_quality(&mut out, q))
};
result.map_err(|e| Error::internal(format!("图片编码失败:{e}")))?;
Ok(out.into_inner())
}
fn write_temp(temp: &Path, ext: &str, bytes: &[u8]) -> Result<String> {
fs::create_dir_all(temp).map_err(|e| Error::from_io(&e))?;
let path = temp.join(format!("{}-image.{ext}", random_hex()));
if let Err(err) = fs::write(&path, bytes) {
let _ = fs::remove_file(&path);
return Err(Error::from_io(&err));
}
Ok(path.to_string_lossy().into_owned())
}
#[cfg(feature = "heic")]
mod heic {
use super::*;
use libheif_rs::{ColorSpace, HeifContext, LibHeif, RgbChroma};
pub fn decode(real: &Path, max_pixels: u64) -> std::result::Result<DynamicImage, ImageOutcome> {
let failed = |err: libheif_rs::HeifError| {
log::debug!("HEIC 解码失败:{err}");
UNSUPPORTED
};
let ctx = HeifContext::read_from_file(&real.to_string_lossy()).map_err(failed)?;
let handle = ctx.primary_image_handle().map_err(failed)?;
if handle.width() as u64 * handle.height() as u64 > max_pixels {
return Err(TOO_LARGE);
}
let alpha = handle.has_alpha_channel();
let chroma = if alpha { RgbChroma::Rgba } else { RgbChroma::Rgb };
let image = LibHeif::new().decode(&handle, ColorSpace::Rgb(chroma), None).map_err(failed)?;
let planes = image.planes();
let plane = planes.interleaved.ok_or(UNSUPPORTED)?;
let channels = if alpha { 4 } else { 3 };
let row = plane.width as usize * channels;
let mut pixels = Vec::with_capacity(row * plane.height as usize);
for y in 0..plane.height as usize {
pixels.extend_from_slice(&plane.data[y * plane.stride..y * plane.stride + row]);
}
let image = if alpha {
::image::RgbaImage::from_raw(plane.width, plane.height, pixels).map(DynamicImage::ImageRgba8)
} else {
::image::RgbImage::from_raw(plane.width, plane.height, pixels).map(DynamicImage::ImageRgb8)
};
image.ok_or(UNSUPPORTED)
}
}
#[cfg(not(feature = "heic"))]
mod heic {
use super::*;
pub fn decode(_real: &Path, _max_pixels: u64) -> std::result::Result<DynamicImage, ImageOutcome> {
Err(UNSUPPORTED)
}
}
pub fn strip_metadata(buf: &[u8], format: &str) -> Vec<u8> {
match format {
"jpeg" => strip_jpeg(buf),
"png" => strip_png(buf),
_ => buf.to_vec(),
}
}
fn be16(b: &[u8], i: usize) -> Option<usize> {
Some(u16::from_be_bytes([*b.get(i)?, *b.get(i + 1)?]) as usize)
}
fn strip_jpeg(buf: &[u8]) -> Vec<u8> {
let mut out = buf[..buf.len().min(2)].to_vec();
let mut i = 2;
let mut orientation: Option<u16> = None;
let mut inserted = false;
while i + 4 <= buf.len() && buf[i] == 0xff {
let marker = buf[i + 1];
if marker == 0xda {
break; }
let Some(len) = be16(buf, i + 2) else { break };
let segment = &buf[i..(i + 2 + len).min(buf.len())];
if marker == 0xe1 {
if buf.get(i + 4..i + 8) == Some(b"Exif") {
orientation = exif_orientation(buf, i + 10, len.saturating_sub(8));
}
} else {
if !inserted && marker != 0xe0 {
if let Some(o) = orientation.filter(|o| *o != 1) {
out.extend_from_slice(&minimal_exif(o));
}
inserted = true;
}
out.extend_from_slice(segment);
}
i += 2 + len;
}
if !inserted {
if let Some(o) = orientation.filter(|o| *o != 1) {
out.extend_from_slice(&minimal_exif(o));
}
}
if i < buf.len() {
out.extend_from_slice(&buf[i..]);
}
out
}
fn exif_orientation(buf: &[u8], start: usize, len: usize) -> Option<u16> {
let tiff = buf.get(start..(start + len).min(buf.len()))?;
let little = tiff.get(0..2)? == [0x49, 0x49];
let u16_at = |i: usize| -> Option<u16> {
let b = [*tiff.get(i)?, *tiff.get(i + 1)?];
Some(if little { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
};
let u32_at = |i: usize| -> Option<u32> {
let b = [*tiff.get(i)?, *tiff.get(i + 1)?, *tiff.get(i + 2)?, *tiff.get(i + 3)?];
Some(if little { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
};
let ifd = u32_at(4)? as usize;
let count = u16_at(ifd)? as usize;
for k in 0..count {
let entry = ifd + 2 + k * 12;
if u16_at(entry)? == 0x0112 {
return u16_at(entry + 8);
}
}
None
}
fn minimal_exif(orientation: u16) -> Vec<u8> {
let tiff = [0x4d, 0x4d, 0x00, 0x2a, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, orientation as u8, 0, 0, 0, 0, 0, 0];
let mut body = b"Exif\0\0".to_vec();
body.extend_from_slice(&tiff);
let len = body.len() + 2;
let mut out = vec![0xff, 0xe1, (len >> 8) as u8, (len & 0xff) as u8];
out.extend_from_slice(&body);
out
}
fn strip_png(buf: &[u8]) -> Vec<u8> {
let mut out = buf[..buf.len().min(8)].to_vec();
let mut i = 8;
while i + 12 <= buf.len() {
let len = u32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]) as usize;
let kind = &buf[i + 4..i + 8];
let end = i.saturating_add(12).saturating_add(len);
if !matches!(kind, b"eXIf" | b"tEXt" | b"iTXt" | b"zTXt") {
out.extend_from_slice(&buf[i..end.min(buf.len())]);
}
i = end;
if kind == b"IEND" {
break;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ::image::{Rgb, RgbImage, Rgba, RgbaImage};
fn jpeg_with_orientation(w: u32, h: u32, orientation: u16) -> Vec<u8> {
let img = RgbImage::from_fn(w, h, |x, _| if x < w / 2 { Rgb([255, 0, 0]) } else { Rgb([0, 0, 255]) });
let mut plain = Cursor::new(Vec::new());
DynamicImage::ImageRgb8(img).write_with_encoder(JpegEncoder::new_with_quality(&mut plain, 95)).unwrap();
let plain = plain.into_inner();
let mut tiff = vec![0x4d, 0x4d, 0x00, 0x2a, 0, 0, 0, 8, 0, 2];
tiff.extend_from_slice(&[0x01, 0x12, 0, 3, 0, 0, 0, 1]);
tiff.extend_from_slice(&orientation.to_be_bytes());
tiff.extend_from_slice(&[0, 0]);
tiff.extend_from_slice(&[0x88, 0x25, 0, 4, 0, 0, 0, 1, 0, 0, 0, 0]);
tiff.extend_from_slice(&[0, 0, 0, 0]);
let mut body = b"Exif\0\0".to_vec();
body.extend_from_slice(&tiff);
let len = body.len() + 2;
let mut out = vec![0xff, 0xd8, 0xff, 0xe1, (len >> 8) as u8, len as u8];
out.extend_from_slice(&body);
out.extend_from_slice(&plain[2..]);
out
}
fn opts(original: bool) -> ImageOpts {
ImageOpts { original, max_side: 2048, quality: 0.85, max_bytes: 20 * 1024 * 1024, max_pixels: 50_000_000 }
}
fn has_exif(bytes: &[u8]) -> bool {
bytes.windows(6).any(|w| w == b"Exif\0\0")
}
#[test]
fn rotates_and_strips_exif() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("photo.jpg");
fs::write(&src, jpeg_with_orientation(40, 20, 6)).unwrap();
assert!(has_exif(&fs::read(&src).unwrap()));
let temp = dir.path().join("temp");
let out = prepare_image(src.to_str().unwrap(), &src, &temp, &opts(false)).unwrap();
let ImageOutcome::Image { path, temp: is_temp, width, height, format, content_type, size } = out else { panic!("{out:?}") };
assert!(is_temp && path.starts_with(temp.to_str().unwrap()));
assert_eq!((width, height, format.as_str(), content_type.as_str()), (20, 40, "jpeg", "image/jpeg"));
let bytes = fs::read(&path).unwrap();
assert_eq!(size, bytes.len() as u64);
assert!(!has_exif(&bytes), "输出不应带 EXIF");
let decoded = ::image::load_from_memory(&bytes).unwrap().to_rgb8();
assert_eq!(decoded.dimensions(), (20, 40));
let top = decoded.get_pixel(10, 5);
let bottom = decoded.get_pixel(10, 35);
assert!(top[0] > 200 && top[2] < 60, "上边应为红色:{top:?}");
assert!(bottom[2] > 200 && bottom[0] < 60, "下边应为蓝色:{bottom:?}");
}
#[test]
fn original_keeps_pixels_and_orientation_only() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("photo.jpg");
let original = jpeg_with_orientation(40, 20, 6);
fs::write(&src, &original).unwrap();
let out = prepare_image(src.to_str().unwrap(), &src, dir.path(), &opts(true)).unwrap();
let ImageOutcome::Image { path, width, height, .. } = out else { panic!() };
assert_eq!((width, height), (20, 40));
let bytes = fs::read(&path).unwrap();
let source_exif = &original[4..original.len().min(4 + 46)];
assert!(!bytes.windows(source_exif.len()).any(|w| w == source_exif));
let minimal = minimal_exif(6);
assert_eq!(bytes.windows(6).filter(|w| *w == b"Exif\0\0").count(), 1);
let at = bytes.windows(minimal.len()).position(|w| w == minimal.as_slice()).expect("应有最小 EXIF");
assert_eq!(exif_orientation(&bytes, at + 10, 26), Some(6));
assert!(bytes.ends_with(&original[original.len() - 100..]));
}
#[test]
fn scales_and_keeps_png_with_alpha() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("wide.png");
let img = RgbaImage::from_fn(3000, 100, |x, _| Rgba([0, 0, 0, (x % 256) as u8]));
img.save(&src).unwrap();
let out = prepare_image(src.to_str().unwrap(), &src, dir.path(), &opts(false)).unwrap();
let ImageOutcome::Image { width, height, format, .. } = out else { panic!() };
assert_eq!((width, height, format.as_str()), (2048, 68, "png"));
let opaque = dir.path().join("opaque.png");
RgbImage::from_pixel(10, 10, Rgb([1, 2, 3])).save(&opaque).unwrap();
let out = prepare_image(opaque.to_str().unwrap(), &opaque, dir.path(), &opts(false)).unwrap();
assert!(matches!(out, ImageOutcome::Image { ref format, .. } if format == "jpeg"));
}
#[test]
fn downgrades() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("photo.jpg");
fs::write(&src, jpeg_with_orientation(40, 20, 1)).unwrap();
let mut small = opts(false);
small.max_pixels = 100;
assert_eq!(prepare_image("p", &src, dir.path(), &small).unwrap(), TOO_LARGE);
let mut tiny = opts(false);
tiny.max_bytes = 10;
assert_eq!(prepare_image("p", &src, dir.path(), &tiny).unwrap(), TOO_LARGE);
let junk = dir.path().join("junk.jpg");
fs::write(&junk, b"not an image at all").unwrap();
assert_eq!(prepare_image("p", &junk, dir.path(), &opts(false)).unwrap(), UNSUPPORTED);
let broken = dir.path().join("broken.jpg");
fs::write(&broken, [0xff, 0xd8, 0xff, 0xe0, 0, 4, 0, 0]).unwrap();
assert_eq!(prepare_image("p", &broken, dir.path(), &opts(false)).unwrap(), UNSUPPORTED);
#[cfg(not(feature = "heic"))]
{
let heic = dir.path().join("a.heic");
fs::write(&heic, b"\0\0\0\x18ftypheic\0\0\0\0mif1heic").unwrap();
assert_eq!(prepare_image("p", &heic, dir.path(), &opts(false)).unwrap(), UNSUPPORTED);
}
}
#[test]
fn gif_is_not_reencoded() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("a.gif");
RgbaImage::from_pixel(8, 6, Rgba([1, 2, 3, 255])).save(&src).unwrap();
let out = prepare_image("/x/a.gif", &src, dir.path(), &opts(false)).unwrap();
assert_eq!(
out,
ImageOutcome::Image { path: "/x/a.gif".into(), temp: false, size: fs::metadata(&src).unwrap().len(), width: 8, height: 6, format: "gif".into(), content_type: "image/gif".into() }
);
}
#[test]
fn png_text_chunks_removed() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("a.png");
RgbImage::from_pixel(2, 2, Rgb([9, 9, 9])).save(&src).unwrap();
let bytes = fs::read(&src).unwrap();
let mut with_text = bytes[..33].to_vec();
let text = b"Commentlocation";
with_text.extend_from_slice(&(text.len() as u32).to_be_bytes());
with_text.extend_from_slice(b"tEXt");
with_text.extend_from_slice(text);
with_text.extend_from_slice(&[0, 0, 0, 0]);
with_text.extend_from_slice(&bytes[33..]);
assert_eq!(strip_png(&with_text), bytes);
}
}