mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
Merge remote-tracking branch 'origin/main' into codex/anicraft-xfl-format
This commit is contained in:
commit
85d63a6fdb
4 files changed
+325
-8
No files matched your search
+140
-2
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]];
|
||||
|
||||
Reference in new issue
Block a user