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:
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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