Phase 4 Iteration 2: sửa text GIỮ NGUYÊN FONT (chuẩn Foxit) + UX edit mượt

Engine:
- fontmatch.rs mới: làm sạch tên PostScript/subset, kiểm coverage cmap bằng
  ttf-parser, match font hệ thống CÙNG HỌ (bảng Windows/macOS, Liberation
  metric-compatible + fc-match trên Linux, alias base-14 Helvetica→Arial...)
- edit.rs: SetText quyết định 3 tầng — (1) sửa TẠI CHỖ giữ nguyên font gốc
  (charset-subset / base-14+ASCII / cmap phủ đủ, gồm font nhúng + tiếng Việt);
  (2) thiếu glyph → thay font cùng họ đúng đậm/nghiêng; (3) mới đến fallback.
  Đổi cỡ chữ = scale matrix neo baseline, không đụng font; fix bug phóng đại
  kép khi text có matrix scale. ObjectInfo thêm font_family/bold/italic/embedded.
  SetText/AddText nhận font_family + bold/italic dạng Option (None = giữ nguyên)
- annot.rs: fallback Linux có đủ biến thể đậm/nghiêng (DejaVu/Liberation)

UI (chuẩn trải nghiệm Foxit):
- Double-click sửa CẢ DÒNG (gộp run cùng baseline), commit 1 batch = 1 undo
- Ô sửa WYSIWYG: đúng font/cỡ/màu/đậm-nghiêng của run gốc khi gõ
- Toolbar Format: dropdown font "(giữ nguyên: X)", nút B/I theo kiểu thật,
  đổi cỡ/màu/kiểu gửi đúng field đó (null = giữ nguyên → không đổi font)
- Kéo di chuyển/resize có phản hồi trực tiếp (live), resize neo góc trên-trái
- Dọn file tạm ff_edit_* khi thoát/lưu (command edit_cleanup)

Test: 14 test edit (7 mới: giữ font ASCII/base-14, giữ font NHÚNG tiếng Việt,
match cùng họ, cỡ chữ neo + hồi quy matrix, bold override, gộp dòng) + 3 unit
fontmatch. Toàn bộ suite engine ngoài qpdf: 50/50 xanh (chạy trên Linux).

Docs: 12 (iteration 2), 13 (E17–E25), 14 MỚI — phân tích định hướng/công nghệ/
tiến độ so với tham vọng thay Foxit, gap còn lại + giải pháp; README cập nhật.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
2026-07-14 16:58:22 +00:00
co-authored by Claude Fable 5
parent e442a9f678
commit 1285a8d6ae
13 changed files with 1553 additions and 153 deletions
+5 -3
View File
@@ -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).
+37 -6
View File
@@ -665,6 +665,10 @@ struct ObjectInfoDto {
rect: RectDto,
text: Option<String>,
font_name: Option<String>,
font_family: Option<String>,
font_bold: Option<bool>,
font_italic: Option<bool>,
font_embedded: Option<bool>,
font_size: Option<f32>,
color: Option<[u8; 4]>,
}
@@ -683,6 +687,10 @@ fn edit_list_objects(path: String, page: u16, password: Option<String>) -> 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<f32>,
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<String>,
/// setText: None = giữ kiểu gốc. addText: None ≙ false.
bold: Option<bool>,
italic: Option<bool>,
#[serde(default)]
x: f32,
#[serde(default)]
@@ -737,6 +746,7 @@ fn edit_op_from_dto(d: EditOpDto) -> Result<ff_engine::EditOp, String> {
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<ff_engine::EditOp, String> {
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<EditOpDto>, 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<String>) {
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<String> {
@@ -861,6 +891,7 @@ fn main() {
edit_apply,
edit_apply_to_temp,
edit_preview,
edit_cleanup,
pick_image
])
.run(tauri::generate_context!())
+14
View File
@@ -81,7 +81,21 @@
<button id="edDelete" title="Xoá đối tượng đang chọn (Delete)" disabled>🗑 Xoá</button>
<button id="edReplaceImage" title="Thay ảnh đang chọn" disabled>🔁 Thay ảnh</button>
<span class="sep"></span>
<label class="ed-prop">Font <select id="edFontFamily" disabled>
<option value="">(giữ nguyên)</option>
<option>Arial</option>
<option>Times New Roman</option>
<option>Calibri</option>
<option>Cambria</option>
<option>Georgia</option>
<option>Verdana</option>
<option>Tahoma</option>
<option>Segoe UI</option>
<option>Courier New</option>
</select></label>
<label class="ed-prop">Cỡ chữ <input id="edFontSize" type="number" min="1" max="400" disabled></label>
<button id="edBold" class="ed-style" title="Đậm (giữ họ font, đổi biến thể)" disabled><b>B</b></button>
<button id="edItalic" class="ed-style" title="Nghiêng (giữ họ font, đổi biến thể)" disabled><i>I</i></button>
<button id="edColorBtn" class="atool" title="Màu chữ" disabled><span class="sw" id="edSw"></span> Màu</button>
<span class="spacer"></span>
<span id="editHint" class="status"></span>
+200 -33
View File
@@ -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: <family gốc>)" — 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);
+20 -1
View File
@@ -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;
}
+44 -7
View File
@@ -334,14 +334,51 @@ pub(crate) fn find_font_bytes(bold: bool, italic: bool) -> Option<Vec<u8>> {
}
#[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
}
}
+359 -95
View File
@@ -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<String>,
/// 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<String>,
/// 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<String>,
/// Chỉ với text: font đậm / nghiêng (từ weight/italic-angle + tên font).
pub font_bold: Option<bool>,
pub font_italic: Option<bool>,
/// Chỉ với text: font có nhúng trong file không.
pub font_embedded: Option<bool>,
/// Chỉ với text: cỡ chữ hiển thị (đã tính scale của matrix).
pub font_size: Option<f32>,
/// 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<f32>, 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<f32>,
color: Option<[u8; 4]>,
font_family: Option<String>,
bold: Option<bool>,
italic: Option<bool>,
},
/// 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<String>,
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<u8>), 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<usize, SetTextMode> = HashMap::new();
let mut add_text_keys: HashMap<usize, FontKey> = HashMap::new();
let mut font_bytes_needed: HashMap<FontKey, Vec<u8>> = 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<char> = 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<FontKey, PdfFontToken> = 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<u16, Captured> = 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<u16> = 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<u16> = 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(
+233
View File
@@ -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<Vec<u8>> {
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"));
}
}
+1
View File
@@ -6,6 +6,7 @@
pub mod annot;
pub mod edit;
pub(crate) mod fontmatch;
pub mod meta;
pub mod organize;
pub mod qpdf;
+399 -4
View File
@@ -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();
+77 -4
View File
@@ -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).
+13
View File
@@ -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: <font gốc>)" | |
| 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ở: <Foxit/Adobe/Chrome> — Hiện tượng: <mô tả> —
+151
View File
@@ -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).