From e442a9f6783789fc2c442115dc89f787050875ea Mon Sep 17 00:00:00 2001 From: trandinhnamuet Date: Tue, 14 Jul 2026 22:07:38 +0700 Subject: [PATCH] Phase 3 (Organize + robust save) + Phase 4 (Edit content); update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 3 + README.md | 13 +- app/src-tauri/src/main.rs | 530 +++++++- app/src/index.html | 59 +- app/src/main.js | 1187 ++++++++++++++++- app/src/styles.css | 144 ++ corpus/README.md | 4 +- corpus/corrupt-truncated.pdf | 57 + corpus/encrypted.pdf | 72 + crates/ff-engine/src/annot.rs | 2 +- crates/ff-engine/src/edit.rs | 374 ++++++ crates/ff-engine/src/lib.rs | 11 + crates/ff-engine/src/organize.rs | 277 ++++ crates/ff-engine/src/qpdf.rs | 140 ++ crates/ff-engine/src/watermark.rs | 205 +++ crates/ff-engine/tests/edit_roundtrip.rs | 218 +++ crates/ff-engine/tests/organize_roundtrip.rs | 173 +++ crates/ff-engine/tests/qpdf_safety.rs | 109 ++ crates/ff-engine/tests/watermark_roundtrip.rs | 135 ++ docs/03-roadmap.md | 12 +- docs/05-dev-setup.md | 24 +- docs/10-phase3-summary.md | 60 + docs/11-phase3-user-tests.md | 43 + docs/12-phase4-summary.md | 40 + docs/13-phase4-user-tests.md | 39 + scripts/fetch-qpdf.ps1 | 33 + 26 files changed, 3924 insertions(+), 40 deletions(-) create mode 100644 corpus/corrupt-truncated.pdf create mode 100644 corpus/encrypted.pdf create mode 100644 crates/ff-engine/src/edit.rs create mode 100644 crates/ff-engine/src/organize.rs create mode 100644 crates/ff-engine/src/qpdf.rs create mode 100644 crates/ff-engine/src/watermark.rs create mode 100644 crates/ff-engine/tests/edit_roundtrip.rs create mode 100644 crates/ff-engine/tests/organize_roundtrip.rs create mode 100644 crates/ff-engine/tests/qpdf_safety.rs create mode 100644 crates/ff-engine/tests/watermark_roundtrip.rs create mode 100644 docs/10-phase3-summary.md create mode 100644 docs/11-phase3-user-tests.md create mode 100644 docs/12-phase4-summary.md create mode 100644 docs/13-phase4-user-tests.md create mode 100644 scripts/fetch-qpdf.ps1 diff --git a/.gitignore b/.gitignore index 082ee03..a11c8d7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 1c0843f..d16ad66 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index 30fd5fc..b19eead 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -193,6 +193,12 @@ fn pick_pdf(app: tauri::AppHandle) -> Option { .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 { + 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 { @@ -205,7 +211,7 @@ fn pick_save_pdf(app: tauri::AppHandle) -> Option { } /// 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 { } } +// ==================== 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) -> Result { + 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, + src_index: Option, + width_pt: Option, + height_pt: Option, + #[serde(default)] + rotation_delta: i32, + crop: Option, +} + +fn plan_entry_from_dto(d: PagePlanEntryDto) -> Result { + 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) -> Result, 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, + output: String, + password: Option, +) -> Result<(), String> { + let pdfium = pdfium()?; + let mapped: Vec = + plan.into_iter().map(plan_entry_from_dto).collect::>()?; + 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, + output: String, + password: Option, +) -> 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, output: String) -> Result<(), String> { + let pdfium = pdfium()?; + let paths: Vec = 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, +) -> Result, 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, + password: Option, +) -> Result { + let pdfium = pdfium()?; + let mapped: Vec = + plan.into_iter().map(plan_entry_from_dto).collect::>()?; + 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 { + 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, +} + +/// 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, +) -> 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, +} + +/// 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, +) -> 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 { + 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 { + 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 { + 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, + font_name: Option, + font_size: Option, + 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) -> Result, 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, + 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 { + 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, + output: String, + password: Option, +) -> Result<(), String> { + let pdfium = pdfium()?; + let mapped: Vec = ops.into_iter().map(edit_op_from_dto).collect::>()?; + 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, password: Option) -> Result { + let pdfium = pdfium()?; + let mapped: Vec = ops.into_iter().map(edit_op_from_dto).collect::>()?; + 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, width: u32, password: Option) -> Result { + let pdfium = pdfium()?; + let mapped: Vec = ops.into_iter().map(edit_op_from_dto).collect::>()?; + 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 { + 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"); diff --git a/app/src/index.html b/app/src/index.html index 8bf589b..7b2ed11 100644 --- a/app/src/index.html +++ b/app/src/index.html @@ -30,11 +30,17 @@ + + + + + +
-
+
Chú thích: @@ -42,18 +48,48 @@ + - - -
+ + + +
-