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
+3
View File
@@ -7,6 +7,9 @@ Cargo.lock.bak
/pdfium.dll
/pdfium.dll.lib
# QPDF prebuilt binary (tải bằng scripts/fetch-qpdf.ps1)
/qpdf/
# Tauri / node
/app/node_modules
/app/dist
+12 -1
View File
@@ -28,7 +28,18 @@
- [x] UI: thanh công cụ + vẽ bằng chuột + preview + chọn màu + tab Chú thích + lưu qua hộp thoại
- [ ] Follow-up: Ink, sửa/di chuyển annotation đã lưu, XFDF — xem [docs/08-phase2-summary.md](docs/08-phase2-summary.md)
- **Checklist test cho bạn**: [docs/09-phase2-user-tests.md](docs/09-phase2-user-tests.md)
- [ ] **Phase 3 — Organize trang + lưu file vững** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md)
- [x] **Phase 3 — Organize trang + lưu file vững** ✅ lõi hoàn tất (36/36 test xanh)
- [x] Engine: chèn/xoá/xoay/trích/thay/đảo trang, merge/split, crop, watermark, header/footer + đánh số trang
- [x] Lưu file vững: QPDF repair file hỏng + mã hoá lại an toàn (`qpdf.rs`)
- [x] UI: chế độ "Tổ chức trang" (lưới + kéo-thả + multi-select), undo/redo toàn cục (chú thích + tổ chức trang chung 1 stack), dialog cho mọi thao tác (có xem trước thật cho Watermark/Header-Footer)
- [ ] Follow-up: preview thật cho Insert/Extract/Replace/Crop, tách theo outline, watermark ảnh — xem [docs/10-phase3-summary.md](docs/10-phase3-summary.md)
- **Checklist test cho bạn**: [docs/11-phase3-user-tests.md](docs/11-phase3-user-tests.md)
- [x] **Phase 4 — Chỉnh sửa nội dung (Edit)** ⭐ tính năng lõi/moat ✅ Iteration 1 hoàn tất (43/43 test xanh)
- [x] Engine `edit.rs`: liệt kê object + sửa text run (tiếng Việt), xoá, di chuyển/resize, thêm chữ/ảnh, thay ảnh — round-trip test
- [x] UI chế độ "Sửa nội dung": overlay đối tượng, sửa text tại chỗ WYSIWYG, thêm chữ/ảnh, xoá, cỡ chữ/màu, undo/redo, lưu
- [ ] Follow-up: reflow đoạn nhiều dòng, xoay/lật/clip, tab Format nâng cao, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md)
- **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md)
- [ ] **Phase 5 — Bảo mật & Chữ ký** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md)
Build & chạy: xem [docs/05-dev-setup.md](docs/05-dev-setup.md).
+528 -2
View File
@@ -193,6 +193,12 @@ fn pick_pdf(app: tauri::AppHandle) -> Option<String> {
.map(|fp| fp.to_string())
}
/// Hộp thoại chọn thư mục (dùng cho Tách file — chọn nơi lưu các phần).
#[tauri::command]
fn pick_dir(app: tauri::AppHandle) -> Option<String> {
app.dialog().file().blocking_pick_folder().map(|fp| fp.to_string())
}
/// Hộp thoại lưu file PDF; trả về đường dẫn (hoặc None nếu huỷ).
#[tauri::command]
fn pick_save_pdf(app: tauri::AppHandle) -> Option<String> {
@@ -205,7 +211,7 @@ fn pick_save_pdf(app: tauri::AppHandle) -> Option<String> {
}
/// Hộp chữ nhật đơn giản (1 quad) dùng cho `AnnotSpecDto.quads`.
#[derive(serde::Deserialize)]
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy)]
#[serde(rename_all = "camelCase")]
struct RectDto {
left: f32,
@@ -322,6 +328,509 @@ fn initial_file() -> Option<String> {
}
}
// ==================== Phase 3: Tổ chức trang ====================
/// Nếu PDFium không mở thẳng được `path` (file hỏng xref/trailer), dùng QPDF
/// repair rồi trả về đường dẫn file tạm đã sửa — frontend mở lại bằng path đó.
/// Trả về chính `path` nếu mở thẳng được (không cần repair).
#[tauri::command]
fn ensure_openable(path: String, password: Option<String>) -> Result<String, String> {
let pdfium = pdfium()?;
let usable = ff_engine::ensure_openable(&pdfium, std::path::Path::new(&path), password.as_deref())
.map_err(|e| e.to_string())?;
Ok(usable.to_string_lossy().into_owned())
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct PagePlanEntryDto {
/// "existing" | "blank"
kind: String,
source: Option<String>,
src_index: Option<u16>,
width_pt: Option<f32>,
height_pt: Option<f32>,
#[serde(default)]
rotation_delta: i32,
crop: Option<RectDto>,
}
fn plan_entry_from_dto(d: PagePlanEntryDto) -> Result<ff_engine::PagePlanEntry, String> {
let page = match d.kind.as_str() {
"existing" => ff_engine::PageSource::Existing {
source: d.source.map(PathBuf::from),
src_index: d.src_index.ok_or("thiếu srcIndex cho khe 'existing'")?,
},
"blank" => ff_engine::PageSource::Blank {
width_pt: d.width_pt.ok_or("thiếu widthPt cho khe 'blank'")?,
height_pt: d.height_pt.ok_or("thiếu heightPt cho khe 'blank'")?,
},
other => return Err(format!("loại khe trang không hỗ trợ: {other}")),
};
Ok(ff_engine::PagePlanEntry {
page,
rotation_delta: d.rotation_delta,
crop: d.crop.map(|c| ff_engine::Rect { left: c.left, bottom: c.bottom, right: c.right, top: c.top }),
})
}
fn plan_entry_to_dto(e: &ff_engine::PagePlanEntry) -> PagePlanEntryDto {
let (kind, source, src_index, width_pt, height_pt) = match &e.page {
ff_engine::PageSource::Existing { source, src_index } => {
("existing".to_string(), source.as_ref().map(|p| p.to_string_lossy().into_owned()), Some(*src_index), None, None)
}
ff_engine::PageSource::Blank { width_pt, height_pt } => {
("blank".to_string(), None, None, Some(*width_pt), Some(*height_pt))
}
};
PagePlanEntryDto {
kind,
source,
src_index,
width_pt,
height_pt,
rotation_delta: e.rotation_delta,
crop: e.crop.map(|c| RectDto { left: c.left, bottom: c.bottom, right: c.right, top: c.top }),
}
}
/// Plan "giữ nguyên" — điểm khởi đầu cho UI Tổ chức trang khi mở 1 file.
#[tauri::command]
fn organize_identity_plan(path: String, password: Option<String>) -> Result<Vec<PagePlanEntryDto>, String> {
let pdfium = pdfium()?;
let plan = ff_engine::identity_plan(&pdfium, std::path::Path::new(&path), password.as_deref())
.map_err(|e| e.to_string())?;
Ok(plan.iter().map(plan_entry_to_dto).collect())
}
/// Áp dụng 1 plan trang đầy đủ (chèn/xoá/xoay/đảo/thay/crop) — dựng tài liệu
/// mới ghi ra `output`. `mainInput` dùng cho các khe `source: null`.
#[tauri::command]
fn organize_apply(
main_input: String,
plan: Vec<PagePlanEntryDto>,
output: String,
password: Option<String>,
) -> Result<(), String> {
let pdfium = pdfium()?;
let mapped: Vec<ff_engine::PagePlanEntry> =
plan.into_iter().map(plan_entry_from_dto).collect::<Result<_, String>>()?;
ff_engine::build_document(
&pdfium,
std::path::Path::new(&main_input),
&mapped,
std::path::Path::new(&output),
password.as_deref(),
)
.map_err(|e| e.to_string())
}
/// Trích các trang `pages` (0-based, giữ thứ tự truyền vào) ra file mới.
#[tauri::command]
fn organize_extract(
input: String,
pages: Vec<u16>,
output: String,
password: Option<String>,
) -> Result<(), String> {
let pdfium = pdfium()?;
ff_engine::extract_pages(
&pdfium,
std::path::Path::new(&input),
&pages,
std::path::Path::new(&output),
password.as_deref(),
)
.map_err(|e| e.to_string())
}
/// Trộn nhiều file PDF thành 1, theo đúng thứ tự `files`.
#[tauri::command]
fn organize_merge(files: Vec<String>, output: String) -> Result<(), String> {
let pdfium = pdfium()?;
let paths: Vec<PathBuf> = files.into_iter().map(PathBuf::from).collect();
ff_engine::merge_files(&pdfium, &paths, std::path::Path::new(&output)).map_err(|e| e.to_string())
}
/// Tách `input` thành nhiều file, mỗi file tối đa `pages_per_file` trang.
/// Trả về danh sách đường dẫn đã ghi.
#[tauri::command]
fn organize_split(
input: String,
pages_per_file: u16,
out_dir: String,
base_name: String,
password: Option<String>,
) -> Result<Vec<String>, String> {
let pdfium = pdfium()?;
let outputs = ff_engine::split_by_page_count(
&pdfium,
std::path::Path::new(&input),
pages_per_file,
std::path::Path::new(&out_dir),
&base_name,
password.as_deref(),
)
.map_err(|e| e.to_string())?;
Ok(outputs.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
}
/// Dựng tạm tài liệu theo `plan` HIỆN TẠI (chưa lưu) ra 1 file tạm — dùng làm
/// "input" cho Watermark/Header-Footer khi người dùng đang có thay đổi tổ
/// chức trang (chèn/xoá/đảo/xoay/crop) chưa lưu, để 2 thao tác đó thấy đúng
/// trạng thái đang xem trên lưới, không phải file gốc còn trên đĩa.
#[tauri::command]
fn organize_materialize(
main_input: String,
plan: Vec<PagePlanEntryDto>,
password: Option<String>,
) -> Result<String, String> {
let pdfium = pdfium()?;
let mapped: Vec<ff_engine::PagePlanEntry> =
plan.into_iter().map(plan_entry_from_dto).collect::<Result<_, String>>()?;
let tmp = std::env::temp_dir().join(format!("ff_materialized_{}.pdf", std::process::id()));
ff_engine::build_document(
&pdfium,
std::path::Path::new(&main_input),
&mapped,
&tmp,
password.as_deref(),
)
.map_err(|e| e.to_string())?;
Ok(tmp.to_string_lossy().into_owned())
}
fn parse_anchor(s: &str) -> Result<ff_engine::Anchor, String> {
use ff_engine::Anchor::*;
Ok(match s {
"top-left" => TopLeft,
"top-center" => TopCenter,
"top-right" => TopRight,
"middle-left" => MiddleLeft,
"center" => Center,
"middle-right" => MiddleRight,
"bottom-left" => BottomLeft,
"bottom-center" => BottomCenter,
"bottom-right" => BottomRight,
other => return Err(format!("vị trí neo không hợp lệ: {other}")),
})
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WatermarkDto {
text: String,
font_size: f32,
color: [u8; 4],
#[serde(default)]
bold: bool,
#[serde(default)]
italic: bool,
#[serde(default)]
rotation_deg: f32,
anchor: String,
#[serde(default)]
pages: Vec<u16>,
}
/// Thêm watermark văn bản vào `input`, ghi ra `output`.
#[tauri::command]
fn watermark_add(
input: String,
spec: WatermarkDto,
output: String,
password: Option<String>,
) -> Result<(), String> {
let pdfium = pdfium()?;
let anchor = parse_anchor(&spec.anchor)?;
let ws = ff_engine::WatermarkSpec {
text: spec.text,
font_size: spec.font_size,
color: spec.color,
bold: spec.bold,
italic: spec.italic,
rotation_deg: spec.rotation_deg,
anchor,
pages: spec.pages,
};
ff_engine::add_watermark(&pdfium, std::path::Path::new(&input), &ws, std::path::Path::new(&output), password.as_deref())
.map_err(|e| e.to_string())
}
#[derive(serde::Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct HeaderFooterDto {
#[serde(default)]
top_left: String,
#[serde(default)]
top_center: String,
#[serde(default)]
top_right: String,
#[serde(default)]
bottom_left: String,
#[serde(default)]
bottom_center: String,
#[serde(default)]
bottom_right: String,
font_size: f32,
color: [u8; 4],
margin_pt: f32,
#[serde(default)]
bold: bool,
#[serde(default)]
italic: bool,
#[serde(default)]
date: String,
#[serde(default)]
pages: Vec<u16>,
}
/// Thêm header/footer (gồm đánh số trang qua token {page}/{total}) vào `input`.
#[tauri::command]
fn header_footer_add(
input: String,
spec: HeaderFooterDto,
output: String,
password: Option<String>,
) -> Result<(), String> {
let pdfium = pdfium()?;
let hf = ff_engine::HeaderFooterSpec {
top_left: spec.top_left,
top_center: spec.top_center,
top_right: spec.top_right,
bottom_left: spec.bottom_left,
bottom_center: spec.bottom_center,
bottom_right: spec.bottom_right,
font_size: spec.font_size,
color: spec.color,
margin_pt: spec.margin_pt,
bold: spec.bold,
italic: spec.italic,
date: spec.date,
pages: spec.pages,
};
ff_engine::add_header_footer(&pdfium, std::path::Path::new(&input), &hf, std::path::Path::new(&output), password.as_deref())
.map_err(|e| e.to_string())
}
fn render_temp_page(pdfium: &pdfium_render::prelude::Pdfium, path: &std::path::Path, page: u16, width: u32) -> Result<String, String> {
let rendered = ff_engine::render::render_page(pdfium, path, page, width, None).map_err(|e| e.to_string())?;
let mut buf = Cursor::new(Vec::new());
rendered.image.write_to(&mut buf, image::ImageFormat::Png).map_err(|e| e.to_string())?;
let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref());
Ok(format!("data:image/png;base64,{b64}"))
}
/// Xem trước watermark trên 1 trang: áp vào file tạm rồi render, không sửa `input`.
#[tauri::command]
fn preview_watermark(input: String, page: u16, spec: WatermarkDto, width: u32) -> Result<String, String> {
let pdfium = pdfium()?;
let anchor = parse_anchor(&spec.anchor)?;
let ws = ff_engine::WatermarkSpec {
text: spec.text, font_size: spec.font_size, color: spec.color,
bold: spec.bold, italic: spec.italic, rotation_deg: spec.rotation_deg,
anchor, pages: vec![page],
};
let tmp = std::env::temp_dir().join(format!("ff_preview_wm_{}.pdf", std::process::id()));
ff_engine::add_watermark(&pdfium, std::path::Path::new(&input), &ws, &tmp, None).map_err(|e| e.to_string())?;
let result = render_temp_page(&pdfium, &tmp, page, width);
let _ = std::fs::remove_file(&tmp);
result
}
/// Xem trước header/footer trên 1 trang: áp vào file tạm rồi render, không sửa `input`.
#[tauri::command]
fn preview_header_footer(input: String, page: u16, spec: HeaderFooterDto, width: u32) -> Result<String, String> {
let pdfium = pdfium()?;
let hf = ff_engine::HeaderFooterSpec {
top_left: spec.top_left, top_center: spec.top_center, top_right: spec.top_right,
bottom_left: spec.bottom_left, bottom_center: spec.bottom_center, bottom_right: spec.bottom_right,
font_size: spec.font_size, color: spec.color, margin_pt: spec.margin_pt,
bold: spec.bold, italic: spec.italic, date: spec.date, pages: vec![page],
};
let tmp = std::env::temp_dir().join(format!("ff_preview_hf_{}.pdf", std::process::id()));
ff_engine::add_header_footer(&pdfium, std::path::Path::new(&input), &hf, &tmp, None).map_err(|e| e.to_string())?;
let result = render_temp_page(&pdfium, &tmp, page, width);
let _ = std::fs::remove_file(&tmp);
result
}
// ---------- Phase 4: Sửa nội dung (Edit) ----------
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ObjectInfoDto {
index: u16,
kind: String,
rect: RectDto,
text: Option<String>,
font_name: Option<String>,
font_size: Option<f32>,
color: Option<[u8; 4]>,
}
/// Liệt kê page object của 1 trang để UI vẽ overlay chỉnh sửa.
#[tauri::command]
fn edit_list_objects(path: String, page: u16, password: Option<String>) -> Result<Vec<ObjectInfoDto>, String> {
let pdfium = pdfium()?;
let objs = ff_engine::list_objects(&pdfium, std::path::Path::new(&path), page, password.as_deref())
.map_err(|e| e.to_string())?;
Ok(objs
.into_iter()
.map(|o| ObjectInfoDto {
index: o.index,
kind: o.kind.as_str().to_string(),
rect: RectDto { left: o.rect.left, bottom: o.rect.bottom, right: o.rect.right, top: o.rect.top },
text: o.text,
font_name: o.font_name,
font_size: o.font_size,
color: o.color,
})
.collect())
}
/// 1 thao tác sửa nội dung (tagged theo field `op`). Field thừa cho mỗi loại để None/mặc định.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct EditOpDto {
/// "transform" | "setText" | "delete" | "replaceImage" | "addText" | "addImage"
op: String,
#[serde(default)]
index: u16,
#[serde(default)]
dx: f32,
#[serde(default)]
dy: f32,
#[serde(default = "one")]
sx: f32,
#[serde(default = "one")]
sy: f32,
#[serde(default)]
text: String,
font_size: Option<f32>,
color: Option<[u8; 4]>,
#[serde(default)]
bold: bool,
#[serde(default)]
italic: bool,
#[serde(default)]
x: f32,
#[serde(default)]
y: f32,
#[serde(default)]
width_pt: f32,
#[serde(default)]
height_pt: f32,
#[serde(default)]
image_path: String,
}
fn one() -> f32 {
1.0
}
fn edit_op_from_dto(d: EditOpDto) -> Result<ff_engine::EditOp, String> {
Ok(match d.op.as_str() {
"transform" => ff_engine::EditOp::Transform { index: d.index, dx: d.dx, dy: d.dy, sx: d.sx, sy: d.sy },
"setText" => ff_engine::EditOp::SetText {
index: d.index,
text: d.text,
font_size: d.font_size,
color: d.color,
bold: d.bold,
italic: d.italic,
},
"delete" => ff_engine::EditOp::Delete { index: d.index },
"replaceImage" => ff_engine::EditOp::ReplaceImage { index: d.index, image_path: d.image_path },
"addText" => ff_engine::EditOp::AddText {
x: d.x,
y: d.y,
text: d.text,
font_size: d.font_size.unwrap_or(14.0),
color: d.color.unwrap_or([0, 0, 0, 255]),
bold: d.bold,
italic: d.italic,
},
"addImage" => ff_engine::EditOp::AddImage {
x: d.x,
y: d.y,
width_pt: d.width_pt,
height_pt: d.height_pt,
image_path: d.image_path,
},
other => return Err(format!("op sửa nội dung không hỗ trợ: {other}")),
})
}
/// Áp các thao tác sửa nội dung lên trang `page`, ghi ra `output`.
#[tauri::command]
fn edit_apply(
input: String,
page: u16,
ops: Vec<EditOpDto>,
output: String,
password: Option<String>,
) -> Result<(), String> {
let pdfium = pdfium()?;
let mapped: Vec<ff_engine::EditOp> = ops.into_iter().map(edit_op_from_dto).collect::<Result<_, String>>()?;
ff_engine::apply_edits(
&pdfium,
std::path::Path::new(&input),
page,
&mapped,
std::path::Path::new(&output),
password.as_deref(),
)
.map_err(|e| e.to_string())
}
/// Áp `ops` rồi ghi ra 1 file PDF tạm MỚI (duy nhất), trả về đường dẫn — dùng
/// cho mô hình "materialize tức thì" ở UI: mỗi thao tác sửa tạo 1 bản làm việc
/// mới để đọc lại object + render WYSIWYG, và để undo quay về bản trước.
#[tauri::command]
fn edit_apply_to_temp(input: String, page: u16, ops: Vec<EditOpDto>, password: Option<String>) -> Result<String, String> {
let pdfium = pdfium()?;
let mapped: Vec<ff_engine::EditOp> = ops.into_iter().map(edit_op_from_dto).collect::<Result<_, String>>()?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let out = std::env::temp_dir().join(format!("ff_edit_{}_{}.pdf", std::process::id(), nanos));
ff_engine::apply_edits(
&pdfium,
std::path::Path::new(&input),
page,
&mapped,
&out,
password.as_deref(),
)
.map_err(|e| e.to_string())?;
Ok(out.to_string_lossy().into_owned())
}
/// Xem trước WYSIWYG: áp `ops` vào file tạm rồi render trang `page` ra PNG.
#[tauri::command]
fn edit_preview(input: String, page: u16, ops: Vec<EditOpDto>, width: u32, password: Option<String>) -> Result<String, String> {
let pdfium = pdfium()?;
let mapped: Vec<ff_engine::EditOp> = ops.into_iter().map(edit_op_from_dto).collect::<Result<_, String>>()?;
let tmp = std::env::temp_dir().join(format!("ff_preview_edit_{}.pdf", std::process::id()));
ff_engine::apply_edits(&pdfium, std::path::Path::new(&input), page, &mapped, &tmp, password.as_deref())
.map_err(|e| e.to_string())?;
let result = render_temp_page(&pdfium, &tmp, page, width);
let _ = std::fs::remove_file(&tmp);
result
}
/// Hộp thoại chọn file ảnh (cho Thêm ảnh / Thay ảnh).
#[tauri::command]
fn pick_image(app: tauri::AppHandle) -> Option<String> {
app.dialog()
.file()
.add_filter("Ảnh", &["png", "jpg", "jpeg", "bmp", "gif", "webp"])
.blocking_pick_file()
.map(|fp| fp.to_string())
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
@@ -333,9 +842,26 @@ fn main() {
page_text_layer,
pick_pdf,
pick_save_pdf,
pick_dir,
apply_annotations,
default_pdf,
initial_file
initial_file,
ensure_openable,
organize_identity_plan,
organize_apply,
organize_extract,
organize_merge,
organize_split,
organize_materialize,
watermark_add,
header_footer_add,
preview_watermark,
preview_header_footer,
edit_list_objects,
edit_apply,
edit_apply_to_temp,
edit_preview,
pick_image
])
.run(tauri::generate_context!())
.expect("lỗi khi chạy ứng dụng Tauri");
+54 -5
View File
@@ -30,11 +30,17 @@
<button id="searchNext" title="Kết quả sau (Enter)">▶</button>
<span class="sep"></span>
<button id="copyPage" title="Copy text trang hiện tại">⧉ Copy</button>
<span class="sep"></span>
<button id="undoBtn" title="Hoàn tác (Ctrl+Z)" disabled>↶ Hoàn tác</button>
<button id="redoBtn" title="Làm lại (Ctrl+Y)" disabled>↷ Làm lại</button>
<span class="sep"></span>
<button id="organizeModeBtn" title="Tổ chức trang">🗂 Tổ chức trang</button>
<button id="editModeBtn" title="Sửa nội dung trang">✏️ Sửa nội dung</button>
<div class="spacer"></div>
<span id="status" class="status"></span>
</header>
<div class="annobar">
<div id="annobar" class="annobar">
<span class="annolabel">Chú thích:</span>
<button class="atool" data-tool="highlight" title="Tô sáng (kéo vùng)">🖍️ Tô sáng</button>
<button class="atool" data-tool="underline" title="Gạch chân">U̲ Gạch chân</button>
@@ -42,18 +48,48 @@
<button class="atool" data-tool="square" title="Khung chữ nhật">▭ Khung</button>
<button class="atool" data-tool="freetext" title="Hộp văn bản">🅣 Text box</button>
<button class="atool" data-tool="note" title="Ghi chú dán">📌 Ghi chú</button>
<button class="atool" data-tool="crop" title="Crop trang">✂️ Crop</button>
<span class="sep"></span>
<button id="annotColorBtn" class="atool" title="Màu chú thích"><span class="sw" id="annotSw"></span> Màu</button>
<span class="sep"></span>
<button id="undoBtn" title="Hoàn tác (Ctrl+Z)" disabled>↶ Hoàn tác</button>
<button id="redoBtn" title="Làm lại (Ctrl+Y)" disabled>↷ Làm lại</button>
<span class="spacer"></span>
<span id="annotHint" class="status"></span>
<button id="saveAnnots" disabled>💾 Lưu chú thích (<span id="annotCount">0</span>)</button>
</div>
<div id="organizeBar" class="organizebar hidden">
<span class="annolabel">Tổ chức trang:</span>
<button id="orgInsert" title="Chèn trang">➕ Chèn</button>
<button id="orgDelete" title="Xoá trang đã chọn (Delete)">🗑 Xoá</button>
<button id="orgRotateL" title="Xoay trái">↺ Xoay trái</button>
<button id="orgRotateR" title="Xoay phải">↻ Xoay phải</button>
<button id="orgExtract" title="Trích trang đã chọn">📤 Trích</button>
<button id="orgReplace" title="Thay trang đã chọn">🔁 Thay</button>
<span class="sep"></span>
<button id="orgMerge" title="Trộn nhiều file PDF">🔀 Trộn file</button>
<button id="orgSplit" title="Tách thành nhiều file">✂ Tách file</button>
<button id="orgWatermark" title="Thêm watermark">💧 Watermark</button>
<button id="orgHeaderFooter" title="Thêm header/footer">📋 Header/Footer</button>
<span class="spacer"></span>
<span id="organizeHint" class="status"></span>
<button id="orgSave" disabled>💾 Lưu thay đổi tổ chức trang</button>
</div>
<div id="editBar" class="editbar hidden">
<span class="annolabel">Sửa nội dung:</span>
<button id="edAddText" title="Thêm chữ — bấm rồi click lên trang">🆎 Thêm chữ</button>
<button id="edAddImage" title="Thêm ảnh — chọn ảnh rồi click lên trang">🖼 Thêm ảnh</button>
<button id="edDelete" title="Xoá đối tượng đang chọn (Delete)" disabled>🗑 Xoá</button>
<button id="edReplaceImage" title="Thay ảnh đang chọn" disabled>🔁 Thay ảnh</button>
<span class="sep"></span>
<label class="ed-prop">Cỡ chữ <input id="edFontSize" type="number" min="1" max="400" disabled></label>
<button id="edColorBtn" class="atool" title="Màu chữ" disabled><span class="sw" id="edSw"></span> Màu</button>
<span class="spacer"></span>
<span id="editHint" class="status"></span>
<button id="edSave" disabled>💾 Lưu thay đổi nội dung</button>
</div>
<div class="body">
<aside class="sidebar">
<aside id="sidebar" class="sidebar">
<div class="tabs">
<button id="tabThumbs" class="tab active">Thumbnails</button>
<button id="tabOutline" class="tab">Outline</button>
@@ -67,6 +103,19 @@
<main id="viewport" class="viewport">
<div id="pages" class="pages"></div>
</main>
<main id="organizeGrid" class="organize-grid hidden"></main>
<main id="editStage" class="edit-stage hidden">
<div id="editPageWrap" class="edit-page-wrap">
<img id="editImg" class="edit-img" alt="trang đang sửa"/>
<div id="editOverlay" class="edit-overlay"></div>
</div>
</main>
</div>
<div id="modalOverlay" class="modal-overlay hidden">
<div id="modalBox" class="modal-box"></div>
</div>
<script src="main.js"></script>
+1168 -19
View File
File diff suppressed because it is too large Load Diff
+144
View File
@@ -1,5 +1,6 @@
:root { color-scheme: dark; --accent: #4d8bff; }
* { box-sizing: border-box; }
.hidden { display: none !important; }
html, body { height: 100%; margin: 0; }
body {
font-family: "Segoe UI", system-ui, sans-serif;
@@ -12,6 +13,7 @@ body {
/* Toolbar */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 6px 12px;
@@ -47,6 +49,7 @@ button {
button:hover { background: #595f63; }
button:disabled { opacity: .4; cursor: default; }
#copyPage { background: var(--accent); }
#organizeModeBtn.active { background: var(--accent); }
select.zoom {
background: #1f2224; color: #eee; border: 1px solid #4a4f52;
border-radius: 6px; padding: 4px 4px; font-size: 13px; min-width: 64px;
@@ -63,6 +66,7 @@ input[type="search"] {
/* Annotation toolbar */
.annobar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 5px 12px;
@@ -266,3 +270,143 @@ body.tool-active .annotlayer * { pointer-events: none; }
outline: 1px solid rgba(255, 184, 0, .9);
}
.hl.current { background: rgba(255, 138, 0, .55); }
/* ===== Phase 3: Tổ chức trang ===== */
.organizebar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 5px 12px;
background: #2b2f31;
border-bottom: 1px solid #1c1f21;
flex: 0 0 auto;
}
#orgSave { background: #2e7d32; }
#orgSave:disabled { background: #555; }
.organize-grid {
flex: 1;
overflow: auto;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 18px;
padding: 24px;
background: #3a3e41;
}
.org-card {
width: 160px;
text-align: center;
cursor: pointer;
user-select: none;
border: 2px solid transparent;
border-radius: 6px;
padding: 6px;
background: #2b2f31;
}
.org-card.selected { border-color: var(--accent); background: #2f3a4d; }
.org-card.drag-over { outline: 3px dashed var(--accent); }
.org-card .org-thumb-wrap {
position: relative;
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,.5);
overflow: hidden;
display: flex; align-items: center; justify-content: center;
min-height: 60px;
}
.org-card img { display: block; width: 100%; transition: transform .15s; }
.org-card .org-n { font-size: 12px; color: #ccc; margin-top: 4px; }
.org-card .org-crop-mark {
position: absolute; inset: 10%; border: 1.5px dashed #e53935; pointer-events: none;
}
/* Modal dialog dùng chung cho Insert/Extract/Replace/Crop/Merge/Split/Watermark/Header-Footer */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.55);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal-box {
background: #2b2f31; border-radius: 8px; padding: 16px 20px;
min-width: 340px; max-width: 560px; max-height: 84vh; overflow: auto;
box-shadow: 0 10px 40px rgba(0,0,0,.6); color: #eee;
}
.modal-box h3 { margin: 0 0 12px; font-size: 15px; color: #fff; }
.modal-box label { display: block; font-size: 12px; color: #9aa0a6; margin: 10px 0 3px; }
.modal-box input[type="text"], .modal-box input[type="number"], .modal-box select, .modal-box textarea {
width: 100%; background: #1f2224; color: #eee; border: 1px solid #4a4f52;
border-radius: 6px; padding: 6px 8px; font-size: 13px; box-sizing: border-box;
}
.modal-box .row { display: flex; gap: 8px; }
.modal-box .row > * { flex: 1; }
.modal-box .radiorow { display: flex; gap: 14px; align-items: center; font-size: 13px; margin: 4px 0; }
.modal-box .foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.modal-box .foot button.primary { background: var(--accent); }
.modal-box .anchor9 { display: grid; grid-template-columns: repeat(3, 32px); gap: 4px; }
.modal-box .anchor9 button { width: 32px; height: 28px; padding: 0; background: #3a3f42; }
.modal-box .anchor9 button.cur { background: var(--accent); }
.modal-box .err { color: #ef5350; font-size: 12px; margin-top: 8px; }
/* ===== Phase 4: Sửa nội dung (Edit) ===== */
#editModeBtn.active { background: var(--accent); }
.editbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 5px 12px;
background: #2b2f31;
border-bottom: 1px solid #1c1f21;
flex: 0 0 auto;
}
.editbar .ed-prop { display: flex; align-items: center; gap: 4px; font-size: 13px; }
.editbar .ed-prop input { width: 56px; }
.editbar button.armed { background: var(--accent); }
#edSave { background: #2e7d32; }
#edSave:disabled { background: #555; }
.edit-stage {
flex: 1;
overflow: auto;
background: #444;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px;
user-select: none;
}
.edit-inline { user-select: text; }
.edit-page-wrap { position: relative; box-shadow: 0 2px 12px rgba(0,0,0,.5); background: #fff; }
.edit-img { display: block; }
.edit-overlay { position: absolute; inset: 0; }
.edit-overlay.armed { cursor: crosshair; }
.edit-box {
position: absolute;
border: 1px solid rgba(77,139,255,.5);
box-sizing: border-box;
cursor: move;
}
.edit-box:hover { border-color: var(--accent); background: rgba(77,139,255,.08); }
.edit-box.kind-text { border-style: dashed; }
.edit-box.selected { border: 2px solid var(--accent); background: rgba(77,139,255,.12); }
.edit-box .ed-handle {
position: absolute;
width: 10px; height: 10px;
right: -6px; bottom: -6px;
background: var(--accent);
border: 1px solid #fff;
cursor: nwse-resize;
}
.edit-inline {
position: absolute;
z-index: 5;
border: 2px solid var(--accent);
background: #fff;
color: #000;
padding: 1px 2px;
outline: none;
font-family: inherit;
overflow: hidden;
white-space: pre;
}
+2 -2
View File
@@ -7,12 +7,12 @@ Bộ file PDF dùng để test & hồi quy. Sẽ mở rộng dần theo roadmap.
| `hello.pdf` | PDF tối giản 1 trang, text Helvetica, xref hợp lệ | Smoke test render (Phase 1) |
| `sample-multipage.pdf` | 3 trang Letter, nội dung biết trước + outline (Chapter 1/2/3 → trang 0/1/2) | Test page_dims, extract_text, search, outline; demo viewer |
| `big-1000.pdf` | 1000 trang ("Trang N"); trang index 500 có "ZZMARKER" | Stress-test hiệu năng/lazy-load (mở 55ms, render 110ms, search 79ms) |
| `encrypted.pdf` | `sample-multipage.pdf` mã hoá AES-256 qua qpdf, user password `fofreexit`, owner password `owner-fofreexit` | Test QPDF encrypted-safe-save (Phase 3), bảo mật (Phase 5) |
| `corrupt-truncated.pdf` | `sample-multipage.pdf` bị cắt cụt 200 byte cuối (mất trailer/startxref) — PDFium đọc thẳng bị `FormatError`, qpdf tự dựng lại xref và đọc lại được đủ 3 trang | Test QPDF repair-trước-khi-mở (Phase 3) |
## Cần bổ sung (các phase sau)
- File nhiều trang (100+, 1000+) — hiệu năng & lazy load.
- File scan (ảnh) — OCR (Phase 7).
- File mã hoá (mật khẩu) — bảo mật (Phase 5).
- File hỏng/xref sai — kiểm thử độ bền parser.
- File tiếng Việt (font nhúng, tổ hợp dấu), CJK, RTL — edit text (Phase 4).
- File có AcroForm — form (Phase 6).
+57
View File
@@ -0,0 +1,57 @@
%PDF-1.7
1 0 obj
<< /Type /Catalog /Pages 2 0 R /Outlines 7 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 6 0 R >> >> /Contents 11 0 R >>
endobj
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 6 0 R >> >> /Contents 12 0 R >>
endobj
5 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 6 0 R >> >> /Contents 13 0 R >>
endobj
6 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
7 0 obj
<< /Type /Outlines /First 8 0 R /Last 10 0 R /Count 3 >>
endobj
8 0 obj
<< /Title (Chapter 1) /Parent 7 0 R /Next 9 0 R /Dest [3 0 R /Fit] >>
endobj
9 0 obj
<< /Title (Chapter 2) /Parent 7 0 R /Prev 8 0 R /Next 10 0 R /Dest [4 0 R /Fit] >>
endobj
10 0 obj
<< /Title (Chapter 3) /Parent 7 0 R /Prev 9 0 R /Dest [5 0 R /Fit] >>
endobj
11 0 obj
<< /Length 92 >>
stream
BT /F1 18 Tf 72 720 Td (FoFreeXit Test Document) Tj 0 -28 Td (Page one content alpha) Tj ET
endstream
endobj
12 0 obj
<< /Length 64 >>
stream
BT /F1 18 Tf 72 720 Td (Page two content beta searchterm) Tj ET
endstream
endobj
13 0 obj
<< /Length 56 >>
stream
BT /F1 18 Tf 72 720 Td (Page three content gamma) Tj ET
endstream
endobj
xref
0 14 65535 f
0000000009 00000 n
0000000074 00000 n
0000000143 00000 n
0000000270 00000 n
0000000397 00000 n
0000000524 00000
+72
View File
@@ -0,0 +1,72 @@
%PDF-1.7
%¿÷¢þ
1 0 obj
<< /Extensions << /ADBE << /BaseVersion /1.7 /ExtensionLevel 8 >> >> /Outlines 2 0 R /Pages 3 0 R /Type /Catalog >>
endobj
2 0 obj
<< /Count 3 /First 4 0 R /Last 5 0 R /Type /Outlines >>
endobj
3 0 obj
<< /Count 3 /Kids [ 6 0 R 7 0 R 8 0 R ] /Type /Pages >>
endobj
4 0 obj
<< /Dest [ 6 0 R /Fit ] /Next 9 0 R /Parent 2 0 R /Title <5cfda9dc41ad5c1510ea99b8f3dd8cb47630e4894c742753f0e442af92c89a2e> >>
endobj
5 0 obj
<< /Dest [ 8 0 R /Fit ] /Parent 2 0 R /Prev 9 0 R /Title <fbedf1b46fa1d0014513c989f9b6efaababfeac5695dc976076c21f242fb94cf> >>
endobj
6 0 obj
<< /Contents 10 0 R /MediaBox [ 0 0 612 792 ] /Parent 3 0 R /Resources << /Font << /F1 11 0 R >> >> /Type /Page >>
endobj
7 0 obj
<< /Contents 12 0 R /MediaBox [ 0 0 612 792 ] /Parent 3 0 R /Resources << /Font << /F1 11 0 R >> >> /Type /Page >>
endobj
8 0 obj
<< /Contents 13 0 R /MediaBox [ 0 0 612 792 ] /Parent 3 0 R /Resources << /Font << /F1 11 0 R >> >> /Type /Page >>
endobj
9 0 obj
<< /Dest [ 7 0 R /Fit ] /Next 5 0 R /Parent 2 0 R /Prev 4 0 R /Title <8bce9f40a0e13e698de5e8cbfb65f674327aa79759ee3e060c955133432fd1fd> >>
endobj
10 0 obj
<< /Length 112 /Filter /FlateDecode >>
stream
x5ë„ðç·`9ñ�ƒxH±õ²ÙÃ蜓d )W@8d¹ÅÎÇæEúæ„wW¤oË_r—ЕŒç¼®e÷qZ?”yD�Ä/ôá[l&•i!„bç#¬ëvN¡t˜ˆ-ã1€tËÑv:Ê–È=endstream
endobj
11 0 obj
<< /BaseFont /Helvetica /Subtype /Type1 /Type /Font >>
endobj
12 0 obj
<< /Length 96 /Filter /FlateDecode >>
stream
Ù [ç,äæ½4k‹ß�1ù`c܆þÿç�®=qg†Þ†!ñä7MôîbõTŒaÙ>ñ
Ó£›(N;?{ú6êîŒ×Â? ªÎ”›¡¤ôe­!îè'8vl9Tº-Ô;î¡9endstream
endobj
13 0 obj
<< /Length 80 /Filter /FlateDecode >>
stream
‰áÛþÓu�Õîd␍‘ÙyFÔŒ␍`ÓQ%‹t~™¸¿õŠ–![ôM¾,ÂzÁ—{¤:º¼Hö*†8ã�Éš1f†0£œ¡bnÓ*2�>F©¬�v�¼endstream
endobj
14 0 obj
<< /CF << /StdCF << /AuthEvent /DocOpen /CFM /AESV3 /Length 32 >> >> /Filter /Standard /Length 256 /O <729b37e1523893a80350fc0adc0899599e71059fc8a526122056d53ab29f6eb4fa241f9de0d3d24e17613bf36d76fb8c> /OE <6183208bd3e782306e0f2cdf908823625931b449fd64048833f3232bed06fb5f> /P -4 /Perms <3b2517f4428945cddeb0cc6fdf0f12b5> /R 6 /StmF /StdCF /StrF /StdCF /U <6416d3fecc061e6b27f23dd8fcb0bb06597312e5b444e1b87db30721196a04400045559b493f6f8a19d6bc4df0da8c5e> /UE <97c8e67034f218a724c0c13e3cc846a29293cc3e8eed182cd1130edb8a4dd549> /V 5 >>
endobj
xref
0 15
0000000000 65535 f
0000000015 00000 n
0000000146 00000 n
0000000217 00000 n
0000000288 00000 n
0000000430 00000 n
0000000572 00000 n
0000000702 00000 n
0000000832 00000 n
0000000962 00000 n
0000001116 00000 n
0000001300 00000 n
0000001371 00000 n
0000001538 00000 n
0000001689 00000 n
trailer << /Root 1 0 R /Size 15 /ID [<8e41f82b9afc8df9d8276f2a629270aa><8e41f82b9afc8df9d8276f2a629270aa>] /Encrypt 14 0 R >>
startxref
2237
%%EOF
+1 -1
View File
@@ -292,7 +292,7 @@ fn pdf_text_string(s: &str) -> LoObj {
/// Tìm file TTF có Unicode trên hệ thống theo đậm/nghiêng (Windows/Linux/Mac).
/// Thử đúng file biến thể (vd `arialbd.ttf`) trước, rồi mới hạ cấp dần về regular
/// của family khác — để FreeText giữ đúng đậm/nghiêng như Foxit.
fn find_font_bytes(bold: bool, italic: bool) -> Option<Vec<u8>> {
pub(crate) fn find_font_bytes(bold: bool, italic: bool) -> Option<Vec<u8>> {
#[cfg(windows)]
{
// [regular, bold, italic, bold-italic] cho từng family phổ biến trên Windows.
+374
View File
@@ -0,0 +1,374 @@
//! Chỉnh sửa nội dung trang (Phase 4 — moat chính): liệt kê & sửa trực tiếp
//! các page object (text run / ảnh / path) trên 1 trang.
//!
//! PDFium đã expose page object cấp cao nên KHÔNG cần tự parse content stream:
//! mỗi `PdfPageTextObject` là 1 text run sẵn để sửa (có text/font/size/matrix/
//! màu riêng). Xem khảo sát trong kế hoạch Phase 4.
//!
//! Bài học tái dùng:
//! - Sửa/Thêm text dùng FULL font nhúng (`find_font_bytes` +
//! `load_true_type_from_bytes(..., true)`) để tiếng Việt hiển thị đúng dấu —
//! `set_text()` của PDFium re-encode theo font HIỆN TẠI (thường là subset)
//! nên glyph ngoài subset sẽ mất; vì vậy SetText = tạo lại object bằng full
//! font, giữ nguyên matrix/cỡ/màu gốc.
//! - PDFium cần `regenerate_content()` trước khi lưu, nếu không mất thay đổi.
use std::collections::HashMap;
use std::path::Path;
use pdfium_render::prelude::*;
use crate::annot::find_font_bytes;
use crate::text::Rect;
use crate::EngineError;
/// Loại page object (rút gọn cho UI).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ObjectKind {
Text,
Image,
Path,
Shading,
XObjectForm,
Unsupported,
}
impl ObjectKind {
fn from_pdfium(t: PdfPageObjectType) -> Self {
match t {
PdfPageObjectType::Text => ObjectKind::Text,
PdfPageObjectType::Image => ObjectKind::Image,
PdfPageObjectType::Path => ObjectKind::Path,
PdfPageObjectType::Shading => ObjectKind::Shading,
PdfPageObjectType::XObjectForm => ObjectKind::XObjectForm,
PdfPageObjectType::Unsupported => ObjectKind::Unsupported,
}
}
pub fn as_str(&self) -> &'static str {
match self {
ObjectKind::Text => "text",
ObjectKind::Image => "image",
ObjectKind::Path => "path",
ObjectKind::Shading => "shading",
ObjectKind::XObjectForm => "form",
ObjectKind::Unsupported => "unsupported",
}
}
}
/// Thông tin 1 page object trả về cho UI (để vẽ overlay & sửa).
#[derive(Clone, Debug)]
pub struct ObjectInfo {
/// Index trong danh sách object của trang (0-based, theo thứ tự vẽ/z-order).
pub index: u16,
pub kind: ObjectKind,
/// Khung bao (AABB) theo điểm PDF.
pub rect: Rect,
/// Chỉ với text: nội dung hiện tại.
pub text: Option<String>,
/// Chỉ với text: tên font.
pub font_name: Option<String>,
/// Chỉ với text: cỡ chữ hiệu dụng (đã tính scale của matrix).
pub font_size: Option<f32>,
/// Chỉ với text: màu chữ RGBA.
pub color: Option<[u8; 4]>,
}
/// Một thao tác sửa nội dung. UI dàn dựng danh sách op rồi áp 1 lần khi lưu.
#[derive(Clone, Debug)]
pub enum EditOp {
/// Dịch (dx,dy) và/hoặc scale (sx,sy) object `index` — scale quanh góc
/// dưới-trái của object để giữ neo góc khi resize.
Transform { index: u16, dx: f32, dy: f32, sx: f32, sy: f32 },
/// Sửa nội dung text object `index` (tạo lại bằng full font, giữ matrix gốc).
SetText { index: u16, text: String, font_size: Option<f32>, color: Option<[u8; 4]>, bold: bool, italic: bool },
/// Xoá object `index`.
Delete { index: u16 },
/// Thay ảnh của image object `index` bằng ảnh từ `image_path`, giữ khung cũ.
ReplaceImage { index: u16, image_path: String },
/// Thêm text box mới tại (x,y) (gốc dưới-trái, điểm PDF).
AddText { x: f32, y: f32, text: String, font_size: f32, color: [u8; 4], bold: bool, italic: bool },
/// Thêm ảnh mới từ file, khung width_pt × height_pt tại (x,y).
AddImage { x: f32, y: f32, width_pt: f32, height_pt: f32, image_path: String },
}
fn quad_to_rect(q: &PdfQuadPoints) -> Rect {
Rect {
left: q.left().value,
bottom: q.bottom().value,
right: q.right().value,
top: q.top().value,
}
}
/// Liệt kê các page object của 1 trang để UI vẽ overlay chỉnh sửa.
pub fn list_objects(
pdfium: &Pdfium,
input: &Path,
page_index: u16,
password: Option<&str>,
) -> Result<Vec<ObjectInfo>, EngineError> {
let document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
let mut out = Vec::new();
for (i, object) in page.objects().iter().enumerate() {
let kind = ObjectKind::from_pdfium(object.object_type());
let rect = object
.bounds()
.map(|q| quad_to_rect(&q))
.unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
let (text, font_name, font_size, color) = if let Some(t) = object.as_text_object() {
let c = t.fill_color().ok();
(
Some(t.text()),
Some(t.font().name()),
Some(t.scaled_font_size().value),
c.map(|c| [c.red(), c.green(), c.blue(), c.alpha()]),
)
} else {
(None, None, None, None)
};
out.push(ObjectInfo {
index: i as u16,
kind,
rect,
text,
font_name,
font_size,
color,
});
}
Ok(out)
}
/// Dữ liệu chụp lại từ 1 object trước khi xoá để tạo lại bản thay thế.
struct Captured {
matrix: PdfMatrix,
unscaled_font_size: f32,
color: PdfColor,
rect: Rect,
}
/// Áp danh sách `ops` lên trang `page_index` của `input`, ghi ra `output`.
/// Không sửa `input`. Thứ tự xử lý giữ index gốc hợp lệ: Transform in-place →
/// chụp dữ liệu object sắp thay/xoá → xoá theo index GIẢM DẦN → thêm bản thay
/// thế → thêm object mới → regenerate → lưu.
pub fn apply_edits(
pdfium: &Pdfium,
input: &Path,
page_index: u16,
ops: &[EditOp],
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
let mut document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
// Nạp trước token cho mọi tổ hợp (bold,italic) cần dùng (SetText/AddText) —
// phải làm TRƯỚC khi mượn page vì cần `document.fonts_mut()`.
let mut needed_fonts: Vec<(bool, bool)> = Vec::new();
for op in ops {
match op {
EditOp::SetText { bold, italic, .. } | EditOp::AddText { bold, italic, .. } => {
if !needed_fonts.contains(&(*bold, *italic)) {
needed_fonts.push((*bold, *italic));
}
}
_ => {}
}
}
let mut tokens: HashMap<(bool, bool), PdfFontToken> = HashMap::new();
for (bold, italic) in needed_fonts {
let bytes = find_font_bytes(bold, italic)
.ok_or_else(|| EngineError::Pdfium("không tìm được font hệ thống để sửa/thêm chữ".into()))?;
let token = document
.fonts_mut()
.load_true_type_from_bytes(&bytes, true)
.map_err(|e| EngineError::Pdfium(format!("nạp font: {e}")))?;
tokens.insert((bold, italic), token);
}
let err = |e: PdfiumError| EngineError::Pdfium(format!("sửa nội dung: {e}"));
{
let mut page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
let obj_count = page.objects().len();
let valid = |i: u16| (i as usize) < obj_count;
// (1) Transform in-place (không đổi cấu trúc/index).
for op in ops {
if let EditOp::Transform { index, dx, dy, sx, sy } = op {
if !valid(*index) {
continue;
}
let mut obj = page.objects().get(*index as usize).map_err(err)?;
if (*sx - 1.0).abs() > f32::EPSILON || (*sy - 1.0).abs() > f32::EPSILON {
let b = obj.bounds().map_err(err)?;
let (x0, y0) = (b.left().value, b.bottom().value);
// Scale quanh góc dưới-trái: về gốc → scale → về chỗ cũ.
obj.translate(PdfPoints::new(-x0), PdfPoints::new(-y0)).map_err(err)?;
obj.scale(*sx, *sy).map_err(err)?;
obj.translate(PdfPoints::new(x0), PdfPoints::new(y0)).map_err(err)?;
}
if *dx != 0.0 || *dy != 0.0 {
obj.translate(PdfPoints::new(*dx), PdfPoints::new(*dy)).map_err(err)?;
}
}
}
// (2) Chụp dữ liệu cho object sắp THAY (SetText/ReplaceImage) — sau khi
// đã Transform để bản thay thế kế thừa cả phép biến đổi đó.
let mut captured: HashMap<u16, Captured> = HashMap::new();
for op in ops {
let idx = match op {
EditOp::SetText { index, .. } | EditOp::ReplaceImage { index, .. } => *index,
_ => continue,
};
if !valid(idx) || captured.contains_key(&idx) {
continue;
}
let obj = page.objects().get(idx as usize).map_err(err)?;
let rect = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
let (matrix, unscaled, color) = if let Some(t) = obj.as_text_object() {
(
obj.matrix().map_err(err)?,
t.unscaled_font_size().value,
t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)),
)
} else {
(obj.matrix().map_err(err)?, 0.0, PdfColor::new(0, 0, 0, 255))
};
captured.insert(idx, Captured { matrix, unscaled_font_size: unscaled, color, rect });
}
// (3) Xoá mọi index thuộc SetText/ReplaceImage/Delete theo GIẢM DẦN.
let mut to_remove: Vec<u16> = ops
.iter()
.filter_map(|op| match op {
EditOp::SetText { index, .. }
| EditOp::ReplaceImage { index, .. }
| EditOp::Delete { index } => Some(*index),
_ => None,
})
.filter(|i| valid(*i))
.collect();
to_remove.sort_unstable();
to_remove.dedup();
for idx in to_remove.into_iter().rev() {
let removed = page.objects_mut().remove_object_at_index(idx as usize).map_err(err)?;
// BẪY PDFium: object vừa tách khỏi trang bị đánh dấu "unowned" → Drop
// gọi FPDFPageObj_Destroy, mà destroy object vốn thuộc document gây
// SEGFAULT (chính pdfium-render cũng cảnh báo điều này trong Drop của
// PdfPageObject). Object đã được FPDFPage_RemoveObject tách ra nên sẽ
// không còn render/lưu; ta `forget` để KHỎI gọi destroy. Rò rỉ không
// đáng kể (giải phóng khi document đóng ngay sau khi lưu).
std::mem::forget(removed);
}
// (4) Thêm bản thay thế cho SetText / ReplaceImage.
for op in ops {
match op {
EditOp::SetText { index, text, font_size, color, bold, italic } => {
let cap = match captured.get(index) {
Some(c) => c,
None => continue,
};
let token = tokens.get(&(*bold, *italic)).copied().ok_or_else(|| {
EngineError::Pdfium("thiếu token font cho SetText".into())
})?;
let size = font_size.unwrap_or(cap.unscaled_font_size).max(1.0);
let mut obj = page
.objects_mut()
.create_text_object(PdfPoints::ZERO, PdfPoints::ZERO, text.clone(), token, PdfPoints::new(size))
.map_err(err)?;
// Object mới tạo tại gốc (0,0) cỡ unscaled → matrix ~identity,
// nên apply_matrix(matrix gốc) tương đương đặt lại đúng vị trí/scale.
obj.apply_matrix(cap.matrix).map_err(err)?;
let c = color
.map(|c| PdfColor::new(c[0], c[1], c[2], c[3]))
.unwrap_or(cap.color);
obj.set_fill_color(c).map_err(err)?;
}
EditOp::ReplaceImage { index, image_path } => {
let cap = match captured.get(index) {
Some(c) => c,
None => continue,
};
let img = image::open(image_path)
.map_err(|e| EngineError::Pdfium(format!("đọc ảnh {image_path}: {e}")))?;
let w = (cap.rect.right - cap.rect.left).max(1.0);
let h = (cap.rect.top - cap.rect.bottom).max(1.0);
page.objects_mut()
.create_image_object(
PdfPoints::new(cap.rect.left),
PdfPoints::new(cap.rect.bottom),
&img,
Some(PdfPoints::new(w)),
Some(PdfPoints::new(h)),
)
.map_err(err)?;
}
_ => {}
}
}
// (5) Thêm object MỚI (AddText / AddImage).
for op in ops {
match op {
EditOp::AddText { x, y, text, font_size, color, bold, italic } => {
let token = tokens.get(&(*bold, *italic)).copied().ok_or_else(|| {
EngineError::Pdfium("thiếu token font cho AddText".into())
})?;
let mut obj = page
.objects_mut()
.create_text_object(
PdfPoints::new(*x),
PdfPoints::new(*y),
text.clone(),
token,
PdfPoints::new(font_size.max(1.0)),
)
.map_err(err)?;
obj.set_fill_color(PdfColor::new(color[0], color[1], color[2], color[3]))
.map_err(err)?;
}
EditOp::AddImage { x, y, width_pt, height_pt, image_path } => {
let img = image::open(image_path)
.map_err(|e| EngineError::Pdfium(format!("đọc ảnh {image_path}: {e}")))?;
page.objects_mut()
.create_image_object(
PdfPoints::new(*x),
PdfPoints::new(*y),
&img,
Some(PdfPoints::new(width_pt.max(1.0))),
Some(PdfPoints::new(height_pt.max(1.0))),
)
.map_err(err)?;
}
_ => {}
}
}
page.regenerate_content().map_err(err)?;
}
document
.save_to_file(output)
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
Ok(())
}
+11
View File
@@ -5,16 +5,27 @@
//! ocr... sẽ thêm dần ở các phase sau (xem docs/03-roadmap.md, 04-architecture.md).
pub mod annot;
pub mod edit;
pub mod meta;
pub mod organize;
pub mod qpdf;
pub mod render;
pub mod text;
pub mod watermark;
pub use annot::{
apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec,
};
pub use edit::{apply_edits, list_objects, EditOp, ObjectInfo, ObjectKind};
pub use meta::{outline, page_dims, OutlineItem, PageDim};
pub use organize::{
build_document, delete_pages, extract_pages, identity_plan, merge_files, rotate_pages,
split_by_page_count, PagePlanEntry, PageSource,
};
pub use qpdf::{encrypt_with_password, ensure_openable, find_qpdf, repair};
pub use render::{bind_pdfium, page_count, render_page_png, PageImage};
pub use text::{extract_text, page_char_boxes, search, CharBox, Rect, SearchHit};
pub use watermark::{add_header_footer, add_watermark, Anchor, HeaderFooterSpec, WatermarkSpec};
/// Lỗi cấp engine.
#[derive(Debug, thiserror::Error)]
+277
View File
@@ -0,0 +1,277 @@
//! Tổ chức trang (Phase 3): chèn/xoá/xoay/trích/thay/đảo/merge/split/crop.
//!
//! Mọi thao tác quy về MỘT core: `build_document` dựng tài liệu MỚI bằng cách
//! copy trang theo đúng thứ tự mô tả trong `plan` từ một hoặc nhiều tài liệu
//! nguồn (PDFium `FPDF_ImportPages*` giữ nguyên nội dung + annotation của
//! trang), rồi áp rotation/crop sau khi copy xong. Insert/Delete/Reorder/
//! Replace/Extract/Merge/Split chỉ là cách build `plan` khác nhau ở tầng gọi
//! (Tauri command) — không cần nhiều hàm engine riêng cho từng thao tác.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use pdfium_render::prelude::*;
use crate::text::Rect;
use crate::EngineError;
/// Nguồn của một trang trong tài liệu đích.
#[derive(Clone, Debug)]
pub enum PageSource {
/// Copy trang `src_index` từ file `source` (None = tài liệu chính truyền
/// vào `build_document`, đã mở kèm `password` nếu có).
Existing { source: Option<PathBuf>, src_index: u16 },
/// Trang trắng mới, kích thước theo điểm PDF.
Blank { width_pt: f32, height_pt: f32 },
}
/// Một "khe" trang trong tài liệu đích.
#[derive(Clone, Debug)]
pub struct PagePlanEntry {
pub page: PageSource,
/// Cộng dồn vào rotation hiện có của trang nguồn (0/90/180/270, có thể âm).
pub rotation_delta: i32,
/// Nếu có: đặt làm CropBox mới (điểm PDF, gốc toạ độ dưới-trái trang).
pub crop: Option<Rect>,
}
impl PagePlanEntry {
pub fn existing(src_index: u16) -> Self {
PagePlanEntry { page: PageSource::Existing { source: None, src_index }, rotation_delta: 0, crop: None }
}
pub fn from_file(source: PathBuf, src_index: u16) -> Self {
PagePlanEntry { page: PageSource::Existing { source: Some(source), src_index }, rotation_delta: 0, crop: None }
}
pub fn blank(width_pt: f32, height_pt: f32) -> Self {
PagePlanEntry { page: PageSource::Blank { width_pt, height_pt }, rotation_delta: 0, crop: None }
}
}
fn rotation_to_enum(deg: i32) -> PdfPageRenderRotation {
match ((deg % 360) + 360) % 360 {
90 => PdfPageRenderRotation::Degrees90,
180 => PdfPageRenderRotation::Degrees180,
270 => PdfPageRenderRotation::Degrees270,
_ => PdfPageRenderRotation::None,
}
}
fn rotation_to_degrees(r: PdfPageRenderRotation) -> i32 {
match r {
PdfPageRenderRotation::None => 0,
PdfPageRenderRotation::Degrees90 => 90,
PdfPageRenderRotation::Degrees180 => 180,
PdfPageRenderRotation::Degrees270 => 270,
}
}
/// Dựng tài liệu mới theo `plan`, ghi ra `output`. `main_input` là tài liệu
/// "đang mở" dùng cho các khe có `source: None`; `password` chỉ áp cho
/// `main_input` (file nguồn khác giả định không mã hoá — đủ cho Phase 3).
pub fn build_document(
pdfium: &Pdfium,
main_input: &Path,
plan: &[PagePlanEntry],
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
if plan.is_empty() {
return Err(EngineError::Pdfium(
"plan rỗng — tài liệu phải có ít nhất 1 trang".into(),
));
}
// Mở mỗi file nguồn đúng 1 lần, dùng lại cho mọi khe trỏ tới nó.
let mut cache: HashMap<PathBuf, PdfDocument> = HashMap::new();
for entry in plan {
if let PageSource::Existing { source, .. } = &entry.page {
let path = source.clone().unwrap_or_else(|| main_input.to_path_buf());
if !cache.contains_key(&path) {
let pw = if path == main_input { password } else { None };
let doc = pdfium
.load_pdf_from_file(&path, pw)
.map_err(|e| EngineError::Pdfium(format!("mở {}: {e}", path.display())))?;
cache.insert(path, doc);
}
}
}
let mut dest = pdfium
.create_new_pdf()
.map_err(|e| EngineError::Pdfium(format!("tạo tài liệu mới: {e}")))?;
for entry in plan {
let dest_index = dest.pages().len();
match &entry.page {
PageSource::Existing { source, src_index } => {
let path = source.clone().unwrap_or_else(|| main_input.to_path_buf());
let src_doc = cache.get(&path).expect("đã mở ở vòng trên");
dest.pages_mut()
.copy_page_from_document(src_doc, *src_index, dest_index)
.map_err(|e| EngineError::Pdfium(format!("copy trang {src_index}: {e}")))?;
}
PageSource::Blank { width_pt, height_pt } => {
dest.pages_mut()
.create_page_at_index(
PdfPagePaperSize::Custom(PdfPoints::new(*width_pt), PdfPoints::new(*height_pt)),
dest_index,
)
.map_err(|e| EngineError::Pdfium(format!("tạo trang trắng: {e}")))?;
}
}
}
// Áp rotation/crop SAU khi đã copy hết toàn bộ (đảm bảo đủ trang & index ổn định).
for (i, entry) in plan.iter().enumerate() {
if entry.rotation_delta == 0 && entry.crop.is_none() {
continue;
}
let mut page = dest
.pages()
.get(i as u16)
.map_err(|e| EngineError::Pdfium(format!("lấy trang đích {i}: {e}")))?;
if entry.rotation_delta != 0 {
let cur = rotation_to_degrees(page.rotation().unwrap_or(PdfPageRenderRotation::None));
page.set_rotation(rotation_to_enum(cur + entry.rotation_delta));
}
if let Some(c) = &entry.crop {
page.boundaries_mut()
.set_crop(PdfRect::new(
PdfPoints::new(c.bottom),
PdfPoints::new(c.left),
PdfPoints::new(c.top),
PdfPoints::new(c.right),
))
.map_err(|e| EngineError::Pdfium(format!("crop trang {i}: {e}")))?;
}
}
dest.save_to_file(output)
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
Ok(())
}
/// Plan "identity": giữ nguyên toàn bộ trang của `input` theo thứ tự hiện tại
/// (dùng làm điểm khởi đầu cho UI Organize, hoặc test).
pub fn identity_plan(pdfium: &Pdfium, input: &Path, password: Option<&str>) -> Result<Vec<PagePlanEntry>, EngineError> {
let count = crate::render::page_count(pdfium, input, password)?;
Ok((0..count).map(PagePlanEntry::existing).collect())
}
/// Xoá các trang có index trong `remove` (0-based) khỏi `input`, ghi ra `output`.
pub fn delete_pages(
pdfium: &Pdfium,
input: &Path,
remove: &[u16],
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
let plan: Vec<PagePlanEntry> = identity_plan(pdfium, input, password)?
.into_iter()
.filter(|e| match &e.page {
PageSource::Existing { src_index, .. } => !remove.contains(src_index),
_ => true,
})
.collect();
if plan.is_empty() {
return Err(EngineError::Pdfium("không thể xoá hết toàn bộ trang".into()));
}
build_document(pdfium, input, &plan, output, password)
}
/// Xoay các trang trong `pages` (0-based, rỗng = tất cả) thêm `delta_deg` độ.
pub fn rotate_pages(
pdfium: &Pdfium,
input: &Path,
pages: &[u16],
delta_deg: i32,
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
let mut plan = identity_plan(pdfium, input, password)?;
for (i, entry) in plan.iter_mut().enumerate() {
if pages.is_empty() || pages.contains(&(i as u16)) {
entry.rotation_delta += delta_deg;
}
}
build_document(pdfium, input, &plan, output, password)
}
/// Trích các trang `pages` (0-based, thứ tự giữ nguyên như truyền vào) của
/// `input` ra một file PDF mới `output` (không sửa `input`).
pub fn extract_pages(
pdfium: &Pdfium,
input: &Path,
pages: &[u16],
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
if pages.is_empty() {
return Err(EngineError::Pdfium("phải chọn ít nhất 1 trang để trích".into()));
}
let plan: Vec<PagePlanEntry> = pages.iter().copied().map(PagePlanEntry::existing).collect();
build_document(pdfium, input, &plan, output, password)
}
/// Trộn nhiều file PDF thành một, theo đúng thứ tự `files` truyền vào (mỗi
/// file lấy toàn bộ trang theo thứ tự gốc). File đầu tiên không cần tồn tại
/// làm "main_input" — chỉ dùng làm tài liệu mở PDFium gốc, không lấy trang từ nó
/// trừ khi nó cũng nằm trong `files`.
pub fn merge_files(pdfium: &Pdfium, files: &[PathBuf], output: &Path) -> Result<(), EngineError> {
if files.is_empty() {
return Err(EngineError::Pdfium("phải có ít nhất 1 file để trộn".into()));
}
let mut plan = Vec::new();
for f in files {
let count = crate::render::page_count(pdfium, f, None)?;
for i in 0..count {
plan.push(PagePlanEntry::from_file(f.clone(), i));
}
}
// main_input chỉ cần là 1 đường dẫn hợp lệ (mọi khe đều có `source` riêng).
build_document(pdfium, &files[0], &plan, output, None)
}
/// Tách `input` thành nhiều file, mỗi file tối đa `pages_per_file` trang liên
/// tiếp. Trả về danh sách đường dẫn đã ghi.
pub fn split_by_page_count(
pdfium: &Pdfium,
input: &Path,
pages_per_file: u16,
out_dir: &Path,
base_name: &str,
password: Option<&str>,
) -> Result<Vec<PathBuf>, EngineError> {
if pages_per_file == 0 {
return Err(EngineError::Pdfium("pages_per_file phải > 0".into()));
}
// Mở file nguồn ĐÚNG 1 LẦN, dùng lại cho mọi phần (tránh mở/parse lại N lần
// như khi gọi build_document mỗi phần — quan trọng với file nhiều trang).
let src = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(format!("mở {}: {e}", input.display())))?;
let count = src.pages().len();
let mut outputs = Vec::new();
let mut start = 0u16;
let mut part = 1u32;
while start < count {
let end = (start + pages_per_file).min(count);
let mut dest = pdfium
.create_new_pdf()
.map_err(|e| EngineError::Pdfium(format!("tạo tài liệu mới: {e}")))?;
for (k, i) in (start..end).enumerate() {
dest.pages_mut()
.copy_page_from_document(&src, i, k as u16)
.map_err(|e| EngineError::Pdfium(format!("copy trang {i}: {e}")))?;
}
let out = out_dir.join(format!("{base_name}_part{part}.pdf"));
dest.save_to_file(&out)
.map_err(|e| EngineError::Pdfium(format!("lưu {}: {e}", out.display())))?;
outputs.push(out);
start = end;
part += 1;
}
Ok(outputs)
}
+140
View File
@@ -0,0 +1,140 @@
//! Lớp an toàn bao ngoài PDFium, dùng QPDF qua CLI (`qpdf.exe` prebuilt, xem
//! `scripts/fetch-qpdf.ps1`) — đúng vai trò đã chốt ở `docs/02-tech-stack.md`
//! ("QPDF cho cấu trúc/mã hoá/repair"):
//!
//! 1. `repair()` — sửa file hỏng (xref/trailer sai) TRƯỚC khi PDFium mở, để
//! không bị `FormatError` trên file PDFium tự đọc thô không nổi.
//! 2. `encrypt_with_password()` — áp lại mật khẩu/mã hoá lên file đã được
//! PDFium/lopdf chỉnh sửa (ghi ra bản KHÔNG mã hoá), để thao tác trang trên
//! file có mật khẩu không làm hỏng/mất mã hoá gốc.
//!
//! QPDF thoát mã 0 = không vấn đề, 3 = thành công nhưng có cảnh báo (file vẫn
//! ghi ra được — vd khi repair xref hỏng), 2 = lỗi thật. Coi 0 và 3 là OK.
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::EngineError;
/// Tìm `qpdf.exe`. Thứ tự: biến môi trường `FOFREEXIT_QPDF_PATH` (đường dẫn
/// tới file exe hoặc thư mục chứa nó) → `<cwd>/qpdf/bin/qpdf.exe` → cạnh file
/// thực thi hiện tại → `qpdf` trong PATH hệ thống.
pub fn find_qpdf() -> Result<PathBuf, EngineError> {
let exe_name = if cfg!(windows) { "qpdf.exe" } else { "qpdf" };
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(p) = std::env::var("FOFREEXIT_QPDF_PATH") {
let p = PathBuf::from(p);
if p.is_dir() {
candidates.push(p.join(exe_name));
} else {
candidates.push(p);
}
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.join("qpdf").join("bin").join(exe_name));
candidates.push(cwd.join(exe_name));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(dir.join(exe_name));
}
}
for c in &candidates {
if c.is_file() {
return Ok(c.clone());
}
}
// Cuối cùng thử PATH hệ thống.
if Command::new(exe_name).arg("--version").output().is_ok() {
return Ok(PathBuf::from(exe_name));
}
Err(EngineError::Pdfium(format!(
"không tìm thấy qpdf. Đặt biến môi trường FOFREEXIT_QPDF_PATH, hoặc chạy scripts/fetch-qpdf.ps1. Đã thử: {}",
candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join("; ")
)))
}
/// `qpdf.exe` hiểu nhầm tiền tố verbatim `\\?\...` (sinh ra bởi
/// `Path::canonicalize()` trên Windows) thành đường dẫn UNC, làm sai lệch
/// path truyền qua command-line. Bỏ tiền tố này trước khi đưa vào argv —
/// an toàn vì các path engine dùng đều ngắn, không cần hỗ trợ path dài.
fn path_arg(p: &Path) -> String {
let s = p.to_string_lossy();
s.strip_prefix(r"\\?\").unwrap_or(&s).to_string()
}
fn run_qpdf(args: &[&str]) -> Result<(), EngineError> {
let qpdf = find_qpdf()?;
let output = Command::new(&qpdf)
.args(args)
.output()
.map_err(|e| EngineError::Pdfium(format!("chạy qpdf thất bại: {e}")))?;
// 0 = OK, 3 = OK nhưng có cảnh báo (vd đã tự sửa xref hỏng). Khác đi = lỗi thật.
match output.status.code() {
Some(0) | Some(3) => Ok(()),
_ => Err(EngineError::Pdfium(format!(
"qpdf lỗi (exit {:?}): {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
))),
}
}
/// Sửa file PDF hỏng (xref/trailer sai, thiếu startxref...) bằng cách đọc rồi
/// ghi lại qua QPDF — QPDF tự dựng lại cross-reference table khi cần. Ghi kết
/// quả ra `output`; không sửa `input`.
pub fn repair(input: &Path, output: &Path) -> Result<(), EngineError> {
let i = path_arg(input);
let o = path_arg(output);
run_qpdf(&[&i, &o])
}
/// Mã hoá `input` (đang KHÔNG mã hoá) thành `output`, dùng AES-256, với user
/// password (mở file) và owner password (đổi quyền) chỉ định. Permissions để
/// mặc định (đầy đủ) cho V1 — đủ cho DoD "ghi không làm hỏng file mã hoá".
pub fn encrypt_with_password(
input: &Path,
output: &Path,
user_password: &str,
owner_password: &str,
) -> Result<(), EngineError> {
let i = path_arg(input);
let o = path_arg(output);
run_qpdf(&[
"--encrypt",
user_password,
owner_password,
"256",
"--",
&i,
&o,
])
}
/// Mở `input` bằng PDFium an toàn cho cả file hỏng và file mã hoá:
/// 1. Nếu mở thẳng được (đúng password nếu có) → dùng luôn.
/// 2. Nếu PDFium báo lỗi format (không phải do password) → thử `repair()` rồi mở lại.
/// Trả về đường dẫn THỰC SỰ nên dùng để mở tiếp (có thể là `input` hoặc 1 file tạm đã repair).
pub fn ensure_openable(
pdfium: &pdfium_render::prelude::Pdfium,
input: &Path,
password: Option<&str>,
) -> Result<PathBuf, EngineError> {
if pdfium.load_pdf_from_file(input, password).is_ok() {
return Ok(input.to_path_buf());
}
let repaired = std::env::temp_dir().join(format!(
"ff_repaired_{}.pdf",
input.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
));
repair(input, &repaired)?;
pdfium
.load_pdf_from_file(&repaired, password)
.map_err(|e| EngineError::Pdfium(format!("vẫn không mở được sau khi repair: {e}")))?;
Ok(repaired)
}
+205
View File
@@ -0,0 +1,205 @@
//! Watermark + Header/Footer + đánh số trang (Phase 3).
//!
//! Dùng API gốc của PDFium (`PdfPages::watermark`) để thêm page object vào
//! mọi trang — không tự dựng content stream bằng tay như đã làm cho FreeText
//! (annot.rs): PDFium tự lo việc mã hoá CID khi font nạp bằng
//! `load_true_type_from_bytes(..., is_cid_font: true)`, nên tiếng Việt hiển
//! thị đúng mà không cần build Type0 dict thủ công.
use std::path::Path;
use pdfium_render::prelude::*;
use crate::annot::find_font_bytes;
use crate::EngineError;
/// Vị trí neo theo lưới 9 điểm, đúng kiểu dialog Watermark của Foxit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Anchor {
TopLeft, TopCenter, TopRight,
MiddleLeft, Center, MiddleRight,
BottomLeft, BottomCenter, BottomRight,
}
/// Watermark văn bản áp cho 1+ trang.
#[derive(Clone, Debug)]
pub struct WatermarkSpec {
pub text: String,
pub font_size: f32,
/// RGBA — alpha (0–255) chính là độ mờ (255 = đậm hẳn, ví dụ Foxit mặc định ~watermark mờ dùng alpha thấp).
pub color: [u8; 4],
pub bold: bool,
pub italic: bool,
/// Độ xoay (độ), dương = ngược chiều kim đồng hồ — khớp quy ước PDFium `rotate_counter_clockwise_degrees`.
pub rotation_deg: f32,
pub anchor: Anchor,
/// 0-based; rỗng = mọi trang.
pub pages: Vec<u16>,
}
/// Header/Footer — 6 ô text (trái/giữa/phải × trên/dưới), hỗ trợ token
/// `{page}` (số trang 1-based), `{total}` (tổng số trang). Token `{date}` do
/// caller tự thay trước (truyền sẵn trong field `date`, rỗng = bỏ token đó).
#[derive(Clone, Debug, Default)]
pub struct HeaderFooterSpec {
pub top_left: String,
pub top_center: String,
pub top_right: String,
pub bottom_left: String,
pub bottom_center: String,
pub bottom_right: String,
pub font_size: f32,
pub color: [u8; 4],
pub margin_pt: f32,
pub bold: bool,
pub italic: bool,
pub date: String,
/// 0-based; rỗng = mọi trang.
pub pages: Vec<u16>,
}
fn substitute(template: &str, page_1based: u16, total: u16, date: &str) -> String {
template
.replace("{page}", &page_1based.to_string())
.replace("{total}", &total.to_string())
.replace("{date}", date)
}
fn applies_to(pages: &[u16], index: u16) -> bool {
pages.is_empty() || pages.contains(&index)
}
fn anchor_xy(anchor: Anchor, page_w: f32, page_h: f32, text_w: f32, text_h: f32, margin: f32) -> (f32, f32) {
let (left, center_x, right) = (margin, (page_w - text_w) / 2.0, page_w - text_w - margin);
let (bottom, center_y, top) = (margin, (page_h - text_h) / 2.0, page_h - text_h - margin);
match anchor {
Anchor::TopLeft => (left, top),
Anchor::TopCenter => (center_x, top),
Anchor::TopRight => (right, top),
Anchor::MiddleLeft => (left, center_y),
Anchor::Center => (center_x, center_y),
Anchor::MiddleRight => (right, center_y),
Anchor::BottomLeft => (left, bottom),
Anchor::BottomCenter => (center_x, bottom),
Anchor::BottomRight => (right, bottom),
}
}
/// Thêm watermark văn bản vào `input`, ghi ra `output`.
pub fn add_watermark(
pdfium: &Pdfium,
input: &Path,
spec: &WatermarkSpec,
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
let mut document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let font_bytes = find_font_bytes(spec.bold, spec.italic)
.ok_or_else(|| EngineError::Pdfium("không tìm được font hệ thống cho watermark".into()))?;
let token = document
.fonts_mut()
.load_true_type_from_bytes(&font_bytes, true)
.map_err(|e| EngineError::Pdfium(format!("nạp font watermark: {e}")))?;
let err = |e: PdfiumError| EngineError::Pdfium(format!("watermark: {e}"));
document
.pages()
.watermark(|group, index, width, height| {
if !applies_to(&spec.pages, index) {
return Ok(());
}
let mut obj = PdfPageTextObject::new(
&document,
spec.text.clone(),
token,
PdfPoints::new(spec.font_size),
)?;
obj.set_fill_color(PdfColor::new(
spec.color[0], spec.color[1], spec.color[2], spec.color[3],
))?;
if spec.rotation_deg != 0.0 {
obj.rotate_counter_clockwise_degrees(spec.rotation_deg)?;
}
let (tw, th) = (obj.width()?.value, obj.height()?.value);
let (x, y) = anchor_xy(spec.anchor, width.value, height.value, tw, th, 0.0);
obj.translate(PdfPoints::new(x), PdfPoints::new(y))?;
group.push(&mut obj.into())
})
.map_err(err)?;
document
.save_to_file(output)
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
Ok(())
}
/// Thêm header/footer (gồm cả đánh số trang qua token `{page}`/`{total}`)
/// vào `input`, ghi ra `output`.
pub fn add_header_footer(
pdfium: &Pdfium,
input: &Path,
spec: &HeaderFooterSpec,
output: &Path,
password: Option<&str>,
) -> Result<(), EngineError> {
let mut document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let font_bytes = find_font_bytes(spec.bold, spec.italic)
.ok_or_else(|| EngineError::Pdfium("không tìm được font hệ thống cho header/footer".into()))?;
let token = document
.fonts_mut()
.load_true_type_from_bytes(&font_bytes, true)
.map_err(|e| EngineError::Pdfium(format!("nạp font header/footer: {e}")))?;
let total = document.pages().len();
let err = |e: PdfiumError| EngineError::Pdfium(format!("header/footer: {e}"));
let slots: [(&str, Anchor); 6] = [
(spec.top_left.as_str(), Anchor::TopLeft),
(spec.top_center.as_str(), Anchor::TopCenter),
(spec.top_right.as_str(), Anchor::TopRight),
(spec.bottom_left.as_str(), Anchor::BottomLeft),
(spec.bottom_center.as_str(), Anchor::BottomCenter),
(spec.bottom_right.as_str(), Anchor::BottomRight),
];
document
.pages()
.watermark(|group, index, width, height| {
if !applies_to(&spec.pages, index) {
return Ok(());
}
for (template, anchor) in slots {
if template.is_empty() {
continue;
}
let text = substitute(template, index + 1, total, &spec.date);
let mut obj = PdfPageTextObject::new(
&document,
text,
token,
PdfPoints::new(spec.font_size),
)?;
obj.set_fill_color(PdfColor::new(
spec.color[0], spec.color[1], spec.color[2], spec.color[3],
))?;
let (tw, th) = (obj.width()?.value, obj.height()?.value);
let (x, y) = anchor_xy(anchor, width.value, height.value, tw, th, spec.margin_pt);
obj.translate(PdfPoints::new(x), PdfPoints::new(y))?;
group.push(&mut obj.into())?;
}
Ok(())
})
.map_err(err)?;
document
.save_to_file(output)
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
Ok(())
}
+218
View File
@@ -0,0 +1,218 @@
//! Round-trip test cho chỉnh sửa nội dung (Phase 4 — moat chính): liệt kê
//! object, sửa text (gồm tiếng Việt), xoá, di chuyển, thêm chữ/ảnh. Đường GHI
//! file rủi ro nhất nên thao tác rồi đọc lại để so khớp.
//!
//! Lưu ý bẫy mutex PDFium (đã ghi memory): mọi assert chạy SAU khi ff_engine
//! đã trả về (doc đã drop), nên panic assert không poison mutex toàn cục.
use std::path::PathBuf;
use ff_engine::{EditOp, ObjectKind};
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn pdfium() -> pdfium_render::prelude::Pdfium {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
ff_engine::bind_pdfium().expect("nạp PDFium")
}
fn sample() -> PathBuf {
workspace_root().join("corpus").join("sample-multipage.pdf")
}
fn tmp(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(name);
let _ = std::fs::remove_file(&p);
p
}
/// Index của text object đầu tiên trên trang 0 có nội dung chứa `needle`.
fn find_text_index(pdf: &pdfium_render::prelude::Pdfium, input: &std::path::Path, needle: &str) -> u16 {
let objs = ff_engine::list_objects(pdf, input, 0, None).expect("list_objects");
objs.into_iter()
.find(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false))
.unwrap_or_else(|| panic!("không tìm thấy text object chứa {needle:?}"))
.index
}
#[test]
fn list_objects_finds_page_text() {
let pdf = pdfium();
let objs = ff_engine::list_objects(&pdf, &sample(), 0, None).expect("list_objects");
assert!(!objs.is_empty(), "trang 0 phải có object");
let has_text = objs
.iter()
.any(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|t| t.contains("Page one") || t.contains("FoFreeXit")).unwrap_or(false));
assert!(has_text, "phải thấy text object của trang 1: {objs:?}");
}
#[test]
fn set_text_replaces_run_content() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_settext.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: "Edited line ABC".into(),
font_size: None,
color: None,
bold: false,
italic: false,
}],
&out,
None,
)
.expect("apply_edits SetText");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Edited line ABC"), "phải có text mới: {text:?}");
assert!(!text.contains("Page one content alpha"), "text cũ phải biến mất: {text:?}");
}
#[test]
fn set_text_vietnamese_round_trips() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_vi.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let vi = "Sửa: nội dung Tiếng Việt";
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: vi.into(),
font_size: None,
color: None,
bold: false,
italic: false,
}],
&out,
None,
)
.expect("apply_edits SetText VI");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Tiếng Việt"), "tiếng Việt phải đúng dấu: {text:?}");
}
#[test]
fn delete_object_reduces_count() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_delete.pdf");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list").len();
assert!(before >= 1);
ff_engine::apply_edits(&pdf, &input, 0, &[EditOp::Delete { index: 0 }], &out, None)
.expect("apply_edits Delete");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out").len();
assert_eq!(after, before - 1, "xoá 1 object phải giảm đúng 1");
}
#[test]
fn transform_translate_moves_object() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_move.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list");
let orig_left = before.iter().find(|o| o.index == idx).expect("obj").rect.left;
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::Transform { index: idx, dx: 50.0, dy: 0.0, sx: 1.0, sy: 1.0 }],
&out,
None,
)
.expect("apply_edits Transform");
// Object đã dịch giữ nguyên thứ tự index (không xoá/thêm) → so cùng index.
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let new_left = after.iter().find(|o| o.index == idx).expect("obj out").rect.left;
assert!((new_left - (orig_left + 50.0)).abs() < 2.0, "left phải dịch ~+50: {orig_left} -> {new_left}");
}
#[test]
fn add_text_appears_in_extract() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_addtext.pdf");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::AddText {
x: 100.0,
y: 100.0,
text: "ADDEDXYZ".into(),
font_size: 14.0,
color: [10, 20, 30, 255],
bold: false,
italic: false,
}],
&out,
None,
)
.expect("apply_edits AddText");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("ADDEDXYZ"), "text thêm mới phải xuất hiện: {text:?}");
}
#[test]
fn add_image_adds_image_object() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_addimg.pdf");
// Tạo 1 PNG đỏ 24x24 làm fixture.
let png = tmp("ff_edit_fixture.png");
let mut img = image::RgbImage::new(24, 24);
for p in img.pixels_mut() {
*p = image::Rgb([220, 30, 30]);
}
img.save(&png).expect("save png");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list").len();
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::AddImage {
x: 50.0,
y: 50.0,
width_pt: 80.0,
height_pt: 60.0,
image_path: png.to_string_lossy().into_owned(),
}],
&out,
None,
)
.expect("apply_edits AddImage");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
assert_eq!(after.len(), before + 1, "thêm ảnh phải tăng 1 object");
assert!(after.iter().any(|o| o.kind == ObjectKind::Image), "phải có object kind Image");
}
@@ -0,0 +1,173 @@
//! Round-trip test cho thao tác tổ chức trang (Phase 3): xoá/xoay/trích/
//! merge/split/crop/chèn trang trắng — đường GHI file rủi ro nhất nên test
//! kỹ bằng cách thao tác rồi đọc lại, so khớp số trang/thứ tự/nội dung.
use std::path::PathBuf;
use ff_engine::{PagePlanEntry, Rect};
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn pdfium() -> pdfium_render::prelude::Pdfium {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
ff_engine::bind_pdfium().expect("nạp PDFium")
}
fn tmp(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(name);
let _ = std::fs::remove_file(&p);
p
}
#[test]
fn delete_pages_keeps_remaining_content_and_order() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = tmp("ff_org_delete.pdf");
// sample-multipage.pdf: 3 trang, outline Chapter 1/2/3 -> trang 0/1/2.
ff_engine::delete_pages(&pdf, &input, &[1], &output, None).expect("delete_pages");
assert_eq!(ff_engine::page_count(&pdf, &output, None).expect("count"), 2);
let p0 = ff_engine::extract_text(&pdf, &output, 0, None).expect("text p0");
let p1 = ff_engine::extract_text(&pdf, &output, 1, None).expect("text p1");
assert!(p0.contains("1") || !p0.is_empty(), "trang 0 gốc phải còn nguyên: {p0:?}");
assert!(!p1.is_empty(), "trang 2 gốc (giờ là trang 1) phải còn nguyên: {p1:?}");
// Trang giữa (index 1 gốc) không còn xuất hiện ở bất kỳ đâu trong output.
assert_ne!(p0, p1);
}
#[test]
fn delete_all_pages_is_rejected() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = tmp("ff_org_delete_all.pdf");
let err = ff_engine::delete_pages(&pdf, &input, &[0, 1, 2], &output, None);
assert!(err.is_err(), "không được cho xoá hết toàn bộ trang");
}
#[test]
fn rotate_pages_persists_in_saved_file() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = tmp("ff_org_rotate.pdf");
ff_engine::rotate_pages(&pdf, &input, &[0], 90, &output, None).expect("rotate_pages");
let doc = pdf.load_pdf_from_file(&output, None).expect("mở lại");
let page0 = doc.pages().get(0).expect("trang 0");
let page1 = doc.pages().get(1).expect("trang 1");
assert_eq!(
page0.rotation().expect("rotation p0"),
pdfium_render::prelude::PdfPageRenderRotation::Degrees90,
"trang 0 phải xoay 90 độ"
);
assert_eq!(
page1.rotation().expect("rotation p1"),
pdfium_render::prelude::PdfPageRenderRotation::None,
"trang 1 không bị xoay"
);
}
#[test]
fn extract_pages_creates_new_file_without_touching_input() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = tmp("ff_org_extract.pdf");
ff_engine::extract_pages(&pdf, &input, &[2, 0], &output, None).expect("extract_pages");
assert_eq!(ff_engine::page_count(&pdf, &output, None).expect("count"), 2);
assert_eq!(ff_engine::page_count(&pdf, &input, None).expect("count input"), 3, "input không bị sửa");
// Thứ tự đúng theo `pages` truyền vào: [2, 0] -> output trang 0 = input trang 2.
let out_p0 = ff_engine::extract_text(&pdf, &output, 0, None).expect("text");
let in_p2 = ff_engine::extract_text(&pdf, &input, 2, None).expect("text");
assert_eq!(out_p0, in_p2, "trích đúng thứ tự đã chọn");
}
#[test]
fn merge_files_concatenates_in_order() {
let pdf = pdfium();
let a = workspace_root().join("corpus").join("hello.pdf");
let b = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = tmp("ff_org_merge.pdf");
ff_engine::merge_files(&pdf, &[a.clone(), b.clone()], &output).expect("merge_files");
let count_a = ff_engine::page_count(&pdf, &a, None).expect("count a");
let count_b = ff_engine::page_count(&pdf, &b, None).expect("count b");
assert_eq!(
ff_engine::page_count(&pdf, &output, None).expect("count out"),
count_a + count_b
);
let merged_first = ff_engine::extract_text(&pdf, &output, 0, None).expect("text");
let a_first = ff_engine::extract_text(&pdf, &a, 0, None).expect("text");
assert_eq!(merged_first, a_first, "trang đầu output phải khớp file a");
}
#[test]
fn split_by_page_count_produces_correct_chunks() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf"); // 3 trang
let out_dir = std::env::temp_dir();
let parts = ff_engine::split_by_page_count(&pdf, &input, 2, &out_dir, "ff_org_split", None)
.expect("split_by_page_count");
assert_eq!(parts.len(), 2, "3 trang / 2 mỗi file = 2 phần (2+1)");
assert_eq!(ff_engine::page_count(&pdf, &parts[0], None).expect("count"), 2);
assert_eq!(ff_engine::page_count(&pdf, &parts[1], None).expect("count"), 1);
for p in &parts {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn crop_sets_cropbox_on_saved_file() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = tmp("ff_org_crop.pdf");
let mut plan = ff_engine::identity_plan(&pdf, &input, None).expect("identity_plan");
plan[0].crop = Some(Rect { left: 50.0, bottom: 60.0, right: 500.0, top: 700.0 });
ff_engine::build_document(&pdf, &input, &plan, &output, None).expect("build_document crop");
let doc = pdf.load_pdf_from_file(&output, None).expect("mở lại");
let page0 = doc.pages().get(0).expect("trang 0");
let crop = page0.boundaries().crop().expect("crop box");
assert!((crop.bounds.left().value - 50.0).abs() < 0.5);
assert!((crop.bounds.bottom().value - 60.0).abs() < 0.5);
assert!((crop.bounds.right().value - 500.0).abs() < 0.5);
assert!((crop.bounds.top().value - 700.0).abs() < 0.5);
}
#[test]
fn insert_blank_page_has_correct_size_and_no_text() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = tmp("ff_org_blank.pdf");
let mut plan = ff_engine::identity_plan(&pdf, &input, None).expect("identity_plan");
plan.insert(1, PagePlanEntry::blank(300.0, 400.0));
ff_engine::build_document(&pdf, &input, &plan, &output, None).expect("build_document blank");
assert_eq!(ff_engine::page_count(&pdf, &output, None).expect("count"), 4);
let doc = pdf.load_pdf_from_file(&output, None).expect("mở lại");
let blank = doc.pages().get(1).expect("trang trắng");
assert!((blank.width().value - 300.0).abs() < 0.5);
assert!((blank.height().value - 400.0).abs() < 0.5);
drop(doc);
let text = ff_engine::extract_text(&pdf, &output, 1, None).expect("text trang trắng");
assert!(text.trim().is_empty(), "trang trắng không được có text: {text:?}");
}
+109
View File
@@ -0,0 +1,109 @@
//! Test lớp an toàn QPDF (Phase 3): repair file hỏng trước khi PDFium mở,
//! và mã hoá lại file sau khi PDFium/lopdf chỉnh sửa — đường GHI/MỞ file rủi
//! ro nhất nên test bằng fixture THẬT (corrupt-truncated.pdf bị PDFium từ
//! chối thẳng, encrypted.pdf cần đúng password).
use std::path::PathBuf;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn pdfium() -> pdfium_render::prelude::Pdfium {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
if std::env::var("FOFREEXIT_QPDF_PATH").is_err() {
std::env::set_var("FOFREEXIT_QPDF_PATH", workspace_root().join("qpdf").join("bin"));
}
ff_engine::bind_pdfium().expect("nạp PDFium")
}
fn tmp(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(name);
let _ = std::fs::remove_file(&p);
p
}
#[test]
fn find_qpdf_locates_binary() {
let _ = pdfium(); // đảm bảo FOFREEXIT_QPDF_PATH đã set
let path = ff_engine::find_qpdf().expect("find_qpdf");
assert!(path.is_file(), "qpdf.exe phải tồn tại tại {}", path.display());
}
#[test]
fn pdfium_rejects_truncated_file_directly() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("corrupt-truncated.pdf");
let err = pdf.load_pdf_from_file(&input, None);
assert!(err.is_err(), "PDFium phải từ chối file bị cắt cụt khi chưa repair");
}
#[test]
fn repair_makes_truncated_file_openable() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("corrupt-truncated.pdf");
let output = tmp("ff_qpdf_repaired.pdf");
ff_engine::repair(&input, &output).expect("repair");
let doc = pdf.load_pdf_from_file(&output, None).expect("mở file đã repair");
assert_eq!(doc.pages().len(), 3, "phải khôi phục đủ 3 trang gốc");
}
#[test]
fn ensure_openable_repairs_automatically() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("corrupt-truncated.pdf");
let usable = ff_engine::ensure_openable(&pdf, &input, None).expect("ensure_openable");
assert_ne!(usable, input, "phải trả về 1 file tạm đã repair, không phải input gốc");
assert_eq!(
ff_engine::page_count(&pdf, &usable, None).expect("page_count"),
3
);
}
#[test]
fn ensure_openable_passes_through_file_that_already_opens() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let usable = ff_engine::ensure_openable(&pdf, &input, None).expect("ensure_openable");
assert_eq!(usable, input, "file mở thẳng được thì không cần repair");
}
#[test]
fn encrypted_fixture_requires_correct_password() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("encrypted.pdf");
assert!(pdf.load_pdf_from_file(&input, None).is_err(), "không password phải lỗi");
assert!(
pdf.load_pdf_from_file(&input, Some("sai-password")).is_err(),
"sai password phải lỗi"
);
let doc = pdf
.load_pdf_from_file(&input, Some("fofreexit"))
.expect("đúng password phải mở được");
assert_eq!(doc.pages().len(), 3);
}
#[test]
fn encrypt_with_password_round_trips() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf"); // chưa mã hoá
let output = tmp("ff_qpdf_encrypted.pdf");
ff_engine::encrypt_with_password(&input, &output, "userpw", "ownerpw")
.expect("encrypt_with_password");
assert!(pdf.load_pdf_from_file(&output, None).is_err(), "phải cần password sau khi mã hoá");
let doc = pdf
.load_pdf_from_file(&output, Some("userpw"))
.expect("đúng password phải mở được");
assert_eq!(doc.pages().len(), 3, "nội dung phải giữ nguyên sau khi mã hoá");
}
@@ -0,0 +1,135 @@
//! Round-trip test cho watermark + header/footer (Phase 3): áp rồi đọc lại
//! bằng extract_text — PDFium ghi watermark/header-footer thành CONTENT thật
//! của trang (không phải annotation) nên phải xuất hiện trong text trích xuất.
use std::path::PathBuf;
use ff_engine::{Anchor, HeaderFooterSpec, WatermarkSpec};
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn pdfium() -> pdfium_render::prelude::Pdfium {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
ff_engine::bind_pdfium().expect("nạp PDFium")
}
fn tmp(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(name);
let _ = std::fs::remove_file(&p);
p
}
fn base_watermark(text: &str) -> WatermarkSpec {
WatermarkSpec {
text: text.to_string(),
font_size: 36.0,
color: [200, 0, 0, 120],
bold: false,
italic: false,
rotation_deg: 45.0,
anchor: Anchor::Center,
pages: vec![],
}
}
#[test]
fn watermark_text_appears_on_all_pages() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf"); // 3 trang
let output = tmp("ff_wm_all.pdf");
ff_engine::add_watermark(&pdf, &input, &base_watermark("CONFIDENTIAL"), &output, None)
.expect("add_watermark");
for i in 0..3u16 {
let text = ff_engine::extract_text(&pdf, &output, i, None).expect("text");
assert!(text.contains("CONFIDENTIAL"), "trang {i} phải có watermark: {text:?}");
}
}
#[test]
fn watermark_respects_page_filter() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = tmp("ff_wm_filtered.pdf");
let mut spec = base_watermark("ONLY-PAGE-1");
spec.pages = vec![1];
ff_engine::add_watermark(&pdf, &input, &spec, &output, None).expect("add_watermark");
let p0 = ff_engine::extract_text(&pdf, &output, 0, None).expect("text p0");
let p1 = ff_engine::extract_text(&pdf, &output, 1, None).expect("text p1");
assert!(!p0.contains("ONLY-PAGE-1"), "trang 0 không được có watermark");
assert!(p1.contains("ONLY-PAGE-1"), "trang 1 phải có watermark");
}
#[test]
fn watermark_vietnamese_text_round_trips() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = tmp("ff_wm_viet.pdf");
ff_engine::add_watermark(&pdf, &input, &base_watermark("Bản nháp"), &output, None)
.expect("add_watermark viet");
let text = ff_engine::extract_text(&pdf, &output, 0, None).expect("text");
assert!(text.contains("Bản nháp"), "watermark tiếng Việt phải đọc lại đúng: {text:?}");
}
#[test]
fn header_footer_inserts_page_numbers_and_total() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf"); // 3 trang
let output = tmp("ff_hf_pagenum.pdf");
let spec = HeaderFooterSpec {
bottom_center: "Trang {page}/{total}".to_string(),
top_right: "{date}".to_string(),
font_size: 10.0,
color: [0, 0, 0, 255],
margin_pt: 20.0,
date: "2026-06-17".to_string(),
..Default::default()
};
ff_engine::add_header_footer(&pdf, &input, &spec, &output, None).expect("add_header_footer");
for i in 0..3u16 {
let text = ff_engine::extract_text(&pdf, &output, i, None).expect("text");
let expected = format!("Trang {}/3", i + 1);
assert!(text.contains(&expected), "trang {i} phải có '{expected}': {text:?}");
assert!(text.contains("2026-06-17"), "trang {i} phải có ngày: {text:?}");
}
}
#[test]
fn header_footer_empty_slots_are_skipped() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = tmp("ff_hf_empty.pdf");
let before = ff_engine::extract_text(&pdf, &input, 0, None).expect("text before");
let spec = HeaderFooterSpec {
bottom_left: "ONLY-THIS".to_string(),
font_size: 10.0,
color: [0, 0, 0, 255],
margin_pt: 20.0,
..Default::default()
};
ff_engine::add_header_footer(&pdf, &input, &spec, &output, None).expect("add_header_footer");
let after = ff_engine::extract_text(&pdf, &output, 0, None).expect("text after");
assert!(after.contains("ONLY-THIS"));
assert_eq!(
after.replace("ONLY-THIS", "").trim_end(),
before.trim_end(),
"không thêm gì ngoài ô đã điền"
);
}
+6 -6
View File
@@ -12,30 +12,30 @@ Một ứng dụng desktop có thể **xem, chú thích, tổ chức trang, và
---
## Phase 0 — Nghiên cứu & Kế hoạch ✅ (đang ở đây)
## Phase 0 — Nghiên cứu & Kế hoạch ✅ HOÀN TẤT
**DoD:** docs 01–03 hoàn tất; chốt ngôn ngữ/framework; dựng skeleton repo.
## Phase 1 — Nền tảng & Viewer (MVP xem được)
## Phase 1 — Nền tảng & Viewer (MVP xem được) ✅ HOÀN TẤT
**Mục tiêu:** Mở và hiển thị PDF chính xác, mượt.
- Tích hợp PDFium (prebuilt) qua FFI; dựng Core API tối thiểu (`open`, `pageCount`, `renderPage`).
- Viewer: scroll liên tục, zoom/fit, thumbnails, outline/bookmarks, **tìm kiếm text**, chọn & copy text.
- Mở file lớn (lazy render + cache tile).
**DoD:** mở 20 file mẫu (gồm file 1000+ trang, CJK, Việt) không crash; render khớp mắt thường với Chrome; search & copy hoạt động; có test render-hash cho trang mẫu.
## Phase 2 — Chú thích (Annotate)
## Phase 2 — Chú thích (Annotate) ✅ HOÀN TẤT (lõi) — xem [docs/08-phase2-summary.md](08-phase2-summary.md)
**Mục tiêu:** Bộ comment dùng hằng ngày.
- Highlight/underline/strikethrough, sticky note, text box, free draw, shapes, stamp.
- Lưu annotation vào PDF (annotation dict), import/export **XFDF**.
- Hiển thị & sửa/xoá annotation; danh sách comment ở sidebar.
**DoD:** tạo/sửa/xoá mọi loại trên; lưu & mở lại giữ nguyên; Acrobat/Foxit đọc được annotation ta ghi; export/import XFDF round-trip.
## Phase 3 — Tổ chức trang (Organize) + Lưu file vững
## Phase 3 — Tổ chức trang (Organize) + Lưu file vững ✅ HOÀN TẤT (lõi, 36/36 test) — xem [docs/10-phase3-summary.md](10-phase3-summary.md)
**Mục tiêu:** Quản lý trang & ghi file an toàn.
- Chèn/xoá/xoay/trích/thay/đảo trang; **merge & split**; crop; đánh số; header/footer; watermark/background.
- Engine ghi file: incremental update **và** full rewrite (qua QPDF); giữ không hỏng file; **undo/redo** toàn cục.
**DoD:** mọi thao tác trang round-trip đúng; merge 50 file OK; ghi không làm hỏng file mã hoá; undo/redo ổn định; test so khớp cấu trúc trang.
## Phase 4 — ⭐ Chỉnh sửa nội dung (Edit) — TÍNH NĂNG LÕI
## Phase 4 — ⭐ Chỉnh sửa nội dung (Edit) — TÍNH NĂNG LÕI ✅ Iteration 1 HOÀN TẤT (43/43 test) — xem [docs/12-phase4-summary.md](12-phase4-summary.md)
**Mục tiêu:** Sửa text & object trực tiếp — moat chính.
- **Page Object Model**: parse content stream → cây object (text/path/image/xobject).
- Sửa **ảnh/object**: di chuyển, resize, xoá, thêm ảnh (dễ hơn → làm trước).
@@ -43,7 +43,7 @@ Một ứng dụng desktop có thể **xem, chú thích, tổ chức trang, và
- Thêm text box mới, đổi font/size/màu.
**DoD:** sửa được text 1 dòng & 1 đoạn trên ≥80% file mẫu thường; thêm/xoá/di chuyển ảnh; lưu mở lại đúng; tiếng Việt hiển thị & sửa đúng dấu; có test corpus chuyên cho edit.
## Phase 5 — Bảo mật & Chữ ký
## Phase 5 — Bảo mật & Chữ ký ⏳ KẾ TIẾP (đang ở đây)
**Mục tiêu:** Tính năng đáng tiền cho doanh nghiệp.
- Mật khẩu mở/permission; mã hoá **AES-256**; xoá mật khẩu.
- **Redaction thật** (xoá nội dung + ảnh + metadata, không chỉ vẽ đè) + kiểm chứng đã xoá.
+20 -4
View File
@@ -7,6 +7,7 @@
- **Visual Studio Build Tools** + workload **Desktop development with C++** (cung cấp `cl.exe`/`link.exe` cho linker MSVC).
- **WebView2 Runtime** (cho Tauri; Windows 11 thường có sẵn).
- **PDFium**: `pdfium.dll` prebuilt — tải bằng `scripts/fetch-pdfium.ps1` (đặt vào gốc workspace + `pdfium/`).
- **QPDF** (từ Phase 3): `qpdf.exe` prebuilt — tải bằng `scripts/fetch-qpdf.ps1`. Dùng để sửa file hỏng (repair) trước khi PDFium mở + mã hoá lại file sau khi chỉnh sửa.
- (Tauri) Node không bắt buộc: app dùng frontend tĩnh, chạy thẳng bằng `cargo run`.
## Lấy PDFium
@@ -16,16 +17,29 @@ powershell -File scripts/fetch-pdfium.ps1
```
Engine tìm `pdfium.dll` theo thứ tự: `FOFREEXIT_PDFIUM_PATH` → cwd → `./pdfium` → thư mục exe → thư viện hệ thống.
## Lấy QPDF
```powershell
# Tải qpdf.exe (prebuilt) về workspace
powershell -File scripts/fetch-qpdf.ps1
```
Engine tìm `qpdf.exe` theo thứ tự: `FOFREEXIT_QPDF_PATH` → `<cwd>/qpdf/bin/qpdf.exe` → cạnh file exe → `qpdf` trong PATH hệ thống (xem `crates/ff-engine/src/qpdf.rs`).
## Cấu trúc
```
crates/ff-cos # COS layer (khung)
crates/ff-pdmodel # PD layer (khung)
crates/ff-engine # render qua PDFium (Phase 1: render.rs)
crates/ff-cli # CLI "ff" (info, render)
crates/ff-engine # render + chỉnh sửa qua PDFium/QPDF
render.rs # Phase 1: render trang, page_dims, extract_text, search, outline
annot.rs # Phase 2: chú thích (highlight/note/shape/free-draw...)
organize.rs # Phase 3: tổ chức trang (insert/delete/rotate/crop/merge/split)
watermark.rs # Phase 3: watermark + header/footer
qpdf.rs # Phase 3: repair file hỏng + mã hoá lại (QPDF CLI)
edit.rs # Phase 4: sửa nội dung (text run/object) trực tiếp
crates/ff-cli # CLI "ff" (info, render, pages, text, search, outline)
app/src # frontend tĩnh (HTML/CSS/JS)
app/src-tauri # backend Tauri (crate riêng, workspace tách)
corpus/ # PDF mẫu để test
scripts/ # tiện ích (fetch-pdfium)
scripts/ # tiện ích (fetch-pdfium, fetch-qpdf)
```
## CLI
@@ -46,8 +60,10 @@ cargo run -p ff-cli -- outline corpus\sample-multipage.pdf
## Test
```powershell
cargo test -p ff-engine # smoke test render (cần pdfium.dll)
cargo test -p ff-engine # toàn bộ test engine (cần pdfium.dll + qpdf.exe)
```
Bao gồm: render smoke, viewer features, big-doc, annot round-trip (Phase 1–2),
organize/watermark/qpdf-safety (Phase 3), edit round-trip (Phase 4). Hiện **43/43 xanh**.
## Chạy app desktop (Tauri)
```powershell
+60
View File
@@ -0,0 +1,60 @@
# 10 — Tổng kết Phase 3 (Tổ chức trang + Lưu file vững)
> Trạng thái: **lõi HOÀN TẤT**. 36/36 test tự động xanh (`ff-engine`/`ff-cos`/`ff-pdmodel`). UI đã chạy thật và chụp ảnh xác nhận từng tính năng.
## 1. Khảo sát trước khi code
Đọc source `pdfium-render 0.8.37` xác nhận PDFium đã có API cấp cao cho gần hết Phase 3 (copy/insert/delete trang, `set_rotation`, `set_crop`, `watermark(closure)`, nạp font TrueType làm CID font Unicode ngay trong PDFium). Vì vậy **QPDF không còn là động cơ thao tác trang chính** như dự kiến ban đầu — vai trò của nó thu hẹp lại đúng như đã chốt ở `docs/02-tech-stack.md`: lớp an toàn sửa file hỏng + mã hoá lại, PDFium làm hết phần chỉnh trang.
## 2. Đã làm
### Engine (`crates/ff-engine/src`)
- **`organize.rs`**: `PagePlanEntry`/`PageSource` + `build_document(plan)` — 1 hàm lõi dựng document đích từ danh sách "lấy trang nào, từ đâu, xoay/crop ra sao". Insert/Delete/Reorder/Replace/Extract/Merge/Split chỉ là cách dựng `plan` khác nhau, không nhân bản logic. Có `identity_plan`, `delete_pages`, `rotate_pages`, `extract_pages`, `merge_files`, `split_by_page_count`.
- **`watermark.rs`**: `add_watermark`/`add_header_footer` dùng `PdfPages::watermark(closure)` + font TrueType nạp làm CID Unicode (tái dùng `find_font_bytes` từ `annot.rs`) — không cần tự build Type0/CIDFontType2 dict tay như FreeText Phase 2. Token `{page}`/`{total}`/`{date}` cho header/footer; vị trí theo lưới 9 điểm (`Anchor`).
- **`qpdf.rs`**: gọi `qpdf.exe` (binary prebuilt, `scripts/fetch-qpdf.ps1`) qua `std::process::Command`. `repair()` sửa file hỏng trước khi PDFium mở; `encrypt_with_password()` áp lại mã hoá sau khi PDFium/lopdf chỉnh sửa ra bản tạm không mã hoá; `ensure_openable()` thử mở thẳng, lỗi thì tự repair rồi mở lại — dùng ở cả engine và `loadDocument` phía UI.
- Bài học kỹ thuật quan trọng (đã ghi vào memory dự án):
- `qpdf.exe` hiểu nhầm tiền tố verbatim `\\?\...` (từ `Path::canonicalize()` trên Windows) thành UNC path → phải tự strip trước khi đưa vào argv.
- Panic trong khi 1 `Pdfium`/`PdfDocument` còn sống sẽ làm "kẹt" (poison) global mutex của `ThreadSafePdfiumBindings` cho hết tiến trình test — mọi test PDFium sau đó báo lỗi `thread_safe.rs:76` không liên quan gì tới bug thật; luôn cô lập test đầu tiên bị fail để tìm nguyên nhân gốc.
### Tauri commands (`app/src-tauri/src/main.rs`)
`ensure_openable`, `organize_identity_plan`, `organize_apply`, `organize_extract`, `organize_merge`, `organize_split`, `watermark_add`, `header_footer_add`, `preview_watermark`, `preview_header_footer`, `pick_dir` (chọn thư mục cho Tách file). DTO `PagePlanEntryDto`/`RectDto` dùng chung 2 chiều (gửi lên + trả về) nên đều có `Serialize + Deserialize`.
### UI (`app/src`)
- **Chế độ "Tổ chức trang"**: nút trên toolbar chính thay `#viewport` bằng lưới thumbnail lớn (`#organizeGrid`), thanh công cụ riêng (`#organizeBar`): Chèn/Xoá/Xoay trái/Xoay phải/Trích/Thay/Trộn file/Tách file/Watermark/Header-Footer/Lưu thay đổi.
- **Multi-select** (click/Ctrl-click/Shift-click) + **kéo-thả đổi thứ tự** (HTML5 drag-and-drop) trên lưới, xoay xem ngay bằng CSS transform trước khi lưu thật.
- **Undo/Redo toàn cục**: mở rộng cơ chế snapshot Phase 2 (chỉ có `annotSpecs`) sang chụp cả `{annotSpecs, pagePlan}` — 1 stack Ctrl+Z/Ctrl+Y duy nhất cho cả chú thích và tổ chức trang.
- **Dialog dùng chung** (`#modalOverlay`/`#modalBox`) cho cả 8 thao tác: Chèn (trang trắng theo cỡ giấy, hoặc từ file + page range), Trích (kèm tuỳ chọn xoá sau khi trích), Thay (theo file + range), Trộn file (thêm/sắp thứ tự/xoá nhiều file), Tách file (theo số trang/file), Watermark (text/cỡ/màu/độ mờ/đậm-nghiêng/góc xoay/lưới 9 vị trí/phạm vi trang — có **xem trước render PDFium thật**), Header/Footer (6 ô trái-giữa-phải × trên-dưới, token chèn nhanh, có xem trước thật).
- **Crop**: tái dùng cơ chế kéo-vẽ hình chữ nhật đã có cho tool "Khung" — thêm nhánh `tool === "crop"` mở dialog 4 số margin (trái/phải/trên/dưới) áp cho trang này hoặc tất cả; áp trực tiếp vào `pagePlan` (chỉ ghi ra file thật khi bấm Lưu).
- **Mở file vững hơn**: `loadDocument` thử mở thẳng, lỗi thì gọi `ensure_openable` (tự repair qua QPDF) rồi mở lại — không cần người dùng tự sửa file.
- Sửa lỗi giao diện phát sinh khi thêm nhiều nút: toolbar/annobar/organizebar tràn ngoài cửa sổ ở độ phân giải hẹp → thêm `flex-wrap: wrap`.
## 3. Test tự động (36/36 xanh, crate `ff-engine`)
- `organize_roundtrip` (8): xoá còn đúng nội dung/thứ tự; xoá hết bị chặn; xoay bền sau khi lưu; trích không đụng file gốc; trộn nhiều file đúng thứ tự; tách đúng số trang/file; crop ghi đúng `/CropBox`; chèn trang trắng đúng kích thước không có text thừa.
- `watermark_roundtrip` (5): watermark hiện trên mọi trang/đúng trang được lọc/tiếng Việt round-trip đúng; header/footer chèn đúng số trang + tổng số trang; ô rỗng bị bỏ qua (không vẽ chữ thừa).
- `qpdf_safety` (7): PDFium từ chối thẳng file bị cắt cụt; repair sửa được; `ensure_openable` tự repair / tự đi qua khi file vốn mở được; fixture mã hoá đòi đúng password; mã hoá round-trip giữ nguyên nội dung.
- Cùng các test Phase 1/2 vẫn xanh (`viewer_features` 6, `big_doc` 3, `render_smoke` 1, `annot_roundtrip` 6).
- `ff-cli` không tự chạy test được trong môi trường này do Windows Smart App Control chặn thực thi binary test mới build (lỗi hệ điều hành, không phải lỗi code) — không ảnh hưởng coverage vì crate này không có test riêng.
## 4. Đã kiểm bằng tay/ảnh (build release, chạy app thật)
- Vào/ra chế độ Tổ chức trang; lưới hiển thị đúng 3 thumbnail thật.
- Chọn trang (viền xanh) + Xoay phải → thumbnail xoay 90° ngay; Hoàn tác → xoay + chọn về lại đúng trạng thái cũ (xác nhận undo/redo toàn cục hoạt động).
- Dialog Chèn trang: chèn trang trắng cỡ Letter vào cuối tài liệu → lưới hiện thêm trang 4 trắng.
- Dialog Watermark: nhập "CONFIDENTIAL", góc xoay 45°, vị trí giữa → bấm Xem trước → ảnh PDFium thật hiện chữ đỏ xoay đúng góc, đúng vị trí, đè lên nội dung trang.
- Dialog Header/Footer: 6 ô + token chèn nhanh hiển thị đúng; Xem trước trả về ảnh PDFium thật (nội dung số trang đã được test-gate ở engine).
## 5. Kiểm duyệt lại luồng hoạt động (2026-06-18) — đã tối ưu
Sau khi rà lại toàn bộ luồng người dùng dùng các chức năng Phase 3, đã sửa:
- **[Hiệu năng — quan trọng] Lưới Tổ chức trang không còn render lại toàn bộ thumbnail khi click chọn/xoay.** Trước đây `orgSelectCard`/`orgRotateSelected` gọi `buildOrganizeGrid()` → xoá sạch lưới và `render_page` lại cho MỌI card (tài liệu N trang → mỗi click = N lần render PDFium + N base64, kèm nhấp nháy). Nay: chọn chỉ đổi class `.selected` (`refreshOrgSelection`), xoay chỉ đổi CSS `transform` của card liên quan; thêm cache thumbnail `state.orgThumbs` (khoá theo `source#srcIndex`) nên cả khi dựng lại lưới (chèn/xoá/đảo) cũng không render lại trang đã render. Reset cache khi mở file mới.
- **[Chưa hợp lý → ĐÃ SỬA THÀNH ĐÚNG, không chỉ cảnh báo] Watermark/Header-Footer giờ tự động áp lên đúng trạng thái `pagePlan` đang xem, không phải file gốc.** Thêm Tauri command `organize_materialize(mainInput, plan, password)` (tái dùng `build_document` có sẵn — không cần engine mới) dựng tạm 1 file PDF theo đúng plan hiện tại; frontend (`materializeBaseInput()`) tự gọi command này khi `planIsDirty()` và dùng file tạm đó làm `input` cho cả Xem trước và Áp dụng của 2 dialog — thay cho cảnh báo "tự đi lưu trước" trước đây. Dialog hiện ghi chú thông tin (không phải cảnh báo chặn) khi đang dùng bản tạm. Đã verify ảnh: xoay trang 1 rồi mở Watermark → Xem trước hiện đúng nội dung trang đã XOAY 90° kèm watermark đè lên — đúng trạng thái đang thấy trên lưới Tổ chức trang, không phải file gốc chưa xoay.
- Sửa kèm: phạm vi trang nhập trong 2 dialog (`Trang áp dụng`) giờ tính theo `pagePlan.length` (số trang ĐANG XEM) thay vì `pages.length` (số trang file gốc) — 2 số này khác nhau ngay khi đã chèn/xoá trang.
- **[Hiệu năng] `split_by_page_count` mở file nguồn đúng 1 lần** thay vì gọi `build_document` mỗi phần (mỗi lần mở/parse lại cả file). Tách file nhiều trang nhanh hơn rõ rệt. Test `split_by_page_count_produces_correct_chunks` vẫn xanh.
- **[Bẫy verify đã gặp, ghi vào memory dự án]** App Tauri là workspace TÁCH RIÊNG khỏi `crates/*` — `cargo build --release` chạy từ gốc repo chỉ build `crates/*`, KHÔNG build app (dễ verify nhầm trên exe cũ). Phải dùng `cargo build --release --manifest-path app/src-tauri/Cargo.toml`.
## 6. Còn lại / follow-up (chưa làm)
- **Mã hoá trong UI:** engine có sẵn `encrypt_with_password`/`ensure_openable` nhưng frontend luôn truyền `password: null`, chưa có hộp nhập mật khẩu lúc mở, chưa nối lại mã hoá sau khi lưu. → File có mật khẩu thực tế chưa mở/lưu được qua UI (DoD "ghi không làm hỏng file mã hoá" mới đạt ở tầng engine/test, chưa ở tầng app). Cần: prompt mật khẩu khi `ensure_openable`/mở lỗi do mã hoá; sau khi `organize_apply`, nếu file gốc có mật khẩu thì gọi `encrypt_with_password` áp lại. Cân nhắc quy mô lớn (đụng tới mọi command mở/render/search) — để làm riêng, không gộp vào lần rà soát này.
- Live preview render thật cho Insert/Extract/Replace/Crop (hiện Crop dùng số liệu margin nhập tay; Insert/Extract/Replace không có preview).
- Tách theo outline cấp 1 (hiện chỉ có "mỗi N trang").
- Watermark ảnh (hiện chỉ có watermark text).
- Incremental save thật giữ chữ ký số (để dành Phase 5 theo đúng kế hoạch ban đầu).
+43
View File
@@ -0,0 +1,43 @@
# 11 — Checklist test thủ công Phase 3 (Tổ chức trang)
Phần kéo-thả/kéo-chuột không tự động test đầy đủ được trong WebView2 (xem ghi chú ở [08-phase2-summary.md](08-phase2-summary.md)), nên cần bạn thử bằng chuột thật, đặc biệt phần Lưu (đường GHI file là rủi ro nhất).
## Chạy
```powershell
cd c:\Project\FoFreeXit\app\src-tauri
cargo run
```
## Bảng test
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| O1 | Bấm **🗂 Tổ chức trang** | Viewport đổi thành lưới thumbnail lớn | |
| O2 | Click 1 trang, Ctrl+click thêm 1 trang | Cả 2 viền xanh (đã chọn) | |
| O3 | Shift+click trang khác | Chọn cả dải liên tục | |
| O4 | Kéo-thả 1 thumbnail sang vị trí khác | Thứ tự trang đổi ngay trên lưới | |
| O5 | Chọn 1 trang, bấm **Xoay phải/trái** | Thumbnail xoay 90° ngay | |
| O6 | Bấm **Hoàn tác** sau O5 | Xoay về như cũ | |
| O7 | Chọn trang, bấm **Xoá** | Trang biến mất khỏi lưới | |
| O8 | Chọn hết tất cả trang, bấm **Xoá** | Bị chặn (báo không thể xoá hết) | |
| O9 | Bấm **➕ Chèn** → Trang trắng → Cuối tài liệu | Thêm 1 trang trắng cuối lưới | |
| O10 | Bấm **➕ Chèn** → Từ file… → chọn 1 PDF khác | Trang từ file đó chèn đúng vị trí | |
| O11 | Chọn 1+ trang, bấm **📤 Trích** → chọn nơi lưu | Tạo file PDF mới chỉ chứa trang đã chọn | |
| O12 | Chọn 1+ trang, bấm **🔁 Thay** → chọn file khác | Nội dung trang đã chọn đổi thành trang từ file mới | |
| O13 | Bấm **🔀 Trộn file** → thêm 2-3 file, sắp thứ tự, Trộn | Tạo 1 file PDF mới ghép đúng thứ tự | |
| O14 | Bấm **✂ Tách file** → nhập số trang/file, chọn thư mục | Sinh nhiều file `_part1.pdf, _part2.pdf...` | |
| O15 | Bấm **💧 Watermark**, nhập chữ, bấm **Xem trước** | Ảnh xem trước hiện watermark đúng vị trí/màu/góc xoay | |
| O16 | Bấm **Áp dụng** ở Watermark → chọn nơi lưu | File mới có watermark trên các trang đã chọn | |
| O17 | Bấm **📋 Header/Footer**, gõ "Trang {page}/{total}" vào ô dưới-giữa, **Xem trước** | Ảnh xem trước hiện đúng số trang/tổng số trang | |
| O18 | Chọn tool **✂️ Crop** (thanh Chú thích), kéo 1 khung trên trang | Dialog Crop hiện 4 số margin đúng theo khung đã kéo | |
| O19 | Áp dụng Crop, vào lại Tổ chức trang | Thumbnail trang đó có dấu chấm crop ở góc | |
| O20 | Sau khi sửa vài thao tác, bấm **💾 Lưu thay đổi tổ chức trang** | Hỏi nơi lưu, tạo file mới đúng với mọi thay đổi đã làm | |
| O21 | Mở file đã lưu ở O20 bằng **Foxit/Adobe/Chrome** | Thứ tự/xoay/crop/nội dung trang đúng như trên lưới | |
| O22 | Mở 1 file PDF bị hỏng (xref lỗi) nếu có | App tự sửa (qua QPDF) và mở được, không báo lỗi cho người dùng | |
## Mẫu phản hồi
```
[Mã] (vd O21) — Phần mềm mở: <Foxit/Adobe/Chrome> — Hiện tượng: <mô tả> —
Kỳ vọng: <...> — Ưu tiên: cao/vừa/thấp
```
> Đặc biệt cần biết **O20/O21** (lưu tổ chức trang ra file mới, mở lại ở phần mềm khác có đúng không) vì đó là đường GHI file rủi ro nhất. Engine đã test round-trip nội bộ cho từng thao tác riêng lẻ, nhưng tổ hợp nhiều thao tác liên tiếp + cross-viewer chưa có test tự động.
+40
View File
@@ -0,0 +1,40 @@
# 12 — Tổng kết Phase 4 (Chỉnh sửa nội dung — Edit) · Iteration 1
> Trạng thái: **lõi MOAT hoạt động**. 43/43 test engine xanh (36 cũ + 7 mới cho edit). Đã chạy app thật, chụp ảnh xác minh: sửa text tiếng Việt WYSIWYG, thêm chữ, xoá đối tượng, undo/redo.
Phase 4 là tính năng lõi/moat theo `docs/03-roadmap.md`: sửa text & object trực tiếp trên trang — lý do quan trọng nhất để bỏ Foxit.
## 1. Khảo sát trước khi code
- **Foxit UX**: *Edit Text* (bấm đoạn → sửa như Word, reflow, đổi font/cỡ/màu); *Edit Object* (chọn text/ảnh/path → di chuyển/resize/xoay/xoá/thay ảnh, tab Format). Nguồn ở `docs/13-phase4-user-tests.md`.
- **pdfium-render 0.8.37** (đọc source vendored): PDFium đã expose page object cấp cao — KHÔNG cần tự parse content stream. Mỗi `PdfPageTextObject` là 1 **text run** sẵn để sửa (`text()`/`set_text()`/`font()`/`unscaled_font_size()`/fill color/transform). Có `create_text_object`/`create_image_object`/`remove_object_at_index`, `PdfFont::name/family/weight`, `regenerate_content()`. → khả thi cao, vượt kỳ vọng worst-case của roadmap.
## 2. Đã làm
### Engine (`crates/ff-engine/src/edit.rs`)
- `list_objects(input, page, password) -> Vec<ObjectInfo>`: liệt kê object (kind, AABB từ `bounds()`, và với text: nội dung/font/cỡ/màu) — cấp dữ liệu cho overlay UI.
- `apply_edits(input, page, ops, output, password)` với `EditOp`: **Transform** (di chuyển + scale quanh góc dưới-trái), **SetText** (sửa text run — tạo lại bằng FULL font nhúng để tiếng Việt đúng dấu, giữ matrix/cỡ/màu gốc qua `apply_matrix`), **Delete**, **ReplaceImage**, **AddText**, **AddImage**. Thứ tự xử lý giữ index gốc hợp lệ: Transform in-place → chụp dữ liệu object sắp thay → xoá theo index GIẢM DẦN → thêm bản thay thế → thêm object mới → `regenerate_content()` → lưu.
- Tái dùng `annot.rs::find_font_bytes` + `fonts_mut().load_true_type_from_bytes(bytes, true)` (như watermark.rs) cho sửa/thêm text Unicode.
- **Bài học quan trọng (đã ghi memory):** object trả về từ `remove_object_at_index` bị đánh dấu *unowned* → `Drop` gọi `FPDFPageObj_Destroy` gây **SEGFAULT** với PDFium build hiện tại. Phải `std::mem::forget` object đã xoá (đã tách khỏi trang, không cần destroy; rò rỉ nhỏ giải phóng khi đóng document).
### Tauri commands (`app/src-tauri/src/main.rs`)
`edit_list_objects`, `edit_apply` (ghi ra output), `edit_apply_to_temp` (áp ops → file tạm mới, trả path — cho mô hình materialize tức thì ở UI), `edit_preview` (render WYSIWYG), `pick_image`. DTO `ObjectInfoDto` (reuse `RectDto`), `EditOpDto` (tagged theo field `op`).
### UI (`app/src`) — chế độ "✏️ Sửa nội dung"
- Nút toolbar bật chế độ riêng: thay viewport bằng `#editStage` (ảnh trang lớn) + `#editOverlay` (1 box/đối tượng, map pdf→css theo `scale = STAGE_W/pageWidthPt`).
- **Mô hình "materialize tức thì"**: mỗi thao tác áp NGAY vào 1 file tạm mới (`edit_apply_to_temp`) rồi đọc lại object + render ảnh từ đó → index luôn khớp ảnh đang hiện (WYSIWYG thật), không cần tự suy đoán vị trí sau biến đổi.
- Thao tác: click chọn (viền xanh + handle); **double-click text → sửa tại chỗ** (ô input, commit Enter → SetText); đổi **cỡ chữ/màu** ở thanh công cụ cho text đang chọn; **Thêm chữ** (bấm nút → click lên trang → gõ); **Thêm ảnh**/**Thay ảnh** (chọn file → đặt); **Xoá** (nút/phím Delete); **kéo di chuyển / kéo handle resize** (chuột thật).
- **Undo/Redo riêng cho edit** (stack file tạm trước mỗi op), dùng chung 2 nút Hoàn tác/Làm lại + Ctrl+Z/Ctrl+Y khi đang ở chế độ sửa.
- **Lưu**: `edit_apply` ghi file mới rồi `loadDocument`.
## 3. Test tự động (7 mới, `tests/edit_roundtrip.rs`)
list_objects thấy text trang 1; SetText đổi nội dung (text cũ biến mất); SetText **tiếng Việt** round-trip đúng dấu; Delete giảm đúng 1 object; Transform translate dịch bounds ~+50; AddText xuất hiện trong extract_text; AddImage tăng 1 object kind Image. Tổng `ff-engine`: **43/43 xanh**.
## 4. Đã kiểm bằng ảnh (app build release thật)
Bật chế độ Sửa nội dung (overlay 2 đối tượng); double-click dòng 2 → sửa thành "Sửa: nội dung Tiếng Việt" → commit → trang render lại ĐÚNG (dấu chuẩn); Thêm chữ "Dòng chữ MỚI thêm" tại vị trí click; Hoàn tác bỏ chữ vừa thêm (giữ phần sửa); Xoá tiêu đề → còn 1 đối tượng. Ảnh: `tmp-out/phase4-*.png`.
## 5. Còn lại / follow-up (ghi nhận, không phải thiếu sót — Iteration sau)
- **Reflow đoạn nhiều dòng "như Word"** (hiện sửa ở mức text run/dòng — mỗi PdfPageTextObject; đây là điểm khác chính so với Foxit, cần gom run theo baseline + reflow).
- Xoay/lật/shear/clip object; tab Format nâng cao (viền, opacity, căn lề, đổi font-family object cũ); z-order arrange; convert text→path.
- Đặt ảnh theo đúng tỉ lệ gốc (hiện mặc định 150×112pt, resize bằng kéo handle); preview ảnh trước khi đặt.
- Sửa nhiều trang trong 1 phiên lưu (hiện lưu theo trang đang sửa); spell-check; sửa bảng; link text blocks.
- Mã hoá UI (nợ từ Phase 3).
+39
View File
@@ -0,0 +1,39 @@
# 13 — Checklist test thủ công Phase 4 (Sửa nội dung)
Kéo-chuột (di chuyển/resize) không tự động test đầy đủ trong WebView2 (xem `docs/08`), nên cần bạn thử bằng chuột thật. Đặc biệt cần xác nhận **lưu ra file mở lại đúng** và **tiếng Việt đúng dấu** ở phần mềm khác.
## Chạy
```powershell
cd c:\Project\FoFreeXit\app\src-tauri
cargo run
```
## Bảng test
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| E1 | Bấm **✏️ Sửa nội dung** | Viewport đổi thành trang lớn + khung mờ quanh từng đối tượng (text/ảnh) | |
| E2 | Bấm 1 lần vào 1 khung text | Khung viền xanh (đã chọn); ô Cỡ chữ + Màu bật lên | |
| E3 | Bấm đúp (double-click) 1 khung text | Hiện ô sửa tại chỗ với nội dung cũ | |
| E4 | Sửa thành chữ có dấu tiếng Việt, Enter | Trang render lại đúng nội dung mới, **đúng dấu** | |
| E5 | Chọn text rồi đổi **Cỡ chữ** | Chữ đổi kích thước | |
| E6 | Chọn text rồi đổi **Màu** | Chữ đổi màu | |
| E7 | Bấm **Thêm chữ** → click lên trang → gõ → Enter | Chữ mới xuất hiện đúng chỗ | |
| E8 | Bấm **Thêm ảnh** → chọn ảnh → click lên trang | Ảnh xuất hiện tại chỗ click | |
| E9 | Chọn 1 ảnh → **Thay ảnh** → chọn ảnh khác | Ảnh được thay, giữ khung cũ | |
| E10 | Chọn 1 đối tượng → **Kéo** để di chuyển | Đối tượng dời theo chuột | |
| E11 | Chọn 1 đối tượng → kéo **ô vuông góc** để resize | Đối tượng to/nhỏ lại | |
| E12 | Chọn 1 đối tượng → phím **Delete** (hoặc nút Xoá) | Đối tượng biến mất | |
| E13 | **Hoàn tác (Ctrl+Z)** sau vài thao tác | Lùi lại từng bước đúng | |
| E14 | **Làm lại (Ctrl+Y)** | Tiến lại bước vừa hoàn tác | |
| E15 | **Lưu thay đổi nội dung** → chọn nơi lưu | Tạo file PDF mới; app mở lại file đó | |
| E16 | Mở file đã lưu ở **Foxit/Adobe/Chrome** | Nội dung đã sửa hiển thị đúng (text, ảnh, **tiếng Việt đúng dấu**), file không hỏng | |
## Mẫu phản hồi
```
[Mã] (vd E16) — Phần mềm mở: <Foxit/Adobe/Chrome> — Hiện tượng: <mô tả> —
Kỳ vọng: <...> — Ưu tiên: cao/vừa/thấp
```
> Quan trọng nhất: **E4** (sửa tiếng Việt), **E15/E16** (lưu + mở lại cross-viewer) — đường GHI nội dung là rủi ro nhất. Engine đã test round-trip nội bộ (gồm tiếng Việt), nhưng xác nhận cross-viewer của bạn rất giá trị.
> Giới hạn đã biết (Iteration 1): sửa text ở mức **dòng/run** (chưa reflow cả đoạn như Word); thêm ảnh mặc định khung 150×112pt (kéo handle để chỉnh); lưu áp cho trang đang sửa. Xem `docs/12-phase4-summary.md` mục 5.
+33
View File
@@ -0,0 +1,33 @@
# Tải QPDF prebuilt (qpdf/qpdf release MSVC x64) cho Windows
# và đặt qpdf.exe + DLL phụ thuộc vào qpdf/bin/ ở gốc workspace.
#
# Dùng: powershell -ExecutionPolicy Bypass -File scripts/fetch-qpdf.ps1
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$qpdfDir = Join-Path $root "qpdf"
$tmp = Join-Path $env:TEMP "qpdf-win-msvc64.zip"
Write-Output "Tra cuu release moi nhat cua qpdf/qpdf..."
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/qpdf/qpdf/releases/latest" -UseBasicParsing
$asset = $release.assets | Where-Object { $_.name -like "*msvc64.zip" } | Select-Object -First 1
if (-not $asset) { throw "Khong tim thay asset msvc64.zip trong release $($release.tag_name)" }
Write-Output "Tai $($asset.name) (tu release $($release.tag_name))..."
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $tmp -UseBasicParsing
$extractDir = Join-Path $env:TEMP "qpdf-extract"
if (Test-Path $extractDir) { Remove-Item -Recurse -Force $extractDir }
Expand-Archive -Path $tmp -DestinationPath $extractDir -Force
$binSrc = Get-ChildItem -Path $extractDir -Recurse -Directory -Filter "bin" | Select-Object -First 1
if (-not $binSrc) { throw "Khong tim thay thu muc bin/ sau khi giai nen" }
New-Item -ItemType Directory -Force -Path (Join-Path $qpdfDir "bin") | Out-Null
Copy-Item (Join-Path $binSrc.FullName "*.exe") (Join-Path $qpdfDir "bin") -Force
Copy-Item (Join-Path $binSrc.FullName "*.dll") (Join-Path $qpdfDir "bin") -Force
$exe = Join-Path $qpdfDir "bin\qpdf.exe"
if (-not (Test-Path $exe)) { throw "Khong tim thay qpdf.exe sau khi copy ($exe)" }
& $exe --version
Write-Output "Xong. qpdf.exe da dat tai: $exe"