The problem
On macOS, a text run is drawn with drawAtPoint: into a flipped context:
// src/platform/macos/appkit_host.m:2661
[value drawAtPoint:NSMakePoint(origin.x, origin.y - size) withAttributes:baseAttributes];
// src/platform/macos/appkit_host.m:2681, the engine-measured-lines path
[lineText drawAtPoint:NSMakePoint(lineX, baseline - size) withAttributes:baseAttributes];
The context is flipped (appkit_host.m:5224, graphicsContextWithCGContext:context flipped:YES), so drawAtPoint: takes the upper-left of the line fragment, and AppKit then puts the baseline round(font.ascender) below that point.
Subtracting size therefore asserts ascent == point size. Ascent is a per-face quantity, not a per-size one, so any two faces whose vertical metrics differ are drawn on two different baselines from the same origin.y.
This is invisible to the engine. text_spans.zig computes one baseline per line for every run on it (text_spans.zig:628, :491), and the display list is correct: same origin.y, same size, one baseline. The host substitutes each face's real ascent at draw time and nothing reports it back. macos/root.zig:253-254 exports width measurement only, so there is no ascent or descent over the FFI for the engine to consult.
Reproducing it
I hit this with the Geist family, whose faces do not agree with each other:
| face |
hhea / OS-2 typo asc, desc, gap |
ascender at 14.5pt |
round() |
| Geist-Regular |
920, -220, 100 |
13.340 |
13 |
| Geist-Medium |
1005, -295, 0 |
14.573 |
15 |
| Geist-Bold |
1005, -295, 0 |
14.573 |
15 |
| GeistMono-Regular |
1005, -295, 0 |
14.573 |
15 |
Drawing through the host's own expression (flipped context, drawAtPoint: at baseline - size, baseline = 40, size = 14.5) and reading back the ink rows:
Geist-Regular asc=13.340 round=13 -> cap-top row 28, baseline row 38
Geist-Medium asc=14.573 round=15 -> cap-top row 30, baseline row 40
Exactly 2.00pt apart, from the same passed point. The glyphs are not a different size: cap height is 710/1000 in both faces and the ink height of the same string is identical to the row. They are shifted.
In practice that means a paragraph like hop on @someone today, where the mention is one span at .weight = .medium, draws the mention 2pt below the words either side of it, with its descenders hanging below the line's rhythm. Bold names beside regular metadata in a row diverge the same way.
The size of the error is round(ascentA) - round(ascentB) and the integer snap makes it lumpy: for these two faces it is 1.00pt at 12, 13, 14, 15 and 16pt, and 2.00pt at 14.5, 20 and 28pt.
There is also a uniform bias in the single-face case: at 14.5pt with Geist-Regular the baseline lands 1.5pt above where the engine put it, in every paragraph.
The fix
Subtract the resolved face's ascent rather than the requested size. After
NSFont *font = NativeSdkPacketPreferredFont(text, size); // appkit_host.m:2651
add CGFloat ascent = roundf(font.ascender); and use it at both draw sites:
appkit_host.m:2661 — NSMakePoint(origin.x, origin.y - size) becomes NSMakePoint(origin.x, origin.y - ascent)
appkit_host.m:2681 — NSMakePoint(lineX, baseline - size) becomes NSMakePoint(lineX, baseline - ascent)
round(font.ascender) reproduced AppKit's actual baseline offset exactly for both faces at every size I probed (12, 13, 14, 14.5, 15, 16, 20, 28). CTLineDraw with CGContextSetTextPosition at the baseline is the subpixel-exact version if the integer snap is unwanted.
One caveat worth stating. The third site, appkit_host.m:2707, is the drawWithRect: fallback with NSStringDrawingUsesLineFragmentOrigin, and the same edit is not safe there. Ten lines earlier the host pins the line box:
// appkit_host.m:2691-2694
CGFloat lineHeight = NativeSdkPacketNumber(layout[@"lineHeight"], 0);
if (lineHeight > 0) { paragraph.minimumLineHeight = lineHeight; paragraph.maximumLineHeight = lineHeight; }
With min/maximumLineHeight set, AppKit repositions the baseline inside the taller box and round(ascender) is no longer the offset. Measured at 14.5pt with lineHeight = 21.75, subtracting the ascent gives cap-top rows 35 and 32 for the two faces: the divergence flips sign and gets worse. The engine always populates line_height on these commands (widget_render.zig:1659), so that path needs an explicit first-baseline computation rather than the same subtraction.
Why it matters beyond one font
Any app mixing weights inside a paragraph hits this whenever its regular and bold faces ship different vertical metrics, which is common: they are frequently built and shipped separately. The symptom reads as a layout bug in the app, and it cannot be found from the app's own side, because everything the engine can measure is correct. It took rendering both faces through the host's expression into a bitmap to see it at all.
What I did meanwhile
Rewrote the three outlier faces so all four share Geist-Regular's vertical metrics. That makes the wrong assumption uniform across the family, so nothing shifts relative to anything else. It is a workaround, not a fix: any future face with different metrics brings it straight back.
Environment
macOS 26, Native SDK 0.9.2 (native_sdk-0.1.0-hzDzQsUNqQKw1x-wc3wEyiXTWBHhciSI2Y_Kf_EklrFf), Zig 0.16.0, gpu_surface view with the Metal backend.
The problem
On macOS, a text run is drawn with
drawAtPoint:into a flipped context:The context is flipped (
appkit_host.m:5224,graphicsContextWithCGContext:context flipped:YES), sodrawAtPoint:takes the upper-left of the line fragment, and AppKit then puts the baselineround(font.ascender)below that point.Subtracting
sizetherefore assertsascent == point size. Ascent is a per-face quantity, not a per-size one, so any two faces whose vertical metrics differ are drawn on two different baselines from the sameorigin.y.This is invisible to the engine.
text_spans.zigcomputes one baseline per line for every run on it (text_spans.zig:628,:491), and the display list is correct: sameorigin.y, samesize, one baseline. The host substitutes each face's real ascent at draw time and nothing reports it back.macos/root.zig:253-254exports width measurement only, so there is no ascent or descent over the FFI for the engine to consult.Reproducing it
I hit this with the Geist family, whose faces do not agree with each other:
round()Drawing through the host's own expression (flipped context,
drawAtPoint:atbaseline - size,baseline = 40,size = 14.5) and reading back the ink rows:Exactly 2.00pt apart, from the same passed point. The glyphs are not a different size: cap height is 710/1000 in both faces and the ink height of the same string is identical to the row. They are shifted.
In practice that means a paragraph like
hop on @someone today, where the mention is one span at.weight = .medium, draws the mention 2pt below the words either side of it, with its descenders hanging below the line's rhythm. Bold names beside regular metadata in a row diverge the same way.The size of the error is
round(ascentA) - round(ascentB)and the integer snap makes it lumpy: for these two faces it is 1.00pt at 12, 13, 14, 15 and 16pt, and 2.00pt at 14.5, 20 and 28pt.There is also a uniform bias in the single-face case: at 14.5pt with Geist-Regular the baseline lands 1.5pt above where the engine put it, in every paragraph.
The fix
Subtract the resolved face's ascent rather than the requested size. After
add
CGFloat ascent = roundf(font.ascender);and use it at both draw sites:appkit_host.m:2661—NSMakePoint(origin.x, origin.y - size)becomesNSMakePoint(origin.x, origin.y - ascent)appkit_host.m:2681—NSMakePoint(lineX, baseline - size)becomesNSMakePoint(lineX, baseline - ascent)round(font.ascender)reproduced AppKit's actual baseline offset exactly for both faces at every size I probed (12, 13, 14, 14.5, 15, 16, 20, 28).CTLineDrawwithCGContextSetTextPositionat the baseline is the subpixel-exact version if the integer snap is unwanted.One caveat worth stating. The third site,
appkit_host.m:2707, is thedrawWithRect:fallback withNSStringDrawingUsesLineFragmentOrigin, and the same edit is not safe there. Ten lines earlier the host pins the line box:With
min/maximumLineHeightset, AppKit repositions the baseline inside the taller box andround(ascender)is no longer the offset. Measured at 14.5pt withlineHeight = 21.75, subtracting the ascent gives cap-top rows 35 and 32 for the two faces: the divergence flips sign and gets worse. The engine always populatesline_heighton these commands (widget_render.zig:1659), so that path needs an explicit first-baseline computation rather than the same subtraction.Why it matters beyond one font
Any app mixing weights inside a paragraph hits this whenever its regular and bold faces ship different vertical metrics, which is common: they are frequently built and shipped separately. The symptom reads as a layout bug in the app, and it cannot be found from the app's own side, because everything the engine can measure is correct. It took rendering both faces through the host's expression into a bitmap to see it at all.
What I did meanwhile
Rewrote the three outlier faces so all four share Geist-Regular's vertical metrics. That makes the wrong assumption uniform across the family, so nothing shifts relative to anything else. It is a workaround, not a fix: any future face with different metrics brings it straight back.
Environment
macOS 26, Native SDK 0.9.2 (
native_sdk-0.1.0-hzDzQsUNqQKw1x-wc3wEyiXTWBHhciSI2Y_Kf_EklrFf), Zig 0.16.0,gpu_surfaceview with the Metal backend.