Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ playground/.webgl-libs
# Vendor files
# yoga.wasm

# Copied from harfbuzzjs by `pnpm build:harfbuzz-wasm`
/harfbuzz.wasm

# Rust build output
crates/layout/target

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,16 @@ const svg = await satori(...)

Of course, you can also load the `layout.wasm` file from your local disk via `fs.readFile` in Node.js or other methods.

The standalone build still bundles the HarfBuzz module that Satori uses to shape text, and compiles it the first time it renders. Runtimes that only run WebAssembly modules uploaded with your code, like Cloudflare Workers, don't allow that. On them, pass `harfbuzz.wasm` to `init` as well:

```jsx
import satori, { init } from 'satori/standalone'
import layout from 'satori/layout.wasm'
import harfbuzz from 'satori/harfbuzz.wasm'

await init({ layout, harfbuzz })
```

### Font Embedding

By default, Satori renders the text as `<path>` in SVG, instead of `<text>`. That means it embeds the font path data as inlined information, so succeeding processes (e.g. render the SVG on another platform) don’t need to deal with font files anymore.
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"license": "MPL-2.0",
"files": [
"dist/**",
"layout.wasm"
"layout.wasm",
"harfbuzz.wasm"
],
"imports": {
"#satori/jsx/jsx-runtime": "./src/jsx/jsx-runtime.ts",
Expand All @@ -25,6 +26,7 @@
"exports": {
"./package.json": "./package.json",
"./layout.wasm": "./layout.wasm",
"./harfbuzz.wasm": "./harfbuzz.wasm",
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
Expand Down Expand Up @@ -71,11 +73,13 @@
"dev:default": "NODE_ENV=development tsup src/index.ts --watch --ignore-watch playground",
"dev:playground": "turbo dev --filter=satori-playground...",
"build:wasm": "node scripts/build-layout-wasm.mjs",
"build": "pnpm run build:default && pnpm run build:standalone && pnpm run test:browser-build",
"build": "pnpm run build:default && pnpm run build:standalone && pnpm run build:harfbuzz-wasm && pnpm run test:browser-build && pnpm run test:workers-build",
"build:default": "NODE_ENV=production tsup",
"build:standalone": "NODE_ENV=production SATORI_STANDALONE=1 tsup",
"build:harfbuzz-wasm": "node scripts/copy-harfbuzz-wasm.mjs",
"test": "NODE_ENV=test vitest run",
"test:browser-build": "node test/browser-build.mjs",
"test:workers-build": "node test/workers-build.mjs",
"test:ui": "NODE_ENV=test vitest --ui --coverage.enabled",
"test-type": "tsc -p tsconfig.json --noEmit && tsc -p playground/tsconfig.json --noEmit",
"dev:test": "NODE_ENV=test vitest --update",
Expand Down
35 changes: 34 additions & 1 deletion patches/harfbuzzjs@0.10.0.patch

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions scripts/copy-harfbuzz-wasm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// `satori/standalone` users can pass this module to `init` on runtimes that
// can't compile the bundled HarfBuzz bytes. It must be the binary that the
// bundled HarfBuzz glue code was built for.
import { copyFileSync } from 'node:fs'
import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)

copyFileSync(
require.resolve('harfbuzzjs/hb.wasm'),
new URL('../harfbuzz.wasm', import.meta.url)
)
54 changes: 51 additions & 3 deletions src/harfbuzz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
*/

import opentype from '@shuding/opentype.js'
import harfbuzzjsPromise from 'harfbuzzjs'
// Not `harfbuzzjs` itself: its entry starts HarfBuzz as soon as it's imported.
import createHarfBuzz from 'harfbuzzjs/hb.js'
import hbjs from 'harfbuzzjs/hbjs.js'
import { loadWasm, type InitInput } from './load-wasm.js'

// HarfBuzz types (will be populated when module loads)
let hb: any = null
Expand Down Expand Up @@ -41,6 +44,36 @@ const hbFontFinalizer =
? new FinalizationRegistry<any>((font) => font.destroy())
: null

let harfBuzzSource: InitInput | undefined

/**
* Makes HarfBuzz use the given `harfbuzz.wasm` module instead of compiling the
* bundled one, for `satori/standalone`.
*/
export function setHarfBuzzSource(input: InitInput) {
harfBuzzSource = input
}

function instantiateHarfBuzz(input: InitInput): Promise<unknown> {
return new Promise((resolve, reject) => {
createHarfBuzz({
instantiateWasm(
imports: WebAssembly.Imports,
receiveInstance: (
instance: WebAssembly.Instance,
module: WebAssembly.Module
) => void
) {
loadWasm(input, imports).then(
({ instance, module }) => receiveInstance(instance, module),
reject
)
return {}
},
}).then(resolve, reject)
})
}

/**
* Initialize HarfBuzz WASM module
* Must be called before using any shaping functions
Expand All @@ -57,16 +90,31 @@ export async function initHarfBuzz(): Promise<void> {

// Start initialization
initPromise = (async () => {
hb = await harfbuzzjsPromise
hb = hbjs(
await (harfBuzzSource
? instantiateHarfBuzz(harfBuzzSource)
: createHarfBuzz())
)

if (!hb || typeof hb.createBlob !== 'function') {
throw new Error('HarfBuzz module loaded but API is not available')
}
})()
})().catch((error) => {
// Let the next render try again instead of failing forever.
hb = null
initPromise = null
throw error
})

await initPromise
}

if (process.env.SATORI_STANDALONE !== '1') {
// Preload the bundled module, like the layout engine. If it fails, the
// next render tries again and reports the error.
initHarfBuzz().catch(() => undefined)
}

/**
* Create or get cached HarfBuzz font from opentype.js font
*/
Expand Down
64 changes: 3 additions & 61 deletions src/layout-engine/wasm.external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
*/

import { createLayoutEngine, type LayoutEngine } from './index.js'
import { loadWasm, type InitInput } from '../load-wasm.js'

export type { InitInput }

let resolveEngine: (engine: LayoutEngine) => void
let rejectEngine: (error: unknown) => void
Expand All @@ -12,67 +15,6 @@ const enginePromise: Promise<LayoutEngine> = new Promise((resolve, reject) => {
rejectEngine = reject
})

export type InitInput =
| string
| Request
| URL
| Response
| BufferSource
| Buffer
| WebAssembly.Module
| Promise<Response | BufferSource | Buffer | WebAssembly.Module>

async function loadWasm(
input: InitInput,
imports: WebAssembly.Imports
): Promise<WebAssembly.WebAssemblyInstantiatedSource> {
let source: Response | BufferSource | Buffer | WebAssembly.Module

if (
typeof input === 'string' ||
(typeof Request === 'function' && input instanceof Request) ||
(typeof URL === 'function' && input instanceof URL)
) {
source = await fetch(input)
} else {
source = await input
}

if (typeof Response === 'function' && source instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(source, imports)
} catch (e) {
if (source.headers.get('Content-Type') !== 'application/wasm') {
console.warn(
'`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n',
e
)
}
}
}

const bytes = await source.arrayBuffer()
return await WebAssembly.instantiate(bytes, imports)
}

const instantiated = (await WebAssembly.instantiate(
'buffer' in source
? source.buffer.slice(
source.byteOffset,
source.byteOffset + source.byteLength
)
: source,
imports
)) as WebAssembly.Instance | WebAssembly.WebAssemblyInstantiatedSource

if (instantiated instanceof WebAssembly.Instance) {
return { instance: instantiated, module: source as WebAssembly.Module }
}

return instantiated
}

export function init(input: InitInput) {
const engine = createLayoutEngine()
loadWasm(input, engine.imports)
Expand Down
32 changes: 28 additions & 4 deletions src/layout-engine/wasm.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import type { InitInput } from './wasm.external.js'
import type { LayoutEngine } from './index.js'
import { setHarfBuzzSource } from '../harfbuzz.js'

export type { InitInput }

export interface InitOptions {
/** The `layout.wasm` module. */
layout: InitInput
/**
* The `harfbuzz.wasm` module. Without it, Satori compiles the HarfBuzz
* module bundled in `satori/standalone`, which runtimes that only run
* WebAssembly modules uploaded with the code (like Cloudflare Workers)
* don't allow.
*/
harfbuzz?: InitInput
}

function isInitOptions(input: InitInput | InitOptions): input is InitOptions {
return typeof input === 'object' && input !== null && 'layout' in input
}

/**
* Initializes `satori/standalone` with the `layout.wasm` module. The default
* build bundles it.
* Initializes `satori/standalone` with the `layout.wasm` module, and optionally
* the `harfbuzz.wasm` module. The default build bundles them.
*/
export function init(input: InitInput) {
export function init(input: InitInput | InitOptions) {
if (process.env.SATORI_STANDALONE === '1') {
return import('./wasm.external.js').then((mod) => mod.init(input))
const options: InitOptions = isInitOptions(input)
? input
: { layout: input }

// Set it now, so renders that don't wait for `init` use it too.
if (options.harfbuzz !== undefined) setHarfBuzzSource(options.harfbuzz)

return import('./wasm.external.js').then((mod) => mod.init(options.layout))
} else {
// Do nothing. It's bundled.
}
Expand Down
65 changes: 65 additions & 0 deletions src/load-wasm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Instantiates a WebAssembly module provided by the user, for
* `satori/standalone`.
*/

export type InitInput =
| string
| Request
| URL
| Response
| BufferSource
| Buffer
| WebAssembly.Module
| Promise<Response | BufferSource | Buffer | WebAssembly.Module>

export async function loadWasm(
input: InitInput,
imports: WebAssembly.Imports
): Promise<WebAssembly.WebAssemblyInstantiatedSource> {
let source: Response | BufferSource | Buffer | WebAssembly.Module

if (
typeof input === 'string' ||
(typeof Request === 'function' && input instanceof Request) ||
(typeof URL === 'function' && input instanceof URL)
) {
source = await fetch(input)
} else {
source = await input
}

if (typeof Response === 'function' && source instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(source, imports)
} catch (e) {
if (source.headers.get('Content-Type') !== 'application/wasm') {
console.warn(
'`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n',
e
)
}
}
}

const bytes = await source.arrayBuffer()
return await WebAssembly.instantiate(bytes, imports)
}

const instantiated = (await WebAssembly.instantiate(
'buffer' in source
? source.buffer.slice(
source.byteOffset,
source.byteOffset + source.byteLength
)
: source,
imports
)) as WebAssembly.Instance | WebAssembly.WebAssemblyInstantiatedSource

if (instantiated instanceof WebAssembly.Instance) {
return { instance: instantiated, module: source as WebAssembly.Module }
}

return instantiated
}
Loading