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:
2026-07-16 01:33:57 +00:00
co-authored by Claude Fable 5
parent ccdb839fca
commit b09bd7d750
12 changed files with 1075 additions and 15 deletions
+7 -1
View File
@@ -54,7 +54,13 @@
- [x] UI thanh "📝 Form": Điền form (modal liệt kê field theo loại), Thêm field, Flatten, Xuất FDF/CSV, Nhập FDF
- [ ] Follow-up: radio-group/list/pushbutton khi tạo, XFDF, vẽ appearance riêng, điền overlay trực tiếp trên trang — xem [docs/17-phase6-summary.md](docs/17-phase6-summary.md)
- **Checklist test cho bạn**: [docs/18-phase6-user-tests.md](docs/18-phase6-user-tests.md)
- [ ] **Phase 7 — OCR & Convert** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md)
- [x] **Phase 7 — OCR & Convert** ✅ lõi hoàn tất (73/73 test engine xanh)
- [x] Engine `ocr.rs`: OCR Tesseract (sidecar, **Việt + Anh**) → lớp text ẨN khớp toạ độ trên trang gốc (searchable/copy được, ảnh không đổi) — test dựng PDF-scan giả lập thật
- [x] Engine `convert.rs`: PDF→PNG (theo DPI), PDF→TXT, PDF→DOCX tự viết (dòng thị giác + cỡ chữ, zip tự ghi); Office↔PDF qua LibreOffice headless (tự phát hiện, fallback rõ ràng)
- [x] UI thanh "🔁 Chuyển đổi": OCR (chọn ngôn ngữ), Xuất PNG/TXT/Word (tự chọn engine), Office→PDF; báo thiếu công cụ ngoài
- [ ] Follow-up: tiền xử lý ảnh scan (deskew), OCR theo vùng, PDF→Excel — xem [docs/19-phase7-summary.md](docs/19-phase7-summary.md)
- **Checklist test cho bạn**: [docs/20-phase7-user-tests.md](docs/20-phase7-user-tests.md)
- [ ] **Phase 8 — Hoàn thiện & Phát hành** (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).
+112
View File
@@ -1160,6 +1160,110 @@ fn form_import_fdf(input: String, fdf: String, output: String) -> Result<usize,
r
}
// ---------- Phase 7: OCR & Chuyển đổi ----------
/// OCR toàn bộ tài liệu (Tesseract) → thêm lớp text ẩn, trả số từ nhận dạng.
#[tauri::command]
fn ocr_run(input: String, lang: String, output: String) -> Result<usize, String> {
let pdfium = pdfium()?;
ff_engine::ocr_add_text_layer(
&pdfium,
std::path::Path::new(&input),
&[],
&lang,
std::path::Path::new(&output),
None,
)
.map_err(|e| e.to_string())
}
/// PDF → PNG mỗi trang theo DPI, trả danh sách file.
#[tauri::command]
fn convert_images(input: String, out_dir: String, dpi: f32) -> Result<Vec<String>, String> {
let pdfium = pdfium()?;
ff_engine::export_images(
&pdfium,
std::path::Path::new(&input),
std::path::Path::new(&out_dir),
dpi.clamp(36.0, 600.0),
None,
)
.map(|v| v.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
.map_err(|e| e.to_string())
}
/// PDF → TXT.
#[tauri::command]
fn convert_txt(input: String, output: String) -> Result<(), String> {
let pdfium = pdfium()?;
ff_engine::export_text(&pdfium, std::path::Path::new(&input), std::path::Path::new(&output), None)
.map_err(|e| e.to_string())
}
/// PDF → DOCX. Ưu tiên LibreOffice (layout tốt hơn) nếu máy có; không có thì
/// dùng bộ chuyển tự viết (text + bố cục cơ bản). Trả tên engine đã dùng.
#[tauri::command]
fn convert_docx(input: String, output: String) -> Result<String, String> {
let inp = std::path::Path::new(&input);
let out = std::path::PathBuf::from(&output);
if ff_engine::find_soffice().is_ok() {
let dir = out.parent().map(|p| p.to_path_buf()).unwrap_or_else(std::env::temp_dir);
match ff_engine::pdf_to_docx_via_soffice(inp, &dir) {
Ok(produced) => {
if produced != out {
std::fs::rename(&produced, &out)
.or_else(|_| std::fs::copy(&produced, &out).map(|_| ()))
.map_err(|e| e.to_string())?;
let _ = std::fs::remove_file(&produced);
}
return Ok("libreoffice".into());
}
Err(_) => { /* rơi xuống bộ tự viết */ }
}
}
let pdfium = pdfium()?;
ff_engine::export_docx(&pdfium, inp, &out, None).map_err(|e| e.to_string())?;
Ok("basic".into())
}
/// Office (docx/xlsx/pptx/odt...) → PDF qua LibreOffice. Trả đường dẫn PDF.
#[tauri::command]
fn office_convert(input: String, out_dir: String) -> Result<String, String> {
ff_engine::office_to_pdf(std::path::Path::new(&input), std::path::Path::new(&out_dir))
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| e.to_string())
}
/// Kiểm tra công cụ ngoài (cho UI hiện trạng thái).
#[tauri::command]
fn convert_tools_status() -> serde_json::Value {
serde_json::json!({
"tesseract": ff_engine::find_tesseract().is_ok(),
"soffice": ff_engine::find_soffice().is_ok(),
})
}
/// Hộp thoại chọn file Office để chuyển sang PDF.
#[tauri::command]
fn pick_office_file(app: tauri::AppHandle) -> Option<String> {
app.dialog()
.file()
.add_filter("Tài liệu Office", &["docx", "doc", "xlsx", "xls", "pptx", "ppt", "odt", "ods", "odp", "rtf"])
.blocking_pick_file()
.map(|fp| fp.to_string())
}
/// Hộp thoại lưu file theo đuôi tuỳ ý.
#[tauri::command]
fn pick_save_as(app: tauri::AppHandle, ext: String, name: String) -> Option<String> {
app.dialog()
.file()
.add_filter(ext.to_uppercase(), &[ext.as_str()])
.set_file_name(&name)
.blocking_save_file()
.map(|fp| fp.to_string())
}
/// Hộp thoại lưu file FDF.
#[tauri::command]
fn pick_save_data(app: tauri::AppHandle) -> Option<String> {
@@ -1266,6 +1370,14 @@ fn main() {
form_create,
form_export,
form_import_fdf,
ocr_run,
convert_images,
convert_txt,
convert_docx,
office_convert,
convert_tools_status,
pick_office_file,
pick_save_as,
pick_any_file,
pick_save_pem,
pick_save_data,
+18
View File
@@ -38,6 +38,7 @@
<button id="editModeBtn" title="Sửa nội dung trang">✏️ Sửa nội dung</button>
<button id="formModeBtn" title="Form: điền, tạo field, flatten, FDF/CSV">📝 Form</button>
<button id="secModeBtn" title="Bảo mật: redact, mật khẩu, metadata">🔒 Bảo mật</button>
<button id="convModeBtn" title="OCR & Chuyển đổi: OCR, PNG, TXT, Word, Office→PDF">🔁 Chuyển đổi</button>
<div class="spacer"></div>
<span id="status" class="status"></span>
</header>
@@ -117,6 +118,23 @@
<span id="formHint" class="status"></span>
</div>
<div id="convBar" class="editbar hidden">
<span class="annolabel">Chuyển đổi:</span>
<label class="ed-prop">OCR ngôn ngữ <select id="cvLang">
<option value="vie+eng">Việt + Anh</option>
<option value="vie">Tiếng Việt</option>
<option value="eng">English</option>
</select></label>
<button id="cvOcr" title="Nhận dạng chữ (Tesseract) → thêm lớp text ẩn, file tìm kiếm/copy được">🔍 OCR tài liệu</button>
<span class="sep"></span>
<button id="cvPng" title="Xuất mỗi trang thành 1 ảnh PNG">🖼 Xuất PNG</button>
<button id="cvTxt" title="Xuất toàn bộ text ra file .txt">📄 Xuất TXT</button>
<button id="cvDocx" title="Xuất Word (.docx) — dùng LibreOffice nếu máy có, không thì bộ chuyển cơ bản">📘 Xuất Word</button>
<button id="cvOffice" title="Chuyển file Office (docx/xlsx/pptx...) sang PDF — cần LibreOffice">📥 Office → PDF</button>
<span class="spacer"></span>
<span id="convHint" class="status"></span>
</div>
<div id="secBar" class="editbar hidden">
<span class="annolabel">Bảo mật:</span>
<button id="secRedactTool" class="atool" data-tool="redact" title="Kéo chuột trên trang để đánh dấu vùng cần bôi đen — nội dung bị XOÁ THẬT khi áp dụng">⬛ Đánh dấu redact</button>
+101
View File
@@ -43,6 +43,7 @@ const state = {
redactMarks: [], // [{page, rect{left,bottom,right,top}}] chờ áp dụng
formMode: false, // thanh Form (Phase 6) đang mở
formFields: [], // field đọc từ tài liệu hiện tại
convMode: false, // thanh Chuyển đổi (Phase 7) đang mở
};
const UNDO_LIMIT = 50;
let annotIdSeq = 1;
@@ -2678,6 +2679,98 @@ async function saveEdits() {
}
}
// ---------- Phase 7: OCR & Chuyển đổi ----------
async function toggleConvMode() {
state.convMode = !state.convMode;
$("convBar").classList.toggle("hidden", !state.convMode);
$("convModeBtn").classList.toggle("active", state.convMode);
if (state.convMode) {
try {
const st = await invoke("convert_tools_status");
const miss = [];
if (!st.tesseract) miss.push("Tesseract (OCR)");
if (!st.soffice) miss.push("LibreOffice (Office↔PDF chất lượng cao)");
$("convHint").textContent = miss.length
? `Chưa cài: ${miss.join(", ")} — các nút liên quan sẽ báo lỗi kèm hướng dẫn`
: "";
$("cvOcr").disabled = !st.tesseract;
$("cvOffice").disabled = !st.soffice;
} catch (_) { /* trạng thái chỉ để gợi ý */ }
} else {
$("convHint").textContent = "";
}
}
async function runOcrAction() {
const lang = $("cvLang").value || "vie+eng";
const out = await invoke("pick_save_pdf");
if (!out) return;
$("convHint").textContent = "Đang OCR… (tài liệu dài có thể mất vài phút)";
try {
const n = await invoke("ocr_run", { input: state.path, lang, output: out });
$("status").textContent = `OCR xong: ${n} từ, đã thêm lớp text ẩn → ${shortName(out)}`;
$("convHint").textContent = "";
loadDocument(out);
} catch (e) {
$("convHint").textContent = "Lỗi OCR: " + e;
}
}
async function exportPngAction() {
const dir = await invoke("pick_dir");
if (!dir) return;
try {
const files = await invoke("convert_images", { input: state.path, outDir: dir, dpi: 150 });
$("status").textContent = `Đã xuất ${files.length} ảnh PNG (150 DPI) vào ${dir}`;
} catch (e) {
$("convHint").textContent = "Lỗi xuất PNG: " + e;
}
}
async function exportTxtAction() {
const base = shortName(state.path).replace(/\.pdf$/i, "");
const out = await invoke("pick_save_as", { ext: "txt", name: base + ".txt" });
if (!out) return;
try {
await invoke("convert_txt", { input: state.path, output: out });
$("status").textContent = `Đã xuất text → ${shortName(out)}`;
} catch (e) {
$("convHint").textContent = "Lỗi xuất TXT: " + e;
}
}
async function exportDocxAction() {
const base = shortName(state.path).replace(/\.pdf$/i, "");
const out = await invoke("pick_save_as", { ext: "docx", name: base + ".docx" });
if (!out) return;
$("convHint").textContent = "Đang chuyển sang Word…";
try {
const engine = await invoke("convert_docx", { input: state.path, output: out });
const note = engine === "libreoffice" ? "LibreOffice (giữ layout tốt)" : "bộ chuyển cơ bản (text + bố cục)";
$("status").textContent = `Đã xuất Word (${note}) → ${shortName(out)}`;
$("convHint").textContent = "";
} catch (e) {
$("convHint").textContent = "Lỗi xuất Word: " + e;
}
}
async function officeToPdfAction() {
const src = await invoke("pick_office_file");
if (!src) return;
const dir = await invoke("pick_dir");
if (!dir) return;
$("convHint").textContent = "Đang chuyển Office → PDF…";
try {
const out = await invoke("office_convert", { input: src, outDir: dir });
$("status").textContent = `Đã chuyển → ${shortName(out)}`;
$("convHint").textContent = "";
loadDocument(out);
} catch (e) {
$("convHint").textContent = "Lỗi Office→PDF (máy cần LibreOffice): " + e;
}
}
// ---------- Phase 6: Form (AcroForm) ----------
function toggleFormMode() {
@@ -3225,6 +3318,14 @@ $("modalOverlay").addEventListener("click", (e) => {
if (e.target.id === "modalOverlay") closeModal();
});
// OCR & Chuyển đổi (Phase 7)
$("convModeBtn").addEventListener("click", toggleConvMode);
$("cvOcr").addEventListener("click", runOcrAction);
$("cvPng").addEventListener("click", exportPngAction);
$("cvTxt").addEventListener("click", exportTxtAction);
$("cvDocx").addEventListener("click", exportDocxAction);
$("cvOffice").addEventListener("click", officeToPdfAction);
// Form (Phase 6)
$("formModeBtn").addEventListener("click", toggleFormMode);
$("fmFill").addEventListener("click", openFillForm);
+325
View File
@@ -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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
/// 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(&central);
// 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(&central_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)
}
+6
View File
@@ -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};
+198
View File
@@ -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:?}");
}
+4 -4
View File
@@ -56,11 +56,11 @@ Một ứng dụng desktop có thể **xem, chú thích, tổ chức trang, và
- [x] Tạo field: text/checkbox/combo (radio-group/list/pushbutton: follow-up); import/export dữ liệu FDF + export CSV.
**DoD:** ✅ điền & lưu giữ dữ liệu (gồm tiếng Việt); tạo form mới; export/import FDF round-trip. Follow-up: XFDF, radio-group/list/pushbutton khi tạo.
## Phase 7 — OCR & Convert
## Phase 7 — OCR & Convert ✅ HOÀN TẤT lõi (73/73 test — xem [docs/19-phase7-summary.md](19-phase7-summary.md))
**Mục tiêu:** Hai tính năng "đáng tiền" còn lại, qua dự án mở.
- **OCR**: Tesseract + tiền xử lý ảnh + layout → tạo searchable PDF (lớp text vô hình khớp toạ độ). Đa ngôn ngữ gồm tiếng Việt.
- **Convert**: PDF→ảnh (sẵn từ PDFium); Office↔PDF qua **LibreOffice headless**; PDF→Word/Excel (giữ layout — bài toán khó, làm bản "đủ dùng" trước).
**DoD:** OCR scan tạo PDF search được (Việt + Anh); convert Office↔PDF ổn; PDF→Word giữ được text & bố cục cơ bản.
- [x] **OCR**: Tesseract → searchable PDF (lớp text vô hình khớp toạ độ trên trang GỐC). Việt + Anh, test đúng dấu. (Tiền xử lý ảnh deskew/denoise: follow-up.)
- [x] **Convert**: PDF→ảnh PNG; Office↔PDF qua **LibreOffice headless**; PDF→Word bản "đủ dùng" tự viết (text + bố cục cơ bản) + bản LibreOffice khi có; PDF→TXT. (PDF→Excel: follow-up.)
**DoD:** ✅ OCR scan search được (Việt + Anh, kiểm toạ độ); ✅ Office↔PDF round-trip; ✅ PDF→Word giữ text & bố cục cơ bản.
## Phase 8 — Hoàn thiện & Phát hành
- So sánh 2 PDF (compare); in ấn đúng; preferences; đa ngôn ngữ UI (Việt/Anh); accessibility.
+11 -10
View File
@@ -102,8 +102,8 @@ Xếp theo mức độ ảnh hưởng trải nghiệm:
|---|---|---|
| ~~Bảo mật, redaction, chữ ký số PAdES (Phase 5)~~ ✅ ĐÃ LÀM | AES-256+quyền, redaction thật (tỉa ký tự), **chữ ký số PKCS#7/CMS + xác thực + phát hiện giả mạo** (`sign.rs`), lưu tối ưu — 62/62 test | Đã giải; còn lại timestamp/LTV/PFX/multi-sig (docs/15 mục 6.6) |
| ~~Form AcroForm (Phase 6)~~ ✅ ĐÃ LÀM | Liệt kê/điền/tạo field (lopdf) + flatten (PDFium) + FDF/CSV — 67/67 test (`form.rs`) | Đã giải; còn XFDF, radio-group/list/pushbutton khi tạo (docs/17 mục 6) |
| OCR (Phase 7) | Tesseract (tiếng Việt có traineddata chuẩn) + lớp text ẩn — kỹ thuật giống watermark.rs đã làm | Thấp, chủ yếu là chất lượng tiền xử lý ảnh |
| Convert Office↔PDF (Phase 7) | LibreOffice headless; PDF→Word "đủ dùng" trước | PDF→Word giữ layout hoàn hảo là bài khó với CẢ Foxit — đặt kỳ vọng "tương đương Foxit" chứ không hơn |
| ~~OCR (Phase 7)~~ ✅ ĐÃ LÀM | Tesseract sidecar → lớp text ẩn khớp toạ độ, Việt+Anh có test đúng dấu (`ocr.rs`) | Đã giải; còn tiền xử lý deskew/denoise, OCR theo vùng |
| ~~Convert Office↔PDF (Phase 7)~~ ✅ ĐÃ LÀM | LibreOffice headless 2 chiều + PDF→DOCX/TXT/PNG tự viết fallback (`convert.rs`) | Đã giải mức "đủ dùng"; PDF→Excel + layout hoàn hảo là follow-up |
| So sánh PDF, in ấn, installer (Phase 8) | render diff đã có nền; Tauri bundler | Thấp |
## 4. Đánh giá định hướng công nghệ (giữ hay đổi?)
@@ -136,16 +136,17 @@ redaction thật kiểm chứng được, xoá metadata — docs/15)**.
**Phase 5 đã HOÀN TẤT** (iter 1+2): mã hoá+quyền, gỡ mật khẩu, redaction thật
+ tỉa ký tự, xoá metadata, **chữ ký số PKCS#7/PAdES + xác thực**, lưu tối ưu.
**Phase 6 đã HOÀN TẤT lõi**: liệt kê/điền/tạo field, flatten, FDF/CSV.
**Phase 6 + 7 đã HOÀN TẤT lõi**: form đầy đủ; OCR Việt+Anh; convert
PNG/TXT/DOCX + Office↔PDF.
Thứ tự đề xuất tiếp theo, bám giá trị người dùng:
1. **Phase 7 — OCR & Convert**: OCR Tesseract (Việt+Anh) tạo lớp text ẩn;
convert Office↔PDF qua LibreOffice headless; PDF→Word/Excel "đủ dùng".
2. Phase 8 — phát hành (installer, so sánh PDF, in ấn, đa ngôn ngữ UI).
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ế, v2 của reflow
(justify/xoay — mục 3.1), nâng chữ ký (timestamp/LTV/PFX/multi-sig), và
nâng form (XFDF, radio-group/list/pushbutton, appearance riêng).
1. **Phase 8 — Hoàn thiện & Phát hành**: installer Windows (MSI/winget qua
Tauri bundler), in ấn, so sánh 2 PDF, preferences, đa ngôn ngữ UI (Việt/
Anh), tối ưu hiệu năng + bộ test hồi quy đầy đủ.
2. Song song, rải dần: mở rộng font matching (3.4), nấc 1 hiệu năng phiên
sửa (3.2), reflow v2 (justify/xoay — 3.1), nâng chữ ký (timestamp/LTV/
PFX/multi-sig), nâng form (XFDF, radio-group), OCR tiền xử lý ảnh,
PDF→Excel.
## 6. Checklist FINAL TARGET & RULE cho chính tài liệu này
- [x] Đối chiếu từng tính năng với hành vi Foxit làm chuẩn nghiệm thu.
+66
View File
@@ -0,0 +1,66 @@
# 19 — Tổng kết Phase 7 (OCR & Convert)
> Trạng thái: **HOÀN TẤT lõi.** OCR Tesseract (Việt + Anh) tạo lớp text ẨN
> khớp toạ độ lên trang gốc; PDF→PNG/TXT/DOCX; Office↔PDF qua LibreOffice.
> 6 test mới, tổng engine **73/73 xanh** (ngoài 2 fixture qpdf Linux).
## 1. Khảo sát Foxit (chuẩn nghiệm thu)
- **OCR (Recognize Text)**: chọn ngôn ngữ → chạy trên tài liệu scan → file
thành searchable/copy được, hình ảnh gốc GIỮ NGUYÊN (text ẩn dưới ảnh).
- **Convert**: PDF→ảnh/Word/Excel/text; Office→PDF.
## 2. Kiến trúc — sidecar CLI như qpdf
- **Tesseract** (OCR) và **LibreOffice headless** (Office↔PDF) là công cụ
ngoài, tìm qua env (`FOFREEXIT_TESSERACT_PATH`/`FOFREEXIT_SOFFICE_PATH`) →
PATH. Không có thì các tính năng khác vẫn chạy, nút liên quan báo rõ.
- PDF→PNG/TXT/DOCX cơ bản tự làm bằng PDFium + code riêng — không phụ thuộc gì.
## 3. Đã làm — Engine
### `ocr.rs`
- `ocr_page_words`: render trang **300 DPI** → `tesseract ... tsv` → parse
bảng từ (bbox pixel + confidence, lọc <30) → quy đổi pixel→điểm PDF.
- `ocr_add_text_layer`: OCR các trang rồi thêm text object **render mode
Invisible** đúng khung từng từ lên CHÍNH trang gốc (không đổi hình ảnh) —
đúng cơ chế searchable PDF chuẩn. Font Unicode (đủ dấu tiếng Việt) nhúng 1
lần. Ngôn ngữ `vie+eng` mặc định.
### `convert.rs`
- `export_images(dpi)`: mỗi trang 1 PNG `<tên>-pN.png`.
- `export_text`: toàn bộ text các trang → .txt.
- `export_docx` (tự viết, "đủ dùng"): gom char boxes thành DÒNG thị giác
(cùng heuristic UI edit) → mỗi dòng 1 đoạn Word với **cỡ chữ xấp xỉ** +
ngắt trang giữa các trang; DOCX = zip tự ghi (method STORE, CRC-32 tự tính,
không thêm dependency) — Word/LibreOffice/unzip đọc chuẩn.
- `office_to_pdf` / `pdf_to_docx_via_soffice`: LibreOffice headless với
profile riêng (`-env:UserInstallation`) để không đụng LibreOffice đang mở;
PDF→DOCX dùng `--infilter=writer_pdf_import` (layout tốt hơn bản tự viết).
## 4. Tauri + UI (thanh "🔁 Chuyển đổi")
- Commands: `ocr_run` (chọn ngôn ngữ), `convert_images`, `convert_txt`,
`convert_docx` (**tự chọn engine**: LibreOffice nếu có → fallback bộ cơ bản,
báo rõ đã dùng gì), `office_convert`, `convert_tools_status` (UI disable
nút + hiện thiếu công cụ nào).
- 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 PDF kết quả).
## 5. Test (6 mới → engine 73/73)
- `ocr_makes_scanned_pdf_searchable_english` — dựng PDF-scan giả lập THẬT
(xoá hết text, chỉ còn ảnh render — extract rỗng như scan) → OCR → từ tìm
được qua extract + search, **toạ độ lớp ẩn khớp vùng chữ** trên ảnh.
- `ocr_vietnamese_diacritics` — "Việt Nam đất nước" nhận dạng ĐÚNG DẤU.
- `export_images_per_page` (3 trang → 3 PNG đúng cỡ); `export_text_all_pages`.
- `export_docx_basic_layout` — docx là zip chuẩn (unzip hệ thống giải được),
document.xml chứa text + ngắt trang.
- `libreoffice_round_trip_when_available` — PDF→DOCX (LibreOffice) và
DOCX→PDF: PDF kết quả mở được, nội dung giữ nguyên. Tự bỏ qua nếu máy
không có soffice.
## 6. Giới hạn (v-sau, không chặn dùng)
- OCR: chưa tiền xử lý ảnh (deskew/denoise) cho scan nghiêng/mờ; chưa chọn
vùng OCR; lớp ẩn đặt theo khung TỪ (chuẩn phổ biến), chưa scale ngang từng
glyph (Tz) nên bôi chọn có thể lệch nhẹ ở từ rất dài.
- PDF→DOCX bản tự viết: giữ text + thứ tự đọc + cỡ chữ tương đối; KHÔNG tái
tạo cột/bảng/ảnh (LibreOffice lo phần đó khi máy có cài). PDF→Excel chưa làm.
- Windows: cần cài Tesseract (UB Mannheim build, tick gói Vietnamese) và/hoặc
LibreOffice; ghi trong docs/05 + checklist docs/20.
+37
View File
@@ -0,0 +1,37 @@
# 20 — Checklist test thủ công Phase 7 (OCR & Convert)
Chuẩn bị trên Windows:
- **Tesseract**: cài bản UB Mannheim (github tesseract-ocr), tick thêm gói
ngôn ngữ **Vietnamese**; hoặc đặt `FOFREEXIT_TESSERACT_PATH`.
- **LibreOffice** (cho Office↔PDF chất lượng cao): cài bản thường; hoặc đặt
`FOFREEXIT_SOFFICE_PATH` trỏ tới soffice.exe.
## Chạy
```powershell
cd c:\Project\FoFreeXit\app\src-tauri
cargo run
```
## Bảng test
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| C1 | Bấm **🔁 Chuyển đổi** | Hiện thanh; nếu thiếu Tesseract/LibreOffice có ghi chú + nút tương ứng mờ | |
| C2 | Mở 1 PDF **scan** (hoặc ảnh chụp tài liệu in ra PDF) → **🔍 OCR** (Việt + Anh) → lưu | Chạy xong báo số từ; file mới nhìn Y HỆT bản gốc | |
| C3 | Trong file C2: Ctrl+F tìm 1 từ có trong ảnh; bôi chọn & copy đoạn chữ | Tìm THẤY, vị trí highlight đúng chỗ; copy ra đúng chữ (kể cả **tiếng Việt có dấu**) | |
| C4 | Mở file C2 ở Foxit/Adobe → search/copy | Hoạt động như C3 (lớp text ẩn chuẩn) | |
| C5 | **🖼 Xuất PNG** → chọn thư mục | Mỗi trang 1 file PNG rõ nét (150 DPI) | |
| C6 | **📄 Xuất TXT** | File .txt chứa đủ text các trang | |
| C7 | **📘 Xuất Word** (máy CÓ LibreOffice) → mở .docx bằng Word | Mở được; chữ + bố cục gần bản gốc; status ghi "LibreOffice" | |
| C8 | **📘 Xuất Word** (máy KHÔNG LibreOffice) → mở .docx bằng Word | Mở được; đủ text đúng thứ tự, cỡ chữ tương đối, ngắt trang đúng; status ghi "bộ chuyển cơ bản" | |
| C9 | **📥 Office → PDF**: chọn 1 file .docx → thư mục ra | PDF tạo ra mở được trong app, nội dung đúng | |
| C10 | OCR file 20+ trang | Chạy hết (có thể vài phút), không crash, số từ hợp lý | |
## Mẫu phản hồi
```
[Mã] (vd C3) — Phần mềm mở: <Foxit/Adobe/Chrome/Word> — Hiện tượng: <mô tả> —
Kỳ vọng: <...> — Ưu tiên: cao/vừa/thấp
```
> Giới hạn đã biết: OCR chưa tiền xử lý scan nghiêng/mờ; PDF→Word bộ cơ bản
> không tái tạo cột/bảng/ảnh (cài LibreOffice để có layout tốt); PDF→Excel
> chưa làm — xem `docs/19-phase7-summary.md` mục 6.