packages feed

hpdft-0.4.6.0: src/PDF/Image.hs

{-# LANGUAGE OverloadedStrings #-}

{-|
Module      : PDF.Image
Description : Image XObject extraction from PDF pages (0.4.3)
License     : MIT

Extracts '/Image' XObjects referenced on a page (including nested Form
XObjects). Inline images are not supported in 0.4.3.

@example
import PDF.Image (extractPageImages)

images <- extractPageImages doc 1
-- each PageImage carries format, bytes, and placement bbox
-}
module PDF.Image
  ( ImageFormat(..)
  , PageImage(..)
  , extractPageImages
  , extractPageImagesToDir
  , classifyImageBytes
  , encodePngRgb
  , encodePngGray
  ) where

import PDF.Definition (Dict, Obj(..), PDFObjIndex)
import PDF.Document (Document(..))
import PDF.DocumentStructure
  ( findDict
  , findDictByRef
  , findObjsByRef
  , rawStreamByRef
  , streamFilterNames
  )
import PDF.Error (PdfError(..), PdfResult)
import PDF.Interpret (Rect(..), interpretPageImageHits)
import PDF.Page (pageRefAt)

import Codec.Compression.Zlib (compressWith, defaultCompressParams)

import Data.Bits (shiftL, shiftR, xor, (.&.))
import Data.Char (ord)
import Data.Word (Word32, Word8)

import Control.Exception (SomeException, catch)
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))

import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC
import qualified Data.ByteString.Lazy as BSL
import qualified Data.Map as M
import qualified Data.Text as T

data ImageFormat = ImageJPEG | ImagePNG | ImageRaw
  deriving (Eq, Show)

data PageImage = PageImage
  { piIndex  :: Int
  , piPage   :: Int
  , piBBox   :: Rect
  , piFormat :: ImageFormat
  , piBytes  :: BS.ByteString
  } deriving (Eq, Show)

-- | Extract image XObjects from a 1-based page number.
extractPageImages :: Document -> Int -> PdfResult [PageImage]
extractPageImages doc pageNum = do
  pref <- pageRefAt doc pageNum
  hits <- interpretPageImageHits doc pref
  mapM (loadHit doc pageNum) (zip [1 ..] hits)

extractPageImagesToDir :: Document -> Int -> FilePath -> IO (PdfResult [FilePath])
extractPageImagesToDir doc pageNum outDir =
  createDirectoryIfMissing True outDir >> go
  where
    go = case extractPageImages doc pageNum of
      Left err -> return (Left err)
      Right images -> fmap Right (mapM (writePageImage outDir) images)

loadHit :: Document -> Int -> (Int, (Int, Rect)) -> PdfResult PageImage
loadHit doc pageNum (idx, (ref, bbox)) = do
  (fmt, bytes) <- loadImageBytes doc ref
  return PageImage
    { piIndex = idx
    , piPage = pageNum
    , piBBox = bbox
    , piFormat = fmt
    , piBytes = bytes
    }

loadImageBytes :: Document -> Int -> PdfResult (ImageFormat, BS.ByteString)
loadImageBytes doc ref = do
  os <- case findObjsByRef ref (docObjs doc) of
    Just x  -> Right x
    Nothing -> Left (MissingObject ref)
  d <- case findDict os of
    Just x  -> Right x
    Nothing -> Left (MissingKey "/Type" ("image object " ++ show ref))
  stream <- case M.lookup ref (docStreamCache doc) of
    Just r -> r
    Nothing -> rawStreamByRef (docSecurity doc) (docObjs doc) ref
  classifyImageBytes (docObjs doc) d (BSL.toStrict stream)

classifyImageBytes
  :: PDFObjIndex -> Dict -> BS.ByteString -> PdfResult (ImageFormat, BS.ByteString)
classifyImageBytes objs d bs = do
  filters <- streamFilterNames d
  if "/DCTDecode" `elem` map T.unpack filters || isJpeg bs
    then return (ImageJPEG, bs)
    else do
      w <- dictPositiveInt d "/Width"
      h <- dictPositiveInt d "/Height"
      bpc <- dictPositiveInt d "/BitsPerComponent"
      comps <- colorSpaceComponents objs d
      case (comps, bpc, BS.length bs) of
        (Just 3, 8, n) | n == w * h * 3 ->
          encodePngRgb w h bs >>= \png -> return (ImagePNG, png)
        (Just 1, 8, n) | n == w * h ->
          encodePngGray w h bs >>= \png -> return (ImagePNG, png)
        _ -> return (ImageRaw, bs)

isJpeg :: BS.ByteString -> Bool
isJpeg bs =
  BS.length bs >= 3
    && BS.index bs 0 == 0xff
    && BS.index bs 1 == 0xd8
    && BS.index bs 2 == 0xff

dictPositiveInt :: Dict -> T.Text -> PdfResult Int
dictPositiveInt d key = case M.lookup key d of
  Just (PdfNumber n) ->
    let i = truncate n
     in if i > 0 then Right i else Left (UnsupportedFeature ("invalid " ++ T.unpack key))
  _ -> Left (MissingKey (T.unpack key) "image XObject")

colorSpaceComponents :: PDFObjIndex -> Dict -> PdfResult (Maybe Int)
colorSpaceComponents objs d = case M.lookup "/ColorSpace" d of
  Nothing -> Right Nothing
  Just (PdfName "/DeviceRGB") -> Right (Just 3)
  Just (PdfName "/DeviceGray") -> Right (Just 1)
  Just (ObjRef r) ->
    case findDictByRef r objs of
      Just csDict -> colorSpaceComponents objs csDict
      Nothing -> Right Nothing
  Just (PdfArray (PdfName "/DeviceRGB" : _)) -> Right (Just 3)
  Just (PdfArray (PdfName "/DeviceGray" : _)) -> Right (Just 1)
  Just (PdfArray (ObjRef r : _)) ->
    case findDictByRef r objs of
      Just csDict -> colorSpaceComponents objs csDict
      Nothing -> Right Nothing
  _ -> Right Nothing

writePageImage :: FilePath -> PageImage -> IO FilePath
writePageImage outDir img = do
  let base = outDir </> pageImageBasename img
      (path, sidecar) = case piFormat img of
        ImageJPEG -> (base ++ ".jpg", Nothing)
        ImagePNG  -> (base ++ ".png", Nothing)
        ImageRaw  -> (base ++ ".raw", Just (base ++ ".json"))
  writeBinary path (piBytes img)
  case sidecar of
    Nothing -> return ()
    Just sc -> writeBinary sc (rawSidecar img)
  return path

pageImageBasename :: PageImage -> String
pageImageBasename PageImage{piPage = pg, piIndex = idx} =
  "page" ++ show pg ++ "-" ++ pad3 idx

pad3 :: Int -> String
pad3 n =
  let s = show n
   in replicate (max 0 (3 - length s)) '0' ++ s

writeBinary :: FilePath -> BS.ByteString -> IO ()
writeBinary path bs =
  BS.writeFile path bs `catch` handleWriteErr
  where
    handleWriteErr :: SomeException -> IO ()
    handleWriteErr e =
      ioError (userError ("cannot write file: " ++ path ++ ": " ++ show e))

rawSidecar :: PageImage -> BS.ByteString
rawSidecar PageImage{piPage = pg, piIndex = idx, piBBox = bbox, piBytes = bs} =
  BSC.pack json
  where
    json :: String
    json =
      "{\"page\":" ++ show pg
        ++ ",\"index\":" ++ show idx
        ++ ",\"format\":\"raw\""
        ++ ",\"length\":" ++ show (BS.length bs)
        ++ ",\"bbox\":[" ++ show (rectX0 bbox) ++ "," ++ show (rectY0 bbox)
        ++ "," ++ show (rectX1 bbox) ++ "," ++ show (rectY1 bbox) ++ "]}"

-- | Minimal PNG encoder (filter 0 scanlines, zlib IDAT). No new dependencies.
encodePngRgb :: Int -> Int -> BS.ByteString -> PdfResult BS.ByteString
encodePngRgb w h pixels = encodePng 8 2 w h (pngScanlines w 3 pixels)

encodePngGray :: Int -> Int -> BS.ByteString -> PdfResult BS.ByteString
encodePngGray w h pixels = encodePng 8 0 w h (pngScanlines w 1 pixels)

encodePng :: Word8 -> Word8 -> Int -> Int -> BS.ByteString -> PdfResult BS.ByteString
encodePng bitDepth colorType w h scanData = do
  let ihdr =
        word32BE (fromIntegral w)
          <> word32BE (fromIntegral h)
          <> BS.pack [bitDepth, colorType, 0, 0, 0]
      idat = BSL.toStrict (compressWith defaultCompressParams (BSL.fromStrict scanData))
  return $
    pngSignature
      <> pngChunk "IHDR" ihdr
      <> pngChunk "IDAT" idat
      <> pngChunk "IEND" BS.empty

pngSignature :: BS.ByteString
pngSignature = BS.pack [137, 80, 78, 71, 13, 10, 26, 10]

pngScanlines :: Int -> Int -> BS.ByteString -> BS.ByteString
pngScanlines w comps pixels =
  BS.concat
    [ BS.cons 0 (BS.take (w * comps) (BS.drop (row * w * comps) pixels))
    | row <- [0 .. rows - 1]
    ]
  where
    rows = BS.length pixels `div` (w * comps)

pngChunk :: String -> BS.ByteString -> BS.ByteString
pngChunk tag payload =
  let tagBs = BS.pack (map (fromIntegral . ord) tag)
      len = word32BE (fromIntegral (BS.length payload))
      body = tagBs <> payload
      crc = word32BE (crc32 body)
   in len <> body <> crc

word32BE :: Word32 -> BS.ByteString
word32BE w =
  BS.pack
    [ fromIntegral ((w `shiftR` 24) .&. 0xff)
    , fromIntegral ((w `shiftR` 16) .&. 0xff)
    , fromIntegral ((w `shiftR` 8) .&. 0xff)
    , fromIntegral (w .&. 0xff)
    ]

crc32 :: BS.ByteString -> Word32
crc32 bs = finish $ BS.foldl' step 0xffffffff bs
  where
    step crc b =
      (crc `shiftR` 8)
        `xor` (crcTable !! fromIntegral ((crc `xor` fromIntegral b) .&. 0xff))
    finish crc = crc `xor` 0xffffffff

crcTable :: [Word32]
crcTable =
  [ foldl
      (\c _ ->
         if c .&. 1 /= 0
           then xor 0xedb88320 (c `shiftR` 1)
           else c `shiftR` 1
      )
      (fromIntegral n)
      [0 .. 7 :: Int]
  | n <- [0 .. 255]
  ]