egui037: reconcile current PhotoCraft main and qualify CI

This commit is contained in:
skylartaylor
2026-10-25 03:41:54 +01:00
13 changed files with 392 additions and 28 deletions
Generated
+9
View File
@@ -1306,6 +1306,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/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -4853,6 +4861,7 @@ dependencies = [
name = "photocraft-codecs"
version = "0.15.1"
dependencies = [
"craft-jpeg",
"exr",
"flate2",
"half",
+1
View File
@@ -46,6 +46,7 @@ photocraft-ipp = { path = "crates/ipp" }
# targeted security/UI bump, not an all-libs flatten. Re-check if main backports them.
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
+17 -3
View File
@@ -17,6 +17,9 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
const MAX_TABLES: usize = 4096;
/// Distinct ids the allocation tables may name; each costs a map slot even when only deleted.
const MAX_ENTRIES: usize = 1 << 22;
/// Bytes of entry names the allocation tables may name. A name is retained twice (in the entry
/// and as its map key), and tables can point many names at the same bytes.
const MAX_NAME_BYTES: usize = 32 << 20;
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
@@ -168,6 +171,7 @@ impl<'a> Archive<'a> {
// Newest record per id: (table date, table index, record).
let mut latest: HashMap<u32, (u64, usize, Option<Box<Entry>>)> = HashMap::new();
let mut names: HashMap<u32, String> = HashMap::new();
let mut name_bytes = 0usize;
let mut seen = HashSet::new();
let mut next = header.fat_offset;
let mut tables = 0usize;
@@ -231,8 +235,16 @@ impl<'a> Archive<'a> {
}
if flag == 0 {
let len = usize::from(c.u16()?);
let name = String::from_utf8_lossy(c.take(len)?).into_owned();
names.entry(id).or_insert(name);
let raw = c.take(len)?;
// Only the first name per id is kept: charge and copy just that one.
if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) {
let name = String::from_utf8_lossy(raw).into_owned();
name_bytes = name_bytes.saturating_add(name.len());
if name_bytes > MAX_NAME_BYTES {
return Err(Error::Limit("archive entry names too large"));
}
slot.insert(name);
}
}
// Equal dates: the first table in the chain (the newest) wins, as in afread.
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
@@ -284,6 +296,9 @@ impl<'a> Archive<'a> {
if total > self.limits.max_total {
return Err(Error::Limit("document too large"));
}
// Charge before decoding: a corrupt entry that fails its checksum has already cost the
// decompression, and an importer that recovers must not get it again for free.
self.extracted = total;
let mut c = Cursor::new(self.bytes, offset(e.offset)?);
if c.take(4)? != b"#Fil" {
return Err(Error::Malformed("archive entry has no data block"));
@@ -303,7 +318,6 @@ impl<'a> Archive<'a> {
if crc32fast::hash(&out) != e.crc {
return Err(Error::Malformed("archive entry checksum mismatch"));
}
self.extracted = total;
Ok(out)
}
}
+15 -1
View File
@@ -148,7 +148,21 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
Value::UInt(n) => usize::try_from(*n).ok()?,
_ => return None,
};
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
let corners = match s.obj(crvs, b"CnrD") {
Some(c) => {
let fields: Vec<&Value> = s.positional(c).collect();
// The corner arrays are scanned for every occurrence of a shared curve, so charge
// them like records before they are walked.
if let Some(Value::Array(idx)) = fields.get(1)
&& let Err(reason) = r.geometry_budget.records(idx.len())
{
r.warn(reason);
return None;
}
corner_radii(fields)
}
None => None,
};
let mut path = Path::default();
let mut seen = 0usize;
for index in 0..count {
+54 -14
View File
@@ -290,6 +290,10 @@ const MAX_NODES: usize = 500_000;
const MAX_CHILD_SLOTS: usize = 8_000_000;
/// Bytes of layer names the model may retain across all occurrences.
const MAX_NAME_BYTES: usize = 64 << 20;
/// Page rectangles the model may retain across all spreads (real books have a few hundred).
const MAX_PAGES: usize = 1 << 18;
/// Bytes of font names the text runs may retain across the document.
const MAX_FONT_NAME_BYTES: usize = 16 << 20;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
@@ -307,6 +311,8 @@ pub(crate) struct Reader<'s, 'a, 'b> {
nodes: usize,
child_slots: usize,
name_bytes: usize,
pages: usize,
font_name_bytes: usize,
active: HashSet<ObjId>,
pub(crate) geometry_budget: crate::geometry::Budget,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
@@ -324,6 +330,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
nodes: 0,
child_slots: 0,
name_bytes: 0,
pages: 0,
font_name_bytes: 0,
active: HashSet::new(),
geometry_budget: crate::geometry::Budget::default(),
pixels_left: limits.max_pixel_bytes,
@@ -365,7 +373,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
self.warn("an unknown kind of page");
continue;
}
let pages = pages(s, spread);
let pages = self.pages(spread)?;
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
@@ -421,7 +429,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
/// Counts raw array slots (null ones included) against [`MAX_CHILD_SLOTS`].
fn charge_slots(&mut self, n: usize) -> Result<(), Error> {
pub(crate) fn charge_slots(&mut self, n: usize) -> Result<(), Error> {
self.child_slots = self.child_slots.saturating_add(n);
if self.child_slots > MAX_CHILD_SLOTS {
return Err(Error::Limit("too many child slots in the layer tree"));
@@ -553,6 +561,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
let s = self.s;
// Every occurrence of a shared layer scans its whole attachment array, null slots included.
self.charge_slots(s.array_len(id, b"AdCh").unwrap_or(0))?;
let attached = s.objs(id, b"AdCh");
let mut mask: Option<Path> = None;
let mut pixels: Option<Image> = None;
@@ -601,21 +611,51 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
}
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads).
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
if let Some(md) = s.obj(spread, b"SpMd") {
let v: Vec<Rect> = s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect();
if !v.is_empty() {
return v;
impl Reader<'_, '_, '_> {
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads). A spread listed several times, or sharing one
/// large page array, is expanded into a new vector each time, so the slots scanned and the
/// rectangles retained are charged across the document.
fn pages(&mut self, spread: ObjId) -> Result<Vec<Rect>, Error> {
let s = self.s;
if let Some(md) = s.obj(spread, b"SpMd") {
self.charge_slots(s.array_len(md, b"PagR").unwrap_or(0))?;
let remaining = MAX_PAGES.saturating_sub(self.pages);
let v: Vec<Rect> = s
.objs(md, b"PagR")
.into_iter()
.filter_map(|p| s.floats::<4>(p, b"rctp").map(rect))
.filter(|r| r.x1 > r.x0 && r.y1 > r.y0)
.take(remaining.saturating_add(1))
.collect();
if !v.is_empty() {
self.charge_pages(v.len())?;
return Ok(v);
}
}
Ok(match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
(Some(b), Some(n @ 2..=16)) => {
self.charge_pages(n as usize)?;
let w = (b.x1 - b.x0) / n as f64;
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
}
_ => Vec::new(),
})
}
match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
(Some(b), Some(n @ 2..=16)) => {
let w = (b.x1 - b.x0) / n as f64;
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
/// Counts retained font-name bytes against [`MAX_FONT_NAME_BYTES`]; false once it is spent.
pub(crate) fn charge_font_name(&mut self, n: usize) -> bool {
self.font_name_bytes = self.font_name_bytes.saturating_add(n);
self.font_name_bytes <= MAX_FONT_NAME_BYTES
}
/// Counts retained page rectangles against [`MAX_PAGES`].
fn charge_pages(&mut self, n: usize) -> Result<(), Error> {
self.pages = self.pages.saturating_add(n);
if self.pages > MAX_PAGES {
return Err(Error::Limit("too many pages"));
}
_ => Vec::new(),
Ok(())
}
}
+7 -1
View File
@@ -232,9 +232,15 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
}
};
let grad = s.obj(fill, b"Grad")?;
// A shared gradient is decoded again for every occurrence: charge the arrays scanned
// and copy only as many positions as there are stops to keep.
if r.charge_slots(s.array_len(grad, b"Cols").unwrap_or(0).saturating_add(s.array_len(grad, b"Posn").unwrap_or(0))).is_err() {
r.warn("gradients beyond the document work budget (left empty)");
return Some(Paint::None);
}
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(),
_ => Vec::new(),
};
let mut stops = Vec::new();
+2
View File
@@ -193,6 +193,8 @@ fn plane(
}
.iter()
.filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None });
// The scan below runs for every occurrence of a shared bitmap, whatever it produces.
r.charge_slots(states.len().min(tw.saturating_mul(th)))?;
let row = (w as usize) * bps;
let mut out = vec![0u8; row * h as usize];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
+14
View File
@@ -258,6 +258,20 @@ mod archive {
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
#[test]
fn a_failed_read_still_spends_the_extraction_allowance() {
let d = doc();
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
let mut broken = file.clone();
broken[90] ^= 0x55;
// Room for exactly one read: the failed attempt must use it up, so a caller that
// recovers from the checksum error cannot make the same entry decode again.
let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len(), ..Limits::default() }).unwrap();
assert!(a.read("doc.dat").is_err());
assert_eq!(a.extracted(), d.len());
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
#[test]
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
let big = vec![7u8; 1 << 20];
+22 -3
View File
@@ -106,6 +106,24 @@ fn int(v: &Value) -> Option<i64> {
}
}
/// Longest font name kept; real PostScript and family names are far shorter.
const MAX_FONT_NAME: usize = 256;
/// A font name for one run. Runs repeat the same shared string, so its copies are charged
/// against a document-wide byte budget; an overlong name or an exhausted budget leaves it empty.
fn font_name(r: &mut Reader, font: Option<ObjId>, tag: &[u8; 4]) -> String {
let name = font.and_then(|f| r.s.str(f, tag)).unwrap_or_default();
if name.len() > MAX_FONT_NAME {
r.warn("font names longer than 256 bytes (left empty)");
return String::new();
}
if !r.charge_font_name(name.len()) {
r.warn("font names beyond the document metadata budget (left empty)");
return String::new();
}
name.to_string()
}
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
let s = r.s;
let doubles = s.field(attrs, b"Doub");
@@ -125,11 +143,12 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR
r.warn("horizontally scaled text");
}
let font = s.obj(attrs, b"DFnt");
let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
let postscript = font_name(r, font, b"Post");
let family = font_name(r, font, b"Famy");
TextRun {
text,
postscript: get(b"Post"),
family: get(b"Famy"),
postscript,
family,
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
size,
+2
View File
@@ -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.
+236 -1
View File
@@ -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,47 @@ 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). Lossless (SOF3) and
/// 12-bit progressive Huffman (SOF2) go there too: craft-jpeg reports them unsupported, where
/// zune-jpeg's failure would read as a damaged file.
fn uses_craft_jpeg(bytes: &[u8]) -> bool {
match craft_jpeg::frame(bytes) {
Some(f) if (0xC9..=0xCB).contains(&f.marker) || f.marker == 0xC3 => true,
Some(f) if matches!(f.marker, 0xC0..=0xC2) => 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 mut lim = craft_jpeg::Limits::default();
lim.max_pixels = limits.max_pixels;
lim.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 +245,25 @@ 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) {
// craft-jpeg sizes its buffers from the frame header before it reads a scan.
if scan_bytes == 0 {
return Err(err("the file has no scan data"));
}
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 +614,178 @@ 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]));
assert!(!uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC2, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0]));
assert!(uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC2, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0]));
assert!(uses_craft_jpeg(&[0xFF, 0xD8, 0xFF, 0xC3, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0]));
}
#[test]
fn lossless_and_twelve_bit_progressive_are_unsupported_not_damaged() {
for marker in [0xC3, 0xC2] {
let mut bytes = vec![0xFF, 0xD8, 0xFF, marker, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0];
bytes.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 0x3F, 0, 0x12, 0x34, 0xFF, 0xD9]);
let e = crate::decode(&bytes).unwrap_err();
assert!(matches!(e, CodecError::Unsupported { .. }), "{marker:#x}: {e:?}");
}
}
#[test]
fn a_twelve_bit_frame_without_scan_data_is_refused_before_decoding() {
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12, 0x40, 0, 0x40, 0, 1, 1, 0x11, 0];
bytes.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 0x3F, 0, 0xFF, 0xD9]);
let e = crate::decode(&bytes).unwrap_err();
assert!(e.to_string().contains("no scan data"), "{e}");
}
#[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}");
}
}
+9 -5
View File
@@ -999,9 +999,12 @@ impl Surface {
// A target that got only default pixels isn't stored, as `prune` would drop it. Checked
// after the copy, a row at a time: a tile with content stops at its first such row.
let finished = tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| Arc::new(tile));
if let Some(k) = memo_key {
built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(k, finished.clone());
}
// Workers that missed the memo at the same time each built a copy: all of them take
// the one stored first, so the targets share it however many threads raced.
let finished = match memo_key {
Some(k) => built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(k).or_insert(finished).clone(),
None => finished,
};
finished.map(|t| (dc, t))
};
#[cfg(not(target_arch = "wasm32"))]
@@ -1869,8 +1872,9 @@ mod tests {
let moved = s.translated(7, 5, s.content_bounds());
let distinct: std::collections::HashSet<_> = moved.tiles().map(|(_, t)| Arc::as_ptr(t)).collect();
// The 15 × 15 interior tiles read the same four source tiles at the same offset, so they are
// one tile; only the 64 border tiles (cut by the content's edge) are built one by one.
assert!(moved.tile_count() == 289 && distinct.len() <= 64 + 5, "{} tiles, {} distinct", moved.tile_count(), distinct.len());
// one tile, however many threads build them; only the 64 border tiles (cut by the
// content's edge) are built one by one.
assert!(moved.tile_count() == 289 && distinct.len() == 64 + 1, "{} tiles, {} distinct", moved.tile_count(), distinct.len());
let all = Rect::new(-50, -50, 16 * TILE_SIZE + 50, 16 * TILE_SIZE + 50);
assert_eq!(moved.read_region(all), s.read_region(all.translate(-7, -5)));
}
+4
View File
@@ -1,5 +1,9 @@
# PhotoCraft: egui 0.37 migration plan
Status update (2026-10-25): local implementation includes main `b278ff72459cbf9754863365edd822ba2248aa07`, preserving its JPEG dependency, Affinity import and raster changes. All nine CI stages passed: 11,590 passing test executions, zero failed, 91 ignored across 93 summaries; the ignored panic hunt, strict lint and WASM compilation passed. The current-main integration received independent review. Native/browser GUI, native IME, PRIMARY, packaged WASM size, real-world 0.36 settings and paired application performance remain open. Publication requires separate explicit approval; human review and human merge are required.
The original planning record below is historical.
Current status — 2026-10-25: the port is implemented and reconciled with main
`486afd82cce99255e999224bcfe28de00fd766aa`. The full managed CI run passed all nine stages:
formatting, strict clippy, layers, scorecard, workspace tests, the ignored panic hunt,