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
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:
+24
-41
@@ -39,8 +39,6 @@ const state = {
|
|||||||
editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không
|
editArm: null, // 'text' | 'image' khi đang chờ click đặt; null = không
|
||||||
editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh)
|
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)
|
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
|
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)
|
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ở
|
secMode: false, // thanh Bảo mật (Phase 5) đang mở
|
||||||
@@ -2056,8 +2054,6 @@ function enterEditMode(pageIndex) {
|
|||||||
state.editSel = null;
|
state.editSel = null;
|
||||||
state.editArm = null;
|
state.editArm = null;
|
||||||
state.editPendingImage = null;
|
state.editPendingImage = null;
|
||||||
state.editFlatBase = null;
|
|
||||||
state.editFlattenFailed = null;
|
|
||||||
$("annobar").classList.add("hidden");
|
$("annobar").classList.add("hidden");
|
||||||
$("editBar").classList.remove("hidden");
|
$("editBar").classList.remove("hidden");
|
||||||
$("viewport").classList.add("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("render_page", { path: state.editBase, page: state.editPage, width: EDIT_STAGE_W }),
|
||||||
invoke("edit_list_objects", { path: state.editBase, page: state.editPage, password: null }),
|
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ả
|
// Object nằm TRONG Form XObject (file Canva/Illustrator): sửa/xoá text
|
||||||
// trang vào form): PDFium không ghi lại được stream form → "mở gói" form
|
// được xử lý bằng PHẪU THUẬT stream ở engine (trong suốt với UI) — không
|
||||||
// ra cấp trang MỘT LẦN (hiển thị y hệt) rồi nạp lại — từ đó sửa như
|
// cần mở gói trang.
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$("editImg").src = url;
|
$("editImg").src = url;
|
||||||
state.editObjects = objs;
|
state.editObjects = objs;
|
||||||
buildEditOverlay();
|
buildEditOverlay();
|
||||||
@@ -2159,8 +2135,7 @@ async function loadEditPage() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("editHint").textContent = "Lỗi nạp trang sửa: " + 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; // chưa có thay đổi nào
|
||||||
$("edSave").disabled = state.editBase === state.path || state.editBase === state.editFlatBase;
|
|
||||||
updateEditUndoButtons();
|
updateEditUndoButtons();
|
||||||
|
|
||||||
// Đúp chuột từ viewer thường (kiểu Foxit): vào thẳng ô sửa tại điểm đã đúp.
|
// Đú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.
|
// đườ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.
|
// `ev` (nếu có) = sự kiện chuột để đặt con trỏ đúng chỗ vừa đúp.
|
||||||
function startTextEdit(o, ev) {
|
function startTextEdit(o, ev) {
|
||||||
const lines = paragraphLines(o);
|
startBlockTextEdit(o, paragraphLines(o), ev);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sửa cả đoạn WYSIWYG (chuẩn Foxit Edit Text): contenteditable phủ kín khối,
|
// 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))
|
.map((i) => state.editObjects.find((x) => x.index === i))
|
||||||
.filter((x) => x && x.kind === "text");
|
.filter((x) => x && x.kind === "text");
|
||||||
if (!runs.length) return;
|
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(
|
stageEditOps(
|
||||||
runs.map((r) => ({
|
runs.map((r) => ({
|
||||||
op: "setText",
|
op: "setText",
|
||||||
@@ -3022,9 +2996,13 @@ function onEditBoxMouseDown(e, o, runs) {
|
|||||||
const runObjs = runs && runs.length ? runs : [o];
|
const runObjs = runs && runs.length ? runs : [o];
|
||||||
const runIndices = runObjs.map((r) => (typeof r === "number" ? r : r.index));
|
const runIndices = runObjs.map((r) => (typeof r === "number" ? r : r.index));
|
||||||
selectEditObject(o.index, runIndices);
|
selectEditObject(o.index, runIndices);
|
||||||
// Run TRONG Form XObject (file Canva...): sửa text được nhưng chưa hỗ trợ
|
// Run TRONG Form XObject (file Canva...): sửa/xoá text được nhưng chưa hỗ
|
||||||
// kéo di chuyển/resize — chọn thôi, không bắt đầu drag.
|
// 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;
|
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 box = e.currentTarget;
|
||||||
const startX = e.clientX, startY = e.clientY;
|
const startX = e.clientX, startY = e.clientY;
|
||||||
const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top);
|
const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top);
|
||||||
@@ -3648,6 +3626,11 @@ async function verifySignaturesAction(pathOverride) {
|
|||||||
|
|
||||||
$("openBtn").addEventListener("click", openFile);
|
$("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).
|
// 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.
|
// 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) {
|
if (window.__TAURI__.event) {
|
||||||
|
|||||||
@@ -376,6 +376,15 @@ body.tool-active .annotlayer * { pointer-events: none; }
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.edit-inline { user-select: text; }
|
.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-page-wrap { position: relative; box-shadow: 0 2px 12px rgba(0,0,0,.5); background: #fff; }
|
||||||
.edit-img { display: block; }
|
.edit-img { display: block; }
|
||||||
.edit-overlay { position: absolute; inset: 0; }
|
.edit-overlay { position: absolute; inset: 0; }
|
||||||
|
|||||||
+174
-14
@@ -298,6 +298,57 @@ fn object_at_path<'a>(page: &PdfPage<'a>, path: &[u16]) -> Result<PdfPageObject<
|
|||||||
Ok(obj)
|
Ok(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dựng job phẫu thuật (formsurgery) từ path các text-child trong form, kèm
|
||||||
|
/// BẤT BIẾN kiểm đếm ở từng mức: thứ tự form/text tính từ chính `entries`
|
||||||
|
/// (thứ tự parse của PDFium) — bên lopdf đếm op trong stream phải khớp.
|
||||||
|
fn surgery_jobs(
|
||||||
|
entries: &[FlatEntry],
|
||||||
|
paths: &[Vec<u16>],
|
||||||
|
) -> Result<Vec<crate::formsurgery::SurgeryJob>, EngineError> {
|
||||||
|
let siblings = |prefix: &[u16]| -> Vec<&FlatEntry> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.path.len() == prefix.len() + 1 && e.path[..prefix.len()] == *prefix)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let mut jobs = Vec::new();
|
||||||
|
for path in paths {
|
||||||
|
let k = path.len() - 1;
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
for level in 0..k {
|
||||||
|
let prefix = &path[..level];
|
||||||
|
let sib = siblings(prefix);
|
||||||
|
let forms: Vec<u16> = sib
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.kind == ObjectKind::XObjectForm)
|
||||||
|
.map(|e| e.path[level])
|
||||||
|
.collect();
|
||||||
|
if !forms.contains(&path[level]) {
|
||||||
|
return Err(EngineError::Pdfium("path phẫu thuật không đi qua form".into()));
|
||||||
|
}
|
||||||
|
let ord = forms.iter().filter(|&&c| c < path[level]).count();
|
||||||
|
chain.push((ord, forms.len()));
|
||||||
|
}
|
||||||
|
let prefix = &path[..k];
|
||||||
|
let sib = siblings(prefix);
|
||||||
|
let texts: Vec<u16> = sib
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.kind == ObjectKind::Text)
|
||||||
|
.map(|e| e.path[k])
|
||||||
|
.collect();
|
||||||
|
if !texts.contains(&path[k]) {
|
||||||
|
return Err(EngineError::Pdfium("path phẫu thuật không trỏ vào text".into()));
|
||||||
|
}
|
||||||
|
let ord = texts.iter().filter(|&&c| c < path[k]).count();
|
||||||
|
jobs.push(crate::formsurgery::SurgeryJob {
|
||||||
|
chain,
|
||||||
|
expected_texts: texts.len(),
|
||||||
|
delete_ordinals: vec![ord],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(jobs)
|
||||||
|
}
|
||||||
|
|
||||||
/// "MỞ GÓI" mọi Form XObject cấp trang thành object cấp trang: rút từng con
|
/// "MỞ GÓI" mọi Form XObject cấp trang thành object cấp trang: rút từng con
|
||||||
/// khỏi form (`FPDFFormObj_RemoveObject` — cần feature pdfium_7350), nhân ma
|
/// khỏi form (`FPDFFormObj_RemoveObject` — cần feature pdfium_7350), nhân ma
|
||||||
/// trận form vào con, chèn lại vào TRANG, xoá form rỗng. Hiển thị y hệt,
|
/// trận form vào con, chèn lại vào TRANG, xoá form rỗng. Hiển thị y hệt,
|
||||||
@@ -703,6 +754,11 @@ pub fn apply_edits(
|
|||||||
// op tra vào đây; dùng chung cho cả pha (A) lẫn (C). Probe font ở pha A
|
// op tra vào đây; dùng chung cho cả pha (A) lẫn (C). Probe font ở pha A
|
||||||
// chỉ thêm+xoá object nháp ở CUỐI trang nên không làm lệch index.
|
// chỉ thêm+xoá object nháp ở CUỐI trang nên không làm lệch index.
|
||||||
let entries: Vec<FlatEntry>;
|
let entries: Vec<FlatEntry>;
|
||||||
|
// Run TRONG form bị thay (path) + rect của chúng + kích thước trang —
|
||||||
|
// phục vụ pha phẫu thuật (A2) và cổng an toàn so render cuối hàm.
|
||||||
|
let nested_removals: Vec<Vec<u16>>;
|
||||||
|
let mut nested_excl: Vec<Rect> = Vec::new();
|
||||||
|
let page_dims: (f32, f32);
|
||||||
{
|
{
|
||||||
let mut page = document
|
let mut page = document
|
||||||
.pages()
|
.pages()
|
||||||
@@ -800,9 +856,6 @@ pub fn apply_edits(
|
|||||||
let mut idxs: Vec<u16> = indices.iter().copied().filter(|i| valid(*i)).collect();
|
let mut idxs: Vec<u16> = indices.iter().copied().filter(|i| valid(*i)).collect();
|
||||||
idxs.sort_unstable();
|
idxs.sort_unstable();
|
||||||
idxs.dedup();
|
idxs.dedup();
|
||||||
if idxs.iter().any(|&i| entries[i as usize].path.len() > 1) {
|
|
||||||
return Err(nested_target_err());
|
|
||||||
}
|
|
||||||
// Hình học từng run: gốc text (e,f của matrix) + bounds.
|
// Hình học từng run: gốc text (e,f của matrix) + bounds.
|
||||||
struct RunGeo {
|
struct RunGeo {
|
||||||
idx: u16,
|
idx: u16,
|
||||||
@@ -850,11 +903,10 @@ pub fn apply_edits(
|
|||||||
// object có TÂM nằm trong bbox khối (nới 2pt) đều thuộc khối →
|
// object có TÂM nằm trong bbox khối (nới 2pt) đều thuộc khối →
|
||||||
// đưa hết vào để xoá sạch, không còn chữ cũ đè dưới chữ mới.
|
// đưa hết vào để xoá sạch, không còn chữ cũ đè dưới chữ mới.
|
||||||
{
|
{
|
||||||
// Rect trang của 1 entry text (None nếu không phải text
|
// Rect trang của 1 entry text (None nếu không phải text).
|
||||||
// hoặc nằm trong form — sau flatten không còn, guard).
|
|
||||||
let text_rect = |i: u16| -> Option<Rect> {
|
let text_rect = |i: u16| -> Option<Rect> {
|
||||||
let entry = &entries[i as usize];
|
let entry = &entries[i as usize];
|
||||||
if entry.kind != ObjectKind::Text || entry.path.len() > 1 {
|
if entry.kind != ObjectKind::Text {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let obj = object_at_path(&page, &entry.path).ok()?;
|
let obj = object_at_path(&page, &entry.path).ok()?;
|
||||||
@@ -1003,8 +1055,13 @@ pub fn apply_edits(
|
|||||||
let fam_key = fontmatch::normalize_key(&family);
|
let fam_key = fontmatch::normalize_key(&family);
|
||||||
let ascii_only =
|
let ascii_only =
|
||||||
text.chars().all(|c| c.is_control() || (' '..='~').contains(&c));
|
text.chars().all(|c| c.is_control() || (' '..='~').contains(&c));
|
||||||
let original_ok =
|
// Tầng 0 (dùng lại token font gốc) chỉ dùng được khi run neo
|
||||||
font_embedded && probe_font_roundtrip(&mut page, orig_token, text);
|
// ở CẤP TRANG: run trong form sẽ bị phẫu thuật xoá + document
|
||||||
|
// MỞ LẠI (token cũ vô hiệu) → đi tầng nhúng-lại-bytes/cùng-họ.
|
||||||
|
let anchor_nested = entries[anchor_idx as usize].path.len() > 1;
|
||||||
|
let original_ok = !anchor_nested
|
||||||
|
&& font_embedded
|
||||||
|
&& probe_font_roundtrip(&mut page, orig_token, text);
|
||||||
let embedded_bytes = if font_embedded {
|
let embedded_bytes = if font_embedded {
|
||||||
font_data.clone().filter(|b| fontmatch::coverage_ok(b, text))
|
font_data.clone().filter(|b| fontmatch::coverage_ok(b, text))
|
||||||
} else {
|
} else {
|
||||||
@@ -1056,6 +1113,62 @@ pub fn apply_edits(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (cuối pha A) Run TRONG form bị thay bởi Delete/ReflowText → xử lý
|
||||||
|
// bằng PHẪU THUẬT stream ở (A2); ghi lại rect (đã quy về trang) làm
|
||||||
|
// vùng loại trừ cho cổng an toàn so render.
|
||||||
|
page_dims = (page.width().value, page.height().value);
|
||||||
|
let mut nr: Vec<Vec<u16>> = Vec::new();
|
||||||
|
for (opi, op) in ops.iter().enumerate() {
|
||||||
|
match op {
|
||||||
|
EditOp::Delete { index } if valid(*index) => {
|
||||||
|
let e = &entries[*index as usize];
|
||||||
|
if e.path.len() > 1 && e.kind == ObjectKind::Text {
|
||||||
|
nr.push(e.path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditOp::ReflowText { .. } => {
|
||||||
|
if let Some(plan) = reflow_plans.get(&opi) {
|
||||||
|
for &i in &plan.indices {
|
||||||
|
let e = &entries[i as usize];
|
||||||
|
if e.path.len() > 1 && e.kind == ObjectKind::Text {
|
||||||
|
nr.push(e.path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nr.sort();
|
||||||
|
nr.dedup();
|
||||||
|
for p in &nr {
|
||||||
|
if let Ok(obj) = object_at_path(&page, p) {
|
||||||
|
if let Ok(q) = obj.bounds() {
|
||||||
|
if let Some(e) = entries.iter().find(|en| &en.path == p) {
|
||||||
|
nested_excl.push(mat_rect(e.acc, &quad_to_rect(&q)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nested_removals = nr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- (A2) PHẪU THUẬT stream form cho run TRONG form bị thay ----
|
||||||
|
// PDFium không ghi lại được stream form (docs/12) → xoá op vẽ chữ của các
|
||||||
|
// run đó bằng lopdf (mọi byte khác giữ nguyên), rồi MỞ LẠI document từ
|
||||||
|
// file đã phẫu thuật; chữ mới sẽ vẽ ở CẤP TRANG trong pha (C). Chỉ số
|
||||||
|
// object CẤP TRANG không đổi (form vẫn đó, chỉ ít text con hơn).
|
||||||
|
let mut surgery_tmp: Option<std::path::PathBuf> = None;
|
||||||
|
if !nested_removals.is_empty() {
|
||||||
|
let jobs = surgery_jobs(&entries, &nested_removals)?;
|
||||||
|
let tmp = output.with_extension("surgery.tmp.pdf");
|
||||||
|
drop(document);
|
||||||
|
formsurgery::delete_form_text_ops(input, page_index, &jobs, &tmp)?;
|
||||||
|
document = pdfium
|
||||||
|
.load_pdf_from_file(&tmp, password)
|
||||||
|
.map_err(|e| EngineError::Pdfium(format!("mở lại sau phẫu thuật: {e}")))?;
|
||||||
|
surgery_tmp = Some(tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- (B) Nạp các font cần dùng (cần fonts_mut → ngoài mượn trang) ----
|
// ---- (B) Nạp các font cần dùng (cần fonts_mut → ngoài mượn trang) ----
|
||||||
@@ -1072,6 +1185,8 @@ pub fn apply_edits(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- (C) Áp thay đổi (mượn trang lần 2) ----
|
// ---- (C) Áp thay đổi (mượn trang lần 2) ----
|
||||||
|
// Số dòng thật sự vẽ ra của từng op reflow (tính vùng loại trừ cho cổng).
|
||||||
|
let mut reflow_out_lines: HashMap<usize, usize> = HashMap::new();
|
||||||
{
|
{
|
||||||
let mut page = document
|
let mut page = document
|
||||||
.pages()
|
.pages()
|
||||||
@@ -1216,12 +1331,9 @@ pub fn apply_edits(
|
|||||||
to_remove.retain(|i| valid(*i));
|
to_remove.retain(|i| valid(*i));
|
||||||
to_remove.sort_unstable();
|
to_remove.sort_unstable();
|
||||||
to_remove.dedup();
|
to_remove.dedup();
|
||||||
// Op xoá nhắm vào object TRONG form: không xử lý được (PDFium không
|
// Run TRONG form đã bị xoá bằng PHẪU THUẬT ở (A2) — loại khỏi danh
|
||||||
// ghi lại stream form) — trang phải được flatten trước, lỗi rõ ràng.
|
// sách; chỉ còn xoá thật ở cấp trang theo index GIẢM DẦN.
|
||||||
if to_remove.iter().any(|&i| is_nested(i)) {
|
to_remove.retain(|&i| !is_nested(i));
|
||||||
return Err(nested_target_err());
|
|
||||||
}
|
|
||||||
// Cấp trang: xoá thật theo index trang GIẢM DẦN.
|
|
||||||
let mut page_idxs: Vec<u16> = to_remove
|
let mut page_idxs: Vec<u16> = to_remove
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&i| entries[i as usize].path[0])
|
.map(|&i| entries[i as usize].path[0])
|
||||||
@@ -1391,6 +1503,7 @@ pub fn apply_edits(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reflow_out_lines.insert(opi, out_lines.len());
|
||||||
let (a, b, c2, d) = plan.linear;
|
let (a, b, c2, d) = plan.linear;
|
||||||
for (i, (line, style)) in out_lines.iter().enumerate() {
|
for (i, (line, style)) in out_lines.iter().enumerate() {
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
@@ -1471,5 +1584,52 @@ pub fn apply_edits(
|
|||||||
document
|
document
|
||||||
.save_to_file(output)
|
.save_to_file(output)
|
||||||
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
|
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
|
||||||
|
|
||||||
|
// ---- CỔNG AN TOÀN cho sửa-trong-form: mọi thay đổi pixel phải nằm
|
||||||
|
// TRONG vùng khối sửa; phần còn lại của trang phải y nguyên. Lệch → huỷ
|
||||||
|
// output, giữ nguyên file gốc, lỗi rõ ràng.
|
||||||
|
if let Some(tmp) = &surgery_tmp {
|
||||||
|
let (page_w_pt, page_h_pt) = page_dims;
|
||||||
|
let s = 500.0 / page_w_pt.max(1.0);
|
||||||
|
let mut excl_px: Vec<(u32, u32, u32, u32)> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut push_rect = |r: &Rect, pad: f32| {
|
||||||
|
let x0 = ((r.left - pad) * s).max(0.0) as u32;
|
||||||
|
let x1 = ((r.right + pad) * s).max(0.0) as u32;
|
||||||
|
let y0 = ((page_h_pt - r.top - pad) * s).max(0.0) as u32;
|
||||||
|
let y1 = ((page_h_pt - r.bottom + pad) * s).max(0.0) as u32;
|
||||||
|
excl_px.push((x0, y0, x1.max(x0 + 1), y1.max(y0 + 1)));
|
||||||
|
};
|
||||||
|
for r in &nested_excl {
|
||||||
|
push_rect(r, 4.0);
|
||||||
|
}
|
||||||
|
for (opi, plan) in &reflow_plans {
|
||||||
|
let n = reflow_out_lines
|
||||||
|
.get(opi)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(plan.line_styles.len()) as f32;
|
||||||
|
push_rect(
|
||||||
|
&Rect {
|
||||||
|
left: plan.left,
|
||||||
|
right: plan.left + plan.width * 1.4,
|
||||||
|
top: plan.first_baseline + plan.line_advance,
|
||||||
|
bottom: plan.first_baseline - plan.line_advance * (n + 1.0),
|
||||||
|
},
|
||||||
|
6.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mm = crate::render::page_render_mismatch_masked(
|
||||||
|
pdfium, input, output, page_index, 500, &excl_px,
|
||||||
|
)?;
|
||||||
|
let _ = std::fs::remove_file(tmp);
|
||||||
|
if mm > 0.005 {
|
||||||
|
let _ = std::fs::remove_file(output);
|
||||||
|
return Err(EngineError::Pdfium(format!(
|
||||||
|
"sửa trong form làm lệch hiển thị NGOÀI vùng sửa ({:.1}% điểm ảnh) — đã huỷ để bảo toàn file (cấu trúc file chưa hỗ trợ)",
|
||||||
|
mm * 100.0
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
//! "PHẪU THUẬT" content stream của Form XObject bằng lopdf (Phase 4 iter 4b):
|
||||||
|
//! xoá đúng các op vẽ chữ (Tj/TJ/'/") của những text-child bị sửa — mọi byte
|
||||||
|
//! khác của form giữ NGUYÊN. Đây là đường duy nhất sửa nội dung trong form an
|
||||||
|
//! toàn: PDFium không ghi lại được stream form, còn generator ghi cả trang
|
||||||
|
//! thì lossy với file phức tạp (font subset trùng tên, ảnh inline — docs/12).
|
||||||
|
//!
|
||||||
|
//! Nguyên tắc map: parser PDFium tạo object theo ĐÚNG thứ tự op trong stream
|
||||||
|
//! → text-child thứ k của 1 form ↔ show-op thứ k trong stream form đó; form
|
||||||
|
//! con thứ n ↔ Do-trỏ-tới-form thứ n. Mỗi bước đều có BẤT BIẾN kiểm đếm
|
||||||
|
//! (số op == số object PDFium thấy) — lệch là từ chối ngay, không đoán mò.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use lopdf::content::{Content, Operation};
|
||||||
|
use lopdf::{Dictionary, Document as LoDoc, Object, ObjectId};
|
||||||
|
|
||||||
|
use crate::EngineError;
|
||||||
|
|
||||||
|
fn le<E: std::fmt::Display>(e: E) -> EngineError {
|
||||||
|
EngineError::Pdfium(format!("phẫu thuật form (lopdf): {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1 việc xoá text trong 1 form (đường đi từ trang xuống form trong cùng).
|
||||||
|
pub(crate) struct SurgeryJob {
|
||||||
|
/// Tại mỗi mức: (thứ tự FORM trong container, tổng số form PDFium thấy
|
||||||
|
/// trong container đó — bất biến kiểm đếm).
|
||||||
|
pub chain: Vec<(usize, usize)>,
|
||||||
|
/// Tổng số text-child PDFium thấy trong form trong cùng (bất biến).
|
||||||
|
pub expected_texts: usize,
|
||||||
|
/// Các show-op (thứ tự trong form trong cùng) cần xoá.
|
||||||
|
pub delete_ordinals: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_text_show(op: &str) -> bool {
|
||||||
|
matches!(op, "Tj" | "TJ" | "'" | "\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve reference (tối đa 8 nấc).
|
||||||
|
fn deref<'a>(doc: &'a LoDoc, mut o: &'a Object) -> &'a Object {
|
||||||
|
for _ in 0..8 {
|
||||||
|
match o {
|
||||||
|
Object::Reference(id) => match doc.get_object(*id) {
|
||||||
|
Ok(x) => o = x,
|
||||||
|
Err(_) => break,
|
||||||
|
},
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Id các XObject FORM theo đúng thứ tự Do trong `content` (chỉ đếm Do trỏ
|
||||||
|
/// tới stream /Subtype /Form qua `resources`).
|
||||||
|
fn form_dos_in_order(
|
||||||
|
doc: &LoDoc,
|
||||||
|
content: &Content,
|
||||||
|
resources: Option<&Dictionary>,
|
||||||
|
) -> Vec<ObjectId> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let xobjects = resources
|
||||||
|
.and_then(|r| r.get(b"XObject").ok())
|
||||||
|
.map(|x| deref(doc, x))
|
||||||
|
.and_then(|x| x.as_dict().ok());
|
||||||
|
let Some(xobjects) = xobjects else { return out };
|
||||||
|
for op in &content.operations {
|
||||||
|
if op.operator != "Do" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(Object::Name(name)) = op.operands.first() else { continue };
|
||||||
|
let Ok(entry) = xobjects.get(name) else { continue };
|
||||||
|
let Object::Reference(id) = entry else { continue };
|
||||||
|
let id = *id;
|
||||||
|
let Ok(obj) = doc.get_object(id) else { continue };
|
||||||
|
let Ok(stream) = obj.as_stream() else { continue };
|
||||||
|
let subtype = stream
|
||||||
|
.dict
|
||||||
|
.get(b"Subtype")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.as_name().ok());
|
||||||
|
if subtype == Some(b"Form") {
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// /Resources của 1 stream form (fallback: resources của container cha).
|
||||||
|
fn stream_resources<'a>(
|
||||||
|
doc: &'a LoDoc,
|
||||||
|
stream_id: ObjectId,
|
||||||
|
parent: Option<&'a Dictionary>,
|
||||||
|
) -> Option<&'a Dictionary> {
|
||||||
|
let stream = doc.get_object(stream_id).ok()?.as_stream().ok()?;
|
||||||
|
match stream.dict.get(b"Resources") {
|
||||||
|
Ok(r) => deref(doc, r).as_dict().ok().or(parent),
|
||||||
|
Err(_) => parent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Áp danh sách job xoá-text-trong-form lên `input`, ghi ra `output`.
|
||||||
|
pub(crate) fn delete_form_text_ops(
|
||||||
|
input: &Path,
|
||||||
|
page_index: u16,
|
||||||
|
jobs: &[SurgeryJob],
|
||||||
|
output: &Path,
|
||||||
|
) -> Result<(), EngineError> {
|
||||||
|
let mut doc = LoDoc::load(input).map_err(le)?;
|
||||||
|
let pages = doc.get_pages();
|
||||||
|
let page_id = *pages
|
||||||
|
.get(&(page_index as u32 + 1))
|
||||||
|
.ok_or_else(|| le(format!("không có trang {page_index}")))?;
|
||||||
|
|
||||||
|
// Content + resources CẤP TRANG (đủ để tìm Do form mức 1).
|
||||||
|
let page_content_bytes = doc.get_page_content(page_id).map_err(le)?;
|
||||||
|
let page_res_dict: Option<Dictionary> = {
|
||||||
|
let (res, ids) = doc.get_page_resources(page_id).map_err(le)?;
|
||||||
|
match res {
|
||||||
|
Some(d) => Some(d.clone()),
|
||||||
|
None => ids
|
||||||
|
.first()
|
||||||
|
.and_then(|id| doc.get_dictionary(*id).ok())
|
||||||
|
.cloned(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gom job theo form trong cùng: resolve chain → stream id (kèm kiểm đếm).
|
||||||
|
let mut per_stream: HashMap<ObjectId, (usize, Vec<usize>)> = HashMap::new();
|
||||||
|
for job in jobs {
|
||||||
|
let mut content = Content::decode(&page_content_bytes).map_err(le)?;
|
||||||
|
let mut res = page_res_dict.clone();
|
||||||
|
let mut stream_id: Option<ObjectId> = None;
|
||||||
|
for (level, &(ordinal, expected_forms)) in job.chain.iter().enumerate() {
|
||||||
|
let forms = form_dos_in_order(&doc, &content, res.as_ref());
|
||||||
|
if forms.len() != expected_forms {
|
||||||
|
return Err(le(format!(
|
||||||
|
"mức {level}: thấy {} Do-form trong stream nhưng PDFium thấy {} form — cấu trúc không khớp, từ chối phẫu thuật",
|
||||||
|
forms.len(),
|
||||||
|
expected_forms
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let id = *forms.get(ordinal).ok_or_else(|| {
|
||||||
|
le(format!("mức {level}: không có form thứ {ordinal}"))
|
||||||
|
})?;
|
||||||
|
let stream = doc
|
||||||
|
.get_object(id)
|
||||||
|
.map_err(le)?
|
||||||
|
.as_stream()
|
||||||
|
.map_err(le)?;
|
||||||
|
let bytes = stream.decompressed_content().map_err(le)?;
|
||||||
|
content = Content::decode(&bytes).map_err(le)?;
|
||||||
|
res = stream_resources(&doc, id, res.as_ref()).cloned();
|
||||||
|
stream_id = Some(id);
|
||||||
|
}
|
||||||
|
let id = stream_id.ok_or_else(|| le("chain rỗng"))?;
|
||||||
|
let entry = per_stream.entry(id).or_insert((job.expected_texts, Vec::new()));
|
||||||
|
if entry.0 != job.expected_texts {
|
||||||
|
return Err(le("job cùng form nhưng expected_texts khác nhau"));
|
||||||
|
}
|
||||||
|
entry.1.extend_from_slice(&job.delete_ordinals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Xoá show-op trong từng stream. Giữ hiệu ứng phụ về VỊ TRÍ của '/" :
|
||||||
|
// ' = T* + Tj → thay bằng T*; " = aw ac T* Tj → thay bằng Tw/Tc/T*.
|
||||||
|
for (stream_id, (expected_texts, ordinals)) in per_stream {
|
||||||
|
let bytes = {
|
||||||
|
let stream = doc
|
||||||
|
.get_object(stream_id)
|
||||||
|
.map_err(le)?
|
||||||
|
.as_stream()
|
||||||
|
.map_err(le)?;
|
||||||
|
stream.decompressed_content().map_err(le)?
|
||||||
|
};
|
||||||
|
let content = Content::decode(&bytes).map_err(le)?;
|
||||||
|
let total_shows = content
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.filter(|o| is_text_show(&o.operator))
|
||||||
|
.count();
|
||||||
|
if total_shows != expected_texts {
|
||||||
|
return Err(le(format!(
|
||||||
|
"form có {total_shows} show-op nhưng PDFium thấy {expected_texts} text object — cấu trúc không khớp, từ chối phẫu thuật"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let del: std::collections::HashSet<usize> = ordinals.into_iter().collect();
|
||||||
|
let mut new_ops: Vec<Operation> = Vec::with_capacity(content.operations.len());
|
||||||
|
let mut show_i = 0usize;
|
||||||
|
for op in content.operations.into_iter() {
|
||||||
|
if !is_text_show(&op.operator) {
|
||||||
|
new_ops.push(op);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let doomed = del.contains(&show_i);
|
||||||
|
show_i += 1;
|
||||||
|
if !doomed {
|
||||||
|
new_ops.push(op);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match op.operator.as_str() {
|
||||||
|
"Tj" | "TJ" => {} // bỏ hẳn — không có hiệu ứng phụ vị trí
|
||||||
|
"'" => new_ops.push(Operation::new("T*", vec![])),
|
||||||
|
"\"" => {
|
||||||
|
let mut operands = op.operands.into_iter();
|
||||||
|
if let (Some(aw), Some(ac)) = (operands.next(), operands.next()) {
|
||||||
|
new_ops.push(Operation::new("Tw", vec![aw]));
|
||||||
|
new_ops.push(Operation::new("Tc", vec![ac]));
|
||||||
|
}
|
||||||
|
new_ops.push(Operation::new("T*", vec![]));
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let encoded = Content { operations: new_ops }.encode().map_err(le)?;
|
||||||
|
let stream = doc
|
||||||
|
.get_object_mut(stream_id)
|
||||||
|
.map_err(le)?
|
||||||
|
.as_stream_mut()
|
||||||
|
.map_err(le)?;
|
||||||
|
stream.dict.remove(b"Filter");
|
||||||
|
stream.dict.remove(b"DecodeParms");
|
||||||
|
stream.set_content(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.save(output).map_err(le)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
pub mod annot;
|
pub mod annot;
|
||||||
pub mod convert;
|
pub mod convert;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
|
mod formsurgery;
|
||||||
pub(crate) mod fontmatch;
|
pub(crate) mod fontmatch;
|
||||||
pub mod form;
|
pub mod form;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
|
|||||||
@@ -127,6 +127,54 @@ pub fn page_render_mismatch(
|
|||||||
Ok(diff as f32 / total as f32)
|
Ok(diff as f32 / total as f32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Như [page_render_mismatch] nhưng BỎ QUA pixel trong các ô `exclude_px`
|
||||||
|
/// ((x0,y0,x1,y1) theo pixel ảnh render) — dùng cho sửa-trong-form: vùng khối
|
||||||
|
/// sửa được phép đổi, phần còn lại của trang phải y nguyên.
|
||||||
|
pub fn page_render_mismatch_masked(
|
||||||
|
pdfium: &Pdfium,
|
||||||
|
a: &Path,
|
||||||
|
b: &Path,
|
||||||
|
page_index: u16,
|
||||||
|
target_width: u32,
|
||||||
|
exclude_px: &[(u32, u32, u32, u32)],
|
||||||
|
) -> Result<f32, EngineError> {
|
||||||
|
let ia = render_page(pdfium, a, page_index, target_width, None)?.image.to_rgba8();
|
||||||
|
let ib = render_page(pdfium, b, page_index, target_width, None)?.image.to_rgba8();
|
||||||
|
if ia.dimensions() != ib.dimensions() {
|
||||||
|
return Ok(1.0);
|
||||||
|
}
|
||||||
|
let (w, h) = ia.dimensions();
|
||||||
|
let mut total = 0u64;
|
||||||
|
let mut diff = 0u64;
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
if exclude_px
|
||||||
|
.iter()
|
||||||
|
.any(|&(x0, y0, x1, y1)| x >= x0 && x < x1 && y >= y0 && y < y1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total += 1;
|
||||||
|
let pa = ia.get_pixel(x, y);
|
||||||
|
let pb = ib.get_pixel(x, y);
|
||||||
|
let d = pa
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.zip(pb.0.iter())
|
||||||
|
.map(|(p, q)| (*p as i16 - *q as i16).unsigned_abs())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
if d > 12 {
|
||||||
|
diff += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return Ok(0.0);
|
||||||
|
}
|
||||||
|
Ok(diff as f32 / total as f32)
|
||||||
|
}
|
||||||
|
|
||||||
/// Render một trang và ghi ra file PNG.
|
/// Render một trang và ghi ra file PNG.
|
||||||
pub fn render_page_png(
|
pub fn render_page_png(
|
||||||
pdfium: &Pdfium,
|
pdfium: &Pdfium,
|
||||||
|
|||||||
@@ -1038,7 +1038,8 @@ fn build_form_xobject_pdf(path: &std::path::Path) {
|
|||||||
"BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
"BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||||
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font_id } },
|
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font_id } },
|
||||||
},
|
},
|
||||||
b"BT /F1 24 Tf 72 700 Td (Hello inside form) Tj ET".to_vec(),
|
// 1 path đỏ + 2 dòng text — đủ để kiểm phẫu thuật giữ nguyên phần khác.
|
||||||
|
b"q 0.9 0.2 0.2 rg 40 40 100 20 re f Q BT /F1 24 Tf 72 700 Td (Hello inside form) Tj ET BT /F1 18 Tf 72 650 Td (Second line stays) Tj ET".to_vec(),
|
||||||
));
|
));
|
||||||
let content_id = doc.add_object(Stream::new(dictionary! {}, b"q /Fx1 Do Q".to_vec()));
|
let content_id = doc.add_object(Stream::new(dictionary! {}, b"q /Fx1 Do Q".to_vec()));
|
||||||
let page_id = doc.add_object(dictionary! {
|
let page_id = doc.add_object(dictionary! {
|
||||||
@@ -1207,18 +1208,89 @@ fn form_xobject_delete_after_flatten_clears_text() {
|
|||||||
assert!(!text.contains("Hello inside form"), "text phải biến mất sau xoá: {text:?}");
|
assert!(!text.contains("Hello inside form"), "text phải biến mất sau xoá: {text:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Op nhắm thẳng vào object trong form (chưa flatten) phải bị TỪ CHỐI rõ ràng
|
// SetText tại chỗ vào object trong form vẫn bị TỪ CHỐI rõ ràng (toolbar đổi
|
||||||
// (không âm thầm ghi ra bản không đổi).
|
// thuộc tính) — chỉ ReflowText/Delete đi đường phẫu thuật.
|
||||||
#[test]
|
#[test]
|
||||||
fn nested_target_without_flatten_is_rejected() {
|
fn nested_settext_is_rejected() {
|
||||||
let pdf = pdfium();
|
let pdf = pdfium();
|
||||||
let input = form_fixture("ff_edit_formx_reject.pdf");
|
let input = form_fixture("ff_edit_formx_reject.pdf");
|
||||||
let out = tmp("ff_edit_formx_reject_out.pdf");
|
let out = tmp("ff_edit_formx_reject_out.pdf");
|
||||||
let idx = find_text_index(&pdf, &input, "Hello inside form");
|
let idx = find_text_index(&pdf, &input, "Hello inside form");
|
||||||
|
|
||||||
let err = ff_engine::apply_edits(&pdf, &input, 0, &[EditOp::Delete { index: idx }], &out, None)
|
let err = ff_engine::apply_edits(
|
||||||
.expect_err("op vào object trong form phải bị từ chối");
|
&pdf,
|
||||||
assert!(format!("{err}").contains("mở gói"), "thông điệp phải nhắc flatten: {err}");
|
&input,
|
||||||
|
0,
|
||||||
|
&[EditOp::SetText {
|
||||||
|
index: idx,
|
||||||
|
text: "abc".into(),
|
||||||
|
font_size: None,
|
||||||
|
color: None,
|
||||||
|
font_family: None,
|
||||||
|
bold: None,
|
||||||
|
italic: None,
|
||||||
|
}],
|
||||||
|
&out,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect_err("SetText vào object trong form phải bị từ chối");
|
||||||
|
assert!(format!("{err}").contains("mở gói"), "thông điệp phải rõ: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHẪU THUẬT: xoá 1 dòng TRONG form không cần mở gói — dòng kia + path đỏ
|
||||||
|
// giữ nguyên, form còn nguyên là form, cổng an toàn (trong apply_edits) pass.
|
||||||
|
#[test]
|
||||||
|
fn form_xobject_surgical_delete() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let input = form_fixture("ff_edit_formx_surg_del.pdf");
|
||||||
|
let out = tmp("ff_edit_formx_surg_del_out.pdf");
|
||||||
|
let idx = find_text_index(&pdf, &input, "Hello inside form");
|
||||||
|
|
||||||
|
ff_engine::apply_edits(&pdf, &input, 0, &[EditOp::Delete { index: idx }], &out, None)
|
||||||
|
.expect("phẫu thuật xoá trong form");
|
||||||
|
|
||||||
|
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||||||
|
assert!(!text.contains("Hello inside form"), "dòng xoá phải biến mất: {text:?}");
|
||||||
|
assert!(text.contains("Second line stays"), "dòng còn lại phải nguyên: {text:?}");
|
||||||
|
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
|
||||||
|
assert!(
|
||||||
|
objs.iter().any(|o| o.kind == ObjectKind::XObjectForm),
|
||||||
|
"form phải CÒN NGUYÊN (không mở gói): {objs:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHẪU THUẬT: sửa cả đoạn TRONG form (reflow) — chữ cũ mất, chữ mới hiện,
|
||||||
|
// phần khác của form giữ nguyên.
|
||||||
|
#[test]
|
||||||
|
fn form_xobject_surgical_reflow() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let input = form_fixture("ff_edit_formx_surg_ref.pdf");
|
||||||
|
let out = tmp("ff_edit_formx_surg_ref_out.pdf");
|
||||||
|
let idx = find_text_index(&pdf, &input, "Hello inside form");
|
||||||
|
|
||||||
|
ff_engine::apply_edits(
|
||||||
|
&pdf,
|
||||||
|
&input,
|
||||||
|
0,
|
||||||
|
&[EditOp::ReflowText {
|
||||||
|
indices: vec![idx],
|
||||||
|
text: "Sửa trong form bằng phẫu thuật stream".into(),
|
||||||
|
}],
|
||||||
|
&out,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("phẫu thuật reflow trong form");
|
||||||
|
|
||||||
|
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||||||
|
let norm = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
assert!(norm.contains("Sửa trong form"), "chữ mới phải có mặt: {text:?}");
|
||||||
|
assert!(!norm.contains("Hello inside form"), "chữ cũ phải biến mất: {text:?}");
|
||||||
|
assert!(norm.contains("Second line stays"), "dòng khác phải nguyên: {text:?}");
|
||||||
|
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
|
||||||
|
assert!(
|
||||||
|
objs.iter().any(|o| o.kind == ObjectKind::XObjectForm),
|
||||||
|
"form phải còn nguyên: {objs:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cổng an toàn: mở gói fixture đơn giản phải giữ nguyên hiển thị (lệch ~0%).
|
// Cổng an toàn: mở gói fixture đơn giản phải giữ nguyên hiển thị (lệch ~0%).
|
||||||
|
|||||||
Reference in New Issue
Block a user