Skip to content

Commit

Permalink
Feat: EXIF orientation flag support (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
SecretX33 authored Nov 18, 2023
1 parent 6b97135 commit fd6f834
Show file tree
Hide file tree
Showing 8 changed files with 202 additions and 21 deletions.
3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ plugins {
}

group = "com.github.secretx33"
version = "0.2.2"
version = "0.2.3"

val javaVersion = 17

Expand All @@ -29,6 +29,7 @@ dependencies {
implementation("org.fusesource.jansi:jansi:2.4.1")
implementation(platform("io.arrow-kt:arrow-stack:1.2.1"))
implementation("io.arrow-kt:arrow-fx-coroutines")
implementation("com.drewnoakes:metadata-extractor:2.18.0")
}

kapt {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.github.secretx33.imagetopdf.convert

const val ORIENTATION_HORIZONTAL = 1
const val ORIENTATION_MIRROR_HORIZONTAL = 2
const val ORIENTATION_ROTATE_180 = 3
const val ORIENTATION_MIRROR_VERTICAL = 4
const val ORIENTATION_MIRROR_HORIZONTAL_AND_ROTATE_270 = 5
const val ORIENTATION_ROTATE_90 = 6
const val ORIENTATION_MIRROR_HORIZONTAL_AND_ROTATE_90 = 7
const val ORIENTATION_ROTATE_270 = 8
116 changes: 100 additions & 16 deletions src/main/kotlin/com/github/secretx33/imagetopdf/convert/PdfBox.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.github.secretx33.imagetopdf.convert

import arrow.core.nonFatalOrThrow
import com.drew.imaging.ImageMetadataReader
import com.github.secretx33.imagetopdf.model.ImageMirroring
import com.github.secretx33.imagetopdf.model.ImageRotation
import com.github.secretx33.imagetopdf.model.PdfImage
import com.github.secretx33.imagetopdf.model.Settings
import com.github.secretx33.imagetopdf.util.ANSI_RESET
Expand All @@ -9,6 +12,8 @@ import com.github.secretx33.imagetopdf.util.absoluteParent
import com.github.secretx33.imagetopdf.util.bail
import com.github.secretx33.imagetopdf.util.byteArrayOutputStream
import com.github.secretx33.imagetopdf.util.formattedFileSize
import com.github.secretx33.imagetopdf.util.imageMirroring
import com.github.secretx33.imagetopdf.util.imageRotation
import org.apache.pdfbox.pdfwriter.compress.CompressParameters
import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.pdmodel.PDPage
Expand Down Expand Up @@ -36,6 +41,8 @@ import java.awt.RenderingHints.VALUE_INTERPOLATION_BICUBIC
import java.awt.RenderingHints.VALUE_RENDER_QUALITY
import java.awt.RenderingHints.VALUE_STROKE_PURE
import java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON
import java.awt.geom.AffineTransform
import java.awt.image.AffineTransformOp
import java.awt.image.BufferedImage
import java.awt.image.RenderedImage
import java.nio.file.Path
Expand Down Expand Up @@ -67,14 +74,15 @@ inline fun createPdf(file: Path, block: PDDocument.() -> Unit) = try {
}

fun PDDocument.createPdfImage(picture: Path, settings: Settings): PdfImage = pdfImage(picture)
.mirrorAndRotate()
.resize(settings.imageResizeFactor)
.compressToJpg(settings.jpgCompressionQuality)

fun PDDocument.addImage(
pdfImage: PdfImage,
settings: Settings,
) {
val scaled = ScalableDimension(pdfImage.width, pdfImage.height, settings.imageRenderFactor).scale()
val scaled = TransformableDimension(pdfImage.width, pdfImage.height, settings.imageRenderFactor).scale()
val page = PDPage(PDRectangle(scaled.getWidth().toFloat(), scaled.getHeight().toFloat()))
.also(::addPage)
val image = pdfImage.image.toPDImageXObject(this, pdfImage.fileName)
Expand All @@ -86,25 +94,100 @@ fun PDDocument.addImage(

private fun PDDocument.pdfImage(picture: Path): PdfImage {
val image = ImageIO.read(picture.toFile())
val metadata = runCatching { ImageMetadataReader.readMetadata(picture.toFile()) }

val imageMirror = metadata.mapCatching { it.imageMirroring }
.onFailure { println("${ANSI_YELLOW}Warn: could not read image mirroring of file '${picture.name}' at '${picture.absoluteParent}', assuming no mirroring metadata is set$ANSI_RESET") }
val imageRotate = metadata.mapCatching { it.imageRotation }
.onFailure { println("${ANSI_YELLOW}Warn: could not read image orientation of file '${picture.name}' at '${picture.absoluteParent}', assuming no rotation metadata is set$ANSI_RESET") }

return PdfImage(
image = image,
fileName = picture.fileName,
file = picture,
width = image.width,
height = image.height,
document = this,
mirroring = imageMirror.getOrElse { ImageMirroring.NONE },
rotation = imageRotate.getOrElse { ImageRotation.NONE },
)
}

private fun PdfImage.mirrorAndRotate(): PdfImage {
if (rotation == ImageRotation.NONE && mirroring == ImageMirroring.NONE) {
return this
}
val image = image.mirrorAndRotate(rotation, mirroring)
return copy(
image = image,
width = image.width,
height = image.height,
)
}

private fun BufferedImage.mirrorAndRotate(
rotation: ImageRotation,
mirroring: ImageMirroring,
): BufferedImage {
val dimension = TransformableDimension(width, height)
val rotatedDimension = dimension.rotate(rotation.degrees)
val affineTransforms = listOfNotNull(
mirrorAffineTransform(mirroring, dimension),
rotateAffineTransform(rotation, dimension),
).map {
AffineTransformOp(it, null)
}

val rotatedImage = BufferedImage(rotatedDimension.width, rotatedDimension.height, colorModel.hasAlpha()) {
val transformedImage = affineTransforms.fold(this@mirrorAndRotate) { image, transformOp ->
transformOp.filter(image, null)
}
drawImage(transformedImage, 0, 0, null)
}
return rotatedImage
}

private fun rotateAffineTransform(
rotation: ImageRotation,
dimension: TransformableDimension,
): AffineTransform? {
if (rotation == ImageRotation.NONE) {
return null
}
val rotatedDimension = dimension.rotate(rotation.degrees)
return AffineTransform().apply {
translate((rotatedDimension.getWidth() - dimension.getWidth()) / 2.0, (rotatedDimension.getHeight() - dimension.getHeight()) / 2.0)
rotate(Math.toRadians(rotation.degrees), dimension.getWidth() / 2.0, dimension.getHeight() / 2.0)
}
}

private fun mirrorAffineTransform(
mirroring: ImageMirroring,
dimension: Dimension,
): AffineTransform? {
if (mirroring == ImageMirroring.NONE) {
return null
}
return AffineTransform().apply {
if (mirroring == ImageMirroring.HORIZONTAL) {
scale(-1.0, 1.0)
translate(-dimension.getWidth(), 0.0)
} else {
scale(1.0, -1.0)
translate(0.0, -dimension.getHeight())
}
}
}

private fun PdfImage.resize(factor: Double): PdfImage {
if (factor == 1.0) {
return this
}
require(factor > 0) { "Invalid factor value (expected > 0, actual: $factor)" }

val dimensions = ScalableDimension(width, height, factor).toScaledDimensionTuple()
val dimensions = TransformableDimension(width, height, factor).toScaledDimensionTuple()
.let {
it.copy(modified = ScalableDimension(
it.copy(modified = TransformableDimension(
it.modified.getWidth().coerceAtLeast(1.0),
it.modified.getHeight().coerceAtLeast(1.0),
))
Expand All @@ -121,7 +204,7 @@ private fun BufferedImage.resize(dimensionTuple: DimensionTuple): BufferedImage
val widthScaled = dimensionTuple.modified.getWidth().toInt()
val heightScaled = dimensionTuple.modified.getHeight().toInt()

val resizedImage = BufferedImage(widthScaled, heightScaled, colorModel.hasAlpha()).graphics {
val resizedImage = BufferedImage(widthScaled, heightScaled, colorModel.hasAlpha()) {
setRenderingHints(RENDERING_HINTS)
transform = transform.apply {
setToScale(dimensionTuple.widthRatio, dimensionTuple.heightRatio)
Expand Down Expand Up @@ -151,7 +234,7 @@ private fun BufferedImage.convertToJpg(dimension: Dimension, fileName: Path): Bu
if (fileName.extension.lowercase() in setOf("jpg", "jpeg")) {
return this
}
val outputImage = BufferedImage(dimension.width, dimension.height, false).graphics {
val outputImage = BufferedImage(dimension.width, dimension.height, false) {
drawImage(this@convertToJpg, 0, 0, Color.WHITE, null)
}
return outputImage
Expand All @@ -172,7 +255,7 @@ private fun BufferedImage.setJpgQuality(jpgCompressFactor: Double): BufferedImag
jpgWriter.write(null, outputImage, jpgWriteParam)
}
}
return ImageIO.read(imageBytes.inputStream())
return imageBytes.inputStream().use(ImageIO::read)
} finally {
jpgWriter.dispose()
}
Expand All @@ -183,24 +266,25 @@ private fun BufferedImage.toPDImageXObject(
fileName: Path,
): PDImageXObject = PDImageXObject.createFromByteArray(document, toByteArray(fileName.extension), fileName.name)

private fun <T> BufferedImage.graphics(block: Graphics2D.() -> T): BufferedImage = apply {
val graphics = createGraphics()
try {
graphics.block()
} finally {
graphics.dispose()
}
}

private fun BufferedImage(
width: Int,
height: Int,
hasAlpha: Boolean,
withGraphics: (Graphics2D.() -> Unit)? = null,
): BufferedImage = BufferedImage(
width,
height,
if (hasAlpha) BufferedImage.TYPE_INT_ARGB else BufferedImage.TYPE_INT_RGB,
)
).run { withGraphics?.let(::graphics) ?: this }

private fun <T> BufferedImage.graphics(block: Graphics2D.() -> T): BufferedImage = apply {
val graphics = createGraphics()
try {
graphics.block()
} finally {
graphics.dispose()
}
}

private fun RenderedImage.toByteArray(fileExtension: String): ByteArray = byteArrayOutputStream {
ImageIO.write(this, fileExtension, it)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.github.secretx33.imagetopdf.convert

import java.awt.Dimension
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin

/**
* Associates a new [Dimension] tuple.
Expand Down Expand Up @@ -35,7 +38,7 @@ data class DimensionTuple(val original: Dimension, val modified: Dimension) {
* @param w The dimension's width
* @param h The dimension's height
*/
class ScalableDimension(
class TransformableDimension(
w: Int,
h: Int,
private val scale: Double = 1.0,
Expand All @@ -51,7 +54,7 @@ class ScalableDimension(
constructor(w: Double, h: Double, scale: Double = 1.0) : this(w = w.toInt(), h = h.toInt(), scale = scale)

fun toScaledDimensionTuple(): DimensionTuple {
val dimensions = ScalableDimension(width, height, scale)
val dimensions = TransformableDimension(width, height, scale)
return DimensionTuple(dimensions, dimensions.scale())
}

Expand All @@ -68,7 +71,10 @@ class ScalableDimension(
val srcHeight = getHeight()

// Scale both dimensions with respect to the best fit ratio
return ScalableDimension((srcWidth * scale).toInt(), (srcHeight * scale).toInt())
return TransformableDimension(
w = (srcWidth * scale).toInt(),
h = (srcHeight * scale).toInt(),
)
}

/**
Expand All @@ -88,7 +94,24 @@ class ScalableDimension(
val ratio = min(desiredDimensions.getWidth() / srcWidth, desiredDimensions.getHeight() / srcHeight)

// Scale both dimensions with respect to the best fit ratio
return ScalableDimension((srcWidth * ratio * scale).toInt(), (srcHeight * ratio * scale).toInt())
return TransformableDimension(
w = (srcWidth * ratio * scale).toInt(),
h = (srcHeight * ratio * scale).toInt()
)
}

fun rotate(degrees: Double): Dimension {
val radian = Math.toRadians(degrees)
val sine = abs(sin(radian))
val cosine = abs(cos(radian))
val rotatedWidth = (getWidth() * cosine + getHeight() * sine).toInt()
val rotatedHeight = (getHeight() * cosine + getWidth() * sine).toInt()

return TransformableDimension(
w = rotatedWidth,
h = rotatedHeight,
scale = scale,
)
}

override fun toString(): String = "(${getWidth()}, ${getHeight()})"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.github.secretx33.imagetopdf.model

import com.github.secretx33.imagetopdf.convert.ORIENTATION_MIRROR_HORIZONTAL
import com.github.secretx33.imagetopdf.convert.ORIENTATION_MIRROR_HORIZONTAL_AND_ROTATE_270
import com.github.secretx33.imagetopdf.convert.ORIENTATION_MIRROR_HORIZONTAL_AND_ROTATE_90
import com.github.secretx33.imagetopdf.convert.ORIENTATION_MIRROR_VERTICAL

enum class ImageMirroring {
NONE,
HORIZONTAL,
VERTICAL;

companion object {
fun from(mirroring: Int): ImageMirroring = when (mirroring) {
ORIENTATION_MIRROR_VERTICAL -> VERTICAL
ORIENTATION_MIRROR_HORIZONTAL,
ORIENTATION_MIRROR_HORIZONTAL_AND_ROTATE_90,
ORIENTATION_MIRROR_HORIZONTAL_AND_ROTATE_270 -> HORIZONTAL
else -> NONE
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.github.secretx33.imagetopdf.model

import com.github.secretx33.imagetopdf.convert.ORIENTATION_MIRROR_HORIZONTAL_AND_ROTATE_270
import com.github.secretx33.imagetopdf.convert.ORIENTATION_MIRROR_HORIZONTAL_AND_ROTATE_90
import com.github.secretx33.imagetopdf.convert.ORIENTATION_ROTATE_180
import com.github.secretx33.imagetopdf.convert.ORIENTATION_ROTATE_270
import com.github.secretx33.imagetopdf.convert.ORIENTATION_ROTATE_90

enum class ImageRotation(val degrees: Double) {
NONE(0.0),
ROTATE_90(90.0),
ROTATE_180(180.0),
ROTATE_270(270.0);

companion object {
fun from(orientation: Int): ImageRotation = when (orientation) {
ORIENTATION_ROTATE_90, ORIENTATION_MIRROR_HORIZONTAL_AND_ROTATE_90 -> ROTATE_90
ORIENTATION_ROTATE_180 -> ROTATE_180
ORIENTATION_ROTATE_270, ORIENTATION_MIRROR_HORIZONTAL_AND_ROTATE_270 -> ROTATE_270
else -> NONE
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ data class PdfImage(
val width: Int,
val height: Int,
val document: PDDocument,
val mirroring: ImageMirroring,
val rotation: ImageRotation,
) {
val dimension = Dimension(width, height)
}
16 changes: 16 additions & 0 deletions src/main/kotlin/com/github/secretx33/imagetopdf/util/Util.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.github.secretx33.imagetopdf.util

import com.drew.metadata.Metadata
import com.drew.metadata.exif.ExifIFD0Directory
import com.github.secretx33.imagetopdf.exception.QuitApplicationException
import com.github.secretx33.imagetopdf.model.ImageMirroring
import com.github.secretx33.imagetopdf.model.ImageRotation
import org.jnativehook.GlobalScreen
import java.io.ByteArrayOutputStream
import java.lang.invoke.MethodHandles
Expand Down Expand Up @@ -79,3 +83,15 @@ fun byteArrayOutputStream(block: (ByteArrayOutputStream) -> Unit): ByteArray = B
block(it)
it.toByteArray()
}

val Metadata.imageRotation: ImageRotation get() {
val exif = getFirstDirectoryOfType(ExifIFD0Directory::class.java) ?: return ImageRotation.NONE
val orientation = exif.getInt(ExifIFD0Directory.TAG_ORIENTATION)
return ImageRotation.from(orientation)
}

val Metadata.imageMirroring: ImageMirroring get() {
val exif = getFirstDirectoryOfType(ExifIFD0Directory::class.java) ?: return ImageMirroring.NONE
val orientation = exif.getInt(ExifIFD0Directory.TAG_ORIENTATION)
return ImageMirroring.from(orientation)
}

0 comments on commit fd6f834

Please sign in to comment.