rapier-jxl 1.1.19 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,27 +1,24 @@
1
1
  # For an agent adding Rapier JXL to an app
2
2
 
3
- 1. Install: `npm install rapier-jxl`, or copy one file from this repository's root into the app: `rapier-jxl.min.mjs`
4
- (the core), `lossless.min.mjs`, `jpeg.min.mjs` or `photo.min.mjs` (one checked function each), or the readable
5
- `.mjs` files with `index.mjs` as the core entry and `photo.mjs` as the optional photo entry. No build needed.
6
- 2. Import `encode` and `transcode` from `rapier-jxl` (the readable modules), `rapier-jxl/min` (the one minified
7
- file), or `encodeLosslessRGBA` from `rapier-jxl/lossless` and `transcode` from `rapier-jxl/jpeg`.
8
- 3. Encode pixels: `encode(rgba, width, height, {quality})`, `rgba` being straight (not premultiplied) RGBA bytes
9
- row by row, as `CanvasRenderingContext2D.getImageData` gives them; `quality` 100 (the default) is lossless,
10
- 1 to 99 lossy. Alpha is exact at every quality. For photographic VarDCT, import `encodePhotoRGBA` from
11
- `rapier-jxl/photo` and call `encodePhotoRGBA(rgba, width, height, {quality: 90})`. It defaults to 90; 100 is
12
- exact, including RGB beneath transparent pixels. Quality numbers across encoders are not equivalent PSNR.
13
- Each answer is a `Uint8Array` of JPEG XL codestream bytes: save it as `.jxl` or wrap it in a `Blob` of type `image/jxl`.
14
- 4. Carry a JPEG: `transcode(jpegBytes)` returns `{bytes, width, height, orientation}`; do not decode the JPEG
15
- first. If it throws `JXL_JPEG` (a form the carrier does not take, a colour profile other than sRGB, a file cut
16
- short), decode it (an `<img>` and a canvas) and call `encode` on the pixels.
17
- 5. Run it in a worker for anything larger than an icon. Calls are synchronous: cancel by terminating that worker,
18
- disregard obsolete request IDs and start a replacement worker. A transferred input buffer is no longer owned
19
- by the sender; keep a copy first if cancellation must retain the original pixels.
20
- 6. Handle the five error codes (`JXL_INPUT`, `JXL_DIMENSIONS`, `JXL_SIZE`, `JXL_MEMORY`, `JXL_JPEG`); there are no
21
- others from the checked entries. The committed conformance cases are decoded by jxl-oxide and native libjxl;
22
- this evidence does not prove every possible input. Keep original pixels until encoding and storage succeed.
23
- 7. Show the result only where the browser decodes JPEG XL (`image/jxl` in `<picture>` with a fallback, or a
24
- feature test on a one-pixel stream); keep the original where it does not.
3
+ 1. `npm install rapier-jxl`, or copy one file from this repository's root: `rapier-jxl.min.mjs` (the core),
4
+ `effort.min.mjs`, `jpeg.min.mjs` or `photo.min.mjs`. No build.
5
+ 2. `import {encode} from 'rapier-jxl'` (or `rapier-jxl/min`), `transcode` from `rapier-jxl/jpeg`, `encodePhoto`
6
+ from `rapier-jxl/photo`. For smaller lossless files at more time, `encode` from `rapier-jxl/effort` with
7
+ `{effort: 3}`.
8
+ 3. Pixels: `encode(rgba, width, height, {quality})`, straight RGBA row by row as `getImageData` gives it; from a
9
+ Display P3 canvas, add `colorSpace: 'display-p3'`. Quality 100 (the default) is lossless, 1 to 99 lossy, alpha
10
+ exact at every quality. Photographs:
11
+ `encodePhoto(rgba, width, height, {quality: 90})`. The answer is a `Uint8Array` of codestream bytes: save it
12
+ as `.jxl` or in a `Blob` of type `image/jxl`.
13
+ 4. A JPEG: `transcode(jpegBytes)` gives `{bytes, width, height, orientation}` without decoding. On `JXL_JPEG`,
14
+ decode it and call `encodePhoto` on the pixels.
15
+ 5. Anything larger than an icon runs in a worker. Calls are synchronous, so cancel by terminating the worker, or
16
+ loop over the door's twin (`encodeSteps`, `transcodeSteps`, `encodePhotoSteps`) and leave the loop. A
17
+ transferred buffer is gone from the sender: copy first if you may retry.
18
+ 6. The error codes are `JXL_INPUT`, `JXL_DIMENSIONS`, `JXL_SIZE`, `JXL_MEMORY` and `JXL_JPEG`, no others;
19
+ `JXL_DIMENSIONS` is past the door's own `LIMITS` (24 million pixels for the core and `effort`, 40 million for
20
+ `photo`, 64 million for a carried JPEG). Keep the original pixels until the encode and its storage succeed.
21
+ 7. Show the result only where the browser decodes JPEG XL (`<picture>` with a fallback, or a one-pixel feature test).
25
22
 
26
- Nothing here reads files, fetches, or touches the DOM; the module is safe to run in a worker, in Node and in
27
- Deno alike. Do not vendor a minified copy under another name: publish the version you took and its licence.
23
+ Nothing here reads files, fetches or touches the DOM: it runs in a worker, Node or Deno alike. Publish the version
24
+ you took and its licence; do not vendor a minified copy under another name.
@@ -1,68 +1,185 @@
1
1
  # Encoder payloads measured on 30 September 2026
2
2
 
3
- Rapier JXL is the smallest JavaScript/WebAssembly JPEG XL encoder **among the published payloads measured here**.
4
- This is a bounded survey, not proof of the smallest possible implementation. The alternatives implement different
5
- subsets of JPEG XL. In particular, Rapier's JPEG carrier preserves coefficients but cannot reconstruct the original
6
- JPEG file; libjxl's reversible JPEG mode can. Larger codecs also offer features Rapier does not, including HDR,
7
- ICC profiles, animation, perceptual search and more compression tools.
3
+ Rapier JXL is the smallest JavaScript or WebAssembly JPEG XL encoder among the published payloads measured here.
4
+ A bounded survey, not proof of a global minimum; the encoders differ in what they cover.
8
5
 
9
6
  | Encoder / entry | Version | Minified JS + WASM bytes | gzip bytes | Included capability |
10
7
  | --- | --- | ---: | ---: | --- |
11
- | Rapier JXL core | this release | 40,286 | 15,904 | 8-bit lossless RGBA, lossy modular with exact alpha, JPEG coefficients |
12
- | Rapier JXL lossless | this release | 16,085 | 6,747 | 8-bit exact RGBA |
13
- | Rapier JXL JPEG | this release | 30,624 | 12,274 | JPEG coefficients, orientation; no JPEG reconstruction |
14
- | Rapier JXL photo | this release | 25,418 | 10,409 | 8-bit photographic VarDCT, exact alpha; q100 lossless |
8
+ | Rapier JXL core | 2.0.0 | 19,495 | 8,475 | 8-bit lossless RGBA, lossy modular with exact alpha |
9
+ | Rapier JXL effort door | 2.0.0 | 25,229 | 10,606 | The core, and the weighted predictor searched for lossless |
10
+ | Rapier JXL JPEG door | 2.0.0 | 29,592 | 12,533 | JPEG coefficients, orientation; no JPEG reconstruction |
11
+ | Rapier JXL photo door | 2.0.0 | 25,646 | 10,804 | 8-bit photographic VarDCT, exact alpha; q100 lossless |
15
12
  | [jSquash](https://github.com/jamsinclair/jSquash/tree/main/packages/jxl) | 1.3.0 | 1,388,572 | 525,782 | 8-bit lossless/lossy RGBA |
16
- | [Discourse's jSquash package](https://www.npmjs.com/package/@discourse/jxl) | 1.3.0 | 1,388,572 | 525,782 | Same measured encoder bytes as jSquash |
17
- | [Lacinak's jSquash fork](https://github.com/kelaci/jSquash) | 1.3.0-kelaci.0 | 2,071,514 | 844,866 | Additional high bit-depth input options |
13
+ | [Discourse's jSquash package](https://www.npmjs.com/package/@discourse/jxl) | 1.3.0 | 1,388,572 | 525,782 | The same encoder bytes as jSquash |
14
+ | [Lacinak's jSquash fork](https://github.com/kelaci/jSquash) | 1.3.0-kelaci.0 | 2,071,514 | 844,866 | High bit-depth input options |
18
15
  | [squoosh-kit](https://github.com/bnowak008/squoosh-kit/tree/main/packages/jxl) | 0.2.10 | 1,371,789 | 511,927 | 8-bit lossless/lossy RGBA |
19
- | [icodec](https://github.com/Kaciras/icodec) | 0.6.0 | 2,474,954 | 900,957 | 8–16-bit lossless/lossy pixels |
20
- | [Cornerstone libjxl](https://github.com/cornerstonejs/codecs/tree/main/packages/libjxl) | 1.1.1 | 2,575,008 | 936,242 | 1–16-bit grayscale/RGB; DICOM-oriented API |
16
+ | [icodec](https://github.com/Kaciras/icodec) | 0.6.0 | 2,474,954 | 900,957 | 8 to 16-bit lossless/lossy pixels |
17
+ | [Cornerstone libjxl](https://github.com/cornerstonejs/codecs/tree/main/packages/libjxl) | 1.1.1 | 2,575,008 | 936,242 | 1 to 16-bit grayscale/RGB; DICOM-oriented API |
21
18
  | [jxl-wasm](https://github.com/saschanaz/jxl-wasm) | 0.7.0 | 2,533,758 | 961,538 | libjxl CLI, pixels and reversible JPEG |
22
- | [jpeg-to-jxl](https://github.com/ChefJulio/jpeg-to-jxl) | 0.2.0 | 2,732,858 | 1,063,406 | Reversible JPEG recompression; includes decode/JPEG fallback |
19
+ | [jpeg-to-jxl](https://github.com/ChefJulio/jpeg-to-jxl) | 0.2.0 | 2,732,858 | 1,063,406 | Reversible JPEG recompression, with decode |
23
20
  | [Squoosh library](https://github.com/GoogleChromeLabs/squoosh/tree/dev/libsquoosh) | 0.5.3 | 1,615,596 | 539,210 | Shared multi-codec JavaScript and one JXL encoder WASM |
24
21
 
25
- Bytes are decimal, not KiB. Every listed file, upstream URL, npm archive integrity and SHA-256 is in
26
- [`public/encoder-sizes.json`](public/encoder-sizes.json). JavaScript is minified with Terser 5.51.2
27
- (`compress.passes=2`, mangling, license comments retained); shipped WASM is unchanged. Each resource is gzipped
28
- independently at level 9, then sizes are summed. These are delivery bytes, not npm tarball sizes. Rapier's
29
- `sizes.json` records its independently optimized single-file builds and complete MIT banner.
30
-
31
- The jSquash, fork, squoosh-kit and icodec rows count the low-level encoder factory and its WASM. Optional
32
- convenience wrappers, threaded variants, workers and decoders are excluded. This favors those alternatives;
33
- it does not pretend the entire npm package is needed to encode one picture. The Squoosh library has shared
34
- JavaScript, so that file is included whole. The JPEG-to-JXL package also exposes reverse conversion from the
35
- same WASM; it is not an encoder-only custom rebuild. Size measurements do not establish the other packages'
36
- correctness or compression quality.
22
+ Bytes are decimal. JavaScript is minified with Terser 5.51.2 (two compress passes, mangling, licence comments
23
+ kept), WASM is unchanged, each resource is gzipped alone at level 9, then summed: delivery bytes, not tarball
24
+ sizes. Every file, URL, npm integrity and SHA-256 is in [`public/encoder-sizes.json`](public/encoder-sizes.json).
25
+ The jSquash, fork, squoosh-kit and icodec rows count the encoder factory and its WASM only, without wrappers,
26
+ workers or decoders, which favours them. Size says nothing about correctness or compression quality.
37
27
 
38
28
  ## Reproduce
39
29
 
40
- In a checkout of this public repository:
41
-
42
30
  ```sh
43
31
  npm install --ignore-scripts
44
32
  node public/measure-encoders.mjs .encoder-size-cache encoder-sizes.json
45
33
  ```
46
34
 
47
- The script fetches only pinned npm archives from `public/encoders.json`, validates their integrity, reads the
48
- listed members in memory and minifies their JavaScript. It never installs or executes downloaded competitor
49
- code. The cache makes repeats offline. Rapier's own byte receipt is generated by its source stager, not by
50
- this survey. For a standalone copy of the script, install `terser@5.51.2` first.
35
+ The script fetches the pinned npm archives in `public/encoders.json`, checks their integrity, reads the listed
36
+ members in memory and minifies them. It never runs downloaded code. Rapier's own bytes come from its stager.
37
+
38
+ ## Quality at matched bytes, 30 September 2026
39
+
40
+ A small encoder does not mean small or good pictures. This measures Rapier's readable source against the
41
+ [libjxl v0.12.0 reference](https://github.com/libjxl/libjxl/releases/tag/v0.12.0) (`cjxl`, `djxl` and
42
+ `butteraugli_main` from the official Linux x86-64 static archive, SHA-256
43
+ `5318a1ea40adad76d023e0c17a03d4627f8282f83cb2b575e69be8e74f1ff456`) on eleven inputs: four synthetic
44
+ textures, the bundled Grace Hopper JPEG, three generated drawings and three generated paintings. It is not a
45
+ representative collection. Each Rapier stream sets a byte budget; the native encoder, at effort 7 with its mode
46
+ left to `cjxl`, is searched over distances for the largest stream within that budget (a gap of at most 0.5%
47
+ counts as matched). Where native distance 0 already fits, the row is marked **†**: an exact stream in fewer
48
+ bytes, not a match. Both outputs are decoded by the same pinned `djxl` to 8-bit sRGB; alpha is byte-exact in
49
+ every selected decode. PSNR and RGB SSIM are computed over the three RGB channels (single-scale SSIM, 11 × 11
50
+ Gaussian, no downsampling); alpha-bearing inputs are matted on white for the table. Butteraugli is the
51
+ reference tool at 80 nits, the worse of the white and black mattes for alpha inputs. Higher PSNR and SSIM and
52
+ lower Butteraugli are that metric's preference, not a human verdict. **R / N** is Rapier / native.
53
+
54
+ ### The photo door
55
+
56
+ Native is within 0.402% of the budget in all 20 rows, and better on PSNR and SSIM in 19 of 20 and on
57
+ Butteraugli in 19 of 20; the exceptions are Grace q99 (Rapier's PSNR and SSIM) and lit surface q99 (Rapier's
58
+ Butteraugli).
59
+
60
+ | Input · Rapier q | Rapier bytes | Native bytes | Native d | PSNR dB R / N ↑ | RGB SSIM R / N ↑ | Butteraugli R / N ↓ |
61
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: |
62
+ | Wood grain · 50 | 6,351 | 6,335 | 5.061 | 33.459 / 37.501 | 0.833394 / 0.920358 | 7.9959 / 3.8476 |
63
+ | Wood grain · 80 | 11,640 | 11,632 | 2.246 | 36.866 / 39.628 | 0.900685 / 0.942440 | 4.0035 / 1.8251 |
64
+ | Wood grain · 90 | 18,713 | 18,713 | 1.226 | 38.910 / 40.727 | 0.929675 / 0.953458 | 2.3318 / 1.5172 |
65
+ | Wood grain · 99 | 76,863 | 76,842 | 0.327 | 45.530 / 47.954 | 0.983684 / 0.991316 | 0.9762 / 0.6128 |
66
+ | Landscape · 50 | 4,729 | 4,710 | 4.944 | 34.594 / 38.053 | 0.852275 / 0.908469 | 8.5642 / 3.4545 |
67
+ | Landscape · 80 | 7,932 | 7,921 | 2.438 | 37.305 / 39.502 | 0.891356 / 0.921732 | 4.1410 / 2.1015 |
68
+ | Landscape · 90 | 12,725 | 12,723 | 1.508 | 39.050 / 40.389 | 0.912417 / 0.931579 | 3.1272 / 1.6892 |
69
+ | Landscape · 99 | 64,221 | 64,192 | 0.377 | 45.545 / 47.492 | 0.977931 / 0.986445 | 1.0067 / 0.6164 |
70
+ | Folded texture · 50 | 3,563 | 3,552 | 5.427 | 35.334 / 38.643 | 0.839685 / 0.908679 | 7.2341 / 4.0082 |
71
+ | Folded texture · 80 | 6,525 | 6,514 | 2.468 | 38.175 / 40.176 | 0.894762 / 0.927122 | 3.6812 / 2.2637 |
72
+ | Folded texture · 90 | 10,046 | 10,046 | 1.668 | 39.618 / 40.820 | 0.917830 / 0.934923 | 2.2918 / 1.6726 |
73
+ | Folded texture · 99 | 57,111 | 57,111 | 0.412 | 45.611 / 47.539 | 0.979148 / 0.986739 | 0.9172 / 0.7164 |
74
+ | Lit surface · 50 | 2,825 | 2,825 | 2.909 | 38.241 / 40.292 | 0.891825 / 0.915090 | 4.4182 / 2.4656 |
75
+ | Lit surface · 80 | 4,107 | 4,107 | 2.314 | 39.474 / 40.530 | 0.903290 / 0.918372 | 3.1575 / 2.1072 |
76
+ | Lit surface · 90 | 5,465 | 5,453 | 1.988 | 40.214 / 40.738 | 0.912497 / 0.921402 | 2.2827 / 1.9999 |
77
+ | Lit surface · 99 | 47,155 | 47,152 | 0.466 | 45.664 / 46.636 | 0.975682 / 0.980349 | 0.9476 / 1.1255 |
78
+ | Grace Hopper · 50 | 15,990 | 15,982 | 5.451 | 29.546 / 30.676 | 0.778970 / 0.807254 | 6.6973 / 4.5990 |
79
+ | Grace Hopper · 80 | 34,419 | 34,409 | 2.338 | 33.675 / 34.477 | 0.862229 / 0.899859 | 3.8410 / 3.0344 |
80
+ | Grace Hopper · 90 | 56,543 | 56,519 | 1.110 | 38.605 / 38.944 | 0.950222 / 0.970007 | 2.3300 / 1.4546 |
81
+ | Grace Hopper · 99 | 110,542 | 110,533 | 0.173 | 49.948 / 47.182 | 0.996348 / 0.995977 | 0.4889 / 0.3406 |
82
+
83
+ ### The core's lossy modular on drawings and paintings
84
+
85
+ Fourteen rows are matched within 0.127%. In the ten **†** rows an exact native stream fits in 3.052% to
86
+ 51.144% fewer bytes. Native SSIM and Butteraugli are better in all 24 rows; PSNR favours Rapier for paint-0001
87
+ and paint-0002 at q80 and q90.
88
+
89
+ | Input · Rapier q | Rapier bytes | Native bytes | Native d | PSNR dB R / N ↑ | RGB SSIM R / N ↑ | Butteraugli R / N ↓ |
90
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: |
91
+ | recipe-0000 · 50 | 14,972 | 14,953 | 1.102 | 37.276 / 45.992 | 0.989901 / 0.996956 | 3.9870 / 1.0352 |
92
+ | recipe-0000 · 80 | 20,051 | 19,439† | 0 | 42.493 / ∞ | 0.995389 / 1.000000 | 2.6508 / 0.0000 |
93
+ | recipe-0000 · 90 | 25,232 | 19,439† | 0 | 46.614 / ∞ | 0.997777 / 1.000000 | 1.4840 / 0.0000 |
94
+ | recipe-0000 · 99 | 39,788 | 19,439† | 0 | 58.188 / ∞ | 0.999803 / 1.000000 | 0.5505 / 0.0000 |
95
+ | recipe-0001 · 50 | 31,284 | 31,279 | 1.642 | 44.663 / 51.571 | 0.998111 / 0.999252 | 3.6761 / 0.9379 |
96
+ | recipe-0001 · 80 | 37,226 | 35,491† | 0 | 49.928 / ∞ | 0.999293 / 1.000000 | 2.1718 / 0.0000 |
97
+ | recipe-0001 · 90 | 43,439 | 35,491† | 0 | 53.998 / ∞ | 0.999699 / 1.000000 | 0.7681 / 0.0000 |
98
+ | recipe-0001 · 99 | 60,225 | 35,491† | 0 | 63.163 / ∞ | 0.999953 / 1.000000 | 0.2608 / 0.0000 |
99
+ | recipe-0002 · 50 | 18,746 | 18,736 | 1.651 | 34.484 / 40.065 | 0.978619 / 0.992176 | 5.0667 / 1.4144 |
100
+ | recipe-0002 · 80 | 27,036 | 27,025 | 0.749 | 39.856 / 43.976 | 0.990765 / 0.995802 | 2.5048 / 1.0876 |
101
+ | recipe-0002 · 90 | 35,330 | 35,306 | 0.425 | 44.044 / 46.482 | 0.995583 / 0.997393 | 1.7128 / 0.6129 |
102
+ | recipe-0002 · 99 | 59,624 | 36,102† | 0 | 55.523 / ∞ | 0.999602 / 1.000000 | 0.5762 / 0.0000 |
103
+ | paint-0000 · 50 | 45,505 | 45,498 | 1.336 | 36.020 / 40.143 | 0.975668 / 0.991663 | 4.7640 / 1.5576 |
104
+ | paint-0000 · 80 | 54,652 | 54,618 | 0.684 | 39.900 / 43.518 | 0.988270 / 0.995770 | 2.7126 / 0.9569 |
105
+ | paint-0000 · 90 | 65,877 | 65,843 | 0.289 | 43.809 / 47.922 | 0.994447 / 0.998212 | 1.5637 / 0.6630 |
106
+ | paint-0000 · 99 | 97,769 | 74,658† | 0 | 54.474 / ∞ | 0.999312 / 1.000000 | 0.5388 / 0.0000 |
107
+ | paint-0001 · 50 | 61,462 | 61,442 | 1.680 | 33.606 / 34.830 | 0.956089 / 0.972432 | 7.1021 / 1.8314 |
108
+ | paint-0001 · 80 | 75,929 | 75,926 | 0.830 | 37.751 / 37.312 | 0.980393 / 0.982498 | 3.5325 / 1.1308 |
109
+ | paint-0001 · 90 | 93,451 | 93,451 | 0.343 | 41.285 / 40.730 | 0.990186 / 0.990973 | 1.9406 / 0.8474 |
110
+ | paint-0001 · 99 | 143,736 | 124,100† | 0 | 51.793 / ∞ | 0.998622 / 1.000000 | 0.6179 / 0.0000 |
111
+ | paint-0002 · 50 | 81,312 | 81,304 | 2.020 | 32.334 / 34.083 | 0.937986 / 0.962754 | 5.7590 / 2.1400 |
112
+ | paint-0002 · 80 | 98,676 | 98,622 | 1.055 | 36.749 / 36.598 | 0.972358 / 0.976745 | 3.1658 / 1.3790 |
113
+ | paint-0002 · 90 | 119,652 | 119,651 | 0.472 | 40.420 / 39.711 | 0.986313 / 0.987143 | 1.8052 / 1.1247 |
114
+ | paint-0002 · 99 | 187,934 | 170,069† | 0 | 50.304 / ∞ | 0.997853 / 1.000000 | 0.5696 / 0.0000 |
115
+
116
+ ### The JPEG carrier against reversible recompression
117
+
118
+ `transcode` against `cjxl --lossless_jpeg=1`. The contracts differ: Rapier carries the coefficients and the
119
+ orientation and cannot rebuild the JPEG file; native also stores the reconstruction data and rebuilt every
120
+ original exactly. So a smaller Rapier row is not a like-for-like win. Six inputs are tiny conformance fixtures;
121
+ Grace is the one photograph.
122
+
123
+ | JPEG input | Original bytes | Rapier carrier | Native reversible | JPEG rebuilt by native |
124
+ | --- | ---: | ---: | ---: | --- |
125
+ | grey-sequential.jpg | 141 | 117 | 177 | yes |
126
+ | quant-before-scan.jpg | 141 | 120 | 181 | yes |
127
+ | quant-after-scan.jpg | 210 | 120 | refused | no reconstruction data possible |
128
+ | colour-sequential.jpg | 925 | 499 | 662 | yes |
129
+ | colour-progressive-restarts.jpg | 1,367 | 464 | 820 | yes |
130
+ | decoder-difference.jpg | 925 | 499 | 662 | yes |
131
+ | reconstruction-difference.jpg | 925 | 510 | 676 | yes |
132
+ | grace-hopper.jpg | 86,089 | 60,543 | 58,773 | yes |
133
+
134
+ ### Inputs and commands
135
+
136
+ Textures are the first four `synthetic-photo` seeds of `tools/corpus/jxl-images.mjs` at 512 × 384; drawings and
137
+ paintings the first three seeds of each kind at the same size, through the real Draw and Paint owners with the
138
+ bundled Geist fonts and pinned `@napi-rs/canvas` 0.1.100. Grace is the public-domain fixture in
139
+ `public/test/photo-corpus/`, decoded once with `ffmpeg -pix_fmt rgba` at its native size.
140
+
141
+ | Input | Dimensions | Seed | Pixels with alpha < 255 | RGBA SHA-256 |
142
+ | --- | ---: | ---: | ---: | --- |
143
+ | Wood grain | 512 × 384 | 3399228925 | 0 | `cfc2495231eb53c4d423ab5ea7692e3cafce6160f07cc51942b910a89d5ffab7` |
144
+ | Landscape | 512 × 384 | 1351084136 | 0 | `c6c33cc695b19538ec6780647e32fd4f22b24872292d22707f26ab9ad1a0cef2` |
145
+ | Folded texture | 512 × 384 | 3597906643 | 0 | `57a0f40e8e14eb0b9fe5286dbfec7bc7783b1e748c6888ea44791f088b0ea106` |
146
+ | Lit surface | 512 × 384 | 1549761854 | 0 | `d998747e1c427144424c4d59f6795f88a2d2983a341dcba54b5a6b346e4f0a0d` |
147
+ | Grace Hopper | 512 × 600 | — | 0 | `af6a4dc548da3797b814f4be1b4489effe658ad13ba842d839628d01ba3ee39f` |
148
+ | recipe-0000 | 512 × 384 | 2385324683 | 23,538 | `6d8240cac5db3c3db509125658ba32cb919537b113ae9f0877856fe6c091e67f` |
149
+ | recipe-0001 | 512 × 384 | 337179894 | 163,701 | `7a5acaf7274bbadfabaa351f0dd3a7931818a438424d17ae555a2c2f72338b9f` |
150
+ | recipe-0002 | 512 × 384 | 2584002401 | 20,928 | `08ce520f30a10897b4acbb630e8d39aeeb97d53e02b50ac721ad80260fd2484c` |
151
+ | paint-0000 | 512 × 384 | 744793156 | 194,804 | `ad9467838000b99434d7f7cb041111cd0bad8f64fe2511ce98d0d106ebd147a3` |
152
+ | paint-0001 | 512 × 384 | 2991615663 | 193,919 | `a3af498e21cade0969d0b3fa63118316a18601aa23bd1b35ffc561e70a123237` |
153
+ | paint-0002 | 512 × 384 | 943470874 | 191,708 | `d2c586460a91e5bcb4c213080f46e4945d674ed6920230f50b2ab396076ea3c1` |
154
+
155
+ ```sh
156
+ cjxl INPUT.pam OUTPUT.jxl --distance=D --effort=7 --num_threads=0 \
157
+ --alpha_distance=0 --keep_invisible=1 --premultiply=0 --container=0 \
158
+ -x color_space=RGB_D65_SRG_Per_SRG --quiet
159
+ djxl INPUT.jxl OUTPUT.pam --bits_per_sample=8 --color_space=RGB_D65_SRG_Per_SRG --num_threads=0 --quiet
160
+ butteraugli_main REFERENCE.ppm DECODED.ppm --intensity_target 80
161
+ cjxl INPUT.jpg OUTPUT.jxl --lossless_jpeg=1 --effort=7 --num_threads=0 --quiet
162
+ djxl OUTPUT.jxl RECONSTRUCTED.jpg --reconstruct_jpeg --num_threads=0 --quiet
163
+ ```
164
+
165
+ From `repo/` in the development checkout, `tools/probes/jxl-quality.mjs --section=photo|modular|jpeg` makes
166
+ the inputs, encodes, runs the bracket search and writes `photo.json`, `modular.json` and `jpeg.json`;
167
+ `tools/probes/jxl-quality-metrics.py` computes the metrics (Python 3.12, NumPy 2.3, SciPy 1.17). The full
168
+ candidate pools, byte brackets, hashes and metric components are in the development receipts.
51
169
 
52
170
  ## Found but not ranked
53
171
 
54
- | Project | What was found | Why it is outside the numeric encoder ranking |
55
- | --- | --- | --- |
56
- | [jxl-oxide-wasm](https://github.com/tirr-c/jxl-oxide-wasm) 0.12.6 | 1,707,757 minified JS + WASM bytes; 612,548 gzip | Decoder only; cannot encode pixels or JPEGs |
57
- | [jxl.js](https://github.com/niutech/jxl.js), jxl-rs-polyfill, TurboJXL, PureJsImage's JXL codec | Browser decoders | Decoding support is not encoding support |
58
- | [libjxl](https://github.com/libjxl/libjxl/blob/main/doc/building_wasm.md) | WASM build instructions; official browser demo is a decoder | Encoder builds are represented by the measured libjxl-based packages; no single canonical encoder payload is published by this demo |
59
- | [jixel](https://github.com/awxkee/jixel), revision `d84b45ff22d23895c76f58875358676d99e3081b` | Rust encoder, WASM-specific source; releases 0.3.2 and earlier have no downloadable WASM assets | No pinned public executable WASM package found; source bytes are not executable bytes |
60
- | [libjxl-tiny](https://github.com/libjxl/libjxl-tiny), revision `8eae18172059d54f5c734ca814f23b96eebff859` | Small C++ encoder and WASM build instructions | No published WASM payload found; an unmeasured custom build cannot support a size ranking |
61
- | [Hydrium](https://github.com/Traneptora/hydrium), revision `45227f35a222fadfd094a37325f937a6e9442c48` | Streaming C encoder; releases offer native executables | No JavaScript/WASM payload found |
62
- | [Imazen jxl-encoder](https://github.com/imazen/jxl-encoder), revision `0d79a23e30da9504cd5f1e1b8a849cb930feb5fe` | Rust encoder and WASM benchmark example | No published WASM payload found |
63
- | webcvt's jsquash-jxl adapter | Wrapper around `@jsquash/jxl` | Same encoder already measured; wrapper is additional application code |
64
- | sharp/libvips packages and `cjxl` npm wrapper | Native binaries | Not JavaScript or WebAssembly encoder payloads |
65
-
66
- Search covered npm's `jpeg-xl`, `jpegxl`, `jxl` and `jixel` results, upstream repositories and web searches for
67
- JavaScript/WASM encoders. A package not found or not measured is not evidence for a universal record. If a
68
- smaller usable encoder is published, it belongs in this table and the claim must change.
172
+ | Project | Why not |
173
+ | --- | --- |
174
+ | [jxl-oxide-wasm](https://github.com/tirr-c/jxl-oxide-wasm) 0.12.6 (1,707,757 bytes, 612,548 gzip) | Decoder only |
175
+ | [jxl.js](https://github.com/niutech/jxl.js), jxl-rs-polyfill, TurboJXL, PureJsImage's codec | Decoders |
176
+ | [libjxl](https://github.com/libjxl/libjxl/blob/main/doc/building_wasm.md) WASM build | No published encoder payload; the libjxl-based packages above stand for it |
177
+ | [jixel](https://github.com/awxkee/jixel) `d84b45ff` | No published WASM |
178
+ | [libjxl-tiny](https://github.com/libjxl/libjxl-tiny) `8eae1817` | No published WASM |
179
+ | [Hydrium](https://github.com/Traneptora/hydrium) `45227f35` | Native executables only |
180
+ | [Imazen jxl-encoder](https://github.com/imazen/jxl-encoder) `0d79a23e` | No published WASM |
181
+ | webcvt's jsquash-jxl adapter | The jSquash encoder, already measured |
182
+ | sharp, libvips, the `cjxl` npm wrapper | Native binaries |
183
+
184
+ Searched: npm's `jpeg-xl`, `jpegxl`, `jxl` and `jixel` results, upstream repositories, the web. A smaller usable
185
+ encoder published later belongs in this table, and the claim changes with it.
package/README.md CHANGED
@@ -1,69 +1,54 @@
1
1
  # Rapier JXL
2
2
 
3
- A JPEG XL encoder in pure JavaScript. No WebAssembly, no build step, no dependency, nothing fetched at run
4
- time. It is the encoder inside [Rapier](https://rapier.website), the single-file Markdown editor, offered on its
5
- own so any app can write JPEG XL pictures with the bytes it can afford: one file of 40.3 kB,
6
- 15.9 kB gzipped, MIT. The smallest JavaScript/WebAssembly JPEG XL encoder among the published
7
- payloads [we measured](ENCODER-COMPARISON.md); the table explains the scope and how to reproduce it.
8
-
9
- - **Lossless.** Every pixel comes back as it went in. 8-bit grey, grey with alpha, RGB and RGBA.
10
- - **Lossy.** Quality 1 to 99, on libjxl's modular path (the Squeeze transform), for drawings, screenshots and
11
- pictures of few colours; alpha stays exact. A picture of few colours is answered exact when that is fewer bytes.
12
- - **Photographs, optionally.** Import `rapier-jxl/photo` for DCT8 photographic compression with exact alpha.
13
- This separate module shares the JPEG carrier's VarDCT writer and adds no bytes to the core import.
14
- - **A JPEG carried as its coefficients.** A JPEG's quantised DCT coefficients, quantisation tables, subsampling
15
- and Exif orientation go into a JPEG XL frame the way libjxl transcodes them, so the picture decodes to the
16
- JPEG's own pixels at about a fifth fewer bytes, in one call, without decoding. Not carried: the JPEG
17
- reconstruction data (the JPEG file cannot be rebuilt from the stream), ICC, Exif beyond the orientation, XMP.
18
-
19
- The retained corpus and seeded fuzz cases are decoded through jxl-oxide and native libjxl (below).
3
+ A JPEG XL encoder in pure JavaScript: one file, 19.5 kB, 8.5 kB gzipped, no WebAssembly, no
4
+ dependencies, MIT. The encoder inside [Rapier](https://rapier.website), published on its own. The smallest
5
+ JavaScript or WebAssembly JPEG XL encoder among the payloads [we measured](ENCODER-COMPARISON.md).
6
+
7
+ - **Lossless.** Every pixel back as it went in: 8-bit grey, grey with alpha, RGB, RGBA.
8
+ - **Lossy**, quality 1 to 99, for flat-colour rasters (screenshots, pixel art, scanned line art). Alpha stays exact.
9
+ A picture of few colours is written exact when that is smaller.
10
+ - **Smaller exact pictures, slower**: `rapier-jxl/effort`, the same `encode` with `{effort: 2}` or `3`, each never
11
+ larger than the effort below. Effort 1, its default, is the core's bytes.
12
+ - **Photographs**: `rapier-jxl/photo`, DCT8 compression with exact alpha, a door of its own.
13
+ - **A JPEG carried as its coefficients**: `rapier-jxl/jpeg`, one call, no decode, the way libjxl transcodes. Not
14
+ carried: the reconstruction data (the JPEG file cannot be rebuilt), the ICC bytes, Exif beyond the orientation, XMP.
15
+ - **sRGB or Display P3.** `{colorSpace: 'display-p3'}` declares a wide-gamut canvas's samples. A JPEG's profile is
16
+ read by what it does, not what it says: sRGB and Display P3 are carried and declared.
20
17
 
21
18
  ## Use it
22
19
 
23
20
  ```js
24
- import {encode, transcode} from 'rapier-jxl';
21
+ import {encode} from 'rapier-jxl';
22
+ import {transcode} from 'rapier-jxl/jpeg';
23
+ import {encodePhoto} from 'rapier-jxl/photo';
25
24
 
26
- // Pixels from a canvas (straight RGBA, row by row) to a JPEG XL codestream.
27
25
  const {data, width, height} = context.getImageData(0, 0, canvas.width, canvas.height);
28
26
  const exact = encode(data, width, height); // lossless
29
27
  const small = encode(data, width, height, {quality: 80}); // lossy
28
+ const photo = encodePhoto(data, width, height); // quality 90; 100 is exact
30
29
  const blob = new Blob([small], {type: 'image/jxl'});
31
30
 
32
- // A JPEG file to JPEG XL, its pixels kept.
33
- const jpeg = new Uint8Array(await file.arrayBuffer());
34
- const {bytes, width: w, height: h, orientation} = transcode(jpeg);
31
+ const {bytes, width: w, height: h, orientation} = transcode(new Uint8Array(await file.arrayBuffer()));
35
32
  ```
36
33
 
37
- `encode(data, width, height, {quality = 100})` takes a `Uint8Array` or `Uint8ClampedArray` of `width * height * 4`
38
- bytes and returns a `Uint8Array` holding a bare JPEG XL codestream (the `.jxl` file's bytes). `transcode(jpeg)`
39
- takes a JPEG's bytes and returns `{bytes, width, height, orientation}`; the width and height are the picture's as
40
- shown (swapped when the Exif orientation turns it), and the orientation is kept in the JPEG XL header.
34
+ `encode(data, width, height, {quality = 100})` takes straight RGBA bytes, row by row, and returns a `Uint8Array`
35
+ holding a bare JPEG XL codestream; `encodePhoto` takes the same. `transcode(jpeg)` returns
36
+ `{bytes, width, height, orientation}`: the size as shown, the orientation kept in the header.
41
37
 
42
- Four complete ES modules carry their MIT notice inside: `rapier-jxl.min.mjs` (core; `rapier-jxl/min`),
43
- `lossless.min.mjs` (`encodeLosslessRGBA`, `LIMITS`; `rapier-jxl/lossless`), `jpeg.min.mjs` (`transcode`, `LIMITS`;
44
- `rapier-jxl/jpeg`), and `photo.min.mjs` (`encodePhotoRGBA`, `LIMITS`; `rapier-jxl/photo`). The readable modules
45
- are beside them, `index.mjs` the core entry and `photo.mjs` the optional photo entry. Copy a complete bundle
46
- into an app, or install the package. The checked entries are `encode`, `encodeLosslessRGBA`,
47
- `encodeLossyRGBA`, `encodePhotoRGBA` and `transcode`; the raw
48
- `encodeLossless`, `encodeLossy`, `transcodeJPEG`, `parseJPEG` and `inspectPixels` beneath them take input the
49
- checked entries have already admitted and may throw plain errors on anything else.
50
-
51
- ```js
52
- import {encodePhotoRGBA} from 'rapier-jxl/photo';
53
- const photo = encodePhotoRGBA(data, width, height, {quality: 90});
54
- ```
55
-
56
- The photo entry defaults to quality 90; quality 100 is exact lossless. Its 1–99 range controls photographic
57
- quantisation, while `encode` retains its artwork-oriented modular path. Quality numbers do not promise
58
- identical PSNR between codecs or images. Neither lossy entry promises fewer bytes than lossless for every input.
38
+ Doors: `rapier-jxl` (the core), `rapier-jxl/effort`, `rapier-jxl/jpeg`, `rapier-jxl/photo`, each readable, so a
39
+ bundler carries their shared modules once; `rapier-jxl/min` is the core as one minified file. `rapier-jxl/writer`
40
+ gives a module's author the layers beneath the doors (readable only; they change only with the major version).
41
+ TypeScript declarations sit beside each door, and a worker and a page are under `public/examples/`. Quality numbers
42
+ are not the same fidelity across encoders or pictures, and lossy is not always smaller than lossless.
59
43
 
60
44
  ### In a worker
61
45
 
62
- Encoding is synchronous; large pictures can take seconds, so run it off the main thread. A complete worker is this:
46
+ Encoding is synchronous. Run it off the main thread:
63
47
 
64
48
  ```js
65
49
  // jxl-worker.mjs
66
- import {encode, transcode} from 'rapier-jxl';
50
+ import {encode} from 'rapier-jxl';
51
+ import {transcode} from 'rapier-jxl/jpeg';
67
52
  self.onmessage = ({data: {id, op, ...ask}}) => {
68
53
  try {
69
54
  const out = op === 'transcode' ? transcode(ask.jpeg) : {bytes: encode(ask.data, ask.width, ask.height, {quality: ask.quality})};
@@ -72,67 +57,86 @@ self.onmessage = ({data: {id, op, ...ask}}) => {
72
57
  };
73
58
  ```
74
59
 
75
- Post `{id, op: 'encode', data, width, height, quality}` or `{id, op: 'transcode', jpeg}`; receive `{id, ok: true,
76
- bytes, ...}` or `{id, ok: false, code, message}`, the output bytes transferred. To cancel synchronous work,
77
- terminate that dedicated worker and discard its request ID. A queued abort message cannot interrupt a
78
- synchronous encode. Keep the input buffer in the caller if retry is needed; posting it without a transfer list
79
- copies it, while transferring it gives ownership to the worker.
60
+ To cancel, terminate the worker and drop its request id. Keep the input in the caller if you may retry.
61
+
62
+ Each door has a twin that does the same work in steps: `encodeSteps`, `transcodeSteps`, `encodePhotoSteps` return a
63
+ job, `for (const done of job)` runs one group of one pass per step (`done` is the fraction, the last exactly 1), and
64
+ `job.bytes` is the stream after the loop, the same bytes the door writes. Leaving the loop cancels, so a worker can
65
+ take messages and report progress between steps (`public/examples/worker.mjs`). `job.hurry = true` (the example's
66
+ `deadline` sets it) ends the effort door's search at its next step with the smallest stream written so far, never
67
+ larger than effort 1's.
80
68
 
81
69
  ### Limits and errors
82
70
 
83
- One picture at a time, at most 16,384 pixels on a side, 24 million pixels, and a 16 MiB stream. The arguments are
84
- read before any work, and a JPEG's header before a byte of it is allocated. A refusal is an `Error` whose `code`
85
- is one of `JXL_INPUT` (the arguments), `JXL_DIMENSIONS`, `JXL_SIZE`, `JXL_MEMORY` (the engine ran out of memory)
86
- or `JXL_JPEG`: a JPEG the carrier does not take (arithmetic coding, 12-bit, lossless, CMYK, a DNL height, a colour
87
- profile other than sRGB), or a JPEG cut short or out of order, which is refused rather than carried with pixels
88
- invented; decode such a JPEG and encode its pixels instead. A checked call returns a stream or throws one of these.
71
+ One picture at a time, at most 16,384 pixels a side and a 16 MiB stream. Each door's `LIMITS` sets its pixels by its
72
+ memory, so that none needs more at its limit than the core at its own: 24 million for the core and `effort` (a lossy
73
+ picture holds its planes whole, 15.7 bytes a pixel at its peak besides the input), 40 million for `photo` (6.5), and
74
+ 64 million for a JPEG that `jpeg` carries (3.3 at 4:2:0, 6.4 at 4:4:4), so a phone's 24 and 48 megapixel
75
+ photographs are carried. Arguments are checked before any work. A refusal is an `Error` whose `code` is `JXL_INPUT`,
76
+ `JXL_DIMENSIONS`, `JXL_SIZE`, `JXL_MEMORY` or `JXL_JPEG` (arithmetic coding, 12-bit, lossless, CMYK, a DNL height, a
77
+ colour profile other than sRGB or Display P3, or a JPEG cut short: decode it and encode the pixels instead).
78
+
79
+ ### From 1.x
80
+
81
+ 2.0.0 is the package's own version (1.x took Rapier's), and the core is pixels only:
82
+
83
+ - `transcode` is in `rapier-jxl/jpeg`, and `encodePhotoRGBA` is `encodePhoto` in `rapier-jxl/photo`.
84
+ - `encodeLosslessRGBA(data, width, height)` and `rapier-jxl/lossless` are `encode(data, width, height)`;
85
+ `encodeLossyRGBA(data, width, height, quality)` is `encode(data, width, height, {quality})`.
86
+ - `encodeLossless`, `encodeLossy`, `inspectPixels`, `parseJPEG` and `transcodeJPEG` are in `rapier-jxl/writer`.
87
+ - A JPEG's colour profile is read by what it does: Display P3 (an iPhone's) is carried and declared, where 1.x
88
+ refused it; a profile of lookup tables is refused, even one named sRGB.
89
+ - Lossless streams and carried JPEGs are 1.x's bytes. Lossy streams of a picture wider or taller than 256 pixels,
90
+ or of a colour picture one pixel wide or high, and every photo stream changed where Chrome's decoder (jxl-rs
91
+ 0.7.4) misread a valid stream; each decodes to the same pixels as before through jxl-oxide and libjxl.
92
+
93
+ New: `rapier-jxl/effort`, each door's twin in steps with `hurry`, `colorSpace: 'display-p3'`, each door's own
94
+ `LIMITS`.
89
95
 
90
96
  ## Sizes
91
97
 
92
- | file | bytes | gzip | Brotli |
93
- | --- | ---: | ---: | ---: |
94
- | `rapier-jxl.min.mjs`, core | 40,286 | 15,904 | 14,096 |
95
- | `lossless.min.mjs`, `encodeLosslessRGBA` alone | 16,085 | 6,747 | 5,948 |
96
- | `jpeg.min.mjs`, `transcode` alone | 30,624 | 12,274 | 10,866 |
97
- | `photo.min.mjs`, photographic pixels | 25,418 | 10,409 | 9,209 |
98
- | all readable modules, including photo | 103,193 | 31,376 | |
99
-
100
- Exact bytes of this release's files, measured by the script that stages this repository; `sizes.json` carries
101
- their hashes and the tools (terser 5.51.2, Node v22.22.2; gzip at level 9, Brotli at quality 11). A minified
102
- file keeps every check and every export it names, and is proved at staging to encode what the readable source
103
- encodes; the readable modules' gzip is of their concatenation.
104
-
105
- What it writes, so a decoder's author knows what to expect: bare codestreams (no container box), 8-bit only,
106
- prefix codes only (never ANS), one frame, no preview, no animation, no ICC profile (sRGB is declared, and a JPEG
107
- with another profile is refused), no XYB, no chroma-from-luma, no filters. Lossless pictures use modular mode,
108
- comparing a palette of up to 512 colours with direct encoding by actual stream length, in groups of 256 by 256
109
- pixels. Measured prediction and colour-transform choices keep smooth artwork and independent channels small.
110
- Lossy artwork uses the Squeeze transform with exact alpha. Carried JPEGs use VarDCT with the JPEG's quantisation
111
- tables as raw dequantisation matrices. The photo entry produces DCT8 coefficients from pixels for the same writer.
112
-
113
- ## Why it exists
114
-
115
- Rapier keeps pictures inside Markdown documents, and the standard it publishes says the best way to embed a
116
- picture in a Markdown document is JPEG XL: exact where it must be exact, small where it may be small, one format
117
- for photographs, paintings and diagrams alike. An editor that follows the standard needs an encoder it can carry
118
- offline in a page that must stay small, and none existed at the size, so Rapier wrote one. This repository is
119
- that encoder, unchanged, republished from Rapier's tree at each of its releases.
120
-
121
- If your app embeds pictures in Markdown, the standard is at [rapier.website](https://rapier.website) and the
122
- encoder is this one: tell your agent to add `rapier-jxl` (see `AGENTS.md`) and it is done.
123
-
124
- ## How it is checked
125
-
126
- Tests and deterministic structure-aware fuzzing live in `public/test/`, with small JPEG seeds and generated pixel
127
- cases. Install the development dependencies and run `npm test`. The workflow requires both
128
- [jxl-oxide](https://github.com/tirr-c/jxl-oxide) 0.12.6 and FFmpeg's native libjxl decoder; a missing native oracle
129
- is explicitly reported locally and is a failure in CI. Cases compare exact pixels and alpha where promised,
130
- lossy fidelity where relevant, accepted JPEG content, and refusal of malformed inputs. Staging also proves each
131
- minified entry returns the same bytes as its readable source. The 30 September audit's 131 pixel cases and
132
- 36 JPEG forms remain the acceptance baseline. These checks cover their inputs, not every possible codestream
133
- or every decoder implementation.
98
+ | file | bytes | gzip | Brotli | added to the core, gzip |
99
+ | --- | ---: | ---: | ---: | ---: |
100
+ | `rapier-jxl.min.mjs`, the core: `encode` | 19,495 | 8,475 | 7,494 | |
101
+ | `effort.min.mjs`: `encode` with effort | 25,229 | 10,606 | 9,351 | 2,131 |
102
+ | `jpeg.min.mjs`: `transcode` | 29,592 | 12,533 | 11,072 | 7,673 |
103
+ | `photo.min.mjs`: `encodePhoto` | 25,646 | 10,804 | 9,537 | 4,242 |
104
+ | every door in one bundle | 47,499 | 19,228 | 16,977 | |
105
+ | all readable modules | 136,580 | 42,707 | | |
106
+
107
+ Exact bytes of release 2.0.0's files, measured by the script that stages this repository; `sizes.json`
108
+ carries their hashes and tools (terser 5.51.2, Node v22.22.2; gzip 9, Brotli 11). Each minified file stands alone
109
+ and is proved at staging to write the same bytes as its readable source; the last column is what a door adds to a
110
+ bundle that already holds the core. `effort`'s `encode` is the core's at effort 1, so it takes the core's place, in
111
+ that column and in the bundle of every door.
112
+
113
+ What it writes: bare codestreams, 8-bit, prefix codes (never ANS), one frame, no preview, animation, ICC (sRGB or
114
+ Display P3 is declared), XYB, chroma-from-luma or filters. Lossless in modular mode, a palette of up to 2,048
115
+ colours weighed against direct coding by actual length, groups of 256, reversible YCoCg, a predictor chosen per
116
+ channel; at effort 2 and 3 also the weighted predictor, its contexts split by its own error. Lossy through Squeeze
117
+ with exact alpha. Carried JPEGs in VarDCT with the JPEG's own tables. The photo door
118
+ writes DCT8 coefficients for the same writer.
119
+
120
+ ## Checked
121
+
122
+ Tests and seeded structure-aware fuzzing in `public/test/`: `npm test`, decoded through
123
+ [jxl-oxide](https://github.com/tirr-c/jxl-oxide) 0.12.6 and FFmpeg's native libjxl (a missing native decoder is
124
+ reported, and fails in CI). Exact pixels and alpha where promised, fidelity where relevant, the accepted JPEG
125
+ forms, refusal of malformed input. The same input writes the same bytes in every JavaScript engine: nothing that
126
+ decides a byte uses a function engines round differently, and `public/test/bytes.test.mjs` holds the streams' hashes
127
+ under Node and Bun. The 30 September run put 2,000,000 JPEG mutations and 32,768 pixel cases
128
+ through both decoders. The two decoders differ by one RGB unit on some JPEG and photo streams (floating-point
129
+ reconstruction), kept in `public/test/seeds/`; alpha and modular output are exact. With a C compiler and libjxl
130
+ headers, `npm run fuzz:scale -- --out fuzz-run --workers 4` repeats the fixed budget.
131
+
132
+ ## Why
133
+
134
+ Rapier keeps pictures inside Markdown, and its standard says a raster picture in Markdown is JPEG XL: exact where
135
+ it must be, small where it may be. Drawings stay SVG. An editor carrying an encoder offline in a small page needed
136
+ one this size, and none existed. The standard is at [rapier.website](https://rapier.website); for an agent, see
137
+ `AGENTS.md`.
134
138
 
135
139
  ## Licence
136
140
 
137
- MIT, copyright rapier.website. The design follows the JPEG XL specification (ISO/IEC 18181) and libjxl's
138
- encoders, whose sources were read; none of their code is here.
141
+ MIT, copyright rapier.website. The design follows ISO/IEC 18181 and libjxl's encoders, whose sources were read;
142
+ none of their code is here.