mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 21:17:48 +00:00
Merge pull request #7081 from dotCooCoo/fix/svg-rtl-text-anchor
SVG: right-to-left text with text-anchor start or end opens at its directional edge
This commit is contained in:
@@ -1236,12 +1236,6 @@ impl Ctx {
|
||||
.collect();
|
||||
let under = stroke_under(&mut cells, label, warnings);
|
||||
|
||||
let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.prop(c.1, "text-anchor")).as_deref() {
|
||||
Some("middle") => 0.5,
|
||||
Some("end") => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let anchor = anchor_of(0);
|
||||
let path = cs.path.and_then(|tp| match self.text_path(xml, tp) {
|
||||
Some(p) => Some((tp, p)),
|
||||
None => {
|
||||
@@ -1250,6 +1244,23 @@ impl Ctx {
|
||||
}
|
||||
});
|
||||
let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical"));
|
||||
let rtl = self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl");
|
||||
// Where the chunk starting at character `i` sits on its anchor: 0 its left (or top) edge,
|
||||
// 0.5 its middle, 1 its right (or bottom) edge; on a path, its edge toward the path's
|
||||
// start, its middle or its edge toward the path's end. In horizontal type `start` and
|
||||
// `end` follow the `direction` of the element holding that character: right-to-left text
|
||||
// starts at its right edge, and on a path at the edge toward the path's end (type on a
|
||||
// path lays its glyphs out in visual order from its start).
|
||||
let anchor_of = |i: usize| {
|
||||
let Some(&(_, el)) = cs.chars.get(i) else { return 0.0 };
|
||||
let a = match self.prop(el, "text-anchor").as_deref() {
|
||||
Some("middle") => 0.5,
|
||||
Some("end") => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
if !vertical && self.prop(el, "direction").is_some_and(|d| d.trim() == "rtl") { 1.0 - a } else { a }
|
||||
};
|
||||
let anchor = anchor_of(0);
|
||||
self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings);
|
||||
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
|
||||
let mut breaks: Vec<Break> = vec![];
|
||||
@@ -1268,6 +1279,8 @@ impl Ctx {
|
||||
let em = self.font_size(tp);
|
||||
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
|
||||
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
|
||||
// Text that would start before the path's beginning starts at it, with all its glyphs
|
||||
// on the path (SVG does not draw the glyphs that fall off it).
|
||||
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0));
|
||||
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None };
|
||||
obj.xf = Affine::IDENTITY;
|
||||
@@ -1293,14 +1306,14 @@ impl Ctx {
|
||||
obj.xf = Affine::IDENTITY;
|
||||
} else {
|
||||
let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]);
|
||||
let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks);
|
||||
let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, rtl, origin.x, &mut breaks);
|
||||
obj.para.justify = justify;
|
||||
obj.xf = Affine::translate((finite(left), finite(origin.y)));
|
||||
// Lines anchored differently (paragraphs aligned differently): each line's paragraph
|
||||
// takes its alignment, and indents that put it where it was.
|
||||
if let Some(starts) = starts.filter(|st| st.iter().any(|(i, _)| anchor_of(*i) != anchor)) {
|
||||
for (k, &(i, ax)) in starts.iter().enumerate() {
|
||||
let a = anchor_of(i);
|
||||
// Lines anchored differently or at different x (paragraphs aligned or indented
|
||||
// differently): each line's paragraph takes its alignment, and indents that put it
|
||||
// where it was.
|
||||
if starts.iter().any(|&(_, a, ax)| a != starts[0].1 || ax != starts[0].2) {
|
||||
for (k, &(_, a, ax)) in starts.iter().enumerate() {
|
||||
let d = finite(ax - left);
|
||||
let mut pa = ParaStyle { justify: justify_of(a), ..ParaStyle::default() };
|
||||
match pa.justify {
|
||||
@@ -1319,13 +1332,7 @@ impl Ctx {
|
||||
if !para_styles.is_empty() {
|
||||
obj.set_paragraph_styles(para_styles);
|
||||
}
|
||||
// SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first
|
||||
// strong character: pinned (on every paragraph) when that would read as right to left.
|
||||
if self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") {
|
||||
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft));
|
||||
} else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) {
|
||||
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight));
|
||||
}
|
||||
pin_direction(&mut obj, rtl);
|
||||
keep_reader_anchors(&mut obj);
|
||||
// The placeholder covers the text (laid out when a paint server needs its bounding box).
|
||||
let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default();
|
||||
@@ -1412,7 +1419,7 @@ fn finite(v: f64) -> f64 {
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The alignment of an SVG text chunk anchored at `anchor` (0 start, 0.5 middle, 1 end).
|
||||
/// The alignment of an SVG text chunk anchored at `anchor` (0 left edge, 0.5 middle, 1 right edge).
|
||||
fn justify_of(anchor: f64) -> Justify {
|
||||
match anchor {
|
||||
a if a >= 1.0 => Justify::Right,
|
||||
@@ -1428,8 +1435,11 @@ fn leading_tolerance(spacing: f64) -> f64 {
|
||||
}
|
||||
|
||||
/// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the
|
||||
/// justification, the x of the first line's left (or anchor) edge and, when no character is
|
||||
/// placed on its own, each line's first character and anchor x.
|
||||
/// justification, the x of the first line's left (or anchor) edge and each line's first
|
||||
/// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk
|
||||
/// starting at a character sits on its anchor, and `rtl` whether the text's `direction` is `rtl`
|
||||
/// ([`pin_direction`]). When characters are placed on their own, every line is left-aligned and
|
||||
/// its anchor x is its left edge.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn lines(
|
||||
cells: &mut [Cell],
|
||||
@@ -1437,10 +1447,11 @@ fn lines(
|
||||
y: &[Option<f64>],
|
||||
dx: &[f64],
|
||||
dy: &[f64],
|
||||
anchor: f64,
|
||||
anchor_of: &dyn Fn(usize) -> f64,
|
||||
rtl: bool,
|
||||
x0: f64,
|
||||
breaks: &mut Vec<Break>,
|
||||
) -> (Justify, f64, Option<Vec<(usize, f64)>>) {
|
||||
) -> (Justify, f64, Vec<(usize, f64, f64)>) {
|
||||
let n = cells.len();
|
||||
let y0 = y[0].unwrap_or(0.0) + dy[0];
|
||||
let (mut base, mut cur) = (y0, y0);
|
||||
@@ -1482,53 +1493,59 @@ fn lines(
|
||||
}
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return (justify_of(anchor), x0, Some(lines));
|
||||
let starts = lines.iter().map(|&(s, lx)| (s, anchor_of(s), lx)).collect();
|
||||
return (justify_of(anchor_of(0)), x0, starts);
|
||||
}
|
||||
// Absolutely placed characters: lay the text out left-aligned and kern each one onto its x.
|
||||
// SVG anchors every absolutely placed chunk on its own, so with a middle/end anchor each
|
||||
// chunk's target moves back by its share of the chunk's width.
|
||||
// SVG anchors every absolutely placed chunk on its own, so a chunk not anchored at its left
|
||||
// edge moves back by its share of its own width. A chunk's edges are those of its glyphs as
|
||||
// they are drawn: the first character of a right-to-left chunk is at its right.
|
||||
let line_of = |i: usize| lines.partition_point(|(s, _)| *s <= i) - 1;
|
||||
let mut left = x0;
|
||||
let mut wants: Vec<(usize, f64)> = targets.clone();
|
||||
if anchor > 0.0 {
|
||||
let (runs, bytes) = assemble(cells, breaks);
|
||||
let lay = measure(&with_runs(runs.0));
|
||||
let (pens, ends) = pens(&lay, &bytes);
|
||||
// Chunk starts in order: line starts and absolutely placed characters.
|
||||
let mut starts: Vec<usize> = lines.iter().map(|l| l.0).chain(targets.iter().map(|t| t.0)).collect();
|
||||
starts.sort_unstable();
|
||||
let width = |s: usize| {
|
||||
let k = starts.partition_point(|&c| c <= s);
|
||||
let e = starts.get(k).copied().unwrap_or(n);
|
||||
let end = (s..e).filter_map(|j| ends[j]).fold(f64::NEG_INFINITY, f64::max);
|
||||
match pens[s] {
|
||||
Some(p) if end.is_finite() => end - p,
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
let line_left: Vec<f64> = lines.iter().map(|&(s, lx)| lx - anchor * width(s)).collect();
|
||||
left = line_left[0];
|
||||
for w in &mut wants {
|
||||
w.1 -= anchor * width(w.0) + line_left[line_of(w.0)];
|
||||
}
|
||||
} else {
|
||||
for w in &mut wants {
|
||||
w.1 -= lines[line_of(w.0)].1;
|
||||
}
|
||||
}
|
||||
// Chunk starts in order: line starts and absolutely placed characters.
|
||||
let mut starts: Vec<usize> = lines.iter().map(|l| l.0).chain(targets.iter().map(|t| t.0)).collect();
|
||||
starts.sort_unstable();
|
||||
// The left and right edges of the chunk starting at character `s`, from each character's pen and end.
|
||||
let edges = |s: usize, pens: &[Option<f64>], ends: &[Option<f64>]| {
|
||||
let e = starts.get(starts.partition_point(|&c| c <= s)).copied().unwrap_or(n);
|
||||
let left = (s..e).filter_map(|j| pens[j]).fold(f64::INFINITY, f64::min);
|
||||
let right = (s..e).filter_map(|j| ends[j]).fold(f64::NEG_INFINITY, f64::max);
|
||||
(left.is_finite() && right.is_finite()).then_some((left, right))
|
||||
};
|
||||
let mut line_left: Vec<f64> = lines.iter().map(|l| l.1).collect();
|
||||
// Where each absolutely placed character's pen goes, from the left edge of its line's first chunk.
|
||||
let mut wants: Vec<(usize, f64)> = vec![];
|
||||
// Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until
|
||||
// every character sits on its x.
|
||||
for _ in 0..4 {
|
||||
for round in 0..4 {
|
||||
let (runs, bytes) = assemble(cells, breaks);
|
||||
let lay = measure(&with_runs(runs.0));
|
||||
let (pens, _) = pens(&lay, &bytes);
|
||||
let mut text = with_runs(runs.0);
|
||||
pin_direction(&mut text, rtl);
|
||||
let (pens, ends) = pens(&measure(&text), &bytes);
|
||||
// The edges of each line's first chunk, found once a round.
|
||||
let first: Vec<Option<(f64, f64)>> = lines.iter().map(|&(s, _)| edges(s, &pens, &ends)).collect();
|
||||
if round == 0 {
|
||||
// The chunks as they lie before any is moved: each one's left edge on its anchor.
|
||||
let left_at = |s: usize, x: f64, chunk: Option<(f64, f64)>| chunk.map_or(x, |(left, right)| x - anchor_of(s) * (right - left));
|
||||
let first_left: Vec<f64> = lines.iter().zip(&first).map(|(&(s, lx), &chunk)| left_at(s, lx, chunk)).collect();
|
||||
wants = targets
|
||||
.iter()
|
||||
.map(|&(i, x)| {
|
||||
let chunk = edges(i, &pens, &ends);
|
||||
// The pen of a right-to-left chunk's first character is right of the chunk's left edge.
|
||||
let pen = pens[i].zip(chunk).map_or(0.0, |(p, (left, _))| p - left);
|
||||
(i, left_at(i, x, chunk) + pen - first_left[line_of(i)])
|
||||
})
|
||||
.collect();
|
||||
// A line's layout starts at the line's left edge, and its first chunk's left edge is `left` from there.
|
||||
line_left = first_left.iter().zip(&first).map(|(&x, chunk)| x - chunk.map_or(0.0, |(left, _)| left)).collect();
|
||||
}
|
||||
let (mut line, mut moved, mut worst) = (usize::MAX, 0.0, 0.0f64);
|
||||
for &(i, want) in &wants {
|
||||
let l = line_of(i);
|
||||
if l != line {
|
||||
(line, moved) = (l, 0.0);
|
||||
}
|
||||
let (Some(p), Some(p0)) = (pens[i], pens[lines[l].0]) else { continue };
|
||||
let (Some(p), Some((p0, _))) = (pens[i], first[l]) else { continue };
|
||||
let d = want - (p - p0 + moved);
|
||||
cells[i - 1].kern += d;
|
||||
moved += d;
|
||||
@@ -1538,7 +1555,8 @@ fn lines(
|
||||
break;
|
||||
}
|
||||
}
|
||||
(Justify::Left, left, None)
|
||||
let starts = lines.iter().zip(&line_left).map(|(&(s, _), &lx)| (s, 0.0, lx)).collect();
|
||||
(Justify::Left, line_left[0], starts)
|
||||
}
|
||||
|
||||
/// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor`
|
||||
@@ -1546,7 +1564,9 @@ fn lines(
|
||||
/// export anchors lines so); VectorCraft aligns a line without that tracking
|
||||
/// ([`vectorcraft_text::LineInfo::trailing`]). Each such line moves back by its share of it: the
|
||||
/// type moves when every line moves alike, otherwise each paragraph's right indent takes the
|
||||
/// tracking (a line of imported point type is a paragraph). Browsers add that letter spacing
|
||||
/// tracking (a line of imported point type is a paragraph). A right-to-left paragraph's lines
|
||||
/// keep all their tracking in the layout (`trailing` is 0), so they stay where they are and their
|
||||
/// whole advance sits on the anchor. Browsers add that letter spacing
|
||||
/// today; CSS WG issue 1518 and Firefox bugs 1907305 and 1913554 may drop it at a line's end.
|
||||
fn keep_reader_anchors(obj: &mut TextObject) {
|
||||
let share = |j: Justify| match j {
|
||||
@@ -1590,6 +1610,17 @@ fn keep_reader_anchors(obj: &mut TextObject) {
|
||||
obj.set_paragraph_styles(styles);
|
||||
}
|
||||
|
||||
/// SVG text runs left to right unless `direction: rtl` on the `<text>` says otherwise (`rtl`),
|
||||
/// whatever its first strong character: pinned (on every paragraph of `obj`) when that would read
|
||||
/// as right to left.
|
||||
fn pin_direction(obj: &mut TextObject, rtl: bool) {
|
||||
if rtl {
|
||||
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft));
|
||||
} else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) {
|
||||
obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight));
|
||||
}
|
||||
}
|
||||
|
||||
/// A left-aligned point text of `runs` at the origin (for measuring).
|
||||
fn with_runs(runs: Vec<TextRun>) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, "", CharStyle::default());
|
||||
|
||||
@@ -360,6 +360,197 @@ fn anchored_letter_spaced_lines_stay_where_a_reader_draws_them() {
|
||||
assert!(close(s[0], 200.0 - a[0] / 2.0, 1e-6) && close(s[1], 200.0 - a[1], 1e-6), "{s:?} {a:?}");
|
||||
}
|
||||
|
||||
/// Document-space left and right edges of the advance of each laid-out line.
|
||||
fn line_edges(t: &TextObject) -> Vec<(f64, f64)> {
|
||||
let lay = layout(FontDb::global(), t);
|
||||
lay.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let g = &lay.glyphs[l.glyph_start..l.glyph_end];
|
||||
let left = g.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min);
|
||||
let right = g.iter().map(|g| (t.xf * g.origin).x + g.advance).fold(f64::NEG_INFINITY, f64::max);
|
||||
(left, right)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// SVG 2 §11.10.1.1 (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties): with
|
||||
/// `direction: rtl`, `start` puts the right side of the text at its position and `end` puts the
|
||||
/// left side there; `middle` centers it either way. The direction is the effective one of the element that
|
||||
/// holds the chunk's first character.
|
||||
#[test]
|
||||
fn rtl_point_text_start_and_end_use_directional_edges() {
|
||||
// (markup, share of the advance left of x = 200)
|
||||
for (body, share) in [
|
||||
(r#"<text x="200" y="60" direction="rtl" font-size="20">Wave</text>"#, 1.0),
|
||||
(r#"<text x="200" y="60" direction="rtl" text-anchor="start" font-size="20">Wave</text>"#, 1.0),
|
||||
(r#"<text x="200" y="60" direction="rtl" text-anchor="middle" font-size="20">Wave</text>"#, 0.5),
|
||||
(r#"<text x="200" y="60" direction="rtl" text-anchor="end" font-size="20">Wave</text>"#, 0.0),
|
||||
(r#"<g direction="rtl"><text x="200" y="60" font-size="20">Wave</text></g>"#, 1.0),
|
||||
(r#"<g direction="rtl" text-anchor="end"><text x="200" y="60" font-size="20">Wave</text></g>"#, 0.0),
|
||||
(r#"<text x="200" y="60" direction="rtl" font-size="20"><tspan direction="ltr">Wave</tspan></text>"#, 0.0),
|
||||
(r#"<text x="200" y="60" text-anchor="start" font-size="20">Wave</text>"#, 0.0),
|
||||
(r#"<text x="200" y="60" direction="ltr" text-anchor="end" font-size="20">Wave</text>"#, 1.0),
|
||||
] {
|
||||
let d = open(body);
|
||||
let (left, right) = line_edges(only_text(&d))[0];
|
||||
let want = 200.0 - share * (right - left);
|
||||
assert!(close(left, want, 1e-6), "{body}: the advance spans {left}..{right}, so it does not start at {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// On a path, right-to-left text anchored by `start` ends at `startOffset` and one anchored by
|
||||
/// `end` begins there (its glyphs run from the path's start in visual order); `middle` centers it.
|
||||
#[test]
|
||||
fn rtl_type_on_a_path_starts_at_its_directional_edge() {
|
||||
// (anchor, direction, share of the advance before the offset)
|
||||
for (anchor, direction, share) in [("start", "rtl", 1.0), ("middle", "rtl", 0.5), ("end", "rtl", 0.0), ("start", "ltr", 0.0), ("end", "ltr", 1.0)]
|
||||
{
|
||||
let d = open(&format!(
|
||||
r##"<path id="p" d="M0 100 L400 100" fill="none"/>
|
||||
<text font-size="20" text-anchor="{anchor}" direction="{direction}"><textPath href="#p" startOffset="200">Wave</textPath></text>"##
|
||||
));
|
||||
let t = only_text(&d);
|
||||
assert!(matches!(t.kind, TextKind::OnPath { .. }), "{:?}", t.kind);
|
||||
let lay = layout(FontDb::global(), t);
|
||||
let left = lay.glyphs.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min);
|
||||
let width: f64 = lay.glyphs.iter().map(|g| g.advance).sum();
|
||||
let want = 200.0 - share * width;
|
||||
assert!(close(left, want, 0.05), "{anchor} {direction}: the text starts at {left}, not {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Letter-spaced right-to-left lines: a line keeps the tracking after each of its characters, and
|
||||
/// its whole advance sits on its anchor as `start`, `middle` and `end` place it.
|
||||
#[test]
|
||||
fn rtl_letter_spaced_lines_stay_on_their_anchor() {
|
||||
for (anchor, share) in [("start", 1.0), ("middle", 0.5), ("end", 0.0)] {
|
||||
let d = open(&format!(r#"<text x="200" y="100" direction="rtl" text-anchor="{anchor}" letter-spacing="8" font-size="40">WAVE</text>"#));
|
||||
let t = only_text(&d);
|
||||
let (left, right) = line_edges(t)[0];
|
||||
let want = 200.0 - share * (right - left);
|
||||
assert!(close(left, want, 1e-6), "{anchor}: the advance spans {left}..{right}, so it does not start at {want}");
|
||||
let natural = {
|
||||
let d = open(&format!(r#"<text x="200" y="100" direction="rtl" text-anchor="{anchor}" font-size="40">WAVE</text>"#));
|
||||
let (l, r) = line_edges(only_text(&d))[0];
|
||||
r - l
|
||||
};
|
||||
assert!(close(right - left, natural + 4.0 * 8.0, 1e-6), "{anchor}: the advance {} holds the tracking of all 4 letters", right - left);
|
||||
}
|
||||
// A right-to-left line anchored by `start` beside a left-to-right `middle` line: each keeps its own anchor.
|
||||
let d = open(
|
||||
r#"<text y="100" font-size="20" letter-spacing="4"><tspan x="200" direction="rtl">Wave</tspan><tspan x="200" dy="30" text-anchor="middle">Line</tspan></text>"#,
|
||||
);
|
||||
let t = only_text(&d);
|
||||
let e = line_edges(t);
|
||||
assert_eq!(e.len(), 2, "{e:?}");
|
||||
assert!(close(e[0].1, 200.0, 1e-6), "the right-to-left line ends at 200: {e:?}");
|
||||
let lay = layout(FontDb::global(), t);
|
||||
let a: f64 = lay.glyphs[lay.lines[1].glyph_start..lay.lines[1].glyph_end].iter().map(|g| g.advance).sum();
|
||||
assert!(close(e[1].0, 200.0 - a / 2.0, 1e-6), "the middle line is centered on 200 with its tracking: {e:?}");
|
||||
}
|
||||
|
||||
/// Each line is a chunk anchored by the `text-anchor` and `direction` of its first character.
|
||||
#[test]
|
||||
fn rtl_lines_use_each_chunk_first_character_direction_and_anchor() {
|
||||
let d = open(
|
||||
r#"<text y="60" font-size="20"><tspan x="200" direction="rtl">Wave</tspan><tspan x="200" dy="30">Line</tspan><tspan x="200" dy="30" direction="rtl" text-anchor="end">Last</tspan><tspan x="200" dy="30" direction="rtl" text-anchor="middle">Mid</tspan></text>"#,
|
||||
);
|
||||
let e = line_edges(only_text(&d));
|
||||
assert_eq!(e.len(), 4, "{e:?}");
|
||||
assert!(close(e[0].1, 200.0, 1e-6), "a right-to-left start line ends at 200: {e:?}");
|
||||
assert!(close(e[1].0, 200.0, 1e-6), "a left-to-right start line starts at 200: {e:?}");
|
||||
assert!(close(e[2].0, 200.0, 1e-6), "a right-to-left end line starts at 200: {e:?}");
|
||||
assert!(close((e[3].0 + e[3].1) / 2.0, 200.0, 1e-6), "a middle line is centered on 200: {e:?}");
|
||||
}
|
||||
|
||||
/// A line starts at its own x, also when it is anchored as the line before it is and when another
|
||||
/// line holds absolutely placed characters.
|
||||
#[test]
|
||||
fn each_line_starts_at_its_own_x() {
|
||||
let d = open(r#"<text y="40" font-size="20"><tspan x="50">Wave</tspan><tspan x="120" dy="30">Line</tspan></text>"#);
|
||||
let e = line_edges(only_text(&d));
|
||||
assert!(close(e[0].0, 50.0, 1e-6) && close(e[1].0, 120.0, 1e-6), "{e:?}");
|
||||
let d = open(r#"<text y="40" font-size="20" text-anchor="end"><tspan x="150">Wave</tspan><tspan x="100" dy="30">Line</tspan></text>"#);
|
||||
let e = line_edges(only_text(&d));
|
||||
assert!(close(e[0].1, 150.0, 1e-6) && close(e[1].1, 100.0, 1e-6), "{e:?}");
|
||||
let d = open(r#"<text y="40" font-size="20"><tspan x="50 100">ab</tspan><tspan x="200" dy="30" direction="rtl">Line</tspan></text>"#);
|
||||
let t = only_text(&d);
|
||||
let e = line_edges(t);
|
||||
assert_eq!(e.len(), 2, "{e:?}");
|
||||
assert!(close(e[0].0, 50.0, 0.05) && close(e[1].1, 200.0, 0.05), "{e:?}");
|
||||
let b = (t.xf * layout(FontDb::global(), t).glyphs[1].origin).x;
|
||||
assert!(close(b, 100.0, 0.05), "b starts at {b}");
|
||||
}
|
||||
|
||||
/// Absolutely placed chunks are anchored on their own, each by the `text-anchor` and `direction`
|
||||
/// of its first character, whatever the first chunk's anchor.
|
||||
#[test]
|
||||
fn absolutely_placed_chunks_use_their_own_rtl_anchor() {
|
||||
// (markup, share of each chunk's advance left of its x)
|
||||
for (body, shares) in [
|
||||
(r#"<text x="80 200" y="40" font-size="20">a<tspan direction="rtl">b</tspan></text>"#, [0.0, 1.0]),
|
||||
(r#"<text x="80 200" y="40" font-size="20">a<tspan direction="rtl" text-anchor="end">b</tspan></text>"#, [0.0, 0.0]),
|
||||
(r#"<text y="40" font-size="20"><tspan x="80">a</tspan><tspan x="200" direction="rtl">b</tspan></text>"#, [0.0, 1.0]),
|
||||
(r#"<text x="80 200" y="40" text-anchor="middle" font-size="20">a<tspan direction="rtl" text-anchor="end">b</tspan></text>"#, [0.5, 0.0]),
|
||||
(r#"<text x="80 200" y="40" text-anchor="middle" font-size="20">a<tspan direction="rtl" text-anchor="start">b</tspan></text>"#, [0.5, 1.0]),
|
||||
(r#"<text x="80 200" y="40" text-anchor="middle" font-size="20">a<tspan direction="rtl">b</tspan></text>"#, [0.5, 0.5]),
|
||||
(r#"<text x="80 200" y="40" direction="rtl" font-size="20">ab</text>"#, [1.0, 1.0]),
|
||||
] {
|
||||
let d = open(body);
|
||||
let t = only_text(&d);
|
||||
let lay = layout(FontDb::global(), t);
|
||||
assert_eq!(lay.glyphs.len(), 2, "{body}");
|
||||
// Natural advance: the kerning that moves `b` onto its x is added after `a`.
|
||||
let style = |i: usize| t.runs.iter().flat_map(|r| r.text.chars().map(move |_| &r.style)).nth(i).unwrap();
|
||||
let natural = |i: usize| lay.glyphs[i].advance - style(i).kerning.unwrap_or(0.0) / 1000.0 * style(i).size;
|
||||
for (i, (x, share)) in [80.0, 200.0].into_iter().zip(shares).enumerate() {
|
||||
let left = (t.xf * lay.glyphs[i].origin).x;
|
||||
let want = x - share * natural(i);
|
||||
assert!(close(left, want, 0.05), "{body}: chunk {i} starts at {left}, not {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A right-to-left chunk of several characters is anchored by its visual edges: its first
|
||||
/// character is drawn at its right, so the chunk runs left from that character's right edge.
|
||||
#[test]
|
||||
fn absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edges() {
|
||||
// (markup, share of the right-to-left chunk's advance left of x = 200)
|
||||
for (body, share) in [
|
||||
(r#"<text x="80 200" y="40" font-size="20">a<tspan direction="rtl">אב</tspan></text>"#, 1.0),
|
||||
(r#"<text x="80 200" y="40" font-size="20">a<tspan direction="rtl" text-anchor="middle">אב</tspan></text>"#, 0.5),
|
||||
(r#"<text x="80 200" y="40" font-size="20">a<tspan direction="rtl" text-anchor="end">אב</tspan></text>"#, 0.0),
|
||||
(r#"<text x="80 200" y="40" font-size="20">a<tspan text-anchor="end">אב</tspan></text>"#, 1.0),
|
||||
// A digit is not a strong character: the text still runs left to right.
|
||||
(r#"<text x="80 200" y="40" font-size="20">1<tspan direction="rtl" text-anchor="end">אב</tspan></text>"#, 0.0),
|
||||
// The chunk starts a line, and another line holds absolutely placed characters.
|
||||
(r#"<text y="40" font-size="20"><tspan x="200" direction="rtl">אב</tspan><tspan x="50 100" dy="30">ab</tspan></text>"#, 1.0),
|
||||
(
|
||||
r#"<text y="40" font-size="20"><tspan x="200" direction="rtl" text-anchor="middle">אב</tspan><tspan x="50 100" dy="30">ab</tspan></text>"#,
|
||||
0.5,
|
||||
),
|
||||
(
|
||||
r#"<text y="40" font-size="20"><tspan x="200" direction="rtl" text-anchor="end">אב</tspan><tspan x="50 100" dy="30">ab</tspan></text>"#,
|
||||
0.0,
|
||||
),
|
||||
] {
|
||||
let d = open(body);
|
||||
let t = only_text(&d);
|
||||
let lay = layout(FontDb::global(), t);
|
||||
let chunk: Vec<_> = lay.glyphs.iter().filter(|g| g.rtl).collect();
|
||||
assert_eq!(chunk.len(), 2, "{body}");
|
||||
assert!(chunk.iter().all(|g| g.advance > 1.0), "{body}: both letters have an advance");
|
||||
let left = chunk.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min);
|
||||
let right = chunk.iter().map(|g| (t.xf * g.origin).x + g.advance).fold(f64::NEG_INFINITY, f64::max);
|
||||
let want = 200.0 - share * (right - left);
|
||||
assert!(close(left, want, 0.05), "{body}: the chunk spans {left}..{right}, so it does not start at {want}");
|
||||
// The first character is the right one of the two.
|
||||
let first = lay.glyphs.iter().filter(|g| g.rtl).min_by_key(|g| g.byte).map(|g| (t.xf * g.origin).x);
|
||||
assert!(first.is_some_and(|x| x > left + 1.0), "{body}: the first character starts at {first:?}, the chunk at {left}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_line_gap_in_tiny_type_keeps_its_leading() {
|
||||
// 0.0085 against an auto leading of 0.006 differs by less than 0.01 but is not a whole multiple of it.
|
||||
|
||||
@@ -1532,6 +1532,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
A function-based PDF shading (type 1) whose colour changes along one axis only, as a browser writes a repeating linear gradient, comes in as a linear gradient over its domain, each hard colour change two stops at one offset; one whose colour changes in two directions comes in as its average colour, with a warning. Either paints only over the shading's domain. Both came in as a flat grey that wasn't in the file (#3621). Each such shading is read once per import, and all of them within one budget of 200,000 function evaluations, so a file filling thousands of paths with long functions still opens in about a second (it took minutes); past the budget they come in as their average colour, then grey, with a warning.
|
||||
A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623).
|
||||
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
|
||||
SVG import places right-to-left point type and type on a path as [SVG 2's `text-anchor`](https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties) says: with `direction: rtl`, `start` puts the right edge of the text at its position (on a path, the text ends at `startOffset`) and `end` puts the left edge there. Each line and each absolutely placed chunk is anchored by the `text-anchor` and `direction` of the element holding its first character, a `<tspan>`'s own included, and each line starts at its own `x`. An absolutely placed chunk is anchored by the left and right edges of its glyphs as they are drawn, so a chunk of several right-to-left characters, whose first character is drawn at its right, sits on its anchor too. A letter-spaced right-to-left line keeps the tracking of all its letters inside the advance it anchors (`rtl_point_text_start_and_end_use_directional_edges`, `rtl_type_on_a_path_starts_at_its_directional_edge`, `rtl_letter_spaced_lines_stay_on_their_anchor`, `rtl_lines_use_each_chunk_first_character_direction_and_anchor`, `each_line_starts_at_its_own_x`, `absolutely_placed_chunks_use_their_own_rtl_anchor` and `absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edges` in `crates/svg/tests/it/import_text.rs`).
|
||||
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.
|
||||
SVG export (Save As, Export As, Export for Screens, Asset Export and the clipboard share the writer) writes simple shapes as SVG basic shapes, as the reference app does (an ellipse is an `ellipse` tag until an anchor is added, [forum](https://community.adobe.com/t5/illustrator-discussions/trouble-exporting-shape-with-points-to-svg/td-p/10720325); rectangles come out as `<rect>` and `<polygon>`, [forum](https://community.adobe.com/t5/illustrator-discussions/force-svg-export-using-all-lt-rect-gt/td-p/11282378)): an axis-aligned rectangle as `<rect>`, a circle or ellipse drawn with four standard arcs as `<circle>`/`<ellipse>`, a straight line as `<line>`, and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision (#4652). A rectangle or ellipse with a dashed stroke, whose dashes would start elsewhere, a turned ellipse, curves and compound paths stay `<path>`. `crates/svg/tests/it/basic_shapes.rs` checks each element, that the picture renders as the canvas does and that the geometry reimports.
|
||||
At a precision of 9 decimals or fewer, SVG export writes each number from an integer when the integer rounds it as the decimal formatter does; the formatter still writes values halfway between two written numbers and values too large for the integer. Exporting one path of 200,000 anchors takes a median of 43.4 ms instead of 167.0 ms on Windows 11, and 20,000 rectangles 81.4 ms instead of 141.4 ms, with the same SVG (`crates/svg/src/lib.rs`).
|
||||
|
||||
Reference in New Issue
Block a user