feat(palette-extract): 加 thumbnail(path, max_edge) 出限尺寸缩略图(M251 · D54)

D54 第 1 条:解码与缩放留在 palette-extract(截图是 PNG,现有 png/jpeg/bmp
解码 feature 已覆盖,零新依赖、零新 crate、零新 feature)。

- 把 finish() 拆成 decode_with_limits()(读 + 嗅探 + 卡解压炸弹)与 fit()(等比缩、
  不放大),取色与缩略图共用同一段解码,只是目标边长不同;
- 新增 thumbnail(path, max_edge) -> Result<Rgba8, ExtractError>,失败给结构化错误
  (Io / Decode),不 panic、不返回灰图;max_edge=0 按 1 处理;
- THUMBNAIL_MAX_EDGE = 160(D54 第 5 条)。

用例 5 条:真 PNG 等比缩到给定长边、小图不放大、坏 PNG 给 Decode、缺文件给 Io、
0 边按 1 处理。
This commit is contained in:
AskaEth committed 2026-11-11 16:15:37 +08:00
1 parent 5487f661d9
commit 798bfb279b
3 files changed
+174 -8

No files matched your search

+91
View File
@@ -187,3 +187,94 @@ fn input_borrows_without_copying() {
ImageInput::Undecodable => panic!("刚解码成功却报了 undecodable"),
}
}
// ---------------------------------------------------------------- 缩略图(M251 · D54)
/// 一次性临时目录(本 crate 不引第三方 tempfile:整仓都没有)。
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 写一个真 PNG 到临时目录,返回 `(目录守卫, 文件路径)`。
fn png_on_disk(tag: &str, image: &RgbaImage) -> (TempDir, std::path::PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("palette-thumb-{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
let path = dir.join("shot.png");
std::fs::write(&path, encode_png(image)).expect("写 PNG");
(TempDir(dir), path)
}
/// 真 PNG → 等比缩到给定长边。
///
/// 抓得住:`thumbnail` 拿错目标边长(例如误用取色那条路的 `MAX_EDGE = 256`)、宽高比被拉平、
/// 缩放后字节数对不上 `width × height × 4`。
/// 抓不住:缩放**画质**(盒式采样的观感只能靠出图看);像素内容是否正确(只查尺寸与不透明通道)。
#[test]
fn thumbnail_scales_a_real_png_to_the_asked_edge() {
let (_dir, path) = png_on_disk("scale", &solid_image(40, 20, [0x2E, 0x7D, 0x4F]));
let thumb = thumbnail(&path, 10).expect("真 PNG 应当出缩略图");
assert_eq!((thumb.width(), thumb.height()), (10, 5), "等比缩到长边 10");
assert_eq!(thumb.pixels().len(), 10 * 5 * 4);
// 纯色图缩完还是那个色(盒式平均不该把纯色抹成别的色)。
assert_eq!(&thumb.pixels()[..4], &[0x2E, 0x7D, 0x4F, 0xFF]);
}
/// 小图**不放大**:图本身比墙上的格子小就原样交付(放大只会糊)。
///
/// 抓得住:有人在 `fit` 里把「长边超限才缩」改成无条件缩(那会把 8×6 的图拉成 160×120)。
#[test]
fn thumbnail_never_upscales() {
let (_dir, path) = png_on_disk("small", &solid_image(8, 6, [0x67, 0x50, 0xA4]));
let thumb = thumbnail(&path, THUMBNAIL_MAX_EDGE).expect("小图也应当成功");
assert_eq!((thumb.width(), thumb.height()), (8, 6));
}
/// 坏文件给**结构化错误**,不 panic、不返回灰图。
///
/// 抓得住:把 `?` 换成 `unwrap`/`unwrap_or_default`(前者 panic 让用例挂,后者返回一张图让
/// `expect_err` 挂)。
/// 抓不住:错误文案的措辞(那是 `image` 的原文,只进括号)。
#[test]
fn a_broken_png_is_a_decode_error_not_a_panic() {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("palette-thumb-bad-{}-{seq}", std::process::id()));
std::fs::create_dir_all(&dir).expect("建临时目录");
let guard = TempDir(dir.clone());
let path = dir.join("2024-05-01_10.00.00.png");
std::fs::write(&path, b"\x89PNG\r\n\x1a\n truncated garbage").expect("写坏文件");
match thumbnail(&path, THUMBNAIL_MAX_EDGE) {
Err(ExtractError::Decode(_)) => {}
other => panic!("坏 PNG 应当给 Decode 错误,实际 {other:?}"),
}
drop(guard);
}
/// 文件不存在 ⇒ `Io`(与「解不出来」分开:一个是没读到,一个是读到了但读不懂)。
#[test]
fn a_missing_file_is_an_io_error() {
let path = std::env::temp_dir().join("palette-thumb-does-not-exist.png");
let _ = std::fs::remove_file(&path);
match thumbnail(&path, THUMBNAIL_MAX_EDGE) {
Err(ExtractError::Io(_)) => {}
other => panic!("不存在的文件应当给 Io 错误,实际 {other:?}"),
}
}
/// `max_edge = 0` 按 1 处理(防调用方传 0 得到 0×0 的图,墙上一格空着还查不出原因)。
#[test]
fn a_zero_edge_is_treated_as_one() {
let (_dir, path) = png_on_disk("zero", &solid_image(40, 20, [0x3C, 0x8D, 0xBC]));
let thumb = thumbnail(&path, 0).expect("0 也应当能出图");
assert_eq!((thumb.width(), thumb.height()), (1, 1));
}