Ship your own fonts
Your app wants to render in a face nobody’s machine has. Put the .ttf in the payload with
gjsify.ship.fonts, then call initFonts() once before you build any UI.
Two steps, because staging a file and having the toolkit know about it are different
things. The gap between them is the quietest failure in GTK: Pango does not report a missing
family. set_family('Brand') against a font map that has never heard of Brand resolves to
the default sans, the window draws and the process exits 0. Your app is just wearing the
wrong typeface.
Measured, and the detail matters because it is the only thing that ever tells you: the
win32 backend does print couldn't load font "Brand …", falling back to "Sans …" at
layout time, while the fontconfig host printed nothing at all for the same invented family.
A line on stderr on one platform, silence on the other, and neither is a value your code can
branch on — which is why initFonts() returns the answer instead (§ Check that it
worked).
1. Stage the faces
Section titled “1. Stage the faces”{ "gjsify": { "ship": { "fonts": "data/fonts" // a directory, or a single face } }}data/fonts is also the default: leave the key out and a directory by that name is picked
up if it exists. Every face lands at share/fonts/<appId>/<basename> in the payload, on all
three layouts. The app id is in the path because /usr/share/fonts is shared with every
other package on the machine, and a Regular.ttf at the top of it belongs to whoever
installed last.
Ship the desktop formats: .ttf, .otf, .ttc, .otc. A .woff, .woff2 or .eot is
refused by name, not quietly skipped. Whether FreeType can open one is a build option of
the FreeType inside the shipped runtime, not the one on your packaging machine, so a web
wrapper that works here is exactly the kind of thing that substitutes silently there.
Two other refusals, same reasoning: a configured path that holds no face at all, and two faces whose basenames collide. One of the two would not ship, and a face that did not ship is a substituted typeface rather than an error.
2. Register them at startup
Section titled “2. Register them at startup”gjsify ship’s launcher exports GJSIFY_FONT_DIR at the staged directory, on every
layout. Only the launcher knows whether your payload became /usr, a --prefix tree,
/app, a bundle’s Contents/Resources or C:\Program Files\My App. Reading it is your
app’s side of the handover, and @gjsify/gtk-host does it for you:
gjsify install @gjsify/gtk-hostimport { initFonts } from '@gjsify/gtk-host/fonts';
const fonts = initFonts();That is the whole API. It reads GJSIFY_FONT_DIR itself, walks the directory, hands every
face to PangoCairo.FontMap.get_default().add_font_file(), and returns what happened:
interface InitFontsResult { /** The APPLICATION's font directory that was read, or `undefined` when nothing named one. */ dir: string | undefined; /** Every directory registered, runtime first — see below. */ sources: readonly { dir: string; origin: 'runtime' | 'app' }[]; /** What the UI-font policy did, or `undefined` when none was asked for. See below. */ uiFont: { next: string | undefined; kind: 'raised' | 'family' | 'restored' | 'kept' | 'unparsed' } | undefined; /** Faces now on the default font map. */ registered: readonly string[]; /** Faces a font map that does no runtime registration refused. See below. */ declined: readonly string[]; /** Faces that failed for any other reason. Each one was also warned about on stderr. */ failed: readonly { path: string; message: string }[]; /** The family NAMES the map gained across this call, sorted. See below. */ families: readonly string[]; /** What each `expectedFamilies` name resolves to on the map. Empty when you declared none. */ matches: readonly FontFamilyMatch[];}registered is a list of FILES, and no font-family declaration takes one. The last two
fields are the ones you act on: families is a diff of list_families() across the call —
which only the registering call is in a position to take — and matches answers your own
names against the map. Pass them in:
const fonts = initFonts({ expectedFamilies: ['Merriweather', 'Source Sans 3'] });Every name that does not resolve exactly is warned about on stderr on the spot, which is the one place a missing family is ever loud.
It never throws. A face that will not open costs you one stderr line and one entry in
failed; taking the app down over a decorative typeface would be worse than drawing it in
a fallback. What it will not do is fail silently, which is the entire point:
initFonts: /tmp/gjsify-badface-MHB3U3/NotAFont.ttf could not be read as an application font(Adding font /tmp/gjsify-badface-MHB3U3/NotAFont.ttf to fontconfig configuration failed).Text asking for a family this application ships will render in a substituted one.It is also safe in an app that ships no fonts. The launcher exports GJSIFY_FONT_DIR only
when it actually staged a face, so an unset variable gives you dir: undefined and empty
arrays — and with no expectedFamilies either, the call returns without reading the font map
at all, so nothing about it is a cost you pay for shipping no faces.
Running from your source tree
Section titled “Running from your source tree”Before there is a payload there is no GJSIFY_FONT_DIR. Pass the directory instead. The
option wins over the environment, so one line covers both:
import GLib from 'gi://GLib?version=2.0';import { initFonts } from '@gjsify/gtk-host/fonts';
const fonts = initFonts({ fontDir: GLib.getenv('GJSIFY_FONT_DIR') ?? 'data/fonts' });print(`dir=${fonts.dir} registered=${fonts.registered.length} failed=${fonts.failed.length}`);Run from a project whose data/fonts holds one face:
dir=data/fonts registered=1 failed=0A relative path resolves against the working directory. That is what you want in a source
tree and never what you want in a shipped app, where GJSIFY_FONT_DIR is absolute and takes
over.
3. Call it before you build any UI
Section titled “3. Call it before you build any UI”This is a contract, not a style preference.
Registration is not retroactive. The fontconfig-backed font map caches the fontset it
resolved for a font description, and add_font_file does not invalidate that cache. So a
Pango.Layout that measured your family before the call keeps measuring the fallback for
the life of the process, even though list_families() now lists the family, and even though
a context created afterwards loads the real face.
Measured, on Linux, with one layout before the call and one after:
before initFonts: listed=false measured=87x63initFonts: registered=1 declined=0 failed=0after initFonts: listed=true measured=87x63control (invented family): 87x63Read the last three lines together. registered=1 succeeded. listed=true says the family
is on the font map. And the text still measures 87x63, byte for byte what a family that
does not exist measures. The symptom is not “no font”; it is a stale measurement, which
from inside your app reads like “the font is installed and Pango is ignoring me”.
On Windows a late call recovers, because that backend clears the map’s cache when you add a file. So registering early is free on the backend that recovers and unrecoverable on the one that does not, which is why the rule is stated flatly for both.
In an Adw.Application, the place that satisfies the rule is startup, before the first
window exists:
import Adw from 'gi://Adw?version=1';import GObject from 'gi://GObject?version=2.0';import { initFonts } from '@gjsify/gtk-host/fonts';
class MyApplication extends Adw.Application { static { GObject.registerClass(MyApplication); }
vfunc_startup(): void { initFonts(); // before anything measures text super.vfunc_startup(); // …now build your windows, load your CSS, create your widgets }}If you use runAdwaitaApp, the line above it is early
enough: createWindow does not run until the first activate.
What each operating system does
Section titled “What each operating system does”One payload directory, three different readers. You write the same call everywhere; each operating system answers it differently, and all three answers are correct:
| Where it runs | What reaches the font map | What initFonts() reports |
|---|---|---|
Linux (.deb, .rpm, Flatpak, a --prefix tree) | fontconfig finds the staged directory on its own, through the stock fonts.conf | the faces in registered |
macOS (.app) | this call, on a bundled GTK runtime — the loader asks for the fontconfig backend there. On a CoreText map instead: ATSApplicationFontsPath in Info.plist, which macOS honours before your code runs | the faces in registered; declined on a CoreText map that already has them. Neither is a failure |
Windows (program directory, .msi) | this call. On a bundled GTK runtime the loader also selects the fontconfig backend; on the DirectWrite map this call is the only mechanism there is | the faces in registered |
Windows is the row that matters. GTK4 there is pangowin32, and that font map is populated
from DirectWrite and from nothing else: no filesystem search path, no fontconfig. A
fontconfig directory beside your app is inert. Measured on Windows 11, a config naming your
staged directory moves the default font map by zero families even when it is the only
configuration loaded. What puts the family there is add_font_file on that same map, the
one a Gtk.Label renders through.
macOS is the row that MOVED, and the reason is worth knowing because it is the same
reason Windows has always been special. Pango does not have one font map per platform: pangocairo
builds the first backend compiled in — CoreText, then pangowin32, then fontconfig — and
PANGOCAIRO_BACKEND overrides that. The default maps do not reach every face the system
installs: non-Latin text, Tamil among it, rendered as empty boxes in every shipped app,
with the system’s own Tamil face installed the whole time. So the bundled GTK runtime now
SELECTS the fontconfig backend on macOS and Windows both, and gets it on both — on
that map add_font_file works, so your faces land in registered like everywhere else.
Windows is the same story with one extra step. That pango can build a fontconfig map too,
but a Windows process carries two environments — the Win32 block that g_getenv() reads, and
the C runtime copy that getenv() reads — and Node writes only the first while pango reads the
second. The loader mirrors its writes across, so the choice takes effect there as well.
add_font_file stays wired on both platforms regardless: it is what a process on the DirectWrite
map still needs, and you get that map by pinning PANGOCAIRO_BACKEND=win32.
If a process does resolve a CoreText map — a system GTK, or PANGOCAIRO_BACKEND=coretext
set by hand — the call answers G_IO_ERROR_NOT_SUPPORTED and initFonts() files the face
under declined rather than failed. Nothing is lost there either: the bundle’s
Info.plist had the OS activate the same directory at launch, earlier than any code of
yours could have run.
Development is the exception, and initFonts() covers it. gjsify run on a Homebrew
GTK resolves CoreText and has no .app, so nothing activates your faces there. When the
map declines faces whose family it does not already hold, initFonts() builds Pango’s
fontconfig map, registers them on it and makes it the process default — the faces then
land in registered and the result says fontconfigFallback: true. It does not do this
when PANGOCAIRO_BACKEND is set (so coretext pins the platform map), when fontconfig has
no configuration, or when the family is already on the CoreText map, which is the shipped
.app. Text in that process is then rasterised by FreeType, and widgets built before the
call keep the old map — call it first. So both outcomes are correct on macOS, and neither needs a branch in
your app — which is why there is no process.platform check anywhere in this API. The
decision is made from the error the font map returns, so it stays right whichever backend
a host actually compiled in, and stayed right through this change.
The family NAME can differ per platform too
Section titled “The family NAME can differ per platform too”Not just whether the face arrives — what it is then called. The family name comes out of
the font’s naming table, and the two font stacks do not read it the same way. The same
byte-identical Merriweather_400Regular.ttf from Google Fonts, SHA-256-verified on both
machines and handed to add_font_file by one script:
| host | font stack | the map gained |
|---|---|---|
| Fedora 44, GJS | fontconfig | Merriweather |
Windows 11, @gjsify/gtk-runtime-win32-x64 | gvsbuild | Merriweather 18pt |
Google Fonts ships Merriweather as an optical-size family, and the readers disagree about
whether the size axis belongs in the name. Source Sans 3, from the same staging run, has
no size axis and reads identically on both.
So font-family: Merriweather renders in Merriweather on Linux and in Tahoma on Windows,
from one payload and one call that reported success on both. matches is the answer:
exact is the name you wrote, optical hands you the name to ask for instead,
ambiguous means the map has several optical sizes and which one to set at which point
size is your design decision, and absent is the family nothing has.
Check that it worked
Section titled “Check that it worked”Do not take registered.length as proof. It says the call did not fail; it does not say
the family is renderable, and a substituted family looks identical to a resolved one from
the outside. Measure instead, with an invented family as your control:
import Pango from 'gi://Pango?version=1.0';import PangoCairo from 'gi://PangoCairo?version=1.0';import { initFonts } from '@gjsify/gtk-host/fonts';
const FAMILY = 'Round9x13'; // the family name your face carriesconst CONTROL = 'ZzzNoSuchFamily'; // a family that cannot exist
const fonts = initFonts({ expectedFamilies: [FAMILY, CONTROL] });print(`dir=${fonts.dir}`);print(`registered=${fonts.registered.length} declined=${fonts.declined.length} failed=${fonts.failed.length}`);
// `matches` in place of a hand-rolled `list_families()` scan, and it is not the same check:// it accepts the optical-size alias the Windows stack makes of the name (`match.family` is// then what you put in `font-family`) and refuses to guess between several of them.const listed = (family: string) => fonts.matches.find((match) => match.declared === family)?.kind !== 'absent';
const map = PangoCairo.FontMap.get_default();
const measure = (family: string) => { const description = new Pango.FontDescription(); description.set_family(family); description.set_size(40 * Pango.SCALE); const layout = Pango.Layout.new(map.create_context()); layout.set_font_description(description); layout.set_text('Wg', -1); return layout.get_pixel_size().join('x');};
print(`listed=${listed(FAMILY)}`);print(`${FAMILY}=${measure(FAMILY)} vs ${CONTROL}=${measure(CONTROL)}`);Two things have to be true, and the second is the one that discriminates:
- the declared name resolves —
matchessays anything butabsent, and - it measures differently from the control.
The first is now the call’s own answer rather than yours to compute, and fonts.families
tells you which names this call put there. The second still has to be measured, because a
resolved name and a rendered face are not the same claim: registration is not retroactive
(§ Call it before you build any UI), so a family can be
on the map and still measure as the fallback.
With the face staged and GJSIFY_FONT_DIR pointing at it:
dir=/tmp/fontcheckregistered=1 declined=0 failed=0listed=trueRound9x13=66x50 vs ZzzNoSuchFamily=87x63The same program with nothing staged, which is what a broken payload looks like from the inside:
dir=undefinedregistered=0 declined=0 failed=0listed=falseRound9x13=87x63 vs ZzzNoSuchFamily=87x6387x63 twice. Your family and a family that cannot exist are rendering as the same face,
and nothing about the run says so. That is why the control is in the check.
On macOS the honest expectation is different again: declined=1, and the family absent
from list_families() at this point in the process, with the text still correct on screen
because the OS activated the directory before your code ran. The check to run in a shipped
.app is PangoCairo.FontMap.get_default().list_families() inside the running bundle, and
PANGOCAIRO_BACKEND=bogus ./YourApp makes Pango print which backends it was actually built
with.
The platform’s own typeface comes from the runtime
Section titled “The platform’s own typeface comes from the runtime”Everything above is about your face. There is a second one, and off Linux nobody installs it: the GNOME UI typeface itself.
Measured on Windows 11 / GTK 4.22.4 against the published 0.50.0 runtime bundle — 82 font
families on the map, and Cantarell, Adwaita Sans and Adwaita Mono among none of them.
Every request for one came back as Tahoma, with the same couldn't load font …, falling back
line and the same exit 0. An Adwaita stylesheet naming the GNOME font got a foreign face, in
every gjsify GTK app on that platform.
@gjsify/gtk-runtime-<target> now carries Adwaita Sans + Adwaita Mono under
gtk/share/fonts (OFL-1.1, named in the bundle’s THIRD-PARTY-NOTICES.md), and
@gjsify/node-gi’s loader publishes that directory as GJSIFY_GTK_RUNTIME_FONT_DIR.
You do not have to do anything about it on Linux and Windows. The same initFonts() call
registers both — the runtime’s faces first, then yours:
const fonts = initFonts({ expectedFamilies: ['Brand'] });
fonts.sources;// [{ dir: 'C:\…\gtk\share\fonts', origin: 'runtime' },// { dir: 'C:\…\share\fonts\org.example.App', origin: 'app' }]Two variables and not one, deliberately: an app that ships a brand face must never have to choose between its face and the platform’s. On Linux neither is usually set and the call stays the no-op it always was.
…and the size, which the faces do not fix
Section titled “…and the size, which the faces do not fix”GTK takes the system UI font from the shell. Windows’ is 9 pt; GNOME designs for 11.
Measured as ascent + descent — points are not comparable across platforms — that is 16.0 px
against GNOME’s 19.0, about 16 % small, which is the whole of “the font looks a bit small” and
is not something the typeface can answer. macOS measures 18.8 px and needs no correction, so the
gap is Windows-alone.
Which of those you want is a policy, and your app picks one of three:
| policy | on Windows | what it means |
|---|---|---|
system | Segoe UI 9 — untouched | the host’s font, size included. Someone who chose 9 pt keeps 9 pt |
size | Segoe UI 11 | the host’s face, drawn at the size Adwaita was designed for. Raise-only |
adwaita | Adwaita Sans 11 | the GNOME font, identical on every platform |
import { applyUiFontPolicy, UI_FONT_POLICIES } from '@gjsify/gtk-host/fonts';
applyUiFontPolicy('size'); // recommended for an app shipping a bundled GTKapplyUiFontPolicy('adwaita'); // or let the user choose — UI_FONT_POLICIES enumerates themapplyUiFontPolicy('system'); // and back again, at any timeYou can also ask for it once, while registering the faces:
initFonts({ uiFont: 'size' });Nothing happens unless you ask. Registering a typeface and rewriting the user’s font
setting are two different acts, and a runtime that does the second uninvited is a surprise.
There is a second reason: if anything applied a policy by default, system would already be
unreachable — the host’s own value would have been overwritten before you could choose to
keep it.
Which is the other half of how this works. initFonts() records gtk-font-name as the
process first found it, and system restores exactly that:
uiFontBaseline(); // "Segoe UI 9" — the host's own, before anything wroteThat capture is why switching adwaita → system in a preferences dialog lands on the
user’s real setting rather than an approximation. Once a value has been overwritten it is not
recoverable: GTK keeps no previous value, Windows has no GSettings to re-read, and on Linux
the value a session applied may itself be an override of the schema default.
Before you offer the adwaita option
Section titled “Before you offer the adwaita option”Forcing Adwaita Sans 11 on a host where that family never arrived — an older bundle, a
system GTK without adwaita-fonts — does not fail. Pango substitutes, and the user who picked
“use the Adwaita font” gets Tahoma: one substitution traded for another, by a setting that now
lies about what it did. So ask first:
const adwaita = adwaitaUiFontAvailability();if (!adwaita.available) { // don't offer it, or offer it disabled — `adwaita.match` says why}It answers a FontFamilyMatch, not a boolean, because optical is a real third state — and the
name it answers depends on WHICH reader put the face on the map, not on the OS. Adwaita Sans is
a variable font with an opsz axis whose value at 14 is named Text: fontconfig puts
Adwaita Sans on the map and a DirectWrite reader puts Adwaita Sans Text. Byte-identical
file, two family names. Which one you get follows the MAP, not the OS: the bundled runtime reads
the face through fontconfig on macOS and Windows both and gets the first, while a process on a
platform map — a system GTK, or PANGOCAIRO_BACKEND pinned by hand — gets the second on Windows.
applyUiFontPolicy('adwaita') handles that for you: it asks the map which name it holds and
writes that one. If you set gtk-font-name yourself, do the same — asking for the declared name
on a host that spells it the other way gets you a substitution without a word.
Call it after initFonts(), which is what puts the bundled faces there.
Who does what
Section titled “Who does what”| Piece | Job |
|---|---|
gjsify.ship.fonts | names the faces; gjsify ship copies them to share/fonts/<appId>/ |
| the generated launcher | exports GJSIFY_FONT_DIR at that directory, on every layout |
initFonts() from @gjsify/gtk-host/fonts | reads the variable and registers what it finds |
applyUiFontPolicy() from the same module | applies one of the three UI-font states, and undoes it |
@gjsify/gtk-runtime-<target> | carries the GNOME UI typeface in gtk/share/fonts |
@gjsify/node-gi’s loader | exports GJSIFY_GTK_RUNTIME_FONT_DIR at that directory, and on macOS/Windows selects the Pango backend that reads any of it (PANGOCAIRO_BACKEND=fc, only if you have not set it) |
gjsify ship deliberately does not make the call for you. A packaging command that injected
a startup step would be deciding your app’s initialisation order, invisibly, and the
ordering above is exactly the thing that has to stay yours.
When it does not work
Section titled “When it does not work”| What you see | What it means |
|---|---|
dir is undefined in a shipped app | the payload staged no face. Check gjsify.ship.fonts and re-run gjsify ship |
declined holds your faces, on macOS | this process resolved a CoreText map — a system GTK, or PANGOCAIRO_BACKEND set by hand. Correct: ATSApplicationFontsPath already did the work |
declined holds your faces, anywhere else | this process resolved a font map that does no runtime registration; check PANGOCAIRO_BACKEND |
| non-Latin text is empty boxes | a platform font map is drawing — CoreText or DirectWrite — and its script fallback does not reach the faces the system installs. PANGOCAIRO_BACKEND=fc is what the bundled runtime selects; a value you set yourself wins over it, and PANGOCAIRO_BACKEND=bogus prints which backends your pango has |
initFonts: … could not be read as an application font | FreeType would not open that file: truncated, corrupt, or something wearing a face extension |
| the family is listed but text is unchanged | something laid out text before initFonts(). Move the call earlier |
initFonts: X: on the font map as "X 18pt" | this font stack keeps the optical-size axis in the family name. Ask for the name on the right — match.family |
initFonts: X: NOT on the font map | the face never arrived under that name. Check families for what did |
gjsify ship: … is a web-font wrapper | replace the .woff2 with the desktop face it was made from |
gjsify ship: … holds no font face | the configured directory has no .ttf/.otf/.ttc/.otc in it |
Where to next
Section titled “Where to next”- Windows artifacts: the layout where this call is the only mechanism there is.
- macOS app bundles: where the
Info.plistkey does the work instead. - Linux packages: where fontconfig gets there on its own.
- CLI Reference →
gjsify shiplists every configuration key.