@happyvertical/images 0.74.8

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/dist/index.js ADDED
@@ -0,0 +1,1302 @@
1
+ import { createHmac } from "node:crypto";
2
+ class ImageError extends Error {
3
+ constructor(message, code, adapter) {
4
+ super(message);
5
+ this.code = code;
6
+ this.adapter = adapter;
7
+ this.name = "ImageError";
8
+ }
9
+ }
10
+ class ImageNotFoundError extends ImageError {
11
+ constructor(path, adapter) {
12
+ super(`Image not found: ${path}`, "IMAGE_NOT_FOUND", adapter);
13
+ this.name = "ImageNotFoundError";
14
+ }
15
+ }
16
+ class UnsupportedFormatError extends ImageError {
17
+ constructor(format, adapter) {
18
+ super(`Unsupported image format: ${format}`, "UNSUPPORTED_FORMAT", adapter);
19
+ this.name = "UnsupportedFormatError";
20
+ }
21
+ }
22
+ class OperationNotSupportedError extends ImageError {
23
+ constructor(operation, adapter) {
24
+ super(
25
+ `Operation not supported: ${operation}`,
26
+ "OPERATION_NOT_SUPPORTED",
27
+ adapter
28
+ );
29
+ this.name = "OperationNotSupportedError";
30
+ }
31
+ }
32
+ class ProcessingError extends ImageError {
33
+ constructor(message, adapter, cause) {
34
+ super(message, "PROCESSING_ERROR", adapter);
35
+ this.cause = cause;
36
+ this.name = "ProcessingError";
37
+ }
38
+ }
39
+ class InvalidAdapterError extends ImageError {
40
+ constructor(type) {
41
+ super(`Invalid adapter type: ${type}`, "INVALID_ADAPTER");
42
+ this.name = "InvalidAdapterError";
43
+ }
44
+ }
45
+ class RemoteServiceError extends ImageError {
46
+ constructor(message, adapter, statusCode) {
47
+ super(message, "REMOTE_SERVICE_ERROR", adapter);
48
+ this.statusCode = statusCode;
49
+ this.name = "RemoteServiceError";
50
+ }
51
+ }
52
+ function signImgproxyUrl(source, config, options = {}) {
53
+ const adapter = new ImgproxyAdapter({
54
+ type: "imgproxy",
55
+ baseUrl: config.baseUrl,
56
+ key: config.key,
57
+ salt: config.salt
58
+ });
59
+ return adapter.getSignedUrl(source, options);
60
+ }
61
+ class ImgproxyAdapter {
62
+ baseUrl;
63
+ key;
64
+ salt;
65
+ constructor(options) {
66
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
67
+ if (options.key && options.salt) {
68
+ this.key = Buffer.from(options.key, "hex");
69
+ this.salt = Buffer.from(options.salt, "hex");
70
+ }
71
+ }
72
+ /**
73
+ * Generate a signed imgproxy URL without fetching the image
74
+ *
75
+ * This is useful for:
76
+ * - Client-side usage where you want to display images directly
77
+ * - Debugging URL signing issues
78
+ * - Pre-generating URLs for batch processing
79
+ *
80
+ * @param source - Source image URL (must be http/https)
81
+ * @param options - Processing options
82
+ * @returns Signed imgproxy URL
83
+ *
84
+ * @example
85
+ * ```typescript
86
+ * const adapter = new ImgproxyAdapter({
87
+ * type: 'imgproxy',
88
+ * baseUrl: 'https://imgproxy.example.com',
89
+ * key: 'hex-key',
90
+ * salt: 'hex-salt'
91
+ * });
92
+ *
93
+ * // Generate thumbnail URL
94
+ * const url = adapter.getSignedUrl('https://example.com/image.jpg', {
95
+ * width: 300,
96
+ * height: 200,
97
+ * fit: 'cover',
98
+ * format: 'webp'
99
+ * });
100
+ * // Returns: https://imgproxy.example.com/SIGNATURE/rs:fill:300:200/aHR0cHM6Ly9.../image.webp
101
+ * ```
102
+ */
103
+ getSignedUrl(source, options = {}) {
104
+ if (!source.startsWith("http://") && !source.startsWith("https://")) {
105
+ throw new ProcessingError(
106
+ `imgproxy requires HTTP/HTTPS URLs as source. Got: ${source.substring(0, 50)}...`,
107
+ "imgproxy"
108
+ );
109
+ }
110
+ const resizeType = this.mapFitToResizeType(options.fit || "cover");
111
+ const processing = this.buildProcessingString({
112
+ resize: resizeType,
113
+ width: options.width || 0,
114
+ height: options.height || 0,
115
+ quality: options.quality,
116
+ format: options.format
117
+ });
118
+ return this.buildUrl(source, processing, options.format);
119
+ }
120
+ /**
121
+ * Sign a path using HMAC-SHA256
122
+ */
123
+ sign(path) {
124
+ if (!this.key || !this.salt) {
125
+ return "unsafe";
126
+ }
127
+ const hmac = createHmac("sha256", this.key);
128
+ hmac.update(this.salt);
129
+ hmac.update(path);
130
+ return hmac.digest("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "").substring(0, 32);
131
+ }
132
+ /**
133
+ * Encode source URL for imgproxy
134
+ */
135
+ encodeSource(source) {
136
+ return Buffer.from(source).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
137
+ }
138
+ /**
139
+ * Build imgproxy URL
140
+ */
141
+ buildUrl(source, processing, extension) {
142
+ const encodedSource = this.encodeSource(source);
143
+ const ext = extension ? `.${extension}` : "";
144
+ const path = `/${processing}/${encodedSource}${ext}`;
145
+ const signature = this.sign(path);
146
+ return `${this.baseUrl}/${signature}${path}`;
147
+ }
148
+ /**
149
+ * Convert ImageInput to URL string
150
+ */
151
+ toUrl(input) {
152
+ if (Buffer.isBuffer(input)) {
153
+ throw new ProcessingError(
154
+ "imgproxy adapter requires URL input, not Buffer. Upload the buffer to a storage service first.",
155
+ "imgproxy"
156
+ );
157
+ }
158
+ if (!input.startsWith("http://") && !input.startsWith("https://")) {
159
+ throw new ProcessingError(
160
+ `imgproxy adapter requires HTTP/HTTPS URLs as input. Got: ${input.substring(0, 50)}...`,
161
+ "imgproxy"
162
+ );
163
+ }
164
+ return input;
165
+ }
166
+ async getDimensions(input) {
167
+ const url = this.toUrl(input);
168
+ try {
169
+ const infoUrl = this.buildUrl(url, "info:1");
170
+ const response = await fetch(infoUrl);
171
+ if (!response.ok) {
172
+ throw new RemoteServiceError(
173
+ `imgproxy info request failed: ${response.statusText}`,
174
+ "imgproxy",
175
+ response.status
176
+ );
177
+ }
178
+ const info = await response.json();
179
+ return {
180
+ width: info.width,
181
+ height: info.height
182
+ };
183
+ } catch (error) {
184
+ if (error instanceof ProcessingError || error instanceof RemoteServiceError) {
185
+ throw error;
186
+ }
187
+ throw new ProcessingError(
188
+ `Failed to get dimensions: ${error instanceof Error ? error.message : String(error)}`,
189
+ "imgproxy",
190
+ error instanceof Error ? error : void 0
191
+ );
192
+ }
193
+ }
194
+ async thumbnail(input, output, options = {}) {
195
+ const url = this.toUrl(input);
196
+ try {
197
+ const processing = this.buildProcessingString({
198
+ resize: "fit",
199
+ width: options.maxWidth || 0,
200
+ height: options.maxHeight || 0,
201
+ quality: options.quality,
202
+ format: options.format
203
+ });
204
+ const ext = options.format || this.inferFormat(output);
205
+ const imgUrl = this.buildUrl(url, processing, ext);
206
+ await this.fetchAndSave(imgUrl, output);
207
+ } catch (error) {
208
+ if (error instanceof ProcessingError || error instanceof RemoteServiceError) {
209
+ throw error;
210
+ }
211
+ throw new ProcessingError(
212
+ `Thumbnail generation failed: ${error instanceof Error ? error.message : String(error)}`,
213
+ "imgproxy",
214
+ error instanceof Error ? error : void 0
215
+ );
216
+ }
217
+ }
218
+ async convert(input, output, options = {}) {
219
+ const url = this.toUrl(input);
220
+ try {
221
+ const format = options.format || this.inferFormat(output);
222
+ const processing = this.buildProcessingString({
223
+ quality: options.quality,
224
+ format
225
+ });
226
+ const imgUrl = this.buildUrl(url, processing, format);
227
+ await this.fetchAndSave(imgUrl, output);
228
+ } catch (error) {
229
+ if (error instanceof ProcessingError || error instanceof RemoteServiceError) {
230
+ throw error;
231
+ }
232
+ throw new ProcessingError(
233
+ `Format conversion failed: ${error instanceof Error ? error.message : String(error)}`,
234
+ "imgproxy",
235
+ error instanceof Error ? error : void 0
236
+ );
237
+ }
238
+ }
239
+ async getMetadata(input) {
240
+ const url = this.toUrl(input);
241
+ try {
242
+ const infoUrl = this.buildUrl(url, "info:1");
243
+ const response = await fetch(infoUrl);
244
+ if (!response.ok) {
245
+ throw new RemoteServiceError(
246
+ `imgproxy info request failed: ${response.statusText}`,
247
+ "imgproxy",
248
+ response.status
249
+ );
250
+ }
251
+ const info = await response.json();
252
+ return {
253
+ width: info.width,
254
+ height: info.height,
255
+ format: info.type || "unknown"
256
+ // imgproxy returns limited metadata
257
+ // Additional EXIF/IPTC/XMP not typically available
258
+ };
259
+ } catch (error) {
260
+ if (error instanceof ProcessingError || error instanceof RemoteServiceError) {
261
+ throw error;
262
+ }
263
+ throw new ProcessingError(
264
+ `Failed to get metadata: ${error instanceof Error ? error.message : String(error)}`,
265
+ "imgproxy",
266
+ error instanceof Error ? error : void 0
267
+ );
268
+ }
269
+ }
270
+ async hash(_input, _algorithm = "perceptual") {
271
+ throw new OperationNotSupportedError(
272
+ "hash (imgproxy does not support direct hashing - fetch image first)",
273
+ "imgproxy"
274
+ );
275
+ }
276
+ async resize(input, output, options) {
277
+ const url = this.toUrl(input);
278
+ try {
279
+ const resizeType = this.mapFitToResizeType(options.fit || "cover");
280
+ const processing = this.buildProcessingString({
281
+ resize: resizeType,
282
+ width: options.width || 0,
283
+ height: options.height || 0,
284
+ quality: options.quality,
285
+ format: options.format
286
+ });
287
+ const ext = options.format || this.inferFormat(output);
288
+ const imgUrl = this.buildUrl(url, processing, ext);
289
+ await this.fetchAndSave(imgUrl, output);
290
+ } catch (error) {
291
+ if (error instanceof ProcessingError || error instanceof RemoteServiceError) {
292
+ throw error;
293
+ }
294
+ throw new ProcessingError(
295
+ `Resize failed: ${error instanceof Error ? error.message : String(error)}`,
296
+ "imgproxy",
297
+ error instanceof Error ? error : void 0
298
+ );
299
+ }
300
+ }
301
+ /**
302
+ * Build imgproxy processing string
303
+ */
304
+ buildProcessingString(opts) {
305
+ const parts = [];
306
+ if (opts.resize) {
307
+ const w = opts.width || 0;
308
+ const h = opts.height || 0;
309
+ parts.push(`rs:${opts.resize}:${w}:${h}`);
310
+ }
311
+ if (opts.quality) {
312
+ parts.push(`q:${opts.quality}`);
313
+ }
314
+ return parts.length > 0 ? parts.join("/") : "raw:1";
315
+ }
316
+ /**
317
+ * Map fit mode to imgproxy resize type
318
+ */
319
+ mapFitToResizeType(fit) {
320
+ const map = {
321
+ cover: "fill",
322
+ // Fill and crop
323
+ contain: "fit",
324
+ // Fit within
325
+ fill: "force",
326
+ // Force exact size
327
+ inside: "fit",
328
+ // Same as contain
329
+ outside: "fill"
330
+ // Same as cover
331
+ };
332
+ return map[fit] || "fit";
333
+ }
334
+ /**
335
+ * Fetch image from imgproxy and save to file
336
+ */
337
+ async fetchAndSave(url, output) {
338
+ const response = await fetch(url);
339
+ if (!response.ok) {
340
+ throw new RemoteServiceError(
341
+ `imgproxy request failed: ${response.statusText}`,
342
+ "imgproxy",
343
+ response.status
344
+ );
345
+ }
346
+ const buffer = Buffer.from(await response.arrayBuffer());
347
+ const { writeFile } = await import("node:fs/promises");
348
+ await writeFile(output, buffer);
349
+ }
350
+ /**
351
+ * Infer image format from file extension
352
+ */
353
+ inferFormat(path) {
354
+ const ext = path.split(".").pop()?.toLowerCase();
355
+ const map = {
356
+ jpg: "jpeg",
357
+ jpeg: "jpeg",
358
+ png: "png",
359
+ webp: "webp",
360
+ avif: "avif",
361
+ gif: "gif",
362
+ tiff: "tiff",
363
+ tif: "tiff"
364
+ };
365
+ return map[ext || ""] || "jpeg";
366
+ }
367
+ }
368
+ const imgproxy = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
369
+ __proto__: null,
370
+ ImgproxyAdapter,
371
+ signImgproxyUrl
372
+ }, Symbol.toStringTag, { value: "Module" }));
373
+ class JimpAdapter {
374
+ jimpStatic = null;
375
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: Stored for API compatibility and future extensibility
376
+ options;
377
+ /**
378
+ * Create a new Jimp adapter
379
+ * @param options - Jimp adapter options
380
+ */
381
+ constructor(options = { type: "jimp" }) {
382
+ this.options = options;
383
+ }
384
+ /**
385
+ * Lazily load Jimp module
386
+ */
387
+ async getJimp() {
388
+ if (!this.jimpStatic) {
389
+ try {
390
+ const jimpModule = await import("jimp");
391
+ this.jimpStatic = jimpModule.Jimp;
392
+ } catch (error) {
393
+ throw new ProcessingError(
394
+ "Jimp library is not installed. Install it with: npm install jimp",
395
+ "jimp",
396
+ error instanceof Error ? error : void 0
397
+ );
398
+ }
399
+ }
400
+ return this.jimpStatic;
401
+ }
402
+ async getDimensions(input) {
403
+ const Jimp = await this.getJimp();
404
+ try {
405
+ const image = await Jimp.read(input);
406
+ return { width: image.width, height: image.height };
407
+ } catch (error) {
408
+ throw this.mapError(error, input);
409
+ }
410
+ }
411
+ async thumbnail(input, output, options = {}) {
412
+ const Jimp = await this.getJimp();
413
+ try {
414
+ const image = await Jimp.read(input);
415
+ if (options.maxWidth && options.maxHeight) {
416
+ image.scaleToFit({ w: options.maxWidth, h: options.maxHeight });
417
+ } else if (options.maxWidth) {
418
+ const scale = options.maxWidth / image.width;
419
+ if (scale < 1) {
420
+ image.scale(scale);
421
+ }
422
+ } else if (options.maxHeight) {
423
+ const scale = options.maxHeight / image.height;
424
+ if (scale < 1) {
425
+ image.scale(scale);
426
+ }
427
+ }
428
+ await this.writeWithOptions(image, output, options.quality);
429
+ } catch (error) {
430
+ if (error instanceof ProcessingError || error instanceof ImageNotFoundError) {
431
+ throw error;
432
+ }
433
+ throw this.mapError(error, input);
434
+ }
435
+ }
436
+ async convert(input, output, options = {}) {
437
+ const Jimp = await this.getJimp();
438
+ try {
439
+ const image = await Jimp.read(input);
440
+ await this.writeWithOptions(image, output, options.quality);
441
+ } catch (error) {
442
+ throw this.mapError(error, input);
443
+ }
444
+ }
445
+ async getMetadata(input) {
446
+ const Jimp = await this.getJimp();
447
+ try {
448
+ const image = await Jimp.read(input);
449
+ const mime = this.getMimeFromInput(input);
450
+ const format = this.formatFromMime(mime);
451
+ return {
452
+ width: image.width,
453
+ height: image.height,
454
+ format,
455
+ channels: 4,
456
+ // Jimp uses RGBA internally
457
+ hasAlpha: true
458
+ // Jimp always works with alpha channel
459
+ // Jimp has limited metadata extraction
460
+ // EXIF/IPTC/XMP not directly supported
461
+ };
462
+ } catch (error) {
463
+ throw this.mapError(error, input);
464
+ }
465
+ }
466
+ async hash(input, algorithm = "perceptual") {
467
+ const Jimp = await this.getJimp();
468
+ try {
469
+ if (algorithm === "md5" || algorithm === "sha256") {
470
+ const { createHash } = await import("node:crypto");
471
+ const image2 = await Jimp.read(input);
472
+ const buffer = await image2.getBuffer("image/png");
473
+ return createHash(algorithm).update(buffer).digest("hex");
474
+ }
475
+ const image = await Jimp.read(input);
476
+ const resized = image.clone().resize({ w: 8, h: 8 }).greyscale();
477
+ const data = resized.bitmap.data;
478
+ let sum = 0;
479
+ const pixels = [];
480
+ for (let i = 0; i < data.length; i += 4) {
481
+ const gray = data[i];
482
+ pixels.push(gray);
483
+ sum += gray;
484
+ }
485
+ const avg = sum / pixels.length;
486
+ let hash = "";
487
+ for (const val of pixels) {
488
+ hash += val >= avg ? "1" : "0";
489
+ }
490
+ return BigInt(`0b${hash}`).toString(16).padStart(16, "0");
491
+ } catch (error) {
492
+ throw this.mapError(error, input);
493
+ }
494
+ }
495
+ async resize(input, output, options) {
496
+ const Jimp = await this.getJimp();
497
+ try {
498
+ const image = await Jimp.read(input);
499
+ const fit = options.fit || "cover";
500
+ if (options.width && options.height) {
501
+ if (fit === "cover") {
502
+ image.cover({ w: options.width, h: options.height });
503
+ } else if (fit === "contain" || fit === "inside") {
504
+ image.contain({ w: options.width, h: options.height });
505
+ } else {
506
+ image.resize({ w: options.width, h: options.height });
507
+ }
508
+ } else if (options.width) {
509
+ const scale = options.width / image.width;
510
+ image.scale(scale);
511
+ } else if (options.height) {
512
+ const scale = options.height / image.height;
513
+ image.scale(scale);
514
+ }
515
+ await this.writeWithOptions(image, output, options.quality);
516
+ } catch (error) {
517
+ throw this.mapError(error, input);
518
+ }
519
+ }
520
+ /**
521
+ * Write image with quality settings based on output format
522
+ */
523
+ async writeWithOptions(image, output, quality) {
524
+ const format = this.inferFormat(output);
525
+ const mime = this.mimeFromFormat(format);
526
+ if (format === "avif") {
527
+ throw new UnsupportedFormatError("avif", "jimp");
528
+ }
529
+ const buffer = await image.getBuffer(mime, {
530
+ quality: quality ?? 80
531
+ });
532
+ const { writeFile } = await import("node:fs/promises");
533
+ await writeFile(output, buffer);
534
+ }
535
+ /**
536
+ * Infer image format from file extension
537
+ */
538
+ inferFormat(path) {
539
+ const ext = path.split(".").pop()?.toLowerCase();
540
+ const map = {
541
+ jpg: "jpeg",
542
+ jpeg: "jpeg",
543
+ png: "png",
544
+ webp: "webp",
545
+ avif: "avif",
546
+ gif: "gif",
547
+ tiff: "tiff",
548
+ tif: "tiff",
549
+ bmp: "png"
550
+ // Convert BMP to PNG
551
+ };
552
+ return map[ext || ""] || "jpeg";
553
+ }
554
+ /**
555
+ * Get MIME type from format
556
+ */
557
+ mimeFromFormat(format) {
558
+ const map = {
559
+ jpeg: "image/jpeg",
560
+ png: "image/png",
561
+ webp: "image/webp",
562
+ avif: "image/avif",
563
+ gif: "image/gif",
564
+ tiff: "image/tiff"
565
+ };
566
+ return map[format] || "image/jpeg";
567
+ }
568
+ /**
569
+ * Get format from MIME type
570
+ */
571
+ formatFromMime(mime) {
572
+ const map = {
573
+ "image/jpeg": "jpeg",
574
+ "image/png": "png",
575
+ "image/webp": "webp",
576
+ "image/gif": "gif",
577
+ "image/tiff": "tiff",
578
+ "image/bmp": "bmp"
579
+ };
580
+ return map[mime] || "unknown";
581
+ }
582
+ /**
583
+ * Try to determine MIME type from input
584
+ */
585
+ getMimeFromInput(input) {
586
+ if (typeof input === "string") {
587
+ const format = this.inferFormat(input);
588
+ return this.mimeFromFormat(format);
589
+ }
590
+ return "image/png";
591
+ }
592
+ /**
593
+ * Map Jimp errors to our error types
594
+ */
595
+ mapError(error, input) {
596
+ const message = error instanceof Error ? error.message : String(error);
597
+ if (message.includes("no such file") || message.includes("ENOENT") || message.includes("Could not find")) {
598
+ const path = typeof input === "string" ? input : "<buffer>";
599
+ throw new ImageNotFoundError(path, "jimp");
600
+ }
601
+ if (message.includes("not supported") || message.includes("Could not find MIME")) {
602
+ throw new UnsupportedFormatError("unknown", "jimp");
603
+ }
604
+ return new ProcessingError(
605
+ `Image processing failed: ${message}`,
606
+ "jimp",
607
+ error instanceof Error ? error : void 0
608
+ );
609
+ }
610
+ }
611
+ const jimp = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
612
+ __proto__: null,
613
+ JimpAdapter
614
+ }, Symbol.toStringTag, { value: "Module" }));
615
+ class SharpAdapter {
616
+ sharp = null;
617
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: Stored for API compatibility and future extensibility
618
+ options;
619
+ /**
620
+ * Create a new Sharp adapter
621
+ * @param options - Sharp adapter options
622
+ */
623
+ constructor(options = {}) {
624
+ this.options = options;
625
+ }
626
+ /**
627
+ * Lazily load sharp module
628
+ */
629
+ async getSharp() {
630
+ if (!this.sharp) {
631
+ try {
632
+ this.sharp = (await import("sharp")).default;
633
+ } catch (error) {
634
+ throw new ProcessingError(
635
+ "Sharp library is not installed. Install it with: npm install sharp",
636
+ "sharp",
637
+ error instanceof Error ? error : void 0
638
+ );
639
+ }
640
+ }
641
+ return this.sharp;
642
+ }
643
+ async getDimensions(input) {
644
+ const sharp2 = await this.getSharp();
645
+ try {
646
+ const metadata = await sharp2(input).metadata();
647
+ if (!metadata.width || !metadata.height) {
648
+ throw new ProcessingError(
649
+ "Could not determine image dimensions",
650
+ "sharp"
651
+ );
652
+ }
653
+ return { width: metadata.width, height: metadata.height };
654
+ } catch (error) {
655
+ if (error instanceof ProcessingError) throw error;
656
+ throw this.mapError(error, input);
657
+ }
658
+ }
659
+ async thumbnail(input, output, options = {}) {
660
+ const sharp2 = await this.getSharp();
661
+ try {
662
+ let pipeline = sharp2(input).resize({
663
+ width: options.maxWidth,
664
+ height: options.maxHeight,
665
+ fit: options.fit || "inside",
666
+ withoutEnlargement: true
667
+ });
668
+ if (options.format) {
669
+ pipeline = pipeline.toFormat(options.format, {
670
+ quality: options.quality
671
+ });
672
+ } else if (options.quality) {
673
+ const format = this.inferFormat(output);
674
+ pipeline = pipeline.toFormat(format, { quality: options.quality });
675
+ }
676
+ await pipeline.toFile(output);
677
+ } catch (error) {
678
+ throw this.mapError(error, input);
679
+ }
680
+ }
681
+ async convert(input, output, options = {}) {
682
+ const sharp2 = await this.getSharp();
683
+ try {
684
+ const format = options.format || this.inferFormat(output);
685
+ await sharp2(input).toFormat(format, { quality: options.quality }).toFile(output);
686
+ } catch (error) {
687
+ throw this.mapError(error, input);
688
+ }
689
+ }
690
+ async getMetadata(input) {
691
+ const sharp2 = await this.getSharp();
692
+ try {
693
+ const meta = await sharp2(input).metadata();
694
+ if (!meta.width || !meta.height || !meta.format) {
695
+ throw new ProcessingError("Could not extract image metadata", "sharp");
696
+ }
697
+ return {
698
+ width: meta.width,
699
+ height: meta.height,
700
+ format: meta.format,
701
+ space: meta.space,
702
+ channels: meta.channels,
703
+ depth: meta.depth,
704
+ density: meta.density,
705
+ hasAlpha: meta.hasAlpha,
706
+ orientation: meta.orientation,
707
+ exif: meta.exif ? this.parseExifBuffer(meta.exif) : void 0,
708
+ iptc: meta.iptc ? this.parseIptcBuffer(meta.iptc) : void 0,
709
+ xmp: meta.xmp ? this.parseXmpBuffer(meta.xmp) : void 0
710
+ };
711
+ } catch (error) {
712
+ if (error instanceof ProcessingError) throw error;
713
+ throw this.mapError(error, input);
714
+ }
715
+ }
716
+ async hash(input, algorithm = "perceptual") {
717
+ const sharp2 = await this.getSharp();
718
+ try {
719
+ if (algorithm === "md5" || algorithm === "sha256") {
720
+ const { createHash } = await import("node:crypto");
721
+ const buffer = await sharp2(input).toBuffer();
722
+ return createHash(algorithm).update(buffer).digest("hex");
723
+ }
724
+ const { data } = await sharp2(input).resize(8, 8, { fit: "fill" }).grayscale().raw().toBuffer({ resolveWithObject: true });
725
+ const avg = data.reduce((sum, val) => sum + val, 0) / data.length;
726
+ let hash = "";
727
+ for (const val of data) {
728
+ hash += val >= avg ? "1" : "0";
729
+ }
730
+ return BigInt(`0b${hash}`).toString(16).padStart(16, "0");
731
+ } catch (error) {
732
+ throw this.mapError(error, input);
733
+ }
734
+ }
735
+ async resize(input, output, options) {
736
+ const sharp2 = await this.getSharp();
737
+ try {
738
+ let pipeline = sharp2(input).resize({
739
+ width: options.width,
740
+ height: options.height,
741
+ fit: options.fit || "cover"
742
+ });
743
+ if (options.format) {
744
+ pipeline = pipeline.toFormat(options.format, {
745
+ quality: options.quality
746
+ });
747
+ } else if (options.quality) {
748
+ const format = this.inferFormat(output);
749
+ pipeline = pipeline.toFormat(format, { quality: options.quality });
750
+ }
751
+ await pipeline.toFile(output);
752
+ } catch (error) {
753
+ throw this.mapError(error, input);
754
+ }
755
+ }
756
+ /**
757
+ * Infer image format from file extension
758
+ */
759
+ inferFormat(path) {
760
+ const ext = path.split(".").pop()?.toLowerCase();
761
+ const map = {
762
+ jpg: "jpeg",
763
+ jpeg: "jpeg",
764
+ png: "png",
765
+ webp: "webp",
766
+ avif: "avif",
767
+ gif: "gif",
768
+ tiff: "tiff",
769
+ tif: "tiff"
770
+ };
771
+ return map[ext || ""] || "jpeg";
772
+ }
773
+ /**
774
+ * Parse EXIF buffer to object (basic parsing)
775
+ */
776
+ parseExifBuffer(buffer) {
777
+ return { raw: `${buffer.toString("base64").slice(0, 100)}...` };
778
+ }
779
+ /**
780
+ * Parse IPTC buffer to object (basic parsing)
781
+ */
782
+ parseIptcBuffer(buffer) {
783
+ return { raw: `${buffer.toString("base64").slice(0, 100)}...` };
784
+ }
785
+ /**
786
+ * Parse XMP buffer to object (basic parsing)
787
+ */
788
+ parseXmpBuffer(buffer) {
789
+ try {
790
+ const xmpString = buffer.toString("utf8");
791
+ return { raw: `${xmpString.slice(0, 500)}...` };
792
+ } catch {
793
+ return { raw: `${buffer.toString("base64").slice(0, 100)}...` };
794
+ }
795
+ }
796
+ /**
797
+ * Map sharp errors to our error types
798
+ */
799
+ mapError(error, input) {
800
+ const message = error instanceof Error ? error.message : String(error);
801
+ if (message.includes("Input file is missing")) {
802
+ const path = typeof input === "string" ? input : "<buffer>";
803
+ throw new ImageNotFoundError(path, "sharp");
804
+ }
805
+ if (message.includes("Input buffer contains unsupported image format")) {
806
+ throw new ProcessingError("Unsupported image format", "sharp");
807
+ }
808
+ return new ProcessingError(
809
+ `Image processing failed: ${message}`,
810
+ "sharp",
811
+ error instanceof Error ? error : void 0
812
+ );
813
+ }
814
+ }
815
+ const sharp = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
816
+ __proto__: null,
817
+ SharpAdapter
818
+ }, Symbol.toStringTag, { value: "Module" }));
819
+ let cachedFontData = null;
820
+ let cachedFontName = null;
821
+ async function loadGoogleFont(fontName) {
822
+ if (cachedFontData && cachedFontName === fontName) {
823
+ return cachedFontData;
824
+ }
825
+ const fontUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;600;700&display=swap`;
826
+ const cssResponse = await fetch(fontUrl, {
827
+ headers: {
828
+ // Use an old user agent to get WOFF format (Satori doesn't support woff2)
829
+ "User-Agent": "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)"
830
+ }
831
+ });
832
+ if (!cssResponse.ok) {
833
+ throw new Error(`Failed to fetch font CSS: ${cssResponse.statusText}`);
834
+ }
835
+ const css = await cssResponse.text();
836
+ let urlMatch = css.match(
837
+ /src:\s*url\(([^)]+)\)\s*format\(['"]truetype['"]\)/
838
+ );
839
+ if (!urlMatch) {
840
+ urlMatch = css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]woff['"]\)/);
841
+ }
842
+ if (!urlMatch) {
843
+ urlMatch = css.match(/src:\s*url\(([^)]+)\)(?!\s*format\(['"]woff2['"]\))/);
844
+ }
845
+ if (!urlMatch) {
846
+ throw new Error(
847
+ `Could not find compatible font URL in CSS for ${fontName}. CSS: ${css.slice(0, 300)}...`
848
+ );
849
+ }
850
+ const fontFileUrl = urlMatch[1];
851
+ const fontResponse = await fetch(fontFileUrl);
852
+ if (!fontResponse.ok) {
853
+ throw new Error(`Failed to fetch font file: ${fontResponse.statusText}`);
854
+ }
855
+ cachedFontData = await fontResponse.arrayBuffer();
856
+ cachedFontName = fontName;
857
+ return cachedFontData;
858
+ }
859
+ function renderDefaultTemplate({
860
+ title,
861
+ options
862
+ }) {
863
+ const {
864
+ width,
865
+ brandColor,
866
+ backgroundColor,
867
+ subtitle,
868
+ fontFamily
869
+ } = options;
870
+ return {
871
+ type: "div",
872
+ props: {
873
+ style: {
874
+ display: "flex",
875
+ flexDirection: "column",
876
+ justifyContent: "center",
877
+ width: "100%",
878
+ height: "100%",
879
+ backgroundColor,
880
+ padding: "60px",
881
+ fontFamily
882
+ },
883
+ children: [
884
+ // Top accent bar
885
+ {
886
+ type: "div",
887
+ props: {
888
+ style: {
889
+ position: "absolute",
890
+ top: 0,
891
+ left: 0,
892
+ right: 0,
893
+ height: "8px",
894
+ backgroundColor: brandColor
895
+ }
896
+ }
897
+ },
898
+ // Subtitle
899
+ subtitle ? {
900
+ type: "div",
901
+ props: {
902
+ style: {
903
+ fontSize: "24px",
904
+ fontWeight: 600,
905
+ color: brandColor,
906
+ marginBottom: "20px",
907
+ textTransform: "uppercase",
908
+ letterSpacing: "2px"
909
+ },
910
+ children: subtitle
911
+ }
912
+ } : null,
913
+ // Title
914
+ {
915
+ type: "div",
916
+ props: {
917
+ style: {
918
+ fontSize: Math.min(72, Math.floor(width / (title.length * 0.6))),
919
+ fontWeight: 700,
920
+ color: "#1e293b",
921
+ lineHeight: 1.2,
922
+ maxWidth: "90%"
923
+ },
924
+ children: title
925
+ }
926
+ },
927
+ // Bottom accent
928
+ {
929
+ type: "div",
930
+ props: {
931
+ style: {
932
+ position: "absolute",
933
+ bottom: "60px",
934
+ left: "60px",
935
+ width: "80px",
936
+ height: "4px",
937
+ backgroundColor: brandColor
938
+ }
939
+ }
940
+ }
941
+ ].filter(Boolean)
942
+ }
943
+ };
944
+ }
945
+ function renderNewsTemplate({ title, options }) {
946
+ const {
947
+ width,
948
+ brandColor,
949
+ backgroundColor,
950
+ subtitle,
951
+ fontFamily
952
+ } = options;
953
+ return {
954
+ type: "div",
955
+ props: {
956
+ style: {
957
+ display: "flex",
958
+ flexDirection: "row",
959
+ width: "100%",
960
+ height: "100%",
961
+ backgroundColor,
962
+ fontFamily
963
+ },
964
+ children: [
965
+ // Left accent bar
966
+ {
967
+ type: "div",
968
+ props: {
969
+ style: {
970
+ width: "16px",
971
+ height: "100%",
972
+ backgroundColor: brandColor
973
+ }
974
+ }
975
+ },
976
+ // Content area
977
+ {
978
+ type: "div",
979
+ props: {
980
+ style: {
981
+ display: "flex",
982
+ flexDirection: "column",
983
+ justifyContent: "center",
984
+ flex: 1,
985
+ padding: "60px"
986
+ },
987
+ children: [
988
+ // Category badge (using flex with alignSelf since Satori doesn't support inline-flex)
989
+ subtitle ? {
990
+ type: "div",
991
+ props: {
992
+ style: {
993
+ display: "flex",
994
+ alignSelf: "flex-start",
995
+ backgroundColor: brandColor,
996
+ color: "#ffffff",
997
+ fontSize: "18px",
998
+ fontWeight: 600,
999
+ padding: "8px 16px",
1000
+ borderRadius: "4px",
1001
+ marginBottom: "24px",
1002
+ textTransform: "uppercase",
1003
+ letterSpacing: "1px"
1004
+ },
1005
+ children: subtitle
1006
+ }
1007
+ } : null,
1008
+ // Title
1009
+ {
1010
+ type: "div",
1011
+ props: {
1012
+ style: {
1013
+ fontSize: Math.min(
1014
+ 64,
1015
+ Math.floor((width - 100) / (title.length * 0.5))
1016
+ ),
1017
+ fontWeight: 700,
1018
+ color: "#0f172a",
1019
+ lineHeight: 1.15
1020
+ },
1021
+ children: title
1022
+ }
1023
+ }
1024
+ ].filter(Boolean)
1025
+ }
1026
+ }
1027
+ ]
1028
+ }
1029
+ };
1030
+ }
1031
+ function renderMinimalTemplate({
1032
+ title,
1033
+ options
1034
+ }) {
1035
+ const { width, brandColor, backgroundColor, fontFamily } = options;
1036
+ return {
1037
+ type: "div",
1038
+ props: {
1039
+ style: {
1040
+ display: "flex",
1041
+ flexDirection: "column",
1042
+ alignItems: "center",
1043
+ justifyContent: "center",
1044
+ width: "100%",
1045
+ height: "100%",
1046
+ backgroundColor,
1047
+ padding: "80px",
1048
+ fontFamily
1049
+ },
1050
+ children: [
1051
+ // Decorative line
1052
+ {
1053
+ type: "div",
1054
+ props: {
1055
+ style: {
1056
+ width: "60px",
1057
+ height: "3px",
1058
+ backgroundColor: brandColor,
1059
+ marginBottom: "40px"
1060
+ }
1061
+ }
1062
+ },
1063
+ // Title
1064
+ {
1065
+ type: "div",
1066
+ props: {
1067
+ style: {
1068
+ fontSize: Math.min(56, Math.floor(width / (title.length * 0.55))),
1069
+ fontWeight: 600,
1070
+ color: "#334155",
1071
+ lineHeight: 1.3,
1072
+ textAlign: "center",
1073
+ maxWidth: "85%"
1074
+ },
1075
+ children: title
1076
+ }
1077
+ },
1078
+ // Decorative line
1079
+ {
1080
+ type: "div",
1081
+ props: {
1082
+ style: {
1083
+ width: "60px",
1084
+ height: "3px",
1085
+ backgroundColor: brandColor,
1086
+ marginTop: "40px"
1087
+ }
1088
+ }
1089
+ }
1090
+ ]
1091
+ }
1092
+ };
1093
+ }
1094
+ async function generateHeadlineCard(title, options = {}) {
1095
+ const width = options.width ?? 1200;
1096
+ const height = options.height ?? 630;
1097
+ const brandColor = options.brandColor ?? "#3b82f6";
1098
+ const backgroundColor = options.backgroundColor ?? "#ffffff";
1099
+ options.textColor ?? "#64748b";
1100
+ const template = options.template ?? "default";
1101
+ const fontFamily = options.fontFamily ?? "Inter";
1102
+ const resolvedOptions = {
1103
+ ...options,
1104
+ width,
1105
+ brandColor,
1106
+ backgroundColor,
1107
+ fontFamily
1108
+ };
1109
+ const [{ default: satori }, { Resvg }] = await Promise.all([
1110
+ import("satori"),
1111
+ import("@resvg/resvg-js")
1112
+ ]);
1113
+ const fontData = options.fontData ?? await loadGoogleFont(fontFamily);
1114
+ let content;
1115
+ switch (template) {
1116
+ case "news":
1117
+ content = renderNewsTemplate({ title, options: resolvedOptions });
1118
+ break;
1119
+ case "minimal":
1120
+ content = renderMinimalTemplate({ title, options: resolvedOptions });
1121
+ break;
1122
+ default:
1123
+ content = renderDefaultTemplate({ title, options: resolvedOptions });
1124
+ }
1125
+ const svg = await satori(content, {
1126
+ width,
1127
+ height,
1128
+ fonts: [
1129
+ {
1130
+ name: fontFamily,
1131
+ data: fontData,
1132
+ weight: 400,
1133
+ style: "normal"
1134
+ },
1135
+ {
1136
+ name: fontFamily,
1137
+ data: fontData,
1138
+ weight: 600,
1139
+ style: "normal"
1140
+ },
1141
+ {
1142
+ name: fontFamily,
1143
+ data: fontData,
1144
+ weight: 700,
1145
+ style: "normal"
1146
+ }
1147
+ ]
1148
+ });
1149
+ const resvg = new Resvg(svg, {
1150
+ fitTo: {
1151
+ mode: "width",
1152
+ value: width
1153
+ }
1154
+ });
1155
+ const pngData = resvg.render();
1156
+ const buffer = Buffer.from(pngData.asPng());
1157
+ return {
1158
+ buffer,
1159
+ width,
1160
+ height,
1161
+ mimeType: "image/png"
1162
+ };
1163
+ }
1164
+ function resetFontCache() {
1165
+ cachedFontData = null;
1166
+ cachedFontName = null;
1167
+ }
1168
+ function isSharpOptions(options) {
1169
+ return !options.type || options.type === "sharp";
1170
+ }
1171
+ function isJimpOptions(options) {
1172
+ return options.type === "jimp";
1173
+ }
1174
+ function isImgproxyOptions(options) {
1175
+ return options.type === "imgproxy";
1176
+ }
1177
+ function loadEnvConfig(options) {
1178
+ if (typeof process === "undefined" || !process.env) {
1179
+ return options;
1180
+ }
1181
+ const env = process.env;
1182
+ const envDefaults = {};
1183
+ const typeEnv = env.HAVE_IMAGES_TYPE || env.HAVE_IMAGES_ADAPTER;
1184
+ if (typeEnv && !options.type) {
1185
+ envDefaults.type = typeEnv;
1186
+ }
1187
+ if (env.HAVE_IMAGES_BASE_URL) {
1188
+ envDefaults.baseUrl = env.HAVE_IMAGES_BASE_URL;
1189
+ }
1190
+ if (env.HAVE_IMAGES_KEY) {
1191
+ envDefaults.key = env.HAVE_IMAGES_KEY;
1192
+ }
1193
+ if (env.HAVE_IMAGES_SALT) {
1194
+ envDefaults.salt = env.HAVE_IMAGES_SALT;
1195
+ }
1196
+ return { ...envDefaults, ...options };
1197
+ }
1198
+ async function getImageProcessor(options = {}) {
1199
+ options = loadEnvConfig(options);
1200
+ if (isSharpOptions(options)) {
1201
+ const { SharpAdapter: SharpAdapter2 } = await Promise.resolve().then(() => sharp);
1202
+ return new SharpAdapter2(options);
1203
+ }
1204
+ if (isJimpOptions(options)) {
1205
+ const { JimpAdapter: JimpAdapter2 } = await Promise.resolve().then(() => jimp);
1206
+ return new JimpAdapter2(options);
1207
+ }
1208
+ if (isImgproxyOptions(options)) {
1209
+ const { ImgproxyAdapter: ImgproxyAdapter2 } = await Promise.resolve().then(() => imgproxy);
1210
+ return new ImgproxyAdapter2(options);
1211
+ }
1212
+ throw new InvalidAdapterError(
1213
+ options.type || "unknown"
1214
+ );
1215
+ }
1216
+ function getAvailableAdapters() {
1217
+ return ["sharp", "jimp", "imgproxy"];
1218
+ }
1219
+ const factory = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
1220
+ __proto__: null,
1221
+ getAvailableAdapters,
1222
+ getImageProcessor
1223
+ }, Symbol.toStringTag, { value: "Module" }));
1224
+ let cachedProcessor = null;
1225
+ let detectionAttempted = false;
1226
+ async function getDefaultProcessor(adapterOptions) {
1227
+ if (adapterOptions?.type) {
1228
+ const { getImageProcessor: getImageProcessor2 } = await Promise.resolve().then(() => factory);
1229
+ return getImageProcessor2(adapterOptions);
1230
+ }
1231
+ if (cachedProcessor && !adapterOptions) {
1232
+ return cachedProcessor;
1233
+ }
1234
+ if (!detectionAttempted) {
1235
+ detectionAttempted = true;
1236
+ try {
1237
+ await import("sharp");
1238
+ const { SharpAdapter: SharpAdapter2 } = await Promise.resolve().then(() => sharp);
1239
+ cachedProcessor = new SharpAdapter2({});
1240
+ } catch {
1241
+ const { JimpAdapter: JimpAdapter2 } = await Promise.resolve().then(() => jimp);
1242
+ cachedProcessor = new JimpAdapter2({ type: "jimp" });
1243
+ }
1244
+ }
1245
+ if (!cachedProcessor) {
1246
+ throw new Error("No image processor available. Install sharp or jimp.");
1247
+ }
1248
+ return cachedProcessor;
1249
+ }
1250
+ function resetProcessor() {
1251
+ cachedProcessor = null;
1252
+ detectionAttempted = false;
1253
+ }
1254
+ async function getDimensions(input, adapterOptions) {
1255
+ const processor = await getDefaultProcessor(adapterOptions);
1256
+ return processor.getDimensions(input);
1257
+ }
1258
+ async function generateThumbnail(input, output, options, adapterOptions) {
1259
+ const processor = await getDefaultProcessor(adapterOptions);
1260
+ return processor.thumbnail(input, output, options);
1261
+ }
1262
+ async function convertFormat(input, output, options, adapterOptions) {
1263
+ const processor = await getDefaultProcessor(adapterOptions);
1264
+ return processor.convert(input, output, options);
1265
+ }
1266
+ async function getImageMetadata(input, adapterOptions) {
1267
+ const processor = await getDefaultProcessor(adapterOptions);
1268
+ return processor.getMetadata(input);
1269
+ }
1270
+ async function getImageHash(input, algorithm = "perceptual", adapterOptions) {
1271
+ const processor = await getDefaultProcessor(adapterOptions);
1272
+ return processor.hash(input, algorithm);
1273
+ }
1274
+ async function resizeImage(input, output, options, adapterOptions) {
1275
+ const processor = await getDefaultProcessor(adapterOptions);
1276
+ return processor.resize(input, output, options);
1277
+ }
1278
+ export {
1279
+ ImageError,
1280
+ ImageNotFoundError,
1281
+ ImgproxyAdapter,
1282
+ InvalidAdapterError,
1283
+ JimpAdapter,
1284
+ OperationNotSupportedError,
1285
+ ProcessingError,
1286
+ RemoteServiceError,
1287
+ SharpAdapter,
1288
+ UnsupportedFormatError,
1289
+ convertFormat,
1290
+ generateHeadlineCard,
1291
+ generateThumbnail,
1292
+ getAvailableAdapters,
1293
+ getDimensions,
1294
+ getImageHash,
1295
+ getImageMetadata,
1296
+ getImageProcessor,
1297
+ resetFontCache,
1298
+ resetProcessor,
1299
+ resizeImage,
1300
+ signImgproxyUrl
1301
+ };
1302
+ //# sourceMappingURL=index.js.map