mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Arithmetic-coded frames (SOF9-SOF11) and SOF0/SOF1 frames whose precision isn't 8 bits go to craft-jpeg; everything else stays on zune-jpeg. 12-bit files open as 16-bit images. PhotoCraft's own marker scan still supplies ICC, EXIF, XMP, resolution and Photoshop resources, and damaged or truncated entropy data adds the Truncated warning. A truncated scan too short for the frame it declares is refused, as on the zune-jpeg path. craft-jpeg is pinned to the aiongg fork until it lands on storytold. Co-authored-by: Michael Higgins <mhiggins217@gmail.com>
This commit is contained in:
Generated
+9
@@ -1311,6 +1311,14 @@ name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-jpeg"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/aiongg/craft-libs?rev=059d1519a6c90ecb0064ffe39020311014ce760d#059d1519a6c90ecb0064ffe39020311014ce760d"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -4860,6 +4868,7 @@ dependencies = [
|
||||
name = "photocraft-codecs"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-jpeg",
|
||||
"exr",
|
||||
"flate2",
|
||||
"half",
|
||||
|
||||
@@ -41,6 +41,7 @@ photocraft-ipp = { path = "crates/ipp" }
|
||||
# external
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-jpeg = { git = "https://github.com/aiongg/craft-libs", rev = "059d1519a6c90ecb0064ffe39020311014ce760d" }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -31,6 +31,8 @@ image-webp = "0.2"
|
||||
zune-jpeg = "0.5"
|
||||
zune-core = "0.5"
|
||||
jpeg-encoder = "0.6"
|
||||
# 12-bit and arithmetic-coded JPEG, which zune-jpeg does not decode.
|
||||
craft-jpeg.workspace = true
|
||||
photocraft-heif = { path = "../heif", optional = true }
|
||||
|
||||
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! JPEG: decode via `zune-jpeg` (gray, YCbCr, CMYK, YCCK), encode via
|
||||
//! JPEG: decode via `zune-jpeg` (gray, YCbCr, CMYK, YCCK; arithmetic-coded and 12-bit files
|
||||
//! via [`craft_jpeg`]), encode via
|
||||
//! `jpeg-encoder` (gray, RGB, CMYK). Metadata (APP0 JFIF density, APP1
|
||||
//! EXIF/XMP, APP2 multi-segment ICC, APP13 Photoshop resources, APP14 Adobe) is
|
||||
//! parsed by our own marker scanner so behaviour does not depend on decoder internals.
|
||||
@@ -190,6 +191,43 @@ fn err(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
/// Whether `craft-jpeg` (rather than `zune-jpeg`) decodes the file: arithmetic coding
|
||||
/// (SOF9–SOF11) or a DCT frame whose precision isn't 8 bits (#4934, #5003).
|
||||
fn uses_craft_jpeg(bytes: &[u8]) -> bool {
|
||||
match craft_jpeg::frame(bytes) {
|
||||
Some(f) if (0xC9..=0xCB).contains(&f.marker) => true,
|
||||
Some(f) if matches!(f.marker, 0xC0 | 0xC1) => f.precision != 8,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn craft_jpeg_error(e: craft_jpeg::Error) -> CodecError {
|
||||
match e {
|
||||
craft_jpeg::Error::Malformed(m) => err(m),
|
||||
craft_jpeg::Error::Unsupported(m) => CodecError::unsupported(F, m),
|
||||
craft_jpeg::Error::LimitExceeded(m) => CodecError::LimitExceeded(m),
|
||||
e => err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a file [`uses_craft_jpeg`] routes away from `zune-jpeg`; `damaged` says entropy-coded
|
||||
/// data was cut short or corrupt.
|
||||
fn decode_craft_jpeg(bytes: &[u8], limits: &Limits, adobe_transform: Option<u8>) -> Result<(Image, bool), CodecError> {
|
||||
let lim = craft_jpeg::Limits { max_pixels: limits.max_pixels, max_alloc: limits.max_alloc };
|
||||
let d = craft_jpeg::decode(bytes, &lim, adobe_transform).map_err(craft_jpeg_error)?;
|
||||
let layout = match d.components {
|
||||
1 => ChannelLayout::Gray,
|
||||
3 => ChannelLayout::Rgb,
|
||||
4 => ChannelLayout::Cmyk,
|
||||
n => return Err(err(format!("{n} components"))),
|
||||
};
|
||||
let img = match d.samples {
|
||||
craft_jpeg::Samples::U8(px) => Image::from_raw(d.width, d.height, layout, SampleType::U8, px)?,
|
||||
craft_jpeg::Samples::U16(px) => Image::from_u16(d.width, d.height, layout, &px)?,
|
||||
};
|
||||
Ok((img, d.damaged))
|
||||
}
|
||||
|
||||
/// Most pixels one byte of truncated scan data may account for. Even a flat image costs a few
|
||||
/// bits per 8×8 block, and truncated photographs are far above this.
|
||||
const MAX_TRUNCATED_PIXELS_PER_BYTE: u64 = 2048;
|
||||
@@ -203,6 +241,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
if let Some((w, h, nc)) = meta.frame {
|
||||
limits.check_bytes(w, h, u64::from(nc.max(1)))?;
|
||||
}
|
||||
if uses_craft_jpeg(bytes) {
|
||||
if let Some((w, h, _)) = meta.frame
|
||||
&& end == DataEnd::Truncated
|
||||
&& u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > scan_bytes
|
||||
{
|
||||
return Err(err("the truncated scan is too short for the frame it declares"));
|
||||
}
|
||||
let (mut img, damaged) = decode_craft_jpeg(bytes, limits, meta.adobe_transform)?;
|
||||
img.icc = meta.icc;
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, photoshop_resources: meta.photoshop, ..Default::default() };
|
||||
if end == DataEnd::Truncated || damaged {
|
||||
img.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
}
|
||||
return Ok(img);
|
||||
}
|
||||
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(65535).set_max_height(65535);
|
||||
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), options);
|
||||
dec.decode_headers().map_err(err)?;
|
||||
@@ -553,4 +606,157 @@ mod tests {
|
||||
assert!(has_jfif_app0(&bytes));
|
||||
assert_eq!(scan_metadata(&bytes).dpi, Some((96.0, 96.0)));
|
||||
}
|
||||
|
||||
// --- Arithmetic-coded and 12-bit files, decoded by craft-jpeg ---
|
||||
|
||||
fn segment(out: &mut Vec<u8>, marker: u8, body: &[u8]) {
|
||||
out.extend_from_slice(&[0xFF, marker]);
|
||||
out.extend_from_slice(&((body.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(body);
|
||||
}
|
||||
|
||||
/// One 8×8 gray block, quantizer 1, with entropy-coded scan data `data`.
|
||||
fn gray_block(sof: u8, precision: u8, dht: Option<&[u8]>, data: &[u8]) -> Vec<u8> {
|
||||
let mut s = vec![0xFF, 0xD8];
|
||||
let mut dqt = vec![0u8];
|
||||
dqt.extend([1u8; 64]);
|
||||
segment(&mut s, 0xDB, &dqt);
|
||||
segment(&mut s, sof, &[precision, 0, 8, 0, 8, 1, 1, 0x11, 0]);
|
||||
if let Some(dht) = dht {
|
||||
segment(&mut s, 0xC4, dht);
|
||||
}
|
||||
segment(&mut s, 0xDA, &[1, 1, 0x00, 0, 63, 0]);
|
||||
s.extend_from_slice(data);
|
||||
s.extend([0xFF, 0xD9]);
|
||||
s
|
||||
}
|
||||
|
||||
/// SOF9 with only a DC coefficient (from a T.81 arithmetic encoder): 8-bit DC 200 → sample
|
||||
/// 200 / 8 + 128 = 153; 12-bit DC 1000 → 1000 / 8 + 2048 = 2173.
|
||||
fn arithmetic_block(precision: u8) -> Vec<u8> {
|
||||
let data: &[u8] = if precision == 8 { &[210, 217, 68, 0] } else { &[210, 234, 134, 0] };
|
||||
gray_block(0xC9, precision, None, data)
|
||||
}
|
||||
|
||||
/// A 12-bit SOF1 stream: one DC coefficient. The DC table has one code (`0`, category 13),
|
||||
/// the AC table one (`0`, end of block). DC = 7616 → sample 7616 / 8 + 2048 = 3000.
|
||||
fn twelve_bit_huffman_block() -> Vec<u8> {
|
||||
let mut dht = vec![0x00, 1];
|
||||
dht.extend([0u8; 15]);
|
||||
dht.push(13);
|
||||
dht.extend([0x10, 1]);
|
||||
dht.extend([0u8; 15]);
|
||||
dht.push(0x00);
|
||||
// Bits: `0`, 7616 in 13 bits, `0`, one pad `1`.
|
||||
let word: u16 = (7616 << 2) | 1;
|
||||
gray_block(0xC1, 12, Some(&dht), &word.to_be_bytes())
|
||||
}
|
||||
|
||||
fn twelve_bit(v: f32) -> u16 {
|
||||
(v * 65535.0 / 4095.0).round() as u16
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing() {
|
||||
assert!(!uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0]));
|
||||
assert!(!uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0]));
|
||||
assert!(uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC9, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0]));
|
||||
assert!(uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0]));
|
||||
assert!(uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0]));
|
||||
assert!(!uses_craft_jpeg(&[0xFF, 0xD8]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_eight_bit_block_is_eight_bit() {
|
||||
let img = crate::decode(&arithmetic_block(8)).unwrap();
|
||||
assert_eq!((img.dimensions(), img.sample_type(), img.layout()), ((8, 8), SampleType::U8, ChannelLayout::Gray));
|
||||
assert!(img.data().iter().all(|&v| v.abs_diff(153) <= 1), "{:?}", &img.data()[..4]);
|
||||
assert!(img.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_twelve_bit_block_is_sixteen_bit() {
|
||||
let img = crate::decode(&arithmetic_block(12)).unwrap();
|
||||
assert_eq!((img.dimensions(), img.sample_type(), img.layout()), ((8, 8), SampleType::U16, ChannelLayout::Gray));
|
||||
let want = twelve_bit(2173.0);
|
||||
assert!(img.to_u16_samples().unwrap().iter().all(|&v| v.abs_diff(want) <= 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twelve_bit_huffman_block_decodes_to_sixteen_bit() {
|
||||
let img = crate::decode(&twelve_bit_huffman_block()).unwrap();
|
||||
assert_eq!((img.dimensions(), img.sample_type()), ((8, 8), SampleType::U16));
|
||||
let want = twelve_bit(3000.0);
|
||||
let px = img.to_u16_samples().unwrap();
|
||||
assert!(px.iter().all(|&v| v.abs_diff(want) <= 1), "{:?} vs {want}", &px[..4]);
|
||||
assert!(img.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twelve_bit_jpeg_keeps_its_photoshop_resources() {
|
||||
// An APP13 `8BIM` block (paths, Photoshop settings) right after SOI, as on the zune-jpeg path.
|
||||
let resources = b"8BIM\x04\x04\0\0\0\0\0\0".to_vec();
|
||||
let mut app13 = PHOTOSHOP_HEADER.to_vec();
|
||||
app13.extend_from_slice(&resources);
|
||||
let stream = twelve_bit_huffman_block();
|
||||
let mut with = stream[..2].to_vec();
|
||||
segment(&mut with, 0xED, &app13);
|
||||
with.extend_from_slice(&stream[2..]);
|
||||
assert!(uses_craft_jpeg(&with));
|
||||
let img = crate::decode(&with).unwrap();
|
||||
assert_eq!(img.meta.photoshop_resources.as_deref(), Some(resources.as_slice()));
|
||||
}
|
||||
|
||||
/// Where the entropy-coded data of the last scan starts.
|
||||
fn last_scan_data(s: &[u8]) -> usize {
|
||||
let sos = s.windows(2).rposition(|p| p == [0xFF, 0xDA]).unwrap();
|
||||
sos + 2 + usize::from(u16::from_be_bytes([s[sos + 2], s[sos + 3]]))
|
||||
}
|
||||
|
||||
/// A 12-bit scan whose entropy data runs out, EOI kept, is never a clean image.
|
||||
#[test]
|
||||
fn twelve_bit_scan_out_of_data_is_not_clean() {
|
||||
let full = twelve_bit_huffman_block();
|
||||
let data = last_scan_data(&full);
|
||||
for keep in 0..2 {
|
||||
let mut s = full[..data + keep].to_vec();
|
||||
s.extend([0xFF, 0xD9]);
|
||||
if let Ok(img) = crate::decode(&s) {
|
||||
assert!(!img.warnings.is_empty(), "keep {keep}: clean decode of missing data");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A multi-MCU SOF1 12-bit scan cut in half, EOI kept, decodes with the Truncated warning; a
|
||||
/// truncated scan too short for its frame is refused, as on the zune-jpeg path.
|
||||
#[test]
|
||||
fn damaged_or_truncated_twelve_bit_scan() {
|
||||
let (w, h) = (64usize, 64usize);
|
||||
let rgb: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 4) as u8, (i / w * 4) as u8, (i * 7) as u8]).collect();
|
||||
let mut out = Vec::new();
|
||||
jpeg_encoder::Encoder::new(&mut out, 90).encode(&rgb, w as u16, h as u16, jpeg_encoder::ColorType::Rgb).unwrap();
|
||||
// Relabel the baseline frame as 12-bit SOF1: the Huffman data still parses, as different values.
|
||||
let sof = out.windows(2).position(|p| p == [0xFF, 0xC0]).unwrap();
|
||||
out[sof + 1] = 0xC1;
|
||||
out[sof + 4] = 12;
|
||||
let data = last_scan_data(&out);
|
||||
let end = out.len() - 2;
|
||||
let mut cut = out[..data + (end - data) / 2].to_vec();
|
||||
if cut.last() == Some(&0xFF) {
|
||||
cut.pop();
|
||||
}
|
||||
cut.extend([0xFF, 0xD9]);
|
||||
let img = crate::decode(&out).unwrap();
|
||||
assert_eq!(img.sample_type(), SampleType::U16);
|
||||
assert!(img.warnings.is_empty(), "{:?}", img.warnings);
|
||||
let img = crate::decode(&cut).unwrap();
|
||||
assert_eq!(img.warnings, vec![DecodeWarning::Truncated { format: Format::Jpeg }]);
|
||||
// Without EOI the same cut is a truncated file; under a 20000×20000 frame it is refused.
|
||||
cut.truncate(cut.len() - 2);
|
||||
assert_eq!(data_end(&cut).0, DataEnd::Truncated);
|
||||
assert!(crate::decode(&cut).is_ok());
|
||||
cut[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]);
|
||||
let e = crate::decode(&cut).unwrap_err();
|
||||
assert!(e.to_string().contains("too short for the frame it declares"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user