From 939bb049413202a4df37d1affa3765cd9a41e9e2 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:21:15 +0800 Subject: [PATCH] feat: implement editMetadata operation in image-engine --- packages/image-engine/src/engine.ts | 3 + packages/image-engine/src/index.ts | 1 + .../src/operations/edit-metadata.ts | 91 +++++++++++++++++++ tests/unit/image-engine/operations.test.ts | 87 ++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 packages/image-engine/src/operations/edit-metadata.ts diff --git a/packages/image-engine/src/engine.ts b/packages/image-engine/src/engine.ts index 25d1090e..a2d6e578 100644 --- a/packages/image-engine/src/engine.ts +++ b/packages/image-engine/src/engine.ts @@ -5,6 +5,7 @@ import { compress } from "./operations/compress.js"; import { contrast } from "./operations/contrast.js"; import { convert } from "./operations/convert.js"; import { crop } from "./operations/crop.js"; +import { editMetadata } from "./operations/edit-metadata.js"; import { flip } from "./operations/flip.js"; import { grayscale } from "./operations/grayscale.js"; import { invert } from "./operations/invert.js"; @@ -20,6 +21,7 @@ import type { ContrastOptions, ConvertOptions, CropOptions, + EditMetadataOptions, FlipOptions, OperationResult, OutputFormat, @@ -54,6 +56,7 @@ const OPERATION_MAP: Record< grayscale: (img) => grayscale(img), sepia: (img) => sepia(img), invert: (img) => invert(img), + "edit-metadata": (img, opts) => editMetadata(img, opts as unknown as EditMetadataOptions), }; const FORMAT_MAP: Record = { diff --git a/packages/image-engine/src/index.ts b/packages/image-engine/src/index.ts index 135cba4f..c9d173be 100644 --- a/packages/image-engine/src/index.ts +++ b/packages/image-engine/src/index.ts @@ -6,6 +6,7 @@ export { compress } from "./operations/compress.js"; export { contrast } from "./operations/contrast.js"; export { convert } from "./operations/convert.js"; export { crop } from "./operations/crop.js"; +export { editMetadata } from "./operations/edit-metadata.js"; export { flip } from "./operations/flip.js"; export { grayscale } from "./operations/grayscale.js"; export { invert } from "./operations/invert.js"; diff --git a/packages/image-engine/src/operations/edit-metadata.ts b/packages/image-engine/src/operations/edit-metadata.ts new file mode 100644 index 00000000..c2e9f525 --- /dev/null +++ b/packages/image-engine/src/operations/edit-metadata.ts @@ -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 { + const edits: { IFD0: Record; IFD2: Record } = { + 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 = {}; + const existingIFD2: Record = {}; + + 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> = {}; + 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> = {}; + 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); +} diff --git a/tests/unit/image-engine/operations.test.ts b/tests/unit/image-engine/operations.test.ts index 4743ea69..c3909fb8 100644 --- a/tests/unit/image-engine/operations.test.ts +++ b/tests/unit/image-engine/operations.test.ts @@ -19,6 +19,7 @@ import { contrast, convert, crop, + editMetadata, flip, getImageInfo, grayscale, @@ -1470,3 +1471,89 @@ describe("parseXmp", () => { 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); + }); +});