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:
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user