Skip to content

Always push wght variation coordinates - #1099

Merged
LaurenzV merged 4 commits into
linebender:mainfrom
arx-ein:font-weight-fix
Aug 1, 2026
Merged

Always push wght variation coordinates#1099
LaurenzV merged 4 commits into
linebender:mainfrom
arx-ein:font-weight-fix

Conversation

@arx-ein

@arx-ein arx-ein commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Variable font support introduced in #997 skips the font-weight CSS property -> OpenType wght variation axis mapping whenever the weight is 400, on the assumption that a variable font's default instance is always Regular (wght = 400). In crates/usvg/src/parser/text.rs's convert_font:

if !has_wght && weight != 400 {
    variations.push(FontVariation::new(*b"wght", weight as f32));
}

The problem is that this assumption doesn't hold for every variable font. Google's Noto CJK variable fonts, for example, default to Thin. In such case, font-weight: 400 or unstyled text render at the font's Thin instance instead of Regular, while every other weight value (100, 200, 300, 500...) is mapped and renders correctly.

This PR fixes this problem by dropping the weight != 400 condition, keeping the existing !has_wght check so font-variation-settings: "wght" ... still takes precedence. It also adds a font-level has_variation_axes check so this doesn't regress the glyph-outline cache for static fonts.

Details

Why the mapping should be unconditional

CSS Fonts Module Level 4 doesn't carve out an exception for weight 400. The property-to-axis mapping table (https://www.w3.org/TR/css-fonts-4/#font-variation-settings-def) in the font-variation-settings section is unconditional:

Weight (wght) → font-weight → The font-weight property will set the wght axis if one is present.

And the normative precedence algorithm (https://www.w3.org/TR/css-fonts-4/#feature-variation-precedence) confirms the intended layering: font-weight always applies at step 2, and an explicit font-variation-settings only overrides it because it's applied later, at step 12, not because font-weight abstains at any particular value:

  1. Font variations enabled by the font-weight, font-width, and font-style properties are applied.

...

  1. Font variations implied by the value of the font-variation-settings property are applied.

For situations where the combined list of font feature settings contains more than one value for the same feature, the last value is used.

Avoiding an outline-cache regression

The glyph-outlining path in crates/usvg/src/text/flatten.rs uses !span.variations.is_empty() to decide whether it's appropriate to use the non-variable (font ID, glyph ID) outline cache. Simply dropping the weight != 400 guard means span.variations is non-empty for every span by default, not just for non-400 weights, since the default CSS weight is 400. Left alone, this change would make every span bypass that cache, including plain static fonts like Arial that have no fvar table and get no benefit from the variation machinery.

To avoid that, this PR adds a second, font-level check, has_variation_axes, memoized per font ID the same way the existing has_opsz_axis already is:

pub(crate) fn has_variation_axes(&mut self, font: ID) -> bool {
    if let Some(&cached) = self.cache_has_axes.get(&font) {
        return cached;
    }
    let has_axes = self.fontdb.has_variation_axes(font);
    self.cache_has_axes.insert(font, has_axes);
    has_axes
}

and gates the cache bypass on it:

let needs_variations = (has_explicit_variations && cache.has_variation_axes(glyph.font))
    || (span.font_optical_sizing == crate::FontOpticalSizing::Auto && cache.has_opsz_axis(glyph.font));

Static (non-variable) fonts now go right back through the ordinary outline path, same as before this change existed. This mirrors has_opsz_axis's existing logic.

Verification

Rendered with a small custom CLI wrapper built on krilla, a SVG-to-PDF library which depends on usvg, recommended as the successor to svg2pdf (a separate, now-unmaintained crate). svg2pdf itself couldn't be used to check this, since it's pinned to a usvg version before variable font support. The wrapper's --text-to-paths flag (mirroring svg2pdf's own CLI) makes the PDF consume usvg's flattened glyph outlines directly instead of embedding fonts, which is the only way to visually exercise the exact flatten.rs code this PR touches; the default embedded-font mode instead confirms the CSS weight -> wght mapping in text.rs (shared by both paths) is picked up correctly on the embedding/shaping side.

Tested with a ladder SVG containing font-weight 100 through 900, a line with no font-weight attribute (implicit 400), and a line using font-variation-settings: 'wght' 700, all set in Noto Sans JP, and visually inspected the output in both modes.

Ladder SVG Before fix After fix
weights weights-krilla weights-krilla-patched

Before the fix, weight 400 and unstyled text were rendered as weight 100 (Thin); every other weight already worked. After the fix, those two instances are rendered correctly as intended. Also checked a static-font case (Arial at 400/700/default) to confirm no visual change there.

Test coverage

I haven't added an automated reftest yet. The repo's variable-font test asset, RobotoFlex.subset.ttf, defaults to wght 400, so it can't reproduce this specific bug (only fonts whose fvar default isn't 400 can). Reproducing it in an automated test would need a variable font with a non-400 default instance, which isn't currently in the test-fonts set. Happy to add a subsetted NotoSansJP-VF.ttf (OFL-licensed, same as the other Noto fonts already bundled) if that's an acceptable way to cover this, or to follow whatever approach you'd prefer.

arx-ein added 2 commits July 24, 2026 16:16
…apping logic

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@arx-ein

arx-ein commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Since I stumbled upon this problem while using svg2pdf and then found krilla for that matter, I was not aware that resvg has its own in-house svg -> png conversion CLI tool and made an unnecessary round-trip around pdf conversion for verifying this fix. I redid the test with the same svg file this time with resvg's own CLI and the results are the same as shown below.

Before fix (3fe0c34) After fix (22b6876)
weights-resvg weights-resvg-patched

@LaurenzV

Copy link
Copy Markdown
Collaborator

The bug seems real, but we are currently in the process of porting resvg to skrifa (see #922), since that PR has been rebased too often already, we will likely wait until that has been merged before merging this. Hope you understand!

@arx-ein

arx-ein commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

The "Avoiding an outline-cache regression" section has become obsolete since the caching logic has changed through the skrifa port. Now this PR only includes the font-weight -> wght axis mapping change.

@LaurenzV LaurenzV changed the title Map font-weight to the wght axis even at 400 (variable fonts whose default instance isn't Regular render the wrong weight) Always push weight variation coordinates Aug 1, 2026
@LaurenzV LaurenzV changed the title Always push weight variation coordinates Always push wght variation coordinates Aug 1, 2026
@LaurenzV

LaurenzV commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Yep I think it should be fine now. Thank you!

@LaurenzV
LaurenzV merged commit 35679dc into linebender:main Aug 1, 2026
5 checks passed
@arx-ein
arx-ein deleted the font-weight-fix branch August 1, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants