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.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Round-trip test cho thao tác tổ chức trang (Phase 3): xoá/xoay/trích/
|
||||
//! merge/split/crop/chèn trang trắng — đường GHI file rủi ro nhất nên test
|
||||
//! kỹ bằng cách thao tác rồi đọc lại, so khớp số trang/thứ tự/nội dung.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ff_engine::{PagePlanEntry, Rect};
|
||||
|
||||
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 tmp(name: &str) -> PathBuf {
|
||||
let p = std::env::temp_dir().join(name);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_pages_keeps_remaining_content_and_order() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
|
||||
let output = tmp("ff_org_delete.pdf");
|
||||
|
||||
// sample-multipage.pdf: 3 trang, outline Chapter 1/2/3 -> trang 0/1/2.
|
||||
ff_engine::delete_pages(&pdf, &input, &[1], &output, None).expect("delete_pages");
|
||||
|
||||
assert_eq!(ff_engine::page_count(&pdf, &output, None).expect("count"), 2);
|
||||
let p0 = ff_engine::extract_text(&pdf, &output, 0, None).expect("text p0");
|
||||
let p1 = ff_engine::extract_text(&pdf, &output, 1, None).expect("text p1");
|
||||
assert!(p0.contains("1") || !p0.is_empty(), "trang 0 gốc phải còn nguyên: {p0:?}");
|
||||
assert!(!p1.is_empty(), "trang 2 gốc (giờ là trang 1) phải còn nguyên: {p1:?}");
|
||||
// Trang giữa (index 1 gốc) không còn xuất hiện ở bất kỳ đâu trong output.
|
||||
assert_ne!(p0, p1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_all_pages_is_rejected() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
|
||||
let output = tmp("ff_org_delete_all.pdf");
|
||||
let err = ff_engine::delete_pages(&pdf, &input, &[0, 1, 2], &output, None);
|
||||
assert!(err.is_err(), "không được cho xoá hết toàn bộ trang");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_pages_persists_in_saved_file() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
|
||||
let output = tmp("ff_org_rotate.pdf");
|
||||
|
||||
ff_engine::rotate_pages(&pdf, &input, &[0], 90, &output, None).expect("rotate_pages");
|
||||
|
||||
let doc = pdf.load_pdf_from_file(&output, None).expect("mở lại");
|
||||
let page0 = doc.pages().get(0).expect("trang 0");
|
||||
let page1 = doc.pages().get(1).expect("trang 1");
|
||||
assert_eq!(
|
||||
page0.rotation().expect("rotation p0"),
|
||||
pdfium_render::prelude::PdfPageRenderRotation::Degrees90,
|
||||
"trang 0 phải xoay 90 độ"
|
||||
);
|
||||
assert_eq!(
|
||||
page1.rotation().expect("rotation p1"),
|
||||
pdfium_render::prelude::PdfPageRenderRotation::None,
|
||||
"trang 1 không bị xoay"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pages_creates_new_file_without_touching_input() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
|
||||
let output = tmp("ff_org_extract.pdf");
|
||||
|
||||
ff_engine::extract_pages(&pdf, &input, &[2, 0], &output, None).expect("extract_pages");
|
||||
|
||||
assert_eq!(ff_engine::page_count(&pdf, &output, None).expect("count"), 2);
|
||||
assert_eq!(ff_engine::page_count(&pdf, &input, None).expect("count input"), 3, "input không bị sửa");
|
||||
|
||||
// Thứ tự đúng theo `pages` truyền vào: [2, 0] -> output trang 0 = input trang 2.
|
||||
let out_p0 = ff_engine::extract_text(&pdf, &output, 0, None).expect("text");
|
||||
let in_p2 = ff_engine::extract_text(&pdf, &input, 2, None).expect("text");
|
||||
assert_eq!(out_p0, in_p2, "trích đúng thứ tự đã chọn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_files_concatenates_in_order() {
|
||||
let pdf = pdfium();
|
||||
let a = workspace_root().join("corpus").join("hello.pdf");
|
||||
let b = workspace_root().join("corpus").join("sample-multipage.pdf");
|
||||
let output = tmp("ff_org_merge.pdf");
|
||||
|
||||
ff_engine::merge_files(&pdf, &[a.clone(), b.clone()], &output).expect("merge_files");
|
||||
|
||||
let count_a = ff_engine::page_count(&pdf, &a, None).expect("count a");
|
||||
let count_b = ff_engine::page_count(&pdf, &b, None).expect("count b");
|
||||
assert_eq!(
|
||||
ff_engine::page_count(&pdf, &output, None).expect("count out"),
|
||||
count_a + count_b
|
||||
);
|
||||
|
||||
let merged_first = ff_engine::extract_text(&pdf, &output, 0, None).expect("text");
|
||||
let a_first = ff_engine::extract_text(&pdf, &a, 0, None).expect("text");
|
||||
assert_eq!(merged_first, a_first, "trang đầu output phải khớp file a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_by_page_count_produces_correct_chunks() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf"); // 3 trang
|
||||
let out_dir = std::env::temp_dir();
|
||||
|
||||
let parts = ff_engine::split_by_page_count(&pdf, &input, 2, &out_dir, "ff_org_split", None)
|
||||
.expect("split_by_page_count");
|
||||
|
||||
assert_eq!(parts.len(), 2, "3 trang / 2 mỗi file = 2 phần (2+1)");
|
||||
assert_eq!(ff_engine::page_count(&pdf, &parts[0], None).expect("count"), 2);
|
||||
assert_eq!(ff_engine::page_count(&pdf, &parts[1], None).expect("count"), 1);
|
||||
|
||||
for p in &parts {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_sets_cropbox_on_saved_file() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
|
||||
let output = tmp("ff_org_crop.pdf");
|
||||
|
||||
let mut plan = ff_engine::identity_plan(&pdf, &input, None).expect("identity_plan");
|
||||
plan[0].crop = Some(Rect { left: 50.0, bottom: 60.0, right: 500.0, top: 700.0 });
|
||||
ff_engine::build_document(&pdf, &input, &plan, &output, None).expect("build_document crop");
|
||||
|
||||
let doc = pdf.load_pdf_from_file(&output, None).expect("mở lại");
|
||||
let page0 = doc.pages().get(0).expect("trang 0");
|
||||
let crop = page0.boundaries().crop().expect("crop box");
|
||||
assert!((crop.bounds.left().value - 50.0).abs() < 0.5);
|
||||
assert!((crop.bounds.bottom().value - 60.0).abs() < 0.5);
|
||||
assert!((crop.bounds.right().value - 500.0).abs() < 0.5);
|
||||
assert!((crop.bounds.top().value - 700.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_blank_page_has_correct_size_and_no_text() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
|
||||
let output = tmp("ff_org_blank.pdf");
|
||||
|
||||
let mut plan = ff_engine::identity_plan(&pdf, &input, None).expect("identity_plan");
|
||||
plan.insert(1, PagePlanEntry::blank(300.0, 400.0));
|
||||
ff_engine::build_document(&pdf, &input, &plan, &output, None).expect("build_document blank");
|
||||
|
||||
assert_eq!(ff_engine::page_count(&pdf, &output, None).expect("count"), 4);
|
||||
let doc = pdf.load_pdf_from_file(&output, None).expect("mở lại");
|
||||
let blank = doc.pages().get(1).expect("trang trắng");
|
||||
assert!((blank.width().value - 300.0).abs() < 0.5);
|
||||
assert!((blank.height().value - 400.0).abs() < 0.5);
|
||||
drop(doc);
|
||||
let text = ff_engine::extract_text(&pdf, &output, 1, None).expect("text trang trắng");
|
||||
assert!(text.trim().is_empty(), "trang trắng không được có text: {text:?}");
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Test lớp an toàn QPDF (Phase 3): repair file hỏng trước khi PDFium mở,
|
||||
//! và mã hoá lại file sau khi PDFium/lopdf chỉnh sửa — đường GHI/MỞ file rủi
|
||||
//! ro nhất nên test bằng fixture THẬT (corrupt-truncated.pdf bị PDFium từ
|
||||
//! chối thẳng, encrypted.pdf cần đúng password).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
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());
|
||||
}
|
||||
if std::env::var("FOFREEXIT_QPDF_PATH").is_err() {
|
||||
std::env::set_var("FOFREEXIT_QPDF_PATH", workspace_root().join("qpdf").join("bin"));
|
||||
}
|
||||
ff_engine::bind_pdfium().expect("nạp PDFium")
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let p = std::env::temp_dir().join(name);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_qpdf_locates_binary() {
|
||||
let _ = pdfium(); // đảm bảo FOFREEXIT_QPDF_PATH đã set
|
||||
let path = ff_engine::find_qpdf().expect("find_qpdf");
|
||||
assert!(path.is_file(), "qpdf.exe phải tồn tại tại {}", path.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfium_rejects_truncated_file_directly() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("corrupt-truncated.pdf");
|
||||
let err = pdf.load_pdf_from_file(&input, None);
|
||||
assert!(err.is_err(), "PDFium phải từ chối file bị cắt cụt khi chưa repair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_makes_truncated_file_openable() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("corrupt-truncated.pdf");
|
||||
let output = tmp("ff_qpdf_repaired.pdf");
|
||||
|
||||
ff_engine::repair(&input, &output).expect("repair");
|
||||
let doc = pdf.load_pdf_from_file(&output, None).expect("mở file đã repair");
|
||||
assert_eq!(doc.pages().len(), 3, "phải khôi phục đủ 3 trang gốc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_openable_repairs_automatically() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("corrupt-truncated.pdf");
|
||||
|
||||
let usable = ff_engine::ensure_openable(&pdf, &input, None).expect("ensure_openable");
|
||||
assert_ne!(usable, input, "phải trả về 1 file tạm đã repair, không phải input gốc");
|
||||
assert_eq!(
|
||||
ff_engine::page_count(&pdf, &usable, None).expect("page_count"),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_openable_passes_through_file_that_already_opens() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
|
||||
let usable = ff_engine::ensure_openable(&pdf, &input, None).expect("ensure_openable");
|
||||
assert_eq!(usable, input, "file mở thẳng được thì không cần repair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_fixture_requires_correct_password() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("encrypted.pdf");
|
||||
|
||||
assert!(pdf.load_pdf_from_file(&input, None).is_err(), "không password phải lỗi");
|
||||
assert!(
|
||||
pdf.load_pdf_from_file(&input, Some("sai-password")).is_err(),
|
||||
"sai password phải lỗi"
|
||||
);
|
||||
let doc = pdf
|
||||
.load_pdf_from_file(&input, Some("fofreexit"))
|
||||
.expect("đúng password phải mở được");
|
||||
assert_eq!(doc.pages().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_with_password_round_trips() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf"); // chưa mã hoá
|
||||
let output = tmp("ff_qpdf_encrypted.pdf");
|
||||
|
||||
ff_engine::encrypt_with_password(&input, &output, "userpw", "ownerpw")
|
||||
.expect("encrypt_with_password");
|
||||
|
||||
assert!(pdf.load_pdf_from_file(&output, None).is_err(), "phải cần password sau khi mã hoá");
|
||||
let doc = pdf
|
||||
.load_pdf_from_file(&output, Some("userpw"))
|
||||
.expect("đúng password phải mở được");
|
||||
assert_eq!(doc.pages().len(), 3, "nội dung phải giữ nguyên sau khi mã hoá");
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Round-trip test cho watermark + header/footer (Phase 3): áp rồi đọc lại
|
||||
//! bằng extract_text — PDFium ghi watermark/header-footer thành CONTENT thật
|
||||
//! của trang (không phải annotation) nên phải xuất hiện trong text trích xuất.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ff_engine::{Anchor, HeaderFooterSpec, WatermarkSpec};
|
||||
|
||||
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 tmp(name: &str) -> PathBuf {
|
||||
let p = std::env::temp_dir().join(name);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
p
|
||||
}
|
||||
|
||||
fn base_watermark(text: &str) -> WatermarkSpec {
|
||||
WatermarkSpec {
|
||||
text: text.to_string(),
|
||||
font_size: 36.0,
|
||||
color: [200, 0, 0, 120],
|
||||
bold: false,
|
||||
italic: false,
|
||||
rotation_deg: 45.0,
|
||||
anchor: Anchor::Center,
|
||||
pages: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watermark_text_appears_on_all_pages() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf"); // 3 trang
|
||||
let output = tmp("ff_wm_all.pdf");
|
||||
|
||||
ff_engine::add_watermark(&pdf, &input, &base_watermark("CONFIDENTIAL"), &output, None)
|
||||
.expect("add_watermark");
|
||||
|
||||
for i in 0..3u16 {
|
||||
let text = ff_engine::extract_text(&pdf, &output, i, None).expect("text");
|
||||
assert!(text.contains("CONFIDENTIAL"), "trang {i} phải có watermark: {text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watermark_respects_page_filter() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
|
||||
let output = tmp("ff_wm_filtered.pdf");
|
||||
|
||||
let mut spec = base_watermark("ONLY-PAGE-1");
|
||||
spec.pages = vec![1];
|
||||
ff_engine::add_watermark(&pdf, &input, &spec, &output, None).expect("add_watermark");
|
||||
|
||||
let p0 = ff_engine::extract_text(&pdf, &output, 0, None).expect("text p0");
|
||||
let p1 = ff_engine::extract_text(&pdf, &output, 1, None).expect("text p1");
|
||||
assert!(!p0.contains("ONLY-PAGE-1"), "trang 0 không được có watermark");
|
||||
assert!(p1.contains("ONLY-PAGE-1"), "trang 1 phải có watermark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watermark_vietnamese_text_round_trips() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
|
||||
let output = tmp("ff_wm_viet.pdf");
|
||||
|
||||
ff_engine::add_watermark(&pdf, &input, &base_watermark("Bản nháp"), &output, None)
|
||||
.expect("add_watermark viet");
|
||||
|
||||
let text = ff_engine::extract_text(&pdf, &output, 0, None).expect("text");
|
||||
assert!(text.contains("Bản nháp"), "watermark tiếng Việt phải đọc lại đúng: {text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_footer_inserts_page_numbers_and_total() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf"); // 3 trang
|
||||
let output = tmp("ff_hf_pagenum.pdf");
|
||||
|
||||
let spec = HeaderFooterSpec {
|
||||
bottom_center: "Trang {page}/{total}".to_string(),
|
||||
top_right: "{date}".to_string(),
|
||||
font_size: 10.0,
|
||||
color: [0, 0, 0, 255],
|
||||
margin_pt: 20.0,
|
||||
date: "2026-06-17".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
ff_engine::add_header_footer(&pdf, &input, &spec, &output, None).expect("add_header_footer");
|
||||
|
||||
for i in 0..3u16 {
|
||||
let text = ff_engine::extract_text(&pdf, &output, i, None).expect("text");
|
||||
let expected = format!("Trang {}/3", i + 1);
|
||||
assert!(text.contains(&expected), "trang {i} phải có '{expected}': {text:?}");
|
||||
assert!(text.contains("2026-06-17"), "trang {i} phải có ngày: {text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_footer_empty_slots_are_skipped() {
|
||||
let pdf = pdfium();
|
||||
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
|
||||
let output = tmp("ff_hf_empty.pdf");
|
||||
|
||||
let before = ff_engine::extract_text(&pdf, &input, 0, None).expect("text before");
|
||||
let spec = HeaderFooterSpec {
|
||||
bottom_left: "ONLY-THIS".to_string(),
|
||||
font_size: 10.0,
|
||||
color: [0, 0, 0, 255],
|
||||
margin_pt: 20.0,
|
||||
..Default::default()
|
||||
};
|
||||
ff_engine::add_header_footer(&pdf, &input, &spec, &output, None).expect("add_header_footer");
|
||||
|
||||
let after = ff_engine::extract_text(&pdf, &output, 0, None).expect("text after");
|
||||
assert!(after.contains("ONLY-THIS"));
|
||||
assert_eq!(
|
||||
after.replace("ONLY-THIS", "").trim_end(),
|
||||
before.trim_end(),
|
||||
"không thêm gì ngoài ô đã điền"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user