mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Type on a path: right-to-left text that overflows shows its start
Its glyphs were put in visual order and then cut where the path ran out, which kept the text's end. Now the longest start of the text, in reading order, whose glyphs fit in visual order is laid out, as area type does with each line it breaks, by the same rules that place the line: steps where the glyphs sit, hanging punctuation and the path beyond the brackets.
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