diff --git a/.typos.toml b/.typos.toml index 389e359e0..418faf839 100644 --- a/.typos.toml +++ b/.typos.toml @@ -16,6 +16,7 @@ SVGinOT = "SVGinOT" # Match Inside a Word - Case Insensitive [default.extend-words] +wdth = "wdth" [files] # Include .github, .cargo, etc. diff --git a/Cargo.lock b/Cargo.lock index 6dcbc486d..70c4e4165 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,6 +679,7 @@ dependencies = [ "strict-num", "svgtypes", "tiny-skia-path", + "ttf-parser", "unicode-bidi", "unicode-script", "unicode-vo", diff --git a/crates/resvg/tests/fonts/README.md b/crates/resvg/tests/fonts/README.md index 7108843b6..d100d9164 100644 --- a/crates/resvg/tests/fonts/README.md +++ b/crates/resvg/tests/fonts/README.md @@ -14,4 +14,9 @@ Noto COLOR Emoji (COLRv1) 3. Run `fonttools ttx NotoColorEmojiCOLR.subset.ttf` 4. Go to the section and rename all instances of "Noto Color Emoji" to "Noto Color Emoji COLR" (so that we can distinguish them from CBDT in tests). -5. Run `fonttools ttx -f NotoColorEmojiCOLR.subset.ttx` \ No newline at end of file +5. Run `fonttools ttx -f NotoColorEmojiCOLR.subset.ttx` + +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 diff --git a/crates/resvg/tests/fonts/RobotoFlex-LICENSE-OFL.txt b/crates/resvg/tests/fonts/RobotoFlex-LICENSE-OFL.txt new file mode 100644 index 000000000..5530c5720 --- /dev/null +++ b/crates/resvg/tests/fonts/RobotoFlex-LICENSE-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Flex Project Authors (https://github.com/googlefonts/roboto-flex) + +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/RobotoFlex.subset.ttf b/crates/resvg/tests/fonts/RobotoFlex.subset.ttf new file mode 100644 index 000000000..fc9440d2e Binary files /dev/null and b/crates/resvg/tests/fonts/RobotoFlex.subset.ttf differ diff --git a/crates/resvg/tests/integration/render.rs b/crates/resvg/tests/integration/render.rs index d5d651c8d..ad79dd7e5 100644 --- a/crates/resvg/tests/integration/render.rs +++ b/crates/resvg/tests/integration/render.rs @@ -1457,6 +1457,20 @@ use crate::render; #[test] fn text_font_style_oblique() { assert_eq!(render("tests/text/font-style/oblique"), 0); } #[test] fn text_font_variant_inherit() { assert_eq!(render("tests/text/font-variant/inherit"), 0); } #[test] fn text_font_variant_small_caps() { assert_eq!(render("tests/text/font-variant/small-caps"), 0); } +#[test] fn text_font_variation_settings_all_axes_combined() { assert_eq!(render("tests/text/font-variation-settings/all-axes-combined"), 0); } +#[test] fn text_font_variation_settings_auto_font_stretch_condensed() { assert_eq!(render("tests/text/font-variation-settings/auto-font-stretch-condensed"), 0); } +#[test] fn text_font_variation_settings_auto_font_style_oblique() { assert_eq!(render("tests/text/font-variation-settings/auto-font-style-oblique"), 0); } +#[test] fn text_font_variation_settings_auto_font_weight_700() { assert_eq!(render("tests/text/font-variation-settings/auto-font-weight-700"), 0); } +#[test] fn text_font_variation_settings_explicit_overrides_auto() { assert_eq!(render("tests/text/font-variation-settings/explicit-overrides-auto"), 0); } +#[test] fn text_font_variation_settings_grad_negative() { assert_eq!(render("tests/text/font-variation-settings/grad-negative"), 0); } +#[test] fn text_font_variation_settings_multiple_axes() { assert_eq!(render("tests/text/font-variation-settings/multiple-axes"), 0); } +#[test] fn text_font_variation_settings_opsz_144() { assert_eq!(render("tests/text/font-variation-settings/opsz-144"), 0); } +#[test] fn text_font_variation_settings_slnt_negative() { assert_eq!(render("tests/text/font-variation-settings/slnt-negative"), 0); } +#[test] fn text_font_variation_settings_wdth_151() { assert_eq!(render("tests/text/font-variation-settings/wdth-151"), 0); } +#[test] fn text_font_variation_settings_wdth_25() { assert_eq!(render("tests/text/font-variation-settings/wdth-25"), 0); } +#[test] fn text_font_variation_settings_wght_100() { assert_eq!(render("tests/text/font-variation-settings/wght-100"), 0); } +#[test] fn text_font_variation_settings_wght_700() { assert_eq!(render("tests/text/font-variation-settings/wght-700"), 0); } +#[test] fn text_font_variation_settings_xtra_extreme() { assert_eq!(render("tests/text/font-variation-settings/xtra-extreme"), 0); } #[test] fn text_font_weight_650() { assert_eq!(render("tests/text/font-weight/650"), 0); } #[test] fn text_font_weight_700() { assert_eq!(render("tests/text/font-weight/700"), 0); } #[test] fn text_font_weight_bold() { assert_eq!(render("tests/text/font-weight/bold"), 0); } diff --git a/crates/resvg/tests/tests/text/font-variation-settings/all-axes-combined.png b/crates/resvg/tests/tests/text/font-variation-settings/all-axes-combined.png new file mode 100644 index 000000000..203656bc7 Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/all-axes-combined.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/all-axes-combined.svg b/crates/resvg/tests/tests/text/font-variation-settings/all-axes-combined.svg new file mode 100644 index 000000000..6f3fca97c --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/all-axes-combined.svg @@ -0,0 +1,10 @@ + + Multiple custom axes combined + + Combo + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-stretch-condensed.png b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-stretch-condensed.png new file mode 100644 index 000000000..9cd355480 Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-stretch-condensed.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-stretch-condensed.svg b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-stretch-condensed.svg new file mode 100644 index 000000000..919945f3a --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-stretch-condensed.svg @@ -0,0 +1,9 @@ + + font-stretch condensed auto-maps to wdth axis + + Narrow + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-style-oblique.png b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-style-oblique.png new file mode 100644 index 000000000..c07f1e09d Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-style-oblique.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-style-oblique.svg b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-style-oblique.svg new file mode 100644 index 000000000..28079ceb6 --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-style-oblique.svg @@ -0,0 +1,9 @@ + + font-style oblique auto-maps to slnt axis + + Slant + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-weight-700.png b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-weight-700.png new file mode 100644 index 000000000..43fe14e4e Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-weight-700.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-weight-700.svg b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-weight-700.svg new file mode 100644 index 000000000..9f718eaea --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-weight-700.svg @@ -0,0 +1,9 @@ + + font-weight auto-maps to wght axis + + Bold + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/explicit-overrides-auto.png b/crates/resvg/tests/tests/text/font-variation-settings/explicit-overrides-auto.png new file mode 100644 index 000000000..29de617c3 Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/explicit-overrides-auto.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/explicit-overrides-auto.svg b/crates/resvg/tests/tests/text/font-variation-settings/explicit-overrides-auto.svg new file mode 100644 index 000000000..66f953d39 --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/explicit-overrides-auto.svg @@ -0,0 +1,10 @@ + + Explicit settings override font-weight auto-map + + Thin + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/grad-negative.png b/crates/resvg/tests/tests/text/font-variation-settings/grad-negative.png new file mode 100644 index 000000000..b9ad9103b Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/grad-negative.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/grad-negative.svg b/crates/resvg/tests/tests/text/font-variation-settings/grad-negative.svg new file mode 100644 index 000000000..b9fc20712 --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/grad-negative.svg @@ -0,0 +1,10 @@ + + `font-variation-settings: "GRAD" -200` + + Grade + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/multiple-axes.png b/crates/resvg/tests/tests/text/font-variation-settings/multiple-axes.png new file mode 100644 index 000000000..e827e8ae9 Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/multiple-axes.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/multiple-axes.svg b/crates/resvg/tests/tests/text/font-variation-settings/multiple-axes.svg new file mode 100644 index 000000000..04e32a7b1 --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/multiple-axes.svg @@ -0,0 +1,10 @@ + + `font-variation-settings: "wght" 700, "wdth" 75` + + Bold + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/opsz-144.png b/crates/resvg/tests/tests/text/font-variation-settings/opsz-144.png new file mode 100644 index 000000000..9e7ddf97b Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/opsz-144.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/opsz-144.svg b/crates/resvg/tests/tests/text/font-variation-settings/opsz-144.svg new file mode 100644 index 000000000..68da8f538 --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/opsz-144.svg @@ -0,0 +1,10 @@ + + `font-variation-settings: "opsz" 144` + + Display + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/slnt-negative.png b/crates/resvg/tests/tests/text/font-variation-settings/slnt-negative.png new file mode 100644 index 000000000..c07f1e09d Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/slnt-negative.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/slnt-negative.svg b/crates/resvg/tests/tests/text/font-variation-settings/slnt-negative.svg new file mode 100644 index 000000000..74f60b6fa --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/slnt-negative.svg @@ -0,0 +1,10 @@ + + `font-variation-settings: "slnt" -10` + + Slant + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wdth-151.png b/crates/resvg/tests/tests/text/font-variation-settings/wdth-151.png new file mode 100644 index 000000000..9d2aca7cc Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/wdth-151.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wdth-151.svg b/crates/resvg/tests/tests/text/font-variation-settings/wdth-151.svg new file mode 100644 index 000000000..a73f2128b --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/wdth-151.svg @@ -0,0 +1,10 @@ + + `font-variation-settings: "wdth" 151` + + Wide + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wdth-25.png b/crates/resvg/tests/tests/text/font-variation-settings/wdth-25.png new file mode 100644 index 000000000..482dc68c5 Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/wdth-25.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wdth-25.svg b/crates/resvg/tests/tests/text/font-variation-settings/wdth-25.svg new file mode 100644 index 000000000..6b690ab48 --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/wdth-25.svg @@ -0,0 +1,10 @@ + + `font-variation-settings: "wdth" 25` + + Narrow + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wght-100.png b/crates/resvg/tests/tests/text/font-variation-settings/wght-100.png new file mode 100644 index 000000000..29de617c3 Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/wght-100.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wght-100.svg b/crates/resvg/tests/tests/text/font-variation-settings/wght-100.svg new file mode 100644 index 000000000..43c6c8358 --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/wght-100.svg @@ -0,0 +1,10 @@ + + `font-variation-settings: "wght" 100` + + Thin + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wght-700.png b/crates/resvg/tests/tests/text/font-variation-settings/wght-700.png new file mode 100644 index 000000000..43fe14e4e Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/wght-700.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wght-700.svg b/crates/resvg/tests/tests/text/font-variation-settings/wght-700.svg new file mode 100644 index 000000000..f5e426f90 --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/wght-700.svg @@ -0,0 +1,10 @@ + + `font-variation-settings: "wght" 700` + + Bold + + + + diff --git a/crates/resvg/tests/tests/text/font-variation-settings/xtra-extreme.png b/crates/resvg/tests/tests/text/font-variation-settings/xtra-extreme.png new file mode 100644 index 000000000..81a24d168 Binary files /dev/null and b/crates/resvg/tests/tests/text/font-variation-settings/xtra-extreme.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/xtra-extreme.svg b/crates/resvg/tests/tests/text/font-variation-settings/xtra-extreme.svg new file mode 100644 index 000000000..a2f20e5c1 --- /dev/null +++ b/crates/resvg/tests/tests/text/font-variation-settings/xtra-extreme.svg @@ -0,0 +1,10 @@ + + `font-variation-settings: "XTRA" 603` + + Wide + + + + diff --git a/crates/usvg/Cargo.toml b/crates/usvg/Cargo.toml index f9c1778e5..d38ac108c 100644 --- a/crates/usvg/Cargo.toml +++ b/crates/usvg/Cargo.toml @@ -38,6 +38,9 @@ siphasher = "1.0" # perfect hash implementation # text fontdb = { version = "0.23.0", default-features = false, optional = true } rustybuzz = { version = "0.20.1", optional = true } +# Note: ttf-parser is re-exported from rustybuzz, but we need gvar-alloc for variable fonts +# with many variation axes (like Roboto Flex which has 13 axes) +ttf-parser = { version = "0.25.1", features = ["gvar-alloc"], optional = true } unicode-bidi = { version = "0.3", optional = true } unicode-script = { version = "0.5", optional = true } unicode-vo = { version = "0.1", optional = true } @@ -49,7 +52,7 @@ once_cell = "1.5" default = ["text", "system-fonts", "memmap-fonts"] # Enables text-to-path conversion support. # Adds around 400KiB to your binary. -text = ["fontdb", "rustybuzz", "unicode-bidi", "unicode-script", "unicode-vo"] +text = ["fontdb", "rustybuzz", "ttf-parser", "unicode-bidi", "unicode-script", "unicode-vo"] # Enables system fonts loading. system-fonts = ["fontdb/fs", "fontdb/fontconfig"] # Enables font files memmaping for faster loading. diff --git a/crates/usvg/codegen/attributes.txt b/crates/usvg/codegen/attributes.txt index 32cac0622..3ed557d77 100644 --- a/crates/usvg/codegen/attributes.txt +++ b/crates/usvg/codegen/attributes.txt @@ -40,6 +40,7 @@ font font-family font-feature-settings font-kerning +font-optical-sizing font-size font-size-adjust font-stretch @@ -51,6 +52,7 @@ font-variant-east-asian font-variant-ligatures font-variant-numeric font-variant-position +font-variation-settings font-weight fr fx diff --git a/crates/usvg/src/parser/converter.rs b/crates/usvg/src/parser/converter.rs index 7f5758ca9..38b1a666d 100644 --- a/crates/usvg/src/parser/converter.rs +++ b/crates/usvg/src/parser/converter.rs @@ -57,6 +57,8 @@ pub struct Cache { cache_svg: HashMap<(ID, GlyphId), Option>, #[cfg(feature = "text")] cache_raster: HashMap<(ID, GlyphId), Option>, + #[cfg(feature = "text")] + cache_has_opsz: HashMap, pub clip_paths: HashMap>, pub masks: HashMap>, @@ -105,6 +107,8 @@ impl Cache { cache_svg: HashMap::new(), #[cfg(feature = "text")] cache_raster: HashMap::new(), + #[cfg(feature = "text")] + cache_has_opsz: HashMap::new(), clip_paths: HashMap::new(), masks: HashMap::new(), @@ -204,6 +208,16 @@ impl Cache { font_lookup!(fontdb_colr, cache_colr, colr, Tree); font_lookup!(fontdb_svg, cache_svg, svg, Node); font_lookup!(fontdb_raster, cache_raster, raster, BitmapImage); + + #[cfg(feature = "text")] + pub(crate) fn has_opsz_axis(&mut self, font: ID) -> bool { + if let Some(&cached) = self.cache_has_opsz.get(&font) { + return cached; + } + let has_opsz = self.fontdb.has_opsz_axis(font); + self.cache_has_opsz.insert(font, has_opsz); + has_opsz + } } // TODO: is there a simpler way? diff --git a/crates/usvg/src/parser/svgtree/mod.rs b/crates/usvg/src/parser/svgtree/mod.rs index 3591fd8f3..5aeadb33c 100644 --- a/crates/usvg/src/parser/svgtree/mod.rs +++ b/crates/usvg/src/parser/svgtree/mod.rs @@ -710,12 +710,14 @@ impl AId { | AId::FloodOpacity | AId::FontFamily | AId::FontKerning // technically not presentation + | AId::FontOpticalSizing // technically not presentation | AId::FontSize | AId::FontSizeAdjust | AId::FontStretch | AId::FontStyle | AId::FontVariant | AId::FontWeight + | AId::FontVariationSettings | AId::GlyphOrientationHorizontal | AId::GlyphOrientationVertical | AId::ImageRendering @@ -786,6 +788,7 @@ impl AId { | AId::FloodOpacity | AId::FontFamily | AId::FontKerning + | AId::FontOpticalSizing | AId::FontSize | AId::FontStretch | AId::FontStyle diff --git a/crates/usvg/src/parser/svgtree/names.rs b/crates/usvg/src/parser/svgtree/names.rs index 1e6e2590c..d2afec152 100644 --- a/crates/usvg/src/parser/svgtree/names.rs +++ b/crates/usvg/src/parser/svgtree/names.rs @@ -205,6 +205,7 @@ pub enum AId { FontFamily, FontFeatureSettings, FontKerning, + FontOpticalSizing, FontSize, FontSizeAdjust, FontStretch, @@ -216,6 +217,7 @@ pub enum AId { FontVariantLigatures, FontVariantNumeric, FontVariantPosition, + FontVariationSettings, FontWeight, Fr, Fx, @@ -375,261 +377,264 @@ pub enum AId { } static ATTRIBUTES: Map = Map { - key: 3347381344252206323, + key: 3213172566270843353, disps: &[ - (0, 111), - (0, 2), - (0, 45), - (0, 5), + (0, 63), + (4, 146), + (0, 0), + (3, 42), + (2, 197), + (0, 0), (0, 1), - (2, 56), - (0, 5), - (2, 99), - (13, 198), - (0, 61), - (0, 52), - (1, 29), - (0, 21), - (0, 70), - (0, 164), - (2, 60), - (3, 52), + (0, 0), + (0, 0), + (0, 18), + (0, 11), + (1, 20), + (0, 8), + (17, 110), + (1, 112), + (1, 108), + (5, 94), + (2, 128), + (4, 95), + (0, 63), + (0, 96), + (0, 0), + (1, 110), (0, 1), - (0, 86), - (0, 10), + (40, 30), + (17, 157), + (0, 61), + (0, 16), + (7, 16), + (0, 80), + (0, 107), + (6, 111), + (0, 153), + (6, 202), + (18, 86), + (0, 194), (0, 0), - (0, 4), - (2, 175), - (6, 59), - (1, 14), - (0, 13), - (3, 175), - (1, 10), - (2, 76), - (0, 53), - (0, 24), - (123, 202), - (0, 14), - (0, 30), - (0, 62), - (0, 98), - (11, 193), - (8, 79), - (0, 17), - (22, 5), - (36, 106), - (1, 1), + (0, 7), + (0, 69), + (0, 5), + (0, 19), + (0, 0), + (4, 65), ], entries: &[ - ("mask-border-source", AId::MaskBorderSource), - ("stop-opacity", AId::StopOpacity), - ("stroke-linejoin", AId::StrokeLinejoin), - ("dominant-baseline", AId::DominantBaseline), - ("spreadMethod", AId::SpreadMethod), - ("order", AId::Order), - ("stroke", AId::Stroke), - ("stitchTiles", AId::StitchTiles), - ("height", AId::Height), - ("font-size", AId::FontSize), - ("background-color", AId::BackgroundColor), - ("tableValues", AId::TableValues), - ("x1", AId::X1), - ("y", AId::Y), - ("width", AId::Width), - ("text-indent", AId::TextIndent), - ("fill-opacity", AId::FillOpacity), - ("word-spacing", AId::WordSpacing), - ("cy", AId::Cy), - ("scale", AId::Scale), - ("x2", AId::X2), + ("alignment-baseline", AId::AlignmentBaseline), + ("fx", AId::Fx), + ("targetY", AId::TargetY), + ("clip-path", AId::ClipPath), ("lengthAdjust", AId::LengthAdjust), - ("glyph-orientation-horizontal", AId::GlyphOrientationHorizontal), - ("opacity", AId::Opacity), - ("mask-border", AId::MaskBorder), - ("font-stretch", AId::FontStretch), - ("stroke-dashoffset", AId::StrokeDashoffset), - ("fill", AId::Fill), - ("space", AId::Space), - ("baseline-shift", AId::BaselineShift), - ("text-align-last", AId::TextAlignLast), - ("font-variant-east-asian", AId::FontVariantEastAsian), - ("mask-border-mode", AId::MaskBorderMode), - ("font-variant-caps", AId::FontVariantCaps), + ("mask-size", AId::MaskSize), + ("unicode-bidi", AId::UnicodeBidi), + ("z", AId::Z), + ("font-variant-numeric", AId::FontVariantNumeric), + ("clip-rule", AId::ClipRule), + ("font", AId::Font), ("gradientUnits", AId::GradientUnits), - ("exponent", AId::Exponent), - ("text-decoration-color", AId::TextDecorationColor), - ("refX", AId::RefX), - ("enable-background", AId::EnableBackground), - ("mask-border-width", AId::MaskBorderWidth), + ("style", AId::Style), + ("font-stretch", AId::FontStretch), + ("intercept", AId::Intercept), + ("mask-border-slice", AId::MaskBorderSlice), + ("y", AId::Y), + ("xChannelSelector", AId::XChannelSelector), ("numOctaves", AId::NumOctaves), - ("kerning", AId::Kerning), + ("x1", AId::X1), + ("fill-rule", AId::FillRule), + ("image-rendering", AId::ImageRendering), + ("surfaceScale", AId::SurfaceScale), + ("seed", AId::Seed), ("mix-blend-mode", AId::MixBlendMode), - ("mask-clip", AId::MaskClip), - ("mask-mode", AId::MaskMode), - ("type", AId::Type), - ("class", AId::Class), - ("font", AId::Font), + ("path", AId::Path), ("mask-border-repeat", AId::MaskBorderRepeat), + ("transform", AId::Transform), + ("stroke", AId::Stroke), + ("refX", AId::RefX), + ("text-orientation", AId::TextOrientation), + ("line-height", AId::LineHeight), + ("display", AId::Display), + ("kerning", AId::Kerning), + ("transform-origin", AId::TransformOrigin), + ("shape-subtract", AId::ShapeSubtract), + ("width", AId::Width), ("stroke-miterlimit", AId::StrokeMiterlimit), + ("dy", AId::Dy), + ("text-decoration-color", AId::TextDecorationColor), + ("white-space", AId::WhiteSpace), + ("diffuseConstant", AId::DiffuseConstant), ("text-decoration-stroke", AId::TextDecorationStroke), - ("z", AId::Z), + ("values", AId::Values), + ("font-size", AId::FontSize), + ("shape-image-threshold", AId::ShapeImageThreshold), + ("href", AId::Href), + ("cy", AId::Cy), + ("mask-image", AId::MaskImage), + ("unicode-range", AId::UnicodeRange), + ("specularConstant", AId::SpecularConstant), + ("baseline-shift", AId::BaselineShift), + ("k3", AId::K3), + ("text-anchor", AId::TextAnchor), + ("mask-border-mode", AId::MaskBorderMode), + ("requiredFeatures", AId::RequiredFeatures), + ("color-rendering", AId::ColorRendering), + ("amplitude", AId::Amplitude), + ("mask-border-width", AId::MaskBorderWidth), + ("stroke-linecap", AId::StrokeLinecap), + ("paint-order", AId::PaintOrder), + ("lighting-color", AId::LightingColor), ("dx", AId::Dx), - ("clip-path", AId::ClipPath), - ("markerHeight", AId::MarkerHeight), - ("text-underline-position", AId::TextUnderlinePosition), - ("stdDeviation", AId::StdDeviation), + ("markerWidth", AId::MarkerWidth), + ("scale", AId::Scale), ("id", AId::Id), - ("paint-order", AId::PaintOrder), - ("elevation", AId::Elevation), - ("specularConstant", AId::SpecularConstant), - ("result", AId::Result), - ("font-size-adjust", AId::FontSizeAdjust), - ("mask-origin", AId::MaskOrigin), + ("color", AId::Color), + ("in2", AId::In2), + ("targetX", AId::TargetX), ("direction", AId::Direction), - ("font-variant-numeric", AId::FontVariantNumeric), - ("startOffset", AId::StartOffset), - ("maskUnits", AId::MaskUnits), - ("font-variant", AId::FontVariant), - ("text-orientation", AId::TextOrientation), - ("amplitude", AId::Amplitude), - ("rx", AId::Rx), - ("mask-type", AId::MaskType), - ("filter", AId::Filter), - ("in", AId::In), - ("display", AId::Display), - ("seed", AId::Seed), - ("unicode-range", AId::UnicodeRange), - ("color-profile", AId::ColorProfile), - ("x", AId::X), - ("href", AId::Href), - ("font-feature-settings", AId::FontFeatureSettings), - ("fill-rule", AId::FillRule), - ("fr", AId::Fr), - ("font-variant-ligatures", AId::FontVariantLigatures), - ("text-decoration-style", AId::TextDecorationStyle), - ("radius", AId::Radius), - ("xChannelSelector", AId::XChannelSelector), - ("orient", AId::Orient), - ("isolation", AId::Isolation), - ("gradientTransform", AId::GradientTransform), - ("transform-box", AId::TransformBox), - ("pointsAtY", AId::PointsAtY), - ("text-decoration-line", AId::TextDecorationLine), - ("requiredFeatures", AId::RequiredFeatures), - ("patternContentUnits", AId::PatternContentUnits), + ("pointsAtX", AId::PointsAtX), + ("stitchTiles", AId::StitchTiles), + ("patternUnits", AId::PatternUnits), ("shape-padding", AId::ShapePadding), - ("text-overflow", AId::TextOverflow), - ("clipPathUnits", AId::ClipPathUnits), - ("azimuth", AId::Azimuth), - ("line-height", AId::LineHeight), - ("viewBox", AId::ViewBox), - ("preserveAspectRatio", AId::PreserveAspectRatio), - ("path", AId::Path), + ("k2", AId::K2), + ("font-optical-sizing", AId::FontOpticalSizing), ("k4", AId::K4), - ("systemLanguage", AId::SystemLanguage), + ("vector-effect", AId::VectorEffect), + ("mask-composite", AId::MaskComposite), ("stroke-width", AId::StrokeWidth), - ("specularExponent", AId::SpecularExponent), - ("writing-mode", AId::WritingMode), - ("transform-origin", AId::TransformOrigin), - ("stroke-linecap", AId::StrokeLinecap), - ("points", AId::Points), - ("style", AId::Style), - ("pointsAtZ", AId::PointsAtZ), - ("targetX", AId::TargetX), - ("font-synthesis", AId::FontSynthesis), - ("maskContentUnits", AId::MaskContentUnits), - ("text-align", AId::TextAlign), - ("cx", AId::Cx), - ("alignment-baseline", AId::AlignmentBaseline), - ("font-kerning", AId::FontKerning), - ("requiredExtensions", AId::RequiredExtensions), - ("clip-rule", AId::ClipRule), + ("font-variation-settings", AId::FontVariationSettings), ("mask-border-outset", AId::MaskBorderOutset), - ("primitiveUnits", AId::PrimitiveUnits), - ("textLength", AId::TextLength), + ("in", AId::In), + ("stroke-linejoin", AId::StrokeLinejoin), + ("stop-opacity", AId::StopOpacity), + ("inline-size", AId::InlineSize), + ("mask-type", AId::MaskType), + ("filterUnits", AId::FilterUnits), + ("color-profile", AId::ColorProfile), + ("space", AId::Space), ("text-decoration-fill", AId::TextDecorationFill), - ("fy", AId::Fy), - ("mask-size", AId::MaskSize), - ("k3", AId::K3), - ("marker-start", AId::MarkerStart), - ("mode", AId::Mode), - ("k1", AId::K1), - ("refY", AId::RefY), - ("y1", AId::Y1), - ("shape-rendering", AId::ShapeRendering), - ("operator", AId::Operator), - ("mask-image", AId::MaskImage), - ("marker-end", AId::MarkerEnd), - ("rotate", AId::Rotate), - ("limitingConeAngle", AId::LimitingConeAngle), - ("surfaceScale", AId::SurfaceScale), - ("intercept", AId::Intercept), - ("font-variant-position", AId::FontVariantPosition), + ("font-kerning", AId::FontKerning), + ("offset", AId::Offset), + ("pointsAtZ", AId::PointsAtZ), + ("text-align", AId::TextAlign), ("clip", AId::Clip), - ("fx", AId::Fx), - ("visibility", AId::Visibility), - ("shape-margin", AId::ShapeMargin), - ("font-style", AId::FontStyle), - ("y2", AId::Y2), - ("dy", AId::Dy), + ("y1", AId::Y1), + ("mask-origin", AId::MaskOrigin), + ("mask-mode", AId::MaskMode), ("yChannelSelector", AId::YChannelSelector), - ("ry", AId::Ry), - ("color-rendering", AId::ColorRendering), - ("white-space", AId::WhiteSpace), - ("patternUnits", AId::PatternUnits), - ("shape-subtract", AId::ShapeSubtract), - ("markerWidth", AId::MarkerWidth), - ("d", AId::D), - ("shape-inside", AId::ShapeInside), - ("preserveAlpha", AId::PreserveAlpha), - ("shape-image-threshold", AId::ShapeImageThreshold), - ("image-rendering", AId::ImageRendering), + ("font-variant-caps", AId::FontVariantCaps), ("marker-mid", AId::MarkerMid), - ("filterUnits", AId::FilterUnits), - ("bias", AId::Bias), - ("mask-border-slice", AId::MaskBorderSlice), - ("pointsAtX", AId::PointsAtX), + ("shape-rendering", AId::ShapeRendering), + ("text-rendering", AId::TextRendering), + ("fill-opacity", AId::FillOpacity), + ("word-spacing", AId::WordSpacing), + ("fill", AId::Fill), + ("mask-clip", AId::MaskClip), + ("font-feature-settings", AId::FontFeatureSettings), + ("radius", AId::Radius), ("kernelMatrix", AId::KernelMatrix), - ("color-interpolation", AId::ColorInterpolation), - ("glyph-orientation-vertical", AId::GlyphOrientationVertical), - ("color", AId::Color), - ("patternTransform", AId::PatternTransform), ("kernelUnitLength", AId::KernelUnitLength), + ("mask-border-source", AId::MaskBorderSource), + ("k1", AId::K1), + ("mask", AId::Mask), + ("opacity", AId::Opacity), ("markerUnits", AId::MarkerUnits), + ("visibility", AId::Visibility), + ("spreadMethod", AId::SpreadMethod), + ("pointsAtY", AId::PointsAtY), + ("d", AId::D), + ("slope", AId::Slope), + ("side", AId::Side), + ("tableValues", AId::TableValues), + ("order", AId::Order), + ("text-align-last", AId::TextAlignLast), + ("font-size-adjust", AId::FontSizeAdjust), + ("rotate", AId::Rotate), + ("shape-margin", AId::ShapeMargin), + ("limitingConeAngle", AId::LimitingConeAngle), ("font-weight", AId::FontWeight), - ("overflow", AId::Overflow), + ("text-decoration-line", AId::TextDecorationLine), ("stop-color", AId::StopColor), + ("requiredExtensions", AId::RequiredExtensions), + ("enable-background", AId::EnableBackground), + ("systemLanguage", AId::SystemLanguage), + ("clipPathUnits", AId::ClipPathUnits), + ("stroke-dashoffset", AId::StrokeDashoffset), + ("ry", AId::Ry), + ("overflow", AId::Overflow), + ("class", AId::Class), + ("mask-border", AId::MaskBorder), + ("specularExponent", AId::SpecularExponent), + ("text-decoration", AId::TextDecoration), + ("startOffset", AId::StartOffset), + ("stroke-dasharray", AId::StrokeDasharray), + ("fr", AId::Fr), + ("mask-position", AId::MaskPosition), + ("writing-mode", AId::WritingMode), + ("font-synthesis", AId::FontSynthesis), + ("isolation", AId::Isolation), + ("rx", AId::Rx), + ("bias", AId::Bias), + ("markerHeight", AId::MarkerHeight), + ("edgeMode", AId::EdgeMode), ("r", AId::R), - ("k2", AId::K2), - ("text-anchor", AId::TextAnchor), - ("inline-size", AId::InlineSize), - ("unicode-bidi", AId::UnicodeBidi), + ("stroke-opacity", AId::StrokeOpacity), + ("maskContentUnits", AId::MaskContentUnits), + ("height", AId::Height), + ("font-variant-position", AId::FontVariantPosition), + ("operator", AId::Operator), ("font-family", AId::FontFamily), - ("color-interpolation-filters", AId::ColorInterpolationFilters), - ("slope", AId::Slope), - ("baseFrequency", AId::BaseFrequency), - ("transform", AId::Transform), - ("text-rendering", AId::TextRendering), - ("divisor", AId::Divisor), - ("edgeMode", AId::EdgeMode), + ("fy", AId::Fy), + ("dominant-baseline", AId::DominantBaseline), + ("y2", AId::Y2), + ("shape-inside", AId::ShapeInside), ("letter-spacing", AId::LetterSpacing), + ("azimuth", AId::Azimuth), + ("stdDeviation", AId::StdDeviation), ("flood-color", AId::FloodColor), - ("in2", AId::In2), - ("side", AId::Side), - ("mask-composite", AId::MaskComposite), - ("offset", AId::Offset), - ("values", AId::Values), - ("vector-effect", AId::VectorEffect), - ("mask", AId::Mask), - ("pathLength", AId::PathLength), - ("lighting-color", AId::LightingColor), - ("mask-position", AId::MaskPosition), - ("stroke-dasharray", AId::StrokeDasharray), - ("text-decoration", AId::TextDecoration), - ("stroke-opacity", AId::StrokeOpacity), - ("targetY", AId::TargetY), ("flood-opacity", AId::FloodOpacity), - ("diffuseConstant", AId::DiffuseConstant), + ("type", AId::Type), + ("font-variant-east-asian", AId::FontVariantEastAsian), + ("points", AId::Points), + ("refY", AId::RefY), + ("text-underline-position", AId::TextUnderlinePosition), + ("patternContentUnits", AId::PatternContentUnits), + ("baseFrequency", AId::BaseFrequency), + ("color-interpolation", AId::ColorInterpolation), + ("font-variant-ligatures", AId::FontVariantLigatures), + ("font-style", AId::FontStyle), + ("filter", AId::Filter), + ("text-decoration-style", AId::TextDecorationStyle), + ("preserveAlpha", AId::PreserveAlpha), + ("mode", AId::Mode), + ("divisor", AId::Divisor), + ("cx", AId::Cx), + ("patternTransform", AId::PatternTransform), + ("background-color", AId::BackgroundColor), + ("preserveAspectRatio", AId::PreserveAspectRatio), + ("gradientTransform", AId::GradientTransform), + ("x2", AId::X2), + ("pathLength", AId::PathLength), + ("marker-start", AId::MarkerStart), + ("glyph-orientation-horizontal", AId::GlyphOrientationHorizontal), + ("maskUnits", AId::MaskUnits), + ("textLength", AId::TextLength), + ("viewBox", AId::ViewBox), + ("text-overflow", AId::TextOverflow), + ("glyph-orientation-vertical", AId::GlyphOrientationVertical), + ("result", AId::Result), + ("primitiveUnits", AId::PrimitiveUnits), + ("exponent", AId::Exponent), + ("x", AId::X), + ("font-variant", AId::FontVariant), + ("elevation", AId::Elevation), + ("color-interpolation-filters", AId::ColorInterpolationFilters), + ("text-indent", AId::TextIndent), + ("marker-end", AId::MarkerEnd), + ("transform-box", AId::TransformBox), + ("orient", AId::Orient), ], }; diff --git a/crates/usvg/src/parser/text.rs b/crates/usvg/src/parser/text.rs index 743756a05..a8309afb6 100644 --- a/crates/usvg/src/parser/text.rs +++ b/crates/usvg/src/parser/text.rs @@ -263,6 +263,11 @@ fn collect_text_chunks_impl( apply_kerning = false; } + let font_optical_sizing = match parent.find_attribute::<&str>(AId::FontOpticalSizing) { + Some("none") => crate::FontOpticalSizing::None, + _ => crate::FontOpticalSizing::Auto, // "auto" or missing (browser default) + }; + let mut text_length = parent.try_convert_length(AId::TextLength, Units::UserSpaceOnUse, state); // Negative values should be ignored. @@ -284,6 +289,7 @@ fn collect_text_chunks_impl( font_size, small_caps: parent.find_attribute::<&str>(AId::FontVariant) == Some("small-caps"), apply_kerning, + font_optical_sizing, decoration: resolve_decoration(parent, state, cache), visible: visibility == Visibility::Visible, dominant_baseline, @@ -392,6 +398,49 @@ fn convert_font(node: SvgNode, state: &converter::State) -> Font { let style: FontStyle = node.find_attribute(AId::FontStyle).unwrap_or_default(); let stretch = conv_font_stretch(node); let weight = resolve_font_weight(node); + let mut variations = parse_font_variation_settings(node); + + // Auto-map standard font properties to variation axes if not explicitly set. + // This allows variable fonts to work with regular font-weight/font-stretch properties. + let has_wght = variations.iter().any(|v| &v.tag == b"wght"); + let has_wdth = variations.iter().any(|v| &v.tag == b"wdth"); + let has_ital = variations.iter().any(|v| &v.tag == b"ital"); + let has_slnt = variations.iter().any(|v| &v.tag == b"slnt"); + + // Map font-weight to wght axis (if not already set) + if !has_wght && weight != 400 { + variations.push(FontVariation::new(*b"wght", weight as f32)); + } + + // Map font-stretch to wdth axis (if not already set) + // CSS font-stretch percentages: ultra-condensed=50%, condensed=75%, normal=100%, expanded=125%, ultra-expanded=200% + if !has_wdth { + let wdth = match stretch { + FontStretch::UltraCondensed => 50.0, + FontStretch::ExtraCondensed => 62.5, + FontStretch::Condensed => 75.0, + FontStretch::SemiCondensed => 87.5, + FontStretch::Normal => 100.0, + FontStretch::SemiExpanded => 112.5, + FontStretch::Expanded => 125.0, + FontStretch::ExtraExpanded => 150.0, + FontStretch::UltraExpanded => 200.0, + }; + if wdth != 100.0 { + variations.push(FontVariation::new(*b"wdth", wdth)); + } + } + + // Map font-style: italic to ital axis (if not already set) + if !has_ital && style == FontStyle::Italic { + variations.push(FontVariation::new(*b"ital", 1.0)); + } + + // Map font-style: oblique to slnt axis (if not already set) + // Default oblique angle is typically 12-14 degrees + if !has_slnt && style == FontStyle::Oblique { + variations.push(FontVariation::new(*b"slnt", -12.0)); + } let font_families = if let Some(n) = node.ancestors().find(|n| n.has_attribute(AId::FontFamily)) { @@ -421,9 +470,99 @@ fn convert_font(node: SvgNode, state: &converter::State) -> Font { style, stretch, weight, + variations, } } +/// Parses the `font-variation-settings` CSS property. +/// +/// Syntax: `normal | [ ]#` +/// Example: `"wght" 700, "wdth" 50` +fn parse_font_variation_settings(node: SvgNode) -> Vec { + let value = if let Some(n) = node + .ancestors() + .find(|n| n.has_attribute(AId::FontVariationSettings)) + { + let v = n.attribute(AId::FontVariationSettings).unwrap_or(""); + log::debug!("Found font-variation-settings: '{}'", v); + v + } else { + return Vec::new(); + }; + + // "normal" means no variations + if value.eq_ignore_ascii_case("normal") || value.is_empty() { + return Vec::new(); + } + + let mut variations = Vec::new(); + + // Parse comma-separated list of "tag" value pairs + for part in value.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + + // Find the tag (quoted string) and value + // Format: "wght" 700 or 'wght' 700 + let mut chars = part.chars().peekable(); + + // Skip whitespace + while chars.peek().map_or(false, |c| c.is_whitespace()) { + chars.next(); + } + + // Parse quoted tag + let quote = match chars.next() { + Some('"') => '"', + Some('\'') => '\'', + _ => continue, // Invalid format + }; + + let mut tag_str = String::new(); + for c in chars.by_ref() { + if c == quote { + break; + } + tag_str.push(c); + } + + // Tag must be exactly 4 characters + if tag_str.len() != 4 { + log::warn!( + "Invalid font-variation-settings tag: '{}' (must be 4 characters)", + tag_str + ); + continue; + } + + // Skip whitespace before value + while chars.peek().map_or(false, |c| c.is_whitespace()) { + chars.next(); + } + + // Parse the numeric value + let value_str: String = chars.collect(); + let value_str = value_str.trim(); + + let value = match value_str.parse::() { + Ok(v) => v, + Err(_) => { + log::warn!("Invalid font-variation-settings value: '{}'", value_str); + continue; + } + }; + + let tag_bytes = tag_str.as_bytes(); + let tag = [tag_bytes[0], tag_bytes[1], tag_bytes[2], tag_bytes[3]]; + + variations.push(FontVariation::new(tag, value)); + } + + variations +} + // TODO: properly resolve narrower/wider fn conv_font_stretch(node: SvgNode) -> FontStretch { if let Some(n) = node.ancestors().find(|n| n.has_attribute(AId::FontStretch)) { diff --git a/crates/usvg/src/text/flatten.rs b/crates/usvg/src/text/flatten.rs index 89929a08e..e5fa39f76 100644 --- a/crates/usvg/src/text/flatten.rs +++ b/crates/usvg/src/text/flatten.rs @@ -71,6 +71,10 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ // with just outlines (which is the most common case). let mut span_builder = tiny_skia_path::PathBuilder::new(); + // For variable fonts, we need to extract the outline with variations applied. + // We can't use the cache here since the outline depends on variation values. + let has_explicit_variations = !span.variations.is_empty(); + for glyph in &span.positioned_glyphs { // A (best-effort conversion of a) COLR glyph. if let Some(tree) = cache.fontdb_colr(glyph.font, glyph.id) { @@ -128,11 +132,28 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ group.calculate_bounding_boxes(); new_children.push(Node::Group(Box::new(group))); - } else if let Some(outline) = cache - .fontdb_outline(glyph.font, glyph.id) - .and_then(|p| p.transform(glyph.outline_transform())) - { - span_builder.push_path(&outline); + } else { + // Only bypass cache if: explicit variations OR (auto opsz AND font has opsz axis) + let needs_variations = has_explicit_variations + || (span.font_optical_sizing == crate::FontOpticalSizing::Auto + && cache.has_opsz_axis(glyph.font)); + + let outline = if needs_variations { + cache.fontdb.outline_with_variations( + glyph.font, + glyph.id, + &span.variations, + glyph.font_size(), + span.font_optical_sizing, + ) + } else { + cache.fontdb_outline(glyph.font, glyph.id) + }; + + if let Some(outline) = outline.and_then(|p| p.transform(glyph.outline_transform())) + { + span_builder.push_path(&outline); + } } } @@ -187,6 +208,15 @@ impl ttf_parser::OutlineBuilder for PathBuilder { pub(crate) trait DatabaseExt { fn outline(&self, id: ID, glyph_id: GlyphId) -> Option; + fn outline_with_variations( + &self, + id: ID, + glyph_id: GlyphId, + variations: &[crate::FontVariation], + font_size: f32, + font_optical_sizing: crate::FontOpticalSizing, + ) -> Option; + fn has_opsz_axis(&self, id: ID) -> bool; fn raster(&self, id: ID, glyph_id: GlyphId) -> Option; fn svg(&self, id: ID, glyph_id: GlyphId) -> Option; fn colr(&self, id: ID, glyph_id: GlyphId) -> Option; @@ -206,7 +236,56 @@ impl DatabaseExt for Database { #[inline(never)] fn outline(&self, id: ID, glyph_id: GlyphId) -> Option { self.with_face_data(id, |data, face_index| -> Option { - let font = ttf_parser::Face::parse(data, face_index).ok()?; + let mut font = ttf_parser::Face::parse(data, face_index).ok()?; + + // For variable fonts, we need to set default variation values to get proper outlines + if font.is_variable() { + for axis in font.variation_axes() { + font.set_variation(axis.tag, axis.def_value); + } + } + + let mut builder = PathBuilder { + builder: tiny_skia_path::PathBuilder::new(), + }; + + font.outline_glyph(glyph_id, &mut builder)?; + builder.builder.finish() + })? + } + + #[inline(never)] + fn outline_with_variations( + &self, + id: ID, + glyph_id: GlyphId, + variations: &[crate::FontVariation], + font_size: f32, + font_optical_sizing: crate::FontOpticalSizing, + ) -> Option { + self.with_face_data(id, |data, face_index| -> Option { + let mut font = ttf_parser::Face::parse(data, face_index).ok()?; + + for v in variations { + font.set_variation(ttf_parser::Tag::from_bytes(&v.tag), v.value); + } + + // Auto-set opsz if font-optical-sizing is auto and not explicitly set + if font_optical_sizing == crate::FontOpticalSizing::Auto { + let has_explicit_opsz = variations.iter().any(|v| v.tag == *b"opsz"); + if !has_explicit_opsz { + // Check if font has opsz axis + if let Some(axes) = font.tables().fvar { + let has_opsz_axis = axes + .axes + .into_iter() + .any(|axis| axis.tag == ttf_parser::Tag::from_bytes(b"opsz")); + if has_opsz_axis { + font.set_variation(ttf_parser::Tag::from_bytes(b"opsz"), font_size); + } + } + } + } let mut builder = PathBuilder { builder: tiny_skia_path::PathBuilder::new(), @@ -217,6 +296,20 @@ impl DatabaseExt for Database { })? } + fn has_opsz_axis(&self, id: ID) -> bool { + self.with_face_data(id, |data, face_index| -> Option { + let font = ttf_parser::Face::parse(data, face_index).ok()?; + let has_opsz = font.tables().fvar.map_or(false, |axes| { + axes.axes + .into_iter() + .any(|axis| axis.tag == ttf_parser::Tag::from_bytes(b"opsz")) + }); + Some(has_opsz) + }) + .flatten() + .unwrap_or(false) + } + fn raster(&self, id: ID, glyph_id: GlyphId) -> Option { self.with_face_data(id, |data, face_index| -> Option { let font = ttf_parser::Face::parse(data, face_index).ok()?; diff --git a/crates/usvg/src/text/layout.rs b/crates/usvg/src/text/layout.rs index 2261f66bb..448d6545d 100644 --- a/crates/usvg/src/text/layout.rs +++ b/crates/usvg/src/text/layout.rs @@ -48,6 +48,11 @@ pub struct PositionedGlyph { } impl PositionedGlyph { + /// Returns the font size for this glyph. + pub fn font_size(&self) -> f32 { + self.font_size + } + /// Returns the transform of glyph. pub fn transform(&self) -> Transform { let sx = self.font_size / self.units_per_em as f32; @@ -147,6 +152,10 @@ pub struct Span { pub paint_order: PaintOrder, /// The font size of the span. pub font_size: NonZeroPositiveF32, + /// Font variation settings for variable fonts. + pub variations: Vec, + /// Font optical sizing mode. + pub font_optical_sizing: crate::FontOpticalSizing, /// The visibility of the span. pub visible: bool, /// The glyphs that make up the span. @@ -343,6 +352,8 @@ pub(crate) fn layout_text( stroke: span.stroke.clone(), paint_order: span.paint_order, font_size: span.font_size, + variations: span.font.variations.clone(), + font_optical_sizing: span.font_optical_sizing, visible: span.visible, positioned_glyphs, underline, @@ -899,6 +910,9 @@ fn process_chunk( font, span.small_caps, span.apply_kerning, + &span.font.variations, + span.font_size.get(), + span.font_optical_sizing, resolver, fontdb, ); @@ -1281,11 +1295,23 @@ pub(crate) fn shape_text( font: Arc, small_caps: bool, apply_kerning: bool, + variations: &[crate::FontVariation], + font_size: f32, + font_optical_sizing: crate::FontOpticalSizing, resolver: &FontResolver, fontdb: &mut Arc, ) -> Vec { - let mut glyphs = shape_text_with_font(text, font.clone(), small_caps, apply_kerning, fontdb) - .unwrap_or_default(); + let mut glyphs = shape_text_with_font( + text, + font.clone(), + small_caps, + apply_kerning, + variations, + font_size, + font_optical_sizing, + fontdb, + ) + .unwrap_or_default(); // Remember all fonts used for shaping. let mut used_fonts = vec![font.id]; @@ -1314,6 +1340,9 @@ pub(crate) fn shape_text( fallback_font.clone(), small_caps, apply_kerning, + variations, + font_size, + font_optical_sizing, fontdb, ) .unwrap_or_default(); @@ -1371,10 +1400,49 @@ fn shape_text_with_font( font: Arc, small_caps: bool, apply_kerning: bool, + variations: &[crate::FontVariation], + font_size: f32, + font_optical_sizing: crate::FontOpticalSizing, fontdb: &fontdb::Database, ) -> Option> { fontdb.with_face_data(font.id, |font_data, face_index| -> Option> { - let rb_font = rustybuzz::Face::from_slice(font_data, face_index)?; + let mut rb_font = rustybuzz::Face::from_slice(font_data, face_index)?; + + // Build the list of variations to apply + let mut final_variations: Vec = variations + .iter() + .map(|v| rustybuzz::Variation { + tag: Tag::from_bytes(&v.tag), + value: v.value, + }) + .collect(); + + // Automatic optical sizing: if font-optical-sizing is auto and the font has + // an 'opsz' axis that isn't explicitly set, auto-set it to match font size. + // This matches browser behavior (CSS font-optical-sizing: auto). + if font_optical_sizing == crate::FontOpticalSizing::Auto { + let has_explicit_opsz = variations.iter().any(|v| v.tag == *b"opsz"); + if !has_explicit_opsz { + // Check if font has opsz axis using the already parsed rb_font + if let Some(axes) = rb_font.tables().fvar { + let has_opsz_axis = axes + .axes + .into_iter() + .any(|axis| axis.tag == ttf_parser::Tag::from_bytes(b"opsz")); + if has_opsz_axis { + final_variations.push(rustybuzz::Variation { + tag: Tag::from_bytes(b"opsz"), + value: font_size, + }); + } + } + } + } + + // Apply font variations for variable fonts + if !final_variations.is_empty() { + rb_font.set_variations(&final_variations); + } let bidi_info = unicode_bidi::BidiInfo::new(text, Some(unicode_bidi::Level::ltr())); let paragraph = &bidi_info.paragraphs[0]; diff --git a/crates/usvg/src/tree/text.rs b/crates/usvg/src/tree/text.rs index 10898bb3c..c1739b901 100644 --- a/crates/usvg/src/tree/text.rs +++ b/crates/usvg/src/tree/text.rs @@ -66,6 +66,39 @@ impl From for fontdb::Stretch { } } +/// A font variation axis setting. +/// +/// Used for variable fonts to specify axis values like weight, width, etc. +#[derive(Clone, Copy, Debug)] +pub struct FontVariation { + /// The 4-byte axis tag (e.g., b"wght" for weight). + pub tag: [u8; 4], + /// The axis value. + pub value: f32, +} + +impl FontVariation { + /// Creates a new font variation. + pub fn new(tag: [u8; 4], value: f32) -> Self { + Self { tag, value } + } +} + +impl PartialEq for FontVariation { + fn eq(&self, other: &Self) -> bool { + self.tag == other.tag && self.value.to_bits() == other.value.to_bits() + } +} + +impl Eq for FontVariation {} + +impl std::hash::Hash for FontVariation { + fn hash(&self, state: &mut H) { + self.tag.hash(state); + self.value.to_bits().hash(state); + } +} + /// A font style property. #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] pub enum FontStyle { @@ -113,6 +146,7 @@ pub struct Font { pub(crate) style: FontStyle, pub(crate) stretch: FontStretch, pub(crate) weight: u16, + pub(crate) variations: Vec, } impl Font { @@ -137,6 +171,11 @@ impl Font { pub fn weight(&self) -> u16 { self.weight } + + /// Font variation settings for variable fonts. + pub fn variations(&self) -> &[FontVariation] { + &self.variations + } } /// A dominant baseline property. @@ -218,6 +257,24 @@ impl Default for LengthAdjust { } } +/// A font optical sizing property. +/// +/// Controls automatic adjustment of the `opsz` axis in variable fonts +/// based on font size. Matches CSS `font-optical-sizing`. +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum FontOpticalSizing { + /// Automatically set `opsz` to match font size (browser default). + Auto, + /// Do not automatically adjust `opsz`. + None, +} + +impl Default for FontOpticalSizing { + fn default() -> Self { + Self::Auto + } +} + /// A text span decoration style. /// /// In SVG, text decoration and text it's applied to can have different styles. @@ -281,6 +338,7 @@ pub struct TextSpan { pub(crate) font_size: NonZeroPositiveF32, pub(crate) small_caps: bool, pub(crate) apply_kerning: bool, + pub(crate) font_optical_sizing: FontOpticalSizing, pub(crate) decoration: TextDecoration, pub(crate) dominant_baseline: DominantBaseline, pub(crate) alignment_baseline: AlignmentBaseline, @@ -346,6 +404,15 @@ impl TextSpan { self.apply_kerning } + /// Font optical sizing mode. + /// + /// When `Auto` (default), the `opsz` axis will be automatically set + /// to match the font size for variable fonts that support it. + /// This matches the CSS `font-optical-sizing: auto` behavior. + pub fn font_optical_sizing(&self) -> FontOpticalSizing { + self.font_optical_sizing + } + /// A span decorations. pub fn decoration(&self) -> &TextDecoration { &self.decoration