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.
Build Windows portable / build (push) Canceled after 0s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 03:22:41 +07:00
co-authored by Claude Fable 5
parent 93d49a67df
commit 5c53c1f987
6 changed files with 110 additions and 10 deletions
+20 -1
View File
@@ -1321,7 +1321,26 @@ fn edit_flatten_to_temp(input: String, page: u16, password: Option<String>) -> 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
+16 -2
View File
@@ -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,
+1 -1
View File
@@ -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};
+34
View File
@@ -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<f32, EngineError> {
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,
+11
View File
@@ -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);
}
+28 -6
View File
@@ -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