Files
ttree/src-tauri/src/ocr/image.rs
T

112 lines
4.5 KiB
Rust

//! 将系统 PNG 解码并绘制成 Vision 可以稳定读取的 8-bit sRGB 位图。
#![cfg(target_os = "macos")]
use objc2_core_foundation::{CFData, CFRetained, CGPoint, CGRect, CGSize};
use objc2_core_graphics::{
kCGColorSpaceSRGB, CGBitmapContextCreate, CGBitmapContextCreateImage, CGBitmapInfo,
CGColorSpace, CGContext, CGImage, CGImageAlphaInfo,
};
use objc2_image_io::CGImageSource;
const MIN_DIMENSION: usize = 128;
const BORDER: usize = 16;
const MAX_PIXELS: usize = 32_000_000;
pub(super) fn prepare_png(bytes: &[u8]) -> Result<CFRetained<CGImage>, String> {
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 24 {
return Err("截图不是有效的 PNG 图片,请重新框选。".to_string());
}
// 在原生解码前限制 IHDR 尺寸,避免异常文件触发巨量分配。
if &bytes[12..16] != b"IHDR" {
return Err("截图 PNG 缺少图片尺寸信息,请重新框选。".to_string());
}
let width = u32::from_be_bytes(bytes[16..20].try_into().unwrap()) as usize;
let height = u32::from_be_bytes(bytes[20..24].try_into().unwrap()) as usize;
canvas_size(width, height)?;
let data = CFData::from_bytes(bytes);
// SAFETY: 使用 ImageIO 默认选项,data 的内容在整个解码期间不变。
let source = unsafe { CGImageSource::with_data(&data, None) }
.ok_or_else(|| "系统无法读取截图 PNG,请重新框选。".to_string())?;
let image = unsafe { source.image_at_index(0, None) }
.ok_or_else(|| "截图 PNG 解码失败,请重新框选。".to_string())?;
let width = CGImage::width(Some(&image));
let height = CGImage::height(Some(&image));
let (canvas_width, canvas_height) = canvas_size(width, height)?;
// 显示器截图可能包含灰度、透明度、广色域或 16-bit 像素。统一为
// 8-bit sRGB、32-bit RGBX,并用白色背景消除透明通道的歧义。
let color_space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB }))
.ok_or_else(|| "创建 OCR 色彩空间失败".to_string())?;
let bitmap_info = CGImageAlphaInfo::NoneSkipLast.0 | CGBitmapInfo::ByteOrder32Big.bits();
// SAFETY: null 让 CoreGraphics 自行管理内存;尺寸已检查且每像素 4 字节。
let context = unsafe {
CGBitmapContextCreate(
std::ptr::null_mut(),
canvas_width,
canvas_height,
8,
canvas_width * 4,
Some(&color_space),
bitmap_info,
)
}
.ok_or_else(|| "创建 OCR 位图失败".to_string())?;
CGContext::set_rgb_fill_color(Some(&context), 1.0, 1.0, 1.0, 1.0);
CGContext::fill_rect(Some(&context), rect(0, 0, canvas_width, canvas_height));
// 保持原始像素大小;窄单行截图加边距,避免极小图像进入文字模型。
CGContext::draw_image(
Some(&context),
rect((canvas_width - width) / 2, (canvas_height - height) / 2, width, height),
Some(&image),
);
CGBitmapContextCreateImage(Some(&context))
.ok_or_else(|| "生成 OCR 位图失败".to_string())
}
fn canvas_size(width: usize, height: usize) -> Result<(usize, usize), String> {
if width == 0 || height == 0 {
return Err("截图尺寸为空,请重新框选。".to_string());
}
let padded = width
.checked_add(BORDER * 2)
.zip(height.checked_add(BORDER * 2))
.map(|(width, height)| (width.max(MIN_DIMENSION), height.max(MIN_DIMENSION)))
.filter(|(width, height)| {
width.checked_mul(*height).is_some_and(|pixels| pixels <= MAX_PIXELS)
});
padded.ok_or_else(|| "截图区域过大,请缩小框选范围后重试。".to_string())
}
fn rect(x: usize, y: usize, width: usize, height: usize) -> CGRect {
CGRect {
origin: CGPoint { x: x as _, y: y as _ },
size: CGSize { width: width as _, height: height as _ },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pads_single_line_captures_without_stretching_text() {
assert_eq!(canvas_size(400, 20).unwrap(), (432, 128));
assert_eq!(canvas_size(1, 1).unwrap(), (128, 128));
}
#[test]
fn refuses_zero_or_excessive_dimensions() {
assert!(canvas_size(0, 20).is_err());
assert!(canvas_size(20, 0).is_err());
assert!(canvas_size(usize::MAX, 20).is_err());
assert!(canvas_size(10_000, 10_000).is_err());
}
#[test]
fn rejects_truncated_png_without_entering_vision() {
assert!(prepare_png(b"\x89PNG\r\n\x1a\n").is_err());
}
}