diff --git a/CHANGELOG.md b/CHANGELOG.md
index e13b8f1b0..72121491a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,8 @@ This changelog also contains important changes in dependencies.
so a nested span stays on the same baseline as its siblings. (#864)
- Panics and incorrect clipping when a filter region is smaller than its input. (#1141)
- Incorrect transform order on the outermost `svg` element with a `viewBox`. (#899)
+- Relative positioning of right-to-left text with `dx` and `dy`, including text on a path and text decorations. (#1093)
+- `text-anchor` now uses the actual extent of a text chunk, which `dx` and `textLength` can change. (#1145)
## [0.48.1] 2026-08-02
diff --git a/crates/resvg/tests/integration/render.rs b/crates/resvg/tests/integration/render.rs
index 79c9e3c79..adb80acdc 100644
--- a/crates/resvg/tests/integration/render.rs
+++ b/crates/resvg/tests/integration/render.rs
@@ -1569,6 +1569,7 @@ use crate::render;
#[test] fn text_text_xml_lang_eq_ja() { assert_eq!(render("tests/text/text/xml-lang=ja"), 0); }
#[test] fn text_text_xml_space() { assert_eq!(render("tests/text/text/xml-space"), 0); }
#[test] fn text_text_zalgo() { assert_eq!(render("tests/text/text/zalgo"), 0); }
+#[test] fn text_text_anchor_arabic_with_dx() { assert_eq!(render("tests/text/text-anchor/arabic-with-dx"), 0); }
#[test] fn text_text_anchor_coordinates_list() { assert_eq!(render("tests/text/text-anchor/coordinates-list"), 0); }
#[test] fn text_text_anchor_end_on_text() { assert_eq!(render("tests/text/text-anchor/end-on-text"), 0); }
#[test] fn text_text_anchor_end_with_letter_spacing() { assert_eq!(render("tests/text/text-anchor/end-with-letter-spacing"), 0); }
@@ -1577,6 +1578,7 @@ use crate::render;
#[test] fn text_text_anchor_inheritance_3() { assert_eq!(render("tests/text/text-anchor/inheritance-3"), 0); }
#[test] fn text_text_anchor_invalid_value_on_text() { assert_eq!(render("tests/text/text-anchor/invalid-value-on-text"), 0); }
#[test] fn text_text_anchor_middle_on_text() { assert_eq!(render("tests/text/text-anchor/middle-on-text"), 0); }
+#[test] fn text_text_anchor_negative_dx() { assert_eq!(render("tests/text/text-anchor/negative-dx"), 0); }
#[test] fn text_text_anchor_on_the_first_tspan() { assert_eq!(render("tests/text/text-anchor/on-the-first-tspan"), 0); }
#[test] fn text_text_anchor_on_tspan_with_arabic() { assert_eq!(render("tests/text/text-anchor/on-tspan-with-arabic"), 0); }
#[test] fn text_text_anchor_on_tspan() { assert_eq!(render("tests/text/text-anchor/on-tspan"), 0); }
@@ -1598,6 +1600,7 @@ use crate::render;
#[test] fn text_text_decoration_tspan_decoration() { assert_eq!(render("tests/text/text-decoration/tspan-decoration"), 0); }
#[test] fn text_text_decoration_underline_with_dy_list_1() { assert_eq!(render("tests/text/text-decoration/underline-with-dy-list-1"), 0); }
#[test] fn text_text_decoration_underline_with_dy_list_2() { assert_eq!(render("tests/text/text-decoration/underline-with-dy-list-2"), 0); }
+#[test] fn text_text_decoration_underline_with_dy_on_arabic() { assert_eq!(render("tests/text/text-decoration/underline-with-dy-on-arabic"), 0); }
#[test] fn text_text_decoration_underline_with_rotate_list_3() { assert_eq!(render("tests/text/text-decoration/underline-with-rotate-list-3"), 0); }
#[test] fn text_text_decoration_underline_with_rotate_list_4() { assert_eq!(render("tests/text/text-decoration/underline-with-rotate-list-4"), 0); }
#[test] fn text_text_decoration_underline_with_y_list() { assert_eq!(render("tests/text/text-decoration/underline-with-y-list"), 0); }
@@ -1620,6 +1623,7 @@ use crate::render;
#[test] fn text_textLength_on_a_single_tspan() { assert_eq!(render("tests/text/textLength/on-a-single-tspan"), 0); }
#[test] fn text_textLength_on_text_and_tspan() { assert_eq!(render("tests/text/textLength/on-text-and-tspan"), 0); }
#[test] fn text_textLength_zero() { assert_eq!(render("tests/text/textLength/zero"), 0); }
+#[test] fn text_textPath_arabic_with_dy() { assert_eq!(render("tests/text/textPath/arabic-with-dy"), 0); }
#[test] fn text_textPath_closed_path() { assert_eq!(render("tests/text/textPath/closed-path"), 0); }
#[test] fn text_textPath_complex() { assert_eq!(render("tests/text/textPath/complex"), 0); }
#[test] fn text_textPath_dy_with_tiny_coordinates() { assert_eq!(render("tests/text/textPath/dy-with-tiny-coordinates"), 0); }
@@ -1675,6 +1679,7 @@ use crate::render;
#[test] fn text_tref_with_a_title_child() { assert_eq!(render("tests/text/tref/with-a-title-child"), 0); }
#[test] fn text_tref_with_text() { assert_eq!(render("tests/text/tref/with-text"), 0); }
#[test] fn text_tref_xml_space() { assert_eq!(render("tests/text/tref/xml-space"), 0); }
+#[test] fn text_tspan_arabic_with_dy() { assert_eq!(render("tests/text/tspan/arabic-with-dy"), 0); }
#[test] fn text_tspan_bidi_reordering() { assert_eq!(render("tests/text/tspan/bidi-reordering"), 0); }
#[test] fn text_tspan_mixed_font_size() { assert_eq!(render("tests/text/tspan/mixed-font-size"), 0); }
#[test] fn text_tspan_mixed_xml_space_1() { assert_eq!(render("tests/text/tspan/mixed-xml-space-1"), 0); }
@@ -1733,6 +1738,7 @@ use crate::render;
#[test] fn text_writing_mode_tb_with_dx_on_tspan() { assert_eq!(render("tests/text/writing-mode/tb-with-dx-on-tspan"), 0); }
#[test] fn text_writing_mode_tb_with_dy_on_second_tspan() { assert_eq!(render("tests/text/writing-mode/tb-with-dy-on-second-tspan"), 0); }
#[test] fn text_writing_mode_tb_with_rotate_and_underline() { assert_eq!(render("tests/text/writing-mode/tb-with-rotate-and-underline"), 0); }
+#[test] fn text_writing_mode_tb_with_negative_dy_and_text_anchor() { assert_eq!(render("tests/text/writing-mode/tb-with-negative-dy-and-text-anchor"), 0); }
#[test] fn text_writing_mode_tb_with_rotate() { assert_eq!(render("tests/text/writing-mode/tb-with-rotate"), 0); }
#[test] fn text_writing_mode_tb() { assert_eq!(render("tests/text/writing-mode/tb"), 0); }
#[test] fn text_writing_mode_vertical_lr() { assert_eq!(render("tests/text/writing-mode/vertical-lr"), 0); }
diff --git a/crates/resvg/tests/tests/text/text-anchor/arabic-with-dx.png b/crates/resvg/tests/tests/text/text-anchor/arabic-with-dx.png
new file mode 100644
index 000000000..ab3165e51
Binary files /dev/null and b/crates/resvg/tests/tests/text/text-anchor/arabic-with-dx.png differ
diff --git a/crates/resvg/tests/tests/text/text-anchor/arabic-with-dx.svg b/crates/resvg/tests/tests/text/text-anchor/arabic-with-dx.svg
new file mode 100644
index 000000000..5863ddbdc
--- /dev/null
+++ b/crates/resvg/tests/tests/text/text-anchor/arabic-with-dx.svg
@@ -0,0 +1,14 @@
+
diff --git a/crates/resvg/tests/tests/text/text-anchor/negative-dx.png b/crates/resvg/tests/tests/text/text-anchor/negative-dx.png
new file mode 100644
index 000000000..a435b55a4
Binary files /dev/null and b/crates/resvg/tests/tests/text/text-anchor/negative-dx.png differ
diff --git a/crates/resvg/tests/tests/text/text-anchor/negative-dx.svg b/crates/resvg/tests/tests/text/text-anchor/negative-dx.svg
new file mode 100644
index 000000000..5e881f965
--- /dev/null
+++ b/crates/resvg/tests/tests/text/text-anchor/negative-dx.svg
@@ -0,0 +1,13 @@
+
diff --git a/crates/resvg/tests/tests/text/text-decoration/underline-with-dy-on-arabic.png b/crates/resvg/tests/tests/text/text-decoration/underline-with-dy-on-arabic.png
new file mode 100644
index 000000000..106428c71
Binary files /dev/null and b/crates/resvg/tests/tests/text/text-decoration/underline-with-dy-on-arabic.png differ
diff --git a/crates/resvg/tests/tests/text/text-decoration/underline-with-dy-on-arabic.svg b/crates/resvg/tests/tests/text/text-decoration/underline-with-dy-on-arabic.svg
new file mode 100644
index 000000000..eb683bb59
--- /dev/null
+++ b/crates/resvg/tests/tests/text/text-decoration/underline-with-dy-on-arabic.svg
@@ -0,0 +1,9 @@
+
diff --git a/crates/resvg/tests/tests/text/textLength/zero.png b/crates/resvg/tests/tests/text/textLength/zero.png
index 1f92d4467..9c1b32571 100644
Binary files a/crates/resvg/tests/tests/text/textLength/zero.png and b/crates/resvg/tests/tests/text/textLength/zero.png differ
diff --git a/crates/resvg/tests/tests/text/textPath/arabic-with-dy.png b/crates/resvg/tests/tests/text/textPath/arabic-with-dy.png
new file mode 100644
index 000000000..b8e0c9c04
Binary files /dev/null and b/crates/resvg/tests/tests/text/textPath/arabic-with-dy.png differ
diff --git a/crates/resvg/tests/tests/text/textPath/arabic-with-dy.svg b/crates/resvg/tests/tests/text/textPath/arabic-with-dy.svg
new file mode 100644
index 000000000..721f088de
--- /dev/null
+++ b/crates/resvg/tests/tests/text/textPath/arabic-with-dy.svg
@@ -0,0 +1,10 @@
+
diff --git a/crates/resvg/tests/tests/text/tspan/arabic-with-dy.png b/crates/resvg/tests/tests/text/tspan/arabic-with-dy.png
new file mode 100644
index 000000000..d5da3c504
Binary files /dev/null and b/crates/resvg/tests/tests/text/tspan/arabic-with-dy.png differ
diff --git a/crates/resvg/tests/tests/text/tspan/arabic-with-dy.svg b/crates/resvg/tests/tests/text/tspan/arabic-with-dy.svg
new file mode 100644
index 000000000..d9621a5c4
--- /dev/null
+++ b/crates/resvg/tests/tests/text/tspan/arabic-with-dy.svg
@@ -0,0 +1,12 @@
+
diff --git a/crates/resvg/tests/tests/text/writing-mode/tb-with-negative-dy-and-text-anchor.png b/crates/resvg/tests/tests/text/writing-mode/tb-with-negative-dy-and-text-anchor.png
new file mode 100644
index 000000000..4d6652dd1
Binary files /dev/null and b/crates/resvg/tests/tests/text/writing-mode/tb-with-negative-dy-and-text-anchor.png differ
diff --git a/crates/resvg/tests/tests/text/writing-mode/tb-with-negative-dy-and-text-anchor.svg b/crates/resvg/tests/tests/text/writing-mode/tb-with-negative-dy-and-text-anchor.svg
new file mode 100644
index 000000000..2fe69d582
--- /dev/null
+++ b/crates/resvg/tests/tests/text/writing-mode/tb-with-negative-dy-and-text-anchor.svg
@@ -0,0 +1,13 @@
+
diff --git a/crates/usvg/src/text/layout.rs b/crates/usvg/src/text/layout.rs
index ae22028a5..7370a6a91 100644
--- a/crates/usvg/src/text/layout.rs
+++ b/crates/usvg/src/text/layout.rs
@@ -180,6 +180,7 @@ struct GlyphCluster {
codepoint: char,
width: f32,
advance: f32,
+ text_length_spacing: f32,
ascent: f32,
descent: f32,
has_relative_shift: bool,
@@ -545,6 +546,29 @@ fn clusters_length(clusters: &[GlyphCluster]) -> f32 {
clusters.iter().fold(0.0, |w, cluster| w + cluster.advance)
}
+fn resolve_relative_shifts(
+ text: &Text,
+ chunk: &TextChunk,
+ offset: usize,
+ clusters: &[GlyphCluster],
+) -> (Vec<(f32, f32)>, Vec) {
+ // `dx` and `dy` shift a character and all the characters after it in logical order,
+ // while the clusters are in visual order, which differs in right-to-left text.
+ let mut logical_order: Vec = (0..clusters.len()).collect();
+ logical_order.sort_by_key(|&i| clusters[i].byte_idx.value());
+
+ let mut shifts = vec![(0.0, 0.0); clusters.len()];
+ let (mut shift_x, mut shift_y) = (0.0, 0.0);
+ for &i in &logical_order {
+ let cp = offset + clusters[i].byte_idx.code_point_at(&chunk.text);
+ shift_x += text.dx.get(cp).cloned().unwrap_or(0.0);
+ shift_y += text.dy.get(cp).cloned().unwrap_or(0.0);
+ shifts[i] = (shift_x, shift_y);
+ }
+
+ (shifts, logical_order)
+}
+
fn resolve_clusters_positions_horizontal(
text: &Text,
chunk: &TextChunk,
@@ -552,23 +576,40 @@ fn resolve_clusters_positions_horizontal(
writing_mode: WritingMode,
clusters: &mut [GlyphCluster],
) -> (f32, f32) {
- let mut x = process_anchor(chunk.anchor, clusters_length(clusters));
- let mut y = 0.0;
+ let (mut shifts, logical_order) = resolve_relative_shifts(text, chunk, offset, clusters);
+ let first = logical_order[0];
+ let last = logical_order[logical_order.len() - 1];
+
+ // Vertical text is laid out horizontally and rotated by 90 degrees afterwards.
+ if writing_mode == WritingMode::TopToBottom {
+ for shift in &mut shifts {
+ *shift = (shift.1, -shift.0);
+ }
+ }
- for cluster in clusters {
- let cp = offset + cluster.byte_idx.code_point_at(&chunk.text);
- if let (Some(dx), Some(dy)) = (text.dx.get(cp), text.dy.get(cp)) {
- if writing_mode == WritingMode::LeftToRight {
- x += dx;
- y += dy;
- } else {
- y -= dx;
- x += dy;
- }
- cluster.has_relative_shift = !dx.approx_zero_ulps(4) || !dy.approx_zero_ulps(4);
+ for i in 1..clusters.len() {
+ if shifts[i] != shifts[i - 1] {
+ clusters[i].has_relative_shift = true;
}
+ }
+
+ let mut left = f32::INFINITY;
+ let mut right = f32::NEG_INFINITY;
+ let mut advance = 0.0;
+ for (cluster, (shift_x, _)) in clusters.iter().zip(&shifts) {
+ let start = advance + shift_x;
+ let end = start + cluster.advance - cluster.text_length_spacing;
+ left = left.min(start).min(end);
+ right = right.max(start).max(end);
+ advance += cluster.advance;
+ }
- cluster.transform = cluster.transform.pre_translate(x, y);
+ let mut x = shifts[first].0 - left + process_anchor(chunk.anchor, right - left);
+
+ for (cluster, (shift_x, shift_y)) in clusters.iter_mut().zip(&shifts) {
+ let cp = offset + cluster.byte_idx.code_point_at(&chunk.text);
+
+ cluster.transform = cluster.transform.pre_translate(x + shift_x, *shift_y);
if let Some(angle) = text.rotate.get(cp).cloned() {
if !angle.approx_zero_ulps(4) {
@@ -580,7 +621,7 @@ fn resolve_clusters_positions_horizontal(
x += cluster.advance;
}
- (x, y)
+ (x + shifts[last].0, shifts[last].1)
}
// Baseline resolving in SVG is a mess.
@@ -638,7 +679,7 @@ fn resolve_clusters_positions_path(
let mut last_x = 0.0;
let mut last_y = 0.0;
- let mut dy = 0.0;
+ let (shifts, _) = resolve_relative_shifts(text, chunk, char_offset, clusters);
// In the text path mode, chunk's x/y coordinates provide an additional offset along the path.
// The X coordinate is used in a horizontal mode, and Y in vertical.
@@ -650,8 +691,8 @@ fn resolve_clusters_positions_path(
let start_offset =
chunk_offset + path.start_offset + process_anchor(chunk.anchor, clusters_length(clusters));
- let normals = collect_normals(text, chunk, clusters, &path.path, char_offset, start_offset);
- for (cluster, normal) in clusters.iter_mut().zip(normals) {
+ let normals = collect_normals(text, clusters, &shifts, &path.path, start_offset);
+ for ((cluster, normal), (_, dy)) in clusters.iter_mut().zip(normals).zip(shifts) {
let (x, y, angle) = match normal {
Some(normal) => (normal.x, normal.y, normal.angle),
None => {
@@ -673,7 +714,6 @@ fn resolve_clusters_positions_path(
cluster.transform = cluster.transform.pre_rotate_at(angle, half_width, 0.0);
let cp = char_offset + cluster.byte_idx.code_point_at(&chunk.text);
- dy += text.dy.get(cp).cloned().unwrap_or(0.0);
let baseline_shift = chunk_span_at(chunk, cluster.byte_idx)
.map(|span| {
@@ -726,25 +766,20 @@ pub(crate) struct PathNormal {
fn collect_normals(
text: &Text,
- chunk: &TextChunk,
clusters: &[GlyphCluster],
+ shifts: &[(f32, f32)],
path: &tiny_skia_path::Path,
- char_offset: usize,
offset: f32,
) -> Vec