Project Files
src / exifEdit.ts
import * as fs from "node:fs";
import piexif from "piexifjs";
const WRITABLE_EXIF_SUFFIXES = new Set([".jpg", ".jpeg", ".webp"]);
const ALLOWED_IFDS = new Set(["0th", "Exif", "GPS", "Interop", "1st"]);
function emptyExifTemplate(): Record<string, unknown> {
return { "0th": {}, Exif: {}, GPS: {}, Interop: {}, "1st": {}, thumbnail: null };
}
function loadExifDict(filePath: string): Record<string, unknown> {
const suffix = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
try {
const binary = fs.readFileSync(filePath).toString("binary");
return piexif.load(binary) as Record<string, unknown>;
} catch (e) {
if (suffix === ".webp") return emptyExifTemplate();
throw new Error(`Could not read EXIF from ${filePath}: ${String(e)}`);
}
}
function tagIdForName(ifd: string, tagName: string): number {
if (!ALLOWED_IFDS.has(ifd)) {
throw new Error(`IFD '${ifd}' is not editable here; use one of: ${[...ALLOWED_IFDS].sort().join(", ")}`);
}
const table = (piexif as { Tags?: Record<string, Record<number, { name: string }>> }).Tags?.[ifd];
if (!table) throw new Error(`Unknown IFD '${ifd}'`);
if (/^\d+$/.test(tagName)) return Number(tagName);
for (const [tagIdStr, meta] of Object.entries(table)) {
const tagId = Number(tagIdStr);
if (meta?.name === tagName) return tagId;
}
throw new Error(`Unknown tag '${tagName}' in IFD '${ifd}'`);
}
function normalizeTagValue(ifd: string, tagId: number, value: unknown): unknown {
if (value === null || value === undefined) {
throw new Error("Tag values must not be null; omit the key or use remove_tags");
}
const ExifIFD = (piexif as { ExifIFD?: Record<string, number> }).ExifIFD;
const UserCommentTag = ExifIFD?.UserComment;
if (ifd === "Exif" && UserCommentTag !== undefined && tagId === UserCommentTag && typeof value === "string") {
const helper = (piexif as { helper?: { UserComment?: { dump: (s: string, enc: string) => unknown } } }).helper;
if (helper?.UserComment?.dump) return helper.UserComment.dump(value, "unicode");
}
if (typeof value === "string") return Buffer.from(value, "utf8");
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return Buffer.from(value as Uint8Array);
if (Array.isArray(value) && value.length === 2) {
const a = value[0];
const b = value[1];
if (typeof a === "number" && typeof b === "number") return [Math.trunc(a), Math.trunc(b)];
}
return value;
}
export function updateImageExif(
filePath: string,
setTags: Record<string, Record<string, unknown>>,
removeTags: Record<string, string[]>,
): Record<string, unknown> {
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
throw new Error(`Not a file: ${filePath}`);
}
const suffix = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
if (!WRITABLE_EXIF_SUFFIXES.has(suffix)) {
throw new Error(`EXIF write is only supported for ${[...WRITABLE_EXIF_SUFFIXES].sort().join(", ")}; got '${suffix}'`);
}
if ((!setTags || Object.keys(setTags).length === 0) && (!removeTags || Object.keys(removeTags).length === 0)) {
return {
path: fs.realpathSync(filePath),
suffix,
tags_updated: [] as string[],
tags_removed: [] as string[],
unchanged: true,
};
}
const exif = loadExifDict(filePath) as Record<string, Record<number, unknown>>;
const removed: string[] = [];
const updated: string[] = [];
for (const [ifdName, names] of Object.entries(removeTags || {})) {
if (!ALLOWED_IFDS.has(ifdName)) throw new Error(`IFD '${ifdName}' is not allowed in remove_tags`);
const bucket = exif[ifdName];
if (!bucket || typeof bucket !== "object") continue;
for (const tagName of names) {
const tagId = tagIdForName(ifdName, tagName);
if (Object.prototype.hasOwnProperty.call(bucket, tagId)) {
delete bucket[tagId];
removed.push(`${ifdName}.${tagName}`);
}
}
}
for (const [ifdName, pairs] of Object.entries(setTags || {})) {
if (!ALLOWED_IFDS.has(ifdName)) throw new Error(`IFD '${ifdName}' is not allowed in set_tags`);
let bucket = exif[ifdName];
if (!bucket || typeof bucket !== "object") {
bucket = {};
exif[ifdName] = bucket;
}
for (const [tagName, raw] of Object.entries(pairs)) {
const tagId = tagIdForName(ifdName, tagName);
bucket[tagId] = normalizeTagValue(ifdName, tagId, raw);
updated.push(`${ifdName}.${tagName}`);
}
}
const exifBytes = piexif.dump(exif);
const imageBytes = fs.readFileSync(filePath).toString("binary");
const newJpeg = piexif.insert(exifBytes, imageBytes);
fs.writeFileSync(filePath, Buffer.from(newJpeg, "binary"));
return {
path: fs.realpathSync(filePath),
suffix,
tags_updated: updated,
tags_removed: removed,
};
}