@limulus/blur-hash 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eric McCarthy
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,88 @@
1
+ # @limulus/blur-hash
2
+
3
+ Web component to render [BlurHash](https://blurha.sh/) placeholders as background images of
4
+ `<img>` elements.
5
+
6
+ ![limulus.net/photos loading over a slowed connection: each image’s tile shows the blurred placeholder until the photo loads](docs/demo.gif)
7
+
8
+ ## Installation
9
+
10
+ ```sh
11
+ npm install @limulus/blur-hash
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ Import and automatically define the `<blur-hash>` custom element:
17
+
18
+ ```javascript
19
+ import '@limulus/blur-hash/define'
20
+ ```
21
+
22
+ This package makes use of a web worker for BlurHash decoding and creation of BMP blobs.
23
+ Check that your bundler has support for web workers or use a plugin that adds support.
24
+
25
+ Wrap your images in `<blur-hash>`, giving it the image’s BlurHash and the image’s full
26
+ dimensions:
27
+
28
+ ```html
29
+ <blur-hash blurhash="LFG8o]yV?tnO}n%fS0xGx]oynPWA" width="3024" height="4032">
30
+ <img src="photo.jpeg" width="3024" height="4032" alt="…" />
31
+ </blur-hash>
32
+ ```
33
+
34
+ If you are wondering why `<blur-hash>` does not simply use the dimensions from the `<img>`
35
+ element, it’s because it needs the aspect ratio of the image — not its rendered box
36
+ dimensions.
37
+
38
+ ## Rationale
39
+
40
+ The approach this package takes to make use of `background-image` for the BlurHash
41
+ placeholder may seem less than ideal. However, this approach trades the potential to animate
42
+ a transition from placeholder to image for fewer potential bugs doing element or `src`
43
+ replacement.
44
+
45
+ Using a worker for BlurHash rendering is a trade-off as well. A `<blur-hash>` will never
46
+ block the main JS thread, but it does mean that images may load without a placeholder ever
47
+ being seen by the user. This introduces inconsistency, but it also ensures that the user
48
+ never waits for an image.
49
+
50
+ ## API
51
+
52
+ ### `BlurHash.define(name = 'blur-hash')`
53
+
54
+ Defines the element under `name`, unless that name is already defined.
55
+
56
+ ### `BlurHash.workerUrl`
57
+
58
+ Property specifying the location of the decode worker, as a string or `URL`. Leave it unset
59
+ if your bundler bundles the worker. It’s read when the first placeholder is decoded, so set
60
+ it before calling `BlurHash.define()`, and import the main `@limulus/blur-hash` rather than
61
+ `@limulus/blur-hash/define`.
62
+
63
+ Browsers only start workers from the page’s origin, so if you load this package from a CDN,
64
+ start the worker from a `blob:` URL that imports it. The CDN needs to rewrite bare imports
65
+ to URLs, as `esm.sh` does, because the worker imports `fast-blurhash`:
66
+
67
+ ```javascript
68
+ import { BlurHash } from 'https://esm.sh/@limulus/blur-hash@1.0.0'
69
+
70
+ BlurHash.workerUrl = URL.createObjectURL(
71
+ new Blob(["import 'https://esm.sh/@limulus/blur-hash@1.0.0/worker'"], {
72
+ type: 'text/javascript',
73
+ })
74
+ )
75
+
76
+ BlurHash.define()
77
+ ```
78
+
79
+ If your page has a Content Security Policy, it needs to allow `blob:` in `worker-src`.
80
+
81
+ ### `BlurHashError`
82
+
83
+ Thrown if an element’s `blurhash`, `width` or `height` attribute is missing or invalid. If a
84
+ BlurHash can’t be decoded, the error logged to the console includes this error’s message.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The main entry, with `<blur-hash>` already defined. If you need to set
3
+ * `BlurHash.workerUrl`, import the main entry instead, since elements on the page start
4
+ * decoding as soon as `<blur-hash>` is defined.
5
+ */
6
+ import { BlurHash } from './index.js';
7
+ export * from './index.js';
8
+ BlurHash.define();
9
+ //# sourceMappingURL=define.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"define.js","sourceRoot":"","sources":["../../src/define.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAErC,cAAc,YAAY,CAAA;AAE1B,QAAQ,CAAC,MAAM,EAAE,CAAA"}
@@ -0,0 +1,3 @@
1
+ export { BlurHash } from './lib/blur-hash.js';
2
+ export { BlurHashError } from './lib/blur-hash-error.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Thrown when a `<blur-hash>` element's attributes are missing or invalid, and when a
3
+ * BlurHash can't be decoded.
4
+ */
5
+ export class BlurHashError extends Error {
6
+ name = 'BlurHashError';
7
+ }
8
+ //# sourceMappingURL=blur-hash-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blur-hash-error.js","sourceRoot":"","sources":["../../../src/lib/blur-hash-error.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,OAAO,aAAc,SAAQ,KAAK;IACtC,IAAI,GAAG,eAAe,CAAA;CACvB"}
@@ -0,0 +1,207 @@
1
+ import { BlurHashError } from './blur-hash-error.js';
2
+ // Placeholders are never revoked or evicted, since any later element may need the same one;
3
+ // each is 3 to 12 KB.
4
+ const cache = new Map();
5
+ // Resolves the decode with each id when the worker replies.
6
+ const pending = new Map();
7
+ // `null` if the worker couldn't be constructed, so that it isn't tried again.
8
+ let worker;
9
+ // The background-size that draws the placeholder where each object-fit draws the image.
10
+ // `none` draws the image at its natural size, which isn't known until it loads; if the
11
+ // photo is bigger than its box, cover is the closest match.
12
+ const backgroundSizes = {
13
+ fill: '100% 100%',
14
+ contain: 'contain',
15
+ 'scale-down': 'contain',
16
+ cover: 'cover',
17
+ none: 'cover',
18
+ };
19
+ /**
20
+ * Paints a BlurHash placeholder as the background image of the `<img>` it wraps, until
21
+ * that image loads:
22
+ *
23
+ * ```html
24
+ * <blur-hash blurhash="LFG8o]yV?tnO}n%fS0xGx]oynPWA" width="3024" height="4032">
25
+ * <img src="photo.jpeg" width="3024" height="4032" alt="…" />
26
+ * </blur-hash>
27
+ * ```
28
+ */
29
+ export class BlurHash extends HTMLElement {
30
+ /**
31
+ * Where to load the decode worker from. Leave unset if your bundler handles
32
+ * `new Worker(new URL('./worker.js', import.meta.url))`; otherwise bundle
33
+ * `@limulus/blur-hash/worker` and set its URL here. It's read when the first placeholder
34
+ * is decoded.
35
+ */
36
+ static workerUrl;
37
+ /** Defines the element as `name`, unless that name is already taken. */
38
+ static define(name = 'blur-hash') {
39
+ if (!customElements.get(name)) {
40
+ customElements.define(name, this);
41
+ }
42
+ }
43
+ #observer;
44
+ connectedCallback() {
45
+ if (this.#tryPlacehold()) {
46
+ return;
47
+ }
48
+ // The HTML parser connects an already-defined element as soon as it reads the start
49
+ // tag, before any of its children exist, so wait for the <img> to be parsed.
50
+ this.#observer = new MutationObserver(() => {
51
+ this.#tryPlacehold();
52
+ });
53
+ this.#observer.observe(this, { childList: true, subtree: true });
54
+ }
55
+ disconnectedCallback() {
56
+ this.#stopObserving();
57
+ }
58
+ #stopObserving() {
59
+ this.#observer?.disconnect();
60
+ this.#observer = undefined;
61
+ }
62
+ /** Placeholds the `<img>` if it has been parsed, and returns whether it had. */
63
+ #tryPlacehold() {
64
+ const imgEl = this.querySelector('img');
65
+ if (!imgEl) {
66
+ return false;
67
+ }
68
+ // Stop first, so that invalid attributes throw once, not on every later mutation.
69
+ this.#stopObserving();
70
+ this.#placehold(imgEl);
71
+ return true;
72
+ }
73
+ #placehold(imgEl) {
74
+ const blurhash = this.getAttribute('blurhash');
75
+ const width = parseInt(this.getAttribute('width') ?? '0', 10);
76
+ const height = parseInt(this.getAttribute('height') ?? '0', 10);
77
+ if (!blurhash) {
78
+ throw new BlurHashError('No blurhash attribute found');
79
+ }
80
+ // Negated so that NaN, from a missing or non-numeric attribute, fails too.
81
+ if (!(width > 0 && height > 0)) {
82
+ throw new BlurHashError('The width and height attributes must be positive numbers');
83
+ }
84
+ // Don't apply blurhash if the image is already loaded
85
+ if (isLoaded(imgEl)) {
86
+ return;
87
+ }
88
+ // Named functions, not closures: adding the same listener again is a no-op, so if the
89
+ // element is moved and connects again, its listeners don't stack up.
90
+ imgEl.addEventListener('load', clearPlaceholder);
91
+ imgEl.addEventListener('error', showAltText);
92
+ const { workerUrl } = this.constructor;
93
+ void placeholderUrl(blurhash, width, height, workerUrl).then((blobUrl) => {
94
+ // If the image loaded while the worker was decoding, the load listener above had
95
+ // nothing to clear yet, and painting now would leave the placeholder behind it.
96
+ if (blobUrl === undefined || isLoaded(imgEl)) {
97
+ return;
98
+ }
99
+ const { objectFit, objectPosition } = getComputedStyle(imgEl);
100
+ Object.assign(imgEl.style, {
101
+ backgroundImage: `url(${blobUrl})`,
102
+ backgroundSize: backgroundSizes[objectFit],
103
+ backgroundPosition: objectPosition,
104
+ backgroundRepeat: 'no-repeat',
105
+ });
106
+ // Hide alt text that Firefox displays while loading images because we can't know if
107
+ // the text will be legible atop the blurhash background. If the image has already
108
+ // failed, its error event fired before there was anything to restore.
109
+ if (!isBroken(imgEl)) {
110
+ imgEl.style.color = 'transparent';
111
+ }
112
+ });
113
+ }
114
+ }
115
+ // Safari seems to sometimes calculate object-fit images differently than the background
116
+ // image, which can lead to the background image peeking through on the edges. We don't need
117
+ // to keep these blur hash images around if they are not being displayed, so we may as well
118
+ // remove them.
119
+ function clearPlaceholder() {
120
+ // Attempt to prevent flash caused by removing the background image before the image is
121
+ // rendered
122
+ requestAnimationFrame(() => {
123
+ Object.assign(this.style, {
124
+ backgroundImage: '',
125
+ backgroundSize: '',
126
+ backgroundPosition: '',
127
+ backgroundRepeat: '',
128
+ color: '',
129
+ });
130
+ });
131
+ }
132
+ // A broken image's alt text is all that describes it, so let the browser draw it.
133
+ function showAltText() {
134
+ this.style.color = '';
135
+ }
136
+ function isLoaded(imgEl) {
137
+ return imgEl.complete && imgEl.naturalWidth > 0;
138
+ }
139
+ // An <img> without a source is also complete with no width, but it has no currentSrc.
140
+ function isBroken(imgEl) {
141
+ return imgEl.complete && imgEl.naturalWidth === 0 && imgEl.currentSrc !== '';
142
+ }
143
+ /** Resolves with a blob URL for the placeholder, or `undefined` if it couldn't be decoded. */
144
+ function placeholderUrl(blurhash, width, height, workerUrl) {
145
+ // Make the short side 32 px, unless that would make the long side more than 128 px.
146
+ // Otherwise an extreme aspect ratio would have the worker decode an enormous image.
147
+ const scale = Math.min(32 / Math.min(width, height), 128 / Math.max(width, height));
148
+ const scaledWidth = Math.max(1, Math.round(width * scale));
149
+ const scaledHeight = Math.max(1, Math.round(height * scale));
150
+ const id = `${blurhash}-${scaledWidth}-${scaledHeight}`;
151
+ let blobUrl = cache.get(id);
152
+ if (!blobUrl) {
153
+ const message = {
154
+ id,
155
+ blurhash,
156
+ width: scaledWidth,
157
+ height: scaledHeight,
158
+ };
159
+ blobUrl = new Promise((resolve) => {
160
+ if (worker === undefined)
161
+ worker = createWorker(workerUrl);
162
+ if (worker) {
163
+ pending.set(id, resolve);
164
+ worker.postMessage(message);
165
+ }
166
+ }).then((result) => {
167
+ if ('error' in result) {
168
+ console.error(`BlurHash: Could not decode "${blurhash}": ${result.error}`);
169
+ return undefined;
170
+ }
171
+ return URL.createObjectURL(result.image);
172
+ });
173
+ cache.set(id, blobUrl);
174
+ }
175
+ return blobUrl;
176
+ }
177
+ /** Starts the decode worker, or returns `null` if it can't be constructed. */
178
+ function createWorker(workerUrl) {
179
+ let decoder;
180
+ try {
181
+ // Spelled out in full so that bundlers which recognize this pattern bundle the worker.
182
+ decoder =
183
+ workerUrl === undefined
184
+ ? new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })
185
+ : new Worker(workerUrl, { type: 'module' });
186
+ }
187
+ catch (error) {
188
+ // Chromium throws here for a cross-origin worker, such as one loaded from a CDN, where
189
+ // other browsers fire the error event below instead.
190
+ reportWorkerFailure(error);
191
+ return null;
192
+ }
193
+ decoder.addEventListener('message', ({ data }) => {
194
+ pending.get(data.id)(data);
195
+ pending.delete(data.id);
196
+ });
197
+ // If the worker fails to load, nothing else reports it in the console. That's what
198
+ // happens with bundlers that leave the URL above alone, like esbuild and plain Rollup.
199
+ decoder.addEventListener('error', reportWorkerFailure);
200
+ return decoder;
201
+ }
202
+ function reportWorkerFailure(cause) {
203
+ console.error("BlurHash: The decode worker couldn't start, so no placeholders will be shown. If your " +
204
+ "bundler doesn't bundle it, or you load this package from a CDN, see " +
205
+ 'BlurHash.workerUrl in the README.', cause);
206
+ }
207
+ //# sourceMappingURL=blur-hash.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blur-hash.js","sourceRoot":"","sources":["../../../src/lib/blur-hash.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,55 @@
1
+ import { decodeBlurHash } from 'fast-blurhash';
2
+ import { BlurHashError } from './blur-hash-error.js';
3
+ const BASE83 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~';
4
+ /** Decodes `blurhash` into a `width`×`height` BMP. Throws if `blurhash` isn't valid. */
5
+ export function decodeToBmp(blurhash, width, height) {
6
+ validate(blurhash);
7
+ return new Blob([bmp(width, height, decodeBlurHash(blurhash, width, height))], {
8
+ type: 'image/bmp',
9
+ });
10
+ }
11
+ // fast-blurhash decodes anything without complaint, turning a malformed hash into a black or
12
+ // garbled image, so reject what Wolt's reference decoder would have.
13
+ function validate(blurhash) {
14
+ if (blurhash.length < 6) {
15
+ throw new BlurHashError('The blurhash string must be at least 6 characters');
16
+ }
17
+ for (const char of blurhash) {
18
+ if (!BASE83.includes(char)) {
19
+ throw new BlurHashError(`The blurhash string contains an invalid character: ${char}`);
20
+ }
21
+ }
22
+ const sizeFlag = BASE83.indexOf(blurhash[0]);
23
+ const expected = 4 + 2 * ((sizeFlag % 9) + 1) * (Math.floor(sizeFlag / 9) + 1);
24
+ if (blurhash.length !== expected) {
25
+ throw new BlurHashError(`blurhash length mismatch: length is ${blurhash.length} but it should be ${expected}`);
26
+ }
27
+ }
28
+ // An uncompressed 24-bit BMP with its rows top to bottom. BlurHash images are opaque, so the
29
+ // alpha channel is dropped. Writing this directly is several times faster than drawing to an
30
+ // OffscreenCanvas and encoding a PNG, and needs no canvas at all.
31
+ function bmp(width, height, rgba) {
32
+ const headerSize = 14 + 40; // BITMAPFILEHEADER + BITMAPINFOHEADER
33
+ const rowSize = (width * 3 + 3) & ~3; // rows are padded to 4 bytes
34
+ const out = new Uint8Array(headerSize + rowSize * height);
35
+ const view = new DataView(out.buffer);
36
+ out[0] = 0x42; // 'B'
37
+ out[1] = 0x4d; // 'M'
38
+ view.setUint32(2, out.length, true);
39
+ view.setUint32(10, headerSize, true); // where the pixels start
40
+ view.setUint32(14, 40, true); // BITMAPINFOHEADER size
41
+ view.setInt32(18, width, true);
42
+ view.setInt32(22, -height, true); // negative: rows run top to bottom
43
+ view.setUint16(26, 1, true); // color planes
44
+ view.setUint16(28, 24, true); // bits per pixel; compression and the rest stay 0
45
+ for (let y = 0; y < height; y++) {
46
+ let o = headerSize + y * rowSize;
47
+ for (let i = y * width * 4, end = i + width * 4; i < end; i += 4) {
48
+ out[o++] = rgba[i + 2];
49
+ out[o++] = rgba[i + 1];
50
+ out[o++] = rgba[i];
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ //# sourceMappingURL=decode.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decode.js","sourceRoot":"","sources":["../../../src/lib/decode.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ import { decodeToBmp } from './decode.js';
2
+ self.onmessage = ({ data }) => {
3
+ let response;
4
+ try {
5
+ const image = decodeToBmp(data.blurhash, data.width, data.height);
6
+ response = { id: data.id, image };
7
+ }
8
+ catch (error) {
9
+ response = { id: data.id, error: String(error) };
10
+ }
11
+ self.postMessage(response);
12
+ };
13
+ //# sourceMappingURL=worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.js","sourceRoot":"","sources":["../../../src/lib/worker.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEzC,IAAI,CAAC,SAAS,GAAG,CAAC,EAAE,IAAI,EAAuC,EAAE,EAAE;IACjE,IAAI,QAAqC,CAAA;IACzC,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,WAAW,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAA;QACjE,QAAQ,GAAG,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,CAAA;IACnC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,QAAQ,GAAG,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAA;IAClD,CAAC;IACD,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAA;AAC5B,CAAC,CAAA"}
@@ -0,0 +1,2 @@
1
+ export * from './index.ts';
2
+ //# sourceMappingURL=define.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"define.d.ts","sourceRoot":"","sources":["../../src/define.ts"],"names":[],"mappings":"AAOA,cAAc,YAAY,CAAA"}
@@ -0,0 +1,3 @@
1
+ export { BlurHash } from './lib/blur-hash.ts';
2
+ export { BlurHashError } from './lib/blur-hash-error.ts';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Thrown when a `<blur-hash>` element's attributes are missing or invalid, and when a
3
+ * BlurHash can't be decoded.
4
+ */
5
+ export declare class BlurHashError extends Error {
6
+ name: string;
7
+ }
8
+ //# sourceMappingURL=blur-hash-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blur-hash-error.d.ts","sourceRoot":"","sources":["../../../src/lib/blur-hash-error.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACtC,IAAI,SAAkB;CACvB"}
@@ -0,0 +1,39 @@
1
+ export interface BlurHashDecodeMessage {
2
+ id: string;
3
+ blurhash: string;
4
+ width: number;
5
+ height: number;
6
+ }
7
+ /** The decoded image, or why decoding failed. */
8
+ export type BlurHashDecodeResultMessage = {
9
+ id: string;
10
+ } & ({
11
+ image: Blob;
12
+ } | {
13
+ error: string;
14
+ });
15
+ /**
16
+ * Paints a BlurHash placeholder as the background image of the `<img>` it wraps, until
17
+ * that image loads:
18
+ *
19
+ * ```html
20
+ * <blur-hash blurhash="LFG8o]yV?tnO}n%fS0xGx]oynPWA" width="3024" height="4032">
21
+ * <img src="photo.jpeg" width="3024" height="4032" alt="…" />
22
+ * </blur-hash>
23
+ * ```
24
+ */
25
+ export declare class BlurHash extends HTMLElement {
26
+ #private;
27
+ /**
28
+ * Where to load the decode worker from. Leave unset if your bundler handles
29
+ * `new Worker(new URL('./worker.js', import.meta.url))`; otherwise bundle
30
+ * `@limulus/blur-hash/worker` and set its URL here. It's read when the first placeholder
31
+ * is decoded.
32
+ */
33
+ static workerUrl: string | URL | undefined;
34
+ /** Defines the element as `name`, unless that name is already taken. */
35
+ static define(name?: string): void;
36
+ connectedCallback(): void;
37
+ disconnectedCallback(): void;
38
+ }
39
+ //# sourceMappingURL=blur-hash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blur-hash.d.ts","sourceRoot":"","sources":["../../../src/lib/blur-hash.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,MAAM,CAAA;IACV,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iDAAiD;AACjD,MAAM,MAAM,2BAA2B,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,CACzD;IAAE,KAAK,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CACpC,CAAA;AAqBD;;;;;;;;;GASG;AACH,qBAAa,QAAS,SAAQ,WAAW;;IACvC;;;;;OAKG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,GAAG,GAAG,SAAS,CAAA;IAE1C,wEAAwE;IACxE,MAAM,CAAC,MAAM,CAAC,IAAI,SAAc,GAAG,IAAI,CAItC;IAID,iBAAiB,IAAI,IAAI,CAWxB;IAED,oBAAoB,IAAI,IAAI,CAE3B;CAoEF"}
@@ -0,0 +1,3 @@
1
+ /** Decodes `blurhash` into a `width`×`height` BMP. Throws if `blurhash` isn't valid. */
2
+ export declare function decodeToBmp(blurhash: string, width: number, height: number): Blob;
3
+ //# sourceMappingURL=decode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decode.d.ts","sourceRoot":"","sources":["../../../src/lib/decode.ts"],"names":[],"mappings":"AAOA,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAKjF"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../../src/lib/worker.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "@limulus/blur-hash",
3
+ "version": "1.0.0",
4
+ "description": "Custom element that paints a BlurHash placeholder behind an image until it loads",
5
+ "keywords": [
6
+ "blurhash",
7
+ "custom-element",
8
+ "web-component",
9
+ "placeholder",
10
+ "lazy-loading"
11
+ ],
12
+ "module": "dist/esm/index.js",
13
+ "types": "dist/types/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/types/index.d.ts",
17
+ "import": "./dist/esm/index.js"
18
+ },
19
+ "./define": {
20
+ "types": "./dist/types/define.d.ts",
21
+ "import": "./dist/esm/define.js"
22
+ },
23
+ "./worker": {
24
+ "types": "./dist/types/lib/worker.d.ts",
25
+ "import": "./dist/esm/lib/worker.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "!src/**/*.spec.ts",
32
+ "!src/mocks"
33
+ ],
34
+ "type": "module",
35
+ "devEngines": {
36
+ "runtime": {
37
+ "name": "node",
38
+ "version": ">=24"
39
+ }
40
+ },
41
+ "scripts": {
42
+ "prebuild": "npm run clean",
43
+ "build": "node node_modules/typescript-7/bin/tsc --build tsconfig.build.json",
44
+ "clean": "del-cli *.tsbuildinfo coverage dist",
45
+ "lint": "eslint .",
46
+ "lint:fix": "npm run lint -- --fix",
47
+ "prepack": "npm run build",
48
+ "prepare": "is-ci || husky",
49
+ "test": "vitest run --coverage --project chromium && vitest run --project webkit --project firefox",
50
+ "tscc": "node node_modules/typescript-7/bin/tsc --build",
51
+ "verify": "concurrently -c cyan,magenta,blue npm:lint npm:test npm:tscc"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/limulus/blur-hash.git"
56
+ },
57
+ "author": {
58
+ "name": "Eric McCarthy",
59
+ "email": "eric@limulus.net",
60
+ "url": "https://limulus.net/"
61
+ },
62
+ "license": "MIT",
63
+ "bugs": {
64
+ "url": "https://github.com/limulus/blur-hash/issues"
65
+ },
66
+ "homepage": "https://github.com/limulus/blur-hash#readme",
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
70
+ "devDependencies": {
71
+ "@commitlint/cli": "^21.2.2",
72
+ "@commitlint/config-conventional": "^21.2.2",
73
+ "@limulus/eslint-config": "^6.2.0",
74
+ "@types/node": "^24.12.0",
75
+ "@vitest/browser-playwright": "^4.1.12",
76
+ "@vitest/coverage-v8": "^4.1.10",
77
+ "concurrently": "^10.0.5",
78
+ "del-cli": "^7.0.0",
79
+ "eslint": "^10.8.1",
80
+ "husky": "^9.1.7",
81
+ "is-ci": "^4.1.0",
82
+ "playwright": "^1.64.1",
83
+ "typescript": "^6.0.3",
84
+ "typescript-7": "npm:typescript@7.0.2",
85
+ "vitest": "^4.1.10"
86
+ },
87
+ "dependencies": {
88
+ "fast-blurhash": "^1.2.0"
89
+ }
90
+ }
package/src/define.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The main entry, with `<blur-hash>` already defined. If you need to set
3
+ * `BlurHash.workerUrl`, import the main entry instead, since elements on the page start
4
+ * decoding as soon as `<blur-hash>` is defined.
5
+ */
6
+ import { BlurHash } from './index.ts'
7
+
8
+ export * from './index.ts'
9
+
10
+ BlurHash.define()
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { BlurHash } from './lib/blur-hash.ts'
2
+ export { BlurHashError } from './lib/blur-hash-error.ts'
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Thrown when a `<blur-hash>` element's attributes are missing or invalid, and when a
3
+ * BlurHash can't be decoded.
4
+ */
5
+ export class BlurHashError extends Error {
6
+ name = 'BlurHashError'
7
+ }
@@ -0,0 +1,259 @@
1
+ import { BlurHashError } from './blur-hash-error.ts'
2
+
3
+ export interface BlurHashDecodeMessage {
4
+ id: string
5
+ blurhash: string
6
+ width: number
7
+ height: number
8
+ }
9
+
10
+ /** The decoded image, or why decoding failed. */
11
+ export type BlurHashDecodeResultMessage = { id: string } & (
12
+ { image: Blob } | { error: string }
13
+ )
14
+
15
+ // Placeholders are never revoked or evicted, since any later element may need the same one;
16
+ // each is 3 to 12 KB.
17
+ const cache = new Map<string, Promise<string | undefined>>()
18
+ // Resolves the decode with each id when the worker replies.
19
+ const pending = new Map<string, (result: BlurHashDecodeResultMessage) => void>()
20
+ // `null` if the worker couldn't be constructed, so that it isn't tried again.
21
+ let worker: Worker | null | undefined
22
+
23
+ // The background-size that draws the placeholder where each object-fit draws the image.
24
+ // `none` draws the image at its natural size, which isn't known until it loads; if the
25
+ // photo is bigger than its box, cover is the closest match.
26
+ const backgroundSizes: Record<string, string> = {
27
+ fill: '100% 100%',
28
+ contain: 'contain',
29
+ 'scale-down': 'contain',
30
+ cover: 'cover',
31
+ none: 'cover',
32
+ }
33
+
34
+ /**
35
+ * Paints a BlurHash placeholder as the background image of the `<img>` it wraps, until
36
+ * that image loads:
37
+ *
38
+ * ```html
39
+ * <blur-hash blurhash="LFG8o]yV?tnO}n%fS0xGx]oynPWA" width="3024" height="4032">
40
+ * <img src="photo.jpeg" width="3024" height="4032" alt="…" />
41
+ * </blur-hash>
42
+ * ```
43
+ */
44
+ export class BlurHash extends HTMLElement {
45
+ /**
46
+ * Where to load the decode worker from. Leave unset if your bundler handles
47
+ * `new Worker(new URL('./worker.js', import.meta.url))`; otherwise bundle
48
+ * `@limulus/blur-hash/worker` and set its URL here. It's read when the first placeholder
49
+ * is decoded.
50
+ */
51
+ static workerUrl: string | URL | undefined
52
+
53
+ /** Defines the element as `name`, unless that name is already taken. */
54
+ static define(name = 'blur-hash'): void {
55
+ if (!customElements.get(name)) {
56
+ customElements.define(name, this)
57
+ }
58
+ }
59
+
60
+ #observer: MutationObserver | undefined
61
+
62
+ connectedCallback(): void {
63
+ if (this.#tryPlacehold()) {
64
+ return
65
+ }
66
+
67
+ // The HTML parser connects an already-defined element as soon as it reads the start
68
+ // tag, before any of its children exist, so wait for the <img> to be parsed.
69
+ this.#observer = new MutationObserver(() => {
70
+ this.#tryPlacehold()
71
+ })
72
+ this.#observer.observe(this, { childList: true, subtree: true })
73
+ }
74
+
75
+ disconnectedCallback(): void {
76
+ this.#stopObserving()
77
+ }
78
+
79
+ #stopObserving(): void {
80
+ this.#observer?.disconnect()
81
+ this.#observer = undefined
82
+ }
83
+
84
+ /** Placeholds the `<img>` if it has been parsed, and returns whether it had. */
85
+ #tryPlacehold(): boolean {
86
+ const imgEl = this.querySelector('img')
87
+ if (!imgEl) {
88
+ return false
89
+ }
90
+
91
+ // Stop first, so that invalid attributes throw once, not on every later mutation.
92
+ this.#stopObserving()
93
+ this.#placehold(imgEl)
94
+ return true
95
+ }
96
+
97
+ #placehold(imgEl: HTMLImageElement): void {
98
+ const blurhash = this.getAttribute('blurhash')
99
+ const width = parseInt(this.getAttribute('width') ?? '0', 10)
100
+ const height = parseInt(this.getAttribute('height') ?? '0', 10)
101
+
102
+ if (!blurhash) {
103
+ throw new BlurHashError('No blurhash attribute found')
104
+ }
105
+
106
+ // Negated so that NaN, from a missing or non-numeric attribute, fails too.
107
+ if (!(width > 0 && height > 0)) {
108
+ throw new BlurHashError('The width and height attributes must be positive numbers')
109
+ }
110
+
111
+ // Don't apply blurhash if the image is already loaded
112
+ if (isLoaded(imgEl)) {
113
+ return
114
+ }
115
+
116
+ // Named functions, not closures: adding the same listener again is a no-op, so if the
117
+ // element is moved and connects again, its listeners don't stack up.
118
+ imgEl.addEventListener('load', clearPlaceholder)
119
+ imgEl.addEventListener('error', showAltText)
120
+
121
+ const { workerUrl } = this.constructor as typeof BlurHash
122
+ void placeholderUrl(blurhash, width, height, workerUrl).then((blobUrl) => {
123
+ // If the image loaded while the worker was decoding, the load listener above had
124
+ // nothing to clear yet, and painting now would leave the placeholder behind it.
125
+ if (blobUrl === undefined || isLoaded(imgEl)) {
126
+ return
127
+ }
128
+
129
+ const { objectFit, objectPosition } = getComputedStyle(imgEl)
130
+ Object.assign(imgEl.style, {
131
+ backgroundImage: `url(${blobUrl})`,
132
+ backgroundSize: backgroundSizes[objectFit],
133
+ backgroundPosition: objectPosition,
134
+ backgroundRepeat: 'no-repeat',
135
+ })
136
+
137
+ // Hide alt text that Firefox displays while loading images because we can't know if
138
+ // the text will be legible atop the blurhash background. If the image has already
139
+ // failed, its error event fired before there was anything to restore.
140
+ if (!isBroken(imgEl)) {
141
+ imgEl.style.color = 'transparent'
142
+ }
143
+ })
144
+ }
145
+ }
146
+
147
+ // Safari seems to sometimes calculate object-fit images differently than the background
148
+ // image, which can lead to the background image peeking through on the edges. We don't need
149
+ // to keep these blur hash images around if they are not being displayed, so we may as well
150
+ // remove them.
151
+ function clearPlaceholder(this: HTMLImageElement): void {
152
+ // Attempt to prevent flash caused by removing the background image before the image is
153
+ // rendered
154
+ requestAnimationFrame(() => {
155
+ Object.assign(this.style, {
156
+ backgroundImage: '',
157
+ backgroundSize: '',
158
+ backgroundPosition: '',
159
+ backgroundRepeat: '',
160
+ color: '',
161
+ })
162
+ })
163
+ }
164
+
165
+ // A broken image's alt text is all that describes it, so let the browser draw it.
166
+ function showAltText(this: HTMLImageElement): void {
167
+ this.style.color = ''
168
+ }
169
+
170
+ function isLoaded(imgEl: HTMLImageElement): boolean {
171
+ return imgEl.complete && imgEl.naturalWidth > 0
172
+ }
173
+
174
+ // An <img> without a source is also complete with no width, but it has no currentSrc.
175
+ function isBroken(imgEl: HTMLImageElement): boolean {
176
+ return imgEl.complete && imgEl.naturalWidth === 0 && imgEl.currentSrc !== ''
177
+ }
178
+
179
+ /** Resolves with a blob URL for the placeholder, or `undefined` if it couldn't be decoded. */
180
+ function placeholderUrl(
181
+ blurhash: string,
182
+ width: number,
183
+ height: number,
184
+ workerUrl: string | URL | undefined
185
+ ): Promise<string | undefined> {
186
+ // Make the short side 32 px, unless that would make the long side more than 128 px.
187
+ // Otherwise an extreme aspect ratio would have the worker decode an enormous image.
188
+ const scale = Math.min(32 / Math.min(width, height), 128 / Math.max(width, height))
189
+ const scaledWidth = Math.max(1, Math.round(width * scale))
190
+ const scaledHeight = Math.max(1, Math.round(height * scale))
191
+
192
+ const id = `${blurhash}-${scaledWidth}-${scaledHeight}`
193
+ let blobUrl = cache.get(id)
194
+
195
+ if (!blobUrl) {
196
+ const message: BlurHashDecodeMessage = {
197
+ id,
198
+ blurhash,
199
+ width: scaledWidth,
200
+ height: scaledHeight,
201
+ }
202
+ blobUrl = new Promise<BlurHashDecodeResultMessage>((resolve) => {
203
+ if (worker === undefined) worker = createWorker(workerUrl)
204
+ if (worker) {
205
+ pending.set(id, resolve)
206
+ worker.postMessage(message)
207
+ }
208
+ }).then((result) => {
209
+ if ('error' in result) {
210
+ console.error(`BlurHash: Could not decode "${blurhash}": ${result.error}`)
211
+ return undefined
212
+ }
213
+ return URL.createObjectURL(result.image)
214
+ })
215
+ cache.set(id, blobUrl)
216
+ }
217
+
218
+ return blobUrl
219
+ }
220
+
221
+ /** Starts the decode worker, or returns `null` if it can't be constructed. */
222
+ function createWorker(workerUrl: string | URL | undefined): Worker | null {
223
+ let decoder: Worker
224
+ try {
225
+ // Spelled out in full so that bundlers which recognize this pattern bundle the worker.
226
+ decoder =
227
+ workerUrl === undefined
228
+ ? new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })
229
+ : new Worker(workerUrl, { type: 'module' })
230
+ } catch (error) {
231
+ // Chromium throws here for a cross-origin worker, such as one loaded from a CDN, where
232
+ // other browsers fire the error event below instead.
233
+ reportWorkerFailure(error)
234
+ return null
235
+ }
236
+
237
+ decoder.addEventListener(
238
+ 'message',
239
+ ({ data }: MessageEvent<BlurHashDecodeResultMessage>) => {
240
+ pending.get(data.id)!(data)
241
+ pending.delete(data.id)
242
+ }
243
+ )
244
+
245
+ // If the worker fails to load, nothing else reports it in the console. That's what
246
+ // happens with bundlers that leave the URL above alone, like esbuild and plain Rollup.
247
+ decoder.addEventListener('error', reportWorkerFailure)
248
+
249
+ return decoder
250
+ }
251
+
252
+ function reportWorkerFailure(cause: unknown): void {
253
+ console.error(
254
+ "BlurHash: The decode worker couldn't start, so no placeholders will be shown. If your " +
255
+ "bundler doesn't bundle it, or you load this package from a CDN, see " +
256
+ 'BlurHash.workerUrl in the README.',
257
+ cause
258
+ )
259
+ }
@@ -0,0 +1,71 @@
1
+ import { decodeBlurHash } from 'fast-blurhash'
2
+
3
+ import { BlurHashError } from './blur-hash-error.ts'
4
+
5
+ const BASE83 =
6
+ '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'
7
+
8
+ /** Decodes `blurhash` into a `width`×`height` BMP. Throws if `blurhash` isn't valid. */
9
+ export function decodeToBmp(blurhash: string, width: number, height: number): Blob {
10
+ validate(blurhash)
11
+ return new Blob([bmp(width, height, decodeBlurHash(blurhash, width, height))], {
12
+ type: 'image/bmp',
13
+ })
14
+ }
15
+
16
+ // fast-blurhash decodes anything without complaint, turning a malformed hash into a black or
17
+ // garbled image, so reject what Wolt's reference decoder would have.
18
+ function validate(blurhash: string): void {
19
+ if (blurhash.length < 6) {
20
+ throw new BlurHashError('The blurhash string must be at least 6 characters')
21
+ }
22
+
23
+ for (const char of blurhash) {
24
+ if (!BASE83.includes(char)) {
25
+ throw new BlurHashError(`The blurhash string contains an invalid character: ${char}`)
26
+ }
27
+ }
28
+
29
+ const sizeFlag = BASE83.indexOf(blurhash[0])
30
+ const expected = 4 + 2 * ((sizeFlag % 9) + 1) * (Math.floor(sizeFlag / 9) + 1)
31
+ if (blurhash.length !== expected) {
32
+ throw new BlurHashError(
33
+ `blurhash length mismatch: length is ${blurhash.length} but it should be ${expected}`
34
+ )
35
+ }
36
+ }
37
+
38
+ // An uncompressed 24-bit BMP with its rows top to bottom. BlurHash images are opaque, so the
39
+ // alpha channel is dropped. Writing this directly is several times faster than drawing to an
40
+ // OffscreenCanvas and encoding a PNG, and needs no canvas at all.
41
+ function bmp(
42
+ width: number,
43
+ height: number,
44
+ rgba: Uint8ClampedArray
45
+ ): Uint8Array<ArrayBuffer> {
46
+ const headerSize = 14 + 40 // BITMAPFILEHEADER + BITMAPINFOHEADER
47
+ const rowSize = (width * 3 + 3) & ~3 // rows are padded to 4 bytes
48
+ const out = new Uint8Array(headerSize + rowSize * height)
49
+ const view = new DataView(out.buffer)
50
+
51
+ out[0] = 0x42 // 'B'
52
+ out[1] = 0x4d // 'M'
53
+ view.setUint32(2, out.length, true)
54
+ view.setUint32(10, headerSize, true) // where the pixels start
55
+ view.setUint32(14, 40, true) // BITMAPINFOHEADER size
56
+ view.setInt32(18, width, true)
57
+ view.setInt32(22, -height, true) // negative: rows run top to bottom
58
+ view.setUint16(26, 1, true) // color planes
59
+ view.setUint16(28, 24, true) // bits per pixel; compression and the rest stay 0
60
+
61
+ for (let y = 0; y < height; y++) {
62
+ let o = headerSize + y * rowSize
63
+ for (let i = y * width * 4, end = i + width * 4; i < end; i += 4) {
64
+ out[o++] = rgba[i + 2]
65
+ out[o++] = rgba[i + 1]
66
+ out[o++] = rgba[i]
67
+ }
68
+ }
69
+
70
+ return out
71
+ }
@@ -0,0 +1,15 @@
1
+ // Types only: importing a value from the element would load it here, where there is no
2
+ // HTMLElement to extend.
3
+ import type { BlurHashDecodeMessage, BlurHashDecodeResultMessage } from './blur-hash.ts'
4
+ import { decodeToBmp } from './decode.ts'
5
+
6
+ self.onmessage = ({ data }: MessageEvent<BlurHashDecodeMessage>) => {
7
+ let response: BlurHashDecodeResultMessage
8
+ try {
9
+ const image = decodeToBmp(data.blurhash, data.width, data.height)
10
+ response = { id: data.id, image }
11
+ } catch (error) {
12
+ response = { id: data.id, error: String(error) }
13
+ }
14
+ self.postMessage(response)
15
+ }