Kaynağa Gözat

feat: multiple fonts fallbacks (#8286)

Marcel Mraz 1 yıl önce
ebeveyn
işleme
230d0edc44

+ 17 - 41
excalidraw-app/index.html

@@ -95,6 +95,11 @@
         color: #fff;
       }
     </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) { %>
     <script>
@@ -114,52 +119,16 @@
       ) {
         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>
+
+    <!-- Following placeholder is replaced during the build step -->
+    <!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
+
     <% } else { %>
     <script>
       window.EXCALIDRAW_ASSET_PATH = window.origin;
     </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 -->
     <link
       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
       rel="preload"
       href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
@@ -200,6 +169,13 @@
       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' &&
     VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
     <script>

+ 5 - 5
packages/excalidraw/components/FontPicker/FontPickerList.tsx

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

+ 124 - 43
packages/excalidraw/fonts/ExcalidrawFont.ts

@@ -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;
-
-    // fallback to unpkg to form a valid URL in case of a passed relative assetUrl
-    let baseUrlBuilder = window.EXCALIDRAW_ASSET_PATH || UNPKG_PROD_URL;
-
-    // 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();
-    }
+    this.urls = ExcalidrawFont.createUrls(uri);
 
-    // ensure there is a trailing slash, otherwise url won't be correctly concatenated
-    baseUrl = `${baseUrlBuilder.replace(/\/+$/, "")}/`;
+    const sources = this.urls
+      .map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`)
+      .join(", ");
 
-    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),
+    );
+
+    // 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 (!response.ok) {
-      console.error(
-        `Couldn't fetch font-family "${this.fontFace.family}" from url "${this.url}"`,
-        response,
+    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 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 `data:${mimeType};base64,${await stringToBase64(
-      await toByteString(buffer),
-      true,
-    )}`;
+    return result;
   }
 }

+ 2 - 1
packages/excalidraw/fonts/assets/fonts.css

@@ -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";

+ 11 - 12
packages/excalidraw/fonts/index.ts

@@ -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),
       ),
     });

+ 3 - 0
packages/excalidraw/fonts/metadata.ts

@@ -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: {

+ 1 - 1
packages/excalidraw/global.d.ts

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

+ 13 - 21
packages/excalidraw/scene/export.ts

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

+ 86 - 0
scripts/woff2/woff2-vite-plugins.js

@@ -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.
  * Other woff2 imports are automatically served and resolved as a file uri.
@@ -41,6 +44,89 @@ module.exports.woff2BrowserPlugin = () => {
           `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"
+      />
+    `,
+        );
+      }
     },
   };
 };

+ 4 - 2
setupTests.ts

@@ -72,12 +72,14 @@ vi.mock(
       ...mod,
       ExcalidrawFont: class extends ExcalidrawFontImpl {
         public async getContent(): Promise<string> {
-          if (this.url.protocol !== "file:") {
+          const url = this.urls[0];
+
+          if (url.protocol !== "file:") {
             return super.getContent();
           }
 
           // 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")}`;
         }
       },

+ 27 - 1
vercel.json

@@ -6,7 +6,7 @@
       "headers": [
         {
           "key": "Access-Control-Allow-Origin",
-          "value": "*"
+          "value": "https://excalidraw.com"
         },
         {
           "key": "X-Content-Type-Options",
@@ -21,6 +21,32 @@
           "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": [