From 26304a295e2fff13f2cd2a08c3757fbb2bd8ce9e Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Jan 2026 00:01:24 +0800 Subject: [PATCH] fix(inkscape): use headless-safe export syntax (--export-type/--export-filename) Breaking change from Inkscape 1.0+: - Old syntax: inkscape input.png -o output.svg (triggers GTK init) - New syntax: inkscape input.png --export-type=svg --export-filename=output.svg This is the correct headless-safe approach, no need for xvfb. Ref: https://inkscape.org/doc/inkscape-man.html --- Dockerfile | 3 +- src/converters/inkscape.ts | 62 +++++++++------------------- tests/converters/inkscape.test.ts | 67 ++++++++++++++----------------- 3 files changed, 52 insertions(+), 80 deletions(-) diff --git a/Dockerfile b/Dockerfile index 18ef063..1066812 100644 --- a/Dockerfile +++ b/Dockerfile @@ -167,14 +167,13 @@ RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \ # 階段 4:圖像處理工具 # 注意:bookworm 使用 imagemagick(版本 6),trixie 才有 imagemagick-7 -# 注意:xvfb 用於 Inkscape 在 headless 環境運行(需要虛擬 X11 顯示器) +# 注意:Inkscape 1.0+ 使用 --export-type/--export-filename 語法,支援 headless 執行,不需要 xvfb RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \ imagemagick \ inkscape \ libheif-examples \ libjxl-tools \ libvips-tools \ - xvfb \ && rm -rf /var/lib/apt/lists/* # 階段 5:文件處理工具 diff --git a/src/converters/inkscape.ts b/src/converters/inkscape.ts index 919b7ca..1e4e48d 100644 --- a/src/converters/inkscape.ts +++ b/src/converters/inkscape.ts @@ -1,4 +1,5 @@ import { execFile as execFileOriginal } from "node:child_process"; +import { extname } from "node:path"; import { ExecFileFn } from "./types"; export const properties = { @@ -32,12 +33,16 @@ export const properties = { * Inkscape 轉換器 * * ⚠️ Headless 環境注意事項: - * Inkscape 需要 X11 顯示器連線。在 Docker/Server 環境中, - * 必須使用 xvfb-run 建立虛擬顯示器。 + * Inkscape 1.0+ 的新版命令列語法支援 headless 執行, + * 不需要 X11 或 xvfb。 * - * 🔧 解決方案: - * 1. 優先使用 xvfb-run(虛擬 X11) - * 2. 若 xvfb-run 失敗,嘗試 GDK_BACKEND=svg(純 SVG 後端) + * 🔧 正確的 headless-safe 語法: + * inkscape input.png --export-type=svg --export-filename=output.svg + * + * ❌ 舊版語法(會觸發 GTK 初始化): + * inkscape input.png -o output.svg + * + * 參考:https://inkscape.org/doc/inkscape-man.html */ export function convert( filePath: string, @@ -48,45 +53,18 @@ export function convert( execFile: ExecFileFn = execFileOriginal, // to make it mockable ): Promise { return new Promise((resolve, reject) => { - // 使用 xvfb-run 執行 Inkscape(建立虛擬 X11 顯示器) - // -a: 自動尋找可用的 display number - // --server-args: 設定虛擬螢幕解析度 - const xvfbArgs = [ - "-a", - "--server-args=-screen 0 1024x768x24", - "inkscape", - filePath, - "-o", - targetPath, - ]; + // 從目標路徑取得輸出格式(移除開頭的點) + const exportType = extname(targetPath).slice(1).toLowerCase(); - execFile("xvfb-run", xvfbArgs, (error: Error | null, stdout: string, stderr: string) => { + // 使用 Inkscape 1.0+ 的 headless-safe 命令列語法 + // --export-type: 明確指定輸出格式 + // --export-filename: 指定輸出檔案路徑 + // 這種語法不會初始化 GTK,因此在無 DISPLAY 的環境也能運作 + const args = [filePath, `--export-type=${exportType}`, `--export-filename=${targetPath}`]; + + execFile("inkscape", args, (error: Error | null, stdout: string, stderr: string) => { if (error) { - // xvfb-run 失敗,回退到直接執行 inkscape - // 這可能在某些已有 DISPLAY 設定的環境中運作 - console.log("[Inkscape] xvfb-run failed, trying direct inkscape execution..."); - console.log(`[Inkscape] Original error: ${error.message}`); - - execFile( - "inkscape", - [filePath, "-o", targetPath], - (error2: Error | null, stdout2: string, stderr2: string) => { - if (error2) { - reject(`error: ${error2}`); - return; - } - - if (stdout2) { - console.log(`stdout: ${stdout2}`); - } - - if (stderr2) { - console.error(`stderr: ${stderr2}`); - } - - resolve("Done"); - }, - ); + reject(`error: ${error}`); return; } diff --git a/tests/converters/inkscape.test.ts b/tests/converters/inkscape.test.ts index 114293c..330dd34 100644 --- a/tests/converters/inkscape.test.ts +++ b/tests/converters/inkscape.test.ts @@ -3,17 +3,37 @@ import { convert } from "../../src/converters/inkscape"; import type { ExecFileException } from "node:child_process"; import { ExecFileFn } from "../../src/converters/types"; -// Inkscape 使用 xvfb-run 包裝,需要自訂測試 -// 模擬 xvfb-run 成功執行的情況 +// Inkscape 測試 +// 使用 Inkscape 1.0+ 的 headless-safe 命令列語法: +// inkscape input.png --export-type=svg --export-filename=output.svg -test("convert resolves when xvfb-run succeeds", async () => { +test("convert uses correct headless-safe arguments", async () => { + let capturedCmd = ""; + let capturedArgs: string[] = []; + + const mockExecFile: ExecFileFn = ( + cmd: string, + args: string[], + callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, + ) => { + capturedCmd = cmd; + capturedArgs = args; + callback(null, "Conversion complete", ""); + }; + + await convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile); + + expect(capturedCmd).toBe("inkscape"); + expect(capturedArgs).toEqual(["input.svg", "--export-type=png", "--export-filename=output.png"]); +}); + +test("convert resolves when inkscape succeeds", async () => { const mockExecFile: ExecFileFn = ( cmd: string, _args: string[], callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, ) => { - // xvfb-run 成功 - if (cmd === "xvfb-run") { + if (cmd === "inkscape") { callback(null, "Conversion complete", ""); } }; @@ -22,38 +42,13 @@ test("convert resolves when xvfb-run succeeds", async () => { expect(result).toBe("Done"); }); -test("convert falls back to direct inkscape when xvfb-run fails", async () => { - let callCount = 0; - +test("convert rejects when inkscape fails", async () => { const mockExecFile: ExecFileFn = ( cmd: string, _args: string[], callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, ) => { - callCount++; - if (cmd === "xvfb-run") { - // xvfb-run 失敗 - callback(new Error("xvfb-run not found"), "", ""); - } else if (cmd === "inkscape") { - // 直接呼叫 inkscape 成功 - callback(null, "Direct inkscape success", ""); - } - }; - - const result = await convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile); - expect(result).toBe("Done"); - expect(callCount).toBe(2); // 應該呼叫兩次(xvfb-run + inkscape) -}); - -test("convert rejects when both xvfb-run and inkscape fail", async () => { - const mockExecFile: ExecFileFn = ( - cmd: string, - _args: string[], - callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, - ) => { - if (cmd === "xvfb-run") { - callback(new Error("xvfb-run failed"), "", ""); - } else if (cmd === "inkscape") { + if (cmd === "inkscape") { callback(new Error("inkscape failed"), "", ""); } }; @@ -77,7 +72,7 @@ test("convert logs stdout when present", async () => { _args: string[], callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, ) => { - if (cmd === "xvfb-run") { + if (cmd === "inkscape") { callback(null, "Fake stdout", ""); } }; @@ -102,16 +97,16 @@ test("convert logs stderr when present (non-fatal warning)", async () => { _args: string[], callback: (err: ExecFileException | null, stdout: string, stderr: string) => void, ) => { - if (cmd === "xvfb-run") { + if (cmd === "inkscape") { // Inkscape 經常輸出警告到 stderr,但轉換仍成功 - callback(null, "", "Gtk-WARNING: some warning"); + callback(null, "", "Some warning message"); } }; await convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile); console.log = originalConsoleLog; - expect(loggedMessage).toBe("stderr: Gtk-WARNING: some warning"); + expect(loggedMessage).toBe("stderr: Some warning message"); }); test.skip("dummy - required to trigger test detection", () => {});