Anything that draws an image itself, be it a framebuffer, a GPU texture, a display panel, or a compositor, needs the image as plain pixels: every pixel's red, green, blue and alpha channel in a known order. Files don't arrive that way. A map tile comes as JPEG, an icon as PNG with a palette, a scan as a TIFF in strips or tiles, and each format has its own compression, color models and alpha rules to undo first.
The only problem? PHP's built-in answer is gd, but gd was made to edit images, not to hand their pixels over. It keeps alpha in 7 bits, it can't read TIFF, and it gives its pixels out through imagecolorat(), one call per pixel. Getting a 512 × 512 JPEG tile into an RGBA8 string that way takes about 22 ms on an average 4-5 year old MBP. gd decodes the tile in about 1 ms; the other 21 are the 262,144 imagecolorat() calls plus the packing logic around them.
ext-imgdec decodes in C. Three functions take a file's bytes and answer ['width', 'height', 'rgba8']: every pixel as one string, straight alpha, rows top to bottom. The same tile takes about 1 ms. PNG goes through libpng, JPEG through libjpeg, TIFF through libtiff, each with its formats' quirks settled by fixed, written-down rules: palettes expanded, transparency made alpha, 16-bit samples cut to 8, CMYK converted, associated alpha divided out.
Most ways you will interface with ext-imgdec are in two parts -
Get the bytes and pick the decoder. The function is chosen by the file's first bytes, not its name, so a misnamed file still decodes.
function decode(string $bytes): array
{
return match (true) {
str_starts_with($bytes, "\x89PNG") => imgdec_png($bytes),
str_starts_with($bytes, "\xFF\xD8\xFF") => imgdec_jpeg($bytes),
str_starts_with($bytes, "II*\0"), str_starts_with($bytes, "MM\0*") => imgdec_tiff($bytes),
default => throw new InvalidArgumentException('not a PNG, JPEG or TIFF'),
};
}
['width' => $width, 'height' => $height, 'rgba8' => $pixels] = decode(file_get_contents('legend.png'));Use the pixels. rgba8 is width × height × 4 bytes, red, green, blue, alpha, rows top to bottom, so the pixel at (x, y) starts at byte (y × width + x) × 4. Hand the string as it is to whatever draws.
echo "{$width} × {$height}, ", strlen($pixels), " bytes\n";
$pixel = fn (int $x, int $y): string => bin2hex(substr($pixels, ($y * $width + $x) * 4, 4));
echo $pixel(0, 0), ' ', $pixel(3, 1), ' ', $pixel(1, 0), "\n";$ php legend.php
4 × 2, 32 bytes
ff0000ff 0000ff81 00000000Opaque red, half-transparent blue, and a clear pixel: straight alpha, so a clear pixel's colour is whatever the file stored.
The functions take bytes, so an image fetched over HTTP decodes without touching the disk.
$jpeg = file_get_contents('https://gibs.earthdata.nasa.gov/wmts/epsg4326/best/MODIS_Terra_CorrectedReflectance_TrueColor/default/2021-09-21/250m/2/1/2.jpeg');
['width' => $width, 'height' => $height, 'rgba8' => $tile] = imgdec_jpeg($jpeg);
echo "{$width} × {$height}, ", strlen($tile), " bytes\n";512 × 512, 1048576 bytesEvery refusal is an ImgdecException, and its code says which kind: the bytes aren't a readable image, the image is in a layout the rules leave out, or it is past the size limits. The message is the library's reason, or the rule that refused it.
foreach (['scan.tif', 'poster.tif', 'cut.png'] as $path) {
try {
decode(file_get_contents($path));
} catch (ImgdecException $e) {
echo $path, ': ', match ($e->getCode()) {
IMGDEC_CORRUPT => 'not a readable image',
IMGDEC_UNSUPPORTED => 'a layout imgdec leaves out',
IMGDEC_TOO_LARGE => 'past the size limits',
}, ' (', $e->getMessage(), ")\n";
}
}scan.tif: a layout imgdec leaves out (photometric 5)
poster.tif: past the size limits (a 9000x9000 image is past the limit: sides up to 65535, 67108864 pixels in all.)
cut.png: not a readable image (the data stops before the image does.)scan.tif is CMYK, photometric 5 in TIFF's terms; poster.tif declares 81 million pixels; cut.png stops after its first 40 bytes.
| OS | Linux or macOS. composer.json lists those two families. |
| PHP | 8.4 or newer, NTS or ZTS. |
| Libraries | libpng 1.6 or newer, libjpeg (libjpeg-turbo is the usual one) and libtiff 4.5 or newer, with their headers, found through pkg-config. |
To build it you need a C compiler, pkg-config, the three libraries' development files, and the development files of the PHP you intend to load it into:
# Debian, Ubuntu, Raspberry Pi OS
sudo apt install build-essential pkg-config libpng-dev libjpeg-dev libtiff-dev php8.4-dev
# macOS: Xcode Command Line Tools, and Homebrew's PHP ships phpize and php-config
xcode-select --install
brew install pkg-config libpng jpeg-turbo libtiff php@8.4PIE is the PHP Foundation's installer for extensions. It downloads the tagged source from Packagist, builds it against the PHP on your PATH, installs the module and enables it. sudo where your PHP's extension directory is root-owned, as on Debian; not on a Homebrew Mac. Install the libraries above first:
pie install php-io-extensions/imgdecThe repository ships two installers, one per platform. Each one checks that pkg-config finds the three libraries, builds for each PHP it targets, installs imgdec.so into that PHP's extension_dir, writes 30-imgdec.ini into its conf.d scan directory, and checks the module loads. The macOS one builds in a temporary copy of the checkout and ad-hoc signs the module so the system loads it on first use; the Debian one builds in the checkout, writes the ini into the CLI, FPM and Apache conf.d of that PHP, and cleans the build products out afterwards.
git clone https://github.com/php-io-extensions/imgdec.git
cd imgdec
./install-debian-trixie.sh # Debian Trixie, Ubuntu 24.04+, Raspberry Pi OS: the php on PATH
./install-macos.sh # Homebrew php@8.4 and php@8.4-zts
./install-macos.sh /path/to/bin/php # the macOS script takes specific PHP binariesgit clone https://github.com/php-io-extensions/imgdec.git
cd imgdec
phpize
./configure --enable-imgdec
make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
sudo make installThen enable it: add extension=imgdec.so to your php.ini, or drop that line into a file in the scan directory that php --ini reports.
php --ri imgdec also names the library versions it was built against:
$ php --ri imgdec
imgdec
imgdec support => enabled
Version => 0.10.x
libpng => 1.6.59
libjpeg API => 80
libtiff => LIBTIFF, Version 4.7.2Three functions, one exception class and five constants, declared in stubs/imgdec.stub.php. Each function takes the whole file as a string and answers ['width' => int, 'height' => int, 'rgba8' => string]: width × height × 4 bytes of straight-alpha RGBA8, rows top to bottom. Every failure is an ImgdecException with one of the three codes below; no partial image is ever returned. An argument that is not a string is a TypeError.
Decodes PNG through libpng.
- Palettes and grey are expanded to RGB, and an image with no alpha gets alpha 255.
- A
tRNSchunk becomes alpha, matched on all 16 bits of a 16-bit image. - 16-bit samples are cut to their high byte, and interlacing is undone.
- No gamma is applied: the samples are the file's.
Decodes JPEG, baseline or progressive, through libjpeg's default decode. Alpha is 255.
- Grey is made RGB.
- CMYK and YCCK are converted as gd converts them,
(255 − c) × (255 − k) / 255per channel, with the samples inverted first when an Adobe marker says so. - A warning, such as data that stops early, does not stop a decode.
Decodes the first image of a classic TIFF through libtiff, in any compression libtiff reads, with chunky or separate planes, in strips or tiles.
- Accepted: grey of either polarity at 1, 2, 4, 8 or 16 bits; RGB at 8 or 16; a palette at 1, 2, 4 or 8, its colour map entries cut to their high byte. Unsigned samples, orientation top-left.
- A first extra sample that is alpha is kept. Associated alpha is divided out,
c = round(c × 255 ÷ a)capped at 255. - Anything else is
IMGDEC_UNSUPPORTED, with the reason in the message:photometric 5for CMYK,orientation 3for a turned image,sample format 3for floats,12-bit RGB.
Extends Exception. Its code is one of:
| Constant | Value | Meaning |
|---|---|---|
IMGDEC_CORRUPT |
1 |
The bytes are not a readable image. The message is the library's reason. |
IMGDEC_UNSUPPORTED |
2 |
A readable image in a layout the rules above leave out. The message names it. |
IMGDEC_TOO_LARGE |
3 |
Past one of the limits below. |
| Constant | Value | Meaning |
|---|---|---|
IMGDEC_MAX_SIDE |
65535 |
The longest side an image may have. |
IMGDEC_MAX_PIXELS |
67108864 |
The most pixels one image may hold, 8192 × 8192: a 256 MB result. |
The limits are read from the header and checked before any pixel buffer exists, so a file that only claims to be enormous costs nothing to refuse.
- ext-fb: a pixel store in your display's byte layout.
FbBuffer::blitRgba8()maps thergba8string into it, andpaintRgba8()places it scaled, turned or blended. - ext-pcurl: libcurl's multi socket API, for fetching many tiles at once and decoding each as it lands.
This codebase was built with LLM assistance under human direction. Prompts, reviews, and architectural decisions were owned by a professional software engineer — the model accelerates drafting and exploration; it does not replace judgment on APIs, dependency direction, or what ships.
This repository carries an Open Knowledge Format bundle at .okf/: Markdown with YAML frontmatter, readable by you and loadable by a coding agent. It records what the README doesn't: the library calls behind each decoder's rules, the exact wording of every refusal, how each library's warnings and errors are handled per call, the libraries the build finds through pkg-config, the scratch build and both installers, and a log of every change that altered behaviour.
Start at .okf/index.md. AGENTS.md is the short rule sheet an agent reads before touching the C, the stub, or packaging; each rule points into the bundle for the reason behind it.
Point your agent at the repo and it has the same context the maintainer has. Point yourself at it when the README's answer is "it does X" and you want "because".
- libpng, libjpeg-turbo and libtiff: the libraries every decode goes through.
- PIE: the PHP Foundation's extension installer.
gen_stub.php: the php-src stub format.stubs/imgdec.stub.phpis the API declaration;imgdec_arginfo.his generated from it.- Open Knowledge Format: the spec the
.okf/bundle follows.
The suite is Pest v4 and makes its own images: PNG and JPEG through gd, TIFF through an inline writer, so it needs no image files and no network. It covers each format's rules, alpha exactly as the file holds it, baseline and progressive JPEG, palette and grey TIFF with alpha, and every refusal with its code. It runs on Linux and macOS, and needs ext/gd: built into Homebrew's PHP, php8.4-gd on Debian. Build the module in the clone, then point php at it:
git clone https://github.com/php-io-extensions/imgdec.git
cd imgdec
phpize && ./configure --enable-imgdec && make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
composer install
php -n -d extension="$PWD/modules/imgdec.so" vendor/bin/pestTests: 16 passed (50 assertions)
Duration: 0.05s-n skips your php.ini, so the suite tests the clone and not an installed imgdec.so; without it, a PHP that already loads imgdec warns Module "imgdec" is already loaded and keeps the installed one. Where gd is a shared extension rather than built in, as on Debian packaging, add -d extension=gd before the imgdec one, since -n dropped the ini that loads it. A module only loads in the PHP whose phpize built it: for Homebrew's ZTS build, run phpize and pass --with-php-config from /opt/homebrew/opt/php@8.4-zts/bin.
Open an issue first for anything beyond a typo. Pull requests change stubs/imgdec.stub.php and the C under src/ together, regenerate stubs/imgdec_arginfo.h with gen_stub, and add a Pest test for the behaviour. The decoding rules and the wording of each refusal are a contract other code relies on: a change to either needs its reason in the pull request. CI must be green on every cell of the matrix. Read AGENTS.md for the rules the code follows.
ext-imgdec parses image files, which often come from the network, through libpng, libjpeg and libtiff. See SECURITY.md for what it guards against and how to report a vulnerability.
MIT. See LICENSE.