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:
2026-07-14 22:07:38 +07:00
parent 274012f854
commit e442a9f678
26 changed files with 3924 additions and 40 deletions
+1 -1
View File
@@ -292,7 +292,7 @@ fn pdf_text_string(s: &str) -> LoObj {
/// Tìm file TTF có Unicode trên hệ thống theo đậm/nghiêng (Windows/Linux/Mac).
/// Thử đúng file biến thể (vd `arialbd.ttf`) trước, rồi mới hạ cấp dần về regular
/// của family khác — để FreeText giữ đúng đậm/nghiêng như Foxit.
fn find_font_bytes(bold: bool, italic: bool) -> Option<Vec<u8>> {
pub(crate) fn find_font_bytes(bold: bool, italic: bool) -> Option<Vec<u8>> {
#[cfg(windows)]
{
// [regular, bold, italic, bold-italic] cho từng family phổ biến trên Windows.
+374
View File
@@ -0,0 +1,374 @@
//! Chỉnh sửa nội dung trang (Phase 4 — moat chính): liệt kê & sửa trực tiếp
//! các page object (text run / ảnh / path) trên 1 trang.
//!
//! PDFium đã expose page object cấp cao nên KHÔNG cần tự parse content stream:
//! mỗi `PdfPageTextObject` là 1 text run sẵn để sửa (có text/font/size/matrix/
//! màu riêng). Xem khảo sát trong kế hoạch Phase 4.
//!
//! Bài học tái dùng:
//! - Sửa/Thêm text dùng FULL font nhúng (`find_font_bytes` +
//! `load_true_type_from_bytes(..., true)`) để tiếng Việt hiển thị đúng dấu —
//! `set_text()` của PDFium re-encode theo font HIỆN TẠI (thường là subset)
//! nên glyph ngoài subset sẽ mất; vì vậy SetText = tạo lại object bằng full
//! font, giữ nguyên matrix/cỡ/màu gốc.
//! - PDFium cần `regenerate_content()` trước khi lưu, nếu không mất thay đổi.
use std::collections::HashMap;
use std::path::Path;
use pdfium_render::prelude::*;
use crate::annot::find_font_bytes;
use crate::text::Rect;
use crate::EngineError;
/// Loại page object (rút gọn cho UI).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ObjectKind {
Text,
Image,
Path,
Shading,
XObjectForm,
Unsupported,
}
impl ObjectKind {
fn from_pdfium(t: PdfPageObjectType) -> Self {
match t {
PdfPageObjectType::Text => ObjectKind::Text,
PdfPageObjectType::Image => ObjectKind::Image,
PdfPageObjectType::Path => ObjectKind::Path,
PdfPageObjectType::Shading => ObjectKind::Shading,
PdfPageObjectType::XObjectForm => ObjectKind::XObjectForm,
PdfPageObjectType::Unsupported => ObjectKind::Unsupported,
}
}
pub fn as_str(&self) -> &'static str {
match self {
ObjectKind::Text => "text",
ObjectKind::Image => "image",
ObjectKind::Path => "path",
ObjectKind::Shading => "shading",
ObjectKind::XObjectForm => "form",
ObjectKind::Unsupported => "unsupported",
}
}
}
/// Thông tin 1 page object trả về cho UI (để vẽ overlay & sửa).
#[derive(Clone, Debug)]
pub struct ObjectInfo {
/// Index trong danh sách object của trang (0-based, theo thứ tự vẽ/z-order).
pub index: u16,
pub kind: ObjectKind,
/// Khung bao (AABB) theo điểm PDF.
pub rect: Rect,
/// Chỉ với text: nội dung hiện tại.
pub text: Option<String>,
/// Chỉ với text: tên font.
pub font_name: Option<String>,
/// Chỉ với text: cỡ chữ hiệu dụng (đã tính scale của matrix).
pub font_size: Option<f32>,
/// Chỉ với text: màu chữ RGBA.
pub color: Option<[u8; 4]>,
}
/// Một thao tác sửa nội dung. UI dàn dựng danh sách op rồi áp 1 lần khi lưu.
#[derive(Clone, Debug)]
pub enum EditOp {
/// Dịch (dx,dy) và/hoặc scale (sx,sy) object `index` — scale quanh góc
/// dưới-trái của object để giữ neo góc khi resize.
Transform { index: u16, dx: f32, dy: f32, sx: f32, sy: f32 },
/// Sửa nội dung text object `index` (tạo lại bằng full font, giữ matrix gốc).
SetText { index: u16, text: String, font_size: Option<f32>, color: Option<[u8; 4]>, bold: bool, italic: bool },
/// Xoá object `index`.
Delete { index: u16 },
/// Thay ảnh của image object `index` bằng ảnh từ `image_path`, giữ khung cũ.
ReplaceImage { index: u16, image_path: String },
/// Thêm text box mới tại (x,y) (gốc dưới-trái, điểm PDF).
AddText { x: f32, y: f32, text: String, font_size: f32, color: [u8; 4], bold: bool, italic: bool },
/// Thêm ảnh mới từ file, khung width_pt × height_pt tại (x,y).
AddImage { x: f32, y: f32, width_pt: f32, height_pt: f32, image_path: String },
}
fn quad_to_rect(q: &PdfQuadPoints) -> Rect {
Rect {
left: q.left().value,
bottom: q.bottom().value,
right: q.right().value,
top: q.top().value,
}
}
/// Liệt kê các page object của 1 trang để UI vẽ overlay chỉnh sửa.
pub fn list_objects(
pdfium: &Pdfium,
input: &Path,
page_index: u16,
password: Option<&str>,
) -> Result<Vec<ObjectInfo>, EngineError> {
let document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
let mut out = Vec::new();
for (i, object) in page.objects().iter().enumerate() {
let kind = ObjectKind::from_pdfium(object.object_type());
let rect = object
.bounds()
.map(|q| quad_to_rect(&q))
.unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
let (text, font_name, font_size, color) = if let Some(t) = object.as_text_object() {
let c = t.fill_color().ok();
(
Some(t.text()),
Some(t.font().name()),
Some(t.scaled_font_size().value),
c.map(|c| [c.red(), c.green(), c.blue(), c.alpha()]),
)
} else {
(None, None, None, None)
};
out.push(ObjectInfo {
index: i as u16,
kind,
rect,
text,
font_name,
font_size,
color,
});
}
Ok(out)
}
/// Dữ liệu chụp lại từ 1 object trước khi xoá để tạo lại bản thay thế.
struct Captured {
matrix: PdfMatrix,
unscaled_font_size: f32,
color: PdfColor,
rect: Rect,
}
/// Áp danh sách `ops` lên trang `page_index` của `input`, ghi ra `output`.
/// Không sửa `input`. Thứ tự xử lý giữ index gốc hợp lệ: Transform in-place →
/// chụp dữ liệu object sắp thay/xoá → xoá theo index GIẢM DẦN → thêm bản thay
/// thế → thêm object mới → regenerate → lưu.
pub fn apply_edits(
pdfium: &Pdfium,
input: &Path,
page_index: u16,
ops: &[EditOp],
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
let mut document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
// Nạp trước token cho mọi tổ hợp (bold,italic) cần dùng (SetText/AddText) —
// phải làm TRƯỚC khi mượn page vì cần `document.fonts_mut()`.
let mut needed_fonts: Vec<(bool, bool)> = Vec::new();
for op in ops {
match op {
EditOp::SetText { bold, italic, .. } | EditOp::AddText { bold, italic, .. } => {
if !needed_fonts.contains(&(*bold, *italic)) {
needed_fonts.push((*bold, *italic));
}
}
_ => {}
}
}
let mut tokens: HashMap<(bool, bool), PdfFontToken> = HashMap::new();
for (bold, italic) in needed_fonts {
let bytes = find_font_bytes(bold, italic)
.ok_or_else(|| EngineError::Pdfium("không tìm được font hệ thống để sửa/thêm chữ".into()))?;
let token = document
.fonts_mut()
.load_true_type_from_bytes(&bytes, true)
.map_err(|e| EngineError::Pdfium(format!("nạp font: {e}")))?;
tokens.insert((bold, italic), token);
}
let err = |e: PdfiumError| EngineError::Pdfium(format!("sửa nội dung: {e}"));
{
let mut page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
let obj_count = page.objects().len();
let valid = |i: u16| (i as usize) < obj_count;
// (1) Transform in-place (không đổi cấu trúc/index).
for op in ops {
if let EditOp::Transform { index, dx, dy, sx, sy } = op {
if !valid(*index) {
continue;
}
let mut obj = page.objects().get(*index as usize).map_err(err)?;
if (*sx - 1.0).abs() > f32::EPSILON || (*sy - 1.0).abs() > f32::EPSILON {
let b = obj.bounds().map_err(err)?;
let (x0, y0) = (b.left().value, b.bottom().value);
// Scale quanh góc dưới-trái: về gốc → scale → về chỗ cũ.
obj.translate(PdfPoints::new(-x0), PdfPoints::new(-y0)).map_err(err)?;
obj.scale(*sx, *sy).map_err(err)?;
obj.translate(PdfPoints::new(x0), PdfPoints::new(y0)).map_err(err)?;
}
if *dx != 0.0 || *dy != 0.0 {
obj.translate(PdfPoints::new(*dx), PdfPoints::new(*dy)).map_err(err)?;
}
}
}
// (2) Chụp dữ liệu cho object sắp THAY (SetText/ReplaceImage) — sau khi
// đã Transform để bản thay thế kế thừa cả phép biến đổi đó.
let mut captured: HashMap<u16, Captured> = HashMap::new();
for op in ops {
let idx = match op {
EditOp::SetText { index, .. } | EditOp::ReplaceImage { index, .. } => *index,
_ => continue,
};
if !valid(idx) || captured.contains_key(&idx) {
continue;
}
let obj = page.objects().get(idx as usize).map_err(err)?;
let rect = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
let (matrix, unscaled, color) = if let Some(t) = obj.as_text_object() {
(
obj.matrix().map_err(err)?,
t.unscaled_font_size().value,
t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)),
)
} else {
(obj.matrix().map_err(err)?, 0.0, PdfColor::new(0, 0, 0, 255))
};
captured.insert(idx, Captured { matrix, unscaled_font_size: unscaled, color, rect });
}
// (3) Xoá mọi index thuộc SetText/ReplaceImage/Delete theo GIẢM DẦN.
let mut to_remove: Vec<u16> = ops
.iter()
.filter_map(|op| match op {
EditOp::SetText { index, .. }
| EditOp::ReplaceImage { index, .. }
| EditOp::Delete { index } => Some(*index),
_ => None,
})
.filter(|i| valid(*i))
.collect();
to_remove.sort_unstable();
to_remove.dedup();
for idx in to_remove.into_iter().rev() {
let removed = page.objects_mut().remove_object_at_index(idx as usize).map_err(err)?;
// BẪY PDFium: object vừa tách khỏi trang bị đánh dấu "unowned" → Drop
// gọi FPDFPageObj_Destroy, mà destroy object vốn thuộc document gây
// SEGFAULT (chính pdfium-render cũng cảnh báo điều này trong Drop của
// PdfPageObject). Object đã được FPDFPage_RemoveObject tách ra nên sẽ
// không còn render/lưu; ta `forget` để KHỎI gọi destroy. Rò rỉ không
// đáng kể (giải phóng khi document đóng ngay sau khi lưu).
std::mem::forget(removed);
}
// (4) Thêm bản thay thế cho SetText / ReplaceImage.
for op in ops {
match op {
EditOp::SetText { index, text, font_size, color, bold, italic } => {
let cap = match captured.get(index) {
Some(c) => c,
None => continue,
};
let token = tokens.get(&(*bold, *italic)).copied().ok_or_else(|| {
EngineError::Pdfium("thiếu token font cho SetText".into())
})?;
let size = font_size.unwrap_or(cap.unscaled_font_size).max(1.0);
let mut obj = page
.objects_mut()
.create_text_object(PdfPoints::ZERO, PdfPoints::ZERO, text.clone(), token, PdfPoints::new(size))
.map_err(err)?;
// Object mới tạo tại gốc (0,0) cỡ unscaled → matrix ~identity,
// nên apply_matrix(matrix gốc) tương đương đặt lại đúng vị trí/scale.
obj.apply_matrix(cap.matrix).map_err(err)?;
let c = color
.map(|c| PdfColor::new(c[0], c[1], c[2], c[3]))
.unwrap_or(cap.color);
obj.set_fill_color(c).map_err(err)?;
}
EditOp::ReplaceImage { index, image_path } => {
let cap = match captured.get(index) {
Some(c) => c,
None => continue,
};
let img = image::open(image_path)
.map_err(|e| EngineError::Pdfium(format!("đọc ảnh {image_path}: {e}")))?;
let w = (cap.rect.right - cap.rect.left).max(1.0);
let h = (cap.rect.top - cap.rect.bottom).max(1.0);
page.objects_mut()
.create_image_object(
PdfPoints::new(cap.rect.left),
PdfPoints::new(cap.rect.bottom),
&img,
Some(PdfPoints::new(w)),
Some(PdfPoints::new(h)),
)
.map_err(err)?;
}
_ => {}
}
}
// (5) Thêm object MỚI (AddText / AddImage).
for op in ops {
match op {
EditOp::AddText { x, y, text, font_size, color, bold, italic } => {
let token = tokens.get(&(*bold, *italic)).copied().ok_or_else(|| {
EngineError::Pdfium("thiếu token font cho AddText".into())
})?;
let mut obj = page
.objects_mut()
.create_text_object(
PdfPoints::new(*x),
PdfPoints::new(*y),
text.clone(),
token,
PdfPoints::new(font_size.max(1.0)),
)
.map_err(err)?;
obj.set_fill_color(PdfColor::new(color[0], color[1], color[2], color[3]))
.map_err(err)?;
}
EditOp::AddImage { x, y, width_pt, height_pt, image_path } => {
let img = image::open(image_path)
.map_err(|e| EngineError::Pdfium(format!("đọc ảnh {image_path}: {e}")))?;
page.objects_mut()
.create_image_object(
PdfPoints::new(*x),
PdfPoints::new(*y),
&img,
Some(PdfPoints::new(width_pt.max(1.0))),
Some(PdfPoints::new(height_pt.max(1.0))),
)
.map_err(err)?;
}
_ => {}
}
}
page.regenerate_content().map_err(err)?;
}
document
.save_to_file(output)
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
Ok(())
}
+11
View File
@@ -5,16 +5,27 @@
//! ocr... sẽ thêm dần ở các phase sau (xem docs/03-roadmap.md, 04-architecture.md).
pub mod annot;
pub mod edit;
pub mod meta;
pub mod organize;
pub mod qpdf;
pub mod render;
pub mod text;
pub mod watermark;
pub use annot::{
apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec,
};
pub use edit::{apply_edits, list_objects, EditOp, ObjectInfo, ObjectKind};
pub use meta::{outline, page_dims, OutlineItem, PageDim};
pub use organize::{
build_document, delete_pages, extract_pages, identity_plan, merge_files, rotate_pages,
split_by_page_count, PagePlanEntry, PageSource,
};
pub use qpdf::{encrypt_with_password, ensure_openable, find_qpdf, repair};
pub use render::{bind_pdfium, page_count, 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};
/// Lỗi cấp engine.
#[derive(Debug, thiserror::Error)]
+277
View File
@@ -0,0 +1,277 @@
//! Tổ chức trang (Phase 3): chèn/xoá/xoay/trích/thay/đảo/merge/split/crop.
//!
//! Mọi thao tác quy về MỘT core: `build_document` dựng tài liệu MỚI bằng cách
//! copy trang theo đúng thứ tự mô tả trong `plan` từ một hoặc nhiều tài liệu
//! nguồn (PDFium `FPDF_ImportPages*` giữ nguyên nội dung + annotation của
//! trang), rồi áp rotation/crop sau khi copy xong. Insert/Delete/Reorder/
//! Replace/Extract/Merge/Split chỉ là cách build `plan` khác nhau ở tầng gọi
//! (Tauri command) — không cần nhiều hàm engine riêng cho từng thao tác.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use pdfium_render::prelude::*;
use crate::text::Rect;
use crate::EngineError;
/// Nguồn của một trang trong tài liệu đích.
#[derive(Clone, Debug)]
pub enum PageSource {
/// Copy trang `src_index` từ file `source` (None = tài liệu chính truyền
/// vào `build_document`, đã mở kèm `password` nếu có).
Existing { source: Option<PathBuf>, src_index: u16 },
/// Trang trắng mới, kích thước theo điểm PDF.
Blank { width_pt: f32, height_pt: f32 },
}
/// Một "khe" trang trong tài liệu đích.
#[derive(Clone, Debug)]
pub struct PagePlanEntry {
pub page: PageSource,
/// Cộng dồn vào rotation hiện có của trang nguồn (0/90/180/270, có thể âm).
pub rotation_delta: i32,
/// Nếu có: đặt làm CropBox mới (điểm PDF, gốc toạ độ dưới-trái trang).
pub crop: Option<Rect>,
}
impl PagePlanEntry {
pub fn existing(src_index: u16) -> Self {
PagePlanEntry { page: PageSource::Existing { source: None, src_index }, rotation_delta: 0, crop: None }
}
pub fn from_file(source: PathBuf, src_index: u16) -> Self {
PagePlanEntry { page: PageSource::Existing { source: Some(source), src_index }, rotation_delta: 0, crop: None }
}
pub fn blank(width_pt: f32, height_pt: f32) -> Self {
PagePlanEntry { page: PageSource::Blank { width_pt, height_pt }, rotation_delta: 0, crop: None }
}
}
fn rotation_to_enum(deg: i32) -> PdfPageRenderRotation {
match ((deg % 360) + 360) % 360 {
90 => PdfPageRenderRotation::Degrees90,
180 => PdfPageRenderRotation::Degrees180,
270 => PdfPageRenderRotation::Degrees270,
_ => PdfPageRenderRotation::None,
}
}
fn rotation_to_degrees(r: PdfPageRenderRotation) -> i32 {
match r {
PdfPageRenderRotation::None => 0,
PdfPageRenderRotation::Degrees90 => 90,
PdfPageRenderRotation::Degrees180 => 180,
PdfPageRenderRotation::Degrees270 => 270,
}
}
/// Dựng tài liệu mới theo `plan`, ghi ra `output`. `main_input` là tài liệu
/// "đang mở" dùng cho các khe có `source: None`; `password` chỉ áp cho
/// `main_input` (file nguồn khác giả định không mã hoá — đủ cho Phase 3).
pub fn build_document(
pdfium: &Pdfium,
main_input: &Path,
plan: &[PagePlanEntry],
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
if plan.is_empty() {
return Err(EngineError::Pdfium(
"plan rỗng — tài liệu phải có ít nhất 1 trang".into(),
));
}
// Mở mỗi file nguồn đúng 1 lần, dùng lại cho mọi khe trỏ tới nó.
let mut cache: HashMap<PathBuf, PdfDocument> = HashMap::new();
for entry in plan {
if let PageSource::Existing { source, .. } = &entry.page {
let path = source.clone().unwrap_or_else(|| main_input.to_path_buf());
if !cache.contains_key(&path) {
let pw = if path == main_input { password } else { None };
let doc = pdfium
.load_pdf_from_file(&path, pw)
.map_err(|e| EngineError::Pdfium(format!("mở {}: {e}", path.display())))?;
cache.insert(path, doc);
}
}
}
let mut dest = pdfium
.create_new_pdf()
.map_err(|e| EngineError::Pdfium(format!("tạo tài liệu mới: {e}")))?;
for entry in plan {
let dest_index = dest.pages().len();
match &entry.page {
PageSource::Existing { source, src_index } => {
let path = source.clone().unwrap_or_else(|| main_input.to_path_buf());
let src_doc = cache.get(&path).expect("đã mở ở vòng trên");
dest.pages_mut()
.copy_page_from_document(src_doc, *src_index, dest_index)
.map_err(|e| EngineError::Pdfium(format!("copy trang {src_index}: {e}")))?;
}
PageSource::Blank { width_pt, height_pt } => {
dest.pages_mut()
.create_page_at_index(
PdfPagePaperSize::Custom(PdfPoints::new(*width_pt), PdfPoints::new(*height_pt)),
dest_index,
)
.map_err(|e| EngineError::Pdfium(format!("tạo trang trắng: {e}")))?;
}
}
}
// Áp rotation/crop SAU khi đã copy hết toàn bộ (đảm bảo đủ trang & index ổn định).
for (i, entry) in plan.iter().enumerate() {
if entry.rotation_delta == 0 && entry.crop.is_none() {
continue;
}
let mut page = dest
.pages()
.get(i as u16)
.map_err(|e| EngineError::Pdfium(format!("lấy trang đích {i}: {e}")))?;
if entry.rotation_delta != 0 {
let cur = rotation_to_degrees(page.rotation().unwrap_or(PdfPageRenderRotation::None));
page.set_rotation(rotation_to_enum(cur + entry.rotation_delta));
}
if let Some(c) = &entry.crop {
page.boundaries_mut()
.set_crop(PdfRect::new(
PdfPoints::new(c.bottom),
PdfPoints::new(c.left),
PdfPoints::new(c.top),
PdfPoints::new(c.right),
))
.map_err(|e| EngineError::Pdfium(format!("crop trang {i}: {e}")))?;
}
}
dest.save_to_file(output)
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
Ok(())
}
/// Plan "identity": giữ nguyên toàn bộ trang của `input` theo thứ tự hiện tại
/// (dùng làm điểm khởi đầu cho UI Organize, hoặc test).
pub fn identity_plan(pdfium: &Pdfium, input: &Path, password: Option<&str>) -> Result<Vec<PagePlanEntry>, EngineError> {
let count = crate::render::page_count(pdfium, input, password)?;
Ok((0..count).map(PagePlanEntry::existing).collect())
}
/// Xoá các trang có index trong `remove` (0-based) khỏi `input`, ghi ra `output`.
pub fn delete_pages(
pdfium: &Pdfium,
input: &Path,
remove: &[u16],
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
let plan: Vec<PagePlanEntry> = identity_plan(pdfium, input, password)?
.into_iter()
.filter(|e| match &e.page {
PageSource::Existing { src_index, .. } => !remove.contains(src_index),
_ => true,
})
.collect();
if plan.is_empty() {
return Err(EngineError::Pdfium("không thể xoá hết toàn bộ trang".into()));
}
build_document(pdfium, input, &plan, output, password)
}
/// Xoay các trang trong `pages` (0-based, rỗng = tất cả) thêm `delta_deg` độ.
pub fn rotate_pages(
pdfium: &Pdfium,
input: &Path,
pages: &[u16],
delta_deg: i32,
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
let mut plan = identity_plan(pdfium, input, password)?;
for (i, entry) in plan.iter_mut().enumerate() {
if pages.is_empty() || pages.contains(&(i as u16)) {
entry.rotation_delta += delta_deg;
}
}
build_document(pdfium, input, &plan, output, password)
}
/// Trích các trang `pages` (0-based, thứ tự giữ nguyên như truyền vào) của
/// `input` ra một file PDF mới `output` (không sửa `input`).
pub fn extract_pages(
pdfium: &Pdfium,
input: &Path,
pages: &[u16],
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
if pages.is_empty() {
return Err(EngineError::Pdfium("phải chọn ít nhất 1 trang để trích".into()));
}
let plan: Vec<PagePlanEntry> = pages.iter().copied().map(PagePlanEntry::existing).collect();
build_document(pdfium, input, &plan, output, password)
}
/// Trộn nhiều file PDF thành một, theo đúng thứ tự `files` truyền vào (mỗi
/// file lấy toàn bộ trang theo thứ tự gốc). File đầu tiên không cần tồn tại
/// làm "main_input" — chỉ dùng làm tài liệu mở PDFium gốc, không lấy trang từ nó
/// trừ khi nó cũng nằm trong `files`.
pub fn merge_files(pdfium: &Pdfium, files: &[PathBuf], output: &Path) -> Result<(), EngineError> {
if files.is_empty() {
return Err(EngineError::Pdfium("phải có ít nhất 1 file để trộn".into()));
}
let mut plan = Vec::new();
for f in files {
let count = crate::render::page_count(pdfium, f, None)?;
for i in 0..count {
plan.push(PagePlanEntry::from_file(f.clone(), i));
}
}
// main_input chỉ cần là 1 đường dẫn hợp lệ (mọi khe đều có `source` riêng).
build_document(pdfium, &files[0], &plan, output, None)
}
/// Tách `input` thành nhiều file, mỗi file tối đa `pages_per_file` trang liên
/// tiếp. Trả về danh sách đường dẫn đã ghi.
pub fn split_by_page_count(
pdfium: &Pdfium,
input: &Path,
pages_per_file: u16,
out_dir: &Path,
base_name: &str,
password: Option<&str>,
) -> Result<Vec<PathBuf>, EngineError> {
if pages_per_file == 0 {
return Err(EngineError::Pdfium("pages_per_file phải > 0".into()));
}
// Mở file nguồn ĐÚNG 1 LẦN, dùng lại cho mọi phần (tránh mở/parse lại N lần
// như khi gọi build_document mỗi phần — quan trọng với file nhiều trang).
let src = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(format!("mở {}: {e}", input.display())))?;
let count = src.pages().len();
let mut outputs = Vec::new();
let mut start = 0u16;
let mut part = 1u32;
while start < count {
let end = (start + pages_per_file).min(count);
let mut dest = pdfium
.create_new_pdf()
.map_err(|e| EngineError::Pdfium(format!("tạo tài liệu mới: {e}")))?;
for (k, i) in (start..end).enumerate() {
dest.pages_mut()
.copy_page_from_document(&src, i, k as u16)
.map_err(|e| EngineError::Pdfium(format!("copy trang {i}: {e}")))?;
}
let out = out_dir.join(format!("{base_name}_part{part}.pdf"));
dest.save_to_file(&out)
.map_err(|e| EngineError::Pdfium(format!("lưu {}: {e}", out.display())))?;
outputs.push(out);
start = end;
part += 1;
}
Ok(outputs)
}
+140
View File
@@ -0,0 +1,140 @@
//! Lớp an toàn bao ngoài PDFium, dùng QPDF qua CLI (`qpdf.exe` prebuilt, xem
//! `scripts/fetch-qpdf.ps1`) — đúng vai trò đã chốt ở `docs/02-tech-stack.md`
//! ("QPDF cho cấu trúc/mã hoá/repair"):
//!
//! 1. `repair()` — sửa file hỏng (xref/trailer sai) TRƯỚC khi PDFium mở, để
//! không bị `FormatError` trên file PDFium tự đọc thô không nổi.
//! 2. `encrypt_with_password()` — áp lại mật khẩu/mã hoá lên file đã được
//! PDFium/lopdf chỉnh sửa (ghi ra bản KHÔNG mã hoá), để thao tác trang trên
//! file có mật khẩu không làm hỏng/mất mã hoá gốc.
//!
//! QPDF thoát mã 0 = không vấn đề, 3 = thành công nhưng có cảnh báo (file vẫn
//! ghi ra được — vd khi repair xref hỏng), 2 = lỗi thật. Coi 0 và 3 là OK.
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::EngineError;
/// Tìm `qpdf.exe`. Thứ tự: biến môi trường `FOFREEXIT_QPDF_PATH` (đường dẫn
/// tới file exe hoặc thư mục chứa nó) → `<cwd>/qpdf/bin/qpdf.exe` → cạnh file
/// thực thi hiện tại → `qpdf` trong PATH hệ thống.
pub fn find_qpdf() -> Result<PathBuf, EngineError> {
let exe_name = if cfg!(windows) { "qpdf.exe" } else { "qpdf" };
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(p) = std::env::var("FOFREEXIT_QPDF_PATH") {
let p = PathBuf::from(p);
if p.is_dir() {
candidates.push(p.join(exe_name));
} else {
candidates.push(p);
}
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.join("qpdf").join("bin").join(exe_name));
candidates.push(cwd.join(exe_name));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(dir.join(exe_name));
}
}
for c in &candidates {
if c.is_file() {
return Ok(c.clone());
}
}
// Cuối cùng thử PATH hệ thống.
if Command::new(exe_name).arg("--version").output().is_ok() {
return Ok(PathBuf::from(exe_name));
}
Err(EngineError::Pdfium(format!(
"không tìm thấy qpdf. Đặt biến môi trường FOFREEXIT_QPDF_PATH, hoặc chạy scripts/fetch-qpdf.ps1. Đã thử: {}",
candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join("; ")
)))
}
/// `qpdf.exe` hiểu nhầm tiền tố verbatim `\\?\...` (sinh ra bởi
/// `Path::canonicalize()` trên Windows) thành đường dẫn UNC, làm sai lệch
/// path truyền qua command-line. Bỏ tiền tố này trước khi đưa vào argv —
/// an toàn vì các path engine dùng đều ngắn, không cần hỗ trợ path dài.
fn path_arg(p: &Path) -> String {
let s = p.to_string_lossy();
s.strip_prefix(r"\\?\").unwrap_or(&s).to_string()
}
fn run_qpdf(args: &[&str]) -> Result<(), EngineError> {
let qpdf = find_qpdf()?;
let output = Command::new(&qpdf)
.args(args)
.output()
.map_err(|e| EngineError::Pdfium(format!("chạy qpdf thất bại: {e}")))?;
// 0 = OK, 3 = OK nhưng có cảnh báo (vd đã tự sửa xref hỏng). Khác đi = lỗi thật.
match output.status.code() {
Some(0) | Some(3) => Ok(()),
_ => Err(EngineError::Pdfium(format!(
"qpdf lỗi (exit {:?}): {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
))),
}
}
/// Sửa file PDF hỏng (xref/trailer sai, thiếu startxref...) bằng cách đọc rồi
/// ghi lại qua QPDF — QPDF tự dựng lại cross-reference table khi cần. Ghi kết
/// quả ra `output`; không sửa `input`.
pub fn repair(input: &Path, output: &Path) -> Result<(), EngineError> {
let i = path_arg(input);
let o = path_arg(output);
run_qpdf(&[&i, &o])
}
/// Mã hoá `input` (đang KHÔNG mã hoá) thành `output`, dùng AES-256, với user
/// password (mở file) và owner password (đổi quyền) chỉ định. Permissions để
/// mặc định (đầy đủ) cho V1 — đủ cho DoD "ghi không làm hỏng file mã hoá".
pub fn encrypt_with_password(
input: &Path,
output: &Path,
user_password: &str,
owner_password: &str,
) -> Result<(), EngineError> {
let i = path_arg(input);
let o = path_arg(output);
run_qpdf(&[
"--encrypt",
user_password,
owner_password,
"256",
"--",
&i,
&o,
])
}
/// Mở `input` bằng PDFium an toàn cho cả file hỏng và file mã hoá:
/// 1. Nếu mở thẳng được (đúng password nếu có) → dùng luôn.
/// 2. Nếu PDFium báo lỗi format (không phải do password) → thử `repair()` rồi mở lại.
/// Trả về đường dẫn THỰC SỰ nên dùng để mở tiếp (có thể là `input` hoặc 1 file tạm đã repair).
pub fn ensure_openable(
pdfium: &pdfium_render::prelude::Pdfium,
input: &Path,
password: Option<&str>,
) -> Result<PathBuf, EngineError> {
if pdfium.load_pdf_from_file(input, password).is_ok() {
return Ok(input.to_path_buf());
}
let repaired = std::env::temp_dir().join(format!(
"ff_repaired_{}.pdf",
input.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
));
repair(input, &repaired)?;
pdfium
.load_pdf_from_file(&repaired, password)
.map_err(|e| EngineError::Pdfium(format!("vẫn không mở được sau khi repair: {e}")))?;
Ok(repaired)
}
+205
View File
@@ -0,0 +1,205 @@
//! Watermark + Header/Footer + đánh số trang (Phase 3).
//!
//! Dùng API gốc của PDFium (`PdfPages::watermark`) để thêm page object vào
//! mọi trang — không tự dựng content stream bằng tay như đã làm cho FreeText
//! (annot.rs): PDFium tự lo việc mã hoá CID khi font nạp bằng
//! `load_true_type_from_bytes(..., is_cid_font: true)`, nên tiếng Việt hiển
//! thị đúng mà không cần build Type0 dict thủ công.
use std::path::Path;
use pdfium_render::prelude::*;
use crate::annot::find_font_bytes;
use crate::EngineError;
/// Vị trí neo theo lưới 9 điểm, đúng kiểu dialog Watermark của Foxit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Anchor {
TopLeft, TopCenter, TopRight,
MiddleLeft, Center, MiddleRight,
BottomLeft, BottomCenter, BottomRight,
}
/// Watermark văn bản áp cho 1+ trang.
#[derive(Clone, Debug)]
pub struct WatermarkSpec {
pub text: String,
pub font_size: f32,
/// RGBA — alpha (0–255) chính là độ mờ (255 = đậm hẳn, ví dụ Foxit mặc định ~watermark mờ dùng alpha thấp).
pub color: [u8; 4],
pub bold: bool,
pub italic: bool,
/// Độ xoay (độ), dương = ngược chiều kim đồng hồ — khớp quy ước PDFium `rotate_counter_clockwise_degrees`.
pub rotation_deg: f32,
pub anchor: Anchor,
/// 0-based; rỗng = mọi trang.
pub pages: Vec<u16>,
}
/// Header/Footer — 6 ô text (trái/giữa/phải × trên/dưới), hỗ trợ token
/// `{page}` (số trang 1-based), `{total}` (tổng số trang). Token `{date}` do
/// caller tự thay trước (truyền sẵn trong field `date`, rỗng = bỏ token đó).
#[derive(Clone, Debug, Default)]
pub struct HeaderFooterSpec {
pub top_left: String,
pub top_center: String,
pub top_right: String,
pub bottom_left: String,
pub bottom_center: String,
pub bottom_right: String,
pub font_size: f32,
pub color: [u8; 4],
pub margin_pt: f32,
pub bold: bool,
pub italic: bool,
pub date: String,
/// 0-based; rỗng = mọi trang.
pub pages: Vec<u16>,
}
fn substitute(template: &str, page_1based: u16, total: u16, date: &str) -> String {
template
.replace("{page}", &page_1based.to_string())
.replace("{total}", &total.to_string())
.replace("{date}", date)
}
fn applies_to(pages: &[u16], index: u16) -> bool {
pages.is_empty() || pages.contains(&index)
}
fn anchor_xy(anchor: Anchor, page_w: f32, page_h: f32, text_w: f32, text_h: f32, margin: f32) -> (f32, f32) {
let (left, center_x, right) = (margin, (page_w - text_w) / 2.0, page_w - text_w - margin);
let (bottom, center_y, top) = (margin, (page_h - text_h) / 2.0, page_h - text_h - margin);
match anchor {
Anchor::TopLeft => (left, top),
Anchor::TopCenter => (center_x, top),
Anchor::TopRight => (right, top),
Anchor::MiddleLeft => (left, center_y),
Anchor::Center => (center_x, center_y),
Anchor::MiddleRight => (right, center_y),
Anchor::BottomLeft => (left, bottom),
Anchor::BottomCenter => (center_x, bottom),
Anchor::BottomRight => (right, bottom),
}
}
/// Thêm watermark văn bản vào `input`, ghi ra `output`.
pub fn add_watermark(
pdfium: &Pdfium,
input: &Path,
spec: &WatermarkSpec,
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
let mut document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let font_bytes = find_font_bytes(spec.bold, spec.italic)
.ok_or_else(|| EngineError::Pdfium("không tìm được font hệ thống cho watermark".into()))?;
let token = document
.fonts_mut()
.load_true_type_from_bytes(&font_bytes, true)
.map_err(|e| EngineError::Pdfium(format!("nạp font watermark: {e}")))?;
let err = |e: PdfiumError| EngineError::Pdfium(format!("watermark: {e}"));
document
.pages()
.watermark(|group, index, width, height| {
if !applies_to(&spec.pages, index) {
return Ok(());
}
let mut obj = PdfPageTextObject::new(
&document,
spec.text.clone(),
token,
PdfPoints::new(spec.font_size),
)?;
obj.set_fill_color(PdfColor::new(
spec.color[0], spec.color[1], spec.color[2], spec.color[3],
))?;
if spec.rotation_deg != 0.0 {
obj.rotate_counter_clockwise_degrees(spec.rotation_deg)?;
}
let (tw, th) = (obj.width()?.value, obj.height()?.value);
let (x, y) = anchor_xy(spec.anchor, width.value, height.value, tw, th, 0.0);
obj.translate(PdfPoints::new(x), PdfPoints::new(y))?;
group.push(&mut obj.into())
})
.map_err(err)?;
document
.save_to_file(output)
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
Ok(())
}
/// Thêm header/footer (gồm cả đánh số trang qua token `{page}`/`{total}`)
/// vào `input`, ghi ra `output`.
pub fn add_header_footer(
pdfium: &Pdfium,
input: &Path,
spec: &HeaderFooterSpec,
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
let mut document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let font_bytes = find_font_bytes(spec.bold, spec.italic)
.ok_or_else(|| EngineError::Pdfium("không tìm được font hệ thống cho header/footer".into()))?;
let token = document
.fonts_mut()
.load_true_type_from_bytes(&font_bytes, true)
.map_err(|e| EngineError::Pdfium(format!("nạp font header/footer: {e}")))?;
let total = document.pages().len();
let err = |e: PdfiumError| EngineError::Pdfium(format!("header/footer: {e}"));
let slots: [(&str, Anchor); 6] = [
(spec.top_left.as_str(), Anchor::TopLeft),
(spec.top_center.as_str(), Anchor::TopCenter),
(spec.top_right.as_str(), Anchor::TopRight),
(spec.bottom_left.as_str(), Anchor::BottomLeft),
(spec.bottom_center.as_str(), Anchor::BottomCenter),
(spec.bottom_right.as_str(), Anchor::BottomRight),
];
document
.pages()
.watermark(|group, index, width, height| {
if !applies_to(&spec.pages, index) {
return Ok(());
}
for (template, anchor) in slots {
if template.is_empty() {
continue;
}
let text = substitute(template, index + 1, total, &spec.date);
let mut obj = PdfPageTextObject::new(
&document,
text,
token,
PdfPoints::new(spec.font_size),
)?;
obj.set_fill_color(PdfColor::new(
spec.color[0], spec.color[1], spec.color[2], spec.color[3],
))?;
let (tw, th) = (obj.width()?.value, obj.height()?.value);
let (x, y) = anchor_xy(anchor, width.value, height.value, tw, th, spec.margin_pt);
obj.translate(PdfPoints::new(x), PdfPoints::new(y))?;
group.push(&mut obj.into())?;
}
Ok(())
})
.map_err(err)?;
document
.save_to_file(output)
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
Ok(())
}
+218
View File
@@ -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:?}");
}
+109
View File
@@ -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"
);
}