Files
FoFreeXit/crates/ff-engine/tests/edit_roundtrip.rs
T
admin e442a9f678 Phase 3 (Organize + robust save) + Phase 4 (Edit content); update docs
Engine:
- organize.rs: build_document(plan) core — insert/delete/rotate/crop/extract/merge/split
- watermark.rs: watermark + header/footer via PdfPages::watermark, CID Unicode fonts
- qpdf.rs: QPDF CLI wrapper — repair corrupt files, re-encrypt after edits, ensure_openable
- edit.rs: MOAT — list_objects + apply_edits (SetText VN, Transform, Delete, Add/ReplaceImage)
- Tests: organize/watermark/qpdf_safety/edit roundtrip — 43/43 engine tests green

UI (Tauri): "Tổ chức trang" mode (grid + drag-reorder + multi-select), "Sửa nội dung"
mode (WYSIWYG object overlay, edit-in-place), global undo/redo, robust file open.

Corpus: add encrypted.pdf + corrupt-truncated.pdf fixtures.

Docs: mark Phase 1-4 done in roadmap; document QPDF setup + engine module layout in
05-dev-setup; add 10-13 phase summaries & user-test checklists; README status.
2026-07-14 22:07:38 +07:00

219 lines
6.8 KiB
Rust

//! Round-trip test cho chỉnh sửa nội dung (Phase 4 — moat chính): liệt kê
//! object, sửa text (gồm tiếng Việt), xoá, di chuyển, thêm chữ/ảnh. Đường GHI
//! file rủi ro nhất nên thao tác rồi đọc lại để so khớp.
//!
//! Lưu ý bẫy mutex PDFium (đã ghi memory): mọi assert chạy SAU khi ff_engine
//! đã trả về (doc đã drop), nên panic assert không poison mutex toàn cục.
use std::path::PathBuf;
use ff_engine::{EditOp, ObjectKind};
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn pdfium() -> pdfium_render::prelude::Pdfium {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
ff_engine::bind_pdfium().expect("nạp PDFium")
}
fn sample() -> PathBuf {
workspace_root().join("corpus").join("sample-multipage.pdf")
}
fn tmp(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(name);
let _ = std::fs::remove_file(&p);
p
}
/// Index của text object đầu tiên trên trang 0 có nội dung chứa `needle`.
fn find_text_index(pdf: &pdfium_render::prelude::Pdfium, input: &std::path::Path, needle: &str) -> u16 {
let objs = ff_engine::list_objects(pdf, input, 0, None).expect("list_objects");
objs.into_iter()
.find(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false))
.unwrap_or_else(|| panic!("không tìm thấy text object chứa {needle:?}"))
.index
}
#[test]
fn list_objects_finds_page_text() {
let pdf = pdfium();
let objs = ff_engine::list_objects(&pdf, &sample(), 0, None).expect("list_objects");
assert!(!objs.is_empty(), "trang 0 phải có object");
let has_text = objs
.iter()
.any(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|t| t.contains("Page one") || t.contains("FoFreeXit")).unwrap_or(false));
assert!(has_text, "phải thấy text object của trang 1: {objs:?}");
}
#[test]
fn set_text_replaces_run_content() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_settext.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: "Edited line ABC".into(),
font_size: None,
color: None,
bold: false,
italic: false,
}],
&out,
None,
)
.expect("apply_edits SetText");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Edited line ABC"), "phải có text mới: {text:?}");
assert!(!text.contains("Page one content alpha"), "text cũ phải biến mất: {text:?}");
}
#[test]
fn set_text_vietnamese_round_trips() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_vi.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let vi = "Sửa: nội dung Tiếng Việt";
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: vi.into(),
font_size: None,
color: None,
bold: false,
italic: false,
}],
&out,
None,
)
.expect("apply_edits SetText VI");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Tiếng Việt"), "tiếng Việt phải đúng dấu: {text:?}");
}
#[test]
fn delete_object_reduces_count() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_delete.pdf");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list").len();
assert!(before >= 1);
ff_engine::apply_edits(&pdf, &input, 0, &[EditOp::Delete { index: 0 }], &out, None)
.expect("apply_edits Delete");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out").len();
assert_eq!(after, before - 1, "xoá 1 object phải giảm đúng 1");
}
#[test]
fn transform_translate_moves_object() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_move.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list");
let orig_left = before.iter().find(|o| o.index == idx).expect("obj").rect.left;
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::Transform { index: idx, dx: 50.0, dy: 0.0, sx: 1.0, sy: 1.0 }],
&out,
None,
)
.expect("apply_edits Transform");
// Object đã dịch giữ nguyên thứ tự index (không xoá/thêm) → so cùng index.
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let new_left = after.iter().find(|o| o.index == idx).expect("obj out").rect.left;
assert!((new_left - (orig_left + 50.0)).abs() < 2.0, "left phải dịch ~+50: {orig_left} -> {new_left}");
}
#[test]
fn add_text_appears_in_extract() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_addtext.pdf");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::AddText {
x: 100.0,
y: 100.0,
text: "ADDEDXYZ".into(),
font_size: 14.0,
color: [10, 20, 30, 255],
bold: false,
italic: false,
}],
&out,
None,
)
.expect("apply_edits AddText");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("ADDEDXYZ"), "text thêm mới phải xuất hiện: {text:?}");
}
#[test]
fn add_image_adds_image_object() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_addimg.pdf");
// Tạo 1 PNG đỏ 24x24 làm fixture.
let png = tmp("ff_edit_fixture.png");
let mut img = image::RgbImage::new(24, 24);
for p in img.pixels_mut() {
*p = image::Rgb([220, 30, 30]);
}
img.save(&png).expect("save png");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list").len();
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::AddImage {
x: 50.0,
y: 50.0,
width_pt: 80.0,
height_pt: 60.0,
image_path: png.to_string_lossy().into_owned(),
}],
&out,
None,
)
.expect("apply_edits AddImage");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
assert_eq!(after.len(), before + 1, "thêm ảnh phải tăng 1 object");
assert!(after.iter().any(|o| o.kind == ObjectKind::Image), "phải có object kind Image");
}