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 diff --git a/build.zig b/build.zig index 6c191ee85..fc58f89a2 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", .{}); @@ -1348,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 ca6d7d1a1..cff57e84e 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 @@ -2053,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 new file mode 100644 index 000000000..ff12baeac --- /dev/null +++ b/src/platform/macos/appkit_text_baseline.h @@ -0,0 +1,42 @@ +#ifndef NATIVE_SDK_APPKIT_TEXT_BASELINE_H +#define NATIVE_SDK_APPKIT_TEXT_BASELINE_H + +#import + +/* 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, + CGFloat x, + CGFloat baseline, + CGFloat width, + CGFloat height, + CGFloat *outFirstLineOffset +) { + if (outFirstLineOffset) *outFirstLineOffset = 0; + 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; + 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 0fbc51bb7..f829af60d 100644 --- a/src/platform/macos/root.zig +++ b/src/platform/macos/root.zig @@ -164,6 +164,28 @@ 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_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, + out_fallback_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 +1286,66 @@ 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 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; + 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, + canvas.font_ttf.geist_mono_bytes.ptr, + canvas.font_ttf.geist_mono_bytes.len, + 14.5, + 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, + &fallback_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); + + // 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); + try std.testing.expectEqual(fixed_regular_first, fallback_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 @@ -2901,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 new file mode 100644 index 000000000..944defea7 --- /dev/null +++ b/src/platform/macos/text_baseline_test.m @@ -0,0 +1,190 @@ +#import +#import + +#include +#include + +#include "appkit_text_baseline.h" + +typedef struct { + int first; + int last; + CGFloat baselineOffset; +} 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, + CGFloat lineHeight, + NSString *value, + size_t scanColumnLimit +) { + 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, .baselineOffset = NAN }; + 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, .baselineOffset = NAN }; + } + + /* 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]; + CGFloat baselineOffset = size; + if (lineHeight > 0) { + NSMutableParagraphStyle *paragraph = [[NSMutableParagraphStyle alloc] init]; + paragraph.minimumLineHeight = lineHeight; + paragraph.maximumLineHeight = lineHeight; + attributes[NSParagraphStyleAttributeName] = paragraph; + if (corrected) { + NativeSdkAppKitDrawAttributedText( + value, + attributes, + 8, + baseline, + 80, + CGFLOAT_MAX, + &baselineOffset + ); + } else { + [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 { + [value drawAtPoint:NSMakePoint(8, baseline - size) 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 < scanColumns; 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, + double *outCompactRegularOffset, + double *outCompactMonoOffset, + int *outOldRegularFirst, + int *outOldMonoFirst, + int *outFixedRegularFirst, + int *outFixedMonoFirst, + int *outWrappedRegularFirst, + int *outWrappedMonoFirst, + int *outCompactRegularFirst, + 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, @"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, @"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 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); + 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; + if (outFallbackFirst) *outFallbackFirst = fallback.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 && + compactRegular.baselineOffset < 0 && compactMono.baselineOffset < 0 && + compactRegular.first == compactMono.first && compactRegular.last == compactMono.last && + fixedRegular.first == compactRegular.first && fixedRegular.last == compactRegular.last && + fixedRegular.first == fallback.first && fixedRegular.last == fallback.last; + } +}