Ghostscript を GitHub の CI 上で wasm にコンパイルして npm パッケージとして公開する

npm パッケージとして作ってみた
npm provenance というのを有効にして GitHub Actions 上でビルドしてみた。
これでこのソースコードでビルドしたものを配信していますという証拠になる(と思う)。

今は別の方法で証明する方法もある?

このあたりのプロジェクトをみて、
- Webブラウザで使えて
- GUIでオプションを設定できて
- クライアントで動作する
PDF変換・圧縮アプリを作りたくなった。この3つを満たすものを作りたい。
見た感じ、既存のPDF変換サービスはクライアントで動作しないし、上のプロジェクトはオプションが大まかに固定されているか手打ちっぽい。
そのためにはwasmビルドを用意する必要があるけど、ローカルでコンパイルしたものをぽんと置くのではなく、トレーサビリティのためにCI上でコンパイルしたやつを使えると嬉しい。ghostscriptやコンパイラ(Emscripten)の定期的なバージョン更新とかも自動PRでできるはず?
と思って、まず、Ghostscript を Github の CI 上で wasm コンパイルしてみたい。

コンパイルの参考になりそうなやつ
https://github.com/u1f992/ghostpdl/tree/ghostpdl-10.05.1-wasm-dev npmパッケージあり。もしかしてこれもうci上でコンパイルしてる?
https://github.com/jsscheller/ghostscript-wasm npmパッケージ
https://github.com/ochachacha/ps-wasm/tree/master 上の記事 で言及されていたやつ

一旦普通のコンパイルを試す。
https://github.com/ArtifexSoftware/ghostpdl で
WindowsなのでWSL使う
compile.sh
#!/bin/bash
echo "=== Ghostscript WSL Build Test ==="
echo "1. Checking build tools..."
gcc --version
make --version
echo "2. Cleaning previous build..."
make distclean 2>/dev/null || true
echo "3. Running configure..."
./configure
echo "4. Building..."
make -j$(nproc)
echo "5. Testing binary..."
if [ -x "./bin/gs" ]; then
./bin/gs --version
echo "Build SUCCESS!"
else
echo "Build FAILED!"
fi
を実行してビルド。
実行ファイルのテストとして、
test.ps
%!PS-Adobe-3.0
/Times-Roman findfont 20 scalefont setfont
100 700 moveto
(Hello, Ghostscript!) show
100 650 moveto
(Build test successful!) show
showpage
test.sh
# PostScript → PDF変換
./bin/gs -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=test.pdf test.ps
# PostScript → PNG変換
./bin/gs -dNOPAUSE -dBATCH -sDEVICE=png16m -r150 -sOutputFile=test.png test.ps
# PostScript → JPEG変換
./bin/gs -dNOPAUSE -dBATCH -sDEVICE=jpeg -r150 -sOutputFile=test.jpg test.ps
を作ってtest.shを実行。うまく行ってそう。

最初は ./configure がないから
echo "3. Running configure..."
./autoconf.sh
こうか
autoconfのインストールが必要かも

convert_test.sh
#!/bin/bash
INPUT_PDF="sample.pdf"
ORIGINAL_SIZE=$(ls -la "$INPUT_PDF" | awk '{print $5}')
echo "Original size: $ORIGINAL_SIZE bytes"
# 1. 画像圧縮を含む最大圧縮
./bin/gs \
-sDEVICE=pdfwrite \
-dCompatibilityLevel=1.4 \
-dPDFSETTINGS=/screen \
-dNOPAUSE \
-dQUIET \
-dBATCH \
-dColorImageResolution=150 \
-dGrayImageResolution=150 \
-dMonoImageResolution=150 \
-sOutputFile=compressed_01_screen.pdf \
$INPUT_PDF
# 2. カラー画像のJPEG圧縮レベル指定
./bin/gs \
-sDEVICE=pdfwrite \
-dPDFSETTINGS=/ebook \
-dColorImageDownsampleType=/Bicubic \
-dColorImageResolution=150 \
-dColorImageFilter=/DCTEncode \
-dJPEGQ=90 \
-dNOPAUSE \
-dBATCH \
-sOutputFile=compressed_02_ebook.pdf \
$INPUT_PDF
# 3. フォント埋め込み無効化(さらに小さく)
./bin/gs \
-sDEVICE=pdfwrite \
-dPDFSETTINGS=/screen \
-dEmbedAllFonts=false \
-dSubsetFonts=false \
-dNOPAUSE \
-dBATCH \
-sOutputFile=compressed_03_no_fonts.pdf \
$INPUT_PDF
# 4. グレースケール変換で圧縮
./bin/gs \
-sDEVICE=pdfwrite \
-sProcessColorModel=DeviceGray \
-sColorConversionStrategy=Gray \
-dOverrideICC \
-dNOPAUSE \
-dBATCH \
-sOutputFile=compressed_04_grayscale.pdf \
$INPUT_PDF
で実際の日本語入り画像入りPDFを変換してみたけどうまく行ってそう
3.
はフォントによって表示されたりされなかったりするけどそれで正常

普通のコンパイルはうまくいったのでwasmに挑戦してみる。
流れは https://web.dev/articles/compiling-mkbitmap-to-webassembly?hl=ja を参考に
一旦Windows上のターミナルから、
docker run --rm -it -v ${PWD}:/src emscripten/emsdk:4.0.13 bash
で入って色々試していく。
まず autoconf がはいってなかったのでいれる
apt update && apt install --yes autoconf
apt updateもしないとだめだった

「mkbitmap を WebAssembly にコンパイルする」を見ながらWebAssembly にコンパイルする
compile.sh
#!/bin/bash
echo "=== Ghostscript WebAssembly Build ==="
echo "1. Checking Emscripten tools..."
emcc --version
emmake --version
echo "2. Cleaning previous build..."
make distclean 2>/dev/null || true
echo "3. Generating autotools files (no configure)..."
NOCONFIGURE=1 ./autogen.sh
echo "4. Configuring for WebAssembly..."
emconfigure ./configure
echo "5. Building with Emscripten..."
emmake make -j$(nproc)
echo "6. Testing WebAssembly binary..."
if [ -f "./bin/gs" ]; then
echo "Build SUCCESS!"
echo "Output files:"
ls -la ./bin/gs*
else
echo "Build FAILED!"
echo "Checking for alternative output locations..."
find . -name "*.wasm" -o -name "*.js" | head -10
fi
echo "=== Build Complete ==="
でコンパイルの一連の作業を実行してみたら
checking build system type... x86_64-pc-linux-gnu
checking host system type... x86_64-pc-linux-gnu
~略~
make: ./obj/aux/echogs: Permission denied
make: *** [base/lib.mak:2427: obj/siscale_0.dev] Error 127
make: *** Waiting for unfinished jobs....
1 warning generated.
emmake: error: 'make -j8' failed (returned 2)
6. Testing WebAssembly binary...
Build FAILED!
Checking for alternative output locations...
./a.wasm
./freetype/docs/reference/assets/javascripts/bundle.fe8b6f2b.min.js
./freetype/docs/reference/assets/javascripts/lunr/min/lunr.ar.min.js
./freetype/docs/reference/assets/javascripts/lunr/min/lunr.da.min.js
./freetype/docs/reference/assets/javascripts/lunr/min/lunr.de.min.js
./freetype/docs/reference/assets/javascripts/lunr/min/lunr.du.min.js
./freetype/docs/reference/assets/javascripts/lunr/min/lunr.el.min.js
./freetype/docs/reference/assets/javascripts/lunr/min/lunr.es.min.js
./freetype/docs/reference/assets/javascripts/lunr/min/lunr.fi.min.js
./freetype/docs/reference/assets/javascripts/lunr/min/lunr.fr.min.js
=== Build Complete ===
となってビルドに失敗したので、--host
と --build
を明示してみると(by AIのアドバイス(どこかのドキュメントに書いてあるのを見たわけではないよ、の意味))↓
#!/bin/bash
echo "=== Ghostscript WebAssembly Build ==="
echo "1. Checking Emscripten tools..."
emcc --version
emmake --version
echo "2. Cleaning previous build..."
make distclean 2>/dev/null || true
echo "3. Generating autotools files (no configure)..."
NOCONFIGURE=1 ./autogen.sh
echo "4. Configuring for WebAssembly..."
emconfigure ./configure \
--host=$(emcc -dumpmachine) \
--build=$(./config.guess)
echo "5. Building with Emscripten..."
emmake make -j$(nproc)
echo "6. Testing WebAssembly binary..."
if [ -f "./bin/gs" ]; then
echo "Build SUCCESS!"
echo "Output files:"
ls -la ./bin/gs*
else
echo "Build FAILED!"
echo "Checking for alternative output locations..."
find . -name "*.wasm" -o -name "*.js" | head -10
fi
echo "=== Build Complete ==="
Build SUCCESS!
Output files:
-rw-r--r-- 1 root root 185740 Aug 15 18:43 ./bin/gs
-rwxr-xr-x 1 root root 25448138 Aug 15 18:43 ./bin/gs.wasm
となってビルドが成功した。
このときの ./bin/gs は中身がjsらしい。
その直後、 js で動くかテストするためnodeで実行してみたところコンパイルはうまくいってそう
root@dcb8c3db18e6:/src# node --version
v22.16.0
root@dcb8c3db18e6:/src# node ./bin/gs --version
10.06.0

root@d232f8606549:/src# head -n 20 ./bin/gs
// include: shell.js
// The Module object: Our interface to the outside world. We import
// and export values on it. There are various ways Module can be used:
// 1. Not defined. We create it here
// 2. A function parameter, function(moduleArg) => Promise<Module>
// 3. pre-run appended it, var Module = {}; ..generated code..
// 4. External script tag defines var Module.
// We need to check if Module already exists (e.g. case 3 above).
// Substitution will be replaced with actual code on later stage of the build,
// this way Closure Compiler will not mangle it (e.g. case 4. above).
// Note that if you want to run closure, and also to use Module
// after the generated code, you will need to define var Module = {};
// before the code. Then that object will be used in the code, and you
// can continue to use Module afterwards as well.
var Module = typeof Module != 'undefined' ? Module : {};
// Determine the runtime environment we are in. You can customize this by
// setting the ENVIRONMENT setting at compile time (see settings.js).
// Attempt to auto-detect the environment

記事に合わせると
root@d232f8606549:/src# mv ./bin/gs ./bin/gs.js
root@d232f8606549:/src# node ./bin/gs.js --version
10.06.0
このとき、./bin/gs.js は ./bin/gs.wasm に依存しているらしく、.wasmファイルの名前を変えると動かなかった。

「ブラウザで WebAssembly を使用した mkbitmap」のmkbitmapをgsに読み替えて進めていく
gsフォルダを作って中にgs.js, gs.wasmを用意する。
更に以下の index.html を用意する
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>gs</title>
</head>
<body>
<script src="gs.js"></script>
</body>
</html>
当然のようにvscodeを使っていたので、live preview拡張を使ってサーバーを立ち上げる。(方法は何でも良い)
該当ページ(http://127.0.0.1:3000/gs/)にアクセスするとプロンプトが表示される。これは想定通り。

に合わせて、
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>gs</title>
</head>
<body>
<script src="script.js"></script>
<script src="gs.js"></script>
</body>
</html>
script.js
var Module = {
// Don't run main() at page load
noInitialRun: true,
};
でプロンプトが表示されなくなる。

と同じようなオプションを加えつつコンパイルし直す。hostとbuildはすでに指定しているのでそのまま。
compile.sh
#!/bin/bash
echo "=== Ghostscript WebAssembly Build ==="
echo "1. Checking Emscripten tools..."
emcc --version
emmake --version
echo "2. Cleaning previous build..."
make distclean 2>/dev/null || true
echo "3. Generating autotools files (no configure)..."
NOCONFIGURE=1 ./autogen.sh
echo "4. Configuring for WebAssembly..."
emconfigure ./configure \
--host=$(emcc -dumpmachine) \
--build=$(./config.guess) \
CFLAGS='-sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0'
echo "5. Building with Emscripten..."
emmake make -j$(nproc)
echo "6. Testing WebAssembly binary..."
if [ -f "./bin/gs" ]; then
echo "Build SUCCESS!"
echo "Output files:"
ls -la ./bin/gs*
else
echo "Build FAILED!"
echo "Checking for alternative output locations..."
find . -name "*.wasm" -o -name "*.js" | head -10
fi
echo "=== Build Complete ==="
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>gs</title>
</head>
<body>
<!-- No longer load `gs.js` here -->
<script src="script.js" type="module"></script>
</body>
</html>
script.js
import loadWASM from "./gs.js";
const run = async () => {
const Module = await loadWASM();
console.log(Module);
};
run();
記事と合わせてこうしてみたが、これだとページを読み込んだときに
script.js:1 Uncaught SyntaxError: The requested module './gs.js' does not provide an export named 'default' (at script.js:1:8)
というエラーがコンソールに出てしまった。

コンパイル時のログをみると
emcc: warning: linker setting ignored during compilation: 'FILESYSTEM' [-Wunused-command-line-argument]
emcc: warning: linker setting ignored during compilation: 'EXPORTED_RUNTIME_METHODS' [-Wunused-command-line-argument]
emcc: warning: linker setting ignored during compilation: 'MODULARIZE' [-Wunused-command-line-argument]
emcc: warning: linker setting ignored during compilation: 'EXPORT_ES6' [-Wunused-command-line-argument]
emcc: warning: linker setting ignored during compilation: 'INVOKE_RUN' [-Wunused-command-line-argument]
が繰り返し出てくる。フラグが効いていない様子。

AIに聞いたらghostscriptでは CFLAGS
ではなく LDFLAGS
らしい。
そのようにしたら良さそうだった。
echo "4. Configuring for WebAssembly..."
emconfigure ./configure \
--host=$(emcc -dumpmachine) \
--build=$(./config.guess) \
LDFLAGS='-sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0'
でやった結果、正しくFSとcallMainが出力されていそう。
「ビルドフラグを追加してモジュール式ビルドを作成する」終わり

その次の「標準出力をリダイレクトする」もできた
script.js
import loadWASM from "./gs.js";
const run = async () => {
let consoleOutput = "Powered by ";
const Module = await loadWASM({
print: (text) => (consoleOutput += text),
});
Module.callMain(["-v"]);
document.body.textContent = consoleOutput;
};
run();

「最初の実際の実行」 から「メモリ ファイル システムから出力ファイルを取得する」 まで
gsフォルダにさっき使ったtest.psファイルをおいて、それをpdfに変換・ダウンロードまでできるようにする。
import loadWASM from "./gs.js";
const run = async () => {
const Module = await loadWASM();
// pdf to pdf エラーあり メモリの問題?
// const buffer = await fetch("./example.pdf").then((res) => res.arrayBuffer());
// Module.FS.writeFile("example.pdf", new Uint8Array(buffer));
// Module.callMain([
// "-sDEVICE=pdfwrite",
// "-dCompatibilityLevel=1.4",
// "-dPDFSETTINGS=/screen",
// "-dNOPAUSE",
// "-dQUIET",
// "-dBATCH",
// "-dColorImageResolution=72",
// "-dGrayImageResolution=72",
// "-dMonoImageResolution=72",
// "-sOutputFile=example_output.pdf",
// "example.pdf",
// ]);
// const output = Module.FS.readFile("example_output.pdf", {
// encoding: "binary",
// });
// const file = new File([output], "example_output.pdf", {
// type: "application/pdf",
// });
// ps to pdf エラーなし
const buffer = await fetch("./test.ps").then((res) => res.arrayBuffer());
Module.FS.writeFile("test.ps", new Uint8Array(buffer));
Module.callMain([
"-dNOPAUSE",
"-dBATCH",
"-sDEVICE=pdfwrite",
"-sOutputFile=test.pdf",
"test.ps",
]);
const output = Module.FS.readFile("test.pdf", { encoding: "binary" });
const file = new File([output], "test.pdf", { type: "application/pdf" });
// ダウンロードボタン
const a = document.createElement("a");
a.href = URL.createObjectURL(file);
a.download = file.name;
a.innerText = "Download File";
document.body.appendChild(a);
console.log(Module.FS.readdir("/"));
};
run();
元のコードだとページをリロードするたびにPDFがダウンロードされてしまうので、少しアレンジした。ダウンロードボタンをクリックしてダウンロードするようにした。

本当は手元のPDFを変換しようとしたが、メモリ関連のようなエラーが出たので後回しにした(pdf to pdfのコメントアウトした箇所)。

ダウンロードしたtest.pdfをgsフォルダに入れて、最初にやったtest.shと同じことをしてみる。
編集したscript.js pdf to png
import loadWASM from "./gs.js";
const run = async () => {
const Module = await loadWASM();
// pdf to png エラーなし
const buffer = await fetch("./test.pdf").then((res) => res.arrayBuffer());
Module.FS.writeFile("test.pdf", new Uint8Array(buffer));
Module.callMain([
"-dNOPAUSE",
"-dBATCH",
"-sDEVICE=png16m",
"-r72",
"-sOutputFile=test.png",
"test.pdf",
]);
const output = Module.FS.readFile("test.png", {
encoding: "binary",
});
const file = new File([output], "test.png", {
type: "image/png",
});
// ダウンロードボタン
const a = document.createElement("a");
a.href = URL.createObjectURL(file);
a.download = file.name;
a.innerText = "Download File";
document.body.appendChild(a);
console.log(Module.FS.readdir("/"));
};
run();
編集したscript.js pdf to jpeg
import loadWASM from "./gs.js";
const run = async () => {
const Module = await loadWASM();
// pdf to jpeg エラーなし
const buffer = await fetch("./test.pdf").then((res) => res.arrayBuffer());
Module.FS.writeFile("test.pdf", new Uint8Array(buffer));
Module.callMain([
"-dNOPAUSE",
"-dBATCH",
"-sDEVICE=jpeg",
"-r150",
"-sOutputFile=test.jpg",
"test.pdf",
]);
const output = Module.FS.readFile("test.jpg", {
encoding: "binary",
});
const file = new File([output], "test.jpg", {
type: "image/jpeg",
});
// ダウンロードボタン
const a = document.createElement("a");
a.href = URL.createObjectURL(file);
a.download = file.name;
a.innerText = "Download File";
document.body.appendChild(a);
console.log(Module.FS.readdir("/"));
};
run();
どっちも動いた

メモリ関連のようなエラーがでたときにテストしようとしたPDFは 2.36 MB
作業にたくさんメモリを使うっぽい?
とりあえずフラグに -sALLOW_MEMORY_GROWTH=1
を追加してコンパイルしてみる
echo "4. Configuring for WebAssembly..."
emconfigure ./configure \
--host=$(emcc -dumpmachine) \
--build=$(./config.guess) \
LDFLAGS='-sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0 -sALLOW_MEMORY_GROWTH=1'

今度は2.36 MBのPDFでもエラーが出ず、ちゃんとファイルサイズ圧縮できた。
script.js 最初エラーがでていた pdf to pdf
import loadWASM from "./gs.js";
const run = async () => {
const Module = await loadWASM();
// pdf to pdf メモリ問題解消 PDF は 2.36 MB
const buffer = await fetch("./example.pdf").then((res) => res.arrayBuffer());
Module.FS.writeFile("example.pdf", new Uint8Array(buffer));
Module.callMain([
"-sDEVICE=pdfwrite",
"-dCompatibilityLevel=1.4",
"-dPDFSETTINGS=/screen",
"-dNOPAUSE",
"-dQUIET",
"-dBATCH",
"-dColorImageResolution=72",
"-dGrayImageResolution=72",
"-dMonoImageResolution=72",
"-sOutputFile=example_output.pdf",
"example.pdf",
]);
const output = Module.FS.readFile("example_output.pdf", {
encoding: "binary",
});
const file = new File([output], "example_output.pdf", {
type: "application/pdf",
});
// ダウンロードボタン
const a = document.createElement("a");
a.href = URL.createObjectURL(file);
a.download = file.name;
a.innerText = "Download File";
document.body.appendChild(a);
console.log(Module.FS.readdir("/"));
};
run();
ちなみに
gs.js 186 KB
gs.wasm 24.2 MB

「メモリ ファイル システムから出力ファイルを取得する」終わり

最後、「インタラクティブな UI を追加する」
を参考にしたかったけどglitchのプロジェクトが見れなかった。
そこで、ユーザーにファイルを指定してもらって、GUIでオプションを変更するテストをAIに書いてもらった。
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>gs</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<main>
<h1>PDF 圧縮ツール</h1>
<div>
<label>
入力ファイル
<input type="file" id="inputFile" accept="application/pdf" />
</label>
</div>
<div>
<label for="pdfSettings">出力プリファレンス</label>
<select id="pdfSettings">
<option value="/screen">/screen(低解像度)</option>
<option value="/ebook">/ebook(中解像度)</option>
<option value="/printer">/printer(印刷向け)</option>
<option value="/prepress">/prepress(製版向け高品質)</option>
<option value="/default" selected>/default(汎用)</option>
</select>
</div>
<div>
<label for="imgResolution">画像ダウンサンプリング解像度 (dpi)</label>
<select id="imgResolution">
<option value="default" selected>
デフォルト(プリファレンスのものを利用)
</option>
<option value="72">72</option>
<option value="150">150</option>
<option value="300">300</option>
<option value="custom">カスタム</option>
</select>
<div>
<input
type="range"
id="imgResSlider"
min="36"
max="600"
step="1"
value="150"
/>
<span id="imgResSliderValue">150</span>
</div>
</div>
<div>
<button id="runBtn">実行</button>
<button id="downloadBtn" style="display: none">ダウンロード</button>
</div>
<div id="sizeInfo" aria-live="polite"></div>
<section>
<h2>ログ</h2>
<pre
id="log"
style="white-space: pre-wrap; max-height: 40vh; overflow: auto"
></pre>
</section>
</main>
<script src="script.js" type="module"></script>
</body>
</html>
script.js
import loadWASM from "./gs.js";
let moduleInstance = null;
let currentDownloadUrl = null;
const $ = (id) => document.getElementById(id);
const formatBytes = (bytes) => {
if (!Number.isFinite(bytes)) return "-";
const thresh = 1024;
if (Math.abs(bytes) < thresh) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let u = -1;
do {
bytes /= thresh;
++u;
} while (Math.abs(bytes) >= thresh && u < units.length - 1);
return `${bytes.toFixed(2)} ${units[u]}`;
};
const outputNameFor = (inputName) => {
const base = inputName.replace(/\.[^.]*$/, "");
return `${base}_compressed.pdf`;
};
const ensureModule = async () => {
if (moduleInstance) return moduleInstance;
const logEl = document.getElementById("log");
const log = (msg) => {
if (!logEl) return;
const text = typeof msg === "string" ? msg : JSON.stringify(msg);
logEl.textContent += (logEl.textContent ? "\n" : "") + text;
};
moduleInstance = await loadWASM({
print: (text) => log(text),
printErr: (text) => log(`[err] ${text}`),
});
return moduleInstance;
};
const bindUI = () => {
const inputEl = $("inputFile");
const settingsEl = $("pdfSettings");
const imgResEl = $("imgResolution");
const imgResSlider = $("imgResSlider");
const imgResSliderValue = $("imgResSliderValue");
const runBtn = $("runBtn");
const downloadBtn = $("downloadBtn");
const sizeInfo = $("sizeInfo");
// 初期表示は不要(select の初期値を使用)
if (imgResEl && imgResSlider && imgResSliderValue) {
if (imgResEl.value !== "custom" && imgResEl.value !== "default") {
imgResSlider.value = imgResEl.value;
}
imgResSliderValue.textContent = String(imgResSlider.value);
// デフォルトはスライダー操作不可(disabled のみ)
imgResSlider.disabled = imgResEl.value === "default";
imgResEl.addEventListener("change", () => {
if (imgResEl.value !== "custom" && imgResEl.value !== "default") {
imgResSlider.value = imgResEl.value;
imgResSliderValue.textContent = String(imgResSlider.value);
}
// デフォルトはスライダー操作不可(disabled のみ)
imgResSlider.disabled = imgResEl.value === "default";
});
imgResSlider.addEventListener("input", () => {
imgResSliderValue.textContent = String(imgResSlider.value);
if (imgResEl.value !== "custom") imgResEl.value = "custom";
});
}
// 起動時にバージョン表示(-v)
(async () => {
try {
const Module = await ensureModule();
Module.callMain(["-v"]);
} catch (e) {
console.error(e);
}
})();
runBtn.addEventListener("click", async () => {
if (!inputEl.files || inputEl.files.length === 0) {
alert("PDF を選択してください。");
return;
}
const inputFile = inputEl.files[0];
const pdfSettings = settingsEl.value; // 例: /screen, /ebook
const selected = imgResEl.value;
const resInput =
selected === "custom"
? parseInt(imgResSlider.value, 10)
: parseInt(selected, 10);
const resDpi = Number.isFinite(resInput)
? Math.min(600, Math.max(36, resInput))
: 150;
runBtn.disabled = true;
runBtn.textContent = "実行中...";
sizeInfo.textContent = "処理中...";
downloadBtn.style.display = "none";
try {
const Module = await ensureModule();
// 入力ファイルを FS に書き込み
const inputBuffer = new Uint8Array(await inputFile.arrayBuffer());
Module.FS.writeFile(inputFile.name, inputBuffer);
console.log(Module.FS.readdir("/"));
const outName = outputNameFor(inputFile.name);
// Ghostscript 引数
const args = [
"-sDEVICE=pdfwrite",
`-dPDFSETTINGS=${pdfSettings}`,
"-dNOPAUSE",
// "-dQUIET",
"-dBATCH",
];
// 画像ダウンサンプリングの指定(デフォルト選択時は未指定)
if (selected !== "default") {
args.push(
"-dDownsampleColorImages=true",
"-dDownsampleGrayImages=true",
"-dDownsampleMonoImages=true",
"-dColorImageDownsampleType=/Bicubic",
"-dGrayImageDownsampleType=/Bicubic",
"-dMonoImageDownsampleType=/Subsample",
`-dColorImageResolution=${resDpi}`,
`-dGrayImageResolution=${resDpi}`,
`-dMonoImageResolution=${Math.max(150, resDpi)}`
);
}
// 出力・入力
args.push(`-sOutputFile=${outName}`, inputFile.name);
Module.callMain(args);
console.log(Module.FS.readdir("/"));
// 出力の読み取り
const outData = Module.FS.readFile(outName);
const outBlob = new Blob([outData], { type: "application/pdf" });
// ダウンロードリンク更新
if (currentDownloadUrl) URL.revokeObjectURL(currentDownloadUrl);
currentDownloadUrl = URL.createObjectURL(outBlob);
downloadBtn.style.display = "inline-block";
downloadBtn.textContent = "ダウンロード";
downloadBtn.onclick = () => {
const a = document.createElement("a");
a.href = currentDownloadUrl;
a.download = outName;
document.body.appendChild(a);
a.click();
a.remove();
};
// サイズ比較
const inSize = inputFile.size;
const outSize = outData.length;
const ratio = inSize > 0 ? (1 - outSize / inSize) * 100 : 0;
sizeInfo.innerHTML = `入力: ${formatBytes(inSize)} / 出力: ${formatBytes(
outSize
)} (削減率: ${ratio.toFixed(1)}%)`;
} catch (err) {
console.error(err);
alert("変換中にエラーが発生しました。コンソールを確認してください。");
sizeInfo.textContent = "エラーが発生しました。";
} finally {
runBtn.disabled = false;
runBtn.textContent = "実行";
}
});
};
// DOM 生成後にバインド
bindUI();
よさげ。
実行してみるとわかるように、ログが変換処理終了後に一気に表示される。
メインスレッドで実行しているので、処理中はWebページも固まってしまうはず。
次はweb workerで動かしてみる。

web workerを使ってメインスレッドを止めずに実行できた。ログがリアルタイムで流れるため進捗がわかる。
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>gs</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<main>
<h1>PDF 圧縮ツール</h1>
<div>
<label>
入力ファイル
<input type="file" id="inputFile" accept="application/pdf" />
</label>
</div>
<div>
<label for="pdfSettings">出力プリファレンス</label>
<select id="pdfSettings">
<option value="/screen">/screen(低解像度)</option>
<option value="/ebook">/ebook(中解像度)</option>
<option value="/printer">/printer(印刷向け)</option>
<option value="/prepress">/prepress(製版向け高品質)</option>
<option value="/default" selected>/default(汎用)</option>
</select>
</div>
<div>
<label for="imgResolution">画像ダウンサンプリング解像度 (dpi)</label>
<select id="imgResolution">
<option value="default" selected>
デフォルト(プリファレンスのものを利用)
</option>
<option value="72">72</option>
<option value="150">150</option>
<option value="300">300</option>
<option value="custom">カスタム</option>
</select>
<div>
<input
type="range"
id="imgResSlider"
min="36"
max="600"
step="1"
value="150"
/>
<span id="imgResSliderValue">150</span>
</div>
</div>
<div>
<button id="runBtn">実行</button>
<button id="downloadBtn" style="display: none">ダウンロード</button>
</div>
<div id="sizeInfo" aria-live="polite"></div>
<section>
<h2>ログ</h2>
<pre
id="log"
style="white-space: pre-wrap; max-height: 40vh; overflow: auto"
></pre>
</section>
</main>
<script src="script.js" type="module"></script>
</body>
</html>
script.js
// Run Ghostscript via Web Worker for responsive UI
let workerInstance = null;
let currentDownloadUrl = null;
const $ = (id) => document.getElementById(id);
const formatBytes = (bytes) => {
if (!Number.isFinite(bytes)) return "-";
const thresh = 1024;
if (Math.abs(bytes) < thresh) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let u = -1;
do {
bytes /= thresh;
++u;
} while (Math.abs(bytes) >= thresh && u < units.length - 1);
return `${bytes.toFixed(2)} ${units[u]}`;
};
const appendLog = (msg, isErr = false) => {
const logEl = document.getElementById("log");
if (!logEl) return;
const text = typeof msg === "string" ? msg : JSON.stringify(msg);
logEl.textContent +=
(logEl.textContent ? "\n" : "") + (isErr ? `[err] ${text}` : text);
logEl.scrollTop = logEl.scrollHeight;
};
const ensureWorker = () => {
if (workerInstance) return workerInstance;
const w = new Worker(new URL("./worker.js", import.meta.url), {
type: "module",
});
w.onmessage = (ev) => {
const data = ev.data || {};
if (data.type === "log") {
appendLog(data.text, !!data.isErr);
}
};
w.onerror = (e) => {
appendLog(`worker error: ${e.message}`, true);
};
w.postMessage({ type: "init" });
workerInstance = w;
return workerInstance;
};
const bindUI = () => {
const inputEl = $("inputFile");
const settingsEl = $("pdfSettings");
const imgResEl = $("imgResolution");
const imgResSlider = $("imgResSlider");
const imgResSliderValue = $("imgResSliderValue");
const runBtn = $("runBtn");
const downloadBtn = $("downloadBtn");
const sizeInfo = $("sizeInfo");
// 初期表示は不要(select の初期値を使用)
if (imgResEl && imgResSlider && imgResSliderValue) {
if (imgResEl.value !== "custom" && imgResEl.value !== "default") {
imgResSlider.value = imgResEl.value;
}
imgResSliderValue.textContent = String(imgResSlider.value);
// デフォルトはスライダー操作不可(disabled のみ)
imgResSlider.disabled = imgResEl.value === "default";
imgResEl.addEventListener("change", () => {
if (imgResEl.value !== "custom" && imgResEl.value !== "default") {
imgResSlider.value = imgResEl.value;
imgResSliderValue.textContent = String(imgResSlider.value);
}
// デフォルトはスライダー操作不可(disabled のみ)
imgResSlider.disabled = imgResEl.value === "default";
});
imgResSlider.addEventListener("input", () => {
imgResSliderValue.textContent = String(imgResSlider.value);
if (imgResEl.value !== "custom") imgResEl.value = "custom";
});
}
// 起動時: Worker 初期化(-v は worker 側の init で実行)
ensureWorker();
runBtn.addEventListener("click", async () => {
if (!inputEl.files || inputEl.files.length === 0) {
alert("PDF を選択してください。");
return;
}
const inputFile = inputEl.files[0];
const pdfSettings = settingsEl.value; // 例: /screen, /ebook
const selected = imgResEl.value;
const resInput =
selected === "custom"
? parseInt(imgResSlider.value, 10)
: parseInt(selected, 10);
const resDpi = Number.isFinite(resInput)
? Math.min(600, Math.max(36, resInput))
: 150;
runBtn.disabled = true;
runBtn.textContent = "実行中...";
sizeInfo.textContent = "処理中...";
downloadBtn.style.display = "none";
try {
const worker = ensureWorker();
const fileBuffer = await inputFile.arrayBuffer();
const result = await new Promise((resolve, reject) => {
const onMessage = (ev) => {
const data = ev.data || {};
if (data.type === "done") {
worker.removeEventListener("message", onMessage);
resolve(data);
} else if (data.type === "error") {
worker.removeEventListener("message", onMessage);
reject(new Error(data.message || "worker error"));
}
// ログは既存のグローバルハンドラで処理される
};
worker.addEventListener("message", onMessage);
worker.postMessage(
{
type: "convert",
fileName: inputFile.name,
fileBuffer,
pdfSettings,
resSelection: selected,
resDpi,
},
[fileBuffer]
);
});
const outBlob = new Blob([result.outputBuffer], {
type: "application/pdf",
});
if (currentDownloadUrl) URL.revokeObjectURL(currentDownloadUrl);
currentDownloadUrl = URL.createObjectURL(outBlob);
downloadBtn.style.display = "inline-block";
downloadBtn.textContent = "ダウンロード";
downloadBtn.onclick = () => {
const a = document.createElement("a");
a.href = currentDownloadUrl;
a.download = result.outName;
document.body.appendChild(a);
a.click();
a.remove();
};
const inSize = result.inSize;
const outSize = result.outSize;
const ratio = inSize > 0 ? (1 - outSize / inSize) * 100 : 0;
sizeInfo.innerHTML = `入力: ${formatBytes(inSize)} / 出力: ${formatBytes(
outSize
)} (削減率: ${ratio.toFixed(1)}%)`;
} catch (err) {
console.error(err);
alert("変換中にエラーが発生しました。コンソールを確認してください。");
sizeInfo.textContent = "エラーが発生しました。";
} finally {
runBtn.disabled = false;
runBtn.textContent = "実行";
}
});
};
// DOM 生成後にバインド
bindUI();
worker.js
import loadWASM from "./gs.js";
let moduleInstance = null;
const postLog = (text, isErr = false) => {
const line = typeof text === "string" ? text : JSON.stringify(text);
self.postMessage({ type: "log", text: line, isErr });
};
const postWorkerLog = (text, isErr = false) => {
const line = typeof text === "string" ? text : JSON.stringify(text);
self.postMessage({ type: "log", text: `[worker] ${line}`, isErr });
};
const ensureModule = async () => {
if (moduleInstance) return moduleInstance;
moduleInstance = await loadWASM({
print: (t) => postLog(t, false),
printErr: (t) => postLog(t, true),
});
return moduleInstance;
};
const outputNameFor = (inputName) => {
const base = inputName.replace(/\.[^.]*$/, "");
return `${base}_compressed.pdf`;
};
self.onmessage = async (ev) => {
const data = ev.data || {};
try {
switch (data.type) {
case "init": {
const Module = await ensureModule();
// Show version
Module.callMain(["-v"]);
self.postMessage({ type: "ready" });
break;
}
case "convert": {
const { fileName, fileBuffer, pdfSettings, resSelection, resDpi } =
data;
const Module = await ensureModule();
// Write input file to FS
const inBytes = new Uint8Array(fileBuffer);
Module.FS.writeFile(fileName, inBytes);
const outName = outputNameFor(fileName);
const args = [
"-sDEVICE=pdfwrite",
`-dPDFSETTINGS=${pdfSettings}`,
"-dNOPAUSE",
// "-dQUIET", // コメントアウトしてログを表示
"-dBATCH",
];
if (resSelection !== "default") {
args.push(
"-dDownsampleColorImages=true",
"-dDownsampleGrayImages=true",
"-dDownsampleMonoImages=true",
"-dColorImageDownsampleType=/Bicubic",
"-dGrayImageDownsampleType=/Bicubic",
"-dMonoImageDownsampleType=/Subsample",
`-dColorImageResolution=${resDpi}`,
`-dGrayImageResolution=${resDpi}`,
`-dMonoImageResolution=${Math.max(150, resDpi)}`
);
}
args.push(`-sOutputFile=${outName}`, fileName);
postWorkerLog(`Starting conversion: ${fileName} -> ${outName}`, false);
postWorkerLog(`Args: ${args.join(" ")}`, false);
Module.callMain(args);
postWorkerLog(
`Conversion completed, reading output file: ${outName}`,
false
);
const outData = Module.FS.readFile(outName);
postWorkerLog(`Output file size: ${outData.length} bytes`, false);
self.postMessage(
{
type: "done",
outName,
outputBuffer: outData.buffer,
inSize: inBytes.length,
outSize: outData.length,
},
[outData.buffer]
);
break;
}
default:
break;
}
} catch (err) {
postWorkerLog(String(err?.message || err), true);
self.postMessage({ type: "error", message: String(err?.message || err) });
}
};
ということで、このコンパイル手順・オプションでやりたいことができそう。

./autogen.sh について
compile.sh にて、
NOCONFIGURE=1 ./autogen.sh
emconfigure ./configure \
--host=$(emcc -dumpmachine) \
--build=$(./config.guess) \
LDFLAGS='-sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0 -sALLOW_MEMORY_GROWTH=1'
のように二段階に分けるのではなく、
emconfigure ./autogen.sh \
--host=$(emcc -dumpmachine) \
--build=$(./config.guess) \
LDFLAGS='-sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0 -sALLOW_MEMORY_GROWTH=1'
で良いのでは?
と思ったが、それだとcompile.sh実行時にエラーが出る。
エラー
/emsdk/upstream/emscripten/emcc -I./obj/ -I./freetype/include -I./freetype/include -Wp,-w -D"FT_EXPORT(x)"="__attribute__((visibility(\"hidden\"))) x" -D"FT_EXPORT_DEF(x)"="__attribute__((visibility(\"hidden\"))) x" -DSHARE_FT=0 -DFT_CONFIG_OPTIONS_H=\"gsftopts.h\" -DFT2_BUILD_LIBRARY -DDARWIN_NO_CARBON -DFT_CONFIG_OPTION_SYSTEM_ZLIB -DHAVE_MKSTEMP -DHAVE_FSEEKO -DHAVE_SETLOCALE -DHAVE_BSWAP32 -DHAVE_BYTESWAP_H -DHAVE_STRERROR -DHAVE_PREAD_PWRITE=1 -DGS_RECURSIVE_MUTEXATTR=PTHREAD_MUTEX_RECURSIVE -O -DNDEBUG -Wall -Wstrict-prototypes -Wundef -Wmissing-declarations -Wmissing-prototypes -Wwrite-strings -fno-strict-aliasing -Werror=declaration-after-statement -fno-builtin -fno-common -Werror=return-type -Wno-unused-local-typedefs -DHAVE_STDINT_H=1 -DHAVE_DIRENT_H=1 -DHAVE_NDIR_H=0 -DHAVE_SYS_DIR_H=1 -DHAVE_SYS_NDIR_H=0 -DHAVE_SYS_TIME_H=1 -DHAVE_SYS_TIMES_H=1 -DHAVE_INTTYPES_H=1 -DHAVE_LIBDL=1 -D__USE_UNIX98=1 -DHAVE_SNPRINTF -DBUILD_PDF=1 -I./pdf -DHAVE_RESTRICT=1 -DHAVE_LIMITS_H=1 -DHAVE_STRING_H=1 -fno-strict-aliasing -DHAVE_POPEN_PROTO=1 -o ./obj/ftdbgmem.o -c ./freetype/src/base/ftdbgmem.c
In file included from ./tiff//libtiff/tif_zip.c:27:
./base/stdint_.h:220:25: error: invalid token at start of a preprocessor expression
220 | # if ARCH_SIZEOF_SIZE_T == 4
| ^
./base/stdint_.h:281:25: error: invalid token at start of a preprocessor expression
281 | # if ARCH_SIZEOF_SIZE_T == 4
| ^
/emsdk/upstream/emscripten/emcc -I./obj/ -I./freetype/include -I./freetype/include -Wp,-w -D"FT_EXPORT(x)"="__attribute__((visibility(\"hidden\"))) x" -D"FT_EXPORT_DEF(x)"="__attribute__((visibility(\"hidden\"))) x" -DSHARE_FT=0 -DFT_CONFIG_OPTIONS_H=\"gsftopts.h\" -DFT2_BUILD_LIBRARY -DDARWIN_NO_CARBON -DFT_CONFIG_OPTION_SYSTEM_ZLIB -DHAVE_MKSTEMP -DHAVE_FSEEKO -DHAVE_SETLOCALE -DHAVE_BSWAP32 -DHAVE_BYTESWAP_H -DHAVE_STRERROR -DHAVE_PREAD_PWRITE=1 -DGS_RECURSIVE_MUTEXATTR=PTHREAD_MUTEX_RECURSIVE -O -DNDEBUG -Wall -Wstrict-prototypes -Wundef -Wmissing-declarations -Wmissing-prototypes -Wwrite-strings -fno-strict-aliasing -Werror=declaration-after-statement -fno-builtin -fno-common -Werror=return-type -Wno-unused-local-typedefs -DHAVE_STDINT_H=1 -DHAVE_DIRENT_H=1 -DHAVE_NDIR_H=0 -DHAVE_SYS_DIR_H=1 -DHAVE_SYS_NDIR_H=0 -DHAVE_SYS_TIME_H=1 -DHAVE_SYS_TIMES_H=1 -DHAVE_INTTYPES_H=1 -DHAVE_LIBDL=1 -D__USE_UNIX98=1 -DHAVE_SNPRINTF -DBUILD_PDF=1 -I./pdf -DHAVE_RESTRICT=1 -DHAVE_LIMITS_H=1 -DHAVE_STRING_H=1 -fno-strict-aliasing -DHAVE_POPEN_PROTO=1 -o ./obj/ftgloadr.o -c ./freetype/src/base/ftgloadr.c
/emsdk/upstream/emscripten/emcc -I./obj/ -I./freetype/include -I./freetype/include -Wp,-w -D"FT_EXPORT(x)"="__attribute__((visibility(\"hidden\"))) x" -D"FT_EXPORT_DEF(x)"="__attribute__((visibility(\"hidden\"))) x" -DSHARE_FT=0 -DFT_CONFIG_OPTIONS_H=\"gsftopts.h\" -DFT2_BUILD_LIBRARY -DDARWIN_NO_CARBON -DFT_CONFIG_OPTION_SYSTEM_ZLIB -DHAVE_MKSTEMP -DHAVE_FSEEKO -DHAVE_SETLOCALE -DHAVE_BSWAP32 -DHAVE_BYTESWAP_H -DHAVE_STRERROR -DHAstrict-prototypes -Wundef -Wmissing-declarations -Wmissing-prototypes -Wwrite-strings -fno-strict-aliasing -Werror=declaration-after-statement -fno-builtin -fno-common -Werror=return-type -Wno-unused-local-typedefs -DHAVE_STDINT_H=1 -DHAVE_DIRENT_H=1 -DHAVE_NDIR_H=0 -DHAVE_SYS_DIR_H=1 -DHAVE_SYS_NDIR_H=0 -DHAVE_SYS_TIME_H=1 -DHAVE_SYS_TIMES_H=1 -DHAVE_INTTYPES_H=1 -DHAVE_LIBDL=1 -D__USE_UNIX98=1 -DHAVE_SNPRINTF -DBUILD_PDF=1 -I./pdf -DHAVE_RESTRICT=1 -DHAVE_LIMITS_H=1 -DHAVE_STRING_H=1 -fno-strict-aliasing -DHAVE_POPEN_PROTO=1 -o ./obj/ftobjs.o -c ./freetype/src/base/ftobjs.c
2 errors generated.
make: *** [base/tiff.mak:177: obj/tif_zip.o] Error 1
make: *** Waiting for unfinished jobs....
emmake: error: 'make -j20' failed (returned 2)
のでそこは変えない

npm パッケージ
パッケージとして使えるようにする
ghostpdlをgit submoduleで持ち、emscripten/emsdkイメージでコンパイルし、npm publishするイメージ。
現在のcompile.sh
#!/bin/bash
set -euo pipefail
echo "=== Ghostscript WebAssembly Build ==="
echo "0. Entering submodule directory..."
pushd ghostpdl >/dev/null
echo "1. Checking Emscripten tools..."
emcc --version
echo "2. Cleaning previous build..."
make distclean 2>/dev/null || true
echo "3. Installing autoconf..."
apt-get update && apt-get install --yes autoconf=2.71-2
echo "4. Configuring for WebAssembly..."
NOCONFIGURE=1 ./autogen.sh
emconfigure ./configure \
--host=$(emcc -dumpmachine) \
--build=$(./config.guess) \
LDFLAGS='-sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0 -sALLOW_MEMORY_GROWTH=1'
# CFLAGS='-O3 -g0' \
# CXXFLAGS='-O3 -g0' \
# LDFLAGS='-O3 -g0 -sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0 -sALLOW_MEMORY_GROWTH=1'
echo "5. Building with Emscripten..."
emmake make -j$(nproc)
echo "6. Testing WebAssembly binary..."
if [ -f "./bin/gs" ]; then
echo "Build SUCCESS!"
echo "Output files:"
ls -la ./bin/gs*
else
echo "Build FAILED!"
echo "Checking for alternative output locations..."
find . -name "*.wasm" -o -name "*.js" | head -10
fi
echo "7. Copying files to gs directory..."
mkdir -p ../dist
cp ./bin/gs ../dist/gs.js
cp ./bin/gs.wasm ../dist/gs.wasm
popd >/dev/null
echo "=== Build Complete ==="
npm パッケージ が本当にそのコードで生成されたものを配信しているか確認できるようにしたいと思っていたら、そういう機能があるらしい?
このprovenanceというのが使えれば良い?
生成物の最適化
ファイルサイズをできるだけ抑えたい
emconfigure ./configure \
--host=$(emcc -dumpmachine) \
--build=$(./config.guess) \
LDFLAGS='-sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0 -sALLOW_MEMORY_GROWTH=1'
だと、
-rw-r--r-- 1 root root 191126 Aug 24 19:01 ./bin/gs
-rwxr-xr-x 1 root root 25448326 Aug 24 19:01 ./bin/gs.wasm

emconfigure ./configure \
--host=$(emcc -dumpmachine) \
--build=$(./config.guess) \
CFLAGS='-O3' \
CXXFLAGS='-O3' \
LDFLAGS='-O3 -sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0 -sALLOW_MEMORY_GROWTH=1'
-rw-r--r-- 1 root root 77690 Aug 24 19:06 ./bin/gs
-rwxr-xr-x 1 root root 17384350 Aug 24 19:06 ./bin/gs.wasm

emconfigure ./configure \
--host=$(emcc -dumpmachine) \
--build=$(./config.guess) \
CFLAGS='-O3 -g0' \
CXXFLAGS='-O3 -g0' \
LDFLAGS='-O3 -g0 -sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0 -sALLOW_MEMORY_GROWTH=1'
-rw-r--r-- 1 root root 77690 Aug 24 18:55 ./bin/gs
-rwxr-xr-x 1 root root 17384350 Aug 24 18:55 ./bin/gs.wasm

emconfigure ./configure \
--host=$(emcc -dumpmachine) \
--build=$(./config.guess) \
CFLAGS='-O3 -g0 -flto' \
CXXFLAGS='-O3 -g0 -flto' \
LDFLAGS='-O3 -g0 -flto -sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0 -sALLOW_MEMORY_GROWTH=1'
-rw-r--r-- 1 root root 78266 Aug 24 19:12 ./bin/gs
-rwxr-xr-x 1 root root 18306388 Aug 24 19:12 ./bin/gs.wasm

ghostscriptのバージョン問題
再現性のためにghostpdlをバージョンタグのコミットに固定していた。しかし ghostpdl-10.05.1(とghostpdl-10.05.0も?)だとビルドは成功してバージョン表示もできるが変換時にエラーになっていた。
コンパイルと動作確認してたときはmainブランチ使ってたので、そのときのtag付けされてないコミット(バージョンの表示が PRERELEASE ghostpdl-10.06.0って出るやつ)を使ったら動いた。