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.
- package/README.md +128 -7
- package/esm/src/formats/jpeg.d.ts +12 -1
- package/esm/src/formats/jpeg.js +633 -4
- package/esm/src/formats/png_base.d.ts +8 -0
- package/esm/src/formats/png_base.js +176 -3
- package/esm/src/formats/tiff.d.ts +6 -1
- package/esm/src/formats/tiff.js +31 -0
- package/esm/src/formats/webp.d.ts +9 -2
- package/esm/src/formats/webp.js +211 -62
- package/esm/src/image.d.ts +51 -0
- package/esm/src/image.js +225 -5
- package/esm/src/types.d.ts +41 -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 +1 -1
- package/script/src/formats/jpeg.d.ts +12 -1
- package/script/src/formats/jpeg.js +633 -4
- package/script/src/formats/png_base.d.ts +8 -0
- package/script/src/formats/png_base.js +176 -3
- package/script/src/formats/tiff.d.ts +6 -1
- package/script/src/formats/tiff.js +31 -0
- package/script/src/formats/webp.d.ts +9 -2
- package/script/src/formats/webp.js +211 -62
- package/script/src/image.d.ts +51 -0
- package/script/src/image.js +223 -3
- package/script/src/types.d.ts +41 -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/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,28 @@ 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
|
+
];
|
|
420
569
|
}
|
|
421
570
|
}
|
package/esm/src/image.d.ts
CHANGED
|
@@ -79,6 +79,12 @@ export declare class Image {
|
|
|
79
79
|
* @returns Image instance
|
|
80
80
|
*/
|
|
81
81
|
static decode(data: Uint8Array, format?: string): Promise<Image>;
|
|
82
|
+
/**
|
|
83
|
+
* Get supported metadata fields for a specific format
|
|
84
|
+
* @param format Format name (e.g., "jpeg", "png", "webp")
|
|
85
|
+
* @returns Array of supported metadata field names, or undefined if format doesn't support metadata
|
|
86
|
+
*/
|
|
87
|
+
static getSupportedMetadata(format: string): Array<keyof ImageMetadata> | undefined;
|
|
82
88
|
/**
|
|
83
89
|
* Read an image from bytes
|
|
84
90
|
* @deprecated Use `decode()` instead. This method will be removed in a future version.
|
|
@@ -197,6 +203,12 @@ export declare class Image {
|
|
|
197
203
|
* @returns This image instance for chaining
|
|
198
204
|
*/
|
|
199
205
|
saturation(amount: number): this;
|
|
206
|
+
/**
|
|
207
|
+
* Adjust hue of the image by rotating the color wheel
|
|
208
|
+
* @param degrees Hue rotation in degrees (any value accepted, wraps at 360)
|
|
209
|
+
* @returns This image instance for chaining
|
|
210
|
+
*/
|
|
211
|
+
hue(degrees: number): this;
|
|
200
212
|
/**
|
|
201
213
|
* Invert colors of the image
|
|
202
214
|
* @returns This image instance for chaining
|
|
@@ -282,5 +294,44 @@ export declare class Image {
|
|
|
282
294
|
* @returns This image instance for chaining
|
|
283
295
|
*/
|
|
284
296
|
setPixel(x: number, y: number, r: number, g: number, b: number, a?: number): this;
|
|
297
|
+
/**
|
|
298
|
+
* Rotate the image 90 degrees clockwise
|
|
299
|
+
* @returns This image instance for chaining
|
|
300
|
+
*/
|
|
301
|
+
rotate90(): this;
|
|
302
|
+
/**
|
|
303
|
+
* Rotate the image 180 degrees
|
|
304
|
+
* @returns This image instance for chaining
|
|
305
|
+
*/
|
|
306
|
+
rotate180(): this;
|
|
307
|
+
/**
|
|
308
|
+
* Rotate the image 270 degrees clockwise (or 90 degrees counter-clockwise)
|
|
309
|
+
* @returns This image instance for chaining
|
|
310
|
+
*/
|
|
311
|
+
rotate270(): this;
|
|
312
|
+
/**
|
|
313
|
+
* Rotate the image by the specified angle in degrees
|
|
314
|
+
* @param degrees Rotation angle in degrees (positive = clockwise, negative = counter-clockwise)
|
|
315
|
+
* @returns This image instance for chaining
|
|
316
|
+
*
|
|
317
|
+
* @example
|
|
318
|
+
* ```ts
|
|
319
|
+
* image.rotate(90); // Rotate 90° clockwise
|
|
320
|
+
* image.rotate(-90); // Rotate 90° counter-clockwise
|
|
321
|
+
* image.rotate(180); // Rotate 180°
|
|
322
|
+
* image.rotate(45); // Rotate 45° clockwise (rounded to nearest 90°)
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
rotate(degrees: number): this;
|
|
326
|
+
/**
|
|
327
|
+
* Flip the image horizontally (mirror)
|
|
328
|
+
* @returns This image instance for chaining
|
|
329
|
+
*/
|
|
330
|
+
flipHorizontal(): this;
|
|
331
|
+
/**
|
|
332
|
+
* Flip the image vertically
|
|
333
|
+
* @returns This image instance for chaining
|
|
334
|
+
*/
|
|
335
|
+
flipVertical(): this;
|
|
285
336
|
}
|
|
286
337
|
//# sourceMappingURL=image.d.ts.map
|
package/esm/src/image.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { resizeBilinear, resizeNearest } from "./utils/resize.js";
|
|
2
|
-
import { adjustBrightness, adjustContrast, adjustExposure, adjustSaturation, boxBlur, composite, crop, fillRect, gaussianBlur, grayscale, invert, medianFilter, sepia, sharpen, } from "./utils/image_processing.js";
|
|
1
|
+
import { resizeBicubic, resizeBilinear, resizeNearest, } from "./utils/resize.js";
|
|
2
|
+
import { adjustBrightness, adjustContrast, adjustExposure, adjustHue, adjustSaturation, boxBlur, composite, crop, fillRect, flipHorizontal, flipVertical, gaussianBlur, grayscale, invert, medianFilter, rotate180, rotate270, rotate90, sepia, sharpen, } from "./utils/image_processing.js";
|
|
3
3
|
import { PNGFormat } from "./formats/png.js";
|
|
4
4
|
import { APNGFormat } from "./formats/apng.js";
|
|
5
5
|
import { JPEGFormat } from "./formats/jpeg.js";
|
|
@@ -181,6 +181,18 @@ export class Image {
|
|
|
181
181
|
}
|
|
182
182
|
throw new Error("Unsupported or unrecognized image format");
|
|
183
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Get supported metadata fields for a specific format
|
|
186
|
+
* @param format Format name (e.g., "jpeg", "png", "webp")
|
|
187
|
+
* @returns Array of supported metadata field names, or undefined if format doesn't support metadata
|
|
188
|
+
*/
|
|
189
|
+
static getSupportedMetadata(format) {
|
|
190
|
+
const formatHandler = Image.formats.find((f) => f.name === format.toLowerCase());
|
|
191
|
+
if (!formatHandler) {
|
|
192
|
+
throw new Error(`Unknown image format: ${format}`);
|
|
193
|
+
}
|
|
194
|
+
return formatHandler.getSupportedMetadata?.();
|
|
195
|
+
}
|
|
184
196
|
/**
|
|
185
197
|
* Read an image from bytes
|
|
186
198
|
* @deprecated Use `decode()` instead. This method will be removed in a future version.
|
|
@@ -301,19 +313,98 @@ export class Image {
|
|
|
301
313
|
resize(options) {
|
|
302
314
|
if (!this.imageData)
|
|
303
315
|
throw new Error("No image loaded");
|
|
304
|
-
const { width, height, method = "bilinear" } = options;
|
|
316
|
+
const { width, height, method = "bilinear", fit = "stretch" } = options;
|
|
305
317
|
// Validate new dimensions for security (prevent integer overflow and heap exhaustion)
|
|
306
318
|
validateImageDimensions(width, height);
|
|
307
319
|
const { data: srcData, width: srcWidth, height: srcHeight } = this.imageData;
|
|
320
|
+
// Handle fitting modes
|
|
321
|
+
let targetWidth = width;
|
|
322
|
+
let targetHeight = height;
|
|
323
|
+
let shouldCenter = false;
|
|
324
|
+
const fitMode = fit === "contain" ? "fit" : fit === "cover" ? "fill" : fit;
|
|
325
|
+
if (fitMode === "fit" || fitMode === "fill") {
|
|
326
|
+
const srcAspect = srcWidth / srcHeight;
|
|
327
|
+
const targetAspect = width / height;
|
|
328
|
+
if (fitMode === "fit") {
|
|
329
|
+
// Fit within dimensions (letterbox)
|
|
330
|
+
if (srcAspect > targetAspect) {
|
|
331
|
+
// Source is wider - fit to width
|
|
332
|
+
targetWidth = width;
|
|
333
|
+
targetHeight = Math.round(width / srcAspect);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
// Source is taller - fit to height
|
|
337
|
+
targetWidth = Math.round(height * srcAspect);
|
|
338
|
+
targetHeight = height;
|
|
339
|
+
}
|
|
340
|
+
shouldCenter = true;
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
// Fill dimensions (crop)
|
|
344
|
+
if (srcAspect > targetAspect) {
|
|
345
|
+
// Source is wider - fit to height and crop width
|
|
346
|
+
targetWidth = Math.round(height * srcAspect);
|
|
347
|
+
targetHeight = height;
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
// Source is taller - fit to width and crop height
|
|
351
|
+
targetWidth = width;
|
|
352
|
+
targetHeight = Math.round(width / srcAspect);
|
|
353
|
+
}
|
|
354
|
+
shouldCenter = true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Perform the resize
|
|
308
358
|
let resizedData;
|
|
309
359
|
if (method === "nearest") {
|
|
310
|
-
resizedData = resizeNearest(srcData, srcWidth, srcHeight,
|
|
360
|
+
resizedData = resizeNearest(srcData, srcWidth, srcHeight, targetWidth, targetHeight);
|
|
361
|
+
}
|
|
362
|
+
else if (method === "bicubic") {
|
|
363
|
+
resizedData = resizeBicubic(srcData, srcWidth, srcHeight, targetWidth, targetHeight);
|
|
311
364
|
}
|
|
312
365
|
else {
|
|
313
|
-
resizedData = resizeBilinear(srcData, srcWidth, srcHeight,
|
|
366
|
+
resizedData = resizeBilinear(srcData, srcWidth, srcHeight, targetWidth, targetHeight);
|
|
314
367
|
}
|
|
315
368
|
// Preserve metadata when resizing
|
|
316
369
|
const metadata = this.imageData.metadata;
|
|
370
|
+
// If we need to center (fit mode) or crop (fill mode), create a canvas
|
|
371
|
+
if (shouldCenter && (targetWidth !== width || targetHeight !== height)) {
|
|
372
|
+
const canvas = new Uint8Array(width * height * 4);
|
|
373
|
+
// Fill with transparent black by default
|
|
374
|
+
canvas.fill(0);
|
|
375
|
+
if (fitMode === "fit") {
|
|
376
|
+
// Center the resized image (letterbox)
|
|
377
|
+
const offsetX = Math.floor((width - targetWidth) / 2);
|
|
378
|
+
const offsetY = Math.floor((height - targetHeight) / 2);
|
|
379
|
+
for (let y = 0; y < targetHeight; y++) {
|
|
380
|
+
for (let x = 0; x < targetWidth; x++) {
|
|
381
|
+
const srcIdx = (y * targetWidth + x) * 4;
|
|
382
|
+
const dstIdx = ((y + offsetY) * width + (x + offsetX)) * 4;
|
|
383
|
+
canvas[dstIdx] = resizedData[srcIdx];
|
|
384
|
+
canvas[dstIdx + 1] = resizedData[srcIdx + 1];
|
|
385
|
+
canvas[dstIdx + 2] = resizedData[srcIdx + 2];
|
|
386
|
+
canvas[dstIdx + 3] = resizedData[srcIdx + 3];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
resizedData = canvas;
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
// Crop to fill (center crop)
|
|
393
|
+
const offsetX = Math.floor((targetWidth - width) / 2);
|
|
394
|
+
const offsetY = Math.floor((targetHeight - height) / 2);
|
|
395
|
+
for (let y = 0; y < height; y++) {
|
|
396
|
+
for (let x = 0; x < width; x++) {
|
|
397
|
+
const srcIdx = ((y + offsetY) * targetWidth + (x + offsetX)) * 4;
|
|
398
|
+
const dstIdx = (y * width + x) * 4;
|
|
399
|
+
canvas[dstIdx] = resizedData[srcIdx];
|
|
400
|
+
canvas[dstIdx + 1] = resizedData[srcIdx + 1];
|
|
401
|
+
canvas[dstIdx + 2] = resizedData[srcIdx + 2];
|
|
402
|
+
canvas[dstIdx + 3] = resizedData[srcIdx + 3];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
resizedData = canvas;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
317
408
|
this.imageData = {
|
|
318
409
|
width,
|
|
319
410
|
height,
|
|
@@ -439,6 +530,17 @@ export class Image {
|
|
|
439
530
|
this.imageData.data = adjustSaturation(this.imageData.data, amount);
|
|
440
531
|
return this;
|
|
441
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* Adjust hue of the image by rotating the color wheel
|
|
535
|
+
* @param degrees Hue rotation in degrees (any value accepted, wraps at 360)
|
|
536
|
+
* @returns This image instance for chaining
|
|
537
|
+
*/
|
|
538
|
+
hue(degrees) {
|
|
539
|
+
if (!this.imageData)
|
|
540
|
+
throw new Error("No image loaded");
|
|
541
|
+
this.imageData.data = adjustHue(this.imageData.data, degrees);
|
|
542
|
+
return this;
|
|
543
|
+
}
|
|
442
544
|
/**
|
|
443
545
|
* Invert colors of the image
|
|
444
546
|
* @returns This image instance for chaining
|
|
@@ -602,6 +704,124 @@ export class Image {
|
|
|
602
704
|
this.imageData.data[idx + 3] = a;
|
|
603
705
|
return this;
|
|
604
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* Rotate the image 90 degrees clockwise
|
|
709
|
+
* @returns This image instance for chaining
|
|
710
|
+
*/
|
|
711
|
+
rotate90() {
|
|
712
|
+
if (!this.imageData)
|
|
713
|
+
throw new Error("No image loaded");
|
|
714
|
+
const result = rotate90(this.imageData.data, this.imageData.width, this.imageData.height);
|
|
715
|
+
this.imageData.width = result.width;
|
|
716
|
+
this.imageData.height = result.height;
|
|
717
|
+
this.imageData.data = result.data;
|
|
718
|
+
// Update physical dimensions if DPI is set
|
|
719
|
+
if (this.imageData.metadata) {
|
|
720
|
+
const metadata = this.imageData.metadata;
|
|
721
|
+
if (metadata.dpiX && metadata.dpiY) {
|
|
722
|
+
// Swap physical dimensions
|
|
723
|
+
const tempPhysical = metadata.physicalWidth;
|
|
724
|
+
this.imageData.metadata.physicalWidth = metadata.physicalHeight;
|
|
725
|
+
this.imageData.metadata.physicalHeight = tempPhysical;
|
|
726
|
+
// Swap DPI
|
|
727
|
+
const tempDpi = metadata.dpiX;
|
|
728
|
+
this.imageData.metadata.dpiX = metadata.dpiY;
|
|
729
|
+
this.imageData.metadata.dpiY = tempDpi;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return this;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Rotate the image 180 degrees
|
|
736
|
+
* @returns This image instance for chaining
|
|
737
|
+
*/
|
|
738
|
+
rotate180() {
|
|
739
|
+
if (!this.imageData)
|
|
740
|
+
throw new Error("No image loaded");
|
|
741
|
+
this.imageData.data = rotate180(this.imageData.data, this.imageData.width, this.imageData.height);
|
|
742
|
+
return this;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Rotate the image 270 degrees clockwise (or 90 degrees counter-clockwise)
|
|
746
|
+
* @returns This image instance for chaining
|
|
747
|
+
*/
|
|
748
|
+
rotate270() {
|
|
749
|
+
if (!this.imageData)
|
|
750
|
+
throw new Error("No image loaded");
|
|
751
|
+
const result = rotate270(this.imageData.data, this.imageData.width, this.imageData.height);
|
|
752
|
+
this.imageData.width = result.width;
|
|
753
|
+
this.imageData.height = result.height;
|
|
754
|
+
this.imageData.data = result.data;
|
|
755
|
+
// Update physical dimensions if DPI is set
|
|
756
|
+
if (this.imageData.metadata) {
|
|
757
|
+
const metadata = this.imageData.metadata;
|
|
758
|
+
if (metadata.dpiX && metadata.dpiY) {
|
|
759
|
+
// Swap physical dimensions
|
|
760
|
+
const tempPhysical = metadata.physicalWidth;
|
|
761
|
+
this.imageData.metadata.physicalWidth = metadata.physicalHeight;
|
|
762
|
+
this.imageData.metadata.physicalHeight = tempPhysical;
|
|
763
|
+
// Swap DPI
|
|
764
|
+
const tempDpi = metadata.dpiX;
|
|
765
|
+
this.imageData.metadata.dpiX = metadata.dpiY;
|
|
766
|
+
this.imageData.metadata.dpiY = tempDpi;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return this;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Rotate the image by the specified angle in degrees
|
|
773
|
+
* @param degrees Rotation angle in degrees (positive = clockwise, negative = counter-clockwise)
|
|
774
|
+
* @returns This image instance for chaining
|
|
775
|
+
*
|
|
776
|
+
* @example
|
|
777
|
+
* ```ts
|
|
778
|
+
* image.rotate(90); // Rotate 90° clockwise
|
|
779
|
+
* image.rotate(-90); // Rotate 90° counter-clockwise
|
|
780
|
+
* image.rotate(180); // Rotate 180°
|
|
781
|
+
* image.rotate(45); // Rotate 45° clockwise (rounded to nearest 90°)
|
|
782
|
+
* ```
|
|
783
|
+
*/
|
|
784
|
+
rotate(degrees) {
|
|
785
|
+
// Normalize to 0-360 range
|
|
786
|
+
let normalizedDegrees = degrees % 360;
|
|
787
|
+
if (normalizedDegrees < 0) {
|
|
788
|
+
normalizedDegrees += 360;
|
|
789
|
+
}
|
|
790
|
+
// Round to nearest 90 degrees
|
|
791
|
+
const rounded = Math.round(normalizedDegrees / 90) * 90;
|
|
792
|
+
// Apply rotation based on rounded value
|
|
793
|
+
switch (rounded % 360) {
|
|
794
|
+
case 90:
|
|
795
|
+
return this.rotate90();
|
|
796
|
+
case 180:
|
|
797
|
+
return this.rotate180();
|
|
798
|
+
case 270:
|
|
799
|
+
return this.rotate270();
|
|
800
|
+
default:
|
|
801
|
+
// 0 or 360 degrees - no rotation needed
|
|
802
|
+
return this;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Flip the image horizontally (mirror)
|
|
807
|
+
* @returns This image instance for chaining
|
|
808
|
+
*/
|
|
809
|
+
flipHorizontal() {
|
|
810
|
+
if (!this.imageData)
|
|
811
|
+
throw new Error("No image loaded");
|
|
812
|
+
this.imageData.data = flipHorizontal(this.imageData.data, this.imageData.width, this.imageData.height);
|
|
813
|
+
return this;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Flip the image vertically
|
|
817
|
+
* @returns This image instance for chaining
|
|
818
|
+
*/
|
|
819
|
+
flipVertical() {
|
|
820
|
+
if (!this.imageData)
|
|
821
|
+
throw new Error("No image loaded");
|
|
822
|
+
this.imageData.data = flipVertical(this.imageData.data, this.imageData.width, this.imageData.height);
|
|
823
|
+
return this;
|
|
824
|
+
}
|
|
605
825
|
}
|
|
606
826
|
Object.defineProperty(Image, "formats", {
|
|
607
827
|
enumerable: true,
|