use anyhow::{Context, Result, anyhow};
use std::collections::BTreeMap;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use swash::FontRef;
use swash::scale::{Render, ScaleContext, Scaler, Source};
use zeno::Format;
const NOTO_URL: &str = "https://cdn.jsdelivr.net/gh/notofonts/noto-cjk@main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf";
const SIZE: f32 = 64.0;
const DIM: usize = 64;
const LOW_CONF: f32 = 0.55;
const PUA: std::ops::RangeInclusive<u32> = 0xE000..=0xF8FF;
pub async fn run(font: &str, out: &Path, base_font: Option<&Path>) {
if let Err(e) = try_run(font, out, base_font).await {
eprintln!("✗ gen-fontmap 失败:{e:#}");
std::process::exit(1);
}
}
async fn try_run(font: &str, out: &Path, base_font: Option<&Path>) -> Result<()> {
let enc_sfnt = to_sfnt(load_font(font).await.context("载入加密字体")?)?;
let enc = FontRef::from_index(&enc_sfnt, 0).ok_or_else(|| anyhow!("加密字体解析失败"))?;
let base_bytes = match base_font {
Some(p) => std::fs::read(p).with_context(|| format!("读取基准字体 {}", p.display()))?,
None => ensure_noto().await?,
};
let base_sfnt = to_sfnt(base_bytes)?;
let base = FontRef::from_index(&base_sfnt, 0).ok_or_else(|| anyhow!("基准字体解析失败"))?;
let pua: Vec<char> = PUA
.filter_map(char::from_u32)
.filter(|&c| enc.charmap().map(c) != 0)
.collect();
if pua.is_empty() {
return Err(anyhow!(
"加密字体里没有私有区(PUA)字形,可能不是字体反爬字体"
));
}
eprintln!("加密字体覆盖 {} 个 PUA 码点", pua.len());
let cands = baseline_candidates();
eprintln!("基准候选字 {} 个,渲染中…", cands.len());
let mut bctx = ScaleContext::new();
let mut bscaler = bctx.builder(base).size(SIZE).hint(false).build();
let cand_vecs: Vec<(char, Vec<f32>)> = cands
.iter()
.filter_map(|&c| render_norm(&mut bscaler, base.charmap().map(c)).map(|v| (c, v)))
.collect();
let mut ectx = ScaleContext::new();
let mut escaler = ectx.builder(enc).size(SIZE).hint(false).build();
let mut map: BTreeMap<String, String> = BTreeMap::new();
let mut low = Vec::new();
for &c in &pua {
let Some(v) = render_norm(&mut escaler, enc.charmap().map(c)) else {
continue;
};
let (best, score) = cand_vecs
.iter()
.map(|(cc, cv)| (*cc, dot(&v, cv)))
.max_by(|a, b| a.1.total_cmp(&b.1))
.ok_or_else(|| anyhow!("基准候选为空"))?;
map.insert(format!("{:04X}", c as u32), best.to_string());
if score < LOW_CONF {
low.push((c, best, score));
}
}
std::fs::write(out, serde_json::to_string(&map)?)
.with_context(|| format!("写出 {}", out.display()))?;
eprintln!("✓ 已生成 {} 项映射 → {}", map.len(), out.display());
if !low.is_empty() {
eprintln!("⚠ {} 个低置信(<{LOW_CONF})映射,建议人工核对:", low.len());
for (c, best, score) in &low {
eprintln!(" U+{:04X} → {best} ({score:.2})", *c as u32);
}
}
Ok(())
}
fn render_norm(scaler: &mut Scaler<'_>, gid: u16) -> Option<Vec<f32>> {
let img = Render::new(&[Source::Outline])
.format(Format::Alpha)
.render(scaler, gid)?;
let (w, h) = (img.placement.width as usize, img.placement.height as usize);
if w == 0 || h == 0 {
return None;
}
let mut g = vec![0f32; DIM * DIM];
let ox = DIM.saturating_sub(w) / 2;
let oy = DIM.saturating_sub(h) / 2;
for y in 0..h.min(DIM) {
for x in 0..w.min(DIM) {
g[(oy + y) * DIM + ox + x] = img.data[y * w + x] as f32;
}
}
let norm = g.iter().map(|v| v * v).sum::<f32>().sqrt();
if norm < 1e-6 {
return None;
}
for v in &mut g {
*v /= norm;
}
Some(g)
}
fn dot(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
fn baseline_candidates() -> Vec<char> {
let mut out = gb2312_level1();
out.extend('0'..='9');
out.extend('A'..='Z');
out.extend('a'..='z');
out
}
fn gb2312_level1() -> Vec<char> {
let mut out = Vec::with_capacity(3755);
for hi in 0xB0u8..=0xD7 {
for lo in 0xA1u8..=0xFE {
let bytes = [hi, lo];
let (s, _, had_err) = encoding_rs::GBK.decode(&bytes);
if had_err {
continue;
}
if let Some(c) = s.chars().next()
&& ('\u{4E00}'..='\u{9FFF}').contains(&c)
{
out.push(c);
}
}
}
out
}
fn to_sfnt(bytes: Vec<u8>) -> Result<Vec<u8>> {
if woff2::decode::is_woff2(&bytes) {
woff2::decode::convert_woff2_to_ttf(&mut Cursor::new(bytes))
.map_err(|e| anyhow!("woff2 解压失败:{e:?}"))
} else {
Ok(bytes)
}
}
async fn load_font(src: &str) -> Result<Vec<u8>> {
if src.starts_with("http://") || src.starts_with("https://") {
Ok(reqwest::get(src)
.await?
.error_for_status()?
.bytes()
.await?
.to_vec())
} else {
std::fs::read(src).with_context(|| format!("读取 {src}"))
}
}
async fn ensure_noto() -> Result<Vec<u8>> {
let dir = crate::utils::novel_catch_dir()?.join("gen-fontmap");
let path: PathBuf = dir.join("NotoSansCJKsc-Regular.otf");
if path.exists() {
return Ok(std::fs::read(&path)?);
}
eprintln!("未指定 --base-font,自动下载基准字体 Noto Sans CJK SC(~16MB)…");
let bytes = reqwest::get(NOTO_URL)
.await?
.error_for_status()?
.bytes()
.await?
.to_vec();
std::fs::create_dir_all(&dir)?;
std::fs::write(&path, &bytes)?;
Ok(bytes)
}