Initial Comicviewer implementation

This commit is contained in:
burnintuna 2026-07-21 02:12:05 +09:00
commit cb01395612
20 changed files with 4940 additions and 0 deletions

178
src/archive.rs Normal file
View file

@ -0,0 +1,178 @@
use std::fs::File;
use std::io::{Cursor, Read};
use std::path::Path;
use anyhow::{Context, Result, bail};
use image::ImageReader;
use zip::ZipArchive;
use crate::browser::natural_cmp;
const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024;
const MAX_IMAGE_DIMENSION: u32 = 30_000;
const MAX_IMAGE_PIXELS: u64 = 100_000_000;
#[derive(Debug, Clone)]
pub struct ArchivePage {
pub index: usize,
pub name: String,
}
pub struct DecodedPage {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
pub fn list_pages(path: &Path) -> 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(&left.name, &right.name));
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);
}
let reader = ImageReader::new(Cursor::new(&encoded))
.with_guessed_format()
.with_context(|| format!("이미지 형식을 확인할 수 없습니다: {}", page.name))?;
let (width, height) = reader
.into_dimensions()
.with_context(|| format!("이미지 크기를 읽을 수 없습니다: {}", page.name))?;
validate_dimensions(width, height, &page.name)?;
let image = image::load_from_memory(&encoded)
.with_context(|| format!("이미지를 디코딩할 수 없습니다: {}", page.name))?;
let rgba = image.into_rgba8();
Ok(DecodedPage {
width,
height,
rgba: rgba.into_raw(),
})
}
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).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.rgba.len(), 2 * 3 * 4);
std::fs::remove_file(path).unwrap();
}
}