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