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
|
@@ -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,29 @@ 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
|
+
];
|
|
456
605
|
}
|
|
457
606
|
}
|
|
458
607
|
exports.WebPFormat = WebPFormat;
|
package/script/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/script/src/image.js
CHANGED
|
@@ -184,6 +184,18 @@ class Image {
|
|
|
184
184
|
}
|
|
185
185
|
throw new Error("Unsupported or unrecognized image format");
|
|
186
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Get supported metadata fields for a specific format
|
|
189
|
+
* @param format Format name (e.g., "jpeg", "png", "webp")
|
|
190
|
+
* @returns Array of supported metadata field names, or undefined if format doesn't support metadata
|
|
191
|
+
*/
|
|
192
|
+
static getSupportedMetadata(format) {
|
|
193
|
+
const formatHandler = Image.formats.find((f) => f.name === format.toLowerCase());
|
|
194
|
+
if (!formatHandler) {
|
|
195
|
+
throw new Error(`Unknown image format: ${format}`);
|
|
196
|
+
}
|
|
197
|
+
return formatHandler.getSupportedMetadata?.();
|
|
198
|
+
}
|
|
187
199
|
/**
|
|
188
200
|
* Read an image from bytes
|
|
189
201
|
* @deprecated Use `decode()` instead. This method will be removed in a future version.
|
|
@@ -304,19 +316,98 @@ class Image {
|
|
|
304
316
|
resize(options) {
|
|
305
317
|
if (!this.imageData)
|
|
306
318
|
throw new Error("No image loaded");
|
|
307
|
-
const { width, height, method = "bilinear" } = options;
|
|
319
|
+
const { width, height, method = "bilinear", fit = "stretch" } = options;
|
|
308
320
|
// Validate new dimensions for security (prevent integer overflow and heap exhaustion)
|
|
309
321
|
(0, security_js_1.validateImageDimensions)(width, height);
|
|
310
322
|
const { data: srcData, width: srcWidth, height: srcHeight } = this.imageData;
|
|
323
|
+
// Handle fitting modes
|
|
324
|
+
let targetWidth = width;
|
|
325
|
+
let targetHeight = height;
|
|
326
|
+
let shouldCenter = false;
|
|
327
|
+
const fitMode = fit === "contain" ? "fit" : fit === "cover" ? "fill" : fit;
|
|
328
|
+
if (fitMode === "fit" || fitMode === "fill") {
|
|
329
|
+
const srcAspect = srcWidth / srcHeight;
|
|
330
|
+
const targetAspect = width / height;
|
|
331
|
+
if (fitMode === "fit") {
|
|
332
|
+
// Fit within dimensions (letterbox)
|
|
333
|
+
if (srcAspect > targetAspect) {
|
|
334
|
+
// Source is wider - fit to width
|
|
335
|
+
targetWidth = width;
|
|
336
|
+
targetHeight = Math.round(width / srcAspect);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
// Source is taller - fit to height
|
|
340
|
+
targetWidth = Math.round(height * srcAspect);
|
|
341
|
+
targetHeight = height;
|
|
342
|
+
}
|
|
343
|
+
shouldCenter = true;
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
// Fill dimensions (crop)
|
|
347
|
+
if (srcAspect > targetAspect) {
|
|
348
|
+
// Source is wider - fit to height and crop width
|
|
349
|
+
targetWidth = Math.round(height * srcAspect);
|
|
350
|
+
targetHeight = height;
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
// Source is taller - fit to width and crop height
|
|
354
|
+
targetWidth = width;
|
|
355
|
+
targetHeight = Math.round(width / srcAspect);
|
|
356
|
+
}
|
|
357
|
+
shouldCenter = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Perform the resize
|
|
311
361
|
let resizedData;
|
|
312
362
|
if (method === "nearest") {
|
|
313
|
-
resizedData = (0, resize_js_1.resizeNearest)(srcData, srcWidth, srcHeight,
|
|
363
|
+
resizedData = (0, resize_js_1.resizeNearest)(srcData, srcWidth, srcHeight, targetWidth, targetHeight);
|
|
364
|
+
}
|
|
365
|
+
else if (method === "bicubic") {
|
|
366
|
+
resizedData = (0, resize_js_1.resizeBicubic)(srcData, srcWidth, srcHeight, targetWidth, targetHeight);
|
|
314
367
|
}
|
|
315
368
|
else {
|
|
316
|
-
resizedData = (0, resize_js_1.resizeBilinear)(srcData, srcWidth, srcHeight,
|
|
369
|
+
resizedData = (0, resize_js_1.resizeBilinear)(srcData, srcWidth, srcHeight, targetWidth, targetHeight);
|
|
317
370
|
}
|
|
318
371
|
// Preserve metadata when resizing
|
|
319
372
|
const metadata = this.imageData.metadata;
|
|
373
|
+
// If we need to center (fit mode) or crop (fill mode), create a canvas
|
|
374
|
+
if (shouldCenter && (targetWidth !== width || targetHeight !== height)) {
|
|
375
|
+
const canvas = new Uint8Array(width * height * 4);
|
|
376
|
+
// Fill with transparent black by default
|
|
377
|
+
canvas.fill(0);
|
|
378
|
+
if (fitMode === "fit") {
|
|
379
|
+
// Center the resized image (letterbox)
|
|
380
|
+
const offsetX = Math.floor((width - targetWidth) / 2);
|
|
381
|
+
const offsetY = Math.floor((height - targetHeight) / 2);
|
|
382
|
+
for (let y = 0; y < targetHeight; y++) {
|
|
383
|
+
for (let x = 0; x < targetWidth; x++) {
|
|
384
|
+
const srcIdx = (y * targetWidth + x) * 4;
|
|
385
|
+
const dstIdx = ((y + offsetY) * width + (x + offsetX)) * 4;
|
|
386
|
+
canvas[dstIdx] = resizedData[srcIdx];
|
|
387
|
+
canvas[dstIdx + 1] = resizedData[srcIdx + 1];
|
|
388
|
+
canvas[dstIdx + 2] = resizedData[srcIdx + 2];
|
|
389
|
+
canvas[dstIdx + 3] = resizedData[srcIdx + 3];
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
resizedData = canvas;
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
// Crop to fill (center crop)
|
|
396
|
+
const offsetX = Math.floor((targetWidth - width) / 2);
|
|
397
|
+
const offsetY = Math.floor((targetHeight - height) / 2);
|
|
398
|
+
for (let y = 0; y < height; y++) {
|
|
399
|
+
for (let x = 0; x < width; x++) {
|
|
400
|
+
const srcIdx = ((y + offsetY) * targetWidth + (x + offsetX)) * 4;
|
|
401
|
+
const dstIdx = (y * width + x) * 4;
|
|
402
|
+
canvas[dstIdx] = resizedData[srcIdx];
|
|
403
|
+
canvas[dstIdx + 1] = resizedData[srcIdx + 1];
|
|
404
|
+
canvas[dstIdx + 2] = resizedData[srcIdx + 2];
|
|
405
|
+
canvas[dstIdx + 3] = resizedData[srcIdx + 3];
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
resizedData = canvas;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
320
411
|
this.imageData = {
|
|
321
412
|
width,
|
|
322
413
|
height,
|
|
@@ -442,6 +533,17 @@ class Image {
|
|
|
442
533
|
this.imageData.data = (0, image_processing_js_1.adjustSaturation)(this.imageData.data, amount);
|
|
443
534
|
return this;
|
|
444
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* Adjust hue of the image by rotating the color wheel
|
|
538
|
+
* @param degrees Hue rotation in degrees (any value accepted, wraps at 360)
|
|
539
|
+
* @returns This image instance for chaining
|
|
540
|
+
*/
|
|
541
|
+
hue(degrees) {
|
|
542
|
+
if (!this.imageData)
|
|
543
|
+
throw new Error("No image loaded");
|
|
544
|
+
this.imageData.data = (0, image_processing_js_1.adjustHue)(this.imageData.data, degrees);
|
|
545
|
+
return this;
|
|
546
|
+
}
|
|
445
547
|
/**
|
|
446
548
|
* Invert colors of the image
|
|
447
549
|
* @returns This image instance for chaining
|
|
@@ -605,6 +707,124 @@ class Image {
|
|
|
605
707
|
this.imageData.data[idx + 3] = a;
|
|
606
708
|
return this;
|
|
607
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* Rotate the image 90 degrees clockwise
|
|
712
|
+
* @returns This image instance for chaining
|
|
713
|
+
*/
|
|
714
|
+
rotate90() {
|
|
715
|
+
if (!this.imageData)
|
|
716
|
+
throw new Error("No image loaded");
|
|
717
|
+
const result = (0, image_processing_js_1.rotate90)(this.imageData.data, this.imageData.width, this.imageData.height);
|
|
718
|
+
this.imageData.width = result.width;
|
|
719
|
+
this.imageData.height = result.height;
|
|
720
|
+
this.imageData.data = result.data;
|
|
721
|
+
// Update physical dimensions if DPI is set
|
|
722
|
+
if (this.imageData.metadata) {
|
|
723
|
+
const metadata = this.imageData.metadata;
|
|
724
|
+
if (metadata.dpiX && metadata.dpiY) {
|
|
725
|
+
// Swap physical dimensions
|
|
726
|
+
const tempPhysical = metadata.physicalWidth;
|
|
727
|
+
this.imageData.metadata.physicalWidth = metadata.physicalHeight;
|
|
728
|
+
this.imageData.metadata.physicalHeight = tempPhysical;
|
|
729
|
+
// Swap DPI
|
|
730
|
+
const tempDpi = metadata.dpiX;
|
|
731
|
+
this.imageData.metadata.dpiX = metadata.dpiY;
|
|
732
|
+
this.imageData.metadata.dpiY = tempDpi;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return this;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Rotate the image 180 degrees
|
|
739
|
+
* @returns This image instance for chaining
|
|
740
|
+
*/
|
|
741
|
+
rotate180() {
|
|
742
|
+
if (!this.imageData)
|
|
743
|
+
throw new Error("No image loaded");
|
|
744
|
+
this.imageData.data = (0, image_processing_js_1.rotate180)(this.imageData.data, this.imageData.width, this.imageData.height);
|
|
745
|
+
return this;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Rotate the image 270 degrees clockwise (or 90 degrees counter-clockwise)
|
|
749
|
+
* @returns This image instance for chaining
|
|
750
|
+
*/
|
|
751
|
+
rotate270() {
|
|
752
|
+
if (!this.imageData)
|
|
753
|
+
throw new Error("No image loaded");
|
|
754
|
+
const result = (0, image_processing_js_1.rotate270)(this.imageData.data, this.imageData.width, this.imageData.height);
|
|
755
|
+
this.imageData.width = result.width;
|
|
756
|
+
this.imageData.height = result.height;
|
|
757
|
+
this.imageData.data = result.data;
|
|
758
|
+
// Update physical dimensions if DPI is set
|
|
759
|
+
if (this.imageData.metadata) {
|
|
760
|
+
const metadata = this.imageData.metadata;
|
|
761
|
+
if (metadata.dpiX && metadata.dpiY) {
|
|
762
|
+
// Swap physical dimensions
|
|
763
|
+
const tempPhysical = metadata.physicalWidth;
|
|
764
|
+
this.imageData.metadata.physicalWidth = metadata.physicalHeight;
|
|
765
|
+
this.imageData.metadata.physicalHeight = tempPhysical;
|
|
766
|
+
// Swap DPI
|
|
767
|
+
const tempDpi = metadata.dpiX;
|
|
768
|
+
this.imageData.metadata.dpiX = metadata.dpiY;
|
|
769
|
+
this.imageData.metadata.dpiY = tempDpi;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return this;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Rotate the image by the specified angle in degrees
|
|
776
|
+
* @param degrees Rotation angle in degrees (positive = clockwise, negative = counter-clockwise)
|
|
777
|
+
* @returns This image instance for chaining
|
|
778
|
+
*
|
|
779
|
+
* @example
|
|
780
|
+
* ```ts
|
|
781
|
+
* image.rotate(90); // Rotate 90° clockwise
|
|
782
|
+
* image.rotate(-90); // Rotate 90° counter-clockwise
|
|
783
|
+
* image.rotate(180); // Rotate 180°
|
|
784
|
+
* image.rotate(45); // Rotate 45° clockwise (rounded to nearest 90°)
|
|
785
|
+
* ```
|
|
786
|
+
*/
|
|
787
|
+
rotate(degrees) {
|
|
788
|
+
// Normalize to 0-360 range
|
|
789
|
+
let normalizedDegrees = degrees % 360;
|
|
790
|
+
if (normalizedDegrees < 0) {
|
|
791
|
+
normalizedDegrees += 360;
|
|
792
|
+
}
|
|
793
|
+
// Round to nearest 90 degrees
|
|
794
|
+
const rounded = Math.round(normalizedDegrees / 90) * 90;
|
|
795
|
+
// Apply rotation based on rounded value
|
|
796
|
+
switch (rounded % 360) {
|
|
797
|
+
case 90:
|
|
798
|
+
return this.rotate90();
|
|
799
|
+
case 180:
|
|
800
|
+
return this.rotate180();
|
|
801
|
+
case 270:
|
|
802
|
+
return this.rotate270();
|
|
803
|
+
default:
|
|
804
|
+
// 0 or 360 degrees - no rotation needed
|
|
805
|
+
return this;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Flip the image horizontally (mirror)
|
|
810
|
+
* @returns This image instance for chaining
|
|
811
|
+
*/
|
|
812
|
+
flipHorizontal() {
|
|
813
|
+
if (!this.imageData)
|
|
814
|
+
throw new Error("No image loaded");
|
|
815
|
+
this.imageData.data = (0, image_processing_js_1.flipHorizontal)(this.imageData.data, this.imageData.width, this.imageData.height);
|
|
816
|
+
return this;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Flip the image vertically
|
|
820
|
+
* @returns This image instance for chaining
|
|
821
|
+
*/
|
|
822
|
+
flipVertical() {
|
|
823
|
+
if (!this.imageData)
|
|
824
|
+
throw new Error("No image loaded");
|
|
825
|
+
this.imageData.data = (0, image_processing_js_1.flipVertical)(this.imageData.data, this.imageData.width, this.imageData.height);
|
|
826
|
+
return this;
|
|
827
|
+
}
|
|
608
828
|
}
|
|
609
829
|
exports.Image = Image;
|
|
610
830
|
Object.defineProperty(Image, "formats", {
|