From 74f161a627af2dd80283fb2c6e9f0593fb713e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Nam?= Date: Mon, 10 Aug 2026 16:44:06 +0700 Subject: [PATCH] Dot 1 "edit chuan moi PDF": CID/Identity-H font, format TUNG CHU, edit OCR, bu metrics, splash, Open-with 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 --- app/src-tauri/src/main.rs | 88 ++++++++- app/src-tauri/tauri.conf.json | 3 +- app/src/index.html | 7 + app/src/main.js | 152 ++++++++++++++- app/src/styles.css | 26 +++ crates/ff-engine/src/edit.rs | 178 ++++++++++++++--- crates/ff-engine/src/fontfix.rs | 235 +++++++++++++++++++++-- crates/ff-engine/src/tounicode.rs | 68 ++++++- crates/ff-engine/tests/edit_roundtrip.rs | 146 +++++++++++++- 9 files changed, 839 insertions(+), 64 deletions(-) diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index 870a8c3..a9e0c3e 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -703,6 +703,62 @@ fn edit_list_objects(path: String, page: u16, password: Option) -> Resul .collect()) } +/// TỰ ĐĂNG KÝ "Open with" mỗi lần khởi động (HKCU — không cần admin): bản +/// portable hay bị di chuyển/ghi đè → entry Applications trong registry trỏ +/// đường dẫn exe CŨ, Windows không khởi chạy được và hiện lại hộp "chọn ứng +/// dụng khác". Ghi lại command trỏ exe HIỆN TẠI + ProgID cho .pdf để +/// FoFreeXit luôn có mặt (và chạy được) trong menu Open with. +#[cfg(windows)] +fn register_open_with() { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let Ok(exe) = std::env::current_exe() else { return }; + let exe_s = exe.to_string_lossy().to_string(); + let open_cmd = format!("\"{exe_s}\" \"%1\""); + let icon = format!("\"{exe_s}\",0"); + let reg = |args: &[&str]| { + let _ = std::process::Command::new("reg") + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .status(); + }; + // Entry của picker "Choose another app" (Windows tạo khi user browse exe). + reg(&[ + "add", + r"HKCU\Software\Classes\Applications\FoFreeXit.exe\shell\open\command", + "/ve", "/t", "REG_SZ", "/d", &open_cmd, "/f", + ]); + reg(&[ + "add", + r"HKCU\Software\Classes\Applications\FoFreeXit.exe", + "/v", "FriendlyAppName", "/t", "REG_SZ", "/d", "FoFreeXit", "/f", + ]); + // ProgID riêng + gắn vào danh sách Open With của .pdf. + reg(&[ + "add", + r"HKCU\Software\Classes\FoFreeXit.pdf\shell\open\command", + "/ve", "/t", "REG_SZ", "/d", &open_cmd, "/f", + ]); + reg(&[ + "add", + r"HKCU\Software\Classes\FoFreeXit.pdf", + "/ve", "/t", "REG_SZ", "/d", "Tài liệu PDF (FoFreeXit)", "/f", + ]); + reg(&[ + "add", + r"HKCU\Software\Classes\FoFreeXit.pdf\DefaultIcon", + "/ve", "/t", "REG_SZ", "/d", &icon, "/f", + ]); + reg(&[ + "add", + r"HKCU\Software\Classes\.pdf\OpenWithProgids", + "/v", "FoFreeXit.pdf", "/t", "REG_SZ", "/d", "", "/f", + ]); +} + +#[cfg(not(windows))] +fn register_open_with() {} + /// Bytes (base64) của font ĐÓNG GÓI theo app (Noto) — lưới đỡ WYSIWYG cho ô /// sửa: glyph không có trong subset nhúng (ký tự mới gõ) hay font không /// nhúng đều rơi về font này, KHÔNG rơi về font hệ điều hành. @@ -782,12 +838,17 @@ struct EditOpDto { rich_lines: Option>>, } -/// 1 đoạn cùng style trong reflow nhiều-style. +/// 1 đoạn cùng style trong reflow nhiều-style. `bold`/`italic`: override +/// người dùng đặt riêng cho đoạn (bôi đen 1 chữ bấm B/I) — None = theo run. #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct RichSegDto { text: String, style: u16, + #[serde(default)] + bold: Option, + #[serde(default)] + italic: Option, } fn one() -> f32 { @@ -826,7 +887,12 @@ fn edit_op_from_dto(d: EditOpDto) -> Result { .into_iter() .map(|line| { line.into_iter() - .map(|s| ff_engine::RichSeg { text: s.text, style: s.style }) + .map(|s| ff_engine::RichSeg { + text: s.text, + style: s.style, + bold: s.bold, + italic: s.italic, + }) .collect() }) .collect() @@ -1451,6 +1517,24 @@ fn main() { } tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .setup(|app| { + // Cửa sổ tạo ẨN (tauri.conf.json visible:false) — UI gọi show() + // khi frontend SẴN SÀNG để user không thấy khung trắng đơ lúc + // WebView khởi tạo. Lưới an toàn: JS lỗi sớm thì vẫn hiện sau 4s. + use tauri::Manager; + let handle = app.handle().clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(4)); + if let Some(w) = handle.get_webview_window("main") { + if !w.is_visible().unwrap_or(true) { + let _ = w.show(); + } + } + }); + // Đăng ký Open-with ở nền — không chặn khởi động. + std::thread::spawn(register_open_with); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ open_document, render_page, diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 090ca17..0b36991 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -13,7 +13,8 @@ "label": "main", "title": "FoFreeXit — PDF Viewer (Phase 1)", "width": 1100, - "height": 850 + "height": 850, + "visible": false } ], "security": { diff --git a/app/src/index.html b/app/src/index.html index 3cc00b7..446f88c 100644 --- a/app/src/index.html +++ b/app/src/index.html @@ -188,6 +188,13 @@ + +
+ +
+
Đang khởi động…
+
+ diff --git a/app/src/main.js b/app/src/main.js index 53ca569..74d9a2e 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -126,9 +126,22 @@ async function loadDocument(path) { } async function boot() { + // Cửa sổ được tạo ẨN (tauri.conf.json visible:false) để user không thấy + // khung trắng ĐƠ trong lúc WebView khởi tạo — hiện NGAY khi UI chạy tới + // đây (đã tương tác được), splash che nốt phần nạp tài liệu đầu tiên. + try { + const w = window.__TAURI__.window.getCurrentWindow(); + await w.show(); + await w.setFocus(); + } catch (_) { /* chạy ngoài Tauri (harness test) */ } // Nếu app được mở qua "Open with FoFreeXit" / double-click file PDF trong // Explorer, Windows truyền đường dẫn file qua command-line — ưu tiên mở nó. const fromExplorer = await invoke("initial_file"); + const splash = $("bootSplash"); + if (splash) { + splash.classList.add("hide"); + setTimeout(() => splash.remove(), 400); + } loadDocument(fromExplorer || (await invoke("default_pdf"))); } @@ -2864,9 +2877,12 @@ function styleGroupsOfLine(runs) { const last = groups[groups.length - 1]; if (last && last.key === key) { last.text += sep + t; + last.right = Math.max(last.right, r.rect.right); } else { if (last && sep) last.text += " "; - groups.push({ key, rep: r, text: t }); + // left/right: quãng chiếm chỗ THẬT của nhóm trên trang (pt) — dùng bù + // bề rộng khi ô sửa phải thay font (metrics khác font gốc). + groups.push({ key, rep: r, text: t, left: r.rect.left, right: r.rect.right }); } prev = r; } @@ -3052,6 +3068,8 @@ function startBlockTextEdit(o, lines, ev) { const span = document.createElement("span"); span.textContent = g.text; span.dataset.styleRun = String(g.rep.index); + // Bề rộng nhóm trên trang (pt) — pass bù metrics trong fitEditLinesToPdf. + if (g.right > g.left) span.dataset.pdfw = String(g.right - g.left); span.style.fontFamily = editFontStack(g.rep, g.rep.fontFamily || o.fontFamily); span.style.fontWeight = g.rep.fontBold ? "bold" : "normal"; span.style.fontStyle = g.rep.fontItalic ? "italic" : "normal"; @@ -3128,8 +3146,11 @@ function startBlockTextEdit(o, lines, ev) { sel.addRange(r); } ce.scrollIntoView({ block: "center", behavior: "instant" }); + // Trong ô sửa luôn cho phép B/I trên phần bôi đen (chuẩn Word). + $("edBold").disabled = false; + $("edItalic").disabled = false; $("editHint").textContent = - "Sửa cả đoạn — Enter: xuống dòng · Ctrl+Enter hoặc bấm ra ngoài: áp dụng · Esc: huỷ"; + "Sửa cả đoạn — Enter: xuống dòng · Bôi đen + B/I (Ctrl+B/I): đậm/nghiêng từng chữ · Ctrl+Enter: áp dụng · Esc: huỷ"; // Đọc nội dung theo DÒNG từ contenteditable (div/br → \n). const readText = () => { @@ -3148,8 +3169,13 @@ function startBlockTextEdit(o, lines, ev) { const txt = (node.textContent || "").replace(/\u00a0/g, " "); if (!txt || node.nodeName === "BR") continue; let si = null; + let bold = null; + let italic = null; if (node.nodeType === 1 && node.dataset && node.dataset.styleRun) { si = Number(node.dataset.styleRun); + // Override B/I ng\u01b0\u1eddi d\u00f9ng \u0111\u1eb7t cho ri\u00eang \u0111o\u1ea1n (b\u00f4i \u0111en \u2192 B/I). + if (node.dataset.boldOverride != null) bold = node.dataset.boldOverride === "1"; + if (node.dataset.italicOverride != null) italic = node.dataset.italicOverride === "1"; } else { const pick = (sib) => sib && sib.nodeType === 1 && sib.dataset && sib.dataset.styleRun @@ -3158,8 +3184,12 @@ function startBlockTextEdit(o, lines, ev) { const p = pick(node.previousSibling); si = p != null ? p : pick(node.nextSibling); } - if (segs.length && segs[segs.length - 1].style === si) segs[segs.length - 1].text += txt; - else segs.push({ text: txt, style: si }); + const last = segs[segs.length - 1]; + if (last && last.style === si && last.bold === bold && last.italic === italic) { + last.text += txt; + } else { + segs.push({ text: txt, style: si, bold, italic }); + } } const known = segs.find((g) => g.style != null); for (const g of segs) { @@ -3191,11 +3221,13 @@ function startBlockTextEdit(o, lines, ev) { const styleCount = new Set( rich.flatMap((line) => line.map((g) => styleKeyOf(state.editObjects.find((x) => x.index === g.style) || {}))) ).size; + // Có override B/I từng đoạn → bắt buộc đi đường rich dù khối 1 style. + const hasOverride = rich.some((line) => line.some((g) => g.bold != null || g.italic != null)); stageEditOp({ op: "reflowText", indices: allRuns.map((r) => r.index), text, - richLines: styleCount > 1 ? rich : null, + richLines: styleCount > 1 || hasOverride ? rich : null, }); } else { $("editHint").textContent = ""; @@ -3272,6 +3304,28 @@ function fitEditLinesToPdf(ce, lines, s, page) { } } + // (1b) BỀ RỘNG từng nhóm style — kiểu Adobe Sans MM: khi font trong ô sửa + // không phải font gốc (thay bằng Noto đóng gói…), bề rộng chữ lệch so với + // trang → bù bằng letter-spacing để mỗi nhóm chiếm ĐÚNG quãng gốc (chữ nằm + // trùng chữ, con trỏ đặt đúng chỗ). Font nhúng đúng thì lệch <2%, bỏ qua. + ce.querySelectorAll("span[data-style-run][data-pdfw]").forEach((sp) => { + const text = sp.textContent || ""; + if (text.trim().length < 2) return; + const target = parseFloat(sp.dataset.pdfw) * s; + if (!(target > 4)) return; + const range = document.createRange(); + range.selectNodeContents(sp); + const actual = range.getBoundingClientRect().width; + if (!(actual > 1)) return; + const diff = target - actual; + if (Math.abs(diff) / target <= 0.02) return; + const fs = parseFloat(sp.style.fontSize) || 12; + const perChar = diff / text.length; + // Kẹp ±18% cỡ chữ — lệch quá mức là dữ liệu bất thường, đừng phá layout. + const ls = Math.max(-0.18 * fs, Math.min(0.18 * fs, perChar)); + if (Math.abs(ls) > 0.05) sp.style.letterSpacing = ls.toFixed(2) + "px"; + }); + // (2) Vị trí từng dòng (đo lại sau khi đã chỉnh cỡ). const imgR = img.getBoundingClientRect(); for (let i = 0; i < divs.length && i < lines.length; i++) { @@ -3314,6 +3368,77 @@ function firstTextRect(div) { return rects.length ? rects[0] : null; } +// ---- Format TỪNG CHỮ trong ô sửa (bôi đen → B/I, chuẩn Word/Foxit) ---- +// Tách span[data-style-run] theo vùng chọn; phần được chọn thành span riêng +// mang override (data-bold-override / data-italic-override) — commit gửi +// override từng đoạn để engine lấy font BIẾN THỂ thật (không giả lập đậm). +function toggleInlineFormat(prop) { + const ce = $("editOverlay").querySelector(".edit-inline"); + if (!ce) return false; + const sel = window.getSelection(); + if (!sel.rangeCount || sel.isCollapsed) return false; + const range = sel.getRangeAt(0); + if (!ce.contains(range.commonAncestorContainer)) return false; + const walker = document.createTreeWalker(ce, NodeFilter.SHOW_TEXT); + const parts = []; + let n; + while ((n = walker.nextNode())) { + if (!range.intersectsNode(n)) continue; + const start = n === range.startContainer ? range.startOffset : 0; + const end = n === range.endContainer ? range.endOffset : (n.textContent || "").length; + if (end > start) parts.push({ node: n, start, end }); + } + const spanOf = (node) => + node.parentElement && node.parentElement.closest("span[data-style-run]"); + const targets = parts.filter((p) => spanOf(p.node)); + if (!targets.length) return false; + const isOn = (sp) => + prop === "bold" + ? sp.style.fontWeight === "bold" || parseInt(sp.style.fontWeight, 10) >= 600 + : sp.style.fontStyle === "italic"; + // Chuẩn Word: tất cả phần chọn đã ở trạng thái đó → tắt; còn lại → bật. + const turnOn = !targets.every((p) => isOn(spanOf(p.node))); + for (const p of targets) { + const sp = spanOf(p.node); + const whole = + p.start === 0 && p.end === (p.node.textContent || "").length && sp.childNodes.length === 1; + if (whole) { + applyInlineOverride(sp, prop, turnOn); + continue; + } + // Tách text node thành [trước][chọn][sau], mỗi phần bọc span cùng gốc. + let mid = p.node; + if (p.start > 0) mid = mid.splitText(p.start); + if (p.end - p.start < (mid.textContent || "").length) mid.splitText(p.end - p.start); + const frag = document.createDocumentFragment(); + for (const child of [...sp.childNodes]) { + const piece = sp.cloneNode(false); + delete piece.dataset.pdfw; // đã tách — bề rộng gốc không còn ứng với đoạn + piece.textContent = child.textContent; + if (child === mid) applyInlineOverride(piece, prop, turnOn); + frag.appendChild(piece); + } + sp.replaceWith(frag); + } + sel.removeAllRanges(); // DOM đã đổi — bỏ vùng chọn, giữ focus trong ô + ce.focus(); + $("editHint").textContent = turnOn + ? (prop === "bold" ? "Đã in đậm phần bôi đen" : "Đã in nghiêng phần bôi đen") + + " — Ctrl+Enter để áp dụng" + : "Đã bỏ " + (prop === "bold" ? "in đậm" : "in nghiêng") + " phần bôi đen"; + return true; +} + +function applyInlineOverride(sp, prop, on) { + if (prop === "bold") { + sp.style.fontWeight = on ? "bold" : "normal"; + sp.dataset.boldOverride = on ? "1" : "0"; + } else { + sp.style.fontStyle = on ? "italic" : "normal"; + sp.dataset.italicOverride = on ? "1" : "0"; + } +} + // Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung // từng run), `part` chỉ chứa field muốn đổi — field khác null = GIỮ NGUYÊN. function applyTextPropToSelected(part) { @@ -4263,11 +4388,21 @@ $("edFontFamily").addEventListener("change", () => { const fam = $("edFontFamily").value; if (fam) applyTextPropToSelected({ fontFamily: fam }); }); +// B/I: đang mở ô sửa + có bôi đen → format RIÊNG phần bôi đen; ngoài ô sửa → +// đổi cả dòng đang chọn như cũ. mousedown preventDefault để bấm nút không +// cướp focus của ô sửa (blur = commit sớm, mất vùng chọn). +for (const id of ["edBold", "edItalic"]) { + $(id).addEventListener("mousedown", (e) => { + if ($("editOverlay").querySelector(".edit-inline")) e.preventDefault(); + }); +} $("edBold").addEventListener("click", () => { + if (toggleInlineFormat("bold")) return; const o = state.editObjects.find((x) => x.index === state.editSel); if (o) applyTextPropToSelected({ bold: !o.fontBold }); }); $("edItalic").addEventListener("click", () => { + if (toggleInlineFormat("italic")) return; const o = state.editObjects.find((x) => x.index === state.editSel); if (o) applyTextPropToSelected({ italic: !o.fontItalic }); }); @@ -4349,6 +4484,13 @@ window.addEventListener("keydown", (e) => { finishEditing(); closeNotePopup(); closeColorPopover(); deselectAnnot(); setTool(null); return; } + // Ctrl+B / Ctrl+I trong ô sửa: format phần bôi đen (chuẩn Word). + if (e.ctrlKey && !e.altKey && state.editMode && (e.key === "b" || e.key === "B")) { + if (toggleInlineFormat("bold")) { e.preventDefault(); return; } + } + if (e.ctrlKey && !e.altKey && state.editMode && (e.key === "i" || e.key === "I")) { + if (toggleInlineFormat("italic")) { e.preventDefault(); return; } + } if ((e.key === "Delete" || e.key === "Backspace") && !typing && state.editMode && state.editSel != null) { e.preventDefault(); deleteSelectedEditObject(); diff --git a/app/src/styles.css b/app/src/styles.css index 9905ab4..5c70eaf 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -367,6 +367,32 @@ body.tool-active .annotlayer * { pointer-events: none; } #edDiscard { background: #8d3030; } #edDiscard:disabled { background: #555; } +/* Màn hình khởi động — hiện NGAY khi HTML paint, mờ dần khi app sẵn sàng. */ +.boot-splash { + position: fixed; + inset: 0; + z-index: 10000; + background: #2b2b2b; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + transition: opacity 0.3s ease; +} +.boot-splash.hide { opacity: 0; pointer-events: none; } +.boot-splash .boot-logo { font-size: 28px; font-weight: 700; color: #7aa2ff; letter-spacing: 1px; } +.boot-splash .boot-note { font-size: 12px; color: #999; } +.boot-spin { + width: 26px; + height: 26px; + border: 3px solid #555; + border-top-color: #7aa2ff; + border-radius: 50%; + animation: bootspin 0.9s linear infinite; +} +@keyframes bootspin { to { transform: rotate(360deg); } } + .edit-stage { flex: 1; overflow: auto; diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index fe4c4c3..3c5f0d8 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -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, + pub italic: Option, +} + +/// 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, + italic: Option, +} + +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, color: PdfColor, - /// Reflow NHIỀU STYLE: các dòng cứng theo đoạn + bảng style đã resolve. - rich: Option<(Vec>, HashMap)>, + /// 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>, HashMap)>, + /// 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, } /// 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 = { + 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>, HashMap)> = + // 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>, HashMap)> = if let Some(rich_lines) = rich { - let mut style_ids: Vec = - rich_lines.iter().flatten().map(|s| s.style).collect(); - style_ids.sort_unstable(); + let mut style_ids: Vec = + 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 = HashMap::new(); - for sid in style_ids { + let mut table: HashMap = 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> = styles + let faces: HashMap> = 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 = 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::() + space_w; } diff --git a/crates/ff-engine/src/fontfix.rs b/crates/ff-engine/src/fontfix.rs index 6c1a3f3..6942096 100644 --- a/crates/ff-engine/src/fontfix.rs +++ b/crates/ff-engine/src/fontfix.rs @@ -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>, + mapping: Option<&FontMapping>, ) -> Vec { - 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>, + mapping: Option<&FontMapping>, ) -> 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 + 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)> = Vec::with_capacity(num + 1); + let mut tables: Vec<([u8; 4], Vec)> = 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)]) -> 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> { + 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 { + let entries: [(u16, &str); 2] = [(1, "FFX Embedded"), (2, "Regular")]; + let mut strings: Vec = Vec::new(); + let mut records: Vec = 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 { + 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, is_cid: bool, cid_to_gid: Option>) -> 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 { + 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)> = + 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); + } } diff --git a/crates/ff-engine/src/tounicode.rs b/crates/ff-engine/src/tounicode.rs index de44f67..77c6c1c 100644 --- a/crates/ff-engine/src/tounicode.rs +++ b/crates/ff-engine/src/tounicode.rs @@ -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, + /// 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>, +} + +/// 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> { +) -> Option { 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> { +) -> Option { 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 }); } } } diff --git a/crates/ff-engine/tests/edit_roundtrip.rs b/crates/ff-engine/tests/edit_roundtrip.rs index dad8fc4..1d18a3f 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -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); +}