hb-subset-wasm 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/THIRD_PARTY_NOTICES +48 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +371 -0
- package/dist/hb-subset.wasm +0 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +67 -0
- package/dist/types.js +1 -0
- package/dist/wasm.d.ts +16 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kyosuke Nakamura
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# hb-subset-wasm
|
|
2
|
+
|
|
3
|
+
HarfBuzz font subsetting compiled to WebAssembly. Optimized for Cloudflare Workers.
|
|
4
|
+
|
|
5
|
+
This is an **unofficial** package that wraps HarfBuzz's subset API in a minimal, standalone WebAssembly module. It is designed to be easy to use for common font subsetting tasks.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
- **Not harfbuzzjs** — harfbuzzjs exposes a large, low-level HarfBuzz API. This package exposes only subsetting, with a small high-level API.
|
|
10
|
+
- **Cloudflare Workers first** — standalone wasm with no JS glue code, no WASI, no filesystem access, no Node.js-specific imports. Works everywhere.
|
|
11
|
+
- **Variable font support** — supports pinning variation axes and narrowing axis ranges.
|
|
12
|
+
- **Composable** — output is a standard `Uint8Array` that can be piped into [woff2-encode-wasm](https://www.npmjs.com/package/woff2-encode-wasm) or any other tool.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install hb-subset-wasm
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> **Note:** This package is ESM-only.
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
### Cloudflare Workers
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { init, subset } from 'hb-subset-wasm';
|
|
28
|
+
import wasmModule from 'hb-subset-wasm/wasm';
|
|
29
|
+
|
|
30
|
+
const ready = init(wasmModule);
|
|
31
|
+
|
|
32
|
+
export default {
|
|
33
|
+
async fetch(request) {
|
|
34
|
+
await ready;
|
|
35
|
+
|
|
36
|
+
const fontData = new Uint8Array(/* fetch from R2, KV, or origin */);
|
|
37
|
+
const result = await subset(fontData, {
|
|
38
|
+
text: 'The characters you need',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return new Response(result, {
|
|
42
|
+
headers: { 'Content-Type': 'font/sfnt' },
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Node.js
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { readFileSync } from 'node:fs';
|
|
52
|
+
import { init, subset } from 'hb-subset-wasm';
|
|
53
|
+
|
|
54
|
+
await init(readFileSync('node_modules/hb-subset-wasm/dist/hb-subset.wasm'));
|
|
55
|
+
|
|
56
|
+
const fontData = new Uint8Array(/* ... your .ttf or .otf bytes ... */);
|
|
57
|
+
const result = await subset(fontData, { text: 'Hello, world!' });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Browser
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { init, subset } from 'hb-subset-wasm';
|
|
64
|
+
|
|
65
|
+
await init(fetch('/hb-subset.wasm'));
|
|
66
|
+
|
|
67
|
+
const result = await subset(fontData, { text: 'Hello, world!' });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Safety limits for untrusted input
|
|
71
|
+
|
|
72
|
+
When subsetting user-supplied fonts in a service:
|
|
73
|
+
|
|
74
|
+
- Enforce a request/body size limit before calling `subset()`.
|
|
75
|
+
- Keep memory growth bounded. `scripts/build-wasm.sh` uses `MAXIMUM_MEMORY_BYTES` (default: `268435456`, i.e. 256MiB).
|
|
76
|
+
- Apply normal service safeguards (timeouts, rate limits, concurrency limits).
|
|
77
|
+
|
|
78
|
+
The Worker E2E example includes a 10MiB request-body limit and returns `413` for oversized payloads.
|
|
79
|
+
|
|
80
|
+
## API
|
|
81
|
+
|
|
82
|
+
### `init(source): Promise<void>`
|
|
83
|
+
|
|
84
|
+
Initialize the WebAssembly module. Call once before using `subset()`.
|
|
85
|
+
|
|
86
|
+
`source` accepts:
|
|
87
|
+
|
|
88
|
+
| Type | Use case |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `WebAssembly.Module` | Cloudflare Workers (pre-compiled, fastest startup) |
|
|
91
|
+
| `BufferSource` | Node.js / Deno (raw .wasm bytes via `readFileSync`) |
|
|
92
|
+
| `Response \| Promise<Response>` | Browser (`fetch()` response, supports streaming compilation) |
|
|
93
|
+
|
|
94
|
+
### `subset(fontData, options): Promise<Uint8Array>`
|
|
95
|
+
|
|
96
|
+
Subset a font. Returns the subsetted font as a `Uint8Array`.
|
|
97
|
+
|
|
98
|
+
| Option | Type | Description |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `text` | `string` | Characters to retain (easiest option) |
|
|
101
|
+
| `unicodes` | `number[]` | Unicode codepoints to retain |
|
|
102
|
+
| `glyphIds` | `number[]` | Glyph IDs to retain |
|
|
103
|
+
| `retainGids` | `boolean` | Preserve original glyph IDs (don't renumber) |
|
|
104
|
+
| `noHinting` | `boolean` | Remove hinting instructions (smaller output) |
|
|
105
|
+
| `variationAxes` | `Record<string, number \| {min?, max?, default?}>` | Pin or narrow variation axes |
|
|
106
|
+
| `passthroughTables` | `string[]` | Table tags to pass through without subsetting |
|
|
107
|
+
| `dropTables` | `string[]` | Table tags to drop entirely |
|
|
108
|
+
|
|
109
|
+
At least one of `text`, `unicodes`, or `glyphIds` must be provided.
|
|
110
|
+
|
|
111
|
+
## Variable fonts
|
|
112
|
+
|
|
113
|
+
Pin a variation axis to a fixed value (removes variability, smaller output):
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const result = await subset(fontData, {
|
|
117
|
+
text: 'Hello',
|
|
118
|
+
variationAxes: { wght: 400 },
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Narrow a variation axis range:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const result = await subset(fontData, {
|
|
126
|
+
text: 'Hello',
|
|
127
|
+
variationAxes: {
|
|
128
|
+
wght: { min: 300, max: 700 },
|
|
129
|
+
wdth: { min: 75, max: 100, default: 100 },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Composing with WOFF2 encoding
|
|
135
|
+
|
|
136
|
+
This package outputs standard TrueType/OpenType font bytes. To convert to WOFF2, pipe the output into a WOFF2 encoder:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { readFileSync } from 'node:fs';
|
|
140
|
+
import { init as initSubset, subset } from 'hb-subset-wasm';
|
|
141
|
+
import { init as initWoff2, encode } from 'woff2-encode-wasm';
|
|
142
|
+
|
|
143
|
+
await initSubset(readFileSync('node_modules/hb-subset-wasm/dist/hb-subset.wasm'));
|
|
144
|
+
await initWoff2(readFileSync('node_modules/woff2-encode-wasm/dist/encoder.wasm'));
|
|
145
|
+
|
|
146
|
+
const subsetFont = await subset(fontData, { text: 'Hello' });
|
|
147
|
+
const woff2Font = await encode(subsetFont);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Performance
|
|
151
|
+
|
|
152
|
+
On a test machine (Apple Silicon), subsetting a small font takes approximately **0.05ms per operation**. The wasm binary is ~577KB (standalone, no JS glue).
|
|
153
|
+
|
|
154
|
+
## Limitations
|
|
155
|
+
|
|
156
|
+
- **WOFF2 encoding is out of scope** — use a separate package like `woff2-encode-wasm`.
|
|
157
|
+
- **Single face only** — font collections (TTC) are not supported; only the first face is used.
|
|
158
|
+
- **No shaping** — this package only performs subsetting, not text shaping.
|
|
159
|
+
- **Axis range narrowing** — fully supported via HarfBuzz's `hb_subset_input_set_axis_range`. Behavior depends on the font's variation data.
|
|
160
|
+
- **AAT features** — Apple Advanced Typography tables are not included in the build to reduce binary size.
|
|
161
|
+
|
|
162
|
+
## HarfBuzz version
|
|
163
|
+
|
|
164
|
+
Built against HarfBuzz 10.4.0. The HarfBuzz source is included as a git submodule under `deps/harfbuzz`.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT (this package). HarfBuzz itself is licensed under the [Old MIT license](https://github.com/harfbuzz/harfbuzz/blob/main/COPYING). See [THIRD_PARTY_NOTICES](./THIRD_PARTY_NOTICES) for full details.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
This package includes HarfBuzz, compiled into the WebAssembly binary (dist/hb-subset.wasm).
|
|
2
|
+
|
|
3
|
+
HarfBuzz is licensed under the "Old MIT" license:
|
|
4
|
+
|
|
5
|
+
--------------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
HarfBuzz is licensed under the so-called "Old MIT" license. Details follow.
|
|
8
|
+
For parts of HarfBuzz that are licensed under different licenses see individual
|
|
9
|
+
files names COPYING in subdirectories where applicable.
|
|
10
|
+
|
|
11
|
+
Copyright © 2010-2022 Google, Inc.
|
|
12
|
+
Copyright © 2015-2020 Ebrahim Byagowi
|
|
13
|
+
Copyright © 2019,2020 Facebook, Inc.
|
|
14
|
+
Copyright © 2012,2015 Mozilla Foundation
|
|
15
|
+
Copyright © 2011 Codethink Limited
|
|
16
|
+
Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies)
|
|
17
|
+
Copyright © 2009 Keith Stribley
|
|
18
|
+
Copyright © 2011 Martin Hosken and SIL International
|
|
19
|
+
Copyright © 2007 Chris Wilson
|
|
20
|
+
Copyright © 2005,2006,2020,2021,2022,2023 Behdad Esfahbod
|
|
21
|
+
Copyright © 2004,2007,2008,2009,2010,2013,2021,2022,2023 Red Hat, Inc.
|
|
22
|
+
Copyright © 1998-2005 David Turner and Werner Lemberg
|
|
23
|
+
Copyright © 2016 Igalia S.L.
|
|
24
|
+
Copyright © 2022 Matthias Clasen
|
|
25
|
+
Copyright © 2018,2021 Khaled Hosny
|
|
26
|
+
Copyright © 2018,2019,2020 Adobe, Inc
|
|
27
|
+
Copyright © 2013-2015 Alexei Podtelezhnikov
|
|
28
|
+
|
|
29
|
+
For full copyright notices consult the individual files in the package.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
Permission is hereby granted, without written agreement and without
|
|
33
|
+
license or royalty fees, to use, copy, modify, and distribute this
|
|
34
|
+
software and its documentation for any purpose, provided that the
|
|
35
|
+
above copyright notice and the following two paragraphs appear in
|
|
36
|
+
all copies of this software.
|
|
37
|
+
|
|
38
|
+
IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR
|
|
39
|
+
DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
|
|
40
|
+
ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN
|
|
41
|
+
IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
|
42
|
+
DAMAGE.
|
|
43
|
+
|
|
44
|
+
THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
|
|
45
|
+
BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
46
|
+
FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS
|
|
47
|
+
ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO
|
|
48
|
+
PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SubsetOptions, WasmSource } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Initialize the wasm module.
|
|
4
|
+
*
|
|
5
|
+
* Call once before using `subset()`. Accepts various source types:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* // Cloudflare Workers — pre-compiled module (fastest)
|
|
9
|
+
* import wasmModule from 'hb-subset-wasm/wasm';
|
|
10
|
+
* await init(wasmModule);
|
|
11
|
+
*
|
|
12
|
+
* // Node.js — raw bytes
|
|
13
|
+
* import { readFileSync } from 'node:fs';
|
|
14
|
+
* await init(readFileSync('node_modules/hb-subset-wasm/dist/hb-subset.wasm'));
|
|
15
|
+
*
|
|
16
|
+
* // Browser — fetch
|
|
17
|
+
* await init(fetch('/hb-subset.wasm'));
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function init(source: WasmSource): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Subset a font, returning the subsetted font bytes.
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* const result = await subset(fontBytes, { text: 'Hello, world!' });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function subset(fontData: Uint8Array | ArrayBuffer, options: SubsetOptions): Promise<Uint8Array>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// HarfBuzz subset flag constants (from hb-subset.h)
|
|
2
|
+
const HB_SUBSET_FLAGS_NO_HINTING = 0x00000001;
|
|
3
|
+
const HB_SUBSET_FLAGS_RETAIN_GIDS = 0x00000002;
|
|
4
|
+
const HB_SUBSET_FLAGS_NOTDEF_OUTLINE = 0x00000040;
|
|
5
|
+
const ERROR_MESSAGES = {
|
|
6
|
+
1: 'Failed to create font blob',
|
|
7
|
+
2: 'Failed to create font face',
|
|
8
|
+
3: 'Font has no glyphs — invalid or corrupted font data',
|
|
9
|
+
4: 'Failed to create subset input — out of memory',
|
|
10
|
+
5: 'Subset operation failed',
|
|
11
|
+
6: 'Failed to serialize subset result',
|
|
12
|
+
7: 'Subset result is empty',
|
|
13
|
+
8: 'Failed to allocate output buffer — out of memory',
|
|
14
|
+
9: 'Failed to pin variation axis — invalid axis tag for this font',
|
|
15
|
+
10: 'Failed to set variation axis range — invalid axis tag or range',
|
|
16
|
+
};
|
|
17
|
+
const MAX_UNICODE_CODEPOINT = 0x10FFFF;
|
|
18
|
+
const MAX_UINT32 = 0xFFFFFFFF;
|
|
19
|
+
const TAG_PATTERN = /^[\x20-\x7E]{4}$/;
|
|
20
|
+
let ex = null;
|
|
21
|
+
let initPromise = null;
|
|
22
|
+
function getRuntimeWebAssembly() {
|
|
23
|
+
const wasm = globalThis.WebAssembly;
|
|
24
|
+
if (!wasm) {
|
|
25
|
+
throw new Error('WebAssembly is not available in this runtime');
|
|
26
|
+
}
|
|
27
|
+
return wasm;
|
|
28
|
+
}
|
|
29
|
+
/** Wasm import stubs — the standalone module needs only these two. */
|
|
30
|
+
function buildImportObject() {
|
|
31
|
+
return {
|
|
32
|
+
env: {
|
|
33
|
+
emscripten_notify_memory_growth: () => { },
|
|
34
|
+
},
|
|
35
|
+
wasi_snapshot_preview1: {
|
|
36
|
+
proc_exit(code) {
|
|
37
|
+
throw new Error(`hb-subset wasm called proc_exit(${code})`);
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function isBinarySource(source) {
|
|
43
|
+
return source instanceof ArrayBuffer || ArrayBuffer.isView(source);
|
|
44
|
+
}
|
|
45
|
+
function isWasmModule(source, wasm) {
|
|
46
|
+
return source instanceof wasm.Module;
|
|
47
|
+
}
|
|
48
|
+
function hasInstance(result) {
|
|
49
|
+
return typeof result === 'object' && result !== null && 'instance' in result;
|
|
50
|
+
}
|
|
51
|
+
function getInstance(result) {
|
|
52
|
+
return hasInstance(result) ? result.instance : result;
|
|
53
|
+
}
|
|
54
|
+
function assertResponseLike(value) {
|
|
55
|
+
if (!value || typeof value !== 'object' || typeof value.arrayBuffer !== 'function') {
|
|
56
|
+
throw new TypeError('init(source) expects a WebAssembly.Module, ArrayBuffer, ArrayBufferView, Response, or Promise<Response>');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function finiteNumber(name, value) {
|
|
60
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
61
|
+
throw new TypeError(`${name} must be a finite number`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
function integerInRange(name, value, min, max) {
|
|
66
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
|
|
67
|
+
throw new RangeError(`${name} must be an integer in [${min}, ${max}]`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function validateTag(tag, field) {
|
|
72
|
+
if (typeof tag !== 'string' || !TAG_PATTERN.test(tag)) {
|
|
73
|
+
throw new TypeError(`${field} must be exactly 4 printable ASCII characters`);
|
|
74
|
+
}
|
|
75
|
+
return tag;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Initialize the wasm module.
|
|
79
|
+
*
|
|
80
|
+
* Call once before using `subset()`. Accepts various source types:
|
|
81
|
+
*
|
|
82
|
+
* ```ts
|
|
83
|
+
* // Cloudflare Workers — pre-compiled module (fastest)
|
|
84
|
+
* import wasmModule from 'hb-subset-wasm/wasm';
|
|
85
|
+
* await init(wasmModule);
|
|
86
|
+
*
|
|
87
|
+
* // Node.js — raw bytes
|
|
88
|
+
* import { readFileSync } from 'node:fs';
|
|
89
|
+
* await init(readFileSync('node_modules/hb-subset-wasm/dist/hb-subset.wasm'));
|
|
90
|
+
*
|
|
91
|
+
* // Browser — fetch
|
|
92
|
+
* await init(fetch('/hb-subset.wasm'));
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export async function init(source) {
|
|
96
|
+
if (ex)
|
|
97
|
+
return;
|
|
98
|
+
if (initPromise)
|
|
99
|
+
return initPromise;
|
|
100
|
+
initPromise = (async () => {
|
|
101
|
+
const wasm = getRuntimeWebAssembly();
|
|
102
|
+
const imports = buildImportObject();
|
|
103
|
+
let instance;
|
|
104
|
+
if (isWasmModule(source, wasm)) {
|
|
105
|
+
// Pre-compiled module (Cloudflare Workers)
|
|
106
|
+
const instantiated = await wasm.instantiate(source, imports);
|
|
107
|
+
instance = getInstance(instantiated);
|
|
108
|
+
}
|
|
109
|
+
else if (isBinarySource(source)) {
|
|
110
|
+
// Raw bytes (Node.js, Deno)
|
|
111
|
+
const bytes = source instanceof ArrayBuffer
|
|
112
|
+
? source
|
|
113
|
+
: source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
|
|
114
|
+
const instantiated = await wasm.instantiate(bytes, imports);
|
|
115
|
+
instance = getInstance(instantiated);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// Response or Promise<Response> (browser fetch)
|
|
119
|
+
const response = await source;
|
|
120
|
+
assertResponseLike(response);
|
|
121
|
+
if (typeof wasm.instantiateStreaming === 'function' && typeof response.clone === 'function') {
|
|
122
|
+
try {
|
|
123
|
+
const instantiated = await wasm.instantiateStreaming(response, imports);
|
|
124
|
+
instance = getInstance(instantiated);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Fallback if content-type isn't application/wasm
|
|
128
|
+
const buf = await response.clone().arrayBuffer();
|
|
129
|
+
const instantiated = await wasm.instantiate(buf, imports);
|
|
130
|
+
instance = getInstance(instantiated);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const buf = await response.arrayBuffer();
|
|
135
|
+
const instantiated = await wasm.instantiate(buf, imports);
|
|
136
|
+
instance = getInstance(instantiated);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
ex = instance.exports;
|
|
140
|
+
// STANDALONE_WASM reactors need _initialize called once
|
|
141
|
+
if (typeof ex._initialize === 'function') {
|
|
142
|
+
ex._initialize();
|
|
143
|
+
}
|
|
144
|
+
})();
|
|
145
|
+
try {
|
|
146
|
+
await initPromise;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
initPromise = null;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function getWasm() {
|
|
154
|
+
if (!ex) {
|
|
155
|
+
throw new Error('hb-subset-wasm not initialized. Call init() first.');
|
|
156
|
+
}
|
|
157
|
+
return ex;
|
|
158
|
+
}
|
|
159
|
+
/** Get the current wasm memory buffer (may change after growth). */
|
|
160
|
+
function buf() {
|
|
161
|
+
return getWasm().memory.buffer;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Encode a 4-character OpenType tag string to bytes.
|
|
165
|
+
*/
|
|
166
|
+
function encodeTag(tag) {
|
|
167
|
+
validateTag(tag, 'tag');
|
|
168
|
+
return [
|
|
169
|
+
tag.charCodeAt(0),
|
|
170
|
+
tag.charCodeAt(1),
|
|
171
|
+
tag.charCodeAt(2),
|
|
172
|
+
tag.charCodeAt(3),
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Subset a font, returning the subsetted font bytes.
|
|
177
|
+
*
|
|
178
|
+
* ```ts
|
|
179
|
+
* const result = await subset(fontBytes, { text: 'Hello, world!' });
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
export async function subset(fontData, options) {
|
|
183
|
+
if (!options || typeof options !== 'object') {
|
|
184
|
+
throw new TypeError('options must be an object');
|
|
185
|
+
}
|
|
186
|
+
if (options.text !== undefined && typeof options.text !== 'string') {
|
|
187
|
+
throw new TypeError('text must be a string');
|
|
188
|
+
}
|
|
189
|
+
if (options.unicodes !== undefined && !Array.isArray(options.unicodes)) {
|
|
190
|
+
throw new TypeError('unicodes must be an array of integers');
|
|
191
|
+
}
|
|
192
|
+
if (options.glyphIds !== undefined && !Array.isArray(options.glyphIds)) {
|
|
193
|
+
throw new TypeError('glyphIds must be an array of integers');
|
|
194
|
+
}
|
|
195
|
+
if (options.retainGids !== undefined && typeof options.retainGids !== 'boolean') {
|
|
196
|
+
throw new TypeError('retainGids must be a boolean');
|
|
197
|
+
}
|
|
198
|
+
if (options.noHinting !== undefined && typeof options.noHinting !== 'boolean') {
|
|
199
|
+
throw new TypeError('noHinting must be a boolean');
|
|
200
|
+
}
|
|
201
|
+
if (options.passthroughTables !== undefined && !Array.isArray(options.passthroughTables)) {
|
|
202
|
+
throw new TypeError('passthroughTables must be an array of OpenType tags');
|
|
203
|
+
}
|
|
204
|
+
if (options.dropTables !== undefined && !Array.isArray(options.dropTables)) {
|
|
205
|
+
throw new TypeError('dropTables must be an array of OpenType tags');
|
|
206
|
+
}
|
|
207
|
+
if (options.variationAxes !== undefined
|
|
208
|
+
&& (typeof options.variationAxes !== 'object' || options.variationAxes === null || Array.isArray(options.variationAxes))) {
|
|
209
|
+
throw new TypeError('variationAxes must be an object mapping axis tags to numbers or ranges');
|
|
210
|
+
}
|
|
211
|
+
const m = getWasm();
|
|
212
|
+
const font = fontData instanceof Uint8Array ? fontData : new Uint8Array(fontData);
|
|
213
|
+
if (font.length === 0) {
|
|
214
|
+
throw new RangeError('fontData must not be empty');
|
|
215
|
+
}
|
|
216
|
+
// Collect all unicode codepoints
|
|
217
|
+
const unicodes = [];
|
|
218
|
+
if (options.text) {
|
|
219
|
+
for (const char of options.text) {
|
|
220
|
+
const cp = char.codePointAt(0);
|
|
221
|
+
if (cp !== undefined)
|
|
222
|
+
unicodes.push(cp);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (options.unicodes) {
|
|
226
|
+
options.unicodes.forEach((cp, index) => {
|
|
227
|
+
unicodes.push(integerInRange(`unicodes[${index}]`, cp, 0, MAX_UNICODE_CODEPOINT));
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const glyphIds = (options.glyphIds ?? []).map((gid, index) => integerInRange(`glyphIds[${index}]`, gid, 0, MAX_UINT32));
|
|
231
|
+
if (unicodes.length === 0 && glyphIds.length === 0) {
|
|
232
|
+
throw new Error('At least one of text, unicodes, or glyphIds must be provided');
|
|
233
|
+
}
|
|
234
|
+
// Build flags
|
|
235
|
+
let flags = HB_SUBSET_FLAGS_NOTDEF_OUTLINE; // keep .notdef outline by default
|
|
236
|
+
if (options.retainGids)
|
|
237
|
+
flags |= HB_SUBSET_FLAGS_RETAIN_GIDS;
|
|
238
|
+
if (options.noHinting)
|
|
239
|
+
flags |= HB_SUBSET_FLAGS_NO_HINTING;
|
|
240
|
+
// Prepare variation axes
|
|
241
|
+
const pinTags = [];
|
|
242
|
+
const pinValues = [];
|
|
243
|
+
const rangeTags = [];
|
|
244
|
+
const rangeMins = [];
|
|
245
|
+
const rangeMaxs = [];
|
|
246
|
+
const rangeDefs = [];
|
|
247
|
+
if (options.variationAxes) {
|
|
248
|
+
for (const [rawTag, rawValue] of Object.entries(options.variationAxes)) {
|
|
249
|
+
const tag = validateTag(rawTag, `variationAxes key "${rawTag}"`);
|
|
250
|
+
const encoded = encodeTag(tag);
|
|
251
|
+
if (typeof rawValue === 'number') {
|
|
252
|
+
pinTags.push(encoded);
|
|
253
|
+
pinValues.push(finiteNumber(`variationAxes.${tag}`, rawValue));
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
|
|
257
|
+
throw new TypeError(`variationAxes.${tag} must be a finite number or { min?, max?, default? }`);
|
|
258
|
+
}
|
|
259
|
+
const hasMin = rawValue.min !== undefined;
|
|
260
|
+
const hasMax = rawValue.max !== undefined;
|
|
261
|
+
const hasDefault = rawValue.default !== undefined;
|
|
262
|
+
if (!hasMin && !hasMax && !hasDefault) {
|
|
263
|
+
throw new TypeError(`variationAxes.${tag} must specify at least one of min, max, or default`);
|
|
264
|
+
}
|
|
265
|
+
const min = hasMin ? finiteNumber(`variationAxes.${tag}.min`, rawValue.min) : Number.NaN;
|
|
266
|
+
const max = hasMax ? finiteNumber(`variationAxes.${tag}.max`, rawValue.max) : Number.NaN;
|
|
267
|
+
const def = hasDefault ? finiteNumber(`variationAxes.${tag}.default`, rawValue.default) : Number.NaN;
|
|
268
|
+
if (!Number.isNaN(min) && !Number.isNaN(max) && min > max) {
|
|
269
|
+
throw new RangeError(`variationAxes.${tag}.min must be <= variationAxes.${tag}.max`);
|
|
270
|
+
}
|
|
271
|
+
if (!Number.isNaN(def) && !Number.isNaN(min) && def < min) {
|
|
272
|
+
throw new RangeError(`variationAxes.${tag}.default must be >= variationAxes.${tag}.min`);
|
|
273
|
+
}
|
|
274
|
+
if (!Number.isNaN(def) && !Number.isNaN(max) && def > max) {
|
|
275
|
+
throw new RangeError(`variationAxes.${tag}.default must be <= variationAxes.${tag}.max`);
|
|
276
|
+
}
|
|
277
|
+
rangeTags.push(encoded);
|
|
278
|
+
rangeMins.push(min);
|
|
279
|
+
rangeMaxs.push(max);
|
|
280
|
+
rangeDefs.push(def);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Prepare tag arrays
|
|
284
|
+
const passthroughTags = (options.passthroughTables ?? []).map((tag, index) => encodeTag(validateTag(tag, `passthroughTables[${index}]`)));
|
|
285
|
+
const dropTags = (options.dropTables ?? []).map((tag, index) => encodeTag(validateTag(tag, `dropTables[${index}]`)));
|
|
286
|
+
// --- Allocate wasm memory ---
|
|
287
|
+
const allocations = [];
|
|
288
|
+
function walloc(size) {
|
|
289
|
+
if (size === 0)
|
|
290
|
+
return 0;
|
|
291
|
+
const ptr = m.malloc(size);
|
|
292
|
+
if (!ptr)
|
|
293
|
+
throw new Error('wasm malloc failed');
|
|
294
|
+
allocations.push(ptr);
|
|
295
|
+
return ptr;
|
|
296
|
+
}
|
|
297
|
+
function writeTags(tags, ptr) {
|
|
298
|
+
const view = new Uint8Array(buf(), ptr, tags.length * 4);
|
|
299
|
+
for (let i = 0; i < tags.length; i++) {
|
|
300
|
+
view[i * 4] = tags[i][0];
|
|
301
|
+
view[i * 4 + 1] = tags[i][1];
|
|
302
|
+
view[i * 4 + 2] = tags[i][2];
|
|
303
|
+
view[i * 4 + 3] = tags[i][3];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
// Font data
|
|
308
|
+
const fontPtr = walloc(font.length);
|
|
309
|
+
new Uint8Array(buf()).set(font, fontPtr);
|
|
310
|
+
// Unicodes array (uint32)
|
|
311
|
+
const unicodesPtr = walloc(unicodes.length * 4);
|
|
312
|
+
new Uint32Array(buf(), unicodesPtr, unicodes.length).set(unicodes);
|
|
313
|
+
// Glyph IDs array (uint32)
|
|
314
|
+
const glyphIdsPtr = walloc(glyphIds.length * 4);
|
|
315
|
+
if (glyphIds.length > 0) {
|
|
316
|
+
new Uint32Array(buf(), glyphIdsPtr, glyphIds.length).set(glyphIds);
|
|
317
|
+
}
|
|
318
|
+
// Passthrough tags (4 bytes each)
|
|
319
|
+
const passthroughPtr = walloc(passthroughTags.length * 4);
|
|
320
|
+
if (passthroughTags.length > 0)
|
|
321
|
+
writeTags(passthroughTags, passthroughPtr);
|
|
322
|
+
// Drop tags
|
|
323
|
+
const dropPtr = walloc(dropTags.length * 4);
|
|
324
|
+
if (dropTags.length > 0)
|
|
325
|
+
writeTags(dropTags, dropPtr);
|
|
326
|
+
// Pin axis tags + values
|
|
327
|
+
const pinTagsPtr = walloc(pinTags.length * 4);
|
|
328
|
+
const pinValuesPtr = walloc(pinTags.length * 4);
|
|
329
|
+
if (pinTags.length > 0) {
|
|
330
|
+
writeTags(pinTags, pinTagsPtr);
|
|
331
|
+
new Float32Array(buf(), pinValuesPtr, pinTags.length).set(pinValues);
|
|
332
|
+
}
|
|
333
|
+
// Range axis tags + min/max/def
|
|
334
|
+
const rangeTagsPtr = walloc(rangeTags.length * 4);
|
|
335
|
+
const rangeMinsPtr = walloc(rangeTags.length * 4);
|
|
336
|
+
const rangeMaxsPtr = walloc(rangeTags.length * 4);
|
|
337
|
+
const rangeDefsPtr = walloc(rangeTags.length * 4);
|
|
338
|
+
if (rangeTags.length > 0) {
|
|
339
|
+
writeTags(rangeTags, rangeTagsPtr);
|
|
340
|
+
new Float32Array(buf(), rangeMinsPtr, rangeTags.length).set(rangeMins);
|
|
341
|
+
new Float32Array(buf(), rangeMaxsPtr, rangeTags.length).set(rangeMaxs);
|
|
342
|
+
new Float32Array(buf(), rangeDefsPtr, rangeTags.length).set(rangeDefs);
|
|
343
|
+
}
|
|
344
|
+
// Output pointers (pointer to pointer + size)
|
|
345
|
+
const outDataPtrPtr = walloc(4);
|
|
346
|
+
const outSizePtr = walloc(4);
|
|
347
|
+
// Call subset
|
|
348
|
+
const result = m.hb_wrapper_subset(fontPtr, font.length, unicodesPtr, unicodes.length, glyphIdsPtr, glyphIds.length, flags, passthroughPtr, passthroughTags.length, dropPtr, dropTags.length, pinTagsPtr, pinValuesPtr, pinTags.length, rangeTagsPtr, rangeMinsPtr, rangeMaxsPtr, rangeDefsPtr, rangeTags.length, outDataPtrPtr, outSizePtr);
|
|
349
|
+
if (result !== 0) {
|
|
350
|
+
const msg = ERROR_MESSAGES[result] || `Unknown error (code ${result})`;
|
|
351
|
+
throw new Error(`Subset failed: ${msg}`);
|
|
352
|
+
}
|
|
353
|
+
// Read output — re-read buffer in case memory grew during subset
|
|
354
|
+
const outDataPtr = new Uint32Array(buf(), outDataPtrPtr, 1)[0];
|
|
355
|
+
const outSize = new Uint32Array(buf(), outSizePtr, 1)[0];
|
|
356
|
+
if (!outDataPtr || !outSize) {
|
|
357
|
+
throw new Error('Subset produced empty output');
|
|
358
|
+
}
|
|
359
|
+
// Copy result to a new Uint8Array before freeing
|
|
360
|
+
const output = new Uint8Array(outSize);
|
|
361
|
+
output.set(new Uint8Array(buf(), outDataPtr, outSize));
|
|
362
|
+
// Free the output buffer allocated by C
|
|
363
|
+
m.hb_wrapper_free(outDataPtr);
|
|
364
|
+
return output;
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
for (const ptr of allocations) {
|
|
368
|
+
m.free(ptr);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
Binary file
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { init, subset } from './api.js';
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wasm source for initialization.
|
|
3
|
+
*
|
|
4
|
+
* - `WebAssembly.Module` — pre-compiled module (Cloudflare Workers)
|
|
5
|
+
* - `ArrayBuffer | ArrayBufferView` — raw .wasm bytes (Node.js, Deno)
|
|
6
|
+
* - `Response | Promise<Response>` — fetch() response (browser)
|
|
7
|
+
*/
|
|
8
|
+
export interface ResponseLike {
|
|
9
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
10
|
+
clone?(): ResponseLike;
|
|
11
|
+
}
|
|
12
|
+
type RuntimeWebAssemblyModule = typeof globalThis extends {
|
|
13
|
+
WebAssembly: {
|
|
14
|
+
Module: infer ModuleType;
|
|
15
|
+
};
|
|
16
|
+
} ? ModuleType : never;
|
|
17
|
+
type RuntimeResponse = typeof globalThis extends {
|
|
18
|
+
Response: infer ResponseType;
|
|
19
|
+
} ? ResponseType : ResponseLike;
|
|
20
|
+
export type WasmSource = RuntimeWebAssemblyModule | ArrayBuffer | ArrayBufferView | RuntimeResponse | Promise<RuntimeResponse>;
|
|
21
|
+
/**
|
|
22
|
+
* Options for subsetting a font.
|
|
23
|
+
*
|
|
24
|
+
* At least one of `text`, `unicodes`, or `glyphIds` must be provided.
|
|
25
|
+
*/
|
|
26
|
+
export interface SubsetOptions {
|
|
27
|
+
/** Characters to retain — the simplest way to subset. */
|
|
28
|
+
text?: string;
|
|
29
|
+
/** Unicode codepoints to retain. */
|
|
30
|
+
unicodes?: number[];
|
|
31
|
+
/** Glyph IDs to retain. */
|
|
32
|
+
glyphIds?: number[];
|
|
33
|
+
/** If true, glyph IDs in the output font are preserved (not renumbered). */
|
|
34
|
+
retainGids?: boolean;
|
|
35
|
+
/** If true, hinting instructions are removed (smaller output). */
|
|
36
|
+
noHinting?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Variation axes to pin or constrain.
|
|
39
|
+
*
|
|
40
|
+
* - A number pins the axis to a fixed value (removes variability).
|
|
41
|
+
* - An object `{ min?, max?, default? }` narrows the axis range.
|
|
42
|
+
*
|
|
43
|
+
* Example:
|
|
44
|
+
* ```ts
|
|
45
|
+
* variationAxes: {
|
|
46
|
+
* wght: 400, // pin weight to 400
|
|
47
|
+
* wdth: { min: 75, max: 100 } // narrow width range
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
variationAxes?: Record<string, number | {
|
|
52
|
+
min?: number;
|
|
53
|
+
max?: number;
|
|
54
|
+
default?: number;
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* Table tags to pass through without subsetting.
|
|
58
|
+
* Example: `['GSUB', 'GPOS']`
|
|
59
|
+
*/
|
|
60
|
+
passthroughTables?: string[];
|
|
61
|
+
/**
|
|
62
|
+
* Table tags to drop entirely from the output.
|
|
63
|
+
* Example: `['DSIG']`
|
|
64
|
+
*/
|
|
65
|
+
dropTables?: string[];
|
|
66
|
+
}
|
|
67
|
+
export {};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/wasm.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-compiled WebAssembly module for hb-subset.
|
|
3
|
+
*
|
|
4
|
+
* In Cloudflare Workers, import this directly:
|
|
5
|
+
* ```ts
|
|
6
|
+
* import wasmModule from 'hb-subset-wasm/wasm';
|
|
7
|
+
* await init(wasmModule);
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
type RuntimeWasmModule =
|
|
11
|
+
typeof globalThis extends { WebAssembly: { Module: infer ModuleType } }
|
|
12
|
+
? ModuleType
|
|
13
|
+
: unknown;
|
|
14
|
+
|
|
15
|
+
declare const wasmModule: RuntimeWasmModule;
|
|
16
|
+
export default wasmModule;
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hb-subset-wasm",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "HarfBuzz font subsetting compiled to WebAssembly — optimized for Cloudflare Workers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./wasm": {
|
|
14
|
+
"types": "./dist/wasm.d.ts",
|
|
15
|
+
"default": "./dist/hb-subset.wasm"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"THIRD_PARTY_NOTICES"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build:wasm": "bash scripts/build-wasm.sh",
|
|
24
|
+
"build:ts": "tsc && cp src/wasm.d.ts dist/wasm.d.ts",
|
|
25
|
+
"build": "rm -rf dist && npm run build:wasm && npm run build:ts",
|
|
26
|
+
"test": "node --test test/*.test.js",
|
|
27
|
+
"pretest": "npm run build",
|
|
28
|
+
"prepublishOnly": "npm test",
|
|
29
|
+
"bench": "node bench/bench.js"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"harfbuzz",
|
|
33
|
+
"font",
|
|
34
|
+
"subset",
|
|
35
|
+
"wasm",
|
|
36
|
+
"webassembly",
|
|
37
|
+
"cloudflare-workers",
|
|
38
|
+
"font-subsetting",
|
|
39
|
+
"variable-fonts",
|
|
40
|
+
"opentype"
|
|
41
|
+
],
|
|
42
|
+
"author": "Kyosuke Nakamura",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/kyosuke/hb-subset-wasm.git"
|
|
47
|
+
},
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/kyosuke/hb-subset-wasm/issues"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/kyosuke/hb-subset-wasm#readme",
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"typescript": "^6.0.2"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=18"
|
|
57
|
+
}
|
|
58
|
+
}
|