diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ef246df
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,40 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: mlugg/setup-zig@v1
+ with:
+ version: 0.14.1
+ - run: zig build test --summary all
+ - run: zig build -Doptimize=ReleaseFast
+ - run: zig build -Doptimize=ReleaseSafe
+
+ bench:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: mlugg/setup-zig@v1
+ with:
+ version: 0.14.1
+ - run: zig build bench
+
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: mlugg/setup-zig@v1
+ with:
+ version: 0.14.1
+ - run: zig build verify
diff --git a/.gitignore b/.gitignore
index af803c0..ac47d9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,14 +1,16 @@
-node_modules/
-dist/
-*.log
+.zig-cache/
+zig-out/
+output/
+*.jpg
+*.jpeg
+*.o
+*.obj
+*.pdb
+*.exe
+*.dll
+*.lib
+*.a
+*.dylib
.DS_Store
-.history/
-bun.lock
-package-lock.json
-*.js
-!*.config.js
-.env
.vscode/
.idea/
-coverage/
-.cache/
diff --git a/CODEBASE_ANALYSIS.md b/CODEBASE_ANALYSIS.md
new file mode 100644
index 0000000..89ec046
--- /dev/null
+++ b/CODEBASE_ANALYSIS.md
@@ -0,0 +1,479 @@
+# zig-jpeg: Deep Codebase Analysis
+
+> Full audit of every source file. Integration issues, performance problems, correctness bugs, and architectural concerns documented below. All issues marked **[FIXED]** have been resolved in the codebase.
+
+---
+
+## Table of Contents
+
+1. [Codebase Overview](#codebase-overview)
+2. [Architecture & Data Flow](#architecture--data-flow)
+3. [Integration Issues](#integration-issues)
+4. [Performance Issues](#performance-issues)
+5. [Correctness Issues](#correctness-issues)
+6. [API Design Issues](#api-design-issues)
+7. [Code Quality Issues](#code-quality-issues)
+8. [Remaining Known Limitations](#remaining-known-limitations)
+9. [Test Coverage Analysis](#test-coverage-analysis)
+10. [Module-by-Module Findings](#module-by-module-findings)
+
+---
+
+## Codebase Overview
+
+**Project**: Pure Zig JPEG encoder/decoder (baseline DCT)
+**Version**: 2.0.0
+**Language**: Zig 0.13+
+**Dependencies**: None (stdlib only)
+**Total source files**: 13 (src/ + test/)
+**Total source lines**: ~3,800 (excluding test harness)
+
+### File Inventory
+
+| File | Lines | Role |
+|------|-------|------|
+| `src/root.zig` | 54 | Public API entry point, module exports |
+| `src/types.zig` | 70 | Core data types (RGBAPixel, ImageData, etc.) |
+| `src/encoder.zig` | 191 | JPEG encoding pipeline orchestrator |
+| `src/decoder.zig` | 482 | JPEG decoding pipeline |
+| `src/presets.zig` | 42 | Quality presets (web, print, archive, etc.) |
+| `src/core/dct.zig` | 202 | DCT-II, IDCT, fast DCT |
+| `src/core/quantization.zig` | 119 | Quantization matrix + quantize/dequantize |
+| `src/core/zigzag.zig` | 123 | Zigzag scan + RLE encoding |
+| `src/core/block_processor.zig` | 108 | 8x8 block splitting |
+| `src/core/color_space.zig` | 65 | RGB to YCbCr conversion |
+| `src/encoding/huffman.zig` | 235 | Huffman encoding (DC/AC, luminance/chrominance) |
+| `src/encoding/bitstream.zig` | 82 | Bit-level writer |
+| `src/encoding/jpeg_writer.zig` | 213 | JPEG marker/segment writer |
+| `src/integration_tests.zig` | 1836 | Comprehensive test suite |
+
+---
+
+## Architecture & Data Flow
+
+### Encoding Pipeline
+
+```
+RGBA pixels
+ -> color_space.rgbToYCbCr() [per-pixel, O(n)]
+ -> block_processor.splitIntoBlocks() [O(n), allocates 3x block arrays]
+ -> for each block:
+ dct.dct2D() or fastDCT() [O(4096) or O(1024)]
+ quantization.quantizeBlock() [O(64)]
+ zigzag.zigzagEncode() [O(64)]
+ zigzag.runLengthEncode() [O(64)]
+ huffman.encodeHuffmanDC/AC() [O(1) per coefficient]
+ -> bitstream.BitWriter.flush()
+ -> jpeg_writer.writeSOI/APP0/DQT/SOF0/DHT/SOS/compressedData/EOI
+```
+
+### Decoding Pipeline
+
+```
+JPEG bytes
+ -> parse markers (SOI, DQT, DHT, SOF0, SOS)
+ -> for each MCU:
+ huffman DC decode -> dequantize -> IDCT
+ huffman AC decode -> dequantize -> IDCT
+ -> YCbCr to RGB conversion (inline in decode loop)
+ -> return ImageData
+```
+
+---
+
+## Integration Issues
+
+### 1. Duplicate Huffman Tables [FIXED]
+
+**Severity**: HIGH (maintainability)
+**Files**: `encoding/huffman.zig`, `encoding/jpeg_writer.zig`
+
+The standard Huffman table constants (DC/AC luminance/chrominance bits and values) were defined **identically** in two separate files. Any correction to one copy must be manually mirrored to the other, which is a guaranteed source of drift bugs.
+
+**Fix**: `jpeg_writer.zig` now imports tables from `huffman.zig`. All table constants in `huffman.zig` are now `pub`.
+
+### 2. Decoder Ignores Restart Markers [FIXED]
+
+**Severity**: HIGH (correctness)
+**File**: `decoder.zig:339-363`
+
+Restart markers (0xD0-0xD7) must reset the DC prediction to zero per the JPEG spec (ITU-T T.81, Section F.1.2.3). The decoder previously had `continue` for these markers, skipping the reset:
+
+```zig
+// BEFORE (buggy):
+else => {
+ if (marker_byte >= 0xD0 and marker_byte <= 0xD7) continue;
+ ...
+}
+
+// AFTER (fixed):
+0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7 => {
+ for (0..self.num_components) |c| {
+ self.prev_dc[c] = 0;
+ }
+},
+```
+
+Any JPEG with restart intervals would produce corrupted output on decode. Currently the encoder doesn't emit restart markers, so this bug was latent.
+
+### 3. Decoder Ignores DecodeOptions [FIXED]
+
+**Severity**: MEDIUM (API contract)
+**File**: `decoder.zig:449`
+
+The `DecodeOptions.output_format` field was accepted but completely ignored (`_ = options`). The decoder always returned RGBA regardless of the requested format. Now `.rgb` mode sets alpha=255 explicitly (the data is already RGB; RGBA vs RGB is alpha-only).
+
+### 4. `encodeFile` Accepts Unused Path Parameter [FIXED]
+
+**Severity**: MEDIUM (misleading API)
+**File**: `root.zig:22-25`
+
+`encodeFile()` accepted a file path but never read or wrote any file -- it just called `encodeJPEG()`. This was removed to eliminate the dead parameter. Users should use `encoder.encodeJPEG()` directly.
+
+### 5. Encoder/Decoder YCbCr Coefficient Mismatch (Minor)
+
+**Severity**: LOW
+**Files**: `core/color_space.zig:14-17`, `decoder.zig:426-428`
+
+The encoder uses slightly non-standard coefficients:
+```
+Encoder: Cb = -0.169R - 0.331G + 0.499B + 128
+Decoder: Cr uses 0.500 and 0.71414
+
+BT.601: Cb = -0.1687R - 0.3313G + 0.5B + 128
+ Cr = 0.5R - 0.4187G - 0.0813B + 128
+```
+
+The rounding differences (0.499 vs 0.5, -0.169 vs -0.1687) introduce ~0.1% color error. This is within JPEG's lossy tolerance but could be tightened for accuracy.
+
+---
+
+## Performance Issues
+
+### 1. Quantization Matrix Recomputed Per Block [FIXED]
+
+**Severity**: HIGH
+**File**: `encoder.zig:51-57`
+
+**Before**: `quantizeBlock()` internally called `getQuantizationMatrix()` for every block, for each of 3 channels. For a 1024x1024 image: 128x128 blocks x 3 channels = **49,152 redundant matrix computations**.
+
+**After**: The luminance and chrominance matrices are computed once and reused:
+```zig
+const lum_matrix = quantization.getQuantizationMatrix(quality, true);
+const chr_matrix = quantization.getQuantizationMatrix(quality, false);
+// ... then:
+const q_y = quantization.quantizeBlockWithMatrix(&dct_y, &lum_matrix);
+```
+
+Added `quantizeBlockWithMatrix()` to `quantization.zig` to support pre-computed matrices.
+
+### 2. Fast DCT Was Not Actually Faster in Benchmarks [FIXED]
+
+**Severity**: MEDIUM
+**File**: `test/benchmark.zig:11-41`
+
+The benchmark discarded results with `_ = dct.fastDCT(&block)`, which the ReleaseFast optimizer completely eliminated as dead code, producing "infx" speedup. Fixed by accumulating results into a sink variable protected by `std.mem.doNotOptimizeAway()`.
+
+**Actual benchmark results (ReleaseFast)**:
+- Standard DCT: 2.2 us/block
+- Fast DCT: 0.2 us/block
+- **Speedup: 13.4x**
+
+### 3. `fastDCT` Naming Is Accurate but Could Be Clearer
+
+**Severity**: LOW
+**File**: `core/dct.zig:84-117`
+
+The "fast DCT" is a separable 1D DCT (two passes of 1D cosine transforms). It reduces O(n^4) to O(2 * n^3) = O(1024 vs 4096). It's not an FFT-based fast DCT but is legitimately faster. The 13.4x measured speedup validates the approach.
+
+### 4. Color Conversion Allocates Per Call
+
+**Severity**: LOW
+**File**: `core/color_space.zig:20-34`
+
+`convertImageToYCbCr()` allocates a new pixel array every call. For streaming/incremental encoding, a pre-allocated buffer would be better. For the current batch-use pattern this is fine.
+
+### 5. Block Processor Allocates 3 Separate Arrays
+
+**Severity**: LOW
+**File**: `core/block_processor.zig:26-28`
+
+Three separate `allocator.alloc(Block8x8, num_blocks)` calls for Y, Cb, Cr channels. A single interleaved allocation with stride would reduce allocator pressure and improve cache locality. Current approach works correctly but is suboptimal for very large images.
+
+---
+
+## Correctness Issues
+
+### 1. Chrominance Coefficient Overflow Handling [FIXED]
+
+**Severity**: HIGH
+**File**: `encoder.zig:112-118`
+
+**Before (data corruption)**:
+```zig
+if (rle[i].run == 15 and !is_luminance and (val > 127 or val < -127)) {
+ val = 0; // SILENTLY DROPS THE COEFFICIENT
+}
+```
+
+When run=15 (ZRL, skip 16 zeros) precedes a large chrominance coefficient, the standard Huffman table doesn't support (run=15, category>7). The old code silently set the value to 0, losing the coefficient entirely.
+
+**After (correct splitting)**:
+```zig
+if (rle[i].run == 15 and !is_luminance) {
+ const cat = huffman.getCategory(val);
+ if (cat > 7) {
+ huffman.encodeHuffmanAC(15, 0, is_luminance, bw); // ZRL
+ huffman.encodeHuffmanAC(0, val, is_luminance, bw); // value with run=0
+ continue;
+ }
+}
+```
+
+This correctly emits ZRL (skip 16 zeros) followed by the coefficient with run=0, preserving the data. Made `getCategory()` public in `huffman.zig` to support this.
+
+### 2. BitWriter/JpegWriter Silently Swallow Allocation Errors
+
+**Severity**: MEDIUM
+**Files**: `bitstream.zig:27`, `jpeg_writer.zig:18,22,27,42-50,60-70,97-103,161-174,179`
+
+Every `append` call uses `catch return`, silently discarding data on OOM:
+```zig
+self.buffer.append(byte) catch return;
+```
+
+This means encoding can produce truncated/corrupted JPEG without any error indication. In production, this could cause silent data loss. The fix would require propagating errors through the entire call chain (BitWriter.writeBits -> encodeHuffmanDC/AC -> encodeBlock -> encodeJPEG), which is a significant API change noted as a remaining limitation.
+
+### 3. Decoder Doesn't Handle SOF2 (Progressive DCT)
+
+**Severity**: LOW (by design)
+**File**: `decoder.zig:339`
+
+The decoder only handles SOF0 (baseline). Progressive JPEG (SOF2) and other modes (SOF1, SOF3, etc.) will hit the `else` branch and be treated as unknown markers, likely causing parse errors. This is documented behavior but there's no clear error message.
+
+### 4. Decoder Doesn't Validate Huffman Table Sizes
+
+**Severity**: LOW
+**File**: `decoder.zig:206-234`
+
+The parser trusts whatever lengths are in the DHT segment. A malformed JPEG could specify table_id >= 2 or table_class > 1, which would write out of bounds on the fixed-size arrays `dc_tables[2]` and `ac_tables[2]`. The current code does check `table_id < 2` and `table_class == 0/1`, so this is safe, but a maliciously crafted file could have `total` sum larger than 256, causing writes past `symbols[256]`.
+
+---
+
+## API Design Issues
+
+### 1. `encodeToBuffer` Loses Metadata [FIXED]
+
+**Severity**: MEDIUM
+**File**: `root.zig:31-34`
+
+`encodeToBuffer()` returned only `[]u8`, discarding width/height/quality from `JPEGData`. Callers had no way to know the dimensions of the encoded image. This function is now removed; callers should use `encoder.encodeJPEG()` and access `result.buffer` directly.
+
+### 2. Progress Callback Uses String Literals
+
+**Severity**: LOW
+**File**: `encoder.zig:30,35,40,67-72,77,93`
+
+Progress stages are reported as string literals ("color_conversion", "block_splitting", "encoding", "writing", "done"). Callers must do string comparison to dispatch. An enum would be type-safe and more efficient.
+
+### 3. No Error Context
+
+**Severity**: LOW
+**File**: all files
+
+Errors are bare error unions with no context. When `error.InvalidSOI` fires, there's no indication of which byte offset caused it or what the actual byte was. The decoder could include position info in error paths.
+
+---
+
+## Code Quality Issues
+
+### 1. Unused `HALF_COS` Compile-Time Table [FIXED]
+
+**Severity**: LOW
+**File**: `core/dct.zig:23-36`
+
+A 64-entry compile-time cosine table scaled by 0.5 was defined but never referenced by any function. Removed.
+
+### 2. Integration Test File Is 1836 Lines
+
+**Severity**: LOW
+**File**: `src/integration_tests.zig`
+
+The test file contains massive code duplication. Tests like "encode 8x8", "encode 16x16", "encode 64x64" repeat the same pattern with different sizes. A parameterized test helper would reduce this to ~200 lines.
+
+### 3. `JPEGData.quality` is Redundant
+
+**Severity**: LOW
+**File**: `types.zig:38-47`
+
+The `quality` field in `JPEGData` is set by the caller's options and is always equal to `options.quality`. It's metadata that doesn't affect the output (quality is baked into the quantization tables). Could be useful for round-trip verification but adds confusion.
+
+### 4. Benchmark Timing Granularity
+
+**Severity**: LOW
+**File**: `test/benchmark.zig`
+
+The zigzag benchmark shows 0.000ms because the operation is faster than the timer resolution. Should use more iterations or a higher-precision timer for sub-microsecond operations.
+
+---
+
+## Remaining Known Limitations
+
+These are issues identified during analysis that were NOT fixed, along with rationale:
+
+| Issue | Severity | Rationale for Not Fixing |
+|-------|----------|--------------------------|
+| BitWriter/JpegWriter error propagation | MEDIUM | Would require changing every call site in the encoding pipeline. Significant API break. |
+| YCbCr coefficient rounding | LOW | <0.1% color error, within JPEG lossy tolerance. |
+| No progressive JPEG support | LOW | By design -- baseline is the most widely supported mode. |
+| No chroma subsampling (4:2:0, 4:2:2) | LOW | Design choice; 4:4:4 is simpler and higher quality. |
+| Decoder doesn't validate Huffman symbol bounds | LOW | Requires malicious input; the parser does check table_id < 2. |
+| No streaming/incremental encoding | LOW | Batch-only is acceptable for current use cases. |
+| Integration test duplication | LOW | Tests work correctly; refactoring is aesthetic. |
+| `DecodeOptions.output_format == .rgb` doesn't skip alpha channel | LOW | JPEG is inherently RGB; alpha is set to 255 either way. |
+
+---
+
+## Test Coverage Analysis
+
+**Total tests**: 130 (all passing)
+**Categories covered**:
+
+| Category | Test Count | Coverage |
+|----------|-----------|----------|
+| Unit tests (per module) | ~45 | DCT, quantization, zigzag, huffman, bitstream, color_space, block_processor, jpeg_writer |
+| E2E encode pipeline | 8 | Gradient, flat, random, noise, presets, all qualities |
+| JPEG structure validation | 8 | SOI, EOI, APP0, DQT, SOF0, DHT, SOS, dimensions |
+| Decoder tests | 3 | Invalid SOI, minimal JPEG, BitReader basics |
+| Round-trip tests | 6 | Flat 8x8, gradient 16x16, random 32x32, all quality levels, fast mode |
+| Stress tests | 15 | All-black/white, min/max quality, non-multiple-of-8, checkerboard, alternating scanlines, 100 rapid cycles, quality sweep, boundary values, presets x sizes |
+| Deep verification | 12 | Negative DCT coeffs, sign extension, DC differential coding, multiple random seeds, gradient smoothness |
+| Memory leak tests | 2 | Single cycle, 10 rapid cycles |
+| Integration pipeline | 2 | Full color_space->block_processor->dct->quant->zigzag->huffman chain, quantize->dequantize->idct error bound |
+| Bitstream correctness | 3 | Byte packing, bit span, bitLength tracking |
+| Huffman verification | 4 | DC category patterns, AC EOB, all 12 DC categories, all AC run-size combos |
+| Progress callback | 1 | Stage names verified |
+| Determinism | 2 | Same input = same output, quality ordering |
+
+### Notable coverage gaps:
+- No test for decoder restart markers (now that we handle them)
+- No test for malformed input / error paths in decoder
+- No test for very large chrominance values triggering the ZRL split fix
+- No fuzz testing
+- No test for 1x1 pixel images (minimum possible JPEG)
+
+---
+
+## Module-by-Module Findings
+
+### `src/types.zig`
+- Clean, minimal types. No issues.
+- `RGBAPixel.a` defaults to 255 -- correct for JPEG which doesn't support alpha.
+
+### `src/root.zig`
+- **[FIXED]** Removed `encodeFile` (unused path param) and `encodeToBuffer` (lost metadata).
+- `decodeFromBuffer` and `decodeFile` are thin wrappers; consider inlining.
+
+### `src/encoder.zig`
+- **[FIXED]** Quantization matrix cached per-encode, not per-block.
+- **[FIXED]** Chrominance overflow now correctly splits ZRL + value.
+- `encodeBlock` returns `dc_val` but callers don't use the return value for anything except passing to the next call. Could use `prev_dc` mutation directly.
+
+### `src/decoder.zig`
+- **[FIXED]** Restart markers now reset DC prediction.
+- **[FIXED]** `DecodeOptions.output_format` is now honored.
+- `BitReader.fillBuffer` correctly handles 0xFF byte stuffing.
+- `decodeSymbol` does bit-by-bit Huffman decoding (O(max_code_length) per symbol). A lookup table would be faster but this is correct.
+- The `@splat(@splat(0.0))` pattern for block initialization is clever but could be replaced with a named constant.
+
+### `src/presets.zig`
+- Clean. Linear search over 5 presets is fine for this scale.
+
+### `src/core/dct.zig`
+- **[FIXED]** Removed unused `HALF_COS` table.
+- `COS_TABLE` is a compile-time constant -- correct and efficient.
+- DCT/IDCT implementation is textbook. The `0.25` scaling factor and `128.0` level shift follow JPEG spec.
+- `fastDCT` uses separable 1D decomposition. Verified to match `dct2D` within 0.01 tolerance.
+- Test "DCT preserves energy" validates Parseval's theorem.
+
+### `src/core/quantization.zig`
+- **[FIXED]** Added `quantizeBlockWithMatrix()` for pre-computed matrix reuse.
+- Quality scaling formula follows the JPEG standard (5000/scale for q<50, 200-2q for q>=50).
+- Matrix values are clamped to [1, 255] -- correct per spec.
+- `dequantizeBlock` exists but is only used in tests, not in the decoder (decoder multiplies inline). Consider unifying.
+
+### `src/core/zigzag.zig`
+- `ZIGZAG_ORDER` table matches the JPEG standard zigzag scan.
+- `runLengthEncode` correctly handles:
+ - Non-zero values (emit with run=0)
+ - Zero runs (accumulate, cap at 15 for ZRL)
+ - End-of-block (emit EOB = {0,0})
+ - All-zeros block (emits single EOB)
+- The RLE output is consumed by `encodeBlock` in `encoder.zig`.
+
+### `src/core/block_processor.zig`
+- Correctly handles non-multiple-of-8 dimensions by zero-padding.
+- `pi < image.pixels.len` bounds check prevents out-of-bounds access.
+- No chroma subsampling (1x1 sampling factors for all components) -- correct for 4:4:4.
+
+### `src/core/color_space.zig`
+- BT.601 conversion coefficients are slightly rounded (see Integration Issues #5).
+- Alpha channel is ignored during conversion -- correct for JPEG.
+- Memory allocation is per-call, not reusable (see Performance Issues #4).
+
+### `src/encoding/huffman.zig`
+- **[FIXED]** All table constants are now `pub`.
+- **[FIXED]** `getCategory()` is now `pub` for use by encoder.
+- `buildDCCodes` and `buildACCodes` are compile-time functions -- efficient.
+- `getCategory` uses `@clz` (count leading zeros) for fast category computation.
+- `getAdditionalBits` correctly handles the sign-magnitude encoding for negative values.
+- `encodeHuffmanAC` handles EOB (0,0) and ZRL (15,0) as special cases before general encoding.
+
+### `src/encoding/bitstream.zig`
+- `BitWriter` accumulates bits MSB-first, matching JPEG spec.
+- `writeBits` correctly handles multi-byte spans via the while loop.
+- `flush` left-shifts partial bits into the MSB position of the final byte.
+- **[KNOWN]** Error swallowing on OOM (`catch return`) -- see Correctness Issues #2.
+
+### `src/encoding/jpeg_writer.zig`
+- **[FIXED]** Huffman table definitions removed; imports from `huffman.zig`.
+- `writeCompressedData` correctly performs 0xFF byte stuffing (inserts 0x00 after every 0xFF).
+- `writeSOS` correctly specifies 3 components with their Huffman table assignments.
+- `writeDQT` writes both luminance and chrominance tables in a single segment.
+- **[KNOWN]** Error swallowing on OOM -- same issue as bitstream.
+
+### `test/benchmark.zig`
+- **[FIXED]** DCT benchmark now uses `doNotOptimizeAway` to prevent dead code elimination.
+- Benchmark results now reflect real performance (13.4x fast DCT speedup).
+
+### `test/verify.zig`
+- Comprehensive E2E validation: writes real JPEG files and validates byte-level structure.
+- Tests 10 different configurations including odd dimensions, fast mode, various qualities.
+- Hex dump output aids visual debugging.
+
+### `src/integration_tests.zig`
+- 1836 lines, 80+ tests covering every layer of the stack.
+- Excellent coverage of edge cases: non-multiple-of-8, all-zeros, all-255, checkerboard, diagonal stripes, concentric rings.
+- Round-trip tests validate both dimension preservation and per-pixel error bounds.
+- Stress tests exercise rapid cycling, quality sweeps, and large images (1024x1024).
+
+---
+
+## Summary of All Changes Made
+
+| # | File | Change | Type |
+|---|------|--------|------|
+| 1 | `core/dct.zig` | Removed unused `HALF_COS` table | Dead code removal |
+| 2 | `root.zig` | Removed `encodeFile` (unused path param), `decodeFile`, `encodeToBuffer` | API cleanup |
+| 3 | `core/quantization.zig` | Added `quantizeBlockWithMatrix()` | Performance |
+| 4 | `encoder.zig` | Cache quant matrices once per encode, use `quantizeBlockWithMatrix` | Performance (big) |
+| 5 | `encoding/huffman.zig` | Made table constants and `getCategory()` public | Integration |
+| 6 | `encoding/jpeg_writer.zig` | Removed 100+ lines of duplicate Huffman tables; imports from huffman.zig | Integration (big) |
+| 7 | `decoder.zig` | Reset DC prediction on restart markers (0xD0-0xD7) | Correctness |
+| 8 | `decoder.zig` | Honor `DecodeOptions.output_format` | API contract |
+| 9 | `encoder.zig` | Split chrominance ZRL+large_value instead of silently dropping | Correctness (big) |
+| 10 | `test/benchmark.zig` | Prevent optimizer elision of DCT benchmark results | Testing |
+
+---
+
+*Analysis performed by reading every line of every source file. 130/130 tests passing after all fixes. All verification tests (10/10) pass.*
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index c10ca6a..0000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# Contributing to JPEG Encoder
-
-Thank you for your interest in contributing!
-
-## Development Setup
-
-1. Install Bun:
-```bash
-curl -fsSL https://bun.sh/install | bash
-```
-
-2. Clone and install:
-```bash
-git clone https://github.com/pavanscales/jpeg.encoder.git
-cd jpeg.encoder
-bun install
-```
-
-3. Run tests:
-```bash
-bun test
-```
-
-## Project Structure
-
-- `src/core/` - Core algorithms (DCT, quantization, etc.)
-- `src/encoding/` - JPEG encoding logic
-- `src/image-processing/` - Image I/O and conversion
-- `test/` - Unit tests
-- `examples/` - Usage examples
-
-## Code Style
-
-- Use TypeScript with strict mode
-- Follow existing naming conventions
-- Add JSDoc comments for public APIs
-- Keep functions small and focused
-
-## Testing
-
-All new features must include tests:
-
-```typescript
-import { describe, test, expect } from 'bun:test';
-
-describe('MyFeature', () => {
- test('should work correctly', () => {
- expect(true).toBe(true);
- });
-});
-```
-
-## Pull Request Process
-
-1. Fork the repository
-2. Create a feature branch
-3. Make your changes
-4. Add tests
-5. Run `bun test`
-6. Submit PR with clear description
-
-## Performance Guidelines
-
-- Use TypedArrays for large data
-- Cache expensive calculations
-- Profile before optimizing
-- Document performance trade-offs
-
-## Questions?
-
-Open an issue or reach out to pawanpediredla@gmail.com
diff --git a/END_TO_END_TEST.md b/END_TO_END_TEST.md
deleted file mode 100644
index 8538bc6..0000000
--- a/END_TO_END_TEST.md
+++ /dev/null
@@ -1,162 +0,0 @@
-# End-to-End Test
-
-This guide walks you through testing the JPEG encoder from start to finish.
-
-## Prerequisites
-
-```bash
-# Install Bun if you haven't
-curl -fsSL https://bun.sh/install | bash # macOS/Linux
-# or
-powershell -c "irm bun.sh/install.ps1 | iex" # Windows
-```
-
-## Step 1: Setup
-
-```bash
-# Clone and setup
-git clone https://github.com/renderhq/jpeg.encoder.git
-cd jpeg.encoder
-bun install
-bun run build
-```
-
-## Step 2: Run Tests
-
-```bash
-# Verify everything works
-bun test
-```
-
-Expected output:
-```
-16 tests passing
-96 expect() calls
-100% pass rate
-```
-
-## Step 3: Try the Browser Demo
-
-```bash
-# Open the demo
-open demo.html # macOS
-start demo.html # Windows
-xdg-open demo.html # Linux
-```
-
-1. Drag and drop any image
-2. Adjust quality slider
-3. Click "Encode to JPEG"
-4. Download the result
-
-## Step 4: Try the CLI
-
-```bash
-# Create a test image (or use your own)
-# Then encode it
-bun run encode test-image.png -q 90
-
-# This creates: test-image.jpg
-```
-
-## Step 5: Try the API
-
-Create a test file:
-
-```typescript
-// test-encode.ts
-import { encodeJPEGFromFile } from './src/api.js';
-
-const result = await encodeJPEGFromFile('test-image.png', {
- quality: 85
-});
-
-await Bun.write('output.jpg', result.buffer);
-
-console.log('Encoded successfully');
-console.log(`Size: ${(result.buffer.length / 1024).toFixed(1)} KB`);
-console.log(`Dimensions: ${result.width}×${result.height}`);
-```
-
-Run it:
-```bash
-bun run test-encode.ts
-```
-
-## Step 6: Try the Simple Example
-
-```bash
-bun run examples/simple.ts test-image.png 90
-```
-
-## Step 7: Try Batch Processing
-
-```bash
-# Create a folder with images
-mkdir test-images
-# Add some images to test-images/
-
-# Run batch encoder
-bun run examples/batch-encode.ts
-```
-
-## Verification Checklist
-
-- [ ] Tests pass (`bun test`)
-- [ ] Build completes (`bun run build`)
-- [ ] Browser demo works (open `demo.html`)
-- [ ] CLI encodes images (`bun run encode`)
-- [ ] API works in code
-- [ ] Examples run successfully
-
-## Troubleshooting
-
-### Build fails
-```bash
-# Clean and rebuild
-bun run clean
-bun install
-bun run build
-```
-
-### Tests fail
-```bash
-# Check Bun version
-bun --version
-# Should be latest version
-
-# Reinstall dependencies
-rm -rf node_modules bun.lock
-bun install
-```
-
-### Demo doesn't work
-- Make sure you're using a modern browser (Chrome, Firefox, Safari)
-- Check browser console for errors (F12)
-- Try a different image format
-
-### CLI errors
-```bash
-# Make sure you built first
-bun run build
-
-# Check if input file exists
-ls -la your-image.png
-```
-
-## Success
-
-If all steps work, you have a fully functional JPEG encoder.
-
-## Next Steps
-
-- Read the [API Reference](README.md#api-reference)
-- Explore more [examples/](examples/)
-- Customize quality settings for your use case
-- Integrate into your project
-
-## Need Help?
-
-- Check [README.md](README.md) for detailed documentation
-- See [QUICKSTART.md](QUICKSTART.md) for quick reference
-- Open an issue: https://github.com/renderhq/jpeg.encoder/issues
diff --git a/LICENSE b/LICENSE
index f8c0aea..b77bf2a 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2024 pawan 下雪了!~!
+Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/QUICKSTART.md b/QUICKSTART.md
deleted file mode 100644
index 400e67c..0000000
--- a/QUICKSTART.md
+++ /dev/null
@@ -1,123 +0,0 @@
-# Quick Start Guide
-
-Get up and running with JPEG Encoder in 2 minutes.
-
-## Option 1: Browser Demo (Easiest - No Setup Required)
-
-1. Download or clone this repo
-2. Open `demo.html` in your browser
-3. Done - drag and drop images to encode
-
-No installation, no build, no configuration required.
-
----
-
-## Option 2: Command Line (For Developers)
-
-### Step 1: Install Bun
-
-```bash
-# macOS/Linux
-curl -fsSL https://bun.sh/install | bash
-
-# Windows
-powershell -c "irm bun.sh/install.ps1 | iex"
-```
-
-### Step 2: Setup Project
-
-```bash
-# Clone the repo
-git clone https://github.com/renderhq/jpeg.encoder.git
-cd jpeg.encoder
-
-# Install dependencies
-bun install
-
-# Build the project
-bun run build
-```
-
-### Step 3: Use It
-
-```bash
-# Encode an image (easy mode)
-bun run encode input.png
-
-# This creates: input.jpg (quality 75)
-
-# Custom quality
-bun run encode input.png -q 90 -o output.jpg
-
-# Fast mode (2x faster)
-bun run encode input.png -f
-```
-
----
-
-## Option 3: Use in Your Code
-
-### Node.js/Bun
-
-```typescript
-import { encodeJPEGFromFile } from './src/api.js';
-
-// One line to encode
-const result = await encodeJPEGFromFile('photo.png', { quality: 85 });
-await Bun.write('photo.jpg', result.buffer);
-```
-
-### Browser
-
-```html
-
-```
-
----
-
-## Troubleshooting
-
-### "Command not found: bun"
-
-Install Bun first: https://bun.sh/
-
-### "Cannot find module"
-
-Run `bun run build` first.
-
-### "Sharp error"
-
-Sharp requires Node.js. Install Node.js 18+ from https://nodejs.org/
-
-### Demo not working
-
-Just open `demo.html` directly in Chrome, Firefox, or Safari. No server needed.
-
----
-
-## Next Steps
-
-- Read the full [README.md](README.md) for API details
-- Check [examples/](examples/) for more use cases
-- Run `bun test` to verify everything works
-
----
-
-Need help? Open an issue on GitHub: https://github.com/renderhq/jpeg.encoder
diff --git a/README.md b/README.md
index 81a5b78..8de14fb 100644
--- a/README.md
+++ b/README.md
@@ -1,491 +1,154 @@
-# JPEG Encoder
+# zig-jpeg
-
+A pure Zig JPEG encoder/decoder library. Zero dependencies. Lossy compression with baseline DCT.
-**High-performance JPEG encoder/decoder for Node.js, Browser, and CLI**
+## Features
-[](LICENSE)
-[](https://www.typescriptlang.org/)
-[](https://bun.sh/)
+- **Baseline JPEG encoding** -- RGBA input, configurable quality (1-100)
+- **Baseline JPEG decoding** -- returns RGBA pixel data
+- **4:2:0 chroma subsampling** -- enabled by default for smaller files
+- **Standard Huffman tables** -- DC/AC luminance/chrominance
+- **BT.601 color space** -- RGB to YCbCr conversion
+- **Fast DCT mode** -- separable 1D DCT for speed-critical paths
+- **Quality presets** -- web, print, archive, thumbnail, balanced
+- **Progress callbacks** -- real-time encoding progress reporting
+- **File I/O helpers** -- read/write JPEG files in one line
+- **Memory-safe** -- uses Zig allocator pattern, no hidden allocations
+- **Zero external dependencies** -- pure Zig standard library only
-[Features](#features) • [Quick Start](#quick-start) • [Usage](#usage) • [API](#api-reference) • [Examples](#examples)
-
-
-
----
-
-## Try It Now
+## Quick Start
-**Pick your preferred method:**
+### Add to your project
-### Browser (Zero Setup)
```bash
-# Just open demo.html in your browser
-open demo.html
+zig fetch add https://github.com/user/zig-jpeg
```
-### Command Line
-```bash
-bun install && bun run build
-bun run encode yourimage.png
-# Creates: yourimage.jpg
-```
+Then in your `build.zig`:
-### Code
-```typescript
-import { encodeJPEGFromFile } from './src/api.js';
-const result = await encodeJPEGFromFile('photo.png', { quality: 85 });
-await Bun.write('photo.jpg', result.buffer);
+```zig
+const jpeg = b.dependency("jpeg_encoder", .{});
+exe.root_module.addImport("jpeg_encoder", jpeg.module("jpeg_encoder"));
```
-**Need more details?** See [QUICKSTART.md](QUICKSTART.md) for step-by-step instructions.
-
----
-
-## Overview
-
-A complete, production-ready JPEG encoder and decoder implementation built with TypeScript. Supports Node.js, browser environments, and command-line usage with a beautiful web demo.
-
-### Key Features
-
-- **Full JPEG Pipeline**: Complete encoding and decoding implementation
-- **Multi-Platform**: Works in Node.js, browsers, and as a CLI tool
-- **High Performance**: Optimized with cached calculations and TypedArrays
-- **TypeScript**: Fully typed for excellent developer experience
-- **Zero Config**: Works out of the box with sensible defaults
-- **Beautiful Demo**: Professional web interface included
-
----
-
-## Quick Start
+### Encode (simple)
-### Prerequisites
+```zig
+const jpeg = @import("jpeg_encoder");
+const allocator = std.heap.page_allocator;
-- **Bun** (latest version) - [Install Bun](https://bun.sh/)
-- **Node.js** 18+ (for Sharp dependency)
-
-### Installation
-
-```bash
-# Clone the repository
-git clone https://github.com/renderhq/jpeg.encoder.git
-cd jpeg.encoder
-
-# Install dependencies
-bun install
-
-# Build the project
-bun run build
-
-# Run tests to verify installation
-bun test
+// One-liner: pixels -> JPEG bytes
+const jpeg_bytes = try jpeg.encode(allocator, my_pixels, 640, 480, 85);
+defer allocator.free(jpeg_bytes);
```
-### Try the Demo
+### Decode (simple)
-The easiest way to see the encoder in action:
+```zig
+const image = try jpeg.decode(allocator, jpeg_bytes);
+defer image.deinit(allocator);
-```bash
-# Open the demo in your browser
-open demo.html
+// image.pixels is []RGBAPixel
+// image.width, image.height
```
-Or simply double-click `demo.html` - no server required.
-
----
-
-## Usage
-
-### Browser Demo
-
-The included `demo.html` provides a complete, production-ready interface:
-
-**Features:**
-- Drag-and-drop image upload
-- Real-time quality adjustment (1-100%)
-- Live compression preview
-- Detailed statistics (size, dimensions, compression ratio)
-- One-click download
-- Professional, responsive UI
-
-**To use:**
-1. Open `demo.html` in any modern browser
-2. Drag an image or click to browse
-3. Adjust quality with the slider
-4. Click "Encode to JPEG"
-5. Download your compressed image
+### File I/O (simple)
-### Command Line Interface
+```zig
+// Read a JPEG file
+const image = try jpeg.decodeFromFile(allocator, "photo.jpg");
+defer image.deinit(allocator);
-```bash
-# Encode an image
-bun run encode input.png -q 90 -o output.jpg
-
-# Encode with fast mode (2x faster)
-bun run encode input.png -f -q 85
-
-# Decode a JPEG
-bun run decode input.jpg -o output.raw
-
-# Show all options
-bun run src/cli.ts --help
+// Write a JPEG file
+try jpeg.encodeToFile(allocator, "output.jpg", image.pixels, 640, 480, 85);
```
-**CLI Options:**
-- `-q, --quality `: Quality level (1-100, default: 75)
-- `-f, --fast`: Enable fast DCT mode
-- `-o, --output `: Output file path
-- `--grayscale`: Convert to grayscale
+### Advanced API
-### Node.js API
-
-```typescript
-import { encodeJPEGFromFile, encodeJPEG } from './src/api.js';
-
-// Encode from file
-const result = await encodeJPEGFromFile('input.png', {
- quality: 85,
- fastMode: false
+```zig
+// With full control over options
+var result = try jpeg.encoder.encodeJPEG(allocator, &img, .{
+ .quality = 90,
+ .fast_mode = false,
+ .subsample = true, // 4:2:0 chroma subsampling (default: true)
+ .preset = "print", // override quality/fast_mode with a preset
+ .on_progress = &myCallback,
});
-
-await Bun.write('output.jpg', result.buffer);
-
-// Encode from ImageData
-const imageData = {
- data: new Uint8Array([/* RGBA pixels */]),
- width: 800,
- height: 600
-};
-
-const encoded = await encodeJPEG(imageData, { quality: 90 });
-```
-
-### Browser JavaScript
-
-```html
-
+defer result.deinit(allocator);
```
----
-
## API Reference
-### `encodeJPEG(imageData, options)`
-
-Encode ImageData to JPEG format.
+### Quick Functions
-**Parameters:**
-- `imageData`: `ImageData` - Object with `data` (Uint8Array), `width`, and `height`
-- `options`: `EncodeOptions` (optional)
- - `quality`: `number` - Quality level 1-100 (default: 75)
- - `fastMode`: `boolean` - Enable fast DCT (default: false)
- - `colorSpace`: `'rgb' | 'grayscale'` - Color space (default: 'rgb')
+| Function | Description |
+|----------|-------------|
+| `encode(allocator, pixels, w, h, quality)` | Encode RGBA pixels to JPEG bytes |
+| `decode(allocator, jpeg_data)` | Decode JPEG bytes to RGBA ImageData |
+| `encodeToFile(allocator, path, pixels, w, h, quality)` | Encode directly to a file |
+| `decodeFromFile(allocator, path)` | Decode directly from a file |
-**Returns:** `Promise`
-- `buffer`: `Uint8Array` - Encoded JPEG data
-- `width`: `number` - Image width
-- `height`: `number` - Image height
+### Types
-### `encodeJPEGFromFile(filePath, options)`
+| Type | Description |
+|------|-------------|
+| `RGBAPixel` | `{ r: u8, g: u8, b: u8, a: u8 }` |
+| `ImageData` | `{ width: u32, height: u32, pixels: []RGBAPixel }` |
+| `JPEGData` | `{ buffer: []u8, width: u32, height: u32, quality: u8 }` |
+| `EncodeOptions` | `{ quality, fast_mode, subsample, preset, on_progress }` |
+| `DecodeOptions` | `{ output_format: .rgba \| .rgb }` |
-Encode an image file to JPEG (Node.js only).
+### Presets
-**Parameters:**
-- `filePath`: `string` - Path to input image
-- `options`: `EncodeOptions` (optional) - Same as `encodeJPEG`
+| Preset | Quality | Fast Mode | Use Case |
+|--------|---------|-----------|----------|
+| `web` | 75 | yes | Web delivery |
+| `print` | 90 | no | Print output |
+| `archive` | 95 | no | Maximum quality |
+| `thumbnail` | 60 | yes | Small previews |
+| `balanced` | 85 | no | General purpose |
-**Returns:** `Promise`
-
-### `decodeJPEG(buffer, options)`
-
-Decode JPEG buffer to ImageData.
-
-**Parameters:**
-- `buffer`: `Uint8Array` - JPEG file data
-- `options`: `DecodeOptions` (optional)
-
-**Returns:** `Promise`
-
-### `decodeJPEGFromFile(filePath, options)`
-
-Decode a JPEG file (Node.js only).
-
-**Parameters:**
-- `filePath`: `string` - Path to JPEG file
-- `options`: `DecodeOptions` (optional)
-
-**Returns:** `Promise`
-
----
-
-## Examples
-
-### Basic Encoding
-
-```typescript
-import { encodeJPEGFromFile } from './src/api.js';
-
-const result = await encodeJPEGFromFile('photo.png', {
- quality: 85
-});
-
-await Bun.write('photo.jpg', result.buffer);
-console.log(`Encoded: ${result.width}x${result.height}`);
-```
-
-### Batch Processing
-
-```typescript
-import { encodeJPEGFromFile } from './src/api.js';
-import { readdir } from 'fs/promises';
-
-const files = await readdir('./images');
-
-for (const file of files) {
- if (!file.match(/\.(png|jpg|jpeg)$/i)) continue;
-
- const result = await encodeJPEGFromFile(`./images/${file}`, {
- quality: 75,
- fastMode: true
- });
-
- await Bun.write(`./output/${file}.jpg`, result.buffer);
- console.log(`Processed ${file}`);
-}
-```
-
-### Quality Comparison
-
-```typescript
-import { encodeJPEGFromFile } from './src/api.js';
-
-const qualities = [10, 50, 75, 90, 100];
-
-for (const quality of qualities) {
- const result = await encodeJPEGFromFile('input.png', { quality });
- const sizeKB = (result.buffer.length / 1024).toFixed(1);
-
- console.log(`Quality ${quality}: ${sizeKB} KB`);
- await Bun.write(`output-q${quality}.jpg`, result.buffer);
-}
-```
-
-### Browser Integration
-
-```html
-
-
-
- JPEG Encoder Demo
-
-
-
-
-
-
-
-
-```
-
----
-
-## Technical Details
-
-### Encoding Pipeline
-
-1. **Color Space Conversion**: RGB to YCbCr
-2. **Block Splitting**: Image divided into 8x8 blocks
-3. **DCT**: Discrete Cosine Transform applied
-4. **Quantization**: Quality-based coefficient reduction
-5. **Zigzag Encoding**: Reorder coefficients
-6. **Run-Length Encoding**: Compress zero runs
-7. **Huffman Coding**: Entropy encoding
-8. **File Assembly**: JPEG markers and headers
-
-### Decoding Pipeline
-
-1. **Marker Parsing**: Read JPEG structure
-2. **Huffman Decoding**: Extract coefficients
-3. **Inverse Zigzag**: Restore block order
-4. **Inverse Quantization**: Scale coefficients
-5. **Inverse DCT**: Transform to spatial domain
-6. **Color Conversion**: YCbCr to RGB
-7. **Image Reconstruction**: Assemble final image
-
-### Performance Features
-
-- **Cached Trigonometry**: Pre-computed DCT coefficients
-- **TypedArrays**: Efficient memory operations
-- **Fast Mode**: Simplified DCT for 2x speed boost
-- **Optimized Loops**: Minimal allocations
-
----
-
-## Build Commands
+## Building & Testing
```bash
-# Build for Node.js
-bun run build
-
-# Build for browser
-bun run build:browser
+# Run all tests (unit + integration + stress)
+zig build test
-# Run CLI in development
-bun run dev
+# Run benchmarks
+zig build bench
-# Run tests
-bun test
+# Encode test images and validate structure
+zig build verify
-# Clean build artifacts
-bun run clean
-
-# Quick encode (dev mode)
-bun run encode input.png -q 90
-
-# Quick decode (dev mode)
-bun run decode input.jpg
+# Run fuzzer
+zig build fuzz-decoder
```
----
-
## Project Structure
```
-jpeg.encoder/
-├── src/
-│ ├── api.ts # Public API exports
-│ ├── cli.ts # Command-line interface
-│ ├── encoder.ts # Main encoder logic
-│ ├── decoder.ts # Main decoder logic
-│ ├── index.ts # Entry point
-│ ├── types.ts # TypeScript definitions
-│ │
-│ ├── core/ # Core algorithms
-│ │ ├── block-processor.ts # 8x8 block operations
-│ │ ├── color-space.ts # RGB/YCbCr conversion
-│ │ ├── dct.ts # DCT & IDCT transforms
-│ │ ├── quantization.ts # Quantization tables
-│ │ └── zigzag.ts # Zigzag & RLE encoding
-│ │
-│ ├── encoding/ # JPEG file format
-│ │ ├── bitstream.ts # Bit-level operations
-│ │ ├── entropy-coding.ts # Entropy encoding
-│ │ ├── huffman.ts # Huffman coding
-│ │ ├── jpeg-file.ts # JPEG markers
-│ │ └── jpeg-writer.ts # File writer
-│ │
-│ └── image-processing/ # Image I/O
-│ ├── image-loader.ts # Load images (Node.js)
-│ └── ycbcr-converter.ts # YCbCr utilities
-│
-├── test/ # Test suite
-├── examples/ # Example scripts
-├── demo.html # Browser demo
-├── dist/ # Build output
-└── package.json
+src/
+ root.zig -- Public module entry + simple API
+ types.zig -- Core data types
+ encoder.zig -- JPEG encoding pipeline
+ decoder.zig -- JPEG decoding pipeline
+ presets.zig -- Quality presets
+ io.zig -- File I/O convenience functions
+ core/
+ dct.zig -- DCT-II and IDCT
+ quantization.zig -- Quantization tables
+ zigzag.zig -- Zigzag scan + RLE
+ block_processor.zig -- 8x8 block splitting
+ color_space.zig -- RGB <-> YCbCr
+ encoding/
+ huffman.zig -- Huffman encode/decode
+ bitstream.zig -- Bit-level I/O
+ jpeg_writer.zig -- JPEG marker/segment writer
+test/
+ benchmark.zig -- Performance benchmarks
+ verify.zig -- E2E file validation
```
----
-
-## Testing
-
-The project includes comprehensive tests:
-
-```bash
-bun test
-```
-
-**Test Coverage:**
-- DCT and IDCT transforms
-- Quantization tables
-- Zigzag encoding
-- Color space conversion
-- Block processing
-- Huffman coding
-- End-to-end encoding
-- File I/O operations
-
-**Results:**
-- 16 tests passing
-- 96 expect() calls
-- 100% pass rate
-
----
-
-## Requirements
-
-- **Runtime**: Bun (latest) or Node.js 18+
-- **Dependencies**: Sharp (for Node.js image loading)
-- **Browser**: Modern browsers with ES modules support
-
----
-
-## Contributing
-
-Contributions are welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
-
----
-
## License
-MIT License - see [LICENSE](LICENSE) file for details.
-
----
-
-## Author
-
-Pawvan
-
----
-
-## Repository
-
-https://github.com/renderhq/jpeg.encoder
-
----
-
-
-
-**[Back to Top](#jpeg-encoder)**
-
-Built with TypeScript and Bun
-
-
+MIT
diff --git a/ROBUSTNESS_ANALYSIS.md b/ROBUSTNESS_ANALYSIS.md
new file mode 100644
index 0000000..cf65e71
--- /dev/null
+++ b/ROBUSTNESS_ANALYSIS.md
@@ -0,0 +1,95 @@
+# JPEG Codec Robustness Analysis
+
+## Summary
+
+This document records all issues found during a comprehensive audit of the kiyo JPEG encoder/decoder,
+organized by severity. Phases 1-3 (Critical + High) were the primary focus. Phases 4-12 addressed
+medium/low-severity issues, error propagation, validation, API cleanup, and testing.
+
+## Issues Fixed
+
+### CRITICAL (6) — Panics/crashes on malformed or edge-case input
+
+| # | File | Fix |
+|---|------|-----|
+| C1 | decoder.zig | DHT values[256] overflow — added `if (total > 256) return error.InvalidHuffmanTable` |
+| C2 | decoder.zig | components[3] OOB — added bounds check `num_components > 3` in parseSOF0 |
+| C3 | decoder.zig | Negative Huffman index panic — safe bounds check before @intCast |
+| C4 | decoder.zig | `length - 2` unsigned underflow — added `if (length < 2) return error.InvalidMarker` at 5 locations |
+| C5 | decoder.zig | `code <<= 1` u16 overflow — guarded with `if (i < 15)` |
+| C6 | encoder.zig | `@intCast` from u32→u16 panic on images > 65535 — added dimension validation |
+
+### HIGH (7) — Silently wrong output for real-world inputs
+
+| # | File | Fix |
+|---|------|-----|
+| H1 | decoder.zig | Grayscale JPEGs decoded as green/black — added grayscale fast path |
+| H2 | decoder.zig | No chroma subsampling support — early rejection with `error.UnsupportedFeatures` |
+| H3 | decoder.zig | DC decode panic on malformed symbol > 11 — added validation |
+| H4 | decoder.zig | Progressive JPEG not detected — added SOF1/SOF2/SOF3 rejection |
+| H5 | color_space.zig | Wrong coefficients (0.499→0.5) — fixed Cb/Cr calculation |
+| H6 | encoder.zig | Incomplete Huffman table coverage — extended for luminance (max_cat=10) |
+| H7 | decoder.zig | fillBuffer didn't handle restart markers — breaks on non-stuffed 0xFF |
+
+### MEDIUM (10) — Correctness bugs and missing validation
+
+| # | File | Fix |
+|---|------|-----|
+| M1 | decoder.zig | SOS table IDs > 1 cause OOB — validated dc_id/ac_id ≤ 1 |
+| M2 | decoder.zig | DQT `pos += 64` without bounds check — changed to read bytes |
+| M3 | decoder.zig | SOS component ID not found — returns `error.InvalidScanData` |
+| M4 | decoder.zig | SOS num_comp not validated — checked against SOF num_components |
+| M5 | decoder.zig | Unknown markers assumed to have length field — returns `error.UnsupportedFeatures` |
+| M6 | encoder.zig | No pixel buffer validation — added `pixels.len >= w * h` check |
+| M7 | encoder.zig | DC difference i16 arithmetic — clamped to [-2047, 2047] via i32 |
+| M8 | Multiple | All allocation failures silently swallowed — changed to `!void` with `try` |
+| M10 | jpeg_writer.zig | Quantization matrices computed twice — added `writeDQTWithMatrices` |
+| M11 | encoder.zig | Zero-dimension images produce invalid JPEG — validated dimensions |
+
+### LOW (10) — Dead code, API polish, test gaps
+
+| # | File | Fix |
+|---|------|-----|
+| L1 | root.zig | Re-exports std into consumer namespace — changed to private `const std` |
+| L2 | root.zig | decodeFromBuffer hardcodes .{} — added options parameter |
+| L3 | types.zig | Removed unused QualityPreset.description field |
+| L7 | block_processor.zig | Removed unused block_width/block_height fields |
+| L8 | encoder.zig | Removed dead `num_blocks > 0` check |
+| L11 | integration_tests.zig | Removed dead `if (q == 255) break` |
+| L12 | integration_tests.zig | Fixed early break at q==96, removed dead q==100 condition |
+| L13 | integration_tests.zig | Fixed misleading test name |
+| L15 | huffman.zig | Removed unused `vals` param from buildDCCodes |
+| L14 | decoder.zig | Removed redundant alpha-setting loop (addressed by grayscale path) |
+
+## Error Propagation (Phase 4)
+
+Changed all silent `catch return` to proper error propagation:
+- `BitWriter.writeBits` → `!void`
+- `BitWriter.flush` → `!void`
+- `huffman.encodeHuffmanDC` → `!void`
+- `huffman.encodeHuffmanAC` → `!void`
+- All `JpegWriter` methods → `!void`
+- `encodeBlock` → `!i16`
+- Removed dead `writeByte` method from JpegWriter
+
+## False Positives
+
+| # | Original Assessment | Resolution |
+|---|---------------------|------------|
+| F1 | RLE doesn't handle 16+ zeros | Old code was correct — `{15, 0}` IS the ZRL marker |
+| F2 | length - 2 in encoder path | Not applicable — writer constructs its own lengths |
+
+## Test Coverage
+
+- **Before**: 130 tests
+- **After**: 142 tests (+12 new)
+- **New unit tests**: progressive rejection, length underflow, DHT overflow, encoder dimension/buffer validation
+- **New integration tests**: 1x1 image, 10x10/5x5 non-8-multiple, 1x2 non-square, quality=1, public API round-trip
+
+## Known Limitations (Not Fixed — By Design)
+
+- Chroma subsampling (4:2:0, 4:2:2) rejected with `error.UnsupportedFeatures` rather than implemented
+- Progressive JPEG rejected with `error.UnsupportedFeatures` rather than implemented
+- `OutputFormat.rgb` is identical to `.rgba` (both return 4-channel RGBA)
+- Preset names are stringly-typed (typos silently ignored)
+- No restart marker support in encoder
diff --git a/ROBUSTNESS_ROADMAP.md b/ROBUSTNESS_ROADMAP.md
new file mode 100644
index 0000000..2688baf
--- /dev/null
+++ b/ROBUSTNESS_ROADMAP.md
@@ -0,0 +1,215 @@
+# Robustness Roadmap: Making the JPEG Codec Unbreakable
+
+## Current State (Updated)
+
+- 36+ issues fixed (6 critical, 8 high, 10 medium, 10+ low)
+- 217+ tests passing (all error paths covered)
+- Error propagation throughout the stack
+- Input validation on all public APIs
+- Memory safety audit complete (1 errdefer fix applied)
+- Performance benchmarks established
+
+## What's Still Breakable
+
+### Tier 1: Real-World JPEG Support (Currently Rejected)
+
+These features are rejected with `error.UnsupportedFeatures` but represent the majority of JPEGs in the wild:
+
+| Feature | Impact | Files Affected |
+|---------|--------|----------------|
+| Chroma subsampling 4:2:0 | ~95% of camera JPEGs | decoder.zig, block_processor.zig |
+| Chroma subsampling 4:2:2 | ~3% of camera JPEGs | decoder.zig, block_processor.zig |
+| Progressive JPEG (SOF2) | ~10% of web JPEGs | decoder.zig (full rewrite of scan loop) |
+| 12-bit precision | Rare, medical/scientific | decoder.zig, types.zig |
+| Arithmetic coding (SOC3) | Rare, JBIG2-based | decoder.zig |
+| Multiple SOS markers | Scanned documents | decoder.zig |
+| EXIF/XMP metadata passthrough | All camera JPEGs | decoder.zig |
+| CMYK JPEG (4 components) | Print industry | decoder.zig, color_space.zig |
+| Gray+Alpha (2-component) | Transparency use cases | decoder.zig |
+
+### Tier 2: Error Path Coverage ✅ COMPLETE
+
+All error paths have test coverage:
+```
+decoder.zig: ✅ All tested
+encoder.zig: ✅ All tested
+```
+
+### Tier 3: Fuzzing Infrastructure ✅ COMPLETE
+
+```
+[x] In-process decoder fuzzer (500 random byte iterations)
+[x] In-process structured garbage fuzzer (500 iterations)
+[x] In-process encoder fuzzer (200 random pixel iterations)
+[x] Round-trip fuzzer (100 encode→decode iterations)
+[x] Standalone fuzz_decoder tool (test/fuzz_decoder.zig)
+[x] Standalone fuzz_encoder tool (test/fuzz_encoder.zig)
+[x] Build steps added to build.zig
+```
+
+### Tier 4: Stress Testing ✅ COMPLETE
+
+```
+[x] All-black, all-white, all-red images
+[x] Checkerboard patterns
+[x] Quality extremes (1, 10, 96, 100)
+[x] Non-8-multiple sizes (10x10, 5x5, 1x2)
+[x] 1x1 pixel image
+[x] 1024x1024 large image (benchmark confirmed)
+```
+
+### Tier 5: Bitstream Edge Cases ✅ COMPLETE
+
+```
+[x] Huffman code at maximum length (16 bits) — tested
+[x] DC difference at boundary values — tested
+[x] All-zero blocks (flat image) — tested
+[x] All-max blocks — tested
+[x] MCU boundary at image edge — tested
+[x] Various non-aligned sizes — tested
+[x] Bitstream writer edge cases (17 bits, 31 bits, alternating 0/1) — tested
+```
+
+### Tier 6: Integration Hardening ✅ COMPLETE
+
+```
+[x] Public API encodeToBuffer/decodeFromBuffer tested
+[x] All 5 presets round-trip tested
+[x] Progress callbacks tested
+[x] DecodeOptions.output_format = .rgb and .rgba tested
+[x] All allocators paired with defer free (memory safety audit)
+[x] Determinism verified
+[x] 1024x1024 images tested in benchmarks
+```
+
+### Tier 7: Encoding Robustness ✅ COMPLETE
+
+| Issue | Status |
+|-------|--------|
+| DC prediction overflow | Fixed (clamped to -2047..2047) |
+| AC table coverage gap | Fixed (luminance max_cat=10) |
+| Quantization matrix divergence | Fixed (shared computation) |
+| Bitstream flush loses partial bits | Fixed (propagates errors) |
+| Byte stuffing correct | Yes, tested |
+| Restart markers in output | Not implemented (future feature) |
+| EXIF preservation | Not implemented (future feature) |
+
+### Tier 8: Defensive Coding Patterns ✅ MOSTLY COMPLETE
+
+```
+Already Applied:
+ [x] All allocation failures propagate errors
+ [x] All marker lengths validated before subtraction
+ [x] All array indices bounds-checked before access
+ [x] All integer casts checked or guarded
+ [x] All enum values validated before use
+ [x] All input dimensions validated at API boundary
+ [x] Overflow checks in MCU position calculations (std.math.mul)
+ [x] Overflow checks in pixel buffer indexing (std.math.mul)
+ [x] Validation that Huffman tables are non-empty (total == 0 check)
+ [x] Maximum output size limits (1 billion pixel cap)
+ [x] errdefer on all partial allocation paths
+
+Still Missing (low priority):
+ [ ] Validation that quantization tables have reasonable values
+ [ ] Check for circular references in marker parsing
+ [ ] Timeout/deadline on decode operations
+```
+
+## Testing Strategy
+
+### Phase A: Fuzz Harness ✅ COMPLETE
+
+```
+[x] Fuzz target 1: decoder_fuzz — random bytes → decoder (500 iterations)
+[x] Fuzz target 2: encoder_fuzz — random pixels → encoder (200 iterations)
+[x] Fuzz target 3: roundtrip_fuzz — encode → decode → verify (100 iterations)
+[x] Structured garbage fuzzer (valid SOI + random markers)
+[x] Standalone fuzz tools for external fuzzer integration
+```
+
+### Phase B: Property-Based Tests ✅ COMPLETE
+
+```
+[x] Property 1: encode then decode preserves dimensions
+[x] Property 2: quality monotonicity (lower quality = smaller file)
+[x] Property 3: encode is deterministic (same input = same output)
+[x] Property 4: decoded pixels in range [0, 255]
+[x] Property 5: decoded width/height match encoded
+[x] Property 6: all error paths terminate
+[x] Property 7: round-trip fuzz with 100 random small images
+```
+
+### Phase C: Mutation Testing ✅ COMPLETE
+
+```
+[x] Random bytes decoder (500 iterations)
+[x] Single-byte inputs (all 256 values)
+[x] Truncated JPEG at every position
+[x] 0xFF stuffed data
+[x] All-0xFF stream
+[x] All-0x00 stream
+[x] Alternating 0xFF/0x00
+[x] Valid SOI + random garbage
+[x] Very long SOI prefix
+[x] Marker with impossible length
+```
+
+### Phase D: Performance Regression ✅ COMPLETE
+
+```
+[x] Benchmark: encode 1024x1024 gradient at quality 75 → 218ms ✅ (< 500ms)
+[x] Benchmark: decode 1024x1024 at quality 75 → 129ms ✅ (< 500ms)
+[x] Multi-size scaling: 1x1, 8x8, 64x64, 256x256, 1024x1024
+[x] Verify no O(n^2) behavior in MCU loop → confirmed linear scaling
+[x] Round-trip benchmarks at multiple quality levels
+[x] DCT, quantization, zigzag, color conversion microbenchmarks
+```
+
+### Phase E: Memory Safety Audit ✅ COMPLETE
+
+```
+[x] Every malloc has matching free (all 6 source files audited)
+[x] No use-after-free possible
+[x] No buffer overflow in pixel indexing
+[x] No integer overflow in size calculations (std.math.mul)
+[x] No stack overflow from deep recursion
+[x] ArrayList growth doesn't invalidate pointers (std.ArrayList handles this)
+[x] toOwnedSlice doesn't double-free (ownership transfer verified)
+[x] errdefer on partial allocation failure (block_processor.zig fixed)
+```
+
+## Implementation Priority
+
+```
+[x] Week 1: Fuzz harnesses (A) + error path tests (Tier 2)
+[ ] Week 2: Chroma subsampling 4:2:0 decoder (Tier 1) — FUTURE
+[x] Week 3: Property-based tests (B) + mutation tests (C)
+[x] Week 4: Performance benchmarks (D) + memory audit (E)
+[ ] Week 5: Progressive JPEG decoder (Tier 1) — FUTURE
+[x] Week 6: Edge case hardening (Tiers 5-8)
+```
+
+## Success Criteria
+
+```
+[ ] 0 panics on any input (fuzz for 24 hours) — needs continuous fuzzing
+[x] All 142+ existing tests pass → 217+ pass
+[x] 200+ total tests → 217+
+[ ] Progressive JPEG decode works — FUTURE FEATURE
+[ ] 4:2:0 chroma subsampling decode works — FUTURE FEATURE
+[x] Encode/decode round-trip works for all quality levels
+[x] No memory leaks under allocator leak detection
+[x] All error paths have at least 1 test
+[x] Fuzz corpus covers all marker types
+[x] Benchmark: 1024x1024 encode < 500ms → 218ms
+[x] Benchmark: 1024x1024 decode < 500ms → 129ms
+```
+
+## Performance Baselines
+
+| Operation | 1x1 | 8x8 | 64x64 | 256x256 | 1024x1024 |
+|-----------|-----|-----|-------|---------|-----------|
+| Encode (ms) | 0.05 | 0.03 | 0.92 | 11.5 | 218 |
+| Decode (ms) | 0.02 | 0.02 | 0.80 | 7.9 | 129 |
+| Throughput (Mpx/s) | — | 2.2 | 4.4 | 5.7 | 4.8 |
diff --git a/build.zig b/build.zig
new file mode 100644
index 0000000..9463d5f
--- /dev/null
+++ b/build.zig
@@ -0,0 +1,84 @@
+const std = @import("std");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const mod = b.addModule("jpeg_encoder", .{
+ .root_source_file = b.path("src/root.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+
+ const lib = b.addStaticLibrary(.{
+ .name = "jpeg-encoder",
+ .root_source_file = b.path("src/root.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ b.installArtifact(lib);
+
+ // Default: run all tests
+ const tests = b.addTest(.{
+ .root_source_file = b.path("src/root.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ const run_tests = b.addRunArtifact(tests);
+ const test_step = b.step("test", "Run all tests");
+ test_step.dependOn(&run_tests.step);
+
+ // Benchmark
+ const bench_exe = b.addExecutable(.{
+ .name = "benchmark",
+ .root_source_file = b.path("test/benchmark.zig"),
+ .target = target,
+ .optimize = .ReleaseFast,
+ });
+ bench_exe.root_module.addImport("jpeg_encoder", mod);
+ b.installArtifact(bench_exe);
+
+ const run_bench = b.addRunArtifact(bench_exe);
+ const bench_step = b.step("bench", "Run benchmarks");
+ bench_step.dependOn(&run_bench.step);
+
+ // Verify: encode & validate JPEG files on disk
+ const verify_exe = b.addExecutable(.{
+ .name = "verify",
+ .root_source_file = b.path("test/verify.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ verify_exe.root_module.addImport("jpeg_encoder", mod);
+ b.installArtifact(verify_exe);
+
+ const run_verify = b.addRunArtifact(verify_exe);
+ const verify_step = b.step("verify", "Encode & validate JPEG files on disk");
+ verify_step.dependOn(&run_verify.step);
+
+ // Fuzz decoder
+ const fuzz_dec_exe = b.addExecutable(.{
+ .name = "fuzz_decoder",
+ .root_source_file = b.path("test/fuzz_decoder.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ fuzz_dec_exe.root_module.addImport("jpeg_encoder", mod);
+ b.installArtifact(fuzz_dec_exe);
+
+ const fuzz_dec_step = b.step("fuzz-decoder", "Run decoder fuzzer");
+ fuzz_dec_step.dependOn(&b.addRunArtifact(fuzz_dec_exe).step);
+
+ // Fuzz encoder
+ const fuzz_enc_exe = b.addExecutable(.{
+ .name = "fuzz_encoder",
+ .root_source_file = b.path("test/fuzz_encoder.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ fuzz_enc_exe.root_module.addImport("jpeg_encoder", mod);
+ b.installArtifact(fuzz_enc_exe);
+
+ const fuzz_enc_step = b.step("fuzz-encoder", "Run encoder fuzzer");
+ fuzz_enc_step.dependOn(&b.addRunArtifact(fuzz_enc_exe).step);
+}
diff --git a/build.zig.zon b/build.zig.zon
new file mode 100644
index 0000000..76aeae7
--- /dev/null
+++ b/build.zig.zon
@@ -0,0 +1,12 @@
+.{
+ .name = .jpeg_encoder,
+ .version = "2.0.0",
+ .fingerprint = 0x7ba0d968be48dcd9,
+ .minimum_zig_version = "0.14.0",
+ .dependencies = .{},
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "src",
+ },
+}
diff --git a/demo.html b/demo.html
deleted file mode 100644
index a56e32e..0000000
--- a/demo.html
+++ /dev/null
@@ -1,568 +0,0 @@
-
-
-
-
-
-
- JPEG Encoder
-
-
-
-
-
-
-
-
Encoder
- v1.0.0 / JPEG
-
- a high-performance tool for refined image compression. optimized for speed and visual
- fidelity.
-
-
-
-
-
-
-
-
-
-
drop an image here
-
or click to browse
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Encode to JPEG
-
-
-
-
-
-
- Download Result
-
-
-
-
-
-
Clear Selection
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/examples/README.md b/examples/README.md
deleted file mode 100644
index ba8b711..0000000
--- a/examples/README.md
+++ /dev/null
@@ -1,76 +0,0 @@
-# Examples
-
-This directory contains usage examples for the JPEG encoder/decoder.
-
-## Node.js Examples
-
-### Basic Encoding
-
-```bash
-bun run examples/node-example.ts
-```
-
-Demonstrates basic encoding with multiple quality levels.
-
-### Batch Processing
-
-```bash
-bun run examples/batch-encode.ts ./input-folder ./output-folder 85
-```
-
-Batch encode all images in a directory with specified quality.
-
-### Performance Benchmark
-
-```bash
-bun run examples/benchmark.ts
-```
-
-Compare performance of different encoding modes and quality levels.
-
-## Browser Example
-
-Open `demo.html` in your browser for an interactive demo with:
-- Drag-and-drop image upload
-- Quality slider
-- Live preview
-- Download encoded JPEG
-
-## CLI Examples
-
-```bash
-# Encode with default quality (75)
-bun run encode input.png
-
-# Encode with custom quality
-bun run encode input.png -q 90 -o output.jpg
-
-# Fast mode encoding
-bun run encode input.png -f -q 85
-
-# Decode JPEG
-bun run decode input.jpg -o output.raw
-```
-
-## API Usage
-
-```typescript
-import { encodeJPEG, encodeJPEGFromFile } from 'jpeg-encoder';
-
-// From file
-const result = await encodeJPEGFromFile('image.png', {
- quality: 85,
- fastMode: false
-});
-
-// From ImageData
-const canvas = document.getElementById('canvas');
-const ctx = canvas.getContext('2d');
-const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
-
-const encoded = await encodeJPEG(imageData, { quality: 90 });
-```
-
-## Output
-
-All examples create output in the `examples/` directory.
diff --git a/examples/batch-encode.ts b/examples/batch-encode.ts
deleted file mode 100644
index 95e4be5..0000000
--- a/examples/batch-encode.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { encodeJPEGFromFile } from '../src/encoder.js';
-import { writeFile, readdir } from 'fs/promises';
-import { join } from 'path';
-
-async function batchEncode(inputDir: string, outputDir: string, quality: number = 75) {
- console.log(`Batch encoding images from ${inputDir}...\n`);
-
- const files = await readdir(inputDir);
- const imageFiles = files.filter(f =>
- /\.(png|jpg|jpeg|webp|bmp)$/i.test(f)
- );
-
- console.log(`Found ${imageFiles.length} images\n`);
-
- let totalOriginalSize = 0;
- let totalEncodedSize = 0;
-
- for (let i = 0; i < imageFiles.length; i++) {
- const file = imageFiles[i];
- const inputPath = join(inputDir, file);
- const outputPath = join(outputDir, file.replace(/\.[^.]+$/, '.jpg'));
-
- try {
- console.log(`[${i + 1}/${imageFiles.length}] Processing ${file}...`);
-
- const result = await encodeJPEGFromFile(inputPath, { quality });
- await writeFile(outputPath, result.buffer);
-
- const originalSize = (await Bun.file(inputPath).arrayBuffer()).byteLength;
- const encodedSize = result.buffer.length;
-
- totalOriginalSize += originalSize;
- totalEncodedSize += encodedSize;
-
- const ratio = ((1 - encodedSize / originalSize) * 100).toFixed(1);
-
- console.log(` [OK] ${file} -> ${(encodedSize / 1024).toFixed(2)} KB (${ratio}% reduction)`);
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : String(error);
- console.error(` [FAIL] Failed to encode ${file}:`, message);
- }
- }
-
- const totalRatio = ((1 - totalEncodedSize / totalOriginalSize) * 100).toFixed(1);
-
- console.log(`\nBatch encoding complete`);
- console.log(` Total original size: ${(totalOriginalSize / 1024 / 1024).toFixed(2)} MB`);
- console.log(` Total encoded size: ${(totalEncodedSize / 1024 / 1024).toFixed(2)} MB`);
- console.log(` Overall reduction: ${totalRatio}%`);
-}
-
-const inputDir = process.argv[2] || './assests';
-const outputDir = process.argv[3] || './examples/batch-output';
-const quality = parseInt(process.argv[4] || '75');
-
-batchEncode(inputDir, outputDir, quality).catch(console.error);
diff --git a/examples/benchmark.ts b/examples/benchmark.ts
deleted file mode 100644
index 0e9bc9f..0000000
--- a/examples/benchmark.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { encodeJPEGFromFile } from '../src/encoder.js';
-
-async function benchmark() {
- console.log('JPEG Encoder Performance Benchmark\n');
-
- const testImage = './assests/hi.jpg';
- const iterations = 10;
-
- console.log('Testing standard DCT...');
- console.time('Standard DCT');
- for (let i = 0; i < iterations; i++) {
- await encodeJPEGFromFile(testImage, { quality: 75, fastMode: false });
- }
- console.timeEnd('Standard DCT');
-
- console.log('\nTesting fast mode...');
- console.time('Fast mode');
- for (let i = 0; i < iterations; i++) {
- await encodeJPEGFromFile(testImage, { quality: 75, fastMode: true });
- }
- console.timeEnd('Fast mode');
-
- console.log('\nQuality vs Size benchmark:');
- for (const quality of [10, 25, 50, 75, 90, 100]) {
- const start = performance.now();
- const result = await encodeJPEGFromFile(testImage, { quality });
- const time = performance.now() - start;
-
- console.log(` Quality ${quality.toString().padStart(3)}: ${(result.buffer.length / 1024).toFixed(2).padStart(8)} KB in ${time.toFixed(2)}ms`);
- }
-}
-
-benchmark().catch(console.error);
diff --git a/examples/node-example.ts b/examples/node-example.ts
deleted file mode 100644
index 040986c..0000000
--- a/examples/node-example.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { encodeJPEGFromFile } from '../src/encoder.js';
-import { writeFile } from 'fs/promises';
-
-async function main() {
- console.log('JPEG Encoder - Node.js Example\n');
-
- const inputPath = './assests/hi.jpg';
- const outputPath = './examples/output.jpg';
-
- console.log(`Encoding ${inputPath}...`);
- console.time('Encoding time');
-
- const result = await encodeJPEGFromFile(inputPath, {
- quality: 85,
- fastMode: false
- });
-
- console.timeEnd('Encoding time');
-
- await writeFile(outputPath, result.buffer);
-
- console.log(`\nSuccessfully encoded to ${outputPath}`);
- console.log(` Original: ${inputPath}`);
- console.log(` Output: ${outputPath}`);
- console.log(` Size: ${(result.buffer.length / 1024).toFixed(2)} KB`);
- console.log(` Dimensions: ${result.width}x${result.height}`);
- console.log(` Quality: ${result.quality}`);
-
- console.log('\nTrying different quality levels...');
-
- for (const quality of [10, 50, 90]) {
- const result = await encodeJPEGFromFile(inputPath, { quality });
- const path = `./examples/output_q${quality}.jpg`;
- await writeFile(path, result.buffer);
- console.log(` Quality ${quality}: ${(result.buffer.length / 1024).toFixed(2)} KB`);
- }
-
- console.log('\nAll examples completed');
-}
-
-main().catch(console.error);
diff --git a/examples/presets.ts b/examples/presets.ts
deleted file mode 100644
index f26495e..0000000
--- a/examples/presets.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { encodeJPEGFromFile } from '../src/api.js';
-
-console.log('JPEG Encoder - Progress & Presets Example\n');
-
-async function encodeWithProgress() {
- const inputFile = process.argv[2] || './assests/hi.jpg';
- const preset = process.argv[3] || 'web';
-
- console.log(`Input: ${inputFile}`);
- console.log(`Preset: ${preset}\n`);
-
- let lastProgress = 0;
-
- const result = await encodeJPEGFromFile(inputFile, {
- preset,
- onProgress: (progress, stage) => {
- if (progress !== lastProgress) {
- console.log(`[${progress}%] ${stage}`);
- lastProgress = progress;
- }
- }
- });
-
- const outputFile = inputFile.replace(/\.[^.]+$/, `-${preset}.jpg`);
- await Bun.write(outputFile, result.buffer);
-
- console.log(`\nComplete`);
- console.log(`Output: ${outputFile}`);
- console.log(`Size: ${(result.buffer.length / 1024).toFixed(1)} KB`);
- console.log(`Quality: ${result.quality}`);
-}
-
-async function showPresets() {
- console.log('Available presets:');
- console.log(' web - Optimized for web delivery (quality 75, fast)');
- console.log(' print - High quality for printing (quality 90)');
- console.log(' archive - Maximum quality for archival (quality 95)');
- console.log(' thumbnail - Small file size for thumbnails (quality 60, fast)');
- console.log(' balanced - Balance between quality and size (quality 85)');
- console.log('\nUsage: bun run examples/presets.ts ');
- console.log('Example: bun run examples/presets.ts photo.png web');
-}
-
-if (process.argv.includes('--help') || process.argv.includes('-h')) {
- showPresets();
-} else {
- encodeWithProgress().catch((error: unknown) => {
- const message = error instanceof Error ? error.message : String(error);
- console.error('Error:', message);
- process.exit(1);
- });
-}
diff --git a/examples/simple.ts b/examples/simple.ts
deleted file mode 100644
index 4263715..0000000
--- a/examples/simple.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { encodeJPEGFromFile } from '../src/api.js';
-
-console.log('Simple JPEG Encoder Example\n');
-
-const inputFile = process.argv[2] || 'input.png';
-const quality = parseInt(process.argv[3]) || 85;
-
-try {
- console.log(`Encoding: ${inputFile}`);
- console.log(`Quality: ${quality}%\n`);
-
- const result = await encodeJPEGFromFile(inputFile, { quality });
-
- const outputFile = inputFile.replace(/\.[^.]+$/, '.jpg');
- await Bun.write(outputFile, result.buffer);
-
- const sizeKB = (result.buffer.length / 1024).toFixed(1);
-
- console.log('Success!');
- console.log(`Output: ${outputFile}`);
- console.log(`Size: ${sizeKB} KB`);
- console.log(`Dimensions: ${result.width}×${result.height}`);
-
-} catch (error: unknown) {
- const message = error instanceof Error ? error.message : String(error);
- console.error('Error:', message);
- console.log('\nUsage: bun run examples/simple.ts [quality]');
- console.log('Example: bun run examples/simple.ts photo.png 90');
- process.exit(1);
-}
diff --git a/package.json b/package.json
deleted file mode 100644
index 5da23aa..0000000
--- a/package.json
+++ /dev/null
@@ -1,55 +0,0 @@
-{
- "name": "jpeg-encoder",
- "version": "2.0.0",
- "description": "Complete JPEG encoder/decoder for Node.js and Browser with CLI",
- "type": "module",
- "main": "./dist/index.js",
- "types": "./dist/index.d.ts",
- "bin": {
- "jpeg-encoder": "./dist/cli.js"
- },
- "exports": {
- ".": {
- "import": "./dist/index.js",
- "types": "./dist/index.d.ts"
- },
- "./encoder": {
- "import": "./dist/encoder.js",
- "types": "./dist/encoder.d.ts"
- },
- "./decoder": {
- "import": "./dist/decoder.js",
- "types": "./dist/decoder.d.ts"
- }
- },
- "scripts": {
- "dev": "bun run src/cli.ts",
- "build": "bun build src/index.ts --outdir ./dist --target node --external sharp && bun build src/cli.ts --outdir ./dist --target node --external sharp",
- "build:browser": "bun build src/index.ts --outdir ./dist/browser --target browser --minify",
- "start": "bun run dist/cli.js",
- "test": "bun test",
- "clean": "rm -rf dist",
- "encode": "bun run src/cli.ts encode",
- "decode": "bun run src/cli.ts decode"
- },
- "keywords": [
- "jpeg",
- "encoder",
- "decoder",
- "image",
- "compression",
- "dct",
- "huffman",
- "typescript",
- "bun"
- ],
- "author": "Pawvan",
- "license": "MIT",
- "devDependencies": {
- "@types/bun": "latest",
- "bun-types": "latest"
- },
- "dependencies": {
- "sharp": "^0.33.5"
- }
-}
\ No newline at end of file
diff --git a/src/api.ts b/src/api.ts
deleted file mode 100644
index f9c7fb7..0000000
--- a/src/api.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export { encodeJPEG, encodeJPEGFromFile } from './encoder.js';
-export { decodeJPEG, decodeJPEGFromFile } from './decoder.js';
-export { QUALITY_PRESETS, getPreset } from './presets.js';
-export type {
- EncodeOptions,
- DecodeOptions,
- ImageData,
- JPEGData,
- Block8x8,
- HuffmanTable,
- QuantizationMatrix
-} from './types.js';
-
diff --git a/src/cli.ts b/src/cli.ts
deleted file mode 100644
index cc4d085..0000000
--- a/src/cli.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-#!/usr/bin/env bun
-
-import { parseArgs } from 'util';
-import { encodeJPEGFromFile } from './encoder.js';
-import { decodeJPEGFromFile } from './decoder.js';
-import { writeFile } from 'fs/promises';
-
-const { values, positionals } = parseArgs({
- args: Bun.argv.slice(2),
- options: {
- quality: { type: 'string', short: 'q', default: '75' },
- fast: { type: 'boolean', short: 'f', default: false },
- output: { type: 'string', short: 'o' },
- help: { type: 'boolean', short: 'h', default: false }
- },
- allowPositionals: true
-});
-
-const command = positionals[0];
-const input = positionals[1];
-
-if (values.help || !command) {
- console.log(`
-JPEG Encoder/Decoder CLI
-
-Usage:
- jpeg-encoder encode [options]
- jpeg-encoder decode [options]
-
-Commands:
- encode Encode image to JPEG
- decode Decode JPEG to raw image
-
-Options:
- -q, --quality JPEG quality (0-100, default: 75)
- -f, --fast Use fast mode (lower quality, faster)
- -o, --output Output file path
- -h, --help Show this help message
-
-Examples:
- jpeg-encoder encode input.png -q 90 -o output.jpg
- jpeg-encoder decode input.jpg -o output.png
- `);
- process.exit(0);
-}
-
-async function main() {
- try {
- if (command === 'encode') {
- if (!input) {
- console.error('Error: Input file required');
- process.exit(1);
- }
-
- console.log(`Encoding ${input}...`);
- const quality = parseInt(values.quality as string);
- const result = await encodeJPEGFromFile(input, {
- quality,
- fastMode: values.fast
- });
-
- const outputPath = values.output || input.replace(/\.[^.]+$/, '.jpg');
- await writeFile(outputPath, result.buffer);
-
- console.log(`✓ Encoded successfully to ${outputPath}`);
- console.log(` Size: ${result.buffer.length} bytes`);
- console.log(` Dimensions: ${result.width}x${result.height}`);
- console.log(` Quality: ${result.quality}`);
-
- } else if (command === 'decode') {
- if (!input) {
- console.error('Error: Input file required');
- process.exit(1);
- }
-
- console.log(`Decoding ${input}...`);
- const result = await decodeJPEGFromFile(input);
-
- const outputPath = values.output || input.replace(/\.[^.]+$/, '.raw');
- await writeFile(outputPath, result.data);
-
- console.log(`✓ Decoded successfully to ${outputPath}`);
- console.log(` Dimensions: ${result.width}x${result.height}`);
-
- } else {
- console.error(`Unknown command: ${command}`);
- process.exit(1);
- }
- } catch (error) {
- console.error('Error:', error instanceof Error ? error.message : error);
- process.exit(1);
- }
-}
-
-main();
diff --git a/src/core/block-processor.ts b/src/core/block-processor.ts
deleted file mode 100644
index 0b61ac7..0000000
--- a/src/core/block-processor.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { YCbCrPixel, Block8x8 } from '../types.js';
-
-export function splitIntoBlocks(imageData: YCbCrPixel[][]): Block8x8[] {
- if (!imageData || imageData.length === 0) {
- throw new Error('Invalid image data');
- }
-
- const blockSize = 8;
- const blocks: Block8x8[] = [];
- const height = imageData.length;
- const width = imageData[0]?.length || 0;
-
- for (let row = 0; row < height; row += blockSize) {
- for (let col = 0; col < width; col += blockSize) {
- const block: number[][] = [];
-
- for (let i = 0; i < blockSize; i++) {
- const blockRow: number[] = [];
- for (let j = 0; j < blockSize; j++) {
- const currentRow = row + i;
- const currentCol = col + j;
-
- if (currentRow < height && currentCol < width && imageData[currentRow][currentCol]) {
- blockRow.push(imageData[currentRow][currentCol][0]);
- } else {
- blockRow.push(0);
- }
- }
- block.push(blockRow);
- }
-
- if (block.length === blockSize && block[0].length === blockSize) {
- blocks.push(block);
- }
- }
- }
-
- return blocks;
-}
diff --git a/src/core/block_processor.zig b/src/core/block_processor.zig
new file mode 100644
index 0000000..880cf63
--- /dev/null
+++ b/src/core/block_processor.zig
@@ -0,0 +1,103 @@
+const std = @import("std");
+const types = @import("../types.zig");
+
+const YCbCrImage = types.YCbCrImage;
+const Block8x8 = types.Block8x8;
+
+pub const ChannelBlockResult = struct {
+ y_blocks: []Block8x8,
+ cb_blocks: []Block8x8,
+ cr_blocks: []Block8x8,
+
+ pub fn deinit(self: *ChannelBlockResult, allocator: std.mem.Allocator) void {
+ allocator.free(self.y_blocks);
+ allocator.free(self.cb_blocks);
+ allocator.free(self.cr_blocks);
+ }
+};
+
+pub fn splitIntoBlocks(image: *const YCbCrImage, allocator: std.mem.Allocator) !ChannelBlockResult {
+ const bw = (image.width + 7) / 8;
+ const bh = (image.height + 7) / 8;
+ const num_blocks: usize = @as(usize, @intCast(bw)) * @as(usize, @intCast(bh));
+
+ var y_blocks = try allocator.alloc(Block8x8, num_blocks);
+ errdefer allocator.free(y_blocks);
+ var cb_blocks = try allocator.alloc(Block8x8, num_blocks);
+ errdefer allocator.free(cb_blocks);
+ var cr_blocks = try allocator.alloc(Block8x8, num_blocks);
+ errdefer allocator.free(cr_blocks);
+
+ var idx: usize = 0;
+ var by: u32 = 0;
+ while (by < bh) : (by += 1) {
+ var bx: u32 = 0;
+ while (bx < bw) : (bx += 1) {
+ var block_y: Block8x8 = undefined;
+ var block_cb: Block8x8 = undefined;
+ var block_cr: Block8x8 = undefined;
+
+ for (0..8) |row| {
+ for (0..8) |col| {
+ const px = bx * 8 + @as(u32, @intCast(col));
+ const py = by * 8 + @as(u32, @intCast(row));
+ const pi: usize = @as(usize, @intCast(py)) * @as(usize, @intCast(image.width)) + @as(usize, @intCast(px));
+ if (pi < image.pixels.len) {
+ block_y[row][col] = image.pixels[pi].y;
+ block_cb[row][col] = image.pixels[pi].cb;
+ block_cr[row][col] = image.pixels[pi].cr;
+ } else {
+ block_y[row][col] = 128.0;
+ block_cb[row][col] = 128.0;
+ block_cr[row][col] = 128.0;
+ }
+ }
+ }
+
+ y_blocks[idx] = block_y;
+ cb_blocks[idx] = block_cb;
+ cr_blocks[idx] = block_cr;
+ idx += 1;
+ }
+ }
+
+ return .{
+ .y_blocks = y_blocks,
+ .cb_blocks = cb_blocks,
+ .cr_blocks = cr_blocks,
+ };
+}
+
+test "splitIntoBlocks 8x8 image" {
+ const allocator = std.testing.allocator;
+ var pixels: [64]types.YCbCrPixel = undefined;
+ for (0..64) |i| {
+ pixels[i] = .{ .y = @floatFromInt(i), .cb = 128.0, .cr = 128.0 };
+ }
+ var image = types.YCbCrImage{
+ .width = 8,
+ .height = 8,
+ .pixels = &pixels,
+ };
+ var blocks = try splitIntoBlocks(&image, allocator);
+ defer blocks.deinit(allocator);
+
+ try std.testing.expectApproxEqAbs(@as(f64, 0.0), blocks.y_blocks[0][0][0], 0.001);
+}
+
+test "splitIntoBlocks 16x16 image produces 4 blocks" {
+ const allocator = std.testing.allocator;
+ var pixels: [256]types.YCbCrPixel = undefined;
+ for (0..256) |i| {
+ pixels[i] = .{ .y = @floatFromInt(i % 256), .cb = 128.0, .cr = 128.0 };
+ }
+ var image = types.YCbCrImage{
+ .width = 16,
+ .height = 16,
+ .pixels = &pixels,
+ };
+ var blocks = try splitIntoBlocks(&image, allocator);
+ defer blocks.deinit(allocator);
+
+ try std.testing.expectEqual(@as(usize, 4), blocks.y_blocks.len);
+}
diff --git a/src/core/color-space.ts b/src/core/color-space.ts
deleted file mode 100644
index 439792f..0000000
--- a/src/core/color-space.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { YCbCrPixel } from '../types.js';
-
-export function rgbToYCbCr(r: number, g: number, b: number): YCbCrPixel {
- const y = 0.299 * r + 0.587 * g + 0.114 * b;
- const cb = -0.169 * r - 0.331 * g + 0.499 * b + 128;
- const cr = 0.499 * r - 0.419 * g - 0.0813 * b + 128;
-
- return [y, cb, cr];
-}
diff --git a/src/core/color_space.zig b/src/core/color_space.zig
new file mode 100644
index 0000000..93c7b04
--- /dev/null
+++ b/src/core/color_space.zig
@@ -0,0 +1,64 @@
+const std = @import("std");
+const types = @import("../types.zig");
+
+const RGBAPixel = types.RGBAPixel;
+const YCbCrPixel = types.YCbCrPixel;
+const YCbCrImage = types.YCbCrImage;
+
+pub fn rgbToYCbCr(r: u8, g: u8, b: u8) YCbCrPixel {
+ const rf: f64 = @floatFromInt(r);
+ const gf: f64 = @floatFromInt(g);
+ const bf: f64 = @floatFromInt(b);
+
+ return .{
+ .y = 0.299 * rf + 0.587 * gf + 0.114 * bf,
+ .cb = -0.168736 * rf - 0.331264 * gf + 0.5 * bf + 128.0,
+ .cr = 0.5 * rf - 0.418688 * gf - 0.081312 * bf + 128.0,
+ };
+}
+
+pub fn convertImageToYCbCr(rgba_pixels: []const RGBAPixel, width: u32, height: u32, allocator: std.mem.Allocator) !YCbCrImage {
+ const total: usize = @as(usize, @intCast(width)) * @as(usize, @intCast(height));
+ const pixels = try allocator.alloc(YCbCrPixel, total);
+
+ for (0..total) |i| {
+ const p = rgba_pixels[i];
+ pixels[i] = rgbToYCbCr(p.r, p.g, p.b);
+ }
+
+ return .{
+ .width = width,
+ .height = height,
+ .pixels = pixels,
+ };
+}
+
+test "rgbToYCbCr black" {
+ const result = rgbToYCbCr(0, 0, 0);
+ try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.y, 0.01);
+ try std.testing.expectApproxEqAbs(@as(f64, 128.0), result.cb, 0.01);
+ try std.testing.expectApproxEqAbs(@as(f64, 128.0), result.cr, 0.01);
+}
+
+test "rgbToYCbCr white" {
+ const result = rgbToYCbCr(255, 255, 255);
+ try std.testing.expectApproxEqAbs(@as(f64, 255.0), result.y, 1.0);
+ try std.testing.expectApproxEqAbs(@as(f64, 128.0), result.cb, 1.0);
+ try std.testing.expectApproxEqAbs(@as(f64, 128.0), result.cr, 1.0);
+}
+
+test "convertImageToYCbCr basic" {
+ const allocator = std.testing.allocator;
+ var pixels = [_]RGBAPixel{
+ .{ .r = 255, .g = 0, .b = 0 },
+ .{ .r = 0, .g = 255, .b = 0 },
+ .{ .r = 0, .g = 0, .b = 255 },
+ .{ .r = 128, .g = 128, .b = 128 },
+ };
+ var img = try convertImageToYCbCr(&pixels, 2, 2, allocator);
+ defer img.deinit(allocator);
+
+ try std.testing.expectEqual(@as(u32, 2), img.width);
+ try std.testing.expectEqual(@as(u32, 2), img.height);
+ try std.testing.expect(img.pixels[0].y > 0);
+}
diff --git a/src/core/dct.ts b/src/core/dct.ts
deleted file mode 100644
index 8d02669..0000000
--- a/src/core/dct.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { Block8x8 } from '../types.js';
-
-const DCT_CACHE = new Map();
-
-function getCachedCos(x: number, u: number, N: number): number {
- const key = `${x}_${u}_${N}`;
- if (DCT_CACHE.has(key)) {
- return DCT_CACHE.get(key)!;
- }
- const value = Math.cos(((2 * x + 1) * u * Math.PI) / (2 * N));
- DCT_CACHE.set(key, value);
- return value;
-}
-
-export function dct2D(input: Block8x8): Block8x8 {
- if (!input || input.length === 0) {
- throw new Error('Invalid input block');
- }
-
- const N = 8;
- const result: Block8x8 = Array.from({ length: N }, () => Array(N).fill(0));
-
- for (let u = 0; u < N; u++) {
- for (let v = 0; v < N; v++) {
- let sum = 0;
-
- for (let x = 0; x < N; x++) {
- for (let y = 0; y < N; y++) {
- if (!input[x] || input[x][y] === undefined) {
- continue;
- }
- sum += (input[x][y] - 128) * getCachedCos(x, u, N) * getCachedCos(y, v, N);
- }
- }
-
- const cu = u === 0 ? 1 / Math.sqrt(2) : 1;
- const cv = v === 0 ? 1 / Math.sqrt(2) : 1;
- result[u][v] = 0.25 * cu * cv * sum;
- }
- }
-
- return result;
-}
-
-export function idct2D(input: Block8x8): Block8x8 {
- if (!input || input.length === 0) {
- throw new Error('Invalid input block');
- }
-
- const N = 8;
- const result: Block8x8 = Array.from({ length: N }, () => Array(N).fill(0));
-
- for (let x = 0; x < N; x++) {
- for (let y = 0; y < N; y++) {
- let sum = 0;
-
- for (let u = 0; u < N; u++) {
- for (let v = 0; v < N; v++) {
- const cu = u === 0 ? 1 / Math.sqrt(2) : 1;
- const cv = v === 0 ? 1 / Math.sqrt(2) : 1;
- sum += cu * cv * input[u][v] * getCachedCos(x, u, N) * getCachedCos(y, v, N);
- }
- }
-
- result[x][y] = Math.round(0.25 * sum + 128);
- }
- }
-
- return result;
-}
-
-export function fastDCT(input: Block8x8): Block8x8 {
- const N = 8;
- const result: Block8x8 = Array.from({ length: N }, () => Array(N).fill(0));
-
- for (let i = 0; i < N; i++) {
- for (let j = 0; j < N; j++) {
- result[i][j] = (input[i][j] - 128) * (i === 0 && j === 0 ? 0.125 : 0.25);
- }
- }
-
- return result;
-}
diff --git a/src/core/dct.zig b/src/core/dct.zig
new file mode 100644
index 0000000..0d77e64
--- /dev/null
+++ b/src/core/dct.zig
@@ -0,0 +1,202 @@
+const std = @import("std");
+const testing = std.testing;
+const types = @import("../types.zig");
+
+const Block8x8 = types.Block8x8;
+const SQRT2_INV: f64 = 0.7071067811865476;
+
+const COS_TABLE: [8][8]f64 = blk: {
+ var t: [8][8]f64 = undefined;
+ var x: usize = 0;
+ while (x < 8) : (x += 1) {
+ var u: usize = 0;
+ while (u < 8) : (u += 1) {
+ const angle = (@as(f64, @floatFromInt(2 * x + 1)) *
+ @as(f64, @floatFromInt(u)) * std.math.pi) /
+ 16.0;
+ t[x][u] = @cos(angle);
+ }
+ }
+ break :blk t;
+};
+
+pub fn dct2D(input: *const Block8x8) Block8x8 {
+ var result: Block8x8 = undefined;
+ var u: u8 = 0;
+ while (u < 8) : (u += 1) {
+ const cu: f64 = if (u == 0) SQRT2_INV else 1.0;
+ var v: u8 = 0;
+ while (v < 8) : (v += 1) {
+ const cv: f64 = if (v == 0) SQRT2_INV else 1.0;
+ var sum: f64 = 0.0;
+ var x: u8 = 0;
+ while (x < 8) : (x += 1) {
+ const cos_u = COS_TABLE[x][u];
+ var y: u8 = 0;
+ while (y < 8) : (y += 1) {
+ sum += (input[x][y] - 128.0) * cos_u * COS_TABLE[y][v];
+ }
+ }
+ result[u][v] = sum * cu * cv * 0.25;
+ }
+ }
+ return result;
+}
+
+pub fn idct2D(input: *const Block8x8) Block8x8 {
+ var result: Block8x8 = undefined;
+ var x: u8 = 0;
+ while (x < 8) : (x += 1) {
+ var y: u8 = 0;
+ while (y < 8) : (y += 1) {
+ var sum: f64 = 0.0;
+ var u: u8 = 0;
+ while (u < 8) : (u += 1) {
+ const cu: f64 = if (u == 0) SQRT2_INV else 1.0;
+ const cos_u = COS_TABLE[x][u];
+ var v: u8 = 0;
+ while (v < 8) : (v += 1) {
+ const cv: f64 = if (v == 0) SQRT2_INV else 1.0;
+ sum += input[u][v] * cu * cv * cos_u * COS_TABLE[y][v];
+ }
+ }
+ result[x][y] = sum * 0.25 + 128.0;
+ }
+ }
+ return result;
+}
+
+pub fn fastDCT(input: *const Block8x8) Block8x8 {
+ var temp: [8][8]f64 = undefined;
+ var result: Block8x8 = undefined;
+
+ var x: u8 = 0;
+ while (x < 8) : (x += 1) {
+ var v: u8 = 0;
+ while (v < 8) : (v += 1) {
+ var sum: f64 = 0.0;
+ var y: u8 = 0;
+ while (y < 8) : (y += 1) {
+ sum += (input[x][y] - 128.0) * COS_TABLE[y][v];
+ }
+ temp[x][v] = sum;
+ }
+ }
+
+ var u: u8 = 0;
+ while (u < 8) : (u += 1) {
+ const cu: f64 = if (u == 0) SQRT2_INV else 1.0;
+ var v: u8 = 0;
+ while (v < 8) : (v += 1) {
+ const cv: f64 = if (v == 0) SQRT2_INV else 1.0;
+ var sum: f64 = 0.0;
+ var x2: u8 = 0;
+ while (x2 < 8) : (x2 += 1) {
+ sum += temp[x2][v] * COS_TABLE[x2][u];
+ }
+ result[u][v] = sum * cu * cv * 0.25;
+ }
+ }
+
+ return result;
+}
+
+test "dct2D uniform block produces near-zero" {
+ var block: Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = 128.0;
+ }
+ }
+ const out = dct2D(&block);
+ try std.testing.expectApproxEqAbs(@as(f64, 0.0), out[0][0], 1.0);
+ for (0..8) |i| {
+ for (1..8) |j| {
+ try std.testing.expectApproxEqAbs(@as(f64, 0.0), out[i][j], 0.01);
+ }
+ }
+}
+
+test "dct2D then idct2D round-trip" {
+ var block: Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(i * 32 + j * 8 + 16);
+ }
+ }
+ const dct_out = dct2D(&block);
+ const idct_out = idct2D(&dct_out);
+ for (0..8) |i| {
+ for (0..8) |j| {
+ try std.testing.expectApproxEqAbs(block[i][j], idct_out[i][j], 5.0);
+ }
+ }
+}
+
+test "fastDCT produces valid output" {
+ var block: Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(i * 32 + j * 16 + 42);
+ }
+ }
+ const fast_out = fastDCT(&block);
+ const std_out = dct2D(&block);
+ try testing.expect(fast_out[0][0] != 0.0);
+ var max_err: f64 = 0.0;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ const err = @abs(fast_out[i][j] - std_out[i][j]);
+ if (err > max_err) max_err = err;
+ }
+ }
+ try testing.expect(max_err < 0.01);
+}
+
+test "DCT of constant block has non-zero DC only" {
+ var block: Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = 200.0;
+ }
+ }
+ const out = dct2D(&block);
+ try std.testing.expect(out[0][0] > 0.0);
+ for (0..8) |i| {
+ for (0..8) |j| {
+ if (i == 0 and j == 0) continue;
+ try std.testing.expectApproxEqAbs(@as(f64, 0.0), out[i][j], 0.01);
+ }
+ }
+}
+
+test "IDCT of single DC coefficient produces flat output" {
+ var block: Block8x8 = @splat(@splat(0.0));
+ block[0][0] = 100.0;
+ const out = idct2D(&block);
+ const expected = 100.0 * 0.5 * 0.25 + 128.0;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ try std.testing.expectApproxEqAbs(expected, out[i][j], 0.01);
+ }
+ }
+}
+
+test "DCT preserves energy" {
+ var block: Block8x8 = undefined;
+ var energy_in: f64 = 0.0;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(@as(i32, @intCast(i)) * @as(i32, @intCast(j)) + 1);
+ energy_in += (block[i][j] - 128.0) * (block[i][j] - 128.0);
+ }
+ }
+ const dct_out = dct2D(&block);
+ var energy_out: f64 = 0.0;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ energy_out += dct_out[i][j] * dct_out[i][j];
+ }
+ }
+ try std.testing.expectApproxEqAbs(energy_in, energy_out, energy_in * 0.001);
+}
diff --git a/src/core/quantization.ts b/src/core/quantization.ts
deleted file mode 100644
index 6fb6592..0000000
--- a/src/core/quantization.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { Block8x8, QuantizationMatrix } from '../types.js';
-
-const STANDARD_LUMINANCE_QUANT: QuantizationMatrix = [
- [16, 11, 10, 16, 24, 40, 51, 61],
- [12, 12, 14, 19, 26, 58, 60, 55],
- [14, 13, 16, 24, 40, 57, 69, 56],
- [14, 17, 22, 29, 51, 87, 80, 62],
- [18, 22, 37, 56, 68, 109, 103, 77],
- [24, 35, 55, 64, 81, 104, 113, 92],
- [49, 64, 78, 87, 103, 121, 120, 101],
- [72, 92, 95, 98, 112, 100, 103, 99]
-];
-
-const STANDARD_CHROMINANCE_QUANT: QuantizationMatrix = [
- [17, 18, 24, 47, 99, 99, 99, 99],
- [18, 21, 26, 66, 99, 99, 99, 99],
- [24, 26, 56, 99, 99, 99, 99, 99],
- [47, 66, 99, 99, 99, 99, 99, 99],
- [99, 99, 99, 99, 99, 99, 99, 99],
- [99, 99, 99, 99, 99, 99, 99, 99],
- [99, 99, 99, 99, 99, 99, 99, 99],
- [99, 99, 99, 99, 99, 99, 99, 99]
-];
-
-export function getQuantizationMatrix(quality: number, isLuminance: boolean = true): QuantizationMatrix {
- const baseMatrix = isLuminance ? STANDARD_LUMINANCE_QUANT : STANDARD_CHROMINANCE_QUANT;
- const scale = quality < 50 ? 5000 / quality : 200 - quality * 2;
-
- return baseMatrix.map(row =>
- row.map(val => Math.max(1, Math.min(255, Math.floor((val * scale + 50) / 100))))
- );
-}
-
-export function quantizeBlock(dctBlock: Block8x8, quality: number = 50, isLuminance: boolean = true): Block8x8 {
- if (!dctBlock || dctBlock.length !== 8) {
- throw new Error('Invalid DCT block');
- }
-
- const quantMatrix = getQuantizationMatrix(quality, isLuminance);
- const quantized: Block8x8 = Array.from({ length: 8 }, () => Array(8).fill(0));
-
- for (let i = 0; i < 8; i++) {
- if (!dctBlock[i] || dctBlock[i].length !== 8) {
- throw new Error(`Invalid DCT block row at index ${i}`);
- }
- for (let j = 0; j < 8; j++) {
- quantized[i][j] = Math.round(dctBlock[i][j] / quantMatrix[i][j]);
- }
- }
-
- return quantized;
-}
-
-export function dequantizeBlock(quantBlock: Block8x8, quality: number = 50, isLuminance: boolean = true): Block8x8 {
- const quantMatrix = getQuantizationMatrix(quality, isLuminance);
- const dequantized: Block8x8 = Array.from({ length: 8 }, () => Array(8).fill(0));
-
- for (let i = 0; i < 8; i++) {
- for (let j = 0; j < 8; j++) {
- dequantized[i][j] = quantBlock[i][j] * quantMatrix[i][j];
- }
- }
-
- return dequantized;
-}
diff --git a/src/core/quantization.zig b/src/core/quantization.zig
new file mode 100644
index 0000000..b8d5c4a
--- /dev/null
+++ b/src/core/quantization.zig
@@ -0,0 +1,122 @@
+const std = @import("std");
+const types = @import("../types.zig");
+
+const Block8x8 = types.Block8x8;
+
+pub const STANDARD_LUMINANCE_QUANT: [8][8]u8 = .{
+ .{ 16, 11, 10, 16, 24, 40, 51, 61 },
+ .{ 12, 12, 14, 19, 26, 58, 60, 55 },
+ .{ 14, 13, 16, 24, 40, 57, 69, 56 },
+ .{ 14, 17, 22, 29, 51, 87, 80, 62 },
+ .{ 18, 22, 37, 56, 68, 109, 103, 77 },
+ .{ 24, 35, 55, 64, 81, 104, 113, 92 },
+ .{ 49, 64, 78, 87, 103, 121, 120, 101 },
+ .{ 72, 92, 95, 98, 112, 100, 103, 99 },
+};
+
+pub const STANDARD_CHROMINANCE_QUANT: [8][8]u8 = .{
+ .{ 17, 18, 24, 47, 99, 99, 99, 99 },
+ .{ 18, 21, 26, 66, 99, 99, 99, 99 },
+ .{ 24, 26, 56, 99, 99, 99, 99, 99 },
+ .{ 47, 66, 99, 99, 99, 99, 99, 99 },
+ .{ 99, 99, 99, 99, 99, 99, 99, 99 },
+ .{ 99, 99, 99, 99, 99, 99, 99, 99 },
+ .{ 99, 99, 99, 99, 99, 99, 99, 99 },
+ .{ 99, 99, 99, 99, 99, 99, 99, 99 },
+};
+
+pub fn getQuantizationMatrix(quality: u8, is_luminance: bool) [8][8]u32 {
+ const q = @max(@min(quality, 100), 1);
+ const scale: i32 = if (q < 50) blk: {
+ break :blk @divTrunc(5000, @as(i32, q));
+ } else blk: {
+ break :blk 200 - @as(i32, q) * 2;
+ };
+
+ var matrix: [8][8]u32 = undefined;
+ const base = if (is_luminance) STANDARD_LUMINANCE_QUANT else STANDARD_CHROMINANCE_QUANT;
+
+ for (0..8) |i| {
+ for (0..8) |j| {
+ const val: i32 = @divFloor(@as(i32, base[i][j]) * scale + 50, 100);
+ matrix[i][j] = @intCast(@max(@min(val, 255), 1));
+ }
+ }
+
+ return matrix;
+}
+
+pub fn quantizeBlock(dct_block: *const Block8x8, quality: u8, is_luminance: bool) Block8x8 {
+ const matrix = getQuantizationMatrix(quality, is_luminance);
+ return quantizeBlockWithMatrix(dct_block, &matrix);
+}
+
+pub fn quantizeBlockWithMatrix(dct_block: *const Block8x8, matrix: *const [8][8]u32) Block8x8 {
+ var result: Block8x8 = undefined;
+
+ for (0..8) |i| {
+ for (0..8) |j| {
+ result[i][j] = @round(dct_block[i][j] / @as(f64, @floatFromInt(matrix[i][j])));
+ }
+ }
+
+ return result;
+}
+
+pub fn dequantizeBlock(quant_block: *const Block8x8, quality: u8, is_luminance: bool) Block8x8 {
+ const matrix = getQuantizationMatrix(quality, is_luminance);
+ var result: Block8x8 = undefined;
+
+ for (0..8) |i| {
+ for (0..8) |j| {
+ result[i][j] = quant_block[i][j] * @as(f64, @floatFromInt(matrix[i][j]));
+ }
+ }
+
+ return result;
+}
+
+test "quantize produces valid 8x8 output" {
+ var block: Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(i * 32 + j * 16);
+ }
+ }
+ const q = quantizeBlock(&block, 50, true);
+ for (0..8) |i| {
+ for (0..8) |j| {
+ _ = q[i][j];
+ }
+ }
+ try std.testing.expect(true);
+}
+
+test "dequantize round-trip" {
+ var block: Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(100 + i * 10 + j * 5);
+ }
+ }
+ const q = quantizeBlock(&block, 75, true);
+ const dq = dequantizeBlock(&q, 75, true);
+ for (0..8) |i| {
+ for (0..8) |j| {
+ const tol = @as(f64, @floatFromInt(100 + i * 10 + j * 5)) * 0.15;
+ try std.testing.expectApproxEqAbs(block[i][j], dq[i][j], tol);
+ }
+ }
+}
+
+test "higher quality uses smaller quantization values" {
+ const low = getQuantizationMatrix(10, true);
+ const high = getQuantizationMatrix(90, true);
+ try std.testing.expect(low[0][0] > high[0][0]);
+}
+
+test "chrominance differs from luminance" {
+ const lum = getQuantizationMatrix(50, true);
+ const chr = getQuantizationMatrix(50, false);
+ try std.testing.expect(lum[0][0] != chr[0][0]);
+}
diff --git a/src/core/zigzag.ts b/src/core/zigzag.ts
deleted file mode 100644
index f3b75d3..0000000
--- a/src/core/zigzag.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { Block8x8 } from '../types.js';
-
-const ZIGZAG_ORDER = [
- 0, 1, 8, 16, 9, 2, 3, 10,
- 17, 24, 32, 25, 18, 11, 4, 5,
- 12, 19, 26, 33, 40, 48, 41, 34,
- 27, 20, 13, 6, 7, 14, 21, 28,
- 35, 42, 49, 56, 57, 50, 43, 36,
- 29, 22, 15, 23, 30, 37, 44, 51,
- 58, 59, 52, 45, 38, 31, 39, 46,
- 53, 60, 61, 54, 47, 55, 62, 63
-];
-
-export function zigzagEncode(block: Block8x8): number[] {
- const result: number[] = new Array(64);
-
- for (let i = 0; i < 64; i++) {
- const pos = ZIGZAG_ORDER[i];
- const row = Math.floor(pos / 8);
- const col = pos % 8;
- result[i] = block[row][col];
- }
-
- return result;
-}
-
-export function zigzagDecode(array: number[]): Block8x8 {
- const block: Block8x8 = Array.from({ length: 8 }, () => Array(8).fill(0));
-
- for (let i = 0; i < 64; i++) {
- const pos = ZIGZAG_ORDER[i];
- const row = Math.floor(pos / 8);
- const col = pos % 8;
- block[row][col] = array[i];
- }
-
- return block;
-}
-
-export function runLengthEncode(data: number[]): Array<[number, number]> {
- const result: Array<[number, number]> = [];
- let zeroCount = 0;
-
- for (let i = 0; i < data.length; i++) {
- if (data[i] === 0) {
- zeroCount++;
- } else {
- while (zeroCount > 15) {
- result.push([15, 0]);
- zeroCount -= 16;
- }
- result.push([zeroCount, data[i]]);
- zeroCount = 0;
- }
- }
-
- if (zeroCount > 0) {
- result.push([0, 0]);
- }
-
- return result;
-}
diff --git a/src/core/zigzag.zig b/src/core/zigzag.zig
new file mode 100644
index 0000000..86de801
--- /dev/null
+++ b/src/core/zigzag.zig
@@ -0,0 +1,122 @@
+const std = @import("std");
+const types = @import("../types.zig");
+
+const Block8x8 = types.Block8x8;
+
+pub const ZIGZAG_ORDER: [64]u8 = .{
+ 0, 1, 8, 16, 9, 2, 3, 10,
+ 17, 24, 32, 25, 18, 11, 4, 5,
+ 12, 19, 26, 33, 40, 48, 41, 34,
+ 27, 20, 13, 6, 7, 14, 21, 28,
+ 35, 42, 49, 56, 57, 50, 43, 36,
+ 29, 22, 15, 23, 30, 37, 44, 51,
+ 58, 59, 52, 45, 38, 31, 39, 46,
+ 53, 60, 61, 54, 47, 55, 62, 63,
+};
+
+pub const RLEPair = struct {
+ run: u4,
+ value: i16,
+};
+
+pub fn zigzagEncode(block: *const Block8x8) [64]f64 {
+ var result: [64]f64 = undefined;
+ for (0..64) |i| {
+ const pos = ZIGZAG_ORDER[i];
+ const row = pos / 8;
+ const col = pos % 8;
+ result[i] = block[row][col];
+ }
+ return result;
+}
+
+pub fn zigzagDecode(array: *const [64]f64) Block8x8 {
+ var result: Block8x8 = undefined;
+ for (0..64) |i| {
+ const pos = ZIGZAG_ORDER[i];
+ const row = pos / 8;
+ const col = pos % 8;
+ result[row][col] = array[i];
+ }
+ return result;
+}
+
+pub fn runLengthEncode(data: *const [64]f64, output: []RLEPair) usize {
+ var count: usize = 0;
+ var i: usize = 1;
+
+ while (i < 64) {
+ if (data[i] != 0.0) {
+ output[count] = .{ .run = 0, .value = @intFromFloat(data[i]) };
+ count += 1;
+ i += 1;
+ } else {
+ var zeros: u4 = 0;
+ while (i < 64 and data[i] == 0.0 and zeros < 15) : (i += 1) {
+ zeros += 1;
+ }
+ if (i >= 64) {
+ output[count] = .{ .run = 0, .value = 0 };
+ count += 1;
+ break;
+ }
+ output[count] = .{ .run = zeros, .value = @intFromFloat(data[i]) };
+ count += 1;
+ i += 1;
+ }
+ }
+
+ return count;
+}
+
+test "zigzag encode produces 64 elements" {
+ var block: Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(i * 8 + j);
+ }
+ }
+ const encoded = zigzagEncode(&block);
+ try std.testing.expectEqual(@as(usize, 64), encoded.len);
+ try std.testing.expectApproxEqAbs(@as(f64, 0.0), encoded[0], 0.001);
+ try std.testing.expectApproxEqAbs(@as(f64, 1.0), encoded[1], 0.001);
+}
+
+test "zigzag round-trip" {
+ var block: Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(i * 8 + j);
+ }
+ }
+ const encoded = zigzagEncode(&block);
+ const decoded = zigzagDecode(&encoded);
+ for (0..8) |i| {
+ for (0..8) |j| {
+ try std.testing.expectApproxEqAbs(block[i][j], decoded[i][j], 0.001);
+ }
+ }
+}
+
+test "run-length encoding" {
+ var data: [64]f64 = undefined;
+ data[0] = 100.0;
+ for (1..64) |i| {
+ data[i] = 0.0;
+ }
+ data[3] = 5.0;
+
+ var output: [64]RLEPair = undefined;
+ const count = runLengthEncode(&data, &output);
+ try std.testing.expect(count > 0);
+ try std.testing.expect(count < 64);
+}
+
+test "all zeros RLE" {
+ var data: [64]f64 = @splat(0.0);
+ var output: [64]RLEPair = undefined;
+ const count = runLengthEncode(&data, &output);
+ try std.testing.expect(count > 0);
+ try std.testing.expectEqual(@as(u4, 0), output[count - 1].run);
+ try std.testing.expectEqual(@as(i16, 0), output[count - 1].value);
+}
diff --git a/src/decoder.ts b/src/decoder.ts
deleted file mode 100644
index 4f4bb89..0000000
--- a/src/decoder.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { DecodeOptions, ImageData } from './types.js';
-import { idct2D } from './core/dct.js';
-import { dequantizeBlock } from './core/quantization.js';
-import { zigzagDecode } from './core/zigzag.js';
-
-export class JPEGDecoder {
- private data: Uint8Array;
- private position: number = 0;
-
- constructor(buffer: Uint8Array) {
- this.data = buffer;
- }
-
- readByte(): number {
- return this.data[this.position++];
- }
-
- readWord(): number {
- const high = this.readByte();
- const low = this.readByte();
- return (high << 8) | low;
- }
-
- readMarker(): number {
- if (this.readByte() !== 0xFF) {
- throw new Error('Invalid JPEG marker');
- }
- return this.readByte();
- }
-
- skipSegment(): void {
- const length = this.readWord();
- this.position += length - 2;
- }
-
- parseSOF(): { width: number; height: number } {
- const length = this.readWord();
- const precision = this.readByte();
- const height = this.readWord();
- const width = this.readWord();
- const components = this.readByte();
-
- this.position += length - 8;
-
- return { width, height };
- }
-
- async decode(): Promise {
- if (this.readMarker() !== 0xD8) {
- throw new Error('Not a valid JPEG file');
- }
-
- let width = 0;
- let height = 0;
- let quality = 75;
-
- while (this.position < this.data.length) {
- const marker = this.readMarker();
-
- switch (marker) {
- case 0xC0:
- const sof = this.parseSOF();
- width = sof.width;
- height = sof.height;
- break;
- case 0xD9:
- return this.reconstructImage(width, height, quality);
- default:
- this.skipSegment();
- }
- }
-
- throw new Error('Incomplete JPEG file');
- }
-
- private reconstructImage(width: number, height: number, quality: number): ImageData {
- const data = new Uint8ClampedArray(width * height * 4);
-
- for (let i = 0; i < data.length; i += 4) {
- data[i] = 128;
- data[i + 1] = 128;
- data[i + 2] = 128;
- data[i + 3] = 255;
- }
-
- return { data, width, height };
- }
-}
-
-export async function decodeJPEG(
- buffer: Uint8Array,
- options: DecodeOptions = {}
-): Promise {
- const decoder = new JPEGDecoder(buffer);
- return decoder.decode();
-}
-
-export async function decodeJPEGFromFile(
- filePath: string,
- options: DecodeOptions = {}
-): Promise {
- const fs = await import('fs/promises');
- const buffer = await fs.readFile(filePath);
- return decodeJPEG(new Uint8Array(buffer), options);
-}
diff --git a/src/decoder.zig b/src/decoder.zig
new file mode 100644
index 0000000..d34f4fa
--- /dev/null
+++ b/src/decoder.zig
@@ -0,0 +1,747 @@
+const std = @import("std");
+const types = @import("types.zig");
+const dct = @import("core/dct.zig");
+const zigzag_mod = @import("core/zigzag.zig");
+
+const Block8x8 = types.Block8x8;
+
+pub const JPEGError = error{
+ InvalidSOI,
+ InvalidMarker,
+ TruncatedData,
+ InvalidHuffmanTable,
+ InvalidScanData,
+ UnsupportedFeatures,
+};
+
+const ComponentInfo = struct {
+ id: u8 = 0,
+ h_sampling: u8 = 1,
+ v_sampling: u8 = 1,
+ dc_table_id: u8 = 0,
+ ac_table_id: u8 = 0,
+ quant_table_id: u8 = 0,
+};
+
+const HuffmanDecodeTable = struct {
+ min_code: [17]u16 = [_]u16{0} ** 17,
+ max_code: [17]u16 = [_]u16{0} ** 17,
+ val_offset: [17]i32 = [_]i32{0} ** 17,
+ symbols: [256]u8 = [_]u8{0} ** 256,
+ num_symbols: u16 = 0,
+ num_codes: [17]u16 = [_]u16{0} ** 17,
+};
+
+const BitReader = struct {
+ data: []const u8,
+ byte_pos: usize,
+ bit_buf: u32,
+ bits_in_buf: u8,
+
+ fn init(data: []const u8) BitReader {
+ return .{
+ .data = data,
+ .byte_pos = 0,
+ .bit_buf = 0,
+ .bits_in_buf = 0,
+ };
+ }
+
+ fn fillBuffer(self: *BitReader) void {
+ while (self.bits_in_buf <= 24 and self.byte_pos < self.data.len) {
+ const byte = self.data[self.byte_pos];
+ self.byte_pos += 1;
+ if (byte == 0xFF) {
+ if (self.byte_pos < self.data.len) {
+ const next = self.data[self.byte_pos];
+ if (next == 0x00) {
+ self.byte_pos += 1;
+ } else {
+ break;
+ }
+ }
+ }
+ self.bit_buf = (self.bit_buf << 8) | @as(u32, byte);
+ self.bits_in_buf += 8;
+ }
+ }
+
+ fn readBits(self: *BitReader, count: u8) !u32 {
+ if (count == 0) return 0;
+ if (self.bits_in_buf < count) {
+ self.fillBuffer();
+ if (self.bits_in_buf < count) return error.TruncatedData;
+ }
+ const shift: u5 = @intCast(self.bits_in_buf - count);
+ const val = (self.bit_buf >> shift) & ((@as(u32, 1) << @as(u5, @intCast(count))) - 1);
+ self.bits_in_buf -= count;
+ return val;
+ }
+
+ fn peekBits(self: *BitReader, count: u8) !u32 {
+ if (self.bits_in_buf < count) {
+ self.fillBuffer();
+ if (self.bits_in_buf < count) return error.TruncatedData;
+ }
+ const shift: u5 = @intCast(self.bits_in_buf - count);
+ return (self.bit_buf >> shift) & ((@as(u32, 1) << @as(u5, @intCast(count))) - 1);
+ }
+
+ fn skipBits(self: *BitReader, count: u8) void {
+ self.bits_in_buf -|= count;
+ }
+
+ fn skipRestartMarker(self: *BitReader) bool {
+ self.bits_in_buf = 0;
+ self.bit_buf = 0;
+ if (self.byte_pos < self.data.len) {
+ const marker = self.data[self.byte_pos];
+ if (marker >= 0xD0 and marker <= 0xD7) {
+ self.byte_pos += 1;
+ return true;
+ }
+ }
+ return false;
+ }
+};
+
+fn buildDecodeTable(bits: *const [16]u8, values: []const u8) HuffmanDecodeTable {
+ var table: HuffmanDecodeTable = .{};
+ table.num_symbols = @intCast(values.len);
+
+ var code: u16 = 0;
+ var val_idx: u16 = 0;
+
+ for (0..16) |i| {
+ const count = bits[i];
+ if (count > 0) {
+ table.num_codes[i + 1] = count;
+ table.min_code[i + 1] = code;
+ table.val_offset[i + 1] = @as(i32, @intCast(val_idx)) - @as(i32, @intCast(code));
+ }
+ for (0..count) |_| {
+ if (val_idx < values.len) {
+ table.symbols[val_idx] = values[val_idx];
+ val_idx += 1;
+ }
+ code += 1;
+ }
+ if (count > 0) {
+ table.max_code[i + 1] = code - 1;
+ }
+ if (i < 15) code <<= 1;
+ }
+
+ return table;
+}
+
+fn decodeSymbol(br: *BitReader, table: *const HuffmanDecodeTable) !u8 {
+ var code: u32 = 0;
+ for (1..17) |len| {
+ code = (code << 1) | try br.peekBits(1);
+ br.skipBits(1);
+
+ if (table.num_codes[len] > 0) {
+ if (code >= table.min_code[len] and code <= table.max_code[len]) {
+ const raw_idx = table.val_offset[len] + @as(i32, @intCast(code));
+ if (raw_idx >= 0 and raw_idx < 256) return table.symbols[@intCast(raw_idx)];
+ }
+ }
+ }
+ return error.InvalidHuffmanTable;
+}
+
+pub const JPEGDecoder = struct {
+ data: []const u8,
+ width: u32 = 0,
+ height: u32 = 0,
+ components: [3]ComponentInfo = [_]ComponentInfo{ComponentInfo{}} ** 3,
+ num_components: u8 = 0,
+ dc_tables: [2]HuffmanDecodeTable = [_]HuffmanDecodeTable{.{}} ** 2,
+ ac_tables: [2]HuffmanDecodeTable = [_]HuffmanDecodeTable{.{}} ** 2,
+ quant_tables: [4][64]u16 = [_][64]u16{[_]u16{0} ** 64} ** 4,
+ prev_dc: [3]i16 = [_]i16{ 0, 0, 0 },
+ restart_interval: u16 = 0,
+
+ pub fn init(data: []const u8) JPEGDecoder {
+ return .{ .data = data };
+ }
+
+ pub fn readByte(self: *JPEGDecoder, pos: *usize) !u8 {
+ if (pos.* >= self.data.len) return error.TruncatedData;
+ const b = self.data[pos.*];
+ pos.* += 1;
+ return b;
+ }
+
+ pub fn readWord(self: *JPEGDecoder, pos: *usize) !u16 {
+ const hi = try self.readByte(pos);
+ const lo = try self.readByte(pos);
+ return (@as(u16, hi) << 8) | lo;
+ }
+
+ fn parseDQT(self: *JPEGDecoder, pos: *usize) !void {
+ const length = try self.readWord(pos);
+ if (length < 2) return error.InvalidMarker;
+ const end_pos = pos.* + @as(usize, @intCast(length - 2));
+
+ while (pos.* + 1 < end_pos and pos.* < self.data.len) {
+ const info = try self.readByte(pos);
+ const table_id = info & 0x0F;
+ const precision = (info >> 4) & 0x0F;
+ if (precision != 0) return error.UnsupportedFeatures;
+
+ if (table_id < 4) {
+ for (0..64) |k| {
+ self.quant_tables[table_id][k] = try self.readByte(pos);
+ }
+ } else {
+ for (0..64) |_| {
+ _ = try self.readByte(pos);
+ }
+ }
+ }
+ pos.* = end_pos;
+ }
+
+ fn parseSOF0(self: *JPEGDecoder, pos: *usize) !void {
+ _ = try self.readWord(pos);
+ const precision = try self.readByte(pos);
+ if (precision != 8) return error.UnsupportedFeatures;
+
+ self.height = try self.readWord(pos);
+ self.width = try self.readWord(pos);
+ self.num_components = try self.readByte(pos);
+ if (self.num_components == 0 or self.num_components > 3) return error.UnsupportedFeatures;
+
+ for (0..self.num_components) |i| {
+ self.components[i].id = try self.readByte(pos);
+ const sampling = try self.readByte(pos);
+ self.components[i].h_sampling = (sampling >> 4) & 0x0F;
+ self.components[i].v_sampling = sampling & 0x0F;
+ self.components[i].quant_table_id = try self.readByte(pos);
+ }
+
+ var max_h: u8 = 0;
+ var max_v: u8 = 0;
+ for (0..self.num_components) |i| {
+ if (self.components[i].h_sampling > max_h) max_h = self.components[i].h_sampling;
+ if (self.components[i].v_sampling > max_v) max_v = self.components[i].v_sampling;
+ }
+ if (max_h > 2 or max_v > 2) return error.UnsupportedFeatures;
+ if (max_h == 0 or max_v == 0) return error.UnsupportedFeatures;
+ }
+
+ fn parseDHT(self: *JPEGDecoder, pos: *usize) !void {
+ const length = try self.readWord(pos);
+ if (length < 2) return error.InvalidMarker;
+ const end_pos = pos.* + @as(usize, @intCast(length - 2));
+
+ while (pos.* < end_pos) {
+ const info = try self.readByte(pos);
+ const table_class = (info >> 4) & 0x0F;
+ const table_id = info & 0x0F;
+
+ var counts: [16]u8 = undefined;
+ for (0..16) |i| {
+ counts[i] = try self.readByte(pos);
+ }
+
+ var total: u16 = 0;
+ for (counts) |c| total += c;
+ if (total > 256) return error.InvalidHuffmanTable;
+
+ var values: [256]u8 = undefined;
+ for (0..total) |i| {
+ values[i] = try self.readByte(pos);
+ }
+
+ if (total == 0) return error.InvalidHuffmanTable;
+
+ const table = buildDecodeTable(&counts, values[0..total]);
+ if (table_class == 0 and table_id < 2) {
+ self.dc_tables[table_id] = table;
+ } else if (table_class == 1 and table_id < 2) {
+ self.ac_tables[table_id] = table;
+ }
+ }
+ }
+
+ fn parseSOS(self: *JPEGDecoder, pos: *usize) !void {
+ const length = try self.readWord(pos);
+ if (length < 2) return error.InvalidMarker;
+ const end_pos = pos.* + @as(usize, @intCast(length - 2));
+
+ const num_comp = try self.readByte(pos);
+ if (num_comp != self.num_components) return error.InvalidScanData;
+ for (0..num_comp) |_| {
+ const comp_id = try self.readByte(pos);
+ const tables = try self.readByte(pos);
+ const dc_id = (tables >> 4) & 0x0F;
+ const ac_id = tables & 0x0F;
+ if (dc_id > 1 or ac_id > 1) return error.InvalidScanData;
+ var found = false;
+ for (0..self.num_components) |i| {
+ if (self.components[i].id == comp_id) {
+ self.components[i].dc_table_id = dc_id;
+ self.components[i].ac_table_id = ac_id;
+ found = true;
+ }
+ }
+ if (!found) return error.InvalidScanData;
+ }
+
+ _ = try self.readByte(pos);
+ _ = try self.readByte(pos);
+ _ = try self.readByte(pos);
+
+ pos.* = end_pos;
+ }
+
+ fn parseDRI(self: *JPEGDecoder, pos: *usize) !void {
+ const length = try self.readWord(pos);
+ if (length != 4) return error.InvalidMarker;
+ self.restart_interval = try self.readWord(pos);
+ }
+
+ fn decodeDCCoefficient(self: *JPEGDecoder, br: *BitReader, comp_idx: usize) !i16 {
+ const dc_tbl = self.components[comp_idx].dc_table_id;
+ const symbol = try decodeSymbol(br, &self.dc_tables[dc_tbl]);
+
+ if (symbol == 0) return self.prev_dc[comp_idx];
+ if (symbol > 11) return error.InvalidHuffmanTable;
+
+ const bits = try br.readBits(symbol);
+ var diff: i16 = @intCast(bits);
+ if (bits < (@as(u32, 1) << @intCast(symbol - 1))) {
+ diff = diff - @as(i16, @intCast(@as(u32, 1) << @intCast(symbol))) + 1;
+ }
+
+ const dc_val = self.prev_dc[comp_idx] +% diff;
+ self.prev_dc[comp_idx] = dc_val;
+ return dc_val;
+ }
+
+ fn decodeACCoefficients(self: *JPEGDecoder, br: *BitReader, comp_idx: usize, block: *Block8x8) !void {
+ const ac_tbl = self.components[comp_idx].ac_table_id;
+
+ var pos: usize = 1;
+ while (pos < 64) {
+ const symbol = try decodeSymbol(br, &self.ac_tables[ac_tbl]);
+
+ if (symbol == 0x00) {
+ return;
+ }
+
+ if (symbol == 0xF0) {
+ var k: usize = 0;
+ while (k < 16 and pos < 64) : (k += 1) {
+ const zig = zigzag_mod.ZIGZAG_ORDER[pos];
+ block[zig / 8][zig % 8] = 0.0;
+ pos += 1;
+ }
+ continue;
+ }
+
+ const run = (symbol >> 4) & 0x0F;
+ const category = symbol & 0x0F;
+
+ var k: usize = 0;
+ while (k < run and pos < 64) : (k += 1) {
+ const zig = zigzag_mod.ZIGZAG_ORDER[pos];
+ block[zig / 8][zig % 8] = 0.0;
+ pos += 1;
+ }
+
+ if (pos >= 64) return;
+
+ const bits = try br.readBits(category);
+ var val: i16 = @intCast(bits);
+ if (bits < (@as(u32, 1) << @intCast(category - 1))) {
+ val = val - @as(i16, @intCast(@as(u32, 1) << @intCast(category))) + 1;
+ }
+
+ const zig = zigzag_mod.ZIGZAG_ORDER[pos];
+ block[zig / 8][zig % 8] = @floatFromInt(val);
+ pos += 1;
+ }
+ }
+
+ fn decodeOneBlock(self: *JPEGDecoder, br: *BitReader, comp_idx: usize, block: *Block8x8) !void {
+ block.* = @splat(@splat(0.0));
+
+ const dc = try self.decodeDCCoefficient(br, comp_idx);
+ block[0][0] = @floatFromInt(dc);
+
+ try self.decodeACCoefficients(br, comp_idx, block);
+
+ const qt = self.components[comp_idx].quant_table_id;
+ for (0..8) |r| {
+ for (0..8) |col| {
+ block[r][col] *= @as(f64, @floatFromInt(self.quant_tables[qt][r * 8 + col]));
+ }
+ }
+
+ const pixel_block = dct.idct2D(block);
+ block.* = pixel_block;
+ }
+
+ fn decode(self: *JPEGDecoder, allocator: std.mem.Allocator) !types.ImageData {
+ if (self.data.len < 2) return error.InvalidSOI;
+ var pos: usize = 0;
+
+ var marker: u8 = 0xFF;
+ while (marker == 0xFF) {
+ marker = try self.readByte(&pos);
+ }
+ if (marker != 0xD8) return error.InvalidSOI;
+
+ var found_sos = false;
+
+ while (pos < self.data.len) {
+ var marker_byte: u8 = 0xFF;
+ while (marker_byte == 0xFF) {
+ marker_byte = try self.readByte(&pos);
+ }
+
+ switch (marker_byte) {
+ 0xD9 => break,
+ 0xC0 => try self.parseSOF0(&pos),
+ 0xC1, 0xC2, 0xC3 => return error.UnsupportedFeatures,
+ 0xC4 => try self.parseDHT(&pos),
+ 0xC8 => return error.UnsupportedFeatures,
+ 0xCC => return error.UnsupportedFeatures,
+ 0xDB => try self.parseDQT(&pos),
+ 0xDD => try self.parseDRI(&pos),
+ 0xDA => {
+ try self.parseSOS(&pos);
+ found_sos = true;
+ break;
+ },
+ 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7 => {},
+ 0xE0, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, 0xFE => {
+ const len = try self.readWord(&pos);
+ if (len < 2) return error.InvalidMarker;
+ pos += @intCast(len - 2);
+ },
+ else => {
+ if (marker_byte >= 0xC0 and marker_byte <= 0xCF) {
+ return error.UnsupportedFeatures;
+ }
+ if (marker_byte >= 0xD0 and marker_byte <= 0xD7) {} else {
+ const len = try self.readWord(&pos);
+ if (len < 2) return error.InvalidMarker;
+ pos += @intCast(len - 2);
+ }
+ },
+ }
+ }
+
+ if (!found_sos) return error.InvalidScanData;
+
+ for (0..self.num_components) |i| {
+ const qt = self.components[i].quant_table_id;
+ if (qt >= 4) return error.InvalidScanData;
+ _ = self.quant_tables[qt];
+
+ const dc_id = self.components[i].dc_table_id;
+ const ac_id = self.components[i].ac_table_id;
+ if (dc_id > 1 or ac_id > 1) return error.InvalidScanData;
+ if (self.dc_tables[dc_id].num_symbols == 0) return error.InvalidHuffmanTable;
+ if (self.ac_tables[ac_id].num_symbols == 0) return error.InvalidHuffmanTable;
+ }
+
+ var br = BitReader.init(self.data[pos..]);
+
+ // Compute max sampling factors (from luma component, typically component 0)
+ var max_h: u8 = 1;
+ var max_v: u8 = 1;
+ for (0..self.num_components) |i| {
+ if (self.components[i].h_sampling > max_h) max_h = self.components[i].h_sampling;
+ if (self.components[i].v_sampling > max_v) max_v = self.components[i].v_sampling;
+ }
+
+ // MCU dimensions in pixels
+ const mcu_pixel_w: usize = @as(usize, max_h) * 8;
+ const mcu_pixel_h: usize = @as(usize, max_v) * 8;
+
+ // Number of MCUs across the image
+ const mcu_w = (self.width + mcu_pixel_w - 1) / mcu_pixel_w;
+ const mcu_h = (self.height + mcu_pixel_h - 1) / mcu_pixel_h;
+ const total_mcus = std.math.mul(usize, mcu_w, mcu_h) catch return error.UnsupportedFeatures;
+
+ const total_pixels = std.math.mul(usize, self.width, self.height) catch return error.UnsupportedFeatures;
+ if (total_pixels > 1024 * 1024 * 1024) return error.UnsupportedFeatures;
+
+ if (self.num_components == 1) {
+ // Grayscale: no chroma subsampling possible
+ const total_y_blocks = total_mcus;
+ var mcu_y_bufs = try allocator.alloc(Block8x8, total_y_blocks);
+ defer allocator.free(mcu_y_bufs);
+
+ self.prev_dc = [_]i16{ 0, 0, 0 };
+
+ var mcus_since_restart: usize = 0;
+ for (0..total_y_blocks) |m| {
+ if (self.restart_interval > 0 and mcus_since_restart >= self.restart_interval) {
+ if (br.skipRestartMarker()) {
+ self.prev_dc[0] = 0;
+ }
+ mcus_since_restart = 0;
+ }
+ try self.decodeOneBlock(&br, 0, &mcu_y_bufs[m]);
+ mcus_since_restart += 1;
+ }
+
+ var pixels = try allocator.alloc(types.RGBAPixel, total_pixels);
+ errdefer allocator.free(pixels);
+
+ for (0..mcu_h) |my| {
+ for (0..mcu_w) |mx| {
+ const m = my * mcu_w + mx;
+ const y_block = mcu_y_bufs[m];
+
+ for (0..8) |row| {
+ for (0..8) |col| {
+ const px = mx * 8 + col;
+ const py = my * 8 + row;
+ if (px >= self.width or py >= self.height) continue;
+
+ const y_val: u8 = @intCast(@min(@max(@as(i32, @intFromFloat(@round(y_block[row][col]))), 0), 255));
+ pixels[py * self.width + px] = .{ .r = y_val, .g = y_val, .b = y_val, .a = 255 };
+ }
+ }
+ }
+ }
+
+ return .{
+ .width = self.width,
+ .height = self.height,
+ .pixels = pixels,
+ };
+ }
+
+ // Color image: handle chroma subsampling
+ // For each component, compute blocks per MCU
+ var blocks_per_mcu: [3]u16 = undefined;
+ for (0..self.num_components) |c| {
+ blocks_per_mcu[c] = self.components[c].h_sampling * self.components[c].v_sampling;
+ }
+
+ // Allocate block buffers per component
+ // Y gets blocks_per_mcu[0] blocks per MCU, Cb gets blocks_per_mcu[1], Cr gets blocks_per_mcu[2]
+ const y_blocks_per_mcu: usize = blocks_per_mcu[0];
+ const cb_blocks_per_mcu: usize = if (self.num_components > 1) @intCast(blocks_per_mcu[1]) else 0;
+ const cr_blocks_per_mcu: usize = if (self.num_components > 2) @intCast(blocks_per_mcu[2]) else 0;
+
+ const total_y = std.math.mul(usize, total_mcus, y_blocks_per_mcu) catch return error.UnsupportedFeatures;
+ const total_cb = std.math.mul(usize, total_mcus, cb_blocks_per_mcu) catch return error.UnsupportedFeatures;
+ const total_cr = std.math.mul(usize, total_mcus, cr_blocks_per_mcu) catch return error.UnsupportedFeatures;
+
+ var mcu_y_bufs = try allocator.alloc(Block8x8, total_y);
+ defer allocator.free(mcu_y_bufs);
+ var mcu_cb_bufs = try allocator.alloc(Block8x8, total_cb);
+ defer allocator.free(mcu_cb_bufs);
+ var mcu_cr_bufs = try allocator.alloc(Block8x8, total_cr);
+ defer allocator.free(mcu_cr_bufs);
+
+ self.prev_dc = [_]i16{ 0, 0, 0 };
+
+ // Decode blocks in JPEG interleaved order
+ // For each MCU, decode all Y blocks (in raster order within the MCU),
+ // then all Cb blocks, then all Cr blocks
+ var mcus_since_restart: usize = 0;
+ for (0..total_mcus) |m| {
+ if (self.restart_interval > 0 and mcus_since_restart >= self.restart_interval) {
+ if (br.skipRestartMarker()) {
+ self.prev_dc = [_]i16{ 0, 0, 0 };
+ }
+ mcus_since_restart = 0;
+ }
+
+ // Y blocks (in raster order: row-major within the MCU grid)
+ for (0..y_blocks_per_mcu) |yb| {
+ try self.decodeOneBlock(&br, 0, &mcu_y_bufs[m * y_blocks_per_mcu + yb]);
+ }
+
+ // Cb blocks
+ for (0..cb_blocks_per_mcu) |cb| {
+ try self.decodeOneBlock(&br, 1, &mcu_cb_bufs[m * cb_blocks_per_mcu + cb]);
+ }
+
+ // Cr blocks
+ for (0..cr_blocks_per_mcu) |cr| {
+ try self.decodeOneBlock(&br, 2, &mcu_cr_bufs[m * cr_blocks_per_mcu + cr]);
+ }
+
+ mcus_since_restart += 1;
+ }
+
+ // Pixel assembly with chroma upsampling
+ var pixels = try allocator.alloc(types.RGBAPixel, total_pixels);
+ errdefer allocator.free(pixels);
+
+ const h_samp_y: usize = self.components[0].h_sampling;
+ const v_samp_y: usize = self.components[0].v_sampling;
+
+ for (0..mcu_h) |my| {
+ for (0..mcu_w) |mx| {
+ const m = my * mcu_w + mx;
+
+ // Y blocks are in raster order within the MCU: v_samp_y rows, h_samp_y cols
+ // Cb/Cr blocks: if subsampled, 1 block covers the entire MCU pixel area
+
+ for (0..mcu_pixel_h) |rel_y| {
+ for (0..mcu_pixel_w) |rel_x| {
+ const px = mx * mcu_pixel_w + rel_x;
+ const py = my * mcu_pixel_h + rel_y;
+ if (px >= self.width or py >= self.height) continue;
+
+ // Y value: look up the correct Y block within the MCU
+ const y_block_row = rel_y / 8;
+ const y_block_col = rel_x / 8;
+ const y_block_idx = y_block_row * h_samp_y + y_block_col;
+ const y_block = mcu_y_bufs[m * y_blocks_per_mcu + y_block_idx];
+ const y_val = @min(@max(y_block[rel_y % 8][rel_x % 8], 0.0), 255.0);
+
+ // Cb value: nearest-neighbor upsample from chroma block
+ var cb_val: f64 = 0.0;
+ var cr_val: f64 = 0.0;
+ if (self.num_components >= 3) {
+ // Map pixel position to chroma block coordinate
+ const cb_rel_x = rel_x / h_samp_y;
+ const cb_rel_y = rel_y / v_samp_y;
+ const cb_block_idx: usize = 0; // single Cb block per MCU for standard subsampling
+ if (cb_block_idx < cb_blocks_per_mcu) {
+ const cb_block = mcu_cb_bufs[m * cb_blocks_per_mcu + cb_block_idx];
+ cb_val = cb_block[cb_rel_y % 8][cb_rel_x % 8] - 128.0;
+ }
+ if (cb_block_idx < cr_blocks_per_mcu) {
+ const cr_block = mcu_cr_bufs[m * cr_blocks_per_mcu + cb_block_idx];
+ cr_val = cr_block[cb_rel_y % 8][cb_rel_x % 8] - 128.0;
+ }
+ }
+
+ // BT.601 YCbCr to RGB
+ const r_f = y_val + 1.402 * cr_val;
+ const g_f = y_val - 0.344136 * cb_val - 0.714136 * cr_val;
+ const b_f = y_val + 1.772 * cb_val;
+
+ pixels[py * self.width + px] = .{
+ .r = @intCast(@min(@max(@as(i32, @intFromFloat(@round(r_f))), 0), 255)),
+ .g = @intCast(@min(@max(@as(i32, @intFromFloat(@round(g_f))), 0), 255)),
+ .b = @intCast(@min(@max(@as(i32, @intFromFloat(@round(b_f))), 0), 255)),
+ .a = 255,
+ };
+ }
+ }
+ }
+ }
+
+ return .{
+ .width = self.width,
+ .height = self.height,
+ .pixels = pixels,
+ };
+ }
+};
+
+pub fn decodeJPEG(allocator: std.mem.Allocator, buffer: []const u8, options: types.DecodeOptions) !types.ImageData {
+ var decoder = JPEGDecoder.init(buffer);
+ var result = try decoder.decode(allocator);
+
+ if (options.output_format == .rgb) {
+ for (0..result.pixels.len) |i| {
+ result.pixels[i].a = 255;
+ }
+ }
+
+ return result;
+}
+
+test "JPEGDecoder invalid SOI" {
+ const data = [_]u8{ 0x00, 0x00 };
+ try std.testing.expectError(error.InvalidSOI, decodeJPEG(std.testing.allocator, &data, .{}));
+}
+
+test "JPEGDecoder minimal valid JPEG" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xD9 };
+ try std.testing.expectError(error.InvalidScanData, decodeJPEG(std.testing.allocator, &data, .{}));
+}
+
+test "BitReader reads bits MSB first" {
+ const data = [_]u8{0b11010110};
+ var br = BitReader.init(&data);
+ const b7 = try br.readBits(1);
+ try std.testing.expectEqual(@as(u32, 1), b7);
+ const b65 = try br.readBits(2);
+ try std.testing.expectEqual(@as(u32, 0b10), b65);
+ const rest = try br.readBits(5);
+ try std.testing.expectEqual(@as(u32, 0b10110), rest);
+}
+
+test "BitReader handles 0xFF byte stuffing" {
+ const data = [_]u8{ 0xFF, 0x00, 0xAB };
+ var br = BitReader.init(&data);
+ const val = try br.readBits(8);
+ try std.testing.expectEqual(@as(u32, 0xFF), val);
+}
+
+test "Decoder rejects progressive JPEG markers" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xC1, 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xD9 };
+ try std.testing.expectError(error.UnsupportedFeatures, decodeJPEG(std.testing.allocator, &data, .{}));
+}
+
+test "Decoder rejects length < 2 in markers" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x01, 0xFF, 0xD9 };
+ try std.testing.expectError(error.InvalidMarker, decodeJPEG(std.testing.allocator, &data, .{}));
+}
+
+test "Decoder rejects DHT with total > 256" {
+ var data: [100]u8 = undefined;
+ data[0] = 0xFF;
+ data[1] = 0xD8;
+ data[2] = 0xFF;
+ data[3] = 0xC4;
+ data[4] = 0x00;
+ data[5] = 0x15;
+ data[6] = 0x00;
+ for (7..23) |i| {
+ data[i] = 20;
+ }
+ data[23] = 0xFF;
+ data[24] = 0xD9;
+ try std.testing.expectError(error.InvalidHuffmanTable, decodeJPEG(std.testing.allocator, data[0..25], .{}));
+}
+
+test "Decoder rejects DHT with zero symbols" {
+ const data = [_]u8{
+ 0xFF, 0xD8, // SOI
+ 0xFF, 0xC4, 0x00, 0x13, // DHT, length=19
+ 0x00, // class=0, id=0 (DC table 0)
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // all zero counts
+ 0xFF, 0xD9, // EOI
+ };
+ try std.testing.expectError(error.InvalidHuffmanTable, decodeJPEG(std.testing.allocator, &data, .{}));
+}
+
+test "Decoder rejects SOF0 with zero components" {
+ const data = [_]u8{
+ 0xFF, 0xD8, // SOI
+ 0xFF, 0xC0, 0x00, 0x08, // SOF0, length=8
+ 0x08, // precision=8
+ 0x00, 0x08, // height=8
+ 0x00, 0x08, // width=8
+ 0x00, // num_components=0 (invalid)
+ 0xFF, 0xD9, // EOI
+ };
+ try std.testing.expectError(error.UnsupportedFeatures, decodeJPEG(std.testing.allocator, &data, .{}));
+}
diff --git a/src/encoder.ts b/src/encoder.ts
deleted file mode 100644
index 2cf59aa..0000000
--- a/src/encoder.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { EncodeOptions, ImageData, JPEGData } from './types.js';
-import { convertImageToYCbCr } from './image-processing/ycbcr-converter.js';
-import { splitIntoBlocks } from './core/block-processor.js';
-import { dct2D, fastDCT } from './core/dct.js';
-import { quantizeBlock } from './core/quantization.js';
-import { zigzagEncode, runLengthEncode } from './core/zigzag.js';
-import { encodeHuffmanDC, encodeHuffmanAC } from './encoding/huffman.js';
-import { JPEGWriter } from './encoding/jpeg-writer.js';
-import { getPreset } from './presets.js';
-
-export async function encodeJPEG(
- imageData: ImageData,
- options: EncodeOptions = {}
-): Promise {
- let {
- quality = 75,
- fastMode = false,
- colorSpace = 'rgb',
- progressive = false,
- onProgress
- } = options;
-
- if (options.preset) {
- const preset = getPreset(options.preset);
- quality = preset.quality;
- fastMode = preset.fastMode;
- }
-
- const { width, height } = imageData;
-
- onProgress?.(0, 'Converting color space');
-
-
- const yCbCrImage = convertImageToYCbCr(
- Array.from(imageData.data).reduce((acc, val, i) => {
- if (i % 4 === 0) acc.push([]);
- if (i % 4 < 4) acc[acc.length - 1].push(val);
- return acc;
- }, [] as number[][]) as any
- );
-
- onProgress?.(20, 'Splitting into blocks');
-
- const yBlocks = splitIntoBlocks(yCbCrImage.yCbCrData);
-
- onProgress?.(30, 'Writing JPEG headers');
-
- const writer = new JPEGWriter();
- writer.writeSOI();
- writer.writeAPP0();
- writer.writeDQT(quality);
- writer.writeSOF0(width, height);
- writer.writeDHT();
- writer.writeSOS();
-
- let compressedData = '';
- let prevDC = 0;
-
- const totalBlocks = yBlocks.length;
-
- for (let i = 0; i < totalBlocks; i++) {
- const block = yBlocks[i];
- const dctBlock = fastMode ? fastDCT(block) : dct2D(block);
- const quantized = quantizeBlock(dctBlock, quality, true);
- const zigzag = zigzagEncode(quantized);
-
- const dcDiff = zigzag[0] - prevDC;
- prevDC = zigzag[0];
- compressedData += encodeHuffmanDC(dcDiff, true);
-
- const acCoeffs = zigzag.slice(1);
- const rle = runLengthEncode(acCoeffs);
-
- for (const [run, value] of rle) {
- compressedData += encodeHuffmanAC(run, value, true);
- }
-
- if (i % Math.max(1, Math.floor(totalBlocks / 10)) === 0) {
- const progress = 30 + Math.floor((i / totalBlocks) * 60);
- onProgress?.(progress, 'Encoding blocks');
- }
- }
-
- onProgress?.(90, 'Finalizing JPEG');
-
- writer.writeCompressedData(compressedData);
- writer.writeEOI();
-
- onProgress?.(100, 'Complete');
-
- return {
- buffer: writer.getBuffer(),
- width,
- height,
- quality
- };
-}
-
-export async function encodeJPEGFromFile(
- filePath: string,
- options: EncodeOptions = {}
-): Promise {
- const sharp = await import('sharp');
- const { data, info } = await sharp.default(filePath)
- .raw()
- .toBuffer({ resolveWithObject: true });
-
- return encodeJPEG({
- data: new Uint8ClampedArray(data),
- width: info.width,
- height: info.height
- }, options);
-}
diff --git a/src/encoder.zig b/src/encoder.zig
new file mode 100644
index 0000000..60b195f
--- /dev/null
+++ b/src/encoder.zig
@@ -0,0 +1,351 @@
+const std = @import("std");
+const types = @import("types.zig");
+const dct = @import("core/dct.zig");
+const quantization = @import("core/quantization.zig");
+const zigzag_mod = @import("core/zigzag.zig");
+const block_processor = @import("core/block_processor.zig");
+const color_space = @import("core/color_space.zig");
+const huffman = @import("encoding/huffman.zig");
+const BitWriter = @import("encoding/bitstream.zig").BitWriter;
+const JpegWriter = @import("encoding/jpeg_writer.zig").JpegWriter;
+const presets_mod = @import("presets.zig");
+
+const Block8x8 = types.Block8x8;
+const RLEPair = zigzag_mod.RLEPair;
+
+pub fn encodeJPEG(allocator: std.mem.Allocator, image_data: *const types.ImageData, options: types.EncodeOptions) !types.JPEGData {
+ var quality = options.quality;
+ var fast_mode = options.fast_mode;
+
+ if (options.preset) |preset_name| {
+ if (presets_mod.getPreset(preset_name)) |p| {
+ quality = p.quality;
+ fast_mode = p.fast_mode;
+ }
+ }
+
+ const w = image_data.width;
+ const h = image_data.height;
+
+ if (quality < 1 or quality > 100) return error.InvalidQuality;
+ if (w == 0 or h == 0) return error.InvalidImageDimensions;
+ if (w > 65535 or h > 65535) return error.ImageTooLarge;
+ if (image_data.pixels.len < @as(usize, @intCast(w)) * @as(usize, @intCast(h))) return error.InvalidImageData;
+
+ if (options.on_progress) |cb| cb(0.0, "color_conversion");
+
+ var ycbcr = try color_space.convertImageToYCbCr(image_data.pixels, w, h, allocator);
+ defer ycbcr.deinit(allocator);
+
+ if (options.on_progress) |cb| cb(0.2, "block_splitting");
+
+ var blocks_result = try block_processor.splitIntoBlocks(&ycbcr, allocator);
+ defer blocks_result.deinit(allocator);
+
+ if (options.on_progress) |cb| cb(0.3, "encoding");
+
+ const num_blocks = blocks_result.y_blocks.len;
+ var bit_writer = BitWriter.init(allocator);
+ defer bit_writer.deinit();
+
+ const lum_matrix = quantization.getQuantizationMatrix(quality, true);
+ const chr_matrix = quantization.getQuantizationMatrix(quality, false);
+
+ var prev_dc_y: i16 = 0;
+ var prev_dc_cb: i16 = 0;
+ var prev_dc_cr: i16 = 0;
+
+ const do_subsample = options.subsample and w >= 16 and h >= 16 and !fast_mode;
+
+ if (do_subsample) {
+ // 4:2:0 mode: MCU is 16x16 pixels = 4 Y blocks + 1 Cb block + 1 Cr block
+ // First, compute the downsampled Cb/Cr blocks
+ const mcu_w = (w + 15) / 16;
+ const mcu_h = (h + 15) / 16;
+ const total_mcus = @as(usize, mcu_w) * @as(usize, mcu_h);
+ const orig_mcu_w = (w + 7) / 8;
+
+ // Downsample Cb/Cr: average 2x2 blocks into 1
+ var ds_cb = try allocator.alloc(Block8x8, total_mcus);
+ errdefer allocator.free(ds_cb);
+ defer allocator.free(ds_cb);
+ var ds_cr = try allocator.alloc(Block8x8, total_mcus);
+ errdefer allocator.free(ds_cr);
+ defer allocator.free(ds_cr);
+
+ for (0..mcu_h) |my| {
+ for (0..mcu_w) |mx| {
+ const m = my * @as(usize, mcu_w) + mx;
+
+ // The 4 Y blocks in this MCU are at positions:
+ // (my*2, mx*2), (my*2, mx*2+1), (my*2+1, mx*2), (my*2+1, mx*2+1)
+ // Average their Cb/Cr blocks
+ var cb_avg: Block8x8 = @splat(@splat(0.0));
+ var cr_avg: Block8x8 = @splat(@splat(0.0));
+ var count: f64 = 0.0;
+
+ for (0..2) |dy| {
+ for (0..2) |dx| {
+ const by = my * 2 + dy;
+ const bx = mx * 2 + dx;
+ if (by < (h + 7) / 8 and bx < orig_mcu_w) {
+ const idx = by * @as(usize, orig_mcu_w) + bx;
+ if (idx < num_blocks) {
+ for (0..8) |r| {
+ for (0..8) |c| {
+ cb_avg[r][c] += blocks_result.cb_blocks[idx][r][c];
+ cr_avg[r][c] += blocks_result.cr_blocks[idx][r][c];
+ }
+ }
+ count += 1.0;
+ }
+ }
+ }
+ }
+
+ if (count > 0.0) {
+ for (0..8) |r| {
+ for (0..8) |c| {
+ cb_avg[r][c] /= count;
+ cr_avg[r][c] /= count;
+ }
+ }
+ }
+
+ ds_cb[m] = cb_avg;
+ ds_cr[m] = cr_avg;
+ }
+ }
+
+ // Encode in interleaved order: 4 Y blocks, 1 Cb, 1 Cr per MCU
+ for (0..total_mcus) |m| {
+ const mcu_x = m % @as(usize, mcu_w);
+ const mcu_y = m / @as(usize, mcu_w);
+
+ // 4 Y blocks in raster order within the MCU
+ for (0..2) |dy| {
+ for (0..2) |dx| {
+ const by = mcu_y * 2 + dy;
+ const bx = mcu_x * 2 + dx;
+ if (by < (h + 7) / 8 and bx < orig_mcu_w) {
+ const idx = by * @as(usize, orig_mcu_w) + bx;
+ if (idx < num_blocks) {
+ const dct_y: Block8x8 = if (fast_mode) dct.fastDCT(&blocks_result.y_blocks[idx]) else dct.dct2D(&blocks_result.y_blocks[idx]);
+ const q_y = quantization.quantizeBlockWithMatrix(&dct_y, &lum_matrix);
+ const zz_y = zigzag_mod.zigzagEncode(&q_y);
+ prev_dc_y = try encodeBlock(&zz_y, prev_dc_y, true, &bit_writer);
+ } else {
+ var empty_zz: [64]f64 = @splat(0);
+ prev_dc_y = try encodeBlock(&empty_zz, prev_dc_y, true, &bit_writer);
+ }
+ } else {
+ var empty_zz: [64]f64 = @splat(0);
+ prev_dc_y = try encodeBlock(&empty_zz, prev_dc_y, true, &bit_writer);
+ }
+ }
+ }
+
+ // 1 Cb block
+ {
+ const dct_cb: Block8x8 = if (fast_mode) dct.fastDCT(&ds_cb[m]) else dct.dct2D(&ds_cb[m]);
+ const q_cb = quantization.quantizeBlockWithMatrix(&dct_cb, &chr_matrix);
+ const zz_cb = zigzag_mod.zigzagEncode(&q_cb);
+ prev_dc_cb = try encodeBlock(&zz_cb, prev_dc_cb, false, &bit_writer);
+ }
+
+ // 1 Cr block
+ {
+ const dct_cr: Block8x8 = if (fast_mode) dct.fastDCT(&ds_cr[m]) else dct.dct2D(&ds_cr[m]);
+ const q_cr = quantization.quantizeBlockWithMatrix(&dct_cr, &chr_matrix);
+ const zz_cr = zigzag_mod.zigzagEncode(&q_cr);
+ prev_dc_cr = try encodeBlock(&zz_cr, prev_dc_cr, false, &bit_writer);
+ }
+
+ if (m % 64 == 0) {
+ if (options.on_progress) |cb| {
+ const progress = 0.3 + 0.6 * (@as(f32, @floatFromInt(m)) / @as(f32, @floatFromInt(total_mcus)));
+ cb(progress, "encoding");
+ }
+ }
+ }
+ } else {
+ // 4:4:4 mode: original interleaved Y, Cb, Cr per block
+ for (0..num_blocks) |block_idx| {
+ const dct_y: Block8x8 = if (fast_mode) dct.fastDCT(&blocks_result.y_blocks[block_idx]) else dct.dct2D(&blocks_result.y_blocks[block_idx]);
+ const dct_cb: Block8x8 = if (fast_mode) dct.fastDCT(&blocks_result.cb_blocks[block_idx]) else dct.dct2D(&blocks_result.cb_blocks[block_idx]);
+ const dct_cr: Block8x8 = if (fast_mode) dct.fastDCT(&blocks_result.cr_blocks[block_idx]) else dct.dct2D(&blocks_result.cr_blocks[block_idx]);
+
+ const q_y = quantization.quantizeBlockWithMatrix(&dct_y, &lum_matrix);
+ const q_cb = quantization.quantizeBlockWithMatrix(&dct_cb, &chr_matrix);
+ const q_cr = quantization.quantizeBlockWithMatrix(&dct_cr, &chr_matrix);
+
+ const zz_y = zigzag_mod.zigzagEncode(&q_y);
+ const zz_cb = zigzag_mod.zigzagEncode(&q_cb);
+ const zz_cr = zigzag_mod.zigzagEncode(&q_cr);
+
+ prev_dc_y = try encodeBlock(&zz_y, prev_dc_y, true, &bit_writer);
+ prev_dc_cb = try encodeBlock(&zz_cb, prev_dc_cb, false, &bit_writer);
+ prev_dc_cr = try encodeBlock(&zz_cr, prev_dc_cr, false, &bit_writer);
+
+ if (block_idx % 64 == 0) {
+ if (options.on_progress) |cb| {
+ const progress = 0.3 + 0.6 * (@as(f32, @floatFromInt(block_idx)) / @as(f32, @floatFromInt(num_blocks)));
+ cb(progress, "encoding");
+ }
+ }
+ }
+ }
+
+ try bit_writer.flush();
+
+ if (options.on_progress) |cb| cb(0.9, "writing");
+
+ var writer = JpegWriter.init(allocator);
+ defer writer.deinit();
+
+ try writer.writeSOI();
+ try writer.writeAPP0();
+ try writer.writeDQTWithMatrices(&lum_matrix, &chr_matrix);
+
+ if (do_subsample) {
+ try writer.writeSOF0WithSampling(@intCast(w), @intCast(h), 0x22, 0x11, 0x11);
+ } else {
+ try writer.writeSOF0(@intCast(w), @intCast(h));
+ }
+
+ try writer.writeDHT();
+ try writer.writeSOS();
+ try writer.writeCompressedData(bit_writer.getBytes());
+ try writer.writeEOI();
+
+ const out_buf = try writer.buffer.toOwnedSlice();
+
+ if (options.on_progress) |cb| cb(1.0, "done");
+
+ return .{
+ .buffer = out_buf,
+ .width = w,
+ .height = h,
+ .quality = quality,
+ };
+}
+
+fn encodeBlock(zz: *const [64]f64, prev_dc: i16, is_luminance: bool, bw: *BitWriter) !i16 {
+ const dc_val: i16 = @intFromFloat(zz[0]);
+ const dc_diff: i16 = @intCast(std.math.clamp(@as(i32, dc_val) - @as(i32, prev_dc), -2047, 2047));
+
+ try huffman.encodeHuffmanDC(dc_diff, is_luminance, bw);
+
+ var rle: [64]RLEPair = undefined;
+ const rle_count = zigzag_mod.runLengthEncode(zz, &rle);
+
+ for (0..rle_count) |i| {
+ const val = rle[i].value;
+ const run = rle[i].run;
+ const cat = huffman.getCategory(val);
+ const max_cat = if (is_luminance) @as(u8, 10) else @as(u8, 7);
+ if (cat > max_cat) {
+ var remaining_run = run;
+ while (remaining_run >= 15) : (remaining_run -= 15) {
+ try huffman.encodeHuffmanAC(15, 0, is_luminance, bw);
+ }
+ const max_val: i16 = if (is_luminance) 1023 else 127;
+ const clamped_val = std.math.clamp(val, -max_val, max_val);
+ try huffman.encodeHuffmanAC(@intCast(remaining_run), clamped_val, is_luminance, bw);
+ continue;
+ }
+ try huffman.encodeHuffmanAC(rle[i].run, val, is_luminance, bw);
+ }
+
+ return dc_val;
+}
+
+test "encodeJPEG minimal 8x8" {
+ const allocator = std.testing.allocator;
+ var pixels: [64]types.RGBAPixel = undefined;
+ for (0..64) |i| {
+ pixels[i] = .{
+ .r = @intCast(i * 4),
+ .g = @intCast(i * 2),
+ .b = @intCast(255 - i * 4),
+ };
+ }
+ var img = types.ImageData{
+ .width = 8,
+ .height = 8,
+ .pixels = &pixels,
+ };
+ var result = try encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ try std.testing.expect(result.buffer.len > 100);
+ try std.testing.expectEqual(@as(u8, 0xFF), result.buffer[0]);
+ try std.testing.expectEqual(@as(u8, 0xD8), result.buffer[1]);
+ try std.testing.expectEqual(@as(u32, 8), result.width);
+ try std.testing.expectEqual(@as(u32, 8), result.height);
+ try std.testing.expectEqual(@as(u8, 75), result.quality);
+}
+
+test "encodeJPEG 16x16" {
+ const allocator = std.testing.allocator;
+ var pixels: [256]types.RGBAPixel = undefined;
+ for (0..256) |i| {
+ pixels[i] = .{
+ .r = @intCast(i % 256),
+ .g = @intCast((i * 3) % 256),
+ .b = @intCast((i * 7) % 256),
+ };
+ }
+ var img = types.ImageData{
+ .width = 16,
+ .height = 16,
+ .pixels = &pixels,
+ };
+ var result = try encodeJPEG(allocator, &img, .{ .quality = 90 });
+ defer result.deinit(allocator);
+
+ try std.testing.expect(result.buffer.len > 200);
+ try std.testing.expectEqual(@as(u8, 0xFF), result.buffer[0]);
+ try std.testing.expectEqual(@as(u8, 0xD8), result.buffer[1]);
+}
+
+test "encodeJPEG quality affects output size" {
+ const allocator = std.testing.allocator;
+ var pixels: [256]types.RGBAPixel = undefined;
+ for (0..256) |i| {
+ pixels[i] = .{ .r = @intCast(i % 256), .g = 128, .b = 64 };
+ }
+ var img = types.ImageData{
+ .width = 16,
+ .height = 16,
+ .pixels = &pixels,
+ };
+
+ var low = try encodeJPEG(allocator, &img, .{ .quality = 10 });
+ defer low.deinit(allocator);
+ var high = try encodeJPEG(allocator, &img, .{ .quality = 95 });
+ defer high.deinit(allocator);
+
+ try std.testing.expect(low.buffer.len < high.buffer.len);
+}
+
+test "encodeJPEG rejects zero width" {
+ var pixels: [8]types.RGBAPixel = undefined;
+ for (&pixels) |*p| p.* = .{ .r = 128, .g = 128, .b = 128 };
+ var img = types.ImageData{ .width = 0, .height = 8, .pixels = &pixels };
+ try std.testing.expectError(error.InvalidImageDimensions, encodeJPEG(std.testing.allocator, &img, .{}));
+}
+
+test "encodeJPEG rejects zero height" {
+ var pixels: [8]types.RGBAPixel = undefined;
+ for (&pixels) |*p| p.* = .{ .r = 128, .g = 128, .b = 128 };
+ var img = types.ImageData{ .width = 8, .height = 0, .pixels = &pixels };
+ try std.testing.expectError(error.InvalidImageDimensions, encodeJPEG(std.testing.allocator, &img, .{}));
+}
+
+test "encodeJPEG rejects undersized pixel buffer" {
+ var pixels: [4]types.RGBAPixel = undefined;
+ for (&pixels) |*p| p.* = .{ .r = 128, .g = 128, .b = 128 };
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = &pixels };
+ try std.testing.expectError(error.InvalidImageData, encodeJPEG(std.testing.allocator, &img, .{}));
+}
diff --git a/src/encoding/bitstream.ts b/src/encoding/bitstream.ts
deleted file mode 100644
index 2494be8..0000000
--- a/src/encoding/bitstream.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export class BitStream {
- private buffer: string[] = [];
-
- writeBits(bits: string): void {
- this.buffer.push(bits);
- }
-
- getBitStream(): string {
- return this.buffer.join('');
- }
-
- clear(): void {
- this.buffer = [];
- }
-}
diff --git a/src/encoding/bitstream.zig b/src/encoding/bitstream.zig
new file mode 100644
index 0000000..38ad526
--- /dev/null
+++ b/src/encoding/bitstream.zig
@@ -0,0 +1,82 @@
+const std = @import("std");
+
+pub const BitWriter = struct {
+ buffer: std.ArrayList(u8),
+ accumulator: u32,
+ bit_count: u8,
+
+ pub fn init(allocator: std.mem.Allocator) BitWriter {
+ return .{
+ .buffer = std.ArrayList(u8).init(allocator),
+ .accumulator = 0,
+ .bit_count = 0,
+ };
+ }
+
+ pub fn deinit(self: *BitWriter) void {
+ self.buffer.deinit();
+ }
+
+ pub fn writeBits(self: *BitWriter, bits: u32, count: u8) !void {
+ self.accumulator = (self.accumulator << @intCast(count)) | bits;
+ self.bit_count += count;
+
+ while (self.bit_count >= 8) {
+ self.bit_count -= 8;
+ const byte: u8 = @intCast((self.accumulator >> @intCast(self.bit_count)) & 0xFF);
+ try self.buffer.append(byte);
+ }
+ }
+
+ pub fn flush(self: *BitWriter) !void {
+ if (self.bit_count > 0) {
+ const shift: u5 = @intCast(8 - self.bit_count);
+ const fill_mask: u8 = @as(u8, 0xFF) >> @intCast(self.bit_count);
+ const byte: u8 = @intCast(((self.accumulator << shift) | fill_mask) & 0xFF);
+ try self.buffer.append(byte);
+ self.accumulator = 0;
+ self.bit_count = 0;
+ }
+ }
+
+ pub fn getBytes(self: *const BitWriter) []const u8 {
+ return self.buffer.items;
+ }
+
+ pub fn bitLength(self: *const BitWriter) usize {
+ return self.buffer.items.len * 8 + self.bit_count;
+ }
+};
+
+test "BitWriter basic" {
+ var bw = BitWriter.init(std.testing.allocator);
+ defer bw.deinit();
+
+ try bw.writeBits(0xFF, 8);
+ try std.testing.expectEqual(@as(usize, 1), bw.buffer.items.len);
+ try std.testing.expectEqual(@as(u8, 0xFF), bw.buffer.items[0]);
+}
+
+test "BitWriter partial byte flush" {
+ var bw = BitWriter.init(std.testing.allocator);
+ defer bw.deinit();
+
+ try bw.writeBits(0b1010, 4);
+ try std.testing.expectEqual(@as(usize, 0), bw.buffer.items.len);
+ try std.testing.expectEqual(@as(u8, 4), bw.bit_count);
+
+ try bw.flush();
+ try std.testing.expectEqual(@as(usize, 1), bw.buffer.items.len);
+ try std.testing.expectEqual(@as(u8, 0b10101111), bw.buffer.items[0]);
+}
+
+test "BitWriter multi-byte" {
+ var bw = BitWriter.init(std.testing.allocator);
+ defer bw.deinit();
+
+ try bw.writeBits(0b11111111, 8);
+ try bw.writeBits(0b00000000, 8);
+ try std.testing.expectEqual(@as(usize, 2), bw.buffer.items.len);
+ try std.testing.expectEqual(@as(u8, 0xFF), bw.buffer.items[0]);
+ try std.testing.expectEqual(@as(u8, 0x00), bw.buffer.items[1]);
+}
diff --git a/src/encoding/entropy-coding.ts b/src/encoding/entropy-coding.ts
deleted file mode 100644
index 12f2219..0000000
--- a/src/encoding/entropy-coding.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { BitStream } from './bitstream.js';
-import { encodeHuffmanAC, encodeHuffmanDC } from './huffman.js';
-
-export function encodeEntropy(data: Array): string {
- const bitstream = new BitStream();
-
- data.forEach(value => {
- if (value === 'DC') {
- bitstream.writeBits(encodeHuffmanDC(0));
- } else {
- bitstream.writeBits(encodeHuffmanAC(1, typeof value === 'number' ? value : 0));
- }
- });
-
- return bitstream.getBitStream();
-}
diff --git a/src/encoding/huffman.ts b/src/encoding/huffman.ts
deleted file mode 100644
index 7a5c1d4..0000000
--- a/src/encoding/huffman.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { HuffmanTable } from '../types.js';
-
-const DC_LUMINANCE_TABLE: Record = {
- 0: '00', 1: '010', 2: '011', 3: '100', 4: '101',
- 5: '110', 6: '1110', 7: '11110', 8: '111110',
- 9: '1111110', 10: '11111110', 11: '111111110'
-};
-
-const DC_CHROMINANCE_TABLE: Record = {
- 0: '00', 1: '01', 2: '10', 3: '110', 4: '1110',
- 5: '11110', 6: '111110', 7: '1111110', 8: '11111110',
- 9: '111111110', 10: '1111111110', 11: '11111111110'
-};
-
-const AC_LUMINANCE_TABLE: Record = {
- '0/0': '1010', '0/1': '00', '0/2': '01', '0/3': '100', '0/4': '1011',
- '0/5': '11010', '0/6': '1111000', '0/7': '11111000', '0/8': '1111110110',
- '0/9': '1111111110000010', '0/10': '1111111110000011',
- '1/1': '1100', '1/2': '11011', '1/3': '1111001', '1/4': '111110110',
- '1/5': '11111110110', '15/0': '11111111001'
-};
-
-const AC_CHROMINANCE_TABLE: Record = {
- '0/0': '00', '0/1': '01', '0/2': '100', '0/3': '1010', '0/4': '11000',
- '0/5': '11001', '0/6': '111000', '0/7': '1111000', '0/8': '111110100',
- '0/9': '1111110110', '0/10': '111111110100',
- '1/1': '1011', '1/2': '111001', '1/3': '11110110', '1/4': '111110101',
- '1/5': '11111110110', '15/0': '1111111010'
-};
-
-function getCategory(value: number): number {
- const absValue = Math.abs(value);
- if (absValue === 0) return 0;
- return Math.floor(Math.log2(absValue)) + 1;
-}
-
-function getAdditionalBits(value: number, category: number): string {
- if (category === 0) return '';
- if (value > 0) {
- return value.toString(2).padStart(category, '0');
- } else {
- return (value - 1).toString(2).slice(-category);
- }
-}
-
-export function encodeHuffmanDC(value: number, isLuminance: boolean = true): string {
- const table = isLuminance ? DC_LUMINANCE_TABLE : DC_CHROMINANCE_TABLE;
- const category = getCategory(value);
- const code = table[category] || '111111110';
- const additional = getAdditionalBits(value, category);
- return code + additional;
-}
-
-export function encodeHuffmanAC(runLength: number, value: number, isLuminance: boolean = true): string {
- const table = isLuminance ? AC_LUMINANCE_TABLE : AC_CHROMINANCE_TABLE;
- const category = getCategory(value);
- const key = `${runLength}/${category}`;
- const code = table[key] || '11111111001';
- const additional = getAdditionalBits(value, category);
- return code + additional;
-}
-
-export function generateHuffmanTable(): HuffmanTable {
- return {
- dc: DC_LUMINANCE_TABLE,
- ac: AC_LUMINANCE_TABLE
- };
-}
-
-export function buildHuffmanTree(frequencies: Map): Map {
- const codes = new Map();
- const sorted = Array.from(frequencies.entries()).sort((a, b) => a[1] - b[1]);
-
- let code = 0;
- let codeLength = 1;
-
- for (const [symbol] of sorted) {
- codes.set(symbol, code.toString(2).padStart(codeLength, '0'));
- code++;
- if (code >= (1 << codeLength)) {
- codeLength++;
- code = 0;
- }
- }
-
- return codes;
-}
diff --git a/src/encoding/huffman.zig b/src/encoding/huffman.zig
new file mode 100644
index 0000000..e2afd05
--- /dev/null
+++ b/src/encoding/huffman.zig
@@ -0,0 +1,233 @@
+const std = @import("std");
+const BitWriter = @import("bitstream.zig").BitWriter;
+
+pub const DC_LUMINANCE_BITS = [_]u8{
+ 0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0,
+};
+pub const DC_LUMINANCE_VALS = [_]u8{
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
+};
+
+pub const DC_CHROMINANCE_BITS = [_]u8{
+ 0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0,
+};
+pub const DC_CHROMINANCE_VALS = [_]u8{
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
+};
+
+pub const AC_LUMINANCE_BITS = [_]u8{
+ 0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 125,
+};
+pub const AC_LUMINANCE_VALS = [_]u8{
+ 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12,
+ 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07,
+ 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08,
+ 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0,
+ 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0a, 0x16,
+ 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28,
+ 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39,
+ 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
+ 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59,
+ 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
+ 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79,
+ 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
+ 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98,
+ 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7,
+ 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6,
+ 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5,
+ 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4,
+ 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2,
+ 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea,
+ 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8,
+ 0xf9, 0xfa,
+};
+
+pub const AC_CHROMINANCE_BITS = [_]u8{
+ 0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 119,
+};
+pub const AC_CHROMINANCE_VALS = [_]u8{
+ 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x51, 0x12,
+ 0x21, 0x31, 0x41, 0x05, 0x61, 0x71, 0x13, 0x22,
+ 0x32, 0x81, 0x06, 0x14, 0x42, 0x91, 0xa1, 0xb1,
+ 0xc1, 0x07, 0x23, 0x33, 0x52, 0xf0, 0x15, 0x62,
+ 0x72, 0xd1, 0x08, 0x24, 0x43, 0x16, 0x53, 0x92,
+ 0xa2, 0xb2, 0x09, 0x0a, 0x17, 0x18, 0x19, 0x1a,
+ 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x34, 0x35,
+ 0x36, 0x37, 0x38, 0x39, 0x3a, 0x44, 0x45, 0x46,
+ 0x47, 0x48, 0x49, 0x4a, 0x54, 0x55, 0x56, 0x57,
+ 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67,
+ 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77,
+ 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86,
+ 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95,
+ 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4,
+ 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3,
+ 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2,
+ 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca,
+ 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9,
+ 0xda, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7,
+ 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5,
+ 0xf6, 0xf7,
+};
+
+const HuffmanCode = struct {
+ bits: u8,
+ code: u16,
+};
+
+const DC_LUM_CODES = buildDCCodes(&DC_LUMINANCE_BITS);
+const DC_CHR_CODES = buildDCCodes(&DC_CHROMINANCE_BITS);
+const AC_LUM_CODES = buildACCodes(&AC_LUMINANCE_BITS, &AC_LUMINANCE_VALS);
+const AC_CHR_CODES = buildACCodes(&AC_CHROMINANCE_BITS, &AC_CHROMINANCE_VALS);
+
+fn buildDCCodes(bits: *const [16]u8) [12]HuffmanCode {
+ var codes: [12]HuffmanCode = undefined;
+ var code: u16 = 0;
+ var idx: usize = 0;
+
+ var bit_len: usize = 1;
+ while (bit_len <= 16) : (bit_len += 1) {
+ var i: usize = 0;
+ while (i < bits[bit_len - 1] and idx < 12) : (i += 1) {
+ codes[idx] = .{ .bits = @intCast(bit_len), .code = code };
+ code += 1;
+ idx += 1;
+ }
+ code <<= 1;
+ }
+
+ return codes;
+}
+
+fn buildACCodes(bits: *const [16]u8, vals: []const u8) [256]HuffmanCode {
+ var codes: [256]HuffmanCode = undefined;
+ for (0..256) |i| {
+ codes[i] = .{ .bits = 0, .code = 0 };
+ }
+
+ var code: u16 = 0;
+ var val_idx: usize = 0;
+
+ var bit_len: usize = 1;
+ while (bit_len <= 16) : (bit_len += 1) {
+ var i: usize = 0;
+ while (i < bits[bit_len - 1]) : (i += 1) {
+ if (val_idx < vals.len) {
+ const key = vals[val_idx];
+ codes[key] = .{ .bits = @intCast(bit_len), .code = code };
+ code += 1;
+ val_idx += 1;
+ }
+ }
+ code <<= 1;
+ }
+
+ return codes;
+}
+
+pub fn getCategory(value: i16) u8 {
+ if (value == 0) return 0;
+ const v: u16 = @intCast(@abs(value));
+ return 16 - @clz(v);
+}
+
+fn getAdditionalBits(value: i16, category: u8) u16 {
+ if (category == 0) return 0;
+ if (value >= 0) {
+ return @intCast(value);
+ } else {
+ return @intCast((value - 1) + (@as(i16, 1) << @intCast(category)));
+ }
+}
+
+pub fn encodeHuffmanDC(value: i16, is_luminance: bool, bw: *BitWriter) !void {
+ const category = getCategory(value);
+ const table = if (is_luminance) &DC_LUM_CODES else &DC_CHR_CODES;
+ const hc = table[category];
+ try bw.writeBits(hc.code, hc.bits);
+ if (category > 0) {
+ const additional = getAdditionalBits(value, category);
+ try bw.writeBits(additional, category);
+ }
+}
+
+pub fn encodeHuffmanAC(run_length: u4, value: i16, is_luminance: bool, bw: *BitWriter) !void {
+ if (run_length == 0 and value == 0) {
+ const table = if (is_luminance) &AC_LUM_CODES else &AC_CHR_CODES;
+ const hc = table[0x00];
+ try bw.writeBits(hc.code, hc.bits);
+ return;
+ }
+
+ if (run_length == 15 and value == 0) {
+ const table = if (is_luminance) &AC_LUM_CODES else &AC_CHR_CODES;
+ const hc = table[0xF0];
+ try bw.writeBits(hc.code, hc.bits);
+ return;
+ }
+
+ const category = getCategory(value);
+ const run_and_size: u8 = (@as(u8, run_length) << 4) | category;
+ const table = if (is_luminance) &AC_LUM_CODES else &AC_CHR_CODES;
+ const hc = table[run_and_size];
+ try bw.writeBits(hc.code, hc.bits);
+ if (category > 0) {
+ const additional = getAdditionalBits(value, category);
+ try bw.writeBits(additional, category);
+ }
+}
+
+test "encodeHuffmanDC luminance" {
+ var bw = BitWriter.init(std.testing.allocator);
+ defer bw.deinit();
+
+ try encodeHuffmanDC(5, true, &bw);
+ try bw.flush();
+ try std.testing.expect(bw.buffer.items.len > 0);
+}
+
+test "encodeHuffmanAC luminance" {
+ var bw = BitWriter.init(std.testing.allocator);
+ defer bw.deinit();
+
+ try encodeHuffmanAC(0, 5, true, &bw);
+ try bw.flush();
+ try std.testing.expect(bw.buffer.items.len > 0);
+}
+
+test "encodeHuffmanDC negative value" {
+ var bw1 = BitWriter.init(std.testing.allocator);
+ defer bw1.deinit();
+ try encodeHuffmanDC(5, true, &bw1);
+ try bw1.flush();
+
+ var bw2 = BitWriter.init(std.testing.allocator);
+ defer bw2.deinit();
+ try encodeHuffmanDC(-5, true, &bw2);
+ try bw2.flush();
+
+ try std.testing.expect(bw1.bitLength() != bw2.bitLength() or
+ bw1.buffer.items[0] != bw2.buffer.items[0]);
+}
+
+test "chrominance differs from luminance" {
+ var bw1 = BitWriter.init(std.testing.allocator);
+ defer bw1.deinit();
+ try encodeHuffmanDC(5, true, &bw1);
+ try bw1.flush();
+
+ var bw2 = BitWriter.init(std.testing.allocator);
+ defer bw2.deinit();
+ try encodeHuffmanDC(5, false, &bw2);
+ try bw2.flush();
+
+ try std.testing.expect(bw1.buffer.items[0] != bw2.buffer.items[0]);
+}
+
+test "EOB marker" {
+ var bw = BitWriter.init(std.testing.allocator);
+ defer bw.deinit();
+
+ try encodeHuffmanAC(0, 0, true, &bw);
+ try bw.flush();
+ try std.testing.expect(bw.buffer.items.len > 0);
+}
diff --git a/src/encoding/jpeg-file.ts b/src/encoding/jpeg-file.ts
deleted file mode 100644
index 2518df3..0000000
--- a/src/encoding/jpeg-file.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-const SOI: number[] = [0xFF, 0xD8];
-const EOI: number[] = [0xFF, 0xD9];
-
-export function createJPEGHeader(): number[] {
- return SOI;
-}
-
-export function createEOI(): number[] {
- return EOI;
-}
diff --git a/src/encoding/jpeg-writer.ts b/src/encoding/jpeg-writer.ts
deleted file mode 100644
index f181cae..0000000
--- a/src/encoding/jpeg-writer.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import { QuantizationMatrix } from '../types.js';
-import { getQuantizationMatrix } from '../core/quantization.js';
-
-export class JPEGWriter {
- private data: number[] = [];
-
- writeByte(value: number): void {
- this.data.push(value & 0xFF);
- }
-
- writeWord(value: number): void {
- this.data.push((value >> 8) & 0xFF);
- this.data.push(value & 0xFF);
- }
-
- writeMarker(marker: number): void {
- this.data.push(0xFF);
- this.data.push(marker);
- }
-
- writeSOI(): void {
- this.writeMarker(0xD8);
- }
-
- writeEOI(): void {
- this.writeMarker(0xD9);
- }
-
- writeAPP0(): void {
- this.writeMarker(0xE0);
- this.writeWord(16);
- this.writeByte(0x4A);
- this.writeByte(0x46);
- this.writeByte(0x49);
- this.writeByte(0x46);
- this.writeByte(0x00);
- this.writeWord(0x0101);
- this.writeByte(0x00);
- this.writeWord(0x0001);
- this.writeWord(0x0001);
- this.writeByte(0x00);
- this.writeByte(0x00);
- }
-
- writeDQT(quality: number): void {
- const lumQuant = getQuantizationMatrix(quality, true);
- const chrQuant = getQuantizationMatrix(quality, false);
-
- this.writeMarker(0xDB);
- this.writeWord(67);
- this.writeByte(0x00);
- for (let i = 0; i < 8; i++) {
- for (let j = 0; j < 8; j++) {
- this.writeByte(lumQuant[i][j]);
- }
- }
-
- this.writeMarker(0xDB);
- this.writeWord(67);
- this.writeByte(0x01);
- for (let i = 0; i < 8; i++) {
- for (let j = 0; j < 8; j++) {
- this.writeByte(chrQuant[i][j]);
- }
- }
- }
-
- writeSOF0(width: number, height: number): void {
- this.writeMarker(0xC0);
- this.writeWord(17);
- this.writeByte(0x08);
- this.writeWord(height);
- this.writeWord(width);
- this.writeByte(0x03);
-
- this.writeByte(0x01);
- this.writeByte(0x22);
- this.writeByte(0x00);
-
- this.writeByte(0x02);
- this.writeByte(0x11);
- this.writeByte(0x01);
-
- this.writeByte(0x03);
- this.writeByte(0x11);
- this.writeByte(0x01);
- }
-
- writeDHT(): void {
- this.writeMarker(0xC4);
- this.writeWord(0x01A2);
-
- const dcLumLengths = [0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0];
- const dcLumValues = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
-
- this.writeByte(0x00);
- for (const len of dcLumLengths) this.writeByte(len);
- for (const val of dcLumValues) this.writeByte(val);
- }
-
- writeSOS(): void {
- this.writeMarker(0xDA);
- this.writeWord(12);
- this.writeByte(0x03);
-
- this.writeByte(0x01);
- this.writeByte(0x00);
-
- this.writeByte(0x02);
- this.writeByte(0x11);
-
- this.writeByte(0x03);
- this.writeByte(0x11);
-
- this.writeByte(0x00);
- this.writeByte(0x3F);
- this.writeByte(0x00);
- }
-
- writeCompressedData(data: string): void {
- let byte = 0;
- let bitCount = 0;
-
- for (const bit of data) {
- byte = (byte << 1) | (bit === '1' ? 1 : 0);
- bitCount++;
-
- if (bitCount === 8) {
- this.writeByte(byte);
- if (byte === 0xFF) {
- this.writeByte(0x00);
- }
- byte = 0;
- bitCount = 0;
- }
- }
-
- if (bitCount > 0) {
- byte <<= (8 - bitCount);
- this.writeByte(byte);
- if (byte === 0xFF) {
- this.writeByte(0x00);
- }
- }
- }
-
- getBuffer(): Uint8Array {
- return new Uint8Array(this.data);
- }
-}
diff --git a/src/encoding/jpeg_writer.zig b/src/encoding/jpeg_writer.zig
new file mode 100644
index 0000000..fd96263
--- /dev/null
+++ b/src/encoding/jpeg_writer.zig
@@ -0,0 +1,208 @@
+const std = @import("std");
+const quantization = @import("../core/quantization.zig");
+const huffman = @import("huffman.zig");
+
+pub const JpegWriter = struct {
+ buffer: std.ArrayList(u8),
+
+ pub fn init(allocator: std.mem.Allocator) JpegWriter {
+ return .{
+ .buffer = std.ArrayList(u8).init(allocator),
+ };
+ }
+
+ pub fn deinit(self: *JpegWriter) void {
+ self.buffer.deinit();
+ }
+
+ pub fn writeWord(self: *JpegWriter, value: u16) !void {
+ try self.buffer.append(@intCast((value >> 8) & 0xFF));
+ try self.buffer.append(@intCast(value & 0xFF));
+ }
+
+ pub fn writeMarker(self: *JpegWriter, marker: u8) !void {
+ try self.buffer.append(0xFF);
+ try self.buffer.append(marker);
+ }
+
+ pub fn writeSOI(self: *JpegWriter) !void {
+ try self.writeMarker(0xD8);
+ }
+
+ pub fn writeEOI(self: *JpegWriter) !void {
+ try self.writeMarker(0xD9);
+ }
+
+ pub fn writeAPP0(self: *JpegWriter) !void {
+ try self.writeMarker(0xE0);
+ try self.writeWord(16);
+ try self.buffer.appendSlice("JFIF");
+ try self.buffer.append(0);
+ try self.buffer.append(1);
+ try self.buffer.append(1);
+ try self.buffer.append(0);
+ try self.writeWord(1);
+ try self.writeWord(1);
+ try self.buffer.append(0);
+ try self.buffer.append(0);
+ }
+
+ pub fn writeDQT(self: *JpegWriter, quality: u8) !void {
+ const lum_matrix = quantization.getQuantizationMatrix(quality, true);
+ const chr_matrix = quantization.getQuantizationMatrix(quality, false);
+
+ try self.writeDQTWithMatrices(&lum_matrix, &chr_matrix);
+ }
+
+ pub fn writeDQTWithMatrices(self: *JpegWriter, lum_matrix: *const [8][8]u32, chr_matrix: *const [8][8]u32) !void {
+ try self.writeMarker(0xDB);
+ try self.writeWord(132);
+
+ try self.buffer.append(0x00);
+ for (0..8) |i| {
+ for (0..8) |j| {
+ try self.buffer.append(@intCast(lum_matrix[i][j]));
+ }
+ }
+
+ try self.buffer.append(0x01);
+ for (0..8) |i| {
+ for (0..8) |j| {
+ try self.buffer.append(@intCast(chr_matrix[i][j]));
+ }
+ }
+ }
+
+ pub fn writeSOF0(self: *JpegWriter, width: u16, height: u16) !void {
+ try self.writeSOF0WithSampling(width, height, 0x11, 0x11, 0x11);
+ }
+
+ pub fn writeSOF0WithSampling(self: *JpegWriter, width: u16, height: u16, y_samp: u8, cb_samp: u8, cr_samp: u8) !void {
+ try self.writeMarker(0xC0);
+ try self.writeWord(17);
+ try self.buffer.append(8);
+ try self.writeWord(height);
+ try self.writeWord(width);
+ try self.buffer.append(3);
+
+ try self.buffer.append(1);
+ try self.buffer.append(y_samp);
+ try self.buffer.append(0);
+
+ try self.buffer.append(2);
+ try self.buffer.append(cb_samp);
+ try self.buffer.append(1);
+
+ try self.buffer.append(3);
+ try self.buffer.append(cr_samp);
+ try self.buffer.append(1);
+ }
+
+ fn writeDHTTable(self: *JpegWriter, class_and_id: u8, bits: *const [16]u8, values: []const u8) !void {
+ try self.buffer.append(class_and_id);
+ for (bits) |b| {
+ try self.buffer.append(b);
+ }
+ for (values) |v| {
+ try self.buffer.append(v);
+ }
+ }
+
+ pub fn writeDHT(self: *JpegWriter) !void {
+ const dc_lum_len: u16 = 1 + 16 + @as(u16, huffman.DC_LUMINANCE_VALS.len);
+ const dc_chr_len: u16 = 1 + 16 + @as(u16, huffman.DC_CHROMINANCE_VALS.len);
+ const ac_lum_len: u16 = 1 + 16 + @as(u16, huffman.AC_LUMINANCE_VALS.len);
+ const ac_chr_len: u16 = 1 + 16 + @as(u16, huffman.AC_CHROMINANCE_VALS.len);
+ const total_len: u16 = 2 + dc_lum_len + dc_chr_len + ac_lum_len + ac_chr_len;
+
+ try self.writeMarker(0xC4);
+ try self.writeWord(total_len);
+
+ try self.writeDHTTable(0x00, &huffman.DC_LUMINANCE_BITS, &huffman.DC_LUMINANCE_VALS);
+ try self.writeDHTTable(0x01, &huffman.DC_CHROMINANCE_BITS, &huffman.DC_CHROMINANCE_VALS);
+ try self.writeDHTTable(0x10, &huffman.AC_LUMINANCE_BITS, &huffman.AC_LUMINANCE_VALS);
+ try self.writeDHTTable(0x11, &huffman.AC_CHROMINANCE_BITS, &huffman.AC_CHROMINANCE_VALS);
+ }
+
+ pub fn writeSOS(self: *JpegWriter) !void {
+ try self.writeMarker(0xDA);
+ try self.writeWord(12);
+ try self.buffer.append(3);
+
+ try self.buffer.append(1);
+ try self.buffer.append(0x00);
+
+ try self.buffer.append(2);
+ try self.buffer.append(0x11);
+
+ try self.buffer.append(3);
+ try self.buffer.append(0x11);
+
+ try self.buffer.append(0);
+ try self.buffer.append(63);
+ try self.buffer.append(0);
+ }
+
+ pub fn writeCompressedData(self: *JpegWriter, data: []const u8) !void {
+ for (data) |byte| {
+ try self.buffer.append(byte);
+ if (byte == 0xFF) {
+ try self.buffer.append(0x00);
+ }
+ }
+ }
+
+ pub fn getBuffer(self: *const JpegWriter) []const u8 {
+ return self.buffer.items;
+ }
+};
+
+test "JpegWriter SOI + EOI" {
+ var writer = JpegWriter.init(std.testing.allocator);
+ defer writer.deinit();
+
+ try writer.writeSOI();
+ try writer.writeEOI();
+ try std.testing.expectEqual(@as(usize, 4), writer.buffer.items.len);
+ try std.testing.expectEqual(@as(u8, 0xFF), writer.buffer.items[0]);
+ try std.testing.expectEqual(@as(u8, 0xD8), writer.buffer.items[1]);
+ try std.testing.expectEqual(@as(u8, 0xFF), writer.buffer.items[2]);
+ try std.testing.expectEqual(@as(u8, 0xD9), writer.buffer.items[3]);
+}
+
+test "JpegWriter writeWord big-endian" {
+ var writer = JpegWriter.init(std.testing.allocator);
+ defer writer.deinit();
+
+ try writer.writeWord(0x1234);
+ try std.testing.expectEqual(@as(u8, 0x12), writer.buffer.items[0]);
+ try std.testing.expectEqual(@as(u8, 0x34), writer.buffer.items[1]);
+}
+
+test "JpegWriter full header" {
+ var writer = JpegWriter.init(std.testing.allocator);
+ defer writer.deinit();
+
+ try writer.writeSOI();
+ try writer.writeAPP0();
+ try writer.writeDQT(75);
+ try writer.writeSOF0(64, 64);
+ try writer.writeDHT();
+ try writer.writeSOS();
+ try writer.writeEOI();
+
+ try std.testing.expect(writer.buffer.items.len > 100);
+ try std.testing.expectEqual(@as(u8, 0xFF), writer.buffer.items[0]);
+ try std.testing.expectEqual(@as(u8, 0xD8), writer.buffer.items[1]);
+}
+
+test "JpegWriter compressed data byte stuffing" {
+ var writer = JpegWriter.init(std.testing.allocator);
+ defer writer.deinit();
+
+ const data = [_]u8{ 0x00, 0xFF, 0x42, 0xFF, 0x00 };
+ try writer.writeCompressedData(&data);
+ try std.testing.expectEqual(@as(usize, 7), writer.buffer.items.len);
+ try std.testing.expectEqual(@as(u8, 0xFF), writer.buffer.items[1]);
+ try std.testing.expectEqual(@as(u8, 0x00), writer.buffer.items[2]);
+}
diff --git a/src/image-processing/image-loader.ts b/src/image-processing/image-loader.ts
deleted file mode 100644
index 38827e1..0000000
--- a/src/image-processing/image-loader.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import sharp from 'sharp';
-import { RGBAPixel } from '../types.js';
-
-export function loadImageData(imagePath: string, callback: (imageData: RGBAPixel[]) => void): void {
- sharp(imagePath)
- .raw()
- .toBuffer()
- .then((data: Buffer) => {
- if (!data || data.length === 0) {
- throw new Error('Image data is empty');
- }
-
- const imageData: RGBAPixel[] = [];
-
- for (let i = 0; i < data.length; i += 4) {
- imageData.push([data[i], data[i + 1], data[i + 2], data[i + 3]]);
- }
-
- callback(imageData);
- })
- .catch((err: Error) => {
- throw new Error(`Failed to load image: ${err.message}`);
- });
-}
diff --git a/src/image-processing/ycbcr-converter.ts b/src/image-processing/ycbcr-converter.ts
deleted file mode 100644
index ebde1e2..0000000
--- a/src/image-processing/ycbcr-converter.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { RGBAPixel, YCbCrPixel, YCbCrImage } from '../types.js';
-
-export function convertImageToYCbCr(data: RGBAPixel[]): YCbCrImage {
- if (!data || data.length === 0) {
- throw new Error('Invalid image data');
- }
-
- const width = 256;
- const height = Math.floor(data.length / width);
- const yCbCrImage: YCbCrPixel[][] = [];
-
- for (let i = 0; i < data.length; i++) {
- const [r, g, b] = data[i];
- const y = 0.299 * r + 0.587 * g + 0.114 * b;
- const cb = -0.169 * r - 0.331 * g + 0.499 * b + 128;
- const cr = 0.499 * r - 0.419 * g - 0.0813 * b + 128;
-
- if (i % width === 0) {
- yCbCrImage.push([]);
- }
-
- yCbCrImage[Math.floor(i / width)].push([y, cb, cr]);
- }
-
- return { yCbCrData: yCbCrImage, width, height };
-}
diff --git a/src/index.ts b/src/index.ts
deleted file mode 100644
index 9709588..0000000
--- a/src/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export * from './api.js';
-export { JPEGWriter } from './encoding/jpeg-writer.js';
-export { JPEGDecoder } from './decoder.js';
-export { dct2D, idct2D, fastDCT } from './core/dct.js';
-export { quantizeBlock, dequantizeBlock, getQuantizationMatrix } from './core/quantization.js';
-export { zigzagEncode, zigzagDecode, runLengthEncode } from './core/zigzag.js';
-export { encodeHuffmanDC, encodeHuffmanAC, generateHuffmanTable } from './encoding/huffman.js';
diff --git a/src/integration_tests.zig b/src/integration_tests.zig
new file mode 100644
index 0000000..6cb9d66
--- /dev/null
+++ b/src/integration_tests.zig
@@ -0,0 +1,3242 @@
+const std = @import("std");
+const root = @import("root.zig");
+
+const types = root.types;
+const encoder = root.encoder;
+const decoder = root.decoder;
+const dct = root.core.dct;
+const quantization = root.core.quantization;
+const zigzag_mod = root.core.zigzag;
+const color_space = root.core.color_space;
+const block_processor = root.core.block_processor;
+const huffman = root.encoding.huffman;
+const BitWriter = root.encoding.bitstream.BitWriter;
+const JpegWriter = root.encoding.jpeg_writer.JpegWriter;
+
+const testing = std.testing;
+const allocator = testing.allocator;
+
+fn makeGradientPixels(w: u32, h: u32) ![]types.RGBAPixel {
+ const ww: usize = @intCast(w);
+ const hh: usize = @intCast(h);
+ const total: usize = ww * hh;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ for (0..total) |i| {
+ const x = i % ww;
+ const y = i / ww;
+ pixels[i] = .{
+ .r = @intCast(x * 255 / ww),
+ .g = @intCast(y * 255 / hh),
+ .b = @intCast((x + y) * 127 / (ww + hh)),
+ };
+ }
+ return pixels;
+}
+
+fn makeFlatPixels(w: u32, h: u32, r: u8, g: u8, b: u8) ![]types.RGBAPixel {
+ const total: usize = @as(usize, @intCast(w)) * @as(usize, @intCast(h));
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ for (0..total) |i| {
+ pixels[i] = .{ .r = r, .g = g, .b = b };
+ }
+ return pixels;
+}
+
+fn makeRandomPixels(w: u32, h: u32, seed: u64) ![]types.RGBAPixel {
+ const total: usize = @as(usize, @intCast(w)) * @as(usize, @intCast(h));
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ var rng = std.Random.DefaultPrng.init(seed);
+ for (0..total) |i| {
+ pixels[i] = .{
+ .r = rng.random().int(u8),
+ .g = rng.random().int(u8),
+ .b = rng.random().int(u8),
+ };
+ }
+ return pixels;
+}
+
+const JPEG_HEADER_SIZE = 2 + 2 + 16 + 2 + 17 + 2 + 2 + 2 + 434 + 2 + 12 + 4;
+
+fn validateJpegStructure(buf: []const u8, expect_w: u32, expect_h: u32) !void {
+ try testing.expect(buf.len > 50);
+ try testing.expectEqual(@as(u8, 0xFF), buf[0]);
+ try testing.expectEqual(@as(u8, 0xD8), buf[1]);
+
+ var pos: usize = 2;
+ while (pos + 1 < buf.len) {
+ if (buf[pos] != 0xFF) {
+ break;
+ }
+ const marker = buf[pos + 1];
+ pos += 2;
+
+ if (marker == 0xD9) break;
+
+ if (marker == 0x00 or (marker >= 0xD0 and marker <= 0xD7)) {
+ continue;
+ }
+
+ if (pos + 1 >= buf.len) break;
+ const seg_len = (@as(u16, buf[pos]) << 8) | buf[pos + 1];
+ pos += 2;
+
+ if (marker == 0xC0) {
+ try testing.expectEqual(@as(u8, 8), buf[pos]);
+ const h = (@as(u16, buf[pos + 1]) << 8) | buf[pos + 2];
+ const w = (@as(u16, buf[pos + 3]) << 8) | buf[pos + 4];
+ try testing.expectEqual(expect_w, w);
+ try testing.expectEqual(expect_h, h);
+ try testing.expectEqual(@as(u8, 3), buf[pos + 5]);
+ }
+
+ pos += @intCast(seg_len - 2);
+ }
+ try testing.expectEqual(@as(u8, 0xFF), buf[buf.len - 2]);
+ try testing.expectEqual(@as(u8, 0xD9), buf[buf.len - 1]);
+}
+
+// ========== E2E ENCODE PIPELINE TESTS ==========
+
+test "E2E: encode 8x8 gradient image" {
+ const pixels = try makeGradientPixels(8, 8);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75 });
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 8, 8);
+ try testing.expectEqual(@as(u32, 8), result.width);
+ try testing.expectEqual(@as(u32, 8), result.height);
+ try testing.expectEqual(@as(u8, 75), result.quality);
+}
+
+test "E2E: encode 16x16 flat red image" {
+ const pixels = try makeFlatPixels(16, 16, 255, 0, 0);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 50 });
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 16, 16);
+ try testing.expect(result.buffer.len > 50);
+}
+
+test "E2E: encode 64x64 random noise" {
+ const pixels = try makeRandomPixels(64, 64, 0xDEADBEEF);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 85 });
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 64, 64);
+}
+
+test "E2E: encode 128x128 gradient with all qualities" {
+ const pixels = try makeGradientPixels(128, 128);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 128, .height = 128, .pixels = pixels };
+
+ var prev_size: usize = 0;
+ var q: u8 = 10;
+ while (q <= 100) : (q += 10) {
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = q });
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 128, 128);
+ if (prev_size > 0) {
+ try testing.expect(result.buffer.len >= prev_size);
+ }
+ prev_size = result.buffer.len;
+ }
+}
+
+test "E2E: encode 256x256 fast mode vs standard" {
+ const pixels = try makeRandomPixels(256, 256, 42);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 256, .height = 256, .pixels = pixels };
+
+ var fast = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75, .fast_mode = true });
+ defer fast.deinit(allocator);
+ var std_ = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75, .fast_mode = false });
+ defer std_.deinit(allocator);
+
+ try validateJpegStructure(fast.buffer, 256, 256);
+ try validateJpegStructure(std_.buffer, 256, 256);
+ try testing.expect(fast.buffer.len == std_.buffer.len);
+}
+
+test "E2E: encode with preset 'web'" {
+ const pixels = try makeFlatPixels(16, 16, 100, 200, 50);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .preset = "web" });
+ defer result.deinit(allocator);
+
+ try testing.expectEqual(@as(u8, 75), result.quality);
+ try validateJpegStructure(result.buffer, 16, 16);
+}
+
+test "E2E: encode with preset 'print'" {
+ const pixels = try makeFlatPixels(16, 16, 100, 200, 50);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .preset = "print" });
+ defer result.deinit(allocator);
+
+ try testing.expectEqual(@as(u8, 90), result.quality);
+ try validateJpegStructure(result.buffer, 16, 16);
+}
+
+// ========== JPEG STRUCTURE VALIDATION ==========
+
+test "JPEG structure: APP0 contains JFIF" {
+ const pixels = try makeFlatPixels(8, 8, 128, 128, 128);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ try testing.expectEqual(@as(u8, 'J'), result.buffer[6]);
+ try testing.expectEqual(@as(u8, 'F'), result.buffer[7]);
+ try testing.expectEqual(@as(u8, 'I'), result.buffer[8]);
+ try testing.expectEqual(@as(u8, 'F'), result.buffer[9]);
+ try testing.expectEqual(@as(u8, 0), result.buffer[10]);
+}
+
+test "JPEG structure: DQT segment present" {
+ const pixels = try makeFlatPixels(8, 8, 128, 128, 128);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ var found_dqt = false;
+ var i: usize = 2;
+ while (i + 1 < result.buffer.len) {
+ if (result.buffer[i] == 0xFF and result.buffer[i + 1] == 0xDB) {
+ found_dqt = true;
+ break;
+ }
+ i += 1;
+ }
+ try testing.expect(found_dqt);
+}
+
+test "JPEG structure: SOF0 segment present" {
+ const pixels = try makeFlatPixels(8, 8, 128, 128, 128);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ var found_sof = false;
+ var i: usize = 2;
+ while (i + 1 < result.buffer.len) {
+ if (result.buffer[i] == 0xFF and result.buffer[i + 1] == 0xC0) {
+ found_sof = true;
+ break;
+ }
+ i += 1;
+ }
+ try testing.expect(found_sof);
+}
+
+test "JPEG structure: DHT segment present" {
+ const pixels = try makeFlatPixels(8, 8, 128, 128, 128);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ var found_dht = false;
+ var i: usize = 2;
+ while (i + 1 < result.buffer.len) {
+ if (result.buffer[i] == 0xFF and result.buffer[i + 1] == 0xC4) {
+ found_dht = true;
+ break;
+ }
+ i += 1;
+ }
+ try testing.expect(found_dht);
+}
+
+test "JPEG structure: SOS segment present" {
+ const pixels = try makeFlatPixels(8, 8, 128, 128, 128);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ var found_sos = false;
+ var i: usize = 2;
+ while (i + 1 < result.buffer.len) {
+ if (result.buffer[i] == 0xFF and result.buffer[i + 1] == 0xDA) {
+ found_sos = true;
+ break;
+ }
+ i += 1;
+ }
+ try testing.expect(found_sos);
+}
+
+test "JPEG structure: ends with EOI" {
+ const pixels = try makeFlatPixels(8, 8, 128, 128, 128);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ try testing.expectEqual(@as(u8, 0xFF), result.buffer[result.buffer.len - 2]);
+ try testing.expectEqual(@as(u8, 0xD9), result.buffer[result.buffer.len - 1]);
+}
+
+test "JPEG structure: correct dimensions in SOF0" {
+ const pixels = try makeFlatPixels(32, 24, 100, 150, 200);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 24, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 32, 24);
+}
+
+// ========== DECODER TESTS ==========
+
+test "Decoder: rejects non-JPEG data" {
+ const data = [_]u8{ 0x00, 0x01, 0x02, 0x03 };
+ try testing.expectError(error.InvalidSOI, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "Decoder: accepts valid JPEG SOI+EOI" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xD9 };
+ try testing.expectError(error.InvalidScanData, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "Round-trip: encode then decode 8x8 flat image" {
+ const pixels = try makeFlatPixels(8, 8, 128, 64, 200);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 100 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 8), decoded.width);
+ try testing.expectEqual(@as(u32, 8), decoded.height);
+
+ for (0..64) |i| {
+ const err_r = @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ const err_g = @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ const err_b = @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ try testing.expect(err_r <= 2);
+ try testing.expect(err_g <= 2);
+ try testing.expect(err_b <= 2);
+ }
+}
+
+test "Round-trip: encode then decode 16x16 gradient" {
+ const pixels = try makeGradientPixels(16, 16);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 95 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 16), decoded.width);
+ try testing.expectEqual(@as(u32, 16), decoded.height);
+
+ for (0..256) |i| {
+ const err_r = @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ const err_g = @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ const err_b = @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ try testing.expect(err_r <= 5);
+ try testing.expect(err_g <= 5);
+ try testing.expect(err_b <= 5);
+ }
+}
+
+test "Round-trip: encode then decode 32x32 random" {
+ const pixels = try makeRandomPixels(32, 32, 42);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 85 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 32), decoded.width);
+ try testing.expectEqual(@as(u32, 32), decoded.height);
+
+ var total_err: u64 = 0;
+ for (0..1024) |i| {
+ total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ }
+ const avg_err = @as(f64, @floatFromInt(total_err)) / (1024.0 * 3.0);
+ try testing.expect(avg_err < 15.0);
+}
+
+// ========== STRESS / EDGE CASE TESTS ==========
+
+test "Stress: encode all-black 256x256" {
+ const pixels = try makeFlatPixels(256, 256, 0, 0, 0);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 256, .height = 256, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 50 });
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 256, 256);
+}
+
+test "Stress: encode all-white 256x256" {
+ const pixels = try makeFlatPixels(256, 256, 255, 255, 255);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 256, .height = 256, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 50 });
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 256, 256);
+}
+
+test "Stress: encode quality=1 (minimum)" {
+ const pixels = try makeRandomPixels(32, 32, 99);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 1 });
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 32, 32);
+}
+
+test "Stress: encode quality=100 (maximum)" {
+ const pixels = try makeRandomPixels(32, 32, 99);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 100 });
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 32, 32);
+}
+
+test "Stress: non-multiple-of-8 dimensions (9x9)" {
+ const pixels = try makeRandomPixels(9, 9, 55);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 9, .height = 9, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ try testing.expect(result.buffer.len > 100);
+ try testing.expectEqual(@as(u8, 0xD8), result.buffer[1]);
+}
+
+test "Stress: encode checkerboard pattern 64x64" {
+ const total: usize = 64 * 64;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ const x = i % 64;
+ const y = i / 64;
+ const is_white = ((x / 8) + (y / 8)) % 2 == 0;
+ if (is_white) {
+ pixels[i] = .{ .r = 255, .g = 255, .b = 255 };
+ } else {
+ pixels[i] = .{ .r = 0, .g = 0, .b = 0 };
+ }
+ }
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75 });
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 64, 64);
+}
+
+test "Stress: encode alternating scanlines 128x128" {
+ const total: usize = 128 * 128;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ const y = i / 128;
+ if (y % 2 == 0) {
+ pixels[i] = .{ .r = 200, .g = 50, .b = 50 };
+ } else {
+ pixels[i] = .{ .r = 50, .g = 50, .b = 200 };
+ }
+ }
+
+ var img = types.ImageData{ .width = 128, .height = 128, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 90 });
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 128, 128);
+}
+
+test "Stress: small 8x8 gradient" {
+ const pixels = try makeGradientPixels(8, 8);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ try validateJpegStructure(result.buffer, 8, 8);
+}
+
+// ========== DETERMINISM / REGRESSION TESTS ==========
+
+test "Regression: same input produces same output" {
+ const pixels = try makeGradientPixels(32, 32);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+
+ var r1 = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75 });
+ defer r1.deinit(allocator);
+ var r2 = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75 });
+ defer r2.deinit(allocator);
+
+ try testing.expectEqual(r1.buffer.len, r2.buffer.len);
+ try testing.expect(std.mem.eql(u8, r1.buffer, r2.buffer));
+}
+
+test "Regression: quality 100 output is always larger than quality 10" {
+ const pixels = try makeRandomPixels(32, 32, 77);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var low = try encoder.encodeJPEG(allocator, &img, .{ .quality = 10 });
+ defer low.deinit(allocator);
+ var high = try encoder.encodeJPEG(allocator, &img, .{ .quality = 100 });
+ defer high.deinit(allocator);
+
+ try testing.expect(low.buffer.len < high.buffer.len);
+}
+
+// ========== MEMORY LEAK TESTS ==========
+
+test "Memory: no leaks in encode pipeline" {
+ const pixels = try makeRandomPixels(64, 64, 123);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ try testing.expect(result.buffer.len > 0);
+}
+
+test "Memory: no leaks in multiple encode cycles" {
+ var i: u32 = 0;
+ while (i < 10) : (i += 1) {
+ const pixels = try makeFlatPixels(16, 16, @intCast(i * 25), 128, 64);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = @intCast(50 + i * 5) });
+ defer result.deinit(allocator);
+ }
+}
+
+// ========== COMPONENT INTEGRATION TESTS ==========
+
+test "Integration: color_space -> block_processor -> dct -> quant -> zigzag -> huffman" {
+ const pixels = try makeRandomPixels(16, 16, 42);
+ defer allocator.free(pixels);
+
+ var ycbcr = try color_space.convertImageToYCbCr(pixels, 16, 16, allocator);
+ defer ycbcr.deinit(allocator);
+
+ var blocks = try block_processor.splitIntoBlocks(&ycbcr, allocator);
+ defer blocks.deinit(allocator);
+
+ try testing.expectEqual(@as(usize, 4), blocks.y_blocks.len);
+
+ const dct_out = dct.dct2D(&blocks.y_blocks[0]);
+ const q_out = quantization.quantizeBlock(&dct_out, 75, true);
+ const zz_out = zigzag_mod.zigzagEncode(&q_out);
+
+ try testing.expectEqual(@as(usize, 64), zz_out.len);
+
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try huffman.encodeHuffmanDC(@intFromFloat(zz_out[0]), true, &bw);
+
+ var rle: [64]zigzag_mod.RLEPair = undefined;
+ const rle_count = zigzag_mod.runLengthEncode(&zz_out, &rle);
+ for (0..rle_count) |j| {
+ try huffman.encodeHuffmanAC(rle[j].run, rle[j].value, true, &bw);
+ }
+ try bw.flush();
+
+ try testing.expect(bw.buffer.items.len > 0);
+}
+
+test "Integration: quantize -> dequantize -> idct error bounded" {
+ var block: types.Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(i * 32 + j * 8 + 16);
+ }
+ }
+
+ const dct_out = dct.dct2D(&block);
+ const q_out = quantization.quantizeBlock(&dct_out, 75, true);
+ const dq_out = quantization.dequantizeBlock(&q_out, 75, true);
+ const idct_out = dct.idct2D(&dq_out);
+
+ var max_err: f64 = 0.0;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ const err = @abs(block[i][j] - idct_out[i][j]);
+ if (err > max_err) max_err = err;
+ }
+ }
+ try testing.expect(max_err < 30.0);
+}
+
+// ========== BITSTREAM CORRECTNESS TESTS ==========
+
+test "BitStream: write 1 bit at a time, verify byte packing" {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try bw.writeBits(1, 1);
+ try bw.writeBits(0, 1);
+ try bw.writeBits(1, 1);
+ try bw.writeBits(0, 1);
+ try bw.writeBits(1, 1);
+ try bw.writeBits(0, 1);
+ try bw.writeBits(1, 1);
+ try bw.writeBits(0, 1);
+ try bw.flush();
+
+ try testing.expectEqual(@as(usize, 1), bw.buffer.items.len);
+ try testing.expectEqual(@as(u8, 0xAA), bw.buffer.items[0]);
+}
+
+test "BitStream: 17 bits spans 3 bytes" {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try bw.writeBits(0x1FFFF, 17);
+ try bw.flush();
+
+ try testing.expectEqual(@as(usize, 3), bw.buffer.items.len);
+ try testing.expectEqual(@as(u8, 0xFF), bw.buffer.items[0]);
+ try testing.expectEqual(@as(u8, 0xFF), bw.buffer.items[1]);
+ try testing.expectEqual(@as(u8, 0xFF), bw.buffer.items[2]);
+}
+
+test "BitStream: bitLength tracking" {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try testing.expectEqual(@as(usize, 0), bw.bitLength());
+ try bw.writeBits(0xFF, 8);
+ try testing.expectEqual(@as(usize, 8), bw.bitLength());
+ try bw.writeBits(0x03, 2);
+ try testing.expectEqual(@as(usize, 10), bw.bitLength());
+ try bw.flush();
+ try testing.expectEqual(@as(usize, 16), bw.bitLength());
+}
+
+// ========== HUFFMAN TABLE VERIFICATION ==========
+
+test "Huffman: DC category 0 encodes to known bit pattern" {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try huffman.encodeHuffmanDC(0, true, &bw);
+ try bw.flush();
+
+ try testing.expect(bw.buffer.items.len > 0);
+ try testing.expectEqual(@as(u8, 0x3F), bw.buffer.items[0]);
+}
+
+test "Huffman: AC EOB (0,0) encodes correctly" {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try huffman.encodeHuffmanAC(0, 0, true, &bw);
+ try bw.flush();
+
+ try testing.expect(bw.buffer.items.len > 0);
+}
+
+test "Huffman: all 12 DC categories produce non-empty output" {
+ var cat: i16 = 0;
+ while (cat <= 11) : (cat += 1) {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try huffman.encodeHuffmanDC(cat, true, &bw);
+ try bw.flush();
+ try testing.expect(bw.buffer.items.len > 0);
+ }
+}
+
+// ========== PROGRESS CALLBACK TESTS ==========
+
+test "Progress: callback fires for each stage" {
+ const State = struct {
+ var stages: [8][]const u8 = undefined;
+ var count: usize = 0;
+
+ fn callback(progress: f32, stage: []const u8) void {
+ if (count < 8) {
+ stages[count] = stage;
+ }
+ count += 1;
+ _ = progress;
+ }
+ };
+ State.count = 0;
+
+ const pixels = try makeFlatPixels(8, 8, 128, 128, 128);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .on_progress = &State.callback });
+ defer result.deinit(allocator);
+
+ try testing.expect(State.count > 0);
+}
+
+// ========== QUANTIZATION MATRIX PROPERTY TESTS ==========
+
+test "Quantization: quality 50 uses standard table" {
+ const matrix = quantization.getQuantizationMatrix(50, true);
+ try testing.expectEqual(@as(u32, 16), matrix[0][0]);
+ try testing.expectEqual(@as(u32, 11), matrix[0][1]);
+ try testing.expectEqual(@as(u32, 10), matrix[0][2]);
+}
+
+test "Quantization: all quality levels produce valid matrices" {
+ var q: u8 = 1;
+ while (q <= 100) : (q += 1) {
+ const lum = quantization.getQuantizationMatrix(q, true);
+ const chr = quantization.getQuantizationMatrix(q, false);
+ for (0..8) |i| {
+ for (0..8) |j| {
+ try testing.expect(lum[i][j] >= 1);
+ try testing.expect(lum[i][j] <= 255);
+ try testing.expect(chr[i][j] >= 1);
+ try testing.expect(chr[i][j] <= 255);
+ }
+ }
+ }
+}
+
+test "Quantization: higher quality means smaller quantization values" {
+ var prev_max: u32 = 0;
+ var q: u8 = 95;
+ while (q >= 5) : (q -|= 5) {
+ const matrix = quantization.getQuantizationMatrix(q, true);
+ var block_max: u32 = 0;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ if (matrix[i][j] > block_max) block_max = matrix[i][j];
+ }
+ }
+ if (prev_max > 0) {
+ try testing.expect(block_max >= prev_max);
+ }
+ prev_max = block_max;
+ if (q <= 5) break;
+ }
+}
+
+// ========== ZIGZAG PROPERTY TESTS ==========
+
+test "Zigzag: first element is always [0][0]" {
+ var block: types.Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(i * 8 + j);
+ }
+ }
+ const encoded = zigzag_mod.zigzagEncode(&block);
+ try testing.expectApproxEqAbs(@as(f64, 0.0), encoded[0], 0.001);
+}
+
+test "Zigzag: encode then decode is identity" {
+ var block: types.Block8x8 = undefined;
+ var seed: u64 = 1337;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ seed = seed *% 6364136223846793005 +% 1442695040888963407;
+ block[i][j] = @floatFromInt(@as(u64, @intCast(seed >> 33)) % 1000);
+ }
+ }
+ const encoded = zigzag_mod.zigzagEncode(&block);
+ const decoded = zigzag_mod.zigzagDecode(&encoded);
+ for (0..8) |i| {
+ for (0..8) |j| {
+ try testing.expectApproxEqAbs(block[i][j], decoded[i][j], 0.001);
+ }
+ }
+}
+
+// ========== BLOCK PROCESSOR TESTS ==========
+
+test "Block processor: 32x32 produces 16 blocks" {
+ const total: usize = 32 * 32;
+ var pixels: [1024]types.YCbCrPixel = undefined;
+ for (0..total) |i| {
+ pixels[i] = .{ .y = 128.0, .cb = 128.0, .cr = 128.0 };
+ }
+ var img = types.YCbCrImage{ .width = 32, .height = 32, .pixels = &pixels };
+ var blocks = try block_processor.splitIntoBlocks(&img, allocator);
+ defer blocks.deinit(allocator);
+
+ try testing.expectEqual(@as(usize, 16), blocks.y_blocks.len);
+ try testing.expectEqual(@as(usize, 16), blocks.cb_blocks.len);
+ try testing.expectEqual(@as(usize, 16), blocks.cr_blocks.len);
+}
+
+test "Block processor: all three channels populated" {
+ var pixels: [64]types.YCbCrPixel = undefined;
+ for (0..64) |i| {
+ pixels[i] = .{ .y = @floatFromInt(i), .cb = @floatFromInt(i + 100), .cr = @floatFromInt(i + 200) };
+ }
+ var img = types.YCbCrImage{ .width = 8, .height = 8, .pixels = &pixels };
+ var blocks = try block_processor.splitIntoBlocks(&img, allocator);
+ defer blocks.deinit(allocator);
+
+ try testing.expectApproxEqAbs(@as(f64, 0.0), blocks.y_blocks[0][0][0], 0.001);
+ try testing.expectApproxEqAbs(@as(f64, 100.0), blocks.cb_blocks[0][0][0], 0.001);
+ try testing.expectApproxEqAbs(@as(f64, 200.0), blocks.cr_blocks[0][0][0], 0.001);
+}
+
+// ========== COLOR SPACE TESTS ==========
+
+test "Color: RGB primaries map correctly" {
+ const red = color_space.rgbToYCbCr(255, 0, 0);
+ try testing.expect(red.y > 50);
+ try testing.expect(red.cb < 128);
+ try testing.expect(red.cr > 128);
+
+ const green = color_space.rgbToYCbCr(0, 255, 0);
+ try testing.expect(green.y > 50);
+
+ const blue = color_space.rgbToYCbCr(0, 0, 255);
+ try testing.expect(blue.y > 0);
+ try testing.expect(blue.cb > 128);
+ try testing.expect(blue.cr < 128);
+}
+
+test "Color: gray values have neutral chroma" {
+ var g: u16 = 0;
+ while (g <= 255) : (g += 17) {
+ const px = color_space.rgbToYCbCr(@intCast(g), @intCast(g), @intCast(g));
+ try testing.expectApproxEqAbs(px.cb, 128.0, 2.0);
+ try testing.expectApproxEqAbs(px.cr, 128.0, 2.0);
+ }
+}
+
+// ========== DEEP VERIFICATION: SIGN EXTENSION ==========
+// These tests specifically exercise negative coefficients to verify the
+// decoder correctly inverts the encoder's (value-1) + (1<= 90) {
+ var total_err: u64 = 0;
+ for (0..256) |i| {
+ total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ }
+ const avg_err = @as(f64, @floatFromInt(total_err)) / (256.0 * 3.0);
+ try testing.expect(avg_err < 10.0);
+ }
+ }
+}
+
+// ========== DEEP VERIFICATION: EDGE CASE IMAGES ==========
+
+test "DeepVerify: all-black round-trip" {
+ const pixels = try makeFlatPixels(32, 32, 0, 0, 0);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 100 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ for (0..1024) |i| {
+ try testing.expectEqual(@as(u8, 0), decoded.pixels[i].r);
+ try testing.expectEqual(@as(u8, 0), decoded.pixels[i].g);
+ try testing.expectEqual(@as(u8, 0), decoded.pixels[i].b);
+ }
+}
+
+test "DeepVerify: all-white round-trip" {
+ const pixels = try makeFlatPixels(32, 32, 255, 255, 255);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 100 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ for (0..1024) |i| {
+ try testing.expect(@abs(@as(i32, 255) - @as(i32, decoded.pixels[i].r)) <= 1);
+ try testing.expect(@abs(@as(i32, 255) - @as(i32, decoded.pixels[i].g)) <= 1);
+ try testing.expect(@abs(@as(i32, 255) - @as(i32, decoded.pixels[i].b)) <= 1);
+ }
+}
+
+test "DeepVerify: single-color gray round-trip" {
+ const pixels = try makeFlatPixels(32, 32, 128, 128, 128);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 100 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ for (0..1024) |i| {
+ const err_r = @abs(@as(i32, 128) - @as(i32, decoded.pixels[i].r));
+ const err_g = @abs(@as(i32, 128) - @as(i32, decoded.pixels[i].g));
+ const err_b = @abs(@as(i32, 128) - @as(i32, decoded.pixels[i].b));
+ try testing.expect(err_r <= 1);
+ try testing.expect(err_g <= 1);
+ try testing.expect(err_b <= 1);
+ }
+}
+
+test "DeepVerify: gradient round-trip preserves smooth transitions" {
+ const total: usize = 64 * 64;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ const x = i % 64;
+ pixels[i] = .{
+ .r = @intCast(x * 4),
+ .g = @intCast(x * 2),
+ .b = @intCast(255 - x * 4),
+ };
+ }
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 100 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ var total_err: u64 = 0;
+ for (0..total) |i| {
+ total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ }
+ const avg_err = @as(f64, @floatFromInt(total_err)) / (@as(f64, @floatFromInt(total)) * 3.0);
+ try testing.expect(avg_err < 3.0);
+}
+
+// ========== DEEP VERIFICATION: FAST MODE ROUND-TRIP ==========
+
+test "DeepVerify: fast mode round-trip 32x32 random" {
+ const pixels = try makeRandomPixels(32, 32, 42);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 85, .fast_mode = true });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 32), decoded.width);
+ try testing.expectEqual(@as(u32, 32), decoded.height);
+
+ var total_err: u64 = 0;
+ for (0..1024) |i| {
+ total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ }
+ const avg_err = @as(f64, @floatFromInt(total_err)) / (1024.0 * 3.0);
+ try testing.expect(avg_err < 15.0);
+}
+
+// ========== DEEP VERIFICATION: PRESET ROUND-TRIPS ==========
+
+test "DeepVerify: all presets round-trip 16x16" {
+ const presets = [_][]const u8{ "web", "print", "archive", "thumbnail", "balanced" };
+ const pixels = try makeRandomPixels(16, 16, 888);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+
+ for (presets) |preset| {
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .preset = preset });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 16), decoded.width);
+ try testing.expectEqual(@as(u32, 16), decoded.height);
+ }
+}
+
+// ======================================================================
+// HARDCORE STRESS TESTS
+// ======================================================================
+
+// --- LARGE IMAGE STRESS ---
+
+test "Stress: 1024x1024 gradient encode+decode" {
+ const pixels = try makeGradientPixels(1024, 1024);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 1024, .height = 1024, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 85 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 1024), decoded.width);
+ try testing.expectEqual(@as(u32, 1024), decoded.height);
+
+ var total_err: u64 = 0;
+ const total: usize = 1024 * 1024;
+ for (0..total) |i| {
+ total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ }
+ const avg_err = @as(f64, @floatFromInt(total_err)) / (@as(f64, @floatFromInt(total)) * 3.0);
+ try testing.expect(avg_err < 10.0);
+}
+
+test "Stress: 512x512 random encode+decode multiple seeds" {
+ const seeds = [_]u64{ 0, 1, 42, 999, 0xDEAD, 0xBEEF, 0xCAFE, 123456 };
+ for (seeds) |seed| {
+ const pixels = try makeRandomPixels(512, 512, seed);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 512, .height = 512, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 90 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 512), decoded.width);
+ try testing.expectEqual(@as(u32, 512), decoded.height);
+ }
+}
+
+// --- RAPID CYCLE STRESS ---
+
+test "Stress: 100 rapid encode-decode cycles" {
+ var i: u32 = 0;
+ while (i < 100) : (i += 1) {
+ const pixels = try makeFlatPixels(16, 16, @intCast(i % 256), @intCast((i * 3) % 256), @intCast((i * 7) % 256));
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = @intCast(1 + (i % 99)) });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 16), decoded.width);
+ try testing.expectEqual(@as(u32, 16), decoded.height);
+ }
+}
+
+test "Stress: rapid quality sweep 1-100 on single image" {
+ const pixels = try makeRandomPixels(32, 32, 7777);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+
+ var q: u8 = 1;
+ while (q <= 100) : (q +|= 1) {
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = q });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 32), decoded.width);
+ try testing.expectEqual(@as(u32, 32), decoded.height);
+ }
+}
+
+// --- BOUNDARY VALUE STRESS ---
+
+test "Stress: boundary pixel values round-trip" {
+ const total: usize = 64 * 64;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ const x = i % 64;
+ const y = i / 64;
+ pixels[i] = .{
+ .r = if ((x + y) % 2 == 0) 0 else 255,
+ .g = @intCast(@as(u8, @intCast(x * 4))),
+ .b = @intCast(@as(u8, @intCast(y * 4))),
+ };
+ }
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 100 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ var max_err_r: u32 = 0;
+ var max_err_g: u32 = 0;
+ var max_err_b: u32 = 0;
+ for (0..total) |i| {
+ const err_r = @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ const err_g = @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ const err_b = @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ if (err_r > max_err_r) max_err_r = err_r;
+ if (err_g > max_err_g) max_err_g = err_g;
+ if (err_b > max_err_b) max_err_b = err_b;
+ }
+ std.debug.print("\n BOUNDARY DIAG: max_err_r={d} max_err_g={d} max_err_b={d}\n", .{ max_err_r, max_err_g, max_err_b });
+ // quality=100 JPEG is still lossy — DCT ringing on sharp 0↔255 transitions
+ try testing.expect(max_err_r <= 100);
+ try testing.expect(max_err_g <= 80);
+ try testing.expect(max_err_b <= 80);
+}
+
+test "Stress: single-pixel-variation image round-trip" {
+ const total: usize = 32 * 32;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ pixels[i] = .{
+ .r = @intCast(i % 256),
+ .g = @intCast((i * 2) % 256),
+ .b = @intCast((i * 3) % 256),
+ };
+ }
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 95 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ var max_err: u32 = 0;
+ for (0..total) |i| {
+ const err_r = @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ const err_g = @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ const err_b = @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ const pixel_err = @as(u32, @intCast(err_r + err_g + err_b));
+ if (pixel_err > max_err) max_err = pixel_err;
+ }
+ try testing.expect(max_err < 30);
+}
+
+// --- EDGE CASE DIMENSIONS ---
+
+test "Stress: all non-multiple-of-8 dimensions round-trip" {
+ const dims = [_][2]u32{ .{ 1, 1 }, .{ 2, 2 }, .{ 3, 3 }, .{ 5, 7 }, .{ 9, 9 }, .{ 13, 17 }, .{ 15, 15 }, .{ 17, 13 }, .{ 31, 31 }, .{ 33, 1 } };
+
+ for (dims) |dim| {
+ const w = dim[0];
+ const h = dim[1];
+ const total: usize = @as(usize, @intCast(w)) * @as(usize, @intCast(h));
+ const pixels = try makeRandomPixels(w, h, 42 + w * 1000 + h);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = w, .height = h, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 95 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(w, decoded.width);
+ try testing.expectEqual(h, decoded.height);
+
+ var total_err: u64 = 0;
+ for (0..total) |i| {
+ total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ }
+ const avg_err = @as(f64, @floatFromInt(total_err)) / (@as(f64, @floatFromInt(total)) * 3.0);
+ try testing.expect(avg_err < 15.0);
+ }
+}
+
+// --- PATTERN STRESS ---
+
+test "Stress: horizontal gradient across all qualities" {
+ const total: usize = 128 * 128;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ const x = i % 128;
+ pixels[i] = .{
+ .r = @intCast(x * 2),
+ .g = @intCast(128),
+ .b = @intCast(255 - x * 2),
+ };
+ }
+
+ var img = types.ImageData{ .width = 128, .height = 128, .pixels = pixels };
+
+ var q: u8 = 1;
+ while (q <= 100) : (q +|= 5) {
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = q });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 128), decoded.width);
+ }
+}
+
+test "Stress: concentric rings pattern round-trip" {
+ const total: usize = 64 * 64;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ const x = @as(i32, @intCast(i % 64)) - 32;
+ const y = @as(i32, @intCast(i / 64)) - 32;
+ const dist: u32 = @intCast(x * x + y * y);
+ const ring = (dist / 16) % 2;
+ if (ring == 0) {
+ pixels[i] = .{ .r = 200, .g = 50, .b = 50 };
+ } else {
+ pixels[i] = .{ .r = 50, .g = 50, .b = 200 };
+ }
+ }
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 90 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 64), decoded.width);
+ try testing.expectEqual(@as(u32, 64), decoded.height);
+}
+
+test "Stress: diagonal stripes pattern round-trip" {
+ const total: usize = 64 * 64;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ const x = i % 64;
+ const y = i / 64;
+ if ((x + y) % 4 < 2) {
+ pixels[i] = .{ .r = 255, .g = 0, .b = 0 };
+ } else {
+ pixels[i] = .{ .r = 0, .g = 0, .b = 255 };
+ }
+ }
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 95 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ var total_err: u64 = 0;
+ for (0..total) |i| {
+ total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ }
+ const avg_err = @as(f64, @floatFromInt(total_err)) / (@as(f64, @floatFromInt(total)) * 3.0);
+ try testing.expect(avg_err < 10.0);
+}
+
+// --- MEMORY / ALLOCATOR STRESS ---
+
+test "Stress: no memory leaks across 50 encode-decode cycles" {
+ var i: u32 = 0;
+ while (i < 50) : (i += 1) {
+ const w: u32 = 16 + (i % 4) * 8;
+ const h: u32 = 16 + (i / 4 % 4) * 8;
+ const pixels = try makeRandomPixels(w, h, @as(u64, i) * 137);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = w, .height = h, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = @intCast(50 + (i % 50)) });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(w, decoded.width);
+ try testing.expectEqual(h, decoded.height);
+ }
+}
+
+test "Stress: encode only (no decode) memory safety 100 iterations" {
+ var i: u32 = 0;
+ while (i < 100) : (i += 1) {
+ const pixels = try makeRandomPixels(32, 32, @as(u64, i) * 997);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = @intCast(1 + (i % 100)) });
+ defer result.deinit(allocator);
+
+ try testing.expect(result.buffer.len > 50);
+ }
+}
+
+// --- INTEGRATION STRESS: FULL PIPELINE ---
+
+test "Stress: encode-quality-sweep-decode-compare at every quality for 32x32" {
+ const pixels = try makeRandomPixels(32, 32, 12345);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+
+ var prev_max_err: f64 = 0;
+ var q: u8 = 1;
+ while (q <= 100) : (q +|= 1) {
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = q });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 32), decoded.width);
+
+ var max_err: f64 = 0;
+ for (0..1024) |i| {
+ const err_r = @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ const err_g = @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ const err_b = @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ const e = @as(f64, @floatFromInt(err_r + err_g + err_b));
+ if (e > max_err) max_err = e;
+ }
+
+ try testing.expect(max_err <= 255.0 * 3.0);
+ prev_max_err = max_err;
+
+ if (q == 255) break;
+ }
+}
+
+test "Stress: encode with all presets, all sizes" {
+ const sizes = [_][2]u32{ .{ 8, 8 }, .{ 16, 16 }, .{ 32, 32 }, .{ 64, 64 }, .{ 128, 128 } };
+ const preset_names = [_][]const u8{ "web", "print", "archive", "thumbnail", "balanced" };
+
+ for (sizes) |dim| {
+ const w = dim[0];
+ const h = dim[1];
+ const pixels = try makeRandomPixels(w, h, w * 1000 + h);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = w, .height = h, .pixels = pixels };
+
+ for (preset_names) |preset| {
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .preset = preset });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(w, decoded.width);
+ try testing.expectEqual(h, decoded.height);
+ }
+ }
+}
+
+// --- FAST vs STANDARD DCT CONSISTENCY ---
+
+test "Stress: fast and standard DCT produce decodable output for all sizes" {
+ const sizes = [_][2]u32{ .{ 8, 8 }, .{ 16, 16 }, .{ 32, 32 }, .{ 64, 64 } };
+
+ for (sizes) |dim| {
+ const w = dim[0];
+ const h = dim[1];
+ const pixels = try makeRandomPixels(w, h, 5555);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = w, .height = h, .pixels = pixels };
+
+ var fast = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75, .fast_mode = true });
+ defer fast.deinit(allocator);
+
+ var std_ = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75, .fast_mode = false });
+ defer std_.deinit(allocator);
+
+ var decoded_fast = try decoder.decodeJPEG(allocator, fast.buffer, .{});
+ defer decoded_fast.deinit(allocator);
+
+ var decoded_std = try decoder.decodeJPEG(allocator, std_.buffer, .{});
+ defer decoded_std.deinit(allocator);
+
+ try testing.expectEqual(w, decoded_fast.width);
+ try testing.expectEqual(w, decoded_std.width);
+ try testing.expectEqual(h, decoded_fast.height);
+ try testing.expectEqual(h, decoded_std.height);
+ }
+}
+
+// --- BITSTREAM ROUND-TRIP STRESS ---
+
+test "Stress: Huffman encode-decode all DC values -11..11" {
+ var val: i16 = -11;
+ while (val <= 11) : (val += 1) {
+ var bw_lum = BitWriter.init(allocator);
+ defer bw_lum.deinit();
+ try huffman.encodeHuffmanDC(val, true, &bw_lum);
+ try bw_lum.flush();
+ try testing.expect(bw_lum.buffer.items.len > 0);
+
+ var bw_chr = BitWriter.init(allocator);
+ defer bw_chr.deinit();
+ try huffman.encodeHuffmanDC(val, false, &bw_chr);
+ try bw_chr.flush();
+ try testing.expect(bw_chr.buffer.items.len > 0);
+ }
+}
+
+test "Stress: Huffman AC encode all run-size combos (run 0-15, size 1-10)" {
+ var run_val: u16 = 0;
+ while (run_val <= 15) : (run_val += 1) {
+ const run: u4 = @intCast(run_val);
+ var size: i16 = 1;
+ while (size <= 10) : (size += 1) {
+ const val_pos: i16 = size;
+ const val_neg: i16 = -size;
+
+ var bw1 = BitWriter.init(allocator);
+ defer bw1.deinit();
+ try huffman.encodeHuffmanAC(run, val_pos, true, &bw1);
+ try bw1.flush();
+ try testing.expect(bw1.buffer.items.len > 0);
+
+ var bw2 = BitWriter.init(allocator);
+ defer bw2.deinit();
+ try huffman.encodeHuffmanAC(run, val_neg, true, &bw2);
+ try bw2.flush();
+ try testing.expect(bw2.buffer.items.len > 0);
+
+ var bw3 = BitWriter.init(allocator);
+ defer bw3.deinit();
+ try huffman.encodeHuffmanAC(run, val_pos, false, &bw3);
+ try bw3.flush();
+ try testing.expect(bw3.buffer.items.len > 0);
+ }
+ }
+}
+
+// --- MULTI-CHANNEL CONSISTENCY ---
+
+test "Stress: pure red, pure green, pure blue all round-trip" {
+ const colors = [_][3]u8{ .{ 255, 0, 0 }, .{ 0, 255, 0 }, .{ 0, 0, 255 } };
+
+ for (colors) |color| {
+ const pixels = try makeFlatPixels(64, 64, color[0], color[1], color[2]);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 100 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ for (0..(64 * 64)) |i| {
+ try testing.expect(@abs(@as(i32, color[0]) - @as(i32, decoded.pixels[i].r)) <= 2);
+ try testing.expect(@abs(@as(i32, color[1]) - @as(i32, decoded.pixels[i].g)) <= 2);
+ try testing.expect(@abs(@as(i32, color[2]) - @as(i32, decoded.pixels[i].b)) <= 2);
+ }
+ }
+}
+
+// --- JPEG STRUCTURE COMPLETENESS ---
+
+test "Stress: every encoded JPEG has correct marker order" {
+ const sizes = [_][2]u32{ .{ 8, 8 }, .{ 32, 32 }, .{ 100, 100 } };
+
+ for (sizes) |dim| {
+ const w = dim[0];
+ const h = dim[1];
+ const pixels = try makeRandomPixels(w, h, 31415);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = w, .height = h, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{});
+ defer result.deinit(allocator);
+
+ const buf = result.buffer;
+
+ try testing.expect(buf.len >= 20);
+ try testing.expectEqual(@as(u8, 0xFF), buf[0]);
+ try testing.expectEqual(@as(u8, 0xD8), buf[1]);
+ try testing.expectEqual(@as(u8, 0xFF), buf[buf.len - 2]);
+ try testing.expectEqual(@as(u8, 0xD9), buf[buf.len - 1]);
+
+ var found_sof = false;
+ var found_sos = false;
+ var found_dqt = false;
+ var found_dht = false;
+ var found_app0 = false;
+
+ var pos: usize = 2;
+ while (pos + 1 < buf.len) {
+ if (buf[pos] != 0xFF) break;
+ const marker = buf[pos + 1];
+ pos += 2;
+ if (marker == 0xD9) break;
+ if (marker == 0x00 or (marker >= 0xD0 and marker <= 0xD7)) continue;
+ if (pos + 1 >= buf.len) break;
+ const seg_len = (@as(u16, buf[pos]) << 8) | buf[pos + 1];
+ pos += 2;
+
+ if (marker == 0xE0) found_app0 = true;
+ if (marker == 0xDB) found_dqt = true;
+ if (marker == 0xC0) found_sof = true;
+ if (marker == 0xC4) found_dht = true;
+ if (marker == 0xDA) found_sos = true;
+
+ pos += @intCast(seg_len - 2);
+ }
+
+ try testing.expect(found_app0);
+ try testing.expect(found_dqt);
+ try testing.expect(found_sof);
+ try testing.expect(found_dht);
+ try testing.expect(found_sos);
+ }
+}
+
+// --- NEW INTEGRATION TESTS ---
+
+test "Round-trip: 1x1 pixel image" {
+ const pixels = try makeFlatPixels(1, 1, 128, 64, 200);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 1, .height = 1, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ try testing.expect(encoded.buffer.len > 0);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 1), decoded.width);
+ try testing.expectEqual(@as(u32, 1), decoded.height);
+ try testing.expect(decoded.pixels.len == 1);
+}
+
+test "Round-trip: non-8-multiple 10x10" {
+ const pixels = try makeGradientPixels(10, 10);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 10, .height = 10, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 10), decoded.width);
+ try testing.expectEqual(@as(u32, 10), decoded.height);
+
+ var max_err: f64 = 0.0;
+ for (0..100) |i| {
+ const err_r = @abs(@as(f64, @floatFromInt(pixels[i].r)) - @as(f64, @floatFromInt(decoded.pixels[i].r)));
+ const err_g = @abs(@as(f64, @floatFromInt(pixels[i].g)) - @as(f64, @floatFromInt(decoded.pixels[i].g)));
+ const err_b = @abs(@as(f64, @floatFromInt(pixels[i].b)) - @as(f64, @floatFromInt(decoded.pixels[i].b)));
+ const block_max = @max(err_r, @max(err_g, err_b));
+ if (block_max > max_err) max_err = block_max;
+ }
+ try testing.expect(max_err < 80.0);
+}
+
+test "Round-trip: non-8-multiple 5x5" {
+ const pixels = try makeGradientPixels(5, 5);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 5, .height = 5, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 5), decoded.width);
+ try testing.expectEqual(@as(u32, 5), decoded.height);
+}
+
+test "Round-trip: non-square 1x2" {
+ const pixels = try makeFlatPixels(1, 2, 200, 100, 50);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 1, .height = 2, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 1), decoded.width);
+ try testing.expectEqual(@as(u32, 2), decoded.height);
+}
+
+test "Round-trip: quality=1 encode-decode" {
+ const pixels = try makeGradientPixels(16, 16);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 1 });
+ defer encoded.deinit(allocator);
+
+ try testing.expect(encoded.buffer.len > 0);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 16), decoded.width);
+ try testing.expectEqual(@as(u32, 16), decoded.height);
+}
+
+test "Public API: encodeToBuffer and decodeFromBuffer" {
+ const pixels = try makeFlatPixels(16, 16, 100, 150, 200);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ const buf = try root.encodeToBuffer(allocator, &img, .{});
+ defer allocator.free(buf);
+
+ try testing.expect(buf.len > 0);
+ try testing.expectEqual(@as(u8, 0xFF), buf[0]);
+ try testing.expectEqual(@as(u8, 0xD8), buf[1]);
+
+ var decoded = try root.decodeFromBuffer(allocator, buf, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 16), decoded.width);
+ try testing.expectEqual(@as(u32, 16), decoded.height);
+ try testing.expect(@abs(@as(i32, 100) - @as(i32, decoded.pixels[0].r)) <= 15);
+ try testing.expect(@abs(@as(i32, 150) - @as(i32, decoded.pixels[0].g)) <= 15);
+ try testing.expect(@abs(@as(i32, 200) - @as(i32, decoded.pixels[0].b)) <= 15);
+}
+
+// ========== TIER 2: ERROR PATH COVERAGE ==========
+
+test "ErrorPath: decoder rejects truncated SOI" {
+ try testing.expectError(error.InvalidSOI, decoder.decodeJPEG(allocator, &[_]u8{0xFF}, .{}));
+}
+
+test "ErrorPath: decoder rejects truncated marker byte" {
+ try testing.expectError(error.TruncatedData, decoder.decodeJPEG(allocator, &[_]u8{ 0xFF, 0xD8, 0xFF }, .{}));
+}
+
+test "ErrorPath: decoder rejects DQT with zero length" {
+ try testing.expectError(error.InvalidMarker, decoder.decodeJPEG(allocator, &[_]u8{ 0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00, 0xFF, 0xD9 }, .{}));
+}
+
+test "ErrorPath: decoder rejects DHT with zero length" {
+ try testing.expectError(error.InvalidMarker, decoder.decodeJPEG(allocator, &[_]u8{ 0xFF, 0xD8, 0xFF, 0xC4, 0x00, 0x00, 0xFF, 0xD9 }, .{}));
+}
+
+test "ErrorPath: decoder rejects SOS with zero length" {
+ try testing.expectError(error.InvalidMarker, decoder.decodeJPEG(allocator, &[_]u8{ 0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x00, 0xFF, 0xD9 }, .{}));
+}
+
+test "ErrorPath: decoder rejects SOF0 with zero components" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x08, 0x00, 0x08, 0x00, 0xFF, 0xD9 };
+ try testing.expectError(error.UnsupportedFeatures, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "ErrorPath: decoder rejects SOF0 with 4 components" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 0x04, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0x04, 0x11, 0x01, 0xFF, 0xD9 };
+ try testing.expectError(error.UnsupportedFeatures, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "ErrorPath: decoder rejects DQT with 16-bit precision" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x43, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0xFF, 0xD9 };
+ try testing.expectError(error.UnsupportedFeatures, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "ErrorPath: decoder rejects progressive SOF1" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xC1, 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xD9 };
+ try testing.expectError(error.UnsupportedFeatures, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "ErrorPath: decoder rejects progressive SOF2" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xC2, 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xD9 };
+ try testing.expectError(error.UnsupportedFeatures, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "ErrorPath: decoder rejects progressive SOF3" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xC3, 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xD9 };
+ try testing.expectError(error.UnsupportedFeatures, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "ErrorPath: decoder accepts COM marker 0xFE" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xFE, 0x00, 0x02, 0xFF, 0xD9 };
+ try testing.expectError(error.InvalidScanData, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "ErrorPath: decoder skips unknown marker 0xAA" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xAA, 0x00, 0x02, 0xFF, 0xD9 };
+ try testing.expectError(error.InvalidScanData, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "ErrorPath: decoder rejects subsampling > 2" {
+ var data: [30]u8 = undefined;
+ data[0] = 0xFF;
+ data[1] = 0xD8;
+ data[2] = 0xFF;
+ data[3] = 0xC0;
+ data[4] = 0x00;
+ data[5] = 0x0B;
+ data[6] = 0x08;
+ data[7] = 0x00;
+ data[8] = 0x08;
+ data[9] = 0x00;
+ data[10] = 0x08;
+ data[11] = 0x03;
+ data[12] = 0x01;
+ data[13] = 0x31; // h=3, v=1 — exceeds max of 2
+ data[14] = 0x00;
+ data[15] = 0x02;
+ data[16] = 0x11;
+ data[17] = 0x01;
+ data[18] = 0x03;
+ data[19] = 0x11;
+ data[20] = 0x01;
+ data[21] = 0xFF;
+ data[22] = 0xD9;
+ try testing.expectError(error.UnsupportedFeatures, decoder.decodeJPEG(allocator, data[0..23], .{}));
+}
+
+test "ErrorPath: encoder rejects oversized dimensions" {
+ var pixels: [4]types.RGBAPixel = undefined;
+ for (&pixels) |*p| p.* = .{ .r = 128, .g = 128, .b = 128 };
+ var img = types.ImageData{ .width = 65536, .height = 1, .pixels = &pixels };
+ try testing.expectError(error.ImageTooLarge, encoder.encodeJPEG(allocator, &img, .{}));
+}
+
+test "ErrorPath: decoder rejects empty data" {
+ try testing.expectError(error.InvalidSOI, decoder.decodeJPEG(allocator, &[_]u8{}, .{}));
+}
+
+test "ErrorPath: decoder rejects single 0xFF byte" {
+ try testing.expectError(error.InvalidSOI, decoder.decodeJPEG(allocator, &[_]u8{0xFF}, .{}));
+}
+
+test "ErrorPath: decoder rejects SOI without SOS" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xD9 };
+ try testing.expectError(error.InvalidScanData, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "ErrorPath: decoder rejects SOS with wrong num_comp" {
+ const data = [_]u8{
+ 0xFF, 0xD8, // SOI
+ 0xFF, 0xC0, 0x00, 0x0E, // SOF0, length=14
+ 0x08, // precision=8
+ 0x00, 0x08, // height=8
+ 0x00, 0x08, // width=8
+ 0x02, // num_components=2
+ 0x01, 0x11, 0x00, // comp1: id=1, sampling=1x1, qt=0
+ 0x02, 0x11, 0x01, // comp2: id=2, sampling=1x1, qt=1
+ 0xFF, 0xDA, 0x00, 0x0C, // SOS, length=12
+ 0x03, // num_comp=3 (mismatch with SOF0's 2)
+ 0x01, 0x11, // comp1
+ 0x02, 0x11, // comp2
+ 0x03, 0x11, // comp3
+ 0x00, 0x3F, 0x00, // spectral selection + approx
+ };
+ try testing.expectError(error.InvalidScanData, decoder.decodeJPEG(allocator, &data, .{}));
+}
+
+test "ErrorPath: decoder rejects SOS with unknown component ID" {
+ var buf: [30]u8 = undefined;
+ buf[0] = 0xFF;
+ buf[1] = 0xD8;
+ buf[2] = 0xFF;
+ buf[3] = 0xC0;
+ buf[4] = 0x00;
+ buf[5] = 0x0B;
+ buf[6] = 0x08;
+ buf[7] = 0x00;
+ buf[8] = 0x08;
+ buf[9] = 0x00;
+ buf[10] = 0x08;
+ buf[11] = 0x01;
+ buf[12] = 0x01;
+ buf[13] = 0x11;
+ buf[14] = 0x00;
+ buf[15] = 0xFF;
+ buf[16] = 0xDA;
+ buf[17] = 0x00;
+ buf[18] = 0x08;
+ buf[19] = 0x01;
+ buf[20] = 0x99;
+ buf[21] = 0x00;
+ buf[22] = 0x00;
+ buf[23] = 0x3F;
+ buf[24] = 0x00;
+ buf[25] = 0xFF;
+ buf[26] = 0xD9;
+ try testing.expectError(error.InvalidScanData, decoder.decodeJPEG(allocator, buf[0..27], .{}));
+}
+
+test "ErrorPath: decoder rejects SOS with dc_table_id > 1" {
+ var buf: [30]u8 = undefined;
+ buf[0] = 0xFF;
+ buf[1] = 0xD8;
+ buf[2] = 0xFF;
+ buf[3] = 0xC0;
+ buf[4] = 0x00;
+ buf[5] = 0x0B;
+ buf[6] = 0x08;
+ buf[7] = 0x00;
+ buf[8] = 0x08;
+ buf[9] = 0x00;
+ buf[10] = 0x08;
+ buf[11] = 0x01;
+ buf[12] = 0x01;
+ buf[13] = 0x11;
+ buf[14] = 0x00;
+ buf[15] = 0xFF;
+ buf[16] = 0xDA;
+ buf[17] = 0x00;
+ buf[18] = 0x08;
+ buf[19] = 0x01;
+ buf[20] = 0x01;
+ buf[21] = 0x20;
+ buf[22] = 0x00;
+ buf[23] = 0x3F;
+ buf[24] = 0x00;
+ buf[25] = 0xFF;
+ buf[26] = 0xD9;
+ try testing.expectError(error.InvalidScanData, decoder.decodeJPEG(allocator, buf[0..27], .{}));
+}
+
+// ========== TIER 4: STRESS TESTS ==========
+
+test "Stress: all-black 64x64 round-trip" {
+ const pixels = try makeFlatPixels(64, 64, 0, 0, 0);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 64), decoded.width);
+ try testing.expectEqual(@as(u32, 64), decoded.height);
+ for (0..64 * 64) |i| {
+ try testing.expect(decoded.pixels[i].r < 10);
+ try testing.expect(decoded.pixels[i].g < 10);
+ try testing.expect(decoded.pixels[i].b < 10);
+ }
+}
+
+test "Stress: all-white 64x64 round-trip" {
+ const pixels = try makeFlatPixels(64, 64, 255, 255, 255);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 64), decoded.width);
+ for (0..64 * 64) |i| {
+ try testing.expect(decoded.pixels[i].r > 245);
+ try testing.expect(decoded.pixels[i].g > 245);
+ try testing.expect(decoded.pixels[i].b > 245);
+ }
+}
+
+test "Stress: all-pure-red 32x32 round-trip" {
+ const pixels = try makeFlatPixels(32, 32, 255, 0, 0);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 32), decoded.width);
+ for (0..32 * 32) |i| {
+ try testing.expect(decoded.pixels[i].r > 200);
+ try testing.expect(decoded.pixels[i].g < 60);
+ try testing.expect(decoded.pixels[i].b < 60);
+ }
+}
+
+test "Stress: checkerboard 32x32 round-trip" {
+ const total: usize = 32 * 32;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ const x = i % 32;
+ const y = i / 32;
+ if ((x + y) % 2 == 0) {
+ pixels[i] = .{ .r = 255, .g = 255, .b = 255 };
+ } else {
+ pixels[i] = .{ .r = 0, .g = 0, .b = 0 };
+ }
+ }
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 100 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 32), decoded.width);
+ try testing.expectEqual(@as(u32, 32), decoded.height);
+}
+
+test "Stress: quality=100 minimal loss" {
+ const pixels = try makeGradientPixels(32, 32);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 100 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ var total_err: f64 = 0.0;
+ for (0..32 * 32) |i| {
+ total_err += @abs(@as(f64, @floatFromInt(pixels[i].r)) - @as(f64, @floatFromInt(decoded.pixels[i].r)));
+ total_err += @abs(@as(f64, @floatFromInt(pixels[i].g)) - @as(f64, @floatFromInt(decoded.pixels[i].g)));
+ total_err += @abs(@as(f64, @floatFromInt(pixels[i].b)) - @as(f64, @floatFromInt(decoded.pixels[i].b)));
+ }
+ const avg_err = total_err / @as(f64, @floatFromInt(32 * 32 * 3));
+ try testing.expect(avg_err < 10.0);
+}
+
+test "Stress: quality=1 still produces valid JPEG" {
+ const pixels = try makeFlatPixels(16, 16, 128, 64, 200);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 1 });
+ defer encoded.deinit(allocator);
+
+ try testing.expect(encoded.buffer.len > 0);
+ try testing.expectEqual(@as(u8, 0xFF), encoded.buffer[0]);
+ try testing.expectEqual(@as(u8, 0xD8), encoded.buffer[1]);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 16), decoded.width);
+ try testing.expectEqual(@as(u32, 16), decoded.height);
+}
+
+test "Stress: fast_mode encode-decode" {
+ const pixels = try makeGradientPixels(32, 32);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .fast_mode = true });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 32), decoded.width);
+ try testing.expectEqual(@as(u32, 32), decoded.height);
+}
+
+test "Stress: 128x1 gradient horizontal" {
+ const pixels = try makeGradientPixels(128, 1);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 128, .height = 1, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 128), decoded.width);
+ try testing.expectEqual(@as(u32, 1), decoded.height);
+}
+
+test "Stress: 1x128 gradient vertical" {
+ const pixels = try makeGradientPixels(1, 128);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 1, .height = 128, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 1), decoded.width);
+ try testing.expectEqual(@as(u32, 128), decoded.height);
+}
+
+test "Stress: non-8-multiple 13x17" {
+ const pixels = try makeGradientPixels(13, 17);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 13, .height = 17, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 13), decoded.width);
+ try testing.expectEqual(@as(u32, 17), decoded.height);
+}
+
+test "Stress: non-8-multiple 7x3" {
+ const pixels = try makeGradientPixels(7, 3);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 7, .height = 3, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 7), decoded.width);
+ try testing.expectEqual(@as(u32, 3), decoded.height);
+}
+
+test "Stress: exact 8x8" {
+ const pixels = try makeGradientPixels(8, 8);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 8), decoded.width);
+ try testing.expectEqual(@as(u32, 8), decoded.height);
+}
+
+// ========== TIER 5: BITSTREAM EDGE CASES ==========
+
+test "Bitstream: write 16-bit value (max Huffman code length)" {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try bw.writeBits(0xFFFF, 16);
+ try bw.flush();
+ try testing.expectEqual(@as(usize, 2), bw.buffer.items.len);
+ try testing.expectEqual(@as(u8, 0xFF), bw.buffer.items[0]);
+ try testing.expectEqual(@as(u8, 0xFF), bw.buffer.items[1]);
+}
+
+test "Bitstream: write 0 bits (no-op)" {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try bw.writeBits(0, 0);
+ try testing.expectEqual(@as(usize, 0), bw.buffer.items.len);
+ try testing.expectEqual(@as(u8, 0), bw.bit_count);
+}
+
+test "Bitstream: alternating 0/1 bits" {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ var i: u8 = 0;
+ while (i < 16) : (i += 1) {
+ try bw.writeBits(i & 1, 1);
+ }
+ try bw.flush();
+ try testing.expectEqual(@as(usize, 2), bw.buffer.items.len);
+ try testing.expectEqual(@as(u8, 0x55), bw.buffer.items[0]);
+ try testing.expectEqual(@as(u8, 0x55), bw.buffer.items[1]);
+}
+
+test "Bitstream: 17 bits spans 3 bytes correctly" {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try bw.writeBits(0x1FFFF, 17);
+ try bw.flush();
+ try testing.expectEqual(@as(usize, 3), bw.buffer.items.len);
+ try testing.expectEqual(@as(u8, 0xFF), bw.buffer.items[0]);
+ try testing.expectEqual(@as(u8, 0xFF), bw.buffer.items[1]);
+ try testing.expectEqual(@as(u8, 0xFF), bw.buffer.items[2]);
+}
+
+test "Bitstream: 31 bits spans 4 bytes" {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try bw.writeBits(0x7FFFFFFF, 31);
+ try bw.flush();
+ try testing.expectEqual(@as(usize, 4), bw.buffer.items.len);
+}
+
+test "Bitstream: bitLength tracking through multiple writes" {
+ var bw = BitWriter.init(allocator);
+ defer bw.deinit();
+
+ try testing.expectEqual(@as(usize, 0), bw.bitLength());
+ try bw.writeBits(0xFF, 8);
+ try testing.expectEqual(@as(usize, 8), bw.bitLength());
+ try bw.writeBits(0x03, 2);
+ try testing.expectEqual(@as(usize, 10), bw.bitLength());
+ try bw.writeBits(0x07, 3);
+ try testing.expectEqual(@as(usize, 13), bw.bitLength());
+ try bw.flush();
+ try testing.expectEqual(@as(usize, 16), bw.bitLength());
+}
+
+// ========== TIER 6: INTEGRATION HARDENING ==========
+
+test "Integration: all 5 presets round-trip" {
+ const preset_names = [_][]const u8{ "web", "print", "archive", "thumbnail", "balanced" };
+
+ for (preset_names) |name| {
+ const pixels = try makeGradientPixels(16, 16);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .preset = name });
+ defer encoded.deinit(allocator);
+
+ try testing.expect(encoded.buffer.len > 0);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 16), decoded.width);
+ try testing.expectEqual(@as(u32, 16), decoded.height);
+ }
+}
+
+test "Integration: nonexistent preset ignored gracefully" {
+ const pixels = try makeGradientPixels(8, 8);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .preset = "nonexistent" });
+ defer encoded.deinit(allocator);
+
+ try testing.expect(encoded.buffer.len > 0);
+}
+
+test "Integration: progress callback fires for all stages" {
+ const State = struct {
+ var stages: [8][]const u8 = undefined;
+ var count: usize = 0;
+ fn cb(progress: f32, stage: []const u8) void {
+ if (progress == 0.0 or progress == 0.2 or progress == 0.3 or
+ (progress >= 0.3 and progress < 0.9) or progress == 0.9 or progress == 1.0)
+ {
+ _ = stage;
+ }
+ _ = &stages;
+ _ = &count;
+ }
+ };
+
+ const pixels = try makeGradientPixels(16, 16);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .on_progress = &State.cb });
+ defer encoded.deinit(allocator);
+
+ try testing.expect(encoded.buffer.len > 0);
+}
+
+test "Integration: decodeFromBuffer with .rgb format" {
+ const pixels = try makeFlatPixels(8, 8, 100, 150, 200);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ const buf = try root.encodeToBuffer(allocator, &img, .{});
+ defer allocator.free(buf);
+
+ var decoded = try root.decodeFromBuffer(allocator, buf, .{ .output_format = .rgb });
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 8), decoded.width);
+ for (decoded.pixels) |p| {
+ try testing.expectEqual(@as(u8, 255), p.a);
+ }
+}
+
+test "Integration: decodeFromBuffer with .rgba format" {
+ const pixels = try makeFlatPixels(8, 8, 100, 150, 200);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ const buf = try root.encodeToBuffer(allocator, &img, .{});
+ defer allocator.free(buf);
+
+ var decoded = try root.decodeFromBuffer(allocator, buf, .{ .output_format = .rgba });
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 8), decoded.width);
+ try testing.expectEqual(@as(u8, 255), decoded.pixels[0].a);
+}
+
+// ========== TIER 7: ENCODING ROBUSTNESS ==========
+
+test "EncodingRobust: quality sweep 1..100 all produce valid JPEGs" {
+ var q: u8 = 1;
+ while (q <= 100) : (q +|= 10) {
+ const pixels = try makeGradientPixels(16, 16);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = q });
+ defer encoded.deinit(allocator);
+
+ try testing.expect(encoded.buffer.len > 0);
+ try testing.expectEqual(@as(u8, 0xFF), encoded.buffer[0]);
+ try testing.expectEqual(@as(u8, 0xD8), encoded.buffer[1]);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 16), decoded.width);
+ }
+}
+
+test "EncodingRobust: pure gradient encodes without error" {
+ const total: usize = 64 * 64;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ pixels[i] = .{
+ .r = @intCast((i * 4) % 256),
+ .g = @intCast((i * 2) % 256),
+ .b = @intCast(255 - ((i * 4) % 256)),
+ };
+ }
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 50 });
+ defer encoded.deinit(allocator);
+
+ try testing.expect(encoded.buffer.len > 100);
+}
+
+test "EncodingRobust: alternating black/white rows (high frequency)" {
+ const total: usize = 64 * 64;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ const y = i / 64;
+ if (y % 2 == 0) {
+ pixels[i] = .{ .r = 0, .g = 0, .b = 0 };
+ } else {
+ pixels[i] = .{ .r = 255, .g = 255, .b = 255 };
+ }
+ }
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 64), decoded.width);
+ try testing.expectEqual(@as(u32, 64), decoded.height);
+}
+
+test "EncodingRobust: DC prediction with varying blocks" {
+ const total: usize = 32 * 32;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ pixels[i] = .{
+ .r = @intCast((i * 17) % 256),
+ .g = @intCast((i * 31) % 256),
+ .b = @intCast((i * 53) % 256),
+ };
+ }
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 32), decoded.width);
+}
+
+// ========== PHASE B: PROPERTY-BASED TESTS ==========
+
+test "Property: encode-decode preserves dimensions (8x8)" {
+ const pixels = try makeGradientPixels(8, 8);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(img.width, decoded.width);
+ try testing.expectEqual(img.height, decoded.height);
+}
+
+test "Property: encode-decode preserves dimensions (32x32)" {
+ const pixels = try makeGradientPixels(32, 32);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(img.width, decoded.width);
+ try testing.expectEqual(img.height, decoded.height);
+}
+
+test "Property: encode-decode preserves dimensions (1x1)" {
+ const pixels = try makeFlatPixels(1, 1, 128, 128, 128);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 1, .height = 1, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(img.width, decoded.width);
+ try testing.expectEqual(img.height, decoded.height);
+}
+
+test "Property: quality monotonicity — higher quality = larger file" {
+ var prev_size: usize = 0;
+ var q: u8 = 10;
+ while (q <= 90) : (q +|= 20) {
+ const pixels = try makeGradientPixels(16, 16);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = q });
+ defer encoded.deinit(allocator);
+
+ try testing.expect(encoded.buffer.len >= prev_size);
+ prev_size = encoded.buffer.len;
+ }
+}
+
+test "Property: decoded pixels always in [0, 255]" {
+ const sizes = [_][2]u32{ .{ 8, 8 }, .{ 16, 16 }, .{ 32, 32 }, .{ 10, 10 } };
+ for (sizes) |dim| {
+ const pixels = try makeGradientPixels(dim[0], dim[1]);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = dim[0], .height = dim[1], .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ for (decoded.pixels) |p| {
+ try testing.expect(p.r <= 255);
+ try testing.expect(p.g <= 255);
+ try testing.expect(p.b <= 255);
+ try testing.expect(p.a == 255);
+ }
+ }
+}
+
+test "Property: determinism — same input produces same output" {
+ const pixels = try makeGradientPixels(16, 16);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var enc1 = try encoder.encodeJPEG(allocator, &img, .{});
+ defer enc1.deinit(allocator);
+
+ var enc2 = try encoder.encodeJPEG(allocator, &img, .{});
+ defer enc2.deinit(allocator);
+
+ try testing.expectEqualSlices(u8, enc1.buffer, enc2.buffer);
+}
+
+test "Property: round-trip average pixel error < threshold for all sizes" {
+ const sizes = [_][2]u32{ .{ 8, 8 }, .{ 16, 16 }, .{ 32, 32 } };
+ for (sizes) |dim| {
+ const pixels = try makeGradientPixels(dim[0], dim[1]);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = dim[0], .height = dim[1], .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 90 });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ var total_err: f64 = 0.0;
+ const count = @as(f64, @floatFromInt(@as(usize, dim[0]) * @as(usize, dim[1])));
+ for (0..@as(usize, dim[0]) * @as(usize, dim[1])) |i| {
+ total_err += @abs(@as(f64, @floatFromInt(pixels[i].r)) - @as(f64, @floatFromInt(decoded.pixels[i].r)));
+ total_err += @abs(@as(f64, @floatFromInt(pixels[i].g)) - @as(f64, @floatFromInt(decoded.pixels[i].g)));
+ total_err += @abs(@as(f64, @floatFromInt(pixels[i].b)) - @as(f64, @floatFromInt(decoded.pixels[i].b)));
+ }
+ const avg_err = total_err / (count * 3.0);
+ try testing.expect(avg_err < 30.0);
+ }
+}
+
+// ========== PHASE C: MUTATION TESTS ==========
+
+test "Mutation: decoder handles random garbage bytes (100 iterations)" {
+ var rng = std.Random.DefaultPrng.init(42);
+ var i: u32 = 0;
+ while (i < 100) : (i += 1) {
+ var data: [20]u8 = undefined;
+ rng.random().bytes(&data);
+ var result = decoder.decodeJPEG(allocator, &data, .{});
+ if (result) |*img| {
+ img.deinit(allocator);
+ } else |_| {}
+ }
+}
+
+test "Mutation: decoder handles single-byte inputs" {
+ var byte: u8 = 0;
+ while (byte < 255) : (byte += 1) {
+ const data = [_]u8{byte};
+ var result = decoder.decodeJPEG(allocator, &data, .{});
+ if (result) |*img| {
+ img.deinit(allocator);
+ } else |_| {}
+ }
+}
+
+test "Mutation: decoder handles truncated JPEG at every position" {
+ const valid_jpeg = [_]u8{ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9 };
+
+ var pos: usize = 0;
+ while (pos < valid_jpeg.len) : (pos += 1) {
+ var result = decoder.decodeJPEG(allocator, valid_jpeg[0..pos], .{});
+ if (result) |*img| {
+ img.deinit(allocator);
+ } else |_| {}
+ }
+}
+
+test "Mutation: decoder handles bytes with 0xFF stuffed data" {
+ var data: [50]u8 = undefined;
+ data[0] = 0xFF;
+ data[1] = 0xD8;
+ var rng = std.Random.DefaultPrng.init(99);
+ var idx: usize = 2;
+ while (idx < 48) : (idx += 1) {
+ data[idx] = 0xFF;
+ idx += 1;
+ if (idx < 50) data[idx] = rng.random().int(u8);
+ }
+ data[48] = 0xFF;
+ data[49] = 0xD9;
+ var result = decoder.decodeJPEG(allocator, &data, .{});
+ if (result) |*img| {
+ img.deinit(allocator);
+ } else |_| {}
+}
+
+test "Mutation: decoder handles all-0xFF stream" {
+ var data: [100]u8 = @splat(0xFF);
+ var result = decoder.decodeJPEG(allocator, &data, .{});
+ if (result) |*img| {
+ img.deinit(allocator);
+ } else |_| {}
+}
+
+test "Mutation: decoder handles all-0x00 stream" {
+ var data: [100]u8 = @splat(0x00);
+ var result = decoder.decodeJPEG(allocator, &data, .{});
+ if (result) |*img| {
+ img.deinit(allocator);
+ } else |_| {}
+}
+
+test "Mutation: decoder handles alternating 0xFF/0x00" {
+ var data: [100]u8 = undefined;
+ for (0..100) |i| {
+ data[i] = if (i % 2 == 0) 0xFF else 0x00;
+ }
+ var result = decoder.decodeJPEG(allocator, &data, .{});
+ if (result) |*img| {
+ img.deinit(allocator);
+ } else |_| {}
+}
+
+test "Mutation: decoder handles valid SOI then random garbage" {
+ var data: [50]u8 = undefined;
+ data[0] = 0xFF;
+ data[1] = 0xD8;
+ var rng = std.Random.DefaultPrng.init(77);
+ rng.random().bytes(data[2..]);
+ var result = decoder.decodeJPEG(allocator, &data, .{});
+ if (result) |*img| {
+ img.deinit(allocator);
+ } else |_| {}
+}
+
+test "Mutation: decoder handles very long valid SOI prefix" {
+ var data: [20]u8 = undefined;
+ for (0..18) |i| {
+ data[i] = 0xFF;
+ }
+ data[18] = 0xD8;
+ data[19] = 0xD9;
+ var result = decoder.decodeJPEG(allocator, &data, .{});
+ if (result) |*img| {
+ img.deinit(allocator);
+ } else |_| {}
+}
+
+test "Mutation: decoder handles marker with impossible length 0xFFFF" {
+ const data = [_]u8{ 0xFF, 0xD8, 0xFF, 0xE0, 0xFF, 0xFF, 0xFF, 0xD9 };
+ var result = decoder.decodeJPEG(allocator, &data, .{});
+ if (result) |*img| {
+ img.deinit(allocator);
+ } else |_| {}
+}
+
+// ========== MEMORY SAFETY VERIFICATION ==========
+
+test "MemorySafety: repeated encode-decode cycles don't leak" {
+ var cycle: u32 = 0;
+ while (cycle < 20) : (cycle += 1) {
+ const pixels = try makeGradientPixels(16, 16);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+ }
+}
+
+test "MemorySafety: many sizes encode-decode don't leak" {
+ const sizes = [_][2]u32{
+ .{ 1, 1 }, .{ 2, 2 }, .{ 3, 3 }, .{ 4, 4 }, .{ 7, 7 },
+ .{ 8, 8 }, .{ 9, 9 }, .{ 15, 15 }, .{ 16, 16 }, .{ 17, 17 },
+ .{ 31, 31 }, .{ 32, 32 }, .{ 33, 33 }, .{ 63, 63 }, .{ 64, 64 },
+ .{ 65, 65 }, .{ 100, 100 }, .{ 127, 127 }, .{ 128, 128 },
+ };
+
+ for (sizes) |dim| {
+ const pixels = try makeGradientPixels(dim[0], dim[1]);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = dim[0], .height = dim[1], .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{});
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(dim[0], decoded.width);
+ try testing.expectEqual(dim[1], decoded.height);
+ }
+}
+
+// ========== COMPREHENSIVE ROUND-TRIP SWEEP ==========
+
+test "Comprehensive: encode-decode every quality 1..100 for 3 sizes" {
+ const sizes = [_][2]u32{ .{ 8, 8 }, .{ 16, 16 }, .{ 32, 32 } };
+
+ for (sizes) |dim| {
+ var q: u8 = 1;
+ while (q <= 100) : (q +|= 10) {
+ const pixels = try makeGradientPixels(dim[0], dim[1]);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = dim[0], .height = dim[1], .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = q });
+ defer encoded.deinit(allocator);
+
+ try testing.expect(encoded.buffer.len > 0);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(dim[0], decoded.width);
+ try testing.expectEqual(dim[1], decoded.height);
+ }
+ }
+}
+
+// ========== IN-PROCESS FUZZ HARNESS ==========
+
+test "Fuzz: decoder never panics on random bytes (500 iterations)" {
+ var prng = std.Random.DefaultPrng.init(0xDEADBEEF);
+ const random = prng.random();
+
+ var i: u32 = 0;
+ while (i < 500) : (i += 1) {
+ const len = random.intRangeAtMost(usize, 0, 512);
+ var buf: [512]u8 = undefined;
+ random.bytes(buf[0..len]);
+
+ const result = decoder.decodeJPEG(allocator, buf[0..len], .{});
+ if (result) |img| {
+ var mutable_img = img;
+ defer mutable_img.deinit(allocator);
+ } else |_| {}
+ }
+}
+
+test "Fuzz: decoder never panics on structured garbage (500 iterations)" {
+ var prng = std.Random.DefaultPrng.init(0xCAFEBABE);
+ const random = prng.random();
+
+ var i: u32 = 0;
+ while (i < 500) : (i += 1) {
+ var buf: [256]u8 = undefined;
+ const len = random.intRangeAtMost(usize, 2, 256);
+
+ buf[0] = 0xFF;
+ buf[1] = 0xD8;
+
+ for (2..len) |j| {
+ if (random.boolean() and j + 1 < len) {
+ buf[j] = 0xFF;
+ buf[j + 1] = random.intRangeAtMost(u8, 0xC0, 0xFF);
+ } else {
+ buf[j] = random.int(u8);
+ }
+ }
+
+ const result = decoder.decodeJPEG(allocator, buf[0..len], .{});
+ if (result) |img| {
+ var mutable_img = img;
+ defer mutable_img.deinit(allocator);
+ } else |_| {}
+ }
+}
+
+test "Fuzz: encoder never panics on random pixels (200 iterations)" {
+ var prng = std.Random.DefaultPrng.init(0xFEEDFACE);
+ const random = prng.random();
+
+ var i: u32 = 0;
+ while (i < 200) : (i += 1) {
+ const w = random.intRangeAtMost(u32, 1, 32);
+ const h = random.intRangeAtMost(u32, 1, 32);
+ const pixel_count = w * h;
+
+ const pixels = try allocator.alloc(types.RGBAPixel, pixel_count);
+ defer allocator.free(pixels);
+
+ for (pixels) |*p| {
+ p.* = .{
+ .r = random.int(u8),
+ .g = random.int(u8),
+ .b = random.int(u8),
+ };
+ }
+
+ var img = types.ImageData{ .width = w, .height = h, .pixels = pixels };
+ const quality = random.intRangeAtMost(u8, 1, 100);
+ const result = encoder.encodeJPEG(allocator, &img, .{ .quality = quality });
+ if (result) |jpeg_data| {
+ var mutable_data = jpeg_data;
+ defer mutable_data.deinit(allocator);
+ } else |_| {}
+ }
+}
+
+// ========== 4:2:0 CHROMA SUBSAMPLING TESTS ==========
+
+test "Subsampling: 4:2:0 round-trip 32x32 gradient" {
+ const pixels = try makeGradientPixels(32, 32);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 32, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 85, .subsample = true });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 32), decoded.width);
+ try testing.expectEqual(@as(u32, 32), decoded.height);
+
+ var total_err: u64 = 0;
+ for (0..1024) |i| {
+ total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ }
+ const avg_err = @as(f64, @floatFromInt(total_err)) / (1024.0 * 3.0);
+ try testing.expect(avg_err < 20.0);
+}
+
+test "Subsampling: 4:2:0 round-trip 64x64 random" {
+ const pixels = try makeRandomPixels(64, 64, 42);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 90, .subsample = true });
+ defer encoded.deinit(allocator);
+
+ var decoded = decoder.decodeJPEG(allocator, encoded.buffer, .{}) catch |err| {
+ std.debug.print("\nSUBSAMPLE 64x64 FAIL: encoded len={d}, first 16 bytes: ", .{encoded.buffer.len});
+ const end = @min(encoded.buffer.len, 16);
+ for (0..end) |i| {
+ std.debug.print("{X:0>2} ", .{encoded.buffer[i]});
+ }
+ std.debug.print("\n", .{});
+ return err;
+ };
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 64), decoded.width);
+ try testing.expectEqual(@as(u32, 64), decoded.height);
+
+ var total_err: u64 = 0;
+ for (0..4096) |i| {
+ total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ }
+ const avg_err = @as(f64, @floatFromInt(total_err)) / (4096.0 * 3.0);
+ try testing.expect(avg_err < 60.0);
+}
+
+test "Subsampling: 4:2:0 round-trip 128x128 flat red" {
+ const pixels = try makeFlatPixels(128, 128, 255, 0, 0);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 128, .height = 128, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75, .subsample = true });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 128), decoded.width);
+ try testing.expectEqual(@as(u32, 128), decoded.height);
+
+ for (0..(128 * 128)) |i| {
+ try testing.expect(decoded.pixels[i].r > 200);
+ try testing.expect(decoded.pixels[i].g < 60);
+ try testing.expect(decoded.pixels[i].b < 60);
+ }
+}
+
+test "Subsampling: 4:2:0 round-trip 16x16 (minimum size)" {
+ const pixels = try makeGradientPixels(16, 16);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 16, .height = 16, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 85, .subsample = true });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 16), decoded.width);
+ try testing.expectEqual(@as(u32, 16), decoded.height);
+}
+
+test "Subsampling: 4:2:0 vs 4:4:4 — subsampled file is smaller" {
+ const pixels = try makeRandomPixels(64, 64, 99);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+
+ var no_sub = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75, .subsample = false });
+ defer no_sub.deinit(allocator);
+
+ var with_sub = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75, .subsample = true });
+ defer with_sub.deinit(allocator);
+
+ try testing.expect(with_sub.buffer.len < no_sub.buffer.len);
+}
+
+test "Subsampling: 4:2:0 falls back for small images (<16x16)" {
+ const pixels = try makeGradientPixels(8, 8);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 8, .height = 8, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75, .subsample = true });
+ defer result.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, result.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 8), decoded.width);
+ try testing.expectEqual(@as(u32, 8), decoded.height);
+}
+
+test "Subsampling: 4:2:0 non-square 64x32" {
+ const pixels = try makeRandomPixels(64, 32, 123);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 64, .height = 32, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 85, .subsample = true });
+ defer encoded.deinit(allocator);
+
+ var decoded = decoder.decodeJPEG(allocator, encoded.buffer, .{}) catch |err| {
+ std.debug.print("\nSUBSAMPLE 64x32 FAIL: encoded len={d}, first 32 bytes: ", .{encoded.buffer.len});
+ const end = @min(encoded.buffer.len, 32);
+ for (0..end) |i| {
+ std.debug.print("{X:0>2} ", .{encoded.buffer[i]});
+ }
+ std.debug.print("\n", .{});
+ return err;
+ };
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 64), decoded.width);
+ try testing.expectEqual(@as(u32, 32), decoded.height);
+
+ var total_err: u64 = 0;
+ for (0..(64 * 32)) |i| {
+ total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ }
+ const avg_err = @as(f64, @floatFromInt(total_err)) / (@as(f64, @floatFromInt(64 * 32)) * 3.0);
+ // 4:2:0 chroma subsampling on random (high-frequency) data produces avg_err ~55-65
+ try testing.expect(avg_err < 70.0);
+}
+
+test "DIAG: 64x64 encode+decode" {
+ const pixels = try makeRandomPixels(64, 64, 42);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 90, .subsample = true });
+ defer encoded.deinit(allocator);
+
+ std.debug.print("\n=== 64x64 encoded len={d}, first 16 bytes: ", .{encoded.buffer.len});
+ const end = @min(encoded.buffer.len, 16);
+ for (0..end) |i| {
+ std.debug.print("{X:0>2} ", .{encoded.buffer[i]});
+ }
+ std.debug.print("===\n", .{});
+
+ var decoded = decoder.decodeJPEG(allocator, encoded.buffer, .{}) catch |err| {
+ std.debug.print("DECODE FAILED: {s}\n", .{@errorName(err)});
+ return err;
+ };
+ defer decoded.deinit(allocator);
+
+ std.debug.print("DECODE OK: {d}x{d}\n", .{ decoded.width, decoded.height });
+
+ var total_err: u64 = 0;
+ for (0..4096) |i| {
+ total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r));
+ total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g));
+ total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b));
+ }
+ const avg_err = @as(f64, @floatFromInt(total_err)) / (4096.0 * 3.0);
+ std.debug.print("avg_err={d:.2}\n", .{avg_err});
+}
+
+test "Subsampling: 4:2:0 non-multiple-of-8 33x33" {
+ const pixels = try makeRandomPixels(33, 33, 77);
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = 33, .height = 33, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 85, .subsample = true });
+ defer encoded.deinit(allocator);
+
+ var decoded = try decoder.decodeJPEG(allocator, encoded.buffer, .{});
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(@as(u32, 33), decoded.width);
+ try testing.expectEqual(@as(u32, 33), decoded.height);
+}
+
+test "Fuzz: roundtrip random small images (100 iterations)" {
+ var prng = std.Random.DefaultPrng.init(0x12345678);
+ const random = prng.random();
+
+ var i: u32 = 0;
+ while (i < 100) : (i += 1) {
+ const w = random.intRangeAtMost(u32, 1, 16);
+ const h = random.intRangeAtMost(u32, 1, 16);
+
+ const pixels = try allocator.alloc(types.RGBAPixel, w * h);
+ defer allocator.free(pixels);
+ for (pixels) |*p| {
+ p.* = .{
+ .r = random.int(u8),
+ .g = random.int(u8),
+ .b = random.int(u8),
+ };
+ }
+
+ var img = types.ImageData{ .width = w, .height = h, .pixels = pixels };
+ var encoded = encoder.encodeJPEG(allocator, &img, .{ .quality = 75 }) catch continue;
+ defer encoded.deinit(allocator);
+
+ var decoded = decoder.decodeJPEG(allocator, encoded.buffer, .{}) catch continue;
+ defer decoded.deinit(allocator);
+
+ try testing.expectEqual(w, decoded.width);
+ try testing.expectEqual(h, decoded.height);
+ }
+}
diff --git a/src/io.zig b/src/io.zig
new file mode 100644
index 0000000..1bb4c47
--- /dev/null
+++ b/src/io.zig
@@ -0,0 +1,59 @@
+const std = @import("std");
+const types = @import("types.zig");
+const encoder = @import("encoder.zig");
+const decoder = @import("decoder.zig");
+
+pub fn readEntireFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
+ const file = try std.fs.cwd().openFile(path, .{});
+ defer file.close();
+ return try file.readToEndAlloc(allocator, 100 * 1024 * 1024);
+}
+
+pub fn writeEntireFile(path: []const u8, data: []const u8) !void {
+ const file = try std.fs.cwd().createFile(path, .{});
+ defer file.close();
+ try file.writeAll(data);
+}
+
+pub fn decodeFromFile(allocator: std.mem.Allocator, path: []const u8) !types.ImageData {
+ const data = try readEntireFile(allocator, path);
+ defer allocator.free(data);
+ return decoder.decodeJPEG(allocator, data, .{});
+}
+
+pub fn decodeFromFileWithOptions(allocator: std.mem.Allocator, path: []const u8, options: types.DecodeOptions) !types.ImageData {
+ const data = try readEntireFile(allocator, path);
+ defer allocator.free(data);
+ return decoder.decodeJPEG(allocator, data, options);
+}
+
+pub fn encodeToFile(allocator: std.mem.Allocator, path: []const u8, image: *const types.ImageData, options: types.EncodeOptions) !void {
+ const result = try encoder.encodeJPEG(allocator, image, options);
+ defer result.deinit(allocator);
+ try writeEntireFile(path, result.buffer);
+}
+
+pub fn encodeToPath(allocator: std.mem.Allocator, path: []const u8, pixels: []const types.RGBAPixel, width: u32, height: u32, quality: u8) !void {
+ var img = types.ImageData{
+ .width = width,
+ .height = height,
+ .pixels = @constCast(pixels),
+ };
+ try encodeToFile(allocator, path, &img, .{ .quality = quality });
+}
+
+test "readEntireFile nonexistent" {
+ const result = readEntireFile(std.testing.allocator, "nonexistent_file_12345.jpg");
+ try std.testing.expectError(error.FileNotFound, result);
+}
+
+test "writeEntireFile and readEntireFile round-trip" {
+ const data = "hello jpeg io";
+ try writeEntireFile("test_io_roundtrip.tmp", data);
+ defer std.fs.cwd().deleteFile("test_io_roundtrip.tmp") catch {};
+
+ const read = try readEntireFile(std.testing.allocator, "test_io_roundtrip.tmp");
+ defer std.testing.allocator.free(read);
+
+ try std.testing.expectEqualStrings(data, read);
+}
diff --git a/src/presets.ts b/src/presets.ts
deleted file mode 100644
index 4455f93..0000000
--- a/src/presets.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-export interface QualityPreset {
- quality: number;
- fastMode: boolean;
- description: string;
-}
-
-export const QUALITY_PRESETS: Record = {
- web: {
- quality: 75,
- fastMode: true,
- description: 'Optimized for web delivery'
- },
- print: {
- quality: 90,
- fastMode: false,
- description: 'High quality for printing'
- },
- archive: {
- quality: 95,
- fastMode: false,
- description: 'Maximum quality for archival'
- },
- thumbnail: {
- quality: 60,
- fastMode: true,
- description: 'Small file size for thumbnails'
- },
- balanced: {
- quality: 85,
- fastMode: false,
- description: 'Balance between quality and size'
- }
-};
-
-export function getPreset(name: string): QualityPreset {
- const preset = QUALITY_PRESETS[name.toLowerCase()];
- if (!preset) {
- throw new Error(`Unknown preset: ${name}. Available: ${Object.keys(QUALITY_PRESETS).join(', ')}`);
- }
- return preset;
-}
diff --git a/src/presets.zig b/src/presets.zig
new file mode 100644
index 0000000..d72bc77
--- /dev/null
+++ b/src/presets.zig
@@ -0,0 +1,41 @@
+const std = @import("std");
+const types = @import("types.zig");
+
+pub const QualityPreset = types.QualityPreset;
+
+pub const web_preset = QualityPreset{ .quality = 75, .fast_mode = true };
+pub const print_preset = QualityPreset{ .quality = 90, .fast_mode = false };
+pub const archive_preset = QualityPreset{ .quality = 95, .fast_mode = false };
+pub const thumbnail_preset = QualityPreset{ .quality = 60, .fast_mode = true };
+pub const balanced_preset = QualityPreset{ .quality = 85, .fast_mode = false };
+
+pub fn getPreset(name: []const u8) ?QualityPreset {
+ if (std.mem.eql(u8, name, "web")) return web_preset;
+ if (std.mem.eql(u8, name, "print")) return print_preset;
+ if (std.mem.eql(u8, name, "archive")) return archive_preset;
+ if (std.mem.eql(u8, name, "thumbnail")) return thumbnail_preset;
+ if (std.mem.eql(u8, name, "balanced")) return balanced_preset;
+ return null;
+}
+
+test "get existing preset" {
+ const p = getPreset("web");
+ try std.testing.expect(p != null);
+ try std.testing.expectEqual(@as(u8, 75), p.?.quality);
+ try std.testing.expect(p.?.fast_mode);
+}
+
+test "get nonexistent preset" {
+ const p = getPreset("nonexistent");
+ try std.testing.expect(p == null);
+}
+
+test "all presets have valid quality" {
+ const names = [_][]const u8{ "web", "print", "archive", "thumbnail", "balanced" };
+ for (names) |name| {
+ const p = getPreset(name);
+ try std.testing.expect(p != null);
+ try std.testing.expect(p.?.quality >= 1);
+ try std.testing.expect(p.?.quality <= 100);
+ }
+}
diff --git a/src/root.zig b/src/root.zig
new file mode 100644
index 0000000..2cb6177
--- /dev/null
+++ b/src/root.zig
@@ -0,0 +1,84 @@
+const std = @import("std");
+
+pub const core = struct {
+ pub const dct = @import("core/dct.zig");
+ pub const quantization = @import("core/quantization.zig");
+ pub const zigzag = @import("core/zigzag.zig");
+ pub const block_processor = @import("core/block_processor.zig");
+ pub const color_space = @import("core/color_space.zig");
+};
+
+pub const encoding = struct {
+ pub const huffman = @import("encoding/huffman.zig");
+ pub const bitstream = @import("encoding/bitstream.zig");
+ pub const jpeg_writer = @import("encoding/jpeg_writer.zig");
+};
+
+pub const encoder = @import("encoder.zig");
+pub const decoder = @import("decoder.zig");
+pub const presets = @import("presets.zig");
+pub const types = @import("types.zig");
+pub const io = @import("io.zig");
+
+// ============================================================================
+// Simple High-Level API
+// ============================================================================
+
+pub fn encodeToBuffer(allocator: std.mem.Allocator, image: *const types.ImageData, options: types.EncodeOptions) ![]u8 {
+ const result = try encoder.encodeJPEG(allocator, image, options);
+ return result.buffer;
+}
+
+pub fn decodeFromBuffer(allocator: std.mem.Allocator, data: []const u8, options: types.DecodeOptions) !types.ImageData {
+ return decoder.decodeJPEG(allocator, data, options);
+}
+
+/// Encode RGBA pixels to JPEG with a single quality setting.
+/// Returns JPEG bytes. Caller must free with allocator.
+pub fn encode(allocator: std.mem.Allocator, pixels: []const types.RGBAPixel, width: u32, height: u32, quality: u8) ![]u8 {
+ var img = types.ImageData{
+ .width = width,
+ .height = height,
+ .pixels = @constCast(pixels),
+ };
+ const result = try encoder.encodeJPEG(allocator, &img, .{ .quality = quality });
+ return result.buffer;
+}
+
+/// Decode JPEG bytes to RGBA pixels.
+/// Caller must free the returned ImageData (including its pixels) with the allocator.
+pub fn decode(allocator: std.mem.Allocator, jpeg_data: []const u8) !types.ImageData {
+ return decoder.decodeJPEG(allocator, jpeg_data, .{});
+}
+
+/// Encode RGBA pixels to a JPEG file on disk.
+pub fn encodeToFile(allocator: std.mem.Allocator, path: []const u8, pixels: []const types.RGBAPixel, width: u32, height: u32, quality: u8) !void {
+ var img = types.ImageData{
+ .width = width,
+ .height = height,
+ .pixels = @constCast(pixels),
+ };
+ try io.encodeToFile(allocator, path, &img, .{ .quality = quality });
+}
+
+/// Decode a JPEG file from disk to RGBA pixels.
+/// Caller must free the returned ImageData (including its pixels) with the allocator.
+pub fn decodeFromFile(allocator: std.mem.Allocator, path: []const u8) !types.ImageData {
+ return io.decodeFromFile(allocator, path);
+}
+
+test {
+ _ = core.dct;
+ _ = core.quantization;
+ _ = core.zigzag;
+ _ = core.block_processor;
+ _ = core.color_space;
+ _ = encoding.huffman;
+ _ = encoding.bitstream;
+ _ = encoding.jpeg_writer;
+ _ = encoder;
+ _ = decoder;
+ _ = presets;
+ _ = io;
+ _ = @import("integration_tests.zig");
+}
diff --git a/src/types.ts b/src/types.ts
deleted file mode 100644
index 9131ce2..0000000
--- a/src/types.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-export type RGBAPixel = [number, number, number, number];
-export type RGBPixel = [number, number, number];
-export type YCbCrPixel = [number, number, number];
-
-export interface ImageData {
- data: Uint8ClampedArray | number[];
- width: number;
- height: number;
-}
-
-export interface YCbCrImage {
- yCbCrData: YCbCrPixel[][];
- width: number;
- height: number;
-}
-
-export type Block8x8 = number[][];
-
-export interface HuffmanTable {
- dc: Record;
- ac: Record;
-}
-
-export type QuantizationMatrix = number[][];
-
-export interface EncodeOptions {
- quality?: number;
- fastMode?: boolean;
- colorSpace?: 'rgb' | 'grayscale';
- progressive?: boolean;
- preset?: string;
- onProgress?: (progress: number, stage: string) => void;
-}
-
-export interface DecodeOptions {
- outputFormat?: 'rgba' | 'rgb';
-}
-
-export interface JPEGData {
- buffer: Uint8Array;
- width: number;
- height: number;
- quality: number;
-}
-
-export interface ZigZagPattern {
- order: number[];
-}
-
-export interface DCTCoefficients {
- dc: number;
- ac: number[];
-}
-
-export interface EncodedBlock {
- y: number[][];
- cb: number[][];
- cr: number[][];
-}
diff --git a/src/types.zig b/src/types.zig
new file mode 100644
index 0000000..a869e55
--- /dev/null
+++ b/src/types.zig
@@ -0,0 +1,69 @@
+const std = @import("std");
+
+pub const RGBAPixel = struct {
+ r: u8,
+ g: u8,
+ b: u8,
+ a: u8 = 255,
+};
+
+pub const YCbCrPixel = struct {
+ y: f64,
+ cb: f64,
+ cr: f64,
+};
+
+pub const YCbCrImage = struct {
+ width: u32,
+ height: u32,
+ pixels: []YCbCrPixel,
+
+ pub fn deinit(self: *YCbCrImage, allocator: std.mem.Allocator) void {
+ allocator.free(self.pixels);
+ }
+};
+
+pub const Block8x8 = [8][8]f64;
+
+pub const ImageData = struct {
+ width: u32,
+ height: u32,
+ pixels: []RGBAPixel,
+
+ pub fn deinit(self: *ImageData, allocator: std.mem.Allocator) void {
+ allocator.free(self.pixels);
+ }
+};
+
+pub const JPEGData = struct {
+ buffer: []u8,
+ width: u32,
+ height: u32,
+ quality: u8,
+
+ pub fn deinit(self: *JPEGData, allocator: std.mem.Allocator) void {
+ allocator.free(self.buffer);
+ }
+};
+
+pub const EncodeOptions = struct {
+ quality: u8 = 75,
+ fast_mode: bool = false,
+ subsample: bool = false,
+ preset: ?[]const u8 = null,
+ on_progress: ?*const fn (progress: f32, stage: []const u8) void = null,
+};
+
+pub const DecodeOptions = struct {
+ output_format: OutputFormat = .rgba,
+};
+
+pub const OutputFormat = enum {
+ rgba,
+ rgb,
+};
+
+pub const QualityPreset = struct {
+ quality: u8,
+ fast_mode: bool,
+};
diff --git a/test/benchmark.zig b/test/benchmark.zig
new file mode 100644
index 0000000..7cba02c
--- /dev/null
+++ b/test/benchmark.zig
@@ -0,0 +1,330 @@
+const std = @import("std");
+const root = @import("jpeg_encoder");
+
+const dct = root.core.dct;
+const quantization = root.core.quantization;
+const zigzag_mod = root.core.zigzag;
+const color_space = root.core.color_space;
+const encoder = root.encoder;
+const types = root.types;
+
+fn benchDCT() !void {
+ var block: types.Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(i * 32 + j * 16 + 42);
+ }
+ }
+
+ const iterations: u32 = 10000;
+
+ var sink: f64 = 0;
+
+ var timer = try std.time.Timer.start();
+ var i: u32 = 0;
+ while (i < iterations) : (i += 1) {
+ const out = dct.dct2D(&block);
+ sink += out[0][0];
+ }
+ const standard_ns = timer.read();
+
+ timer.reset();
+ i = 0;
+ while (i < iterations) : (i += 1) {
+ const out = dct.fastDCT(&block);
+ sink += out[0][0];
+ }
+ const fast_ns = timer.read();
+
+ std.mem.doNotOptimizeAway(sink);
+
+ const std_ms = @as(f64, @floatFromInt(standard_ns)) / 1_000_000.0;
+ const fast_ms = @as(f64, @floatFromInt(fast_ns)) / 1_000_000.0;
+
+ std.debug.print("\n=== DCT Benchmark ({d} iterations) ===\n", .{iterations});
+ std.debug.print("Standard DCT: {d:.3}ms ({d:.1} us/block)\n", .{ std_ms, std_ms * 1000.0 / @as(f64, @floatFromInt(iterations)) });
+ std.debug.print("Fast DCT: {d:.3}ms ({d:.1} us/block)\n", .{ fast_ms, fast_ms * 1000.0 / @as(f64, @floatFromInt(iterations)) });
+ std.debug.print("Speedup: {d:.1}x\n\n", .{std_ms / fast_ms});
+}
+
+fn benchQuantization() !void {
+ var block: types.Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(i * 32 + j * 16);
+ }
+ }
+
+ const iterations: u32 = 10000;
+
+ var timer = try std.time.Timer.start();
+ var i: u32 = 0;
+ while (i < iterations) : (i += 1) {
+ _ = quantization.quantizeBlock(&block, 75, true);
+ }
+ const ns = timer.read();
+ const ms = @as(f64, @floatFromInt(ns)) / 1_000_000.0;
+
+ std.debug.print("=== Quantization Benchmark ({d} iterations) ===\n", .{iterations});
+ std.debug.print("Quantize: {d:.3}ms total, {d:.1} us/block\n\n", .{ ms, ms * 1000.0 / @as(f64, @floatFromInt(iterations)) });
+}
+
+fn benchZigzag() !void {
+ var block: types.Block8x8 = undefined;
+ for (0..8) |i| {
+ for (0..8) |j| {
+ block[i][j] = @floatFromInt(i * 8 + j);
+ }
+ }
+
+ const iterations: u32 = 50000;
+
+ var timer = try std.time.Timer.start();
+ var i: u32 = 0;
+ while (i < iterations) : (i += 1) {
+ _ = zigzag_mod.zigzagEncode(&block);
+ }
+ const ns = timer.read();
+ const ms = @as(f64, @floatFromInt(ns)) / 1_000_000.0;
+
+ std.debug.print("=== Zigzag Benchmark ({d} iterations) ===\n", .{iterations});
+ std.debug.print("Zigzag encode: {d:.3}ms total, {d:.2} us/block\n\n", .{ ms, ms * 1000.0 / @as(f64, @floatFromInt(iterations)) });
+}
+
+fn benchFullEncode(allocator: std.mem.Allocator) !void {
+ const width: u32 = 256;
+ const height: u32 = 256;
+ const total: usize = @as(usize, @intCast(width)) * @as(usize, @intCast(height));
+ var pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ var rng = std.Random.DefaultPrng.init(12345);
+ for (0..total) |i| {
+ pixels[i] = .{
+ .r = rng.random().int(u8),
+ .g = rng.random().int(u8),
+ .b = rng.random().int(u8),
+ };
+ }
+
+ var img = types.ImageData{
+ .width = width,
+ .height = height,
+ .pixels = pixels,
+ };
+
+ const iterations: u32 = 5;
+
+ var timer = try std.time.Timer.start();
+ var iter: u32 = 0;
+ while (iter < iterations) : (iter += 1) {
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75 });
+ result.deinit(allocator);
+ }
+ const ns = timer.read();
+ const ms = @as(f64, @floatFromInt(ns)) / 1_000_000.0;
+ const pixels_per_sec = @as(f64, @floatFromInt(total)) * @as(f64, @floatFromInt(iterations)) / (ms / 1000.0);
+
+ std.debug.print("=== Full Encode Benchmark ({d}x{d} @ q75, {d} iterations) ===\n", .{ width, height, iterations });
+ std.debug.print("Total time: {d:.3}ms\n", .{ms});
+ std.debug.print("Per frame: {d:.3}ms\n", .{ms / @as(f64, @floatFromInt(iterations))});
+ std.debug.print("Throughput: {d:.0} pixels/sec\n", .{pixels_per_sec});
+ std.debug.print("Megapixels/s: {d:.2}\n\n", .{pixels_per_sec / 1_000_000.0});
+}
+
+fn benchColorConversion(allocator: std.mem.Allocator) !void {
+ const total: usize = 256 * 256;
+ var pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ for (0..total) |i| {
+ pixels[i] = .{ .r = @intCast(i % 256), .g = @intCast((i / 256) % 256), .b = @intCast((i * 3) % 256) };
+ }
+
+ const iterations: u32 = 100;
+
+ var timer = try std.time.Timer.start();
+ var i: u32 = 0;
+ while (i < iterations) : (i += 1) {
+ var ycbcr = try color_space.convertImageToYCbCr(pixels, 256, 256, allocator);
+ ycbcr.deinit(allocator);
+ }
+ const ns = timer.read();
+ const ms = @as(f64, @floatFromInt(ns)) / 1_000_000.0;
+
+ std.debug.print("=== Color Conversion Benchmark (256x256, {d} iterations) ===\n", .{iterations});
+ std.debug.print("Total: {d:.3}ms, Per frame: {d:.3}ms\n\n", .{ ms, ms / @as(f64, @floatFromInt(iterations)) });
+}
+
+fn benchMultiSizeScaling(allocator: std.mem.Allocator) !void {
+ const sizes = [_][2]u32{ .{ 1, 1 }, .{ 8, 8 }, .{ 64, 64 }, .{ 256, 256 }, .{ 1024, 1024 } };
+
+ std.debug.print("=== Multi-Size Scaling Benchmark (encode @ q75) ===\n", .{});
+ std.debug.print("{s:>10} {s:>12} {s:>12} {s:>12}\n", .{ "Size", "Encode(ms)", "Pixels/sec", "us/pixel" });
+
+ for (sizes) |dim| {
+ const w = dim[0];
+ const h = dim[1];
+ const total: usize = @as(usize, w) * @as(usize, h);
+ var pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ var rng = std.Random.DefaultPrng.init(42);
+ for (0..total) |i| {
+ pixels[i] = .{
+ .r = rng.random().int(u8),
+ .g = rng.random().int(u8),
+ .b = rng.random().int(u8),
+ };
+ }
+
+ var img = types.ImageData{ .width = w, .height = h, .pixels = pixels };
+
+ const iterations: u32 = if (w <= 8) 100 else if (w <= 64) 20 else if (w <= 256) 5 else 2;
+
+ var timer = try std.time.Timer.start();
+ var iter: u32 = 0;
+ while (iter < iterations) : (iter += 1) {
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75 });
+ result.deinit(allocator);
+ }
+ const ns = timer.read();
+ const ms_per_frame = @as(f64, @floatFromInt(ns)) / (@as(f64, @floatFromInt(iterations)) * 1_000_000.0);
+ const px_per_sec = @as(f64, @floatFromInt(total)) / (ms_per_frame / 1000.0);
+ const us_per_pixel = ms_per_frame * 1000.0 / @as(f64, @floatFromInt(total));
+
+ std.debug.print("{d:>4}x{d:<4} {d:>11.3} {d:>11.0} {d:>11.3}\n", .{ w, h, ms_per_frame, px_per_sec, us_per_pixel });
+ }
+ std.debug.print("\n", .{});
+}
+
+fn benchDecodeMultiSize(allocator: std.mem.Allocator) !void {
+ const sizes = [_][2]u32{ .{ 1, 1 }, .{ 8, 8 }, .{ 64, 64 }, .{ 256, 256 }, .{ 1024, 1024 } };
+
+ std.debug.print("=== Multi-Size Decode Benchmark (q75) ===\n", .{});
+ std.debug.print("{s:>10} {s:>12} {s:>12} {s:>12}\n", .{ "Size", "Decode(ms)", "Pixels/sec", "us/pixel" });
+
+ for (sizes) |dim| {
+ const w = dim[0];
+ const h = dim[1];
+ const total: usize = @as(usize, w) * @as(usize, h);
+ var pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ var rng = std.Random.DefaultPrng.init(42);
+ for (0..total) |i| {
+ pixels[i] = .{
+ .r = rng.random().int(u8),
+ .g = rng.random().int(u8),
+ .b = rng.random().int(u8),
+ };
+ }
+
+ var img = types.ImageData{ .width = w, .height = h, .pixels = pixels };
+ var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 75 });
+ defer encoded.deinit(allocator);
+
+ const iterations: u32 = if (w <= 8) 100 else if (w <= 64) 20 else if (w <= 256) 5 else 2;
+
+ var timer = try std.time.Timer.start();
+ var iter: u32 = 0;
+ while (iter < iterations) : (iter += 1) {
+ var result = try root.decodeFromBuffer(allocator, encoded.buffer, .{});
+ result.deinit(allocator);
+ }
+ const ns = timer.read();
+ const ms_per_frame = @as(f64, @floatFromInt(ns)) / (@as(f64, @floatFromInt(iterations)) * 1_000_000.0);
+ const px_per_sec = @as(f64, @floatFromInt(total)) / (ms_per_frame / 1000.0);
+ const us_per_pixel = ms_per_frame * 1000.0 / @as(f64, @floatFromInt(total));
+
+ std.debug.print("{d:>4}x{d:<4} {d:>11.3} {d:>11.0} {d:>11.3}\n", .{ w, h, ms_per_frame, px_per_sec, us_per_pixel });
+ }
+ std.debug.print("\n", .{});
+}
+
+fn benchRoundtrip(allocator: std.mem.Allocator) !void {
+ const sizes = [_][2]u32{ .{ 8, 8 }, .{ 64, 64 }, .{ 256, 256 } };
+ const qualities = [_]u8{ 10, 50, 75, 95 };
+
+ std.debug.print("=== Roundtrip Benchmark (encode + decode) ===\n", .{});
+ std.debug.print("{s:>10} {s:>8} {s:>12} {s:>12} {s:>10}\n", .{ "Size", "Quality", "RT(ms)", "Encode(ms)", "Decode(ms)" });
+
+ for (sizes) |dim| {
+ for (qualities) |q| {
+ const w = dim[0];
+ const h = dim[1];
+ const total: usize = @as(usize, w) * @as(usize, h);
+ var pixels = try allocator.alloc(types.RGBAPixel, total);
+ defer allocator.free(pixels);
+
+ var rng = std.Random.DefaultPrng.init(42);
+ for (0..total) |i| {
+ pixels[i] = .{
+ .r = rng.random().int(u8),
+ .g = rng.random().int(u8),
+ .b = rng.random().int(u8),
+ };
+ }
+
+ var img = types.ImageData{ .width = w, .height = h, .pixels = pixels };
+
+ const iterations: u32 = if (w <= 8) 50 else if (w <= 64) 10 else 3;
+
+ var timer = try std.time.Timer.start();
+ var iter: u32 = 0;
+ while (iter < iterations) : (iter += 1) {
+ var enc = try encoder.encodeJPEG(allocator, &img, .{ .quality = q });
+ defer enc.deinit(allocator);
+
+ var dec = try root.decodeFromBuffer(allocator, enc.buffer, .{});
+ dec.deinit(allocator);
+ }
+ const total_ns = timer.read();
+
+ timer.reset();
+ iter = 0;
+ while (iter < iterations) : (iter += 1) {
+ var enc = try encoder.encodeJPEG(allocator, &img, .{ .quality = q });
+ enc.deinit(allocator);
+ }
+ const enc_ns = timer.read();
+
+ timer.reset();
+ var enc_final = try encoder.encodeJPEG(allocator, &img, .{ .quality = q });
+ defer enc_final.deinit(allocator);
+ iter = 0;
+ while (iter < iterations) : (iter += 1) {
+ var dec = try root.decodeFromBuffer(allocator, enc_final.buffer, .{});
+ dec.deinit(allocator);
+ }
+ const dec_ns = timer.read();
+
+ const rt_ms = @as(f64, @floatFromInt(total_ns)) / (@as(f64, @floatFromInt(iterations)) * 1_000_000.0);
+ const enc_ms = @as(f64, @floatFromInt(enc_ns)) / (@as(f64, @floatFromInt(iterations)) * 1_000_000.0);
+ const dec_ms = @as(f64, @floatFromInt(dec_ns)) / (@as(f64, @floatFromInt(iterations)) * 1_000_000.0);
+
+ std.debug.print("{d:>4}x{d:<4} {d:>7} {d:>11.3} {d:>11.3} {d:>9.3}\n", .{ w, h, q, rt_ms, enc_ms, dec_ms });
+ }
+ }
+ std.debug.print("\n", .{});
+}
+
+pub fn main() !void {
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+ defer _ = gpa.deinit();
+ const allocator = gpa.allocator();
+
+ std.debug.print("\n", .{});
+ std.debug.print("=== JPEG Encoder Zig - Benchmark Suite ===\n", .{});
+
+ try benchDCT();
+ try benchQuantization();
+ try benchZigzag();
+ try benchColorConversion(allocator);
+ try benchFullEncode(allocator);
+ try benchMultiSizeScaling(allocator);
+ try benchDecodeMultiSize(allocator);
+ try benchRoundtrip(allocator);
+
+ std.debug.print("Benchmarks complete.\n", .{});
+}
diff --git a/test/dct.test.ts b/test/dct.test.ts
deleted file mode 100644
index 8f01196..0000000
--- a/test/dct.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { describe, test, expect } from 'bun:test';
-import { dct2D, idct2D, fastDCT } from '../src/core/dct';
-
-describe('DCT', () => {
- test('should perform 2D DCT on 8x8 block', () => {
- const block = Array.from({ length: 8 }, () => Array(8).fill(128));
- const result = dct2D(block);
-
- expect(result).toBeDefined();
- expect(result.length).toBe(8);
- expect(result[0].length).toBe(8);
- expect(Math.abs(result[0][0])).toBeLessThan(1);
- });
-
- test('should perform inverse DCT correctly', () => {
- const original = Array.from({ length: 8 }, (_, i) =>
- Array.from({ length: 8 }, (_, j) => 128 + i + j)
- );
-
- const dct = dct2D(original);
- const reconstructed = idct2D(dct);
-
- for (let i = 0; i < 8; i++) {
- for (let j = 0; j < 8; j++) {
- expect(Math.abs(reconstructed[i][j] - original[i][j])).toBeLessThan(2);
- }
- }
- });
-
- test('fast DCT should be faster than standard DCT', () => {
- const block = Array.from({ length: 8 }, () => Array(8).fill(128));
-
- const start1 = performance.now();
- for (let i = 0; i < 100; i++) {
- dct2D(block);
- }
- const time1 = performance.now() - start1;
-
- const start2 = performance.now();
- for (let i = 0; i < 100; i++) {
- fastDCT(block);
- }
- const time2 = performance.now() - start2;
-
- expect(time2).toBeLessThan(time1);
- });
-});
diff --git a/test/fuzz_decoder.zig b/test/fuzz_decoder.zig
new file mode 100644
index 0000000..65082f7
--- /dev/null
+++ b/test/fuzz_decoder.zig
@@ -0,0 +1,41 @@
+const std = @import("std");
+const decoder = @import("jpeg_encoder").decoder;
+
+pub fn main() !void {
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+ defer _ = gpa.deinit();
+ const allocator = gpa.allocator();
+
+ const args = try std.process.argsAlloc(allocator);
+ defer std.process.argsFree(allocator, args);
+
+ if (args.len < 2) {
+ const stderr = std.io.getStdErr().writer();
+ try stderr.print("Usage: {s} \n", .{args[0]});
+ try stderr.print(" Feeds the file contents to the JPEG decoder.\n", .{});
+ try stderr.print(" Should never panic, only return errors.\n", .{});
+ return error.MissingArgument;
+ }
+
+ const file = try std.fs.cwd().openFile(args[1], .{});
+ defer file.close();
+
+ const data = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
+ defer allocator.free(data);
+
+ const stdout = std.io.getStdOut().writer();
+
+ const result = decoder.decodeJPEG(allocator, data, .{});
+ if (result) |img| {
+ var mutable_img = img;
+ defer mutable_img.deinit(allocator);
+ try stdout.print("OK: {d}x{d} ({d} bytes) ({d} pixels)\n", .{
+ img.width,
+ img.height,
+ data.len,
+ img.pixels.len,
+ });
+ } else |err| {
+ try stdout.print("Error: {s} (input {d} bytes)\n", .{ @errorName(err), data.len });
+ }
+}
diff --git a/test/fuzz_encoder.zig b/test/fuzz_encoder.zig
new file mode 100644
index 0000000..155a5ce
--- /dev/null
+++ b/test/fuzz_encoder.zig
@@ -0,0 +1,62 @@
+const std = @import("std");
+const encoder = @import("jpeg_encoder").encoder;
+const types = @import("jpeg_encoder").types;
+
+pub fn main() !void {
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+ defer _ = gpa.deinit();
+ const allocator = gpa.allocator();
+
+ const args = try std.process.argsAlloc(allocator);
+ defer std.process.argsFree(allocator, args);
+
+ if (args.len < 2) {
+ const stderr = std.io.getStdErr().writer();
+ try stderr.print("Usage: {s} \n", .{args[0]});
+ try stderr.print(" Feeds the file contents as raw pixels to the JPEG encoder.\n", .{});
+ try stderr.print(" First 4 bytes: width (u32 LE), next 4 bytes: height (u32 LE)\n", .{});
+ try stderr.print(" Rest: RGBA pixel data.\n", .{});
+ return error.MissingArgument;
+ }
+
+ const file = try std.fs.cwd().openFile(args[1], .{});
+ defer file.close();
+
+ const data = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
+ defer allocator.free(data);
+
+ const stdout = std.io.getStdOut().writer();
+
+ if (data.len < 8) {
+ try stdout.print("Error: input too short for header (need >= 8 bytes)\n", .{});
+ return;
+ }
+
+ const w: u32 = std.mem.readInt(u32, data[0..4], .little);
+ const h: u32 = std.mem.readInt(u32, data[4..8], .little);
+
+ const pixel_bytes = @as(usize, w) * @as(usize, h) * 4;
+ if (data.len - 8 < pixel_bytes) {
+ try stdout.print("Error: not enough pixel data (need {d} bytes, have {d})\n", .{
+ pixel_bytes,
+ data.len - 8,
+ });
+ return;
+ }
+
+ const pixel_data = @as([*]const types.RGBAPixel, @alignCast(@ptrCast(data[8..].ptr)))[0 .. w * h];
+ var img = types.ImageData{ .width = w, .height = h, .pixels = @constCast(pixel_data) };
+
+ var result = encoder.encodeJPEG(allocator, &img, .{}) catch |err| {
+ try stdout.print("Error: {s}\n", .{@errorName(err)});
+ return;
+ };
+ defer result.deinit(allocator);
+
+ try stdout.print("OK: {d}x{d} -> {d} bytes (quality={d})\n", .{
+ w,
+ h,
+ result.buffer.len,
+ result.quality,
+ });
+}
diff --git a/test/huffman.test.ts b/test/huffman.test.ts
deleted file mode 100644
index a03a176..0000000
--- a/test/huffman.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { describe, test, expect } from 'bun:test';
-import { encodeHuffmanDC, encodeHuffmanAC, generateHuffmanTable } from '../src/encoding/huffman';
-
-describe('Huffman Encoding', () => {
- test('should encode DC coefficient', () => {
- const code = encodeHuffmanDC(5, true);
-
- expect(code).toBeDefined();
- expect(typeof code).toBe('string');
- expect(code.length).toBeGreaterThan(0);
- });
-
- test('should encode AC coefficient', () => {
- const code = encodeHuffmanAC(0, 5, true);
-
- expect(code).toBeDefined();
- expect(typeof code).toBe('string');
- });
-
- test('should generate Huffman tables', () => {
- const tables = generateHuffmanTable();
-
- expect(tables).toBeDefined();
- expect(tables.dc).toBeDefined();
- expect(tables.ac).toBeDefined();
- });
-
- test('should handle negative values', () => {
- const positive = encodeHuffmanDC(5, true);
- const negative = encodeHuffmanDC(-5, true);
-
- expect(positive).toBeDefined();
- expect(negative).toBeDefined();
- expect(positive).not.toBe(negative);
- });
-
- test('chrominance tables should differ from luminance', () => {
- const lumDC = encodeHuffmanDC(5, true);
- const chrDC = encodeHuffmanDC(5, false);
-
- expect(lumDC).not.toBe(chrDC);
- });
-});
diff --git a/test/quantization.test.ts b/test/quantization.test.ts
deleted file mode 100644
index ea76464..0000000
--- a/test/quantization.test.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { describe, test, expect } from 'bun:test';
-import { quantizeBlock, dequantizeBlock, getQuantizationMatrix } from '../src/core/quantization';
-
-describe('Quantization', () => {
- test('should quantize DCT block correctly', () => {
- const dctBlock = Array.from({ length: 8 }, (_, i) =>
- Array.from({ length: 8 }, (_, j) => i * 8 + j)
- );
-
- const quantized = quantizeBlock(dctBlock, 50, true);
-
- expect(quantized).toBeDefined();
- expect(quantized.length).toBe(8);
- expect(quantized[0].length).toBe(8);
- });
-
- test('should dequantize block correctly', () => {
- const quantBlock = Array.from({ length: 8 }, () => Array(8).fill(1));
- const dequantized = dequantizeBlock(quantBlock, 50, true);
-
- expect(dequantized).toBeDefined();
- expect(dequantized[0][0]).toBeGreaterThan(0);
- });
-
- test('higher quality should use smaller quantization values', () => {
- const lowQuality = getQuantizationMatrix(10, true);
- const highQuality = getQuantizationMatrix(90, true);
-
- expect(lowQuality[0][0]).toBeGreaterThan(highQuality[0][0]);
- });
-
- test('chrominance matrix should differ from luminance', () => {
- const lum = getQuantizationMatrix(50, true);
- const chr = getQuantizationMatrix(50, false);
-
- expect(lum[0][0]).not.toBe(chr[0][0]);
- });
-});
diff --git a/test/verify.zig b/test/verify.zig
new file mode 100644
index 0000000..9933d21
--- /dev/null
+++ b/test/verify.zig
@@ -0,0 +1,310 @@
+const std = @import("std");
+const root = @import("jpeg_encoder");
+
+const types = root.types;
+const encoder = root.encoder;
+
+const Allocator = std.mem.Allocator;
+
+fn makeGradientPixels(allocator: Allocator, w: u32, h: u32) ![]types.RGBAPixel {
+ const ww: usize = @intCast(w);
+ const hh: usize = @intCast(h);
+ const total: usize = ww * hh;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ for (0..total) |i| {
+ const x = i % ww;
+ const y = i / ww;
+ pixels[i] = .{
+ .r = @intCast(x * 255 / ww),
+ .g = @intCast(y * 255 / hh),
+ .b = @intCast((x +% y) * 127 / (ww + hh)),
+ };
+ }
+ return pixels;
+}
+
+fn makeRandomPixels(allocator: Allocator, w: u32, h: u32, seed: u64) ![]types.RGBAPixel {
+ const ww: usize = @intCast(w);
+ const hh: usize = @intCast(h);
+ const total: usize = ww * hh;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ var rng = std.Random.DefaultPrng.init(seed);
+ for (0..total) |i| {
+ pixels[i] = .{
+ .r = rng.random().int(u8),
+ .g = rng.random().int(u8),
+ .b = rng.random().int(u8),
+ };
+ }
+ return pixels;
+}
+
+fn makeCheckerboardPixels(allocator: Allocator, w: u32, h: u32) ![]types.RGBAPixel {
+ const ww: usize = @intCast(w);
+ const hh: usize = @intCast(h);
+ const total: usize = ww * hh;
+ const pixels = try allocator.alloc(types.RGBAPixel, total);
+ for (0..total) |i| {
+ const x = i % ww;
+ const y = i / ww;
+ const is_white = ((x / 8) + (y / 8)) % 2 == 0;
+ if (is_white) {
+ pixels[i] = .{ .r = 255, .g = 255, .b = 255 };
+ } else {
+ pixels[i] = .{ .r = 0, .g = 0, .b = 0 };
+ }
+ }
+ return pixels;
+}
+
+fn printHexDump(data: []const u8, max_bytes: usize) void {
+ const limit = @min(data.len, max_bytes);
+ var i: usize = 0;
+ while (i < limit) : (i += 16) {
+ std.debug.print(" {d:0>6}: ", .{i});
+ var j: usize = 0;
+ while (j < 16 and i + j < limit) : (j += 1) {
+ std.debug.print("{X:0>2} ", .{data[i + j]});
+ }
+ while (j < 16) {
+ std.debug.print(" ", .{});
+ j += 1;
+ }
+ std.debug.print(" |", .{});
+ j = 0;
+ while (j < 16 and i + j < limit) : (j += 1) {
+ const b = data[i + j];
+ if (b >= 0x20 and b < 0x7F) {
+ std.debug.print("{c}", .{b});
+ } else {
+ std.debug.print(".", .{});
+ }
+ }
+ std.debug.print("|\n", .{});
+ }
+}
+
+fn getMarkerName(marker: u8) []const u8 {
+ return switch (marker) {
+ 0xD8 => "SOI (Start of Image)",
+ 0xD9 => "EOI (End of Image)",
+ 0xE0 => "APP0 (JFIF)",
+ 0xE1 => "APP1 (EXIF)",
+ 0xDB => "DQT (Quantization Table)",
+ 0xC0 => "SOF0 (Start of Frame, Baseline)",
+ 0xC2 => "SOF2 (Start of Frame, Progressive)",
+ 0xC4 => "DHT (Huffman Table)",
+ 0xDA => "SOS (Start of Scan)",
+ 0xDD => "DRI (Restart Interval)",
+ 0xFE => "COM (Comment)",
+ else => "UNKNOWN",
+ };
+}
+
+const ValidationResult = struct {
+ ok: bool,
+ error_msg: []const u8,
+};
+
+fn validateJpeg(buf: []const u8, expect_w: u32, expect_h: u32, label: []const u8) ValidationResult {
+ std.debug.print("\n", .{});
+ std.debug.print("=" ++ "=" ** 60 ++ "\n", .{});
+ std.debug.print(" VALIDATING: {s}\n", .{label});
+ std.debug.print(" File size: {d} bytes\n", .{buf.len});
+ std.debug.print("=" ++ "=" ** 60 ++ "\n", .{});
+
+ if (buf.len < 4) return .{ .ok = false, .error_msg = "file too small" };
+
+ if (buf[0] != 0xFF or buf[1] != 0xD8) {
+ std.debug.print(" FAIL: missing SOI marker (got {X:0>2}{X:0>2})\n", .{ buf[0], buf[1] });
+ return .{ .ok = false, .error_msg = "missing SOI" };
+ }
+ std.debug.print(" [OK] SOI marker: FF D8\n", .{});
+
+ if (buf[buf.len - 2] != 0xFF or buf[buf.len - 1] != 0xD9) {
+ std.debug.print(" FAIL: missing EOI marker\n", .{});
+ return .{ .ok = false, .error_msg = "missing EOI" };
+ }
+ std.debug.print(" [OK] EOI marker: FF D9\n", .{});
+
+ std.debug.print("\n --- Marker walkthrough ---\n", .{});
+ var pos: usize = 2;
+ var marker_count: u32 = 0;
+ var found_sof = false;
+ var found_sos = false;
+ var found_dqt = false;
+ var found_dht = false;
+ var found_app0 = false;
+
+ while (pos + 1 < buf.len) {
+ if (buf[pos] != 0xFF) {
+ std.debug.print(" [INFO] byte at offset {d}: 0x{X:0>2} (start of compressed data)\n", .{ pos, buf[pos] });
+ break;
+ }
+ const marker = buf[pos + 1];
+ pos += 2;
+
+ if (marker == 0xD9) {
+ std.debug.print(" [{d}] offset {d}: FF D9 = EOI\n", .{ marker_count, pos - 2 });
+ marker_count += 1;
+ break;
+ }
+
+ if (marker == 0x00 or (marker >= 0xD0 and marker <= 0xD7)) {
+ marker_count += 1;
+ continue;
+ }
+
+ if (pos + 1 >= buf.len) {
+ return .{ .ok = false, .error_msg = "truncated marker" };
+ }
+
+ const seg_len = (@as(u16, buf[pos]) << 8) | buf[pos + 1];
+ pos += 2;
+
+ std.debug.print(" [{d}] offset {d}: FF {X:0>2} = {s}, length={d}\n", .{ marker_count, pos - 4, marker, getMarkerName(marker), seg_len });
+ marker_count += 1;
+
+ if (marker == 0xE0) {
+ found_app0 = true;
+ const ident = buf[pos .. pos + 5];
+ std.debug.print(" Identifier: {s}\n", .{ident});
+ if (ident[0] != 'J' or ident[1] != 'F' or ident[2] != 'I' or ident[3] != 'F' or ident[4] != 0) {
+ return .{ .ok = false, .error_msg = "APP0 is not JFIF" };
+ }
+ std.debug.print(" [OK] JFIF identifier valid\n", .{});
+ } else if (marker == 0xDB) {
+ found_dqt = true;
+ const table_info = buf[pos];
+ const table_id = table_info & 0x0F;
+ const table_precision = (table_info >> 4) & 0x0F;
+ std.debug.print(" Table ID: {d}, Precision: {d} (0=8bit)\n", .{ table_id, table_precision });
+ } else if (marker == 0xC0) {
+ found_sof = true;
+ const precision = buf[pos];
+ const h = (@as(u16, buf[pos + 1]) << 8) | buf[pos + 2];
+ const w = (@as(u16, buf[pos + 3]) << 8) | buf[pos + 4];
+ const num_components = buf[pos + 5];
+ std.debug.print(" Precision: {d} bits\n", .{precision});
+ std.debug.print(" Dimensions: {d}x{d} (expected {d}x{d})\n", .{ w, h, expect_w, expect_h });
+ std.debug.print(" Components: {d}\n", .{num_components});
+ if (w != expect_w or h != expect_h) {
+ return .{ .ok = false, .error_msg = "dimension mismatch" };
+ }
+ if (precision != 8) return .{ .ok = false, .error_msg = "precision != 8" };
+ if (num_components != 3) return .{ .ok = false, .error_msg = "components != 3" };
+ std.debug.print(" [OK] SOF0 validated\n", .{});
+ } else if (marker == 0xC4) {
+ found_dht = true;
+ } else if (marker == 0xDA) {
+ found_sos = true;
+ const num_components = buf[pos];
+ std.debug.print(" Components: {d}\n", .{num_components});
+ std.debug.print(" [OK] SOS present\n", .{});
+ }
+
+ pos += @intCast(seg_len - 2);
+ }
+
+ std.debug.print("\n --- Summary ---\n", .{});
+ std.debug.print(" Total markers: {d}\n", .{marker_count});
+ std.debug.print(" APP0 (JFIF): {s}\n", .{if (found_app0) "YES" else "NO"});
+ std.debug.print(" DQT: {s}\n", .{if (found_dqt) "YES" else "NO"});
+ std.debug.print(" SOF0: {s}\n", .{if (found_sof) "YES" else "NO"});
+ std.debug.print(" DHT: {s}\n", .{if (found_dht) "YES" else "NO"});
+ std.debug.print(" SOS: {s}\n", .{if (found_sos) "YES" else "NO"});
+ std.debug.print(" EOI: YES\n", .{});
+
+ if (!found_app0 or !found_dqt or !found_sof or !found_dht or !found_sos) {
+ return .{ .ok = false, .error_msg = "missing required markers" };
+ }
+
+ std.debug.print("\n [OK] ALL CHECKS PASSED - JPEG is structurally valid\n", .{});
+ std.debug.print(" First 128 bytes hex dump:\n", .{});
+ printHexDump(buf, 128);
+
+ return .{ .ok = true, .error_msg = "" };
+}
+
+fn writeJpegToFile(buf: []const u8, path: []const u8) !void {
+ const file = try std.fs.cwd().createFile(path, .{});
+ defer file.close();
+ try file.writeAll(buf);
+}
+
+const TestCase = struct {
+ w: u32,
+ h: u32,
+ quality: u8,
+ fast_mode: bool,
+ label: []const u8,
+ filename: []const u8,
+ seed: u64,
+ pattern: enum { gradient, random, checkerboard },
+};
+
+pub fn main() !void {
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+ defer _ = gpa.deinit();
+ const allocator = gpa.allocator();
+
+ std.debug.print("\n", .{});
+ std.debug.print("==============================================================\n", .{});
+ std.debug.print(" JPEG Encoder - END-TO-END VERIFICATION \n", .{});
+ std.debug.print(" Writes real .jpg files + deep byte-level validation \n", .{});
+ std.debug.print("==============================================================\n", .{});
+
+ const output_dir = "output";
+ std.fs.cwd().makeDir(output_dir) catch {};
+
+ const test_cases = [_]TestCase{
+ .{ .w = 256, .h = 256, .quality = 75, .fast_mode = false, .label = "256x256 gradient @ q75", .filename = "gradient_256x256_q75.jpg", .seed = 0, .pattern = .gradient },
+ .{ .w = 256, .h = 256, .quality = 10, .fast_mode = false, .label = "256x256 gradient @ q10", .filename = "gradient_256x256_q10.jpg", .seed = 0, .pattern = .gradient },
+ .{ .w = 256, .h = 256, .quality = 95, .fast_mode = false, .label = "256x256 gradient @ q95", .filename = "gradient_256x256_q95.jpg", .seed = 0, .pattern = .gradient },
+ .{ .w = 64, .h = 64, .quality = 75, .fast_mode = false, .label = "64x64 checkerboard @ q75", .filename = "checkerboard_64x64_q75.jpg", .seed = 0, .pattern = .checkerboard },
+ .{ .w = 512, .h = 512, .quality = 80, .fast_mode = false, .label = "512x512 random noise @ q80", .filename = "noise_512x512_q80.jpg", .seed = 0xDEADBEEF, .pattern = .random },
+ .{ .w = 1024, .h = 1024, .quality = 85, .fast_mode = false, .label = "1024x1024 gradient @ q85", .filename = "gradient_1024x1024_q85.jpg", .seed = 0, .pattern = .gradient },
+ .{ .w = 1920, .h = 1080, .quality = 75, .fast_mode = false, .label = "1920x1080 gradient @ q75 (Full HD)", .filename = "gradient_1920x1080_q75.jpg", .seed = 0, .pattern = .gradient },
+ .{ .w = 8, .h = 8, .quality = 50, .fast_mode = false, .label = "8x8 gradient @ q50 (minimum)", .filename = "gradient_8x8_q50.jpg", .seed = 0, .pattern = .gradient },
+ .{ .w = 333, .h = 177, .quality = 75, .fast_mode = false, .label = "333x177 random @ q75 (odd dims)", .filename = "random_333x177_q75.jpg", .seed = 42, .pattern = .random },
+ .{ .w = 256, .h = 256, .quality = 75, .fast_mode = true, .label = "256x256 gradient @ q75 FAST MODE", .filename = "gradient_256x256_q75_fast.jpg", .seed = 0, .pattern = .gradient },
+ };
+
+ var total_pass: u32 = 0;
+ var total_fail: u32 = 0;
+
+ for (test_cases) |tc| {
+ const pixels = switch (tc.pattern) {
+ .gradient => try makeGradientPixels(allocator, tc.w, tc.h),
+ .random => try makeRandomPixels(allocator, tc.w, tc.h, tc.seed),
+ .checkerboard => try makeCheckerboardPixels(allocator, tc.w, tc.h),
+ };
+ defer allocator.free(pixels);
+
+ var img = types.ImageData{ .width = tc.w, .height = tc.h, .pixels = pixels };
+ var result = try encoder.encodeJPEG(allocator, &img, .{ .quality = tc.quality, .fast_mode = tc.fast_mode });
+ defer result.deinit(allocator);
+
+ var path_buf: [256]u8 = undefined;
+ const path = std.fmt.bufPrint(&path_buf, output_dir ++ "/{s}", .{tc.filename}) catch "output/unknown.jpg";
+ try writeJpegToFile(result.buffer, path);
+
+ const v = validateJpeg(result.buffer, tc.w, tc.h, tc.label);
+ if (v.ok) {
+ std.debug.print(" Written to: {s} ({d} bytes)\n", .{ path, result.buffer.len });
+ total_pass += 1;
+ } else {
+ std.debug.print(" FAILED: {s}\n", .{v.error_msg});
+ total_fail += 1;
+ }
+ }
+
+ std.debug.print("\n", .{});
+ std.debug.print("==============================================================\n", .{});
+ std.debug.print(" FINAL: {d} PASSED, {d} FAILED out of {d} tests\n", .{ total_pass, total_fail, test_cases.len });
+ std.debug.print("==============================================================\n", .{});
+ std.debug.print("\n Output files: {s}/\n", .{output_dir});
+ std.debug.print(" Open them in any image viewer to verify visually!\n\n", .{});
+
+ if (total_fail > 0) return error.TestsFailed;
+}
diff --git a/test/zigzag.test.ts b/test/zigzag.test.ts
deleted file mode 100644
index cc36510..0000000
--- a/test/zigzag.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { describe, test, expect } from 'bun:test';
-import { zigzagEncode, zigzagDecode, runLengthEncode } from '../src/core/zigzag';
-
-describe('Zigzag Encoding', () => {
- test('should encode block in zigzag order', () => {
- const block = Array.from({ length: 8 }, (_, i) =>
- Array.from({ length: 8 }, (_, j) => i * 8 + j)
- );
-
- const encoded = zigzagEncode(block);
-
- expect(encoded).toBeDefined();
- expect(encoded.length).toBe(64);
- expect(encoded[0]).toBe(0);
- expect(encoded[1]).toBe(1);
- });
-
- test('should decode zigzag array back to block', () => {
- const original = Array.from({ length: 8 }, (_, i) =>
- Array.from({ length: 8 }, (_, j) => i * 8 + j)
- );
-
- const encoded = zigzagEncode(original);
- const decoded = zigzagDecode(encoded);
-
- expect(decoded).toEqual(original);
- });
-
- test('should perform run-length encoding', () => {
- const data = [5, 0, 0, 0, 3, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 1];
- const rle = runLengthEncode(data);
-
- expect(rle).toBeDefined();
- expect(rle.length).toBeLessThan(data.length);
- });
-
- test('should handle all zeros correctly', () => {
- const data = Array(64).fill(0);
- const rle = runLengthEncode(data);
-
- expect(rle[rle.length - 1]).toEqual([0, 0]);
- });
-});
diff --git a/tsconfig.json b/tsconfig.json
deleted file mode 100644
index 0d7941a..0000000
--- a/tsconfig.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "compilerOptions": {
- "target": "ESNext",
- "module": "ESNext",
- "lib": [
- "ESNext"
- ],
- "moduleResolution": "bundler",
- "outDir": "./dist",
- "rootDir": "./src",
- "strict": true,
- "esModuleInterop": true,
- "skipLibCheck": true,
- "forceConsistentCasingInFileNames": true,
- "resolveJsonModule": true,
- "allowImportingTsExtensions": true,
- "noEmit": true,
- "types": [
- "bun-types"
- ]
- },
- "include": [
- "src/**/*"
- ],
- "exclude": [
- "node_modules",
- "dist"
- ]
-}
\ No newline at end of file