Repository navigation
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
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/900is 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, // 700crates/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
LeadcodeDev commentedon Oct 1, 2026
Working on this.
LeadcodeDev commentedon Oct 1, 2026
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 amatchrepeated at six sites. They disagree on three inputs, not one:bolderlightergradient_textalready passes numbers through, so it is right on the first row and wrong on the other two.captionresolves straight to a skiaWeightwith noFontWeightin between. The>= 600arm is intext,caption,counter,number_wheelandrich_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 amatch. The invariant stops being something tests check and becomes something there is only one place to express.Invariants.
boldmaps to 700.Out of scope.
shape.rs::text_in_shape_font_stylematches on the schemaFontWeight, which is a different input deserialised from a shape's embedded text config, not aCssStyle. Its mapping is already exact and it keepsFontWeight::to_skia_weight.Acceptance.
weights: [800, 900], as it already does with[900].font-weight: 900selects the 900 face when 800 and 900 are both registered: the painted ink mass matches the[900]render.bolderresolves to 800 andlighterto 300 on both sides.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 onlyfonts[0].weights:[900][800, 900][500, 900][400 ... 900][500, 900]carrying the least ink is the tie at distance 200 resolving to the first registered variant, as the issue says.LeadcodeDev commentedon Oct 1, 2026
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:
boldermeasured 800 and painted 700,lightermeasured 300 and painted 400.gradient_textwas already right on the numeric case and wrong on both keywords, so fixing only the>= 600arm 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:[900][800, 900][500, 900][400 … 900]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_weightincluding 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 onCaption::resolve_font_style, a real paint path, over 600/700/800/900 and overbolder/lighter. The two tests that already existed there covered onlyboldand 350, which is why they never caught this. Full suite: 2150 passed, 0 failed.Left alone:
shape::text_in_shape_font_styleandrich_text's per-spanfont_weight, which take the schemaFontWeightdeserialised from JSON rather than a re-encoding of aCssStyle. Their mapping was already exact.