packages feed

JuicyPixels-blp 0.2.0.0 → 0.3.0.0

raw patch · 13 files changed

+821/−289 lines, 13 filesdep +containersdep +tastydep +tasty-hunitdep −hashabledep −text-showdep −unordered-containersdep ~JuicyPixelsdep ~attoparsecdep ~basePVP ok

version bump matches the API change (PVP)

Dependencies added: containers, tasty, tasty-hunit

Dependencies removed: hashable, text-show, unordered-containers

Dependency ranges changed: JuicyPixels, attoparsec, base, bytestring, directory, filepath, vector

API changes (from Hackage documentation)

- Codec.Picture.Blp.Internal.Convert: toBlpUncompressable :: DynamicImage -> Image PixelRGBA8
- Codec.Picture.Blp.Internal.Convert: toPngRepresentable :: DynamicImage -> DynamicImage
- Codec.Picture.Blp.Internal.Data: instance Data.Hashable.Class.Hashable Codec.Picture.Blp.Internal.Data.BlpCompression
- Codec.Picture.Blp.Internal.Data: instance Data.Hashable.Class.Hashable Codec.Picture.Blp.Internal.Data.BlpFlag
- Codec.Picture.Blp.Internal.Data: instance Data.Hashable.Class.Hashable Codec.Picture.Blp.Internal.Data.BlpPictureType
- Codec.Picture.Blp.Internal.Data: instance GHC.Enum.Bounded Codec.Picture.Blp.Internal.Data.BlpCompression
- Codec.Picture.Blp.Internal.Data: instance GHC.Enum.Bounded Codec.Picture.Blp.Internal.Data.BlpFlag
- Codec.Picture.Blp.Internal.Data: instance GHC.Enum.Bounded Codec.Picture.Blp.Internal.Data.BlpPictureType
- Codec.Picture.Blp.Internal.Data: instance GHC.Enum.Enum Codec.Picture.Blp.Internal.Data.BlpCompression
- Codec.Picture.Blp.Internal.Data: instance GHC.Enum.Enum Codec.Picture.Blp.Internal.Data.BlpFlag
- Codec.Picture.Blp.Internal.Data: instance GHC.Enum.Enum Codec.Picture.Blp.Internal.Data.BlpPictureType
- Codec.Picture.Blp.Internal.Data: instance GHC.Generics.Generic Codec.Picture.Blp.Internal.Data.BlpCompression
- Codec.Picture.Blp.Internal.Data: instance GHC.Generics.Generic Codec.Picture.Blp.Internal.Data.BlpExt
- Codec.Picture.Blp.Internal.Data: instance GHC.Generics.Generic Codec.Picture.Blp.Internal.Data.BlpFlag
- Codec.Picture.Blp.Internal.Data: instance GHC.Generics.Generic Codec.Picture.Blp.Internal.Data.BlpPictureType
- Codec.Picture.Blp.Internal.Data: instance GHC.Generics.Generic Codec.Picture.Blp.Internal.Data.BlpStruct
- Codec.Picture.Blp.Internal.Data: instance GHC.Show.Show Codec.Picture.Blp.Internal.Data.BlpCompression
- Codec.Picture.Blp.Internal.Data: instance GHC.Show.Show Codec.Picture.Blp.Internal.Data.BlpExt
- Codec.Picture.Blp.Internal.Data: instance GHC.Show.Show Codec.Picture.Blp.Internal.Data.BlpFlag
- Codec.Picture.Blp.Internal.Data: instance GHC.Show.Show Codec.Picture.Blp.Internal.Data.BlpPictureType
- Codec.Picture.Blp.Internal.Data: instance GHC.Show.Show Codec.Picture.Blp.Internal.Data.BlpStruct
- Codec.Picture.Blp.Internal.Data: instance TextShow.Classes.TextShow Codec.Picture.Blp.Internal.Data.BlpCompression
- Codec.Picture.Blp.Internal.Data: instance TextShow.Classes.TextShow Codec.Picture.Blp.Internal.Data.BlpExt
- Codec.Picture.Blp.Internal.Data: instance TextShow.Classes.TextShow Codec.Picture.Blp.Internal.Data.BlpFlag
- Codec.Picture.Blp.Internal.Data: instance TextShow.Classes.TextShow Codec.Picture.Blp.Internal.Data.BlpPictureType
- Codec.Picture.Blp.Internal.Data: instance TextShow.Classes.TextShow Codec.Picture.Blp.Internal.Data.BlpStruct
- Codec.Picture.Blp.Internal.Encoder: instance Foreign.Storable.Storable Codec.Picture.Types.PixelRGBA8
+ Codec.Picture.Blp.Internal.Convert: fromBlpJpeg :: Bool -> DynamicImage -> DynamicImage
+ Codec.Picture.Blp.Internal.Convert: jpegAlphaIsUsable :: DynamicImage -> Bool
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Enum.Bounded Codec.Picture.Blp.Internal.Data.BlpCompression
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Enum.Bounded Codec.Picture.Blp.Internal.Data.BlpFlag
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Enum.Bounded Codec.Picture.Blp.Internal.Data.BlpPictureType
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Enum.Enum Codec.Picture.Blp.Internal.Data.BlpCompression
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Enum.Enum Codec.Picture.Blp.Internal.Data.BlpFlag
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Enum.Enum Codec.Picture.Blp.Internal.Data.BlpPictureType
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Generics.Generic Codec.Picture.Blp.Internal.Data.BlpCompression
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Generics.Generic Codec.Picture.Blp.Internal.Data.BlpExt
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Generics.Generic Codec.Picture.Blp.Internal.Data.BlpFlag
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Generics.Generic Codec.Picture.Blp.Internal.Data.BlpPictureType
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Generics.Generic Codec.Picture.Blp.Internal.Data.BlpStruct
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Show.Show Codec.Picture.Blp.Internal.Data.BlpCompression
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Show.Show Codec.Picture.Blp.Internal.Data.BlpExt
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Show.Show Codec.Picture.Blp.Internal.Data.BlpFlag
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Show.Show Codec.Picture.Blp.Internal.Data.BlpPictureType
+ Codec.Picture.Blp.Internal.Data: instance GHC.Internal.Show.Show Codec.Picture.Blp.Internal.Data.BlpStruct
- Codec.Picture.Blp.Internal.Parser: blpParser :: Parser BlpStruct
+ Codec.Picture.Blp.Internal.Parser: blpParser :: Int -> Parser BlpStruct

Files

CHANGELOG.md view
@@ -1,3 +1,60 @@+0.3.0.0 (2026-10-15)+====================++Breaking changes:++- `encodeBlpUncompressedWithAlpha` always writes an alpha plane and+  `encodeBlpUncompressedWithoutAlpha` never does. Both used to pick by the+  input image, so the two were identical and `blp2any -b` had no effect.+- `decodeBlp` and `decodeBlpMipmaps` always return `ImageRGBA8`. JPEG BLPs+  whose stream is not four-component (greyscale, YCbCr) used to come back as+  `ImageY8` or `ImageRGB8`.+- Removed unused functions from `Codec.Picture.Blp.Internal.Convert`:+  `toPngRepresentable` and `toBlpUncompressable`.+- `mipMapsUpTo` counts every level down to the minimum side; it was one short+  and returned 0 or less for tiny images.+- Removed the `TextShow` and `Hashable` instances from+  `Codec.Picture.Blp.Internal.Data`, and with them the `text-show` and+  `hashable` dependencies. `blp2any` no longer depends on+  `unordered-containers`.++The encoder always writes mip 0; a mipmap count of zero or less used to+produce an unreadable file, which `blp2any` did for 1x1 images.++The uncompressed encoder no longer hands out ByteStrings over image memory it+does not keep alive, and no longer relies on an orphan, endianness-dependent+`Storable PixelRGBA8` instance.++Decoder robustness for BLP1 files found in the wild:++- Accept picture type 6 (WoW JPEG variant) and picture type 0 (#5, #10).+- Tolerate palettes shorter than 256 entries (#6).+- Decode files labelled with-alpha whose mipmap 0 has no alpha plane as+  without-alpha instead of crashing (#8).+- A corrupt JPEG mipmap no longer fails the whole file; the mipmap list is+  cut at the first broken level (#9).+- `decodeBlpMipmaps` renders uncompressed mipmaps at their own sizes instead of+  crashing; a mip 0 too short for the image is a `Left` (#13).+- Without-alpha textures decode opaque regardless of the palette's fourth+  byte, so the encoder's own output no longer comes back transparent (#14).+- Decoding no longer dumps the parsed structure to stderr (#15).+- The JPEG encoder writes plain BGRA planes instead of dividing colour by+  alpha, and a uniformly semi-transparent alpha plane is kept; only all-0x00+  or all-0xFF planes count as unpopulated (#17).+- The JPEG encoder shares one header between mipmaps; 1x1 textures no longer+  encode to a file without image data (#18).+- Skip mipmap slots that point past the end of the file or overlap an earlier+  one (#7).+- Decode JPEG planes as BGRA instead of CMYK (#11).++Support GHC 9.4 – 9.14: relax upper bounds for `attoparsec`, `bytestring`,+`hashable`, `text-show`, `vector` and `filepath`. Minimum supported GHC is now 9.4.++0.2.1.0+=======++Relax upper boundary for `text-show-3.9`.+ 0.2.0.0 ======= 
JuicyPixels-blp.cabal view
@@ -1,27 +1,38 @@+cabal-version:       2.2 name:                JuicyPixels-blp-version:             0.2.0.0+version:             0.3.0.0 synopsis:            BLP format decoder/encoder over JuicyPixels library description:   The library provides decoding/encoding functions for BLP1 texture format of Warcraft III game.   The result is represented in types of [JuicyPixels](http://hackage.haskell.org/package/JuicyPixels) library.    Please see README.md for detailed description.-homepage:            http://github.com/NCrashed/JuicyPixels-blp#readme-license:             BSD3+homepage:            https://github.com/NCrashed/JuicyPixels-blp#readme+license:             BSD-3-Clause license-file:        LICENSE author:              Anton Gushcha maintainer:          ncrashed@gmail.com-copyright:           2015-2018 Anton Gushcha+copyright:           2015-2026 Anton Gushcha category:            Codec, Graphics, Image build-type:          Simple-cabal-version:       >=1.10-extra-source-files:  README.md, CHANGELOG.md+extra-doc-files:     README.md, CHANGELOG.md+tested-with:+  GHC == 9.4.8+   || == 9.6.7+   || == 9.8.4+   || == 9.10.3+   || == 9.12.3+   || == 9.14.1  source-repository head   type:      git-  location:  git@github.com:NCrashed/JuicyPixels-blp.git+  location:  https://github.com/NCrashed/JuicyPixels-blp.git +common warnings+  ghc-options:         -Wall -Wcompat+ library+  import:              warnings   hs-source-dirs:      src   exposed-modules:     Codec.Picture.Blp@@ -32,14 +43,12 @@    default-language:    Haskell2010   build-depends:-      base                  >= 4.7    && < 5-    , attoparsec            >= 0.12   && < 0.14+      base                  >= 4.17   && < 5+    , attoparsec            >= 0.12   && < 0.15     , binary                >= 0.8    && < 0.10-    , bytestring            >= 0.10   && < 0.11-    , hashable              >= 1.2    && < 1.4+    , bytestring            >= 0.10   && < 0.13     , JuicyPixels           >= 3.2    && < 3.4-    , text-show             >= 3.7    && < 3.9-    , vector                >= 0.10   && < 0.13+    , vector                >= 0.10   && < 0.14    default-extensions:     BangPatterns@@ -49,6 +58,7 @@     RecordWildCards  executable blp2any+  import:              warnings   hs-source-dirs:      blp2any   main-is:             Main.hs   other-modules:@@ -58,15 +68,36 @@   ghc-options:       -threaded -rtsopts -with-rtsopts=-N   default-language:    Haskell2010   build-depends:-      base                  >= 4.7    && < 5+      base     , bytestring+    , containers            >= 0.6    && < 0.9     , directory             >= 1.2    && < 1.4-    , filepath              >= 1.4    && < 1.5+    , filepath              >= 1.4    && < 1.6     , JuicyPixels     , JuicyPixels-blp     , optparse-simple       >= 0.0.3  && < 0.2-    , text-show-    , unordered-containers  >= 0.2    && < 0.3    default-extensions:+    RecordWildCards++test-suite spec+  import:              warnings+  type:                exitcode-stdio-1.0+  hs-source-dirs:      test+  main-is:             Main.hs+  other-modules:+    Fixture+  default-language:    Haskell2010+  build-depends:+      base+    , bytestring+    , directory+    , filepath+    , JuicyPixels+    , JuicyPixels-blp+    , tasty                 >= 1.4    && < 1.6+    , tasty-hunit           >= 0.10   && < 0.11++  default-extensions:+    OverloadedStrings     RecordWildCards
README.md view
@@ -1,7 +1,7 @@ JuicyPixels-blp =============== -[![Build Status](https://travis-ci.org/NCrashed/JuicyPixels-blp.svg?branch=master)](https://travis-ci.org/NCrashed/JuicyPixels-blp)+[![CI](https://github.com/NCrashed/JuicyPixels-blp/actions/workflows/ci.yml/badge.svg)](https://github.com/NCrashed/JuicyPixels-blp/actions/workflows/ci.yml)  The library provides decoding/encoding functions for BLP1 texture format of Warcraft III game. The result is represented in types of [JuicyPixels](http://hackage.haskell.org/package/JuicyPixels) library.
blp2any/Convert.hs view
@@ -12,7 +12,6 @@ import Control.Monad import Data.Char import Data.Maybe-import Data.Monoid import System.Directory import System.FilePath 
blp2any/Main.hs view
@@ -1,6 +1,5 @@ module Main where -import Data.Monoid import Options.Applicative.Simple  import Statistics
blp2any/Statistics.hs view
@@ -12,7 +12,7 @@ import System.FilePath  import qualified Data.ByteString as BS-import qualified Data.HashMap.Strict as H +import qualified Data.Map.Strict as M import qualified Data.List as L   import File @@ -20,13 +20,13 @@ type BlpInfo = (BlpCompression, [BlpFlag], BlpPictureType, Word32) type BlpStat = (FilePath, Int) -type StatMap = H.HashMap BlpInfo BlpStat+type StatMap = M.Map BlpInfo BlpStat  collectSamples :: FilePath -> IO StatMap collectSamples dir = do    f <- doesDirectoryExist dir   unless f $ fail $ "Provided directory " ++ dir ++ " doesn't exists!"-  forEachFile dir ((".blp" ==).takeExtension) collectSample H.empty+  forEachFile dir ((".blp" ==).takeExtension) collectSample M.empty    collectSample :: StatMap -> FilePath -> IO StatMap  collectSample stats inputFile = do @@ -40,7 +40,7 @@ blpType BlpStruct{..} = (blpCompression, blpFlags, blpPictureType, blpPictureSubType)  adjust' :: (Maybe BlpStat -> BlpStat) -> BlpInfo -> StatMap -> StatMap -adjust' f k m = H.insert k (f $ H.lookup k m) m+adjust' f k m = M.insert k (f $ M.lookup k m) m  updateStat :: FilePath -> Maybe BlpStat -> BlpStat  updateStat path Nothing = (path, 1)@@ -49,7 +49,7 @@ printStatistics :: FilePath -> StatMap -> IO ()  printStatistics dir stats = do     createDirectoryIfMissing True dir-    forM_ (H.toList stats) $ \((compression, flags, pictureType, pictureSubType), (path, i)) -> do +    forM_ (M.toList stats) $ \((compression, flags, pictureType, pictureSubType), (path, i)) -> do        let postfix = L.intercalate "-" [show compression, show flags, show pictureType, show pictureSubType]       copyFile path $ dir ++ "/" ++ dropExtension (takeFileName path) ++ "-"         ++ postfix ++ ".blp"
src/Codec/Picture/Blp.hs view
@@ -1,8 +1,30 @@+{-|+Decoding and encoding of BLP1, the texture format of Warcraft III, on top of+JuicyPixels.++BLP1 comes in two flavours: JPEG compressed and uncompressed with a 256+colour palette. Uncompressed textures either carry an alpha plane or not.+A file holds the full size image followed by its mipmaps, each half the size+of the previous one.++Decoding always yields 'ImageRGBA8'. Files from the wild are often slightly+off spec; the decoder accepts the common deviations and, when a mipmap is+damaged, keeps the levels before it.++> Right img <- readBlp "war3mapMap.blp"+> writePng "war3mapMap.png" (convertRGBA8 img)+-} module Codec.Picture.Blp(+  -- * Decoding     readBlp   , readBlpMipmaps   , decodeBlp   , decodeBlpMipmaps+  -- * Encoding+  -- | The mipmap count passed to the encoders is the number of levels to+  -- write, mip 0 included. It is clamped to at least one, and asking for+  -- more levels than the image has stops at the level where a side reaches+  -- one pixel. 'mipMapsUpTo' computes it from a minimum side length.   , writeBlpJpeg   , writeBlpUncompressedWithAlpha   , writeBlpUncompressedWithoutAlpha@@ -13,11 +35,8 @@   ) where  import Codec.Picture-import Codec.Picture.Types+import Data.Bits (shiftR) import Data.ByteString (ByteString)-import Data.Monoid-import Data.Word-import TextShow.Debug.Trace  import qualified Data.ByteString as BS import qualified Data.ByteString.Lazy as BSL@@ -28,91 +47,147 @@ import Codec.Picture.Blp.Internal.Encoder import Codec.Picture.Blp.Internal.Parser --- | Read BLP from given file without mipmaps+-- | Read a BLP file and decode its full size image. IO errors, such as a+-- missing file, are thrown; malformed contents give 'Left'. readBlp :: FilePath -> IO (Either String DynamicImage) readBlp fp = decodeBlp `fmap` BS.readFile fp --- | Read BLP from given file with mipmaps+-- | Read a BLP file and decode the full size image followed by its mipmaps.+-- See 'decodeBlpMipmaps'. readBlpMipmaps :: FilePath -> IO (Either String [DynamicImage]) readBlpMipmaps fp = decodeBlpMipmaps `fmap` BS.readFile fp --- | Decodes BLP without mipmaps+-- | Decode the full size image of a BLP file. The result is 'ImageRGBA8'. decodeBlp :: ByteString -> Either String DynamicImage decodeBlp bs = do   is <- decodeBlpMipmaps bs-  if null is then Left "No data in BLP"-    else return $ head is+  case is of+    i : _ -> Right i+    [] -> Left "No data in BLP" --- | Decodes BLP and returns original image plus all mipmaps+-- | Decode the full size image followed by its mipmaps, all 'ImageRGBA8'.+--+-- Element @ is always mip level @. Mipmaps are decoded lazily, and the+-- list stops at the first one that is damaged, so a broken mipmap never+-- costs the levels before it. A damaged full size image fails the file. decodeBlpMipmaps :: ByteString -> Either String [DynamicImage] decodeBlpMipmaps bs = do   blp <- parseBlp bs-  case blpExt $ traceTextShowId blp of+  let width = fromIntegral $ blpWidth blp+      height = fromIntegral $ blpHeight blp+  case blpExt blp of     BlpJpeg {..} -> do       let jpegs = (blpJpegHeader <>) `fmap` blpJpegData-      mips <- mapM decodeJpeg jpegs-      return $ toPngRepresentable <$> mips+      case jpegs of+        [] -> Left "No JPEG mipmap data in BLP"+        (j0:js) -> do+          -- Mipmap 0 is the full-resolution image; single-image callers+          -- (`decodeBlp`) only ever take `head` of the result. Decoding it+          -- strictly means a failure here still fails the whole file as+          -- before. Tail mipmaps are decoded lazily and the chain stops at the+          -- first one that fails, so a corrupt tail can no longer poison the+          -- main image, and the result stays a prefix of the mip levels+          -- (index i is always level i).+          mip0 <- decodeJpeg j0+          let validPrefix (j:rest) | Right m <- decodeJpeg j = m : validPrefix rest+              validPrefix _ = []+              lazyTail = validPrefix js+              -- The alpha decision is taken once, from the full size mipmap, so that+              -- every mipmap of a texture is decoded the same way. Smaller mipmaps+              -- are often padded out with a flat alpha plane of their own.+              useAlpha = BlpFlagAlphaChannel `elem` blpFlags blp && jpegAlphaIsUsable mip0+          Right $ fromBlpJpeg useAlpha <$> (mip0 : lazyTail) -    BlpUncompressed1 {..} -> do-      let mkImage mip = ImageRGBA8 $ generateImage (gen mip) (fromIntegral $ blpWidth blp) (fromIntegral $ blpHeight blp)-      return $ mkImage `fmap` blpU1MipMaps+    BlpUncompressed1 {..} -> uncompressedLevels width height fits render blpU1MipMaps       where-      palette :: Word8 -> PixelRGBA8-      palette i = blpU1Palette V.! fromIntegral i--      makeIndex x y = y*(fromIntegral $ blpWidth blp)+x-      takeColor mip x y = palette $ fst mip `BS.index` makeIndex x y-      takeAlpha mip x y = snd mip `BS.index` makeIndex x y-      gen mip x y = let-        PixelRGBA8 r g b _ = takeColor mip x y-        a = takeAlpha mip x y-        in PixelRGBA8 b g r a+      fits n (indexList, alphaList) = BS.length indexList >= n && BS.length alphaList >= n+      render w (indexList, alphaList) x y = let+        i = y * w + x+        PixelRGBA8 r g b _ = blpU1Palette V.! fromIntegral (indexList `BS.index` i)+        in PixelRGBA8 b g r (alphaList `BS.index` i) -    BlpUncompressed2 {..} -> do-      let mkImage mip = ImageRGBA8 $ generateImage (gen mip) (fromIntegral $ blpWidth blp) (fromIntegral $ blpHeight blp)-      return $ mkImage `fmap` blpU2MipMaps+    BlpUncompressed2 {..} -> uncompressedLevels width height fits render blpU2MipMaps       where-      palette :: Word8 -> PixelRGBA8-      palette i = blpU2Palette V.! fromIntegral i+      fits n indexList = BS.length indexList >= n+      render w indexList x y = let+        PixelRGBA8 r g b _ = blpU2Palette V.! fromIntegral (indexList `BS.index` (y * w + x))+        -- Without-alpha textures have no alpha to decode; the palette's fourth+        -- byte is not alpha and tools fill it inconsistently.+        in PixelRGBA8 b g r 255 -      makeIndex x y = y*(fromIntegral $ blpWidth blp)+x-      takeColor mip x y = palette $ mip `BS.index` makeIndex x y-      gen mip x y = let-        PixelRGBA8 r g b a = takeColor mip x y-        in PixelRGBA8 b g r (255 - a)+-- | Render the mipmaps of an uncompressed BLP. Level @l@ of a @w@ x @h@+-- texture is @w / 2^l@ by @h / 2^l@, but never smaller than 1x1. The list+-- stops at the first level whose buffer is too short for its size, so the+-- result is always a prefix of the mip levels; a short level 0 fails the file.+uncompressedLevels :: Int -> Int+  -> (Int -> mip -> Bool) -- ^ Does the buffer hold this many pixels?+  -> (Int -> mip -> Int -> Int -> PixelRGBA8) -- ^ Pixel at (x, y) of a level of given width+  -> [mip]+  -> Either String [DynamicImage]+uncompressedLevels width height fits render mips+  | null mips = Right []+  | otherwise = case go 0 mips of+      [] -> Left "Mipmap 0 is too short for the image size"+      levels -> Right levels+  where+    go l (mip : rest)+      | fits (w * h) mip = ImageRGBA8 (generateImage (render w mip) w h) : go (l + 1) rest+      where+        w = levelSide width l+        h = levelSide height l+    go _ _ = []+    levelSide side l = if side <= 0 then 0 else max 1 (side `shiftR` l) --- | Calculate needed count of mipmaps to cover sizes up to given minimum size (helper for 'writeBlp*' functions)-mipMapsUpTo :: Int -- ^ Minimum size of picture side for generation of mipmap (ex. 2, 4, 16, 32, 64, 512, 1024 and etc)-  -> DynamicImage -- ^ Image for which we generate mipmaps+-- | Number of mip levels, mip 0 included, whose smaller side is at least the+-- given length; never less than one. Pass it to the encoders as the mipmap+-- count. A 16x16 texture has five levels down to 1 pixel and three down to+-- 4 pixels.+mipMapsUpTo :: Int -- ^ Smallest side length to keep (2, 4, 16, ...)+  -> DynamicImage -- ^ Image the mipmaps are made for   -> Int-mipMapsUpTo minSize img = maxNum - minSizeNum+mipMapsUpTo minSize img = max 1 . length . takeWhile (>= max 1 minSize) $ iterate (`div` 2) minSide   where-    w = dynamicMap imageWidth img-    h = dynamicMap imageHeight img-    minSide = min w h-    maxNum = ceiling $ logBase 2 (fromIntegral minSide)-    minSizeNum = floor $ logBase 2 (fromIntegral (max 1 minSize))+    minSide = min (dynamicMap imageWidth img) (dynamicMap imageHeight img) +-- | Encode with 'encodeBlpJpeg' and write the result to a file. writeBlpJpeg :: FilePath -> Int -> Int -> DynamicImage -> IO () writeBlpJpeg fp quality numMips img = do   let bs = encodeBlpJpeg quality numMips img   BSL.writeFile fp bs +-- | Encode with 'encodeBlpUncompressedWithAlpha' and write the result to a file. writeBlpUncompressedWithAlpha :: FilePath -> Int -> DynamicImage -> IO () writeBlpUncompressedWithAlpha fp numMips img = do   let bs = encodeBlpUncompressedWithAlpha numMips img   BSL.writeFile fp bs +-- | Encode with 'encodeBlpUncompressedWithoutAlpha' and write the result to a+-- file. writeBlpUncompressedWithoutAlpha :: FilePath -> Int -> DynamicImage -> IO () writeBlpUncompressedWithoutAlpha fp numMips img = do   let bs = encodeBlpUncompressedWithoutAlpha numMips img   BSL.writeFile fp bs -encodeBlpJpeg :: Int -> Int -> DynamicImage -> BSL.ByteString-encodeBlpJpeg quality numMips = encodeBlp numMips {-. traceTextShowId-} . toBlpStruct BlpCompressionJPEG quality numMips+-- | Encode as a JPEG compressed BLP. The texture gets an alpha plane when the+-- input image has an alpha channel.+encodeBlpJpeg :: Int -- ^ JPEG quality, 0 to 100+  -> Int -- ^ Number of mip levels to write+  -> DynamicImage+  -> BSL.ByteString+encodeBlpJpeg quality numMips = encodeBlp numMips . toBlpStruct BlpCompressionJPEG quality numMips -encodeBlpUncompressedWithAlpha :: Int -> DynamicImage -> BSL.ByteString-encodeBlpUncompressedWithAlpha numMips = encodeBlp numMips {-. traceTextShowId-} . toBlpStruct BlpCompressionUncompressed 100 numMips+-- | Encode as an uncompressed paletted BLP with an alpha plane. An input+-- without alpha gets a fully opaque plane. Colours are quantised to 256.+encodeBlpUncompressedWithAlpha :: Int -- ^ Number of mip levels to write+  -> DynamicImage+  -> BSL.ByteString+encodeBlpUncompressedWithAlpha numMips =+  encodeBlp numMips . toBlpStruct BlpCompressionUncompressed 100 numMips . ImageRGBA8 . convertRGBA8 -encodeBlpUncompressedWithoutAlpha :: Int -> DynamicImage -> BSL.ByteString-encodeBlpUncompressedWithoutAlpha numMips = encodeBlp numMips {-. traceTextShowId-} . toBlpStruct BlpCompressionUncompressed 100 numMips+-- | Encode as an uncompressed paletted BLP without alpha. The alpha channel+-- of the input, if any, is dropped. Colours are quantised to 256.+encodeBlpUncompressedWithoutAlpha :: Int -- ^ Number of mip levels to write+  -> DynamicImage+  -> BSL.ByteString+encodeBlpUncompressedWithoutAlpha numMips =+  encodeBlp numMips . toBlpStruct BlpCompressionUncompressed 100 numMips . ImageRGB8 . convertRGB8
src/Codec/Picture/Blp/Internal/Convert.hs view
@@ -1,118 +1,78 @@ module Codec.Picture.Blp.Internal.Convert(-    toPngRepresentable-  , toBlpUncompressable+    fromBlpJpeg+  , jpegAlphaIsUsable   , toBlpCMYK8   ) where  import Codec.Picture-import Codec.Picture.Types+import Codec.Picture.Types (pixelFold) import Data.Word -toPngRepresentable :: DynamicImage -> DynamicImage-toPngRepresentable i = case i of-  ImageY8 _ -> i-  ImageY16 _ -> i-  ImageYF p -> ImageRGB16 . convertFloatImage16 . promoteImage $ p-  ImageYA8 _ -> i-  ImageYA16 _ -> i-  ImageRGB8 _ -> i-  ImageRGB16 _ -> i-  ImageRGBF p -> ImageRGB16 . convertFloatImage16 $ p-  ImageRGBA8 _ -> i-  ImageRGBA16 _ -> i-  ImageYCbCr8 p -> ImageRGB8 . convertImage $ p-  ImageCMYK8 p -> ImageRGBA8 . convertCMYK8Image $ p-  ImageCMYK16 p -> ImageRGBA16 . convertCMYK16Image $ p--toBlpUncompressable :: DynamicImage -> Image PixelRGBA8-toBlpUncompressable i = case i of-  ImageY8 p -> toBlpRGB8 (promoteImage p :: Image PixelRGB8)-  ImageY16 p -> toBlpRGB8 (dropBits (promoteImage p :: Image PixelRGB16) :: Image PixelRGB8)-  ImageYF p -> toBlpRGB8 (convertFloatImage8 (promoteImage p :: Image PixelRGBF) :: Image PixelRGB8)-  ImageYA8 p -> toBlpRGBA8 (promoteImage p :: Image PixelRGBA8)-  ImageYA16 p -> toBlpRGBA8 (dropBitsA (promoteImage p :: Image PixelRGBA16) :: Image PixelRGBA8)-  ImageRGB8 p -> toBlpRGB8 p-  ImageRGB16 p -> toBlpRGB8 (dropBits p :: Image PixelRGB8)-  ImageRGBF p -> toBlpRGB8 (convertFloatImage8 p :: Image PixelRGB8)-  ImageRGBA8 p -> toBlpRGBA8 p-  ImageRGBA16 p -> toBlpRGBA8 (dropBitsA p :: Image PixelRGBA8)-  ImageYCbCr8 p -> toBlpRGB8 (convertImage p :: Image PixelRGB8)-  ImageCMYK8 p -> toBlpRGB8 (convertImage p :: Image PixelRGB8)-  ImageCMYK16 p -> toBlpRGB8 (dropBits (convertImage p :: Image PixelRGB16) :: Image PixelRGB8)--dropBits :: Image PixelRGB16 -> Image PixelRGB8-dropBits = pixelMap $ \(PixelRGB16 r g b) -> PixelRGB8 (f r) (f g) (f b)-  where-    f :: Word16 -> Word8-    f x = round $ 255 * (fromIntegral x :: Double) / 65535+-- | Convert a decoded mipmap of a JPEG compressed BLP1 into an image that can+-- be written out directly.+--+-- The four components of a BLP1 JPEG are the raw B, G, R and A planes of the+-- texture. The stream carries no Adobe marker, so a JPEG decoder has nothing+-- to go on and reports it as CMYK; running the generic CMYK conversion over+-- it applies an ink model to data that is not ink, and multiplies every+-- colour channel by the alpha plane. Map the planes straight across instead.+--+-- The 'Bool' says whether the alpha plane should be honoured; see+-- 'jpegAlphaIsUsable'. When it is 'False' the texture is made fully opaque.+-- Any other kind of JPEG (greyscale, YCbCr) is converted to RGBA as usual.+fromBlpJpeg :: Bool -> DynamicImage -> DynamicImage+fromBlpJpeg useAlpha i = case i of+  ImageCMYK8 p ->+    let rgba = blpJpegToRGBA8 p+    in ImageRGBA8 $ if useAlpha then rgba else forceOpaque rgba+  _ -> ImageRGBA8 $ convertRGBA8 i -dropBitsA :: Image PixelRGBA16 -> Image PixelRGBA8-dropBitsA = pixelMap $ \(PixelRGBA16 r g b a) -> PixelRGBA8 (f r) (f g) (f b) (f a)-  where-    f :: Word16 -> Word8-    f x = round $ 255 * (fromIntegral x :: Double) / 65535+-- | Whether the alpha plane of a decoded BLP1 JPEG mipmap carries information.+--+-- Warcraft III authoring tools disagree about the alpha plane of a JPEG+-- compressed BLP1. Some set the alpha flag and fill the plane with 0x00,+-- others with 0xFF, and both mean the same thing: the texture is opaque and+-- the plane was never populated. Taking either at face value gives a wrong+-- answer for half the files in the wild, so a plane that is entirely 0x00 or+-- entirely 0xFF is treated as carrying no information at all.+--+-- Any other plane, including one that is uniformly semi-transparent, is used+-- as it stands.+jpegAlphaIsUsable :: DynamicImage -> Bool+jpegAlphaIsUsable i = case i of+  ImageCMYK8 p -> case uniformAlpha (blpJpegToRGBA8 p) of+    Just a -> a /= 0 && a /= 255+    Nothing -> True+  _ -> True -convertFloatImage8 :: Image PixelRGBF -> Image PixelRGB8-convertFloatImage8 = pixelMap convert+-- | The alpha value every pixel of an image carries, if they all agree. An+-- empty image counts as uniformly opaque.+uniformAlpha :: Image PixelRGBA8 -> Maybe Word8+uniformAlpha img+  | imageWidth img <= 0 || imageHeight img <= 0 = Just 255+  | pixelFold same True img = Just a0+  | otherwise = Nothing   where-    convert (PixelRGBF rf gf bf) = PixelRGB8-      (round $ 255 * rf)-      (round $ 255 * gf)-      (round $ 255 * bf)+    PixelRGBA8 _ _ _ a0 = pixelAt img 0 0+    same acc _ _ (PixelRGBA8 _ _ _ a) = acc && a == a0 -convertFloatImage16 :: Image PixelRGBF -> Image PixelRGB16-convertFloatImage16 = pixelMap convert+-- | Reinterpret the four planes a JPEG decoder labelled CMYK as the BGRA they+-- actually are. This inverts 'toBlpCMYK8'.+blpJpegToRGBA8 :: Image PixelCMYK8 -> Image PixelRGBA8+blpJpegToRGBA8 = pixelMap convert   where-    convert (PixelRGBF rf gf bf) = PixelRGB16-      (round $ 65535 * rf)-      (round $ 65535 * gf)-      (round $ 65535 * bf)+  convert (PixelCMYK8 c m y k) = PixelRGBA8 y m c k -convertCMYK8Image :: Image PixelCMYK8 -> Image PixelRGBA8-convertCMYK8Image = pixelMap convert+forceOpaque :: Image PixelRGBA8 -> Image PixelRGBA8+forceOpaque = pixelMap convert   where-  clampWord8 = fromIntegral . max 0 . min 255 . (`div` 256)-  convert (PixelCMYK8 c m y k) =-      PixelRGBA8 (clampWord8 r) (clampWord8 g) (clampWord8 b) k-    where-    ik :: Int-    ik = fromIntegral k-    r = fromIntegral y * ik-    g = fromIntegral m * ik-    b = fromIntegral c * ik+  convert (PixelRGBA8 r g b _) = PixelRGBA8 r g b 255 +-- | Lay out the B, G, R and A planes of a texture as the four components of+-- a BLP1 JPEG. The encoder labels them CMYK only because that is the+-- four-component JPEG it can write; this is the exact inverse of+-- @blpJpegToRGBA8@. toBlpCMYK8 :: Image PixelRGBA8 -> Image PixelCMYK8 toBlpCMYK8 = pixelMap convert   where-  clampWord8 = fromIntegral . max 0 . min 255-  convert (PixelRGBA8 r g b a) = PixelCMYK8 (clampWord8 c) (clampWord8 m) (clampWord8 y) a-    where-    ik = fromIntegral a :: Double-    c, m, y :: Int-    c = round $ 256 * fromIntegral b / ik-    m = round $ 256 * fromIntegral g / ik-    y = round $ 256 * fromIntegral r / ik--toBlpRGBA8 :: Image PixelRGBA8 -> Image PixelRGBA8-toBlpRGBA8 = pixelMap convert-  where-  convert (PixelRGBA8 r g b a) = PixelRGBA8 b g r a--toBlpRGB8 :: Image PixelRGB8 -> Image PixelRGBA8-toBlpRGB8 = pixelMap convert-  where-  convert (PixelRGB8 r g b) = PixelRGBA8 b g r 0--convertCMYK16Image :: Image PixelCMYK16 -> Image PixelRGBA16-convertCMYK16Image = pixelMap convert-  where-  convert (PixelCMYK16 c m y k) =-      PixelRGBA16 (clampWord16 r) (clampWord16 g) (clampWord16 b) 65535-    where-    clampWord16 = fromIntegral . max 0 . min 65535 . (`div` 65535)--    ik :: Int-    ik = fromIntegral k-    r = fromIntegral y * ik-    g = fromIntegral m * ik-    b = fromIntegral c * ik+  convert (PixelRGBA8 r g b a) = PixelCMYK8 b g r a
src/Codec/Picture/Blp/Internal/Data.hs view
@@ -7,18 +7,11 @@   ) where  import Codec.Picture-import Data.Bifunctor import Data.ByteString (ByteString)-import Data.Hashable-import Data.Monoid import Data.Vector (Vector) import Data.Word import GHC.Generics-import TextShow -import qualified Data.ByteString as BS-import qualified Data.Vector as V- data BlpStruct = BlpStruct {   blpCompression :: !BlpCompression , blpFlags :: ![BlpFlag]@@ -29,17 +22,6 @@ , blpExt :: BlpExt } deriving (Show, Generic) -instance TextShow BlpStruct where-  showb BlpStruct{..} = "BlpStruct {\n"-    <> "  blpCompression = " <> showb blpCompression <> "\n"-    <> ", blpFlags = " <> showb blpFlags <> "\n"-    <> ", blpWidth = " <> showb blpWidth <> "\n"-    <> ", blpHeight = " <> showb blpHeight <> "\n"-    <> ", blpPictureType = " <> showb blpPictureType <> "\n"-    <> ", blpPictureSubType = " <> showb blpPictureSubType <> "\n"-    <> ", blpExt = " <> showb blpExt <> "\n"-    <> "}"- data BlpExt =     BlpJpeg {       blpJpegHeader :: !ByteString@@ -55,70 +37,16 @@     }   deriving (Show, Generic) --- | Helper to display bytestring as placeholder with length-showBinary :: ByteString -> Builder-showBinary v = "<bslength " <> tl v <> ">"-  where-    tl = showb . BS.length---- | Helper to display bytestring as placeholder with length-showVector :: Vector a -> Builder-showVector v = "<veclength " <> tl v <> ">"-  where-    tl = showb . V.length---- | Helper that works as Data.Text.intercalate-intercalateb :: Builder -> [Builder] -> Builder-intercalateb _ [] = mempty-intercalateb _ [x] = x -intercalateb spacer (x:xs) = x <> spacer <> intercalateb spacer xs--instance TextShow BlpExt where-  showb v = case v of-    BlpJpeg{..} -> "BlpJpeg { \n"-      <> "  blpJpegHeader = " <> showBinary blpJpegHeader <> "\n"-      <> ", blpJpegData = [" <> intercalateb ", " (fmap showBinary blpJpegData) <> "]\n"-      <> "}"-    BlpUncompressed1{..} -> "BlpUncompressed1 { \n"-      <> "  blpU1Palette = " <> showVector blpU1Palette <> "\n"-      <> ", blpU1MipMaps = [" <> intercalateb ", " ( (\(a, b) -> "(" <> showBinary a <> ", " <> showBinary b <> ")") <$> blpU1MipMaps) <> "]\n"-      <> "}"-    BlpUncompressed2{..} -> "BlpUncompressed2 { \n"-      <> "  blpU2Palette = " <> showVector blpU2Palette <> "\n"-      <> ", blpU2MipMaps = [" <> intercalateb ", " (fmap showBinary blpU2MipMaps) <> "]\n"-      <> "}"- data BlpCompression =     BlpCompressionJPEG   | BlpCompressionUncompressed   deriving (Eq, Ord, Enum, Bounded, Show, Generic) -instance TextShow BlpCompression where-  showb v = case v of-    BlpCompressionJPEG -> "BlpCompressionJPEG"-    BlpCompressionUncompressed -> "BlpCompressionUncompressed"--instance Hashable BlpCompression- data BlpFlag = BlpFlagAlphaChannel   deriving (Eq, Ord, Enum, Bounded, Show, Generic) -instance TextShow BlpFlag where-  showb v = case v of-    BlpFlagAlphaChannel -> "BlpFlagAlphaChannel"--instance Hashable BlpFlag- data BlpPictureType =     JPEGType   | UncompressedWithAlpha   | UncompressedWithoutAlpha   deriving (Eq, Ord, Enum, Bounded, Show, Generic)--instance TextShow BlpPictureType where-  showb v = case v of-    JPEGType -> "JPEGType"-    UncompressedWithAlpha -> "UncompressedWithAlpha"-    UncompressedWithoutAlpha -> "UncompressedWithoutAlpha"--instance Hashable BlpPictureType
src/Codec/Picture/Blp/Internal/Encoder.hs view
@@ -16,15 +16,11 @@ import Data.Bits import Data.ByteString.Lazy (ByteString, toStrict) import Data.Foldable (traverse_)-import Data.Maybe-import Data.Monoid import Data.Word-import Foreign-import System.IO.Unsafe (unsafePerformIO)  import qualified Codec.Picture.Metadata as CM import qualified Data.ByteString as BS-import qualified Data.ByteString.Unsafe as BS+import qualified Data.ByteString.Internal as BI import qualified Data.Foldable as F import qualified Data.Vector as V import qualified Data.Vector.Storable as VS@@ -38,7 +34,7 @@  -- | Raw encoder for BLP blpEncoder :: Int -> BlpStruct -> Put-blpEncoder numMips BlpStruct{..} = do+blpEncoder requestedMips BlpStruct{..} = do   putBlpVersion   putCompression   putFlags@@ -50,6 +46,8 @@   putMipMapSizes   putBlpExt   where+    -- A file without mip 0 has no image at all, so always write it.+    numMips = max 1 requestedMips     putBlpVersion = putByteString "BLP1"     putCompression = putWord32le $ case blpCompression of       BlpCompressionJPEG -> 0@@ -187,25 +185,25 @@ -- | Convert picture to BLP payload toBlpExt :: BlpCompression -> BlpPictureType -> Int -> Int -> Image PixelRGBA8 -> BlpExt toBlpExt compr pictype quality numMips img = case compr of-  BlpCompressionJPEG -> toBlpJpg (fromIntegral quality) numMips hasAlpha img+  BlpCompressionJPEG -> toBlpJpg (fromIntegral quality) numMips keepAlpha img   BlpCompressionUncompressed -> case pictype of     UncompressedWithAlpha -> toBlpUncompressed1 numMips img     UncompressedWithoutAlpha -> toBlpUncompressed2 numMips img     JPEGType -> toBlpUncompressed2 numMips img -- Consider this as without alpha   where-    hasAlpha = case pictype of+    keepAlpha = case pictype of       UncompressedWithAlpha -> True       _ -> False  -- | Convert picture to BLP JPEG and create mipmaps toBlpJpg :: Word8 -> Int -> Bool -> Image PixelRGBA8 -> BlpExt-toBlpJpg quality numMips hasAlpha img = BlpJpeg {+toBlpJpg quality numMips keepAlpha img = BlpJpeg {     blpJpegHeader = header   , blpJpegData = mipmapsRawWithoutHeader   }   where     processAlpha :: Image PixelRGBA8 -> Image PixelRGBA8-    processAlpha = pixelMap $ \p@(PixelRGBA8 r g b a) -> if hasAlpha then p else PixelRGBA8 r g b 255+    processAlpha = pixelMap $ \p@(PixelRGBA8 r g b _) -> if keepAlpha then p else PixelRGBA8 r g b 255      mipmaps :: [Image PixelCMYK8]     mipmaps = toBlpCMYK8 <$> (fakeMipMaps numMips . createMipMaps . processAlpha $ img)@@ -225,42 +223,31 @@ -- | Manually scan shared prefix of each mipmap scanHeader :: Int -> [BS.ByteString] -> BS.ByteString scanHeader _ [] = mempty-scanHeader maxheader [x] = BS.take maxheader x-scanHeader maxheader mipmaps = go mipmaps mempty+scanHeader maxheader mipmaps@(m0 : _) = BS.take common m0   where-    go !mps !acc = let-      unconses = BS.uncons <$> mps :: [Maybe (Word8, BS.ByteString)]-      heads = fmap fst <$> unconses :: [Maybe Word8]-      tails = catMaybes $ fmap snd <$> unconses :: [BS.ByteString]-      hitEmpty = any isNothing heads-      firstByte = case heads of-        (Just v : _) -> v-        _ -> 0-      allEqual = all (Just firstByte ==) heads-      in if | hitEmpty -> acc-            | maxheader >= BS.length acc -> acc-            | allEqual -> go tails (acc `BS.snoc` firstByte)-            | otherwise -> acc+    -- Each mipmap keeps at least one byte of its own: faked mipmaps are+    -- copies of a real one, and a header swallowing a whole stream would+    -- leave a zero-size slot that readers skip.+    limit = min maxheader (minimum (BS.length <$> mipmaps) - 1)+    common = length . takeWhile sameAt $ [0 .. limit - 1]+    -- Safe to index: every stream is longer than the limit.+    sameAt i = all (\m -> BS.index m i == BS.index m0 i) mipmaps  -- | Helper to quantise colors to 256 colour pallete makePallette :: Image PixelRGBA8 -> (Image Pixel8, Palette) makePallette = palettize defaultPaletteOptions . pixelMap dropTransparency -instance Storable PixelRGBA8 where-  sizeOf _ = sizeOf (undefined :: Word32)-  alignment _ = alignment (undefined :: Word32)-  peek ptr = unpackPixel <$> peek (castPtr ptr :: Ptr Word32)-  poke ptr px = poke (castPtr ptr :: Ptr Word32) . packPixel $ px- -- | Convert palette to format that we need for BLP convertPalette :: Palette -> V.Vector PixelRGBA8-convertPalette = V.convert . VS.unsafeCast . imageData . pixelMap ((\(PixelRGBA8 r g b a) -> PixelRGBA8 b g r a) . promotePixel :: PixelRGB8 -> PixelRGBA8)+convertPalette palette = V.generate (imageWidth palette) $ \i ->+  let PixelRGB8 r g b = pixelAt palette i 0 in PixelRGBA8 b g r 255  -- | Convert indexed image to raw bytestring convertIndexed :: Image Pixel8 -> BS.ByteString convertIndexed img = let   (fptr, l) = VS.unsafeToForeignPtr0 . imageData $ img-  in unsafePerformIO $ withForeignPtr fptr $ \ptr -> BS.unsafePackCStringLen (castPtr ptr, l)+  -- Shares the image buffer without copying; the ByteString keeps it alive.+  in BI.fromForeignPtr fptr 0 l  -- | Convert picture to BLP uncompressed with alpha and create mipmaps toBlpUncompressed1 :: Int -> Image PixelRGBA8 -> BlpExt
src/Codec/Picture/Blp/Internal/Parser.hs view
@@ -18,7 +18,7 @@ import Data.Attoparsec.ByteString as AT import Data.Bits import Data.ByteString (ByteString)-import Data.List (nub)+import Data.List (nub, sortOn) import Data.Word  import qualified Data.Vector as V@@ -27,8 +27,8 @@  import Codec.Picture.Blp.Internal.Data -blpParser :: Parser BlpStruct-blpParser = do+blpParser :: Int -> Parser BlpStruct+blpParser inputLen = do   _ <- blpVersion   blpCompression <- compressionParser   blpFlags <- flagsParser@@ -38,10 +38,52 @@   blpPictureSubType <- dword <?> "picture subtype"   blpMipMapOffset <- replicateM 16 dword <?> "mipmaps offsets"   blpMipMapSize <- replicateM 16 dword <?> "mipmaps sizes"-  let mipMapsInfo = nub . filter ((> 0) . snd) $ blpMipMapOffset `zip` blpMipMapSize+  -- BLP1 specifies 16 mipmap slots but most files only use a handful; the+  -- unused slots should be zero but some real-world files contain garbage+  -- offsets/sizes left behind by the writing tool. Drop any slot whose+  -- advertised range would run past the end of the file, then sort the+  -- survivors by offset so that the monotonic `skipToOffset` walks forward+  -- correctly even if the file stored mipmaps out of order.+  let rawMipMapsInfo = nub . filter ((> 0) . snd) $ blpMipMapOffset `zip` blpMipMapSize+      fitsInput (o, s) =+        fromIntegral o + fromIntegral s <= (fromIntegral inputLen :: Integer)+      -- Sorting is not enough on its own. `skipToOffset` only ever moves+      -- forward, so a slot starting inside a range that has already been+      -- consumed can never be reached: the skip stays where it is, the+      -- following `take` reads the wrong bytes, and every later mipmap is+      -- dragged forward until the walk runs off the end of the file. Slots+      -- holding garbage that points back into mipmap 0 are common enough in+      -- the wild to matter, so keep only a forward-progressing run.+      dropOverlapping :: Integer -> [(Word32, Word32)] -> [(Word32, Word32)]+      dropOverlapping _ [] = []+      dropOverlapping end (slot@(o, s) : rest)+        | fromIntegral o >= end =+            slot : dropOverlapping (fromIntegral o + fromIntegral s) rest+        | otherwise = dropOverlapping end rest+      mipMapsInfo =+        dropOverlapping 0 . sortOn fst . filter fitsInput $ rawMipMapsInfo+  -- Some writing tools set `pictureType` to `UncompressedWithAlpha` on+  -- files whose mipmap 0 is actually only large enough for a single+  -- index plane (w*h bytes) rather than indices + alpha (2*w*h bytes).+  -- Trust the physical size over the picture-type byte and decode those+  -- files via the without-alpha path instead of crashing on a half-read+  -- index list. Anything short of 2*w*h cannot hold both planes, so the+  -- check is a range rather than an exact match.+  let pixels0 :: Integer+      pixels0 = fromIntegral blpWidth * fromIntegral blpHeight+      mipZeroSize = case mipMapsInfo of+        ((_, s):_) -> fromIntegral s :: Integer+        []         -> 0+      mislabeledAsAlpha =+        blpPictureType == UncompressedWithAlpha &&+        pixels0 > 0 &&+        mipZeroSize >= pixels0 &&+        mipZeroSize < 2 * pixels0+      effectivePictureType =+        if mislabeledAsAlpha then UncompressedWithoutAlpha else blpPictureType   blpExt <- case blpCompression of     BlpCompressionJPEG -> blpJpegParser mipMapsInfo-    BlpCompressionUncompressed -> case blpPictureType of+    BlpCompressionUncompressed -> case effectivePictureType of       JPEGType -> fail "JPEG type with Uncompressed type mix"       UncompressedWithAlpha -> blpUncompressed1Parser mipMapsInfo       UncompressedWithoutAlpha -> blpUncompressed2Parser mipMapsInfo@@ -80,10 +122,18 @@ pictureTypeParser = (<?> "picture type") $ do   i <- dword   case i of+    0 -> return UncompressedWithAlpha     2 -> return JPEGType     3 -> return UncompressedWithAlpha     4 -> return UncompressedWithAlpha     5 -> return UncompressedWithoutAlpha+    -- BLP1 files seen in the wild (notably from World of Warcraft assets,+    -- as well as some BLPs found inside Warcraft III maps) use picture type+    -- 6 for JPEG-compressed textures with an alpha channel. The actual data+    -- layout is identical to type 2, and the dispatch in `blpParser` already+    -- decides JPEG vs uncompressed based on the `compression` field, so we+    -- can safely treat 6 as another spelling of `JPEGType`.+    6 -> return JPEGType     _ -> fail $ "Unknown picture type " ++ show i  blpJpegParser :: [(Word32, Word32)] -> Parser BlpExt@@ -105,9 +155,27 @@   if diff <= 0 then return ()     else void $ AT.take diff +-- | Read up to 256 palette entries, bounded by the offset of the first+-- mipmap. Some BLP1 files in the wild (e.g. produced by community map+-- tools) only emit the palette entries that are actually used, so the+-- palette region between the main header and the first mipmap can be+-- shorter than the canonical 1024 bytes. We pad the resulting vector to+-- 256 entries with transparent black so the rest of the codebase, which+-- indexes the palette by an arbitrary `Word8`, stays sound.+paletteParser :: [(Word32, Word32)] -> Parser (V.Vector PixelRGBA8)+paletteParser mps = do+  pos <- getPos+  let firstOffset = case mps of+        ((o, _):_) -> fromIntegral o+        []         -> pos + 1024  -- no mipmaps: keep historical behaviour+      available   = max 0 (firstOffset - pos)+      nEntries    = min 256 (available `div` 4)+  entries <- V.replicateM nEntries rgba8+  return $ entries V.++ V.replicate (256 - nEntries) (PixelRGBA8 0 0 0 0)+ blpUncompressed1Parser :: [(Word32, Word32)] -> Parser BlpExt blpUncompressed1Parser mps = do-  blpU1Palette <- V.replicateM 256 rgba8+  blpU1Palette <- paletteParser mps   blpU1MipMaps <- forM mps $ \(offset, size) -> do     skipToOffset offset     let halfSize = fromIntegral size `div` 2@@ -118,11 +186,11 @@  blpUncompressed2Parser :: [(Word32, Word32)] -> Parser BlpExt blpUncompressed2Parser mps = do-  blpU2Palette <- V.replicateM 256 rgba8+  blpU2Palette <- paletteParser mps   blpU2MipMaps <- forM mps $ \(offset, size) -> do     skipToOffset offset     AT.take (fromIntegral size) <?> "index list"   return $ BlpUncompressed2 {..}  parseBlp :: ByteString -> Either String BlpStruct-parseBlp = parseOnly blpParser+parseBlp bs = parseOnly (blpParser (BS.length bs)) bs
+ test/Fixture.hs view
@@ -0,0 +1,97 @@+-- | Byte-level BLP1 builder for tests.+--+-- Uncompressed files are assembled here field by field rather than with the+-- library's encoder, so decoder tests do not depend on the encoder being+-- right. JPEG files are taken from the encoder and patched with 'setDword'.+module Fixture(+    Header(..)+  , defHeader+  , headerSize+  , blpFile+  , uncompressed+  , PaletteEntry+  , paletteBytes+  , getDword+  , setDword+  , offsetSlot+  , sizeSlot+  , pictureTypeOffset+  ) where++import Data.Bits+import Data.ByteString (ByteString)+import Data.Word++import qualified Data.ByteString as BS++data Header = Header {+  hCompression :: Word32+, hFlags       :: Word32+, hWidth       :: Word32+, hHeight      :: Word32+, hPictureType :: Word32+, hSubType     :: Word32+}++-- | Uncompressed, with-alpha 4x4 header; override fields as needed.+defHeader :: Header+defHeader = Header {+    hCompression = 1+  , hFlags       = 8+  , hWidth       = 4+  , hHeight      = 4+  , hPictureType = 4+  , hSubType     = 5+  }++-- | Size of the fixed BLP1 header: tag, six dwords, 16 offsets, 16 sizes.+headerSize :: Int+headerSize = 156++-- | Assemble a file from a header, the mipmap slots (padded to 16 with zeros)+-- and everything that follows the header.+blpFile :: Header -> [(Word32, Word32)] -> ByteString -> ByteString+blpFile Header{..} slots body = BS.concat+  [ "BLP1"+  , dwords [hCompression, hFlags, hWidth, hHeight, hPictureType, hSubType]+  , dwords (map fst slots') , dwords (map snd slots')+  , body+  ]+  where+    slots' = take 16 $ slots ++ repeat (0, 0)++-- | Palette entry as it is stored in the file: blue, green, red, alpha.+type PaletteEntry = (Word8, Word8, Word8, Word8)++paletteBytes :: [PaletteEntry] -> ByteString+paletteBytes es = BS.pack $ concat [[b, g, r, a] | (b, g, r, a) <- es]++-- | Uncompressed file: header, palette, then each mipmap back to back with+-- slots pointing at them.+uncompressed :: Header -> [PaletteEntry] -> [ByteString] -> ByteString+uncompressed h palette mips = blpFile h slots (pal <> BS.concat mips)+  where+    pal = paletteBytes palette+    sizes = map (fromIntegral . BS.length) mips+    offsets = scanl (+) (fromIntegral $ headerSize + BS.length pal) sizes+    slots = zip offsets sizes++dwords :: [Word32] -> ByteString+dwords = BS.concat . map dword++dword :: Word32 -> ByteString+dword v = BS.pack [fromIntegral (v `shiftR` (8 * i)) | i <- [0 .. 3]]++getDword :: Int -> ByteString -> Word32+getDword o bs = foldr (\i acc -> acc `shiftL` 8 .|. fromIntegral (BS.index bs (o + i))) 0 [0 .. 3]++setDword :: Int -> Word32 -> ByteString -> ByteString+setDword o v bs = BS.take o bs <> dword v <> BS.drop (o + 4) bs++-- | File offsets of the header fields that tests patch.+pictureTypeOffset :: Int+pictureTypeOffset = 20++offsetSlot, sizeSlot :: Int -> Int+offsetSlot i = 28 + 4 * i+sizeSlot i = 92 + 4 * i
+ test/Main.hs view
@@ -0,0 +1,331 @@+module Main(main) where++import Codec.Picture+import Codec.Picture.Blp+import Codec.Picture.Types (convertImage)+import Control.Exception (bracket_, try)+import Control.Monad (forM_)+import Data.ByteString (ByteString)+import Data.List (isInfixOf)+import Data.Word+import System.Directory (getTemporaryDirectory, removeFile)+import System.FilePath ((</>))+import Test.Tasty+import Test.Tasty.HUnit++import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as BSL++import Fixture++main :: IO ()+main = defaultMain $ testGroup "JuicyPixels-blp"+  [ uncompressedTests+  , malformedTests+  , jpegTests+  , encoderTests+  , fileTests+  ]++-- | 4x4 test texture: pixel (x, y) is palette index @y*4+x@.+indices :: ByteString+indices = BS.pack [0 .. 15]++-- | Alpha plane of the with-alpha variant.+alphaPlane :: ByteString+alphaPlane = BS.pack [k * 16 + 15 | k <- [0 .. 15]]++-- | Palette stored in the file; only the first 16 entries are referenced.+palette :: Int -> [PaletteEntry]+palette n = take n $ [(k * 3, 255 - k * 16, k * 16, 0) | k <- [0 .. 15]] ++ repeat (0, 0, 0, 0)++-- | What the decoder should produce for index @k@: palette colour, alpha from+-- the alpha plane (with alpha) or opaque (without alpha).+withAlphaPixel, withoutAlphaPixel :: Word8 -> PixelRGBA8+withAlphaPixel k = PixelRGBA8 (k * 16) (255 - k * 16) (k * 3) (k * 16 + 15)+withoutAlphaPixel k = PixelRGBA8 (k * 16) (255 - k * 16) (k * 3) 255++withAlphaFile :: Word32 -> ByteString+withAlphaFile ptype = uncompressed defHeader { hPictureType = ptype } (palette 256) [indices <> alphaPlane]++withoutAlphaFile :: ByteString+withoutAlphaFile = uncompressed defHeader { hFlags = 0, hPictureType = 5 } (palette 256) [indices]++pixelsOf :: Image PixelRGBA8 -> [PixelRGBA8]+pixelsOf img = [pixelAt img x y | y <- [0 .. imageHeight img - 1], x <- [0 .. imageWidth img - 1]]++decodeRGBA :: ByteString -> IO (Image PixelRGBA8)+decodeRGBA bs = either (assertFailure . ("decodeBlp: " ++)) (return . convertRGBA8) $ decodeBlp bs++decodeLevels :: ByteString -> IO [Image PixelRGBA8]+decodeLevels bs = either (assertFailure . ("decodeBlpMipmaps: " ++)) (return . map convertRGBA8) $ decodeBlpMipmaps bs++assertPixels :: (Word8 -> PixelRGBA8) -> ByteString -> Assertion+assertPixels expected bs = do+  img <- decodeRGBA bs+  (imageWidth img, imageHeight img) @?= (4, 4)+  pixelsOf img @?= map expected [0 .. 15]++assertLeft :: String -> Either String a -> Assertion+assertLeft needle r = case r of+  Left e -> assertBool ("error " ++ show e ++ " should mention " ++ show needle) (needle `isInfixOf` e)+  Right _ -> assertFailure "expected a decoding error"++uncompressedTests :: TestTree+uncompressedTests = testGroup "uncompressed"+  [ testCase "with alpha, picture type 4" $ assertPixels withAlphaPixel (withAlphaFile 4)+  , testCase "with alpha, picture type 3" $ assertPixels withAlphaPixel (withAlphaFile 3)+  , testCase "picture type 0 decodes as with alpha (#10)" $ assertPixels withAlphaPixel (withAlphaFile 0)+  , testCase "without alpha, picture type 5" $ assertPixels withoutAlphaPixel withoutAlphaFile+  , testCase "without alpha ignores the palette's fourth byte (#14)" $+      assertPixels withoutAlphaPixel $ uncompressed defHeader { hFlags = 0, hPictureType = 5 }+        [(b, g, r, 255) | (b, g, r, _) <- palette 256] [indices]+  , testCase "labelled with alpha, mip 0 holds only indices (#8)" $+      assertPixels withoutAlphaPixel $ uncompressed defHeader (palette 256) [indices]+  , testCase "labelled with alpha, mip 0 between one and two planes (#8)" $+      assertPixels withoutAlphaPixel $ uncompressed defHeader (palette 256) [indices <> "pad"]+  , testCase "palette shorter than 256 entries (#6)" $+      assertPixels withoutAlphaPixel $ uncompressed defHeader { hFlags = 0, hPictureType = 5 } (palette 16) [indices]+  , testCase "mipmap slot past the end of the file is skipped (#7)" $ do+      let bs = setDword (offsetSlot 1) 100000 . setDword (sizeSlot 1) 16 $ withAlphaFile 4+      assertPixels withAlphaPixel bs+      levels <- decodeLevels bs+      length levels @?= 1+  , testCase "mipmap slot overlapping mip 0 is skipped (#7)" $ do+      let mip0 = getDword (offsetSlot 0) (withAlphaFile 4)+          bs = setDword (offsetSlot 1) (mip0 + 4) . setDword (sizeSlot 1) 8 $ withAlphaFile 4+      assertPixels withAlphaPixel bs+      levels <- decodeLevels bs+      length levels @?= 1+  , testCase "with alpha mip levels have their own sizes (#13)" $ do+      let bs = uncompressed defHeader (palette 256)+            [indices <> alphaPlane, "\1\2\3\4" <> "\5\6\7\8", "\9" <> "\10"]+      levels <- decodeLevels bs+      sizes levels @?= [(4, 4), (2, 2), (1, 1)]+      pixelsOf (levels !! 1) @?= [PixelRGBA8 (k * 16) (255 - k * 16) (k * 3) (k + 4) | k <- [1 .. 4]]+      pixelsOf (levels !! 2) @?= [PixelRGBA8 144 111 27 10]+  , testCase "without alpha mip levels have their own sizes (#13)" $ do+      let bs = uncompressed defHeader { hFlags = 0, hPictureType = 5 } (palette 256) [indices, "\1\2\3\4", "\9"]+      levels <- decodeLevels bs+      sizes levels @?= [(4, 4), (2, 2), (1, 1)]+      pixelsOf (levels !! 1) @?= map withoutAlphaPixel [1 .. 4]+  , testCase "non-square texture halves each side down to 1 (#13)" $ do+      let bs = uncompressed defHeader { hFlags = 0, hPictureType = 5, hWidth = 4, hHeight = 2 } (palette 256)+            [BS.take 8 indices, "\1\2", "\3"]+      levels <- decodeLevels bs+      sizes levels @?= [(4, 2), (2, 1), (1, 1)]+  , testCase "mip level shorter than its size ends the list (#13)" $ do+      let bs = uncompressed defHeader { hFlags = 0, hPictureType = 5 } (palette 256) [indices, "\1\2", "\9"]+      levels <- decodeLevels bs+      sizes levels @?= [(4, 4)]+  , testCase "mip 0 shorter than the image is an error, not an exception (#13)" $+      assertLeft "too short" . decodeBlp $ uncompressed defHeader { hFlags = 0, hPictureType = 5 } (palette 256) [BS.take 8 indices]+  ]++malformedTests :: TestTree+malformedTests = testGroup "malformed input"+  [ testCase "wrong version tag" $+      assertLeft "BLP1" . decodeBlp $ "BLP2" <> BS.drop 4 (withAlphaFile 4)+  , testCase "unknown compression" $+      assertLeft "compression" . decodeBlp $ uncompressed defHeader { hCompression = 7 } (palette 256) [indices]+  , testCase "unknown picture type" $+      assertLeft "picture type" . decodeBlp $ withAlphaFile 9+  , testCase "uncompressed file with JPEG picture type" $+      assertLeft "JPEG type" . decodeBlp $ withAlphaFile 2+  , testCase "truncated header" $+      assertLeft "" . decodeBlp $ BS.take 100 (withAlphaFile 4)+  , testCase "empty input" $+      assertLeft "" $ decodeBlp BS.empty+  , testCase "file cut inside the header names the field" $+      assertLeft "mipmaps sizes" . decodeBlp $ BS.take 100 (withAlphaFile 4)+  , testCase "JPEG header longer than the file" $+      assertLeft "jpeg header" . decodeBlp $ setDword headerSize 100000 jpegFile+  , testCase "uncompressed file without mipmaps" $+      assertLeft "No data" . decodeBlp $ blpFile defHeader [] (paletteBytes $ palette 256)+  , testCase "JPEG file without mipmaps" $+      assertLeft "No JPEG mipmap data" . decodeBlp $ blpFile defHeader { hCompression = 0 } [] "\0\0\0\0"+  ]++-- | 16x16 texture with colour and alpha gradients, enough for three mip levels.+gradient :: Image PixelRGBA8+gradient = generateImage px 16 16+  where+    px x y = PixelRGBA8 (fromIntegral $ x * 16) (fromIntegral $ y * 16) 128 (fromIntegral $ 255 - x * 8)++-- | 'gradient' with the alpha plane flattened to opaque.+opaqueGradient :: Image PixelRGBA8+opaqueGradient = pixelMap (\(PixelRGBA8 r g b _) -> PixelRGBA8 r g b 255) gradient++jpegFile :: ByteString+jpegFile = BSL.toStrict $ encodeBlpJpeg 100 3 (ImageRGBA8 gradient)++-- | Largest per-channel difference between two equally sized images.+maxDiff :: Image PixelRGBA8 -> Image PixelRGBA8 -> Int+maxDiff a b = maximum $ zipWith diff (pixelsOf a) (pixelsOf b)+  where+    diff (PixelRGBA8 r g b' x) (PixelRGBA8 r2 g2 b2 x2) =+      maximum [d r r2, d g g2, d b' b2, d x x2]+    d u v = abs (fromIntegral u - fromIntegral v :: Int)++sizes :: [Image PixelRGBA8] -> [(Int, Int)]+sizes = map (\i -> (imageWidth i, imageHeight i))++jpegTests :: TestTree+jpegTests = testGroup "jpeg"+  [ testCase "opaque texture round-trips through the encoder (#11)" $ do+      img <- decodeRGBA . BSL.toStrict $ encodeBlpJpeg 100 1 (ImageRGBA8 opaqueGradient)+      let d = maxDiff opaqueGradient img+      assertBool ("max channel difference " ++ show d) (d <= 8)+  , testCase "all three mip levels decode" $ do+      levels <- decodeLevels jpegFile+      sizes levels @?= [(16, 16), (8, 8), (4, 4)]+  , testCase "picture type 6 decodes like the original (#5)" $ do+      orig <- decodeRGBA jpegFile+      img <- decodeRGBA (setDword pictureTypeOffset 6 jpegFile)+      pixelsOf img @?= pixelsOf orig+  , testCase "broken middle mip keeps only the levels before it (#9)" $ do+      levels <- decodeLevels (setDword (sizeSlot 1) 12 jpegFile)+      sizes levels @?= [(16, 16)]+  , testCase "broken last mip keeps the levels before it (#9)" $ do+      levels <- decodeLevels (setDword (sizeSlot 2) 12 jpegFile)+      sizes levels @?= [(16, 16), (8, 8)]+  , testCase "broken mip 0 fails the file" $+      assertLeft "" . decodeBlp $ setDword (sizeSlot 0) 12 jpegFile+  , testCase "three-component JPEG decodes to RGBA" $ do+      let rgb = generateImage (\x y -> PixelRGB8 (fromIntegral $ x * 16) (fromIntegral $ y * 16) 128) 16 16+          jpeg = BSL.toStrict . encodeJpeg $ (convertImage rgb :: Image PixelYCbCr8)+          bs = blpFile defHeader { hCompression = 0, hFlags = 0, hWidth = 16, hHeight = 16, hPictureType = 2 }+            [(fromIntegral headerSize + 4, fromIntegral $ BS.length jpeg)] ("\0\0\0\0" <> jpeg)+      dyn <- either (assertFailure . ("decodeBlp: " ++)) return $ decodeBlp bs+      case dyn of+        ImageRGBA8 img -> do+          direct <- either (assertFailure . ("decodeJpeg: " ++)) (return . convertRGBA8) $ decodeJpeg jpeg+          pixelsOf img @?= pixelsOf direct+        _ -> assertFailure "expected ImageRGBA8"+  , testCase "semi-transparent texture round-trips through the encoder (#17)" $ do+      img <- decodeRGBA . BSL.toStrict $ encodeBlpJpeg 100 1 (ImageRGBA8 gradient)+      let d = maxDiff gradient img+      assertBool ("max channel difference " ++ show d) (d <= 8)+  , testCase "uniformly semi-transparent texture keeps its alpha (#17)" $ do+      let solid = generateImage (\_ _ -> PixelRGBA8 200 100 50 128) 8 8+      img <- decodeRGBA . BSL.toStrict $ encodeBlpJpeg 100 1 (ImageRGBA8 solid)+      let d = maxDiff solid img+      assertBool ("max channel difference " ++ show d) (d <= 4)+  , testCase "all-zero alpha plane means opaque (#11)" $ do+      let clear = generateImage (\_ _ -> PixelRGBA8 200 100 50 0) 8 8+          opaque = pixelMap (\(PixelRGBA8 r g b _) -> PixelRGBA8 r g b 255) clear+      img <- decodeRGBA . BSL.toStrict $ encodeBlpJpeg 100 1 (ImageRGBA8 clear)+      let d = maxDiff opaque img+      assertBool ("max channel difference " ++ show d) (d <= 4)+  , testCase "mipmaps share a JPEG header (#18)" $ do+      let shared = getDword headerSize jpegFile+      assertBool ("shared header size " ++ show shared) (shared > 0 && shared <= 624)+      levels <- decodeLevels jpegFile+      sizes levels @?= [(16, 16), (8, 8), (4, 4)]+  , testCase "single mipmap keeps its data out of the shared header (#18)" $ do+      let tiny = generateImage (\_ _ -> PixelRGBA8 200 100 50 255) 1 1+      img <- decodeRGBA . BSL.toStrict $ encodeBlpJpeg 100 1 (ImageRGBA8 tiny)+      let d = maxDiff tiny img+      assertBool ("max channel difference " ++ show d) (d <= 4)+  ]++encoderTests :: TestTree+encoderTests = testGroup "encoder"+  [ testCase "with alpha writes the expected header" $ do+      let bs = BSL.toStrict $ encodeBlpUncompressedWithAlpha 3 (ImageRGBA8 gradient)+      map (`getDword` bs) [4, 8, 12, 16] @?= [1, 8, 16, 16]+      getDword pictureTypeOffset bs @?= 3+      map (\i -> getDword (sizeSlot i) bs) [0 .. 2] @?= [512, 128, 32]+  , testCase "without alpha writes the expected header" $ do+      let bs = BSL.toStrict $ encodeBlpUncompressedWithoutAlpha 3 (ImageRGB8 $ convertRGB8 (ImageRGBA8 gradient))+      map (`getDword` bs) [4, 8] @?= [1, 0]+      getDword pictureTypeOffset bs @?= 5+      map (\i -> getDword (sizeSlot i) bs) [0 .. 2] @?= [256, 64, 16]+  , testCase "with alpha round-trips mip 0" $ do+      img <- decodeRGBA . BSL.toStrict $ encodeBlpUncompressedWithAlpha 1 (ImageRGBA8 gradient)+      let d = maxDiff gradient img+      assertBool ("max channel difference " ++ show d) (d <= 8)+  , testCase "without alpha round-trips mip 0 (#14)" $ do+      let opaque = convertRGB8 (ImageRGBA8 opaqueGradient)+      img <- decodeRGBA . BSL.toStrict $ encodeBlpUncompressedWithoutAlpha 1 (ImageRGB8 opaque)+      let d = maxDiff opaqueGradient img+      assertBool ("max channel difference " ++ show d) (d <= 8)+  , testCase "encoded mipmaps decode at their own sizes (#13)" $ do+      levels <- decodeLevels . BSL.toStrict $ encodeBlpUncompressedWithAlpha 3 (ImageRGBA8 gradient)+      sizes levels @?= [(16, 16), (8, 8), (4, 4)]+  , testCase "JPEG alpha flag and picture type follow the input format" $ do+      let header dyn = let bs = BSL.toStrict $ encodeBlpJpeg 90 1 dyn+                       in (getDword 8 bs, getDword pictureTypeOffset bs)+          rgba = convertRGBA8 (ImageRGBA8 gradient)+      header (ImageY8 $ pixelMap (\(PixelRGBA8 r _ _ _) -> r) rgba) @?= (0, 5)+      header (ImageYA8 $ pixelMap (\(PixelRGBA8 r _ _ a) -> PixelYA8 r a) rgba) @?= (8, 3)+      header (ImageRGB16 $ pixelMap (\(PixelRGBA8 r g b _) -> PixelRGB16 (w16 r) (w16 g) (w16 b)) rgba) @?= (0, 5)+      header (ImageRGBA16 $ pixelMap (\(PixelRGBA8 r g b a) -> PixelRGBA16 (w16 r) (w16 g) (w16 b) (w16 a)) rgba) @?= (8, 3)+  , testCase "without alpha drops the alpha of an RGBA input" $ do+      let bs = BSL.toStrict $ encodeBlpUncompressedWithoutAlpha 1 (ImageRGBA8 gradient)+      (getDword 8 bs, getDword pictureTypeOffset bs) @?= (0, 5)+      img <- decodeRGBA bs+      let d = maxDiff opaqueGradient img+      assertBool ("max channel difference " ++ show d) (d <= 8)+  , testCase "with alpha keeps an alpha plane for an RGB input" $ do+      let bs = BSL.toStrict $ encodeBlpUncompressedWithAlpha 1 (ImageRGB8 $ convertRGB8 (ImageRGBA8 opaqueGradient))+      (getDword 8 bs, getDword pictureTypeOffset bs) @?= (8, 3)+      img <- decodeRGBA bs+      let d = maxDiff opaqueGradient img+      assertBool ("max channel difference " ++ show d) (d <= 8)+  , testCase "JPEG without alpha decodes opaque" $ do+      let bs = BSL.toStrict $ encodeBlpJpeg 100 1 (ImageRGB8 $ convertRGB8 (ImageRGBA8 gradient))+      getDword 8 bs @?= 0+      img <- decodeRGBA bs+      let d = maxDiff opaqueGradient img+      assertBool ("max channel difference " ++ show d) (d <= 8)+  , testCase "asking for more mipmaps than levels stops at 1x1" $ do+      levels <- decodeLevels . BSL.toStrict $ encodeBlpUncompressedWithAlpha 20 (ImageRGBA8 gradient)+      sizes levels @?= [(16, 16), (8, 8), (4, 4), (2, 2), (1, 1)]+  , testCase "non-square mip chain stops when a side reaches 1" $ do+      let wide = generateImage (\x y -> PixelRGBA8 (fromIntegral x) (fromIntegral y) 0 255) 64 16+      levels <- decodeLevels . BSL.toStrict $ encodeBlpJpeg 90 16 (ImageRGBA8 wide)+      sizes levels @?= [(64, 16), (32, 8), (16, 4), (8, 2), (4, 1)]+      odd' <- decodeLevels . BSL.toStrict $ encodeBlpUncompressedWithAlpha 16 (ImageRGBA8 $ generateImage (\_ _ -> PixelRGBA8 1 2 3 4) 5 3)+      sizes odd' @?= [(5, 3), (2, 1)]+  , testCase "mipMapsUpTo counts every level down to the minimum side" $ do+      let img w h = ImageRGBA8 $ generateImage (\_ _ -> PixelRGBA8 0 0 0 0) w h+      mipMapsUpTo 1 (img 16 16) @?= 5+      mipMapsUpTo 4 (img 16 16) @?= 3+      mipMapsUpTo 1 (img 64 16) @?= 5+      mipMapsUpTo 1 (img 5 5) @?= 3+      mipMapsUpTo 1 (img 1 1) @?= 1+      mipMapsUpTo 32 (img 16 16) @?= 1+  , testCase "zero or negative mipmap count still writes mip 0" $ do+      let enc n = [ encodeBlpJpeg 90 n (ImageRGBA8 gradient)+                  , encodeBlpUncompressedWithAlpha n (ImageRGBA8 gradient) ]+      forM_ (concatMap enc [0, -1]) $ \bs -> do+        levels <- decodeLevels (BSL.toStrict bs)+        sizes levels @?= [(16, 16)]+  ]++fileTests :: TestTree+fileTests = testGroup "files"+  [ testCase "write and read back each format" $ do+      tmp <- getTemporaryDirectory+      let path = tmp </> "juicypixels-blp-test.blp"+          img = ImageRGBA8 opaqueGradient+          check write = bracket_ (write path) (removeFile path) $ do+            Right single <- readBlp path+            let d = maxDiff opaqueGradient (convertRGBA8 single)+            assertBool ("max channel difference " ++ show d) (d <= 8)+            Right levels <- readBlpMipmaps path+            sizes (map convertRGBA8 levels) @?= [(16, 16), (8, 8)]+      check $ \p -> writeBlpJpeg p 100 2 img+      check $ \p -> writeBlpUncompressedWithAlpha p 2 img+      check $ \p -> writeBlpUncompressedWithoutAlpha p 2 (ImageRGB8 $ convertRGB8 img)+  , testCase "missing file is an IO error" $ do+      r <- tryIO $ readBlp "/nonexistent/juicypixels-blp.blp"+      assertBool "readBlp should throw" (either (const True) (const False) r)+  ]+  where+    tryIO :: IO a -> IO (Either IOError a)+    tryIO = try++w16 :: Word8 -> Word16+w16 v = fromIntegral v * 257