SVG: an absolutely placed right-to-left chunk is anchored by the edges of its glyphs

A text chunk placed by an absolute x was measured from the pen of its
first character to the farthest glyph end. The first character of a
right-to-left chunk is drawn at its right, so a chunk of several such
characters was measured as that one character: anchored by middle or
end, or starting a line beside absolutely placed characters, it opened
off its position by the width of its other characters.

- A chunk's left and right edges are the smallest glyph start and the
  largest glyph end over all its characters, as SVG 2 anchors a chunk
  (https://www.w3.org/TR/SVG2/text.html#TextLayoutAlgorithm). The first
  character's pen is placed at its own distance from the left edge.
- The layout that measures the chunks has the direction the imported
  text gets: left to right unless the text's direction is rtl. Text
  starting with a digit and holding right-to-left letters was measured
  right to left.
- The chunks are measured in the first of the layouts that kern the
  characters onto their x, so anchored text is laid out once less.
This commit is contained in:
dotCooCoo
2026-11-13 04:32:32 -08:00
parent 6e2fe9fddf
commit d956d2165f
3 changed files with 95 additions and 41 deletions
+55 -40
View File
@@ -1244,6 +1244,7 @@ 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
@@ -1305,7 +1306,7 @@ 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_of, 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 or at different x (paragraphs aligned or indented
@@ -1331,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();
@@ -1442,8 +1437,9 @@ 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 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. When characters are placed on their own, every
/// line is left-aligned and its anchor x is its left edge.
/// 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],
@@ -1452,6 +1448,7 @@ fn lines(
dx: &[f64],
dy: &[f64],
anchor_of: &dyn Fn(usize) -> f64,
rtl: bool,
x0: f64,
breaks: &mut Vec<Break>,
) -> (Justify, f64, Vec<(usize, f64, f64)>) {
@@ -1501,47 +1498,54 @@ fn lines(
}
// 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 a chunk not anchored at its left
// edge moves back by its share of its own width.
// 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;
// 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();
let mut wants: Vec<(usize, f64)> = targets.clone();
if lines.iter().chain(&targets).any(|c| anchor_of(c.0) > 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,
}
};
line_left = lines.iter().map(|&(s, lx)| lx - anchor_of(s) * width(s)).collect();
for w in &mut wants {
w.1 -= anchor_of(w.0) * width(w.0);
}
}
for w in &mut wants {
w.1 -= line_left[line_of(w.0)];
}
// 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;
@@ -1606,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());
+39
View File
@@ -512,6 +512,45 @@ fn absolutely_placed_chunks_use_their_own_rtl_anchor() {
}
}
/// 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.
+1 -1
View File
@@ -1526,7 +1526,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`. 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` and `absolutely_placed_chunks_use_their_own_rtl_anchor` in `crates/svg/tests/it/import_text.rs`).
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`).