PHẪU THUẬT stream form: sửa/xoá text TRONG Form XObject không cần mở gói
Build Windows portable / build (push) Canceled after 0s

Iteration 4b — giải pháp triệt để cho file Canva (thay cổng chặn của 4a):
module formsurgery (lopdf) xoá đúng op vẽ chữ (Tj/TJ/'/") của run bị thay
trong stream form — mọi byte khác giữ NGUYÊN (không đụng generator lossy
của PDFium); chữ mới vẽ ở CẤP TRANG như thường.

- Map PDFium ↔ stream: text-child thứ k của form ↔ show-op thứ k; form con
  thứ n ↔ Do-trỏ-form thứ n. Mỗi mức có BẤT BIẾN kiểm đếm (số op == số
  object PDFium thấy) — lệch là từ chối, không đoán mò. '/" thay bằng
  T*/Tw+Tc+T* để giữ hiệu ứng vị trí cho chữ sau.
- apply_edits: run nested trong ReflowText/Delete → phẫu thuật ở pha A2 rồi
  MỞ LẠI document (index cấp trang không đổi); tầng-0 reflow (token gốc)
  tắt cho anchor nested (token vô hiệu sau mở lại) → đi tầng nhúng bytes/
  cùng họ. SetText/Transform nested vẫn từ chối rõ (UI chặn với hint).
- CỔNG AN TOÀN mới: page_render_mismatch_masked — mọi thay đổi pixel phải
  nằm TRONG vùng khối sửa (band reflow + rect run xoá); lệch ngoài vùng
  >0.5% → huỷ output, giữ nguyên file gốc.
- UI: bỏ auto-flatten (không cần nữa), sửa nested trong suốt qua đúp chuột;
  hint 1 dòng cố định + ellipsis + title (hết cảnh trang nhảy lên xuống do
  thanh công cụ co giãn); chặn-với-thông-điệp cho đổi thuộc tính/kéo khối
  trong form.
- Test: fixture form thêm dòng 2 + path đỏ; 2 test phẫu thuật (delete/reflow
  giữ nguyên phần khác, form còn nguyên); SetText nested vẫn bị từ chối.
  Flatten + cổng cũ giữ nguyên engine-side (có test riêng).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 07:46:39 +07:00
co-authored by Claude Fable 5
parent d7398a6bb0
commit 26701b9242
7 changed files with 561 additions and 62 deletions
+24 -41
View File
@@ -39,8 +39,6 @@ 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)
editPendingPoint: null, // điểm PDF chờ mở ô sửa (đúp chuột từ viewer thường)
editFlatBase: null, // bản "mở gói" Form XObject — không tính là thay đổi
editFlattenFailed: null, // cổng an toàn từ chối mở gói — thông điệp chặn sửa
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)
secMode: false, // thanh Bảo mật (Phase 5) đang mở
@@ -2056,8 +2054,6 @@ function enterEditMode(pageIndex) {
state.editSel = null;
state.editArm = null;
state.editPendingImage = null;
state.editFlatBase = null;
state.editFlattenFailed = null;
$("annobar").classList.add("hidden");
$("editBar").classList.remove("hidden");
$("viewport").classList.add("hidden");
@@ -2129,29 +2125,9 @@ async function loadEditPage() {
invoke("render_page", { path: state.editBase, page: state.editPage, width: EDIT_STAGE_W }),
invoke("edit_list_objects", { path: state.editBase, page: state.editPage, password: null }),
]);
// Trang có object nằm TRONG Form XObject (file Canva/Illustrator gói cả
// trang vào form): PDFium không ghi lại được stream form → "mở gói" form
// ra cấp trang MỘT LẦN (hiển thị y hệt) rồi nạp lại — từ đó sửa như
// trang thường. Bản mở gói không tính là "có thay đổi" cho tới khi sửa.
if (objs.some((o) => o.nested)) {
try {
const flat = await invoke("edit_flatten_to_temp", {
input: state.editBase, page: state.editPage, password: null,
});
if (flat) {
state.editTemps.push(flat);
state.editBase = flat;
state.editFlatBase = flat;
state.editFlattenFailed = null;
return loadEditPage();
}
} catch (e) {
// Cổng an toàn từ chối (mở gói làm lệch hiển thị) → vẫn xem được,
// nhưng chặn sửa các phần nằm trong form với thông điệp rõ.
state.editFlattenFailed = String(e);
$("editHint").textContent = String(e);
}
}
// Object nằm TRONG Form XObject (file Canva/Illustrator): sửa/xoá text
// được xử lý bằng PHẪU THUẬT stream ở engine (trong suốt với UI) — không
// cần mở gói trang.
$("editImg").src = url;
state.editObjects = objs;
buildEditOverlay();
@@ -2159,8 +2135,7 @@ async function loadEditPage() {
} catch (e) {
$("editHint").textContent = "Lỗi nạp trang sửa: " + e;
}
// Chưa có thay đổi nào (bản mở gói form không tính).
$("edSave").disabled = state.editBase === state.path || state.editBase === state.editFlatBase;
$("edSave").disabled = state.editBase === state.path; // chưa có thay đổi nào
updateEditUndoButtons();
// Đúp chuột từ viewer thường (kiểu Foxit): vào thẳng ô sửa tại điểm đã đúp.
@@ -2616,15 +2591,7 @@ function paragraphLines(o) {
// đường code duy nhất, giữ nguyên cấu trúc xuống dòng, commit qua reflow.
// `ev` (nếu có) = sự kiện chuột để đặt con trỏ đúng chỗ vừa đúp.
function startTextEdit(o, ev) {
const lines = paragraphLines(o);
// Trang gói form phức tạp không mở gói được an toàn → text vẫn nằm TRONG
// form, engine không lưu sửa đổi được — báo rõ thay vì mở ô sửa vô dụng.
if (lines.some((l) => l.runs.some((r) => r.nested))) {
$("editHint").textContent =
state.editFlattenFailed || "Phần này nằm trong Form XObject — chưa sửa được trên file này.";
return;
}
startBlockTextEdit(o, lines, ev);
startBlockTextEdit(o, paragraphLines(o), ev);
}
// Sửa cả đoạn WYSIWYG (chuẩn Foxit Edit Text): contenteditable phủ kín khối,
@@ -2926,6 +2893,13 @@ function applyTextPropToSelected(part) {
.map((i) => state.editObjects.find((x) => x.index === i))
.filter((x) => x && x.kind === "text");
if (!runs.length) return;
// Đổi thuộc tính tại chỗ cho run TRONG form chưa hỗ trợ (engine chỉ phẫu
// thuật được sửa/xoá nội dung) — đúp chuột để sửa cả đoạn thì được.
if (runs.some((r) => r.nested)) {
$("editHint").textContent =
"Đổi font/cỡ/màu cho chữ trong khối form chưa hỗ trợ — đúp chuột để sửa nội dung, hoặc xoá.";
return;
}
stageEditOps(
runs.map((r) => ({
op: "setText",
@@ -3022,9 +2996,13 @@ function onEditBoxMouseDown(e, o, runs) {
const runObjs = runs && runs.length ? runs : [o];
const runIndices = runObjs.map((r) => (typeof r === "number" ? r : r.index));
selectEditObject(o.index, runIndices);
// Run TRONG Form XObject (file Canva...): sửa text được nhưng chưa hỗ trợ
// kéo di chuyển/resize — chọn thôi, không bắt đầu drag.
if (runObjs.some((r) => r && typeof r === "object" && r.nested)) return;
// Run TRONG Form XObject (file Canva...): sửa/xoá text được nhưng chưa hỗ
// trợ kéo di chuyển/resize — chọn thôi, không bắt đầu drag.
if (runObjs.some((r) => r && typeof r === "object" && r.nested)) {
$("editHint").textContent =
"Khối này nằm trong form — đúp chuột để sửa nội dung; di chuyển/resize chưa hỗ trợ.";
return;
}
const box = e.currentTarget;
const startX = e.clientX, startY = e.clientY;
const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top);
@@ -3648,6 +3626,11 @@ async function verifySignaturesAction(pathOverride) {
$("openBtn").addEventListener("click", openFile);
// Hint bị cắt bớt bằng CSS (1 dòng cố định) → hover xem đủ qua title.
new MutationObserver(() => {
$("editHint").title = $("editHint").textContent;
}).observe($("editHint"), { childList: true, characterData: true, subtree: true });
// Kéo-thả file PDF từ File Explorer vào cửa sổ → mở luôn (chuẩn desktop app).
// Tauri v2 bắn sự kiện drag-drop toàn cửa sổ với danh sách đường dẫn thật.
if (window.__TAURI__.event) {
+9
View File
@@ -376,6 +376,15 @@ body.tool-active .annotlayer * { pointer-events: none; }
user-select: none;
}
.edit-inline { user-select: text; }
/* Hint trong thanh Sửa nội dung: 1 dòng cố định + cắt bớt — thông điệp dài
không được làm thanh công cụ co giãn (trang nhảy lên xuống khi thao tác). */
#editHint {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.edit-page-wrap { position: relative; box-shadow: 0 2px 12px rgba(0,0,0,.5); background: #fff; }
.edit-img { display: block; }
.edit-overlay { position: absolute; inset: 0; }