Phase 3 (Organize + robust save) + Phase 4 (Edit content); update docs

Engine:
- organize.rs: build_document(plan) core — insert/delete/rotate/crop/extract/merge/split
- watermark.rs: watermark + header/footer via PdfPages::watermark, CID Unicode fonts
- qpdf.rs: QPDF CLI wrapper — repair corrupt files, re-encrypt after edits, ensure_openable
- edit.rs: MOAT — list_objects + apply_edits (SetText VN, Transform, Delete, Add/ReplaceImage)
- Tests: organize/watermark/qpdf_safety/edit roundtrip — 43/43 engine tests green

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

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

Docs: mark Phase 1-4 done in roadmap; document QPDF setup + engine module layout in
05-dev-setup; add 10-13 phase summaries & user-test checklists; README status.
This commit is contained in:
2026-07-14 22:07:38 +07:00
parent 274012f854
commit e442a9f678
26 changed files with 3924 additions and 40 deletions
+140
View File
@@ -0,0 +1,140 @@
//! Lớp an toàn bao ngoài PDFium, dùng QPDF qua CLI (`qpdf.exe` prebuilt, xem
//! `scripts/fetch-qpdf.ps1`) — đúng vai trò đã chốt ở `docs/02-tech-stack.md`
//! ("QPDF cho cấu trúc/mã hoá/repair"):
//!
//! 1. `repair()` — sửa file hỏng (xref/trailer sai) TRƯỚC khi PDFium mở, để
//! không bị `FormatError` trên file PDFium tự đọc thô không nổi.
//! 2. `encrypt_with_password()` — áp lại mật khẩu/mã hoá lên file đã được
//! PDFium/lopdf chỉnh sửa (ghi ra bản KHÔNG mã hoá), để thao tác trang trên
//! file có mật khẩu không làm hỏng/mất mã hoá gốc.
//!
//! QPDF thoát mã 0 = không vấn đề, 3 = thành công nhưng có cảnh báo (file vẫn
//! ghi ra được — vd khi repair xref hỏng), 2 = lỗi thật. Coi 0 và 3 là OK.
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::EngineError;
/// Tìm `qpdf.exe`. Thứ tự: biến môi trường `FOFREEXIT_QPDF_PATH` (đường dẫn
/// tới file exe hoặc thư mục chứa nó) → `<cwd>/qpdf/bin/qpdf.exe` → cạnh file
/// thực thi hiện tại → `qpdf` trong PATH hệ thống.
pub fn find_qpdf() -> Result<PathBuf, EngineError> {
let exe_name = if cfg!(windows) { "qpdf.exe" } else { "qpdf" };
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(p) = std::env::var("FOFREEXIT_QPDF_PATH") {
let p = PathBuf::from(p);
if p.is_dir() {
candidates.push(p.join(exe_name));
} else {
candidates.push(p);
}
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.join("qpdf").join("bin").join(exe_name));
candidates.push(cwd.join(exe_name));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(dir.join(exe_name));
}
}
for c in &candidates {
if c.is_file() {
return Ok(c.clone());
}
}
// Cuối cùng thử PATH hệ thống.
if Command::new(exe_name).arg("--version").output().is_ok() {
return Ok(PathBuf::from(exe_name));
}
Err(EngineError::Pdfium(format!(
"không tìm thấy qpdf. Đặt biến môi trường FOFREEXIT_QPDF_PATH, hoặc chạy scripts/fetch-qpdf.ps1. Đã thử: {}",
candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join("; ")
)))
}
/// `qpdf.exe` hiểu nhầm tiền tố verbatim `\\?\...` (sinh ra bởi
/// `Path::canonicalize()` trên Windows) thành đường dẫn UNC, làm sai lệch
/// path truyền qua command-line. Bỏ tiền tố này trước khi đưa vào argv —
/// an toàn vì các path engine dùng đều ngắn, không cần hỗ trợ path dài.
fn path_arg(p: &Path) -> String {
let s = p.to_string_lossy();
s.strip_prefix(r"\\?\").unwrap_or(&s).to_string()
}
fn run_qpdf(args: &[&str]) -> Result<(), EngineError> {
let qpdf = find_qpdf()?;
let output = Command::new(&qpdf)
.args(args)
.output()
.map_err(|e| EngineError::Pdfium(format!("chạy qpdf thất bại: {e}")))?;
// 0 = OK, 3 = OK nhưng có cảnh báo (vd đã tự sửa xref hỏng). Khác đi = lỗi thật.
match output.status.code() {
Some(0) | Some(3) => Ok(()),
_ => Err(EngineError::Pdfium(format!(
"qpdf lỗi (exit {:?}): {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
))),
}
}
/// Sửa file PDF hỏng (xref/trailer sai, thiếu startxref...) bằng cách đọc rồi
/// ghi lại qua QPDF — QPDF tự dựng lại cross-reference table khi cần. Ghi kết
/// quả ra `output`; không sửa `input`.
pub fn repair(input: &Path, output: &Path) -> Result<(), EngineError> {
let i = path_arg(input);
let o = path_arg(output);
run_qpdf(&[&i, &o])
}
/// Mã hoá `input` (đang KHÔNG mã hoá) thành `output`, dùng AES-256, với user
/// password (mở file) và owner password (đổi quyền) chỉ định. Permissions để
/// mặc định (đầy đủ) cho V1 — đủ cho DoD "ghi không làm hỏng file mã hoá".
pub fn encrypt_with_password(
input: &Path,
output: &Path,
user_password: &str,
owner_password: &str,
) -> Result<(), EngineError> {
let i = path_arg(input);
let o = path_arg(output);
run_qpdf(&[
"--encrypt",
user_password,
owner_password,
"256",
"--",
&i,
&o,
])
}
/// Mở `input` bằng PDFium an toàn cho cả file hỏng và file mã hoá:
/// 1. Nếu mở thẳng được (đúng password nếu có) → dùng luôn.
/// 2. Nếu PDFium báo lỗi format (không phải do password) → thử `repair()` rồi mở lại.
/// Trả về đường dẫn THỰC SỰ nên dùng để mở tiếp (có thể là `input` hoặc 1 file tạm đã repair).
pub fn ensure_openable(
pdfium: &pdfium_render::prelude::Pdfium,
input: &Path,
password: Option<&str>,
) -> Result<PathBuf, EngineError> {
if pdfium.load_pdf_from_file(input, password).is_ok() {
return Ok(input.to_path_buf());
}
let repaired = std::env::temp_dir().join(format!(
"ff_repaired_{}.pdf",
input.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
));
repair(input, &repaired)?;
pdfium
.load_pdf_from_file(&repaired, password)
.map_err(|e| EngineError::Pdfium(format!("vẫn không mở được sau khi repair: {e}")))?;
Ok(repaired)
}