diff --git a/app/src/main.js b/app/src/main.js index d8bf0af..8f7f272 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -39,8 +39,6 @@ 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 - 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,8 +2054,6 @@ function enterEditMode(pageIndex) { state.editSel = null; state.editArm = null; state.editPendingImage = null; - state.editFlatBase = null; - state.editFlattenFailed = null; $("annobar").classList.add("hidden"); $("editBar").classList.remove("hidden"); $("viewport").classList.add("hidden"); @@ -2129,29 +2125,9 @@ 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; - state.editFlattenFailed = null; - return loadEditPage(); - } - } catch (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); - } - } + // Object nằm TRONG Form XObject (file Canva/Illustrator): sửa/xoá text + // được xử lý bằng PHẪU THUẬT stream ở engine (trong suốt với UI) — không + // cần mở gói trang. $("editImg").src = url; state.editObjects = objs; buildEditOverlay(); @@ -2159,8 +2135,7 @@ async function loadEditPage() { } catch (e) { $("editHint").textContent = "Lỗi nạp trang sửa: " + e; } - // 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; + $("edSave").disabled = state.editBase === state.path; // chưa có thay đổi nào updateEditUndoButtons(); // Đúp chuột từ viewer thường (kiểu Foxit): vào thẳng ô sửa tại điểm đã đúp. @@ -2616,15 +2591,7 @@ 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) { - 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); + startBlockTextEdit(o, paragraphLines(o), ev); } // Sửa cả đoạn WYSIWYG (chuẩn Foxit Edit Text): contenteditable phủ kín khối, @@ -2926,6 +2893,13 @@ function applyTextPropToSelected(part) { .map((i) => state.editObjects.find((x) => x.index === i)) .filter((x) => x && x.kind === "text"); if (!runs.length) return; + // Đổi thuộc tính tại chỗ cho run TRONG form chưa hỗ trợ (engine chỉ phẫu + // thuật được sửa/xoá nội dung) — đúp chuột để sửa cả đoạn thì được. + if (runs.some((r) => r.nested)) { + $("editHint").textContent = + "Đổi font/cỡ/màu cho chữ trong khối form chưa hỗ trợ — đúp chuột để sửa nội dung, hoặc xoá."; + return; + } stageEditOps( runs.map((r) => ({ op: "setText", @@ -3022,9 +2996,13 @@ function onEditBoxMouseDown(e, o, runs) { const runObjs = runs && runs.length ? runs : [o]; const runIndices = runObjs.map((r) => (typeof r === "number" ? r : r.index)); selectEditObject(o.index, runIndices); - // Run TRONG Form XObject (file Canva...): sửa text được nhưng chưa hỗ trợ - // kéo di chuyển/resize — chọn thôi, không bắt đầu drag. - if (runObjs.some((r) => r && typeof r === "object" && r.nested)) return; + // Run TRONG Form XObject (file Canva...): sửa/xoá text được nhưng chưa hỗ + // trợ kéo di chuyển/resize — chọn thôi, không bắt đầu drag. + if (runObjs.some((r) => r && typeof r === "object" && r.nested)) { + $("editHint").textContent = + "Khối này nằm trong form — đúp chuột để sửa nội dung; di chuyển/resize chưa hỗ trợ."; + return; + } const box = e.currentTarget; const startX = e.clientX, startY = e.clientY; const left0 = parseFloat(box.style.left), top0 = parseFloat(box.style.top); @@ -3648,6 +3626,11 @@ async function verifySignaturesAction(pathOverride) { $("openBtn").addEventListener("click", openFile); +// Hint bị cắt bớt bằng CSS (1 dòng cố định) → hover xem đủ qua title. +new MutationObserver(() => { + $("editHint").title = $("editHint").textContent; +}).observe($("editHint"), { childList: true, characterData: true, subtree: true }); + // Kéo-thả file PDF từ File Explorer vào cửa sổ → mở luôn (chuẩn desktop app). // Tauri v2 bắn sự kiện drag-drop toàn cửa sổ với danh sách đường dẫn thật. if (window.__TAURI__.event) { diff --git a/app/src/styles.css b/app/src/styles.css index 904b042..008176e 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -376,6 +376,15 @@ body.tool-active .annotlayer * { pointer-events: none; } user-select: none; } .edit-inline { user-select: text; } +/* Hint trong thanh Sửa nội dung: 1 dòng cố định + cắt bớt — thông điệp dài + không được làm thanh công cụ co giãn (trang nhảy lên xuống khi thao tác). */ +#editHint { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} .edit-page-wrap { position: relative; box-shadow: 0 2px 12px rgba(0,0,0,.5); background: #fff; } .edit-img { display: block; } .edit-overlay { position: absolute; inset: 0; } diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index b1289dd..d0628c3 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -298,6 +298,57 @@ fn object_at_path<'a>(page: &PdfPage<'a>, path: &[u16]) -> Result], +) -> Result, EngineError> { + let siblings = |prefix: &[u16]| -> Vec<&FlatEntry> { + entries + .iter() + .filter(|e| e.path.len() == prefix.len() + 1 && e.path[..prefix.len()] == *prefix) + .collect() + }; + let mut jobs = Vec::new(); + for path in paths { + let k = path.len() - 1; + let mut chain = Vec::new(); + for level in 0..k { + let prefix = &path[..level]; + let sib = siblings(prefix); + let forms: Vec = sib + .iter() + .filter(|e| e.kind == ObjectKind::XObjectForm) + .map(|e| e.path[level]) + .collect(); + if !forms.contains(&path[level]) { + return Err(EngineError::Pdfium("path phẫu thuật không đi qua form".into())); + } + let ord = forms.iter().filter(|&&c| c < path[level]).count(); + chain.push((ord, forms.len())); + } + let prefix = &path[..k]; + let sib = siblings(prefix); + let texts: Vec = sib + .iter() + .filter(|e| e.kind == ObjectKind::Text) + .map(|e| e.path[k]) + .collect(); + if !texts.contains(&path[k]) { + return Err(EngineError::Pdfium("path phẫu thuật không trỏ vào text".into())); + } + let ord = texts.iter().filter(|&&c| c < path[k]).count(); + jobs.push(crate::formsurgery::SurgeryJob { + chain, + expected_texts: texts.len(), + delete_ordinals: vec![ord], + }); + } + Ok(jobs) +} + /// "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, @@ -703,6 +754,11 @@ pub fn apply_edits( // op tra vào đây; dùng chung cho cả pha (A) lẫn (C). Probe font ở pha A // chỉ thêm+xoá object nháp ở CUỐI trang nên không làm lệch index. let entries: Vec; + // Run TRONG form bị thay (path) + rect của chúng + kích thước trang — + // phục vụ pha phẫu thuật (A2) và cổng an toàn so render cuối hàm. + let nested_removals: Vec>; + let mut nested_excl: Vec = Vec::new(); + let page_dims: (f32, f32); { let mut page = document .pages() @@ -800,9 +856,6 @@ 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, @@ -850,11 +903,10 @@ 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 - // hoặc nằm trong form — sau flatten không còn, guard). + // Rect trang của 1 entry text (None nếu không phải text). let text_rect = |i: u16| -> Option { let entry = &entries[i as usize]; - if entry.kind != ObjectKind::Text || entry.path.len() > 1 { + if entry.kind != ObjectKind::Text { return None; } let obj = object_at_path(&page, &entry.path).ok()?; @@ -1003,8 +1055,13 @@ pub fn apply_edits( let fam_key = fontmatch::normalize_key(&family); let ascii_only = text.chars().all(|c| c.is_control() || (' '..='~').contains(&c)); - let original_ok = - font_embedded && probe_font_roundtrip(&mut page, orig_token, text); + // Tầng 0 (dùng lại token font gốc) chỉ dùng được khi run neo + // ở CẤP TRANG: run trong form sẽ bị phẫu thuật xoá + document + // MỞ LẠI (token cũ vô hiệu) → đi tầng nhúng-lại-bytes/cùng-họ. + let anchor_nested = entries[anchor_idx as usize].path.len() > 1; + let original_ok = !anchor_nested + && font_embedded + && probe_font_roundtrip(&mut page, orig_token, text); let embedded_bytes = if font_embedded { font_data.clone().filter(|b| fontmatch::coverage_ok(b, text)) } else { @@ -1056,6 +1113,62 @@ pub fn apply_edits( _ => {} } } + + // (cuối pha A) Run TRONG form bị thay bởi Delete/ReflowText → xử lý + // bằng PHẪU THUẬT stream ở (A2); ghi lại rect (đã quy về trang) làm + // vùng loại trừ cho cổng an toàn so render. + page_dims = (page.width().value, page.height().value); + let mut nr: Vec> = Vec::new(); + for (opi, op) in ops.iter().enumerate() { + match op { + EditOp::Delete { index } if valid(*index) => { + let e = &entries[*index as usize]; + if e.path.len() > 1 && e.kind == ObjectKind::Text { + nr.push(e.path.clone()); + } + } + EditOp::ReflowText { .. } => { + if let Some(plan) = reflow_plans.get(&opi) { + for &i in &plan.indices { + let e = &entries[i as usize]; + if e.path.len() > 1 && e.kind == ObjectKind::Text { + nr.push(e.path.clone()); + } + } + } + } + _ => {} + } + } + nr.sort(); + nr.dedup(); + for p in &nr { + if let Ok(obj) = object_at_path(&page, p) { + if let Ok(q) = obj.bounds() { + if let Some(e) = entries.iter().find(|en| &en.path == p) { + nested_excl.push(mat_rect(e.acc, &quad_to_rect(&q))); + } + } + } + } + nested_removals = nr; + } + + // ---- (A2) PHẪU THUẬT stream form cho run TRONG form bị thay ---- + // PDFium không ghi lại được stream form (docs/12) → xoá op vẽ chữ của các + // run đó bằng lopdf (mọi byte khác giữ nguyên), rồi MỞ LẠI document từ + // file đã phẫu thuật; chữ mới sẽ vẽ ở CẤP TRANG trong pha (C). Chỉ số + // object CẤP TRANG không đổi (form vẫn đó, chỉ ít text con hơn). + let mut surgery_tmp: Option = None; + if !nested_removals.is_empty() { + let jobs = surgery_jobs(&entries, &nested_removals)?; + let tmp = output.with_extension("surgery.tmp.pdf"); + drop(document); + formsurgery::delete_form_text_ops(input, page_index, &jobs, &tmp)?; + document = pdfium + .load_pdf_from_file(&tmp, password) + .map_err(|e| EngineError::Pdfium(format!("mở lại sau phẫu thuật: {e}")))?; + surgery_tmp = Some(tmp); } // ---- (B) Nạp các font cần dùng (cần fonts_mut → ngoài mượn trang) ---- @@ -1072,6 +1185,8 @@ pub fn apply_edits( } // ---- (C) Áp thay đổi (mượn trang lần 2) ---- + // Số dòng thật sự vẽ ra của từng op reflow (tính vùng loại trừ cho cổng). + let mut reflow_out_lines: HashMap = HashMap::new(); { let mut page = document .pages() @@ -1216,12 +1331,9 @@ pub fn apply_edits( to_remove.retain(|i| valid(*i)); to_remove.sort_unstable(); to_remove.dedup(); - // 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. + // Run TRONG form đã bị xoá bằng PHẪU THUẬT ở (A2) — loại khỏi danh + // sách; chỉ còn xoá thật ở cấp trang theo index GIẢM DẦN. + to_remove.retain(|&i| !is_nested(i)); let mut page_idxs: Vec = to_remove .iter() .map(|&i| entries[i as usize].path[0]) @@ -1391,6 +1503,7 @@ pub fn apply_edits( } } + reflow_out_lines.insert(opi, out_lines.len()); let (a, b, c2, d) = plan.linear; for (i, (line, style)) in out_lines.iter().enumerate() { if line.is_empty() { @@ -1471,5 +1584,52 @@ pub fn apply_edits( document .save_to_file(output) .map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?; + + // ---- CỔNG AN TOÀN cho sửa-trong-form: mọi thay đổi pixel phải nằm + // TRONG vùng khối sửa; phần còn lại của trang phải y nguyên. Lệch → huỷ + // output, giữ nguyên file gốc, lỗi rõ ràng. + if let Some(tmp) = &surgery_tmp { + let (page_w_pt, page_h_pt) = page_dims; + let s = 500.0 / page_w_pt.max(1.0); + let mut excl_px: Vec<(u32, u32, u32, u32)> = Vec::new(); + { + let mut push_rect = |r: &Rect, pad: f32| { + let x0 = ((r.left - pad) * s).max(0.0) as u32; + let x1 = ((r.right + pad) * s).max(0.0) as u32; + let y0 = ((page_h_pt - r.top - pad) * s).max(0.0) as u32; + let y1 = ((page_h_pt - r.bottom + pad) * s).max(0.0) as u32; + excl_px.push((x0, y0, x1.max(x0 + 1), y1.max(y0 + 1))); + }; + for r in &nested_excl { + push_rect(r, 4.0); + } + for (opi, plan) in &reflow_plans { + let n = reflow_out_lines + .get(opi) + .copied() + .unwrap_or(plan.line_styles.len()) as f32; + push_rect( + &Rect { + left: plan.left, + right: plan.left + plan.width * 1.4, + top: plan.first_baseline + plan.line_advance, + bottom: plan.first_baseline - plan.line_advance * (n + 1.0), + }, + 6.0, + ); + } + } + let mm = crate::render::page_render_mismatch_masked( + pdfium, input, output, page_index, 500, &excl_px, + )?; + let _ = std::fs::remove_file(tmp); + if mm > 0.005 { + let _ = std::fs::remove_file(output); + return Err(EngineError::Pdfium(format!( + "sửa trong form làm lệch hiển thị NGOÀI vùng sửa ({:.1}% điểm ảnh) — đã huỷ để bảo toàn file (cấu trúc file chưa hỗ trợ)", + mm * 100.0 + ))); + } + } Ok(()) } diff --git a/crates/ff-engine/src/formsurgery.rs b/crates/ff-engine/src/formsurgery.rs new file mode 100644 index 0000000..fdd7c8e --- /dev/null +++ b/crates/ff-engine/src/formsurgery.rs @@ -0,0 +1,226 @@ +//! "PHẪU THUẬT" content stream của Form XObject bằng lopdf (Phase 4 iter 4b): +//! xoá đúng các op vẽ chữ (Tj/TJ/'/") của những text-child bị sửa — mọi byte +//! khác của form giữ NGUYÊN. Đây là đường duy nhất sửa nội dung trong form an +//! toàn: PDFium không ghi lại được stream form, còn generator ghi cả trang +//! thì lossy với file phức tạp (font subset trùng tên, ảnh inline — docs/12). +//! +//! Nguyên tắc map: parser PDFium tạo object theo ĐÚNG thứ tự op trong stream +//! → text-child thứ k của 1 form ↔ show-op thứ k trong stream form đó; form +//! con thứ n ↔ Do-trỏ-tới-form thứ n. Mỗi bước đều có BẤT BIẾN kiểm đếm +//! (số op == số object PDFium thấy) — lệch là từ chối ngay, không đoán mò. + +use std::collections::HashMap; +use std::path::Path; + +use lopdf::content::{Content, Operation}; +use lopdf::{Dictionary, Document as LoDoc, Object, ObjectId}; + +use crate::EngineError; + +fn le(e: E) -> EngineError { + EngineError::Pdfium(format!("phẫu thuật form (lopdf): {e}")) +} + +/// 1 việc xoá text trong 1 form (đường đi từ trang xuống form trong cùng). +pub(crate) struct SurgeryJob { + /// Tại mỗi mức: (thứ tự FORM trong container, tổng số form PDFium thấy + /// trong container đó — bất biến kiểm đếm). + pub chain: Vec<(usize, usize)>, + /// Tổng số text-child PDFium thấy trong form trong cùng (bất biến). + pub expected_texts: usize, + /// Các show-op (thứ tự trong form trong cùng) cần xoá. + pub delete_ordinals: Vec, +} + +fn is_text_show(op: &str) -> bool { + matches!(op, "Tj" | "TJ" | "'" | "\"") +} + +/// Resolve reference (tối đa 8 nấc). +fn deref<'a>(doc: &'a LoDoc, mut o: &'a Object) -> &'a Object { + for _ in 0..8 { + match o { + Object::Reference(id) => match doc.get_object(*id) { + Ok(x) => o = x, + Err(_) => break, + }, + _ => break, + } + } + o +} + +/// Id các XObject FORM theo đúng thứ tự Do trong `content` (chỉ đếm Do trỏ +/// tới stream /Subtype /Form qua `resources`). +fn form_dos_in_order( + doc: &LoDoc, + content: &Content, + resources: Option<&Dictionary>, +) -> Vec { + let mut out = Vec::new(); + let xobjects = resources + .and_then(|r| r.get(b"XObject").ok()) + .map(|x| deref(doc, x)) + .and_then(|x| x.as_dict().ok()); + let Some(xobjects) = xobjects else { return out }; + for op in &content.operations { + if op.operator != "Do" { + continue; + } + let Some(Object::Name(name)) = op.operands.first() else { continue }; + let Ok(entry) = xobjects.get(name) else { continue }; + let Object::Reference(id) = entry else { continue }; + let id = *id; + let Ok(obj) = doc.get_object(id) else { continue }; + let Ok(stream) = obj.as_stream() else { continue }; + let subtype = stream + .dict + .get(b"Subtype") + .ok() + .and_then(|s| s.as_name().ok()); + if subtype == Some(b"Form") { + out.push(id); + } + } + out +} + +/// /Resources của 1 stream form (fallback: resources của container cha). +fn stream_resources<'a>( + doc: &'a LoDoc, + stream_id: ObjectId, + parent: Option<&'a Dictionary>, +) -> Option<&'a Dictionary> { + let stream = doc.get_object(stream_id).ok()?.as_stream().ok()?; + match stream.dict.get(b"Resources") { + Ok(r) => deref(doc, r).as_dict().ok().or(parent), + Err(_) => parent, + } +} + +/// Áp danh sách job xoá-text-trong-form lên `input`, ghi ra `output`. +pub(crate) fn delete_form_text_ops( + input: &Path, + page_index: u16, + jobs: &[SurgeryJob], + output: &Path, +) -> Result<(), EngineError> { + let mut doc = LoDoc::load(input).map_err(le)?; + let pages = doc.get_pages(); + let page_id = *pages + .get(&(page_index as u32 + 1)) + .ok_or_else(|| le(format!("không có trang {page_index}")))?; + + // Content + resources CẤP TRANG (đủ để tìm Do form mức 1). + let page_content_bytes = doc.get_page_content(page_id).map_err(le)?; + let page_res_dict: Option = { + let (res, ids) = doc.get_page_resources(page_id).map_err(le)?; + match res { + Some(d) => Some(d.clone()), + None => ids + .first() + .and_then(|id| doc.get_dictionary(*id).ok()) + .cloned(), + } + }; + + // Gom job theo form trong cùng: resolve chain → stream id (kèm kiểm đếm). + let mut per_stream: HashMap)> = HashMap::new(); + for job in jobs { + let mut content = Content::decode(&page_content_bytes).map_err(le)?; + let mut res = page_res_dict.clone(); + let mut stream_id: Option = None; + for (level, &(ordinal, expected_forms)) in job.chain.iter().enumerate() { + let forms = form_dos_in_order(&doc, &content, res.as_ref()); + if forms.len() != expected_forms { + return Err(le(format!( + "mức {level}: thấy {} Do-form trong stream nhưng PDFium thấy {} form — cấu trúc không khớp, từ chối phẫu thuật", + forms.len(), + expected_forms + ))); + } + let id = *forms.get(ordinal).ok_or_else(|| { + le(format!("mức {level}: không có form thứ {ordinal}")) + })?; + let stream = doc + .get_object(id) + .map_err(le)? + .as_stream() + .map_err(le)?; + let bytes = stream.decompressed_content().map_err(le)?; + content = Content::decode(&bytes).map_err(le)?; + res = stream_resources(&doc, id, res.as_ref()).cloned(); + stream_id = Some(id); + } + let id = stream_id.ok_or_else(|| le("chain rỗng"))?; + let entry = per_stream.entry(id).or_insert((job.expected_texts, Vec::new())); + if entry.0 != job.expected_texts { + return Err(le("job cùng form nhưng expected_texts khác nhau")); + } + entry.1.extend_from_slice(&job.delete_ordinals); + } + + // Xoá show-op trong từng stream. Giữ hiệu ứng phụ về VỊ TRÍ của '/" : + // ' = T* + Tj → thay bằng T*; " = aw ac T* Tj → thay bằng Tw/Tc/T*. + for (stream_id, (expected_texts, ordinals)) in per_stream { + let bytes = { + let stream = doc + .get_object(stream_id) + .map_err(le)? + .as_stream() + .map_err(le)?; + stream.decompressed_content().map_err(le)? + }; + let content = Content::decode(&bytes).map_err(le)?; + let total_shows = content + .operations + .iter() + .filter(|o| is_text_show(&o.operator)) + .count(); + if total_shows != expected_texts { + return Err(le(format!( + "form có {total_shows} show-op nhưng PDFium thấy {expected_texts} text object — cấu trúc không khớp, từ chối phẫu thuật" + ))); + } + let del: std::collections::HashSet = ordinals.into_iter().collect(); + let mut new_ops: Vec = Vec::with_capacity(content.operations.len()); + let mut show_i = 0usize; + for op in content.operations.into_iter() { + if !is_text_show(&op.operator) { + new_ops.push(op); + continue; + } + let doomed = del.contains(&show_i); + show_i += 1; + if !doomed { + new_ops.push(op); + continue; + } + match op.operator.as_str() { + "Tj" | "TJ" => {} // bỏ hẳn — không có hiệu ứng phụ vị trí + "'" => new_ops.push(Operation::new("T*", vec![])), + "\"" => { + let mut operands = op.operands.into_iter(); + if let (Some(aw), Some(ac)) = (operands.next(), operands.next()) { + new_ops.push(Operation::new("Tw", vec![aw])); + new_ops.push(Operation::new("Tc", vec![ac])); + } + new_ops.push(Operation::new("T*", vec![])); + } + _ => unreachable!(), + } + } + let encoded = Content { operations: new_ops }.encode().map_err(le)?; + let stream = doc + .get_object_mut(stream_id) + .map_err(le)? + .as_stream_mut() + .map_err(le)?; + stream.dict.remove(b"Filter"); + stream.dict.remove(b"DecodeParms"); + stream.set_content(encoded); + } + + doc.save(output).map_err(le)?; + Ok(()) +} diff --git a/crates/ff-engine/src/lib.rs b/crates/ff-engine/src/lib.rs index a2235b2..8b0ca2f 100644 --- a/crates/ff-engine/src/lib.rs +++ b/crates/ff-engine/src/lib.rs @@ -7,6 +7,7 @@ pub mod annot; pub mod convert; pub mod edit; +mod formsurgery; pub(crate) mod fontmatch; pub mod form; pub mod meta; diff --git a/crates/ff-engine/src/render.rs b/crates/ff-engine/src/render.rs index 025a081..c6893b9 100644 --- a/crates/ff-engine/src/render.rs +++ b/crates/ff-engine/src/render.rs @@ -127,6 +127,54 @@ pub fn page_render_mismatch( Ok(diff as f32 / total as f32) } +/// Như [page_render_mismatch] nhưng BỎ QUA pixel trong các ô `exclude_px` +/// ((x0,y0,x1,y1) theo pixel ảnh render) — dùng cho sửa-trong-form: vùng khối +/// sửa được phép đổi, phần còn lại của trang phải y nguyên. +pub fn page_render_mismatch_masked( + pdfium: &Pdfium, + a: &Path, + b: &Path, + page_index: u16, + target_width: u32, + exclude_px: &[(u32, u32, u32, 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 mut total = 0u64; + let mut diff = 0u64; + for y in 0..h { + for x in 0..w { + if exclude_px + .iter() + .any(|&(x0, y0, x1, y1)| x >= x0 && x < x1 && y >= y0 && y < y1) + { + continue; + } + total += 1; + let pa = ia.get_pixel(x, y); + let pb = ib.get_pixel(x, y); + let d = pa + .0 + .iter() + .zip(pb.0.iter()) + .map(|(p, q)| (*p as i16 - *q as i16).unsigned_abs()) + .max() + .unwrap_or(0); + if d > 12 { + diff += 1; + } + } + } + if total == 0 { + return Ok(0.0); + } + 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 330139d..9cdfa25 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -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::>().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%).