Form XObject vòng 4 — kiến trúc cuối: MỞ GÓI form ra cấp trang trước khi sửa
Build Windows portable / build (push) Canceled after 0s

Kết luận sau 3 vòng CI + đọc nguyên văn source PDFium: PDFium hiện KHÔNG ghi
lại được stream của Form XObject — CPDF_PageContentManager chỉ biết key
/Contents (trang), form là stream tự thân nên nội dung regenerate rơi vào
key /Contents vô nghĩa trên dict form, stream thật giữ nguyên. Mọi cách sửa
"trong form" đều không thể lưu.

- Engine mới: flatten_form_xobjects — rút từng con khỏi form
  (FPDFFormObj_RemoveObject), nhân ma trận form vào con, chèn lại vào TRANG,
  xoá form rỗng; lặp <=4 vòng cho form lồng form. Hiển thị y hệt, mọi object
  thành cấp trang, sửa bằng đường page-level đã test kỹ.
- Tauri: command edit_flatten_to_temp; UI loadEditPage thấy object nested →
  tự mở gói một lần rồi nạp lại (bản mở gói không tính là "có thay đổi").
- apply_edits: gỡ toàn bộ đường sửa-trong-form (NestedRecreate, rút con,
  đánh thức trang — đã chứng minh không thể lưu); op nhắm vào object trong
  form khi chưa flatten → lỗi rõ "trang chưa được mở gói" (có test).
- Test viết lại theo flow flatten: 6 test (liệt kê nested, flatten giữ vị
  trí/nội dung, settext/reflow/delete sau flatten, từ chối op nested).
Docs 12-phase4-summary.md cập nhật kết luận + giới hạn (mất clip/ExtGState
mức form khi flatten; cấu trúc file đổi khi lưu).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 02:26:30 +07:00
co-authored by Claude Fable 5
parent 1b3cf15664
commit d4b09e0ed8
6 changed files with 306 additions and 224 deletions
+70 -21
View File
@@ -1086,18 +1086,47 @@ fn form_xobject_children_are_listed() {
assert!((text.font_size.unwrap_or(0.0) - 24.0).abs() < 2.0, "cỡ hiển thị ~24: {:?}", text.font_size);
}
// Canary quan trọng nhất: sửa IN-PLACE con trong form rồi lưu — PDFium phải
// regenerate content stream của form (ProcessForm → HasDirtyStreams).
// PDFium KHÔNG ghi lại được stream của Form XObject (CPDF_PageContentManager
// chỉ biết /Contents của trang) → muốn sửa phải MỞ GÓI form ra cấp trang.
#[test]
fn form_xobject_settext_inplace_roundtrip() {
fn flatten_form_xobjects_unwraps_page() {
let pdf = pdfium();
let input = form_fixture("ff_edit_formx_flatten.pdf");
let out = tmp("ff_edit_formx_flatten_out.pdf");
let n = ff_engine::flatten_form_xobjects(&pdf, &input, 0, &out, None).expect("flatten");
assert_eq!(n, 1, "phải mở đúng 1 form");
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
assert!(
objs.iter().all(|o| o.kind != ObjectKind::XObjectForm),
"không còn form nào: {objs:?}"
);
let t = objs
.iter()
.find(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|s| s.contains("Hello inside form")).unwrap_or(false))
.unwrap_or_else(|| panic!("text phải thành cấp trang: {objs:?}"));
assert!(!t.nested, "text sau flatten phải hết nested: {t:?}");
// Vị trí giữ nguyên (baseline 700, lề 72) và render vẫn ra chữ.
assert!(t.rect.left > 60.0 && t.rect.left < 84.0, "left ~72: {:?}", t.rect);
assert!(t.rect.bottom > 680.0 && t.rect.top < 740.0, "bbox quanh y=700: {:?}", t.rect);
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Hello inside form"), "nội dung không đổi: {text:?}");
}
// Sau flatten, sửa text (giữ font base-14) round-trip như trang thường.
#[test]
fn form_xobject_settext_after_flatten_roundtrip() {
let pdf = pdfium();
let input = form_fixture("ff_edit_formx_settext.pdf");
let flat = tmp("ff_edit_formx_settext_flat.pdf");
let out = tmp("ff_edit_formx_settext_out.pdf");
let idx = find_text_index(&pdf, &input, "Hello inside form");
ff_engine::flatten_form_xobjects(&pdf, &input, 0, &flat, None).expect("flatten");
let idx = find_text_index(&pdf, &flat, "Hello inside form");
ff_engine::apply_edits(
&pdf,
&input,
&flat,
0,
&[EditOp::SetText {
index: idx,
@@ -1111,25 +1140,26 @@ fn form_xobject_settext_inplace_roundtrip() {
&out,
None,
)
.expect("apply_edits SetText trong form");
.expect("apply_edits SetText sau flatten");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Edited in form!"), "text mới phải có mặt: {text:?}");
assert!(!text.contains("Hello inside form"), "text cũ trong form phải biến mất: {text:?}");
assert!(!text.contains("Hello inside form"), "text cũ phải biến mất: {text:?}");
}
// Reflow đoạn trong form: run cũ bị làm rỗng (không rút được object khỏi
// form), dòng mới vẽ ở cấp trang với toạ độ đã compose ma trận form.
// Reflow tiếng Việt sau flatten: bẻ dòng quanh vị trí khối cũ.
#[test]
fn form_xobject_reflow_roundtrip() {
fn form_xobject_reflow_after_flatten_roundtrip() {
let pdf = pdfium();
let input = form_fixture("ff_edit_formx_reflow.pdf");
let flat = tmp("ff_edit_formx_reflow_flat.pdf");
let out = tmp("ff_edit_formx_reflow_out.pdf");
let idx = find_text_index(&pdf, &input, "Hello inside form");
ff_engine::flatten_form_xobjects(&pdf, &input, 0, &flat, None).expect("flatten");
let idx = find_text_index(&pdf, &flat, "Hello inside form");
ff_engine::apply_edits(
&pdf,
&input,
&flat,
0,
&[EditOp::ReflowText {
indices: vec![idx],
@@ -1138,12 +1168,11 @@ fn form_xobject_reflow_roundtrip() {
&out,
None,
)
.expect("apply_edits ReflowText trong form");
.expect("apply_edits ReflowText sau flatten");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Đoạn văn tiếng Việt thay thế"), "text mới phải có mặt: {text:?}");
assert!(!text.contains("Hello inside form"), "text cũ phải biến mất: {text:?}");
// Dòng mới phải nằm quanh vị trí khối cũ (không rơi về góc trang).
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let new_runs: Vec<_> = objs
.iter()
@@ -1151,21 +1180,41 @@ fn form_xobject_reflow_roundtrip() {
.collect();
assert!(!new_runs.is_empty(), "phải có run mới: {objs:?}");
for r in &new_runs {
assert!(r.rect.left > 50.0 && r.rect.top < 740.0 && r.rect.bottom > 500.0,
"dòng mới phải quanh khối cũ: {:?}", r.rect);
assert!(
r.rect.left > 50.0 && r.rect.top < 740.0 && r.rect.bottom > 500.0,
"dòng mới phải quanh khối cũ: {:?}",
r.rect
);
}
}
// Xoá sau flatten: text biến mất thật khỏi file lưu ra.
#[test]
fn form_xobject_delete_clears_text() {
fn form_xobject_delete_after_flatten_clears_text() {
let pdf = pdfium();
let input = form_fixture("ff_edit_formx_delete.pdf");
let flat = tmp("ff_edit_formx_delete_flat.pdf");
let out = tmp("ff_edit_formx_delete_out.pdf");
let idx = find_text_index(&pdf, &input, "Hello inside form");
ff_engine::flatten_form_xobjects(&pdf, &input, 0, &flat, None).expect("flatten");
let idx = find_text_index(&pdf, &flat, "Hello inside form");
ff_engine::apply_edits(&pdf, &input, 0, &[EditOp::Delete { index: idx }], &out, None)
.expect("apply_edits Delete trong form");
ff_engine::apply_edits(&pdf, &flat, 0, &[EditOp::Delete { index: idx }], &out, None)
.expect("apply_edits Delete sau flatten");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(!text.contains("Hello inside form"), "text trong form 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
// (không âm thầm ghi ra bản không đổi).
#[test]
fn nested_target_without_flatten_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}");
}