cross-image 0.2.2 → 0.2.4

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 (88) hide show
  1. package/README.md +333 -168
  2. package/esm/mod.d.ts +2 -0
  3. package/esm/mod.js +2 -0
  4. package/esm/src/formats/apng.d.ts +13 -1
  5. package/esm/src/formats/apng.js +97 -0
  6. package/esm/src/formats/ascii.d.ts +11 -1
  7. package/esm/src/formats/ascii.js +24 -0
  8. package/esm/src/formats/avif.d.ts +96 -0
  9. package/esm/src/formats/avif.js +607 -0
  10. package/esm/src/formats/bmp.d.ts +11 -1
  11. package/esm/src/formats/bmp.js +73 -0
  12. package/esm/src/formats/dng.d.ts +13 -1
  13. package/esm/src/formats/dng.js +26 -4
  14. package/esm/src/formats/gif.d.ts +15 -2
  15. package/esm/src/formats/gif.js +146 -4
  16. package/esm/src/formats/heic.d.ts +96 -0
  17. package/esm/src/formats/heic.js +608 -0
  18. package/esm/src/formats/ico.d.ts +11 -1
  19. package/esm/src/formats/ico.js +28 -0
  20. package/esm/src/formats/jpeg.d.ts +19 -1
  21. package/esm/src/formats/jpeg.js +709 -4
  22. package/esm/src/formats/pam.d.ts +11 -1
  23. package/esm/src/formats/pam.js +66 -0
  24. package/esm/src/formats/pcx.d.ts +11 -1
  25. package/esm/src/formats/pcx.js +45 -0
  26. package/esm/src/formats/png.d.ts +13 -1
  27. package/esm/src/formats/png.js +87 -0
  28. package/esm/src/formats/png_base.d.ts +8 -0
  29. package/esm/src/formats/png_base.js +176 -3
  30. package/esm/src/formats/ppm.d.ts +11 -1
  31. package/esm/src/formats/ppm.js +34 -0
  32. package/esm/src/formats/tiff.d.ts +13 -1
  33. package/esm/src/formats/tiff.js +165 -0
  34. package/esm/src/formats/webp.d.ts +16 -2
  35. package/esm/src/formats/webp.js +303 -62
  36. package/esm/src/image.d.ts +60 -0
  37. package/esm/src/image.js +253 -5
  38. package/esm/src/types.d.ts +59 -1
  39. package/esm/src/utils/image_processing.d.ts +55 -0
  40. package/esm/src/utils/image_processing.js +210 -0
  41. package/esm/src/utils/metadata/xmp.d.ts +52 -0
  42. package/esm/src/utils/metadata/xmp.js +325 -0
  43. package/esm/src/utils/resize.d.ts +4 -0
  44. package/esm/src/utils/resize.js +74 -0
  45. package/package.json +18 -1
  46. package/script/mod.d.ts +2 -0
  47. package/script/mod.js +5 -1
  48. package/script/src/formats/apng.d.ts +13 -1
  49. package/script/src/formats/apng.js +97 -0
  50. package/script/src/formats/ascii.d.ts +11 -1
  51. package/script/src/formats/ascii.js +24 -0
  52. package/script/src/formats/avif.d.ts +96 -0
  53. package/script/src/formats/avif.js +611 -0
  54. package/script/src/formats/bmp.d.ts +11 -1
  55. package/script/src/formats/bmp.js +73 -0
  56. package/script/src/formats/dng.d.ts +13 -1
  57. package/script/src/formats/dng.js +26 -4
  58. package/script/src/formats/gif.d.ts +15 -2
  59. package/script/src/formats/gif.js +146 -4
  60. package/script/src/formats/heic.d.ts +96 -0
  61. package/script/src/formats/heic.js +612 -0
  62. package/script/src/formats/ico.d.ts +11 -1
  63. package/script/src/formats/ico.js +28 -0
  64. package/script/src/formats/jpeg.d.ts +19 -1
  65. package/script/src/formats/jpeg.js +709 -4
  66. package/script/src/formats/pam.d.ts +11 -1
  67. package/script/src/formats/pam.js +66 -0
  68. package/script/src/formats/pcx.d.ts +11 -1
  69. package/script/src/formats/pcx.js +45 -0
  70. package/script/src/formats/png.d.ts +13 -1
  71. package/script/src/formats/png.js +87 -0
  72. package/script/src/formats/png_base.d.ts +8 -0
  73. package/script/src/formats/png_base.js +176 -3
  74. package/script/src/formats/ppm.d.ts +11 -1
  75. package/script/src/formats/ppm.js +34 -0
  76. package/script/src/formats/tiff.d.ts +13 -1
  77. package/script/src/formats/tiff.js +165 -0
  78. package/script/src/formats/webp.d.ts +16 -2
  79. package/script/src/formats/webp.js +303 -62
  80. package/script/src/image.d.ts +60 -0
  81. package/script/src/image.js +251 -3
  82. package/script/src/types.d.ts +59 -1
  83. package/script/src/utils/image_processing.d.ts +55 -0
  84. package/script/src/utils/image_processing.js +216 -0
  85. package/script/src/utils/metadata/xmp.d.ts +52 -0
  86. package/script/src/utils/metadata/xmp.js +333 -0
  87. package/script/src/utils/resize.d.ts +4 -0
  88. package/script/src/utils/resize.js +75 -0
@@ -932,4 +932,169 @@ export class TIFFFormat {
932
932
  }
933
933
  return rgba;
934
934
  }
935
+ /**
936
+ * Get list of metadata fields supported by TIFF format
937
+ * TIFF supports extensive EXIF metadata including GPS and InteropIFD
938
+ */
939
+ getSupportedMetadata() {
940
+ return [
941
+ "creationDate",
942
+ "description",
943
+ "author",
944
+ "copyright",
945
+ "cameraMake",
946
+ "cameraModel",
947
+ "orientation",
948
+ "software",
949
+ "iso",
950
+ "exposureTime",
951
+ "fNumber",
952
+ "focalLength",
953
+ "flash",
954
+ "whiteBalance",
955
+ "lensMake",
956
+ "lensModel",
957
+ "userComment",
958
+ "latitude",
959
+ "longitude",
960
+ "dpiX",
961
+ "dpiY",
962
+ "physicalWidth",
963
+ "physicalHeight",
964
+ ];
965
+ }
966
+ /**
967
+ * Extract metadata from TIFF data without fully decoding the pixel data
968
+ * This quickly parses IFD entries to extract metadata
969
+ * @param data Raw TIFF data
970
+ * @returns Extracted metadata or undefined
971
+ */
972
+ extractMetadata(data) {
973
+ if (!this.canDecode(data)) {
974
+ return Promise.resolve(undefined);
975
+ }
976
+ // Determine byte order
977
+ const isLittleEndian = data[0] === 0x49 && data[1] === 0x49;
978
+ if (!isLittleEndian && !(data[0] === 0x4d && data[1] === 0x4d)) {
979
+ return Promise.resolve(undefined);
980
+ }
981
+ // Read IFD offset
982
+ const ifdOffset = this.readUint32(data, 4, isLittleEndian);
983
+ if (ifdOffset >= data.length) {
984
+ return Promise.resolve(undefined);
985
+ }
986
+ // Get dimensions for DPI calculation
987
+ const width = this.getIFDValue(data, ifdOffset, 0x0100, isLittleEndian);
988
+ const height = this.getIFDValue(data, ifdOffset, 0x0101, isLittleEndian);
989
+ if (!width || !height) {
990
+ return Promise.resolve(undefined);
991
+ }
992
+ // Extract metadata from TIFF tags
993
+ const metadata = {
994
+ format: "tiff",
995
+ frameCount: 1,
996
+ bitDepth: 8,
997
+ };
998
+ // Get compression type
999
+ const compression = this.getIFDValue(data, ifdOffset, 0x0103, isLittleEndian);
1000
+ if (compression === 1) {
1001
+ metadata.compression = "none";
1002
+ }
1003
+ else if (compression === 5) {
1004
+ metadata.compression = "lzw";
1005
+ }
1006
+ else if (compression === 7) {
1007
+ metadata.compression = "jpeg";
1008
+ }
1009
+ else if (compression === 32773) {
1010
+ metadata.compression = "packbits";
1011
+ }
1012
+ else if (compression) {
1013
+ metadata.compression = `unknown-${compression}`;
1014
+ }
1015
+ // Get bits per sample
1016
+ const bitsPerSample = this.getIFDValue(data, ifdOffset, 0x0102, isLittleEndian);
1017
+ if (bitsPerSample) {
1018
+ metadata.bitDepth = bitsPerSample;
1019
+ }
1020
+ // Get photometric interpretation for color type
1021
+ const photometric = this.getIFDValue(data, ifdOffset, 0x0106, isLittleEndian);
1022
+ const samplesPerPixel = this.getIFDValue(data, ifdOffset, 0x0115, isLittleEndian);
1023
+ if (photometric === 0 || photometric === 1) {
1024
+ metadata.colorType = "grayscale";
1025
+ }
1026
+ else if (photometric === 2) {
1027
+ if (samplesPerPixel === 3) {
1028
+ metadata.colorType = "rgb";
1029
+ }
1030
+ else if (samplesPerPixel === 4) {
1031
+ metadata.colorType = "rgba";
1032
+ }
1033
+ }
1034
+ else if (photometric === 3) {
1035
+ metadata.colorType = "indexed";
1036
+ }
1037
+ // Count IFDs (pages/frames) by following the chain
1038
+ let currentIfdOffset = ifdOffset;
1039
+ let frameCount = 0;
1040
+ while (currentIfdOffset > 0 && currentIfdOffset < data.length &&
1041
+ frameCount < 1000) {
1042
+ frameCount++;
1043
+ // Read number of entries in this IFD
1044
+ const numEntries = this.readUint16(data, currentIfdOffset, isLittleEndian);
1045
+ // Next IFD offset is after all entries (2 + numEntries * 12 bytes)
1046
+ const nextIfdOffsetPos = currentIfdOffset + 2 + (numEntries * 12);
1047
+ if (nextIfdOffsetPos + 4 > data.length)
1048
+ break;
1049
+ currentIfdOffset = this.readUint32(data, nextIfdOffsetPos, isLittleEndian);
1050
+ }
1051
+ metadata.frameCount = frameCount;
1052
+ // XResolution (0x011a) and YResolution (0x011b) for DPI
1053
+ const xResOffset = this.getIFDValue(data, ifdOffset, 0x011a, isLittleEndian);
1054
+ const yResOffset = this.getIFDValue(data, ifdOffset, 0x011b, isLittleEndian);
1055
+ if (xResOffset && xResOffset < data.length - 8) {
1056
+ const numerator = this.readUint32(data, xResOffset, isLittleEndian);
1057
+ const denominator = this.readUint32(data, xResOffset + 4, isLittleEndian);
1058
+ if (denominator > 0) {
1059
+ metadata.dpiX = Math.round(numerator / denominator);
1060
+ }
1061
+ }
1062
+ if (yResOffset && yResOffset < data.length - 8) {
1063
+ const numerator = this.readUint32(data, yResOffset, isLittleEndian);
1064
+ const denominator = this.readUint32(data, yResOffset + 4, isLittleEndian);
1065
+ if (denominator > 0) {
1066
+ metadata.dpiY = Math.round(numerator / denominator);
1067
+ }
1068
+ }
1069
+ // Calculate physical dimensions if DPI is available
1070
+ if (metadata.dpiX && metadata.dpiY) {
1071
+ metadata.physicalWidth = width / metadata.dpiX;
1072
+ metadata.physicalHeight = height / metadata.dpiY;
1073
+ }
1074
+ // ImageDescription (0x010e)
1075
+ const descOffset = this.getIFDValue(data, ifdOffset, 0x010e, isLittleEndian);
1076
+ if (descOffset && descOffset < data.length) {
1077
+ metadata.description = this.readString(data, descOffset);
1078
+ }
1079
+ // Artist (0x013b)
1080
+ const artistOffset = this.getIFDValue(data, ifdOffset, 0x013b, isLittleEndian);
1081
+ if (artistOffset && artistOffset < data.length) {
1082
+ metadata.author = this.readString(data, artistOffset);
1083
+ }
1084
+ // Copyright (0x8298)
1085
+ const copyrightOffset = this.getIFDValue(data, ifdOffset, 0x8298, isLittleEndian);
1086
+ if (copyrightOffset && copyrightOffset < data.length) {
1087
+ metadata.copyright = this.readString(data, copyrightOffset);
1088
+ }
1089
+ // DateTime (0x0132)
1090
+ const dateTimeOffset = this.getIFDValue(data, ifdOffset, 0x0132, isLittleEndian);
1091
+ if (dateTimeOffset && dateTimeOffset < data.length) {
1092
+ const dateStr = this.readString(data, dateTimeOffset);
1093
+ const match = dateStr.match(/^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
1094
+ if (match) {
1095
+ metadata.creationDate = new Date(parseInt(match[1]), parseInt(match[2]) - 1, parseInt(match[3]), parseInt(match[4]), parseInt(match[5]), parseInt(match[6]));
1096
+ }
1097
+ }
1098
+ return Promise.resolve(Object.keys(metadata).length > 0 ? metadata : undefined);
1099
+ }
935
1100
  }
@@ -1,4 +1,4 @@
1
- import type { ImageData, ImageFormat, WebPEncodeOptions } from "../types.js";
1
+ import type { ImageData, ImageFormat, ImageMetadata, WebPEncodeOptions } from "../types.js";
2
2
  /**
3
3
  * WebP format handler
4
4
  * Implements a basic WebP decoder and encoder
@@ -30,10 +30,24 @@ export declare class WebPFormat implements ImageFormat {
30
30
  private readUint24LE;
31
31
  private decodeUsingRuntime;
32
32
  private parseEXIF;
33
+ private parseGPSIFD;
34
+ private readRational;
33
35
  private parseXMP;
34
36
  private injectMetadata;
35
37
  private createEXIFChunk;
38
+ private createGPSIFD;
39
+ private writeRational;
36
40
  private createXMPChunk;
37
- private escapeXML;
41
+ /**
42
+ * Get the list of metadata fields supported by WebP format
43
+ */
44
+ getSupportedMetadata(): Array<keyof ImageMetadata>;
45
+ /**
46
+ * Extract metadata from WebP data without fully decoding the pixel data
47
+ * This quickly parses RIFF chunks to extract EXIF and XMP metadata
48
+ * @param data Raw WebP data
49
+ * @returns Extracted metadata or undefined
50
+ */
51
+ extractMetadata(data: Uint8Array): Promise<ImageMetadata | undefined>;
38
52
  }
39
53
  //# sourceMappingURL=webp.d.ts.map
@@ -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,120 @@ 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
+ ];
569
+ }
570
+ /**
571
+ * Extract metadata from WebP data without fully decoding the pixel data
572
+ * This quickly parses RIFF chunks to extract EXIF and XMP metadata
573
+ * @param data Raw WebP data
574
+ * @returns Extracted metadata or undefined
575
+ */
576
+ extractMetadata(data) {
577
+ if (!this.canDecode(data)) {
578
+ return Promise.resolve(undefined);
579
+ }
580
+ const metadata = {
581
+ format: "webp",
582
+ frameCount: 1,
583
+ bitDepth: 8,
584
+ };
585
+ let pos = 12; // Skip "RIFF" + size + "WEBP"
586
+ const readUint32LE = (data, offset) => {
587
+ return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) |
588
+ (data[offset + 3] << 24);
589
+ };
590
+ const readUint16LE = (data, offset) => {
591
+ return data[offset] | (data[offset + 1] << 8);
592
+ };
593
+ // Parse chunks for metadata
594
+ while (pos + 8 <= data.length) {
595
+ const chunkType = String.fromCharCode(data[pos], data[pos + 1], data[pos + 2], data[pos + 3]);
596
+ const chunkSize = readUint32LE(data, pos + 4);
597
+ pos += 8;
598
+ // Stop if we've gone past the end
599
+ if (pos + chunkSize > data.length)
600
+ break;
601
+ const chunkData = data.slice(pos, pos + chunkSize);
602
+ if (chunkType === "VP8 ") {
603
+ // Lossy VP8 chunk
604
+ metadata.compression = "vp8";
605
+ metadata.colorType = "rgb";
606
+ }
607
+ else if (chunkType === "VP8L") {
608
+ // Lossless VP8L chunk
609
+ metadata.compression = "vp8l";
610
+ metadata.colorType = "rgba";
611
+ }
612
+ else if (chunkType === "VP8X") {
613
+ // Extended format chunk - contains animation info
614
+ if (chunkData.length >= 10) {
615
+ const flags = chunkData[0];
616
+ const _hasAnimation = (flags & 0x02) !== 0;
617
+ const hasAlpha = (flags & 0x10) !== 0;
618
+ if (hasAlpha) {
619
+ metadata.colorType = "rgba";
620
+ }
621
+ else {
622
+ metadata.colorType = "rgb";
623
+ }
624
+ // Animation is handled in ANIM chunk
625
+ }
626
+ }
627
+ else if (chunkType === "ANIM") {
628
+ // Animation parameters chunk
629
+ if (chunkData.length >= 6) {
630
+ // Background color at bytes 0-3
631
+ // Loop count at bytes 4-5
632
+ const _loopCount = readUint16LE(chunkData, 4);
633
+ // Note: Frame count is not directly in ANIM chunk, need to count ANMF chunks
634
+ // Reset frame count to 0 to start counting ANMF frames
635
+ metadata.frameCount = 0;
636
+ }
637
+ }
638
+ else if (chunkType === "ANMF") {
639
+ // Animation frame - count frames
640
+ if (metadata.frameCount !== undefined) {
641
+ metadata.frameCount++;
642
+ }
643
+ else {
644
+ metadata.frameCount = 1;
645
+ }
646
+ }
647
+ else if (chunkType === "EXIF") {
648
+ // EXIF metadata chunk
649
+ this.parseEXIF(chunkData, metadata);
650
+ }
651
+ else if (chunkType === "XMP ") {
652
+ // XMP metadata chunk
653
+ this.parseXMP(chunkData, metadata);
654
+ }
655
+ pos += chunkSize;
656
+ // Chunks are padded to even length
657
+ if (chunkSize % 2 === 1)
658
+ pos++;
659
+ }
660
+ return Promise.resolve(Object.keys(metadata).length > 0 ? metadata : undefined);
420
661
  }
421
662
  }