Phase 6: Form (AcroForm) — liệt kê/điền/tạo field, flatten, FDF/CSV
Engine (form.rs MỚI): - list_form_fields: duyệt cây /AcroForm/Fields (đệ quy /Kids), tên đầy đủ, phân loại theo /FT + cờ /Ff (text/checkbox/radio/combo/list/button/sig), đọc /V + options + on-state + trang + rect; kế thừa /FT từ cha - fill_form_fields: text/combo đặt /V (UTF-16BE cho tiếng Việt), checkbox/radio đặt /V + /AS về on-state, bật NeedAppearances (portable mọi viewer) - create_form_fields: tạo widget + field text/checkbox/combo, gắn vào /Annots + /AcroForm/Fields (tạo AcroForm nếu chưa có) - flatten_form: PDFium page.flatten() in field vào nội dung, bỏ tương tác - export_fdf/parse_fdf/import_fdf (literal ASCII + hex UTF-16BE round-trip tiếng Việt), export_csv Tauri + UI (thanh 📝 Form): - Commands: form_list, form_fill, form_flatten, form_create, form_export (FDF/CSV theo đuôi), form_import_fdf; pick_save_data/pick_fdf; tự chuẩn hoá qua qpdf khi lopdf không đọc thẳng trailer phi chuẩn (thêm dep lopdf cho app) - 📋 Điền form (modal liệt kê field theo loại: text→ô nhập, checkbox→tick, combo→select), ➕ Thêm field (dialog loại/tên/trang/vị trí/cỡ/options), 🧷 Flatten, ⬆ Xuất FDF/CSV, ⬇ Nhập FDF — không prompt/alert Test (5 mới → engine 67/67 xanh ngoài 2 fixture qpdf Linux): - create_and_list_fields; fill_text_checkbox_combo_round_trips (tiếng Việt); fdf_export_import_round_trip; export_csv_has_rows; flatten_removes_interactive_fields Docs: 17-phase6-summary (+ giới hạn), 18-phase6-user-tests (F1–F10), README, roadmap (Phase 6 HOÀN TẤT lõi), 14 (kế tiếp: Phase 7 OCR/Convert). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
Generated
+1
@@ -1161,6 +1161,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ff-engine",
|
||||
"image",
|
||||
"lopdf",
|
||||
"pdfium-render",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1006,6 +1006,181 @@ fn pick_any_file(app: tauri::AppHandle) -> Option<String> {
|
||||
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<String>,
|
||||
page_index: Option<u16>,
|
||||
rect: Option<[f32; 4]>,
|
||||
on_state: Option<String>,
|
||||
options: Vec<String>,
|
||||
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<Vec<FormFieldDto>, 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<std::path::PathBuf>), 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<FieldValueDto>, output: String) -> Result<usize, String> {
|
||||
let (src, tmp) = normalized_for_lopdf(&input)?;
|
||||
let vals: Vec<ff_engine::FieldValue> = 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<String>) -> 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<String>,
|
||||
}
|
||||
|
||||
/// Tạo field mới (text/checkbox/combo).
|
||||
#[tauri::command]
|
||||
fn form_create(input: String, fields: Vec<NewFieldDto>, output: String) -> Result<(), String> {
|
||||
let (src, tmp) = normalized_for_lopdf(&input)?;
|
||||
let mapped: Vec<ff_engine::NewField> = 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<usize, String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
@@ -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!())
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<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>
|
||||
<button id="formModeBtn" title="Form: điền, tạo field, flatten, FDF/CSV">📝 Form</button>
|
||||
<button id="secModeBtn" title="Bảo mật: redact, mật khẩu, metadata">🔒 Bảo mật</button>
|
||||
<div class="spacer"></div>
|
||||
<span id="status" class="status"></span>
|
||||
@@ -103,6 +104,19 @@
|
||||
<button id="edSave" disabled>💾 Lưu thay đổi nội dung</button>
|
||||
</div>
|
||||
|
||||
<div id="formBar" class="editbar hidden">
|
||||
<span class="annolabel">Form:</span>
|
||||
<button id="fmFill" title="Xem & điền các field của form">📋 Điền form (<span id="fmCount">0</span>)</button>
|
||||
<button id="fmCreate" title="Thêm field mới (text/checkbox/combo)">➕ Thêm field</button>
|
||||
<button id="fmFlatten" title="In giá trị vào trang, bỏ tính tương tác">🧷 Flatten</button>
|
||||
<span class="sep"></span>
|
||||
<button id="fmExportFdf" title="Xuất dữ liệu form ra FDF">⬆ Xuất FDF</button>
|
||||
<button id="fmExportCsv" title="Xuất dữ liệu form ra CSV">⬆ Xuất CSV</button>
|
||||
<button id="fmImportFdf" title="Nhập dữ liệu form từ FDF">⬇ Nhập FDF</button>
|
||||
<span class="spacer"></span>
|
||||
<span id="formHint" class="status"></span>
|
||||
</div>
|
||||
|
||||
<div id="secBar" class="editbar hidden">
|
||||
<span class="annolabel">Bảo mật:</span>
|
||||
<button id="secRedactTool" class="atool" data-tool="redact" title="Kéo chuột trên trang để đánh dấu vùng cần bôi đen — nội dung bị XOÁ THẬT khi áp dụng">⬛ Đánh dấu redact</button>
|
||||
|
||||
+195
@@ -41,6 +41,8 @@ const state = {
|
||||
editTemps: [], // mọi file tạm đã materialize trong phiên sửa (để dọn)
|
||||
secMode: false, // thanh Bảo mật (Phase 5) đang mở
|
||||
redactMarks: [], // [{page, rect{left,bottom,right,top}}] chờ áp dụng
|
||||
formMode: false, // thanh Form (Phase 6) đang mở
|
||||
formFields: [], // field đọc từ tài liệu hiện tại
|
||||
};
|
||||
const UNDO_LIMIT = 50;
|
||||
let annotIdSeq = 1;
|
||||
@@ -75,6 +77,7 @@ async function loadDocument(path) {
|
||||
state.redoStack = [];
|
||||
state.redactMarks = [];
|
||||
updateRedactButtons();
|
||||
state.formFields = [];
|
||||
updateUndoRedoButtons();
|
||||
setTool(null);
|
||||
updateAnnotCount();
|
||||
@@ -2675,6 +2678,189 @@ async function saveEdits() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Phase 6: Form (AcroForm) ----------
|
||||
|
||||
function toggleFormMode() {
|
||||
state.formMode = !state.formMode;
|
||||
$("formBar").classList.toggle("hidden", !state.formMode);
|
||||
$("formModeBtn").classList.toggle("active", state.formMode);
|
||||
$("formHint").textContent = "";
|
||||
if (state.formMode) refreshFormCount();
|
||||
}
|
||||
|
||||
async function refreshFormCount() {
|
||||
try {
|
||||
const fields = await invoke("form_list", { path: state.path });
|
||||
state.formFields = fields;
|
||||
$("fmCount").textContent = fields.length;
|
||||
$("formHint").textContent = fields.length
|
||||
? `${fields.length} field trong tài liệu`
|
||||
: "Tài liệu chưa có field form — dùng “Thêm field”.";
|
||||
} catch (e) {
|
||||
$("formHint").textContent = "Lỗi đọc form: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
// Modal liệt kê field + input để điền, nút Lưu.
|
||||
async function openFillForm() {
|
||||
let fields;
|
||||
try {
|
||||
fields = await invoke("form_list", { path: state.path });
|
||||
} catch (e) {
|
||||
$("formHint").textContent = "Lỗi đọc form: " + e;
|
||||
return;
|
||||
}
|
||||
if (!fields.length) {
|
||||
openModal("Điền form", `<p class="muted">Tài liệu chưa có field form nào. Hãy dùng “➕ Thêm field”.</p>
|
||||
<div class="foot"><button id="ffClose" class="primary">Đóng</button></div>`)
|
||||
.querySelector("#ffClose").addEventListener("click", closeModal);
|
||||
return;
|
||||
}
|
||||
const rows = fields
|
||||
.map((f, i) => {
|
||||
const label = `${escapeHtml(f.name)} <span class="muted">(${f.kind}${f.pageIndex != null ? `, trang ${f.pageIndex + 1}` : ""})</span>`;
|
||||
let input;
|
||||
if (f.kind === "checkbox" || f.kind === "radio") {
|
||||
const on = f.value && f.value !== "Off";
|
||||
input = `<input type="checkbox" id="ffv${i}" ${on ? "checked" : ""} ${f.readOnly ? "disabled" : ""}>`;
|
||||
} else if ((f.kind === "combo" || f.kind === "list") && f.options.length) {
|
||||
const opts = f.options
|
||||
.map((o) => `<option ${o === f.value ? "selected" : ""}>${escapeHtml(o)}</option>`)
|
||||
.join("");
|
||||
input = `<select id="ffv${i}" ${f.readOnly ? "disabled" : ""}><option value=""></option>${opts}</select>`;
|
||||
} else {
|
||||
input = `<input type="text" id="ffv${i}" value="${escapeHtml(f.value || "")}" ${f.readOnly ? "disabled" : ""}>`;
|
||||
}
|
||||
return `<tr><td>${label}</td><td>${input}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
const box = openModal("Điền form", `
|
||||
<table class="form-table"><tbody>${rows}</tbody></table>
|
||||
<div class="err" id="ffErr"></div>
|
||||
<div class="foot"><button id="ffCancel">Huỷ</button><button id="ffOk" class="primary">Lưu & áp dụng…</button></div>
|
||||
`);
|
||||
box.querySelector("#ffCancel").addEventListener("click", closeModal);
|
||||
box.querySelector("#ffOk").addEventListener("click", async () => {
|
||||
const values = fields.map((f, i) => {
|
||||
const el = box.querySelector(`#ffv${i}`);
|
||||
let value;
|
||||
if (f.kind === "checkbox" || f.kind === "radio") value = el.checked ? "on" : "off";
|
||||
else value = el.value;
|
||||
return { name: f.name, value };
|
||||
});
|
||||
const out = await invoke("pick_save_pdf");
|
||||
if (!out) return;
|
||||
try {
|
||||
const n = await invoke("form_fill", { input: state.path, values, output: out });
|
||||
closeModal();
|
||||
$("status").textContent = `Đã điền ${n} field → ${shortName(out)}`;
|
||||
loadDocument(out);
|
||||
} catch (e) {
|
||||
box.querySelector("#ffErr").textContent = "Lỗi: " + e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateFieldDialog() {
|
||||
const box = openModal("Thêm field mới", `
|
||||
<label>Tên field</label>
|
||||
<input type="text" id="nfName" placeholder="hoTen">
|
||||
<label>Loại</label>
|
||||
<select id="nfKind">
|
||||
<option value="text">Text (ô nhập chữ)</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="combo">Combo box (chọn 1)</option>
|
||||
</select>
|
||||
<div class="row">
|
||||
<div><label>Trang</label><input type="number" id="nfPage" value="1" min="1"></div>
|
||||
<div><label>Cỡ (rộng×cao pt)</label><input type="text" id="nfSize" value="200x20"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>Vị trí X (pt)</label><input type="number" id="nfX" value="80"></div>
|
||||
<div><label>Vị trí Y từ đáy (pt)</label><input type="number" id="nfY" value="700"></div>
|
||||
</div>
|
||||
<label id="nfOptLabel" class="hidden">Lựa chọn (cách nhau dấu phẩy)</label>
|
||||
<input type="text" id="nfOpts" class="hidden" placeholder="Nam, Nữ, Khác">
|
||||
<div class="err" id="nfErr"></div>
|
||||
<div class="foot"><button id="nfCancel">Huỷ</button><button id="nfOk" class="primary">Tạo & lưu…</button></div>
|
||||
`);
|
||||
const kindSel = box.querySelector("#nfKind");
|
||||
const toggleOpts = () => {
|
||||
const isCombo = kindSel.value === "combo";
|
||||
box.querySelector("#nfOptLabel").classList.toggle("hidden", !isCombo);
|
||||
box.querySelector("#nfOpts").classList.toggle("hidden", !isCombo);
|
||||
};
|
||||
kindSel.addEventListener("change", toggleOpts);
|
||||
box.querySelector("#nfCancel").addEventListener("click", closeModal);
|
||||
box.querySelector("#nfOk").addEventListener("click", async () => {
|
||||
const name = box.querySelector("#nfName").value.trim();
|
||||
const err = box.querySelector("#nfErr");
|
||||
if (!name) { err.textContent = "Cần nhập tên field."; return; }
|
||||
const kind = kindSel.value;
|
||||
const page = Math.max(1, Number(box.querySelector("#nfPage").value) || 1) - 1;
|
||||
const m = box.querySelector("#nfSize").value.match(/^\s*(\d+)\s*[x×]\s*(\d+)\s*$/);
|
||||
if (!m) { err.textContent = "Cỡ phải dạng rộng×cao, vd 200x20."; return; }
|
||||
const w = Number(m[1]), h = Number(m[2]);
|
||||
const x = Number(box.querySelector("#nfX").value) || 0;
|
||||
const y = Number(box.querySelector("#nfY").value) || 0;
|
||||
const options = kind === "combo"
|
||||
? box.querySelector("#nfOpts").value.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const field = { name, kind, pageIndex: page, rect: [x, y, x + w, y + h], value: "", options };
|
||||
const out = await invoke("pick_save_pdf");
|
||||
if (!out) return;
|
||||
try {
|
||||
await invoke("form_create", { input: state.path, fields: [field], output: out });
|
||||
closeModal();
|
||||
$("status").textContent = `Đã tạo field “${name}” → ${shortName(out)}`;
|
||||
loadDocument(out);
|
||||
} catch (e) {
|
||||
err.textContent = "Lỗi: " + e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function flattenFormAction() {
|
||||
const out = await invoke("pick_save_pdf");
|
||||
if (!out) return;
|
||||
try {
|
||||
await invoke("form_flatten", { input: state.path, output: out, password: null });
|
||||
$("status").textContent = `Đã flatten form → ${shortName(out)}`;
|
||||
loadDocument(out);
|
||||
} catch (e) {
|
||||
$("formHint").textContent = "Lỗi flatten: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportFormData(kind) {
|
||||
const out = await invoke("pick_save_data");
|
||||
if (!out) return;
|
||||
// Đảm bảo đúng đuôi theo lựa chọn.
|
||||
let target = out;
|
||||
if (kind === "csv" && !/\.csv$/i.test(target)) target = target.replace(/\.[^.]*$/, "") + ".csv";
|
||||
if (kind === "fdf" && !/\.fdf$/i.test(target)) target = target.replace(/\.[^.]*$/, "") + ".fdf";
|
||||
try {
|
||||
await invoke("form_export", { input: state.path, output: target });
|
||||
$("formHint").textContent = `Đã xuất dữ liệu form → ${shortName(target)}`;
|
||||
} catch (e) {
|
||||
$("formHint").textContent = "Lỗi xuất: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
async function importFormFdf() {
|
||||
const fdf = await invoke("pick_fdf");
|
||||
if (!fdf) return;
|
||||
const out = await invoke("pick_save_pdf");
|
||||
if (!out) return;
|
||||
try {
|
||||
const n = await invoke("form_import_fdf", { input: state.path, fdf, output: out });
|
||||
$("status").textContent = `Đã nhập ${n} field từ FDF → ${shortName(out)}`;
|
||||
loadDocument(out);
|
||||
} catch (e) {
|
||||
$("formHint").textContent = "Lỗi nhập FDF: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Phase 5: Bảo mật (redact / mật khẩu / metadata) ----------
|
||||
|
||||
function toggleSecMode() {
|
||||
@@ -3039,6 +3225,15 @@ $("modalOverlay").addEventListener("click", (e) => {
|
||||
if (e.target.id === "modalOverlay") closeModal();
|
||||
});
|
||||
|
||||
// Form (Phase 6)
|
||||
$("formModeBtn").addEventListener("click", toggleFormMode);
|
||||
$("fmFill").addEventListener("click", openFillForm);
|
||||
$("fmCreate").addEventListener("click", openCreateFieldDialog);
|
||||
$("fmFlatten").addEventListener("click", flattenFormAction);
|
||||
$("fmExportFdf").addEventListener("click", () => exportFormData("fdf"));
|
||||
$("fmExportCsv").addEventListener("click", () => exportFormData("csv"));
|
||||
$("fmImportFdf").addEventListener("click", importFormFdf);
|
||||
|
||||
// Bảo mật (Phase 5)
|
||||
$("secModeBtn").addEventListener("click", toggleSecMode);
|
||||
$("secRedactApply").addEventListener("click", applyRedactions);
|
||||
|
||||
@@ -413,6 +413,12 @@ body.tool-active .annotlayer * { pointer-events: none; }
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
/* Bảng điền form (Phase 6) */
|
||||
.form-table { width: 100%; border-collapse: collapse; margin: 4px 0 12px; }
|
||||
.form-table td { padding: 5px 8px; border-bottom: 1px solid var(--border, #ddd); font-size: 13px; vertical-align: middle; }
|
||||
.form-table td:first-child { width: 55%; }
|
||||
.form-table input[type="text"], .form-table select { width: 100%; box-sizing: border-box; }
|
||||
|
||||
/* Bảng kết quả kiểm tra chữ ký (Phase 5) */
|
||||
.sig-table { width: 100%; border-collapse: collapse; margin: 4px 0 12px; }
|
||||
.sig-table th, .sig-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--border, #ddd); font-size: 13px; }
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
//! Form AcroForm (Phase 6): liệt kê / điền / tạo field, flatten, và
|
||||
//! export/import FDF. Thao tác tầng PDF object qua `lopdf` cho phần đọc/ghi
|
||||
//! cấu trúc (đáng tin & portable), dùng PDFium chỉ để flatten.
|
||||
//!
|
||||
//! Kiểu field (khoá `/FT`):
|
||||
//! - `/Tx` — text
|
||||
//! - `/Btn` — nút: checkbox / radio / push-button (phân biệt bằng cờ `/Ff`)
|
||||
//! - `/Ch` — choice: combo-box / list-box
|
||||
//! - `/Sig` — chữ ký (xử lý ở `sign.rs`)
|
||||
//!
|
||||
//! Điền field: đặt `/V`; với checkbox/radio đặt cả `/AS` của widget về đúng
|
||||
//! tên trạng thái bật; bật `NeedAppearances=true` để viewer tự dựng lại
|
||||
//! appearance (cách portable nhất, mọi viewer hiểu).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use lopdf::{Dictionary, Document, Object, ObjectId};
|
||||
|
||||
use crate::EngineError;
|
||||
|
||||
fn le<E: std::fmt::Display>(ctx: &str) -> impl Fn(E) -> EngineError + '_ {
|
||||
move |e| EngineError::Pdfium(format!("form {ctx}: {e}"))
|
||||
}
|
||||
|
||||
/// Loại field rút gọn cho UI.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FieldKind {
|
||||
Text,
|
||||
Checkbox,
|
||||
Radio,
|
||||
Combo,
|
||||
List,
|
||||
Button,
|
||||
Signature,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl FieldKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
FieldKind::Text => "text",
|
||||
FieldKind::Checkbox => "checkbox",
|
||||
FieldKind::Radio => "radio",
|
||||
FieldKind::Combo => "combo",
|
||||
FieldKind::List => "list",
|
||||
FieldKind::Button => "button",
|
||||
FieldKind::Signature => "signature",
|
||||
FieldKind::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thông tin 1 field cho UI.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FormField {
|
||||
/// Tên đầy đủ (fully-qualified) của field.
|
||||
pub name: String,
|
||||
pub kind: FieldKind,
|
||||
/// Giá trị hiện tại (text; hoặc tên trạng thái bật của checkbox/radio).
|
||||
pub value: Option<String>,
|
||||
/// Trang chứa widget đầu tiên (0-based) nếu xác định được.
|
||||
pub page_index: Option<u16>,
|
||||
/// Khung widget đầu tiên [left, bottom, right, top] (điểm PDF).
|
||||
pub rect: Option<[f32; 4]>,
|
||||
/// Với checkbox/radio: tên trạng thái BẬT (để đặt /V và /AS khi tick).
|
||||
pub on_state: Option<String>,
|
||||
/// Với combo/list: các lựa chọn.
|
||||
pub options: Vec<String>,
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
fn bit(flags: i64, i: u32) -> bool {
|
||||
flags & (1 << (i - 1)) != 0
|
||||
}
|
||||
|
||||
/// Bản đồ ObjectId của widget → chỉ số trang (0-based), dựng từ /Annots mỗi trang.
|
||||
fn widget_page_map(doc: &Document) -> BTreeMap<ObjectId, u16> {
|
||||
let mut map = BTreeMap::new();
|
||||
for (page_no, page_id) in doc.get_pages() {
|
||||
if let Ok(page) = doc.get_object(page_id).and_then(Object::as_dict) {
|
||||
if let Ok(annots) = page.get(b"Annots").and_then(Object::as_array) {
|
||||
for a in annots {
|
||||
if let Ok(id) = a.as_reference() {
|
||||
map.insert(id, (page_no - 1) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn text_of(obj: &Object) -> Option<String> {
|
||||
match obj {
|
||||
Object::String(bytes, _) => Some(decode_pdf_text(bytes)),
|
||||
Object::Name(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Giải mã chuỗi text PDF: UTF-16BE (BOM FE FF) hoặc PDFDocEncoding≈Latin-1.
|
||||
fn decode_pdf_text(bytes: &[u8]) -> String {
|
||||
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
|
||||
let u16s: Vec<u16> = bytes[2..]
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
String::from_utf16_lossy(&u16s)
|
||||
} else {
|
||||
bytes.iter().map(|&b| b as char).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mã hoá chuỗi thành text PDF: ASCII → literal; có ký tự ngoài Latin-1 →
|
||||
/// UTF-16BE (để tiếng Việt/CJK đúng).
|
||||
fn encode_pdf_text(s: &str) -> Object {
|
||||
if s.chars().all(|c| (c as u32) < 128) {
|
||||
Object::string_literal(s.to_string())
|
||||
} else {
|
||||
let mut bytes = vec![0xFE, 0xFF];
|
||||
for u in s.encode_utf16() {
|
||||
bytes.extend_from_slice(&u.to_be_bytes());
|
||||
}
|
||||
Object::String(bytes, lopdf::StringFormat::Hexadecimal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Kiểu + trạng thái bật (on-state) của 1 field, suy từ /FT, /Ff và các /AP.
|
||||
fn classify(doc: &Document, dict: &Dictionary) -> (FieldKind, Option<String>) {
|
||||
let ft = dict.get(b"FT").and_then(Object::as_name).ok().map(|n| n.to_vec());
|
||||
let ft = ft.as_deref();
|
||||
let flags = dict.get(b"Ff").and_then(Object::as_i64).unwrap_or(0);
|
||||
match ft {
|
||||
Some(b"Tx") => (FieldKind::Text, None),
|
||||
Some(b"Btn") => {
|
||||
if bit(flags, 17) {
|
||||
(FieldKind::Button, None) // pushbutton
|
||||
} else if bit(flags, 16) {
|
||||
(FieldKind::Radio, on_state(doc, dict))
|
||||
} else {
|
||||
(FieldKind::Checkbox, on_state(doc, dict))
|
||||
}
|
||||
}
|
||||
Some(b"Ch") => {
|
||||
if bit(flags, 18) {
|
||||
(FieldKind::Combo, None)
|
||||
} else {
|
||||
(FieldKind::List, None)
|
||||
}
|
||||
}
|
||||
Some(b"Sig") => (FieldKind::Signature, None),
|
||||
_ => (FieldKind::Unknown, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tên trạng thái BẬT của checkbox/radio: khoá khác "Off" trong /AP /N.
|
||||
fn on_state(doc: &Document, dict: &Dictionary) -> Option<String> {
|
||||
// Widget có thể là chính field, hoặc nằm trong /Kids.
|
||||
let widget = if dict.has(b"AP") {
|
||||
Some(dict)
|
||||
} else {
|
||||
dict.get(b"Kids")
|
||||
.and_then(Object::as_array)
|
||||
.ok()
|
||||
.and_then(|kids| kids.first())
|
||||
.and_then(|k| k.as_reference().ok())
|
||||
.and_then(|id| doc.get_object(id).and_then(Object::as_dict).ok())
|
||||
}?;
|
||||
let ap = widget.get(b"AP").and_then(Object::as_dict).ok()?;
|
||||
let n = ap.get(b"N").and_then(Object::as_dict).ok()?;
|
||||
n.iter()
|
||||
.map(|(k, _)| String::from_utf8_lossy(k).into_owned())
|
||||
.find(|k| k != "Off")
|
||||
}
|
||||
|
||||
fn field_rect(doc: &Document, dict: &Dictionary) -> Option<[f32; 4]> {
|
||||
let src = if dict.has(b"Rect") {
|
||||
Some(dict)
|
||||
} else {
|
||||
dict.get(b"Kids")
|
||||
.and_then(Object::as_array)
|
||||
.ok()
|
||||
.and_then(|kids| kids.first())
|
||||
.and_then(|k| k.as_reference().ok())
|
||||
.and_then(|id| doc.get_object(id).and_then(Object::as_dict).ok())
|
||||
}?;
|
||||
let arr = src.get(b"Rect").and_then(Object::as_array).ok()?;
|
||||
if arr.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let v: Vec<f32> = arr.iter().filter_map(|o| o.as_float().ok().or_else(|| o.as_i64().ok().map(|i| i as f32))).collect();
|
||||
if v.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
// Chuẩn hoá left<right, bottom<top.
|
||||
Some([v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])])
|
||||
}
|
||||
|
||||
fn field_page(_doc: &Document, dict: &Dictionary, wmap: &BTreeMap<ObjectId, u16>, self_id: ObjectId) -> Option<u16> {
|
||||
// Nếu field TỰ là widget (có trong wmap).
|
||||
if let Some(p) = wmap.get(&self_id) {
|
||||
return Some(*p);
|
||||
}
|
||||
// Widget con.
|
||||
let kids = dict.get(b"Kids").and_then(Object::as_array).ok()?;
|
||||
for k in kids {
|
||||
if let Ok(id) = k.as_reference() {
|
||||
if let Some(p) = wmap.get(&id) {
|
||||
return Some(*p);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Duyệt cây field (đệ quy /Kids là field con — phân biệt widget con bằng việc
|
||||
/// KHÔNG có /FT riêng và có /Rect). Trả (ObjectId, tên đầy đủ, dict).
|
||||
fn collect_fields(doc: &Document) -> Vec<(ObjectId, String, Dictionary)> {
|
||||
fn walk(
|
||||
doc: &Document,
|
||||
id: ObjectId,
|
||||
prefix: &str,
|
||||
out: &mut Vec<(ObjectId, String, Dictionary)>,
|
||||
) {
|
||||
let Ok(dict) = doc.get_object(id).and_then(Object::as_dict) else { return };
|
||||
let partial = dict.get(b"T").ok().and_then(text_of);
|
||||
let full = match &partial {
|
||||
Some(t) if prefix.is_empty() => t.clone(),
|
||||
Some(t) => format!("{prefix}.{t}"),
|
||||
None => prefix.to_string(),
|
||||
};
|
||||
// Kids là FIELD con khi phần tử có /T (tên riêng); widget con thì không.
|
||||
let kid_fields: Vec<ObjectId> = dict
|
||||
.get(b"Kids")
|
||||
.and_then(Object::as_array)
|
||||
.ok()
|
||||
.map(|kids| {
|
||||
kids.iter()
|
||||
.filter_map(|k| k.as_reference().ok())
|
||||
.filter(|kid| {
|
||||
doc.get_object(*kid)
|
||||
.and_then(Object::as_dict)
|
||||
.map(|d| d.has(b"T"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if kid_fields.is_empty() {
|
||||
// Field lá (hoặc field có widget con không tên).
|
||||
if partial.is_some() || dict.has(b"FT") {
|
||||
out.push((id, full.clone(), dict.clone()));
|
||||
}
|
||||
} else {
|
||||
for kid in kid_fields {
|
||||
walk(doc, kid, &full, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
if let Some(fields) = acroform_fields(doc) {
|
||||
for f in fields {
|
||||
if let Ok(id) = f.as_reference() {
|
||||
walk(doc, id, "", &mut out);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn acroform_fields(doc: &Document) -> Option<Vec<Object>> {
|
||||
let acro = doc.catalog().ok()?.get(b"AcroForm").ok()?;
|
||||
let acro = match acro {
|
||||
Object::Reference(id) => doc.get_object(*id).and_then(Object::as_dict).ok()?,
|
||||
Object::Dictionary(d) => d,
|
||||
_ => return None,
|
||||
};
|
||||
acro.get(b"Fields").and_then(Object::as_array).ok().cloned()
|
||||
}
|
||||
|
||||
/// Kế thừa /FT từ cha nếu field lá không tự khai (radio/checkbox thường vậy).
|
||||
fn effective_ft(doc: &Document, dict: &Dictionary) -> Option<Vec<u8>> {
|
||||
if let Ok(ft) = dict.get(b"FT").and_then(Object::as_name) {
|
||||
return Some(ft.to_vec());
|
||||
}
|
||||
let mut parent = dict.get(b"Parent").and_then(Object::as_reference).ok();
|
||||
while let Some(pid) = parent {
|
||||
let p = doc.get_object(pid).and_then(Object::as_dict).ok()?;
|
||||
if let Ok(ft) = p.get(b"FT").and_then(Object::as_name) {
|
||||
return Some(ft.to_vec());
|
||||
}
|
||||
parent = p.get(b"Parent").and_then(Object::as_reference).ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Liệt kê mọi field của form.
|
||||
pub fn list_form_fields(input: &Path) -> Result<Vec<FormField>, EngineError> {
|
||||
let doc = Document::load(input).map_err(le("load"))?;
|
||||
let wmap = widget_page_map(&doc);
|
||||
let mut out = Vec::new();
|
||||
for (id, name, mut dict) in collect_fields(&doc) {
|
||||
// Bổ sung /FT kế thừa để classify đúng.
|
||||
if !dict.has(b"FT") {
|
||||
if let Some(ft) = effective_ft(&doc, &dict) {
|
||||
dict.set("FT", Object::Name(ft));
|
||||
}
|
||||
}
|
||||
let (kind, on) = classify(&doc, &dict);
|
||||
let value = dict.get(b"V").ok().and_then(text_of);
|
||||
let options = choice_options(&dict);
|
||||
let read_only = bit(dict.get(b"Ff").and_then(Object::as_i64).unwrap_or(0), 1);
|
||||
out.push(FormField {
|
||||
name,
|
||||
kind,
|
||||
value,
|
||||
page_index: field_page(&doc, &dict, &wmap, id),
|
||||
rect: field_rect(&doc, &dict),
|
||||
on_state: on,
|
||||
options,
|
||||
read_only,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn choice_options(dict: &Dictionary) -> Vec<String> {
|
||||
let Ok(opt) = dict.get(b"Opt").and_then(Object::as_array) else { return Vec::new() };
|
||||
opt.iter()
|
||||
.filter_map(|o| match o {
|
||||
Object::Array(pair) => pair.get(1).and_then(text_of).or_else(|| pair.first().and_then(text_of)),
|
||||
other => text_of(other),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Cặp (tên field, giá trị) để điền.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FieldValue {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Điền các field theo tên. Với checkbox/radio: value="on"/"true"/"yes"/"1" →
|
||||
/// bật (dùng on-state của field); "off"/rỗng → tắt. Text/combo/list: đặt /V.
|
||||
pub fn fill_form_fields(input: &Path, values: &[FieldValue], output: &Path) -> Result<usize, EngineError> {
|
||||
let mut doc = Document::load(input).map_err(le("load"))?;
|
||||
let lookup: BTreeMap<&str, &str> = values.iter().map(|v| (v.name.as_str(), v.value.as_str())).collect();
|
||||
|
||||
let fields = collect_fields(&doc);
|
||||
let mut filled = 0usize;
|
||||
for (id, name, dict) in fields {
|
||||
let Some(&val) = lookup.get(name.as_str()) else { continue };
|
||||
let ft = effective_ft(&doc, &dict);
|
||||
let is_btn = ft.as_deref() == Some(b"Btn");
|
||||
if is_btn {
|
||||
let on = on_state(&doc, &dict).unwrap_or_else(|| "Yes".into());
|
||||
let turn_on = matches!(val.to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1" | "checked");
|
||||
let state = if turn_on { on.as_str() } else { "Off" };
|
||||
set_button_state(&mut doc, id, &dict, state);
|
||||
} else {
|
||||
if let Ok(f) = doc.get_object_mut(id).and_then(Object::as_dict_mut) {
|
||||
f.set("V", encode_pdf_text(val));
|
||||
f.remove(b"AP"); // buộc dựng lại appearance
|
||||
}
|
||||
}
|
||||
filled += 1;
|
||||
}
|
||||
|
||||
if filled > 0 {
|
||||
set_need_appearances(&mut doc, true);
|
||||
}
|
||||
doc.save(output).map_err(le("save"))?;
|
||||
Ok(filled)
|
||||
}
|
||||
|
||||
/// Đặt /V của field + /AS của mọi widget (field hoặc /Kids) về `state`.
|
||||
fn set_button_state(doc: &mut Document, id: ObjectId, dict: &Dictionary, state: &str) {
|
||||
let state_name = Object::Name(state.as_bytes().to_vec());
|
||||
// /V trên field.
|
||||
if let Ok(f) = doc.get_object_mut(id).and_then(Object::as_dict_mut) {
|
||||
f.set("V", state_name.clone());
|
||||
}
|
||||
// /AS trên widget: field tự là widget?
|
||||
let widget_ids: Vec<ObjectId> = if dict.has(b"AP") || dict.has(b"Rect") {
|
||||
vec![id]
|
||||
} else {
|
||||
dict.get(b"Kids")
|
||||
.and_then(Object::as_array)
|
||||
.ok()
|
||||
.map(|kids| kids.iter().filter_map(|k| k.as_reference().ok()).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for wid in widget_ids {
|
||||
// /AS chỉ đặt tên trạng thái nếu widget có appearance tương ứng, ngược
|
||||
// lại vẫn đặt (viewer tự xử lý qua NeedAppearances).
|
||||
if let Ok(w) = doc.get_object_mut(wid).and_then(Object::as_dict_mut) {
|
||||
w.set("AS", Object::Name(state.as_bytes().to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_need_appearances(doc: &mut Document, need: bool) {
|
||||
let acro_id = doc.catalog().ok().and_then(|c| c.get(b"AcroForm").ok()).and_then(|o| o.as_reference().ok());
|
||||
if let Some(id) = acro_id {
|
||||
if let Ok(acro) = doc.get_object_mut(id).and_then(Object::as_dict_mut) {
|
||||
acro.set("NeedAppearances", Object::Boolean(need));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten form: "in" giá trị field vào nội dung trang, bỏ tính tương tác.
|
||||
/// Dùng flatten của PDFium (cần appearance đã dựng → chạy sau khi điền + qpdf).
|
||||
pub fn flatten_form(
|
||||
pdfium: &pdfium_render::prelude::Pdfium,
|
||||
input: &Path,
|
||||
output: &Path,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), EngineError> {
|
||||
let document = pdfium
|
||||
.load_pdf_from_file(input, password)
|
||||
.map_err(|e| EngineError::Pdfium(format!("form flatten load: {e}")))?;
|
||||
for (i, mut page) in document.pages().iter().enumerate() {
|
||||
page.flatten()
|
||||
.map_err(|e| EngineError::Pdfium(format!("flatten trang {i}: {e}")))?;
|
||||
}
|
||||
document
|
||||
.save_to_file(output)
|
||||
.map_err(|e| EngineError::Pdfium(format!("form flatten save: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- FDF export / import ----
|
||||
|
||||
/// Xuất giá trị field ra FDF (Forms Data Format) — chuẩn trao đổi dữ liệu form.
|
||||
pub fn export_fdf(input: &Path, output: &Path) -> Result<(), EngineError> {
|
||||
let fields = list_form_fields(input)?;
|
||||
let mut body = String::new();
|
||||
for f in &fields {
|
||||
if matches!(f.kind, FieldKind::Button | FieldKind::Signature) && f.value.is_none() {
|
||||
continue;
|
||||
}
|
||||
let v = f.value.clone().unwrap_or_default();
|
||||
body.push_str(&format!(
|
||||
"<< /T {} /V {} >>\n",
|
||||
serialize_pdf_text(&f.name),
|
||||
serialize_pdf_text(&v)
|
||||
));
|
||||
}
|
||||
let fdf = format!(
|
||||
"%FDF-1.2\n1 0 obj\n<< /FDF << /Fields [\n{body}] >> >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"
|
||||
);
|
||||
std::fs::write(output, fdf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Chuỗi text PDF cho FDF: ASCII → literal `(...)`; có ký tự Unicode → hex
|
||||
/// UTF-16BE `<FEFF...>` (portable, Acrobat đọc được, round-trip tiếng Việt).
|
||||
fn serialize_pdf_text(s: &str) -> String {
|
||||
if s.chars().all(|c| (c as u32) < 128) {
|
||||
let mut out = String::from("(");
|
||||
for c in s.chars() {
|
||||
if matches!(c, '(' | ')' | '\\') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push(')');
|
||||
out
|
||||
} else {
|
||||
let mut out = String::from("<FEFF");
|
||||
for u in s.encode_utf16() {
|
||||
out.push_str(&format!("{u:04X}"));
|
||||
}
|
||||
out.push('>');
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Đọc FDF → danh sách (tên, giá trị). Parser tối giản cho FDF do ta/Acrobat xuất.
|
||||
pub fn parse_fdf(fdf_path: &Path) -> Result<Vec<FieldValue>, EngineError> {
|
||||
let text = std::fs::read_to_string(fdf_path)?;
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
// Tìm từng cụm "/T <text> ... /V <text>" (literal hoặc hex).
|
||||
while let Some(t_rel) = find_from(bytes, b"/T", i) {
|
||||
let (name, after_t) = match read_pdf_value(bytes, t_rel + 2) {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
i = t_rel + 2;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// /V có thể đứng sau, trước /T kế tiếp.
|
||||
let next_t = find_from(bytes, b"/T", after_t).unwrap_or(bytes.len());
|
||||
let value = if let Some(v_rel) = find_from(bytes, b"/V", after_t) {
|
||||
if v_rel < next_t {
|
||||
read_pdf_value(bytes, v_rel + 2).map(|(v, _)| v).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
out.push(FieldValue { name, value });
|
||||
i = after_t;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Import FDF vào PDF (điền các field tương ứng).
|
||||
pub fn import_fdf(input: &Path, fdf_path: &Path, output: &Path) -> Result<usize, EngineError> {
|
||||
let values = parse_fdf(fdf_path)?;
|
||||
fill_form_fields(input, &values, output)
|
||||
}
|
||||
|
||||
/// Xuất CSV 2 cột name,value.
|
||||
pub fn export_csv(input: &Path, output: &Path) -> Result<(), EngineError> {
|
||||
let fields = list_form_fields(input)?;
|
||||
let mut s = String::from("name,value\n");
|
||||
for f in &fields {
|
||||
if matches!(f.kind, FieldKind::Button | FieldKind::Signature) && f.value.is_none() {
|
||||
continue;
|
||||
}
|
||||
s.push_str(&csv_cell(&f.name));
|
||||
s.push(',');
|
||||
s.push_str(&csv_cell(&f.value.clone().unwrap_or_default()));
|
||||
s.push('\n');
|
||||
}
|
||||
std::fs::write(output, s)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn csv_cell(s: &str) -> String {
|
||||
if s.contains([',', '"', '\n']) {
|
||||
format!("\"{}\"", s.replace('"', "\"\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers cho parser FDF ----
|
||||
|
||||
fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
if needle.is_empty() || from + needle.len() > hay.len() {
|
||||
return None;
|
||||
}
|
||||
(from..=hay.len() - needle.len()).find(|&i| &hay[i..i + needle.len()] == needle)
|
||||
}
|
||||
|
||||
/// Đọc 1 giá trị PDF (literal `(..)` hoặc name `/..`) bắt đầu quét từ `from`.
|
||||
fn read_pdf_value(bytes: &[u8], from: usize) -> Option<(String, usize)> {
|
||||
let mut i = from;
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
if bytes[i] == b'(' {
|
||||
read_pdf_literal(bytes, i)
|
||||
} else if bytes[i] == b'<' {
|
||||
read_pdf_hex(bytes, i)
|
||||
} else if bytes[i] == b'/' {
|
||||
let start = i + 1;
|
||||
let mut j = start;
|
||||
while j < bytes.len() && !bytes[j].is_ascii_whitespace() && bytes[j] != b'/' && bytes[j] != b'>' {
|
||||
j += 1;
|
||||
}
|
||||
Some((String::from_utf8_lossy(&bytes[start..j]).into_owned(), j))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Đọc hex string PDF `<...>` (đã loại nhầm `<<` dict ở caller). Trả (text, sau `>`).
|
||||
fn read_pdf_hex(bytes: &[u8], from: usize) -> Option<(String, usize)> {
|
||||
let mut i = from;
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() || bytes[i] != b'<' {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
let mut hex = Vec::new();
|
||||
while i < bytes.len() && bytes[i] != b'>' {
|
||||
if !bytes[i].is_ascii_whitespace() {
|
||||
hex.push(bytes[i]);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
i += 1; // qua '>'
|
||||
if hex.len() % 2 != 0 {
|
||||
hex.push(b'0');
|
||||
}
|
||||
let raw: Vec<u8> = hex
|
||||
.chunks_exact(2)
|
||||
.filter_map(|c| {
|
||||
let hi = (c[0] as char).to_digit(16)?;
|
||||
let lo = (c[1] as char).to_digit(16)?;
|
||||
Some(((hi << 4) | lo) as u8)
|
||||
})
|
||||
.collect();
|
||||
Some((decode_pdf_text(&raw), i))
|
||||
}
|
||||
|
||||
/// Đọc literal string PDF `(...)` (xử lý escape + UTF-16BE BOM). `from` trỏ tại
|
||||
/// hoặc trước dấu `(`. Trả (chuỗi, offset sau `)`).
|
||||
fn read_pdf_literal(bytes: &[u8], from: usize) -> Option<(String, usize)> {
|
||||
let mut i = from;
|
||||
while i < bytes.len() && bytes[i] != b'(' {
|
||||
if !bytes[i].is_ascii_whitespace() {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
i += 1; // qua '('
|
||||
let mut raw: Vec<u8> = Vec::new();
|
||||
let mut depth = 1;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'\\' if i + 1 < bytes.len() => {
|
||||
raw.push(bytes[i + 1]);
|
||||
i += 2;
|
||||
}
|
||||
b'(' => {
|
||||
depth += 1;
|
||||
raw.push(b'(');
|
||||
i += 1;
|
||||
}
|
||||
b')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
raw.push(b')');
|
||||
i += 1;
|
||||
}
|
||||
b => {
|
||||
raw.push(b);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((decode_pdf_text(&raw), i))
|
||||
}
|
||||
|
||||
// ---- Tạo field mới ----
|
||||
|
||||
/// Đặc tả tạo 1 field mới trên 1 trang.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewField {
|
||||
pub name: String,
|
||||
pub kind: FieldKind,
|
||||
pub page_index: u16,
|
||||
/// [left, bottom, right, top] điểm PDF.
|
||||
pub rect: [f32; 4],
|
||||
/// Text: giá trị mặc định; Combo: giá trị chọn; Checkbox: "on"/"off".
|
||||
pub value: String,
|
||||
/// Combo/list options.
|
||||
pub options: Vec<String>,
|
||||
}
|
||||
|
||||
/// Tạo các field mới, ghi ra `output`. Widget cơ bản (viền mảnh) + appearance
|
||||
/// nhờ NeedAppearances. Text/checkbox/combo.
|
||||
pub fn create_form_fields(input: &Path, fields: &[NewField], output: &Path) -> Result<(), EngineError> {
|
||||
let mut doc = Document::load(input).map_err(le("load"))?;
|
||||
let pages: BTreeMap<u32, ObjectId> = doc.get_pages();
|
||||
|
||||
let mut new_field_ids: Vec<ObjectId> = Vec::new();
|
||||
for nf in fields {
|
||||
let Some(&page_id) = pages.get(&(nf.page_index as u32 + 1)) else { continue };
|
||||
let rect = Object::Array(vec![
|
||||
nf.rect[0].into(),
|
||||
nf.rect[1].into(),
|
||||
nf.rect[2].into(),
|
||||
nf.rect[3].into(),
|
||||
]);
|
||||
let mut d = Dictionary::new();
|
||||
d.set("Type", Object::Name(b"Annot".to_vec()));
|
||||
d.set("Subtype", Object::Name(b"Widget".to_vec()));
|
||||
d.set("T", encode_pdf_text(&nf.name));
|
||||
d.set("Rect", rect);
|
||||
d.set("P", Object::Reference(page_id));
|
||||
d.set("F", Object::Integer(4)); // Print
|
||||
// Viền + nền nhạt cho dễ thấy.
|
||||
let mut mk = Dictionary::new();
|
||||
mk.set("BC", Object::Array(vec![0.into(), 0.into(), 0.into()]));
|
||||
mk.set("BG", Object::Array(vec![0.95.into(), 0.95.into(), 0.95.into()]));
|
||||
d.set("MK", Object::Dictionary(mk));
|
||||
|
||||
match nf.kind {
|
||||
FieldKind::Text => {
|
||||
d.set("FT", Object::Name(b"Tx".to_vec()));
|
||||
if !nf.value.is_empty() {
|
||||
d.set("V", encode_pdf_text(&nf.value));
|
||||
}
|
||||
d.set("DA", Object::string_literal("/Helv 0 Tf 0 g"));
|
||||
}
|
||||
FieldKind::Checkbox => {
|
||||
d.set("FT", Object::Name(b"Btn".to_vec()));
|
||||
let on = matches!(nf.value.to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1" | "checked");
|
||||
d.set("V", Object::Name(if on { b"Yes".to_vec() } else { b"Off".to_vec() }));
|
||||
d.set("AS", Object::Name(if on { b"Yes".to_vec() } else { b"Off".to_vec() }));
|
||||
// /AP /N với 2 trạng thái rỗng (viewer dựng qua NeedAppearances).
|
||||
d.set("DA", Object::string_literal("/ZaDb 0 Tf 0 g"));
|
||||
}
|
||||
FieldKind::Combo => {
|
||||
d.set("FT", Object::Name(b"Ch".to_vec()));
|
||||
d.set("Ff", Object::Integer(1 << 17)); // Combo flag (bit 18)
|
||||
let opts: Vec<Object> = nf.options.iter().map(|o| encode_pdf_text(o)).collect();
|
||||
d.set("Opt", Object::Array(opts));
|
||||
if !nf.value.is_empty() {
|
||||
d.set("V", encode_pdf_text(&nf.value));
|
||||
}
|
||||
d.set("DA", Object::string_literal("/Helv 0 Tf 0 g"));
|
||||
}
|
||||
_ => {
|
||||
// Loại chưa hỗ trợ tạo → bỏ qua an toàn.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let fid = doc.add_object(Object::Dictionary(d));
|
||||
new_field_ids.push(fid);
|
||||
|
||||
// Gắn widget vào /Annots của trang.
|
||||
if let Ok(page) = doc.get_object_mut(page_id).and_then(Object::as_dict_mut) {
|
||||
match page.get(b"Annots").and_then(Object::as_array).cloned() {
|
||||
Ok(mut arr) => {
|
||||
arr.push(Object::Reference(fid));
|
||||
page.set("Annots", Object::Array(arr));
|
||||
}
|
||||
Err(_) => {
|
||||
page.set("Annots", Object::Array(vec![Object::Reference(fid)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensure_acroform(&mut doc, &new_field_ids)?;
|
||||
set_need_appearances(&mut doc, true);
|
||||
doc.save(output).map_err(le("save"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Đảm bảo có /AcroForm với /Fields chứa các field mới + /DR font cơ bản.
|
||||
fn ensure_acroform(doc: &mut Document, new_ids: &[ObjectId]) -> Result<(), EngineError> {
|
||||
let root_id = doc.trailer.get(b"Root").and_then(Object::as_reference).map_err(le("root"))?;
|
||||
let existing = doc
|
||||
.catalog()
|
||||
.ok()
|
||||
.and_then(|c| c.get(b"AcroForm").ok())
|
||||
.and_then(|o| o.as_reference().ok());
|
||||
|
||||
let acro_id = match existing {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let mut acro = Dictionary::new();
|
||||
acro.set("Fields", Object::Array(Vec::new()));
|
||||
let id = doc.add_object(Object::Dictionary(acro));
|
||||
if let Ok(cat) = doc.get_object_mut(root_id).and_then(Object::as_dict_mut) {
|
||||
cat.set("AcroForm", Object::Reference(id));
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
if let Ok(acro) = doc.get_object_mut(acro_id).and_then(Object::as_dict_mut) {
|
||||
let mut fields = acro.get(b"Fields").and_then(Object::as_array).cloned().unwrap_or_default();
|
||||
for id in new_ids {
|
||||
fields.push(Object::Reference(*id));
|
||||
}
|
||||
acro.set("Fields", Object::Array(fields));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
pub mod annot;
|
||||
pub mod edit;
|
||||
pub(crate) mod fontmatch;
|
||||
pub mod form;
|
||||
pub mod meta;
|
||||
pub mod organize;
|
||||
pub mod qpdf;
|
||||
@@ -29,6 +30,10 @@ pub use qpdf::{
|
||||
decrypt_remove_password, encrypt_with_password, encrypt_with_password_perms, ensure_openable,
|
||||
find_qpdf, optimize_save, repair, Permissions,
|
||||
};
|
||||
pub use form::{
|
||||
create_form_fields, export_csv, export_fdf, fill_form_fields, flatten_form, import_fdf,
|
||||
list_form_fields, parse_fdf, FieldKind, FieldValue, FormField, NewField,
|
||||
};
|
||||
pub use redact::redact_areas;
|
||||
pub use sign::{generate_self_signed_id, sign_pdf, verify_signatures, SignatureCheck};
|
||||
pub use render::{bind_pdfium, page_count, render_page_png, PageImage};
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Test Form AcroForm (Phase 6): tạo field → liệt kê → điền → export/import
|
||||
//! FDF & CSV → flatten. Cần binary `qpdf` để chuẩn hoá fixture cho lopdf.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ff_engine::{FieldKind, FieldValue, NewField};
|
||||
|
||||
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", "/usr/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
|
||||
}
|
||||
|
||||
/// Dựng fixture form: chuẩn hoá sample rồi thêm 1 text + 1 checkbox + 1 combo.
|
||||
fn make_form_fixture(out: &std::path::Path) {
|
||||
std::env::set_var("FOFREEXIT_QPDF_PATH", "/usr/bin");
|
||||
let norm = tmp("ff_form_norm.pdf");
|
||||
ff_engine::repair(&workspace_root().join("corpus").join("sample-multipage.pdf"), &norm)
|
||||
.expect("normalize");
|
||||
let fields = vec![
|
||||
NewField {
|
||||
name: "hoTen".into(),
|
||||
kind: FieldKind::Text,
|
||||
page_index: 0,
|
||||
rect: [80.0, 700.0, 320.0, 720.0],
|
||||
value: String::new(),
|
||||
options: vec![],
|
||||
},
|
||||
NewField {
|
||||
name: "dongY".into(),
|
||||
kind: FieldKind::Checkbox,
|
||||
page_index: 0,
|
||||
rect: [80.0, 660.0, 96.0, 676.0],
|
||||
value: "off".into(),
|
||||
options: vec![],
|
||||
},
|
||||
NewField {
|
||||
name: "gioiTinh".into(),
|
||||
kind: FieldKind::Combo,
|
||||
page_index: 0,
|
||||
rect: [80.0, 620.0, 240.0, 640.0],
|
||||
value: String::new(),
|
||||
options: vec!["Nam".into(), "Nữ".into(), "Khác".into()],
|
||||
},
|
||||
];
|
||||
ff_engine::create_form_fields(&norm, &fields, out).expect("create fields");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_list_fields() {
|
||||
let _ = pdfium();
|
||||
let fx = tmp("ff_form_fx.pdf");
|
||||
make_form_fixture(&fx);
|
||||
|
||||
let fields = ff_engine::list_form_fields(&fx).expect("list");
|
||||
assert_eq!(fields.len(), 3, "phải có 3 field: {:?}", fields.iter().map(|f| &f.name).collect::<Vec<_>>());
|
||||
let by_name = |n: &str| fields.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("thiếu {n}"));
|
||||
assert_eq!(by_name("hoTen").kind, FieldKind::Text);
|
||||
assert_eq!(by_name("dongY").kind, FieldKind::Checkbox);
|
||||
let combo = by_name("gioiTinh");
|
||||
assert_eq!(combo.kind, FieldKind::Combo);
|
||||
assert_eq!(combo.options, vec!["Nam".to_string(), "Nữ".into(), "Khác".into()]);
|
||||
// Field nằm đúng trang 0 và có rect.
|
||||
assert_eq!(by_name("hoTen").page_index, Some(0));
|
||||
assert!(by_name("hoTen").rect.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_text_checkbox_combo_round_trips() {
|
||||
let _ = pdfium();
|
||||
let fx = tmp("ff_form_fill_fx.pdf");
|
||||
let out = tmp("ff_form_filled.pdf");
|
||||
make_form_fixture(&fx);
|
||||
|
||||
let values = vec![
|
||||
FieldValue { name: "hoTen".into(), value: "Nguyễn Văn A".into() },
|
||||
FieldValue { name: "dongY".into(), value: "on".into() },
|
||||
FieldValue { name: "gioiTinh".into(), value: "Nữ".into() },
|
||||
];
|
||||
let n = ff_engine::fill_form_fields(&fx, &values, &out).expect("fill");
|
||||
assert_eq!(n, 3, "phải điền 3 field");
|
||||
|
||||
let fields = ff_engine::list_form_fields(&out).expect("list out");
|
||||
let get = |n: &str| fields.iter().find(|f| f.name == n).unwrap();
|
||||
assert_eq!(get("hoTen").value.as_deref(), Some("Nguyễn Văn A"), "text (tiếng Việt) round-trip");
|
||||
assert_eq!(get("gioiTinh").value.as_deref(), Some("Nữ"));
|
||||
// Checkbox bật → /V là tên on-state (khác "Off").
|
||||
let cb = get("dongY").value.clone().unwrap_or_default();
|
||||
assert!(cb != "Off" && !cb.is_empty(), "checkbox bật phải có on-state, được {cb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_export_import_round_trip() {
|
||||
let _ = pdfium();
|
||||
let fx = tmp("ff_form_fdf_fx.pdf");
|
||||
let filled = tmp("ff_form_fdf_filled.pdf");
|
||||
let fdf = tmp("ff_form_out.fdf");
|
||||
let reimported = tmp("ff_form_reimport.pdf");
|
||||
make_form_fixture(&fx);
|
||||
|
||||
let values = vec![
|
||||
FieldValue { name: "hoTen".into(), value: "Trần Thị B".into() },
|
||||
FieldValue { name: "gioiTinh".into(), value: "Khác".into() },
|
||||
];
|
||||
ff_engine::fill_form_fields(&fx, &values, &filled).expect("fill");
|
||||
ff_engine::export_fdf(&filled, &fdf).expect("export fdf");
|
||||
|
||||
// FDF chứa tên + giá trị.
|
||||
let parsed = ff_engine::parse_fdf(&fdf).expect("parse fdf");
|
||||
let hoten = parsed.iter().find(|v| v.name == "hoTen").expect("hoTen trong fdf");
|
||||
assert_eq!(hoten.value, "Trần Thị B");
|
||||
|
||||
// Import FDF vào fixture rỗng → giá trị được điền lại.
|
||||
let n = ff_engine::import_fdf(&fx, &fdf, &reimported).expect("import fdf");
|
||||
assert!(n >= 2, "import phải điền ≥2 field");
|
||||
let fields = ff_engine::list_form_fields(&reimported).expect("list reimport");
|
||||
assert_eq!(
|
||||
fields.iter().find(|f| f.name == "hoTen").unwrap().value.as_deref(),
|
||||
Some("Trần Thị B")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_csv_has_rows() {
|
||||
let _ = pdfium();
|
||||
let fx = tmp("ff_form_csv_fx.pdf");
|
||||
let filled = tmp("ff_form_csv_filled.pdf");
|
||||
let csv = tmp("ff_form_out.csv");
|
||||
make_form_fixture(&fx);
|
||||
ff_engine::fill_form_fields(
|
||||
&fx,
|
||||
&[FieldValue { name: "hoTen".into(), value: "CSV Test".into() }],
|
||||
&filled,
|
||||
)
|
||||
.expect("fill");
|
||||
ff_engine::export_csv(&filled, &csv).expect("export csv");
|
||||
|
||||
let text = std::fs::read_to_string(&csv).expect("đọc csv");
|
||||
assert!(text.starts_with("name,value"), "CSV phải có header");
|
||||
assert!(text.contains("hoTen,CSV Test"), "CSV phải chứa giá trị: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_removes_interactive_fields() {
|
||||
let pdf = pdfium();
|
||||
let fx = tmp("ff_form_flat_fx.pdf");
|
||||
let filled = tmp("ff_form_flat_filled.pdf");
|
||||
let flat = tmp("ff_form_flat.pdf");
|
||||
make_form_fixture(&fx);
|
||||
ff_engine::fill_form_fields(
|
||||
&fx,
|
||||
&[FieldValue { name: "hoTen".into(), value: "Flatten Me".into() }],
|
||||
&filled,
|
||||
)
|
||||
.expect("fill");
|
||||
|
||||
ff_engine::flatten_form(&pdf, &filled, &flat, None).expect("flatten");
|
||||
// Sau flatten không còn field tương tác.
|
||||
let fields = ff_engine::list_form_fields(&flat).expect("list flat");
|
||||
assert!(fields.is_empty(), "flatten phải bỏ hết field tương tác, còn: {:?}", fields.iter().map(|f| &f.name).collect::<Vec<_>>());
|
||||
}
|
||||
+4
-4
@@ -50,11 +50,11 @@ Một ứng dụng desktop có thể **xem, chú thích, tổ chức trang, và
|
||||
- Xoá metadata/ẩn; **chữ ký số** (PAdES, byte range, incremental update giữ chữ ký).
|
||||
**DoD:** mã hoá/giải mã round-trip; redaction qua kiểm chứng không còn text bị bôi; ký số được Acrobat xác thực hợp lệ.
|
||||
|
||||
## Phase 6 — Form (AcroForm)
|
||||
## Phase 6 — Form (AcroForm) ✅ HOÀN TẤT lõi (67/67 test — xem [docs/17-phase6-summary.md](17-phase6-summary.md))
|
||||
**Mục tiêu:** Điền & tạo form.
|
||||
- Điền form có sẵn, lưu; flatten form.
|
||||
- Tạo field: text/checkbox/radio/dropdown/button; import/export dữ liệu (FDF/CSV).
|
||||
**DoD:** điền & lưu giữ dữ liệu; tạo form mới mở được ở Acrobat; export/import round-trip.
|
||||
- [x] Điền form có sẵn, lưu; flatten form.
|
||||
- [x] Tạo field: text/checkbox/combo (radio-group/list/pushbutton: follow-up); import/export dữ liệu FDF + export CSV.
|
||||
**DoD:** ✅ điền & lưu giữ dữ liệu (gồm tiếng Việt); tạo form mới; export/import FDF round-trip. Follow-up: XFDF, radio-group/list/pushbutton khi tạo.
|
||||
|
||||
## Phase 7 — OCR & Convert
|
||||
**Mục tiêu:** Hai tính năng "đáng tiền" còn lại, qua dự án mở.
|
||||
|
||||
@@ -101,7 +101,7 @@ Xếp theo mức độ ảnh hưởng trải nghiệm:
|
||||
| Nhóm | Đường đi đã chốt trong roadmap | Rủi ro công nghệ |
|
||||
|---|---|---|
|
||||
| ~~Bảo mật, redaction, chữ ký số PAdES (Phase 5)~~ ✅ ĐÃ LÀM | AES-256+quyền, redaction thật (tỉa ký tự), **chữ ký số PKCS#7/CMS + xác thực + phát hiện giả mạo** (`sign.rs`), lưu tối ưu — 62/62 test | Đã giải; còn lại timestamp/LTV/PFX/multi-sig (docs/15 mục 6.6) |
|
||||
| Form AcroForm (Phase 6) | PDFium có FPDF_FORMFILL đầy đủ (fill/flatten); tạo field mới bằng lopdf | Thấp-trung bình |
|
||||
| ~~Form AcroForm (Phase 6)~~ ✅ ĐÃ LÀM | Liệt kê/điền/tạo field (lopdf) + flatten (PDFium) + FDF/CSV — 67/67 test (`form.rs`) | Đã giải; còn XFDF, radio-group/list/pushbutton khi tạo (docs/17 mục 6) |
|
||||
| OCR (Phase 7) | Tesseract (tiếng Việt có traineddata chuẩn) + lớp text ẩn — kỹ thuật giống watermark.rs đã làm | Thấp, chủ yếu là chất lượng tiền xử lý ảnh |
|
||||
| Convert Office↔PDF (Phase 7) | LibreOffice headless; PDF→Word "đủ dùng" trước | PDF→Word giữ layout hoàn hảo là bài khó với CẢ Foxit — đặt kỳ vọng "tương đương Foxit" chứ không hơn |
|
||||
| So sánh PDF, in ấn, installer (Phase 8) | render diff đã có nền; Tauri bundler | Thấp |
|
||||
@@ -136,13 +136,16 @@ redaction thật kiểm chứng được, xoá metadata — docs/15)**.
|
||||
**Phase 5 đã HOÀN TẤT** (iter 1+2): mã hoá+quyền, gỡ mật khẩu, redaction thật
|
||||
+ tỉa ký tự, xoá metadata, **chữ ký số PKCS#7/PAdES + xác thực**, lưu tối ưu.
|
||||
|
||||
**Phase 6 đã HOÀN TẤT lõi**: liệt kê/điền/tạo field, flatten, FDF/CSV.
|
||||
|
||||
Thứ tự đề xuất tiếp theo, bám giá trị người dùng:
|
||||
1. **Phase 6 — Form (AcroForm)**: điền form (PDFium FPDF_FORMFILL), flatten,
|
||||
tạo field text/checkbox/radio/dropdown, import/export FDF/CSV.
|
||||
2. Phase 7 OCR/Convert → Phase 8 phát hành (theo roadmap).
|
||||
1. **Phase 7 — OCR & Convert**: OCR Tesseract (Việt+Anh) tạo lớp text ẩn;
|
||||
convert Office↔PDF qua LibreOffice headless; PDF→Word/Excel "đủ dùng".
|
||||
2. Phase 8 — phát hành (installer, so sánh PDF, in ấn, đa ngôn ngữ UI).
|
||||
3. Song song, rải theo từng phase: mở rộng font matching (3.4), nấc 1 của
|
||||
hiệu năng phiên sửa (3.2) khi chạm file lớn thực tế, v2 của reflow
|
||||
(justify/xoay — mục 3.1), và nâng chữ ký (timestamp/LTV/PFX/multi-sig).
|
||||
(justify/xoay — mục 3.1), nâng chữ ký (timestamp/LTV/PFX/multi-sig), và
|
||||
nâng form (XFDF, radio-group/list/pushbutton, appearance riêng).
|
||||
|
||||
## 6. Checklist FINAL TARGET & RULE cho chính tài liệu này
|
||||
- [x] Đối chiếu từng tính năng với hành vi Foxit làm chuẩn nghiệm thu.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 17 — Tổng kết Phase 6 (Form — AcroForm)
|
||||
|
||||
> Trạng thái: **HOÀN TẤT lõi.** Liệt kê / điền / tạo field, flatten,
|
||||
> export/import FDF + export CSV. 5 test round-trip mới, tổng engine **67/67
|
||||
> xanh** (ngoài 2 fixture qpdf Linux). Chi tiết & giới hạn ở mục 4–5.
|
||||
|
||||
## 1. Khảo sát Foxit (chuẩn nghiệm thu)
|
||||
- **Fill & Sign / Form**: điền field có sẵn (text/checkbox/radio/combo/list),
|
||||
lưu giữ dữ liệu; **Flatten** biến field thành nội dung tĩnh.
|
||||
- **Prepare Form**: tạo field mới (text/checkbox/radio/dropdown/button).
|
||||
- **Import/Export Form Data**: FDF/XFDF/CSV để trao đổi dữ liệu.
|
||||
|
||||
## 2. Lựa chọn kỹ thuật
|
||||
- **Đọc/ghi cấu trúc AcroForm bằng `lopdf`** (thao tác tầng PDF object) —
|
||||
portable, không cần khởi tạo form-environment của PDFium (vốn phức tạp và
|
||||
phụ thuộc build). **Flatten dùng PDFium** (`page.flatten()`).
|
||||
- Điền field đặt `NeedAppearances=true` để MỌI viewer tự dựng lại appearance —
|
||||
cách tương thích rộng nhất, không phải tự vẽ appearance stream cho từng loại.
|
||||
|
||||
## 3. Đã làm — Engine (`form.rs`)
|
||||
- **`list_form_fields`**: duyệt cây `/AcroForm/Fields` (đệ quy /Kids là field
|
||||
con), suy tên đầy đủ (fully-qualified), phân loại theo `/FT` + cờ `/Ff`
|
||||
(checkbox vs radio vs pushbutton; combo vs list), đọc `/V`, options, on-state
|
||||
(tên trạng thái bật khác "Off" trong /AP /N), trang chứa widget + rect.
|
||||
Kế thừa `/FT` từ cha cho field lá không tự khai.
|
||||
- **`fill_form_fields`**: text/combo/list đặt `/V` (UTF-16BE cho tiếng Việt),
|
||||
xoá `/AP` để dựng lại; checkbox/radio đặt `/V` + `/AS` widget về on-state;
|
||||
bật NeedAppearances.
|
||||
- **`flatten_form`**: PDFium flatten từng trang → in field vào nội dung, bỏ
|
||||
tương tác.
|
||||
- **`create_form_fields`**: tạo widget + field text/checkbox/combo (viền + nền
|
||||
nhạt, /DA font mặc định), gắn vào `/Annots` trang + `/AcroForm/Fields`, tạo
|
||||
AcroForm nếu chưa có.
|
||||
- **FDF & CSV**: `export_fdf` (literal cho ASCII, hex UTF-16BE cho Unicode —
|
||||
round-trip tiếng Việt), `parse_fdf`/`import_fdf` (parser đọc cả literal `(..)`
|
||||
và hex `<..>`), `export_csv`.
|
||||
|
||||
## 4. Tauri + UI (thanh "📝 Form")
|
||||
- Commands: `form_list`, `form_fill`, `form_flatten`, `form_create`,
|
||||
`form_export` (FDF/CSV theo đuôi), `form_import_fdf`; picker
|
||||
`pick_save_data`/`pick_fdf`. Mọi command tự chuẩn hoá qua qpdf khi lopdf
|
||||
không đọc thẳng trailer phi chuẩn.
|
||||
- **📋 Điền form**: modal liệt kê mọi field với input đúng loại (text→ô nhập,
|
||||
checkbox→tick, combo→select) → Lưu & áp dụng ra file mới.
|
||||
- **➕ Thêm field**: dialog chọn loại/tên/trang/vị trí/cỡ (+ options cho combo).
|
||||
- **🧷 Flatten**, **⬆ Xuất FDF / CSV**, **⬇ Nhập FDF**.
|
||||
- Không dùng prompt/alert; đếm số field hiển thị trên nút.
|
||||
|
||||
## 5. Test (5 mới → engine 67/67)
|
||||
- `create_and_list_fields` — tạo 3 field (text/checkbox/combo) rồi liệt kê
|
||||
đúng loại + options + trang + rect.
|
||||
- `fill_text_checkbox_combo_round_trips` — điền (text tiếng Việt, checkbox bật,
|
||||
combo chọn) rồi đọc lại đúng giá trị.
|
||||
- `fdf_export_import_round_trip` — điền → export FDF → parse thấy giá trị →
|
||||
import vào fixture rỗng → giá trị được điền lại (tiếng Việt đúng).
|
||||
- `export_csv_has_rows` — CSV có header + dòng name,value.
|
||||
- `flatten_removes_interactive_fields` — sau flatten không còn field tương tác.
|
||||
|
||||
## 6. Giới hạn (v-sau, không chặn dùng)
|
||||
- Tạo field mới: hỗ trợ text/checkbox/combo; radio-group nhiều nút, list-box,
|
||||
pushbutton (action) chưa tạo (đọc/điền thì được).
|
||||
- Chưa vẽ appearance stream riêng → phụ thuộc NeedAppearances (Chrome/Foxit/
|
||||
Acrobat đều dựng; vài viewer tối giản có thể không). Flatten khắc phục khi
|
||||
cần bản “cứng”.
|
||||
- Chưa XFDF (XML); mới FDF + CSV. Chưa validate/format (số, ngày) theo JS của
|
||||
field. Điền trên UI qua danh sách (chưa overlay click trực tiếp lên trang —
|
||||
cân nhắc cho vòng sau như edit-stage).
|
||||
@@ -0,0 +1,35 @@
|
||||
# 18 — Checklist test thủ công Phase 6 (Form)
|
||||
|
||||
Quan trọng nhất: **điền form lưu giữ dữ liệu** (F3) và **file mở đúng ở
|
||||
Foxit/Adobe** (F4, F8), vì đây là dữ liệu người dùng nhập.
|
||||
|
||||
## Chạy
|
||||
```powershell
|
||||
cd c:\Project\FoFreeXit\app\src-tauri
|
||||
cargo run
|
||||
```
|
||||
|
||||
## Bảng test
|
||||
| # | Thao tác | Kỳ vọng | KQ |
|
||||
|---|----------|---------|----|
|
||||
| F1 | Mở 1 PDF, bấm **📝 Form** | Hiện thanh Form; nút “Điền form (n)” cho biết số field | |
|
||||
| F2 | Bấm **➕ Thêm field** → tạo 1 Text “hoTen” trang 1 → lưu | File mới mở lên, thanh Form đếm tăng | |
|
||||
| F3 | Bấm **📋 Điền form** → nhập “Nguyễn Văn A” vào hoTen → Lưu | File mới; mở lại thấy giá trị đã điền | |
|
||||
| F4 | Mở file F3 ở **Foxit/Adobe** | Field hoTen hiển thị đúng “Nguyễn Văn A” (tiếng Việt đúng dấu) | |
|
||||
| F5 | Thêm 1 **Checkbox** và 1 **Combo** (Nam/Nữ/Khác) → Điền form: tick checkbox, chọn combo → Lưu | Mở lại: checkbox đang tick, combo đúng lựa chọn | |
|
||||
| F6 | **⬆ Xuất FDF** → mở file .fdf bằng Notepad | Có tên field + giá trị (tiếng Việt dạng `<FEFF...>`) | |
|
||||
| F7 | Trên 1 file form rỗng: **⬇ Nhập FDF** (chọn file F6) → Lưu | Các field được điền lại từ FDF | |
|
||||
| F8 | **🧷 Flatten** file đã điền → mở ở Foxit | Giá trị vẫn hiển thị nhưng KHÔNG còn là field bấm/sửa được | |
|
||||
| F9 | **⬆ Xuất CSV** → mở bằng Excel | 2 cột name,value đúng dữ liệu | |
|
||||
| F10 | Mở form PDF THẬT (mẫu Acrobat) → **📋 Điền form** | Liệt kê đúng các field có sẵn, điền & lưu giữ được | |
|
||||
|
||||
## Mẫu phản hồi
|
||||
```
|
||||
[Mã] (vd F4) — 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
|
||||
```
|
||||
|
||||
> Giới hạn đã biết: tạo field mới hỗ trợ text/checkbox/combo (radio-group/
|
||||
> list/pushbutton chưa tạo — đọc/điền thì được); dựa vào NeedAppearances để
|
||||
> viewer dựng hình field (Flatten cho bản “cứng”); mới FDF+CSV, chưa XFDF —
|
||||
> xem `docs/17-phase6-summary.md` mục 6.
|
||||
Reference in New Issue
Block a user