# Conversion engine (GhostPDL → WebAssembly)

The PCL/PXL/PRN → PDF conversion runs entirely in the browser using two
GhostPDL executables compiled to WebAssembly with Emscripten:

| Artifact | Built from | Handles |
| --- | --- | --- |
| `public/engine/gpcl6.js` + `gpcl6.wasm` | GhostPCL (`gpcl6`) | PCL 5e/5c (incl. HP-GL/2 inside PCL, PCL 3 DeskJet raster), PCL XL / PCL 6, PJL wrappers |
| `public/engine/gs.js` + `gs.wasm` | Ghostscript (`gs`) | PostScript (incl. PJL-wrapped PostScript `.prn` files) |

Both use only the `pdfwrite` output device. All fonts, ICC profiles and
PostScript init/resource files are compiled into the `.wasm` (`COMPILE_INITS=1`,
`%rom%` file system), so nothing else is fetched at run time.

## Source

- GhostPDL **10.08.0**, official release tarball
  `https://github.com/ArtifexSoftware/ghostpdl-downloads/releases/download/gs10080/ghostpdl-10.08.0.tar.xz`
- sha256 `94943e3c4adb7115d132b3c28b913672f2e62e6a9d802f7e754e716b1634174a`
  (verified by `build.sh` before every build)

## Toolchain used for the committed artifacts

- Emscripten `emcc 6.0.9-git` (Homebrew `emscripten 6.0.9_1`, clang 24.0.0git), macOS arm64
- Host C compiler (`cc`, Apple clang) for GhostPDL's build-time tools

## Rebuilding

```sh
apps/pcl-to-pdf/engine/build.sh
# or, with a tarball you already have (its sha256 is still checked):
GHOSTPDL_TARBALL=/path/to/ghostpdl-10.08.0.tar.xz apps/pcl-to-pdf/engine/build.sh
```

Requirements: `emcc`/`emar`/`emranlib` on `PATH`, a native `cc`, `make`,
`curl`, `tar` (with xz), `patch`, and optionally `brotli` for the size report.
A full build takes about 3–4 minutes on an 11-core M-series Mac.

The script:

1. downloads the tarball into `engine/.build/` if needed (gitignored) and verifies its sha256;
2. extracts a fresh tree into `engine/.build/ghostpdl-10.08.0/` and applies `engine/patches/*.patch`;
3. configures a cross build (`--host=wasm32-unknown-emscripten`). GhostPDL's
   configure re-runs itself for the build machine, so `genarch`, `mkromfs`,
   `echogs` and `genconf` are compiled natively with `CCAUX=cc`. Because
   `genarch` cannot run for the target, `arch/windows-x86-msvc.h` is passed as
   the arch header — wasm32 is ILP32, little-endian, with the same scalar
   sizes and alignments;
4. builds `gpcl6` and `gs` with `-Oz`, no threads, `pdfwrite` only;
5. copies the `.js`/`.wasm` files into `public/engine/`, fails if any file
   exceeds 20 MiB, writes `public/engine/SHA256SUMS`, and prints raw/gzip/brotli sizes.

`SOURCE_DATE_EPOCH` is pinned to the 10.08.0 release date (mkromfs embeds a
build timestamp otherwise); with that, two consecutive clean builds produced
byte-identical artifacts (same `SHA256SUMS`).

### Configure flags

`--with-pcl=gpcl6 --with-pdf=no --with-xps=no --with-gpdl=no --with-drivers=pdfwrite
--disable-threading --disable-contrib --disable-cups --disable-dbus --disable-fontconfig
--disable-gtk --disable-openjpeg --disable-sse2 --disable-neon --without-x --without-libtiff
--without-libpaper --without-libidn --without-pdftoraster --without-ijs --without-jbig2dec
--without-tesseract --without-urf --without-so --without-cal --with-libiconv=no`

`--with-pdf=no` removes the PDF *input* interpreter (not needed: PDF files are
passed through by the app). OpenJPEG and jbig2dec are only needed by that interpreter.

### Link flags (Emscripten)

`-Oz -g0 -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,worker,node -sINVOKE_RUN=0
-sEXIT_RUNTIME=1 -sFORCE_FILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain
-sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=67108864 -sMAXIMUM_MEMORY=2147483648
-sSTACK_SIZE=8388608`

Each conversion instantiates a fresh module and calls `callMain` once. The 8 MiB
stack matches what Ghostscript's interpreters need (Emscripten's default is 64 KiB).
When memory growth would exceed 2 GiB, `malloc` fails and Ghostscript reports a
`VMerror`, which the app maps to `out_of_memory` (verified with a PostScript job
allocating >2 GiB).

## Patches

### `0001-psromfs-uncompressed-no-cjk-fallback-font.patch` (affects `gs` only)

Changes `psi/psromfs.mak`:

1. **Store PostScript resources uncompressed in `%rom%`** (`-b` instead of `-c`).
   mkromfs' zlib compression defeats the HTTP-level brotli/gzip compression of the
   `.wasm`. Uncompressed, the file is larger on disk but much smaller over the wire.
2. **Omit `Resource/CIDFSubst/DroidSansFallback.ttf`** (3.7 MB). It is only used
   when a PostScript job references a CJK CIDFont that is neither embedded nor
   available; such text will fail to render instead of using the Droid fallback.
   Print-to-file PostScript from Windows/macOS drivers normally embeds its fonts; CMaps
   (including `Identity-H`) are still included.

Measured effect on `gs.wasm` (all other flags identical):

| Variant | raw | gzip -9 | brotli -q 11 |
| --- | ---: | ---: | ---: |
| upstream romfs (compressed, with Droid) | 12,558,643 | 10,342,066 | 9,922,739 |
| uncompressed, with Droid | 18,194,669 | 10,260,398 | 8,195,810 |
| compressed, without Droid | 10,605,468 | 8,430,617 | 8,016,001 |
| **uncompressed, without Droid (shipped)** | **14,265,921** | **8,405,302** | **6,746,073** |

### `0002-pcl-kcmy-simple-color-default-palette.patch` (affects `gpcl6` only)

Changes `pcl/pcl/pcindxed.c` (two hunks, bug fix):

1. **KCMY simple colour mode (`ESC * r -4 U`) default palette.** Upstream only
   fills the first 8 of the 16 palette entries and sets the rest to black, so
   every raster pixel with the yellow plane set prints black: red, green, yellow
   and orange areas of PCL 3 colour jobs come out black. Such jobs are produced
   by DeskJet-class drivers, e.g. the CUPS `rastertohp` filter with the stock
   "HP DeskJet Series" PPD (default colour model CMYK) and Ghostscript's
   `cdj550` device (see `fixtures/real-world/cups/`).
2. **Off-by-one write in `set_dev_specific_default_palette()`.** For palettes
   with more than 8 entries it also wrote a fourth byte after each entry; for
   the last entry that is one byte past the palette allocation. Upstream never
   reached it (hence its comment that `cnt = 15` "overruns the palette"); with
   hunk 1 alone the wasm build corrupted its heap and aborted with
   `table index is out of bounds` at the end of KCMY jobs. The byte was always
   overwritten by the next entry, so the write is simply removed.

Verified with a native `gpcl6` 10.08.0 built from the same patched tree: output
of the GhostPDL `pcl/examples` jobs and of the non-KCMY test fixtures renders
identically to the unpatched engine; KCMY jobs render in their real colours.
The patch changes `gpcl6.wasm` only (`gs.*` and `gpcl6.js` are byte-identical),
and two clean builds produced the same `SHA256SUMS`.

## Artifact sizes (shipped)

| File | raw bytes | gzip -9 | brotli -q 11 |
| --- | ---: | ---: | ---: |
| `gpcl6.js` | 71,475 | 19,360 | 17,312 |
| `gpcl6.wasm` | 14,476,198 | 7,770,756 | 4,759,946 |
| `gs.js` | 71,189 | 19,321 | 17,281 |
| `gs.wasm` | 14,265,921 | 8,405,302 | 6,746,073 |

Every file is well under Cloudflare's 25 MiB per-asset limit (build fails above 20 MiB).
`gpcl6.wasm` is dominated by the 80 PCL TrueType fonts (≈11 MB uncompressed);
code is ≈3.5 MB.

## Why two binaries instead of `gpdl`

`gpdl` (one executable with automatic language switching) was built with the
same flags for comparison. GhostPDL's configure only allows `gpdl` together with
the XPS and PDF interpreters, so it also needs OpenJPEG and jbig2dec:

| Build | raw | gzip -9 | brotli -q 11 |
| --- | ---: | ---: | ---: |
| `gpdl.wasm` (PCL+PXL+PS+XPS+PDF) | 26,212,197 | 17,158,407 | 13,862,274 |

That breaks the 20 MiB per-file budget and would make every PCL user download
~14 MB (brotli) instead of ~4.8 MB. `gpcl6` already handles PJL
`ENTER LANGUAGE` switching between PCL 5 and PCL XL; PostScript jobs are routed to
`gs` by content sniffing (`src/engine/detect.ts`), and `gs` skips PJL headers itself.

`-O2` was also measured for `gpcl6`: 15,076,805 bytes raw / 4,863,368 brotli, with
no measurable speed difference on a 50-page 600 dpi PCL XL job (331 ms vs 353 ms)
or a 50-page 6.9 MB PCL 5 raster job (598 ms vs 595 ms), so `-Oz` is used.

## Runtime usage

The app never builds argv from user input. Fixed argv (see `src/engine/engine-runner.ts`):

```
gpcl6: -q -dSAFER -dNOPAUSE -dBATCH -dLastPage=500 -dAutoRotatePages=/None -sDEVICE=pdfwrite -sOutputFile=/work/output.pdf /work/input
gs:    -q -dSAFER -dNOPAUSE -dBATCH -dLastPage=500 -dAutoRotatePages=/None -sDEVICE=pdfwrite -sOutputFile=/work/output.pdf -f /work/input
```

`-dLastPage=500` caps output at 500 pages (later pages are still interpreted but
discarded); the host's timeout bounds total time. `-dAutoRotatePages=/None` keeps
pages in the orientation the printer would have used.

## License (AGPL-3.0)

GhostPDL is © Artifex Software, Inc. and distributed under the **GNU Affero
General Public License v3.0** (see `LICENSE` in the source tarball). Because the
compiled engine is served to users, the site must offer the Corresponding Source
of the engine to everyone who receives it. That Corresponding Source is:

1. the unmodified official tarball (URL and sha256 above),
2. this directory's `build.sh`, and
3. the patches in `engine/patches/`.

Keep all three published alongside the site (e.g. link to this directory in the
public repository and to the tarball from the app's about/licence page), keep the
AGPL notice and a link to the source visible in the UI, and rebuild and update
this README whenever the engine version, flags or patches change. Whether the
surrounding app code (which drives the engine only through `callMain` argv and
its in-memory file system) also needs to be AGPL-licensed is a licensing decision
for the project owner; publishing it under the AGPL is the conservative choice.
