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

View file

@ -11,6 +11,7 @@ import {
import { join, basename, dirname } from "node:path";
import { ExecFileFn } from "./types";
import { getArchiveFileName } from "../transfer";
import { ensureSearchablePdf, cleanupOcrTempFile } from "../helpers/pdfOcr";
/**
* BabelDOC Content Engine
@ -275,7 +276,19 @@ export async function convert(
_options?: unknown,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
let ocrTempFile: string | undefined;
try {
// 0. 自動偵測掃描版 PDF 並進行 OCR 處理
console.log(`[BabelDOC] Checking if PDF needs OCR...`);
const ocrResult = await ensureSearchablePdf(filePath, execFile);
const inputPdf = ocrResult.path;
ocrTempFile = ocrResult.tempFile;
if (ocrResult.wasOcred) {
console.log(`[BabelDOC] ✅ Scanned PDF detected and OCR'd automatically`);
}
// 1. 檢查資源(警告但不阻止)
checkResourcesExist();
@ -300,8 +313,8 @@ export async function convert(
// 5. 設定 BabelDOC 輸出路徑(依輸出格式決定副檔名)
const translatedFilePath = join(tempDir, `${inputFileName}-translated.${outputExt}`);
// 6. 執行 babeldoc 翻譯
await runBabelDoc(filePath, translatedFilePath, targetLang, outputFormat, execFile);
// 6. 執行 babeldoc 翻譯(使用 OCR 處理後的 PDF
await runBabelDoc(inputPdf, translatedFilePath, targetLang, outputFormat, execFile);
// 7. 複製翻譯後的檔案到封裝目錄
const translatedDest = join(archiveDir, `translated-${targetLang}.${outputExt}`);
@ -345,8 +358,13 @@ export async function convert(
// 10. 清理臨時目錄
removeDir(tempDir);
// 11. 清理 OCR 暫存檔案
cleanupOcrTempFile(ocrTempFile);
return "Done";
} catch (error) {
// 確保清理 OCR 暫存檔案
cleanupOcrTempFile(ocrTempFile);
throw new Error(`BabelDOC error: ${error}`);
}
}

View file

@ -31,6 +31,7 @@ import {
properties as propertiesPDFMathTranslate,
} from "./pdfmathtranslate";
import { convert as convertBabelDoc, properties as propertiesBabelDoc } from "./babeldoc";
import { convert as convertOcrMyPdf, properties as propertiesOcrMyPdf } from "./ocrmypdf";
// This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular
@ -156,6 +157,10 @@ const properties: Record<
properties: propertiesBabelDoc,
converter: convertBabelDoc,
},
OCRmyPDF: {
properties: propertiesOcrMyPdf,
converter: convertOcrMyPdf,
},
};
function chunks<T>(arr: T[], size: number): T[][] {

271
src/converters/ocrmypdf.ts Normal file
View file

@ -0,0 +1,271 @@
import { execFile as execFileOriginal } from "node:child_process";
import { existsSync, mkdirSync, copyFileSync, statSync } from "node:fs";
import { dirname, basename } from "node:path";
import type { ExecFileFn } from "./types";
/**
* OCRmyPDF Content Engine
*
* PDF PDF
* 使 Tesseract OCR
*
* OCR Docker
* - eng: 英文
* - chi_tra: 繁體中文
* - chi_sim: 簡體中文
* - jpn: 日文
* - kor: 韓文
* - deu: 德文
* - fra: 法文
*
* UI PDFMathTranslate
* pdf-en, pdf-zh-TW, pdf-ja
*/
// 內建支援的 OCR 語言(與 PDFMathTranslate 風格一致)
const SUPPORTED_LANGUAGES = [
"en", // English
"zh-TW", // 繁體中文
"zh", // 簡體中文
"ja", // 日本語
"ko", // 한국어
"de", // Deutsch
"fr", // Français
] as const;
// Tesseract 語言代碼映射UI 代碼 → Tesseract 代碼)
const LANG_MAP: Record<string, string> = {
"en": "eng",
"zh-TW": "chi_tra",
"zh": "chi_sim",
"ja": "jpn",
"ko": "kor",
"de": "deu",
"fr": "fra",
};
export const properties = {
from: {
document: ["pdf"],
},
to: {
document: SUPPORTED_LANGUAGES.map((lang) => `pdf-${lang}`),
},
};
/**
* convertTo OCR
* @param convertTo "pdf-en" "pdf-zh-TW"
* @returns Tesseract OCR
*/
function extractOcrLanguage(convertTo: string): string {
// convertTo 格式: pdf-<lang>
const match = convertTo.match(/^pdf-(.+)$/);
if (!match || !match[1]) {
throw new Error(`Invalid convertTo format: ${convertTo}. Expected pdf-<lang>`);
}
const uiLang = match[1];
// 轉換為 Tesseract 語言代碼
const tessLang = LANG_MAP[uiLang] || uiLang.replace(/-/g, "_");
return tessLang;
}
/**
*
*/
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
/**
* ocrmypdf PDF OCR
*
* @param inputPath PDF
* @param outputPath PDF
* @param lang OCR
* @param execFile execFile
*/
function runOcrMyPdf(
inputPath: string,
outputPath: string,
lang: string,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
return new Promise((resolve, reject) => {
console.log(`[OCRmyPDF] ========================================`);
console.log(`[OCRmyPDF] 🔍 開始 PDF OCR 處理`);
console.log(`[OCRmyPDF] ========================================`);
// 階段 1驗證輸入檔案
console.log(`[OCRmyPDF] 📋 階段 1/5驗證輸入檔案`);
if (!existsSync(inputPath)) {
reject(new Error(`Input file not found: ${inputPath}`));
return;
}
const inputStats = statSync(inputPath);
console.log(`[OCRmyPDF] ✅ 輸入檔案: ${basename(inputPath)}`);
console.log(`[OCRmyPDF] ✅ 檔案大小: ${formatFileSize(inputStats.size)}`);
// 階段 2準備 OCR 參數
console.log(`[OCRmyPDF] 📋 階段 2/5準備 OCR 參數`);
console.log(`[OCRmyPDF] ✅ OCR 語言: ${lang}`);
const args = [
"-l", lang,
"--skip-text", // 跳過已有文字的頁面
"--optimize", "1", // 輕度優化
"--deskew", // 自動校正傾斜
"--rotate-pages", // 自動偵測頁面方向
"--jobs", "2", // 使用 2 個並行處理
inputPath,
outputPath,
];
console.log(`[OCRmyPDF] ✅ 參數: --skip-text --optimize 1 --deskew --rotate-pages`);
// 階段 3執行 OCR
console.log(`[OCRmyPDF] 📋 階段 3/5執行 Tesseract OCR...`);
console.log(`[OCRmyPDF] ⏳ 處理中(大型 PDF 可能需要數分鐘)...`);
const startTime = Date.now();
execFile("ocrmypdf", args, (error: Error | null, stdout: string, stderr: string) => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (error) {
// 階段 3 失敗處理
console.log(`[OCRmyPDF] ❌ OCR 處理失敗(耗時 ${elapsed}s`);
// 檢查特定錯誤類型
if (stderr && stderr.includes("PriorOcrFoundError")) {
console.log(`[OCRmyPDF] 📋 階段 4/5PDF 已有文字層,跳過 OCR`);
console.log(`[OCRmyPDF] ✅ 複製原始檔案...`);
try {
copyFileSync(inputPath, outputPath);
console.log(`[OCRmyPDF] 📋 階段 5/5完成`);
console.log(`[OCRmyPDF] ✅ 輸出: ${basename(outputPath)}`);
console.log(`[OCRmyPDF] ========================================`);
console.log(`[OCRmyPDF] ✅ OCR 完成PDF 已有文字層)`);
console.log(`[OCRmyPDF] ========================================`);
resolve(outputPath);
return;
} catch (copyError) {
reject(new Error(`Failed to copy already-OCRed PDF: ${copyError}`));
return;
}
}
if (stderr && stderr.includes("EncryptedPdfError")) {
console.log(`[OCRmyPDF] ❌ PDF 已加密,無法處理`);
reject(new Error("PDF is encrypted. Please decrypt it first."));
return;
}
if (stderr && stderr.includes("InputFileError")) {
console.log(`[OCRmyPDF] ❌ PDF 檔案無效或損壞`);
reject(new Error("Invalid or corrupted PDF file."));
return;
}
console.log(`[OCRmyPDF] 錯誤訊息: ${stderr || error.message}`);
reject(new Error(`ocrmypdf error: ${error}\nstderr: ${stderr}`));
return;
}
// 階段 3 成功
console.log(`[OCRmyPDF] ✅ OCR 引擎處理完成(耗時 ${elapsed}s`);
if (stdout) {
console.log(`[OCRmyPDF] stdout: ${stdout}`);
}
// 階段 4驗證輸出
console.log(`[OCRmyPDF] 📋 階段 4/5驗證輸出檔案`);
if (!existsSync(outputPath)) {
console.log(`[OCRmyPDF] ❌ 輸出檔案未生成`);
reject(new Error(`OCR output file not found: ${outputPath}`));
return;
}
const outputStats = statSync(outputPath);
console.log(`[OCRmyPDF] ✅ 輸出檔案: ${basename(outputPath)}`);
console.log(`[OCRmyPDF] ✅ 檔案大小: ${formatFileSize(outputStats.size)}`);
// 階段 5完成
console.log(`[OCRmyPDF] 📋 階段 5/5處理完成`);
console.log(`[OCRmyPDF] ========================================`);
console.log(`[OCRmyPDF] ✅ OCR 處理成功完成!`);
console.log(`[OCRmyPDF] 📥 輸入: ${formatFileSize(inputStats.size)}`);
console.log(`[OCRmyPDF] 📤 輸出: ${formatFileSize(outputStats.size)}`);
console.log(`[OCRmyPDF] ⏱️ 耗時: ${elapsed}s`);
console.log(`[OCRmyPDF] ========================================`);
resolve(outputPath);
});
});
}
/**
*
*
* @param filePath PDF
* @param fileType "pdf"
* @param convertTo "pdf-en""pdf-zh-TW"
* @param targetPath
* @param _options
* @param execFile execFile
*/
export async function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
_options?: unknown,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
console.log(``);
console.log(`[OCRmyPDF] ╔════════════════════════════════════════╗`);
console.log(`[OCRmyPDF] ║ OCRmyPDF - PDF OCR 處理引擎 ║`);
console.log(`[OCRmyPDF] ╚════════════════════════════════════════╝`);
console.log(``);
try {
// 步驟 1解析參數
console.log(`[OCRmyPDF] 🔧 解析轉換參數...`);
const ocrLang = extractOcrLanguage(convertTo);
console.log(`[OCRmyPDF] 輸入格式: ${fileType}`);
console.log(`[OCRmyPDF] 目標格式: ${convertTo}`);
console.log(`[OCRmyPDF] OCR 語言: ${ocrLang}`);
// 步驟 2確保輸出目錄存在
console.log(`[OCRmyPDF] 📁 準備輸出目錄...`);
const outputDir = dirname(targetPath);
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
console.log(`[OCRmyPDF] ✅ 建立目錄: ${outputDir}`);
} else {
console.log(`[OCRmyPDF] ✅ 目錄已存在`);
}
// 步驟 3執行 OCR
console.log(`[OCRmyPDF] 🚀 開始 OCR 處理...`);
await runOcrMyPdf(filePath, targetPath, ocrLang, execFile);
console.log(``);
console.log(`[OCRmyPDF] ╔════════════════════════════════════════╗`);
console.log(`[OCRmyPDF] ║ ✅ PDF OCR 處理完成! ║`);
console.log(`[OCRmyPDF] ╚════════════════════════════════════════╝`);
console.log(``);
return "Done";
} catch (error) {
console.log(``);
console.log(`[OCRmyPDF] ╔════════════════════════════════════════╗`);
console.log(`[OCRmyPDF] ║ ❌ PDF OCR 處理失敗 ║`);
console.log(`[OCRmyPDF] ╚════════════════════════════════════════╝`);
console.log(`[OCRmyPDF] 錯誤: ${error}`);
console.log(``);
throw new Error(`OCRmyPDF error: ${error}`);
}
}

View file

@ -2,6 +2,7 @@ import { execFile as execFileOriginal } from "node:child_process";
import { mkdirSync, existsSync, readdirSync, unlinkSync, rmdirSync, copyFileSync } from "node:fs";
import { join, basename, dirname } from "node:path";
import { getArchiveFileName } from "../transfer";
import { ensureSearchablePdf, cleanupOcrTempFile } from "../helpers/pdfOcr";
import type { ExecFileFn } from "./types";
// 翻譯服務優先順序(自動 fallback
@ -339,7 +340,19 @@ export async function convert(
_options?: unknown,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
let ocrTempFile: string | undefined;
try {
// 0. 自動偵測掃描版 PDF 並進行 OCR 處理
console.log(`[PDFMathTranslate] Checking if PDF needs OCR...`);
const ocrResult = await ensureSearchablePdf(filePath, execFile);
const inputPdf = ocrResult.path;
ocrTempFile = ocrResult.tempFile;
if (ocrResult.wasOcred) {
console.log(`[PDFMathTranslate] ✅ Scanned PDF detected and OCR'd automatically`);
}
// 1. 檢查模型(警告但不阻止,因為 pdf2zh 可能會自動下載)
checkModelsExist();
@ -361,8 +374,8 @@ export async function convert(
const archiveDir = join(tempDir, "archive");
mkdirSync(archiveDir, { recursive: true });
// 5. 執行 pdf2zh 翻譯
const { monoPath, dualPath } = await runPdf2zh(filePath, tempDir, targetLang, execFile);
// 5. 執行 pdf2zh 翻譯(使用 OCR 處理後的 PDF
const { monoPath, dualPath } = await runPdf2zh(inputPdf, tempDir, targetLang, execFile);
// 6. 複製翻譯後的檔案到封裝目錄
// PDFMathTranslate 輸出:
@ -440,8 +453,13 @@ export async function convert(
// 9. 清理臨時目錄
removeDir(tempDir);
// 10. 清理 OCR 暫存檔案
cleanupOcrTempFile(ocrTempFile);
return "Done";
} catch (error) {
// 確保清理 OCR 暫存檔案
cleanupOcrTempFile(ocrTempFile);
throw new Error(`PDFMathTranslate error: ${error}`);
}
}

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}`);
}
}
}