Skip to content

font-weight 800/900 is painted as 700 while measured at its real weight, so text wraps over its next sibling when several weights are loaded #466

Description

@ThomasTartrau

The text painter collapses every numeric font-weight of 600 or more to Bold, i.e. it asks for weight 700, while the text measurer asks for the exact weight. When several weights of a family are loaded, the two resolve different font files:

  • font-weight: 800 / 900 is painted in the wrong weight (700, or whatever registered weight is closest to 700);
  • the box is measured with the requested face and painted with another, slightly wider one, so a text that fits on one line wraps at paint time while its box keeps a single-line height: the second line is drawn over the next sibling.

crates/rustmotion-components/src/text.rs (paint):

let font_weight = match &self.style.font_weight {
    Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => FontWeight::Bold,
    Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold,
    Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n),
    _ => FontWeight::Normal,
};
// ...
FontWeight::Bold => skia_safe::font_style::Weight::BOLD,   // 700

crates/rustmotion-components/src/intrinsic.rs (measure): weight: weight_to_u16(style.font_weight.as_ref()), then Weight::from(self.weight as i32), i.e. the exact 800 or 900.

Both go through typeface_with_fallback -> closest_variant, which picks the registered weight with the smallest |v.weight - requested|, the first registered one on a tie.

Reproduction

{
  "version": "1.0",
  "video": { "width": 1920, "height": 1080, "fps": 30, "background": "#FFFFFF" },
  "fonts": [{ "family": "Inter", "source": "google", "weights": [800, 900] }],
  "scenes": [{
    "duration": 1.0,
    "children": [{
      "type": "div", "position": "absolute", "x": 0, "y": 0,
      "style": { "width": 1920, "height": 1080, "flex-direction": "column", "align-items": "center", "justify-content": "center" },
      "children": [
        { "type": "text", "content": "UN SEUL COMPTE", "style": { "font-family": "Inter", "font-weight": 900, "font-size": 150, "color": "#000000", "line-height": 1.0 } },
        { "type": "text", "content": "NEXT LINE", "style": { "font-family": "Inter", "font-weight": 900, "font-size": 96, "color": "#3366CC", "line-height": 1.0 } }
      ]
    }]
  }]
}

With "weights": [900] the title renders on one line. With [800, 900] it renders as UN SEUL / COMPTE, and COMPTE is drawn on top of NEXT LINE.

Same scenario, varying only fonts[0].weights (render --frame 0):

loaded weights title ink rows laid-out box (background) painted face (ink pixels, nowrap)
[900] 436-547 (1 line) 1331 x 149 39 966 (900)
[400, 900] 436-547 (1 line)
[500, 900] 436-696 (2 lines) 1331 x 149 23 463 (500, tie with 900 at distance 200, first registered wins)
[800, 900] 436-697 (2 lines) 1331 x 149 35 518 (800)
[400 ... 900] 436-697 (2 lines) 31 069 (700)

The box is the same in every case (measured with the 900 face). Static Inter files are near-equal in advance width across weights (UN SEUL COMPTE at 150px: 900 = 1332px, 800 = 1335px, 700 = 1335px), so the painted face is a few pixels wider than the box and the line breaks.

letter-spacing plays no part: the table above is with letter-spacing: 0. The visible symptom first showed up on titles with negative tracking, which is why it looked tracking-related.

Expected

The painter resolves the same weight as the measurer: a numeric font-weight is passed through as-is (Weight::from(n)), and only the bold/bolder keywords map to 700. The >= 600 => Bold arm looks like a leftover from a two-weight model.

Activity

  1. self-assigned this
    on Oct 1, 2026
  2. LeadcodeDev commented on Oct 1, 2026

    @LeadcodeDev
    Owner

    Working on this.

  3. LeadcodeDev commented on Oct 1, 2026

    @LeadcodeDev
    Owner

    Mini-spec

    Sized M, not S: the collapse is in five files, not one, and measuring the other direction turned up two more divergences the issue does not mention.

    The full divergence. Measurement resolves through intrinsic.rs::weight_to_u16, painting through a match repeated at six sites. They disagree on three inputs, not one:

    declared measured painted
    a number >= 600 that number 700
    bolder 800 700
    lighter 300 400

    gradient_text already passes numbers through, so it is right on the first row and wrong on the other two. caption resolves straight to a skia Weight with no FontWeight in between. The >= 600 arm is in text, caption, counter, number_wheel and rich_text.

    Shape. One function, renderer::css_font_weight(Option<&CssFontWeight>) -> skia::Weight, is the only thing that turns a declared weight into a resolved one. The six paint sites and the measure site call it; none of them keeps a match. The invariant stops being something tests check and becomes something there is only one place to express.

    Invariants.

    • Measurement and painting resolve the same declared weight to the same number, for every input, because they call the same function.
    • A numeric weight is passed through, clamped to 1..1000. Only bold maps to 700.

    Out of scope. shape.rs::text_in_shape_font_style matches on the schema FontWeight, which is a different input deserialised from a shape's embedded text config, not a CssStyle. Its mapping is already exact and it keeps FontWeight::to_skia_weight.

    Acceptance.

    1. The issue's scenario renders the title on one line with weights: [800, 900], as it already does with [900].
    2. font-weight: 900 selects the 900 face when 800 and 900 are both registered: the painted ink mass matches the [900] render.
    3. bolder resolves to 800 and lighter to 300 on both sides.
    4. A numeric weight below 600 is unchanged, so the fix does not move what already worked.

    Verify (exit). cargo fmt --all --check; cargo clippy --workspace --all-targets --features rustmotion/studio -- -D warnings; cargo test --workspace --features rustmotion/studio.

    Reproduced offline on main (Inter 400-900 are already cached, so nothing is fetched). Title ink rows, same scenario, varying only fonts[0].weights:

    loaded title rows lines black ink px
    [900] 436-548 1 80885
    [800, 900] 436-698 2 67713
    [500, 900] 436-698 2 46412
    [400 ... 900] 436-698 2 60161

    [500, 900] carrying the least ink is the tie at distance 200 resolving to the first registered variant, as the issue says.

  4. LeadcodeDev commented on Oct 1, 2026

    @LeadcodeDev
    Owner

    Fixed in #469.

    The collapse was in five files, not one, and measuring the other direction turned up two divergences the issue does not mention: bolder measured 800 and painted 700, lighter measured 300 and painted 400. gradient_text was already right on the numeric case and wrong on both keywords, so fixing only the >= 600 arm would have left two thirds of it standing.

    All seven resolutions — six painters plus intrinsic.rs::weight_to_u16 — now go through one function, renderer::css_font_weight. The agreement between measuring and painting is no longer a rule each painter has to remember.

    Verified on your scenario, offline (Inter 400-900 are already cached, nothing is fetched). Title ink rows and black ink mass, varying only fonts[0].weights:

    loaded before after
    [900] 436-548, 1 line, 80885 436-548, 1 line, 80885
    [800, 900] 436-698, 2 lines, 67713 436-548, 1 line, 80885
    [500, 900] 436-698, 2 lines, 46412 436-548, 1 line, 80885
    [400 … 900] 436-698, 2 lines, 60161 436-548, 1 line, 80885

    The identical ink mass is the part that matters: it shows the painted face is the 900 one, not just that the wrap stopped.

    Four tests, none needing a font file or the network: a table over every input of css_font_weight including the 1..1000 clamp; one that registers two faces at 800 and 900 and asserts 900 reaches the 900 one while asking for 700 lands on the 800 face; and two on Caption::resolve_font_style, a real paint path, over 600/700/800/900 and over bolder/lighter. The two tests that already existed there covered only bold and 350, which is why they never caught this. Full suite: 2150 passed, 0 failed.

    Left alone: shape::text_in_shape_font_style and rich_text's per-span font_weight, which take the schema FontWeight deserialised from JSON rather than a re-encoding of a CssStyle. Their mapping was already exact.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions