diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index c66a48f..fe4c4c3 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -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( diff --git a/crates/ff-engine/src/fontfix.rs b/crates/ff-engine/src/fontfix.rs new file mode 100644 index 0000000..6c1a3f3 --- /dev/null +++ b/crates/ff-engine/src/fontfix.rs @@ -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>, +) -> Vec { + 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>, +) -> Option> { + 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)> = 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>) -> Option> { + 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 { + struct Seg { + start: u32, + end: u32, + g_start: u16, + delta_fixed: Option, + } + let mut segs: Vec = 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)>) -> Vec { + // '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 = Vec::new(); + let mut bodies: Vec = 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)], bold: bool, italic: bool) -> Option> { + 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 = Vec::with_capacity(96); + fn u(v: &mut Vec, x: u16) { v.extend_from_slice(&x.to_be_bytes()); } + fn i(v: &mut Vec, x: i16) { v.extend_from_slice(&x.to_be_bytes()); } + fn d(v: &mut Vec, 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 { + 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)> = + 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 { + 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)> = 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 = (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:?}"); + } +} diff --git a/crates/ff-engine/src/formsurgery.rs b/crates/ff-engine/src/formsurgery.rs index 971a1fb..8b6223d 100644 --- a/crates/ff-engine/src/formsurgery.rs +++ b/crates/ff-engine/src/formsurgery.rs @@ -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 { +pub(crate) fn stream_bytes(stream: &lopdf::Stream) -> Vec { 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) { diff --git a/crates/ff-engine/src/lib.rs b/crates/ff-engine/src/lib.rs index 87f100c..3fe6f51 100644 --- a/crates/ff-engine/src/lib.rs +++ b/crates/ff-engine/src/lib.rs @@ -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; diff --git a/crates/ff-engine/src/tounicode.rs b/crates/ff-engine/src/tounicode.rs new file mode 100644 index 0000000..de44f67 --- /dev/null +++ b/crates/ff-engine/src/tounicode.rs @@ -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> { + 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> { + 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), + Kw(String), + ArrOpen, + ArrClose, +} + +fn tokenize(data: &[u8]) -> Vec { + 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 = Vec::new(); + let mut hi: Option = 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 { + 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 { + if b.len() < 2 || b.len() % 2 != 0 { + return None; + } + let units: Vec = 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> { + 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> endcodespacerange +2 beginbfchar +<01> <1EA3> +<0E> +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)); + } +}