Skip to content

Commit 328e295

Browse files
authored
Merge pull request #26 from wiedymi/agent/fix-cjk-fallback-rendering
fix(fonts): preserve visible CJK fallbacks
2 parents fbdc10e + 080c546 commit 328e295

16 files changed

Lines changed: 274 additions & 35 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ This project follows SemVer. While restty is pre-1.0, breaking public API change
2020

2121
### Docs
2222

23+
## [0.2.1] - 2026-07-16
24+
25+
### Fixes
26+
27+
- Kept wide CJK fallback glyphs at their natural em scale instead of enlarging them to fill two cells, and centered them across their occupied cells for consistent baseline and sizing in both WebGPU and WebGL2.
28+
- Skipped fallback fonts whose claimed glyph rasterizes without visible coverage, allowing later CJK fallbacks to render instead of leaving blank text with fonts such as variable PingFang.
29+
- Updated text-shaper to support current Noto Emoji WOFF2 fonts whose outlines use compact `255UInt16` encodings.
30+
2331
## [0.2.0] - 2026-07-04
2432

2533
### Breaking Changes

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "restty",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"description": "Browser terminal rendering library powered by WASM, WebGPU/WebGL2, and TypeScript text shaping.",
55
"keywords": [
66
"ansi",
@@ -97,7 +97,7 @@
9797
"playground:preview": "bun run playground/server.ts"
9898
},
9999
"dependencies": {
100-
"text-shaper": "0.1.23"
100+
"text-shaper": "0.1.26"
101101
},
102102
"devDependencies": {
103103
"@tailwindcss/vite": "^4.2.4",

src/fonts/manager/entries.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export function createFontEntry(font: Font, label: string): FontEntry {
88
label,
99
glyphCache: new Map(),
1010
boundsCache: new Map(),
11+
glyphCoverageCache: new Map(),
1112
colorGlyphTexts: new Map(),
1213
glyphIds: new Set(),
1314
atlas: null,
@@ -24,6 +25,7 @@ export function createFontEntry(font: Font, label: string): FontEntry {
2425
export function resetFontEntry(entry: FontEntry): void {
2526
entry.glyphCache.clear();
2627
entry.boundsCache.clear();
28+
entry.glyphCoverageCache.clear();
2729
entry.colorGlyphTexts.clear();
2830
entry.glyphIds.clear();
2931
entry.atlas = null;

src/fonts/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export type FontEntry = {
3333
glyphCache: Map<string, ShapedCluster>;
3434
/** Cache of glyph advance bounds keyed by glyph ID. */
3535
boundsCache: Map<number, number>;
36+
/** Cache of whether a claimed glyph produces visible raster coverage. */
37+
glyphCoverageCache: Map<number, boolean>;
3638
/** Map of glyph IDs to their original text for color emoji fallback. */
3739
colorGlyphTexts: Map<number, string>;
3840
/** Set of all glyph IDs available in this font. */

src/runtime/create-runtime/font-runtime/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export function createRuntimeFontRuntimeHelpers(options: CreateRuntimeFontRuntim
4747
UnicodeBuffer,
4848
shape,
4949
glyphBufferToShapedGlyphs,
50+
rasterizeGlyph,
5051
});
5152

5253
const gridHelpers = createFontRuntimeGridHelpers({

src/runtime/create-runtime/font-runtime/text.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import {
33
isSymbolFont,
44
isColorEmojiFont,
55
isNerdSymbolFont,
6-
type Font,
76
type FontEntry,
87
} from "../../../fonts";
98
import { isCoverageIgnorableCodepoint, resolvePresentationPreference } from "../codepoint-utils";
9+
import { glyphHasVisibleRaster } from "../../fonts/glyph-coverage";
1010
import type { CreateFontRuntimeTextHelpersOptions } from "./text.types";
1111

1212
function setBoundedMap<K, V>(map: Map<K, V>, key: K, value: V, limit: number): void {
@@ -29,6 +29,7 @@ export function createFontRuntimeTextHelpers(options: CreateFontRuntimeTextHelpe
2929
UnicodeBuffer,
3030
shape,
3131
glyphBufferToShapedGlyphs,
32+
rasterizeGlyph,
3233
} = options;
3334

3435
function shapeClusterWithFont(entry: FontEntry, text: string) {
@@ -56,9 +57,21 @@ export function createFontRuntimeTextHelpers(options: CreateFontRuntimeTextHelpe
5657
entry.colorGlyphTexts.set(glyphId, text);
5758
}
5859

59-
function fontHasGlyph(font: Font, ch: string): boolean {
60-
const glyphId = font.glyphIdForChar(ch);
61-
return glyphId !== undefined && glyphId !== null && glyphId !== 0;
60+
function fontHasGlyph(entry: FontEntry, ch: string, probeCoverage: boolean): boolean {
61+
const glyphId = entry.font.glyphIdForChar(ch);
62+
if (glyphId === undefined || glyphId === null || glyphId === 0) return false;
63+
if (!probeCoverage || isColorEmojiFont(entry)) return true;
64+
65+
const cached = entry.glyphCoverageCache.get(glyphId);
66+
if (cached !== undefined) return cached;
67+
const visible = glyphHasVisibleRaster({
68+
font: entry.font,
69+
glyphId,
70+
sizeMode: fontState.sizeMode,
71+
rasterizeGlyph,
72+
});
73+
entry.glyphCoverageCache.set(glyphId, visible);
74+
return visible;
6275
}
6376

6477
function pickFontIndexForText(text: string, expectedSpan = 1, stylePreference = "regular") {
@@ -106,7 +119,7 @@ export function createFontRuntimeTextHelpers(options: CreateFontRuntimeTextHelpe
106119
if (predicate && !predicate(entry)) continue;
107120
let ok = true;
108121
for (const ch of requiredChars) {
109-
if (!fontHasGlyph(entry.font, ch)) {
122+
if (!fontHasGlyph(entry, ch, i > 0)) {
110123
ok = false;
111124
break;
112125
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { FontManagerState } from "../../../fonts";
2-
import type { GlyphBufferToShapedGlyphsFn, ShapeFn, UnicodeBufferCtor } from "./types";
2+
import type {
3+
GlyphBufferToShapedGlyphsFn,
4+
RasterizeGlyphFn,
5+
ShapeFn,
6+
UnicodeBufferCtor,
7+
} from "./types";
38

49
export type CreateFontRuntimeTextHelpersOptions = {
510
fontState: FontManagerState;
@@ -8,4 +13,5 @@ export type CreateFontRuntimeTextHelpersOptions = {
813
UnicodeBuffer: UnicodeBufferCtor;
914
shape: ShapeFn;
1015
glyphBufferToShapedGlyphs: GlyphBufferToShapedGlyphsFn;
16+
rasterizeGlyph: RasterizeGlyphFn;
1117
};

src/runtime/create-runtime/render-tick-webgl-context.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Color, WebGLState } from "../../renderer";
22
import { fallbackEmScale, type Font, type FontEntry } from "../../fonts";
33
import type { GlyphConstraintMeta } from "../fonts/atlas-builder";
4+
import { resolveWideFallbackScale } from "../fonts/fallback-layout";
45
import type { GlyphQueueItem } from "./render-tick-webgpu.types";
56
import type { WebGLTickContext, WebGLTickDeps } from "./render-tick-webgl.types";
67

@@ -309,10 +310,12 @@ export function buildWebGLTickContext(
309310
const maxSpan = fontMaxCellSpan(entry);
310311
if (maxSpan > 1) {
311312
const advanceUnits = fontAdvanceUnits(entry, shapeClusterWithFont);
312-
const widthPx = advanceUnits * adjustedScale;
313-
const widthAdjustRaw = widthPx > 0 ? (cellW * maxSpan) / widthPx : 1;
314-
const widthAdjust = clamp(widthAdjustRaw, 0.5, 2);
315-
adjustedScale *= widthAdjust;
313+
adjustedScale = resolveWideFallbackScale({
314+
scale: adjustedScale,
315+
advanceUnits,
316+
cellWidth: cellW,
317+
maxSpan,
318+
});
316319
}
317320
const adjustedHeightPx = fontHeightUnits(entry.font) * adjustedScale;
318321
if (adjustedHeightPx > lineHeight && adjustedHeightPx > 0) {

src/runtime/create-runtime/render-tick-webgl-glyph-pipeline.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { GlyphQueueItem } from "./render-tick-webgpu.types";
22
import type { WebGLTickContext } from "./render-tick-webgl.types";
3+
import { resolveFallbackGlyphCenterX } from "../fonts/fallback-layout";
34

45
export function renderWebGLGlyphPipeline(ctx: WebGLTickContext) {
56
const {
@@ -249,15 +250,15 @@ export function renderWebGLGlyphPipeline(ctx: WebGLTickContext) {
249250
item.xPad +
250251
(penX + glyph.xOffset) * itemScale +
251252
metrics.bearingX * bitmapScale;
252-
if (
253-
fontIndex > 0 &&
254-
item.shaped.glyphs.length === 1 &&
255-
!symbolLike &&
256-
maxWidth <= cellW * 1.05
257-
) {
258-
const center = item.x + (maxWidth - gw) * 0.5;
259-
x = center;
260-
}
253+
const centeredX = resolveFallbackGlyphCenterX({
254+
cellX: item.x,
255+
cellWidth: maxWidth,
256+
glyphWidth: gw,
257+
isFallback: fontIndex > 0,
258+
glyphCount: item.shaped.glyphs.length,
259+
symbolLike,
260+
});
261+
if (centeredX !== null) x = centeredX;
261262
const minX = item.x;
262263
const maxX = item.x + maxWidth;
263264
if (x < minX) x = minX;

0 commit comments

Comments
 (0)