cross-image 0.2.0 → 0.2.2
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 +41 -28
- package/esm/mod.d.ts +4 -1
- package/esm/mod.js +4 -1
- package/esm/src/formats/apng.d.ts +50 -0
- package/esm/src/formats/apng.js +364 -0
- package/esm/src/formats/bmp.d.ts +0 -6
- package/esm/src/formats/bmp.js +24 -47
- package/esm/src/formats/dng.js +4 -4
- package/esm/src/formats/gif.d.ts +0 -2
- package/esm/src/formats/gif.js +10 -16
- package/esm/src/formats/ico.d.ts +41 -0
- package/esm/src/formats/ico.js +214 -0
- package/esm/src/formats/pcx.js +1 -1
- package/esm/src/formats/png.d.ts +2 -21
- package/esm/src/formats/png.js +5 -429
- package/esm/src/formats/png_base.d.ts +108 -0
- package/esm/src/formats/png_base.js +487 -0
- package/esm/src/formats/ppm.d.ts +50 -0
- package/esm/src/formats/ppm.js +242 -0
- package/esm/src/formats/tiff.d.ts +4 -0
- package/esm/src/formats/tiff.js +163 -44
- package/esm/src/formats/webp.d.ts +0 -1
- package/esm/src/formats/webp.js +4 -7
- package/esm/src/image.d.ts +30 -0
- package/esm/src/image.js +62 -1
- package/esm/src/utils/byte_utils.d.ts +30 -0
- package/esm/src/utils/byte_utils.js +50 -0
- package/esm/src/utils/gif_encoder.d.ts +3 -2
- package/esm/src/utils/gif_encoder.js +115 -48
- package/esm/src/utils/image_processing.d.ts +43 -0
- package/esm/src/utils/image_processing.js +230 -0
- package/package.json +1 -1
- package/script/mod.d.ts +4 -1
- package/script/mod.js +8 -2
- package/script/src/formats/apng.d.ts +50 -0
- package/script/src/formats/apng.js +368 -0
- package/script/src/formats/bmp.d.ts +0 -6
- package/script/src/formats/bmp.js +24 -47
- package/script/src/formats/dng.js +4 -4
- package/script/src/formats/gif.d.ts +0 -2
- package/script/src/formats/gif.js +10 -16
- package/script/src/formats/ico.d.ts +41 -0
- package/script/src/formats/ico.js +218 -0
- package/script/src/formats/pcx.js +1 -1
- package/script/src/formats/png.d.ts +2 -21
- package/script/src/formats/png.js +5 -429
- package/script/src/formats/png_base.d.ts +108 -0
- package/script/src/formats/png_base.js +491 -0
- package/script/src/formats/ppm.d.ts +50 -0
- package/script/src/formats/ppm.js +246 -0
- package/script/src/formats/tiff.d.ts +4 -0
- package/script/src/formats/tiff.js +163 -44
- package/script/src/formats/webp.d.ts +0 -1
- package/script/src/formats/webp.js +4 -7
- package/script/src/image.d.ts +30 -0
- package/script/src/image.js +61 -0
- package/script/src/utils/byte_utils.d.ts +30 -0
- package/script/src/utils/byte_utils.js +58 -0
- package/script/src/utils/gif_encoder.d.ts +3 -2
- package/script/src/utils/gif_encoder.js +115 -48
- package/script/src/utils/image_processing.d.ts +43 -0
- package/script/src/utils/image_processing.js +235 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { validateImageDimensions } from "../utils/security.js";
|
|
2
|
+
/**
|
|
3
|
+
* PPM format handler
|
|
4
|
+
* Implements the Netpbm PPM (Portable PixMap) format.
|
|
5
|
+
* This is a simple uncompressed RGB format supported by many image tools.
|
|
6
|
+
*
|
|
7
|
+
* Format structure:
|
|
8
|
+
* - P3 (ASCII format):
|
|
9
|
+
* P3
|
|
10
|
+
* <width> <height>
|
|
11
|
+
* <maxval>
|
|
12
|
+
* R G B R G B ... (space-separated decimal values)
|
|
13
|
+
*
|
|
14
|
+
* - P6 (Binary format):
|
|
15
|
+
* P6
|
|
16
|
+
* <width> <height>
|
|
17
|
+
* <maxval>
|
|
18
|
+
* RGB RGB RGB ... (binary byte data)
|
|
19
|
+
*/
|
|
20
|
+
export class PPMFormat {
|
|
21
|
+
constructor() {
|
|
22
|
+
/** Format name identifier */
|
|
23
|
+
Object.defineProperty(this, "name", {
|
|
24
|
+
enumerable: true,
|
|
25
|
+
configurable: true,
|
|
26
|
+
writable: true,
|
|
27
|
+
value: "ppm"
|
|
28
|
+
});
|
|
29
|
+
/** MIME type for PPM images */
|
|
30
|
+
Object.defineProperty(this, "mimeType", {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
configurable: true,
|
|
33
|
+
writable: true,
|
|
34
|
+
value: "image/x-portable-pixmap"
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Check if the given data is a PPM image
|
|
39
|
+
* @param data Raw image data to check
|
|
40
|
+
* @returns true if data has PPM signature (P3 or P6)
|
|
41
|
+
*/
|
|
42
|
+
canDecode(data) {
|
|
43
|
+
// Check if data has at least magic bytes
|
|
44
|
+
if (data.length < 3) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
// Check for P3 or P6 followed by whitespace
|
|
48
|
+
return data[0] === 0x50 && // P
|
|
49
|
+
(data[1] === 0x33 || data[1] === 0x36) && // 3 or 6
|
|
50
|
+
(data[2] === 0x0a || data[2] === 0x0d || data[2] === 0x20 ||
|
|
51
|
+
data[2] === 0x09); // \n, \r, space, or tab
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Decode PPM image data to RGBA
|
|
55
|
+
* Supports both P3 (ASCII) and P6 (binary) formats
|
|
56
|
+
* @param data Raw PPM image data
|
|
57
|
+
* @returns Decoded image data with RGBA pixels
|
|
58
|
+
*/
|
|
59
|
+
decode(data) {
|
|
60
|
+
if (!this.canDecode(data)) {
|
|
61
|
+
throw new Error("Invalid PPM signature");
|
|
62
|
+
}
|
|
63
|
+
const isBinary = data[1] === 0x36; // P6
|
|
64
|
+
// Parse header
|
|
65
|
+
let offset = 0;
|
|
66
|
+
let width = 0;
|
|
67
|
+
let height = 0;
|
|
68
|
+
let maxval = 0;
|
|
69
|
+
let headerValues = 0; // Track how many values we've parsed (need 3: width, height, maxval)
|
|
70
|
+
// Skip magic number and whitespace
|
|
71
|
+
offset = 2;
|
|
72
|
+
while (offset < data.length && this.isWhitespace(data[offset])) {
|
|
73
|
+
offset++;
|
|
74
|
+
}
|
|
75
|
+
// Parse header values (width, height, maxval)
|
|
76
|
+
while (headerValues < 3 && offset < data.length) {
|
|
77
|
+
// Skip comments (lines starting with #)
|
|
78
|
+
if (data[offset] === 0x23) { // #
|
|
79
|
+
// Skip until newline
|
|
80
|
+
while (offset < data.length && data[offset] !== 0x0a) {
|
|
81
|
+
offset++;
|
|
82
|
+
}
|
|
83
|
+
if (offset < data.length)
|
|
84
|
+
offset++; // Skip the newline
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Skip whitespace
|
|
88
|
+
while (offset < data.length && this.isWhitespace(data[offset])) {
|
|
89
|
+
offset++;
|
|
90
|
+
}
|
|
91
|
+
// Read number
|
|
92
|
+
let numStr = "";
|
|
93
|
+
while (offset < data.length &&
|
|
94
|
+
!this.isWhitespace(data[offset]) &&
|
|
95
|
+
data[offset] !== 0x23) {
|
|
96
|
+
numStr += String.fromCharCode(data[offset]);
|
|
97
|
+
offset++;
|
|
98
|
+
}
|
|
99
|
+
if (numStr) {
|
|
100
|
+
const num = parseInt(numStr, 10);
|
|
101
|
+
if (isNaN(num) || num <= 0) {
|
|
102
|
+
throw new Error(`Invalid PPM header value: ${numStr}`);
|
|
103
|
+
}
|
|
104
|
+
if (headerValues === 0) {
|
|
105
|
+
width = num;
|
|
106
|
+
}
|
|
107
|
+
else if (headerValues === 1) {
|
|
108
|
+
height = num;
|
|
109
|
+
}
|
|
110
|
+
else if (headerValues === 2) {
|
|
111
|
+
maxval = num;
|
|
112
|
+
}
|
|
113
|
+
headerValues++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (headerValues < 3) {
|
|
117
|
+
throw new Error("Incomplete PPM header");
|
|
118
|
+
}
|
|
119
|
+
// Skip single whitespace character after maxval (required by spec)
|
|
120
|
+
if (offset < data.length && this.isWhitespace(data[offset])) {
|
|
121
|
+
offset++;
|
|
122
|
+
}
|
|
123
|
+
// Validate dimensions
|
|
124
|
+
validateImageDimensions(width, height);
|
|
125
|
+
// Validate maxval
|
|
126
|
+
if (maxval > 255) {
|
|
127
|
+
throw new Error(`Unsupported PPM maxval: ${maxval}. Only maxval <= 255 is supported.`);
|
|
128
|
+
}
|
|
129
|
+
const pixelCount = width * height;
|
|
130
|
+
const rgba = new Uint8Array(pixelCount * 4);
|
|
131
|
+
if (isBinary) {
|
|
132
|
+
// P6: Binary format
|
|
133
|
+
const expectedDataLength = pixelCount * 3;
|
|
134
|
+
const actualDataLength = data.length - offset;
|
|
135
|
+
if (actualDataLength < expectedDataLength) {
|
|
136
|
+
throw new Error(`Invalid PPM data length: expected ${expectedDataLength}, got ${actualDataLength}`);
|
|
137
|
+
}
|
|
138
|
+
// Read RGB data and convert to RGBA
|
|
139
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
140
|
+
const srcIndex = offset + i * 3;
|
|
141
|
+
const dstIndex = i * 4;
|
|
142
|
+
rgba[dstIndex] = data[srcIndex]; // R
|
|
143
|
+
rgba[dstIndex + 1] = data[srcIndex + 1]; // G
|
|
144
|
+
rgba[dstIndex + 2] = data[srcIndex + 2]; // B
|
|
145
|
+
rgba[dstIndex + 3] = 255; // A (fully opaque)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// P3: ASCII format
|
|
150
|
+
let pixelIndex = 0;
|
|
151
|
+
while (pixelIndex < pixelCount * 3 && offset < data.length) {
|
|
152
|
+
// Skip whitespace and comments
|
|
153
|
+
while (offset < data.length) {
|
|
154
|
+
if (data[offset] === 0x23) { // #
|
|
155
|
+
// Skip comment
|
|
156
|
+
while (offset < data.length && data[offset] !== 0x0a) {
|
|
157
|
+
offset++;
|
|
158
|
+
}
|
|
159
|
+
if (offset < data.length)
|
|
160
|
+
offset++;
|
|
161
|
+
}
|
|
162
|
+
else if (this.isWhitespace(data[offset])) {
|
|
163
|
+
offset++;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Read number
|
|
170
|
+
let numStr = "";
|
|
171
|
+
while (offset < data.length &&
|
|
172
|
+
!this.isWhitespace(data[offset]) &&
|
|
173
|
+
data[offset] !== 0x23) {
|
|
174
|
+
numStr += String.fromCharCode(data[offset]);
|
|
175
|
+
offset++;
|
|
176
|
+
}
|
|
177
|
+
if (numStr) {
|
|
178
|
+
const value = parseInt(numStr, 10);
|
|
179
|
+
if (isNaN(value) || value < 0 || value > maxval) {
|
|
180
|
+
throw new Error(`Invalid PPM pixel value: ${numStr}`);
|
|
181
|
+
}
|
|
182
|
+
// Scale to 0-255 if needed
|
|
183
|
+
const scaledValue = maxval === 255 ? value : Math.round((value * 255) / maxval);
|
|
184
|
+
const component = pixelIndex % 3;
|
|
185
|
+
const rgbaIndex = Math.floor(pixelIndex / 3) * 4;
|
|
186
|
+
if (component === 0) {
|
|
187
|
+
rgba[rgbaIndex] = scaledValue; // R
|
|
188
|
+
}
|
|
189
|
+
else if (component === 1) {
|
|
190
|
+
rgba[rgbaIndex + 1] = scaledValue; // G
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
rgba[rgbaIndex + 2] = scaledValue; // B
|
|
194
|
+
rgba[rgbaIndex + 3] = 255; // A
|
|
195
|
+
}
|
|
196
|
+
pixelIndex++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (pixelIndex < pixelCount * 3) {
|
|
200
|
+
throw new Error(`Incomplete PPM pixel data: expected ${pixelCount * 3} values, got ${pixelIndex}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return Promise.resolve({ width, height, data: rgba });
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Encode RGBA image data to PPM format (P6 binary)
|
|
207
|
+
* Note: Alpha channel is ignored as PPM doesn't support transparency
|
|
208
|
+
* @param imageData Image data to encode
|
|
209
|
+
* @returns Encoded PPM image bytes
|
|
210
|
+
*/
|
|
211
|
+
encode(imageData) {
|
|
212
|
+
const { width, height, data } = imageData;
|
|
213
|
+
// Validate input
|
|
214
|
+
if (data.length !== width * height * 4) {
|
|
215
|
+
throw new Error(`Data length mismatch: expected ${width * height * 4}, got ${data.length}`);
|
|
216
|
+
}
|
|
217
|
+
// Create header
|
|
218
|
+
const header = `P6\n${width} ${height}\n255\n`;
|
|
219
|
+
const encoder = new TextEncoder();
|
|
220
|
+
const headerBytes = encoder.encode(header);
|
|
221
|
+
// Create output buffer (header + RGB data)
|
|
222
|
+
const pixelCount = width * height;
|
|
223
|
+
const output = new Uint8Array(headerBytes.length + pixelCount * 3);
|
|
224
|
+
// Write header
|
|
225
|
+
output.set(headerBytes, 0);
|
|
226
|
+
// Write RGB pixel data (discard alpha channel)
|
|
227
|
+
let outputOffset = headerBytes.length;
|
|
228
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
229
|
+
const srcIndex = i * 4;
|
|
230
|
+
output[outputOffset++] = data[srcIndex]; // R
|
|
231
|
+
output[outputOffset++] = data[srcIndex + 1]; // G
|
|
232
|
+
output[outputOffset++] = data[srcIndex + 2]; // B
|
|
233
|
+
}
|
|
234
|
+
return Promise.resolve(output);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Check if a byte is whitespace (space, tab, CR, LF)
|
|
238
|
+
*/
|
|
239
|
+
isWhitespace(byte) {
|
|
240
|
+
return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -5,6 +5,10 @@ import type { ImageData, ImageFormat, MultiFrameImageData } from "../types.js";
|
|
|
5
5
|
export interface TIFFEncodeOptions {
|
|
6
6
|
/** Compression method: "none" for uncompressed (default), "lzw" for LZW compression */
|
|
7
7
|
compression?: "none" | "lzw";
|
|
8
|
+
/** Encode as grayscale instead of RGB/RGBA */
|
|
9
|
+
grayscale?: boolean;
|
|
10
|
+
/** Encode as RGB without alpha channel (ignored if grayscale is true) */
|
|
11
|
+
rgb?: boolean;
|
|
8
12
|
}
|
|
9
13
|
/**
|
|
10
14
|
* TIFF format handler
|
package/esm/src/formats/tiff.js
CHANGED
|
@@ -132,18 +132,49 @@ export class TIFFFormat {
|
|
|
132
132
|
const { width, height, data, metadata } = imageData;
|
|
133
133
|
const opts = options;
|
|
134
134
|
const compression = opts?.compression ?? "none";
|
|
135
|
+
const grayscale = opts?.grayscale ?? false;
|
|
136
|
+
const rgb = opts?.rgb ?? false;
|
|
137
|
+
// Convert RGBA to grayscale if requested
|
|
138
|
+
let sourceData;
|
|
139
|
+
let samplesPerPixel;
|
|
140
|
+
if (grayscale) {
|
|
141
|
+
sourceData = new Uint8Array(width * height);
|
|
142
|
+
samplesPerPixel = 1;
|
|
143
|
+
for (let i = 0; i < width * height; i++) {
|
|
144
|
+
const r = data[i * 4];
|
|
145
|
+
const g = data[i * 4 + 1];
|
|
146
|
+
const b = data[i * 4 + 2];
|
|
147
|
+
// Use standard luminance formula
|
|
148
|
+
sourceData[i] = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else if (rgb) {
|
|
152
|
+
// Convert RGBA to RGB (strip alpha channel)
|
|
153
|
+
sourceData = new Uint8Array(width * height * 3);
|
|
154
|
+
samplesPerPixel = 3;
|
|
155
|
+
for (let i = 0; i < width * height; i++) {
|
|
156
|
+
sourceData[i * 3] = data[i * 4]; // R
|
|
157
|
+
sourceData[i * 3 + 1] = data[i * 4 + 1]; // G
|
|
158
|
+
sourceData[i * 3 + 2] = data[i * 4 + 2]; // B
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
// Keep as RGBA
|
|
163
|
+
sourceData = data;
|
|
164
|
+
samplesPerPixel = 4;
|
|
165
|
+
}
|
|
135
166
|
// Prepare pixel data (compress if needed)
|
|
136
167
|
let pixelData;
|
|
137
168
|
let compressionCode;
|
|
138
169
|
if (compression === "lzw") {
|
|
139
170
|
// LZW compress the pixel data
|
|
140
171
|
const encoder = new TIFFLZWEncoder();
|
|
141
|
-
pixelData = encoder.compress(
|
|
172
|
+
pixelData = encoder.compress(sourceData);
|
|
142
173
|
compressionCode = 5;
|
|
143
174
|
}
|
|
144
175
|
else {
|
|
145
176
|
// Uncompressed
|
|
146
|
-
pixelData =
|
|
177
|
+
pixelData = sourceData;
|
|
147
178
|
compressionCode = 1;
|
|
148
179
|
}
|
|
149
180
|
const result = [];
|
|
@@ -161,7 +192,10 @@ export class TIFFFormat {
|
|
|
161
192
|
// IFD (Image File Directory)
|
|
162
193
|
const ifdStart = result.length;
|
|
163
194
|
// Count number of entries (including metadata)
|
|
164
|
-
|
|
195
|
+
// Grayscale: 10 entries (no ExtraSamples)
|
|
196
|
+
// RGB: 11 entries (no ExtraSamples)
|
|
197
|
+
// RGBA: 12 entries (includes ExtraSamples)
|
|
198
|
+
let numEntries = grayscale ? 10 : (rgb ? 11 : 12);
|
|
165
199
|
if (metadata?.description)
|
|
166
200
|
numEntries++;
|
|
167
201
|
if (metadata?.author)
|
|
@@ -179,16 +213,28 @@ export class TIFFFormat {
|
|
|
179
213
|
// ImageHeight (0x0101)
|
|
180
214
|
this.writeIFDEntry(result, 0x0101, 4, 1, height);
|
|
181
215
|
// BitsPerSample (0x0102) - 8 bits per channel
|
|
182
|
-
|
|
183
|
-
|
|
216
|
+
if (grayscale) {
|
|
217
|
+
// Single value for grayscale
|
|
218
|
+
this.writeIFDEntry(result, 0x0102, 3, 1, 8);
|
|
219
|
+
}
|
|
220
|
+
else if (rgb) {
|
|
221
|
+
// 3 values for RGB
|
|
222
|
+
this.writeIFDEntry(result, 0x0102, 3, 3, dataOffset);
|
|
223
|
+
dataOffset += 6; // 3 x 2-byte values
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// 4 values for RGBA
|
|
227
|
+
this.writeIFDEntry(result, 0x0102, 3, 4, dataOffset);
|
|
228
|
+
dataOffset += 8; // 4 x 2-byte values
|
|
229
|
+
}
|
|
184
230
|
// Compression (0x0103) - 1 = uncompressed, 5 = LZW
|
|
185
231
|
this.writeIFDEntry(result, 0x0103, 3, 1, compressionCode);
|
|
186
|
-
// PhotometricInterpretation (0x0106) - 2 = RGB
|
|
187
|
-
this.writeIFDEntry(result, 0x0106, 3, 1, 2);
|
|
232
|
+
// PhotometricInterpretation (0x0106) - 1 = BlackIsZero (grayscale), 2 = RGB
|
|
233
|
+
this.writeIFDEntry(result, 0x0106, 3, 1, grayscale ? 1 : 2);
|
|
188
234
|
// StripOffsets (0x0111)
|
|
189
235
|
this.writeIFDEntry(result, 0x0111, 4, 1, 8);
|
|
190
|
-
// SamplesPerPixel (0x0115) - 4
|
|
191
|
-
this.writeIFDEntry(result, 0x0115, 3, 1,
|
|
236
|
+
// SamplesPerPixel (0x0115) - 1 for grayscale, 3 for RGB, 4 for RGBA
|
|
237
|
+
this.writeIFDEntry(result, 0x0115, 3, 1, samplesPerPixel);
|
|
192
238
|
// RowsPerStrip (0x0116)
|
|
193
239
|
this.writeIFDEntry(result, 0x0116, 4, 1, height);
|
|
194
240
|
// StripByteCounts (0x0117)
|
|
@@ -201,8 +247,10 @@ export class TIFFFormat {
|
|
|
201
247
|
const yResOffset = dataOffset;
|
|
202
248
|
this.writeIFDEntry(result, 0x011b, 5, 1, yResOffset);
|
|
203
249
|
dataOffset += 8;
|
|
204
|
-
// ExtraSamples (0x0152) - 2 = unassociated alpha
|
|
205
|
-
|
|
250
|
+
// ExtraSamples (0x0152) - 2 = unassociated alpha (only for RGBA)
|
|
251
|
+
if (!grayscale && !rgb) {
|
|
252
|
+
this.writeIFDEntry(result, 0x0152, 3, 1, 2);
|
|
253
|
+
}
|
|
206
254
|
// Optional metadata entries
|
|
207
255
|
if (metadata?.description) {
|
|
208
256
|
const descBytes = new TextEncoder().encode(metadata.description + "\0");
|
|
@@ -229,11 +277,18 @@ export class TIFFFormat {
|
|
|
229
277
|
// Next IFD offset (0 = no more IFDs)
|
|
230
278
|
this.writeUint32LE(result, 0);
|
|
231
279
|
// Write variable-length data
|
|
232
|
-
// BitsPerSample values (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
280
|
+
// BitsPerSample values (only for RGB and RGBA, not for grayscale)
|
|
281
|
+
if (rgb) {
|
|
282
|
+
this.writeUint16LE(result, 8);
|
|
283
|
+
this.writeUint16LE(result, 8);
|
|
284
|
+
this.writeUint16LE(result, 8);
|
|
285
|
+
}
|
|
286
|
+
else if (!grayscale) {
|
|
287
|
+
this.writeUint16LE(result, 8);
|
|
288
|
+
this.writeUint16LE(result, 8);
|
|
289
|
+
this.writeUint16LE(result, 8);
|
|
290
|
+
this.writeUint16LE(result, 8);
|
|
291
|
+
}
|
|
237
292
|
// XResolution value (rational)
|
|
238
293
|
const dpiX = metadata?.dpiX ?? DEFAULT_DPI;
|
|
239
294
|
this.writeUint32LE(result, dpiX);
|
|
@@ -696,14 +751,23 @@ export class TIFFFormat {
|
|
|
696
751
|
}
|
|
697
752
|
// Check photometric interpretation
|
|
698
753
|
const photometric = this.getIFDValue(data, ifdOffset, 0x0106, isLittleEndian);
|
|
699
|
-
if (photometric !== 2) {
|
|
700
|
-
//
|
|
754
|
+
if (photometric !== 0 && photometric !== 1 && photometric !== 2) {
|
|
755
|
+
// Support: 0 = WhiteIsZero, 1 = BlackIsZero, 2 = RGB
|
|
701
756
|
return null;
|
|
702
757
|
}
|
|
703
758
|
// Get samples per pixel
|
|
704
759
|
const samplesPerPixel = this.getIFDValue(data, ifdOffset, 0x0115, isLittleEndian);
|
|
705
|
-
|
|
706
|
-
|
|
760
|
+
// For grayscale (photometric 0 or 1), expect 1 sample per pixel
|
|
761
|
+
// For RGB, expect 3 or 4 samples per pixel
|
|
762
|
+
if (!samplesPerPixel) {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
if ((photometric === 0 || photometric === 1) && samplesPerPixel !== 1) {
|
|
766
|
+
// Grayscale requires 1 sample per pixel
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
if (photometric === 2 && samplesPerPixel !== 3 && samplesPerPixel !== 4) {
|
|
770
|
+
// RGB requires 3 or 4 samples per pixel
|
|
707
771
|
return null;
|
|
708
772
|
}
|
|
709
773
|
// Get strip offset
|
|
@@ -733,17 +797,40 @@ export class TIFFFormat {
|
|
|
733
797
|
// Convert to RGBA
|
|
734
798
|
const rgba = new Uint8Array(width * height * 4);
|
|
735
799
|
let srcPos = 0;
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
800
|
+
if (photometric === 0 || photometric === 1) {
|
|
801
|
+
// Grayscale image
|
|
802
|
+
for (let y = 0; y < height; y++) {
|
|
803
|
+
for (let x = 0; x < width; x++) {
|
|
804
|
+
const dstIdx = (y * width + x) * 4;
|
|
805
|
+
if (srcPos >= pixelData.length) {
|
|
806
|
+
return null; // Not enough data
|
|
807
|
+
}
|
|
808
|
+
let gray = pixelData[srcPos++];
|
|
809
|
+
// WhiteIsZero (0) means 0=white, 255=black, so invert
|
|
810
|
+
if (photometric === 0) {
|
|
811
|
+
gray = 255 - gray;
|
|
812
|
+
}
|
|
813
|
+
rgba[dstIdx] = gray; // R
|
|
814
|
+
rgba[dstIdx + 1] = gray; // G
|
|
815
|
+
rgba[dstIdx + 2] = gray; // B
|
|
816
|
+
rgba[dstIdx + 3] = 255; // A (opaque)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
// RGB/RGBA image
|
|
822
|
+
for (let y = 0; y < height; y++) {
|
|
823
|
+
for (let x = 0; x < width; x++) {
|
|
824
|
+
const dstIdx = (y * width + x) * 4;
|
|
825
|
+
if (srcPos + samplesPerPixel > pixelData.length) {
|
|
826
|
+
return null; // Not enough data
|
|
827
|
+
}
|
|
828
|
+
// TIFF stores RGB(A) in order
|
|
829
|
+
rgba[dstIdx] = pixelData[srcPos++]; // R
|
|
830
|
+
rgba[dstIdx + 1] = pixelData[srcPos++]; // G
|
|
831
|
+
rgba[dstIdx + 2] = pixelData[srcPos++]; // B
|
|
832
|
+
rgba[dstIdx + 3] = samplesPerPixel === 4 ? pixelData[srcPos++] : 255; // A
|
|
741
833
|
}
|
|
742
|
-
// TIFF stores RGB(A) in order
|
|
743
|
-
rgba[dstIdx] = pixelData[srcPos++]; // R
|
|
744
|
-
rgba[dstIdx + 1] = pixelData[srcPos++]; // G
|
|
745
|
-
rgba[dstIdx + 2] = pixelData[srcPos++]; // B
|
|
746
|
-
rgba[dstIdx + 3] = samplesPerPixel === 4 ? pixelData[srcPos++] : 255; // A
|
|
747
834
|
}
|
|
748
835
|
}
|
|
749
836
|
return rgba;
|
|
@@ -761,14 +848,23 @@ export class TIFFFormat {
|
|
|
761
848
|
}
|
|
762
849
|
// Check photometric interpretation
|
|
763
850
|
const photometric = this.getIFDValue(data, ifdOffset, 0x0106, isLittleEndian);
|
|
764
|
-
if (photometric !== 2) {
|
|
765
|
-
//
|
|
851
|
+
if (photometric !== 0 && photometric !== 1 && photometric !== 2) {
|
|
852
|
+
// Support: 0 = WhiteIsZero, 1 = BlackIsZero, 2 = RGB
|
|
766
853
|
return null;
|
|
767
854
|
}
|
|
768
855
|
// Get samples per pixel
|
|
769
856
|
const samplesPerPixel = this.getIFDValue(data, ifdOffset, 0x0115, isLittleEndian);
|
|
770
|
-
|
|
771
|
-
|
|
857
|
+
// For grayscale (photometric 0 or 1), expect 1 sample per pixel
|
|
858
|
+
// For RGB, expect 3 or 4 samples per pixel
|
|
859
|
+
if (!samplesPerPixel) {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
if ((photometric === 0 || photometric === 1) && samplesPerPixel !== 1) {
|
|
863
|
+
// Grayscale requires 1 sample per pixel
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
if (photometric === 2 && samplesPerPixel !== 3 && samplesPerPixel !== 4) {
|
|
867
|
+
// RGB requires 3 or 4 samples per pixel
|
|
772
868
|
return null;
|
|
773
869
|
}
|
|
774
870
|
// Get strip offset
|
|
@@ -798,17 +894,40 @@ export class TIFFFormat {
|
|
|
798
894
|
// Convert to RGBA
|
|
799
895
|
const rgba = new Uint8Array(width * height * 4);
|
|
800
896
|
let srcPos = 0;
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
897
|
+
if (photometric === 0 || photometric === 1) {
|
|
898
|
+
// Grayscale image
|
|
899
|
+
for (let y = 0; y < height; y++) {
|
|
900
|
+
for (let x = 0; x < width; x++) {
|
|
901
|
+
const dstIdx = (y * width + x) * 4;
|
|
902
|
+
if (srcPos >= pixelData.length) {
|
|
903
|
+
return null; // Not enough data
|
|
904
|
+
}
|
|
905
|
+
let gray = pixelData[srcPos++];
|
|
906
|
+
// WhiteIsZero (0) means 0=white, 255=black, so invert
|
|
907
|
+
if (photometric === 0) {
|
|
908
|
+
gray = 255 - gray;
|
|
909
|
+
}
|
|
910
|
+
rgba[dstIdx] = gray; // R
|
|
911
|
+
rgba[dstIdx + 1] = gray; // G
|
|
912
|
+
rgba[dstIdx + 2] = gray; // B
|
|
913
|
+
rgba[dstIdx + 3] = 255; // A (opaque)
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
// RGB/RGBA image
|
|
919
|
+
for (let y = 0; y < height; y++) {
|
|
920
|
+
for (let x = 0; x < width; x++) {
|
|
921
|
+
const dstIdx = (y * width + x) * 4;
|
|
922
|
+
if (srcPos + samplesPerPixel > pixelData.length) {
|
|
923
|
+
return null; // Not enough data
|
|
924
|
+
}
|
|
925
|
+
// TIFF stores RGB(A) in order
|
|
926
|
+
rgba[dstIdx] = pixelData[srcPos++]; // R
|
|
927
|
+
rgba[dstIdx + 1] = pixelData[srcPos++]; // G
|
|
928
|
+
rgba[dstIdx + 2] = pixelData[srcPos++]; // B
|
|
929
|
+
rgba[dstIdx + 3] = samplesPerPixel === 4 ? pixelData[srcPos++] : 255; // A
|
|
806
930
|
}
|
|
807
|
-
// TIFF stores RGB(A) in order
|
|
808
|
-
rgba[dstIdx] = pixelData[srcPos++]; // R
|
|
809
|
-
rgba[dstIdx + 1] = pixelData[srcPos++]; // G
|
|
810
|
-
rgba[dstIdx + 2] = pixelData[srcPos++]; // B
|
|
811
|
-
rgba[dstIdx + 3] = samplesPerPixel === 4 ? pixelData[srcPos++] : 255; // A
|
|
812
931
|
}
|
|
813
932
|
}
|
|
814
933
|
return rgba;
|
|
@@ -27,7 +27,6 @@ export declare class WebPFormat implements ImageFormat {
|
|
|
27
27
|
* @returns Encoded WebP image bytes
|
|
28
28
|
*/
|
|
29
29
|
encode(imageData: ImageData, options?: WebPEncodeOptions): Promise<Uint8Array>;
|
|
30
|
-
private readUint32LE;
|
|
31
30
|
private readUint24LE;
|
|
32
31
|
private decodeUsingRuntime;
|
|
33
32
|
private parseEXIF;
|
package/esm/src/formats/webp.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validateImageDimensions } from "../utils/security.js";
|
|
2
|
+
import { readUint32LE } from "../utils/byte_utils.js";
|
|
2
3
|
// Default quality for WebP encoding when not specified
|
|
3
4
|
const DEFAULT_WEBP_QUALITY = 90;
|
|
4
5
|
/**
|
|
@@ -52,7 +53,7 @@ export class WebPFormat {
|
|
|
52
53
|
// Read all chunks to extract metadata
|
|
53
54
|
while (pos + 8 <= data.length) {
|
|
54
55
|
const chunkType = String.fromCharCode(data[pos], data[pos + 1], data[pos + 2], data[pos + 3]);
|
|
55
|
-
const chunkSize =
|
|
56
|
+
const chunkSize = readUint32LE(data, pos + 4);
|
|
56
57
|
pos += 8;
|
|
57
58
|
// Stop if we've gone past the end
|
|
58
59
|
if (pos + chunkSize > data.length)
|
|
@@ -74,7 +75,7 @@ export class WebPFormat {
|
|
|
74
75
|
else if (chunkType === "VP8L") {
|
|
75
76
|
// Lossless format - extract dimensions
|
|
76
77
|
if (chunkData.length >= 5 && chunkData[0] === 0x2f) {
|
|
77
|
-
const bits =
|
|
78
|
+
const bits = readUint32LE(chunkData, 1);
|
|
78
79
|
width = (bits & 0x3fff) + 1;
|
|
79
80
|
height = ((bits >> 14) & 0x3fff) + 1;
|
|
80
81
|
}
|
|
@@ -166,10 +167,6 @@ export class WebPFormat {
|
|
|
166
167
|
}
|
|
167
168
|
return encoded;
|
|
168
169
|
}
|
|
169
|
-
readUint32LE(data, offset) {
|
|
170
|
-
return data[offset] | (data[offset + 1] << 8) |
|
|
171
|
-
(data[offset + 2] << 16) | (data[offset + 3] << 24);
|
|
172
|
-
}
|
|
173
170
|
readUint24LE(data, offset) {
|
|
174
171
|
return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16);
|
|
175
172
|
}
|
|
@@ -306,7 +303,7 @@ export class WebPFormat {
|
|
|
306
303
|
let pos = 12;
|
|
307
304
|
while (pos + 8 <= webpData.length) {
|
|
308
305
|
const chunkType = String.fromCharCode(webpData[pos], webpData[pos + 1], webpData[pos + 2], webpData[pos + 3]);
|
|
309
|
-
const chunkSize =
|
|
306
|
+
const chunkSize = readUint32LE(webpData, pos + 4);
|
|
310
307
|
// Don't copy existing EXIF/XMP chunks (we'll add new ones)
|
|
311
308
|
if (chunkType !== "EXIF" && chunkType !== "XMP ") {
|
|
312
309
|
const chunkEnd = pos + 8 + chunkSize + (chunkSize % 2);
|
package/esm/src/image.d.ts
CHANGED
|
@@ -207,6 +207,36 @@ export declare class Image {
|
|
|
207
207
|
* @returns This image instance for chaining
|
|
208
208
|
*/
|
|
209
209
|
grayscale(): this;
|
|
210
|
+
/**
|
|
211
|
+
* Apply sepia tone effect to the image
|
|
212
|
+
* @returns This image instance for chaining
|
|
213
|
+
*/
|
|
214
|
+
sepia(): this;
|
|
215
|
+
/**
|
|
216
|
+
* Apply box blur filter to the image
|
|
217
|
+
* @param radius Blur radius (default: 1)
|
|
218
|
+
* @returns This image instance for chaining
|
|
219
|
+
*/
|
|
220
|
+
blur(radius?: number): this;
|
|
221
|
+
/**
|
|
222
|
+
* Apply Gaussian blur filter to the image
|
|
223
|
+
* @param radius Blur radius (default: 1)
|
|
224
|
+
* @param sigma Optional standard deviation (if not provided, calculated from radius)
|
|
225
|
+
* @returns This image instance for chaining
|
|
226
|
+
*/
|
|
227
|
+
gaussianBlur(radius?: number, sigma?: number): this;
|
|
228
|
+
/**
|
|
229
|
+
* Apply sharpen filter to the image
|
|
230
|
+
* @param amount Sharpening amount (0-1, default: 0.5)
|
|
231
|
+
* @returns This image instance for chaining
|
|
232
|
+
*/
|
|
233
|
+
sharpen(amount?: number): this;
|
|
234
|
+
/**
|
|
235
|
+
* Apply median filter to reduce noise
|
|
236
|
+
* @param radius Filter radius (default: 1)
|
|
237
|
+
* @returns This image instance for chaining
|
|
238
|
+
*/
|
|
239
|
+
medianFilter(radius?: number): this;
|
|
210
240
|
/**
|
|
211
241
|
* Fill a rectangular region with a color
|
|
212
242
|
* @param x Starting X position
|