332 lines
12 KiB
Rust
332 lines
12 KiB
Rust
use std::fs::File;
|
|
use std::io::{Cursor, Read};
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use image::codecs::gif::GifDecoder;
|
|
use image::codecs::webp::WebPDecoder;
|
|
use image::metadata::LoopCount;
|
|
use image::{AnimationDecoder, ImageFormat, ImageReader};
|
|
use zip::ZipArchive;
|
|
|
|
use crate::browser::natural_cmp_with_case;
|
|
|
|
const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024;
|
|
const MAX_IMAGE_DIMENSION: u32 = 30_000;
|
|
const MAX_IMAGE_PIXELS: u64 = 100_000_000;
|
|
const MAX_ANIMATION_FRAMES: usize = 1_000;
|
|
const MAX_ANIMATION_BYTES: usize = 512 * 1024 * 1024;
|
|
const MIN_FRAME_DELAY: Duration = Duration::from_millis(10);
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ArchivePage {
|
|
pub index: usize,
|
|
pub name: String,
|
|
}
|
|
|
|
pub struct DecodedPage {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub frames: Vec<DecodedFrame>,
|
|
pub loop_count: Option<u32>,
|
|
}
|
|
|
|
pub struct DecodedFrame {
|
|
pub rgba: Vec<u8>,
|
|
pub delay: Duration,
|
|
}
|
|
|
|
pub fn list_pages(path: &Path, case_insensitive: bool) -> Result<Vec<ArchivePage>> {
|
|
let file = File::open(path)
|
|
.with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?;
|
|
let mut archive = ZipArchive::new(file)
|
|
.with_context(|| format!("올바른 ZIP 파일이 아닙니다: {}", path.display()))?;
|
|
let mut pages = Vec::new();
|
|
|
|
for index in 0..archive.len() {
|
|
let entry = archive.by_index(index)?;
|
|
if entry.is_file() && is_supported_image_name(entry.name()) {
|
|
pages.push(ArchivePage {
|
|
index,
|
|
name: entry.name().to_owned(),
|
|
});
|
|
}
|
|
}
|
|
|
|
pages.sort_by(|left, right| natural_cmp_with_case(&left.name, &right.name, case_insensitive));
|
|
Ok(pages)
|
|
}
|
|
|
|
pub fn decode_page(path: &Path, page: &ArchivePage) -> Result<DecodedPage> {
|
|
let file = File::open(path)
|
|
.with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?;
|
|
let mut archive = ZipArchive::new(file)?;
|
|
let entry = archive
|
|
.by_index(page.index)
|
|
.with_context(|| format!("ZIP 항목을 찾을 수 없습니다: {}", page.name))?;
|
|
|
|
if entry.size() > MAX_ENTRY_SIZE {
|
|
bail!(
|
|
"이미지 압축 해제 크기가 제한을 초과합니다: {} (최대 256MB)",
|
|
page.name
|
|
);
|
|
}
|
|
|
|
let capacity = usize::try_from(entry.size()).context("이미지 크기를 처리할 수 없습니다")?;
|
|
let mut encoded = Vec::with_capacity(capacity);
|
|
entry
|
|
.take(MAX_ENTRY_SIZE + 1)
|
|
.read_to_end(&mut encoded)
|
|
.with_context(|| format!("ZIP 이미지를 읽을 수 없습니다: {}", page.name))?;
|
|
if encoded.len() as u64 > MAX_ENTRY_SIZE {
|
|
bail!("실제 압축 해제 크기가 제한을 초과합니다: {}", page.name);
|
|
}
|
|
|
|
decode_image_bytes(&encoded, &page.name)
|
|
}
|
|
|
|
pub fn decode_image_file(path: &Path, display_name: &str) -> Result<DecodedPage> {
|
|
let file = File::open(path)
|
|
.with_context(|| format!("이미지 파일을 열 수 없습니다: {display_name}"))?;
|
|
let mut encoded = Vec::new();
|
|
file.take(MAX_ENTRY_SIZE + 1)
|
|
.read_to_end(&mut encoded)
|
|
.with_context(|| format!("이미지 파일을 읽을 수 없습니다: {display_name}"))?;
|
|
if encoded.len() as u64 > MAX_ENTRY_SIZE {
|
|
bail!("이미지 크기가 제한을 초과합니다: {display_name} (최대 256MB)");
|
|
}
|
|
decode_image_bytes(&encoded, display_name)
|
|
}
|
|
|
|
fn decode_image_bytes(encoded: &[u8], name: &str) -> Result<DecodedPage> {
|
|
let reader = ImageReader::new(Cursor::new(encoded))
|
|
.with_guessed_format()
|
|
.with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?;
|
|
let format = reader.format();
|
|
let (width, height) = reader
|
|
.into_dimensions()
|
|
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?;
|
|
validate_dimensions(width, height, name)?;
|
|
|
|
let animated = match format {
|
|
Some(ImageFormat::Gif) => {
|
|
let decoder = GifDecoder::new(Cursor::new(encoded))
|
|
.with_context(|| format!("GIF를 디코딩할 수 없습니다: {name}"))?;
|
|
let loop_count = animation_loop_count(decoder.loop_count());
|
|
Some((decoder.into_frames(), loop_count))
|
|
}
|
|
Some(ImageFormat::WebP) => {
|
|
let decoder = WebPDecoder::new(Cursor::new(encoded))
|
|
.with_context(|| format!("WebP를 디코딩할 수 없습니다: {name}"))?;
|
|
decoder.has_animation().then(|| {
|
|
let loop_count = animation_loop_count(decoder.loop_count());
|
|
(decoder.into_frames(), loop_count)
|
|
})
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some((frames, loop_count)) = animated {
|
|
return decoded_animation(width, height, frames, loop_count, name);
|
|
}
|
|
|
|
let image = image::load_from_memory(encoded)
|
|
.with_context(|| format!("이미지를 디코딩할 수 없습니다: {name}"))?;
|
|
let rgba = image.into_rgba8();
|
|
|
|
Ok(DecodedPage {
|
|
width,
|
|
height,
|
|
frames: vec![DecodedFrame {
|
|
rgba: rgba.into_raw(),
|
|
delay: Duration::ZERO,
|
|
}],
|
|
loop_count: Some(1),
|
|
})
|
|
}
|
|
|
|
fn decoded_animation(
|
|
width: u32,
|
|
height: u32,
|
|
frames: impl Iterator<Item = image::ImageResult<image::Frame>>,
|
|
loop_count: Option<u32>,
|
|
name: &str,
|
|
) -> Result<DecodedPage> {
|
|
let expected_bytes = usize::try_from(width)?
|
|
.checked_mul(usize::try_from(height)?)
|
|
.and_then(|pixels| pixels.checked_mul(4))
|
|
.context("애니메이션 프레임 크기를 처리할 수 없습니다")?;
|
|
let mut decoded = Vec::new();
|
|
let mut total_bytes = 0_usize;
|
|
for (index, frame) in frames.enumerate() {
|
|
if index >= MAX_ANIMATION_FRAMES {
|
|
bail!(
|
|
"애니메이션 프레임 수가 제한을 초과합니다: {name} (최대 {MAX_ANIMATION_FRAMES}개)"
|
|
);
|
|
}
|
|
let frame =
|
|
frame.with_context(|| format!("애니메이션 프레임을 디코딩할 수 없습니다: {name}"))?;
|
|
total_bytes = total_bytes
|
|
.checked_add(expected_bytes)
|
|
.context("애니메이션 전체 크기를 처리할 수 없습니다")?;
|
|
if total_bytes > MAX_ANIMATION_BYTES {
|
|
bail!("애니메이션 디코딩 크기가 제한을 초과합니다: {name} (최대 512MB)");
|
|
}
|
|
let delay = Duration::from(frame.delay()).max(MIN_FRAME_DELAY);
|
|
let rgba = frame.into_buffer();
|
|
if rgba.dimensions() != (width, height) || rgba.len() != expected_bytes {
|
|
bail!("애니메이션 프레임 크기가 일치하지 않습니다: {name}");
|
|
}
|
|
decoded.push(DecodedFrame {
|
|
rgba: rgba.into_raw(),
|
|
delay,
|
|
});
|
|
}
|
|
if decoded.is_empty() {
|
|
bail!("애니메이션에 표시할 프레임이 없습니다: {name}");
|
|
}
|
|
Ok(DecodedPage {
|
|
width,
|
|
height,
|
|
frames: decoded,
|
|
loop_count,
|
|
})
|
|
}
|
|
|
|
fn animation_loop_count(loop_count: LoopCount) -> Option<u32> {
|
|
match loop_count {
|
|
LoopCount::Infinite => None,
|
|
LoopCount::Finite(count) => Some(count.get()),
|
|
}
|
|
}
|
|
|
|
fn validate_dimensions(width: u32, height: u32, name: &str) -> Result<()> {
|
|
let pixels = u64::from(width) * u64::from(height);
|
|
if width == 0
|
|
|| height == 0
|
|
|| width > MAX_IMAGE_DIMENSION
|
|
|| height > MAX_IMAGE_DIMENSION
|
|
|| pixels > MAX_IMAGE_PIXELS
|
|
{
|
|
bail!("이미지 크기가 안전 제한을 초과합니다: {name} ({width}x{height})");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn is_supported_image_name(name: &str) -> bool {
|
|
let extension = Path::new(name)
|
|
.extension()
|
|
.and_then(|extension| extension.to_str());
|
|
extension.is_some_and(|extension| {
|
|
matches!(
|
|
extension.to_ascii_lowercase().as_str(),
|
|
"jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "avif"
|
|
)
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::io::Write;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn filters_supported_image_extensions_case_insensitively() {
|
|
for name in [
|
|
"1.JPG", "2.jpeg", "3.png", "4.WebP", "5.gif", "6.bmp", "7.avif",
|
|
] {
|
|
assert!(is_supported_image_name(name), "{name}");
|
|
}
|
|
assert!(!is_supported_image_name("notes.txt"));
|
|
assert!(!is_supported_image_name("folder/"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_oversized_dimensions() {
|
|
assert!(validate_dimensions(10_000, 10_000, "valid.png").is_ok());
|
|
assert!(validate_dimensions(10_001, 10_000, "large.png").is_err());
|
|
assert!(validate_dimensions(30_001, 1, "wide.png").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn lists_and_decodes_images_from_a_zip() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"comicviewer-archive-test-{}.zip",
|
|
std::process::id()
|
|
));
|
|
let mut encoded_png = Cursor::new(Vec::new());
|
|
let image = image::RgbaImage::from_pixel(2, 3, image::Rgba([10, 20, 30, 255]));
|
|
image::DynamicImage::ImageRgba8(image)
|
|
.write_to(&mut encoded_png, image::ImageFormat::Png)
|
|
.unwrap();
|
|
|
|
let file = File::create(&path).unwrap();
|
|
let mut archive = zip::ZipWriter::new(file);
|
|
let options = zip::write::SimpleFileOptions::default();
|
|
archive.start_file("page10.png", options).unwrap();
|
|
archive.write_all(encoded_png.get_ref()).unwrap();
|
|
archive.start_file("page2.png", options).unwrap();
|
|
archive.write_all(encoded_png.get_ref()).unwrap();
|
|
archive.start_file("notes.txt", options).unwrap();
|
|
archive.write_all(b"ignored").unwrap();
|
|
archive.finish().unwrap();
|
|
|
|
let pages = list_pages(&path, true).unwrap();
|
|
assert_eq!(
|
|
pages
|
|
.iter()
|
|
.map(|page| page.name.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["page2.png", "page10.png"]
|
|
);
|
|
let decoded = decode_page(&path, &pages[0]).unwrap();
|
|
assert_eq!((decoded.width, decoded.height), (2, 3));
|
|
assert_eq!(decoded.frames[0].rgba.len(), 2 * 3 * 4);
|
|
|
|
std::fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_a_standalone_image() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let path = directory.path().join("photo.png");
|
|
let image = image::RgbaImage::from_pixel(4, 5, image::Rgba([1, 2, 3, 255]));
|
|
image.save(&path).unwrap();
|
|
|
|
let decoded = decode_image_file(&path, "photo.png").unwrap();
|
|
assert_eq!((decoded.width, decoded.height), (4, 5));
|
|
assert_eq!(decoded.frames[0].rgba.len(), 4 * 5 * 4);
|
|
}
|
|
|
|
#[test]
|
|
fn decodes_all_animated_gif_frames() {
|
|
let mut encoded = Vec::new();
|
|
let first = image::Frame::from_parts(
|
|
image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255])),
|
|
0,
|
|
0,
|
|
image::Delay::from_numer_denom_ms(20, 1),
|
|
);
|
|
let second = image::Frame::from_parts(
|
|
image::RgbaImage::from_pixel(2, 1, image::Rgba([0, 0, 255, 255])),
|
|
0,
|
|
0,
|
|
image::Delay::from_numer_denom_ms(30, 1),
|
|
);
|
|
let mut encoder = image::codecs::gif::GifEncoder::new(&mut encoded);
|
|
encoder
|
|
.set_repeat(image::codecs::gif::Repeat::Finite(2))
|
|
.unwrap();
|
|
encoder.encode_frames([first, second]).unwrap();
|
|
drop(encoder);
|
|
|
|
let decoded = decode_image_bytes(&encoded, "animated.gif").unwrap();
|
|
|
|
assert_eq!(decoded.frames.len(), 2);
|
|
assert_eq!(decoded.frames[0].delay, Duration::from_millis(20));
|
|
assert_eq!(decoded.frames[1].delay, Duration::from_millis(30));
|
|
assert_eq!(decoded.loop_count, Some(2));
|
|
assert_ne!(decoded.frames[0].rgba, decoded.frames[1].rgba);
|
|
}
|
|
}
|