You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
213 lines
6.5 KiB
213 lines
6.5 KiB
import { stringToBase64, toByteString } from "../data/encode"; |
|
import { LOCAL_FONT_PROTOCOL } from "./metadata"; |
|
import loadWoff2 from "./wasm/woff2.loader"; |
|
import loadHbSubset from "./wasm/hb-subset.loader"; |
|
|
|
export interface Font { |
|
urls: URL[]; |
|
fontFace: FontFace; |
|
getContent(codePoints: ReadonlySet<number>): Promise<string>; |
|
} |
|
export const 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/`; |
|
|
|
export class ExcalidrawFont implements Font { |
|
public readonly urls: URL[]; |
|
public readonly fontFace: FontFace; |
|
|
|
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) { |
|
this.urls = ExcalidrawFont.createUrls(uri); |
|
|
|
const sources = this.urls |
|
.map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`) |
|
.join(", "); |
|
|
|
this.fontFace = new FontFace(family, sources, { |
|
display: "swap", |
|
style: "normal", |
|
weight: "400", |
|
...descriptors, |
|
}); |
|
} |
|
|
|
/** |
|
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks). |
|
* |
|
* NOTE: assumes usage of `dataurl` outside the browser environment |
|
* |
|
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise |
|
*/ |
|
public async getContent(codePoints: ReadonlySet<number>): Promise<string> { |
|
let i = 0; |
|
const errorMessages = []; |
|
|
|
while (i < this.urls.length) { |
|
const url = this.urls[i]; |
|
|
|
// it's dataurl (server), the font is inlined as base64, no need to fetch |
|
if (url.protocol === "data:") { |
|
const arrayBuffer = Buffer.from( |
|
url.toString().split(",")[1], |
|
"base64", |
|
).buffer; |
|
|
|
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints( |
|
arrayBuffer, |
|
codePoints, |
|
); |
|
|
|
return base64; |
|
} |
|
|
|
try { |
|
const response = await fetch(url, { |
|
headers: { |
|
Accept: "font/woff2", |
|
}, |
|
}); |
|
|
|
if (response.ok) { |
|
const arrayBuffer = await response.arrayBuffer(); |
|
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints( |
|
arrayBuffer, |
|
codePoints, |
|
); |
|
|
|
return base64; |
|
} |
|
|
|
// response not ok, try to continue |
|
errorMessages.push( |
|
`"${url.toString()}" returned status "${response.status}"`, |
|
); |
|
} catch (e) { |
|
errorMessages.push(`"${url.toString()}" returned error "${e}"`); |
|
} |
|
|
|
i++; |
|
} |
|
|
|
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() : ""; |
|
} |
|
|
|
/** |
|
* Tries to subset glyphs in a font based on the used codepoints, returning the font as daturl. |
|
* |
|
* @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well |
|
* @param codePoints codepoints used to subset the glyphs |
|
* |
|
* @returns font with subsetted glyphs (all glyphs in case of errors) converted into a dataurl |
|
*/ |
|
private static async subsetGlyphsByCodePoints( |
|
arrayBuffer: ArrayBuffer, |
|
codePoints: ReadonlySet<number>, |
|
): Promise<string> { |
|
try { |
|
// lazy loaded wasm modules to avoid multiple initializations in case of concurrent triggers |
|
const { compress, decompress } = await loadWoff2(); |
|
const { subset } = await loadHbSubset(); |
|
|
|
const decompressedBinary = decompress(arrayBuffer).buffer; |
|
const subsetSnft = subset(decompressedBinary, codePoints); |
|
const compressedBinary = compress(subsetSnft.buffer); |
|
|
|
return ExcalidrawFont.toBase64(compressedBinary.buffer); |
|
} catch (e) { |
|
console.error("Skipped glyph subsetting", e); |
|
// Fallback to encoding whole font in case of errors |
|
return ExcalidrawFont.toBase64(arrayBuffer); |
|
} |
|
} |
|
|
|
private static async toBase64(arrayBuffer: ArrayBuffer) { |
|
let base64: string; |
|
|
|
if (typeof Buffer !== "undefined") { |
|
// node + server-side |
|
base64 = Buffer.from(arrayBuffer).toString("base64"); |
|
} else { |
|
base64 = await stringToBase64(await toByteString(arrayBuffer), true); |
|
} |
|
|
|
return `data:font/woff2;base64,${base64}`; |
|
} |
|
|
|
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)); |
|
}); |
|
} |
|
|
|
// fallback url for bundled fonts |
|
urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL)); |
|
|
|
return urls; |
|
} |
|
|
|
private static getFormat(url: URL) { |
|
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; |
|
} |
|
}
|
|
|