@ultimat3/core 5.0.1 → 6.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.
@@ -1,320 +0,0 @@
1
- // Single responsibility: the geometry and the pixels of a resize — output box, drawn size,
2
- // resampling and the source-over composite. Every format shares this one scaler on purpose: a
3
- // second one is a second place for a PWA icon to grow a grey halo.
4
-
5
- import { parseColor } from './color';
6
- import { imageUnsupported } from './errors';
7
- import { assertPixelBudget, createRaster, type ImageSize, type Raster, rasterFrom } from './raster';
8
-
9
- export type ImageFit = 'cover' | 'contain';
10
-
11
- export interface ResizeSpec {
12
- readonly width?: number | undefined;
13
- readonly height?: number | undefined;
14
- /** Default 'contain'. */
15
- readonly fit?: ImageFit | undefined;
16
- /** Fraction of the shorter OUTPUT edge left empty on every side. `0 <= padding < 0.5`. */
17
- readonly padding?: number | undefined;
18
- /** '#rgb' | '#rgba' | '#rrggbb' | '#rrggbbaa' | 'transparent'. Default transparent. */
19
- readonly background?: string | undefined;
20
- }
21
-
22
- function assertDimension(value: number, field: string): void {
23
- if (!Number.isInteger(value) || value < 1) {
24
- throw imageUnsupported(
25
- `resize ${field} is ${value}, which is not a whole number of pixels above zero`,
26
- `pass an integer ${field} of 1 or more, or omit it to derive it from the source`,
27
- { field, value },
28
- );
29
- }
30
- }
31
-
32
- /**
33
- * The output CANVAS size. A single-axis request clamps to the source: asking for `width: 2000`
34
- * of a 400px original must not invent 1600 pixels of blur, it must hand back the 400.
35
- */
36
- export function fitBox(source: ImageSize, spec: ResizeSpec): ImageSize {
37
- const { width, height } = spec;
38
- if (width !== undefined) assertDimension(width, 'width');
39
- if (height !== undefined) assertDimension(height, 'height');
40
- if (width !== undefined && height !== undefined) return { width, height };
41
- if (width !== undefined) {
42
- const w = Math.min(width, source.width);
43
- return { width: w, height: Math.max(1, Math.round((w * source.height) / source.width)) };
44
- }
45
- if (height !== undefined) {
46
- const h = Math.min(height, source.height);
47
- return { width: Math.max(1, Math.round((h * source.width) / source.height)), height: h };
48
- }
49
- return { width: source.width, height: source.height };
50
- }
51
-
52
- /** The size the source is DRAWN at inside `box` — no letterbox, no crop maths. May upscale. */
53
- export function scaledToFit(source: ImageSize, box: ImageSize, fit: ImageFit): ImageSize {
54
- const x = box.width / source.width;
55
- const y = box.height / source.height;
56
- const scale = fit === 'cover' ? Math.max(x, y) : Math.min(x, y);
57
- return {
58
- width: Math.max(1, Math.round(source.width * scale)),
59
- height: Math.max(1, Math.round(source.height * scale)),
60
- };
61
- }
62
-
63
- interface InnerBox {
64
- readonly pad: number;
65
- readonly inner: ImageSize;
66
- }
67
-
68
- function innerBox(box: ImageSize, padding: number): InnerBox {
69
- if (!Number.isFinite(padding) || padding < 0 || padding >= 0.5) {
70
- throw imageUnsupported(
71
- `resize padding is ${padding}, outside the 0 <= padding < 0.5 range`,
72
- 'pass a fraction of the shorter output edge, e.g. 0.1 for a 10% border on every side',
73
- { padding },
74
- );
75
- }
76
- const pad = Math.round(Math.min(box.width, box.height) * padding);
77
- const inner = { width: box.width - 2 * pad, height: box.height - 2 * pad };
78
- if (inner.width < 1 || inner.height < 1) {
79
- throw imageUnsupported(
80
- `padding ${padding} leaves no room inside a ${box.width}x${box.height} output`,
81
- 'lower the padding or raise the requested width and height',
82
- { padding, pad, width: box.width, height: box.height },
83
- );
84
- }
85
- return { pad, inner };
86
- }
87
-
88
- interface AxisPlan {
89
- /** First contributing source index per target index. */
90
- readonly starts: Int32Array;
91
- /** `taps` normalised weights per target index, at `i * taps`. Unused taps are 0. */
92
- readonly weights: Float32Array;
93
- readonly taps: number;
94
- }
95
-
96
- /**
97
- * Area average when shrinking, bilinear when growing — chosen per axis. Nearest neighbour is
98
- * what makes a downscaled `srcset` variant look cheap, and this file feeds every one of them.
99
- */
100
- function planAxis(source: number, target: number): AxisPlan {
101
- const ratio = source / target;
102
- if (target > source) {
103
- const starts = new Int32Array(target);
104
- const weights = new Float32Array(target * 2);
105
- for (let i = 0; i < target; i += 1) {
106
- const center = (i + 0.5) * ratio - 0.5;
107
- const left = Math.floor(center);
108
- const first = Math.min(Math.max(left, 0), source - 1);
109
- const second = Math.min(Math.max(left + 1, 0), source - 1);
110
- starts[i] = first;
111
- // Clamping at an edge collapses the pair; the surviving tap carries the whole weight.
112
- weights[i * 2] = second === first ? 1 : 1 - (center - left);
113
- weights[i * 2 + 1] = second === first ? 0 : center - left;
114
- }
115
- return { starts, weights, taps: 2 };
116
- }
117
- const taps = Math.ceil(ratio) + 1;
118
- const starts = new Int32Array(target);
119
- const weights = new Float32Array(target * taps);
120
- for (let i = 0; i < target; i += 1) {
121
- const from = i * ratio;
122
- const to = (i + 1) * ratio;
123
- const first = Math.min(Math.floor(from), source - 1);
124
- starts[i] = first;
125
- let total = 0;
126
- for (let k = 0; k < taps; k += 1) {
127
- const s = first + k;
128
- if (s >= source) break;
129
- const overlap = Math.min(to, s + 1) - Math.max(from, s);
130
- if (overlap <= 0) break;
131
- weights[i * taps + k] = overlap;
132
- total += overlap;
133
- }
134
- // Normalising is what keeps a partially covered edge column its own colour instead of
135
- // fading it toward zero, and what makes a solid image survive a downscale unchanged.
136
- if (total > 0) {
137
- for (let k = 0; k < taps; k += 1)
138
- weights[i * taps + k] = (weights[i * taps + k] ?? 0) / total;
139
- }
140
- }
141
- return { starts, weights, taps };
142
- }
143
-
144
- function unpremultiply(src: Float32Array, width: number, height: number): Raster {
145
- const pixels = new Uint8ClampedArray(width * height * 4);
146
- for (let i = 0; i < pixels.length; i += 4) {
147
- const a = src[i + 3] ?? 0;
148
- if (a <= 0) continue;
149
- const f = 255 / a;
150
- pixels[i] = (src[i] ?? 0) * f;
151
- pixels[i + 1] = (src[i + 1] ?? 0) * f;
152
- pixels[i + 2] = (src[i + 2] ?? 0) * f;
153
- pixels[i + 3] = a;
154
- }
155
- return rasterFrom(width, height, pixels);
156
- }
157
-
158
- /** Either plane a pass can read: the 8-bit source itself, or a previous pass's float output. */
159
- type Plane = Float32Array | Uint8ClampedArray;
160
-
161
- /**
162
- * The one weighted 4-channel sum every pass shares. `base` + `step` are the only thing that differs
163
- * between horizontal and vertical, so there is a single accumulation to get right.
164
- *
165
- * Averaging non-premultiplied RGBA bleeds a transparent pixel's colour into the visible edge, so
166
- * every tap is premultiplied — and when the plane is the 8-bit source (`eightBit`) that happens
167
- * HERE, on read, instead of in a float copy of the whole image. `Math.fround` is what a
168
- * `Float32Array` store did in that copy, so the fused read produces identical bytes; on a float
169
- * plane the value is already float32 and it is a no-op.
170
- */
171
- function tapSum(
172
- src: Plane,
173
- out: Float32Array,
174
- q: number,
175
- base: number,
176
- step: number,
177
- plan: AxisPlan,
178
- i: number,
179
- eightBit: boolean,
180
- ): void {
181
- const { starts, weights, taps } = plan;
182
- const from = base + (starts[i] ?? 0) * step;
183
- let r = 0;
184
- let g = 0;
185
- let b = 0;
186
- let a = 0;
187
- for (let k = 0; k < taps; k += 1) {
188
- const w = weights[i * taps + k] ?? 0;
189
- // A zero weight is a tap the plan clamped away; skipping it is also what keeps the
190
- // read inside the buffer at the trailing edge.
191
- if (w === 0) continue;
192
- const p = from + k * step;
193
- const alpha = src[p + 3] ?? 0;
194
- const f = eightBit ? alpha / 255 : 1;
195
- r += Math.fround((src[p] ?? 0) * f) * w;
196
- g += Math.fround((src[p + 1] ?? 0) * f) * w;
197
- b += Math.fround((src[p + 2] ?? 0) * f) * w;
198
- a += alpha * w;
199
- }
200
- out[q] = r;
201
- out[q + 1] = g;
202
- out[q + 2] = b;
203
- out[q + 3] = a;
204
- }
205
-
206
- function scaleX(src: Plane, sw: number, rows: number, dw: number, plan: AxisPlan, first: boolean) {
207
- const out = new Float32Array(dw * rows * 4);
208
- for (let y = 0; y < rows; y += 1) {
209
- for (let x = 0; x < dw; x += 1) {
210
- tapSum(src, out, (y * dw + x) * 4, y * sw * 4, 4, plan, x, first);
211
- }
212
- }
213
- return out;
214
- }
215
-
216
- function scaleY(src: Plane, cols: number, dh: number, plan: AxisPlan, first: boolean) {
217
- const out = new Float32Array(cols * dh * 4);
218
- for (let y = 0; y < dh; y += 1) {
219
- for (let x = 0; x < cols; x += 1) {
220
- tapSum(src, out, (y * cols + x) * 4, x * 4, cols * 4, plan, y, first);
221
- }
222
- }
223
- return out;
224
- }
225
-
226
- /**
227
- * Separable: one axis into scratch, then the other. O(w·h·taps), never O(w·h·taps²).
228
- *
229
- * The FIRST pass reads `raster.pixels` directly, whichever axis it scales, so the only float buffer
230
- * ever allocated is a pass OUTPUT. A standalone premultiplied copy of the source would be
231
- * `Float32Array(w * h * 4)` — a gigabyte for a legal 64MP upload, before the scaler even starts,
232
- * on a path `storage`, `seo` and `pwa` all feed user bytes into.
233
- */
234
- function resample(raster: Raster, size: ImageSize): Raster {
235
- if (raster.width === size.width && raster.height === size.height) return raster;
236
- assertPixelBudget(size.width, size.height, 'resize');
237
- const { pixels, width, height } = raster;
238
- // The early return above means at least one axis scales, so `first` always reads the 8-bit source.
239
- const scalesX = size.width !== width;
240
- const first = scalesX
241
- ? scaleX(pixels, width, height, size.width, planAxis(width, size.width), true)
242
- : scaleY(pixels, width, size.height, planAxis(height, size.height), true);
243
- const both = scalesX && size.height !== height;
244
- const scaled = both
245
- ? scaleY(first, size.width, size.height, planAxis(height, size.height), false)
246
- : first;
247
- return unpremultiply(scaled, size.width, size.height);
248
- }
249
-
250
- function fill(canvas: Raster, color: readonly [number, number, number, number]): void {
251
- const [r, g, b, a] = color;
252
- // A zero-alpha background is canonicalised to all-zero, matching the composite's own
253
- // `outA === 0 -> outC = 0`: '#ff000000' and 'transparent' must not produce different bytes.
254
- if (a === 0) return;
255
- const { pixels } = canvas;
256
- for (let i = 0; i < pixels.length; i += 4) {
257
- pixels[i] = r;
258
- pixels[i + 1] = g;
259
- pixels[i + 2] = b;
260
- pixels[i + 3] = a;
261
- }
262
- }
263
-
264
- /** Source-over. `outA === 0` means every contributor was transparent — the colour is nothing. */
265
- function blend(dst: Uint8ClampedArray, d: number, s: Uint8ClampedArray, p: number): void {
266
- const sa = s[p + 3] ?? 0;
267
- if (sa === 0) return;
268
- const da = dst[d + 3] ?? 0;
269
- if (sa === 255 || da === 0) {
270
- dst[d] = s[p] ?? 0;
271
- dst[d + 1] = s[p + 1] ?? 0;
272
- dst[d + 2] = s[p + 2] ?? 0;
273
- dst[d + 3] = sa;
274
- return;
275
- }
276
- const sf = sa / 255;
277
- const df = (da / 255) * (1 - sf);
278
- const outA = sf + df;
279
- dst[d] = ((s[p] ?? 0) * sf + (dst[d] ?? 0) * df) / outA;
280
- dst[d + 1] = ((s[p + 1] ?? 0) * sf + (dst[d + 1] ?? 0) * df) / outA;
281
- dst[d + 2] = ((s[p + 2] ?? 0) * sf + (dst[d + 2] ?? 0) * df) / outA;
282
- dst[d + 3] = outA * 255;
283
- }
284
-
285
- /** Centres `art` in the inner area and clips to it — that clip is exactly the `cover` crop. */
286
- function composite(canvas: Raster, art: Raster, pad: number, inner: ImageSize): void {
287
- const ox = pad + Math.round((inner.width - art.width) / 2);
288
- const oy = pad + Math.round((inner.height - art.height) / 2);
289
- const x1 = Math.min(pad + inner.width, ox + art.width);
290
- const y1 = Math.min(pad + inner.height, oy + art.height);
291
- for (let y = Math.max(pad, oy); y < y1; y += 1) {
292
- for (let x = Math.max(pad, ox); x < x1; x += 1) {
293
- blend(
294
- canvas.pixels,
295
- (y * canvas.width + x) * 4,
296
- art.pixels,
297
- ((y - oy) * art.width + (x - ox)) * 4,
298
- );
299
- }
300
- }
301
- }
302
-
303
- /** Box, background, resample, centre, composite — the whole resize, in that order. */
304
- export function resizeRaster(raster: Raster, spec: ResizeSpec): Raster {
305
- const box = fitBox(raster, spec);
306
- const { pad, inner } = innerBox(box, spec.padding ?? 0);
307
- const unchanged =
308
- box.width === raster.width &&
309
- box.height === raster.height &&
310
- pad === 0 &&
311
- spec.background === undefined;
312
- if (unchanged) return raster;
313
-
314
- assertPixelBudget(box.width, box.height, 'resize');
315
- const drawn = scaledToFit(raster, inner, spec.fit ?? 'contain');
316
- const canvas = createRaster(box.width, box.height, 'resize');
317
- fill(canvas, parseColor(spec.background ?? 'transparent'));
318
- composite(canvas, resample(raster, drawn), pad, inner);
319
- return canvas;
320
- }