# Changelog All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [2.2.1] - 2026-08-10 No library change: `git diff v2.2.0..HEAD -- src include` is the version macros and nothing else. This is a packaging and documentation release, cut so that what reaches the ESP Component Registry is a version rather than a tag. ### Added - **lowzag is published on the ESP Component Registry, as `defensiblelogic/lowzag`.** One line to depend on it: ```sh idf.py add-dependency "defensiblelogic/lowzag^2.2.1" ``` The component manager adds the requirement to whichever component's manifest asked for it, so there is nothing to write into a `CMakeLists.txt` afterwards. A git dependency still works and is still documented, for tracking a branch or building a commit that has no release. - `CONTRIBUTING.md`, covering the build and test layout, what CI runs, and where to send a patch or a report. Development is on a GitLab instance that needs an account; the GitHub mirror's issues are the route in for everyone else, which is now written down rather than assumed. - **The scripts that render the two sample images**, `examples/gen_sample.py` and `examples/esp32/main/gen_sample.py`. Both take no argument, read no file and fetch nothing, and each writes its committed JPEG in place — the landscape from a fixed seed, the test card from no randomness at all. `test/check_images.py` regenerates both and compares byte for byte; it runs in `make check` and in the `examples` CI job with `--require`, so a missing dependency there is a failure rather than a silent skip. The images were always original to the project, but nothing in the tree said so or could show it — the generators lived in a scratch directory outside the repository. Provenance that rests on a directory nobody will have in a year is not provenance. Now anyone can clone, run the two scripts, and get the committed bytes back. - A note in the README's Provenance section covering the images, which previously covered only code. ### Fixed - **`examples/esp32` could not be built anywhere but inside this checkout**, which is a problem for a project the registry serves as the component's example. It failed at configure time with `Failed to resolve component 'lowzag' required by 'main'`, for two reasons at once: the project `CMakeLists.txt` appended `../..` to `EXTRA_COMPONENT_DIRS`, which outside the checkout is whatever the user's grandparent directory happens to be, and nothing declared the dependency, because `main/` had no manifest. So there was no component of any name for `main` to require. A third thing was wrong without being a cause: `main/CMakeLists.txt` named `lowzag` in `REQUIRES`, which is not what a registry install is called. That one never failed on its own, and the reason is worth writing down because it is easy to get backwards in either direction: `_choose_component` in the component manager's `cmake_component_requirements.py` maps a required `lowzag` onto the known `defensiblelogic__lowzag` before CMake sees the list, so once the dependency existed at all the short name resolved. Checked on ESP-IDF 5.4.1, both ways round — with the manifest present the example builds with `lowzag` still in `REQUIRES`; with the manifest removed it fails exactly as above. The line is dropped regardless: it names nothing that is in the build and it works only by that rewrite. There is now a `main/idf_component.yml` that depends on `defensiblelogic/lowzag` with an `override_path` back to the repository root. The registry strips `override_path` when it serves an example, so the in-tree build uses the working tree and a copy taken from the component page resolves the same dependency from the registry, with no edit either way and no build-system branch. `EXTRA_COMPONENT_DIRS` is gone and `REQUIRES` now names only `esp_timer`. - **The documentation told ESP-IDF users to add `lowzag` to `REQUIRES`.** The component manager injects the requirement itself, and under a registry install the name is `defensiblelogic__lowzag`, so the instruction was at best redundant and named something that is not in the build. Removed from all of it. The install prose now leads with the registry in the six places that carried the git recipe; the git route is documented after it, and the material on git refs not being semver ranges is unchanged, because it is still true. - `idf_component.yml` pointed `issues:` at a tracker that returns 404 to anyone not signed in to the GitLab instance. It and `repository:` now name the GitHub mirror; `url:` still names GitLab, where development happens. That split is deliberate and the manifest now says why: the registry resolves README.md's sixteen relative links against `repository`, deleting them outright if it is unset and pointing them at a host it cannot reach if it is self-hosted. The no-op `examples:` key is gone — anything under `examples/` is discovered anyway, and listing it only made the upload carry a second, empty archive. - `check_docs.py` verified the dependency pin by grepping for the git form, `version: "vX.Y.Z"`. Demoting the git stanzas would have left that pattern matching nothing, which passes. It now requires the registry form in each of the five documents that carry it, and fails when a document carries none. It also pins the manifest description's memory figures — the one copy of them nothing checked, in the one document that cannot be edited after publication. - **`test/fuzz/corpus/bad-escapes.jpg` was byte-identical to `p-32x24-420.jpg`** and had been since 1.0.0. It is meant to carry mangled `FF` escapes, but it was built by rewriting `FF 00` in a 778-byte file that contains no such pair, so the transform did nothing and the corpus claimed a case it never tested. Rebuilt from `p-48x48-rst.jpg`, which has six. `FF 00` becomes `FF C4`, not the originally intended `FF D0`: a restart marker is a resync point, so `FF D0` yields a file the decoder legitimately *accepts*, which is a fine input but not a broken one. `check_docs.py` now refuses any two seeds with the same hash. - Six documents described the sample images, and the ESP32 benchmark sources, as photographs. They are procedural renders and test cards. Statements about photographs as a class — which are why the library exists — are unchanged. ## [2.2.0] - 2026-08-07 Two additions, both off unless asked for: the bytes of the APPn segments lowzag is already walking past, and an image out of a stream that ended early. Appended fields only, zero meaning what 2.1.0 did, so a 2.0.0 caller still needs no change and no recompile. ### Added - **APPn passthrough.** `cfg.marker` receives the payload of whichever segments `cfg.marker_mask` selects — bit *n* is APPn, so `LOWZAG_APP(1)` is EXIF and `LOWZAG_APP_ALL` is all sixteen. lowzag does not interpret a byte of it: EXIF is a TIFF parser and an ICC profile is a colour-management problem, and a JPEG decoder that grew either would be a worse JPEG decoder. But it is the only thing positioned to hand the bytes over, since the caller may be streaming a file it does not have in memory. Payloads arrive in chunks of a few hundred bytes, each carrying its offset and the whole payload's length, and nothing is buffered — the alternative was an allocation whose size is a field in the file, which is an allocation an attacker chooses. The callback returns 0 to continue, a positive value to drop the rest of that segment (the decode is unaffected), or a negative one to abandon the decode, which is `LOWZAG_ERR_CANCELLED` for the same reason `cfg.cancel` is. Every segment is delivered before row 0, in file order, including one sitting between two scans: the entropy pass finishes before the output pass starts. `lowzag_probe()` and `lowzag_estimate()` deliver nothing — they are handed a buffer, so the caller already has the bytes. - **`lowzag_exif_orientation()`**, the fifty lines of TIFF header walking that almost every caller of the above actually wants: a captured APP1 payload in, 1..8 or 0 out. No allocation, no recursion, no sub-IFDs, every offset checked against the length, and any disagreement at all answered with 0 rather than a guess. It lives in `src/lowzag_mem.c`, with `lowzag_decode_mem()`, so that `src/lowzag.c` keeps referencing nothing but `memcpy` and `memset`; a build configured with `-DLOWZAG_BUILD_MEM_API=OFF` therefore does not have it. - **Partial output from a truncated stream**, behind `cfg.allow_partial`. Partial decoding is the format's own feature — each scan refines the last, so a stream that stops after the DC scan and two AC scans still describes a complete, coarser picture, which is what a browser shows while a photograph loads. lowzag was throwing it away. The result is `LOWZAG_PARTIAL`, never `LOWZAG_OK`: a caller who asked for an image and got half a file has to be able to tell, and a flag in `info` would be found by the diligent and missed by everyone else. What arrives is a full frame — every row 0..`out_h`-1, `rows_delivered == out_h` — out of fewer scans. Blocks that no scan reached hold no coefficients at all and render as flat mid-grey; `info.dc_coverage` is the percentage of the frame the DC pass reached, 100 for every complete file, and a caller who will not show a half-grey image checks it. `info.scans_decoded` and `info.bytes_consumed` say how much of the file arrived. Measured against libjpeg's own partial rendering of the same truncated bytes, on `examples/sample.jpg`: 20.5 dB against the complete image at 6 KB where djpeg manages 19.5, 33.2 at 12 KB against 32.9, and bit-identical output by 30 KB. `info.dc_coverage` is per component and reports the worst of them, with a component that no DC scan reached counting as 0. DC scans need not be interleaved: a file carrying one DC scan per component, cut between them, has whole components with nothing in them, and a number derived from the scans that ran would say 100 — "no block is empty" — over a monochrome picture 15 dB from the truth. `test/fixtures/sepdc-16x16.jpg` is built by the fixture generator for exactly that shape, since no ordinary progressive file can reach it. A read callback that reports FAILURE is still `LOWZAG_ERR_IO` and not a partial image: that is the caller's own I/O breaking rather than a short file, and a reader that would rather have the picture returns 0 for end of input instead. That holds at every offset, not only inside the entropy data — a failure between segments, in a segment header or in an APPn payload reaches the marker loop as a bare truncation, and the marker loop now checks why the reader stopped before it decides it has an image. - `examples/preview.c`, a fourth example: the orientation before the pixels, and a picture out of a stream cut short on the command line. `examples/esp32` does both on target and prints the numbers. ### Changed — no source change needed - **`lowzag_state_size()` returns a different number** — 17232 bytes on a 64-bit host build, up from 17208, and 17144 on the ESP32-S3, because the reader and the decoder state carry the short-stream bookkeeping. Callers were always told to call the function. - `LOWZAG_VERSION_NUMBER` is 20200. It should have been 20100 in 2.1.0 and was left at 20000, which is 2.0.0's value: the macro was the one part of the version that nothing compared against anything. `test/check_docs.py` now derives it from the three component macros, so it cannot drift again. A caller testing `>= 20000` is unaffected; one that tested `>= 20100` to detect 2.1.0 was told 2.1.0 was 2.0.0, and there is no fixing that in retrospect. - `lowzag_err_t` has one new member, `LOWZAG_PARTIAL`, at the end. Deliberately not spelled `LOWZAG_ERR_`: it is not a failure, and it is not `LOWZAG_OK`. Code that switches on the enum without a `default` will warn about it, which is the intended amount of noise. Decoded output is unchanged for a caller who uses neither feature, and for a complete file with both: 488 differential comparisons against `djpeg`, 864 crop sub-rectangle checks and 24 damaged-file checks give the same answers. The entropy loops are untouched — every branch the partial path adds is gated on `cfg.allow_partial` and sits in the outer per-block-row loop, beside the cancellation poll. ### Testing - **ThreadSanitizer runs in CI**, and `test/thread_test.c` is what it runs. The header has promised since 1.0.0 that concurrent decodes are safe with their own config and their own working state, and nothing in the repository had ever started a second thread — the claim rested on reading the source and on helgrind runs done by hand. Eight threads now put `lowzag_decode()`, `lowzag_decode_mem()`, `lowzag_estimate()` and `lowzag_probe()` in flight together, with `cfg.state` supplied and NULL, a mutex-guarded shared allocator and per-thread allocators, several threads on one file at one scale and several on different files. Every result is compared against a single-threaded reference byte for byte. `make -C test check-threads` also runs a positive control — every thread deliberately sharing one `cfg.state` — and fails if TSan does not report it, because a race detector that has never been seen to fire says nothing. - **MemorySanitizer runs in CI** over the API suite and over the whole corpus at all four scales, with `-fsanitize-memory-track-origins=2`, and the job states what it does not cover: libc is not instrumented, so this rests on lowzag calling nothing but `memcpy` and `memset` and on the drivers reaching libc only through functions the runtime intercepts. `make -C test check-api-msan` also runs a canary — one decode with an uninitialised `cfg.state`, which the header documents as an indeterminate read — and fails unless MSan stops it. `test/fuzz` is deliberately not the target: its allocator fills every block with 0xA5, which under MSan marks the decoder's working buffers initialised and removes the one property MSan is there for. - SECURITY.md said both of these had been run by hand and that nothing in the repository reproduced them. That was true and is not any more, and the paragraph now names what each job covers and what it does not. ## [2.1.0] - 2026-08-07 The first release since the struct-size scheme went in, and the first evidence it works: this adds a public function and a header field without touching SOVERSION, and a 2.0.0 caller needs no change and no recompile. ### Added - `lowzag_state_align()`, the alignment `cfg.state` requires. Found by running `examples/esp32` on real hardware for the first time: the working state contains a 64-bit field and wants 8-byte alignment, but ESP-IDF's `heap_caps_calloc(..., MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)` returns 4-byte-aligned memory, which lowzag refused with a bare `LOWZAG_ERR_INVALID_ARG`. A caller placing the state deliberately — the whole reason `cfg.state` exists — had no way to learn the number. The ESP-IDF example now uses `heap_caps_aligned_calloc()` with it. ### Changed - `include/lowzag.h` carries a cancellation-latency figure measured on an ESP32-S3 rather than only figures scaled from a host: 481 polls over a 212 ms decode, worst gap 2.4 ms, on the example's 640x480 sample at 1/4. It is a much smaller frame than the table's 12-megapixel rows, so it does not confirm those; it shows the mechanism working on target. ## [2.0.0] - 2026-08-06 A breaking release, spent on one feature and on never needing to break again. Every public struct now begins with a caller-set `struct_size`, so appending a field to any of them is a minor release from here on. Six items of real work to migrate, three of them mechanical; the first is the only one the compiler will not catch. ### Changed — source changes required - **Set `struct_size` on every `lowzag_cfg_t`, `lowzag_info_t` and `lowzag_estimate_t`**, or use `LOWZAG_CFG_INIT` / `LOWZAG_INFO_INIT` / `LOWZAG_ESTIMATE_INIT`. Leaving it at zero is `LOWZAG_ERR_VERSION` on the first call, deterministically, every time. `lowzag_decode(cfg, info)` keeps its shape, so this is the one change that fails at run time rather than at compile time. There is deliberately no `lowzag_cfg_init()` function: a function inside an older library would write an older size into a newer caller's struct, which is precisely the mistake the field exists to catch, so the initialisers are macros evaluated in your translation unit. **Including the structs you used to leave uninitialised.** Through 1.1.1 `lowzag_decode()` began by `memset`ing the whole of `*info`, so ```c lowzag_info_t info; /* 1.1.1: fine, lowzag cleared it */ lowzag_decode(&cfg, &info); ``` was correct, and it is what 1.1.1's own `examples/decode_to_ppm.c` did. 2.0.0 cannot do that: it has to read `info.struct_size` to know how much of the struct exists before it may write any of it. An uninitialised `lowzag_info_t` therefore hands lowzag whatever the stack held. Almost always that is refused, but a leftover word that happens to be a multiple of `sizeof(size_t)` between 8 and `sizeof(lowzag_info_t)` is indistinguishable from an older caller's struct, and lowzag will fill that many bytes and return `LOWZAG_OK` with the rest of the struct still garbage. "Every time" above means every time the field is *zero*; give the struct an initialiser and it is. `LOWZAG_INFO_INIT` is the one-word fix: ```c lowzag_info_t info = LOWZAG_INFO_INIT; ``` The same applies to `lowzag_estimate_t`. It does not apply to `lowzag_cfg_t` in practice — any config with callbacks in it was already built by an aggregate initialiser or a `memset`, both of which zero the field — but set it there the same way. A wrong value is refused as firmly as a missing one: `struct_size` must be a multiple of `sizeof(size_t)`, which every real `sizeof` of these structs is, so a value that would land in the middle of a field cannot be mistaken for a short struct and spliced into half a callback pointer. - **`lowzag_estimate()` takes the config first**: `(data, len, cfg, out)` → `(cfg, data, len, out)`. The pointer types differ, so an un-updated call site fails to compile. - **`lowzag_decode_mem()` takes a config**: `(jpeg, len, target_w, target_h, mem_limit, out, out_size, info)` → `(cfg, jpeg, len, out, out_size, info)`. `target_w`, `target_h` and `mem_limit` move into `cfg`, which also unlocks `crop_*` and `cancel` for this entry point. Still six arguments, and it is the same `cfg` you sized the buffer with. `cfg` may not be NULL here. Compile error if missed. - **A too-small output buffer is `LOWZAG_ERR_BUFFER_TOO_SMALL`, not `LOWZAG_ERR_NOMEM`.** The two used to be the same code and a caller could not tell "your pixel buffer is short" from "`mem_limit` is too low to decode this at all". `LOWZAG_ERR_NOMEM` now means only lowzag's own working storage, which is what `lowzag_decode()` returns for the same condition. - **`lowzag_state_size()` returns a different number** — 17208 bytes on a 64-bit host build, up from 17000, because the state struct grew crop geometry, a discard block and the cancellation bookkeeping. Callers were always told to call the function; anyone with a hardcoded buffer must re-read it. - **`LOWZAG_BUILD_MEM_API=OFF` with `BUILD_SHARED_LIBS=ON` now fails at configure time.** The option preserves "only `memcpy` and `memset` undefined", which matters for a freestanding static link and is meaningless in a shared library that already links libc — and it silently changed a shared library's exported symbols without moving the `SOVERSION`. Static builds are unaffected. ### Changed — no source change needed - `lowzag_probe()` and `lowzag_estimate()` return `LOWZAG_ERR_TRUNCATED` instead of `LOWZAG_ERR_NOT_JPEG` for a prefix under four bytes whose bytes are consistent with a JPEG. `NOT_JPEG` is now returned only on the evidence of bytes actually present: a one-byte `0xFF` prefix is truncated, a one-byte `0x42` prefix is not a JPEG. The growing-buffer loop the header advertises works for a caller who starts very small; one that treated `NOT_JPEG` as "give up" now keeps reading. - `lowzag_decode_mem(cfg, jpeg, len, NULL, 0, &info)` is a size query rather than `LOWZAG_ERR_INVALID_ARG`: it fills `info` and returns `LOWZAG_ERR_BUFFER_TOO_SMALL`, the same code and the same code path as a real buffer that turns out to be too small. `NULL` with a nonzero `out_size` is still `LOWZAG_ERR_INVALID_ARG` — that is a caller bug, not an idiom. - A negative `target_w`/`target_h` given to `lowzag_decode_mem()` now means what it means on the config — no constraint — rather than `LOWZAG_ERR_INVALID_ARG`. Item three moved it onto a shared field. - `SOVERSION` 1 → 2. An old binary cannot load the new shared library. ### Added - `cfg.cancel` / `cfg.cancel_ctx`: return nonzero and the decode stops with `LOWZAG_ERR_CANCELLED`, every allocation released, no further row delivered and the read callback not called again. Polled in both passes — once per marker segment, once per 64 KB while the coefficient arrays are cleared, once per block row of every scan, once per MCU row of the output pass, about 3,000 times for a 12 MP file — and never inside the per-block or per-pixel loops. A NULL cancel is a decode that always runs to completion, and costs nothing. Worst case measured between consecutive polls on a 2 GHz aarch64 host, 4000x3000 4:2:0: 0.07 ms at 1/4 and 0.64 ms at 1/1 for a 247 KB photograph, 0.33 / 1.41 ms for a 9.8 MB file of pure noise. Size a timeout from the noise figures — they are the worst case, and dense entropy data is what an adversary sends. `make -C test check-polls` is what measures it, over the corpus at all four scales, and fails on a loose ceiling — the point is to catch a loop with no poll in it, not to police the machine's load. The corpus holds no 12 MP noise file (it would be 9.8 MB of committed random bytes); the header says how to regenerate one. The header's previous advice — stop from the read callback — could only reach the parse; by the time rows are emitted the whole file has been read, and the output pass is 44-53% of a 1/1 decode. - `cfg.crop_x/y/w/h`: decode a rectangle of the source image. A block outside it keeps its 8-byte nonzero map and no coefficients at all, and is never inverse-transformed. On a 12 MP 4:2:0 frame an 800x480 window costs 3.4 MB at scale 1 against 36.2 MB for the frame. Source-pixel coordinates, because automatic scale selection and the `mem_limit` ladder can both land on a scale the caller did not pick and an output-space rectangle would then name a different part of the picture. Snapped outward to whole output pixels, never inward; `info.crop_*` and `lowzag_estimate_t.crop_*` report what you get. Cropping saves memory and back-end work, not parse time — every scan is still entropy-decoded, because a later refinement scan's bit count depends on the nonzero history of blocks it will never display. A crop covering the whole frame is inert. - `info.rows_delivered`: rows handed to `cfg.row` before the return. Equal to `out_h` on `LOWZAG_OK`, less after a cancellation or a truncated file, and then it is how much of a `lowzag_decode_mem()` buffer is valid — a number the header previously only had prose for. - `info.crop_x/y/w/h` and `lowzag_estimate_t.crop_x/y/w/h`. - `LOWZAG_ERR_CANCELLED`, `LOWZAG_ERR_BUFFER_TOO_SMALL` and `LOWZAG_ERR_VERSION`, appended to the enum so no existing numeric value moves. - `LOWZAG_VERSION_NUMBER` (major\*10000 + minor\*100 + patch) for `#if` tests, and `LOWZAG_RESTRICT`, which qualifies `lowzag_decode_mem()`'s `jpeg` and `out`: overlapping them is undefined, and the qualifier says so in the signature where it cannot be missed. - Cancellation and cropping coverage in `test_api.c`: abort at the first poll, mid-output and during the entropy pass; a cancel that never fires, checked byte-for-byte against no cancel at all; one that always fires; one that reenters lowzag and hits the reentrancy guard. Every abort path is checked with a counting allocator to free exactly what a success frees. The crop invariant — a cropped decode is bit-identical to the same sub-rectangle of an uncropped one at the same scale — is checked over four scales and seven rectangles, block-aligned and not. - The fuzz harness cancels one input in four at a poll number derived from the input hash, and crops one in four at a rectangle derived from it. - A `struct_size` sweep in `test_api.c`: every value from 1 to past the end of each struct, asserted in both directions — refused unless it is a size a real `sizeof` could produce, accepted when it is. It found the one below. ### Fixed - **A `struct_size` that lands inside a callback pointer is `LOWZAG_ERR_VERSION` rather than a crash.** The rule was "at least `sizeof(size_t)`, at most this build's `sizeof`", and a value in between was treated as an older caller's struct and copied. A value inside `cfg.free` — 65 through 68 on LP64 — produced a pointer made of half the caller's bytes and half zeroes, which is not NULL, so it passed the null-callback check and was called during cleanup. `struct_size` must now also be a multiple of `sizeof(size_t)`; every real `sizeof` of these structs is one, in this version and in any future one, so no conforming caller is affected. A field whose job is to make a mis-sized struct diagnosable must not have a range that segfaults. ### Documented - **A caller-supplied `cfg.state` must be written before its first use** — `calloc`, or one `memset`. lowzag reads the reentrancy sentinel out of that buffer before it writes to it, so a bare `malloc` result is a read of an indeterminate value: Valgrind and MemorySanitizer both report it on the pattern the ESP32 example used, and one bit pattern in 2^64 would be refused with `LOWZAG_ERR_INVALID_ARG` for nothing. True since 1.1.1, when the sentinel was added, and never written down. The example now uses `heap_caps_calloc`. ### Unchanged `lowzag_read_cb_t`, `lowzag_row_cb_t`, `alloc`/`free`, the row format, the delivery order, the `force_scale`/`target_w`/`target_h`/`mem_limit` semantics, `lowzag_probe()`'s signature, `lowzag_state_size()`'s signature, `lowzag_strerror()`. Still six exported symbols, still `memcpy` and `memset` as the only undefined ones in `src/lowzag.c`, still an empty `.data` and `.bss`. Decoded output is unchanged: 488 differential comparisons against `djpeg` and 24 damaged-file checks, with the crop inactive, give the same answers they did for 1.1.1. ## [1.1.1] - 2026-08-06 ### Fixed - Reentering `lowzag_decode()` with the same `cfg.state` — most easily by calling it from inside its own row callback — is now refused with `LOWZAG_ERR_INVALID_ARG`. It previously returned `LOWZAG_OK` having emitted a fraction of the image, with the outer decode's entire arena leaked, because the inner call's `memset` cleared the outer one's bookkeeping. The header documented the restriction; it now enforces it. Found with Valgrind. ### Added - The fuzz harness drives `lowzag_estimate()` and `lowzag_decode_mem()`, which it did not. Six frame-header rejection arms behind the estimate had never executed under any test. Fuzz-only line coverage of `src/lowzag.c` goes from 91.68% to 98.21%; the whole suite together reaches 98.97% of 1063 lines and 95.40% of 782 branches. - Valgrind results recorded in `SECURITY.md`: memcheck clean across 2,603 processes with `--track-origins=yes`, and helgrind and DRD clean against the documented threading claim, with a positive control confirming the tools see into the library. ## [1.1.0] - 2026-08-06 Two new entry points and nothing taken away. `lowzag_decode()`, `lowzag_probe()`, `lowzag_state_size()` and `lowzag_strerror()` keep their signatures and their behaviour, and a caller who ignores the new pair is unaffected. The sizing arithmetic moved to make the first of them possible; the decoder did not: every file in the corpus decodes byte-identically before and after, over 640 runs — 128 files at four forced scales plus automatic. The API and error-path suite grew to cover both new entry points and passes clean under ASan and UBSan. ### Added - `lowzag_estimate()`: what a decode of a file would cost, from its frame header alone. Nothing is decoded and nothing is allocated. It takes the `lowzag_cfg_t` the decode would run with, because `force_scale`, `target_w`, `target_h` and `mem_limit` all change the answer, and it reads only those four fields — the callbacks are never touched and may be NULL, and a NULL cfg means the defaults. It fills a `lowzag_estimate_t`: the source and output dimensions, the `scale`, `components` and `chroma_steps` the decode would report, and `alloc_bytes`, `working_bytes` and their sum `total_bytes`. - `alloc_bytes` is the decoder's own arithmetic and not a model of it. The scale ladder, the frame layout and the byte count moved into one `plan_frame()` that `lowzag_decode()` and `lowzag_estimate()` both call, so the figure equals the `info.peak_alloc` of the matching decode to the byte, degradation under `mem_limit` included. The two agree because there is one copy of the code, not because both were kept up to date. - `lowzag_estimate()` separates baseline from out-of-scope where `lowzag_probe()` does not: SOF0 and SOF1 return `LOWZAG_ERR_NOT_PROGRESSIVE`, which a baseline decoder can take, and arithmetic, lossless and hierarchical frames return `LOWZAG_ERR_UNSUPPORTED`, which nothing common will. That matches what `lowzag_decode()` returns for the same file; `lowzag_probe()` lumps them together deliberately, because dispatch only needs to know the file is not ours. - `lowzag_decode_mem()`: whole file in a buffer, RGB888 out into a buffer you supply, no callbacks. Size the output with `lowzag_estimate()` first, or read the dimensions off the `LOWZAG_ERR_NOMEM` a short buffer returns — it fills `info` having written and allocated nothing. The memory ceiling is a positional parameter and not an optional field, so the convenient entry point cannot quietly become an unbounded one; it means what `cfg.mem_limit` means, degradation ladder included, and 0 is still no limit. - `LOWZAG_BUILD_MEM_API`, a CMake option, ON by default. `lowzag_decode_mem()` is the only part of lowzag that calls `malloc` and `free`, so it lives in its own translation unit, `src/lowzag_mem.c`, which `-DLOWZAG_BUILD_MEM_API=OFF` leaves out of the library. Compiled `-O2 -fno-stack-protector`, `src/lowzag.c` still has exactly two undefined symbols, `memcpy` and `memset`, and empty `.data` and `.bss`. A static archive does not pull in an object nobody references, so a firmware image that never calls `lowzag_decode_mem()` keeps that property whether or not the option is set. The option affects the exported symbol set of a shared build without moving the SOVERSION, so a distribution packaging `liblowzag.so` should leave it on. ## [1.0.2] - 2026-08-06 No change to the library. `v1.0.1` was tagged with a pipeline whose consumer check asserted the version against a frozen string literal, so the tag's own CI is red on a release that is otherwise correct. This tag carries the fixed pipeline. ### Fixed - CI checked `LOWZAG_VERSION_STRING` and `pkg-config --modversion` against a hardcoded `"1.0.0"`, which tests the release engineer's memory rather than the package. Both now compare against the version `find_package` reports, so a stale installed header against a newer package still fails and a version bump does not. ## [1.0.1] - 2026-08-05 Everything here was found by preparing the project for public release rather than by using it. No decoded pixel changes: 488 differential comparisons against libjpeg-turbo and 461 API checks are identical before and after. ### Fixed - `rd_byte()` took the read callback's return value on trust. A callback returning more than the 4 KB it was asked for — reporting bytes available rather than bytes written — read off the end of the buffer. Clamped. - The `cfg.state` alignment check tested against `sizeof(uint32_t)`, but `lz_t` holds pointers. A buffer 4-mod-8 aligned on LP64 passed the check and then trapped on the first member access. It now derives the real requirement. ### Added - `SECURITY.md`: reporting contact, supported versions, and a threat model that draws the trust boundary at `lowzag_cfg_t`. - A quick start that runs from a clean clone, and `examples/sample.jpg` to run it against. - Fuzzing now reaches `LOWZAG_MAX_DIM`, `LOWZAG_MAX_SCANS`, the auto-scale path and the I/O-error path, none of which it previously executed. Line coverage over seeds plus a fixed mutation run: 92.49% to 98.31%. ### Removed - 2.8 MB of prebuilt ASan and libFuzzer executables tracked since before the rename. ## [1.0.0] - 2026-08-05 First release. Extracted from the DualCrow ESP32 display firmware, where it ships. Briefly published as `pjpeg` before anyone depended on it. That name is picojpeg's C API prefix — `pjpeg_decode_init()`, `pjpeg_image_info_t`, `PJPG_*` — and picojpeg ships vendored inside Bodmer/JPEGDecoder and other Arduino/ESP32 display projects, which is exactly this library's audience. The documented way to get whole-format coverage is to pair this with a baseline decoder, so that was a collision a user could hit while following the advice given here. `image/pjpeg` is also the legacy MIME type for progressive JPEG, and npm `pjpeg` is a different progressive-JPEG tool. Renamed before it could matter. Three defects were fixed between that tag and this one, all found by auditing for release rather than by use: - A config carrying only the mandatory callbacks decoded at 1/8 and returned success. Naming no target now means no reduction. - The frame dimension cap was two undocumented magic numbers holding back a 32-bit heap overflow. It is now named, explained, statically asserted, and backed by checked multiplication at the allocation sites. - Over `mem_limit` the decoder gives up chroma resolution before it gives up scale, and the documentation pointed callers at `info.scale`, which cannot see that tier. Added `info.chroma_steps`. ### Added - `lowzag_decode()`: single forward pass over a progressive (SOF2, Huffman) JPEG at scale 1/1, 1/2, 1/4 or 1/8. No seeking, no whole-file buffering. I/O, row output and allocation are caller callbacks; the only library functions used are `memcpy` and `memset`. - Band-limited coefficient storage. A reduced-scale IDCT reads only the low `bs x bs` corner of each block, so only the shortest zigzag prefix covering that corner is stored: 64, 25, 5 or 1 coefficients at 1/1, 1/2, 1/4, 1/8. A 64-bit per-block nonzero map is stored alongside, so that AC successive-approximation refinement scans still consume the right number of correction bits for coefficients whose values were discarded. On a 12 MP 4:2:0 photograph peak allocation is 36.2 / 23.0 / 8.9 / 3.6 MB at 1/1 / 1/2 / 1/4 / 1/8, against libjpeg-turbo's ~36.1 MB coefficient array at every scale. The technique is not new — see [docs/PRIOR-ART.md](docs/PRIOR-ART.md) — and memory still scales with the source image. See [docs/ALGORITHM.md](docs/ALGORITHM.md). - Per-axis reduced IDCT (1, 2, 4 and 8-point specializations of the Loeffler factorization, Q13 fixed point, int32 throughout) with a DC-only fast path. Chroma is decoded one scale step sharper than luma so that a subsampled component lands on the output grid at full resolution. - `lowzag_probe()`: header-only classification for dispatch — progressive, baseline/other SOF, not a JPEG, or "show me more bytes". - `lowzag_state_size()` and `cfg.state`, so the hot working set (Huffman lookups, colour tables, read buffer) can be placed in fast memory rather than coming from `cfg.alloc`. 17000 bytes on a 64-bit host build. - `cfg.mem_limit`: a ceiling on allocation. Under automatic scale selection, exceeding it degrades quality — first chroma sharpness, then a whole scale step — instead of failing. Check `info.scale` if that matters. - `lowzag_strerror()`. - Restart marker support; grayscale and YCbCr; sampling factors 1 and 2. - No static or global state: concurrent decodes on separate threads are safe. - Differential test harness (`test/`) against libjpeg-turbo `djpeg` over a generated 128-file corpus at all four scales — 488 comparisons — run under ASan and UBSan, plus a libFuzzer target under `test/fuzz/`. At scale 1 the output is bit-exact against `djpeg -nosmooth` on all 122 valid corpus images, observed against libjpeg-turbo 3.0.4. The harness gates at 40 dB rather than requiring exactness. - `examples/decode_to_ppm.c`, a 130-line standalone host program, and an ESP-IDF example under `examples/esp32/`. ### Fixed Found and fixed before the release; each has before/after evidence in the test harness: - Signed-overflow undefined behaviour in the DC predictor, reachable from a malformed file. The predictor is now clamped on every accumulation, not only before the store. The old code aborted under `-ftrapv`; the new code is clean. - Signed-overflow undefined behaviour in the second `idct_1d()` pass, reachable from a malformed file and found by the fuzzer in its first 60-second run. `DEQUANT_CLAMP` bounded the first pass but nothing bounded the second, which amplifies its input by up to 169352x. Fixed by the `WORK_MAX` clamp; `test/fuzz/corpus/bad-idct-overflow.jpg` is the 138-byte minimized reproducer, kept as a regression seed. - A 19 KB function-static that made concurrent decodes crash. All state is now per-decode. The old code failed under ASan with a double-free on 3 of 3 runs; the new code is clean at 4 threads. ### Known limitations - Progressive only. Baseline files return `LOWZAG_ERR_NOT_PROGRESSIVE` and belong in a baseline decoder. - Memory scales with the **source** dimensions, not the output. A 1000x750 output costs 8.86 MB from a 12 MP source and 14.27 MB from a 48 MP source. Nothing here runs without external RAM at megapixel sizes; the technique lowers the constant, it does not remove the proportionality. - The technique is not novel: it is described in two abandoned patent applications (2005, 2013). No novelty is claimed. See [docs/PRIOR-ART.md](docs/PRIOR-ART.md), which is a literature survey and not a freedom-to-operate opinion. - At 1/8, [bitbank2/JPEGDEC](https://github.com/bitbank2/JPEGDEC) does the same job in ~18 KB of constant memory and is the better choice if 1/8 is the size you want. - Reduced-scale output is coefficient truncation, which is a different downsampler from libjpeg's reduced IDCT. Both are legitimate; they do not agree pixel for pixel. - No arithmetic coding, 12-bit, CMYK/Adobe or lossless JPEG; sampling factors above 2 are rejected. [Unreleased]: https://gitlab.defensiblelogic.com/pub/lowzag/-/compare/v2.2.1...main [2.2.1]: https://gitlab.defensiblelogic.com/pub/lowzag/-/tags/v2.2.1 [2.2.0]: https://gitlab.defensiblelogic.com/pub/lowzag/-/tags/v2.2.0 [2.1.0]: https://gitlab.defensiblelogic.com/pub/lowzag/-/tags/v2.1.0 [2.0.0]: https://gitlab.defensiblelogic.com/pub/lowzag/-/tags/v2.0.0 [1.1.1]: https://gitlab.defensiblelogic.com/pub/lowzag/-/tags/v1.1.1 [1.1.0]: https://gitlab.defensiblelogic.com/pub/lowzag/-/tags/v1.1.0 [1.0.2]: https://gitlab.defensiblelogic.com/pub/lowzag/-/tags/v1.0.2 [1.0.1]: https://gitlab.defensiblelogic.com/pub/lowzag/-/tags/v1.0.1 [1.0.0]: https://gitlab.defensiblelogic.com/pub/lowzag/-/tags/v1.0.0