import type { ImageMetadata } from "../types.ts"; /** * Pragmatic XMP extractor. * * This is intentionally NOT a full RDF/XML parser (which would mean a * dependency). It pulls the semantic fields we care about out of the packet * with targeted matching, and round-trips losslessly with `serializeXmp`. * Unknown/extra properties are ignored on read, never corrupted on write. */ function unesc(value: string): string { return value .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&") .trim(); } /** First text inside the named property (handles Alt/Seq wrappers). */ function firstLi(xmp: string, tag: string): string | undefined { const block = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)`, "i").exec( xmp, ); if (!block) return undefined; const li = /]*>([\s\S]*?)<\/rdf:li>/i.exec(block[1] ?? ""); if (li) return unesc(li[1] ?? ""); // Simple property with no Alt/Bag/Seq wrapper. const inner = (block[1] ?? "").trim(); return inner && !inner.startsWith("<") ? unesc(inner) : undefined; } /** All texts inside the named property (Bag/Seq). */ function allLi(xmp: string, tag: string): string[] | undefined { const block = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)`, "i").exec( xmp, ); if (!block) return undefined; const items: string[] = []; const re = /]*>([\s\S]*?)<\/rdf:li>/gi; let m: RegExpExecArray | null; while ((m = re.exec(block[1] ?? "")) !== null) items.push(unesc(m[1] ?? "")); return items.length ? items : undefined; } /** Simple element value, e.g. X. */ function simple(xmp: string, tag: string): string | undefined { const m = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)`, "i").exec(xmp); return m ? unesc(m[1] ?? "") : undefined; } export function parseXmp(xmp: string): ImageMetadata { const meta: ImageMetadata = {}; const description = firstLi(xmp, "dc:description"); if (description) meta.description = description; const title = firstLi(xmp, "dc:title"); if (title) meta.title = title; const keywords = allLi(xmp, "dc:subject"); if (keywords) meta.keywords = keywords; const creator = firstLi(xmp, "dc:creator"); if (creator) meta.creator = creator; const rights = firstLi(xmp, "dc:rights"); if (rights) meta.rights = rights; const altText = firstLi(xmp, "Iptc4xmpCore:AltTextAccessibility"); if (altText) meta.altText = altText; const credit = simple(xmp, "photoshop:Credit"); if (credit) meta.credit = credit; const copyrightNotice = simple(xmp, "photoshop:Copyright"); if (copyrightNotice) meta.copyrightNotice = copyrightNotice; const licenseUrl = simple(xmp, "xmpRights:WebStatement"); if (licenseUrl) meta.licenseUrl = licenseUrl; // IPTC PLUS Licensor: read the nested URL (required) and optional name. const licensorUrl = simple(xmp, "plus:LicensorURL"); if (licensorUrl) { const licensorName = simple(xmp, "plus:LicensorName"); meta.licensor = licensorName ? { url: licensorUrl, name: licensorName } : { url: licensorUrl }; } const digitalSourceType = simple(xmp, "Iptc4xmpExt:DigitalSourceType"); if (digitalSourceType) meta.digitalSourceType = digitalSourceType; // IPTC 2025.1 AI-generation provenance — only attach `ai` if any field is present. const aiPrompt = simple(xmp, "Iptc4xmpExt:AIPromptInformation"); const aiPromptWriter = simple(xmp, "Iptc4xmpExt:AIPromptWriterName"); const aiSystem = simple(xmp, "Iptc4xmpExt:AISystemUsed"); const aiSystemVersion = simple(xmp, "Iptc4xmpExt:AISystemVersionUsed"); if (aiPrompt || aiPromptWriter || aiSystem || aiSystemVersion) { meta.ai = {}; if (aiPrompt) meta.ai.prompt = aiPrompt; if (aiPromptWriter) meta.ai.promptWriter = aiPromptWriter; if (aiSystem) meta.ai.system = aiSystem; if (aiSystemVersion) meta.ai.systemVersion = aiSystemVersion; } return meta; }