jpeg: arithmetic scans take zeros at a marker without limit

libjpeg's encoder drops trailing zero bytes, so a flat tail of a few thousand blocks decodes from zeros supplied at EOI. Damage is data that runs to the end of the file or a lost restart marker. whitetail-ari.jpg (cjpeg -arithmetic) and its Huffman transcode cover it.
This commit is contained in:
aiongg committed 2026-10-24 22:02:59 +00:00
1 parent 35bd2f3891
commit e96bf9552b
9 files changed
+86 -29

No files matched your search

+6 -1
View File
@@ -57,7 +57,10 @@ if img.damaged { /* warn: part of the image is missing */ }
- `damaged` is set when entropy-coded data was cut short or corrupt after at least one MCU had
decoded, or its scans would decode more than 64 passes over the frame's blocks. The rest of
that scan, and every scan past the budget, is left out; those blocks keep what earlier scans
decoded. A stream with no decodable image data is an error.
decoded. A stream with no decodable image data is an error. Arithmetic-coded data that stops
early at a marker reads as the zero bytes encoders drop at the end of a scan (libjpeg-turbo
treats it the same way), so for arithmetic scans only data that runs to the end of the file
and a missing restart marker count as damage.
## Limits
@@ -105,6 +108,8 @@ SOF11 (lossless) falls in that range and returns `Unsupported`.
- `tests/conformance.rs`, on libjpeg-turbo's test images in `tests/data` (provenance and how
the derived files were made: `tests/data/SOURCE.md`):
- `testimgari.jpg` (SOF9) decodes to exactly the samples of its jpegtran Huffman transcode.
- `whitetail-ari.jpg` (SOF9 from cjpeg, a white lower part that decodes from the zeros
supplied at EOI) decodes undamaged and equal to its Huffman transcode.
- `testorig.jpg` matches `zune-jpeg`, and `testorig12.jpg` matches libjpeg-turbo's 12-bit
decode, within the float-against-integer IDCT difference (mean under 0.75, max 4).
- `tests/mutation.rs`: a seeded mutation run (truncations, bit flips, byte and frame-header
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+18 -17
View File
@@ -595,45 +595,45 @@ const QE: [(u32, u8, u8, bool); 113] = [
/// Qe index in bits 0–6 and the MPS in bit 7.
struct Arith<'a> {
d: &'a [u8],
/// `d` ends at a marker rather than at the end of the file.
at_marker: bool,
pos: usize,
c: u32,
a: u32,
ct: u32,
/// Zero bytes fed in since the start or the last restart because the data had ended.
pad: u32,
/// The decoder needed bytes past the end of the file.
eof: bool,
/// A restart was due but its marker was missing.
lost: bool,
}
impl<'a> Arith<'a> {
/// The most zero bytes a scan or restart interval may consume past the end of its data.
/// The decoder reads two bytes ahead (INITDEC), and encoders drop trailing zero bytes
/// (libjpeg's `finish_pass` does, as D.1.8 allows), so a valid stream consumes a few.
/// Past that the data was cut short, and the decoder would only decode the zeros.
const MAX_PAD: u32 = 8;
fn new(d: &'a [u8]) -> Self {
let mut ar = Arith { d, pos: 0, c: 0, a: 0, ct: 0, pad: 0, lost: false };
fn new(d: &'a [u8], at_marker: bool) -> Self {
let mut ar = Arith { d, at_marker, pos: 0, c: 0, a: 0, ct: 0, eof: false, lost: false };
ar.init();
ar
}
/// At a marker the decoder is fed zeros for as long as it asks (D.2.6): encoders drop
/// trailing zero bytes (libjpeg's `finish_pass` does, as D.1.8 allows), and a flat tail of
/// a few thousand blocks can decode entirely from those zeros. Running out at the end of
/// the file is damage.
fn byte_in(&mut self) {
let v = next_byte(self.d, &mut self.pos).unwrap_or_else(|| {
self.pad = self.pad.saturating_add(1);
let at_restart = matches!(self.d.get(self.pos + 1), Some(0xD0..=0xD7));
self.eof |= !at_restart && !self.at_marker;
0
});
self.c = self.c.wrapping_add(u32::from(v) << 8);
}
/// The scan's data ran out, or a restart marker is missing.
/// The data ended without a marker, or a restart marker is missing.
fn exhausted(&self) -> bool {
self.lost || self.pad > Self::MAX_PAD
self.lost || self.eof
}
/// INITDEC (D.2.7).
fn init(&mut self) {
self.pad = 0;
self.a = 0x10000;
self.c = 0;
self.byte_in();
@@ -1037,7 +1037,8 @@ fn parse_scan(seg: &[u8], frame: &Frame) -> Result<Scan, Error> {
}
/// Decode one scan's entropy-coded data `d`, counting the decoded MCUs in `done`.
fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: &mut usize) -> Result<(), Error> {
/// `at_marker` says whether a marker follows `d` (otherwise it runs to the end of the file).
fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], at_marker: bool, done: &mut usize) -> Result<(), Error> {
for &(ci, _, _) in &scan.comps {
let comp = frame.comps.get_mut(ci).ok_or_else(|| err("bad scan component"))?;
if comp.quant.is_none() {
@@ -1054,7 +1055,7 @@ fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done:
(true, _, _) => ArithMode::AcRefine,
};
let mut dec = ArithScan {
ar: Arith::new(d),
ar: Arith::new(d, at_marker),
mode,
ss: scan.ss,
se: scan.se,
@@ -1154,7 +1155,7 @@ fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool
let end = scan_end(b, i);
let data = b.get(i..end).unwrap_or_default();
scans += 1;
if let Err(e) = decode_scan(f, &tables, &scan, data, &mut mcus) {
if let Err(e) = decode_scan(f, &tables, &scan, data, end < b.len(), &mut mcus) {
if mcus == 0 {
return Err(e);
}
+22 -8
View File
@@ -688,8 +688,9 @@ fn huffman_scan_out_of_data_is_not_clean() {
assert!(decode(&cut, &Limits::default(), None).unwrap().damaged);
}
/// Arithmetic data cut short before a valid EOI is reported damaged, and the blocks before the
/// cut are kept: past the end the decoder is fed zeros, and a restart marker goes missing.
/// Arithmetic data that runs to the end of the file, or loses a restart marker, is reported
/// damaged, and the blocks before the cut are kept. Data cut short before EOI with no restart
/// interval reads as dropped trailing zeros and can't be detected.
#[test]
fn arithmetic_scan_out_of_data_is_damaged() {
let blocks = coefficients(64, 11, 500);
@@ -698,16 +699,29 @@ fn arithmetic_scan_out_of_data_is_damaged() {
assert_eq!(decoded_blocks(&full), blocks, "ri={ri}");
let data = last_scan_data(&full);
let end = full.len() - 2;
for cut in [data + (end - data) / 2, data] {
for cut in [data + (end - data) / 2, data + 1] {
let mut s = full[..cut].to_vec();
if s.last() == Some(&0xFF) {
s.pop();
}
s.extend([0xFF, 0xD9]);
let (frame, _, damaged) = decode_coefficients(&s, &Limits::default()).unwrap();
assert!(damaged, "ri={ri} cut at {}", cut - data);
if cut > data {
assert_eq!(frame.comps[0].coefs[0], blocks[0], "ri={ri}");
for eoi in [false, true] {
if eoi && ri == 0 {
continue;
}
let mut s = s.clone();
if eoi {
s.extend([0xFF, 0xD9]);
}
// With one byte of data the first MCU fails, which is an error, not damage.
match decode_coefficients(&s, &Limits::default()) {
Ok((frame, _, damaged)) => {
assert!(damaged, "ri={ri} eoi={eoi} cut at {}", cut - data);
if cut > data + 1 {
assert_eq!(frame.comps[0].coefs[0], blocks[0], "ri={ri} eoi={eoi}");
}
}
Err(_) => assert_eq!(cut, data + 1, "ri={ri} eoi={eoi}"),
}
}
}
}
+17 -2
View File
@@ -7,13 +7,18 @@ fn fixture(name: &str) -> Vec<u8> {
std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}"))
}
fn decode_fixture(name: &str) -> Decoded {
fn decode_clean(name: &str) -> Decoded {
let img = decode(&fixture(name), &Limits::default(), None).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((img.width, img.height, img.components), (227, 149, 3), "{name}");
assert!(!img.damaged, "{name}");
img
}
fn decode_fixture(name: &str) -> Decoded {
let img = decode_clean(name);
assert_eq!((img.width, img.height, img.components), (227, 149, 3), "{name}");
img
}
/// Mean and maximum absolute difference.
fn diff(a: impl Iterator<Item = f64>, b: impl Iterator<Item = f64>) -> (f64, f64) {
let (mut sum, mut max, mut n) = (0.0, 0.0f64, 0usize);
@@ -36,6 +41,16 @@ fn arithmetic_coded_image_matches_its_huffman_transcode() {
assert!(huffman.samples == arithmetic.samples, "testimgari.jpg decodes differently from testimgari-huffman.jpg");
}
/// libjpeg's arithmetic encoder drops the trailing zero bytes of a scan, so the white lower
/// part of whitetail-ari.jpg decodes from zeros supplied at the EOI marker. It must decode
/// whole and match its Huffman transcode.
#[test]
fn arithmetic_white_tail_decodes_from_implied_zeros() {
let arithmetic = decode_clean("whitetail-ari.jpg");
assert_eq!((arithmetic.width, arithmetic.height, arithmetic.components), (512, 512, 1));
assert!(decode_clean("whitetail-huffman.jpg").samples == arithmetic.samples, "whitetail-ari.jpg decodes differently from whitetail-huffman.jpg");
}
/// The 8-bit path against zune-jpeg. Both use centered linear chroma upsampling; the residual is
/// float against integer IDCT and color conversion.
#[test]
+2
View File
@@ -3,3 +3,5 @@
acc6ec555d41d15b368320edaa3b20958ee6fa97cb6e4a18d1213d5ae8bec73b testorig.jpg
34790770f76db8e60a7765b52ca4edf5f16bc21bcb8c6045ca2efef39a8a013e testorig12.jpg
62d182ed042e243544d3d753c440ada1a6992464717311fe6efd546632f44dc4 testorig12.ppm
0031b82a06300b5b5d19ef5553cc39d3fafafa4fbc474199e6cfbc7818312a87 whitetail-ari.jpg
0f6e17a44be7a005d6610e66f91fa119388dc6b8438e95cb8816b6db58f8c365 whitetail-huffman.jpg
+21 -1
View File
@@ -2,7 +2,8 @@
The JPEG files come from libjpeg-turbo 3.0.0, `testimages/`
(https://github.com/libjpeg-turbo/libjpeg-turbo/tree/3.0.0/testimages), under the IJG License
(README.ijg in this directory). The other two files are derived from them as described below.
(README.ijg in this directory). The other files are derived from them, or made with
libjpeg-turbo's tools, as described below.
`SHA256SUMS` lists every file.
| File | What it is |
@@ -12,6 +13,8 @@ The JPEG files come from libjpeg-turbo 3.0.0, `testimages/`
| `testimgari-huffman.jpg` | `testimgari.jpg` losslessly transcoded to baseline Huffman (SOF0) |
| `testorig12.jpg` | 227×149 YCbCr, 12-bit extended sequential Huffman (SOF1) |
| `testorig12.ppm` | libjpeg-turbo's decode of `testorig12.jpg`: 12-bit RGB, binary PPM, maxval 4095 |
| `whitetail-ari.jpg` | 512×512 gray, arithmetic coded (SOF9), noise in the top 16 rows, white below |
| `whitetail-huffman.jpg` | `whitetail-ari.jpg` losslessly transcoded to baseline Huffman (SOF0) |
`testimgari.jpg` and `testorig.jpg` share their quantization tables but not their coefficients:
libjpeg-turbo decodes them to different pixels. `testimgari-huffman.jpg` holds exactly the
@@ -43,4 +46,21 @@ with open(dst, "wb") as f:
`djpeg -dct int testorig12.jpg` from libjpeg-turbo 3.1.0 writes a byte-identical file.
`whitetail-ari.jpg` and `whitetail-huffman.jpg`, with cjpeg and jpegtran from libjpeg-turbo 3.1.3
(Alpine 3.24, `libjpeg-turbo-utils`). libjpeg's arithmetic encoder drops the trailing zero bytes
of a scan, so the white rows decode almost entirely from zeros supplied at the EOI marker.
`python3 whitetail.py whitetail.pgm` writes the source image:
```python
import sys
w, h, top = 512, 512, 16
px = bytes(((x * 3 + y * 5) ^ (x * y)) & 0xFF if y < top else 255 for y in range(h) for x in range(w))
open(sys.argv[1], "wb").write(b"P5\n%d %d\n255\n" % (w, h) + px)
```
```sh
cjpeg -arithmetic -quality 75 whitetail.pgm > whitetail-ari.jpg
jpegtran -copy none whitetail-ari.jpg > whitetail-huffman.jpg
```
"This software is based in part on the work of the Independent JPEG Group."
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB