fix(terminal): fix black block glyphs on Linux local terminal (#1364) (#1369)

* fix(terminal): resolve bold font weight without document.fonts.check false positives

Chromium reports unavailable bold weights as available, so xterm tried to rasterize weight 700 while the bundled JetBrains Mono fallback only ships 400/500/600. Bold glyphs then rendered as black blocks on Linux local terminals (fixes #1364).

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop unused primaryFontFamily from terminal effects context

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
陈大猫
2026-06-10 14:39:36 +08:00
committed by GitHub
parent 068730c53c
commit 85b552e1a6
6 changed files with 233 additions and 43 deletions

View File

@@ -50,7 +50,7 @@ import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTermi
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
import {
@@ -1118,7 +1118,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isSupportedOs, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};

View File

@@ -28,6 +28,7 @@ import {
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
} from "../../../domain/terminalAppearance";
import { resolveFontWeightBold } from "../../../lib/fontWeightAvailability";
import { logger } from "../../../lib/logger";
import { isMacPlatform } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
@@ -213,11 +214,8 @@ const csiParamsInclude = (
/**
* Extract the primary font family from a CSS font-family string that may
* include fallback fonts. `document.fonts.check` returns `false` when *any*
* listed font is still loading, so passing the entire CJK fallback stack
* causes false negatives during early terminal creation which in turn makes
* `fontWeightBold` fall back to the normal weight and renders bold text too
* thin.
* include fallback fonts. Used by autocomplete and other helpers that need
* the first face without the CJK / icon fallback stack.
*/
export const primaryFontFamily = (fontFamily: string): string => {
// Split on commas that are NOT inside quotes to handle font names like "Foo, Bar"
@@ -278,13 +276,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const keywordHighlightEnabled = settings?.keywordHighlightEnabled ?? false;
const kittyKeyboardMode = createKittyKeyboardModeState();
const resolvedFontWeightBold = (() => {
if (typeof document === "undefined" || !document.fonts?.check) {
return fontWeightBold;
}
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
})();
const resolvedFontWeightBold = resolveFontWeightBold({
fontFamilyCss: fontFamily,
normalWeight: fontWeight,
desiredBoldWeight: fontWeightBold,
fontSize: effectiveFontSize,
});
const term = new XTerm({
...performanceConfig.options,

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useRef } from 'react';
import { resolveFontWeightBold } from '../../lib/fontWeightAvailability';
type TerminalEffectsContext = Record<string, any>;
@@ -47,7 +48,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
}
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
// Remember the last layout we successfully refit while visible so revisiting
// the same workspace tab does not replay expensive force-fit/WebGL recovery.
@@ -453,16 +454,12 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
| 700
| 800
| 900;
const resolvedFontWeightBold = (() => {
const fontFamily = termRef.current?.options.fontFamily || "";
if (typeof document === "undefined" || !document.fonts?.check) {
return terminalSettings.fontWeightBold;
}
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: effectiveFontWeight;
})();
const resolvedFontWeightBold = resolveFontWeightBold({
fontFamilyCss: termRef.current?.options.fontFamily || "",
normalWeight: effectiveFontWeight,
desiredBoldWeight: terminalSettings.fontWeightBold,
fontSize: effectiveFontSize,
});
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
| 100
@@ -662,23 +659,22 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
}
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
const resolvedBold = document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: effectiveFontWeight;
termRef.current.options.fontWeightBold = resolvedBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
}
const resolvedBold = resolveFontWeightBold({
fontFamilyCss: termRef.current.options?.fontFamily || "",
normalWeight: effectiveFontWeight,
desiredBoldWeight: terminalSettings.fontWeightBold,
fontSize: effectiveFontSize,
});
termRef.current.options.fontWeightBold = resolvedBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
}
const id = sessionRef.current;

View File

@@ -22,7 +22,7 @@
import { splitFontFamilyList } from '../infrastructure/config/cjkFonts';
const KNOWN_BUNDLED_FAMILIES = new Set<string>([
'JetBrains Mono', // @fontsource/jetbrains-mono (regular, 500, 600)
'JetBrains Mono', // @fontsource/jetbrains-mono (400, 500, 600)
'Sarasa Mono SC', // public/fonts/SarasaMonoSC-Regular.woff2 (OFL)
]);

View File

@@ -0,0 +1,93 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
isBoldWeightDistinctWithContext,
pickNearestBundledWeight,
resolveFontWeightBold,
} from './fontWeightAvailability';
function makeWeightContext(weightsByFamily: Record<string, Partial<Record<number, number>>>) {
return {
measureText: (font: string, text: string) => {
const match = font.match(/^(\d+)\s+\d+px\s+"?([^",]+)"?,/);
const weight = match ? Number(match[1]) : 400;
const family = match?.[2] ?? '';
const width = weightsByFamily[family]?.[weight] ?? weightsByFamily[family]?.[400] ?? 100;
return {
width: width * text.length,
actualBoundingBoxAscent: 10,
actualBoundingBoxDescent: 2,
} as TextMetrics;
},
};
}
describe('pickNearestBundledWeight', () => {
it('returns the desired weight when bundled', () => {
assert.equal(pickNearestBundledWeight([400, 500, 600], 600, 400), 600);
});
it('falls back to the nearest heavier bundled weight', () => {
assert.equal(pickNearestBundledWeight([400, 500, 600], 700, 400), 600);
});
it('returns normal weight when nothing heavier is bundled', () => {
assert.equal(pickNearestBundledWeight([400], 700, 400), 400);
});
});
describe('isBoldWeightDistinctWithContext', () => {
it('detects a real bold face via width differences', () => {
const ctx = makeWeightContext({
Menlo: { 400: 10, 700: 12 },
});
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), true);
});
it('detects a real bold face via ascent differences', () => {
const ctx = {
measureText: (font: string, text: string) => {
const isBold = font.startsWith('700 ');
return {
width: text.length * 10,
actualBoundingBoxAscent: isBold ? 12 : 10,
actualBoundingBoxDescent: 2,
} as TextMetrics;
},
};
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), true);
});
it('rejects unavailable bold weights that collapse to the normal face', () => {
const ctx = makeWeightContext({
Menlo: { 400: 10, 700: 10 },
});
assert.equal(isBoldWeightDistinctWithContext('Menlo', 400, 700, 14, ctx), false);
});
});
describe('resolveFontWeightBold', () => {
it('caps bundled JetBrains Mono bold at 600 when 700 is requested', () => {
assert.equal(
resolveFontWeightBold({
fontFamilyCss: '"JetBrains Mono", monospace',
normalWeight: 400,
desiredBoldWeight: 700,
fontSize: 14,
}),
600,
);
});
it('returns normal weight when bold is not heavier than normal', () => {
assert.equal(
resolveFontWeightBold({
fontFamilyCss: '"JetBrains Mono", monospace',
normalWeight: 600,
desiredBoldWeight: 500,
fontSize: 14,
}),
600,
);
});
});

View File

@@ -0,0 +1,104 @@
import { extractPrimaryFamily } from './fontAvailability';
/** Weights actually shipped via @fontsource in index.tsx. */
export const BUNDLED_FONT_WEIGHTS: Readonly<Record<string, readonly number[]>> = {
'JetBrains Mono': [400, 500, 600],
};
export type FontWeightMeasureContext = {
measureText: (font: string, text: string) => TextMetrics;
};
const BOLD_PROBE = 'WMwm0123456789';
export function pickNearestBundledWeight(
available: readonly number[],
desired: number,
normal: number,
): number {
if (available.includes(desired)) return desired;
const heavier = available.filter((weight) => weight > normal);
if (heavier.length === 0) return normal;
return heavier.reduce((best, weight) =>
Math.abs(weight - desired) < Math.abs(best - desired) ? weight : best,
);
}
/**
* True when rendering `boldWeight` produces measurably different glyphs than
* `normalWeight` for `family`. Unlike document.fonts.check(), this does not
* false-positive on syntactically valid but unavailable families/weights in
* Chromium (see fontAvailability.ts).
*/
export function isBoldWeightDistinctWithContext(
family: string,
normalWeight: number,
boldWeight: number,
fontSize: number,
ctx: FontWeightMeasureContext,
): boolean {
if (boldWeight <= normalWeight) return false;
const quoted = /\s/.test(family) ? `"${family}"` : family;
const normalFont = `${normalWeight} ${fontSize}px ${quoted}, monospace`;
const boldFont = `${boldWeight} ${fontSize}px ${quoted}, monospace`;
const normalMetrics = ctx.measureText(normalFont, BOLD_PROBE);
const boldMetrics = ctx.measureText(boldFont, BOLD_PROBE);
if (Math.abs(boldMetrics.width - normalMetrics.width) > 0.01) return true;
const normalAscent = normalMetrics.actualBoundingBoxAscent ?? 0;
const boldAscent = boldMetrics.actualBoundingBoxAscent ?? 0;
if (Math.abs(boldAscent - normalAscent) > 0.01) return true;
const normalDescent = normalMetrics.actualBoundingBoxDescent ?? 0;
const boldDescent = boldMetrics.actualBoundingBoxDescent ?? 0;
return Math.abs(boldDescent - normalDescent) > 0.01;
}
function buildBrowserMeasureContext(): FontWeightMeasureContext | null {
if (typeof document === 'undefined') return null;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;
return {
measureText: (font, text) => {
ctx.font = font;
return ctx.measureText(text);
},
};
}
/**
* Resolve the boldest weight xterm can safely rasterize for the primary font.
* Falls back to `normalWeight` when the requested bold face is unavailable.
*/
export function resolveFontWeightBold(args: {
fontFamilyCss: string;
normalWeight: number;
desiredBoldWeight: number;
fontSize: number;
}): number {
const { fontFamilyCss, normalWeight, desiredBoldWeight, fontSize } = args;
if (desiredBoldWeight <= normalWeight) return normalWeight;
const primary = extractPrimaryFamily(fontFamilyCss);
const bundled = BUNDLED_FONT_WEIGHTS[primary];
if (bundled) {
return pickNearestBundledWeight(bundled, desiredBoldWeight, normalWeight);
}
const ctx = buildBrowserMeasureContext();
if (!ctx) return desiredBoldWeight;
return isBoldWeightDistinctWithContext(
primary,
normalWeight,
desiredBoldWeight,
fontSize,
ctx,
)
? desiredBoldWeight
: normalWeight;
}