Merge remote-tracking branch 'origin/main' into codex/anicraft-xfl-format

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 23:01:29 +00:00
commit 85d63a6fdb
4 files changed
+325 -8

No files matched your search

+140 -2
View File
@@ -25,7 +25,7 @@ pub fn embedded_font_programs(ps: &[u8]) -> Vec<EmbeddedFontProgram> {
let names = positions(ps, b"/FontName");
let mut from = 0;
let mut used = 0;
while let Some(ee) = find_from(ps, b"eexec", from) {
while let Some(ee) = find_eexec(ps, from) {
from = ee + 5;
let last_before = |v: &[usize]| v[..v.partition_point(|&p| p < ee)].last().copied().filter(|&p| p >= used);
// The fallback look-back stops at the previous font's end, so it cannot pick up its header.
@@ -56,6 +56,110 @@ fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from)
}
/// Find an executable `eexec` operator, ignoring comments, literal strings and names.
fn find_eexec(ps: &[u8], from: usize) -> Option<usize> {
let mut pos = from.min(ps.len());
while pos < ps.len() {
let c = ps[pos];
if is_white(c) {
pos += 1;
continue;
}
if c == b'%' {
let start = pos;
while pos < ps.len() && !matches!(ps[pos], b'\n' | b'\r') {
pos += 1;
}
let line = &ps[start..pos];
let marker: Option<&[u8]> = if line.starts_with(b"%%BeginData:") {
Some(b"%%EndData")
} else if line.starts_with(b"%%BeginBinary:") {
Some(b"%%EndBinary")
} else if line.starts_with(b"%%BeginPreview") {
Some(b"%%EndPreview")
} else {
None
};
if let Some(marker) = marker {
let rest = ps.get(pos..)?;
pos = rest.windows(marker.len()).position(|window| window == marker).map_or(ps.len(), |offset| pos + offset);
}
continue;
}
if c == b'(' {
pos += 1;
let mut depth = 1usize;
while pos < ps.len() && depth > 0 {
match ps[pos] {
b'\\' => pos = pos.saturating_add(2).min(ps.len()),
b'(' => {
depth = depth.saturating_add(1);
pos += 1;
}
b')' => {
depth -= 1;
pos += 1;
}
_ => pos += 1,
}
}
continue;
}
if c == b'<' {
match ps.get(pos + 1) {
Some(b'<') => pos += 2,
Some(b'~') => {
pos += 2;
pos = ps
.get(pos..)
.and_then(|rest| {
rest.windows(2).position(|window| window == b"~>").map(|offset| pos.saturating_add(offset).saturating_add(2))
})
.unwrap_or(ps.len());
}
_ => {
pos += 1;
while pos < ps.len() && ps[pos] != b'>' {
pos += 1;
}
pos = pos.saturating_add(1).min(ps.len());
}
}
continue;
}
if c == b'/' {
pos += 1;
if ps.get(pos) == Some(&b'/') {
pos += 1;
}
while pos < ps.len() && !is_white(ps[pos]) && !is_delimiter(ps[pos]) {
pos += 1;
}
continue;
}
if is_delimiter(c) {
pos += 1;
continue;
}
let start = pos;
while pos < ps.len() && !is_white(ps[pos]) && !is_delimiter(ps[pos]) {
pos += 1;
}
if &ps[start..pos] == b"eexec" {
return Some(start);
}
}
None
}
fn is_white(c: u8) -> bool {
matches!(c, 0 | 9 | 10 | 12 | 13 | 32)
}
fn is_delimiter(c: u8) -> bool {
matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
}
fn positions(hay: &[u8], needle: &[u8]) -> Vec<usize> {
let mut out = vec![];
let mut from = 0;
@@ -70,7 +174,7 @@ fn name_after(b: &[u8], key: &[u8]) -> Option<String> {
let p = find_from(b, key, 0)? + key.len();
let rest = &b[p..];
let s = rest.iter().position(|c| *c == b'/')? + 1;
let e = rest[s..].iter().position(|c| c.is_ascii_whitespace() || matches!(c, b'/' | b'[' | b'(' | b'{')).map_or(rest.len(), |q| s + q);
let e = rest[s..].iter().position(|c| is_white(*c) || is_delimiter(*c)).map_or(rest.len(), |q| s + q);
(e > s).then(|| String::from_utf8_lossy(&rest[s..e]).into_owned())
}
@@ -128,4 +232,38 @@ mod tests {
let b = fonts.iter().find(|font| font.name.as_deref() == Some("B")).unwrap();
assert!(!String::from_utf8_lossy(&b.program).contains("AdobeFont"));
}
#[test]
fn eexec_in_comments_strings_names_and_font_names_is_not_an_operator() {
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%!PS-AdobeFont-1.0: Test\n%% eexec in a comment\n%%BeginBinary: 10\neexec junk\n%%EndBinary\n/FontName /eexecTest def\n(nested (ignored eexec)) pop\n<6565786563> pop\n<~eexec~> pop\n/eexec def\ncurrentfile eexec\n0000\ncleartomark\n";
let fonts = embedded_font_programs(ps);
assert_eq!(fonts.len(), 1);
assert_eq!(fonts[0].name.as_deref(), Some("eexecTest"));
}
#[test]
fn font_name_stops_at_postscript_delimiters_and_comments() {
let ps = b"%!PS-AdobeFont-1.0: Test\n/FontName /Test%comment\n def\ncurrentfile eexec\n0000\ncleartomark\n";
let fonts = embedded_font_programs(ps);
assert_eq!(fonts.len(), 1);
assert_eq!(fonts[0].name.as_deref(), Some("Test"));
}
#[test]
fn font_names_stop_at_every_postscript_delimiter() {
for delimiter in b"()<>[]{}/%" {
let mut source = b"/FontName /Test".to_vec();
source.push(*delimiter);
source.extend_from_slice(b"ignored");
assert_eq!(super::name_after(&source, b"/FontName").as_deref(), Some("Test"));
}
}
#[test]
fn literal_name_backslash_does_not_escape_a_comment_delimiter() {
let ps = b"%!PS-AdobeFont-1.0: Test\n/FontName /Test def\n/unused\\%(\npop\ncurrentfile eexec\n0000\ncleartomark\n";
let fonts = embedded_font_programs(ps);
assert_eq!(fonts.len(), 1);
assert_eq!(fonts[0].name.as_deref(), Some("Test"));
}
}
+88 -6
View File
@@ -22,9 +22,10 @@ pub struct FrameLimits {
pub max_bytes: u64,
/// Largest canvas (logical screen) in bytes at 4 bytes a pixel. Default 512 MiB.
pub max_canvas_bytes: u64,
/// Combined decoder working-allocation budget. The decoder reserves its persistent canvas
/// before the frame buffer, so both count against this limit. `None` uses twice
/// `max_canvas_bytes`, matching the decoder's working-buffer allowance. Default `None`.
/// Combined decoder working-allocation budget. The persistent canvas reserves budget before
/// the frame buffer. `None` uses twice `max_canvas_bytes`, matching the decoder's working
/// buffer allowance. Set this when an application has a tighter allocator budget. Default
/// `None`.
pub max_decode_bytes: Option<u64>,
/// Merge a frame identical to the one before into it (its delay added). Default off.
pub merge_identical: bool,
@@ -91,7 +92,18 @@ impl std::error::Error for FrameError {}
/// Decodes the frames of a GIF.
pub fn read_frames(bytes: &[u8], limits: &FrameLimits) -> Result<Animation, FrameError> {
let info = crate::scan(bytes, 1).ok_or_else(|| FrameError::Malformed("not a GIF".into()))?;
if limits.max_frames == 0 {
return Err(FrameError::LimitExceeded("the frame limit is zero".into()));
}
let inspect_limit = limits.max_frames.saturating_add(1);
let info = crate::scan(bytes, inspect_limit).ok_or_else(|| FrameError::Malformed("not a GIF".into()))?;
let has_more = if info.frames.len() > limits.max_frames {
Some(true)
} else if info.complete {
Some(false)
} else {
None
};
let (width, height) = (u32::from(info.width), u32::from(info.height));
let canvas = u64::from(width) * u64::from(height) * 4;
if canvas > limits.max_canvas_bytes {
@@ -111,15 +123,30 @@ pub fn read_frames(bytes: &[u8], limits: &FrameLimits) -> Result<Animation, Fram
il.max_alloc = Some(max_decode_bytes);
decoder.set_limits(il).map_err(malformed)?;
let (width, height) = decoder.dimensions();
collect(width, height, decoder.into_frames(), limits)
collect(width, height, decoder.into_frames().take(limits.max_frames), limits, has_more)
}
/// Reads the optional ICC application extension without decoding frame pixels. As in the image
/// decoder, a malformed optional profile is ignored and returned as `None`.
pub fn icc_profile(bytes: &[u8]) -> Option<Vec<u8>> {
let mut decoder = GifDecoder::new(Cursor::new(bytes)).ok()?;
decoder.icc_profile().ok().flatten()
}
/// Keeps the frames `frames` yields under `limits`.
fn collect(width: u32, height: u32, frames: image::Frames<'_>, limits: &FrameLimits) -> Result<Animation, FrameError> {
fn collect(
width: u32,
height: u32,
frames: impl Iterator<Item = image::ImageResult<image::Frame>>,
limits: &FrameLimits,
has_more: Option<bool>,
) -> Result<Animation, FrameError> {
let frame_bytes = u64::from(width) * u64::from(height) * 4;
let mut out: Vec<AnimFrame> = Vec::new();
let mut decoded_frames = 0;
let mut stop = Stop::End;
for frame in frames {
decoded_frames += 1;
let frame = match frame {
Ok(f) => f,
Err(e) if out.is_empty() => {
@@ -155,6 +182,13 @@ fn collect(width: u32, height: u32, frames: image::Frames<'_>, limits: &FrameLim
}
out.push(AnimFrame { rgba, delay_ms });
}
if stop == Stop::End && decoded_frames == limits.max_frames {
stop = match has_more {
Some(true) => Stop::FrameLimit,
Some(false) => Stop::End,
None => Stop::Damaged("could not inspect the blocks after the kept frames".into()),
};
}
if out.is_empty() {
return Err(FrameError::Malformed("no frame".into()));
}
@@ -192,6 +226,30 @@ mod tests {
assert!(a.frames.iter().all(|f| f.rgba.len() == 24));
}
#[test]
fn first_frame_pixel_count_tracks_the_lzw_output() {
let mut gif = Vec::new();
let pixels = image::RgbaImage::from_fn(640, 480, |x, y| image::Rgba([(x / 4) as u8, (y / 3) as u8, 17, 255]));
image::codecs::gif::GifEncoder::new_with_speed(&mut gif, 30).encode_frame(image::Frame::new(pixels)).unwrap();
assert_eq!(crate::first_frame_decoded_pixels(&gif), Some(640 * 480));
assert_eq!(crate::first_frame_decoded_pixels(b"not a gif"), None);
}
#[test]
fn reads_the_optional_icc_application_extension() {
let mut bytes = gif(&[(10, 5)]);
let frame = crate::scan(&bytes, 1).unwrap().frames[0];
let profile = [1, 2, 3, 4, 5];
let mut extension = vec![0x21, 0xff, 0x0b];
extension.extend_from_slice(b"ICCRGBG1012");
extension.push(profile.len() as u8);
extension.extend_from_slice(&profile);
extension.push(0);
bytes.splice(frame.offset..frame.offset, extension);
assert_eq!(icc_profile(&bytes).as_deref(), Some(profile.as_slice()));
}
#[test]
fn partial_frames_composite_and_background_disposal_is_applied() {
let palette = [0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 255, 0];
@@ -250,6 +308,30 @@ mod tests {
Err(FrameError::LimitExceeded(_))
));
assert!(read_frames(&bytes, &FrameLimits { max_decode_bytes: Some(1), ..FrameLimits::default() }).is_err());
assert!(
matches!(read_frames(&bytes, &FrameLimits { max_frames: 0, ..FrameLimits::default() }), Err(FrameError::LimitExceeded(message)) if message.contains("frame limit is zero"))
);
}
#[test]
fn merged_frames_still_report_the_decode_frame_limit() {
let bytes = gif(&[(10, 5), (10, 7), (200, 1)]);
let animation = read_frames(&bytes, &FrameLimits { max_frames: 2, merge_identical: true, ..FrameLimits::default() }).unwrap();
assert_eq!(reds(&animation), [(10, 120)]);
assert_eq!(animation.stop, Stop::FrameLimit);
}
#[test]
fn a_frame_beyond_the_limit_is_not_decoded_or_allocated() {
let mut bytes = gif(&[(1, 1), (2, 1)]);
let info = crate::scan(&bytes, 2).unwrap();
let second = info.frames[1];
// The first frame and canvas fit in 24 bytes; the second frame must not be decoded.
bytes[second.offset + 5..second.offset + 9].copy_from_slice(&[0xff, 0xff, 0xff, 0xff]);
let limits = FrameLimits { max_frames: 1, max_bytes: 24, max_canvas_bytes: 24, max_decode_bytes: Some(1024), ..FrameLimits::default() };
let animation = read_frames(&bytes, &limits).unwrap();
assert_eq!(animation.frames.len(), 1);
assert_eq!(animation.stop, Stop::FrameLimit);
}
#[test]
+88
View File
@@ -188,6 +188,94 @@ pub fn palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option<u8>)> {
Some((colors, transparent))
}
/// Counts pixels emitted by the first GIF frame's LZW stream, up to its declared area.
/// Returns `None` when the first frame or its LZW minimum code size is unreadable. This reads
/// code lengths only; it does not allocate or materialize pixel values.
pub fn first_frame_decoded_pixels(bytes: &[u8]) -> Option<u64> {
let parsed = scan(bytes, 1)?;
let frame = parsed.frames.first()?;
let packed = *bytes.get(frame.offset.checked_add(9)?)?;
let table_bytes = table_len(packed);
let code_size_at = frame.offset.checked_add(10)?.checked_add(table_bytes)?;
let min_size = *bytes.get(code_size_at)?;
if !(1..=11).contains(&min_size) {
return None;
}
let clear = 1usize << min_size;
let end = clear + 1;
let max = u64::from(frame.width) * u64::from(frame.height);
let mut lengths = [1u16; 4096];
let (mut width, mut next, mut previous) = (u32::from(min_size) + 1, end + 1, None::<usize>);
let (mut bits, mut held, mut pixels) = (0u32, 0u32, 0u64);
let mut pos = code_size_at.checked_add(1)?;
while let Some(&length) = bytes.get(pos) {
let start = pos.checked_add(1)?;
pos = start.checked_add(usize::from(length))?;
if length == 0 {
break;
}
for &byte in bytes.get(start..pos.min(bytes.len()))? {
bits |= u32::from(byte) << held;
held += 8;
while held >= width {
let code = (bits & ((1 << width) - 1)) as usize;
bits >>= width;
held -= width;
if code == clear {
(width, next, previous) = (u32::from(min_size) + 1, end + 1, None);
continue;
}
let string_len = match previous {
_ if code == end => return Some(pixels),
None if code < clear => 1,
Some(_) if code < next => lengths.get(code).copied().unwrap_or(1),
Some(prev) if code == next => lengths.get(prev).copied().unwrap_or(1).saturating_add(1),
_ => return Some(pixels),
};
let defined_len = previous.map(|prev| lengths.get(prev).copied().unwrap_or(1).saturating_add(1));
if let Some(defined_len) = defined_len
&& let Some(slot) = lengths.get_mut(next)
{
*slot = defined_len;
next += 1;
if next == 1 << width && width < 12 {
width += 1;
}
}
previous = Some(code);
pixels = pixels.saturating_add(u64::from(string_len));
if pixels >= max {
return Some(max);
}
}
}
}
Some(pixels)
}
/// Counts compressed image-data bytes in the first frame's sub-blocks, excluding its LZW code-size
/// byte and all later blocks. A truncated final sub-block contributes only the bytes present.
pub fn first_frame_compressed_bytes(bytes: &[u8]) -> Option<usize> {
let parsed = scan(bytes, 1)?;
let frame = parsed.frames.first()?;
let packed = *bytes.get(frame.offset.checked_add(9)?)?;
let mut pos = frame.offset.checked_add(10)?.checked_add(table_len(packed))?.checked_add(1)?;
let mut total = 0usize;
loop {
let length = usize::from(*bytes.get(pos)?);
pos = pos.checked_add(1)?;
if length == 0 {
return Some(total);
}
let end = pos.checked_add(length)?;
total = total.checked_add(end.min(bytes.len()).saturating_sub(pos))?;
if end > bytes.len() {
return Some(total);
}
pos = end;
}
}
/// Whether a file of `file_len` bytes can plausibly hold `declared_bytes` of decoded picture: at
/// most `max_ratio` decoded bytes for each file byte, plus 1 MiB. A decoder that sets up the whole
/// logical screen before it reads a frame should be given only files that pass this.
+9
View File
@@ -54,6 +54,15 @@ fn animated(delays_cs: &[u32], repeat: Option<image::codecs::gif::Repeat>) -> Ve
out
}
#[test]
fn first_frame_compressed_bytes_exclude_extensions_after_the_image_data() {
let mut bytes = gif(&[[1, 2, 3], [4, 5, 6]], None, None);
assert_eq!(first_frame_compressed_bytes(&bytes), Some(2));
bytes.splice(bytes.len() - 1..bytes.len() - 1, [0x21, 0xFE, 1, b'x', 0]);
assert_eq!(first_frame_compressed_bytes(&bytes), Some(2));
assert_eq!(first_frame_compressed_bytes(b"not a gif"), None);
}
#[test]
fn the_first_frames_table_and_transparent_entry() {
let g = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]];