Skip to content

Commit 94a2620

Browse files
koji-1009claude
andcommitted
fix(engine): fall back to host fonts for scripts beyond CJK and emoji
On macOS only Hiragino and Apple Color Emoji were loaded, so Simplified Chinese characters Japanese lacks, Hangul, Thai, Arabic, Hebrew, and Devanagari rendered as boxes in a shot reported ok. The harness now loads a fallback family per script on macOS, Linux, and Windows, after the CJK and emoji ones, so text that rendered before renders unchanged. The manual lists the fonts per host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2bf740b commit 94a2620

5 files changed

Lines changed: 113 additions & 27 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Host fallback fonts in text whose style keeps them out.
77
* CJK and emoji in a `CupertinoAlertDialog` rendered as boxes in a shot reported `ok`: their text styles inherit nothing and name the Cupertino system font, so the host fonts added to the default text style and the themes never reached them. The host fonts are now appended to the text of every paragraph and text field before each layout.
88
* Text in a style with its own `fontFamilyFallback`, such as google_fonts', now falls back to the host fonts too; Japanese in a Latin-only Google font rendered as boxes.
99
* Text in a package font's style falls back as well.
10+
* Simplified Chinese, Hangul, Thai, Arabic, Hebrew, and Devanagari fall back to host fonts too; on macOS they rendered as boxes, and only Hiragino and Apple Color Emoji were loaded. Text that rendered before renders unchanged.
1011

1112
## 1.1.0
1213

‎doc/manual.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ Images render at a device pixel ratio of 2.
132132
Elevation paints its shadow as on a device; `flutter_test` would draw a solid outline in its place.
133133

134134
Fonts: every family in the project's `FontManifest.json` (pubspec `fonts:`, package fonts, MaterialIcons) plus Roboto from the Flutter SDK cache.
135-
The test engine has no system font fallback, so a glyph missing from the style's fonts would render as a box. As on a device, shutter falls back to the host's CJK and emoji fonts: Hiragino Sans W3/W6 and Apple Color Emoji on macOS, Noto Sans CJK and Noto Color Emoji on Linux, Yu Gothic and Segoe UI Emoji on Windows. They are appended as `fontFamilyFallback` to the ambient default text style, and to the text theme of every design library whose theme the shell provides (SDK or package); text whose glyphs exist in its own font renders unchanged.
135+
The test engine has no system font fallback, so a glyph missing from the style's fonts would render as a box. As on a device, shutter falls back to the host's fonts for CJK and emoji, then for the scripts those lack: Simplified Chinese, Hangul, Thai, Arabic, Hebrew, and Devanagari. On macOS they are Hiragino Sans W3/W6, Apple Color Emoji, Hiragino Sans GB, Apple SD Gothic Neo, Thonburi, SF Arabic, SF Hebrew, and Kohinoor Devanagari; on Linux, Noto Sans CJK, Noto Color Emoji, and Noto Sans Thai, Arabic, Hebrew, and Devanagari; on Windows, Yu Gothic, Segoe UI Emoji, Microsoft YaHei, Malgun Gothic, Leelawadee UI, Segoe UI, and Nirmala UI. A Han character takes the Japanese form when the CJK font has it. They are appended as `fontFamilyFallback` to the ambient default text style, and to the text theme of every design library whose theme the shell provides (SDK or package); text whose glyphs exist in its own font renders unchanged.
136136
Each frame, before layout, they are also appended to the text of every paragraph and text field, so text whose style keeps them out falls back too: a style that inherits nothing, such as a `CupertinoAlertDialog`'s, or one with its own `fontFamilyFallback`, such as google_fonts'.
137137
Cupertino text names the system font through the families `CupertinoSystemText` and `CupertinoSystemDisplay`, which the test engine does not resolve. On a macOS host they get SF Pro from `/System/Library/Fonts/`, as a macOS app does and as iOS draws; on other hosts, Roboto, the Android system font.
138138
Host fonts come from the machine that shoots, so compare runs made on the same machine.

‎lib/src/cli/manual_text.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Images render at a device pixel ratio of 2.
135135
Elevation paints its shadow as on a device; `flutter_test` would draw a solid outline in its place.
136136
137137
Fonts: every family in the project's `FontManifest.json` (pubspec `fonts:`, package fonts, MaterialIcons) plus Roboto from the Flutter SDK cache.
138-
The test engine has no system font fallback, so a glyph missing from the style's fonts would render as a box. As on a device, shutter falls back to the host's CJK and emoji fonts: Hiragino Sans W3/W6 and Apple Color Emoji on macOS, Noto Sans CJK and Noto Color Emoji on Linux, Yu Gothic and Segoe UI Emoji on Windows. They are appended as `fontFamilyFallback` to the ambient default text style, and to the text theme of every design library whose theme the shell provides (SDK or package); text whose glyphs exist in its own font renders unchanged.
138+
The test engine has no system font fallback, so a glyph missing from the style's fonts would render as a box. As on a device, shutter falls back to the host's fonts for CJK and emoji, then for the scripts those lack: Simplified Chinese, Hangul, Thai, Arabic, Hebrew, and Devanagari. On macOS they are Hiragino Sans W3/W6, Apple Color Emoji, Hiragino Sans GB, Apple SD Gothic Neo, Thonburi, SF Arabic, SF Hebrew, and Kohinoor Devanagari; on Linux, Noto Sans CJK, Noto Color Emoji, and Noto Sans Thai, Arabic, Hebrew, and Devanagari; on Windows, Yu Gothic, Segoe UI Emoji, Microsoft YaHei, Malgun Gothic, Leelawadee UI, Segoe UI, and Nirmala UI. A Han character takes the Japanese form when the CJK font has it. They are appended as `fontFamilyFallback` to the ambient default text style, and to the text theme of every design library whose theme the shell provides (SDK or package); text whose glyphs exist in its own font renders unchanged.
139139
Each frame, before layout, they are also appended to the text of every paragraph and text field, so text whose style keeps them out falls back too: a style that inherits nothing, such as a `CupertinoAlertDialog`'s, or one with its own `fontFamilyFallback`, such as google_fonts'.
140140
Cupertino text names the system font through the families `CupertinoSystemText` and `CupertinoSystemDisplay`, which the test engine does not resolve. On a macOS host they get SF Pro from `/System/Library/Fonts/`, as a macOS app does and as iOS draws; on other hosts, Roboto, the Android system font.
141141
Host fonts come from the machine that shoots, so compare runs made on the same machine.

‎lib/src/engine/harness_text.dart‎

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,10 +1104,25 @@ TextStyle _fallingBack(TextStyle style) {
11041104
);
11051105
}
11061106
1107-
/// Host fonts covering CJK and emoji, by fallback family.
1107+
/// Host fonts by fallback family, in fallback order: CJK, with Japanese
1108+
/// forms for the Han characters they share, and emoji, then the scripts
1109+
/// those lack. Each script is a family of its own, with one typeface per
1110+
/// weight: the engine picks one typeface of a family by weight, not by
1111+
/// glyph.
11081112
Map<String, List<File>> _systemFallbackFonts() {
1109-
final cjk = <File>[];
1110-
final emoji = <File>[];
1113+
final families = <String, List<File>>{
1114+
for (final script in const [
1115+
'Cjk',
1116+
'Emoji',
1117+
'Hans',
1118+
'Hangul',
1119+
'Thai',
1120+
'Arabic',
1121+
'Hebrew',
1122+
'Devanagari',
1123+
])
1124+
'ShutterFallback$script': <File>[],
1125+
};
11111126
List<File> files(String dir) {
11121127
final directory = Directory(dir);
11131128
if (!directory.existsSync()) return const [];
@@ -1123,19 +1138,38 @@ Map<String, List<File>> _systemFallbackFonts() {
11231138
11241139
// Exact paths rather than directory listings: macOS returns
11251140
// decomposed (NFD) Japanese file names, which never equal a literal.
1126-
List<File> existing(List<String> paths) => [
1127-
for (final path in paths)
1128-
if (File(path).existsSync()) File(path),
1129-
];
1141+
void existing(String dir, Map<String, List<String>> names) {
1142+
for (final MapEntry(key: script, value: files) in names.entries) {
1143+
for (final name in files) {
1144+
final file = File('$dir${Platform.pathSeparator}$name');
1145+
if (file.existsSync()) families['ShutterFallback$script']!.add(file);
1146+
}
1147+
}
1148+
}
1149+
11301150
if (Platform.isMacOS) {
1131-
cjk.addAll(
1132-
existing([
1133-
'/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc',
1134-
'/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc',
1135-
]),
1136-
);
1137-
emoji.addAll(existing(['/System/Library/Fonts/Apple Color Emoji.ttc']));
1151+
existing('/System/Library/Fonts', const {
1152+
'Cjk': ['ヒラギノ角ゴシック W3.ttc', 'ヒラギノ角ゴシック W6.ttc'],
1153+
'Emoji': ['Apple Color Emoji.ttc'],
1154+
'Hans': ['Hiragino Sans GB.ttc'],
1155+
'Hangul': ['AppleSDGothicNeo.ttc'],
1156+
'Thai': ['ThonburiUI.ttc'],
1157+
'Arabic': ['SFArabic.ttf'],
1158+
'Hebrew': ['SFHebrew.ttf'],
1159+
'Devanagari': ['Kohinoor.ttc'],
1160+
});
11381161
} else if (Platform.isLinux) {
1162+
// Noto Sans CJK holds every CJK script in each of its faces.
1163+
const noto = {
1164+
'NotoSansThai-Regular.ttf': 'Thai',
1165+
'NotoSansThai-Bold.ttf': 'Thai',
1166+
'NotoSansArabic-Regular.ttf': 'Arabic',
1167+
'NotoSansArabic-Bold.ttf': 'Arabic',
1168+
'NotoSansHebrew-Regular.ttf': 'Hebrew',
1169+
'NotoSansHebrew-Bold.ttf': 'Hebrew',
1170+
'NotoSansDevanagari-Regular.ttf': 'Devanagari',
1171+
'NotoSansDevanagari-Bold.ttf': 'Devanagari',
1172+
};
11391173
final home = Platform.environment['HOME'];
11401174
for (final dir in [
11411175
'/usr/share/fonts',
@@ -1145,22 +1179,31 @@ Map<String, List<File>> _systemFallbackFonts() {
11451179
]) {
11461180
for (final file in files(dir)) {
11471181
final n = file.path.split(Platform.pathSeparator).last;
1148-
if ((n.startsWith('NotoSansCJK') || n.startsWith('NotoSansJP')) &&
1149-
(n.contains('Regular') || n.contains('Bold'))) {
1150-
cjk.add(file);
1151-
} else if (n == 'NotoColorEmoji.ttf') {
1152-
emoji.add(file);
1153-
}
1182+
final script = switch (n) {
1183+
_ when (n.startsWith('NotoSansCJK') || n.startsWith('NotoSansJP')) &&
1184+
(n.contains('Regular') || n.contains('Bold')) =>
1185+
'Cjk',
1186+
'NotoColorEmoji.ttf' => 'Emoji',
1187+
_ => noto[n],
1188+
};
1189+
if (script != null) families['ShutterFallback$script']!.add(file);
11541190
}
11551191
}
11561192
} else if (Platform.isWindows) {
1157-
final fonts = '${Platform.environment['WINDIR'] ?? r'C:\Windows'}\\Fonts';
1158-
cjk.addAll(existing(['$fonts\\YuGothR.ttc', '$fonts\\YuGothB.ttc']));
1159-
emoji.addAll(existing(['$fonts\\seguiemj.ttf']));
1193+
// Segoe UI holds Hebrew as well as Arabic.
1194+
existing('${Platform.environment['WINDIR'] ?? r'C:\Windows'}\\Fonts', const {
1195+
'Cjk': ['YuGothR.ttc', 'YuGothB.ttc'],
1196+
'Emoji': ['seguiemj.ttf'],
1197+
'Hans': ['Msyh.ttc', 'Msyhbd.ttc'],
1198+
'Hangul': ['Malgun.ttf', 'Malgunbd.ttf'],
1199+
'Thai': ['Leelawui.ttf', 'Leelauib.ttf'],
1200+
'Arabic': ['Segoeui.ttf', 'Segoeuib.ttf'],
1201+
'Devanagari': ['Nirmala.ttf', 'Nirmalab.ttf'],
1202+
});
11601203
}
11611204
return {
1162-
if (cjk.isNotEmpty) 'ShutterFallbackCjk': cjk,
1163-
if (emoji.isNotEmpty) 'ShutterFallbackEmoji': emoji,
1205+
for (final MapEntry(key: family, value: files) in families.entries)
1206+
if (files.isNotEmpty) family: files,
11641207
};
11651208
}
11661209

‎test/e2e/shell_test.dart‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ library;
44
import 'dart:io';
55
import 'dart:math';
66

7+
import 'package:crypto/crypto.dart';
78
import 'package:image/image.dart' as img;
89
import 'package:path/path.dart' as p;
910
import 'package:shutter/src/run/manifest.dart';
@@ -259,6 +260,47 @@ Widget shown() => Builder(
259260
},
260261
);
261262

263+
test(
264+
'scripts beyond CJK fall back to host fonts',
265+
() async {
266+
final root = await exampleCopy();
267+
// The same letters in two orders: boxes draw them alike, glyphs
268+
// differently. Han characters only Simplified Chinese has.
269+
const scripts = {
270+
'Hans': ('这们说', '说这们'),
271+
'Hangul': ('한국어', '어한국'),
272+
'Thai': ('กขค', 'คกข'),
273+
'Arabic': ('عرب', 'برع'),
274+
'Hebrew': ('אבג', 'גאב'),
275+
'Devanagari': ('कखग', 'गकख'),
276+
};
277+
writeFiles(root, {
278+
'lib/preview/scripts_preview.dart': [
279+
"import 'package:flutter/widget_previews.dart';",
280+
"import 'package:flutter/widgets.dart';",
281+
for (final MapEntry(key: script, value: (a, b)) in scripts.entries)
282+
for (final (i, text) in [(1, a), (2, b)]) ...[
283+
"@Preview(name: '$script $i', size: Size(200, 40))",
284+
"Widget ${script.toLowerCase()}$i() => const Text('$text');",
285+
],
286+
].join('\n'),
287+
});
288+
final (run, shots) = await shootIn(root, [
289+
'lib/preview/scripts_preview.dart',
290+
]);
291+
Digest png(String name) => sha256.convert(
292+
File(p.join(run, shots[name]!.png!)).readAsBytesSync(),
293+
);
294+
final boxes = [
295+
for (final script in scripts.keys)
296+
if (png('$script 1') == png('$script 2')) script,
297+
];
298+
expect(boxes, isEmpty, reason: 'rendered as boxes');
299+
},
300+
// The CI host has emoji fonts only.
301+
skip: Platform.isMacOS ? false : 'needs the macOS host fonts',
302+
);
303+
262304
test('a project on material_ui gets a material_ui default shell', () async {
263305
final root = await exampleCopy();
264306
File(p.join(root, 'lib', 'preview', 'shell.dart')).deleteSync();

0 commit comments

Comments
 (0)