Merge pull request #1093 from moecr7/codex/fix-1063-text-line-breaks

This commit is contained in:
Brandon Thomas committed 2026-10-13 04:28:28 -04:00
commit 39ef24af3a
5 files changed
+230 -35

No files matched your search

+133 -25
View File
@@ -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<usize>,
pub chars: Range<usize>,
}
/// 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<Item = ParagraphRange> + '_ {
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<Range<usize>> {
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<usize> =
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<usize> {
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<usize> {
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<_>>(), vec![0..2, 4..6, 7..8, 9..9, 10..10]);
assert_eq!(spans.iter().map(|p| &text[p.bytes.clone()]).collect::<Vec<_>>(), 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<_>>(), 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<_>>(), 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<_>>(), 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::<Vec<_>>(), indents, "{text:?} → {expected:?}");
}
}
fn sizes(d: &TextDoc) -> Vec<(usize, f64)> {
d.runs().iter().map(|r| (r.len, r.style.size)).collect()
}
+27
View File
@@ -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();
+6 -8
View File
@@ -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<usize>, 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<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Layout {
let fallback = [(0..text.len(), TextStyle::default())];
@@ -956,12 +956,10 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
let width = para_at(0).width;
let primaries: Vec<Resolved> = 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() {
+59
View File
@@ -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<_>>(), 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() };
+5 -2
View File
@@ -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`,