diff --git a/CHANGELOG.md b/CHANGELOG.md index 117412f..3cad6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## [0.1.8](https://github.com/pi-docket/ConvertX-CN/releases/tag/v0.1.8) (2026-01-20) + +### 🐛 Bug Fixes + +- **LibreOffice PDF→DOCX**: 修復 PDF 轉 DOCX 一定失敗的問題 + - 新增 PDF Import Pipeline:使用 `--infilter=writer_pdf_import` + - 分流邏輯:PDF→文字格式 vs 一般轉換 + - 支援 PDF→DOCX/ODT/RTF/TXT/HTML + +### 🛡️ Reliability + +- **輸出檔案驗證**: 新增 `existsSync` 檢查,確保轉換真的成功 +- **錯誤訊息優化**: 根據 stderr 內容提供有意義的中文錯誤訊息 + - 識別加密檔案、損壞檔案、缺少 filter 等情況 + - 避免 ENOENT 錯誤擴散 + +### 📝 Technical Notes + +- LibreOffice 架構說明: + - Export Pipeline:原生格式→導出格式(DOCX→PDF) + - Import Pipeline:非原生格式→原生格式(PDF→DOCX) + - PDF 作為輸入時必須使用 `writer_pdf_import` + +### ⚠️ Known Limitations + +以下情況仍會失敗(LibreOffice 限制): + +- 加密/密碼保護的 PDF/DOCX +- 損壞的檔案 +- 缺少必要字型(可能成功但版面錯亂) +- 並行大量轉檔(LibreOffice 對 concurrent instance 不友善) + +--- + ## [0.1.7](https://github.com/pi-docket/ConvertX-CN/releases/tag/v0.1.7) (2026-01-20) ### 🐛 Bug Fixes diff --git a/Dockerfile b/Dockerfile index 16b48e9..961251b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # ============================================================================== # ConvertX-CN 官方 Docker Image -# 版本:v0.1.7 +# 版本:v0.1.8 # ============================================================================== # # 📦 Image 說明: diff --git a/package.json b/package.json index e7abb7d..e78705f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "convertx-frontend", - "version": "0.1.7", + "version": "0.1.8", "scripts": { "dev": "bun run --watch src/index.tsx", "hot": "bun run --hot src/index.tsx", diff --git a/src/converters/libreoffice.ts b/src/converters/libreoffice.ts index 02f389a..148171d 100644 --- a/src/converters/libreoffice.ts +++ b/src/converters/libreoffice.ts @@ -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> = { 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> = { 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 { - 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}`; +} diff --git a/tests/converters/libreoffice.test.ts b/tests/converters/libreoffice.test.ts index 4d07f1e..96e04d0 100644 --- a/tests/converters/libreoffice.test.ts +++ b/tests/converters/libreoffice.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; -import { convert } from "../../src/converters/libreoffice"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { convert, type ExistsSyncFn } from "../../src/converters/libreoffice"; import type { ExecFileFn } from "../../src/converters/types"; function requireDefined(value: T, msg: string): NonNullable { @@ -13,16 +13,22 @@ let calls: Call[] = []; let behavior: | { kind: "success"; stdout?: string; stderr?: string } - | { kind: "error"; message?: string; stderr?: string } = { kind: "success" }; + | { kind: "error"; message?: string; stderr?: string; code?: string } = { kind: "success" }; + +// Mock fs.existsSync to control output file existence check +let mockFileExists = true; + +const mockExistsSync: ExistsSyncFn = () => mockFileExists; const mockExecFile: ExecFileFn = (cmd, args, cb) => { calls.push({ cmd, args }); if (behavior.kind === "error") { - cb(new Error(behavior.message ?? "mock failure"), "", behavior.stderr ?? ""); + const error = new Error(behavior.message ?? "mock failure") as Error & { code?: string }; + if (behavior.code) error.code = behavior.code; + cb(error, "", behavior.stderr ?? ""); } else { cb(null, behavior.stdout ?? "ok", behavior.stderr ?? ""); } - // We don't return a real ChildProcess in tests. return undefined; }; @@ -33,7 +39,6 @@ let errors: string[] = []; const originalLog = console.log; const originalError = console.error; -// Use Console["log"] for typing; avoids explicit `any` const makeSink = (sink: string[]): Console["log"] => (...data) => { @@ -43,6 +48,7 @@ const makeSink = beforeEach(() => { calls = []; behavior = { kind: "success" }; + mockFileExists = true; logs = []; errors = []; @@ -55,107 +61,214 @@ afterEach(() => { console.error = originalError; }); -// --- core behavior ----------------------------------------------------------- -test("invokes soffice with --headless and outdir derived from targetPath", async () => { - await convert("in.docx", "docx", "odt", "out/out.odt", undefined, mockExecFile); +// ============================================================================== +// PDF → DOCX (Import Pipeline) 測試 +// ============================================================================== +describe("PDF Import Pipeline", () => { + test("PDF → DOCX uses writer_pdf_import filter", async () => { + await convert("in.pdf", "pdf", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync); - const { cmd, args } = requireDefined(calls[0], "Expected at least one execFile call"); - expect(cmd).toBe("soffice"); - expect(args).toEqual([ - "--headless", - `--infilter="MS Word 2007 XML"`, - "--convert-to", - "odt:writer8", - "--outdir", - "out", - "in.docx", - ]); + const { cmd, args } = requireDefined(calls[0], "Expected at least one execFile call"); + expect(cmd).toBe("soffice"); + expect(args).toContain("--infilter=writer_pdf_import"); + expect(args).toContain("--convert-to"); + + // 驗證輸出格式包含正確的 filter + const convertToIdx = args.indexOf("--convert-to"); + expect(args[convertToIdx + 1]).toBe("docx:MS Word 2007 XML"); + }); + + test("PDF → ODT uses writer_pdf_import filter", async () => { + await convert("in.pdf", "pdf", "odt", "out/out.odt", undefined, mockExecFile, mockExistsSync); + + const { args } = requireDefined(calls[0], "Expected at least one execFile call"); + expect(args).toContain("--infilter=writer_pdf_import"); + + const convertToIdx = args.indexOf("--convert-to"); + expect(args[convertToIdx + 1]).toBe("odt:writer8"); + }); + + test("PDF → RTF uses writer_pdf_import filter", async () => { + await convert("in.pdf", "pdf", "rtf", "out/out.rtf", undefined, mockExecFile, mockExistsSync); + + const { args } = requireDefined(calls[0], "Expected at least one execFile call"); + expect(args).toContain("--infilter=writer_pdf_import"); + }); + + test("PDF → TXT uses writer_pdf_import filter", async () => { + await convert("in.pdf", "pdf", "txt", "out/out.txt", undefined, mockExecFile, mockExistsSync); + + const { args } = requireDefined(calls[0], "Expected at least one execFile call"); + expect(args).toContain("--infilter=writer_pdf_import"); + }); }); -test("uses only outFilter when input has no filter (e.g., pdf -> txt)", async () => { - await convert("in.pdf", "pdf", "txt", "out/out.txt", undefined, mockExecFile); +// ============================================================================== +// 一般轉換流程(Export Pipeline)測試 +// ============================================================================== +describe("Export Pipeline", () => { + test("DOCX → PDF uses normal export pipeline (no infilter)", async () => { + await convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync); - const { args } = requireDefined(calls[0], "Expected at least one execFile call"); + const { args } = requireDefined(calls[0], "Expected at least one execFile call"); - expect(args).not.toContainEqual(expect.stringMatching(/^--infilter=/)); - expect(args).toEqual(["--headless", "--convert-to", "txt", "--outdir", "out", "in.pdf"]); + // DOCX → PDF 不應該使用 PDF import filter + expect(args).not.toContain("--infilter=writer_pdf_import"); + expect(args).toContain("--convert-to"); + + const convertToIdx = args.indexOf("--convert-to"); + expect(args[convertToIdx + 1]).toBe("pdf:writer_pdf_Export"); + }); + + test("DOCX → ODT uses correct filters", async () => { + await convert("in.docx", "docx", "odt", "out/out.odt", undefined, mockExecFile, mockExistsSync); + + const { cmd, args } = requireDefined(calls[0], "Expected at least one execFile call"); + expect(cmd).toBe("soffice"); + + // 應該有 infilter 和 outfilter + expect(args).toContain("--infilter=MS Word 2007 XML"); + + const convertToIdx = args.indexOf("--convert-to"); + expect(args[convertToIdx + 1]).toBe("odt:writer8"); + }); + + test("ODT → PDF uses export pipeline", async () => { + await convert("in.odt", "odt", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync); + + const { args } = requireDefined(calls[0], "Expected at least one execFile call"); + expect(args).not.toContain("--infilter=writer_pdf_import"); + }); }); -test("uses only infilter when convertTo has no out filter (e.g., docx -> pdf)", async () => { - await convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile); +// ============================================================================== +// 輸出檔案驗證測試 +// ============================================================================== +describe("Output File Verification", () => { + test("resolves with 'Done' when output file exists", async () => { + mockFileExists = true; + behavior = { kind: "success", stdout: "Conversion completed", stderr: "" }; - const { args } = requireDefined(calls[0], "Expected at least one execFile call"); + await expect( + convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync), + ).resolves.toBe("Done"); + }); - // If docx has an infilter, it should be present - expect(args).toEqual(["--headless", "--convert-to", "pdf", "--outdir", "out", "in.docx"]); + test("rejects when output file does not exist", async () => { + mockFileExists = false; + behavior = { kind: "success", stdout: "", stderr: "" }; - const i = args.indexOf("--convert-to"); - expect(i).toBeGreaterThanOrEqual(0); - expect(args[i + 1]).toBe("pdf"); - expect(args.slice(-2)).toEqual(["out", "in.docx"]); + await expect( + convert("in.pdf", "pdf", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync), + ).rejects.toMatch(/輸出檔案不存在/); + }); + + test("rejects when execFile returns an error", async () => { + behavior = { kind: "error", message: "convert failed", stderr: "oops" }; + + await expect( + convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync), + ).rejects.toMatch(/LibreOffice 轉換失敗/); + }); }); -test("strips leading './' from outdir", async () => { - await convert("in.txt", "txt", "docx", "./out/out.docx", undefined, mockExecFile); +// ============================================================================== +// 錯誤訊息測試 +// ============================================================================== +describe("Error Messages", () => { + test("provides helpful message for 'no export filter' error", async () => { + behavior = { kind: "error", message: "failed", stderr: "Error: no export filter for this" }; - const { args } = requireDefined(calls[0], "Expected at least one execFile call"); + await expect( + convert("in.pdf", "pdf", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync), + ).rejects.toMatch(/找不到 export filter/); + }); - const outDirIdx = args.indexOf("--outdir"); - expect(outDirIdx).toBeGreaterThanOrEqual(0); - expect(args[outDirIdx + 1]).toBe("out"); + test("provides helpful message for password-protected files", async () => { + behavior = { kind: "error", message: "failed", stderr: "password required" }; + + await expect( + convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync), + ).rejects.toMatch(/加密|密碼/); + }); + + test("provides helpful message for corrupt files", async () => { + behavior = { kind: "error", message: "failed", stderr: "file is corrupt" }; + + await expect( + convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync), + ).rejects.toMatch(/損壞|無效/); + }); + + test("provides helpful message when soffice not found", async () => { + behavior = { kind: "error", message: "ENOENT", stderr: "", code: "ENOENT" }; + + await expect( + convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync), + ).rejects.toMatch(/找不到 soffice/); + }); }); -// --- promise settlement ------------------------------------------------------ -test("resolves with 'Done' when execFile succeeds", async () => { - behavior = { kind: "success", stdout: "fine", stderr: "" }; - await expect( - convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile), - ).resolves.toBe("Done"); +// ============================================================================== +// 路徑處理測試 +// ============================================================================== +describe("Path Handling", () => { + test("strips leading './' from outdir", async () => { + await convert( + "in.txt", + "txt", + "docx", + "./out/out.docx", + undefined, + mockExecFile, + mockExistsSync, + ); + + const { args } = requireDefined(calls[0], "Expected at least one execFile call"); + + const outDirIdx = args.indexOf("--outdir"); + expect(outDirIdx).toBeGreaterThanOrEqual(0); + expect(args[outDirIdx + 1]).toBe("out"); + }); + + test("handles nested output directories", async () => { + await convert( + "in.txt", + "txt", + "docx", + "a/b/c/out.docx", + undefined, + mockExecFile, + mockExistsSync, + ); + + const { args } = requireDefined(calls[0], "Expected at least one execFile call"); + + const outDirIdx = args.indexOf("--outdir"); + expect(args[outDirIdx + 1]).toBe("a/b/c"); + }); }); -test("rejects when execFile returns an error", async () => { - behavior = { kind: "error", message: "convert failed", stderr: "oops" }; - await expect( - convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile), - ).rejects.toMatch(/error: Error: convert failed/); -}); - -// --- logging behavior -------------------------------------------------------- -test("logs stdout when present", async () => { - behavior = { kind: "success", stdout: "hello", stderr: "" }; - - await convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile); - - expect(logs).toContain("stdout: hello"); - expect(errors).toHaveLength(0); -}); - -test("logs stderr when present", async () => { - behavior = { kind: "success", stdout: "", stderr: "uh-oh" }; - - await convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile); - - expect(errors).toContain("stderr: uh-oh"); - // When stdout is empty, no stdout log - expect(logs.find((l) => l.startsWith("stdout:"))).toBeUndefined(); -}); - -test("logs both stdout and stderr when both are present", async () => { - behavior = { kind: "success", stdout: "alpha", stderr: "beta" }; - - await convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile); - - expect(logs).toContain("stdout: alpha"); - expect(errors).toContain("stderr: beta"); -}); - -test("logs stderr on exec error as well", async () => { - behavior = { kind: "error", message: "boom", stderr: "EPIPE" }; - - expect(convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile)).rejects.toMatch( - /error: Error: boom/, - ); - - // The callback still provided stderr; your implementation logs it before settling - expect(errors).toContain("stderr: EPIPE"); +// ============================================================================== +// 日誌測試 +// ============================================================================== +describe("Logging", () => { + test("logs command being executed", async () => { + await convert("in.pdf", "pdf", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync); + + expect(logs.some((l) => l.includes("[LibreOffice] Command:"))).toBe(true); + }); + + test("logs when using PDF import pipeline", async () => { + await convert("in.pdf", "pdf", "docx", "out/out.docx", undefined, mockExecFile, mockExistsSync); + + expect(logs.some((l) => l.includes("PDF import pipeline"))).toBe(true); + }); + + test("logs successful conversion", async () => { + mockFileExists = true; + await convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile, mockExistsSync); + + expect(logs.some((l) => l.includes("Successfully created"))).toBe(true); + }); });