PNG-8 encoder for Kotlin Multiplatform — Android, JVM/desktop, iOS, and the
web (JS and Wasm). Palette quantization with full alpha support, written in
pure Kotlin: no NDK, no native binaries, no third-party dependencies, and no
java.util.zip — the DEFLATE compressor is part of the library.
ARGB pixels in, PNG ByteArray out. On Android, android.graphics.Bitmap
in as well.
Bitmap.compress(PNG, …) always writes 24/32-bit PNG and ignores the
quality argument, so it does not reduce colour depth at all. The usual
answer is pngquant / libimagequant, which is
excellent but needs JNI and is GPL-or-commercial.
Pngine fills the gap: Apache-2.0, pure Kotlin, runs on device — and, being pure Kotlin all the way down to DEFLATE, runs unchanged on iOS, desktop and in the browser.
It is not a reimplementation of anything novel — see Prior art. It is a permissively licensed, dependency-free implementation of well-established algorithms.
androidTarget, jvm, iosArm64, iosSimulatorArm64, iosX64, js and
wasmJs.
Pngine is on Maven Central.
Make sure mavenCentral() is among your repositories — new projects
already have it in settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Add it to commonMain and every target picks up the right artifact:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("org.onedroid:pngine:0.1.0")
}
}
}dependencies {
implementation("org.onedroid:pngine:0.1.0")
}# gradle/libs.versions.toml
[versions]
pngine = "0.1.0"
[libraries]
pngine = { module = "org.onedroid:pngine", version.ref = "pngine" }implementation(libs.pngine)implementation 'org.onedroid:pngine:0.1.0'Maven does not read Gradle module metadata, so name the JVM artifact directly:
<dependency>
<groupId>org.onedroid</groupId>
<artifactId>pngine-jvm</artifactId>
<version>0.1.0</version>
</dependency>From common code, on every target:
import org.onedroid.pngine.Pngine
val bytes = Pngine.encodePixels(argbPixels, width, height)argbPixels is one packed Int per pixel, row-major, alpha in the high
byte. It is modified in place during alpha normalisation — pass a copy if
you still need the original.
Encoding is CPU-bound — run it off the main thread:
val bytes = withContext(Dispatchers.Default) {
Pngine.encodePixels(argbPixels, width, height, PngineOptions(maxColors = 128))
}Presets:
PngineOptions.MaxQuality // slower, wider dither kernel
PngineOptions.Fast // batch work, low memoryThe Android source set adds a Bitmap overload as an extension, so it needs
its own import:
import org.onedroid.pngine.Pngine
import org.onedroid.pngine.encode
suspend fun saveCompressed(context: Context, bitmap: Bitmap): File =
withContext(Dispatchers.Default) {
val bytes = Pngine.encode(bitmap)
File(context.cacheDir, "out.png").apply { writeBytes(bytes) }
}The bitmap is only read; you keep ownership of it. From Java the call reads
PngineBitmaps.encode(Pngine.INSTANCE, bitmap).
Bitmap.getPixels cannot read Config.HARDWARE bitmaps, which is what
ImageDecoder (API 28+) returns by default. Ask for a software bitmap when
decoding:
val bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri)) { decoder, _, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
}or copy an existing one with bitmap.copy(Bitmap.Config.ARGB_8888, false).
Pngine throws IllegalArgumentException with that advice rather than
letting the platform surface a confusing error.
BufferedImage.getRGB already returns packed ARGB:
import java.io.File
import javax.imageio.ImageIO
import org.onedroid.pngine.Pngine
val image = ImageIO.read(File("input.png"))
val pixels = image.getRGB(0, 0, image.width, image.height, null, 0, image.width)
File("output.png").writeBytes(Pngine.encodePixels(pixels, image.width, image.height))Encode in Kotlin and hand the bytes to Swift as NSData:
// iosMain
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import org.onedroid.pngine.Pngine
import platform.Foundation.NSData
import platform.Foundation.create
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
fun encodePng(pixels: IntArray, width: Int, height: Int): NSData {
val bytes = Pngine.encodePixels(pixels, width, height)
return bytes.usePinned {
NSData.create(bytes = it.addressOf(0), length = bytes.size.toULong())
}
}On the Swift side, UIImage(data:) displays the result and
data.write(to:) saves it.
Browser ImageData, Skia and most decoders hand out RGBA bytes rather than
packed ARGB. Convert before encoding:
fun rgbaToArgb(rgba: ByteArray): IntArray = IntArray(rgba.size / 4) { i ->
val o = i * 4
((rgba[o + 3].toInt() and 0xFF) shl 24) or
((rgba[o].toInt() and 0xFF) shl 16) or
((rgba[o + 1].toInt() and 0xFF) shl 8) or
(rgba[o + 2].toInt() and 0xFF)
}| Option | Default | Effect |
|---|---|---|
maxColors |
256 | Palette size, 2..256 |
dithering |
true | Diffuse quantization error |
ditheringAmount |
0.75 | Global dither strength, 0..1 |
ditheringMethod |
FLOYD_STEINBERG |
Error-diffusion kernel |
compressionLevel |
9 | Deflate level, 0..9 |
sampleStride |
1 | Histogram subsampling step |
alphaThreshold |
16 | Alpha at or below this becomes fully transparent |
alphaLevels |
16 | Alpha quantization steps; 256 leaves alpha alone |
alphaWeight |
8 | Weight of alpha in palette distance |
usePerceptualDistance |
true | redmean instead of RGB Euclidean |
kmeansIterations |
7 | Palette refinement passes |
kmeansSampleRate |
8 | Refinement samples every Nth pixel |
useGammaCorrectFS |
true | Diffuse error in linear light |
alphaDiffusionDamping |
0.2 | Alpha error scaling, 0..1 |
errorAdaptiveDither |
true | Attenuate dither where residual is small |
lowErrorThreshold |
18 | Residual below which dither attenuates |
Soft edges suffer when alpha is pre-quantized. For icons and cutouts, raise
alphaLevels to 32 or 256.
- Normalise alpha — clamp near-transparent to zero, snap the rest onto
alphaLevelssteps - Build a 5-5-5 RGB histogram
- Median-cut the histogram into a palette (Heckbert)
- Refine with Lloyd/k-means relaxation in RGBA
- Remap pixels with error diffusion in linear light
- Write indexed PNG — IHDR, PLTE, optional tRNS, IDAT, IEND
- Compress IDAT with the bundled DEFLATE encoder: LZ77 over a 32 KiB window with hash chains and lazy matching, then whichever of a stored, fixed-Huffman or dynamic-Huffman block is cheapest
Every algorithm here is published work. Pngine claims no novelty; it claims a licence and a platform. Full citations are in NOTICE.
- Median-cut quantization — Heckbert (1982)
- k-means / Lloyd refinement — Lloyd (1982)
- Floyd-Steinberg error diffusion — Floyd & Steinberg (1976)
- Jarvis-Judice-Ninke error diffusion — Jarvis, Judice & Ninke (1976)
- Perceptual colour distance ("redmean") — Riemersma / CompuPhase
- PNG container — RFC 2083
- DEFLATE and zlib containers — RFC 1951 and RFC 1950
No code was taken from pngquant or libimagequant.
Ordered by expected size win:
- Sub-8-bit depths. Palettes of 16 or fewer colours can pack at 4bpp, halving IDAT. Currently always bit depth 8.
- Per-row filter selection. Only filter type 0 is emitted. Trying
Up/Subper row wins on flat and vertically repeating images. - Empty-cluster reseeding. k-means clusters that lose all members keep their old value instead of being reseeded, wasting palette slots.
- Output-size guard. Fall back to the source when quantization does not actually save bytes.
- Faster nearest-colour search. Currently a linear scan over the palette per pixel.
- Faster DEFLATE. The bundled compressor is straightforward rather than tuned; zlib is still quicker on the JVM.
./gradlew :pngine:allTestsThat runs the common suite on every target — JVM, Android host, iOS simulator, Node for JS and Wasm.
Tests decode the emitted bytes with a strict in-test PNG reader that
verifies chunk lengths, CRCs and row filters, rather than trusting a
platform decoder. The compressor is round-tripped through an inflater
written for the test suite, so the check also runs on JS, Wasm and native;
the JVM source set repeats the same cases against java.util.zip.Inflater,
so a shared misreading of RFC 1951 cannot hide a bug.
sample/ is a Compose Multiplatform app — Android,
iOS, desktop and web — that encodes a generated image and reports the size
saved. It builds against this repository through a composite build.
The site under docs/ is built with MkDocs Material and deployed to
GitHub Pages by .github/workflows/docs.yml
on every push to main that touches it.
To preview locally:
python3 -m venv .venv
.venv/bin/pip install -r docs/requirements.txt
.venv/bin/mkdocs serveApache-2.0. See LICENSE.