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
This commit is contained in:
parent
b5992a84e0
commit
26304a295e
3 changed files with 52 additions and 80 deletions
|
|
@ -167,14 +167,13 @@ RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \
|
||||||
|
|
||||||
# 階段 4:圖像處理工具
|
# 階段 4:圖像處理工具
|
||||||
# 注意:bookworm 使用 imagemagick(版本 6),trixie 才有 imagemagick-7
|
# 注意: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 \
|
RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
inkscape \
|
inkscape \
|
||||||
libheif-examples \
|
libheif-examples \
|
||||||
libjxl-tools \
|
libjxl-tools \
|
||||||
libvips-tools \
|
libvips-tools \
|
||||||
xvfb \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# 階段 5:文件處理工具
|
# 階段 5:文件處理工具
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { execFile as execFileOriginal } from "node:child_process";
|
import { execFile as execFileOriginal } from "node:child_process";
|
||||||
|
import { extname } from "node:path";
|
||||||
import { ExecFileFn } from "./types";
|
import { ExecFileFn } from "./types";
|
||||||
|
|
||||||
export const properties = {
|
export const properties = {
|
||||||
|
|
@ -32,12 +33,16 @@ export const properties = {
|
||||||
* Inkscape 轉換器
|
* Inkscape 轉換器
|
||||||
*
|
*
|
||||||
* ⚠️ Headless 環境注意事項:
|
* ⚠️ Headless 環境注意事項:
|
||||||
* Inkscape 需要 X11 顯示器連線。在 Docker/Server 環境中,
|
* Inkscape 1.0+ 的新版命令列語法支援 headless 執行,
|
||||||
* 必須使用 xvfb-run 建立虛擬顯示器。
|
* 不需要 X11 或 xvfb。
|
||||||
*
|
*
|
||||||
* 🔧 解決方案:
|
* 🔧 正確的 headless-safe 語法:
|
||||||
* 1. 優先使用 xvfb-run(虛擬 X11)
|
* inkscape input.png --export-type=svg --export-filename=output.svg
|
||||||
* 2. 若 xvfb-run 失敗,嘗試 GDK_BACKEND=svg(純 SVG 後端)
|
*
|
||||||
|
* ❌ 舊版語法(會觸發 GTK 初始化):
|
||||||
|
* inkscape input.png -o output.svg
|
||||||
|
*
|
||||||
|
* 參考:https://inkscape.org/doc/inkscape-man.html
|
||||||
*/
|
*/
|
||||||
export function convert(
|
export function convert(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
|
@ -48,45 +53,18 @@ export function convert(
|
||||||
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
execFile: ExecFileFn = execFileOriginal, // to make it mockable
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 使用 xvfb-run 執行 Inkscape(建立虛擬 X11 顯示器)
|
// 從目標路徑取得輸出格式(移除開頭的點)
|
||||||
// -a: 自動尋找可用的 display number
|
const exportType = extname(targetPath).slice(1).toLowerCase();
|
||||||
// --server-args: 設定虛擬螢幕解析度
|
|
||||||
const xvfbArgs = [
|
|
||||||
"-a",
|
|
||||||
"--server-args=-screen 0 1024x768x24",
|
|
||||||
"inkscape",
|
|
||||||
filePath,
|
|
||||||
"-o",
|
|
||||||
targetPath,
|
|
||||||
];
|
|
||||||
|
|
||||||
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) {
|
if (error) {
|
||||||
// xvfb-run 失敗,回退到直接執行 inkscape
|
reject(`error: ${error}`);
|
||||||
// 這可能在某些已有 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");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,37 @@ import { convert } from "../../src/converters/inkscape";
|
||||||
import type { ExecFileException } from "node:child_process";
|
import type { ExecFileException } from "node:child_process";
|
||||||
import { ExecFileFn } from "../../src/converters/types";
|
import { ExecFileFn } from "../../src/converters/types";
|
||||||
|
|
||||||
// Inkscape 使用 xvfb-run 包裝,需要自訂測試
|
// Inkscape 測試
|
||||||
// 模擬 xvfb-run 成功執行的情況
|
// 使用 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 = (
|
const mockExecFile: ExecFileFn = (
|
||||||
cmd: string,
|
cmd: string,
|
||||||
_args: string[],
|
_args: string[],
|
||||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
) => {
|
) => {
|
||||||
// xvfb-run 成功
|
if (cmd === "inkscape") {
|
||||||
if (cmd === "xvfb-run") {
|
|
||||||
callback(null, "Conversion complete", "");
|
callback(null, "Conversion complete", "");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -22,38 +42,13 @@ test("convert resolves when xvfb-run succeeds", async () => {
|
||||||
expect(result).toBe("Done");
|
expect(result).toBe("Done");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("convert falls back to direct inkscape when xvfb-run fails", async () => {
|
test("convert rejects when inkscape fails", async () => {
|
||||||
let callCount = 0;
|
|
||||||
|
|
||||||
const mockExecFile: ExecFileFn = (
|
const mockExecFile: ExecFileFn = (
|
||||||
cmd: string,
|
cmd: string,
|
||||||
_args: string[],
|
_args: string[],
|
||||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
) => {
|
) => {
|
||||||
callCount++;
|
if (cmd === "inkscape") {
|
||||||
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") {
|
|
||||||
callback(new Error("inkscape failed"), "", "");
|
callback(new Error("inkscape failed"), "", "");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -77,7 +72,7 @@ test("convert logs stdout when present", async () => {
|
||||||
_args: string[],
|
_args: string[],
|
||||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
) => {
|
) => {
|
||||||
if (cmd === "xvfb-run") {
|
if (cmd === "inkscape") {
|
||||||
callback(null, "Fake stdout", "");
|
callback(null, "Fake stdout", "");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -102,16 +97,16 @@ test("convert logs stderr when present (non-fatal warning)", async () => {
|
||||||
_args: string[],
|
_args: string[],
|
||||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||||
) => {
|
) => {
|
||||||
if (cmd === "xvfb-run") {
|
if (cmd === "inkscape") {
|
||||||
// Inkscape 經常輸出警告到 stderr,但轉換仍成功
|
// Inkscape 經常輸出警告到 stderr,但轉換仍成功
|
||||||
callback(null, "", "Gtk-WARNING: some warning");
|
callback(null, "", "Some warning message");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile);
|
await convert("input.svg", "svg", "png", "output.png", undefined, mockExecFile);
|
||||||
console.log = originalConsoleLog;
|
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", () => {});
|
test.skip("dummy - required to trigger test detection", () => {});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue