//! Text layers: every text fixture is imported, exported and parsed back with `effectcraft-aep`; //! the text document (text, paragraphs, style runs, fonts, box) must equal the one After Effects //! wrote for every decoded field, and a second import gives the same Source Text. Documents built //! in code check line breaks, runs, fonts, and what is reported. use crate::export::helpers::*; use effectcraft_aep::export::LossCategory; use effectcraft_aep::import::{ImportOptions, import}; use effectcraft_aep::{Caps, Justification, TextDocument, parse}; use effectcraft_keyframe::Value; use effectcraft_keyframe::text_doc::{BaselineOption, CharStyle, Justify, ParaStyle, StyleRun, TextDoc}; use effectcraft_project::{ItemId, ItemKind, LayerSource, Project}; use effectcraft_time::FrameRate; fn text_fixtures() -> Option> { Some({ let mut v: Vec = crate::common::names("aep")?.into_iter().filter(|n| n.ends_with(".aep") && n.contains("-text-")).collect(); v.sort(); v }) } fn reimport(bytes: &[u8]) -> Project { let mut p = Project::default(); let none = |_: &str| false; let opts = ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }; import(&mut p, bytes, &opts).expect("import"); p } /// The text documents of all text layers, by composition name and layer index. fn documents(bytes: &[u8]) -> Vec<((String, usize), TextDocument)> { let file = parse(bytes).expect("parse"); let view = effectcraft_aep::Project::new(&file).expect("project"); let mut out = Vec::new(); for item in view.all_items() { let Some(comp) = item.comp() else { continue }; for (i, layer) in comp.layers().enumerate() { if let Some(d) = layer.text_document() { out.push(((item.display_name().unwrap_or_default(), i), d)); } } } out.sort_by(|a, b| a.0.cmp(&b.0)); out } fn close(a: f64, b: f64) -> bool { (a - b).abs() < 1e-4 } /// The decoded fields of two documents are equal (`font_of` resolves the font indices). fn same_doc(a: &TextDocument, b: &TextDocument, w: &str) { // U+0003 is imported as a paragraph break and written as one (386: the same layout). assert_eq!(a.raw_text.replace('\u{3}', "\r"), b.raw_text, "{w}"); let paras = |d: &TextDocument| -> Vec<(usize, Justification)> { d.paragraphs.iter().map(|p| (p.length, p.justification)).collect() }; assert_eq!(paras(a), paras(b), "{w}"); assert_eq!(a.style_runs.len(), b.style_runs.len(), "{w}: style runs"); for (x, y) in a.style_runs.iter().zip(&b.style_runs) { assert_eq!(x.length, y.length, "{w}"); assert_eq!(a.font_of(x), b.font_of(y), "{w}"); assert!(close(x.font_size, y.font_size), "{w}: size {} {}", x.font_size, y.font_size); assert!((1..4).all(|i| close(x.fill_argb[i], y.fill_argb[i])), "{w}: fill {:?} {:?}", x.fill_argb, y.fill_argb); assert!(close(x.tracking, y.tracking), "{w}: tracking"); assert_eq!((x.faux_bold, x.faux_italic, x.caps, x.auto_leading), (y.faux_bold, y.faux_italic, y.caps, y.auto_leading), "{w}"); if !x.auto_leading { assert_eq!(x.leading.map(|l| (l * 1e4).round()), y.leading.map(|l| (l * 1e4).round()), "{w}: leading"); } } let bx = |d: &TextDocument| d.text_box.map(|t| [t.x, t.y, t.width, t.height]); match (bx(a), bx(b)) { (None, None) => {} (Some(p), Some(q)) => assert!(p.iter().zip(q).all(|(u, v)| close(*u, v)), "{w}: box {p:?} {q:?}"), other => panic!("{w}: box {other:?}"), } } fn source_text(p: &Project) -> Vec { let mut v = Vec::new(); for item in p.items.values() { let ItemKind::Comp(c) = &item.kind else { continue }; for l in &c.layers { if let Some(Value::Text(d)) = l.props.prop("text/sourceText").map(|s| &s.value) { v.push((**d).clone()); } } } v.sort_by(|a, b| a.text.cmp(&b.text)); v } #[test] fn every_text_fixture_is_written_with_its_document() { let names = skip!(text_fixtures()); assert!(names.len() >= 15, "{names:?}"); for name in &names { let bytes = fixture!(name); let p1 = reimport(&bytes); let r = export_all(&p1); // Text animators and vertical text are imported but not written: the only text losses // allowed. let animators = r .report .losses .iter() .filter(|l| l.category == LossCategory::TextLayers) .flat_map(|l| &l.details) .filter(|d| d.contains("text animator") || d.contains("vertical text")) .count(); assert_eq!(r.report.count(LossCategory::TextLayers), animators, "{name}: {:?}", r.report.losses); let (a, b) = (documents(&bytes), documents(&r.bytes)); assert_eq!(a.len(), b.len(), "{name}"); for ((ka, da), (kb, db)) in a.iter().zip(&b) { assert_eq!(ka.0, kb.0, "{name}"); same_doc(da, db, name); } // The container is consistent and a second import gives the same Source Text. let file = parse(&r.bytes).expect("parse"); assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes, "{name}"); let p2 = reimport(&r.bytes); // Vertical text comes back horizontal (reported above). let horizontal = |v: Vec| v.into_iter().map(|d| TextDoc { vertical: false, ..d }).collect::>(); assert_eq!(horizontal(source_text(&p1)), horizontal(source_text(&p2)), "{name}"); } } fn style(font: &str, size: f64) -> CharStyle { CharStyle { font: font.into(), style: "Regular".into(), size, leading: Some(size * 1.2), ..CharStyle::default() } } fn text_project(docs: Vec) -> (Project, ItemId) { let mut p = Project::default(); let c = add_comp(&mut p, "C", 1920, 1080, FrameRate::new(25, 1), 5.0, None); for (i, d) in docs.into_iter().enumerate() { add_layer(&mut p, c, &format!("T{i}"), LayerSource::Text, (1920, 1080)); with_layer(&mut p, c, 0, |l| { if let Some(st) = l.props.prop_mut("text/sourceText") { st.value = Value::Text(Box::new(d)); } }); } (p, c) } #[test] fn documents_built_in_code_are_written_as_their_fields() { let mut runs = TextDoc::plain("Red\r\nBlue\nX"); runs.set_runs(vec![ StyleRun { len: 5, style: CharStyle { fill: [1.0, 0.0, 0.0, 1.0], tracking: 25.0, ..style("ArialMT", 50.0) } }, StyleRun { len: 6, style: CharStyle { fill: [0.0, 0.0, 1.0, 1.0], faux_bold: true, small_caps: true, leading: None, ..style("Georgia", 70.0) } }, ]); runs.set_paras(vec![ ParaStyle { justify: Justify::Center, ..runs.base_para() }, ParaStyle { justify: Justify::Right, ..runs.base_para() }, ParaStyle { justify: Justify::Left, ..runs.base_para() }, ]); let mut boxed = TextDoc::plain("In a box"); boxed.set_runs(vec![StyleRun { len: 8, style: style("TimesNewRomanPSMT", 30.0) }]); boxed.box_size = Some([300.0, 120.0]); boxed.box_pos = [-150.0, -60.0]; let empty = TextDoc::plain(""); let (p, _) = text_project(vec![runs, boxed, empty]); let r = export_all(&p); assert!(r.report.is_lossless(), "{:?}", r.report.losses); assert_eq!(r.report.exported.text_layers, 3); let docs = documents(&r.bytes); let by_text = |t: &str| docs.iter().map(|(_, d)| d).find(|d| d.raw_text == t).unwrap_or_else(|| panic!("{t}")); // CRLF and LF are After Effects' CR; the final paragraph ends with one too. let d = by_text("Red\rBlue\rX\r"); assert_eq!( d.paragraphs.iter().map(|p| (p.length, p.justification)).collect::>(), vec![(4, Justification::Center), (5, Justification::Right), (2, Justification::Left)] ); // Runs in After Effects' characters: "Red\r" + 'B' of the first style (the CRLF counted // once), then "lue\rX\r". let runs: Vec<_> = d.style_runs.iter().map(|s| (s.length, d.font_of(s).map(str::to_string), s.font_size, s.faux_bold, s.caps, s.auto_leading)).collect(); assert_eq!(runs, vec![(4, Some("ArialMT".into()), 50.0, false, Caps::Normal, false), (7, Some("Georgia".into()), 70.0, true, Caps::Small, true)]); assert!(close(d.style_runs[0].tracking, 25.0) && close(d.style_runs[0].fill_argb[1], 1.0) && close(d.style_runs[1].fill_argb[3], 1.0)); // The prototype's own fonts keep their places; new ones are appended. assert_eq!(d.fonts.first().map(String::as_str), Some("TimesNewRomanPSMT")); let b = by_text("In a box\r"); let tb = b.text_box.expect("box"); assert_eq!([tb.x, tb.y, tb.width, tb.height], [-150.0, -60.0, 300.0, 120.0]); let e = by_text("\r"); assert_eq!(e.style_runs.iter().map(|s| s.length).sum::(), 1); // A text layer named after its text stores no name. let (p2, _) = text_project(vec![TextDoc::plain("Named")]); let mut p2 = p2; let c = p2.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C"); with_layer(&mut p2, c, 0, |l| l.name = "Named".into()); let r2 = export_all(&p2); let file = parse(&r2.bytes).expect("parse"); let view = effectcraft_aep::Project::new(&file).expect("project"); let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer"); assert_eq!(layer.name(), None); assert_eq!(layer.header().map(|h| h.layer_type), Some(effectcraft_aep::LayerType::Text)); } /// #3233: whether a text layer's name follows its text survives export and import. A followed /// name is written as no name (After Effects shows the text) and comes back following; a name the /// user gave is written, even when it equals the text, and comes back as the user's. Without a /// recorded state (older projects) a name equal to the text follows. #[test] fn a_text_layer_name_follows_its_text_through_export_and_import() { for (name, follows, stored) in [ ("Hello", Some(false), Some("Hello")), ("Hello 2", Some(true), None), ("Hello", None, None), ("Text 2", None, None), ("Text Title", None, Some("Text Title")), ("Text Title", Some(false), Some("Text Title")), ] { let case = format!("{name:?} {follows:?}"); let (mut p, c) = text_project(vec![TextDoc::plain("Hello")]); with_layer(&mut p, c, 0, |l| { l.name = name.into(); l.name_follows_text = follows; }); let r = export_all(&p); let file = parse(&r.bytes).expect("parse"); let view = effectcraft_aep::Project::new(&file).expect("project"); let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer"); assert_eq!(layer.name().as_deref(), stored, "stored name ({case})"); let back = reimport(&r.bytes); let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); assert_eq!(l.name, stored.unwrap_or("Hello"), "imported name ({case})"); assert_eq!(l.name_follows_text, Some(stored.is_none()), "imported state ({case})"); } // An empty text layer that follows its text is written without a name, as After Effects // writes it, and comes back named "Text", still following. let (mut p, c) = text_project(vec![TextDoc::plain("")]); with_layer(&mut p, c, 0, |l| { l.name = "Text".into(); l.name_follows_text = Some(true); }); let back = reimport(&export_all(&p).bytes); let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); assert_eq!((l.name.as_str(), l.name_follows_text), ("Text", Some(true)), "an empty text layer"); } /// #3233: a following name cut from a first line longer than 40 characters is written as no name /// and comes back as the same cut name, still following; a long name the user gave stays whole. #[test] fn a_long_first_line_names_a_text_layer_the_same_after_export_and_import() { let line = "A headline that runs past forty characters, to 64"; assert!((41..=64).contains(&line.chars().count())); let text = format!("{line}\rSecond"); let cut = effectcraft_project::text_layer_name(&text); assert_eq!(cut.chars().count(), 40); for (name, follows, back_name) in [(cut.as_str(), Some(true), cut.as_str()), (line, Some(false), line)] { let (mut p, c) = text_project(vec![TextDoc::plain(&text)]); with_layer(&mut p, c, 0, |l| { l.name = name.into(); l.name_follows_text = follows; }); let back = reimport(&export_all(&p).bytes); let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); assert_eq!((l.name.as_str(), l.name_follows_text), (back_name, follows), "{name:?}"); } } #[test] fn what_a_text_document_cannot_hold_is_reported() { let mut d = TextDoc::plain("x"); d.set_runs(vec![StyleRun { len: 1, style: CharStyle { apply_stroke: true, stroke_width: 3.0, h_scale: 80.0, ..style("ArialMT", 40.0) } }]); d.set_paras(vec![ParaStyle { justify: Justify::JustifyAll, indent_left: 10.0, ..d.base_para() }]); d.vertical = true; let (mut p, c) = text_project(vec![d]); with_layer(&mut p, c, 0, |l| { if let Some(st) = l.props.prop_mut("text/sourceText") { st.keys = vec![key(0.0, st.value.clone()), key(1.0, Value::Text(Box::new(TextDoc::plain("y"))))]; } }); let r = export_all(&p); // Vertical text and Source Text keys (stroke, scale, justification and indents are written). assert_eq!(r.report.count(LossCategory::TextLayers), 2, "{:?}", r.report.losses); // A 3D text layer is written; only per-character 3D is reported. let (mut p3, c3) = text_project(vec![TextDoc::plain("3D")]); with_layer(&mut p3, c3, 0, |l| l.switches.three_d = true); assert_eq!(export_all(&p3).report.count(LossCategory::ThreeD), 0); with_layer(&mut p3, c3, 0, |l| { if let Some(pc) = l.props.prop_mut("text/perChar3d") { pc.value = Value::Bool(true); } }); assert_eq!(export_all(&p3).report.count(LossCategory::ThreeD), 1); assert_eq!(r.report.exported.text_layers, 1); } #[test] fn hostile_text_never_panics() { let long: String = "ab\r".repeat(50_000); let mut nan = TextDoc::plain("nan"); nan.set_runs(vec![StyleRun { len: 3, style: CharStyle { size: f64::NAN, tracking: f64::INFINITY, leading: Some(f64::NAN), fill: [f32::NAN, 2.0, -1.0, 1.0], ..style("", 10.0) }, }]); let mut odd = TextDoc::plain("()\\ \u{3} \u{1F600} ünï"); odd.box_size = Some([f64::NAN, 1e300]); odd.box_pos = [f64::INFINITY, -1e300]; // Runs that do not cover the text, or cover more. let mut short = TextDoc::plain("short text"); short.runs = vec![StyleRun { len: 2, style: style("ArialMT", 20.0) }]; let mut over = TextDoc::plain("ab"); over.runs = vec![StyleRun { len: 1000, style: style("ArialMT", 20.0) }, StyleRun { len: usize::MAX, style: style("ArialMT", 20.0) }]; let mut max = TextDoc::plain("max"); max.runs = vec![StyleRun { len: usize::MAX, style: style("ArialMT", 20.0) }]; max.paragraphs = vec![effectcraft_keyframe::text_doc::ParaStyle::default(); 3]; let (p, _) = text_project(vec![TextDoc::plain(&long), nan, odd, short, over, max]); let r = export_all(&p); let file = parse(&r.bytes).expect("parse"); assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes); let _ = reimport(&r.bytes); } #[test] fn the_baseline_option_is_written_into_style_slot_13() { // 526-528: slot /13 is 1 superscript (After Effects' scripted default, also in the prototypes), // 0 normal, 2 subscript; normal text written as 1 is drawn by After Effects at 58.3 % size. let doc = |b: BaselineOption| { let mut d = TextDoc::plain("HHHH"); d.set_runs(vec![StyleRun { len: 4, style: CharStyle { baseline: b, ..style("ArialMT", 100.0) } }]); d }; for (b, slot_differs) in [(BaselineOption::Normal, true), (BaselineOption::Superscript, false), (BaselineOption::Subscript, true)] { let (p, _) = text_project(vec![doc(b)]); let r = export_all(&p); assert!(r.report.is_lossless(), "{b:?}: {:?}", r.report.losses); let docs = documents(&r.bytes); let d = &docs.first().expect("doc").1; // The reader decodes /13 now; the scripted default (also in the prototypes) is 1. assert!(d.undecoded_style_fields.iter().all(|f| f != "13"), "{b:?}: {:?}", d.undecoded_style_fields); let _ = slot_differs; // A second import gives the same baseline option back (normal stays normal). let back = source_text(&reimport(&r.bytes)); let got = back.first().and_then(|t| t.runs().first().map(|r| r.style.baseline)); assert_eq!(got, Some(b), "{b:?}"); let text = String::from_utf8_lossy(&r.bytes).to_string(); let want = match b { BaselineOption::Normal => "/13 0", BaselineOption::Superscript => "/13 1", BaselineOption::Subscript => "/13 2", }; assert!(text.contains(want), "{b:?}"); } } #[test] fn superscript_stays_superscript_and_normal_stays_normal() { // 526 and 113 are superscript (the scripted default), 527 normal, 528 subscript: the merged // importer reads /13 and the exporter writes it back, so a round trip keeps each one. for (name, want) in [ ("113-text-Hello-size60-fillorange.aep", BaselineOption::Superscript), ("526-text-point-HHHH-default.aep", BaselineOption::Superscript), ("527-text-point-HHHH-baseline-normal.aep", BaselineOption::Normal), ("528-text-point-HHHH-baseline-subscript.aep", BaselineOption::Subscript), ] { let p1 = reimport(&fixture!(name)); let baselines = |p: &Project| source_text(p).iter().flat_map(|d| d.runs().into_iter().map(|r| r.style.baseline)).collect::>(); let b1 = baselines(&p1); assert!(!b1.is_empty() && b1.iter().all(|b| *b == want), "{name}: {b1:?}"); let r = export_all(&p1); let p2 = reimport(&r.bytes); assert_eq!(baselines(&p2), b1, "{name}"); let docs = documents(&r.bytes); let stored = match want { BaselineOption::Normal => effectcraft_aep::FontBaseline::Normal, BaselineOption::Superscript => effectcraft_aep::FontBaseline::Superscript, BaselineOption::Subscript => effectcraft_aep::FontBaseline::Subscript, }; assert!(docs.iter().flat_map(|(_, d)| &d.style_runs).all(|s| s.baseline == stored), "{name}"); } } #[test] fn a_text_made_in_effectcraft_is_written_with_the_every_line_composer() { // A plain box text without paragraph entries uses the document's settings: EffectCraft's // default composer is every-line, which After Effects reads back as such (t6). let mut d = TextDoc::plain("One two three four five six seven eight\rTwo"); d.set_runs(vec![StyleRun { len: d.text.chars().count(), style: style("ArialMT", 30.0) }]); d.box_size = Some([300.0, 200.0]); assert!(d.paragraphs.is_empty()); let (p, _) = text_project(vec![d.clone()]); let r = export_all(&p); assert!(r.report.is_lossless(), "{:?}", r.report.losses); let docs = documents(&r.bytes); let paras = &docs[0].1.paragraphs; assert_eq!(paras.len(), 2); assert!(paras.iter().all(|p| p.every_line_composer && p.direction == 0 && p.justification == Justification::Left), "{paras:?}"); assert_eq!(source_text(&reimport(&r.bytes))[0].para(0).composer, effectcraft_keyframe::text_doc::Composer::EveryLine); } #[test] fn right_to_left_justification_is_stored_relative_to_the_direction() { // t7: in a right-to-left paragraph After Effects lays code 0 and 3 out flush right and 1 and // 4 flush left; 2, 5, 6 are the same in both directions. use effectcraft_keyframe::text_doc::Direction; let cases = [ (Justify::Left, Justification::Right), (Justify::Right, Justification::Left), (Justify::Center, Justification::Center), (Justify::JustifyLastLeft, Justification::FullRight), (Justify::JustifyLastRight, Justification::FullLeft), (Justify::JustifyLastCenter, Justification::FullCenter), (Justify::JustifyAll, Justification::FullAll), ]; for (rtl, ltr_stored) in [(true, false), (false, true)] { let docs: Vec = cases .iter() .enumerate() .map(|(i, (j, _))| { let mut d = TextDoc::plain(&format!("T{i}")); d.set_runs(vec![StyleRun { len: 2, style: style("ArialMT", 30.0) }]); let dir = if rtl { Direction::Rtl } else { Direction::Ltr }; d.set_paras(vec![ParaStyle { justify: *j, direction: dir, ..d.base_para() }]); d }) .collect(); let (p, _) = text_project(docs); let r = export_all(&p); let written = documents(&r.bytes); let back = source_text(&reimport(&r.bytes)); for (i, (j, swapped)) in cases.iter().enumerate() { let stored = written.iter().find(|(_, d)| d.raw_text == format!("T{i}\r")).map(|(_, d)| d.paragraphs[0].justification).unwrap(); let want = if ltr_stored { effectcraft_aep_justification(*j) } else { *swapped }; assert_eq!(stored, want, "rtl {rtl}: {j:?}"); assert_eq!(back[i].para(0).justify, *j, "rtl {rtl}: round trip"); } } } fn effectcraft_aep_justification(j: Justify) -> Justification { match j { Justify::Left => Justification::Left, Justify::Right => Justification::Right, Justify::Center => Justification::Center, Justify::JustifyLastLeft => Justification::FullLeft, Justify::JustifyLastRight => Justification::FullRight, Justify::JustifyLastCenter => Justification::FullCenter, Justify::JustifyAll => Justification::FullAll, } } /// Every `(path of match names, record)` below `ADBE Text Animators`, keyed records with the /// unused ease before the first / after the last key and spatial arc lengths zeroed, and the /// sequence of match names (the structure). fn animator_records(bytes: &[u8]) -> (Vec, Vec<(String, Vec)>) { fn walk(cs: &[effectcraft_aep::Chunk<'_>], path: &str, inside: bool, names: &mut Vec, out: &mut Vec<(String, Vec)>) { for (i, c) in cs.iter().enumerate() { let Some(name) = c.bytes().filter(|_| c.is(b"tdmn")).map(|b| String::from_utf8_lossy(b.split(|x| *x == 0).next().unwrap_or(b"")).to_string()) else { // Items, layers and other lists: look inside (a node after a `tdmn` is walked // with it). let after_tdmn = i > 0 && cs.get(i - 1).is_some_and(|p| p.is(b"tdmn")); if !inside && !after_tdmn { walk(c.children(), path, inside, names, out); } continue; }; let here = inside || name == "ADBE Text Animators"; let key = format!("{path}/{name}"); if here { names.push(key.clone()); } let Some(node) = cs.get(i + 1) else { continue }; if here && node.is_list(b"tdbs") { // In the bytes the exporter writes (`decoded`). let mut f = effectcraft_aep::AepFile { chunks: vec![crate::export::helpers::decoded(&node.clone().into_owned())], trailer: Default::default() }; if let Some(list) = f.chunks[0].children_mut().iter_mut().find(|k| k.is_list(b"list")) { let size = list .child(b"lhd3") .and_then(|c| c.bytes()) .and_then(|b| b.get(16..20)) .map_or(0, |b| u32::from_be_bytes(b.try_into().unwrap()) as usize); if let Some(ldat) = list.children_mut().iter_mut().find(|k| k.is(b"ldat")) && let effectcraft_aep::Body::Data(d) = &mut ldat.body { let mut v = d.to_vec(); let n = v.len(); if size == 48 && n >= 96 { v[16..32].fill(0); v[n - 16..n].fill(0); } if size >= 104 && size % 8 == 0 && (size - 56) % 24 == 0 { for r in v.chunks_mut(size) { r[16..24].fill(0); } } *d = v.into(); } } out.push((key.clone(), effectcraft_aep::write(&f).unwrap())); } if node.is_list(b"tdgp") { walk(node.children(), &key, here, names, out); } } } let f = parse(bytes).expect("parse"); let (mut names, mut out) = (Vec::new(), Vec::new()); walk(&f.chunks, "", false, &mut names, &mut out); (names, out) } #[test] fn text_animators_are_written_as_after_effects_wrote_them() { // 114, 579-594, 728: the animators, their properties and range selectors come back with // After Effects' structure and records. let mut names: Vec = fixture_names!("aep").into_iter().filter(|n| n.ends_with(".aep") && (n.contains("textanim") || n.contains("text-animator"))).collect(); names.sort(); assert!(names.len() >= 16, "{names:?}"); let mut compared = 0; for name in &names { let bytes = fixture!(name); let r = export_all(&reimport(&bytes)); assert_eq!(r.report.count(LossCategory::TextLayers), 0, "{name}: {:?}", r.report.losses); let (mut na, mut ra) = animator_records(&bytes); let (nb, rb) = animator_records(&r.bytes); // With Units = Index After Effects keeps the percent range it had before (583); the // index range is what it uses, the export has only that. if na.iter().any(|n| n.ends_with("ADBE Text Index Start")) { na.retain(|n| !n.contains("ADBE Text Percent")); ra.retain(|(n, _)| !n.contains("ADBE Text Percent")); } // A record After Effects writes for a value at its default when another one makes it // matter (587: Smoothness 100 with Shape Ramp Up): absent = default. let explicit = |n: &str| n.ends_with("ADBE Text Selector Smoothness") && !nb.iter().any(|m| m == n); let default_100 = |rec: &[u8]| rec.windows(8).any(|w| w == 100.0f64.to_be_bytes()); let drop: Vec = ra.iter().filter(|(n, rec)| explicit(n) && default_100(rec)).map(|(n, _)| n.clone()).collect(); na.retain(|n| !drop.contains(n)); ra.retain(|(n, _)| !drop.contains(n)); if na != nb { let short = |v: &[String]| v.iter().map(|s| s.rsplit('/').next().unwrap_or_default().to_string()).collect::>(); panic!( "{name}: structure AE {:?} out {:?}", short(&na), short(&nb) ); } for ((ka, a), (kb, b)) in ra.iter().zip(&rb) { assert_eq!(ka, kb, "{name}"); assert_eq!(a, b, "{name}: {ka}"); compared += 1; } assert_eq!(ra.len(), rb.len(), "{name}"); } assert!(compared >= 30, "{compared}"); } #[test] fn hostile_text_animators_never_panic() { use effectcraft_project::build; let mut d = TextDoc::plain("Hostile"); d.set_runs(vec![StyleRun { len: 7, style: style("ArialMT", 40.0) }]); let (mut p, c) = text_project(vec![d]); let mut next = p.next_id; { let mut ids = build::Ids(&mut next); let mut anims = Vec::new(); for i in 0..300 { let props: Vec<_> = ["position", "scale", "fillColor", "tracking", "blur"].iter().flat_map(|k| build::text_anim_props(&mut ids, k, false)).collect(); let mut a = build::text_animator(&mut ids, &format!("A{i}"), props); if let Some(pg) = a.sub_mut("properties") { pg.get_mut("position").unwrap().value = Value::Vec3([f64::NAN, 1e300, 0.0]); pg.get_mut("tracking").unwrap().keys = (0..2000).map(|k| key(f64::from(k) * 0.001, Value::Scalar(f64::from(k)))).collect(); pg.get_mut("fillColor").unwrap().keys = vec![key(0.0, Value::Scalar(1.0)), key(1.0, Value::Color([f64::INFINITY, 0.0, 0.0, 1.0]))]; } if let Some(sels) = a.sub_mut("selectors") { sels.children.push(build::wiggly_selector(&mut ids, "W").into()); } anims.push(effectcraft_project::Node::Group(a)); } with_layer(&mut p, c, 0, |l| l.props.sub_mut("text").unwrap().sub_mut("animators").unwrap().children = anims); } p.next_id = next; let r = export_all(&p); let file = parse(&r.bytes).expect("parse"); assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes); assert!(r.report.count(LossCategory::TextLayers) >= 300, "{:?}", r.report.losses); let _ = reimport(&r.bytes); } /// A text animator's Fill Color outside 0 to 1 is clamped and reported under text layers, like a /// shape colour (review finding: the animator path skipped the check); an in-range one is /// lossless. #[test] fn a_text_animator_colour_outside_the_range_is_reported() { use effectcraft_project::build; let with_fill = |colour: [f64; 4]| { let mut d = TextDoc::plain("Glow"); d.set_runs(vec![StyleRun { len: 4, style: style("ArialMT", 40.0) }]); let (mut p, c) = text_project(vec![d]); let mut next = p.next_id; { let mut ids = build::Ids(&mut next); let props = build::text_anim_props(&mut ids, "fillColor", false); let mut a = build::text_animator(&mut ids, "A", props); if let Some(pg) = a.sub_mut("properties") { pg.get_mut("fillColor").unwrap().value = Value::Color(colour); } with_layer(&mut p, c, 0, |l| l.props.sub_mut("text").unwrap().sub_mut("animators").unwrap().children = vec![effectcraft_project::Node::Group(a)]); } p.next_id = next; p }; let lost = |p: &Project| { let r = export_all(p); r.report .losses .iter() .filter(|l| l.category == LossCategory::TextLayers) .flat_map(|l| l.details.clone()) .filter(|d| d.contains("outside 0 to 1")) .count() }; assert_eq!(lost(&with_fill([0.2, 0.4, 0.6, 1.0])), 0); assert_eq!(lost(&with_fill([3.0, 0.4, -1.0, 1.0])), 1); } /// A text animator's Fill or Stroke Color alpha is reported (After Effects colours have none; the /// converter wrote them opaque without a word, review finding), for the static value and for keys; /// the check follows the values that are written: an alpha on the static value of a keyed colour /// is not reported, an opaque control is lossless. #[test] fn a_text_animator_colour_alpha_is_reported_for_the_values_that_are_written() { use effectcraft_project::build; for id in ["fillColor", "strokeColor"] { let make = |value: [f64; 4], keys: Option<([f64; 4], [f64; 4])>| { let mut d = TextDoc::plain("Glow"); d.set_runs(vec![StyleRun { len: 4, style: style("ArialMT", 40.0) }]); let (mut p, c) = text_project(vec![d]); let mut next = p.next_id; { let mut ids = build::Ids(&mut next); let props = build::text_anim_props(&mut ids, id, false); let mut a = build::text_animator(&mut ids, "A", props); if let Some(pg) = a.sub_mut("properties") { let prop = pg.get_mut(id).unwrap(); prop.value = Value::Color(value); if let Some((k0, k1)) = keys { prop.keys = vec![key(0.0, Value::Color(k0)), key(1.0, Value::Color(k1))]; } } with_layer(&mut p, c, 0, |l| { l.props.sub_mut("text").unwrap().sub_mut("animators").unwrap().children = vec![effectcraft_project::Node::Group(a)] }); } p.next_id = next; p }; let alpha_lost = |p: &Project| { let r = export_all(p); r.report.losses.iter().filter(|l| l.category == LossCategory::TextLayers).flat_map(|l| l.details.clone()).filter(|d| d.contains("alpha")).count() }; let strict = |p: &Project| effectcraft_aep::export::export(p, &effectcraft_aep::export::ExportOptions { allow_losses: false }).is_ok(); let opaque = [0.2, 0.4, 0.6, 1.0]; let clear = [0.2, 0.4, 0.6, 0.25]; let control = make(opaque, None); assert_eq!(alpha_lost(&control), 0, "{id}"); let static_alpha = make(clear, None); assert_eq!(alpha_lost(&static_alpha), 1, "{id}"); assert!(!strict(&static_alpha), "{id}"); let keyed_alpha = make(opaque, Some((opaque, clear))); assert_eq!(alpha_lost(&keyed_alpha), 1, "{id}"); // The static value of a keyed colour is not written: its alpha is not reported. let keyed_opaque = make(clear, Some((opaque, opaque))); assert_eq!(alpha_lost(&keyed_opaque), 0, "{id}"); } } /// Two fonts whose names differ only in bytes a lossy UTF-8 conversion turns into the same text /// (U+4E80 and U+4E81 both read as `N` + U+FFFD) get entries of their own (review finding). #[test] fn non_ascii_font_names_keep_their_own_font_entries() { let mut d = TextDoc::plain("ab"); d.set_runs(vec![StyleRun { len: 1, style: style("\u{4e80}", 40.0) }, StyleRun { len: 1, style: style("\u{4e81}", 40.0) }]); let (p, _) = text_project(vec![d]); let r = export_all(&p); let docs = documents(&r.bytes); let (_, doc) = docs.first().expect("document"); assert!(doc.fonts.iter().any(|f| f == "\u{4e80}") && doc.fonts.iter().any(|f| f == "\u{4e81}"), "{:?}", doc.fonts); }