diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 4138a5014..a72d3cc93 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1661,7 +1661,7 @@ impl Exporter<'_> { // The outlines left to fill as paths: all of them, or those real text leaves. let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill, true)) { Some(text) => { - let (mut left, unwritten) = ex.glyph_text(s, &layout, i, text, fill, true); + let (mut left, unwritten) = ex.glyph_text(s, &layout, i, text, fill, true, Some(false)); images = Some(unwritten); left.extend(run_bars.iter()); Cow::Owned(left) @@ -1701,7 +1701,17 @@ impl Exporter<'_> { s.push_transform(&text_space); // The paint only lets the writer write the text; the glyphs it can't write // as text draw nothing, and colour bitmap glyphs are drawn already. - self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false); + self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false, None); + s.pop(); + }); + } else if let Some(text) = plain.as_deref().filter(|_| layout.glyphs.iter().any(|g| g.run == i && g.bend.is_some())) { + // Glyphs bent along a path were drawn as their outlines (text is set straight): + // they are written again as invisible text, straight where they stand, so they can + // still be selected, searched and copied. + self.invisible = true; + crate::forms::form(s, Mark::Invisible, |s| { + s.push_transform(&text_space); + self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false, Some(true)); s.pop(); }); } @@ -1771,7 +1781,21 @@ impl Exporter<'_> { /// indices of those it can't write or draw (SVG glyphs) are returned with the outlines. The /// hyphen the layout adds where it breaks a line inside a word ([`break_hyphen`]) is written /// as the character its glyph stands for, on its own after the glyphs before it. - fn glyph_text(&mut self, s: &mut Surface, layout: &TextLayout, run: usize, text: &str, fill: &Paint, images: bool) -> (BezPath, Vec) { + /// + /// `bent`: `Some(false)` writes the glyphs bent along a path ([`vectorcraft_text::GlyphBend`]) + /// as outlines, not text (text is set straight); `Some(true)` writes only those, as text where + /// they stand straight; `None` writes all of them as text. + #[allow(clippy::too_many_arguments)] + fn glyph_text( + &mut self, + s: &mut Surface, + layout: &TextLayout, + run: usize, + text: &str, + fill: &Paint, + images: bool, + bent: Option, + ) -> (BezPath, Vec) { let (mut rest, mut unwritten) = (BezPath::new(), vec![]); let mut line: Option = None; // The characters the glyphs stand for: the plain text, then, when the run has glyphs that @@ -1783,6 +1807,14 @@ impl Exporter<'_> { if coloured && !images { continue; } + match (bent, g.bend.is_some()) { + (Some(false), true) => { + rest.extend(g.outline.iter()); + continue; + } + (Some(true), false) => continue, + _ => {} + } // A list marker's glyphs stand for no character of the text either; they are not // line-break hyphens, whatever the marker. let hyphen = if layout.marker_of(gi).is_some() { None } else { break_hyphen(g) }; diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index b57ed8821..c393f98f3 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1939,7 +1939,14 @@ impl Writer<'_> { } let id = self.id_attr(n); let a = self.attrs(&css::transparency(n)); - self.line(&format!("")); + // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group + // names its text for screen readers and search. + let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { + format!(" role=\"img\" aria-label=\"{}\"", xml_escape(&t.plain_text())) + } else { + String::new() + }; + self.line(&format!("")); self.depth += 1; // The glyphs stay in text space; the text's transform joins the document's meanwhile. let saved = self.xf; @@ -1992,6 +1999,12 @@ impl Writer<'_> { return self.text_outlines(n, t); } if let TextKind::OnPath { path, start, end } = &t.kind { + // A reader lays a ``'s glyphs straight: joined glyphs bent along the path + // keep their look as outlines. + if lay.glyphs.iter().any(|g| g.bend.is_some()) { + self.warn("joined type bent along a path is outlined to keep its look"); + return self.text_outlines(n, t); + } // `` has a start offset only: an end bracket, Align to Path and Spacing // keep their look as outlines. if end.is_some() || t.path_align != vectorcraft_doc::PathAlign::Baseline || t.path_spacing != 0.0 { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 4cf205012..2405ad55f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -2,7 +2,7 @@ use std::ops::Range; -use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2}; +use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{ @@ -11,6 +11,7 @@ use vectorcraft_doc::{ }; use vectorcraft_geom::{ArcPath, PathData}; +use crate::GlyphBend; use crate::composer::{Breakpoint, Params, compose}; use crate::fontdb::FontDb; use crate::hyphen::hyphen_points_with; @@ -253,6 +254,7 @@ impl Ctx<'_> { scale: 1.0, image, colour, + bend: None, }); } @@ -1728,6 +1730,7 @@ mod wrapping_tests { vpal: None, level: unicode_bidi::Level::ltr(), no_break: false, + cursive: false, }) .collect() } @@ -2541,6 +2544,107 @@ fn path_steps(ap: &ArcPath, glyphs: &[SGlyph], from: f64, spacing: f64) -> Vec Option { + let GlyphBend { s, step, advance, rise } = *bend; + if outline.elements().is_empty() || !(advance > 1e-9 && step.is_finite() && step > 0.0 && s.is_finite() && rise.is_finite()) { + return None; + } + // How far the path turns under the glyph; none: it lies straight already. + let ((_, a), (_, b)) = (ap.at(s), ap.at(s + step)); + let turn = a.cross(b).atan2(a.dot(b)).abs(); + if !(turn.is_finite() && turn > 1e-9) { + return None; + } + let det = pre.determinant(); + if !(det.is_finite() && det.abs() > 1e-12) { + return None; + } + let to_pen = pre.inverse(); + let k = step / advance; + let map = |q: Point| { + let (p, d) = ap.at(s + q.x * k); + p + Vec2::new(-d.y, d.x) * (q.y + rise) + }; + // Pieces short enough that mapping a piece's control points stays within the tolerance: the + // error of a piece of length L under curvature c, at height y, is about L² c (1 + |y| c) / 8. + let curvature = turn / step; + let pen = to_pen * outline.clone(); + let height = pen.bounding_box().y0.abs().max(pen.bounding_box().y1.abs()) + rise.abs(); + let piece = ((8.0 * BEND_TOLERANCE) / (curvature * (1.0 + height * curvature))).sqrt(); + let pieces = |a: Point, b: Point| -> usize { + let n = ((b - a).hypot() / piece).ceil(); + if n.is_finite() { (n as usize).clamp(1, BEND_PIECES) } else { BEND_PIECES } + }; + let mut out = BezPath::new(); + let (mut start, mut cur) = (Point::ZERO, Point::ZERO); + let line = |out: &mut BezPath, from: Point, to: Point| { + let n = pieces(from, to); + for i in 1..=n { + out.line_to(map(from.lerp(to, i as f64 / n as f64))); + } + }; + for el in pen.elements() { + match *el { + PathEl::MoveTo(p) => { + out.move_to(map(p)); + (start, cur) = (p, p); + } + PathEl::LineTo(p) => { + line(&mut out, cur, p); + cur = p; + } + PathEl::QuadTo(p1, p2) => { + let c = kurbo::QuadBez::new(cur, p1, p2).raise(); + let n = pieces(cur, p1).max(pieces(p1, p2)); + for i in 0..n { + let q = c.subsegment(i as f64 / n as f64..(i + 1) as f64 / n as f64); + out.curve_to(map(q.p1), map(q.p2), map(q.p3)); + } + cur = p2; + } + PathEl::CurveTo(p1, p2, p3) => { + let c = kurbo::CubicBez::new(cur, p1, p2, p3); + let n = pieces(cur, p1).max(pieces(p1, p2)).max(pieces(p2, p3)); + for i in 0..n { + let q = c.subsegment(i as f64 / n as f64..(i + 1) as f64 / n as f64); + out.curve_to(map(q.p1), map(q.p2), map(q.p3)); + } + cur = p3; + } + PathEl::ClosePath => { + if (cur - start).hypot() > 1e-12 { + line(&mut out, cur, start); + } + out.close_path(); + cur = start; + } + } + if out.elements().len() > BEND_MAX_ELEMENTS { + return None; + } + } + let finite = |p: Point| p.x.is_finite() && p.y.is_finite(); + out.elements() + .iter() + .all(|el| match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => finite(p), + PathEl::QuadTo(a, b) => finite(a) && finite(b), + PathEl::CurveTo(a, b, c) => finite(a) && finite(b) && finite(c), + PathEl::ClosePath => true, + }) + .then_some(out) +} + /// Lay type on a path out along `path` (text space), between its brackets. fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &PathData) { cx.out.on_path = true; @@ -2786,6 +2890,19 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path } } cx.emit(g, pre, origin, angle, g.adv, 0); + // A glyph of a script whose letters join is bent along a curved path (Rainbow), so its + // joining strokes meet its neighbours' instead of standing off the curve. + if t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() { + let bend = GlyphBend { s, step, advance: g.adv, rise }; + if let Some(last) = cx.out.glyphs.last_mut() + && last.image.is_none() + && last.colour.is_none() + && let Some(bent) = bend_outline(&last.outline, pre, &ap, &bend) + { + last.outline = bent; + last.bend = Some(bend); + } + } x += step; } let (ps, _) = ap.at(s0); @@ -3212,5 +3329,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi // Ruby draws outlines only. image: None, colour: None, + bend: None, } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index ec81de500..6c913280a 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -132,6 +132,25 @@ pub struct PositionedGlyph { /// A colour glyph's art (COLR layers, an SVG document's art), drawn through `xf` instead of an /// outline: a group in font units (y down). Its `outline` is empty. pub colour: Option>, + /// Type on a curved path in a script whose letters join: the glyph's `outline` is bent along + /// the path ([`GlyphBend`]) so it meets its neighbours, and `xf` is only the straight glyph + /// nearest it (for the caret, hit testing and tools). `None`: the glyph is drawn through `xf`. + pub bend: Option, +} + +/// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its +/// left end, y down from the baseline) goes to `P(s + x · step / advance) + (y + rise) · N` there, +/// `P` the path's point at a distance along it and `N` its normal on the glyph's lower side. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GlyphBend { + /// Where the glyph's left end is along the path. + pub s: f64, + /// How far along the path the glyph runs. + pub step: f64, + /// The glyph's advance. + pub advance: f64, + /// How far down the type moves off the path (Align to Path). + pub rise: f64, } impl PositionedGlyph { @@ -704,6 +723,8 @@ mod tests_joiners; #[cfg(test)] mod tests_justify; #[cfg(test)] +mod tests_path_joins; +#[cfg(test)] mod tests_ruby; #[cfg(test)] mod tests_scripts; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 45ad8ee24..e833d95db 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -65,6 +65,28 @@ pub(crate) struct SGlyph { /// No Break (Character panel menu): keep this glyph on the same line as the neighbouring /// glyphs that have it. pub no_break: bool, + /// The glyph is of a script whose letters join (Arabic, Syriac, N'Ko, Mongolian, Adlam…), + /// decided for the whole segment it was shaped in ([`joins`]): on a curved path it is bent + /// along the path so its joins meet. + pub cursive: bool, +} + +/// Does `script` join its letters (a cursive script, whose neighbouring glyphs touch)? +pub(crate) fn joins(script: Script) -> bool { + matches!( + script, + Script::Arabic + | Script::Syriac + | Script::Nko + | Script::Mongolian + | Script::Adlam + | Script::Mandaic + | Script::Manichaean + | Script::Psalter_Pahlavi + | Script::Hanifi_Rohingya + | Script::Sogdian + | Script::Phags_Pa + ) } /// How `vpal` and the vertical kerning with it move an upright glyph in vertical type and change @@ -560,6 +582,7 @@ fn inline_glyph(face: &Arc, st: &CharStyle, art: &InlineArt, run: usiz inline: Some(InlineBox { art: drawn, xf }), word_end: false, no_break: st.no_break, + cursive: false, } } @@ -610,6 +633,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let last = span.partition_point(|(r, _)| r.start < range.end).max(first); let source_runs = &(source_runs.start + first..source_runs.start + last); let text_seg = &text[range.clone()]; + // The segment's script: that of its first character of a script of its own. + let cursive = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)).is_some_and(joins); let full = st.size.max(0.0); // Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size. let (pos_scale, pos_shift) = st.position.scale_shift(full); @@ -820,6 +845,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe inline: None, word_end: false, no_break: gst.no_break, + cursive, }); } } diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index 07e17b0a7..d7738c501 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -162,6 +162,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout scale: 1.0, image: None, colour: None, + bend: None, }); x += advance; } diff --git a/crates/text/src/tests_path_joins.rs b/crates/text/src/tests_path_joins.rs new file mode 100644 index 000000000..a796490f8 --- /dev/null +++ b/crates/text/src/tests_path_joins.rs @@ -0,0 +1,204 @@ +//! 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> { + 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 = 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()); + } +}