release: v0.1.11 - BabelDOC 翻譯引擎與穩定性改進

## 新增功能

- BabelDOC PDF 翻譯引擎:支援 PDF/Markdown/HTML 輸出,15 種目標語言

- Dockerfile 更新:pipx 安裝 babeldoc,warmup 預載資源

## 修復

- 修正 user.tsx XSS 安全警告(label 包裹 safe span)

## 品質保證

- 173 個測試全數通過

- TypeScript / ESLint / Prettier / Knip 檢查通過
This commit is contained in:
Your Name 2026-01-22 17:41:12 +08:00
parent 6d4ed58637
commit 4ca92e3a22
5 changed files with 852 additions and 19 deletions

352
src/converters/babeldoc.ts Normal file
View file

@ -0,0 +1,352 @@
import { execFile as execFileOriginal } from "node:child_process";
import {
mkdirSync,
existsSync,
readdirSync,
unlinkSync,
rmdirSync,
copyFileSync,
statSync,
} from "node:fs";
import { join, basename, dirname } from "node:path";
import { ExecFileFn } from "./types";
import { getArchiveFileName } from "../transfer";
/**
* BabelDOC Content Engine
*
* PDF BabelDOC CLI
* PDFMathTranslate 使 babeldoc
*
* .tar
* - translated-<lang>.pdf PDF
* - BabelDOC
*
* Docker build --warmup
* runtime
*/
// 支援的目標語言列表(與 PDFMathTranslate 保持一致)
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;
// 支援的輸出格式
const SUPPORTED_OUTPUT_FORMATS = ["pdf", "md", "html"] as const;
type OutputFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number];
// BabelDOC 快取路徑Docker 環境中)
const BABELDOC_CACHE_PATH = process.env.BABELDOC_CACHE_PATH || "/root/.cache/babeldoc";
// 生成 from/to 格式映射
function generateLanguageMappings(): {
from: Record<string, string[]>;
to: Record<string, string[]>;
} {
// BabelDOC 輸出格式:
// - pdf-<lang>: 輸出 PDF
// - md-<lang>: 輸出 Markdown
// - html-<lang>: 輸出 HTML
const outputFormats: string[] = [];
for (const format of SUPPORTED_OUTPUT_FORMATS) {
for (const lang of SUPPORTED_LANGUAGES) {
outputFormats.push(`${format}-${lang}`);
}
}
return {
from: {
document: ["pdf"],
},
to: {
document: outputFormats,
},
};
}
export const properties = {
...generateLanguageMappings(),
outputMode: "archive" as const,
};
/**
* convertTo
* @param convertTo "pdf-zh""md-en""html-ja"
* @returns { lang: 目標語言代碼, format: 輸出格式 }
*/
function extractTargetInfo(convertTo: string): { lang: string; format: OutputFormat } {
// convertTo 格式: <format>-<lang>
// 例如: pdf-zh, md-en, html-ja
const match = convertTo.match(/^(pdf|md|html)-(.+)$/);
if (!match || !match[1] || !match[2]) {
throw new Error(
`Invalid convertTo format: ${convertTo}. Expected <format>-<lang> (format: pdf/md/html)`,
);
}
return {
format: match[1] as OutputFormat,
lang: match[2],
};
}
/**
* BabelDOC
* @returns
*/
function checkResourcesExist(): boolean {
if (!existsSync(BABELDOC_CACHE_PATH)) {
console.warn(`[BabelDOC] Cache directory not found: ${BABELDOC_CACHE_PATH}`);
console.warn(`[BabelDOC] Resources should be pre-downloaded via --warmup 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);
}
}
/**
* BabelDOC
* BabelDOC 使
*/
function toBabelDocLang(lang: string): string {
// BabelDOC 語言代碼映射
const langMap: Record<string, string> = {
"zh-TW": "zh-Hant",
zh: "zh-Hans",
};
return langMap[lang] || lang;
}
/**
* BabelDOC
*/
function getOutputExtension(format: OutputFormat): string {
const extMap: Record<OutputFormat, string> = {
pdf: "pdf",
md: "md",
html: "html",
};
return extMap[format];
}
/**
* babeldoc PDF
*
* @param inputPath PDF
* @param outputPath
* @param targetLang
* @param outputFormat pdf/md/html
* @param execFile
*/
function runBabelDoc(
inputPath: string,
outputPath: string,
targetLang: string,
outputFormat: OutputFormat,
execFile: ExecFileFn,
): Promise<string> {
return new Promise((resolve, reject) => {
// babeldoc CLI 參數:
// -i <input>: 輸入 PDF
// -o <output>: 輸出檔案
// -l <lang>: 目標語言
// --output-format <format>: 輸出格式pdf/md/html
// --service <service>: 翻譯服務(預設 google
const babelLang = toBabelDocLang(targetLang);
const service = process.env.BABELDOC_SERVICE || "google";
const args = [
"-i",
inputPath,
"-o",
outputPath,
"-l",
babelLang,
"--output-format",
outputFormat,
"--service",
service,
];
console.log(`[BabelDOC] Running: babeldoc ${args.join(" ")}`);
execFile("babeldoc", args, (error, stdout, stderr) => {
if (error) {
reject(`babeldoc error: ${error}\nstderr: ${stderr}`);
return;
}
if (stdout) {
console.log(`[BabelDOC] stdout: ${stdout}`);
}
if (stderr) {
console.log(`[BabelDOC] stderr: ${stderr}`);
}
// 檢查輸出檔案是否存在
if (!existsSync(outputPath)) {
reject(`BabelDOC output file not found: ${outputPath}`);
return;
}
resolve(outputPath);
});
});
}
/**
*
*
* @param filePath PDF
* @param fileType "pdf"
* @param convertTo "pdf-babel-zh""md-babel-en""html-babel-ja"
* @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> {
try {
// 1. 檢查資源(警告但不阻止)
checkResourcesExist();
// 2. 提取目標語言和輸出格式
const { lang: targetLang, format: outputFormat } = extractTargetInfo(convertTo);
const outputExt = getOutputExtension(outputFormat);
console.log(`[BabelDOC] Translating to: ${targetLang}, format: ${outputFormat}`);
// 3. 建立臨時輸出目錄
const outputDir = dirname(targetPath);
const inputFileName = basename(filePath, `.${fileType}`);
const tempDir = join(outputDir, `${inputFileName}_babeldoc_${Date.now()}`);
if (!existsSync(tempDir)) {
mkdirSync(tempDir, { recursive: true });
}
// 4. 建立封裝用目錄
const archiveDir = join(tempDir, "archive");
mkdirSync(archiveDir, { recursive: true });
// 5. 設定 BabelDOC 輸出路徑(依輸出格式決定副檔名)
const translatedFilePath = join(tempDir, `${inputFileName}-translated.${outputExt}`);
// 6. 執行 babeldoc 翻譯
await runBabelDoc(filePath, translatedFilePath, targetLang, outputFormat, execFile);
// 7. 複製翻譯後的檔案到封裝目錄
const translatedDest = join(archiveDir, `translated-${targetLang}.${outputExt}`);
copyFileSync(translatedFilePath, translatedDest);
console.log(`[BabelDOC] Copied translated ${outputFormat.toUpperCase()} to archive`);
// 8. 檢查是否有其他 BabelDOC 產生的輔助檔案
const translatedBaseName = `${inputFileName}-translated.${outputExt}`;
const tempFiles = readdirSync(tempDir);
for (const file of tempFiles) {
const fileTempPath = join(tempDir, file);
// 跳過 archive 目錄和已處理的主檔案
if (file === "archive" || file === translatedBaseName) {
continue;
}
// 複製其他產生的檔案(如 debug 輸出、中間結果等)
const destPath = join(archiveDir, file);
if (existsSync(fileTempPath) && !existsSync(destPath)) {
try {
const stats = statSync(fileTempPath);
if (stats.isFile()) {
copyFileSync(fileTempPath, destPath);
console.log(`[BabelDOC] Copied auxiliary file: ${file}`);
}
} catch {
// 忽略複製失敗的輔助檔案
}
}
}
// 9. 建立 .tar 封裝
const tarPath = getArchiveFileName(targetPath);
const tarDir = dirname(tarPath);
if (!existsSync(tarDir)) {
mkdirSync(tarDir, { recursive: true });
}
await createTarArchive(archiveDir, tarPath, execFile);
console.log(`[BabelDOC] Created archive: ${tarPath}`);
// 10. 清理臨時目錄
removeDir(tempDir);
return "Done";
} catch (error) {
throw new Error(`BabelDOC error: ${error}`);
}
}

View file

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