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.
Files changed (62) hide show
  1. package/README.md +41 -28
  2. package/esm/mod.d.ts +4 -1
  3. package/esm/mod.js +4 -1
  4. package/esm/src/formats/apng.d.ts +50 -0
  5. package/esm/src/formats/apng.js +364 -0
  6. package/esm/src/formats/bmp.d.ts +0 -6
  7. package/esm/src/formats/bmp.js +24 -47
  8. package/esm/src/formats/dng.js +4 -4
  9. package/esm/src/formats/gif.d.ts +0 -2
  10. package/esm/src/formats/gif.js +10 -16
  11. package/esm/src/formats/ico.d.ts +41 -0
  12. package/esm/src/formats/ico.js +214 -0
  13. package/esm/src/formats/pcx.js +1 -1
  14. package/esm/src/formats/png.d.ts +2 -21
  15. package/esm/src/formats/png.js +5 -429
  16. package/esm/src/formats/png_base.d.ts +108 -0
  17. package/esm/src/formats/png_base.js +487 -0
  18. package/esm/src/formats/ppm.d.ts +50 -0
  19. package/esm/src/formats/ppm.js +242 -0
  20. package/esm/src/formats/tiff.d.ts +4 -0
  21. package/esm/src/formats/tiff.js +163 -44
  22. package/esm/src/formats/webp.d.ts +0 -1
  23. package/esm/src/formats/webp.js +4 -7
  24. package/esm/src/image.d.ts +30 -0
  25. package/esm/src/image.js +62 -1
  26. package/esm/src/utils/byte_utils.d.ts +30 -0
  27. package/esm/src/utils/byte_utils.js +50 -0
  28. package/esm/src/utils/gif_encoder.d.ts +3 -2
  29. package/esm/src/utils/gif_encoder.js +115 -48
  30. package/esm/src/utils/image_processing.d.ts +43 -0
  31. package/esm/src/utils/image_processing.js +230 -0
  32. package/package.json +1 -1
  33. package/script/mod.d.ts +4 -1
  34. package/script/mod.js +8 -2
  35. package/script/src/formats/apng.d.ts +50 -0
  36. package/script/src/formats/apng.js +368 -0
  37. package/script/src/formats/bmp.d.ts +0 -6
  38. package/script/src/formats/bmp.js +24 -47
  39. package/script/src/formats/dng.js +4 -4
  40. package/script/src/formats/gif.d.ts +0 -2
  41. package/script/src/formats/gif.js +10 -16
  42. package/script/src/formats/ico.d.ts +41 -0
  43. package/script/src/formats/ico.js +218 -0
  44. package/script/src/formats/pcx.js +1 -1
  45. package/script/src/formats/png.d.ts +2 -21
  46. package/script/src/formats/png.js +5 -429
  47. package/script/src/formats/png_base.d.ts +108 -0
  48. package/script/src/formats/png_base.js +491 -0
  49. package/script/src/formats/ppm.d.ts +50 -0
  50. package/script/src/formats/ppm.js +246 -0
  51. package/script/src/formats/tiff.d.ts +4 -0
  52. package/script/src/formats/tiff.js +163 -44
  53. package/script/src/formats/webp.d.ts +0 -1
  54. package/script/src/formats/webp.js +4 -7
  55. package/script/src/image.d.ts +30 -0
  56. package/script/src/image.js +61 -0
  57. package/script/src/utils/byte_utils.d.ts +30 -0
  58. package/script/src/utils/byte_utils.js +58 -0
  59. package/script/src/utils/gif_encoder.d.ts +3 -2
  60. package/script/src/utils/gif_encoder.js +115 -48
  61. package/script/src/utils/image_processing.d.ts +43 -0
  62. 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
@@ -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(data);
172
+ pixelData = encoder.compress(sourceData);
142
173
  compressionCode = 5;
143
174
  }
144
175
  else {
145
176
  // Uncompressed
146
- pixelData = data;
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
- let numEntries = 12; // Base entries (including ExtraSamples)
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
- this.writeIFDEntry(result, 0x0102, 3, 4, dataOffset);
183
- dataOffset += 8; // 4 x 2-byte values
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 (RGBA)
191
- this.writeIFDEntry(result, 0x0115, 3, 1, 4);
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
- this.writeIFDEntry(result, 0x0152, 3, 1, 2);
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 (must be written first to match offset calculation)
233
- this.writeUint16LE(result, 8);
234
- this.writeUint16LE(result, 8);
235
- this.writeUint16LE(result, 8);
236
- this.writeUint16LE(result, 8);
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
- // Only support RGB (photometric = 2)
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
- if (!samplesPerPixel || (samplesPerPixel !== 3 && samplesPerPixel !== 4)) {
706
- // Only support RGB (3) or RGBA (4)
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
- for (let y = 0; y < height; y++) {
737
- for (let x = 0; x < width; x++) {
738
- const dstIdx = (y * width + x) * 4;
739
- if (srcPos + samplesPerPixel > pixelData.length) {
740
- return null; // Not enough data
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
- // Only support RGB (photometric = 2)
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
- if (!samplesPerPixel || (samplesPerPixel !== 3 && samplesPerPixel !== 4)) {
771
- // Only support RGB (3) or RGBA (4)
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
- for (let y = 0; y < height; y++) {
802
- for (let x = 0; x < width; x++) {
803
- const dstIdx = (y * width + x) * 4;
804
- if (srcPos + samplesPerPixel > pixelData.length) {
805
- return null; // Not enough data
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;
@@ -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 = this.readUint32LE(data, pos + 4);
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 = this.readUint32LE(chunkData, 1);
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 = this.readUint32LE(webpData, pos + 4);
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);
@@ -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