mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: extract shared metadata parsing utilities into image-engine
Move sanitizeValue, parseExif, parseGps, parseXmp into the shared image-engine package so both strip-metadata and edit-metadata can reuse them. Includes 13 unit tests covering all four functions.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import exifReader from "exif-reader";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import type { ImageInfo } from "../types.js";
|
import type { ImageInfo } from "../types.js";
|
||||||
|
|
||||||
@@ -26,3 +27,121 @@ export async function getImageInfo(buffer: Buffer): Promise<ImageInfo> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a value for JSON - convert Buffers/Dates and drop overly large blobs.
|
||||||
|
*/
|
||||||
|
export function sanitizeValue(v: unknown): unknown {
|
||||||
|
if (v instanceof Date) return v.toISOString();
|
||||||
|
if (Buffer.isBuffer(v)) {
|
||||||
|
if (v.length > 256) return `<binary ${v.length} bytes>`;
|
||||||
|
return Array.from(v);
|
||||||
|
}
|
||||||
|
if (Array.isArray(v)) return v.map(sanitizeValue);
|
||||||
|
if (v !== null && typeof v === "object") {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, val] of Object.entries(v)) {
|
||||||
|
out[k] = sanitizeValue(val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an EXIF buffer into sanitized sections.
|
||||||
|
*/
|
||||||
|
export function parseExif(exifBuffer: Buffer): {
|
||||||
|
image: Record<string, unknown>;
|
||||||
|
photo: Record<string, unknown>;
|
||||||
|
iop: Record<string, unknown>;
|
||||||
|
gps: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
const result = {
|
||||||
|
image: {} as Record<string, unknown>,
|
||||||
|
photo: {} as Record<string, unknown>,
|
||||||
|
iop: {} as Record<string, unknown>,
|
||||||
|
gps: {} as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!exifBuffer || exifBuffer.length === 0) return result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = exifReader(exifBuffer);
|
||||||
|
|
||||||
|
if (parsed.Image) {
|
||||||
|
for (const [k, v] of Object.entries(parsed.Image)) {
|
||||||
|
result.image[k] = sanitizeValue(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.Photo) {
|
||||||
|
for (const [k, v] of Object.entries(parsed.Photo)) {
|
||||||
|
result.photo[k] = sanitizeValue(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.Iop) {
|
||||||
|
for (const [k, v] of Object.entries(parsed.Iop)) {
|
||||||
|
result.iop[k] = sanitizeValue(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.GPSInfo) {
|
||||||
|
for (const [k, v] of Object.entries(parsed.GPSInfo)) {
|
||||||
|
result.gps[k] = sanitizeValue(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Return empty sections on parse failure
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse GPS coordinates from EXIF GPSInfo into decimal degrees.
|
||||||
|
*/
|
||||||
|
export function parseGps(gps: Record<string, unknown>): {
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
altitude: number | null;
|
||||||
|
} {
|
||||||
|
let latitude: number | null = null;
|
||||||
|
let longitude: number | null = null;
|
||||||
|
let altitude: number | null = null;
|
||||||
|
|
||||||
|
const lat = gps.GPSLatitude as number[] | undefined;
|
||||||
|
const latRef = gps.GPSLatitudeRef as string | undefined;
|
||||||
|
if (lat && lat.length === 3 && lat.every((v) => typeof v === "number" && !Number.isNaN(v))) {
|
||||||
|
latitude = lat[0] + lat[1] / 60 + lat[2] / 3600;
|
||||||
|
if (latRef === "S") latitude = -latitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lon = gps.GPSLongitude as number[] | undefined;
|
||||||
|
const lonRef = gps.GPSLongitudeRef as string | undefined;
|
||||||
|
if (lon && lon.length === 3 && lon.every((v) => typeof v === "number" && !Number.isNaN(v))) {
|
||||||
|
longitude = lon[0] + lon[1] / 60 + lon[2] / 3600;
|
||||||
|
if (lonRef === "W") longitude = -longitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof gps.GPSAltitude === "number" && !Number.isNaN(gps.GPSAltitude)) {
|
||||||
|
altitude = gps.GPSAltitude;
|
||||||
|
if (gps.GPSAltitudeRef === 1) altitude = -altitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { latitude, longitude, altitude };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse XMP XML buffer into key-value pairs.
|
||||||
|
*/
|
||||||
|
export function parseXmp(xmpBuffer: Buffer): Record<string, string> {
|
||||||
|
const xml = xmpBuffer.toString("utf-8");
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const match of xml.matchAll(/(\w+:\w+)="([^"]+)"/g)) {
|
||||||
|
const key = match[1];
|
||||||
|
if (key.startsWith("xmlns:") || key.startsWith("rdf:")) continue;
|
||||||
|
result[key] = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const require = createRequire(
|
|||||||
path.resolve(__dirname, "../../../packages/image-engine/src/index.ts"),
|
path.resolve(__dirname, "../../../packages/image-engine/src/index.ts"),
|
||||||
);
|
);
|
||||||
const sharp = require("sharp") as typeof import("sharp").default;
|
const sharp = require("sharp") as typeof import("sharp").default;
|
||||||
|
const exifReader = require(
|
||||||
|
path.resolve(__dirname, "../../../packages/image-engine/node_modules/exif-reader"),
|
||||||
|
) as typeof import("exif-reader").default;
|
||||||
|
|
||||||
import {
|
import {
|
||||||
brightness,
|
brightness,
|
||||||
@@ -17,11 +20,16 @@ import {
|
|||||||
convert,
|
convert,
|
||||||
crop,
|
crop,
|
||||||
flip,
|
flip,
|
||||||
|
getImageInfo,
|
||||||
grayscale,
|
grayscale,
|
||||||
invert,
|
invert,
|
||||||
|
parseExif,
|
||||||
|
parseGps,
|
||||||
|
parseXmp,
|
||||||
processImage,
|
processImage,
|
||||||
resize,
|
resize,
|
||||||
rotate,
|
rotate,
|
||||||
|
sanitizeValue,
|
||||||
saturation,
|
saturation,
|
||||||
sepia,
|
sepia,
|
||||||
stripMetadata,
|
stripMetadata,
|
||||||
@@ -44,12 +52,14 @@ let png200x150: Buffer;
|
|||||||
let png1x1: Buffer;
|
let png1x1: Buffer;
|
||||||
let jpg100x100: Buffer;
|
let jpg100x100: Buffer;
|
||||||
let webp50x50: Buffer;
|
let webp50x50: Buffer;
|
||||||
|
let jpgWithExif: Buffer;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
png200x150 = readFileSync(path.join(FIXTURES_DIR, "test-200x150.png"));
|
png200x150 = readFileSync(path.join(FIXTURES_DIR, "test-200x150.png"));
|
||||||
png1x1 = readFileSync(path.join(FIXTURES_DIR, "test-1x1.png"));
|
png1x1 = readFileSync(path.join(FIXTURES_DIR, "test-1x1.png"));
|
||||||
jpg100x100 = readFileSync(path.join(FIXTURES_DIR, "test-100x100.jpg"));
|
jpg100x100 = readFileSync(path.join(FIXTURES_DIR, "test-100x100.jpg"));
|
||||||
webp50x50 = readFileSync(path.join(FIXTURES_DIR, "test-50x50.webp"));
|
webp50x50 = readFileSync(path.join(FIXTURES_DIR, "test-50x50.webp"));
|
||||||
|
jpgWithExif = readFileSync(path.join(FIXTURES_DIR, "test-with-exif.jpg"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1343,3 +1353,120 @@ describe("processImage", () => {
|
|||||||
expect(typeof result.info.hasAlpha).toBe("boolean");
|
expect(typeof result.info.hasAlpha).toBe("boolean");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared metadata parsing utilities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("sanitizeValue", () => {
|
||||||
|
it("converts Date to ISO string", () => {
|
||||||
|
const d = new Date("2026-01-15T10:30:00Z");
|
||||||
|
expect(sanitizeValue(d)).toBe("2026-01-15T10:30:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts small Buffer to number array", () => {
|
||||||
|
const buf = Buffer.from([1, 2, 3]);
|
||||||
|
expect(sanitizeValue(buf)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts large Buffer to placeholder string", () => {
|
||||||
|
const buf = Buffer.alloc(300, 0);
|
||||||
|
expect(sanitizeValue(buf)).toBe("<binary 300 bytes>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recursively sanitizes objects", () => {
|
||||||
|
const d = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const result = sanitizeValue({ nested: { date: d } });
|
||||||
|
expect(result).toEqual({ nested: { date: "2026-01-01T00:00:00.000Z" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through primitives unchanged", () => {
|
||||||
|
expect(sanitizeValue("hello")).toBe("hello");
|
||||||
|
expect(sanitizeValue(42)).toBe(42);
|
||||||
|
expect(sanitizeValue(null)).toBe(null);
|
||||||
|
expect(sanitizeValue(true)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseExif", () => {
|
||||||
|
it("parses EXIF buffer from test fixture", async () => {
|
||||||
|
const metadata = await sharp(jpgWithExif).metadata();
|
||||||
|
expect(metadata.exif).toBeTruthy();
|
||||||
|
const result = parseExif(metadata.exif!);
|
||||||
|
expect(result.image.Artist).toBe("Test Artist");
|
||||||
|
expect(result.image.Copyright).toBe("2026 Test Copyright");
|
||||||
|
expect(result.image.Software).toBe("Stirling-Image Test");
|
||||||
|
expect(result.image.ImageDescription).toBe("Test Description");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty sections for empty buffer", () => {
|
||||||
|
const result = parseExif(Buffer.from([]));
|
||||||
|
expect(result.image).toEqual({});
|
||||||
|
expect(result.gps).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseGps", () => {
|
||||||
|
it("parses DMS coordinates to decimal degrees", () => {
|
||||||
|
const result = parseGps({
|
||||||
|
GPSLatitude: [51, 30, 26.4],
|
||||||
|
GPSLatitudeRef: "N",
|
||||||
|
GPSLongitude: [0, 7, 39.6],
|
||||||
|
GPSLongitudeRef: "W",
|
||||||
|
GPSAltitude: 10,
|
||||||
|
GPSAltitudeRef: 0,
|
||||||
|
});
|
||||||
|
expect(result.latitude).toBeCloseTo(51.5073, 3);
|
||||||
|
expect(result.longitude).toBeCloseTo(-0.1277, 3);
|
||||||
|
expect(result.altitude).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nulls for empty GPS data", () => {
|
||||||
|
const result = parseGps({});
|
||||||
|
expect(result.latitude).toBeNull();
|
||||||
|
expect(result.longitude).toBeNull();
|
||||||
|
expect(result.altitude).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles southern hemisphere", () => {
|
||||||
|
const result = parseGps({
|
||||||
|
GPSLatitude: [33, 51, 54],
|
||||||
|
GPSLatitudeRef: "S",
|
||||||
|
GPSLongitude: [151, 12, 36],
|
||||||
|
GPSLongitudeRef: "E",
|
||||||
|
});
|
||||||
|
expect(result.latitude).toBeCloseTo(-33.865, 2);
|
||||||
|
expect(result.longitude).toBeCloseTo(151.21, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseXmp", () => {
|
||||||
|
it("extracts key-value pairs from XMP XML", () => {
|
||||||
|
const xml = Buffer.from(
|
||||||
|
'<x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
|
||||||
|
'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
|
||||||
|
'<rdf:Description dc:creator="Alice" dc:title="My Photo" />' +
|
||||||
|
"</rdf:RDF></x:xmpmeta>",
|
||||||
|
);
|
||||||
|
const result = parseXmp(xml);
|
||||||
|
expect(result["dc:creator"]).toBe("Alice");
|
||||||
|
expect(result["dc:title"]).toBe("My Photo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips xmlns and rdf namespace prefixes", () => {
|
||||||
|
const xml = Buffer.from(
|
||||||
|
'<x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
|
||||||
|
'<rdf:Description rdf:about="" dc:format="image/jpeg" />' +
|
||||||
|
"</x:xmpmeta>",
|
||||||
|
);
|
||||||
|
const result = parseXmp(xml);
|
||||||
|
expect(result["xmlns:x"]).toBeUndefined();
|
||||||
|
expect(result["xmlns:dc"]).toBeUndefined();
|
||||||
|
expect(result["rdf:about"]).toBeUndefined();
|
||||||
|
expect(result["dc:format"]).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty object for empty buffer", () => {
|
||||||
|
const result = parseXmp(Buffer.from(""));
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user