Files
craft-libs/crates/aep/tests/it/container/text_fields.rs
Dylan M. Taylorandarnav-hiwarkar 119ac0771f Add craft-aep: After Effects project container and typed view from EffectCraft (#888)
* Add craft-aep: After Effects project container and typed view from EffectCraft

The .aep (RIFX 'Egg!') and .aepx container as a lossless chunk tree that
writes back byte for byte, and the typed, read-only view over it:
compositions, layers, properties, keyframes, effects and their settings
data, masks, shapes, text documents, markers, cameras, track mattes,
footage, project settings and the saving version.

Moved from effectcraft crates/aep/src/container at f6b4da0a with its unit
tests, the container integration tests, the aepdump and stubicc tools and
docs/aep-format.md. The match-name tables (effect_map, style_map), the
importer and the exporter are written against EffectCraft's model and
stay there. ExtractorChannels::sources takes the caller's name for a
copied channel instead of an EffectCraft constant.

* craft-aep: read the Photoshop layer a footage item shows

A footage item made from one layer of a .psd stores the layer in its
`opti` (tag `8BPS`): the layer record's index (big-endian i32 at 14, -1
for the merged image), the document's two sides (little-endian u32 at 32
and 36), the layer's bounds (little-endian i32 at 78, 82, 86, 90) and its
name (UTF-8 from 344). `Footage::psd_layer` reads them as `PsdLayer`.

The format part of storytold/effectcraft#3449; the importer's use of it
stays in EffectCraft.

---------

Co-authored-by: arnav-hiwarkar <asharnav2008@gmail.com>
2026-11-11 16:42:18 -05:00

211 lines
10 KiB
Rust

//! Text document fields used by the importer: stroke colour, automatic leading, the font of each
//! run, the undecoded-slot check, damaged runs, the Source Text record and the text anchor rule.
use crate::container::helpers::{first_comp, layer_named, open};
use craft_aep::{Caps, LayerType, Project, TextDocument, ValueSource};
/// Every synthetic text case that has a fixture, with its text.
const CASES: &[(&str, &str)] = &[
("110-text-A.aep", "A"),
("113-text-Hello-size60-fillorange.aep", "Hello"),
("114-text-animator-opacity.aep", "Hello"),
("243-text-multiline-A-CR-B.aep", "A\rB"),
("245-text-box-400x200-Hello.aep", "Hello"),
("246-text-box-center-align.aep", "Hello"),
("247-text-tracking-100.aep", "Hello"),
("248-text-range-fill-red-1-3.aep", "Hello"),
("334-text-box-right-align.aep", "Hello"),
("335-text-point-fauxbold.aep", "Hello"),
("336-text-point-fauxitalic.aep", "Hello"),
("337-text-point-allcaps.aep", "Hello"),
("338-text-point-smallcaps.aep", "Hello"),
("339-text-point-leading60.aep", "Hello"),
("340-text-range-two-styles.aep", "Hello"),
("385-text-anchor-10-20.aep", "A"),
("386-text-etx-A-B.aep", "A\u{3}B"),
];
fn doc(name: &str) -> Option<TextDocument> {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).unwrap();
layer_named(&first_comp(&project), "T").text_document().unwrap_or_else(|| panic!("{name}: no text document"))
})
}
/// The raw `btdk` body of the text layer of a fixture.
fn body(name: &str) -> Option<Vec<u8>> {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comp = first_comp(&project);
let layer = layer_named(&comp, "T");
layer.chunk().find_list(b"btdk").and_then(|c| c.bytes()).unwrap().to_vec()
})
}
fn replace_once(hay: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
replace_after(hay, b"", from, to)
}
/// Replaces the first `from` after the first `anchor` (the run style of 244 starts with
/// `/0 0 /1 100.0`; the document's style sheet before it has the same slots).
fn replace_after(hay: &[u8], anchor: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
let start = if anchor.is_empty() { 0 } else { hay.windows(anchor.len()).position(|w| w == anchor).unwrap() };
let at =
start + hay[start..].windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("{:?} not found", String::from_utf8_lossy(from)));
[&hay[..at], to, &hay[at + from.len()..]].concat()
}
#[test]
fn every_sample_is_complete_and_has_only_decoded_slots_changed() {
for (name, text) in CASES {
let d = skip!(doc(name));
assert_eq!(d.text, *text, "{name}");
assert_eq!(d.incomplete_runs, 0, "{name}");
assert!(d.undecoded_style_fields.is_empty(), "{name}: {:?}", d.undecoded_style_fields);
assert!(d.undecoded_paragraph_fields.is_empty(), "{name}: {:?}", d.undecoded_paragraph_fields);
assert_eq!(d.style_runs.iter().map(|r| r.length).sum::<usize>(), d.raw_text.chars().count(), "{name}");
for run in &d.style_runs {
// No sample has automatic leading: the stored leading is used (98 by default).
assert!(!run.auto_leading, "{name}");
// The stroke colour is white in every sample (no stroke was sampled).
assert_eq!(run.stroke_argb, Some([1.0; 4]), "{name}");
assert_eq!(d.font_of(run), Some("TimesNewRomanPSMT"), "{name}");
}
}
}
#[test]
fn stored_leading_and_font_of_a_run() {
assert_eq!(skip!(doc("243-text-multiline-A-CR-B.aep")).style_runs[0].leading, Some(98.0));
assert_eq!(skip!(doc("339-text-point-leading60.aep")).style_runs[0].leading, Some(60.0));
// 335: faux bold moves the run to font index 1, which is still Times New Roman.
let d = skip!(doc("335-text-point-fauxbold.aep"));
assert_eq!((d.style_runs[0].font_index, d.font_of(&d.style_runs[0])), (1, Some("TimesNewRomanPSMT")));
}
#[test]
fn automatic_leading_is_slot_4() {
let b = replace_once(&skip!(body("110-text-A.aep")), b"/4 false /5 98.0", b"/4 true /5 98.0");
let d = TextDocument::parse(&b).unwrap();
assert!(d.style_runs[0].auto_leading);
assert!(d.undecoded_style_fields.is_empty());
}
#[test]
fn automatic_leading_as_after_effects_stores_it() {
// 400: Auto Leading switched on for 387's "A" CR "B" (size 100, explicit leading 98 before).
// Against its unedited save 399 the run style differs in `/4` (true) and `/5` (0.01) only, and
// the composed second line sits 120 px (1.2 x 100) below the first, as After Effects measures.
let d = skip!(doc("400-text-autoleading-on.aep"));
assert_eq!(d.text, "A\rB");
assert_eq!(d.incomplete_runs, 0);
assert!(d.undecoded_style_fields.is_empty(), "{:?}", d.undecoded_style_fields);
assert!(d.undecoded_paragraph_fields.is_empty(), "{:?}", d.undecoded_paragraph_fields);
for run in &d.style_runs {
assert!(run.auto_leading);
assert_eq!((run.font_size, run.leading), (100.0, Some(0.01)));
}
let b = skip!(body("400-text-autoleading-on.aep"));
let text = String::from_utf8_lossy(&b);
assert!(text.contains("/0 << /0 [ 0.0 120.00001 ] >>"), "composed second baseline at 120 px");
}
#[test]
fn undecoded_slots_that_differ_are_listed() {
let src = skip!(body("110-text-A.aep"));
// Style slot 68 and paragraph slot 24 changed to values outside their neutral ones (0 and 1,
// 732, 734); a zero slot the samples lack (93) is not listed.
const RUN: &[u8] = b"/0 0 /1 100.0";
let b = replace_after(&src, RUN, b"/68 0 ", b"/68 2 ");
let b = replace_after(&b, RUN, b"/96 true >>", b"/93 0.0 /94 0.0 /96 true >>");
// The run's paragraph is the one with `/22 1 /23 true` (the style sheet's has `/22 0`).
let para = b"/20 6 /21 false /22 1 /23 true /24 1";
let b = replace_once(&b, para, b"/20 6 /21 false /22 1 /23 true /24 2");
let d = TextDocument::parse(&b).unwrap();
assert_eq!(d.undecoded_style_fields, ["68"]);
assert_eq!(d.undecoded_paragraph_fields, ["24"]);
// A non-zero unknown slot is listed.
let d = TextDocument::parse(&replace_after(&src, RUN, b"/96 true >>", b"/97 2 /96 true >>")).unwrap();
assert_eq!(d.undecoded_style_fields, ["97"]);
// `/nil` is the stored form of a slot without a value (244 has three): not listed.
let d = TextDocument::parse(&replace_once(&src, b"/22 1 /23 true /24 1", b"/22 1 /23 true /24 1 /48 /nil")).unwrap();
assert!(d.undecoded_paragraph_fields.is_empty(), "{:?}", d.undecoded_paragraph_fields);
}
#[test]
fn a_damaged_run_is_kept_and_counted() {
// 340 has three runs; the size of the third (60) is removed: the run stays and takes the size of
// the one before it.
let src = skip!(body("340-text-range-two-styles.aep"));
let at = src.windows(b"/1 60.0".len()).position(|w| w == b"/1 60.0").unwrap();
let b = [&src[..at], &src[at + b"/1 60.0".len()..]].concat();
let d = TextDocument::parse(&b).unwrap();
assert_eq!(d.style_runs.len(), 3);
assert_eq!(d.incomplete_runs, 1);
assert_eq!(d.style_runs[2].font_size, 100.0);
assert_eq!(d.style_runs[2].fill_argb, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(d.style_runs[2].caps, Caps::Normal);
}
#[test]
fn source_text_record_and_text_anchor() {
for name in ["110-text-A.aep", "114-text-animator-opacity.aep", "248-text-range-fill-red-1-3.aep"] {
let bytes = fixture!(name);
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comp = first_comp(&project);
let t = layer_named(&comp, "T");
assert_eq!(t.header().unwrap().layer_type, LayerType::Text);
let st = t.source_text_property().expect("source text record");
assert!(!st.is_animated() && st.expression().is_none(), "{name}");
assert_eq!(t.text_document_count(), 1, "{name}");
// The text anchor is absent: the text origin, not the centre of a source.
let tr = t.transform(1920, 1080).unwrap();
assert_eq!(tr.anchor_fraction.source, ValueSource::Default, "{name}");
assert_eq!(tr.text_anchor_px(), [0.0; 3], "{name}");
assert_eq!(tr.position.value, [960.0, 540.0, 0.0], "{name}");
}
}
#[test]
fn a_stored_text_anchor_is_pixels() {
// 385: 110's text layer with Anchor Point [10, 20], stored as pixels (a text layer has no source).
let bytes = fixture!("385-text-anchor-10-20.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comp = first_comp(&project);
let tr = layer_named(&comp, "T").transform(1920, 1080).unwrap();
assert_eq!(tr.anchor_fraction.source, ValueSource::Stored);
assert_eq!(tr.text_anchor_px(), [10.0, 20.0, 0.0]);
assert_eq!(tr.position.value, [960.0, 540.0, 0.0]);
}
#[test]
fn etx_is_stored_as_it_is_and_splits_the_paragraph_runs() {
// 386: "A" U+0003 "B" is stored with the U+0003; After Effects lays it out like 243's CR (two
// lines, two paragraphs), and the paragraph runs say the same.
let etx = skip!(doc("386-text-etx-A-B.aep"));
let cr = skip!(doc("243-text-multiline-A-CR-B.aep"));
assert_eq!(etx.raw_text, "A\u{3}B\r");
assert_eq!(etx.paragraphs.len(), cr.paragraphs.len());
assert_eq!(etx.paragraphs.iter().map(|p| p.length).collect::<Vec<_>>(), cr.paragraphs.iter().map(|p| p.length).collect::<Vec<_>>());
}
#[test]
fn hostile_documents_never_panic() {
let src = skip!(body("340-text-range-two-styles.aep"));
// Every prefix, a run length that overflows, a string with a lone surrogate, deep nesting.
for cut in (0..src.len()).step_by(97) {
let _ = TextDocument::parse(&src[..cut]);
}
let _ = TextDocument::parse(&replace_once(&src, b"/1 3 >>", b"/1 99999999999999999999 >>"));
let lone = [b"/1 << /1 [ << /0 << /0 (".as_slice(), &[0xfe, 0xff, 0xd8, 0x00, 0x00, b'A'], b") >> >> ] >>"].concat();
let _ = TextDocument::parse(&lone);
let deep = [b"/1 ".as_slice(), &b"<< /0 ".repeat(10_000), &b">> ".repeat(10_000)].concat();
assert!(TextDocument::parse(&deep).is_none());
}