mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Merge pull request #4119 from ham9zah/fix/joined-type-on-path
Type on a path: joined scripts bend along the path so their joins stay connected
This commit is contained in:
@@ -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<usize>) {
|
||||
///
|
||||
/// `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<bool>,
|
||||
) -> (BezPath, Vec<usize>) {
|
||||
let (mut rest, mut unwritten) = (BezPath::new(), vec![]);
|
||||
let mut line: Option<GlyphLine> = 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) };
|
||||
|
||||
@@ -1939,7 +1939,14 @@ impl Writer<'_> {
|
||||
}
|
||||
let id = self.id_attr(n);
|
||||
let a = self.attrs(&css::transparency(n));
|
||||
self.line(&format!("<g{id}{a}>"));
|
||||
// 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!("<g{id}{a}{label}>"));
|
||||
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 `<textPath>`'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);
|
||||
}
|
||||
// `<textPath>` 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 {
|
||||
|
||||
+119
-1
@@ -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<f
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How closely a bent glyph's curves follow the path (text space units).
|
||||
const BEND_TOLERANCE: f64 = 0.01;
|
||||
/// The most pieces one segment of a glyph's outline is cut into to bend it.
|
||||
const BEND_PIECES: usize = 32;
|
||||
/// The most elements a bent glyph outline may have; beyond that the glyph stays straight.
|
||||
const BEND_MAX_ELEMENTS: usize = 8192;
|
||||
|
||||
/// `outline` (text space, placed straight by `pre`, which takes the glyph's pen space there) bent
|
||||
/// along `ap` as `bend` says ([`GlyphBend`]); `None` where the path doesn't turn under the glyph
|
||||
/// (it stays as it is, exactly), or the bent outline would be too large or not finite.
|
||||
fn bend_outline(outline: &BezPath, pre: Affine, ap: &ArcPath, bend: &GlyphBend) -> Option<BezPath> {
|
||||
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<usize>], t: &TextObject, path: &PathData) {
|
||||
cx.out.on_path = true;
|
||||
@@ -2786,6 +2890,19 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Arc<vectorcraft_doc::Node>>,
|
||||
/// 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<GlyphBend>,
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -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<FontFace>, 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<usize>, 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<usize>, seg: &Segment, feats: &OtFe
|
||||
inline: None,
|
||||
word_end: false,
|
||||
no_break: gst.no_break,
|
||||
cursive,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user