From cfb199a7d11286335d00dac251305e0603bd7dc7 Mon Sep 17 00:00:00 2001 From: Teo Mal <76534902+teolines@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:47:53 +0000 Subject: [PATCH] Apply `dx` and `dy` in logical order in right-to-left text `dx` and `dy` shift a character and every character after it in logical order, but the shifts were accumulated while positioning the clusters in visual order. In right-to-left text, the first character is the last cluster, so a `dy` on a `tspan` only moved its first character, and a multi-line text collapsed onto its first line. Accumulate the shifts in logical order before positioning the clusters. Left-to-right text is unchanged. Fixes #1093 --- CHANGELOG.md | 4 ++ crates/resvg/tests/integration/render.rs | 1 + .../tests/tests/text/tspan/arabic-with-dy.png | Bin 0 -> 2266 bytes .../tests/tests/text/tspan/arabic-with-dy.svg | 13 +++++++ crates/usvg/src/text/layout.rs | 36 ++++++++++++------ 5 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 crates/resvg/tests/tests/text/tspan/arabic-with-dy.png create mode 100644 crates/resvg/tests/tests/text/tspan/arabic-with-dy.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f95ecac..aff691591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This changelog also contains important changes in dependencies. ## [Unreleased] +### Fixed + +- `dx` and `dy` on right-to-left text shift the characters after them in logical order, so multi-line `tspan`s no longer collapse onto one line. (#1093) + ## [0.48.1] 2026-08-02 This release has an MSRV of 1.85.0 for `usvg` and `resvg` and the C API. diff --git a/crates/resvg/tests/integration/render.rs b/crates/resvg/tests/integration/render.rs index 8683f61b1..2a3a58d1b 100644 --- a/crates/resvg/tests/integration/render.rs +++ b/crates/resvg/tests/integration/render.rs @@ -1661,6 +1661,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); } 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 0000000000000000000000000000000000000000..2c3f253d7cea2111eb5f0f8375a9eb0eed95ddf6 GIT binary patch literal 2266 zcmcImX;hO*77d2QBZf_a1A{`NAR#O^AUH8Li-2ZfF+%ttQ9?)>1q21Op@k4}5J`(B zqyw@FVniScUzC6-i%WzgECwa0(1HkIRa=&!L6#2f%>S7mQ|DIQ^KQL*=l*zgsY=S@_Fw7w@0tBLFyFNEURf=CZ~D*k^AojmSIRbA6Wg5ME$h@Saw zH~)YzO5CY5=2iCf(wf?q*6tUBL!W=Yp7izu1hUZxU^mh7%hnhPCqFxI%J6@XR z%N|-4ZIm?xm~iiTl?d!KL=H%3MthDWF4s21&3t_o(!TrOC{s|_v`>CIaLmZMLsWb~ ze^~EHjN+@?*vF?g1Fb?D?N(5=c~&N`m_JzF|AqKQF1~)$usTREZqN=#I%=)~$g2X# zqKXyEsHW2f9I7$W;-|?tGK(jBRs7KRJR6~?z%HXMnyWL(+ntD@X!$aI1FqAd>hF&w zpAC`F@fhe;7kdbQ*$><~VO3$9CbDRcP?(xjht&UA7l}+7=uHn6^x>KBU2}{7=t+$z zX4qG7IY05uKs0ZQYkEu_WbWK^>4qa^%_aVRt2Dm~S)Jb)E~QmC%iW);)qL0ZJgo&V zHp41$b2B0#DYh)fa!aVbt0x#HC|TGevt-vFc4$iou_+Y|m;Gj!3;mncF>K#cL6h^ONtq>z_#UOvw)|4~`tO{GEDnHThUJppQI^HNwiBEy_myNhDedmdWqEqppv@M zIe7L=+cW9Cg_lKXQzqjd^}!_{<@C?gH731lp|RO2F86Gd?j@d(%KL~^_*1HU(^hzu z(|ZAXPNfk|noa_-`LfrI?FAEUx1f_BD2o&&CN$jgNe!v<7dTBgg7L<-Zr7~lw~(hG zQ@5+m6K>6D$keyn(7QCBgs307nob97&~M`73+5d?Ry(uNBec8f8|xvf)_`$B@fSnK z-WY&Fm%?qcC(i1UIuF;PkMKFaGUjIs4}Q6CbRe?c?WzEN@Ui2L&H2SAgeUxcMW)a$ z$N85|aE8mnoktr`35K?q`WpRHYh&@+6nZRVX=;(+9~ji!WsnjZ4x~LuNYpa<)dfjx zBkaOi>Lzohlhwml+-( zW{{v}^c0tGAYKaHG;%uO9U>Qs&7YyDc6Zx*!FICd>vSAcx8T5c3GfkBknW>ICyAE| zDtHe}H?5!Px072d`V4ln;Q8SXwa4L8=h(3%YvP+{s%z=vF^F8>QaK0DB%EBKsVB zsJ6Nn2--1-E5g$=9tA?wo#L#fx>l$eKFxg&hk}sZA(WNl$u!AlBbnai6y&Jr3T)(o z!F6JXi?=0iakbVWlYJ`_wOHF2+R8HCXpzH_5j??ue)c`r!DKXbzgh6K>uTKz%Vp=5+gnZhHBe(TGcjR5G@+j%wp$H{j z=??JaV9B{N)<85yncC~@JKEH}WycrZGvTDHMg1~WOy9smR{)i%k-h0bg0V`2lS8p1 zh<;B*rZ|3JS{pMI6=jazhJ{{MHUM`2#501O7Sy=|=jD=caJkSdW=3+NIOnY`%56_| zuLrofb#@WUL{A(a@fS_S<&ipH4UIGa!>HRMm5JAi9`O*}J>%Y2vg&emeM+1%&J|-A zws*X4ULX{XW(g3AH2zw2<`oQtdET74P50=#mLX3#dBMw*CQ-fDM#BG=6s{q{e+9hG e^G4(c6lj;Yl{-J%HR-heL&13adEND-r2h%u!W5PO literal 0 HcmV?d00001 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..d90e39d70 --- /dev/null +++ b/crates/resvg/tests/tests/text/tspan/arabic-with-dy.svg @@ -0,0 +1,13 @@ + + Arabic with `dy` + + + مفتاح + معايير + الويب + + + + + diff --git a/crates/usvg/src/text/layout.rs b/crates/usvg/src/text/layout.rs index ae22028a5..fca855467 100644 --- a/crates/usvg/src/text/layout.rs +++ b/crates/usvg/src/text/layout.rs @@ -552,23 +552,35 @@ 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; - - for cluster in clusters { - let cp = offset + cluster.byte_idx.code_point_at(&chunk.text); + // `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); if let (Some(dx), Some(dy)) = (text.dx.get(cp), text.dy.get(cp)) { if writing_mode == WritingMode::LeftToRight { - x += dx; - y += dy; + shift_x += dx; + shift_y += dy; } else { - y -= dx; - x += dy; + shift_y -= dx; + shift_x += dy; } - cluster.has_relative_shift = !dx.approx_zero_ulps(4) || !dy.approx_zero_ulps(4); + clusters[i].has_relative_shift = !dx.approx_zero_ulps(4) || !dy.approx_zero_ulps(4); } + shifts[i] = (shift_x, shift_y); + } + + let mut x = process_anchor(chunk.anchor, clusters_length(clusters)); + + for (cluster, (cluster_shift_x, cluster_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, y); + cluster.transform = cluster + .transform + .pre_translate(x + cluster_shift_x, cluster_shift_y); if let Some(angle) = text.rotate.get(cp).cloned() { if !angle.approx_zero_ulps(4) { @@ -580,7 +592,7 @@ fn resolve_clusters_positions_horizontal( x += cluster.advance; } - (x, y) + (x + shift_x, shift_y) } // Baseline resolving in SVG is a mess.