Skip to content

Variable fonts with named instances fail to load: fontconfig's encoded face index (instance<<16) is rejected by swash, then misreported as "font not found" #1913

Description

@YellowWhiteBlackCat

A note on how this report was made: I ran into this bug myself while configuring rio. The analysis, probe code and evidence below were put together with the help of AI-assisted tools, so please treat it as a best-effort diagnosis, offered for reference — every log line and probe output comes from my machine and is reproducible with the snippets provided, but do verify the conclusions against the source.

Environment

rio 0.5.26 (distro package), Wayland/KDE, Arch Linux (CachyOS)
fontconfig 2.18.3
Locked deps (rio v0.5.26 Cargo.lock) swash 0.2.10, font-kit 0.14.3, ttf-parser 0.25.1 — the exact same versions are used in every probe below
Font triggering the bug Xiaomi MiSans VF (MiSansVF.ttf, official zip): single-face variable TTF, fvar with 10 named instances (wght 150–700)

Analysis

With symbol-map entries pointing at a family backed by a VF that has named instances, rio pops a GUI warning "font family not found" on startup (nothing on stdout/stderr). The chain, link by link:

  1. fontconfig lists each VF named instance as a separate face whose FC_INDEX follows the FreeType convention named_instance << 16 (e.g. 393216 = 6<<16). Intentional, spec-abiding, meant for FreeType-compatible consumers.
  2. font-kit (loader.rs via SystemSource::select_family_by_name) passes the encoded index through unchanged, and reports the OS/2 weight (400) for every face of the VF — the candidates are indistinguishable. find_best_match then takes whichever face fontconfig happened to list first; ordering is not guaranteed and the index=0 default face gets no priority. For MiSans VF the default face was listed 6th; the winner carried index=393216.
  3. FontData::from_data (sugarloaf/src/font/mod.rs ~L1362) calls swash::FontRef::from_index(data, 393216)None (non-TTC file + nonzero index). The runtime error string Failed to load font from path comes exactly from the ok_or_else wrapping this call.
  4. The Err becomes FindResult::NotFound, lands in fonts_not_found, and surfaces as the misleading "font not found" dialog.

Why it looks random: the outcome hinges on fontconfig's face ordering plus a weight tie, both outside rio's control. My Google Sans Code (also a VF, 6 named instances) loads fine only because its default face happens to be listed first on this machine. Reordering/caching differences flip the result — matching intermittent reports like #1818 (Google Sans Code VF failing elsewhere) and issues closed as "works with static fonts".

Reproduction

  1. Install any single-face variable font with named instances (e.g. MiSans VF).
  2. config.toml:
    [fonts]
    symbol-map = [
      { start = "2E80", end = "2FFF", font-family = "MiSans VF" },
      { start = "4E00", end = "9FFF", font-family = "MiSans VF" },
    ]
  3. RIO_LOG_LEVEL=debug rio → warning dialog; log shows the evidence chain below.
  4. Point symbol-map at a static-weight family instead → loads fine.

Verification code (probes, rio-identical dependency versions)

Probe 1 — fontconfig returns encoded indexes:

$ fc-match "MiSans VF"                --format "%{index}\n"    # → 0
$ fc-match "MiSans VF:style=Semibold" --format "%{index}\n"    # → 524288  (8<<16)
$ fc-match "MiSans VF:weight=200"     --format "%{index}\n"    # → 589824  (9<<16)
$ fc-match "Google Sans Code:weight=400" --format "%{index}\n" # → 393216 (6<<16)

Probe 2 — font-kit candidates (order / index / weight):

let family = SystemSource::new().select_family_by_name(name)?;
for h in family.fonts() {
    if let Handle::Path { path, font_index } = h {
        let f = h.load()?;
        println!("index={font_index} weight={} {}", f.properties().weight.0, path.display());
    }
}
"Google Sans Code": [0] index=0  w=400   ← default face listed first → survives
                    [1..5] index=65536..393216, all w=400 (6 named instances)
"MiSans VF":        [0] index=393216 w=400   ← an instance listed first → breaks
                    [5] index=0  w=400       ← default face buried at position 6
                    (11 faces, all w=400)
"Noto Sans SC":     7 faces, most with encoded indexes → equally at risk

Probe 3 — swash 0.2.10 rejects every nonzero encoded index:

let data = std::fs::read("MiSansVF.ttf")?;
for idx in [0u32, 65536, 262144, 393216, 524288, 589824] {
    println!("{idx}: {}", swash::FontRef::from_index(&data, idx as usize).is_some());
}
// 0: true   65536: false   262144: false   393216: false   524288: false   589824: false

Probe 4 — ttf-parser 0.25.1 behaves identically (Err(FaceIndexOutOfBounds) for any nonzero index, Ok only for 0) — i.e. this is the Rust parsing ecosystem's norm, not a swash-only quirk: FreeType's named-instance index convention is simply not implemented there, so the integration layer must normalize it.

Evidence (rio's own log, real run on my machine)

Failing family — candidates all tied at weight=400, match lands on encoded index 393216, load rejected:

INFO  sugarloaf::font: Font search: 'Query { families: [Name("MiSans VF")], weight: Weight(400), stretch: Normal, style: Normal }'
DEBUG sugarloaf::font::loader: found candidate: weight=400, stretch=Stretch(1.0), style= Normal   (×10, identical)
DEBUG sugarloaf::font::loader: Best match selected at index 0
DEBUG sugarloaf::font::loader: face_source: Path source - /usr/share/fonts/misans-vf/MiSansVF.ttf, index 393216
INFO  sugarloaf::font: Failed to load font 'Query { families: [Name("MiSans VF")], ... }', Failed to load font from path: "/usr/share/fonts/misans-vf/MiSansVF.ttf"

Same machine, working family (Google Sans Code): identical query shape, winner has index 0Font 'Google Sans Code' found in ....

Suggested fixes (any one, all local to sugarloaf)

  1. Normalize before parsing: face_index & 0xFFFF — or retry with 0 on load failure (renders the default instance, same face fontconfig's default entry refers to);
  2. In find_best_match, break weight ties in favor of the index == 0 default face;
  3. At minimum, report "failed to load face (index N)" instead of "font family not found" — the current wording sends users hunting for missing fonts.

Workaround: point symbol-map/families at a static-weight family (verified working here); the VF family remains unusable in rio.

Related issues

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions