1use crate::error::{ApiResult, AppError};
13use std::io::Read;
14use std::path::Path;
15
16#[derive(Debug, Clone)]
18pub struct ExtractedImage {
19 pub name: String,
21 pub data: Vec<u8>,
23}
24
25const IMAGE_EXTS: &[&str] = &[
27 "jpg", "jpeg", "png", "webp", "gif", "avif", "bmp", "tif", "tiff",
28];
29
30pub fn is_image_name(name: &str) -> bool {
32 let lower = name.to_ascii_lowercase();
33 IMAGE_EXTS.iter().any(|e| lower.ends_with(&format!(".{e}")))
34}
35
36fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
38 natord::compare(&a.to_ascii_lowercase(), &b.to_ascii_lowercase())
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum ArchiveKind {
44 TarGz,
46 TarBz2,
48 Tar,
50 Zip,
52 SevenZ,
54 Rar,
56 Gz,
58 Bz2,
60 SingleImage,
62}
63
64fn sniff_archive_kind(bytes: &[u8]) -> Option<ArchiveKind> {
66 if bytes.starts_with(b"Rar!") {
67 return Some(ArchiveKind::Rar);
68 }
69 if bytes.starts_with(b"PK") {
70 return Some(ArchiveKind::Zip);
71 }
72 if bytes.len() >= 6 && bytes[..6] == [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c] {
73 return Some(ArchiveKind::SevenZ);
74 }
75 if bytes.starts_with(b"BZh") {
76 return Some(ArchiveKind::Bz2);
77 }
78 if bytes.starts_with(&[0x1f, 0x8b]) {
79 return Some(ArchiveKind::Gz);
80 }
81 if bytes.len() > 262 && bytes[257..262] == *b"ustar" {
82 return Some(ArchiveKind::Tar);
83 }
84 None
85}
86
87fn archive_kind_from_filename(lower: &str) -> Option<ArchiveKind> {
89 if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
90 Some(ArchiveKind::TarGz)
91 } else if lower.ends_with(".tar.bz2") || lower.ends_with(".tbz2") || lower.ends_with(".tbz") {
92 Some(ArchiveKind::TarBz2)
93 } else if lower.ends_with(".tar") {
94 Some(ArchiveKind::Tar)
95 } else if lower.ends_with(".zip") || lower.ends_with(".cbz") {
96 Some(ArchiveKind::Zip)
97 } else if lower.ends_with(".7z") {
98 Some(ArchiveKind::SevenZ)
99 } else if lower.ends_with(".rar") || lower.ends_with(".cbr") {
100 Some(ArchiveKind::Rar)
101 } else if lower.ends_with(".gz") {
102 Some(ArchiveKind::Gz)
103 } else if lower.ends_with(".bz2") {
104 Some(ArchiveKind::Bz2)
105 } else {
106 None
107 }
108}
109
110fn resolve_archive_kind(filename: &str, bytes: &[u8]) -> Result<ArchiveKind, AppError> {
112 let lower = filename.to_ascii_lowercase();
113 if is_image_name(&lower) || infer_is_image(bytes) {
114 return Ok(ArchiveKind::SingleImage);
115 }
116
117 let sniffed = sniff_archive_kind(bytes);
118 let from_name = archive_kind_from_filename(&lower);
119
120 let kind = match (sniffed, from_name) {
121 (Some(ArchiveKind::Gz), Some(ArchiveKind::TarGz) | None) if is_tar_gz_name(&lower) => {
122 ArchiveKind::TarGz
123 }
124 (Some(ArchiveKind::Bz2), Some(ArchiveKind::TarBz2) | None) if is_tar_bz2_name(&lower) => {
125 ArchiveKind::TarBz2
126 }
127 (Some(k), _) => k,
128 (None, Some(k)) => k,
129 (None, None) => {
130 return Err(AppError::business(format!(
131 "无法识别的图源格式:{filename}(支持 图片/zip/rar/7z/tar/gz/bz2)"
132 )));
133 }
134 };
135 Ok(kind)
136}
137
138fn is_tar_gz_name(lower: &str) -> bool {
140 lower.ends_with(".tar.gz") || lower.ends_with(".tgz")
141}
142
143fn is_tar_bz2_name(lower: &str) -> bool {
145 lower.ends_with(".tar.bz2") || lower.ends_with(".tbz2") || lower.ends_with(".tbz")
146}
147
148fn extract_by_kind(
150 kind: ArchiveKind,
151 filename: &str,
152 bytes: &[u8],
153) -> ApiResult<Vec<ExtractedImage>> {
154 let lower = filename.to_ascii_lowercase();
155 match kind {
156 ArchiveKind::TarGz => collect_tar(flate2::read::GzDecoder::new(bytes)),
157 ArchiveKind::TarBz2 => collect_tar(bzip2_rs::DecoderReader::new(bytes)),
158 ArchiveKind::Tar => collect_tar(bytes),
159 ArchiveKind::Zip => collect_zip(bytes),
160 ArchiveKind::SevenZ => collect_7z(bytes),
161 ArchiveKind::Rar => collect_rar(bytes),
162 ArchiveKind::Gz => collect_single_gz(&lower, bytes),
163 ArchiveKind::Bz2 => collect_single_bz2(&lower, bytes),
164 ArchiveKind::SingleImage => Ok(vec![ExtractedImage {
165 name: filename.to_string(),
166 data: bytes.to_vec(),
167 }]),
168 }
169}
170
171pub fn extract_images(filename: &str, bytes: &[u8]) -> ApiResult<Vec<ExtractedImage>> {
181 let kind = resolve_archive_kind(filename, bytes)?;
182 let mut images = extract_by_kind(kind, filename, bytes)?;
183
184 images.retain(|img| is_image_name(&img.name) || infer_is_image(&img.data));
185 if images.is_empty() {
186 return Err(AppError::business("图源中未找到任何图片喵"));
187 }
188 images.sort_by(|a, b| natural_cmp(&a.name, &b.name));
189 Ok(images)
190}
191
192fn infer_is_image(bytes: &[u8]) -> bool {
194 infer::get(bytes)
195 .map(|t| t.matcher_type() == infer::MatcherType::Image)
196 .unwrap_or(false)
197}
198
199fn collect_zip(bytes: &[u8]) -> ApiResult<Vec<ExtractedImage>> {
201 let reader = std::io::Cursor::new(bytes);
202 let mut archive = zip::ZipArchive::new(reader)
203 .map_err(|e| AppError::business(format!("打开 zip 失败:{e}")))?;
204 let mut out = Vec::new();
205 for i in 0..archive.len() {
206 let mut file = archive
207 .by_index(i)
208 .map_err(|e| AppError::business(format!("读取 zip 条目失败:{e}")))?;
209 if !file.is_file() {
210 continue;
211 }
212 let name = file.name().to_string();
213 if !is_image_name(&name) {
214 continue;
215 }
216 let mut data = Vec::with_capacity(file.size() as usize);
217 file.read_to_end(&mut data)
218 .map_err(|e| AppError::business(format!("解压 zip 条目失败:{e}")))?;
219 out.push(ExtractedImage { name, data });
220 }
221 Ok(out)
222}
223
224fn collect_tar<R: Read>(reader: R) -> ApiResult<Vec<ExtractedImage>> {
226 let mut archive = tar::Archive::new(reader);
227 let mut out = Vec::new();
228 let entries = archive
229 .entries()
230 .map_err(|e| AppError::business(format!("读取 tar 失败:{e}")))?;
231 for entry in entries {
232 let mut entry = entry.map_err(|e| AppError::business(format!("读取 tar 条目失败:{e}")))?;
233 let path = entry
234 .path()
235 .map(|p| p.to_string_lossy().to_string())
236 .unwrap_or_default();
237 if !is_image_name(&path) {
238 continue;
239 }
240 let mut data = Vec::new();
241 entry
242 .read_to_end(&mut data)
243 .map_err(|e| AppError::business(format!("解压 tar 条目失败:{e}")))?;
244 out.push(ExtractedImage { name: path, data });
245 }
246 Ok(out)
247}
248
249fn collect_single_gz(lower_name: &str, bytes: &[u8]) -> ApiResult<Vec<ExtractedImage>> {
251 let mut decoder = flate2::read::GzDecoder::new(bytes);
252 let mut data = Vec::new();
253 decoder
254 .read_to_end(&mut data)
255 .map_err(|e| AppError::business(format!("解压 gz 失败:{e}")))?;
256 let name = lower_name.trim_end_matches(".gz").to_string();
257 Ok(vec![ExtractedImage { name, data }])
258}
259
260fn collect_single_bz2(lower_name: &str, bytes: &[u8]) -> ApiResult<Vec<ExtractedImage>> {
262 let mut decoder = bzip2_rs::DecoderReader::new(bytes);
263 let mut data = Vec::new();
264 decoder
265 .read_to_end(&mut data)
266 .map_err(|e| AppError::business(format!("解压 bz2 失败:{e}")))?;
267 let name = lower_name.trim_end_matches(".bz2").to_string();
268 Ok(vec![ExtractedImage { name, data }])
269}
270
271fn collect_7z(bytes: &[u8]) -> ApiResult<Vec<ExtractedImage>> {
273 let dir =
274 tempfile::tempdir().map_err(|e| AppError::business(format!("创建临时目录失败:{e}")))?;
275 let src = dir.path().join("source.7z");
276 std::fs::write(&src, bytes)
277 .map_err(|e| AppError::business(format!("写入临时 7z 失败:{e}")))?;
278 let out = dir.path().join("out");
279 std::fs::create_dir_all(&out)
280 .map_err(|e| AppError::business(format!("创建解压目录失败:{e}")))?;
281 sevenz_rust::decompress_file(&src, &out)
282 .map_err(|e| AppError::business(format!("解压 7z 失败:{e}")))?;
283 walk_dir_images(&out)
284}
285
286fn collect_rar(bytes: &[u8]) -> ApiResult<Vec<ExtractedImage>> {
288 let dir =
289 tempfile::tempdir().map_err(|e| AppError::business(format!("创建临时目录失败:{e}")))?;
290 let src = dir.path().join("source.rar");
291 std::fs::write(&src, bytes)
292 .map_err(|e| AppError::business(format!("写入临时 rar 失败:{e}")))?;
293 let out = dir.path().join("out");
294 std::fs::create_dir_all(&out)
295 .map_err(|e| AppError::business(format!("创建解压目录失败:{e}")))?;
296 let out_str = out.to_string_lossy().to_string();
297
298 let mut archive = unrar::Archive::new(&src)
299 .open_for_processing()
300 .map_err(|e| AppError::business(format!("打开 rar 失败:{e}")))?;
301 while let Some(header) = archive
302 .read_header()
303 .map_err(|e| AppError::business(format!("读取 rar 头失败:{e}")))?
304 {
305 archive = if header.entry().is_file() {
306 header
307 .extract_with_base(&out_str)
308 .map_err(|e| AppError::business(format!("解压 rar 条目失败:{e}")))?
309 } else {
310 header
311 .skip()
312 .map_err(|e| AppError::business(format!("跳过 rar 目录失败:{e}")))?
313 };
314 }
315 walk_dir_images(&out)
316}
317
318fn walk_dir_images(dir: &Path) -> ApiResult<Vec<ExtractedImage>> {
320 let mut out = Vec::new();
321 for entry in walkdir::WalkDir::new(dir).into_iter().flatten() {
322 if !entry.file_type().is_file() {
323 continue;
324 }
325 let path = entry.path();
326 let name = path
327 .strip_prefix(dir)
328 .unwrap_or(path)
329 .to_string_lossy()
330 .replace('\\', "/");
331 if !is_image_name(&name) {
332 continue;
333 }
334 let data = std::fs::read(path)
335 .map_err(|e| AppError::business(format!("读取解压图片失败:{e}")))?;
336 out.push(ExtractedImage { name, data });
337 }
338 Ok(out)
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
347 fn natural_order_sorts_numerically() {
348 let mut names = ["10.jpg", "2.jpg", "1.jpg"];
349 names.sort_by(|a, b| natural_cmp(a, b));
350 assert_eq!(names, ["1.jpg", "2.jpg", "10.jpg"]);
351 }
352
353 #[test]
355 fn detects_image_names() {
356 assert!(is_image_name("a/b/01.PNG"));
357 assert!(!is_image_name("note.txt"));
358 }
359
360 #[test]
362 fn extract_zip_orders_and_filters_non_image() {
363 use std::io::Write;
364 use zip::write::SimpleFileOptions;
365
366 let mut buf = Vec::new();
367 {
368 let cursor = std::io::Cursor::new(&mut buf);
369 let mut writer = zip::ZipWriter::new(cursor);
370 let opts =
372 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
373 for name in ["10.png", "2.png", "readme.txt", "1.png"] {
374 writer.start_file(name, opts).unwrap();
375 writer.write_all(b"fake-bytes").unwrap();
376 }
377 writer.finish().unwrap();
378 }
379
380 let images = extract_images("pages.zip", &buf).unwrap();
381 let names: Vec<&str> = images.iter().map(|i| i.name.as_str()).collect();
382 assert_eq!(names, ["1.png", "2.png", "10.png"]);
383 }
384
385 #[test]
387 fn unknown_format_errors() {
388 let err = extract_images("note.txt", b"plain text").unwrap_err();
389 assert!(format!("{err:?}").contains("无法识别"));
390 }
391
392 #[test]
394 fn rar_magic_overrides_zip_extension() {
395 assert_eq!(
396 sniff_archive_kind(b"Rar!\x1a\x07\x01\x00"),
397 Some(ArchiveKind::Rar)
398 );
399 let kind = resolve_archive_kind("fake.zip", b"Rar!\x1a\x07\x01\x00").unwrap();
400 assert_eq!(kind, ArchiveKind::Rar);
401 }
402}