前语

在我之前的文章 在安卓中完结读取Exif获取相片拍照日期后以水印文字方法增加到相片上 中,咱们现已完结了在安卓端读取 Exif 信息后增加文字水印到图片上。

也正如我在这篇文章中所说的,其实这个需求运用手机来完结是非常不合理的,一般来说,这种作业都应该交由桌面端来完结。

而我在上篇文章中所述之所以没有运用 Compose-jb 完结跨渠道的原因是没有找到适宜的跨渠道图片编辑库。

虽然现在仍旧没有适宜的跨渠道编辑库,可是我现在决议做一个朴实的桌面端,而不是持续拘泥于跨渠道。

如此一来,可挑选的库就多了。

先来看看完结作用:

宽恕我的 UI 一如既往的丑,期望各位看官别介意,咱们主要是完结需求,能用就行能用就行。

Compose For Desktop 实践:使用 Compose-jb 做一个时间水印助手

Compose For Desktop 实践:使用 Compose-jb 做一个时间水印助手

得益于 Compose 的特性,这个程序同时支撑 Mac、Windows、Linux 系统。

代码地址:TimelapseHelper

UI布局

UI布局总体来说分为左右两个部分:左边的图画预览区(ImageContent)、右边的参数控制区(ControlContent)。

为了确保咱们的内容能够完整显现,咱们需求首先在 Window 进口处设置窗口最小尺度约束:

window.minimumSize = Dimension(MinWindowSize.width.value.roundToInt(), MinWindowSize.height.value.roundToInt())

其中 MinWindowSize 是我自界说的一个变量:val MinWindowSize = DpSize(1100.dp, 700.dp)

下面分开解说两个部分的UI布局。

ImageContent

图画预览区相同分为两个部分:上面的图画预览、下面的文件列表。

由于桌面端需求支撑批量处理,一次能够增加不约束数量的多张图片,所以还需求加上一个文件列表,用来展示当时增加了那些文件。

详细代码如下:

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ImageContent(
    onclick: () -> Unit,
    onDel: (index: Int) -> Unit,
    fileList: List<File> = emptyList()
) {
    var showImageIndex by remember { mutableStateOf(0) }
    Card(
        onClick = onclick,
        modifier = Modifier.size(CardSize).padding(16.dp),
        shape = RoundedCornerShape(8.dp),
        elevation = 4.dp,
        backgroundColor = CardColor,
        enabled = fileList.isEmpty()
    ) {
        if (fileList.isEmpty()) {
            Column(
                modifier = Modifier.fillMaxSize(),
                verticalArrangement = Arrangement.Center,
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(
                    text = "请点击挑选文件(夹)或拖拽文件(夹)至此\n仅支撑 ${legalSuffixList.contentToString()}",
                    textAlign = TextAlign.Center
                )
            }
        }
        else {
            Column(
                modifier = Modifier.fillMaxSize(),
                verticalArrangement = Arrangement.Center,
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Image(
                    bitmap = fileList[showImageIndex.coerceAtMost(fileList.lastIndex)].inputStream().buffered().use(::loadImageBitmap),
                    contentDescription = null,
                    modifier = Modifier.height(CardSize.height / 2).fillMaxWidth(),
                    contentScale = ContentScale.Fit
                )
                LazyColumn(
                    modifier = Modifier.fillMaxWidth()
                ) {
                    item {
                        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
                            Button(onClick = onclick ) {
                                Text("增加")
                            }
                            Button(onClick = { onDel(-1) }) {
                                Text("清空")
                            }
                        }
                    }
                    itemsIndexed(fileList) {index: Int, item: File ->
                        Row(
                            modifier = Modifier.fillMaxWidth(),
                            verticalAlignment = Alignment.CenterVertically,
                            horizontalArrangement = Arrangement.SpaceBetween
                        ) {
                            Text(
                                item.absolutePath,
                                modifier = Modifier.clickable {
                                    showImageIndex = index
                                }.weight(0.9f),
                            )
                            Icon(
                                imageVector = Icons.Rounded.Delete,
                                contentDescription = null,
                                modifier = Modifier.clickable {
                                    onDel(index)
                                }.weight(0.1f)
                            )
                        }
                    }
                }
            }
        }
    }
}

布局很简单,运用 Card 作为父布局,然后判断传入的文件列表是否为空 fileList.isEmpty() ,假如为空则显现提示文本,不为空则显现图画和文件列表。

在这儿咱们界说了一个名为 showImageIndexmutableState 用于记载当时显现预览的是第几个图画文件。

在咱们点击 LazyColumn 中的文件时,会对应的更改这个值。

上面的代码咱们还需求留意一点,那便是关于怎样加载本地文件并显现。

咱们运用的是 File.inputStream().buffered().use(::loadImageBitmap) 从这段代码不难看出,咱们读取文件的输入流(inputStream)后,经过 loadImageBitmap 转为了 Image 组件支撑的参数类型 ImageBitmap

同时,咱们还将 LazyColumn 的榜首列写为了两个按钮 “增加” 和 “清空” ,用于便利的持续增加文件和清空一切文件。

并且,每一个文件名称后边,咱们都会跟上一个删去图标,用于删去单个文件。

作用如下:

Compose For Desktop 实践:使用 Compose-jb 做一个时间水印助手

别的,在没有选中任何文件时,这个界面支撑直接将文件或文件夹拖拽到运用中,也支撑点击后翻开文件挑选界面。这部分内容的详细完结咱们将在后边的完结逻辑中解说。

ControlContent

参数控制界面的作用如下:

Compose For Desktop 实践:使用 Compose-jb 做一个时间水印助手

能够看到,这个界面无非便是一堆控件的堆叠,没有任何难度,所以我就不贴代码了。

需求留意的地方有两点:

一是布局之间会有相关影响,比方榜首个 “输出途径” 这个参数,假如勾选了 “输出至原途径” ,则将输入框和”挑选”按钮禁用,并更改输入框内容为 “原途径”。

完结起来也很简单,这儿直接上代码:


var isUsingSourcePath by remember { mutableStateOf(true) }
// ……
Row(
    verticalAlignment = Alignment.CenterVertically,
) {
    Text("输出途径:")
    OutlinedTextField(
        value = outputPath,
        onValueChange = { outputPath = it },
        modifier = Modifier.width(CardSize.width / 3),
        enabled = !isUsingSourcePath
    )
    Button(
        onClick = {
                  // ……
        },
        modifier = Modifier.padding(start = 8.dp),
        enabled = !isUsingSourcePath
    ) {
        Text("挑选")
    }
    Checkbox(
        checked = isUsingSourcePath,
        onCheckedChange = {
            isUsingSourcePath = it
            outputPath = if (it) "原途径" else ""
        }
    )
    Text("输出至原途径", fontSize = 12.sp)
}

别的一个需求留意的点是咱们需求对输入框的内容做过滤。

由于实践上咱们输入框中的内容根本都是有固定格局的。

比方第二个输入框 “导出图画质量”,需求限定输入内容为 0-1 的浮点数。

第三个输入框 “文字色彩”,输入格局为首字母为 “#” 剩余的是八位十六进制数。

最后一个输入框 “时区”,格局为首字母固定 “GMT” ,接下来紧跟一个 “+” 或者 “-“,最后是固定的 “xx:xx” 格局,其中 xx 能够是任意数字。(其实这儿的时区能够运用多种表明方法,可是这儿咱们人为约束只能运用这种规范表明方法)

由于输入内容过滤我还没玩了解,所以这儿就暂时不说了,等我玩了解了会另开一篇文章解说。(我绝对不会承认其实是我代码在别的一台电脑上忘记 push 到 github 了,而我一时半会拿不到这台电脑)

逻辑代码

读取 Exif

由于咱们这次是给桌面端写的程序,所以之前运用的安卓官方的 Exif 库显然是用不了的,好在咱们有一大堆 java 库能够运用。

这儿我挑选的是 metadata-extractor 这个库。

首先在 build.gradle.kts 文件中增加依赖:

dependencies {
    commonMainImplementation("com.drewnoakes:metadata-extractor:2.18.0")
}

接下来是示例化 Metadata 目标:

val metadata = ImageMetadataReader.readMetadata(file)

这儿由于咱们传入的文件本来便是 File 类型,所以咱们直接运用 File 实例化。

除此之外咱们还能够运用输入流实例化:

val metadata = ImageMetadataReader.readMetadata(inputStream)

示例化完结后便是读取特定的 Exif 标签内容,这儿咱们直接读取 DATETIME_ORIGINAL 标签,不知道各个标签是什么意思的能够看我之前的文章,里面有详细解说:

val directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory::class.java)
val date = directory.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL)

这样咱们就能拿到一个 Date 目标,接下只要解析这个 Date 即可。

可是,你们觉得这样就完了吗?

非也非也,一开始我也以为这样就完了。

直到我实践运用时却发现,这样获取到的时间总是和实践时间相差八个小时。

不多不少刚刚好八个小时,有经验的读者或许现已认识到了,八个小时,那不便是时区不对嘛,由于中国的官方时区便是 GMT+08:00 。

其实这个问题也很好了解,正如我之前文章中所述,在旧版本的 Exif 规范中,并没有指定时区这一内容,也便是说, Exif 中保存的时间不包含时区信息,所以咱们需求自己从头解析时区。

可是这儿又呈现一个问题,咱们不能将时区写死,由于咱们不能假定咱们的用户就一定是某个时区的人,亦或者说,咱们怎样能确保咱们拍照就一定是在 GMT+08:00 拍呢?格局大一点。(狗头

所以,我这儿将时区的挑选权交给了用户自己,也便是咱们上面 UI 一节中所示的需求用户自己输入时区信息。

所以,最终完整的获取 Exif 的函数应该是:

fun getDateFromExif(
    file: File,
    timeZoneID: String
): Date? {
    return try {
        val timeZone = TimeZone.getTimeZone(timeZoneID)
        val metadata = ImageMetadataReader.readMetadata(file)
        val directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory::class.java)
        directory.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL, timeZone)
    } catch (tr: Throwable) {
        tr.printStackTrace()
        null
    }
}

给图片增加文字水印

关于给图片增加文字水印这个需求,咱们运用 JDK 中自带的 Graphics2D 来完结。

运用 Graphics2D 需求先从文件中读取文件流,然后将文件流转为 BufferedImage ,最后运用 BufferedImage 创立 Graphics2D 目标,文字增加结束后再将 BufferedImage 写入文件中即可。

简单完结代码如下:

// 读取原文件
val targetImg: BufferedImage = ImageIO.read(file)
// 创立 Graphics2D
val graphics: Graphics2D = targetImg.createGraphics()
// 往 Graphics2D 上绘制文字
graphics.drawString(text, x, y)
// 保存文件
saveImage(targetImg, outPath, outputQuality)
// 关闭
graphics.dispose()

其中保存 BufferedImage 的函数如下:

fun saveImage(image: BufferedImage?, saveFile: File?, quality: Float) {
    val outputStream = ImageIO.createImageOutputStream(saveFile)
    val jpgWriter: ImageWriter = ImageIO.getImageWritersByFormatName("jpg").next()
    val jpgWriteParam: ImageWriteParam = jpgWriter.defaultWriteParam
    jpgWriteParam.compressionMode = ImageWriteParam.MODE_EXPLICIT
    jpgWriteParam.compressionQuality = quality
    jpgWriter.output = outputStream
    val outputImage = IIOImage(image, null, null)
    jpgWriter.write(null, outputImage, jpgWriteParam)
    jpgWriter.dispose()
    outputStream.flush()
    outputStream.close()
}

saveImage 这个函数接纳一个名为 quality 用于指定保存文件时的质量。

详细完结是经过设置参数 jpgWriteParam.compressionQuality = quality

保存的时分记得要界说这个参数,不然默认值设置的压缩率比较大,一开始我没有设置这个值,导致我十几Mb的图片增加文字后只剩余了几百Kb,画质也肉眼可见的变差,都给我整不会了,这样显然是不契合我的要求的啊。

下面再看看给图片增加水印的详细完结代码:graphics.drawString(text, x, y)

榜首个参数很好了解,便是要增加的文字字符串,第二和第三个参数分别表明放置文字的方位坐标。

这儿的坐标表明的是榜首个字符的基线坐标。

那么问题来了,坐标怎样拿呢?

还记得咱们的UI界面吗?咱们的软件是能够界说水印方位的,能够挑选图片的四个角。

也便是说,咱们需求独自处理一下坐标的计算:

// 水印坐标方位
val width: Int = targetImg.width //图片宽
val height: Int = targetImg.height //图片高
val textWidth = graphics.fontMetrics.stringWidth(text)
val textHeight = graphics.fontMetrics.height
val point = textPos.getPoint(width, height, textWidth, textHeight)
val x = point.x
val y = point.y
// ……
private fun TextPos.getPoint(
    width: Int,
    height: Int,
    textWidth: Int,
    textHeight: Int,
    padding: Int = 10
): Point {
    return when (this) {
        TextPos.LEFT_TOP -> {
            Point(padding, textHeight)
        }
        TextPos.LEFT_BOTTOM -> {
            Point(
                padding,
                (height - padding).coerceAtLeast(0)
            )
        }
        TextPos.RIGHT_TOP -> {
            Point(
                (width - textWidth - padding).coerceAtLeast(0),
                textHeight
            )
        }
        TextPos.RIGHT_BOTTOM -> {
            Point(
                (width - textWidth - padding).coerceAtLeast(0),
                (height - padding).coerceAtLeast(0)
            )
        }
    }
}

上面的 x、y 即计算出来的坐标。

其中,TextPos 是我界说的一个枚举类:

enum class TextPos {
    LEFT_TOP,
    LEFT_BOTTOM,
    RIGHT_TOP,
    RIGHT_BOTTOM
}

在上面的获取坐标的函数 getPoint 中,咱们经过文字的高度 textHeight = graphics.fontMetrics.height ;一切文字的宽度 textWidth = graphics.fontMetrics.stringWidth(text) ,按照用户挑选的文字方位计算出文字应该位于的坐标点。

例如,假如挑选水印在左上角,则 x 坐标为 0(实践还增加了 padding),y 坐标为 文字高度

假如为右下角,则 x 坐标为 图片宽度 - 文字总宽度,y 坐标为 图片高度

现在,增加文字的代码现已全部完结,可是咱们还需求加亿点小细节,例如设置文字大小,设置文字色彩等:

graphics.color = textColor //水印色彩
graphics.font = Font(null, Font.PLAIN, fontSize) // 文字款式,榜首个参数是字体,这儿直接运用 Null(由于支撑多种桌面端,指定字体的话或许反而会找不到)

挑选文件

直接调用文件挑选

这儿咱们运用的是 java swing 中的文件挑选器: JFileChooser 来完结文件挑选功能:

fun showFileSelector(
    suffixList: Array<String> = arrayOf("jpg", "jpeg"), // 过滤的文件扩展名
    isMultiSelection: Boolean = true,  // 是否答应多选
    selectionMode: Int = JFileChooser.FILES_AND_DIRECTORIES, // 能够挑选目录和文件
    selectionFileFilter: FileNameExtensionFilter? = FileNameExtensionFilter("图片(.jpg .jpeg)", *suffixList), // 文件过滤
    onFileSelected: (Array<File>) -> Unit, // 挑选回调
    ) {
    JFileChooser().apply {
        // 这儿是设置挑选器的 UI
        try {
            val lookAndFeel = UIManager.getSystemLookAndFeelClassName()
            UIManager.setLookAndFeel(lookAndFeel)
            SwingUtilities.updateComponentTreeUI(this)
        } catch (e: Throwable) {
            e.printStackTrace()
        }
        fileSelectionMode = selectionMode
        isMultiSelectionEnabled = isMultiSelection
        fileFilter = selectionFileFilter
        // 显现挑选器
        val result = showOpenDialog(ComposeWindow())
        // 挑选后回来
        if (result == JFileChooser.APPROVE_OPTION) {
            if (isMultiSelection) {
                // this.selectedFiles 表明选中的多个文件 array,只有 isMultiSelectionEnabled 为 true 这个变量才有值,不然为 NUll
                onFileSelected(this.selectedFiles)
            }
            else {
                // 假如不开启多选,则回来的是单个文件 this.selectedFile ,可是咱们回调接纳的是 Array,所以需求手动创立
                val resultArray = arrayOf(this.selectedFile)
                onFileSelected(resultArray)
            }
        }
    }
}

代码很简单,这儿就不再过多解说了,需求留意的点现已在注释中阐明。

拖拽挑选

拖拽挑选需求调用到 awt 的原生代码。

咱们需求给主进口的 window 增加一个 dropTarget 用于接纳拖拽事情:

window.contentPane.dropTarget = dropFileTarget { fileList ->
    println(fileList)
}

其中,dropFileTarget 函数如下:

fun dropFileTarget(
    onFileDrop: (List<String>) -> Unit
): DropTarget {
    return object : DropTarget() {
        override fun drop(event: DropTargetDropEvent) {
            event.acceptDrop(DnDConstants.ACTION_REFERENCE)
            val dataFlavors = event.transferable.transferDataFlavors
            dataFlavors.forEach {
                if (it == DataFlavor.javaFileListFlavor) {
                    val list = event.transferable.getTransferData(it) as List<*>
                    val pathList = mutableListOf<String>()
                    list.forEach { filePath ->
                        pathList.add(filePath.toString())
                    }
                    onFileDrop(pathList)
                }
            }
            event.dropComplete(true)
        }
    }
}

需求留意的是,由于咱们这个拖拽事情是增加到主进口的 window 的,而不是独自的图画预览 Card 这意味着接纳拖拽事情的是整个程序窗口而不是独自的这个图画预览界面。

过滤文件

完结上面两种的挑选文件代码后,咱们的处理逻辑还没有完哦,别忘了,咱们说过,这个文件挑选支撑多选文件,乃至是文件夹。

这意味着咱们需求对传入的挑选文件(夹)做遍历以及过滤处理:

fun filterFileList(fileList: List<String>): List<File> {
    val newFile = mutableListOf<File>()
    fileList.map {path ->
        newFile.add(File(path))
    }
    return filterFileList(newFile.toTypedArray())
}
fun filterFileList(fileList: Array<File>): List<File> {
    val newFileList = mutableListOf<File>()
    for (file in fileList) {
        if (file.isDirectory) {
            newFileList.addAll(getAllFile(file))
        }
        else {
            if (file.extension.lowercase() in legalSuffixList) {
                newFileList.add(file)
            }
        }
    }
    return newFileList
}
private fun getAllFile(file: File): List<File> {
    val newFileList = mutableListOf<File>()
    val fileTree = file.walk()
    fileTree.maxDepth(Int.MAX_VALUE)
        .filter { it.isFile }
        .filter { it.extension.lowercase() in legalSuffixList }
        .forEach {
            newFileList.add(it)
        }
    return newFileList
}

然后在挑选文件的回调处调用即可。

上面的代码做的作业便是遍历接纳到的文件列表,假如是文件则判断扩展名是否契合需求,契合则增加至文件列表。

假如是文件夹则运用 FileTreeWalk 遍历这个文件夹,然后找出契合条件的文件增加至文件列表,这儿咱们的遍历深度是最大(Int.MAX_VALUE)也便是说会遍历该文件的一切子文件,以及子文件夹,包含一切深度的子文件夹的一切子文件。

总结

Compose-jb 让本来的移动端开发者也能很便利的进行桌面端开发,可是究竟 Compose 仅仅一个 UI 工具包,关于实践的事务逻辑代码,还是需求调用原生 API 来完结。

好在 Kotlin 是 jvm 语言,并且 Compose-jb 的完结也是基于 java 的 Swing ,也便是说关于安卓开发者来说,即便许多逻辑需求调用的也仅仅 Swing API ,关于安卓开发来说,根本没有什么门槛,看一下文档根本就能上手写了。

参考资料

  1. 运用ComposeDesktop开发一款桌面端多功能APK工具
  2. From Swing to Jetpack Compose Desktop #2
  3. Java中图片增加水印(文字+图片水印)
  4. Image and in-app icons manipulations

本文正在参与「金石计划 . 分割6万现金大奖」