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(())
|
||||
}
|
||||
Reference in New Issue
Block a user