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
+79 -7
View File
@@ -1038,7 +1038,8 @@ fn build_form_xobject_pdf(path: &std::path::Path) {
"BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"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 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:?}");
}
// Op nhắm thẳng vào object trong form (chưa flatten) phải bị TỪ CHỐI rõ ràng
// (không âm thầm ghi ra bản không đổi).
// SetText tại chỗ vào object trong form vẫn bị TỪ CHỐI rõ ràng (toolbar đổi
// thuộc tính) — chỉ ReflowText/Delete đi đường phẫu thuật.
#[test]
fn nested_target_without_flatten_is_rejected() {
fn nested_settext_is_rejected() {
let pdf = pdfium();
let input = form_fixture("ff_edit_formx_reject.pdf");
let out = tmp("ff_edit_formx_reject_out.pdf");
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)
.expect_err("op vào object trong form phải bị từ chối");
assert!(format!("{err}").contains("mở gói"), "thông điệp phải nhắc flatten: {err}");
let err = ff_engine::apply_edits(
&pdf,
&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%).