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 @@ + + Symbol text + + + + [TEXT](test) + + + + \ 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 @@ + + Text cjk + + + + 【中日가Ấあっ!】 + + + + \ 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 @@ - - xml:lang=ja - https://heistak.github.io/your-code-displays-japanese-wrong/ + + xml:lang=ja + https://heistak.github.io/your-code-displays-japanese-wrong/ - + - - 刃直海角骨入 - - 刃直海角骨入 - - 刃直海角骨入 + + 刃直海角骨入 + + 刃直海角骨入 + + 刃直海角骨入 - - - + + + \ 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, }