import {
FONT_FAMILY ,
FONT_FAMILY_FALLBACKS ,
CJK_HAND_DRAWN_FALLBACK_FONT ,
WINDOWS_EMOJI_FALLBACK_FONT ,
isSafari ,
getFontFamilyFallbacks ,
} from "../constants" ;
import { isTextElement } from "../element" ;
import { charWidth , getContainerElement } from "../element/textElement" ;
import { containsCJK } from "../element/textWrapping" ;
import { ShapeCache } from "../scene/ShapeCache" ;
import { getFontString , PromisePool , promiseTry } from "../utils" ;
import { ExcalidrawFontFace } from "./ExcalidrawFontFace" ;
import { CascadiaFontFaces } from "./Cascadia" ;
import { ComicShannsFontFaces } from "./ComicShanns" ;
import { EmojiFontFaces } from "./Emoji" ;
import { ExcalifontFontFaces } from "./Excalifont" ;
import { HelveticaFontFaces } from "./Helvetica" ;
import { LiberationFontFaces } from "./Liberation" ;
import { LilitaFontFaces } from "./Lilita" ;
import { NunitoFontFaces } from "./Nunito" ;
import { VirgilFontFaces } from "./Virgil" ;
import { XiaolaiFontFaces } from "./Xiaolai" ;
import { FONT_METADATA , type FontMetadata } from "./FontMetadata" ;
import type {
ExcalidrawElement ,
ExcalidrawTextElement ,
FontFamilyValues ,
} from "../element/types" ;
import type Scene from "../scene/Scene" ;
import type { ValueOf } from "../utility-types" ;
export class Fonts {
// it's ok to track fonts across multiple instances only once, so let's use
// a static member to reduce memory footprint
public static readonly loadedFontsCache = new Set < string > ( ) ;
private static _registered :
| Map <
number ,
{
metadata : FontMetadata ;
fontFaces : ExcalidrawFontFace [ ] ;
}
>
| undefined ;
private static _initialized : boolean = false ;
public static get registered() {
// lazy load the font registration
if ( ! Fonts . _registered ) {
Fonts . _registered = Fonts . init ( ) ;
} else if ( ! Fonts . _initialized ) {
// case when host app register fonts before they are lazy loaded
// don't override whatever has been previously registered
Fonts . _registered = new Map ( [
. . . Fonts . init ( ) . entries ( ) ,
. . . Fonts . _registered . entries ( ) ,
] ) ;
}
return Fonts . _registered ;
}
public get registered() {
return Fonts . registered ;
}
private readonly scene : Scene ;
constructor ( scene : Scene ) {
this . scene = scene ;
}
/ * *
* Get all the font families for the given scene .
* /
public getSceneFamilies = ( ) = > {
return Fonts . getUniqueFamilies ( this . scene . getNonDeletedElements ( ) ) ;
} ;
/ * *
* if we load a ( new ) font , it ' s likely that text elements using it have
* already been rendered using a fallback font . Thus , we want invalidate
* their shapes and rerender . See # 637 .
*
* Invalidates text elements and rerenders scene , provided that at least one
* of the supplied fontFaces has not already been processed .
* /
public onLoaded = ( fontFaces : readonly FontFace [ ] ) : void = > {
// bail if all fonts with have been processed. We're checking just a
// subset of the font properties (though it should be enough), so it
// can technically bail on a false positive.
let shouldBail = true ;
for ( const fontFace of fontFaces ) {
const sig = ` ${ fontFace . family } - ${ fontFace . style } - ${ fontFace . weight } - ${ fontFace . unicodeRange } ` ;
// make sure to update our cache with all the loaded font faces
if ( ! Fonts . loadedFontsCache . has ( sig ) ) {
Fonts . loadedFontsCache . add ( sig ) ;
shouldBail = false ;
}
}
if ( shouldBail ) {
return ;
}
let didUpdate = false ;
const elementsMap = this . scene . getNonDeletedElementsMap ( ) ;
for ( const element of this . scene . getNonDeletedElements ( ) ) {
if ( isTextElement ( element ) ) {
didUpdate = true ;
ShapeCache . delete ( element ) ;
// clear the width cache, so that we don't perform subsequent wrapping based on the stale fallback font metrics
charWidth . clearCache ( getFontString ( element ) ) ;
const container = getContainerElement ( element , elementsMap ) ;
if ( container ) {
ShapeCache . delete ( container ) ;
}
}
}
if ( didUpdate ) {
this . scene . triggerUpdate ( ) ;
}
} ;
/ * *
* Load font faces for a given scene and trigger scene update .
*
* FontFaceSet loadingdone event we listen on may not always
* fire ( looking at you Safari ) , so on init we manually load all
* fonts and rerender scene text elements once done .
*
* For Safari we make sure to check against each loaded font face
* with the unique characters per family in the scene ,
* otherwise fonts might remain unloaded .
* /
public loadSceneFonts = async ( ) : Promise < FontFace [ ] > = > {
const sceneFamilies = this . getSceneFamilies ( ) ;
const charsPerFamily = isSafari
? Fonts . getCharsPerFamily ( this . scene . getNonDeletedElements ( ) )
: undefined ;
return Fonts . loadFontFaces ( sceneFamilies , charsPerFamily ) ;
} ;
/ * *
* Load font faces for passed elements - use when the scene is unavailable ( i . e . export ) .
*
* For Safari we make sure to check against each loaded font face ,
* with the unique characters per family in the elements
* otherwise fonts might remain unloaded .
* /
public static loadElementsFonts = async (
elements : readonly ExcalidrawElement [ ] ,
) : Promise < FontFace [ ] > = > {
const fontFamilies = Fonts . getUniqueFamilies ( elements ) ;
const charsPerFamily = isSafari
? Fonts . getCharsPerFamily ( elements )
: undefined ;
return Fonts . loadFontFaces ( fontFamilies , charsPerFamily ) ;
} ;
/ * *
* Load all registered font faces .
* /
public static loadAllFonts = async ( ) : Promise < FontFace [ ] > = > {
const allFamilies = Fonts . getAllFamilies ( ) ;
return Fonts . loadFontFaces ( allFamilies ) ;
} ;
/ * *
* Generate CSS @font - face declarations for the given elements .
* /
public static async generateFontFaceDeclarations (
elements : readonly ExcalidrawElement [ ] ,
) {
const families = Fonts . getUniqueFamilies ( elements ) ;
const charsPerFamily = Fonts . getCharsPerFamily ( elements ) ;
// for simplicity, assuming we have just one family with the CJK handdrawn fallback
const familyWithCJK = families . find ( ( x ) = >
getFontFamilyFallbacks ( x ) . includes ( CJK_HAND_DRAWN_FALLBACK_FONT ) ,
) ;
if ( familyWithCJK ) {
const characters = Fonts . getCharacters ( charsPerFamily , familyWithCJK ) ;
if ( containsCJK ( characters ) ) {
const family = FONT_FAMILY_FALLBACKS [ CJK_HAND_DRAWN_FALLBACK_FONT ] ;
// adding the same characters to the CJK handrawn family
charsPerFamily [ family ] = new Set ( characters ) ;
// the order between the families and fallbacks is important, as fallbacks need to be defined first and in the reversed order
// so that they get overriden with the later defined font faces, i.e. in case they share some codepoints
families . unshift ( FONT_FAMILY_FALLBACKS [ CJK_HAND_DRAWN_FALLBACK_FONT ] ) ;
}
}
// don't trigger hundreds of concurrent requests (each performing fetch, creating a worker, etc.),
// instead go three requests at a time, in a controlled manner, without completely blocking the main thread
// and avoiding potential issues such as rate limits
const iterator = Fonts . fontFacesStylesGenerator ( families , charsPerFamily ) ;
const concurrency = 3 ;
const fontFaces = await new PromisePool ( iterator , concurrency ) . all ( ) ;
// dedup just in case (i.e. could be the same font faces with 0 glyphs)
return Array . from ( new Set ( fontFaces ) ) ;
}
private static async loadFontFaces (
fontFamilies : Array < ExcalidrawTextElement [ " fontFamily " ] > ,
charsPerFamily? : Record < number , Set < string > > ,
) {
// add all registered font faces into the `document.fonts` (if not added already)
for ( const { fontFaces , metadata } of Fonts . registered . values ( ) ) {
// skip registering font faces for local fonts (i.e. Helvetica)
if ( metadata . local ) {
continue ;
}
for ( const { fontFace } of fontFaces ) {
if ( ! window . document . fonts . has ( fontFace ) ) {
window . document . fonts . add ( fontFace ) ;
}
}
}
// loading 10 font faces at a time, in a controlled manner
const iterator = Fonts . fontFacesLoader ( fontFamilies , charsPerFamily ) ;
const concurrency = 10 ;
const fontFaces = await new PromisePool ( iterator , concurrency ) . all ( ) ;
return fontFaces . flat ( ) . filter ( Boolean ) ;
}
private static * fontFacesLoader (
fontFamilies : Array < ExcalidrawTextElement [ " fontFamily " ] > ,
charsPerFamily? : Record < number , Set < string > > ,
) : Generator < Promise < void | readonly [ number , FontFace [ ] ] > > {
for ( const [ index , fontFamily ] of fontFamilies . entries ( ) ) {
const font = getFontString ( {
fontFamily ,
fontSize : 16 ,
} ) ;
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
// for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded
const text =
isSafari && charsPerFamily
? Fonts . getCharacters ( charsPerFamily , fontFamily )
: "" ;
if ( ! window . document . fonts . check ( font , text ) ) {
yield promiseTry ( async ( ) = > {
try {
// WARN: browser prioritizes loading only font faces with unicode ranges for characters which are present in the document (html & canvas), other font faces could stay unloaded
// we might want to retry here, i.e. in case CDN is down, but so far I didn't experience any issues - maybe it handles retry-like logic under the hood
const fontFaces = await window . document . fonts . load ( font , text ) ;
return [ index , fontFaces ] ;
} catch ( e ) {
// don't let it all fail if just one font fails to load
console . error (
` Failed to load font " ${ font } " from urls " ${ Fonts . registered
. get ( fontFamily )
? . fontFaces . map ( ( x ) = > x . urls ) } " ` ,
e ,
) ;
}
} ) ;
}
}
}
private static * fontFacesStylesGenerator (
families : Array < number > ,
charsPerFamily : Record < number , Set < string > > ,
) : Generator < Promise < void | readonly [ number , string ] > > {
for ( const [ familyIndex , family ] of families . entries ( ) ) {
const { fontFaces , metadata } = Fonts . registered . get ( family ) ? ? { } ;
if ( ! Array . isArray ( fontFaces ) ) {
console . error (
` Couldn't find registered fonts for font-family " ${ family } " ` ,
Fonts . registered ,
) ;
continue ;
}
if ( metadata ? . local ) {
// don't inline local fonts
continue ;
}
for ( const [ fontFaceIndex , fontFace ] of fontFaces . entries ( ) ) {
yield promiseTry ( async ( ) = > {
try {
const characters = Fonts . getCharacters ( charsPerFamily , family ) ;
const fontFaceCSS = await fontFace . toCSS ( characters ) ;
if ( ! fontFaceCSS ) {
return ;
}
// giving a buffer of 10K font faces per family
const fontFaceOrder = familyIndex * 10 _000 + fontFaceIndex ;
const fontFaceTuple = [ fontFaceOrder , fontFaceCSS ] as const ;
return fontFaceTuple ;
} catch ( error ) {
console . error (
` Couldn't transform font-face to css for family " ${ fontFace . fontFace . family } " ` ,
error ,
) ;
}
} ) ;
}
}
}
/ * *
* Register a new font .
*
* @param family font family
* @param metadata font metadata
* @param fontFacesDecriptors font faces descriptors
* /
private static register (
this :
| Fonts
| {
registered : Map <
number ,
{ metadata : FontMetadata ; fontFaces : ExcalidrawFontFace [ ] }
> ;
} ,
family : string ,
metadata : FontMetadata ,
. . . fontFacesDecriptors : ExcalidrawFontFaceDescriptor [ ]
) {
// TODO: likely we will need to abandon number value in order to support custom fonts
const fontFamily =
FONT_FAMILY [ family as keyof typeof FONT_FAMILY ] ? ?
FONT_FAMILY_FALLBACKS [ family as keyof typeof FONT_FAMILY_FALLBACKS ] ;
const registeredFamily = this . registered . get ( fontFamily ) ;
if ( ! registeredFamily ) {
this . registered . set ( fontFamily , {
metadata ,
fontFaces : fontFacesDecriptors.map (
( { uri , descriptors } ) = >
new ExcalidrawFontFace ( family , uri , descriptors ) ,
) ,
} ) ;
}
return this . registered ;
}
/ * *
* WARN : should be called just once on init , even across multiple instances .
* /
private static init() {
const fonts = {
registered : new Map <
ValueOf < typeof FONT_FAMILY | typeof FONT_FAMILY_FALLBACKS > ,
{ metadata : FontMetadata ; fontFaces : ExcalidrawFontFace [ ] }
> ( ) ,
} ;
const init = (
family : keyof typeof FONT_FAMILY | keyof typeof FONT_FAMILY_FALLBACKS ,
. . . fontFacesDescriptors : ExcalidrawFontFaceDescriptor [ ]
) = > {
const fontFamily =
FONT_FAMILY [ family as keyof typeof FONT_FAMILY ] ? ?
FONT_FAMILY_FALLBACKS [ family as keyof typeof FONT_FAMILY_FALLBACKS ] ;
// default to Excalifont metrics
const metadata =
FONT_METADATA [ fontFamily ] ? ? FONT_METADATA [ FONT_FAMILY . Excalifont ] ;
Fonts . register . call ( fonts , family , metadata , . . . fontFacesDescriptors ) ;
} ;
init ( "Cascadia" , . . . CascadiaFontFaces ) ;
init ( "Comic Shanns" , . . . ComicShannsFontFaces ) ;
init ( "Excalifont" , . . . ExcalifontFontFaces ) ;
// keeping for backwards compatibility reasons, uses system font (Helvetica on MacOS, Arial on Win)
init ( "Helvetica" , . . . HelveticaFontFaces ) ;
// used for server-side pdf & png export instead of helvetica (technically does not need metrics, but kept in for consistency)
init ( "Liberation Sans" , . . . LiberationFontFaces ) ;
init ( "Lilita One" , . . . LilitaFontFaces ) ;
init ( "Nunito" , . . . NunitoFontFaces ) ;
init ( "Virgil" , . . . VirgilFontFaces ) ;
// fallback font faces
init ( CJK_HAND_DRAWN_FALLBACK_FONT , . . . XiaolaiFontFaces ) ;
init ( WINDOWS_EMOJI_FALLBACK_FONT , . . . EmojiFontFaces ) ;
Fonts . _initialized = true ;
return fonts . registered ;
}
/ * *
* Get all the unique font families for the given elements .
* /
private static getUniqueFamilies (
elements : ReadonlyArray < ExcalidrawElement > ,
) : Array < ExcalidrawTextElement [ " fontFamily " ] > {
return Array . from (
elements . reduce ( ( families , element ) = > {
if ( isTextElement ( element ) ) {
families . add ( element . fontFamily ) ;
}
return families ;
} , new Set < number > ( ) ) ,
) ;
}
/ * *
* Get all the unique characters per font family for the given scene .
* /
private static getCharsPerFamily (
elements : ReadonlyArray < ExcalidrawElement > ,
) : Record < number , Set < string > > {
const charsPerFamily : Record < number , Set < string > > = { } ;
for ( const element of elements ) {
if ( ! isTextElement ( element ) ) {
continue ;
}
// gather unique codepoints only when inlining fonts
for ( const char of element . originalText ) {
if ( ! charsPerFamily [ element . fontFamily ] ) {
charsPerFamily [ element . fontFamily ] = new Set ( ) ;
}
charsPerFamily [ element . fontFamily ] . add ( char ) ;
}
}
return charsPerFamily ;
}
/ * *
* Get characters for a given family .
* /
private static getCharacters (
charsPerFamily : Record < number , Set < string > > ,
family : number ,
) {
return charsPerFamily [ family ]
? Array . from ( charsPerFamily [ family ] ) . join ( "" )
: "" ;
}
/ * *
* Get all registered font families .
* /
private static getAllFamilies() {
return Array . from ( Fonts . registered . keys ( ) ) ;
}
}
/ * *
* Calculates vertical offset for a text with alphabetic baseline .
* /
export const getVerticalOffset = (
fontFamily : ExcalidrawTextElement [ "fontFamily" ] ,
fontSize : ExcalidrawTextElement [ "fontSize" ] ,
lineHeightPx : number ,
) = > {
const { unitsPerEm , ascender , descender } =
Fonts . registered . get ( fontFamily ) ? . metadata . metrics ||
FONT_METADATA [ FONT_FAMILY . Virgil ] . metrics ;
const fontSizeEm = fontSize / unitsPerEm ;
const lineGap =
( lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender ) / 2 ;
const verticalOffset = fontSizeEm * ascender + lineGap ;
return verticalOffset ;
} ;
/ * *
* Gets line height forr a selected family .
* /
export const getLineHeight = ( fontFamily : FontFamilyValues ) = > {
const { lineHeight } =
Fonts . registered . get ( fontFamily ) ? . metadata . metrics ||
FONT_METADATA [ FONT_FAMILY . Excalifont ] . metrics ;
return lineHeight as ExcalidrawTextElement [ "lineHeight" ] ;
} ;
export interface ExcalidrawFontFaceDescriptor {
uri : string ;
descriptors? : FontFaceDescriptors ;
}