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,325 @@
|
||||
//! Chuyển đổi (Phase 7): PDF→ảnh PNG, PDF→TXT, PDF→DOCX (tự viết, mức "giữ
|
||||
//! text & bố cục cơ bản": mỗi dòng thị giác = 1 đoạn, cỡ chữ xấp xỉ), và cầu
|
||||
//! nối LibreOffice headless cho Office↔PDF chất lượng cao khi máy có cài.
|
||||
//!
|
||||
//! DOCX = zip chứa XML; ta tự ghi zip (method STORE, CRC-32 tự tính) để không
|
||||
//! thêm dependency — Word/LibreOffice đọc bình thường.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use pdfium_render::prelude::*;
|
||||
|
||||
use crate::EngineError;
|
||||
|
||||
/// PDF → mỗi trang 1 file PNG `<stem>-p<N>.png` trong `out_dir`, theo DPI.
|
||||
pub fn export_images(
|
||||
pdfium: &Pdfium,
|
||||
input: &Path,
|
||||
out_dir: &Path,
|
||||
dpi: f32,
|
||||
password: Option<&str>,
|
||||
) -> Result<Vec<PathBuf>, EngineError> {
|
||||
std::fs::create_dir_all(out_dir)?;
|
||||
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "page".into());
|
||||
let dims = crate::meta::page_dims(pdfium, input, password)?;
|
||||
let mut out = Vec::new();
|
||||
for d in &dims {
|
||||
let width_px = (d.width_pt / 72.0 * dpi).round().max(16.0) as u32;
|
||||
let path = out_dir.join(format!("{stem}-p{}.png", d.index + 1));
|
||||
crate::render::render_page_png(pdfium, input, d.index, &path, width_px, password)?;
|
||||
out.push(path);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// PDF → file text thuần (mỗi trang cách nhau 1 dòng trống).
|
||||
pub fn export_text(
|
||||
pdfium: &Pdfium,
|
||||
input: &Path,
|
||||
output: &Path,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), EngineError> {
|
||||
let dims = crate::meta::page_dims(pdfium, input, password)?;
|
||||
let mut all = String::new();
|
||||
for d in &dims {
|
||||
let t = crate::text::extract_text(pdfium, input, d.index, password)?;
|
||||
all.push_str(&t);
|
||||
all.push_str("\n\n");
|
||||
}
|
||||
std::fs::write(output, all)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 1 dòng văn bản đã dựng từ char boxes: nội dung + cỡ chữ xấp xỉ (pt).
|
||||
struct DocLine {
|
||||
text: String,
|
||||
size_pt: f32,
|
||||
}
|
||||
|
||||
/// Gom char boxes của 1 trang thành các dòng thị giác (cụm theo baseline,
|
||||
/// trái→phải, chèn space khi hở ≥ 0.28 em) — cùng heuristic với UI edit.
|
||||
fn page_lines(
|
||||
pdfium: &Pdfium,
|
||||
input: &Path,
|
||||
page_index: u16,
|
||||
password: Option<&str>,
|
||||
) -> Result<Vec<DocLine>, EngineError> {
|
||||
let boxes = crate::text::page_char_boxes(pdfium, input, page_index, password)?;
|
||||
// Cụm dòng theo bottom (dung sai nửa chiều cao ký tự).
|
||||
struct Line {
|
||||
bottom: f32,
|
||||
h: f32,
|
||||
chars: Vec<(f32, f32, String)>, // (left, right, ch)
|
||||
}
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for b in &boxes {
|
||||
if b.ch.trim().is_empty() && b.ch != " " {
|
||||
continue;
|
||||
}
|
||||
let h = (b.top - b.bottom).max(1.0);
|
||||
let found = lines.iter_mut().find(|l| (l.bottom - b.bottom).abs() <= l.h.max(h) * 0.5);
|
||||
match found {
|
||||
Some(l) => {
|
||||
l.chars.push((b.left, b.right, b.ch.clone()));
|
||||
l.h = l.h.max(h);
|
||||
}
|
||||
None => lines.push(Line { bottom: b.bottom, h, chars: vec![(b.left, b.right, b.ch.clone())] }),
|
||||
}
|
||||
}
|
||||
// Trên → dưới, trong dòng trái → phải.
|
||||
lines.sort_by(|a, b| b.bottom.partial_cmp(&a.bottom).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mut out = Vec::new();
|
||||
for mut l in lines {
|
||||
l.chars.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mut text = String::new();
|
||||
let mut prev_right: Option<f32> = None;
|
||||
for (left, right, ch) in &l.chars {
|
||||
if let Some(pr) = prev_right {
|
||||
if left - pr > l.h * 0.28 && !text.ends_with(' ') && ch != " " {
|
||||
text.push(' ');
|
||||
}
|
||||
}
|
||||
text.push_str(ch);
|
||||
prev_right = Some(*right);
|
||||
}
|
||||
let text = text.trim().to_string();
|
||||
if !text.is_empty() {
|
||||
out.push(DocLine { text, size_pt: l.h });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// PDF → DOCX "đủ dùng": mỗi dòng thị giác = 1 đoạn Word với cỡ chữ xấp xỉ;
|
||||
/// giữa các trang chèn ngắt trang. Giữ text + thứ tự đọc + cỡ tương đối —
|
||||
/// không tái tạo cột/bảng/ảnh (ghi rõ trong docs).
|
||||
pub fn export_docx(
|
||||
pdfium: &Pdfium,
|
||||
input: &Path,
|
||||
output: &Path,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), EngineError> {
|
||||
let dims = crate::meta::page_dims(pdfium, input, password)?;
|
||||
let mut body = String::new();
|
||||
for (i, d) in dims.iter().enumerate() {
|
||||
if i > 0 {
|
||||
body.push_str(r#"<w:p><w:r><w:br w:type="page"/></w:r></w:p>"#);
|
||||
}
|
||||
for line in page_lines(pdfium, input, d.index, password)? {
|
||||
// w:sz theo half-point.
|
||||
let half_points = (line.size_pt * 2.0).round().clamp(8.0, 144.0) as u32;
|
||||
body.push_str(&format!(
|
||||
r#"<w:p><w:r><w:rPr><w:sz w:val="{hp}"/><w:szCs w:val="{hp}"/></w:rPr><w:t xml:space="preserve">{}</w:t></w:r></w:p>"#,
|
||||
xml_escape(&line.text),
|
||||
hp = half_points,
|
||||
));
|
||||
}
|
||||
}
|
||||
let document_xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}<w:sectPr/></w:body></w:document>"#
|
||||
);
|
||||
|
||||
const CONTENT_TYPES: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
const RELS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
|
||||
let entries: Vec<(&str, &[u8])> = vec![
|
||||
("[Content_Types].xml", CONTENT_TYPES.as_bytes()),
|
||||
("_rels/.rels", RELS.as_bytes()),
|
||||
("word/document.xml", document_xml.as_bytes()),
|
||||
];
|
||||
let zip = build_zip_stored(&entries);
|
||||
std::fs::write(output, zip)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
/// Zip tối giản, method STORE (0), đủ chuẩn để Word/LibreOffice/unzip đọc.
|
||||
fn build_zip_stored(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut central: Vec<u8> = Vec::new();
|
||||
let mut offsets: Vec<u32> = Vec::new();
|
||||
|
||||
for (name, data) in entries {
|
||||
offsets.push(out.len() as u32);
|
||||
let crc = crc32(data);
|
||||
let n = name.as_bytes();
|
||||
// Local file header.
|
||||
out.extend_from_slice(&0x04034b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // flags
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // method STORE
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // time
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // date
|
||||
out.extend_from_slice(&crc.to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(n.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // extra len
|
||||
out.extend_from_slice(n);
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
for (i, (name, data)) in entries.iter().enumerate() {
|
||||
let crc = crc32(data);
|
||||
let n = name.as_bytes();
|
||||
central.extend_from_slice(&0x02014b50u32.to_le_bytes());
|
||||
central.extend_from_slice(&20u16.to_le_bytes()); // version made by
|
||||
central.extend_from_slice(&20u16.to_le_bytes()); // version needed
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&crc.to_le_bytes());
|
||||
central.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
central.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
central.extend_from_slice(&(n.len() as u16).to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u32.to_le_bytes());
|
||||
central.extend_from_slice(&offsets[i].to_le_bytes());
|
||||
central.extend_from_slice(n);
|
||||
}
|
||||
let central_offset = out.len() as u32;
|
||||
out.extend_from_slice(¢ral);
|
||||
// End of central directory.
|
||||
out.extend_from_slice(&0x06054b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(¢ral_offset.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// CRC-32 (IEEE 802.3) — bảng dựng 1 lần.
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut table = [0u32; 256];
|
||||
for (i, slot) in table.iter_mut().enumerate() {
|
||||
let mut c = i as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB88320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
*slot = c;
|
||||
}
|
||||
let mut crc = 0xFFFFFFFFu32;
|
||||
for &b in data {
|
||||
crc = table[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8);
|
||||
}
|
||||
crc ^ 0xFFFFFFFF
|
||||
}
|
||||
|
||||
// ---- LibreOffice headless (tùy chọn — chất lượng cao khi máy có cài) ----
|
||||
|
||||
/// Tìm LibreOffice: env `FOFREEXIT_SOFFICE_PATH` → PATH (`soffice`).
|
||||
pub fn find_soffice() -> Result<PathBuf, EngineError> {
|
||||
let exe = if cfg!(windows) { "soffice.exe" } else { "soffice" };
|
||||
if let Ok(p) = std::env::var("FOFREEXIT_SOFFICE_PATH") {
|
||||
let p = PathBuf::from(p);
|
||||
let candidate = if p.is_dir() { p.join(exe) } else { p };
|
||||
if candidate.is_file() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
if Command::new(exe).arg("--version").output().is_ok() {
|
||||
return Ok(PathBuf::from(exe));
|
||||
}
|
||||
Err(EngineError::Pdfium(
|
||||
"không tìm thấy LibreOffice (soffice). Cài LibreOffice hoặc đặt FOFREEXIT_SOFFICE_PATH".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn run_soffice(args: &[&str]) -> Result<(), EngineError> {
|
||||
let soffice = find_soffice()?;
|
||||
// Profile riêng để không đụng LibreOffice đang mở của người dùng.
|
||||
let profile = std::env::temp_dir().join("ff_soffice_profile");
|
||||
let profile_arg = format!(
|
||||
"-env:UserInstallation=file://{}",
|
||||
profile.to_string_lossy().replace('\\', "/")
|
||||
);
|
||||
let output = Command::new(&soffice)
|
||||
.arg(&profile_arg)
|
||||
.args(["--headless", "--norestore"])
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| EngineError::Pdfium(format!("chạy soffice: {e}")))?;
|
||||
if !output.status.success() {
|
||||
return Err(EngineError::Pdfium(format!(
|
||||
"soffice lỗi (exit {:?}): {}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// File kết quả mà soffice --convert-to tạo ra trong out_dir.
|
||||
fn converted_path(input: &Path, out_dir: &Path, ext: &str) -> PathBuf {
|
||||
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
out_dir.join(format!("{stem}.{ext}"))
|
||||
}
|
||||
|
||||
/// Office (docx/xlsx/pptx/odt...) → PDF. Trả đường dẫn PDF tạo ra.
|
||||
pub fn office_to_pdf(input: &Path, out_dir: &Path) -> Result<PathBuf, EngineError> {
|
||||
std::fs::create_dir_all(out_dir)?;
|
||||
run_soffice(&[
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
&out_dir.to_string_lossy(),
|
||||
&input.to_string_lossy(),
|
||||
])?;
|
||||
let out = converted_path(input, out_dir, "pdf");
|
||||
if !out.is_file() {
|
||||
return Err(EngineError::Pdfium("soffice không tạo được PDF".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// PDF → DOCX qua LibreOffice (import filter PDF của Draw/Writer) — chất lượng
|
||||
/// layout tốt hơn bản tự viết; dùng khi máy có LibreOffice.
|
||||
pub fn pdf_to_docx_via_soffice(input: &Path, out_dir: &Path) -> Result<PathBuf, EngineError> {
|
||||
std::fs::create_dir_all(out_dir)?;
|
||||
run_soffice(&[
|
||||
"--infilter=writer_pdf_import",
|
||||
"--convert-to",
|
||||
"docx:MS Word 2007 XML",
|
||||
"--outdir",
|
||||
&out_dir.to_string_lossy(),
|
||||
&input.to_string_lossy(),
|
||||
])?;
|
||||
let out = converted_path(input, out_dir, "docx");
|
||||
if !out.is_file() {
|
||||
return Err(EngineError::Pdfium("soffice không tạo được DOCX".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -5,10 +5,12 @@
|
||||
//! ocr... sẽ thêm dần ở các phase sau (xem docs/03-roadmap.md, 04-architecture.md).
|
||||
|
||||
pub mod annot;
|
||||
pub mod convert;
|
||||
pub mod edit;
|
||||
pub(crate) mod fontmatch;
|
||||
pub mod form;
|
||||
pub mod meta;
|
||||
pub mod ocr;
|
||||
pub mod organize;
|
||||
pub mod qpdf;
|
||||
pub mod redact;
|
||||
@@ -30,10 +32,14 @@ pub use qpdf::{
|
||||
decrypt_remove_password, encrypt_with_password, encrypt_with_password_perms, ensure_openable,
|
||||
find_qpdf, optimize_save, repair, Permissions,
|
||||
};
|
||||
pub use convert::{
|
||||
export_docx, export_images, export_text, find_soffice, office_to_pdf, pdf_to_docx_via_soffice,
|
||||
};
|
||||
pub use form::{
|
||||
create_form_fields, export_csv, export_fdf, fill_form_fields, flatten_form, import_fdf,
|
||||
list_form_fields, parse_fdf, FieldKind, FieldValue, FormField, NewField,
|
||||
};
|
||||
pub use ocr::{find_tesseract, ocr_add_text_layer, ocr_page_words, OcrWord};
|
||||
pub use redact::redact_areas;
|
||||
pub use sign::{generate_self_signed_id, sign_pdf, verify_signatures, SignatureCheck};
|
||||
pub use render::{bind_pdfium, page_count, render_page_png, PageImage};
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! OCR (Phase 7): nhận dạng chữ trong PDF scan bằng Tesseract (sidecar CLI,
|
||||
//! như qpdf) rồi thêm **LỚP TEXT ẨN** (render mode Invisible) khớp toạ độ lên
|
||||
//! CHÍNH trang gốc — file thành searchable/copy được mà không đổi hình ảnh.
|
||||
//!
|
||||
//! Luồng: render trang 300 DPI → `tesseract ... tsv` (bảng từ + bbox pixel +
|
||||
//! confidence) → quy đổi pixel→điểm PDF → tạo text object vô hình đúng khung
|
||||
//! từng từ. Ngôn ngữ mặc định `vie+eng`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use pdfium_render::prelude::*;
|
||||
|
||||
use crate::annot::find_font_bytes;
|
||||
use crate::text::Rect;
|
||||
use crate::EngineError;
|
||||
|
||||
/// DPI render cho OCR — 300 là chuẩn khuyến nghị của Tesseract.
|
||||
const OCR_DPI: f32 = 300.0;
|
||||
/// Ngưỡng confidence (0-100) dưới mức này thì bỏ từ (nhiễu).
|
||||
const MIN_CONFIDENCE: f32 = 30.0;
|
||||
|
||||
/// Tìm binary `tesseract`: env `FOFREEXIT_TESSERACT_PATH` (file hoặc thư mục)
|
||||
/// → PATH hệ thống.
|
||||
pub fn find_tesseract() -> Result<PathBuf, EngineError> {
|
||||
let exe = if cfg!(windows) { "tesseract.exe" } else { "tesseract" };
|
||||
if let Ok(p) = std::env::var("FOFREEXIT_TESSERACT_PATH") {
|
||||
let p = PathBuf::from(p);
|
||||
let candidate = if p.is_dir() { p.join(exe) } else { p };
|
||||
if candidate.is_file() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
if Command::new(exe).arg("--version").output().is_ok() {
|
||||
return Ok(PathBuf::from(exe));
|
||||
}
|
||||
Err(EngineError::Pdfium(
|
||||
"không tìm thấy tesseract. Cài Tesseract OCR (kèm gói ngôn ngữ vie) và/hoặc đặt FOFREEXIT_TESSERACT_PATH".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 1 từ OCR được: text + khung theo điểm PDF + confidence 0-100.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OcrWord {
|
||||
pub text: String,
|
||||
pub rect: Rect,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
/// OCR 1 trang → danh sách từ (toạ độ điểm PDF, gốc dưới-trái).
|
||||
pub fn ocr_page_words(
|
||||
pdfium: &Pdfium,
|
||||
input: &Path,
|
||||
page_index: u16,
|
||||
lang: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<Vec<OcrWord>, EngineError> {
|
||||
let tess = find_tesseract()?;
|
||||
let dims = crate::meta::page_dims(pdfium, input, password)?;
|
||||
let dim = dims
|
||||
.iter()
|
||||
.find(|d| d.index == page_index)
|
||||
.ok_or_else(|| EngineError::Pdfium(format!("không có trang {page_index}")))?;
|
||||
let width_px = (dim.width_pt / 72.0 * OCR_DPI).round().max(64.0) as u32;
|
||||
|
||||
let stamp = format!("{}_{}", std::process::id(), page_index);
|
||||
let png = std::env::temp_dir().join(format!("ff_ocr_{stamp}.png"));
|
||||
let out_base = std::env::temp_dir().join(format!("ff_ocr_{stamp}"));
|
||||
let rendered = crate::render::render_page_png(pdfium, input, page_index, &png, width_px, password)?;
|
||||
|
||||
// tesseract <ảnh> <out_base> -l <lang> tsv → out_base.tsv
|
||||
let output = Command::new(&tess)
|
||||
.arg(&png)
|
||||
.arg(&out_base)
|
||||
.args(["-l", lang, "--psm", "3", "tsv"])
|
||||
.output()
|
||||
.map_err(|e| EngineError::Pdfium(format!("chạy tesseract: {e}")))?;
|
||||
let _ = std::fs::remove_file(&png);
|
||||
if !output.status.success() {
|
||||
return Err(EngineError::Pdfium(format!(
|
||||
"tesseract lỗi (exit {:?}): {}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)));
|
||||
}
|
||||
let tsv_path = out_base.with_extension("tsv");
|
||||
let tsv = std::fs::read_to_string(&tsv_path)?;
|
||||
let _ = std::fs::remove_file(&tsv_path);
|
||||
|
||||
// px → pt theo bề rộng render thật (render có thể làm tròn).
|
||||
let scale = dim.width_pt / rendered.width.max(1) as f32;
|
||||
let page_h = dim.height_pt;
|
||||
|
||||
let mut words = Vec::new();
|
||||
for line in tsv.lines().skip(1) {
|
||||
// level page block par line word left top width height conf text
|
||||
let cols: Vec<&str> = line.split('\t').collect();
|
||||
if cols.len() < 12 || cols[0] != "5" {
|
||||
continue;
|
||||
}
|
||||
let conf: f32 = cols[10].parse().unwrap_or(-1.0);
|
||||
let text = cols[11].trim();
|
||||
if conf < MIN_CONFIDENCE || text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (l, t, w, h): (f32, f32, f32, f32) = match (
|
||||
cols[6].parse(),
|
||||
cols[7].parse(),
|
||||
cols[8].parse(),
|
||||
cols[9].parse(),
|
||||
) {
|
||||
(Ok(l), Ok(t), Ok(w), Ok(h)) => (l, t, w, h),
|
||||
_ => continue,
|
||||
};
|
||||
words.push(OcrWord {
|
||||
text: text.to_string(),
|
||||
rect: Rect {
|
||||
left: l * scale,
|
||||
top: page_h - t * scale,
|
||||
right: (l + w) * scale,
|
||||
bottom: page_h - (t + h) * scale,
|
||||
},
|
||||
confidence: conf,
|
||||
});
|
||||
}
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
/// OCR các trang `pages` (rỗng = mọi trang) và thêm lớp text ẨN khớp toạ độ
|
||||
/// lên trang gốc, ghi ra `output`. Trả tổng số từ đã nhận dạng.
|
||||
pub fn ocr_add_text_layer(
|
||||
pdfium: &Pdfium,
|
||||
input: &Path,
|
||||
pages: &[u16],
|
||||
lang: &str,
|
||||
output: &Path,
|
||||
password: Option<&str>,
|
||||
) -> Result<usize, EngineError> {
|
||||
let err = |e: PdfiumError| EngineError::Pdfium(format!("ocr layer: {e}"));
|
||||
|
||||
// (1) OCR trước (mỗi trang render riêng) — chưa mở document ghi.
|
||||
let dims = crate::meta::page_dims(pdfium, input, password)?;
|
||||
let targets: Vec<u16> = if pages.is_empty() {
|
||||
dims.iter().map(|d| d.index).collect()
|
||||
} else {
|
||||
pages.to_vec()
|
||||
};
|
||||
let mut per_page: Vec<(u16, Vec<OcrWord>)> = Vec::new();
|
||||
for &p in &targets {
|
||||
let words = ocr_page_words(pdfium, input, p, lang, password)?;
|
||||
if !words.is_empty() {
|
||||
per_page.push((p, words));
|
||||
}
|
||||
}
|
||||
|
||||
// (2) Mở document, nạp font Unicode (đủ tiếng Việt) 1 lần.
|
||||
let mut document = pdfium
|
||||
.load_pdf_from_file(input, password)
|
||||
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
|
||||
let bytes = find_font_bytes(false, false)
|
||||
.ok_or_else(|| EngineError::Pdfium("không tìm được font hệ thống cho lớp OCR".into()))?;
|
||||
let token = document
|
||||
.fonts_mut()
|
||||
.load_true_type_from_bytes(&bytes, true)
|
||||
.map_err(|e| EngineError::Pdfium(format!("nạp font OCR: {e}")))?;
|
||||
|
||||
let mut total = 0usize;
|
||||
for (page_index, words) in &per_page {
|
||||
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);
|
||||
for w in words {
|
||||
let size = (w.rect.top - w.rect.bottom).clamp(4.0, 96.0);
|
||||
let mut obj = page
|
||||
.objects_mut()
|
||||
.create_text_object(
|
||||
PdfPoints::new(w.rect.left),
|
||||
PdfPoints::new(w.rect.bottom),
|
||||
w.text.clone(),
|
||||
token,
|
||||
PdfPoints::new(size),
|
||||
)
|
||||
.map_err(err)?;
|
||||
if let Some(t) = obj.as_text_object_mut() {
|
||||
t.set_render_mode(PdfPageTextRenderMode::Invisible).map_err(err)?;
|
||||
}
|
||||
total += 1;
|
||||
}
|
||||
page.regenerate_content().map_err(err)?;
|
||||
}
|
||||
|
||||
document
|
||||
.save_to_file(output)
|
||||
.map_err(|e| EngineError::Pdfium(format!("lưu file OCR: {e}")))?;
|
||||
Ok(total)
|
||||
}
|
||||
@@ -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