mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Merge pull request #3415 from lehni/fix/path-type-rtl-overflow
Type on a path: right-to-left text that overflows shows its start
This commit is contained in:
+94
-46
@@ -2278,28 +2278,36 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
cx.out.on_path = true;
|
||||
// Type on a path is one line: the first paragraph's attributes align it.
|
||||
let para = t.para_at(0);
|
||||
let mut sg = Vec::new();
|
||||
// The first paragraph's direction aligns the line (Auto) and sets the caret's.
|
||||
let mut rtl = None;
|
||||
let mut shaped = Vec::new();
|
||||
for pr in paras {
|
||||
let text = cx.text;
|
||||
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
|
||||
rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical);
|
||||
let shaped = cx.shape_para(pr.clone(), bidi.as_ref());
|
||||
for i in visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, pr.clone(), &shaped) {
|
||||
if let Some(g) = shaped.get(i) {
|
||||
sg.push(g.clone());
|
||||
}
|
||||
}
|
||||
let glyphs = cx.shape_para(pr.clone(), bidi.as_ref());
|
||||
shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs));
|
||||
}
|
||||
let rtl = rtl.unwrap_or(false);
|
||||
// The text before byte `cut` in visual order, as area type reorders each line it breaks.
|
||||
let visual = |cut: usize| {
|
||||
let mut sg = Vec::new();
|
||||
for (pr, bidi, glyphs) in &shaped {
|
||||
let n = glyphs.iter().take_while(|g| g.byte < cut).count();
|
||||
let line = glyphs.get(..n).unwrap_or_default();
|
||||
let end = glyphs.get(n).map_or(pr.end, |g| g.byte);
|
||||
sg.extend(visual_order(bidi.as_ref(), pr.start, pr.start..end, line).into_iter().filter_map(|i| line.get(i).cloned()));
|
||||
}
|
||||
sg
|
||||
};
|
||||
let text_len = cx.text.len();
|
||||
let mut sg = visual(text_len);
|
||||
// A path out of range holds no text, as a path with no length holds none.
|
||||
let ap = if in_range(&path.to_bezpath()) { ArcPath::new(path) } else { ArcPath::default() };
|
||||
let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| {
|
||||
let s = style_metrics(cx.db, cx.style_at(0));
|
||||
(s.0, s.1)
|
||||
});
|
||||
let text_len = cx.text.len();
|
||||
if ap.is_empty() {
|
||||
cx.out.overflow = !sg.is_empty();
|
||||
cx.out.lines.push(LineInfo {
|
||||
@@ -2324,8 +2332,6 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
let s_start = from * ap.len();
|
||||
let avail = (to - from) * ap.len();
|
||||
let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 };
|
||||
let mut steps = path_steps(&ap, &sg, s_start, spacing);
|
||||
let w: f64 = steps.iter().sum();
|
||||
// Optical Margin Alignment and Roman Hanging Punctuation: the brackets are the margins, and the
|
||||
// glyphs at the line's ends hang outside them by `hang_l` and `hang_r`, measured along the path.
|
||||
// The line is aligned in a span that much longer at each end, and the glyphs that hang at the
|
||||
@@ -2336,7 +2342,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
// The hangs are measured on the steps the glyphs take where the line is placed. A right-to-left
|
||||
// line is read from right to left ([`optical_hangs_rtl`]); in vertical type `sg` is in text order.
|
||||
let hanging = t.optical_margin || para.roman_hanging;
|
||||
let hangs = |steps: &[f64]| {
|
||||
let hangs = |sg: &[SGlyph], steps: &[f64]| {
|
||||
if !hanging {
|
||||
return (0.0, 0.0, 0);
|
||||
}
|
||||
@@ -2364,34 +2370,85 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
};
|
||||
(l, r, hung_r)
|
||||
};
|
||||
let place = |(l, r, _): (f64, f64, usize)| {
|
||||
let (lo, room) = (s_start - l, avail + l + r);
|
||||
match para.justify {
|
||||
Justify::Auto if rtl => lo + (room - w).max(0.0),
|
||||
Justify::Center | Justify::JustifyCenter => lo + ((room - w) * 0.5).max(0.0),
|
||||
Justify::Right | Justify::JustifyRight => lo + (room - w).max(0.0),
|
||||
_ => lo,
|
||||
}
|
||||
};
|
||||
let mut hung = hangs(&steps);
|
||||
let mut s0 = place(hung);
|
||||
if spacing != 0.0 && s0 != s_start {
|
||||
// Spaced from where the alignment puts them, the curve under them is another. That changes
|
||||
// the hanging glyphs' steps, so the line is placed again from the new steps until it stays
|
||||
// where it is (at most 8 times).
|
||||
steps = path_steps(&ap, &sg, s0, spacing);
|
||||
for _ in 0..8 {
|
||||
hung = hangs(&steps);
|
||||
let moved = place(hung);
|
||||
if (moved - s0).abs() < 1e-9 {
|
||||
break;
|
||||
// The glyphs `sg` arranged on the path: their steps, where the first starts, and the hangs.
|
||||
let arrange = |sg: &[SGlyph]| {
|
||||
let mut steps = path_steps(&ap, sg, s_start, spacing);
|
||||
let w: f64 = steps.iter().sum();
|
||||
let place = |(l, r, _): (f64, f64, usize)| {
|
||||
let (lo, room) = (s_start - l, avail + l + r);
|
||||
match para.justify {
|
||||
Justify::Auto if rtl => lo + (room - w).max(0.0),
|
||||
Justify::Center | Justify::JustifyCenter => lo + ((room - w) * 0.5).max(0.0),
|
||||
Justify::Right | Justify::JustifyRight => lo + (room - w).max(0.0),
|
||||
_ => lo,
|
||||
}
|
||||
};
|
||||
let mut hung = hangs(sg, &steps);
|
||||
let mut s0 = place(hung);
|
||||
if spacing != 0.0 && s0 != s_start {
|
||||
// Spaced from where the alignment puts them, the curve under them is another. That
|
||||
// changes the hanging glyphs' steps, so the line is placed again from the new steps
|
||||
// until it stays where it is (at most 8 times).
|
||||
steps = path_steps(&ap, sg, s0, spacing);
|
||||
for _ in 0..8 {
|
||||
hung = hangs(sg, &steps);
|
||||
let moved = place(hung);
|
||||
if (moved - s0).abs() < 1e-9 {
|
||||
break;
|
||||
}
|
||||
s0 = moved;
|
||||
steps = path_steps(&ap, sg, s0, spacing);
|
||||
}
|
||||
s0 = moved;
|
||||
steps = path_steps(&ap, &sg, s0, spacing);
|
||||
}
|
||||
(steps, s0, hung)
|
||||
};
|
||||
// How many of the arranged glyphs fit before one runs past the end bracket (with the hang).
|
||||
let fitting = |steps: &[f64], s0: f64, (hang_l, hang_r, hung_r): (f64, f64, usize)| {
|
||||
let (lo, room) = (s_start - hang_l, avail + hang_l + hang_r);
|
||||
let mut x = 0.0;
|
||||
steps
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take_while(|&(k, &step)| {
|
||||
let end = if k + hung_r >= steps.len() { room } else { room - hang_r };
|
||||
let fit = s0 + x + step - lo <= end + 1e-6;
|
||||
x += step;
|
||||
fit
|
||||
})
|
||||
.count()
|
||||
};
|
||||
let (mut steps, mut s0, mut hung) = arrange(&sg);
|
||||
// Where the text that fits ends: before the first glyph that doesn't, which threaded text
|
||||
// flows on from, as from the last line of area type.
|
||||
let mut fits = text_len;
|
||||
// Right-to-left text that overflows keeps the start of its text, not what comes first in visual
|
||||
// order: the longest start, in reading order, whose glyphs fit in visual order is laid out.
|
||||
if shaped.iter().any(|(_, bidi, _)| bidi.is_some()) && fitting(&steps, s0, hung) < sg.len() {
|
||||
let mut starts: Vec<usize> = shaped.iter().flat_map(|(_, _, glyphs)| glyphs.iter().map(|g| g.byte)).collect();
|
||||
starts.dedup();
|
||||
// The text before `starts[fit]` fits; before `starts[over]` (the whole text at the end) it
|
||||
// doesn't.
|
||||
let (mut fit, mut over) = (0, starts.len());
|
||||
while over - fit > 1 {
|
||||
let mid = (fit + over) / 2;
|
||||
let sg = visual(starts.get(mid).copied().unwrap_or(text_len));
|
||||
let (steps, s0, hung) = arrange(&sg);
|
||||
if fitting(&steps, s0, hung) == sg.len() {
|
||||
fit = mid;
|
||||
} else {
|
||||
over = mid;
|
||||
}
|
||||
}
|
||||
cx.out.overflow = true;
|
||||
fits = starts.get(fit).copied().unwrap_or(text_len);
|
||||
sg = visual(fits);
|
||||
(steps, s0, hung) = arrange(&sg);
|
||||
}
|
||||
let fit = fitting(&steps, s0, hung);
|
||||
if fit < sg.len() {
|
||||
cx.out.overflow = true;
|
||||
fits = sg.get(fit..).and_then(|rest| rest.iter().map(|g| g.byte).min()).unwrap_or(text_len);
|
||||
}
|
||||
let (hang_l, hang_r, hung_r) = hung;
|
||||
let (lo, room) = (s_start - hang_l, avail + hang_l + hang_r);
|
||||
// Align to Path: how far down (glyph space) the type moves to run its ascender, centre or
|
||||
// descender along the path instead of its baseline.
|
||||
let rise = match t.path_align {
|
||||
@@ -2401,17 +2458,8 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
PathAlign::Center => (m.0 - m.1) * 0.5,
|
||||
};
|
||||
let mut x = 0.0;
|
||||
// Where the text that fits ends: before the first glyph that doesn't, which threaded text
|
||||
// flows on from, as from the last line of area type.
|
||||
let mut fits = text_len;
|
||||
for (k, (g, &step)) in sg.iter().zip(&steps).enumerate() {
|
||||
for (g, &step) in sg.iter().zip(&steps).take(fit) {
|
||||
let s = s0 + x;
|
||||
let end = if k + hung_r >= sg.len() { room } else { room - hang_r };
|
||||
if s + step - lo > end + 1e-6 {
|
||||
cx.out.overflow = true;
|
||||
fits = sg.get(k..).and_then(|rest| rest.iter().map(|g| g.byte).min()).unwrap_or(text_len);
|
||||
break;
|
||||
}
|
||||
let (p, dir) = ap.at(s + step * 0.5);
|
||||
let angle = dir.y.atan2(dir.x);
|
||||
let half = Affine::translate((-g.adv * 0.5, rise));
|
||||
|
||||
@@ -551,3 +551,63 @@ fn digits_draw_every_digit_in_the_styles_numeral_system() {
|
||||
// Default leaves each digit as typed.
|
||||
assert_ne!(gids(&lay("3 ٣ ۳", Digits::Default)), gids(&lay("3 ٣ ۳", Digits::Hindi)));
|
||||
}
|
||||
|
||||
/// Right-to-left type on a path that overflows shows the start of its text, as a line of area
|
||||
/// type does, not whatever comes first in visual order.
|
||||
#[test]
|
||||
fn overflowing_rtl_type_on_a_path_keeps_the_start_of_its_text() {
|
||||
let s = "שלום עולם ".repeat(8);
|
||||
let mut t = TextObject::point(Point::ZERO, &s, CharStyle::default());
|
||||
let line = kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1);
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
let l = layout(FontDb::global(), &t);
|
||||
assert!(l.overflow && !l.glyphs.is_empty());
|
||||
let shown: Vec<_> = source_clusters(&l).into_iter().map(|(byte, _)| byte).collect();
|
||||
let last = shown.last().copied().unwrap_or(0);
|
||||
let start: Vec<_> = s.char_indices().map(|(i, _)| i).take_while(|i| *i <= last).collect();
|
||||
assert_eq!(shown, start, "the glyphs shown are the text's start");
|
||||
let x = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x).unwrap();
|
||||
assert!(x(0) > x(last), "the text runs right to left along the path");
|
||||
}
|
||||
|
||||
/// On a curve with spacing, where a glyph's step depends on where it sits, what fits is measured
|
||||
/// where the glyphs are placed: the start of the text shows, and every glyph of it.
|
||||
#[test]
|
||||
fn overflowing_rtl_type_on_a_spaced_curve_keeps_the_start_of_its_text() {
|
||||
let s = "שלום, עולם! אבג ".repeat(6);
|
||||
// Straight, then tightly curved: glyphs take other steps there.
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((60.0, 0.0));
|
||||
path.extend(kurbo::Arc::new((60.0, 12.0), (12.0, 12.0), -std::f64::consts::FRAC_PI_2, std::f64::consts::PI, 0.0).append_iter(0.1));
|
||||
let mut t = TextObject::point(Point::ZERO, &s, CharStyle::default());
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||||
for spacing in (-12..=12).map(|i| f64::from(i) * 5.0) {
|
||||
t.path_spacing = spacing;
|
||||
let l = layout(FontDb::global(), &t);
|
||||
assert!(l.overflow && !l.glyphs.is_empty());
|
||||
let shown: Vec<_> = source_clusters(&l).into_iter().map(|(byte, _)| byte).collect();
|
||||
let last = shown.last().copied().unwrap_or(0);
|
||||
let start: Vec<_> = s.char_indices().map(|(i, _)| i).take_while(|i| *i <= last).collect();
|
||||
assert_eq!(shown, start, "the glyphs shown are the text's start ({spacing})");
|
||||
}
|
||||
}
|
||||
|
||||
/// A left-to-right line with right-to-left text in it fits as it is laid out: a period that
|
||||
/// hangs past the end bracket (Roman Hanging Punctuation) stays, and nothing overflows.
|
||||
#[test]
|
||||
fn mixed_type_on_a_path_hangs_its_final_period() {
|
||||
let s = "bא.";
|
||||
let line = kurbo::Line::new((0.0, 0.0), (400.0, 0.0)).to_path(0.1);
|
||||
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
t.para.roman_hanging = true;
|
||||
let advances: Vec<f64> = layout(FontDb::global(), &t).glyphs.iter().map(|g| g.advance).collect();
|
||||
assert_eq!(advances.len(), 3);
|
||||
// The end bracket halfway through the period, with path beyond it.
|
||||
let end = advances[0] + advances[1] + advances[2] * 0.5;
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: Some(end / 400.0) };
|
||||
let l = layout(FontDb::global(), &t);
|
||||
assert!(l.glyphs.iter().any(|g| g.byte == s.len() - 1), "the period shows");
|
||||
assert!(!l.overflow, "all of it fits");
|
||||
}
|
||||
|
||||
@@ -178,6 +178,23 @@ mod tests {
|
||||
assert!(at_space > 0, "some lengths end at a space");
|
||||
}
|
||||
|
||||
/// A right-to-left story flows on from where its text stops fitting a path, in reading order.
|
||||
#[test]
|
||||
fn right_to_left_story_flows_on_from_a_path() {
|
||||
let db = FontDb::global();
|
||||
let text = "שלום עולם ".repeat(8);
|
||||
let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }];
|
||||
let (a, b) = (path_frame(100.0), frame(400.0));
|
||||
let parts: Vec<Vec<TextRun>> = distribute(db, &[&a, &b], &story, &[]).into_iter().map(|s| s.runs).collect();
|
||||
let lens: Vec<usize> = parts.iter().map(|p| runs_len(p)).collect();
|
||||
assert!(lens.iter().all(|l| *l > 0), "each frame takes a share: {lens:?}");
|
||||
let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect();
|
||||
assert_eq!(joined, text, "no character lost or duplicated");
|
||||
let mut on_path = a.clone();
|
||||
on_path.runs = parts[0].clone();
|
||||
assert!(!layout(db, &on_path).overflow, "the path's share fits it");
|
||||
}
|
||||
|
||||
/// A path with no length holds no text, so the story flows past it.
|
||||
#[test]
|
||||
fn story_flows_past_a_path_with_no_length() {
|
||||
|
||||
Reference in New Issue
Block a user