Dot 1 "edit chuan moi PDF": CID/Identity-H font, format TUNG CHU, edit OCR, bu metrics, splash, Open-with
Build Windows portable / build (push) Canceled after 0s

1) fontfix v2 (PDF tu Word/InDesign): tong hop bang name/post khi subset
   thieu (OTS bat buoc); font CID/Identity-H KHONG co cmap -> dung cmap
   (3,1) fmt4 tu /ToUnicode x /CIDToGIDMap (Identity: GID=CID; stream 2B/CID
   parse tu descendant font); chan GID >= numGlyphs (maxp). tounicode.rs tra
   FontMapping {code_to_uni, is_cid, cid_to_gid}.

2) Format TUNG CHU trong o sua (chuan Word/Foxit): boi den -> B/I (nut
   toolbar khong cuop focus + Ctrl+B/I). UI tach span theo vung chon, dat
   data-bold/italic-override; commit gui override tung doan (RichSegDto
   bold/italic). Engine: bang style rich key theo (run nguon, override) -
   doi bien the thi lay font BIEN THE THAT cung ho (fontmatch -> builtin ->
   substitute), khong dung lai font goc. Test:
   rich_seg_bold_italic_override_uses_variant_font.

3) Edit file OCR (scan + text an): khoi toan run render-mode INVISIBLE ->
   ve rect TRANG che vung chu cu tren anh + ve text moi HIEN mau den (ca 4
   duong ve: rich chinh/fallback tu, 1-style chinh/fallback). Test:
   ocr_invisible_block_becomes_visible_with_cover.

4) Bu metrics kieu Adobe Sans MM trong o sua: span mang data-pdfw (be rong
   that tren trang); fitEditLinesToPdf them pass letter-spacing de moi nhom
   style chiem DUNG quang goc khi font thay the lech metrics (>2%); kep
   +/-18% co chu.

5) Khoi dong het "do": cua so tao AN (visible:false), UI show() ngay khi
   frontend san sang + man splash che phan nap dau; luoi an toan Rust hien
   cua so sau 4s neu JS chet som.

6) Open with FoFreeXit.exe: co che argv (initial_file) van hoat dong - loi
   nam o registry Applications tro exe CU (ban portable bi di chuyen/ghi
   de) lam Windows hien lai hop chon app. Fix tu chua: moi lan khoi dong
   dang ky lai HKCU (Applications command + ProgID FoFreeXit.pdf +
   OpenWithProgids .pdf) tro exe hien tai, chay nen qua reg.exe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 16:44:06 +07:00
co-authored by Claude Fable 5
parent a3b00da10d
commit 74f161a627
9 changed files with 839 additions and 64 deletions
+153 -25
View File
@@ -155,6 +155,26 @@ pub struct RichSeg {
pub text: String,
/// Index (danh sách phẳng) của run mang style cho đoạn này.
pub style: u16,
/// Override đậm/nghiêng NGƯỜI DÙNG đặt cho riêng đoạn này (bôi đen 1 chữ
/// rồi bấm B/I trong ô sửa). None = giữ theo run mang style. Khi override
/// đổi biến thể, engine lấy font biến thể THẬT cùng họ (fontmatch), không
/// dùng lại font gốc.
pub bold: Option<bool>,
pub italic: Option<bool>,
}
/// Khoá style hiệu dụng của 1 đoạn rich: run nguồn + override đậm/nghiêng.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
struct RichKey {
style: u16,
bold: Option<bool>,
italic: Option<bool>,
}
impl RichKey {
fn of(seg: &RichSeg) -> Self {
RichKey { style: seg.style, bold: seg.bold, italic: seg.italic }
}
}
fn quad_to_rect(q: &PdfQuadPoints) -> Rect {
@@ -529,8 +549,8 @@ pub fn font_data(
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()))
let mapping = crate::tounicode::font_mapping(input, page_index, &fname);
Ok(crate::fontfix::web_compatible_font(&data, bold, italic, mapping.as_ref()))
}
pub fn list_objects(
@@ -708,8 +728,12 @@ struct ReflowPlan {
/// đúng cỡ dòng gốc thứ i (tiêu đề 22.5/20pt không bị ép về 1 cỡ).
line_styles: Vec<LineStyle>,
color: PdfColor,
/// Reflow NHIỀU STYLE: các dòng cứng theo đoạn + bảng style đã resolve.
rich: Option<(Vec<Vec<RichSeg>>, HashMap<u16, RichStyle>)>,
/// Reflow NHIỀU STYLE: các dòng cứng theo đoạn + bảng style đã resolve
/// (khoá theo run nguồn + override đậm/nghiêng của đoạn).
rich: Option<(Vec<Vec<RichSeg>>, HashMap<RichKey, RichStyle>)>,
/// Khối OCR (mọi run là text ẨN trên ảnh scan): bbox vùng chữ cũ — vẽ
/// rect TRẮNG che ảnh rồi vẽ text mới HIỆN màu đen.
ocr_cover: Option<Rect>,
}
/// Style đã resolve cho 1 đoạn rich: font vẽ + bytes đo + màu + cỡ.
@@ -1045,6 +1069,43 @@ pub fn apply_edits(
}
idxs.sort_unstable();
}
// OCR: mọi run của khối là text ẨN (lớp OCR đè trên ảnh
// scan) → khi vẽ lại phải CHE vùng chữ cũ trên ảnh (rect
// trắng) và vẽ text mới HIỆN màu đen — hành vi "sửa tài
// liệu scan" chuẩn Foxit.
let ocr_cover: Option<Rect> = {
let mut all_inv = !idxs.is_empty();
let mut bb = Rect {
left: f32::INFINITY,
bottom: f32::INFINITY,
right: f32::NEG_INFINITY,
top: f32::NEG_INFINITY,
};
for &i in &idxs {
let entry = &entries[i as usize];
let Ok(obj) = object_at_path(&page, &entry.path) else {
all_inv = false;
break;
};
let Some(t) = obj.as_text_object() else {
all_inv = false;
break;
};
if t.render_mode() != PdfPageTextRenderMode::Invisible {
all_inv = false;
break;
}
if let Ok(q) = obj.bounds() {
let r = mat_rect(entry.acc, &quad_to_rect(&q));
bb.left = bb.left.min(r.left);
bb.bottom = bb.bottom.min(r.bottom);
bb.right = bb.right.max(r.right);
bb.top = bb.top.max(r.top);
}
}
if all_inv && bb.left.is_finite() { Some(bb) } else { None }
};
// Run neo: baseline cao nhất, trái nhất.
let anchor_idx = geos
.iter()
@@ -1192,20 +1253,22 @@ pub fn apply_edits(
(ReflowFont::Loaded(key), Some(bytes))
};
// Reflow NHIỀU STYLE: resolve font/màu/cỡ TỪNG style-run
// (thang giữ-font như trên, tính theo text của style đó).
let rich_resolved: Option<(Vec<Vec<RichSeg>>, HashMap<u16, RichStyle>)> =
// Reflow NHIỀU STYLE: resolve font/màu/cỡ TỪNG khoá style
// (run nguồn + override đậm/nghiêng của đoạn — bôi đen 1
// chữ bấm B/I tạo khoá riêng với font BIẾN THỂ thật).
let rich_resolved: Option<(Vec<Vec<RichSeg>>, HashMap<RichKey, RichStyle>)> =
if let Some(rich_lines) = rich {
let mut style_ids: Vec<u16> =
rich_lines.iter().flatten().map(|s| s.style).collect();
style_ids.sort_unstable();
let mut style_ids: Vec<RichKey> =
rich_lines.iter().flatten().map(RichKey::of).collect();
style_ids.sort_unstable_by_key(|k| (k.style, k.bold, k.italic));
style_ids.dedup();
let mut table: HashMap<u16, RichStyle> = HashMap::new();
for sid in style_ids {
let mut table: HashMap<RichKey, RichStyle> = HashMap::new();
for rk in style_ids {
let sid = rk.style;
let style_text: String = rich_lines
.iter()
.flatten()
.filter(|s| s.style == sid)
.filter(|s| RichKey::of(s) == rk)
.map(|s| s.text.as_str())
.collect();
let src = if valid(sid) { sid } else { anchor_idx };
@@ -1245,6 +1308,52 @@ pub fn apply_edits(
let s_ascii = style_text
.chars()
.all(|c| c.is_control() || (' '..='~').contains(&c));
let skey = fontmatch::normalize_key(&sfam);
// Override đổi biến thể: font gốc/nhúng là MẶT
// CŨ — phải lấy biến thể thật (cùng họ →
// builtin → substitute), không dùng lại.
let eff_bold = rk.bold.unwrap_or(sbold);
let eff_italic = rk.italic.unwrap_or(sitalic);
if (eff_bold, eff_italic) != (sbold, sitalic) {
let (sdraw, smeasure) = if let Some(bytes) =
fontmatch::find_family_font_bytes(&sfam, eff_bold, eff_italic)
.filter(|b| fontmatch::coverage_ok(b, &style_text))
{
let key = (format!("var:{skey}"), eff_bold, eff_italic);
font_needed
.entry(key.clone())
.or_insert(FontLoad::Bytes(bytes.clone()));
(ReflowFont::Loaded(key), Some(bytes))
} else if let (Some(builtin), true) =
(builtin_for(&skey, eff_bold, eff_italic), s_ascii)
{
let measure = find_font_bytes(eff_bold, eff_italic);
let key = (format!("b14:{skey}"), eff_bold, eff_italic);
font_needed
.entry(key.clone())
.or_insert(FontLoad::Builtin(builtin));
(ReflowFont::Loaded(key), measure)
} else {
let (key, bytes) = resolve_substitute_font(
&sfam, eff_bold, eff_italic, &style_text,
)?;
font_needed
.entry(key.clone())
.or_insert(FontLoad::Bytes(bytes.clone()));
(ReflowFont::Loaded(key), Some(bytes))
};
table.insert(
rk,
RichStyle {
draw: sdraw,
measure: smeasure,
color: scolor,
tf: stf,
scaled: sscaled,
},
);
continue;
}
let s_orig_ok = !block_has_nested
&& !snested
&& semb
@@ -1254,7 +1363,6 @@ pub fn apply_edits(
} else {
None
};
let skey = fontmatch::normalize_key(&sfam);
let (sdraw, smeasure) = if s_orig_ok {
(
ReflowFont::Original(stoken),
@@ -1287,7 +1395,7 @@ pub fn apply_edits(
(ReflowFont::Loaded(key), Some(bytes))
};
table.insert(
sid,
rk,
RichStyle {
draw: sdraw,
measure: smeasure,
@@ -1319,6 +1427,7 @@ pub fn apply_edits(
line_styles,
color,
rich: rich_resolved,
ocr_cover,
},
);
}
@@ -1694,6 +1803,25 @@ pub fn apply_edits(
};
let grow_limit = (plan.width * 1.35).min(avail.max(plan.width * 1.02));
// Khối OCR: che vùng chữ scan cũ bằng rect TRẮNG (nới
// 1.5pt) trước khi vẽ; text mới vẽ HIỆN màu đen.
if let Some(cv) = &plan.ocr_cover {
page.objects_mut()
.create_path_object_rect(
PdfRect::new_from_values(
cv.bottom - 1.5,
cv.left - 1.5,
cv.top + 1.5,
cv.right + 1.5,
),
None,
None,
Some(PdfColor::new(255, 255, 255, 255)),
)
.map_err(err)?;
}
let ocr_black = plan.ocr_cover.is_some();
// ---- Vẽ NHIỀU STYLE (dòng lẫn thường + đậm/mono…) ----
// Token hoá theo TỪ giữ style; bẻ dòng chung cả khối; mỗi
// cụm từ liền kề cùng style = 1 text object với đúng
@@ -1701,23 +1829,23 @@ pub fn apply_edits(
if let Some((rich_lines, styles)) = &plan.rich {
struct Piece {
text: String,
style: u16,
style: RichKey,
lead: f32,
}
let faces: HashMap<u16, Option<ttf_parser::Face>> = styles
let faces: HashMap<RichKey, Option<ttf_parser::Face>> = styles
.iter()
.map(|(k, s)| {
(*k, s.measure.as_deref().and_then(|b| ttf_parser::Face::parse(b, 0).ok()))
})
.collect();
let measure_c = |sid: u16, c: char| -> f32 {
let measure_c = |sid: RichKey, c: char| -> f32 {
let sc = styles.get(&sid).map(|s| s.scaled).unwrap_or(plan.scaled);
match faces.get(&sid).and_then(|f| f.as_ref()) {
Some(f) => fontmatch::char_advance(f, c, sc),
None => sc * 0.5,
}
};
let measure_s = |sid: u16, s: &str| -> f32 {
let measure_s = |sid: RichKey, s: &str| -> f32 {
s.chars().map(|c| measure_c(sid, c)).sum()
};
@@ -1727,7 +1855,7 @@ pub fn apply_edits(
// Token hoá: tách từ, giữ "glue" khi 2 đoạn dính liền giữa từ.
struct Tok {
text: String,
style: u16,
style: RichKey,
glue: bool,
}
let mut toks: Vec<Tok> = Vec::new();
@@ -1737,7 +1865,7 @@ pub fn apply_edits(
let mut first = true;
for w in seg.text.split_whitespace() {
let glue = first && !starts_ws && !prev_ends_ws && !toks.is_empty();
toks.push(Tok { text: w.to_string(), style: seg.style, glue });
toks.push(Tok { text: w.to_string(), style: RichKey::of(seg), glue });
first = false;
}
prev_ends_ws = seg.text.trim().is_empty()
@@ -1835,7 +1963,7 @@ pub fn apply_edits(
if ok {
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
.map_err(err)?;
obj.set_fill_color(st.color).map_err(err)?;
obj.set_fill_color(if ocr_black { PdfColor::new(0, 0, 0, 255) } else { st.color }).map_err(err)?;
ir = obj.bounds().ok().map(|q| q.right().value);
}
(ok, ir)
@@ -1862,7 +1990,7 @@ pub fn apply_edits(
.map_err(err)?;
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y))
.map_err(err)?;
obj.set_fill_color(st.color).map_err(err)?;
obj.set_fill_color(if ocr_black { PdfColor::new(0, 0, 0, 255) } else { st.color }).map_err(err)?;
let wir =
obj.bounds().ok().map(|q| q.right().value);
cx = (cx + measure_s(p.style, word))
@@ -1940,7 +2068,7 @@ pub fn apply_edits(
if ok {
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
.map_err(err)?;
obj.set_fill_color(plan.color).map_err(err)?;
obj.set_fill_color(if ocr_black { PdfColor::new(0, 0, 0, 255) } else { plan.color }).map_err(err)?;
}
ok
};
@@ -1966,7 +2094,7 @@ pub fn apply_edits(
.map_err(err)?;
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y))
.map_err(err)?;
obj.set_fill_color(plan.color).map_err(err)?;
obj.set_fill_color(if ocr_black { PdfColor::new(0, 0, 0, 255) } else { plan.color }).map_err(err)?;
cx += word.chars().map(|c| measure_at(c, style.scaled)).sum::<f32>()
+ space_w;
}
+216 -19
View File
@@ -17,17 +17,18 @@
use std::collections::HashMap;
use crate::tounicode::FontMapping;
/// 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.
/// `mapping`: giải mã từ PDF (/ToUnicode + CIDToGIDMap) — cho mã tuỳ biến của
/// subset LibreOffice lẫn font CID/Identity-H (Word/InDesign) KHÔNG có cmap.
pub(crate) fn web_compatible_font(
data: &[u8],
bold: bool,
italic: bool,
code_to_uni: Option<&HashMap<u32, u32>>,
mapping: Option<&FontMapping>,
) -> Vec<u8> {
try_fix(data, bold, italic, code_to_uni).unwrap_or_else(|| data.to_vec())
try_fix(data, bold, italic, mapping).unwrap_or_else(|| data.to_vec())
}
const TAG_TRUE: u32 = u32::from_be_bytes(*b"true");
@@ -37,21 +38,23 @@ fn try_fix(
data: &[u8],
bold: bool,
italic: bool,
code_to_uni: Option<&HashMap<u32, u32>>,
mapping: Option<&FontMapping>,
) -> 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
return None; // không phải sfnt — CFF trần đi đường wrap_bare_cff
}
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 tables: Vec<([u8; 4], Vec<u8>)> = Vec::with_capacity(num + 4);
let mut has_os2 = false;
let mut has_name = false;
let mut has_post = false;
for i in 0..num {
let o = 12 + i * 16;
let tag: [u8; 4] = data[o..o + 4].try_into().ok()?;
@@ -60,30 +63,164 @@ fn try_fix(
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;
match &tag {
b"OS/2" => has_os2 = true,
b"name" => has_name = true,
b"post" => has_post = 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 num_glyphs = read_num_glyphs(&tables);
// cmap: (1,0) fmt 0/6 → chuyển sang (3,1) fmt 4; KHÔNG có cmap (font
// CID/Identity-H của Word) → dựng mới từ /ToUnicode × CIDToGIDMap.
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;
match tables.iter().position(|(t, _)| t == b"cmap") {
Some(ci) => {
if let Some(converted) =
convert_cmap(&tables[ci].1, mapping.map(|m| &m.code_to_uni))
{
tables[ci].1 = converted;
cmap_changed = true;
} else if !cmap_has_unicode_subtable(&tables[ci].1) {
if let Some(cm) = mapping.and_then(|m| build_cid_cmap(m, num_glyphs)) {
tables[ci].1 = cm;
cmap_changed = true;
}
}
}
None => {
if let Some(cm) = mapping.and_then(|m| build_cid_cmap(m, num_glyphs)) {
tables.push((*b"cmap", cm));
cmap_changed = true;
}
}
}
if has_os2 && ver == 0x0001_0000 && !cmap_changed {
if has_os2 && has_name && has_post && 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));
}
// OTS cũng BẮT BUỘC name + post — subset CID hay bỏ cả hai.
if !has_name {
tables.push((*b"name", build_name()));
}
if !has_post {
tables.push((*b"post", build_post()));
}
tables.sort_by(|a, b| a.0.cmp(&b.0));
Some(assemble(ver, tables))
}
/// numGlyphs từ maxp (chặn GID rác khi dựng cmap); 0 = không rõ, không chặn.
fn read_num_glyphs(tables: &[([u8; 4], Vec<u8>)]) -> u16 {
tables
.iter()
.find(|(t, _)| t == b"maxp")
.and_then(|(_, b)| b.get(4..6))
.and_then(|s| s.try_into().ok())
.map(u16::from_be_bytes)
.unwrap_or(0)
}
/// cmap đã có subtable Unicode OTS chấp nhận chưa ((0,*), (3,1), (3,10))?
fn cmap_has_unicode_subtable(cmap: &[u8]) -> bool {
let Some(n) = cmap.get(2..4).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes) else {
return false;
};
for i in 0..n as usize {
let o = 4 + i * 8;
let Some(plat) = cmap.get(o..o + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes)
else {
return false;
};
let Some(enc) =
cmap.get(o + 2..o + 4).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes)
else {
return false;
};
if plat == 0 || (plat == 3 && (enc == 1 || enc == 10)) {
return true;
}
}
false
}
/// cmap (3,1) format 4 cho font CID/Identity-H: Unicode ← /ToUnicode(mã),
/// GID ← CIDToGIDMap(mã) (Identity = chính mã). Bỏ mapping ngoài BMP/GID rác.
fn build_cid_cmap(m: &FontMapping, num_glyphs: u16) -> Option<Vec<u8>> {
if !m.is_cid || m.code_to_uni.is_empty() {
return None;
}
let mut pairs: Vec<(u32, u16)> = Vec::new();
for (&code, &uni) in &m.code_to_uni {
if uni == 0 || uni > 0xFFFF {
continue;
}
let gid = match &m.cid_to_gid {
Some(map) => *map.get(code as usize).unwrap_or(&0),
None => {
if code > 0xFFFF {
continue;
}
code as u16
}
};
if gid == 0 || (num_glyphs != 0 && gid >= num_glyphs) {
continue;
}
pairs.push((uni, gid));
}
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))
}
/// name tối thiểu (format 0, family + subfamily, Windows Unicode en-US) — OTS
/// bắt buộc có bảng name; nội dung không ảnh hưởng hiển thị (CSS đặt alias).
fn build_name() -> Vec<u8> {
let entries: [(u16, &str); 2] = [(1, "FFX Embedded"), (2, "Regular")];
let mut strings: Vec<u8> = Vec::new();
let mut records: Vec<u8> = Vec::new();
for (id, s) in entries {
let start = strings.len() as u16;
for u in s.encode_utf16() {
strings.extend_from_slice(&u.to_be_bytes());
}
let len = strings.len() as u16 - start;
records.extend_from_slice(&3u16.to_be_bytes()); // platform Windows
records.extend_from_slice(&1u16.to_be_bytes()); // encoding Unicode BMP
records.extend_from_slice(&0x0409u16.to_be_bytes()); // en-US
records.extend_from_slice(&id.to_be_bytes());
records.extend_from_slice(&len.to_be_bytes());
records.extend_from_slice(&start.to_be_bytes());
}
let mut out = Vec::with_capacity(6 + records.len() + strings.len());
out.extend_from_slice(&0u16.to_be_bytes()); // format 0
out.extend_from_slice(&(entries.len() as u16).to_be_bytes());
out.extend_from_slice(&((6 + records.len()) as u16).to_be_bytes()); // stringOffset
out.extend_from_slice(&records);
out.extend_from_slice(&strings);
out
}
/// post version 3.0 (32 byte, không tên glyph) — đủ cho OTS.
fn build_post() -> Vec<u8> {
let mut v = Vec::with_capacity(32);
v.extend_from_slice(&0x0003_0000u32.to_be_bytes()); // version 3.0
v.extend_from_slice(&0i32.to_be_bytes()); // italicAngle (Fixed)
v.extend_from_slice(&(-100i16).to_be_bytes()); // underlinePosition
v.extend_from_slice(&50i16.to_be_bytes()); // underlineThickness
v.extend_from_slice(&0u32.to_be_bytes()); // isFixedPitch
v.extend_from_slice(&[0u8; 16]); // min/max mem Type42/Type1
v
}
/// MacRoman 0x80–0xFF → Unicode (bảng chuẩn Apple) — fallback cho mã cao khi
/// không có /ToUnicode.
const MACROMAN_HI: [u16; 128] = [
@@ -412,11 +549,15 @@ mod tests {
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.
// Tag chuẩn hoá + có OS/2 + name/post tổng hợp + 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:?}");
assert_eq!(
tags,
vec!["OS/2", "glyf", "head", "hhea", "name", "post"],
"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];
@@ -482,12 +623,17 @@ mod tests {
0
}
fn mapping(uni: HashMap<u32, u32>, is_cid: bool, cid_to_gid: Option<Vec<u16>>) -> FontMapping {
FontMapping { code_to_uni: uni, is_cid, cid_to_gid }
}
#[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));
let m = mapping(uni, false, None);
let fixed = web_compatible_font(&subset, false, false, Some(&m));
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;
@@ -511,4 +657,55 @@ mod tests {
.collect();
assert!(tags.contains(&"OS/2".to_string()), "tags: {tags:?}");
}
/// Font CID/Identity-H kiểu Word: KHÔNG có cmap, mã = CID, GID = CID
/// (Identity) hoặc qua bảng CIDToGIDMap → dựng cmap (3,1) fmt 4 mới.
fn fake_cid_subset() -> Vec<u8> {
let mut head = vec![0u8; 54];
head[18..20].copy_from_slice(&2048u16.to_be_bytes());
let mut hhea = vec![0u8; 36];
hhea[4..6].copy_from_slice(&1638i16.to_be_bytes());
hhea[6..8].copy_from_slice(&(-410i16).to_be_bytes());
let mut maxp = vec![0u8; 32];
maxp[4..6].copy_from_slice(&50u16.to_be_bytes()); // numGlyphs = 50
let tables: Vec<([u8; 4], Vec<u8>)> =
vec![(*b"glyf", vec![0u8; 8]), (*b"head", head), (*b"hhea", hhea), (*b"maxp", maxp)];
assemble(0x0001_0000, tables)
}
#[test]
fn cid_font_without_cmap_gets_synthesized_cmap() {
let subset = fake_cid_subset();
let mut uni = HashMap::new();
uni.insert(5u32, 0x1EA3u32); // CID 5 = 'ả'
uni.insert(6u32, 0x0041u32); // CID 6 = 'A'
uni.insert(99u32, 0x0042u32); // CID 99 ≥ numGlyphs → phải bị bỏ
// Identity: GID = CID.
let m = mapping(uni.clone(), true, None);
let fixed = web_compatible_font(&subset, false, false, Some(&m));
assert_ne!(fixed, subset, "phải được vá");
let dir = table_dir(&fixed);
let tags: Vec<&str> = dir.iter().map(|(t, _, _)| t.as_str()).collect();
for need in ["cmap", "OS/2", "name", "post"] {
assert!(tags.contains(&need), "thiếu {need}: {tags:?}");
}
let (_, off, len) = dir[tags.iter().position(|t| *t == "cmap").unwrap()].clone();
let cm = fixed[off..off + len].to_vec();
assert_eq!(lookup_f4(&cm, 0x1EA3), 5, "'ả' → GID 5 (Identity)");
assert_eq!(lookup_f4(&cm, 0x41), 6, "'A' → GID 6");
assert_eq!(lookup_f4(&cm, 0x42), 0, "GID ngoài numGlyphs bị bỏ");
// Có bảng CIDToGIDMap: CID 5 → GID 7.
let mut c2g = vec![0u16; 10];
c2g[5] = 7;
c2g[6] = 8;
let m2 = mapping(uni, true, Some(c2g));
let fixed2 = web_compatible_font(&subset, false, false, Some(&m2));
let dir2 = table_dir(&fixed2);
let tags2: Vec<&str> = dir2.iter().map(|(t, _, _)| t.as_str()).collect();
let (_, o2, l2) = dir2[tags2.iter().position(|t| *t == "cmap").unwrap()].clone();
let cm2 = fixed2[o2..o2 + l2].to_vec();
assert_eq!(lookup_f4(&cm2, 0x1EA3), 7, "'ả' → GID 7 theo CIDToGIDMap");
assert_eq!(lookup_f4(&cm2, 0x41), 8);
}
}
+58 -10
View File
@@ -10,15 +10,26 @@ 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
/// Thông tin giải mã của 1 font trong PDF — đủ để fontfix dựng cmap Unicode.
pub(crate) struct FontMapping {
/// Mã trong content stream → Unicode (từ /ToUnicode).
pub code_to_uni: HashMap<u32, u32>,
/// Font CID (Type0/Identity-H): mã = CID. `cid_to_gid`: None = Identity
/// (GID = CID — trường hợp phổ biến của Word/InDesign), Some = bảng tra
/// từ stream /CIDToGIDMap (2 byte big-endian mỗi CID).
pub is_cid: bool,
pub cid_to_gid: Option<Vec<u16>>,
}
/// Mapping 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(
pub(crate) fn font_mapping(
input: &Path,
page_index: u16,
font_name: &str,
) -> Option<HashMap<u32, u32>> {
) -> Option<FontMapping> {
let want = font_name.rsplit('+').next().unwrap_or(font_name).trim();
if want.is_empty() {
return None;
@@ -36,7 +47,7 @@ fn scan_resources(
res: &Dictionary,
want: &str,
depth: usize,
) -> Option<HashMap<u32, u32>> {
) -> Option<FontMapping> {
if depth > 3 {
return None;
}
@@ -54,14 +65,51 @@ fn scan_resources(
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);
let mut code_to_uni = HashMap::new();
if let Ok(tu) = fd.get(b"ToUnicode") {
if let Object::Stream(s) = deref(doc, tu) {
if let Some(m) = parse_cmap(&stream_bytes(s)) {
code_to_uni = m;
}
}
}
// Type0 (Identity-H): mã = CID; GID tra qua /CIDToGIDMap của
// descendant font (Name "Identity" hoặc stream 2B/CID).
let subtype = fd
.get(b"Subtype")
.ok()
.and_then(|o| deref(doc, o).as_name().ok())
.map(|n| n.to_vec())
.unwrap_or_default();
let is_cid = subtype == b"Type0";
let mut cid_to_gid = None;
if is_cid {
if let Ok(desc) = fd.get(b"DescendantFonts") {
let desc = deref(doc, desc);
let first = match desc {
Object::Array(a) => a.first().map(|o| deref(doc, o)),
other => Some(other),
};
if let Some(Ok(dd)) = first.map(|o| o.as_dict()) {
if let Ok(map) = dd.get(b"CIDToGIDMap") {
if let Object::Stream(s) = deref(doc, map) {
let raw = stream_bytes(s);
cid_to_gid = Some(
raw.chunks(2)
.map(|c| {
u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])
})
.collect(),
);
}
}
}
}
}
if code_to_uni.is_empty() {
continue; // không có ToUnicode — thử font trùng tên khác
}
return Some(FontMapping { code_to_uni, is_cid, cid_to_gid });
}
}
}
+144 -2
View File
@@ -1546,8 +1546,18 @@ fn rich_reflow_preserves_mixed_styles() {
indices: vec![idx_a, idx_b],
text: "Trang quan tri hien thi MOI windowsInitialPassword".into(),
rich: Some(vec![vec![
RichSeg { text: "Trang quan tri hien thi MOI ".into(), style: idx_a },
RichSeg { text: "windowsInitialPassword".into(), style: idx_b },
RichSeg {
text: "Trang quan tri hien thi MOI ".into(),
style: idx_a,
bold: None,
italic: None,
},
RichSeg {
text: "windowsInitialPassword".into(),
style: idx_b,
bold: None,
italic: None,
},
]]),
}],
&out,
@@ -1593,3 +1603,135 @@ fn rich_reflow_preserves_mixed_styles() {
plain.rect
);
}
/// Fixture kiểu file đã OCR: text render mode 3 (INVISIBLE — lớp chữ ẩn đè
/// trên ảnh scan để tìm kiếm/copy).
fn build_invisible_text_pdf(path: &std::path::Path) {
use lopdf::{dictionary, Document, Object, Stream};
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
let f1 = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let content_id = doc.add_object(Stream::new(
dictionary! {},
b"BT /F1 14 Tf 3 Tr 72 700 Td (Van ban OCR nhan dien tu anh) Tj ET".to_vec(),
));
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Resources" => dictionary! { "Font" => dictionary! { "F1" => f1 } },
"Contents" => content_id,
});
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}),
);
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog_id);
doc.save(path).expect("lưu fixture OCR");
}
/// Sửa khối text ẨN (file scan đã OCR): engine phải CHE vùng chữ cũ bằng rect
/// trắng và vẽ text mới HIỆN màu đen — như "edit scanned document" của Foxit.
#[test]
fn ocr_invisible_block_becomes_visible_with_cover() {
let pdf = pdfium();
let input = tmp("ff_edit_ocr_fx.pdf");
build_invisible_text_pdf(&input);
let out = tmp("ff_edit_ocr_out.pdf");
let idx = find_text_index(&pdf, &input, "Van ban OCR");
let paths_before = ff_engine::list_objects(&pdf, &input, 0, None)
.expect("list in")
.iter()
.filter(|o| o.kind == ObjectKind::Path)
.count();
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::ReflowText {
indices: vec![idx],
text: "Van ban OCR DA SUA lai noi dung".into(),
rich: None,
}],
&out,
None,
)
.expect("reflow khối OCR");
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let paths_after = objs.iter().filter(|o| o.kind == ObjectKind::Path).count();
assert!(paths_after > paths_before, "phải có rect trắng che vùng chữ scan cũ");
let t = objs
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("DA SUA")).unwrap_or(false))
.unwrap_or_else(|| panic!("thiếu text mới: {objs:?}"));
assert_eq!(t.color, Some([0, 0, 0, 255]), "chữ mới phải màu đen (hiện)");
}
/// Format TỪNG CHỮ: bôi đen 1 từ bấm B → seg mang override bold=true; engine
/// phải vẽ từ đó bằng BIẾN THỂ đậm thật (không phải giả lập), các từ quanh
/// giữ nguyên; italic tương tự.
#[test]
fn rich_seg_bold_italic_override_uses_variant_font() {
use ff_engine::RichSeg;
let pdf = pdfium();
let input = tmp("ff_edit_rich_override.pdf");
build_mixed_style_pdf(&input);
let out = tmp("ff_edit_rich_override_out.pdf");
let idx_a = find_text_index(&pdf, &input, "Trang quan tri");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::ReflowText {
indices: vec![idx_a],
text: "Trang quan tri hien thi".into(),
rich: Some(vec![vec![
RichSeg { text: "Trang ".into(), style: idx_a, bold: None, italic: None },
RichSeg { text: "quan tri ".into(), style: idx_a, bold: Some(true), italic: None },
RichSeg { text: "hien ".into(), style: idx_a, bold: None, italic: Some(true) },
RichSeg { text: "thi".into(), style: idx_a, bold: None, italic: None },
]]),
}],
&out,
None,
)
.expect("apply_edits rich override");
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let find = |needle: &str| {
objs.iter()
.find(|o| o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false))
.unwrap_or_else(|| panic!("thiếu run '{needle}': {objs:?}"))
};
let plain = find("Trang");
let bolded = find("quan tri");
let italicized = find("hien");
assert_eq!(plain.font_bold, Some(false), "'Trang' giữ thường: {:?}", plain.font_name);
assert_eq!(
bolded.font_bold,
Some(true),
"'quan tri' phải thành ĐẬM thật: {:?}",
bolded.font_name
);
assert_eq!(
italicized.font_italic,
Some(true),
"'hien' phải thành NGHIÊNG thật: {:?}",
italicized.font_name
);
// Cùng baseline, thứ tự trái→phải giữ nguyên.
assert!((bolded.rect.bottom - plain.rect.bottom).abs() < 4.0);
assert!(bolded.rect.left > plain.rect.left && italicized.rect.left > bolded.rect.left);
}