mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: multiple fonts fallbacks (#8286)
This commit is contained in:
parent
d0a380758e
commit
230d0edc44
11 changed files with 293 additions and 127 deletions
|
@ -1,41 +1,29 @@
|
|||
import { stringToBase64, toByteString } from "../data/encode";
|
||||
import { LOCAL_FONT_PROTOCOL } from "./metadata";
|
||||
|
||||
export interface Font {
|
||||
url: URL;
|
||||
urls: URL[];
|
||||
fontFace: FontFace;
|
||||
getContent(): Promise<string>;
|
||||
}
|
||||
export const UNPKG_PROD_URL = `https://unpkg.com/${
|
||||
import.meta.env.VITE_PKG_NAME
|
||||
}@${import.meta.env.PKG_VERSION}/dist/prod/`;
|
||||
? `${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/`;
|
||||
|
||||
export class ExcalidrawFont implements Font {
|
||||
public readonly url: URL;
|
||||
public readonly urls: URL[];
|
||||
public readonly fontFace: FontFace;
|
||||
|
||||
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
|
||||
// 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(/^\/+/, "");
|
||||
let baseUrl: string | undefined = undefined;
|
||||
this.urls = ExcalidrawFont.createUrls(uri);
|
||||
|
||||
// fallback to unpkg to form a valid URL in case of a passed relative assetUrl
|
||||
let baseUrlBuilder = window.EXCALIDRAW_ASSET_PATH || UNPKG_PROD_URL;
|
||||
const sources = this.urls
|
||||
.map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`)
|
||||
.join(", ");
|
||||
|
||||
// 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(baseUrlBuilder)) {
|
||||
baseUrlBuilder = new URL(
|
||||
baseUrlBuilder.replace(/^\.?\/+/, ""),
|
||||
window?.location?.origin,
|
||||
).toString();
|
||||
}
|
||||
|
||||
// ensure there is a trailing slash, otherwise url won't be correctly concatenated
|
||||
baseUrl = `${baseUrlBuilder.replace(/\/+$/, "")}/`;
|
||||
|
||||
this.url = new URL(assetUrl, baseUrl);
|
||||
this.fontFace = new FontFace(family, `url(${this.url})`, {
|
||||
this.fontFace = new FontFace(family, sources, {
|
||||
display: "swap",
|
||||
style: "normal",
|
||||
weight: "400",
|
||||
|
@ -44,35 +32,128 @@ export class ExcalidrawFont implements Font {
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetches woff2 content based on the registered url (browser).
|
||||
* Tries to fetch woff2 content, based on the registered urls.
|
||||
* Returns last defined url in case of errors.
|
||||
*
|
||||
* Use dataurl outside the browser environment.
|
||||
* Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment.
|
||||
*/
|
||||
public async getContent(): Promise<string> {
|
||||
if (this.url.protocol === "data:") {
|
||||
// it's dataurl, the font is inlined as base64, no need to fetch
|
||||
return this.url.toString();
|
||||
let i = 0;
|
||||
const errorMessages = [];
|
||||
|
||||
while (i < this.urls.length) {
|
||||
const url = this.urls[i];
|
||||
|
||||
if (url.protocol === "data:") {
|
||||
// it's dataurl, the font is inlined as base64, no need to fetch
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "font/woff2",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const mimeType = await response.headers.get("Content-Type");
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
return `data:${mimeType};base64,${await stringToBase64(
|
||||
await toByteString(buffer),
|
||||
true,
|
||||
)}`;
|
||||
}
|
||||
|
||||
// response not ok, try to continue
|
||||
errorMessages.push(
|
||||
`"${url.toString()}" returned status "${response.status}"`,
|
||||
);
|
||||
} catch (e) {
|
||||
errorMessages.push(`"${url.toString()}" returned error "${e}"`);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
const response = await fetch(this.url, {
|
||||
headers: {
|
||||
Accept: "font/woff2",
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
`Failed to fetch font "${
|
||||
this.fontFace.family
|
||||
}" from urls "${this.urls.toString()}`,
|
||||
JSON.stringify(errorMessages, undefined, 2),
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`Couldn't fetch font-family "${this.fontFace.family}" from url "${this.url}"`,
|
||||
response,
|
||||
// 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() : "";
|
||||
}
|
||||
|
||||
private static createUrls(uri: string): URL[] {
|
||||
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
|
||||
// no url for local fonts
|
||||
return [];
|
||||
}
|
||||
|
||||
if (uri.startsWith("http") || uri.startsWith("data")) {
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = await response.headers.get("Content-Type");
|
||||
const buffer = await response.arrayBuffer();
|
||||
// fallback url for bundled fonts
|
||||
urls.push(new URL(assetUrl, UNPKG_PROD_URL));
|
||||
|
||||
return `data:${mimeType};base64,${await stringToBase64(
|
||||
await toByteString(buffer),
|
||||
true,
|
||||
)}`;
|
||||
return urls;
|
||||
}
|
||||
|
||||
private static getFormat(url: URL) {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const parts = 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/* Only UI fonts here, which are needed before the editor initializes. */
|
||||
/* These also cannot be preprended with `EXCALIDRAW_ASSET_PATH`. */
|
||||
/* These cannot be dynamically prepended with `EXCALIDRAW_ASSET_PATH`. */
|
||||
/* WARN: The following content is replaced during excalidraw-app build */
|
||||
|
||||
@font-face {
|
||||
font-family: "Assistant";
|
||||
|
|
|
@ -39,7 +39,7 @@ export class Fonts {
|
|||
number,
|
||||
{
|
||||
metadata: FontMetadata;
|
||||
fontFaces: Font[];
|
||||
fonts: Font[];
|
||||
}
|
||||
>
|
||||
| undefined;
|
||||
|
@ -121,12 +121,9 @@ export class Fonts {
|
|||
|
||||
public load = async () => {
|
||||
// Add all registered font faces into the `document.fonts` (if not added already)
|
||||
for (const { fontFaces } of Fonts.registered.values()) {
|
||||
for (const { fontFace, url } of fontFaces) {
|
||||
if (
|
||||
url.protocol !== LOCAL_FONT_PROTOCOL &&
|
||||
!window.document.fonts.has(fontFace)
|
||||
) {
|
||||
for (const { fonts } of Fonts.registered.values()) {
|
||||
for (const { fontFace } of fonts) {
|
||||
if (!window.document.fonts.has(fontFace)) {
|
||||
window.document.fonts.add(fontFace);
|
||||
}
|
||||
}
|
||||
|
@ -148,8 +145,10 @@ export class Fonts {
|
|||
} catch (e) {
|
||||
// don't let it all fail if just one font fails to load
|
||||
console.error(
|
||||
`Failed to load font: "${fontString}" with error "${e}", given the following registered font:`,
|
||||
JSON.stringify(Fonts.registered.get(fontFamily), undefined, 2),
|
||||
`Failed to load font "${fontString}" from urls "${Fonts.registered
|
||||
.get(fontFamily)
|
||||
?.fonts.map((x) => x.urls)}"`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -168,7 +167,7 @@ export class Fonts {
|
|||
const fonts = {
|
||||
registered: new Map<
|
||||
ValueOf<typeof FONT_FAMILY>,
|
||||
{ metadata: FontMetadata; fontFaces: Font[] }
|
||||
{ metadata: FontMetadata; fonts: Font[] }
|
||||
>(),
|
||||
};
|
||||
|
||||
|
@ -253,7 +252,7 @@ function register(
|
|||
| {
|
||||
registered: Map<
|
||||
ValueOf<typeof FONT_FAMILY>,
|
||||
{ metadata: FontMetadata; fontFaces: Font[] }
|
||||
{ metadata: FontMetadata; fonts: Font[] }
|
||||
>;
|
||||
},
|
||||
family: string,
|
||||
|
@ -267,7 +266,7 @@ function register(
|
|||
if (!registeredFamily) {
|
||||
this.registered.set(familyId, {
|
||||
metadata,
|
||||
fontFaces: params.map(
|
||||
fonts: params.map(
|
||||
({ uri, descriptors }) => new ExcalidrawFont(family, uri, descriptors),
|
||||
),
|
||||
});
|
||||
|
|
|
@ -27,6 +27,8 @@ export interface FontMetadata {
|
|||
deprecated?: true;
|
||||
/** flag to indicate a server-side only font */
|
||||
serverSide?: true;
|
||||
/** flag to indiccate a local-only font */
|
||||
local?: true;
|
||||
}
|
||||
|
||||
export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
|
@ -85,6 +87,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
|||
},
|
||||
icon: FontFamilyNormalIcon,
|
||||
deprecated: true,
|
||||
local: true,
|
||||
},
|
||||
[FONT_FAMILY.Cascadia]: {
|
||||
metrics: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue