Files
craft-libs/crates/aep/src/write.rs
T
Dylan M. Taylorandvangel76 4a7024cd69 craft-aep: flat read-only tree index and the Pard decoder (ports of effectcraft#3302 and #3319) (#948)
* craft-aep: a flat read-only tree index, borrowed text documents, fewer allocations

The container part of storytold/effectcraft#3302:

- `TreeIndex`: the chunk tree as one flat array in depth-first order,
  borrowed from the input, for readers that only look. Same chunks counted
  against the same limits and the same errors as `parse`. An `.aepx` is
  indexed by `index_aepx` into one buffer. The typed view reads through
  either tree (`Node`, `Root`, `Project::from_index`).
- Chunk parser: each list of siblings is counted, then reserved once.
  `.aepx`: hex decoded into an output sized once, attribute values without
  an entity borrowed. `write` reserves the exact output size.
- Text documents are parsed into a tree that borrows from the body; the
  style and paragraph defaults are built once.
- Layer markers and Source Text are read where they are stored instead of
  searched for.
- Container accessors that only tests used are removed.

What the importer and exporter in EffectCraft reach across the crate
boundary is public here: `Root`, `Draft`, `AepxDrafts`, `index_aepx`,
`TreeIndex::from_drafts`, `Project::of`, `ProjectSettings::of`,
`Node::marker_list`, `Root::file_version` and `saved_by_version`,
`StyleRun::SAMPLED`. `TimeBase::NTSC` holds the rates as plain
numerator and denominator instead of an EffectCraft type.

* craft-aep: one decoder for an effect parameter definition (`Pard`)

The container part of storytold/effectcraft#3319: the fields of a `pard`
record (type code, name, default, valid range, slider range, popup item
count) are read by one type, `Pard`, which `EffectParam` now reads
through. It is public so that an exporter's tests can read back what
they encode instead of keeping a second decoder.

The rest of that PR (import and export bounds, loss reports) is in
EffectCraft's importer and exporter.

---------

Co-authored-by: vangel76 <vangel1976@googlemail.com>
2026-11-11 19:01:12 -05:00

180 lines
7.1 KiB
Rust

//! Writing an [`AepFile`] back to bytes.
use std::io::Write;
use crate::chunk::{AepFile, Body, Chunk};
use crate::error::AepError;
/// Deepest tree [`write`] accepts (a hand-built tree may be deeper than any parsed one).
const MAX_WRITE_DEPTH: usize = 256;
/// Serialises `file`: `RIFX`, the recomputed container size, `Egg!`, the chunks and the trailer.
/// Sizes of the container and of every chunk are recomputed from the tree; an unmodified parsed
/// file comes back byte for byte.
///
/// # Errors
/// [`AepError::TooLarge`] when a chunk or the container exceeds the 32-bit size field,
/// [`AepError::TooDeep`] for an absurdly deep hand-built tree.
pub fn write(file: &AepFile<'_>) -> Result<Vec<u8>, AepError> {
let mut out = Vec::new();
// Sized once to the exact output. A tree past the 32-bit container size fails at once (the
// same error the write below would give after serialising it). `try_reserve` so a hand-built
// tree that repeats one large borrowed body many times cannot abort on the allocation; the
// writer then grows as it goes, as before.
if let Some(len) = sequence_len(&file.chunks, 1) {
if u32::try_from(len.saturating_add(4)).is_err() {
return Err(AepError::TooLarge);
}
if let Some(total) = len.checked_add(12).and_then(|n| n.checked_add(file.trailer.len())) {
let _ = out.try_reserve_exact(total);
}
}
out.extend_from_slice(b"RIFX");
out.extend_from_slice(&[0; 4]);
out.extend_from_slice(b"Egg!");
write_sequence(&mut out, &file.chunks, 1)?;
let size = u32::try_from(out.len().saturating_sub(8)).map_err(|_| AepError::TooLarge)?;
patch_u32(&mut out, 4, size)?;
out.extend_from_slice(&file.trailer);
Ok(out)
}
/// Like [`write`], into any writer.
///
/// # Errors
/// A size or depth error of [`write`] becomes `ErrorKind::InvalidData`; otherwise I/O errors.
pub fn write_to(file: &AepFile<'_>, w: &mut impl Write) -> std::io::Result<()> {
let bytes = write(file).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
w.write_all(&bytes)
}
fn patch_u32(out: &mut [u8], at: usize, v: u32) -> Result<(), AepError> {
let end = at.checked_add(4).ok_or(AepError::TooLarge)?;
let dst = out.get_mut(at..end).ok_or(AepError::TooLarge)?;
for (d, s) in dst.iter_mut().zip(v.to_be_bytes()) {
*d = s;
}
Ok(())
}
fn write_sequence(out: &mut Vec<u8>, chunks: &[Chunk<'_>], depth: usize) -> Result<(), AepError> {
if depth > MAX_WRITE_DEPTH {
return Err(AepError::TooDeep);
}
let count = chunks.len();
for (i, chunk) in chunks.iter().enumerate() {
write_chunk(out, chunk, i + 1 == count, depth)?;
}
Ok(())
}
/// The number of bytes [`write_sequence`] produces for `chunks`; `None` past the depth limit or
/// on overflow (both make the write fail).
fn sequence_len(chunks: &[Chunk<'_>], depth: usize) -> Option<usize> {
if depth > MAX_WRITE_DEPTH {
return None;
}
let count = chunks.len();
let mut total = 0usize;
for (i, chunk) in chunks.iter().enumerate() {
let body = match &chunk.body {
Body::Data(d) => d.len(),
Body::Chunks(c) => sequence_len(c, depth + 1)?,
Body::List { children, .. } => sequence_len(children, depth + 1)?.checked_add(4)?,
Body::OpaqueList { data, .. } => data.len().checked_add(4)?,
};
// The same pad rule as `write_chunk`.
let pad = usize::from(body % 2 == 1 && (chunk.pad.is_some() || i + 1 != count));
total = total.checked_add(8)?.checked_add(body)?.checked_add(pad)?;
}
Some(total)
}
fn write_chunk(out: &mut Vec<u8>, chunk: &Chunk<'_>, is_last: bool, depth: usize) -> Result<(), AepError> {
out.extend_from_slice(&chunk.id);
let size_at = out.len();
out.extend_from_slice(&[0; 4]);
let body_start = out.len();
match &chunk.body {
Body::Data(d) => out.extend_from_slice(d),
Body::Chunks(c) => write_sequence(out, c, depth + 1)?,
Body::List { list_type, children } => {
out.extend_from_slice(list_type);
write_sequence(out, children, depth + 1)?;
}
Body::OpaqueList { list_type, data } => {
out.extend_from_slice(list_type);
out.extend_from_slice(data);
}
}
let len = out.len().saturating_sub(body_start);
patch_u32(out, size_at, u32::try_from(len).map_err(|_| AepError::TooLarge)?)?;
if len % 2 == 1 {
match chunk.pad {
Some(b) => out.push(b),
None if !is_last => out.push(0),
None => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use super::*;
/// The output is sized once to its exact length.
#[test]
fn the_output_is_sized_once_to_its_exact_length() {
let odd = |n: u8| Chunk::data(*b"cdat", vec![n; usize::from(n)]);
let mut padded = odd(3);
padded.pad = Some(0x7f);
let trees = [
AepFile { chunks: Vec::new(), trailer: Cow::Borrowed(&[]) },
AepFile {
chunks: vec![
odd(1),
Chunk::list(*b"Fold", vec![odd(2), odd(3), Chunk::list(*b"tdgp", vec![odd(5)])]),
Chunk { id: *b"LIST", body: Body::OpaqueList { list_type: *b"btdk", data: Cow::Owned(vec![1, 2, 3]) }, pad: None },
Chunk { id: *b"tdmn", body: Body::Chunks(vec![odd(7), padded]), pad: None },
odd(9),
],
trailer: Cow::Borrowed(b"<x:xmpmeta/>"),
},
];
for file in &trees {
let out = write(file).expect("write");
assert_eq!(sequence_len(&file.chunks, 1).map(|n| n + 12 + file.trailer.len()), Some(out.len()));
// Exact with std's allocator today; grown push by push it would be rounded up.
assert_eq!(out.capacity(), out.len(), "the output grew past its size");
}
}
/// A tree past the container size fails before serialising.
#[cfg(target_pointer_width = "64")]
#[test]
fn a_tree_past_the_container_size_fails_before_serialising() {
// 4097 chunks borrowing one 1 MiB body: over 4 GiB of output, none of it written.
let body = vec![0u8; 1 << 20];
let chunks: Vec<Chunk<'_>> = (0..4097).map(|_| Chunk::data(*b"cdat", &body[..])).collect();
let file = AepFile { chunks, trailer: Cow::Borrowed(&[]) };
assert_eq!(write(&file), Err(AepError::TooLarge));
// Just under the limit still sizes, and fails nothing up front.
assert!(sequence_len(&file.chunks[..4000], 1).is_some_and(|n| u32::try_from(n + 4).is_ok()));
}
/// A tree past the depth limit has no size.
#[test]
fn a_tree_past_the_depth_limit_has_no_size() {
let mut deep = Chunk::data(*b"cdat", vec![0]);
for _ in 0..=MAX_WRITE_DEPTH {
deep = Chunk::list(*b"tdgp", vec![deep]);
}
let file = AepFile { chunks: vec![deep], trailer: Cow::Borrowed(&[]) };
assert_eq!(sequence_len(&file.chunks, 1), None);
assert_eq!(write(&file), Err(AepError::TooDeep));
}
}