feat: multiple fonts fallbacks (#8286)

This commit is contained in:
Marcel Mraz 2024-07-30 10:34:40 +02:00 committed by GitHub
parent d0a380758e
commit 230d0edc44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 293 additions and 127 deletions

View File

@ -95,6 +95,11 @@
color: #fff; color: #fff;
} }
</style> </style>
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-------------------------------------------------------------------------> <!------------------------------------------------------------------------->
<% if (typeof PROD != 'undefined' && PROD == true) { %> <% if (typeof PROD != 'undefined' && PROD == true) { %>
<script> <script>
@ -114,52 +119,16 @@
) { ) {
window.location.href = "https://app.excalidraw.com"; window.location.href = "https://app.excalidraw.com";
} }
// point into our CDN in prod
window.EXCALIDRAW_ASSET_PATH =
"https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/";
</script> </script>
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<% } else { %> <% } else { %>
<script> <script>
window.EXCALIDRAW_ASSET_PATH = window.origin; window.EXCALIDRAW_ASSET_PATH = window.origin;
</script> </script>
<% } %>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Preload all default fonts and Virgil for backwards compatibility to avoid swap on init -->
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<link
rel="preload"
href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Excalifont-Regular-C9eKQy_N.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Virgil-Regular-hO16qHwV.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/ComicShanns-Regular-D0c8wzsC.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } else { %>
<!-- in DEV we need to preload from the local server and without the hash --> <!-- in DEV we need to preload from the local server and without the hash -->
<link <link
rel="preload" rel="preload"
@ -184,7 +153,7 @@
/> />
<% } %> <% } %>
<!-- For Nunito only preload the latin range, which should be enough for now --> <!-- For Nunito only preload the latin range, which should be good enough for now -->
<link <link
rel="preload" rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2" href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
@ -200,6 +169,13 @@
type="text/css" type="text/css"
/> />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %> VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
<script> <script>

View File

@ -63,15 +63,15 @@ export const FontPickerList = React.memo(
() => () =>
Array.from(Fonts.registered.entries()) Array.from(Fonts.registered.entries())
.filter(([_, { metadata }]) => !metadata.serverSide) .filter(([_, { metadata }]) => !metadata.serverSide)
.map(([familyId, { metadata, fontFaces }]) => { .map(([familyId, { metadata, fonts }]) => {
const font = { const fontDescriptor = {
value: familyId, value: familyId,
icon: metadata.icon, icon: metadata.icon,
text: fontFaces[0].fontFace.family, text: fonts[0].fontFace.family,
}; };
if (metadata.deprecated) { if (metadata.deprecated) {
Object.assign(font, { Object.assign(fontDescriptor, {
deprecated: metadata.deprecated, deprecated: metadata.deprecated,
badge: { badge: {
type: DropDownMenuItemBadgeType.RED, type: DropDownMenuItemBadgeType.RED,
@ -80,7 +80,7 @@ export const FontPickerList = React.memo(
}); });
} }
return font as FontDescriptor; return fontDescriptor as FontDescriptor;
}) })
.sort((a, b) => .sort((a, b) =>
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1, a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,

View File

@ -1,41 +1,29 @@
import { stringToBase64, toByteString } from "../data/encode"; import { stringToBase64, toByteString } from "../data/encode";
import { LOCAL_FONT_PROTOCOL } from "./metadata";
export interface Font { export interface Font {
url: URL; urls: URL[];
fontFace: FontFace; fontFace: FontFace;
getContent(): Promise<string>; getContent(): Promise<string>;
} }
export const UNPKG_PROD_URL = `https://unpkg.com/${ export const UNPKG_PROD_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME 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 { export class ExcalidrawFont implements Font {
public readonly url: URL; public readonly urls: URL[];
public readonly fontFace: FontFace; public readonly fontFace: FontFace;
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) { 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 this.urls = ExcalidrawFont.createUrls(uri);
const assetUrl: string = uri.replace(/^\/+/, "");
let baseUrl: string | undefined = undefined;
// fallback to unpkg to form a valid URL in case of a passed relative assetUrl const sources = this.urls
let baseUrlBuilder = window.EXCALIDRAW_ASSET_PATH || UNPKG_PROD_URL; .map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`)
.join(", ");
// in case user passed a root-relative url (~absolute path), this.fontFace = new FontFace(family, sources, {
// 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})`, {
display: "swap", display: "swap",
style: "normal", style: "normal",
weight: "400", 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> { public async getContent(): Promise<string> {
if (this.url.protocol === "data:") { let i = 0;
// it's dataurl, the font is inlined as base64, no need to fetch const errorMessages = [];
return this.url.toString();
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, { console.error(
headers: { `Failed to fetch font "${
Accept: "font/woff2", this.fontFace.family
}, }" from urls "${this.urls.toString()}`,
}); JSON.stringify(errorMessages, undefined, 2),
);
if (!response.ok) { // in case of issues, at least return the last url as a content
console.error( // defaults to unpkg for bundled fonts (so that we don't have to host them forever) and http url for others
`Couldn't fetch font-family "${this.fontFace.family}" from url "${this.url}"`, return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
response, }
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"); // fallback url for bundled fonts
const buffer = await response.arrayBuffer(); urls.push(new URL(assetUrl, UNPKG_PROD_URL));
return `data:${mimeType};base64,${await stringToBase64( return urls;
await toByteString(buffer), }
true,
)}`; 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;
} }
} }

View File

@ -1,5 +1,6 @@
/* Only UI fonts here, which are needed before the editor initializes. */ /* 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-face {
font-family: "Assistant"; font-family: "Assistant";

View File

@ -39,7 +39,7 @@ export class Fonts {
number, number,
{ {
metadata: FontMetadata; metadata: FontMetadata;
fontFaces: Font[]; fonts: Font[];
} }
> >
| undefined; | undefined;
@ -121,12 +121,9 @@ export class Fonts {
public load = async () => { public load = async () => {
// Add all registered font faces into the `document.fonts` (if not added already) // Add all registered font faces into the `document.fonts` (if not added already)
for (const { fontFaces } of Fonts.registered.values()) { for (const { fonts } of Fonts.registered.values()) {
for (const { fontFace, url } of fontFaces) { for (const { fontFace } of fonts) {
if ( if (!window.document.fonts.has(fontFace)) {
url.protocol !== LOCAL_FONT_PROTOCOL &&
!window.document.fonts.has(fontFace)
) {
window.document.fonts.add(fontFace); window.document.fonts.add(fontFace);
} }
} }
@ -148,8 +145,10 @@ export class Fonts {
} catch (e) { } catch (e) {
// don't let it all fail if just one font fails to load // don't let it all fail if just one font fails to load
console.error( console.error(
`Failed to load font: "${fontString}" with error "${e}", given the following registered font:`, `Failed to load font "${fontString}" from urls "${Fonts.registered
JSON.stringify(Fonts.registered.get(fontFamily), undefined, 2), .get(fontFamily)
?.fonts.map((x) => x.urls)}"`,
e,
); );
} }
} }
@ -168,7 +167,7 @@ export class Fonts {
const fonts = { const fonts = {
registered: new Map< registered: new Map<
ValueOf<typeof FONT_FAMILY>, ValueOf<typeof FONT_FAMILY>,
{ metadata: FontMetadata; fontFaces: Font[] } { metadata: FontMetadata; fonts: Font[] }
>(), >(),
}; };
@ -253,7 +252,7 @@ function register(
| { | {
registered: Map< registered: Map<
ValueOf<typeof FONT_FAMILY>, ValueOf<typeof FONT_FAMILY>,
{ metadata: FontMetadata; fontFaces: Font[] } { metadata: FontMetadata; fonts: Font[] }
>; >;
}, },
family: string, family: string,
@ -267,7 +266,7 @@ function register(
if (!registeredFamily) { if (!registeredFamily) {
this.registered.set(familyId, { this.registered.set(familyId, {
metadata, metadata,
fontFaces: params.map( fonts: params.map(
({ uri, descriptors }) => new ExcalidrawFont(family, uri, descriptors), ({ uri, descriptors }) => new ExcalidrawFont(family, uri, descriptors),
), ),
}); });

View File

@ -27,6 +27,8 @@ export interface FontMetadata {
deprecated?: true; deprecated?: true;
/** flag to indicate a server-side only font */ /** flag to indicate a server-side only font */
serverSide?: true; serverSide?: true;
/** flag to indiccate a local-only font */
local?: true;
} }
export const FONT_METADATA: Record<number, FontMetadata> = { export const FONT_METADATA: Record<number, FontMetadata> = {
@ -85,6 +87,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
}, },
icon: FontFamilyNormalIcon, icon: FontFamilyNormalIcon,
deprecated: true, deprecated: true,
local: true,
}, },
[FONT_FAMILY.Cascadia]: { [FONT_FAMILY.Cascadia]: {
metrics: { metrics: {

View File

@ -1,7 +1,7 @@
interface Window { interface Window {
ClipboardItem: any; ClipboardItem: any;
__EXCALIDRAW_SHA__: string | undefined; __EXCALIDRAW_SHA__: string | undefined;
EXCALIDRAW_ASSET_PATH: string | undefined; EXCALIDRAW_ASSET_PATH: string | string[] | undefined;
EXCALIDRAW_EXPORT_SOURCE: string; EXCALIDRAW_EXPORT_SOURCE: string;
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined; EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
DEBUG_FRACTIONAL_INDICES: boolean | undefined; DEBUG_FRACTIONAL_INDICES: boolean | undefined;

View File

@ -43,7 +43,6 @@ import type { RenderableElementsMap } from "./types";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { renderStaticScene } from "../renderer/staticScene"; import { renderStaticScene } from "../renderer/staticScene";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
import { LOCAL_FONT_PROTOCOL } from "../fonts/metadata";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@ -375,35 +374,28 @@ export const exportToSvg = async (
? [] ? []
: await Promise.all( : await Promise.all(
Array.from(fontFamilies).map(async (x) => { Array.from(fontFamilies).map(async (x) => {
const { fontFaces } = Fonts.registered.get(x) ?? {}; const { fonts, metadata } = Fonts.registered.get(x) ?? {};
if (!Array.isArray(fontFaces)) { if (!Array.isArray(fonts)) {
console.error( console.error(
`Couldn't find registered font-faces for font-family "${x}"`, `Couldn't find registered fonts for font-family "${x}"`,
Fonts.registered, Fonts.registered,
); );
return; return;
} }
return Promise.all( if (metadata?.local) {
fontFaces // don't inline local fonts
.filter((font) => font.url.protocol !== LOCAL_FONT_PROTOCOL) return;
.map(async (font) => { }
try {
const content = await font.getContent();
return `@font-face { return Promise.all(
fonts.map(
async (font) => `@font-face {
font-family: ${font.fontFace.family}; font-family: ${font.fontFace.family};
src: url(${content}); src: url(${await font.getContent()});
}`; }`,
} catch (e) { ),
console.error(
`Skipped inlining font with URL "${font.url.toString()}"`,
e,
);
return "";
}
}),
); );
}), }),
); );

View File

@ -1,3 +1,6 @@
const OSS_FONTS_CDN =
"https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/";
/** /**
* Custom vite plugin to convert url woff2 imports into a text. * Custom vite plugin to convert url woff2 imports into a text.
* Other woff2 imports are automatically served and resolved as a file uri. * Other woff2 imports are automatically served and resolved as a file uri.
@ -41,6 +44,89 @@ module.exports.woff2BrowserPlugin = () => {
`const $1 = $2`, `const $1 = $2`,
); );
} }
// use CDN for Assistant
if (!isDev && id.endsWith("/excalidraw/fonts/assets/fonts.css")) {
return `/* WARN: The following content is generated during excalidraw-app build */
@font-face {
font-family: "Assistant";
src: url(${OSS_FONTS_CDN}Assistant-Regular-DVxZuzxb.woff2)
format("woff2"),
url(./Assistant-Regular.woff2) format("woff2");
font-weight: 400;
style: normal;
display: swap;
}
@font-face {
font-family: "Assistant";
src: url(${OSS_FONTS_CDN}Assistant-Medium-DrcxCXg3.woff2)
format("woff2"),
url(./Assistant-Medium.woff2) format("woff2");
font-weight: 500;
style: normal;
display: swap;
}
@font-face {
font-family: "Assistant";
src: url(${OSS_FONTS_CDN}Assistant-SemiBold-SCI4bEL9.woff2)
format("woff2"),
url(./Assistant-SemiBold.woff2) format("woff2");
font-weight: 600;
style: normal;
display: swap;
}
@font-face {
font-family: "Assistant";
src: url(${OSS_FONTS_CDN}Assistant-Bold-gm-uSS1B.woff2)
format("woff2"),
url(./Assistant-Bold.woff2) format("woff2");
font-weight: 700;
style: normal;
display: swap;
}`;
}
// using EXCALIDRAW_ASSET_PATH as a SSOT
if (!isDev && id.endsWith("excalidraw-app/index.html")) {
return code.replace(
"<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->",
`<script>
// point into our CDN in prod, fallback to root (excalidraw.com) domain in case of issues
window.EXCALIDRAW_ASSET_PATH = [
"${OSS_FONTS_CDN}",
"/",
];
</script>
<!-- Preload all default fonts and Virgil for backwards compatibility to avoid swap on init -->
<link
rel="preload"
href="${OSS_FONTS_CDN}Excalifont-Regular-C9eKQy_N.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="${OSS_FONTS_CDN}Virgil-Regular-hO16qHwV.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="${OSS_FONTS_CDN}ComicShanns-Regular-D0c8wzsC.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
`,
);
}
}, },
}; };
}; };

View File

@ -72,12 +72,14 @@ vi.mock(
...mod, ...mod,
ExcalidrawFont: class extends ExcalidrawFontImpl { ExcalidrawFont: class extends ExcalidrawFontImpl {
public async getContent(): Promise<string> { public async getContent(): Promise<string> {
if (this.url.protocol !== "file:") { const url = this.urls[0];
if (url.protocol !== "file:") {
return super.getContent(); return super.getContent();
} }
// read local assets directly, without running a server // read local assets directly, without running a server
const content = await fs.promises.readFile(this.url); const content = await fs.promises.readFile(url);
return `data:font/woff2;base64,${content.toString("base64")}`; return `data:font/woff2;base64,${content.toString("base64")}`;
} }
}, },

View File

@ -6,7 +6,7 @@
"headers": [ "headers": [
{ {
"key": "Access-Control-Allow-Origin", "key": "Access-Control-Allow-Origin",
"value": "*" "value": "https://excalidraw.com"
}, },
{ {
"key": "X-Content-Type-Options", "key": "X-Content-Type-Options",
@ -21,6 +21,32 @@
"value": "origin" "value": "origin"
} }
] ]
},
{
"source": "/:file*.woff2",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000"
},
{
"key": "Access-Control-Allow-Origin",
"value": "https://excalidraw.com"
}
]
},
{
"source": "/(Virgil|Cascadia|Assistant-Regular).woff2",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000"
},
{
"key": "Access-Control-Allow-Origin",
"value": "*"
}
]
} }
], ],
"redirects": [ "redirects": [