Phase 5 Iteration 2: chữ ký số PKCS#7/PAdES + redact tỉa ký tự + lưu tối ưu
Engine — chữ ký số (sign.rs MỚI): - generate_self_signed_id: Digital ID tự ký RSA-2048 + X.509 (rcgen), ghi PEM - sign_pdf: đúng cơ chế Adobe — lopdf dựng sig dict (/SubFilter adbe.pkcs7.detached) + Widget field + AcroForm/SigFlags, ByteRange/Contents placeholder; serialize → vá ByteRange theo offset thật → SHA-256 trên 2 đoạn → CMS SignedData detached (crate cms, signed attrs messageDigest+contentType, RSA-SHA256) nhét DER vào Contents - verify_signatures: parse ByteRange/Contents, băm lại, kiểm chữ ký RSA trên signed attrs + messageDigest khớp + phủ toàn bộ file → phát hiện GIẢ MẠO - Bài học: cắt DER về đúng độ dài (bỏ padding hex '0'); bỏ qua /Contents của trang (tham chiếu), chỉ nhận /Contents <hex> của chữ ký Engine — redact tỉa theo KÝ TỰ (redact.rs): - Text chạm một phần: page_char_boxes xác định ký tự trong vùng, GIỮ các đoạn ngoài vùng, dựng lại đúng vị trí/baseline/màu (font nhúng gốc → cùng họ → mặc định). Guard: text xoay/không đọc được font → xoá nguyên (an toàn) - qpdf::optimize_save: --object-streams=generate + nén, dọn object rác Tauri + UI: - Commands: sig_create_id, sig_sign, sig_verify, security_optimize, pick_any_file, pick_save_pem - Thanh Bảo mật thêm: Tạo Digital ID, Ký số (dialog + tự kiểm tra sau khi ký), Kiểm tra chữ ký (modal bảng trạng thái + lý do), Lưu tối ưu Test (7 mới → 62/62 engine xanh ngoài 2 fixture qpdf Linux): - sign_then_verify_is_valid; tampering_after_signing_is_detected (phát hiện sửa sau khi ký); verify_reports_none_for_unsigned - redact tỉa ký tự: cụm mật biến mất, phần cùng dòng ngoài vùng vẫn còn - optimize_save không phình + giữ nội dung Deps: rsa, sha2, cms, x509-cert, rcgen, pkcs8, der, spki, const-oid. Docs: 15 mục 6 (iter 2 + giới hạn), 16 (S13–S19), README, roadmap (Phase 5 HOÀN TẤT), 14 (kế tiếp: Phase 6 Form). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
+192
-53
@@ -15,8 +15,19 @@ use std::path::Path;
|
||||
|
||||
use pdfium_render::prelude::*;
|
||||
|
||||
use crate::text::Rect;
|
||||
use crate::EngineError;
|
||||
use crate::text::{CharBox, Rect};
|
||||
use crate::{fontmatch, EngineError};
|
||||
|
||||
/// 1 đoạn chữ GIỮ LẠI sau khi tỉa các ký tự thuộc vùng redact.
|
||||
struct KeptSegment {
|
||||
text: String,
|
||||
left: f32,
|
||||
baseline: f32,
|
||||
}
|
||||
|
||||
fn rect_hits_box(r: &Rect, b: &CharBox) -> bool {
|
||||
r.left < b.right && b.left < r.right && r.bottom < b.top && b.bottom < r.top
|
||||
}
|
||||
|
||||
fn intersection(a: &Rect, b: &Rect) -> Option<Rect> {
|
||||
let r = Rect {
|
||||
@@ -48,82 +59,123 @@ pub fn redact_areas(
|
||||
password: Option<&str>,
|
||||
) -> Result<usize, EngineError> {
|
||||
let err = |e: PdfiumError| EngineError::Pdfium(format!("redact: {e}"));
|
||||
let document = pdfium
|
||||
|
||||
// Char boxes của trang (đọc TRƯỚC khi mở document ghi) — để tỉa text theo
|
||||
// KÝ TỰ: chỉ xoá ký tự nằm trong vùng redact, giữ phần còn lại đúng chỗ.
|
||||
let char_boxes = crate::text::page_char_boxes(pdfium, input, page_index, password)?;
|
||||
|
||||
let mut document = pdfium
|
||||
.load_pdf_from_file(input, password)
|
||||
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
|
||||
let touched;
|
||||
|
||||
struct NewImg {
|
||||
bounds: Rect,
|
||||
img: image::DynamicImage,
|
||||
}
|
||||
let mut to_remove: Vec<usize> = Vec::new();
|
||||
let mut new_imgs: Vec<NewImg> = Vec::new();
|
||||
let mut text_trims: Vec<TextTrimPlan> = Vec::new();
|
||||
|
||||
// ---- Pha ĐỌC (mượn trang, chỉ đọc): phân loại + chụp dữ liệu cần dựng lại.
|
||||
{
|
||||
let mut page = document
|
||||
let 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]));
|
||||
match obj.object_type() {
|
||||
PdfPageObjectType::Image => {
|
||||
// Đọc pixel gốc, tô đen phần giao. Không đọc được → xoá cả ảnh.
|
||||
match obj.as_image_object().and_then(|io| io.get_raw_image().ok()) {
|
||||
Some(img) => {
|
||||
let mut rgba = img.to_rgba8();
|
||||
let (w, h) = (rgba.width() as f32, rgba.height() as f32);
|
||||
let bw = (b.right - b.left).max(0.01);
|
||||
let bh = (b.top - b.bottom).max(0.01);
|
||||
for cut in &cuts {
|
||||
let x0 = (((cut.left - b.left) / bw) * w).floor().max(0.0) as u32;
|
||||
let x1 = (((cut.right - b.left) / bw) * w).ceil().min(w) as u32;
|
||||
let y0 = (((b.top - cut.top) / bh) * h).floor().max(0.0) as u32;
|
||||
let y1 = (((b.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: b, img: image::DynamicImage::ImageRgba8(rgba) });
|
||||
to_remove.push(i);
|
||||
}
|
||||
None => to_remove.push(i),
|
||||
}
|
||||
}
|
||||
PdfPageObjectType::Text => {
|
||||
// Tỉa theo KÝ TỰ; không chắc chắn (xoay/không đọc được font)
|
||||
// → xoá NGUYÊN object (an toàn: thà thừa còn hơn sót).
|
||||
to_remove.push(i);
|
||||
if let Some(trim) = plan_text_trim(&obj, &b, areas, &char_boxes) {
|
||||
if !trim.segments.is_empty() {
|
||||
text_trims.push(trim);
|
||||
}
|
||||
}
|
||||
new_imgs.push(NewImg { bounds: fix.bounds, img: image::DynamicImage::ImageRgba8(rgba) });
|
||||
to_remove.push(fix.idx);
|
||||
}
|
||||
None => to_remove.push(fix.idx),
|
||||
_ => to_remove.push(i),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Xoá theo index GIẢM DẦN (bẫy Drop của PDFium → mem::forget).
|
||||
// ---- Nạp font cho các đoạn chữ giữ lại (cần fonts_mut → ngoài mượn trang).
|
||||
let mut trim_tokens: Vec<PdfFontToken> = Vec::with_capacity(text_trims.len());
|
||||
for trim in &text_trims {
|
||||
let token = document
|
||||
.fonts_mut()
|
||||
.load_true_type_from_bytes(&trim.font_bytes, true)
|
||||
.map_err(|e| EngineError::Pdfium(format!("nạp font tỉa redact: {e}")))?;
|
||||
trim_tokens.push(token);
|
||||
}
|
||||
|
||||
let touched = to_remove.len();
|
||||
// ---- Pha GHI (mượn trang mut).
|
||||
{
|
||||
let mut page = document
|
||||
.pages()
|
||||
.get(page_index)
|
||||
.map_err(|e| EngineError::Pdfium(format!("trang {page_index}: {e}")))?;
|
||||
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
|
||||
|
||||
// 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ũ).
|
||||
// Tạo lại các đoạn chữ GIỮ LẠI (tỉa theo ký tự).
|
||||
for (trim, token) in text_trims.iter().zip(trim_tokens.iter()) {
|
||||
let (a, b, c, d) = trim.linear;
|
||||
for seg in &trim.segments {
|
||||
let mut o = page
|
||||
.objects_mut()
|
||||
.create_text_object(
|
||||
PdfPoints::ZERO,
|
||||
PdfPoints::ZERO,
|
||||
seg.text.clone(),
|
||||
*token,
|
||||
PdfPoints::new(trim.tf),
|
||||
)
|
||||
.map_err(err)?;
|
||||
o.apply_matrix(PdfMatrix::new(a, b, c, d, seg.left, seg.baseline)).map_err(err)?;
|
||||
o.set_fill_color(trim.color).map_err(err)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Đặ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);
|
||||
@@ -138,7 +190,7 @@ pub fn redact_areas(
|
||||
.map_err(err)?;
|
||||
}
|
||||
|
||||
// (5) Khối đen phủ mỗi vùng — dấu hiệu thị giác chuẩn của redaction.
|
||||
// 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(
|
||||
@@ -157,3 +209,90 @@ pub fn redact_areas(
|
||||
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
|
||||
Ok(touched)
|
||||
}
|
||||
|
||||
/// Lập kế hoạch tỉa 1 text object theo ký tự. Trả None nếu không chắc chắn
|
||||
/// (xoay/nghiêng, không đọc được font, không map được char box) → caller xoá cả.
|
||||
fn plan_text_trim(
|
||||
obj: &PdfPageObject,
|
||||
bounds: &Rect,
|
||||
areas: &[Rect],
|
||||
char_boxes: &[CharBox],
|
||||
) -> Option<TextTrimPlan> {
|
||||
let t = obj.as_text_object()?;
|
||||
let m = obj.matrix().ok()?;
|
||||
// Guard: có xoay/nghiêng (b,c đáng kể) → không tỉa (đặt lại theo trục ngang
|
||||
// sẽ sai). Xoá nguyên object cho an toàn.
|
||||
if m.b().abs() > 0.01 || m.c().abs() > 0.01 {
|
||||
return None;
|
||||
}
|
||||
let font_bytes = pick_font_bytes(t)?;
|
||||
let tf = t.unscaled_font_size().value.max(1.0);
|
||||
let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255));
|
||||
|
||||
// Char box thuộc object này ≈ box có tâm nằm trong bounds (nới nhẹ).
|
||||
let pad = 1.0;
|
||||
let mut chars: Vec<&CharBox> = char_boxes
|
||||
.iter()
|
||||
.filter(|cb| {
|
||||
let cx = (cb.left + cb.right) / 2.0;
|
||||
let cy = (cb.bottom + cb.top) / 2.0;
|
||||
cx >= bounds.left - pad
|
||||
&& cx <= bounds.right + pad
|
||||
&& cy >= bounds.bottom - pad
|
||||
&& cy <= bounds.top + pad
|
||||
})
|
||||
.collect();
|
||||
if chars.is_empty() {
|
||||
return None;
|
||||
}
|
||||
chars.sort_by(|a, b| a.left.partial_cmp(&b.left).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Gom các ký tự KHÔNG bị vùng redact chạm thành đoạn liên tiếp.
|
||||
let baseline = m.f();
|
||||
let mut segments: Vec<KeptSegment> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut cur_left = 0.0f32;
|
||||
for cb in &chars {
|
||||
let cut = areas.iter().any(|a| rect_hits_box(a, cb));
|
||||
if cut {
|
||||
if !cur.is_empty() {
|
||||
segments.push(KeptSegment { text: std::mem::take(&mut cur), left: cur_left, baseline });
|
||||
}
|
||||
} else {
|
||||
if cur.is_empty() {
|
||||
cur_left = cb.left;
|
||||
}
|
||||
cur.push_str(&cb.ch);
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
segments.push(KeptSegment { text: cur, left: cur_left, baseline });
|
||||
}
|
||||
|
||||
Some(TextTrimPlan { segments, tf, linear: (m.a(), m.b(), m.c(), m.d()), color, font_bytes })
|
||||
}
|
||||
|
||||
/// Chọn bytes font để dựng lại đoạn giữ: ưu tiên font NHÚNG gốc, sau đó font
|
||||
/// hệ thống cùng họ, cuối cùng font mặc định.
|
||||
fn pick_font_bytes(t: &PdfPageTextObject) -> Option<Vec<u8>> {
|
||||
if t.font().is_embedded().unwrap_or(false) {
|
||||
if let Ok(bytes) = t.font().data() {
|
||||
if ttf_parser::Face::parse(&bytes, 0).is_ok() {
|
||||
return Some(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = t.font().name();
|
||||
let (family, bold, italic) = fontmatch::clean_font_name(&raw);
|
||||
fontmatch::find_family_font_bytes(&family, bold, italic)
|
||||
.or_else(|| crate::annot::find_font_bytes(bold, italic))
|
||||
}
|
||||
|
||||
/// Kết quả `plan_text_trim` (đặt ngoài để dùng trong chữ ký hàm trên).
|
||||
struct TextTrimPlan {
|
||||
segments: Vec<KeptSegment>,
|
||||
tf: f32,
|
||||
linear: (f32, f32, f32, f32),
|
||||
color: PdfColor,
|
||||
font_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user