中文文档 · English
lua_image is a Lua C module (built with Rust) binding the
image crate: multi-format image decoding,
encoding and processing for Lua.
Unlike the two earlier attempts in the ecosystem (mlua-image, abandoned and
built on a vendored Lua VM; maki's image module, coupled to its plugin host),
this module links the host Lua ABI (module mode, never a second VM),
guards decoding against pixel-bomb attacks, and ships with tests and CI
following the lua-stb standard.
- decode / probe / open — PNG, JPEG, GIF, WebP, BMP, TIFF, PNM, QOI, TGA, ICO (from bytes or file)
- encode — PNG, JPEG, WebP (lossless), GIF, BMP, TIFF, PNM, QOI, TGA, ICO
- process —
resize(nearest/triangle/catmullrom/gaussian/lanczos3),crop,rotate(90/180/270),flip_horizontal/flip_vertical - raw pixels —
to_rgba/to_rgbreturnbytes, width, height(same convention as lua_webp's forced colorspaces) - pixel-bomb guard — images declaring more than 50 MP are rejected before any allocation
- Rust (stable, 1.88+) — Windows additionally needs the
x86_64-pc-windows-gnutoolchain - Lua 5.4 (dev headers +
pkg-config; overridable viaLUA_LIB/LUA_LIB_NAME)
Install examples:
# Debian / Ubuntu
sudo apt-get install lua5.4 liblua5.4-dev pkg-config
# macOS (Homebrew)
brew install lua@5.4 pkgconf
# Windows (MSYS2 / UCRT64) + rustup GNU toolchain
rustup toolchain install stable-x86_64-pc-windows-gnu
pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-make \
mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-lua54make build # cargo build --release + module symlink
make test # regenerate fixtures + run tests/test.lua
make test-rust # cargo test: Rust unit tests + the full Lua suite
make mutants # mutation testing (needs `cargo install cargo-mutants`)The Lua suite runs inside cargo test too (tests/lua_tests.rs loads the built
cdylib through the raw Lua C API), which is what makes make mutants
meaningful: every injected mutant is exercised against the full Lua assertion
set, so a surviving mutant is a genuine coverage gap, not unreached code.
tests/test.lua (~150 assertions) covers:
- probe/decode/open: format/width/height detection, pixel-exact
to_rgba/to_rgb/to_lumaoutput, color-type variants (L8/LA8/RGB8/ RGBA8/16-bit), odd dimensions, 1×1, non-square shapes - encode: PNG/JPEG/WebP(lossless)/GIF/BMP/QOI/TGA output, jpeg
qualityand pngcompressionparameters (strict validation), alpha-drop on jpeg - transforms: resize (fit vs
resize_exact), thumbnail aspect preservation, crop bounds, rotate 90/180/270, flip, color ops (grayscale/invert/brighten/contrast/huerotate/blur) - metamorphic invariants: flip/rotate identity (pixel-exact), full-crop identity, lossless re-encode round-trips (png/webp/tiff/bmp/qoi/pnm pixel-exact), resize dimension round-trip
- guards/errors: 50 MP output cap (resize/thumbnail), pixel-bomb rejection before allocation, out-of-bounds crop/pixel access, save round-trips, garbage/corrupt input
.github/workflows/ci.yml runs on every push/PR:
| Job | Runner | Steps |
|---|---|---|
linux |
ubuntu-latest | make build → make test → make test-rust |
macos |
macos-latest | same (Homebrew lua@5.4 via PKG_CONFIG_PATH/LUA_BIN) |
windows |
windows-latest | MSYS2 UCRT64 + GNU Rust toolchain → mingw32-make build/test/test-rust |
All jobs build the same source; the Windows job verifies the GNU linker
path (MinGW Lua ABI). Rust unit tests (pixel-bomb guard, 50 MP boundary,
round-trips) run under cargo test too.
local image = require "lua_image"
-- probe header-only (no pixel decode)
local info = image.probe(raw_bytes) -- { format="png", width=4, height=4 }
-- decode from bytes or open from a file
local img = image.decode(raw_bytes)
local img = image.open("photo.jpg")
-- inspect and transform
print(img:width(), img:height(), img:format())
local thumb = img:resize(256, 256, "lanczos3")
local cropped = img:crop(10, 10, 200, 200)
local rotated = img:rotate(90)
-- encode to bytes (or save to a file)
local png = img:encode("png")
local webp = img:encode("webp") -- lossless
img:save("out.png")
thumb:save("thumb.jpg", "jpeg")
-- raw pixels, tightly packed, with dimensions
local rgba, w, h = img:to_rgba()
local rgb, w, h = img:to_rgb()| Function | Returns |
|---|---|
image.probe(data) |
{ format, width, height } |
image.decode(data) |
Image userdata |
image.open(path) |
Image userdata |
img:width() / img:height() / img:format() |
integers / format string |
img:color_type() / img:has_alpha() |
"rgb8" etc. / boolean |
img:encode(fmt, params?) |
encoded bytes (string) |
img:save(path, fmt?) |
— (fmt inferred from extension if omitted) |
img:resize(w, h, filter?) |
new Image (aspect preserved, fits inside w×h) |
img:resize_exact(w, h, filter?) |
new Image (exact dimensions) |
img:thumbnail(w, h) |
new Image (aspect preserved) |
img:crop(x, y, w, h) |
new Image (RGBA) |
img:rotate(deg) |
new Image (90/180/270) |
img:flip_horizontal() / img:flip_vertical() |
new Image |
img:grayscale() / img:invert() |
new Image |
img:brighten(factor) / img:contrast(c) |
new Image |
img:huerotate(deg) / img:blur(sigma) |
new Image |
img:get_pixel(x, y) |
r, g, b, a |
img:set_pixel(x, y, r, g, b, a?) |
r, g, b, a (modifies in place) |
img:to_rgba() / img:to_rgb() / img:to_luma() |
bytes, width, height |
filteris one of"nearest","triangle","catmullrom","gaussian","lanczos3"(default"triangle").fmtis one of"png","jpeg"/"jpg","webp","gif","bmp","tiff","pnm"/"ppm"/"pgm"/"pam","qoi","tga","ico".params(encode):{ quality = 0-100 }forjpeg(default 90),{ compression = "default"|"fast"|"best"|"uncompressed" }forpng. A parameter that does not apply to the chosen format, or an unknown key, raises an error.- All failures raise Lua errors prefixed with
lua_image:.
- WebP encoding is lossless-only (the
imagecrate has no lossy WebP encoder); for lossy WebP use lua_webp. - JPEG encoding drops alpha (RGBA → RGB); encode back to PNG to keep it.
- Animated GIF/WebP decode the first frame only (no warning).
resizekeeps the aspect ratio (fits inside w×h); useresize_exactfor exact dimensions.crop/flip_*always produce RGBA8; other transforms keep the source color/bit depth.to_rgba/to_rgb/to_lumasilently truncate 16-bit sources to 8-bit.- All transforms enforce the same 50 MP output cap as decode; a request beyond it raises an error instead of OOM-aborting the host.
- Decoding and transforms run synchronously on the Lua thread; very large images block the caller.
- Lua string ↔ pixel data transfer copies the bytes.
MIT. See LICENSE.