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
Build Windows portable / build (push) Canceled after 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user