diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index ca42c23..e9ff891 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -1302,6 +1302,28 @@ fn pick_save_pem(app: tauri::AppHandle) -> Option { .map(|fp| fp.to_string()) } +/// "Mở gói" mọi Form XObject của trang ra cấp trang (file Canva/Illustrator +/// gói cả trang vào form — PDFium không ghi lại được stream form nên phải +/// flatten trước khi sửa). Trả None nếu trang không có form (không tạo file). +#[tauri::command] +fn edit_flatten_to_temp(input: String, page: u16, password: Option) -> Result, String> { + let pdfium = pdfium()?; + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let out = std::env::temp_dir().join(format!("ff_edit_flat_{}_{}.pdf", std::process::id(), nanos)); + let n = ff_engine::flatten_form_xobjects( + &pdfium, + std::path::Path::new(&input), + page, + &out, + password.as_deref(), + ) + .map_err(|e| e.to_string())?; + Ok(if n > 0 { Some(out.to_string_lossy().into_owned()) } else { None }) +} + /// 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] @@ -1362,6 +1384,7 @@ fn main() { edit_apply_to_temp, edit_preview, edit_cleanup, + edit_flatten_to_temp, redact_apply, security_encrypt, security_decrypt, diff --git a/app/src/main.js b/app/src/main.js index 9b358dc..2b2a212 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -39,6 +39,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) 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 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ở @@ -2054,6 +2055,7 @@ function enterEditMode(pageIndex) { state.editSel = null; state.editArm = null; state.editPendingImage = null; + state.editFlatBase = null; $("annobar").classList.add("hidden"); $("editBar").classList.remove("hidden"); $("viewport").classList.add("hidden"); @@ -2125,6 +2127,25 @@ 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; + return loadEditPage(); + } + } catch (e) { + $("editHint").textContent = "Không mở gói được form: " + e; + } + } $("editImg").src = url; state.editObjects = objs; buildEditOverlay(); @@ -2132,7 +2153,8 @@ async function loadEditPage() { } catch (e) { $("editHint").textContent = "Lỗi nạp trang sửa: " + e; } - $("edSave").disabled = state.editBase === state.path; // chưa có thay đổi nào + // 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; updateEditUndoButtons(); // Đúp chuột từ viewer thường (kiểu Foxit): vào thẳng ô sửa tại điểm đã đúp. diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index 3ce65af..44623f0 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -298,27 +298,94 @@ fn object_at_path<'a>(page: &PdfPage<'a>, path: &[u16]) -> Result, child_path: &[u16]) -> Result<(), EngineError> { - let err = |e: PdfiumError| EngineError::Pdfium(format!("rút object khỏi form: {e}")); - debug_assert!(child_path.len() >= 2); - let parent_path = &child_path[..child_path.len() - 1]; - let child_idx = *child_path.last().unwrap() as usize; - let mut parent = object_at_path(page, parent_path)?; - let form = match &mut parent { - PdfPageObject::XObjectForm(f) => f, - _ => return Err(EngineError::Pdfium("path cha không phải form".into())), - }; - let child = form.get(child_idx).map_err(err)?; - let removed = form.remove_object(child).map_err(err)?; - // Cùng bẫy Drop như remove ở cấp trang: object đã tách, forget để khỏi - // destroy nhầm (rò rỉ nhỏ, giải phóng khi đóng document). - std::mem::forget(removed); - Ok(()) +/// "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 +/// trận form vào con, chèn lại vào TRANG, xoá form rỗng. Hiển thị y hệt, +/// nhưng mọi object thành cấp trang — điều kiện để SỬA được. +/// +/// Vì sao bắt buộc: PDFium hiện KHÔNG ghi lại được stream của form — +/// `CPDF_PageContentManager` chỉ biết key /Contents (trang), còn form là +/// stream tự thân → mọi "regenerate" nội dung form rơi vào key /Contents vô +/// nghĩa trên dict form, stream thật giữ nguyên. Đã kiểm chứng bằng test +/// round-trip 3 vòng CI + đọc source cpdf_pagecontentmanager.cpp. +/// +/// Form lồng form: lặp tối đa 4 vòng (con là form sẽ thành cấp trang rồi +/// được mở tiếp). Trả về số form đã mở; 0 = trang không có form (KHÔNG ghi +/// `output`). +pub fn flatten_form_xobjects( + pdfium: &Pdfium, + input: &Path, + page_index: u16, + output: &Path, + password: Option<&str>, +) -> Result { + let mut document = pdfium + .load_pdf_from_file(input, password) + .map_err(|e| EngineError::Pdfium(e.to_string()))?; + let err = |e: PdfiumError| EngineError::Pdfium(format!("mở gói form: {e}")); + + let mut flattened = 0usize; + { + let mut page = document + .pages() + .get(page_index) + .map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?; + page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual); + + for _round in 0..4 { + let n = page.objects().len(); + let mut form_idxs: Vec = Vec::new(); + for i in 0..n { + if let Ok(o) = page.objects().get(i) { + if matches!(o, PdfPageObject::XObjectForm(_)) { + form_idxs.push(i); + } + } + } + if form_idxs.is_empty() { + break; + } + // Duyệt NGƯỢC: xoá form không làm lệch index các form đứng trước. + for &fi in form_idxs.iter().rev() { + let fm = { + let o = page.objects().get(fi).map_err(err)?; + o.matrix().map_err(err)? + }; + // Rút từng con (luôn con 0) → nhân ma trận form → chèn vào trang + // (chèn ở CUỐI nên không lệch index form đang xử lý). + loop { + let mut child = { + let mut parent = page.objects().get(fi).map_err(err)?; + let form = match &mut parent { + PdfPageObject::XObjectForm(f) => f, + _ => break, + }; + if form.is_empty() { + break; + } + let c = form.get(0).map_err(err)?; + form.remove_object(c).map_err(err)? + }; + child.apply_matrix(fm).map_err(err)?; + page.objects_mut().add_object(child).map_err(err)?; + } + let removed = + page.objects_mut().remove_object_at_index(fi).map_err(err)?; + // Bẫy Drop quen thuộc: object đã tách khỏi trang → forget. + std::mem::forget(removed); + flattened += 1; + } + } + if flattened > 0 { + page.regenerate_content().map_err(err)?; + } + } + if flattened > 0 { + document + .save_to_file(output) + .map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?; + } + Ok(flattened) } /// Kiểu chữ (đậm/nghiêng) của 1 text object, tổng hợp từ weight, italic-angle @@ -564,10 +631,15 @@ enum SetTextMode { /// 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), - /// Con TRONG Form XObject: sửa in-place không được LƯU (SetText trên con - /// không làm form dirty) → rút khỏi form + tạo lại ở CẤP TRANG, ưu tiên - /// giữ chính font object gốc (token) như tầng 0 của reflow. - NestedRecreate(ReflowFont), +} + +/// Op sửa nhắm vào object TRONG Form XObject → lỗi rõ ràng: PDFium không ghi +/// lại được stream form, mọi sửa trong form là vô nghĩa. UI phải "mở gói" +/// trang bằng `flatten_form_xobjects` trước khi sửa (loadEditPage tự làm). +fn nested_target_err() -> EngineError { + EngineError::Pdfium( + "object nằm trong Form XObject — trang chưa được mở gói (flatten) trước khi sửa".into(), + ) } /// Dữ liệu chụp lại từ 1 object trước khi xoá để tạo lại bản thay thế. @@ -647,114 +719,67 @@ pub fn apply_edits( if !valid(*index) { continue; } - let nested_obj = entries[*index as usize].path.len() > 1; - // Chụp mọi thứ cần thiết trong 1 block để NHẢ mượn trang - // trước khi probe font (probe cần &mut page). - struct SetTextInfo { - family: String, - bold: bool, - italic: bool, - old_text: String, - non_embedded: bool, - data_covers: bool, - token: PdfFontToken, - embedded_cover_bytes: Option>, + if entries[*index as usize].path.len() > 1 { + return Err(nested_target_err()); } - let info = { - let obj = object_at_path(&page, &entries[*index as usize].path)?; - match obj.as_text_object() { - Some(t) => { - let (family, bold, italic) = text_object_style(t); - let embedded = t.font().is_embedded().unwrap_or(false); - let data = t.font().data().ok(); - let data_covers = data - .as_deref() - .map_or(false, |b| fontmatch::coverage_ok(b, text)); - SetTextInfo { - family, - bold, - italic, - old_text: t.text(), - non_embedded: !embedded, - data_covers, - token: t.font().token(), - embedded_cover_bytes: if embedded && data_covers { - data - } else { - None - }, - } - } - None => continue, // không phải text → bỏ qua op - } + let obj = object_at_path(&page, &entries[*index as usize].path)?; + let t = match obj.as_text_object() { + Some(t) => t, + None => continue, // không phải text → bỏ qua op }; - let target_bold = bold.unwrap_or(info.bold); - let target_italic = italic.unwrap_or(info.italic); - let style_change = target_bold != info.bold || target_italic != info.italic; + 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(&info.family) + && fontmatch::normalize_key(f) != fontmatch::normalize_key(&cur_family) }); - // Font gốc có giữ được không (không xét in-place hay recreate)? - let old_chars: HashSet = info.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 ascii_only = text - .chars() - .all(|c| c.is_control() || (' '..='~').contains(&c)); - let keep_font_ok = !style_change - && !family_change - && (*text == info.old_text - || new_covered_by_old - || (info.non_embedded && ascii_only) - || info.data_covers); - - let mode = if keep_font_ok && !nested_obj { - SetTextMode::InPlace - } else if keep_font_ok && nested_obj { - // Giữ font gốc nhưng phải TẠO LẠI ở cấp trang: dùng lại - // chính font object (token) nếu probe ghi/đọc tròn trịa; - // hỏng thì nhúng lại bytes font; cuối cùng mới thay họ. - if probe_font_roundtrip(&mut page, info.token, text) { - SetTextMode::NestedRecreate(ReflowFont::Original(info.token)) - } else if let Some(bytes) = info.embedded_cover_bytes { - let key = ( - format!("emb:{}:{}", fontmatch::normalize_key(&info.family), index), - info.bold, - info.italic, - ); - font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes)); - SetTextMode::NestedRecreate(ReflowFont::Loaded(key)) + let mode = if !style_change && !family_change { + let old_text = t.text(); + let old_chars: HashSet = 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( - &info.family, + &cur_family, target_bold, target_italic, text, )?; font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes)); - SetTextMode::NestedRecreate(ReflowFont::Loaded(key)) + SetTextMode::Substitute(key) } } else { - // Thiếu glyph hoặc chủ động đổi family/kiểu → font thay thế. let family = font_family .clone() .filter(|f| !f.trim().is_empty()) - .unwrap_or(info.family); + .unwrap_or(cur_family); let (key, bytes) = resolve_substitute_font(&family, target_bold, target_italic, text)?; font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes)); - if nested_obj { - SetTextMode::NestedRecreate(ReflowFont::Loaded(key)) - } else { - SetTextMode::Substitute(key) - } + SetTextMode::Substitute(key) }; set_text_modes.insert(opi, mode); } @@ -769,6 +794,9 @@ pub fn apply_edits( let mut idxs: Vec = indices.iter().copied().filter(|i| valid(*i)).collect(); idxs.sort_unstable(); 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. struct RunGeo { idx: u16, @@ -816,10 +844,11 @@ pub fn apply_edits( // 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. { - // 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 { let entry = &entries[i as usize]; - if entry.kind != ObjectKind::Text { + if entry.kind != ObjectKind::Text || entry.path.len() > 1 { return None; } let obj = object_at_path(&page, &entry.path).ok()?; @@ -1122,10 +1151,7 @@ pub fn apply_edits( for (opi, op) in ops.iter().enumerate() { let idx = match op { EditOp::SetText { index, .. } - if matches!( - set_text_modes.get(&opi), - Some(SetTextMode::Substitute(_)) | Some(SetTextMode::NestedRecreate(_)) - ) => + if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) => { *index } @@ -1166,10 +1192,7 @@ pub fn apply_edits( for (opi, op) in ops.iter().enumerate() { match op { EditOp::SetText { index, .. } - if matches!( - set_text_modes.get(&opi), - Some(SetTextMode::Substitute(_)) | Some(SetTextMode::NestedRecreate(_)) - ) => + if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) => { to_remove.push(*index) } @@ -1187,39 +1210,14 @@ pub fn apply_edits( to_remove.retain(|i| valid(*i)); to_remove.sort_unstable(); to_remove.dedup(); - // Con TRONG form: RÚT THẬT khỏi form (FPDFFormObj_RemoveObject) — cách - // DUY NHẤT đánh dấu dirty_streams để stream form được ghi lại khi lưu - // (SetText/Transform trên con không làm form dirty). Thứ tự: path SÂU - // trước, cùng cha thì con GIẢM DẦN — rút 1 con làm các em sau nó trong - // cùng form tụt index, và rút ở nấc nông làm lệch path xuyên qua form. - // Làm TRƯỚC khi xoá cấp trang để nấc đầu path (index trang) còn đúng. - // Con không phải text (ảnh/path trong form): v1 bỏ qua. - let mut nested_paths: Vec<&Vec> = to_remove - .iter() - .filter(|&&i| is_nested(i) && entries[i as usize].kind == ObjectKind::Text) - .map(|&i| &entries[i as usize].path) - .collect(); - nested_paths.sort_by(|a, b| b.len().cmp(&a.len()).then(b.cmp(a))); - let mut touched_roots: Vec = nested_paths.iter().map(|p| p[0]).collect(); - for path in nested_paths { - remove_form_child(&page, path)?; - } - // ĐÁNH THỨC trang: GenerateContent chỉ ghi lại stream trang khi có - // object CẤP TRANG dirty; rút con mới làm FORM dirty thôi — op thuần - // trong form (Delete...) không đụng cấp trang → trang không regenerate - // → ProcessForm (nơi ghi lại form) không bao giờ chạy. Nhân identity - // vào form tổ tiên cấp trang: Transform luôn SetDirty, giá trị không - // đổi → trang được ghi lại → form được ghi lại theo. - touched_roots.sort_unstable(); - touched_roots.dedup(); - for r in touched_roots { - let mut root = object_at_path(&page, &[r])?; - root.apply_matrix(PdfMatrix::IDENTITY).map_err(err)?; + // Op xoá nhắm vào object TRONG form: không xử lý được (PDFium không + // ghi lại stream form) — trang phải được flatten trước, lỗi rõ ràng. + if to_remove.iter().any(|&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 = to_remove .iter() - .filter(|&&i| !is_nested(i)) .map(|&i| entries[i as usize].path[0]) .collect(); page_idxs.sort_unstable(); @@ -1237,27 +1235,17 @@ pub fn apply_edits( for (opi, op) in ops.iter().enumerate() { match op { EditOp::SetText { index, text, font_size, color, .. } => { - // Substitute (đổi font) hoặc NestedRecreate (con trong form, - // giữ font gốc qua token) — đều tạo lại object ở cấp trang - // với matrix ĐÃ COMPOSE từ pha chụp. - let token = match set_text_modes.get(&opi) { - Some(SetTextMode::Substitute(key)) => { - tokens.get(key).copied().ok_or_else(|| { - EngineError::Pdfium("thiếu token font cho SetText".into()) - })? - } - Some(SetTextMode::NestedRecreate(ReflowFont::Original(tok))) => *tok, - Some(SetTextMode::NestedRecreate(ReflowFont::Loaded(key))) => { - tokens.get(key).copied().ok_or_else(|| { - EngineError::Pdfium("thiếu token font cho SetText".into()) - })? - } + 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(key).copied().ok_or_else(|| { + EngineError::Pdfium("thiếu token font cho SetText".into()) + })?; // 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 { diff --git a/crates/ff-engine/src/lib.rs b/crates/ff-engine/src/lib.rs index 85899a8..7d09b8c 100644 --- a/crates/ff-engine/src/lib.rs +++ b/crates/ff-engine/src/lib.rs @@ -22,7 +22,7 @@ pub mod watermark; pub use annot::{ apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec, }; -pub use edit::{apply_edits, list_objects, EditOp, ObjectInfo, ObjectKind}; +pub use edit::{apply_edits, flatten_form_xobjects, list_objects, EditOp, ObjectInfo, ObjectKind}; pub use meta::{outline, page_dims, strip_metadata, OutlineItem, PageDim}; pub use organize::{ build_document, delete_pages, extract_pages, identity_plan, merge_files, rotate_pages, diff --git a/crates/ff-engine/tests/edit_roundtrip.rs b/crates/ff-engine/tests/edit_roundtrip.rs index bc39a67..8a3a42e 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -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}"); } diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index 94a27d1..2920eb7 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -23,28 +23,28 @@ Type0/CID nhúng), trong khi `list_objects` chỉ duyệt object cấp trang. thị nhân `mat_vscale(acc)` (độ dài ảnh vector đơn vị Y). - **ObjectInfo thêm `nested`** (con trong form — sửa text OK, kéo/resize chưa) **+ `expanded`** (form đã liệt kê con — UI bỏ khung form để khỏi che con). -- **Áp op cho con trong form — bài học đắt giá từ CI vòng 1**: `dirty_streams_` - của form CHỈ được đánh dấu bởi `RemovePageObject`/`InsertPageObjectAtIndex` - trên CHÍNH form (xem cpdf_pageobjectholder.cpp) — **`FPDFText_SetText` trên - con KHÔNG làm form dirty → sửa in-place trong form không bao giờ được ghi - ra file** (test canary bắt được đúng lỗi này: extract sau lưu vẫn thấy text - cũ). Cách làm đúng: mọi sửa/xoá con trong form = **RÚT THẬT khỏi form qua - `FPDFFormObj_RemoveObject`** (đánh dấu dirty → `ProcessForm` regenerate - stream form khi lưu) **+ với SetText thì tạo lại ở CẤP TRANG** giữ font gốc - qua token (probe như tầng 0 reflow; thiếu glyph → nhúng lại bytes/cùng họ) - với matrix đã compose (`SetTextMode::NestedRecreate`). Thứ tự rút: path sâu - trước, cùng cha con lớn trước (rút làm tụt index các em). Transform - (kéo/resize) con trong form: bỏ qua, UI chặn kéo. -- **Đổi feature pdfium-render `pdfium_latest`(7543) → `pdfium_7350`** (cả - ff-engine lẫn app): wrapper 0.8.37 gate `remove_object` của form chỉ mở cho - 7350/future dù binding có đủ cho 7543 (sót gate upstream); API 7350 là - subset của DLL bblanchon mới → không mất gì (đã quét: 0 hàm 7543-only). -- **Bài học CI vòng 2 — "đánh thức" trang**: `GenerateContent` chỉ ghi lại - stream trang khi có object CẤP TRANG dirty; rút con chỉ làm FORM dirty → - op thuần trong form (Delete) không kích hoạt gì → `ProcessForm` không chạy. - Fix: sau khi rút con, nhân identity matrix vào form tổ tiên cấp trang - (Transform luôn SetDirty, giá trị không đổi) → trang regenerate → form - regenerate theo. +- **KẾT LUẬN QUAN TRỌNG NHẤT (3 vòng CI + đọc source PDFium): PDFium hiện + KHÔNG ghi lại được stream của Form XObject.** Chuỗi chứng cứ: + 1. `FPDFText_SetText` trên con không làm form dirty (`dirty_streams_` chỉ + được đánh dấu bởi Remove/Insert trên chính form — cpdf_pageobjectholder.cpp); + 2. rút con bằng `FPDFFormObj_RemoveObject` + làm trang dirty → `ProcessForm` + CÓ chạy generator cho form, NHƯNG `CPDF_PageContentManager` (nơi ghi kết + quả) chỉ biết key `/Contents` — form là stream TỰ THÂN, không có + /Contents → content mới rơi vào key /Contents vô nghĩa trên dict form, + stream thật giữ nguyên (cpdf_pagecontentmanager.cpp, constructor + + AddStream). Test round-trip bắt đúng triệu chứng: extract sau lưu vẫn + thấy text cũ. +- **Giải pháp cuối: MỞ GÓI (flatten) form ra cấp trang** — + `flatten_form_xobjects`: rút từng con khỏi form (`FPDFFormObj_RemoveObject` + — cần đổi feature pdfium-render `pdfium_latest`→`pdfium_7350` vì wrapper + 0.8.37 sót gate; đã quét 0 hàm 7543-only bị mất), 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 (đã kiểm vị trí/nội dung), mọi object thành cấp trang → sửa + bằng đường page-level đã được test kỹ từ iteration 1-3. UI: `loadEditPage` + thấy object `nested` → gọi `edit_flatten_to_temp` một lần rồi nạp lại + (bản mở gói không tính là "có thay đổi" cho tới khi sửa thật). +- Op nhắm vào object trong form khi CHƯA flatten → engine trả lỗi rõ ràng + ("trang chưa được mở gói") thay vì âm thầm ghi bản không đổi — có test. - CI test chạy `--test-threads=1`: PDFium serialize qua 1 mutex toàn cục — 1 test panic (kể cả panic Ở ASSERT khi instance Pdfium còn sống — unwind drop guard giữa panic) sẽ poison mutex, mọi test SAU đó trong cùng process @@ -60,17 +60,17 @@ Type0/CID nhúng), trong khi `list_objects` chỉ duyệt object cấp trang. - Chặn kéo di chuyển dòng chứa run `nested` (engine chưa hỗ trợ Transform trong form) — vẫn chọn/sửa text/xoá bình thường. -### Test (4 mới — `edit_roundtrip.rs`, fixture tự dựng bằng lopdf) +### Test (6 — `edit_roundtrip.rs`, fixture tự dựng bằng lopdf) - Fixture: PDF 1 trang, chữ "Hello inside form" Helvetica 24pt nằm TRONG Form XObject (trang chỉ có 1 object form) — đúng cấu trúc Canva. - `form_xobject_children_are_listed` — thấy text nested, form expanded, rect quy về trang đúng chỗ (72, ~700), cỡ ~24. -- `form_xobject_settext_inplace_roundtrip` — **canary quan trọng nhất**: sửa - in-place con trong form → lưu → text mới có mặt, text cũ biến mất (chứng - minh PDFium regenerate stream form). -- `form_xobject_reflow_roundtrip` — reflow tiếng Việt trong form: run cũ làm - rỗng, dòng mới ở cấp trang quanh khối cũ. -- `form_xobject_delete_clears_text` — xoá = text biến mất khỏi extract. +- `flatten_form_xobjects_unwraps_page` — mở gói: hết form, text thành cấp + trang, vị trí giữ nguyên, nội dung không đổi. +- `form_xobject_settext_after_flatten_roundtrip` / `..._reflow_...` / + `..._delete_...` — sửa/reflow tiếng Việt/xoá SAU flatten round-trip chuẩn. +- `nested_target_without_flatten_is_rejected` — op vào object trong form khi + chưa flatten bị từ chối với thông điệp rõ. ### CI chạy test engine Máy dev không có Rust nên **CI là nơi duy nhất chạy test**: workflow thêm @@ -78,12 +78,12 @@ bước `cargo test -p ff-engine` TRƯỚC khi build release (fail test = fail build, không publish bản hỏng); cache thêm `target/` gốc workspace. ### Giới hạn ghi nhận (iteration 4 v1) -- Chưa di chuyển/resize object trong form (Transform bị bỏ qua, UI chặn kéo). -- Chưa sửa/thay ẢNH trong form (khung overlay ẩn). -- "Xoá" text trong form để lại text object rỗng trong form (vô hại, không - render); khi wrapper mở `FPDFFormObj_RemoveObject` cho pdfium_7543 sẽ xoá - thật. -- Form lồng sâu >3 cấp hoặc >4000 object con: phần vượt không liệt kê. +- Flatten làm MẤT clip path/ExtGState/transparency group đặt Ở MỨC FORM + (file Canva wrapper toàn trang thường không có — hiển thị y hệt; file có + clip ở form sẽ khác biệt, sẽ xử lý khi gặp thật). +- Cấu trúc file sau khi SỬA thay đổi (form được mở ra trang) — nội dung và + hiển thị giữ nguyên; chỉ xảy ra khi người dùng thật sự lưu thay đổi. +- Form lồng sâu >4 vòng mở hoặc >4000 object: phần vượt giữ nguyên trong form. > Các iteration trước: 52/52 test engine xanh ngoài qpdf (17 test edit + 6 > unit fontmatch + 29 test cũ) — nay thêm 4 test form + 2 unit sfnt style.