rosetta-squint 1.0.0

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 ADDED
@@ -0,0 +1,97 @@
1
+ # rosetta-squint — JS/TS
2
+
3
+ Point at a file path or pass raw image bytes; get back the perceptual hash hex string that every other `rosetta-squint` port produces for the same input.
4
+
5
+ ## Install (Node)
6
+
7
+ ```bash
8
+ npm install rosetta-squint
9
+ ```
10
+
11
+ ```ts
12
+ import { phash, phashBytes } from "rosetta-squint";
13
+
14
+ const h1 = await phash("photo.jpg", 8); // file path
15
+ const h2 = await phashBytes(new Uint8Array(jpegBytes), 8); // raw bytes in memory
16
+ console.log(h1.toString()); // "c3f8a1b27d0e4f96"
17
+ ```
18
+
19
+ ## Install (Browser)
20
+
21
+ ```ts
22
+ import { phashBytes } from "rosetta-squint/browser";
23
+
24
+ const resp = await fetch("/photo.jpg");
25
+ const bytes = new Uint8Array(await resp.arrayBuffer());
26
+ const h = await phashBytes(bytes, 8);
27
+ console.log(h.toString()); // "c3f8a1b27d0e4f96"
28
+ ```
29
+
30
+ The `/browser` sub-export omits the path-based functions (which use `node:fs`) and is otherwise identical. All 10 algorithms are available with the same names plus a `Bytes` suffix:
31
+
32
+ `averageHashBytes`, `phashBytes`, `phashSimpleBytes`, `dhashBytes`, `dhashVerticalBytes`, `whashHaarBytes`, `whashDb4Bytes`, `whashDb4RobustBytes`, `colorhashBytes`, `cropResistantHashBytes`.
33
+
34
+ ### Via CDN
35
+
36
+ Once on npm:
37
+
38
+ ```html
39
+ <script type="module">
40
+ // esm.sh auto-builds browser bundles for any npm package
41
+ import { phashBytes } from "https://esm.sh/rosetta-squint/browser";
42
+
43
+ const resp = await fetch("/photo.jpg");
44
+ const bytes = new Uint8Array(await resp.arrayBuffer());
45
+ console.log((await phashBytes(bytes, 8)).toString());
46
+ </script>
47
+ ```
48
+
49
+ esm.sh handles the @jsquash WASM + libheif-js bundling automatically. Other CDN options (`unpkg`, `jsdelivr`) ship the raw `dist/` and assume your bundler handles CJS→ESM for the libheif-js and utif2 transitive deps.
50
+
51
+ ### Bundler notes
52
+
53
+ The browser entry uses dynamic `import("libheif-js")` and `import("utif2")` which are CommonJS packages. Modern bundlers handle the CJS→ESM interop automatically:
54
+
55
+ | Bundler | What you need |
56
+ |---|---|
57
+ | Vite | works out of the box |
58
+ | esbuild | works out of the box |
59
+ | webpack 5+ | works out of the box |
60
+ | rollup | add `@rollup/plugin-commonjs` |
61
+ | Parcel | works out of the box |
62
+
63
+ WASM (mozjpeg, libwebp) is loaded via `WebAssembly.compileStreaming(fetch(url))` in the browser. Most bundlers emit the WASM blobs as static assets and rewrite the import URL — no manual config required for Vite/esbuild/webpack 5+.
64
+
65
+ The `loadWasm` helper detects browser vs. Node at runtime and uses the right loader path. The `node:fs` import in the Node branch is dynamic (`await import("node:fs")`) so browser bundlers tree-shake it.
66
+
67
+ ## API
68
+
69
+ ```ts
70
+ // Path entry (Node only)
71
+ phash(path: string, hashSize: number): Promise<Hash>
72
+ phashSimple(path: string, hashSize: number): Promise<Hash>
73
+ dhash(path: string, hashSize: number): Promise<Hash>
74
+ dhashVertical(path: string, hashSize: number): Promise<Hash>
75
+ averageHash(path: string, hashSize: number): Promise<Hash>
76
+ whashHaar(path: string, hashSize: number): Promise<Hash>
77
+ whashDb4(path: string, hashSize: number): Promise<Hash>
78
+ whashDb4Robust(path: string, hashSize: number): Promise<Hash>
79
+ colorhash(path: string, binbits: number): Promise<Hash>
80
+ cropResistantHash(path: string): Promise<ImageMultiHash>
81
+
82
+ // Bytes entry (Node + browser)
83
+ phashBytes(bytes: Uint8Array, hashSize: number): Promise<Hash>
84
+ // ... same suffix-Bytes pattern for every algorithm above ...
85
+
86
+ // Lower level
87
+ decodeFile(path: string): Promise<RgbImage> // Node only
88
+ decodeBytes(bytes: Uint8Array): Promise<RgbImage> // Node + browser
89
+ ```
90
+
91
+ ## Cross-port verification
92
+
93
+ `phashBytes(imagehash.png, 8) === "ba8c84536bd3c366"` in this package's browser entry, this package's Node entry, the Go squint port, the Java squint port, and the Python `rosetta_squint` port. Same hex everywhere.
94
+
95
+ ## License
96
+
97
+ BSD-2-Clause.
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "rosetta-squint",
3
+ "version": "1.0.0",
4
+ "description": "Cross-language byte-exact perceptual image hashing — decode + hash in one call. Umbrella package combining rosetta-squint-decode + rosetta-squint-hash.",
5
+ "keywords": [
6
+ "perceptual-hashing",
7
+ "imagehash",
8
+ "byte-exact",
9
+ "cross-language",
10
+ "image-decoder",
11
+ "phash"
12
+ ],
13
+ "homepage": "https://github.com/wmetcalf/rosetta-squint#readme",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/wmetcalf/rosetta-squint.git",
17
+ "directory": "squint/js/rosetta-squint"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/wmetcalf/rosetta-squint/issues"
21
+ },
22
+ "author": "Will Metcalf <william.metcalf@gmail.com>",
23
+ "license": "BSD-2-Clause",
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ },
32
+ "./browser": {
33
+ "types": "./dist/browser.d.ts",
34
+ "import": "./dist/browser.js"
35
+ }
36
+ },
37
+ "engines": { "node": ">=18" },
38
+ "bin": {
39
+ "squint-cli": "./scripts/squint-cli.mjs"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc",
43
+ "test": "vitest run"
44
+ },
45
+ "dependencies": {
46
+ "rosetta-squint-decode": "file:../../../decode/js/rosetta-squint-decode",
47
+ "rosetta-squint-hash": "file:../../../hash/js/rosetta-squint-hash",
48
+ "@jsquash/jpeg": "^1.6.0",
49
+ "@jsquash/webp": "^1.5.0",
50
+ "libheif-js": "1.17.1",
51
+ "pngjs": "^7",
52
+ "utif2": "^4.1.0"
53
+ },
54
+ "devDependencies": {
55
+ "typescript": "^5.5",
56
+ "vitest": "^2",
57
+ "@types/node": "^20"
58
+ }
59
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ phash, phashSimple, dhash, dhashVertical, averageHash,
4
+ whashHaar, whashDb4, whashDb4Robust, colorhash, cropResistantHash,
5
+ } from "../dist/index.js";
6
+
7
+ const [, , algo, sizeStr, path] = process.argv;
8
+ if (!algo || !sizeStr || !path) {
9
+ process.stderr.write("usage: squint-cli <algo> <size> <path>\n");
10
+ process.exit(2);
11
+ }
12
+ const size = parseInt(sizeStr, 10);
13
+
14
+ const algos = {
15
+ phash, phash_simple: phashSimple, dhash, dhash_vertical: dhashVertical,
16
+ average_hash: averageHash, whash_haar: whashHaar, whash_db4: whashDb4,
17
+ whash_db4_robust: whashDb4Robust, colorhash,
18
+ crop_resistant_hash: cropResistantHash,
19
+ };
20
+
21
+ const fn = algos[algo];
22
+ if (!fn) {
23
+ process.stderr.write(`unknown algo: ${algo}\n`);
24
+ process.exit(2);
25
+ }
26
+
27
+ try {
28
+ const result = algo === "crop_resistant_hash"
29
+ ? await cropResistantHash(path)
30
+ : await fn(path, size);
31
+ process.stdout.write(`${result.toString()}\n`);
32
+ } catch (e) {
33
+ process.stderr.write(`error: ${e.message ?? e}\n`);
34
+ process.exit(1);
35
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Browser entry for rosetta-squint.
3
+ *
4
+ * Same convenience API as the main entry except the path-based functions
5
+ * (which read from disk via `node:fs`) are omitted. Use the `_Bytes`
6
+ * variants in browsers; obtain bytes from `fetch(url).then(r => r.arrayBuffer())`,
7
+ * `FileReader`, drag-and-drop, etc.
8
+ *
9
+ * import { phashBytes } from "rosetta-squint/browser";
10
+ *
11
+ * const resp = await fetch("/photo.jpg");
12
+ * const bytes = new Uint8Array(await resp.arrayBuffer());
13
+ * const hash = await phashBytes(bytes, 8);
14
+ * console.log(hash.toString()); // "c3f8a1b27d0e4f96"
15
+ *
16
+ * Underlying decoders (mozjpeg, libwebp, libheif) run in browser via WASM.
17
+ * TIFF (utif2) and HEIC (libheif-js) are CommonJS — your bundler must
18
+ * support CJS→ESM interop (esbuild, vite, webpack 5+, rollup with
19
+ * @rollup/plugin-commonjs all do).
20
+ */
21
+
22
+ import { decode, type DecodedImage } from "rosetta-squint-decode";
23
+ import * as rih from "rosetta-squint-hash/browser";
24
+
25
+ export type { Hash, RgbImage } from "rosetta-squint-hash/browser";
26
+ export type { Format } from "rosetta-squint-decode";
27
+ export {
28
+ ImageMultiHash,
29
+ hexToHash,
30
+ hexToFlathash,
31
+ hexToMultiHash,
32
+ } from "rosetta-squint-hash/browser";
33
+
34
+ function decodedToRgbImage(d: DecodedImage): rih.RgbImage {
35
+ return {
36
+ width: d.width,
37
+ height: d.height,
38
+ data: d.data,
39
+ channels: d.channels,
40
+ };
41
+ }
42
+
43
+ export async function decodeBytes(bytes: Uint8Array): Promise<rih.RgbImage> {
44
+ const decoded = await decode(bytes);
45
+ return decodedToRgbImage(decoded);
46
+ }
47
+
48
+ // Bytes-based convenience hash functions (no path variants in browser).
49
+
50
+ export async function averageHashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
51
+ return rih.averageHash(await decodeBytes(bytes), hashSize);
52
+ }
53
+ export async function phashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
54
+ return rih.phash(await decodeBytes(bytes), hashSize);
55
+ }
56
+ export async function phashSimpleBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
57
+ return rih.phashSimple(await decodeBytes(bytes), hashSize);
58
+ }
59
+ export async function dhashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
60
+ return rih.dhash(await decodeBytes(bytes), hashSize);
61
+ }
62
+ export async function dhashVerticalBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
63
+ return rih.dhashVertical(await decodeBytes(bytes), hashSize);
64
+ }
65
+ export async function whashHaarBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
66
+ return rih.whashHaar(await decodeBytes(bytes), hashSize);
67
+ }
68
+ export async function whashDb4Bytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
69
+ return rih.whashDb4(await decodeBytes(bytes), hashSize);
70
+ }
71
+ export async function whashDb4RobustBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
72
+ return rih.whashDb4Robust(await decodeBytes(bytes), hashSize);
73
+ }
74
+ export async function colorhashBytes(bytes: Uint8Array, binbits: number): Promise<rih.Hash> {
75
+ return rih.colorhash(await decodeBytes(bytes), binbits);
76
+ }
77
+ export async function cropResistantHashBytes(
78
+ bytes: Uint8Array,
79
+ limitSegments?: number,
80
+ ) {
81
+ return rih.cropResistantHash(await decodeBytes(bytes), limitSegments);
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { open, lstat } from "node:fs/promises";
2
+ import { decode, type DecodedImage } from "rosetta-squint-decode";
3
+ import * as rih from "rosetta-squint-hash";
4
+
5
+ // Re-export key types and utilities from the underlying packages for ergonomics.
6
+ export type { Hash } from "rosetta-squint-hash";
7
+ export type { Format } from "rosetta-squint-decode";
8
+ export { ImageMultiHash, hexToHash, hexToFlathash, hexToMultiHash } from "rosetta-squint-hash";
9
+
10
+ /**
11
+ * Maximum allowed size for path-based decode inputs. Refuse anything larger
12
+ * BEFORE reading bytes. Callers that genuinely need to process images larger
13
+ * than this should decode via rosetta-squint-decode directly after explicit
14
+ * validation.
15
+ */
16
+ export const MAX_FILE_SIZE = 256 * 1024 * 1024; // 256 MiB
17
+
18
+ /** Adapt a rosetta-squint-decode DecodedImage into the rosetta-squint-hash RgbImage shape. */
19
+ function decodedToRgbImage(d: DecodedImage): rih.RgbImage {
20
+ return {
21
+ width: d.width,
22
+ height: d.height,
23
+ data: d.data,
24
+ channels: d.channels,
25
+ };
26
+ }
27
+
28
+ /** Decode raw bytes (any supported format) into the rgb image shape used by the hash lib. */
29
+ export async function decodeBytes(bytes: Uint8Array): Promise<rih.RgbImage> {
30
+ const decoded = await decode(bytes);
31
+ return decodedToRgbImage(decoded);
32
+ }
33
+
34
+ /** Read a file from disk and decode.
35
+ *
36
+ * Refuses symlinks (via `lstat`), non-regular files (FIFOs, /dev/zero,
37
+ * character devices, etc.) and files larger than MAX_FILE_SIZE BEFORE
38
+ * reading bytes — without these guards `readFile("/dev/zero")` would loop
39
+ * until OOM and a 300 MiB sparse file would allocate 300 MiB even though
40
+ * it contains no image. Callers who genuinely want symlink resolution
41
+ * must do it explicitly (e.g. `fs.promises.realpath`) before calling
42
+ * this function.
43
+ *
44
+ * Node's `fs.open` doesn't directly expose `O_NOFOLLOW`, so we lstat the
45
+ * path first and reject symlinks. The window between the lstat and the
46
+ * subsequent open is narrow — the attacker would have to swap the target
47
+ * between those two syscalls, much harder than swapping the symlink
48
+ * destination across an unrelated stat→read window.
49
+ *
50
+ * The file is then opened ONCE via `fs.open`, and `fhandle.stat()` plus
51
+ * `fhandle.read()` operate on the same fd. `fhandle.stat()` ultimately
52
+ * calls fstat(2) on the open descriptor, not stat(2) on the path, which
53
+ * closes the TOCTOU window between the size check and the read. The read
54
+ * is bounded by `MAX_FILE_SIZE + 1` so a concurrent writer that grows the
55
+ * file after the size check is still rejected.
56
+ */
57
+ export async function decodeFile(path: string): Promise<rih.RgbImage> {
58
+ // Lstat-then-open is unfortunately not atomic on Node — there's no
59
+ // public API for O_NOFOLLOW. The race is much narrower than the
60
+ // stat→read race we already close below.
61
+ const linkStat = await lstat(path);
62
+ if (linkStat.isSymbolicLink()) {
63
+ throw new TypeError(`symlink not allowed: ${path}`);
64
+ }
65
+ const fh = await open(path, "r");
66
+ try {
67
+ const st = await fh.stat();
68
+ if (!st.isFile()) {
69
+ throw new Error(`not a regular file: ${path}`);
70
+ }
71
+ if (st.size > MAX_FILE_SIZE) {
72
+ throw new Error(
73
+ `input file too large: ${st.size} bytes (max ${MAX_FILE_SIZE} `
74
+ + `bytes / 256 MiB). For images above this threshold, decode `
75
+ + `via rosetta-squint-decode directly after explicit validation.`,
76
+ );
77
+ }
78
+ // Read up to MAX_FILE_SIZE+1 bytes so a concurrent writer that
79
+ // grows the file post-stat is rejected rather than silently
80
+ // exceeding the cap.
81
+ const cap = Math.min(st.size, MAX_FILE_SIZE) + 1;
82
+ const buf = new Uint8Array(cap);
83
+ let total = 0;
84
+ while (total < cap) {
85
+ const { bytesRead } = await fh.read(
86
+ buf, total, cap - total, total,
87
+ );
88
+ if (bytesRead === 0) break;
89
+ total += bytesRead;
90
+ }
91
+ if (total > MAX_FILE_SIZE) {
92
+ throw new Error(
93
+ `input file too large: ${total} bytes (max ${MAX_FILE_SIZE} `
94
+ + `bytes / 256 MiB). For images above this threshold, decode `
95
+ + `via rosetta-squint-decode directly after explicit validation.`,
96
+ );
97
+ }
98
+ const bytes = buf.subarray(0, total);
99
+ return decodeBytes(bytes);
100
+ } finally {
101
+ await fh.close();
102
+ }
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Convenience hash functions — each accepts either a file path or raw bytes.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export async function averageHash(path: string, hashSize: number): Promise<rih.Hash> {
110
+ return rih.averageHash(await decodeFile(path), hashSize);
111
+ }
112
+ export async function averageHashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
113
+ return rih.averageHash(await decodeBytes(bytes), hashSize);
114
+ }
115
+
116
+ export async function phash(path: string, hashSize: number): Promise<rih.Hash> {
117
+ return rih.phash(await decodeFile(path), hashSize);
118
+ }
119
+ export async function phashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
120
+ return rih.phash(await decodeBytes(bytes), hashSize);
121
+ }
122
+
123
+ export async function phashSimple(path: string, hashSize: number): Promise<rih.Hash> {
124
+ return rih.phashSimple(await decodeFile(path), hashSize);
125
+ }
126
+ export async function phashSimpleBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
127
+ return rih.phashSimple(await decodeBytes(bytes), hashSize);
128
+ }
129
+
130
+ export async function dhash(path: string, hashSize: number): Promise<rih.Hash> {
131
+ return rih.dhash(await decodeFile(path), hashSize);
132
+ }
133
+ export async function dhashBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
134
+ return rih.dhash(await decodeBytes(bytes), hashSize);
135
+ }
136
+
137
+ export async function dhashVertical(path: string, hashSize: number): Promise<rih.Hash> {
138
+ return rih.dhashVertical(await decodeFile(path), hashSize);
139
+ }
140
+ export async function dhashVerticalBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
141
+ return rih.dhashVertical(await decodeBytes(bytes), hashSize);
142
+ }
143
+
144
+ export async function whashHaar(path: string, hashSize: number): Promise<rih.Hash> {
145
+ return rih.whashHaar(await decodeFile(path), hashSize);
146
+ }
147
+ export async function whashHaarBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
148
+ return rih.whashHaar(await decodeBytes(bytes), hashSize);
149
+ }
150
+
151
+ export async function whashDb4(path: string, hashSize: number): Promise<rih.Hash> {
152
+ return rih.whashDb4(await decodeFile(path), hashSize);
153
+ }
154
+ export async function whashDb4Bytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
155
+ return rih.whashDb4(await decodeBytes(bytes), hashSize);
156
+ }
157
+
158
+ export async function whashDb4Robust(path: string, hashSize: number): Promise<rih.Hash> {
159
+ return rih.whashDb4Robust(await decodeFile(path), hashSize);
160
+ }
161
+ export async function whashDb4RobustBytes(bytes: Uint8Array, hashSize: number): Promise<rih.Hash> {
162
+ return rih.whashDb4Robust(await decodeBytes(bytes), hashSize);
163
+ }
164
+
165
+ export async function colorhash(path: string, binbits: number): Promise<rih.Hash> {
166
+ return rih.colorhash(await decodeFile(path), binbits);
167
+ }
168
+ export async function colorhashBytes(bytes: Uint8Array, binbits: number): Promise<rih.Hash> {
169
+ return rih.colorhash(await decodeBytes(bytes), binbits);
170
+ }
171
+
172
+ export async function cropResistantHash(
173
+ path: string,
174
+ limitSegments?: number,
175
+ ): Promise<rih.ImageMultiHash> {
176
+ return rih.cropResistantHash(await decodeFile(path), limitSegments);
177
+ }
178
+ export async function cropResistantHashBytes(
179
+ bytes: Uint8Array,
180
+ limitSegments?: number,
181
+ ): Promise<rih.ImageMultiHash> {
182
+ return rih.cropResistantHash(await decodeBytes(bytes), limitSegments);
183
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Browser-entry smoke test for rosetta-squint.
3
+ *
4
+ * Imports `rosetta-squint/browser` and verifies:
5
+ * 1. All bytes-based convenience functions are exported and callable
6
+ * 2. Path-based functions are NOT exported (no `node:fs`)
7
+ * 3. Hash output matches the main entry for the same byte input
8
+ * (no behavioral drift between entries)
9
+ *
10
+ * vitest runs this in Node, so we can't directly verify browser execution,
11
+ * but the import-tree check + dynamic-import-of-node:fs branch logic in
12
+ * loadWasm.ts means the same dist/browser.js bundle that loads here will
13
+ * load in browsers when fetched via CDN / served by a bundler.
14
+ */
15
+
16
+ import { describe, expect, it } from "vitest";
17
+ import { readFile } from "node:fs/promises";
18
+ import * as browserEntry from "../src/browser.js";
19
+ import * as mainEntry from "../src/index.js";
20
+
21
+ const FIXTURES = new URL("../../../../hash/spec/fixtures/", import.meta.url);
22
+
23
+ describe("rosetta-squint/browser entry", () => {
24
+ it("exposes bytes-based functions for all 10 algorithms", () => {
25
+ expect(typeof browserEntry.averageHashBytes).toBe("function");
26
+ expect(typeof browserEntry.phashBytes).toBe("function");
27
+ expect(typeof browserEntry.phashSimpleBytes).toBe("function");
28
+ expect(typeof browserEntry.dhashBytes).toBe("function");
29
+ expect(typeof browserEntry.dhashVerticalBytes).toBe("function");
30
+ expect(typeof browserEntry.whashHaarBytes).toBe("function");
31
+ expect(typeof browserEntry.whashDb4Bytes).toBe("function");
32
+ expect(typeof browserEntry.whashDb4RobustBytes).toBe("function");
33
+ expect(typeof browserEntry.colorhashBytes).toBe("function");
34
+ expect(typeof browserEntry.cropResistantHashBytes).toBe("function");
35
+ expect(typeof browserEntry.decodeBytes).toBe("function");
36
+ });
37
+
38
+ it("does NOT expose path-based functions (no node:fs)", () => {
39
+ // @ts-expect-error — phash (path) is intentionally not exported from /browser
40
+ expect(browserEntry.phash).toBeUndefined();
41
+ // @ts-expect-error
42
+ expect(browserEntry.decodeFile).toBeUndefined();
43
+ });
44
+
45
+ it("phashBytes on imagehash.png matches main entry's phash", async () => {
46
+ const path = new URL("imagehash.png", FIXTURES);
47
+ const bytes = new Uint8Array(await readFile(path));
48
+ const hBrowser = await browserEntry.phashBytes(bytes, 8);
49
+ const hMain = await mainEntry.phashBytes(bytes, 8);
50
+ expect(hBrowser.toString()).toBe(hMain.toString());
51
+ // And matches the cross-port reference value from Go/Java/JS reports
52
+ expect(hBrowser.toString()).toBe("ba8c84536bd3c366");
53
+ });
54
+
55
+ it("dhashBytes on imagehash.png produces non-empty hex", async () => {
56
+ const path = new URL("imagehash.png", FIXTURES);
57
+ const bytes = new Uint8Array(await readFile(path));
58
+ const h = await browserEntry.dhashBytes(bytes, 8);
59
+ expect(h.toString()).toMatch(/^[0-9a-f]+$/);
60
+ expect(h.toString().length).toBe(16);
61
+ });
62
+
63
+ it("decodeBytes produces an RgbImage shape", async () => {
64
+ const path = new URL("imagehash.png", FIXTURES);
65
+ const bytes = new Uint8Array(await readFile(path));
66
+ const img = await browserEntry.decodeBytes(bytes);
67
+ expect(img.width).toBeGreaterThan(0);
68
+ expect(img.height).toBeGreaterThan(0);
69
+ expect(img.channels === 3 || img.channels === 4).toBe(true);
70
+ expect(img.data.byteLength).toBe(img.width * img.height * img.channels);
71
+ });
72
+ });
@@ -0,0 +1,325 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ phash, phashBytes,
7
+ averageHash, averageHashBytes,
8
+ dhash, dhashBytes,
9
+ dhashVertical, dhashVerticalBytes,
10
+ phashSimple, phashSimpleBytes,
11
+ whashHaar, whashHaarBytes,
12
+ whashDb4, whashDb4Bytes,
13
+ colorhash, colorhashBytes,
14
+ cropResistantHash, cropResistantHashBytes,
15
+ decodeFile,
16
+ hexToHash, hexToFlathash, hexToMultiHash,
17
+ ImageMultiHash,
18
+ } from "../src/index.js";
19
+ import * as rih from "rosetta-squint-hash";
20
+
21
+ const FIXTURES_PNG = join(
22
+ new URL(".", import.meta.url).pathname,
23
+ "../../../../decode/spec/fixtures/png/valid",
24
+ );
25
+ const FIXTURES_JPEG = join(
26
+ new URL(".", import.meta.url).pathname,
27
+ "../../../../decode/spec/fixtures/jpeg/valid",
28
+ );
29
+
30
+ const PNG_FILES = [
31
+ "imagehash.png",
32
+ "peppers.png",
33
+ "checker-256.png",
34
+ ];
35
+ const JPEG_FILES = [
36
+ "larger-photo-128.jpg",
37
+ "64x64-quality-50.jpg",
38
+ ];
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // phash
42
+ // ---------------------------------------------------------------------------
43
+ describe("phash", () => {
44
+ for (const name of PNG_FILES) {
45
+ const path = join(FIXTURES_PNG, name);
46
+
47
+ it(`returns a Hash for ${name}`, async () => {
48
+ const h = await phash(path, 8);
49
+ expect(h).toBeInstanceOf(rih.Hash);
50
+ expect(h.toHex().length).toBe(16); // 8*8 bits = 64 bits = 16 hex chars
51
+ });
52
+
53
+ it(`phash === phashBytes for ${name}`, async () => {
54
+ const [hPath, hBytes] = await Promise.all([
55
+ phash(path, 8),
56
+ readFile(path).then(b => phashBytes(new Uint8Array(b), 8)),
57
+ ]);
58
+ expect(hPath.toHex()).toBe(hBytes.toHex());
59
+ });
60
+
61
+ it(`phash matches rih.phash(decodeFile) for ${name}`, async () => {
62
+ const [hSquint, img] = await Promise.all([
63
+ phash(path, 8),
64
+ decodeFile(path),
65
+ ]);
66
+ const hRih = rih.phash(img, 8);
67
+ expect(hSquint.toHex()).toBe(hRih.toHex());
68
+ });
69
+ }
70
+
71
+ for (const name of JPEG_FILES) {
72
+ const path = join(FIXTURES_JPEG, name);
73
+
74
+ it(`returns a Hash for ${name}`, async () => {
75
+ const h = await phash(path, 8);
76
+ expect(h).toBeInstanceOf(rih.Hash);
77
+ });
78
+
79
+ it(`phash === phashBytes for ${name}`, async () => {
80
+ const [hPath, hBytes] = await Promise.all([
81
+ phash(path, 8),
82
+ readFile(path).then(b => phashBytes(new Uint8Array(b), 8)),
83
+ ]);
84
+ expect(hPath.toHex()).toBe(hBytes.toHex());
85
+ });
86
+ }
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // averageHash
91
+ // ---------------------------------------------------------------------------
92
+ describe("averageHash", () => {
93
+ for (const name of PNG_FILES) {
94
+ const path = join(FIXTURES_PNG, name);
95
+
96
+ it(`returns a Hash for ${name}`, async () => {
97
+ const h = await averageHash(path, 8);
98
+ expect(h).toBeInstanceOf(rih.Hash);
99
+ });
100
+
101
+ it(`averageHash === averageHashBytes for ${name}`, async () => {
102
+ const [hPath, hBytes] = await Promise.all([
103
+ averageHash(path, 8),
104
+ readFile(path).then(b => averageHashBytes(new Uint8Array(b), 8)),
105
+ ]);
106
+ expect(hPath.toHex()).toBe(hBytes.toHex());
107
+ });
108
+
109
+ it(`averageHash matches rih.averageHash(decodeFile) for ${name}`, async () => {
110
+ const [hSquint, img] = await Promise.all([
111
+ averageHash(path, 8),
112
+ decodeFile(path),
113
+ ]);
114
+ const hRih = rih.averageHash(img, 8);
115
+ expect(hSquint.toHex()).toBe(hRih.toHex());
116
+ });
117
+ }
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // dhash
122
+ // ---------------------------------------------------------------------------
123
+ describe("dhash", () => {
124
+ for (const name of ["imagehash.png", "checker-256.png"]) {
125
+ const path = join(FIXTURES_PNG, name);
126
+
127
+ it(`returns a Hash for ${name}`, async () => {
128
+ const h = await dhash(path, 8);
129
+ expect(h).toBeInstanceOf(rih.Hash);
130
+ });
131
+
132
+ it(`dhash === dhashBytes for ${name}`, async () => {
133
+ const [hPath, hBytes] = await Promise.all([
134
+ dhash(path, 8),
135
+ readFile(path).then(b => dhashBytes(new Uint8Array(b), 8)),
136
+ ]);
137
+ expect(hPath.toHex()).toBe(hBytes.toHex());
138
+ });
139
+ }
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // dhashVertical
144
+ // ---------------------------------------------------------------------------
145
+ describe("dhashVertical", () => {
146
+ for (const name of ["imagehash.png", "checker-256.png"]) {
147
+ const path = join(FIXTURES_PNG, name);
148
+
149
+ it(`returns a Hash for ${name}`, async () => {
150
+ const h = await dhashVertical(path, 8);
151
+ expect(h).toBeInstanceOf(rih.Hash);
152
+ });
153
+
154
+ it(`dhashVertical === dhashVerticalBytes for ${name}`, async () => {
155
+ const [hPath, hBytes] = await Promise.all([
156
+ dhashVertical(path, 8),
157
+ readFile(path).then(b => dhashVerticalBytes(new Uint8Array(b), 8)),
158
+ ]);
159
+ expect(hPath.toHex()).toBe(hBytes.toHex());
160
+ });
161
+ }
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // phashSimple
166
+ // ---------------------------------------------------------------------------
167
+ describe("phashSimple", () => {
168
+ for (const name of ["imagehash.png", "peppers.png"]) {
169
+ const path = join(FIXTURES_PNG, name);
170
+
171
+ it(`returns a Hash for ${name}`, async () => {
172
+ const h = await phashSimple(path, 8);
173
+ expect(h).toBeInstanceOf(rih.Hash);
174
+ });
175
+
176
+ it(`phashSimple === phashSimpleBytes for ${name}`, async () => {
177
+ const [hPath, hBytes] = await Promise.all([
178
+ phashSimple(path, 8),
179
+ readFile(path).then(b => phashSimpleBytes(new Uint8Array(b), 8)),
180
+ ]);
181
+ expect(hPath.toHex()).toBe(hBytes.toHex());
182
+ });
183
+ }
184
+ });
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // whashHaar
188
+ // ---------------------------------------------------------------------------
189
+ describe("whashHaar", () => {
190
+ for (const name of ["imagehash.png", "peppers.png"]) {
191
+ const path = join(FIXTURES_PNG, name);
192
+
193
+ it(`returns a Hash for ${name}`, async () => {
194
+ const h = await whashHaar(path, 8);
195
+ expect(h).toBeInstanceOf(rih.Hash);
196
+ });
197
+
198
+ it(`whashHaar === whashHaarBytes for ${name}`, async () => {
199
+ const [hPath, hBytes] = await Promise.all([
200
+ whashHaar(path, 8),
201
+ readFile(path).then(b => whashHaarBytes(new Uint8Array(b), 8)),
202
+ ]);
203
+ expect(hPath.toHex()).toBe(hBytes.toHex());
204
+ });
205
+ }
206
+ });
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // whashDb4
210
+ // ---------------------------------------------------------------------------
211
+ describe("whashDb4", () => {
212
+ for (const name of ["imagehash.png", "peppers.png"]) {
213
+ const path = join(FIXTURES_PNG, name);
214
+
215
+ it(`returns a Hash for ${name}`, async () => {
216
+ const h = await whashDb4(path, 8);
217
+ expect(h).toBeInstanceOf(rih.Hash);
218
+ });
219
+
220
+ it(`whashDb4 === whashDb4Bytes for ${name}`, async () => {
221
+ const [hPath, hBytes] = await Promise.all([
222
+ whashDb4(path, 8),
223
+ readFile(path).then(b => whashDb4Bytes(new Uint8Array(b), 8)),
224
+ ]);
225
+ expect(hPath.toHex()).toBe(hBytes.toHex());
226
+ });
227
+ }
228
+ });
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // colorhash
232
+ // ---------------------------------------------------------------------------
233
+ describe("colorhash", () => {
234
+ for (const name of ["imagehash.png", "peppers.png"]) {
235
+ const path = join(FIXTURES_PNG, name);
236
+
237
+ it(`returns a Hash for ${name}`, async () => {
238
+ const h = await colorhash(path, 3);
239
+ expect(h).toBeInstanceOf(rih.Hash);
240
+ });
241
+
242
+ it(`colorhash === colorhashBytes for ${name}`, async () => {
243
+ const [hPath, hBytes] = await Promise.all([
244
+ colorhash(path, 3),
245
+ readFile(path).then(b => colorhashBytes(new Uint8Array(b), 3)),
246
+ ]);
247
+ expect(hPath.toHex()).toBe(hBytes.toHex());
248
+ });
249
+
250
+ it(`colorhash matches rih.colorhash(decodeFile) for ${name}`, async () => {
251
+ const [hSquint, img] = await Promise.all([
252
+ colorhash(path, 3),
253
+ decodeFile(path),
254
+ ]);
255
+ const hRih = rih.colorhash(img, 3);
256
+ expect(hSquint.toHex()).toBe(hRih.toHex());
257
+ });
258
+ }
259
+ });
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // cropResistantHash
263
+ // ---------------------------------------------------------------------------
264
+ describe("cropResistantHash", () => {
265
+ for (const name of ["imagehash.png", "peppers.png"]) {
266
+ const path = join(FIXTURES_PNG, name);
267
+
268
+ it(`returns an ImageMultiHash for ${name}`, async () => {
269
+ const mh = await cropResistantHash(path);
270
+ expect(mh).toBeInstanceOf(ImageMultiHash);
271
+ expect(mh.segmentHashes.length).toBeGreaterThan(0);
272
+ });
273
+
274
+ it(`cropResistantHash === cropResistantHashBytes for ${name}`, async () => {
275
+ const [mhPath, mhBytes] = await Promise.all([
276
+ cropResistantHash(path),
277
+ readFile(path).then(b => cropResistantHashBytes(new Uint8Array(b))),
278
+ ]);
279
+ expect(mhPath.toString()).toBe(mhBytes.toString());
280
+ });
281
+
282
+ it(`cropResistantHash matches rih.cropResistantHash(decodeFile) for ${name}`, async () => {
283
+ const [mhSquint, img] = await Promise.all([
284
+ cropResistantHash(path),
285
+ decodeFile(path),
286
+ ]);
287
+ const mhRih = rih.cropResistantHash(img);
288
+ expect(mhSquint.toString()).toBe(mhRih.toString());
289
+ });
290
+ }
291
+ });
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Re-export sanity checks
295
+ // ---------------------------------------------------------------------------
296
+ describe("re-exports", () => {
297
+ it("hexToHash round-trips a 16-char hex string", () => {
298
+ const hex = "ffffffffffffffff";
299
+ const h = hexToHash(hex);
300
+ expect(h.toHex()).toBe(hex);
301
+ });
302
+
303
+ it("hexToFlathash round-trips a colorhash string", async () => {
304
+ const path = join(FIXTURES_PNG, "imagehash.png");
305
+ const h = await colorhash(path, 3);
306
+ const hex = h.toHex();
307
+ const h2 = hexToFlathash(hex, 3);
308
+ expect(h2.toHex()).toBe(hex);
309
+ });
310
+
311
+ it("hexToMultiHash round-trips a cropResistantHash string", async () => {
312
+ const path = join(FIXTURES_PNG, "imagehash.png");
313
+ const mh = await cropResistantHash(path);
314
+ const str = mh.toString();
315
+ const mh2 = hexToMultiHash(str);
316
+ expect(mh2.toString()).toBe(str);
317
+ });
318
+
319
+ it("ImageMultiHash is exported and constructible", () => {
320
+ const bits = [[true, false], [false, true]];
321
+ const h = new rih.Hash(bits);
322
+ const mh = new ImageMultiHash([h]);
323
+ expect(mh.segmentHashes.length).toBe(1);
324
+ });
325
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "strict": true,
7
+ "declaration": true,
8
+ "outDir": "dist",
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "isolatedModules": true,
12
+ "resolveJsonModule": true,
13
+ "noUncheckedIndexedAccess": false
14
+ },
15
+ "include": ["src/**/*"]
16
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["tests/**/*.test.ts"],
6
+ },
7
+ });