Skip to content

Commit c2cb5e5

Browse files
fix: ensure consistent pixel spacing between all characters
1 parent 30077fa commit c2cb5e5

File tree

1 file changed

+72
-128
lines changed

1 file changed

+72
-128
lines changed

ansifonts/kerning.go

Lines changed: 72 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,6 @@ import (
55
"unicode/utf8"
66
)
77

8-
// runeAt is a helper to safely get a rune at a specific column index within a string.
9-
// It handles multi-byte runes correctly.
10-
func runeAt(s string, x int) (rune, bool) {
11-
if x < 0 {
12-
return ' ', false
13-
}
14-
i := 0
15-
for _, r := range s {
16-
if i == x {
17-
return r, true
18-
}
19-
i++
20-
}
21-
return ' ', false
22-
}
23-
248
// maxRowLen is a helper to find the maximum rune length among all rows of a glyph,
259
// effectively determining its bounding box width.
2610
func maxRowLen(rows []string) int {
@@ -50,140 +34,100 @@ func normalizeGlyph(glyph []string, height int) []string {
5034
out[i] = row
5135
} else {
5236
// Pad with spaces (not empty strings) to maintain consistent width
53-
out[i] = strings.Repeat(" ", maxRowLen(glyph))
37+
out[i] = strings.Repeat(" ", maxLen)
5438
}
5539
}
5640
return out
5741
}
5842

59-
// getActivePixels extracts all non-space pixel coordinates for a given glyph.
60-
// These are the "ink" pixels that contribute to the character's visual form.
61-
func getActivePixels(glyph []string) map[pixelCoord]bool {
62-
activePixels := make(map[pixelCoord]bool)
63-
for y, row := range glyph {
64-
for x := range utf8.RuneCountInString(row) {
65-
r, _ := runeAt(row, x)
66-
// ' ' (space) and '\u0000' (null character) are considered empty/background.
67-
if r != ' ' && r != 0 {
68-
activePixels[pixelCoord{float64(x), y, false}] = true
69-
}
70-
}
43+
// computeKerning calculates the horizontal offset needed to align glyphB relative to glyphA
44+
// such that the minimum distance between their visible pixels is exactly 1 (touching).
45+
// This allows the renderer to add precise character spacing on top of this baseline.
46+
func computeKerning(glyphA, glyphB []string) int {
47+
if len(glyphA) == 0 || len(glyphB) == 0 {
48+
return 0
7149
}
72-
return activePixels
73-
}
7450

75-
// Helper function to calculate absolute value for float64
76-
func abs(x float64) float64 {
77-
if x < 0 {
78-
return -x
79-
}
80-
return x
81-
}
51+
// Normalize heights for line-by-line comparison
52+
h := max(len(glyphA), len(glyphB))
53+
a := normalizeGlyph(glyphA, h)
54+
b := normalizeGlyph(glyphB, h)
8255

83-
// checkCollisionWithSmartBuffer uses enhanced visual analysis to allow better kerning
84-
func checkCollisionWithSmartBuffer(glyphA, glyphB []string, widthA int, spacing int) bool {
85-
aPixels := getActivePixels(glyphA)
56+
widthA := maxRowLen(a)
57+
if widthA == 0 {
58+
return 0
59+
}
8660

87-
// Check for half-pixel alignment issues
88-
heightDiff := len(glyphA) - len(glyphB)
89-
needsHalfPixelAdjustment := heightDiff%2 != 0
61+
minDist := 1000 // effectively infinity
62+
hasOverlap := false
9063

91-
for yB, rowB := range glyphB {
92-
for xB := 0; xB < utf8.RuneCountInString(rowB); xB++ {
93-
rB, _ := runeAt(rowB, xB)
94-
if rB == ' ' || rB == 0 {
95-
continue
96-
}
64+
// Global bounds for fallback (handling non-vertically overlapping characters)
65+
maxAGlobal := -1
66+
minBGlobal := 1000
9767

98-
// Calculate the absolute X position of glyphB's pixel
99-
xAbsB := float64(widthA + spacing + xB)
100-
yAbsB := yB
68+
for y := 0; y < h; y++ {
69+
rowA := []rune(a[y])
70+
rowB := []rune(b[y])
10171

102-
// Apply half-pixel adjustment if needed
103-
if needsHalfPixelAdjustment && yAbsB >= len(glyphB)/2 {
104-
xAbsB += 0.5 // Adjust by half a pixel for the bottom half
72+
// Find rightmost pixel in A on this line
73+
maxA := -1
74+
for x := len(rowA) - 1; x >= 0; x-- {
75+
if rowA[x] != ' ' && rowA[x] != 0 {
76+
maxA = x
77+
break
10578
}
79+
}
10680

107-
// Check for direct overlap
108-
for coord := range aPixels {
109-
// Convert to float64 for comparison
110-
coordX := coord.x
111-
coordY := coord.y
112-
113-
// Check if this pixel would overlap with any pixel in glyphA
114-
if abs(coordX-xAbsB) < 1.0 && coordY == yAbsB {
115-
return true
116-
}
81+
// Find leftmost pixel in B on this line
82+
minB := -1
83+
for x := 0; x < len(rowB); x++ {
84+
if rowB[x] != ' ' && rowB[x] != 0 {
85+
minB = x
86+
break
11787
}
88+
}
11889

119-
// Enhanced adjacency check
120-
conflictCount := 0
121-
for dy := -1; dy <= 1; dy++ {
122-
for dx := -1; dx <= 1; dx++ {
123-
if dx == 0 && dy == 0 {
124-
continue
125-
}
126-
127-
checkX := xAbsB + float64(dx)
128-
checkY := yAbsB + dy
129-
130-
for coord := range aPixels {
131-
coordX := coord.x
132-
coordY := coord.y
133-
134-
// Check proximity with half-pixel precision
135-
xDist := abs(coordX - checkX)
136-
yDist := abs(float64(coordY) - float64(checkY))
137-
138-
if xDist < 1.0 && yDist < 1.0 {
139-
conflictCount++
140-
// Weight horizontal conflicts more heavily
141-
if dx != 0 && dy == 0 {
142-
conflictCount += 2
143-
}
144-
}
145-
}
146-
}
90+
// Update global bounds
91+
if maxA != -1 {
92+
if maxA > maxAGlobal {
93+
maxAGlobal = maxA
14794
}
148-
149-
// Only consider it a collision if there's significant conflict
150-
if conflictCount >= 3 {
151-
return true
95+
}
96+
if minB != -1 {
97+
if minB < minBGlobal {
98+
minBGlobal = minB
15299
}
153100
}
154-
}
155-
156-
return false
157-
}
158-
159-
// computeKerning calculates the minimum horizontal spacing between two characters (glyphA and glyphB)
160-
// such that their active pixels do not touch (even by corners). This spacing is constrained to -1, 0, or +1.
161-
func computeKerning(glyphA, glyphB []string) int {
162-
// If either glyph has no visual width (e.g., empty bitmap), return neutral spacing
163-
if maxRowLen(glyphA) == 0 || maxRowLen(glyphB) == 0 {
164-
return 0 // No adjustment needed for empty glyphs
165-
}
166-
167-
hA, hB := len(glyphA), len(glyphB)
168-
h := max(hA, hB)
169101

170-
a := normalizeGlyph(glyphA, h)
171-
b := normalizeGlyph(glyphB, h)
172-
widthA := maxRowLen(a)
173-
174-
// Check spacing options in order: -1 (tight), 0 (normal), +1 (extra space)
175-
// Return the minimum safe spacing within our constraint range
176-
177-
// First try -1 (tight kerning/tucking)
178-
if !checkCollisionWithSmartBuffer(a, b, widthA, -1) {
179-
return -1 // Safe to tuck closer
102+
// If both have pixels on this line, calculate the horizontal distance
103+
if maxA != -1 && minB != -1 {
104+
// Calculate distance: (Start of B - Start of A)
105+
// We imagine B starts at WidthA.
106+
// PosA = maxA
107+
// PosB = WidthA + minB
108+
// Distance = PosB - PosA
109+
dist := (widthA + minB) - maxA
110+
if dist < minDist {
111+
minDist = dist
112+
}
113+
hasOverlap = true
114+
}
180115
}
181116

182-
// Then try 0 (normal spacing)
183-
if !checkCollisionWithSmartBuffer(a, b, widthA, 0) {
184-
return 0 // Normal spacing works
117+
// If no lines overlap (e.g., punctuation marks at different heights like ' and .),
118+
// fall back to the bounding box horizontal distance.
119+
if !hasOverlap {
120+
if maxAGlobal != -1 && minBGlobal != 1000 {
121+
minDist = (widthA + minBGlobal) - maxAGlobal
122+
} else {
123+
// One or both glyphs are empty space
124+
return 0
125+
}
185126
}
186127

187-
// Finally, use +1 (extra space needed)
188-
return 1 // Need extra space to prevent collision
128+
// We want the closest pixels to be adjacent (distance 1 pixel).
129+
// This creates a visual gap of 0.
130+
// The renderer will add the user's specific spacing on top of this.
131+
// Adjustment = DesiredDistance(1) - ActualDistance(minDist)
132+
return 1 - minDist
189133
}

0 commit comments

Comments
 (0)