Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ This project follows SemVer. While restty is pre-1.0, breaking public API change

### Docs

## [0.2.4] - 2026-07-16

### Fixes

- Matched Ghostty's default em-normalized `ic_width` adjustment for fallback fonts, using the primary printable-ASCII box and the fallback ideograph advance to remove excessive spacing around CJK glyphs while preserving their shared baseline and two-cell bound.

## [0.2.3] - 2026-07-16

### Fixes
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "restty",
"version": "0.2.3",
"version": "0.2.4",
"description": "Browser terminal rendering library powered by WASM, WebGPU/WebGL2, and TypeScript text shaping.",
"keywords": [
"ansi",
Expand Down
65 changes: 9 additions & 56 deletions src/runtime/create-runtime/render-tick-webgl-context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { Color, WebGLState } from "../../renderer";
import { fallbackEmScale, type Font, type FontEntry } from "../../fonts";
import { fallbackEmScale } from "../../fonts";
import type { GlyphConstraintMeta } from "../fonts/atlas-builder";
import { resolveFallbackBaselineAdjust, resolveFallbackTextScale } from "../fonts/fallback-layout";
import {
resolveFallbackBaselineAdjust,
resolveFallbackIcWidth,
resolveFallbackScaleAdjustment,
resolveFallbackTextScale,
} from "../fonts/fallback-layout";
import type { GlyphQueueItem } from "./render-tick-webgpu.types";
import type { WebGLTickContext, WebGLTickDeps } from "./render-tick-webgl.types";

Expand Down Expand Up @@ -210,57 +215,6 @@ export function buildWebGLTickContext(
const fgColorCache = new Map<number, Color>();
const bgColorCache = new Map<number, Color>();
const ulColorCache = new Map<number, Color>();
type FallbackScaleMetric = "ic_width" | "ex_height" | "cap_height" | "line_height";
const resolveFallbackMetric = (font: Font | null | undefined, metric: FallbackScaleMetric) => {
if (!font) return 0;
if (metric === "ic_width") {
const glyphId = font.glyphIdForChar("水");
if (!glyphId) return 0;
const advance = font.advanceWidth(glyphId);
if (!Number.isFinite(advance) || advance <= 0) return 0;
const bounds = font.getGlyphBounds(glyphId);
// If outline width exceeds advance, ic-width is likely unreliable for scaling.
if (
bounds &&
Number.isFinite(bounds.xMax - bounds.xMin) &&
bounds.xMax - bounds.xMin > advance
) {
return 0;
}
return advance;
}
if (metric === "ex_height") {
const exHeight = font.os2?.sxHeight ?? 0;
return Number.isFinite(exHeight) && exHeight > 0 ? exHeight : 0;
}
if (metric === "cap_height") {
const capHeight = font.os2?.sCapHeight ?? 0;
return Number.isFinite(capHeight) && capHeight > 0 ? capHeight : 0;
}
const lineHeightUnits = font.height;
return Number.isFinite(lineHeightUnits) && lineHeightUnits > 0 ? lineHeightUnits : 0;
};
const fallbackScaleAdjustment = (
primary: FontEntry | undefined,
entry: FontEntry | undefined,
): number => {
if (!primary?.font || !entry?.font) return 1;
const metricOrder: FallbackScaleMetric[] = [
"ic_width",
"ex_height",
"cap_height",
"line_height",
];
for (let i = 0; i < metricOrder.length; i += 1) {
const metric = metricOrder[i];
const primaryMetric = resolveFallbackMetric(primary.font, metric);
const fallbackMetric = resolveFallbackMetric(entry.font, metric);
if (primaryMetric <= 0 || fallbackMetric <= 0) continue;
const factor = primaryMetric / fallbackMetric;
if (Number.isFinite(factor) && factor > 0) return factor;
}
return 1;
};
const baseScaleByFont = fontState.fonts.map((entry, idx) => {
if (!entry?.font) return primaryScale;
if (idx === 0) return primaryScale;
Expand All @@ -283,12 +237,11 @@ export function buildWebGLTickContext(
return baseScale;
}
if (isColorEmojiFont(entry)) return baseScale;
const metricAdjust = clamp(fallbackScaleAdjustment(primaryEntry, entry), 1, 2);
const metricAdjust = resolveFallbackScaleAdjustment(primaryEntry?.font, entry.font);
const maxSpan = fontMaxCellSpan(entry);
const advanceUnits =
maxSpan > 1
? resolveFallbackMetric(entry.font, "ic_width") ||
fontAdvanceUnits(entry, shapeClusterWithFont)
? resolveFallbackIcWidth(entry.font) || fontAdvanceUnits(entry, shapeClusterWithFont)
: 0;
const emScale = fallbackEmScale(primaryEntry?.font, primaryScale, entry.font);
return resolveFallbackTextScale({
Expand Down
62 changes: 9 additions & 53 deletions src/runtime/create-runtime/render-tick-webgpu-cell-pass.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { Color } from "../../renderer";
import { fallbackEmScale, type Font, type FontEntry } from "../../fonts";
import { fallbackEmScale } from "../../fonts";
import type { GlyphConstraintMeta } from "../fonts/atlas-builder";
import { resolveFallbackBaselineAdjust, resolveFallbackTextScale } from "../fonts/fallback-layout";
import {
resolveFallbackBaselineAdjust,
resolveFallbackIcWidth,
resolveFallbackScaleAdjustment,
resolveFallbackTextScale,
} from "../fonts/fallback-layout";
import { resolveLigatureRun, resolveRenderableLigatureRun } from "./ligature-runs";
import type { CollectWebGPUCellPassParams, GlyphQueueItem } from "./render-tick-webgpu.types";
import {
Expand Down Expand Up @@ -126,54 +131,6 @@ export function collectWebGPUCellPass(params: CollectWebGPUCellPassParams) {
const mergedLigatureSkip = new Uint8Array(codepoints.length);

const primaryEntry = fontState.fonts[0];
type FallbackScaleMetric = "ic_width" | "ex_height" | "cap_height" | "line_height";
const resolveFallbackMetric = (font: Font | null | undefined, metric: FallbackScaleMetric) => {
if (!font) return 0;
if (metric === "ic_width") {
const glyphId = font.glyphIdForChar("水");
if (!glyphId) return 0;
const advance = font.advanceWidth(glyphId);
if (!Number.isFinite(advance) || advance <= 0) return 0;
const bounds = font.getGlyphBounds(glyphId);
if (bounds) {
const width = bounds.xMax - bounds.xMin;
// If outline width exceeds advance, ic-width is likely unreliable for scaling.
if (Number.isFinite(width) && width > advance) return 0;
}
return advance;
}
if (metric === "ex_height") {
const exHeight = font.os2?.sxHeight ?? 0;
return Number.isFinite(exHeight) && exHeight > 0 ? exHeight : 0;
}
if (metric === "cap_height") {
const capHeight = font.os2?.sCapHeight ?? 0;
return Number.isFinite(capHeight) && capHeight > 0 ? capHeight : 0;
}
const lineHeightUnits = font.height;
return Number.isFinite(lineHeightUnits) && lineHeightUnits > 0 ? lineHeightUnits : 0;
};
const fallbackScaleAdjustment = (
primary: FontEntry | undefined,
entry: FontEntry | undefined,
): number => {
if (!primary?.font || !entry?.font) return 1;
const metricOrder: FallbackScaleMetric[] = [
"ic_width",
"ex_height",
"cap_height",
"line_height",
];
for (let i = 0; i < metricOrder.length; i += 1) {
const metric = metricOrder[i];
const primaryMetric = resolveFallbackMetric(primary.font, metric);
const fallbackMetric = resolveFallbackMetric(entry.font, metric);
if (primaryMetric <= 0 || fallbackMetric <= 0) continue;
const factor = primaryMetric / fallbackMetric;
if (Number.isFinite(factor) && factor > 0) return factor;
}
return 1;
};
const baseScaleByFont = fontState.fonts.map((entry, idx) => {
if (!entry?.font) return primaryScale;
if (idx === 0) return primaryScale;
Expand All @@ -196,12 +153,11 @@ export function collectWebGPUCellPass(params: CollectWebGPUCellPassParams) {
return baseScale;
}
if (isColorEmojiFont(entry)) return baseScale;
const metricAdjust = clamp(fallbackScaleAdjustment(primaryEntry, entry), 1, 2);
const metricAdjust = resolveFallbackScaleAdjustment(primaryEntry?.font, entry.font);
const maxSpan = fontMaxCellSpan(entry);
const advanceUnits =
maxSpan > 1
? resolveFallbackMetric(entry.font, "ic_width") ||
fontAdvanceUnits(entry, shapeClusterWithFont)
? resolveFallbackIcWidth(entry.font) || fontAdvanceUnits(entry, shapeClusterWithFont)
: 0;
const emScale = fallbackEmScale(primaryEntry?.font, primaryScale, entry.font);
return resolveFallbackTextScale({
Expand Down
134 changes: 131 additions & 3 deletions src/runtime/fonts/fallback-layout.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,131 @@
import type { Font } from "../../fonts";

type FallbackFaceMetrics = {
unitsPerEm: number;
ascender: number;
lineHeight: number;
cellWidth: number;
asciiHeight: number;
icWidth: number;
exHeight: number;
capHeight: number;
};

const fallbackFaceMetricsCache = new WeakMap<Font, FallbackFaceMetrics>();

function positive(value: number | null | undefined): number {
return Number.isFinite(value) && (value ?? 0) > 0 ? (value ?? 0) : 0;
}

function glyphHeight(font: Font, ch: string): number {
const glyphId = font.glyphIdForChar(ch);
if (!glyphId) return 0;
const bounds = font.getGlyphBounds(glyphId);
if (!bounds) return 0;
return positive(bounds.yMax - bounds.yMin);
}

function measureFallbackFace(font: Font): FallbackFaceMetrics {
const cached = fallbackFaceMetricsCache.get(font);
if (cached) return cached;

let cellWidth = 0;
let asciiTop = 0;
let asciiBottom = 0;
for (let codepoint = 32; codepoint < 127; codepoint += 1) {
const glyphId = font.glyphIdForChar(String.fromCodePoint(codepoint));
if (!glyphId) continue;
cellWidth = Math.max(cellWidth, positive(font.advanceWidth(glyphId)));
const bounds = font.getGlyphBounds(glyphId);
if (!bounds) continue;
if (Number.isFinite(bounds.yMax)) asciiTop = Math.max(asciiTop, bounds.yMax);
if (Number.isFinite(bounds.yMin)) asciiBottom = Math.min(asciiBottom, bounds.yMin);
}

let icWidth = 0;
const ideographGlyphId = font.glyphIdForChar("水");
if (ideographGlyphId) {
const advance = positive(font.advanceWidth(ideographGlyphId));
const bounds = font.getGlyphBounds(ideographGlyphId);
const outlineWidth = bounds ? positive(bounds.xMax - bounds.xMin) : 0;
if (advance > 0 && (outlineWidth <= 0 || outlineWidth <= advance)) icWidth = advance;
}

const metrics = {
unitsPerEm: positive(font.unitsPerEm),
ascender: positive(font.ascender),
lineHeight: positive(font.height),
cellWidth,
asciiHeight: positive(asciiTop - asciiBottom),
icWidth,
exHeight: positive(font.os2?.sxHeight) || glyphHeight(font, "x"),
capHeight: positive(font.os2?.sCapHeight) || glyphHeight(font, "H"),
};
fallbackFaceMetricsCache.set(font, metrics);
return metrics;
}

function capHeight(metrics: FallbackFaceMetrics): number {
return metrics.capHeight || metrics.ascender * 0.75;
}

function exHeight(metrics: FallbackFaceMetrics): number {
return metrics.exHeight || capHeight(metrics) * 0.75;
}

function icWidth(metrics: FallbackFaceMetrics): number {
if (metrics.icWidth > 0) return metrics.icWidth;
const asciiHeight = metrics.asciiHeight || capHeight(metrics) * 1.5;
return Math.min(asciiHeight, metrics.cellWidth * 2);
}

function normalizedMetricFactor(
primaryMetric: number,
primary: FallbackFaceMetrics,
fallbackMetric: number,
fallback: FallbackFaceMetrics,
): number {
if (
primaryMetric <= 0 ||
fallbackMetric <= 0 ||
primary.unitsPerEm <= 0 ||
fallback.unitsPerEm <= 0
) {
return 1;
}
const factor = primaryMetric / primary.unitsPerEm / (fallbackMetric / fallback.unitsPerEm);
return Number.isFinite(factor) && factor > 0 ? factor : 1;
}

/** Match Ghostty's default ic-width fallback size adjustment. */
export function resolveFallbackScaleAdjustment(
primaryFont: Font | null | undefined,
fallbackFont: Font | null | undefined,
): number {
if (!primaryFont || !fallbackFont) return 1;
const primary = measureFallbackFace(primaryFont);
const fallback = measureFallbackFace(fallbackFont);

// Ghostty only uses ic-width when the fallback has a valid explicit 水
// advance. Otherwise it falls through ex-height, cap-height, and
// finally line-height while estimating the corresponding primary metric.
if (fallback.icWidth > 0) {
return normalizedMetricFactor(icWidth(primary), primary, fallback.icWidth, fallback);
}
if (fallback.exHeight > 0) {
return normalizedMetricFactor(exHeight(primary), primary, fallback.exHeight, fallback);
}
if (fallback.capHeight > 0) {
return normalizedMetricFactor(capHeight(primary), primary, fallback.capHeight, fallback);
}
return normalizedMetricFactor(primary.lineHeight, primary, fallback.lineHeight, fallback);
}

/** Return a face's valid explicit ideograph advance, if it has one. */
export function resolveFallbackIcWidth(font: Font | null | undefined): number {
return font ? measureFallbackFace(font).icWidth : 0;
}

export type WideFallbackScaleOptions = {
scale: number;
advanceUnits: number;
Expand Down Expand Up @@ -38,15 +166,15 @@ export function resolveFallbackTextScale(options: FallbackTextScaleOptions): num
fontHeightUnits,
lineHeight,
} = options;
if (maxSpan > 1 && primaryEmScale > 0) {
let scale = (primaryEmScale > 0 ? primaryEmScale : baseScale) * metricAdjust;
if (maxSpan > 1) {
return resolveWideFallbackScale({
scale: primaryEmScale,
scale,
advanceUnits,
cellWidth,
maxSpan,
});
}
let scale = baseScale * metricAdjust;
const heightPx = fontHeightUnits * scale;
if (heightPx > lineHeight && heightPx > 0) scale *= lineHeight / heightPx;
return scale;
Expand Down
Loading
Loading