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:
@@ -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).
|
||||
|
||||
@@ -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!())
|
||||
|
||||
@@ -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>
|
||||
|
||||
+198
-31
@@ -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({
|
||||
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: o.index,
|
||||
index: runs[0].index,
|
||||
text,
|
||||
fontSize: o.font_size || null,
|
||||
color: o.color ? o.color : [0, 0, 0, 255],
|
||||
bold: false,
|
||||
italic: false,
|
||||
});
|
||||
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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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] = &[
|
||||
// [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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+352
-88
@@ -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 {
|
||||
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 { bold, italic, .. } | EditOp::AddText { bold, italic, .. } => {
|
||||
if !needed_fonts.contains(&(*bold, *italic)) {
|
||||
needed_fonts.push((*bold, *italic));
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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}"));
|
||||
// ---- (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(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod annot;
|
||||
pub mod edit;
|
||||
pub(crate) mod fontmatch;
|
||||
pub mod meta;
|
||||
pub mod organize;
|
||||
pub mod qpdf;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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ả> —
|
||||
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user