cross-image 0.2.2 → 0.2.3

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.
Files changed (36) hide show
  1. package/README.md +128 -7
  2. package/esm/src/formats/jpeg.d.ts +12 -1
  3. package/esm/src/formats/jpeg.js +633 -4
  4. package/esm/src/formats/png_base.d.ts +8 -0
  5. package/esm/src/formats/png_base.js +176 -3
  6. package/esm/src/formats/tiff.d.ts +6 -1
  7. package/esm/src/formats/tiff.js +31 -0
  8. package/esm/src/formats/webp.d.ts +9 -2
  9. package/esm/src/formats/webp.js +211 -62
  10. package/esm/src/image.d.ts +51 -0
  11. package/esm/src/image.js +225 -5
  12. package/esm/src/types.d.ts +41 -1
  13. package/esm/src/utils/image_processing.d.ts +55 -0
  14. package/esm/src/utils/image_processing.js +210 -0
  15. package/esm/src/utils/metadata/xmp.d.ts +52 -0
  16. package/esm/src/utils/metadata/xmp.js +325 -0
  17. package/esm/src/utils/resize.d.ts +4 -0
  18. package/esm/src/utils/resize.js +74 -0
  19. package/package.json +1 -1
  20. package/script/src/formats/jpeg.d.ts +12 -1
  21. package/script/src/formats/jpeg.js +633 -4
  22. package/script/src/formats/png_base.d.ts +8 -0
  23. package/script/src/formats/png_base.js +176 -3
  24. package/script/src/formats/tiff.d.ts +6 -1
  25. package/script/src/formats/tiff.js +31 -0
  26. package/script/src/formats/webp.d.ts +9 -2
  27. package/script/src/formats/webp.js +211 -62
  28. package/script/src/image.d.ts +51 -0
  29. package/script/src/image.js +223 -3
  30. package/script/src/types.d.ts +41 -1
  31. package/script/src/utils/image_processing.d.ts +55 -0
  32. package/script/src/utils/image_processing.js +216 -0
  33. package/script/src/utils/metadata/xmp.d.ts +52 -0
  34. package/script/src/utils/metadata/xmp.js +333 -0
  35. package/script/src/utils/resize.d.ts +4 -0
  36. package/script/src/utils/resize.js +75 -0
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.WebPFormat = void 0;
37
37
  const security_js_1 = require("../utils/security.js");
38
38
  const byte_utils_js_1 = require("../utils/byte_utils.js");
39
+ const xmp_js_1 = require("../utils/metadata/xmp.js");
39
40
  // Default quality for WebP encoding when not specified
40
41
  const DEFAULT_WEBP_QUALITY = 90;
41
42
  /**
@@ -255,6 +256,7 @@ class WebPFormat {
255
256
  const numEntries = littleEndian
256
257
  ? data[ifd0Offset] | (data[ifd0Offset + 1] << 8)
257
258
  : (data[ifd0Offset] << 8) | data[ifd0Offset + 1];
259
+ let gpsIfdOffset = 0;
258
260
  // Parse basic EXIF tags (simplified version)
259
261
  for (let i = 0; i < numEntries && i < 50; i++) {
260
262
  const entryOffset = ifd0Offset + 2 + i * 12;
@@ -281,32 +283,103 @@ class WebPFormat {
281
283
  }
282
284
  }
283
285
  }
286
+ // GPS IFD Pointer tag (0x8825)
287
+ if (tag === 0x8825) {
288
+ gpsIfdOffset = littleEndian
289
+ ? data[entryOffset + 8] | (data[entryOffset + 9] << 8) |
290
+ (data[entryOffset + 10] << 16) | (data[entryOffset + 11] << 24)
291
+ : (data[entryOffset + 8] << 24) | (data[entryOffset + 9] << 16) |
292
+ (data[entryOffset + 10] << 8) | data[entryOffset + 11];
293
+ }
294
+ }
295
+ // Parse GPS IFD if present
296
+ if (gpsIfdOffset > 0 && gpsIfdOffset + 2 <= data.length) {
297
+ this.parseGPSIFD(data, gpsIfdOffset, littleEndian, metadata);
284
298
  }
285
299
  }
286
300
  catch (_e) {
287
301
  // Ignore EXIF parsing errors
288
302
  }
289
303
  }
304
+ parseGPSIFD(data, gpsIfdOffset, littleEndian, metadata) {
305
+ try {
306
+ const numEntries = littleEndian
307
+ ? data[gpsIfdOffset] | (data[gpsIfdOffset + 1] << 8)
308
+ : (data[gpsIfdOffset] << 8) | data[gpsIfdOffset + 1];
309
+ let latRef = "";
310
+ let lonRef = "";
311
+ let latitude;
312
+ let longitude;
313
+ for (let i = 0; i < numEntries; i++) {
314
+ const entryOffset = gpsIfdOffset + 2 + i * 12;
315
+ if (entryOffset + 12 > data.length)
316
+ break;
317
+ const tag = littleEndian
318
+ ? data[entryOffset] | (data[entryOffset + 1] << 8)
319
+ : (data[entryOffset] << 8) | data[entryOffset + 1];
320
+ const type = littleEndian
321
+ ? data[entryOffset + 2] | (data[entryOffset + 3] << 8)
322
+ : (data[entryOffset + 2] << 8) | data[entryOffset + 3];
323
+ const valueOffset = littleEndian
324
+ ? data[entryOffset + 8] | (data[entryOffset + 9] << 8) |
325
+ (data[entryOffset + 10] << 16) | (data[entryOffset + 11] << 24)
326
+ : (data[entryOffset + 8] << 24) | (data[entryOffset + 9] << 16) |
327
+ (data[entryOffset + 10] << 8) | data[entryOffset + 11];
328
+ // GPSLatitudeRef (0x0001)
329
+ if (tag === 0x0001 && type === 2) {
330
+ latRef = String.fromCharCode(data[entryOffset + 8]);
331
+ }
332
+ // GPSLatitude (0x0002)
333
+ if (tag === 0x0002 && type === 5 && valueOffset + 24 <= data.length) {
334
+ const degrees = this.readRational(data, valueOffset, littleEndian);
335
+ const minutes = this.readRational(data, valueOffset + 8, littleEndian);
336
+ const seconds = this.readRational(data, valueOffset + 16, littleEndian);
337
+ latitude = degrees + minutes / 60 + seconds / 3600;
338
+ }
339
+ // GPSLongitudeRef (0x0003)
340
+ if (tag === 0x0003 && type === 2) {
341
+ lonRef = String.fromCharCode(data[entryOffset + 8]);
342
+ }
343
+ // GPSLongitude (0x0004)
344
+ if (tag === 0x0004 && type === 5 && valueOffset + 24 <= data.length) {
345
+ const degrees = this.readRational(data, valueOffset, littleEndian);
346
+ const minutes = this.readRational(data, valueOffset + 8, littleEndian);
347
+ const seconds = this.readRational(data, valueOffset + 16, littleEndian);
348
+ longitude = degrees + minutes / 60 + seconds / 3600;
349
+ }
350
+ }
351
+ // Apply hemisphere references
352
+ if (latitude !== undefined && latRef) {
353
+ metadata.latitude = latRef === "S" ? -latitude : latitude;
354
+ }
355
+ if (longitude !== undefined && lonRef) {
356
+ metadata.longitude = lonRef === "W" ? -longitude : longitude;
357
+ }
358
+ }
359
+ catch (_e) {
360
+ // Ignore GPS parsing errors
361
+ }
362
+ }
363
+ readRational(data, offset, littleEndian) {
364
+ const numerator = littleEndian
365
+ ? data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) |
366
+ (data[offset + 3] << 24)
367
+ : (data[offset] << 24) | (data[offset + 1] << 16) |
368
+ (data[offset + 2] << 8) | data[offset + 3];
369
+ const denominator = littleEndian
370
+ ? data[offset + 4] | (data[offset + 5] << 8) | (data[offset + 6] << 16) |
371
+ (data[offset + 7] << 24)
372
+ : (data[offset + 4] << 24) | (data[offset + 5] << 16) |
373
+ (data[offset + 6] << 8) | data[offset + 7];
374
+ return denominator !== 0 ? numerator / denominator : 0;
375
+ }
290
376
  parseXMP(data, metadata) {
291
- // XMP is XML-based metadata - simple parsing for common fields
377
+ // Parse XMP using the centralized utility
292
378
  try {
293
379
  const xmpStr = new TextDecoder().decode(data);
294
- // Extract title
295
- const titleMatch = xmpStr.match(/<dc:title[^>]*>([^<]+)<\/dc:title>/);
296
- if (titleMatch)
297
- metadata.title = titleMatch[1].trim();
298
- // Extract description
299
- const descMatch = xmpStr.match(/<dc:description[^>]*>([^<]+)<\/dc:description>/);
300
- if (descMatch)
301
- metadata.description = descMatch[1].trim();
302
- // Extract creator/author
303
- const creatorMatch = xmpStr.match(/<dc:creator[^>]*>([^<]+)<\/dc:creator>/);
304
- if (creatorMatch)
305
- metadata.author = creatorMatch[1].trim();
306
- // Extract rights/copyright
307
- const rightsMatch = xmpStr.match(/<dc:rights[^>]*>([^<]+)<\/dc:rights>/);
308
- if (rightsMatch)
309
- metadata.copyright = rightsMatch[1].trim();
380
+ const parsedMetadata = (0, xmp_js_1.parseXMP)(xmpStr);
381
+ // Merge parsed metadata into the existing metadata object
382
+ Object.assign(metadata, parsedMetadata);
310
383
  }
311
384
  catch (_e) {
312
385
  // Ignore XMP parsing errors
@@ -320,8 +393,9 @@ class WebPFormat {
320
393
  chunks.push(webpData.slice(0, 12));
321
394
  // Create metadata chunks
322
395
  const metadataChunks = [];
323
- // Create EXIF chunk if we have date or other EXIF data
324
- if (metadata.creationDate) {
396
+ // Create EXIF chunk if we have date or GPS data
397
+ if (metadata.creationDate ||
398
+ (metadata.latitude !== undefined && metadata.longitude !== undefined)) {
325
399
  const exifData = this.createEXIFChunk(metadata);
326
400
  if (exifData) {
327
401
  metadataChunks.push(exifData);
@@ -377,7 +451,10 @@ class WebPFormat {
377
451
  return finalData;
378
452
  }
379
453
  createEXIFChunk(metadata) {
380
- if (!metadata.creationDate)
454
+ const hasDate = metadata.creationDate !== undefined;
455
+ const hasGPS = metadata.latitude !== undefined &&
456
+ metadata.longitude !== undefined;
457
+ if (!hasDate && !hasGPS)
381
458
  return null;
382
459
  const exifData = [];
383
460
  // Byte order marker (little endian)
@@ -385,21 +462,47 @@ class WebPFormat {
385
462
  exifData.push(0x2a, 0x00); // 42
386
463
  // IFD0 offset
387
464
  exifData.push(0x08, 0x00, 0x00, 0x00);
388
- // Number of entries
389
- exifData.push(0x01, 0x00);
390
- // DateTime entry
391
- const date = metadata.creationDate;
392
- const dateStr = `${date.getFullYear()}:${String(date.getMonth() + 1).padStart(2, "0")}:${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}\0`;
393
- const dateBytes = new TextEncoder().encode(dateStr);
394
- // Tag 0x0132, Type 2 (ASCII), Count, Offset
395
- exifData.push(0x32, 0x01, 0x02, 0x00);
396
- exifData.push(dateBytes.length & 0xff, (dateBytes.length >> 8) & 0xff, (dateBytes.length >> 16) & 0xff, (dateBytes.length >> 24) & 0xff);
397
- exifData.push(0x12, 0x00, 0x00, 0x00); // Offset to data
465
+ // Number of entries (DateTime + GPS IFD pointer if needed)
466
+ const numEntries = (hasDate ? 1 : 0) + (hasGPS ? 1 : 0);
467
+ exifData.push(numEntries & 0xff, (numEntries >> 8) & 0xff);
468
+ let dataOffset = 8 + 2 + numEntries * 12 + 4;
469
+ // DateTime entry (if present)
470
+ if (hasDate) {
471
+ const date = metadata.creationDate;
472
+ const dateStr = `${date.getFullYear()}:${String(date.getMonth() + 1).padStart(2, "0")}:${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}\0`;
473
+ const dateBytes = new TextEncoder().encode(dateStr);
474
+ // Tag 0x0132, Type 2 (ASCII), Count, Offset
475
+ exifData.push(0x32, 0x01, 0x02, 0x00);
476
+ exifData.push(dateBytes.length & 0xff, (dateBytes.length >> 8) & 0xff, (dateBytes.length >> 16) & 0xff, (dateBytes.length >> 24) & 0xff);
477
+ exifData.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
478
+ dataOffset += dateBytes.length;
479
+ }
480
+ // GPS IFD pointer (if present)
481
+ let gpsIfdOffset = 0;
482
+ if (hasGPS) {
483
+ gpsIfdOffset = dataOffset;
484
+ exifData.push(0x25, 0x88); // Tag 0x8825
485
+ exifData.push(0x04, 0x00); // Type LONG
486
+ exifData.push(0x01, 0x00, 0x00, 0x00); // Count 1
487
+ exifData.push(gpsIfdOffset & 0xff, (gpsIfdOffset >> 8) & 0xff, (gpsIfdOffset >> 16) & 0xff, (gpsIfdOffset >> 24) & 0xff);
488
+ }
398
489
  // Next IFD
399
490
  exifData.push(0x00, 0x00, 0x00, 0x00);
400
- // Date string data
401
- for (const byte of dateBytes) {
402
- exifData.push(byte);
491
+ // Date string data (if present)
492
+ if (hasDate) {
493
+ const date = metadata.creationDate;
494
+ const dateStr = `${date.getFullYear()}:${String(date.getMonth() + 1).padStart(2, "0")}:${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}\0`;
495
+ const dateBytes = new TextEncoder().encode(dateStr);
496
+ for (const byte of dateBytes) {
497
+ exifData.push(byte);
498
+ }
499
+ }
500
+ // GPS IFD data (if present)
501
+ if (hasGPS) {
502
+ const gpsIfd = this.createGPSIFD(metadata, gpsIfdOffset);
503
+ for (const byte of gpsIfd) {
504
+ exifData.push(byte);
505
+ }
403
506
  }
404
507
  // Create chunk header
405
508
  const chunkData = new Uint8Array(exifData);
@@ -412,29 +515,59 @@ class WebPFormat {
412
515
  chunk.set(chunkData, 8);
413
516
  return chunk;
414
517
  }
518
+ createGPSIFD(metadata, gpsIfdStart) {
519
+ const gps = [];
520
+ const numEntries = 4;
521
+ gps.push(numEntries & 0xff, (numEntries >> 8) & 0xff);
522
+ const latitude = metadata.latitude;
523
+ const longitude = metadata.longitude;
524
+ const absLat = Math.abs(latitude);
525
+ const absLon = Math.abs(longitude);
526
+ const latDeg = Math.floor(absLat);
527
+ const latMin = Math.floor((absLat - latDeg) * 60);
528
+ const latSec = ((absLat - latDeg) * 60 - latMin) * 60;
529
+ const lonDeg = Math.floor(absLon);
530
+ const lonMin = Math.floor((absLon - lonDeg) * 60);
531
+ const lonSec = ((absLon - lonDeg) * 60 - lonMin) * 60;
532
+ let dataOffset = gpsIfdStart + 2 + numEntries * 12 + 4;
533
+ // GPSLatitudeRef
534
+ gps.push(0x01, 0x00);
535
+ gps.push(0x02, 0x00);
536
+ gps.push(0x02, 0x00, 0x00, 0x00);
537
+ gps.push(latitude >= 0 ? 78 : 83, 0x00, 0x00, 0x00);
538
+ // GPSLatitude
539
+ gps.push(0x02, 0x00);
540
+ gps.push(0x05, 0x00);
541
+ gps.push(0x03, 0x00, 0x00, 0x00);
542
+ gps.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
543
+ dataOffset += 24;
544
+ // GPSLongitudeRef
545
+ gps.push(0x03, 0x00);
546
+ gps.push(0x02, 0x00);
547
+ gps.push(0x02, 0x00, 0x00, 0x00);
548
+ gps.push(longitude >= 0 ? 69 : 87, 0x00, 0x00, 0x00);
549
+ // GPSLongitude
550
+ gps.push(0x04, 0x00);
551
+ gps.push(0x05, 0x00);
552
+ gps.push(0x03, 0x00, 0x00, 0x00);
553
+ gps.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
554
+ gps.push(0x00, 0x00, 0x00, 0x00);
555
+ // Write rationals
556
+ this.writeRational(gps, latDeg, 1);
557
+ this.writeRational(gps, latMin, 1);
558
+ this.writeRational(gps, Math.round(latSec * 1000000), 1000000);
559
+ this.writeRational(gps, lonDeg, 1);
560
+ this.writeRational(gps, lonMin, 1);
561
+ this.writeRational(gps, Math.round(lonSec * 1000000), 1000000);
562
+ return gps;
563
+ }
564
+ writeRational(output, numerator, denominator) {
565
+ output.push(numerator & 0xff, (numerator >> 8) & 0xff, (numerator >> 16) & 0xff, (numerator >> 24) & 0xff);
566
+ output.push(denominator & 0xff, (denominator >> 8) & 0xff, (denominator >> 16) & 0xff, (denominator >> 24) & 0xff);
567
+ }
415
568
  createXMPChunk(metadata) {
416
- const xmpParts = [];
417
- xmpParts.push('<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>');
418
- xmpParts.push('<x:xmpmeta xmlns:x="adobe:ns:meta/">');
419
- xmpParts.push('<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">');
420
- xmpParts.push('<rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/">');
421
- if (metadata.title) {
422
- xmpParts.push(`<dc:title>${this.escapeXML(metadata.title)}</dc:title>`);
423
- }
424
- if (metadata.description) {
425
- xmpParts.push(`<dc:description>${this.escapeXML(metadata.description)}</dc:description>`);
426
- }
427
- if (metadata.author) {
428
- xmpParts.push(`<dc:creator>${this.escapeXML(metadata.author)}</dc:creator>`);
429
- }
430
- if (metadata.copyright) {
431
- xmpParts.push(`<dc:rights>${this.escapeXML(metadata.copyright)}</dc:rights>`);
432
- }
433
- xmpParts.push("</rdf:Description>");
434
- xmpParts.push("</rdf:RDF>");
435
- xmpParts.push("</x:xmpmeta>");
436
- xmpParts.push('<?xpacket end="w"?>');
437
- const xmpStr = xmpParts.join("\n");
569
+ // Use the centralized XMP utility to create the XMP packet
570
+ const xmpStr = (0, xmp_js_1.createXMP)(metadata);
438
571
  const xmpData = new TextEncoder().encode(xmpStr);
439
572
  // Create chunk
440
573
  const chunk = new Uint8Array(8 + xmpData.length);
@@ -446,13 +579,29 @@ class WebPFormat {
446
579
  chunk.set(xmpData, 8);
447
580
  return chunk;
448
581
  }
449
- escapeXML(str) {
450
- return str
451
- .replace(/&/g, "&amp;")
452
- .replace(/</g, "&lt;")
453
- .replace(/>/g, "&gt;")
454
- .replace(/"/g, "&quot;")
455
- .replace(/'/g, "&apos;");
582
+ /**
583
+ * Get the list of metadata fields supported by WebP format
584
+ */
585
+ getSupportedMetadata() {
586
+ return [
587
+ // EXIF chunk
588
+ "creationDate",
589
+ "latitude",
590
+ "longitude",
591
+ // XMP chunk (enhanced support)
592
+ "title",
593
+ "description",
594
+ "author",
595
+ "copyright",
596
+ "cameraMake",
597
+ "cameraModel",
598
+ "orientation",
599
+ "software",
600
+ "iso",
601
+ "exposureTime",
602
+ "fNumber",
603
+ "focalLength",
604
+ ];
456
605
  }
457
606
  }
458
607
  exports.WebPFormat = WebPFormat;
@@ -79,6 +79,12 @@ export declare class Image {
79
79
  * @returns Image instance
80
80
  */
81
81
  static decode(data: Uint8Array, format?: string): Promise<Image>;
82
+ /**
83
+ * Get supported metadata fields for a specific format
84
+ * @param format Format name (e.g., "jpeg", "png", "webp")
85
+ * @returns Array of supported metadata field names, or undefined if format doesn't support metadata
86
+ */
87
+ static getSupportedMetadata(format: string): Array<keyof ImageMetadata> | undefined;
82
88
  /**
83
89
  * Read an image from bytes
84
90
  * @deprecated Use `decode()` instead. This method will be removed in a future version.
@@ -197,6 +203,12 @@ export declare class Image {
197
203
  * @returns This image instance for chaining
198
204
  */
199
205
  saturation(amount: number): this;
206
+ /**
207
+ * Adjust hue of the image by rotating the color wheel
208
+ * @param degrees Hue rotation in degrees (any value accepted, wraps at 360)
209
+ * @returns This image instance for chaining
210
+ */
211
+ hue(degrees: number): this;
200
212
  /**
201
213
  * Invert colors of the image
202
214
  * @returns This image instance for chaining
@@ -282,5 +294,44 @@ export declare class Image {
282
294
  * @returns This image instance for chaining
283
295
  */
284
296
  setPixel(x: number, y: number, r: number, g: number, b: number, a?: number): this;
297
+ /**
298
+ * Rotate the image 90 degrees clockwise
299
+ * @returns This image instance for chaining
300
+ */
301
+ rotate90(): this;
302
+ /**
303
+ * Rotate the image 180 degrees
304
+ * @returns This image instance for chaining
305
+ */
306
+ rotate180(): this;
307
+ /**
308
+ * Rotate the image 270 degrees clockwise (or 90 degrees counter-clockwise)
309
+ * @returns This image instance for chaining
310
+ */
311
+ rotate270(): this;
312
+ /**
313
+ * Rotate the image by the specified angle in degrees
314
+ * @param degrees Rotation angle in degrees (positive = clockwise, negative = counter-clockwise)
315
+ * @returns This image instance for chaining
316
+ *
317
+ * @example
318
+ * ```ts
319
+ * image.rotate(90); // Rotate 90° clockwise
320
+ * image.rotate(-90); // Rotate 90° counter-clockwise
321
+ * image.rotate(180); // Rotate 180°
322
+ * image.rotate(45); // Rotate 45° clockwise (rounded to nearest 90°)
323
+ * ```
324
+ */
325
+ rotate(degrees: number): this;
326
+ /**
327
+ * Flip the image horizontally (mirror)
328
+ * @returns This image instance for chaining
329
+ */
330
+ flipHorizontal(): this;
331
+ /**
332
+ * Flip the image vertically
333
+ * @returns This image instance for chaining
334
+ */
335
+ flipVertical(): this;
285
336
  }
286
337
  //# sourceMappingURL=image.d.ts.map
@@ -184,6 +184,18 @@ class Image {
184
184
  }
185
185
  throw new Error("Unsupported or unrecognized image format");
186
186
  }
187
+ /**
188
+ * Get supported metadata fields for a specific format
189
+ * @param format Format name (e.g., "jpeg", "png", "webp")
190
+ * @returns Array of supported metadata field names, or undefined if format doesn't support metadata
191
+ */
192
+ static getSupportedMetadata(format) {
193
+ const formatHandler = Image.formats.find((f) => f.name === format.toLowerCase());
194
+ if (!formatHandler) {
195
+ throw new Error(`Unknown image format: ${format}`);
196
+ }
197
+ return formatHandler.getSupportedMetadata?.();
198
+ }
187
199
  /**
188
200
  * Read an image from bytes
189
201
  * @deprecated Use `decode()` instead. This method will be removed in a future version.
@@ -304,19 +316,98 @@ class Image {
304
316
  resize(options) {
305
317
  if (!this.imageData)
306
318
  throw new Error("No image loaded");
307
- const { width, height, method = "bilinear" } = options;
319
+ const { width, height, method = "bilinear", fit = "stretch" } = options;
308
320
  // Validate new dimensions for security (prevent integer overflow and heap exhaustion)
309
321
  (0, security_js_1.validateImageDimensions)(width, height);
310
322
  const { data: srcData, width: srcWidth, height: srcHeight } = this.imageData;
323
+ // Handle fitting modes
324
+ let targetWidth = width;
325
+ let targetHeight = height;
326
+ let shouldCenter = false;
327
+ const fitMode = fit === "contain" ? "fit" : fit === "cover" ? "fill" : fit;
328
+ if (fitMode === "fit" || fitMode === "fill") {
329
+ const srcAspect = srcWidth / srcHeight;
330
+ const targetAspect = width / height;
331
+ if (fitMode === "fit") {
332
+ // Fit within dimensions (letterbox)
333
+ if (srcAspect > targetAspect) {
334
+ // Source is wider - fit to width
335
+ targetWidth = width;
336
+ targetHeight = Math.round(width / srcAspect);
337
+ }
338
+ else {
339
+ // Source is taller - fit to height
340
+ targetWidth = Math.round(height * srcAspect);
341
+ targetHeight = height;
342
+ }
343
+ shouldCenter = true;
344
+ }
345
+ else {
346
+ // Fill dimensions (crop)
347
+ if (srcAspect > targetAspect) {
348
+ // Source is wider - fit to height and crop width
349
+ targetWidth = Math.round(height * srcAspect);
350
+ targetHeight = height;
351
+ }
352
+ else {
353
+ // Source is taller - fit to width and crop height
354
+ targetWidth = width;
355
+ targetHeight = Math.round(width / srcAspect);
356
+ }
357
+ shouldCenter = true;
358
+ }
359
+ }
360
+ // Perform the resize
311
361
  let resizedData;
312
362
  if (method === "nearest") {
313
- resizedData = (0, resize_js_1.resizeNearest)(srcData, srcWidth, srcHeight, width, height);
363
+ resizedData = (0, resize_js_1.resizeNearest)(srcData, srcWidth, srcHeight, targetWidth, targetHeight);
364
+ }
365
+ else if (method === "bicubic") {
366
+ resizedData = (0, resize_js_1.resizeBicubic)(srcData, srcWidth, srcHeight, targetWidth, targetHeight);
314
367
  }
315
368
  else {
316
- resizedData = (0, resize_js_1.resizeBilinear)(srcData, srcWidth, srcHeight, width, height);
369
+ resizedData = (0, resize_js_1.resizeBilinear)(srcData, srcWidth, srcHeight, targetWidth, targetHeight);
317
370
  }
318
371
  // Preserve metadata when resizing
319
372
  const metadata = this.imageData.metadata;
373
+ // If we need to center (fit mode) or crop (fill mode), create a canvas
374
+ if (shouldCenter && (targetWidth !== width || targetHeight !== height)) {
375
+ const canvas = new Uint8Array(width * height * 4);
376
+ // Fill with transparent black by default
377
+ canvas.fill(0);
378
+ if (fitMode === "fit") {
379
+ // Center the resized image (letterbox)
380
+ const offsetX = Math.floor((width - targetWidth) / 2);
381
+ const offsetY = Math.floor((height - targetHeight) / 2);
382
+ for (let y = 0; y < targetHeight; y++) {
383
+ for (let x = 0; x < targetWidth; x++) {
384
+ const srcIdx = (y * targetWidth + x) * 4;
385
+ const dstIdx = ((y + offsetY) * width + (x + offsetX)) * 4;
386
+ canvas[dstIdx] = resizedData[srcIdx];
387
+ canvas[dstIdx + 1] = resizedData[srcIdx + 1];
388
+ canvas[dstIdx + 2] = resizedData[srcIdx + 2];
389
+ canvas[dstIdx + 3] = resizedData[srcIdx + 3];
390
+ }
391
+ }
392
+ resizedData = canvas;
393
+ }
394
+ else {
395
+ // Crop to fill (center crop)
396
+ const offsetX = Math.floor((targetWidth - width) / 2);
397
+ const offsetY = Math.floor((targetHeight - height) / 2);
398
+ for (let y = 0; y < height; y++) {
399
+ for (let x = 0; x < width; x++) {
400
+ const srcIdx = ((y + offsetY) * targetWidth + (x + offsetX)) * 4;
401
+ const dstIdx = (y * width + x) * 4;
402
+ canvas[dstIdx] = resizedData[srcIdx];
403
+ canvas[dstIdx + 1] = resizedData[srcIdx + 1];
404
+ canvas[dstIdx + 2] = resizedData[srcIdx + 2];
405
+ canvas[dstIdx + 3] = resizedData[srcIdx + 3];
406
+ }
407
+ }
408
+ resizedData = canvas;
409
+ }
410
+ }
320
411
  this.imageData = {
321
412
  width,
322
413
  height,
@@ -442,6 +533,17 @@ class Image {
442
533
  this.imageData.data = (0, image_processing_js_1.adjustSaturation)(this.imageData.data, amount);
443
534
  return this;
444
535
  }
536
+ /**
537
+ * Adjust hue of the image by rotating the color wheel
538
+ * @param degrees Hue rotation in degrees (any value accepted, wraps at 360)
539
+ * @returns This image instance for chaining
540
+ */
541
+ hue(degrees) {
542
+ if (!this.imageData)
543
+ throw new Error("No image loaded");
544
+ this.imageData.data = (0, image_processing_js_1.adjustHue)(this.imageData.data, degrees);
545
+ return this;
546
+ }
445
547
  /**
446
548
  * Invert colors of the image
447
549
  * @returns This image instance for chaining
@@ -605,6 +707,124 @@ class Image {
605
707
  this.imageData.data[idx + 3] = a;
606
708
  return this;
607
709
  }
710
+ /**
711
+ * Rotate the image 90 degrees clockwise
712
+ * @returns This image instance for chaining
713
+ */
714
+ rotate90() {
715
+ if (!this.imageData)
716
+ throw new Error("No image loaded");
717
+ const result = (0, image_processing_js_1.rotate90)(this.imageData.data, this.imageData.width, this.imageData.height);
718
+ this.imageData.width = result.width;
719
+ this.imageData.height = result.height;
720
+ this.imageData.data = result.data;
721
+ // Update physical dimensions if DPI is set
722
+ if (this.imageData.metadata) {
723
+ const metadata = this.imageData.metadata;
724
+ if (metadata.dpiX && metadata.dpiY) {
725
+ // Swap physical dimensions
726
+ const tempPhysical = metadata.physicalWidth;
727
+ this.imageData.metadata.physicalWidth = metadata.physicalHeight;
728
+ this.imageData.metadata.physicalHeight = tempPhysical;
729
+ // Swap DPI
730
+ const tempDpi = metadata.dpiX;
731
+ this.imageData.metadata.dpiX = metadata.dpiY;
732
+ this.imageData.metadata.dpiY = tempDpi;
733
+ }
734
+ }
735
+ return this;
736
+ }
737
+ /**
738
+ * Rotate the image 180 degrees
739
+ * @returns This image instance for chaining
740
+ */
741
+ rotate180() {
742
+ if (!this.imageData)
743
+ throw new Error("No image loaded");
744
+ this.imageData.data = (0, image_processing_js_1.rotate180)(this.imageData.data, this.imageData.width, this.imageData.height);
745
+ return this;
746
+ }
747
+ /**
748
+ * Rotate the image 270 degrees clockwise (or 90 degrees counter-clockwise)
749
+ * @returns This image instance for chaining
750
+ */
751
+ rotate270() {
752
+ if (!this.imageData)
753
+ throw new Error("No image loaded");
754
+ const result = (0, image_processing_js_1.rotate270)(this.imageData.data, this.imageData.width, this.imageData.height);
755
+ this.imageData.width = result.width;
756
+ this.imageData.height = result.height;
757
+ this.imageData.data = result.data;
758
+ // Update physical dimensions if DPI is set
759
+ if (this.imageData.metadata) {
760
+ const metadata = this.imageData.metadata;
761
+ if (metadata.dpiX && metadata.dpiY) {
762
+ // Swap physical dimensions
763
+ const tempPhysical = metadata.physicalWidth;
764
+ this.imageData.metadata.physicalWidth = metadata.physicalHeight;
765
+ this.imageData.metadata.physicalHeight = tempPhysical;
766
+ // Swap DPI
767
+ const tempDpi = metadata.dpiX;
768
+ this.imageData.metadata.dpiX = metadata.dpiY;
769
+ this.imageData.metadata.dpiY = tempDpi;
770
+ }
771
+ }
772
+ return this;
773
+ }
774
+ /**
775
+ * Rotate the image by the specified angle in degrees
776
+ * @param degrees Rotation angle in degrees (positive = clockwise, negative = counter-clockwise)
777
+ * @returns This image instance for chaining
778
+ *
779
+ * @example
780
+ * ```ts
781
+ * image.rotate(90); // Rotate 90° clockwise
782
+ * image.rotate(-90); // Rotate 90° counter-clockwise
783
+ * image.rotate(180); // Rotate 180°
784
+ * image.rotate(45); // Rotate 45° clockwise (rounded to nearest 90°)
785
+ * ```
786
+ */
787
+ rotate(degrees) {
788
+ // Normalize to 0-360 range
789
+ let normalizedDegrees = degrees % 360;
790
+ if (normalizedDegrees < 0) {
791
+ normalizedDegrees += 360;
792
+ }
793
+ // Round to nearest 90 degrees
794
+ const rounded = Math.round(normalizedDegrees / 90) * 90;
795
+ // Apply rotation based on rounded value
796
+ switch (rounded % 360) {
797
+ case 90:
798
+ return this.rotate90();
799
+ case 180:
800
+ return this.rotate180();
801
+ case 270:
802
+ return this.rotate270();
803
+ default:
804
+ // 0 or 360 degrees - no rotation needed
805
+ return this;
806
+ }
807
+ }
808
+ /**
809
+ * Flip the image horizontally (mirror)
810
+ * @returns This image instance for chaining
811
+ */
812
+ flipHorizontal() {
813
+ if (!this.imageData)
814
+ throw new Error("No image loaded");
815
+ this.imageData.data = (0, image_processing_js_1.flipHorizontal)(this.imageData.data, this.imageData.width, this.imageData.height);
816
+ return this;
817
+ }
818
+ /**
819
+ * Flip the image vertically
820
+ * @returns This image instance for chaining
821
+ */
822
+ flipVertical() {
823
+ if (!this.imageData)
824
+ throw new Error("No image loaded");
825
+ this.imageData.data = (0, image_processing_js_1.flipVertical)(this.imageData.data, this.imageData.width, this.imageData.height);
826
+ return this;
827
+ }
608
828
  }
609
829
  exports.Image = Image;
610
830
  Object.defineProperty(Image, "formats", {