diff --git a/crates/resvg/tests/fonts/README.md b/crates/resvg/tests/fonts/README.md
index d100d9164..e54ddaa0b 100644
--- a/crates/resvg/tests/fonts/README.md
+++ b/crates/resvg/tests/fonts/README.md
@@ -20,3 +20,7 @@ Roboto Flex (Variable Font)
1. Download: https://github.com/googlefonts/roboto-flex/raw/main/fonts/RobotoFlex%5BGRAD%2CXOPQ%2CXTRA%2CYOPQ%2CYTAS%2CYTDE%2CYTFI%2CYTLC%2CYTUC%2Copsz%2Cslnt%2Cwdth%2Cwght%5D.ttf
2. Run `pyftsubset RobotoFlex*.ttf --unicodes="U+0020-007E" --layout-features='*' --output-file=RobotoFlex.subset.ttf`
3. Copy OFL license from https://github.com/googlefonts/roboto-flex/blob/main/OFL.txt
+
+Source Han Sans HW SC (CJK)
+1. Download: https://github.com/adobe-fonts/source-han-sans/releases/download/2.005R/14_SourceHanSansHWSC.zip
+2. Run `pyftsubset SourceHanSansHWSC-Regular.otf --unicodes="U+4E2D,U+5165,U+5203,U+56FD,U+5E38,U+65E5,U+672C,U+6D77,U+76F4,U+89D2,U+9577,U+975E,U+9AA8,U+0020-007F,U+0100-017F,U+0600-06FF,U+3040-309F,U+30A0-30FF,U+3000-303F,U+FE30-FE4F,U+FF00-FFEF,U+2000-206F,U+AC00,U+0100-017F,U+1EA0-1EFF" --output-file=SourceHanSansHWSC-Regular.subset.ttf`
\ No newline at end of file
diff --git a/crates/resvg/tests/fonts/SourceHanSansHWSC-LICENSE.txt b/crates/resvg/tests/fonts/SourceHanSansHWSC-LICENSE.txt
new file mode 100644
index 000000000..3ff0ccaba
--- /dev/null
+++ b/crates/resvg/tests/fonts/SourceHanSansHWSC-LICENSE.txt
@@ -0,0 +1,96 @@
+Copyright 2014-2025 Adobe (http://www.adobe.com/), with Reserved Font
+Name 'Source'. Source is a trademark of Adobe in the United States
+and/or other countries.
+
+This Font Software is licensed under the SIL Open Font License,
+Version 1.1.
+
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font
+creation efforts of academic and linguistic communities, and to
+provide a free and open framework in which fonts may be shared and
+improved in partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply to
+any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software
+components as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to,
+deleting, or substituting -- in part or in whole -- any of the
+components of the Original Version, by changing formats or by porting
+the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed,
+modify, redistribute, and sell modified and unmodified copies of the
+Font Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components, in
+Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the
+corresponding Copyright Holder. This restriction only applies to the
+primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created using
+the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/crates/resvg/tests/fonts/SourceHanSansHWSC-Regular.subset.ttf b/crates/resvg/tests/fonts/SourceHanSansHWSC-Regular.subset.ttf
new file mode 100644
index 000000000..63f39079f
Binary files /dev/null and b/crates/resvg/tests/fonts/SourceHanSansHWSC-Regular.subset.ttf differ
diff --git a/crates/resvg/tests/integration/render.rs b/crates/resvg/tests/integration/render.rs
index ad79dd7e5..b141b24cb 100644
--- a/crates/resvg/tests/integration/render.rs
+++ b/crates/resvg/tests/integration/render.rs
@@ -1485,6 +1485,8 @@ use crate::render;
#[test] fn text_font_weight_normal() { assert_eq!(render("tests/text/font-weight/normal"), 0); }
#[test] fn text_glyph_orientation_horizontal_simple_case() { assert_eq!(render("tests/text/glyph-orientation-horizontal/simple-case"), 0); }
#[test] fn text_glyph_orientation_vertical_simple_case() { assert_eq!(render("tests/text/glyph-orientation-vertical/simple-case"), 0); }
+#[test] fn text_glyph_orientation_vertical_text_cjk() { assert_eq!(render("tests/text/glyph-orientation-vertical/text-cjk"), 0); }
+#[test] fn text_glyph_orientation_vertical_symbol_text() { assert_eq!(render("tests/text/glyph-orientation-vertical/symbol-text"), 0); }
#[test] fn text_kerning_0() { assert_eq!(render("tests/text/kerning/0"), 0); }
#[test] fn text_kerning_10percent() { assert_eq!(render("tests/text/kerning/10percent"), 0); }
#[test] fn text_lengthAdjust_spacingAndGlyphs() { assert_eq!(render("tests/text/lengthAdjust/spacingAndGlyphs"), 0); }
diff --git a/crates/resvg/tests/tests/text/glyph-orientation-vertical/symbol-text.png b/crates/resvg/tests/tests/text/glyph-orientation-vertical/symbol-text.png
new file mode 100644
index 000000000..332ad6057
Binary files /dev/null and b/crates/resvg/tests/tests/text/glyph-orientation-vertical/symbol-text.png differ
diff --git a/crates/resvg/tests/tests/text/glyph-orientation-vertical/symbol-text.svg b/crates/resvg/tests/tests/text/glyph-orientation-vertical/symbol-text.svg
new file mode 100644
index 000000000..6626aea32
--- /dev/null
+++ b/crates/resvg/tests/tests/text/glyph-orientation-vertical/symbol-text.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/crates/resvg/tests/tests/text/glyph-orientation-vertical/text-cjk.png b/crates/resvg/tests/tests/text/glyph-orientation-vertical/text-cjk.png
new file mode 100644
index 000000000..7c70e1123
Binary files /dev/null and b/crates/resvg/tests/tests/text/glyph-orientation-vertical/text-cjk.png differ
diff --git a/crates/resvg/tests/tests/text/glyph-orientation-vertical/text-cjk.svg b/crates/resvg/tests/tests/text/glyph-orientation-vertical/text-cjk.svg
new file mode 100644
index 000000000..e67cfb3bf
--- /dev/null
+++ b/crates/resvg/tests/tests/text/glyph-orientation-vertical/text-cjk.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/crates/resvg/tests/tests/text/text/xml-lang=ja.png b/crates/resvg/tests/tests/text/text/xml-lang=ja.png
index 6594045ca..d943f6589 100644
Binary files a/crates/resvg/tests/tests/text/text/xml-lang=ja.png and b/crates/resvg/tests/tests/text/text/xml-lang=ja.png differ
diff --git a/crates/resvg/tests/tests/text/text/xml-lang=ja.svg b/crates/resvg/tests/tests/text/text/xml-lang=ja.svg
index 7c3ce5400..143e4f395 100644
--- a/crates/resvg/tests/tests/text/text/xml-lang=ja.svg
+++ b/crates/resvg/tests/tests/text/text/xml-lang=ja.svg
@@ -1,18 +1,17 @@
-
\ No newline at end of file
diff --git a/crates/resvg/tests/tests/text/textPath/complex.png b/crates/resvg/tests/tests/text/textPath/complex.png
index 3639e971d..88ad30fc7 100644
Binary files a/crates/resvg/tests/tests/text/textPath/complex.png and b/crates/resvg/tests/tests/text/textPath/complex.png differ
diff --git a/crates/resvg/tests/tests/text/writing-mode/mixed-languages-with-tb-and-underline.png b/crates/resvg/tests/tests/text/writing-mode/mixed-languages-with-tb-and-underline.png
index 74a7d219c..a875858d4 100644
Binary files a/crates/resvg/tests/tests/text/writing-mode/mixed-languages-with-tb-and-underline.png and b/crates/resvg/tests/tests/text/writing-mode/mixed-languages-with-tb-and-underline.png differ
diff --git a/crates/resvg/tests/tests/text/writing-mode/mixed-languages-with-tb-and-underline.svg b/crates/resvg/tests/tests/text/writing-mode/mixed-languages-with-tb-and-underline.svg
index 19f0d5b98..6f1fa9033 100644
--- a/crates/resvg/tests/tests/text/writing-mode/mixed-languages-with-tb-and-underline.svg
+++ b/crates/resvg/tests/tests/text/writing-mode/mixed-languages-with-tb-and-underline.svg
@@ -1,13 +1,12 @@
-
- Mixed languages with `tb` and underline
+
+ Mixed languages with `tb` and underline
-
+
- 本 Hi! اليابان
+ 本 Hi!
+ اليابان
-
-
-
+
+
+
\ No newline at end of file
diff --git a/crates/resvg/tests/tests/text/writing-mode/tb-and-punctuation.png b/crates/resvg/tests/tests/text/writing-mode/tb-and-punctuation.png
index cc6885a64..88d7f4bd6 100644
Binary files a/crates/resvg/tests/tests/text/writing-mode/tb-and-punctuation.png and b/crates/resvg/tests/tests/text/writing-mode/tb-and-punctuation.png differ
diff --git a/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate-and-underline.png b/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate-and-underline.png
index 2e974af07..45505a989 100644
Binary files a/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate-and-underline.png and b/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate-and-underline.png differ
diff --git a/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate-and-underline.svg b/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate-and-underline.svg
index 641e80e06..bee0dd3c7 100644
--- a/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate-and-underline.svg
+++ b/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate-and-underline.svg
@@ -1,15 +1,13 @@
-
- `tb` with rotate and underline (UB)
+
+ `tb` with rotate and underline (UB)
-
+
-
- 日本 Japan اليابان
-
+
+ 日本 Japan اليابان
+
-
-
-
+
+
+
\ No newline at end of file
diff --git a/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate.png b/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate.png
index 5c609c77a..f4158eb22 100644
Binary files a/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate.png and b/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate.png differ
diff --git a/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate.svg b/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate.svg
index 7c468403c..04245f012 100644
--- a/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate.svg
+++ b/crates/resvg/tests/tests/text/writing-mode/tb-with-rotate.svg
@@ -1,14 +1,13 @@
-
+`tb` with rotate (UB)
-
+
日本 Japan اليابان
-
-
+
+
\ No newline at end of file
diff --git a/crates/usvg/src/parser/text.rs b/crates/usvg/src/parser/text.rs
index 93a672954..db4ac283e 100644
--- a/crates/usvg/src/parser/text.rs
+++ b/crates/usvg/src/parser/text.rs
@@ -242,6 +242,8 @@ fn collect_text_chunks_impl(
parent.find_attribute(AId::PaintOrder).unwrap_or_default();
let paint_order = super::converter::svg_paint_order_to_usvg(raw_paint_order);
+ let writing_mode = convert_writing_mode(parent);
+
let mut dominant_baseline = parent
.find_attribute(AId::DominantBaseline)
.unwrap_or_default();
@@ -299,6 +301,7 @@ fn collect_text_chunks_impl(
baseline_shift: convert_baseline_shift(parent, state),
letter_spacing: parent.resolve_length(AId::LetterSpacing, state, 0.0),
word_spacing: parent.resolve_length(AId::WordSpacing, state, 0.0),
+ writing_mode,
text_length,
length_adjust: parent.find_attribute(AId::LengthAdjust).unwrap_or_default(),
};
diff --git a/crates/usvg/src/text/layout.rs b/crates/usvg/src/text/layout.rs
index 448d6545d..1fad8f271 100644
--- a/crates/usvg/src/text/layout.rs
+++ b/crates/usvg/src/text/layout.rs
@@ -181,6 +181,8 @@ struct GlyphCluster {
descent: f32,
has_relative_shift: bool,
glyphs: Vec,
+ glyphs_orientation: unicode_vo::Orientation,
+ glyphs_replaced: bool,
transform: Transform,
path_transform: Transform,
visible: bool,
@@ -911,6 +913,7 @@ fn process_chunk(
span.small_caps,
span.apply_kerning,
&span.font.variations,
+ span.writing_mode,
span.font_size.get(),
span.font_optical_sizing,
resolver,
@@ -971,6 +974,7 @@ fn process_chunk(
&glyphs[range],
&chunk.text,
span.font_size.get(),
+ span.writing_mode,
));
}
}
@@ -1039,31 +1043,53 @@ fn apply_length_adjust(chunk: &TextChunk, clusters: &mut [GlyphCluster]) {
}
/// Rotates clusters according to
-/// [Unicode Vertical_Orientation Property](https://www.unicode.org/reports/tr50/tr50-19.html).
+/// [UAX#50 Unicode Vertical Text Layout](https://unicode.org/reports/tr50).
fn apply_writing_mode(writing_mode: WritingMode, clusters: &mut [GlyphCluster]) {
if writing_mode != WritingMode::TopToBottom {
return;
}
for cluster in clusters {
- let orientation = unicode_vo::char_orientation(cluster.codepoint);
- if orientation == unicode_vo::Orientation::Upright {
+ let orientation = cluster.glyphs_orientation;
+ // Whether the single glyph were replaced with vertical variants.
+ // The Connected script (like Arabic) always set to false to avoid breaking the algorithm.
+ let replaced = cluster.glyphs_replaced;
+
+ let (needs_rotation, angle) = match orientation {
+ // Characters which are displayed upright, with the same orientation that appears in the code charts.
+ unicode_vo::Orientation::Upright => (true, -90.0),
+ // Characters which are not just upright or sideways
+ // Generally require a different glyph than in the code charts when used in vertical texts.
+ // As a fallback, the character can be displayed with the code chart glyph rotated 90 degrees clockwise.
+ unicode_vo::Orientation::TransformedOrRotated => (replaced, -90.0),
+ // Same as Tu except that, as a fallback, the character can be displayed with the code chart glyph upright (same as U).
+ // The replacement glyph for TU is usually moved to the center position, so the processing method is consistent.
+ unicode_vo::Orientation::TransformedOrUpright => (true, -90.0),
+ // As a fallback, all non-U, TU, and TR types are classified as R.
+ _ => (false, 0.0),
+ };
+
+ if needs_rotation {
+ let vertical_center = (cluster.ascent + cluster.descent) / 2.0;
+ let horizontal_center = cluster.width / 2.0;
+
let mut ts = Transform::default();
// Position glyph in the center of vertical axis.
- ts = ts.pre_translate(0.0, (cluster.ascent + cluster.descent) / 2.0);
- // Rotate by 90 degrees in the center.
- ts = ts.pre_rotate_at(
- -90.0,
- cluster.width / 2.0,
- -(cluster.ascent + cluster.descent) / 2.0,
- );
+ ts = ts.pre_translate(0.0, vertical_center);
+ // Rotate by 90/-90 degrees in the center.
+ ts = ts.pre_rotate_at(angle, horizontal_center, -vertical_center);
cluster.path_transform = ts;
// Move "baseline" to the middle and make height equal to width.
- cluster.ascent = cluster.width / 2.0;
- cluster.descent = -cluster.width / 2.0;
- } else {
+ let half_width = cluster.width / 2.0;
+ cluster.ascent = half_width;
+ cluster.descent = -half_width;
+ } else if matches!(
+ orientation,
+ // Characters which are displayed sideways, rotated 90 degrees clockwise compared to the code charts.
+ unicode_vo::Orientation::TransformedOrRotated | _
+ ) {
// Could not find a spec that explains this,
// but this is how other applications are shifting the "rotated" characters
// in the top-to-bottom mode.
@@ -1141,24 +1167,53 @@ fn apply_word_spacing(chunk: &TextChunk, clusters: &mut [GlyphCluster]) {
}
}
-fn form_glyph_clusters(glyphs: &[Glyph], text: &str, font_size: f32) -> GlyphCluster {
+fn form_glyph_clusters(
+ glyphs: &[Glyph],
+ text: &str,
+ font_size: f32,
+ writing_mode: WritingMode,
+) -> GlyphCluster {
debug_assert!(!glyphs.is_empty());
let mut width = 0.0;
let mut x: f32 = 0.0;
+ // Whole glyphs should in the same orientation.
+ let mut glyphs_orientation = unicode_vo::Orientation::Rotated;
+ let mut glyphs_replaced = false;
+
let mut positioned_glyphs = vec![];
for glyph in glyphs {
let sx = glyph.font.scale(font_size);
+ glyphs_orientation = glyph.text_orientation;
+ glyphs_replaced = glyph.glyph_replaced;
+
// Apply offset.
//
// The first glyph in the cluster will have an offset from 0x0,
// but the later one will have an offset from the "current position".
// So we have to keep an advance.
// TODO: should be done only inside a single text span
- let ts = Transform::from_translate(x + glyph.dx as f32, -glyph.dy as f32);
+ let horizontal_transform = Transform::from_translate(x + glyph.dx as f32, -glyph.dy as f32);
+ let vertical_sub_transform = Transform::from_translate(0.0, glyph.vertical_sub as f32);
+
+ let ts = if writing_mode == WritingMode::TopToBottom {
+ // Vertical writing mode handling
+ // See detail in fn `apply_writing_mode`.
+ match glyphs_orientation {
+ unicode_vo::Orientation::Rotated => horizontal_transform,
+ unicode_vo::Orientation::TransformedOrRotated if glyphs_replaced => {
+ vertical_sub_transform
+ }
+ unicode_vo::Orientation::TransformedOrRotated => horizontal_transform,
+ _ => vertical_sub_transform, // Covers Upright, TransformedOrUpright, etc.
+ }
+ } else {
+ // Horizontal writing mode - always use horizontal positioning
+ horizontal_transform
+ };
positioned_glyphs.push(PositionedGlyph {
glyph_ts: ts,
@@ -1175,7 +1230,21 @@ fn form_glyph_clusters(glyphs: &[Glyph], text: &str, font_size: f32) -> GlyphClu
x += glyph.width as f32;
- let glyph_width = glyph.width as f32 * sx;
+ // GlyphCluster.width is visual width in logical coordinates
+ let glyph_width = if writing_mode == WritingMode::TopToBottom {
+ // In vertical writing mode:
+ // - Rotated glyphs (Latin, Cyrillic, etc.) use their original width (set in fn `shape_text_with_font`).
+ // (will be rotated 90 degrees)
+ // - Upright glyphs (CJK, etc.) use vertical advance (dy * 2)
+ if glyph.text_orientation == unicode_vo::Orientation::Rotated {
+ glyph.width as f32 * sx // Use original width for rotated characters
+ } else {
+ glyph.dx.abs() as f32 * 2.0 * sx // Vertical advance for upright characters
+ }
+ } else {
+ // Horizontal writing mode - use standard glyph width
+ glyph.width as f32 * sx
+ };
if glyph_width > width {
width = glyph_width;
}
@@ -1194,6 +1263,8 @@ fn form_glyph_clusters(glyphs: &[Glyph], text: &str, font_size: f32) -> GlyphClu
transform: Transform::default(),
path_transform: Transform::default(),
glyphs: positioned_glyphs,
+ glyphs_orientation,
+ glyphs_replaced,
visible: true,
}
}
@@ -1296,6 +1367,7 @@ pub(crate) fn shape_text(
small_caps: bool,
apply_kerning: bool,
variations: &[crate::FontVariation],
+ writing_mode: WritingMode,
font_size: f32,
font_optical_sizing: crate::FontOpticalSizing,
resolver: &FontResolver,
@@ -1307,6 +1379,7 @@ pub(crate) fn shape_text(
small_caps,
apply_kerning,
variations,
+ writing_mode,
font_size,
font_optical_sizing,
fontdb,
@@ -1341,6 +1414,7 @@ pub(crate) fn shape_text(
small_caps,
apply_kerning,
variations,
+ writing_mode,
font_size,
font_optical_sizing,
fontdb,
@@ -1401,11 +1475,35 @@ fn shape_text_with_font(
small_caps: bool,
apply_kerning: bool,
variations: &[crate::FontVariation],
+ writing_mode: WritingMode,
font_size: f32,
font_optical_sizing: crate::FontOpticalSizing,
fontdb: &fontdb::Database,
) -> Option> {
fontdb.with_face_data(font.id, |font_data, face_index| -> Option> {
+ let Some(font_face) = fontdb.face(font.id) else {
+ log::warn!("Failed to load font: {:?}", font.id);
+ return None;
+ };
+
+ const CJK_LANGUAGES: &[fontdb::Language] = &[
+ fontdb::Language::Chinese_PeoplesRepublicOfChina,
+ fontdb::Language::Chinese_HongKongSAR,
+ fontdb::Language::Chinese_MacaoSAR,
+ fontdb::Language::Chinese_Taiwan,
+ fontdb::Language::Chinese_Singapore,
+ fontdb::Language::Japanese_Japan,
+ fontdb::Language::Korean_Korea,
+ fontdb::Language::Vietnamese_Vietnam,
+ ];
+
+ // Perhaps one could use `unicode_script::Script::Han` to obtain the text language.
+ // But in practice, this is not advisable as it is prone to misjudgment.
+ // If possible, libraries such as lingua-rs, CLD2, Whatlang, and Whichlang should be utilized.
+ let is_cjk_font = font_face
+ .families
+ .iter()
+ .any(|family| CJK_LANGUAGES.contains(&family.1));
let mut rb_font = rustybuzz::Face::from_slice(font_data, face_index)?;
// Build the list of variations to apply
@@ -1457,6 +1555,18 @@ fn shape_text_with_font(
continue;
}
+ // Determine if this is a connected script (cursive writing systems).
+ let first_char = sub_text.chars().next().unwrap();
+ let is_connected_script = matches!(
+ first_char.script(),
+ unicode_script::Script::Arabic
+ | unicode_script::Script::Syriac
+ | unicode_script::Script::Nko
+ | unicode_script::Script::Manichaean
+ | unicode_script::Script::Mongolian
+ | unicode_script::Script::Phags_Pa
+ );
+
let ltr = levels[run.start].is_ltr();
let hb_direction = if ltr {
rustybuzz::Direction::LeftToRight
@@ -1464,9 +1574,21 @@ fn shape_text_with_font(
rustybuzz::Direction::RightToLeft
};
- let mut buffer = rustybuzz::UnicodeBuffer::new();
- buffer.push_str(sub_text);
- buffer.set_direction(hb_direction);
+ // Most characters are rotated (R) in the vertical direction.
+ // RustyBuzz processes vertical content, the rendering effect is inconsistent with Chrome.
+ // It is necessary to continue obtaining the horizontal direction as a backup.
+ let mut buffer_horizontal = rustybuzz::UnicodeBuffer::new();
+ buffer_horizontal.push_str(sub_text);
+ buffer_horizontal.set_direction(hb_direction);
+ // Ogham script uses BottomToTop in vertical orientation, but not implemented currently
+ // See: https://www.w3.org/TR/2019/CR-css-writing-modes-4-20190730/#script-orientations
+ let mut buffer_vertical = rustybuzz::UnicodeBuffer::new();
+ buffer_vertical.push_str(sub_text);
+ buffer_vertical.set_direction(if is_connected_script {
+ rustybuzz::Direction::RightToLeft
+ } else {
+ rustybuzz::Direction::TopToBottom
+ });
let mut features = Vec::new();
if small_caps {
@@ -1477,14 +1599,51 @@ fn shape_text_with_font(
features.push(rustybuzz::Feature::new(Tag::from_bytes(b"kern"), 0, ..));
}
- let output = rustybuzz::shape(&rb_font, &features, buffer);
+ // Add vertical layout features for CJK fonts
+ if is_cjk_font {
+ if writing_mode == WritingMode::TopToBottom {
+ features.push(rustybuzz::Feature::new(Tag::from_bytes(b"vhal"), 1, ..));
+ } else {
+ features.push(rustybuzz::Feature::new(Tag::from_bytes(b"kern"), 0, ..));
+ features.push(rustybuzz::Feature::new(Tag::from_bytes(b"halt"), 1, ..));
+ }
+ }
+
+ // Note: rustybuzz automatically applies 'vert' and 'vkrn' features for vertical text
+ // 'vert' - glyphs to be presented upright
+ // 'vrtr' - glyphs to be presented sideways (rotate 90°)
+ // Use 'vrtr' when text-orientation: 'text-orientation' is required
+
+ // Perform text shaping for both orientations
+ let output_horizontal = rustybuzz::shape(&rb_font, &features, buffer_horizontal);
+ let output_vertical = rustybuzz::shape(&rb_font, &features, buffer_vertical);
+
+ // Select appropriate results based on writing mode
+ let (positions, sub_positions, infos, sub_infos) =
+ if writing_mode == WritingMode::TopToBottom {
+ (
+ output_vertical.glyph_positions(),
+ output_horizontal.glyph_positions(),
+ output_vertical.glyph_infos(),
+ output_horizontal.glyph_infos(),
+ )
+ } else {
+ (
+ output_horizontal.glyph_positions(),
+ output_vertical.glyph_positions(),
+ output_horizontal.glyph_infos(),
+ output_vertical.glyph_infos(),
+ )
+ };
- let positions = output.glyph_positions();
- let infos = output.glyph_infos();
+ let mut pre_pos = rustybuzz::GlyphPosition::default();
+ let mut vertical_sub = 0;
- for i in 0..output.len() {
+ for i in 0..output_horizontal.len() {
let pos = positions[i];
+ let sub_pos = sub_positions[i];
let info = infos[i];
+ let sub_info = sub_infos[i];
let idx = run.start + info.cluster as usize;
let start = info.cluster as usize;
@@ -1497,14 +1656,69 @@ fn shape_text_with_font(
.and_then(|last| infos.get(last))
.map_or(sub_text.len(), |info| info.cluster as usize);
+ let mut glyph_replaced = false;
+ let mut text_orientation: unicode_vo::Orientation =
+ unicode_vo::Orientation::Rotated;
+ if !is_connected_script {
+ let text_str = &sub_text[start..end];
+
+ if let Some(first_char) = text_str.chars().next() {
+ // Determine text orientation based on first character
+ text_orientation = unicode_vo::char_orientation(first_char);
+
+ // Get relative glyph ID for comparison (only for non-rotated text)
+ let relative_glyph_id =
+ if text_orientation != unicode_vo::Orientation::Rotated {
+ Some(sub_info.glyph_id)
+ } else {
+ None // Skip comparison for rotated text
+ };
+
+ // Check if glyph was replaced in vertical mode
+ glyph_replaced =
+ relative_glyph_id.map_or(false, |h_id| h_id != info.glyph_id);
+ }
+ }
+
+ let (mut glyph_x_offset, mut glyph_y_offset, mut glyph_width) =
+ (pos.x_offset, pos.y_offset, pos.x_advance);
+
+ if writing_mode == WritingMode::TopToBottom {
+ // Vertical text layout
+ if text_orientation == unicode_vo::Orientation::Rotated {
+ // Rotated glyphs (Latin, Cyrillic, etc.) in vertical text
+ // Use horizontal positioning from opposite layout mode
+ glyph_x_offset = sub_pos.x_offset;
+ glyph_y_offset = sub_pos.y_offset;
+ glyph_width = sub_pos.x_advance;
+ } else if i > 0 && is_cjk_font {
+ // CJK vertical typography adjustments
+ // Current implementation supports half-width character vertical adjustment
+
+ // Calculate relative positioning from previous glyph
+ let vertical_offset = pre_pos.y_offset - pos.y_offset;
+ let vertical_advance = pre_pos.y_advance - pos.y_advance;
+
+ if vertical_offset != 0 && vertical_advance != vertical_offset {
+ vertical_sub = vertical_offset;
+ }
+ }
+
+ // Store current position for next iteration comparison
+ pre_pos = pos;
+ }
+
glyphs.push(Glyph {
byte_idx: ByteIndex::new(idx),
cluster_len: end.checked_sub(start).unwrap_or(0), // TODO: can fail?
text: sub_text[start..end].to_string(),
id: GlyphId(info.glyph_id as u16),
- dx: pos.x_offset,
- dy: pos.y_offset,
- width: pos.x_advance,
+ dx: glyph_x_offset,
+ dy: glyph_y_offset,
+ width: glyph_width,
+ vertical_sub,
+ text_orientation,
+ glyph_replaced,
font: font.clone(),
});
}
@@ -1608,6 +1822,15 @@ pub(crate) struct Glyph {
/// The glyph width / X-advance in font units.
pub(crate) width: i32,
+ /// The glyph Y-axis minus in vertical mode.
+ pub(crate) vertical_sub: i32,
+
+ /// The text orientation.
+ pub(crate) text_orientation: unicode_vo::Orientation,
+
+ /// Whether the glyph was replaced by another glyph.
+ pub(crate) glyph_replaced: bool,
+
/// Reference to the source font.
///
/// Each glyph can have it's own source font.
diff --git a/crates/usvg/src/tree/text.rs b/crates/usvg/src/tree/text.rs
index c1739b901..99939169e 100644
--- a/crates/usvg/src/tree/text.rs
+++ b/crates/usvg/src/tree/text.rs
@@ -346,6 +346,7 @@ pub struct TextSpan {
pub(crate) visible: bool,
pub(crate) letter_spacing: f32,
pub(crate) word_spacing: f32,
+ pub(crate) writing_mode: WritingMode,
pub(crate) text_length: Option,
pub(crate) length_adjust: LengthAdjust,
}