fontfix: va subset PDF cho FontFace - them OS/2 + cmap (3,1) format 4 tu /ToUnicode
Build Windows portable / build (push) Canceled after 0s

Build 34 tra bytes font tho nhung OTS cua WebView tu choi het (bat qua CDP
Log): (1) "OS/2: missing required table", (2) "cmap: no supported subtables"
- subset LibreOffice chi co cmap (1,0) format 0, ma tuy bien: ASCII giu
nguyen ma, chu Viet co dau gan ma thap 1-31 (chi /ToUnicode giai nghia duoc).

- fontfix.rs: dung OS/2 v4 (96B) tu head/hhea + co dam/nghieng cua run
  (fsSelection/usWeightClass - khong bi synthesize bold chong); convert cmap
  (1,0) fmt 0/6 -> (3,1) format 4 (segment gop cap lien tiep, moi glyph 1
  segment khi roi rac); ghep lai sfnt: danh muc sort theo tag, pad 4B,
  checksum bang + head.checkSumAdjustment (tong = B1B0AFBA); tag 'true' ->
  0x00010000. Khong phai sfnt (CFF tran) tra nguyen trang.
- tounicode.rs: doc /ToUnicode qua lopdf (bfchar/bfrange ca 2 dang, UTF-16BE
  ke ca surrogate pair; ligature nhieu ky tu bo qua), match BaseFont theo
  suffix sau prefix subset "ABCDEF+", tim ca trong resources cua Form
  XObject (sau 3) cho file Canva. Ma khong co ToUnicode: ASCII identity +
  MacRoman cho ma cao; ma thap khong giai nghia duoc thi bo (ky tu roi xuong
  font ke trong stack CSS).

Kiem chung truoc CI: nhan ban logic sang Node, va bytes THAT cua
NotoSansMono-Bold subset (dump tu app) -> FontFace load OK trong WebView2
that (truoc: SyntaxError). Unit test: OS/2 + checksum + format 4 lookup
('A' giu glyph, 'a?' theo ToUnicode) + bfchar/bfrange parser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 11:32:17 +07:00
co-authored by Claude Fable 5
parent f2ebf2deff
commit f77092bff2
5 changed files with 786 additions and 3 deletions
+9 -1
View File
@@ -522,7 +522,15 @@ pub fn font_data(
if data.is_empty() {
return Err(EngineError::Pdfium("font không nhúng trong file".into()));
}
Ok(data.to_vec())
// Subset PDF hay thiếu bảng OS/2 và mang cmap (1,0) OTS không hỗ trợ →
// FontFace của WebView từ chối. Vá: thêm OS/2 (cờ đậm/nghiêng cho
// fsSelection/usWeightClass) + dựng cmap (3,1) format 4; mã tuỳ biến
// (chữ Việt trong subset LibreOffice) giải nghĩa qua /ToUnicode.
let (_, bold, italic) = text_object_style(t);
let raw_name = t.font().name();
let fname = if raw_name.trim().is_empty() { t.font().family() } else { raw_name };
let uni = crate::tounicode::code_to_unicode(input, page_index, &fname);
Ok(crate::fontfix::web_compatible_font(&data, bold, italic, uni.as_ref()))
}
pub fn list_objects(
+514
View File
@@ -0,0 +1,514 @@
//! Vá font subset trích từ PDF cho WebView dùng được qua FontFace.
//!
//! OTS (OpenType Sanitizer của Chromium) BẮT BUỘC font TrueType phải có bảng
//! `OS/2`, nhưng subset nhúng trong PDF thường bị trình tạo file bỏ bảng này
//! (PDF không cần) → `new FontFace(...)` ném "OTS parsing error: OS/2:
//! missing required table". Đã bắt được đúng thông điệp đó qua CDP Log trên
//! font NotoSansMono-Bold subset (12 bảng, đủ name/post/cmap, thiếu mỗi
//! OS/2).
//!
//! Ở đây dựng OS/2 version 4 tối thiểu từ `head` (unitsPerEm) + `hhea`
//! (ascender/descender/lineGap) + cờ đậm/nghiêng lấy từ chính text object,
//! rồi ghép lại sfnt: danh mục bảng sắp theo tag, offset pad 4 byte,
//! checksum từng bảng + head.checkSumAdjustment chuẩn. Sfnt tag `true`
//! (TrueType kiểu Apple) được chuẩn hoá về 0x00010000. Dữ liệu không phải
//! sfnt (CFF trần của font Type1C...) trả nguyên trạng — UI tự rơi về stack
//! font hệ thống.
use std::collections::HashMap;
/// Bytes font sẵn sàng cho FontFace: vá nếu cần, còn lại giữ nguyên.
/// `code_to_uni`: map mã→Unicode từ /ToUnicode của PDF (cho các mã tuỳ biến
/// ngoài ASCII — chữ Việt trong subset LibreOffice); None → chỉ ASCII
/// identity + MacRoman.
pub(crate) fn web_compatible_font(
data: &[u8],
bold: bool,
italic: bool,
code_to_uni: Option<&HashMap<u32, u32>>,
) -> Vec<u8> {
try_fix(data, bold, italic, code_to_uni).unwrap_or_else(|| data.to_vec())
}
const TAG_TRUE: u32 = u32::from_be_bytes(*b"true");
const TAG_OTTO: u32 = u32::from_be_bytes(*b"OTTO");
fn try_fix(
data: &[u8],
bold: bool,
italic: bool,
code_to_uni: Option<&HashMap<u32, u32>>,
) -> Option<Vec<u8>> {
if data.len() < 12 {
return None;
}
let ver = u32::from_be_bytes(data[0..4].try_into().ok()?);
if ver != 0x0001_0000 && ver != TAG_TRUE && ver != TAG_OTTO {
return None; // không phải sfnt (CFF trần...) — không đụng vào
}
let num = u16::from_be_bytes(data[4..6].try_into().ok()?) as usize;
if num == 0 || data.len() < 12 + num * 16 {
return None;
}
let mut tables: Vec<([u8; 4], Vec<u8>)> = Vec::with_capacity(num + 1);
let mut has_os2 = false;
for i in 0..num {
let o = 12 + i * 16;
let tag: [u8; 4] = data[o..o + 4].try_into().ok()?;
let off = u32::from_be_bytes(data[o + 8..o + 12].try_into().ok()?) as usize;
let len = u32::from_be_bytes(data[o + 12..o + 16].try_into().ok()?) as usize;
if off.checked_add(len)? > data.len() {
return None; // danh mục hỏng — trả nguyên trạng cho lành
}
if &tag == b"OS/2" {
has_os2 = true;
}
tables.push((tag, data[off..off + len].to_vec()));
}
// cmap (1,0) format 0/6 — OTS không hỗ trợ → chuyển sang (3,1) format 4.
let mut cmap_changed = false;
if let Some(ci) = tables.iter().position(|(t, _)| t == b"cmap") {
if let Some(converted) = convert_cmap(&tables[ci].1, code_to_uni) {
tables[ci].1 = converted;
cmap_changed = true;
}
}
if has_os2 && ver == 0x0001_0000 && !cmap_changed {
return None; // đã chuẩn sẵn — giữ nguyên bytes gốc
}
if !has_os2 {
let os2 = build_os2(&tables, bold, italic)?;
tables.push((*b"OS/2", os2));
}
tables.sort_by(|a, b| a.0.cmp(&b.0));
Some(assemble(ver, tables))
}
/// MacRoman 0x80–0xFF → Unicode (bảng chuẩn Apple) — fallback cho mã cao khi
/// không có /ToUnicode.
const MACROMAN_HI: [u16; 128] = [
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4,
0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF,
0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020,
0x00B0, 0x00A2, 0x00A3, 0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4,
0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202,
0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1,
0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB, 0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3,
0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA,
0x00FF, 0x0178, 0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A,
0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE, 0x00CF, 0x00CC,
0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF,
0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7,
];
/// cmap chỉ có subtable OTS không hỗ trợ ((1,0) format 0/6 của subset PDF) →
/// dựng lại thành (3,1) format 4 theo Unicode. Đã có subtable Unicode/Windows
/// → None (giữ nguyên). Mã→Unicode: ưu tiên /ToUnicode; thiếu thì ASCII
/// identity (0x20–0x7E) + MacRoman (≥0x80); mã thấp không giải nghĩa được thì
/// bỏ — ký tự đó rơi xuống font kế trong stack CSS.
fn convert_cmap(cmap: &[u8], code_to_uni: Option<&HashMap<u32, u32>>) -> Option<Vec<u8>> {
if cmap.len() < 4 {
return None;
}
let n = u16::from_be_bytes(cmap[2..4].try_into().ok()?) as usize;
if cmap.len() < 4 + n * 8 {
return None;
}
let mut src: Option<(usize, u16)> = None;
for i in 0..n {
let o = 4 + i * 8;
let plat = u16::from_be_bytes(cmap[o..o + 2].try_into().ok()?);
let enc = u16::from_be_bytes(cmap[o + 2..o + 4].try_into().ok()?);
let off = u32::from_be_bytes(cmap[o + 4..o + 8].try_into().ok()?) as usize;
if off + 2 > cmap.len() {
return None;
}
let fmt = u16::from_be_bytes(cmap[off..off + 2].try_into().ok()?);
if plat == 0 || (plat == 3 && (enc == 1 || enc == 10)) {
return None; // đã có subtable Unicode — OTS chấp nhận
}
if plat == 1 && enc == 0 && (fmt == 0 || fmt == 6) {
src = Some((off, fmt));
}
}
let (off, fmt) = src?;
let mut pairs: Vec<(u32, u16)> = Vec::new();
let mut push = |code: u32, glyph: u16| {
if glyph == 0 {
return;
}
let uni = match code_to_uni.and_then(|m| m.get(&code)) {
Some(&u) => u,
None => {
if (0x20..=0x7E).contains(&code) {
code
} else if (0x80..=0xFF).contains(&code) {
u32::from(MACROMAN_HI[(code - 0x80) as usize])
} else {
return; // mã thấp không giải nghĩa được
}
}
};
if uni == 0 || uni > 0xFFFF {
return; // format 4 chỉ chứa BMP
}
pairs.push((uni, glyph));
};
if fmt == 0 {
if off + 6 + 256 > cmap.len() {
return None;
}
for c in 0..256u32 {
push(c, u16::from(cmap[off + 6 + c as usize]));
}
} else {
if off + 10 > cmap.len() {
return None;
}
let first = u32::from(u16::from_be_bytes(cmap[off + 6..off + 8].try_into().ok()?));
let cnt = u16::from_be_bytes(cmap[off + 8..off + 10].try_into().ok()?) as usize;
if off + 10 + cnt * 2 > cmap.len() {
return None;
}
for k in 0..cnt {
let g = u16::from_be_bytes(cmap[off + 10 + k * 2..off + 12 + k * 2].try_into().ok()?);
push(first + k as u32, g);
}
}
if pairs.is_empty() {
return None;
}
pairs.sort_by_key(|p| p.0);
pairs.dedup_by_key(|p| p.0);
Some(build_cmap_format4(&pairs))
}
/// cmap trọn vẹn: 1 subtable (3,1) format 4 từ các cặp (unicode, glyph) đã
/// sort + khử trùng. Cặp liên tiếp cả 2 chiều gộp thành 1 segment.
fn build_cmap_format4(pairs: &[(u32, u16)]) -> Vec<u8> {
struct Seg {
start: u32,
end: u32,
g_start: u16,
delta_fixed: Option<u16>,
}
let mut segs: Vec<Seg> = Vec::new();
for &(u, g) in pairs {
if let Some(last) = segs.last_mut() {
let next_g = last.g_start as u32 + (u - last.start);
if u == last.end + 1 && next_g <= 0xFFFF && next_g == u32::from(g) {
last.end = u;
continue;
}
}
segs.push(Seg { start: u, end: u, g_start: g, delta_fixed: None });
}
segs.push(Seg { start: 0xFFFF, end: 0xFFFF, g_start: 0, delta_fixed: Some(1) });
let n = segs.len();
let sub_len = 16 + n * 8;
let mut pow = 1usize;
let mut es = 0u16;
while pow * 2 <= n {
pow *= 2;
es += 1;
}
let mut sub = vec![0u8; sub_len];
let w16 = |b: &mut [u8], o: usize, v: u16| b[o..o + 2].copy_from_slice(&v.to_be_bytes());
w16(&mut sub, 0, 4);
w16(&mut sub, 2, sub_len as u16);
w16(&mut sub, 4, 0); // language
w16(&mut sub, 6, (n * 2) as u16);
w16(&mut sub, 8, (pow * 2) as u16);
w16(&mut sub, 10, es);
w16(&mut sub, 12, (n * 2 - pow * 2) as u16);
for (i, s) in segs.iter().enumerate() {
w16(&mut sub, 14 + i * 2, s.end as u16); // endCode
w16(&mut sub, 16 + n * 2 + i * 2, s.start as u16); // startCode (sau pad)
let delta = s
.delta_fixed
.unwrap_or_else(|| (u32::from(s.g_start).wrapping_sub(s.start) & 0xFFFF) as u16);
w16(&mut sub, 16 + n * 4 + i * 2, delta); // idDelta
w16(&mut sub, 16 + n * 6 + i * 2, 0); // idRangeOffset
}
let mut cmap = vec![0u8; 12 + sub_len];
w16(&mut cmap, 0, 0); // version
w16(&mut cmap, 2, 1); // numTables
w16(&mut cmap, 4, 3); // platformID Windows
w16(&mut cmap, 6, 1); // encodingID Unicode BMP
cmap[8..12].copy_from_slice(&12u32.to_be_bytes());
cmap[12..].copy_from_slice(&sub);
cmap
}
/// Tổng checksum kiểu sfnt: cộng u32 big-endian, phần dư pad 0.
fn checksum(data: &[u8]) -> u32 {
let mut sum = 0u32;
let mut chunks = data.chunks_exact(4);
for c in &mut chunks {
sum = sum.wrapping_add(u32::from_be_bytes(c.try_into().unwrap()));
}
let rem = chunks.remainder();
if !rem.is_empty() {
let mut last = [0u8; 4];
last[..rem.len()].copy_from_slice(rem);
sum = sum.wrapping_add(u32::from_be_bytes(last));
}
sum
}
fn assemble(orig_ver: u32, tables: Vec<([u8; 4], Vec<u8>)>) -> Vec<u8> {
// 'true' → 0x00010000 (OTS chấp nhận cả hai nhưng chuẩn hoá cho chắc);
// OTTO (CFF) giữ nguyên tag.
let ver = if orig_ver == TAG_OTTO { orig_ver } else { 0x0001_0000 };
let num = tables.len() as u16;
let mut pow = 1u16;
let mut entry_selector = 0u16;
while pow * 2 <= num {
pow *= 2;
entry_selector += 1;
}
let search_range = pow * 16;
let range_shift = num * 16 - search_range;
let mut out = Vec::new();
out.extend_from_slice(&ver.to_be_bytes());
out.extend_from_slice(&num.to_be_bytes());
out.extend_from_slice(&search_range.to_be_bytes());
out.extend_from_slice(&entry_selector.to_be_bytes());
out.extend_from_slice(&range_shift.to_be_bytes());
let mut off = 12 + tables.len() * 16;
let mut dir: Vec<u8> = Vec::new();
let mut bodies: Vec<u8> = Vec::new();
let mut head_off = None;
for (tag, mut body) in tables {
if &tag == b"head" && body.len() >= 12 {
// checkSumAdjustment phải = 0 khi tính checksum — điền lại ở cuối.
body[8..12].fill(0);
head_off = Some(off);
}
let cs = checksum(&body);
dir.extend_from_slice(&tag);
dir.extend_from_slice(&cs.to_be_bytes());
dir.extend_from_slice(&(off as u32).to_be_bytes());
dir.extend_from_slice(&(body.len() as u32).to_be_bytes());
let padded = (body.len() + 3) & !3;
off += padded;
body.resize(padded, 0);
bodies.extend_from_slice(&body);
}
out.extend_from_slice(&dir);
out.extend_from_slice(&bodies);
if let Some(h) = head_off {
let adj = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out));
out[h + 8..h + 12].copy_from_slice(&adj.to_be_bytes());
}
out
}
/// OS/2 version 4 (96 byte) với số liệu suy từ head/hhea — đủ cho OTS và cho
/// trình duyệt chọn đúng mặt đậm/nghiêng, không ảnh hưởng shaping.
fn build_os2(tables: &[([u8; 4], Vec<u8>)], bold: bool, italic: bool) -> Option<Vec<u8>> {
let find = |tag: &[u8; 4]| tables.iter().find(|(t, _)| t == tag).map(|(_, b)| b.as_slice());
let head = find(b"head")?;
let upm = if head.len() >= 20 {
i32::from(u16::from_be_bytes(head[18..20].try_into().ok()?))
} else {
1000
};
let (asc, desc, gap) = match find(b"hhea") {
Some(h) if h.len() >= 10 => (
i16::from_be_bytes(h[4..6].try_into().ok()?),
i16::from_be_bytes(h[6..8].try_into().ok()?),
i16::from_be_bytes(h[8..10].try_into().ok()?),
),
_ => ((upm * 4 / 5) as i16, -((upm / 5) as i16), 0),
};
let mut v: Vec<u8> = Vec::with_capacity(96);
fn u(v: &mut Vec<u8>, x: u16) { v.extend_from_slice(&x.to_be_bytes()); }
fn i(v: &mut Vec<u8>, x: i16) { v.extend_from_slice(&x.to_be_bytes()); }
fn d(v: &mut Vec<u8>, x: u32) { v.extend_from_slice(&x.to_be_bytes()); }
u(&mut v, 4); // version
i(&mut v, (upm / 2) as i16); // xAvgCharWidth (xấp xỉ — không dùng để shaping)
u(&mut v, if bold { 700 } else { 400 }); // usWeightClass
u(&mut v, 5); // usWidthClass = medium
u(&mut v, 0); // fsType: không hạn chế nhúng
let sub = (upm * 65 / 100) as i16;
i(&mut v, sub); // ySubscriptXSize
i(&mut v, sub); // ySubscriptYSize
i(&mut v, 0); // ySubscriptXOffset
i(&mut v, (upm * 14 / 100) as i16); // ySubscriptYOffset
i(&mut v, sub); // ySuperscriptXSize
i(&mut v, sub); // ySuperscriptYSize
i(&mut v, 0); // ySuperscriptXOffset
i(&mut v, (upm * 48 / 100) as i16); // ySuperscriptYOffset
i(&mut v, (upm * 5 / 100) as i16); // yStrikeoutSize
i(&mut v, (upm * 26 / 100) as i16); // yStrikeoutPosition
i(&mut v, 0); // sFamilyClass
v.extend_from_slice(&[0u8; 10]); // panose: any
d(&mut v, 0); d(&mut v, 0); d(&mut v, 0); d(&mut v, 0); // ulUnicodeRange 1–4
v.extend_from_slice(b"FFXT"); // achVendID
let mut fs = 0u16;
if italic { fs |= 0x0001; }
if bold { fs |= 0x0020; }
if fs == 0 { fs = 0x0040; } // REGULAR
u(&mut v, fs); // fsSelection
u(&mut v, 0x0020); // usFirstCharIndex
u(&mut v, 0xFFFD); // usLastCharIndex
i(&mut v, asc); // sTypoAscender
i(&mut v, desc); // sTypoDescender
i(&mut v, gap); // sTypoLineGap
u(&mut v, asc.max(0) as u16); // usWinAscent
u(&mut v, (-(desc.min(0))) as u16); // usWinDescent
d(&mut v, 1); // ulCodePageRange1: Latin 1
d(&mut v, 0); // ulCodePageRange2
i(&mut v, (upm / 2) as i16); // sxHeight
i(&mut v, (upm * 7 / 10) as i16); // sCapHeight
u(&mut v, 0); // usDefaultChar
u(&mut v, 0x0020); // usBreakChar
u(&mut v, 0); // usMaxContext
debug_assert_eq!(v.len(), 96);
Some(v)
}
#[cfg(test)]
mod tests {
use super::*;
/// Sfnt giả tối thiểu: head (54B, upm=1000) + hhea (36B) + glyf giả,
/// KHÔNG có OS/2 — đúng hình dạng subset PDF gây lỗi OTS.
fn fake_subset() -> Vec<u8> {
let mut head = vec![0u8; 54];
head[18..20].copy_from_slice(&1000u16.to_be_bytes()); // unitsPerEm
let mut hhea = vec![0u8; 36];
hhea[4..6].copy_from_slice(&800i16.to_be_bytes()); // ascender
hhea[6..8].copy_from_slice(&(-200i16).to_be_bytes()); // descender
hhea[8..10].copy_from_slice(&90i16.to_be_bytes()); // lineGap
let glyf = vec![1u8, 2, 3, 4, 5]; // lẻ 4 byte để thử pad
let tables: Vec<([u8; 4], Vec<u8>)> =
vec![(*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea)];
assemble(TAG_TRUE, tables)
}
fn table_dir(font: &[u8]) -> Vec<(String, usize, usize)> {
let num = u16::from_be_bytes(font[4..6].try_into().unwrap()) as usize;
(0..num)
.map(|i| {
let o = 12 + i * 16;
(
String::from_utf8_lossy(&font[o..o + 4]).to_string(),
u32::from_be_bytes(font[o + 8..o + 12].try_into().unwrap()) as usize,
u32::from_be_bytes(font[o + 12..o + 16].try_into().unwrap()) as usize,
)
})
.collect()
}
#[test]
fn missing_os2_is_synthesized_with_valid_checksums() {
let subset = fake_subset();
let fixed = web_compatible_font(&subset, true, false, None);
assert_ne!(fixed, subset, "phải được vá");
// Tag chuẩn hoá + có OS/2 + danh mục sắp theo tag.
assert_eq!(&fixed[0..4], &0x0001_0000u32.to_be_bytes());
let dir = table_dir(&fixed);
let tags: Vec<&str> = dir.iter().map(|(t, _, _)| t.as_str()).collect();
assert_eq!(tags, vec!["OS/2", "glyf", "head", "hhea"], "sắp theo tag: {tags:?}");
// OS/2 đọc lại đúng nội dung suy ra.
let (_, off, len) = dir[0].clone();
let os2 = &fixed[off..off + len];
assert_eq!(len, 96);
assert_eq!(u16::from_be_bytes(os2[4..6].try_into().unwrap()), 700, "usWeightClass bold");
assert_eq!(u16::from_be_bytes(os2[62..64].try_into().unwrap()), 0x0020, "fsSelection BOLD");
assert_eq!(
i16::from_be_bytes(os2[68..70].try_into().unwrap()),
800,
"sTypoAscender từ hhea"
);
// Checksum toàn font phải ra hằng số ma thuật khi head.adjustment đã điền.
assert_eq!(checksum(&fixed), 0xB1B0_AFBA, "checkSumAdjustment chuẩn");
// Vá lần 2 phải là no-op (đã có OS/2 + tag chuẩn, không có cmap).
assert_eq!(web_compatible_font(&fixed, true, false, None), fixed);
}
#[test]
fn non_sfnt_data_passes_through() {
let cff = vec![0x01, 0x00, 0x04, 0x04, 0xAA, 0xBB];
assert_eq!(web_compatible_font(&cff, false, false, None), cff);
}
/// cmap (1,0) format 0 kiểu subset LibreOffice: mã ASCII giữ nguyên, mã
/// thấp (1) là chữ có dấu chỉ /ToUnicode giải nghĩa được.
fn fake_subset_with_cmap() -> Vec<u8> {
let mut sub = vec![0u8; 6 + 256];
sub[0..2].copy_from_slice(&0u16.to_be_bytes()); // format 0
sub[2..4].copy_from_slice(&262u16.to_be_bytes());
sub[6 + 0x41] = 5; // 'A' → glyph 5
sub[6 + 0x42] = 6; // 'B' → glyph 6 (liên tiếp → gộp segment)
sub[6 + 1] = 7; // mã 1 → glyph 7 (chữ 'ả' qua ToUnicode)
let mut cmap = vec![0u8; 12];
cmap[2..4].copy_from_slice(&1u16.to_be_bytes()); // numTables
cmap[4..6].copy_from_slice(&1u16.to_be_bytes()); // platform Mac
cmap[6..8].copy_from_slice(&0u16.to_be_bytes()); // encoding 0
cmap[8..12].copy_from_slice(&12u32.to_be_bytes());
cmap.extend_from_slice(&sub);
let mut head = vec![0u8; 54];
head[18..20].copy_from_slice(&1000u16.to_be_bytes());
let tables: Vec<([u8; 4], Vec<u8>)> = vec![(*b"cmap", cmap), (*b"head", head)];
assemble(TAG_TRUE, tables)
}
/// Tra format 4: quét segment tuyến tính.
fn lookup_f4(cmap: &[u8], uni: u16) -> u16 {
assert_eq!(u16::from_be_bytes(cmap[4..6].try_into().unwrap()), 3, "platform Windows");
assert_eq!(u16::from_be_bytes(cmap[6..8].try_into().unwrap()), 1, "encoding BMP");
let off = u32::from_be_bytes(cmap[8..12].try_into().unwrap()) as usize;
assert_eq!(u16::from_be_bytes(cmap[off..off + 2].try_into().unwrap()), 4, "format 4");
let n = (u16::from_be_bytes(cmap[off + 6..off + 8].try_into().unwrap()) / 2) as usize;
for i in 0..n {
let end = u16::from_be_bytes(cmap[off + 14 + i * 2..off + 16 + i * 2].try_into().unwrap());
let start =
u16::from_be_bytes(cmap[off + 16 + n * 2 + i * 2..off + 18 + n * 2 + i * 2].try_into().unwrap());
if uni >= start && uni <= end {
let delta =
u16::from_be_bytes(cmap[off + 16 + n * 4 + i * 2..off + 18 + n * 4 + i * 2].try_into().unwrap());
return uni.wrapping_add(delta);
}
}
0
}
#[test]
fn unsupported_cmap_is_converted_to_windows_format4() {
let subset = fake_subset_with_cmap();
let mut uni = HashMap::new();
uni.insert(1u32, 0x1EA3u32); // mã 1 = 'ả'
let fixed = web_compatible_font(&subset, false, false, Some(&uni));
assert_ne!(fixed, subset);
// Tìm bảng cmap mới.
let num = u16::from_be_bytes(fixed[4..6].try_into().unwrap()) as usize;
let mut cm = None;
for i in 0..num {
let o = 12 + i * 16;
if &fixed[o..o + 4] == b"cmap" {
let off = u32::from_be_bytes(fixed[o + 8..o + 12].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(fixed[o + 12..o + 16].try_into().unwrap()) as usize;
cm = Some(fixed[off..off + len].to_vec());
}
}
let cm = cm.expect("có cmap");
assert_eq!(lookup_f4(&cm, 0x41), 5, "'A' giữ glyph cũ");
assert_eq!(lookup_f4(&cm, 0x42), 6, "'B' gộp segment vẫn đúng");
assert_eq!(lookup_f4(&cm, 0x1EA3), 7, "'ả' theo ToUnicode");
assert_eq!(lookup_f4(&cm, 0x43), 0, "mã không map → notdef");
// OS/2 cũng được thêm (fake subset không có).
let tags: Vec<String> = (0..u16::from_be_bytes(fixed[4..6].try_into().unwrap()) as usize)
.map(|i| String::from_utf8_lossy(&fixed[12 + i * 16..16 + i * 16]).to_string())
.collect();
assert!(tags.contains(&"OS/2".to_string()), "tags: {tags:?}");
}
}
+2 -2
View File
@@ -39,14 +39,14 @@ fn is_text_show(op: &str) -> bool {
/// Bytes content của stream: giải nén nếu có filter; stream KHÔNG nén thì
/// `decompressed_content()` của lopdf báo lỗi thiếu /Filter → dùng bytes thô.
/// (Bytes sai kiểu sẽ bị bất biến kiểm đếm chặn ở bước sau.)
fn stream_bytes(stream: &lopdf::Stream) -> Vec<u8> {
pub(crate) fn stream_bytes(stream: &lopdf::Stream) -> Vec<u8> {
stream
.decompressed_content()
.unwrap_or_else(|_| stream.content.clone())
}
/// Resolve reference (tối đa 8 nấc).
fn deref<'a>(doc: &'a LoDoc, mut o: &'a Object) -> &'a Object {
pub(crate) fn deref<'a>(doc: &'a LoDoc, mut o: &'a Object) -> &'a Object {
for _ in 0..8 {
match o {
Object::Reference(id) => match doc.get_object(*id) {
+2
View File
@@ -7,7 +7,9 @@
pub mod annot;
pub mod convert;
pub mod edit;
mod fontfix;
mod formsurgery;
mod tounicode;
pub(crate) mod fontmatch;
pub mod form;
pub mod meta;
+259
View File
@@ -0,0 +1,259 @@
//! Đọc /ToUnicode của font trong PDF (lopdf): map MÃ trong content stream →
//! Unicode. Dùng cho fontfix: subset kiểu LibreOffice gán các mã thấp 1–31
//! cho ký tự ngoài WinAnsi (chữ Việt có dấu…) — chỉ /ToUnicode giải nghĩa
//! được các mã đó; ASCII giữ nguyên mã nên không cần.
use std::collections::HashMap;
use std::path::Path;
use lopdf::{Dictionary, Document as LoDoc, Object};
use crate::formsurgery::{deref, stream_bytes};
/// Map mã→Unicode của font có BaseFont khớp `font_name` (so phần sau prefix
/// subset "ABCDEF+") trên trang `page_index`; tìm cả trong resources của Form
/// XObject (sâu tối đa 3). Không thấy/không parse được → None (fontfix rơi về
/// ASCII identity + MacRoman).
pub(crate) fn code_to_unicode(
input: &Path,
page_index: u16,
font_name: &str,
) -> Option<HashMap<u32, u32>> {
let want = font_name.rsplit('+').next().unwrap_or(font_name).trim();
if want.is_empty() {
return None;
}
let doc = LoDoc::load(input).ok()?;
let pages = doc.get_pages();
let page_id = *pages.get(&(u32::from(page_index) + 1))?;
let page = doc.get_dictionary(page_id).ok()?;
let res = deref(&doc, page.get(b"Resources").ok()?).as_dict().ok()?;
scan_resources(&doc, res, want, 0)
}
fn scan_resources(
doc: &LoDoc,
res: &Dictionary,
want: &str,
depth: usize,
) -> Option<HashMap<u32, u32>> {
if depth > 3 {
return None;
}
if let Ok(fonts) = res.get(b"Font") {
if let Ok(fonts) = deref(doc, fonts).as_dict() {
for (_, fref) in fonts.iter() {
let Ok(fd) = deref(doc, fref).as_dict() else { continue };
let base = fd
.get(b"BaseFont")
.ok()
.and_then(|o| deref(doc, o).as_name().ok())
.map(|n| String::from_utf8_lossy(n).to_string())
.unwrap_or_default();
let suffix = base.rsplit('+').next().unwrap_or(&base);
if suffix != want {
continue;
}
let Ok(tu) = fd.get(b"ToUnicode") else { continue };
if let Object::Stream(s) = deref(doc, tu) {
if let Some(m) = parse_cmap(&stream_bytes(s)) {
if !m.is_empty() {
return Some(m);
}
}
}
}
}
}
// Font có thể nằm trong resources của Form XObject (file Canva…).
if let Ok(xo) = res.get(b"XObject") {
if let Ok(xo) = deref(doc, xo).as_dict() {
for (_, oref) in xo.iter() {
if let Object::Stream(s) = deref(doc, oref) {
if let Ok(r2) = s.dict.get(b"Resources") {
if let Ok(r2) = deref(doc, r2).as_dict() {
if let Some(m) = scan_resources(doc, r2, want, depth + 1) {
return Some(m);
}
}
}
}
}
}
}
None
}
enum Tok {
Hex(Vec<u8>),
Kw(String),
ArrOpen,
ArrClose,
}
fn tokenize(data: &[u8]) -> Vec<Tok> {
let mut out = Vec::new();
let mut i = 0;
while i < data.len() {
match data[i] {
b'<' => {
let mut j = i + 1;
let mut hex: Vec<u8> = Vec::new();
let mut hi: Option<u8> = None;
while j < data.len() && data[j] != b'>' {
let c = data[j];
let d = match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
};
if let Some(d) = d {
match hi {
None => hi = Some(d),
Some(h) => {
hex.push(h * 16 + d);
hi = None;
}
}
}
j += 1;
}
if let Some(h) = hi {
hex.push(h * 16); // số hex lẻ digit: pad 0 theo spec PDF
}
out.push(Tok::Hex(hex));
i = j + 1;
}
b'[' => {
out.push(Tok::ArrOpen);
i += 1;
}
b']' => {
out.push(Tok::ArrClose);
i += 1;
}
c if c.is_ascii_alphabetic() => {
let mut j = i;
while j < data.len() && data[j].is_ascii_alphanumeric() {
j += 1;
}
out.push(Tok::Kw(String::from_utf8_lossy(&data[i..j]).to_string()));
i = j;
}
_ => i += 1,
}
}
out
}
/// Mã nguồn (≤4 byte hex) → u32 big-endian.
fn hex_code(b: &[u8]) -> Option<u32> {
if b.is_empty() || b.len() > 4 {
return None;
}
Some(b.iter().fold(0u32, |v, &x| (v << 8) | u32::from(x)))
}
/// Giá trị UTF-16BE → đúng 1 scalar Unicode (surrogate pair ok); mapping ra
/// nhiều ký tự (ligature) → None — cmap chỉ chứa 1 codepoint/glyph.
fn utf16be_single(b: &[u8]) -> Option<u32> {
if b.len() < 2 || b.len() % 2 != 0 {
return None;
}
let units: Vec<u16> = b.chunks(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
let mut it = char::decode_utf16(units.iter().copied());
let first = it.next()?.ok()?;
if it.next().is_some() {
return None;
}
Some(first as u32)
}
/// Parse bfchar/bfrange của CMap ToUnicode — đủ cho file thực tế
/// (LibreOffice/Word/Canva). Lỗi cục bộ thì bỏ entry đó, không fail cả map.
fn parse_cmap(data: &[u8]) -> Option<HashMap<u32, u32>> {
let toks = tokenize(data);
let mut map = HashMap::new();
let mut i = 0;
while i < toks.len() {
match &toks[i] {
Tok::Kw(k) if k == "beginbfchar" => {
i += 1;
while let (Some(Tok::Hex(src)), Some(Tok::Hex(dst))) = (toks.get(i), toks.get(i + 1)) {
if let (Some(c), Some(u)) = (hex_code(src), utf16be_single(dst)) {
map.insert(c, u);
}
i += 2;
}
}
Tok::Kw(k) if k == "beginbfrange" => {
i += 1;
loop {
let (Some(Tok::Hex(lo)), Some(Tok::Hex(hi))) = (toks.get(i), toks.get(i + 1)) else {
break;
};
let (Some(lo), Some(hi)) = (hex_code(lo), hex_code(hi)) else { break };
match toks.get(i + 2) {
Some(Tok::Hex(d)) => {
if let Some(u0) = utf16be_single(d) {
for k in 0..=hi.saturating_sub(lo).min(0xFFFF) {
map.insert(lo + k, u0 + k);
}
}
i += 3;
}
Some(Tok::ArrOpen) => {
i += 3;
let mut c = lo;
while let Some(Tok::Hex(d)) = toks.get(i) {
if let Some(u) = utf16be_single(d) {
map.insert(c, u);
}
c += 1;
i += 1;
}
if matches!(toks.get(i), Some(Tok::ArrClose)) {
i += 1;
}
}
_ => break,
}
}
}
_ => i += 1,
}
}
Some(map)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_bfchar_and_bfrange() {
let cmap = br#"
/CIDInit /ProcSet findresource begin
begincmap
1 begincodespacerange <00> <FF> endcodespacerange
2 beginbfchar
<01> <1EA3>
<0E> <D83D DE00>
endbfchar
2 beginbfrange
<20> <7E> <0020>
<02> <04> [<1EBF> <1EC7> <0110>]
endbfrange
endcmap
"#;
let m = parse_cmap(cmap).unwrap();
assert_eq!(m.get(&0x01), Some(&0x1EA3)); // ả
assert_eq!(m.get(&0x0E), Some(&0x1F600)); // surrogate pair → emoji
assert_eq!(m.get(&0x20), Some(&0x20)); // range dạng 1
assert_eq!(m.get(&0x41), Some(&0x41));
assert_eq!(m.get(&0x7E), Some(&0x7E));
assert_eq!(m.get(&0x03), Some(&0x1EC7)); // range dạng mảng
assert_eq!(m.get(&0x04), Some(&0x0110));
}
}