diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index f1845993..6c1e5b84 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -4,8 +4,8 @@ //! style of the first character and the settings of the first paragraph), which is all a //! single-style document needs and what older projects contain. Mixed formatting is stored in //! [`TextDoc::runs`] (character style runs covering the text, by character count) and -//! [`TextDoc::paragraphs`] (one entry per `\n`-separated paragraph). Both lists are empty while the -//! document is uniform, so single-style documents serialize exactly as before. +//! [`TextDoc::paragraphs`] (one entry per CR, LF or CRLF-separated paragraph). Both lists are +//! empty while the document is uniform, so single-style documents serialize exactly as before. //! //! Positions are **character** indices (Unicode scalar values), as in After Effects' expression //! and scripting APIs. @@ -15,6 +15,46 @@ use std::ops::Range; use serde::{Deserialize, Serialize}; use serde_json::Value as J; +/// A paragraph's original byte and character ranges, excluding its line break. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParagraphRange { + pub bytes: Range, + pub chars: Range, +} + +/// Paragraphs separated by CR, LF or CRLF (one break), including empty/trailing paragraphs. +/// The source is never normalized, so formatting and caret indices keep their original offsets. +pub fn paragraph_ranges(text: &str) -> impl Iterator + '_ { + let mut chars = text.char_indices().enumerate().peekable(); + let (mut byte_start, mut char_start, mut char_end) = (0, 0, 0); + let mut done = false; + std::iter::from_fn(move || { + if done { + return None; + } + while let Some((ci, (bi, ch))) = chars.next() { + char_end = ci.saturating_add(1); + if !matches!(ch, '\r' | '\n') { + continue; + } + let range = ParagraphRange { bytes: byte_start..bi, chars: char_start..ci }; + byte_start = bi.saturating_add(1); + char_start = char_end; + if ch == '\r' + && matches!(chars.peek(), Some((_, (_, '\n')))) + && let Some((ci, (bi, _))) = chars.next() + { + byte_start = bi.saturating_add(1); + char_end = ci.saturating_add(1); + char_start = char_end; + } + return Some(range); + } + done = true; + Some(ParagraphRange { bytes: byte_start..text.len(), chars: char_start..char_end }) + }) +} + /// Horizontal paragraph alignment (AE's seven Paragraph panel buttons). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Justify { @@ -690,30 +730,26 @@ impl TextDoc { self.apply_style(0..n.max(1), f); } - /// Number of paragraphs (`\n`-separated). + /// Number of paragraphs (CR, LF or CRLF-separated). pub fn para_count(&self) -> usize { - self.text.matches('\n').count() + 1 + paragraph_ranges(&self.text).count() } /// Character ranges of the paragraphs (without their newline). pub fn para_ranges(&self) -> Vec> { - let mut out = Vec::new(); - let mut start = 0; - let mut i = 0; - for c in self.text.chars() { - if c == '\n' { - out.push(start..i); - start = i + 1; - } - i += 1; - } - out.push(start..i); - out + paragraph_ranges(&self.text).map(|p| p.chars).collect() } /// Paragraph index of character / caret position `ci`. pub fn para_of(&self, ci: usize) -> usize { - self.text.chars().take(ci).filter(|c| *c == '\n').count() + let (mut para, mut cr) = (0usize, false); + for ch in self.text.chars().take(ci) { + if ch == '\r' || (ch == '\n' && !cr) { + para = para.saturating_add(1); + } + cr = ch == '\r'; + } + para } /// Paragraph settings, one per paragraph. @@ -775,15 +811,35 @@ impl TextDoc { let j = split_at(&mut runs, b); let ins = s.chars().count(); runs.splice(i..j, std::iter::once(StyleRun { len: ins, style: st })); - // Paragraphs: the joined paragraph keeps the first one's settings; new paragraphs copy it. - let mut paras = self.paras(); - let pa = self.para_of(a); - let pb = self.para_of(b); - let added = s.matches('\n').count(); - let keep = paras[pa].clone(); - paras.splice(pa..=pb.min(paras.len() - 1), std::iter::repeat_n(keep, added + 1)); + // Map paragraph starts back to the old text; inserted paragraphs inherit the edited + // paragraph. Counting breaks in `s` alone loses styles when CR/LF join or split at an + // edit boundary (including deleting just one half of a CRLF). + let old_paras = self.paras(); + let starts: Vec = + std::iter::once(0).chain(self.para_ranges().into_iter().take(old_paras.len().saturating_sub(1)).map(|r| r.end.saturating_add(1))).collect(); + let base = self.base_para(); + let old_para = |ci| { + let i = starts.partition_point(|start| *start <= ci).saturating_sub(1); + old_paras.get(i).cloned().unwrap_or_else(|| base.clone()) + }; + let keep = old_para(a); let (ba, bb) = (self.byte_of(a), self.byte_of(b)); self.text.replace_range(ba..bb, s); + let end = a.saturating_add(ins); + let ends_with_break = s.ends_with('\r') || s.ends_with('\n'); + let paras = self + .para_ranges() + .into_iter() + .map(|r| { + if r.start <= a { + old_para(r.start) + } else if r.start < end || (r.start == end && ends_with_break) { + keep.clone() + } else { + old_para(r.start.saturating_sub(ins).saturating_add(b.saturating_sub(a))) + } + }) + .collect(); self.set_runs(runs); self.set_paras(paras); } @@ -1209,7 +1265,7 @@ pub fn word_at(text: &str, ci: usize) -> Range { let class = |c: char| { if c.is_alphanumeric() { 0 - } else if c == '\n' { + } else if matches!(c, '\r' | '\n') { 2 } else if c.is_whitespace() { 1 @@ -1232,6 +1288,58 @@ pub fn word_at(text: &str, ci: usize) -> Range { mod tests { use super::*; + #[test] + fn mixed_line_breaks_keep_original_byte_and_character_ranges() { + let text = "é\r\nβ\rx\n\n"; + let spans: Vec<_> = paragraph_ranges(text).collect(); + assert_eq!(spans.iter().map(|p| p.bytes.clone()).collect::>(), vec![0..2, 4..6, 7..8, 9..9, 10..10]); + assert_eq!(spans.iter().map(|p| &text[p.bytes.clone()]).collect::>(), vec!["é", "β", "x", "", ""]); + let doc = TextDoc::plain(text); + assert_eq!(doc.para_count(), 5); + assert_eq!(doc.para_ranges(), vec![0..1, 3..4, 5..6, 7..7, 8..8]); + assert_eq!((0..=8).map(|ci| doc.para_of(ci)).collect::>(), vec![0, 0, 1, 1, 1, 2, 2, 3, 4]); + let restored: TextDoc = serde_json::from_value(serde_json::to_value(&doc).unwrap()).unwrap(); + assert_eq!(restored.text, text, "line endings survive a project round trip"); + assert_eq!(paragraph_ranges("").count(), 1); + assert_eq!(paragraph_ranges("\r\n").count(), 2); + } + + #[test] + fn paragraph_formatting_follows_cr_lf_and_crlf_edits() { + for separator in ["\r", "\n", "\r\n"] { + let mut doc = TextDoc::plain(&format!("one{separator}two{separator}three")); + let start = doc.para_ranges()[1].start; + doc.apply_para(start..start + 1, |p| p.indent_left = 30.0); + doc.replace_range(start + 1..start + 1, separator, None); + assert_eq!(doc.text, format!("one{separator}t{separator}wo{separator}three")); + assert_eq!(doc.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0, 30.0, 0.0]); + let ranges = doc.para_ranges(); + doc.replace_range(ranges[0].end..ranges[1].start, "", None); + assert_eq!(doc.text, format!("onet{separator}wo{separator}three")); + assert_eq!(doc.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0, 0.0]); + } + } + + #[test] + fn editing_crlf_boundaries_preserves_untouched_paragraph_styles() { + for (text, range, inserted, expected, indents) in [ + ("a\r\nb", 1..2, "", "a\nb", vec![1.0, 2.0]), + ("a\r\nb", 2..3, "", "a\rb", vec![1.0, 2.0]), + ("a\rb", 2..2, "\n", "a\r\nb", vec![1.0, 2.0]), + ("a\nb", 1..1, "\r", "a\r\nb", vec![1.0, 2.0]), + ("a\rX\nb", 2..3, "", "a\r\nb", vec![1.0, 3.0]), + ("a\r\nb", 2..2, "X", "a\rX\nb", vec![1.0, 2.0, 2.0]), + ] { + let mut doc = TextDoc::plain(text); + for (i, p) in doc.para_ranges().into_iter().enumerate() { + doc.apply_para(p, |p| p.indent_left = (i + 1) as f64); + } + doc.replace_range(range, inserted, None); + assert_eq!(doc.text, expected); + assert_eq!(doc.paras().iter().map(|p| p.indent_left).collect::>(), indents, "{text:?} → {expected:?}"); + } + } + fn sizes(d: &TextDoc) -> Vec<(usize, f64)> { d.runs().iter().map(|r| (r.len, r.style.size)).collect() } diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index d3eaaa6b..87f1f45e 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -31,6 +31,33 @@ fn layer<'a>(c: &'a Comp, name: &str) -> &'a Layer { c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}")) } +/// #1063 (item 8): Source Text retains CR while sourceRectAtTime sees both lines. +#[test] +fn carriage_return_source_text_has_multiline_script_bounds() { + let mut s = session(); + let result = ok( + &mut s, + r#" + var comp = app.project.items.addComp("Line breaks", 640, 360, 1, 1, 30); + var crDoc = new TextDocument("Line 1\rLine 2"); + crDoc.font = "Inter"; + crDoc.fontSize = 40; + var lfDoc = new TextDocument("Line 1\nLine 2"); + lfDoc.font = "Inter"; + lfDoc.fontSize = 40; + var cr = comp.layers.addText(crDoc); + var lf = comp.layers.addText(lfDoc); + var crRect = cr.sourceRectAtTime(0, false); + var lfRect = lf.sourceRectAtTime(0, false); + [cr.text.sourceText.value.text, crRect.height, lfRect.height, crRect.width, lfRect.width] + "#, + ); + assert_eq!(result.result[0], "Line 1\rLine 2"); + assert!(result.result[1].as_f64().unwrap() > 40.0); + assert_eq!(result.result[1], result.result[2]); + assert_eq!(result.result[3], result.result[4]); +} + #[test] fn builds_a_comp_end_to_end() { let mut s = session(); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 5dd8df8b..c1503215 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use harfrust::{Direction, Feature, Tag, UnicodeBuffer}; use unicode_bidi::{BidiInfo, Level}; -use effectcraft_keyframe::OpenType; +use effectcraft_keyframe::{OpenType, text_doc::paragraph_ranges}; use crate::fonts::{self, FaceId, Resolved}; @@ -940,13 +940,13 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar out } -/// Lay out `text` (paragraphs separated by `\n`) in one style. +/// Lay out `text` (paragraphs separated by CR, LF or CRLF) in one style. pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout { layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para)) } /// Lay out `text` with character style runs (byte ranges covering the text, in order; at least -/// one) and paragraph settings (one per `\n`-separated paragraph; the last repeats). The wrap +/// one) and paragraph settings (one per CR/LF/CRLF-separated paragraph; the last repeats). The wrap /// width is the first paragraph's. pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], paras: &[ParagraphStyle]) -> Layout { let fallback = [(0..text.len(), TextStyle::default())]; @@ -956,12 +956,10 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para let width = para_at(0).width; let primaries: Vec = runs.iter().map(|(_, s)| fonts::resolve(&s.family, &s.style)).collect(); let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() }; - let mut base = 0usize; let mut plines: Vec<(usize, ParaLine)> = Vec::new(); - for (pi, p) in text.split('\n').enumerate() { - let p_clean = p.strip_suffix('\r').unwrap_or(p); - plines.extend(paragraph(p_clean, base, runs, &primaries, para_at(pi), width).into_iter().map(|l| (pi, l))); - base += p.len() + 1; + for (pi, span) in paragraph_ranges(text).enumerate() { + let p = text.get(span.bytes.clone()).unwrap_or_default(); + plines.extend(paragraph(p, span.bytes.start, runs, &primaries, para_at(pi), width).into_iter().map(|l| (pi, l))); } let mut baseline = 0.0f32; for (i, (pi, pl)) in plines.into_iter().enumerate() { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 8b7ca771..e8ba9d95 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -447,6 +447,65 @@ pub fn char_glyph(doc: &TextDoc, ch: char) -> (BezPath, f64) { mod tests { use super::*; + /// #1063 (item 8): After Effects scripts use CR for Source Text line breaks. + #[test] + fn carriage_returns_create_lines_without_rewriting_source_text() { + let lf = TextDoc::plain("Line 1\nLine 2"); + let cr = TextDoc::plain("Line 1\rLine 2"); + let expected = layout_doc(&lf); + let actual = layout_doc(&cr); + assert_eq!(actual.lines, 2); + assert_eq!(actual.bounds, expected.bounds); + assert_eq!(actual.line_boxes, expected.line_boxes); + assert_eq!(cr.text, "Line 1\rLine 2"); + assert_eq!(actual.chars, cr.char_len()); + } + + #[test] + fn line_break_variants_match_in_point_box_and_vertical_text() { + for vertical in [false, true] { + for boxed in [false, true] { + let template = TextDoc { size: 30.0, vertical, box_size: boxed.then_some([120.0, 120.0]), ..Default::default() }; + let expected = layout_doc(&TextDoc { text: "one two three\n\nshort\n".into(), ..template.clone() }); + for separator in ["\r", "\r\n", "\n"] { + let text = format!("one two three{separator}{separator}short{separator}"); + let doc = TextDoc { text: text.clone(), ..template.clone() }; + let actual = layout_doc(&doc); + assert_eq!(actual.lines, expected.lines, "{separator:?}, boxed={boxed}, vertical={vertical}"); + assert_eq!(actual.bounds, expected.bounds); + assert_eq!(actual.line_boxes, expected.line_boxes); + assert_eq!(doc.text, text); + assert_eq!(actual.chars, doc.char_len()); + } + } + } + } + + #[test] + fn crlf_preserves_unicode_style_indices_and_paragraph_formatting() { + for separator in ["\r", "\n", "\r\n"] { + let text = format!("é{separator}Beta"); + let mut doc = TextDoc::plain(&text); + let start = 1 + separator.chars().count(); + doc.apply_style(start..doc.char_len(), |s| { + s.size = 80.0; + s.fill = [0.0, 1.0, 0.0, 1.0]; + }); + doc.apply_para(start..start + 1, |p| p.indent_left = 30.0); + let layout = layout_doc(&doc); + assert_eq!(layout.lines, 2); + let glyph = layout.glyphs.iter().find(|g| g.ch == 'B').unwrap(); + assert_eq!(glyph.char_index, start); + assert_eq!(glyph.line_index, 1); + assert_eq!(glyph.size, 80.0); + assert_eq!(layout.styles[glyph.run].fill, [0.0, 1.0, 0.0, 1.0]); + assert_eq!(layout.line_span(start), (start, doc.char_len())); + assert_eq!(layout.char_of_byte(doc.byte_of(start)), start); + assert_eq!(doc.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0]); + assert_eq!(doc.text, text); + } + } + #[test] fn layout_counts_and_bounds() { let doc = TextDoc { text: "Hello big world".into(), size: 100.0, ..Default::default() }; diff --git a/docs/agents.md b/docs/agents.md index 01738edd..7acba7af 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -478,8 +478,11 @@ effectcraft-cli run clip.ecproj roto.stroke '{"layer":"#1","points":[[300,200],[ ### Text: styles and editing -Source Text holds character style runs and per-paragraph settings. `layer.setText` changes the -whole layer, or only characters `range: [start, end]` (character indices): character attributes +Source Text holds character style runs and per-paragraph settings. CR (`\r`), LF (`\n`) and +CRLF (`\r\n`) each make a paragraph break; the original string and character indices are +preserved. This also applies to scripted `TextDocument` values and `sourceRectAtTime()` bounds. +`layer.setText` changes the whole layer, or only characters `range: [start, end]` (character +indices): character attributes (`font`, `size`, `fill`, `tracking`, `kerning: metrics|optical|<1/1000 em>`, `tsume`, `baseline: superscript|subscript`, `allCaps`…) split the text into runs; paragraph attributes (`justify`, `indentLeft`, `indentFirst`, `spaceBefore`, `direction`, `composer`,