Skip to content

fix(text): resolve sans-serif to Helvetica on macOS, not a face with full-width Cyrillic - #354

Merged
sidorares merged 1 commit into
masterfrom
claude/fallback-advances
Sep 12, 2026
Merged

fix(text): resolve sans-serif to Helvetica on macOS, not a face with full-width Cyrillic#354
sidorares merged 1 commit into
masterfrom
claude/fallback-advances

Conversation

@sidorares

@sidorares sidorares commented Sep 12, 2026

Copy link
Copy Markdown
Owner

A map label set with app.fonts.layout in sans-serif on the X11 backend came out as "М о с т": each Cyrillic letter followed by a gap. The report suspected a fallback face's advances being scaled with the primary face's units per em. They are not. What ntk draws is exactly what the face says; the face is the wrong one.

What is going on

  • On a Mac where Homebrew's fontconfig is the fc-match on PATH, fc-match sans-serif answers Hiragino Sans, a Japanese face. It covers Cyrillic itself, so no fallback happens at all.
  • Hiragino maps Cyrillic, Greek, and to full-width forms: every advance in "Мост" is 1000 of its 1000 units per em. ntk's shaped advances equal the face's own hmtx exactly, 22 px each at size 22, 88 px for the word, and CoreText sets the same word in the same face at exactly 88.0 px. Only the opt-in palt feature narrows them, and browsers do not turn it on.
  • Why Hiragino: Homebrew's fontconfig 2.18 ships 48-guessfamily.conf. Under it, a sans-serif query ranks every face with "Sans" in its name ahead of all of 60-latin.conf's preferences, and Verdana, Arial and Helvetica come in 111th to 114th. Removing that one file from the config makes the same query answer Verdana, and XQuartz's own fontconfig 2.17.1, which ships without it, answers Verdana too.
  • It is not only Cyrillic: ή fell back to Comic Sans MS, the next "…Sans…" face on that list, and Hiragino's Greek, ellipsis and em dash were full-width as well.

The fix

On darwin, the family list ntk hands fc-match names Helvetica ahead of sans-serif, which is what Safari, Chrome and Firefox resolve sans-serif to on macOS. The generic stays after it, so a character Helvetica lacks still falls back through fontconfig's own sans-serif list, and CJK still lands on Hiragino. serif and monospace resolve to PT Serif and Andale Mono there, which set Cyrillic at their own widths, so they are left alone, as is every other platform. The change is in patternFor, which the prewarm and the synchronous lookup share, so the two still agree on the cache key.

Rendered by this branch, headless: node-x11's in-process X server, the default font source, TextLayout.draw into a pixmap, read back with getImageData.

Before, on master: Hiragino Sans, "Мост" at a 22 px pitch, ή from Comic Sans MS.

before

After: Helvetica, "Мост" 52.6 px wide as its own advances say, while 橋 and 字 still come from Hiragino.

after

Tests

In test/fontconfig.test.js:

  • platformFamilies: the darwin mapping, a family list around the generic, case, and a list that already names Helvetica first; serif and monospace untouched; Linux, FreeBSD and Windows untouched.
  • The pattern fc-match actually receives, through the prewarm and then the sync lookup, via a stub on the child's PATH: Helvetica,sans-serif:weight=80 on macOS and sans-serif:weight=80 elsewhere, so CI checks the Linux half.
  • Live on macOS, skipped elsewhere: sans-serif resolves to Helvetica, "Мост" is one run as wide as Helvetica's own advances within a pixel and well under four em, and 字 falls back to a face that covers it. This one fails on master, where the face is Hiragino and the word is 88 px.

Full suite on Xvfb: 1181 pass, 7 skip, 0 fail. CI runs on Linux, where none of this changes behaviour.

…full-width Cyrillic

Homebrew's fontconfig answers sans-serif with Hiragino Sans on a Mac, whose
Cyrillic, Greek, ellipsis and em dash are full-width, so map labels such as
Мост were set as four em-wide cells. The advances were the font's own, and
CoreText sets that face the same way. On darwin the pattern handed to
fc-match now names Helvetica ahead of the generic, as browsers resolve it
there, and CJK still falls back to Hiragino through the generic.
@sidorares
sidorares merged commit 8af0711 into master Sep 12, 2026
6 checks passed
@sidorares
sidorares deleted the claude/fallback-advances branch September 12, 2026 22:44
sidorares pushed a commit that referenced this pull request Sep 12, 2026
🤖 I have created a release *beep* *boop*
---


## [8.8.2](v8.8.1...v8.8.2)
(2026-09-12)


### Bug Fixes

* **gl:** ask CGL for a stencil buffer — every stencil test passed on
XQuartz ([#353](#353))
([4abff02](4abff02))
* **text:** resolve sans-serif to Helvetica on macOS, not a face with
full-width Cyrillic
([#354](#354))
([8af0711](8af0711))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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.

1 participant