Files
vectorcraft/crates/text/src/tests_path_joins.rs
T
ham9zah e56aa356b5 Type on a path: joined scripts bend along the path so their joins stay connected
On a curved path each glyph was placed rigidly (turned to the tangent at
its middle), so its baseline ends stood off the curve and neighbours
turned by different angles: in scripts whose letters join, every join
opened into a notch. Measured with Arial on circles: at 29 pt on a
radius of 110 pt, 0.64 pt at the baseline and 1.24 pt at the height of
the joining stroke (a quarter of the size); 14.7 pt at 72 pt on a radius
of 60. Placing glyphs on the chord alone closes the baseline but leaves
about h (a_i + a_{i+1}) / 2R above it (0.71 pt here), so joined glyphs
are bent instead.

- Shaping marks each glyph of a segment whose script joins (Arabic,
  Syriac, N'Ko, Mongolian, Adlam and the other cursive scripts), by the
  segment's script, so a word bends as a whole.
- With the Rainbow effect such a glyph's outline is mapped point by
  point: pen space (x, y) -> P(s + x step/advance) + (y + rise) N there,
  each segment cut into pieces short enough for 0.01 pt (capped). A
  glyph the path doesn't turn under stays exactly as it was; Skew, 3D
  Ribbon, Stair Step and Gravity, vertical type, inline art and colour
  glyphs are unchanged. `PositionedGlyph::bend` records it; `xf` stays
  the straight glyph for the caret, hit testing and tools.
- PDF export draws bent glyphs as outlines and writes them again as
  invisible text where they stand, so they can be selected, searched
  and copied. SVG export outlines them (right-to-left type already was),
  never as a <textPath>, and labels the group with its text
  (role="img", aria-label) for screen readers and search.

Tests (crates/text/src/tests_path_joins.rs; installed Arabic font, skip
without one): the gap at the baseline and at join height is <= 0.01 pt
at 12, 29 and 72 pt on radii 60, 110 and 300, on both sides of the
path, with finite outlines; Latin on a curve and joined type on a
straight path are byte-for-byte what they were; the chord-only
prediction is checked.

vectorcraft-cli perf (load average 43 before, 34 after: not trustworthy;
the synthetic document has no type on a path, so bending doesn't run):
render fit 113.8 -> 132.7 ms, pan 115.5 -> 140.0 ms, 400% 32.9 -> 43.3
ms, hit test 1.36 -> 1.59 ms, save 81.5 -> 88.2 ms, open 306 -> 401 ms,
SVG 426 -> 439 ms, Pathfinder 93.7 -> 85.5 ms, handle drag 5.5 -> 6.7
ms, segment drag 5.1 -> 6.7 ms.
2026-10-26 09:09:47 +07:00

205 lines
9.6 KiB
Rust

//! Joined (cursive) type on a curved path: neighbouring glyphs must meet where they join. The gap
//! between one glyph's edge and the next one's, at the baseline and at the height of the joining
//! stroke, is the measure. Fonts are installed ones (none is kept in the repository): the tests
//! that need an Arabic font say so and stop without one.
use super::*;
use kurbo::{Affine, BezPath, Point, Shape};
use std::sync::Arc;
use vectorcraft_doc::{CharStyle, TextKind};
fn arabic_face() -> Option<Arc<FontFace>> {
let db = FontDb::global();
["Noto Sans Arabic", "Arial", "Geeza Pro"]
.into_iter()
.filter_map(|name| db.face(name, "Regular"))
.find(|f| ['س', 'ل', 'ة', 'م', 'ت', 'ص'].iter().all(|c| f.covers(*c)))
}
/// A circle of radius `r` round the origin, one closed path.
fn circle(r: f64) -> vectorcraft_geom::PathData {
vectorcraft_geom::PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), r).to_path(1e-3))
}
/// Type `s` in `style` on a circle of radius `r`, the whole loop for it.
fn on_circle(s: &str, style: CharStyle, r: f64) -> TextLayout {
let mut t = TextObject::point(Point::ZERO, s, style);
t.kind = TextKind::OnPath { path: circle(r), start: 0.25, end: None };
layout(FontDb::global(), &t)
}
/// For each pair of neighbouring glyphs on the line (in visual order, inside a word), how far
/// apart the right edge of the left one and the left edge of the right one are, at the baseline
/// and `h` above it.
fn gaps(l: &TextLayout, text: &str, h: f64) -> Vec<(f64, f64)> {
// Visual order: the glyphs as placed, sorted along the line by where their left edge is on it.
let edges: Vec<(Point, Point, Point, Point, usize)> = l
.glyphs
.iter()
.filter(|g| text.get(g.byte..g.byte + g.len).is_some_and(|c| !c.trim().is_empty()))
.map(|g| {
let [a, b, ..] = g.xf.as_coeffs();
let sx = a.hypot(b);
let w = g.advance / sx;
let up = h / sx;
(g.xf * Point::new(0.0, 0.0), g.xf * Point::new(w, 0.0), g.xf * Point::new(0.0, -up), g.xf * Point::new(w, -up), g.byte)
})
.collect();
// Which word a byte is in: the spaces before it.
let word = |b: usize| text.get(..b).map_or(0, |t| t.chars().filter(|c| c.is_whitespace()).count());
edges.windows(2).filter(|w| word(w[0].4) == word(w[1].4)).map(|w| ((w[0].1 - w[1].0).hypot(), (w[0].3 - w[1].2).hypot())).collect()
}
/// The largest gaps (baseline, joining height) of type `s` on a circle of radius `r` at `size`.
fn worst(face: &FontFace, s: &str, size: f64, r: f64) -> (f64, f64) {
let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size, ..CharStyle::default() };
let l = on_circle(s, style, r);
gaps(&l, s, size * 0.25).into_iter().fold((0.0, 0.0), |(a, b), (x, y)| (f64::max(a, x), f64::max(b, y)))
}
/// The measure, printed: joined Arabic and separate Latin on circles at three sizes.
#[test]
fn joins_on_a_circle_are_measured() {
let Some(face) = arabic_face() else {
eprintln!("SKIPPED: joins_on_a_circle_are_measured; no installed font covers Arabic");
return;
};
eprintln!("ASSERTIONS RUN: joins_on_a_circle_are_measured; Arabic font {} {}", face.family, face.style);
for size in [12.0, 29.0, 72.0] {
for r in [60.0, 110.0, 300.0] {
let (b, j) = worst(&face, "سلسلة متصلة", size, r);
let (lb, lj) = worst(&face, "minimum", size, r);
eprintln!("size {size:>4} r {r:>5}: arabic baseline {b:.4} join {j:.4} | latin baseline {lb:.4} at {lj:.4}");
}
}
let _ = BezPath::new();
let _ = Affine::IDENTITY;
}
/// Chord placement, worked out here only (not in the layout): each glyph's baseline ends on the
/// circle, the glyph turned to the chord between them. Neighbours then meet at the baseline, and `h`
/// above it they stand apart by `h` times the angle between their chords, (a_i + a_{i+1}) / 2R.
#[test]
fn chord_placement_leaves_the_join_gap_above_the_baseline() {
let Some(face) = arabic_face() else {
eprintln!("SKIPPED: chord_placement_leaves_the_join_gap_above_the_baseline; no installed font covers Arabic");
return;
};
let (size, r) = (29.0, 110.0);
let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size, ..CharStyle::default() };
let s = "سلسلة";
let l = on_circle(s, style, r);
let h = size * 0.25;
let advances: Vec<f64> = l.glyphs.iter().map(|g| g.advance).filter(|a| *a > 1e-6).collect();
let mut worst: f64 = 0.0;
for w in advances.windows(2) {
let (a, b) = (w[0], w[1]);
// The chords' directions differ by the angle between their middles on the circle.
let turn = (a + b) / (2.0 * r);
let gap = 2.0 * h * (turn * 0.5).sin();
let predicted = h * (a + b) / (2.0 * r);
assert!((gap - predicted).abs() < 0.01, "{gap} vs {predicted}");
worst = worst.max(gap);
}
eprintln!("chord placement at {size} pt on radius {r}: baseline gap 0, join-height gap {worst:.3}");
assert!(worst > 0.5, "chord placement alone leaves a visible gap: {worst}");
}
/// A circle of radius `r` run the other way (clockwise): type on it stands on the inside, its
/// tops towards the centre, as Flip puts it.
fn circle_reversed(r: f64) -> vectorcraft_geom::PathData {
let mut bp = kurbo::Circle::new((0.0, 0.0), r).to_path(1e-3);
bp = bp.reverse_subpaths();
vectorcraft_geom::PathData::from_bezpath(&bp)
}
fn on(path: vectorcraft_geom::PathData, s: &str, style: CharStyle) -> TextLayout {
let mut t = TextObject::point(Point::ZERO, s, style);
t.kind = TextKind::OnPath { path, start: 0.25, end: None };
layout(FontDb::global(), &t)
}
/// The largest gap between neighbouring bent glyphs of a word at the baseline and `h` above it,
/// where their bends put their edges; and how many glyphs were bent.
fn bent_gaps(l: &TextLayout, text: &str, path: &vectorcraft_geom::PathData, h: f64) -> (f64, f64, usize) {
let ap = vectorcraft_geom::ArcPath::new(path);
let at = |b: &GlyphBend, x: f64, y: f64| {
let (p, d) = ap.at(b.s + x * b.step / b.advance);
p + kurbo::Vec2::new(-d.y, d.x) * (y + b.rise)
};
let word = |byte: usize| text.get(..byte).map_or(0, |t| t.chars().filter(|c| c.is_whitespace()).count());
let bent: Vec<(&GlyphBend, usize)> = l.glyphs.iter().filter_map(|g| g.bend.as_ref().map(|b| (b, g.byte))).collect();
let (mut base, mut join) = (0.0f64, 0.0f64);
for w in bent.windows(2) {
let ((a, ab), (b, bb)) = (w[0], w[1]);
if word(ab) != word(bb) {
continue;
}
base = base.max((at(a, a.advance, 0.0) - at(b, 0.0, 0.0)).hypot());
join = join.max((at(a, a.advance, -h) - at(b, 0.0, -h)).hypot());
}
(base, join, bent.len())
}
/// Joined Arabic on circles of three radii at three sizes, on both sides of the path: neighbouring
/// glyphs meet, at the baseline and at the height of the joining stroke.
#[test]
fn joined_glyphs_meet_on_a_curved_path() {
let Some(face) = arabic_face() else {
eprintln!("SKIPPED: joined_glyphs_meet_on_a_curved_path; no installed font covers Arabic");
return;
};
eprintln!("ASSERTIONS RUN: joined_glyphs_meet_on_a_curved_path; Arabic font {} {}", face.family, face.style);
let s = "سلسلة متصلة";
for size in [12.0, 29.0, 72.0] {
let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size, ..CharStyle::default() };
for r in [60.0, 110.0, 300.0] {
for path in [circle(r), circle_reversed(r)] {
let l = on(path.clone(), s, style.clone());
let (base, join, n) = bent_gaps(&l, s, &path, size * 0.25);
assert!(n >= 8, "the Arabic glyphs are bent ({n})");
assert!(base <= 0.01 && join <= 0.01, "size {size} r {r}: baseline {base}, join {join}");
for g in &l.glyphs {
let finite = g.outline.elements().iter().all(|e| e.end_point().is_none_or(|p| p.x.is_finite() && p.y.is_finite()));
assert!(finite, "a bent outline that isn't finite");
}
}
}
}
}
/// Latin type on the same path is placed as before: straight glyphs, not bent, each outline its
/// font's glyph through `xf`.
#[test]
fn latin_on_a_curved_path_stays_straight() {
let db = FontDb::global();
let style = CharStyle { size: 29.0, ..CharStyle::default() };
let l = on(circle(110.0), "minimum", style);
assert!(!l.glyphs.is_empty());
for g in &l.glyphs {
assert!(g.bend.is_none());
let face = db.face_by_id(g.font_id).unwrap();
let straight = g.xf * (*db.outline(&face, g.gid)).clone();
assert_eq!(g.outline, straight, "glyph {} drawn through its xf", g.gid);
}
}
/// On a straight path nothing turns: joined type is laid out exactly as before, straight.
#[test]
fn joined_type_on_a_straight_path_is_unchanged() {
let Some(face) = arabic_face() else {
eprintln!("SKIPPED: joined_type_on_a_straight_path_is_unchanged; no installed font covers Arabic");
return;
};
let db = FontDb::global();
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((400.0, 0.0));
let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 29.0, ..CharStyle::default() };
let l = on(vectorcraft_geom::PathData::from_bezpath(&line), "سلسلة متصلة", style);
for g in &l.glyphs {
assert!(g.bend.is_none());
let face = db.face_by_id(g.font_id).unwrap();
assert_eq!(g.outline, g.xf * (*db.outline(&face, g.gid)).clone());
}
}