Changelog

# 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