use std::path::Path;
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "webp", "bmp"];
pub(crate) const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
pub(crate) fn is_image(path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return false;
};
IMAGE_EXTS.contains(&ext.to_ascii_lowercase().as_str())
}
fn is_cjk(c: char) -> bool {
matches!(c as u32, 0x4E00..=0x9FFF | 0x3400..=0x4DBF | 0xF900..=0xFAFF)
}
pub(crate) fn clean_cjk_spaces(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
for (i, &c) in chars.iter().enumerate() {
if c == ' ' {
let prev = out.chars().last();
let next = chars.get(i + 1).copied();
if let (Some(p), Some(n)) = (prev, next)
&& is_cjk(p)
&& is_cjk(n)
{
continue;
}
}
out.push(c);
}
out
}
pub(crate) fn dual_form_content(raw: &str) -> String {
let raw = raw.trim();
if raw.is_empty() {
return String::new();
}
let cleaned = clean_cjk_spaces(raw);
if cleaned == raw {
raw.to_string()
} else {
format!("{raw}\n{cleaned}")
}
}
#[cfg(windows)]
mod engine_impl {
use std::path::{Path, PathBuf};
use windows::Graphics::Imaging::{BitmapDecoder, SoftwareBitmap};
use windows::Media::Ocr::OcrEngine;
use windows::Storage::{FileAccessMode, StorageFile};
use windows::core::{HSTRING, RuntimeType};
use windows_future::IAsyncOperation;
fn block_on<T: RuntimeType + 'static>(
op: windows::core::Result<IAsyncOperation<T>>,
) -> windows::core::Result<T> {
pollster::block_on(op?)
}
pub fn is_available() -> bool {
OcrEngine::TryCreateFromUserProfileLanguages().is_ok()
}
pub struct Engine(OcrEngine);
pub fn create_engine() -> anyhow::Result<Engine> {
OcrEngine::TryCreateFromUserProfileLanguages()
.map(Engine)
.map_err(|e| anyhow::anyhow!("创建 OCR 引擎失败(可能没有任何语言包): {e}"))
}
fn strip_extended_prefix(path: &Path) -> PathBuf {
PathBuf::from(crate::display_path(&path.to_string_lossy()))
}
fn load_software_bitmap(path: &Path) -> windows::core::Result<SoftwareBitmap> {
let normalized = strip_extended_prefix(path);
let hpath = HSTRING::from(normalized.as_os_str());
let file = block_on(StorageFile::GetFileFromPathAsync(&hpath))?;
let stream = block_on(file.OpenAsync(FileAccessMode::Read))?;
let decoder = block_on(BitmapDecoder::CreateAsync(&stream))?;
block_on(decoder.GetSoftwareBitmapAsync())
}
pub fn recognize(engine: &Engine, path: &Path) -> anyhow::Result<String> {
let bitmap = load_software_bitmap(path)
.map_err(|e| anyhow::anyhow!("加载图片失败 {}: {e}", path.display()))?;
let result = block_on(engine.0.RecognizeAsync(&bitmap))
.map_err(|e| anyhow::anyhow!("OCR 识别失败 {}: {e}", path.display()))?;
Ok(result.Text()?.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_extended_prefix_removes_drive_prefix() {
assert_eq!(
strip_extended_prefix(Path::new(r"\\?\C:\a\b.png")),
PathBuf::from(r"C:\a\b.png")
);
}
#[test]
fn strip_extended_prefix_converts_unc_form() {
assert_eq!(
strip_extended_prefix(Path::new(r"\\?\UNC\server\share\a.png")),
PathBuf::from(r"\\server\share\a.png")
);
}
#[test]
fn strip_extended_prefix_leaves_normal_paths_untouched() {
assert_eq!(
strip_extended_prefix(Path::new(r"C:\a\b.png")),
PathBuf::from(r"C:\a\b.png")
);
}
}
}
#[cfg(not(windows))]
mod engine_impl {
use std::path::Path;
pub fn is_available() -> bool {
false
}
pub struct Engine;
pub fn create_engine() -> anyhow::Result<Engine> {
anyhow::bail!("OCR 仅支持 Windows")
}
pub fn recognize(_engine: &Engine, _path: &Path) -> anyhow::Result<String> {
anyhow::bail!("OCR 仅支持 Windows")
}
}
pub fn is_available() -> bool {
engine_impl::is_available()
}
pub(crate) use engine_impl::{create_engine, recognize};
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
#[test]
fn is_image_matches_supported_extensions_case_insensitively() {
assert!(is_image(Path::new("shot.png")));
assert!(is_image(Path::new("shot.PNG")));
assert!(is_image(Path::new("photo.JPEG")));
assert!(is_image(Path::new("a.webp")));
assert!(is_image(Path::new("a.bmp")));
assert!(!is_image(Path::new("note.txt")));
assert!(!is_image(Path::new("noext")));
}
#[test]
fn clean_cjk_spaces_removes_spaces_between_cjk_characters() {
let raw = "分 布 式 限 流 器 的 令 牌 桶 实 现";
assert_eq!(clean_cjk_spaces(raw), "分布式限流器的令牌桶实现");
}
#[test]
fn clean_cjk_spaces_keeps_spaces_touching_latin_text() {
let raw = "Rust 调 用 Windows.Media.Ocr API OcrEngine.TryCreateFromLanguage(zh-Hans) 截 图 文 字 进 索 引 milestone 4 spike test";
let cleaned = clean_cjk_spaces(raw);
assert_eq!(
cleaned,
"Rust 调用 Windows.Media.Ocr API OcrEngine.TryCreateFromLanguage(zh-Hans) 截图文字进索引 milestone 4 spike test"
);
}
#[test]
fn clean_cjk_spaces_is_identity_for_pure_latin_text() {
let raw = "milestone 4 spike test with multiple english words";
assert_eq!(clean_cjk_spaces(raw), raw);
}
#[test]
fn dual_form_content_stacks_raw_and_cleaned_when_they_differ() {
let raw = "分 布 式 限 流 器";
let content = dual_form_content(raw);
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines, vec!["分 布 式 限 流 器", "分布式限流器"]);
}
#[test]
fn dual_form_content_does_not_duplicate_when_cleaning_is_a_no_op() {
let raw = "milestone 4 spike test";
assert_eq!(dual_form_content(raw), raw);
}
#[test]
fn dual_form_content_of_blank_text_is_empty() {
assert_eq!(dual_form_content(" \n "), "");
assert_eq!(dual_form_content(""), "");
}
}