Skip to content

Commit 872a2b6

Browse files
tommymeclaude
andcommitted
fix(renderer): align font metrics to device pixel boundaries to prevent seams
When devicePixelRatio is non-integer (e.g. 1.25/1.5/1.75 from browser zoom), rounding cell width/height to the nearest CSS pixel produces fractional physical pixel coordinates at cell edges. The canvas rasterizer antialiases clearRect/fillRect at those sub-pixel boundaries, and with alpha:true the resulting partially-transparent edge pixels composite against the page background and appear as thin black seams between rows and columns. Fix by rounding up to the nearest *device* pixel instead: Math.ceil(value * dpr) / dpr This guarantees cell boundaries always fall on exact physical pixel boundaries regardless of zoom level or monitor DPR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6a1a50d commit 872a2b6

File tree

1 file changed

+14
-8
lines changed

1 file changed

+14
-8
lines changed

lib/renderer.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ export interface RendererOptions {
5151
}
5252

5353
export interface FontMetrics {
54-
width: number; // Character cell width in CSS pixels
55-
height: number; // Character cell height in CSS pixels
56-
baseline: number; // Distance from top to text baseline
54+
width: number; // Character cell width in CSS pixels (multiple of 1/devicePixelRatio)
55+
height: number; // Character cell height in CSS pixels (multiple of 1/devicePixelRatio)
56+
baseline: number; // Distance from top to text baseline in CSS pixels
5757
}
5858

5959
// ============================================================================
@@ -197,16 +197,22 @@ export class CanvasRenderer {
197197

198198
// Measure width using 'M' (typically widest character)
199199
const widthMetrics = ctx.measureText('M');
200-
const width = Math.ceil(widthMetrics.width);
201200

202201
// Measure height using ascent + descent with padding for glyph overflow
203202
const ascent = widthMetrics.actualBoundingBoxAscent || this.fontSize * 0.8;
204203
const descent = widthMetrics.actualBoundingBoxDescent || this.fontSize * 0.2;
205204

206-
// Add 2px padding to height to account for glyphs that overflow (like 'f', 'd', 'g', 'p')
207-
// and anti-aliasing pixels
208-
const height = Math.ceil(ascent + descent) + 2;
209-
const baseline = Math.ceil(ascent) + 1; // Offset baseline by half the padding
205+
// Round up to the nearest device pixel (not CSS pixel) so that cell boundaries
206+
// fall on exact physical pixel boundaries at any devicePixelRatio.
207+
// Without this, non-integer DPR values (e.g. 1.25/1.5/1.75 from browser zoom)
208+
// produce fractional physical coordinates at cell edges, which causes the canvas
209+
// rasterizer to antialias clearRect/fillRect calls there. Combined with alpha:true
210+
// on the canvas, those partially-transparent edge pixels composite against the page
211+
// background and appear as thin black seams between rows/columns.
212+
const dpr = this.devicePixelRatio;
213+
const width = Math.ceil(widthMetrics.width * dpr) / dpr;
214+
const height = Math.ceil((ascent + descent + 2) * dpr) / dpr;
215+
const baseline = Math.ceil((ascent + 1) * dpr) / dpr;
210216

211217
return { width, height, baseline };
212218
}

0 commit comments

Comments
 (0)