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.
- package/README.md +333 -168
- package/esm/mod.d.ts +2 -0
- package/esm/mod.js +2 -0
- package/esm/src/formats/apng.d.ts +13 -1
- package/esm/src/formats/apng.js +97 -0
- package/esm/src/formats/ascii.d.ts +11 -1
- package/esm/src/formats/ascii.js +24 -0
- package/esm/src/formats/avif.d.ts +96 -0
- package/esm/src/formats/avif.js +607 -0
- package/esm/src/formats/bmp.d.ts +11 -1
- package/esm/src/formats/bmp.js +73 -0
- package/esm/src/formats/dng.d.ts +13 -1
- package/esm/src/formats/dng.js +26 -4
- package/esm/src/formats/gif.d.ts +15 -2
- package/esm/src/formats/gif.js +146 -4
- package/esm/src/formats/heic.d.ts +96 -0
- package/esm/src/formats/heic.js +608 -0
- package/esm/src/formats/ico.d.ts +11 -1
- package/esm/src/formats/ico.js +28 -0
- package/esm/src/formats/jpeg.d.ts +19 -1
- package/esm/src/formats/jpeg.js +709 -4
- package/esm/src/formats/pam.d.ts +11 -1
- package/esm/src/formats/pam.js +66 -0
- package/esm/src/formats/pcx.d.ts +11 -1
- package/esm/src/formats/pcx.js +45 -0
- package/esm/src/formats/png.d.ts +13 -1
- package/esm/src/formats/png.js +87 -0
- package/esm/src/formats/png_base.d.ts +8 -0
- package/esm/src/formats/png_base.js +176 -3
- package/esm/src/formats/ppm.d.ts +11 -1
- package/esm/src/formats/ppm.js +34 -0
- package/esm/src/formats/tiff.d.ts +13 -1
- package/esm/src/formats/tiff.js +165 -0
- package/esm/src/formats/webp.d.ts +16 -2
- package/esm/src/formats/webp.js +303 -62
- package/esm/src/image.d.ts +60 -0
- package/esm/src/image.js +253 -5
- package/esm/src/types.d.ts +59 -1
- package/esm/src/utils/image_processing.d.ts +55 -0
- package/esm/src/utils/image_processing.js +210 -0
- package/esm/src/utils/metadata/xmp.d.ts +52 -0
- package/esm/src/utils/metadata/xmp.js +325 -0
- package/esm/src/utils/resize.d.ts +4 -0
- package/esm/src/utils/resize.js +74 -0
- package/package.json +18 -1
- package/script/mod.d.ts +2 -0
- package/script/mod.js +5 -1
- package/script/src/formats/apng.d.ts +13 -1
- package/script/src/formats/apng.js +97 -0
- package/script/src/formats/ascii.d.ts +11 -1
- package/script/src/formats/ascii.js +24 -0
- package/script/src/formats/avif.d.ts +96 -0
- package/script/src/formats/avif.js +611 -0
- package/script/src/formats/bmp.d.ts +11 -1
- package/script/src/formats/bmp.js +73 -0
- package/script/src/formats/dng.d.ts +13 -1
- package/script/src/formats/dng.js +26 -4
- package/script/src/formats/gif.d.ts +15 -2
- package/script/src/formats/gif.js +146 -4
- package/script/src/formats/heic.d.ts +96 -0
- package/script/src/formats/heic.js +612 -0
- package/script/src/formats/ico.d.ts +11 -1
- package/script/src/formats/ico.js +28 -0
- package/script/src/formats/jpeg.d.ts +19 -1
- package/script/src/formats/jpeg.js +709 -4
- package/script/src/formats/pam.d.ts +11 -1
- package/script/src/formats/pam.js +66 -0
- package/script/src/formats/pcx.d.ts +11 -1
- package/script/src/formats/pcx.js +45 -0
- package/script/src/formats/png.d.ts +13 -1
- package/script/src/formats/png.js +87 -0
- package/script/src/formats/png_base.d.ts +8 -0
- package/script/src/formats/png_base.js +176 -3
- package/script/src/formats/ppm.d.ts +11 -1
- package/script/src/formats/ppm.js +34 -0
- package/script/src/formats/tiff.d.ts +13 -1
- package/script/src/formats/tiff.js +165 -0
- package/script/src/formats/webp.d.ts +16 -2
- package/script/src/formats/webp.js +303 -62
- package/script/src/image.d.ts +60 -0
- package/script/src/image.js +251 -3
- package/script/src/types.d.ts +59 -1
- package/script/src/utils/image_processing.d.ts +55 -0
- package/script/src/utils/image_processing.js +216 -0
- package/script/src/utils/metadata/xmp.d.ts +52 -0
- package/script/src/utils/metadata/xmp.js +333 -0
- package/script/src/utils/resize.d.ts +4 -0
- package/script/src/utils/resize.js +75 -0
|
@@ -935,5 +935,170 @@ class TIFFFormat {
|
|
|
935
935
|
}
|
|
936
936
|
return rgba;
|
|
937
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* Get list of metadata fields supported by TIFF format
|
|
940
|
+
* TIFF supports extensive EXIF metadata including GPS and InteropIFD
|
|
941
|
+
*/
|
|
942
|
+
getSupportedMetadata() {
|
|
943
|
+
return [
|
|
944
|
+
"creationDate",
|
|
945
|
+
"description",
|
|
946
|
+
"author",
|
|
947
|
+
"copyright",
|
|
948
|
+
"cameraMake",
|
|
949
|
+
"cameraModel",
|
|
950
|
+
"orientation",
|
|
951
|
+
"software",
|
|
952
|
+
"iso",
|
|
953
|
+
"exposureTime",
|
|
954
|
+
"fNumber",
|
|
955
|
+
"focalLength",
|
|
956
|
+
"flash",
|
|
957
|
+
"whiteBalance",
|
|
958
|
+
"lensMake",
|
|
959
|
+
"lensModel",
|
|
960
|
+
"userComment",
|
|
961
|
+
"latitude",
|
|
962
|
+
"longitude",
|
|
963
|
+
"dpiX",
|
|
964
|
+
"dpiY",
|
|
965
|
+
"physicalWidth",
|
|
966
|
+
"physicalHeight",
|
|
967
|
+
];
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Extract metadata from TIFF data without fully decoding the pixel data
|
|
971
|
+
* This quickly parses IFD entries to extract metadata
|
|
972
|
+
* @param data Raw TIFF data
|
|
973
|
+
* @returns Extracted metadata or undefined
|
|
974
|
+
*/
|
|
975
|
+
extractMetadata(data) {
|
|
976
|
+
if (!this.canDecode(data)) {
|
|
977
|
+
return Promise.resolve(undefined);
|
|
978
|
+
}
|
|
979
|
+
// Determine byte order
|
|
980
|
+
const isLittleEndian = data[0] === 0x49 && data[1] === 0x49;
|
|
981
|
+
if (!isLittleEndian && !(data[0] === 0x4d && data[1] === 0x4d)) {
|
|
982
|
+
return Promise.resolve(undefined);
|
|
983
|
+
}
|
|
984
|
+
// Read IFD offset
|
|
985
|
+
const ifdOffset = this.readUint32(data, 4, isLittleEndian);
|
|
986
|
+
if (ifdOffset >= data.length) {
|
|
987
|
+
return Promise.resolve(undefined);
|
|
988
|
+
}
|
|
989
|
+
// Get dimensions for DPI calculation
|
|
990
|
+
const width = this.getIFDValue(data, ifdOffset, 0x0100, isLittleEndian);
|
|
991
|
+
const height = this.getIFDValue(data, ifdOffset, 0x0101, isLittleEndian);
|
|
992
|
+
if (!width || !height) {
|
|
993
|
+
return Promise.resolve(undefined);
|
|
994
|
+
}
|
|
995
|
+
// Extract metadata from TIFF tags
|
|
996
|
+
const metadata = {
|
|
997
|
+
format: "tiff",
|
|
998
|
+
frameCount: 1,
|
|
999
|
+
bitDepth: 8,
|
|
1000
|
+
};
|
|
1001
|
+
// Get compression type
|
|
1002
|
+
const compression = this.getIFDValue(data, ifdOffset, 0x0103, isLittleEndian);
|
|
1003
|
+
if (compression === 1) {
|
|
1004
|
+
metadata.compression = "none";
|
|
1005
|
+
}
|
|
1006
|
+
else if (compression === 5) {
|
|
1007
|
+
metadata.compression = "lzw";
|
|
1008
|
+
}
|
|
1009
|
+
else if (compression === 7) {
|
|
1010
|
+
metadata.compression = "jpeg";
|
|
1011
|
+
}
|
|
1012
|
+
else if (compression === 32773) {
|
|
1013
|
+
metadata.compression = "packbits";
|
|
1014
|
+
}
|
|
1015
|
+
else if (compression) {
|
|
1016
|
+
metadata.compression = `unknown-${compression}`;
|
|
1017
|
+
}
|
|
1018
|
+
// Get bits per sample
|
|
1019
|
+
const bitsPerSample = this.getIFDValue(data, ifdOffset, 0x0102, isLittleEndian);
|
|
1020
|
+
if (bitsPerSample) {
|
|
1021
|
+
metadata.bitDepth = bitsPerSample;
|
|
1022
|
+
}
|
|
1023
|
+
// Get photometric interpretation for color type
|
|
1024
|
+
const photometric = this.getIFDValue(data, ifdOffset, 0x0106, isLittleEndian);
|
|
1025
|
+
const samplesPerPixel = this.getIFDValue(data, ifdOffset, 0x0115, isLittleEndian);
|
|
1026
|
+
if (photometric === 0 || photometric === 1) {
|
|
1027
|
+
metadata.colorType = "grayscale";
|
|
1028
|
+
}
|
|
1029
|
+
else if (photometric === 2) {
|
|
1030
|
+
if (samplesPerPixel === 3) {
|
|
1031
|
+
metadata.colorType = "rgb";
|
|
1032
|
+
}
|
|
1033
|
+
else if (samplesPerPixel === 4) {
|
|
1034
|
+
metadata.colorType = "rgba";
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
else if (photometric === 3) {
|
|
1038
|
+
metadata.colorType = "indexed";
|
|
1039
|
+
}
|
|
1040
|
+
// Count IFDs (pages/frames) by following the chain
|
|
1041
|
+
let currentIfdOffset = ifdOffset;
|
|
1042
|
+
let frameCount = 0;
|
|
1043
|
+
while (currentIfdOffset > 0 && currentIfdOffset < data.length &&
|
|
1044
|
+
frameCount < 1000) {
|
|
1045
|
+
frameCount++;
|
|
1046
|
+
// Read number of entries in this IFD
|
|
1047
|
+
const numEntries = this.readUint16(data, currentIfdOffset, isLittleEndian);
|
|
1048
|
+
// Next IFD offset is after all entries (2 + numEntries * 12 bytes)
|
|
1049
|
+
const nextIfdOffsetPos = currentIfdOffset + 2 + (numEntries * 12);
|
|
1050
|
+
if (nextIfdOffsetPos + 4 > data.length)
|
|
1051
|
+
break;
|
|
1052
|
+
currentIfdOffset = this.readUint32(data, nextIfdOffsetPos, isLittleEndian);
|
|
1053
|
+
}
|
|
1054
|
+
metadata.frameCount = frameCount;
|
|
1055
|
+
// XResolution (0x011a) and YResolution (0x011b) for DPI
|
|
1056
|
+
const xResOffset = this.getIFDValue(data, ifdOffset, 0x011a, isLittleEndian);
|
|
1057
|
+
const yResOffset = this.getIFDValue(data, ifdOffset, 0x011b, isLittleEndian);
|
|
1058
|
+
if (xResOffset && xResOffset < data.length - 8) {
|
|
1059
|
+
const numerator = this.readUint32(data, xResOffset, isLittleEndian);
|
|
1060
|
+
const denominator = this.readUint32(data, xResOffset + 4, isLittleEndian);
|
|
1061
|
+
if (denominator > 0) {
|
|
1062
|
+
metadata.dpiX = Math.round(numerator / denominator);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
if (yResOffset && yResOffset < data.length - 8) {
|
|
1066
|
+
const numerator = this.readUint32(data, yResOffset, isLittleEndian);
|
|
1067
|
+
const denominator = this.readUint32(data, yResOffset + 4, isLittleEndian);
|
|
1068
|
+
if (denominator > 0) {
|
|
1069
|
+
metadata.dpiY = Math.round(numerator / denominator);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
// Calculate physical dimensions if DPI is available
|
|
1073
|
+
if (metadata.dpiX && metadata.dpiY) {
|
|
1074
|
+
metadata.physicalWidth = width / metadata.dpiX;
|
|
1075
|
+
metadata.physicalHeight = height / metadata.dpiY;
|
|
1076
|
+
}
|
|
1077
|
+
// ImageDescription (0x010e)
|
|
1078
|
+
const descOffset = this.getIFDValue(data, ifdOffset, 0x010e, isLittleEndian);
|
|
1079
|
+
if (descOffset && descOffset < data.length) {
|
|
1080
|
+
metadata.description = this.readString(data, descOffset);
|
|
1081
|
+
}
|
|
1082
|
+
// Artist (0x013b)
|
|
1083
|
+
const artistOffset = this.getIFDValue(data, ifdOffset, 0x013b, isLittleEndian);
|
|
1084
|
+
if (artistOffset && artistOffset < data.length) {
|
|
1085
|
+
metadata.author = this.readString(data, artistOffset);
|
|
1086
|
+
}
|
|
1087
|
+
// Copyright (0x8298)
|
|
1088
|
+
const copyrightOffset = this.getIFDValue(data, ifdOffset, 0x8298, isLittleEndian);
|
|
1089
|
+
if (copyrightOffset && copyrightOffset < data.length) {
|
|
1090
|
+
metadata.copyright = this.readString(data, copyrightOffset);
|
|
1091
|
+
}
|
|
1092
|
+
// DateTime (0x0132)
|
|
1093
|
+
const dateTimeOffset = this.getIFDValue(data, ifdOffset, 0x0132, isLittleEndian);
|
|
1094
|
+
if (dateTimeOffset && dateTimeOffset < data.length) {
|
|
1095
|
+
const dateStr = this.readString(data, dateTimeOffset);
|
|
1096
|
+
const match = dateStr.match(/^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
|
1097
|
+
if (match) {
|
|
1098
|
+
metadata.creationDate = new Date(parseInt(match[1]), parseInt(match[2]) - 1, parseInt(match[3]), parseInt(match[4]), parseInt(match[5]), parseInt(match[6]));
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return Promise.resolve(Object.keys(metadata).length > 0 ? metadata : undefined);
|
|
1102
|
+
}
|
|
938
1103
|
}
|
|
939
1104
|
exports.TIFFFormat = TIFFFormat;
|
|
@@ -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
|
-
|
|
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
|
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.WebPFormat = void 0;
|
|
37
37
|
const security_js_1 = require("../utils/security.js");
|
|
38
38
|
const byte_utils_js_1 = require("../utils/byte_utils.js");
|
|
39
|
+
const xmp_js_1 = require("../utils/metadata/xmp.js");
|
|
39
40
|
// Default quality for WebP encoding when not specified
|
|
40
41
|
const DEFAULT_WEBP_QUALITY = 90;
|
|
41
42
|
/**
|
|
@@ -255,6 +256,7 @@ class WebPFormat {
|
|
|
255
256
|
const numEntries = littleEndian
|
|
256
257
|
? data[ifd0Offset] | (data[ifd0Offset + 1] << 8)
|
|
257
258
|
: (data[ifd0Offset] << 8) | data[ifd0Offset + 1];
|
|
259
|
+
let gpsIfdOffset = 0;
|
|
258
260
|
// Parse basic EXIF tags (simplified version)
|
|
259
261
|
for (let i = 0; i < numEntries && i < 50; i++) {
|
|
260
262
|
const entryOffset = ifd0Offset + 2 + i * 12;
|
|
@@ -281,32 +283,103 @@ class WebPFormat {
|
|
|
281
283
|
}
|
|
282
284
|
}
|
|
283
285
|
}
|
|
286
|
+
// GPS IFD Pointer tag (0x8825)
|
|
287
|
+
if (tag === 0x8825) {
|
|
288
|
+
gpsIfdOffset = littleEndian
|
|
289
|
+
? data[entryOffset + 8] | (data[entryOffset + 9] << 8) |
|
|
290
|
+
(data[entryOffset + 10] << 16) | (data[entryOffset + 11] << 24)
|
|
291
|
+
: (data[entryOffset + 8] << 24) | (data[entryOffset + 9] << 16) |
|
|
292
|
+
(data[entryOffset + 10] << 8) | data[entryOffset + 11];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Parse GPS IFD if present
|
|
296
|
+
if (gpsIfdOffset > 0 && gpsIfdOffset + 2 <= data.length) {
|
|
297
|
+
this.parseGPSIFD(data, gpsIfdOffset, littleEndian, metadata);
|
|
284
298
|
}
|
|
285
299
|
}
|
|
286
300
|
catch (_e) {
|
|
287
301
|
// Ignore EXIF parsing errors
|
|
288
302
|
}
|
|
289
303
|
}
|
|
304
|
+
parseGPSIFD(data, gpsIfdOffset, littleEndian, metadata) {
|
|
305
|
+
try {
|
|
306
|
+
const numEntries = littleEndian
|
|
307
|
+
? data[gpsIfdOffset] | (data[gpsIfdOffset + 1] << 8)
|
|
308
|
+
: (data[gpsIfdOffset] << 8) | data[gpsIfdOffset + 1];
|
|
309
|
+
let latRef = "";
|
|
310
|
+
let lonRef = "";
|
|
311
|
+
let latitude;
|
|
312
|
+
let longitude;
|
|
313
|
+
for (let i = 0; i < numEntries; i++) {
|
|
314
|
+
const entryOffset = gpsIfdOffset + 2 + i * 12;
|
|
315
|
+
if (entryOffset + 12 > data.length)
|
|
316
|
+
break;
|
|
317
|
+
const tag = littleEndian
|
|
318
|
+
? data[entryOffset] | (data[entryOffset + 1] << 8)
|
|
319
|
+
: (data[entryOffset] << 8) | data[entryOffset + 1];
|
|
320
|
+
const type = littleEndian
|
|
321
|
+
? data[entryOffset + 2] | (data[entryOffset + 3] << 8)
|
|
322
|
+
: (data[entryOffset + 2] << 8) | data[entryOffset + 3];
|
|
323
|
+
const valueOffset = littleEndian
|
|
324
|
+
? data[entryOffset + 8] | (data[entryOffset + 9] << 8) |
|
|
325
|
+
(data[entryOffset + 10] << 16) | (data[entryOffset + 11] << 24)
|
|
326
|
+
: (data[entryOffset + 8] << 24) | (data[entryOffset + 9] << 16) |
|
|
327
|
+
(data[entryOffset + 10] << 8) | data[entryOffset + 11];
|
|
328
|
+
// GPSLatitudeRef (0x0001)
|
|
329
|
+
if (tag === 0x0001 && type === 2) {
|
|
330
|
+
latRef = String.fromCharCode(data[entryOffset + 8]);
|
|
331
|
+
}
|
|
332
|
+
// GPSLatitude (0x0002)
|
|
333
|
+
if (tag === 0x0002 && type === 5 && valueOffset + 24 <= data.length) {
|
|
334
|
+
const degrees = this.readRational(data, valueOffset, littleEndian);
|
|
335
|
+
const minutes = this.readRational(data, valueOffset + 8, littleEndian);
|
|
336
|
+
const seconds = this.readRational(data, valueOffset + 16, littleEndian);
|
|
337
|
+
latitude = degrees + minutes / 60 + seconds / 3600;
|
|
338
|
+
}
|
|
339
|
+
// GPSLongitudeRef (0x0003)
|
|
340
|
+
if (tag === 0x0003 && type === 2) {
|
|
341
|
+
lonRef = String.fromCharCode(data[entryOffset + 8]);
|
|
342
|
+
}
|
|
343
|
+
// GPSLongitude (0x0004)
|
|
344
|
+
if (tag === 0x0004 && type === 5 && valueOffset + 24 <= data.length) {
|
|
345
|
+
const degrees = this.readRational(data, valueOffset, littleEndian);
|
|
346
|
+
const minutes = this.readRational(data, valueOffset + 8, littleEndian);
|
|
347
|
+
const seconds = this.readRational(data, valueOffset + 16, littleEndian);
|
|
348
|
+
longitude = degrees + minutes / 60 + seconds / 3600;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Apply hemisphere references
|
|
352
|
+
if (latitude !== undefined && latRef) {
|
|
353
|
+
metadata.latitude = latRef === "S" ? -latitude : latitude;
|
|
354
|
+
}
|
|
355
|
+
if (longitude !== undefined && lonRef) {
|
|
356
|
+
metadata.longitude = lonRef === "W" ? -longitude : longitude;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
catch (_e) {
|
|
360
|
+
// Ignore GPS parsing errors
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
readRational(data, offset, littleEndian) {
|
|
364
|
+
const numerator = littleEndian
|
|
365
|
+
? data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) |
|
|
366
|
+
(data[offset + 3] << 24)
|
|
367
|
+
: (data[offset] << 24) | (data[offset + 1] << 16) |
|
|
368
|
+
(data[offset + 2] << 8) | data[offset + 3];
|
|
369
|
+
const denominator = littleEndian
|
|
370
|
+
? data[offset + 4] | (data[offset + 5] << 8) | (data[offset + 6] << 16) |
|
|
371
|
+
(data[offset + 7] << 24)
|
|
372
|
+
: (data[offset + 4] << 24) | (data[offset + 5] << 16) |
|
|
373
|
+
(data[offset + 6] << 8) | data[offset + 7];
|
|
374
|
+
return denominator !== 0 ? numerator / denominator : 0;
|
|
375
|
+
}
|
|
290
376
|
parseXMP(data, metadata) {
|
|
291
|
-
// XMP
|
|
377
|
+
// Parse XMP using the centralized utility
|
|
292
378
|
try {
|
|
293
379
|
const xmpStr = new TextDecoder().decode(data);
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
metadata.title = titleMatch[1].trim();
|
|
298
|
-
// Extract description
|
|
299
|
-
const descMatch = xmpStr.match(/<dc:description[^>]*>([^<]+)<\/dc:description>/);
|
|
300
|
-
if (descMatch)
|
|
301
|
-
metadata.description = descMatch[1].trim();
|
|
302
|
-
// Extract creator/author
|
|
303
|
-
const creatorMatch = xmpStr.match(/<dc:creator[^>]*>([^<]+)<\/dc:creator>/);
|
|
304
|
-
if (creatorMatch)
|
|
305
|
-
metadata.author = creatorMatch[1].trim();
|
|
306
|
-
// Extract rights/copyright
|
|
307
|
-
const rightsMatch = xmpStr.match(/<dc:rights[^>]*>([^<]+)<\/dc:rights>/);
|
|
308
|
-
if (rightsMatch)
|
|
309
|
-
metadata.copyright = rightsMatch[1].trim();
|
|
380
|
+
const parsedMetadata = (0, xmp_js_1.parseXMP)(xmpStr);
|
|
381
|
+
// Merge parsed metadata into the existing metadata object
|
|
382
|
+
Object.assign(metadata, parsedMetadata);
|
|
310
383
|
}
|
|
311
384
|
catch (_e) {
|
|
312
385
|
// Ignore XMP parsing errors
|
|
@@ -320,8 +393,9 @@ class WebPFormat {
|
|
|
320
393
|
chunks.push(webpData.slice(0, 12));
|
|
321
394
|
// Create metadata chunks
|
|
322
395
|
const metadataChunks = [];
|
|
323
|
-
// Create EXIF chunk if we have date or
|
|
324
|
-
if (metadata.creationDate
|
|
396
|
+
// Create EXIF chunk if we have date or GPS data
|
|
397
|
+
if (metadata.creationDate ||
|
|
398
|
+
(metadata.latitude !== undefined && metadata.longitude !== undefined)) {
|
|
325
399
|
const exifData = this.createEXIFChunk(metadata);
|
|
326
400
|
if (exifData) {
|
|
327
401
|
metadataChunks.push(exifData);
|
|
@@ -377,7 +451,10 @@ class WebPFormat {
|
|
|
377
451
|
return finalData;
|
|
378
452
|
}
|
|
379
453
|
createEXIFChunk(metadata) {
|
|
380
|
-
|
|
454
|
+
const hasDate = metadata.creationDate !== undefined;
|
|
455
|
+
const hasGPS = metadata.latitude !== undefined &&
|
|
456
|
+
metadata.longitude !== undefined;
|
|
457
|
+
if (!hasDate && !hasGPS)
|
|
381
458
|
return null;
|
|
382
459
|
const exifData = [];
|
|
383
460
|
// Byte order marker (little endian)
|
|
@@ -385,21 +462,47 @@ class WebPFormat {
|
|
|
385
462
|
exifData.push(0x2a, 0x00); // 42
|
|
386
463
|
// IFD0 offset
|
|
387
464
|
exifData.push(0x08, 0x00, 0x00, 0x00);
|
|
388
|
-
// Number of entries
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
465
|
+
// Number of entries (DateTime + GPS IFD pointer if needed)
|
|
466
|
+
const numEntries = (hasDate ? 1 : 0) + (hasGPS ? 1 : 0);
|
|
467
|
+
exifData.push(numEntries & 0xff, (numEntries >> 8) & 0xff);
|
|
468
|
+
let dataOffset = 8 + 2 + numEntries * 12 + 4;
|
|
469
|
+
// DateTime entry (if present)
|
|
470
|
+
if (hasDate) {
|
|
471
|
+
const date = metadata.creationDate;
|
|
472
|
+
const dateStr = `${date.getFullYear()}:${String(date.getMonth() + 1).padStart(2, "0")}:${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}\0`;
|
|
473
|
+
const dateBytes = new TextEncoder().encode(dateStr);
|
|
474
|
+
// Tag 0x0132, Type 2 (ASCII), Count, Offset
|
|
475
|
+
exifData.push(0x32, 0x01, 0x02, 0x00);
|
|
476
|
+
exifData.push(dateBytes.length & 0xff, (dateBytes.length >> 8) & 0xff, (dateBytes.length >> 16) & 0xff, (dateBytes.length >> 24) & 0xff);
|
|
477
|
+
exifData.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
|
|
478
|
+
dataOffset += dateBytes.length;
|
|
479
|
+
}
|
|
480
|
+
// GPS IFD pointer (if present)
|
|
481
|
+
let gpsIfdOffset = 0;
|
|
482
|
+
if (hasGPS) {
|
|
483
|
+
gpsIfdOffset = dataOffset;
|
|
484
|
+
exifData.push(0x25, 0x88); // Tag 0x8825
|
|
485
|
+
exifData.push(0x04, 0x00); // Type LONG
|
|
486
|
+
exifData.push(0x01, 0x00, 0x00, 0x00); // Count 1
|
|
487
|
+
exifData.push(gpsIfdOffset & 0xff, (gpsIfdOffset >> 8) & 0xff, (gpsIfdOffset >> 16) & 0xff, (gpsIfdOffset >> 24) & 0xff);
|
|
488
|
+
}
|
|
398
489
|
// Next IFD
|
|
399
490
|
exifData.push(0x00, 0x00, 0x00, 0x00);
|
|
400
|
-
// Date string data
|
|
401
|
-
|
|
402
|
-
|
|
491
|
+
// Date string data (if present)
|
|
492
|
+
if (hasDate) {
|
|
493
|
+
const date = metadata.creationDate;
|
|
494
|
+
const dateStr = `${date.getFullYear()}:${String(date.getMonth() + 1).padStart(2, "0")}:${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}\0`;
|
|
495
|
+
const dateBytes = new TextEncoder().encode(dateStr);
|
|
496
|
+
for (const byte of dateBytes) {
|
|
497
|
+
exifData.push(byte);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// GPS IFD data (if present)
|
|
501
|
+
if (hasGPS) {
|
|
502
|
+
const gpsIfd = this.createGPSIFD(metadata, gpsIfdOffset);
|
|
503
|
+
for (const byte of gpsIfd) {
|
|
504
|
+
exifData.push(byte);
|
|
505
|
+
}
|
|
403
506
|
}
|
|
404
507
|
// Create chunk header
|
|
405
508
|
const chunkData = new Uint8Array(exifData);
|
|
@@ -412,29 +515,59 @@ class WebPFormat {
|
|
|
412
515
|
chunk.set(chunkData, 8);
|
|
413
516
|
return chunk;
|
|
414
517
|
}
|
|
518
|
+
createGPSIFD(metadata, gpsIfdStart) {
|
|
519
|
+
const gps = [];
|
|
520
|
+
const numEntries = 4;
|
|
521
|
+
gps.push(numEntries & 0xff, (numEntries >> 8) & 0xff);
|
|
522
|
+
const latitude = metadata.latitude;
|
|
523
|
+
const longitude = metadata.longitude;
|
|
524
|
+
const absLat = Math.abs(latitude);
|
|
525
|
+
const absLon = Math.abs(longitude);
|
|
526
|
+
const latDeg = Math.floor(absLat);
|
|
527
|
+
const latMin = Math.floor((absLat - latDeg) * 60);
|
|
528
|
+
const latSec = ((absLat - latDeg) * 60 - latMin) * 60;
|
|
529
|
+
const lonDeg = Math.floor(absLon);
|
|
530
|
+
const lonMin = Math.floor((absLon - lonDeg) * 60);
|
|
531
|
+
const lonSec = ((absLon - lonDeg) * 60 - lonMin) * 60;
|
|
532
|
+
let dataOffset = gpsIfdStart + 2 + numEntries * 12 + 4;
|
|
533
|
+
// GPSLatitudeRef
|
|
534
|
+
gps.push(0x01, 0x00);
|
|
535
|
+
gps.push(0x02, 0x00);
|
|
536
|
+
gps.push(0x02, 0x00, 0x00, 0x00);
|
|
537
|
+
gps.push(latitude >= 0 ? 78 : 83, 0x00, 0x00, 0x00);
|
|
538
|
+
// GPSLatitude
|
|
539
|
+
gps.push(0x02, 0x00);
|
|
540
|
+
gps.push(0x05, 0x00);
|
|
541
|
+
gps.push(0x03, 0x00, 0x00, 0x00);
|
|
542
|
+
gps.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
|
|
543
|
+
dataOffset += 24;
|
|
544
|
+
// GPSLongitudeRef
|
|
545
|
+
gps.push(0x03, 0x00);
|
|
546
|
+
gps.push(0x02, 0x00);
|
|
547
|
+
gps.push(0x02, 0x00, 0x00, 0x00);
|
|
548
|
+
gps.push(longitude >= 0 ? 69 : 87, 0x00, 0x00, 0x00);
|
|
549
|
+
// GPSLongitude
|
|
550
|
+
gps.push(0x04, 0x00);
|
|
551
|
+
gps.push(0x05, 0x00);
|
|
552
|
+
gps.push(0x03, 0x00, 0x00, 0x00);
|
|
553
|
+
gps.push(dataOffset & 0xff, (dataOffset >> 8) & 0xff, (dataOffset >> 16) & 0xff, (dataOffset >> 24) & 0xff);
|
|
554
|
+
gps.push(0x00, 0x00, 0x00, 0x00);
|
|
555
|
+
// Write rationals
|
|
556
|
+
this.writeRational(gps, latDeg, 1);
|
|
557
|
+
this.writeRational(gps, latMin, 1);
|
|
558
|
+
this.writeRational(gps, Math.round(latSec * 1000000), 1000000);
|
|
559
|
+
this.writeRational(gps, lonDeg, 1);
|
|
560
|
+
this.writeRational(gps, lonMin, 1);
|
|
561
|
+
this.writeRational(gps, Math.round(lonSec * 1000000), 1000000);
|
|
562
|
+
return gps;
|
|
563
|
+
}
|
|
564
|
+
writeRational(output, numerator, denominator) {
|
|
565
|
+
output.push(numerator & 0xff, (numerator >> 8) & 0xff, (numerator >> 16) & 0xff, (numerator >> 24) & 0xff);
|
|
566
|
+
output.push(denominator & 0xff, (denominator >> 8) & 0xff, (denominator >> 16) & 0xff, (denominator >> 24) & 0xff);
|
|
567
|
+
}
|
|
415
568
|
createXMPChunk(metadata) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
xmpParts.push('<x:xmpmeta xmlns:x="adobe:ns:meta/">');
|
|
419
|
-
xmpParts.push('<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">');
|
|
420
|
-
xmpParts.push('<rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/">');
|
|
421
|
-
if (metadata.title) {
|
|
422
|
-
xmpParts.push(`<dc:title>${this.escapeXML(metadata.title)}</dc:title>`);
|
|
423
|
-
}
|
|
424
|
-
if (metadata.description) {
|
|
425
|
-
xmpParts.push(`<dc:description>${this.escapeXML(metadata.description)}</dc:description>`);
|
|
426
|
-
}
|
|
427
|
-
if (metadata.author) {
|
|
428
|
-
xmpParts.push(`<dc:creator>${this.escapeXML(metadata.author)}</dc:creator>`);
|
|
429
|
-
}
|
|
430
|
-
if (metadata.copyright) {
|
|
431
|
-
xmpParts.push(`<dc:rights>${this.escapeXML(metadata.copyright)}</dc:rights>`);
|
|
432
|
-
}
|
|
433
|
-
xmpParts.push("</rdf:Description>");
|
|
434
|
-
xmpParts.push("</rdf:RDF>");
|
|
435
|
-
xmpParts.push("</x:xmpmeta>");
|
|
436
|
-
xmpParts.push('<?xpacket end="w"?>');
|
|
437
|
-
const xmpStr = xmpParts.join("\n");
|
|
569
|
+
// Use the centralized XMP utility to create the XMP packet
|
|
570
|
+
const xmpStr = (0, xmp_js_1.createXMP)(metadata);
|
|
438
571
|
const xmpData = new TextEncoder().encode(xmpStr);
|
|
439
572
|
// Create chunk
|
|
440
573
|
const chunk = new Uint8Array(8 + xmpData.length);
|
|
@@ -446,13 +579,121 @@ class WebPFormat {
|
|
|
446
579
|
chunk.set(xmpData, 8);
|
|
447
580
|
return chunk;
|
|
448
581
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
582
|
+
/**
|
|
583
|
+
* Get the list of metadata fields supported by WebP format
|
|
584
|
+
*/
|
|
585
|
+
getSupportedMetadata() {
|
|
586
|
+
return [
|
|
587
|
+
// EXIF chunk
|
|
588
|
+
"creationDate",
|
|
589
|
+
"latitude",
|
|
590
|
+
"longitude",
|
|
591
|
+
// XMP chunk (enhanced support)
|
|
592
|
+
"title",
|
|
593
|
+
"description",
|
|
594
|
+
"author",
|
|
595
|
+
"copyright",
|
|
596
|
+
"cameraMake",
|
|
597
|
+
"cameraModel",
|
|
598
|
+
"orientation",
|
|
599
|
+
"software",
|
|
600
|
+
"iso",
|
|
601
|
+
"exposureTime",
|
|
602
|
+
"fNumber",
|
|
603
|
+
"focalLength",
|
|
604
|
+
];
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Extract metadata from WebP data without fully decoding the pixel data
|
|
608
|
+
* This quickly parses RIFF chunks to extract EXIF and XMP metadata
|
|
609
|
+
* @param data Raw WebP data
|
|
610
|
+
* @returns Extracted metadata or undefined
|
|
611
|
+
*/
|
|
612
|
+
extractMetadata(data) {
|
|
613
|
+
if (!this.canDecode(data)) {
|
|
614
|
+
return Promise.resolve(undefined);
|
|
615
|
+
}
|
|
616
|
+
const metadata = {
|
|
617
|
+
format: "webp",
|
|
618
|
+
frameCount: 1,
|
|
619
|
+
bitDepth: 8,
|
|
620
|
+
};
|
|
621
|
+
let pos = 12; // Skip "RIFF" + size + "WEBP"
|
|
622
|
+
const readUint32LE = (data, offset) => {
|
|
623
|
+
return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) |
|
|
624
|
+
(data[offset + 3] << 24);
|
|
625
|
+
};
|
|
626
|
+
const readUint16LE = (data, offset) => {
|
|
627
|
+
return data[offset] | (data[offset + 1] << 8);
|
|
628
|
+
};
|
|
629
|
+
// Parse chunks for metadata
|
|
630
|
+
while (pos + 8 <= data.length) {
|
|
631
|
+
const chunkType = String.fromCharCode(data[pos], data[pos + 1], data[pos + 2], data[pos + 3]);
|
|
632
|
+
const chunkSize = readUint32LE(data, pos + 4);
|
|
633
|
+
pos += 8;
|
|
634
|
+
// Stop if we've gone past the end
|
|
635
|
+
if (pos + chunkSize > data.length)
|
|
636
|
+
break;
|
|
637
|
+
const chunkData = data.slice(pos, pos + chunkSize);
|
|
638
|
+
if (chunkType === "VP8 ") {
|
|
639
|
+
// Lossy VP8 chunk
|
|
640
|
+
metadata.compression = "vp8";
|
|
641
|
+
metadata.colorType = "rgb";
|
|
642
|
+
}
|
|
643
|
+
else if (chunkType === "VP8L") {
|
|
644
|
+
// Lossless VP8L chunk
|
|
645
|
+
metadata.compression = "vp8l";
|
|
646
|
+
metadata.colorType = "rgba";
|
|
647
|
+
}
|
|
648
|
+
else if (chunkType === "VP8X") {
|
|
649
|
+
// Extended format chunk - contains animation info
|
|
650
|
+
if (chunkData.length >= 10) {
|
|
651
|
+
const flags = chunkData[0];
|
|
652
|
+
const _hasAnimation = (flags & 0x02) !== 0;
|
|
653
|
+
const hasAlpha = (flags & 0x10) !== 0;
|
|
654
|
+
if (hasAlpha) {
|
|
655
|
+
metadata.colorType = "rgba";
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
metadata.colorType = "rgb";
|
|
659
|
+
}
|
|
660
|
+
// Animation is handled in ANIM chunk
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
else if (chunkType === "ANIM") {
|
|
664
|
+
// Animation parameters chunk
|
|
665
|
+
if (chunkData.length >= 6) {
|
|
666
|
+
// Background color at bytes 0-3
|
|
667
|
+
// Loop count at bytes 4-5
|
|
668
|
+
const _loopCount = readUint16LE(chunkData, 4);
|
|
669
|
+
// Note: Frame count is not directly in ANIM chunk, need to count ANMF chunks
|
|
670
|
+
// Reset frame count to 0 to start counting ANMF frames
|
|
671
|
+
metadata.frameCount = 0;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
else if (chunkType === "ANMF") {
|
|
675
|
+
// Animation frame - count frames
|
|
676
|
+
if (metadata.frameCount !== undefined) {
|
|
677
|
+
metadata.frameCount++;
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
metadata.frameCount = 1;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
else if (chunkType === "EXIF") {
|
|
684
|
+
// EXIF metadata chunk
|
|
685
|
+
this.parseEXIF(chunkData, metadata);
|
|
686
|
+
}
|
|
687
|
+
else if (chunkType === "XMP ") {
|
|
688
|
+
// XMP metadata chunk
|
|
689
|
+
this.parseXMP(chunkData, metadata);
|
|
690
|
+
}
|
|
691
|
+
pos += chunkSize;
|
|
692
|
+
// Chunks are padded to even length
|
|
693
|
+
if (chunkSize % 2 === 1)
|
|
694
|
+
pos++;
|
|
695
|
+
}
|
|
696
|
+
return Promise.resolve(Object.keys(metadata).length > 0 ? metadata : undefined);
|
|
456
697
|
}
|
|
457
698
|
}
|
|
458
699
|
exports.WebPFormat = WebPFormat;
|