packages feed

hpdft-0.4.6.0: src/PDF/FormExtract.hs

{-# LANGUAGE OverloadedStrings #-}

{-|
Module      : PDF.FormExtract
Description : Form XObject extraction to standalone PDF files (0.4.5)
License     : MIT

Extracts a named Form XObject from a page (including nested resources)
and writes a minimal single-page PDF that draws the form as vector content.

@example
import PDF.FormExtract (pageFormNames, extractFormPdf)

names <- pageFormNames doc 1
pdfBytes <- extractFormPdf doc 1 "Fm42"
-}
module PDF.FormExtract
  ( pageFormNames
  , extractFormPdf
  , extractFormToFile
  ) where

import PDF.Definition
import PDF.Document (Document(..))
import PDF.DocumentStructure
  ( findDict
  , findDictByRef
  , findObjsByRef
  , findResourcesDict
  )
import PDF.Error (PdfError(..), PdfResult)
import PDF.Page (pageRefAt)

import Data.Char (chr, isAscii, isHexDigit, ord)
import Data.List (find, sort, sortOn, nub)
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))
import Text.Printf (printf)

import Data.Text.Encoding (encodeUtf16BE)

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.Set as S
import qualified Data.Text as T

pdfHeader :: BS.ByteString
pdfHeader = BSC.pack "%PDF-1.5\n%\xc2\xb5\xc2\xb6\n"

-- | Top-level Form XObject names on a page (page /Resources /XObject only, inherited).
-- Nested forms inside a Form are not listed.
pageFormNames :: Document -> Int -> PdfResult [T.Text]
pageFormNames doc pageNum = do
  pref <- pageRefAt doc pageNum
  xobj <- pageXObjectDict doc pref
  let objs = docObjs doc
  return $ sort $ map stripNameSlash $ filter (isFormName objs xobj) (M.keys xobj)

extractFormPdf :: Document -> Int -> T.Text -> PdfResult BS.ByteString
extractFormPdf doc pageNum name = do
  pref <- pageRefAt doc pageNum
  formRef <- findFormRef doc pref name
  formDict <- formDictAt doc formRef
  bbox <- formBBox formDict
  matrix <- formMatrixArray formDict
  let formKey = formNameKey name
      index = docObjs doc
  copiedRefs <- transitiveClosure formRef index
  let renum = renumberMap copiedRefs
      formNewRef = renum M.! formRef
      pageContent = pageDrawStream formKey matrix
      wrapper =
        [ (1, catalogBody)
        , (2, pagesBody)
        , (3, pageBody bbox formKey formNewRef)
        , (4, contentStreamBody pageContent)
        ]
      copied =
        [ (renum M.! oldRef, serializeCopiedObject oldRef index renum)
        | oldRef <- copiedRefs
        ]
      allObjs = wrapper ++ copied
      (body, offsets) = buildBody allObjs
      xrefPos = BS.length body
      maxObj = maximum (map fst allObjs)
      size = maxObj + 1
  return $
    body
      <> xrefTable offsets size
      <> trailerPart size xrefPos

extractFormToFile :: Document -> Int -> T.Text -> FilePath -> IO (PdfResult FilePath)
extractFormToFile doc pageNum name outDir =
  createDirectoryIfMissing True outDir >> go
  where
    go = case extractFormPdf doc pageNum name of
      Left err -> return (Left err)
      Right bytes -> do
        let path = outDir </> (T.unpack (stripNameSlash name) ++ ".pdf")
        BS.writeFile path bytes
        return (Right path)

-- | Page resources with inheritance (same walk as 'PDF.Interpret.pageResourcesInherited').
pageResourcesInherited :: Dict -> PDFObjIndex -> Maybe Dict
pageResourcesInherited dict objs =
  case findResourcesDict dict objs of
    Just r -> Just r
    Nothing ->
      case M.lookup "/Parent" dict of
        Just (ObjRef pref) ->
          case findDictByRef pref objs of
            Just parent -> pageResourcesInherited parent objs
            Nothing -> Nothing
        _ -> Nothing

pageXObjectDict :: Document -> Int -> PdfResult (M.Map T.Text Obj)
pageXObjectDict doc pageRef = do
  pageDict <- case findObjsByRef pageRef (docObjs doc) of
    Just os -> case findDictOfTypePage os of
      Just d -> Right d
      Nothing -> Left (MissingKey "/Type" ("page " ++ show pageRef))
    Nothing -> Left (MissingObject pageRef)
  case pageResourcesInherited pageDict (docObjs doc) of
    Nothing -> Right M.empty
    Just res -> xobjectEntries res (docObjs doc)

findDictOfTypePage :: [Obj] -> Maybe Dict
findDictOfTypePage os = findDict os

xobjectEntries :: Dict -> PDFObjIndex -> PdfResult (M.Map T.Text Obj)
xobjectEntries res objs = case M.lookup "/XObject" res of
  Nothing -> Right M.empty
  Just (PdfDict xd) -> Right xd
  Just (ObjRef r) ->
    case findDictByRef r objs of
      Just xd -> Right xd
      Nothing -> Left (MissingObject r)
  Just _ -> Left (UnsupportedFeature "invalid /XObject entry on page")

isFormName :: PDFObjIndex -> M.Map T.Text Obj -> T.Text -> Bool
isFormName objs xobj name =
  case M.lookup name xobj of
    Just (ObjRef r) -> isFormObject objs r
    _ -> False

findFormRef :: Document -> Int -> T.Text -> PdfResult Int
findFormRef doc pageRef name = do
  xobj <- pageXObjectDict doc pageRef
  let key = formNameKey name
  case M.lookup key xobj of
    Nothing ->
      Left (MissingKey (T.unpack key) ("page " ++ show pageRef ++ " XObject dictionary"))
    Just (ObjRef r) ->
      if isFormObject (docObjs doc) r
        then Right r
        else Left (UnsupportedFeature (T.unpack key ++ " is not a Form XObject"))
    Just _ ->
      Left (UnsupportedFeature (T.unpack key ++ " is not an indirect Form XObject"))

isFormObject :: PDFObjIndex -> Int -> Bool
isFormObject objs ref =
  case findDictByRef ref objs of
    Just d -> M.lookup "/Subtype" d == Just (PdfName "/Form")
    Nothing -> False

formDictAt :: Document -> Int -> PdfResult Dict
formDictAt doc ref =
  case findDictByRef ref (docObjs doc) of
    Just d -> Right d
    Nothing -> Left (MissingObject ref)

formBBox :: Dict -> PdfResult [Double]
formBBox d = case M.lookup "/BBox" d of
  Just (PdfArray nums) -> parseBBox nums
  Nothing -> Left (MissingKey "/BBox" "Form XObject")
  Just _ -> Left (UnsupportedFeature "Form /BBox must be an array of four numbers")

parseBBox :: [Obj] -> PdfResult [Double]
parseBBox nums =
  case map asNumber nums of
    [Just a, Just b, Just c, Just d'] -> Right [a, b, c, d']
    _ -> Left (UnsupportedFeature "Form /BBox must be four numbers")

asNumber :: Obj -> Maybe Double
asNumber (PdfNumber n) = Just n
asNumber _ = Nothing

formMatrixArray :: Dict -> PdfResult (Maybe [Double])
formMatrixArray d = case M.lookup "/Matrix" d of
  Nothing -> Right Nothing
  Just (PdfArray nums) ->
    case map asNumber nums of
      [Just a, Just b, Just c, Just d', Just e, Just f] -> Right (Just [a, b, c, d', e, f])
      _ -> Left (UnsupportedFeature "Form /Matrix must be six numbers")
  Just _ -> Left (UnsupportedFeature "Form /Matrix must be an array")

formNameKey :: T.Text -> T.Text
formNameKey n =
  if "/" `T.isPrefixOf` n then n else "/" `T.append` n

stripNameSlash :: T.Text -> T.Text
stripNameSlash n =
  if "/" `T.isPrefixOf` n then T.drop 1 n else n

transitiveClosure :: Int -> PDFObjIndex -> PdfResult [Int]
transitiveClosure start objs = go [start] S.empty []
  where
    go [] _ acc = Right (reverse acc)
    go (r : rs) seen acc
      | r `S.member` seen = go rs seen acc
      | otherwise =
          case findObjsByRef r objs of
            Nothing -> Left (MissingObject r)
            Just os ->
              let refs = nub (collectObjRefs os)
                  unseen = filter (not . (`S.member` seen)) refs
              in go (rs ++ unseen) (S.insert r seen) (r : acc)

collectObjRefs :: [Obj] -> [Int]
collectObjRefs = concatMap refsInObj

refsInObj :: Obj -> [Int]
refsInObj (PdfDict d) = concatMap refsInObj (M.elems d)
refsInObj (PdfArray a) = concatMap refsInObj a
refsInObj (ObjRef r) = [r]
refsInObj _ = []

renumberMap :: [Int] -> M.Map Int Int
renumberMap copied = M.fromList (zip copied [5 ..])

rewriteObj :: M.Map Int Int -> Obj -> Obj
rewriteObj mp = go
  where
    go (PdfDict d) = PdfDict (M.map go d)
    go (PdfArray a) = PdfArray (map go a)
    go r@(ObjRef old) = maybe r ObjRef (M.lookup old mp)
    go o = o

catalogBody :: BS.ByteString
catalogBody = BSC.pack "<< /Type /Catalog /Pages 2 0 R >>"

pagesBody :: BS.ByteString
pagesBody = BSC.pack "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"

pageBody :: [Double] -> T.Text -> Int -> BS.ByteString
pageBody bbox formKey formNewRef =
  BSC.pack $
    unwords
      [ "<< /Type /Page /Parent 2 0 R"
      , "/MediaBox [" ++ showBBox bbox ++ "]"
      , "/Resources << /XObject <<"
      , BSC.unpack (serializeName formKey)
      , show formNewRef ++ " 0 R >> >>"
      , "/Contents 4 0 R >>"
      ]

showBBox :: [Double] -> String
showBBox = unwords . map showPdfNumber

pageDrawStream :: T.Text -> Maybe [Double] -> BS.ByteString
pageDrawStream formKey mmat =
  let cmOp =
        case mmat of
          Nothing -> "1 0 0 1 0 0 cm"
          Just [a, b, c, d, e, f]
            | isIdentity [a, b, c, d, e, f] -> "1 0 0 1 0 0 cm"
            | otherwise ->
                unwords (map showPdfNumber [a, b, c, d, e, f]) ++ " cm"
          _ -> "1 0 0 1 0 0 cm"
      ops =
        BSC.concat
          [ "q\n"
          , BSC.pack cmOp
          , "\n"
          , serializeName formKey
          , " Do\nQ\n"
          ]
  in ops

isIdentity :: [Double] -> Bool
isIdentity [a, b, c, d, e, f] =
  near a 1 && near b 0 && near c 0 && near d 1 && near e 0 && near f 0
isIdentity _ = False

near :: Double -> Double -> Bool
near x y = abs (x - y) < 1e-9

contentStreamBody :: BS.ByteString -> BS.ByteString
contentStreamBody streamBytes =
  BSC.concat
    [ "<< /Length "
    , BSC.pack (show (BS.length streamBytes))
    , " >>\nstream\n"
    , streamBytes
    , "\nendstream"
    ]

serializeCopiedObject :: Int -> PDFObjIndex -> M.Map Int Int -> BS.ByteString
serializeCopiedObject ref objs renum =
  case findObjsByRef ref objs of
    Nothing -> "null"
    Just os ->
      case findDict os of
        Just d ->
          case findStream os of
            Nothing ->
              BSC.concat ["<<", serializeDict (rewriteObj renum <$> d), " >>"]
            Just stream ->
              serializeStreamObject (rewriteObj renum <$> d) stream
        Nothing ->
          case os of
            [single] -> serializeObj (rewriteObj renum single)
            _ -> "null"

findStream :: [Obj] -> Maybe BSL.ByteString
findStream os = case find isStream os of
  Just (PdfStream s) -> Just s
  _ -> Nothing
  where
    isStream (PdfStream _) = True
    isStream _ = False

serializeStreamObject :: Dict -> BSL.ByteString -> BS.ByteString
serializeStreamObject d stream =
  let raw = BSL.toStrict stream
      -- PdfStream in the index holds file bytes (still filter-encoded).
      -- Re-compressing would double-apply FlateDecode and break content streams.
      finalDict =
        M.insert "/Length" (PdfNumber (fromIntegral (BS.length raw))) (stripLength d)
  in BSC.concat
       [ "<<"
       , serializeDict finalDict
       , " >>\nstream\n"
       , raw
       , "\nendstream"
       ]
stripLength = M.filterWithKey (\k _ -> k /= "/Length") . M.delete "/Length"

serializeDict :: Dict -> BS.ByteString
serializeDict d =
  BSC.concat [serializeDictEntry k v | (k, v) <- M.toAscList d]

serializeDictEntry :: T.Text -> Obj -> BS.ByteString
serializeDictEntry k v =
  BSC.concat [" ", serializeName k, " ", serializeObj v]

serializeObj :: Obj -> BS.ByteString
serializeObj (PdfDict d) = BSC.concat ["<<", serializeDict d, " >>"]
serializeObj (PdfArray a) =
  BSC.concat ["[", BSC.intercalate " " (map serializeObj a), "]"]
serializeObj (PdfName n) = serializeName n
serializeObj (PdfText t) = serializeText t
serializeObj (PdfHex h) = serializeHex h
serializeObj (PdfNumber n) = BSC.pack (showPdfNumber n)
serializeObj (PdfBool True) = "true"
serializeObj (PdfBool False) = "false"
serializeObj (ObjRef r) = BSC.pack (show r ++ " 0 R")
serializeObj PdfNull = "null"
serializeObj (ObjOther o) = BSC.pack (T.unpack o)

serializeName :: T.Text -> BS.ByteString
serializeName n = BSC.pack (T.unpack n)

serializeText :: T.Text -> BS.ByteString
serializeText t
  | T.all isAscii t =
      let esc c =
            case c of
              '\\' -> "\\"
              '(' -> "\\("
              ')' -> "\\)"
              _ -> [c]
       in BSC.concat ["(", BSC.pack (concatMap esc (T.unpack t)), ")"]
  | otherwise =
      BSC.concat
        [ "<"
        , bytesToHex (BS.pack [0xfe, 0xff] <> encodeUtf16BE t)
        , ">"
        ]

-- | Re-encode 'PdfHex' from parsed objects (hex digits or raw byte chars).
serializeHex :: T.Text -> BS.ByteString
serializeHex h
  | T.all (\c -> isHexDigit c) h = BSC.concat ["<", BSC.pack (T.unpack h), ">"]
  | otherwise =
      BSC.concat ["<", bytesToHex (textRawBytes h), ">"]

textRawBytes :: T.Text -> BS.ByteString
textRawBytes = BS.pack . map (fromIntegral . ord) . T.unpack

bytesToHex :: BS.ByteString -> BS.ByteString
bytesToHex =
  BSC.pack . concatMap byteHex . BS.unpack
  where
    byteHex w =
      let hi = w `div` 16
          lo = w `mod` 16
       in [hexChar hi, hexChar lo]

hexChar :: Integral a => a -> Char
hexChar n
  | n < 10 = chr (fromIntegral n + ord '0')
  | otherwise = chr (fromIntegral n - 10 + ord 'A')

showPdfNumber :: Double -> String
showPdfNumber n
  | n == fromIntegral (truncate n :: Integer) = show (truncate n :: Integer)
  | otherwise = show n

obj :: Int -> BS.ByteString -> BS.ByteString
obj n body = BSC.concat [BSC.pack (show n), " 0 obj\n", body, "\nendobj\n"]

buildBody :: [(Int, BS.ByteString)] -> (BS.ByteString, [(Int, Int)])
buildBody objects = go pdfHeader [] (sortOn fst objects)
  where
    go acc offs [] = (acc, reverse offs)
    go acc offs ((n, b) : rest) =
      go (acc <> obj n b) ((n, BS.length acc) : offs) rest

xrefEntry :: Int -> BS.ByteString
xrefEntry off = BSC.pack (printf "%010d 00000 n \n" off)

xrefTable :: [(Int, Int)] -> Int -> BS.ByteString
xrefTable offsets size =
  BSC.concat $
    [ "xref\n"
    , BSC.pack ("0 " ++ show size ++ "\n")
    , "0000000000 65535 f \n"
    ]
      ++ map (xrefEntry . snd) (sortOn fst offsets)

trailerPart :: Int -> Int -> BS.ByteString
trailerPart size xrefPos =
  BSC.concat
    [ "trailer\n<< /Size "
    , BSC.pack (show size)
    , " /Root 1 0 R >>\nstartxref\n"
    , BSC.pack (show xrefPos)
    , "\n%%EOF\n"
    ]