From fd731255acb695b72409e695075fded6e2dd854c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Nam?= Date: Sat, 8 Aug 2026 02:00:10 +0700 Subject: [PATCH] =?UTF-8?q?Form=20XObject=20v=C3=B2ng=202:=20r=C3=BAt=20ob?= =?UTF-8?q?ject=20kh=E1=BB=8Fi=20form=20th=E1=BA=ADt=20s=E1=BB=B1=20?= =?UTF-8?q?=E2=80=94=20s=E1=BB=ADa=20trong=20form=20m=E1=BB=9Bi=20=C4=91?= =?UTF-8?q?=C6=B0=E1=BB=A3c=20L=C6=AFU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI vòng 1 bắt đúng lỗi thiết kế: FPDFText_SetText trên con trong form KHÔNG đánh dấu dirty_streams của form (chỉ RemovePageObject/InsertPageObjectAtIndex mới làm — xem cpdf_pageobjectholder.cpp) nên mọi sửa in-place trong form không bao giờ được ghi ra file. Test canary fail chính xác ở điểm này. - Đổi feature pdfium-render pdfium_latest(7543) -> pdfium_7350 (cả ff-engine lẫn app): mở remove_object cho Form XObject qua blanket impl PdfPageObjectsCommon (wrapper 0.8.37 sót gate 7543 dù binding có đủ); API 7350 là subset của DLL bblanchon mới, đã quét 0 hàm 7543-only. - Mọi sửa/xoá con trong form = RÚT THẬT khỏi form (FPDFFormObj_RemoveObject, thứ tự path sâu trước + cùng cha con lớn trước) -> form dirty -> ProcessForm regenerate stream khi lưu. SetText con trong form = SetTextMode mới NestedRecreate: rút khỏi form + 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ọ), matrix đã compose. - CI test thêm --test-threads=1: PDFium serialize qua 1 mutex toàn cục, 1 test panic khi giữ lock poison mutex làm 17 test song song chết oan. Docs cập nhật theo (12-phase4-summary.md Iteration 4). Co-Authored-By: Claude Fable 5 --- .github/workflows/build-windows.yml | 4 +- README.md | 2 +- app/src-tauri/Cargo.toml | 3 +- crates/ff-engine/Cargo.toml | 6 +- crates/ff-engine/src/edit.rs | 214 ++++++++++++++++++++-------- docs/12-phase4-summary.md | 31 ++-- 6 files changed, 183 insertions(+), 77 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 2f42be1..75232d8 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -43,8 +43,10 @@ jobs: # Máy dev không có Rust — CI là nơi duy nhất chạy test engine. # Fail test = fail build, không publish bản hỏng. + # --test-threads=1: PDFium serialize qua 1 mutex toàn cục; 1 test panic + # khi đang giữ lock sẽ poison mutex làm mọi test song song chết oan. - name: Run engine tests - run: cargo test -p ff-engine + run: cargo test -p ff-engine -- --test-threads=1 - name: Build release working-directory: app/src-tauri diff --git a/README.md b/README.md index a70efce..cb87181 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ - [x] **Iteration 2 — GIỮ FONT khi sửa (chuẩn Foxit)**: sửa tại chỗ giữ nguyên font gốc/nhúng (kể cả tiếng Việt); thiếu glyph mới thay font CÙNG HỌ (`fontmatch.rs`); đổi cỡ/màu không đụng font (fix bug phóng đại kép); sửa CẢ DÒNG (gộp run); WYSIWYG khi gõ; B/I + đổi font family; kéo-thả live; dọn file tạm - [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng - [x] **Đúp chuột từ viewer thường (chuẩn Foxit)**: đang xem, đúp vào chữ → tự vào chế độ sửa đúng trang + mở ngay ô sửa tại đoạn đó; đúp vùng trống không làm gì - - [x] **Iteration 4 — Sửa text TRONG Form XObject**: file Canva/Illustrator gói cả trang vào form giờ sửa được (liệt kê đệ quy + quy toạ độ về trang; xoá trong form = set text rỗng; CI chạy `cargo test` trước khi build) + - [x] **Iteration 4 — Sửa text TRONG Form XObject**: file Canva/Illustrator gói cả trang vào form giờ sửa được (liệt kê đệ quy + quy toạ độ về trang; sửa/xoá = rút object khỏi form — cách duy nhất làm form được ghi lại khi lưu; CI chạy `cargo test` trước khi build) - [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md) - **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30) - [x] **Phase 5 — Bảo mật** ✅ Iteration 1 + 2 hoàn tất (62/62 test engine xanh) diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 8943cd4..ac45c1a 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -18,7 +18,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" image = "0.25" -pdfium-render = "0.8" +# Đồng bộ feature với ff-engine (xem chú thích ở crates/ff-engine/Cargo.toml). +pdfium-render = { version = "0.8", default-features = false, features = ["pdfium_7350", "thread_safe", "image"] } lopdf = "0.36" ff-engine = { path = "../../crates/ff-engine" } diff --git a/crates/ff-engine/Cargo.toml b/crates/ff-engine/Cargo.toml index 5cdc00a..68a5d68 100644 --- a/crates/ff-engine/Cargo.toml +++ b/crates/ff-engine/Cargo.toml @@ -10,7 +10,11 @@ ff-cos = { path = "../ff-cos" } ff-pdmodel = { path = "../ff-pdmodel" } anyhow.workspace = true thiserror.workspace = true -pdfium-render = "0.8" +# default-features tắt để chọn pdfium_7350 thay vì pdfium_latest(7543): +# wrapper 0.8.37 chỉ mở FPDFFormObj_RemoveObject (rút object khỏi Form +# XObject — cần cho sửa file Canva) với gate 7350/future dù binding có cả +# 7543; DLL bblanchon mới hơn là superset của API 7350 nên tương thích. +pdfium-render = { version = "0.8", default-features = false, features = ["pdfium_7350", "thread_safe", "image"] } image = "0.25" lopdf = "0.36" ttf-parser = "0.25" diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index d09a873..417cb50 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -298,6 +298,29 @@ 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(()) +} + /// Kiểu chữ (đậm/nghiêng) của 1 text object, tổng hợp từ weight, italic-angle /// và tên font. Family lấy từ `name()` (BaseFont khai báo trong PDF) — với /// font KHÔNG nhúng, `family()` trả tên font stub nội bộ của PDFium ("Chrom @@ -541,6 +564,10 @@ 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), } /// Dữ liệu chụp lại từ 1 object trước khi xoá để tạo lại bản thay thế. @@ -620,64 +647,114 @@ pub fn apply_edits( if !valid(*index) { continue; } - 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 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>, + } + 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 (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 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 family_change = font_family.as_deref().map_or(false, |f| { !f.trim().is_empty() - && fontmatch::normalize_key(f) != fontmatch::normalize_key(&cur_family) + && fontmatch::normalize_key(f) != fontmatch::normalize_key(&info.family) }); - 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 + // 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)) } else { let (key, bytes) = resolve_substitute_font( - &cur_family, + &info.family, target_bold, target_italic, text, )?; font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes)); - SetTextMode::Substitute(key) + SetTextMode::NestedRecreate(ReflowFont::Loaded(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(cur_family); + .unwrap_or(info.family); let (key, bytes) = resolve_substitute_font(&family, target_bold, target_italic, text)?; font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes)); - SetTextMode::Substitute(key) + if nested_obj { + SetTextMode::NestedRecreate(ReflowFont::Loaded(key)) + } else { + SetTextMode::Substitute(key) + } }; set_text_modes.insert(opi, mode); } @@ -1045,7 +1122,10 @@ 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(_))) => + if matches!( + set_text_modes.get(&opi), + Some(SetTextMode::Substitute(_)) | Some(SetTextMode::NestedRecreate(_)) + ) => { *index } @@ -1086,7 +1166,10 @@ 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(_))) => + if matches!( + set_text_modes.get(&opi), + Some(SetTextMode::Substitute(_)) | Some(SetTextMode::NestedRecreate(_)) + ) => { to_remove.push(*index) } @@ -1104,22 +1187,21 @@ pub fn apply_edits( to_remove.retain(|i| valid(*i)); to_remove.sort_unstable(); to_remove.dedup(); - // Con TRONG form: chưa rút object ra được (FPDFFormObj_RemoveObject - // chưa mở ở wrapper) → "xoá" = set text RỖNG — glyph biến mất, PDFium - // regenerate stream form khi lưu (ProcessForm → HasDirtyStreams). - // Làm TRƯỚC khi xoá object cấp trang để path (nấc đầu = index trang) - // còn đúng. Con không phải text (ảnh/path trong form): v1 bỏ qua. - for &i in to_remove.iter().filter(|&&i| is_nested(i)) { - let entry = &entries[i as usize]; - if entry.kind != ObjectKind::Text { - continue; - } - let mut obj = object_at_path(&page, &entry.path)?; - if let Some(t) = obj.as_text_object_mut() { - if t.set_text("").is_err() { - let _ = t.set_text(" "); - } - } + // 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))); + for path in nested_paths { + remove_form_child(&page, path)?; } // Cấp trang: xoá thật theo index trang GIẢM DẦN. let mut page_idxs: Vec = to_remove @@ -1142,17 +1224,27 @@ pub fn apply_edits( for (opi, op) in ops.iter().enumerate() { match op { EditOp::SetText { index, text, font_size, color, .. } => { - let key = match set_text_modes.get(&opi) { - Some(SetTextMode::Substitute(key)) => key, + // 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()) + })? + } _ => 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/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index 4c5efad..6b9fe5d 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -23,18 +23,25 @@ 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**: SetText in-place resolve theo path (đổi cỡ: - so cỡ theo KHÔNG GIAN TRANG — target × vscale); SetText-substitute và - ReflowText tạo object mới ở CẤP TRANG với matrix/toạ độ đã compose; - **"xoá" con trong form = `set_text("")`** (glyph biến mất) vì - `FPDFFormObj_RemoveObject` chưa mở ở wrapper cho pdfium_7543 — làm TRƯỚC - khi xoá object cấp trang để path còn đúng. Transform (kéo/resize) con - trong form: bỏ qua, UI chặn kéo. -- **Điểm sống còn đã xác minh trước khi code**: PDFium (bản mới, bblanchon - latest) REGENERATE content stream của form khi con bị sửa — - `CPDF_PageContentGenerator::ProcessForm` → `HasDirtyStreams` → chạy - generator đệ quy cho form. Không có cái này thì sửa xong lưu ra vẫn thấy - chữ cũ. +- **Á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). +- CI test chạy `--test-threads=1`: PDFium serialize qua 1 mutex toàn cục — + 1 test panic khi giữ lock sẽ poison mutex, 17 test song song chết oan + (đã dính ở CI vòng 1). - Vá kèm: đọc source crate pdfium-render tải từ crates.io để chốt API vì máy dev không compile được Rust (xem mục CI bên dưới).