import 'package:intl/intl.dart'; import 'package:xml/xml.dart'; class Namespaces { static const acdsee = 'http://ns.acdsee.com/iptc/1.0/'; static const adsmlat = 'http://adsml.org/xmlns/'; static const avm = 'http://www.communicatingastronomy.org/avm/1.0/'; static const camera = 'http://pix4d.com/camera/1.0/'; static const cc = 'http://creativecommons.org/ns#'; static const container = 'http://ns.google.com/photos/1.0/container/'; static const creatorAtom = 'http://ns.adobe.com/creatorAtom/1.0/'; static const crd = 'http://ns.adobe.com/camera-raw-defaults/1.0/'; static const crs = 'http://ns.adobe.com/camera-raw-settings/1.0/'; static const crss = 'http://ns.adobe.com/camera-raw-saved-settings/1.0/'; static const darktable = 'http://darktable.sf.net/'; static const dc = 'http://purl.org/dc/elements/1.1/'; static const dcterms = 'http://purl.org/dc/terms/'; static const dicom = 'http://ns.adobe.com/DICOM/'; static const digiKam = 'http://www.digikam.org/ns/1.0/'; static const droneDji = 'http://www.dji.com/drone-dji/1.0/'; static const dwc = 'http://rs.tdwg.org/dwc/index.htm'; static const dwciri = 'http://rs.tdwg.org/dwc/iri/'; static const exif = 'http://ns.adobe.com/exif/1.0/'; static const exifAux = 'http://ns.adobe.com/exif/1.0/aux/'; static const exifEx = 'http://cipa.jp/exif/1.0/'; static const gAudio = 'http://ns.google.com/photos/1.0/audio/'; static const gCamera = 'http://ns.google.com/photos/1.0/camera/'; static const gCreations = 'http://ns.google.com/photos/1.0/creations/'; static const gDepth = 'http://ns.google.com/photos/1.0/depthmap/'; static const gettyImagesGift = 'http://xmp.gettyimages.com/gift/1.0/'; static const gFocus = 'http://ns.google.com/photos/1.0/focus/'; static const gImage = 'http://ns.google.com/photos/1.0/image/'; static const gimp = 'http://www.gimp.org/ns/2.10/'; static const gPano = 'http://ns.google.com/photos/1.0/panorama/'; static const gSpherical = 'http://ns.google.com/videos/1.0/spherical/'; static const illustrator = 'http://ns.adobe.com/illustrator/1.0/'; static const iptc4xmpCore = 'http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/'; static const iptc4xmpExt = 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/'; static const lr = 'http://ns.adobe.com/lightroom/1.0/'; static const mediapro = 'http://ns.iview-multimedia.com/mediapro/1.0/'; // also seen in the wild for prefix `MicrosoftPhoto`: 'http://ns.microsoft.com/photo/1.0' static const microsoftPhoto = 'http://ns.microsoft.com/photo/1.0/'; static const mp1 = 'http://ns.microsoft.com/photo/1.1'; static const mp = 'http://ns.microsoft.com/photo/1.2/'; static const mpri = 'http://ns.microsoft.com/photo/1.2/t/RegionInfo#'; static const mpreg = 'http://ns.microsoft.com/photo/1.2/t/Region#'; static const mwgrs = 'http://www.metadataworkinggroup.com/schemas/regions/'; static const nga = 'https://standards.nga.gov/metadata/media/image/artobject/1.0'; static const panorama = 'http://ns.adobe.com/photoshop/1.0/panorama-profile'; static const panoStudio = 'http://www.tshsoft.com/xmlns'; static const pdf = 'http://ns.adobe.com/pdf/1.3/'; static const pdfX = 'http://ns.adobe.com/pdfx/1.3/'; static const photoMechanic = 'http://ns.camerabits.com/photomechanic/1.0/'; static const photoshop = 'http://ns.adobe.com/photoshop/1.0/'; static const plus = 'http://ns.useplus.org/ldf/xmp/1.0/'; static const pmtm = 'http://www.hdrsoft.com/photomatix_settings01'; static const rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; static const stEvt = 'http://ns.adobe.com/xap/1.0/sType/ResourceEvent#'; static const stRef = 'http://ns.adobe.com/xap/1.0/sType/ResourceRef#'; static const tiff = 'http://ns.adobe.com/tiff/1.0/'; static const x = 'adobe:ns:meta/'; static const xmp = 'http://ns.adobe.com/xap/1.0/'; static const xmpBJ = 'http://ns.adobe.com/xap/1.0/bj/'; static const xmpDM = 'http://ns.adobe.com/xmp/1.0/DynamicMedia/'; static const xmpGImg = 'http://ns.adobe.com/xap/1.0/g/img/'; static const xmpMM = 'http://ns.adobe.com/xap/1.0/mm/'; static const xmpNote = 'http://ns.adobe.com/xmp/note/'; static const xmpRights = 'http://ns.adobe.com/xap/1.0/rights/'; static const xmpTPg = 'http://ns.adobe.com/xap/1.0/t/pg/'; // cf https://exiftool.org/TagNames/XMP.html static const Map nsTitles = { acdsee: 'ACDSee', adsmlat: 'AdsML', exifAux: 'Exif Aux', avm: 'Astronomy Visualization', camera: 'Pix4D Camera', cc: 'Creative Commons', container: 'Container', crd: 'Camera Raw Defaults', creatorAtom: 'After Effects', crs: 'Camera Raw Settings', crss: 'Camera Raw Saved Settings', darktable: 'darktable', dc: 'Dublin Core', digiKam: 'digiKam', droneDji: 'DJI Drone', dwc: 'Darwin Core', exif: 'Exif', exifEx: 'Exif Ex', gettyImagesGift: 'Getty Images', gAudio: 'Google Audio', gCamera: 'Google Camera', gCreations: 'Google Creations', gDepth: 'Google Depth', gFocus: 'Google Focus', gImage: 'Google Image', gimp: 'GIMP', gPano: 'Google Panorama', gSpherical: 'Google Spherical', illustrator: 'Illustrator', iptc4xmpCore: 'IPTC Core', iptc4xmpExt: 'IPTC Extension', lr: 'Lightroom', mediapro: 'MediaPro', microsoftPhoto: 'Microsoft Photo 1.0', mp1: 'Microsoft Photo 1.1', mp: 'Microsoft Photo 1.2', mwgrs: 'Regions', nga: 'National Gallery of Art', panorama: 'Panorama', panoStudio: 'PanoramaStudio', pdf: 'PDF', pdfX: 'PDF/X', photoMechanic: 'Photo Mechanic', photoshop: 'Photoshop', plus: 'PLUS', pmtm: 'Photomatix', tiff: 'TIFF', xmp: 'Basic', xmpBJ: 'Basic Job Ticket', xmpDM: 'Dynamic Media', xmpMM: 'Media Management', xmpRights: 'Rights Management', xmpTPg: 'Paged-Text', }; static final defaultPrefixes = { container: 'Container', dc: 'dc', gCamera: 'GCamera', microsoftPhoto: 'MicrosoftPhoto', rdf: 'rdf', x: 'x', xmp: 'xmp', xmpGImg: 'xmpGImg', xmpNote: 'xmpNote', }; } class XMP { static const xmlnsPrefix = 'xmlns'; static const propNamespaceSeparator = ':'; static const structFieldSeparator = '/'; static String prefixOf(String ns) => Namespaces.defaultPrefixes[ns] ?? ''; // elements static const xXmpmeta = 'xmpmeta'; static const rdfRoot = 'RDF'; static const rdfDescription = 'Description'; static const containerDirectory = 'Directory'; static const dcDescription = 'description'; static const dcSubject = 'subject'; static const dcTitle = 'title'; static const msPhotoRating = 'Rating'; static const xmpRating = 'Rating'; // attributes static const xXmptk = 'xmptk'; static const rdfAbout = 'about'; static const gCameraMicroVideo = 'MicroVideo'; static const gCameraMicroVideoVersion = 'MicroVideoVersion'; static const gCameraMicroVideoOffset = 'MicroVideoOffset'; static const gCameraMicroVideoPresentationTimestampUs = 'MicroVideoPresentationTimestampUs'; static const gCameraMotionPhoto = 'MotionPhoto'; static const gCameraMotionPhotoVersion = 'MotionPhotoVersion'; static const gCameraMotionPhotoPresentationTimestampUs = 'MotionPhotoPresentationTimestampUs'; static const xmpCreateDate = 'CreateDate'; static const xmpMetadataDate = 'MetadataDate'; static const xmpModifyDate = 'ModifyDate'; static const xmpNoteHasExtendedXMP = 'HasExtendedXMP'; // for `rdf:Description` node only static bool _hasMeaningfulChildren(XmlNode node) => node.children.any((v) => v.nodeType != XmlNodeType.TEXT || v.text.trim().isNotEmpty); // for `rdf:Description` node only static bool _hasMeaningfulAttributes(XmlNode description) { final hasMeaningfulAttributes = description.attributes.any((v) { switch (v.name.local) { case rdfAbout: case xmpMetadataDate: case xmpModifyDate: return false; default: switch (v.name.prefix) { case xmlnsPrefix: return false; default: // if the attribute got defined with the prefix as part of the name, // the prefix is not recognized as such, so we check the full name return !v.name.qualified.startsWith(xmlnsPrefix); } } }); return hasMeaningfulAttributes; } // return time zone designator, formatted as `Z` or `+hh:mm` or `-hh:mm` // as of intl v0.17.0, formatting time zone offset is not implemented static String _xmpTimeZoneDesignator(DateTime date) { final offsetMinutes = date.timeZoneOffset.inMinutes; final abs = offsetMinutes.abs(); final h = abs ~/ Duration.minutesPerHour; final m = abs % Duration.minutesPerHour; return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; } static String toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}'; static String? getString( List nodes, String name, { required String namespace, }) { for (final node in nodes) { final attribute = node.getAttribute(name, namespace: namespace); if (attribute != null) return attribute; final element = node.getElement(name, namespace: namespace); if (element != null) return element.innerText; } return null; } static void _addNamespaces(XmlNode node, Map namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); // remove elements and attributes static bool removeElements(List nodes, String name, String namespace) { var removed = false; nodes.forEach((node) { final elements = node.findElements(name, namespace: namespace).toSet(); if (elements.isNotEmpty) { elements.forEach(node.children.remove); removed = true; } if (node.getAttributeNode(name, namespace: namespace) != null) { node.removeAttribute(name, namespace: namespace); removed = true; } }); return removed; } // remove attribute/element from all nodes, and set attribute with new value, if any, in the first node static bool setAttribute( List nodes, String name, String? value, { required String namespace, required XmpEditStrategy strat, }) { final removed = removeElements(nodes, name, namespace); if (value == null) return removed; bool modified = removed; if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { final node = nodes.first; _addNamespaces(node, {namespace: prefixOf(namespace)}); // use qualified name, otherwise the namespace prefix is not added final qualifiedName = '${prefixOf(namespace)}$propNamespaceSeparator$name'; node.setAttribute(qualifiedName, value); modified = true; } return modified; } // remove attribute/element from all nodes, and create element with new value, if any, in the first node static void setElement( List nodes, String name, String? value, { required String namespace, required XmpEditStrategy strat, }) { final removed = removeElements(nodes, name, namespace); if (value == null) return; if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { final node = nodes.first; _addNamespaces(node, {namespace: prefixOf(namespace)}); final builder = XmlBuilder(); builder.namespace(namespace, prefixOf(namespace)); builder.element(name, namespace: namespace, nest: () { builder.text(value); }); node.children.add(builder.buildFragment()); } } // remove bag from all nodes, and create bag with new values, if any, in the first node static bool setStringBag( List nodes, String name, Set values, { required String namespace, required XmpEditStrategy strat, }) { // remove existing final removed = removeElements(nodes, name, namespace); if (values.isEmpty) return removed; bool modified = removed; if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) { final node = nodes.first; _addNamespaces(node, {namespace: prefixOf(namespace)}); // add new bag final rootBuilder = XmlBuilder(); rootBuilder.namespace(namespace, prefixOf(namespace)); rootBuilder.element(name, namespace: namespace); node.children.add(rootBuilder.buildFragment()); final bagBuilder = XmlBuilder(); bagBuilder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); bagBuilder.element('Bag', namespace: Namespaces.rdf, nest: () { values.forEach((v) { bagBuilder.element('li', namespace: Namespaces.rdf, nest: v); }); }); node.children.last.children.add(bagBuilder.buildFragment()); modified = true; } return modified; } static Future edit( String? xmpString, Future Function() toolkit, bool Function(List descriptions) apply, { DateTime? modifyDate, }) async { XmlDocument? xmpDoc; if (xmpString != null) { xmpDoc = XmlDocument.parse(xmpString); } if (xmpDoc == null) { final builder = XmlBuilder(); builder.namespace(Namespaces.x, prefixOf(Namespaces.x)); builder.element(xXmpmeta, namespace: Namespaces.x, namespaces: { Namespaces.x: prefixOf(Namespaces.x), }, attributes: { '${prefixOf(Namespaces.x)}$propNamespaceSeparator$xXmptk': await toolkit(), }); xmpDoc = builder.buildDocument(); } final root = xmpDoc.rootElement; XmlNode? rdf = root.getElement(rdfRoot, namespace: Namespaces.rdf); if (rdf == null) { final builder = XmlBuilder(); builder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); builder.element(rdfRoot, namespace: Namespaces.rdf, namespaces: { Namespaces.rdf: prefixOf(Namespaces.rdf), }); // get element because doc fragment cannot be used to edit root.children.add(builder.buildFragment()); rdf = root.getElement(rdfRoot, namespace: Namespaces.rdf)!; } // content can be split in multiple `rdf:Description` elements List descriptions = rdf.children.where((node) { return node is XmlElement && node.name.local == rdfDescription && node.name.namespaceUri == Namespaces.rdf; }).toList(); if (descriptions.isEmpty) { final builder = XmlBuilder(); builder.namespace(Namespaces.rdf, prefixOf(Namespaces.rdf)); builder.element(rdfDescription, namespace: Namespaces.rdf, attributes: { '${prefixOf(Namespaces.rdf)}$propNamespaceSeparator$rdfAbout': '', }); rdf.children.add(builder.buildFragment()); // get element because doc fragment cannot be used to edit descriptions.add(rdf.getElement(rdfDescription, namespace: Namespaces.rdf)!); } final modified = apply(descriptions); // clean description nodes with no children descriptions.where((v) => !_hasMeaningfulChildren(v)).forEach((v) => v.children.clear()); // remove superfluous description nodes rdf.children.removeWhere((v) => !_hasMeaningfulChildren(v) && !_hasMeaningfulAttributes(v)); if (rdf.children.isNotEmpty) { if (modified) { _addNamespaces(descriptions.first, {Namespaces.xmp: prefixOf(Namespaces.xmp)}); final xmpDate = toXmpDate(modifyDate ?? DateTime.now()); setAttribute(descriptions, xmpMetadataDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); setAttribute(descriptions, xmpModifyDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always); } } else { // clear XMP if there are no attributes or elements worth preserving xmpDoc = null; } return xmpDoc?.toXmlString(); } static String? toMsPhotoRating(int? rating) { if (rating == null) return null; switch (rating) { case 5: return '99'; case 4: return '75'; case 3: return '50'; case 2: return '25'; case 1: return '1'; case 0: return null; case -1: return '-1'; } return null; } } enum XmpEditStrategy { always, updateIfPresent }