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 @@ + + Arabic with `dx` and text anchoring + + + + + ادر + ادر + ادر + + + + 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 @@ + + Negative `dx` with text anchoring + + + + ABC + ABC + ABC + + + + 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 @@ + + Underline with `dy` on Arabic + + مفتاح + + + + 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 @@ + + Arabic with `dy` + + + مفتاح + + + + 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 @@ + + Arabic with `dy` + + + مفتاح + معايير + + + + + 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 @@ + + `tb` with negative `dy` and text anchoring + + + + ABC + ABC + ABC + + + + 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> { let mut offsets = Vec::with_capacity(clusters.len()); let mut normals = Vec::with_capacity(clusters.len()); { let mut advance = offset; - for cluster in clusters { + for (cluster, (shift_x, _)) in clusters.iter().zip(shifts) { // Clusters should be rotated by the x-midpoint x baseline position. let half_width = cluster.width / 2.0; - // Include relative position. - let cp = char_offset + cluster.byte_idx.code_point_at(&chunk.text); - advance += text.dx.get(cp).cloned().unwrap_or(0.0); - - let offset = advance + half_width; + let offset = advance + shift_x + half_width; // Clusters outside the path have no normals. if offset < 0.0 { @@ -1020,6 +1055,7 @@ fn apply_length_adjust(chunk: &TextChunk, clusters: &mut [GlyphCluster]) { for i in cluster_indexes { clusters[i].advance = clusters[i].width + factor; + clusters[i].text_length_spacing = factor; } } else { let factor = target_width / width; @@ -1193,6 +1229,7 @@ fn form_glyph_clusters(glyphs: &[Glyph], text: &str, font_size: f32) -> GlyphClu codepoint: byte_idx.char_from(text), width, advance, + text_length_spacing: 0.0, ascent: font.ascent(font_size), descent: font.descent(font_size), has_relative_shift: false,