mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
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.
205 lines
9.6 KiB
Rust
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());
|
|
}
|
|
}
|