Phase 5 Iteration 1: Bảo mật — mã hoá + quyền hạn, gỡ mật khẩu, redaction THẬT, xoá metadata
Engine: - qpdf.rs: Permissions (in/sửa/copy/chú thích) map sang cờ AES-256 của qpdf; encrypt_with_password_perms (API cũ giữ tương thích); decrypt_remove_password (--password --decrypt, sai mật khẩu trả lỗi không ghi file rác) - redact.rs MỚI — redaction thật, nghiêng về xoá thừa (an toàn): text/path/shading/form giao vùng → xoá nguyên object; ảnh giao vùng → đọc pixel gốc qua get_raw_image, BÔI ĐEN đúng phần giao trong dữ liệu ảnh rồi thay ảnh (không đọc được → xoá cả ảnh); vẽ khối đen phủ vùng - meta.rs: strip_metadata — xoá /Info trailer + XMP /Metadata catalog, xoá cả tham chiếu lẫn object đích (lopdf; file trailer phi chuẩn qua qpdf trước) Tauri + UI (thanh 🔒 Bảo mật): - Commands: redact_apply (nhiều trang, chain file tạm), security_encrypt, security_decrypt, security_strip_metadata - Redact 2 bước như Foxit: tool quét vùng (khối đen mờ viền đỏ, bấm để bỏ, giữ tool quét nhiều vùng) → Áp dụng (n) → lưu file mới - Dialog Đặt mật khẩu (user/confirm/owner + 4 checkbox quyền), Gỡ mật khẩu, Xoá metadata — modal chuyên nghiệp, không prompt/alert Test (6 mới, tổng 58/58 ngoài qpdf-fixture): - redact: chuỗi mật biến mất khỏi extract + tâm vùng render đen; đọc lại RAW image từ file kết quả — nửa redact đen trong chính dữ liệu ảnh - encrypt+perms round-trip + qpdf --show-encryption xác nhận cấm in; decrypt gỡ mật khẩu, mật khẩu sai fail; metadata biến mất khỏi BYTES file - qpdf_safety: 5/7 xanh trên Linux; 2 test repair fixture corrupt-truncated fail do khác biệt qpdf 11.9 Linux (bản Windows dự án xử lý được) — ghi chú trong docs/15, cần xác nhận lại trên máy Windows Docs: 15-phase5-summary (kèm kế hoạch Iteration 2: chữ ký số PAdES, lưu tối ưu, redact tỉa ký tự), 16-phase5-user-tests (S1–S12), README, roadmap, 14. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
@@ -42,7 +42,12 @@
|
|||||||
- [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng
|
- [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng
|
||||||
- [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md)
|
- [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md)
|
||||||
- **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30)
|
- **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30)
|
||||||
- [ ] **Phase 5 — Bảo mật & Chữ ký** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md)
|
- [x] **Phase 5 — Bảo mật** ✅ Iteration 1 hoàn tất (58/58 test engine xanh)
|
||||||
|
- [x] Engine: mã hoá AES-256 + quyền hạn (in/sửa/copy/chú thích), gỡ mật khẩu (`qpdf.rs`); **redaction THẬT** (`redact.rs`: xoá object, bôi đen pixel ảnh trong chính dữ liệu ảnh — test kiểm bytes/pixel/extract); xoá metadata /Info + XMP (`meta.rs`)
|
||||||
|
- [x] UI thanh "🔒 Bảo mật": đánh dấu redact 2 bước như Foxit (quét vùng → Áp dụng), dialog Đặt/Gỡ mật khẩu + checkbox quyền, Xoá metadata
|
||||||
|
- [ ] Iteration 2: chữ ký số PAdES + xác thực, lưu tối ưu (subset font + dọn rác), redact tỉa theo ký tự — xem [docs/15-phase5-summary.md](docs/15-phase5-summary.md)
|
||||||
|
- **Checklist test cho bạn**: [docs/16-phase5-user-tests.md](docs/16-phase5-user-tests.md)
|
||||||
|
- [ ] **Phase 6 — Form (AcroForm)** (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).
|
Build & chạy: xem [docs/05-dev-setup.md](docs/05-dev-setup.md).
|
||||||
|
|
||||||
|
|||||||
@@ -836,6 +836,109 @@ fn edit_preview(input: String, page: u16, ops: Vec<EditOpDto>, width: u32, passw
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Phase 5: Bảo mật ----------
|
||||||
|
|
||||||
|
/// Vùng redact của 1 trang: rects theo [left, bottom, right, top] (điểm PDF).
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RedactPageDto {
|
||||||
|
page: u16,
|
||||||
|
rects: Vec<[f32; 4]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redact THẬT nhiều trang: áp tuần tự qua file tạm, trả tổng số object đã
|
||||||
|
/// xoá/bôi đen.
|
||||||
|
#[tauri::command]
|
||||||
|
fn redact_apply(
|
||||||
|
input: String,
|
||||||
|
areas: Vec<RedactPageDto>,
|
||||||
|
output: String,
|
||||||
|
password: Option<String>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
if areas.is_empty() {
|
||||||
|
return Err("chưa đánh dấu vùng redact nào".into());
|
||||||
|
}
|
||||||
|
let pdfium = pdfium()?;
|
||||||
|
let mut total = 0usize;
|
||||||
|
let mut cur = std::path::PathBuf::from(&input);
|
||||||
|
let mut temps: Vec<std::path::PathBuf> = Vec::new();
|
||||||
|
let n = areas.len();
|
||||||
|
for (i, a) in areas.iter().enumerate() {
|
||||||
|
let rects: Vec<ff_engine::Rect> = a
|
||||||
|
.rects
|
||||||
|
.iter()
|
||||||
|
.map(|r| ff_engine::Rect { left: r[0], bottom: r[1], right: r[2], top: r[3] })
|
||||||
|
.collect();
|
||||||
|
let dst = if i + 1 == n {
|
||||||
|
std::path::PathBuf::from(&output)
|
||||||
|
} else {
|
||||||
|
std::env::temp_dir().join(format!("ff_redact_{}_{}.pdf", std::process::id(), i))
|
||||||
|
};
|
||||||
|
total += ff_engine::redact_areas(&pdfium, &cur, a.page, &rects, &dst, password.as_deref())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if i > 0 {
|
||||||
|
temps.push(cur.clone());
|
||||||
|
}
|
||||||
|
cur = dst;
|
||||||
|
}
|
||||||
|
for t in temps {
|
||||||
|
let _ = std::fs::remove_file(t);
|
||||||
|
}
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Đặt mật khẩu + quyền hạn (AES-256 qua qpdf).
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn security_encrypt(
|
||||||
|
input: String,
|
||||||
|
output: String,
|
||||||
|
user_password: String,
|
||||||
|
owner_password: String,
|
||||||
|
allow_print: bool,
|
||||||
|
allow_modify: bool,
|
||||||
|
allow_extract: bool,
|
||||||
|
allow_annotate: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
ff_engine::encrypt_with_password_perms(
|
||||||
|
std::path::Path::new(&input),
|
||||||
|
std::path::Path::new(&output),
|
||||||
|
&user_password,
|
||||||
|
&owner_password,
|
||||||
|
ff_engine::Permissions { allow_print, allow_modify, allow_extract, allow_annotate },
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gỡ mật khẩu (cần đúng password hiện tại).
|
||||||
|
#[tauri::command]
|
||||||
|
fn security_decrypt(input: String, password: String, output: String) -> Result<(), String> {
|
||||||
|
ff_engine::decrypt_remove_password(
|
||||||
|
std::path::Path::new(&input),
|
||||||
|
&password,
|
||||||
|
std::path::Path::new(&output),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Xoá metadata nhận dạng (/Info + XMP). File lạ (trailer phi chuẩn) được
|
||||||
|
/// chuẩn hoá qua qpdf trước khi lopdf xử lý.
|
||||||
|
#[tauri::command]
|
||||||
|
fn security_strip_metadata(input: String, output: String) -> Result<(), String> {
|
||||||
|
let inp = std::path::Path::new(&input);
|
||||||
|
let out = std::path::Path::new(&output);
|
||||||
|
match ff_engine::strip_metadata(inp, out) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(_) => {
|
||||||
|
let norm = std::env::temp_dir().join(format!("ff_meta_norm_{}.pdf", std::process::id()));
|
||||||
|
ff_engine::repair(inp, &norm).map_err(|e| e.to_string())?;
|
||||||
|
let r = ff_engine::strip_metadata(&norm, out).map_err(|e| e.to_string());
|
||||||
|
let _ = std::fs::remove_file(&norm);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Dọn các file làm việc tạm của chế độ sửa (undo stack). Chỉ xoá file có tên
|
/// Dọn các file làm việc tạm của chế độ sửa (undo stack). Chỉ xoá file có tên
|
||||||
/// `ff_edit_*` nằm đúng trong thư mục temp — không bao giờ đụng file người dùng.
|
/// `ff_edit_*` nằm đúng trong thư mục temp — không bao giờ đụng file người dùng.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -896,6 +999,10 @@ fn main() {
|
|||||||
edit_apply_to_temp,
|
edit_apply_to_temp,
|
||||||
edit_preview,
|
edit_preview,
|
||||||
edit_cleanup,
|
edit_cleanup,
|
||||||
|
redact_apply,
|
||||||
|
security_encrypt,
|
||||||
|
security_decrypt,
|
||||||
|
security_strip_metadata,
|
||||||
pick_image
|
pick_image
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<button id="organizeModeBtn" title="Tổ chức trang">🗂 Tổ chức trang</button>
|
<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="editModeBtn" title="Sửa nội dung trang">✏️ Sửa nội dung</button>
|
||||||
|
<button id="secModeBtn" title="Bảo mật: redact, mật khẩu, metadata">🔒 Bảo mật</button>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<span id="status" class="status"></span>
|
<span id="status" class="status"></span>
|
||||||
</header>
|
</header>
|
||||||
@@ -102,6 +103,19 @@
|
|||||||
<button id="edSave" disabled>💾 Lưu thay đổi nội dung</button>
|
<button id="edSave" disabled>💾 Lưu thay đổi nội dung</button>
|
||||||
</div>
|
</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>
|
||||||
|
<button id="secRedactApply" disabled title="Xoá thật nội dung trong các vùng đã đánh dấu, lưu ra file mới">Áp dụng redact (<span id="redactCount">0</span>)</button>
|
||||||
|
<button id="secRedactClear" disabled title="Bỏ toàn bộ đánh dấu">Bỏ đánh dấu</button>
|
||||||
|
<span class="sep"></span>
|
||||||
|
<button id="secEncrypt" title="Đặt mật khẩu mở file + quyền hạn (AES-256)">🔑 Đặt mật khẩu</button>
|
||||||
|
<button id="secDecrypt" title="Gỡ mật khẩu (cần mật khẩu hiện tại)">🔓 Gỡ mật khẩu</button>
|
||||||
|
<button id="secStripMeta" title="Xoá /Info (tác giả, phần mềm tạo...) và XMP metadata khỏi file">🧹 Xoá metadata</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span id="secHint" class="status"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<aside id="sidebar" class="sidebar">
|
<aside id="sidebar" class="sidebar">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
|
|||||||
+182
-3
@@ -39,6 +39,8 @@ const state = {
|
|||||||
editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh)
|
editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh)
|
||||||
editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text
|
editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text
|
||||||
editTemps: [], // mọi file tạm đã materialize trong phiên sửa (để dọn)
|
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
|
||||||
};
|
};
|
||||||
const UNDO_LIMIT = 50;
|
const UNDO_LIMIT = 50;
|
||||||
let annotIdSeq = 1;
|
let annotIdSeq = 1;
|
||||||
@@ -71,6 +73,8 @@ async function loadDocument(path) {
|
|||||||
state.annotSpecs = [];
|
state.annotSpecs = [];
|
||||||
state.undoStack = [];
|
state.undoStack = [];
|
||||||
state.redoStack = [];
|
state.redoStack = [];
|
||||||
|
state.redactMarks = [];
|
||||||
|
updateRedactButtons();
|
||||||
updateUndoRedoButtons();
|
updateUndoRedoButtons();
|
||||||
setTool(null);
|
setTool(null);
|
||||||
updateAnnotCount();
|
updateAnnotCount();
|
||||||
@@ -599,9 +603,11 @@ function setTool(tool) {
|
|||||||
$("annotHint").textContent = state.tool
|
$("annotHint").textContent = state.tool
|
||||||
? state.tool === "note"
|
? state.tool === "note"
|
||||||
? "Bấm lên trang để đặt ghi chú"
|
? "Bấm lên trang để đặt ghi chú"
|
||||||
: isTextMarkupTool(state.tool)
|
: state.tool === "redact"
|
||||||
? "Kéo chọn văn bản trên trang để tô (bấm lại nút để tắt)"
|
? "Kéo chuột quét vùng cần bôi đen — nội dung sẽ bị XOÁ THẬT khi Áp dụng"
|
||||||
: "Kéo chuột trên trang để vẽ"
|
: isTextMarkupTool(state.tool)
|
||||||
|
? "Kéo chọn văn bản trên trang để tô (bấm lại nút để tắt)"
|
||||||
|
: "Kéo chuột trên trang để vẽ"
|
||||||
: "";
|
: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,6 +764,28 @@ function drawAnnotsForPage(idx) {
|
|||||||
}
|
}
|
||||||
layer.appendChild(el);
|
layer.appendChild(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Đánh dấu redact (Phase 5) — vẽ cùng layer để sống sót qua mọi lần redraw.
|
||||||
|
for (const m of state.redactMarks) {
|
||||||
|
if (m.page !== idx) continue;
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "redact-mark";
|
||||||
|
Object.assign(el.style, {
|
||||||
|
left: m.rect.left * scale + "px",
|
||||||
|
top: (p.heightPt - m.rect.top) * scale + "px",
|
||||||
|
width: (m.rect.right - m.rect.left) * scale + "px",
|
||||||
|
height: (m.rect.top - m.rect.bottom) * scale + "px",
|
||||||
|
});
|
||||||
|
el.title = "Bấm để bỏ đánh dấu redact";
|
||||||
|
el.addEventListener("click", (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const i = state.redactMarks.indexOf(m);
|
||||||
|
if (i >= 0) state.redactMarks.splice(i, 1);
|
||||||
|
drawAnnotsForPage(idx);
|
||||||
|
updateRedactButtons();
|
||||||
|
});
|
||||||
|
layer.appendChild(el);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAnnot(id) {
|
function selectAnnot(id) {
|
||||||
@@ -1195,6 +1223,13 @@ function onPagesMouseUp() {
|
|||||||
openCropDialog(d.idx, rect);
|
openCropDialog(d.idx, rect);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (tool === "redact") {
|
||||||
|
if (tiny) return;
|
||||||
|
state.redactMarks.push({ page: d.idx, rect });
|
||||||
|
drawAnnotsForPage(d.idx);
|
||||||
|
updateRedactButtons();
|
||||||
|
return; // giữ tool để quét tiếp nhiều vùng (như Foxit Mark for Redaction)
|
||||||
|
}
|
||||||
if (tool === "note") {
|
if (tool === "note") {
|
||||||
const sz = 18 / scale;
|
const sz = 18 / scale;
|
||||||
rect.right = rect.left + sz;
|
rect.right = rect.left + sz;
|
||||||
@@ -2640,6 +2675,142 @@ async function saveEdits() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Phase 5: Bảo mật (redact / mật khẩu / metadata) ----------
|
||||||
|
|
||||||
|
function toggleSecMode() {
|
||||||
|
state.secMode = !state.secMode;
|
||||||
|
$("secBar").classList.toggle("hidden", !state.secMode);
|
||||||
|
$("secModeBtn").classList.toggle("active", state.secMode);
|
||||||
|
if (!state.secMode && state.tool === "redact") setTool(null);
|
||||||
|
$("secHint").textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRedactButtons() {
|
||||||
|
const n = state.redactMarks.length;
|
||||||
|
$("redactCount").textContent = n;
|
||||||
|
$("secRedactApply").disabled = n === 0;
|
||||||
|
$("secRedactClear").disabled = n === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRedactMarks() {
|
||||||
|
const pages = [...new Set(state.redactMarks.map((m) => m.page))];
|
||||||
|
state.redactMarks = [];
|
||||||
|
pages.forEach((p) => drawAnnotsForPage(p));
|
||||||
|
updateRedactButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Áp dụng redact: XOÁ THẬT nội dung trong các vùng đã đánh dấu → file mới.
|
||||||
|
async function applyRedactions() {
|
||||||
|
if (!state.redactMarks.length) return;
|
||||||
|
const out = await invoke("pick_save_pdf");
|
||||||
|
if (!out) return;
|
||||||
|
// Gom theo trang: [{page, rects: [[l,b,r,t], ...]}]
|
||||||
|
const byPage = new Map();
|
||||||
|
for (const m of state.redactMarks) {
|
||||||
|
if (!byPage.has(m.page)) byPage.set(m.page, []);
|
||||||
|
byPage.get(m.page).push([m.rect.left, m.rect.bottom, m.rect.right, m.rect.top]);
|
||||||
|
}
|
||||||
|
const areas = [...byPage.entries()].map(([page, rects]) => ({ page, rects }));
|
||||||
|
try {
|
||||||
|
const n = await invoke("redact_apply", { input: state.path, areas, output: out, password: null });
|
||||||
|
clearRedactMarks();
|
||||||
|
setTool(null);
|
||||||
|
$("status").textContent = `Đã redact (xoá thật) ${n} đối tượng → ${shortName(out)}`;
|
||||||
|
loadDocument(out);
|
||||||
|
} catch (e) {
|
||||||
|
$("secHint").textContent = "Lỗi redact: " + e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEncryptDialog() {
|
||||||
|
const box = openModal("Đặt mật khẩu (AES-256)", `
|
||||||
|
<label>Mật khẩu mở file (user password)</label>
|
||||||
|
<input type="password" id="secPw1" autocomplete="new-password">
|
||||||
|
<label>Nhập lại mật khẩu</label>
|
||||||
|
<input type="password" id="secPw2" autocomplete="new-password">
|
||||||
|
<label>Mật khẩu chủ sở hữu (owner — để trống = dùng mật khẩu mở)</label>
|
||||||
|
<input type="password" id="secPwOwner" autocomplete="new-password">
|
||||||
|
<label>Quyền hạn khi mở bằng mật khẩu user</label>
|
||||||
|
<div class="row">
|
||||||
|
<label><input type="checkbox" id="secPermPrint" checked> In</label>
|
||||||
|
<label><input type="checkbox" id="secPermModify" checked> Sửa nội dung</label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label><input type="checkbox" id="secPermExtract" checked> Sao chép text/ảnh</label>
|
||||||
|
<label><input type="checkbox" id="secPermAnnotate" checked> Chú thích/điền form</label>
|
||||||
|
</div>
|
||||||
|
<div class="err" id="secEncErr"></div>
|
||||||
|
<div class="foot"><button id="secEncCancel">Huỷ</button><button id="secEncOk" class="primary">Mã hoá…</button></div>
|
||||||
|
`);
|
||||||
|
box.querySelector("#secEncCancel").addEventListener("click", closeModal);
|
||||||
|
box.querySelector("#secEncOk").addEventListener("click", async () => {
|
||||||
|
const p1 = box.querySelector("#secPw1").value;
|
||||||
|
const p2 = box.querySelector("#secPw2").value;
|
||||||
|
const err = box.querySelector("#secEncErr");
|
||||||
|
if (!p1) { err.textContent = "Mật khẩu không được để trống."; return; }
|
||||||
|
if (p1 !== p2) { err.textContent = "Hai lần nhập không khớp."; return; }
|
||||||
|
const out = await invoke("pick_save_pdf");
|
||||||
|
if (!out) return;
|
||||||
|
try {
|
||||||
|
await invoke("security_encrypt", {
|
||||||
|
input: state.path,
|
||||||
|
output: out,
|
||||||
|
userPassword: p1,
|
||||||
|
ownerPassword: box.querySelector("#secPwOwner").value,
|
||||||
|
allowPrint: box.querySelector("#secPermPrint").checked,
|
||||||
|
allowModify: box.querySelector("#secPermModify").checked,
|
||||||
|
allowExtract: box.querySelector("#secPermExtract").checked,
|
||||||
|
allowAnnotate: box.querySelector("#secPermAnnotate").checked,
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
$("status").textContent = `Đã mã hoá AES-256 → ${shortName(out)}`;
|
||||||
|
$("secHint").textContent = "File mã hoá đã lưu riêng — file đang mở giữ nguyên.";
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = "Lỗi: " + e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDecryptDialog() {
|
||||||
|
const box = openModal("Gỡ mật khẩu", `
|
||||||
|
<p class="muted">Chọn file PDF đang có mật khẩu, nhập mật khẩu hiện tại, lưu ra bản không mã hoá.</p>
|
||||||
|
<label>Mật khẩu hiện tại</label>
|
||||||
|
<input type="password" id="secDecPw" autocomplete="current-password">
|
||||||
|
<div class="err" id="secDecErr"></div>
|
||||||
|
<div class="foot"><button id="secDecCancel">Huỷ</button><button id="secDecOk" class="primary">Chọn file & gỡ…</button></div>
|
||||||
|
`);
|
||||||
|
box.querySelector("#secDecCancel").addEventListener("click", closeModal);
|
||||||
|
box.querySelector("#secDecOk").addEventListener("click", async () => {
|
||||||
|
const pw = box.querySelector("#secDecPw").value;
|
||||||
|
const err = box.querySelector("#secDecErr");
|
||||||
|
if (!pw) { err.textContent = "Cần nhập mật khẩu hiện tại."; return; }
|
||||||
|
const inp = await invoke("pick_pdf");
|
||||||
|
if (!inp) return;
|
||||||
|
const out = await invoke("pick_save_pdf");
|
||||||
|
if (!out) return;
|
||||||
|
try {
|
||||||
|
await invoke("security_decrypt", { input: inp, password: pw, output: out });
|
||||||
|
closeModal();
|
||||||
|
$("status").textContent = `Đã gỡ mật khẩu → ${shortName(out)}`;
|
||||||
|
loadDocument(out);
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = "Lỗi (mật khẩu sai?): " + e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stripMetadataAction() {
|
||||||
|
const out = await invoke("pick_save_pdf");
|
||||||
|
if (!out) return;
|
||||||
|
try {
|
||||||
|
await invoke("security_strip_metadata", { input: state.path, output: out });
|
||||||
|
$("status").textContent = `Đã xoá metadata (/Info + XMP) → ${shortName(out)}`;
|
||||||
|
loadDocument(out);
|
||||||
|
} catch (e) {
|
||||||
|
$("secHint").textContent = "Lỗi xoá metadata: " + e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Sự kiện ----------
|
// ---------- Sự kiện ----------
|
||||||
|
|
||||||
$("openBtn").addEventListener("click", openFile);
|
$("openBtn").addEventListener("click", openFile);
|
||||||
@@ -2755,6 +2926,14 @@ $("modalOverlay").addEventListener("click", (e) => {
|
|||||||
if (e.target.id === "modalOverlay") closeModal();
|
if (e.target.id === "modalOverlay") closeModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bảo mật (Phase 5)
|
||||||
|
$("secModeBtn").addEventListener("click", toggleSecMode);
|
||||||
|
$("secRedactApply").addEventListener("click", applyRedactions);
|
||||||
|
$("secRedactClear").addEventListener("click", clearRedactMarks);
|
||||||
|
$("secEncrypt").addEventListener("click", openEncryptDialog);
|
||||||
|
$("secDecrypt").addEventListener("click", openDecryptDialog);
|
||||||
|
$("secStripMeta").addEventListener("click", stripMetadataAction);
|
||||||
|
|
||||||
// Sửa nội dung (Phase 4)
|
// Sửa nội dung (Phase 4)
|
||||||
$("editModeBtn").addEventListener("click", toggleEditMode);
|
$("editModeBtn").addEventListener("click", toggleEditMode);
|
||||||
$("edAddText").addEventListener("click", () => {
|
$("edAddText").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -413,6 +413,18 @@ body.tool-active .annotlayer * { pointer-events: none; }
|
|||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Đánh dấu redact (Phase 5): khối mờ đen viền đỏ, bấm để bỏ */
|
||||||
|
.redact-mark {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
border: 1.5px dashed #e33;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.redact-mark:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* Textarea sửa cả đoạn (reflow) — đè white-space:pre của .edit-inline */
|
/* Textarea sửa cả đoạn (reflow) — đè white-space:pre của .edit-inline */
|
||||||
.edit-inline-block {
|
.edit-inline-block {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub(crate) mod fontmatch;
|
|||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod organize;
|
pub mod organize;
|
||||||
pub mod qpdf;
|
pub mod qpdf;
|
||||||
|
pub mod redact;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
pub mod watermark;
|
pub mod watermark;
|
||||||
@@ -18,12 +19,16 @@ pub use annot::{
|
|||||||
apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec,
|
apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec,
|
||||||
};
|
};
|
||||||
pub use edit::{apply_edits, list_objects, EditOp, ObjectInfo, ObjectKind};
|
pub use edit::{apply_edits, list_objects, EditOp, ObjectInfo, ObjectKind};
|
||||||
pub use meta::{outline, page_dims, OutlineItem, PageDim};
|
pub use meta::{outline, page_dims, strip_metadata, OutlineItem, PageDim};
|
||||||
pub use organize::{
|
pub use organize::{
|
||||||
build_document, delete_pages, extract_pages, identity_plan, merge_files, rotate_pages,
|
build_document, delete_pages, extract_pages, identity_plan, merge_files, rotate_pages,
|
||||||
split_by_page_count, PagePlanEntry, PageSource,
|
split_by_page_count, PagePlanEntry, PageSource,
|
||||||
};
|
};
|
||||||
pub use qpdf::{encrypt_with_password, ensure_openable, find_qpdf, repair};
|
pub use qpdf::{
|
||||||
|
decrypt_remove_password, encrypt_with_password, encrypt_with_password_perms, ensure_openable,
|
||||||
|
find_qpdf, repair, Permissions,
|
||||||
|
};
|
||||||
|
pub use redact::redact_areas;
|
||||||
pub use render::{bind_pdfium, page_count, render_page_png, PageImage};
|
pub use render::{bind_pdfium, page_count, render_page_png, PageImage};
|
||||||
pub use text::{extract_text, page_char_boxes, search, CharBox, Rect, SearchHit};
|
pub use text::{extract_text, page_char_boxes, search, CharBox, Rect, SearchHit};
|
||||||
pub use watermark::{add_header_footer, add_watermark, Anchor, HeaderFooterSpec, WatermarkSpec};
|
pub use watermark::{add_header_footer, add_watermark, Anchor, HeaderFooterSpec, WatermarkSpec};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Metadata tài liệu: kích thước trang & outline (bookmarks).
|
//! Metadata tài liệu: kích thước trang & outline (bookmarks) + xoá metadata
|
||||||
|
//! nhận dạng (Phase 5).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -6,6 +7,40 @@ use pdfium_render::prelude::*;
|
|||||||
|
|
||||||
use crate::EngineError;
|
use crate::EngineError;
|
||||||
|
|
||||||
|
/// Xoá metadata nhận dạng khỏi file (Phase 5 — riêng tư): /Info trong trailer
|
||||||
|
/// (Author/Producer/CreationDate…) và stream XMP `/Metadata` trong catalog —
|
||||||
|
/// xoá cả THAM CHIẾU lẫn OBJECT đích để nội dung không còn nằm trong file.
|
||||||
|
/// Thao tác tầng PDF object qua lopdf; file mã hoá cần gỡ mật khẩu trước.
|
||||||
|
pub fn strip_metadata(input: &Path, output: &Path) -> Result<(), EngineError> {
|
||||||
|
let mut doc = lopdf::Document::load(input)
|
||||||
|
.map_err(|e| EngineError::Pdfium(format!("lopdf load: {e}")))?;
|
||||||
|
|
||||||
|
// /Info: gỡ tham chiếu trong trailer + xoá object đích.
|
||||||
|
if let Ok(id) = doc.trailer.get(b"Info").and_then(|o| o.as_reference()) {
|
||||||
|
doc.objects.remove(&id);
|
||||||
|
}
|
||||||
|
doc.trailer.remove(b"Info");
|
||||||
|
|
||||||
|
// /Metadata (XMP) trong catalog: gỡ tham chiếu + xoá stream đích.
|
||||||
|
if let Ok(root_id) = doc.trailer.get(b"Root").and_then(|o| o.as_reference()) {
|
||||||
|
let meta_id = doc
|
||||||
|
.get_object(root_id)
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| c.as_dict().ok())
|
||||||
|
.and_then(|d| d.get(b"Metadata").ok().and_then(|o| o.as_reference().ok()));
|
||||||
|
if let Ok(lopdf::Object::Dictionary(dict)) = doc.get_object_mut(root_id) {
|
||||||
|
dict.remove(b"Metadata");
|
||||||
|
}
|
||||||
|
if let Some(id) = meta_id {
|
||||||
|
doc.objects.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.save(output)
|
||||||
|
.map_err(|e| EngineError::Pdfium(format!("lopdf save: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Kích thước một trang theo điểm PDF (points, 1/72 inch).
|
/// Kích thước một trang theo điểm PDF (points, 1/72 inch).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct PageDim {
|
pub struct PageDim {
|
||||||
|
|||||||
@@ -94,28 +94,82 @@ pub fn repair(input: &Path, output: &Path) -> Result<(), EngineError> {
|
|||||||
run_qpdf(&[&i, &o])
|
run_qpdf(&[&i, &o])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mã hoá `input` (đang KHÔNG mã hoá) thành `output`, dùng AES-256, với user
|
/// Quyền hạn áp khi mã hoá (Phase 5) — map thẳng sang cờ AES-256 của qpdf.
|
||||||
/// password (mở file) và owner password (đổi quyền) chỉ định. Permissions để
|
/// Mặc định: cho phép tất cả (chỉ chặn bằng mật khẩu mở file).
|
||||||
/// mặc định (đầy đủ) cho V1 — đủ cho DoD "ghi không làm hỏng file mã hoá".
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Permissions {
|
||||||
|
/// In tài liệu (`--print=full|none`).
|
||||||
|
pub allow_print: bool,
|
||||||
|
/// Sửa nội dung (`--modify=all|none`).
|
||||||
|
pub allow_modify: bool,
|
||||||
|
/// Sao chép/trích text & ảnh (`--extract=y|n`).
|
||||||
|
pub allow_extract: bool,
|
||||||
|
/// Thêm/sửa chú thích + điền form (`--annotate=y|n`).
|
||||||
|
pub allow_annotate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Permissions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { allow_print: true, allow_modify: true, allow_extract: true, allow_annotate: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mã hoá `input` (đang KHÔNG mã hoá) thành `output`, AES-256, permissions
|
||||||
|
/// đầy đủ. Giữ cho tương thích — bản đầy đủ: [`encrypt_with_password_perms`].
|
||||||
pub fn encrypt_with_password(
|
pub fn encrypt_with_password(
|
||||||
input: &Path,
|
input: &Path,
|
||||||
output: &Path,
|
output: &Path,
|
||||||
user_password: &str,
|
user_password: &str,
|
||||||
owner_password: &str,
|
owner_password: &str,
|
||||||
|
) -> Result<(), EngineError> {
|
||||||
|
encrypt_with_password_perms(input, output, user_password, owner_password, Permissions::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mã hoá AES-256 với user password (mở file), owner password (đổi quyền) và
|
||||||
|
/// bộ quyền hạn chỉ định. Owner password rỗng → qpdf dùng user password làm
|
||||||
|
/// owner (tránh file "khoá quyền nhưng ai cũng mở được" thiếu chủ đích).
|
||||||
|
pub fn encrypt_with_password_perms(
|
||||||
|
input: &Path,
|
||||||
|
output: &Path,
|
||||||
|
user_password: &str,
|
||||||
|
owner_password: &str,
|
||||||
|
perms: Permissions,
|
||||||
) -> Result<(), EngineError> {
|
) -> Result<(), EngineError> {
|
||||||
let i = path_arg(input);
|
let i = path_arg(input);
|
||||||
let o = path_arg(output);
|
let o = path_arg(output);
|
||||||
|
let owner = if owner_password.is_empty() { user_password } else { owner_password };
|
||||||
|
let print = if perms.allow_print { "--print=full" } else { "--print=none" };
|
||||||
|
let modify = if perms.allow_modify { "--modify=all" } else { "--modify=none" };
|
||||||
|
let extract = if perms.allow_extract { "--extract=y" } else { "--extract=n" };
|
||||||
|
let annotate = if perms.allow_annotate { "--annotate=y" } else { "--annotate=n" };
|
||||||
run_qpdf(&[
|
run_qpdf(&[
|
||||||
"--encrypt",
|
"--encrypt",
|
||||||
user_password,
|
user_password,
|
||||||
owner_password,
|
owner,
|
||||||
"256",
|
"256",
|
||||||
|
print,
|
||||||
|
modify,
|
||||||
|
extract,
|
||||||
|
annotate,
|
||||||
"--",
|
"--",
|
||||||
&i,
|
&i,
|
||||||
&o,
|
&o,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GỠ mật khẩu/mã hoá: giải mã `input` (mở bằng `password`) và ghi bản KHÔNG
|
||||||
|
/// mã hoá ra `output`. Cần đúng password (user hoặc owner).
|
||||||
|
pub fn decrypt_remove_password(
|
||||||
|
input: &Path,
|
||||||
|
password: &str,
|
||||||
|
output: &Path,
|
||||||
|
) -> Result<(), EngineError> {
|
||||||
|
let i = path_arg(input);
|
||||||
|
let o = path_arg(output);
|
||||||
|
let pw = format!("--password={password}");
|
||||||
|
run_qpdf(&[&pw, "--decrypt", &i, &o])
|
||||||
|
}
|
||||||
|
|
||||||
/// Mở `input` bằng PDFium an toàn cho cả file hỏng và file mã hoá:
|
/// Mở `input` bằng PDFium an toàn cho cả file hỏng và file mã hoá:
|
||||||
/// 1. Nếu mở thẳng được (đúng password nếu có) → dùng luôn.
|
/// 1. Nếu mở thẳng được (đúng password nếu có) → dùng luôn.
|
||||||
/// 2. Nếu PDFium báo lỗi format (không phải do password) → thử `repair()` rồi mở lại.
|
/// 2. Nếu PDFium báo lỗi format (không phải do password) → thử `repair()` rồi mở lại.
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! Redaction THẬT (Phase 5): xoá nội dung khỏi file — không phải vẽ đè.
|
||||||
|
//!
|
||||||
|
//! Nguyên tắc an toàn (nghiêng về XOÁ THỪA, không bao giờ xoá thiếu):
|
||||||
|
//! - Text/path/shading/form GIAO vùng redact → xoá NGUYÊN object (v1 xoá cả
|
||||||
|
//! run text bị chạm — cắt theo ký tự là nâng cấp v2; xoá thừa an toàn hơn
|
||||||
|
//! sót chữ).
|
||||||
|
//! - Ảnh giao vùng → đọc pixel gốc, BÔI ĐEN đúng phần giao rồi thay ảnh
|
||||||
|
//! (nội dung gốc không còn trong file); không đọc được pixel → xoá cả ảnh.
|
||||||
|
//! - Sau khi xoá, vẽ khối đen phủ mỗi vùng (dấu hiệu thị giác chuẩn redaction).
|
||||||
|
//!
|
||||||
|
//! Kiểm chứng: caller (test/UI) extract_text + render lại để xác nhận nội
|
||||||
|
//! dung đã biến mất thật — xem `tests/redact_roundtrip.rs`.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use pdfium_render::prelude::*;
|
||||||
|
|
||||||
|
use crate::text::Rect;
|
||||||
|
use crate::EngineError;
|
||||||
|
|
||||||
|
fn intersection(a: &Rect, b: &Rect) -> Option<Rect> {
|
||||||
|
let r = Rect {
|
||||||
|
left: a.left.max(b.left),
|
||||||
|
bottom: a.bottom.max(b.bottom),
|
||||||
|
right: a.right.min(b.right),
|
||||||
|
top: a.top.min(b.top),
|
||||||
|
};
|
||||||
|
(r.left < r.right && r.bottom < r.top).then_some(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn obj_rect(obj: &PdfPageObject) -> Option<Rect> {
|
||||||
|
obj.bounds().ok().map(|q| Rect {
|
||||||
|
left: q.left().value,
|
||||||
|
bottom: q.bottom().value,
|
||||||
|
right: q.right().value,
|
||||||
|
top: q.top().value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Xoá thật nội dung trong các vùng `areas` (điểm PDF) trên trang `page_index`,
|
||||||
|
/// ghi ra `output`. Trả về số object đã xoá/bôi đen (để UI báo cáo).
|
||||||
|
pub fn redact_areas(
|
||||||
|
pdfium: &Pdfium,
|
||||||
|
input: &Path,
|
||||||
|
page_index: u16,
|
||||||
|
areas: &[Rect],
|
||||||
|
output: &Path,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<usize, EngineError> {
|
||||||
|
let err = |e: PdfiumError| EngineError::Pdfium(format!("redact: {e}"));
|
||||||
|
let document = pdfium
|
||||||
|
.load_pdf_from_file(input, password)
|
||||||
|
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
|
||||||
|
let touched;
|
||||||
|
{
|
||||||
|
let mut page = document
|
||||||
|
.pages()
|
||||||
|
.get(page_index)
|
||||||
|
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
|
||||||
|
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
|
||||||
|
|
||||||
|
// (1) Phân loại object bị chạm: ảnh → bôi đen pixel; còn lại → xoá.
|
||||||
|
let mut to_remove: Vec<usize> = Vec::new();
|
||||||
|
struct ImgFix {
|
||||||
|
idx: usize,
|
||||||
|
bounds: Rect,
|
||||||
|
cuts: Vec<Rect>,
|
||||||
|
}
|
||||||
|
let mut img_fixes: Vec<ImgFix> = Vec::new();
|
||||||
|
for (i, obj) in page.objects().iter().enumerate() {
|
||||||
|
let Some(b) = obj_rect(&obj) else { continue };
|
||||||
|
let cuts: Vec<Rect> = areas.iter().filter_map(|a| intersection(a, &b)).collect();
|
||||||
|
if cuts.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if obj.object_type() == PdfPageObjectType::Image {
|
||||||
|
img_fixes.push(ImgFix { idx: i, bounds: b, cuts });
|
||||||
|
} else {
|
||||||
|
to_remove.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) Ảnh: đọc pixel gốc, tô đen phần giao. Không đọc được → xoá cả ảnh.
|
||||||
|
struct NewImg {
|
||||||
|
bounds: Rect,
|
||||||
|
img: image::DynamicImage,
|
||||||
|
}
|
||||||
|
let mut new_imgs: Vec<NewImg> = Vec::new();
|
||||||
|
for fix in &img_fixes {
|
||||||
|
let obj = page.objects().get(fix.idx).map_err(err)?;
|
||||||
|
let raw = obj.as_image_object().and_then(|io| io.get_raw_image().ok());
|
||||||
|
match raw {
|
||||||
|
Some(img) => {
|
||||||
|
let mut rgba = img.to_rgba8();
|
||||||
|
let (w, h) = (rgba.width() as f32, rgba.height() as f32);
|
||||||
|
let bw = (fix.bounds.right - fix.bounds.left).max(0.01);
|
||||||
|
let bh = (fix.bounds.top - fix.bounds.bottom).max(0.01);
|
||||||
|
for cut in &fix.cuts {
|
||||||
|
// Trang → pixel: gốc pixel (0,0) là góc TRÊN-trái của ảnh.
|
||||||
|
let x0 = (((cut.left - fix.bounds.left) / bw) * w).floor().max(0.0) as u32;
|
||||||
|
let x1 = (((cut.right - fix.bounds.left) / bw) * w).ceil().min(w) as u32;
|
||||||
|
let y0 = (((fix.bounds.top - cut.top) / bh) * h).floor().max(0.0) as u32;
|
||||||
|
let y1 = (((fix.bounds.top - cut.bottom) / bh) * h).ceil().min(h) as u32;
|
||||||
|
for y in y0..y1 {
|
||||||
|
for x in x0..x1 {
|
||||||
|
rgba.put_pixel(x, y, image::Rgba([0, 0, 0, 255]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new_imgs.push(NewImg { bounds: fix.bounds, img: image::DynamicImage::ImageRgba8(rgba) });
|
||||||
|
to_remove.push(fix.idx);
|
||||||
|
}
|
||||||
|
None => to_remove.push(fix.idx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) Xoá theo index GIẢM DẦN (bẫy Drop của PDFium → mem::forget).
|
||||||
|
to_remove.sort_unstable();
|
||||||
|
to_remove.dedup();
|
||||||
|
touched = to_remove.len();
|
||||||
|
for idx in to_remove.into_iter().rev() {
|
||||||
|
let removed = page.objects_mut().remove_object_at_index(idx).map_err(err)?;
|
||||||
|
std::mem::forget(removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (4) Đặt lại ảnh đã bôi đen (giữ khung cũ).
|
||||||
|
for ni in &new_imgs {
|
||||||
|
let w = (ni.bounds.right - ni.bounds.left).max(1.0);
|
||||||
|
let h = (ni.bounds.top - ni.bounds.bottom).max(1.0);
|
||||||
|
page.objects_mut()
|
||||||
|
.create_image_object(
|
||||||
|
PdfPoints::new(ni.bounds.left),
|
||||||
|
PdfPoints::new(ni.bounds.bottom),
|
||||||
|
&ni.img,
|
||||||
|
Some(PdfPoints::new(w)),
|
||||||
|
Some(PdfPoints::new(h)),
|
||||||
|
)
|
||||||
|
.map_err(err)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (5) Khối đen phủ mỗi vùng — dấu hiệu thị giác chuẩn của redaction.
|
||||||
|
for a in areas {
|
||||||
|
page.objects_mut()
|
||||||
|
.create_path_object_rect(
|
||||||
|
PdfRect::new_from_values(a.bottom, a.left, a.top, a.right),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(PdfColor::new(0, 0, 0, 255)),
|
||||||
|
)
|
||||||
|
.map_err(err)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.regenerate_content().map_err(err)?;
|
||||||
|
}
|
||||||
|
document
|
||||||
|
.save_to_file(output)
|
||||||
|
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
|
||||||
|
Ok(touched)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! Test redaction THẬT (Phase 5): nội dung phải BIẾN MẤT khỏi file (extract
|
||||||
|
//! không còn, pixel ảnh gốc bị bôi đen thật) — không phải chỉ vẽ đè.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use ff_engine::{redact_areas, EditOp, Rect};
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
ff_engine::bind_pdfium().expect("nạp PDFium")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample() -> PathBuf {
|
||||||
|
workspace_root().join("corpus").join("sample-multipage.pdf")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmp(name: &str) -> PathBuf {
|
||||||
|
let p = std::env::temp_dir().join(name);
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rect của text object đầu tiên chứa `needle` trên trang 0.
|
||||||
|
fn find_text_rect(pdf: &pdfium_render::prelude::Pdfium, input: &std::path::Path, needle: &str) -> Rect {
|
||||||
|
ff_engine::list_objects(pdf, input, 0, None)
|
||||||
|
.expect("list")
|
||||||
|
.into_iter()
|
||||||
|
.find(|o| o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false))
|
||||||
|
.unwrap_or_else(|| panic!("không thấy text {needle:?}"))
|
||||||
|
.rect
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redact text: chuỗi mật phải biến mất khỏi extract, chuỗi công khai giữ
|
||||||
|
/// nguyên, và vùng redact hiển thị ĐEN khi render.
|
||||||
|
#[test]
|
||||||
|
fn redact_removes_text_content_for_real() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let fx = tmp("ff_redact_fx.pdf");
|
||||||
|
let out = tmp("ff_redact_out.pdf");
|
||||||
|
let mk = |x: f32, y: f32, s: &str| EditOp::AddText {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
text: s.into(),
|
||||||
|
font_size: 14.0,
|
||||||
|
color: [0, 0, 0, 255],
|
||||||
|
font_family: None,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
};
|
||||||
|
ff_engine::apply_edits(
|
||||||
|
&pdf,
|
||||||
|
&sample(),
|
||||||
|
0,
|
||||||
|
&[mk(60.0, 700.0, "PUBLICINFO vẫn còn"), mk(60.0, 600.0, "TOPSECRET42 tuyệt mật")],
|
||||||
|
&fx,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("fixture");
|
||||||
|
|
||||||
|
let mut area = find_text_rect(&pdf, &fx, "TOPSECRET42");
|
||||||
|
// Nới 2pt mỗi phía như người dùng quét chuột.
|
||||||
|
area = Rect { left: area.left - 2.0, bottom: area.bottom - 2.0, right: area.right + 2.0, top: area.top + 2.0 };
|
||||||
|
|
||||||
|
let touched = redact_areas(&pdf, &fx, 0, &[area], &out, None).expect("redact");
|
||||||
|
assert!(touched >= 1, "phải xoá ít nhất 1 object");
|
||||||
|
|
||||||
|
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||||||
|
assert!(!text.contains("TOPSECRET42"), "nội dung mật phải BIẾN MẤT: {text:?}");
|
||||||
|
assert!(text.contains("PUBLICINFO"), "nội dung ngoài vùng phải còn: {text:?}");
|
||||||
|
|
||||||
|
// Render: tâm vùng redact phải ĐEN.
|
||||||
|
let png = tmp("ff_redact_out.png");
|
||||||
|
ff_engine::render_page_png(&pdf, &out, 0, &png, 600, None).expect("render");
|
||||||
|
let img = image::open(&png).expect("mở png").to_rgba8();
|
||||||
|
let dims = ff_engine::page_dims(&pdf, &out, None).expect("dims");
|
||||||
|
let scale = 600.0 / dims[0].width_pt;
|
||||||
|
let cx = ((area.left + area.right) / 2.0 * scale) as u32;
|
||||||
|
let cy = ((dims[0].height_pt - (area.bottom + area.top) / 2.0) * scale) as u32;
|
||||||
|
let p = img.get_pixel(cx, cy);
|
||||||
|
assert!(p[0] < 40 && p[1] < 40 && p[2] < 40, "tâm vùng redact phải đen, được {p:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redact ảnh: pixel vùng giao bị BÔI ĐEN TRONG CHÍNH DỮ LIỆU ẢNH (đọc lại
|
||||||
|
/// raw image từ file kết quả), phần ngoài vùng giữ nguyên màu gốc.
|
||||||
|
#[test]
|
||||||
|
fn redact_blacks_out_image_pixels_for_real() {
|
||||||
|
use pdfium_render::prelude::*;
|
||||||
|
let pdf = pdfium();
|
||||||
|
let fx = tmp("ff_redact_img_fx.pdf");
|
||||||
|
let out = tmp("ff_redact_img_out.pdf");
|
||||||
|
|
||||||
|
// Ảnh đỏ 40×32 đặt tại (300,500) khung 100×80pt → bounds (300,500)-(400,580).
|
||||||
|
let png = tmp("ff_redact_red.png");
|
||||||
|
let mut img = image::RgbImage::new(40, 32);
|
||||||
|
for p in img.pixels_mut() {
|
||||||
|
*p = image::Rgb([220, 30, 30]);
|
||||||
|
}
|
||||||
|
img.save(&png).expect("save png");
|
||||||
|
ff_engine::apply_edits(
|
||||||
|
&pdf,
|
||||||
|
&sample(),
|
||||||
|
0,
|
||||||
|
&[EditOp::AddImage {
|
||||||
|
x: 300.0,
|
||||||
|
y: 500.0,
|
||||||
|
width_pt: 100.0,
|
||||||
|
height_pt: 80.0,
|
||||||
|
image_path: png.to_string_lossy().into_owned(),
|
||||||
|
}],
|
||||||
|
&fx,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("fixture ảnh");
|
||||||
|
|
||||||
|
// Redact NỬA TRÁI của ảnh.
|
||||||
|
let area = Rect { left: 300.0, bottom: 500.0, right: 350.0, top: 580.0 };
|
||||||
|
redact_areas(&pdf, &fx, 0, &[area], &out, None).expect("redact ảnh");
|
||||||
|
|
||||||
|
// Đọc lại raw image từ file kết quả — kiểm content thật, không phải hình vẽ đè.
|
||||||
|
let document = pdf.load_pdf_from_file(&out, None).expect("mở out");
|
||||||
|
let page = document.pages().get(0).expect("trang 0");
|
||||||
|
let mut checked = false;
|
||||||
|
for obj in page.objects().iter() {
|
||||||
|
let Some(io) = obj.as_image_object() else { continue };
|
||||||
|
let Ok(raw) = io.get_raw_image() else { continue };
|
||||||
|
let rgba = raw.to_rgba8();
|
||||||
|
let (w, h) = (rgba.width(), rgba.height());
|
||||||
|
if w < 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Nửa trái đen, nửa phải vẫn đỏ.
|
||||||
|
let left_px = rgba.get_pixel(w / 4, h / 2);
|
||||||
|
let right_px = rgba.get_pixel(3 * w / 4, h / 2);
|
||||||
|
assert!(
|
||||||
|
left_px[0] < 40 && left_px[1] < 40 && left_px[2] < 40,
|
||||||
|
"pixel nửa trái phải ĐEN trong dữ liệu ảnh: {left_px:?}"
|
||||||
|
);
|
||||||
|
assert!(right_px[0] > 150 && right_px[1] < 90, "nửa phải vẫn đỏ: {right_px:?}");
|
||||||
|
checked = true;
|
||||||
|
}
|
||||||
|
assert!(checked, "phải còn image object (đã thay bằng bản bôi đen) để kiểm pixel");
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//! Test bảo mật Phase 5: mã hoá AES-256 + permissions, gỡ mật khẩu, xoá
|
||||||
|
//! metadata nhận dạng. Cần binary `qpdf` (như qpdf_safety).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
ff_engine::bind_pdfium().expect("nạp PDFium")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hello() -> PathBuf {
|
||||||
|
workspace_root().join("corpus").join("hello.pdf")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmp(name: &str) -> PathBuf {
|
||||||
|
let p = std::env::temp_dir().join(name);
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mã hoá với permissions: mở KHÔNG mật khẩu phải fail, đúng mật khẩu OK, và
|
||||||
|
/// qpdf --show-encryption xác nhận cờ quyền in = none.
|
||||||
|
#[test]
|
||||||
|
fn encrypt_with_perms_round_trip_and_flags() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let out = tmp("ff_sec_enc.pdf");
|
||||||
|
let perms = ff_engine::Permissions { allow_print: false, ..Default::default() };
|
||||||
|
ff_engine::encrypt_with_password_perms(&hello(), &out, "u123", "o456", perms).expect("encrypt");
|
||||||
|
|
||||||
|
assert!(pdf.load_pdf_from_file(&out, None).is_err(), "không mật khẩu phải bị chặn");
|
||||||
|
assert!(pdf.load_pdf_from_file(&out, Some("u123")).is_ok(), "đúng user password phải mở được");
|
||||||
|
assert!(pdf.load_pdf_from_file(&out, Some("o456")).is_ok(), "owner password phải mở được");
|
||||||
|
|
||||||
|
let qpdf = ff_engine::find_qpdf().expect("qpdf");
|
||||||
|
let show = Command::new(qpdf)
|
||||||
|
.args(["--password=u123", "--show-encryption", &out.to_string_lossy()])
|
||||||
|
.output()
|
||||||
|
.expect("show-encryption");
|
||||||
|
let s = String::from_utf8_lossy(&show.stdout).to_lowercase();
|
||||||
|
assert!(s.contains("print"), "phải có dòng quyền in: {s}");
|
||||||
|
// qpdf in "print high resolution: not allowed" (và/hoặc low) khi --print=none.
|
||||||
|
assert!(s.contains("not allowed"), "quyền in phải bị chặn: {s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gỡ mật khẩu: sau decrypt_remove_password, file mở được KHÔNG cần mật khẩu
|
||||||
|
/// và nội dung còn nguyên.
|
||||||
|
#[test]
|
||||||
|
fn decrypt_removes_password() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let enc = tmp("ff_sec_enc2.pdf");
|
||||||
|
let dec = tmp("ff_sec_dec2.pdf");
|
||||||
|
ff_engine::encrypt_with_password(&hello(), &enc, "pw789", "pw789").expect("encrypt");
|
||||||
|
assert!(pdf.load_pdf_from_file(&enc, None).is_err(), "fixture phải đang khoá");
|
||||||
|
|
||||||
|
ff_engine::decrypt_remove_password(&enc, "pw789", &dec).expect("decrypt");
|
||||||
|
assert!(pdf.load_pdf_from_file(&dec, None).is_ok(), "sau gỡ phải mở tự do");
|
||||||
|
let text = ff_engine::extract_text(&pdf, &dec, 0, None).expect("extract");
|
||||||
|
assert!(text.contains("Hello"), "nội dung giữ nguyên: {text:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gỡ mật khẩu bằng password SAI phải lỗi (không âm thầm ghi file rỗng).
|
||||||
|
#[test]
|
||||||
|
fn decrypt_with_wrong_password_fails() {
|
||||||
|
let enc = tmp("ff_sec_enc3.pdf");
|
||||||
|
let dec = tmp("ff_sec_dec3.pdf");
|
||||||
|
ff_engine::encrypt_with_password(&hello(), &enc, "right", "right").expect("encrypt");
|
||||||
|
assert!(
|
||||||
|
ff_engine::decrypt_remove_password(&enc, "wrong", &dec).is_err(),
|
||||||
|
"password sai phải trả lỗi"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Xoá metadata: /Info (Producer/Author…) và XMP /Metadata phải biến mất KHỎI
|
||||||
|
/// FILE (soi bytes thô), file vẫn mở/đọc bình thường.
|
||||||
|
#[test]
|
||||||
|
fn strip_metadata_removes_info_and_xmp() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let fx = tmp("ff_sec_meta_fx.pdf");
|
||||||
|
let out = tmp("ff_sec_meta_out.pdf");
|
||||||
|
|
||||||
|
// Fixture: chuẩn hoá qua qpdf trước (trailer của hello.pdf viết tay, lopdf
|
||||||
|
// không parse thẳng được — đúng luồng thật: file luôn qua repair/save của
|
||||||
|
// engine trước khi strip), rồi chèn /Info với Producer nhận dạng được.
|
||||||
|
let norm = tmp("ff_sec_meta_norm.pdf");
|
||||||
|
ff_engine::repair(&hello(), &norm).expect("qpdf normalize");
|
||||||
|
let mut doc = lopdf::Document::load(&norm).expect("lopdf load");
|
||||||
|
let mut info = lopdf::Dictionary::new();
|
||||||
|
info.set("Producer", lopdf::Object::string_literal("FoFreeXitSecretProducer"));
|
||||||
|
info.set("Author", lopdf::Object::string_literal("NguoiDungBiMat"));
|
||||||
|
let info_id = doc.add_object(lopdf::Object::Dictionary(info));
|
||||||
|
doc.trailer.set("Info", lopdf::Object::Reference(info_id));
|
||||||
|
doc.save(&fx).expect("save fixture");
|
||||||
|
let raw_before = std::fs::read(&fx).expect("đọc fixture");
|
||||||
|
assert!(
|
||||||
|
raw_before.windows(23).any(|w| w == b"FoFreeXitSecretProducer"),
|
||||||
|
"fixture phải chứa Producer"
|
||||||
|
);
|
||||||
|
|
||||||
|
ff_engine::strip_metadata(&fx, &out).expect("strip");
|
||||||
|
|
||||||
|
let raw = std::fs::read(&out).expect("đọc out");
|
||||||
|
assert!(
|
||||||
|
!raw.windows(23).any(|w| w == b"FoFreeXitSecretProducer"),
|
||||||
|
"Producer phải biến mất khỏi bytes của file"
|
||||||
|
);
|
||||||
|
assert!(!raw.windows(14).any(|w| w == b"NguoiDungBiMat"), "Author phải biến mất");
|
||||||
|
let cleaned = lopdf::Document::load(&out).expect("lopdf load out");
|
||||||
|
assert!(cleaned.trailer.get(b"Info").is_err(), "trailer không còn /Info");
|
||||||
|
// Vẫn mở & đọc được bình thường.
|
||||||
|
assert!(pdf.load_pdf_from_file(&out, None).is_ok());
|
||||||
|
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||||||
|
assert!(text.contains("Hello"), "nội dung trang giữ nguyên: {text:?}");
|
||||||
|
}
|
||||||
+1
-1
@@ -43,7 +43,7 @@ Một ứng dụng desktop có thể **xem, chú thích, tổ chức trang, và
|
|||||||
- Thêm text box mới, đổi font/size/màu.
|
- Thêm text box mới, đổi font/size/màu.
|
||||||
**DoD:** sửa được text 1 dòng & 1 đoạn trên ≥80% file mẫu thường; thêm/xoá/di chuyển ảnh; lưu mở lại đúng; tiếng Việt hiển thị & sửa đúng dấu; có test corpus chuyên cho edit.
|
**DoD:** sửa được text 1 dòng & 1 đoạn trên ≥80% file mẫu thường; thêm/xoá/di chuyển ảnh; lưu mở lại đúng; tiếng Việt hiển thị & sửa đúng dấu; có test corpus chuyên cho edit.
|
||||||
|
|
||||||
## Phase 5 — Bảo mật & Chữ ký ⏳ KẾ TIẾP (đang ở đây)
|
## Phase 5 — Bảo mật & Chữ ký ✅ Iteration 1 HOÀN TẤT (mã hoá+quyền, gỡ mật khẩu, redaction thật, xoá metadata — xem [docs/15-phase5-summary.md](15-phase5-summary.md)) · Iteration 2: chữ ký số
|
||||||
**Mục tiêu:** Tính năng đáng tiền cho doanh nghiệp.
|
**Mục tiêu:** Tính năng đáng tiền cho doanh nghiệp.
|
||||||
- Mật khẩu mở/permission; mã hoá **AES-256**; xoá mật khẩu.
|
- Mật khẩu mở/permission; mã hoá **AES-256**; xoá mật khẩu.
|
||||||
- **Redaction thật** (xoá nội dung + ảnh + metadata, không chỉ vẽ đè) + kiểm chứng đã xoá.
|
- **Redaction thật** (xoá nội dung + ảnh + metadata, không chỉ vẽ đè) + kiểm chứng đã xoá.
|
||||||
|
|||||||
@@ -130,11 +130,12 @@ Foxit hơn" — chi phí khổng lồ, không giải quyết gap nào ở mục
|
|||||||
|
|
||||||
Đã xong (lõi, có test): Viewer ✅ · Annotate ✅ · Organize + lưu vững ✅ ·
|
Đã xong (lõi, có test): Viewer ✅ · Annotate ✅ · Organize + lưu vững ✅ ·
|
||||||
**Edit text/object giữ font ✅ (iteration 2) · Reflow đoạn "như Word" ✅
|
**Edit text/object giữ font ✅ (iteration 2) · Reflow đoạn "như Word" ✅
|
||||||
(iteration 3)**.
|
(iteration 3) · Bảo mật iteration 1 ✅ (mã hoá AES-256 + quyền, gỡ mật khẩu,
|
||||||
|
redaction thật kiểm chứng được, xoá metadata — docs/15)**.
|
||||||
|
|
||||||
Thứ tự đề xuất tiếp theo, bám giá trị người dùng:
|
Thứ tự đề xuất tiếp theo, bám giá trị người dùng:
|
||||||
1. **Phase 5 Bảo mật + "Lưu tối ưu"** (mục 3.3 gộp vào đây): mã hoá UI (nợ
|
1. **Phase 5 Iteration 2**: chữ ký số PAdES + xác thực; "Lưu tối ưu" (mục
|
||||||
Phase 3), redaction thật, subset font, dọn rác, chữ ký số.
|
3.3: subset font, dọn object rác); redact tỉa theo ký tự.
|
||||||
2. Phase 6 Form → Phase 7 OCR/Convert → Phase 8 phát hành (theo roadmap).
|
2. Phase 6 Form → Phase 7 OCR/Convert → Phase 8 phát hành (theo roadmap).
|
||||||
3. Song song, rải theo từng phase: mở rộng font matching (3.4), nấc 1 của
|
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ế, và v2 của reflow
|
hiệu năng phiên sửa (3.2) khi chạm file lớn thực tế, và v2 của reflow
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 15 — Tổng kết Phase 5 (Bảo mật) · Iteration 1
|
||||||
|
|
||||||
|
> Trạng thái: **mã hoá + permissions, gỡ mật khẩu, redaction THẬT, xoá
|
||||||
|
> metadata — hoàn tất, có test kiểm chứng nội dung thật sự biến mất.**
|
||||||
|
> Chữ ký số (PAdES) là Iteration 2 — kế hoạch ở mục 5.
|
||||||
|
|
||||||
|
## 1. Khảo sát Foxit (chuẩn nghiệm thu)
|
||||||
|
- **Protect › Password Protect**: user/owner password, AES-256, tick quyền
|
||||||
|
in/sửa/copy/chú thích. → ta làm dialog tương đương.
|
||||||
|
- **Protect › Mark for Redaction → Apply**: quét vùng → khối đen dashed →
|
||||||
|
Apply xoá nội dung THẬT (text không còn select/search được, ảnh bị đục),
|
||||||
|
vẽ khối đen. → ta làm đúng luồng 2 bước này.
|
||||||
|
- **Remove hidden information**: xoá metadata Author/Producer/XMP.
|
||||||
|
|
||||||
|
## 2. Đã làm
|
||||||
|
|
||||||
|
### Engine
|
||||||
|
- **`qpdf.rs`**: `Permissions {allow_print, allow_modify, allow_extract,
|
||||||
|
allow_annotate}` map sang cờ AES-256 của qpdf (`--print/--modify/--extract/
|
||||||
|
--annotate`); `encrypt_with_password_perms(...)` (API cũ giữ nguyên, uỷ
|
||||||
|
quyền với quyền đầy đủ); `decrypt_remove_password(input, password, output)`
|
||||||
|
(`qpdf --password=… --decrypt`) — password sai trả lỗi, không ghi file rác.
|
||||||
|
- **`redact.rs` (mới) — redaction THẬT, nghiêng về xoá thừa (an toàn)**:
|
||||||
|
- Text/path/shading/form GIAO vùng → xoá NGUYÊN object khỏi trang (v1 xoá
|
||||||
|
cả run bị chạm — sót chữ là lỗi bảo mật, xoá thừa thì không).
|
||||||
|
- Ảnh giao vùng → `get_raw_image` đọc pixel gốc, **bôi đen đúng phần giao
|
||||||
|
trong dữ liệu ảnh** rồi thay ảnh (nội dung gốc không còn trong file);
|
||||||
|
không đọc được pixel → xoá cả ảnh.
|
||||||
|
- Vẽ khối đen phủ mỗi vùng (dấu hiệu thị giác chuẩn redaction).
|
||||||
|
- **`meta.rs::strip_metadata`**: xoá `/Info` trong trailer (Author/Producer/
|
||||||
|
CreationDate…) + stream XMP `/Metadata` trong catalog — xoá cả THAM CHIẾU
|
||||||
|
lẫn OBJECT đích (kiểm bằng soi bytes thô của file kết quả). Qua lopdf;
|
||||||
|
trailer phi chuẩn được qpdf chuẩn hoá trước (fallback trong Tauri command).
|
||||||
|
|
||||||
|
### Tauri + UI (thanh "🔒 Bảo mật")
|
||||||
|
- Commands: `redact_apply` (nhiều trang, chain qua file tạm, trả số object đã
|
||||||
|
xử lý), `security_encrypt`, `security_decrypt`, `security_strip_metadata`.
|
||||||
|
- **Redact 2 bước như Foxit**: nút *⬛ Đánh dấu redact* (tool kéo-quét trên
|
||||||
|
trang, giữ tool để quét nhiều vùng; khối đen mờ viền đỏ dashed, bấm vào
|
||||||
|
khối để bỏ) → *Áp dụng redact (n)* → chọn nơi lưu → mở file kết quả.
|
||||||
|
- **Đặt mật khẩu**: dialog user/confirm/owner password + 4 checkbox quyền →
|
||||||
|
lưu bản mã hoá riêng (file đang mở giữ nguyên).
|
||||||
|
- **Gỡ mật khẩu**: nhập mật khẩu → chọn file khoá → lưu bản đã gỡ → mở luôn.
|
||||||
|
- **Xoá metadata**: 1 nút → lưu bản sạch → mở luôn. Không dùng prompt/alert.
|
||||||
|
|
||||||
|
## 3. Test (6 mới; tổng engine 58/58 ngoài qpdf-fixture)
|
||||||
|
- `redact_removes_text_content_for_real` — chuỗi mật biến mất khỏi
|
||||||
|
`extract_text`, chuỗi ngoài vùng còn nguyên, tâm vùng render ra ĐEN.
|
||||||
|
- `redact_blacks_out_image_pixels_for_real` — đọc lại RAW IMAGE từ file kết
|
||||||
|
quả: nửa bị redact đen trong chính dữ liệu ảnh, nửa kia vẫn màu gốc
|
||||||
|
(chứng minh không phải "vẽ đè").
|
||||||
|
- `encrypt_with_perms_round_trip_and_flags` — không mật khẩu bị chặn, đúng
|
||||||
|
user/owner mở được, `qpdf --show-encryption` xác nhận quyền in bị cấm.
|
||||||
|
- `decrypt_removes_password` + `decrypt_with_wrong_password_fails`.
|
||||||
|
- `strip_metadata_removes_info_and_xmp` — Producer/Author biến mất khỏi
|
||||||
|
BYTES của file, trailer sạch, file vẫn mở/đọc bình thường.
|
||||||
|
- `qpdf_safety` cũ: 5/7 xanh trên Linux CI; 2 test repair fixture
|
||||||
|
`corrupt-truncated.pdf` fail do **qpdf 11.9 Linux không dựng lại được
|
||||||
|
fixture này** (bản qpdf Windows của dự án xử lý được — khác biệt phiên bản
|
||||||
|
qpdf, không phải hồi quy code; cần xác nhận lại trên máy Windows).
|
||||||
|
|
||||||
|
## 4. Đối chiếu FINAL TARGET & RULE
|
||||||
|
- Redaction là "xoá thật + kiểm chứng được", không phải vẽ đè — test đọc lại
|
||||||
|
bytes/pixel/extract để chứng minh, đúng DoD roadmap.
|
||||||
|
- Không thoả hiệp dialog: mọi thao tác có UI chuyên nghiệp (modal + toolbar).
|
||||||
|
- Điểm CHƯA đạt 100% so với Foxit (ghi nhận, có kế hoạch):
|
||||||
|
- Redact theo VÙNG QUÉT, xoá cả run text bị chạm (Foxit tỉa theo ký tự).
|
||||||
|
Nâng cấp v2: cắt run tại ranh giới ký tự bằng char-box (đã có
|
||||||
|
`page_char_boxes` từ Phase 1) + tái tạo phần giữ lại như reflow.
|
||||||
|
- Chưa xoá "hidden information" dạng attachment/script/comment ẩn — gộp
|
||||||
|
vào Iteration 2.
|
||||||
|
|
||||||
|
## 5. Iteration 2 (kế tiếp của Phase 5) — Chữ ký số PAdES + Lưu tối ưu
|
||||||
|
1. **Ký số**: tạo chữ ký PAdES-B (CMS/PKCS#7 qua crate `cms`/`rsa`/`p256`),
|
||||||
|
ghi `/ByteRange` + placeholder `/Contents`, incremental update để không
|
||||||
|
phá chữ ký cũ; xác thực bằng Acrobat làm DoD. Đọc chứng chỉ PFX/PKCS#12.
|
||||||
|
2. **Xác thực chữ ký** khi mở (hiện trạng thái valid/invalid).
|
||||||
|
3. **Lưu tối ưu** (từ docs/14 mục 3.3): subset font nhúng theo glyph dùng
|
||||||
|
thật + `qpdf --object-streams=generate` dọn object rác sau edit.
|
||||||
|
4. Redact tỉa theo ký tự (mục 4) + quét "hidden info" còn lại.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 16 — Checklist test thủ công Phase 5 (Bảo mật)
|
||||||
|
|
||||||
|
Quan trọng nhất: xác nhận **redaction xoá thật** (S4–S6) và **file mã hoá mở
|
||||||
|
đúng ở Foxit/Adobe** (S8) — đây là tính năng an toàn, sai là lộ dữ liệu.
|
||||||
|
|
||||||
|
## Chạy
|
||||||
|
```powershell
|
||||||
|
cd c:\Project\FoFreeXit\app\src-tauri
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bảng test
|
||||||
|
| # | Thao tác | Kỳ vọng | KQ |
|
||||||
|
|---|----------|---------|----|
|
||||||
|
| S1 | Bấm **🔒 Bảo mật** | Hiện thanh Bảo mật (redact / mật khẩu / metadata) | |
|
||||||
|
| S2 | Bấm **⬛ Đánh dấu redact** → kéo quét 1 vùng chữ | Khối đen mờ viền đỏ dashed phủ vùng; đếm ở nút "Áp dụng redact (n)" tăng | |
|
||||||
|
| S3 | Quét thêm vùng thứ 2 (trang khác cũng được) → bấm vào 1 khối | Khối bị bấm biến mất (bỏ đánh dấu); đếm giảm | |
|
||||||
|
| S4 | **Áp dụng redact** → lưu file mới | File mới mở lên: vùng redact là khối ĐEN | |
|
||||||
|
| S5 | Trong file kết quả: Ctrl+F tìm đúng chữ đã redact; thử chọn & copy vùng đó | **KHÔNG tìm thấy, không copy được** — nội dung đã bị xoá thật | |
|
||||||
|
| S6 | Redact đè lên 1 phần ẢNH rồi áp dụng; mở file kết quả ở Foxit, dùng Edit Object xem ảnh | Phần ảnh bị redact ĐEN NGAY TRONG ẢNH (kéo ảnh ra vẫn đen), không phải hình chữ nhật đè | |
|
||||||
|
| S7 | **🔑 Đặt mật khẩu**: nhập mật khẩu + bỏ tick "In" → lưu | File mới đòi mật khẩu khi mở | |
|
||||||
|
| S8 | Mở file S7 ở **Foxit/Adobe**: nhập đúng mật khẩu; thử In | Mở được bằng đúng mật khẩu; lệnh In bị chặn/mờ | |
|
||||||
|
| S9 | Nhập SAI mật khẩu ở app | Báo lỗi, không mở | |
|
||||||
|
| S10 | **🔓 Gỡ mật khẩu**: nhập mật khẩu hiện tại → chọn file S7 → lưu | File kết quả mở KHÔNG cần mật khẩu, nội dung nguyên vẹn | |
|
||||||
|
| S11 | Gỡ mật khẩu với mật khẩu SAI | Báo lỗi rõ ràng, không tạo file hỏng | |
|
||||||
|
| S12 | **🧹 Xoá metadata** → lưu → mở file kết quả ở Foxit: File › Properties | Author/Producer/Created… trống; app vẫn mở đọc bình thường | |
|
||||||
|
|
||||||
|
## Mẫu phản hồi
|
||||||
|
```
|
||||||
|
[Mã] (vd S5) — 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 (Iteration 1): redact xoá NGUYÊN text run bị vùng quét
|
||||||
|
> chạm vào (an toàn — thừa còn hơn sót; tỉa theo ký tự ở iteration 2); chữ ký
|
||||||
|
> số PAdES là Iteration 2 — xem `docs/15-phase5-summary.md` mục 5.
|
||||||
Reference in New Issue
Block a user