packages feed

anitomata-aseprite-0.1.1.0: library/Anitomata/Aseprite/Preprocessor.hs

{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
module Anitomata.Aseprite.Preprocessor
  ( preprocessor
  , preprocessorWith
  , PreprocessorOpts(..)
  ) where

import Prelude

import Control.Applicative ((<**>))
import Data.Aeson (FromJSON)
import Data.Kind (Type)
import GHC.Generics (Generic)
import GHC.Records (HasField(getField))
import ModuleMunging
  ( DeclBody(..), DeclName(..), ModuleDeclaration(..), ModuleFragment(..), ModuleImport(..)
  , ModuleImportStyle(..), ModuleName(..), buildModule, displayModule
  )
import Text.ParserCombinators.ReadP (ReadP)
import Text.Printf (printf)
import Type.Reflection (Typeable, typeRep)

import Control.Monad qualified as Monad
import Data.Aeson qualified as Aeson
import Data.Aeson.Types qualified as Aeson.Types
import Data.Char qualified as Char
import Data.List qualified as List
import Data.Maybe qualified as Maybe
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text.IO
import Options.Applicative qualified as Opt
import System.Directory qualified as Directory
import System.Exit qualified as Exit
import System.FilePath qualified as FilePath
import Text.Read qualified as Read
import Text.ParserCombinators.ReadP qualified as ReadP

preprocessor :: IO ()
preprocessor = do
  opts <- Opt.execParser optsParser
  preprocessorWith opts

preprocessorWith :: PreprocessorOpts -> IO ()
preprocessorWith opts = do
  sourceFile' <- Directory.makeRelativeToCurrentDirectory sourceFile
  atlasPath <- do
    let dir = FilePath.dropFileName sourceFile'
    let baseName = FilePath.takeBaseName sourceFile'
    let atlasPath = dir ++ FilePath.addExtension baseName ".json"
    atlasExists <- Directory.doesFileExist atlasPath
    Monad.unless atlasExists do
      Exit.die $ printf "aseprite2haskell: Aseprite atlas JSON does not exist at %s" atlasPath
    pure atlasPath
  atlas <- Aeson.eitherDecodeFileStrict atlasPath >>= \case
    Left decodeErr -> Exit.die $ "aseprite2haskell: failed to parse JSON - " <> show decodeErr
    Right x -> pure x
  Text.IO.writeFile outputFile $ Text.pack $ displayModule $ buildModule (ModuleNameFromFilePath sourceFile') $ mconcat
    [ mkBuildersAndSlices atlas
    , mkFrames $ frames atlas
    , mkDurations $ frames atlas
    ]
  where
  PreprocessorOpts
    { preprocessorOptsOrigSourceFile = sourceFile
    , preprocessorOptsOutputFile = outputFile
    } = opts

mkBuildersAndSlices :: Atlas -> ModuleFragment
mkBuildersAndSlices Atlas { frames, meta = Meta { frameTags = tags } }
  | null frames = mempty
  | otherwise = mconcat $ flattenFrameInfo $ List.groupBy groupFrameInfo $ zip frames [0 :: Int ..]
  where
  flattenFrameInfo :: [[(FrameInfo, Int)]] -> [ModuleFragment]
  flattenFrameInfo = foldMap \case
    [] -> []
    (FrameInfo { filename }, frameIdx) : xs ->
      let builderName = mkBuilderName filename
          sliceName = printf "%s_slice" builderName :: String
          (dir, mTagRepeat) = dirAndRepeatFromTags filename
       in [ ModuleFragment
              { moduleFragmentImports =
                  [ ModuleImport
                      { moduleImportName = "Prelude"
                      , moduleImportStyle = ModuleImportStyleOpen
                      }
                  , ModuleImport
                      { moduleImportName = "Anitomata"
                      , moduleImportStyle = ModuleImportStyleExplicit
                          $ "AnimBuilder"
                          : "AnimDir(..)"
                          : "AnimSlice"
                          : "AnimSlice_(..)"
                          : builderFn dir
                          : [x | Maybe.isJust mTagRepeat, x <- ["AnimRepeat(..)", "repeatAnim"]]
                      }
                  , ModuleImport
                      { moduleImportName = "Data.Vector.Unboxed"
                      , moduleImportStyle = ModuleImportStyleQualified $ Just "U"
                      }
                  ]
              , moduleFragmentDeclarations =
                  [ ModuleDeclaration True (DeclName builderName) $ DeclBody
                      case mTagRepeat of
                        Nothing ->
                          List.intercalate "\n"
                            [ printf "%s :: AnimBuilder" builderName
                            , printf "%s = %s %s" builderName (builderFn dir) sliceName
                            ]
                        Just (TagRepeat n) ->
                          List.intercalate "\n"
                            [ printf "%s :: AnimBuilder" builderName
                            , printf "%s = repeatAnim (AnimRepeatCount %d) $ %s %s" builderName n (builderFn dir) sliceName
                            ]
                  , ModuleDeclaration True (DeclName sliceName) $ DeclBody $ List.intercalate "\n"
                      [ printf "%s :: AnimSlice" sliceName
                      , printf "%s =" sliceName
                      ,        "  AnimSlice"
                      , printf "    { animSliceDir = %s" (toAnimDir dir)
                      , printf "    , animSliceFrameDurs = U.slice %d %d %s" frameIdx (1 + length xs) durationsVecName
                      , printf "    , animSliceFrames = U.slice %d %d %s" frameIdx (1 + length xs) framesVecName
                      ,        "    }"
                      ]
                  ]
              }
          ]

  groupFrameInfo :: (FrameInfo, Int) -> (FrameInfo, Int) -> Bool
  groupFrameInfo (FrameInfo { filename = x }, _) (FrameInfo { filename = y }, _) =
    getField @"file" x == getField @"file" y && getField @"tag" x == getField @"tag" y

  dirAndRepeatFromTags :: FilenameField -> (Direction, Maybe TagRepeat)
  dirAndRepeatFromTags FilenameField { file, tag } =
    maybe (Forward, Nothing) ((,) <$> direction <*> getField @"repeat")
      $ flip List.find tags \case
          FrameTag { name = nameField } ->
            file == getField @"file" nameField && tag == getField @"tag" nameField

  toAnimDir :: Direction -> String
  toAnimDir = \case
    Forward -> "AnimDirForward"
    Reverse -> "AnimDirBackward"
    Pingpong -> "AnimDirForward"
    PingpongReverse -> "AnimDirBackward"

  builderFn :: Direction -> String
  builderFn = \case
    Forward -> "fromAnimSlice"
    Reverse -> "fromAnimSlice"
    Pingpong -> "pingpongAnimSlice"
    PingpongReverse -> "pingpongAnimSlice"

mkFrames :: [FrameInfo] -> ModuleFragment
mkFrames = \case
  [] -> mempty
  xs ->
    ModuleFragment
      { moduleFragmentImports =
          [ ModuleImport
              { moduleImportName = "Prelude"
              , moduleImportStyle = ModuleImportStyleOpen
              }
          , ModuleImport
              { moduleImportName = "Data.Vector.Unboxed"
              , moduleImportStyle = ModuleImportStyleQualified $ Just "U"
              }
          , ModuleImport
              { moduleImportName = "Anitomata"
              , moduleImportStyle = ModuleImportStyleExplicit ["AnimFrame", "AnimFrame_(..)"]
              }
          ]
      , moduleFragmentDeclarations =
          [ ModuleDeclaration False (DeclName framesVecName) $ DeclBody $ List.intercalate "\n"
              [ printf "%s :: U.Vector AnimFrame" framesVecName
              , printf "%s = U.fromListN %d" framesVecName $ length xs
              , printf "  [ %s" $ List.intercalate "\n  , " (sourceRect . frame <$> xs)
              ,        "  ]"
              ]
          ]
      }
  where
  sourceRect :: Frame -> String
  sourceRect Frame { x, y, w, h } =
    printf "AnimFrame { animFrameX = %d, animFrameY = %d, animFrameW = %d, animFrameH = %d }" x y w h

mkDurations :: [FrameInfo] -> ModuleFragment
mkDurations = \case
  [] -> mempty
  xs ->
    ModuleFragment
      { moduleFragmentImports =
          [ ModuleImport
              { moduleImportName = "Prelude"
              , moduleImportStyle = ModuleImportStyleOpen
              }
          , ModuleImport
              { moduleImportName = "Data.Vector.Unboxed"
              , moduleImportStyle = ModuleImportStyleQualified $ Just "U"
              }
          ]
      , moduleFragmentDeclarations =
          [ ModuleDeclaration False (DeclName durationsVecName) $ DeclBody $ List.intercalate "\n"
              [ printf "%s :: U.Vector Double" durationsVecName
              , printf "%s = U.fromListN %d" durationsVecName $ length xs
              , printf "  [ %s" $ List.intercalate "\n  , " (show . toSeconds . duration <$> xs)
              ,        "  ]"
              ]
          ]
      }
  where
  toSeconds :: Int -> Double
  toSeconds ms = fromIntegral ms / 1000

type Atlas :: Type
data Atlas = Atlas
  { frames :: [FrameInfo]
  , meta :: Meta
  } deriving stock (Generic)
    deriving anyclass (FromJSON)

type Meta :: Type
newtype Meta = Meta
  { frameTags :: [FrameTag]
  } deriving stock (Generic)
    deriving anyclass (FromJSON)

type FrameTag :: Type
data FrameTag = FrameTag
  { name :: TagNameField
  , from :: Int
  , to :: Int
  , direction :: Direction
  , repeat :: Maybe TagRepeat
  } deriving stock (Generic)
    deriving anyclass (FromJSON)

type TagNameField :: Type
data TagNameField = TagNameField
  { file :: Text
  , tag :: Text
  } deriving stock (Show)

instance FromJSON TagNameField where
  parseJSON :: Aeson.Value -> Aeson.Types.Parser TagNameField
  parseJSON = parseJSONViaReadP tagnameFieldParser

tagnameFieldParser :: ReadP TagNameField
tagnameFieldParser = do
  file <- Text.pack <$> ReadP.munch1 (/= '|')
  _ <- ReadP.char '|'
  tag <- Text.pack <$> ReadP.munch1 (/= '|')
  ReadP.eof
  pure TagNameField { file, tag }

type TagRepeat :: Type
newtype TagRepeat = TagRepeat Int
  deriving stock (Show)

instance FromJSON TagRepeat where
  parseJSON :: Aeson.Value -> Aeson.Types.Parser TagRepeat
  parseJSON = parseJSONViaReadP tagRepeatParser

tagRepeatParser :: ReadP TagRepeat
tagRepeatParser = do
  count <- intParser
  ReadP.eof
  pure $ TagRepeat count

type Direction :: Type
data Direction
  = Forward
  | Reverse
  | Pingpong
  | PingpongReverse

instance FromJSON Direction where
  parseJSON :: Aeson.Value -> Aeson.Types.Parser Direction
  parseJSON = Aeson.withText "FromJSON Direction" \case
    "forward" -> pure Forward
    "reverse" -> pure Reverse
    "pingpong" -> pure Pingpong
    "pingpong_reverse" -> pure PingpongReverse
    other -> fail $ "Invalid direction: " <> show other

type FrameInfo :: Type
data FrameInfo = FrameInfo
  { filename :: FilenameField
  , frame :: Frame
  , duration :: Int
  } deriving stock (Generic, Show)
    deriving anyclass (FromJSON)

framesVecName :: String
framesVecName = "frames"

durationsVecName :: String
durationsVecName = "durations"

type FilenameField :: Type
data FilenameField = FilenameField
  { file :: Text
  , tag :: Text
    -- | This is the frame index in the specific .aseprite file, NOT the frame
    -- index in the overall texture atlas frames. It is only parsed here for
    -- debugging's sake to cross-reference individual .aseprite files.
  , frameIndex :: Int
  } deriving stock (Show)

mkBuilderName :: FilenameField -> String
mkBuilderName FilenameField { file, tag } = printf "%s_%s" file tag

instance FromJSON FilenameField where
  parseJSON :: Aeson.Value -> Aeson.Types.Parser FilenameField
  parseJSON = parseJSONViaReadP filenameFieldParser

filenameFieldParser :: ReadP FilenameField
filenameFieldParser = do
  file <- Text.pack . map sanitize <$> ReadP.munch1 (/= '|')
  _ <- ReadP.char '|'
  tag <- Text.pack . map sanitize <$> ReadP.munch1 (/= '|')
  _ <- ReadP.char '|'
  frameIndex <- intParser
  ReadP.eof
  pure FilenameField { file, tag, frameIndex }
  where
  sanitize :: Char -> Char
  sanitize = \case
    '-' -> '_'
    c -> c

type Frame :: Type
data Frame = Frame
  { x :: Int
  , y :: Int
  , w :: Int
  , h :: Int
  } deriving stock (Generic, Show)
    deriving anyclass (FromJSON)

optsParser :: Opt.ParserInfo PreprocessorOpts
optsParser = Opt.info (preprocessorOptsParser <**> Opt.helper) $ mconcat
  [ Opt.fullDesc
  , Opt.progDesc "Convert an Aseprite texture atlas JSON into Haskell code"
  , Opt.header "aseprite2haskell - Aseprite -> Haskell preprocessor"
  ]

type PreprocessorOpts :: Type
data PreprocessorOpts = PreprocessorOpts
  { preprocessorOptsOrigSourceFile :: FilePath
  , preprocessorOptsOutputFile :: FilePath
  } deriving stock (Show)

preprocessorOptsParser :: Opt.Parser PreprocessorOpts
preprocessorOptsParser = do
  preprocessorOptsOrigSourceFile <- Opt.argument Opt.str $ mconcat
    [ Opt.metavar "SOURCE"
    , Opt.help "Original source filepath (passed by GHC)"
    ]
  -- It seems this positional argument only matters when multiple preprocessors
  -- are stacked (e.g. CPP and this custom one). It is ignored here.
  _ <- Opt.argument @String Opt.str $ mconcat
    [ Opt.metavar "INPUT"
    , Opt.help "Input filepath (passed by GHC)"
    ]
  preprocessorOptsOutputFile <- Opt.argument Opt.str $ mconcat
    [ Opt.metavar "OUTPUT"
    , Opt.help "Output filepath (passed by GHC)"
    ]
  pure PreprocessorOpts
    { preprocessorOptsOrigSourceFile
    , preprocessorOptsOutputFile
    }

parseJSONViaReadP
  :: forall a
   . (Show a, Typeable a)
  => ReadP a
  -> Aeson.Types.Value
  -> Aeson.Types.Parser a
parseJSONViaReadP parser =
  Aeson.withText (printf "FromJSON %s" $ show $ typeRep @a) \case
    t -> case ReadP.readP_to_S parser s of
      [] -> fail $ printf "Parse failed on input: %s" s
      [(x, _)] -> pure x
      xs -> fail $ printf "Produced too many parses - input: %s, parses: %s" s $ show xs
      where
      s = Text.unpack t

intParser :: ReadP Int
intParser = do
  digitChars <- ReadP.many1 digitParser
  case Read.readMaybe digitChars of
    Nothing -> fail $ printf "Failed to read digits as Int: %s" digitChars
    Just x -> pure x

digitParser :: ReadP Char
digitParser = ReadP.satisfy Char.isDigit