convertor/src/converters/pdfmathtranslate.ts
Your Name d943771f36 feat: add PDFMathTranslate converter for PDF translation with mathematical content
- Introduced PDFMathTranslate converter to handle PDF translations while preserving mathematical formulas.
- Updated Dockerfile to include necessary dependencies and pre-download models during build.
- Enhanced README.md to document the new converter and its features.
- Added tests for PDFMathTranslate to ensure correct functionality and error handling.
2026-01-21 14:31:19 +08:00

326 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { ExecFileFn } from "./types";
import { getArchiveFileName } from "../transfer";
/**
* PDFMathTranslate Content Engine
*
* 用於翻譯 PDF 文件同時保留數學公式的引擎。
*
* 使用 pdf2zh CLI 工具進行翻譯,支援多種目標語言。
* 所有輸出一律打包為 .tar 檔案,包含:
* - original.pdf原始 PDF
* - translated-<lang>.pdf翻譯後的 PDF
*
* 必須在 Docker build 階段預先下載所需模型,
* 不允許在 runtime 隱式下載模型。
*/
// 支援的目標語言列表
const SUPPORTED_LANGUAGES = [
"en", // English
"zh", // Chinese (Simplified)
"zh-TW", // Chinese (Traditional)
"ja", // Japanese
"ko", // Korean
"de", // German
"fr", // French
"es", // Spanish
"it", // Italian
"pt", // Portuguese
"ru", // Russian
"ar", // Arabic
"hi", // Hindi
"vi", // Vietnamese
"th", // Thai
] as const;
type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number];
// 模型路徑Docker 環境中)
const MODELS_PATH = process.env.PDFMATHTRANSLATE_MODELS_PATH || "/models/pdfmathtranslate";
// 生成 from/to 格式映射
function generateLanguageMappings(): { from: Record<string, string[]>; to: Record<string, string[]> } {
const outputFormats = SUPPORTED_LANGUAGES.map(lang => `pdf-${lang}`);
return {
from: {
document: ["pdf"],
},
to: {
document: outputFormats as unknown as string[],
},
};
}
export const properties = {
...generateLanguageMappings(),
outputMode: "archive" as const,
};
/**
* 從 convertTo 格式中提取目標語言
* @param convertTo 格式如 "pdf-zh" 或 "pdf-en"
* @returns 目標語言代碼
*/
function extractTargetLanguage(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>`);
}
return match[1];
}
/**
* 檢查模型是否已預先下載
* @returns 模型是否存在
*/
function checkModelsExist(): boolean {
// 檢查 ONNX 模型目錄是否存在
if (!existsSync(MODELS_PATH)) {
console.warn(`[PDFMathTranslate] Models directory not found: ${MODELS_PATH}`);
console.warn(`[PDFMathTranslate] Models should be pre-downloaded during Docker build.`);
return false;
}
return true;
}
/**
* Helper function to create a .tar archive from a directory (no compression)
*
* ⚠️ 重要:僅使用 .tar 格式,禁止 .tar.gz / .tgz / .zip
*/
function createTarArchive(
sourceDir: string,
outputTar: string,
execFile: ExecFileFn,
): Promise<void> {
return new Promise((resolve, reject) => {
// Use tar command to create archive (without gzip compression)
// tar -cf <output.tar> -C <sourceDir> .
// 注意:使用 -cf 而非 -czf避免 gzip 壓縮
execFile(
"tar",
["-cf", outputTar, "-C", sourceDir, "."],
(error, stdout, stderr) => {
if (error) {
reject(`tar error: ${error}`);
return;
}
if (stdout) {
console.log(`tar stdout: ${stdout}`);
}
if (stderr) {
console.error(`tar stderr: ${stderr}`);
}
resolve();
},
);
});
}
/**
* Helper function to remove a directory recursively
*/
function removeDir(dirPath: string): void {
if (existsSync(dirPath)) {
const files = readdirSync(dirPath, { withFileTypes: true });
for (const file of files) {
const filePath = join(dirPath, file.name);
if (file.isDirectory()) {
removeDir(filePath);
} else {
unlinkSync(filePath);
}
}
rmdirSync(dirPath);
}
}
/**
* 執行 pdf2zh 命令進行 PDF 翻譯
*
* @param inputPath 輸入 PDF 路徑
* @param outputDir 輸出目錄
* @param targetLang 目標語言
* @param execFile 執行函數
*/
function runPdf2zh(
inputPath: string,
outputDir: string,
targetLang: string,
execFile: ExecFileFn,
): Promise<{ monoPath: string; dualPath: string }> {
return new Promise((resolve, reject) => {
// pdf2zh CLI 參數:
// -lo <lang>: 目標語言
// -o <dir>: 輸出目錄
// -s google: 使用 Google 翻譯(預設,免費)
//
// 輸出檔案:
// - <filename>-mono.pdf: 純翻譯版本
// - <filename>-dual.pdf: 雙語對照版本
const args = [
inputPath,
"-lo", targetLang,
"-o", outputDir,
"-s", process.env.PDFMATHTRANSLATE_SERVICE || "google",
];
// 如果設定了自訂模型路徑,使用 --onnx 參數
if (existsSync(MODELS_PATH)) {
const onnxModelPath = join(MODELS_PATH, "model.onnx");
if (existsSync(onnxModelPath)) {
args.push("--onnx", onnxModelPath);
}
}
console.log(`[PDFMathTranslate] Running: pdf2zh ${args.join(" ")}`);
execFile("pdf2zh", args, (error, stdout, stderr) => {
if (error) {
reject(`pdf2zh error: ${error}\nstderr: ${stderr}`);
return;
}
if (stdout) {
console.log(`[PDFMathTranslate] stdout: ${stdout}`);
}
if (stderr) {
console.log(`[PDFMathTranslate] stderr: ${stderr}`);
}
// 找到輸出的 PDF 檔案
const inputFileName = basename(inputPath, ".pdf");
const monoPath = join(outputDir, `${inputFileName}-mono.pdf`);
const dualPath = join(outputDir, `${inputFileName}-dual.pdf`);
// 檢查輸出檔案是否存在
if (!existsSync(monoPath) && !existsSync(dualPath)) {
// 嘗試在 outputDir 下尋找任何 PDF 檔案
const files = readdirSync(outputDir);
const pdfFiles = files.filter(f => f.endsWith(".pdf"));
console.log(`[PDFMathTranslate] Found PDF files: ${pdfFiles.join(", ")}`);
if (pdfFiles.length === 0) {
reject(`No output PDF files found in ${outputDir}`);
return;
}
}
resolve({ monoPath, dualPath });
});
});
}
/**
* 主要轉換函數
*
* @param filePath 輸入 PDF 檔案路徑
* @param fileType 檔案類型(應為 "pdf"
* @param convertTo 目標格式(如 "pdf-zh"
* @param targetPath 輸出路徑
* @param options 額外選項
* @param execFile 執行函數覆寫
*/
export async function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
// 1. 檢查模型(警告但不阻止,因為 pdf2zh 可能會自動下載)
checkModelsExist();
// 2. 提取目標語言
const targetLang = extractTargetLanguage(convertTo);
console.log(`[PDFMathTranslate] Translating to: ${targetLang}`);
// 3. 建立臨時輸出目錄
const outputDir = dirname(targetPath);
const inputFileName = basename(filePath, `.${fileType}`);
const tempDir = join(outputDir, `${inputFileName}_pdfmathtranslate_${Date.now()}`);
if (!existsSync(tempDir)) {
mkdirSync(tempDir, { recursive: true });
}
// 4. 建立封裝用目錄
const archiveDir = join(tempDir, "archive");
mkdirSync(archiveDir, { recursive: true });
// 5. 執行 pdf2zh 翻譯
const { monoPath, dualPath } = await runPdf2zh(filePath, tempDir, targetLang, execFile);
// 6. 複製原始檔案到封裝目錄
const originalDest = join(archiveDir, "original.pdf");
copyFileSync(filePath, originalDest);
// 7. 複製翻譯後的檔案(優先使用 mono因為它是純翻譯版本
const translatedDest = join(archiveDir, `translated-${targetLang}.pdf`);
// 檢查各種可能的輸出檔案名稱
const possibleOutputs = [
monoPath,
dualPath,
join(tempDir, `${inputFileName}-mono.pdf`),
join(tempDir, `${inputFileName}-dual.pdf`),
];
let foundOutput = false;
for (const outputPath of possibleOutputs) {
if (existsSync(outputPath)) {
copyFileSync(outputPath, translatedDest);
foundOutput = true;
console.log(`[PDFMathTranslate] Using output: ${outputPath}`);
break;
}
}
// 如果找不到預期的輸出檔案,嘗試找任何 PDF
if (!foundOutput) {
const allFiles = readdirSync(tempDir);
const pdfFiles = allFiles.filter(f => f.endsWith(".pdf") && f !== basename(filePath));
const firstPdfName = pdfFiles[0];
if (firstPdfName) {
const firstPdf = join(tempDir, firstPdfName);
copyFileSync(firstPdf, translatedDest);
foundOutput = true;
console.log(`[PDFMathTranslate] Using fallback output: ${firstPdf}`);
}
}
if (!foundOutput) {
throw new Error("No translated PDF output found");
}
// 8. 建立 .tar 封裝
const tarPath = getArchiveFileName(targetPath);
const tarDir = dirname(tarPath);
if (!existsSync(tarDir)) {
mkdirSync(tarDir, { recursive: true });
}
await createTarArchive(archiveDir, tarPath, execFile);
console.log(`[PDFMathTranslate] Created archive: ${tarPath}`);
// 9. 清理臨時目錄
removeDir(tempDir);
resolve("Done");
} catch (error) {
reject(`PDFMathTranslate error: ${error}`);
}
});
}