You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
210 lines
6.3 KiB
TypeScript
210 lines
6.3 KiB
TypeScript
import { promiseTry } from "../utils";
|
|
import { LOCAL_FONT_PROTOCOL } from "./FontMetadata";
|
|
import { subsetWoff2GlyphsByCodepoints } from "../subset/subset-main";
|
|
|
|
type DataURL = string;
|
|
|
|
export class ExcalidrawFontFace {
|
|
public readonly urls: URL[] | DataURL[];
|
|
public readonly fontFace: FontFace;
|
|
|
|
private static readonly UNPKG_FALLBACK_URL = `https://unpkg.com/${
|
|
import.meta.env.VITE_PKG_NAME
|
|
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
|
|
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
|
|
}/dist/prod/`;
|
|
|
|
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
|
|
this.urls = ExcalidrawFontFace.createUrls(uri);
|
|
|
|
const sources = this.urls
|
|
.map((url) => `url(${url}) ${ExcalidrawFontFace.getFormat(url)}`)
|
|
.join(", ");
|
|
|
|
this.fontFace = new FontFace(family, sources, {
|
|
display: "swap",
|
|
style: "normal",
|
|
weight: "400",
|
|
...descriptors,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generates CSS `@font-face` definition with the (subsetted) font source as a data url for the characters within the unicode range.
|
|
*
|
|
* Retrieves `undefined` otherwise.
|
|
*/
|
|
public toCSS(characters: string): Promise<string> | undefined {
|
|
// quick exit in case the characters are not within this font face's unicode range
|
|
if (!this.getUnicodeRangeRegex().test(characters)) {
|
|
return;
|
|
}
|
|
|
|
const codepoints = Array.from(characters).map(
|
|
(char) => char.codePointAt(0)!,
|
|
);
|
|
|
|
return this.getContent(codepoints).then(
|
|
(content) =>
|
|
`@font-face { font-family: ${this.fontFace.family}; src: url(${content}); }`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
|
|
*
|
|
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
|
|
*/
|
|
public async getContent(codePoints: Array<number>): Promise<string> {
|
|
let i = 0;
|
|
const errorMessages = [];
|
|
|
|
while (i < this.urls.length) {
|
|
const url = this.urls[i];
|
|
|
|
try {
|
|
const arrayBuffer = await this.fetchFont(url);
|
|
const base64 = await subsetWoff2GlyphsByCodepoints(
|
|
arrayBuffer,
|
|
codePoints,
|
|
);
|
|
|
|
return base64;
|
|
} catch (e) {
|
|
errorMessages.push(`"${url.toString()}" returned error "${e}"`);
|
|
}
|
|
|
|
i++;
|
|
}
|
|
|
|
console.error(
|
|
`Failed to fetch font family "${this.fontFace.family}"`,
|
|
JSON.stringify(errorMessages, undefined, 2),
|
|
);
|
|
|
|
// in case of issues, at least return the last url as a content
|
|
// defaults to unpkg for bundled fonts (so that we don't have to host them forever) and http url for others
|
|
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
|
|
}
|
|
|
|
public fetchFont(url: URL | DataURL): Promise<ArrayBuffer> {
|
|
return promiseTry(async () => {
|
|
const response = await fetch(url, {
|
|
// always prefer cache (even stale), otherwise it always triggers an unnecessary validation request
|
|
// which we don't need as we are controlling freshness of the fonts with the stable hash suffix in the url
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/Request/cache
|
|
cache: "force-cache",
|
|
headers: {
|
|
Accept: "font/woff2",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const urlString = url instanceof URL ? url.toString() : "dataurl";
|
|
throw new Error(
|
|
`Failed to fetch "${urlString}": ${response.statusText}`,
|
|
);
|
|
}
|
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
return arrayBuffer;
|
|
});
|
|
}
|
|
|
|
private getUnicodeRangeRegex() {
|
|
// using \u{h} or \u{hhhhh} to match any number of hex digits,
|
|
// otherwise we would get an "Invalid Unicode escape" error
|
|
// e.g. U+0-1007F -> \u{0}-\u{1007F}
|
|
const unicodeRangeRegex = this.fontFace.unicodeRange
|
|
.split(/,\s*/)
|
|
.map((range) => {
|
|
const [start, end] = range.replace("U+", "").split("-");
|
|
if (end) {
|
|
return `\\u{${start}}-\\u{${end}}`;
|
|
}
|
|
|
|
return `\\u{${start}}`;
|
|
})
|
|
.join("");
|
|
|
|
return new RegExp(`[${unicodeRangeRegex}]`, "u");
|
|
}
|
|
|
|
private static createUrls(uri: string): URL[] | DataURL[] {
|
|
if (uri.startsWith("data")) {
|
|
// don't create the URL instance, as parsing the huge dataurl string is expensive
|
|
return [uri];
|
|
}
|
|
|
|
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
|
|
// no url for local fonts
|
|
return [];
|
|
}
|
|
|
|
if (uri.startsWith("http")) {
|
|
// one url for http imports or data url
|
|
return [new URL(uri)];
|
|
}
|
|
|
|
// absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away
|
|
const assetUrl: string = uri.replace(/^\/+/, "");
|
|
const urls: URL[] = [];
|
|
|
|
if (typeof window.EXCALIDRAW_ASSET_PATH === "string") {
|
|
const normalizedBaseUrl = this.normalizeBaseUrl(
|
|
window.EXCALIDRAW_ASSET_PATH,
|
|
);
|
|
|
|
urls.push(new URL(assetUrl, normalizedBaseUrl));
|
|
} else if (Array.isArray(window.EXCALIDRAW_ASSET_PATH)) {
|
|
window.EXCALIDRAW_ASSET_PATH.forEach((path) => {
|
|
const normalizedBaseUrl = this.normalizeBaseUrl(path);
|
|
urls.push(new URL(assetUrl, normalizedBaseUrl));
|
|
});
|
|
}
|
|
|
|
// fallback url for bundled fonts
|
|
urls.push(new URL(assetUrl, ExcalidrawFontFace.UNPKG_FALLBACK_URL));
|
|
|
|
return urls;
|
|
}
|
|
|
|
private static getFormat(url: URL | DataURL) {
|
|
if (!(url instanceof URL)) {
|
|
// format is irrelevant for data url
|
|
return "";
|
|
}
|
|
|
|
try {
|
|
const parts = new URL(url).pathname.split(".");
|
|
|
|
if (parts.length === 1) {
|
|
return "";
|
|
}
|
|
|
|
return `format('${parts.pop()}')`;
|
|
} catch (error) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
private static normalizeBaseUrl(baseUrl: string) {
|
|
let result = baseUrl;
|
|
|
|
// in case user passed a root-relative url (~absolute path),
|
|
// like "/" or "/some/path", or relative (starts with "./"),
|
|
// prepend it with `location.origin`
|
|
if (/^\.?\//.test(result)) {
|
|
result = new URL(
|
|
result.replace(/^\.?\/+/, ""),
|
|
window?.location?.origin,
|
|
).toString();
|
|
}
|
|
|
|
// ensure there is a trailing slash, otherwise url won't be correctly concatenated
|
|
result = `${result.replace(/\/+$/, "")}/`;
|
|
|
|
return result;
|
|
}
|
|
}
|