Phase 7: OCR & Convert — Tesseract lớp text ẩn (Việt+Anh), PDF→PNG/TXT/DOCX, Office↔PDF
Engine: - ocr.rs MỚI: find_tesseract (sidecar như qpdf, env FOFREEXIT_TESSERACT_PATH); ocr_page_words (render 300 DPI → tesseract tsv → lọc confidence ≥30 → quy đổi pixel→điểm PDF); ocr_add_text_layer — thêm text object render-mode INVISIBLE đúng khung từng từ lên CHÍNH trang gốc (searchable PDF chuẩn, ảnh không đổi), font Unicode nhúng 1 lần, mặc định vie+eng - convert.rs MỚI: export_images (PNG mỗi trang theo DPI), export_text, export_docx tự viết (gom char boxes thành dòng thị giác → đoạn Word với cỡ chữ xấp xỉ + ngắt trang; DOCX = zip STORE tự ghi kèm CRC-32, không thêm dependency); find_soffice + office_to_pdf + pdf_to_docx_via_soffice (LibreOffice headless, profile riêng -env:UserInstallation) Tauri + UI (thanh 🔁 Chuyển đổi): - Commands: ocr_run, convert_images, convert_txt, convert_docx (tự chọn LibreOffice nếu có → fallback bộ cơ bản, báo engine đã dùng), office_convert, convert_tools_status (UI disable nút + báo thiếu công cụ), pick_office_file, pick_save_as - Nút: OCR (select Việt+Anh/Việt/Anh), Xuất PNG 150 DPI, Xuất TXT, Xuất Word, Office→PDF (mở luôn kết quả) Test (6 mới → engine 73/73 xanh ngoài 2 fixture qpdf Linux): - OCR tiếng Anh trên PDF-scan giả lập THẬT (xoá text chỉ còn ảnh render): extract + search thấy từ, toạ độ lớp ẩn khớp vùng chữ - OCR tiếng Việt đúng dấu ("Việt Nam đất nước") - export PNG 3 trang / TXT / DOCX (unzip hệ thống giải được, có ngắt trang) - LibreOffice round-trip 2 chiều (tự bỏ qua nếu máy không có soffice) Docs: 19-phase7-summary (+ giới hạn v-sau), 20-phase7-user-tests (C1–C10 kèm hướng dẫn cài Tesseract/LibreOffice trên Windows), README, roadmap (Phase 7 HOÀN TẤT lõi), 14 (kế tiếp: Phase 8 phát hành). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
//! Test Phase 7 (OCR & Convert). OCR cần binary `tesseract` + gói `vie`/`eng`
|
||||
//! (như qpdf); LibreOffice test riêng, tự bỏ qua nếu máy không có soffice.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ff_engine::EditOp;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Dựng PDF-SCAN giả lập: render 1 trang có chữ to rõ thành PNG rồi nhét PNG
|
||||
/// đó vào 1 trang mới → PDF chỉ có ẢNH, extract_text = rỗng (như scan thật).
|
||||
fn make_scanned_fixture(pdf: &pdfium_render::prelude::Pdfium, text: &str, out: &std::path::Path) {
|
||||
let with_text = tmp("ff_ocr_srctext.pdf");
|
||||
ff_engine::apply_edits(
|
||||
pdf,
|
||||
&sample(),
|
||||
0,
|
||||
&[EditOp::AddText {
|
||||
x: 60.0,
|
||||
y: 520.0,
|
||||
text: text.into(),
|
||||
font_size: 32.0,
|
||||
color: [0, 0, 0, 255],
|
||||
font_family: None,
|
||||
bold: false,
|
||||
italic: false,
|
||||
}],
|
||||
&with_text,
|
||||
None,
|
||||
)
|
||||
.expect("dựng trang chữ");
|
||||
let png = tmp("ff_ocr_scan.png");
|
||||
ff_engine::render_page_png(pdf, &with_text, 0, &png, 1700, None).expect("render scan");
|
||||
|
||||
// Trang mới chỉ chứa ảnh (dùng trang 2 của sample làm nền trắng? — đơn giản:
|
||||
// đè ảnh phủ toàn trang 0 của sample; nội dung text gốc bị ảnh che nhưng
|
||||
// extract vẫn thấy → thay bằng cách XOÁ text gốc trước rồi đặt ảnh).
|
||||
let dims = ff_engine::page_dims(pdf, &sample(), None).expect("dims");
|
||||
let (w, h) = (dims[0].width_pt, dims[0].height_pt);
|
||||
// Xoá mọi object trang 0 rồi thêm ảnh full trang.
|
||||
let objs = ff_engine::list_objects(pdf, &sample(), 0, None).expect("list");
|
||||
let mut ops: Vec<EditOp> = objs.iter().map(|o| EditOp::Delete { index: o.index }).collect();
|
||||
ops.push(EditOp::AddImage {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width_pt: w,
|
||||
height_pt: h,
|
||||
image_path: png.to_string_lossy().into_owned(),
|
||||
});
|
||||
ff_engine::apply_edits(pdf, &sample(), 0, &ops, out, None).expect("dựng pdf scan");
|
||||
|
||||
// Xác nhận đúng chất scan: không extract được chữ.
|
||||
let t = ff_engine::extract_text(pdf, out, 0, None).expect("extract fixture");
|
||||
assert!(
|
||||
!t.to_lowercase().contains("hello") && !t.contains("OCRTARGET"),
|
||||
"fixture scan không được còn text: {t:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// OCR tiếng Anh: PDF scan → lớp text ẩn → tìm/copy được chữ, hình ảnh giữ nguyên.
|
||||
#[test]
|
||||
fn ocr_makes_scanned_pdf_searchable_english() {
|
||||
let pdf = pdfium();
|
||||
let scan = tmp("ff_ocr_scan_en.pdf");
|
||||
let out = tmp("ff_ocr_out_en.pdf");
|
||||
make_scanned_fixture(&pdf, "OCRTARGET WORKS FINE", &scan);
|
||||
|
||||
let n = ff_engine::ocr_add_text_layer(&pdf, &scan, &[], "eng", &out, None).expect("ocr");
|
||||
assert!(n >= 3, "phải nhận dạng được ≥3 từ, được {n}");
|
||||
|
||||
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract out");
|
||||
let norm = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(norm.contains("OCRTARGET"), "từ OCR phải tìm được: {norm:?}");
|
||||
// Vị trí đúng: từ nằm quanh y≈520-552 (cỡ 32pt tại y=520).
|
||||
let hits = ff_engine::search(&pdf, &out, "OCRTARGET", false, None).expect("search");
|
||||
assert!(!hits.is_empty(), "search phải thấy từ OCR");
|
||||
let r = hits[0].rect.as_ref().expect("hit phải có rect");
|
||||
assert!(r.bottom > 480.0 && r.top < 590.0, "toạ độ lớp ẩn phải khớp vùng chữ: {r:?}");
|
||||
}
|
||||
|
||||
/// OCR tiếng Việt: nhận dạng đúng dấu.
|
||||
#[test]
|
||||
fn ocr_vietnamese_diacritics() {
|
||||
let pdf = pdfium();
|
||||
let scan = tmp("ff_ocr_scan_vi.pdf");
|
||||
let out = tmp("ff_ocr_out_vi.pdf");
|
||||
make_scanned_fixture(&pdf, "Việt Nam đất nước", &scan);
|
||||
|
||||
ff_engine::ocr_add_text_layer(&pdf, &scan, &[], "vie", &out, None).expect("ocr vie");
|
||||
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||||
let norm = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(norm.contains("Việt Nam"), "tiếng Việt đúng dấu: {norm:?}");
|
||||
}
|
||||
|
||||
/// PDF → PNG mỗi trang.
|
||||
#[test]
|
||||
fn export_images_per_page() {
|
||||
let pdf = pdfium();
|
||||
let dir = std::env::temp_dir().join("ff_conv_imgs");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let files = ff_engine::export_images(&pdf, &sample(), &dir, 150.0, None).expect("export png");
|
||||
assert_eq!(files.len(), 3, "3 trang → 3 PNG");
|
||||
for f in &files {
|
||||
assert!(f.is_file(), "thiếu {f:?}");
|
||||
let img = image::open(f).expect("mở png");
|
||||
assert!(img.width() > 500, "150dpi phải ra ảnh đủ lớn");
|
||||
}
|
||||
}
|
||||
|
||||
/// PDF → TXT chứa nội dung các trang.
|
||||
#[test]
|
||||
fn export_text_all_pages() {
|
||||
let pdf = pdfium();
|
||||
let out = tmp("ff_conv_out.txt");
|
||||
ff_engine::export_text(&pdf, &sample(), &out, None).expect("export txt");
|
||||
let t = std::fs::read_to_string(&out).expect("đọc txt");
|
||||
assert!(t.contains("Page one"), "trang 1: {t:?}");
|
||||
assert!(t.contains("Page two") || t.contains("two"), "trang 2 phải có mặt");
|
||||
}
|
||||
|
||||
/// PDF → DOCX (tự viết): file zip hợp lệ, document.xml chứa text + ngắt trang.
|
||||
#[test]
|
||||
fn export_docx_basic_layout() {
|
||||
let pdf = pdfium();
|
||||
let out = tmp("ff_conv_out.docx");
|
||||
ff_engine::export_docx(&pdf, &sample(), &out, None).expect("export docx");
|
||||
let bytes = std::fs::read(&out).expect("đọc docx");
|
||||
assert_eq!(&bytes[..4], b"PK\x03\x04", "docx phải là zip");
|
||||
// Giải nén bằng unzip hệ thống để chắc là zip CHUẨN.
|
||||
let dir = std::env::temp_dir().join("ff_docx_extract");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let ok = std::process::Command::new("unzip")
|
||||
.args(["-o", "-q", &out.to_string_lossy(), "-d", &dir.to_string_lossy()])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
assert!(ok, "unzip phải giải được docx");
|
||||
let doc = std::fs::read_to_string(dir.join("word/document.xml")).expect("document.xml");
|
||||
assert!(doc.contains("Page one"), "text trang 1 trong docx");
|
||||
assert!(doc.contains(r#"<w:br w:type="page"/>"#), "có ngắt trang giữa các trang");
|
||||
}
|
||||
|
||||
/// Office→PDF và PDF→DOCX qua LibreOffice — tự bỏ qua nếu máy không có soffice.
|
||||
#[test]
|
||||
fn libreoffice_round_trip_when_available() {
|
||||
if ff_engine::find_soffice().is_err() {
|
||||
eprintln!("BỎ QUA: máy không có LibreOffice (soffice)");
|
||||
return;
|
||||
}
|
||||
let pdf = pdfium();
|
||||
let dir = std::env::temp_dir().join("ff_soffice_out");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// PDF → DOCX (LibreOffice).
|
||||
let docx = ff_engine::pdf_to_docx_via_soffice(&sample(), &dir).expect("pdf→docx");
|
||||
assert!(docx.is_file());
|
||||
|
||||
// DOCX (bản tự viết từ sample) → PDF: kiểm chiều Office→PDF.
|
||||
let our_docx = tmp("ff_office_src.docx");
|
||||
ff_engine::export_docx(&pdf, &sample(), &our_docx, None).expect("docx nguồn");
|
||||
let out_pdf = ff_engine::office_to_pdf(&our_docx, &dir).expect("office→pdf");
|
||||
assert!(out_pdf.is_file());
|
||||
// PDF kết quả mở được và còn nội dung.
|
||||
assert!(pdf.load_pdf_from_file(&out_pdf, None).is_ok(), "PDF từ Office phải mở được");
|
||||
let text = ff_engine::extract_text(&pdf, &out_pdf, 0, None).expect("extract");
|
||||
assert!(text.contains("Page one"), "nội dung giữ qua Office→PDF: {text:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user