diff --git a/README.md b/README.md index d16ad66..40f2891 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ | [docs/02-tech-stack.md](docs/02-tech-stack.md) | Phân tích & lựa chọn ngôn ngữ / framework / engine | | [docs/03-roadmap.md](docs/03-roadmap.md) | Lộ trình theo phase, mục tiêu, phương pháp, định nghĩa hoàn thành (DoD) | | [docs/04-architecture.md](docs/04-architecture.md) | Kiến trúc kỹ thuật chi tiết (sẽ chi tiết hóa dần) | +| [docs/14-foxit-gap-analysis.md](docs/14-foxit-gap-analysis.md) | Đánh giá định hướng/công nghệ/tiến độ so với tham vọng thay Foxit; các khoảng cách còn lại + giải pháp | ## Trạng thái hiện tại @@ -34,11 +35,12 @@ - [x] UI: chế độ "Tổ chức trang" (lưới + kéo-thả + multi-select), undo/redo toàn cục (chú thích + tổ chức trang chung 1 stack), dialog cho mọi thao tác (có xem trước thật cho Watermark/Header-Footer) - [ ] Follow-up: preview thật cho Insert/Extract/Replace/Crop, tách theo outline, watermark ảnh — xem [docs/10-phase3-summary.md](docs/10-phase3-summary.md) - **Checklist test cho bạn**: [docs/11-phase3-user-tests.md](docs/11-phase3-user-tests.md) -- [x] **Phase 4 — Chỉnh sửa nội dung (Edit)** ⭐ tính năng lõi/moat ✅ Iteration 1 hoàn tất (43/43 test xanh) +- [x] **Phase 4 — Chỉnh sửa nội dung (Edit)** ⭐ tính năng lõi/moat ✅ Iteration 1 + 2 hoàn tất (50/50 test xanh ngoài qpdf) - [x] Engine `edit.rs`: liệt kê object + sửa text run (tiếng Việt), xoá, di chuyển/resize, thêm chữ/ảnh, thay ảnh — round-trip test - [x] UI chế độ "Sửa nội dung": overlay đối tượng, sửa text tại chỗ WYSIWYG, thêm chữ/ảnh, xoá, cỡ chữ/màu, undo/redo, lưu - - [ ] Follow-up: reflow đoạn nhiều dòng, xoay/lật/clip, tab Format nâng cao, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md) - - **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) + - [x] **Iteration 2 — GIỮ FONT khi sửa (chuẩn Foxit)**: sửa tại chỗ giữ nguyên font gốc/nhúng (kể cả tiếng Việt); thiếu glyph mới thay font CÙNG HỌ (`fontmatch.rs`); đổi cỡ/màu không đụng font (fix bug phóng đại kép); sửa CẢ DÒNG (gộp run); WYSIWYG khi gõ; B/I + đổi font family; kéo-thả live; dọn file tạm + - [ ] Follow-up: reflow đoạn nhiều dòng (kế hoạch trong [docs/14-foxit-gap-analysis.md](docs/14-foxit-gap-analysis.md)), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md) + - **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E25) - [ ] **Phase 5 — Bảo mật & Chữ ký** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md) Build & chạy: xem [docs/05-dev-setup.md](docs/05-dev-setup.md). diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index b19eead..67e7423 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -665,6 +665,10 @@ struct ObjectInfoDto { rect: RectDto, text: Option, font_name: Option, + font_family: Option, + font_bold: Option, + font_italic: Option, + font_embedded: Option, font_size: Option, color: Option<[u8; 4]>, } @@ -683,6 +687,10 @@ fn edit_list_objects(path: String, page: u16, password: Option) -> Resul rect: RectDto { left: o.rect.left, bottom: o.rect.bottom, right: o.rect.right, top: o.rect.top }, text: o.text, font_name: o.font_name, + font_family: o.font_family, + font_bold: o.font_bold, + font_italic: o.font_italic, + font_embedded: o.font_embedded, font_size: o.font_size, color: o.color, }) @@ -709,10 +717,11 @@ struct EditOpDto { text: String, font_size: Option, color: Option<[u8; 4]>, - #[serde(default)] - bold: bool, - #[serde(default)] - italic: bool, + /// setText/addText: None = giữ font gốc; Some = đổi sang family này. + font_family: Option, + /// setText: None = giữ kiểu gốc. addText: None ≙ false. + bold: Option, + italic: Option, #[serde(default)] x: f32, #[serde(default)] @@ -737,6 +746,7 @@ fn edit_op_from_dto(d: EditOpDto) -> Result { text: d.text, font_size: d.font_size, color: d.color, + font_family: d.font_family, bold: d.bold, italic: d.italic, }, @@ -748,8 +758,9 @@ fn edit_op_from_dto(d: EditOpDto) -> Result { text: d.text, font_size: d.font_size.unwrap_or(14.0), color: d.color.unwrap_or([0, 0, 0, 255]), - bold: d.bold, - italic: d.italic, + font_family: d.font_family, + bold: d.bold.unwrap_or(false), + italic: d.italic.unwrap_or(false), }, "addImage" => ff_engine::EditOp::AddImage { x: d.x, @@ -821,6 +832,25 @@ fn edit_preview(input: String, page: u16, ops: Vec, width: u32, passw result } +/// Dọn các file làm việc tạm của chế độ sửa (undo stack). Chỉ xoá file có tên +/// `ff_edit_*` nằm đúng trong thư mục temp — không bao giờ đụng file người dùng. +#[tauri::command] +fn edit_cleanup(paths: Vec) { + let tmp = std::env::temp_dir(); + for p in paths { + let path = std::path::PathBuf::from(&p); + let name_ok = path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("ff_edit_") && n.ends_with(".pdf")) + .unwrap_or(false); + let dir_ok = path.parent().map(|d| d == tmp).unwrap_or(false); + if name_ok && dir_ok { + let _ = std::fs::remove_file(&path); + } + } +} + /// Hộp thoại chọn file ảnh (cho Thêm ảnh / Thay ảnh). #[tauri::command] fn pick_image(app: tauri::AppHandle) -> Option { @@ -861,6 +891,7 @@ fn main() { edit_apply, edit_apply_to_temp, edit_preview, + edit_cleanup, pick_image ]) .run(tauri::generate_context!()) diff --git a/app/src/index.html b/app/src/index.html index 7b2ed11..5e77646 100644 --- a/app/src/index.html +++ b/app/src/index.html @@ -81,7 +81,21 @@ + + + diff --git a/app/src/main.js b/app/src/main.js index d4e859f..90f69c8 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -38,6 +38,7 @@ const state = { editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh) editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text + editTemps: [], // mọi file tạm đã materialize trong phiên sửa (để dọn) }; const UNDO_LIMIT = 50; let annotIdSeq = 1; @@ -2001,6 +2002,7 @@ function enterEditMode() { state.editBase = state.path; state.editUndo = []; state.editRedo = []; + state.editTemps = []; state.editSel = null; state.editArm = null; state.editPendingImage = null; @@ -2013,7 +2015,17 @@ function enterEditMode() { setTool(null); loadEditPage(); } +// Dọn mọi file làm việc tạm của phiên sửa (backend chỉ xoá đúng ff_edit_*.pdf +// trong thư mục temp nên gọi thoải mái). +function cleanupEditTemps() { + if (state.editTemps.length) { + invoke("edit_cleanup", { paths: state.editTemps.slice() }).catch(() => {}); + state.editTemps = []; + } +} + function exitEditMode() { + cleanupEditTemps(); state.editMode = false; $("annobar").classList.remove("hidden"); $("editBar").classList.add("hidden"); @@ -2116,9 +2128,26 @@ function selectEditObject(index) { $("edReplaceImage").disabled = !isImage; $("edFontSize").disabled = !isText; $("edColorBtn").disabled = !isText; + $("edFontFamily").disabled = !isText; + $("edBold").disabled = !isText; + $("edItalic").disabled = !isText; if (isText) { $("edFontSize").value = Math.round(o.font_size || 12); if (o.color) { state.editColor = o.color.slice(0, 3); $("edSw").style.background = rgbCss(state.editColor); } + // Ô font: mặc định "(giữ nguyên: )" — chỉ đổi khi người dùng chọn khác. + $("edFontFamily").options[0].textContent = o.font_family + ? `(giữ nguyên: ${o.font_family})` + : "(giữ nguyên)"; + $("edFontFamily").value = ""; + $("edBold").classList.toggle("on", !!o.font_bold); + $("edItalic").classList.toggle("on", !!o.font_italic); + const emb = o.font_embedded == null ? "" : o.font_embedded ? " · font nhúng" : " · font hệ thống"; + $("editHint").textContent = + `${o.font_family || o.font_name || "?"} · ${Math.round(o.font_size || 0)}pt${emb}`; + } else { + $("edFontFamily").options[0].textContent = "(giữ nguyên)"; + $("edBold").classList.remove("on"); + $("edItalic").classList.remove("on"); } refreshEditSelection(); } @@ -2130,12 +2159,14 @@ function updateEditUndoButtons() { $("redoBtn").disabled = state.editRedo.length === 0; } -// Áp 1 op: lưu editBase cũ vào stack undo, materialize ra file tạm mới. -async function stageEditOp(op) { +// Áp 1 nhóm op (1 thao tác người dùng): lưu editBase cũ vào stack undo, +// materialize ra file tạm mới, ghi nhận file tạm để dọn khi thoát. +async function stageEditOps(ops) { try { const out = await invoke("edit_apply_to_temp", { - input: state.editBase, page: state.editPage, ops: [op], password: null, + input: state.editBase, page: state.editPage, ops, password: null, }); + state.editTemps.push(out); state.editUndo.push(state.editBase); state.editRedo = []; state.editBase = out; @@ -2147,6 +2178,10 @@ async function stageEditOp(op) { } } +function stageEditOp(op) { + return stageEditOps([op]); +} + function editUndo() { if (!state.editUndo.length) return; state.editRedo.push(state.editBase); @@ -2162,36 +2197,121 @@ function editRedo() { loadEditPage(); } -// Sửa text tại chỗ: ô input phủ lên box, gõ xong Enter/blur → SetText. +// CSS font-family xấp xỉ cho family PDF (để ô sửa hiển thị đúng dáng chữ). +function cssFontStack(family) { + if (!family) return "sans-serif"; + const k = family.toLowerCase(); + const generic = /times|georgia|garamond|palatino|cambria|book|serif/.test(k) + ? "serif" + : /courier|consolas|mono/.test(k) + ? "monospace" + : "sans-serif"; + return `"${family}", ${generic}`; +} + +// Gom các text run CÙNG DÒNG với `o` (baseline xấp xỉ, liền kề theo chiều +// ngang) — PDF hay cắt 1 dòng nhìn thấy thành nhiều run; Foxit cho sửa cả +// dòng nên ta cũng vậy. Trả về mảng run đã xếp trái→phải (luôn chứa `o`). +function sameLineRuns(o) { + const fs = o.font_size || 12; + const tol = Math.max(1.5, fs * 0.25); + const cands = state.editObjects + .filter( + (x) => + x.kind === "text" && + Math.abs(x.rect.bottom - o.rect.bottom) <= tol && + (x.font_size || 12) <= fs * 2.2 && + (x.font_size || 12) >= fs / 2.2 + ) + .sort((a, b) => a.rect.left - b.rect.left); + const i0 = cands.findIndex((x) => x.index === o.index); + if (i0 < 0) return [o]; + const gapMax = Math.max(fs * 1.2, 6); // hở quá 1.2em coi như cột khác + const runs = [cands[i0]]; + for (let i = i0 - 1; i >= 0; i--) { + if (runs[0].rect.left - cands[i].rect.right <= gapMax) runs.unshift(cands[i]); + else break; + } + for (let i = i0 + 1; i < cands.length; i++) { + if (cands[i].rect.left - runs[runs.length - 1].rect.right <= gapMax) runs.push(cands[i]); + else break; + } + return runs; +} + +// Ghép nội dung 1 dòng từ các run: chèn khoảng trắng khi 2 run hở nhau rõ. +function composeLineText(runs) { + let s = ""; + for (let i = 0; i < runs.length; i++) { + const t = runs[i].text || ""; + if (i > 0) { + const gap = runs[i].rect.left - runs[i - 1].rect.right; + const em = (runs[i].font_size || 12) * 0.28; + if (gap > em && !s.endsWith(" ") && !t.startsWith(" ")) s += " "; + } + s += t; + } + return s; +} + +// Sửa text tại chỗ kiểu Foxit: gộp cả dòng, ô sửa hiển thị đúng font/cỡ/màu, +// Enter/blur commit — engine GIỮ FONT GỐC (chỉ thay khi thiếu glyph). function startInlineTextEdit(o) { const ov = $("editOverlay"); const existing = ov.querySelector(".edit-inline"); if (existing) existing.remove(); + + const runs = sameLineRuns(o); + const original = composeLineText(runs); + const union = { + left: Math.min(...runs.map((r) => r.rect.left)), + right: Math.max(...runs.map((r) => r.rect.right)), + bottom: Math.min(...runs.map((r) => r.rect.bottom)), + top: Math.max(...runs.map((r) => r.rect.top)), + }; + const inp = document.createElement("input"); inp.className = "edit-inline"; - inp.value = o.text || ""; - const st = editBoxStyle(o.rect); + inp.value = original; + const st = editBoxStyle(union); Object.assign(inp.style, st); + // Chừa chỗ gõ thêm về bên phải (không tràn khung trang). + const p = state.pages[state.editPage]; + const maxW = (p.widthPt - union.left) * state.editScale - 4; + inp.style.width = Math.min(maxW, parseFloat(st.width) + 220) + "px"; + // WYSIWYG khi gõ: đúng font/cỡ/màu/kiểu của run đầu. inp.style.fontSize = Math.max(8, (o.font_size || 12) * state.editScale) + "px"; + inp.style.fontFamily = cssFontStack(o.font_family); + inp.style.fontWeight = o.font_bold ? "bold" : "normal"; + inp.style.fontStyle = o.font_italic ? "italic" : "normal"; + if (o.color) inp.style.color = rgbCss(o.color); ov.appendChild(inp); inp.focus(); inp.select(); + let done = false; const commit = (save) => { if (done) return; done = true; const text = inp.value; inp.remove(); - if (save && text !== (o.text || "")) { - stageEditOp({ - op: "setText", - index: o.index, - text, - fontSize: o.font_size || null, - color: o.color ? o.color : [0, 0, 0, 255], - bold: false, - italic: false, - }); + if (save && text !== original) { + // Run đầu nhận toàn bộ text mới (giữ font/cỡ/màu gốc — mọi field null), + // các run còn lại của dòng bị xoá. + const ops = [ + { + op: "setText", + index: runs[0].index, + text, + fontSize: null, + color: null, + fontFamily: null, + bold: null, + italic: null, + }, + ...runs.slice(1).map((r) => ({ op: "delete", index: r.index })), + ]; + stageEditOps(ops); } }; inp.addEventListener("keydown", (e) => { @@ -2202,19 +2322,21 @@ function startInlineTextEdit(o) { inp.addEventListener("blur", () => commit(true)); } -// Đổi cỡ chữ / màu cho text object đang chọn → SetText giữ nguyên nội dung. -function applyTextPropToSelected() { +// Đổi thuộc tính chữ cho run đang chọn. `part` chỉ chứa field muốn đổi — +// mọi field khác gửi null = GIỮ NGUYÊN (engine không đụng font khi không cần). +function applyTextPropToSelected(part) { const o = state.editObjects.find((x) => x.index === state.editSel); if (!o || o.kind !== "text") return; - const size = Number($("edFontSize").value) || o.font_size || 12; stageEditOp({ op: "setText", index: o.index, text: o.text || "", - fontSize: size, - color: [state.editColor[0], state.editColor[1], state.editColor[2], 255], - bold: false, - italic: false, + fontSize: null, + color: null, + fontFamily: null, + bold: null, + italic: null, + ...part, }); } @@ -2257,10 +2379,14 @@ function promptAddText(pdfX, pdfY) { inp.placeholder = "Nhập chữ…"; const s = state.editScale; const p = state.pages[state.editPage]; + const family = $("edFontFamily").value || null; // null = font mặc định + const size = Number($("edFontSize").value) || 16; inp.style.left = pdfX * s + "px"; inp.style.top = (p.heightPt - pdfY) * s + "px"; inp.style.minWidth = "120px"; - inp.style.fontSize = Math.max(10, 16 * s) + "px"; + inp.style.fontSize = Math.max(10, size * s) + "px"; + inp.style.fontFamily = cssFontStack(family); + inp.style.color = rgbCss(state.editColor); ov.appendChild(inp); inp.focus(); let done = false; @@ -2271,8 +2397,9 @@ function promptAddText(pdfX, pdfY) { if (save && text.trim()) { stageEditOp({ op: "addText", x: pdfX, y: pdfY, text, - fontSize: 16, color: [state.editColor[0], state.editColor[1], state.editColor[2], 255], - bold: false, italic: false, + fontSize: size, color: [state.editColor[0], state.editColor[1], state.editColor[2], 255], + fontFamily: family, + bold: null, italic: null, }); } }; @@ -2284,19 +2411,31 @@ function promptAddText(pdfX, pdfY) { inp.addEventListener("blur", () => commit(true)); } -// Kéo để di chuyển object đang chọn (chuột thật; WebView2 có thể lọc mousemove giả lập). +// Kéo để di chuyển object đang chọn — box đi theo con trỏ NGAY (live feedback +// như Foxit), thả chuột mới commit vào engine. function onEditBoxMouseDown(e, o) { if (state.editArm) return; if (e.target.classList.contains("ed-handle")) return; selectEditObject(o.index); + const box = e.currentTarget; const startX = e.clientX, startY = e.clientY; + const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top); let moved = false; const onMove = (ev) => { - if (Math.abs(ev.clientX - startX) > 2 || Math.abs(ev.clientY - startY) > 2) moved = true; + const dx = ev.clientX - startX, dy = ev.clientY - startY; + if (!moved && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) { + moved = true; + box.classList.add("dragging"); + } + if (moved) { + box.style.left = left0 + dx + "px"; + box.style.top = top0 + dy + "px"; + } }; const onUp = (ev) => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); + box.classList.remove("dragging"); if (!moved) return; const dx = (ev.clientX - startX) / state.editScale; const dy = -(ev.clientY - startY) / state.editScale; // CSS y xuống = PDF y giảm @@ -2307,22 +2446,33 @@ function onEditBoxMouseDown(e, o) { e.preventDefault(); } -// Kéo handle góc dưới-phải để resize. +// Kéo handle góc dưới-phải để resize — khung đổi kích thước live, thả commit. function onEditResizeMouseDown(e, o) { e.stopPropagation(); e.preventDefault(); + const box = e.target.parentElement; const startX = e.clientX, startY = e.clientY; const w0 = (o.rect.right - o.rect.left), h0 = (o.rect.top - o.rect.bottom); - const onMove = () => {}; + const cssW0 = parseFloat(box.style.width), cssH0 = parseFloat(box.style.height); + const onMove = (ev) => { + box.classList.add("dragging"); + box.style.width = Math.max(4, cssW0 + (ev.clientX - startX)) + "px"; + box.style.height = Math.max(4, cssH0 + (ev.clientY - startY)) + "px"; + }; const onUp = (ev) => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); + box.classList.remove("dragging"); const dwPt = (ev.clientX - startX) / state.editScale; const dhPt = (ev.clientY - startY) / state.editScale; // kéo xuống = cao hơn (y giảm ở đáy) const sx = Math.max(0.1, (w0 + dwPt) / Math.max(1, w0)); const sy = Math.max(0.1, (h0 + dhPt) / Math.max(1, h0)); if (Math.abs(sx - 1) > 0.01 || Math.abs(sy - 1) > 0.01) { - stageEditOp({ op: "transform", index: o.index, dx: 0, dy: 0, sx, sy }); + // Engine scale quanh góc DƯỚI-trái; bù dy để neo góc TRÊN-trái đứng yên + // (khớp handle góc dưới-phải + preview khi kéo, như Foxit). + stageEditOp({ op: "transform", index: o.index, dx: 0, dy: -(h0 * (sy - 1)), sx, sy }); + } else { + loadEditPage(); // trả khung về đúng kích thước cũ } }; window.addEventListener("mousemove", onMove); @@ -2488,12 +2638,29 @@ $("edAddText").addEventListener("click", () => { $("edAddImage").addEventListener("click", armAddImage); $("edDelete").addEventListener("click", deleteSelectedEditObject); $("edReplaceImage").addEventListener("click", replaceSelectedImage); -$("edFontSize").addEventListener("change", applyTextPropToSelected); +// Mọi thay đổi thuộc tính chỉ gửi ĐÚNG field đó — engine giữ nguyên font khi +// không cần thay (chuẩn Foxit: đổi cỡ/màu không được đổi font). +$("edFontSize").addEventListener("change", () => { + const size = Number($("edFontSize").value); + if (size > 0) applyTextPropToSelected({ fontSize: size }); +}); +$("edFontFamily").addEventListener("change", () => { + const fam = $("edFontFamily").value; + if (fam) applyTextPropToSelected({ fontFamily: fam }); +}); +$("edBold").addEventListener("click", () => { + const o = state.editObjects.find((x) => x.index === state.editSel); + if (o) applyTextPropToSelected({ bold: !o.font_bold }); +}); +$("edItalic").addEventListener("click", () => { + const o = state.editObjects.find((x) => x.index === state.editSel); + if (o) applyTextPropToSelected({ italic: !o.font_italic }); +}); $("edColorBtn").addEventListener("click", () => { openColorPopover($("edColorBtn"), state.editColor, (rgb) => { state.editColor = rgb; $("edSw").style.background = rgbCss(rgb); - applyTextPropToSelected(); + applyTextPropToSelected({ color: [rgb[0], rgb[1], rgb[2], 255] }); }); }); $("edSave").addEventListener("click", saveEdits); diff --git a/app/src/styles.css b/app/src/styles.css index 644175f..0f745bd 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -404,9 +404,28 @@ body.tool-active .annotlayer * { pointer-events: none; } border: 2px solid var(--accent); background: #fff; color: #000; - padding: 1px 2px; + padding: 0 1px; outline: none; font-family: inherit; + line-height: 1; overflow: hidden; white-space: pre; + box-sizing: content-box; +} + +/* Nút Đậm/Nghiêng trong thanh Sửa nội dung */ +.ed-style { + min-width: 28px; + padding: 3px 7px; +} +.ed-style.on { + background: var(--accent); + color: #fff; +} + +/* Kéo-thả trực tiếp: box đang kéo nổi lên trên, mờ nhẹ như Foxit */ +.edit-box.dragging { + opacity: 0.75; + z-index: 6; + border-style: solid; } diff --git a/crates/ff-engine/src/annot.rs b/crates/ff-engine/src/annot.rs index 8f27d48..42b118e 100644 --- a/crates/ff-engine/src/annot.rs +++ b/crates/ff-engine/src/annot.rs @@ -334,14 +334,51 @@ pub(crate) fn find_font_bytes(bold: bool, italic: bool) -> Option> { } #[cfg(not(any(windows, target_os = "macos")))] { - let _ = (bold, italic); - let candidates: &[&str] = &[ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/TTF/DejaVuSans.ttf", - "/usr/share/fonts/dejavu/DejaVuSans.ttf", + // [regular, bold, italic, bold-italic] cho các family Linux phổ biến. + const FAMILIES: &[[&str; 4]] = &[ + [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-BoldOblique.ttf", + ], + [ + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-BoldItalic.ttf", + ], + [ + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Oblique.ttf", + "/usr/share/fonts/TTF/DejaVuSans-BoldOblique.ttf", + ], + [ + "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/dejavu/DejaVuSans-Oblique.ttf", + "/usr/share/fonts/dejavu/DejaVuSans-BoldOblique.ttf", + ], ]; - candidates.iter().find_map(|p| std::fs::read(p).ok()) + let idx = match (bold, italic) { + (false, false) => 0, + (true, false) => 1, + (false, true) => 2, + (true, true) => 3, + }; + for fam in FAMILIES { + if let Ok(bytes) = std::fs::read(fam[idx]) { + return Some(bytes); + } + } + // Không có biến thể đúng → hạ cấp về regular đầu tiên có sẵn. + for fam in FAMILIES { + if let Ok(bytes) = std::fs::read(fam[0]) { + return Some(bytes); + } + } + None } } diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index e71ea01..c517ebc 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -3,22 +3,34 @@ //! //! PDFium đã expose page object cấp cao nên KHÔNG cần tự parse content stream: //! mỗi `PdfPageTextObject` là 1 text run sẵn để sửa (có text/font/size/matrix/ -//! màu riêng). Xem khảo sát trong kế hoạch Phase 4. +//! màu riêng). //! -//! Bài học tái dùng: -//! - Sửa/Thêm text dùng FULL font nhúng (`find_font_bytes` + -//! `load_true_type_from_bytes(..., true)`) để tiếng Việt hiển thị đúng dấu — -//! `set_text()` của PDFium re-encode theo font HIỆN TẠI (thường là subset) -//! nên glyph ngoài subset sẽ mất; vì vậy SetText = tạo lại object bằng full -//! font, giữ nguyên matrix/cỡ/màu gốc. -//! - PDFium cần `regenerate_content()` trước khi lưu, nếu không mất thay đổi. +//! ## Giữ font gốc khi sửa (chuẩn Foxit — iteration 2) +//! Sửa text KHÔNG được đổi font. Quyết định theo 3 tầng cho mỗi `SetText`: +//! 1. **Giữ nguyên font gốc, sửa tại chỗ** (`FPDFText_SetText` re-encode theo +//! charmap của font hiện tại) khi chắc chắn an toàn: text mới chỉ dùng ký tự +//! đã có trong text cũ, HOẶC cmap của font (đọc qua `FPDFFont_GetFontData`) +//! phủ đủ mọi ký tự mới. Đổi cỡ chữ tại chỗ = scale matrix (không đụng font). +//! 2. Font gốc thiếu glyph (subset) → thay bằng font hệ thống **cùng họ, đúng +//! đậm/nghiêng** (`fontmatch::find_family_font_bytes`) — gần như không nhìn +//! ra khác biệt. +//! 3. Bất đắc dĩ mới rơi về font mặc định (`annot::find_font_bytes`). +//! +//! Cỡ chữ trong `SetText`/`ObjectInfo` theo nghĩa **hiển thị** (đã nhân scale +//! của matrix — đúng như UI thấy). Tạo lại object phải quy đổi ngược về cỡ +//! "unscaled" trước khi áp matrix gốc, nếu không chữ sẽ phóng đại kép. +//! +//! Bài học PDFium giữ nguyên: `regenerate_content()` trước khi lưu; object gỡ +//! bằng `remove_object_at_index` phải `std::mem::forget` (Drop gọi +//! FPDFPageObj_Destroy → SEGFAULT). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use pdfium_render::prelude::*; use crate::annot::find_font_bytes; +use crate::fontmatch; use crate::text::Rect; use crate::EngineError; @@ -67,9 +79,16 @@ pub struct ObjectInfo { pub rect: Rect, /// Chỉ với text: nội dung hiện tại. pub text: Option, - /// Chỉ với text: tên font. + /// Chỉ với text: tên font gốc trong PDF (có thể kèm prefix subset). pub font_name: Option, - /// Chỉ với text: cỡ chữ hiệu dụng (đã tính scale của matrix). + /// Chỉ với text: family đã làm sạch (để UI hiển thị + CSS xấp xỉ). + pub font_family: Option, + /// Chỉ với text: font đậm / nghiêng (từ weight/italic-angle + tên font). + pub font_bold: Option, + pub font_italic: Option, + /// Chỉ với text: font có nhúng trong file không. + pub font_embedded: Option, + /// Chỉ với text: cỡ chữ hiển thị (đã tính scale của matrix). pub font_size: Option, /// Chỉ với text: màu chữ RGBA. pub color: Option<[u8; 4]>, @@ -81,14 +100,34 @@ pub enum EditOp { /// Dịch (dx,dy) và/hoặc scale (sx,sy) object `index` — scale quanh góc /// dưới-trái của object để giữ neo góc khi resize. Transform { index: u16, dx: f32, dy: f32, sx: f32, sy: f32 }, - /// Sửa nội dung text object `index` (tạo lại bằng full font, giữ matrix gốc). - SetText { index: u16, text: String, font_size: Option, color: Option<[u8; 4]>, bold: bool, italic: bool }, + /// Sửa text object `index`. Mặc định GIỮ NGUYÊN font/cỡ/màu/kiểu gốc: + /// mọi field `None` nghĩa là "không đổi". `font_size` theo cỡ hiển thị. + /// `font_family`/`bold`/`italic` = Some(...) chủ động đổi font (match font + /// hệ thống cùng họ). + SetText { + index: u16, + text: String, + font_size: Option, + color: Option<[u8; 4]>, + font_family: Option, + bold: Option, + italic: Option, + }, /// Xoá object `index`. Delete { index: u16 }, /// Thay ảnh của image object `index` bằng ảnh từ `image_path`, giữ khung cũ. ReplaceImage { index: u16, image_path: String }, /// Thêm text box mới tại (x,y) (gốc dưới-trái, điểm PDF). - AddText { x: f32, y: f32, text: String, font_size: f32, color: [u8; 4], bold: bool, italic: bool }, + AddText { + x: f32, + y: f32, + text: String, + font_size: f32, + color: [u8; 4], + font_family: Option, + bold: bool, + italic: bool, + }, /// Thêm ảnh mới từ file, khung width_pt × height_pt tại (x,y). AddImage { x: f32, y: f32, width_pt: f32, height_pt: f32, image_path: String }, } @@ -102,6 +141,29 @@ fn quad_to_rect(q: &PdfQuadPoints) -> Rect { } } +/// Kiểu chữ (đậm/nghiêng) của 1 text object, tổng hợp từ weight, italic-angle +/// và tên font. Family lấy từ `name()` (BaseFont khai báo trong PDF) — với +/// font KHÔNG nhúng, `family()` trả tên font stub nội bộ của PDFium ("Chrom +/// Sans OTF") chứ không phải font thật, nên chỉ dùng làm fallback. +fn text_object_style(t: &PdfPageTextObject) -> (String, bool, bool) { + let raw_name = t.font().name(); + let family_src = if raw_name.trim().is_empty() { + t.font().family() + } else { + raw_name + }; + let (family, name_bold, name_italic) = fontmatch::clean_font_name(&family_src); + let weight_bold = matches!( + t.font().weight(), + Ok(PdfFontWeight::Weight600) + | Ok(PdfFontWeight::Weight700Bold) + | Ok(PdfFontWeight::Weight800) + | Ok(PdfFontWeight::Weight900) + ) || matches!(t.font().weight(), Ok(PdfFontWeight::Custom(v)) if v >= 600); + let angle_italic = t.font().italic_angle().map(|a| a != 0).unwrap_or(false); + (family, weight_bold || name_bold, angle_italic || name_italic) +} + /// Liệt kê các page object của 1 trang để UI vẽ overlay chỉnh sửa. pub fn list_objects( pdfium: &Pdfium, @@ -125,43 +187,89 @@ pub fn list_objects( .map(|q| quad_to_rect(&q)) .unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 }); - let (text, font_name, font_size, color) = if let Some(t) = object.as_text_object() { - let c = t.fill_color().ok(); - ( - Some(t.text()), - Some(t.font().name()), - Some(t.scaled_font_size().value), - c.map(|c| [c.red(), c.green(), c.blue(), c.alpha()]), - ) - } else { - (None, None, None, None) - }; - - out.push(ObjectInfo { + let mut info = ObjectInfo { index: i as u16, kind, rect, - text, - font_name, - font_size, - color, - }); + text: None, + font_name: None, + font_family: None, + font_bold: None, + font_italic: None, + font_embedded: None, + font_size: None, + color: None, + }; + if let Some(t) = object.as_text_object() { + let (family, bold, italic) = text_object_style(t); + info.text = Some(t.text()); + info.font_name = Some(t.font().name()); + info.font_family = Some(family); + info.font_bold = Some(bold); + info.font_italic = Some(italic); + info.font_embedded = t.font().is_embedded().ok(); + info.font_size = Some(t.scaled_font_size().value); + info.color = t + .fill_color() + .ok() + .map(|c| [c.red(), c.green(), c.blue(), c.alpha()]); + } + out.push(info); } Ok(out) } +/// Key nhận diện 1 font thay thế cần nạp: (family chuẩn hoá — rỗng = font mặc +/// định, đậm, nghiêng). +type FontKey = (String, bool, bool); + +/// Cách xử lý 1 op SetText, quyết định ở pha lập kế hoạch. +enum SetTextMode { + /// Giữ nguyên font gốc — sửa text/cỡ/màu ngay trên object. + InPlace, + /// Phải thay font (glyph thiếu hoặc người dùng đổi family/kiểu) — xoá và + /// tạo lại object với font `key`. + Substitute(FontKey), +} + /// Dữ liệu chụp lại từ 1 object trước khi xoá để tạo lại bản thay thế. struct Captured { matrix: PdfMatrix, unscaled_font_size: f32, + scaled_font_size: f32, color: PdfColor, rect: Rect, } +/// Chọn font thay thế cho (family mong muốn, kiểu chữ, text cần hiển thị): +/// font hệ thống cùng họ nếu có + đủ glyph, ngược lại font mặc định. +fn resolve_substitute_font( + family: &str, + bold: bool, + italic: bool, + text: &str, +) -> Result<(FontKey, Vec), EngineError> { + if !family.trim().is_empty() { + if let Some(bytes) = fontmatch::find_family_font_bytes(family, bold, italic) { + if fontmatch::coverage_ok(&bytes, text) { + return Ok(((fontmatch::normalize_key(family), bold, italic), bytes)); + } + } + } + let bytes = find_font_bytes(bold, italic).ok_or_else(|| { + EngineError::Pdfium("không tìm được font hệ thống để sửa/thêm chữ".into()) + })?; + Ok(((String::new(), bold, italic), bytes)) +} + /// Áp danh sách `ops` lên trang `page_index` của `input`, ghi ra `output`. -/// Không sửa `input`. Thứ tự xử lý giữ index gốc hợp lệ: Transform in-place → -/// chụp dữ liệu object sắp thay/xoá → xoá theo index GIẢM DẦN → thêm bản thay -/// thế → thêm object mới → regenerate → lưu. +/// Không sửa `input`. +/// +/// Trình tự: (A) lập kế hoạch font cho SetText/AddText (đọc trang lần 1) → +/// (B) nạp các font thay thế cần thiết vào document → (C) áp thay đổi (đọc +/// trang lần 2): Transform in-place → SetText in-place (giữ font gốc) → chụp +/// dữ liệu object sắp thay → xoá theo index GIẢM DẦN → thêm bản thay thế → +/// thêm object mới → regenerate → lưu. pub fn apply_edits( pdfium: &Pdfium, input: &Path, @@ -174,32 +282,109 @@ pub fn apply_edits( .load_pdf_from_file(input, password) .map_err(|e| EngineError::Pdfium(e.to_string()))?; - // Nạp trước token cho mọi tổ hợp (bold,italic) cần dùng (SetText/AddText) — - // phải làm TRƯỚC khi mượn page vì cần `document.fonts_mut()`. - let mut needed_fonts: Vec<(bool, bool)> = Vec::new(); - for op in ops { - match op { - EditOp::SetText { bold, italic, .. } | EditOp::AddText { bold, italic, .. } => { - if !needed_fonts.contains(&(*bold, *italic)) { - needed_fonts.push((*bold, *italic)); - } - } - _ => {} - } - } - let mut tokens: HashMap<(bool, bool), PdfFontToken> = HashMap::new(); - for (bold, italic) in needed_fonts { - let bytes = find_font_bytes(bold, italic) - .ok_or_else(|| EngineError::Pdfium("không tìm được font hệ thống để sửa/thêm chữ".into()))?; - let token = document - .fonts_mut() - .load_true_type_from_bytes(&bytes, true) - .map_err(|e| EngineError::Pdfium(format!("nạp font: {e}")))?; - tokens.insert((bold, italic), token); - } - let err = |e: PdfiumError| EngineError::Pdfium(format!("sửa nội dung: {e}")); + // ---- (A) Lập kế hoạch font (mượn trang lần 1, chỉ đọc) ---- + let mut set_text_modes: HashMap = HashMap::new(); + let mut add_text_keys: HashMap = HashMap::new(); + let mut font_bytes_needed: HashMap> = HashMap::new(); + { + let page = document + .pages() + .get(page_index) + .map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?; + let obj_count = page.objects().len(); + let valid = |i: u16| (i as usize) < obj_count; + + for (opi, op) in ops.iter().enumerate() { + match op { + EditOp::SetText { index, text, font_family, bold, italic, .. } => { + if !valid(*index) { + continue; + } + let obj = page.objects().get(*index as usize).map_err(err)?; + let t = match obj.as_text_object() { + Some(t) => t, + None => continue, // không phải text → bỏ qua op + }; + let (cur_family, cur_bold, cur_italic) = text_object_style(t); + let target_bold = bold.unwrap_or(cur_bold); + let target_italic = italic.unwrap_or(cur_italic); + let style_change = target_bold != cur_bold || target_italic != cur_italic; + let family_change = font_family.as_deref().map_or(false, |f| { + !f.trim().is_empty() + && fontmatch::normalize_key(f) != fontmatch::normalize_key(&cur_family) + }); + + let mode = if !style_change && !family_change { + let old_text = t.text(); + let old_chars: HashSet = old_text.chars().collect(); + let new_covered_by_old = text + .chars() + .all(|c| c.is_control() || old_chars.contains(&c)); + // Font KHÔNG nhúng (base-14 Helvetica/Times...): viewer nào + // cũng tự thay bằng font hệ thống đủ Latin → re-encode text + // ASCII tại chỗ chắc chắn an toàn, BaseFont khai báo giữ + // nguyên trong file (đúng hành vi Foxit với base-14). + let non_embedded = !t.font().is_embedded().unwrap_or(true); + let ascii_only = text + .chars() + .all(|c| c.is_control() || (' '..='~').contains(&c)); + if *text == old_text || new_covered_by_old { + SetTextMode::InPlace + } else if non_embedded && ascii_only { + SetTextMode::InPlace + } else if t + .font() + .data() + .ok() + .map_or(false, |bytes| fontmatch::coverage_ok(&bytes, text)) + { + SetTextMode::InPlace + } else { + let (key, bytes) = resolve_substitute_font( + &cur_family, + target_bold, + target_italic, + text, + )?; + font_bytes_needed.entry(key.clone()).or_insert(bytes); + SetTextMode::Substitute(key) + } + } else { + let family = font_family + .clone() + .filter(|f| !f.trim().is_empty()) + .unwrap_or(cur_family); + let (key, bytes) = + resolve_substitute_font(&family, target_bold, target_italic, text)?; + font_bytes_needed.entry(key.clone()).or_insert(bytes); + SetTextMode::Substitute(key) + }; + set_text_modes.insert(opi, mode); + } + EditOp::AddText { text, font_family, bold, italic, .. } => { + let family = font_family.clone().unwrap_or_default(); + let (key, bytes) = resolve_substitute_font(&family, *bold, *italic, text)?; + font_bytes_needed.entry(key.clone()).or_insert(bytes); + add_text_keys.insert(opi, key); + } + _ => {} + } + } + } + + // ---- (B) Nạp các font thay thế cần dùng (cần fonts_mut → ngoài mượn trang) ---- + let mut tokens: HashMap = HashMap::new(); + for (key, bytes) in &font_bytes_needed { + let token = document + .fonts_mut() + .load_true_type_from_bytes(bytes, true) + .map_err(|e| EngineError::Pdfium(format!("nạp font: {e}")))?; + tokens.insert(key.clone(), token); + } + + // ---- (C) Áp thay đổi (mượn trang lần 2) ---- { let mut page = document .pages() @@ -231,73 +416,150 @@ pub fn apply_edits( } } - // (2) Chụp dữ liệu cho object sắp THAY (SetText/ReplaceImage) — sau khi - // đã Transform để bản thay thế kế thừa cả phép biến đổi đó. + // (2) SetText IN-PLACE — giữ nguyên font gốc (chuẩn Foxit). Đổi cỡ chữ + // = scale matrix quanh gốc text (giữ điểm neo baseline e,f). + for (opi, op) in ops.iter().enumerate() { + let EditOp::SetText { index, text, font_size, color, .. } = op else { + continue; + }; + if !matches!(set_text_modes.get(&opi), Some(SetTextMode::InPlace)) { + continue; + } + let mut obj = page.objects().get(*index as usize).map_err(err)?; + let (old_text, scaled) = match obj.as_text_object() { + Some(t) => (t.text(), t.scaled_font_size().value), + None => continue, + }; + if *text != old_text { + obj.as_text_object_mut() + .ok_or_else(|| EngineError::Pdfium("object không phải text".into()))? + .set_text(text) + .map_err(err)?; + } + if let Some(target) = font_size { + if scaled > 0.01 && (*target - scaled).abs() > 0.01 { + let k = *target / scaled; + let m = obj.matrix().map_err(err)?; + // PDFium post-multiply: M' = M · [k,0,0,k, e(1−k), f(1−k)] + // → phần tuyến tính nhân k (đổi cỡ), translation (e,f) giữ + // nguyên (neo tại gốc baseline như Foxit). + obj.apply_matrix(PdfMatrix::new( + k, + 0.0, + 0.0, + k, + m.e() * (1.0 - k), + m.f() * (1.0 - k), + )) + .map_err(err)?; + } + } + if let Some(c) = color { + obj.set_fill_color(PdfColor::new(c[0], c[1], c[2], c[3])).map_err(err)?; + } + } + + // (3) Chụp dữ liệu cho object sắp THAY (SetText-substitute/ReplaceImage) + // — sau Transform để bản thay thế kế thừa cả phép biến đổi đó. let mut captured: HashMap = HashMap::new(); - for op in ops { + for (opi, op) in ops.iter().enumerate() { let idx = match op { - EditOp::SetText { index, .. } | EditOp::ReplaceImage { index, .. } => *index, + EditOp::SetText { index, .. } + if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) => + { + *index + } + EditOp::ReplaceImage { index, .. } => *index, _ => continue, }; if !valid(idx) || captured.contains_key(&idx) { continue; } let obj = page.objects().get(idx as usize).map_err(err)?; - let rect = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 }); - let (matrix, unscaled, color) = if let Some(t) = obj.as_text_object() { + let rect = obj + .bounds() + .map(|q| quad_to_rect(&q)) + .unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 }); + let (matrix, unscaled, scaled, color) = if let Some(t) = obj.as_text_object() { ( obj.matrix().map_err(err)?, t.unscaled_font_size().value, + t.scaled_font_size().value, t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)), ) } else { - (obj.matrix().map_err(err)?, 0.0, PdfColor::new(0, 0, 0, 255)) + (obj.matrix().map_err(err)?, 0.0, 0.0, PdfColor::new(0, 0, 0, 255)) }; - captured.insert(idx, Captured { matrix, unscaled_font_size: unscaled, color, rect }); + captured.insert( + idx, + Captured { matrix, unscaled_font_size: unscaled, scaled_font_size: scaled, color, rect }, + ); } - // (3) Xoá mọi index thuộc SetText/ReplaceImage/Delete theo GIẢM DẦN. - let mut to_remove: Vec = ops - .iter() - .filter_map(|op| match op { + // (4) Xoá mọi index thuộc SetText-substitute/ReplaceImage/Delete GIẢM DẦN. + let mut to_remove: Vec = Vec::new(); + for (opi, op) in ops.iter().enumerate() { + match op { EditOp::SetText { index, .. } - | EditOp::ReplaceImage { index, .. } - | EditOp::Delete { index } => Some(*index), - _ => None, - }) - .filter(|i| valid(*i)) - .collect(); + if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) => + { + to_remove.push(*index) + } + EditOp::ReplaceImage { index, .. } | EditOp::Delete { index } => { + to_remove.push(*index) + } + _ => {} + } + } + to_remove.retain(|i| valid(*i)); to_remove.sort_unstable(); to_remove.dedup(); for idx in to_remove.into_iter().rev() { let removed = page.objects_mut().remove_object_at_index(idx as usize).map_err(err)?; // BẪY PDFium: object vừa tách khỏi trang bị đánh dấu "unowned" → Drop // gọi FPDFPageObj_Destroy, mà destroy object vốn thuộc document gây - // SEGFAULT (chính pdfium-render cũng cảnh báo điều này trong Drop của - // PdfPageObject). Object đã được FPDFPage_RemoveObject tách ra nên sẽ - // không còn render/lưu; ta `forget` để KHỎI gọi destroy. Rò rỉ không - // đáng kể (giải phóng khi document đóng ngay sau khi lưu). + // SEGFAULT. Object đã tách nên không còn render/lưu; `forget` để khỏi + // destroy (rò rỉ nhỏ, giải phóng khi đóng document). std::mem::forget(removed); } - // (4) Thêm bản thay thế cho SetText / ReplaceImage. - for op in ops { + // (5) Thêm bản thay thế cho SetText-substitute / ReplaceImage. + for (opi, op) in ops.iter().enumerate() { match op { - EditOp::SetText { index, text, font_size, color, bold, italic } => { + EditOp::SetText { index, text, font_size, color, .. } => { + let key = match set_text_modes.get(&opi) { + Some(SetTextMode::Substitute(key)) => key, + _ => continue, + }; let cap = match captured.get(index) { Some(c) => c, None => continue, }; - let token = tokens.get(&(*bold, *italic)).copied().ok_or_else(|| { + let token = tokens.get(key).copied().ok_or_else(|| { EngineError::Pdfium("thiếu token font cho SetText".into()) })?; - let size = font_size.unwrap_or(cap.unscaled_font_size).max(1.0); + // Quy đổi cỡ hiển thị mong muốn → cỡ unscaled trước khi áp + // matrix gốc (matrix có thể chứa scale — tránh phóng đại kép). + let tf = match font_size { + Some(target) if cap.scaled_font_size > 0.01 => { + cap.unscaled_font_size * (*target / cap.scaled_font_size) + } + Some(target) => *target, + None => cap.unscaled_font_size, + } + .max(1.0); let mut obj = page .objects_mut() - .create_text_object(PdfPoints::ZERO, PdfPoints::ZERO, text.clone(), token, PdfPoints::new(size)) + .create_text_object( + PdfPoints::ZERO, + PdfPoints::ZERO, + text.clone(), + token, + PdfPoints::new(tf), + ) .map_err(err)?; - // Object mới tạo tại gốc (0,0) cỡ unscaled → matrix ~identity, - // nên apply_matrix(matrix gốc) tương đương đặt lại đúng vị trí/scale. + // Object mới tạo tại gốc (0,0) → matrix ~identity, nên + // apply_matrix(matrix gốc) đặt lại đúng vị trí/scale. obj.apply_matrix(cap.matrix).map_err(err)?; let c = color .map(|c| PdfColor::new(c[0], c[1], c[2], c[3])) @@ -327,13 +589,15 @@ pub fn apply_edits( } } - // (5) Thêm object MỚI (AddText / AddImage). - for op in ops { + // (6) Thêm object MỚI (AddText / AddImage). + for (opi, op) in ops.iter().enumerate() { match op { - EditOp::AddText { x, y, text, font_size, color, bold, italic } => { - let token = tokens.get(&(*bold, *italic)).copied().ok_or_else(|| { - EngineError::Pdfium("thiếu token font cho AddText".into()) - })?; + EditOp::AddText { x, y, text, font_size, color, .. } => { + let token = add_text_keys + .get(&opi) + .and_then(|k| tokens.get(k)) + .copied() + .ok_or_else(|| EngineError::Pdfium("thiếu token font cho AddText".into()))?; let mut obj = page .objects_mut() .create_text_object( diff --git a/crates/ff-engine/src/fontmatch.rs b/crates/ff-engine/src/fontmatch.rs new file mode 100644 index 0000000..ca650fd --- /dev/null +++ b/crates/ff-engine/src/fontmatch.rs @@ -0,0 +1,233 @@ +//! Khớp & kiểm tra font cho chỉnh sửa text (Phase 4 iteration 2). +//! +//! Mục tiêu chuẩn Foxit: sửa text KHÔNG được đổi font. Chiến lược 3 tầng +//! (dùng ở `edit.rs`): +//! 1. Giữ nguyên font gốc của object nếu font mã hoá được text mới +//! (`FPDFText_SetText` dùng charmap của font hiện tại) — kiểm bằng +//! charset-subset hoặc cmap của font bytes (`coverage_ok`). +//! 2. Nếu font gốc thiếu glyph (subset) → tìm font HỆ THỐNG CÙNG HỌ +//! (`find_family_font_bytes`) đúng đậm/nghiêng — trông gần như y hệt. +//! 3. Bất đắc dĩ mới rơi về font mặc định (`annot::find_font_bytes`). + +/// Chuẩn hoá tên family để so khớp: chữ thường, chỉ giữ chữ+số. +pub(crate) fn normalize_key(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +/// Tách tên font PDF (PostScript name) thành (family sạch, đậm?, nghiêng?). +/// Ví dụ: "ABCDEF+TimesNewRomanPS-BoldItalicMT" → ("TimesNewRoman", true, true). +pub(crate) fn clean_font_name(raw: &str) -> (String, bool, bool) { + let mut name = raw.trim(); + // Bỏ prefix subset "ABCDEF+". + let bytes = name.as_bytes(); + if bytes.len() > 7 && bytes[6] == b'+' && bytes[..6].iter().all(|b| b.is_ascii_uppercase()) { + name = &name[7..]; + } + let lower = name.to_ascii_lowercase(); + let bold = ["bold", "black", "heavy", "semibold", "demibold"] + .iter() + .any(|k| lower.contains(k)); + let italic = lower.contains("italic") || lower.contains("oblique"); + + // Family = phần trước dấu '-' hoặc ',' đầu tiên (quy ước PostScript). + let mut family = name.split(['-', ',']).next().unwrap_or(name).to_string(); + // Bỏ từ khoá kiểu dính liền cuối family ("ArialBold", "CalibriItalic"...). + let lower_fam = family.to_ascii_lowercase(); + for suffix in ["bolditalic", "boldoblique", "bold", "italic", "oblique", "regular"] { + if lower_fam.ends_with(suffix) && lower_fam.len() > suffix.len() { + family.truncate(family.len() - suffix.len()); + break; + } + } + // Bỏ hậu tố PostScript vô nghĩa với người dùng. + for suffix in ["PSMT", "PS", "MT"] { + if family.ends_with(suffix) && family.len() > suffix.len() { + family.truncate(family.len() - suffix.len()); + } + } + (family.trim().to_string(), bold, italic) +} + +/// Font bytes (TTF/OTF/TTC) có glyph cho MỌI ký tự của `text` không? +/// Parse hỏng (Type1 thuần...) → false (caller sẽ thử đường khác). +pub(crate) fn coverage_ok(font_bytes: &[u8], text: &str) -> bool { + let n = ttf_parser::fonts_in_collection(font_bytes).unwrap_or(1).max(1); + 'face: for i in 0..n { + let face = match ttf_parser::Face::parse(font_bytes, i) { + Ok(f) => f, + Err(_) => continue, + }; + for ch in text.chars() { + if ch.is_control() { + continue; + } + if face.glyph_index(ch).is_none() { + continue 'face; + } + } + return true; + } + false +} + +/// Tìm font HỆ THỐNG cùng family (đúng đậm/nghiêng) — trả về bytes TTF. +/// Family so khớp mềm theo `normalize_key` (chứa nhau, tối thiểu 4 ký tự). +pub(crate) fn find_family_font_bytes(family: &str, bold: bool, italic: bool) -> Option> { + let mut key = normalize_key(family); + if key.is_empty() { + return None; + } + // Alias các tên chuẩn PDF (base-14) → family hệ thống tương đương. + if key == "helvetica" || key == "helveticaneue" { + key = "arial".into(); + } else if key == "times" || key == "timesroman" { + key = "timesnewroman".into(); + } else if key == "courier" { + key = "couriernew".into(); + } + let style_idx = match (bold, italic) { + (false, false) => 0, + (true, false) => 1, + (false, true) => 2, + (true, true) => 3, + }; + + #[cfg(windows)] + { + // (key, [regular, bold, italic, bold-italic]) trong C:\Windows\Fonts. + const TABLE: &[(&str, [&str; 4])] = &[ + ("timesnewroman", ["times.ttf", "timesbd.ttf", "timesi.ttf", "timesbi.ttf"]), + ("arial", ["arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"]), + ("calibri", ["calibri.ttf", "calibrib.ttf", "calibrii.ttf", "calibriz.ttf"]), + ("cambria", ["cambria.ttc", "cambriab.ttf", "cambriai.ttf", "cambriaz.ttf"]), + ("candara", ["candara.ttf", "candarab.ttf", "candarai.ttf", "candaraz.ttf"]), + ("georgia", ["georgia.ttf", "georgiab.ttf", "georgiai.ttf", "georgiaz.ttf"]), + ("verdana", ["verdana.ttf", "verdanab.ttf", "verdanai.ttf", "verdanaz.ttf"]), + ("tahoma", ["tahoma.ttf", "tahomabd.ttf", "tahoma.ttf", "tahomabd.ttf"]), + ("trebuchetms", ["trebuc.ttf", "trebucbd.ttf", "trebucit.ttf", "trebucbi.ttf"]), + ("couriernew", ["cour.ttf", "courbd.ttf", "couri.ttf", "courbi.ttf"]), + ("segoeui", ["segoeui.ttf", "segoeuib.ttf", "segoeuii.ttf", "segoeuiz.ttf"]), + ("consolas", ["consola.ttf", "consolab.ttf", "consolai.ttf", "consolaz.ttf"]), + ("comicsansms", ["comic.ttf", "comicbd.ttf", "comici.ttf", "comicz.ttf"]), + ("impact", ["impact.ttf", "impact.ttf", "impact.ttf", "impact.ttf"]), + ("garamond", ["gara.ttf", "garabd.ttf", "garait.ttf", "garabd.ttf"]), + ("palatinolinotype", ["pala.ttf", "palab.ttf", "palai.ttf", "palabi.ttf"]), + ]; + for (k, files) in TABLE { + if keys_match(&key, k) { + if let Ok(bytes) = std::fs::read(format!(r"C:\Windows\Fonts\{}", files[style_idx])) { + return Some(bytes); + } + } + } + None + } + + #[cfg(target_os = "macos")] + { + const TABLE: &[(&str, &str)] = &[ + ("timesnewroman", "Times New Roman"), + ("arial", "Arial"), + ("georgia", "Georgia"), + ("verdana", "Verdana"), + ("tahoma", "Tahoma"), + ("trebuchetms", "Trebuchet MS"), + ("couriernew", "Courier New"), + ("comicsansms", "Comic Sans MS"), + ("impact", "Impact"), + ]; + let style = ["", " Bold", " Italic", " Bold Italic"][style_idx]; + for (k, display) in TABLE { + if keys_match(&key, k) { + for dir in ["/System/Library/Fonts/Supplemental", "/Library/Fonts"] { + if let Ok(bytes) = std::fs::read(format!("{dir}/{display}{style}.ttf")) { + return Some(bytes); + } + } + } + } + None + } + + #[cfg(not(any(windows, target_os = "macos")))] + { + // 1) Liberation: tương thích metric với Arial/Times/Courier — ưu tiên. + let lib = match key.as_str() { + "arial" => Some("LiberationSans"), + "timesnewroman" => Some("LiberationSerif"), + "couriernew" => Some("LiberationMono"), + _ => None, + }; + if let Some(base) = lib { + let style = ["Regular", "Bold", "Italic", "BoldItalic"][style_idx]; + for dir in [ + "/usr/share/fonts/truetype/liberation", + "/usr/share/fonts/liberation", + "/usr/share/fonts/TTF", + ] { + if let Ok(bytes) = std::fs::read(format!("{dir}/{base}-{style}.ttf")) { + return Some(bytes); + } + } + } + // 2) fontconfig: fc-match trả family+file; chỉ nhận khi family khớp thật + // (fc-match luôn trả "gần nhất" kể cả khi không có font đó). + let style = ["Regular", "Bold", "Italic", "Bold Italic"][style_idx]; + let pattern = format!("{family}:style={style}"); + if let Ok(out) = std::process::Command::new("fc-match") + .args(["-f", "%{family}\t%{file}", &pattern]) + .output() + { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout); + if let Some((fam, file)) = s.trim().split_once('\t') { + if keys_match(&key, &normalize_key(fam)) { + if let Ok(bytes) = std::fs::read(file) { + return Some(bytes); + } + } + } + } + } + None + } +} + +/// So khớp mềm 2 key đã chuẩn hoá: bằng nhau, hoặc chứa nhau (đủ dài để tránh nhiễu). +fn keys_match(a: &str, b: &str) -> bool { + if a == b { + return true; + } + let (short, long) = if a.len() <= b.len() { (a, b) } else { (b, a) }; + short.len() >= 4 && long.contains(short) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clean_subset_postscript_name() { + let (fam, bold, italic) = clean_font_name("ABCDEF+TimesNewRomanPS-BoldItalicMT"); + assert_eq!(fam, "TimesNewRoman"); + assert!(bold); + assert!(italic); + } + + #[test] + fn clean_simple_names() { + assert_eq!(clean_font_name("Helvetica"), ("Helvetica".into(), false, false)); + assert_eq!(clean_font_name("Arial-Bold"), ("Arial".into(), true, false)); + assert_eq!(clean_font_name("CalibriItalic"), ("Calibri".into(), false, true)); + } + + #[test] + fn keys_match_soft() { + assert!(keys_match("timesnewroman", "timesnewromanps")); + assert!(!keys_match("arial", "georgia")); + assert!(keys_match("arial", "arial")); + } +} diff --git a/crates/ff-engine/src/lib.rs b/crates/ff-engine/src/lib.rs index 13ea11b..4755d07 100644 --- a/crates/ff-engine/src/lib.rs +++ b/crates/ff-engine/src/lib.rs @@ -6,6 +6,7 @@ pub mod annot; pub mod edit; +pub(crate) mod fontmatch; pub mod meta; pub mod organize; pub mod qpdf; diff --git a/crates/ff-engine/tests/edit_roundtrip.rs b/crates/ff-engine/tests/edit_roundtrip.rs index 75d318a..d50a0fa 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -70,8 +70,9 @@ fn set_text_replaces_run_content() { text: "Edited line ABC".into(), font_size: None, color: None, - bold: false, - italic: false, + font_family: None, + bold: None, + italic: None, }], &out, None, @@ -100,8 +101,9 @@ fn set_text_vietnamese_round_trips() { text: vi.into(), font_size: None, color: None, - bold: false, - italic: false, + font_family: None, + bold: None, + italic: None, }], &out, None, @@ -168,6 +170,7 @@ fn add_text_appears_in_extract() { text: "ADDEDXYZ".into(), font_size: 14.0, color: [10, 20, 30, 255], + font_family: None, bold: false, italic: false, }], @@ -180,6 +183,398 @@ fn add_text_appears_in_extract() { assert!(text.contains("ADDEDXYZ"), "text thêm mới phải xuất hiện: {text:?}"); } +/// CHUẨN FOXIT (iteration 2): sửa text ASCII trên font base-14 (Helvetica, +/// không nhúng) phải GIỮ NGUYÊN BaseFont — không đổi font. Text mới cố ý dùng +/// ký tự ngoài text cũ để không "ăn may" qua luật charset-subset. +#[test] +fn set_text_keeps_original_font() { + let pdf = pdfium(); + let input = sample(); + let out = tmp("ff_edit_keepfont.pdf"); + let idx = find_text_index(&pdf, &input, "Page one"); + let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list"); + let font_before = before + .iter() + .find(|o| o.index == idx) + .and_then(|o| o.font_name.clone()) + .expect("font gốc"); + + ff_engine::apply_edits( + &pdf, + &input, + 0, + &[EditOp::SetText { + index: idx, + text: "Fixed by editor 2026 JQXZ!".into(), + font_size: None, + color: None, + font_family: None, + bold: None, + italic: None, + }], + &out, + None, + ) + .expect("apply_edits SetText"); + + let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out"); + let edited = after + .iter() + .find(|o| o.text.as_deref().map(|t| t.contains("JQXZ")).unwrap_or(false)) + .expect("run đã sửa"); + assert_eq!( + edited.font_name.as_deref(), + Some(font_before.as_str()), + "sửa text ASCII phải GIỮ NGUYÊN font (chuẩn Foxit)" + ); + let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract"); + assert!(text.contains("Fixed by editor"), "text mới phải có mặt: {text:?}"); +} + +/// Tiếng Việt trên font base-14 (KHÔNG có glyph Việt ở bất kỳ đâu để giữ): +/// phải thay bằng font hệ thống CÙNG HỌ metric-compatible (Helvetica→Arial/ +/// LiberationSans), tuyệt đối không rơi bừa về font generic. +#[test] +fn vietnamese_on_base14_uses_matched_family() { + let pdf = pdfium(); + let input = sample(); + let out = tmp("ff_edit_vnmatch.pdf"); + let idx = find_text_index(&pdf, &input, "Page one"); + + ff_engine::apply_edits( + &pdf, + &input, + 0, + &[EditOp::SetText { + index: idx, + text: "Thay thế hoàn chỉnh".into(), + font_size: None, + color: None, + font_family: None, + bold: None, + italic: None, + }], + &out, + None, + ) + .expect("apply_edits VI"); + + let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out"); + let edited = after + .iter() + .find(|o| o.text.as_deref().map(|t| t.contains("hoàn chỉnh")).unwrap_or(false)) + .expect("run đã sửa"); + let font = edited.font_name.clone().unwrap_or_default(); + #[cfg(not(any(windows, target_os = "macos")))] + assert!( + font.contains("Liberation"), + "Helvetica+VI phải match LiberationSans (metric-compatible), được {font:?}" + ); + #[cfg(windows)] + assert!(font.to_lowercase().contains("arial"), "Helvetica+VI phải match Arial, được {font:?}"); + let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract"); + assert!(text.contains("hoàn chỉnh"), "tiếng Việt đúng dấu: {text:?}"); +} + +/// Case quan trọng nhất với tài liệu Việt thực tế: font NHÚNG đầy đủ glyph → +/// sửa sang nội dung Việt hoàn toàn khác phải GIỮ NGUYÊN font nhúng (sửa tại +/// chỗ, không tạo lại bằng font khác). +#[test] +fn set_text_preserves_embedded_font_vietnamese() { + let pdf = pdfium(); + let input = sample(); + let step1 = tmp("ff_edit_emb1.pdf"); + let step2 = tmp("ff_edit_emb2.pdf"); + + // B1: tạo run với font nhúng FULL (AddText nhúng nguyên font hệ thống). + ff_engine::apply_edits( + &pdf, + &input, + 0, + &[EditOp::AddText { + x: 60.0, + y: 300.0, + text: "Chào FoFreeXit".into(), + font_size: 18.0, + color: [0, 0, 0, 255], + font_family: None, + bold: false, + italic: false, + }], + &step1, + None, + ) + .expect("add embedded run"); + let idx = find_text_index(&pdf, &step1, "Chào"); + let mid = ff_engine::list_objects(&pdf, &step1, 0, None).expect("list mid"); + let run = mid.iter().find(|o| o.index == idx).expect("run"); + assert_eq!(run.font_embedded, Some(true), "fixture phải là font nhúng"); + let font_before = run.font_name.clone().expect("tên font nhúng"); + + // B2: sửa sang câu Việt khác hẳn → phải giữ nguyên font nhúng. + ff_engine::apply_edits( + &pdf, + &step1, + 0, + &[EditOp::SetText { + index: idx, + text: "Đã kiểm định — sửa giữ font nhúng".into(), + font_size: None, + color: None, + font_family: None, + bold: None, + italic: None, + }], + &step2, + None, + ) + .expect("edit embedded run"); + + let after = ff_engine::list_objects(&pdf, &step2, 0, None).expect("list out"); + let edited = after + .iter() + .find(|o| o.text.as_deref().map(|t| t.contains("kiểm định")).unwrap_or(false)) + .expect("run đã sửa"); + assert_eq!( + edited.font_name.as_deref(), + Some(font_before.as_str()), + "font NHÚNG phải được giữ nguyên khi sửa tiếng Việt" + ); + let text = ff_engine::extract_text(&pdf, &step2, 0, None).expect("extract"); + assert!(text.contains("sửa giữ font nhúng"), "text round-trip: {text:?}"); +} + +/// Đổi CỠ CHỮ (không đổi nội dung) phải giữ nguyên font + text, cỡ mới đúng +/// theo nghĩa hiển thị và vị trí neo (left) không trôi. +#[test] +fn font_size_change_keeps_font_and_anchors() { + let pdf = pdfium(); + let input = sample(); + let out = tmp("ff_edit_size.pdf"); + let idx = find_text_index(&pdf, &input, "Page one"); + let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list"); + let orig = before.iter().find(|o| o.index == idx).expect("obj").clone(); + + ff_engine::apply_edits( + &pdf, + &input, + 0, + &[EditOp::SetText { + index: idx, + text: orig.text.clone().unwrap_or_default(), + font_size: Some(30.0), + color: None, + font_family: None, + bold: None, + italic: None, + }], + &out, + None, + ) + .expect("apply_edits size"); + + let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out"); + let got = after.iter().find(|o| o.index == idx).expect("obj out"); + assert_eq!(got.font_name, orig.font_name, "đổi cỡ không được đổi font"); + assert_eq!(got.text, orig.text, "đổi cỡ không được đổi nội dung"); + let sz = got.font_size.expect("size"); + assert!((sz - 30.0).abs() < 0.5, "cỡ hiển thị phải ≈30, được {sz}"); + assert!( + (got.rect.left - orig.rect.left).abs() < 2.0, + "điểm neo trái không được trôi: {} -> {}", + orig.rect.left, + got.rect.left + ); +} + +/// Hồi quy bug phóng đại kép: text có matrix scale (Tf nhỏ × matrix lớn) — +/// đặt cỡ hiển thị 20pt phải ra đúng ~20pt, không nhân đôi theo matrix. +#[test] +fn font_size_change_respects_matrix_scale() { + let pdf = pdfium(); + let input = sample(); + let step1 = tmp("ff_edit_mtx1.pdf"); + let step2 = tmp("ff_edit_mtx2.pdf"); + let step3 = tmp("ff_edit_mtx3.pdf"); + + // Tạo run 16pt rồi scale ×2 qua Transform → cỡ hiển thị 32pt, matrix scale 2. + ff_engine::apply_edits( + &pdf, + &input, + 0, + &[EditOp::AddText { + x: 80.0, + y: 200.0, + text: "MATRIXCASE".into(), + font_size: 16.0, + color: [0, 0, 0, 255], + font_family: None, + bold: false, + italic: false, + }], + &step1, + None, + ) + .expect("add"); + let idx = find_text_index(&pdf, &step1, "MATRIXCASE"); + ff_engine::apply_edits( + &pdf, + &step1, + 0, + &[EditOp::Transform { index: idx, dx: 0.0, dy: 0.0, sx: 2.0, sy: 2.0 }], + &step2, + None, + ) + .expect("scale"); + let mid = ff_engine::list_objects(&pdf, &step2, 0, None).expect("list mid"); + let scaled = mid.iter().find(|o| o.index == idx).and_then(|o| o.font_size).expect("size mid"); + assert!((scaled - 32.0).abs() < 1.0, "sau scale ×2 phải ~32pt, được {scaled}"); + + // Đặt cỡ hiển thị 20 — phải ra ~20 (bug cũ: tạo Tf=20 rồi nhân matrix 2 → 40). + ff_engine::apply_edits( + &pdf, + &step2, + 0, + &[EditOp::SetText { + index: idx, + text: "MATRIXCASE".into(), + font_size: Some(20.0), + color: None, + font_family: None, + bold: None, + italic: None, + }], + &step3, + None, + ) + .expect("resize"); + let fin = ff_engine::list_objects(&pdf, &step3, 0, None).expect("list fin"); + let got = fin.iter().find(|o| o.index == idx).and_then(|o| o.font_size).expect("size fin"); + assert!((got - 20.0).abs() < 0.5, "cỡ hiển thị phải ≈20, được {got}"); +} + +/// Ép ĐẬM qua override: font phải đổi sang biến thể đậm (khác font gốc) nhưng +/// nội dung (kể cả tiếng Việt) round-trip nguyên vẹn. +#[test] +fn bold_override_substitutes_font_and_keeps_text() { + let pdf = pdfium(); + let input = sample(); + let out = tmp("ff_edit_bold.pdf"); + let idx = find_text_index(&pdf, &input, "Page one"); + let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list"); + let font_before = before + .iter() + .find(|o| o.index == idx) + .and_then(|o| o.font_name.clone()) + .expect("font gốc"); + + ff_engine::apply_edits( + &pdf, + &input, + 0, + &[EditOp::SetText { + index: idx, + text: "Chữ đậm kiểm thử".into(), + font_size: None, + color: None, + font_family: None, + bold: Some(true), + italic: None, + }], + &out, + None, + ) + .expect("apply_edits bold"); + + let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out"); + let edited = after + .iter() + .find(|o| o.text.as_deref().map(|t| t.contains("đậm")).unwrap_or(false)) + .expect("run đã sửa"); + assert_ne!( + edited.font_name.as_deref(), + Some(font_before.as_str()), + "ép đậm phải chuyển sang biến thể font khác" + ); + let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract"); + assert!(text.contains("Chữ đậm kiểm thử"), "text round-trip: {text:?}"); +} + +/// Luồng UI "sửa cả dòng": 1 batch = SetText(run đầu, text gộp) + Delete(các +/// run còn lại). Chốt hành vi: text mới có mặt, các run kia biến mất, tổng +/// object giảm đúng số run bị gộp. +#[test] +fn line_merge_batch_set_text_plus_delete() { + let pdf = pdfium(); + let input = sample(); + let step1 = tmp("ff_edit_line1.pdf"); + let step2 = tmp("ff_edit_line2.pdf"); + + // Dựng "1 dòng bị cắt làm 2 run" bằng 2 AddText cạnh nhau. + ff_engine::apply_edits( + &pdf, + &input, + 0, + &[ + EditOp::AddText { + x: 60.0, + y: 260.0, + text: "Nửa đầu".into(), + font_size: 14.0, + color: [0, 0, 0, 255], + font_family: None, + bold: false, + italic: false, + }, + EditOp::AddText { + x: 130.0, + y: 260.0, + text: "nửa sau".into(), + font_size: 14.0, + color: [0, 0, 0, 255], + font_family: None, + bold: false, + italic: false, + }, + ], + &step1, + None, + ) + .expect("dựng 2 run"); + + let objs = ff_engine::list_objects(&pdf, &step1, 0, None).expect("list"); + let first = find_text_index(&pdf, &step1, "Nửa đầu"); + let second = find_text_index(&pdf, &step1, "nửa sau"); + let count_before = objs.len(); + + ff_engine::apply_edits( + &pdf, + &step1, + 0, + &[ + EditOp::SetText { + index: first, + text: "Cả dòng đã gộp và sửa".into(), + font_size: None, + color: None, + font_family: None, + bold: None, + italic: None, + }, + EditOp::Delete { index: second }, + ], + &step2, + None, + ) + .expect("batch gộp dòng"); + + let after = ff_engine::list_objects(&pdf, &step2, 0, None).expect("list out"); + assert_eq!(after.len(), count_before - 1, "gộp 2 run còn 1 → tổng giảm 1"); + let text = ff_engine::extract_text(&pdf, &step2, 0, None).expect("extract"); + assert!(text.contains("Cả dòng đã gộp và sửa"), "text gộp phải có: {text:?}"); + assert!(!text.contains("nửa sau"), "run bị gộp phải biến mất: {text:?}"); +} + #[test] fn add_image_adds_image_object() { let pdf = pdfium(); diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index a7e1e86..2bb215f 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -1,6 +1,76 @@ -# 12 — Tổng kết Phase 4 (Chỉnh sửa nội dung — Edit) · Iteration 1 +# 12 — Tổng kết Phase 4 (Chỉnh sửa nội dung — Edit) · Iteration 1 + 2 -> Trạng thái: **lõi MOAT hoạt động**. 43/43 test engine xanh (36 cũ + 7 mới cho edit). Đã chạy app thật, chụp ảnh xác minh: sửa text tiếng Việt WYSIWYG, thêm chữ, xoá đối tượng, undo/redo. +> Trạng thái sau Iteration 2: **sửa text GIỮ NGUYÊN FONT (chuẩn Foxit)**. +> 50/50 test engine xanh ngoài qpdf (14 test edit + 3 unit fontmatch + 33 test cũ). +> Iteration 1: 43/43, đã chạy app thật + chụp ảnh xác minh. + +## Iteration 2 — Giữ font gốc + trải nghiệm Foxit (mới) + +Đóng khoảng cách lớn nhất so với Foxit được chỉ ra khi review: **mọi lần sửa +text đều bị đổi sang font hệ thống** (Helvetica → Arial/DejaVu), kể cả khi chỉ +đổi cỡ/màu; bold/italic gốc bị mất; sửa theo mảnh run thay vì cả dòng. + +### Engine (`edit.rs` viết lại + `fontmatch.rs` mới) +- **`SetText` 3 tầng, ưu tiên GIỮ FONT GỐC** (sửa tại chỗ bằng `FPDFText_SetText`, + không xoá/tạo lại): + 1. *In-place an toàn chắc chắn*: text mới chỉ dùng ký tự đã có trong run; hoặc + font **không nhúng** (base-14 Helvetica/Times…) + text mới toàn ASCII + (BaseFont khai báo giữ nguyên trong file — đúng hành vi Foxit); hoặc cmap + của font (đọc qua `FPDFFont_GetFontData` + `ttf-parser`) phủ đủ ký tự mới + → **font giữ nguyên 100%**, gồm cả font NHÚNG với tiếng Việt. + 2. *Thiếu glyph thật sự* → thay bằng font hệ thống **CÙNG HỌ, đúng đậm/nghiêng** + (`fontmatch::find_family_font_bytes`: bảng family Windows/macOS, Liberation + metric-compatible + `fc-match` trên Linux; alias Helvetica→Arial, + Times→Times New Roman…), có kiểm coverage trước khi nhận. + 3. Bất đắc dĩ mới rơi về font mặc định (`find_font_bytes` — nay có đủ biến thể + đậm/nghiêng trên cả Linux). +- **Đổi cỡ chữ tại chỗ không đụng font**: nhân matrix `[k,0,0,k, e(1−k), f(1−k)]` + (neo baseline như Foxit). **Fix bug phóng đại kép**: cỡ chữ nghĩa "hiển thị" + (đã nhân matrix scale) — trước đây đặt cỡ 20 lên text có matrix scale ×2 ra 40pt. +- `ObjectInfo` thêm `font_family` (đã làm sạch tên PostScript/subset), + `font_bold`, `font_italic`, `font_embedded` — nguồn từ `name()` (BaseFont) vì + `family()` của font không nhúng trả tên stub nội bộ PDFium ("Chrom Sans OTF"). +- `SetText`/`AddText` nhận `font_family`/`bold`/`italic` dạng Option — **None = + giữ nguyên**; Some = chủ động đổi (đổi font qua tầng 2). +- Bẫy mới ghi nhận: `set_matrix` của pdfium-render 0.8.37 là alias deprecated + của `apply_matrix` (NHÂN DỒN, không thay thế) — phải tự dựng matrix delta. + +### UI (`main.js` + toolbar) +- **Sửa CẢ DÒNG như Foxit**: double-click gom các run cùng baseline liền kề + (PDF hay cắt 1 dòng thành nhiều run) → 1 ô sửa cho cả dòng; commit = SetText + run đầu + Delete các run còn lại (1 batch, 1 nấc undo). +- **WYSIWYG khi gõ**: ô sửa dùng đúng family (CSS xấp xỉ theo `font_family`), + cỡ, màu, đậm/nghiêng của run gốc; nền trắng che text cũ. +- **Giữ nguyên mặc định**: mọi commit sửa text gửi `null` cho font/cỡ/màu/kiểu + — chỉ field người dùng chủ động đổi mới được gửi. +- **Toolbar Format kiểu Foxit**: dropdown Font (mặc định "(giữ nguyên: X)"), + nút **B**/**I** toggle theo kiểu thật của run (đổi biến thể cùng họ), Cỡ chữ, + Màu — đổi thuộc tính KHÔNG đổi font. +- **Kéo-thả live**: khung đi theo con trỏ ngay khi kéo (move + resize), thả + chuột mới commit; resize neo góc trên-trái đúng như preview. +- **Dọn file tạm**: mọi file `ff_edit_*.pdf` của phiên sửa được xoá khi thoát + chế độ/lưu (command `edit_cleanup` — chỉ xoá đúng pattern trong %TEMP%). +- Hint hiển thị font đang chọn: `Tên font · cỡ pt · font nhúng/hệ thống`. + +### Test mới (7): `edit_roundtrip.rs` 14 test + `fontmatch` 3 unit +- `set_text_keeps_original_font` — sửa ASCII trên Helvetica base-14 GIỮ NGUYÊN + "Helvetica" (ép ký tự ngoài charset cũ để không ăn may). +- `set_text_preserves_embedded_font_vietnamese` — **font NHÚNG + tiếng Việt + giữ nguyên font** (case quan trọng nhất với tài liệu Việt). +- `vietnamese_on_base14_uses_matched_family` — tiếng Việt trên base-14 (không + có glyph Việt để giữ) match đúng họ metric-compatible (Liberation/Arial), + không rơi bừa về generic. +- `font_size_change_keeps_font_and_anchors`; `font_size_change_respects_matrix_scale` + (hồi quy bug phóng đại kép); `bold_override_substitutes_font_and_keeps_text`; + `line_merge_batch_set_text_plus_delete` (luồng UI gộp dòng). + +### Đối chiếu FINAL TARGET & RULE +- Vướng thư viện (PDFium `set_text` re-encode theo subset) → **không cắt giảm**: + kiểm tra coverage bằng chính font bytes + luật charset/ASCII để tận dụng + `set_text` an toàn, chỉ thay font khi về mặt vật lý không còn glyph để giữ — + và khi thay thì match cùng họ như Foxit. Đúng thứ tự a→c của luật. + +## Iteration 1 (giữ nguyên bên dưới để tham chiếu) Phase 4 là tính năng lõi/moat theo `docs/03-roadmap.md`: sửa text & object trực tiếp trên trang — lý do quan trọng nhất để bỏ Foxit. @@ -33,8 +103,11 @@ list_objects thấy text trang 1; SetText đổi nội dung (text cũ biến m Bật chế độ Sửa nội dung (overlay 2 đối tượng); double-click dòng 2 → sửa thành "Sửa: nội dung Tiếng Việt" → commit → trang render lại ĐÚNG (dấu chuẩn); Thêm chữ "Dòng chữ MỚI thêm" tại vị trí click; Hoàn tác bỏ chữ vừa thêm (giữ phần sửa); Xoá tiêu đề → còn 1 đối tượng. Ảnh: `tmp-out/phase4-*.png`. ## 5. Còn lại / follow-up (ghi nhận, không phải thiếu sót — Iteration sau) -- **Reflow đoạn nhiều dòng "như Word"** (hiện sửa ở mức text run/dòng — mỗi PdfPageTextObject; đây là điểm khác chính so với Foxit, cần gom run theo baseline + reflow). -- Xoay/lật/shear/clip object; tab Format nâng cao (viền, opacity, căn lề, đổi font-family object cũ); z-order arrange; convert text→path. +- **Reflow đoạn nhiều dòng "như Word"** — khoảng cách chính còn lại so với Foxit + (Iteration 2 đã sửa được CẢ DÒNG; bước tiếp: gom block nhiều dòng theo khoảng + cách baseline + đo width bằng hmtx của font (ttf-parser đã có) + tự bẻ dòng). + Xem kế hoạch ở `docs/14-foxit-gap-analysis.md`. +- Xoay/lật/shear/clip object; tab Format nâng cao (viền, opacity, căn lề); z-order arrange; convert text→path. (Đổi font-family/B/I cho object cũ: ĐÃ XONG ở Iteration 2.) - Đặt ảnh theo đúng tỉ lệ gốc (hiện mặc định 150×112pt, resize bằng kéo handle); preview ảnh trước khi đặt. - Sửa nhiều trang trong 1 phiên lưu (hiện lưu theo trang đang sửa); spell-check; sửa bảng; link text blocks. - Mã hoá UI (nợ từ Phase 3). diff --git a/docs/13-phase4-user-tests.md b/docs/13-phase4-user-tests.md index 6df3bb9..692caa5 100644 --- a/docs/13-phase4-user-tests.md +++ b/docs/13-phase4-user-tests.md @@ -28,6 +28,19 @@ cargo run | E15 | **Lưu thay đổi nội dung** → chọn nơi lưu | Tạo file PDF mới; app mở lại file đó | | | E16 | Mở file đã lưu ở **Foxit/Adobe/Chrome** | Nội dung đã sửa hiển thị đúng (text, ảnh, **tiếng Việt đúng dấu**), file không hỏng | | +## Bổ sung Iteration 2 — GIỮ FONT + sửa cả dòng (chuẩn Foxit) +| # | Thao tác | Kỳ vọng | KQ | +|---|----------|---------|----| +| E17 | Bấm đúp 1 dòng text bị cắt thành nhiều mảnh (PDF xuất từ Word thường vậy) | Ô sửa phủ **CẢ DÒNG** (không chỉ 1 mảnh), nội dung ghép đủ | | +| E18 | Sửa 1 từ trong dòng, Enter | **Font giữ nguyên y hệt** phần còn lại của trang (so bằng mắt + panel chọn hiện tên font cũ) | | +| E19 | Trong ô sửa đang gõ | Chữ trong ô hiển thị **đúng font/cỡ/màu/đậm-nghiêng** của dòng (WYSIWYG khi gõ, không phải font hệ thống chung chung) | | +| E20 | Chọn text → chỉ đổi **Cỡ chữ** hoặc **Màu** | Font KHÔNG đổi, nội dung không đổi, vị trí neo không trôi | | +| E21 | Chọn text → bấm **B** (Đậm) | Chữ thành đậm **cùng họ font** (Times → Times Bold, không nhảy sang Arial) | | +| E22 | Chọn text → đổi **Font** trong dropdown | Chữ đổi đúng family chọn; dropdown mặc định hiển thị "(giữ nguyên: )" | | +| E23 | Kéo di chuyển / kéo handle resize | Khung **đi theo con trỏ NGAY khi kéo** (live), thả chuột mới ghi nhận | | +| E24 | Sửa text tiếng Việt trên file có font nhúng (file Word xuất PDF) | Đúng dấu **và giữ nguyên font nhúng** (mở lại ở Foxit xem tên font trong Edit) | | +| E25 | Sửa nhiều lần rồi thoát chế độ sửa (không lưu) | Không còn file `ff_edit_*.pdf` rác trong thư mục %TEMP% | | + ## Mẫu phản hồi ``` [Mã] (vd E16) — Phần mềm mở: — Hiện tượng: — diff --git a/docs/14-foxit-gap-analysis.md b/docs/14-foxit-gap-analysis.md new file mode 100644 index 0000000..fbdff20 --- /dev/null +++ b/docs/14-foxit-gap-analysis.md @@ -0,0 +1,151 @@ +# 14 — Phân tích khoảng cách so với Foxit: định hướng, công nghệ, tiến độ + +> Trả lời câu hỏi chiến lược: *"Với tham vọng thay thế Foxit, đạt chất lượng +> tương đương hoặc hơn về trải nghiệm và tính năng, thì định hướng, công nghệ +> và tiến độ hiện tại có đáp ứng được không? Có vấn đề gì khiến các tính năng +> không đáp ứng được như Foxit không?"* +> +> Cập nhật lần đầu: sau Phase 4 Iteration 2 (giữ font khi sửa text). + +## 1. Kết luận ngắn + +**Định hướng và công nghệ: ĐÁP ỨNG ĐƯỢC, không có rào cản chết người.** +Stack Rust + Tauri + PDFium + QPDF đủ trần năng lực để đạt (và ở vài điểm vượt) +Foxit cho toàn bộ lộ trình Phase 1–8. Lý do tin được điều đó: + +- **PDFium là engine của Chrome** — render/parse text/font ngang hàng engine + thương mại; Foxit bán chính engine của họ cho Google làm gốc PDFium. Ta đứng + trên cùng một nền engine với đối thủ. +- Những chỗ PDFium *không* expose đủ (ví dụ annotation appearance, /W width + của font Type0, coverage cmap khi sửa text) đều đã xử lý được bằng cách + **tự thao tác tầng PDF object qua `lopdf`/`ttf-parser`** (annot.rs, + fontmatch.rs) — đúng thứ tự leo thang của FINAL TARGET & RULE, và đã chứng + minh trong code chứ không phải giả định. +- Các bài toán "khó nhất" của một PDF editor (sửa text giữ font, tổ chức trang + an toàn, file hỏng/mã hoá) đã có lời giải chạy được + test. + +**Tiến độ: đúng hướng nhưng mới đi ~40% quãng đường tính năng.** Phase 1–4 +(viewer, annotate, organize, edit lõi) là "core thay Foxit cho cá nhân" và đã +xong ở mức lõi. Phần "đáng tiền doanh nghiệp" (bảo mật/chữ ký số, form, OCR, +convert) chưa bắt đầu — đó là các phase 5–7 theo roadmap, không phải rủi ro +công nghệ mà là khối lượng việc. + +## 2. Những vấn đề TỪNG khiến tính năng không đạt chuẩn Foxit — và cách đã giải + +| Vấn đề | Hậu quả với người dùng | Giải pháp đã áp dụng (đã có test) | +|---|---|---| +| PDFium `set_text` re-encode theo font subset → mất glyph ngoài subset | Sửa text là **đổi font toàn bộ run** (Times → Arial), lệch hẳn tài liệu; chỉ đổi cỡ/màu cũng đổi font; mất đậm/nghiêng | **Iteration 2**: quyết định 3 tầng — (1) sửa TẠI CHỖ giữ nguyên font khi chắc chắn an toàn (charset-subset / base-14 + ASCII / cmap của font bytes phủ đủ); (2) thiếu glyph thật → thay font **cùng họ** metric-compatible (bảng family + fc-match + alias base-14); (3) mới đến font mặc định. Đổi cỡ = scale matrix, không đụng font | +| Cỡ chữ hiển thị vs Tf size lẫn lộn khi matrix có scale | Đặt cỡ 20 trên text scale ×2 → chữ 40pt (phóng đại kép) | Quy ước cỡ = "hiển thị"; in-place scale matrix neo baseline; tạo lại thì quy đổi ngược Tf trước khi áp matrix. Test hồi quy riêng | +| PDF cắt 1 dòng nhìn thấy thành nhiều text run | Double-click chỉ sửa được 1 mảnh chữ — trải nghiệm vụn | UI gom run cùng baseline liền kề → sửa **cả dòng**; commit 1 batch (SetText + Delete) = 1 nấc undo | +| Ô sửa inline dùng font mặc định của WebView | Đang gõ không giống kết quả (không WYSIWYG) | Ô sửa nhận đúng family/cỡ/màu/kiểu của run (CSS xấp xỉ theo `font_family` engine trả về) | +| Kéo-thả không có phản hồi trực tiếp | Cảm giác "lag", không giống Foxit | Khung đi theo con trỏ ngay khi kéo; thả mới commit | +| Annotation FreeText hiển thị sai width tiếng Việt ở viewer khác | File ghi ra "xấu" ở Acrobat | Tự dựng /W (widths) cho font Type0 nhúng bằng `ttf-parser` (Phase 2) | +| PDFium segfault khi Drop object đã remove | Crash khi xoá object | `std::mem::forget` object đã tách (đã ghi memory + comment) | +| File hỏng/mã hoá làm hỏng luồng lưu | Mất file người dùng | QPDF repair + mã hoá lại an toàn (Phase 3), test riêng | + +**Bài học chung:** chưa gặp vấn đề nào *không giải được* trên stack này. Mọi +"thư viện không hỗ trợ" đến nay đều xử lý được bằng đọc source thư viện + thao +tác tầng PDF object trực tiếp. + +## 3. Khoảng cách CÒN LẠI so với Foxit + giải pháp cụ thể + +Xếp theo mức độ ảnh hưởng trải nghiệm: + +### 3.1 Reflow đoạn văn nhiều dòng ("sửa như Word") — GAP LỚN NHẤT CÒN LẠI +- **Hiện tại:** sửa được cả dòng (Iteration 2), chưa tự bẻ dòng lại cả đoạn. +- **Foxit:** click vào đoạn → gõ → chữ tự chảy xuống dòng trong khối. +- **Giải pháp (Iteration 3, khả thi với stack hiện tại):** + 1. Gom block: các dòng có baseline cách đều (±20%), cùng lề trái/phải giao nhau. + 2. Đo width chính xác tầng engine bằng `hmtx` của đúng font bytes + (`ttf-parser` đã có trong cây phụ thuộc; với font không nhúng thì đo bằng + font hệ thống đã match — chấp nhận sai số như chính Foxit). + 3. UI: textarea phủ block; engine nhận `ReflowBlock { runs, new_text, box }` + → tự bẻ dòng theo width, sinh dãy SetText/AddText/Delete với baseline + spacing gốc. + 4. Nghiệm thu so với Foxit: gõ giữa đoạn 5 dòng → không tràn box, giãn dòng + giữ nguyên, font giữ nguyên. +- **Rủi ro:** đoạn justify (giãn từ) và kerning → v1 chấp nhận căn trái/đều + theo heuristic, như mức "Edit Text" thường của Foxit. + +### 3.2 Hiệu năng phiên sửa trên file rất lớn +- **Hiện tại:** mô hình "materialize tức thì" — mỗi thao tác lưu toàn bộ tài + liệu ra file tạm mới rồi render lại. Đúng tuyệt đối (WYSIWYG thật, undo chắc + chắn), nhưng file vài trăm MB sẽ trễ nhìn thấy được ở mỗi thao tác; Foxit + chỉnh trong RAM và lưu 1 lần. +- **Giải pháp theo nấc (không phá kiến trúc):** + 1. (rẻ) Giữ document PDFium mở suốt phiên sửa trong Tauri state, áp op + in-memory, chỉ render lại trang — bỏ chu kỳ load-từ-đĩa mỗi op; save ra + temp chỉ để làm mốc undo (có thể async). + 2. (vừa) Undo bằng danh sách op thay vì snapshot file: replay từ gốc khi + undo (op rẻ vì in-memory). + 3. (nếu cần) Incremental save của QPDF cho file khổng lồ. +- **Ghi chú:** với file văn phòng thông thường (<20MB) mô hình hiện tại đã đủ + mượt; nấc 1 chỉ là việc tổ chức lại Tauri command, không đổi engine. + +### 3.3 Lưu file sau sửa: font nhúng full + object rác +- **Hiện tại:** khi phải thay font (tầng 2/3), font mới nhúng **nguyên cả file + TTF** (Arial ~1MB); object bị thay vẫn nằm lại file dưới dạng rác (đã tách + khỏi trang nên không hiển thị, nhưng tốn dung lượng). Foxit subset font khi + lưu và dọn object rác. +- **Giải pháp:** bước "Lưu tối ưu" chạy khi save: (1) subset font nhúng theo + đúng glyph dùng thật — tự viết được trên `ttf-parser` (đã dựng /W thủ công ở + annot.rs, subset TTF là bài quen thuộc: lọc glyf/loca/cmap/hmtx); (2) chạy + `qpdf --object-streams=generate` (đã có sẵn qpdf.rs) để dọn rác + nén object + stream. Đưa vào Phase 5 cùng nhóm "lưu file vững". + +### 3.4 Độ phủ font hệ thống khi thay thế +- **Hiện tại:** bảng family phổ biến (Windows ~16 họ, macOS, Linux + Liberation + fc-match). Font ngoài bảng → fallback mặc định. +- **Giải pháp:** Windows dùng DirectWrite/GDI enumeration (crate `font-kit` + hoặc đọc registry Fonts) để match theo TÊN THẬT của mọi font đã cài thay vì + bảng tĩnh; giữ bảng tĩnh làm fallback offline. Việc nhỏ, độc lập, làm khi + chạm phải file thực tế đầu tiên không match. + +### 3.5 Các tính năng Foxit chưa bắt đầu (không phải rào cản kỹ thuật) +| Nhóm | Đường đi đã chốt trong roadmap | Rủi ro công nghệ | +|---|---|---| +| Bảo mật, redaction, chữ ký số PAdES (Phase 5) | QPDF (mã hoá AES-256 sẵn), redaction = xoá object thật (đã có nền edit.rs), ký số qua `cryptographic-message-syntax`/`rustls` + incremental update | Trung bình — byte-range + incremental update phải chuẩn; có spec rõ, test bằng Acrobat | +| Form AcroForm (Phase 6) | PDFium có FPDF_FORMFILL đầy đủ (fill/flatten); tạo field mới bằng lopdf | Thấp-trung bình | +| OCR (Phase 7) | Tesseract (tiếng Việt có traineddata chuẩn) + lớp text ẩn — kỹ thuật giống watermark.rs đã làm | Thấp, chủ yếu là chất lượng tiền xử lý ảnh | +| Convert Office↔PDF (Phase 7) | LibreOffice headless; PDF→Word "đủ dùng" trước | PDF→Word giữ layout hoàn hảo là bài khó với CẢ Foxit — đặt kỳ vọng "tương đương Foxit" chứ không hơn | +| So sánh PDF, in ấn, installer (Phase 8) | render diff đã có nền; Tauri bundler | Thấp | + +## 4. Đánh giá định hướng công nghệ (giữ hay đổi?) + +**GIỮ.** Đánh giá lại từng lựa chọn sau 4 phase: + +- **PDFium qua `pdfium-render`**: đúng đắn. Đã 2 lần tưởng "không làm được" + (annotation appearance, giữ font khi sửa) và cả 2 lần đều giải được bằng + API sẵn có + tự bù tầng PDF object. Trần năng lực còn xa. + *Lưu ý vận hành:* `pdfium-render` 0.8.37 có API deprecated đổi nghĩa + (`set_matrix` = apply, không phải replace) — đã ghi chú trong code; khi nâng + version cần đọc CHANGELOG cẩn thận. +- **Tauri/WebView UI**: đủ mượt cho viewer (lazy render 1000+ trang đã chứng + minh ở Phase 1). Điểm cần canh: thao tác chuột mô phỏng trong WebView2 khó + test tự động → bù bằng checklist test tay (docs/13) — chấp nhận được. +- **QPDF sidecar**: gọn, đúng việc (repair/mã hoá/nén), không cần thay. +- **Rust workspace tách engine/UI**: cho phép test engine không cần UI — chính + là lý do 50 test tự động chạy được trong CI/Linux dù app nhắm Windows. + +**Điều KHÔNG nên làm:** tự viết engine PDF từ đầu, hay chuyển C++/Qt để "giống +Foxit hơn" — chi phí khổng lồ, không giải quyết gap nào ở mục 3. + +## 5. Tiến độ so với đích + thứ tự việc đề xuất + +Đã xong (lõi, có test): Viewer ✅ · Annotate ✅ · Organize + lưu vững ✅ · +**Edit text/object giữ font ✅ (iteration 2)**. + +Thứ tự đề xuất tiếp theo, bám giá trị người dùng: +1. **Iteration 3 của Edit: reflow đoạn** (mục 3.1) — hoàn tất lời hứa "sửa như + Word", gap trải nghiệm cuối cùng của tính năng moat. +2. **Phase 5 Bảo mật + "Lưu tối ưu"** (mục 3.3 gộp vào đây): mã hoá UI (nợ + Phase 3), redaction thật, subset font, dọn rác, chữ ký số. +3. Phase 6 Form → Phase 7 OCR/Convert → Phase 8 phát hành (theo roadmap). +4. Song song, rải theo từng phase: mở rộng font matching (3.4) và nấc 1 của + hiệu năng phiên sửa (3.2) khi chạm file lớn thực tế. + +## 6. Checklist FINAL TARGET & RULE cho chính tài liệu này +- [x] Đối chiếu từng tính năng với hành vi Foxit làm chuẩn nghiệm thu. +- [x] Không đề xuất cắt giảm tính năng nào; mọi gap đều kèm giải pháp cụ thể. +- [x] Giải pháp theo đúng thứ tự: dùng API thư viện → bù tầng PDF object → tự viết. +- [x] Mỗi gap có tiêu chí nghiệm thu so với Foxit (mục 3).