# Compression — KBQR 1.1

The demo now offers **None**, **Auto (gzip only if at least 50% smaller)**,
**Gzip**, and **Deflate**. Auto is the demo default. It measures the complete
QR wire text, including every header and the manifest, against an uncompressed
set using the same chunk size and label. It keeps KBQR1 if savings are below 50%.
Forced Gzip/Deflate can make small inputs larger; the page shows actual byte
savings, QR-text savings and frame counts for your input.

## Measured savings

Synthetic, reproducible examples, not measurements of your actual backup:

| Data | Original bytes | Gzip bytes | Payload saved | Complete QR text saved | Frames before → after |
|---|---:|---:|---:|---:|---:|
| Random hex | 8,192 | 4,302 | 47.5% | 46.7% | 53 → 28 |
| JSON with hex keys | 9,562 | 1,912 | 80.0% | 79.0% | 61 → 13 |
| One small JWT | 167 | 169 | −1.2% | −20.4% | 3 → 3 |
| 40 JSON JWTs | 15,969 | 4,430 | 72.3% | 71.7% | 101 → 29 |
| KPOP base64 JSON | 12,764 | 3,082 | 75.9% | 74.8% | 81 → 21 |
| KPOP random base64 | 8,204 | 6,211 | 24.3% | 23.7% | 53 → 40 |

Thus 50%+ is realistic for repeated JSON structure and batches of similar JWTs,
but not guaranteed for random hex, short JWTs or encrypted/random base64.
Auto selected compression for the three structured multi-record samples above.
A single JWT does not necessarily resemble a batch. Hex values and signature
bytes themselves need not repeat; repeated surrounding structure is compressible.
No JSON parsing, hex conversion or JWT rewriting occurs: exact original text,
including signatures, spacing and line endings, is preserved by the module.

Default chunk size is 160 bytes of the transport stream. Savings in QR text do
not translate exactly to fewer pages, because frame and page counts are rounded.
The table is generated by tests/compression.mjs using samples in
 tests/compression-samples.mjs; full Gzip/Deflate results are in
 tests/compression-results.json. The old demo sample uses a repeating byte pattern
and is intentionally NOT used as evidence for typical compression savings.

## Module integration

Existing synchronous `encode` and `Decoder` remain unchanged and support KBQR1.
For optional compression and automatic restoration of both versions:

```js
import { encodeAsync, DecoderAsync } from './kbqr.js'

const { frames, index, stats } = await encodeAsync(backupLine, {
  compression: 'auto', // 'none', 'auto', 'gzip', 'deflate'
  chunkSize: 160,
  label: 'KPOP backup'
})
const decoder = new DecoderAsync()
const progress = await decoder.add(scannedFrame)
if (progress.complete) useRestoredText(progress.text)
```

Do not pass compression to the old synchronous API; use `encodeAsync`.
Its default is `none` for API compatibility. The demo explicitly selects `auto`.
`encodeAsync` returns the usual frames/index plus stats. Stats include requested,
method, originalBytes, transportBytes, wireBytes, uncompressedWireBytes,
savedBytes, payloadSavingsPercent, wireSavingsPercent, uncompressedFrameCount,
frameCount and fallback. Wire-byte figures are the sum of ASCII frame lengths,
without export-file newline separators. Negative savings mean growth.

`DecoderAsync.add` always returns a Promise. `progress` and `reset` stay
synchronous. Adds are serialized, including concurrent camera/photo submissions.
A reset cancels queued operations and prevents in-flight decompression from
committing stale content. It retains any constructor expectedId lock.
`parseCompressedFrame` parses KBQR2 only; old `parseFrame` remains KBQR1 only.
`compressionSupported(method, direction)` checks native support; direction is
'both' by default, or 'encode'/'decode'.

The browser's native CompressionStream / DecompressionStream implement Gzip and
zlib-wrapped Deflate. No additional library or network access is used.
Auto falls back to uncompressed if compression is unavailable. Forced methods
report an error; decoding compressed sets requires decompression support.
See the [browser Compression Streams documentation](https://developer.mozilla.org/en-US/docs/Web/API/Compression_Streams_API).
Keep kbqr.js, kbqr-core.js and kbqr-compression.js together for module imports.
Classic scripts load core, then compression, then app.js as index.html does.

## Compatibility and verification

None produces byte-identical KBQR1 frames. Compressed sets use KBQR2 and require
the updated reader; an old KBQR 1.0 reader cannot restore compressed sets.
The updated reader handles both formats automatically, without a decode-method
selector. It first verifies compressed bytes, then decompresses with a bounded
output size, verifies original byte length and original SHA-256, and only then
returns text. No payload content is exposed before final verification.

Resource limits remain 1 MiB original text / 4096 frames; compressed transport
is capped at 1 MiB + 64 KiB. The demo still caps original input at 64 KiB.
The uncompressed baseline must fit the original frame limit; increase chunk
size if it does not. Native compression implementations may emit different
valid compressed bytes across browsers, so compressed set ids can differ even
for identical text/settings. Always keep frames from the same generated set.

Eleven additional test groups passed: both codecs; original format compatibility;
exact Unicode/CRLF/KPOP recovery; the Auto threshold; concurrent and duplicate
scans; mixed-set rejection; corruption; independent Node zlib interoperability;
bounded expansion; missing native APIs; reset during decompression; and full
compressed KPOP optical QR round-trips at two rendering sizes.
Run `node tests/compression.mjs` without installing dependencies.
Physical printer/camera and browser UI testing remain outstanding, as before.
