mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-29 18:04:21 +01:00 
			
		
		
		
	Glyph subsetting with wasm-based harfbuzzjs
This commit is contained in:
		| @@ -850,7 +850,7 @@ export const actionChangeFontFamily = register({ | ||||
|         ExcalidrawTextElement, | ||||
|         ExcalidrawElement | null | ||||
|       >(); | ||||
|       let uniqueGlyphs = new Set<string>(); | ||||
|       let uniqueChars = new Set<string>(); | ||||
|       let skipFontFaceCheck = false; | ||||
|  | ||||
|       const fontsCache = Array.from(Fonts.loadedFontsCache.values()); | ||||
| @@ -898,8 +898,8 @@ export const actionChangeFontFamily = register({ | ||||
|               } | ||||
|  | ||||
|               if (!skipFontFaceCheck) { | ||||
|                 uniqueGlyphs = new Set([ | ||||
|                   ...uniqueGlyphs, | ||||
|                 uniqueChars = new Set([ | ||||
|                   ...uniqueChars, | ||||
|                   ...Array.from(newElement.originalText), | ||||
|                 ]); | ||||
|               } | ||||
| @@ -919,12 +919,9 @@ export const actionChangeFontFamily = register({ | ||||
|       const fontString = `10px ${getFontFamilyString({ | ||||
|         fontFamily: nextFontFamily, | ||||
|       })}`; | ||||
|       const glyphs = Array.from(uniqueGlyphs.values()).join(); | ||||
|       const chars = Array.from(uniqueChars.values()).join(); | ||||
|  | ||||
|       if ( | ||||
|         skipFontFaceCheck || | ||||
|         window.document.fonts.check(fontString, glyphs) | ||||
|       ) { | ||||
|       if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) { | ||||
|         // 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) { | ||||
|           // trigger synchronous redraw | ||||
| @@ -936,8 +933,8 @@ export const actionChangeFontFamily = register({ | ||||
|           ); | ||||
|         } | ||||
|       } else { | ||||
|         // otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded | ||||
|         window.document.fonts.load(fontString, glyphs).then((fontFaces) => { | ||||
|         // otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded | ||||
|         window.document.fonts.load(fontString, chars).then((fontFaces) => { | ||||
|           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) | ||||
|             const latestElement = app.scene.getElement(element.id); | ||||
|   | ||||
| @@ -1,12 +1,15 @@ | ||||
| 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"; | ||||
|  | ||||
| // import init, * as brotli from "../../../node_modules/brotli-wasm/pkg.web/brotli_wasm.js"; | ||||
| export interface Font { | ||||
|   urls: URL[]; | ||||
|   fontFace: FontFace; | ||||
|   getContent(): Promise<string>; | ||||
|   getContent(codePoints: ReadonlySet<number>): Promise<string>; | ||||
| } | ||||
| export const UNPKG_PROD_URL = `https://unpkg.com/${ | ||||
| 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) | ||||
| @@ -32,21 +35,32 @@ export class ExcalidrawFont implements Font { | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Tries to fetch woff2 content, based on the registered urls. | ||||
|    * Returns last defined url in case of errors. | ||||
|    * Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks). | ||||
|    * | ||||
|    * 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; | ||||
|     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:") { | ||||
|         // it's dataurl, the font is inlined as base64, no need to fetch | ||||
|         return url.toString(); | ||||
|         const arrayBuffer = Buffer.from( | ||||
|           url.toString().split(",")[1], | ||||
|           "base64", | ||||
|         ).buffer; | ||||
|  | ||||
|         const base64 = await ExcalidrawFont.trySubsetGlyphsByCodePoints( | ||||
|           arrayBuffer, | ||||
|           codePoints, | ||||
|         ); | ||||
|  | ||||
|         return base64; | ||||
|       } | ||||
|  | ||||
|       try { | ||||
| @@ -57,13 +71,13 @@ export class ExcalidrawFont implements Font { | ||||
|         }); | ||||
|  | ||||
|         if (response.ok) { | ||||
|           const mimeType = await response.headers.get("Content-Type"); | ||||
|           const buffer = await response.arrayBuffer(); | ||||
|           const arrayBuffer = await response.arrayBuffer(); | ||||
|           const base64 = await ExcalidrawFont.trySubsetGlyphsByCodePoints( | ||||
|             arrayBuffer, | ||||
|             codePoints, | ||||
|           ); | ||||
|  | ||||
|           return `data:${mimeType};base64,${await stringToBase64( | ||||
|             await toByteString(buffer), | ||||
|             true, | ||||
|           )}`; | ||||
|           return base64; | ||||
|         } | ||||
|  | ||||
|         // response not ok, try to continue | ||||
| @@ -89,6 +103,42 @@ export class ExcalidrawFont implements Font { | ||||
|     return this.urls.length ? this.urls[this.urls.length - 1].toString() : ""; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Tries to convert a font data as arraybuffer into a dataurl (base64) with subsetted glyphs based on the specified `codePoints`. | ||||
|    * | ||||
|    * @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 trySubsetGlyphsByCodePoints( | ||||
|     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) { | ||||
|     return `data:font/woff2;base64,${await stringToBase64( | ||||
|       await toByteString(arrayBuffer), | ||||
|       true, | ||||
|     )}`; | ||||
|   } | ||||
|  | ||||
|   private static createUrls(uri: string): URL[] { | ||||
|     if (uri.startsWith(LOCAL_FONT_PROTOCOL)) { | ||||
|       // no url for local fonts | ||||
| @@ -118,15 +168,14 @@ export class ExcalidrawFont implements Font { | ||||
|     } | ||||
|  | ||||
|     // fallback url for bundled fonts | ||||
|     urls.push(new URL(assetUrl, UNPKG_PROD_URL)); | ||||
|     urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL)); | ||||
|  | ||||
|     return urls; | ||||
|   } | ||||
|  | ||||
|   private static getFormat(url: URL) { | ||||
|     try { | ||||
|       const pathname = new URL(url).pathname; | ||||
|       const parts = pathname.split("."); | ||||
|       const parts = new URL(url).pathname.split("."); | ||||
|  | ||||
|       if (parts.length === 1) { | ||||
|         return ""; | ||||
|   | ||||
							
								
								
									
										202
									
								
								packages/excalidraw/fonts/wasm/hb-subset.bindings.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								packages/excalidraw/fonts/wasm/hb-subset.bindings.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,202 @@ | ||||
| /** | ||||
|  * Modified version of hb-subset bindings from "subset-font" package https://github.com/papandreou/subset-font/blob/3f711c8aa29a426c7f22655861abfb976950f527/index.js | ||||
|  *  | ||||
|  * CHANGELOG: | ||||
|  * - removed dependency on node APIs to work inside the browser | ||||
|  * - removed dependency on font fontverter for brotli compression | ||||
|  * - removed dependencies on lodash and p-limit | ||||
|  * - removed options for preserveNameIds, variationAxes, noLayoutClosure (not needed for now) | ||||
|  * - replaced text input with codepoints | ||||
|  * - rewritten in typescript and with esm modules | ||||
|  | ||||
| Copyright (c) 2012, Andreas Lind Petersen | ||||
| All rights reserved. | ||||
|  | ||||
| Redistribution and use in source and binary forms, with or without | ||||
| modification, are permitted provided that the following conditions are | ||||
| met: | ||||
|  | ||||
|   * Redistributions of source code must retain the above copyright | ||||
|     notice, this list of conditions and the following disclaimer. | ||||
|   * Redistributions in binary form must reproduce the above copyright | ||||
|     notice, this list of conditions and the following disclaimer in | ||||
|     the documentation and/or other materials provided with the | ||||
|     distribution. | ||||
|   * Neither the name of the author nor the names of contributors may | ||||
|     be used to endorse or promote products derived from this | ||||
|     software without specific prior written permission. | ||||
|  | ||||
| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS | ||||
| IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED | ||||
| TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A | ||||
| PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | ||||
| HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | ||||
| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | ||||
| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | ||||
| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | ||||
| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | ||||
| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||||
| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||||
|  */ | ||||
|  | ||||
| // function HB_TAG(str) { | ||||
| //   return str.split("").reduce((a, ch) => { | ||||
| //     return (a << 8) + ch.charCodeAt(0); | ||||
| //   }, 0); | ||||
| // } | ||||
|  | ||||
| function subset( | ||||
|   hbSubsetWasm: any, | ||||
|   heapu8: Uint8Array, | ||||
|   font: ArrayBuffer, | ||||
|   codePoints: ReadonlySet<number>, | ||||
| ) { | ||||
|   const input = hbSubsetWasm.hb_subset_input_create_or_fail(); | ||||
|   if (input === 0) { | ||||
|     throw new Error( | ||||
|       "hb_subset_input_create_or_fail (harfbuzz) returned zero, indicating failure", | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const fontBuffer = hbSubsetWasm.malloc(font.byteLength); | ||||
|   heapu8.set(new Uint8Array(font), fontBuffer); | ||||
|  | ||||
|   // Create the face | ||||
|   const blob = hbSubsetWasm.hb_blob_create( | ||||
|     fontBuffer, | ||||
|     font.byteLength, | ||||
|     2, // HB_MEMORY_MODE_WRITABLE | ||||
|     0, | ||||
|     0, | ||||
|   ); | ||||
|   const face = hbSubsetWasm.hb_face_create(blob, 0); | ||||
|   hbSubsetWasm.hb_blob_destroy(blob); | ||||
|  | ||||
|   // Do the equivalent of --font-features=* | ||||
|   const layoutFeatures = hbSubsetWasm.hb_subset_input_set( | ||||
|     input, | ||||
|     6, // HB_SUBSET_SETS_LAYOUT_FEATURE_TAG | ||||
|   ); | ||||
|   hbSubsetWasm.hb_set_clear(layoutFeatures); | ||||
|   hbSubsetWasm.hb_set_invert(layoutFeatures); | ||||
|  | ||||
|   // if (preserveNameIds) { | ||||
|   //   const inputNameIds = harfbuzzJsWasm.hb_subset_input_set( | ||||
|   //     input, | ||||
|   //     4, // HB_SUBSET_SETS_NAME_ID | ||||
|   //   ); | ||||
|   //   for (const nameId of preserveNameIds) { | ||||
|   //     harfbuzzJsWasm.hb_set_add(inputNameIds, nameId); | ||||
|   //   } | ||||
|   // } | ||||
|  | ||||
|   // if (noLayoutClosure) { | ||||
|   //   harfbuzzJsWasm.hb_subset_input_set_flags( | ||||
|   //     input, | ||||
|   //     harfbuzzJsWasm.hb_subset_input_get_flags(input) | 0x00000200, // HB_SUBSET_FLAGS_NO_LAYOUT_CLOSURE | ||||
|   //   ); | ||||
|   // } | ||||
|  | ||||
|   // Add unicodes indices | ||||
|   const inputUnicodes = hbSubsetWasm.hb_subset_input_unicode_set(input); | ||||
|   for (const c of codePoints) { | ||||
|     hbSubsetWasm.hb_set_add(inputUnicodes, c); | ||||
|   } | ||||
|  | ||||
|   // if (variationAxes) { | ||||
|   //   for (const [axisName, value] of Object.entries(variationAxes)) { | ||||
|   //     if (typeof value === "number") { | ||||
|   //       // Simple case: Pin/instance the variation axis to a single value | ||||
|   //       if ( | ||||
|   //         !harfbuzzJsWasm.hb_subset_input_pin_axis_location( | ||||
|   //           input, | ||||
|   //           face, | ||||
|   //           HB_TAG(axisName), | ||||
|   //           value, | ||||
|   //         ) | ||||
|   //       ) { | ||||
|   //         harfbuzzJsWasm.hb_face_destroy(face); | ||||
|   //         harfbuzzJsWasm.free(fontBuffer); | ||||
|   //         throw new Error( | ||||
|   //           `hb_subset_input_pin_axis_location (harfbuzz) returned zero when pinning ${axisName} to ${value}, indicating failure. Maybe the axis does not exist in the font?`, | ||||
|   //         ); | ||||
|   //       } | ||||
|   //     } else if (value && typeof value === "object") { | ||||
|   //       // Complex case: Reduce the variation space of the axis | ||||
|   //       if ( | ||||
|   //         typeof value.min === "undefined" || | ||||
|   //         typeof value.max === "undefined" | ||||
|   //       ) { | ||||
|   //         harfbuzzJsWasm.hb_face_destroy(face); | ||||
|   //         harfbuzzJsWasm.free(fontBuffer); | ||||
|   //         throw new Error( | ||||
|   //           `${axisName}: You must provide both a min and a max value when setting the axis range`, | ||||
|   //         ); | ||||
|   //       } | ||||
|   //       if ( | ||||
|   //         !harfbuzzJsWasm.hb_subset_input_set_axis_range( | ||||
|   //           input, | ||||
|   //           face, | ||||
|   //           HB_TAG(axisName), | ||||
|   //           value.min, | ||||
|   //           value.max, | ||||
|   //           // An explicit NaN makes harfbuzz use the existing default value, clamping to the new range if necessary | ||||
|   //           value.default ?? NaN, | ||||
|   //         ) | ||||
|   //       ) { | ||||
|   //         harfbuzzJsWasm.hb_face_destroy(face); | ||||
|   //         harfbuzzJsWasm.free(fontBuffer); | ||||
|   //         throw new Error( | ||||
|   //           `hb_subset_input_set_axis_range (harfbuzz) returned zero when setting the range of ${axisName} to [${value.min}; ${value.max}] and a default value of ${value.default}, indicating failure. Maybe the axis does not exist in the font?`, | ||||
|   //         ); | ||||
|   //       } | ||||
|   //     } | ||||
|   //   } | ||||
|   // } | ||||
|  | ||||
|   let subset; | ||||
|   try { | ||||
|     subset = hbSubsetWasm.hb_subset_or_fail(face, input); | ||||
|     if (subset === 0) { | ||||
|       hbSubsetWasm.hb_face_destroy(face); | ||||
|       hbSubsetWasm.free(fontBuffer); | ||||
|       throw new Error( | ||||
|         "hb_subset_or_fail (harfbuzz) returned zero, indicating failure. Maybe the input file is corrupted?", | ||||
|       ); | ||||
|     } | ||||
|   } finally { | ||||
|     // Clean up | ||||
|     hbSubsetWasm.hb_subset_input_destroy(input); | ||||
|   } | ||||
|  | ||||
|   // Get result blob | ||||
|   const result = hbSubsetWasm.hb_face_reference_blob(subset); | ||||
|  | ||||
|   const offset = hbSubsetWasm.hb_blob_get_data(result, 0); | ||||
|   const subsetByteLength = hbSubsetWasm.hb_blob_get_length(result); | ||||
|   if (subsetByteLength === 0) { | ||||
|     hbSubsetWasm.hb_blob_destroy(result); | ||||
|     hbSubsetWasm.hb_face_destroy(subset); | ||||
|     hbSubsetWasm.hb_face_destroy(face); | ||||
|     hbSubsetWasm.free(fontBuffer); | ||||
|     throw new Error( | ||||
|       "Failed to create subset font, maybe the input file is corrupted?", | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const subsetFont = new Uint8Array( | ||||
|     heapu8.subarray(offset, offset + subsetByteLength), | ||||
|   ); | ||||
|  | ||||
|   // Clean up | ||||
|   hbSubsetWasm.hb_blob_destroy(result); | ||||
|   hbSubsetWasm.hb_face_destroy(subset); | ||||
|   hbSubsetWasm.hb_face_destroy(face); | ||||
|   hbSubsetWasm.free(fontBuffer); | ||||
|  | ||||
|   return subsetFont; | ||||
| } | ||||
|  | ||||
| export default { | ||||
|   subset, | ||||
| }; | ||||
							
								
								
									
										44
									
								
								packages/excalidraw/fonts/wasm/hb-subset.loader.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								packages/excalidraw/fonts/wasm/hb-subset.loader.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| let loadedWasm: ReturnType<typeof load> | null = null; | ||||
|  | ||||
| // TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.) | ||||
| const load = (): Promise<{ | ||||
|   subset: ( | ||||
|     fontBuffer: ArrayBuffer, | ||||
|     codePoints: ReadonlySet<number>, | ||||
|   ) => Uint8Array; | ||||
| }> => { | ||||
|   return new Promise(async (resolve) => { | ||||
|     const [binary, bindings] = await Promise.all([ | ||||
|       import("./hb-subset.wasm"), | ||||
|       import("./hb-subset.bindings"), | ||||
|     ]); | ||||
|  | ||||
|     WebAssembly.instantiate(binary.default).then((module) => { | ||||
|       const harfbuzzJsWasm = module.instance.exports; | ||||
|       // @ts-expect-error since `.buffer` is custom prop | ||||
|       const heapu8 = new Uint8Array(harfbuzzJsWasm.memory.buffer); | ||||
|  | ||||
|       const hbSubset = { | ||||
|         subset: (fontBuffer: ArrayBuffer, codePoints: ReadonlySet<number>) => { | ||||
|           return bindings.default.subset( | ||||
|             harfbuzzJsWasm, | ||||
|             heapu8, | ||||
|             fontBuffer, | ||||
|             codePoints, | ||||
|           ); | ||||
|         }, | ||||
|       }; | ||||
|  | ||||
|       resolve(hbSubset); | ||||
|     }); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| // lazy load the default export | ||||
| export default (): ReturnType<typeof load> => { | ||||
|   if (!loadedWasm) { | ||||
|     loadedWasm = load(); | ||||
|   } | ||||
|  | ||||
|   return loadedWasm; | ||||
| }; | ||||
							
								
								
									
										57
									
								
								packages/excalidraw/fonts/wasm/hb-subset.wasm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								packages/excalidraw/fonts/wasm/hb-subset.wasm.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										4047
									
								
								packages/excalidraw/fonts/wasm/woff2.bindings.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4047
									
								
								packages/excalidraw/fonts/wasm/woff2.bindings.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										59
									
								
								packages/excalidraw/fonts/wasm/woff2.loader.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								packages/excalidraw/fonts/wasm/woff2.loader.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| type Vector = any; | ||||
|  | ||||
| let loadedWasm: ReturnType<typeof load> | null = null; | ||||
|  | ||||
| // TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.) | ||||
| const load = (): Promise<{ | ||||
|   compress: (buffer: ArrayBuffer) => Uint8Array; | ||||
|   decompress: (buffer: ArrayBuffer) => Uint8Array; | ||||
| }> => { | ||||
|   return new Promise(async (resolve) => { | ||||
|     const [binary, bindings] = await Promise.all([ | ||||
|       import("./woff2.wasm"), | ||||
|       import("./woff2.bindings"), | ||||
|     ]); | ||||
|  | ||||
|     // initializing the module manually, so that we could pass in the wasm binary | ||||
|     bindings | ||||
|       .default({ wasmBinary: binary.default }) | ||||
|       .then( | ||||
|         (module: { | ||||
|           woff2Enc: (buffer: ArrayBuffer, byteLength: number) => Vector; | ||||
|           woff2Dec: (buffer: ArrayBuffer, byteLength: number) => Vector; | ||||
|         }) => { | ||||
|           // re-map from internal vector into byte array | ||||
|           function convertFromVecToUint8Array(vector: Vector): Uint8Array { | ||||
|             const arr = []; | ||||
|             for (let i = 0, l = vector.size(); i < l; i++) { | ||||
|               arr.push(vector.get(i)); | ||||
|             } | ||||
|  | ||||
|             return new Uint8Array(arr); | ||||
|           } | ||||
|  | ||||
|           // re-exporting only compress and decompress functions (also avoids infinite loop inside emscripten bindings) | ||||
|           const woff2 = { | ||||
|             compress: (buffer: ArrayBuffer) => | ||||
|               convertFromVecToUint8Array( | ||||
|                 module.woff2Enc(buffer, buffer.byteLength), | ||||
|               ), | ||||
|             decompress: (buffer: ArrayBuffer) => | ||||
|               convertFromVecToUint8Array( | ||||
|                 module.woff2Dec(buffer, buffer.byteLength), | ||||
|               ), | ||||
|           }; | ||||
|  | ||||
|           resolve(woff2); | ||||
|         }, | ||||
|       ); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| // lazy loaded default export | ||||
| export default (): ReturnType<typeof load> => { | ||||
|   if (!loadedWasm) { | ||||
|     loadedWasm = load(); | ||||
|   } | ||||
|  | ||||
|   return loadedWasm; | ||||
| }; | ||||
							
								
								
									
										55
									
								
								packages/excalidraw/fonts/wasm/woff2.wasm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								packages/excalidraw/fonts/wasm/woff2.wasm.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -113,6 +113,8 @@ | ||||
|     "esbuild-sass-plugin": "2.16.0", | ||||
|     "eslint-plugin-react": "7.32.2", | ||||
|     "fake-indexeddb": "3.1.7", | ||||
|     "fonteditor-core": "2.4.1", | ||||
|     "harfbuzzjs": "0.3.6", | ||||
|     "import-meta-loader": "1.1.0", | ||||
|     "mini-css-extract-plugin": "2.6.1", | ||||
|     "postcss-loader": "7.0.1", | ||||
|   | ||||
| @@ -355,50 +355,14 @@ export const exportToSvg = async ( | ||||
|         </clipPath>`; | ||||
|   } | ||||
|  | ||||
|   const fontFamilies = elements.reduce((acc, element) => { | ||||
|     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()}); | ||||
|           }`, | ||||
|             ), | ||||
|           ); | ||||
|         }), | ||||
|       ); | ||||
|   const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements); | ||||
|  | ||||
|   svgRoot.innerHTML = ` | ||||
|   ${SVG_EXPORT_TAG} | ||||
|   ${metadata} | ||||
|   <defs> | ||||
|     <style class="style-fonts"> | ||||
|       ${fontFaces.flat().filter(Boolean).join("\n")} | ||||
|       ${fontFaces.join("\n")} | ||||
|     </style> | ||||
|     ${exportingFrameClipPath} | ||||
|   </defs> | ||||
| @@ -469,3 +433,56 @@ export const getExportSize = ( | ||||
|  | ||||
|   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(); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										75
									
								
								scripts/buildWasm.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								scripts/buildWasm.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| /** | ||||
|  * This script is used to convert the wasm modules into js modules, with the binary converted into base64 encoded strings. | ||||
|  */ | ||||
| const fs = require("fs"); | ||||
| const path = require("path"); | ||||
|  | ||||
| const wasmModules = [ | ||||
|   { | ||||
|     pkg: `../node_modules/fonteditor-core`, | ||||
|     src: `./wasm/woff2.wasm`, | ||||
|     dest: `../packages/excalidraw/fonts/wasm/woff2.wasm.ts`, | ||||
|   }, | ||||
|   { | ||||
|     pkg: `../node_modules/harfbuzzjs`, | ||||
|     src: `./wasm/hb-subset.wasm`, | ||||
|     dest: `../packages/excalidraw/fonts/wasm/hb-subset.wasm.ts`, | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| for (const { pkg, src, dest } of wasmModules) { | ||||
|   const packagePath = path.resolve(__dirname, pkg, "package.json"); | ||||
|   const licensePath = path.resolve(__dirname, pkg, "LICENSE"); | ||||
|   const sourcePath = path.resolve(__dirname, src); | ||||
|   const destPath = path.resolve(__dirname, dest); | ||||
|  | ||||
|   const { | ||||
|     name, | ||||
|     version, | ||||
|     author, | ||||
|     license, | ||||
|     authors, | ||||
|     licenses, | ||||
|   } = require(packagePath); | ||||
|  | ||||
|   const licenseContent = fs.readFileSync(licensePath, "utf-8") || ""; | ||||
|   const base64 = fs.readFileSync(sourcePath, "base64"); | ||||
|   const content = `// GENERATED CODE -- DO NOT EDIT! | ||||
| /* eslint-disable prettier/prettier */ | ||||
| // @ts-nocheck | ||||
|  | ||||
| /** | ||||
| * The following wasm module is generated with \`scripts/buildWasm.js\` and encoded as base64. | ||||
| * | ||||
| * The source of this content is taken from the package "${name}", which contains the following metadata: | ||||
| *  | ||||
| * @author ${author || JSON.stringify(authors)}  | ||||
| * @license ${license || JSON.stringify(licenses)} | ||||
| * @version ${version} | ||||
|  | ||||
| ${licenseContent} | ||||
| */ | ||||
|  | ||||
| // faster atob alternative - https://github.com/evanw/esbuild/issues/1534#issuecomment-902738399 | ||||
| const __toBinary = /* @__PURE__ */ (() => { | ||||
|   const table = new Uint8Array(128); | ||||
|   for (let i = 0; i < 64; i++) | ||||
|     {table[i < 26 ? i + 65 : i < 52 ? i + 71 : i < 62 ? i - 4 : i * 4 - 205] = i;} | ||||
|   return (base64) => { | ||||
|     const n = base64.length; const bytes = new Uint8Array((n - (base64[n - 1] == "=") - (base64[n - 2] == "=")) * 3 / 4 | 0); | ||||
|     for (let i2 = 0, j = 0; i2 < n; ) { | ||||
|       const c0 = table[base64.charCodeAt(i2++)]; const c1 = table[base64.charCodeAt(i2++)]; | ||||
|       const c2 = table[base64.charCodeAt(i2++)]; const c3 = table[base64.charCodeAt(i2++)]; | ||||
|       bytes[j++] = c0 << 2 | c1 >> 4; | ||||
|       bytes[j++] = c1 << 4 | c2 >> 2; | ||||
|       bytes[j++] = c2 << 6 | c3; | ||||
|     } | ||||
|     return bytes; | ||||
|   }; | ||||
| })(); | ||||
|  | ||||
| export default __toBinary(\`${base64}\`); | ||||
| `; | ||||
|  | ||||
|   fs.writeFileSync(destPath, content); | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								scripts/wasm/hb-subset.wasm
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								scripts/wasm/hb-subset.wasm
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								scripts/wasm/woff2.wasm
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								scripts/wasm/woff2.wasm
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										12
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								yarn.lock
									
									
									
									
									
								
							| @@ -6194,6 +6194,13 @@ fonteditor-core@2.4.0: | ||||
|   dependencies: | ||||
|     "@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: | ||||
|   version "0.3.3" | ||||
|   resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" | ||||
| @@ -6457,6 +6464,11 @@ hachure-fill@^0.5.2: | ||||
|   resolved "https://registry.yarnpkg.com/hachure-fill/-/hachure-fill-0.5.2.tgz#d19bc4cc8750a5962b47fb1300557a85fcf934cc" | ||||
|   integrity sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg== | ||||
|  | ||||
| harfbuzzjs@0.3.6: | ||||
|   version "0.3.6" | ||||
|   resolved "https://registry.yarnpkg.com/harfbuzzjs/-/harfbuzzjs-0.3.6.tgz#97865c861aa7734af5bd1904570712e9d753fda9" | ||||
|   integrity sha512-dzf7y6NS8fiAIvPAL/VKwY8wx2HCzUB0vUfOo6h1J5UilFEEf7iYqFsvgwjHwvM3whbjfOMadNvQekU3KuRnWQ== | ||||
|  | ||||
| has-ansi@^2.0.0: | ||||
|   version "2.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Marcel Mraz
					Marcel Mraz