Skip to content

Repository files navigation

swift-png

An implementation of PNG in Swift, built to be usable two ways.

PNG is a Swift library with a Swift API and no C dependencies.

libpng16 is the same engine behind libpng's published C API, built so that a program compiled for libpng links or loads it without being changed or rebuilt. That includes the details substitution actually depends on: the complete exported symbol set and nothing beyond it, the versioned library name, and the Mach-O compatibility version or ELF symbol version that the dynamic loader checks.

Status: every published function this build's configuration exports is implemented — 256 of 256, the same count png.h and the reference build agree on once the functions gated out by this build's pnglibconf.h (png_set_strip_error_numbers among them, behind PNG_ERROR_NUMBERS_SUPPORTED, which neither build turns on) are set aside; a raw text search of the header that skips those guards overcounts. Both reading and writing work, sequentially and interlaced, both ways a client can ask for an interlaced image; every colour type, every bit depth from 1 to 16, every filter. The control structure lifecycle, the allocator and stream callbacks, and every metadata chunk with its accessors all work: the palette and transparency, the colour and gamma chunks, the embedded profile, the physical layout, the timestamp, the camera metadata, the high dynamic range signalling, and all three text chunks. Every read transform works — the expansions, the depth conversions, the channel rearrangements, the filler, the shift, gamma correction, the conversion to greyscale, compositing against a background, alpha mode, and quantisation. The convenience png_image_* shortcut is complete on both sides now, too. Reading: colour-mapped output works for a source with real coverage of its own, indexed, grey, grey-plus-alpha and RGB alike, coverage kept or removed, against a named background or not; and discarding colour from a file that also carries coverage is done by running the reference's own workaround for a bug in combining the two, since rgb-to-gray and compositing correct for gamma twice if the library is asked to do both in the same pass, and this does the blend itself once rgb-to-gray has already run instead. Writing: a client's own colour map becomes an indexed file's palette, and — wherever an entry is not fully opaque — its transparency table, built once per entry the way the reference's own png_image_set_PLTE builds it rather than once per pixel. Discarding colour and coverage together now works at sixteen bits as well as eight, the same workaround applied a second time: nothing there composites in the library either, so there is no background to ask for and no bug to trip over.

That last corner — an indexed source asked to remove its coverage onto the client's own buffer with no background named — is closed now, and what it needed was not what it looked like. The transform pipeline was right all along. An indexed image's coverage is arranged in its palette, where the reference premultiplies each partly covered entry and then re-encodes it for the display, so the row that reaches the blend holds sRGB bytes rather than the linear light every other colour type leaves there — and the reference blends them as sRGB bytes, which is not gamma-correct and is exactly what it did to the palette. Reproducing that is the whole of it.

So nothing in the colour-mapped or simplified reader refuses a conversion any more. The only refusals left are the reference's own, which this library returns the same way and which the comparison therefore reads as agreement rather than as a gap.

Building

The Swift library and the test suites:

swift test

The installable C library, which needs the Ninja generator because that is one of the two generators CMake can compile Swift with:

cmake --preset release && cmake --build build/release && ctest --test-dir build/release

Two build options matter. SPNG_USE_SYSTEM_ZLIB (off by default) swaps the compression for the system zlib; the default library is Swift throughout, swift-zlib included, and links against nothing. SPNG_STATIC_STDLIB links the Swift runtime into the library, which is worth doing on platforms that do not ship one.

Using the Swift API

The PNG module is the engine itself, with the same working model the C API has — a context, an info store, and callbacks for memory and bytes — but with Swift types, typed throws, and no pointer-to-struct boundary to cross. The callbacks live in a Host; they are C-compatible function pointers, so state travels through the owner pointer rather than a capture. This host reads from and writes to an in-memory buffer, which is the whole of the boilerplate:

import Foundation
import PNG

final class Buffer {
    var bytes: [UInt8]
    var offset = 0
    init(_ bytes: [UInt8] = []) { self.bytes = bytes }
}

// `PNG.Host` written in full, because Foundation exports a `Host` of its own.
func makeHost(_ buffer: Buffer) -> PNG.Host {
    PNG.Host(
        owner: Unmanaged.passUnretained(buffer).toOpaque(),
        allocate: { _, size in malloc(Int(size)) },
        deallocate: { _, memory in free(memory) },
        read: { owner, destination, count in
            let buffer = Unmanaged<Buffer>.fromOpaque(owner!).takeUnretainedValue()
            precondition(buffer.offset + Int(count) <= buffer.bytes.count, "file truncated")
            buffer.bytes.withUnsafeBufferPointer {
                destination!.update(from: $0.baseAddress! + buffer.offset, count: Int(count))
            }
            buffer.offset += Int(count)
        },
        warn: { _, _, _ in },
        warnChunk: { _, _, _, _ in },
        writeBytes: { owner, data, count in
            let buffer = Unmanaged<Buffer>.fromOpaque(owner!).takeUnretainedValue()
            guard let data else { return }
            buffer.bytes.append(contentsOf: UnsafeBufferPointer(start: data, count: Int(count)))
        },
        flushBytes: { _ in }
    )
}

Decoding reads the header, then rows, each into a buffer of info.rowBytes:

func decode(_ pngBytes: [UInt8]) throws(Diagnostic) -> [[UInt8]] {
    let file = Buffer(pngBytes)
    let host = makeHost(file)

    let context = PngContext(host: host, isReading: true)
    let info = InfoStore(host: host)
    defer {
        info.release()
        context.release()
    }

    try context.readInfo(into: info)

    guard let header = info.header else { throw Diagnostic("no header") }

    try context.updateInfoForClient(info)

    var image = [[UInt8]]()
    var row = [UInt8](repeating: 0, count: info.rowBytes)

    for _ in 0 ..< header.height {
        try row.withUnsafeMutableBufferPointer { (buffer) throws(Diagnostic) in
            try context.readRow(into: buffer.baseAddress)
        }
        image.append(row)
    }

    try context.readEnd(into: nil)

    return image
}

The read transforms are requested between readInfo and updateInfoForClient, as flags on the context rather than calls: context.transformFlags.insert([.expand, .scale16]) asks for palettes and sub-byte depths expanded and 16-bit samples folded to 8, the way png_set_expand and png_set_scale_16 would. An interlaced image wants context.enableInterlaceHandling() there too, after which the same row loop reads full-width rows and knows nothing about passes.

Encoding is the mirror image — describe the image, write the info, feed rows, finish:

func encodeGradient() throws(Diagnostic) -> [UInt8] {
    let out = Buffer()
    let host = makeHost(out)

    let context = PngContext(host: host, isReading: false)
    let info = InfoStore(host: host)
    defer {
        info.release()
        context.release()
    }

    try context.writeHeader(
        Header.Fields(
            width: 256, height: 256, bitDepth: 8, colorType: 2,
            compressionMethod: 0, filterMethod: 0, interlaceMethod: 0
        )
    )
    try context.writePalette(info)

    var row = [UInt8](repeating: 0, count: 256 * 3)

    for y in 0 ..< 256 {
        for x in 0 ..< 256 {
            row[x * 3] = UInt8(x)
            row[x * 3 + 1] = UInt8(y)
            row[x * 3 + 2] = UInt8(x ^ y)
        }
        try row.withUnsafeMutableBufferPointer { (buffer) throws(Diagnostic) in
            try context.writeRow(buffer.baseAddress)
        }
    }

    try context.writeEnd(info)

    // out.bytes is now a complete PNG file.
    return out.bytes
}

Two complete programs in this repository use the API this way and stay compiling: Sources/pngbench-swift drives every read transform and both whole-image and row-at-a-time reading, and Sources/wasm-smoke-test is the round trip above, built for embedded WebAssembly.

Speed

Substituting for a library is not much use if it costs the program that substitutes it. So the same benchmark is compiled twice and linked against each library, the way the conformance programs are, and run over the whole corpus:

cmake --build build/release --target benchmark

It reports decode, decode-with-transforms and encode per image and adds them up. As of this writing the default build — the one that compresses through Swift and links no zlib — totals 0.64× the reference's, and the build that swaps the system zlib in totals 0.70×; either way this library is the faster of the two, and the number moves by about ±0.01 between runs, so read three.

Encode wins by doing less — 0.65× on the corpus in the default build, and a 2048×2048 gradient encodes in 52ms against the reference's 98 — partly from filtering sixteen bytes at a time and partly from a compressor that reaches the same answer with less work: the files it writes are the size the reference writes, byte-identical on most of the benchmark images and a whisker smaller on the rest. Decode wins by reading ahead. Scanlines are decompressed a quarter megabyte at a time and served from that buffer, and the difference is not the bookkeeping but what a roomy output buffer lets the decompressor's fast path do: with a scanline's worth of room it must step carefully near the end of every row, and with a fill's worth it almost never leaves the fast path. A large gradient — the entropy decoder's hardest case, and the one place the reference used to lead — decodes in well under half the reference's time in either build. A 16MB noise image, where the stream is mostly stored bytes and the time goes to checksums and copying, reads in 2.6ms against the reference's 2.9, because the checksums run through an eight-lane carryless-multiply fold where the processor has one. Setting up a decoder is a microsecond-scale affair in both builds — the Swift decompressor keeps its fixed tables built once for the whole process, the way zlib carries them as constant data — so there is no workload left where SPNG_USE_SYSTEM_ZLIB=ON buys anything; it remains for clients who want zlib underneath for their own reasons.

The numbers behind those claims, measured on an Apple M1 Pro against Homebrew libpng 1.6.58 over the system zlib, default build, best of ten alternating rounds. The gradients are the entropy coder's hardest case and the noise image is the filter's; the two encoders write byte-identical files for the 2048×2048 gradient, so the encode column compares the same work.

image decode, reference decode, this library encode, reference encode, this library
512×512 gray gradient 0.24 ms 0.05 ms 2.00 ms 1.20 ms
512×512 RGB gradient 0.31 ms 0.11 ms 6.06 ms 3.34 ms
512×512 RGBA gradient 0.35 ms 0.15 ms 8.15 ms 4.36 ms
2048×2048 RGB gradient 2.44 ms 1.47 ms 97.8 ms 52.2 ms
2048×2048 RGBA noise, 16 MB 2.84 ms 2.63 ms
1×1, the whole lifecycle 1.4 µs 2.5 µs
whole corpus, all three phases 27.2 ms 17.3 ms

The one row the reference still wins is the degenerate one: creating and destroying a decoder around a one-pixel image, where what is measured is object setup rather than decoding, and a microsecond of Swift allocation stands next to a microsecond of C. Everything an image is actually made of decodes and encodes faster here.

Platforms without a C library underneath

PNG and the compression modules it works through have no dependency on zlib, libc, or an operating system, and compile under Embedded Swift for targets that have none of the three: WebAssembly, and bare-metal ARM. Building PNG for one of these disables the SystemZlib trait (swift build --disable-default-traits ...), which switches DEFLATE and INFLATE to the from-scratch, zlib-compatible codec in swift-zlib — the same code path exercised by swift test --disable-default-traits on every hosted platform, so it is not only ever tested on targets that are otherwise hard to test at all.

  • wasm32-unknown-wasip1 builds via swift build --swift-sdk swift-6.3.3-RELEASE_wasm-embedded and is run for real, not just compiled: Sources/wasm-smoke-test is an encode-then-decode round trip executed under Wasmtime in CI.
  • ARM bare metal (aarch64-none-none-elf, armv7em-none-none-eabi, armv6m-none-none-eabi, arm64-apple-none-macho) builds via scripts/build_embedded.sh <triple>, which drives swiftc directly since these triples have no SwiftPM SDK bundle. armv7em-none-none-eabi is also run for real, as bootable firmware under an emulated Cortex-M4F — see Firmware/QEMU. arm64-apple-none-macho has the same boot-and-verify firmware for real Apple Silicon hardware, under Hypervisor.framework — see Firmware/Hypervisor — kept out of CI (its own README says why) and run locally instead.

All of this is what .github/workflows/embedded.yml checks on every push.

How it is put together

CPNG        the published C API, and the parts that have to be C:
            error dispatch and the jump boundary, allocation, callbacks
  PNGABI    one @c function per published entry point
    PNG     the engine: chunks, row pipeline, transforms
      Zlib  DEFLATE and INFLATE, from swift-zlib rather than vendored here

Two decisions shape everything else.

The control structures are split. png_struct is opaque in the published ABI, so its layout is ours to choose. It holds only plain data that the error and callback machinery touches, plus a pointer to a Swift object that owns the codec state. That division exists because a client may longjmp out of any callback we invoke, abandoning Swift frames without running their cleanups; keeping the jump machinery's world free of managed references is what makes that survivable.

The engine throws; only the boundary jumps. Nothing below the exported entry points calls png_error. Failures propagate as thrown Swift errors, every engine frame unwinds normally, and the exported function transfers control to the client's handler as the last thing it does.

Correctness

The C API's behaviour is not something to infer from documentation. Every expectation in the conformance suite is checked against the reference implementation as well as against this one, by compiling the same program twice and linking it against each library. An expectation that only holds for us is a bug in the test, and this arrangement is what catches it.

It has already paid for itself several times over. The reference clears the unused bits at the end of a row narrower than a whole number of bytes, but only in the copy it hands the client, still reconstructing the next row from the bits as stored. It reports a bad header as one warning per fault followed by a single generic error, rather than failing at the first. It passes the decompressor's own wording through rather than summarising it. None of that is written down anywhere; all of it is what clients actually observe.

The transforms are where the reference is least guessable, because a client asks for them in any order and the library applies them in a fixed order of its own. So the comparison drives combinations rather than one transform at a time, and drives several of them twice with the requests reversed — the result has to be identical, and checking that against the reference proves it for the reference too rather than only for us. That is 2640 decodes over 22 images in both read modes.

Discarding colour is a weighted sum in integer arithmetic, and the two depths round it differently: the eight bit path truncates and the sixteen bit path adds a half first. Nothing suggests that asymmetry and it shows on almost every pixel, so it was found by arithmetic rather than by reading — computing both candidates for one pixel and seeing which the reference agreed with.

Compositing an image over a background is the same shape of problem and taught the same lesson twice. The background lives in two spaces at once: a fully transparent pixel becomes it as the display should see it, and a partly covered one is blended against it as light. Which exponent reaches each depends on the space the client said the colour was in, not on the image's own curve — using the image's exponent for a colour that was never in the file's space is the mistake that took longest to find. An indexed image is composited in its palette, once, and its rows left as indices; a request to composite an image with nothing transparent in it is dropped entirely, and dropping it has to take the implied palette expansion with it.

Averaging samples at all is only meaningful on light levels, and a file's samples are not light levels: they carry the file's own curve. So when a gamma correction is in force the conversion decodes each sample to linear, averages there, and re-encodes — and the correction then belongs to the conversion rather than to the separate gamma step, which would otherwise apply it twice. Four consequences of that fall out of the comparison rather than out of the design. The sum rounds on this path where the plain sum truncates. A pixel that was already grey skips the round trip and takes the combined correction directly, which is a different answer because the trip through eight bit linear loses precision. An indexed image's palette is left uncorrected, since the conversion will correct it as it averages. And asking for the conversion suppresses the gamma step even on a greyscale image where the conversion does not run — so a client asking for both on such an image gets no gamma at all, which is peculiar, but is what clients see.

Gamma looks like the one place where agreeing to the last bit is straightforwardly reachable, because the reference build computes it in double precision rather than through its fixed-point logarithm path — so the arithmetic is reproducible rather than a reimplementation of an approximation. The comparison then found the parts that are not arithmetic at all: an indexed image is corrected in its palette, once, and its rows expanded from the corrected entries, so correcting the rows as well double-corrects them; the palette a client reads back afterwards is the corrected one; and samples narrower than a byte are corrected without being widened, by repeating each sample across a byte, looking it up in the eight bit table, and keeping the top bits.

At sixteen bits it is not straightforward at all, and computing the curve exactly is the wrong answer. A table over sixteen bit samples would need 65536 entries, so the reference does not build one: it quantizes a row sample to its top bits first — coarsened by a significant-bits chunk, and capped at eleven bits whenever the samples are to be narrowed to eight, since precision about to be discarded is not worth correcting. Single values it still computes exactly, so a colour-map entry and a row sample of the same number can legitimately differ. And the narrowing table is built backwards: it walks the two hundred and fifty six possible outputs and finds the input at each boundary through the fixed-point reciprocal of the exponent, whose own rounding then shows in the result. That is why about sixty of its entries defeat every forward formula — they are not corrections of any input, they are boundaries. Both constructions are reproduced rather than fitted, which is what it takes to agree.

Almost every rule it found was one that could not have been guessed. The shift moves samples down rather than up: it recovers the narrower values an image was made from rather than filling the depth, so five significant bits in eight means shifting right by three. Asking for colour from a greyscale image implies widening it first, and asking either that or for low-depth greyscale to be widened also expands a palette — but neither turns a transparent colour into an alpha channel, while any expansion at all makes the library stop reporting the transparency. Adding a filler channel happens after the alpha swap rather than before, so a filler alpha is never moved to the front. Palette entries a transparency table does not mention are opaque. And the shift amounts are checked against the image's own depth even for an indexed image, where the plausible reading — that the palette's eight bit samples are what matter — is wrong.

Interlacing turned up a contract that is easy to get wrong. A client that reads an interlaced image row by row calls png_set_interlace_handling, is told there are seven passes, and then sweeps every row of the image seven times — so most of those calls fall on rows the current pass does not carry, and those consume nothing from the stream. An image small enough to have empty passes runs out of data before it runs out of sweeps: a single pixel is carried entirely by the first pass, so six of its seven sweeps have nothing to do at all, and refusing them would break the documented loop.

The metadata chunks turned up more of the same. The significant-bits chunk does not report zero for the channels it does not carry: a greyscale value is mirrored into the colour channels, and an image without an alpha channel reports its own bit depth as its alpha precision. A palette longer than the bit depth can address is truncated silently rather than refused. A background value the image's depth could not express is rejected outright rather than clamped, and an indexed one is resolved through the palette before being reported. An embedded colour profile is refused unless it describes at least two tags — which is a count, not a size, as a thousand-byte profile with one tag is still refused.

The damaged-file half of the corpus is also what verifies the jump discipline, and it found a real defect: a client jumping out of its error handler used to leave a Swift exclusivity access open, so the next call trapped. Reading a byte through a mutating method on a struct held in a class property takes an exclusive access for the whole call, including the part that runs the client's callback — so the engine's mutable state is held by reference, and buffers are replaced rather than mutated in place.

The comparison runs in both directions. Decoding a file exercises the getters but never the setters, since a decoded file reaches the info structure through the parsers; so a second program sets every field and reads it back, and is compiled against each library the same way. That is what checks the conversions in the setters — the mastering display's chromaticity is stored at one scale and published at another, and only a round trip shows that the two halves agree.

Where the reference does something this library should not copy, the case is listed with its reason and the comparison reports it without failing: Conformance/known-differences.txt for decoding, and Conformance/known-transform-differences.txt for the transforms. The decoding file has one entry, a chunk the reference build refuses to read wherever it appears. The transform file has two kinds of entry and keeps them apart on purpose. On the reference's side, it accepts a filler channel it cannot honour on sub-byte samples, reports a row shape that cannot exist, and then fails mid-decode. On this library's side there are three gaps: reversing sub-byte samples within a byte is applied to an interlaced pass row rather than to the full-width row it is spread into, sixteen bit gamma is computed exactly where the reference deliberately uses a coarser table, and the weighted sum that discards colour is taken on encoded samples rather than on linear light. Unfinished work is labelled as unfinished rather than filed alongside a decision.

The comparison also refuses to carry a recorded difference that no longer differs. An exemption that has stopped applying is worse than none at all, because it sits in the file looking considered while silently covering whatever regresses into its place — so the file has to earn every line it holds.

Licensing

This implementation is under the license in LICENSE. The public headers are libpng's own, vendored unchanged because they define the ABI being implemented; LICENSE.libpng covers those.

About

Swift library for PNG

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages