@majikah/majik-signature 0.0.6 → 0.0.7

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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * dct-stego.ts — DCT coefficient steganography
3
+ *
4
+ * Embeds and extracts binary data by manipulating the parity of mid-frequency
5
+ * DCT coefficients in each 8×8 luminance block of an image.
6
+ *
7
+ * ── Why mid-frequency? ──────────────────────────────────────────────────────
8
+ *
9
+ * Each 8×8 DCT block has 64 coefficients arranged by frequency:
10
+ *
11
+ * DC AC1 AC2 AC3 AC4 AC5 AC6 AC7
12
+ * AC8 AC9 ...
13
+ * ...
14
+ *
15
+ * Low-frequency (DC, AC1-AC3): Carry most visual information. Modifying
16
+ * these creates visible artifacts.
17
+ *
18
+ * Mid-frequency (AC4-AC20 in zigzag order): Survive Q70 quantization
19
+ * (quantization step ~8-24). Modifying parity is invisible and recoverable
20
+ * after recompression IF we choose coefficients with large enough magnitude.
21
+ *
22
+ * High-frequency (AC21-AC63): Zeroed out by Q70 quantization. Useless.
23
+ *
24
+ * ── Encoding scheme ─────────────────────────────────────────────────────────
25
+ *
26
+ * For each target block:
27
+ * 1. Apply 2D DCT to the 8×8 luma block
28
+ * 2. Pick coefficient at zigzag position 5 (the 'embedding coefficient')
29
+ * - Zigzag pos 5 = matrix position [1,2] = moderate frequency
30
+ * 3. If coefficient magnitude < SKIP_THRESHOLD (4), skip this block
31
+ * (coefficient is too small — quantization would zero it)
32
+ * 4. To embed bit 1: force coefficient to be ODD after quantization
33
+ * To embed bit 0: force coefficient to be EVEN after quantization
34
+ * 5. Apply inverse DCT and update pixels
35
+ *
36
+ * ── Survival at Q70 ─────────────────────────────────────────────────────────
37
+ *
38
+ * At Q70, the quantization step for position [1,2] is typically 10-16.
39
+ * When JPEG re-encodes:
40
+ * coef_quantized = round(coef / step)
41
+ * coef_dequantized = coef_quantized * step
42
+ *
43
+ * Our parity encoding forces coef_quantized to be odd or even.
44
+ * After dequantization, the parity of coef_quantized is preserved!
45
+ * → The embedded bit survives as long as the coefficient doesn't get zeroed.
46
+ *
47
+ * We skip coefficients with magnitude < SKIP_THRESHOLD * quantStep to
48
+ * avoid the zero-rounding problem.
49
+ *
50
+ * With Reed-Solomon ECC providing 27-byte error correction, we can tolerate
51
+ * ~16% of embedded bits being corrupted by aggressive recompression.
52
+ *
53
+ * ── Pixel domain operation ───────────────────────────────────────────────────
54
+ *
55
+ * We operate on raw RGBA pixel data (from Canvas or image decode).
56
+ * We implement our own 8×8 DCT/IDCT to ensure determinism across
57
+ * platforms (browser Canvas, Node sharp, etc.).
58
+ *
59
+ * Note: We work on the Y (luma) channel after RGB→YCbCr conversion.
60
+ * Cb/Cr channels are left untouched.
61
+ */
62
+ /**
63
+ * Embed bytes into image pixel data using DCT coefficient parity.
64
+ *
65
+ * @param pixels RGBA pixel data (modified in place)
66
+ * @param width Image width
67
+ * @param height Image height
68
+ * @param data Bytes to embed
69
+ * @returns Number of bits successfully written
70
+ */
71
+ export declare function dctEmbed(pixels: Uint8ClampedArray, width: number, height: number, data: Uint8Array): number;
72
+ /**
73
+ * Extract bytes from image pixel data by reading DCT coefficient parity.
74
+ *
75
+ * @param pixels RGBA pixel data (read only)
76
+ * @param width Image width
77
+ * @param height Image height
78
+ * @param numBytes Number of bytes to extract
79
+ * @returns Extracted bytes (may contain bit errors — use RS ECC to correct)
80
+ */
81
+ export declare function dctExtract(pixels: Uint8ClampedArray, width: number, height: number, numBytes: number): Uint8Array;
82
+ /**
83
+ * Calculate the embedding capacity of an image in bytes.
84
+ * Actual usable capacity depends on coefficient magnitudes — this is an
85
+ * upper bound assuming all blocks have usable coefficients.
86
+ */
87
+ export declare function dctCapacity(width: number, height: number): number;
@@ -0,0 +1,388 @@
1
+ /**
2
+ * dct-stego.ts — DCT coefficient steganography
3
+ *
4
+ * Embeds and extracts binary data by manipulating the parity of mid-frequency
5
+ * DCT coefficients in each 8×8 luminance block of an image.
6
+ *
7
+ * ── Why mid-frequency? ──────────────────────────────────────────────────────
8
+ *
9
+ * Each 8×8 DCT block has 64 coefficients arranged by frequency:
10
+ *
11
+ * DC AC1 AC2 AC3 AC4 AC5 AC6 AC7
12
+ * AC8 AC9 ...
13
+ * ...
14
+ *
15
+ * Low-frequency (DC, AC1-AC3): Carry most visual information. Modifying
16
+ * these creates visible artifacts.
17
+ *
18
+ * Mid-frequency (AC4-AC20 in zigzag order): Survive Q70 quantization
19
+ * (quantization step ~8-24). Modifying parity is invisible and recoverable
20
+ * after recompression IF we choose coefficients with large enough magnitude.
21
+ *
22
+ * High-frequency (AC21-AC63): Zeroed out by Q70 quantization. Useless.
23
+ *
24
+ * ── Encoding scheme ─────────────────────────────────────────────────────────
25
+ *
26
+ * For each target block:
27
+ * 1. Apply 2D DCT to the 8×8 luma block
28
+ * 2. Pick coefficient at zigzag position 5 (the 'embedding coefficient')
29
+ * - Zigzag pos 5 = matrix position [1,2] = moderate frequency
30
+ * 3. If coefficient magnitude < SKIP_THRESHOLD (4), skip this block
31
+ * (coefficient is too small — quantization would zero it)
32
+ * 4. To embed bit 1: force coefficient to be ODD after quantization
33
+ * To embed bit 0: force coefficient to be EVEN after quantization
34
+ * 5. Apply inverse DCT and update pixels
35
+ *
36
+ * ── Survival at Q70 ─────────────────────────────────────────────────────────
37
+ *
38
+ * At Q70, the quantization step for position [1,2] is typically 10-16.
39
+ * When JPEG re-encodes:
40
+ * coef_quantized = round(coef / step)
41
+ * coef_dequantized = coef_quantized * step
42
+ *
43
+ * Our parity encoding forces coef_quantized to be odd or even.
44
+ * After dequantization, the parity of coef_quantized is preserved!
45
+ * → The embedded bit survives as long as the coefficient doesn't get zeroed.
46
+ *
47
+ * We skip coefficients with magnitude < SKIP_THRESHOLD * quantStep to
48
+ * avoid the zero-rounding problem.
49
+ *
50
+ * With Reed-Solomon ECC providing 27-byte error correction, we can tolerate
51
+ * ~16% of embedded bits being corrupted by aggressive recompression.
52
+ *
53
+ * ── Pixel domain operation ───────────────────────────────────────────────────
54
+ *
55
+ * We operate on raw RGBA pixel data (from Canvas or image decode).
56
+ * We implement our own 8×8 DCT/IDCT to ensure determinism across
57
+ * platforms (browser Canvas, Node sharp, etc.).
58
+ *
59
+ * Note: We work on the Y (luma) channel after RGB→YCbCr conversion.
60
+ * Cb/Cr channels are left untouched.
61
+ */
62
+ // ─── Constants ────────────────────────────────────────────────────────────────
63
+ /** Zigzag scan order for 8×8 DCT block */
64
+ const ZIGZAG = [
65
+ [0, 0],
66
+ [0, 1],
67
+ [1, 0],
68
+ [2, 0],
69
+ [1, 1],
70
+ [0, 2],
71
+ [0, 3],
72
+ [1, 2], // 0-7
73
+ [2, 1],
74
+ [3, 0],
75
+ [4, 0],
76
+ [3, 1],
77
+ [2, 2],
78
+ [1, 3],
79
+ [0, 4],
80
+ [0, 5], // 8-15
81
+ [1, 4],
82
+ [2, 3],
83
+ [3, 2],
84
+ [4, 1],
85
+ [5, 0],
86
+ [6, 0],
87
+ [5, 1],
88
+ [4, 2], // 16-23
89
+ [3, 3],
90
+ [2, 4],
91
+ [1, 5],
92
+ [0, 6],
93
+ [0, 7],
94
+ [1, 6],
95
+ [2, 5],
96
+ [3, 4], // 24-31
97
+ [4, 3],
98
+ [5, 2],
99
+ [6, 1],
100
+ [7, 0],
101
+ [7, 1],
102
+ [6, 2],
103
+ [5, 3],
104
+ [4, 4], // 32-39
105
+ [3, 5],
106
+ [2, 6],
107
+ [1, 7],
108
+ [2, 7],
109
+ [3, 6],
110
+ [4, 5],
111
+ [5, 4],
112
+ [6, 3], // 40-47
113
+ [7, 2],
114
+ [7, 3],
115
+ [6, 4],
116
+ [5, 5],
117
+ [4, 6],
118
+ [3, 7],
119
+ [4, 7],
120
+ [5, 6], // 48-55
121
+ [6, 5],
122
+ [7, 4],
123
+ [7, 5],
124
+ [6, 6],
125
+ [5, 7],
126
+ [6, 7],
127
+ [7, 6],
128
+ [7, 7], // 56-63
129
+ ];
130
+ /**
131
+ * Zigzag positions to use for embedding (mid-frequency, Q70-safe).
132
+ * These are [row, col] positions in the 8×8 DCT matrix.
133
+ * We use positions 5, 8, 9, 13 — reliably non-zero at Q70.
134
+ */
135
+ const EMBED_POSITIONS = [
136
+ ZIGZAG[5], // [0,2]
137
+ ZIGZAG[8], // [2,1]
138
+ ZIGZAG[9], // [3,0]
139
+ ZIGZAG[13], // [1,3]
140
+ ];
141
+ /** Skip coefficient if |value| < threshold (will be zeroed by quantization) */
142
+ const SKIP_THRESHOLD = 4;
143
+ // ─── Public API ───────────────────────────────────────────────────────────────
144
+ /**
145
+ * Embed bytes into image pixel data using DCT coefficient parity.
146
+ *
147
+ * @param pixels RGBA pixel data (modified in place)
148
+ * @param width Image width
149
+ * @param height Image height
150
+ * @param data Bytes to embed
151
+ * @returns Number of bits successfully written
152
+ */
153
+ export function dctEmbed(pixels, width, height, data) {
154
+ const bits = bytesToBits(data);
155
+ let bitIndex = 0;
156
+ let bitsWritten = 0;
157
+ const blocksX = Math.floor(width / 8);
158
+ const blocksY = Math.floor(height / 8);
159
+ outer: for (let by = 0; by < blocksY; by++) {
160
+ for (let bx = 0; bx < blocksX; bx++) {
161
+ if (bitIndex >= bits.length)
162
+ break outer;
163
+ // Extract 8×8 luma block
164
+ const block = extractLumaBlock(pixels, width, bx * 8, by * 8);
165
+ // Forward DCT
166
+ const dct = dct2d_8x8(block);
167
+ // Try each embedding position until we find a usable coefficient
168
+ let embedded = false;
169
+ for (const [row, col] of EMBED_POSITIONS) {
170
+ const coefIdx = row * 8 + col;
171
+ const coef = dct[coefIdx];
172
+ if (Math.abs(coef) < SKIP_THRESHOLD)
173
+ continue;
174
+ // Embed bit by forcing parity
175
+ const bit = bits[bitIndex];
176
+ const quantized = Math.round(coef);
177
+ const isOdd = Math.abs(quantized) % 2 === 1;
178
+ if (bit === 1 && !isOdd) {
179
+ // Make odd: add 1 if positive, subtract 1 if negative
180
+ dct[coefIdx] = coef >= 0 ? coef + 1 : coef - 1;
181
+ }
182
+ else if (bit === 0 && isOdd) {
183
+ // Make even
184
+ dct[coefIdx] = coef >= 0 ? coef - 1 : coef + 1;
185
+ }
186
+ // If parity already matches, no modification needed
187
+ embedded = true;
188
+ break;
189
+ }
190
+ if (!embedded)
191
+ continue; // block has all-small coefficients, skip
192
+ // Inverse DCT and write back
193
+ const restored = idct2d_8x8(dct);
194
+ writeLumaBlock(pixels, width, bx * 8, by * 8, restored);
195
+ bitIndex++;
196
+ bitsWritten++;
197
+ }
198
+ }
199
+ return bitsWritten;
200
+ }
201
+ /**
202
+ * Extract bytes from image pixel data by reading DCT coefficient parity.
203
+ *
204
+ * @param pixels RGBA pixel data (read only)
205
+ * @param width Image width
206
+ * @param height Image height
207
+ * @param numBytes Number of bytes to extract
208
+ * @returns Extracted bytes (may contain bit errors — use RS ECC to correct)
209
+ */
210
+ export function dctExtract(pixels, width, height, numBytes) {
211
+ const numBits = numBytes * 8;
212
+ const bits = [];
213
+ const blocksX = Math.floor(width / 8);
214
+ const blocksY = Math.floor(height / 8);
215
+ outer: for (let by = 0; by < blocksY; by++) {
216
+ for (let bx = 0; bx < blocksX; bx++) {
217
+ if (bits.length >= numBits)
218
+ break outer;
219
+ const block = extractLumaBlock(pixels, width, bx * 8, by * 8);
220
+ const dct = dct2d_8x8(block);
221
+ let read = false;
222
+ for (const [row, col] of EMBED_POSITIONS) {
223
+ const coef = dct[row * 8 + col];
224
+ if (Math.abs(coef) < SKIP_THRESHOLD)
225
+ continue;
226
+ const quantized = Math.round(coef);
227
+ bits.push(Math.abs(quantized) % 2 === 1 ? 1 : 0);
228
+ read = true;
229
+ break;
230
+ }
231
+ if (!read)
232
+ continue;
233
+ }
234
+ }
235
+ return bitsToBytes(bits);
236
+ }
237
+ /**
238
+ * Calculate the embedding capacity of an image in bytes.
239
+ * Actual usable capacity depends on coefficient magnitudes — this is an
240
+ * upper bound assuming all blocks have usable coefficients.
241
+ */
242
+ export function dctCapacity(width, height) {
243
+ const blocksX = Math.floor(width / 8);
244
+ const blocksY = Math.floor(height / 8);
245
+ // Assume ~60% of blocks have usable mid-frequency coefficients
246
+ return Math.floor((blocksX * blocksY * 0.6) / 8);
247
+ }
248
+ // ─── 8×8 DCT ─────────────────────────────────────────────────────────────────
249
+ /**
250
+ * Forward 8×8 DCT-II (Type 2).
251
+ * Input: 64 floats (8×8 luma values, 0-255)
252
+ * Output: 64 DCT coefficients
253
+ */
254
+ function dct2d_8x8(block) {
255
+ const N = 8;
256
+ const tmp = new Float64Array(64);
257
+ const out = new Float64Array(64);
258
+ // DCT each row
259
+ for (let row = 0; row < N; row++) {
260
+ for (let k = 0; k < N; k++) {
261
+ let sum = 0;
262
+ for (let n = 0; n < N; n++) {
263
+ sum += block[row * N + n] * COS_TABLE[k][n];
264
+ }
265
+ tmp[row * N + k] = SCALE[k] * sum;
266
+ }
267
+ }
268
+ // DCT each column
269
+ for (let col = 0; col < N; col++) {
270
+ for (let k = 0; k < N; k++) {
271
+ let sum = 0;
272
+ for (let n = 0; n < N; n++) {
273
+ sum += tmp[n * N + col] * COS_TABLE[k][n];
274
+ }
275
+ out[k * N + col] = SCALE[k] * sum;
276
+ }
277
+ }
278
+ return out;
279
+ }
280
+ /**
281
+ * Inverse 8×8 DCT-III (Type 3).
282
+ * Input: 64 DCT coefficients
283
+ * Output: 64 reconstructed luma values (0-255, clamped)
284
+ */
285
+ function idct2d_8x8(dct) {
286
+ const N = 8;
287
+ const tmp = new Float64Array(64);
288
+ const out = new Float64Array(64);
289
+ // IDCT each row (transpose of DCT)
290
+ for (let row = 0; row < N; row++) {
291
+ for (let n = 0; n < N; n++) {
292
+ let sum = SCALE[0] * dct[row * N + 0];
293
+ for (let k = 1; k < N; k++) {
294
+ sum += SCALE[k] * dct[row * N + k] * COS_TABLE[k][n];
295
+ }
296
+ tmp[row * N + n] = sum;
297
+ }
298
+ }
299
+ // IDCT each column
300
+ for (let col = 0; col < N; col++) {
301
+ for (let n = 0; n < N; n++) {
302
+ let sum = SCALE[0] * tmp[0 * N + col];
303
+ for (let k = 1; k < N; k++) {
304
+ sum += SCALE[k] * tmp[k * N + col] * COS_TABLE[k][n];
305
+ }
306
+ out[n * N + col] = Math.max(0, Math.min(255, Math.round(sum)));
307
+ }
308
+ }
309
+ return out;
310
+ }
311
+ // ─── Precomputed cosine and scale tables ──────────────────────────────────────
312
+ const COS_TABLE = (() => {
313
+ const N = 8;
314
+ const table = [];
315
+ for (let k = 0; k < N; k++) {
316
+ table[k] = [];
317
+ for (let n = 0; n < N; n++) {
318
+ table[k][n] = Math.cos((Math.PI * k * (2 * n + 1)) / (2 * N));
319
+ }
320
+ }
321
+ return table;
322
+ })();
323
+ const SCALE = (() => {
324
+ const N = 8;
325
+ const s = [];
326
+ for (let k = 0; k < N; k++) {
327
+ s[k] = k === 0 ? Math.sqrt(1 / N) : Math.sqrt(2 / N);
328
+ }
329
+ return s;
330
+ })();
331
+ // ─── Block extraction / writing ───────────────────────────────────────────────
332
+ function extractLumaBlock(pixels, width, blockX, blockY) {
333
+ const block = new Float64Array(64);
334
+ for (let y = 0; y < 8; y++) {
335
+ for (let x = 0; x < 8; x++) {
336
+ const idx = ((blockY + y) * width + (blockX + x)) * 4;
337
+ const r = pixels[idx];
338
+ const g = pixels[idx + 1];
339
+ const b = pixels[idx + 2];
340
+ // Rec. 601 luma (matches JPEG's YCbCr conversion)
341
+ block[y * 8 + x] = 0.299 * r + 0.587 * g + 0.114 * b;
342
+ }
343
+ }
344
+ return block;
345
+ }
346
+ function writeLumaBlock(pixels, width, blockX, blockY, luma) {
347
+ for (let y = 0; y < 8; y++) {
348
+ for (let x = 0; x < 8; x++) {
349
+ const idx = ((blockY + y) * width + (blockX + x)) * 4;
350
+ const r = pixels[idx];
351
+ const g = pixels[idx + 1];
352
+ const b = pixels[idx + 2];
353
+ // Extract original YCbCr
354
+ const Y0 = 0.299 * r + 0.587 * g + 0.114 * b;
355
+ const Cb = -0.169 * r - 0.331 * g + 0.5 * b;
356
+ const Cr = 0.5 * r - 0.419 * g - 0.081 * b;
357
+ // New Y from modified DCT
358
+ const Y1 = luma[y * 8 + x];
359
+ // Convert back to RGB (keeping Cb, Cr unchanged)
360
+ const newR = Math.max(0, Math.min(255, Math.round(Y1 + 1.402 * Cr)));
361
+ const newG = Math.max(0, Math.min(255, Math.round(Y1 - 0.344 * Cb - 0.714 * Cr)));
362
+ const newB = Math.max(0, Math.min(255, Math.round(Y1 + 1.772 * Cb)));
363
+ pixels[idx] = newR;
364
+ pixels[idx + 1] = newG;
365
+ pixels[idx + 2] = newB;
366
+ // Alpha unchanged
367
+ }
368
+ }
369
+ }
370
+ // ─── Bit/byte conversion ──────────────────────────────────────────────────────
371
+ function bytesToBits(bytes) {
372
+ const bits = [];
373
+ for (const byte of bytes) {
374
+ for (let i = 7; i >= 0; i--) {
375
+ bits.push((byte >> i) & 1);
376
+ }
377
+ }
378
+ return bits;
379
+ }
380
+ function bitsToBytes(bits) {
381
+ const bytes = new Uint8Array(Math.ceil(bits.length / 8));
382
+ for (let i = 0; i < bits.length; i++) {
383
+ if (bits[i]) {
384
+ bytes[Math.floor(i / 8)] |= 1 << (7 - (i % 8));
385
+ }
386
+ }
387
+ return bytes;
388
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * image-utils.ts — Cross-platform image pixel I/O
3
+ *
4
+ * Handles the messy job of getting RGBA pixel data in and out of images,
5
+ * across browser (Canvas API) and Node.js (sharp or canvas package) contexts.
6
+ *
7
+ * All functions accept/return Blob in browser and Buffer/Blob in Node.
8
+ *
9
+ * Minimum image dimensions we enforce: 600×600 px.
10
+ * Smaller images are padded with white before embedding.
11
+ */
12
+ export declare const MIN_DIMENSION = 600;
13
+ export interface ImagePixels {
14
+ pixels: Uint8ClampedArray;
15
+ width: number;
16
+ height: number;
17
+ }
18
+ export interface ImageEncodeOptions {
19
+ mimeType?: "image/png" | "image/jpeg" | "image/webp";
20
+ quality?: number;
21
+ }
22
+ /**
23
+ * Decode an image Blob to RGBA pixel data using browser Canvas API.
24
+ *
25
+ * @param blob Any image format the browser supports (JPEG, PNG, WebP, GIF, etc.)
26
+ * @returns RGBA pixels, width, height
27
+ */
28
+ export declare function decodeImage(blob: Blob): Promise<ImagePixels>;
29
+ /**
30
+ * Encode RGBA pixel data to an image Blob using browser Canvas API.
31
+ *
32
+ * @param pixels RGBA pixel data
33
+ * @param width Image width
34
+ * @param height Image height
35
+ * @param options Encoding options
36
+ * @returns Image Blob
37
+ */
38
+ export declare function encodeImage(pixels: Uint8ClampedArray, width: number, height: number, options?: ImageEncodeOptions): Promise<Blob>;
39
+ /**
40
+ * Pad image to minimum dimensions, centering the original content.
41
+ * Used to ensure small signature images have enough DCT blocks.
42
+ */
43
+ export declare function padToMinimum(img: ImagePixels): ImagePixels;
44
+ /**
45
+ * Check if the runtime environment is a browser with Canvas API.
46
+ */
47
+ export declare function hasBrowserCanvas(): boolean;
48
+ /**
49
+ * Node.js version of decodeImage using sharp.
50
+ * Only available if 'sharp' is installed.
51
+ */
52
+ export declare function decodeImageNode(buffer: Buffer | Uint8Array): Promise<ImagePixels>;
53
+ /**
54
+ * Node.js version of encodeImage using sharp.
55
+ * Only available if 'sharp' is installed.
56
+ */
57
+ export declare function encodeImageNode(pixels: Uint8ClampedArray, width: number, height: number, options?: ImageEncodeOptions): Promise<Buffer>;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * image-utils.ts — Cross-platform image pixel I/O
3
+ *
4
+ * Handles the messy job of getting RGBA pixel data in and out of images,
5
+ * across browser (Canvas API) and Node.js (sharp or canvas package) contexts.
6
+ *
7
+ * All functions accept/return Blob in browser and Buffer/Blob in Node.
8
+ *
9
+ * Minimum image dimensions we enforce: 600×600 px.
10
+ * Smaller images are padded with white before embedding.
11
+ */
12
+ import sharp from "sharp";
13
+ export const MIN_DIMENSION = 600;
14
+ // ─── Browser implementation ───────────────────────────────────────────────────
15
+ /**
16
+ * Decode an image Blob to RGBA pixel data using browser Canvas API.
17
+ *
18
+ * @param blob Any image format the browser supports (JPEG, PNG, WebP, GIF, etc.)
19
+ * @returns RGBA pixels, width, height
20
+ */
21
+ export async function decodeImage(blob) {
22
+ if (typeof createImageBitmap === "undefined") {
23
+ throw new Error("decodeImage requires a browser environment with createImageBitmap. " +
24
+ "In Node.js, use decodeImageNode() with the sharp package.");
25
+ }
26
+ const bitmap = await createImageBitmap(blob);
27
+ const { width, height } = bitmap;
28
+ const canvas = new OffscreenCanvas(width, height);
29
+ const ctx = canvas.getContext("2d");
30
+ ctx.drawImage(bitmap, 0, 0);
31
+ bitmap.close();
32
+ const imageData = ctx.getImageData(0, 0, width, height);
33
+ return {
34
+ pixels: imageData.data,
35
+ width,
36
+ height,
37
+ };
38
+ }
39
+ /**
40
+ * Encode RGBA pixel data to an image Blob using browser Canvas API.
41
+ *
42
+ * @param pixels RGBA pixel data
43
+ * @param width Image width
44
+ * @param height Image height
45
+ * @param options Encoding options
46
+ * @returns Image Blob
47
+ */
48
+ export async function encodeImage(pixels, width, height, options = {}) {
49
+ if (typeof OffscreenCanvas === "undefined") {
50
+ throw new Error("encodeImage requires a browser environment with OffscreenCanvas. " +
51
+ "In Node.js, use encodeImageNode() with the sharp package.");
52
+ }
53
+ const mimeType = options.mimeType ?? "image/png";
54
+ const quality = options.quality ?? 0.92;
55
+ const canvas = new OffscreenCanvas(width, height);
56
+ const ctx = canvas.getContext("2d");
57
+ // ImageData's data-first overload requires Uint8ClampedArray<ArrayBuffer>
58
+ // strictly — no SharedArrayBuffer, no pooled buffers with non-zero byteOffset.
59
+ // The safest fix is to allocate a fresh Uint8ClampedArray (which always owns
60
+ // a plain ArrayBuffer) and copy into it via set(), then construct ImageData
61
+ // from width/height and write pixels via putImageData on the blank canvas.
62
+ const imageData = ctx.createImageData(width, height);
63
+ imageData.data.set(pixels);
64
+ ctx.putImageData(imageData, 0, 0);
65
+ return canvas.convertToBlob({ type: mimeType, quality });
66
+ }
67
+ /**
68
+ * Pad image to minimum dimensions, centering the original content.
69
+ * Used to ensure small signature images have enough DCT blocks.
70
+ */
71
+ export function padToMinimum(img) {
72
+ const { pixels, width, height } = img;
73
+ if (width >= MIN_DIMENSION && height >= MIN_DIMENSION) {
74
+ return img; // already large enough
75
+ }
76
+ const newW = Math.max(width, MIN_DIMENSION);
77
+ const newH = Math.max(height, MIN_DIMENSION);
78
+ const newPixels = new Uint8ClampedArray(newW * newH * 4);
79
+ // Fill with white
80
+ for (let i = 0; i < newPixels.length; i += 4) {
81
+ newPixels[i] = 255; // R
82
+ newPixels[i + 1] = 255; // G
83
+ newPixels[i + 2] = 255; // B
84
+ newPixels[i + 3] = 255; // A
85
+ }
86
+ // Copy original image, centered
87
+ const offsetX = Math.floor((newW - width) / 2);
88
+ const offsetY = Math.floor((newH - height) / 2);
89
+ for (let y = 0; y < height; y++) {
90
+ for (let x = 0; x < width; x++) {
91
+ const srcIdx = (y * width + x) * 4;
92
+ const dstIdx = ((offsetY + y) * newW + (offsetX + x)) * 4;
93
+ newPixels[dstIdx] = pixels[srcIdx];
94
+ newPixels[dstIdx + 1] = pixels[srcIdx + 1];
95
+ newPixels[dstIdx + 2] = pixels[srcIdx + 2];
96
+ newPixels[dstIdx + 3] = pixels[srcIdx + 3];
97
+ }
98
+ }
99
+ return { pixels: newPixels, width: newW, height: newH };
100
+ }
101
+ /**
102
+ * Check if the runtime environment is a browser with Canvas API.
103
+ */
104
+ export function hasBrowserCanvas() {
105
+ return (typeof createImageBitmap !== "undefined" &&
106
+ typeof OffscreenCanvas !== "undefined");
107
+ }
108
+ // ─── Node.js stubs ────────────────────────────────────────────────────────────
109
+ //
110
+ // These throw helpful errors if called in a Node.js environment without the
111
+ // optional `sharp` dependency. Install sharp for Node.js support:
112
+ // npm install sharp
113
+ //
114
+ // Or use the canvas package:
115
+ // npm install canvas
116
+ /**
117
+ * Node.js version of decodeImage using sharp.
118
+ * Only available if 'sharp' is installed.
119
+ */
120
+ export async function decodeImageNode(buffer) {
121
+ const img = sharp(buffer);
122
+ const { width, height } = await img.metadata();
123
+ if (!width || !height)
124
+ throw new Error("Could not read image dimensions");
125
+ const rawBuffer = await img.ensureAlpha().raw().toBuffer();
126
+ return {
127
+ pixels: new Uint8ClampedArray(rawBuffer.buffer),
128
+ width,
129
+ height,
130
+ };
131
+ }
132
+ /**
133
+ * Node.js version of encodeImage using sharp.
134
+ * Only available if 'sharp' is installed.
135
+ */
136
+ export async function encodeImageNode(pixels, width, height, options = {}) {
137
+ const mimeType = options.mimeType ?? "image/png";
138
+ const quality = Math.round((options.quality ?? 0.92) * 100);
139
+ const img = sharp(Buffer.from(pixels.buffer), {
140
+ raw: { width, height, channels: 4 },
141
+ });
142
+ if (mimeType === "image/png")
143
+ return img.png().toBuffer();
144
+ if (mimeType === "image/jpeg")
145
+ return img.jpeg({ quality }).toBuffer();
146
+ if (mimeType === "image/webp")
147
+ return img.webp({ quality }).toBuffer();
148
+ throw new Error(`Unsupported mimeType: ${mimeType}`);
149
+ }