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
@@ -269,6 +269,7 @@ export class JPEGFormat {
269
269
  const numEntries = littleEndian
270
270
  ? exifData[ifd0Offset] | (exifData[ifd0Offset + 1] << 8)
271
271
  : (exifData[ifd0Offset] << 8) | exifData[ifd0Offset + 1];
272
+ let gpsIfdOffset = 0;
272
273
  // Parse entries
273
274
  for (let i = 0; i < numEntries; i++) {
274
275
  const entryOffset = ifd0Offset + 2 + i * 12;
@@ -345,12 +346,306 @@ export class JPEGFormat {
345
346
  }
346
347
  }
347
348
  }
349
+ // Make tag (0x010F)
350
+ if (tag === 0x010f) {
351
+ const valueOffset = littleEndian
352
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
353
+ (exifData[entryOffset + 10] << 16) |
354
+ (exifData[entryOffset + 11] << 24)
355
+ : (exifData[entryOffset + 8] << 24) |
356
+ (exifData[entryOffset + 9] << 16) |
357
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
358
+ if (valueOffset < exifData.length) {
359
+ const endIndex = exifData.indexOf(0, valueOffset);
360
+ if (endIndex > valueOffset) {
361
+ metadata.cameraMake = new TextDecoder().decode(exifData.slice(valueOffset, endIndex));
362
+ }
363
+ }
364
+ }
365
+ // Model tag (0x0110)
366
+ if (tag === 0x0110) {
367
+ const valueOffset = littleEndian
368
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
369
+ (exifData[entryOffset + 10] << 16) |
370
+ (exifData[entryOffset + 11] << 24)
371
+ : (exifData[entryOffset + 8] << 24) |
372
+ (exifData[entryOffset + 9] << 16) |
373
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
374
+ if (valueOffset < exifData.length) {
375
+ const endIndex = exifData.indexOf(0, valueOffset);
376
+ if (endIndex > valueOffset) {
377
+ metadata.cameraModel = new TextDecoder().decode(exifData.slice(valueOffset, endIndex));
378
+ }
379
+ }
380
+ }
381
+ // Orientation tag (0x0112)
382
+ if (tag === 0x0112) {
383
+ const value = littleEndian
384
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8)
385
+ : (exifData[entryOffset + 8] << 8) | exifData[entryOffset + 9];
386
+ metadata.orientation = value;
387
+ }
388
+ // Software tag (0x0131)
389
+ if (tag === 0x0131) {
390
+ const valueOffset = littleEndian
391
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
392
+ (exifData[entryOffset + 10] << 16) |
393
+ (exifData[entryOffset + 11] << 24)
394
+ : (exifData[entryOffset + 8] << 24) |
395
+ (exifData[entryOffset + 9] << 16) |
396
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
397
+ if (valueOffset < exifData.length) {
398
+ const endIndex = exifData.indexOf(0, valueOffset);
399
+ if (endIndex > valueOffset) {
400
+ metadata.software = new TextDecoder().decode(exifData.slice(valueOffset, endIndex));
401
+ }
402
+ }
403
+ }
404
+ // GPS IFD Pointer tag (0x8825)
405
+ if (tag === 0x8825) {
406
+ gpsIfdOffset = littleEndian
407
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
408
+ (exifData[entryOffset + 10] << 16) |
409
+ (exifData[entryOffset + 11] << 24)
410
+ : (exifData[entryOffset + 8] << 24) |
411
+ (exifData[entryOffset + 9] << 16) |
412
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
413
+ }
414
+ // ExifIFD Pointer tag (0x8769) - points to EXIF Sub-IFD
415
+ const type = littleEndian
416
+ ? exifData[entryOffset + 2] | (exifData[entryOffset + 3] << 8)
417
+ : (exifData[entryOffset + 2] << 8) | exifData[entryOffset + 3];
418
+ if (tag === 0x8769 && type === 4) {
419
+ const exifIfdOffset = littleEndian
420
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
421
+ (exifData[entryOffset + 10] << 16) |
422
+ (exifData[entryOffset + 11] << 24)
423
+ : (exifData[entryOffset + 8] << 24) |
424
+ (exifData[entryOffset + 9] << 16) |
425
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
426
+ if (exifIfdOffset > 0 && exifIfdOffset + 2 <= exifData.length) {
427
+ this.parseExifSubIFD(exifData, exifIfdOffset, littleEndian, metadata);
428
+ }
429
+ }
430
+ }
431
+ // Parse GPS IFD if present
432
+ if (gpsIfdOffset > 0 && gpsIfdOffset + 2 <= exifData.length) {
433
+ this.parseGPSIFD(exifData, gpsIfdOffset, littleEndian, metadata);
348
434
  }
349
435
  }
350
436
  catch (_e) {
351
437
  // Ignore EXIF parsing errors
352
438
  }
353
439
  }
440
+ parseExifSubIFD(exifData, exifIfdOffset, littleEndian, metadata) {
441
+ try {
442
+ const numEntries = littleEndian
443
+ ? exifData[exifIfdOffset] | (exifData[exifIfdOffset + 1] << 8)
444
+ : (exifData[exifIfdOffset] << 8) | exifData[exifIfdOffset + 1];
445
+ for (let i = 0; i < numEntries; i++) {
446
+ const entryOffset = exifIfdOffset + 2 + i * 12;
447
+ if (entryOffset + 12 > exifData.length)
448
+ break;
449
+ const tag = littleEndian
450
+ ? exifData[entryOffset] | (exifData[entryOffset + 1] << 8)
451
+ : (exifData[entryOffset] << 8) | exifData[entryOffset + 1];
452
+ const type = littleEndian
453
+ ? exifData[entryOffset + 2] | (exifData[entryOffset + 3] << 8)
454
+ : (exifData[entryOffset + 2] << 8) | exifData[entryOffset + 3];
455
+ // ExposureTime tag (0x829A) - RATIONAL
456
+ if (tag === 0x829a && type === 5) {
457
+ const valueOffset = littleEndian
458
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
459
+ (exifData[entryOffset + 10] << 16) |
460
+ (exifData[entryOffset + 11] << 24)
461
+ : (exifData[entryOffset + 8] << 24) |
462
+ (exifData[entryOffset + 9] << 16) |
463
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
464
+ if (valueOffset + 8 <= exifData.length) {
465
+ metadata.exposureTime = this.readRational(exifData, valueOffset, littleEndian);
466
+ }
467
+ }
468
+ // FNumber tag (0x829D) - RATIONAL
469
+ if (tag === 0x829d && type === 5) {
470
+ const valueOffset = littleEndian
471
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
472
+ (exifData[entryOffset + 10] << 16) |
473
+ (exifData[entryOffset + 11] << 24)
474
+ : (exifData[entryOffset + 8] << 24) |
475
+ (exifData[entryOffset + 9] << 16) |
476
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
477
+ if (valueOffset + 8 <= exifData.length) {
478
+ metadata.fNumber = this.readRational(exifData, valueOffset, littleEndian);
479
+ }
480
+ }
481
+ // ISOSpeedRatings tag (0x8827) - SHORT
482
+ if (tag === 0x8827 && type === 3) {
483
+ metadata.iso = littleEndian
484
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8)
485
+ : (exifData[entryOffset + 8] << 8) | exifData[entryOffset + 9];
486
+ }
487
+ // FocalLength tag (0x920A) - RATIONAL
488
+ if (tag === 0x920a && type === 5) {
489
+ const valueOffset = littleEndian
490
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
491
+ (exifData[entryOffset + 10] << 16) |
492
+ (exifData[entryOffset + 11] << 24)
493
+ : (exifData[entryOffset + 8] << 24) |
494
+ (exifData[entryOffset + 9] << 16) |
495
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
496
+ if (valueOffset + 8 <= exifData.length) {
497
+ metadata.focalLength = this.readRational(exifData, valueOffset, littleEndian);
498
+ }
499
+ }
500
+ // UserComment tag (0x9286) - UNDEFINED
501
+ if (tag === 0x9286) {
502
+ const count = littleEndian
503
+ ? exifData[entryOffset + 4] | (exifData[entryOffset + 5] << 8) |
504
+ (exifData[entryOffset + 6] << 16) |
505
+ (exifData[entryOffset + 7] << 24)
506
+ : (exifData[entryOffset + 4] << 24) |
507
+ (exifData[entryOffset + 5] << 16) |
508
+ (exifData[entryOffset + 6] << 8) | exifData[entryOffset + 7];
509
+ if (count > 8) {
510
+ const valueOffset = littleEndian
511
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
512
+ (exifData[entryOffset + 10] << 16) |
513
+ (exifData[entryOffset + 11] << 24)
514
+ : (exifData[entryOffset + 8] << 24) |
515
+ (exifData[entryOffset + 9] << 16) |
516
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
517
+ if (valueOffset + count <= exifData.length) {
518
+ // Skip 8-byte character code prefix
519
+ const commentData = exifData.slice(valueOffset + 8, valueOffset + count);
520
+ metadata.userComment = new TextDecoder().decode(commentData)
521
+ .replace(/\0+$/, "");
522
+ }
523
+ }
524
+ }
525
+ // Flash tag (0x9209) - SHORT
526
+ if (tag === 0x9209 && type === 3) {
527
+ metadata.flash = littleEndian
528
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8)
529
+ : (exifData[entryOffset + 8] << 8) | exifData[entryOffset + 9];
530
+ }
531
+ // WhiteBalance tag (0xA403) - SHORT
532
+ if (tag === 0xa403 && type === 3) {
533
+ metadata.whiteBalance = littleEndian
534
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8)
535
+ : (exifData[entryOffset + 8] << 8) | exifData[entryOffset + 9];
536
+ }
537
+ // LensMake tag (0xA433) - ASCII
538
+ if (tag === 0xa433 && type === 2) {
539
+ const valueOffset = littleEndian
540
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
541
+ (exifData[entryOffset + 10] << 16) |
542
+ (exifData[entryOffset + 11] << 24)
543
+ : (exifData[entryOffset + 8] << 24) |
544
+ (exifData[entryOffset + 9] << 16) |
545
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
546
+ if (valueOffset < exifData.length) {
547
+ const endIndex = exifData.indexOf(0, valueOffset);
548
+ if (endIndex > valueOffset) {
549
+ metadata.lensMake = new TextDecoder().decode(exifData.slice(valueOffset, endIndex));
550
+ }
551
+ }
552
+ }
553
+ // LensModel tag (0xA434) - ASCII
554
+ if (tag === 0xa434 && type === 2) {
555
+ const valueOffset = littleEndian
556
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
557
+ (exifData[entryOffset + 10] << 16) |
558
+ (exifData[entryOffset + 11] << 24)
559
+ : (exifData[entryOffset + 8] << 24) |
560
+ (exifData[entryOffset + 9] << 16) |
561
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
562
+ if (valueOffset < exifData.length) {
563
+ const endIndex = exifData.indexOf(0, valueOffset);
564
+ if (endIndex > valueOffset) {
565
+ metadata.lensModel = new TextDecoder().decode(exifData.slice(valueOffset, endIndex));
566
+ }
567
+ }
568
+ }
569
+ }
570
+ }
571
+ catch (_e) {
572
+ // Ignore EXIF Sub-IFD parsing errors
573
+ }
574
+ }
575
+ parseGPSIFD(exifData, gpsIfdOffset, littleEndian, metadata) {
576
+ try {
577
+ const numEntries = littleEndian
578
+ ? exifData[gpsIfdOffset] | (exifData[gpsIfdOffset + 1] << 8)
579
+ : (exifData[gpsIfdOffset] << 8) | exifData[gpsIfdOffset + 1];
580
+ let latRef = "";
581
+ let lonRef = "";
582
+ let latitude;
583
+ let longitude;
584
+ for (let i = 0; i < numEntries; i++) {
585
+ const entryOffset = gpsIfdOffset + 2 + i * 12;
586
+ if (entryOffset + 12 > exifData.length)
587
+ break;
588
+ const tag = littleEndian
589
+ ? exifData[entryOffset] | (exifData[entryOffset + 1] << 8)
590
+ : (exifData[entryOffset] << 8) | exifData[entryOffset + 1];
591
+ const type = littleEndian
592
+ ? exifData[entryOffset + 2] | (exifData[entryOffset + 3] << 8)
593
+ : (exifData[entryOffset + 2] << 8) | exifData[entryOffset + 3];
594
+ const valueOffset = littleEndian
595
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
596
+ (exifData[entryOffset + 10] << 16) |
597
+ (exifData[entryOffset + 11] << 24)
598
+ : (exifData[entryOffset + 8] << 24) |
599
+ (exifData[entryOffset + 9] << 16) |
600
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
601
+ // GPSLatitudeRef (0x0001) - 'N' or 'S'
602
+ if (tag === 0x0001 && type === 2) {
603
+ latRef = String.fromCharCode(exifData[entryOffset + 8]);
604
+ }
605
+ // GPSLatitude (0x0002) - three rationals: degrees, minutes, seconds
606
+ if (tag === 0x0002 && type === 5 && valueOffset + 24 <= exifData.length) {
607
+ const degrees = this.readRational(exifData, valueOffset, littleEndian);
608
+ const minutes = this.readRational(exifData, valueOffset + 8, littleEndian);
609
+ const seconds = this.readRational(exifData, valueOffset + 16, littleEndian);
610
+ latitude = degrees + minutes / 60 + seconds / 3600;
611
+ }
612
+ // GPSLongitudeRef (0x0003) - 'E' or 'W'
613
+ if (tag === 0x0003 && type === 2) {
614
+ lonRef = String.fromCharCode(exifData[entryOffset + 8]);
615
+ }
616
+ // GPSLongitude (0x0004) - three rationals: degrees, minutes, seconds
617
+ if (tag === 0x0004 && type === 5 && valueOffset + 24 <= exifData.length) {
618
+ const degrees = this.readRational(exifData, valueOffset, littleEndian);
619
+ const minutes = this.readRational(exifData, valueOffset + 8, littleEndian);
620
+ const seconds = this.readRational(exifData, valueOffset + 16, littleEndian);
621
+ longitude = degrees + minutes / 60 + seconds / 3600;
622
+ }
623
+ }
624
+ // Apply hemisphere references
625
+ if (latitude !== undefined && latRef) {
626
+ metadata.latitude = latRef === "S" ? -latitude : latitude;
627
+ }
628
+ if (longitude !== undefined && lonRef) {
629
+ metadata.longitude = lonRef === "W" ? -longitude : longitude;
630
+ }
631
+ }
632
+ catch (_e) {
633
+ // Ignore GPS parsing errors
634
+ }
635
+ }
636
+ readRational(data, offset, littleEndian) {
637
+ const numerator = littleEndian
638
+ ? data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) |
639
+ (data[offset + 3] << 24)
640
+ : (data[offset] << 24) | (data[offset + 1] << 16) |
641
+ (data[offset + 2] << 8) | data[offset + 3];
642
+ const denominator = littleEndian
643
+ ? data[offset + 4] | (data[offset + 5] << 8) | (data[offset + 6] << 16) |
644
+ (data[offset + 7] << 24)
645
+ : (data[offset + 4] << 24) | (data[offset + 5] << 16) |
646
+ (data[offset + 6] << 8) | data[offset + 7];
647
+ return denominator !== 0 ? numerator / denominator : 0;
648
+ }
354
649
  createEXIFData(metadata) {
355
650
  const entries = [];
356
651
  // Add DateTime if available
@@ -387,7 +682,55 @@ export class JPEGFormat {
387
682
  value: new TextEncoder().encode(metadata.copyright + "\0"),
388
683
  });
389
684
  }
390
- if (entries.length === 0)
685
+ // Add Make (camera manufacturer)
686
+ if (metadata.cameraMake) {
687
+ entries.push({
688
+ tag: 0x010f,
689
+ type: 2,
690
+ value: new TextEncoder().encode(metadata.cameraMake + "\0"),
691
+ });
692
+ }
693
+ // Add Model (camera model)
694
+ if (metadata.cameraModel) {
695
+ entries.push({
696
+ tag: 0x0110,
697
+ type: 2,
698
+ value: new TextEncoder().encode(metadata.cameraModel + "\0"),
699
+ });
700
+ }
701
+ // Add Orientation
702
+ if (metadata.orientation !== undefined) {
703
+ const orientationBytes = new Uint8Array(2);
704
+ orientationBytes[0] = metadata.orientation & 0xff;
705
+ orientationBytes[1] = (metadata.orientation >> 8) & 0xff;
706
+ entries.push({
707
+ tag: 0x0112,
708
+ type: 3, // SHORT
709
+ value: orientationBytes,
710
+ });
711
+ }
712
+ // Add Software
713
+ if (metadata.software) {
714
+ entries.push({
715
+ tag: 0x0131,
716
+ type: 2,
717
+ value: new TextEncoder().encode(metadata.software + "\0"),
718
+ });
719
+ }
720
+ // Check if we have GPS data
721
+ const hasGPS = metadata.latitude !== undefined &&
722
+ metadata.longitude !== undefined;
723
+ // Check if we have Exif Sub-IFD data
724
+ const hasExifSubIFD = metadata.iso !== undefined ||
725
+ metadata.exposureTime !== undefined ||
726
+ metadata.fNumber !== undefined ||
727
+ metadata.focalLength !== undefined ||
728
+ metadata.flash !== undefined ||
729
+ metadata.whiteBalance !== undefined ||
730
+ metadata.lensMake !== undefined ||
731
+ metadata.lensModel !== undefined ||
732
+ metadata.userComment !== undefined;
733
+ if (entries.length === 0 && !hasGPS && !hasExifSubIFD)
391
734
  return [];
392
735
  // Build EXIF structure
393
736
  const exif = [];
@@ -396,10 +739,12 @@ export class JPEGFormat {
396
739
  exif.push(0x2a, 0x00); // 42
397
740
  // Offset to IFD0 (8 bytes from start)
398
741
  exif.push(0x08, 0x00, 0x00, 0x00);
399
- // Number of entries
400
- exif.push(entries.length & 0xff, (entries.length >> 8) & 0xff);
742
+ // Number of entries (add GPS IFD pointer and Exif Sub-IFD pointer if needed)
743
+ const ifd0Entries = entries.length + (hasGPS ? 1 : 0) +
744
+ (hasExifSubIFD ? 1 : 0);
745
+ exif.push(ifd0Entries & 0xff, (ifd0Entries >> 8) & 0xff);
401
746
  // Calculate data offset
402
- let dataOffset = 8 + 2 + entries.length * 12 + 4;
747
+ let dataOffset = 8 + 2 + ifd0Entries * 12 + 4;
403
748
  for (const entry of entries) {
404
749
  // Tag
405
750
  exif.push(entry.tag & 0xff, (entry.tag >> 8) & 0xff);
@@ -419,6 +764,26 @@ export class JPEGFormat {
419
764
  dataOffset += entry.value.length;
420
765
  }
421
766
  }
767
+ // Add Exif Sub-IFD pointer if we have camera metadata
768
+ let exifSubIfdOffset = 0;
769
+ if (hasExifSubIFD) {
770
+ exifSubIfdOffset = dataOffset;
771
+ // Exif IFD Pointer tag (0x8769), type 4 (LONG), count 1
772
+ exif.push(0x69, 0x87); // Tag
773
+ exif.push(0x04, 0x00); // Type
774
+ exif.push(0x01, 0x00, 0x00, 0x00); // Count
775
+ exif.push(exifSubIfdOffset & 0xff, (exifSubIfdOffset >> 8) & 0xff, (exifSubIfdOffset >> 16) & 0xff, (exifSubIfdOffset >> 24) & 0xff);
776
+ }
777
+ // Add GPS IFD pointer if we have GPS data
778
+ let gpsIfdOffset = 0;
779
+ if (hasGPS) {
780
+ gpsIfdOffset = dataOffset;
781
+ // GPS IFD Pointer tag (0x8825), type 4 (LONG), count 1
782
+ exif.push(0x25, 0x88); // Tag
783
+ exif.push(0x04, 0x00); // Type
784
+ exif.push(0x01, 0x00, 0x00, 0x00); // Count
785
+ exif.push(gpsIfdOffset & 0xff, (gpsIfdOffset >> 8) & 0xff, (gpsIfdOffset >> 16) & 0xff, (gpsIfdOffset >> 24) & 0xff);
786
+ }
422
787
  // Next IFD offset (0 = no more IFDs)
423
788
  exif.push(0x00, 0x00, 0x00, 0x00);
424
789
  // Append data for entries that didn't fit in value field
@@ -429,6 +794,346 @@ export class JPEGFormat {
429
794
  }
430
795
  }
431
796
  }
797
+ // Add Exif Sub-IFD if we have camera metadata
798
+ if (hasExifSubIFD) {
799
+ const exifSubIfd = this.createExifSubIFD(metadata, exifSubIfdOffset);
800
+ for (const byte of exifSubIfd) {
801
+ exif.push(byte);
802
+ }
803
+ // Update GPS offset since we added the Sub-IFD
804
+ if (hasGPS) {
805
+ gpsIfdOffset = exif.length;
806
+ }
807
+ }
808
+ // Add GPS IFD if we have GPS data
809
+ if (hasGPS) {
810
+ const gpsIfd = this.createGPSIFD(metadata, gpsIfdOffset);
811
+ for (const byte of gpsIfd) {
812
+ exif.push(byte);
813
+ }
814
+ }
432
815
  return exif;
433
816
  }
817
+ createGPSIFD(metadata, gpsIfdStart) {
818
+ const gps = [];
819
+ // We'll create 4 GPS entries: LatitudeRef, Latitude, LongitudeRef, Longitude
820
+ const numEntries = 4;
821
+ gps.push(numEntries & 0xff, (numEntries >> 8) & 0xff);
822
+ const latitude = metadata.latitude;
823
+ const longitude = metadata.longitude;
824
+ // Convert to absolute values for DMS calculation
825
+ const absLat = Math.abs(latitude);
826
+ const absLon = Math.abs(longitude);
827
+ // Calculate degrees, minutes, seconds
828
+ const latDeg = Math.floor(absLat);
829
+ const latMin = Math.floor((absLat - latDeg) * 60);
830
+ const latSec = ((absLat - latDeg) * 60 - latMin) * 60;
831
+ const lonDeg = Math.floor(absLon);
832
+ const lonMin = Math.floor((absLon - lonDeg) * 60);
833
+ const lonSec = ((absLon - lonDeg) * 60 - lonMin) * 60;
834
+ // Calculate offset for rational data (relative to start of EXIF data, not GPS IFD)
835
+ let dataOffset = gpsIfdStart + 2 + numEntries * 12 + 4;
836
+ // Entry 1: GPSLatitudeRef (tag 0x0001)
837
+ gps.push(0x01, 0x00); // Tag
838
+ gps.push(0x02, 0x00); // Type (ASCII)
839
+ gps.push(0x02, 0x00, 0x00, 0x00); // Count (2 bytes including null)
840
+ // Value stored inline: 'N' or 'S' + null
841
+ gps.push(latitude >= 0 ? 78 : 83, 0x00, 0x00, 0x00); // 'N' = 78, 'S' = 83
842
+ // Entry 2: GPSLatitude (tag 0x0002)
843
+ gps.push(0x02, 0x00); // Tag
844
+ gps.push(0x05, 0x00); // Type (RATIONAL)
845
+ gps.push(0x03, 0x00, 0x00, 0x00); // Count (3 rationals)
846
+ gps.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
847
+ dataOffset += 24; // 3 rationals * 8 bytes
848
+ // Entry 3: GPSLongitudeRef (tag 0x0003)
849
+ gps.push(0x03, 0x00); // Tag
850
+ gps.push(0x02, 0x00); // Type (ASCII)
851
+ gps.push(0x02, 0x00, 0x00, 0x00); // Count
852
+ gps.push(longitude >= 0 ? 69 : 87, 0x00, 0x00, 0x00); // 'E' = 69, 'W' = 87
853
+ // Entry 4: GPSLongitude (tag 0x0004)
854
+ gps.push(0x04, 0x00); // Tag
855
+ gps.push(0x05, 0x00); // Type (RATIONAL)
856
+ gps.push(0x03, 0x00, 0x00, 0x00); // Count
857
+ gps.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
858
+ // Next IFD offset (0 = no more IFDs)
859
+ gps.push(0x00, 0x00, 0x00, 0x00);
860
+ // Write latitude rationals (degrees, minutes, seconds)
861
+ this.writeRational(gps, latDeg, 1);
862
+ this.writeRational(gps, latMin, 1);
863
+ this.writeRational(gps, Math.round(latSec * 1000000), 1000000);
864
+ // Write longitude rationals
865
+ this.writeRational(gps, lonDeg, 1);
866
+ this.writeRational(gps, lonMin, 1);
867
+ this.writeRational(gps, Math.round(lonSec * 1000000), 1000000);
868
+ return gps;
869
+ }
870
+ createExifSubIFD(metadata, exifIfdStart) {
871
+ const entries = [];
872
+ // ISO Speed Ratings (0x8827) - SHORT
873
+ if (metadata.iso !== undefined) {
874
+ entries.push({
875
+ tag: 0x8827,
876
+ type: 3,
877
+ data: [metadata.iso & 0xff, (metadata.iso >> 8) & 0xff],
878
+ });
879
+ }
880
+ // Exposure Time (0x829A) - RATIONAL
881
+ if (metadata.exposureTime !== undefined) {
882
+ entries.push({ tag: 0x829a, type: 5, data: [] }); // Will add offset later
883
+ }
884
+ // FNumber (0x829D) - RATIONAL
885
+ if (metadata.fNumber !== undefined) {
886
+ entries.push({ tag: 0x829d, type: 5, data: [] }); // Will add offset later
887
+ }
888
+ // Flash (0x9209) - SHORT
889
+ if (metadata.flash !== undefined) {
890
+ entries.push({
891
+ tag: 0x9209,
892
+ type: 3,
893
+ data: [metadata.flash & 0xff, (metadata.flash >> 8) & 0xff],
894
+ });
895
+ }
896
+ // Focal Length (0x920A) - RATIONAL
897
+ if (metadata.focalLength !== undefined) {
898
+ entries.push({ tag: 0x920a, type: 5, data: [] }); // Will add offset later
899
+ }
900
+ // User Comment (0x9286) - UNDEFINED
901
+ if (metadata.userComment !== undefined) {
902
+ entries.push({ tag: 0x9286, type: 7, data: [] }); // Will add offset later
903
+ }
904
+ // White Balance (0xA403) - SHORT
905
+ if (metadata.whiteBalance !== undefined) {
906
+ entries.push({
907
+ tag: 0xa403,
908
+ type: 3,
909
+ data: [
910
+ metadata.whiteBalance & 0xff,
911
+ (metadata.whiteBalance >> 8) & 0xff,
912
+ ],
913
+ });
914
+ }
915
+ // Lens Make (0xA433) - ASCII
916
+ if (metadata.lensMake !== undefined) {
917
+ entries.push({ tag: 0xa433, type: 2, data: [] }); // Will add offset later
918
+ }
919
+ // Lens Model (0xA434) - ASCII
920
+ if (metadata.lensModel !== undefined) {
921
+ entries.push({ tag: 0xa434, type: 2, data: [] }); // Will add offset later
922
+ }
923
+ const exifSubIfd = [];
924
+ const numEntries = entries.length;
925
+ exifSubIfd.push(numEntries & 0xff, (numEntries >> 8) & 0xff);
926
+ let dataOffset = exifIfdStart + 2 + numEntries * 12 + 4;
927
+ // Write entry headers
928
+ for (const entry of entries) {
929
+ exifSubIfd.push(entry.tag & 0xff, (entry.tag >> 8) & 0xff);
930
+ exifSubIfd.push(entry.type & 0xff, (entry.type >> 8) & 0xff);
931
+ if (entry.tag === 0x829a && metadata.exposureTime !== undefined) {
932
+ // Exposure Time - 1 RATIONAL
933
+ exifSubIfd.push(0x01, 0x00, 0x00, 0x00); // Count
934
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
935
+ dataOffset += 8;
936
+ }
937
+ else if (entry.tag === 0x829d && metadata.fNumber !== undefined) {
938
+ // FNumber - 1 RATIONAL
939
+ exifSubIfd.push(0x01, 0x00, 0x00, 0x00); // Count
940
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
941
+ dataOffset += 8;
942
+ }
943
+ else if (entry.tag === 0x920a && metadata.focalLength !== undefined) {
944
+ // Focal Length - 1 RATIONAL
945
+ exifSubIfd.push(0x01, 0x00, 0x00, 0x00); // Count
946
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
947
+ dataOffset += 8;
948
+ }
949
+ else if (entry.tag === 0x9286 && metadata.userComment !== undefined) {
950
+ // User Comment - UNDEFINED with character code
951
+ const commentBytes = new TextEncoder().encode(metadata.userComment);
952
+ const totalLength = 8 + commentBytes.length; // 8 bytes for character code
953
+ exifSubIfd.push(totalLength & 0xff, (totalLength >> 8) & 0xff, (totalLength >> 16) & 0xff, (totalLength >> 24) & 0xff);
954
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
955
+ dataOffset += totalLength;
956
+ }
957
+ else if (entry.tag === 0xa433 && metadata.lensMake !== undefined) {
958
+ // Lens Make - ASCII
959
+ const bytes = new TextEncoder().encode(metadata.lensMake + "\0");
960
+ exifSubIfd.push(bytes.length & 0xff, (bytes.length >> 8) & 0xff, (bytes.length >> 16) & 0xff, (bytes.length >> 24) & 0xff);
961
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
962
+ dataOffset += bytes.length;
963
+ }
964
+ else if (entry.tag === 0xa434 && metadata.lensModel !== undefined) {
965
+ // Lens Model - ASCII
966
+ const bytes = new TextEncoder().encode(metadata.lensModel + "\0");
967
+ exifSubIfd.push(bytes.length & 0xff, (bytes.length >> 8) & 0xff, (bytes.length >> 16) & 0xff, (bytes.length >> 24) & 0xff);
968
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
969
+ dataOffset += bytes.length;
970
+ }
971
+ else {
972
+ // SHORT types stored inline
973
+ exifSubIfd.push(0x01, 0x00, 0x00, 0x00); // Count
974
+ exifSubIfd.push(...entry.data, 0x00, 0x00); // Value (4 bytes)
975
+ }
976
+ }
977
+ // Next IFD offset
978
+ exifSubIfd.push(0x00, 0x00, 0x00, 0x00);
979
+ // Write data for RATIONAL and ASCII types
980
+ for (const entry of entries) {
981
+ if (entry.tag === 0x829a && metadata.exposureTime !== undefined) {
982
+ // Convert exposure time to rational (numerator/denominator)
983
+ const [num, den] = this.toRational(metadata.exposureTime);
984
+ this.writeRational(exifSubIfd, num, den);
985
+ }
986
+ else if (entry.tag === 0x829d && metadata.fNumber !== undefined) {
987
+ const [num, den] = this.toRational(metadata.fNumber);
988
+ this.writeRational(exifSubIfd, num, den);
989
+ }
990
+ else if (entry.tag === 0x920a && metadata.focalLength !== undefined) {
991
+ const [num, den] = this.toRational(metadata.focalLength);
992
+ this.writeRational(exifSubIfd, num, den);
993
+ }
994
+ else if (entry.tag === 0x9286 && metadata.userComment !== undefined) {
995
+ // Character code: ASCII (8 bytes)
996
+ exifSubIfd.push(0x41, 0x53, 0x43, 0x49, 0x49, 0x00, 0x00, 0x00);
997
+ const commentBytes = new TextEncoder().encode(metadata.userComment);
998
+ for (const byte of commentBytes) {
999
+ exifSubIfd.push(byte);
1000
+ }
1001
+ }
1002
+ else if (entry.tag === 0xa433 && metadata.lensMake !== undefined) {
1003
+ const bytes = new TextEncoder().encode(metadata.lensMake + "\0");
1004
+ for (const byte of bytes) {
1005
+ exifSubIfd.push(byte);
1006
+ }
1007
+ }
1008
+ else if (entry.tag === 0xa434 && metadata.lensModel !== undefined) {
1009
+ const bytes = new TextEncoder().encode(metadata.lensModel + "\0");
1010
+ for (const byte of bytes) {
1011
+ exifSubIfd.push(byte);
1012
+ }
1013
+ }
1014
+ }
1015
+ return exifSubIfd;
1016
+ }
1017
+ toRational(value) {
1018
+ // Convert decimal to rational representation
1019
+ // Try to find a reasonable denominator
1020
+ const denominators = [1, 10, 100, 1000, 10000, 100000, 1000000];
1021
+ for (const den of denominators) {
1022
+ const num = Math.round(value * den);
1023
+ if (Math.abs(num / den - value) < 0.000001) {
1024
+ return [num, den];
1025
+ }
1026
+ }
1027
+ // Fallback
1028
+ return [Math.round(value * 1000000), 1000000];
1029
+ }
1030
+ writeRational(output, numerator, denominator) {
1031
+ // Write as little endian
1032
+ output.push(numerator & 0xff, (numerator >> 8) & 0xff, (numerator >> 16) & 0xff, (numerator >> 24) & 0xff);
1033
+ output.push(denominator & 0xff, (denominator >> 8) & 0xff, (denominator >> 16) & 0xff, (denominator >> 24) & 0xff);
1034
+ }
1035
+ /**
1036
+ * Get the list of metadata fields supported by JPEG format
1037
+ */
1038
+ getSupportedMetadata() {
1039
+ return [
1040
+ "creationDate",
1041
+ "description",
1042
+ "author",
1043
+ "copyright",
1044
+ "cameraMake",
1045
+ "cameraModel",
1046
+ "orientation",
1047
+ "software",
1048
+ "latitude",
1049
+ "longitude",
1050
+ "iso",
1051
+ "exposureTime",
1052
+ "fNumber",
1053
+ "focalLength",
1054
+ "flash",
1055
+ "whiteBalance",
1056
+ "lensMake",
1057
+ "lensModel",
1058
+ "userComment",
1059
+ "dpiX",
1060
+ "dpiY",
1061
+ ];
1062
+ }
1063
+ /**
1064
+ * Extract metadata from JPEG data without fully decoding the pixel data
1065
+ * This quickly parses JFIF and EXIF markers to extract metadata
1066
+ * @param data Raw JPEG data
1067
+ * @returns Extracted metadata or undefined
1068
+ */
1069
+ extractMetadata(data) {
1070
+ if (!this.canDecode(data)) {
1071
+ return Promise.resolve(undefined);
1072
+ }
1073
+ // Parse JPEG structure to extract metadata
1074
+ let pos = 2; // Skip initial FF D8
1075
+ const metadata = {
1076
+ format: "jpeg",
1077
+ compression: "dct",
1078
+ frameCount: 1,
1079
+ bitDepth: 8,
1080
+ colorType: "rgb",
1081
+ };
1082
+ let width = 0;
1083
+ let height = 0;
1084
+ while (pos < data.length - 1) {
1085
+ if (data[pos] !== 0xff) {
1086
+ pos++;
1087
+ continue;
1088
+ }
1089
+ const marker = data[pos + 1];
1090
+ pos += 2;
1091
+ // SOF markers (Start of Frame) - get dimensions for DPI calculation
1092
+ if (marker >= 0xc0 && marker <= 0xcf &&
1093
+ marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
1094
+ const length = (data[pos] << 8) | data[pos + 1];
1095
+ // precision at pos+2
1096
+ const precision = data[pos + 2];
1097
+ if (precision && precision !== 8) {
1098
+ metadata.bitDepth = precision;
1099
+ }
1100
+ height = (data[pos + 3] << 8) | data[pos + 4];
1101
+ width = (data[pos + 5] << 8) | data[pos + 6];
1102
+ // Check number of components
1103
+ const numComponents = data[pos + 7];
1104
+ if (numComponents === 1) {
1105
+ metadata.colorType = "grayscale";
1106
+ }
1107
+ // Don't break - continue parsing for metadata
1108
+ pos += length;
1109
+ continue;
1110
+ }
1111
+ // APP0 marker (JFIF)
1112
+ if (marker === 0xe0) {
1113
+ const length = (data[pos] << 8) | data[pos + 1];
1114
+ const appData = data.slice(pos + 2, pos + length);
1115
+ this.parseJFIF(appData, metadata, width, height);
1116
+ pos += length;
1117
+ continue;
1118
+ }
1119
+ // APP1 marker (EXIF)
1120
+ if (marker === 0xe1) {
1121
+ const length = (data[pos] << 8) | data[pos + 1];
1122
+ const appData = data.slice(pos + 2, pos + length);
1123
+ this.parseEXIF(appData, metadata);
1124
+ pos += length;
1125
+ continue;
1126
+ }
1127
+ // Skip other markers
1128
+ if (marker === 0xd9 || marker === 0xda)
1129
+ break; // EOI or SOS
1130
+ if (marker >= 0xd0 && marker <= 0xd8)
1131
+ continue; // RST markers have no length
1132
+ if (marker === 0x01)
1133
+ continue; // TEM has no length
1134
+ const length = (data[pos] << 8) | data[pos + 1];
1135
+ pos += length;
1136
+ }
1137
+ return Promise.resolve(Object.keys(metadata).length > 0 ? metadata : undefined);
1138
+ }
434
1139
  }