Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 26 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ on:
jobs:
test:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
# Pinned rather than ubuntu-latest: the servers this package targets run Ubuntu 24.04, so the
# bundled binary is tested on that release. Move it up when the servers move.
runs-on: ubuntu-24.04

strategy:
fail-fast: false
Expand All @@ -27,41 +29,45 @@ jobs:
tools: composer:v2
coverage: none

- name: Install wkhtmltopdf (upstream jammy deb, as the README's native install)
# Why not the BUNDLED x64/linux/wkhtmltopdf: it links OpenSSL 1.1 (libssl.so.1.1,
# libcrypto.so.1.1; read from its ELF DT_NEEDED entries on 2026-10-12), which Ubuntu
# 22.04 and later do not ship. On this runner it is executable but cannot start, and
# since bootstrap.php only checks is_executable() the wrapper would still choose it and
# every render test would fail. bootstrap.php prefers /usr/local/bin/wkhtmltopdf on Linux,
# which is where this deb installs, so the suite runs the same upstream 0.12.6.1-3
# patched-Qt build the Docker image is made from.
- name: Install the bundled Linux binary's runtime libraries (README apt line)
# Since 1.1.0 the suite tests the BUNDLED x64/linux/wkhtmltopdf, not an upstream .deb
# install (issue #6). Up to 1.0.3 the bundled binary linked OpenSSL 1.1, which Ubuntu 22.04
# and later do not ship, so CI had to install the jammy deb instead and the bundled binary
# went untested. It is now the binary from that same jammy deb (OpenSSL 3), so it can run
# here, and this step installs only what the README tells server admins to install. That
# keeps the README's library list honest: if it misses a library, the probe test fails.
# Most of these are already on the runner image; apt skips what is installed.
# Why not the Docker fallback: the image runs as root, so the output PDF it writes into
# the host's /tmp is root-owned, and /tmp is sticky, so the runner user most likely
# cannot delete it and the temp-file tests would fail on that. Inferred from the code
# and the Dockerfile (no USER), not run.
# Dependency list: README, "Native install on Ubuntu servers" (the xfonts packages are
# the ones a bare dpkg -i usually misses).
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends xfonts-75dpi xfonts-base libfontconfig1 libfreetype6 libjpeg-turbo8 libx11-6 libxext6 libxrender1
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb
sudo apt-get install -y ./wkhtmltox_0.12.6.1-3.jammy_amd64.deb
/usr/local/bin/wkhtmltopdf -V
sudo apt-get install -y --no-install-recommends libfontconfig1 libfreetype6 libjpeg-turbo8 libpng16-16t64 libssl3t64 libx11-6 libxrender1 zlib1g fonts-dejavu-core

- name: Show whether the bundled Linux binary can start here (informational)
# Never fails the job: it only records, in the log, which shared libraries the bundled
# binary is missing on the current runner image.
- name: Check the bundled binary resolves all its shared libraries
# Fails the job (grep finds a "not found" line) instead of only logging it, as before.
run: |
ldd x64/linux/wkhtmltopdf | grep 'not found' || echo 'bundled binary: all libraries found'
ldd x64/linux/wkhtmltopdf
if ldd x64/linux/wkhtmltopdf | grep 'not found'; then exit 1; fi
x64/linux/wkhtmltopdf --version

- name: Install dependencies
# No composer.lock is committed (it is a library), so this resolves fresh per PHP version.
run: composer install --no-interaction --no-progress

- name: Check the wrapper resolves the bundled binary
# bootstrap.php prefers /usr/local/bin/wkhtmltopdf; this runner has none, so the render
# tests below must be running the bundled binary. Fails loudly if that ever changes.
run: |
resolved=$(php -r 'require "vendor/autoload.php"; echo Restruct\WkhtmltoPdf\WkhtmltoPdfStatic::getPath();')
echo "resolved: $resolved"
test "$resolved" = "$PWD/x64/linux/wkhtmltopdf"

- name: Run tests
# --fail-on-skipped: the render tests skip when no wkhtmltopdf can be resolved, which is
# right on a developer machine without one but would let CI pass green with nothing
# rendered. Here one was installed above, so a skip is a failure.
# rendered. Here the bundled binary is checked above, so a skip is a failure.
# --fail-on-empty-test-suite: a run that discovers no tests exits non-zero instead of 0.
run: vendor/bin/phpunit --fail-on-skipped --fail-on-empty-test-suite

Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# Changelog

## 1.1.0 (2026-10-12)

### Changed

- The bundled Linux binary (`x64/linux/wkhtmltopdf`) is replaced with the upstream wkhtmltopdf
0.12.6.1 (patched Qt) binary from the official Ubuntu 22.04 package
(https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb,
`.deb` sha256 `4f723b2691ad8638a9df960e0421d346d7315083e3583a334f33362280ddba15`, binary sha256
`750c92708349321a502b92e5e6c16b25499318ec24d1175cc8a81b6aefa321b2`). The previous binary linked
OpenSSL 1.1 and could not start on Ubuntu 22.04 or later; the new one links OpenSSL 3 and runs on
22.04 and 24.04. **It no longer runs on Ubuntu 20.04 or older** (#6). The README lists the runtime
libraries and the apt line for 24.04 (on a stock 24.04 server only `libxrender1` may be missing).
- CI now tests the bundled Linux binary on Ubuntu 24.04, installing only the README's runtime
libraries, instead of installing the upstream `.deb`.

### Fixed

- A binary that is executable but cannot start (for example a missing shared library) is no longer
chosen. Each candidate is run once with `--version` and must exit 0 (cached per PHP process); if
it fails, the next candidate is tried and then the Docker strategy. Before, only the exec bit was
checked, so such a binary was chosen and every render failed (#6).

### Added

- `WkhtmltoPdfStatic::probeBinary($path)` and `WkhtmltoPdfStatic::setProbeTimeout($seconds)`
(default 10 s).

## 1.0.3 (2026-10-11)

### Fixed
Expand Down
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,79 @@ WKHTMLTOPDF_DOCKER_IMAGE=ghcr.io/restruct/wkhtmltopdf:0.12.6

If both resolve, the native binary wins.

A binary only counts as resolved if it can actually start (from 1.1.0). Each candidate path is run
once with `--version` (10 s timeout) and must exit 0; the result is cached for the rest of the PHP
process, so this costs one extra process start per path, not one per render. A binary that is
executable but cannot start, for example because a shared library is missing, is skipped: the
next candidate in the table above is tried, and if none starts the Docker strategy is used. Up to
1.0.3 only the exec bit was checked, so such a binary was chosen anyway and every render failed.
`WkhtmltoPdfStatic::probeBinary($path)` runs the same check by hand.

## Platform support

| Platform | Native binary | Docker image |
|---|---|---|
| macOS x64 | ✅ bundled (`x64/mac`) | — |
| macOS ARM (Apple Silicon) | ✅ bundled x64 binary via Rosetta 2 | ✅ native `linux/arm64` (in OrbStack/Docker Desktop) |
| Linux x64 | ✅ bundled (`x64/linux`, Ubuntu 20.04+) or system deb | ✅ `linux/amd64` |
| Linux x64 | ✅ bundled (`x64/linux`, Ubuntu 22.04+, see [below](#the-bundled-linux-binary)) or system deb | ✅ `linux/amd64` |
| Linux ARM64 | ❌ upstream never shipped one | ✅ `linux/arm64` |

The Docker image is the answer for every platform without a workable native binary — and the long-term insurance for when OS upgrades break the native ones.

## The bundled Linux binary

`x64/linux/wkhtmltopdf` is the upstream wkhtmltopdf 0.12.6.1 (patched Qt) binary, taken unchanged
from the official Ubuntu 22.04 (jammy) amd64 package (from 1.1.0). It links OpenSSL 3, so it runs
on Ubuntu 22.04 and 24.04. It does **not** run on Ubuntu 20.04 or older, which ship OpenSSL 1.1
only. Up to 1.0.3 the bundled binary was an older build linked against OpenSSL 1.1, which in turn
could not start on 22.04 and later (#6).

| | |
|---|---|
| Source | https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb |
| `.deb` sha256 | `4f723b2691ad8638a9df960e0421d346d7315083e3583a334f33362280ddba15` |
| Binary sha256 | `750c92708349321a502b92e5e6c16b25499318ec24d1175cc8a81b6aefa321b2` (`usr/local/bin/wkhtmltopdf` in the `.deb`) |
| Version | `wkhtmltopdf 0.12.6.1 (with patched qt)` |

Upstream publishes no checksums for its release assets (neither on the release page nor on
wkhtmltopdf.org), so the `.deb` sha256 above is the one we computed on download. It matches the
value several unrelated public projects pin for the same file, and the binary matches the MD5 in the
`.deb`'s own `md5sums` list. To check the bundled binary yourself:

```bash
wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb
sha256sum wkhtmltox_0.12.6.1-3.jammy_amd64.deb
dpkg-deb -x wkhtmltox_0.12.6.1-3.jammy_amd64.deb deb && sha256sum deb/usr/local/bin/wkhtmltopdf
```

### Runtime libraries (Ubuntu 24.04)

The binary links these shared libraries (its ELF `DT_NEEDED` list): `libjpeg.so.8`,
`libpng16.so.16`, `libXrender.so.1`, `libfontconfig.so.1`, `libfreetype.so.6`, `libX11.so.6`,
`libssl.so.3`, `libcrypto.so.3`, `libz.so.1`, plus the C/C++ runtime (`libc`, `libm`, `libstdc++`,
`libgcc_s`). Fontconfig also needs at least one font installed to render text.

On the official Ubuntu 24.04 server cloud image (package manifest of 2026-09-26) all of these are
already installed **except `libxrender1`**. A server provisioned with more packages (Laravel Forge,
for example) usually has that one too. To be sure on any 24.04 server, run this; apt skips what is
already installed:

```bash
sudo apt-get install -y --no-install-recommends libfontconfig1 libfreetype6 libjpeg-turbo8 libpng16-16t64 libssl3t64 libx11-6 libxrender1 zlib1g fonts-dejavu-core
```

On 22.04 two of the package names differ: use `libpng16-16` and `libssl3` instead of
`libpng16-16t64` and `libssl3t64`. Then check that nothing is missing:

```bash
ldd vendor/restruct/wkhtmltopdf-static/x64/linux/wkhtmltopdf | grep 'not found' # prints nothing
vendor/restruct/wkhtmltopdf-static/x64/linux/wkhtmltopdf --version
```

The upstream `.deb` also depends on `xfonts-75dpi` and `xfonts-base` (X bitmap fonts). The binary
starts and renders without them; install them only if your HTML relies on those fonts. CI runs the
test suite against the bundled binary on Ubuntu 24.04 with exactly the apt line above.

## The Docker image

`ghcr.io/restruct/wkhtmltopdf:0.12.6` — public, multi-arch (`linux/amd64` + `linux/arm64`). Ubuntu 22.04 with the official final wkhtmltopdf deb and all runtime libraries frozen in. Built from [`build/Dockerfile`](build/Dockerfile):
Expand Down
20 changes: 16 additions & 4 deletions bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
* 3. macOS: bundled x64/mac/wkhtmltopdf
* 4. Linux: system-installed /usr/local/bin/wkhtmltopdf → bundled x64/linux/wkhtmltopdf
*
* Each candidate path (env var included) must pass a probe: it is run once with --version and
* must exit 0 (WkhtmltoPdfStatic::probeBinary(), cached per process). A binary that is executable
* but cannot start, e.g. because a shared library is missing, falls through to the next candidate.
*
* Priority for WKHTMLTOPDF_DOCKER_IMAGE:
* 1. Already defined constant
* 2. WKHTMLTOPDF_DOCKER_IMAGE environment variable
Expand All @@ -25,9 +29,17 @@
if (!defined('WKHTMLTOPDF_PATH')) {
$wkPath = null;

# Probe that the candidate can actually start, not just that it has the exec bit (issue #6).
# Falls back to is_executable() only if this file is included without the package autoloader.
$wkUsable = static function (string $candidate): bool {
return class_exists(\Restruct\WkhtmltoPdf\WkhtmltoPdfStatic::class)
? \Restruct\WkhtmltoPdf\WkhtmltoPdfStatic::probeBinary($candidate)
: is_executable($candidate);
};

# Check environment variable first
$envPath = getenv('WKHTMLTOPDF_PATH');
if ($envPath !== false && $envPath !== '' && is_executable($envPath)) {
if ($envPath !== false && $envPath !== '' && $wkUsable($envPath)) {
$wkPath = $envPath;
}

Expand All @@ -36,7 +48,7 @@
case 'Darwin':
# macOS: use bundled binary (Intel, runs on ARM via Rosetta 2)
$candidate = __DIR__ . '/x64/mac/wkhtmltopdf';
if (is_executable($candidate)) {
if ($wkUsable($candidate)) {
$wkPath = $candidate;
}
break;
Expand All @@ -45,10 +57,10 @@
# Linux: prefer system-installed, fall back to bundled
$candidates = [
'/usr/local/bin/wkhtmltopdf', # System install (Debian/Ubuntu package)
__DIR__ . '/x64/linux/wkhtmltopdf', # Bundled Ubuntu 20.04+ binary
__DIR__ . '/x64/linux/wkhtmltopdf', # Bundled Ubuntu 22.04+ binary (jammy build, OpenSSL 3; up to 1.0.3 it was a 20.04+ build needing OpenSSL 1.1)
];
foreach ($candidates as $candidate) {
if (is_executable($candidate)) {
if ($wkUsable($candidate)) {
$wkPath = $candidate;
break;
}
Expand Down
62 changes: 60 additions & 2 deletions src/WkhtmltoPdfStatic.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ class WkhtmltoPdfStatic
/** Cached Docker availability check */
private static ?bool $dockerAvailable = null;

/**
* Per-process cache of binary probe results, keyed by path.
*
* @var array<string, bool>
*/
private static array $probeCache = [];

/** Timeout in seconds for one binary probe (`--version`) */
private static int $probeTimeout = 10;

/**
* Include the bootstrap file (once).
*/
Expand Down Expand Up @@ -71,9 +81,12 @@ public static function isAvailable(): bool
*/
public static function getStrategy(): string
{
# Prefer direct binary (faster, no container overhead)
# Prefer direct binary (faster, no container overhead). It must actually start: a binary
# that is executable but cannot load its shared libraries (e.g. one linked against
# OpenSSL 1.1 on Ubuntu 22.04+, issue #6) would otherwise be chosen and fail every render,
# where falling through to Docker could still work.
$path = self::getPath();
if ($path !== '' && is_executable($path)) {
if ($path !== '' && self::probeBinary($path)) {
return 'binary';
}

Expand All @@ -86,6 +99,51 @@ public static function getStrategy(): string
return '';
}

/**
* Check that a wkhtmltopdf binary can actually start, by running it with `--version`.
*
* is_executable() only checks the file mode; it says nothing about whether the dynamic loader
* can resolve the binary's shared libraries. A missing library makes the loader exit with
* 127 before wkhtmltopdf runs at all, so the exit code of a cheap `--version` call is the
* signal. The result is cached per path for the rest of the process, so the probe costs one
* extra process start per path, not one per render.
*
* @param string $path Full path to a candidate binary
* @return bool True if it ran and exited 0 within the probe timeout
*/
public static function probeBinary(string $path): bool
{
if (array_key_exists($path, self::$probeCache)) {
return self::$probeCache[$path];
}

# No point starting a process for a path that is not an executable file
if ($path === '' || !is_file($path) || !is_executable($path)) {
return self::$probeCache[$path] = false;
}

try {
$process = new Process([$path, '--version']);
$process->setTimeout(self::$probeTimeout);
$process->run();
$usable = $process->getExitCode() === 0;
} catch (\Exception $e) {
# Timed out (ProcessTimedOutException) or could not be started at all
# (Symfony's RuntimeException): either way not a binary to render with
$usable = false;
}

return self::$probeCache[$path] = $usable;
}

/**
* Set the timeout for one binary probe. A probe that times out counts as unusable.
*/
public static function setProbeTimeout(int $seconds): void
{
self::$probeTimeout = $seconds;
}

/**
* Get the wkhtmltopdf version string.
*/
Expand Down
Loading
Loading