diff --git a/src/components/Charts/utils/chartTypefaceFallbacks.ts b/src/components/Charts/utils/chartTypefaceFallbacks.ts index b7efdcc616c8..f4fd15b47424 100644 --- a/src/components/Charts/utils/chartTypefaceFallbacks.ts +++ b/src/components/Charts/utils/chartTypefaceFallbacks.ts @@ -11,4 +11,11 @@ const CHART_TYPEFACE_SAME_FAMILY_FALLBACKS: Partial> = { + EXP_NEW_KANSAS_MEDIUM: 'EXP_NEUE_BOLD', + EXP_NEW_KANSAS_MEDIUM_ITALIC: 'EXP_NEUE_BOLD_ITALIC', +}; + export default CHART_TYPEFACE_SAME_FAMILY_FALLBACKS; +export {CHART_TYPEFACE_GLYPH_FALLBACKS}; diff --git a/src/components/Charts/utils/getChartSkiaTypeface.ts b/src/components/Charts/utils/getChartSkiaTypeface.ts index 0ba710b2f355..e9780718bbcd 100644 --- a/src/components/Charts/utils/getChartSkiaTypeface.ts +++ b/src/components/Charts/utils/getChartSkiaTypeface.ts @@ -8,7 +8,7 @@ import type {TextStyle} from 'react-native'; import type {ChartLabelFontWeight} from './normalizeChartFontWeight'; -import CHART_TYPEFACE_SAME_FAMILY_FALLBACKS from './chartTypefaceFallbacks'; +import CHART_TYPEFACE_SAME_FAMILY_FALLBACKS, {CHART_TYPEFACE_GLYPH_FALLBACKS} from './chartTypefaceFallbacks'; import normalizeChartFontWeight from './normalizeChartFontWeight'; type ChartLabelFontStyle = 'normal' | 'italic'; @@ -45,6 +45,35 @@ function getChartSkiaTypefaceKey(fontFamily: string | undefined, fontStyle: Char return matchingKey ?? 'EXP_NEUE'; } +// Skip ASCII control characters (e.g. the `\n` line separators callers split on) that have no glyph. +const FIRST_PRINTABLE_CODE_POINT = 0x20; + +function typefaceCanRenderText(typeface: SkTypeface, text: string): boolean { + for (const char of text) { + const codePoint = char.codePointAt(0); + + if (codePoint === undefined || codePoint < FIRST_PRINTABLE_CODE_POINT) { + continue; + } + + if (typeface.getGlyphIDs(char).every((glyphID) => glyphID === 0)) { + return false; + } + } + + return true; +} + +/** + * Some brand fonts (e.g. Expensify New Kansas) only cover Latin script and don't include rarer + * currency symbols (e.g. the Vietnamese dong sign). Unlike CSS, Skia's `Font`/`SkText` draw with a + * single typeface and never fall back to another font for a glyph it's missing, so an unsupported + * character renders incorrectly. + */ +function getGlyphFallbackKey(typefaceKey: ChartSkiaTypefaceKey): ChartSkiaTypefaceKey { + return CHART_TYPEFACE_GLYPH_FALLBACKS[typefaceKey] ?? 'EXP_NEUE'; +} + function getFirstAvailableTypeface(typefaces: ChartDefaultTypeface): SkTypeface | null { for (const typeface of Object.values(typefaces)) { if (typeface) { @@ -94,9 +123,21 @@ function getChartSkiaTypeface( fontStyle?: string; fontWeight?: string | number; }, + /** When provided, swaps to a typeface with broader glyph coverage if the resolved one can't render this text. */ + text?: string, ): SkTypeface | null { const typefaceKey = getChartSkiaTypefaceKey(fontFamily, normalizeFontStyle(fontStyle), normalizeChartFontWeight(fontWeight)); - return resolveTypefaceWithFallbacks(typefaces, typefaceKey); + const resolvedTypeface = resolveTypefaceWithFallbacks(typefaces, typefaceKey); + + if (resolvedTypeface && text && !typefaceCanRenderText(resolvedTypeface, text)) { + const fallbackTypeface = resolveTypefaceWithFallbacks(typefaces, getGlyphFallbackKey(typefaceKey)); + + if (fallbackTypeface) { + return fallbackTypeface; + } + } + + return resolvedTypeface; } export default getChartSkiaTypeface; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabel.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabel.tsx index dedac9501be9..415fb34be31d 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabel.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabel.tsx @@ -44,11 +44,15 @@ function VictoryChartLabel({x, y, text, color, fontSize, fontWeight, fontFamily, const lineFontFamily = fontFamily?.[index]; const lineFontStyle = fontStyle?.[index]; const lineLineHeight = lineHeight?.[index]; - const typeface = getChartSkiaTypeface(typefaces, { - fontFamily: lineFontFamily, - fontStyle: lineFontStyle, - fontWeight: lineFontWeight, - }); + const typeface = getChartSkiaTypeface( + typefaces, + { + fontFamily: lineFontFamily, + fontStyle: lineFontStyle, + fontWeight: lineFontWeight, + }, + line, + ); const lineFont = typeface && lineFontSize ? Skia.Font(typeface, lineFontSize) : null; const {ascent, lineHeight: metricsLineHeight} = getSkiaLineMetrics(lineFont); const lineWidth = lineFont?.getGlyphWidths(lineFont.getGlyphIDs(line)).reduce((totalWidth, width) => totalWidth + width, 0) ?? 0; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLegend.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLegend.tsx index a45b1d88533f..f89a786e893d 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLegend.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLegend.tsx @@ -36,7 +36,7 @@ function VictoryChartLegend({x, y, entries, gutter, symbolSpacer, chartWidth}: V const theme = useTheme(); const processedEntries = entries.reduce( (acc, {text, color, fontSize, fontWeight, fontFamily, fontStyle, symbolColor, symbolSize}) => { - const typeface = getChartSkiaTypeface(typefaces, {fontFamily, fontStyle, fontWeight}); + const typeface = getChartSkiaTypeface(typefaces, {fontFamily, fontStyle, fontWeight}, text); const font = typeface && fontSize ? Skia.Font(typeface, fontSize) : null; const {ascent, descent, lineHeight} = getSkiaLineMetrics(font); const rowCenterY = y + lineHeight / 2; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computePieLabelLayout.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computePieLabelLayout.ts index d749ba4a9843..2ce1639e7ed6 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computePieLabelLayout.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computePieLabelLayout.ts @@ -95,11 +95,15 @@ function computeLabelLineWidth( typefaces: ChartDefaultTypeface, ): number { const fontSize = baseLabelItem.fontSize?.[lineIndex]; - const typeface = getChartSkiaTypeface(typefaces, { - fontFamily: baseLabelItem.fontFamily?.[lineIndex], - fontStyle: baseLabelItem.fontStyle?.[lineIndex], - fontWeight: baseLabelItem.fontWeight?.[lineIndex], - }); + const typeface = getChartSkiaTypeface( + typefaces, + { + fontFamily: baseLabelItem.fontFamily?.[lineIndex], + fontStyle: baseLabelItem.fontStyle?.[lineIndex], + fontWeight: baseLabelItem.fontWeight?.[lineIndex], + }, + line, + ); const font = typeface && fontSize ? Skia.Font(typeface, fontSize) : null; if (!font) { diff --git a/tests/unit/getChartSkiaTypefaceTest.ts b/tests/unit/getChartSkiaTypefaceTest.ts index 484a79d6ec8a..12142334d345 100644 --- a/tests/unit/getChartSkiaTypefaceTest.ts +++ b/tests/unit/getChartSkiaTypefaceTest.ts @@ -2,6 +2,8 @@ import type {ChartDefaultTypeface} from '@components/Charts/types/chartSkiaTypef import {CHART_SKIA_TYPEFACE_ASSETS} from '@components/Charts/utils/chartFontAssets'; import getChartSkiaTypeface from '@components/Charts/utils/getChartSkiaTypeface'; +import FontUtils from '@styles/utils/FontUtils'; + import ObjectUtils from '@src/types/utils/ObjectUtils'; import type {SkTypeface} from '@shopify/react-native-skia'; @@ -14,6 +16,13 @@ function makeTypefaces(): ChartDefaultTypeface { return ObjectUtils.typedFromEntries(CHART_SKIA_TYPEFACE_KEYS.map((key) => [key, createMock({})] as const)); } +/** Simulates a typeface whose glyph coverage excludes every character in `unsupportedChars`. */ +function makeTypefaceWithGlyphCoverage(unsupportedChars: string): SkTypeface { + return createMock({ + getGlyphIDs: (text: string) => [...text].map((char) => (unsupportedChars.includes(char) ? 0 : 1)), + }); +} + describe('getChartSkiaTypeface', () => { const typefaces = makeTypefaces(); @@ -44,14 +53,14 @@ describe('getChartSkiaTypeface', () => { it('should resolve Expensify New Kansas by font family', () => { const typeface = getChartSkiaTypeface(typefaces, { - fontFamily: 'Expensify New Kansas', + fontFamily: FontUtils.fontFamily.single.EXP_NEW_KANSAS_MEDIUM.fontFamily, }); expect(typeface).toBe(typefaces.EXP_NEW_KANSAS_MEDIUM); }); it('should resolve italic Expensify Neue bold to the bold italic typeface', () => { const typeface = getChartSkiaTypeface(typefaces, { - fontFamily: 'Expensify Neue', + fontFamily: FontUtils.fontFamily.single.EXP_NEUE.fontFamily, fontStyle: 'italic', fontWeight: 'bold', }); @@ -78,7 +87,7 @@ describe('getChartSkiaTypeface', () => { }; const typeface = getChartSkiaTypeface(partialTypefaces, { - fontFamily: 'Expensify New Kansas', + fontFamily: FontUtils.fontFamily.single.EXP_NEW_KANSAS_MEDIUM.fontFamily, }); expect(typeface).toBe(partialTypefaces.EXP_NEUE); }); @@ -92,4 +101,46 @@ describe('getChartSkiaTypeface', () => { const typeface = getChartSkiaTypeface(emptyTypefaces, {fontWeight: 700}); expect(typeface).toBeNull(); }); + + it('should keep the resolved typeface when it can render the given text', () => { + const glyphAwareTypefaces = { + ...typefaces, + EXP_NEW_KANSAS_MEDIUM: makeTypefaceWithGlyphCoverage('₫'), + }; + + const typeface = getChartSkiaTypeface(glyphAwareTypefaces, {fontFamily: FontUtils.fontFamily.single.EXP_NEW_KANSAS_MEDIUM.fontFamily}, '$59'); + expect(typeface).toBe(glyphAwareTypefaces.EXP_NEW_KANSAS_MEDIUM); + }); + + it('should fall back to EXP_NEUE_BOLD when Expensify New Kansas cannot render the given text', () => { + const glyphAwareTypefaces = { + ...typefaces, + EXP_NEW_KANSAS_MEDIUM: makeTypefaceWithGlyphCoverage('₫'), + EXP_NEUE_BOLD: makeTypefaceWithGlyphCoverage(''), + }; + + const typeface = getChartSkiaTypeface(glyphAwareTypefaces, {fontFamily: FontUtils.fontFamily.single.EXP_NEW_KANSAS_MEDIUM.fontFamily}, '₫59'); + expect(typeface).toBe(glyphAwareTypefaces.EXP_NEUE_BOLD); + }); + + it('should fall back to EXP_NEUE_BOLD_ITALIC when italic Expensify New Kansas cannot render the given text', () => { + const glyphAwareTypefaces = { + ...typefaces, + EXP_NEW_KANSAS_MEDIUM_ITALIC: makeTypefaceWithGlyphCoverage('₫'), + EXP_NEUE_BOLD_ITALIC: makeTypefaceWithGlyphCoverage(''), + }; + + const typeface = getChartSkiaTypeface(glyphAwareTypefaces, {fontFamily: FontUtils.fontFamily.single.EXP_NEW_KANSAS_MEDIUM.fontFamily, fontStyle: 'italic'}, '₫59'); + expect(typeface).toBe(glyphAwareTypefaces.EXP_NEUE_BOLD_ITALIC); + }); + + it('should ignore newlines when checking glyph coverage', () => { + const glyphAwareTypefaces = { + ...typefaces, + EXP_NEW_KANSAS_MEDIUM: makeTypefaceWithGlyphCoverage('₫'), + }; + + const typeface = getChartSkiaTypeface(glyphAwareTypefaces, {fontFamily: FontUtils.fontFamily.single.EXP_NEW_KANSAS_MEDIUM.fontFamily}, 'Total\n$59'); + expect(typeface).toBe(glyphAwareTypefaces.EXP_NEW_KANSAS_MEDIUM); + }); });