diff --git a/README.md b/README.md index 064d9dc..5ea49b3 100644 --- a/README.md +++ b/README.md @@ -179,17 +179,27 @@ Recommended for multicolor images. > - For iOS generate images from @1x to @3x (where @1x is 1:1 in pixels to specified size) > - For JVM and JS a single image of specified size is generated. +> **night** +> +> Night/Dark Mode images are supported for Android and iOS by adding the `(night)` modifier. The filename and type of +> the image must match the corresponding day/light version without the `(night)` modifier. +> +> - For Android this creates night images in `drawable-night-nodpi`. +> - For iOS this creates a `"appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ]` entry in the `imageset`. + Filename examples: ``` some_hd_image_(100).jpg app_logo_(orig).svg my_colorful_bitmap_(orig)_(150).png +image_with_night_support_(night).png ``` Kotlin: ```kotlin MainRes.image.some_hd_image MainRes.image.app_logo MainRes.image.my_colorful_bitmap +MainRes.image.image_with_night_support ``` Swift: ```swift diff --git a/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/LibresImagesGenerationTask.kt b/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/LibresImagesGenerationTask.kt index 29ea103..51e6530 100644 --- a/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/LibresImagesGenerationTask.kt +++ b/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/LibresImagesGenerationTask.kt @@ -1,19 +1,21 @@ package io.github.skeptick.libres.plugin -import org.gradle.api.DefaultTask -import org.gradle.api.file.FileCollection -import org.gradle.api.tasks.* -import org.gradle.work.ChangeType -import org.gradle.work.Incremental -import org.gradle.work.InputChanges import io.github.skeptick.libres.plugin.common.declarations.saveToDirectory import io.github.skeptick.libres.plugin.common.extensions.deleteFilesInDirectory import io.github.skeptick.libres.plugin.images.ImagesTypeSpecsBuilder import io.github.skeptick.libres.plugin.images.declarations.EmptyImagesObject import io.github.skeptick.libres.plugin.images.declarations.ImagesObjectFile import io.github.skeptick.libres.plugin.images.models.ImageProps +import io.github.skeptick.libres.plugin.images.models.ImageSet import io.github.skeptick.libres.plugin.images.processing.removeImage import io.github.skeptick.libres.plugin.images.processing.saveImage +import io.github.skeptick.libres.plugin.images.processing.saveImageSet +import org.gradle.api.DefaultTask +import org.gradle.api.file.FileCollection +import org.gradle.api.tasks.* +import org.gradle.work.ChangeType +import org.gradle.work.Incremental +import org.gradle.work.InputChanges import java.io.File @CacheableTask @@ -35,18 +37,33 @@ abstract class LibresImagesGenerationTask : DefaultTask() { @TaskAction fun apply(inputChanges: InputChanges) { - inputChanges.getFileChanges(inputDirectory).forEach { change -> - @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") - when (change.changeType) { - ChangeType.REMOVED -> ImageProps(change.file).removeImage(outputResourcesDirectories) - ChangeType.MODIFIED, ChangeType.ADDED -> ImageProps(change.file).saveImage(outputResourcesDirectories) + // Update images for changed files + inputChanges.getFileChanges(inputDirectory) + .forEach { change -> + val image = ImageProps(change.file) + + @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") + when (change.changeType) { + ChangeType.MODIFIED, ChangeType.ADDED -> image.saveImage(outputResourcesDirectories) + ChangeType.REMOVED -> image.removeImage(outputResourcesDirectories) + } } - } + // Generate image sets + inputDirectory.files + .map(::ImageProps) + .groupBy(ImageProps::name) + .map { (name, files) -> ImageSet(name, files) } + .forEach { catalog -> + catalog.saveImageSet(outputResourcesDirectories) + } + + // Generate code inputDirectory.files .takeIf { files -> files.isNotEmpty() } - ?.map { file -> ImageProps(file) } - ?.let { imageProps -> buildImages(imageProps) } + ?.map(::ImageProps) + ?.distinctBy(ImageProps::name) + ?.let(::buildImages) ?: buildEmptyImages() } @@ -66,5 +83,4 @@ abstract class LibresImagesGenerationTask : DefaultTask() { imagesObjectFileSpec.saveToDirectory(directory) } } - -} \ No newline at end of file +} diff --git a/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/models/ImageProps.kt b/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/models/ImageProps.kt index 26c2bac..3b9e686 100644 --- a/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/models/ImageProps.kt +++ b/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/models/ImageProps.kt @@ -8,20 +8,22 @@ internal class ImageProps(val file: File) { val extension: String val targetSize: Int? val isTintable: Boolean + val isNightMode: Boolean init { val nameWithoutExtension = file.nameWithoutExtension val parameters = ParametersRegex.findAll(nameWithoutExtension).toList() + this.name = nameWithoutExtension.substringBefore("_(").lowercase() this.extension = file.extension.lowercase() this.targetSize = if (!isVector) parameters.firstNotNullOfOrNull { it.groupValues[1].toIntOrNull() } else null this.isTintable = parameters.none { it.groupValues[1].startsWith("orig") } + this.isNightMode = parameters.any { it.groupValues[1] == "night" } } companion object { private val ParametersRegex = Regex("_\\((.*?)\\)") } - } -internal val ImageProps.isVector: Boolean get() = extension == "svg" \ No newline at end of file +internal val ImageProps.isVector: Boolean get() = extension == "svg" diff --git a/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/models/ImageSet.kt b/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/models/ImageSet.kt new file mode 100644 index 0000000..7dfa753 --- /dev/null +++ b/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/models/ImageSet.kt @@ -0,0 +1,12 @@ +package io.github.skeptick.libres.plugin.images.models + +internal class ImageSet( + val name: String, + val images: Iterable, +) { + val isVector: Boolean + get() = images.all { it.isVector } + + val isTintable: Boolean + get() = images.all { it.isTintable } +} diff --git a/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/models/ImageSetContents.kt b/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/models/ImageSetContents.kt index ff67433..64a26f7 100644 --- a/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/models/ImageSetContents.kt +++ b/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/models/ImageSetContents.kt @@ -23,9 +23,24 @@ internal data class ImageSetContents( data class Image( val filename: String, val scale: ImageScale? = null, - val idiom: String = "universal" + val idiom: String = "universal", + val appearances: List? = null, ) + sealed interface Appearance { + val appearance: String + val value: String + + sealed interface Luminosity : Appearance { + override val appearance: String + get() = "luminosity" + + object Dark : Luminosity { + override val value: String = "dark" + } + } + } + data class Info( val author: String = "xcode", val version: Int = 1 @@ -35,24 +50,4 @@ internal data class ImageSetContents( @JsonProperty("preserves-vector-representation") val preserveVectorRepresentation: Boolean?, @JsonProperty("template-rendering-intent") val templateRenderingIntent: VectorRenderingType ) - } - -internal fun ImageProps.toImageSetContents() = - ImageSetContents( - images = when (targetSize) { - null -> listOf( - ImageSetContents.Image(filename = "$name.$extension") - ) - else -> ImageSetContents.ImageScale.values().map { - ImageSetContents.Image(filename = "${this.name}_${it.name}.$extension", scale = it) - } - }, - properties = ImageSetContents.Properties( - preserveVectorRepresentation = if (isVector) true else null, - templateRenderingIntent = when (isTintable) { - true -> ImageSetContents.VectorRenderingType.Template - false -> ImageSetContents.VectorRenderingType.Original - } - ) - ) \ No newline at end of file diff --git a/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/processing/ImageSpecs+Operations.kt b/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/processing/ImageSpecs+Operations.kt index e2a02ef..c83b5b3 100644 --- a/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/processing/ImageSpecs+Operations.kt +++ b/gradle-plugin/src/main/java/io/github/skeptick/libres/plugin/images/processing/ImageSpecs+Operations.kt @@ -5,14 +5,18 @@ import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.core.util.DefaultIndenter import com.fasterxml.jackson.core.util.DefaultPrettyPrinter import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import org.bytedeco.opencv.global.opencv_imgcodecs -import org.bytedeco.opencv.global.opencv_imgproc -import org.bytedeco.opencv.opencv_core.Mat import io.github.skeptick.libres.plugin.KotlinPlatform -import io.github.skeptick.libres.plugin.images.models.* -import io.github.skeptick.libres.plugin.images.models.ImageScale import io.github.skeptick.libres.plugin.images.models.ImageProps +import io.github.skeptick.libres.plugin.images.models.ImageScale +import io.github.skeptick.libres.plugin.images.models.ImageSet +import io.github.skeptick.libres.plugin.images.models.ImageSetContents +import io.github.skeptick.libres.plugin.images.models.ImageSetContents.Appearance import io.github.skeptick.libres.plugin.images.models.androidName +import io.github.skeptick.libres.plugin.images.models.isVector +import io.github.skeptick.libres.plugin.images.models.times +import org.bytedeco.opencv.global.opencv_imgcodecs +import org.bytedeco.opencv.global.opencv_imgproc +import org.bytedeco.opencv.opencv_core.Mat import java.io.File import java.io.FileOutputStream import java.io.OutputStream @@ -25,10 +29,6 @@ private val jsonWriter = jacksonObjectMapper().let { } internal fun ImageProps.saveImage(directories: Map) { - directories[KotlinPlatform.Apple]?.let { - saveImageSetContents(it) - } - if (targetSize == null) { saveOriginal(directories) } else { @@ -50,10 +50,10 @@ internal fun ImageProps.removeImage(directories: Map) { when { platform == KotlinPlatform.Common -> continue platform == KotlinPlatform.Apple -> File(directory, "$name.imageset").deleteRecursively() - platform == KotlinPlatform.Android && targetSize != null -> ImageScale.values().forEach { - File(directory, targetFilePath(platform, it)).delete() + platform == KotlinPlatform.Android && targetSize != null -> ImageScale.values().forEach { scale -> + targetFile(directory, platform, scale).delete() } - else -> File(directory, targetFilePath(platform)).delete() + else -> targetFile(directory, platform).delete() } } } @@ -63,14 +63,18 @@ private fun ImageProps.saveOriginal(directories: Map) { when { platform == KotlinPlatform.Common -> continue platform == KotlinPlatform.Android && isVector -> { - val targetFile = File(directory, targetFilePath(platform)) - targetFile.parentFile.mkdirs() + val targetFile = targetFile(directory, platform).apply { + parentFile.mkdirs() + } + val output = FileOutputStream(targetFile) parseSvgToXml(file, output) } else -> { - val targetFile = File(directory, targetFilePath(platform)) - targetFile.parentFile.mkdirs() + val targetFile = targetFile(directory, platform).apply { + parentFile.mkdirs() + } + file.copyTo(targetFile, overwrite = true) } } @@ -80,8 +84,10 @@ private fun ImageProps.saveOriginal(directories: Map) { private fun ImageProps.saveOriginal(scale: ImageScale, directories: Map) { for ((platform, directory) in directories) { if (platform in scale.supportedPlatforms) { - val targetFile = File(directory, targetFilePath(platform, scale)) - file.parentFile.mkdirs() + val targetFile = targetFile(directory, platform, scale).apply { + parentFile.mkdirs() + } + file.copyTo(targetFile, overwrite = true) } } @@ -94,28 +100,110 @@ private fun ImageProps.resizeAndSave(src: Mat, scale: ImageScale, size: Int, dir for ((platform, directory) in directories) { if (platform in scale.supportedPlatforms) { - val targetPath = directory.absolutePath + targetFilePath(platform, scale) - File(targetPath).parentFile.mkdirs() - opencv_imgcodecs.imwrite(targetPath, destinationImage) + val targetFile = targetFile(directory, platform, scale).apply { + parentFile.mkdirs() + } + + opencv_imgcodecs.imwrite(targetFile.absolutePath, destinationImage) + } + } +} + +internal fun ImageSet.saveImageSet(directories: Map) { + for ((platform, directory) in directories) { + when (platform) { + KotlinPlatform.Apple -> saveImageSetContents(directory) + KotlinPlatform.Android, KotlinPlatform.Common, KotlinPlatform.Jvm, KotlinPlatform.Js -> Unit } } } -private fun ImageProps.saveImageSetContents(directory: File) { - val text = jsonWriter.writeValueAsString(toImageSetContents()) +private fun ImageSet.saveImageSetContents(directory: File) { + val text = jsonWriter.writeValueAsString(toImageSetContents(directory)) val file = File(directory, "$name.imageset/Contents.json") file.parentFile.mkdirs() file.writeText(text) } +private fun ImageSet.toImageSetContents(directory: File) = + ImageSetContents( + images = images.map { image -> + val appearances = image.appearances() + when (image.targetSize) { + null -> listOf( + ImageSetContents.Image( + filename = image.targetFile(directory, KotlinPlatform.Apple).name, + appearances = appearances, + ) + ) + + else -> ImageSetContents.ImageScale.values().map { scale -> + ImageSetContents.Image( + filename = image.targetFile(directory, KotlinPlatform.Apple, scale.toImageScale()).name, + scale = scale, + appearances = appearances, + ) + } + } + }.flatten(), + properties = ImageSetContents.Properties( + preserveVectorRepresentation = if (isVector) true else null, + templateRenderingIntent = when (isTintable) { + true -> ImageSetContents.VectorRenderingType.Template + false -> ImageSetContents.VectorRenderingType.Original + } + ) + ) + +private fun ImageSetContents.ImageScale.toImageScale() = + when (this) { + ImageSetContents.ImageScale.x1 -> ImageScale.x1 + ImageSetContents.ImageScale.x2 -> ImageScale.x2 + ImageSetContents.ImageScale.x3 -> ImageScale.x3 + } + +private fun ImageProps.appearances(): List? = + listOfNotNull( + if (isNightMode) Appearance.Luminosity.Dark else null, + ).takeIf(List<*>::isNotEmpty) + +private fun ImageProps.targetFile( + directory: File, + platform: KotlinPlatform, + scale: ImageScale? = null, +) = File(directory, targetFilePath(platform, scale)) + private fun ImageProps.targetFilePath(platform: KotlinPlatform, scale: ImageScale? = null): String = when (platform) { - KotlinPlatform.Android -> "/drawable-${scale?.androidName ?: "nodpi"}/$name.${if (isVector) "xml" else extension}" - KotlinPlatform.Apple -> "/$name.imageset/$name${if (scale != null) "_${scale.name}" else ""}.$extension" + KotlinPlatform.Android -> androidTargetFilePath(scale) + KotlinPlatform.Apple -> appleTargetFilePath(scale) KotlinPlatform.Jvm, KotlinPlatform.Js -> "/$name.$extension" - KotlinPlatform.Common -> throw IllegalArgumentException() + KotlinPlatform.Common -> error("Can not generate targetFilePath for platform '$platform'.") } +private fun ImageProps.androidTargetFilePath(scale: ImageScale?): String { + val folderName = listOfNotNull( + "drawable", + if (isNightMode) "night" else null, + scale?.androidName ?: if (isVector) "anydpi" else "nodpi", + ).joinToString("-") + val fileName = name + val extension = if (isVector) "xml" else extension + + return "/$folderName/$fileName.$extension" +} + +private fun ImageProps.appleTargetFilePath(scale: ImageScale?): String { + val folderName = "$name.imageset" + val fileName = listOfNotNull( + name, + scale?.name, + if (isNightMode) "night" else null, + ).joinToString("_") + + return "/$folderName/$fileName.$extension" +} + /** * Workaround for Svg2Vector::parseSvgToXml */ @@ -125,4 +213,4 @@ private fun parseSvgToXml(inputSvg: File, output: OutputStream) { File::class.java -> method(null, inputSvg, output) else -> method(null, inputSvg.toPath(), output) } -} \ No newline at end of file +} diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 0000000..d1f7068 --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,4 @@ +jdk: + - openjdk17 +install: + - ./gradlew publishToMavenLocal