feat: 新增 OCRmyPDF 轉換引擎 (v0.1.14)

## 新功能
- OCRmyPDF 轉換引擎:將掃描版 PDF 轉換為可搜尋 PDF
  - 支援 7 種語言:en, zh-TW, zh, ja, ko, de, fr
  - 與 PDFMathTranslate 風格一致的 UI 格式 (pdf-<lang>)
  - 自動偵測頁面方向並旋轉
  - 自動校正傾斜
  - 跳過已有文字層的頁面
  - 詳細的 5 階段處理進度輸出

## 建置
- Dockerfile:安裝 ocrmypdf 與 Tesseract OCR 語言包

## 文件
- 更新 OCR 功能文件
- 文件目錄結構改為中文名稱

## 測試
- 修復 BabelDOC 和 PDFMathTranslate 測試的 OCR mock
- 所有 345 個測試通過
This commit is contained in:
Your Name 2026-01-23 16:28:33 +08:00
parent f24eec070c
commit a06df23b1d
53 changed files with 1427 additions and 675 deletions

202
src/helpers/pdfOcr.ts Normal file
View file

@ -0,0 +1,202 @@
import { execFile as execFileOriginal } from "node:child_process";
import { existsSync, unlinkSync, readFileSync } from "node:fs";
import { join, dirname, basename } from "node:path";
import type { ExecFileFn } from "../converters/types";
/**
* PDF OCR
*
* PDF 使 ocrmypdf OCR
*
*/
// OCR 偵測閾值(文字字元數低於此值視為掃描版)
const SCANNED_PDF_TEXT_THRESHOLD = 100;
// 預設 OCR 語言(可透過環境變數覆蓋)
const DEFAULT_OCR_LANG = process.env.OCR_LANG || "eng+chi_tra+chi_sim+jpn";
/**
* 使 pdftotext PDF
*
* @param pdfPath PDF
* @param execFile execFile
* @returns
*/
function extractTextFromPdf(
pdfPath: string,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
return new Promise((resolve, reject) => {
// pdftotext -layout <input.pdf> -
// -layout: 保持排版
// -: 輸出到 stdout
execFile("pdftotext", ["-layout", pdfPath, "-"], (error, stdout, stderr) => {
if (error) {
// pdftotext 失敗可能是因為 PDF 沒有文字層
console.warn(`[PDF-OCR] pdftotext failed: ${stderr}`);
resolve(""); // 返回空字串,視為掃描版
return;
}
resolve(stdout || "");
});
});
}
/**
* PDF
*
* @param pdfPath PDF
* @param execFile execFile
* @returns PDF
*/
export async function isScannedPdf(
pdfPath: string,
execFile: ExecFileFn = execFileOriginal,
): Promise<boolean> {
try {
const text = await extractTextFromPdf(pdfPath, execFile);
// 移除空白字元後計算有效字元數
const cleanText = text.replace(/\s+/g, "");
const charCount = cleanText.length;
console.log(`[PDF-OCR] Extracted ${charCount} characters from PDF`);
// 如果字元數低於閾值,視為掃描版
const isScanned = charCount < SCANNED_PDF_TEXT_THRESHOLD;
if (isScanned) {
console.log(`[PDF-OCR] ⚠️ Detected scanned PDF (chars: ${charCount} < ${SCANNED_PDF_TEXT_THRESHOLD})`);
} else {
console.log(`[PDF-OCR] ✅ PDF has text layer (chars: ${charCount})`);
}
return isScanned;
} catch (error) {
console.warn(`[PDF-OCR] Detection failed, assuming scanned: ${error}`);
return true; // 偵測失敗時,保守起見視為掃描版
}
}
/**
* 使 ocrmypdf PDF OCR
*
* @param inputPath PDF
* @param outputPath PDF
* @param lang OCR eng+chi_tra+chi_sim+jpn
* @param execFile execFile
* @returns PDF
*/
export function runOcrMyPdf(
inputPath: string,
outputPath: string,
lang: string = DEFAULT_OCR_LANG,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
return new Promise((resolve, reject) => {
// ocrmypdf 參數:
// -l <lang>: OCR 語言(可用 + 連接多語言)
// --skip-text: 跳過已有文字的頁面(避免重複 OCR
// --optimize 1: 輕度優化,平衡速度和品質
// --deskew: 自動校正傾斜
// --clean: 清理背景雜訊
// --force-ocr: 強制對所有頁面進行 OCR即使有文字層
const args = [
"-l", lang,
"--skip-text",
"--optimize", "1",
"--deskew",
inputPath,
outputPath,
];
console.log(`[PDF-OCR] Running: ocrmypdf ${args.join(" ")}`);
execFile("ocrmypdf", args, (error: Error | null, stdout: string, stderr: string) => {
if (error) {
// 檢查是否是「PDF 已經有文字」的警告
if (stderr && stderr.includes("page already has text")) {
console.log(`[PDF-OCR] PDF already has text layer, using original`);
resolve(inputPath);
return;
}
reject(new Error(`ocrmypdf error: ${error}\nstderr: ${stderr}`));
return;
}
if (stdout) {
console.log(`[PDF-OCR] stdout: ${stdout}`);
}
if (stderr) {
// ocrmypdf 的進度資訊會輸出到 stderr
console.log(`[PDF-OCR] stderr: ${stderr}`);
}
if (!existsSync(outputPath)) {
reject(new Error(`OCR output file not found: ${outputPath}`));
return;
}
console.log(`[PDF-OCR] ✅ OCR completed: ${outputPath}`);
resolve(outputPath);
});
});
}
/**
* PDF
*
* PDF OCR
* PDF
*
* @param inputPath PDF
* @param execFile execFile
* @returns PDF OCR
*/
export async function ensureSearchablePdf(
inputPath: string,
execFile: ExecFileFn = execFileOriginal,
): Promise<{ path: string; wasOcred: boolean; tempFile?: string }> {
// 1. 偵測是否為掃描版
const scanned = await isScannedPdf(inputPath, execFile);
if (!scanned) {
// PDF 已有文字層,直接使用
return { path: inputPath, wasOcred: false };
}
// 2. 建立 OCR 輸出路徑
const dir = dirname(inputPath);
const name = basename(inputPath, ".pdf");
const ocrOutputPath = join(dir, `${name}_ocr_${Date.now()}.pdf`);
// 3. 執行 OCR
console.log(`[PDF-OCR] Starting OCR for scanned PDF...`);
await runOcrMyPdf(inputPath, ocrOutputPath, DEFAULT_OCR_LANG, execFile);
return {
path: ocrOutputPath,
wasOcred: true,
tempFile: ocrOutputPath, // 呼叫者需要在完成後清理此暫存檔
};
}
/**
* OCR
*
* @param tempFile
*/
export function cleanupOcrTempFile(tempFile: string | undefined): void {
if (tempFile && existsSync(tempFile)) {
try {
unlinkSync(tempFile);
console.log(`[PDF-OCR] Cleaned up temp file: ${tempFile}`);
} catch (error) {
console.warn(`[PDF-OCR] Failed to cleanup temp file: ${error}`);
}
}
}