Skip to content

Repository files navigation

Pngine

Maven Central CI License

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.

Documentation

Why

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.

Targets

androidTarget, jvm, iosArm64, iosSimulatorArm64, iosX64, js and wasmJs.

Install

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()
    }
}

Kotlin Multiplatform

Add it to commonMain and every target picks up the right artifact:

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("org.onedroid:pngine:0.1.0")
        }
    }
}

Android or JVM only

dependencies {
    implementation("org.onedroid:pngine:0.1.0")
}

Version catalog

# gradle/libs.versions.toml
[versions]
pngine = "0.1.0"

[libraries]
pngine = { module = "org.onedroid:pngine", version.ref = "pngine" }
implementation(libs.pngine)

Groovy DSL

implementation 'org.onedroid:pngine:0.1.0'

Maven

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>

Usage

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 memory

Android

The 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.

JVM and desktop

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))

iOS

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.

RGBA sources

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)
}

Options

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.

How it works

  1. Normalise alpha — clamp near-transparent to zero, snap the rest onto alphaLevels steps
  2. Build a 5-5-5 RGB histogram
  3. Median-cut the histogram into a palette (Heckbert)
  4. Refine with Lloyd/k-means relaxation in RGBA
  5. Remap pixels with error diffusion in linear light
  6. Write indexed PNG — IHDR, PLTE, optional tRNS, IDAT, IEND
  7. 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

Prior art

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.

Roadmap

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/Sub per 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.

Testing

./gradlew :pngine:allTests

That 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

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.

Docs

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 serve

Licence

Apache-2.0. See LICENSE.

About

PNG-8 encoder for Kotlin Multiplatform, in pure Kotlin.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages