mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #1093 from moecr7/codex/fix-1063-text-line-breaks
This commit is contained in:
commit
39ef24af3a
5 files changed
+230
-35
No files matched your search
+133
-25
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user