Compare commits
1 Commits
master
...
mrazator/g
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9c91cf93dd |
@ -850,7 +850,7 @@ export const actionChangeFontFamily = register({
|
|||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
ExcalidrawElement | null
|
ExcalidrawElement | null
|
||||||
>();
|
>();
|
||||||
let uniqueGlyphs = new Set<string>();
|
let uniqueChars = new Set<string>();
|
||||||
let skipFontFaceCheck = false;
|
let skipFontFaceCheck = false;
|
||||||
|
|
||||||
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
|
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
|
||||||
@ -898,8 +898,8 @@ export const actionChangeFontFamily = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!skipFontFaceCheck) {
|
if (!skipFontFaceCheck) {
|
||||||
uniqueGlyphs = new Set([
|
uniqueChars = new Set([
|
||||||
...uniqueGlyphs,
|
...uniqueChars,
|
||||||
...Array.from(newElement.originalText),
|
...Array.from(newElement.originalText),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -919,12 +919,9 @@ export const actionChangeFontFamily = register({
|
|||||||
const fontString = `10px ${getFontFamilyString({
|
const fontString = `10px ${getFontFamilyString({
|
||||||
fontFamily: nextFontFamily,
|
fontFamily: nextFontFamily,
|
||||||
})}`;
|
})}`;
|
||||||
const glyphs = Array.from(uniqueGlyphs.values()).join();
|
const chars = Array.from(uniqueChars.values()).join();
|
||||||
|
|
||||||
if (
|
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
|
||||||
skipFontFaceCheck ||
|
|
||||||
window.document.fonts.check(fontString, glyphs)
|
|
||||||
) {
|
|
||||||
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
||||||
for (const [element, container] of elementContainerMapping) {
|
for (const [element, container] of elementContainerMapping) {
|
||||||
// trigger synchronous redraw
|
// trigger synchronous redraw
|
||||||
@ -936,8 +933,8 @@ export const actionChangeFontFamily = register({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
|
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
|
||||||
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
|
window.document.fonts.load(fontString, chars).then((fontFaces) => {
|
||||||
for (const [element, container] of elementContainerMapping) {
|
for (const [element, container] of elementContainerMapping) {
|
||||||
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
||||||
const latestElement = app.scene.getElement(element.id);
|
const latestElement = app.scene.getElement(element.id);
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { stringToBase64, toByteString } from "../data/encode";
|
|
||||||
import { LOCAL_FONT_PROTOCOL } from "./metadata";
|
import { LOCAL_FONT_PROTOCOL } from "./metadata";
|
||||||
|
|
||||||
export interface Font {
|
export interface Font {
|
||||||
urls: URL[];
|
urls: URL[];
|
||||||
fontFace: FontFace;
|
fontFace: FontFace;
|
||||||
getContent(): Promise<string>;
|
getContent(codePoints: ReadonlySet<number>): 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
|
||||||
@ -12,6 +11,10 @@ export const UNPKG_PROD_URL = `https://unpkg.com/${
|
|||||||
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
|
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
|
||||||
}/dist/prod/`;
|
}/dist/prod/`;
|
||||||
|
|
||||||
|
/** caches for lazy loaded chunks, reused across concurrent calls and separate editor instances */
|
||||||
|
let fontEditorCache: Promise<typeof import("fonteditor-core")> | null = null;
|
||||||
|
let brotliCache: Promise<typeof import("fonteditor-core").woff2> | null = null;
|
||||||
|
|
||||||
export class ExcalidrawFont implements Font {
|
export class ExcalidrawFont implements Font {
|
||||||
public readonly urls: URL[];
|
public readonly urls: URL[];
|
||||||
public readonly fontFace: FontFace;
|
public readonly fontFace: FontFace;
|
||||||
@ -33,20 +36,31 @@ export class ExcalidrawFont implements Font {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries to fetch woff2 content, based on the registered urls.
|
* Tries to fetch woff2 content, based on the registered urls.
|
||||||
* Returns last defined url in case of errors.
|
|
||||||
*
|
*
|
||||||
* Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment.
|
* 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(): Promise<string> {
|
public async getContent(codePoints: ReadonlySet<number>): Promise<string> {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const errorMessages = [];
|
const errorMessages = [];
|
||||||
|
|
||||||
while (i < this.urls.length) {
|
while (i < this.urls.length) {
|
||||||
const url = this.urls[i];
|
const url = this.urls[i];
|
||||||
|
|
||||||
|
// it's dataurl, the font is inlined as base64, no need to fetch
|
||||||
if (url.protocol === "data:") {
|
if (url.protocol === "data:") {
|
||||||
// it's dataurl, the font is inlined as base64, no need to fetch
|
const arrayBuffer = Buffer.from(
|
||||||
return url.toString();
|
url.toString().split(",")[1],
|
||||||
|
"base64",
|
||||||
|
).buffer;
|
||||||
|
|
||||||
|
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||||
|
arrayBuffer,
|
||||||
|
codePoints,
|
||||||
|
);
|
||||||
|
|
||||||
|
return base64;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -57,13 +71,12 @@ export class ExcalidrawFont implements Font {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const mimeType = await response.headers.get("Content-Type");
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const buffer = await response.arrayBuffer();
|
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||||
|
arrayBuffer,
|
||||||
return `data:${mimeType};base64,${await stringToBase64(
|
codePoints,
|
||||||
await toByteString(buffer),
|
);
|
||||||
true,
|
return base64;
|
||||||
)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// response not ok, try to continue
|
// response not ok, try to continue
|
||||||
@ -89,6 +102,45 @@ export class ExcalidrawFont implements Font {
|
|||||||
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
|
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a font data as arraybuffer into a dataurl (base64) with subsetted glyphs based on the specified `codePoints`.
|
||||||
|
*
|
||||||
|
* NOTE: only glyphs are subsetted, other metadata as GPOS tables stay, consider filtering those as well in the future
|
||||||
|
*
|
||||||
|
* @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 converted into a dataurl
|
||||||
|
*/
|
||||||
|
private static async subsetGlyphsByCodePoints(
|
||||||
|
arrayBuffer: ArrayBuffer,
|
||||||
|
codePoints: ReadonlySet<number>,
|
||||||
|
): Promise<string> {
|
||||||
|
// checks for the cache first to avoid triggering the import multiple times in case of concurrent calls
|
||||||
|
if (!fontEditorCache) {
|
||||||
|
fontEditorCache = import("fonteditor-core");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Font, woff2 } = await fontEditorCache;
|
||||||
|
|
||||||
|
// checks for the cache first to avoid triggering the init multiple times in case of concurrent calls
|
||||||
|
if (!brotliCache) {
|
||||||
|
brotliCache = woff2.init("/wasm/woff2.wasm");
|
||||||
|
}
|
||||||
|
|
||||||
|
await brotliCache;
|
||||||
|
|
||||||
|
const font = Font.create(arrayBuffer, {
|
||||||
|
type: "woff2",
|
||||||
|
kerning: true,
|
||||||
|
hinting: true,
|
||||||
|
// subset the glyhs based on the specified codepoints!
|
||||||
|
subset: [...codePoints],
|
||||||
|
});
|
||||||
|
|
||||||
|
return font.toBase64({ type: "woff2", hinting: true });
|
||||||
|
}
|
||||||
|
|
||||||
private static createUrls(uri: string): URL[] {
|
private static createUrls(uri: string): URL[] {
|
||||||
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
|
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
|
||||||
// no url for local fonts
|
// no url for local fonts
|
||||||
|
@ -67,6 +67,7 @@
|
|||||||
"canvas-roundrect-polyfill": "0.0.1",
|
"canvas-roundrect-polyfill": "0.0.1",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
|
"fonteditor-core": "2.4.1",
|
||||||
"fractional-indexing": "3.2.0",
|
"fractional-indexing": "3.2.0",
|
||||||
"fuzzy": "0.1.3",
|
"fuzzy": "0.1.3",
|
||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
|
@ -354,50 +354,14 @@ export const exportToSvg = async (
|
|||||||
</clipPath>`;
|
</clipPath>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fontFamilies = elements.reduce((acc, element) => {
|
const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements);
|
||||||
if (isTextElement(element)) {
|
|
||||||
acc.add(element.fontFamily);
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, new Set<number>());
|
|
||||||
|
|
||||||
const fontFaces = opts?.skipInliningFonts
|
|
||||||
? []
|
|
||||||
: await Promise.all(
|
|
||||||
Array.from(fontFamilies).map(async (x) => {
|
|
||||||
const { fonts, metadata } = Fonts.registered.get(x) ?? {};
|
|
||||||
|
|
||||||
if (!Array.isArray(fonts)) {
|
|
||||||
console.error(
|
|
||||||
`Couldn't find registered fonts for font-family "${x}"`,
|
|
||||||
Fonts.registered,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata?.local) {
|
|
||||||
// don't inline local fonts
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
fonts.map(
|
|
||||||
async (font) => `@font-face {
|
|
||||||
font-family: ${font.fontFace.family};
|
|
||||||
src: url(${await font.getContent()});
|
|
||||||
}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
svgRoot.innerHTML = `
|
svgRoot.innerHTML = `
|
||||||
${SVG_EXPORT_TAG}
|
${SVG_EXPORT_TAG}
|
||||||
${metadata}
|
${metadata}
|
||||||
<defs>
|
<defs>
|
||||||
<style class="style-fonts">
|
<style class="style-fonts">
|
||||||
${fontFaces.flat().filter(Boolean).join("\n")}
|
${fontFaces.join("\n")}
|
||||||
</style>
|
</style>
|
||||||
${exportingFrameClipPath}
|
${exportingFrameClipPath}
|
||||||
</defs>
|
</defs>
|
||||||
@ -468,3 +432,56 @@ export const getExportSize = (
|
|||||||
|
|
||||||
return [width, height];
|
return [width, height];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFontFaces = async (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): Promise<string[]> => {
|
||||||
|
const fontFamilies = new Set<number>();
|
||||||
|
const codePoints = new Set<number>();
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
if (!isTextElement(element)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fontFamilies.add(element.fontFamily);
|
||||||
|
|
||||||
|
for (const codePoint of Array.from(element.originalText, (u) =>
|
||||||
|
u.codePointAt(0),
|
||||||
|
)) {
|
||||||
|
if (codePoint) {
|
||||||
|
codePoints.add(codePoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontFaces = await Promise.all(
|
||||||
|
Array.from(fontFamilies).map(async (x) => {
|
||||||
|
const { fonts, metadata } = Fonts.registered.get(x) ?? {};
|
||||||
|
|
||||||
|
if (!Array.isArray(fonts)) {
|
||||||
|
console.error(
|
||||||
|
`Couldn't find registered fonts for font-family "${x}"`,
|
||||||
|
Fonts.registered,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata?.local) {
|
||||||
|
// don't inline local fonts
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
fonts.map(
|
||||||
|
async (font) => `@font-face {
|
||||||
|
font-family: ${font.fontFace.family};
|
||||||
|
src: url(${await font.getContent(codePoints)});
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return fontFaces.flat();
|
||||||
|
};
|
||||||
|
BIN
public/wasm/woff2.wasm
Normal file
BIN
public/wasm/woff2.wasm
Normal file
Binary file not shown.
@ -6194,6 +6194,13 @@ fonteditor-core@2.4.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@xmldom/xmldom" "^0.8.3"
|
"@xmldom/xmldom" "^0.8.3"
|
||||||
|
|
||||||
|
fonteditor-core@2.4.1:
|
||||||
|
version "2.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/fonteditor-core/-/fonteditor-core-2.4.1.tgz#ff4b3cd04b50f98026bedad353d0ef6692464bc9"
|
||||||
|
integrity sha512-nKDDt6kBQGq665tQO5tCRQUClJG/2MAF9YT1eKHl+I4NasdSb6DgXrv/gMjNxjo9NyaVEv9KU9VZxLHMstN1wg==
|
||||||
|
dependencies:
|
||||||
|
"@xmldom/xmldom" "^0.8.3"
|
||||||
|
|
||||||
for-each@^0.3.3:
|
for-each@^0.3.3:
|
||||||
version "0.3.3"
|
version "0.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
|
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user