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:
2026-07-15 16:52:51 +00:00
co-authored by Claude Fable 5
parent f62cded997
commit ccdb839fca
14 changed files with 1493 additions and 10 deletions
+7 -1
View File
@@ -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).
+1
View File
@@ -1161,6 +1161,7 @@ dependencies = [
"base64 0.22.1",
"ff-engine",
"image",
"lopdf",
"pdfium-render",
"serde",
"serde_json",
+1
View File
@@ -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]
+183
View File
@@ -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!())
+14
View File
@@ -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
View File
@@ -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 &amp; á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 &amp; 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);
+6
View File
@@ -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; }
+788
View File
@@ -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(())
}
+5
View File
@@ -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};
+179
View File
@@ -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
View File
@@ -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ở.
+8 -5
View File
@@ -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.
+67
View File
@@ -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).
+35
View File
@@ -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.