fix(libreoffice): PDF 轉 DOCX 修正 (v0.1.8)
- 新增 PDF 匯入管線:PDF → DOCX/ODT/RTF/TXT/HTML 使用 --infilter=writer_pdf_import - 新增輸出檔案存在性驗證,避免 ENOENT 錯誤 - 改善錯誤訊息:密碼保護、檔案損壞、無匯出過濾器等情境 - 重寫測試套件:19 個測試全數通過
This commit is contained in:
parent
03be03bbc2
commit
a0eccf0437
5 changed files with 369 additions and 107 deletions
|
|
@ -1,6 +1,35 @@
|
|||
import { execFile as execFileOriginal } from "node:child_process";
|
||||
import { existsSync as existsSyncOriginal } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { ExecFileFn } from "./types";
|
||||
|
||||
// ==============================================================================
|
||||
// LibreOffice 轉換器
|
||||
// ==============================================================================
|
||||
//
|
||||
// 🔑 關鍵技術說明:
|
||||
//
|
||||
// LibreOffice 有兩種轉換模式:
|
||||
//
|
||||
// 1. Export Pipeline(輸出轉換)
|
||||
// - 用於:DOCX → PDF, ODT → PDF 等
|
||||
// - 原生格式 → 導出格式
|
||||
// - 使用 --convert-to 參數
|
||||
//
|
||||
// 2. Import Pipeline(輸入轉換)
|
||||
// - 用於:PDF → DOCX, PDF → ODT 等
|
||||
// - 非原生格式 → 原生格式
|
||||
// - 必須使用 --infilter 參數指定 import filter
|
||||
//
|
||||
// ⚠️ 常見錯誤:
|
||||
// PDF → DOCX 若不指定 --infilter=writer_pdf_import
|
||||
// 會得到 "no export filter" 錯誤
|
||||
//
|
||||
// ==============================================================================
|
||||
|
||||
// 用於測試的依賴注入類型
|
||||
export type ExistsSyncFn = (path: string) => boolean;
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
text: [
|
||||
|
|
@ -102,7 +131,7 @@ const filters: Record<FileCategories, Record<string, string>> = {
|
|||
odt: "writer8",
|
||||
ott: "writer8_template",
|
||||
pages: "Apple Pages",
|
||||
// pdf: "writer_pdf_import",
|
||||
pdf: "writer_pdf_Export", // PDF 作為輸出格式
|
||||
psw: "PocketWord File",
|
||||
rtf: "Rich Text Format",
|
||||
sdw: "StarOffice_Writer",
|
||||
|
|
@ -123,6 +152,16 @@ const filters: Record<FileCategories, Record<string, string>> = {
|
|||
calc: {},
|
||||
};
|
||||
|
||||
// ==============================================================================
|
||||
// PDF Import Filter(PDF 作為輸入格式時使用)
|
||||
// ==============================================================================
|
||||
const PDF_IMPORT_FILTER = "writer_pdf_import";
|
||||
|
||||
// 需要使用 PDF import pipeline 的情況
|
||||
function needsPdfImportPipeline(inputExt: string, outputExt: string): boolean {
|
||||
return inputExt === "pdf" && ["docx", "doc", "odt", "rtf", "txt", "html"].includes(outputExt);
|
||||
}
|
||||
|
||||
const getFilters = (fileType: string, converto: string) => {
|
||||
if (fileType in filters.text && converto in filters.text) {
|
||||
return [filters.text[fileType], filters.text[converto]];
|
||||
|
|
@ -139,39 +178,115 @@ export function convert(
|
|||
targetPath: string,
|
||||
options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal,
|
||||
existsSync: ExistsSyncFn = existsSyncOriginal,
|
||||
): Promise<string> {
|
||||
const outputPath = targetPath.split("/").slice(0, -1).join("/").replace("./", "") ?? targetPath;
|
||||
const outputDir = dirname(targetPath).replace("./", "") || ".";
|
||||
const inputFileName = basename(filePath);
|
||||
const inputBaseName = inputFileName.replace(/\.[^.]+$/, "");
|
||||
const expectedOutputFile = join(outputDir, `${inputBaseName}.${convertTo}`);
|
||||
|
||||
// Build arguments array
|
||||
const args: string[] = [];
|
||||
args.push("--headless");
|
||||
const [inFilter, outFilter] = getFilters(fileType, convertTo);
|
||||
|
||||
if (inFilter) {
|
||||
args.push(`--infilter="${inFilter}"`);
|
||||
}
|
||||
// ==============================================================================
|
||||
// 關鍵分流:PDF → 文字格式 vs 其他轉換
|
||||
// ==============================================================================
|
||||
if (needsPdfImportPipeline(fileType, convertTo)) {
|
||||
// PDF → DOCX/ODT 等:必須使用 import pipeline
|
||||
console.log(`[LibreOffice] Using PDF import pipeline: ${fileType} → ${convertTo}`);
|
||||
args.push(`--infilter=${PDF_IMPORT_FILTER}`);
|
||||
|
||||
if (outFilter) {
|
||||
args.push("--convert-to", `${convertTo}:${outFilter}`, "--outdir", outputPath, filePath);
|
||||
// 輸出格式仍需指定 filter
|
||||
const outFilter = filters.text[convertTo];
|
||||
if (outFilter && convertTo !== "pdf") {
|
||||
args.push("--convert-to", `${convertTo}:${outFilter}`);
|
||||
} else {
|
||||
args.push("--convert-to", convertTo);
|
||||
}
|
||||
} else {
|
||||
args.push("--convert-to", convertTo, "--outdir", outputPath, filePath);
|
||||
// 一般轉換流程(export pipeline)
|
||||
const [inFilter, outFilter] = getFilters(fileType, convertTo);
|
||||
|
||||
if (inFilter && fileType !== "pdf") {
|
||||
args.push(`--infilter=${inFilter}`);
|
||||
}
|
||||
|
||||
if (outFilter) {
|
||||
args.push("--convert-to", `${convertTo}:${outFilter}`);
|
||||
} else {
|
||||
args.push("--convert-to", convertTo);
|
||||
}
|
||||
}
|
||||
|
||||
args.push("--outdir", outputDir, filePath);
|
||||
|
||||
console.log(`[LibreOffice] Command: soffice ${args.join(" ")}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile("soffice", args, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(`error: ${error}`);
|
||||
}
|
||||
// ==============================================================================
|
||||
// 錯誤處理與輸出檔案驗證
|
||||
// ==============================================================================
|
||||
|
||||
if (stdout) {
|
||||
console.log(`stdout: ${stdout}`);
|
||||
console.log(`[LibreOffice] stdout: ${stdout}`);
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.error(`stderr: ${stderr}`);
|
||||
console.error(`[LibreOffice] stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
// 檢查 LibreOffice 執行錯誤
|
||||
if (error) {
|
||||
const errorMsg = getLibreOfficeErrorMessage(error, stderr);
|
||||
console.error(`[LibreOffice] Error: ${errorMsg}`);
|
||||
reject(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// 關鍵防呆:檢查輸出檔案是否實際存在
|
||||
// ==============================================================================
|
||||
if (!existsSync(expectedOutputFile)) {
|
||||
const errorMsg = `LibreOffice 轉換失敗:輸出檔案不存在 (${expectedOutputFile})。可能原因:1) 輸入檔案損壞或加密 2) 缺少必要字型 3) 格式不支援`;
|
||||
console.error(`[LibreOffice] ${errorMsg}`);
|
||||
reject(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[LibreOffice] Successfully created: ${expectedOutputFile}`);
|
||||
resolve("Done");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// 錯誤訊息解析
|
||||
// ==============================================================================
|
||||
function getLibreOfficeErrorMessage(
|
||||
error: Error & { code?: number | string },
|
||||
stderr: string,
|
||||
): string {
|
||||
const stderrLower = stderr.toLowerCase();
|
||||
|
||||
// 常見錯誤類型判斷
|
||||
if (stderrLower.includes("no export filter")) {
|
||||
return "LibreOffice 錯誤:找不到 export filter。可能是格式不支援或需要使用不同的轉換路徑。";
|
||||
}
|
||||
|
||||
if (stderrLower.includes("password") || stderrLower.includes("encrypted")) {
|
||||
return "LibreOffice 錯誤:檔案已加密或需要密碼。請先解除密碼保護。";
|
||||
}
|
||||
|
||||
if (stderrLower.includes("corrupt") || stderrLower.includes("damaged")) {
|
||||
return "LibreOffice 錯誤:檔案已損壞或格式無效。";
|
||||
}
|
||||
|
||||
if (error.code === "ENOENT") {
|
||||
return "LibreOffice 錯誤:找不到 soffice 執行檔。請確認 LibreOffice 已正確安裝。";
|
||||
}
|
||||
|
||||
// 通用錯誤
|
||||
return `LibreOffice 轉換失敗 (exit code: ${error.code || "unknown"}): ${stderr || error.message}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue