From 2245d6afd169cc4f29820133fb4f179cda07e789 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 22 Aug 2026 12:52:47 -0500 Subject: [PATCH 1/5] fix(macos): align mixed-face text baselines - Place AppKit text from each resolved font's baseline metrics. - Preserve engine baselines in measured-line and wrapping fallback paths. - Add a pixel regression using bundled Geist sans and mono faces. --- build.zig | 4 + src/platform/macos/appkit_host.m | 1 + src/platform/macos/appkit_text_baseline.h | 49 +++++++ src/platform/macos/root.zig | 58 +++++++++ src/platform/macos/text_baseline_test.m | 148 ++++++++++++++++++++++ 5 files changed, 260 insertions(+) create mode 100644 src/platform/macos/appkit_text_baseline.h create mode 100644 src/platform/macos/text_baseline_test.m diff --git a/build.zig b/build.zig index 6c191ee85..d36896a9f 100644 --- a/build.zig +++ b/build.zig @@ -241,6 +241,10 @@ pub fn build(b: *std.Build) void { else &.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0" }; desktop_mod.addCSourceFile(.{ .file = b.path("src/platform/macos/image_fit_test.m"), .flags = flags }); + desktop_mod.addCSourceFile(.{ .file = b.path("src/platform/macos/text_baseline_test.m"), .flags = flags }); + desktop_mod.linkFramework("AppKit", .{}); + desktop_mod.linkFramework("CoreGraphics", .{}); + desktop_mod.linkFramework("CoreText", .{}); desktop_mod.linkFramework("Foundation", .{}); desktop_mod.linkFramework("ImageIO", .{}); desktop_mod.linkSystemLibrary("objc", .{}); diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index ca6d7d1a1..c08e0acbd 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -1,4 +1,5 @@ #import "appkit_host.h" +#import "appkit_text_baseline.h" #import #import diff --git a/src/platform/macos/appkit_text_baseline.h b/src/platform/macos/appkit_text_baseline.h new file mode 100644 index 000000000..927f38520 --- /dev/null +++ b/src/platform/macos/appkit_text_baseline.h @@ -0,0 +1,49 @@ +#ifndef NATIVE_SDK_APPKIT_TEXT_BASELINE_H +#define NATIVE_SDK_APPKIT_TEXT_BASELINE_H + +#import + +#include + +/* NSString drawing in a flipped AppKit context takes the upper-left of a + * line fragment, not a baseline. AppKit snaps the resolved face's ascent to + * the point grid when it places that fragment's baseline, so reverse that + * exact conversion. Point size is not a font metric: using it here puts two + * faces with different ascenders on different baselines even when the engine + * supplied one shared baseline. */ +static inline CGFloat NativeSdkAppKitLineFragmentOriginY(NSFont *font, CGFloat baseline) { + return baseline - round(font.ascender); +} + +/* The legacy host-wrapping fallback can impose an explicit line height. + * TextKit may then distribute extra leading around the first line, so its + * baseline offset is not necessarily round(font.ascender). Ask the same + * layout machinery drawWithRect: uses and translate its container so the + * first baseline still lands on the engine's coordinate. */ +static inline CGFloat NativeSdkAppKitFirstBaselineOffset( + NSString *value, + NSDictionary *attributes, + NSFont *font, + NSSize containerSize +) { + const CGFloat fallback = round(font.ascender); + if (value.length == 0 || containerSize.width <= 0 || containerSize.height <= 0) return fallback; + + NSTextStorage *storage = [[NSTextStorage alloc] initWithString:value attributes:attributes]; + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + layoutManager.usesFontLeading = YES; + NSTextContainer *container = [[NSTextContainer alloc] initWithContainerSize:containerSize]; + container.lineFragmentPadding = 0; + [storage addLayoutManager:layoutManager]; + [layoutManager addTextContainer:container]; + + const NSRange glyphRange = [layoutManager glyphRangeForTextContainer:container]; + if (glyphRange.length == 0) return fallback; + const NSUInteger firstGlyph = glyphRange.location; + const NSRect lineFragment = [layoutManager lineFragmentRectForGlyphAtIndex:firstGlyph effectiveRange:NULL]; + const NSPoint glyphLocation = [layoutManager locationForGlyphAtIndex:firstGlyph]; + const CGFloat offset = NSMinY(lineFragment) + glyphLocation.y; + return isfinite(offset) && offset >= 0 ? offset : fallback; +} + +#endif diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 0fbc51bb7..78bff2a80 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -164,6 +164,23 @@ extern fn native_sdk_test_imageio_thumbnail_dimensions( out_height: *usize, ) c_int; +extern fn native_sdk_test_appkit_text_baselines( + regular_bytes: [*]const u8, + regular_len: usize, + mono_bytes: [*]const u8, + mono_len: usize, + size: f64, + baseline: f64, + out_regular_offset: *f64, + out_mono_offset: *f64, + out_old_regular_first: *c_int, + out_old_mono_first: *c_int, + out_fixed_regular_first: *c_int, + out_fixed_mono_first: *c_int, + out_wrapped_regular_first: *c_int, + out_wrapped_mono_first: *c_int, +) c_int; + const shortcut_modifier_primary: u32 = 1 << 0; const shortcut_modifier_command: u32 = 1 << 1; const shortcut_modifier_control: u32 = 1 << 2; @@ -1264,6 +1281,47 @@ fn decodeImage(context: ?*anyopaque, bytes: []const u8, buffer: []u8, max_pixels }; } +test "mac packet text keeps mixed-face runs on the engine baseline" { + if (comptime builtin.os.tag != .macos) return error.SkipZigTest; + + var regular_offset: f64 = 0; + var mono_offset: f64 = 0; + var old_regular_first: c_int = -1; + var old_mono_first: c_int = -1; + var fixed_regular_first: c_int = -1; + var fixed_mono_first: c_int = -1; + var wrapped_regular_first: c_int = -1; + var wrapped_mono_first: c_int = -1; + try std.testing.expectEqual(@as(c_int, 1), native_sdk_test_appkit_text_baselines( + canvas.font_ttf.geist_regular_bytes.ptr, + canvas.font_ttf.geist_regular_bytes.len, + canvas.font_ttf.geist_mono_bytes.ptr, + canvas.font_ttf.geist_mono_bytes.len, + 14.5, + 40, + ®ular_offset, + &mono_offset, + &old_regular_first, + &old_mono_first, + &fixed_regular_first, + &fixed_mono_first, + &wrapped_regular_first, + &wrapped_mono_first, + )); + + // The fixtures genuinely exercise different line-fragment metrics, + // and the former point-size conversion visibly split their ink rows. + try std.testing.expectEqual(@as(f64, 13), regular_offset); + try std.testing.expectEqual(@as(f64, 15), mono_offset); + try std.testing.expect(old_regular_first != old_mono_first); + + // Both the engine-measured-line path and the rare host-wrapping fallback + // now place the identical cap outline on one shared engine baseline. + try std.testing.expectEqual(fixed_regular_first, fixed_mono_first); + try std.testing.expectEqual(fixed_regular_first, wrapped_regular_first); + try std.testing.expectEqual(fixed_regular_first, wrapped_mono_first); +} + test "mac image decoder keeps ImageIO thumbnail rounding inside the pixel cap" { // The Objective-C probe is compiled and linked only for macOS test // artifacts (build.zig's target-gated image_fit_test.m source). Keep diff --git a/src/platform/macos/text_baseline_test.m b/src/platform/macos/text_baseline_test.m new file mode 100644 index 000000000..7e91da1cf --- /dev/null +++ b/src/platform/macos/text_baseline_test.m @@ -0,0 +1,148 @@ +#import +#import + +#include +#include + +#include "appkit_text_baseline.h" + +typedef struct { + int first; + int last; +} NativeSdkTestInkRows; + +static NSFont *NativeSdkTestFont(const uint8_t *bytes, size_t length, CGFloat size) { + NSData *data = [NSData dataWithBytesNoCopy:(void *)bytes length:length freeWhenDone:NO]; + CTFontDescriptorRef descriptor = CTFontManagerCreateFontDescriptorFromData((__bridge CFDataRef)data); + if (!descriptor) return nil; + CTFontRef coreTextFont = CTFontCreateWithFontDescriptor(descriptor, size, NULL); + CFRelease(descriptor); + return CFBridgingRelease(coreTextFont); +} + +static NativeSdkTestInkRows NativeSdkTestDrawText( + NSFont *font, + CGFloat size, + CGFloat baseline, + BOOL corrected, + BOOL wrapped +) { + const size_t width = 96; + const size_t height = 72; + const size_t bytesPerRow = width * 4; + uint8_t *pixels = calloc(height, bytesPerRow); + if (!pixels) return (NativeSdkTestInkRows){ .first = -1, .last = -1 }; + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGContextRef context = CGBitmapContextCreate( + pixels, + width, + height, + 8, + bytesPerRow, + colorSpace, + kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big + ); + CGColorSpaceRelease(colorSpace); + if (!context) { + free(pixels); + return (NativeSdkTestInkRows){ .first = -1, .last = -1 }; + } + + /* Match appkit_host.m's packet bitmap and flipped NSGraphicsContext. */ + CGContextSetAllowsAntialiasing(context, true); + CGContextSetShouldAntialias(context, true); + CGContextTranslateCTM(context, 0, (CGFloat)height); + CGContextScaleCTM(context, 1, -1); + NSGraphicsContext *graphics = [NSGraphicsContext graphicsContextWithCGContext:context flipped:YES]; + [NSGraphicsContext saveGraphicsState]; + [NSGraphicsContext setCurrentContext:graphics]; + + NSMutableDictionary *attributes = [@{ + NSFontAttributeName: font, + NSForegroundColorAttributeName: NSColor.whiteColor, + } mutableCopy]; + if (wrapped) { + NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init]; + paragraph.minimumLineHeight = 20; + paragraph.maximumLineHeight = 20; + attributes[NSParagraphStyleAttributeName] = paragraph; + const NSSize extent = NSMakeSize(80, 30); + const CGFloat offset = corrected + ? NativeSdkAppKitFirstBaselineOffset(@"H", attributes, font, extent) + : size; + [@"H" drawWithRect:NSMakeRect(8, baseline - offset, extent.width, extent.height) + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading + attributes:attributes]; + } else { + const CGFloat y = corrected + ? NativeSdkAppKitLineFragmentOriginY(font, baseline) + : baseline - size; + [@"H" drawAtPoint:NSMakePoint(8, y) withAttributes:attributes]; + } + + [NSGraphicsContext restoreGraphicsState]; + CGContextRelease(context); + + NativeSdkTestInkRows rows = { .first = -1, .last = -1 }; + for (size_t row = 0; row < height; row++) { + BOOL hasInk = NO; + for (size_t column = 0; column < width; column++) { + if (pixels[row * bytesPerRow + column * 4 + 3] != 0) { + hasInk = YES; + break; + } + } + if (!hasInk) continue; + if (rows.first < 0) rows.first = (int)row; + rows.last = (int)row; + } + free(pixels); + return rows; +} + +/* Test-only AppKit pixel probe linked by build.zig, not by apps. The bundled + * Geist fixtures have the same cap height but different vertical metrics at + * 14.5pt, making them a hermetic regression for mixed-face baselines. */ +int native_sdk_test_appkit_text_baselines( + const uint8_t *regularBytes, + size_t regularLength, + const uint8_t *monoBytes, + size_t monoLength, + double size, + double baseline, + double *outRegularOffset, + double *outMonoOffset, + int *outOldRegularFirst, + int *outOldMonoFirst, + int *outFixedRegularFirst, + int *outFixedMonoFirst, + int *outWrappedRegularFirst, + int *outWrappedMonoFirst +) { + @autoreleasepool { + NSFont *regular = NativeSdkTestFont(regularBytes, regularLength, size); + NSFont *mono = NativeSdkTestFont(monoBytes, monoLength, size); + if (!regular || !mono) return 0; + + const NativeSdkTestInkRows oldRegular = NativeSdkTestDrawText(regular, size, baseline, NO, NO); + const NativeSdkTestInkRows oldMono = NativeSdkTestDrawText(mono, size, baseline, NO, NO); + const NativeSdkTestInkRows fixedRegular = NativeSdkTestDrawText(regular, size, baseline, YES, NO); + const NativeSdkTestInkRows fixedMono = NativeSdkTestDrawText(mono, size, baseline, YES, NO); + const NativeSdkTestInkRows wrappedRegular = NativeSdkTestDrawText(regular, size, baseline, YES, YES); + const NativeSdkTestInkRows wrappedMono = NativeSdkTestDrawText(mono, size, baseline, YES, YES); + + if (outRegularOffset) *outRegularOffset = round(regular.ascender); + if (outMonoOffset) *outMonoOffset = round(mono.ascender); + if (outOldRegularFirst) *outOldRegularFirst = oldRegular.first; + if (outOldMonoFirst) *outOldMonoFirst = oldMono.first; + if (outFixedRegularFirst) *outFixedRegularFirst = fixedRegular.first; + if (outFixedMonoFirst) *outFixedMonoFirst = fixedMono.first; + if (outWrappedRegularFirst) *outWrappedRegularFirst = wrappedRegular.first; + if (outWrappedMonoFirst) *outWrappedMonoFirst = wrappedMono.first; + + return oldRegular.first >= 0 && oldMono.first >= 0 && oldRegular.first != oldMono.first && + fixedRegular.first == fixedMono.first && fixedRegular.last == fixedMono.last && + wrappedRegular.first == wrappedMono.first && wrappedRegular.last == wrappedMono.last && + fixedRegular.first == wrappedRegular.first && fixedRegular.last == wrappedRegular.last; + } +} From 44586edea62c2c4ba03fd0b1ed13aa6d1042e7dd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 22 Aug 2026 13:45:29 -0500 Subject: [PATCH 2/5] ci(macos): run platform framework tests --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09c142e6f..79a8a0411 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,9 @@ jobs: # gpu-components is a TypeScript-core app, so its smoke build needs # the frontend compiler and exact-pinned TypeScript toolchain. - run: npm ci --prefix packages/core + # Platform-only framework tests include AppKit pixel regressions that + # are compiled out of the Linux Zig Core lane. + - run: zig build test-desktop-platform # The mobile aggregate runs on Linux for Android. Exercise the other # store-capable cross-target here against the real iPhone simulator SDK. - run: zig build test-example-mobile-canvas-lib-ios-store From 9a32ce9e325b3236d4eb468942336e7ff5efb3e8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 22 Aug 2026 14:16:29 -0500 Subject: [PATCH 3/5] test(macos): cover compact text baselines --- src/platform/macos/appkit_text_baseline.h | 6 ++- src/platform/macos/root.zig | 20 +++++++++ src/platform/macos/text_baseline_test.m | 51 +++++++++++++++-------- 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/platform/macos/appkit_text_baseline.h b/src/platform/macos/appkit_text_baseline.h index 927f38520..23dd2aff7 100644 --- a/src/platform/macos/appkit_text_baseline.h +++ b/src/platform/macos/appkit_text_baseline.h @@ -43,7 +43,11 @@ static inline CGFloat NativeSdkAppKitFirstBaselineOffset( const NSRect lineFragment = [layoutManager lineFragmentRectForGlyphAtIndex:firstGlyph effectiveRange:NULL]; const NSPoint glyphLocation = [layoutManager locationForGlyphAtIndex:firstGlyph]; const CGFloat offset = NSMinY(lineFragment) + glyphLocation.y; - return isfinite(offset) && offset >= 0 ? offset : fallback; + /* A line height smaller than the font box legitimately moves TextKit's + * first baseline above the container origin, producing a negative + * offset. Preserve that answer: rejecting it would put compact runs back + * on face-dependent ascenders and split their shared engine baseline. */ + return isfinite(offset) ? offset : fallback; } #endif diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 78bff2a80..855516ecd 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -173,12 +173,16 @@ extern fn native_sdk_test_appkit_text_baselines( baseline: f64, out_regular_offset: *f64, out_mono_offset: *f64, + out_compact_regular_offset: *f64, + out_compact_mono_offset: *f64, out_old_regular_first: *c_int, out_old_mono_first: *c_int, out_fixed_regular_first: *c_int, out_fixed_mono_first: *c_int, out_wrapped_regular_first: *c_int, out_wrapped_mono_first: *c_int, + out_compact_regular_first: *c_int, + out_compact_mono_first: *c_int, ) c_int; const shortcut_modifier_primary: u32 = 1 << 0; @@ -1286,12 +1290,16 @@ test "mac packet text keeps mixed-face runs on the engine baseline" { var regular_offset: f64 = 0; var mono_offset: f64 = 0; + var compact_regular_offset: f64 = 0; + var compact_mono_offset: f64 = 0; var old_regular_first: c_int = -1; var old_mono_first: c_int = -1; var fixed_regular_first: c_int = -1; var fixed_mono_first: c_int = -1; var wrapped_regular_first: c_int = -1; var wrapped_mono_first: c_int = -1; + var compact_regular_first: c_int = -1; + var compact_mono_first: c_int = -1; try std.testing.expectEqual(@as(c_int, 1), native_sdk_test_appkit_text_baselines( canvas.font_ttf.geist_regular_bytes.ptr, canvas.font_ttf.geist_regular_bytes.len, @@ -1301,12 +1309,16 @@ test "mac packet text keeps mixed-face runs on the engine baseline" { 40, ®ular_offset, &mono_offset, + &compact_regular_offset, + &compact_mono_offset, &old_regular_first, &old_mono_first, &fixed_regular_first, &fixed_mono_first, &wrapped_regular_first, &wrapped_mono_first, + &compact_regular_first, + &compact_mono_first, )); // The fixtures genuinely exercise different line-fragment metrics, @@ -1315,11 +1327,19 @@ test "mac packet text keeps mixed-face runs on the engine baseline" { try std.testing.expectEqual(@as(f64, 15), mono_offset); try std.testing.expect(old_regular_first != old_mono_first); + // Explicit line heights smaller than the font box validly place the + // first baseline above TextKit's container origin. Those negative + // offsets must not be replaced by the face-dependent ascent fallback. + try std.testing.expect(compact_regular_offset < 0); + try std.testing.expect(compact_mono_offset < 0); + // Both the engine-measured-line path and the rare host-wrapping fallback // now place the identical cap outline on one shared engine baseline. try std.testing.expectEqual(fixed_regular_first, fixed_mono_first); try std.testing.expectEqual(fixed_regular_first, wrapped_regular_first); try std.testing.expectEqual(fixed_regular_first, wrapped_mono_first); + try std.testing.expectEqual(fixed_regular_first, compact_regular_first); + try std.testing.expectEqual(fixed_regular_first, compact_mono_first); } test "mac image decoder keeps ImageIO thumbnail rounding inside the pixel cap" { diff --git a/src/platform/macos/text_baseline_test.m b/src/platform/macos/text_baseline_test.m index 7e91da1cf..836af39ed 100644 --- a/src/platform/macos/text_baseline_test.m +++ b/src/platform/macos/text_baseline_test.m @@ -9,6 +9,7 @@ typedef struct { int first; int last; + CGFloat baselineOffset; } NativeSdkTestInkRows; static NSFont *NativeSdkTestFont(const uint8_t *bytes, size_t length, CGFloat size) { @@ -25,13 +26,13 @@ static NativeSdkTestInkRows NativeSdkTestDrawText( CGFloat size, CGFloat baseline, BOOL corrected, - BOOL wrapped + CGFloat lineHeight ) { const size_t width = 96; const size_t height = 72; const size_t bytesPerRow = width * 4; uint8_t *pixels = calloc(height, bytesPerRow); - if (!pixels) return (NativeSdkTestInkRows){ .first = -1, .last = -1 }; + if (!pixels) return (NativeSdkTestInkRows){ .first = -1, .last = -1, .baselineOffset = NAN }; CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); CGContextRef context = CGBitmapContextCreate( pixels, @@ -45,7 +46,7 @@ static NativeSdkTestInkRows NativeSdkTestDrawText( CGColorSpaceRelease(colorSpace); if (!context) { free(pixels); - return (NativeSdkTestInkRows){ .first = -1, .last = -1 }; + return (NativeSdkTestInkRows){ .first = -1, .last = -1, .baselineOffset = NAN }; } /* Match appkit_host.m's packet bitmap and flipped NSGraphicsContext. */ @@ -61,16 +62,17 @@ static NativeSdkTestInkRows NativeSdkTestDrawText( NSFontAttributeName: font, NSForegroundColorAttributeName: NSColor.whiteColor, } mutableCopy]; - if (wrapped) { + CGFloat baselineOffset = corrected ? round(font.ascender) : size; + if (lineHeight > 0) { NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init]; - paragraph.minimumLineHeight = 20; - paragraph.maximumLineHeight = 20; + paragraph.minimumLineHeight = lineHeight; + paragraph.maximumLineHeight = lineHeight; attributes[NSParagraphStyleAttributeName] = paragraph; const NSSize extent = NSMakeSize(80, 30); - const CGFloat offset = corrected + baselineOffset = corrected ? NativeSdkAppKitFirstBaselineOffset(@"H", attributes, font, extent) : size; - [@"H" drawWithRect:NSMakeRect(8, baseline - offset, extent.width, extent.height) + [@"H" drawWithRect:NSMakeRect(8, baseline - baselineOffset, extent.width, extent.height) options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading attributes:attributes]; } else { @@ -83,7 +85,7 @@ static NativeSdkTestInkRows NativeSdkTestDrawText( [NSGraphicsContext restoreGraphicsState]; CGContextRelease(context); - NativeSdkTestInkRows rows = { .first = -1, .last = -1 }; + NativeSdkTestInkRows rows = { .first = -1, .last = -1, .baselineOffset = baselineOffset }; for (size_t row = 0; row < height; row++) { BOOL hasInk = NO; for (size_t column = 0; column < width; column++) { @@ -112,37 +114,52 @@ int native_sdk_test_appkit_text_baselines( double baseline, double *outRegularOffset, double *outMonoOffset, + double *outCompactRegularOffset, + double *outCompactMonoOffset, int *outOldRegularFirst, int *outOldMonoFirst, int *outFixedRegularFirst, int *outFixedMonoFirst, int *outWrappedRegularFirst, - int *outWrappedMonoFirst + int *outWrappedMonoFirst, + int *outCompactRegularFirst, + int *outCompactMonoFirst ) { @autoreleasepool { NSFont *regular = NativeSdkTestFont(regularBytes, regularLength, size); NSFont *mono = NativeSdkTestFont(monoBytes, monoLength, size); if (!regular || !mono) return 0; - const NativeSdkTestInkRows oldRegular = NativeSdkTestDrawText(regular, size, baseline, NO, NO); - const NativeSdkTestInkRows oldMono = NativeSdkTestDrawText(mono, size, baseline, NO, NO); - const NativeSdkTestInkRows fixedRegular = NativeSdkTestDrawText(regular, size, baseline, YES, NO); - const NativeSdkTestInkRows fixedMono = NativeSdkTestDrawText(mono, size, baseline, YES, NO); - const NativeSdkTestInkRows wrappedRegular = NativeSdkTestDrawText(regular, size, baseline, YES, YES); - const NativeSdkTestInkRows wrappedMono = NativeSdkTestDrawText(mono, size, baseline, YES, YES); + const NativeSdkTestInkRows oldRegular = NativeSdkTestDrawText(regular, size, baseline, NO, 0); + const NativeSdkTestInkRows oldMono = NativeSdkTestDrawText(mono, size, baseline, NO, 0); + const NativeSdkTestInkRows fixedRegular = NativeSdkTestDrawText(regular, size, baseline, YES, 0); + const NativeSdkTestInkRows fixedMono = NativeSdkTestDrawText(mono, size, baseline, YES, 0); + const NativeSdkTestInkRows wrappedRegular = NativeSdkTestDrawText(regular, size, baseline, YES, 20); + const NativeSdkTestInkRows wrappedMono = NativeSdkTestDrawText(mono, size, baseline, YES, 20); + /* Smaller than either face's font box: TextKit's valid first-baseline + * offsets are negative, exercising the compact-line regression. */ + const NativeSdkTestInkRows compactRegular = NativeSdkTestDrawText(regular, size, baseline, YES, 1); + const NativeSdkTestInkRows compactMono = NativeSdkTestDrawText(mono, size, baseline, YES, 1); if (outRegularOffset) *outRegularOffset = round(regular.ascender); if (outMonoOffset) *outMonoOffset = round(mono.ascender); + if (outCompactRegularOffset) *outCompactRegularOffset = compactRegular.baselineOffset; + if (outCompactMonoOffset) *outCompactMonoOffset = compactMono.baselineOffset; if (outOldRegularFirst) *outOldRegularFirst = oldRegular.first; if (outOldMonoFirst) *outOldMonoFirst = oldMono.first; if (outFixedRegularFirst) *outFixedRegularFirst = fixedRegular.first; if (outFixedMonoFirst) *outFixedMonoFirst = fixedMono.first; if (outWrappedRegularFirst) *outWrappedRegularFirst = wrappedRegular.first; if (outWrappedMonoFirst) *outWrappedMonoFirst = wrappedMono.first; + if (outCompactRegularFirst) *outCompactRegularFirst = compactRegular.first; + if (outCompactMonoFirst) *outCompactMonoFirst = compactMono.first; return oldRegular.first >= 0 && oldMono.first >= 0 && oldRegular.first != oldMono.first && fixedRegular.first == fixedMono.first && fixedRegular.last == fixedMono.last && wrappedRegular.first == wrappedMono.first && wrappedRegular.last == wrappedMono.last && - fixedRegular.first == wrappedRegular.first && fixedRegular.last == wrappedRegular.last; + fixedRegular.first == wrappedRegular.first && fixedRegular.last == wrappedRegular.last && + compactRegular.baselineOffset < 0 && compactMono.baselineOffset < 0 && + compactRegular.first == compactMono.first && compactRegular.last == compactMono.last && + fixedRegular.first == compactRegular.first && fixedRegular.last == compactRegular.last; } } From b216e10b36cd3d82fdfa63e0f5dd6467b81e6561 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 22 Aug 2026 15:18:15 -0500 Subject: [PATCH 4/5] fix(macos): preserve fallback text baselines --- src/platform/macos/appkit_text_baseline.h | 22 +++++---- src/platform/macos/root.zig | 4 ++ src/platform/macos/text_baseline_test.m | 55 +++++++++++++++-------- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/src/platform/macos/appkit_text_baseline.h b/src/platform/macos/appkit_text_baseline.h index 23dd2aff7..4ada08412 100644 --- a/src/platform/macos/appkit_text_baseline.h +++ b/src/platform/macos/appkit_text_baseline.h @@ -15,15 +15,18 @@ static inline CGFloat NativeSdkAppKitLineFragmentOriginY(NSFont *font, CGFloat b return baseline - round(font.ascender); } -/* The legacy host-wrapping fallback can impose an explicit line height. - * TextKit may then distribute extra leading around the first line, so its - * baseline offset is not necessarily round(font.ascender). Ask the same - * layout machinery drawWithRect: uses and translate its container so the - * first baseline still lands on the engine's coordinate. */ -static inline CGFloat NativeSdkAppKitFirstBaselineOffset( +/* The legacy host-wrapping fallback can impose an explicit line height or + * resolve fallback faces whose metrics enlarge the line fragment. TextKit may + * then place the first baseline somewhere other than round(font.ascender). + * Build the layout once, translate that exact glyph range to the engine's + * baseline, and draw through the same manager: measuring with NSLayoutManager + * but drawing with NSString lets the two APIs choose different baselines for + * fallback glyphs (for example a Geist run containing an emoji). */ +static inline CGFloat NativeSdkAppKitDrawTextOnFirstBaseline( NSString *value, NSDictionary *attributes, NSFont *font, + NSPoint engineBaseline, NSSize containerSize ) { const CGFloat fallback = round(font.ascender); @@ -42,12 +45,15 @@ static inline CGFloat NativeSdkAppKitFirstBaselineOffset( const NSUInteger firstGlyph = glyphRange.location; const NSRect lineFragment = [layoutManager lineFragmentRectForGlyphAtIndex:firstGlyph effectiveRange:NULL]; const NSPoint glyphLocation = [layoutManager locationForGlyphAtIndex:firstGlyph]; - const CGFloat offset = NSMinY(lineFragment) + glyphLocation.y; + const CGFloat measuredOffset = NSMinY(lineFragment) + glyphLocation.y; /* A line height smaller than the font box legitimately moves TextKit's * first baseline above the container origin, producing a negative * offset. Preserve that answer: rejecting it would put compact runs back * on face-dependent ascenders and split their shared engine baseline. */ - return isfinite(offset) ? offset : fallback; + const CGFloat offset = isfinite(measuredOffset) ? measuredOffset : fallback; + const NSPoint containerOrigin = NSMakePoint(engineBaseline.x, engineBaseline.y - offset); + [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:containerOrigin]; + return offset; } #endif diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 855516ecd..1b3673364 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -183,6 +183,7 @@ extern fn native_sdk_test_appkit_text_baselines( out_wrapped_mono_first: *c_int, out_compact_regular_first: *c_int, out_compact_mono_first: *c_int, + out_fallback_first: *c_int, ) c_int; const shortcut_modifier_primary: u32 = 1 << 0; @@ -1300,6 +1301,7 @@ test "mac packet text keeps mixed-face runs on the engine baseline" { var wrapped_mono_first: c_int = -1; var compact_regular_first: c_int = -1; var compact_mono_first: c_int = -1; + var fallback_first: c_int = -1; try std.testing.expectEqual(@as(c_int, 1), native_sdk_test_appkit_text_baselines( canvas.font_ttf.geist_regular_bytes.ptr, canvas.font_ttf.geist_regular_bytes.len, @@ -1319,6 +1321,7 @@ test "mac packet text keeps mixed-face runs on the engine baseline" { &wrapped_mono_first, &compact_regular_first, &compact_mono_first, + &fallback_first, )); // The fixtures genuinely exercise different line-fragment metrics, @@ -1340,6 +1343,7 @@ test "mac packet text keeps mixed-face runs on the engine baseline" { try std.testing.expectEqual(fixed_regular_first, wrapped_mono_first); try std.testing.expectEqual(fixed_regular_first, compact_regular_first); try std.testing.expectEqual(fixed_regular_first, compact_mono_first); + try std.testing.expectEqual(fixed_regular_first, fallback_first); } test "mac image decoder keeps ImageIO thumbnail rounding inside the pixel cap" { diff --git a/src/platform/macos/text_baseline_test.m b/src/platform/macos/text_baseline_test.m index 836af39ed..7dabfcf82 100644 --- a/src/platform/macos/text_baseline_test.m +++ b/src/platform/macos/text_baseline_test.m @@ -26,7 +26,9 @@ static NativeSdkTestInkRows NativeSdkTestDrawText( CGFloat size, CGFloat baseline, BOOL corrected, - CGFloat lineHeight + CGFloat lineHeight, + NSString *value, + size_t scanColumnLimit ) { const size_t width = 96; const size_t height = 72; @@ -69,26 +71,34 @@ static NativeSdkTestInkRows NativeSdkTestDrawText( paragraph.maximumLineHeight = lineHeight; attributes[NSParagraphStyleAttributeName] = paragraph; const NSSize extent = NSMakeSize(80, 30); - baselineOffset = corrected - ? NativeSdkAppKitFirstBaselineOffset(@"H", attributes, font, extent) - : size; - [@"H" drawWithRect:NSMakeRect(8, baseline - baselineOffset, extent.width, extent.height) - options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading - attributes:attributes]; + if (corrected) { + baselineOffset = NativeSdkAppKitDrawTextOnFirstBaseline( + value, + attributes, + font, + NSMakePoint(8, baseline), + extent + ); + } else { + [value drawWithRect:NSMakeRect(8, baseline - baselineOffset, extent.width, extent.height) + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading + attributes:attributes]; + } } else { const CGFloat y = corrected ? NativeSdkAppKitLineFragmentOriginY(font, baseline) : baseline - size; - [@"H" drawAtPoint:NSMakePoint(8, y) withAttributes:attributes]; + [value drawAtPoint:NSMakePoint(8, y) withAttributes:attributes]; } [NSGraphicsContext restoreGraphicsState]; CGContextRelease(context); NativeSdkTestInkRows rows = { .first = -1, .last = -1, .baselineOffset = baselineOffset }; + const size_t scanColumns = MIN(width, scanColumnLimit); for (size_t row = 0; row < height; row++) { BOOL hasInk = NO; - for (size_t column = 0; column < width; column++) { + for (size_t column = 0; column < scanColumns; column++) { if (pixels[row * bytesPerRow + column * 4 + 3] != 0) { hasInk = YES; break; @@ -123,23 +133,28 @@ int native_sdk_test_appkit_text_baselines( int *outWrappedRegularFirst, int *outWrappedMonoFirst, int *outCompactRegularFirst, - int *outCompactMonoFirst + int *outCompactMonoFirst, + int *outFallbackFirst ) { @autoreleasepool { NSFont *regular = NativeSdkTestFont(regularBytes, regularLength, size); NSFont *mono = NativeSdkTestFont(monoBytes, monoLength, size); if (!regular || !mono) return 0; - const NativeSdkTestInkRows oldRegular = NativeSdkTestDrawText(regular, size, baseline, NO, 0); - const NativeSdkTestInkRows oldMono = NativeSdkTestDrawText(mono, size, baseline, NO, 0); - const NativeSdkTestInkRows fixedRegular = NativeSdkTestDrawText(regular, size, baseline, YES, 0); - const NativeSdkTestInkRows fixedMono = NativeSdkTestDrawText(mono, size, baseline, YES, 0); - const NativeSdkTestInkRows wrappedRegular = NativeSdkTestDrawText(regular, size, baseline, YES, 20); - const NativeSdkTestInkRows wrappedMono = NativeSdkTestDrawText(mono, size, baseline, YES, 20); + const NativeSdkTestInkRows oldRegular = NativeSdkTestDrawText(regular, size, baseline, NO, 0, @"H", 96); + const NativeSdkTestInkRows oldMono = NativeSdkTestDrawText(mono, size, baseline, NO, 0, @"H", 96); + const NativeSdkTestInkRows fixedRegular = NativeSdkTestDrawText(regular, size, baseline, YES, 0, @"H", 96); + const NativeSdkTestInkRows fixedMono = NativeSdkTestDrawText(mono, size, baseline, YES, 0, @"H", 96); + const NativeSdkTestInkRows wrappedRegular = NativeSdkTestDrawText(regular, size, baseline, YES, 20, @"H", 96); + const NativeSdkTestInkRows wrappedMono = NativeSdkTestDrawText(mono, size, baseline, YES, 20, @"H", 96); /* Smaller than either face's font box: TextKit's valid first-baseline * offsets are negative, exercising the compact-line regression. */ - const NativeSdkTestInkRows compactRegular = NativeSdkTestDrawText(regular, size, baseline, YES, 1); - const NativeSdkTestInkRows compactMono = NativeSdkTestDrawText(mono, size, baseline, YES, 1); + const NativeSdkTestInkRows compactRegular = NativeSdkTestDrawText(regular, size, baseline, YES, 1, @"H", 96); + const NativeSdkTestInkRows compactMono = NativeSdkTestDrawText(mono, size, baseline, YES, 1, @"H", 96); + /* The emoji resolves to a fallback face with taller metrics. Scan only + * the leading Geist H: measuring with NSLayoutManager and then drawing + * through NSString used to move this H five rows above its baseline. */ + const NativeSdkTestInkRows fallback = NativeSdkTestDrawText(regular, size, baseline, YES, 20, @"H\U0001F600", 19); if (outRegularOffset) *outRegularOffset = round(regular.ascender); if (outMonoOffset) *outMonoOffset = round(mono.ascender); @@ -153,6 +168,7 @@ int native_sdk_test_appkit_text_baselines( if (outWrappedMonoFirst) *outWrappedMonoFirst = wrappedMono.first; if (outCompactRegularFirst) *outCompactRegularFirst = compactRegular.first; if (outCompactMonoFirst) *outCompactMonoFirst = compactMono.first; + if (outFallbackFirst) *outFallbackFirst = fallback.first; return oldRegular.first >= 0 && oldMono.first >= 0 && oldRegular.first != oldMono.first && fixedRegular.first == fixedMono.first && fixedRegular.last == fixedMono.last && @@ -160,6 +176,7 @@ int native_sdk_test_appkit_text_baselines( fixedRegular.first == wrappedRegular.first && fixedRegular.last == wrappedRegular.last && compactRegular.baselineOffset < 0 && compactMono.baselineOffset < 0 && compactRegular.first == compactMono.first && compactRegular.last == compactMono.last && - fixedRegular.first == compactRegular.first && fixedRegular.last == compactRegular.last; + fixedRegular.first == compactRegular.first && fixedRegular.last == compactRegular.last && + fixedRegular.first == fallback.first && fixedRegular.last == fallback.last; } } From 417d4828acf8d7c45fd33b7cb6e0291697c0b4e3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 22 Aug 2026 19:29:57 -0500 Subject: [PATCH 5/5] test(macos): exercise production text baseline helper --- build.zig | 5 +- src/platform/macos/appkit_host.m | 19 +------ src/platform/macos/appkit_text_baseline.h | 67 +++++++++-------------- src/platform/macos/root.zig | 6 +- src/platform/macos/text_baseline_test.m | 34 +++++++----- 5 files changed, 54 insertions(+), 77 deletions(-) diff --git a/build.zig b/build.zig index d36896a9f..fc58f89a2 100644 --- a/build.zig +++ b/build.zig @@ -1352,8 +1352,9 @@ pub fn build(b: *std.Build) void { .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkPacketTextLineBreakMode" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkPacketTextAlignment" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "static BOOL NativeSdkPacketDrawAttributedText(" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "drawGlyphsForGlyphRange:glyphRange atPoint:" }, - .{ .path = "src/platform/macos/appkit_host.m", .pattern = "glyphRangeForTextContainer:container" }, + .{ .path = "src/platform/macos/appkit_host.m", .pattern = "return NativeSdkAppKitDrawAttributedText(value, attributes, x, baseline, width, height, NULL)" }, + .{ .path = "src/platform/macos/appkit_text_baseline.h", .pattern = "drawGlyphsForGlyphRange:glyphRange atPoint:" }, + .{ .path = "src/platform/macos/appkit_text_baseline.h", .pattern = "glyphRangeForTextContainer:container" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkPacketNumber(layout[@\"maxWidth\"], 0)" }, .{ .path = "src/platform/macos/appkit_host.m", .pattern = "native_sdk_appkit_measure_text_ink(" }, }); diff --git a/src/platform/macos/appkit_host.m b/src/platform/macos/appkit_host.m index c08e0acbd..cff57e84e 100644 --- a/src/platform/macos/appkit_host.m +++ b/src/platform/macos/appkit_host.m @@ -2054,24 +2054,7 @@ static BOOL NativeSdkPacketDrawAttributedText( CGFloat width, CGFloat height ) { - if (value.length == 0) return YES; - - NSTextStorage *storage = [[NSTextStorage alloc] initWithString:value attributes:attributes]; - NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; - NSTextContainer *container = [[NSTextContainer alloc] initWithContainerSize:NSMakeSize( - width > 0 ? width : CGFLOAT_MAX, - height > 0 ? height : CGFLOAT_MAX - )]; - container.lineFragmentPadding = 0; - [layoutManager addTextContainer:container]; - [storage addLayoutManager:layoutManager]; - [layoutManager ensureLayoutForTextContainer:container]; - - NSRange glyphRange = [layoutManager glyphRangeForTextContainer:container]; - if (glyphRange.length == 0) return YES; - CGFloat firstLineOffset = [layoutManager locationForGlyphAtIndex:glyphRange.location].y; - [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:NSMakePoint(x, baseline - firstLineOffset)]; - return YES; + return NativeSdkAppKitDrawAttributedText(value, attributes, x, baseline, width, height, NULL); } // Italicizes a resolved sans face for the reserved italic span font ids diff --git a/src/platform/macos/appkit_text_baseline.h b/src/platform/macos/appkit_text_baseline.h index 4ada08412..ff12baeac 100644 --- a/src/platform/macos/appkit_text_baseline.h +++ b/src/platform/macos/appkit_text_baseline.h @@ -3,57 +3,40 @@ #import -#include - -/* NSString drawing in a flipped AppKit context takes the upper-left of a - * line fragment, not a baseline. AppKit snaps the resolved face's ascent to - * the point grid when it places that fragment's baseline, so reverse that - * exact conversion. Point size is not a font metric: using it here puts two - * faces with different ascenders on different baselines even when the engine - * supplied one shared baseline. */ -static inline CGFloat NativeSdkAppKitLineFragmentOriginY(NSFont *font, CGFloat baseline) { - return baseline - round(font.ascender); -} - -/* The legacy host-wrapping fallback can impose an explicit line height or - * resolve fallback faces whose metrics enlarge the line fragment. TextKit may - * then place the first baseline somewhere other than round(font.ascender). - * Build the layout once, translate that exact glyph range to the engine's - * baseline, and draw through the same manager: measuring with NSLayoutManager - * but drawing with NSString lets the two APIs choose different baselines for - * fallback glyphs (for example a Geist run containing an emoji). */ -static inline CGFloat NativeSdkAppKitDrawTextOnFirstBaseline( +/* Shared by the packet host and its pixel regression. TextKit owns both the + * fallback-face metrics and explicit-line-height placement, so build and draw + * one layout and translate its first baseline to the engine coordinate. The + * optional offset exposes that exact placement to the regression without + * giving the test a second rendering algorithm that can drift from the host. */ +static inline BOOL NativeSdkAppKitDrawAttributedText( NSString *value, NSDictionary *attributes, - NSFont *font, - NSPoint engineBaseline, - NSSize containerSize + CGFloat x, + CGFloat baseline, + CGFloat width, + CGFloat height, + CGFloat *outFirstLineOffset ) { - const CGFloat fallback = round(font.ascender); - if (value.length == 0 || containerSize.width <= 0 || containerSize.height <= 0) return fallback; + if (outFirstLineOffset) *outFirstLineOffset = 0; + if (value.length == 0) return YES; NSTextStorage *storage = [[NSTextStorage alloc] initWithString:value attributes:attributes]; NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; - layoutManager.usesFontLeading = YES; - NSTextContainer *container = [[NSTextContainer alloc] initWithContainerSize:containerSize]; + NSTextContainer *container = [[NSTextContainer alloc] initWithContainerSize:NSMakeSize( + width > 0 ? width : CGFLOAT_MAX, + height > 0 ? height : CGFLOAT_MAX + )]; container.lineFragmentPadding = 0; - [storage addLayoutManager:layoutManager]; [layoutManager addTextContainer:container]; + [storage addLayoutManager:layoutManager]; + [layoutManager ensureLayoutForTextContainer:container]; - const NSRange glyphRange = [layoutManager glyphRangeForTextContainer:container]; - if (glyphRange.length == 0) return fallback; - const NSUInteger firstGlyph = glyphRange.location; - const NSRect lineFragment = [layoutManager lineFragmentRectForGlyphAtIndex:firstGlyph effectiveRange:NULL]; - const NSPoint glyphLocation = [layoutManager locationForGlyphAtIndex:firstGlyph]; - const CGFloat measuredOffset = NSMinY(lineFragment) + glyphLocation.y; - /* A line height smaller than the font box legitimately moves TextKit's - * first baseline above the container origin, producing a negative - * offset. Preserve that answer: rejecting it would put compact runs back - * on face-dependent ascenders and split their shared engine baseline. */ - const CGFloat offset = isfinite(measuredOffset) ? measuredOffset : fallback; - const NSPoint containerOrigin = NSMakePoint(engineBaseline.x, engineBaseline.y - offset); - [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:containerOrigin]; - return offset; + NSRange glyphRange = [layoutManager glyphRangeForTextContainer:container]; + if (glyphRange.length == 0) return YES; + CGFloat firstLineOffset = [layoutManager locationForGlyphAtIndex:glyphRange.location].y; + if (outFirstLineOffset) *outFirstLineOffset = firstLineOffset; + [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:NSMakePoint(x, baseline - firstLineOffset)]; + return YES; } #endif diff --git a/src/platform/macos/root.zig b/src/platform/macos/root.zig index 1b3673364..f829af60d 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -2983,9 +2983,11 @@ test "mac platform module exports type" { test "mac AppKit packet text anchors use the resolved font ascent" { const host_source = @embedFile("appkit_host.m"); + const baseline_source = @embedFile("appkit_text_baseline.h"); try std.testing.expect(std.mem.indexOf(u8, host_source, "static BOOL NativeSdkPacketDrawAttributedText(") != null); - try std.testing.expect(std.mem.indexOf(u8, host_source, "drawGlyphsForGlyphRange:glyphRange atPoint:") != null); - try std.testing.expect(std.mem.indexOf(u8, host_source, "glyphRangeForTextContainer:container") != null); + try std.testing.expect(std.mem.indexOf(u8, host_source, "return NativeSdkAppKitDrawAttributedText(value, attributes, x, baseline, width, height, NULL)") != null); + try std.testing.expect(std.mem.indexOf(u8, baseline_source, "drawGlyphsForGlyphRange:glyphRange atPoint:") != null); + try std.testing.expect(std.mem.indexOf(u8, baseline_source, "glyphRangeForTextContainer:container") != null); try std.testing.expect(std.mem.indexOf(u8, host_source, "native_sdk_appkit_measure_text_ink(") != null); } diff --git a/src/platform/macos/text_baseline_test.m b/src/platform/macos/text_baseline_test.m index 7dabfcf82..944defea7 100644 --- a/src/platform/macos/text_baseline_test.m +++ b/src/platform/macos/text_baseline_test.m @@ -64,31 +64,39 @@ static NativeSdkTestInkRows NativeSdkTestDrawText( NSFontAttributeName: font, NSForegroundColorAttributeName: NSColor.whiteColor, } mutableCopy]; - CGFloat baselineOffset = corrected ? round(font.ascender) : size; + CGFloat baselineOffset = size; if (lineHeight > 0) { NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init]; paragraph.minimumLineHeight = lineHeight; paragraph.maximumLineHeight = lineHeight; attributes[NSParagraphStyleAttributeName] = paragraph; - const NSSize extent = NSMakeSize(80, 30); if (corrected) { - baselineOffset = NativeSdkAppKitDrawTextOnFirstBaseline( + NativeSdkAppKitDrawAttributedText( value, attributes, - font, - NSMakePoint(8, baseline), - extent + 8, + baseline, + 80, + CGFLOAT_MAX, + &baselineOffset ); } else { - [value drawWithRect:NSMakeRect(8, baseline - baselineOffset, extent.width, extent.height) + [value drawWithRect:NSMakeRect(8, baseline - baselineOffset, 80, 30) options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading attributes:attributes]; } + } else if (corrected) { + NativeSdkAppKitDrawAttributedText( + value, + attributes, + 8, + baseline, + CGFLOAT_MAX, + CGFLOAT_MAX, + &baselineOffset + ); } else { - const CGFloat y = corrected - ? NativeSdkAppKitLineFragmentOriginY(font, baseline) - : baseline - size; - [value drawAtPoint:NSMakePoint(8, y) withAttributes:attributes]; + [value drawAtPoint:NSMakePoint(8, baseline - size) withAttributes:attributes]; } [NSGraphicsContext restoreGraphicsState]; @@ -152,8 +160,8 @@ int native_sdk_test_appkit_text_baselines( const NativeSdkTestInkRows compactRegular = NativeSdkTestDrawText(regular, size, baseline, YES, 1, @"H", 96); const NativeSdkTestInkRows compactMono = NativeSdkTestDrawText(mono, size, baseline, YES, 1, @"H", 96); /* The emoji resolves to a fallback face with taller metrics. Scan only - * the leading Geist H: measuring with NSLayoutManager and then drawing - * through NSString used to move this H five rows above its baseline. */ + * the leading Geist H to prove the shared TextKit draw keeps it on the + * same baseline as the no-fallback direct path. */ const NativeSdkTestInkRows fallback = NativeSdkTestDrawText(regular, size, baseline, YES, 20, @"H\U0001F600", 19); if (outRegularOffset) *outRegularOffset = round(regular.ascender);