@ -3,11 +3,17 @@ import {
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 {
charWidth ,
containsCJK ,
getContainerElement ,
} from "../element/textElement" ;
import { ShapeCache } from "../scene/ShapeCache" ;
import { getFontString } from "../utils" ;
import { getFontString , PromisePool , promiseTry } from "../utils" ;
import { ExcalidrawFontFace } from "./ExcalidrawFontFace" ;
import { CascadiaFontFaces } from "./Cascadia" ;
@ -73,6 +79,13 @@ export class Fonts {
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
@ -81,7 +94,7 @@ export class Fonts {
* 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 [ ] ) = > {
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.
@ -127,12 +140,40 @@ export class Fonts {
/ * *
* 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 loaded = await Fonts . loadFontFaces ( sceneFamilies ) ;
this . onLoaded ( loaded ) ;
return loaded ;
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 ) ;
} ;
/ * *
@ -144,17 +185,48 @@ export class Fonts {
} ;
/ * *
* Load font faces for passed elements - use when the scene is unavailable ( i . e . export ) .
* Generate CSS @font - face declarations for the given elements .
* /
public static loadElementsFonts = async (
public static async generateFontFaceDeclarations (
elements : readonly ExcalidrawElement [ ] ,
) : Promise < FontFace [ ] > = > {
const fontFamilies = Fonts . getElementsFamilies ( elements ) ;
return await Fonts . loadFontFaces ( fontFamilies ) ;
} ;
) {
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 ( ) ) {
@ -170,81 +242,96 @@ export class Fonts {
}
}
const loadedFontFaces = await Promise . all (
fontFamilies . map ( async ( fontFamily ) = > {
const fontString = getFontString ( {
fontFamily ,
fontSize : 16 ,
} ) ;
// 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 )
: "" ;
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
if ( ! window . document . fonts . check ( fontString ) ) {
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
return await window . document . fonts . load ( fontString ) ;
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 " ${ fontString } " from urls " ${ Fonts . registered
` Failed to load font " ${ font } " from urls " ${ Fonts . registered
. get ( fontFamily )
? . fontFaces . map ( ( x ) = > x . urls ) } " ` ,
e ,
) ;
}
}
return Promise . resolve ( ) ;
} ) ,
) ;
return loadedFontFaces . flat ( ) . filter ( Boolean ) as FontFace [ ] ;
} ) ;
}
}
}
/ * *
* 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 ] ;
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 ;
}
Fonts . register . call ( fonts , family , metadata , . . . fontFacesDescriptors ) ;
} ;
if ( metadata ? . local ) {
// don't inline local fonts
continue ;
}
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 ) ;
for ( const [ fontFaceIndex , fontFace ] of fontFaces . entries ( ) ) {
yield promiseTry ( async ( ) = > {
try {
const characters = Fonts . getCharacters ( charsPerFamily , family ) ;
const fontFaceCSS = await fontFace . toCSS ( characters ) ;
// fallback font faces
init ( CJK_HAND_DRAWN_FALLBACK_FONT , . . . XiaolaiFontFaces ) ;
init ( WINDOWS_EMOJI_FALLBACK_FONT , . . . EmojiFontFaces ) ;
if ( ! fontFaceCSS ) {
return ;
}
Fonts . _initialized = true ;
// giving a buffer of 10K font faces per family
const fontFaceOrder = familyIndex * 10 _000 + fontFaceIndex ;
const fontFaceTuple = [ fontFaceOrder , fontFaceCSS ] as const ;
return fonts . registered ;
return fontFaceTuple ;
} catch ( error ) {
console . error (
` Couldn't transform font-face to css for family " ${ fontFace . fontFace . family } " ` ,
error ,
) ;
}
} ) ;
}
}
}
/ * *
@ -288,17 +375,55 @@ export class Fonts {
}
/ * *
* Gets all the font families for the given scene .
* WARN: should be called just once on init , even across multiple instances .
* /
public getSceneFamilies = ( ) = > {
return Fonts . getElementsFamilies ( this . scene . getNonDeletedElements ( ) ) ;
} ;
private static init() {
const fonts = {
registered : new Map <
ValueOf < typeof FONT_FAMILY | typeof FONT_FAMILY_FALLBACKS > ,
{ metadata : FontMetadata ; fontFaces : ExcalidrawFontFace [ ] }
> ( ) ,
} ;
private static getAllFamilies() {
return Array . from ( Fonts . registered . keys ( ) ) ;
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 ;
}
private static getElementsFamilies (
/ * *
* Get all the unique font families for the given elements .
* /
private static getUniqueFamilies (
elements : ReadonlyArray < ExcalidrawElement > ,
) : Array < ExcalidrawTextElement [ " fontFamily " ] > {
return Array . from (
@ -310,6 +435,51 @@ export class Fonts {
} , 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 ( ) ) ;
}
}
/ * *