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