diff --git a/README.md b/README.md index 7737bd9..5a10a31 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ | [docs/03-roadmap.md](docs/03-roadmap.md) | Lộ trình theo phase, mục tiêu, phương pháp, định nghĩa hoàn thành (DoD) | | [docs/04-architecture.md](docs/04-architecture.md) | Kiến trúc kỹ thuật chi tiết (sẽ chi tiết hóa dần) | | [docs/14-foxit-gap-analysis.md](docs/14-foxit-gap-analysis.md) | Đánh giá định hướng/công nghệ/tiến độ so với tham vọng thay Foxit; các khoảng cách còn lại + giải pháp | +| [docs/17-phase6-summary.md](docs/17-phase6-summary.md) | Tổng kết Phase 6 (Form/AcroForm) | ## Trạng thái hiện tại @@ -48,7 +49,12 @@ - [x] UI thanh "🔒 Bảo mật": redact 2 bước như Foxit, Đặt/Gỡ mật khẩu + quyền, Xoá metadata, **Tạo Digital ID / Ký số / Kiểm tra chữ ký / Lưu tối ưu** - [ ] Về sau: timestamp PAdES-T/LTV, đọc PFX/PKCS#12, multi-sig incremental — xem [docs/15-phase5-summary.md](docs/15-phase5-summary.md) mục 6.6 - **Checklist test cho bạn**: [docs/16-phase5-user-tests.md](docs/16-phase5-user-tests.md) (mới: S13–S19) -- [ ] **Phase 6 — Form (AcroForm)** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md) +- [x] **Phase 6 — Form (AcroForm)** ✅ lõi hoàn tất (67/67 test engine xanh) + - [x] Engine `form.rs`: liệt kê field (text/checkbox/radio/combo/list), điền (giữ tiếng Việt qua UTF-16BE + NeedAppearances), tạo field mới (text/checkbox/combo), flatten (PDFium), export/import **FDF** + export **CSV** — round-trip test + - [x] UI thanh "📝 Form": Điền form (modal liệt kê field theo loại), Thêm field, Flatten, Xuất FDF/CSV, Nhập FDF + - [ ] Follow-up: radio-group/list/pushbutton khi tạo, XFDF, vẽ appearance riêng, điền overlay trực tiếp trên trang — xem [docs/17-phase6-summary.md](docs/17-phase6-summary.md) + - **Checklist test cho bạn**: [docs/18-phase6-user-tests.md](docs/18-phase6-user-tests.md) +- [ ] **Phase 7 — OCR & Convert** (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/Cargo.lock b/app/src-tauri/Cargo.lock index 031593a..c045219 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -1161,6 +1161,7 @@ dependencies = [ "base64 0.22.1", "ff-engine", "image", + "lopdf", "pdfium-render", "serde", "serde_json", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index ae91d47..8943cd4 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ serde_json = "1" base64 = "0.22" image = "0.25" pdfium-render = "0.8" +lopdf = "0.36" ff-engine = { path = "../../crates/ff-engine" } [profile.release] diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index 7a7d1d2..36f2cc6 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -1006,6 +1006,181 @@ fn pick_any_file(app: tauri::AppHandle) -> Option { app.dialog().file().blocking_pick_file().map(|fp| fp.to_string()) } +// ---------- Phase 6: Form (AcroForm) ---------- + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct FormFieldDto { + name: String, + kind: String, + value: Option, + page_index: Option, + rect: Option<[f32; 4]>, + on_state: Option, + options: Vec, + read_only: bool, +} + +/// Liệt kê field của form. Chuẩn hoá qua qpdf nếu lopdf không đọc thẳng được. +#[tauri::command] +fn form_list(path: String) -> Result, String> { + let p = std::path::Path::new(&path); + let fields = match ff_engine::list_form_fields(p) { + Ok(f) => f, + Err(_) => { + let norm = std::env::temp_dir().join(format!("ff_form_norm_{}.pdf", std::process::id())); + ff_engine::repair(p, &norm).map_err(|e| e.to_string())?; + let r = ff_engine::list_form_fields(&norm).map_err(|e| e.to_string()); + let _ = std::fs::remove_file(&norm); + r? + } + }; + Ok(fields + .into_iter() + .map(|f| FormFieldDto { + name: f.name, + kind: f.kind.as_str().to_string(), + value: f.value, + page_index: f.page_index, + rect: f.rect, + on_state: f.on_state, + options: f.options, + read_only: f.read_only, + }) + .collect()) +} + +#[derive(serde::Deserialize)] +struct FieldValueDto { + name: String, + value: String, +} + +/// Chuẩn hoá input qua qpdf (lopdf cần trailer chuẩn) rồi trả path dùng được. +fn normalized_for_lopdf(input: &str) -> Result<(std::path::PathBuf, Option), String> { + let p = std::path::PathBuf::from(input); + if lopdf::Document::load(&p).is_ok() { + return Ok((p, None)); + } + let norm = std::env::temp_dir().join(format!("ff_form_in_{}.pdf", std::process::id())); + ff_engine::repair(&p, &norm).map_err(|e| e.to_string())?; + Ok((norm.clone(), Some(norm))) +} + +/// Điền form theo danh sách (tên, giá trị), ghi ra `output`. +#[tauri::command] +fn form_fill(input: String, values: Vec, output: String) -> Result { + let (src, tmp) = normalized_for_lopdf(&input)?; + let vals: Vec = values + .into_iter() + .map(|v| ff_engine::FieldValue { name: v.name, value: v.value }) + .collect(); + let r = ff_engine::fill_form_fields(&src, &vals, std::path::Path::new(&output)) + .map_err(|e| e.to_string()); + if let Some(t) = tmp { + let _ = std::fs::remove_file(t); + } + r +} + +/// Flatten form (in giá trị vào trang, bỏ tương tác). +#[tauri::command] +fn form_flatten(input: String, output: String, password: Option) -> Result<(), String> { + let pdfium = pdfium()?; + ff_engine::flatten_form( + &pdfium, + std::path::Path::new(&input), + std::path::Path::new(&output), + password.as_deref(), + ) + .map_err(|e| e.to_string()) +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct NewFieldDto { + name: String, + kind: String, + page_index: u16, + rect: [f32; 4], + #[serde(default)] + value: String, + #[serde(default)] + options: Vec, +} + +/// Tạo field mới (text/checkbox/combo). +#[tauri::command] +fn form_create(input: String, fields: Vec, output: String) -> Result<(), String> { + let (src, tmp) = normalized_for_lopdf(&input)?; + let mapped: Vec = fields + .into_iter() + .map(|f| ff_engine::NewField { + name: f.name, + kind: match f.kind.as_str() { + "checkbox" => ff_engine::FieldKind::Checkbox, + "combo" => ff_engine::FieldKind::Combo, + _ => ff_engine::FieldKind::Text, + }, + page_index: f.page_index, + rect: f.rect, + value: f.value, + options: f.options, + }) + .collect(); + let r = ff_engine::create_form_fields(&src, &mapped, std::path::Path::new(&output)) + .map_err(|e| e.to_string()); + if let Some(t) = tmp { + let _ = std::fs::remove_file(t); + } + r +} + +/// Xuất dữ liệu form ra FDF hoặc CSV theo phần mở rộng của `output`. +#[tauri::command] +fn form_export(input: String, output: String) -> Result<(), String> { + let p = std::path::Path::new(&output); + let inp = std::path::Path::new(&input); + if output.to_lowercase().ends_with(".csv") { + ff_engine::export_csv(inp, p).map_err(|e| e.to_string()) + } else { + ff_engine::export_fdf(inp, p).map_err(|e| e.to_string()) + } +} + +/// Import FDF vào form, ghi ra `output`. +#[tauri::command] +fn form_import_fdf(input: String, fdf: String, output: String) -> Result { + let (src, tmp) = normalized_for_lopdf(&input)?; + let r = ff_engine::import_fdf(&src, std::path::Path::new(&fdf), std::path::Path::new(&output)) + .map_err(|e| e.to_string()); + if let Some(t) = tmp { + let _ = std::fs::remove_file(t); + } + r +} + +/// Hộp thoại lưu file FDF. +#[tauri::command] +fn pick_save_data(app: tauri::AppHandle) -> Option { + app.dialog() + .file() + .add_filter("Dữ liệu form", &["fdf", "csv"]) + .set_file_name("form-data.fdf") + .blocking_save_file() + .map(|fp| fp.to_string()) +} + +/// Hộp thoại chọn file FDF để import. +#[tauri::command] +fn pick_fdf(app: tauri::AppHandle) -> Option { + app.dialog() + .file() + .add_filter("FDF", &["fdf"]) + .blocking_pick_file() + .map(|fp| fp.to_string()) +} + /// Hộp thoại lưu file PEM (Digital ID). #[tauri::command] fn pick_save_pem(app: tauri::AppHandle) -> Option { @@ -1085,8 +1260,16 @@ fn main() { sig_create_id, sig_sign, sig_verify, + form_list, + form_fill, + form_flatten, + form_create, + form_export, + form_import_fdf, pick_any_file, pick_save_pem, + pick_save_data, + pick_fdf, pick_image ]) .run(tauri::generate_context!()) diff --git a/app/src/index.html b/app/src/index.html index 77e1856..b1f1c6c 100644 --- a/app/src/index.html +++ b/app/src/index.html @@ -36,6 +36,7 @@ +
@@ -103,6 +104,19 @@ + +