Open32

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

ピン留めされたアイテム
okathiraokathira

npm パッケージとして作ってみた
https://www.npmjs.com/package/@okathira/ghostpdl-wasm


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

https://github.blog/jp/2023-04-26-introducing-npm-package-provenance

okathiraokathira

https://meyer-laurent.com/playing-around-webassembly-and-ghostscript
https://github.com/laurentmmeyer/ghostscript-pdf-compress.wasm
https://gist.github.com/oaustegard/2bc7a7537626882aac03db985a0774d2
https://x.com/u1f992/status/1941130229524918523

このあたりのプロジェクトをみて、

  1. Webブラウザで使えて
  2. GUIでオプションを設定できて
  3. クライアントで動作する

PDF変換・圧縮アプリを作りたくなった。この3つを満たすものを作りたい。
見た感じ、既存のPDF変換サービスはクライアントで動作しないし、上のプロジェクトはオプションが大まかに固定されているか手打ちっぽい。

そのためにはwasmビルドを用意する必要があるけど、ローカルでコンパイルしたものをぽんと置くのではなく、トレーサビリティのためにCI上でコンパイルしたやつを使えると嬉しい。ghostscriptやコンパイラ(Emscripten)の定期的なバージョン更新とかも自動PRでできるはず?

と思って、まず、Ghostscript を Github の CI 上で wasm コンパイルしてみたい。

okathiraokathira

一旦普通のコンパイルを試す。
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を実行。うまく行ってそう。

okathiraokathira

最初は ./configure がないから

echo "3. Running configure..."
./autoconf.sh

こうか

autoconfのインストールが必要かも

okathiraokathira
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. はフォントによって表示されたりされなかったりするけどそれで正常

okathiraokathira

普通のコンパイルはうまくいったので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もしないとだめだった

okathiraokathira

https://web.dev/articles/compiling-mkbitmap-to-webassembly?hl=ja#compile_mkbitmap_to_webassembly
「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
okathiraokathira
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
okathiraokathira

記事に合わせると

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ファイルの名前を変えると動かなかった。

okathiraokathira

https://web.dev/articles/compiling-mkbitmap-to-webassembly?hl=ja#mkbitmap_with_webassembly_in_the_browser
「ブラウザで 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/)にアクセスするとプロンプトが表示される。これは想定通り。

okathiraokathira

https://web.dev/articles/compiling-mkbitmap-to-webassembly?hl=ja#create_a_modular_build_with_some_more_build_flags
と同じようなオプションを加えつつコンパイルし直す。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)

というエラーがコンソールに出てしまった。

okathiraokathira

コンパイル時のログをみると

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]

が繰り返し出てくる。フラグが効いていない様子。

okathiraokathira

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が出力されていそう。

「ビルドフラグを追加してモジュール式ビルドを作成する」終わり

okathiraokathira

その次の「標準出力をリダイレクトする」もできた

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();

okathiraokathira

「最初の実際の実行」 から「メモリ ファイル システムから出力ファイルを取得する」 まで

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がダウンロードされてしまうので、少しアレンジした。ダウンロードボタンをクリックしてダウンロードするようにした。

okathiraokathira

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

okathiraokathira

ダウンロードした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();

どっちも動いた

okathiraokathira

メモリ関連のようなエラーがでたときにテストしようとした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'
okathiraokathira

今度は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

okathiraokathira

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

okathiraokathira

最後、「インタラクティブな 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で動かしてみる。

okathiraokathira

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) });
  }
};

ということで、このコンパイル手順・オプションでやりたいことができそう。

okathiraokathira

./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'

で良いのでは?
https://ghostscript.readthedocs.io/en/gs10.05.1/Make.html#how-to-build-ghostscript-from-source-unix-version

と思ったが、それだと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)

のでそこは変えない

okathiraokathira

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 パッケージ が本当にそのコードで生成されたものを配信しているか確認できるようにしたいと思っていたら、そういう機能があるらしい?
https://docs.npmjs.com/generating-provenance-statements
このprovenanceというのが使えれば良い?

okathiraokathira

生成物の最適化

ファイルサイズをできるだけ抑えたい

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
okathiraokathira
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
okathiraokathira
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
okathiraokathira
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
okathiraokathira

ghostscriptのバージョン問題

再現性のためにghostpdlをバージョンタグのコミットに固定していた。しかし ghostpdl-10.05.1(とghostpdl-10.05.0も?)だとビルドは成功してバージョン表示もできるが変換時にエラーになっていた。

コンパイルと動作確認してたときはmainブランチ使ってたので、そのときのtag付けされてないコミット(バージョンの表示が PRERELEASE ghostpdl-10.06.0って出るやつ)を使ったら動いた。