mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
Merge pull request #867 from AganzeFelicite/fix/fig-paragraph-returns-852
fix(text): a line separator breaks the line inside its paragraph (#852)
This commit is contained in:
+47
-23
@@ -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,
|
||||
}
|
||||
@@ -157,6 +160,15 @@ 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 `.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.
|
||||
fn is_line_break(c: char) -> bool {
|
||||
c == '\n' || c == LINE_SEPARATOR
|
||||
}
|
||||
|
||||
/// 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 +344,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
}
|
||||
// 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 +382,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
{
|
||||
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 +394,15 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
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 +415,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
char_start: seg_start,
|
||||
char_end: seg_start + 1,
|
||||
style: st_idx,
|
||||
ch: '\n',
|
||||
ch: first,
|
||||
level,
|
||||
});
|
||||
} else {
|
||||
@@ -465,6 +479,17 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
para += 1;
|
||||
continue;
|
||||
}
|
||||
// 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));
|
||||
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.
|
||||
@@ -491,7 +516,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) {
|
||||
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 || break_after(l.1)) {
|
||||
lines.push((line_start, items.len(), true, para));
|
||||
}
|
||||
|
||||
@@ -546,7 +572,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
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.
|
||||
@@ -563,7 +589,9 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
// 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.
|
||||
@@ -574,7 +602,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
.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 +677,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
(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;
|
||||
@@ -687,6 +715,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
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;
|
||||
@@ -1142,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.para_end && !last_line) {
|
||||
if !in_line {
|
||||
continue;
|
||||
}
|
||||
if index == line.char_end && line.para_end && !last_line {
|
||||
// Before the newline: 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 {
|
||||
@@ -1179,8 +1204,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))
|
||||
@@ -1247,7 +1272,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 }
|
||||
}
|
||||
@@ -1274,12 +1299,11 @@ 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.
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 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::*;
|
||||
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<TextData> {
|
||||
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<String> {
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::<String>()).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: 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);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user