packages feed

brush-stroking-0.1.0.0: src/BrushStroking/Document/Serialise.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}

{-# OPTIONS_GHC -Wno-orphans #-}

module BrushStroking.Document.Serialise
  ( documentToJSON, documentFromJSON
  , saveDocument, loadDocument
  )
  where

-- base
import Control.Monad
  ( unless )
import Control.Monad.ST
  ( RealWorld, stToIO )
import Control.Exception
  ( try )
import qualified Data.Bifunctor as Bifunctor
  ( first )
import Data.Maybe
  ( fromMaybe, maybeToList )
import Data.STRef
  ( newSTRef )
import Data.Traversable
  ( for )
import Data.Version
  ( Version(versionBranch) )
import GHC.Exts
  ( Proxy# )

-- aeson
import Data.Aeson
  ( (.=) )
import qualified Data.Aeson as Aeson

-- aeson-pretty
import qualified Data.Aeson.Encode.Pretty as PrettyAeson

-- atomic-file-ops
import System.IO.AtomicFileOps
  ( atomicReplaceFile )

-- bytestring
import qualified Data.ByteString as Strict
  ( ByteString )
import qualified Data.ByteString as Strict.ByteString
  ( readFile )
import qualified Data.ByteString.Lazy as Lazy
  ( ByteString )

-- containers
import qualified Data.Map.Strict as Map

-- directory
import System.Directory
  ( canonicalizePath, createDirectoryIfMissing, doesFileExist )

-- filepath
import System.FilePath
  ( takeDirectory )

-- hermes-json
import qualified Data.Hermes as Hermes

-- stm
import qualified Control.Concurrent.STM as STM
  ( atomically )

-- text
import Data.Text
  ( Text )
import qualified Data.Text as Text
  ( pack, unwords, unpack )

-- transformers
import Control.Monad.IO.Class
  ( MonadIO(liftIO) )
import qualified Control.Monad.Trans.Reader as Reader

-- unordered-containers
import Data.HashMap.Strict
  ( HashMap )
import qualified Data.HashMap.Strict as HashMap

-- brush-strokes
import Math.Bezier.Spline
  ( SplineType(..), SSplineType(..), SplineTypeI(..) )
import Math.Bezier.Stroke
  ( CachedStroke(..) )
import Math.Linear
  ( ℝ(..) )

-- MetaBrush
import BrushStroking.Brush
  ( provePointFields, duplicates )
import BrushStroking.Document
import BrushStroking.Layer
  ( LayerMetadata(..) )
import BrushStroking.Serialisable
import BrushStroking.Stroke
import BrushStroking.Records
  ( Record, knownSymbols )
import BrushStroking.Unique
  ( UniqueSupply, freshUnique )
import qualified Paths_brush_stroking as Cabal
  ( version )

--------------------------------------------------------------------------------

-- | Serialise a document to JSON (in the form of a lazy bytestring).
documentToJSON :: Document -> Lazy.ByteString
documentToJSON = PrettyAeson.encodePretty' $
  PrettyAeson.Config
    { PrettyAeson.confIndent    = PrettyAeson.Spaces 4
    , PrettyAeson.confCompare   = compFn
    , PrettyAeson.confNumFormat = PrettyAeson.Generic
    , PrettyAeson.confTrailingNewline = False
    }
    where
      order :: HashMap Text Int
      order =
        HashMap.fromList $
          zip
            [ "version", "name", "zoom", "center", "strokes", "splineStart", "point", "coords", "closed" ]
            [ 0 .. ]
      compFn :: Text -> Text -> Ordering
      compFn x y
        | x == y
        = EQ
        | let mbIx1 = HashMap.lookup x order
              mbIx2 = HashMap.lookup y order
        = case ( mbIx1, mbIx2 ) of
            ( Nothing, Just {} ) -> GT
            ( Just {}, Nothing ) -> LT
            ( Just i1, Just i2 ) -> compare i1 i2
            ( Nothing, Nothing ) -> compare x y

-- | Parse a document from JSON (given by a strict bytestring).
--
-- Updates the store of brushes by adding any new brushes contained in the document.
documentFromJSON
  :: UniqueSupply
  -> Maybe FilePath
  -> Strict.ByteString
  -> IO ( Either Text Document )
documentFromJSON uniqueSupply mbFilePath docData = do
  mbDoc <-
    try @Hermes.HermesException $
      Hermes.decodeEitherIO ( decodeDocument uniqueSupply mbFilePath ) docData
  return $ Bifunctor.first Hermes.formatException mbDoc

--------------------------------------------------------------------------------

-- | Save a MetaBrush document to a file (in JSON format).
saveDocument :: FilePath -> Document -> IO ()
saveDocument path doc = do
  path' <- canonicalizePath path
  let
    dir :: FilePath
    dir = takeDirectory path'
  createDirectoryIfMissing True dir
  exists <- doesFileExist path
  unless exists do
    writeFile path ""
  atomicReplaceFile Nothing path' ( documentToJSON doc )

-- | Load a MetaBrush document.
loadDocument :: UniqueSupply -> FilePath -> IO ( Either Text Document )
loadDocument uniqueSupply fp = do
  exists <- doesFileExist fp
  if exists
  then ( documentFromJSON uniqueSupply ( Just fp ) =<< Strict.ByteString.readFile fp )
  else pure ( Left $ "No file at " <> Text.pack fp )

--------------------------------------------------------------------------------

instance Aeson.ToJSON brushParams => Aeson.ToJSON ( PointData brushParams ) where
  toJSON ( PointData { pointCoords, brushParams } ) =
    Aeson.object
      [ "coords"      .= pointCoords
      , "brushParams" .= brushParams ]

instance FromJSON brushParams => FromJSON ( PointData brushParams ) where
  decoder =
    Hermes.object $ do
      pointCoords <- key "coords"
      brushParams <- key "brushParams"
      pure ( PointData { pointCoords, brushParams } )

decodeFields :: Hermes.Decoder [ Text ]
decodeFields = do
  fields <- Hermes.list Hermes.text
  case duplicates fields of
    []    -> pure fields
    [dup] -> fail ( "Duplicate field name " <> Text.unpack dup <> " in brush record type" )
    dups  -> fail ( "Duplicate field names in brush record type:\n" <> Text.unpack ( Text.unwords dups ) )


instance Aeson.ToJSON Stroke where
  toJSON
    ( Stroke
      { strokeSpline = strokeSpline :: StrokeSpline clo ( Record pointFields )
      , strokeBrushName
      }
    ) =
    let
      closed :: Bool
      closed = case ssplineType @clo of
        SClosed -> True
        SOpen   -> False
      mbEncodeBrush = case strokeBrushName of
        Nothing      -> []
        Just brushNm -> [ "brush" .= Aeson.object [ "name" .= brushNm ] ]
    in
      Aeson.object $
        [ "closed" .= closed
        , "pointFields" .= knownSymbols @pointFields
        , "spline" .= strokeSpline
        ] ++ mbEncodeBrush

newCurveData :: Integer -> Hermes.FieldsDecoder ( CurveData RealWorld )
newCurveData i = do
  emptyCache <- liftIO . stToIO $ CachedStroke <$> newSTRef Nothing
  return $
    CurveData
      { curveIndex   = fromInteger i
      , cachedStroke = emptyCache
      }

instance FromJSON Stroke where
  decoder = Hermes.object do
    strokeClosed  <- key         "closed"
    mbBrushNm     <- Hermes.atKeyOptional "brush" $ Hermes.object $ key "name"
    pointFields   <- Hermes.atKey "pointFields" decodeFields
    -- decodeFields ensured there were no duplicate field names.
    provePointFields pointFields \ ( _ :: Proxy# pointFields ) ->
      if strokeClosed
      then do
        strokeSpline <- Hermes.atKey "spline" ( decodeSpline @Closed @( PointData ( Record pointFields ) ) newCurveData )
        pure $ case mbBrushNm of
          Nothing ->
            Stroke { strokeSpline, strokeBrushName = Nothing }
          Just brushNm ->
            Stroke { strokeSpline, strokeBrushName = Just brushNm }
      else do
        strokeSpline <- Hermes.atKey "spline" ( decodeSpline @Open   @( PointData ( Record pointFields ) ) newCurveData )
        pure $ case mbBrushNm of
          Nothing ->
            Stroke { strokeSpline, strokeBrushName = Nothing }
          Just brushNm ->
            Stroke { strokeSpline, strokeBrushName = Just brushNm }

instance Aeson.ToJSON Layer where
  toJSON layer =
    Aeson.object $
      [ "name" .= layerName layer
      ] ++ ( if layerVisible layer then [ ] else [ "visible" .= False ] )
        ++ ( if layerLocked  layer then [ "locked" .= True ] else [] )
        ++ case layer of
        GroupLayer { groupChildren } ->
          [ "contents" .= groupChildren ]
        StrokeLayer { layerStroke } ->
          [ "stroke" .= layerStroke ]

decodeLayer :: UniqueSupply -> Hermes.Decoder Layer
decodeLayer uniqueSupply = Hermes.object $ do
    mbLayerName     <- keyOptional "name"
    mbLayerVisible  <- keyOptional "visible"
    mbLayerLocked   <- keyOptional "locked"
    let layerVisible = fromMaybe True  mbLayerVisible
        layerLocked  = fromMaybe False mbLayerLocked
    mbLayerStroke <- keyOptional "stroke"
    case mbLayerStroke of
      Nothing -> do
        let layerName = fromMaybe "Group" mbLayerName
        groupChildren <- fromMaybe [] <$> Hermes.atKeyOptional "contents" ( Hermes.list ( decodeLayer uniqueSupply ) )
        pure ( GroupLayer { layerName, layerVisible, layerLocked, groupChildren } )
      Just layerStroke -> do
        let layerName = fromMaybe "Stroke" mbLayerName
        pure ( StrokeLayer { layerName, layerVisible, layerLocked, layerStroke } )


instance Aeson.ToJSON Guide where
  toJSON ( Guide { guidePoint, guideNormal } ) =
    Aeson.object
      [ "point" .= guidePoint, "normal" .= guideNormal ]

instance FromJSON Guide where
  decoder = Hermes.object do
    guidePoint  <- key "point"
    guideNormal <- key "normal"
    return $ Guide { guidePoint, guideNormal }

instance Aeson.ToJSON DocumentSize where
  toJSON ( DocumentSize { documentTopLeft = p1, documentBottomRight = p2 } ) =
    Aeson.toJSON [ p1, p2 ]
instance FromJSON DocumentSize where
  decoder = do
    xys <- Hermes.list decoder
    case xys of
      [ ℝ2 x1 y1, ℝ2 x2 y2 ] ->
        return $
          DocumentSize
            { documentTopLeft     = ℝ2 ( min x1 x2 ) ( min y1 y2 )
            , documentBottomRight = ℝ2 ( max x1 x2 ) ( max y1 y2 )
            }
      _ -> fail $ unlines
             [ "Cannot parse 'DocumentSize'."
             , "Expected 2 points, but got: " ++ show ( length xys ) ++ "."
             ]

decodeDocumentMetadata
  :: UniqueSupply
  -> Maybe FilePath
  -> LayerMetadata
  -> Hermes.FieldsDecoder DocumentMetadata
decodeDocumentMetadata uniqueSupply mbFilePath layerMetadata = do
  mbDocName      <- keyOptional "name"
  mbCenter       <- keyOptional "center"
  zoomFactor     <- keyOptional "zoom"
  guides         <- keyOptional "guides"
  mbDocSize      <- keyOptional "size"
  documentGuides <- fmap Map.fromList . liftIO . STM.atomically $
    for ( fromMaybe [] guides ) $ \ guide -> do
      u <- Reader.runReaderT freshUnique uniqueSupply
      return ( u, guide )
  return $
    Metadata
      { documentName     = fromMaybe ( documentName defaultMeta ) mbDocName
      , documentFilePath = mbFilePath
      , viewportCenter   = fromMaybe ( viewportCenter defaultMeta ) mbCenter
      , documentZoom     = maybe ( Zoom 1 ) Zoom zoomFactor
      , documentGuides
      , layerMetadata
      , selectedPoints   = mempty
      , documentSize     = mbDocSize
      }
  where
    defaultMeta = documentMetadata $ emptyDocument "Document"

instance Aeson.ToJSON Document where
  toJSON ( Document { documentMetadata = meta, documentContent } ) =
    Aeson.object $
      [ "version" .= versionBranch Cabal.version
      , "name"    .= documentName meta
      ] ++
      [ "size"    .= sz | sz <- maybeToList ( documentSize meta ) ]
        ++
      [ "center"  .= viewportCenter meta
      , "zoom"    .= ( zoomFactor $ documentZoom meta )
      , "strokes" .= ( strokeHierarchyLayers ( layerMetadata meta ) ( strokeHierarchy documentContent ) )
      ] ++ if null guides then [] else [ "guides" .= guides ]
      where
        guides = Map.elems $ documentGuides meta

decodeDocument :: UniqueSupply -> Maybe FilePath -> Hermes.Decoder Document
decodeDocument uniqueSupply mbFilePath =
  Hermes.object do
    let
      unsavedChanges :: Bool
      unsavedChanges = False
    mbLayers1 <- Hermes.atKeyOptional "strokes" ( Hermes.list ( decodeLayer uniqueSupply ) )
    -- Preserve back-compat (a previous format used 'content.strokes' instead of 'strokes').
    mbLayers2 <- Hermes.atKeyOptional "content" ( Hermes.object $ Hermes.atKeyOptional "strokes" ( Hermes.list ( decodeLayer uniqueSupply ) ) )
    let layers = fromMaybe [] mbLayers1 <> fromMaybe [] ( fromMaybe ( Just [] ) mbLayers2 )
    ( layerMetadata, strokeHierarchy ) <- ( `Reader.runReaderT` uniqueSupply ) $ layersStrokeHierarchy layers
    let documentContent = Content { unsavedChanges, strokeHierarchy }
    documentMetadata <- decodeDocumentMetadata uniqueSupply mbFilePath layerMetadata
    return $
      Document
        { documentMetadata
        , documentContent
        }