import type { ImageMetadata } from "../types.ts";
/**
* Serialize a semantic metadata object into a standards-compliant XMP packet.
*
* XMP is just RDF/XML text wrapped in xpacket markers — no binary offsets, so
* this is the safe, format-agnostic core. The same packet is spliced into any
* container (WebP `XMP ` chunk, JPEG APP1, PNG iTXt, AVIF mime item).
*/
const XPACKET_BEGIN = '';
const XPACKET_END = '';
const NS = {
dc: "http://purl.org/dc/elements/1.1/",
xmpRights: "http://ns.adobe.com/xap/1.0/rights/",
photoshop: "http://ns.adobe.com/photoshop/1.0/",
Iptc4xmpCore: "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/",
Iptc4xmpExt: "http://iptc.org/std/Iptc4xmpExt/2008-02-29/",
plus: "http://ns.useplus.org/ldf/xmp/1.0/",
} as const;
function esc(value: string): string {
return value
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
/** A language-alternative property (dc:description, dc:title, dc:rights, alt text). */
function altLang(tag: string, value: string): string {
return (
` <${tag}>\n` +
` \n` +
` ${esc(value)}\n` +
` \n` +
` ${tag}>\n`
);
}
/** A simple text/URI property (photoshop:Credit, the Iptc4xmpExt AI fields). */
function simpleEl(tag: string, value: string): string {
return ` <${tag}>${esc(value)}${tag}>\n`;
}
/** An unordered array property (dc:subject / keywords). */
function bag(tag: string, values: string[]): string {
const items = values
.map((v) => ` ${esc(v)}\n`)
.join("");
return ` <${tag}>\n \n${items} \n ${tag}>\n`;
}
/** An ordered array property (dc:creator). */
function seq(tag: string, values: string[]): string {
const items = values
.map((v) => ` ${esc(v)}\n`)
.join("");
return ` <${tag}>\n \n${items} \n ${tag}>\n`;
}
/**
* The IPTC PLUS Licensor: a structured rdf:Seq of licensor entries, each a
* resource with plus:LicensorName / plus:LicensorURL. Google reads LicensorURL
* for the "Get this image on…" link.
*/
function licensorSeq(url: string, name?: string): string {
const nameLine = name
? ` ${esc(name)}\n`
: "";
return (
` \n` +
` \n` +
` \n` +
nameLine +
` ${esc(url)}\n` +
` \n` +
` \n` +
` \n`
);
}
export function serializeXmp(meta: ImageMetadata): string {
const props: string[] = [];
if (meta.description) props.push(altLang("dc:description", meta.description));
if (meta.title) props.push(altLang("dc:title", meta.title));
if (meta.keywords?.length) props.push(bag("dc:subject", meta.keywords));
if (meta.creator) props.push(seq("dc:creator", [meta.creator]));
if (meta.rights) props.push(altLang("dc:rights", meta.rights));
if (meta.altText)
props.push(altLang("Iptc4xmpCore:AltTextAccessibility", meta.altText));
if (meta.credit) props.push(simpleEl("photoshop:Credit", meta.credit));
if (meta.copyrightNotice)
props.push(simpleEl("photoshop:Copyright", meta.copyrightNotice));
if (meta.licenseUrl)
props.push(simpleEl("xmpRights:WebStatement", meta.licenseUrl));
if (meta.licensor?.url)
props.push(licensorSeq(meta.licensor.url, meta.licensor.name));
// IPTC Extension: digital source type + 2025.1 AI-generation provenance.
// All five are plain single-valued text/URI properties per the spec.
if (meta.digitalSourceType)
props.push(simpleEl("Iptc4xmpExt:DigitalSourceType", meta.digitalSourceType));
if (meta.ai?.prompt)
props.push(simpleEl("Iptc4xmpExt:AIPromptInformation", meta.ai.prompt));
if (meta.ai?.promptWriter)
props.push(simpleEl("Iptc4xmpExt:AIPromptWriterName", meta.ai.promptWriter));
if (meta.ai?.system)
props.push(simpleEl("Iptc4xmpExt:AISystemUsed", meta.ai.system));
if (meta.ai?.systemVersion)
props.push(simpleEl("Iptc4xmpExt:AISystemVersionUsed", meta.ai.systemVersion));
const xmlns = Object.entries(NS)
.map(([prefix, uri]) => ` xmlns:${prefix}="${uri}"`)
.join("\n");
return (
`${XPACKET_BEGIN}\n` +
`\n` +
` \n` +
` \n` +
props.join("") +
` \n` +
` \n` +
`\n` +
`${XPACKET_END}`
);
}