Skip to content

Text runs are placed by subtracting the point size instead of the resolved font's ascent, so two faces on one line draw on two baselines #392

Description

@sepehr-safari

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:2661NSMakePoint(origin.x, origin.y - size) becomes NSMakePoint(origin.x, origin.y - ascent)
  • appkit_host.m:2681NSMakePoint(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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions