Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/components/Charts/utils/chartTypefaceFallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,11 @@ const CHART_TYPEFACE_SAME_FAMILY_FALLBACKS: Partial<Record<ChartSkiaTypefaceKey,
EXP_NEW_KANSAS_MEDIUM_ITALIC: ['EXP_NEW_KANSAS_MEDIUM'],
};

/** List of fallback fonts for symbols not included in Expensify New Kansas */
const CHART_TYPEFACE_GLYPH_FALLBACKS: Partial<Record<ChartSkiaTypefaceKey, ChartSkiaTypefaceKey>> = {
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};
45 changes: 43 additions & 2 deletions src/components/Charts/utils/getChartSkiaTypeface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
57 changes: 54 additions & 3 deletions tests/unit/getChartSkiaTypefaceTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -14,6 +16,13 @@ function makeTypefaces(): ChartDefaultTypeface {
return ObjectUtils.typedFromEntries(CHART_SKIA_TYPEFACE_KEYS.map((key) => [key, createMock<SkTypeface>({})] as const));
}

/** Simulates a typeface whose glyph coverage excludes every character in `unsupportedChars`. */
function makeTypefaceWithGlyphCoverage(unsupportedChars: string): SkTypeface {
return createMock<SkTypeface>({
getGlyphIDs: (text: string) => [...text].map((char) => (unsupportedChars.includes(char) ? 0 : 1)),
});
}

describe('getChartSkiaTypeface', () => {
const typefaces = makeTypefaces();

Expand Down Expand Up @@ -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',
});
Expand All @@ -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);
});
Expand All @@ -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);
});
});
Loading