diff --git a/README.md b/README.md index a74e608..5db753c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # XMP Core for Kotlin Multiplatform -[![Kotlin](https://img.shields.io/badge/kotlin-1.9.21-blue.svg?logo=kotlin)](httpw://kotlinlang.org) +[![Kotlin](https://img.shields.io/badge/kotlin-1.9.22-blue.svg?logo=kotlin)](httpw://kotlinlang.org) ![JVM](https://img.shields.io/badge/-JVM-gray.svg?style=flat) ![Android](https://img.shields.io/badge/-Android-gray.svg?style=flat) ![macOS](https://img.shields.io/badge/-macOS-gray.svg?style=flat) @@ -17,7 +17,7 @@ It's part of [Ashampoo Photos](https://ashampoo.com/photos). ## Installation ``` -implementation("com.ashampoo:xmpcore:0.3") +implementation("com.ashampoo:xmpcore:1.0.0") ``` ## How to use diff --git a/build.gradle.kts b/build.gradle.kts index dc8e4d7..8056dac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl plugins { - kotlin("multiplatform") version "1.9.21" + kotlin("multiplatform") version "1.9.22" id("com.android.library") version "8.0.2" id("maven-publish") id("signing") diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPConst.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPConst.kt index c69ed5b..769bae2 100644 --- a/src/commonMain/kotlin/com/ashampoo/xmp/XMPConst.kt +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPConst.kt @@ -195,6 +195,8 @@ object XMPConst { */ const val TYPE_DIMENSIONS: String = "http://ns.adobe.com/xap/1.0/sType/Dimensions#" + const val TYPE_AREA: String = "http://ns.adobe.com/xmp/sType/Area#" + const val TYPE_TEXT: String = "http://ns.adobe.com/xap/1.0/t/" const val TYPE_PAGED_FILE: String = "http://ns.adobe.com/xap/1.0/t/pg/" @@ -294,4 +296,6 @@ object XMPConst { const val XMP_IPTC_EXT_PERSON_IN_IMAGE: String = "PersonInImage" + const val XMP_MWG_RS_TYPE_FACE: String = "Face" + } diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPMeta.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPMeta.kt index 8695ea3..bd4f535 100644 --- a/src/commonMain/kotlin/com/ashampoo/xmp/XMPMeta.kt +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPMeta.kt @@ -9,6 +9,7 @@ package com.ashampoo.xmp import com.ashampoo.xmp.Utils.normalizeLangValue +import com.ashampoo.xmp.XMPConst.NS_MWG_RS import com.ashampoo.xmp.XMPNodeUtils.appendLangItem import com.ashampoo.xmp.XMPNodeUtils.chooseLocalizedText import com.ashampoo.xmp.XMPNodeUtils.deleteNode @@ -1655,7 +1656,7 @@ class XMPMeta { val regionType = getPropertyString(XMPConst.NS_MWG_RS, "$prefix:Type") /* We only want faces. */ - if (regionType != "Face") + if (regionType != XMPConst.XMP_MWG_RS_TYPE_FACE) continue val name = getPropertyString(XMPConst.NS_MWG_RS, "$prefix:Name") @@ -1675,13 +1676,120 @@ class XMPMeta { return faces } -// fun setFaces(faces: Map) { -// -// /* Delete existing entries, if any */ -// deleteProperty(NS_MWG_RS, "Regions") -// -// // TODO Write faces -// } + fun setFaces( + faces: Map, + widthPx: Int, + heightPx: Int + ) { + + /* Delete existing entries, if any */ + deleteProperty(NS_MWG_RS, "Regions") + + if (faces.isNotEmpty()) { + + setStructField( + NS_MWG_RS, "Regions/mwg-rs:AppliedToDimensions", + XMPConst.TYPE_DIMENSIONS, "w", + widthPx.toString() + ) + + setStructField( + NS_MWG_RS, "Regions/mwg-rs:AppliedToDimensions", + XMPConst.TYPE_DIMENSIONS, "h", + heightPx.toString() + ) + + setStructField( + NS_MWG_RS, "Regions/mwg-rs:AppliedToDimensions", + XMPConst.TYPE_DIMENSIONS, "unit", "pixel" + ) + + setStructField( + NS_MWG_RS, "Regions", NS_MWG_RS, "RegionList", + null, arrayOptions + ) + + faces.onEachIndexed { index, face -> + + val oneBasedIndex = index + 1 + + val structNameItem = "Regions/mwg-rs:RegionList[$oneBasedIndex]" + val structNameArea = "$structNameItem/mwg-rs:Area" + + insertArrayItem( + schemaNS = NS_MWG_RS, + arrayName = "Regions/mwg-rs:RegionList", + itemIndex = oneBasedIndex, + itemValue = "", + options = PropertyOptions().setStruct(true) + ) + + setStructField( + NS_MWG_RS, + structNameItem, + XMPConst.NS_MWG_RS, + "Type", + XMPConst.XMP_MWG_RS_TYPE_FACE + ) + + setStructField( + NS_MWG_RS, + structNameItem, + XMPConst.NS_MWG_RS, + "Name", + face.key + ) + + setStructField( + NS_MWG_RS, + structNameArea, + XMPConst.TYPE_AREA, + "x", + face.value.xPos.toString() + ) + + setStructField( + NS_MWG_RS, + structNameArea, + XMPConst.TYPE_AREA, + "x", + face.value.xPos.toString() + ) + + setStructField( + NS_MWG_RS, + structNameArea, + XMPConst.TYPE_AREA, + "y", + face.value.yPos.toString() + ) + + setStructField( + NS_MWG_RS, + structNameArea, + XMPConst.TYPE_AREA, + "w", + face.value.width.toString() + ) + + setStructField( + NS_MWG_RS, + structNameArea, + XMPConst.TYPE_AREA, + "h", + face.value.height.toString() + ) + + setStructField( + NS_MWG_RS, + structNameArea, + XMPConst.TYPE_AREA, + "unit", + "normalized" + ) + } + } + } fun getPersonsInImage(): Set { diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPNodeUtils.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPNodeUtils.kt index 4cf9e40..550ded9 100644 --- a/src/commonMain/kotlin/com/ashampoo/xmp/XMPNodeUtils.kt +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPNodeUtils.kt @@ -100,11 +100,11 @@ object XMPNodeUtils { !parent.isImplicit -> throw XMPException( - "Named children only allowed for schemas and structs", XMPError.BADXPATH + "Named children only allowed for schemas and structs: $childName", XMPError.BADXPATH ) parent.options.isArray() -> - throw XMPException("Named children not allowed for arrays", XMPError.BADXPATH) + throw XMPException("Named children not allowed for arrays: $childName", XMPError.BADXPATH) createNodes -> parent.options.setStruct(true) diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPSchemaRegistry.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPSchemaRegistry.kt index 71632ff..fe23f8e 100644 --- a/src/commonMain/kotlin/com/ashampoo/xmp/XMPSchemaRegistry.kt +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPSchemaRegistry.kt @@ -268,6 +268,7 @@ object XMPSchemaRegistry { registerNamespace(XMPConst.TYPE_IMAGE, "xmpGImg") registerNamespace(XMPConst.TYPE_FONT, "stFnt") registerNamespace(XMPConst.TYPE_DIMENSIONS, "stDim") + registerNamespace(XMPConst.TYPE_AREA, "stArea") registerNamespace(XMPConst.TYPE_RESOURCE_EVENT, "stEvt") registerNamespace(XMPConst.TYPE_RESOURCE_REF, "stRef") registerNamespace(XMPConst.TYPE_ST_VERSION, "stVer") diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/xpath/XMPPathParser.kt b/src/commonMain/kotlin/com/ashampoo/xmp/xpath/XMPPathParser.kt index 12a74fd..54aff91 100644 --- a/src/commonMain/kotlin/com/ashampoo/xmp/xpath/XMPPathParser.kt +++ b/src/commonMain/kotlin/com/ashampoo/xmp/xpath/XMPPathParser.kt @@ -50,11 +50,13 @@ object XMPPathParser { pos.path = path - // Pull out the first component and do some special processing on it: add the schema - // namespace prefix and see if it is an alias. The start must be a "qualName". + /* + * Pull out the first component and do some special processing on it: add the schema + * namespace prefix and see if it is an alias. The start must be a "qualName". + */ parseRootNode(schemaNS, pos, expandedXPath) - // Now continue to process the rest of the XMPPath string. + /* Now continue to process the rest of the XMPPath string. */ while (pos.stepEnd < path.length) { pos.stepBegin = pos.stepEnd @@ -66,10 +68,13 @@ object XMPPathParser { var segment: XMPPathSegment segment = if (path[pos.stepBegin] != '[') { - // A struct field or qualifier. + + /* A struct field or qualifier. */ parseStructSegment(pos) + } else { - // One of the array forms. + + /* One of the array forms. */ parseIndexSegment(pos) } @@ -122,7 +127,7 @@ object XMPPathParser { if (path[pos.stepBegin] == '/') { - // skip slash + /* Skip the slash */ pos.stepBegin++ if (pos.stepBegin >= path.length) @@ -131,7 +136,7 @@ object XMPPathParser { if (path[pos.stepBegin] == '*') { - // skip asterisk + /* Skip the asterisk */ pos.stepBegin++ if (pos.stepBegin >= path.length || path[pos.stepBegin] != '[') @@ -164,11 +169,12 @@ object XMPPathParser { val segment: XMPPathSegment - pos.stepEnd++ // Look at the character after the leading '['. + /* Look at the character after the leading '['. */ + pos.stepEnd++ if (pos.path!![pos.stepEnd] in '0'..'9') { - // A numeric (decimal integer) array index. + /* A numeric (decimal integer) array index. */ while ( pos.stepEnd < pos.path!!.length && '0' <= pos.path!![pos.stepEnd] && pos.path!![pos.stepEnd] <= '9' @@ -179,7 +185,7 @@ object XMPPathParser { } else { - // Could be "[last()]" or one of the selector forms. Find the ']' or '='. + /* Could be "[last()]" or one of the selector forms. Find the ']' or '='. */ while ( pos.stepEnd < pos.path!!.length && pos.path!![pos.stepEnd] != ']' && pos.path!![pos.stepEnd] != '=' @@ -201,20 +207,22 @@ object XMPPathParser { pos.nameStart = pos.stepBegin + 1 pos.nameEnd = pos.stepEnd - pos.stepEnd++ // Absorb the '=', remember the quote. + /* Absorb the '=', remember the quote. */ + pos.stepEnd++ val quote = pos.path!![pos.stepEnd] if (quote != '\'' && quote != '"') throw XMPException("Invalid quote in array selector", XMPError.BADXPATH) - pos.stepEnd++ // Absorb the leading quote. + /* Absorb the leading quote */ + pos.stepEnd++ while (pos.stepEnd < pos.path!!.length) { if (pos.path!![pos.stepEnd] == quote) { - // check for escaped quote + /* Check for escaped quote */ if (pos.stepEnd + 1 >= pos.path!!.length || pos.path!![pos.stepEnd + 1] != quote) break @@ -227,9 +235,10 @@ object XMPPathParser { if (pos.stepEnd >= pos.path!!.length) throw XMPException("No terminating quote for array selector", XMPError.BADXPATH) - pos.stepEnd++ // Absorb the trailing quote. + /* Absorb the trailing quote. */ + pos.stepEnd++ - // ! Touch up later, also changing '@' to '?'. + /* ! Touch up later, also changing '@' to '?'. */ segment = XMPPathSegment(null, XMPPath.FIELD_SELECTOR_STEP) } } @@ -261,7 +270,7 @@ object XMPPathParser { if (aliasInfo == null) { - // add schema xpath step + /* Add schema xpath step */ expandedXPath.add(XMPPathSegment(schemaNS, XMPPath.SCHEMA_NODE)) val rootStep = XMPPathSegment(rootProp, XMPPath.STRUCT_FIELD_STEP) @@ -270,7 +279,7 @@ object XMPPathParser { } else { - // add schema xpath step and base step of alias + /* Add schema xpath step and base step of alias */ expandedXPath.add(XMPPathSegment(aliasInfo.getNamespace(), XMPPath.SCHEMA_NODE)) val rootStep = XMPPathSegment( @@ -328,7 +337,7 @@ object XMPPathParser { } } - throw XMPException("Ill-formed qualified name", XMPError.BADXPATH) + throw XMPException("Ill-formed qualified name: $qualName", XMPError.BADXPATH) } /** @@ -349,36 +358,42 @@ object XMPPathParser { */ private fun verifyXPathRoot(schemaNS: String?, rootProp: String): String { - // Do some basic checks on the URI and name. Try to look up the URI. See if the name is qualified. + /* Do some basic checks on the URI and name. Try to look up the URI. See if the name is qualified. */ if (schemaNS.isNullOrEmpty()) throw XMPException("Schema namespace URI is required", XMPError.BADSCHEMA) if (rootProp[0] == '?' || rootProp[0] == '@') - throw XMPException("Top level name must not be a qualifier", XMPError.BADXPATH) + throw XMPException( + "Top level name must not be a qualifier, but was '$rootProp'", + XMPError.BADXPATH + ) if (rootProp.indexOf('/') >= 0 || rootProp.indexOf('[') >= 0) - throw XMPException("Top level name must be simple", XMPError.BADXPATH) + throw XMPException("Top level name must be simple, but was '$rootProp'", XMPError.BADXPATH) var prefix = schemaRegistry.getNamespacePrefix(schemaNS) - ?: throw XMPException("Unregistered schema namespace URI", XMPError.BADSCHEMA) + ?: throw XMPException("Unregistered schema namespace URI: $schemaNS", XMPError.BADSCHEMA) - // Verify the various URI and prefix combinations. Initialize the expanded XMPPath. + /* Verify the various URI and prefix combinations. Initialize the expanded XMPPath. */ val colonPos = rootProp.indexOf(':') return if (colonPos < 0) { - // The propName is unqualified, use the schemaURI and associated prefix. + /* The propName is unqualified, use the schemaURI and associated prefix. */ - verifySimpleXMLName(rootProp) // Verify the part before any colon + /* Verify the part before any colon */ + verifySimpleXMLName(rootProp) prefix + rootProp } else { - // The propName is qualified. Make sure the prefix is legit. - // Use the associated URI and qualified name. + /* + * The propName is qualified. Make sure the prefix is legit. + * Use the associated URI and qualified name. + */ - // Verify the part before any colon + /* Verify the part before any colon */ verifySimpleXMLName(rootProp.substring(0, colonPos)) verifySimpleXMLName(rootProp.substring(colonPos)) diff --git a/src/commonTest/kotlin/com/ashampoo/xmp/WriteXmpTest.kt b/src/commonTest/kotlin/com/ashampoo/xmp/WriteXmpTest.kt index f2773ef..0984230 100644 --- a/src/commonTest/kotlin/com/ashampoo/xmp/WriteXmpTest.kt +++ b/src/commonTest/kotlin/com/ashampoo/xmp/WriteXmpTest.kt @@ -166,20 +166,26 @@ class WriteXmpTest { private fun writeTestValues(xmpMeta: XMPMeta) { - /* Write rating. */ xmpMeta.setRating(3) - /* Write taken date. */ xmpMeta.setDateTimeOriginal("2023-07-07T13:37:42") - /* Write GPS coordinates. */ - xmpMeta.setGpsCoordinates( latitudeDdm = "53,13.1635N", longitudeDdm = "8,14.3797E" ) xmpMeta.setKeywords(setOf("bird", "cat", "dog")) + + xmpMeta.setFaces( + faces = mapOf( + "Eye Left" to XMPRegionArea(0.295179, 0.278880, 0.033245, 0.05), + "Eye Right" to XMPRegionArea(0.814990, 0.472579, 0.033245, 0.05), + "Nothing" to XMPRegionArea(0.501552, 0.905484, 0.033245, 0.05) + ), + widthPx = 1500, + heightPx = 1000 + ) } private fun getXmp(name: String): String = diff --git a/src/commonTest/resources/com/ashampoo/xmp/new.xmp b/src/commonTest/resources/com/ashampoo/xmp/new.xmp index ee104e3..d820458 100644 --- a/src/commonTest/resources/com/ashampoo/xmp/new.xmp +++ b/src/commonTest/resources/com/ashampoo/xmp/new.xmp @@ -4,6 +4,9 @@ dog + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/commonTest/resources/com/ashampoo/xmp/updated.xmp b/src/commonTest/resources/com/ashampoo/xmp/updated.xmp new file mode 100644 index 0000000..d820458 --- /dev/null +++ b/src/commonTest/resources/com/ashampoo/xmp/updated.xmp @@ -0,0 +1,72 @@ + + + + + + + bird + cat + dog + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file