packages feed

document-build-0.1.0.0: src/Document/Build/Image.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wall #-}

{- | Commands that operate on images, with ImageMagick (@identify@ and
@convert@).
-}
module Document.Build.Image (
  -- * Information
  ImageInfo (..),
  imageFormat,
  imageWidth,
  imageHeight,
  imageResolutionX,
  imageResolutionY,
  imageResolutionUnits,
  imageColourspace,
  imageDepth,
  imageFrames,
  imageInfo,
  parseImageInfo,
  GetImageInfo (..),
  HasImageInfo (..),
  ReviewImageInfo (..),
  AsImageInfo (..),

  -- * Conversion
  imageConvert,
  imageConvertWith,
  imageConvertFirstFrame,
  imageResize,
  imageAppend,
) where

import Control.DeepSeq (NFData)
import Control.Lens (Field1, Field2, Field3, Field4, Field5, Field6, Field7, Field8, Field9, Getter, Lens', Prism', Review, lens, prism', review, unto, view, _1, _2, _3, _4, _5, _6, _7, _8, _9, _Wrapped)
import Control.Monad.Trans.Class (lift)
import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.=))
import Data.Data (Data)
import Document.Build.Build (Command, createParentDirectory, expectOutput, failBuild, process, requireDirectory, requireInput)
import Document.Build.Error (BuildError (..), CommandLine (..), ReviewBuildError)
import GHC.Generics (Generic)
import System.FilePath ((</>))
import Text.Read (readMaybe)

{- $setup
>>> import Control.Lens ((^.))
-}

{- | Information about an image: its format (such as @"PNG"@), width and
height in pixels, horizontal and vertical resolution, the units of the
resolution (such as @"PixelsPerInch"@ or @"Undefined"@), colourspace (such as
@"sRGB"@), bit depth, and number of frames.

@since 0.1.0.0
-}
data ImageInfo
  = ImageInfo String Int Int Double Double String String Int Int
  deriving stock (Eq, Ord, Show, Generic, Data)
  deriving anyclass (NFData)

-- | @since 0.1.0.0
instance ToJSON ImageInfo where
  toJSON (ImageInfo m w h x y u c z n) =
    object
      [ "format" .= m
      , "width" .= w
      , "height" .= h
      , "resolutionX" .= x
      , "resolutionY" .= y
      , "resolutionUnits" .= u
      , "colourspace" .= c
      , "depth" .= z
      , "frames" .= n
      ]

-- | @since 0.1.0.0
instance FromJSON ImageInfo where
  parseJSON =
    withObject "ImageInfo" $ \o ->
      ImageInfo
        <$> o .: "format"
        <*> o .: "width"
        <*> o .: "height"
        <*> o .: "resolutionX"
        <*> o .: "resolutionY"
        <*> o .: "resolutionUnits"
        <*> o .: "colourspace"
        <*> o .: "depth"
        <*> o .: "frames"

-- | @since 0.1.0.0
instance Field1 ImageInfo ImageInfo String String

-- | @since 0.1.0.0
instance Field2 ImageInfo ImageInfo Int Int

-- | @since 0.1.0.0
instance Field3 ImageInfo ImageInfo Int Int

-- | @since 0.1.0.0
instance Field4 ImageInfo ImageInfo Double Double

-- | @since 0.1.0.0
instance Field5 ImageInfo ImageInfo Double Double

-- | @since 0.1.0.0
instance Field6 ImageInfo ImageInfo String String

-- | @since 0.1.0.0
instance Field7 ImageInfo ImageInfo String String

-- | @since 0.1.0.0
instance Field8 ImageInfo ImageInfo Int Int

-- | @since 0.1.0.0
instance Field9 ImageInfo ImageInfo Int Int

-- | @since 0.1.0.0
imageFormat :: Lens' ImageInfo String
imageFormat =
  _1
{-# INLINE imageFormat #-}

-- | @since 0.1.0.0
imageWidth :: Lens' ImageInfo Int
imageWidth =
  _2
{-# INLINE imageWidth #-}

-- | @since 0.1.0.0
imageHeight :: Lens' ImageInfo Int
imageHeight =
  _3
{-# INLINE imageHeight #-}

-- | @since 0.1.0.0
imageResolutionX :: Lens' ImageInfo Double
imageResolutionX =
  _4
{-# INLINE imageResolutionX #-}

-- | @since 0.1.0.0
imageResolutionY :: Lens' ImageInfo Double
imageResolutionY =
  _5
{-# INLINE imageResolutionY #-}

-- | @since 0.1.0.0
imageResolutionUnits :: Lens' ImageInfo String
imageResolutionUnits =
  _6
{-# INLINE imageResolutionUnits #-}

-- | @since 0.1.0.0
imageColourspace :: Lens' ImageInfo String
imageColourspace =
  _7
{-# INLINE imageColourspace #-}

-- | @since 0.1.0.0
imageDepth :: Lens' ImageInfo Int
imageDepth =
  _8
{-# INLINE imageDepth #-}

-- | @since 0.1.0.0
imageFrames :: Lens' ImageInfo Int
imageFrames =
  _9
{-# INLINE imageFrames #-}

-- | The @identify@ format that 'parseImageInfo' reads.
identifyFormat :: String
identifyFormat =
  "%m\\n%w\\n%h\\n%x\\n%y\\n%U\\n%[colorspace]\\n%z\\n%n\\n"

{- | Information about the image being operated on, from its first frame,
using @identify@.

@since 0.1.0.0
-}
imageInfo :: (ReviewBuildError e) => Command e ImageInfo
imageInfo =
  do
    i <- requireInput
    let c = CommandLine "identify" ["-format", identifyFormat, i <> "[0]"]
    o <- lift (process c)
    maybe
      (lift (failBuild (ProcessOutputUnrecognised c "image format, width, height, resolution, units, colourspace, depth and frames")))
      pure
      (parseImageInfo o)

{- | Parse the output of @identify@ with the format
@%m\\n%w\\n%h\\n%x\\n%y\\n%U\\n%[colorspace]\\n%z\\n%n\\n@. A resolution may
be followed by its units, which are ignored.

>>> fmap (^. imageWidth) (parseImageInfo "PNG\n3384\n262\n72\n72\nUndefined\nsRGB\n8\n1\n")
Just 3384

>>> parseImageInfo "PNG\n3384\n262\n72 PixelsPerInch\n72 PixelsPerInch\nPixelsPerInch\nsRGB\n8\n1\n"
Just (ImageInfo "PNG" 3384 262 72.0 72.0 "PixelsPerInch" "sRGB" 8 1)

>>> parseImageInfo "PNG\n"
Nothing

@since 0.1.0.0
-}
parseImageInfo :: String -> Maybe ImageInfo
parseImageInfo s =
  let number :: (Read a) => String -> Maybe a
      number x =
        case words x of
          (n : _) -> readMaybe n
          [] -> Nothing
   in case lines s of
        (m : w : h : x : y : u : c : z : n : _) ->
          ImageInfo m <$> number w <*> number h <*> number x <*> number y <*> pure u <*> pure c <*> number z <*> number n
        _ -> Nothing

-- | @since 0.1.0.0
class GetImageInfo s where
  getImageInfo :: Getter s ImageInfo

-- | @since 0.1.0.0
instance GetImageInfo ImageInfo where
  getImageInfo =
    id
  {-# INLINE getImageInfo #-}

-- | @since 0.1.0.0
class (GetImageInfo s) => HasImageInfo s where
  {-# MINIMAL setImageInfo #-}
  setImageInfo :: ImageInfo -> s -> s

  imageInfoLens :: Lens' s ImageInfo
  imageInfoLens =
    lens (view getImageInfo) (flip setImageInfo)
  {-# INLINE imageInfoLens #-}

-- | @since 0.1.0.0
instance HasImageInfo ImageInfo where
  setImageInfo =
    const
  {-# INLINE setImageInfo #-}

-- | @since 0.1.0.0
class ReviewImageInfo t where
  reviewImageInfo :: Review t ImageInfo

-- | @since 0.1.0.0
instance ReviewImageInfo ImageInfo where
  reviewImageInfo =
    unto id
  {-# INLINE reviewImageInfo #-}

-- | @since 0.1.0.0
class (ReviewImageInfo t) => AsImageInfo t where
  {-# MINIMAL matchImageInfo #-}
  matchImageInfo :: t -> Maybe ImageInfo

  _ImageInfo :: Prism' t ImageInfo
  _ImageInfo =
    prism' (review reviewImageInfo) matchImageInfo
  {-# INLINE _ImageInfo #-}

-- | @since 0.1.0.0
instance AsImageInfo ImageInfo where
  matchImageInfo =
    Just
  {-# INLINE matchImageInfo #-}

{- | Convert the image being operated on to a file path, with the format given
by its extension, and return the file path.

@since 0.1.0.0
-}
imageConvert :: (ReviewBuildError e) => FilePath -> Command e FilePath
imageConvert =
  imageConvertWith []
{-# INLINE imageConvert #-}

{- | Convert the image being operated on with @convert@ and options that
follow the input file, writing to a file path, and return the file path.

@since 0.1.0.0
-}
imageConvertWith :: (ReviewBuildError e) => [String] -> FilePath -> Command e FilePath
imageConvertWith opts o =
  do
    i <- requireInput
    let c = CommandLine "convert" ([i] <> opts <> [o])
    lift (createParentDirectory o *> process c *> expectOutput c o)

{- | Convert the first frame of the image being operated on with @convert@
and options that follow the input file, writing to a file path, and return
the file path. Unlike 'imageConvertWith', an image with more than one frame,
such as an animated GIF, produces one file.

@since 0.1.0.0
-}
imageConvertFirstFrame :: (ReviewBuildError e) => [String] -> FilePath -> Command e FilePath
imageConvertFirstFrame opts o =
  do
    i <- requireInput
    let c = CommandLine "convert" ([i <> "[0]"] <> opts <> [o])
    lift (createParentDirectory o *> process c *> expectOutput c o)

{- | Resize the image being operated on to fit within a square of a number of
pixels, keeping its aspect ratio, writing to a file path, and return the file
path.

@since 0.1.0.0
-}
imageResize :: (ReviewBuildError e) => Int -> FilePath -> Command e FilePath
imageResize n =
  imageConvertWith ["-resize", show n <> "x" <> show n]
{-# INLINE imageResize #-}

{- | Join images in the directory being operated on, given by their paths
relative to it, side by side, from left to right, into a file path, and
return the file path.

@since 0.1.0.0
-}
imageAppend :: (ReviewBuildError e) => [FilePath] -> FilePath -> Command e FilePath
imageAppend fs o =
  do
    d <- requireDirectory
    let is = fmap (d </>) fs
        c = CommandLine "convert" (is <> ["+append", o])
    _ <- lift (traverse (view _Wrapped requireInput) is)
    lift (createParentDirectory o *> process c *> expectOutput c o)