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

+27 -8
View File
@@ -64,6 +64,10 @@ use std::path::Path;
use unlucky_design::palette::{self, ImageInput, ImageSource, Seed};
mod thumbnail;
pub use thumbnail::{thumbnail, THUMBNAIL_MAX_EDGE};
/// 交给 `unlucky_design` 前允许的最大边长(像素)。
///
/// 256 的依据:量化器自己只采 65536 个点(≈256²),再大的图在 `crates/design` 里也会被
@@ -186,31 +190,46 @@ pub fn input<'a>(loaded: &'a Result<Rgba8, ExtractError>) -> ImageInput<'a> {
}
fn finish<R: std::io::BufRead + std::io::Seek>(
mut reader: image::ImageReader<R>,
reader: image::ImageReader<R>,
) -> Result<Rgba8, ExtractError> {
Ok(fit(decode_with_limits(reader)?, MAX_EDGE))
}
/// 解码一个 reader(带 [`MAX_SOURCE_EDGE`] 头尺寸上限),**不缩放**。
///
/// 从 [`finish`] 里拆出来只有一个理由:取色([`load`])与缩略图([`thumbnail`])要**不同的
/// 目标边长**,而「读文件 + 嗅探格式 + 卡解压炸弹」这段必须只有一份(两份就会有一份忘了卡上限)。
pub(crate) fn decode_with_limits<R: std::io::BufRead + std::io::Seek>(
mut reader: image::ImageReader<R>,
) -> Result<image::RgbaImage, ExtractError> {
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_SOURCE_EDGE);
limits.max_image_height = Some(MAX_SOURCE_EDGE);
reader.limits(limits);
let decoded = reader.decode().map_err(ExtractError::Decode)?;
let rgba = decoded.to_rgba8();
Ok(decoded.to_rgba8())
}
/// 把解码结果等比缩到**长边 ≤ `max_edge`**(不放大:本来就小的图原样交付)。
///
/// `thumbnail` 是整数盒式采样:对取色(只关心色彩分布)与缩略图(160px 的墙)都够,
/// 且比 Lanczos 快一个量级。
pub(crate) fn fit(rgba: image::RgbaImage, max_edge: u32) -> Rgba8 {
let (width, height) = (rgba.width(), rgba.height());
let scaled = if width.max(height) > MAX_EDGE {
// 等比缩到长边 = MAX_EDGE。`thumbnail` 是整数快速采样,对取色(只关心色彩分布)
// 足够,且比 Lanczos 快一个量级。
let factor = f64::from(MAX_EDGE) / f64::from(width.max(height));
let scaled = if width.max(height) > max_edge {
let factor = f64::from(max_edge) / f64::from(width.max(height));
let target_width = ((f64::from(width) * factor).round() as u32).max(1);
let target_height = ((f64::from(height) * factor).round() as u32).max(1);
image::imageops::thumbnail(&rgba, target_width, target_height)
} else {
rgba
};
Ok(Rgba8 {
Rgba8 {
width: scaled.width(),
height: scaled.height(),
pixels: scaled.into_raw(),
})
}
}
#[cfg(test)]
+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));
}
+56
View File
@@ -0,0 +1,56 @@
//! 缩略图:路径 → 限尺寸的 [`Rgba8`](D54 第 1 条,M251 · SAVESHOTS)。
//!
//! ## 为什么落在本 crate
//!
//! D54 定的分层没有变:图片解码**只在本 crate**(`crates/design` 零第三方依赖的代价就是把解码
//! 摊到这里)。截图是 PNG,本 crate 的 `image` 已经开了 `png`/`jpeg`/`bmp` 解码 feature,
//! 所以加这条路**零新依赖、零新 crate、零新 feature**——只是把「解码 + 限尺寸」这段已有的能力
//! 换个目标边长([`THUMBNAIL_MAX_EDGE`])暴露一次。
//!
//! ## 不落盘
//!
//! 这里**只返回像素**,不写任何缓存文件(D54 第 2 条):写 PNG 需要编码器,而编码器只为一份
//! 磁盘缓存引入不划算;内存缓存归调用方(presenter 的 `host::saves::thumbs`)。
//!
//! ## 失败不 panic
//!
//! 读不到 / 不是图片 / 头里写着 40000×40000,全都变成 [`ExtractError`](与取色那条路同一族
//! 错误)。调用方据此给「逐条可见」的失败原因——**不许**在这里「读不到就画个灰块」。
//!
//! 与 [`crate::load`] 的区别只有一条:目标边长。`load` 钉 `MAX_EDGE = 256`(取色只需要色彩分布),
//! 这里由调用方给(缩略图墙用 160)。
//!
//! ```no_run
//! use unlucky_palette_extract::{thumbnail, THUMBNAIL_MAX_EDGE};
//!
//! match thumbnail("screenshots/2024-05-01_10.00.00.png", THUMBNAIL_MAX_EDGE) {
//! Ok(thumb) => println!("{}×{}", thumb.width(), thumb.height()),
//! Err(error) => eprintln!("这张图没有缩略图:{error}"),
//! }
//! ```
use std::path::Path;
use crate::{decode_with_limits, fit, ExtractError, Rgba8};
/// 缩略图的最大边(像素)。D54 定 160。
///
/// 依据:一屏缩略图墙按 4 列排、详情列宽约 700px ⇒ 每格 ≈ 160px。再大只是多占内存
/// (160² × 4B ≈ 100KB/张,60 张 ≈ 6MB),肉眼在墙上看不出差别。
pub const THUMBNAIL_MAX_EDGE: u32 = 160;
/// 从文件路径解码并缩到**长边 ≤ `max_edge`**(等比、不放大),返回 RGBA8 像素。
///
/// 格式按**魔数**嗅探,不看扩展名(与 [`crate::load`] 同一条路:用户把 `.jpg` 改名成 `.png`
/// 也照样读得出来)。`max_edge = 0` 按 1 处理(防调用方传 0 得到 0×0 的图)。
///
/// # Errors
///
/// - [`ExtractError::Io`]:文件不存在 / 无权限 / 被占用 / 打不开;
/// - [`ExtractError::Decode`]:不是认得的图片、文件截断、或头尺寸超过 16384(解压炸弹)。
pub fn thumbnail(path: impl AsRef<Path>, max_edge: u32) -> Result<Rgba8, ExtractError> {
let max_edge = max_edge.max(1);
let reader = image::ImageReader::open(path).map_err(ExtractError::Io)?;
let reader = reader.with_guessed_format().map_err(ExtractError::Io)?;
Ok(fit(decode_with_limits(reader)?, max_edge))
}