@swiftbrowser/web 0.1.0 → 0.3.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/README.md +141 -63
- package/app/index.html +21 -10
- package/node/dist/node/apps.d.ts +16 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +9 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +14 -1
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +20 -3
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/examples.d.ts +2 -1
- package/node/dist/node/examples.d.ts.map +1 -1
- package/node/dist/node/examples.js +12 -2
- package/node/dist/node/examples.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +4 -1
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +3 -2
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/asset-catalogs.d.ts +15 -3
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
- package/node/dist/plugins/asset-catalogs.js +111 -13
- package/node/dist/plugins/asset-catalogs.js.map +1 -1
- package/node/dist/plugins/included-sites.d.ts +26 -0
- package/node/dist/plugins/included-sites.d.ts.map +1 -0
- package/node/dist/plugins/included-sites.js +70 -0
- package/node/dist/plugins/included-sites.js.map +1 -0
- package/node/dist/plugins/pdfImages.d.ts +27 -0
- package/node/dist/plugins/pdfImages.d.ts.map +1 -0
- package/node/dist/plugins/pdfImages.js +145 -0
- package/node/dist/plugins/pdfImages.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +19 -1
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
- package/node/dist/plugins/swift-wasm.js +45 -7
- package/node/dist/plugins/swift-wasm.js.map +1 -1
- package/node/dist/plugins/symbolTemplates.d.ts +22 -0
- package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
- package/node/dist/plugins/symbolTemplates.js +83 -0
- package/node/dist/plugins/symbolTemplates.js.map +1 -0
- package/package.json +4 -2
- package/plugins/app-manifests.ts +3 -2
- package/plugins/asset-catalogs.ts +119 -14
- package/plugins/included-sites.ts +74 -0
- package/plugins/pdfImages.ts +163 -0
- package/plugins/swift-wasm.ts +52 -6
- package/plugins/symbolTemplates.ts +88 -0
- package/public/_headers +7 -0
- package/public/app/oauth-callback.html +62 -0
- package/src/animation.ts +235 -8
- package/src/appFiles.ts +201 -0
- package/src/base64.ts +43 -0
- package/src/bitmapPlan.ts +143 -0
- package/src/bitmapStore.ts +206 -0
- package/src/bitmapWorker.ts +41 -0
- package/src/bitmapWorkerProtocol.ts +15 -0
- package/src/bitmaps.ts +283 -0
- package/src/blockedHosts.ts +29 -0
- package/src/canvasDrawing.ts +361 -0
- package/src/chartDom.ts +2 -2
- package/src/chromeColor.ts +179 -0
- package/src/clipboard.ts +78 -0
- package/src/colorMultiply.ts +93 -0
- package/src/colorPicker.ts +44 -0
- package/src/corsProxy.ts +45 -0
- package/src/gaugeRing.ts +74 -0
- package/src/gif.ts +297 -0
- package/src/hostRequests.ts +426 -0
- package/src/imageOps.ts +592 -0
- package/src/interaction.ts +68 -3
- package/src/landing.ts +2 -2
- package/src/largeTitle.ts +79 -0
- package/src/layout/chart.ts +9 -3
- package/src/layout/engine.ts +681 -174
- package/src/layout/index.ts +3 -1
- package/src/layout/text.ts +217 -1
- package/src/layout/types.ts +31 -0
- package/src/layout/typography.ts +24 -7
- package/src/layout/video.ts +24 -0
- package/src/layoutDom.ts +66 -6
- package/src/main.ts +127 -4
- package/src/media.ts +132 -0
- package/src/mock.ts +4 -3
- package/src/notifications.ts +109 -0
- package/src/pathShape.ts +156 -0
- package/src/protocol.ts +782 -13
- package/src/renderer.ts +603 -44
- package/src/richText.ts +65 -0
- package/src/scanner.ts +234 -0
- package/src/scrollTo.ts +123 -0
- package/src/styles.css +521 -100
- package/src/styles.ts +61 -0
- package/src/swiftData.ts +128 -0
- package/src/symbols.ts +658 -28
- package/src/translation.ts +126 -0
- package/src/video.ts +299 -0
- package/src/vite-env.d.ts +6 -0
- package/src/wasm.ts +227 -32
- package/src/webAuth.ts +267 -0
- package/src/webSocket.ts +148 -0
- package/src/zoneinfo.ts +269 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.colorMultiply(_:)`: CSS has no filter that multiplies by a color, so the
|
|
3
|
+
* page keeps one SVG filter per color in a hidden `<svg>` and the view's box
|
|
4
|
+
* names it in its `filter` (`url(#sb-multiply-…)`). The filter is an
|
|
5
|
+
* `feColorMatrix` that scales red, green, blue and alpha by the color's, in
|
|
6
|
+
* sRGB as SwiftUI multiplies. A semantic or catalog color is resolved for the
|
|
7
|
+
* scheme the page shows when the style is applied.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Color, RGBAColor } from './protocol';
|
|
11
|
+
import { colorToCSS } from './styles';
|
|
12
|
+
|
|
13
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
14
|
+
const DEFS_ID = 'sb-filter-defs';
|
|
15
|
+
|
|
16
|
+
function clamp01(v: number): number {
|
|
17
|
+
return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isRGBA(color: Color): color is RGBAColor {
|
|
21
|
+
return typeof (color as RGBAColor).r === 'number';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The filter's id for a color: its components to three places. */
|
|
25
|
+
export function multiplyFilterId(color: RGBAColor): string {
|
|
26
|
+
const part = (v: number) => Math.round(clamp01(v) * 1000).toString(36);
|
|
27
|
+
return `sb-multiply-${part(color.r)}-${part(color.g)}-${part(color.b)}-${part(color.a ?? 1)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The `feColorMatrix` values that multiply by `color`. */
|
|
31
|
+
export function multiplyMatrix(color: RGBAColor): string {
|
|
32
|
+
const v = (n: number) => String(Math.round(clamp01(n) * 1000) / 1000);
|
|
33
|
+
return [
|
|
34
|
+
`${v(color.r)} 0 0 0 0`,
|
|
35
|
+
`0 ${v(color.g)} 0 0 0`,
|
|
36
|
+
`0 0 ${v(color.b)} 0 0`,
|
|
37
|
+
`0 0 0 ${v(color.a ?? 1)} 0`,
|
|
38
|
+
].join(' ');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Parses a computed `rgb()` / `rgba()` / `color(srgb …)` value. */
|
|
42
|
+
export function parseComputedColor(value: string): RGBAColor | null {
|
|
43
|
+
const rgb = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/.exec(value.trim());
|
|
44
|
+
if (rgb) {
|
|
45
|
+
return { r: Number(rgb[1]) / 255, g: Number(rgb[2]) / 255, b: Number(rgb[3]) / 255, a: rgb[4] != null ? Number(rgb[4]) : 1 };
|
|
46
|
+
}
|
|
47
|
+
const srgb = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/.exec(value.trim());
|
|
48
|
+
if (srgb) return { r: Number(srgb[1]), g: Number(srgb[2]), b: Number(srgb[3]), a: srgb[4] != null ? Number(srgb[4]) : 1 };
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The color's components as `el` would draw it (a semantic or catalog color through its CSS). */
|
|
53
|
+
function resolve(color: Color, el: HTMLElement): RGBAColor {
|
|
54
|
+
if (isRGBA(color)) return color;
|
|
55
|
+
const probe = el.ownerDocument.createElement('span');
|
|
56
|
+
probe.style.display = 'none';
|
|
57
|
+
probe.style.color = colorToCSS(color);
|
|
58
|
+
el.appendChild(probe);
|
|
59
|
+
const computed = getComputedStyle(probe).color;
|
|
60
|
+
probe.remove();
|
|
61
|
+
return parseComputedColor(computed) ?? { r: 1, g: 1, b: 1, a: 1 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The `filter` function that multiplies `el`'s box by `color`, adding the filter to the document once. */
|
|
65
|
+
export function colorMultiplyFilter(color: Color, el: HTMLElement): string {
|
|
66
|
+
const rgba = resolve(color, el);
|
|
67
|
+
const id = multiplyFilterId(rgba);
|
|
68
|
+
const doc = el.ownerDocument;
|
|
69
|
+
if (!doc.getElementById(id)) {
|
|
70
|
+
let defs: Element | null = doc.getElementById(DEFS_ID);
|
|
71
|
+
if (!defs) {
|
|
72
|
+
const svg = doc.createElementNS(SVG_NS, 'svg');
|
|
73
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
74
|
+
svg.setAttribute('width', '0');
|
|
75
|
+
svg.setAttribute('height', '0');
|
|
76
|
+
svg.style.position = 'absolute';
|
|
77
|
+
const created = doc.createElementNS(SVG_NS, 'defs');
|
|
78
|
+
created.id = DEFS_ID;
|
|
79
|
+
svg.appendChild(created);
|
|
80
|
+
defs = created;
|
|
81
|
+
doc.body.appendChild(svg);
|
|
82
|
+
}
|
|
83
|
+
const filter = doc.createElementNS(SVG_NS, 'filter');
|
|
84
|
+
filter.id = id;
|
|
85
|
+
filter.setAttribute('color-interpolation-filters', 'sRGB');
|
|
86
|
+
const matrix = doc.createElementNS(SVG_NS, 'feColorMatrix');
|
|
87
|
+
matrix.setAttribute('type', 'matrix');
|
|
88
|
+
matrix.setAttribute('values', multiplyMatrix(rgba));
|
|
89
|
+
filter.appendChild(matrix);
|
|
90
|
+
defs.appendChild(filter);
|
|
91
|
+
}
|
|
92
|
+
return `url(#${id})`;
|
|
93
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SwiftUI's `ColorPicker`, page side: a `pickColor` host request opens the
|
|
3
|
+
* browser's color chooser (`<input type="color">`, clicked inside the tap
|
|
4
|
+
* that sent the request) at the current color and answers
|
|
5
|
+
* `{ color: "#rrggbb" }` when the viewer picks one, or `{}` when they close
|
|
6
|
+
* it (where the browser reports that with a `cancel` event; otherwise a
|
|
7
|
+
* dismissal is never answered and the app's color stays as it was).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PickColorRequestParams } from './protocol';
|
|
11
|
+
|
|
12
|
+
export type PickColorResult = {
|
|
13
|
+
color?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Opens a color chooser at `color` (`#rrggbb`); resolves with the chosen color, or null. */
|
|
17
|
+
export type ColorChooser = (color: string) => Promise<string | null>;
|
|
18
|
+
|
|
19
|
+
export async function performPickColor(params: PickColorRequestParams, choose: ColorChooser | undefined): Promise<PickColorResult> {
|
|
20
|
+
if (!choose) throw new Error('unsupported: this page cannot open a color chooser');
|
|
21
|
+
const current = /^#[0-9a-f]{6}$/i.test(params.color ?? '') ? params.color!.toLowerCase() : '#000000';
|
|
22
|
+
const chosen = await choose(current);
|
|
23
|
+
return chosen ? { color: chosen.toLowerCase() } : {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The browser's color chooser: a hidden `<input type="color">` clicked at once. */
|
|
27
|
+
export function chooseColor(color: string): Promise<string | null> {
|
|
28
|
+
const input = document.createElement('input');
|
|
29
|
+
input.type = 'color';
|
|
30
|
+
input.value = color;
|
|
31
|
+
input.style.cssText = 'position:fixed;left:-1000px;top:0;width:1px;height:1px;opacity:0';
|
|
32
|
+
input.dataset['sbPicker'] = '';
|
|
33
|
+
document.body.append(input);
|
|
34
|
+
const result = new Promise<string | null>((resolve) => {
|
|
35
|
+
const finish = (value: string | null): void => {
|
|
36
|
+
input.remove();
|
|
37
|
+
resolve(value);
|
|
38
|
+
};
|
|
39
|
+
input.addEventListener('change', () => finish(input.value), { once: true });
|
|
40
|
+
input.addEventListener('cancel', () => finish(null), { once: true });
|
|
41
|
+
});
|
|
42
|
+
input.click();
|
|
43
|
+
return result;
|
|
44
|
+
}
|
package/src/corsProxy.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The CORS proxy an app may be built with (`swiftbrowser build --cors-proxy
|
|
2
|
+
// <url>`, packages/cors-proxy). A page reads a cross-origin response only
|
|
3
|
+
// when the server allows the page's origin; many servers don't, for images
|
|
4
|
+
// especially, which the app reads with `fetch` (natively they load). With a
|
|
5
|
+
// proxy, a cross-origin GET or HEAD that fails is tried once more through
|
|
6
|
+
// `<proxy>?url=<target>`. Requests go direct first, so a server that allows
|
|
7
|
+
// the page (an API, with the user's token) never sees the proxy, and a
|
|
8
|
+
// request that may change something (POST, PUT…) is never sent twice.
|
|
9
|
+
|
|
10
|
+
/** Whether `url` (absolute) is http(s) on another origin than `pageOrigin`. */
|
|
11
|
+
export function isCrossOrigin(url: string, pageOrigin: string): boolean {
|
|
12
|
+
try {
|
|
13
|
+
const target = new URL(url);
|
|
14
|
+
return (target.protocol === 'http:' || target.protocol === 'https:') && target.origin !== pageOrigin;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `<proxy>?url=<target>`, keeping a query the proxy URL already has. */
|
|
21
|
+
export function proxiedURL(proxy: string, target: string): string {
|
|
22
|
+
const url = new URL(proxy);
|
|
23
|
+
url.searchParams.set('url', target);
|
|
24
|
+
return url.href;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `fetchImpl`, retrying a failed cross-origin GET or HEAD through `proxy`.
|
|
29
|
+
* A failure is a rejection (CORS refusals and network errors look the same
|
|
30
|
+
* to a page); an answer with any status is the server's and is kept. An
|
|
31
|
+
* aborted request is not retried.
|
|
32
|
+
*/
|
|
33
|
+
export function withCORSProxy(fetchImpl: typeof fetch, proxy: string, pageOrigin: string): typeof fetch {
|
|
34
|
+
return async (input, init) => {
|
|
35
|
+
try {
|
|
36
|
+
return await fetchImpl(input, init);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
39
|
+
const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
|
|
40
|
+
const aborted = init?.signal?.aborted || (error instanceof DOMException && error.name === 'AbortError');
|
|
41
|
+
if (aborted || (method !== 'GET' && method !== 'HEAD') || !isCrossOrigin(url, pageOrigin)) throw error;
|
|
42
|
+
return fetchImpl(proxiedURL(proxy, url), init);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
package/src/gaugeRing.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ring of a circular gauge (`accessoryCircularCapacity`,
|
|
3
|
+
* `accessoryCircular`), an SVG the layout places over the gauge's square
|
|
4
|
+
* (the `ring` chrome rect). In a 100-unit box: a capacity gauge is a full
|
|
5
|
+
* track with the value filled clockwise from the top; an `accessoryCircular`
|
|
6
|
+
* gauge is an arc of 270° open at the bottom, filled from its start to the
|
|
7
|
+
* value. The track is the fill color, the value the tint (`--sb-tint`, else
|
|
8
|
+
* the accent color), with round caps (styles.css).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
12
|
+
|
|
13
|
+
/** The ring's stroke, in units of the 100-unit box (about 6 points at 58). */
|
|
14
|
+
const STROKE = 10;
|
|
15
|
+
const RADIUS = 50 - STROKE / 2;
|
|
16
|
+
|
|
17
|
+
/** The part of the circle an `accessoryCircular` gauge's arc covers. */
|
|
18
|
+
const OPEN_ARC = 0.75;
|
|
19
|
+
|
|
20
|
+
export function isCircularGauge(style: string | undefined): boolean {
|
|
21
|
+
return style === 'accessoryCircular' || style === 'accessoryCircularCapacity';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A ring with its track and value circles, in a box the layout places (and
|
|
26
|
+
* hides, as an HTML element can be), hidden until laid out.
|
|
27
|
+
*/
|
|
28
|
+
export function createGaugeRing(): HTMLElement {
|
|
29
|
+
const box = document.createElement('div');
|
|
30
|
+
box.className = 'sb-gauge-ring';
|
|
31
|
+
box.hidden = true;
|
|
32
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
33
|
+
svg.setAttribute('viewBox', '0 0 100 100');
|
|
34
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
35
|
+
for (const part of ['track', 'value']) {
|
|
36
|
+
const circle = document.createElementNS(SVG_NS, 'circle');
|
|
37
|
+
circle.setAttribute('class', `sb-gauge-ring-${part}`);
|
|
38
|
+
circle.setAttribute('cx', '50');
|
|
39
|
+
circle.setAttribute('cy', '50');
|
|
40
|
+
circle.setAttribute('r', String(RADIUS));
|
|
41
|
+
circle.setAttribute('fill', 'none');
|
|
42
|
+
circle.setAttribute('stroke-width', String(STROKE));
|
|
43
|
+
// Dashes in hundredths of the circle.
|
|
44
|
+
circle.setAttribute('pathLength', '100');
|
|
45
|
+
svg.append(circle);
|
|
46
|
+
}
|
|
47
|
+
box.append(svg);
|
|
48
|
+
return box;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The dash arrays and rotation that draw `value` (0...1) in `style`. */
|
|
52
|
+
export function gaugeRingGeometry(style: string, value: number): { track: string; value: string; rotation: number } {
|
|
53
|
+
const v = Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
|
|
54
|
+
const round = (n: number) => Math.round(n * 1000) / 1000;
|
|
55
|
+
if (style === 'accessoryCircular') {
|
|
56
|
+
const arc = OPEN_ARC * 100;
|
|
57
|
+
// From 135° (bottom left), clockwise over the top to 45° (bottom right).
|
|
58
|
+
return { track: `${arc} 100`, value: `${round(arc * v)} 100`, rotation: 135 };
|
|
59
|
+
}
|
|
60
|
+
// Capacity: the whole circle, the value from 12 o'clock.
|
|
61
|
+
return { track: '100 0', value: `${round(100 * v)} 100`, rotation: -90 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function updateGaugeRing(box: HTMLElement, style: string, value: number): void {
|
|
65
|
+
const geometry = gaugeRingGeometry(style, value);
|
|
66
|
+
const svg = box.firstElementChild;
|
|
67
|
+
if (!svg) return;
|
|
68
|
+
const [track, fill] = Array.from(svg.children) as SVGCircleElement[];
|
|
69
|
+
for (const circle of [track, fill]) circle?.setAttribute('transform', `rotate(${geometry.rotation} 50 50)`);
|
|
70
|
+
track?.setAttribute('stroke-dasharray', geometry.track);
|
|
71
|
+
fill?.setAttribute('stroke-dasharray', geometry.value);
|
|
72
|
+
// A zero value draws nothing (a round cap would show a dot).
|
|
73
|
+
fill?.setAttribute('visibility', geometry.value.startsWith('0 ') ? 'hidden' : 'visible');
|
|
74
|
+
}
|
package/src/gif.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animated GIFs, a frame at a time. Image I/O in Swift reports a GIF's
|
|
3
|
+
* frames and delays (Sources/ImageIO/GIF.swift) and draws frame `n` as a
|
|
4
|
+
* recipe whose `frame` is `n`; the page draws that frame here: decoded (LZW)
|
|
5
|
+
* and composed over the frames before it as the browser composes them when
|
|
6
|
+
* it animates a GIF itself. The canvas starts clear, a frame's transparent
|
|
7
|
+
* pixels leave what is under them, and a frame's disposal applies before
|
|
8
|
+
* the next one is drawn: none or leave in place (0, 1), clear its rectangle
|
|
9
|
+
* (2: the background, which browsers draw clear), or restore what was under
|
|
10
|
+
* it (3). Frames are composed in order once and kept, so a loop is decoded
|
|
11
|
+
* once (docs/ops-protocol.md, "Animated GIFs").
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface FrameRecord {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
interlaced: boolean;
|
|
20
|
+
/** RGB triples of the frame's own color table, else the global one. */
|
|
21
|
+
colors: Uint8Array | null;
|
|
22
|
+
transparent: number | null;
|
|
23
|
+
disposal: number;
|
|
24
|
+
/** Delay before the next frame, in hundredths of a second. */
|
|
25
|
+
delay: number;
|
|
26
|
+
minCodeSize: number;
|
|
27
|
+
/** The image data's sub-blocks, joined. */
|
|
28
|
+
data: Uint8Array;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A GIF's frames, composed on demand. */
|
|
32
|
+
export class GifDecoder {
|
|
33
|
+
readonly width: number;
|
|
34
|
+
readonly height: number;
|
|
35
|
+
private readonly records: FrameRecord[];
|
|
36
|
+
/** Composed frames so far, straight RGBA. */
|
|
37
|
+
private readonly composed: Uint8ClampedArray<ArrayBuffer>[] = [];
|
|
38
|
+
/** The canvas as the next frame will find it (the last frame's disposal applied). */
|
|
39
|
+
private canvas: Uint8ClampedArray<ArrayBuffer>;
|
|
40
|
+
|
|
41
|
+
private constructor(width: number, height: number, records: FrameRecord[]) {
|
|
42
|
+
this.width = width;
|
|
43
|
+
this.height = height;
|
|
44
|
+
this.records = records;
|
|
45
|
+
this.canvas = new Uint8ClampedArray(width * height * 4);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The decoder for a GIF file; null when `bytes` is not one, or has no frame. */
|
|
49
|
+
static parse(bytes: Uint8Array): GifDecoder | null {
|
|
50
|
+
const signature = String.fromCharCode(...bytes.subarray(0, 6));
|
|
51
|
+
if (bytes.length < 13 || (signature !== 'GIF87a' && signature !== 'GIF89a')) return null;
|
|
52
|
+
const u16 = (at: number): number => (bytes[at] ?? 0) | ((bytes[at + 1] ?? 0) << 8);
|
|
53
|
+
const width = u16(6);
|
|
54
|
+
const height = u16(8);
|
|
55
|
+
if (width === 0 || height === 0) return null;
|
|
56
|
+
const screen = bytes[10]!;
|
|
57
|
+
let at = 13;
|
|
58
|
+
let globalColors: Uint8Array | null = null;
|
|
59
|
+
if (screen & 0x80) {
|
|
60
|
+
const size = 3 << ((screen & 0x07) + 1);
|
|
61
|
+
globalColors = bytes.subarray(at, at + size);
|
|
62
|
+
at += size;
|
|
63
|
+
}
|
|
64
|
+
/** The sub-blocks starting at `start`, joined, and the index after them. */
|
|
65
|
+
const subBlocks = (start: number): { data: Uint8Array; next: number } => {
|
|
66
|
+
const parts: Uint8Array[] = [];
|
|
67
|
+
let total = 0;
|
|
68
|
+
let index = start;
|
|
69
|
+
while (index < bytes.length) {
|
|
70
|
+
const size = bytes[index]!;
|
|
71
|
+
index += 1;
|
|
72
|
+
if (size === 0) break;
|
|
73
|
+
const part = bytes.subarray(index, Math.min(bytes.length, index + size));
|
|
74
|
+
parts.push(part);
|
|
75
|
+
total += part.length;
|
|
76
|
+
index += size;
|
|
77
|
+
}
|
|
78
|
+
const data = new Uint8Array(total);
|
|
79
|
+
let offset = 0;
|
|
80
|
+
for (const part of parts) {
|
|
81
|
+
data.set(part, offset);
|
|
82
|
+
offset += part.length;
|
|
83
|
+
}
|
|
84
|
+
return { data, next: index };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const records: FrameRecord[] = [];
|
|
88
|
+
let transparent: number | null = null;
|
|
89
|
+
let disposal = 0;
|
|
90
|
+
let delay = 0;
|
|
91
|
+
while (at < bytes.length) {
|
|
92
|
+
const block = bytes[at]!;
|
|
93
|
+
if (block === 0x21) {
|
|
94
|
+
const label = bytes[at + 1];
|
|
95
|
+
if (label === 0xf9 && (bytes[at + 2] ?? 0) >= 4) {
|
|
96
|
+
const packed = bytes[at + 3] ?? 0;
|
|
97
|
+
disposal = (packed >> 2) & 0x07;
|
|
98
|
+
delay = u16(at + 4);
|
|
99
|
+
transparent = packed & 0x01 ? (bytes[at + 6] ?? 0) : null;
|
|
100
|
+
}
|
|
101
|
+
at = subBlocks(at + 2).next;
|
|
102
|
+
} else if (block === 0x2c) {
|
|
103
|
+
if (at + 10 > bytes.length) break;
|
|
104
|
+
const packed = bytes[at + 9]!;
|
|
105
|
+
let index = at + 10;
|
|
106
|
+
let colors = globalColors;
|
|
107
|
+
if (packed & 0x80) {
|
|
108
|
+
const size = 3 << ((packed & 0x07) + 1);
|
|
109
|
+
colors = bytes.subarray(index, index + size);
|
|
110
|
+
index += size;
|
|
111
|
+
}
|
|
112
|
+
const minCodeSize = bytes[index] ?? 0;
|
|
113
|
+
const { data, next } = subBlocks(index + 1);
|
|
114
|
+
records.push({
|
|
115
|
+
x: u16(at + 1),
|
|
116
|
+
y: u16(at + 3),
|
|
117
|
+
width: u16(at + 5),
|
|
118
|
+
height: u16(at + 7),
|
|
119
|
+
interlaced: (packed & 0x40) !== 0,
|
|
120
|
+
colors,
|
|
121
|
+
transparent,
|
|
122
|
+
disposal,
|
|
123
|
+
delay,
|
|
124
|
+
minCodeSize,
|
|
125
|
+
data,
|
|
126
|
+
});
|
|
127
|
+
transparent = null;
|
|
128
|
+
disposal = 0;
|
|
129
|
+
delay = 0;
|
|
130
|
+
at = next;
|
|
131
|
+
} else {
|
|
132
|
+
// The trailer (0x3b), or not a block: the file ends here.
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return records.length > 0 ? new GifDecoder(width, height, records) : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get frameCount(): number {
|
|
140
|
+
return this.records.length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Frame `index`'s delay in hundredths of a second, as the file says. */
|
|
144
|
+
delay(index: number): number {
|
|
145
|
+
return this.records[index]?.delay ?? 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Frame `index` composed over the ones before it: straight RGBA, `width` × `height`. */
|
|
149
|
+
frame(index: number): Uint8ClampedArray<ArrayBuffer> | null {
|
|
150
|
+
if (index < 0 || index >= this.records.length) return null;
|
|
151
|
+
while (this.composed.length <= index) this.composeNext();
|
|
152
|
+
return this.composed[index]!;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private composeNext(): void {
|
|
156
|
+
const record = this.records[this.composed.length]!;
|
|
157
|
+
const { width, height } = this;
|
|
158
|
+
const before = record.disposal === 3 ? this.canvas.slice() : null;
|
|
159
|
+
const pixels = record.width * record.height;
|
|
160
|
+
const { indices, count } = lzwDecode(record.minCodeSize, record.data, pixels);
|
|
161
|
+
const colors = record.colors;
|
|
162
|
+
const canvas = this.canvas;
|
|
163
|
+
for (let i = 0; i < count; i += 1) {
|
|
164
|
+
const row = interlacedRow(Math.floor(i / record.width), record.height, record.interlaced);
|
|
165
|
+
const x = record.x + (i % record.width);
|
|
166
|
+
const y = record.y + row;
|
|
167
|
+
if (x >= width || y >= height) continue;
|
|
168
|
+
const color = indices[i]!;
|
|
169
|
+
if (color === record.transparent) continue;
|
|
170
|
+
const offset = (y * width + x) * 4;
|
|
171
|
+
if (colors && color * 3 + 2 < colors.length) {
|
|
172
|
+
canvas[offset] = colors[color * 3]!;
|
|
173
|
+
canvas[offset + 1] = colors[color * 3 + 1]!;
|
|
174
|
+
canvas[offset + 2] = colors[color * 3 + 2]!;
|
|
175
|
+
} else {
|
|
176
|
+
canvas[offset] = canvas[offset + 1] = canvas[offset + 2] = 0;
|
|
177
|
+
}
|
|
178
|
+
canvas[offset + 3] = 255;
|
|
179
|
+
}
|
|
180
|
+
this.composed.push(canvas.slice());
|
|
181
|
+
// The disposal readies the canvas for the next frame.
|
|
182
|
+
if (record.disposal === 2) {
|
|
183
|
+
for (let y = record.y; y < Math.min(height, record.y + record.height); y += 1) {
|
|
184
|
+
const start = (y * width + record.x) * 4;
|
|
185
|
+
const end = (y * width + Math.min(width, record.x + record.width)) * 4;
|
|
186
|
+
if (end > start) canvas.fill(0, start, end);
|
|
187
|
+
}
|
|
188
|
+
} else if (before) {
|
|
189
|
+
this.canvas = before;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The row an interlaced image's `n`th decoded row belongs on (passes of 8, 8, 4 and 2). */
|
|
195
|
+
function interlacedRow(n: number, height: number, interlaced: boolean): number {
|
|
196
|
+
if (!interlaced) return n;
|
|
197
|
+
const passes: [number, number][] = [
|
|
198
|
+
[0, 8],
|
|
199
|
+
[4, 8],
|
|
200
|
+
[2, 4],
|
|
201
|
+
[1, 2],
|
|
202
|
+
];
|
|
203
|
+
let remaining = n;
|
|
204
|
+
for (const [start, step] of passes) {
|
|
205
|
+
const rows = start < height ? Math.ceil((height - start) / step) : 0;
|
|
206
|
+
if (remaining < rows) return start + remaining * step;
|
|
207
|
+
remaining -= rows;
|
|
208
|
+
}
|
|
209
|
+
return height;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* GIF's variable-width LZW: color indices for up to `pixels` pixels, and how
|
|
214
|
+
* many the data held (a file cut short leaves the rest undrawn).
|
|
215
|
+
*/
|
|
216
|
+
export function lzwDecode(minCodeSize: number, data: Uint8Array, pixels: number): { indices: Uint8Array; count: number } {
|
|
217
|
+
const indices = new Uint8Array(pixels);
|
|
218
|
+
if (minCodeSize < 1 || minCodeSize > 11) return { indices, count: 0 };
|
|
219
|
+
const clear = 1 << minCodeSize;
|
|
220
|
+
const end = clear + 1;
|
|
221
|
+
const prefix = new Int16Array(4096);
|
|
222
|
+
const suffix = new Uint8Array(4096);
|
|
223
|
+
const stack = new Uint8Array(4097);
|
|
224
|
+
for (let code = 0; code < clear; code += 1) {
|
|
225
|
+
prefix[code] = -1;
|
|
226
|
+
suffix[code] = code;
|
|
227
|
+
}
|
|
228
|
+
let codeSize = minCodeSize + 1;
|
|
229
|
+
let codeMask = (1 << codeSize) - 1;
|
|
230
|
+
let next = end + 1;
|
|
231
|
+
let previous = -1;
|
|
232
|
+
let first = 0;
|
|
233
|
+
let datum = 0;
|
|
234
|
+
let bits = 0;
|
|
235
|
+
let position = 0;
|
|
236
|
+
let count = 0;
|
|
237
|
+
while (count < pixels) {
|
|
238
|
+
while (bits < codeSize && position < data.length) {
|
|
239
|
+
datum |= data[position]! << bits;
|
|
240
|
+
position += 1;
|
|
241
|
+
bits += 8;
|
|
242
|
+
}
|
|
243
|
+
if (bits < codeSize) break;
|
|
244
|
+
let code = datum & codeMask;
|
|
245
|
+
datum >>>= codeSize;
|
|
246
|
+
bits -= codeSize;
|
|
247
|
+
if (code === clear) {
|
|
248
|
+
codeSize = minCodeSize + 1;
|
|
249
|
+
codeMask = (1 << codeSize) - 1;
|
|
250
|
+
next = end + 1;
|
|
251
|
+
previous = -1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (code === end) break;
|
|
255
|
+
if (previous === -1) {
|
|
256
|
+
if (code >= clear) break;
|
|
257
|
+
indices[count] = code;
|
|
258
|
+
count += 1;
|
|
259
|
+
previous = code;
|
|
260
|
+
first = code;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (code > next) break;
|
|
264
|
+
const current = code;
|
|
265
|
+
let top = 0;
|
|
266
|
+
if (code === next) {
|
|
267
|
+
// The code being defined: the previous string and its own first index.
|
|
268
|
+
stack[top] = first;
|
|
269
|
+
top += 1;
|
|
270
|
+
code = previous;
|
|
271
|
+
}
|
|
272
|
+
while (code >= clear) {
|
|
273
|
+
stack[top] = suffix[code]!;
|
|
274
|
+
top += 1;
|
|
275
|
+
code = prefix[code]!;
|
|
276
|
+
}
|
|
277
|
+
first = suffix[code]!;
|
|
278
|
+
stack[top] = first;
|
|
279
|
+
top += 1;
|
|
280
|
+
if (next < 4096) {
|
|
281
|
+
prefix[next] = previous;
|
|
282
|
+
suffix[next] = first;
|
|
283
|
+
next += 1;
|
|
284
|
+
if ((next & codeMask) === 0 && next < 4096) {
|
|
285
|
+
codeSize += 1;
|
|
286
|
+
codeMask = (1 << codeSize) - 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
previous = current;
|
|
290
|
+
while (top > 0 && count < pixels) {
|
|
291
|
+
top -= 1;
|
|
292
|
+
indices[count] = stack[top]!;
|
|
293
|
+
count += 1;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return { indices, count };
|
|
297
|
+
}
|