#1063: render carriage-return text as separate paragraphs

This commit is contained in:
Moein Saharkhiz committed 2026-10-12 14:47:58 -04:00
1 parent 470ea7a3af
commit a27f9f54b9
5 files changed
+230 -35

No files matched your search

+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() };