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
@@ -305,6 +305,7 @@ class JPEGFormat {
305
305
  const numEntries = littleEndian
306
306
  ? exifData[ifd0Offset] | (exifData[ifd0Offset + 1] << 8)
307
307
  : (exifData[ifd0Offset] << 8) | exifData[ifd0Offset + 1];
308
+ let gpsIfdOffset = 0;
308
309
  // Parse entries
309
310
  for (let i = 0; i < numEntries; i++) {
310
311
  const entryOffset = ifd0Offset + 2 + i * 12;
@@ -381,12 +382,306 @@ class JPEGFormat {
381
382
  }
382
383
  }
383
384
  }
385
+ // Make tag (0x010F)
386
+ if (tag === 0x010f) {
387
+ const valueOffset = littleEndian
388
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
389
+ (exifData[entryOffset + 10] << 16) |
390
+ (exifData[entryOffset + 11] << 24)
391
+ : (exifData[entryOffset + 8] << 24) |
392
+ (exifData[entryOffset + 9] << 16) |
393
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
394
+ if (valueOffset < exifData.length) {
395
+ const endIndex = exifData.indexOf(0, valueOffset);
396
+ if (endIndex > valueOffset) {
397
+ metadata.cameraMake = new TextDecoder().decode(exifData.slice(valueOffset, endIndex));
398
+ }
399
+ }
400
+ }
401
+ // Model tag (0x0110)
402
+ if (tag === 0x0110) {
403
+ const valueOffset = littleEndian
404
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
405
+ (exifData[entryOffset + 10] << 16) |
406
+ (exifData[entryOffset + 11] << 24)
407
+ : (exifData[entryOffset + 8] << 24) |
408
+ (exifData[entryOffset + 9] << 16) |
409
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
410
+ if (valueOffset < exifData.length) {
411
+ const endIndex = exifData.indexOf(0, valueOffset);
412
+ if (endIndex > valueOffset) {
413
+ metadata.cameraModel = new TextDecoder().decode(exifData.slice(valueOffset, endIndex));
414
+ }
415
+ }
416
+ }
417
+ // Orientation tag (0x0112)
418
+ if (tag === 0x0112) {
419
+ const value = littleEndian
420
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8)
421
+ : (exifData[entryOffset + 8] << 8) | exifData[entryOffset + 9];
422
+ metadata.orientation = value;
423
+ }
424
+ // Software tag (0x0131)
425
+ if (tag === 0x0131) {
426
+ const valueOffset = littleEndian
427
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
428
+ (exifData[entryOffset + 10] << 16) |
429
+ (exifData[entryOffset + 11] << 24)
430
+ : (exifData[entryOffset + 8] << 24) |
431
+ (exifData[entryOffset + 9] << 16) |
432
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
433
+ if (valueOffset < exifData.length) {
434
+ const endIndex = exifData.indexOf(0, valueOffset);
435
+ if (endIndex > valueOffset) {
436
+ metadata.software = new TextDecoder().decode(exifData.slice(valueOffset, endIndex));
437
+ }
438
+ }
439
+ }
440
+ // GPS IFD Pointer tag (0x8825)
441
+ if (tag === 0x8825) {
442
+ gpsIfdOffset = littleEndian
443
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
444
+ (exifData[entryOffset + 10] << 16) |
445
+ (exifData[entryOffset + 11] << 24)
446
+ : (exifData[entryOffset + 8] << 24) |
447
+ (exifData[entryOffset + 9] << 16) |
448
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
449
+ }
450
+ // ExifIFD Pointer tag (0x8769) - points to EXIF Sub-IFD
451
+ const type = littleEndian
452
+ ? exifData[entryOffset + 2] | (exifData[entryOffset + 3] << 8)
453
+ : (exifData[entryOffset + 2] << 8) | exifData[entryOffset + 3];
454
+ if (tag === 0x8769 && type === 4) {
455
+ const exifIfdOffset = littleEndian
456
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
457
+ (exifData[entryOffset + 10] << 16) |
458
+ (exifData[entryOffset + 11] << 24)
459
+ : (exifData[entryOffset + 8] << 24) |
460
+ (exifData[entryOffset + 9] << 16) |
461
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
462
+ if (exifIfdOffset > 0 && exifIfdOffset + 2 <= exifData.length) {
463
+ this.parseExifSubIFD(exifData, exifIfdOffset, littleEndian, metadata);
464
+ }
465
+ }
466
+ }
467
+ // Parse GPS IFD if present
468
+ if (gpsIfdOffset > 0 && gpsIfdOffset + 2 <= exifData.length) {
469
+ this.parseGPSIFD(exifData, gpsIfdOffset, littleEndian, metadata);
384
470
  }
385
471
  }
386
472
  catch (_e) {
387
473
  // Ignore EXIF parsing errors
388
474
  }
389
475
  }
476
+ parseExifSubIFD(exifData, exifIfdOffset, littleEndian, metadata) {
477
+ try {
478
+ const numEntries = littleEndian
479
+ ? exifData[exifIfdOffset] | (exifData[exifIfdOffset + 1] << 8)
480
+ : (exifData[exifIfdOffset] << 8) | exifData[exifIfdOffset + 1];
481
+ for (let i = 0; i < numEntries; i++) {
482
+ const entryOffset = exifIfdOffset + 2 + i * 12;
483
+ if (entryOffset + 12 > exifData.length)
484
+ break;
485
+ const tag = littleEndian
486
+ ? exifData[entryOffset] | (exifData[entryOffset + 1] << 8)
487
+ : (exifData[entryOffset] << 8) | exifData[entryOffset + 1];
488
+ const type = littleEndian
489
+ ? exifData[entryOffset + 2] | (exifData[entryOffset + 3] << 8)
490
+ : (exifData[entryOffset + 2] << 8) | exifData[entryOffset + 3];
491
+ // ExposureTime tag (0x829A) - RATIONAL
492
+ if (tag === 0x829a && type === 5) {
493
+ const valueOffset = littleEndian
494
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
495
+ (exifData[entryOffset + 10] << 16) |
496
+ (exifData[entryOffset + 11] << 24)
497
+ : (exifData[entryOffset + 8] << 24) |
498
+ (exifData[entryOffset + 9] << 16) |
499
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
500
+ if (valueOffset + 8 <= exifData.length) {
501
+ metadata.exposureTime = this.readRational(exifData, valueOffset, littleEndian);
502
+ }
503
+ }
504
+ // FNumber tag (0x829D) - RATIONAL
505
+ if (tag === 0x829d && type === 5) {
506
+ const valueOffset = littleEndian
507
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
508
+ (exifData[entryOffset + 10] << 16) |
509
+ (exifData[entryOffset + 11] << 24)
510
+ : (exifData[entryOffset + 8] << 24) |
511
+ (exifData[entryOffset + 9] << 16) |
512
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
513
+ if (valueOffset + 8 <= exifData.length) {
514
+ metadata.fNumber = this.readRational(exifData, valueOffset, littleEndian);
515
+ }
516
+ }
517
+ // ISOSpeedRatings tag (0x8827) - SHORT
518
+ if (tag === 0x8827 && type === 3) {
519
+ metadata.iso = littleEndian
520
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8)
521
+ : (exifData[entryOffset + 8] << 8) | exifData[entryOffset + 9];
522
+ }
523
+ // FocalLength tag (0x920A) - RATIONAL
524
+ if (tag === 0x920a && type === 5) {
525
+ const valueOffset = littleEndian
526
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
527
+ (exifData[entryOffset + 10] << 16) |
528
+ (exifData[entryOffset + 11] << 24)
529
+ : (exifData[entryOffset + 8] << 24) |
530
+ (exifData[entryOffset + 9] << 16) |
531
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
532
+ if (valueOffset + 8 <= exifData.length) {
533
+ metadata.focalLength = this.readRational(exifData, valueOffset, littleEndian);
534
+ }
535
+ }
536
+ // UserComment tag (0x9286) - UNDEFINED
537
+ if (tag === 0x9286) {
538
+ const count = littleEndian
539
+ ? exifData[entryOffset + 4] | (exifData[entryOffset + 5] << 8) |
540
+ (exifData[entryOffset + 6] << 16) |
541
+ (exifData[entryOffset + 7] << 24)
542
+ : (exifData[entryOffset + 4] << 24) |
543
+ (exifData[entryOffset + 5] << 16) |
544
+ (exifData[entryOffset + 6] << 8) | exifData[entryOffset + 7];
545
+ if (count > 8) {
546
+ const valueOffset = littleEndian
547
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
548
+ (exifData[entryOffset + 10] << 16) |
549
+ (exifData[entryOffset + 11] << 24)
550
+ : (exifData[entryOffset + 8] << 24) |
551
+ (exifData[entryOffset + 9] << 16) |
552
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
553
+ if (valueOffset + count <= exifData.length) {
554
+ // Skip 8-byte character code prefix
555
+ const commentData = exifData.slice(valueOffset + 8, valueOffset + count);
556
+ metadata.userComment = new TextDecoder().decode(commentData)
557
+ .replace(/\0+$/, "");
558
+ }
559
+ }
560
+ }
561
+ // Flash tag (0x9209) - SHORT
562
+ if (tag === 0x9209 && type === 3) {
563
+ metadata.flash = littleEndian
564
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8)
565
+ : (exifData[entryOffset + 8] << 8) | exifData[entryOffset + 9];
566
+ }
567
+ // WhiteBalance tag (0xA403) - SHORT
568
+ if (tag === 0xa403 && type === 3) {
569
+ metadata.whiteBalance = littleEndian
570
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8)
571
+ : (exifData[entryOffset + 8] << 8) | exifData[entryOffset + 9];
572
+ }
573
+ // LensMake tag (0xA433) - ASCII
574
+ if (tag === 0xa433 && type === 2) {
575
+ const valueOffset = littleEndian
576
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
577
+ (exifData[entryOffset + 10] << 16) |
578
+ (exifData[entryOffset + 11] << 24)
579
+ : (exifData[entryOffset + 8] << 24) |
580
+ (exifData[entryOffset + 9] << 16) |
581
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
582
+ if (valueOffset < exifData.length) {
583
+ const endIndex = exifData.indexOf(0, valueOffset);
584
+ if (endIndex > valueOffset) {
585
+ metadata.lensMake = new TextDecoder().decode(exifData.slice(valueOffset, endIndex));
586
+ }
587
+ }
588
+ }
589
+ // LensModel tag (0xA434) - ASCII
590
+ if (tag === 0xa434 && type === 2) {
591
+ const valueOffset = littleEndian
592
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
593
+ (exifData[entryOffset + 10] << 16) |
594
+ (exifData[entryOffset + 11] << 24)
595
+ : (exifData[entryOffset + 8] << 24) |
596
+ (exifData[entryOffset + 9] << 16) |
597
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
598
+ if (valueOffset < exifData.length) {
599
+ const endIndex = exifData.indexOf(0, valueOffset);
600
+ if (endIndex > valueOffset) {
601
+ metadata.lensModel = new TextDecoder().decode(exifData.slice(valueOffset, endIndex));
602
+ }
603
+ }
604
+ }
605
+ }
606
+ }
607
+ catch (_e) {
608
+ // Ignore EXIF Sub-IFD parsing errors
609
+ }
610
+ }
611
+ parseGPSIFD(exifData, gpsIfdOffset, littleEndian, metadata) {
612
+ try {
613
+ const numEntries = littleEndian
614
+ ? exifData[gpsIfdOffset] | (exifData[gpsIfdOffset + 1] << 8)
615
+ : (exifData[gpsIfdOffset] << 8) | exifData[gpsIfdOffset + 1];
616
+ let latRef = "";
617
+ let lonRef = "";
618
+ let latitude;
619
+ let longitude;
620
+ for (let i = 0; i < numEntries; i++) {
621
+ const entryOffset = gpsIfdOffset + 2 + i * 12;
622
+ if (entryOffset + 12 > exifData.length)
623
+ break;
624
+ const tag = littleEndian
625
+ ? exifData[entryOffset] | (exifData[entryOffset + 1] << 8)
626
+ : (exifData[entryOffset] << 8) | exifData[entryOffset + 1];
627
+ const type = littleEndian
628
+ ? exifData[entryOffset + 2] | (exifData[entryOffset + 3] << 8)
629
+ : (exifData[entryOffset + 2] << 8) | exifData[entryOffset + 3];
630
+ const valueOffset = littleEndian
631
+ ? exifData[entryOffset + 8] | (exifData[entryOffset + 9] << 8) |
632
+ (exifData[entryOffset + 10] << 16) |
633
+ (exifData[entryOffset + 11] << 24)
634
+ : (exifData[entryOffset + 8] << 24) |
635
+ (exifData[entryOffset + 9] << 16) |
636
+ (exifData[entryOffset + 10] << 8) | exifData[entryOffset + 11];
637
+ // GPSLatitudeRef (0x0001) - 'N' or 'S'
638
+ if (tag === 0x0001 && type === 2) {
639
+ latRef = String.fromCharCode(exifData[entryOffset + 8]);
640
+ }
641
+ // GPSLatitude (0x0002) - three rationals: degrees, minutes, seconds
642
+ if (tag === 0x0002 && type === 5 && valueOffset + 24 <= exifData.length) {
643
+ const degrees = this.readRational(exifData, valueOffset, littleEndian);
644
+ const minutes = this.readRational(exifData, valueOffset + 8, littleEndian);
645
+ const seconds = this.readRational(exifData, valueOffset + 16, littleEndian);
646
+ latitude = degrees + minutes / 60 + seconds / 3600;
647
+ }
648
+ // GPSLongitudeRef (0x0003) - 'E' or 'W'
649
+ if (tag === 0x0003 && type === 2) {
650
+ lonRef = String.fromCharCode(exifData[entryOffset + 8]);
651
+ }
652
+ // GPSLongitude (0x0004) - three rationals: degrees, minutes, seconds
653
+ if (tag === 0x0004 && type === 5 && valueOffset + 24 <= exifData.length) {
654
+ const degrees = this.readRational(exifData, valueOffset, littleEndian);
655
+ const minutes = this.readRational(exifData, valueOffset + 8, littleEndian);
656
+ const seconds = this.readRational(exifData, valueOffset + 16, littleEndian);
657
+ longitude = degrees + minutes / 60 + seconds / 3600;
658
+ }
659
+ }
660
+ // Apply hemisphere references
661
+ if (latitude !== undefined && latRef) {
662
+ metadata.latitude = latRef === "S" ? -latitude : latitude;
663
+ }
664
+ if (longitude !== undefined && lonRef) {
665
+ metadata.longitude = lonRef === "W" ? -longitude : longitude;
666
+ }
667
+ }
668
+ catch (_e) {
669
+ // Ignore GPS parsing errors
670
+ }
671
+ }
672
+ readRational(data, offset, littleEndian) {
673
+ const numerator = littleEndian
674
+ ? data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) |
675
+ (data[offset + 3] << 24)
676
+ : (data[offset] << 24) | (data[offset + 1] << 16) |
677
+ (data[offset + 2] << 8) | data[offset + 3];
678
+ const denominator = littleEndian
679
+ ? data[offset + 4] | (data[offset + 5] << 8) | (data[offset + 6] << 16) |
680
+ (data[offset + 7] << 24)
681
+ : (data[offset + 4] << 24) | (data[offset + 5] << 16) |
682
+ (data[offset + 6] << 8) | data[offset + 7];
683
+ return denominator !== 0 ? numerator / denominator : 0;
684
+ }
390
685
  createEXIFData(metadata) {
391
686
  const entries = [];
392
687
  // Add DateTime if available
@@ -423,7 +718,55 @@ class JPEGFormat {
423
718
  value: new TextEncoder().encode(metadata.copyright + "\0"),
424
719
  });
425
720
  }
426
- if (entries.length === 0)
721
+ // Add Make (camera manufacturer)
722
+ if (metadata.cameraMake) {
723
+ entries.push({
724
+ tag: 0x010f,
725
+ type: 2,
726
+ value: new TextEncoder().encode(metadata.cameraMake + "\0"),
727
+ });
728
+ }
729
+ // Add Model (camera model)
730
+ if (metadata.cameraModel) {
731
+ entries.push({
732
+ tag: 0x0110,
733
+ type: 2,
734
+ value: new TextEncoder().encode(metadata.cameraModel + "\0"),
735
+ });
736
+ }
737
+ // Add Orientation
738
+ if (metadata.orientation !== undefined) {
739
+ const orientationBytes = new Uint8Array(2);
740
+ orientationBytes[0] = metadata.orientation & 0xff;
741
+ orientationBytes[1] = (metadata.orientation >> 8) & 0xff;
742
+ entries.push({
743
+ tag: 0x0112,
744
+ type: 3, // SHORT
745
+ value: orientationBytes,
746
+ });
747
+ }
748
+ // Add Software
749
+ if (metadata.software) {
750
+ entries.push({
751
+ tag: 0x0131,
752
+ type: 2,
753
+ value: new TextEncoder().encode(metadata.software + "\0"),
754
+ });
755
+ }
756
+ // Check if we have GPS data
757
+ const hasGPS = metadata.latitude !== undefined &&
758
+ metadata.longitude !== undefined;
759
+ // Check if we have Exif Sub-IFD data
760
+ const hasExifSubIFD = metadata.iso !== undefined ||
761
+ metadata.exposureTime !== undefined ||
762
+ metadata.fNumber !== undefined ||
763
+ metadata.focalLength !== undefined ||
764
+ metadata.flash !== undefined ||
765
+ metadata.whiteBalance !== undefined ||
766
+ metadata.lensMake !== undefined ||
767
+ metadata.lensModel !== undefined ||
768
+ metadata.userComment !== undefined;
769
+ if (entries.length === 0 && !hasGPS && !hasExifSubIFD)
427
770
  return [];
428
771
  // Build EXIF structure
429
772
  const exif = [];
@@ -432,10 +775,12 @@ class JPEGFormat {
432
775
  exif.push(0x2a, 0x00); // 42
433
776
  // Offset to IFD0 (8 bytes from start)
434
777
  exif.push(0x08, 0x00, 0x00, 0x00);
435
- // Number of entries
436
- exif.push(entries.length & 0xff, (entries.length >> 8) & 0xff);
778
+ // Number of entries (add GPS IFD pointer and Exif Sub-IFD pointer if needed)
779
+ const ifd0Entries = entries.length + (hasGPS ? 1 : 0) +
780
+ (hasExifSubIFD ? 1 : 0);
781
+ exif.push(ifd0Entries & 0xff, (ifd0Entries >> 8) & 0xff);
437
782
  // Calculate data offset
438
- let dataOffset = 8 + 2 + entries.length * 12 + 4;
783
+ let dataOffset = 8 + 2 + ifd0Entries * 12 + 4;
439
784
  for (const entry of entries) {
440
785
  // Tag
441
786
  exif.push(entry.tag & 0xff, (entry.tag >> 8) & 0xff);
@@ -455,6 +800,26 @@ class JPEGFormat {
455
800
  dataOffset += entry.value.length;
456
801
  }
457
802
  }
803
+ // Add Exif Sub-IFD pointer if we have camera metadata
804
+ let exifSubIfdOffset = 0;
805
+ if (hasExifSubIFD) {
806
+ exifSubIfdOffset = dataOffset;
807
+ // Exif IFD Pointer tag (0x8769), type 4 (LONG), count 1
808
+ exif.push(0x69, 0x87); // Tag
809
+ exif.push(0x04, 0x00); // Type
810
+ exif.push(0x01, 0x00, 0x00, 0x00); // Count
811
+ exif.push(exifSubIfdOffset & 0xff, (exifSubIfdOffset >> 8) & 0xff, (exifSubIfdOffset >> 16) & 0xff, (exifSubIfdOffset >> 24) & 0xff);
812
+ }
813
+ // Add GPS IFD pointer if we have GPS data
814
+ let gpsIfdOffset = 0;
815
+ if (hasGPS) {
816
+ gpsIfdOffset = dataOffset;
817
+ // GPS IFD Pointer tag (0x8825), type 4 (LONG), count 1
818
+ exif.push(0x25, 0x88); // Tag
819
+ exif.push(0x04, 0x00); // Type
820
+ exif.push(0x01, 0x00, 0x00, 0x00); // Count
821
+ exif.push(gpsIfdOffset & 0xff, (gpsIfdOffset >> 8) & 0xff, (gpsIfdOffset >> 16) & 0xff, (gpsIfdOffset >> 24) & 0xff);
822
+ }
458
823
  // Next IFD offset (0 = no more IFDs)
459
824
  exif.push(0x00, 0x00, 0x00, 0x00);
460
825
  // Append data for entries that didn't fit in value field
@@ -465,7 +830,271 @@ class JPEGFormat {
465
830
  }
466
831
  }
467
832
  }
833
+ // Add Exif Sub-IFD if we have camera metadata
834
+ if (hasExifSubIFD) {
835
+ const exifSubIfd = this.createExifSubIFD(metadata, exifSubIfdOffset);
836
+ for (const byte of exifSubIfd) {
837
+ exif.push(byte);
838
+ }
839
+ // Update GPS offset since we added the Sub-IFD
840
+ if (hasGPS) {
841
+ gpsIfdOffset = exif.length;
842
+ }
843
+ }
844
+ // Add GPS IFD if we have GPS data
845
+ if (hasGPS) {
846
+ const gpsIfd = this.createGPSIFD(metadata, gpsIfdOffset);
847
+ for (const byte of gpsIfd) {
848
+ exif.push(byte);
849
+ }
850
+ }
468
851
  return exif;
469
852
  }
853
+ createGPSIFD(metadata, gpsIfdStart) {
854
+ const gps = [];
855
+ // We'll create 4 GPS entries: LatitudeRef, Latitude, LongitudeRef, Longitude
856
+ const numEntries = 4;
857
+ gps.push(numEntries & 0xff, (numEntries >> 8) & 0xff);
858
+ const latitude = metadata.latitude;
859
+ const longitude = metadata.longitude;
860
+ // Convert to absolute values for DMS calculation
861
+ const absLat = Math.abs(latitude);
862
+ const absLon = Math.abs(longitude);
863
+ // Calculate degrees, minutes, seconds
864
+ const latDeg = Math.floor(absLat);
865
+ const latMin = Math.floor((absLat - latDeg) * 60);
866
+ const latSec = ((absLat - latDeg) * 60 - latMin) * 60;
867
+ const lonDeg = Math.floor(absLon);
868
+ const lonMin = Math.floor((absLon - lonDeg) * 60);
869
+ const lonSec = ((absLon - lonDeg) * 60 - lonMin) * 60;
870
+ // Calculate offset for rational data (relative to start of EXIF data, not GPS IFD)
871
+ let dataOffset = gpsIfdStart + 2 + numEntries * 12 + 4;
872
+ // Entry 1: GPSLatitudeRef (tag 0x0001)
873
+ gps.push(0x01, 0x00); // Tag
874
+ gps.push(0x02, 0x00); // Type (ASCII)
875
+ gps.push(0x02, 0x00, 0x00, 0x00); // Count (2 bytes including null)
876
+ // Value stored inline: 'N' or 'S' + null
877
+ gps.push(latitude >= 0 ? 78 : 83, 0x00, 0x00, 0x00); // 'N' = 78, 'S' = 83
878
+ // Entry 2: GPSLatitude (tag 0x0002)
879
+ gps.push(0x02, 0x00); // Tag
880
+ gps.push(0x05, 0x00); // Type (RATIONAL)
881
+ gps.push(0x03, 0x00, 0x00, 0x00); // Count (3 rationals)
882
+ gps.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
883
+ dataOffset += 24; // 3 rationals * 8 bytes
884
+ // Entry 3: GPSLongitudeRef (tag 0x0003)
885
+ gps.push(0x03, 0x00); // Tag
886
+ gps.push(0x02, 0x00); // Type (ASCII)
887
+ gps.push(0x02, 0x00, 0x00, 0x00); // Count
888
+ gps.push(longitude >= 0 ? 69 : 87, 0x00, 0x00, 0x00); // 'E' = 69, 'W' = 87
889
+ // Entry 4: GPSLongitude (tag 0x0004)
890
+ gps.push(0x04, 0x00); // Tag
891
+ gps.push(0x05, 0x00); // Type (RATIONAL)
892
+ gps.push(0x03, 0x00, 0x00, 0x00); // Count
893
+ gps.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
894
+ // Next IFD offset (0 = no more IFDs)
895
+ gps.push(0x00, 0x00, 0x00, 0x00);
896
+ // Write latitude rationals (degrees, minutes, seconds)
897
+ this.writeRational(gps, latDeg, 1);
898
+ this.writeRational(gps, latMin, 1);
899
+ this.writeRational(gps, Math.round(latSec * 1000000), 1000000);
900
+ // Write longitude rationals
901
+ this.writeRational(gps, lonDeg, 1);
902
+ this.writeRational(gps, lonMin, 1);
903
+ this.writeRational(gps, Math.round(lonSec * 1000000), 1000000);
904
+ return gps;
905
+ }
906
+ createExifSubIFD(metadata, exifIfdStart) {
907
+ const entries = [];
908
+ // ISO Speed Ratings (0x8827) - SHORT
909
+ if (metadata.iso !== undefined) {
910
+ entries.push({
911
+ tag: 0x8827,
912
+ type: 3,
913
+ data: [metadata.iso & 0xff, (metadata.iso >> 8) & 0xff],
914
+ });
915
+ }
916
+ // Exposure Time (0x829A) - RATIONAL
917
+ if (metadata.exposureTime !== undefined) {
918
+ entries.push({ tag: 0x829a, type: 5, data: [] }); // Will add offset later
919
+ }
920
+ // FNumber (0x829D) - RATIONAL
921
+ if (metadata.fNumber !== undefined) {
922
+ entries.push({ tag: 0x829d, type: 5, data: [] }); // Will add offset later
923
+ }
924
+ // Flash (0x9209) - SHORT
925
+ if (metadata.flash !== undefined) {
926
+ entries.push({
927
+ tag: 0x9209,
928
+ type: 3,
929
+ data: [metadata.flash & 0xff, (metadata.flash >> 8) & 0xff],
930
+ });
931
+ }
932
+ // Focal Length (0x920A) - RATIONAL
933
+ if (metadata.focalLength !== undefined) {
934
+ entries.push({ tag: 0x920a, type: 5, data: [] }); // Will add offset later
935
+ }
936
+ // User Comment (0x9286) - UNDEFINED
937
+ if (metadata.userComment !== undefined) {
938
+ entries.push({ tag: 0x9286, type: 7, data: [] }); // Will add offset later
939
+ }
940
+ // White Balance (0xA403) - SHORT
941
+ if (metadata.whiteBalance !== undefined) {
942
+ entries.push({
943
+ tag: 0xa403,
944
+ type: 3,
945
+ data: [
946
+ metadata.whiteBalance & 0xff,
947
+ (metadata.whiteBalance >> 8) & 0xff,
948
+ ],
949
+ });
950
+ }
951
+ // Lens Make (0xA433) - ASCII
952
+ if (metadata.lensMake !== undefined) {
953
+ entries.push({ tag: 0xa433, type: 2, data: [] }); // Will add offset later
954
+ }
955
+ // Lens Model (0xA434) - ASCII
956
+ if (metadata.lensModel !== undefined) {
957
+ entries.push({ tag: 0xa434, type: 2, data: [] }); // Will add offset later
958
+ }
959
+ const exifSubIfd = [];
960
+ const numEntries = entries.length;
961
+ exifSubIfd.push(numEntries & 0xff, (numEntries >> 8) & 0xff);
962
+ let dataOffset = exifIfdStart + 2 + numEntries * 12 + 4;
963
+ // Write entry headers
964
+ for (const entry of entries) {
965
+ exifSubIfd.push(entry.tag & 0xff, (entry.tag >> 8) & 0xff);
966
+ exifSubIfd.push(entry.type & 0xff, (entry.type >> 8) & 0xff);
967
+ if (entry.tag === 0x829a && metadata.exposureTime !== undefined) {
968
+ // Exposure Time - 1 RATIONAL
969
+ exifSubIfd.push(0x01, 0x00, 0x00, 0x00); // Count
970
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
971
+ dataOffset += 8;
972
+ }
973
+ else if (entry.tag === 0x829d && metadata.fNumber !== undefined) {
974
+ // FNumber - 1 RATIONAL
975
+ exifSubIfd.push(0x01, 0x00, 0x00, 0x00); // Count
976
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
977
+ dataOffset += 8;
978
+ }
979
+ else if (entry.tag === 0x920a && metadata.focalLength !== undefined) {
980
+ // Focal Length - 1 RATIONAL
981
+ exifSubIfd.push(0x01, 0x00, 0x00, 0x00); // Count
982
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
983
+ dataOffset += 8;
984
+ }
985
+ else if (entry.tag === 0x9286 && metadata.userComment !== undefined) {
986
+ // User Comment - UNDEFINED with character code
987
+ const commentBytes = new TextEncoder().encode(metadata.userComment);
988
+ const totalLength = 8 + commentBytes.length; // 8 bytes for character code
989
+ exifSubIfd.push(totalLength & 0xff, (totalLength >> 8) & 0xff, (totalLength >> 16) & 0xff, (totalLength >> 24) & 0xff);
990
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
991
+ dataOffset += totalLength;
992
+ }
993
+ else if (entry.tag === 0xa433 && metadata.lensMake !== undefined) {
994
+ // Lens Make - ASCII
995
+ const bytes = new TextEncoder().encode(metadata.lensMake + "\0");
996
+ exifSubIfd.push(bytes.length & 0xff, (bytes.length >> 8) & 0xff, (bytes.length >> 16) & 0xff, (bytes.length >> 24) & 0xff);
997
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
998
+ dataOffset += bytes.length;
999
+ }
1000
+ else if (entry.tag === 0xa434 && metadata.lensModel !== undefined) {
1001
+ // Lens Model - ASCII
1002
+ const bytes = new TextEncoder().encode(metadata.lensModel + "\0");
1003
+ exifSubIfd.push(bytes.length & 0xff, (bytes.length >> 8) & 0xff, (bytes.length >> 16) & 0xff, (bytes.length >> 24) & 0xff);
1004
+ exifSubIfd.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
1005
+ dataOffset += bytes.length;
1006
+ }
1007
+ else {
1008
+ // SHORT types stored inline
1009
+ exifSubIfd.push(0x01, 0x00, 0x00, 0x00); // Count
1010
+ exifSubIfd.push(...entry.data, 0x00, 0x00); // Value (4 bytes)
1011
+ }
1012
+ }
1013
+ // Next IFD offset
1014
+ exifSubIfd.push(0x00, 0x00, 0x00, 0x00);
1015
+ // Write data for RATIONAL and ASCII types
1016
+ for (const entry of entries) {
1017
+ if (entry.tag === 0x829a && metadata.exposureTime !== undefined) {
1018
+ // Convert exposure time to rational (numerator/denominator)
1019
+ const [num, den] = this.toRational(metadata.exposureTime);
1020
+ this.writeRational(exifSubIfd, num, den);
1021
+ }
1022
+ else if (entry.tag === 0x829d && metadata.fNumber !== undefined) {
1023
+ const [num, den] = this.toRational(metadata.fNumber);
1024
+ this.writeRational(exifSubIfd, num, den);
1025
+ }
1026
+ else if (entry.tag === 0x920a && metadata.focalLength !== undefined) {
1027
+ const [num, den] = this.toRational(metadata.focalLength);
1028
+ this.writeRational(exifSubIfd, num, den);
1029
+ }
1030
+ else if (entry.tag === 0x9286 && metadata.userComment !== undefined) {
1031
+ // Character code: ASCII (8 bytes)
1032
+ exifSubIfd.push(0x41, 0x53, 0x43, 0x49, 0x49, 0x00, 0x00, 0x00);
1033
+ const commentBytes = new TextEncoder().encode(metadata.userComment);
1034
+ for (const byte of commentBytes) {
1035
+ exifSubIfd.push(byte);
1036
+ }
1037
+ }
1038
+ else if (entry.tag === 0xa433 && metadata.lensMake !== undefined) {
1039
+ const bytes = new TextEncoder().encode(metadata.lensMake + "\0");
1040
+ for (const byte of bytes) {
1041
+ exifSubIfd.push(byte);
1042
+ }
1043
+ }
1044
+ else if (entry.tag === 0xa434 && metadata.lensModel !== undefined) {
1045
+ const bytes = new TextEncoder().encode(metadata.lensModel + "\0");
1046
+ for (const byte of bytes) {
1047
+ exifSubIfd.push(byte);
1048
+ }
1049
+ }
1050
+ }
1051
+ return exifSubIfd;
1052
+ }
1053
+ toRational(value) {
1054
+ // Convert decimal to rational representation
1055
+ // Try to find a reasonable denominator
1056
+ const denominators = [1, 10, 100, 1000, 10000, 100000, 1000000];
1057
+ for (const den of denominators) {
1058
+ const num = Math.round(value * den);
1059
+ if (Math.abs(num / den - value) < 0.000001) {
1060
+ return [num, den];
1061
+ }
1062
+ }
1063
+ // Fallback
1064
+ return [Math.round(value * 1000000), 1000000];
1065
+ }
1066
+ writeRational(output, numerator, denominator) {
1067
+ // Write as little endian
1068
+ output.push(numerator & 0xff, (numerator >> 8) & 0xff, (numerator >> 16) & 0xff, (numerator >> 24) & 0xff);
1069
+ output.push(denominator & 0xff, (denominator >> 8) & 0xff, (denominator >> 16) & 0xff, (denominator >> 24) & 0xff);
1070
+ }
1071
+ /**
1072
+ * Get the list of metadata fields supported by JPEG format
1073
+ */
1074
+ getSupportedMetadata() {
1075
+ return [
1076
+ "creationDate",
1077
+ "description",
1078
+ "author",
1079
+ "copyright",
1080
+ "cameraMake",
1081
+ "cameraModel",
1082
+ "orientation",
1083
+ "software",
1084
+ "latitude",
1085
+ "longitude",
1086
+ "iso",
1087
+ "exposureTime",
1088
+ "fNumber",
1089
+ "focalLength",
1090
+ "flash",
1091
+ "whiteBalance",
1092
+ "lensMake",
1093
+ "lensModel",
1094
+ "userComment",
1095
+ "dpiX",
1096
+ "dpiY",
1097
+ ];
1098
+ }
470
1099
  }
471
1100
  exports.JPEGFormat = JPEGFormat;