Skip to content

Commit 729bc3c

Browse files
committed
Improve font measurement and add multilingual support
1 parent 106ca47 commit 729bc3c

20 files changed

Lines changed: 401 additions & 119 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ Goldens/output/
1717
.vscode/
1818
.idea/
1919
.claude/
20+
dist-wasm

Examples/BasicApp/Package.resolved

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

Examples/BasicApp/WebHost/displayListPlayer.mjs

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const OP_DRAW_TEXT = 7;
1111
const OP_RETAINED_BEGIN = 8;
1212
const OP_RETAINED_END = 9;
1313

14-
export function play(ck, buffer, canvas, typeface) {
14+
export function play(ck, buffer, canvas, ...typefaces) {
1515
const view = new DataView(buffer);
1616
let offset = 0;
1717

@@ -89,28 +89,55 @@ export function play(ck, buffer, canvas, typeface) {
8989
const x = readFloat();
9090
const y = readFloat();
9191
const fontSize = readFloat();
92-
readInt32(); // fontWeight (unused)
92+
const fontWeight = readInt32(); // fontWeight
9393
const color = readUint32();
9494
const boundsWidth = readFloat();
9595
// fontFamily (4-byte length + UTF-8 bytes, length 0 = nil)
9696
const familyLen = readInt32();
97+
let fontFamily = null;
9798
if (familyLen > 0) {
99+
const familyBytes = new Uint8Array(buffer, offset, familyLen);
98100
offset += familyLen;
101+
fontFamily = new TextDecoder().decode(familyBytes);
99102
}
100-
readInt32(); // lineLimit
103+
const lineLimit = readInt32();
101104
readInt32(); // lineBreakMode
102-
setColor(color);
105+
106+
// Font fallback management:
107+
const fontProvider = ck.TypefaceFontProvider.Make();
108+
for (const tf of typefaces) {
109+
fontProvider.registerTypeface(tf, ""); // Registering with empty string allows them to be used as fallbacks
110+
}
111+
112+
const style = new ck.ParagraphStyle({
113+
textStyle: {
114+
color: ck.Color4f(
115+
((color >> 16) & 0xFF) / 255,
116+
((color >> 8) & 0xFF) / 255,
117+
(color & 0xFF) / 255,
118+
((color >>> 24) & 0xFF) / 255
119+
),
120+
fontSize: fontSize,
121+
fontFamilies: fontFamily ? [fontFamily, 'sans-serif'] : ['sans-serif'],
122+
},
123+
maxLines: lineLimit > 0 ? lineLimit : undefined,
124+
ellipsis: lineLimit > 0 ? '...' : undefined,
125+
});
126+
127+
const builder = ck.ParagraphBuilder.MakeFromFontProvider(style, fontProvider);
128+
builder.addText(text);
129+
const paragraph = builder.build();
130+
paragraph.layout(boundsWidth > 0 ? boundsWidth : 100000);
103131

104-
const font = new ck.Font(typeface, fontSize);
105132
let drawX = x;
106133
if (boundsWidth > 0) {
107-
const ids = font.getGlyphIDs(text);
108-
const widths = font.getGlyphWidths(ids);
109-
const actualWidth = widths.reduce((sum, w) => sum + w, 0);
110-
drawX = (boundsWidth - actualWidth) / 2;
134+
drawX = (boundsWidth - paragraph.getMaxIntrinsicWidth()) / 2;
111135
}
112-
canvas.drawText(text, drawX, y, paint, font);
113-
font.delete();
136+
canvas.drawParagraph(paragraph, drawX, y);
137+
138+
paragraph.delete();
139+
builder.delete();
140+
fontProvider.delete();
114141
break;
115142
}
116143
case OP_RETAINED_BEGIN:

Examples/BasicApp/WebHost/index.html

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,68 @@
3535
const surface = CanvasKit.MakeWebGLCanvasSurface(canvas);
3636
const skCanvas = surface.getCanvas();
3737

38-
// Load default font for text rendering
39-
const fontResp = await fetch("https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me5Q.ttf");
40-
const fontData = await fontResp.arrayBuffer();
41-
const typeface = CanvasKit.Typeface.MakeFreeTypeFaceFromData(fontData);
38+
// Use a secondary canvas for measurement to avoid flushing the main surface
39+
const offscreenCanvas = document.createElement("canvas");
40+
const ctx = offscreenCanvas.getContext("2d");
41+
42+
// Load multiple fonts for fallback support (English and Korean)
43+
const [robotoResp, notoResp] = await Promise.all([
44+
fetch("https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me5Q.ttf"),
45+
fetch("https://fonts.gstatic.com/s/notosanskr/v36/PBy7FmXiEBPT4ITM6vY_mS_v.ttf")
46+
]);
47+
const [robotoData, notoData] = await Promise.all([
48+
robotoResp.arrayBuffer(),
49+
notoResp.arrayBuffer()
50+
]);
51+
52+
const typeface = CanvasKit.Typeface.MakeFreeTypeFaceFromData(robotoData);
53+
const notoTypeface = CanvasKit.Typeface.MakeFreeTypeFaceFromData(notoData);
4254

4355
// Set up the bridge that Swift's WebBridge.start() expects
4456
window.skiaUI = {
4557
viewport: { width: canvas.width, height: canvas.height },
58+
measureText(text, fontSize, fontWeight, fontFamily, maxWidth) {
59+
// Map common family names to the custom font if they aren't available in browser
60+
const fontStr = `${fontWeight} ${fontSize}px ${fontFamily ? fontFamily + ',' : ''} "Noto Sans KR", sans-serif`;
61+
ctx.font = fontStr;
62+
63+
if (maxWidth > 0) {
64+
// Multiline measurement
65+
const words = text.split(" ");
66+
let lines = 0;
67+
let currentLine = "";
68+
let maxMeasuredWidth = 0;
69+
70+
for (let n = 0; n < words.length; n++) {
71+
const testLine = currentLine + words[n] + " ";
72+
const metrics = ctx.measureText(testLine);
73+
const testWidth = metrics.width;
74+
if (testWidth > maxWidth && n > 0) {
75+
lines++;
76+
currentLine = words[n] + " ";
77+
} else {
78+
currentLine = testLine;
79+
maxMeasuredWidth = Math.max(maxMeasuredWidth, testWidth);
80+
}
81+
}
82+
lines++;
83+
return {
84+
width: maxMeasuredWidth,
85+
height: lines * (fontSize * 1.2)
86+
};
87+
} else {
88+
const metrics = ctx.measureText(text);
89+
return {
90+
width: metrics.width,
91+
height: fontSize * 1.2
92+
};
93+
}
94+
},
4695
submitDisplayList(bytes) {
4796
try {
4897
const buf = bytes.buffer ?? bytes;
4998
skCanvas.clear(CanvasKit.Color4f(1.0, 1.0, 1.0, 1.0));
50-
play(CanvasKit, buf, skCanvas, typeface);
99+
play(CanvasKit, buf, skCanvas, typeface, notoTypeface);
51100
surface.flush();
52101
} catch (err) {
53102
console.error("[SkiaUI] submitDisplayList error:", err);

Sources/SkiaUIRenderTree/DisplayListBuilder.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public struct DisplayListBuilder: Sendable {
6161

6262
// Draw text
6363
if let text = node.textContent {
64-
list.append(.drawText(text: text.text, x: 0, y: text.fontSize, fontSize: text.fontSize, fontWeight: text.fontWeight, color: text.color, boundsWidth: w, fontFamily: text.fontFamily, lineLimit: text.lineLimit, lineBreakMode: text.lineBreakMode))
64+
list.append(.drawText(text: text.text, x: 0, y: 0, fontSize: text.fontSize, fontWeight: text.fontWeight, color: text.color, boundsWidth: w, fontFamily: text.fontFamily, lineLimit: text.lineLimit, lineBreakMode: text.lineBreakMode))
6565
}
6666

6767
// Draw children

Sources/SkiaUIRuntime/RootHost.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public final class RootHost: @unchecked Sendable {
1414
private let context: RenderContext
1515
private var currentElement: Element?
1616
private var currentLayout: LayoutNode?
17-
private let layoutEngine = LayoutEngine()
17+
private let layoutEngine: LayoutEngine
1818
private let reconciler = Reconciler()
1919
private var previousElement: Element?
2020
private var previousDisplayListBytes: [UInt8]?
@@ -29,8 +29,17 @@ public final class RootHost: @unchecked Sendable {
2929
private var onDisplayList: (([UInt8]) -> Void)?
3030
private let attributeGraph = AttributeGraph()
3131

32-
public init(context: RenderContext = .default) {
32+
public init(context: RenderContext = .default, textMeasurer: (any TextMeasurer)? = nil) {
3333
self.context = context
34+
35+
let measurer: any TextMeasurer = textMeasurer ?? {
36+
#if canImport(CoreText)
37+
return CoreTextMeasurer()
38+
#else
39+
return EstimatedTextMeasurer()
40+
#endif
41+
}()
42+
self.layoutEngine = LayoutEngine(textMeasurer: measurer)
3443
}
3544

3645
public func setViewport(width: Float, height: Float) {

Sources/SkiaUIWebBridge/WebBridge.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public struct WebBridge {
1212
public static func start<A: App>(_ appType: A.Type) {
1313
let context = RenderContext()
1414
nonisolated(unsafe) let app = A()
15-
let host = RootHost(context: context)
15+
let host = RootHost(context: context, textMeasurer: WebTextMeasurer())
1616

1717
let skiaUI = JSObject.global.skiaUI.object!
1818

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// WebTextMeasurer.swift – SkiaUIWebBridge module
2+
// JavaScript-based text measurement for accurate sizing in Wasm/Browser.
3+
4+
import SkiaUILayout
5+
6+
#if canImport(JavaScriptKit)
7+
import JavaScriptKit
8+
9+
public struct WebTextMeasurer: TextMeasurer, @unchecked Sendable {
10+
private let skiaUI: JSObject
11+
12+
public init() {
13+
self.skiaUI = JSObject.global.skiaUI.object!
14+
}
15+
16+
public func measure(text: String, fontSize: Float, fontWeight: Int, fontFamily: String?, maxWidth: Float?, lineLimit: Int?) -> TextMeasurement {
17+
guard let measureFunc = skiaUI.measureText.function else {
18+
// Fallback if JS side didn't provide measureText
19+
return EstimatedTextMeasurer().measure(text: text, fontSize: fontSize, fontWeight: fontWeight, fontFamily: fontFamily, maxWidth: maxWidth, lineLimit: lineLimit)
20+
}
21+
22+
let result = measureFunc(
23+
text.jsValue,
24+
fontSize.jsValue,
25+
fontWeight.jsValue,
26+
(fontFamily ?? "").jsValue,
27+
(maxWidth ?? -1).jsValue,
28+
(lineLimit ?? 0).jsValue
29+
)
30+
31+
return TextMeasurement(
32+
width: Float(result.width.number ?? 0),
33+
height: Float(result.height.number ?? 0)
34+
)
35+
}
36+
}
37+
#endif

Tests/GoldenTests/TypographyImageTests.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,24 @@ extension AllGoldenTests {
4242
named: "multipleTextsInVStack"
4343
)
4444
}
45+
46+
@Test func multilingualText() {
47+
assertImageSnapshot(
48+
VStack(spacing: 8) {
49+
Text("Hello World (English)")
50+
Text("안녕하세요 (Korean)")
51+
Text("こんにちは (Japanese)")
52+
Text("你好 (Chinese)")
53+
Text("नमस्ते (Hindi)")
54+
Text("สวัสดี (Thai)")
55+
Text("مرحبا (Arabic - RTL)")
56+
Text("Emojis: 🚀 🌍 🌈 🍎")
57+
Text("Mixed: 한글, 中文, 日本語, 123").fontSize(14)
58+
},
59+
named: "multilingualText",
60+
width: 400,
61+
height: 350
62+
)
63+
}
4564
}
4665
}
22.7 KB
Loading

0 commit comments

Comments
 (0)