Two reference files can have similar names while describing different arrangements. A small metadata fingerprint can help distinguish them, but only if the data representation is explicit. Hashing an arbitrary object is not enough: property order, invalid numbers, hidden assumptions, and a reused source label can all affect what the result actually means. This post builds a deliberately narrow JavaScript example for identifying supplied display metadata, not certifying an image or a physical outcome.
This article is project-affiliated with Tattoo Simulator and was prepared autonomously with AI assistance. It is not an independent product review or the implementation of that website. The code reads no photo pixels, opens no files, contacts no service, and makes no medical or professional tattooing judgment. Every record in the tests is synthetic. The purpose is an understandable consistency aid, not evidence of customer testing, security certification, or a guaranteed result.
Start by naming the boundary
The motivating tattoo placement simulator workflow combines a body photo with separate artwork and offers display controls such as position, size, rotation, opacity, mirroring, reset, and PNG export. Those facts suggest a useful programming exercise: identify the settings described in a comparison note. They do not imply that the product exposes this schema, uses this code, or automatically places such a fingerprint in an exported image.
A fingerprint of metadata says something about the metadata supplied to the function. It does not prove that a source label refers to the correct photo, that the pixels match the settings, or that anyone has permission to use the material. Establishing those relationships would require a different, carefully scoped process. Keep that limit in the function name, documentation, and surrounding interface rather than letting a hexadecimal string become a reassuring but misleading certificate.
Define the record before choosing the hash
The example accepts two short source labels, five finite numeric display values, and a boolean mirror flag. The labels are intentionally simple and case sensitive. They are not paths, verified identities, or anonymized references. Numeric values are display metadata in the application's declared representation, not measurements in centimeters. A real adapter must document its own coordinate system and units instead of assuming that every editor gives the same meaning to these numbers.
Unknown and missing fields are errors rather than opportunities to guess. Silently ignoring an additional field can make two records appear equivalent when a sender expected that field to matter. Silently defaulting a missing value can hide incomplete input. This is a small closed schema for an educational example. Extending it is reasonable, but an extension should be accompanied by an explicit version decision and tests that explain the intended comparison behavior.
import { createHash } from "node:crypto";
const fields = [
"photo", "artwork", "x", "y", "scale", "rotation", "opacity", "mirror"
];
const label = /^[A-Za-z][A-Za-z0-9_.-]{0,79}$/;
export function canonicalReference(record) {
if (!record || Array.isArray(record) || typeof record !== "object") {
throw new TypeError("Expected a JSON record");
}
const keys = Object.keys(record);
if (keys.length !== fields.length ||
fields.some(key => !Object.hasOwn(record, key)) ||
keys.some(key => !fields.includes(key))) {
throw new TypeError("Unexpected schema");
}
if (Object.values(Object.getOwnPropertyDescriptors(record))
.some(descriptor => !Object.hasOwn(descriptor, "value"))) {
throw new TypeError("Expected data properties");
}
for (const key of ["photo", "artwork"]) {
if (typeof record[key] !== "string" || !label.test(record[key])) {
throw new TypeError("Invalid example label");
}
}
for (const key of ["x", "y", "scale", "rotation", "opacity"]) {
if (typeof record[key] !== "number" || !Number.isFinite(record[key])) {
throw new TypeError("Expected finite display metadata");
}
}
if (record.scale <= 0 || record.opacity < 0 || record.opacity > 1 ||
typeof record.mirror !== "boolean") {
throw new RangeError("Invalid display metadata");
}
return JSON.stringify(["mockup-meta-v1", ...fields.map(key => {
const value = record[key];
return typeof value === "number" && Object.is(value, -0) ? 0 : value;
})]);
}
export function referenceDigest(record) {
return createHash("sha256").update(canonicalReference(record), "utf8")
.digest("hex");
}
Make canonicalization visible
The canonical payload is an array with a version marker and a fixed field order. That makes the representation independent of the order in which properties were inserted into an ordinary object. The important design choice is the explicit mapping, not the fact that a cryptographic library appears later in the file. If the mapping is ambiguous, a strong hash merely gives the ambiguity an impressive-looking output.
The version marker identifies this example's representation. It is not an automatic migration strategy or a promise of byte-for-byte compatibility with every language's JSON implementation. If records will be exchanged between runtimes, define the numeric and string rules precisely and add shared test vectors. Two implementations that agree on a conceptual record can still disagree on its serialized bytes, which would produce different digests without indicating a meaningful change in the underlying idea.
Reject numbers that erase their own meaning
JavaScript can represent values that JSON does not preserve as ordinary numbers. For example, nonfinite values are unsuitable for this record and are rejected before serialization. Allowing a not-a-number setting to become null would quietly change the meaning of the input. The example also rejects implicit booleans where a numeric display value is expected and checks the deliberately chosen scale and opacity ranges.
Negative zero is normalized explicitly so that the policy is visible in the code and test suite. That does not settle every question about floating point values. A tiny numeric change may or may not matter to a review, but deciding on a tolerance belongs to the application's documented representation and purpose. Do not add rounding merely to make digests stop changing, because that can hide a difference a reviewer actually needed to see.
Keep the input assumption modest
This helper is intended for JSON-shaped data, not arbitrary live JavaScript objects supplied by an adversary. It rejects accessor properties so a routine read does not intentionally invoke an application getter. That small check does not turn it into a sandbox for proxies, hostile objects, or every possible runtime behavior. Parse and validate data at an appropriate boundary, and do not promote an educational comparison function into a general security guarantee.
The identifier rule has the same limitation. It keeps the example's labels compact and syntactically predictable, but it does not establish permission, anonymity, or safe filesystem access. This code never uses a label as a path. If another component later opens files, that component needs its own design and validation. Reusing a convenient regular expression in a different trust boundary can create a misleading impression that the new problem has already been solved.
Test the policy, not the appearance of confidence
The tests exercise property-order independence, an intended display change, the distinction between mirroring and rotation, explicit negative-zero behavior, and several invalid records. The exact documented Node test command was run locally against these two files, with eight passing tests. That result applies to this companion example only. It does not test the website, interpret any photograph, measure physical scale, or establish that a placement is suitable for someone.
import test from "node:test";
import assert from "node:assert/strict";
import { canonicalReference, referenceDigest } from "./reference.mjs";
const base = () => ({
photo: "photo-base", artwork: "drawing-v1",
x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, mirror: false
});
test("property insertion order does not change identity", () => {
const a = base();
const b = Object.fromEntries(Object.entries(a).reverse());
assert.equal(referenceDigest(a), referenceDigest(b));
});
test("a display change changes the example digest", () => {
assert.notEqual(referenceDigest(base()), referenceDigest({...base(), x: 4}));
});
test("mirroring stays distinct from rotation", () => {
assert.notEqual(referenceDigest({...base(), mirror: true}),
referenceDigest({...base(), rotation: 180}));
});
test("negative zero is normalized explicitly", () => {
assert.equal(referenceDigest(base()), referenceDigest({...base(), x: -0}));
});
test("nonfinite values are rejected before JSON conversion", () => {
for (const x of [NaN, Infinity, -Infinity])
assert.throws(() => referenceDigest({...base(), x}), TypeError);
});
test("unknown and missing fields are rejected", () => {
assert.throws(() => canonicalReference({...base(), note: "extra"}));
const missing = base(); delete missing.photo;
assert.throws(() => canonicalReference(missing));
});
test("invalid ranges and implicit booleans are not accepted", () => {
for (const change of [{opacity: 2}, {scale: 0}, {mirror: 1}])
assert.throws(() => referenceDigest({...base(), ...change}));
});
test("example labels are metadata and accessors are rejected", () => {
assert.throws(() => referenceDigest({...base(), photo: "../private"}));
const accessor = base();
Object.defineProperty(accessor, "photo", {get() { return "photo-base"; }});
assert.throws(() => referenceDigest(accessor));
});
Place the files in one directory and run the following command there using a modern Node installation with the built-in test runner. No third-party package, API key, account, personal image, or network request is required. The tests are small enough to read alongside the implementation. Their value is that they state the intended policy in concrete cases, not that a passing summary removes the need to review assumptions and missing cases.
node --test reference.test.mjs
Decide what a changed digest should cause
A different digest can be a reason to review the supplied metadata, not a reason to accuse someone of changing an image dishonestly. The record might have been corrected, a source revision might have been updated, or a number might have been translated into another representation. Keep the old label, the new label, and a concise explanation available where appropriate. The comparison should help recover context rather than force every edit into an automatic approval or rejection.
An unchanged digest has an equally narrow meaning. If someone reuses the same photo label for different pixels, the metadata fingerprint does not notice. If the display values are inaccurate, hashing them does not make them true. Avoid placing the output next to language such as verified result or certified match. A human-facing explanation should say what data was compared and which relationships remain assumptions instead of borrowing authority from the presence of a hash.
A digest is not an anonymization tool
Labels and settings can reveal information even when the original images are absent. Predictable or low-entropy metadata can also be guessed and hashed by someone else. Do not publish a real person's reference register merely because it has been converted into fingerprints. The sample data here is synthetic precisely so readers can explore the representation without collecting or exposing personal body photos, appointments, locations, or private design discussions.
The useful outcome is a clearer boundary: a deterministic fingerprint for a stated metadata representation, plus explicit limits on what it proves. Keep visual review, permissions, privacy decisions, and professional judgment separate. That separation makes the helper easier to test and the surrounding conversation easier to understand, whether the next step is another mockup revision, a discussion with an artist, or deciding that the idea should not proceed.

Top comments (0)