@effected/images 0.0.0-stage → 0.1.1
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/ImageBackend.js +117 -0
- package/ImageBackendError.js +28 -0
- package/ImageCache.js +70 -0
- package/ImageCacheKey.js +56 -0
- package/ImageCacheKeyError.js +31 -0
- package/ImageFacts.js +78 -0
- package/ImageFormat.js +55 -0
- package/ImageGenerateError.js +34 -0
- package/ImageParseError.js +35 -0
- package/LICENSE +21 -0
- package/README.md +151 -2
- package/cache.d.ts +257 -0
- package/cache.js +8 -0
- package/index.d.ts +96 -0
- package/index.js +5 -0
- package/internal/avif.js +95 -0
- package/internal/bytes.js +14 -0
- package/internal/canonical.js +48 -0
- package/internal/detect.js +31 -0
- package/internal/digest.js +6 -0
- package/internal/gif.js +9 -0
- package/internal/jpeg.js +41 -0
- package/internal/png.js +15 -0
- package/internal/result.js +21 -0
- package/internal/webp.js +33 -0
- package/package.json +52 -5
- package/tsdoc-metadata.json +11 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Result } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/canonical.ts
|
|
4
|
+
const MAX_DEPTH = 256;
|
|
5
|
+
const isPlainObject = (value) => {
|
|
6
|
+
const proto = Object.getPrototypeOf(value);
|
|
7
|
+
return proto === Object.prototype || proto === null;
|
|
8
|
+
};
|
|
9
|
+
const encode = (value, depth) => {
|
|
10
|
+
if (depth > MAX_DEPTH) return Result.fail(`nesting deeper than ${MAX_DEPTH}`);
|
|
11
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") return Result.succeed(JSON.stringify(value));
|
|
12
|
+
if (typeof value === "number") return Number.isFinite(value) ? Result.succeed(JSON.stringify(value)) : Result.fail(`non-finite number ${value}`);
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
const parts = [];
|
|
15
|
+
for (const item of value) {
|
|
16
|
+
if (item === void 0) return Result.fail("undefined array element");
|
|
17
|
+
const part = encode(item, depth + 1);
|
|
18
|
+
if (Result.isFailure(part)) return part;
|
|
19
|
+
parts.push(part.success);
|
|
20
|
+
}
|
|
21
|
+
return Result.succeed(`[${parts.join(",")}]`);
|
|
22
|
+
}
|
|
23
|
+
if (typeof value === "object" && isPlainObject(value)) {
|
|
24
|
+
const parts = [];
|
|
25
|
+
for (const key of Object.keys(value).sort()) {
|
|
26
|
+
const member = value[key];
|
|
27
|
+
if (member === void 0) continue;
|
|
28
|
+
const part = encode(member, depth + 1);
|
|
29
|
+
if (Result.isFailure(part)) return part;
|
|
30
|
+
parts.push(`${JSON.stringify(key)}:${part.success}`);
|
|
31
|
+
}
|
|
32
|
+
return Result.succeed(`{${parts.join(",")}}`);
|
|
33
|
+
}
|
|
34
|
+
const kind = typeof value === "object" ? value.constructor?.name ?? "object" : typeof value;
|
|
35
|
+
return Result.fail(`value of type ${kind} is not JSON data`);
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Canonical JSON: recursively sorted keys, no whitespace, undefined members omitted; non-JSON values refused.
|
|
39
|
+
*
|
|
40
|
+
* Core has no canonical serializer, and `@effected/schemastore`'s `CanonicalJson` is a file formatter that keeps
|
|
41
|
+
* insertion order, so this one is the package's own.
|
|
42
|
+
*
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
45
|
+
const canonicalJson = (value) => encode(value, 0);
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
export { canonicalJson };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ascii, matches } from "./bytes.js";
|
|
2
|
+
import { isAvifBrand } from "./avif.js";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/detect.ts
|
|
5
|
+
const PNG = [
|
|
6
|
+
137,
|
|
7
|
+
80,
|
|
8
|
+
78,
|
|
9
|
+
71,
|
|
10
|
+
13,
|
|
11
|
+
10,
|
|
12
|
+
26,
|
|
13
|
+
10
|
|
14
|
+
];
|
|
15
|
+
const JPEG = [
|
|
16
|
+
255,
|
|
17
|
+
216,
|
|
18
|
+
255
|
|
19
|
+
];
|
|
20
|
+
/** The format whose signature `bytes` starts with, or undefined. A partial signature is no match. */
|
|
21
|
+
const detectFormat = (b) => {
|
|
22
|
+
if (matches(b, 0, PNG)) return "png";
|
|
23
|
+
if (matches(b, 0, JPEG)) return "jpeg";
|
|
24
|
+
const gif = b.length >= 6 ? ascii(b, 0, 6) : "";
|
|
25
|
+
if (gif === "GIF87a" || gif === "GIF89a") return "gif";
|
|
26
|
+
if (b.length >= 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP") return "webp";
|
|
27
|
+
if (isAvifBrand(b)) return "avif";
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
export { detectFormat };
|
package/internal/gif.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { u16le } from "./bytes.js";
|
|
2
|
+
import { dimensions, truncated } from "./result.js";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/gif.ts
|
|
5
|
+
/** The logical screen descriptor follows the 6-byte signature: width(2 LE), height(2 LE). */
|
|
6
|
+
const readGif = (b) => b.length < 10 ? truncated(`need 10 bytes for the screen descriptor, have ${b.length}`) : dimensions(u16le(b, 6), u16le(b, 8));
|
|
7
|
+
|
|
8
|
+
//#endregion
|
|
9
|
+
export { readGif };
|
package/internal/jpeg.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { u16be } from "./bytes.js";
|
|
2
|
+
import { dimensions, malformed, truncated } from "./result.js";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/jpeg.ts
|
|
5
|
+
/** Markers plus fill bytes the walk may consume before it gives up. Hostile input cannot loop past it. */
|
|
6
|
+
const JPEG_STEP_BUDGET = 4096;
|
|
7
|
+
/** SOFn frame headers: C0-CF except C4 (DHT), C8 (JPG) and CC (DAC). */
|
|
8
|
+
const isFrameHeader = (marker) => marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
|
|
9
|
+
/** Markers that carry no length field. */
|
|
10
|
+
const isStandalone = (marker) => marker === 1 || marker === 216 || marker >= 208 && marker <= 215;
|
|
11
|
+
const readJpeg = (b) => {
|
|
12
|
+
let offset = 2;
|
|
13
|
+
let steps = 0;
|
|
14
|
+
for (;;) {
|
|
15
|
+
if (++steps > 4096) return malformed("segment walk exceeded its step budget");
|
|
16
|
+
if (offset >= b.length) return truncated("ended before a frame header");
|
|
17
|
+
if (b[offset] !== 255) return malformed(`expected a marker at offset ${offset}`);
|
|
18
|
+
let at = offset + 1;
|
|
19
|
+
while (at < b.length && b[at] === 255) {
|
|
20
|
+
at++;
|
|
21
|
+
if (++steps > 4096) return malformed("segment walk exceeded its step budget");
|
|
22
|
+
}
|
|
23
|
+
if (at >= b.length) return truncated("ended inside a marker");
|
|
24
|
+
const marker = b[at];
|
|
25
|
+
offset = at + 1;
|
|
26
|
+
if (isStandalone(marker)) continue;
|
|
27
|
+
if (marker === 217 || marker === 218) return malformed("reached end of image or a scan before any frame header");
|
|
28
|
+
if (offset + 2 > b.length) return truncated("ended inside a segment length");
|
|
29
|
+
const length = u16be(b, offset);
|
|
30
|
+
if (length < 2) return malformed(`segment length ${length} is below 2`);
|
|
31
|
+
if (isFrameHeader(marker)) {
|
|
32
|
+
if (length < 11) return malformed(`frame header length ${length} is below 11`);
|
|
33
|
+
if (offset + 7 > b.length) return truncated("ended inside the frame header");
|
|
34
|
+
return dimensions(u16be(b, offset + 5), u16be(b, offset + 3));
|
|
35
|
+
}
|
|
36
|
+
offset += length;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
//#endregion
|
|
41
|
+
export { JPEG_STEP_BUDGET, readJpeg };
|
package/internal/png.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ascii, u32be } from "./bytes.js";
|
|
2
|
+
import { dimensions, malformed, truncated } from "./result.js";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/png.ts
|
|
5
|
+
/** IHDR is the first chunk: length(4)=13, type(4)="IHDR", width(4), height(4) after the 8-byte signature. */
|
|
6
|
+
const readPng = (b) => {
|
|
7
|
+
if (b.length < 24) return truncated(`need 24 bytes for IHDR dimensions, have ${b.length}`);
|
|
8
|
+
if (ascii(b, 12, 4) !== "IHDR") return malformed("first chunk is not IHDR");
|
|
9
|
+
const length = u32be(b, 8);
|
|
10
|
+
if (length !== 13) return malformed(`IHDR length ${length} is not 13`);
|
|
11
|
+
return dimensions(u32be(b, 16), u32be(b, 20));
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
export { readPng };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Result } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/result.ts
|
|
4
|
+
const MAX_DIMENSION = 2147483647;
|
|
5
|
+
const truncated = (detail) => Result.fail({
|
|
6
|
+
reason: "truncated",
|
|
7
|
+
detail
|
|
8
|
+
});
|
|
9
|
+
const malformed = (detail) => Result.fail({
|
|
10
|
+
reason: "malformed",
|
|
11
|
+
detail
|
|
12
|
+
});
|
|
13
|
+
const legal = (n) => Number.isInteger(n) && n >= 1 && n <= 2147483647;
|
|
14
|
+
/** Succeed with the pair, or fail `malformed` when either side is out of range. */
|
|
15
|
+
const dimensions = (width, height) => legal(width) && legal(height) ? Result.succeed({
|
|
16
|
+
width,
|
|
17
|
+
height
|
|
18
|
+
}) : malformed(`dimensions ${width}x${height} out of range 1..${MAX_DIMENSION}`);
|
|
19
|
+
|
|
20
|
+
//#endregion
|
|
21
|
+
export { MAX_DIMENSION, dimensions, malformed, truncated };
|
package/internal/webp.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ascii, u16le, u24le } from "./bytes.js";
|
|
2
|
+
import { dimensions, malformed, truncated } from "./result.js";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/webp.ts
|
|
5
|
+
const PAYLOAD = 20;
|
|
6
|
+
const readWebp = (b) => {
|
|
7
|
+
if (b.length < 16) return truncated("need the first chunk's fourcc");
|
|
8
|
+
const fourcc = ascii(b, 12, 4);
|
|
9
|
+
switch (fourcc) {
|
|
10
|
+
case "VP8 ":
|
|
11
|
+
if (b.length < 30) return truncated("need the VP8 frame header");
|
|
12
|
+
if (b[23] !== 157 || b[24] !== 1 || b[25] !== 42) return malformed("VP8 start code is not 9D 01 2A");
|
|
13
|
+
return dimensions(u16le(b, 26) & 16383, u16le(b, 28) & 16383);
|
|
14
|
+
case "VP8L": {
|
|
15
|
+
if (b.length < 25) return truncated("need the VP8L header");
|
|
16
|
+
if (b[PAYLOAD] !== 47) return malformed("VP8L signature byte is not 0x2F");
|
|
17
|
+
const b1 = b[21];
|
|
18
|
+
const b2 = b[22];
|
|
19
|
+
const b3 = b[23];
|
|
20
|
+
const b4 = b[24];
|
|
21
|
+
const width = 1 + ((b2 & 63) << 8 | b1);
|
|
22
|
+
const height = 1 + ((b4 & 15) << 10 | b3 << 2 | (b2 & 192) >> 6);
|
|
23
|
+
return dimensions(width, height);
|
|
24
|
+
}
|
|
25
|
+
case "VP8X":
|
|
26
|
+
if (b.length < 30) return truncated("need the VP8X canvas size");
|
|
27
|
+
return dimensions(1 + u24le(b, 24), 1 + u24le(b, 27));
|
|
28
|
+
default: return malformed(`unknown first WebP chunk ${JSON.stringify(fourcc)}`);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
//#endregion
|
|
33
|
+
export { readWebp };
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@effected/images",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Image facts (format, MIME type, dimensions) from bytes, and a generated-image cache, for Effect.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"image",
|
|
8
|
+
"dimensions",
|
|
9
|
+
"png",
|
|
10
|
+
"jpeg",
|
|
11
|
+
"webp",
|
|
12
|
+
"avif",
|
|
13
|
+
"cache",
|
|
14
|
+
"effect",
|
|
15
|
+
"effected"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/images#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/spencerbeggs/effected/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/spencerbeggs/effected.git",
|
|
24
|
+
"directory": "packages/images"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "C. Spencer Beggs",
|
|
29
|
+
"email": "spencer@beggs.codes",
|
|
30
|
+
"url": "https://spencerbeg.gs"
|
|
31
|
+
},
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"type": "module",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./index.d.ts",
|
|
37
|
+
"import": "./index.js",
|
|
38
|
+
"default": "./index.js"
|
|
39
|
+
},
|
|
40
|
+
"./cache": {
|
|
41
|
+
"types": "./cache.d.ts",
|
|
42
|
+
"import": "./cache.js",
|
|
43
|
+
"default": "./cache.js"
|
|
44
|
+
},
|
|
45
|
+
"./package.json": "./package.json"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"effect": "^4.0.0"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=24.11.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// This file is read by tools that parse documentation comments conforming to the TSDoc standard.
|
|
2
|
+
// It should be published with your NPM package. It should not be tracked by Git.
|
|
3
|
+
{
|
|
4
|
+
"tsdocVersion": "0.12",
|
|
5
|
+
"toolPackages": [
|
|
6
|
+
{
|
|
7
|
+
"packageName": "@microsoft/api-extractor",
|
|
8
|
+
"packageVersion": "7.59.1"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|