mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: implement editMetadata operation in image-engine
This commit is contained in:
@@ -5,6 +5,7 @@ import { compress } from "./operations/compress.js";
|
|||||||
import { contrast } from "./operations/contrast.js";
|
import { contrast } from "./operations/contrast.js";
|
||||||
import { convert } from "./operations/convert.js";
|
import { convert } from "./operations/convert.js";
|
||||||
import { crop } from "./operations/crop.js";
|
import { crop } from "./operations/crop.js";
|
||||||
|
import { editMetadata } from "./operations/edit-metadata.js";
|
||||||
import { flip } from "./operations/flip.js";
|
import { flip } from "./operations/flip.js";
|
||||||
import { grayscale } from "./operations/grayscale.js";
|
import { grayscale } from "./operations/grayscale.js";
|
||||||
import { invert } from "./operations/invert.js";
|
import { invert } from "./operations/invert.js";
|
||||||
@@ -20,6 +21,7 @@ import type {
|
|||||||
ContrastOptions,
|
ContrastOptions,
|
||||||
ConvertOptions,
|
ConvertOptions,
|
||||||
CropOptions,
|
CropOptions,
|
||||||
|
EditMetadataOptions,
|
||||||
FlipOptions,
|
FlipOptions,
|
||||||
OperationResult,
|
OperationResult,
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
@@ -54,6 +56,7 @@ const OPERATION_MAP: Record<
|
|||||||
grayscale: (img) => grayscale(img),
|
grayscale: (img) => grayscale(img),
|
||||||
sepia: (img) => sepia(img),
|
sepia: (img) => sepia(img),
|
||||||
invert: (img) => invert(img),
|
invert: (img) => invert(img),
|
||||||
|
"edit-metadata": (img, opts) => editMetadata(img, opts as unknown as EditMetadataOptions),
|
||||||
};
|
};
|
||||||
|
|
||||||
const FORMAT_MAP: Record<string, string> = {
|
const FORMAT_MAP: Record<string, string> = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export { compress } from "./operations/compress.js";
|
|||||||
export { contrast } from "./operations/contrast.js";
|
export { contrast } from "./operations/contrast.js";
|
||||||
export { convert } from "./operations/convert.js";
|
export { convert } from "./operations/convert.js";
|
||||||
export { crop } from "./operations/crop.js";
|
export { crop } from "./operations/crop.js";
|
||||||
|
export { editMetadata } from "./operations/edit-metadata.js";
|
||||||
export { flip } from "./operations/flip.js";
|
export { flip } from "./operations/flip.js";
|
||||||
export { grayscale } from "./operations/grayscale.js";
|
export { grayscale } from "./operations/grayscale.js";
|
||||||
export { invert } from "./operations/invert.js";
|
export { invert } from "./operations/invert.js";
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import exifReader from "exif-reader";
|
||||||
|
import type { EditMetadataOptions, Sharp } from "../types.js";
|
||||||
|
import { sanitizeValue } from "../utils/metadata.js";
|
||||||
|
|
||||||
|
const COMMON_FIELD_MAP: Array<{
|
||||||
|
option: keyof EditMetadataOptions;
|
||||||
|
ifd: "IFD0" | "IFD2";
|
||||||
|
tag: string;
|
||||||
|
}> = [
|
||||||
|
{ option: "artist", ifd: "IFD0", tag: "Artist" },
|
||||||
|
{ option: "copyright", ifd: "IFD0", tag: "Copyright" },
|
||||||
|
{ option: "imageDescription", ifd: "IFD0", tag: "ImageDescription" },
|
||||||
|
{ option: "software", ifd: "IFD0", tag: "Software" },
|
||||||
|
{ option: "dateTime", ifd: "IFD0", tag: "DateTime" },
|
||||||
|
{ option: "dateTimeOriginal", ifd: "IFD2", tag: "DateTimeOriginal" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function editMetadata(
|
||||||
|
image: Sharp,
|
||||||
|
options: EditMetadataOptions = {},
|
||||||
|
): Promise<Sharp> {
|
||||||
|
const edits: { IFD0: Record<string, string>; IFD2: Record<string, string> } = {
|
||||||
|
IFD0: {},
|
||||||
|
IFD2: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const { option, ifd, tag } of COMMON_FIELD_MAP) {
|
||||||
|
const value = options[option];
|
||||||
|
if (typeof value === "string" && value.length > 0) {
|
||||||
|
edits[ifd][tag] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writtenTags = new Set([...Object.keys(edits.IFD0), ...Object.keys(edits.IFD2)]);
|
||||||
|
const fieldsToRemove = (options.fieldsToRemove ?? []).filter((f) => !writtenTags.has(f));
|
||||||
|
|
||||||
|
const hasEdits = Object.keys(edits.IFD0).length > 0 || Object.keys(edits.IFD2).length > 0;
|
||||||
|
const hasRemovals = fieldsToRemove.length > 0 || options.clearGps === true;
|
||||||
|
|
||||||
|
if (!hasEdits && !hasRemovals) {
|
||||||
|
return image.keepMetadata();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRemovals) {
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
|
||||||
|
const existingIFD0: Record<string, string> = {};
|
||||||
|
const existingIFD2: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (metadata.exif) {
|
||||||
|
try {
|
||||||
|
const parsed = exifReader(metadata.exif);
|
||||||
|
if (parsed.Image) {
|
||||||
|
for (const [k, v] of Object.entries(parsed.Image)) {
|
||||||
|
if (fieldsToRemove.includes(k)) continue;
|
||||||
|
const sv = sanitizeValue(v);
|
||||||
|
if (typeof sv === "string" || typeof sv === "number") {
|
||||||
|
existingIFD0[k] = String(sv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.Photo) {
|
||||||
|
for (const [k, v] of Object.entries(parsed.Photo)) {
|
||||||
|
if (fieldsToRemove.includes(k)) continue;
|
||||||
|
const sv = sanitizeValue(v);
|
||||||
|
if (typeof sv === "string" || typeof sv === "number") {
|
||||||
|
existingIFD2[k] = String(sv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If parsing fails, proceed with just the edits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalIFD0 = { ...existingIFD0, ...edits.IFD0 };
|
||||||
|
const finalIFD2 = { ...existingIFD2, ...edits.IFD2 };
|
||||||
|
|
||||||
|
const exif: Record<string, Record<string, string>> = {};
|
||||||
|
if (Object.keys(finalIFD0).length > 0) exif.IFD0 = finalIFD0;
|
||||||
|
if (Object.keys(finalIFD2).length > 0) exif.IFD2 = finalIFD2;
|
||||||
|
|
||||||
|
return image.withExif(exif);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exif: Record<string, Record<string, string>> = {};
|
||||||
|
if (Object.keys(edits.IFD0).length > 0) exif.IFD0 = edits.IFD0;
|
||||||
|
if (Object.keys(edits.IFD2).length > 0) exif.IFD2 = edits.IFD2;
|
||||||
|
|
||||||
|
return image.withExifMerge(exif);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
contrast,
|
contrast,
|
||||||
convert,
|
convert,
|
||||||
crop,
|
crop,
|
||||||
|
editMetadata,
|
||||||
flip,
|
flip,
|
||||||
getImageInfo,
|
getImageInfo,
|
||||||
grayscale,
|
grayscale,
|
||||||
@@ -1470,3 +1471,89 @@ describe("parseXmp", () => {
|
|||||||
expect(result).toEqual({});
|
expect(result).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// editMetadata
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("editMetadata", () => {
|
||||||
|
it("writes common fields readable via exif-reader", async () => {
|
||||||
|
const image = sharp(jpgWithExif);
|
||||||
|
const result = await editMetadata(image, {
|
||||||
|
artist: "New Artist",
|
||||||
|
copyright: "New Copyright",
|
||||||
|
});
|
||||||
|
const buf = await result.jpeg().toBuffer();
|
||||||
|
const meta = await sharp(buf).metadata();
|
||||||
|
expect(meta.exif).toBeTruthy();
|
||||||
|
const parsed = exifReader(meta.exif!);
|
||||||
|
expect(parsed.Image?.Artist).toBe("New Artist");
|
||||||
|
expect(parsed.Image?.Copyright).toBe("New Copyright");
|
||||||
|
// Original fields should be preserved via withExifMerge
|
||||||
|
expect(parsed.Image?.Software).toBe("Stirling-Image Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears GPS while preserving other EXIF", async () => {
|
||||||
|
// First write GPS to the image
|
||||||
|
const withGps = sharp(jpgWithExif).withExif({
|
||||||
|
IFD0: { Artist: "GPS Test" },
|
||||||
|
IFD3: { GPSLatitudeRef: "N" },
|
||||||
|
});
|
||||||
|
const gpsBuf = await withGps.jpeg().toBuffer();
|
||||||
|
|
||||||
|
const image = sharp(gpsBuf);
|
||||||
|
const result = await editMetadata(image, { clearGps: true });
|
||||||
|
const buf = await result.jpeg().toBuffer();
|
||||||
|
const meta = await sharp(buf).metadata();
|
||||||
|
const parsed = exifReader(meta.exif!);
|
||||||
|
// GPS should be gone
|
||||||
|
expect(parsed.GPSInfo).toBeUndefined();
|
||||||
|
// Other EXIF should still be present
|
||||||
|
expect(parsed.Image?.Artist).toBe("GPS Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes specific fields via fieldsToRemove", async () => {
|
||||||
|
const image = sharp(jpgWithExif);
|
||||||
|
const result = await editMetadata(image, {
|
||||||
|
fieldsToRemove: ["Software"],
|
||||||
|
});
|
||||||
|
const buf = await result.jpeg().toBuffer();
|
||||||
|
const meta = await sharp(buf).metadata();
|
||||||
|
const parsed = exifReader(meta.exif!);
|
||||||
|
expect(parsed.Image?.Software).toBeUndefined();
|
||||||
|
// Other fields preserved
|
||||||
|
expect(parsed.Image?.Artist).toBe("Test Artist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves metadata with no options", async () => {
|
||||||
|
const image = sharp(jpgWithExif);
|
||||||
|
const result = await editMetadata(image, {});
|
||||||
|
const buf = await result.jpeg().toBuffer();
|
||||||
|
const meta = await sharp(buf).metadata();
|
||||||
|
expect(meta.exif).toBeTruthy();
|
||||||
|
const parsed = exifReader(meta.exif!);
|
||||||
|
expect(parsed.Image?.Artist).toBe("Test Artist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edit wins over remove for same field", async () => {
|
||||||
|
const image = sharp(jpgWithExif);
|
||||||
|
const result = await editMetadata(image, {
|
||||||
|
artist: "Override Artist",
|
||||||
|
fieldsToRemove: ["Artist"],
|
||||||
|
});
|
||||||
|
const buf = await result.jpeg().toBuffer();
|
||||||
|
const meta = await sharp(buf).metadata();
|
||||||
|
const parsed = exifReader(meta.exif!);
|
||||||
|
expect(parsed.Image?.Artist).toBe("Override Artist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes fresh EXIF to image without existing metadata", async () => {
|
||||||
|
const image = sharp(png1x1);
|
||||||
|
const result = await editMetadata(image, {
|
||||||
|
artist: "Fresh Artist",
|
||||||
|
copyright: "Fresh Copyright",
|
||||||
|
});
|
||||||
|
const buf = await result.png().toBuffer();
|
||||||
|
// The operation should not throw
|
||||||
|
expect(buf.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user