rapier-jxl 1.1.16

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 ADDED
@@ -0,0 +1,18 @@
1
+ # For an agent adding Rapier JXL to an app
2
+
3
+ 1. Install: `npm install rapier-jxl` (or copy the eleven `.mjs` files at this repository's root into the app; no build needed).
4
+ 2. Import `encode` and `transcode` from `rapier-jxl` (or from `index.mjs`).
5
+ 3. Encode pixels: `encode(rgba, width, height, {quality})`, `rgba` being straight (not premultiplied) RGBA bytes
6
+ row by row, as `CanvasRenderingContext2D.getImageData` gives them; `quality` 100 (the default) is lossless,
7
+ 1 to 99 lossy. The answer is a `Uint8Array` of JPEG XL codestream bytes: save it as `.jxl` or wrap it in a
8
+ `Blob` of type `image/jxl`.
9
+ 4. Carry a JPEG: `transcode(jpegBytes)` returns `{bytes, width, height, orientation}`; do not decode the JPEG
10
+ first. If it throws `JXL_JPEG`, decode it (an `<img>` and a canvas) and call `encode` on the pixels.
11
+ 5. Run it in a worker for anything larger than an icon; the README shows a complete one in eight lines.
12
+ 6. Handle the five error codes (`JXL_INPUT`, `JXL_DIMENSIONS`, `JXL_SIZE`, `JXL_MEMORY`, `JXL_JPEG`); there are no
13
+ others, and a call that returns has returned a stream every JPEG XL decoder reads.
14
+ 7. Show the result only where the browser decodes JPEG XL (`image/jxl` in `<picture>` with a fallback, or a
15
+ feature test on a one-pixel stream); keep the original where it does not.
16
+
17
+ Nothing here reads files, fetches, or touches the DOM; the module is safe to run in a worker, in Node and in
18
+ Deno alike. Do not vendor a minified copy under another name: publish the version you took and its licence.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rapier.website
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # Rapier JXL
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: 24.7 KB gzipped
6
+ (83.7 KB of readable source), MIT.
7
+
8
+ - **Lossless.** Every pixel comes back as it went in. 8-bit grey, grey with alpha, RGB and RGBA.
9
+ - **Lossy.** Quality 1 to 99, on libjxl's modular path (the Squeeze transform), for drawings, screenshots and
10
+ pictures of few colours; a picture of few colours is answered exact when that is fewer bytes.
11
+ - **A JPEG carried whole.** A JPEG's coefficients go into a JPEG XL frame untouched, the way libjxl transcodes one,
12
+ so the picture decodes to the JPEG's own pixels at about a fifth fewer bytes, in one call, without decoding.
13
+
14
+ Every browser that reads JPEG XL reads these streams; so do libjxl, jxl-oxide and everything built on them.
15
+
16
+ ## Use it
17
+
18
+ ```js
19
+ import {encode, transcode} from 'rapier-jxl';
20
+
21
+ // Pixels from a canvas (straight RGBA, row by row) to a JPEG XL codestream.
22
+ const {data, width, height} = context.getImageData(0, 0, canvas.width, canvas.height);
23
+ const exact = encode(data, width, height); // lossless
24
+ const small = encode(data, width, height, {quality: 80}); // lossy
25
+ const blob = new Blob([small], {type: 'image/jxl'});
26
+
27
+ // A JPEG file to JPEG XL, its pixels kept.
28
+ const jpeg = new Uint8Array(await file.arrayBuffer());
29
+ const {bytes, width: w, height: h, orientation} = transcode(jpeg);
30
+ ```
31
+
32
+ `encode(data, width, height, {quality = 100})` takes a `Uint8Array` or `Uint8ClampedArray` of `width * height * 4`
33
+ bytes and returns a `Uint8Array` holding a bare JPEG XL codestream (the `.jxl` file's bytes). `transcode(jpeg)`
34
+ takes a JPEG's bytes and returns `{bytes, width, height, orientation}`; the width and height are the picture's as
35
+ shown (swapped when the Exif orientation turns it), and the orientation is kept in the JPEG XL header.
36
+
37
+ Named entries for a tighter bundle: `encodeLosslessRGBA`, `encodeLossyRGBA`, `transcode`, and the raw
38
+ `encodeLossless`, `encodeLossy`, `transcodeJPEG`, `parseJPEG`, `inspectPixels` beneath them. The whole module is
39
+ the eleven `.mjs` files at the root of this repository: copy them into an app as they are, or install the package.
40
+
41
+ ### In a worker
42
+
43
+ Encoding is synchronous and takes tens to hundreds of milliseconds on a large picture, so do it off the main
44
+ thread. A complete worker is this:
45
+
46
+ ```js
47
+ // jxl-worker.mjs
48
+ import {encode, transcode} from 'rapier-jxl';
49
+ self.onmessage = ({data: {id, op, ...ask}}) => {
50
+ try {
51
+ const out = op === 'transcode' ? transcode(ask.jpeg) : {bytes: encode(ask.data, ask.width, ask.height, {quality: ask.quality})};
52
+ self.postMessage({id, ok: true, ...out}, [out.bytes.buffer]);
53
+ } catch (error) { self.postMessage({id, ok: false, code: error.code || 'JXL_ERROR', message: String(error.message || error)}); }
54
+ };
55
+ ```
56
+
57
+ Post `{id, op: 'encode', data, width, height, quality}` or `{id, op: 'transcode', jpeg}`; receive `{id, ok: true,
58
+ bytes, ...}` or `{id, ok: false, code, message}`, the bytes transferred, never copied.
59
+
60
+ ### Limits and errors
61
+
62
+ One picture at a time, at most 16,384 pixels on a side, 24 million pixels, and a 16 MiB stream. Anything else is
63
+ refused before any work with an `Error` whose `code` is one of `JXL_INPUT` (the arguments), `JXL_DIMENSIONS`,
64
+ `JXL_SIZE`, `JXL_MEMORY` (the engine ran out of memory) or `JXL_JPEG` (a JPEG the carrier does not take:
65
+ arithmetic coding, 12-bit, lossless, CMYK or a DNL height; decode it and encode its pixels instead). There is no
66
+ other failure: a call either returns a stream every decoder reads or throws one of these.
67
+
68
+ ## Sizes
69
+
70
+ | what | source | gzipped |
71
+ | --- | --- | --- |
72
+ | the whole module (the eleven files) | 83.7 KB | 24.7 KB |
73
+ | lossless only (`encodeLosslessRGBA`) | 35.7 KB | 11.0 KB |
74
+ | the JPEG carrier only (`transcode`) | 60.1 KB | 18.0 KB |
75
+
76
+ Measured on this release's files by the script that stages this repository, gzip at level 9, before any
77
+ minifier. A bundler that drops what you do not import lands between the rows.
78
+
79
+ What it writes, so a decoder's author knows what to expect: bare codestreams (no container box), 8-bit only,
80
+ prefix codes only (never ANS), one frame, no preview, no animation, no ICC profile (sRGB is declared), no
81
+ XYB, no chroma-from-luma, no filters. Lossless pictures use the modular mode with a palette of up to 512
82
+ colours, the reversible YCoCg transform, the clamped-gradient predictor and one prefix code per channel, in
83
+ groups of 256 by 256 pixels. Lossy pictures use the modular mode with the Squeeze transform. Carried JPEGs use
84
+ the VarDCT mode with the JPEG's own quantisation tables as raw dequantisation matrices.
85
+
86
+ ## Why it exists
87
+
88
+ Rapier keeps pictures inside Markdown documents, and the standard it publishes says the best way to embed a
89
+ picture in a Markdown document is JPEG XL: exact where it must be exact, small where it may be small, one format
90
+ for photographs, paintings and diagrams alike. An editor that follows the standard needs an encoder it can carry
91
+ offline in a page that must stay small, and none existed at the size, so Rapier wrote one. This repository is
92
+ that encoder, unchanged, republished from Rapier's tree at each of its releases.
93
+
94
+ If your app embeds pictures in Markdown, the standard is at [rapier.website](https://rapier.website) and the
95
+ encoder is this one: tell your agent to add `rapier-jxl` (see `AGENTS.md`) and it is done.
96
+
97
+ ## How it is checked
98
+
99
+ Rapier's own tree holds the tests: every stream this encoder writes is decoded again through
100
+ [jxl-oxide](https://github.com/tirr-c/jxl-oxide) and compared pixel by pixel (exact where exactness is promised,
101
+ above 38 dB where it is not, one stream for every JPEG form of one picture), and the page's retained checks run
102
+ the same before each release. This repository is republished from that tree at each release, so what is here has
103
+ passed them.
104
+
105
+ ## Licence
106
+
107
+ MIT, copyright rapier.website. The design follows the JPEG XL specification (ISO/IEC 18181) and libjxl's
108
+ encoders, whose sources were read; none of their code is here.
package/bits.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // Rapier's JPEG XL encoder: the bit writer. MIT (LICENSE).
2
+ // JPEG XL packs bits least-significant first; `write` takes up to 32 bits at a time.
3
+
4
+ export class BitWriter {
5
+ constructor(capacity = 4096) {
6
+ this.bytes = new Uint8Array(capacity);
7
+ this.at = 0; // whole bytes written
8
+ this.acc = 0; // the pending bits, as a number below 2^40
9
+ this.pending = 0; // how many bits `acc` holds, always below 8 between calls
10
+ }
11
+ write(count, value) {
12
+ if (count > 32 || value < 0 || value >= 2 ** count) throw new Error('bit write out of range: ' + count + ' bits, ' + value);
13
+ let acc = this.acc + value * 2 ** this.pending, pending = this.pending + count;
14
+ if (this.at + 5 >= this.bytes.length) this.grow();
15
+ const bytes = this.bytes;
16
+ while (pending >= 8) { bytes[this.at++] = acc & 255; acc = Math.floor(acc / 256); pending -= 8; }
17
+ this.acc = acc; this.pending = pending;
18
+ }
19
+ // The U32 field of the specification: a two-bit selector, then that choice's bits (`offset` plus the bits).
20
+ writeU32(choices, value) {
21
+ for (let selector = 0; selector < 4; selector++) {
22
+ const [bits, offset] = choices[selector];
23
+ if (value >= offset && value - offset < 2 ** bits) { this.write(2, selector); if (bits) this.write(bits, value - offset); return; }
24
+ }
25
+ throw new Error('U32 value out of range: ' + value);
26
+ }
27
+ zeroPadToByte() {
28
+ if (!this.pending) return;
29
+ if (this.at + 1 >= this.bytes.length) this.grow();
30
+ this.bytes[this.at++] = this.acc; this.acc = 0; this.pending = 0;
31
+ }
32
+ get bitLength() { return this.at * 8 + this.pending; }
33
+ grow(need = 0) {
34
+ const next = new Uint8Array(Math.max(this.bytes.length * 2, this.at + need + 16));
35
+ next.set(this.bytes.subarray(0, this.at)); this.bytes = next;
36
+ }
37
+ // Appends another writer's bits at the current bit position.
38
+ append(other) {
39
+ if (this.at + other.at + 8 >= this.bytes.length) this.grow(other.at + 8);
40
+ if (!this.pending) { this.bytes.set(other.bytes.subarray(0, other.at), this.at); this.at += other.at; }
41
+ else for (let i = 0; i < other.at; i++) this.write(8, other.bytes[i]);
42
+ if (other.pending) this.write(other.pending, other.acc);
43
+ }
44
+ // The bytes so far, the last partial byte zero-padded.
45
+ finish() {
46
+ const length = this.at + (this.pending ? 1 : 0), out = new Uint8Array(length);
47
+ out.set(this.bytes.subarray(0, this.at));
48
+ if (this.pending) out[this.at] = this.acc;
49
+ return out;
50
+ }
51
+ }
52
+
53
+ // Residuals travel unsigned: 0, -1, 1, -2, 2 ... become 0, 1, 2, 3, 4 ...
54
+ export function packSigned(value) { return value >= 0 ? value * 2 : -value * 2 - 1; }
55
+
56
+ export function floorLog2(value) { return 31 - Math.clz32(value); }
57
+ export function ceilLog2(value) { return value <= 1 ? 0 : 32 - Math.clz32(value - 1); }
58
+
59
+ // IEEE half precision, round to nearest even, as the specification's F16 fields.
60
+ export function float16Bits(value) {
61
+ if (value === 0) return 0;
62
+ const sign = value < 0 ? 0x8000 : 0;
63
+ value = Math.abs(value);
64
+ if (!(value < 65520)) throw new Error('half float out of range: ' + value);
65
+ let exponent = Math.floor(Math.log2(value));
66
+ let mantissa = value / 2 ** exponent - 1;
67
+ if (exponent < -14) { mantissa = value / 2 ** -14; exponent = -15; }
68
+ let m = Math.round(mantissa * 1024);
69
+ if (m === 1024) { m = 0; exponent++; }
70
+ return sign | ((exponent + 15) << 10) | m;
71
+ }
package/entropy.mjs ADDED
@@ -0,0 +1,99 @@
1
+ // Rapier's JPEG XL encoder: token coding over many contexts. MIT (LICENSE).
2
+ // The AC coefficients of a VarDCT frame are coded in hundreds of contexts; the contexts are clustered into a few
3
+ // dozen histograms by greedy merging (the cheapest entropy increase first), each histogram takes the hybrid
4
+ // integer split that costs least on its own values, and a prefix code is built per histogram.
5
+ import {buildCode, uintConfig, hybridToken} from './prefix.mjs';
6
+ import {floorLog2} from './bits.mjs';
7
+
8
+ const CONFIGS = [uintConfig(0), uintConfig(1), uintConfig(2), uintConfig(3), uintConfig(4), uintConfig(4, 1, 1), uintConfig(5, 1, 1), uintConfig(2, 0, 1), uintConfig(3, 0, 1)];
9
+ const CLUSTER_CONFIG = uintConfig(4, 1, 1);
10
+
11
+ // Value counts per context: values below 64 in a table, larger ones in a map per context.
12
+ export class TokenCounts {
13
+ constructor(contexts) { this.contexts = contexts; this.small = new Uint32Array(contexts * 64); this.large = Array.from({length: contexts}, () => null); this.totals = new Float64Array(contexts); }
14
+ add(ctx, value) {
15
+ this.totals[ctx]++;
16
+ if (value < 64) { this.small[ctx * 64 + value]++; return; }
17
+ const map = this.large[ctx] || (this.large[ctx] = new Map());
18
+ map.set(value, (map.get(value) || 0) + 1);
19
+ }
20
+ // Token histogram of a set of contexts under a configuration.
21
+ tokens(contexts, config, size = 64) {
22
+ const freqs = new Uint32Array(size);
23
+ for (const ctx of contexts) {
24
+ const base = ctx * 64;
25
+ for (let v = 0; v < 64; v++) if (this.small[base + v]) freqs[hybridTokenOf(config, v)] += this.small[base + v];
26
+ const map = this.large[ctx];
27
+ if (map) for (const [v, n] of map) freqs[hybridTokenOf(config, v)] += n;
28
+ }
29
+ return freqs;
30
+ }
31
+ // The bits a set of contexts costs under a configuration: token entropy plus the raw bits.
32
+ cost(contexts, config) {
33
+ const freqs = this.tokens(contexts, config);
34
+ let total = 0, bits = 0;
35
+ for (const f of freqs) total += f;
36
+ for (const f of freqs) if (f) bits += f * Math.log2(total / f);
37
+ for (const ctx of contexts) {
38
+ const base = ctx * 64;
39
+ for (let v = config.splitToken; v < 64; v++) if (this.small[base + v]) bits += this.small[base + v] * (floorLog2(v) - config.msb - config.lsb);
40
+ const map = this.large[ctx];
41
+ if (map) for (const [v, n] of map) bits += n * (floorLog2(v) - config.msb - config.lsb);
42
+ }
43
+ return bits;
44
+ }
45
+ }
46
+
47
+ const slots = [0, 0, 0];
48
+ function hybridTokenOf(config, value) { hybridToken(config, value, slots); return slots[0]; }
49
+
50
+ function entropyBits(freqs) {
51
+ let total = 0, bits = 0;
52
+ for (const f of freqs) total += f;
53
+ for (const f of freqs) if (f) bits += f * Math.log2(total / f);
54
+ return bits;
55
+ }
56
+
57
+ // Clusters the used contexts (unused ones join cluster 0) and builds one prefix code per cluster. Returns the
58
+ // context map and histograms for writeHistograms, and a writer for tokens.
59
+ export function buildTokenCoding(counts, {maxClusters = 48, newClusterCost = 320} = {}) {
60
+ const used = [];
61
+ for (let ctx = 0; ctx < counts.contexts; ctx++) if (counts.totals[ctx] > 0) used.push(ctx);
62
+ used.sort((a, b) => counts.totals[b] - counts.totals[a]);
63
+ const clusters = []; // {contexts, freqs, bits}
64
+ for (const ctx of used) {
65
+ const own = counts.tokens([ctx], CLUSTER_CONFIG), ownBits = entropyBits(own);
66
+ let best = -1, bestIncrease = Infinity;
67
+ for (let i = 0; i < clusters.length; i++) {
68
+ const cluster = clusters[i], merged = new Uint32Array(64);
69
+ for (let t = 0; t < 64; t++) merged[t] = cluster.freqs[t] + own[t];
70
+ const increase = entropyBits(merged) - cluster.bits - ownBits;
71
+ if (increase < bestIncrease) { bestIncrease = increase; best = i; }
72
+ }
73
+ if (best < 0 || (clusters.length < maxClusters && bestIncrease > newClusterCost)) clusters.push({contexts: [ctx], freqs: own, bits: ownBits});
74
+ else {
75
+ const cluster = clusters[best];
76
+ cluster.contexts.push(ctx);
77
+ for (let t = 0; t < 64; t++) cluster.freqs[t] += own[t];
78
+ cluster.bits = entropyBits(cluster.freqs);
79
+ }
80
+ }
81
+ if (!clusters.length) clusters.push({contexts: [], freqs: new Uint32Array(64), bits: 0});
82
+ const contextMap = new Uint8Array(counts.contexts);
83
+ let bits = counts.contexts * 2; // the context map's entries, roughly
84
+ const histograms = clusters.map((cluster, index) => {
85
+ for (const ctx of cluster.contexts) contextMap[ctx] = index;
86
+ let config = CONFIGS[0], bestCost = Infinity;
87
+ for (const candidate of CONFIGS) { const cost = counts.cost(cluster.contexts, candidate); if (cost < bestCost) { bestCost = cost; config = candidate; } }
88
+ const code = buildCode(counts.tokens(cluster.contexts, config, 128));
89
+ bits += bestCost + code.alphabetSize * 3 + 40;
90
+ return {config, code};
91
+ });
92
+ const write = (w, ctx, value) => {
93
+ const histogram = histograms[contextMap[ctx]];
94
+ hybridToken(histogram.config, value, slots);
95
+ w.write(histogram.code.lengths[slots[0]], histogram.code.codes[slots[0]]);
96
+ if (slots[1]) w.write(slots[1], slots[2]);
97
+ };
98
+ return {contextMap, histograms, write, bits};
99
+ }
package/frame.mjs ADDED
@@ -0,0 +1,100 @@
1
+ // Rapier's JPEG XL encoder: the codestream around a frame. MIT (LICENSE).
2
+ // A bare codestream (no container): signature, size header, image metadata, one frame with its table of contents.
3
+ import {BitWriter} from './bits.mjs';
4
+
5
+ export const GROUP_DIM = 256, DC_GROUP_DIM = 2048;
6
+
7
+ function writeSize(w, size) {
8
+ if (size <= 1 << 9) { w.write(2, 0); w.write(9, size - 1); }
9
+ else if (size <= 1 << 13) { w.write(2, 1); w.write(13, size - 1); }
10
+ else if (size <= 1 << 18) { w.write(2, 2); w.write(18, size - 1); }
11
+ else { w.write(2, 3); w.write(30, size - 1); }
12
+ }
13
+
14
+ // 8 bits per sample; `colour` 1 (grey) or 3 (sRGB); an 8-bit alpha channel when `alpha`. Frames start byte-aligned.
15
+ export function writeImageHeader(w, width, height, colour, alpha, {xyb = false, orientation = 1} = {}) {
16
+ w.write(16, 0x0AFF);
17
+ w.write(1, 0); // not the small size form
18
+ writeSize(w, height);
19
+ w.write(3, 0); // no aspect ratio shortcut
20
+ writeSize(w, width);
21
+ w.write(1, 0); // metadata not all default
22
+ const extra = orientation !== 1;
23
+ w.write(1, extra ? 1 : 0); // extra fields: only an orientation (the Exif value, as a JPEG carried it)
24
+ if (extra) { w.write(3, orientation - 1); w.write(1, 0); w.write(1, 0); w.write(1, 0); } // no intrinsic size, preview or animation
25
+ w.write(1, 0); // integer samples
26
+ w.write(2, 0); // 8 bits per sample
27
+ w.write(1, 1); // 16-bit buffers suffice
28
+ if (alpha) { w.write(2, 1); w.write(1, 1); } // one extra channel, all default: 8-bit alpha
29
+ else w.write(2, 0);
30
+ w.write(1, xyb ? 1 : 0); // xyb_encoded
31
+ if (colour === 3) w.write(1, 1); // colour encoding all default: sRGB
32
+ else {
33
+ w.write(1, 0); w.write(1, 0); // not default, no ICC
34
+ w.write(2, 1); // grey
35
+ w.write(2, 1); // D65
36
+ w.write(1, 0); // no gamma
37
+ w.write(2, 2); w.write(4, 11); // transfer function: sRGB
38
+ w.write(2, 1); // relative rendering intent
39
+ }
40
+ if (extra) w.write(1, 1); // default tone mapping
41
+ w.write(2, 0); // no extensions
42
+ w.write(1, 1); // default transform data
43
+ w.zeroPadToByte();
44
+ }
45
+
46
+ // A lossless modular frame, the last frame, no filters, one pass, 256-pixel groups, replace blending.
47
+ export function writeModularFrameHeader(w, {alpha}) {
48
+ w.write(1, 0); // not all default
49
+ w.write(2, 0); // regular frame
50
+ w.write(1, 1); // modular
51
+ w.write(2, 0); // default flags
52
+ w.write(1, 0); // not YCbCr
53
+ w.write(2, 0); // no upsampling
54
+ if (alpha) w.write(2, 0); // no extra-channel upsampling
55
+ w.write(2, 1); // group size shift 1: 256
56
+ w.write(2, 0); // one pass
57
+ w.write(1, 0); // no custom size or origin
58
+ w.write(2, 0); // replace blending
59
+ if (alpha) w.write(2, 0); // replace for the extra channel
60
+ w.write(1, 1); // the last frame
61
+ w.write(2, 0); // no name
62
+ w.write(1, 0); // loop filter not default:
63
+ w.write(1, 0); // no gaborish
64
+ w.write(2, 0); // no edge-preserving filter
65
+ w.write(2, 0); // no filter extensions
66
+ w.write(2, 0); // no frame header extensions
67
+ }
68
+
69
+ const TOC_OFFSET = [0, 1024, 17408, 4211712], TOC_BITS = [12, 16, 24, 32];
70
+
71
+ export function writeTOC(w, sizes) {
72
+ w.write(1, 0); // no permutation
73
+ w.zeroPadToByte();
74
+ for (const size of sizes) {
75
+ let bucket = 0;
76
+ while (bucket < 3 && size >= TOC_OFFSET[bucket + 1]) bucket++;
77
+ w.write(2, bucket);
78
+ w.write(TOC_BITS[bucket] - 2, size - TOC_OFFSET[bucket]);
79
+ }
80
+ w.zeroPadToByte();
81
+ }
82
+
83
+ export function groupLayout(width, height) {
84
+ const groupsX = Math.ceil(width / GROUP_DIM), groupsY = Math.ceil(height / GROUP_DIM);
85
+ const dcGroupsX = Math.ceil(width / DC_GROUP_DIM), dcGroupsY = Math.ceil(height / DC_GROUP_DIM);
86
+ return {groupsX, groupsY, dcGroupsX, dcGroupsY, single: groupsX === 1 && groupsY === 1};
87
+ }
88
+
89
+ // Sections in the specification's order: DC global, the DC groups, AC global, the AC groups (one pass). A single
90
+ // group frame has one section holding everything. Every section ends on a byte.
91
+ export function assembleCodestream(header, sections) {
92
+ const sizes = sections.map(section => section.length);
93
+ writeTOC(header, sizes);
94
+ const head = header.finish();
95
+ const out = new Uint8Array(head.length + sizes.reduce((a, b) => a + b, 0));
96
+ out.set(head);
97
+ let at = head.length;
98
+ for (const section of sections) { out.set(section, at); at += section.length; }
99
+ return out;
100
+ }
package/index.mjs ADDED
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Rapier JXL: a JPEG XL encoder in pure JavaScript, the one Rapier's page carries. This is the module's face for
3
+ // other apps; Rapier's own worker takes the modules directly (images/encoder.mjs). No WebAssembly, no build step,
4
+ // no dependency, nothing read or fetched at run time: pixels in, a bare codestream out.
5
+ import {inspectPixels, encodeLossless} from './lossless.mjs';
6
+ import {encodeLossy} from './lossy.mjs';
7
+ import {transcodeJPEG} from './vardct.mjs';
8
+ import {parseJPEG} from './jpeg.mjs';
9
+
10
+ // What one call takes at most: the 16 MiB codestream, 24 million pixels, 16,384 on a side. Larger asks are refused
11
+ // with a coded error before any work, the way a decoder would refuse them after it.
12
+ export const LIMITS = Object.freeze({bytes: 16 * 1024 * 1024, pixels: 24_000_000, edge: 16384});
13
+
14
+ const fault = (code, message) => Object.assign(new Error(message), {code});
15
+ function admitSize(width, height) {
16
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw fault('JXL_INPUT', 'A picture is at least one pixel wide and high, in whole pixels.');
17
+ if (width > LIMITS.edge || height > LIMITS.edge) throw fault('JXL_DIMENSIONS', 'A picture is at most ' + LIMITS.edge + ' pixels on a side.');
18
+ if (width * height > LIMITS.pixels) throw fault('JXL_DIMENSIONS', 'A picture is at most ' + LIMITS.pixels.toLocaleString('en-US') + ' pixels.');
19
+ }
20
+ function admitPixels(data, width, height) {
21
+ admitSize(width, height);
22
+ if (!(data instanceof Uint8Array) && !(data instanceof Uint8ClampedArray)) throw fault('JXL_INPUT', 'Pixels are a Uint8Array or Uint8ClampedArray of RGBA bytes.');
23
+ if (data.length !== width * height * 4) throw fault('JXL_INPUT', 'Pixels are width * height * 4 bytes: straight (not premultiplied) RGBA, row by row.');
24
+ }
25
+ function answer(bytes) {
26
+ if (bytes.length > LIMITS.bytes) throw fault('JXL_SIZE', 'The encoded picture exceeds 16 MiB.');
27
+ return bytes;
28
+ }
29
+ function guard(work) {
30
+ try { return work(); }
31
+ catch (error) { if (error instanceof RangeError && !error.code) throw fault('JXL_MEMORY', 'Not enough memory for this picture.'); throw error; }
32
+ }
33
+
34
+ // Exact: every pixel comes back as it went in. An opaque alpha is dropped, a grey picture keeps one channel,
35
+ // up to 512 colours become a palette, colour goes through the reversible YCoCg transform.
36
+ export function encodeLosslessRGBA(data, width, height) {
37
+ admitPixels(data, width, height);
38
+ return guard(() => answer(encodeLossless(data, width, height, {shape: inspectPixels(data, width, height)})));
39
+ }
40
+
41
+ // Lossy modular at a quality from 1 to 99 (90 is libjxl's distance 1.0); a picture of few colours is answered
42
+ // exact when that is fewer bytes. Quality 100 is the lossless answer.
43
+ export function encodeLossyRGBA(data, width, height, quality = 90) {
44
+ admitPixels(data, width, height);
45
+ if (!Number.isFinite(quality) || quality < 1 || quality > 100) throw fault('JXL_INPUT', 'Quality is a number from 1 to 100.');
46
+ return guard(() => {
47
+ const shape = inspectPixels(data, width, height);
48
+ if (quality >= 100) return answer(encodeLossless(data, width, height, {shape}));
49
+ let bytes = encodeLossy(data, width, height, {quality, shape});
50
+ if (shape.palette) { const exact = encodeLossless(data, width, height, {shape}); if (exact.length <= bytes.length) bytes = exact; }
51
+ return answer(bytes);
52
+ });
53
+ }
54
+
55
+ // One call for both: quality 100 (the default) is exact, anything lower is lossy.
56
+ export function encode(data, width, height, {quality = 100} = {}) {
57
+ return quality >= 100 ? encodeLosslessRGBA(data, width, height) : encodeLossyRGBA(data, width, height, quality);
58
+ }
59
+
60
+ // A JPEG carried whole into JPEG XL: its coefficients, quantisation tables, colour and subsampling kept, only the
61
+ // entropy coding changed, so the picture decodes to the JPEG's own pixels at about a fifth fewer bytes. Baseline,
62
+ // extended sequential and progressive scans, with restarts; 8-bit; grey or YCbCr/RGB; an Exif orientation kept.
63
+ // A JPEG this path does not take (arithmetic coding, 12-bit, lossless, CMYK, a DNL height) is refused as
64
+ // JXL_JPEG; decode it and encode its pixels instead.
65
+ export function transcode(jpeg) {
66
+ if (!(jpeg instanceof Uint8Array) || !jpeg.length) throw fault('JXL_INPUT', 'A JPEG is a non-empty Uint8Array.');
67
+ if (jpeg.length > LIMITS.bytes) throw fault('JXL_SIZE', 'A JPEG is at most 16 MiB.');
68
+ return guard(() => {
69
+ const parsed = parseJPEG(jpeg);
70
+ admitSize(parsed.width, parsed.height);
71
+ const bytes = answer(transcodeJPEG(jpeg, parsed));
72
+ const swapped = parsed.orientation >= 5;
73
+ return {bytes, width: swapped ? parsed.height : parsed.width, height: swapped ? parsed.width : parsed.height, orientation: parsed.orientation};
74
+ });
75
+ }
76
+
77
+ export {encodeLossless, encodeLossy, transcodeJPEG, parseJPEG, inspectPixels};