Skip to content

Commit 4dc2411

Browse files
authored
Use writing system to determine fallback font and add Noto fonts (#3190)
1 parent e3112e7 commit 4dc2411

File tree

9 files changed

+652
-60
lines changed

9 files changed

+652
-60
lines changed

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"angularKarmaTestExplorer.projectRootPath": "src/SIL.XForge.Scripture/ClientApp",
55
"csharp.format.enable": true,
66
"cSpell.words": [
7+
"Andika",
78
"appbuilder",
89
"attributors",
910
"backout",
@@ -12,6 +13,7 @@
1213
"bugsnag",
1314
"caniuse",
1415
"cdnjs",
16+
"Charis",
1517
"combobox",
1618
"commenters",
1719
"compodoc",
@@ -36,6 +38,7 @@
3638
"ngsw",
3739
"Nllb",
3840
"noopener",
41+
"noto",
3942
"nums",
4043
"objectid",
4144
"openid",

scripts/update_font_list.mts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,43 @@ for (const [family, files] of Object.entries(filesByFamily)) {
6464
}
6565
}
6666

67+
// Step 3: Get the Noto fonts
68+
69+
const notoFonts = await fetch(
70+
"https://raw.githubusercontent.com/notofonts/notofonts.github.io/refs/heads/main/state.json"
71+
).then(response => response.json());
72+
73+
const notoFontsByFamily: { [family: string]: string } = {};
74+
75+
for (const [_group, specification] of Object.entries(notoFonts)) {
76+
if ((specification as any).families == null) continue;
77+
78+
for (const [family, familySpecification] of Object.entries((specification as any).families)) {
79+
const matchingFiles = (familySpecification as any).files.filter(file =>
80+
/^fonts\/\w+\/full\/otf\/\w+-Regular.otf$/.test(file)
81+
);
82+
83+
if (matchingFiles.length === 0) {
84+
console.warn(`No matching files found for ${family}`);
85+
continue;
86+
}
87+
88+
// sort by file name and use the shortest
89+
matchingFiles.sort((a, b) => a.length - b.length);
90+
91+
if (matchingFiles.length > 1) {
92+
console.warn(`Multiple Regular files found for ${family}: ${matchingFiles.join(", ")}`);
93+
console.warn(`Using ${matchingFiles[0]}`);
94+
}
95+
96+
const file = matchingFiles[0];
97+
98+
const url = `https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/${file}`;
99+
notoFontsByFamily[family] = url;
100+
bestFileByFamily[family] = url;
101+
}
102+
}
103+
67104
const filePath = `${import.meta.dirname}/../src/SIL.XForge.Scripture/fonts.json`;
68105

69106
Deno.writeTextFileSync(filePath, JSON.stringify(bestFileByFamily, null, 2) + "\n");
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env -S deno run --allow-net --allow-write
2+
3+
// Step 1: Map writing systems to font families by family ID
4+
5+
const fallbacks = await(
6+
await fetch("https://raw.githubusercontent.com/silnrsi/langfontfinder/refs/heads/main/data/fallback.json")
7+
).json();
8+
9+
const writingSystemRegionFallbackMap: { [key: string]: string } = {};
10+
11+
for (const [writingSystem, specifications] of Object.entries(fallbacks)) {
12+
for (const specification of specifications as any) {
13+
const fontId = specification.roles.default[0];
14+
for (const region of specification["regions"] ?? [null]) {
15+
if (region === null) {
16+
writingSystemRegionFallbackMap[writingSystem] = fontId;
17+
} else {
18+
writingSystemRegionFallbackMap[`${writingSystem}-${region}`] = fontId;
19+
}
20+
}
21+
}
22+
}
23+
24+
// Step 2: Find the font families that we can load
25+
26+
import fontsByUrl from '../src/SIL.XForge.Scripture/fonts.json' with { type: 'json' };
27+
28+
const loadableFontFamilies = Object.keys(fontsByUrl);
29+
30+
// Step 3: Create a map of writing systems to font families
31+
32+
const families = await fetch("https://raw.githubusercontent.com/silnrsi/fonts/refs/heads/main/families.json").then(
33+
response => response.json()
34+
);
35+
36+
const writingSystemFontFamilyNameMap: { [writingSystem: string]: string } = {};
37+
38+
for (const [writingSystem, fontId] of Object.entries(writingSystemRegionFallbackMap)) {
39+
const family = families[fontId];
40+
if (family && loadableFontFamilies.includes(family.family)) {
41+
writingSystemFontFamilyNameMap[writingSystem] = family.family;
42+
}
43+
}
44+
45+
const filePath = `${import.meta.dirname}/../src/SIL.XForge.Scripture/writing_system_font_map.json`;
46+
47+
Deno.writeTextFileSync(filePath, JSON.stringify(writingSystemFontFamilyNameMap, null, 2) + "\n");

src/SIL.XForge.Scripture/ClientApp/src/app/translate/font-unsupported-message/font-unsupported-message.component.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,13 @@ export class FontUnsupportedMessageComponent {
4848
}
4949

5050
get fallbackFont(): string {
51+
// This is not entirely correct logic. What it doesn't handle is when the selected font isn't a Graphite font, but
52+
// the default font for the writing system is a Graphite font. That would require a different message this component
53+
// doesn't currently display, stating that the selected font is not supported, but it will fall back to one font in
54+
// browsers that support Graphite, and another font in browsers that don't.
5155
return this.fontService.isGraphiteFont(this.selectedFont ?? '')
5256
? this.fontService.nonGraphiteFallback(this.selectedFont ?? '')
53-
: this.fontService.fontFallback(this.selectedFont ?? '');
57+
: this.fontService.getFontFamilyFromProject(this.projectDoc);
5458
}
5559

5660
get warningI18nKey(): I18nKey {
Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import FONT_FACE_DEFINITIONS from '../../../fonts.json';
2-
import { FONT_FACE_FALLBACKS, FontService } from './font.service';
2+
import { FONT_FACE_FALLBACKS, FontResolver, FontService } from './font.service';
33
import { MockConsole } from './mock-console';
4+
import { isGecko } from './utils';
45

56
// Mocking the document with ts-mockito does not work because the type definitions for document.fonts does not include
67
// the add method
@@ -14,6 +15,60 @@ class FakeDocument {
1415
}
1516

1617
const mockedConsole: MockConsole = MockConsole.install();
18+
describe('FontResolver', () => {
19+
const fontResolver = new FontResolver();
20+
21+
it('can resolve specified_font', () => {
22+
const resolution = fontResolver.resolveFont({ defaultFont: 'Andika', writingSystem: { tag: 'en' } });
23+
expect(resolution).toEqual({
24+
requestedFont: 'Andika',
25+
resolution: 'specified_font',
26+
family: 'Andika',
27+
url: FONT_FACE_DEFINITIONS['Andika']
28+
});
29+
});
30+
31+
it('can resolve near_match_specified_font', () => {
32+
const resolution = fontResolver.resolveFont({ defaultFont: 'Andika Eng-Lit', writingSystem: { tag: 'en' } });
33+
expect(resolution).toEqual({
34+
requestedFont: 'Andika Eng-Lit',
35+
resolution: 'near_match_specified_font',
36+
family: 'Andika',
37+
url: FONT_FACE_DEFINITIONS['Andika']
38+
});
39+
});
40+
41+
it('can resolve writing_system_default and take region into account', () => {
42+
let resolution = fontResolver.resolveFont({ defaultFont: 'Unknown font', writingSystem: { tag: 'unk-Arab' } });
43+
expect(resolution).toEqual({
44+
requestedFont: 'Unknown font',
45+
resolution: 'writing_system_default',
46+
family: 'Scheherazade New',
47+
url: FONT_FACE_DEFINITIONS['Scheherazade New']
48+
});
49+
resolution = fontResolver.resolveFont({ defaultFont: 'Unknown font', writingSystem: { tag: 'unk-Arab-NE' } });
50+
expect(resolution).toEqual({
51+
requestedFont: 'Unknown font',
52+
resolution: 'writing_system_default',
53+
family: 'Harmattan',
54+
url: FONT_FACE_DEFINITIONS['Harmattan']
55+
});
56+
});
57+
58+
it('can fall back to the default font', () => {
59+
const resolution = fontResolver.resolveFont({ defaultFont: 'Unknown font', writingSystem: { tag: 'unk' } });
60+
expect(resolution).toEqual({
61+
requestedFont: 'Unknown font',
62+
resolution: 'default_font',
63+
family: 'Charis SIL',
64+
url: FONT_FACE_DEFINITIONS['Charis SIL']
65+
});
66+
});
67+
});
68+
69+
function spec(font?: string): { defaultFont?: string; writingSystem: { tag: string } } {
70+
return { defaultFont: font, writingSystem: { tag: 'en' } };
71+
}
1772

1873
describe('FontService', () => {
1974
let fontService: FontService;
@@ -23,31 +78,42 @@ describe('FontService', () => {
2378
});
2479

2580
it('should default to Charis SIL when font is not specified', () => {
26-
expect(fontService.getCSSFontName(undefined)).toEqual('Charis SIL');
27-
expect(fontService.getCSSFontName('')).toEqual('Charis SIL');
81+
mockedConsole.expectAndHide(/No font definition for /);
82+
expect(fontService.getFontFamilyFromProject(spec(undefined))).toEqual('Charis SIL');
83+
expect(fontService.getFontFamilyFromProject(spec(''))).toEqual('Charis SIL');
84+
mockedConsole.verify();
85+
mockedConsole.reset();
2886
});
2987

3088
it('should default to Charis SIL when font is not recognized', () => {
31-
mockedConsole.expectAndHide(/No font definition/);
32-
expect(fontService.getCSSFontName('zyz123')).toEqual('Charis SIL');
89+
mockedConsole.expectAndHide(/No font definition for zyz123/);
90+
expect(fontService.getFontFamilyFromProject(spec('zyz123'))).toEqual('Charis SIL');
3391
mockedConsole.verify();
3492
mockedConsole.reset();
3593
});
3694

3795
it('should default to Charis SIL for proprietary serif fonts', () => {
38-
expect(fontService.getCSSFontName('Times New Roman')).toEqual('Charis SIL');
39-
expect(fontService.getCSSFontName('Cambria')).toEqual('Charis SIL');
40-
expect(fontService.getCSSFontName('Sylfaen')).toEqual('Charis SIL');
96+
mockedConsole.expectAndHide(/No font definition for /);
97+
expect(fontService.getFontFamilyFromProject(spec('Times New Roman'))).toEqual('Charis SIL');
98+
expect(fontService.getFontFamilyFromProject(spec('Cambria'))).toEqual('Charis SIL');
99+
expect(fontService.getFontFamilyFromProject(spec('Sylfaen'))).toEqual('Charis SIL');
100+
mockedConsole.verify();
101+
mockedConsole.reset();
41102
});
42103

43104
it('should default to Andika for proprietary sans-serif fonts', () => {
44-
expect(fontService.getCSSFontName('Arial')).toEqual('Andika');
45-
expect(fontService.getCSSFontName('Verdana')).toEqual('Andika');
46-
expect(fontService.getCSSFontName('Tahoma')).toEqual('Andika');
105+
mockedConsole.expectAndHide(/No font definition for /);
106+
expect(fontService.getFontFamilyFromProject(spec('Arial'))).toEqual('Andika');
107+
expect(fontService.getFontFamilyFromProject(spec('Verdana'))).toEqual('Andika');
108+
expect(fontService.getFontFamilyFromProject(spec('Tahoma'))).toEqual('Andika');
109+
mockedConsole.verify();
110+
mockedConsole.reset();
47111
});
48112

49113
it('should fall back from Annapurna SIL Thami to Annapurna SIL', () => {
50-
expect(fontService.getCSSFontName('Annapurna SIL Thami')).toEqual('Annapurna SIL');
114+
mockedConsole.expectAndHide(/No font definition for /);
115+
expect(fontService.getFontFamilyFromProject(spec('Annapurna SIL Thami'))).toEqual('Annapurna SIL');
116+
mockedConsole.verify();
51117
});
52118

53119
it('should not have any broken font fallbacks', () => {
@@ -56,10 +122,28 @@ describe('FontService', () => {
56122
});
57123

58124
it('should only load each font once', () => {
59-
fontService.getCSSFontName('Charis SIL');
60-
fontService.getCSSFontName('Charis SIL');
61-
fontService.getCSSFontName('Charis SIL');
125+
fontService.getFontFamilyFromProject(spec('Charis SIL'));
126+
fontService.getFontFamilyFromProject(spec('Charis SIL'));
127+
fontService.getFontFamilyFromProject(spec('Charis SIL'));
62128

63129
expect(((fontService as any).document as FakeDocument).addCount).toEqual(1);
64130
});
131+
132+
it("should default to the specified writing system's default font", () => {
133+
mockedConsole.expectAndHide(/No font definition for /);
134+
const tagToExpectedFont = {
135+
'aaa-Arab-NE': 'Harmattan',
136+
'aaa-Arab-PK': isGecko() ? 'Awami Nastaliq' : 'Scheherazade New',
137+
'aaa-Arab': 'Scheherazade New',
138+
'aaa-Deva-IN': 'Annapurna SIL'
139+
};
140+
141+
for (const [tag, font] of Object.entries(tagToExpectedFont)) {
142+
expect(fontService.getFontFamilyFromProject({ defaultFont: 'Unknown font', writingSystem: { tag } })).toEqual(
143+
font
144+
);
145+
}
146+
mockedConsole.verify();
147+
mockedConsole.reset();
148+
});
65149
});

0 commit comments

Comments
 (0)