From 572c06f67def29f1b1f344a7ffe914078967a1d6 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Thu, 4 Dec 2025 10:09:41 -0500 Subject: [PATCH 1/6] font/coretext: Use positions to fix x/y offsets --- pkg/macos/text/run.zig | 13 ++++++ src/font/shaper/coretext.zig | 80 ++++++++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/pkg/macos/text/run.zig b/pkg/macos/text/run.zig index 2895bfe34..a34cd5307 100644 --- a/pkg/macos/text/run.zig +++ b/pkg/macos/text/run.zig @@ -106,6 +106,19 @@ pub const Run = opaque { pub fn getStatus(self: *Run) Status { return @bitCast(c.CTRunGetStatus(@ptrCast(self))); } + + pub fn getAttributes(self: *Run) *foundation.Dictionary { + return @ptrCast(@constCast(c.CTRunGetAttributes(@ptrCast(self)))); + } + + pub fn getFont(self: *Run) ?*text.Font { + const attrs = self.getAttributes(); + const font_ptr = attrs.getValue(*const anyopaque, c.kCTFontAttributeName); + if (font_ptr) |ptr| { + return @ptrCast(@constCast(ptr)); + } + return null; + } }; /// https://developer.apple.com/documentation/coretext/ctrunstatus?language=objc diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 97cb5cd89..498b45799 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -377,11 +377,21 @@ pub const Shaper = struct { const line = typesetter.createLine(.{ .location = 0, .length = 0 }); self.cf_release_pool.appendAssumeCapacity(line); - // This keeps track of the current offsets within a single cell. + // This keeps track of the current offsets within a run. + var run_offset: struct { + x: f64 = 0, + y: f64 = 0, + } = .{}; + + // This keeps track of the current offsets within a cell. var cell_offset: struct { cluster: u32 = 0, x: f64 = 0, y: f64 = 0, + + // For debugging positions, turn this on: + start_index: usize = 0, + end_index: usize = 0, } = .{}; // Clear our cell buf and make sure we have enough room for the whole @@ -411,15 +421,18 @@ pub const Shaper = struct { // Get our glyphs and positions const glyphs = ctrun.getGlyphsPtr() orelse try ctrun.getGlyphs(alloc); const advances = ctrun.getAdvancesPtr() orelse try ctrun.getAdvances(alloc); + const positions = ctrun.getPositionsPtr() orelse try ctrun.getPositions(alloc); const indices = ctrun.getStringIndicesPtr() orelse try ctrun.getStringIndices(alloc); assert(glyphs.len == advances.len); + assert(glyphs.len == positions.len); assert(glyphs.len == indices.len); for ( glyphs, advances, + positions, indices, - ) |glyph, advance, index| { + ) |glyph, advance, position, index| { // Our cluster is also our cell X position. If the cluster changes // then we need to reset our current cell offsets. const cluster = state.codepoints.items[index].cluster; @@ -431,20 +444,71 @@ pub const Shaper = struct { // wait for that. if (cell_offset.cluster > cluster) break :pad; - cell_offset = .{ .cluster = cluster }; + cell_offset = .{ + .cluster = cluster, + .x = run_offset.x, + .y = run_offset.y, + + // For debugging positions, turn this on: + .start_index = index, + .end_index = index, + }; + } else { + if (index < cell_offset.start_index) { + cell_offset.start_index = index; + } + if (index > cell_offset.end_index) { + cell_offset.end_index = index; + } + } + + const x_offset = position.x - cell_offset.x; + const y_offset = position.y - cell_offset.y; + + const advance_x_offset = run_offset.x - cell_offset.x; + const advance_y_offset = run_offset.y - cell_offset.y; + const x_offset_diff = x_offset - advance_x_offset; + const y_offset_diff = y_offset - advance_y_offset; + + if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { + var allocating = std.Io.Writer.Allocating.init(alloc); + const writer = &allocating.writer; + const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("\\u{{{x}}}", .{cp.codepoint}); + } + try writer.writeAll(" → "); + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + } + const formatted_cps = try allocating.toOwnedSlice(); + + log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ + cluster, + x_offset, + y_offset, + advance_x_offset, + advance_y_offset, + x_offset_diff, + y_offset_diff, + state.codepoints.items[index].codepoint, + formatted_cps, + }); } self.cell_buf.appendAssumeCapacity(.{ .x = @intCast(cluster), - .x_offset = @intFromFloat(@round(cell_offset.x)), - .y_offset = @intFromFloat(@round(cell_offset.y)), + .x_offset = @intFromFloat(@round(x_offset)), + .y_offset = @intFromFloat(@round(y_offset)), .glyph_index = glyph, }); - // Add our advances to keep track of our current cell offsets. + // Add our advances to keep track of our run offsets. // Advances apply to the NEXT cell. - cell_offset.x += advance.width; - cell_offset.y += advance.height; + run_offset.x += advance.width; + run_offset.y += advance.height; } } From f4560390d7ad6abdb15a61ccc09f02895cabf538 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Thu, 11 Dec 2025 09:35:40 -0500 Subject: [PATCH 2/6] Remove accidental changes to macos/text/run.ig --- pkg/macos/text/run.zig | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/pkg/macos/text/run.zig b/pkg/macos/text/run.zig index a34cd5307..2895bfe34 100644 --- a/pkg/macos/text/run.zig +++ b/pkg/macos/text/run.zig @@ -106,19 +106,6 @@ pub const Run = opaque { pub fn getStatus(self: *Run) Status { return @bitCast(c.CTRunGetStatus(@ptrCast(self))); } - - pub fn getAttributes(self: *Run) *foundation.Dictionary { - return @ptrCast(@constCast(c.CTRunGetAttributes(@ptrCast(self)))); - } - - pub fn getFont(self: *Run) ?*text.Font { - const attrs = self.getAttributes(); - const font_ptr = attrs.getValue(*const anyopaque, c.kCTFontAttributeName); - if (font_ptr) |ptr| { - return @ptrCast(@constCast(ptr)); - } - return null; - } }; /// https://developer.apple.com/documentation/coretext/ctrunstatus?language=objc From 6addccdeeb450fbee6661c36e3ccdbecd94e94d4 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Thu, 11 Dec 2025 10:48:28 -0500 Subject: [PATCH 3/6] Add shape Tai Tham vowels test --- src/font/shaper/coretext.zig | 149 +++++++++++++++++++++++++---------- src/terminal/Terminal.zig | 2 +- 2 files changed, 108 insertions(+), 43 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 498b45799..32b7ab77b 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -390,8 +390,8 @@ pub const Shaper = struct { y: f64 = 0, // For debugging positions, turn this on: - start_index: usize = 0, - end_index: usize = 0, + //start_index: usize = 0, + //end_index: usize = 0, } = .{}; // Clear our cell buf and make sure we have enough room for the whole @@ -450,53 +450,56 @@ pub const Shaper = struct { .y = run_offset.y, // For debugging positions, turn this on: - .start_index = index, - .end_index = index, + //.start_index = index, + //.end_index = index, }; - } else { - if (index < cell_offset.start_index) { - cell_offset.start_index = index; - } - if (index > cell_offset.end_index) { - cell_offset.end_index = index; - } + + // For debugging positions, turn this on: + //} else { + // if (index < cell_offset.start_index) { + // cell_offset.start_index = index; + // } + // if (index > cell_offset.end_index) { + // cell_offset.end_index = index; + // } } const x_offset = position.x - cell_offset.x; const y_offset = position.y - cell_offset.y; - const advance_x_offset = run_offset.x - cell_offset.x; - const advance_y_offset = run_offset.y - cell_offset.y; - const x_offset_diff = x_offset - advance_x_offset; - const y_offset_diff = y_offset - advance_y_offset; + // Ford debugging positions, turn this on: + //const advance_x_offset = run_offset.x - cell_offset.x; + //const advance_y_offset = run_offset.y - cell_offset.y; + //const x_offset_diff = x_offset - advance_x_offset; + //const y_offset_diff = y_offset - advance_y_offset; - if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { - var allocating = std.Io.Writer.Allocating.init(alloc); - const writer = &allocating.writer; - const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; - for (codepoints) |cp| { - if (cp.codepoint == 0) continue; // Skip surrogate pair padding - try writer.print("\\u{{{x}}}", .{cp.codepoint}); - } - try writer.writeAll(" → "); - for (codepoints) |cp| { - if (cp.codepoint == 0) continue; // Skip surrogate pair padding - try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); - } - const formatted_cps = try allocating.toOwnedSlice(); + //if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { + // var allocating = std.Io.Writer.Allocating.init(alloc); + // const writer = &allocating.writer; + // const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; + // for (codepoints) |cp| { + // if (cp.codepoint == 0) continue; // Skip surrogate pair padding + // try writer.print("\\u{{{x}}}", .{cp.codepoint}); + // } + // try writer.writeAll(" → "); + // for (codepoints) |cp| { + // if (cp.codepoint == 0) continue; // Skip surrogate pair padding + // try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + // } + // const formatted_cps = try allocating.toOwnedSlice(); - log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ - cluster, - x_offset, - y_offset, - advance_x_offset, - advance_y_offset, - x_offset_diff, - y_offset_diff, - state.codepoints.items[index].codepoint, - formatted_cps, - }); - } + // log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ + // cluster, + // x_offset, + // y_offset, + // advance_x_offset, + // advance_y_offset, + // x_offset_diff, + // y_offset_diff, + // state.codepoints.items[index].codepoint, + // formatted_cps, + // }); + //} self.cell_buf.appendAssumeCapacity(.{ .x = @intCast(cluster), @@ -1332,7 +1335,7 @@ test "shape with empty cells in between" { } } -test "shape Chinese characters" { +test "shape Combining characters" { const testing = std.testing; const alloc = testing.allocator; @@ -1350,6 +1353,9 @@ test "shape Chinese characters" { var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); defer t.deinit(alloc); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + var s = t.vtStream(); defer s.deinit(); try s.nextSlice(buf[0..buf_idx]); @@ -1397,6 +1403,9 @@ test "shape Devanagari string" { var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); defer t.deinit(alloc); + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); + var s = t.vtStream(); defer s.deinit(); try s.nextSlice("अपार्टमेंट"); @@ -1429,6 +1438,62 @@ test "shape Devanagari string" { try testing.expect(try it.next(alloc) == null); } +test "shape Tai Tham vowels (position differs from advance)" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Tai Tham for this to work, if we can't find + // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Tai Tham", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ + buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + const cell_width = run.grid.metrics.cell_width; + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + + // The first glyph renders in the next cell + try testing.expectEqual(@as(i16, @intCast(cell_width)), cells[0].x_offset); + try testing.expectEqual(@as(i16, 0), cells[1].x_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + test "shape box glyphs" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6c9db6a8d..b0d43c192 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -8893,7 +8893,7 @@ test "Terminal: insertBlanks shift graphemes" { var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - // Disable grapheme clustering + // Enable grapheme clustering t.modes.set(.grapheme_cluster, true); try t.printString("A"); From 075ef6980bfaa8f6c196bdc2124e78eaccd391bb Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Fri, 12 Dec 2025 09:27:45 -0500 Subject: [PATCH 4/6] Fix comment typo --- src/font/shaper/coretext.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 32b7ab77b..15ac5762b 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -467,7 +467,7 @@ pub const Shaper = struct { const x_offset = position.x - cell_offset.x; const y_offset = position.y - cell_offset.y; - // Ford debugging positions, turn this on: + // For debugging positions, turn this on: //const advance_x_offset = run_offset.x - cell_offset.x; //const advance_y_offset = run_offset.y - cell_offset.y; //const x_offset_diff = x_offset - advance_x_offset; From 139a23a0a2f4879c884dfab075c45ca93eb5ae64 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Wed, 17 Dec 2025 09:57:32 -0500 Subject: [PATCH 5/6] Pull out debugging into a separate function. --- src/font/shaper/coretext.zig | 136 +++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 54 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 15ac5762b..6b01d79aa 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -103,6 +103,17 @@ pub const Shaper = struct { } }; + const RunOffset = struct { + x: f64 = 0, + y: f64 = 0, + }; + + const CellOffset = struct { + cluster: u32 = 0, + x: f64 = 0, + y: f64 = 0, + }; + /// Create a CoreFoundation Dictionary suitable for /// settings the font features of a CoreText font. fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary { @@ -378,21 +389,14 @@ pub const Shaper = struct { self.cf_release_pool.appendAssumeCapacity(line); // This keeps track of the current offsets within a run. - var run_offset: struct { - x: f64 = 0, - y: f64 = 0, - } = .{}; + var run_offset: RunOffset = .{}; // This keeps track of the current offsets within a cell. - var cell_offset: struct { - cluster: u32 = 0, - x: f64 = 0, - y: f64 = 0, + var cell_offset: CellOffset = .{}; - // For debugging positions, turn this on: - //start_index: usize = 0, - //end_index: usize = 0, - } = .{}; + // For debugging positions, turn this on: + //var start_index: usize = 0; + //var end_index: usize = 0; // Clear our cell buf and make sure we have enough room for the whole // line of glyphs, so that we can just assume capacity when appending @@ -448,59 +452,26 @@ pub const Shaper = struct { .cluster = cluster, .x = run_offset.x, .y = run_offset.y, - - // For debugging positions, turn this on: - //.start_index = index, - //.end_index = index, }; // For debugging positions, turn this on: + // start_index = index; + // end_index = index; //} else { - // if (index < cell_offset.start_index) { - // cell_offset.start_index = index; + // if (index < start_index) { + // start_index = index; // } - // if (index > cell_offset.end_index) { - // cell_offset.end_index = index; + // if (index > end_index) { + // end_index = index; // } } + // For debugging positions, turn this on: + //try self.debugPositions(alloc, run_offset, cell_offset, position, start_index, end_index, index); + const x_offset = position.x - cell_offset.x; const y_offset = position.y - cell_offset.y; - // For debugging positions, turn this on: - //const advance_x_offset = run_offset.x - cell_offset.x; - //const advance_y_offset = run_offset.y - cell_offset.y; - //const x_offset_diff = x_offset - advance_x_offset; - //const y_offset_diff = y_offset - advance_y_offset; - - //if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { - // var allocating = std.Io.Writer.Allocating.init(alloc); - // const writer = &allocating.writer; - // const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; - // for (codepoints) |cp| { - // if (cp.codepoint == 0) continue; // Skip surrogate pair padding - // try writer.print("\\u{{{x}}}", .{cp.codepoint}); - // } - // try writer.writeAll(" → "); - // for (codepoints) |cp| { - // if (cp.codepoint == 0) continue; // Skip surrogate pair padding - // try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); - // } - // const formatted_cps = try allocating.toOwnedSlice(); - - // log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ - // cluster, - // x_offset, - // y_offset, - // advance_x_offset, - // advance_y_offset, - // x_offset_diff, - // y_offset_diff, - // state.codepoints.items[index].codepoint, - // formatted_cps, - // }); - //} - self.cell_buf.appendAssumeCapacity(.{ .x = @intCast(cluster), .x_offset = @intFromFloat(@round(x_offset)), @@ -680,6 +651,63 @@ pub const Shaper = struct { _ = self; } }; + + fn debugPositions( + self: *Shaper, + alloc: Allocator, + run_offset: RunOffset, + cell_offset: CellOffset, + position: macos.graphics.Point, + start_index: usize, + end_index: usize, + index: usize, + ) !void { + const state = &self.run_state; + const x_offset = position.x - cell_offset.x; + const y_offset = position.y - cell_offset.y; + const advance_x_offset = run_offset.x - cell_offset.x; + const advance_y_offset = run_offset.y - cell_offset.y; + const x_offset_diff = x_offset - advance_x_offset; + const y_offset_diff = y_offset - advance_y_offset; + + if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { + var allocating = std.Io.Writer.Allocating.init(alloc); + const writer = &allocating.writer; + const codepoints = state.codepoints.items[start_index .. end_index + 1]; + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("\\u{{{x}}}", .{cp.codepoint}); + } + try writer.writeAll(" → "); + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + } + const formatted_cps = try allocating.toOwnedSlice(); + + // Note that the codepoints from `start_index .. end_index + 1` + // might not include all the codepoints being shaped. Sometimes a + // codepoint gets represented in a glyph with a later codepoint + // such that the index for the former codepoint is skipped and just + // the index for the latter codepoint is used. Additionally, this + // gets called as we iterate through the glyphs, so it won't + // include the codepoints that come later that might be affecting + // positions for the current glyph. Usually though, for that case + // the positions of the later glyphs will also be affected and show + // up in the logs. + log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ + cell_offset.cluster, + x_offset, + y_offset, + advance_x_offset, + advance_y_offset, + x_offset_diff, + y_offset_diff, + state.codepoints.items[index].codepoint, + formatted_cps, + }); + } + } }; test "run iterator" { From d820a633eeb293d8da7052a0d31097a7c0023d18 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 08:34:30 -0800 Subject: [PATCH 6/6] fix up typos --- typos.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/typos.toml b/typos.toml index 26876aef9..27ec9d684 100644 --- a/typos.toml +++ b/typos.toml @@ -56,6 +56,8 @@ DECID = "DECID" flate = "flate" typ = "typ" kend = "kend" +# Tai Tham is a script/writing system +Tham = "Tham" # GTK GIR = "GIR" # terminfo