diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a798a08 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 110 +tab_width = 4 +trim_trailing_whitespace = true + +[{*.yml,*.json,gradlew}] +indent_size = 2 + +[{*.yml,*.md,*.txt}] +max_line_length = off diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ae2c07 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,123 @@ +name: Build & Test + +on: + push: + branches: + - main + tags: + - '*' + pull_request: + branches: + - main + +jobs: + build: + name: Build & Test + runs-on: macos-latest + steps: + - name: Checkout workspace + uses: actions/checkout@v3 + timeout-minutes: 5 + with: + fetch-depth: 0 # no shallow clones for SonarQube + - name: Set up Java JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17.0.5+8' + - name: Gradle Cache + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.gradle/native + key: ${{ runner.os }}-gradle-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'build.gradle.kts') }} + restore-keys: ${{ runner.os }}-gradle- + - name: Kotlin Native Cache + uses: actions/cache@v3 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'build.gradle.kts') }} + restore-keys: ${{ runner.os }}-konan- + - name: Android Cache + uses: actions/cache@v3 + with: + path: ~/.android + key: ${{ runner.os }}-android-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'build.gradle.kts') }} + restore-keys: ${{ runner.os }}-android- + - name: Cache SonarCloud packages + uses: actions/cache@v1 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Build & Test + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + chmod +x ./gradlew + ./gradlew build test koverXmlReport detekt sonar assembleXCFramework --parallel + - name: Set RELEASE_VERSION variable + run: | + echo "RELEASE_VERSION=$(cat build/version.txt)" >> $GITHUB_ENV + echo ${{ env.RELEASE_VERSION }} + - name: Upload reports + uses: actions/upload-artifact@v3 + if: always() + with: + name: reports.zip + path: build/reports + - name: List files + run: | + ls -lah build/bin/*/releaseExecutable build/libs/ build/outputs/aar/ build/XCFrameworks/release/ + - name: Upload xmpcore.jar + uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: xmpcore.jar + path: build/libs/xmpcore-jvm-${{ env.RELEASE_VERSION }}.jar + - name: Upload xmpcore.aar + uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: xmpcore.aar + path: build/outputs/aar/xmpcore-release.aar + - name: Upload xmpcore.xcframework + uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: xmpcore.xcframework + path: build/XCFrameworks/release/xmpcore.xcframework + - name: Upload xmpcore.exe + uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: xmpcore.exe + path: build/bin/win/releaseExecutable/xmpcore.exe + - name: Upload xmpcore-macosX64.kexe + uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: xmpcore-macosX64.kexe + path: build/bin/macosX64/releaseExecutable/xmpcore.kexe + - name: Upload xmpcore-macosArm64.kexe + uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: xmpcore-macosArm64.kexe + path: build/bin/macosArm64/releaseExecutable/xmpcore.kexe + - name: Export secring.pgp + if: startsWith(github.ref, 'refs/tags/') # Only for releases + run: | + echo ${{ secrets.SINGING_SECRET_KEY_RING_FILE_CONTENTS }} | base64 --decode > secring.pgp + - name: Publish to Maven Central + if: startsWith(github.ref, 'refs/tags/') # Only for releases + env: + SIGNING_ENABLED: true + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + run: ./gradlew publishAllPublicationsToSonatypeRepository diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b27f24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +**/.DS_Store +**/Thumbs.db +.DS_Store +/.gradle/ +/.idea/.name +/.idea/artifacts/ +/.idea/assetWizardSettings.xml +/.idea/compiler.xml +/.idea/deploymentTargetDropDown.xml +/.idea/gradle.xml +/.idea/jarRepositories.xml +/.idea/kotlinScripting.xml +/.idea/kotlinc.xml +/.idea/libraries/ +/.idea/misc.xml +/.idea/modules.xml +/.idea/modules/ +/.idea/runConfigurations.xml +/.idea/shelf/ +/.idea/sonarlint/ +/.idea/uiDesigner.xml +/.idea/vcs.xml +/.idea/workspace.xml +/.idea/xmpcore.iml +/build/ +/local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..8d78c13 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/detekt.xml b/.idea/detekt.xml new file mode 100644 index 0000000..5ac0d84 --- /dev/null +++ b/.idea/detekt.xml @@ -0,0 +1,21 @@ + + + + + + + + true + true + detekt.yml + + \ No newline at end of file diff --git a/.idea/externalDependencies.xml b/.idea/externalDependencies.xml new file mode 100644 index 0000000..6545923 --- /dev/null +++ b/.idea/externalDependencies.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..35642cc --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,1581 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Publish_to_Maven_local.xml b/.idea/runConfigurations/Publish_to_Maven_local.xml new file mode 100644 index 0000000..8ebb442 --- /dev/null +++ b/.idea/runConfigurations/Publish_to_Maven_local.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bad5ff --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# XMP Core for Kotlin Multiplatform + +[![Kotlin](https://img.shields.io/badge/kotlin-1.8.20-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) +![iOS](https://img.shields.io/badge/-iOS-gray.svg?style=flat) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=xmpcore&metric=coverage)](https://sonarcloud.io/summary/new_code?id=xmpcore) +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.ashampoo/xmpcore/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.ashampoo/xmpcore) + +This library is a port of Adobe's XMP SDK to Kotlin Multiplatform. + +It's part of [Ashampoo Photos](https://ashampoo.com/photos). + +## Installation + +``` +implementation("com.ashampoo:xmpcore:0.1.0") +``` + +## How to use + +The library has been designed as a drop-in replacement for users who previously +employed XMP Core Java. Therefore, all the documentation applicable to the +Java SDK also pertains to this library. +However, please note that we have made the decision to remove the functionality for reading +from and writing to ByteArray and InputStreams, as we believe it is unnecessary. + +### Sample code + +``` +val originalXmp: String = "... your XMP ..." + +val xmpMeta: XMPMeta = XMPMetaFactory.parseFromString(originalXmp) + +val xmpSerializeOptions = + SerializeOptions() + .setOmitXmpMetaElement(false) + .setOmitPacketWrapper(false) + .setUseCompactFormat(true) + .setSort(true) + +val newXmp = XMPMetaFactory.serializeToString(xmpMeta, xmpSerializeOptions) +``` + +You find more samples in the unit tests. + +### Migration hint + +If you have previously utilized the official XMP Core Java library available on +Maven Central, please make sure to update your imports from `com.adobe.internal.xmp` +to `com.ashampoo.xmp`. + +## Acknowledgements + +* JetBrains for making [Kotlin](https://kotlinlang.org). +* Adobe for making the XMP Core Java SDK. +* Paul de Vrieze for making [XmlUtil](https://github.com/pdvrieze/xmlutil) + +## License + +The same [BSD license](original_source/original_license.txt) applies to this project as to Adobe's open source XMP SDK, +from which it is derived. + +Note: The original license page went offline, but you can still find it on +[archive.org](https://web.archive.org/web/20210616112605/https://www.adobe.com/devnet/xmp/library/eula-xmp-library-java.html). diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..e2be98e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,423 @@ +import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + +plugins { + kotlin("multiplatform") version "1.8.20" + id("com.android.library") version "7.4.2" + id("maven-publish") + id("signing") + id("io.gitlab.arturbosch.detekt") version "1.22.0" + id("org.sonarqube") version "4.0.0.2929" + id("org.jetbrains.kotlinx.kover") version "0.6.1" + id("com.asarkar.gradle.build-time-tracker") version "4.3.0" + id("me.qoomon.git-versioning") version "6.4.1" + id("com.goncalossilva.resources") version "0.3.2" +} + +repositories { + google() + mavenCentral() +} + +val productName = "Ashampoo XMP Core" + +val ktorVersion: String = "2.3.2" +val xmlUtilVersion: String = "0.86.1" + +description = productName +group = "com.ashampoo" +version = "0.0.0" + +gitVersioning.apply { + + refs { + /* Main branch contains the current dev version */ + branch("main") { + version = "\${commit.short}" + } + /* Release / tags have real version numbers */ + tag("v(?.*)") { + version = "\${ref.version}" + } + } + + /* Fallback if branch was not found (for feature branches) */ + rev { + version = "\${commit.short}" + } +} + +apply(plugin = "io.gitlab.arturbosch.detekt") +apply(plugin = "org.sonarqube") +apply(plugin = "kover") + +buildTimeTracker { + sortBy.set(com.asarkar.gradle.buildtimetracker.Sort.DESC) +} + +sonar { + properties { + + property("sonar.projectKey", "xmpcore") + property("sonar.projectName", productName) + property("sonar.organization", "realashampoo") + property("sonar.host.url", "https://sonarcloud.io") + + property( + "sonar.sources", + listOf( + "./src/commonMain/kotlin", + "./src/posixMain/kotlin" + ) + ) + property( + "sonar.tests", + listOf( + "./src/commonTest/kotlin" + ) + ) + + /* Include Android Lint */ + property("sonar.android.lint.report", "${project.buildDir}/reports/lint-results.xml") + + /* Include Detekt issues */ + val detektPath = "${project.buildDir}/reports/detekt/detekt.xml" + println("Detekt report: $detektPath") + property("sonar.kotlin.detekt.reportPaths", detektPath) + + /* Include Kover code coverage */ + val koverPath = "${project.buildDir}/reports/kover/xml/report.xml" + println("Kover report: $koverPath") + property("sonar.coverage.jacoco.xmlReportPaths", koverPath) + } +} + +detekt { + source = files("src", "build.gradle.kts") + allRules = true + config = files("detekt.yml") + parallel = true + ignoreFailures = true + autoCorrect = true +} + +kover { +} + +koverMerged { + xmlReport { + } +} + +dependencies { + detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.22.0") +} + +kotlin { + + android { + + compilations.all { + kotlinOptions { + jvmTarget = "11" + } + } + + publishLibraryVariants("release") + } + + mingwX64("win") { + binaries { + executable( + buildTypes = setOf(NativeBuildType.RELEASE) + ) + } + } + + jvm { + + java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } + + @Suppress("UnusedPrivateMember") // False positive + val commonMain by sourceSets.getting { + + dependencies { + + /* Needed for Charset class. */ + /* Defined as api() to prevent problems when used from a pure-java project. */ + api("io.ktor:ktor-io:$ktorVersion") + + /* Needed to parse XML and create a DOM Document */ + api("io.github.pdvrieze.xmlutil:core:$xmlUtilVersion") + api("io.github.pdvrieze.xmlutil:serialization:$xmlUtilVersion") + } + } + + @Suppress("UnusedPrivateMember", "UNUSED_VARIABLE") // False positive + val commonTest by sourceSets.getting { + dependencies { + + /* Kotlin Test */ + implementation(kotlin("test")) + + /* Multiplatform test resources */ + implementation("com.goncalossilva:resources:0.3.2") + + /* Multiplatform file access */ + implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.2.0") + } + } + + @Suppress("UnusedPrivateMember", "UNUSED_VARIABLE") // False positive + val jvmTest by sourceSets.getting { + dependencies { + implementation(kotlin("test-junit")) + } + } + + val xcf = XCFramework() + + listOf( + /* App Store */ + iosArm64(), + /* Apple Silicon iOS Simulator */ + iosSimulatorArm64(), + /* macOS Devices */ + macosX64(), + macosArm64() + ).forEach { + + it.binaries.executable( + buildTypes = setOf(NativeBuildType.RELEASE) + ) { + baseName = "xmpcore" + } + + it.binaries.framework( + buildTypes = setOf(NativeBuildType.RELEASE) + ) { + baseName = "xmpcore" + /* Part of the XCFramework */ + xcf.add(this) + } + } + + // See https://youtrack.jetbrains.com/issue/KT-55751 + val myAttribute = Attribute.of("KT-55751", String::class.java) + + // replace releaseFrameworkIosFat by the name of the first configuration that conflicts + configurations.named("releaseFrameworkIosFat").configure { + attributes { + // put a unique attribute + attribute(myAttribute, "release-all") + } + } + + // replace releaseFrameworkOsxFat by the name of the first configuration that conflicts + configurations.named("releaseFrameworkOsxFat").configure { + attributes { + // put a unique attribute + attribute(myAttribute, "release-all") + } + } + + val jvmMain by sourceSets.getting + + @Suppress("UnusedPrivateMember", "UNUSED_VARIABLE") // False positive + val androidMain by sourceSets.getting { + dependsOn(jvmMain) + } + + val posixMain by sourceSets.creating { + dependsOn(commonMain) + } + + @Suppress("UnusedPrivateMember", "UNUSED_VARIABLE") // False positive + val winMain by sourceSets.getting { + dependsOn(posixMain) + } + + val iosArm64Main by sourceSets.getting + val iosSimulatorArm64Main by sourceSets.getting + val macosX64Main by sourceSets.getting + val macosArm64Main by sourceSets.getting + + @Suppress("UnusedPrivateMember", "UNUSED_VARIABLE") // False positive + val appleMain by sourceSets.creating { + + dependsOn(commonMain) + dependsOn(posixMain) + + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + macosX64Main.dependsOn(this) + macosArm64Main.dependsOn(this) + } +} + +// region Writing version.txt for GitHub Actions +val writeVersion = tasks.register("writeVersion") { + doLast { + File("build/version.txt").writeText(project.version.toString()) + } +} + +tasks.getByPath("build").finalizedBy(writeVersion) +// endregion + +// region Android setup +android { + + namespace = "com.ashampoo.xmpcore" + + compileSdk = 33 + + sourceSets["main"].res.srcDirs("src/commonMain/resources") + + defaultConfig { + minSdk = 23 + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(11) + targetCompatibility = JavaVersion.toVersion(11) + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } +} +// endregion + +// region Maven publish + +ext["signing.keyId"] = System.getenv("SIGNING_KEY_ID") +ext["signing.password"] = System.getenv("SIGNING_PASSWORD") +ext["signing.secretKeyRingFile"] = "secring.pgp" +ext["ossrhUsername"] = System.getenv("OSSRH_USERNAME") +ext["ossrhPassword"] = System.getenv("OSSRH_PASSWORD") + +val javadocJar by tasks.registering(Jar::class) { + archiveClassifier.set("javadoc") +} + +val signingEnabled: Boolean = System.getenv("SIGNING_ENABLED")?.toBoolean() ?: false + +afterEvaluate { + + if (signingEnabled) { + + /* + * Explicitly configure that signing comes before publishing. + * Otherwise the task execution of "publishAllPublicationsToSonatypeRepository" will fail. + */ + + val signJvmPublication by tasks.getting + val signAndroidReleasePublication by tasks.getting + val signIosArm64Publication by tasks.getting + val signIosSimulatorArm64Publication by tasks.getting + val signMacosArm64Publication by tasks.getting + val signMacosX64Publication by tasks.getting + val signWinPublication by tasks.getting + val signKotlinMultiplatformPublication by tasks.getting + + val publishJvmPublicationToSonatypeRepository by tasks.getting + val publishAndroidReleasePublicationToSonatypeRepository by tasks.getting + val publishIosArm64PublicationToSonatypeRepository by tasks.getting + val publishIosSimulatorArm64PublicationToSonatypeRepository by tasks.getting + val publishMacosArm64PublicationToSonatypeRepository by tasks.getting + val publishMacosX64PublicationToSonatypeRepository by tasks.getting + val publishWinPublicationToSonatypeRepository by tasks.getting + val publishKotlinMultiplatformPublicationToSonatypeRepository by tasks.getting + val publishAllPublicationsToSonatypeRepository by tasks.getting + + val signTasks = listOf( + signJvmPublication, signAndroidReleasePublication, + signIosArm64Publication, signIosSimulatorArm64Publication, + signMacosArm64Publication, signMacosX64Publication, + signWinPublication, signKotlinMultiplatformPublication + ) + + val publishTasks = listOf( + publishJvmPublicationToSonatypeRepository, + publishAndroidReleasePublicationToSonatypeRepository, + publishIosArm64PublicationToSonatypeRepository, + publishIosSimulatorArm64PublicationToSonatypeRepository, + publishMacosArm64PublicationToSonatypeRepository, + publishMacosX64PublicationToSonatypeRepository, + publishWinPublicationToSonatypeRepository, + publishKotlinMultiplatformPublicationToSonatypeRepository, + publishAllPublicationsToSonatypeRepository + ) + + /* Each publish task depenends on every sign task. */ + for (publishTask in publishTasks) + for (signTask in signTasks) + publishTask.dependsOn(signTask) + } +} + +fun getExtraString(name: String) = ext[name]?.toString() + +publishing { + publications { + + // Configure maven central repository + repositories { + maven { + name = "sonatype" + setUrl("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") + credentials { + username = getExtraString("ossrhUsername") + password = getExtraString("ossrhPassword") + } + } + } + + publications.withType { + + artifact(javadocJar.get()) + + pom { + + name.set(productName) + description.set("XMP Core for Kotlin Multiplatform") + url.set("https://github.com/Ashampoo/xmpcore") + + licenses { + license { + name.set("The BSD License") + url.set("http://www.adobe.com/devnet/xmp/library/eula-xmp-library-java.html") + } + } + + developers { + developer { + name.set("Ashampoo GmbH & Co. KG") + url.set("https://www.ashampoo.com/") + } + } + + scm { + connection.set("https://github.com/Ashampoo/xmpcore.git") + url.set("https://github.com/Ashampoo/xmpcore") + } + } + } + + if (signingEnabled) { + + signing { + sign(publishing.publications) + } + } + } +} +// endregion diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 0000000..ed51aef --- /dev/null +++ b/detekt.yml @@ -0,0 +1,931 @@ +# This file is based on the default rules and modified +# https://github.com/detekt/detekt/blob/main/detekt-core/src/main/resources/default-detekt-config.yml +# https://github.com/detekt/detekt/blob/main/detekt-formatting/src/main/resources/config/config.yml + +build: + maxIssues: 0 + excludeCorrectable: false + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + warningsAsErrors: false + checkExhaustiveness: true + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + # - 'KtFileCountProcessor' + # - 'PackageCountProcessor' + # - 'ClassCountProcessor' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ProjectComplexityProcessor' + # - 'ProjectCognitiveComplexityProcessor' + # - 'ProjectLLOCProcessor' + # - 'ProjectCLOCProcessor' + # - 'ProjectLOCProcessor' + # - 'ProjectSLOCProcessor' + # - 'LicenseHeaderLoaderExtension' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'LiteFindingsReport' + +output-reports: + active: true + exclude: + # - 'TxtOutputReport' + # - 'XmlOutputReport' + # - 'HtmlOutputReport' + # - 'MdOutputReport' + # - 'SarifOutputReport' + +comments: + active: false + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: false + CommentOverPrivateFunction: + active: false # not our policy + CommentOverPrivateProperty: + active: false # not our policy + DeprecatedBlockTag: + active: true + EndOfSentenceFormat: + active: false # not important for us + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: false # not our policy + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + OutdatedDocumentation: + active: true + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: false # not our policy + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + searchInProtectedClass: false + UndocumentedPublicFunction: + active: false # not our policy + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + searchProtectedFunction: false + UndocumentedPublicProperty: + active: false # not our policy + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + searchProtectedProperty: false + +complexity: + active: true + CognitiveComplexMethod: + active: false # already checked by SonarQube + threshold: 15 + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: true + threshold: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ignoreOverloaded: false + CyclomaticComplexMethod: + active: false # already checked by SonarQube + threshold: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false # hard to replace + ignoredLabels: [ ] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 100 # the default of 60 is quite short for Jetpack Compose + LongParameterList: + active: false # already checked by SonarQube + functionThreshold: 7 # 7 is SonarQube default + constructorThreshold: 7 + ignoreDefaultParameters: false + ignoreDataClasses: true + ignoreAnnotatedParameter: [ ] + MethodOverloading: + active: true + threshold: 6 + NamedArguments: + active: true + threshold: 3 + ignoreArgumentsMatchingNames: false + NestedBlockDepth: + active: true + threshold: 4 + NestedScopeFunctions: + active: true + threshold: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: true + StringLiteralDuplication: + active: true + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + thresholdInFiles: 11 + thresholdInClasses: 11 + thresholdInInterfaces: 11 + thresholdInObjects: 11 + thresholdInEnums: 11 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: true + InjectDispatcher: + active: true + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunWithCoroutineScopeReceiver: + active: true + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: true + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + NotImplementedDeclaration: + active: true + ObjectExtendsThrowable: + active: true + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: true + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: false # Sometimes this is just what we want to do. + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: true + allowedPattern: '^(is|has|are)' + ignoreOverridden: true + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: true + forbiddenName: [ ] + FunctionMaxLength: + active: false # not useful + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false # not useful + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + functionPattern: '[a-z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + ignoreAnnotated: [ 'Composable' ] + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + InvalidPackageDeclaration: + active: true + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: true + mustBeFirst: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: true + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: true + maximumVariableNameLength: 64 + VariableMinLength: + active: true + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: true + threshold: 3 + ForEachOnRange: + active: true + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + SpreadOperator: + active: true + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + UnnecessaryPartOfBinaryExpression: + active: true + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastToNullableType: + active: true + Deprecation: + active: true + DontDowncastCollectionTypes: + active: true + DoubleMutabilityForCollection: + active: true + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - '*.CheckResult' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - '*.CanIgnoreReturnValue' + returnValueTypes: + - 'kotlin.sequences.Sequence' + - 'kotlinx.coroutines.flow.*Flow' + - 'java.util.stream.*Stream' + ignoreFunctionCall: [ ] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: true + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: true + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: true + excludes: [ '**/*.kts', '**/main.kt' ] + NullCheckOnMutableProperty: + active: true + NullableToStringCall: + active: true + UnconditionalJumpStatementInLoop: + active: true + UnnecessaryNotNullCheck: + active: true + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: [ '**/commonTest/**', '**/jvmTest/**' ] + UnsafeCast: + active: true + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AlsoCouldBeApply: + active: true + CanBeNonNullable: + active: true + CascadingCallWrapping: + active: true + includeElvis: true + ClassOrdering: + active: true + CollapsibleIfStatements: + active: true + DataClassContainsFunctions: + active: false # allowed, be careful + conversionFunctionPrefix: + - 'to' + DataClassShouldBeImmutable: + active: true + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: true + ExplicitCollectionElementAccessMethod: + active: true + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: true + includeLineWrapping: false + ForbiddenComment: + active: true + values: + - 'FIXME:' + - 'STOPSHIP:' + - 'TODO:' + allowedPatterns: '' + customMessage: '' + ForbiddenImport: + active: true + imports: [ ] + forbiddenPatterns: '' + ForbiddenMethodCall: + active: true + methods: + - reason: 'print does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.print' + - reason: 'println does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.println' + ForbiddenSuppress: + active: true + rules: [ ] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [ ] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: [ '**/commonTest/**', '**/jvmTest/**', '**/*.kts' ] + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesIfStatements: + active: false # not our policy + MandatoryBracesLoops: + active: false # not our policy + MaxChainedCallsOnSameLine: + active: true + maxChainedCalls: 5 + MaxLineLength: + active: true + maxLineLength: 110 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludeRawStrings: true + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: true + MultilineRawStringIndentation: + active: true + indentSize: 4 + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: true + NullableBooleanCheck: + active: true + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false # It's recommended to specify explicitly public & protected declaration types + OptionalWhenBraces: + active: false # should be decided by the dev if it's needed on multiline expressions + PreferToOverPairSyntax: + active: true + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: true + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: true + ReturnCount: + active: false # not our policy since we don't like nesting + max: 2 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: true + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: true + TrimMultilineRawString: + active: true + UnderscoresInNumericLiterals: + active: true + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: true + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: true + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: true + UnnecessaryLet: + active: true + UnnecessaryParentheses: + active: true + allowForUnclearPrecedence: false + UntilInsteadOfRangeTo: + active: true + UnusedImports: + active: true + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: '(_|ignored|expected|serialVersionUID)' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: true + allowVars: false + UseEmptyCounterpart: + active: true + UseIfEmptyOrIfBlank: + active: true + UseIfInsteadOfWhen: + active: true + UseIsNullOrEmpty: + active: true + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: true + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + ignoreLateinitVar: false + WildcardImport: + active: true + excludeImports: + - 'java.util.*' + +formatting: + active: true + android: false + autoCorrect: true + AnnotationOnSeparateLine: + active: true + autoCorrect: true + AnnotationSpacing: + active: true + autoCorrect: true + ArgumentListWrapping: + active: false # strange results sometimes + autoCorrect: true + indentSize: 4 + maxLineLength: 110 + BlockCommentInitialStarAlignment: + active: true + autoCorrect: true + ChainWrapping: + active: true + autoCorrect: true + CommentSpacing: + active: true + autoCorrect: true + CommentWrapping: + active: true + autoCorrect: true + indentSize: 4 + DiscouragedCommentLocation: + active: true + autoCorrect: true + EnumEntryNameCase: + active: true + autoCorrect: true + Filename: + active: false # looks odd, not sure about this rule + FinalNewline: + active: true + autoCorrect: true + insertFinalNewLine: true + FunKeywordSpacing: + active: true + autoCorrect: true + FunctionReturnTypeSpacing: + active: true + autoCorrect: true + FunctionSignature: + active: false # has some false positives in Detekt 0.22 + autoCorrect: true + forceMultilineWhenParameterCountGreaterOrEqualThan: 3 + functionBodyExpressionWrapping: 'default' + maxLineLength: 80 + indentSize: 4 + FunctionStartOfBodySpacing: + active: true + autoCorrect: true + FunctionTypeReferenceSpacing: + active: true + autoCorrect: true + ImportOrdering: + active: true + autoCorrect: true + layout: '*,java.**,javax.**,kotlin.**,^' + Indentation: + active: true + autoCorrect: true + indentSize: 4 + KdocWrapping: + active: true + autoCorrect: true + indentSize: 4 + MaximumLineLength: + active: true + maxLineLength: 110 + ignoreBackTickedIdentifier: false + ModifierListSpacing: + active: true + autoCorrect: true + ModifierOrdering: + active: true + autoCorrect: true + MultiLineIfElse: + active: false # not our policy + autoCorrect: true + NoBlankLineBeforeRbrace: + active: false # bad style + autoCorrect: true + NoBlankLinesInChainedMethodCalls: + active: true + autoCorrect: true + NoConsecutiveBlankLines: + active: true + autoCorrect: true + NoEmptyClassBody: + active: true + autoCorrect: true + NoEmptyFirstLineInMethodBlock: + active: false # bad style + autoCorrect: true + NoLineBreakAfterElse: + active: true + autoCorrect: true + NoLineBreakBeforeAssignment: + active: true + autoCorrect: true + NoMultipleSpaces: + active: true + autoCorrect: true + NoSemicolons: + active: true + autoCorrect: true + NoTrailingSpaces: + active: true + autoCorrect: true + NoUnitReturn: + active: true + autoCorrect: true + NoUnusedImports: + active: true + autoCorrect: true + NoWildcardImports: + active: true + packagesToUseImportOnDemandProperty: 'java.util.*,kotlinx.android.synthetic.**' + NullableTypeSpacing: + active: true + autoCorrect: true + PackageName: + active: true + autoCorrect: true + ParameterListSpacing: + active: true + autoCorrect: true + ParameterListWrapping: + active: true + autoCorrect: true + maxLineLength: 110 + SpacingAroundAngleBrackets: + active: true + autoCorrect: true + SpacingAroundColon: + active: true + autoCorrect: true + SpacingAroundComma: + active: true + autoCorrect: true + SpacingAroundCurly: + active: true + autoCorrect: true + SpacingAroundDot: + active: true + autoCorrect: true + SpacingAroundDoubleColon: + active: true + autoCorrect: true + SpacingAroundKeyword: + active: true + autoCorrect: true + SpacingAroundOperators: + active: true + autoCorrect: true + SpacingAroundParens: + active: true + autoCorrect: true + SpacingAroundRangeOperator: + active: true + autoCorrect: true + SpacingAroundUnaryOperator: + active: true + autoCorrect: true + SpacingBetweenDeclarationsWithAnnotations: + active: true + autoCorrect: true + SpacingBetweenDeclarationsWithComments: + active: true + autoCorrect: true + SpacingBetweenFunctionNameAndOpeningParenthesis: + active: true + autoCorrect: true + StringTemplate: + active: true + autoCorrect: true + TrailingCommaOnCallSite: + active: false # looks ugly + autoCorrect: true + useTrailingCommaOnCallSite: true + TrailingCommaOnDeclarationSite: + active: false # looks ugly + autoCorrect: true + useTrailingCommaOnDeclarationSite: true + TypeArgumentListSpacing: + active: true + autoCorrect: true + TypeParameterListSpacing: + active: true + autoCorrect: true + UnnecessaryParenthesesBeforeTrailingLambda: + active: true + autoCorrect: true + Wrapping: + active: true + autoCorrect: true + indentSize: 4 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4831294 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,8 @@ +android.useAndroidX=true +kotlin.code.style=official +kotlin.mpp.androidSourceSetLayoutVersion=2 +kotlin.mpp.enableCInteropCommonization=true +kotlin.mpp.stability.nowarn=true +org.gradle.caching=true +org.gradle.jvmargs=-Xmx4g -Dkotlin.daemon.jvm.options\="-Xmx4g" +org.gradle.parallel=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37aef8d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..aeb74cb --- /dev/null +++ b/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/original_source/XMPCoreJava-5.1.3.zip b/original_source/XMPCoreJava-5.1.3.zip new file mode 100644 index 0000000..3306412 Binary files /dev/null and b/original_source/XMPCoreJava-5.1.3.zip differ diff --git a/original_source/original_license.png b/original_source/original_license.png new file mode 100644 index 0000000..598ae00 Binary files /dev/null and b/original_source/original_license.png differ diff --git a/original_source/original_license.txt b/original_source/original_license.txt new file mode 100644 index 0000000..615718d --- /dev/null +++ b/original_source/original_license.txt @@ -0,0 +1,27 @@ +Copyright (c) 2009, Adobe Systems Incorporated +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of Adobe Systems Incorporated, nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANT ABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..3cd6dd3 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,9 @@ +rootProject.name = "xmpcore" + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPConst.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPConst.kt new file mode 100644 index 0000000..043e808 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPConst.kt @@ -0,0 +1,287 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp + +/** + * Common constants for the XMP Toolkit. + */ +object XMPConst { + + /** + * The XML namespace for XML. + */ + const val NS_XML: String = "http://www.w3.org/XML/1998/namespace" + + /** + * The XML namespace for RDF. + */ + const val NS_RDF: String = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + + /** + * The XML namespace for the Dublin Core schema. + */ + const val NS_DC: String = "http://purl.org/dc/elements/1.1/" + + /** + * The XML namespace for the IPTC Core schema. + */ + const val NS_IPTCCORE: String = "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/" + + /** + * The XML namespace for the IPTC Extension schema. + */ + const val NS_IPTCEXT: String = "http://iptc.org/std/Iptc4xmpExt/2008-02-29/" + + /** + * The XML namespace for the DICOM medical schema. + */ + const val NS_DICOM: String = "http://ns.adobe.com/DICOM/" + + /** + * The XML namespace for the PLUS (Picture Licensing Universal System, http://www.useplus.org) + */ + const val NS_PLUS: String = "http://ns.useplus.org/ldf/xmp/1.0/" + + const val NS_MWG_RS: String = "http://www.metadataworkinggroup.com/schemas/regions/" + + const val NS_ACDSEE: String = "http://ns.acdsee.com/iptc/1.0/" + + // Adobe standard namespaces + + /** + * The XML namespace Adobe XMP Metadata. + */ + const val NS_X: String = "adobe:ns:meta/" + + const val NS_IX: String = "http://ns.adobe.com/iX/1.0/" + + /** + * The XML namespace for the XMP "basic" schema. + */ + const val NS_XMP: String = "http://ns.adobe.com/xap/1.0/" + + /** + * The XML namespace for the XMP copyright schema. + */ + const val NS_XMP_RIGHTS: String = "http://ns.adobe.com/xap/1.0/rights/" + + /** + * The XML namespace for the XMP digital asset management schema. + */ + const val NS_XMP_MM: String = "http://ns.adobe.com/xap/1.0/mm/" + + /** + * The XML namespace for the job management schema. + */ + const val NS_XMP_BJ: String = "http://ns.adobe.com/xap/1.0/bj/" + + /** + * The XML namespace for the job management schema. + */ + const val NS_XMP_NOTE: String = "http://ns.adobe.com/xmp/note/" + + /** + * The XML namespace for the PDF schema. + */ + const val NS_PDF: String = "http://ns.adobe.com/pdf/1.3/" + + /** + * The XML namespace for the PDF schema. + */ + const val NS_PDFX: String = "http://ns.adobe.com/pdfx/1.3/" + + const val NS_PDFX_ID: String = "http://www.npes.org/pdfx/ns/id/" + + const val NS_PDFA_SCHEMA: String = "http://www.aiim.org/pdfa/ns/schema#" + + const val NS_PDFA_PROPERTY: String = "http://www.aiim.org/pdfa/ns/property#" + + const val NS_PDFA_TYPE: String = "http://www.aiim.org/pdfa/ns/type#" + + const val NS_PDFA_FIELD: String = "http://www.aiim.org/pdfa/ns/field#" + + const val NS_PDFA_ID: String = "http://www.aiim.org/pdfa/ns/id/" + + const val NS_PDFA_EXTENSION: String = "http://www.aiim.org/pdfa/ns/extension/" + + /** + * The XML namespace for the Photoshop custom schema. + */ + const val NS_PHOTOSHOP: String = "http://ns.adobe.com/photoshop/1.0/" + + /** + * The XML namespace for the Photoshop Album schema. + */ + const val NS_PSALBUM: String = "http://ns.adobe.com/album/1.0/" + + /** + * The XML namespace for Adobe's EXIF schema. + */ + const val NS_EXIF: String = "http://ns.adobe.com/exif/1.0/" + + /** + * NS for the CIPA XMP for Exif document v1.1 + */ + const val NS_EXIFX: String = "http://cipa.jp/exif/1.0/" + + const val NS_EXIF_AUX: String = "http://ns.adobe.com/exif/1.0/aux/" + + const val NS_TIFF: String = "http://ns.adobe.com/tiff/1.0/" + + const val NS_PNG: String = "http://ns.adobe.com/png/1.0/" + + const val NS_JPEG: String = "http://ns.adobe.com/jpeg/1.0/" + + const val NS_JP2K: String = "http://ns.adobe.com/jp2k/1.0/" + + const val NS_CAMERARAW: String = "http://ns.adobe.com/camera-raw-settings/1.0/" + + const val NS_ADOBESTOCKPHOTO: String = "http://ns.adobe.com/StockPhoto/1.0/" + + const val NS_CREATOR_ATOM: String = "http://ns.adobe.com/creatorAtom/1.0/" + + const val NS_ASF: String = "http://ns.adobe.com/asf/1.0/" + + const val NS_WAV: String = "http://ns.adobe.com/xmp/wav/1.0/" + + /** + * BExt Schema + */ + const val NS_BWF: String = "http://ns.adobe.com/bwf/bext/1.0/" + + /** + * RIFF Info Schema + */ + const val NS_RIFFINFO: String = "http://ns.adobe.com/riff/info/" + + const val NS_SCRIPT: String = "http://ns.adobe.com/xmp/1.0/Script/" + + /** + * Transform XMP + */ + const val NS_TXMP: String = "http://ns.adobe.com/TransformXMP/" + + /** + * Adobe Flash SWF + */ + const val NS_SWF: String = "http://ns.adobe.com/swf/1.0/" + + // XMP namespaces that are Adobe private + + const val NS_DM: String = "http://ns.adobe.com/xmp/1.0/DynamicMedia/" + + const val NS_TRANSIENT: String = "http://ns.adobe.com/xmp/transient/1.0/" + + /** + * legacy Dublin Core NS, will be converted to NS_DC + */ + const val NS_DC_DEPRECATED: String = "http://purl.org/dc/1.1/" + + // XML namespace constants for qualifiers and structured property fields. + + /** + * The XML namespace for qualifiers of the xmp:Identifier property. + */ + const val TYPE_IDENTIFIERQUAL: String = "http://ns.adobe.com/xmp/Identifier/qual/1.0/" + + /** + * The XML namespace for fields of the Dimensions type. + */ + const val TYPE_DIMENSIONS: String = "http://ns.adobe.com/xap/1.0/sType/Dimensions#" + + const val TYPE_TEXT: String = "http://ns.adobe.com/xap/1.0/t/" + + const val TYPE_PAGEDFILE: String = "http://ns.adobe.com/xap/1.0/t/pg/" + + const val TYPE_GRAPHICS: String = "http://ns.adobe.com/xap/1.0/g/" + + /** + * The XML namespace for fields of a graphical image. Used for the Thumbnail type. + */ + const val TYPE_IMAGE: String = "http://ns.adobe.com/xap/1.0/g/img/" + + const val TYPE_FONT: String = "http://ns.adobe.com/xap/1.0/sType/Font#" + + /** + * The XML namespace for fields of the ResourceEvent type. + */ + const val TYPE_RESOURCEEVENT: String = "http://ns.adobe.com/xap/1.0/sType/ResourceEvent#" + + /** + * The XML namespace for fields of the ResourceRef type. + */ + const val TYPE_RESOURCEREF: String = "http://ns.adobe.com/xap/1.0/sType/ResourceRef#" + + /** + * The XML namespace for fields of the Version type. + */ + const val TYPE_ST_VERSION: String = "http://ns.adobe.com/xap/1.0/sType/Version#" + + /** + * The XML namespace for fields of the JobRef type. + */ + const val TYPE_ST_JOB: String = "http://ns.adobe.com/xap/1.0/sType/Job#" + + const val TYPE_MANIFESTITEM: String = "http://ns.adobe.com/xap/1.0/sType/ManifestItem#" + + // --------------------------------------------------------------------------------------------- + // Basic types and constants + + /** + * The canonical true string value for Booleans in serialized XMP. Code that converts from the + * string to a bool should be case insensitive, and even allow "1". + */ + const val TRUESTR: String = "True" + + /** + * The canonical false string value for Booleans in serialized XMP. Code that converts from the + * string to a bool should be case insensitive, and even allow "0". + */ + const val FALSESTR: String = "False" + + /** + * Index that has the meaning to be always the last item in an array. + */ + const val ARRAY_LAST_ITEM: Int = -1 + + /** + * Node name of an array item. + */ + const val ARRAY_ITEM_NAME: String = "[]" + + /** + * The x-default string for localized properties + */ + const val X_DEFAULT: String = "x-default" + + /** + * xml:lang qualfifier + */ + const val XML_LANG: String = "xml:lang" + + /** + * rdf:type qualfifier + */ + const val RDF_TYPE: String = "rdf:type" + + /** + * Processing Instruction (PI) for xmp packet + */ + const val XMP_PI: String = "xpacket" + + /** + * XMP meta tag version new + */ + const val TAG_XMPMETA: String = "xmpmeta" + + /** + * XMP meta tag version old + */ + const val TAG_XAPMETA: String = "xapmeta" +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPError.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPError.kt new file mode 100644 index 0000000..e61bfc3 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPError.kt @@ -0,0 +1,28 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp + +interface XMPError { + + companion object { + const val UNKNOWN: Int = 0 + const val BADPARAM: Int = 4 + const val BADVALUE: Int = 5 + const val INTERNALFAILURE: Int = 9 + const val BADSCHEMA: Int = 101 + const val BADXPATH: Int = 102 + const val BADOPTIONS: Int = 103 + const val BADINDEX: Int = 104 + const val BADSERIALIZE: Int = 107 + const val BADXML: Int = 201 + const val BADRDF: Int = 202 + const val BADXMP: Int = 203 + const val BADSTREAM: Int = 204 + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPException.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPException.kt new file mode 100644 index 0000000..c2542e3 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPException.kt @@ -0,0 +1,18 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp + +/** + * This exception wraps all errors that occur in the XMP Toolkit. + */ +class XMPException( + message: String, + val errorCode: Int, + cause: Throwable? = null +) : Exception(message, cause) diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPIterator.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPIterator.kt new file mode 100644 index 0000000..d3959e2 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPIterator.kt @@ -0,0 +1,77 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp + +import com.ashampoo.xmp.properties.XMPPropertyInfo + +/** + * Interface for the `XMPMeta` iteration services. + * `XMPIterator` provides a uniform means to iterate over the + * schema and properties within an XMP object. + * + * The iteration over the schema and properties within an XMP object is very + * complex. It is helpful to have a thorough understanding of the XMP data tree. + * One way to learn this is to create some complex XMP and examine the output of + * `XMPMeta#toString`. This is also described in the XMP + * Specification, in the XMP Data Model chapter. + * + * The top of the XMP data tree is a single root node. This does not explicitly + * appear in the dump and is never visited by an iterator (that is, it is never + * returned from `XMPIterator#next()`). Beneath the root are + * schema nodes. These are just collectors for top level properties in the same + * namespace. They are created and destroyed implicitly. Beneath the schema + * nodes are the property nodes. The nodes below a property node depend on its + * type (simple, struct, or array) and whether it has qualifiers. + * + * An `XMPIterator` is created by XMPMeta#interator() constructor + * defines a starting point for the iteration and options that control how it + * proceeds. By default the iteration starts at the root and visits all nodes + * beneath it in a depth first manner. The root node is not visited, the first + * visited node is a schema node. You can provide a schema name or property path + * to select a different starting node. By default this visits the named root + * node first then all nodes beneath it in a depth first manner. + * + * The `XMPIterator#next()` method delivers the schema URI, path, + * and option flags for the node being visited. If the node is simple it also + * delivers the value. Qualifiers for this node are visited next. The fields of + * a struct or items of an array are visited after the qualifiers of the parent. + * + * The options to control the iteration are: + * + * * JUST_CHILDREN - Visit just the immediate children of the root. Skip + * the root itself and all nodes below the immediate children. This omits the + * qualifiers of the immediate children, the qualifier nodes being below what + * they qualify, default is to visit the complete subtree. + * * JUST_LEAFNODES - Visit just the leaf property nodes and their + * qualifiers. + * * JUST_LEAFNAME - Return just the leaf component of the node names. + * The default is to return the full xmp path. + * * OMIT_QUALIFIERS - Do not visit the qualifiers. + * * INCLUDE_ALIASES - Adds known alias properties to the properties in the iteration. + * *Note:* Not supported in Java XMPCore! + * + * `next()` returns `XMPPropertyInfo`-objects and throws + * a `NoSuchElementException` if there are no more properties to + * return. + */ +interface XMPIterator : Iterator { + + /** + * Skip the subtree below the current node when `next()` is + * called. + */ + fun skipSubtree() + + /** + * Skip the subtree below and remaining siblings of the current node when + * `next()` is called. + */ + fun skipSiblings() + +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPMeta.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPMeta.kt new file mode 100644 index 0000000..4694f40 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPMeta.kt @@ -0,0 +1,766 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp + +import com.ashampoo.xmp.options.IteratorOptions +import com.ashampoo.xmp.options.ParseOptions +import com.ashampoo.xmp.options.PropertyOptions +import com.ashampoo.xmp.properties.XMPProperty + +/** + * This class represents the set of XMP metadata as a DOM representation. It has methods to read and + * modify all kinds of properties, create an iterator over all properties and serialize the metadata + * to a String, byte-array or `OutputStream`. + */ +@Suppress("ComplexInterface", "TooManyFunctions") +interface XMPMeta { + + // --------------------------------------------------------------------------------------------- + // Basic property manipulation functions + + /** + * The property value getter-methods all take a property specification: the first two parameters + * are always the top level namespace URI (the "schema" namespace) and the basic name + * of the property being referenced. See the introductory discussion of path expression usage + * for more information. + * + * All the functions return an object inherited from `PropertyBase` or + * `null` if the property does not exist. The result object contains the value of + * the property and option flags describing the property. Arrays and the non-leaf levels of + * nodes do not have values. + * + * See [PropertyOptions] for detailed information about the options. + * + * This is the simplest property getter, mainly for top level simple properties or after using + * the path composition functions in XMPPathFactory. + * + * @param schemaNS The namespace URI for the property. + * The URI must be for a registered namespace. + * @param propName The name of the property. May be a general path expression, + * must not be `null` or the empty string. + * Using a namespace prefix on the first component is optional. + * If present without a schemaNS value then the prefix specifies the namespace. + * The prefix must be for a registered namespace. + * If both a schemaNS URI and propName prefix are present, + * they must be corresponding parts of a registered namespace. + * @return Returns a `XMPProperty` containing the value and the options or `null` + * if the property does not exist. + */ + fun getProperty(schemaNS: String, propName: String): XMPProperty? + + /** + * Provides access to items within an array. The index is passed as an integer, you need not + * worry about the path string syntax for array items, convert a loop index to a string, etc. + * + * @param schemaNS The namespace URI for the array. Has the same usage as in `getProperty()`. + * @param arrayName The name of the array. May be a general path expression, + * must not be `null` or the empty string. + * Has the same namespace prefix usage as propName in `getProperty()`. + * @param itemIndex The index of the desired item. Arrays in XMP are indexed from 1. + * The constant [XMPConst.ARRAY_LAST_ITEM] always refers to the last + * existing array item. + * @return Returns a `XMPProperty` containing the value and the options or + * `null` if the property does not exist. + */ + fun getArrayItem(schemaNS: String, arrayName: String, itemIndex: Int): XMPProperty? + + /** + * Returns the number of items in the array. + * + * @param schemaNS The namespace URI for the array. Has the same usage as in getProperty. + * @param arrayName The name of the array. May be a general path expression, + * must not be `null` or the empty string. + * Has the same namespace prefix usage as propName in `getProperty()`. + * @return Returns the number of items in the array. + */ + fun countArrayItems(schemaNS: String, arrayName: String): Int + + /** + * Provides access to fields within a nested structure. The namespace for the field is passed as + * a URI, you need not worry about the path string syntax. + * + * The names of fields should be XML qualified names, that is within an XML namespace. The path + * syntax for a qualified name uses the namespace prefix. This is unreliable since the prefix is + * never guaranteed. The URI is the formal name, the prefix is just a local shorthand in a given + * sequence of XML text. + * + * @param schemaNS The namespace URI for the struct. Has the same usage as in getProperty. + * @param structName The name of the struct. + * May be a general path expression, must not be `null` or the empty string. + * Has the same namespace prefix usage as propName in `getProperty()`. + * @param fieldNS The namespace URI for the field. Has the same URI and prefix usage as the + * schemaNS parameter. + * @param fieldName The name of the field. Must be a single XML name, must not be + * `null` or the empty string. Has the same namespace prefix usage as the + * structName parameter. + * @return Returns a `XMPProperty` containing the value and the options or + * `null` if the property does not exist. Arrays and non-leaf levels of + * structs do not have values. + */ + fun getStructField( + schemaNS: String, + structName: String, + fieldNS: String, + fieldName: String + ): XMPProperty? + + /** + * Provides access to a qualifier attached to a property. The namespace for the qualifier is + * passed as a URI, you need not worry about the path string syntax. In many regards qualifiers + * are like struct fields. See the introductory discussion of qualified properties for more + * information. + * + * The names of qualifiers should be XML qualified names, that is within an XML namespace. The + * path syntax for a qualified name uses the namespace prefix. This is unreliable since the + * prefix is never guaranteed. The URI is the formal name, the prefix is just a local shorthand + * in a given sequence of XML text. + * + * *Note:* Qualifiers are only supported for simple leaf properties. + * + * @param schemaNS The namespace URI for the struct. Has the same usage as in getProperty. + * @param structName The name of the struct. + * May be a general path expression, must not be `null` or the empty string. + * Has the same namespace prefix usage as propName in `getProperty()`. + * @param qualNS The namespace URI for the qualifier. Has the same URI and prefix usage as the + * schemaNS parameter. + * @param qualName The name of the qualifier. Must be a single XML name, must not be + * `null` or the empty string. Has the same namespace prefix usage as the + * propName parameter. + * @return Returns a `XMPProperty` containing the value and the options of the + * qualifier or `null` if the property does not exist. The name of the + * qualifier must be a single XML name, must not be `null` or the empty + * string. Has the same namespace prefix usage as the propName parameter. + * + * The value of the qualifier is only set if it has one (Arrays and non-leaf levels of + * structs do not have values). + */ + fun getQualifier( + schemaNS: String, + propName: String, + qualNS: String, + qualName: String + ): XMPProperty? + + // --------------------------------------------------------------------------------------------- + // Functions for setting property values + + /** + * The property value `setters` all take a property specification, their + * differences are in the form of this. The first two parameters are always the top level + * namespace URI (the `schema` namespace) and the basic name of the property being + * referenced. See the introductory discussion of path expression usage for more information. + * + * All of the functions take a string value for the property and option flags describing the + * property. The value must be Unicode in UTF-8 encoding. Arrays and non-leaf levels of structs + * do not have values. Empty arrays and structs may be created using appropriate option flags. + * All levels of structs that is assigned implicitly are created if necessary. appendArayItem + * implicitly creates the named array if necessary. + * + * See [PropertyOptions] for detailed information about the options. + * + * This is the simplest property setter, mainly for top level simple properties or after using + * the path composition functions in [XMPPathFactory]. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in getProperty. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @param propValue the value for the property (only leaf properties have a value). + * Arrays and non-leaf levels of structs do not have values. + * Must be `null` if the value is not relevant. + * The value is automatically detected: Boolean, Integer, Long, Double, XMPDateTime and + * byte[] are handled, on all other `toString()` is called. + * @param options Option flags describing the property. See the earlier description. + */ + fun setProperty( + schemaNS: String, + propName: String, + propValue: Any?, + options: PropertyOptions = PropertyOptions() + ) + + /** + * Replaces an item within an array. The index is passed as an integer, you need not worry about + * the path string syntax for array items, convert a loop index to a string, etc. The array + * passed must already exist. In normal usage the selected array item is modified. A new item is + * automatically appended if the index is the array size plus 1. + * + * @param schemaNS The namespace URI for the struct. Has the same usage as in getProperty. + * @param arrayName The name of the array. + * May be a general path expression, must not be `null` or the empty string. + * Has the same namespace prefix usage as propName in getProperty. + * @param itemIndex The index of the desired item. Arrays in XMP are indexed from 1. To address + * the last existing item, use [XMPMeta.countArrayItems] to find + * out the length of the array. + * @param itemValue the new value of the array item. Has the same usage as propValue in + * `setProperty()`. + * @param options the set options for the item. + */ + fun setArrayItem( + schemaNS: String, + arrayName: String, + itemIndex: Int, + itemValue: String, + options: PropertyOptions = PropertyOptions() + ) + + /** + * Inserts an item into an array previous to the given index. The index is passed as an integer, + * you need not worry about the path string syntax for array items, convert a loop index to a + * string, etc. The array passed must already exist. In normal usage the selected array item is + * modified. A new item is automatically appended if the index is the array size plus 1. + * + * @param schemaNS The namespace URI for the struct. Has the same usage as in getProperty. + * @param arrayName The name of the array. + * May be a general path expression, must not be `null` or the empty string. + * Has the same namespace prefix usage as propName in getProperty. + * @param itemIndex The index to insert the new item. Arrays in XMP are indexed from 1. Use + * `XMPConst.ARRAY_LAST_ITEM` to append items. + * @param itemValue the new value of the array item. Has the same usage as + * propValue in `setProperty()`. + * @param options the set options that decide about the kind of the node. + */ + fun insertArrayItem( + schemaNS: String, + arrayName: String, + itemIndex: Int, + itemValue: String, + options: PropertyOptions = PropertyOptions() + ) + + /** + * Simplifies the construction of an array by not requiring that you pre-create an empty array. + * The array that is assigned is created automatically if it does not yet exist. Each call to + * appendArrayItem() appends an item to the array. The corresponding parameters have the same + * use as setArrayItem(). The arrayOptions parameter is used to specify what kind of array. If + * the array exists, it must have the specified form. + * + * @param schemaNS The namespace URI for the struct. Has the same usage as in getProperty. + * @param arrayName The name of the array. + * May be a general path expression, must not be `null` or the empty string. + * Has the same namespace prefix usage as propName in getProperty. + * @param arrayOptions Option flags describing the array form. The only valid options are + * + * * [PropertyOptions.ARRAY], + * * [PropertyOptions.ARRAY_ORDERED], + * * [PropertyOptions.ARRAY_ALTERNATE] or + * * [PropertyOptions.ARRAY_ALT_TEXT]. + * + * *Note:* the array options only need to be provided if the array is not + * already existing, otherwise you can set them to `null` or use [XMPMeta.appendArrayItem]. + * + * @param itemValue the value of the array item. Has the same usage as propValue in getProperty. + * @param itemOptions Option flags describing the item to append ([PropertyOptions]) + */ + fun appendArrayItem( + schemaNS: String, + arrayName: String, + arrayOptions: PropertyOptions = PropertyOptions(), + itemValue: String, + itemOptions: PropertyOptions = PropertyOptions() + ) + + /** + * Provides access to fields within a nested structure. The namespace for the field is passed as + * a URI, you need not worry about the path string syntax. The names of fields should be XML + * qualified names, that is within an XML namespace. The path syntax for a qualified name uses + * the namespace prefix, which is unreliable because the prefix is never guaranteed. The URI is + * the formal name, the prefix is just a local shorthand in a given sequence of XML text. + * + * @param schemaNS The namespace URI for the struct. Has the same usage as in getProperty. + * @param structName The name of the struct. May be a general path expression, must not be null + * or the empty string. Has the same namespace prefix usage as propName in getProperty. + * @param fieldNS The namespace URI for the field. Has the same URI and prefix usage as the + * schemaNS parameter. + * @param fieldName The name of the field. Must be a single XML name, must not be null or the + * empty string. Has the same namespace prefix usage as the structName parameter. + * @param fieldValue the value of thefield, if the field has a value. + * Has the same usage as propValue in getProperty. + * @param options Option flags describing the field. See the earlier description. + */ + fun setStructField( + schemaNS: String, + structName: String, + fieldNS: String, + fieldName: String, + fieldValue: String?, + options: PropertyOptions = PropertyOptions() + ) + + /** + * Provides access to a qualifier attached to a property. The namespace for the qualifier is + * passed as a URI, you need not worry about the path string syntax. In many regards qualifiers + * are like struct fields. See the introductory discussion of qualified properties for more + * information. The names of qualifiers should be XML qualified names, that is within an XML + * namespace. The path syntax for a qualified name uses the namespace prefix, which is + * unreliable because the prefix is never guaranteed. The URI is the formal name, the prefix is + * just a local shorthand in a given sequence of XML text. The property the qualifier + * will be attached has to exist. + * + * @param schemaNS The namespace URI for the struct. Has the same usage as in getProperty. + * @param propName The name of the property to which the qualifier is attached. Has the same + * usage as in getProperty. + * @param qualNS The namespace URI for the qualifier. Has the same URI and prefix usage as the + * schemaNS parameter. + * @param qualName The name of the qualifier. Must be a single XML name, must not be + * `null` or the empty string. Has the same namespace prefix usage as the + * propName parameter. + * @param qualValue A pointer to the `null` terminated UTF-8 string that is the + * value of the qualifier, if the qualifier has a value. Has the same usage as propValue + * in getProperty. + * @param options Option flags describing the qualifier. See the earlier description. + */ + fun setQualifier( + schemaNS: String, + propName: String, + qualNS: String, + qualName: String, + qualValue: String, + options: PropertyOptions = PropertyOptions() + ) + + // --------------------------------------------------------------------------------------------- + // Functions for deleting and detecting properties. + // These should be obvious from the descriptions of the getters and setters. + + /** + * Deletes the given XMP subtree rooted at the given property. + * It is not an error if the property does not exist. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `getProperty()`. + * @param propName The name of the property. Has the same usage as in getProperty. + */ + fun deleteProperty(schemaNS: String, propName: String) + + /** + * Deletes the given XMP subtree rooted at the given array item. + * It is not an error if the array item does not exist. + * + * @param schemaNS The namespace URI for the array. Has the same usage as in getProperty. + * @param arrayName The name of the array. May be a general path expression, must not be + * `null` or the empty string. Has the same namespace prefix usage as + * propName in `getProperty()`. + * @param itemIndex The index of the desired item. Arrays in XMP are indexed from 1. The + * constant `XMPConst.ARRAY_LAST_ITEM` always refers to the last + * existing array item. + */ + fun deleteArrayItem(schemaNS: String, arrayName: String, itemIndex: Int) + + /** + * Deletes the given XMP subtree rooted at the given struct field. + * It is not an error if the field does not exist. + * + * @param schemaNS The namespace URI for the struct. Has the same usage as in `getProperty()`. + * @param structName The name of the struct. May be a general path expression, must not be + * `null` or the empty string. Has the same namespace prefix usage as + * propName in getProperty. + * @param fieldNS The namespace URI for the field. Has the same URI and prefix usage as the + * schemaNS parameter. + * @param fieldName The name of the field. Must be a single XML name, must not be + * `null` or the empty string. Has the same namespace prefix usage as the + * structName parameter. + */ + fun deleteStructField(schemaNS: String, structName: String, fieldNS: String, fieldName: String) + + /** + * Deletes the given XMP subtree rooted at the given qualifier. + * It is not an error if the qualifier does not exist. + * + * @param schemaNS The namespace URI for the struct. Has the same usage as in `getProperty()`. + * @param propName The name of the property to which the qualifier is attached. Has the same + * usage as in getProperty. + * @param qualNS The namespace URI for the qualifier. Has the same URI and prefix usage as the + * schemaNS parameter. + * @param qualName The name of the qualifier. Must be a single XML name, must not be + * `null` or the empty string. Has the same namespace prefix usage as the + * propName parameter. + */ + fun deleteQualifier(schemaNS: String, propName: String, qualNS: String, qualName: String) + + /** + * Returns whether the property exists. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in getProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @return Returns true if the property exists. + */ + fun doesPropertyExist(schemaNS: String, propName: String): Boolean + + /** + * Tells if the array item exists. + * + * @param schemaNS The namespace URI for the array. Has the same usage as in `getProperty()`. + * @param arrayName The name of the array. May be a general path expression, must not be + * `null` or the empty string. Has the same namespace prefix usage as + * propName in `getProperty()`. + * @param itemIndex The index of the desired item. Arrays in XMP are indexed from 1. The + * constant `XMPConst.ARRAY_LAST_ITEM` always refers to the last + * existing array item. + * @return Returns `true` if the array exists, `false` otherwise. + */ + fun doesArrayItemExist(schemaNS: String, arrayName: String, itemIndex: Int): Boolean + + /** + * Tells if the struct field exists. + * + * @param schemaNS The namespace URI for the struct. Has the same usage as in `getProperty()`. + * @param structName The name of the struct. May be a general path expression, + * must not be `null` or the empty string. + * Has the same namespace prefix usage as propName in `getProperty()`. + * @param fieldNS The namespace URI for the field. + * Has the same URI and prefix usage as the schemaNS parameter. + * @param fieldName The name of the field. Must be a single XML name, + * must not be `null` or the empty string. + * Has the same namespace prefix usage as the structName parameter. + * @return Returns true if the field exists. + */ + fun doesStructFieldExist( + schemaNS: String, + structName: String, + fieldNS: String, + fieldName: String + ): Boolean + + /** + * Tells if the qualifier exists. + * + * @param schemaNS The namespace URI for the struct. Has the same usage as in `getProperty()`. + * @param propName The name of the property to which the qualifier is attached. Has the same + * usage as in `getProperty()`. + * @param qualNS The namespace URI for the qualifier. Has the same URI and prefix usage as the + * schemaNS parameter. + * @param qualName The name of the qualifier. Must be a single XML name, must not be + * `null` or the empty string. Has the same namespace prefix usage as the + * propName parameter. + * @return Returns true if the qualifier exists. + */ + fun doesQualifierExist(schemaNS: String, propName: String, qualNS: String, qualName: String): Boolean + + // --------------------------------------------------------------------------------------------- + // Specialized Get and Set functions + + /** + * These functions provide convenient support for localized text properties, including a number + * of special and obscure aspects. Localized text properties are stored in alt-text arrays. They + * allow multiple concurrent localizations of a property value, for example a document title or + * copyright in several languages. The most important aspect of these functions is that they + * select an appropriate array item based on one or two RFC 3066 language tags. One of these + * languages, the "specific" language, is preferred and selected if there is an exact match. For + * many languages it is also possible to define a "generic" language that may be used if there + * is no specific language match. The generic language must be a valid RFC 3066 primary subtag, + * or the empty string. For example, a specific language of "en-US" should be used in the US, + * and a specific language of "en-UK" should be used in England. It is also appropriate to use + * "en" as the generic language in each case. If a US document goes to England, the "en-US" + * title is selected by using the "en" generic language and the "en-UK" specific language. It is + * considered poor practice, but allowed, to pass a specific language that is just an RFC 3066 + * primary tag. For example "en" is not a good specific language, it should only be used as a + * generic language. Passing "i" or "x" as the generic language is also considered poor practice + * but allowed. Advice from the W3C about the use of RFC 3066 language tags can be found at: + * http://www.w3.org/International/articles/language-tags/ + * + * *Note:* RFC 3066 language tags must be treated in a case insensitive manner. The XMP + * Toolkit does this by normalizing their capitalization: + * + * * The primary subtag is lower case, the suggested practice of ISO 639. + * * All 2 letter secondary subtags are upper case, the suggested practice of ISO 3166. + * * All other subtags are lower case. The XMP specification defines an artificial language, + * * "x-default", that is used to explicitly denote a default item in an alt-text array. + * + * The XMP toolkit normalizes alt-text arrays such that the x-default item is the first item. + * The SetLocalizedText function has several special features related to the x-default item, see + * its description for details. The selection of the array item is the same for GetLocalizedText + * and SetLocalizedText: + * + * * Look for an exact match with the specific language. + * * If a generic language is given, look for a partial match. + * * Look for an x-default item. + * * Choose the first item. + * + * A partial match with the generic language is where the start of the item's language matches + * the generic string and the next character is '-'. An exact match is also recognized as a + * degenerate case. It is fine to pass x-default as the specific language. In this case, + * selection of an x-default item is an exact match by the first rule, not a selection by the + * 3rd rule. The last 2 rules are fallbacks used when the specific and generic languages fail to + * produce a match. `getLocalizedText` returns information about a selected item in + * an alt-text array. The array item is selected according to the rules given above. + * + * @param schemaNS The namespace URI for the alt-text array. Has the same usage as in `getProperty()`. + * @param altTextName The name of the alt-text array. May be a general path expression, must not + * be `null` or the empty string. Has the same namespace prefix usage as + * propName in `getProperty()`. + * @param genericLang The name of the generic language as an RFC 3066 primary subtag. May be + * `null` or the empty string if no generic language is wanted. + * @param specificLang The name of the specific language as an RFC 3066 tag. Must not be + * `null` or the empty string. + * @return Returns an `XMPProperty` containing the value, the actual language and + * the options if an appropriate alternate collection item exists, `null` + * if the property. + * does not exist. + * + */ + fun getLocalizedText( + schemaNS: String, + altTextName: String, + genericLang: String?, + specificLang: String + ): XMPProperty? + + /** + * Modifies the value of a selected item in an alt-text array. Creates an appropriate array item + * if necessary, and handles special cases for the x-default item. If the selected item is from + * a match with the specific language, the value of that item is modified. If the existing value + * of that item matches the existing value of the x-default item, the x-default item is also + * modified. If the array only has 1 existing item (which is not x-default), an x-default item + * is added with the given value. If the selected item is from a match with the generic language + * and there are no other generic matches, the value of that item is modified. If the existing + * value of that item matches the existing value of the x-default item, the x-default item is + * also modified. If the array only has 1 existing item (which is not x-default), an x-default + * item is added with the given value. If the selected item is from a partial match with the + * generic language and there are other partial matches, a new item is created for the specific + * language. The x-default item is not modified. If the selected item is from the last 2 rules + * then a new item is created for the specific language. If the array only had an x-default + * item, the x-default item is also modified. If the array was empty, items are created for the + * specific language and x-default. + * + * @param schemaNS The namespace URI for the alt-text array. Has the same usage as in `getProperty()`. + * @param altTextName The name of the alt-text array. May be a general path expression, + * must not be `null` or the empty string. + * Has the same namespace prefix usage as propName in `getProperty()`. + * @param genericLang The name of the generic language as an RFC 3066 primary subtag. + * May be `null` or the empty string if no generic language is wanted. + * @param specificLang The name of the specific language as an RFC 3066 tag. + * Must not be `null` or the empty string. + * @param itemValue A pointer to the `null` terminated UTF-8 string that is the new + * value for the appropriate array item. + * @param options Option flags, none are defined at present. + */ + fun setLocalizedText( + schemaNS: String, + altTextName: String, + genericLang: String?, + specificLang: String, + itemValue: String, + options: PropertyOptions = PropertyOptions() + ) + + // --------------------------------------------------------------------------------------------- + // Functions accessing properties as binary values. + + /** + * These are very similar to `getProperty()` and `SetProperty()` above, + * but the value is returned or provided in a literal form instead of as a UTF-8 string. + * The path composition functions in `XMPPathFactory` may be used to compose an path + * expression for fields in nested structures, items in arrays, or qualifiers. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `getProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @return Returns a `Boolean` value or `null` if the property does not exist. + */ + fun getPropertyBoolean(schemaNS: String, propName: String): Boolean? + + /** + * Convenience method to retrieve the literal value of a property. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `getProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @return Returns an `Integer` value or `null` if the property does not exist. + */ + fun getPropertyInteger(schemaNS: String, propName: String): Int? + + /** + * Convenience method to retrieve the literal value of a property. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `getProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @return Returns a `Long` value or `null` if the property does not exist. + */ + fun getPropertyLong(schemaNS: String, propName: String): Long? + + /** + * Convenience method to retrieve the literal value of a property. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `getProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @return Returns a `Double` value or `null` if the property does not exist. + */ + fun getPropertyDouble(schemaNS: String, propName: String): Double? + + /** + * Convenience method to retrieve the literal value of a property. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `getProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @return Returns a `byte[]`-array contained the decoded base64 value or `null` if the property does + * not exist. + */ + fun getPropertyBase64(schemaNS: String, propName: String): ByteArray? + + /** + * Convenience method to retrieve the literal value of a property. + * + * *Note:* There is no `setPropertyString()`, + * because `setProperty()` sets a string value. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `getProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @return Returns a `String` value or `null` if the property does not exist. + */ + fun getPropertyString(schemaNS: String, propName: String): String? + + /** + * Convenience method to set a property to a literal `boolean` value. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `setProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @param propValue the literal property value as `boolean`. + * @param options options of the property to set (optional). + */ + fun setPropertyBoolean( + schemaNS: String, + propName: String, + propValue: Boolean, + options: PropertyOptions = PropertyOptions() + ) + + /** + * Convenience method to set a property to a literal `int` value. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `setProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @param propValue the literal property value as `int`. + * @param options options of the property to set (optional). + * + */ + fun setPropertyInteger( + schemaNS: String, + propName: String, + propValue: Int, + options: PropertyOptions = PropertyOptions() + ) + + /** + * Convenience method to set a property to a literal `long` value. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `setProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @param propValue the literal property value as `long`. + * @param options options of the property to set (optional). + */ + fun setPropertyLong( + schemaNS: String, + propName: String, + propValue: Long, + options: PropertyOptions = PropertyOptions() + ) + + /** + * Convenience method to set a property to a literal `double` value. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `setProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @param propValue the literal property value as `double`. + * @param options options of the property to set (optional). + */ + fun setPropertyDouble( + schemaNS: String, + propName: String, + propValue: Double, + options: PropertyOptions = PropertyOptions() + ) + + /** + * Convenience method to set a property from a binary `byte[]`-array, + * which is serialized as base64-string. + * + * @param schemaNS The namespace URI for the property. Has the same usage as in `setProperty()`. + * @param propName The name of the property. Has the same usage as in `getProperty()`. + * @param propValue the literal property value as byte array. + * @param options options of the property to set (optional). + */ + fun setPropertyBase64( + schemaNS: String, + propName: String, + propValue: ByteArray, + options: PropertyOptions = PropertyOptions() + ) + + /** + * Constructs an iterator for the properties within this XMP object. + * + * @return Returns an `XMPIterator`. + */ + fun iterator(): XMPIterator + + /** + * Constructs an iterator for the properties within this XMP object using some options. + * + * @param options Option flags to control the iteration. + * @return Returns an `XMPIterator`. + */ + fun iterator(options: IteratorOptions): XMPIterator + + /** + * Construct an iterator for the properties within an XMP object. According to the parameters it iterates + * the entire data tree, properties within a specific schema, or a subtree rooted at a specific node. + * + * @param schemaNS Optional schema namespace URI to restrict the iteration. + * Omitted (visit all schema) by passing `null` or empty String. + * @param propName Optional property name to restrict the iteration. May be an arbitrary path + * expression. Omitted (visit all properties) by passing `null` or empty + * String. If no schema URI is given, it is ignored. + * @param options Option flags to control the iteration. See [IteratorOptions] for details. + * @return Returns an `XMPIterator` for this `XMPMeta`-object considering the given options. + */ + fun iterator( + schemaNS: String?, + propName: String?, + options: IteratorOptions = IteratorOptions() + ): XMPIterator + + /** + * This correlates to the about-attribute, + * returns the empty String if no name is set. + * + * @return Returns the name of the XMP object. + */ + fun getObjectName(): String + + /** + * @param name Sets the name of the XMP object. + */ + fun setObjectName(name: String) + + /** + * @return Returns the unparsed content of the <?xpacket> processing instruction. + * This contains normally the attribute-like elements 'begin="<BOM>" + * id="W5M0MpCehiHzreSzNTczkc9d"' and possibly the deprecated elements 'bytes="1234"' or + * 'encoding="XXX"'. If the parsed packet has not been wrapped into an xpacket, + * `null` is returned. + */ + fun getPacketHeader(): String? + + /** + * Sorts the complete datamodel according to the following rules: + * + * * Schema nodes are sorted by prefix. + * * Properties at top level and within structs are sorted by full name, that is prefix + local name. + * * Array items are not sorted, even if they have no certain order such as bags. + * * Qualifier are sorted, with the exception of "xml:lang" and/or "rdf:type" + * that stay at the top of the list in that order. + */ + fun sort() + + /** + * Perform the normalization as a separate parsing step. + * Normally it is done during parsing, unless the parsing option + * [ParseOptions.OMIT_NORMALIZATION] is set to `true`. + * *Note:* It does no harm to call this method to an already normalized xmp object. + * It was a PDF/A requirement to get hand on the unnormalized `XMPMeta` object. + */ + fun normalize(options: ParseOptions = ParseOptions()) + +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPMetaFactory.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPMetaFactory.kt new file mode 100644 index 0000000..690589f --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPMetaFactory.kt @@ -0,0 +1,50 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp + +import com.ashampoo.xmp.impl.XMPMetaImpl +import com.ashampoo.xmp.impl.XMPMetaParser +import com.ashampoo.xmp.impl.XMPRDFWriter +import com.ashampoo.xmp.impl.XMPSchemaRegistryImpl +import com.ashampoo.xmp.options.ParseOptions +import com.ashampoo.xmp.options.SerializeOptions + +/** + * Creates `XMPMeta`-instances from an `InputStream` + */ +object XMPMetaFactory { + + @kotlin.jvm.JvmStatic + val schemaRegistry = XMPSchemaRegistryImpl + + @kotlin.jvm.JvmStatic + val versionInfo = XMPVersionInfo + + fun create(): XMPMeta = XMPMetaImpl() + + fun parseFromString( + packet: String, + options: ParseOptions = ParseOptions() + ): XMPMeta = + XMPMetaParser.parse(packet, options) + + fun serializeToString( + xmp: XMPMeta, + options: SerializeOptions = SerializeOptions() + ): String { + + require(xmp is XMPMetaImpl) { "Serialization only works with XMPMetaImpl" } + + /* sort the internal data model on demand */ + if (options.getSort()) + xmp.sort() + + return XMPRDFWriter(xmp, options).serialize() + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPPathFactory.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPPathFactory.kt new file mode 100644 index 0000000..8bf7ec6 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPPathFactory.kt @@ -0,0 +1,179 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp + +import com.ashampoo.xmp.impl.Utils +import com.ashampoo.xmp.impl.xpath.XMPPath +import com.ashampoo.xmp.impl.xpath.XMPPathParser + +/** + * Utility services for the metadata object. It has only public static functions, you cannot create + * an object. These are all functions that layer cleanly on top of the core XMP toolkit. + * + * These functions provide support for composing path expressions to deeply nested properties. The + * functions `XMPMeta` such as `getProperty()`, + * `getArrayItem()` and `getStructField()` provide easy access to top + * level simple properties, items in top level arrays, and fields of top level structs. They do not + * provide convenient access to more complex things like fields several levels deep in a complex + * struct, or fields within an array of structs, or items of an array that is a field of a struct. + * These functions can also be used to compose paths to top level array items or struct fields so + * that you can use the binary accessors like `getPropertyAsInteger()`. + * + * You can use these functions is to compose a complete path expression, or all but the last + * component. Suppose you have a property that is an array of integers within a struct. + * + * *Note:* It might look confusing that the schemaNS is passed in all of the calls above. + * This is because the XMP toolkit keeps the top level "schema" namespace separate from + * the rest of the path expression. + * + * *Note:* These methods are much simpler than in the C++-API, they don't check the given path or array indices. + */ +object XMPPathFactory { + + /** + * Compose the path expression for an item in an array. + * + * @param arrayName The name of the array. + * May be a general path expression, must not be `null` or the empty string. + * @param itemIndex The index of the desired item. Arrays in XMP are indexed from 1. + * 0 and below means last array item and renders as `[last()]`. + * @return Returns the composed path basing on fullPath. This will be of the form + * ns:arrayName[i], where "ns" is the prefix for schemaNS and + * "i" is the decimal representation of itemIndex. + */ + @kotlin.jvm.JvmStatic + fun composeArrayItemPath(arrayName: String, itemIndex: Int): String { + + if (itemIndex > 0) + return "$arrayName[$itemIndex]" + + if (itemIndex == XMPConst.ARRAY_LAST_ITEM) + return "$arrayName[last()]" + + throw XMPException("Array index must be larger than zero", XMPError.BADINDEX) + } + + /** + * Compose the path expression for a field in a struct. The result can be added to the + * path of + * + * @param fieldNS The namespace URI for the field. Must not be `null` or the empty string. + * @param fieldName The name of the field. Must be a simple XML name, + * must not be `null` or the empty string. + * @return Returns the composed path. This will be of the form + * ns:structName/fNS:fieldName, where "ns" is the prefix for + * schemaNS and "fNS" is the prefix for fieldNS. + */ + @kotlin.jvm.JvmStatic + fun composeStructFieldPath(fieldNS: String, fieldName: String): String { + + if (fieldNS.length == 0) + throw XMPException("Empty field namespace URI", XMPError.BADSCHEMA) + + if (fieldName.length == 0) + throw XMPException("Empty field name", XMPError.BADXPATH) + + val fieldPath = XMPPathParser.expandXPath(fieldNS, fieldName) + + if (fieldPath.size() != 2) + throw XMPException("The field name must be simple", XMPError.BADXPATH) + + return '/'.toString() + fieldPath.getSegment(XMPPath.STEP_ROOT_PROP).name + } + + /** + * Compose the path expression for a qualifier. + * + * @param qualNS The namespace URI for the qualifier. + * May be `null` or the empty string if the qualifier is in the XML empty namespace. + * @param qualName The name of the qualifier. + * Must be a simple XML name, must not be `null` or the empty string. + * @return Returns the composed path. This will be of the form + * ns:propName/?qNS:qualName, where "ns" is the prefix for + * schemaNS and "qNS" is the prefix for qualNS. + */ + @kotlin.jvm.JvmStatic + fun composeQualifierPath(qualNS: String, qualName: String): String { + + if (qualNS.length == 0) + throw XMPException("Empty qualifier namespace URI", XMPError.BADSCHEMA) + + if (qualName.length == 0) + throw XMPException("Empty qualifier name", XMPError.BADXPATH) + + val qualPath = XMPPathParser.expandXPath(qualNS, qualName) + + if (qualPath.size() != 2) + throw XMPException("The qualifier name must be simple", XMPError.BADXPATH) + + return "/?" + qualPath.getSegment(XMPPath.STEP_ROOT_PROP).name + } + + /** + * Compose the path expression to select an alternate item by language. The + * path syntax allows two forms of "content addressing" that may + * be used to select an item in an array of alternatives. The form used in + * ComposeLangSelector lets you select an item in an alt-text array based on + * the value of its xml:lang qualifier. The other form of content + * addressing is shown in ComposeFieldSelector. + * + * ComposeLangSelector does not supplant SetLocalizedText or GetLocalizedText. + * They should generally be used, as they provide extra logic to choose the appropriate + * language and maintain consistency with the 'x-default' value. + * ComposeLangSelector gives you an path expression that is explicitly and + * only for the language given in the langName parameter. + * + * @param arrayName The name of the array. + * May be a general path expression, must not be `null` or the empty string. + * @param langName The RFC 3066 code for the desired language. + * @return Returns the composed path. This will be of the form + * ns:arrayName[@xml:lang='langName'], where + * "ns" is the prefix for schemaNS. + */ + fun composeLangSelector(arrayName: String, langName: String): String = + arrayName + "[?xml:lang=\"" + Utils.normalizeLangValue(langName) + "\"]" + + /** + * Compose the path expression to select an alternate item by a field's value. The path syntax + * allows two forms of "content addressing" that may be used to select an item in an + * array of alternatives. The form used in ComposeFieldSelector lets you select an item in an + * array of structs based on the value of one of the fields in the structs. The other form of + * content addressing is shown in ComposeLangSelector. For example, consider a simple struct + * that has two fields, the name of a city and the URI of an FTP site in that city. Use this to + * create an array of download alternatives. You can show the user a popup built from the values + * of the city fields. + * + * @param arrayName The name of the array. + * May be a general path expression, must not be `null` or the empty string. + * @param fieldNS The namespace URI for the field used as the selector. + * Must not be `null` or the empty string. + * @param fieldName The name of the field used as the selector. + * Must be a simple XML name, must not be `null` or the empty string. + * It must be the name of a field that is itself simple. + * @param fieldValue The desired value of the field. + * @return Returns the composed path. This will be of the form + * ns:arrayName[fNS:fieldName='fieldValue'], where "ns" is the + * prefix for schemaNS and "fNS" is the prefix for fieldNS. + */ + fun composeFieldSelector( + arrayName: String, + fieldNS: String?, + fieldName: String?, + fieldValue: String + ): String { + + val fieldPath = XMPPathParser.expandXPath(fieldNS, fieldName) + + if (fieldPath.size() != 2) + throw XMPException("The fieldName name must be simple", XMPError.BADXPATH) + + return arrayName + + '[' + fieldPath.getSegment(XMPPath.STEP_ROOT_PROP).name + "=\"" + fieldValue + "\"]" + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPSchemaRegistry.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPSchemaRegistry.kt new file mode 100644 index 0000000..26e1ff0 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPSchemaRegistry.kt @@ -0,0 +1,142 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp + +import com.ashampoo.xmp.properties.XMPAliasInfo + +/** + * The schema registry keeps track of all namespaces and aliases used in the XMP + * metadata. At initialisation time, the default namespaces and default aliases + * are automatically registered. **Namespaces** must be registered before + * used in namespace URI parameters or path expressions. Within the XMP Toolkit + * the registered namespace URIs and prefixes must be unique. Additional + * namespaces encountered when parsing RDF are automatically registered. The + * namespace URI should always end in an XML name separator such as '/' or '#'. + * This is because some forms of RDF shorthand catenate a namespace URI with an + * element name to form a new URI. + * + * **Aliases** in XMP serve the same purpose as Windows file shortcuts, + * Macintosh file aliases, or UNIX file symbolic links. The aliases are simply + * multiple names for the same property. One distinction of XMP aliases is that + * they are ordered, there is an alias name pointing to an actual name. The + * primary significance of the actual name is that it is the preferred name for + * output, generally the most widely recognized name. + * + * The names that can be aliased in XMP are restricted. The alias must be a top + * level property name, not a field within a structure or an element within an + * array. The actual may be a top level property name, the first element within + * a top level array, or the default element in an alt-text array. This does not + * mean the alias can only be a simple property. It is OK to alias a top level + * structure or array to an identical top level structure or array, or to the + * first item of an array of structures. + */ +interface XMPSchemaRegistry { + + // --------------------------------------------------------------------------------------------- + // Namespace Functions + + /** + * Register a namespace URI with a suggested prefix. It is not an error if + * the URI is already registered, no matter what the prefix is. If the URI + * is not registered but the suggested prefix is in use, a unique prefix is + * created from the suggested one. The actual registeed prefix is always + * returned. The function result tells if the registered prefix is the + * suggested one. + * + * Note: No checking is presently done on either the URI or the prefix. + * + * @param namespaceURI The URI for the namespace. Must be a valid XML URI. + * @param suggestedPrefix The suggested prefix to be used if the URI is not yet registered. + * Must be a valid XML name. + * @return Returns the registered prefix for this URI, is equal to the suggestedPrefix if the + * namespace hasn't been registered before, otherwise the existing prefix. + */ + fun registerNamespace(namespaceURI: String, suggestedPrefix: String): String + + /** + * Obtain the prefix for a registered namespace URI. + * + * It is not an error if the namespace URI is not registered. + * + * @param namespaceURI The URI for the namespace. Must not be null or the empty string. + * @return Returns the prefix registered for this namespace URI or null. + */ + fun getNamespacePrefix(namespaceURI: String): String? + + /** + * Obtain the URI for a registered namespace prefix. + * + * It is not an error if the namespace prefix is not registered. + * + * @param namespacePrefix The prefix for the namespace. Must not be null or the empty string. + * @return Returns the URI registered for this prefix or null. + */ + fun getNamespaceURI(namespacePrefix: String): String? + + /** + * @return Returns the registered prefix/namespace-pairs as map, where the keys are the + * namespaces and the values are the prefixes. + */ + fun getNamespaces(): Map + + /** + * @return Returns the registered namespace/prefix-pairs as map, where the keys are the + * prefixes and the values are the namespaces. + */ + fun getPrefixes(): Map + + /** + * Deletes a namespace from the registry. + * + * Does nothing if the URI is not registered, or if the namespaceURI + * parameter is null or the empty string. + * + * @param namespaceURI The URI for the namespace. + */ + fun deleteNamespace(namespaceURI: String) + + // --------------------------------------------------------------------------------------------- + // Alias Functions + + /** + * Determines if a name is an alias, and what it is aliased to. + * + * @param aliasNS The namespace URI of the alias. Must not be `null` or the empty string. + * @param aliasProp The name of the alias. + * May be an arbitrary path expression path, must not be `null` or the empty string. + * @return Returns the `XMPAliasInfo` for the given alias namespace and property + * or `null` if there is no such alias. + */ + fun resolveAlias(aliasNS: String, aliasProp: String): XMPAliasInfo? + + /** + * Collects all aliases that are contained in the provided namespace. + * If nothing is found, an empty array is returned. + * + * @param aliasNS a schema namespace URI + * @return Returns all alias infos from aliases that are contained in the provided namespace. + */ + fun findAliases(aliasNS: String): Set + + /** + * Searches for registered aliases. + * + * @param qname an XML conform qname + * @return Returns if an alias definition for the given qname to another + * schema and property is registered. + */ + fun findAlias(qname: String): XMPAliasInfo? + + /** + * @return Returns the registered aliases as map, where the key is the "qname" (prefix and name) + * and the value an `XMPAliasInfo`-object. + */ + fun getAliases(): Map + +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPUtils.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPUtils.kt new file mode 100644 index 0000000..adde755 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPUtils.kt @@ -0,0 +1,120 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Utility methods for XMP. I included only those that are different from the + * Java default conversion utilities. + */ +object XMPUtils { + + /** + * Convert from string to Boolean. + * + * @param value The string representation of the Boolean. + * @return The appropriate boolean value for the string. + * The checked values for `true` and `false` are: + * * [XMPConst.TRUESTR] and [XMPConst.FALSESTR] + * * "t" and "f" + * * "on" and "off" + * * "yes" and "no" + * * "value != 0" and "value == 0" + */ + @kotlin.jvm.JvmStatic + fun convertToBoolean(value: String?): Boolean { + + if (value == null || value.length == 0) + throw XMPException("Empty convert-string", XMPError.BADVALUE) + + val valueLowercase = value.lowercase() + + try { + + /* First try interpretation as Integer (anything not 0 is true) */ + return valueLowercase.toInt() != 0 + + } catch (ex: NumberFormatException) { + + /* Fallback to other common true values */ + return "true" == valueLowercase || "t" == valueLowercase || + "on" == valueLowercase || "yes" == valueLowercase + } + } + + @kotlin.jvm.JvmStatic + fun convertToInteger(rawValue: String?): Int { + try { + + if (rawValue == null || rawValue.length == 0) + throw XMPException("Empty convert-string", XMPError.BADVALUE) + + return if (rawValue.startsWith("0x")) + rawValue.substring(2).toInt(16) + else + rawValue.toInt() + + } catch (ex: NumberFormatException) { + throw XMPException("Invalid integer string", XMPError.BADVALUE, ex) + } + } + + @kotlin.jvm.JvmStatic + fun convertToLong(rawValue: String?): Long { + + try { + + if (rawValue == null || rawValue.length == 0) + throw XMPException("Empty convert-string", XMPError.BADVALUE) + + return if (rawValue.startsWith("0x")) + rawValue.substring(2).toLong(16) + else + rawValue.toLong() + + } catch (ex: NumberFormatException) { + throw XMPException("Invalid long string", XMPError.BADVALUE, ex) + } + } + + @kotlin.jvm.JvmStatic + fun convertToDouble(rawValue: String?): Double { + + try { + + if (rawValue == null || rawValue.length == 0) + throw XMPException("Empty convert-string", XMPError.BADVALUE) + + return rawValue.toDouble() + + } catch (ex: NumberFormatException) { + throw XMPException("Invalid double string", XMPError.BADVALUE, ex) + } + } + + @OptIn(ExperimentalEncodingApi::class) + @kotlin.jvm.JvmStatic + fun encodeBase64(buffer: ByteArray): String = + Base64.encode(buffer) + + @OptIn(ExperimentalEncodingApi::class) + @kotlin.jvm.JvmStatic + fun decodeBase64(base64String: String): ByteArray { + + try { + + return Base64.decode(base64String.encodeToByteArray()) + + } catch (ex: Throwable) { + throw XMPException("Invalid base64 string", XMPError.BADVALUE, ex) + } + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/XMPVersionInfo.kt b/src/commonMain/kotlin/com/ashampoo/xmp/XMPVersionInfo.kt new file mode 100644 index 0000000..d5fc837 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/XMPVersionInfo.kt @@ -0,0 +1,21 @@ +package com.ashampoo.xmp + +/** + * We ported from version 1.5.3, which was the final release available on + * https://www.adobe.com/devnet/xmp/library/eula-xmp-library-java.html + * under the BSD license, prior to the webpage being taken down. + * Hence we report this as the used version. + */ +@Suppress("MagicNumber") +object XMPVersionInfo { + + const val VERSION_MESSAGE = "Adobe XMP Core 5.1.3" + + const val major: Int = 5 + const val minor: Int = 1 + const val micro: Int = 3 + const val build: Int = 0 + const val isDebug: Boolean = false + const val message: String = VERSION_MESSAGE + +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/DomParser.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/DomParser.kt new file mode 100644 index 0000000..e512338 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/DomParser.kt @@ -0,0 +1,9 @@ +package com.ashampoo.xmp.impl + +import nl.adaptivity.xmlutil.dom.Document + +fun interface DomParser { + + fun parseDocumentFromString(input: String): Document + +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/QName.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/QName.kt new file mode 100644 index 0000000..0595009 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/QName.kt @@ -0,0 +1,51 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl + +class QName { + + /** + * XML namespace prefix + */ + var prefix: String? = null + private set + + /** + * XML localname + */ + var localName: String? = null + private set + + /** + * Splits a qname into prefix and localname. + * + * @param qname a QName + */ + constructor(qname: String) { + + val colon = qname.indexOf(':') + + if (colon >= 0) { + prefix = qname.substring(0, colon) + localName = qname.substring(colon + 1) + } else { + prefix = "" + localName = qname + } + } + + constructor(prefix: String?, localName: String?) { + this.prefix = prefix + this.localName = localName + } + + fun hasPrefix(): Boolean = + prefix != null && prefix!!.length > 0 + +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/Utils.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/Utils.kt new file mode 100644 index 0000000..420595d --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/Utils.kt @@ -0,0 +1,367 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl + +import com.ashampoo.xmp.XMPConst + +/** + * Utility functions for the XMPToolkit implementation. + */ +object Utils { + + /** + * segments of a UUID + */ + const val UUID_SEGMENT_COUNT = 4 + + /** + * length of a UUID + */ + const val UUID_LENGTH = 32 + UUID_SEGMENT_COUNT + + /** + * table of XML name start chars (<= 0xFF) + */ + private val xmlNameStartChars = BooleanArray(0x0100) + + /** + * table of XML name chars (<= 0xFF) + */ + private val xmlNameChars = BooleanArray(0x0100) + + private val controlCharRegex = Regex("[\\p{Cntrl}]") + + /** init char tables */ + init { + initCharTables() + } + + /** + * Normalize an xml:lang value so that comparisons are effectively case + * insensitive as required by RFC 3066 (which superceeds RFC 1766). The + * normalization rules: + * + * * The primary subtag is lower case, the suggested practice of ISO 639. + * * All 2 letter secondary subtags are upper case, the suggested + * practice of ISO 3166. + * * All other subtags are lower case. + * + * + * @param value raw value + * @return Returns the normalized value. + */ + @kotlin.jvm.JvmStatic + fun normalizeLangValue(value: String): String { + + // don't normalize x-default + if (XMPConst.X_DEFAULT == value) + return value + + var subTag = 1 + val buffer = StringBuilder() + + for (i in 0 until value.length) { + + when (value[i]) { + + '-', '_' -> { + /* Move to next subtag and convert underscore to hyphen */ + buffer.append('-') + subTag++ + } + + ' ' -> { + /* Leave as is. */ + } + + else -> + /* Convert second subtag to uppercase, all other to lowercase */ + if (subTag != 2) + buffer.append(value[i].lowercaseChar()) + else + buffer.append(value[i].uppercaseChar()) + } + } + + return buffer.toString() + } + + /** + * Split the name and value parts for field and qualifier selectors: + * + * * [qualName="value"] - An element in an array of structs, chosen by a field value. + * * [?qualName="value"] - An element in an array, chosen by a qualifier value. + * + * The value portion is a string quoted by ''' or '"'. The value may contain + * any character including a doubled quoting character. The value may be + * empty. *Note:* It is assumed that the expression is formal + * correct + * + * @param selector the selector + * @return Returns an array where the first entry contains the name and the second the value. + */ + @kotlin.jvm.JvmStatic + fun splitNameAndValue(selector: String): Array { + + // get the name + val eq = selector.indexOf('=') + + var pos = 1 + + if (selector[pos] == '?') + pos++ + + val name = selector.substring(pos, eq) + + // get the value + pos = eq + 1 + + val quote = selector[pos] + + pos++ + + val end = selector.length - 2 // quote and ] + + val value = StringBuilder(end - eq) + + while (pos < end) { + + value.append(selector[pos]) + + pos++ + + if (selector[pos] == quote) { + // skip one quote in value + pos++ + } + } + + return arrayOf(name, value.toString()) + } + + /** + * Check some requirements for an UUID: + * + * * Length of the UUID is 32 + * * The Delimiter count is 4 and all the 4 delimiter are on their right position (8,13,18,23) + * + * @param uuid uuid to test + * @return true - this is a well formed UUID, false - UUID has not the expected format + */ + @kotlin.jvm.JvmStatic + fun checkUUIDFormat(uuid: String?): Boolean { + + var result = true + var delimCnt = 0 + var delimPos = 0 + + if (uuid == null) + return false + + delimPos = 0 + + while (delimPos < uuid.length) { + + if (uuid[delimPos] == '-') { + delimCnt++ + result = result && (delimPos == 8 || delimPos == 13 || delimPos == 18 || delimPos == 23) + } + + delimPos++ + } + + return result && UUID_SEGMENT_COUNT == delimCnt && UUID_LENGTH == delimPos + } + + /** + * Simple check for valid XMLNames. Within ASCII range

+ * ":" | [A-Z] | "_" | [a-z] | [#xC0-#xD6] | [#xD8-#xF6]

+ * are accepted, above all characters (which is not entirely + * correct according to the XML Spec. + * + * @param name an XML Name + * @return Return `true` if the name is correct. + */ + fun isXMLName(name: String): Boolean { + + if (name.length > 0 && !isNameStartChar(name[0])) + return false + + for (i in 1 until name.length) + if (!isNameChar(name[i])) + return false + + return true + } + + /** + * Checks if the value is a legal "unqualified" XML name, as + * defined in the XML Namespaces proposed recommendation. + * These are XML names, except that they must not contain a colon. + * + * @param name the value to check + * @return Returns true if the name is a valid "unqualified" XML name. + */ + @kotlin.jvm.JvmStatic + fun isXMLNameNS(name: String): Boolean { + + if (name.length > 0 && (!isNameStartChar(name[0]) || name[0] == ':')) + return false + + for (index in 1 until name.length) + if (!isNameChar(name[index]) || name[index] == ':') + return false + + return true + } + + /** + * Serializes the node value in XML encoding. Its used for tag bodies and attributes. + * + * *Note:* The attribute is always limited by quotes, thats why `'` is never serialized. + * + * *Note:* Control chars are written unescaped, but if the user uses others than tab, LF + * and CR the resulting XML will become invalid. + * + * @param value a string + * @param forAttribute flag if string is attribute value (need to additional escape quotes) + * @param escapeWhitespaces Decides if LF, CR and TAB are escaped. + * @return Returns the value ready for XML output. + */ + @kotlin.jvm.JvmStatic + fun escapeXML(value: String, forAttribute: Boolean, escapeWhitespaces: Boolean): String { + + // quick check if character are contained that need special treatment + var needsEscaping = false + + for (index in 0 until value.length) { + + val c = value[index] + + if ( + c == '<' || c == '>' || c == '&' || escapeWhitespaces && + (c == '\t' || c == '\n' || c == '\r') || forAttribute && c == '"' + ) { + needsEscaping = true + break + } + } + + if (!needsEscaping) + return value + + // slow path with escaping + val buffer = StringBuilder(value.length * 4 / 3) + + for (index in 0 until value.length) { + + val char = value[index] + + if (!(escapeWhitespaces && (char == '\t' || char == '\n' || char == '\r'))) { + + when (char) { + + '<' -> { + buffer.append("<") + continue + } + + '>' -> { + buffer.append(">") + continue + } + + '&' -> { + buffer.append("&") + continue + } + + '"' -> { + buffer.append(if (forAttribute) """ else "\"") + continue + } + + else -> { + buffer.append(char) + continue + } + } + + } else { + + // write control chars escaped, + // if there are others than tab, LF and CR the xml will become invalid. + buffer.append("&#x") + buffer.append(char.code.toString(16).uppercase()) + buffer.append(';') + } + } + + return buffer.toString() + } + + /** + * Replaces the ASCII control chars with a space. + * + * @param value a node value + * @return Returns the cleaned up value + */ + @kotlin.jvm.JvmStatic + fun replaceControlCharsWithSpace(value: String): String = + value.replace(controlCharRegex, " ") + + /** + * Simple check if a character is a valid XML start name char. + * All characters according to the XML Spec 1.1 are accepted: + * http://www.w3.org/TR/xml11/#NT-NameStartChar + * + * @param ch a character + * @return Returns true if the character is a valid first char of an XML name. + */ + private fun isNameStartChar(ch: Char): Boolean = + ch.code <= 0xFF && xmlNameStartChars[ch.code] || ch.code >= 0x100 && ch.code <= 0x2FF || + ch.code >= 0x370 && ch.code <= 0x37D || ch.code >= 0x37F && ch.code <= 0x1FFF || + ch.code >= 0x200C && ch.code <= 0x200D || ch.code >= 0x2070 && ch.code <= 0x218F || + ch.code >= 0x2C00 && ch.code <= 0x2FEF || ch.code >= 0x3001 && ch.code <= 0xD7FF || + ch.code >= 0xF900 && ch.code <= 0xFDCF || ch.code >= 0xFDF0 && ch.code <= 0xFFFD || + ch.code >= 0x10000 && ch.code <= 0xEFFFF + + /** + * Simple check if a character is a valid XML name char + * (every char except the first one), according to the XML Spec 1.1: + * http://www.w3.org/TR/xml11/#NT-NameChar + * + * @param ch a character + * @return Returns true if the character is a valid char of an XML name. + */ + private fun isNameChar(ch: Char): Boolean = + ch.code <= 0xFF && xmlNameChars[ch.code] || isNameStartChar(ch) || + ch.code >= 0x300 && ch.code <= 0x36F || ch.code >= 0x203F && ch.code <= 0x2040 + + /** + * Initializes the char tables for the chars 0x00-0xFF for later use, + * according to the XML 1.1 specification at http://www.w3.org/TR/xml11 + */ + private fun initCharTables() { + + var ch = 0.toChar() + + while (ch < xmlNameChars.size.toChar()) { + + xmlNameStartChars[ch.code] = ch == ':' || 'A' <= ch && ch <= 'Z' || ch == '_' || + 'a' <= ch && ch <= 'z' || 0xC0 <= ch.code && ch.code <= 0xD6 || + 0xD8 <= ch.code && ch.code <= 0xF6 || 0xF8 <= ch.code && ch.code <= 0xFF + + xmlNameChars[ch.code] = xmlNameStartChars[ch.code] || ch == '-' || ch == '.' || + '0' <= ch && ch <= '9' || ch.code == 0xB7 + + ch++ + } + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPIteratorImpl.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPIteratorImpl.kt new file mode 100644 index 0000000..9c4f494 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPIteratorImpl.kt @@ -0,0 +1,498 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl + +import com.ashampoo.xmp.XMPError +import com.ashampoo.xmp.XMPException +import com.ashampoo.xmp.XMPIterator +import com.ashampoo.xmp.XMPMetaFactory.schemaRegistry +import com.ashampoo.xmp.impl.XMPNodeUtils.findNode +import com.ashampoo.xmp.impl.XMPNodeUtils.findSchemaNode +import com.ashampoo.xmp.impl.xpath.XMPPath +import com.ashampoo.xmp.impl.xpath.XMPPathParser.expandXPath +import com.ashampoo.xmp.options.IteratorOptions +import com.ashampoo.xmp.options.PropertyOptions +import com.ashampoo.xmp.properties.XMPPropertyInfo + +/** + * The `XMPIterator` implementation. + * Iterates the XMP Tree according to a set of options. + * During the iteration the XMPMeta-object must not be changed. + * Calls to `skipSubtree()` / `skipSiblings()` will affect the iteration. + */ +class XMPIteratorImpl( + xmp: XMPMetaImpl, + schemaNS: String?, + propPath: String?, + options: IteratorOptions? +) : XMPIterator { + + private val options: IteratorOptions + + /** + * the base namespace of the property path, will be changed during the iteration + */ + private var baseNS: String? = null + + /** + * flag to indicate that skipSiblings() has been called. + */ + private var skipSiblings = false + + /** + * flag to indicate that skipSubtree() has been called. + */ + private var skipSubtree = false + + /** + * the node iterator doing the work + */ + private var nodeIterator: Iterator? = null + + /** + * Constructor with optionsl initial values. If `propName` is provided, + * `schemaNS` has also be provided. + * + * @param xmp the iterated metadata object. + * @param schemaNS the iteration is reduced to this schema (optional) + * @param propPath the iteration is redurce to this property within the `schemaNS` + * @param options advanced iteration options, see [IteratorOptions] + * + */ + init { + + // make sure that options is defined at least with defaults + this.options = options ?: IteratorOptions() + + // the start node of the iteration depending on the schema and property filter + var startNode: XMPNode? = null + var initialPath: String? = null + val baseSchema = schemaNS != null && schemaNS.length > 0 + val baseProperty = propPath != null && propPath.length > 0 + + when { + + !baseSchema && !baseProperty -> { + + // complete tree will be iterated + startNode = xmp.root + } + + baseSchema && baseProperty -> { + + // Schema and property node provided + + val path = expandXPath(schemaNS, propPath) + + // base path is the prop path without the property leaf + val basePath = XMPPath() + + for (i in 0 until path.size() - 1) + basePath.add(path.getSegment(i)) + + startNode = findNode(xmp.root, path, false, null) + baseNS = schemaNS + initialPath = basePath.toString() + } + + baseSchema && !baseProperty -> { + + // Only Schema provided + startNode = findSchemaNode(xmp.root, schemaNS, false) + } + + else -> { + + // !baseSchema && baseProperty + // No schema but property provided -> error + throw XMPException("Schema namespace URI is required", XMPError.BADSCHEMA) + } + } + + // create iterator + if (startNode != null) { + + if (!this.options.isJustChildren()) + nodeIterator = NodeIterator(startNode, initialPath, 1) + else + nodeIterator = NodeIteratorChildren(startNode, initialPath) + + } else + nodeIterator = emptySequence().iterator() + } + + override fun skipSubtree() { + skipSubtree = true + } + + override fun skipSiblings() { + skipSubtree() + skipSiblings = true + } + + override fun hasNext(): Boolean = + nodeIterator!!.hasNext() + + override fun next(): XMPPropertyInfo = + nodeIterator!!.next() + + /** + * The `XMPIterator` implementation. + * It first returns the node itself, then recursivly the children and qualifier of the node. + */ + private open inner class NodeIterator : Iterator { + + /** + * the state of the iteration + */ + private var state = ITERATE_NODE + + /** + * the currently visited node + */ + private var visitedNode: XMPNode? = null + + /** + * the recursively accumulated path + */ + private var path: String? = null + + /** + * the iterator that goes through the children and qualifier list + */ + protected var childrenIterator: Iterator? = null + + /** + * index of node with parent, only interesting for arrays + */ + private var index = 0 + + /** + * the iterator for each child + */ + private var subIterator = emptySequence().iterator() + + /** + * the cached `PropertyInfo` to return + */ + protected var returnProperty: XMPPropertyInfo? = null + + /** + * Default constructor + */ + constructor() + + /** + * Constructor for the node iterator. + * + * @param visitedNode the currently visited node + * @param parentPath the accumulated path of the node + * @param index the index within the parent node (only for arrays) + */ + constructor(visitedNode: XMPNode, parentPath: String?, index: Int) { + + this.visitedNode = visitedNode + state = ITERATE_NODE + + if (visitedNode.options.isSchemaNode()) + baseNS = visitedNode.name + + // for all but the root node and schema nodes + path = accumulatePath(visitedNode, parentPath, index) + } + + /** + * Prepares the next node to return if not already done. + * + * @see Iterator.hasNext + */ + override fun hasNext(): Boolean { + + if (returnProperty != null) + return true // hasNext has been called before + + // find next node + return if (state == ITERATE_NODE) { + + reportNode() + + } else if (state == ITERATE_CHILDREN) { + + if (childrenIterator == null) + childrenIterator = visitedNode!!.iterateChildren() + + var hasNext = iterateChildren(childrenIterator!!) + + if (!hasNext && visitedNode!!.hasQualifier() && !options.isOmitQualifiers()) { + state = ITERATE_QUALIFIER + childrenIterator = null + hasNext = hasNext() + } + + hasNext + + } else { + + if (childrenIterator == null) + childrenIterator = visitedNode!!.iterateQualifier() + + iterateChildren(childrenIterator!!) + } + } + + /** + * Sets the returnProperty as next item or recurses into `hasNext()`. + * + * @return Returns if there is a next item to return. + */ + protected fun reportNode(): Boolean { + + state = ITERATE_CHILDREN + + return if (visitedNode!!.parent != null && + (!options.isJustLeafnodes() || !visitedNode!!.hasChildren()) + ) { + returnProperty = createPropertyInfo(visitedNode, baseNS!!, path!!) + true + } else { + hasNext() + } + } + + /** + * Handles the iteration of the children or qualfier + * + * @return Returns if there are more elements available. + */ + private fun iterateChildren(iterator: Iterator): Boolean { + + if (skipSiblings) { + + skipSiblings = false + + subIterator = emptySequence().iterator() + } + + /* + * Create sub iterator for every child, if its the first child + * visited or the former child is finished + */ + if (!subIterator.hasNext() && iterator.hasNext()) { + + val child = iterator.next() + + index++ + + subIterator = NodeIterator(child, path, index) + } + + if (subIterator.hasNext()) { + + returnProperty = subIterator.next() + + /* We have more available */ + return true + } + + /* There are no more children - end iteration. */ + return false + } + + /** + * Calls hasNext() and returnes the prepared node. Afterward its set to null. + * The existance of returnProperty indicates if there is a next node, otherwise + * an exceptio is thrown. + * + * @see Iterator.next + */ + override fun next(): XMPPropertyInfo { + + if (!hasNext()) + throw NoSuchElementException("There are no more nodes to return") + + val result = returnProperty + + returnProperty = null + + return result!! + } + + /** + * @param currNode the node that will be added to the path. + * @param parentPath the path up to this node. + * @param currentIndex the current array index if an arrey is traversed + * @return Returns the updated path. + */ + protected fun accumulatePath(currNode: XMPNode, parentPath: String?, currentIndex: Int): String? { + + val separator: String + val segmentName: String? + + if (currNode.parent == null || currNode.options.isSchemaNode()) { + return null + } else if (currNode.parent!!.options.isArray()) { + separator = "" + segmentName = "[$currentIndex]" + } else { + separator = "/" + segmentName = currNode.name + } + + return if (parentPath == null || parentPath.length == 0) { + + segmentName + + } else if (options.isJustLeafname()) { + + if (!segmentName!!.startsWith("?")) + segmentName + else + segmentName.substring(1) // qualifier + + } else { + + parentPath + separator + segmentName + } + } + + /** + * Creates a property info object from an `XMPNode`. + * + * @param node an `XMPNode` + * @param baseNS the base namespace to report + * @param path the full property path + * @return Returns a `XMPProperty`-object that serves representation of the node. + */ + protected fun createPropertyInfo( + node: XMPNode?, + baseNS: String, + path: String + ): XMPPropertyInfo { + + val value = if (node!!.options.isSchemaNode()) + null + else + node.value + + return object : XMPPropertyInfo { + + override fun getNamespace(): String { + + if (node.options.isSchemaNode()) + return baseNS + + // determine namespace of leaf node + val qname = QName(node.name!!) + + return schemaRegistry.getNamespaceURI(qname.prefix!!)!! + } + + override fun getPath(): String = path + + override fun getValue(): String = value!! + + override fun getOptions(): PropertyOptions = node.options + + // the language is not reported + override fun getLanguage(): String? = null + } + } + } + + /** + * This iterator is derived from the default `NodeIterator`, + * and is only used for the option [IteratorOptions.JUST_CHILDREN]. + */ + private inner class NodeIteratorChildren(parentNode: XMPNode, parentPath: String?) : NodeIterator() { + + private val parentPath: String + + private val nodeChildrenIterator: Iterator + + private var index = 0 + + /** + * Constructor + * + * @param parentNode the node which children shall be iterated. + * @param parentPath the full path of the former node without the leaf node. + */ + init { + + if (parentNode.options.isSchemaNode()) + baseNS = parentNode.name + + this.parentPath = accumulatePath(parentNode, parentPath, 1)!! + + nodeChildrenIterator = parentNode.iterateChildren() + } + + /** + * Prepares the next node to return if not already done. + * + * @see Iterator.hasNext + */ + override fun hasNext(): Boolean { + + return if (returnProperty != null) { + + // hasNext has been called before + true + + } else if (skipSiblings) { + + false + + } else if (nodeChildrenIterator.hasNext()) { + + val child = nodeChildrenIterator.next() + + index++ + + var path: String? = null + + if (child.options.isSchemaNode()) { + baseNS = child.name + } else if (child.parent != null) { + // for all but the root node and schema nodes + path = accumulatePath(child, parentPath, index) + } + + // report next property, skip not-leaf nodes in case options is set + if (!options.isJustLeafnodes() || !child.hasChildren()) { + returnProperty = createPropertyInfo(child, baseNS!!, path!!) + true + } else { + hasNext() + } + + } else { + false + } + } + } + + companion object { + + /** + * iteration state + */ + const val ITERATE_NODE = 0 + + /** + * iteration state + */ + const val ITERATE_CHILDREN = 1 + + /** + * iteration state + */ + const val ITERATE_QUALIFIER = 2 + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPMetaImpl.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPMetaImpl.kt new file mode 100644 index 0000000..0774a82 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPMetaImpl.kt @@ -0,0 +1,954 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl + +import com.ashampoo.xmp.XMPConst +import com.ashampoo.xmp.XMPError +import com.ashampoo.xmp.XMPException +import com.ashampoo.xmp.XMPIterator +import com.ashampoo.xmp.XMPMeta +import com.ashampoo.xmp.XMPPathFactory.composeArrayItemPath +import com.ashampoo.xmp.XMPPathFactory.composeQualifierPath +import com.ashampoo.xmp.XMPPathFactory.composeStructFieldPath +import com.ashampoo.xmp.XMPUtils.convertToBoolean +import com.ashampoo.xmp.XMPUtils.convertToDouble +import com.ashampoo.xmp.XMPUtils.convertToInteger +import com.ashampoo.xmp.XMPUtils.convertToLong +import com.ashampoo.xmp.XMPUtils.decodeBase64 +import com.ashampoo.xmp.impl.Utils.normalizeLangValue +import com.ashampoo.xmp.impl.XMPNodeUtils.appendLangItem +import com.ashampoo.xmp.impl.XMPNodeUtils.chooseLocalizedText +import com.ashampoo.xmp.impl.XMPNodeUtils.deleteNode +import com.ashampoo.xmp.impl.XMPNodeUtils.findNode +import com.ashampoo.xmp.impl.XMPNodeUtils.setNodeValue +import com.ashampoo.xmp.impl.XMPNodeUtils.verifySetOptions +import com.ashampoo.xmp.impl.XMPNormalizer.normalize +import com.ashampoo.xmp.impl.xpath.XMPPathParser.expandXPath +import com.ashampoo.xmp.options.IteratorOptions +import com.ashampoo.xmp.options.ParseOptions +import com.ashampoo.xmp.options.PropertyOptions +import com.ashampoo.xmp.properties.XMPProperty + +/** + * Implementation for [XMPMeta]. + */ +class XMPMetaImpl : XMPMeta { + + /** + * root of the metadata tree + */ + var root: XMPNode + private set + + /** + * the xpacket processing instructions content + */ + private var packetHeader: String? = null + + /** + * Constructor for an empty metadata object. + */ + constructor() { + // create root node + this.root = XMPNode(null, null, PropertyOptions()) + } + + constructor(tree: XMPNode) { + this.root = tree + } + + override fun appendArrayItem( + schemaNS: String, + arrayName: String, + arrayOptions: PropertyOptions, + itemValue: String, + itemOptions: PropertyOptions + ) { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (arrayName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + if (!arrayOptions.isOnlyArrayOptions()) + throw XMPException("Only array form flags allowed for arrayOptions", XMPError.BADOPTIONS) + + // Check if array options are set correctly. + val verifiedArrayOptions = verifySetOptions(arrayOptions, null) + + // Locate or create the array. If it already exists, make sure the array form from the options + // parameter is compatible with the current state. + val arrayPath = expandXPath(schemaNS, arrayName) + + // Just lookup, don't try to create. + var arrayNode = findNode(this.root, arrayPath, false, null) + + if (arrayNode != null) { + + // The array exists, make sure the form is compatible. Zero arrayForm means take what exists. + if (!arrayNode.options.isArray()) + throw XMPException("The named property is not an array", XMPError.BADXPATH) + + } else { + + // The array does not exist, try to create it. + if (verifiedArrayOptions.isArray()) { + + arrayNode = findNode(this.root, arrayPath, true, verifiedArrayOptions) + + if (arrayNode == null) + throw XMPException("Failure creating array node", XMPError.BADXPATH) + + } else { + + // array options missing + throw XMPException("Explicit arrayOptions required to create new array", XMPError.BADOPTIONS) + } + } + + doSetArrayItem(arrayNode, XMPConst.ARRAY_LAST_ITEM, itemValue, itemOptions, true) + } + + override fun countArrayItems(schemaNS: String, arrayName: String): Int { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (arrayName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + val arrayPath = expandXPath(schemaNS, arrayName) + val arrayNode = findNode(this.root, arrayPath, false, null) ?: return 0 + + if (!arrayNode.options.isArray()) + throw XMPException("The named property is not an array", XMPError.BADXPATH) + + return arrayNode.getChildrenLength() + } + + override fun deleteArrayItem(schemaNS: String, arrayName: String, itemIndex: Int) { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (arrayName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + val itemPath = composeArrayItemPath(arrayName, itemIndex) + + deleteProperty(schemaNS, itemPath) + } + + override fun deleteProperty(schemaNS: String, propName: String) { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (propName.isEmpty()) + throw XMPException("Empty property name", XMPError.BADPARAM) + + val propNode = findNode( + xmpTree = this.root, + xpath = expandXPath(schemaNS, propName), + createNodes = false, + leafOptions = null + ) ?: return + + deleteNode(propNode) + } + + override fun deleteQualifier(schemaNS: String, propName: String, qualNS: String, qualName: String) { + + // Note: qualNS and qualName are checked inside composeQualfierPath + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (propName.isEmpty()) + throw XMPException("Empty property name", XMPError.BADPARAM) + + val qualPath = propName + composeQualifierPath(qualNS, qualName) + + deleteProperty(schemaNS, qualPath) + } + + override fun deleteStructField( + schemaNS: String, + structName: String, + fieldNS: String, + fieldName: String + ) { + + // fieldNS and fieldName are checked inside composeStructFieldPath + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (structName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + val fieldPath = structName + composeStructFieldPath(fieldNS, fieldName) + + deleteProperty(schemaNS, fieldPath) + } + + override fun doesPropertyExist(schemaNS: String, propName: String): Boolean { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (propName.isEmpty()) + throw XMPException("Empty property name", XMPError.BADPARAM) + + val propNode = findNode( + xmpTree = this.root, + xpath = expandXPath(schemaNS, propName), + createNodes = false, + leafOptions = null + ) + + return propNode != null + } + + override fun doesArrayItemExist(schemaNS: String, arrayName: String, itemIndex: Int): Boolean { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (arrayName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + val path = composeArrayItemPath(arrayName, itemIndex) + + return doesPropertyExist(schemaNS, path) + } + + override fun doesStructFieldExist( + schemaNS: String, + structName: String, + fieldNS: String, + fieldName: String + ): Boolean { + + // fieldNS and fieldName are checked inside composeStructFieldPath() + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (structName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + val path = composeStructFieldPath(fieldNS, fieldName) + + return doesPropertyExist(schemaNS, structName + path) + } + + override fun doesQualifierExist( + schemaNS: String, + propName: String, + qualNS: String, + qualName: String + ): Boolean { + + // qualNS and qualName are checked inside composeQualifierPath() + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (propName.isEmpty()) + throw XMPException("Empty property name", XMPError.BADPARAM) + + val path = composeQualifierPath(qualNS, qualName) + + return doesPropertyExist(schemaNS, propName + path) + } + + override fun getArrayItem(schemaNS: String, arrayName: String, itemIndex: Int): XMPProperty? { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (arrayName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + val itemPath = composeArrayItemPath(arrayName, itemIndex) + + return getProperty(schemaNS, itemPath) + } + + override fun getLocalizedText( + schemaNS: String, + altTextName: String, + genericLang: String?, + specificLang: String + ): XMPProperty? { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (altTextName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + if (specificLang.isEmpty()) + throw XMPException("Empty specific language", XMPError.BADPARAM) + + val normalizedGenericLang = if (genericLang != null) normalizeLangValue(genericLang) else null + val normalizedSpecificLang = normalizeLangValue(specificLang) + + val arrayPath = expandXPath(schemaNS, altTextName) + + // *** This expand/find idiom is used in 3 Getters. + val arrayNode = findNode(this.root, arrayPath, false, null) ?: return null + val result = chooseLocalizedText(arrayNode, normalizedGenericLang, normalizedSpecificLang) + val match = result[0] as Int + val itemNode = result[1] as? XMPNode + + return if (match != XMPNodeUtils.CLT_NO_VALUES) { + + object : XMPProperty { + override fun getValue(): String { + return itemNode!!.value!! + } + + override fun getOptions(): PropertyOptions { + return itemNode!!.options + } + + override fun getLanguage(): String { + return itemNode!!.getQualifier(1).value!! + } + + override fun toString(): String { + return itemNode!!.value.toString() + } + } + + } else { + null + } + } + + override fun setLocalizedText( + schemaNS: String, + altTextName: String, + genericLang: String?, + specificLang: String, + itemValue: String, + options: PropertyOptions + ) { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (altTextName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + if (specificLang.isEmpty()) + throw XMPException("Empty specific language", XMPError.BADPARAM) + + val normalizedGenericLang = if (genericLang != null) normalizeLangValue(genericLang) else null + val normalizedSpecificLang = normalizeLangValue(specificLang) + + val arrayPath = expandXPath(schemaNS, altTextName) + + // Find the array node and set the options if it was just created. + val arrayNode = findNode( + this.root, arrayPath, true, + PropertyOptions( + PropertyOptions.ARRAY or PropertyOptions.ARRAY_ORDERED + or PropertyOptions.ARRAY_ALTERNATE or PropertyOptions.ARRAY_ALT_TEXT + ) + ) + + if (arrayNode == null) { + + throw XMPException("Failed to find or create array node", XMPError.BADXPATH) + + } else if (!arrayNode.options.isArrayAltText()) { + + if (!arrayNode.hasChildren() && arrayNode.options.isArrayAlternate()) + arrayNode.options.setArrayAltText(true) + else + throw XMPException("Specified property is no alt-text array", XMPError.BADXPATH) + } + + // Make sure the x-default item, if any, is first. + var haveXDefault = false + var xdItem: XMPNode? = null + + for (item in arrayNode.iterateChildren()) { + + if (!item.hasQualifier() || XMPConst.XML_LANG != item.getQualifier(1).name) + throw XMPException("Language qualifier must be first", XMPError.BADXPATH) + + if (XMPConst.X_DEFAULT == item.getQualifier(1).value) { + xdItem = item + haveXDefault = true + break + } + } + + // Moves x-default to the beginning of the array + if (xdItem != null && arrayNode.getChildrenLength() > 1) { + + arrayNode.removeChild(xdItem) + arrayNode.addChild(1, xdItem) + } + + // Find the appropriate item. + // chooseLocalizedText will make sure the array is a language alternative. + val result = chooseLocalizedText(arrayNode, normalizedGenericLang, normalizedSpecificLang) + val match = result[0] as Int + val itemNode = result[1] as? XMPNode + + val specificXDefault = XMPConst.X_DEFAULT == normalizedSpecificLang + + when (match) { + + XMPNodeUtils.CLT_NO_VALUES -> { + + // Create the array items for the specificLang and x-default, with x-default first. + appendLangItem(arrayNode, XMPConst.X_DEFAULT, itemValue) + + haveXDefault = true + + if (!specificXDefault) + appendLangItem(arrayNode, normalizedSpecificLang, itemValue) + } + + XMPNodeUtils.CLT_SPECIFIC_MATCH -> if (!specificXDefault) { + + // Update the specific item, update x-default if it matches the old value. + if (haveXDefault && xdItem != itemNode && xdItem != null && xdItem.value == itemNode!!.value) + xdItem.value = itemValue + + // ! Do this after the x-default check! + itemNode!!.value = itemValue + + } else { + + // Update all items whose values match the old x-default value. + check(haveXDefault && xdItem == itemNode) + + val it = arrayNode.iterateChildren() + + while (it.hasNext()) { + + val currItem = it.next() + + if (currItem == xdItem || currItem.value != xdItem?.value) + continue + + currItem.value = itemValue + } + + // And finally do the x-default item. + if (xdItem != null) + xdItem.value = itemValue + } + + XMPNodeUtils.CLT_SINGLE_GENERIC -> { + + // Update the generic item, update x-default if it matches the old value. + if (haveXDefault && xdItem != itemNode && xdItem != null && xdItem.value == itemNode!!.value) + xdItem.value = itemValue + + // ! Do this after the x-default check! + itemNode!!.value = itemValue + } + + XMPNodeUtils.CLT_FIRST_ITEM, XMPNodeUtils.CLT_MULTIPLE_GENERIC -> { + + // Create the specific language, ignore x-default. + appendLangItem(arrayNode, normalizedSpecificLang, itemValue) + + if (specificXDefault) haveXDefault = true + } + + XMPNodeUtils.CLT_XDEFAULT -> { + + // Create the specific language, update x-default if it was the only item. + if (xdItem != null && arrayNode.getChildrenLength() == 1) + xdItem.value = itemValue + + appendLangItem(arrayNode, normalizedSpecificLang, itemValue) + } + + else -> // does not happen under normal circumstances + throw XMPException("Unexpected result from ChooseLocalizedText", XMPError.INTERNALFAILURE) + } + + // Add an x-default at the front if needed. + if (!haveXDefault && arrayNode.getChildrenLength() == 1) + appendLangItem(arrayNode, XMPConst.X_DEFAULT, itemValue) + } + + override fun getProperty(schemaNS: String, propName: String): XMPProperty? = + getProperty(schemaNS, propName, VALUE_STRING) + + /** + * Returns a property, but the result value can be requested. It can be one + * of [XMPMetaImpl.VALUE_STRING], [XMPMetaImpl.VALUE_BOOLEAN], + * [XMPMetaImpl.VALUE_INTEGER], [XMPMetaImpl.VALUE_LONG], + * [XMPMetaImpl.VALUE_DOUBLE], [XMPMetaImpl.VALUE_DATE], + * [XMPMetaImpl.VALUE_TIME_IN_MILLIS], [XMPMetaImpl.VALUE_BASE64]. + */ + private fun getProperty(schemaNS: String, propName: String, valueType: Int): XMPProperty? { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (propName.isEmpty()) + throw XMPException("Empty property name", XMPError.BADPARAM) + + val propNode = findNode( + xmpTree = this.root, + xpath = expandXPath(schemaNS, propName), + createNodes = false, + leafOptions = null + ) ?: return null + + if (valueType != VALUE_STRING && propNode.options.isCompositeProperty()) + throw XMPException("Property must be simple when a value type is requested", XMPError.BADXPATH) + + val value = evaluateNodeValue(valueType, propNode) + + return object : XMPProperty { + + override fun getValue(): String? { + return value?.toString() + } + + override fun getOptions(): PropertyOptions { + return propNode.options + } + + override fun getLanguage(): String? { + return null + } + + override fun toString(): String { + return value.toString() + } + } + } + + /** + * Returns a property, but the result value can be requested. + */ + private fun getPropertyObject(schemaNS: String, propName: String, valueType: Int): Any? { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (propName.isEmpty()) + throw XMPException("Empty property name", XMPError.BADPARAM) + + val propNode = findNode( + xmpTree = this.root, + xpath = expandXPath(schemaNS, propName), + createNodes = false, + leafOptions = null + ) ?: return null + + if (valueType != VALUE_STRING && propNode.options.isCompositeProperty()) + throw XMPException("Property must be simple when a value type is requested", XMPError.BADXPATH) + + return evaluateNodeValue(valueType, propNode) + } + + override fun getPropertyBoolean(schemaNS: String, propName: String): Boolean? = + getPropertyObject(schemaNS, propName, VALUE_BOOLEAN) as? Boolean + + override fun setPropertyBoolean( + schemaNS: String, + propName: String, + propValue: Boolean, + options: PropertyOptions + ) { + setProperty( + schemaNS, + propName, + if (propValue) XMPConst.TRUESTR else XMPConst.FALSESTR, + options + ) + } + + override fun getPropertyInteger(schemaNS: String, propName: String): Int? = + getPropertyObject(schemaNS, propName, VALUE_INTEGER) as? Int + + override fun setPropertyInteger( + schemaNS: String, + propName: String, + propValue: Int, + options: PropertyOptions + ) { + setProperty(schemaNS, propName, propValue, options) + } + + override fun getPropertyLong(schemaNS: String, propName: String): Long? = + getPropertyObject(schemaNS, propName, VALUE_LONG) as? Long + + override fun setPropertyLong( + schemaNS: String, + propName: String, + propValue: Long, + options: PropertyOptions + ) { + setProperty(schemaNS, propName, propValue, options) + } + + override fun getPropertyDouble(schemaNS: String, propName: String): Double? = + getPropertyObject(schemaNS, propName, VALUE_DOUBLE) as? Double + + override fun setPropertyDouble( + schemaNS: String, + propName: String, + propValue: Double, + options: PropertyOptions + ) { + setProperty(schemaNS, propName, propValue, options) + } + + override fun getPropertyBase64(schemaNS: String, propName: String): ByteArray? = + getPropertyObject(schemaNS, propName, VALUE_BASE64) as? ByteArray + + override fun getPropertyString(schemaNS: String, propName: String): String? = + getPropertyObject(schemaNS, propName, VALUE_STRING) as? String + + override fun setPropertyBase64( + schemaNS: String, + propName: String, + propValue: ByteArray, + options: PropertyOptions + ) { + setProperty(schemaNS, propName, propValue, options) + } + + override fun getQualifier( + schemaNS: String, + propName: String, + qualNS: String, + qualName: String + ): XMPProperty? { + + // qualNS and qualName are checked inside composeQualfierPath + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (propName.isEmpty()) + throw XMPException("Empty property name", XMPError.BADPARAM) + + val qualPath = propName + composeQualifierPath(qualNS, qualName) + + return getProperty(schemaNS, qualPath) + } + + override fun getStructField( + schemaNS: String, + structName: String, + fieldNS: String, + fieldName: String + ): XMPProperty? { + + // fieldNS and fieldName are checked inside composeStructFieldPath + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (structName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + val fieldPath = structName + composeStructFieldPath(fieldNS, fieldName) + + return getProperty(schemaNS, fieldPath) + } + + override fun iterator(): XMPIterator = + iterator(IteratorOptions()) + + override fun iterator(options: IteratorOptions): com.ashampoo.xmp.XMPIterator = + iterator(null, null, options) + + override fun iterator( + schemaNS: String?, + propName: String?, + options: IteratorOptions + ): XMPIterator = + XMPIteratorImpl(this, schemaNS, propName, options) + + override fun setArrayItem( + schemaNS: String, + arrayName: String, + itemIndex: Int, + itemValue: String, + options: PropertyOptions + ) { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (arrayName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + // Just lookup, don't try to create. + val arrayPath = expandXPath(schemaNS, arrayName) + val arrayNode = findNode(this.root, arrayPath, false, null) + + if (arrayNode == null) + throw XMPException("Specified array does not exist", XMPError.BADXPATH) + + doSetArrayItem(arrayNode, itemIndex, itemValue, options, false) + } + + override fun insertArrayItem( + schemaNS: String, + arrayName: String, + itemIndex: Int, + itemValue: String, + options: PropertyOptions + ) { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (arrayName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + // Just lookup, don't try to create. + val arrayPath = expandXPath(schemaNS, arrayName) + val arrayNode = findNode(this.root, arrayPath, false, null) + + if (arrayNode == null) + throw XMPException("Specified array does not exist", XMPError.BADXPATH) + + doSetArrayItem(arrayNode, itemIndex, itemValue, options, true) + } + + override fun setProperty( + schemaNS: String, + propName: String, + propValue: Any?, + options: PropertyOptions + ) { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (propName.isEmpty()) + throw XMPException("Empty property name", XMPError.BADPARAM) + + val verifiedOptions = verifySetOptions(options, propValue) + + val propNode = findNode( + xmpTree = this.root, + xpath = expandXPath(schemaNS, propName), + createNodes = true, + leafOptions = verifySetOptions(options, propValue) + ) ?: throw XMPException("Specified property does not exist", XMPError.BADXPATH) + + setNode(propNode, propValue, verifiedOptions, false) + } + + override fun setQualifier( + schemaNS: String, + propName: String, + qualNS: String, + qualName: String, + qualValue: String, + options: PropertyOptions + ) { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (propName.isEmpty()) + throw XMPException("Empty property name", XMPError.BADPARAM) + + if (!doesPropertyExist(schemaNS, propName)) + throw XMPException("Specified property does not exist!", XMPError.BADXPATH) + + val qualPath = propName + composeQualifierPath(qualNS, qualName) + + setProperty(schemaNS, qualPath, qualValue, options) + } + + override fun setStructField( + schemaNS: String, + structName: String, + fieldNS: String, + fieldName: String, + fieldValue: String?, + options: PropertyOptions + ) { + + if (schemaNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (structName.isEmpty()) + throw XMPException("Empty array name", XMPError.BADPARAM) + + val fieldPath = structName + composeStructFieldPath(fieldNS, fieldName) + + setProperty(schemaNS, fieldPath, fieldValue, options) + } + + override fun getObjectName(): String = + root.name ?: "" + + override fun setObjectName(name: String) { + root.name = name + } + + override fun getPacketHeader(): String? = + packetHeader + + /** + * Sets the packetHeader attributes, only used by the parser. + */ + fun setPacketHeader(packetHeader: String?) { + this.packetHeader = packetHeader + } + + override fun sort() { + this.root.sort() + } + + override fun normalize(options: ParseOptions) { + normalize(this, options) + } + + // ------------------------------------------------------------------------------------- + // private + + /** + * Locate or create the item node and set the value. Note the index + * parameter is one-based! The index can be in the range [1..size + 1] or + * "last()", normalize it and check the insert flags. The order of the + * normalization checks is important. If the array is empty we end up with + * an index and location to set item size + 1. + */ + private fun doSetArrayItem( + arrayNode: XMPNode, + itemIndex: Int, + itemValue: String, + itemOptions: PropertyOptions, + insert: Boolean + ) { + + val itemNode = XMPNode(XMPConst.ARRAY_ITEM_NAME, null) + + val verifiedItemOptions = verifySetOptions(itemOptions, itemValue) + + // in insert mode the index after the last is allowed, + // even ARRAY_LAST_ITEM points to the index *after* the last. + val maxIndex = if (insert) + arrayNode.getChildrenLength() + 1 + else + arrayNode.getChildrenLength() + + val limitedItemIndex = if (itemIndex == XMPConst.ARRAY_LAST_ITEM) + maxIndex + else + itemIndex + + if (1 <= limitedItemIndex && limitedItemIndex <= maxIndex) { + + if (!insert) + arrayNode.removeChild(limitedItemIndex) + + arrayNode.addChild(limitedItemIndex, itemNode) + setNode(itemNode, itemValue, verifiedItemOptions, false) + + } else { + throw XMPException("Array index out of bounds", XMPError.BADINDEX) + } + } + + /** + * The internals for setProperty() and related calls, used after the node is found or created. + */ + fun setNode(node: XMPNode, value: Any?, newOptions: PropertyOptions, deleteExisting: Boolean) { + + val compositeMask = PropertyOptions.ARRAY or PropertyOptions.ARRAY_ALT_TEXT or + PropertyOptions.ARRAY_ALTERNATE or PropertyOptions.ARRAY_ORDERED or PropertyOptions.STRUCT + + if (deleteExisting) + node.clear() + + // its checked by setOptions(), if the merged result is a valid options set + node.options.mergeWith(newOptions) + + if (node.options.getOptions() and compositeMask == 0) { + + // This is setting the value of a leaf node. + setNodeValue(node, value) + + } else { + + if (value != null && value.toString().length > 0) + throw XMPException("Composite nodes can't have values", XMPError.BADXPATH) + + // Can't change an array to a struct, or vice versa. + if (node.options.getOptions() and compositeMask != 0) + if (newOptions.getOptions() and compositeMask != node.options.getOptions() and compositeMask) + throw XMPException("Requested and existing composite form mismatch", XMPError.BADXPATH) + + node.removeChildren() + } + } + + /** + * Evaluates a raw node value to the given value type, apply special + * conversions for defined types in XMP. + */ + private fun evaluateNodeValue(valueType: Int, propNode: XMPNode): Any? { + + val value: Any? + val rawValue = propNode.value + + value = when (valueType) { + + VALUE_BOOLEAN -> convertToBoolean(rawValue) + + VALUE_INTEGER -> convertToInteger(rawValue) + + VALUE_LONG -> convertToLong(rawValue) + + VALUE_DOUBLE -> convertToDouble(rawValue) + + VALUE_BASE64 -> decodeBase64(rawValue!!) + + // leaf values return empty string instead of null + // for the other cases the converter methods provides a "null" value. + // a default value can only occur if this method is made public. + VALUE_STRING -> + if (rawValue != null || propNode.options.isCompositeProperty()) rawValue else "" + + else -> + if (rawValue != null || propNode.options.isCompositeProperty()) rawValue else "" + } + + return value + } + + companion object { + + /** + * Property values are Strings by default + */ + + private const val VALUE_STRING = 0 + private const val VALUE_BOOLEAN = 1 + private const val VALUE_INTEGER = 2 + private const val VALUE_LONG = 3 + private const val VALUE_DOUBLE = 4 + private const val VALUE_BASE64 = 7 + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPMetaParser.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPMetaParser.kt new file mode 100644 index 0000000..3007b5f --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPMetaParser.kt @@ -0,0 +1,157 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl + +import com.ashampoo.xmp.XMPConst +import com.ashampoo.xmp.XMPMeta +import com.ashampoo.xmp.impl.XMPNormalizer.normalize +import com.ashampoo.xmp.options.ParseOptions +import nl.adaptivity.xmlutil.dom.Element +import nl.adaptivity.xmlutil.dom.Node +import nl.adaptivity.xmlutil.dom.ProcessingInstruction +import nl.adaptivity.xmlutil.dom.Text +import nl.adaptivity.xmlutil.dom.childNodes +import nl.adaptivity.xmlutil.dom.getData +import nl.adaptivity.xmlutil.dom.getTarget +import nl.adaptivity.xmlutil.dom.length +import nl.adaptivity.xmlutil.dom.localName +import nl.adaptivity.xmlutil.dom.namespaceURI + +/** + * This class replaces the `ExpatAdapter.cpp` and does the + * XML-parsing and fixes the prefix. After the parsing several normalisations + * are applied to the XMPTree. + */ +object XMPMetaParser { + + private val XMP_RDF = Any() // "new Object()" in Java + + /** + * Parses the input source into an XMP metadata object, including + * de-aliasing and normalisation. + * + * @param input the XMP string + * @param options the parse options + * @return Returns the resulting XMP metadata object + */ + fun parse( + input: String, + options: ParseOptions = ParseOptions() + ): XMPMeta { + + val document = XmlUtilDomParser.parseDocumentFromString(input) + + val xmpmetaRequired = options.getRequireXMPMeta() + + var result: Array? = arrayOfNulls(3) + + result = findRootNode(document, xmpmetaRequired, result) + + return if (result != null && result[1] === XMP_RDF) { + + val xmp = XMPRDFParser.parse(result[0] as Node, options) + + xmp.setPacketHeader(result[2] as? String) + + // Check if the XMP object shall be normalized + if (!options.getOmitNormalization()) + normalize(xmp, options) + else + xmp + + } else { + + /* No appropriate root node found, return empty metadata object */ + XMPMetaImpl() + } + } + + /** + * Find the XML node that is the root of the XMP data tree. Generally this + * will be an outer node, but it could be anywhere if a general XML document + * is parsed (e.g. SVG). The XML parser counted all rdf:RDF and + * pxmp:XMP_Packet nodes, and kept a pointer to the last one. If there is + * more than one possible root use PickBestRoot to choose among them. + * + * If there is a root node, try to extract the version of the previous XMP + * toolkit. + * + * Pick the first x:xmpmeta among multiple root candidates. If there aren't + * any, pick the first bare rdf:RDF if that is allowed. The returned root is + * the rdf:RDF child if an x:xmpmeta element was chosen. The search is + * breadth first, so a higher level candiate is chosen over a lower level + * one that was textually earlier in the serialized XML. + * + * @param root the root of the xml document + * @param xmpmetaRequired flag if the xmpmeta-tag is still required, might be set + * initially to `true`, if the parse option "REQUIRE_XMP_META" is set + * @param result The result array that is filled during the recursive process. + * @return Returns an array that contains the result or `null`. + * The array contains: + * + * * [0] - the rdf:RDF-node + * * [1] - an object that is either XMP_RDF or XMP_PLAIN (the latter is decrecated) + * * [2] - the body text of the xpacket-instruction. + */ + private fun findRootNode(root: Node, xmpmetaRequired: Boolean, result: Array?): Array? { + + // Look among this parent's content for x:xapmeta or x:xmpmeta. + // The recursion for x:xmpmeta is broader than the strictly defined choice, + // but gives us smaller code. + + for (index in 0 until root.childNodes.length) { + + val child = root.childNodes.item(index) + + requireNotNull(child) + + if (child is ProcessingInstruction && XMPConst.XMP_PI == child.getTarget()) { + + // Store the processing instructions content + if (result != null) + result[2] = child.getData() + + } else if (child !is Text && child !is ProcessingInstruction) { + + val childElement = child as Element + + val rootNS = childElement.namespaceURI + + val rootLocal = childElement.localName + + if ( + (XMPConst.TAG_XMPMETA == rootLocal || XMPConst.TAG_XAPMETA == rootLocal) && + XMPConst.NS_X == rootNS + ) { + + // by not passing the RequireXMPMeta-option, the rdf-Node will be valid + return findRootNode(child, false, result) + } + + if (!xmpmetaRequired && "RDF" == rootLocal && XMPConst.NS_RDF == rootNS) { + + if (result != null) { + result[0] = child + result[1] = XMP_RDF + } + + return result + } + + // continue searching + val newResult = findRootNode(child, xmpmetaRequired, result) + + return newResult ?: continue + } + } + + // no appropriate node has been found + return null + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPNode.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPNode.kt new file mode 100644 index 0000000..5ac5acd --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPNode.kt @@ -0,0 +1,328 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl + +import com.ashampoo.xmp.XMPConst +import com.ashampoo.xmp.XMPError +import com.ashampoo.xmp.XMPException +import com.ashampoo.xmp.options.PropertyOptions + +/** + * A node in the internally XMP tree, which can be a schema node, a property node, an array node, + * an array item, a struct node or a qualifier node (without '?'). + */ +class XMPNode( + + /** + * name of the node, contains different information depending of the node kind + */ + var name: String?, + + /** + * value of the node, contains different information depending of the node kind + */ + var value: String?, + + /** + * options describing the kind of the node + */ + var options: PropertyOptions = PropertyOptions() + +) : Comparable { + + var parent: XMPNode? = null + private var children: MutableList? = null + private var qualifier: MutableList? = null + + /* Internal processing options */ + + var isImplicit = false + var hasAliases = false + var isAlias = false + var hasValueChild = false + + fun clear() { + name = null + value = null + options = PropertyOptions() + children = null + qualifier = null + } + + /** + * Returns the children or empty list, if there are none. + * Will not lazily create the list! + */ + fun getChildren(): List = + children ?: emptyList() + + fun getChild(index: Int): XMPNode = + getOrCreateChildren()[index - 1] + + fun addChild(node: XMPNode) { + + assertChildNotExisting(node.name!!) + + node.parent = this + + getOrCreateChildren().add(node) + } + + fun addChild(index: Int, node: XMPNode) { + + assertChildNotExisting(node.name!!) + + node.parent = this + + getOrCreateChildren().add(index - 1, node) + } + + /** + * Replaces a node with another one. + */ + fun replaceChild(index: Int, node: XMPNode) { + + node.parent = this + + getOrCreateChildren()[index - 1] = node + } + + fun removeChild(itemIndex: Int) { + + getOrCreateChildren().removeAt(itemIndex - 1) + + cleanupChildren() + } + + /** + * Removes a child node. + * If its a schema node and doesn't have any children anymore, its deleted. + */ + fun removeChild(node: XMPNode) { + + getOrCreateChildren().remove(node) + + cleanupChildren() + } + + /** + * Removes the children list if this node has no children anymore; + * checks if the provided node is a schema node and doesn't have any children anymore, its deleted. + */ + private fun cleanupChildren() { + + if (children?.isEmpty() == null) + children = null + } + + /** + * Removes all children from the node. + */ + fun removeChildren() { + children = null + } + + fun getChildrenLength(): Int = + children?.size ?: 0 + + fun findChildByName(expr: String?): XMPNode? = + getOrCreateChildren()?.find { it.name == expr } + + /** + * Returns the qualifier or empty list, if there are none. + * Will not lazily create the list! + */ + fun getQualifier(): List = + qualifier ?: emptyList() + + fun getQualifier(index: Int): XMPNode = + getOrCreateQualifier()[index - 1] + + fun getQualifierLength(): Int = + qualifier?.size ?: 0 + + fun addQualifier(qualNode: XMPNode) { + + assertQualifierNotExisting(qualNode.name!!) + + qualNode.parent = this + qualNode.options.setQualifier(true) + + options.setHasQualifiers(true) + + // contraints + if (XMPConst.XML_LANG == qualNode.name) { + + // "xml:lang" is always first and the option "hasLanguage" is set + options.setHasLanguage(true) + + getOrCreateQualifier().add(0, qualNode) + + } else if ("rdf:type" == qualNode.name) { + + // "rdf:type" must be first or second after "xml:lang" and the option "hasType" is set + options.setHasType(true) + + getOrCreateQualifier().add( + if (!options.hasLanguage()) 0 else 1, + qualNode + ) + + } else { + + // other qualifiers are appended + getOrCreateQualifier().add(qualNode) + } + } + + /** + * Removes one qualifier node and fixes the options. + */ + fun removeQualifier(qualNode: XMPNode) { + + if (XMPConst.XML_LANG == qualNode.name) { + // if "xml:lang" is removed, remove hasLanguage-flag too + options.setHasLanguage(false) + } else if ("rdf:type" == qualNode.name) { + // if "rdf:type" is removed, remove hasType-flag too + options.setHasType(false) + } + + getOrCreateQualifier().remove(qualNode) + + if (qualifier!!.isEmpty()) { + options.setHasQualifiers(false) + qualifier = null + } + } + + /** + * Removes all qualifiers from the node and sets the options appropriate. + */ + fun removeQualifiers() { + + // clear qualifier related options + options.setHasQualifiers(false) + options.setHasLanguage(false) + options.setHasType(false) + + qualifier = null + } + + fun findQualifierByName(expr: String?): XMPNode? = + qualifier?.find { it.name == expr } + + fun hasChildren(): Boolean = + children?.isNotEmpty() ?: false + + fun iterateChildren(): Iterator = + children?.iterator() ?: emptySequence().iterator() + + fun iterateChildrenMutable(): MutableIterator = + children?.listIterator() ?: mutableListOf().listIterator() + + fun hasQualifier(): Boolean = + qualifier?.isNotEmpty() ?: false + + fun iterateQualifier(): Iterator = + qualifier?.listIterator() ?: emptySequence().iterator() + + override fun compareTo(other: XMPNode): Int { + + return if (options.isSchemaNode()) + value!!.compareTo(other.value!!) + else + name!!.compareTo(other.name!!) + } + + /** + * Sorts the complete datamodel according to the following rules: + * + * * Nodes at one level are sorted by name, that is prefix + local name + * * Starting at the root node the children and qualifier are sorted recursively, + * which the following exceptions. + * * Sorting will not be used for arrays. + * * Within qualifier "xml:lang" and/or "rdf:type" stay at the top in that order, all others are sorted. + */ + fun sort() { + + // sort qualifier + if (hasQualifier()) { + + val quals = getOrCreateQualifier().toTypedArray() + + var sortFrom = 0 + + while (quals.size > sortFrom && + (XMPConst.XML_LANG == quals[sortFrom].name || "rdf:type" == quals[sortFrom].name) + ) { + quals[sortFrom].sort() + sortFrom++ + } + + quals.sort(sortFrom, quals.size) + + val iterator = qualifier!!.listIterator() + + for (j in quals.indices) { + iterator.next() + iterator.set(quals[j]) + quals[j].sort() + } + } + + // sort children + if (hasChildren()) { + + if (!options.isArray()) + children!!.sort() + + val it = iterateChildren() + + while (it.hasNext()) + it.next().sort() + } + } + + // ------------------------------------------------------------------------------ private methods + + private fun getOrCreateChildren(): MutableList { + + if (children == null) + children = mutableListOf() + + return children!! + } + + private fun getOrCreateQualifier(): MutableList { + + if (qualifier == null) + qualifier = mutableListOf() + + return qualifier!! + } + + /** + * Checks that a node name is not existing on the same level, except for array items. + */ + private fun assertChildNotExisting(childName: String) { + + if (XMPConst.ARRAY_ITEM_NAME != childName && findChildByName(childName) != null) + throw XMPException("Duplicate property or field node '$childName'", XMPError.BADXMP) + } + + /** + * Checks that a qualifier name is not existing on the same level. + */ + private fun assertQualifierNotExisting(qualifierName: String) { + + if (XMPConst.ARRAY_ITEM_NAME != qualifierName && findQualifierByName(qualifierName) != null) + throw XMPException("Duplicate '$qualifierName' qualifier", XMPError.BADXMP) + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPNodeUtils.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPNodeUtils.kt new file mode 100644 index 0000000..291f79a --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPNodeUtils.kt @@ -0,0 +1,712 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl + +import com.ashampoo.xmp.XMPConst +import com.ashampoo.xmp.XMPError +import com.ashampoo.xmp.XMPException +import com.ashampoo.xmp.XMPMetaFactory.schemaRegistry +import com.ashampoo.xmp.XMPUtils.encodeBase64 +import com.ashampoo.xmp.impl.Utils.normalizeLangValue +import com.ashampoo.xmp.impl.Utils.replaceControlCharsWithSpace +import com.ashampoo.xmp.impl.Utils.splitNameAndValue +import com.ashampoo.xmp.impl.xpath.XMPPath +import com.ashampoo.xmp.impl.xpath.XMPPathSegment +import com.ashampoo.xmp.options.AliasOptions +import com.ashampoo.xmp.options.PropertyOptions + +/** + * Utilities for `XMPNode`. + */ +object XMPNodeUtils { + + const val CLT_NO_VALUES = 0 + + const val CLT_SPECIFIC_MATCH = 1 + + const val CLT_SINGLE_GENERIC = 2 + + const val CLT_MULTIPLE_GENERIC = 3 + + const val CLT_XDEFAULT = 4 + + const val CLT_FIRST_ITEM = 5 + + /** + * Find or create a schema node if `createNodes` is false and + * + * Note: If `createNodes` is `true`, it is **always** returned a valid node. + */ + @kotlin.jvm.JvmStatic + fun findSchemaNode(tree: XMPNode, namespaceURI: String?, createNodes: Boolean): XMPNode? = + findSchemaNode(tree, namespaceURI, null, createNodes) + + /** + * Find or create a schema node if `createNodes` is true. + * + * Note: If `createNodes` is `true`, it is **always** returned a valid node. + */ + fun findSchemaNode( + tree: XMPNode, + namespaceURI: String?, + suggestedPrefix: String?, + createNodes: Boolean + ): XMPNode? { + + // make sure that its the root + require(tree.parent == null) + + var schemaNode = tree.findChildByName(namespaceURI) + + if (schemaNode == null && createNodes) { + + schemaNode = XMPNode( + name = namespaceURI, + value = null, + options = PropertyOptions().setSchemaNode(true) + ) + + schemaNode.isImplicit = true + + // only previously registered schema namespaces are allowed in the XMP tree. + var prefix = schemaRegistry.getNamespacePrefix(namespaceURI!!) + + if (prefix == null) { + + prefix = if (suggestedPrefix != null && suggestedPrefix.length != 0) + schemaRegistry.registerNamespace(namespaceURI, suggestedPrefix) + else + throw XMPException("Unregistered schema namespace URI", XMPError.BADSCHEMA) + } + + schemaNode.value = prefix + + tree.addChild(schemaNode) + } + + return schemaNode + } + + /** + * Find or create a child node under a given parent node. + */ + fun findChildNode(parent: XMPNode, childName: String?, createNodes: Boolean): XMPNode? { + + if (!parent.options.isSchemaNode() && !parent.options.isStruct()) { + + when { + + !parent.isImplicit -> + throw XMPException( + "Named children only allowed for schemas and structs", XMPError.BADXPATH + ) + + parent.options.isArray() -> + throw XMPException("Named children not allowed for arrays", XMPError.BADXPATH) + + createNodes -> + parent.options.setStruct(true) + } + } + + var childNode = parent.findChildByName(childName) + + if (childNode == null && createNodes) { + + childNode = XMPNode(childName, null) + + childNode.isImplicit = true + + parent.addChild(childNode) + } + + check(childNode != null || !createNodes) + + return childNode + } + + /** + * Follow an expanded path expression to find or create a node. + * + * @param xmpTree the node to begin the search. + * @param xpath the complete xpath + * @param createNodes flag if nodes shall be created (when called by `setProperty()`) + * @param leafOptions the options for the created leaf nodes (only when`createNodes == true`). + * @return Returns the node if found or created or `null`. + + */ + @kotlin.jvm.JvmStatic + fun findNode( + xmpTree: XMPNode, + xpath: XMPPath?, + createNodes: Boolean, + leafOptions: PropertyOptions? + ): XMPNode? { + + if (xpath == null || xpath.size() == 0) + throw XMPException("Empty XMPPath", XMPError.BADXPATH) + + // Root of implicitly created subtree to possible delete it later. + // Valid only if leaf is new. + var rootImplicitNode: XMPNode? = null + + var currNode: XMPNode? = + findSchemaNode(xmpTree, xpath.getSegment(XMPPath.STEP_SCHEMA).name, createNodes) + + if (currNode == null) + return null + + if (currNode.isImplicit) { + + currNode.isImplicit = false // Clear the implicit node bit. + rootImplicitNode = currNode // Save the top most implicit node. + } + + // Now follow the remaining steps of the original XMPPath. + try { + + for (index in 1 until xpath.size()) { + + currNode = followXPathStep(currNode!!, xpath.getSegment(index), createNodes) + + if (currNode == null) { + + // delete implicitly created nodes + if (createNodes) + deleteNode(rootImplicitNode!!) + + return null + + } else if (currNode.isImplicit) { + + // clear the implicit node flag + currNode.isImplicit = false + + // if node is an ALIAS (can be only in root step, auto-create array + // when the path has been resolved from a not simple alias type + if (index == 1 && + xpath.getSegment(index).isAlias && xpath.getSegment(index).aliasForm != 0 + ) { + currNode.options.setOption(xpath.getSegment(index).aliasForm, true) + } else if ( // "CheckImplicitStruct" in C++ + index < xpath.size() - 1 && + xpath.getSegment(index).kind == XMPPath.STRUCT_FIELD_STEP && + !currNode.options.isCompositeProperty() + ) { + currNode.options.setStruct(true) + } + + if (rootImplicitNode == null) + rootImplicitNode = currNode // Save the top most implicit node. + } + } + + } catch (ex: XMPException) { + + // if new notes have been created prior to the error, delete them + if (rootImplicitNode != null) + deleteNode(rootImplicitNode) + + throw ex + } + + if (rootImplicitNode != null) { + + // set options only if a node has been successful created + if (leafOptions != null) + currNode!!.options.mergeWith(leafOptions) + + currNode!!.options = currNode.options + } + + return currNode + } + + /** + * Deletes the the given node and its children from its parent. + * Takes care about adjusting the flags. + * + * @param node the top-most node to delete. + */ + @kotlin.jvm.JvmStatic + fun deleteNode(node: XMPNode) { + + val parent = node.parent + + if (node.options.isQualifier()) + parent!!.removeQualifier(node) + else + parent!!.removeChild(node) + + // delete empty Schema nodes + if (!parent.hasChildren() && parent.options.isSchemaNode()) + parent.parent!!.removeChild(parent) + } + + /** + * This is setting the value of a leaf node. + * + * @param node an XMPNode + * @param value a value + */ + @kotlin.jvm.JvmStatic + fun setNodeValue(node: XMPNode, value: Any?) { + + val strValue = serializeNodeValue(value) + + if (!(node.options.isQualifier() && XMPConst.XML_LANG == node.name)) + node.value = strValue + else + node.value = normalizeLangValue(strValue!!) + } + + /** + * Verifies the PropertyOptions for consistancy and updates them as needed. + * If options are `null` they are created with default values. + * + * @param options the `PropertyOptions` + * @param itemValue the node value to set + * @return Returns the updated options. + * + */ + @kotlin.jvm.JvmStatic + fun verifySetOptions(options: PropertyOptions, itemValue: Any?): PropertyOptions { + + if (options.isArrayAltText()) + options.setArrayAlternate(true) + + if (options.isArrayAlternate()) + options.setArrayOrdered(true) + + if (options.isArrayOrdered()) + options.setArray(true) + + if (options.isCompositeProperty() && itemValue != null && itemValue.toString().length > 0) + throw XMPException("Structs and arrays can't have values", XMPError.BADOPTIONS) + + options.assertConsistency(options.getOptions()) + + return options + } + + /** + * Converts the node value to String, apply special conversions for defined + * types in XMP. + * + * @param value the node value to set + * @return Returns the String representation of the node value. + */ + fun serializeNodeValue(value: Any?): String? { + + if (value == null) + return null + + val strValue: String = when (value) { + is Boolean -> if (value) XMPConst.TRUESTR else XMPConst.FALSESTR + is Int -> value.toString() + is Long -> value.toString() + is Double -> value.toString() + is ByteArray -> encodeBase64(value) + else -> value.toString() + } + + return replaceControlCharsWithSpace(strValue) + } + + /** + * After processing by ExpandXPath, a step can be of these forms: + * + * * qualName - A top level property or struct field. + * * [index] - An element of an array. + * * [last()] - The last element of an array. + * * [qualName="value"] - An element in an array of structs, chosen by a field value. + * * [?qualName="value"] - An element in an array, chosen by a qualifier value. + * * ?qualName - A general qualifier. + * + * Find the appropriate child node, resolving aliases, and optionally creating nodes. + */ + private fun followXPathStep( + parentNode: XMPNode, + nextStep: XMPPathSegment, + createNodes: Boolean + ): XMPNode? { + + var nextNode: XMPNode? = null + var index = 0 + val stepKind = nextStep.kind + + if (stepKind == XMPPath.STRUCT_FIELD_STEP) { + nextNode = findChildNode(parentNode, nextStep.name, createNodes) + } else if (stepKind == XMPPath.QUALIFIER_STEP) { + nextNode = findQualifierNode(parentNode, nextStep.name!!.substring(1), createNodes) + } else { + + // This is an array indexing step. First get the index, then get the node. + if (!parentNode.options.isArray()) + throw XMPException("Indexing applied to non-array", XMPError.BADXPATH) + + index = when (stepKind) { + + XMPPath.ARRAY_INDEX_STEP -> + findIndexedItem(parentNode, nextStep.name, createNodes) + + XMPPath.ARRAY_LAST_STEP -> + parentNode.getChildrenLength() + + XMPPath.FIELD_SELECTOR_STEP -> { + + val result = splitNameAndValue(nextStep.name!!) + val fieldName = result[0] + val fieldValue = result[1] + + lookupFieldSelector(parentNode, fieldName, fieldValue) + } + + XMPPath.QUAL_SELECTOR_STEP -> { + + val result = splitNameAndValue(nextStep.name!!) + val qualName = result[0] + val qualValue = result[1] + + lookupQualSelector(parentNode, qualName, qualValue, nextStep.aliasForm) + } + + else -> + throw XMPException( + "Unknown array indexing step in FollowXPathStep", + XMPError.INTERNALFAILURE + ) + } + + if (1 <= index && index <= parentNode.getChildrenLength()) + nextNode = parentNode.getChild(index) + } + + return nextNode + } + + /** + * Find or create a qualifier node under a given parent node. Returns a pointer to the + * qualifier node, and optionally an iterator for the node's position in the parent's vector + * of qualifiers. The iterator is unchanged if no qualifier node (null) is returned. + * + * *Note:* On entry, the qualName parameter must not have the leading '?' from the XMPPath step. + */ + private fun findQualifierNode(parent: XMPNode?, qualName: String, createNodes: Boolean): XMPNode? { + + require(!qualName.startsWith("?")) + + var qualNode = parent!!.findQualifierByName(qualName) + + if (qualNode == null && createNodes) { + qualNode = XMPNode(qualName, null) + qualNode.isImplicit = true + parent.addQualifier(qualNode) + } + + return qualNode + } + + /** + * @param arrayNode an array node + * @param segment the segment containing the array index + * @param createNodes flag if new nodes are allowed to be created. + * @return Returns the index or index = -1 if not found + * + */ + private fun findIndexedItem(arrayNode: XMPNode?, segment: String?, createNodes: Boolean): Int { + + var segment = segment + var index: Int + + try { + + segment = segment!!.substring(1, segment.length - 1) + index = segment.toInt() + + if (index < 1) + throw XMPException("Array index must be larger than zero", XMPError.BADXPATH) + + } catch (ex: NumberFormatException) { + throw XMPException("Array index not digits.", XMPError.BADXPATH, ex) + } + + if (createNodes && index == arrayNode!!.getChildrenLength() + 1) { + + // Append a new last + 1 node. + val newItem = XMPNode(XMPConst.ARRAY_ITEM_NAME, null) + + newItem.isImplicit = true + + arrayNode.addChild(newItem) + } + + return index + } + + /** + * Searches for a field selector in a node: + * [fieldName="value] - an element in an array of structs, chosen by a field value. + * No implicit nodes are created by field selectors. + * + * @param arrayNode + * @param fieldName + * @param fieldValue + * @return Returns the index of the field if found, otherwise -1. + */ + private fun lookupFieldSelector(arrayNode: XMPNode?, fieldName: String, fieldValue: String): Int { + + var result = -1 + var index = 1 + + while (index <= arrayNode!!.getChildrenLength() && result < 0) { + + val currItem = arrayNode.getChild(index) + + if (!currItem.options.isStruct()) + throw XMPException("Field selector must be used on array of struct", XMPError.BADXPATH) + + for (childIndex in 1..currItem.getChildrenLength()) { + + val currField = currItem.getChild(childIndex) + + if (fieldName != currField.name) + continue + + if (fieldValue == currField.value) { + result = index + break + } + } + + index++ + } + + return result + } + + /** + * Searches for a qualifier selector in a node: + * [?qualName="value"] - an element in an array, chosen by a qualifier value. + * No implicit nodes are created for qualifier selectors, except for an alias to an x-default item. + */ + private fun lookupQualSelector( + arrayNode: XMPNode, + qualName: String, + qualValue: String, + aliasForm: Int + ): Int { + + return if (XMPConst.XML_LANG == qualName) { + + val normalizedQualValue = normalizeLangValue(qualValue) + + val index = lookupLanguageItem(arrayNode, normalizedQualValue) + + if (index < 0 && aliasForm and AliasOptions.PROP_ARRAY_ALT_TEXT > 0) { + + val langNode = XMPNode(XMPConst.ARRAY_ITEM_NAME, null) + + val xdefault = XMPNode(XMPConst.XML_LANG, XMPConst.X_DEFAULT) + + langNode.addQualifier(xdefault) + + arrayNode.addChild(1, langNode) + + 1 + + } else { + index + } + + } else { + + for (index in 1 until arrayNode.getChildrenLength()) { + + val currItem = arrayNode.getChild(index) + + for (qualifier in currItem.getQualifier()) + if (qualName == qualifier.name && qualValue == qualifier.value) + return index + } + + -1 + } + } + + /** + * Make sure the x-default item is first. Touch up "single value" + * arrays that have a default plus one real language. This case should have + * the same value for both items. Older Adobe apps were hardwired to only + * use the "x-default" item, so we copy that value to the other + * item. + * + * @param arrayNode an alt text array node + */ + fun normalizeLangArray(arrayNode: XMPNode) { + + if (!arrayNode.options.isArrayAltText()) + return + + // check if node with x-default qual is first place + for (index in 2..arrayNode.getChildrenLength()) { + + val child = arrayNode.getChild(index) + + if (child.hasQualifier() && XMPConst.X_DEFAULT == child.getQualifier(1).value) { + + // move node to first place + arrayNode.removeChild(index) + arrayNode.addChild(1, child) + + if (index == 2) + arrayNode.getChild(2).value = child.value + + break + } + } + } + + /** + * See if an array is an alt-text array. If so, make sure the x-default item + * is first. + * + * @param arrayNode the array node to check if its an alt-text array + */ + fun detectAltText(arrayNode: XMPNode) { + + if (arrayNode.options.isArrayAlternate() && arrayNode.hasChildren()) { + + var isAltText = false + + for (child in arrayNode.getChildren()) { + if (child.options.hasLanguage()) { + isAltText = true + break + } + } + + if (isAltText) { + arrayNode.options.setArrayAltText(true) + normalizeLangArray(arrayNode) + } + } + } + + /** + * Appends a language item to an alt text array. + */ + @kotlin.jvm.JvmStatic + fun appendLangItem(arrayNode: XMPNode, itemLang: String?, itemValue: String?) { + + val newItem = XMPNode(XMPConst.ARRAY_ITEM_NAME, itemValue) + val langQual = XMPNode(XMPConst.XML_LANG, itemLang) + + newItem.addQualifier(langQual) + + if (XMPConst.X_DEFAULT != langQual.value) + arrayNode.addChild(newItem) + else + arrayNode.addChild(1, newItem) + } + + /** + * 1. Look for an exact match with the specific language. + * 1. If a generic language is given, look for partial matches. + * 1. Look for an "x-default"-item. + * 1. Choose the first item. + */ + @kotlin.jvm.JvmStatic + fun chooseLocalizedText(arrayNode: XMPNode, genericLang: String?, specificLang: String): Array { + + // See if the array has the right form. Allow empty alt arrays, that is what parsing returns. + + if (!arrayNode.options.isArrayAltText()) + throw XMPException("Localized text array is not alt-text", XMPError.BADXPATH) + else if (!arrayNode.hasChildren()) + return arrayOf(CLT_NO_VALUES, null) + + var foundGenericMatches = 0 + var resultNode: XMPNode? = null + var xDefault: XMPNode? = null + + // Look for the first partial match with the generic language. + val it = arrayNode.iterateChildren() + + while (it.hasNext()) { + + val currItem = it.next() + + // perform some checks on the current item + if (currItem.options.isCompositeProperty()) + throw XMPException("Alt-text array item is not simple", XMPError.BADXPATH) + else if (!currItem.hasQualifier() || XMPConst.XML_LANG != currItem.getQualifier(1).name) + throw XMPException("Alt-text array item has no language qualifier", XMPError.BADXPATH) + + val currLang = currItem.getQualifier(1).value + + // Look for an exact match with the specific language. + when { + + specificLang == currLang -> + return arrayOf(CLT_SPECIFIC_MATCH, currItem) + + genericLang != null && currLang!!.startsWith(genericLang) -> { + + if (resultNode == null) + resultNode = currItem + + // ! Don't return/break, need to look for other matches. + foundGenericMatches++ + } + + XMPConst.X_DEFAULT == currLang -> + xDefault = currItem + } + } + + // evaluate loop + return when { + + foundGenericMatches == 1 -> + arrayOf(CLT_SINGLE_GENERIC, resultNode) + + foundGenericMatches > 1 -> + arrayOf(CLT_MULTIPLE_GENERIC, resultNode) + + xDefault != null -> + arrayOf(CLT_XDEFAULT, xDefault) + + else -> // Everything failed, choose the first item. + arrayOf(CLT_FIRST_ITEM, arrayNode.getChild(1)) + } + } + + /** + * Looks for the appropriate language item in a text alternative array.item + * Returns the index if the language has been found, -1 otherwise. + */ + fun lookupLanguageItem(arrayNode: XMPNode?, language: String): Int { + + if (!arrayNode!!.options.isArray()) + throw XMPException("Language item must be used on array", XMPError.BADXPATH) + + for (index in 1..arrayNode.getChildrenLength()) { + + val child = arrayNode.getChild(index) + + if (!child.hasQualifier() || XMPConst.XML_LANG != child.getQualifier(1).name) + continue + else if (language == child.getQualifier(1).value) + return index + } + + return -1 + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPNormalizer.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPNormalizer.kt new file mode 100644 index 0000000..39857ad --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPNormalizer.kt @@ -0,0 +1,496 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl + +import com.ashampoo.xmp.XMPConst +import com.ashampoo.xmp.XMPError +import com.ashampoo.xmp.XMPException +import com.ashampoo.xmp.XMPMeta +import com.ashampoo.xmp.XMPMetaFactory.schemaRegistry +import com.ashampoo.xmp.impl.Utils.checkUUIDFormat +import com.ashampoo.xmp.impl.xpath.XMPPathParser.expandXPath +import com.ashampoo.xmp.options.ParseOptions +import com.ashampoo.xmp.options.PropertyOptions + +internal object XMPNormalizer { + + /** + * caches the correct dc-property array forms + */ + private val dcArrayForms: Map = createDCArrays() + + /** + * Normalizes a raw parsed XMPMeta-Object + * + * @param xmp the raw metadata object + * @param options the parsing options + * @return Returns the normalized metadata object + * + */ + @kotlin.jvm.JvmStatic + fun normalize(xmp: XMPMetaImpl, options: ParseOptions): XMPMeta { + + val tree = xmp.root + + touchUpDataModel(xmp) + moveExplicitAliases(tree, options) + tweakOldXMP(tree) + deleteEmptySchemas(tree) + + return xmp + } + + /** + * Tweak old XMP: Move an instance ID from rdf:about to the + * *xmpMM:InstanceID* property. An old instance ID usually looks + * like "uuid:bac965c4-9d87-11d9-9a30-000d936b79c4", plus InDesign + * 3.0 wrote them like "bac965c4-9d87-11d9-9a30-000d936b79c4". + * + * If the name looks like a UUID simply move it to *xmpMM:InstanceID*, + * don't worry about any existing *xmpMM:InstanceID*. Both will + * only be present when a newer file with the *xmpMM:InstanceID* + * property is updated by an old app that uses *rdf:about*. + */ + private fun tweakOldXMP(tree: XMPNode) { + + if (tree.name != null && tree.name!!.length >= Utils.UUID_LENGTH) { + + var nameStr = tree.name!!.lowercase() + + if (nameStr.startsWith("uuid:")) + nameStr = nameStr.substring(5) + + if (checkUUIDFormat(nameStr)) { + + // move UUID to xmpMM:InstanceID and remove it from the root node + val path = expandXPath(XMPConst.NS_XMP_MM, "InstanceID") + val idNode = XMPNodeUtils.findNode(tree, path, true, null) + + if (idNode != null) { + + idNode.options = PropertyOptions() // Clobber any existing xmpMM:InstanceID. + idNode.value = "uuid:$nameStr" + idNode.removeChildren() + idNode.removeQualifiers() + + tree.name = null + + } else + throw XMPException("Failure creating xmpMM:InstanceID", XMPError.INTERNALFAILURE) + } + } + } + + /** + * Visit all schemas to do general fixes and handle special cases. + */ + private fun touchUpDataModel(xmp: XMPMetaImpl) { + + // make sure the DC schema is existing, because it might be needed within the normalization + // if not touched it will be removed by removeEmptySchemas + XMPNodeUtils.findSchemaNode(xmp.root, XMPConst.NS_DC, true) + + // Do the special case fixes within each schema. + val it = xmp.root.iterateChildren() + + while (it.hasNext()) { + + val currSchema = it.next() + + when { + + XMPConst.NS_DC == currSchema.name -> + normalizeDCArrays(currSchema) + + XMPConst.NS_EXIF == currSchema.name -> + XMPNodeUtils.findChildNode(currSchema, "exif:UserComment", false) + ?.let { userComment -> repairAltText(userComment) } + + XMPConst.NS_XMP_RIGHTS == currSchema.name -> + XMPNodeUtils.findChildNode(currSchema, "xmpRights:UsageTerms", false) + ?.let { usageTerms -> repairAltText(usageTerms) } + } + } + } + + /** + * Undo the denormalization performed by the XMP used in Acrobat 5. + * If a Dublin Core array had only one item, it was serialized as a simple property. + * The `xml:lang` attribute was dropped from an `alt-text` item if the language was `x-default`. + * + */ + private fun normalizeDCArrays(dcSchema: XMPNode) { + + for (index in 1..dcSchema.getChildrenLength()) { + + val currProp = dcSchema.getChild(index) + + val arrayForm = dcArrayForms[currProp.name] + + if (arrayForm == null) { + + continue + + } else if (currProp.options.isSimple()) { + + // create a new array and add the current property as child, if it was formerly simple + val newArray = XMPNode(currProp.name, null, arrayForm) + + currProp.name = XMPConst.ARRAY_ITEM_NAME + newArray.addChild(currProp) + dcSchema.replaceChild(index, newArray) + + // fix language alternatives + if (arrayForm.isArrayAltText() && !currProp.options.hasLanguage()) { + + val newLang = XMPNode(XMPConst.XML_LANG, XMPConst.X_DEFAULT) + + currProp.addQualifier(newLang) + } + + } else { + + // clear array options and add corrected array form if it has been an array before + currProp.options.setOption( + PropertyOptions.ARRAY or + PropertyOptions.ARRAY_ORDERED or + PropertyOptions.ARRAY_ALTERNATE or + PropertyOptions.ARRAY_ALT_TEXT, + false + ) + + currProp.options.mergeWith(arrayForm) + + // applying for "dc:description", "dc:rights", "dc:title" + if (arrayForm.isArrayAltText()) + repairAltText(currProp) + } + } + } + + /** + * Make sure that the array is well-formed AltText. Each item must be simple + * and have an "xml:lang" qualifier. If repairs are needed, keep simple + * non-empty items by adding the "xml:lang" with value "x-repair". + * + * @param arrayNode the property node of the array to repair. + */ + private fun repairAltText(arrayNode: XMPNode?) { + + if (arrayNode == null || !arrayNode.options.isArray()) + return // Already OK or not even an array. + + // fix options + arrayNode.options.setArrayOrdered(true).setArrayAlternate(true).setArrayAltText(true) + + val it = arrayNode.iterateChildrenMutable() + + while (it.hasNext()) { + + val currChild = it.next() + + if (currChild.options.isCompositeProperty()) { + + // Delete non-simple children. + it.remove() + + } else if (!currChild.options.hasLanguage()) { + + val childValue = currChild.value + + if (childValue == null || childValue.length == 0) { + + // Delete empty valued children that have no xml:lang. + it.remove() + + } else { + + // Add an xml:lang qualifier with the value "x-repair". + val repairLang = XMPNode(XMPConst.XML_LANG, "x-repair") + currChild.addQualifier(repairLang) + } + } + } + } + + /** + * Visit all of the top level nodes looking for aliases. If there is + * no base, transplant the alias subtree. If there is a base and strict + * aliasing is on, make sure the alias and base subtrees match. + * + * @param tree the root of the metadata tree + * @param options th parsing options + */ + private fun moveExplicitAliases(tree: XMPNode, options: ParseOptions) { + + if (!tree.hasAliases) + return + + tree.hasAliases = false + + val strictAliasing = options.getStrictAliasing() + + val schemaIt: Iterator = tree.iterateChildren() + + while (schemaIt.hasNext()) { + + val currSchema = schemaIt.next() + + if (!currSchema.hasAliases) + continue + + val propertyIt = currSchema.iterateChildrenMutable() + + while (propertyIt.hasNext()) { + + val currProp = propertyIt.next() + + if (!currProp.isAlias) + continue + + currProp.isAlias = false + + // Find the base path, look for the base schema and root node. + val info = schemaRegistry.findAlias(currProp.name!!) + + if (info != null) { + + // find or create schema + val baseSchema = XMPNodeUtils.findSchemaNode( + tree, info.getNamespace(), null, true + ) + + checkNotNull(baseSchema) { "SchemaNode should have been created." } + + baseSchema.isImplicit = false + + var baseNode = XMPNodeUtils.findChildNode( + baseSchema, + info.getPrefix() + info.getPropName(), false + ) + + if (baseNode == null) { + + if (info.getAliasForm().isSimple()) { + + // A top-to-top alias, transplant the property. + // change the alias property name to the base name + val qname = info.getPrefix() + info.getPropName() + + currProp.name = qname + + baseSchema.addChild(currProp) + + // remove the alias property + propertyIt.remove() + + } else { + + // An alias to an array item, + // create the array and transplant the property. + baseNode = XMPNode( + name = info.getPrefix() + info.getPropName(), + value = null, + options = info.getAliasForm().toPropertyOptions() + ) + + baseSchema.addChild(baseNode) + + transplantArrayItemAlias(propertyIt, currProp, baseNode) + } + + } else if (info.getAliasForm().isSimple()) { + + // The base node does exist and this is a top-to-top alias. + // Check for conflicts if strict aliasing is on. + // Remove and delete the alias subtree. + if (strictAliasing) + compareAliasedSubtrees(currProp, baseNode, true) + + propertyIt.remove() + + } else { + + // This is an alias to an array item and the array exists. + // Look for the aliased item. + // Then transplant or check & delete as appropriate. + var itemNode: XMPNode? = null + + if (info.getAliasForm().isArrayAltText()) { + + val xdIndex = XMPNodeUtils.lookupLanguageItem(baseNode, XMPConst.X_DEFAULT) + + if (xdIndex != -1) + itemNode = baseNode.getChild(xdIndex) + + } else if (baseNode.hasChildren()) { + + itemNode = baseNode.getChild(1) + } + + if (itemNode == null) { + + transplantArrayItemAlias(propertyIt, currProp, baseNode) + + } else { + + if (strictAliasing) + compareAliasedSubtrees(currProp, itemNode, true) + + propertyIt.remove() + } + } + } + } + + currSchema.hasAliases = false + } + } + + /** + * Moves an alias node of array form to another schema into an array + * + * @param propertyIt the property iterator of the old schema (used to delete the property) + * @param childNode the node to be moved + * @param baseArray the base array for the array item + * + */ + private fun transplantArrayItemAlias( + propertyIt: MutableIterator, + childNode: XMPNode, + baseArray: XMPNode + ) { + + if (baseArray.options.isArrayAltText()) { + + // *** Allow x-default. + if (childNode.options.hasLanguage()) + throw XMPException("Alias to x-default already has a language qualifier", XMPError.BADXMP) + + val langQual = XMPNode(XMPConst.XML_LANG, XMPConst.X_DEFAULT) + + childNode.addQualifier(langQual) + } + + propertyIt.remove() + + childNode.name = XMPConst.ARRAY_ITEM_NAME + + baseArray.addChild(childNode) + } + + /** + * Remove all empty schemas from the metadata tree that were generated during the rdf parsing. + * + * @param tree the root of the metadata tree + */ + private fun deleteEmptySchemas(tree: XMPNode) { + + // Delete empty schema nodes. Do this last, other cleanup can make empty schema. + + val it = tree.iterateChildrenMutable() + + while (it.hasNext()) { + + val schema = it.next() + + if (!schema.hasChildren()) + it.remove() + } + } + + /** + * The outermost call is special. The names almost certainly differ. The + * qualifiers (and hence options) will differ for an alias to the x-default + * item of a langAlt array. + * + * @param aliasNode the alias node + * @param baseNode the base node of the alias + * @param outerCall marks the outer call of the recursion + * + */ + private fun compareAliasedSubtrees( + aliasNode: XMPNode, + baseNode: XMPNode, + outerCall: Boolean + ) { + + if (aliasNode.value != baseNode.value || aliasNode.getChildrenLength() != baseNode.getChildrenLength()) + throw XMPException("Mismatch between alias and base nodes", XMPError.BADXMP) + + if (!outerCall && + ( + aliasNode.name != baseNode.name || + !aliasNode.options.equals(baseNode.options) || + aliasNode.getQualifierLength() != baseNode.getQualifierLength() + ) + ) + throw XMPException("Mismatch between alias and base nodes", XMPError.BADXMP) + + run { + val an = aliasNode.iterateChildren() + val bn = baseNode.iterateChildren() + + while (an.hasNext() && bn.hasNext()) { + val aliasChild = an.next() + val baseChild = bn.next() + compareAliasedSubtrees(aliasChild, baseChild, false) + } + } + + val an = aliasNode.iterateQualifier() + val bn = baseNode.iterateQualifier() + + while (an.hasNext() && bn.hasNext()) { + + val aliasQual = an.next() + val baseQual = bn.next() + + compareAliasedSubtrees(aliasQual, baseQual, false) + } + } + + /** + * Initializes the map that contains the known arrays, that are fixed by + * [XMPNormalizer.normalizeDCArrays]. + */ + private fun createDCArrays(): Map { + + val dcArrayForms = mutableMapOf() + + // Properties supposed to be a "Bag". + val bagForm = PropertyOptions() + bagForm.setArray(true) + dcArrayForms["dc:contributor"] = bagForm + dcArrayForms["dc:language"] = bagForm + dcArrayForms["dc:publisher"] = bagForm + dcArrayForms["dc:relation"] = bagForm + dcArrayForms["dc:subject"] = bagForm + dcArrayForms["dc:type"] = bagForm + + // Properties supposed to be a "Seq". + val seqForm = PropertyOptions() + seqForm.setArray(true) + seqForm.setArrayOrdered(true) + dcArrayForms["dc:creator"] = seqForm + dcArrayForms["dc:date"] = seqForm + + // Properties supposed to be an "Alt" in alternative-text form. + val altTextForm = PropertyOptions() + altTextForm.setArray(true) + altTextForm.setArrayOrdered(true) + altTextForm.setArrayAlternate(true) + altTextForm.setArrayAltText(true) + dcArrayForms["dc:description"] = altTextForm + dcArrayForms["dc:rights"] = altTextForm + dcArrayForms["dc:title"] = altTextForm + + return dcArrayForms + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPRDFParser.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPRDFParser.kt new file mode 100644 index 0000000..971e819 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPRDFParser.kt @@ -0,0 +1,1185 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl + +import com.ashampoo.xmp.XMPConst +import com.ashampoo.xmp.XMPError +import com.ashampoo.xmp.XMPException +import com.ashampoo.xmp.XMPMetaFactory.schemaRegistry +import com.ashampoo.xmp.options.ParseOptions +import com.ashampoo.xmp.options.PropertyOptions +import nl.adaptivity.xmlutil.dom.Attr +import nl.adaptivity.xmlutil.dom.Element +import nl.adaptivity.xmlutil.dom.Node +import nl.adaptivity.xmlutil.dom.Text +import nl.adaptivity.xmlutil.dom.attributes +import nl.adaptivity.xmlutil.dom.childNodes +import nl.adaptivity.xmlutil.dom.data +import nl.adaptivity.xmlutil.dom.length +import nl.adaptivity.xmlutil.dom.localName +import nl.adaptivity.xmlutil.dom.namespaceURI +import nl.adaptivity.xmlutil.dom.nodeName +import nl.adaptivity.xmlutil.dom.nodeType +import nl.adaptivity.xmlutil.dom.ownerElement +import nl.adaptivity.xmlutil.dom.prefix +import nl.adaptivity.xmlutil.dom.value + +/** + * Parser for "normal" XML serialisation of RDF. + */ +internal object XMPRDFParser : XMPError { + + const val RDFTERM_OTHER = 0 + + /** + * Start of coreSyntaxTerms. + */ + const val RDFTERM_RDF = 1 + + const val RDFTERM_ID = 2 + + const val RDFTERM_ABOUT = 3 + + const val RDFTERM_PARSE_TYPE = 4 + + const val RDFTERM_RESOURCE = 5 + + const val RDFTERM_NODE_ID = 6 + + /** + * End of coreSyntaxTerms + */ + const val RDFTERM_DATATYPE = 7 + + /** + * Start of additions for syntax Terms. + */ + const val RDFTERM_DESCRIPTION = 8 + + /** + * End of of additions for syntaxTerms. + */ + const val RDFTERM_LI = 9 + + /** + * Start of oldTerms. + */ + const val RDFTERM_ABOUT_EACH = 10 + + const val RDFTERM_ABOUT_EACH_PREFIX = 11 + + /** + * End of oldTerms. + */ + const val RDFTERM_BAG_ID = 12 + + const val RDFTERM_FIRST_CORE = RDFTERM_RDF + + const val RDFTERM_LAST_CORE = RDFTERM_DATATYPE + + /** + * ! Yes, the syntax terms include the core terms. + */ + const val RDFTERM_FIRST_SYNTAX = RDFTERM_FIRST_CORE + + const val RDFTERM_LAST_SYNTAX = RDFTERM_LI + + const val RDFTERM_FIRST_OLD = RDFTERM_ABOUT_EACH + + const val RDFTERM_LAST_OLD = RDFTERM_BAG_ID + + /** + * this prefix is used for default namespaces + */ + const val DEFAULT_PREFIX = "_dflt" + + /** + * The main parsing method. The XML tree is walked through from the root node and and XMP tree + * is created. This is a raw parse, the normalisation of the XMP tree happens outside. + * + */ + @kotlin.jvm.JvmStatic + fun parse(xmlRoot: Node, options: ParseOptions): XMPMetaImpl { + + val xmp = XMPMetaImpl() + + parseRdfRoot(xmp, xmlRoot, options) + + return xmp + } + + /** + * Each of these parsing methods is responsible for recognizing an RDF + * syntax production and adding the appropriate structure to the XMP tree. + * They simply return for success, failures will throw an exception. + */ + @Suppress("ThrowsCount") + fun parseRdfRoot(xmp: XMPMetaImpl, rdfRdfNode: Node, options: ParseOptions) { + + if (rdfRdfNode.nodeName != "rdf:RDF") + throw XMPException("Root node should be of type rdf:RDF", XMPError.BADRDF) + + if (rdfRdfNode !is Element) + throw XMPException("Root node must be of element type.", XMPError.BADRDF) + + if (rdfRdfNode.attributes.length == 0) + throw XMPException("Illegal: rdf:RDF node has no attributes", XMPError.BADRDF) + + for (index in 0 until rdfRdfNode.childNodes.length) { + + val child = rdfRdfNode.childNodes.item(index)!! + + /* Filter whitespace nodes. */ + if (isWhitespaceNode(child)) + continue + + parseRdfNodeElement(xmp, xmp.root, child as Element, true, options) + } + } + + /** + * 7.2.5 nodeElementURIs + * anyURI - ( coreSyntaxTerms | rdf:li | oldTerms ) + * + * 7.2.11 nodeElement + * start-element ( URI == nodeElementURIs, + * attributes == set ( ( idAttr | nodeIdAttr | aboutAttr )?, propertyAttr* ) ) + * propertyEltList + * end-element() + * + * A node element URI is rdf:Description or anything else that is not an RDF + * term. + */ + private fun parseRdfNodeElement( + xmp: XMPMetaImpl, + xmpParent: XMPNode, + xmlNode: Element, + isTopLevel: Boolean, + options: ParseOptions + ) { + + val nodeTerm = getRDFTermKind(xmlNode) + + if (nodeTerm != RDFTERM_DESCRIPTION && nodeTerm != RDFTERM_OTHER) + throw XMPException("Node element must be rdf:Description or typed node", XMPError.BADRDF) + + if (isTopLevel && nodeTerm == RDFTERM_OTHER) + throw XMPException("Top level typed node not allowed", XMPError.BADXMP) + + parseRdfNodeElementAttrs(xmp, xmpParent, xmlNode, isTopLevel) + parseRdfPropertyElementList(xmp, xmpParent, xmlNode, isTopLevel, options) + } + + /** + * 7.2.7 propertyAttributeURIs + * anyURI - ( coreSyntaxTerms | rdf:Description | rdf:li | oldTerms ) + * + * 7.2.11 nodeElement + * start-element ( URI == nodeElementURIs, + * attributes == set ( ( idAttr | nodeIdAttr | aboutAttr )?, propertyAttr* ) ) + * propertyEltList + * + * Process the attribute list for an RDF node element. A property attribute URI is + * anything other than an RDF term. The rdf:ID and rdf:nodeID attributes are simply ignored, + * as are rdf:about attributes on inner nodes. + * + */ + private fun parseRdfNodeElementAttrs( + xmp: XMPMetaImpl, + xmpParent: XMPNode, + xmlNode: Element, + isTopLevel: Boolean + ) { + + // Used to detect attributes that are mutually exclusive. + var exclusiveAttrs = 0 + + for (index in 0 until xmlNode.attributes.length) { + + val attribute = xmlNode.attributes.item(index) as Attr + + // quick hack, ns declarations do not appear in C++ + // ignore "ID" without namespace + if ("xmlns" == attribute.prefix || attribute.prefix == null && "xmlns" == attribute.nodeName) + continue + + val attrTerm = getRDFTermKind(attribute) + + when (attrTerm) { + + RDFTERM_ID, RDFTERM_NODE_ID, RDFTERM_ABOUT -> { + + if (exclusiveAttrs > 0) + throw XMPException("Mutally exclusive about, ID, nodeID attributes", XMPError.BADRDF) + + exclusiveAttrs++ + + if (isTopLevel && attrTerm == RDFTERM_ABOUT) { + + // This is the rdf:about attribute on a top level node. Set + // the XMP tree name if + // it doesn't have a name yet. Make sure this name matches + // the XMP tree name. + if (xmpParent.name != null && xmpParent.name!!.length > 0) { + + if (xmpParent.name != attribute.value) + throw XMPException("Mismatched top level rdf:about values", XMPError.BADXMP) + + } else { + xmpParent.name = attribute.value + } + } + } + + RDFTERM_OTHER -> + addChildNode(xmp, xmpParent, attribute, attribute.value, isTopLevel) + + else -> throw XMPException("Invalid nodeElement attribute", XMPError.BADRDF) + } + } + } + + /** + * 7.2.13 propertyEltList + * ws* ( propertyElt ws* )* + * + * @param xmp the xmp metadata object that is generated + * @param xmpParent the parent xmp node + * @param xmlParent the currently processed XML node + * @param isTopLevel Flag if the node is a top-level node + * @param options ParseOptions to indicate the parse options provided by the client + * + */ + private fun parseRdfPropertyElementList( + xmp: XMPMetaImpl, + xmpParent: XMPNode, + xmlParent: Node?, + isTopLevel: Boolean, + options: ParseOptions + ) { + + for (index in 0 until xmlParent!!.childNodes.length) { + + val currChild = xmlParent.childNodes.item(index)!! + + if (isWhitespaceNode(currChild)) + continue + + if (currChild !is Element) + throw XMPException("Expected property element node not found", XMPError.BADRDF) + + parseRdfPropertyElement(xmp, xmpParent, currChild, isTopLevel, options) + } + } + + /** + * 7.2.14 propertyElt + * + * resourcePropertyElt | literalPropertyElt | parseTypeLiteralPropertyElt | + * parseTypeResourcePropertyElt | parseTypeCollectionPropertyElt | + * parseTypeOtherPropertyElt | emptyPropertyElt + * + * 7.2.15 resourcePropertyElt + * start-element ( URI == propertyElementURIs, attributes == set ( idAttr? ) ) + * ws* nodeElement ws* + * end-element() + * + * 7.2.16 literalPropertyElt + * start-element ( + * URI == propertyElementURIs, attributes == set ( idAttr?, datatypeAttr?) ) + * text() + * end-element() + * + * 7.2.17 parseTypeLiteralPropertyElt + * start-element ( + * URI == propertyElementURIs, attributes == set ( idAttr?, parseLiteral ) ) + * literal + * end-element() + * + * 7.2.18 parseTypeResourcePropertyElt + * start-element ( + * URI == propertyElementURIs, attributes == set ( idAttr?, parseResource ) ) + * propertyEltList + * end-element() + * + * 7.2.19 parseTypeCollectionPropertyElt + * start-element ( + * URI == propertyElementURIs, attributes == set ( idAttr?, parseCollection ) ) + * nodeElementList + * end-element() + * + * 7.2.20 parseTypeOtherPropertyElt + * start-element ( URI == propertyElementURIs, attributes == set ( idAttr?, parseOther ) ) + * propertyEltList + * end-element() + * + * 7.2.21 emptyPropertyElt + * start-element ( URI == propertyElementURIs, + * attributes == set ( idAttr?, ( resourceAttr | nodeIdAttr )?, propertyAttr* ) ) + * end-element() + * + * The various property element forms are not distinguished by the XML element name, + * but by their attributes for the most part. The exceptions are resourcePropertyElt and + * literalPropertyElt. They are distinguished by their XML element content. + * + * NOTE: The RDF syntax does not explicitly include the xml:lang attribute although it can + * appear in many of these. We have to allow for it in the attibute counts below. + * + */ + private fun parseRdfPropertyElement( + xmp: XMPMetaImpl, + xmpParent: XMPNode, + xmlNode: Element, + isTopLevel: Boolean, + options: ParseOptions + ) { + + val nodeTerm = getRDFTermKind(xmlNode) + + if (!isPropertyElementName(nodeTerm)) + throw XMPException("Invalid property element name", XMPError.BADRDF) + + // remove the namespace-definitions from the list + val attributes = xmlNode.attributes + + var nsAttrs: MutableList? = null + + for (index in 0 until attributes.length) { + + val attribute = attributes.item(index) as Attr + + if ("xmlns" == attribute.prefix || attribute.prefix == null && "xmlns" == attribute.nodeName) { + + if (nsAttrs == null) + nsAttrs = mutableListOf() + + nsAttrs.add(attribute.nodeName) + } + } + + if (nsAttrs != null) { + + val it = nsAttrs.iterator() + + while (it.hasNext()) + attributes.removeNamedItem(it.next()) + } + + if (attributes.length > 3) { + + // Only an emptyPropertyElt can have more than 3 attributes. + parseEmptyPropertyElement(xmp, xmpParent, xmlNode, isTopLevel) + + } else { + + // Look through the attributes for one that isn't rdf:ID or xml:lang, + // it will usually tell what we should be dealing with. + // The called routines must verify their specific syntax! + for (index in 0 until attributes.length) { + + val attribute = attributes.item(index) as Attr + + val attrValue = attribute.value + + val condition = XMPConst.XML_LANG == attribute.nodeName && + !("ID" == attribute.localName && XMPConst.NS_RDF == attribute.namespaceURI) + + if (!condition) { + + when { + + "datatype" == attribute.localName && XMPConst.NS_RDF == attribute.namespaceURI -> + parseRdfLiteralPropertyElement(xmp, xmpParent, xmlNode, isTopLevel) + + !("parseType" == attribute.localName && XMPConst.NS_RDF == attribute.namespaceURI) -> + parseEmptyPropertyElement(xmp, xmpParent, xmlNode, isTopLevel) + + "Literal" == attrValue -> + throw XMPException("Literal property element not allowed", XMPError.BADXMP) + + "Resource" == attrValue -> + parseTypeResourcePropertyElement(xmp, xmpParent, xmlNode, isTopLevel, options) + + "Collection" == attrValue -> + throw XMPException("Collection property element forbidden", XMPError.BADXMP) + + else -> + throw XMPException("Other property element not allowed", XMPError.BADXMP) + } + + return + } + } + + // Only rdf:ID and xml:lang, could be a resourcePropertyElt, a literalPropertyElt, + // or an emptyPropertyElt. Look at the child XML nodes to decide which. + if (xmlNode.childNodes.length > 0) { + + for (index in 0 until xmlNode.childNodes.length) { + + val currentChild = xmlNode.childNodes.item(index) + + if (currentChild !is Text) { + + parseRdfResourcePropertyElement(xmp, xmpParent, xmlNode, isTopLevel, options) + + return + } + } + + parseRdfLiteralPropertyElement(xmp, xmpParent, xmlNode, isTopLevel) + + } else + parseEmptyPropertyElement(xmp, xmpParent, xmlNode, isTopLevel) + } + } + + /** + * 7.2.15 resourcePropertyElt + * start-element ( URI == propertyElementURIs, attributes == set ( idAttr? ) ) + * ws* nodeElement ws* + * end-element() + * + * This handles structs using an rdf:Description node, + * arrays using rdf:Bag/Seq/Alt, and typedNodes. It also catches and cleans up qualified + * properties written with rdf:Description and rdf:value. + */ + private fun parseRdfResourcePropertyElement( + xmp: XMPMetaImpl, + xmpParent: XMPNode, + xmlNode: Element, + isTopLevel: Boolean, + options: ParseOptions + ) { + + // Strip old "punchcard" chaff which has on the prefix "iX:". + if (isTopLevel && "iX:changes" == xmlNode.nodeName) + return + + val newCompound = addChildNode(xmp, xmpParent, xmlNode, "", isTopLevel) + + // walk through the attributes + @Suppress("LoopWithTooManyJumpStatements") + for (index in 0 until xmlNode.attributes.length) { + + val attribute = xmlNode.attributes.item(index) as Attr + + if ("xmlns" == attribute.prefix || attribute.prefix == null && "xmlns" == attribute.nodeName) + continue + + if (XMPConst.XML_LANG == attribute.nodeName) + addQualifierNode(newCompound, XMPConst.XML_LANG, attribute.value) + else if ("ID" == attribute.localName && XMPConst.NS_RDF == attribute.namespaceURI) + continue // Ignore all rdf:ID attributes. + else + throw XMPException("Invalid attribute for resource property element", XMPError.BADRDF) + } + + // walk through the children + var found = false + + for (index in 0 until xmlNode.childNodes.length) { + + val currentChild = xmlNode.childNodes.item(index)!! + + if (!isWhitespaceNode(currentChild)) { + + if (currentChild is Element && !found) { + + val isRDF = XMPConst.NS_RDF == currentChild.namespaceURI + + val localName = currentChild.localName + + when { + + isRDF && "Bag" == localName -> + newCompound.options.setArray(true) + + isRDF && "Seq" == localName -> + newCompound.options.setArray(true).setArrayOrdered(true) + + isRDF && "Alt" == localName -> + newCompound.options.setArray(true).setArrayOrdered(true).setArrayAlternate(true) + + else -> { + + newCompound.options.setStruct(true) + + if (!isRDF && "Description" != localName) { + + var typeName = currentChild.namespaceURI + ?: throw XMPException( + "All XML elements must be in a namespace", XMPError.BADXMP + ) + + typeName += ":$localName" + + addQualifierNode(newCompound, "rdf:type", typeName) + } + } + } + + parseRdfNodeElement(xmp, newCompound, currentChild, false, options) + + if (newCompound.hasValueChild) + fixupQualifiedNode(newCompound) + else if (newCompound.options.isArrayAlternate()) + XMPNodeUtils.detectAltText(newCompound) + + found = true + + } else if (found) { + // found second child element + throw XMPException("Invalid child of resource property element", XMPError.BADRDF) + } else { + throw XMPException( + "Children of resource property element must be XML elements", XMPError.BADRDF + ) + } + } + } + + if (!found) + throw XMPException("Missing child of resource property element", XMPError.BADRDF) + } + + /** + * 7.2.16 literalPropertyElt + * start-element ( URI == propertyElementURIs, + * attributes == set ( idAttr?, datatypeAttr?) ) + * text() + * end-element() + * + * Add a leaf node with the text value and qualifiers for the attributes. + */ + private fun parseRdfLiteralPropertyElement( + xmp: XMPMetaImpl, + xmpParent: XMPNode, + xmlNode: Element, + isTopLevel: Boolean + ) { + + val newChild = addChildNode(xmp, xmpParent, xmlNode, null, isTopLevel) + + @Suppress("LoopWithTooManyJumpStatements") + for (index in 0 until xmlNode.attributes.length) { + + val attribute = xmlNode.attributes.item(index) as Attr + + if ("xmlns" == attribute.prefix || attribute.prefix == null && "xmlns" == attribute.nodeName) + continue + + if (XMPConst.XML_LANG == attribute.nodeName) + addQualifierNode(newChild, XMPConst.XML_LANG, attribute.value) + else if ( + XMPConst.NS_RDF == attribute.namespaceURI && + ("ID" == attribute.localName || "datatype" == attribute.localName) + ) + continue // Ignore all rdf:ID and rdf:datatype attributes. + else + throw XMPException("Invalid attribute for literal property element", XMPError.BADRDF) + } + + var textValue = "" + + for (index in 0 until xmlNode.childNodes.length) { + + val child = xmlNode.childNodes.item(index) + + if (child !is Text) + throw XMPException("Invalid child of literal property element", XMPError.BADRDF) + + textValue += child.data + } + + newChild.value = textValue + } + + /** + * 7.2.18 parseTypeResourcePropertyElt + * start-element ( URI == propertyElementURIs, + * attributes == set ( idAttr?, parseResource ) ) + * propertyEltList + * end-element() + * + * Add a new struct node with a qualifier for the possible rdf:ID attribute. + * Then process the XML child nodes to get the struct fields. + */ + private fun parseTypeResourcePropertyElement( + xmp: XMPMetaImpl, + xmpParent: XMPNode, + xmlNode: Element, + isTopLevel: Boolean, + options: ParseOptions + ) { + + val newStruct = addChildNode(xmp, xmpParent, xmlNode, "", isTopLevel) + + newStruct.options.setStruct(true) + + @Suppress("LoopWithTooManyJumpStatements") + for (index in 0 until xmlNode.attributes.length) { + + val attribute = xmlNode.attributes.item(index) as Attr + + if ("xmlns" == attribute.prefix || attribute.prefix == null && "xmlns" == attribute.nodeName) + continue + + if (XMPConst.XML_LANG == attribute.nodeName) { + addQualifierNode(newStruct, XMPConst.XML_LANG, attribute.value) + } else if ( + XMPConst.NS_RDF == attribute.namespaceURI && + ("ID" == attribute.localName || "parseType" == attribute.localName) + ) { + continue // The caller ensured the value is "Resource". Ignore all rdf:ID attributes. + } else { + throw XMPException( + "Invalid attribute for ParseTypeResource property element", XMPError.BADRDF + ) + } + } + + parseRdfPropertyElementList(xmp, newStruct, xmlNode, false, options) + + if (newStruct.hasValueChild) + fixupQualifiedNode(newStruct) + } + + /** + * 7.2.21 emptyPropertyElt + * start-element ( URI == propertyElementURIs, + * attributes == set ( + * idAttr?, ( resourceAttr | nodeIdAttr )?, propertyAttr* ) ) + * end-element() + * + * + * + * + * + * + * An emptyPropertyElt is an element with no contained content, just a possibly empty set of + * attributes. An emptyPropertyElt can represent three special cases of simple XMP properties: a + * simple property with an empty value (ns:Prop1), a simple property whose value is a URI + * (ns:Prop2), or a simple property with simple qualifiers (ns:Prop3). + * An emptyPropertyElt can also represent an XMP struct whose fields are all simple and + * unqualified (ns:Prop4). + * + * It is an error to use both rdf:value and rdf:resource - that can lead to invalid RDF in the + * verbose form written using a literalPropertyElt. + * + * The XMP mapping for an emptyPropertyElt is a bit different from generic RDF, partly for + * design reasons and partly for historical reasons. The XMP mapping rules are: + * + * 1. If there is an rdf:value attribute then this is a simple property with a text value. + * All other attributes are qualifiers. + * 2. If there is an rdf:resource attribute then this is a simple property with a URI value. + * All other attributes are qualifiers. + * 3. If there are no attributes other than xml:lang, rdf:ID, or rdf:nodeID then this is a simple + * property with an empty value. + * 4. Otherwise this is a struct, the attributes other than xml:lang, rdf:ID, or rdf:nodeID are fields. + */ + private fun parseEmptyPropertyElement( + xmp: XMPMetaImpl, + xmpParent: XMPNode, + xmlNode: Element, + isTopLevel: Boolean + ) { + + var hasPropertyAttrs = false + var hasResourceAttr = false + var hasNodeIDAttr = false + var hasValueAttr = false + var valueNode: Node? = null // ! Can come from rdf:value or rdf:resource. + + if (xmlNode.childNodes.length > 0) + throw XMPException( + "Nested content not allowed with rdf:resource or property attributes", XMPError.BADRDF + ) + + // First figure out what XMP this maps to and remember the XML node for a simple value. + for (index in 0 until xmlNode.attributes.length) { + + val attribute = xmlNode.attributes.item(index) as Attr + + if ("xmlns" == attribute.prefix || attribute.prefix == null && "xmlns" == attribute.nodeName) + continue + + val attrTerm = getRDFTermKind(attribute) + + when (attrTerm) { + + RDFTERM_ID -> { + /* Do nothing. */ + } + + RDFTERM_RESOURCE -> { + + if (hasNodeIDAttr) { + throw XMPException( + "Empty property element can't have both rdf:resource and rdf:nodeID", + XMPError.BADRDF + ) + } else if (hasValueAttr) { + throw XMPException( + "Empty property element can't have both rdf:value and rdf:resource", + XMPError.BADXMP + ) + } + + hasResourceAttr = true + + if (!hasValueAttr) + valueNode = attribute + } + + RDFTERM_NODE_ID -> { + + if (hasResourceAttr) { + throw XMPException( + "Empty property element can't have both rdf:resource and rdf:nodeID", + XMPError.BADRDF + ) + } + + hasNodeIDAttr = true + } + + RDFTERM_OTHER -> { + + if (attribute.localName == "value" && attribute.namespaceURI == XMPConst.NS_RDF) { + + if (hasResourceAttr) { + throw XMPException( + "Empty property element can't have both rdf:value and rdf:resource", + XMPError.BADXMP + ) + } + + hasValueAttr = true + valueNode = attribute + + } else if (XMPConst.XML_LANG != attribute.nodeName) { + + hasPropertyAttrs = true + } + } + + else -> + throw XMPException("Unrecognized attribute of empty property element", XMPError.BADRDF) + } + } + + // Create the right kind of child node and visit the attributes again + // to add the fields or qualifiers. + // ! Because of implementation vagaries, + // the xmpParent is the tree root for top level properties. + // ! The schema is found, created if necessary, by addChildNode. + val childNode = addChildNode(xmp, xmpParent, xmlNode, "", isTopLevel) + + var childIsStruct = false + + if (hasValueAttr || hasResourceAttr) { + + val valueNodeValue = when (valueNode) { + null -> null + is Attr -> valueNode.value + else -> throw XMPException("Unknown Node ${xmlNode.nodeType}", XMPError.BADXMP) + } + + childNode.value = valueNodeValue ?: "" + + // ! Might have both rdf:value and rdf:resource. + if (!hasValueAttr) + childNode.options.setURI(true) + + } else if (hasPropertyAttrs) { + childNode.options.setStruct(true) + childIsStruct = true + } + + for (index in 0 until xmlNode.attributes.length) { + + val attribute = xmlNode.attributes.item(index) as Attr + + if ( + attribute === valueNode || "xmlns" == attribute.prefix || + attribute.prefix == null && "xmlns" == attribute.nodeName + ) + continue // Skip the rdf:value or rdf:resource attribute holding the value. + + val attrTerm = getRDFTermKind(attribute) + + when (attrTerm) { + + RDFTERM_ID, RDFTERM_NODE_ID -> { + /* Do nothing. */ + } + + RDFTERM_RESOURCE -> + addQualifierNode(childNode, "rdf:resource", attribute.value) + + RDFTERM_OTHER -> { + + if (!childIsStruct) + addQualifierNode(childNode, attribute.nodeName, attribute.value) + else if (XMPConst.XML_LANG == attribute.nodeName) + addQualifierNode(childNode, XMPConst.XML_LANG, attribute.value) + else + addChildNode(xmp, childNode, attribute, attribute.value, false) + } + + else -> throw XMPException( + "Unrecognized attribute of empty property element", + XMPError.BADRDF + ) + } + } + } + + private fun addChildNode( + xmp: XMPMetaImpl, + xmpParent: XMPNode, + xmlNode: Node, + value: String?, + isTopLevel: Boolean + ): XMPNode { + + var actualXmpParent = xmpParent + + val registry = schemaRegistry + + var namespace = when (xmlNode) { + is Element -> xmlNode.namespaceURI + is Attr -> xmlNode.namespaceURI + else -> throw XMPException("Unknown Node ${xmlNode.nodeType}", XMPError.BADXMP) + } + + if (namespace.isNullOrEmpty()) + throw XMPException( + "XML namespace required for all elements and attributes: $xmlNode", + XMPError.BADRDF + ) + + // Fix a legacy DC namespace + if (XMPConst.NS_DC_DEPRECATED == namespace) + namespace = XMPConst.NS_DC + + var prefix = registry.getNamespacePrefix(namespace) + + if (prefix == null) { + + val xmlNodePrefix = when (xmlNode) { + is Element -> xmlNode.prefix + is Attr -> xmlNode.prefix + else -> throw XMPException("Unknown Node ${xmlNode.nodeType}", XMPError.BADXMP) + } + + prefix = if (xmlNodePrefix != null) + xmlNodePrefix + else + DEFAULT_PREFIX + + prefix = registry.registerNamespace(namespace, prefix) + } + + val xmlNodeLocalName = when (xmlNode) { + is Element -> xmlNode.localName + is Attr -> xmlNode.localName + else -> throw XMPException("Unknown Node ${xmlNode.nodeType}", XMPError.BADXMP) + } + + val childName = prefix + xmlNodeLocalName + + // create schema node if not already there + val childOptions = PropertyOptions() + + var isAlias = false + + if (isTopLevel) { + + // Lookup the schema node, adjust the XMP parent pointer. + // Incoming parent must be the tree root. + val schemaNode = XMPNodeUtils.findSchemaNode( + xmp.root, namespace, + DEFAULT_PREFIX, true + ) + + checkNotNull(schemaNode) { "SchemaNode should have been created." } + + schemaNode.isImplicit = false // Clear the implicit node bit. + + // *** Should use "opt &= ~flag" (no conditional), + // need runtime check for proper 32 bit code. + actualXmpParent = schemaNode + + // If this is an alias set the alias flag in the node + // and the hasAliases flag in the tree. + if (registry.findAlias(childName) != null) { + isAlias = true + xmp.root.hasAliases = true + schemaNode.hasAliases = true + } + } + + // Make sure that this is not a duplicate of a named node. + val isArrayItem = isNumberedArrayItemName(childName) + val isValueNode = "rdf:value" == childName + + // Create XMP node and so some checks + val newChild = XMPNode(childName, value, childOptions) + + newChild.isAlias = isAlias + + // Add the new child to the XMP parent node, a value node first. + if (!isValueNode) + actualXmpParent.addChild(newChild) + else + actualXmpParent.addChild(1, newChild) + + if (isValueNode) { + + if (isTopLevel || !actualXmpParent.options.isStruct()) + throw XMPException("Misplaced rdf:value element", XMPError.BADRDF) + + actualXmpParent.hasValueChild = true + } + + val isParentArray = actualXmpParent.options.isArray() + + when { + + isParentArray && isArrayItem -> + newChild.name = XMPConst.ARRAY_ITEM_NAME + + !isParentArray && isArrayItem -> + throw XMPException("Misplaced rdf:li element", XMPError.BADRDF) + + isParentArray && !isArrayItem -> + throw XMPException("Arrays cannot have arbitrary child names", XMPError.BADRDF) + } + + return newChild + } + + private fun addQualifierNode(xmpParent: XMPNode, name: String, value: String): XMPNode { + + val isLang = XMPConst.XML_LANG == name + + // normalize value of language qualifiers + val normalizedValue = if (isLang) + Utils.normalizeLangValue(value) + else + value + + val newQualifier = XMPNode(name, normalizedValue) + + xmpParent.addQualifier(newQualifier) + + return newQualifier + } + + /** + * The parent is an RDF pseudo-struct containing an rdf:value field. Fix the + * XMP data model. The rdf:value node must be the first child, the other + * children are qualifiers. The form, value, and children of the rdf:value + * node are the real ones. The rdf:value node's qualifiers must be added to + * the others. + */ + private fun fixupQualifiedNode(xmpParent: XMPNode) { + + require(xmpParent.options.isStruct() && xmpParent.hasChildren()) + + val valueNode = xmpParent.getChild(1) + + require("rdf:value" == valueNode.name) + + // Move the qualifiers on the value node to the parent. + // Make sure an xml:lang qualifier stays at the front. + // Check for duplicate names between the value node's qualifiers and the parent's children. + // The parent's children are about to become qualifiers. Check here, between the groups. + // Intra-group duplicates are caught by XMPNode#addChild(...). + + if (valueNode.options.hasLanguage()) { + + if (xmpParent.options.hasLanguage()) + throw XMPException("Redundant xml:lang for rdf:value element", XMPError.BADXMP) + + val langQual = valueNode.getQualifier(1) + + valueNode.removeQualifier(langQual) + + xmpParent.addQualifier(langQual) + } + + // Start the remaining copy after the xml:lang qualifier. + for (index in 1..valueNode.getQualifierLength()) { + + val qualifier = valueNode.getQualifier(index) + + xmpParent.addQualifier(qualifier) + } + + // Change the parent's other children into qualifiers. + // This loop starts at 1, child 0 is the rdf:value node. + for (index in 2..xmpParent.getChildrenLength()) { + + val qualifier = xmpParent.getChild(index) + + xmpParent.addQualifier(qualifier) + } + + check(xmpParent.options.isStruct() || xmpParent.hasValueChild) + + xmpParent.hasValueChild = false + xmpParent.options.setStruct(false) + xmpParent.options.mergeWith(valueNode.options) + xmpParent.value = valueNode.value + xmpParent.removeChildren() + + for (child in valueNode.getChildren()) + xmpParent.addChild(child) + } + + /** + * Checks if the node is a white space. + * + * @param node an XML-node + * @return Returns whether the node is a whitespace node, i.e. a text node that contains only whitespaces. + */ + private fun isWhitespaceNode(node: Node): Boolean { + + if (node !is Text) + return false + + val value = node.data + + for (index in 0 until value.length) + if (!value[index].isWhitespace()) + return false + + return true + } + + /** + * 7.2.6 propertyElementURIs + * anyURI - ( coreSyntaxTerms | rdf:Description | oldTerms ) + */ + private fun isPropertyElementName(term: Int): Boolean { + + if (term == RDFTERM_DESCRIPTION || isOldTerm(term)) + return false + + return !isCoreSyntaxTerm(term) + } + + /** + * 7.2.4 oldTerms

+ * rdf:aboutEach | rdf:aboutEachPrefix | rdf:bagID + * + * @param term the term id + * @return Returns true if the term is an old term. + */ + private fun isOldTerm(term: Int): Boolean = + RDFTERM_FIRST_OLD <= term && term <= RDFTERM_LAST_OLD + + /** + * 7.2.2 coreSyntaxTerms

+ * rdf:RDF | rdf:ID | rdf:about | rdf:parseType | rdf:resource | rdf:nodeID | + * rdf:datatype + * + * @param term the term id + * @return Return true if the term is a core syntax term + */ + private fun isCoreSyntaxTerm(term: Int): Boolean = + RDFTERM_FIRST_CORE <= term && term <= RDFTERM_LAST_CORE + + /** + * Determines the ID for a certain RDF Term. + * Arranged to hopefully minimize the parse time for large XMP. + * + * @param node an XML node + * @return Returns the term ID. + */ + private fun getRDFTermKind(node: Node): Int { + + val nodeName = node.nodeName + + var namespace = when (node) { + is Element -> node.namespaceURI + is Attr -> node.namespaceURI + else -> throw XMPException("Unknown Node ${node.nodeType}", XMPError.BADXMP) + } + + if (namespace == null && + ("about" == nodeName || "ID" == nodeName) && + node is Attr && XMPConst.NS_RDF == node.ownerElement?.namespaceURI + ) { + namespace = XMPConst.NS_RDF + } + + if (namespace == XMPConst.NS_RDF) { + + when (nodeName) { + + "rdf:li" -> + return RDFTERM_LI + + "parseType" -> + return RDFTERM_PARSE_TYPE + + "rdf:Description" -> + return RDFTERM_DESCRIPTION + + "rdf:about" -> + return RDFTERM_ABOUT + + "resource" -> + return RDFTERM_RESOURCE + + "rdf:RDF" -> + return RDFTERM_RDF + + "ID" -> + return RDFTERM_ID + + "nodeID" -> + return RDFTERM_NODE_ID + + "datatype" -> + return RDFTERM_DATATYPE + + "aboutEach" -> + return RDFTERM_ABOUT_EACH + + "aboutEachPrefix" -> + return RDFTERM_ABOUT_EACH_PREFIX + + "bagID" -> + return RDFTERM_BAG_ID + } + } + + return RDFTERM_OTHER + } + + private fun isNumberedArrayItemName(nodeName: String): Boolean { + + var result = "rdf:li" == nodeName + + if (nodeName.startsWith("rdf:_")) { + + result = true + + for (i in 5 until nodeName.length) + result = result && nodeName[i] >= '0' && nodeName[i] <= '9' + } + + return result + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPRDFWriter.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPRDFWriter.kt new file mode 100644 index 0000000..2212624 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPRDFWriter.kt @@ -0,0 +1,993 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl + +import com.ashampoo.xmp.XMPConst +import com.ashampoo.xmp.XMPError +import com.ashampoo.xmp.XMPException +import com.ashampoo.xmp.XMPMetaFactory.schemaRegistry +import com.ashampoo.xmp.XMPMetaFactory.versionInfo +import com.ashampoo.xmp.impl.Utils.escapeXML +import com.ashampoo.xmp.options.SerializeOptions + +/** + * Serializes the `XMPMeta`-object using the standard RDF serialization format. + * The output is a XMP String according to the `SerializeOptions`. + */ +internal class XMPRDFWriter( + val xmp: XMPMetaImpl, + val options: SerializeOptions +) { + + private val sb: StringBuilder = StringBuilder() + + /** + * The actual serialization. + */ + fun serialize(): String { + + try { + + sb.clear() + + serializeAsRDF() + + return sb.toString() + + } catch (ex: Exception) { + throw XMPException("Error writing the XMP", XMPError.UNKNOWN, ex) + } + } + + /** + * Writes the (optional) packet header and the outer rdf-tags. + */ + private fun serializeAsRDF() { + + var level = 0 + + // Write the packet header PI. + if (!options.getOmitPacketWrapper()) { + writeIndent(level) + write(PACKET_HEADER) + writeNewline() + } + + // Write the x:xmpmeta element's start tag. + if (!options.getOmitXmpMetaElement()) { + + writeIndent(level) + write(RDF_XMPMETA_START) + write(versionInfo.message) + write("\">") + writeNewline() + + level++ + } + + // Write the rdf:RDF start tag. + writeIndent(level) + write(RDF_RDF_START) + writeNewline() + + // Write all of the properties. + if (options.getUseCanonicalFormat()) + serializeCanonicalRDFSchemas(level) + else + serializeCompactRDFSchemas(level) + + // Write the rdf:RDF end tag. + writeIndent(level) + write(RDF_RDF_END) + writeNewline() + + // Write the xmpmeta end tag. + if (!options.getOmitXmpMetaElement()) { + + level-- + + writeIndent(level) + write(RDF_XMPMETA_END) + writeNewline() + } + + // Write the packet trailer PI into the tail string as UTF-8. + var tailStr = "" + + if (!options.getOmitPacketWrapper()) { + + level = 0 + + while (level > 0) { + tailStr += XMP_DEFAULT_INDENT + level-- + } + + tailStr += PACKET_TRAILER + tailStr += if (options.getReadOnlyPacket()) 'r' else 'w' + tailStr += PACKET_TRAILER2 + } + + write(tailStr) + } + + /** + * Serializes the metadata in pretty-printed manner. + * + * @param level indent level + */ + private fun serializeCanonicalRDFSchemas(level: Int) { + + if (xmp.root.hasChildren()) { + + startOuterRDFDescription(xmp.root, level) + + for (schema in xmp.root.getChildren()) + serializeCanonicalRDFSchema(schema, level) + + endOuterRDFDescription(level) + + } else { + + writeIndent(level + 1) + write(RDF_SCHEMA_START) // Special case an empty XMP object. + writeTreeName() + write("/>") + writeNewline() + } + } + + private fun writeTreeName() { + + write('"') + + val name = xmp.root.name + + if (name != null) + appendNodeValue(name, true) + + write('"') + } + + /** + * Serializes the metadata in compact manner. + * + * @param level indent level to start with + */ + private fun serializeCompactRDFSchemas(level: Int) { + + // Begin the rdf:Description start tag. + writeIndent(level + 1) + write(RDF_SCHEMA_START) + writeTreeName() + + // Write all necessary xmlns attributes. + val usedPrefixes: MutableSet = mutableSetOf() + usedPrefixes.add("xml") + usedPrefixes.add("rdf") + + for (schema in xmp.root.getChildren()) + declareUsedNamespaces(schema, usedPrefixes, level + 3) + + // Write the top level "attrProps" and close the rdf:Description start tag. + var allAreAttrs = true + + for (schema in xmp.root.getChildren()) + allAreAttrs = allAreAttrs and serializeCompactRDFAttrProps(schema, level + 2) + + if (!allAreAttrs) { + + write('>') + writeNewline() + + } else { + + write("/>") + writeNewline() + return // ! Done if all properties in all schema are written as attributes. + } + + // Write the remaining properties for each schema. + for (schema in xmp.root.getChildren()) + serializeCompactRDFElementProps(schema, level + 2) + + // Write the rdf:Description end tag. + // *** Elide the end tag if everything (all props in all schema) is an attr. + writeIndent(level + 1) + write(RDF_SCHEMA_END) + writeNewline() + } + + /** + * Write each of the parent's simple unqualified properties as an attribute. Returns true if all + * of the properties are written as attributes. + * + * @param parentNode the parent property node + * @param indent the current indent level + * @return Returns true if all properties can be rendered as RDF attribute. + */ + private fun serializeCompactRDFAttrProps(parentNode: XMPNode, indent: Int): Boolean { + + var allAreAttrs = true + + for (prop in parentNode.getChildren()) { + + if (canBeRDFAttrProp(prop)) { + + writeNewline() + writeIndent(indent) + write(prop.name!!) + write("=\"") + appendNodeValue(prop.value, true) + write('"') + + } else { + + allAreAttrs = false + } + } + + return allAreAttrs + } + + /** + * Recursively handles the "value" for a node that must be written as an RDF + * property element. It does not matter if it is a top level property, a + * field of a struct, or an item of an array. The indent is that for the + * property element. The patterns bwlow ignore attribute qualifiers such as + * xml:lang, they don't affect the output form. + * + * @param parentNode the parent node + * @param indent the current indent level + */ + private fun serializeCompactRDFElementProps(parentNode: XMPNode, indent: Int) { + + for (node in parentNode.getChildren()) { + + if (canBeRDFAttrProp(node)) + continue + + var emitEndTag = true + var indentEndTag = true + + // Determine the XML element name, write the name part of the start tag. Look over the + // qualifiers to decide on "normal" versus "rdf:value" form. Emit the attribute + // qualifiers at the same time. + var elemName = node.name + + if (XMPConst.ARRAY_ITEM_NAME == elemName) + elemName = "rdf:li" + + writeIndent(indent) + write('<') + write(elemName!!) + + var hasGeneralQualifiers = false + var hasRDFResourceQual = false + + for (qualifier in node.getQualifier()) { + + if (!RDF_ATTR_QUALIFIER.contains(qualifier.name)) { + + hasGeneralQualifiers = true + + } else { + + hasRDFResourceQual = "rdf:resource" == qualifier.name + write(' ') + write(qualifier.name!!) + write("=\"") + appendNodeValue(qualifier.value, true) + write('"') + } + } + + // Process the property according to the standard patterns. + if (hasGeneralQualifiers) { + + serializeCompactRDFGeneralQualifier(indent, node) + + } else { + + // This node has only attribute qualifiers. Emit as a property element. + if (!node.options.isCompositeProperty()) { + + val result = serializeCompactRDFSimpleProp(node) + + emitEndTag = result[0] as Boolean + indentEndTag = result[1] as Boolean + + } else if (node.options.isArray()) { + + serializeCompactRDFArrayProp(node, indent) + + } else { + + emitEndTag = serializeCompactRDFStructProp(node, indent, hasRDFResourceQual) + } + } + + // Emit the property element end tag. + if (emitEndTag) { + + if (indentEndTag) + writeIndent(indent) + + write("') + writeNewline() + } + } + } + + /** + * Serializes a simple property. + * + * @param node an XMPNode + * @return Returns an array containing the flags emitEndTag and indentEndTag. + */ + private fun serializeCompactRDFSimpleProp(node: XMPNode): Array { + + // This is a simple property. + var emitEndTag = true + var indentEndTag = true + + if (node.options.isURI()) { + + write(" rdf:resource=\"") + appendNodeValue(node.value, true) + write("\"/>") + writeNewline() + emitEndTag = false + + } else if (node.value == null || node.value?.length == 0) { + + write("/>") + writeNewline() + emitEndTag = false + + } else { + + write('>') + appendNodeValue(node.value, false) + indentEndTag = false + } + + return arrayOf(emitEndTag, indentEndTag) + } + + /** + * Serializes an array property. + * + * @param node an XMPNode + * @param indent the current indent level + */ + private fun serializeCompactRDFArrayProp(node: XMPNode, indent: Int) { + + // This is an array. + write('>') + writeNewline() + emitRDFArrayTag(node, true, indent + 1) + + if (node.options.isArrayAltText()) + XMPNodeUtils.normalizeLangArray(node) + + serializeCompactRDFElementProps(node, indent + 2) + emitRDFArrayTag(node, false, indent + 1) + } + + /** + * Serializes a struct property. + * + * @param node an XMPNode + * @param indent the current indent level + * @param hasRDFResourceQual Flag if the element has resource qualifier + * @return Returns true if an end flag shall be emitted. + */ + private fun serializeCompactRDFStructProp( + node: XMPNode, + indent: Int, + hasRDFResourceQual: Boolean + ): Boolean { + + // This must be a struct. + var hasAttrFields = false + var hasElemFields = false + var emitEndTag = true + + for (field in node.getChildren()) { + + if (canBeRDFAttrProp(field)) + hasAttrFields = true + else + hasElemFields = true + + if (hasAttrFields && hasElemFields) + break // No sense looking further. + } + + if (hasRDFResourceQual && hasElemFields) + throw XMPException("Can't mix rdf:resource qualifier and element fields", XMPError.BADRDF) + + when { + + !node.hasChildren() -> { + + // Catch an empty struct as a special case. The case + // below would emit an empty + // XML element, which gets reparsed as a simple property + // with an empty value. + write(" rdf:parseType=\"Resource\"/>") + writeNewline() + emitEndTag = false + } + + !hasElemFields -> { + + // All fields can be attributes, use the + // emptyPropertyElt form. + serializeCompactRDFAttrProps(node, indent + 1) + write("/>") + writeNewline() + emitEndTag = false + } + + !hasAttrFields -> { + + // All fields must be elements, use the + // parseTypeResourcePropertyElt form. + write(" rdf:parseType=\"Resource\">") + writeNewline() + serializeCompactRDFElementProps(node, indent + 1) + } + + else -> { + + // Have a mix of attributes and elements, use an inner rdf:Description. + write('>') + writeNewline() + writeIndent(indent + 1) + write(RDF_STRUCT_START) + serializeCompactRDFAttrProps(node, indent + 2) + write(">") + writeNewline() + serializeCompactRDFElementProps(node, indent + 1) + writeIndent(indent + 1) + write(RDF_STRUCT_END) + writeNewline() + } + } + + return emitEndTag + } + + /** + * Serializes the general qualifier. + * + * @param node the root node of the subtree + * @param indent the current indent level + */ + private fun serializeCompactRDFGeneralQualifier(indent: Int, node: XMPNode) { + + // The node has general qualifiers, ones that can't be + // attributes on a property element. + // Emit using the qualified property pseudo-struct form. The + // value is output by a call + // to SerializePrettyRDFProperty with emitAsRDFValue set. + + // *** We're losing compactness in the calls to SerializePrettyRDFProperty. + // *** Should refactor to have SerializeCompactRDFProperty that does one node. + write(" rdf:parseType=\"Resource\">") + writeNewline() + serializeCanonicalRDFProperty(node, false, true, indent + 1) + + for (qualifier in node.getQualifier()) + serializeCanonicalRDFProperty(qualifier, false, false, indent + 1) + } + + /** + * Serializes one schema with all contained properties in pretty-printed + * manner. + * + * Each schema's properties are written to a single + * rdf:Description element. All of the necessary namespaces are declared in + * the rdf:Description element. The baseIndent is the base level for the + * entire serialization, that of the x:xmpmeta element. An xml:lang + * qualifier is written as an attribute of the property start tag, not by + * itself forcing the qualified property form. + */ + private fun serializeCanonicalRDFSchema(schemaNode: XMPNode, level: Int) { + + // Write each of the schema's actual properties. + + for (propNode in schemaNode.getChildren()) + serializeCanonicalRDFProperty(propNode, options.getUseCanonicalFormat(), false, level + 2) + } + + /** + * Writes all used namespaces of the subtree in node to the output. + * The subtree is recursivly traversed. + */ + private fun declareUsedNamespaces(node: XMPNode, usedPrefixes: MutableSet, indent: Int) { + + if (node.options.isSchemaNode()) { + + // The schema node name is the URI, the value is the prefix. + val prefix = node.value!!.substring(0, node.value!!.length - 1) + declareNamespace(prefix, node.name, usedPrefixes, indent) + + } else if (node.options.isStruct()) { + + for (field in node.getChildren()) + declareNamespace(field.name!!, null, usedPrefixes, indent) + } + + for (child in node.getChildren()) + declareUsedNamespaces(child, usedPrefixes, indent) + + for (qualifier in node.getQualifier()) { + + declareNamespace(qualifier.name!!, null, usedPrefixes, indent) + declareUsedNamespaces(qualifier, usedPrefixes, indent) + } + } + + /** + * Writes one namespace declaration to the output. + * + * @param prefix a namespace prefix (without colon) or a complete qname (when namespace == null) + * @param namespace the a namespace + * @param usedPrefixes a set containing currently used prefixes + * @param indent the current indent level + */ + private fun declareNamespace( + prefix: String, + namespace: String?, + usedPrefixes: MutableSet, + indent: Int + ) { + + var prefix = prefix + var namespace = namespace + + if (namespace == null) { + + // prefix contains qname, extract prefix and lookup namespace with prefix + val qname = QName(prefix) + + if (!qname.hasPrefix()) + return + + prefix = qname.prefix!! + + // add colon for lookup + namespace = schemaRegistry.getNamespaceURI("$prefix:") + + // prefix w/o colon + declareNamespace(prefix, namespace, usedPrefixes, indent) + } + + if (!usedPrefixes.contains(prefix)) { + + writeNewline() + writeIndent(indent) + write("xmlns:") + write(prefix) + write("=\"") + write(namespace!!) + write('"') + + usedPrefixes.add(prefix) + } + } + + /** + * Start the outer rdf:Description element, including all needed xmlns attributes. + * Leave the element open so that the compact form can add property attributes. + */ + private fun startOuterRDFDescription(schemaNode: XMPNode, level: Int) { + + writeIndent(level + 1) + write(RDF_SCHEMA_START) + writeTreeName() + + val usedPrefixes: MutableSet = mutableSetOf() + usedPrefixes.add("xml") + usedPrefixes.add("rdf") + + declareUsedNamespaces(schemaNode, usedPrefixes, level + 3) + + write('>') + writeNewline() + } + + /** + * Write the end tag. + */ + private fun endOuterRDFDescription(level: Int) { + + writeIndent(level + 1) + write(RDF_SCHEMA_END) + writeNewline() + } + + /** + * Recursively handles the "value" for a node. It does not matter if it is a + * top level property, a field of a struct, or an item of an array. The + * indent is that for the property element. An xml:lang qualifier is written + * as an attribute of the property start tag, not by itself forcing the + * qualified property form. The patterns below mostly ignore attribute + * qualifiers like xml:lang. Except for the one struct case, attribute + * qualifiers don't affect the output form. + * + * @param node the property node + * @param emitAsRDFValue property shall be rendered as attribute rather than tag + * @param useCanonicalRDF use canonical form with inner description tag or + * the compact form with rdf:ParseType="resource" attribute. + * @param indent the current indent level + */ + private fun serializeCanonicalRDFProperty( + node: XMPNode, + useCanonicalRDF: Boolean, + emitAsRDFValue: Boolean, + indent: Int + ) { + + var indent = indent + var emitEndTag = true + var indentEndTag = true + + // Determine the XML element name. Open the start tag with the name and + // attribute qualifiers. + var elemName = node.name + + if (emitAsRDFValue) + elemName = "rdf:value" + else if (XMPConst.ARRAY_ITEM_NAME == elemName) + elemName = "rdf:li" + + writeIndent(indent) + write('<') + write(elemName!!) + + var hasGeneralQualifiers = false + var hasRDFResourceQual = false + + val it = node.iterateQualifier() + + while (it.hasNext()) { + + val qualifier = it.next() + + if (!RDF_ATTR_QUALIFIER.contains(qualifier.name)) { + + hasGeneralQualifiers = true + + } else { + + hasRDFResourceQual = "rdf:resource" == qualifier.name + + if (!emitAsRDFValue) { + + write(' ') + write(qualifier.name!!) + write("=\"") + appendNodeValue(qualifier.value, true) + write('"') + } + } + } + + // Process the property according to the standard patterns. + if (hasGeneralQualifiers && !emitAsRDFValue) { + + // This node has general, non-attribute, qualifiers. Emit using the + // qualified property form. + // ! The value is output by a recursive call ON THE SAME NODE with + // emitAsRDFValue set. + if (hasRDFResourceQual) + throw XMPException("Can't mix rdf:resource and general qualifiers", XMPError.BADRDF) + + // Change serialization to canonical format with inner rdf:Description-tag + // depending on option + if (useCanonicalRDF) { + + write(">") + writeNewline() + indent++ + writeIndent(indent) + write(RDF_STRUCT_START) + write(">") + + } else { + write(" rdf:parseType=\"Resource\">") + } + + writeNewline() + + serializeCanonicalRDFProperty(node, useCanonicalRDF, true, indent + 1) + + for (qualifier in node.getQualifier()) + if (!RDF_ATTR_QUALIFIER.contains(qualifier.name)) + serializeCanonicalRDFProperty(qualifier, useCanonicalRDF, false, indent + 1) + + if (useCanonicalRDF) { + + writeIndent(indent) + write(RDF_STRUCT_END) + writeNewline() + indent-- + } + + } else { + + // This node has no general qualifiers. Emit using an unqualified form. + when { + + !node.options.isCompositeProperty() -> { + + // This is a simple property. + if (node.options.isURI()) { + + write(" rdf:resource=\"") + appendNodeValue(node.value, true) + write("\"/>") + writeNewline() + + emitEndTag = false + + } else if (node.value == null || "" == node.value) { + + write("/>") + writeNewline() + + emitEndTag = false + + } else { + + write('>') + appendNodeValue(node.value, false) + + indentEndTag = false + } + + } + + node.options.isArray() -> { + + // This is an array. + write('>') + writeNewline() + emitRDFArrayTag(node, true, indent + 1) + + if (node.options.isArrayAltText()) + XMPNodeUtils.normalizeLangArray(node) + + for (child in node.getChildren()) + serializeCanonicalRDFProperty(child, useCanonicalRDF, false, indent + 2) + + emitRDFArrayTag(node, false, indent + 1) + } + + !hasRDFResourceQual -> { + + // This is a "normal" struct, use the rdf:parseType="Resource" form. + if (!node.hasChildren()) { + + // Change serialization to canonical format with inner rdf:Description-tag + // if option is set + if (useCanonicalRDF) { + + write(">") + writeNewline() + writeIndent(indent + 1) + write(RDF_EMPTY_STRUCT) + + } else { + + write(" rdf:parseType=\"Resource\"/>") + + emitEndTag = false + } + + writeNewline() + + } else { + + // Change serialization to canonical format with inner rdf:Description-tag + // if option is set + if (useCanonicalRDF) { + + write(">") + writeNewline() + indent++ + writeIndent(indent) + write(RDF_STRUCT_START) + write(">") + + } else { + + write(" rdf:parseType=\"Resource\">") + } + + writeNewline() + + for (child in node.getChildren()) + serializeCanonicalRDFProperty(child, useCanonicalRDF, false, indent + 1) + + if (useCanonicalRDF) { + writeIndent(indent) + write(RDF_STRUCT_END) + writeNewline() + indent-- + } + } + + } + + else -> { + + // This is a struct with an rdf:resource attribute, use the "empty property element" form. + + for (child in node.getChildren()) { + + if (!canBeRDFAttrProp(child)) + throw XMPException("Can't mix rdf:resource and complex fields", XMPError.BADRDF) + + writeNewline() + writeIndent(indent + 1) + write(' ') + write(child.name!!) + write("=\"") + appendNodeValue(child.value, true) + write('"') + } + + write("/>") + writeNewline() + + emitEndTag = false + } + } + } + + // Emit the property element end tag. + if (emitEndTag) { + + if (indentEndTag) + writeIndent(indent) + + write("') + writeNewline() + } + } + + /** + * Writes the array start and end tags. + * + * @param arrayNode an array node + * @param isStartTag flag if its the start or end tag + * @param indent the current indent level + */ + private fun emitRDFArrayTag(arrayNode: XMPNode, isStartTag: Boolean, indent: Int) { + + if (isStartTag || arrayNode.hasChildren()) { + + writeIndent(indent) + + write(if (isStartTag) "") + else + write(">") + + writeNewline() + } + } + + /** + * Serializes the node value in XML encoding. Its used for tag bodies and + * attributes. *Note:* The attribute is always limited by quotes, + * thats why `'` is never serialized. *Note:* + * Control chars are written unescaped, but if the user uses others than tab, LF + * and CR the resulting XML will become invalid. + * + * @param value the value of the node + * @param forAttribute flag if value is an attribute value + * + */ + private fun appendNodeValue(value: String?, forAttribute: Boolean) = + write(escapeXML(value ?: "", forAttribute, true)) + + /** + * A node can be serialized as RDF-Attribute, if it meets the following conditions: + * + * * is not array item + * * don't has qualifier + * * is no URI + * * is no composite property + * + * @param node an XMPNode + * @return Returns true if the node serialized as RDF-Attribute + */ + private fun canBeRDFAttrProp(node: XMPNode): Boolean = + !node.hasQualifier() && !node.options.isURI() && !node.options.isCompositeProperty() && + XMPConst.ARRAY_ITEM_NAME != node.name + + private fun writeIndent(times: Int) = + repeat(times) { sb.append(XMP_DEFAULT_INDENT) } + + private fun write(c: Char) = + sb.append(c) + + private fun write(str: String) = + sb.append(str) + + /** + * Writes a newline. + */ + private fun writeNewline() { + sb.append(XMP_DEFAULT_NEWLINE) + } + + companion object { + + /** linefeed (U+000A) is the standard XML line terminator. XMP defaults to it. */ + const val XMP_DEFAULT_NEWLINE = "\n" + + /** Two ASCII spaces (U+0020) are the default indent for XMP files. */ + const val XMP_DEFAULT_INDENT = " " + + private const val PACKET_HEADER = "" + + /** + * The w/r is missing inbetween + */ + private const val PACKET_TRAILER = "" + + private const val RDF_XMPMETA_START = "" + + private const val RDF_RDF_END = "" + + private const val RDF_SCHEMA_START = "" + + private const val RDF_STRUCT_START = " = setOf( + XMPConst.XML_LANG, "rdf:resource", "rdf:ID", "rdf:bagID", "rdf:nodeID" + ) + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPSchemaRegistryImpl.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPSchemaRegistryImpl.kt new file mode 100644 index 0000000..4602af0 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XMPSchemaRegistryImpl.kt @@ -0,0 +1,613 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl + +import com.ashampoo.xmp.XMPConst +import com.ashampoo.xmp.XMPError +import com.ashampoo.xmp.XMPException +import com.ashampoo.xmp.XMPSchemaRegistry +import com.ashampoo.xmp.impl.Utils.isXMLNameNS +import com.ashampoo.xmp.options.AliasOptions +import com.ashampoo.xmp.properties.XMPAliasInfo + +/** + * The schema registry handles the namespaces, aliases and global options for the XMP Toolkit. + * There is only one single instance used by the toolkit. + */ +object XMPSchemaRegistryImpl : XMPSchemaRegistry { + + /** + * a map from a namespace URI to its registered prefix + */ + private val namespaceToPrefixMap: MutableMap = mutableMapOf() + + /** + * a map from a prefix to the associated namespace URI + */ + private val prefixToNamespaceMap: MutableMap = mutableMapOf() + + /** + * a map of all registered aliases. + * The map is a relationship from a qname to an `XMPAliasInfo`-object. + */ + private val aliasMap: MutableMap = mutableMapOf() + + /** + * The pattern that must not be contained in simple properties + */ + private val simpleProperyPattern = Regex("[/*?\\[\\]]") + + /** + * Performs the initialisation of the registry with the default namespaces, aliases and global + * options. + */ + init { + try { + + registerStandardNamespaces() + registerStandardAliases() + + } catch (ex: XMPException) { + throw IllegalStateException("The XMPSchemaRegistry cannot be initialized!", ex) + } + } + + // --------------------------------------------------------------------------------------------- + // Namespace Functions + + override fun registerNamespace(namespaceURI: String, suggestedPrefix: String): String { + + var suggestedPrefix = suggestedPrefix + + if (namespaceURI.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (suggestedPrefix.isEmpty()) + throw XMPException("Empty prefix", XMPError.BADPARAM) + + if (suggestedPrefix[suggestedPrefix.length - 1] != ':') + suggestedPrefix += ':' + + if (!isXMLNameNS(suggestedPrefix.substring(0, suggestedPrefix.length - 1))) + throw XMPException("The prefix is a bad XML name", XMPError.BADXML) + + val registeredPrefix = namespaceToPrefixMap[namespaceURI] + val registeredNS = prefixToNamespaceMap[suggestedPrefix] + + // Return the actual prefix + if (registeredPrefix != null) + return registeredPrefix + + if (registeredNS != null) { + + // the namespace is new, but the prefix is already engaged, + // we generate a new prefix out of the suggested + var generatedPrefix = suggestedPrefix + + var i = 1 + + while (prefixToNamespaceMap.containsKey(generatedPrefix)) { + generatedPrefix = + suggestedPrefix.substring(0, suggestedPrefix.length - 1) + "_" + i + "_:" + i++ + } + + suggestedPrefix = generatedPrefix + } + + prefixToNamespaceMap[suggestedPrefix] = namespaceURI + namespaceToPrefixMap[namespaceURI] = suggestedPrefix + + // Return the suggested prefix + return suggestedPrefix + } + + override fun deleteNamespace(namespaceURI: String) { + + val prefixToDelete = getNamespacePrefix(namespaceURI) ?: return + + namespaceToPrefixMap.remove(namespaceURI) + prefixToNamespaceMap.remove(prefixToDelete) + } + + override fun getNamespacePrefix(namespaceURI: String): String? = + namespaceToPrefixMap[namespaceURI] + + override fun getNamespaceURI(namespacePrefix: String): String? { + + var namespacePrefix = namespacePrefix + + if (!namespacePrefix.endsWith(":")) + namespacePrefix += ":" + + return prefixToNamespaceMap[namespacePrefix] + } + + override fun getNamespaces(): Map = + namespaceToPrefixMap + + override fun getPrefixes(): Map = + prefixToNamespaceMap + + /** + * Register the standard namespaces of schemas and types that are included in the XMP + * Specification and some other Adobe private namespaces. + * Note: This method is not lock because only called by the constructor. + */ + private fun registerStandardNamespaces() { + + // register standard namespaces + registerNamespace(XMPConst.NS_XML, "xml") + registerNamespace(XMPConst.NS_RDF, "rdf") + registerNamespace(XMPConst.NS_DC, "dc") + registerNamespace(XMPConst.NS_IPTCCORE, "Iptc4xmpCore") + registerNamespace(XMPConst.NS_IPTCEXT, "Iptc4xmpExt") + registerNamespace(XMPConst.NS_DICOM, "DICOM") + registerNamespace(XMPConst.NS_PLUS, "plus") + + // register other common schemas + registerNamespace(XMPConst.NS_MWG_RS, "mwg-rs") + registerNamespace(XMPConst.NS_ACDSEE, "acdsee") + + // register Adobe standard namespaces + registerNamespace(XMPConst.NS_X, "x") + registerNamespace(XMPConst.NS_IX, "iX") + registerNamespace(XMPConst.NS_XMP, "xmp") + registerNamespace(XMPConst.NS_XMP_RIGHTS, "xmpRights") + registerNamespace(XMPConst.NS_XMP_MM, "xmpMM") + registerNamespace(XMPConst.NS_XMP_BJ, "xmpBJ") + registerNamespace(XMPConst.NS_XMP_NOTE, "xmpNote") + registerNamespace(XMPConst.NS_PDF, "pdf") + registerNamespace(XMPConst.NS_PDFX, "pdfx") + registerNamespace(XMPConst.NS_PDFX_ID, "pdfxid") + registerNamespace(XMPConst.NS_PDFA_SCHEMA, "pdfaSchema") + registerNamespace(XMPConst.NS_PDFA_PROPERTY, "pdfaProperty") + registerNamespace(XMPConst.NS_PDFA_TYPE, "pdfaType") + registerNamespace(XMPConst.NS_PDFA_FIELD, "pdfaField") + registerNamespace(XMPConst.NS_PDFA_ID, "pdfaid") + registerNamespace(XMPConst.NS_PDFA_EXTENSION, "pdfaExtension") + registerNamespace(XMPConst.NS_PHOTOSHOP, "photoshop") + registerNamespace(XMPConst.NS_PSALBUM, "album") + registerNamespace(XMPConst.NS_EXIF, "exif") + registerNamespace(XMPConst.NS_EXIFX, "exifEX") + registerNamespace(XMPConst.NS_EXIF_AUX, "aux") + registerNamespace(XMPConst.NS_TIFF, "tiff") + registerNamespace(XMPConst.NS_PNG, "png") + registerNamespace(XMPConst.NS_JPEG, "jpeg") + registerNamespace(XMPConst.NS_JP2K, "jp2k") + registerNamespace(XMPConst.NS_CAMERARAW, "crs") + registerNamespace(XMPConst.NS_ADOBESTOCKPHOTO, "bmsp") + registerNamespace(XMPConst.NS_CREATOR_ATOM, "creatorAtom") + registerNamespace(XMPConst.NS_ASF, "asf") + registerNamespace(XMPConst.NS_WAV, "wav") + registerNamespace(XMPConst.NS_BWF, "bext") + registerNamespace(XMPConst.NS_RIFFINFO, "riffinfo") + registerNamespace(XMPConst.NS_SCRIPT, "xmpScript") + registerNamespace(XMPConst.NS_TXMP, "txmp") + registerNamespace(XMPConst.NS_SWF, "swf") + + // register Adobe private namespaces + registerNamespace(XMPConst.NS_DM, "xmpDM") + registerNamespace(XMPConst.NS_TRANSIENT, "xmpx") + + // register Adobe standard type namespaces + registerNamespace(XMPConst.TYPE_TEXT, "xmpT") + registerNamespace(XMPConst.TYPE_PAGEDFILE, "xmpTPg") + registerNamespace(XMPConst.TYPE_GRAPHICS, "xmpG") + registerNamespace(XMPConst.TYPE_IMAGE, "xmpGImg") + registerNamespace(XMPConst.TYPE_FONT, "stFnt") + registerNamespace(XMPConst.TYPE_DIMENSIONS, "stDim") + registerNamespace(XMPConst.TYPE_RESOURCEEVENT, "stEvt") + registerNamespace(XMPConst.TYPE_RESOURCEREF, "stRef") + registerNamespace(XMPConst.TYPE_ST_VERSION, "stVer") + registerNamespace(XMPConst.TYPE_ST_JOB, "stJob") + registerNamespace(XMPConst.TYPE_MANIFESTITEM, "stMfs") + registerNamespace(XMPConst.TYPE_IDENTIFIERQUAL, "xmpidq") + } + + // --------------------------------------------------------------------------------------------- + // Alias Functions + + override fun resolveAlias(aliasNS: String, aliasProp: String): XMPAliasInfo? { + + val aliasPrefix = getNamespacePrefix(aliasNS) ?: return null + + return aliasMap[aliasPrefix + aliasProp] + } + + override fun findAlias(qname: String): XMPAliasInfo? = + aliasMap[qname] + + override fun findAliases(aliasNS: String): Set { + + val prefix = getNamespacePrefix(aliasNS) + + if (prefix == null) return emptySet() + + val result = mutableSetOf() + + for (qname in aliasMap.keys) { + + if (qname.startsWith(prefix)) { + + val alias = findAlias(qname) ?: continue + + result.add(alias) + } + } + + return result + } + + /** + * Associates an alias name with an actual name. + * + * Define a alias mapping from one namespace/property to another. Both + * property names must be simple names. An alias can be a direct mapping, + * where the alias and actual have the same data type. It is also possible + * to map a simple alias to an item in an array. This can either be to the + * first item in the array, or to the 'x-default' item in an alt-text array. + * Multiple alias names may map to the same actual, as long as the forms + * match. It is a no-op to reregister an alias in an identical fashion. + * Note: This method is not locking because only called by registerStandardAliases + * which is only called by the constructor. + * Note2: The method is only package-private so that it can be tested with unittests + * + * @param aliasNS The namespace URI for the alias. Must not be null or the empty + * string. + * @param aliasProp The name of the alias. Must be a simple name, not null or the + * empty string and not a general path expression. + * @param actualNS The namespace URI for the actual. Must not be null or the + * empty string. + * @param actualProp The name of the actual. Must be a simple name, not null or the + * empty string and not a general path expression. + * @param aliasForm Provides options for aliases for simple aliases to array + * items. This is needed to know what kind of array to create if + * set for the first time via the simple alias. Pass + * `XMP_NoOptions`, the default value, for all + * direct aliases regardless of whether the actual data type is + * an array or not (see [AliasOptions]). + */ + fun registerAlias( + aliasNS: String, + aliasProp: String, + actualNS: String, + actualProp: String, + aliasForm: AliasOptions? + ) { + + if (aliasNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (aliasProp.isEmpty()) + throw XMPException("Empty property name", XMPError.BADPARAM) + + if (actualNS.isEmpty()) + throw XMPException("Empty schema namespace URI", XMPError.BADPARAM) + + if (actualProp.isEmpty()) + throw XMPException("Empty property name", XMPError.BADPARAM) + + // Fix the alias options + val aliasOpts = if (aliasForm != null) + AliasOptions( + XMPNodeUtils.verifySetOptions( + aliasForm.toPropertyOptions(), + null + ).getOptions() + ) else + AliasOptions() + + if (simpleProperyPattern.matches(aliasProp) || simpleProperyPattern.matches(actualProp)) + throw XMPException("Alias and actual property names must be simple", XMPError.BADXPATH) + + // check if both namespaces are registered + val aliasPrefix = getNamespacePrefix(aliasNS) + val actualPrefix = getNamespacePrefix(actualNS) + + if (aliasPrefix == null) + throw XMPException("Alias namespace is not registered", XMPError.BADSCHEMA) + else if (actualPrefix == null) + throw XMPException("Actual namespace is not registered", XMPError.BADSCHEMA) + + val key = aliasPrefix + aliasProp + + // check if alias is already existing + if (aliasMap.containsKey(key)) + throw XMPException("Alias is already existing", XMPError.BADPARAM) + else if (aliasMap.containsKey(actualPrefix + actualProp)) + throw XMPException( + "Actual property is already an alias, use the base property", XMPError.BADPARAM + ) + + val aliasInfo: XMPAliasInfo = object : XMPAliasInfo { + + override fun getNamespace(): String = actualNS + + override fun getPrefix(): String = actualPrefix + + override fun getPropName(): String = actualProp + + override fun getAliasForm(): AliasOptions = aliasOpts + + override fun toString(): String = + actualPrefix + actualProp + " NS(" + actualNS + "), FORM (" + getAliasForm() + ")" + } + + aliasMap[key] = aliasInfo + } + + override fun getAliases(): Map = + aliasMap + + /** + * Register the standard aliases. + * Note: This method is not lock because only called by the constructor. + */ + private fun registerStandardAliases() { + + val aliasToArrayOrdered = AliasOptions().setArrayOrdered(true) + val aliasToArrayAltText = AliasOptions().setArrayAltText(true) + + // Aliases from XMP to DC. + registerAlias( + XMPConst.NS_XMP, + "Author", + XMPConst.NS_DC, + "creator", + aliasToArrayOrdered + ) + registerAlias( + XMPConst.NS_XMP, + "Authors", + XMPConst.NS_DC, + "creator", + null + ) + registerAlias( + XMPConst.NS_XMP, + "Description", + XMPConst.NS_DC, + "description", + null + ) + registerAlias( + XMPConst.NS_XMP, + "Format", + XMPConst.NS_DC, + "format", + null + ) + registerAlias( + XMPConst.NS_XMP, + "Keywords", + XMPConst.NS_DC, + "subject", + null + ) + registerAlias( + XMPConst.NS_XMP, + "Locale", + XMPConst.NS_DC, + "language", + null + ) + registerAlias( + XMPConst.NS_XMP, + "Title", + XMPConst.NS_DC, + "title", + null + ) + registerAlias( + XMPConst.NS_XMP_RIGHTS, + "Copyright", + XMPConst.NS_DC, + "rights", + null + ) + + // Aliases from PDF to DC and XMP. + registerAlias( + XMPConst.NS_PDF, + "Author", + XMPConst.NS_DC, + "creator", + aliasToArrayOrdered + ) + registerAlias( + XMPConst.NS_PDF, + "BaseURL", + XMPConst.NS_XMP, + "BaseURL", + null + ) + registerAlias( + XMPConst.NS_PDF, + "CreationDate", + XMPConst.NS_XMP, + "CreateDate", + null + ) + registerAlias( + XMPConst.NS_PDF, + "Creator", + XMPConst.NS_XMP, + "CreatorTool", + null + ) + registerAlias( + XMPConst.NS_PDF, + "ModDate", + XMPConst.NS_XMP, + "ModifyDate", + null + ) + registerAlias( + XMPConst.NS_PDF, + "Subject", + XMPConst.NS_DC, + "description", + aliasToArrayAltText + ) + registerAlias( + XMPConst.NS_PDF, + "Title", + XMPConst.NS_DC, + "title", + aliasToArrayAltText + ) + + // Aliases from PHOTOSHOP to DC and XMP. + registerAlias( + XMPConst.NS_PHOTOSHOP, + "Author", + XMPConst.NS_DC, + "creator", + aliasToArrayOrdered + ) + registerAlias( + XMPConst.NS_PHOTOSHOP, + "Caption", + XMPConst.NS_DC, + "description", + aliasToArrayAltText + ) + registerAlias( + XMPConst.NS_PHOTOSHOP, + "Copyright", + XMPConst.NS_DC, + "rights", + aliasToArrayAltText + ) + registerAlias( + XMPConst.NS_PHOTOSHOP, + "Keywords", + XMPConst.NS_DC, + "subject", + null + ) + registerAlias( + XMPConst.NS_PHOTOSHOP, + "Marked", + XMPConst.NS_XMP_RIGHTS, + "Marked", + null + ) + registerAlias( + XMPConst.NS_PHOTOSHOP, + "Title", + XMPConst.NS_DC, + "title", + aliasToArrayAltText + ) + registerAlias( + XMPConst.NS_PHOTOSHOP, + "WebStatement", + XMPConst.NS_XMP_RIGHTS, + "WebStatement", + null + ) + + // Aliases from TIFF and EXIF to DC and XMP. + registerAlias( + XMPConst.NS_TIFF, + "Artist", + XMPConst.NS_DC, + "creator", + aliasToArrayOrdered + ) + registerAlias( + XMPConst.NS_TIFF, + "Copyright", + XMPConst.NS_DC, + "rights", + null + ) + registerAlias( + XMPConst.NS_TIFF, + "DateTime", + XMPConst.NS_XMP, + "ModifyDate", + null + ) + registerAlias( + XMPConst.NS_EXIF, + "DateTimeDigitized", + XMPConst.NS_XMP, + "CreateDate", + null + ) + registerAlias( + XMPConst.NS_TIFF, + "ImageDescription", + XMPConst.NS_DC, + "description", + null + ) + registerAlias( + XMPConst.NS_TIFF, + "Software", + XMPConst.NS_XMP, + "CreatorTool", + null + ) + + // Aliases from PNG (Acrobat ImageCapture) to DC and XMP. + registerAlias( + XMPConst.NS_PNG, + "Author", + XMPConst.NS_DC, + "creator", + aliasToArrayOrdered + ) + registerAlias( + XMPConst.NS_PNG, + "Copyright", + XMPConst.NS_DC, + "rights", + aliasToArrayAltText + ) + registerAlias( + XMPConst.NS_PNG, + "CreationTime", + XMPConst.NS_XMP, + "CreateDate", + null + ) + registerAlias( + XMPConst.NS_PNG, + "Description", + XMPConst.NS_DC, + "description", + aliasToArrayAltText + ) + registerAlias( + XMPConst.NS_PNG, + "ModificationTime", + XMPConst.NS_XMP, + "ModifyDate", + null + ) + registerAlias( + XMPConst.NS_PNG, + "Software", + XMPConst.NS_XMP, + "CreatorTool", + null + ) + registerAlias( + XMPConst.NS_PNG, + "Title", + XMPConst.NS_DC, + "title", + aliasToArrayAltText + ) + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/XmlUtilDomParser.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XmlUtilDomParser.kt new file mode 100644 index 0000000..cf2ee0f --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/XmlUtilDomParser.kt @@ -0,0 +1,32 @@ +package com.ashampoo.xmp.impl + +import com.ashampoo.xmp.XMPError +import com.ashampoo.xmp.XMPException +import nl.adaptivity.xmlutil.DomWriter +import nl.adaptivity.xmlutil.EventType +import nl.adaptivity.xmlutil.XmlStreaming +import nl.adaptivity.xmlutil.dom.Document +import nl.adaptivity.xmlutil.writeCurrent + +object XmlUtilDomParser : DomParser { + + override fun parseDocumentFromString(input: String): Document { + + try { + + val writer = DomWriter() + + val reader = XmlStreaming.newReader(input) + + do { + val event = reader.next() + reader.writeCurrent(writer) + } while (event != EventType.END_DOCUMENT) + + return writer.target + + } catch (ex: Exception) { + throw XMPException("Error reading the XML-file", XMPError.BADSTREAM, ex) + } + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/PathPosition.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/PathPosition.kt new file mode 100644 index 0000000..fbd9a5d --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/PathPosition.kt @@ -0,0 +1,41 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl.xpath + +/** + * This objects contains all needed char positions to parse. + */ +internal class PathPosition { + + /** + * the complete path + */ + var path: String? = null + + /** + * the start of a segment name + */ + var nameStart = 0 + + /** + * the end of a segment name + */ + var nameEnd = 0 + + /** + * the begin of a step + */ + var stepBegin = 0 + + /** + * the end of a step + */ + var stepEnd = 0 + +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/XMPPath.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/XMPPath.kt new file mode 100644 index 0000000..4c1c502 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/XMPPath.kt @@ -0,0 +1,79 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl.xpath + +/** + * Representates an XMP XMPPath with segment accessor methods. + */ +class XMPPath { + + private val segments = mutableListOf() + + fun add(segment: XMPPathSegment) { + segments.add(segment) + } + + fun getSegment(index: Int): XMPPathSegment = segments[index] + + fun size(): Int = segments.size + + override fun toString(): String { + + val result = StringBuilder() + var index = 1 + + while (index < size()) { + + result.append(getSegment(index)) + + if (index < size() - 1) { + + val kind = getSegment(index + 1).kind + + if (kind == STRUCT_FIELD_STEP || kind == QUALIFIER_STEP) + result.append('/') + } + + index++ + } + + return result.toString() + } + + companion object { + + /** + * Marks a struct field step , also for top level nodes (schema "fields"). + */ + const val STRUCT_FIELD_STEP = 0x01 + + /** + * Marks a qualifier step. + * Note: Order is significant to separate struct/qual from array kinds! + */ + const val QUALIFIER_STEP = 0x02 + + /** + * Marks an array index step + */ + const val ARRAY_INDEX_STEP = 0x03 + + const val ARRAY_LAST_STEP = 0x04 + + const val QUAL_SELECTOR_STEP = 0x05 + + const val FIELD_SELECTOR_STEP = 0x06 + + const val SCHEMA_NODE = -0x80000000 + + const val STEP_SCHEMA = 0 + + const val STEP_ROOT_PROP = 1 + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/XMPPathParser.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/XMPPathParser.kt new file mode 100644 index 0000000..795815f --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/XMPPathParser.kt @@ -0,0 +1,396 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl.xpath + +import com.ashampoo.xmp.XMPError +import com.ashampoo.xmp.XMPException +import com.ashampoo.xmp.XMPMetaFactory.schemaRegistry +import com.ashampoo.xmp.impl.Utils + +/** + * Parser for XMP XPaths. + */ +object XMPPathParser { + + /** + * Split an XMPPath expression apart at the conceptual steps, adding the + * root namespace prefix to the first property component. The schema URI is + * put in the first (0th) slot in the expanded XMPPath. Check if the top + * level component is an alias, but don't resolve it. + * + * The logic is complicated though by shorthand for arrays, the separating + * '/' and leading '*' are optional. These are all equivalent: array/ *[2] + * array/[2] array*[2] array[2] All of these are broken into the 2 steps + * "array" and "[2]". + * + * The value portion in the array selector forms is a string quoted by ''' + * or '"'. The value may contain any character including a doubled quoting + * character. The value may be empty. + * + * The syntax isn't checked, but an XML name begins with a letter or '_', + * and contains letters, digits, '.', '-', '_', and a bunch of special + * non-ASCII Unicode characters. An XML qualified name is a pair of names + * separated by a colon. + */ + @kotlin.jvm.JvmStatic + fun expandXPath(schemaNS: String?, path: String?): XMPPath { + + if (schemaNS == null || path == null) + throw XMPException("Parameter must not be null", XMPError.BADPARAM) + + val expandedXPath = XMPPath() + + val pos = PathPosition() + + pos.path = path + + // Pull out the first component and do some special processing on it: add the schema + // namespace prefix and 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. + while (pos.stepEnd < path.length) { + + pos.stepBegin = pos.stepEnd + + skipPathDelimiter(path, pos) + + pos.stepEnd = pos.stepBegin + + var segment: XMPPathSegment + + segment = if (path[pos.stepBegin] != '[') { + // A struct field or qualifier. + parseStructSegment(pos) + } else { + // One of the array forms. + parseIndexSegment(pos) + } + + if (segment.kind == XMPPath.STRUCT_FIELD_STEP) { + + if (segment.name!![0] == '@') { + + segment.name = "?" + segment.name!!.substring(1) + + if ("?xml:lang" != segment.name) + throw XMPException("Only xml:lang allowed with '@'", XMPError.BADXPATH) + } + + if (segment.name!![0] == '?') { + + pos.nameStart++ + + segment.kind = XMPPath.QUALIFIER_STEP + } + + verifyQualName(pos.path!!.substring(pos.nameStart, pos.nameEnd)) + + } else if (segment.kind == XMPPath.FIELD_SELECTOR_STEP) { + + if (segment.name!![1] == '@') { + + segment.name = "[?" + segment.name!!.substring(2) + + if (!segment.name!!.startsWith("[?xml:lang=")) + throw XMPException("Only xml:lang allowed with '@'", XMPError.BADXPATH) + } + + if (segment.name!![1] == '?') { + + pos.nameStart++ + + segment.kind = XMPPath.QUAL_SELECTOR_STEP + + verifyQualName(pos.path!!.substring(pos.nameStart, pos.nameEnd)) + } + } + + expandedXPath.add(segment) + } + + return expandedXPath + } + + private fun skipPathDelimiter(path: String, pos: PathPosition) { + + if (path[pos.stepBegin] == '/') { + + // skip slash + pos.stepBegin++ + + if (pos.stepBegin >= path.length) + throw XMPException("Empty XMPPath segment", XMPError.BADXPATH) + } + + if (path[pos.stepBegin] == '*') { + + // skip asterisk + pos.stepBegin++ + + if (pos.stepBegin >= path.length || path[pos.stepBegin] != '[') + throw XMPException("Missing '[' after '*'", XMPError.BADXPATH) + } + } + + private fun parseStructSegment(pos: PathPosition): XMPPathSegment { + + pos.nameStart = pos.stepBegin + + while (pos.stepEnd < pos.path!!.length && "/[*".indexOf(pos.path!![pos.stepEnd]) < 0) + pos.stepEnd++ + + pos.nameEnd = pos.stepEnd + + if (pos.stepEnd == pos.stepBegin) + throw XMPException("Empty XMPPath segment", XMPError.BADXPATH) + + return XMPPathSegment( + pos.path!!.substring(pos.stepBegin, pos.stepEnd), + XMPPath.STRUCT_FIELD_STEP + ) + } + + /** + * Parses an array index segment. + */ + private fun parseIndexSegment(pos: PathPosition): XMPPathSegment { + + val segment: XMPPathSegment + + pos.stepEnd++ // Look at the character after the leading '['. + + if ('0' <= pos.path!![pos.stepEnd] && pos.path!![pos.stepEnd] <= '9') { + + // A numeric (decimal integer) array index. + while ( + pos.stepEnd < pos.path!!.length && + '0' <= pos.path!![pos.stepEnd] && pos.path!![pos.stepEnd] <= '9' + ) + pos.stepEnd++ + + segment = XMPPathSegment(null, XMPPath.ARRAY_INDEX_STEP) + + } else { + + // 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] != '=' + ) + pos.stepEnd++ + + if (pos.stepEnd >= pos.path!!.length) + throw XMPException("Missing ']' or '=' for array index", XMPError.BADXPATH) + + if (pos.path!![pos.stepEnd] == ']') { + + if ("[last()" != pos.path!!.substring(pos.stepBegin, pos.stepEnd)) + throw XMPException("Invalid non-numeric array index", XMPError.BADXPATH) + + segment = XMPPathSegment(null, XMPPath.ARRAY_LAST_STEP) + + } else { + + pos.nameStart = pos.stepBegin + 1 + pos.nameEnd = pos.stepEnd + + pos.stepEnd++ // Absorb the '=', remember the quote. + + val quote = pos.path!![pos.stepEnd] + + if (quote != '\'' && quote != '"') + throw XMPException("Invalid quote in array selector", XMPError.BADXPATH) + + pos.stepEnd++ // Absorb the leading quote. + + while (pos.stepEnd < pos.path!!.length) { + + if (pos.path!![pos.stepEnd] == quote) { + + // check for escaped quote + if (pos.stepEnd + 1 >= pos.path!!.length || pos.path!![pos.stepEnd + 1] != quote) + break + + pos.stepEnd++ + } + + pos.stepEnd++ + } + + if (pos.stepEnd >= pos.path!!.length) + throw XMPException("No terminating quote for array selector", XMPError.BADXPATH) + + pos.stepEnd++ // Absorb the trailing quote. + + // ! Touch up later, also changing '@' to '?'. + segment = XMPPathSegment(null, XMPPath.FIELD_SELECTOR_STEP) + } + } + + if (pos.stepEnd >= pos.path!!.length || pos.path!![pos.stepEnd] != ']') + throw XMPException("Missing ']' for array index", XMPError.BADXPATH) + + pos.stepEnd++ + + segment.name = pos.path!!.substring(pos.stepBegin, pos.stepEnd) + + return segment + } + + /** + * Parses the root node of an XMP Path, checks if namespace and prefix fit together + * and resolve the property to the base property if it is an alias. + */ + private fun parseRootNode(schemaNS: String, pos: PathPosition, expandedXPath: XMPPath) { + + while (pos.stepEnd < pos.path!!.length && "/[*".indexOf(pos.path!![pos.stepEnd]) < 0) + pos.stepEnd++ + + if (pos.stepEnd == pos.stepBegin) + throw XMPException("Empty initial XMPPath step", XMPError.BADXPATH) + + val rootProp = verifyXPathRoot(schemaNS, pos.path!!.substring(pos.stepBegin, pos.stepEnd)) + val aliasInfo = schemaRegistry.findAlias(rootProp) + + if (aliasInfo == null) { + + // add schema xpath step + expandedXPath.add(XMPPathSegment(schemaNS, XMPPath.SCHEMA_NODE)) + + val rootStep = XMPPathSegment(rootProp, XMPPath.STRUCT_FIELD_STEP) + + expandedXPath.add(rootStep) + + } else { + + // add schema xpath step and base step of alias + expandedXPath.add(XMPPathSegment(aliasInfo.getNamespace(), XMPPath.SCHEMA_NODE)) + + val rootStep = XMPPathSegment( + verifyXPathRoot(aliasInfo.getNamespace(), aliasInfo.getPropName()), + XMPPath.STRUCT_FIELD_STEP + ) + + rootStep.isAlias = true + rootStep.aliasForm = aliasInfo.getAliasForm().getOptions() + + expandedXPath.add(rootStep) + + if (aliasInfo.getAliasForm().isArrayAltText()) { + + val qualSelectorStep = + XMPPathSegment("[?xml:lang='x-default']", XMPPath.QUAL_SELECTOR_STEP) + + qualSelectorStep.isAlias = true + qualSelectorStep.aliasForm = aliasInfo.getAliasForm().getOptions() + + expandedXPath.add(qualSelectorStep) + + } else if (aliasInfo.getAliasForm().isArray()) { + + val indexStep = XMPPathSegment("[1]", XMPPath.ARRAY_INDEX_STEP) + + indexStep.isAlias = true + indexStep.aliasForm = aliasInfo.getAliasForm().getOptions() + + expandedXPath.add(indexStep) + } + } + } + + /** + * Verifies whether the qualifier name is not XML conformant or the + * namespace prefix has not been registered. + */ + private fun verifyQualName(qualName: String) { + + val colonPos = qualName.indexOf(':') + + if (colonPos > 0) { + + val prefix = qualName.substring(0, colonPos) + + if (Utils.isXMLNameNS(prefix)) { + + val regURI = schemaRegistry.getNamespaceURI(prefix) + + if (regURI != null) + return + + throw XMPException("Unknown namespace prefix for qualified name", XMPError.BADXPATH) + } + } + + throw XMPException("Ill-formed qualified name", XMPError.BADXPATH) + } + + /** + * Verify if an XML name is conformant. + */ + private fun verifySimpleXMLName(name: String) { + + if (!Utils.isXMLName(name)) + throw XMPException("Bad XML name", XMPError.BADXPATH) + } + + /** + * Set up the first 2 components of the expanded XMPPath. Normalizes the various cases of using + * the full schema URI and/or a qualified root property name. Returns true for normal + * processing. If allowUnknownSchemaNS is true and the schema namespace is not registered, false + * is returned. If allowUnknownSchemaNS is false and the schema namespace is not registered, an + * exception is thrown + */ + private fun verifyXPathRoot(schemaNS: String?, rootProp: String): String { + + // Do some basic checks on the URI and name. Try to lookup the URI. See if the name is qualified. + if (schemaNS == null || schemaNS.length == 0) + 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) + + if (rootProp.indexOf('/') >= 0 || rootProp.indexOf('[') >= 0) + throw XMPException("Top level name must be simple", XMPError.BADXPATH) + + var prefix = schemaRegistry.getNamespacePrefix(schemaNS) + ?: throw XMPException("Unregistered schema namespace URI", XMPError.BADSCHEMA) + + // 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. + + verifySimpleXMLName(rootProp) // Verify the part before any colon + + prefix + rootProp + + } else { + + // The propName is qualified. Make sure the prefix is legit. + // Use the associated URI and qualified name. + + // Verify the part before any colon + verifySimpleXMLName(rootProp.substring(0, colonPos)) + verifySimpleXMLName(rootProp.substring(colonPos)) + + prefix = rootProp.substring(0, colonPos + 1) + + val regPrefix = schemaRegistry.getNamespacePrefix(schemaNS) + ?: throw XMPException("Unknown schema namespace prefix", XMPError.BADSCHEMA) + + if (prefix != regPrefix) + throw XMPException("Schema namespace URI and prefix mismatch", XMPError.BADSCHEMA) + + rootProp + } + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/XMPPathSegment.kt b/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/XMPPathSegment.kt new file mode 100644 index 0000000..f63c420 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/impl/xpath/XMPPathSegment.kt @@ -0,0 +1,56 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.impl.xpath + +/** + * A segment of a parsed `XMPPath`. + */ +class XMPPathSegment { + + /** + * name of the path segment + */ + var name: String? + + /** + * kind of the path segment + */ + var kind = 0 + + /** + * flag if segment is an alias + */ + var isAlias = false + + /** + * alias form if applicable + */ + var aliasForm = 0 + + /** + * Constructor with initial values. + */ + constructor(name: String) { + this.name = name + } + + /** + * Constructor with initial values. + * + * Note: Name can be NULL for XMPPath.ARRAY_INDEX_STEP and others. + */ + constructor(name: String?, kind: Int) { + this.name = name + this.kind = kind + } + + override fun toString(): String = + name ?: "null" + +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/options/AliasOptions.kt b/src/commonMain/kotlin/com/ashampoo/xmp/options/AliasOptions.kt new file mode 100644 index 0000000..21df954 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/options/AliasOptions.kt @@ -0,0 +1,96 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.options + +/** + * Options for XMPSchemaRegistryImpl#registerAlias. + */ +class AliasOptions : Options { + + constructor() : super() + + constructor(options: Int) : super(options) + + fun isSimple(): Boolean = + getOptions() == PROP_DIRECT + + fun isArray(): Boolean = + getOption(PROP_ARRAY) + + fun setArray(value: Boolean): AliasOptions { + setOption(PROP_ARRAY, value) + return this + } + + fun isArrayOrdered(): Boolean = + getOption(PROP_ARRAY_ORDERED) + + fun setArrayOrdered(value: Boolean): AliasOptions { + setOption(PROP_ARRAY or PROP_ARRAY_ORDERED, value) + return this + } + + fun isArrayAlternate(): Boolean = + getOption(PROP_ARRAY_ALTERNATE) + + fun setArrayAlternate(value: Boolean): AliasOptions { + setOption(PROP_ARRAY or PROP_ARRAY_ORDERED or PROP_ARRAY_ALTERNATE, value) + return this + } + + fun isArrayAltText(): Boolean = + getOption(PROP_ARRAY_ALT_TEXT) + + fun setArrayAltText(value: Boolean): AliasOptions { + setOption(PROP_ARRAY or PROP_ARRAY_ORDERED or PROP_ARRAY_ALTERNATE or PROP_ARRAY_ALT_TEXT, value) + return this + } + + fun toPropertyOptions(): PropertyOptions = + PropertyOptions(getOptions()) + + protected override fun defineOptionName(option: Int): String? { + return when (option) { + PROP_DIRECT -> "PROP_DIRECT" + PROP_ARRAY -> "ARRAY" + PROP_ARRAY_ORDERED -> "ARRAY_ORDERED" + PROP_ARRAY_ALTERNATE -> "ARRAY_ALTERNATE" + PROP_ARRAY_ALT_TEXT -> "ARRAY_ALT_TEXT" + else -> null + } + } + + protected override fun getValidOptions(): Int = + PROP_DIRECT or PROP_ARRAY or PROP_ARRAY_ORDERED or PROP_ARRAY_ALTERNATE or PROP_ARRAY_ALT_TEXT + + companion object { + + const val PROP_DIRECT = 0 + + /** + * The actual is an unordered array, the alias is to the first element of the array. + */ + const val PROP_ARRAY = PropertyOptions.ARRAY + + /** + * The actual is an ordered array, the alias is to the first element of the array. + */ + const val PROP_ARRAY_ORDERED = PropertyOptions.ARRAY_ORDERED + + /** + * The actual is an alternate array, the alias is to the first element of the array. + */ + const val PROP_ARRAY_ALTERNATE = PropertyOptions.ARRAY_ALTERNATE + + /** + * The actual is an alternate text array, the alias is to the 'x-default' element of the array. + */ + const val PROP_ARRAY_ALT_TEXT = PropertyOptions.ARRAY_ALT_TEXT + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/options/IteratorOptions.kt b/src/commonMain/kotlin/com/ashampoo/xmp/options/IteratorOptions.kt new file mode 100644 index 0000000..254062e --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/options/IteratorOptions.kt @@ -0,0 +1,127 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.options + +/** + * Options for XMPIterator construction. + */ +class IteratorOptions : Options() { + + /** + * @return Returns whether the option is set. + */ + fun isJustChildren(): Boolean = + getOption(JUST_CHILDREN) + + /** + * @return Returns whether the option is set. + */ + fun isJustLeafname(): Boolean = + getOption(JUST_LEAFNAME) + + /** + * @return Returns whether the option is set. + */ + fun isJustLeafnodes(): Boolean = + getOption(JUST_LEAFNODES) + + /** + * @return Returns whether the option is set. + */ + fun isOmitQualifiers(): Boolean = + getOption(OMIT_QUALIFIERS) + + /** + * Sets the option and returns the instance. + * + * @param value the value to set + * @return Returns the instance to call more set-methods. + */ + fun setJustChildren(value: Boolean): IteratorOptions { + setOption(JUST_CHILDREN, value) + return this + } + + /** + * Sets the option and returns the instance. + * + * @param value the value to set + * @return Returns the instance to call more set-methods. + */ + fun setJustLeafname(value: Boolean): IteratorOptions { + setOption(JUST_LEAFNAME, value) + return this + } + + /** + * Sets the option and returns the instance. + * + * @param value the value to set + * @return Returns the instance to call more set-methods. + */ + fun setJustLeafnodes(value: Boolean): IteratorOptions { + setOption(JUST_LEAFNODES, value) + return this + } + + /** + * Sets the option and returns the instance. + * + * @param value the value to set + * @return Returns the instance to call more set-methods. + */ + fun setOmitQualifiers(value: Boolean): IteratorOptions { + setOption(OMIT_QUALIFIERS, value) + return this + } + + /** + * @see Options.defineOptionName + */ + override fun defineOptionName(option: Int): String? { + return when (option) { + JUST_CHILDREN -> "JUST_CHILDREN" + JUST_LEAFNODES -> "JUST_LEAFNODES" + JUST_LEAFNAME -> "JUST_LEAFNAME" + OMIT_QUALIFIERS -> "OMIT_QUALIFIERS" + else -> null + } + } + + /** + * @see Options.getValidOptions + */ + override fun getValidOptions(): Int = + JUST_CHILDREN or JUST_LEAFNODES or JUST_LEAFNAME or OMIT_QUALIFIERS + + /** + * Just do the immediate children of the root, default is subtree. + */ + companion object { + + const val JUST_CHILDREN = 0x0100 + + /** + * Just do the leaf nodes, default is all nodes in the subtree. + * Bugfix #2658965: If this option is set the Iterator returns the namespace + * of the leaf instead of the namespace of the base property. + */ + const val JUST_LEAFNODES = 0x0200 + + /** + * Return just the leaf part of the path, default is the full path. + */ + const val JUST_LEAFNAME = 0x0400 + + /** + * Omit all qualifiers. + */ + const val OMIT_QUALIFIERS = 0x1000 + } +} diff --git a/src/commonMain/kotlin/com/ashampoo/xmp/options/Options.kt b/src/commonMain/kotlin/com/ashampoo/xmp/options/Options.kt new file mode 100644 index 0000000..19b3855 --- /dev/null +++ b/src/commonMain/kotlin/com/ashampoo/xmp/options/Options.kt @@ -0,0 +1,229 @@ +// ================================================================================================= +// ADOBE SYSTEMS INCORPORATED +// Copyright 2006 Adobe Systems Incorporated +// All Rights Reserved +// +// NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms +// of the Adobe license agreement accompanying it. +// ================================================================================================= +package com.ashampoo.xmp.options + +import com.ashampoo.xmp.XMPError +import com.ashampoo.xmp.XMPException + +/** + * The base class for a collection of 32 flag bits. Individual flags are defined as enum value bit + * masks. Inheriting classes add convenience accessor methods. + */ +abstract class Options { + + /** + * the internal int containing all options + */ + private var valueBits = 0 + + /** + * a map containing the bit names + */ + private val optionNames = mutableMapOf() + + /** + * The default constructor. + */ + protected constructor() + + /** + * Constructor with the options bit mask. + * + * @param options the options bit mask + * + */ + protected constructor(options: Int) { + assertOptionsValid(options) + setOptions(options) + } + + protected abstract fun getValidOptions(): Int + + /** + * Resets the options. + */ + fun clear() { + valueBits = 0 + } + + /** + * @param optionBits an option bitmask + * @return Returns true, if this object is equal to the given options. + */ + fun isExactly(optionBits: Int): Boolean = + getOptions() == optionBits + + /** + * @param optionBits an option bitmask + * @return Returns true, if this object contains all given options. + */ + fun containsAllOptions(optionBits: Int): Boolean = + getOptions() and optionBits == optionBits + + /** + * @param optionBits an option bitmask + * @return Returns true, if this object contain at least one of the given options. + */ + fun containsOneOf(optionBits: Int): Boolean = + getOptions() and optionBits != 0 + + /** + * @param optionBit the binary bit or bits that are requested + * @return Returns if *all* of the requested bits are set or not. + */ + protected fun getOption(optionBit: Int): Boolean = + valueBits and optionBit != 0 + + /** + * @param optionBits the binary bit or bits that shall be set to the given value + * @param value the boolean value to set + */ + fun setOption(optionBits: Int, value: Boolean) { + this.valueBits = if (value) + this.valueBits or optionBits + else + this.valueBits and optionBits.inv() + } + + /** + * Is friendly to access it during the tests. + * + * @return Returns the options. + */ + fun getOptions(): Int = valueBits + + /** + * @param options The options to set. + * + */ + fun setOptions(options: Int) { + + assertOptionsValid(options) + + this.valueBits = options + } + + /** + * @see Object.equals + */ + override fun equals(other: Any?): Boolean = + getOptions() == (other as? Options)?.getOptions() + + /** + * @see Object.hashCode + */ + override fun hashCode(): Int = getOptions() + + /** + * Creates a human readable string from the set options. *Note:* This method is quite + * expensive and should only be used within tests or as + * + * @return Returns a String listing all options that are set to `true` by their name, + * like "option1 | option4". + */ + fun getOptionsString(): String { + + if (valueBits != 0) { + + val sb = StringBuilder() + + var theBits = valueBits + + while (theBits != 0) { + + val oneLessBit = theBits and theBits - 1 // clear rightmost one bit + val singleBit = theBits xor oneLessBit + val bitName = getOptionName(singleBit) + sb.append(bitName) + + if (oneLessBit != 0) + sb.append(" | ") + + theBits = oneLessBit + } + + return sb.toString() + + } else { + return "" + } + } + + /** + * @return Returns the options as hex bitmask. + */ + override fun toString(): String = + "0x" + valueBits.toString(16) + + /** + * To be implemeted by inheritants. + * + * @param option a single, valid option bit. + * @return Returns a human readable name for an option bit. + */ + protected abstract fun defineOptionName(option: Int): String? + + /** + * The inheriting option class can do additional checks on the options. + * *Note:* For performance reasons this method is only called + * when setting bitmasks directly. + * When get- and set-methods are used, this method must be called manually, + * normally only when the Options-object has been created from a client + * (it has to be made public therefore). + * + * @param options the bitmask to check. + * + */ + protected open fun assertConsistency(options: Int) = Unit // empty, no checks + + /** + * Checks options before they are set. + * First it is checked if only defined options are used, + * second the additional [Options.assertConsistency]-method is called. + * + * @param options the options to check + * + */ + private fun assertOptionsValid(options: Int) { + + val invalidOptions = options and getValidOptions().inv() + + if (invalidOptions == 0) + assertConsistency(options) + else + throw XMPException( + "The option bit(s) 0x" + invalidOptions.toString(16) + " + are invalid!", + XMPError.BADOPTIONS + ) + } + + /** + * Looks up or asks the inherited class for the name of an option bit. + * Its save that there is only one valid option handed into the method. + * + * @param option a single option bit + * @return Returns the option name or undefined. + */ + private fun getOptionName(option: Int): String { + + var result = optionNames[option] + + if (result == null) { + + result = defineOptionName(option) + + if (result != null) + optionNames[option] = result + else + result = "