From af457c8fbd7cd450a30ce25a34ae101ae971b0e0 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 23 May 2026 04:14:23 -0300 Subject: [PATCH] fix(renderer): align font metrics to device pixel boundaries to prevent seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When devicePixelRatio is non-integer (e.g. 1.25, 1.5, 1.75 from browser zoom or HiDPI displays), rounding cell width/height to the nearest CSS pixel with Math.ceil() produces fractional *physical* pixel coordinates at cell edges. The canvas rasterizer antialiases clearRect/fillRect calls at those sub-pixel boundaries. With alpha:true on the canvas (enabled in #93 for transparent backgrounds), the resulting partially-transparent edge pixels composite against the page background and appear as thin black seams between rows and columns. Fix: round up to the nearest *device* pixel instead of CSS pixel. The +2/+1 paddings for glyph overflow stay in CSS units before the DPR multiplication so they scale correctly. Ports only the font-metrics subset of upstream PR #146 — the rest of that PR bundles a substantial render-loop refactor (startRenderLoop → scheduleRender) and several perf caches whose risk/benefit needs separate evaluation against our current architecture. Co-authored-by: tommyme Inspired-by: https://github.com/coder/ghostty-web/pull/146 --- lib/renderer.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/renderer.ts b/lib/renderer.ts index 3b51bfdd..e6cbe8f3 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -51,9 +51,9 @@ export interface RendererOptions { } export interface FontMetrics { - width: number; // Character cell width in CSS pixels - height: number; // Character cell height in CSS pixels - baseline: number; // Distance from top to text baseline + width: number; // Character cell width in CSS pixels (multiple of 1/devicePixelRatio) + height: number; // Character cell height in CSS pixels (multiple of 1/devicePixelRatio) + baseline: number; // Distance from top to text baseline in CSS pixels } // ============================================================================ @@ -197,16 +197,27 @@ export class CanvasRenderer { // Measure width using 'M' (typically widest character) const widthMetrics = ctx.measureText('M'); - const width = Math.ceil(widthMetrics.width); // Measure height using ascent + descent with padding for glyph overflow const ascent = widthMetrics.actualBoundingBoxAscent || this.fontSize * 0.8; const descent = widthMetrics.actualBoundingBoxDescent || this.fontSize * 0.2; - // Add 2px padding to height to account for glyphs that overflow (like 'f', 'd', 'g', 'p') - // and anti-aliasing pixels - const height = Math.ceil(ascent + descent) + 2; - const baseline = Math.ceil(ascent) + 1; // Offset baseline by half the padding + // Round up to the nearest device pixel (not CSS pixel) so that cell + // boundaries fall on exact physical pixel boundaries at any + // devicePixelRatio. Without this, non-integer DPR values (e.g. 1.25, + // 1.5, 1.75 from browser zoom or HiDPI displays) produce fractional + // physical coordinates at cell edges, which causes the canvas + // rasterizer to antialias clearRect/fillRect at those edges. Combined + // with alpha:true on the canvas (used for transparency support since + // #93), those partially-transparent edge pixels composite against the + // page background and appear as thin black seams between rows/columns. + // + // The +2/+1 pixel paddings stay in CSS-pixel units before the DPR + // multiplication so the glyph-overflow margin scales correctly. + const dpr = this.devicePixelRatio; + const width = Math.ceil(widthMetrics.width * dpr) / dpr; + const height = Math.ceil((ascent + descent + 2) * dpr) / dpr; + const baseline = Math.ceil((ascent + 1) * dpr) / dpr; return { width, height, baseline }; }