360 lines
11 KiB
TypeScript

import type Scene from "../scene/Scene";
import type { ValueOf } from "../utility-types";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
FontFamilyValues,
} from "../element/types";
import { ShapeCache } from "../scene/ShapeCache";
import { isTextElement } from "../element";
import { getFontString } from "../utils";
import { FONT_FAMILY } from "../constants";
import {
LOCAL_FONT_PROTOCOL,
FONT_METADATA,
RANGES,
type FontMetadata,
} from "./metadata";
import { ExcalidrawFont, type Font } from "./ExcalidrawFont";
import { getContainerElement } from "../element/textElement";
import Virgil from "./assets/Virgil-Regular.woff2";
import Excalifont from "./assets/Excalifont-Regular.woff2";
import Cascadia from "./assets/CascadiaCode-Regular.woff2";
import ComicShanns from "./assets/ComicShanns-Regular.woff2";
import LiberationSans from "./assets/LiberationSans-Regular.woff2";
import LilitaLatin from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
import LilitaLatinExt from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
import NunitoLatin from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
import NunitoLatinExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
import NunitoCyrilic from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
import NunitoCyrilicExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
import NunitoVietnamese from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
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;
fonts: Font[];
}
>
| 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: Scene }) {
this.scene = scene;
}
/**
* 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[]) => {
if (
// 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.
fontFaces.every((fontFace) => {
const sig = `${fontFace.family}-${fontFace.style}-${fontFace.weight}-${fontFace.unicodeRange}`;
if (Fonts.loadedFontsCache.has(sig)) {
return true;
}
Fonts.loadedFontsCache.add(sig);
return false;
})
) {
return false;
}
let didUpdate = false;
const elementsMap = this.scene.getNonDeletedElementsMap();
for (const element of this.scene.getNonDeletedElements()) {
if (isTextElement(element)) {
didUpdate = true;
ShapeCache.delete(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.
*/
public loadSceneFonts = async (): Promise<FontFace[]> => {
const sceneFamilies = this.getSceneFontFamilies();
const loaded = await Fonts.loadFontFaces(sceneFamilies);
this.onLoaded(loaded);
return loaded;
};
/**
* Gets all the font families for the given scene.
*/
public getSceneFontFamilies = () => {
return Fonts.getFontFamilies(this.scene.getNonDeletedElements());
};
/**
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
*/
public static loadFontsForElements = async (
elements: readonly ExcalidrawElement[],
): Promise<FontFace[]> => {
const fontFamilies = Fonts.getFontFamilies(elements);
return await Fonts.loadFontFaces(fontFamilies);
};
private static async loadFontFaces(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
) {
// add all registered font faces into the `document.fonts` (if not added already)
for (const { fonts, metadata } of Fonts.registered.values()) {
// skip registering font faces for local fonts (i.e. Helvetica)
if (metadata.local) {
continue;
}
for (const { fontFace } of fonts) {
if (!window.document.fonts.has(fontFace)) {
window.document.fonts.add(fontFace);
}
}
}
const loadedFontFaces = await Promise.all(
fontFamilies.map(async (fontFamily) => {
const fontString = 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!
if (!window.document.fonts.check(fontString)) {
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);
} 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
.get(fontFamily)
?.fonts.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>,
{ metadata: FontMetadata; fonts: Font[] }
>(),
};
// TODO: let's tweak this once we know how `register` will be exposed as part of the custom fonts API
const _register = register.bind(fonts);
_register("Virgil", FONT_METADATA[FONT_FAMILY.Virgil], {
uri: Virgil,
});
_register("Excalifont", FONT_METADATA[FONT_FAMILY.Excalifont], {
uri: Excalifont,
});
// keeping for backwards compatibility reasons, uses system font (Helvetica on MacOS, Arial on Win)
_register("Helvetica", FONT_METADATA[FONT_FAMILY.Helvetica], {
uri: LOCAL_FONT_PROTOCOL,
});
// used for server-side pdf & png export instead of helvetica (technically does not need metrics, but kept in for consistency)
_register(
"Liberation Sans",
FONT_METADATA[FONT_FAMILY["Liberation Sans"]],
{
uri: LiberationSans,
},
);
_register("Cascadia", FONT_METADATA[FONT_FAMILY.Cascadia], {
uri: Cascadia,
});
_register("Comic Shanns", FONT_METADATA[FONT_FAMILY["Comic Shanns"]], {
uri: ComicShanns,
});
_register(
"Lilita One",
FONT_METADATA[FONT_FAMILY["Lilita One"]],
{ uri: LilitaLatinExt, descriptors: { unicodeRange: RANGES.LATIN_EXT } },
{ uri: LilitaLatin, descriptors: { unicodeRange: RANGES.LATIN } },
);
_register(
"Nunito",
FONT_METADATA[FONT_FAMILY.Nunito],
{
uri: NunitoCyrilicExt,
descriptors: { unicodeRange: RANGES.CYRILIC_EXT, weight: "500" },
},
{
uri: NunitoCyrilic,
descriptors: { unicodeRange: RANGES.CYRILIC, weight: "500" },
},
{
uri: NunitoVietnamese,
descriptors: { unicodeRange: RANGES.VIETNAMESE, weight: "500" },
},
{
uri: NunitoLatinExt,
descriptors: { unicodeRange: RANGES.LATIN_EXT, weight: "500" },
},
{
uri: NunitoLatin,
descriptors: { unicodeRange: RANGES.LATIN, weight: "500" },
},
);
Fonts._initialized = true;
return fonts.registered;
}
private static getFontFamilies(
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>()),
);
}
}
/**
* Register a new font.
*
* @param family font family
* @param metadata font metadata
* @param params array of the rest of the FontFace parameters [uri: string, descriptors: FontFaceDescriptors?] ,
*/
function register(
this:
| Fonts
| {
registered: Map<
ValueOf<typeof FONT_FAMILY>,
{ metadata: FontMetadata; fonts: Font[] }
>;
},
family: string,
metadata: FontMetadata,
...params: Array<{ uri: string; descriptors?: FontFaceDescriptors }>
) {
// TODO: likely we will need to abandon number "id" in order to support custom fonts
const familyId = FONT_FAMILY[family as keyof typeof FONT_FAMILY];
const registeredFamily = this.registered.get(familyId);
if (!registeredFamily) {
this.registered.set(familyId, {
metadata,
fonts: params.map(
({ uri, descriptors }) => new ExcalidrawFont(family, uri, descriptors),
),
});
}
return this.registered;
}
/**
* 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"];
};