packages feed

hakyll-images-1.2.1: library/Hakyll/Images/CompressJpg.hs

{-# LANGUAGE GeneralisedNewtypeDeriving #-}
{-# LANGUAGE RecordWildCards            #-}
{-# LANGUAGE ScopedTypeVariables        #-}
-- |
-- Module      : Hakyll.Images.CompressJpg
-- Description : Hakyll compiler to compress Jpeg images
-- Copyright   : (c) Laurent P René de Cotret, 2019 - present
-- License     : BSD3
-- Maintainer  : laurent.decotret@outlook.com
-- Stability   : unstable
-- Portability : portable
--
-- This module defines a Hakyll compiler, 'compressJpgCompiler', which can be used to
-- re-encode Jpeg images at a lower quality during website compilation. Original images are
-- left unchanged, but compressed images can be up to 10x smaller.
--
-- The @compressJpgCompiler@ is expected to be used like this:
--
-- @
--     import Hakyll
--     import Hakyll.Images        ( loadImage
--                                 , compressJpgCompiler
--                                 )
--
--     hakyll $ do
--
--         -- Compress all source Jpegs to a Jpeg quality of 50
--         match "images/**.jpg" $ do
--             route idRoute
--             compile $ loadImage
--                 >>= compressJpgCompiler 50
--
--         (... omitted ...)
-- @
module Hakyll.Images.CompressJpg
  ( JpgQuality,
    compressJpgCompiler,
    compressJpg,
  )
where

import Codec.Picture.Types (DynamicImage(..), dropTransparency, pixelMap)
import qualified Codec.Picture.Types as Picture
import Codec.Picture.Metadata (Metadatas, SourceFormat(SourceJpeg), basicMetadata) 
import qualified Codec.Picture.Metadata as Meta
import Codec.Picture.Metadata.Exif (ExifTag(TagOrientation))
import Codec.Picture.Jpg (JpgEncodable, decodeJpegWithMetadata, encodeDirectJpegAtQualityWithMetadata)
import Data.ByteString.Lazy (toStrict)
import Data.ByteString (ByteString) 
import Hakyll.Core.Compiler (Compiler)
import Hakyll.Core.Item (Item (..))
import Hakyll.Images.Common
  ( Image (..),
    ImageFormat (..),
    format,
    image,
  )
import Numeric.Natural (Natural)


-- | Jpeg encoding quality, from 0 (lower quality) to 100 (best quality).
-- @since 1.2.0
newtype JpgQuality = JpgQuality Natural
  deriving (Num, Eq, Enum, Ord, Real, Integral)


-- | @JpgQuality@ smart constructor. Ensures that @JpgQuality@ is always
-- in the interval [0, 100]. Numbers outside this range will result in either
-- a quality of 0 or 100.
--
-- @since 1.2.0
mkJpgQuality :: Integral a => a -> JpgQuality
mkJpgQuality q | q < 0     = JpgQuality 0
               | q > 100   = JpgQuality 100
               | otherwise = JpgQuality (fromIntegral q)


-- | Compress a JPG bytestring to a certain quality setting.
-- The quality should be between 0 (lowest quality) and 100 (best quality).
-- An error is raised if the image cannot be decoded.
--
-- In the rare case where the JPEG data contains transparency information, it will be dropped.
compressJpg :: Integral a => a -> Image -> Image
compressJpg quality' src =
  if format src /= Jpeg
    then error "Image is not a JPEG."
    -- It is important to preserve some metadata, such as orientation (issue #11).
    else case decodeJpegWithMetadata $ image src of
      Left msg -> error $ "Loading the image failed for the following reason: " <> msg
      Right (dynImage, meta) -> 
         Image Jpeg $ case dynImage of 
          (ImageY8 img)     -> (encodeJpeg quality meta img)
          (ImageCMYK8 img)  -> (encodeJpeg quality meta img)
          (ImageRGB8 img)   -> (encodeJpeg quality meta img)
          (ImageYCbCr8 img) -> (encodeJpeg quality meta img)
          -- Out of the 5 possible image types that can be returned by `decodeJpegWithMetadata`, only 1
          -- has transparency. This is also the only image type which cannot be re-encoded directly;
          -- we need to remove transparency.
          (ImageYA8 img)    -> (encodeJpeg quality meta (pixelMap dropTransparency img))
          _ -> error "Loading the image failed because the color space is unknown." 
  where 
    quality = mkJpgQuality quality'


-- | Encode a JPEG image at a particular quality, preserving some metadata.
encodeJpeg :: (Integral q, JpgEncodable px) 
           => q 
           -> Metadatas 
           -> Picture.Image px 
           -> ByteString
encodeJpeg qual meta img@Picture.Image{..} 
  = toStrict $ encodeDirectJpegAtQualityWithMetadata (fromIntegral qual) newmeta img
  where
    -- We want to preserve Exif orientation metadata, which is important for presentation (see #11)
    -- However, other metadata tags are notoriously finicky and can leads to corrupted
    -- files. 
    exifOrientationMeta = foldMap (\(k, v) -> Meta.singleton (Meta.Exif k) v) 
                        $ filter (\(k, _) -> k == TagOrientation) 
                        $ Meta.extractExifMetas meta
    newmeta = exifOrientationMeta <> basicMetadata SourceJpeg imageWidth imageHeight


-- | Compiler that compresses a JPG image to a certain quality setting.
-- The quality should be between 0 (lowest quality) and 100 (best quality).
-- Values outside of this range will be normalized to the interval [0, 100].
-- An error is raised if the image cannot be decoded.
--
-- @
-- match "*.jpg" $ do
--     route idRoute
--     compile $ loadImage
--         >>= compressJpgCompiler 50
-- @
compressJpgCompiler :: Integral a => a -> Item Image -> Compiler (Item Image)
compressJpgCompiler quality = return . fmap (compressJpg quality)