All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.matthicks.media4s.image.ImageUtil.scala Maven / Gradle / Ivy

The newest version!
package org.matthicks.media4s.image

import java.io._
import java.util.Base64

import org.im4java.core.{CompositeCmd, ConvertCmd, IMOperation, IMOps, Info}
import org.matthicks.media4s.Size
import io.youi.stream._
import profig.JsonUtil

import scala.sys.process._
import scala.util.Try

object ImageUtil {
  var iccProfiles = "/opt/icc"

  def info(file: File): ImageInfo = Try(convertInfo(file)).toOption.getOrElse(im4Info(file))

  private def im4Info(file: File): ImageInfo = {
    val filename = file.getAbsolutePath
    val info = new Info(filename)
    val extension = filename.substring(filename.lastIndexOf('.') + 1)
    val imageType = ImageType.fromExtension(extension)

    ImageInfo(
      width = info.getImageWidth(0),
      height = info.getImageHeight(0),
      depth = info.getImageDepth(0),
      format = info.getImageFormat(0),
      imageType = imageType,
      colorSpace = Option(info.getProperty("Colorspace"))
    )
  }

  private def convertInfo(file: File): ImageInfo = {
    val command = Seq(
      "convert",
      file.getCanonicalPath,
      "json:"
    )
    val b = new StringBuilder
    val log: String => Unit = (line: String) => {
      b.append(line)
      b.append('\n')
      ()
    }
    val result = command ! ProcessLogger(log, println)
    if (result != 0) {
      throw new RuntimeException(s"Bad result while trying to execute convert: $result. Command: ${command.mkString(" ")}. Verify ImageMagick is installed.")
    }
    val image = JsonUtil
      .fromJsonString[List[ConvertResult]](b.toString())
      .head
      .image
    ImageInfo(
      width = image.geometry.width,
      height = image.geometry.height,
      depth = image.depth,
      format = image.format,
      imageType = ImageType.fromExtension(image.format),
      colorSpace = image.colorspace
    )
  }

  def scaleUp(imageInfo: ImageInfo, minWidth: Int, minHeight: Int): ImageInfo =
    if (imageInfo.width < minWidth || imageInfo.height <= minHeight) {
      val widthScaled = imageInfo.copy(width = minWidth, height =
        math.round(minWidth * imageInfo.aspectRatio).toInt)

      val heightScaled = imageInfo.copy(
        width = math.round(minHeight / imageInfo.aspectRatio).toInt,
        height = minHeight
      )

      if (widthScaled.pixels > heightScaled.pixels) widthScaled
      else heightScaled
    } else {
      imageInfo
    }

  /**
   * Resizes the supplied file with adaptive resizing based on the supplied
   * width and/or height values.
   *
   * @param input the original file to resize
   * @param output generated image
   * @param width the width value option
   * @param height the height value option
   */
  def generateResized(input: File,
                      output: File,
                      width: Option[Int] = None,
                      height: Option[Int] = None,
                      strip: Boolean = true,
                      gaussianBlur: Double = 0.0,
                      quality: Double = 0.0,
                      isCMYK: Boolean = false): Unit = {
    val original = input.getAbsolutePath
    val altered = output.getAbsolutePath
    val op = new IMOperation
    op.autoOrient()

    op.background("none")
    if (altered.endsWith(".jpg")) {
      // Remove transparent background
      op.flatten()
    }

    if (strip) op.strip()
    if (gaussianBlur != 0.0) op.gaussianBlur(gaussianBlur)
    if (quality != 0) op.quality(quality)

    op.density(288)
    op.addImage(s"$original[0]")

    // Only resize to shrink image to max width/height where defined.
    op.adaptiveResize(
      width.map(Int.box).orNull,
      height.map(Int.box).orNull,
      '>')

    if (isCMYK) applyCMYKConversion(op)

    op.addImage(altered)

    val cmd = new ConvertCmd
    cmd.run(op)
  }

  def applyCMYKConversion(op: IMOperation): IMOps = {
    if (!new File(s"$iccProfiles/CMYK/USWebCoatedSWOP.icc").exists()) {
      throw new RuntimeException(s"ICC Profiles not installed properly in $iccProfiles.")
    }
    op.profile(s"$iccProfiles/CMYK/USWebCoatedSWOP.icc")
    op.profile(s"$iccProfiles/RGB/AdobeRGB1998.icc")
  }

  /**
   * Generates a thumbnail of the specified image file at the supplied width and
   * height. The aspect ratio will be maintained and the outer extents will be
   * transparent in the generated PNG.
   *
   * @param input  Original image to generate a thumbnail from
   * @param output Generated image
   * @param width  Thumbnail width
   * @param height Thumbnail height
   */
  def generateThumbnail(input: File,
                        output: File,
                        width: Int,
                        height: Int,
                        isCMYK: Boolean = false): Unit = {
    val original = input.getAbsolutePath
    val altered = output.getAbsolutePath
    val op = new IMOperation
    op.autoOrient()

    op.density(288)
    op.addImage(s"$original[0]")
    op.thumbnail(width, height)
    op.background("transparent")
    op.flatten()
    op.gravity("center")
    op.extent(width, height)
    if (isCMYK) applyCMYKConversion(op)
    op.addImage(altered)

    val cmd = new ConvertCmd
    cmd.run(op)
  }

  def pngToJpg(input: File, output: File): Unit = {
    val op = new IMOperation
    op.autoOrient()
    op.flatten()
    op.addImage(input.getAbsolutePath)
    op.addImage(output.getAbsolutePath)

    val cmd = new ConvertCmd
    cmd.run(op)
  }

  def addWatermark(input: File,
                   output: File,
                   overlayPath: String,
                   gravity: String = "center"): Unit = {
    val original = input.getAbsolutePath
    val outputPath = output.getAbsolutePath
    val altered =
      if (outputPath.endsWith(".jpg")) outputPath + ".png"
      else outputPath

    val op = new IMOperation

    op.compose("over")
    op.gravity(gravity)
    op.addImage(overlayPath)
    op.addImage(original)
    op.addImage(altered)

    val cmd = new CompositeCmd
    cmd.run(op)

    if (outputPath.endsWith(".jpg")) {
      val temp = new File(altered)
      pngToJpg(temp, new File(outputPath))
      if (!temp.delete()) {
        throw new RuntimeException(s"Unable to delete the temporary PNG: ${temp.getAbsolutePath}")
      }
    }
  }

  /** Given a gaussian blur and image quality parameter, determines if a JPEG or
    * PNG output is needed.
    */
  def destinationType(gaussianBlur: Double, quality: Double): ImageType =
    if (gaussianBlur == 0.0d && quality == 0.0d) ImageType.PNG
    else ImageType.JPEG

  def generateGIFCropped(input: File,
                         output: File,
                         width: Int,
                         height: Int): Unit = {
    val info = this.info(input)
    var transcoder = GIFSicleTranscoder(input, output)
      .resize(Some(width), Some(height))
      .optimize(3)
    val destination = Size(width, height)
    if (info.aspectRatio != destination.aspectRatio) {
      val cropped = info.cropToAspectRatio(destination)
      val x1 = math.round((info.width - cropped.width) / 2.0).toInt
      val y1 = math.round((info.height - cropped.height) / 2.0).toInt
      val x2 = x1 + cropped.width
      val y2 = y1 + cropped.height
      transcoder = transcoder.crop(x1, y1, x2, y2)
    }
    transcoder.execute()
  }

  /**
   * Generates an image completely filling the provided dimensions.
   *
   * A Vector image is not re-sized this way, but this method could be
   * called to produce a rasterized version of a vector graphic for use
   * as a library search results image.
   */
  def generateCropped(input: File,
                      output: File,
                      outputType: ImageType,
                      width: Int,
                      height: Int,
                      strip: Boolean = true,
                      gaussianBlur: Double = 0.0d,
                      quality: Double = 0.0d,
                      flatten: Boolean = true,
                      isCMYK: Boolean = false): Unit = {
    val original = input.getAbsolutePath
    val altered = output.getAbsolutePath
    val op = new IMOperation
    op.autoOrient()

    if (outputType == ImageType.GIF || outputType == ImageType.PNG) {
      op.background("none")
    }

    if (outputType == ImageType.JPEG) {
      if (strip) op.strip()
      if (gaussianBlur != 0.0) op.gaussianBlur(gaussianBlur)
      if (quality != 0) op.quality(quality)
    }

    op.density(288)
    op.addImage(s"$original[0]")
    if (flatten) op.flatten()
    op.resize(width, height, '^')
    op.gravity("center")
    op.crop(width, height, 0, 0)
    op.p_repage()
    if (isCMYK) applyCMYKConversion(op)
    op.addImage(altered)

    val cmd = new ConvertCmd
    cmd.run(op)
  }

  /**
    * Takes a base64 encoded String and outputs it to the file specified as a proper binary representation.
    *
    * @param base64 the base64 encoded image
    * @param file the binary file to output to
    */
  def saveBase64(base64: String, file: File): Unit = {
    val index = base64.indexOf("base64,")
    val encoded = if (index != -1) {
      base64.substring(index + 7)
    } else {
      base64
    }
    val decoded = Base64.getDecoder.decode(encoded)
    IO.stream(new ByteArrayInputStream(decoded), file)
    ()
  }

  // TODO: create dominantColors method: convert imagecontent.jpg +dither -colors 5 -define histogram:unique-colors=true -format "%c" histogram:info:

  case class ConvertResult(image: ConvertImage)
  case class ConvertImage(name: Option[String],
                          baseName: String,
                          format: String,
                          formatDescription: String,
                          mimeType: Option[String],
                          geometry: ConvertImageGeometry,
                          colorspace: Option[String],
                          depth: Int,
                          baseDepth: Int,
                          pixels: Long)
  case class ConvertImageGeometry(width: Int, height: Int, x: Int, y: Int)
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy