From fd02772d224f2e406c64b7df49be36d13d000d91 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:21 +0200 Subject: [PATCH 1/8] refactor(text): one helper for the chars that end a line --- crates/text/src/layout.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7fc6e11a..6266dc2b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -157,6 +157,11 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { if v.is_finite() { v } else { auto } } +/// A char that ends its line: a newline (which also ends the paragraph). +fn is_line_break(c: char) -> bool { + c == '\n' +} + /// Opening punctuation, which hangs outside a line's start. fn opens(c: char) -> bool { matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '\u{201E}' | '\u{201A}' | '\u{00AB}' | '\u{2039}' | '(' | '[' | '{' | '\u{00BF}' | '\u{00A1}') @@ -332,7 +337,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only // for a text with emoji, or with characters nothing above covers. - let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); + let covered = |c: char| is_line_break(c) || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() }; fallbacks.extend(emoji.iter().cloned()); drop(dbw); @@ -370,7 +375,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) { return f.clone(); } - if c == '\n' || primary.has_char(c) { + if is_line_break(c) || primary.has_char(c) { return primary.clone(); } let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks); @@ -382,13 +387,15 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let mut seg_end = seg_start + 1; while seg_end < *end && chars.get(seg_end).is_some_and(|c| { - *c != '\n' && first != '\n' && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) + !is_line_break(*c) + && !is_line_break(first) + && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) }) && level_at(seg_end) == level { seg_end += 1; } - if first == '\n' { + if is_line_break(first) { items.push(Item { face: face.clone(), coords: no_coords(), @@ -401,7 +408,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) char_start: seg_start, char_end: seg_start + 1, style: st_idx, - ch: '\n', + ch: first, level, }); } else { @@ -546,7 +553,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let baseline = y + (lh - (asc + desc)) / 2.0 + asc; // Trailing spaces don't count for width/alignment. let mut vis_end = *e; - while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || it.ch == '\n') { + while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) { vis_end -= 1; } // Letter spacing separates glyphs: none after the line's last visible one. @@ -574,7 +581,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) .get(vis_end..*e) .unwrap_or(&[]) .iter() - .filter(|it| it.ch != '\n') + .filter(|it| !is_line_break(it.ch)) .map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 }) .sum(); let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; @@ -649,7 +656,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) (0..line_items.len()).collect() }; for it in order.iter().filter_map(|k| line_items.get(*k)) { - if it.ch == '\n' { + if is_line_break(it.ch) { continue; } let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale; From 855bbb328fde58feee6d8cbc5d5d380c87235cc9 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:34 +0200 Subject: [PATCH 2/8] refactor(text): Line::break_after for carets and selections Carets, clicks and selections asked `para_end && !last_line` to mean "the line ends with a break char". Record that on the line instead, so a break that doesn't end the paragraph can use the same rules. No behaviour change: today only a newline ends a line early. --- crates/text/src/layout.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 6266dc2b..7aef4e5e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -82,6 +82,9 @@ pub struct Line { pub x: f64, /// The line ends a paragraph (hard break or end of text). pub para_end: bool, + /// The line ends with a line break char: a newline, or a line separator (U+2028) that breaks + /// the line inside its paragraph. + pub break_after: bool, /// The paragraph runs right to left (its first strong character is right-to-left). pub rtl: bool, } @@ -498,6 +501,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } i += 1; } + let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch)); if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) { lines.push((line_start, items.len(), true, para)); } @@ -694,6 +698,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) width: width + line_indent - hang_l - hang_r, x: 0.0, para_end: *para_end, + break_after: break_after(*e), rtl: rtl_line, }); y += lh; @@ -1151,11 +1156,11 @@ impl TextLayout { let last_line = li + 1 == self.lines.len(); let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end)); // A caret at the end of a soft-wrapped line belongs to the next line. - if !(in_line || index == line.char_end && line.para_end && !last_line) { + if !(in_line || index == line.char_end && line.break_after && !last_line) { continue; } - if index == line.char_end && line.para_end && !last_line { - // Before the newline: end of this line. + if index == line.char_end && line.break_after && !last_line { + // Before the line break: end of this line. let x = self.line_end_x(line); return (x, li); } @@ -1186,8 +1191,8 @@ impl TextLayout { if !line.rtl && !gl.iter().any(|g| g.rtl) { return None; } - // The caret can't sit after a paragraph's newline on its own line. - let end = if line.para_end && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; + // The caret can't sit after a line's break on its own line. + let end = if line.break_after && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; let next = (line.char_start..=end.max(line.char_start)) .filter(|i| *i != index) .map(|i| (i, self.caret(i).0)) @@ -1254,7 +1259,7 @@ impl TextLayout { } } let last_line = li + 1 == self.lines.len(); - let end = if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }; + let end = if line.break_after && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }; // Right of a right-to-left line is its start. if line.rtl { line.char_start } else { end } } @@ -1286,7 +1291,7 @@ impl TextLayout { let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 }; let x1 = if b >= line.char_end { // The last line has no paragraph break to show, so it ends at its glyphs. - self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } + self.line_end_x(line) + if line.break_after && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } } else { self.caret(b).0 }; From 692f45123c5b4b28053bfa90c531f5861f414335 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:47 +0200 Subject: [PATCH 3/8] fix(text): a line separator breaks the line inside its paragraph (#852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Figma stores Shift+Enter as U+2028. `.fig` imports keep it, but layout only broke lines at a newline, so imported text ran soft-broken lines together ("Option 1SL: …"), lost the empty line of a separator followed by a newline, and put glyph bullets on one line. A line separator now ends its line without ending the paragraph: no paragraph spacing and no new list marker after it, the next line keeps the item's indent, and it draws no glyph. A trailing one leaves an empty last line, and a line it cuts short isn't stretched when justified. --- crates/text/src/layout.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7aef4e5e..f9139f6f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -160,9 +160,13 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { if v.is_finite() { v } else { auto } } -/// A char that ends its line: a newline (which also ends the paragraph). +/// The line separator (U+2028): a line break inside a paragraph, as Figma's Shift+Enter stores it +/// (imported text keeps it; typed text has newlines only). +const LINE_SEPARATOR: char = '\u{2028}'; + +/// A char that ends its line: a newline (which also ends the paragraph) or a line separator. fn is_line_break(c: char) -> bool { - c == '\n' + c == '\n' || c == LINE_SEPARATOR } /// Opening punctuation, which hangs outside a line's start. @@ -475,6 +479,17 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) para += 1; continue; } + // A line separator (Figma's Shift+Enter) breaks the line inside its paragraph: no + // paragraph spacing and no new list marker after it. + if it.ch == LINE_SEPARATOR { + lines.push((line_start, i + 1, false, para)); + i += 1; + line_start = i; + x = 0.0; + last_break = None; + para_first_line = false; + continue; + } let line_indent = line_indent_of(para, para_first_line, it.char_start); let w = it.adv; // Hanging punctuation takes no room inside the box. @@ -502,7 +517,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) i += 1; } let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch)); - if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) { + if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) { lines.push((line_start, items.len(), true, para)); } @@ -574,7 +589,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // Justify: extra space per gap. let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); let justify_extra = match (text.align_h, wrap) { - (TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0), + (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => { + ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) + } _ => 0.0, }; // Where the visible text ends once justified. From 9e4dcc9204b3d694f6c9a2085524b6d9defa2444 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:58 +0200 Subject: [PATCH 4/8] test(text): line separators in layout, lists and carets (#852) --- crates/text/src/lib.rs | 2 + crates/text/src/line_separator_tests.rs | 82 +++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 crates/text/src/line_separator_tests.rs diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 683350f6..8160cc90 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -150,6 +150,8 @@ mod behaviour_tests; #[cfg(test)] mod cache_tests; #[cfg(test)] +mod line_separator_tests; +#[cfg(test)] mod list_tests; #[cfg(test)] mod metrics_tests; diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs new file mode 100644 index 00000000..d5ad16b0 --- /dev/null +++ b/crates/text/src/line_separator_tests.rs @@ -0,0 +1,82 @@ +//! A line separator (U+2028: what Figma's Shift+Enter stores, and `.fig` imports keep) breaks the +//! line inside its paragraph: no paragraph spacing or new list marker after it, and no glyph (#852). + +use super::*; +use figcraft_doc::{LineHeight, ListStyle, TextAutoResize, TextData}; + +const EPS: f64 = 1e-6; + +/// A 20 px text on 30 px lines in the default face, or None (skip) on a machine without fonts. +fn text(s: &str) -> Option { + scan_system_fonts(); + let f = db().write().unwrap().default_face()?; + let mut t = TextData::new(s); + (t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone()); + t.style.font_size = 20.0; + t.style.line_height = LineHeight::Pixels(30.0); + t.auto_resize = TextAutoResize::WidthAndHeight; + Some(t) +} + +/// The chars of each line. +fn line_texts(t: &TextData, l: &TextLayout) -> Vec { + let chars: Vec = t.characters.chars().collect(); + l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::()).collect() +} + +#[test] +fn breaks_the_line_but_not_the_paragraph() { + let Some(mut t) = text("one\u{2028}two\nthree") else { return }; + t.paragraph_spacing = 10.0; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["one\u{2028}", "two\n", "three"]); + assert!(!l.lines[0].para_end && l.lines[0].break_after); + assert!(l.lines[1].para_end && l.lines[1].break_after); + assert!(!l.lines[2].break_after); + // Paragraph spacing after the newline only: 30, then 30 + 10. + assert!((l.lines[1].top - 30.0).abs() < EPS && (l.lines[2].top - 70.0).abs() < EPS, "{} {}", l.lines[1].top, l.lines[2].top); + // The separator draws nothing. + assert!(l.glyphs.iter().all(|g| !(g.char_start..g.char_end).contains(&3))); +} + +#[test] +fn before_a_newline_or_at_the_end_it_leaves_an_empty_line() { + // "Hi,⏎¶Season" in #852: Figma shows an empty line between the two. + let Some(t) = text("Hi,\u{2028}\nSeason") else { return }; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["Hi,\u{2028}", "\n", "Season"]); + let Some(t) = text("end\u{2028}") else { return }; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["end\u{2028}", ""]); + assert!((l.height - 60.0).abs() < EPS, "{}", l.height); +} + +#[test] +fn a_list_item_continues_after_it_without_a_new_marker() { + let Some(mut t) = text("item\u{2028}more\nnext") else { return }; + t.list = ListStyle::Bulleted; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["item\u{2028}", "more\n", "next"]); + // Markers are the glyphs that cover no char: one per paragraph. + assert_eq!(l.glyphs.iter().filter(|g| g.char_start == g.char_end).count(), 2); + // The line after the separator lines up with the item's text, past the bullet. + let text_x = |li: usize| l.glyphs.iter().find(|g| g.char_start == l.lines[li].char_start && g.char_end > g.char_start).map(|g| g.x); + let (first, cont) = (text_x(0).unwrap(), text_x(1).unwrap()); + assert!(first > 0.0 && (first - cont).abs() < 1e-3, "{first} {cont}"); +} + +#[test] +fn carets_and_clicks_treat_it_like_a_newline() { + let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return }; + let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None)); + for i in 0..=5 { + assert_eq!(ls.caret(i), ln.caret(i), "caret before char {i}"); + } + for line in &ln.lines { + for x in [-10.0, 5.0, line.width + 50.0] { + assert_eq!(ls.hit(x, line.top + 5.0), ln.hit(x, line.top + 5.0), "click at {x} on the line at {}", line.top); + } + } + // A click right of the first line lands before the separator, not after it. + assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2); +} From 1b66fec3ece5123e21ba6680ff11d0e8182edebb Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:12:54 +0200 Subject: [PATCH 5/8] fix(text): the caret after a line break sits on the next line `caret` put the index right after a line break (a newline, or a line separator) at the end of the line above, so after Enter the caret showed on the previous line until something was typed, and Up/Down counted lines from there. The index before the break stays at the end of its line; the one after it is now on the next line, as after a wrap. Selection highlights already started such lines at their own start (#665); their comment no longer describes the old caret. --- crates/text/src/layout.rs | 13 ++++--------- crates/text/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f9139f6f..014bb1cd 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1171,16 +1171,12 @@ impl TextLayout { pub fn caret(&self, index: usize) -> (f64, usize) { for (li, line) in self.lines.iter().enumerate() { let last_line = li + 1 == self.lines.len(); + // A caret at a line's end belongs to the next line, whether the line wraps or ends with a + // line break (before the break it is still on this line: past its glyphs). let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end)); - // A caret at the end of a soft-wrapped line belongs to the next line. - if !(in_line || index == line.char_end && line.break_after && !last_line) { + if !in_line { continue; } - if index == line.char_end && line.break_after && !last_line { - // Before the line break: end of this line. - let x = self.line_end_x(line); - return (x, li); - } let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); for g in gl { if g.char_end == g.char_start { @@ -1303,8 +1299,7 @@ impl TextLayout { out.extend(Self::mixed_selection(gl, a, b, line)); continue; } - // From the line's own start: `caret(char_start)` after a hard break is the end of the - // line above. + // From the line's own start. let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 }; let x1 = if b >= line.char_end { // The last line has no paragraph break to show, so it ends at its glyphs. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f5eaab6b..ebf5889c 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -481,3 +481,19 @@ fn selection_after_a_line_break_starts_at_the_line_start() { // A break followed by text still doesn't highlight the next line's start. assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none()); } + +#[test] +fn the_caret_after_a_line_break_sits_on_the_next_line() { + if !have_fonts() { + return; + } + // Before the break: the end of its line; after it: the start of the next (after Enter, the + // caret shows on the new line, and Up/Down count from there). + let l = layout(&TextData::new("ab\ncd"), None, None); + assert_eq!(l.caret(2).1, 0); + assert!(l.caret(2).0 > 0.0); + assert_eq!(l.caret(3), (0.0, 1)); + // A trailing break: the caret sits on the empty last line. + let l = layout(&TextData::new("ab\n"), None, None); + assert_eq!(l.caret(3), (0.0, 1)); +} From b930ea5871ab6b7cc11a92c86a7ba787b8461609 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:13:12 +0200 Subject: [PATCH 6/8] test(text): the caret's line on either side of a line separator (#852) --- crates/text/src/line_separator_tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs index d5ad16b0..a7042483 100644 --- a/crates/text/src/line_separator_tests.rs +++ b/crates/text/src/line_separator_tests.rs @@ -77,6 +77,10 @@ fn carets_and_clicks_treat_it_like_a_newline() { assert_eq!(ls.hit(x, line.top + 5.0), ln.hit(x, line.top + 5.0), "click at {x} on the line at {}", line.top); } } + // Before the separator: the end of the first line; after it: the start of the second. + assert_eq!(ls.caret(2).1, 0); + assert!(ls.caret(2).0 > 0.0); + assert_eq!(ls.caret(3), (0.0, 1)); // A click right of the first line lands before the separator, not after it. assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2); } From 710c95e527e6b6fa35d28aac043c49472b7ebffc Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:52:09 +0200 Subject: [PATCH 7/8] test(text): a selection across a line separator shows the break (#852) --- crates/text/src/line_separator_tests.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs index a7042483..6891f97b 100644 --- a/crates/text/src/line_separator_tests.rs +++ b/crates/text/src/line_separator_tests.rs @@ -84,3 +84,14 @@ fn carets_and_clicks_treat_it_like_a_newline() { // A click right of the first line lands before the separator, not after it. assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2); } + +#[test] +fn a_selection_across_it_shows_the_break_like_a_newline() { + let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return }; + let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None)); + let (before, across) = (ls.selection_rects(0, 2), ls.selection_rects(0, 5)); + assert_eq!((before.len(), across.len()), (1, 2)); + // Reaching past the separator adds the 4 px break mark at the first line's end. + assert!((across[0].width() - before[0].width() - 4.0).abs() < EPS, "{} {}", across[0].width(), before[0].width()); + assert_eq!(across, ln.selection_rects(0, 5)); +} From f2ede63dd931502f81f1f72f7d92e5795ac2507f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:00:09 +0700 Subject: [PATCH 8/8] #867: comments describe the line separator without naming another product --- crates/text/src/layout.rs | 6 +++--- crates/text/src/line_separator_tests.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 014bb1cd..26ae3d33 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -160,8 +160,8 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { if v.is_finite() { v } else { auto } } -/// The line separator (U+2028): a line break inside a paragraph, as Figma's Shift+Enter stores it -/// (imported text keeps it; typed text has newlines only). +/// The line separator (U+2028): a line break inside a paragraph, as `.fig` files store a soft +/// break (imported text keeps it; typed text has newlines only). const LINE_SEPARATOR: char = '\u{2028}'; /// A char that ends its line: a newline (which also ends the paragraph) or a line separator. @@ -479,7 +479,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) para += 1; continue; } - // A line separator (Figma's Shift+Enter) breaks the line inside its paragraph: no + // A line separator (a soft break) breaks the line inside its paragraph: no // paragraph spacing and no new list marker after it. if it.ch == LINE_SEPARATOR { lines.push((line_start, i + 1, false, para)); diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs index 6891f97b..13f91786 100644 --- a/crates/text/src/line_separator_tests.rs +++ b/crates/text/src/line_separator_tests.rs @@ -1,4 +1,4 @@ -//! A line separator (U+2028: what Figma's Shift+Enter stores, and `.fig` imports keep) breaks the +//! A line separator (U+2028: a soft break in `.fig` files, kept on import) breaks the //! line inside its paragraph: no paragraph spacing or new list marker after it, and no glyph (#852). use super::*; @@ -41,7 +41,7 @@ fn breaks_the_line_but_not_the_paragraph() { #[test] fn before_a_newline_or_at_the_end_it_leaves_an_empty_line() { - // "Hi,⏎¶Season" in #852: Figma shows an empty line between the two. + // "Hi,⏎¶Season" in #852: an empty line between the two. let Some(t) = text("Hi,\u{2028}\nSeason") else { return }; let l = layout(&t, None, None); assert_eq!(line_texts(&t, &l), ["Hi,\u{2028}", "\n", "Season"]);