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