Release v0.2.1

This commit is contained in:
burnintuna 2026-07-22 14:42:23 +09:00
parent ffa8bc93ba
commit 048df6355e
10 changed files with 1467 additions and 254 deletions

View file

@ -1,9 +1,13 @@
use std::fs::File;
use std::io::{Cursor, Read};
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use image::ImageReader;
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;
@ -11,6 +15,9 @@ 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 {
@ -21,7 +28,13 @@ pub struct ArchivePage {
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>> {
@ -90,11 +103,33 @@ 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();
@ -102,10 +137,69 @@ fn decode_image_bytes(encoded: &[u8], name: &str) -> Result<DecodedPage> {
Ok(DecodedPage {
width,
height,
rgba: rgba.into_raw(),
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
@ -188,7 +282,7 @@ mod tests {
);
let decoded = decode_page(&path, &pages[0]).unwrap();
assert_eq!((decoded.width, decoded.height), (2, 3));
assert_eq!(decoded.rgba.len(), 2 * 3 * 4);
assert_eq!(decoded.frames[0].rgba.len(), 2 * 3 * 4);
std::fs::remove_file(path).unwrap();
}
@ -202,6 +296,37 @@ mod tests {
let decoded = decode_image_file(&path, "photo.png").unwrap();
assert_eq!((decoded.width, decoded.height), (4, 5));
assert_eq!(decoded.rgba.len(), 4 * 5 * 4);
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);
}
}