From 5c53c1f987f00f9a136481931af4b5445633c039 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 03:22:41 +0700 Subject: [PATCH] Cong an toan cho mo goi form: so render truoc/sau (page_render_mismatch), lech >0.5% pixel -> huy + chan sua voi thong diep ro. E2E tren CV Canva that phat hien generator PDFium lossy (font subset trung ten bi gop lam mat glyph, anh inline bi bo, z-order xao tron) - khong ship flatten mu quang; ke hoach iteration sau (phau thuat stream bang lopdf) ghi trong docs. Co-Authored-By: Claude Fable 5 --- app/src-tauri/src/main.rs | 21 ++++++++++++++- app/src/main.js | 18 +++++++++++-- crates/ff-engine/src/lib.rs | 2 +- crates/ff-engine/src/render.rs | 34 ++++++++++++++++++++++++ crates/ff-engine/tests/edit_roundtrip.rs | 11 ++++++++ docs/12-phase4-summary.md | 34 +++++++++++++++++++----- 6 files changed, 110 insertions(+), 10 deletions(-) diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index e9ff891..f278f76 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -1321,7 +1321,26 @@ fn edit_flatten_to_temp(input: String, page: u16, password: Option) -> R password.as_deref(), ) .map_err(|e| e.to_string())?; - Ok(if n > 0 { Some(out.to_string_lossy().into_owned()) } else { None }) + if n == 0 { + return Ok(None); + } + // CỔNG AN TOÀN: mở gói phải giữ nguyên hiển thị. Generator của PDFium + // lossy với file phức tạp (font subset TRÙNG TÊN bị gộp làm mất glyph, + // ảnh inline bị bỏ, z-order form lồng nhau xáo trộn) — so render 2 bản, + // lệch quá 0.5% điểm ảnh → HUỶ, giữ nguyên file gốc. + let mismatch = + ff_engine::page_render_mismatch(&pdfium, std::path::Path::new(&input), &out, page, 500) + .map_err(|e| e.to_string())?; + if mismatch > 0.005 { + let _ = std::fs::remove_file(&out); + return Err(format!( + "Trang gói nội dung trong Form XObject quá phức tạp — mở gói làm lệch hiển thị \ + {:.1}% điểm ảnh nên đã huỷ để bảo toàn file. Xem/chú thích vẫn bình thường; \ + SỬA NỘI DUNG file dạng này sẽ hỗ trợ ở bản sau.", + mismatch * 100.0 + )); + } + Ok(Some(out.to_string_lossy().into_owned())) } /// Dọn các file làm việc tạm của chế độ sửa (undo stack). Chỉ xoá file có tên diff --git a/app/src/main.js b/app/src/main.js index 2b2a212..d8bf0af 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -40,6 +40,7 @@ const state = { 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 + 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 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ở @@ -2056,6 +2057,7 @@ function enterEditMode(pageIndex) { state.editArm = null; state.editPendingImage = null; state.editFlatBase = null; + state.editFlattenFailed = null; $("annobar").classList.add("hidden"); $("editBar").classList.remove("hidden"); $("viewport").classList.add("hidden"); @@ -2140,10 +2142,14 @@ async function loadEditPage() { state.editTemps.push(flat); state.editBase = flat; state.editFlatBase = flat; + state.editFlattenFailed = null; return loadEditPage(); } } catch (e) { - $("editHint").textContent = "Không mở gói được form: " + 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; @@ -2610,7 +2616,15 @@ function paragraphLines(o) { // đườ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. function startTextEdit(o, ev) { - startBlockTextEdit(o, paragraphLines(o), ev); + const lines = paragraphLines(o); + // 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, diff --git a/crates/ff-engine/src/lib.rs b/crates/ff-engine/src/lib.rs index 7d09b8c..a2235b2 100644 --- a/crates/ff-engine/src/lib.rs +++ b/crates/ff-engine/src/lib.rs @@ -42,7 +42,7 @@ pub use form::{ pub use ocr::{find_tesseract, ocr_add_text_layer, ocr_page_words, OcrWord}; pub use redact::redact_areas; pub use sign::{generate_self_signed_id, sign_pdf, verify_signatures, SignatureCheck}; -pub use render::{bind_pdfium, page_count, render_page_png, PageImage}; +pub use render::{bind_pdfium, page_count, page_render_mismatch, render_page_png, PageImage}; pub use text::{extract_text, page_char_boxes, search, CharBox, Rect, SearchHit}; pub use watermark::{add_header_footer, add_watermark, Anchor, HeaderFooterSpec, WatermarkSpec}; diff --git a/crates/ff-engine/src/render.rs b/crates/ff-engine/src/render.rs index 1728ead..025a081 100644 --- a/crates/ff-engine/src/render.rs +++ b/crates/ff-engine/src/render.rs @@ -93,6 +93,40 @@ pub fn render_page( }) } +/// Tỉ lệ pixel KHÁC NHAU (0.0–1.0) giữa cùng 1 trang của 2 file, render cùng +/// bề rộng. Dùng làm CỔNG AN TOÀN cho các phép biến đổi phải giữ nguyên hiển +/// thị (mở gói Form XObject…): lệch quá ngưỡng → từ chối biến đổi thay vì âm +/// thầm làm hỏng file. Sai khác ≤12/255 mỗi kênh bỏ qua (nhiễu anti-alias). +pub fn page_render_mismatch( + pdfium: &Pdfium, + a: &Path, + b: &Path, + page_index: u16, + target_width: u32, +) -> Result { + 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 total = (w as u64 * h as u64).max(1); + let mut diff = 0u64; + for (pa, pb) in ia.pixels().zip(ib.pixels()) { + let d = pa + .0 + .iter() + .zip(pb.0.iter()) + .map(|(x, y)| (*x as i16 - *y as i16).unsigned_abs()) + .max() + .unwrap_or(0); + if d > 12 { + diff += 1; + } + } + Ok(diff as f32 / total as f32) +} + /// Render một trang và ghi ra file PNG. pub fn render_page_png( pdfium: &Pdfium, diff --git a/crates/ff-engine/tests/edit_roundtrip.rs b/crates/ff-engine/tests/edit_roundtrip.rs index cd480be..330139d 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -1220,3 +1220,14 @@ fn nested_target_without_flatten_is_rejected() { .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}"); } + +// Cổng an toàn: mở gói fixture đơn giản phải giữ nguyên hiển thị (lệch ~0%). +#[test] +fn flatten_is_visually_lossless_on_simple_form() { + let pdf = pdfium(); + let input = form_fixture("ff_edit_formx_gate.pdf"); + let out = tmp("ff_edit_formx_gate_out.pdf"); + ff_engine::flatten_form_xobjects(&pdf, &input, 0, &out, None).expect("flatten"); + let mm = ff_engine::page_render_mismatch(&pdf, &input, &out, 0, 500).expect("mismatch"); + assert!(mm < 0.005, "mở gói phải giữ nguyên hiển thị, lệch {:.3}%", mm * 100.0); +} diff --git a/docs/12-phase4-summary.md b/docs/12-phase4-summary.md index 2920eb7..7ac3190 100644 --- a/docs/12-phase4-summary.md +++ b/docs/12-phase4-summary.md @@ -77,12 +77,34 @@ Máy dev không có Rust nên **CI là nơi duy nhất chạy test**: workflow t 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) -- 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. +### CỔNG AN TOÀN + giới hạn ghi nhận (iteration 4 v1) +Kiểm chứng E2E trên file Canva THẬT (CV, 1149 object sau mở gói) phát hiện: +**generator của PDFium ghi lại cả trang là LOSSY với file phức tạp**: +- Map font theo (type, BaseFont) — Canva nhúng 2+ subset TRÙNG TÊN + ("AAAAAA+Now-Bold" ×2): run của subset 2 bị ép qua subset 1 → mất glyph + rải rác (PROFILE → "O ILE"). +- `ProcessImage` BỎ ảnh inline (`IsInline() → return`) — mất ảnh chân dung. +- Mở form theo vòng append cuối trang → z-order xáo trộn (panel đè lên chữ). + +→ **`edit_flatten_to_temp` có CỔNG AN TOÀN `page_render_mismatch`**: render +trang trước/sau mở gói (500px, dung sai 12/255/kênh), lệch >0.5% điểm ảnh → +HUỶ file tạm + trả lỗi rõ; UI hiển thị thông điệp và CHẶN mở ô sửa phần nằm +trong form (xem/chú thích/tổ chức trang vẫn bình thường). File gói form đơn +giản (đa số file xuất máy in ảo, fixture test) qua cổng → sửa thoải mái. + +**Kế hoạch iteration sau cho file Canva-phức-tạp** (đã chốt kiến trúc): +KHÔNG regenerate qua PDFium — phẫu thuật content stream bằng lopdf: +(1) xoá đúng các op vẽ chữ (Tj/TJ/'/") của run bị sửa trong stream form +(map: text-child thứ k của form ↔ show-op thứ k, thứ tự parse tuần tự); +(2) chữ mới vẽ ở CẤP TRANG bằng PDFium như hiện tại (trang chỉ có Do + +path — regenerate trang không đụng nội dung form, không dính lỗi trùng tên +font). Mọi byte khác của form giữ NGUYÊN — lossless by construction. + +Giới hạn khác: +- Flatten làm mất clip/ExtGState/transparency group đặt Ở MỨC FORM — các + trường hợp này thường cũng làm lệch render → cổng an toàn tự chặn. +- Cấu trúc file sau khi SỬA thay đổi (form mở ra trang) — hiển thị giữ + nguyên (đã có cổng kiểm); chỉ xảy ra khi người dùng thật sự lưu. - 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