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:
@@ -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
|
- [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)
|
- [ ] 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)
|
- **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).
|
Build & chạy: xem [docs/05-dev-setup.md](docs/05-dev-setup.md).
|
||||||
|
|
||||||
|
|||||||
@@ -1160,6 +1160,110 @@ fn form_import_fdf(input: String, fdf: String, output: String) -> Result<usize,
|
|||||||
r
|
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.
|
/// Hộp thoại lưu file FDF.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn pick_save_data(app: tauri::AppHandle) -> Option<String> {
|
fn pick_save_data(app: tauri::AppHandle) -> Option<String> {
|
||||||
@@ -1266,6 +1370,14 @@ fn main() {
|
|||||||
form_create,
|
form_create,
|
||||||
form_export,
|
form_export,
|
||||||
form_import_fdf,
|
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_any_file,
|
||||||
pick_save_pem,
|
pick_save_pem,
|
||||||
pick_save_data,
|
pick_save_data,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<button id="editModeBtn" title="Sửa nội dung trang">✏️ Sửa nội dung</button>
|
<button id="editModeBtn" title="Sửa nội dung trang">✏️ Sửa nội dung</button>
|
||||||
<button id="formModeBtn" title="Form: điền, tạo field, flatten, FDF/CSV">📝 Form</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="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>
|
<div class="spacer"></div>
|
||||||
<span id="status" class="status"></span>
|
<span id="status" class="status"></span>
|
||||||
</header>
|
</header>
|
||||||
@@ -117,6 +118,23 @@
|
|||||||
<span id="formHint" class="status"></span>
|
<span id="formHint" class="status"></span>
|
||||||
</div>
|
</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">
|
<div id="secBar" class="editbar hidden">
|
||||||
<span class="annolabel">Bảo mật:</span>
|
<span class="annolabel">Bảo mật:</span>
|
||||||
<button id="secRedactTool" class="atool" data-tool="redact" title="Kéo chuột trên trang để đánh dấu vùng cần bôi đen — nội dung bị XOÁ THẬT khi áp dụng">⬛ Đánh dấu redact</button>
|
<button id="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
@@ -43,6 +43,7 @@ const state = {
|
|||||||
redactMarks: [], // [{page, rect{left,bottom,right,top}}] chờ áp dụng
|
redactMarks: [], // [{page, rect{left,bottom,right,top}}] chờ áp dụng
|
||||||
formMode: false, // thanh Form (Phase 6) đang mở
|
formMode: false, // thanh Form (Phase 6) đang mở
|
||||||
formFields: [], // field đọc từ tài liệu hiện tại
|
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;
|
const UNDO_LIMIT = 50;
|
||||||
let annotIdSeq = 1;
|
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) ----------
|
// ---------- Phase 6: Form (AcroForm) ----------
|
||||||
|
|
||||||
function toggleFormMode() {
|
function toggleFormMode() {
|
||||||
@@ -3225,6 +3318,14 @@ $("modalOverlay").addEventListener("click", (e) => {
|
|||||||
if (e.target.id === "modalOverlay") closeModal();
|
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)
|
// Form (Phase 6)
|
||||||
$("formModeBtn").addEventListener("click", toggleFormMode);
|
$("formModeBtn").addEventListener("click", toggleFormMode);
|
||||||
$("fmFill").addEventListener("click", openFillForm);
|
$("fmFill").addEventListener("click", openFillForm);
|
||||||
|
|||||||
@@ -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).
|
//! ocr... sẽ thêm dần ở các phase sau (xem docs/03-roadmap.md, 04-architecture.md).
|
||||||
|
|
||||||
pub mod annot;
|
pub mod annot;
|
||||||
|
pub mod convert;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
pub(crate) mod fontmatch;
|
pub(crate) mod fontmatch;
|
||||||
pub mod form;
|
pub mod form;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
|
pub mod ocr;
|
||||||
pub mod organize;
|
pub mod organize;
|
||||||
pub mod qpdf;
|
pub mod qpdf;
|
||||||
pub mod redact;
|
pub mod redact;
|
||||||
@@ -30,10 +32,14 @@ pub use qpdf::{
|
|||||||
decrypt_remove_password, encrypt_with_password, encrypt_with_password_perms, ensure_openable,
|
decrypt_remove_password, encrypt_with_password, encrypt_with_password_perms, ensure_openable,
|
||||||
find_qpdf, optimize_save, repair, Permissions,
|
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::{
|
pub use form::{
|
||||||
create_form_fields, export_csv, export_fdf, fill_form_fields, flatten_form, import_fdf,
|
create_form_fields, export_csv, export_fdf, fill_form_fields, flatten_form, import_fdf,
|
||||||
list_form_fields, parse_fdf, FieldKind, FieldValue, FormField, NewField,
|
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 redact::redact_areas;
|
||||||
pub use sign::{generate_self_signed_id, sign_pdf, verify_signatures, SignatureCheck};
|
pub use sign::{generate_self_signed_id, sign_pdf, verify_signatures, SignatureCheck};
|
||||||
pub use render::{bind_pdfium, page_count, render_page_png, PageImage};
|
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:?}");
|
||||||
|
}
|
||||||
+4
-4
@@ -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.
|
- [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.
|
**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ở.
|
**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.
|
- [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.)
|
||||||
- **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).
|
- [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 tạo PDF search được (Việt + Anh); convert Office↔PDF ổn; PDF→Word giữ được text & bố cục cơ bản.
|
**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
|
## 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.
|
- So sánh 2 PDF (compare); in ấn đúng; preferences; đa ngôn ngữ UI (Việt/Anh); accessibility.
|
||||||
|
|||||||
@@ -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) |
|
| ~~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) |
|
| ~~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 |
|
| ~~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) | 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 |
|
| ~~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 |
|
| 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?)
|
## 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
|
**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.
|
+ 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:
|
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;
|
1. **Phase 8 — Hoàn thiện & Phát hành**: installer Windows (MSI/winget qua
|
||||||
convert Office↔PDF qua LibreOffice headless; PDF→Word/Excel "đủ dùng".
|
Tauri bundler), in ấn, so sánh 2 PDF, preferences, đa ngôn ngữ UI (Việt/
|
||||||
2. Phase 8 — phát hành (installer, so sánh PDF, in ấn, đa ngôn ngữ UI).
|
Anh), tối ưu hiệu năng + bộ test hồi quy đầy đủ.
|
||||||
3. Song song, rải theo từng phase: mở rộng font matching (3.4), nấc 1 của
|
2. Song song, rải dần: mở rộng font matching (3.4), nấc 1 hiệu năng phiên
|
||||||
hiệu năng phiên sửa (3.2) khi chạm file lớn thực tế, v2 của reflow
|
sửa (3.2), reflow v2 (justify/xoay — 3.1), nâng chữ ký (timestamp/LTV/
|
||||||
(justify/xoay — mục 3.1), nâng chữ ký (timestamp/LTV/PFX/multi-sig), và
|
PFX/multi-sig), nâng form (XFDF, radio-group), OCR tiền xử lý ảnh,
|
||||||
nâng form (XFDF, radio-group/list/pushbutton, appearance riêng).
|
PDF→Excel.
|
||||||
|
|
||||||
## 6. Checklist FINAL TARGET & RULE cho chính tài liệu này
|
## 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.
|
- [x] Đối chiếu từng tính năng với hành vi Foxit làm chuẩn nghiệm thu.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user