packages feed

JuicyPixels-blp 0.6.0.0 → 1.0.0.0

raw patch · 20 files changed

+1101/−313 lines, 20 filesdep ~vectorPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: vector

API changes (from Hackage documentation)

- Codec.Picture.Blp: encodeBlpJpeg :: Int -> Int -> DynamicImage -> ByteString
- Codec.Picture.Blp: encodeBlpUncompressedWithAlpha :: Int -> DynamicImage -> ByteString
- Codec.Picture.Blp: encodeBlpUncompressedWithoutAlpha :: Int -> DynamicImage -> ByteString
- Codec.Picture.Blp: writeBlpJpeg :: FilePath -> Int -> Int -> DynamicImage -> IO ()
- Codec.Picture.Blp: writeBlpUncompressedWithAlpha :: FilePath -> Int -> DynamicImage -> IO ()
- Codec.Picture.Blp: writeBlpUncompressedWithoutAlpha :: FilePath -> Int -> DynamicImage -> IO ()
- Codec.Picture.Blp.Internal.Parser: blpVersion :: Parser ByteString
+ Codec.Picture.Blp: BlpCorruptImage :: String -> BlpDecodeError
+ Codec.Picture.Blp: BlpFormatBgra :: BlpFormat
+ Codec.Picture.Blp: BlpFormatDxt1 :: BlpFormat
+ Codec.Picture.Blp: BlpFormatDxt3 :: BlpFormat
+ Codec.Picture.Blp: BlpFormatDxt5 :: BlpFormat
+ Codec.Picture.Blp: BlpInvalidSize :: !Int -> !Int -> BlpDecodeError
+ Codec.Picture.Blp: BlpNoImageData :: BlpDecodeError
+ Codec.Picture.Blp: BlpNotBlp :: BlpDecodeError
+ Codec.Picture.Blp: BlpTooLarge :: !Int -> !Int -> !Int -> BlpDecodeError
+ Codec.Picture.Blp: BlpTruncatedHeader :: BlpDecodeError
+ Codec.Picture.Blp: BlpUnsupported :: String -> BlpDecodeError
+ Codec.Picture.Blp: BlpUnsupportedFormat :: !BlpVersion -> !BlpFormat -> BlpEncodeError
+ Codec.Picture.Blp: [blpVersion] :: BlpEncodeOptions -> !BlpVersion
+ Codec.Picture.Blp: data BlpDecodeError
+ Codec.Picture.Blp: displayBlpDecodeError :: BlpDecodeError -> String
+ Codec.Picture.Blp.Internal.Data: BlpFormatBgra :: BlpFormat
+ Codec.Picture.Blp.Internal.Data: BlpFormatDxt1 :: BlpFormat
+ Codec.Picture.Blp.Internal.Data: BlpFormatDxt3 :: BlpFormat
+ Codec.Picture.Blp.Internal.Data: BlpFormatDxt5 :: BlpFormat
+ Codec.Picture.Blp.Internal.Data: BlpUnsupportedFormat :: !BlpVersion -> !BlpFormat -> BlpEncodeError
+ Codec.Picture.Blp.Internal.Data: [blpVersion] :: BlpEncodeOptions -> !BlpVersion
+ Codec.Picture.Blp.Internal.Dxt: encodeDxt :: DxtFormat -> Image PixelRGBA8 -> ByteString
+ Codec.Picture.Blp.Internal.Encoder: blp2Encoder :: Blp2Struct -> Put
+ Codec.Picture.Blp.Internal.Encoder: encodeBlp2 :: Blp2Struct -> ByteString
+ Codec.Picture.Blp.Internal.Encoder: toBlp2Struct :: BlpEncodeOptions -> DynamicImage -> Either BlpEncodeError Blp2Struct
+ Codec.Picture.Blp.Internal.Parser: blp1Tag :: Parser ByteString
- Codec.Picture.Blp: BlpEncodeOptions :: !BlpFormat -> !Int -> !Bool -> !Maybe Int -> BlpEncodeOptions
+ Codec.Picture.Blp: BlpEncodeOptions :: !BlpFormat -> !Int -> !Bool -> !Maybe Int -> !BlpVersion -> BlpEncodeOptions
- Codec.Picture.Blp: blpInfo :: ByteString -> Either String BlpInfo
+ Codec.Picture.Blp: blpInfo :: ByteString -> Either BlpDecodeError BlpInfo
- Codec.Picture.Blp: decodeBlp :: ByteString -> Either String DynamicImage
+ Codec.Picture.Blp: decodeBlp :: ByteString -> Either BlpDecodeError (Image PixelRGBA8)
- Codec.Picture.Blp: decodeBlpMipmaps :: ByteString -> Either String [DynamicImage]
+ Codec.Picture.Blp: decodeBlpMipmaps :: ByteString -> Either BlpDecodeError [Image PixelRGBA8]
- Codec.Picture.Blp: decodeBlpMipmapsWith :: BlpDecodeOptions -> ByteString -> Either String [DynamicImage]
+ Codec.Picture.Blp: decodeBlpMipmapsWith :: BlpDecodeOptions -> ByteString -> Either BlpDecodeError [Image PixelRGBA8]
- Codec.Picture.Blp: decodeBlpWith :: BlpDecodeOptions -> ByteString -> Either String DynamicImage
+ Codec.Picture.Blp: decodeBlpWith :: BlpDecodeOptions -> ByteString -> Either BlpDecodeError (Image PixelRGBA8)
- Codec.Picture.Blp: readBlp :: FilePath -> IO (Either String DynamicImage)
+ Codec.Picture.Blp: readBlp :: FilePath -> IO (Either BlpDecodeError (Image PixelRGBA8))
- Codec.Picture.Blp: readBlpMipmaps :: FilePath -> IO (Either String [DynamicImage])
+ Codec.Picture.Blp: readBlpMipmaps :: FilePath -> IO (Either BlpDecodeError [Image PixelRGBA8])
- Codec.Picture.Blp.Internal.Data: BlpEncodeOptions :: !BlpFormat -> !Int -> !Bool -> !Maybe Int -> BlpEncodeOptions
+ Codec.Picture.Blp.Internal.Data: BlpEncodeOptions :: !BlpFormat -> !Int -> !Bool -> !Maybe Int -> !BlpVersion -> BlpEncodeOptions
- Codec.Picture.Blp.Internal.Decoder: decodeBlp2Struct :: BlpDecodeOptions -> Blp2Struct -> Either String [DynamicImage]
+ Codec.Picture.Blp.Internal.Decoder: decodeBlp2Struct :: BlpDecodeOptions -> Blp2Struct -> Either BlpDecodeError [Image PixelRGBA8]
- Codec.Picture.Blp.Internal.Decoder: decodeBlpFile :: BlpDecodeOptions -> BlpFile -> Either String [DynamicImage]
+ Codec.Picture.Blp.Internal.Decoder: decodeBlpFile :: BlpDecodeOptions -> BlpFile -> Either BlpDecodeError [Image PixelRGBA8]
- Codec.Picture.Blp.Internal.Decoder: decodeBlpStruct :: BlpDecodeOptions -> BlpStruct -> Either String [DynamicImage]
+ Codec.Picture.Blp.Internal.Decoder: decodeBlpStruct :: BlpDecodeOptions -> BlpStruct -> Either BlpDecodeError [Image PixelRGBA8]
- Codec.Picture.Blp.Internal.Encoder: toBlpStruct :: BlpEncodeOptions -> DynamicImage -> BlpStruct
+ Codec.Picture.Blp.Internal.Encoder: toBlpStruct :: BlpEncodeOptions -> DynamicImage -> Either BlpEncodeError BlpStruct
- Codec.Picture.Blp.Internal.Parser: parseBlp :: ByteString -> Either String BlpStruct
+ Codec.Picture.Blp.Internal.Parser: parseBlp :: ByteString -> Either BlpDecodeError BlpStruct
- Codec.Picture.Blp.Internal.Parser: parseBlp2 :: ByteString -> Either String Blp2Struct
+ Codec.Picture.Blp.Internal.Parser: parseBlp2 :: ByteString -> Either BlpDecodeError Blp2Struct
- Codec.Picture.Blp.Internal.Parser: parseBlpFile :: ByteString -> Either String BlpFile
+ Codec.Picture.Blp.Internal.Parser: parseBlpFile :: ByteString -> Either BlpDecodeError BlpFile

Files

CHANGELOG.md view
@@ -1,3 +1,72 @@+1.0.0.0 (2026-10-17)+====================++The library now reads and writes both BLP versions in use, and its API is+meant to stay. Decoding reports why it fails and returns the image type it+always produced, BLP2 can be written, and the deprecated encoders are gone.+Every decoding function and the encoding options change; see below.++### Writing BLP2++- **Breaking:** `BlpEncodeOptions` has a new field, `blpVersion` (`Blp1` by+  default), and `BlpFormat` new constructors `BlpFormatDxt1`,+  `BlpFormatDxt3`, `BlpFormatDxt5` and `BlpFormatBgra`. These exist only in+  BLP2; JPEG exists only in BLP1, and a palette in both. Other combinations+  give the new `BlpUnsupportedFormat`.+- The DXT encoder fits each block's colours along their principal axis,+  refines the endpoints by least squares, and tries a single-colour fit+  from exact tables. Against ImageMagick's DDS encoders on seven test+  images it is 0.7–2.5 dB above range fit and 0.2–0.6 dB below cluster+  fit (1.8 dB on a 64×64 icon). It encodes a 512×512 texture in about+  80 ms (DXT1) or 100 ms (DXT5).+- Files written this way decode with an independent decoder (Pinta365/blp,+  with the DXT5 alpha fix reported in Pinta365/blp#1): palette and BGRA+  exactly, DXT within the two decoders' rounding differences.+- `blp2any convert` writes BLP2 with `-b dxt1`, `dxt3`, `dxt5` or `bgra`,+  and paletted BLP2 with `--blp2`.++### Decoding++- **Breaking:** `decodeBlp`, `decodeBlpWith`, `readBlp` and the `Mipmaps`+  variants return `Image PixelRGBA8` instead of `DynamicImage`; the image+  was always `ImageRGBA8`. Drop the `convertRGBA8` call, or wrap the result+  in `ImageRGBA8` where a `DynamicImage` is needed.+- **Breaking:** they and `blpInfo` report errors as `BlpDecodeError`+  instead of `String`: `BlpNotBlp`, `BlpTruncatedHeader`, `BlpUnsupported`,+  `BlpInvalidSize`, `BlpTooLarge`, `BlpNoImageData` and `BlpCorruptImage`.+  `displayBlpDecodeError` gives a readable message. Callers can tell a+  broken file from one that is too large or of an unsupported kind without+  parsing text.+- A paletted file without any usable mip level is now `BlpNoImageData` for+  `decodeBlpMipmaps` too; it used to give an empty list. JPEG files already+  failed this way. Apart from that, decoding is unchanged: an equivalence+  run against 0.6.0.0 over 4735 cases gives the same pixels, levels,+  `blpInfo` results and failures.+- JPEG errors from JuicyPixels name the level, for example+  `JPEG mipmap 0: not enough bytes`.++### Also++- **Breaking:** the deprecated `encodeBlpJpeg`,+  `encodeBlpUncompressedWithAlpha`, `encodeBlpUncompressedWithoutAlpha` and+  their `writeBlp*` variants are removed. Use `encodeBlpWith` or+  `writeBlpWith`; unlike them, these return `Left` instead of throwing.+- `BlpDecodeError` and `BlpEncodeError` are `Exception`s, and the public+  result and option types have `NFData` instances.+- `blp2any stats` groups BLP2 files too, by content type, `alphaDepth`,+  `alphaType` and `hasMips`. Group names of BLP1 files are unchanged.+- The README lists what is out of scope on purpose: BLP0, BLP2 with JPEG+  content, chosen header values through the public encoder, and archives+  or other texture formats.+- Internal modules:+  - `Internal.Dxt` has `encodeDxt`.+  - `Internal.Encoder` has `toBlp2Struct`, `encodeBlp2` and `blp2Encoder`,+    and `toBlpStruct` returns `Either BlpEncodeError`.+  - The parser and decoder return `BlpDecodeError`, and the decoder+    `Image PixelRGBA8`.+  - The BLP1 magic parser `blpVersion` is renamed `blp1Tag`, as the name+    now belongs to the option.+ 0.6.0.0 (2026-10-16) ==================== 
JuicyPixels-blp.cabal view
@@ -1,9 +1,9 @@ cabal-version:       2.2 name:                JuicyPixels-blp-version:             0.6.0.0+version:             1.0.0.0 synopsis:            BLP format decoder/encoder over JuicyPixels library description:-  Read and write BLP1, the texture format of Warcraft III, and read BLP2,+  Read and write BLP1, the texture format of Warcraft III, and BLP2,   its World of Warcraft variant, with images as   [JuicyPixels](https://hackage.haskell.org/package/JuicyPixels) types.   Supports JPEG, paletted, DXT1/3/5 and BGRA content with and without@@ -119,7 +119,6 @@   ghc-options:         -rtsopts -with-rtsopts=-M1g   other-modules:     Blp2-    Deprecated     Dxt     Fixture     Properties@@ -136,6 +135,7 @@     , QuickCheck            >= 2.14   && < 2.17     , tasty-hunit           >= 0.10   && < 0.11     , tasty-quickcheck      >= 0.10.2 && < 0.12+    , vector    default-extensions:     OverloadedStrings
README.md view
@@ -4,7 +4,7 @@ [![CI](https://github.com/NCrashed/JuicyPixels-blp/actions/workflows/ci.yml/badge.svg)](https://github.com/NCrashed/JuicyPixels-blp/actions/workflows/ci.yml) [![Hackage](https://img.shields.io/hackage/v/JuicyPixels-blp.svg)](https://hackage.haskell.org/package/JuicyPixels-blp) -Read and write BLP1, the texture format of Warcraft III, and read BLP2, its+Read and write BLP1, the texture format of Warcraft III, and BLP2, its World of Warcraft variant, with images as [JuicyPixels](https://hackage.haskell.org/package/JuicyPixels) types. The package also ships `blp2any`, a command-line converter between BLP and@@ -21,9 +21,9 @@  main :: IO () main = do-  -- Decode: the result is always ImageRGBA8.+  -- Decode: the result is an Image PixelRGBA8, whatever the content.   Right img <- readBlp "war3mapMap.blp"-  writePng "war3mapMap.png" (convertRGBA8 img)+  writePng "war3mapMap.png" img    -- Encode: JPEG at quality 90 with every mip level down to 1x1.   Right png <- readImage "icon.png"@@ -31,13 +31,22 @@   either print return result ``` -- `readBlp` / `decodeBlp` return the full size image of a BLP1 or BLP2 file.+- `readBlp` / `decodeBlp` return the full size image of a BLP1 or BLP2 file+  as `Image PixelRGBA8`. - `readBlpMipmaps` / `decodeBlpMipmaps` return the image followed by its   mipmaps.+- Files they cannot read give a `BlpDecodeError`, which says why: not a BLP+  file, a header cut short, unsupported content, an invalid size, a size+  above the limit, no image data, or a damaged full size image.+  `displayBlpDecodeError` turns it into a message. - `encodeBlpWith` / `writeBlpWith` encode with `BlpEncodeOptions`:-  - `blpFormat`: `BlpFormatJpeg quality` (1 to 100; the texture gets alpha-    when the image has it) or `BlpFormatPaletted withAlpha` (256 colour-    palette, with 8-bit alpha or none).+  - `blpVersion`: `Blp1` (Warcraft III, the default) or `Blp2` (World of+    Warcraft).+  - `blpFormat`: in BLP1, `BlpFormatJpeg quality` (1 to 100; the texture+    gets alpha when the image has it); in BLP2, `BlpFormatDxt1` (1-bit+    alpha when some alpha is below 128), `BlpFormatDxt3`, `BlpFormatDxt5`+    or `BlpFormatBgra` (lossless); in both, `BlpFormatPaletted withAlpha`+    (256 colour palette, with 8-bit alpha or none).   - `blpMipLevels`: how many mip levels to write, clamped to 1 and the full     chain. `mipMapsUpTo` computes it from a smallest side length.   - `blpDither`: whether paletted textures are dithered (on by default;@@ -50,10 +59,8 @@    They return `Left` for images they cannot encode (`BlpEncodeError`): an   empty image, a side above 65535 (after `blpMaxSize`), an invalid JPEG-  quality or size limit, or a file over 4 GiB.-- The older `encodeBlpJpeg`, `encodeBlpUncompressedWithAlpha`,-  `encodeBlpUncompressedWithoutAlpha` and their `write` variants are-  deprecated wrappers around `encodeBlpWith`.+  quality or size limit, a format the version does not have (such as+  JPEG in BLP2), or a file over 4 GiB.  The `Codec.Picture.Blp.Internal.*` modules expose the parser, decoder, encoder and file structure. They are useful for tools, but their API may@@ -74,28 +81,44 @@   - truncated or corrupt mipmaps. These cost only the levels from the damaged     one onwards; a damaged full size image still fails the file. -  The decoder reports malformed input as `Left`, including errors raised-  inside JuicyPixels' JPEG decoder. Memory use follows the size in the file-  header, so decoding refuses images above 8192×8192 by default; check-  untrusted files with `blpInfo` and set your own limit with-  `decodeBlpWith (BlpDecodeOptions n)`. One known exception: some corrupt JPEG-  streams make JuicyPixels loop forever+  The decoder reports malformed input as a `BlpDecodeError`, including+  errors raised inside JuicyPixels' JPEG decoder. Memory use follows the+  size in the file header, so decoding refuses images above 8192×8192 by+  default; check untrusted files with `blpInfo` and set your own limit with+  `decodeBlpWith (BlpDecodeOptions n)`. One known exception: some corrupt+  JPEG streams make JuicyPixels loop forever   ([Juicy.Pixels#230](https://github.com/haskell-github-trust/Juicy.Pixels/issues/230)),   so decoding untrusted files needs a timeout.-- **Encoding** to JPEG or a 256 colour palette, with or without alpha. The-  JPEG header is shared between mipmaps, as in Blizzard's own files.+- **Encoding** BLP1 to JPEG or a 256 colour palette, with or without alpha.+  The JPEG header is shared between mipmaps, as in Blizzard's own files.+- **Encoding BLP2** to DXT1, DXT3, DXT5, BGRA or a palette. The DXT encoder+  fits each block along its principal axis and refines it by least squares;+  on test images it is 0.2–0.6 dB below libsquish's cluster fit (1.8 dB on a+  small icon) and 0.7–2.5 dB above a plain range fit.  ## Limitations -- **BLP0, writing BLP2, and BLP2 with JPEG content.** BLP0 keeps mipmaps in-  separate `.b00`–`.b15` files. BLP2 is read only, and its JPEG variant,-  which no known file uses, is rejected. - **Lossy palette.** Uncompressed encoding quantises to 256 colours, so it is   lossy for images with more colours. Mipmaps after the first are mapped to   the first level's palette. - **Fixed header values.** The encoder writes `extra` as 3 with alpha and 5   without, and `hasMipmaps` as 5, or 0 for a single level. +## Out of scope++The library reads and writes every BLP version in use, BLP1 and BLP2.+These are left out on purpose, not for lack of time:++- **BLP0**, which keeps mipmaps in separate `.b00`–`.b15` files and appeared+  only in the Reign of Chaos beta.+- **BLP2 with JPEG content** (`type` 0): no known file uses it, so it is+  rejected rather than guessed at.+- **Writing 1-bit or 4-bit alpha, or chosen `extra` and `hasMipmaps`+  values,** through the public encoder. `Codec.Picture.Blp.Internal.Encoder`+  can write any `BlpStruct`.+- **Archives and other texture formats**, such as MPQ, CASC or the DDS+  textures of Warcraft III: Reforged.+ ## blp2any  Ready-made binaries for Windows, macOS (Apple Silicon) and Linux are@@ -107,7 +130,8 @@  ``` blp2any convert INPUT_PATH OUTPUT_PATH [-i FORMAT] [-f FORMAT] [-q QUALITY]-                [-b jpg|uncompressedWithAlpha|uncompressedWithoutAlpha]+                [-b jpg|uncompressedWithAlpha|uncompressedWithoutAlpha|dxt1|dxt3|dxt5|bgra]+                [--blp2]                 [--min-mipmap-size N] [--max-size PIXELS] [--no-dither]                 [-s|--shallow] [-p|--flatten] [--timeout SECONDS] blp2any stats DIRECTORY OUTPUT_PATH@@ -125,7 +149,9 @@     untrusted input, where some corrupt JPEG data makes decoding hang. The     output never overwrites its input.   - When writing BLP:-    - `-b` picks the internal format, JPEG by default.+    - `-b` picks the internal format, JPEG by default. `dxt1`, `dxt3`,+      `dxt5` and `bgra` write BLP2; the others write BLP1 unless `--blp2`+      is given, which writes a paletted BLP2.     - `--max-size PIXELS` halves the image until its larger side fits;       Warcraft III shows mip levels up to 512 pixels only.     - `--no-dither` turns off dithering for paletted output, which suits@@ -135,10 +161,11 @@       textures without their smallest levels; Warcraft III draws missing       levels as transparent black, so this is only safe for interface       images.-- **`stats`** groups the BLP1 files in a directory by their header values-  and copies one example of each group to `OUTPUT_PATH`. Files it cannot-  read, BLP2 files included, are reported and skipped. It helps when-  hunting for unusual files.+- **`stats`** groups the BLP files in a directory by their header values+  and copies one example of each group to `OUTPUT_PATH`, named after the+  group: content type, `alphaBits`, `extra` and `hasMipmaps` for BLP1, and+  `alphaDepth`, `alphaType` and `hasMips` for BLP2. Files it cannot read are+  reported and skipped. It helps when hunting for unusual files.  ``` blp2any convert war3/ textures-png/ -f png
bench/Main.hs view
@@ -11,7 +11,7 @@ import Codec.Picture import Codec.Picture.Blp import Codec.Picture.Blp.Internal.Convert (jpegAlphaIsUsable)-import Codec.Picture.Blp.Internal.Dxt (DxtFormat(..), decodeDxt, dxtLevelBytes)+import Codec.Picture.Blp.Internal.Dxt (DxtFormat(..), decodeDxt, dxtLevelBytes, encodeDxt) import Codec.Picture.Blp.Internal.Encoder (createMipMaps) import Codec.Picture.Jpg (encodeDirectJpegAtQualityWithMetadata) import Control.DeepSeq@@ -52,6 +52,10 @@         bench "decodeDxt/2048 dxt1" $ nf (fmap imageData . decodeDxt (Dxt1 True) 2048 2048) blocks     , env (return . force $ dxtBlocks Dxt5 2048) $ \blocks ->         bench "decodeDxt/2048 dxt5" $ nf (fmap imageData . decodeDxt Dxt5 2048 2048) blocks+    , env (return . force $ convertRGBA8 (texture 512)) $ \img ->+        bench "encodeDxt/512 dxt1" $ nf (encodeDxt (Dxt1 False)) img+    , env (return . force $ convertRGBA8 (texture 512)) $ \img ->+        bench "encodeDxt/512 dxt5" $ nf (encodeDxt Dxt5) img     ]   ] @@ -64,7 +68,7 @@       ]   | n <- sizes ]   where-    pixels img = imageData (convertRGBA8 img)+    pixels = imageData  -- | Encode an image of each size with the given format and level count. encodeGroup :: String -> BlpFormat -> Int -> (Int -> DynamicImage) -> [Int] -> Benchmark
blp2any/Convert.hs view
@@ -31,6 +31,10 @@     BlpJpeg   | BlpUncompressedWithAlpha   | BlpUncompressedWithoutAlpha+  | BlpDxt1+  | BlpDxt3+  | BlpDxt5+  | BlpBgra   deriving (Eq, Ord, Show, Enum, Bounded)  readBlpKind :: String -> Maybe BlpKind@@ -41,6 +45,10 @@   "uncompressedwithalpha" -> Just BlpUncompressedWithAlpha   "uncompressed2" -> Just BlpUncompressedWithoutAlpha   "uncompressedwithoutalpha" -> Just BlpUncompressedWithoutAlpha+  "dxt1" -> Just BlpDxt1+  "dxt3" -> Just BlpDxt3+  "dxt5" -> Just BlpDxt5+  "bgra" -> Just BlpBgra   _ -> Nothing  data ConvertFormat =@@ -99,6 +107,7 @@ , convertTimeout        :: Maybe Int , convertDither         :: Bool , convertMaxSize        :: Maybe Int+, convertBlp2           :: Bool }  convertFiles :: ConvertOptions -> IO ()@@ -130,7 +139,7 @@ -- | Try to load input file with desired format readInputFile :: FilePath -> ConvertFormat -> IO (Either String DynamicImage) readInputFile inputFile format = case format of-  Blp -> either (Left . ("parse error: " <>)) Right . decodeBlp <$> BS.readFile inputFile+  Blp -> either (Left . ("parse error: " <>) . displayBlpDecodeError) (Right . ImageRGBA8) . decodeBlp <$> BS.readFile inputFile   Png -> loadJuicy readPng   Jpeg -> loadJuicy readJpeg   Tiff -> loadJuicy readTiff@@ -166,7 +175,7 @@    let mipsCount = mipMapsUpTo convertBlpMinMipSize img   let encodeOptions = defaultBlpEncodeOptions { blpMipLevels = mipsCount, blpDither = convertDither, blpMaxSize = convertMaxSize }-  ExceptT $ convertionFunction distFormat convertQuality convertBlpKind encodeOptions outputFile img+  ExceptT $ convertionFunction distFormat convertQuality convertBlpKind convertBlp2 encodeOptions outputFile img   pure $ inputFile <> ": Success"  -- | A readable message for an encoding error.@@ -177,6 +186,15 @@   BlpInvalidJpegQuality q -> "JPEG quality must be 1 to 100, got " <> show q   BlpFileTooLarge -> "the BLP file would be larger than 4 GiB"   BlpInvalidMaxSize n -> "--max-size must be at least 1, got " <> show n+  BlpUnsupportedFormat v f -> formatName f <> " cannot be written as " <> (if v == Blp1 then "BLP1" else "BLP2")+  where+    formatName f = case f of+      BlpFormatJpeg _ -> "JPEG"+      BlpFormatPaletted _ -> "a palette"+      BlpFormatDxt1 -> "DXT1"+      BlpFormatDxt3 -> "DXT3"+      BlpFormatDxt5 -> "DXT5"+      BlpFormatBgra -> "BGRA"  -- | Run a conversion, giving up after the given number of seconds. withTimeout :: Maybe Int -> IO (Either String a) -> IO (Either String a)@@ -184,14 +202,20 @@ withTimeout (Just seconds) action =   fromMaybe (Left $ "gave up after " <> show seconds <> " s") <$> timeout (seconds * 1000000) action -convertionFunction :: ConvertFormat -> Int -> BlpKind -> BlpEncodeOptions -> FilePath -> DynamicImage -> IO (Either String ())-convertionFunction f quality blpKind encodeOptions path img = case f of+convertionFunction :: ConvertFormat -> Int -> BlpKind -> Bool -> BlpEncodeOptions -> FilePath -> DynamicImage -> IO (Either String ())+convertionFunction f quality blpKind blp2 encodeOptions path img = case f of   Blp -> do     let format = case blpKind of           BlpJpeg -> BlpFormatJpeg quality           BlpUncompressedWithAlpha -> BlpFormatPaletted True           BlpUncompressedWithoutAlpha -> BlpFormatPaletted False-    res <- writeBlpWith path encodeOptions { blpFormat = format } img+          BlpDxt1 -> BlpFormatDxt1+          BlpDxt3 -> BlpFormatDxt3+          BlpDxt5 -> BlpFormatDxt5+          BlpBgra -> BlpFormatBgra+        -- DXT and BGRA exist only in BLP2; a palette goes either way.+        version = if blp2 || blpKind `elem` [BlpDxt1, BlpDxt3, BlpDxt5, BlpBgra] then Blp2 else Blp1+    res <- writeBlpWith path encodeOptions { blpFormat = format, blpVersion = version } img     pure $ either (Left . describeEncodeError) Right res   Png -> do     res <- writeDynamicPng path img
blp2any/Main.hs view
@@ -43,11 +43,12 @@       <*> option qualityR (long "quality" <> short 'q' <> value 90 <> help "Quality level for formats with lossy compression, 1 to 100 (default 90)")       <*> fmap not (switch (long "flatten" <> long "notPreserveStructure" <> short 'p' <> help "Write every converted file straight into OUTPUT_PATH instead of recreating subfolders (files with the same name overwrite each other)"))       <*> switch (long "shallow" <> short 's' <> help "Only convert files directly in INPUT_PATH, not in its subfolders")-      <*> option blpKindR (long "blpFormat" <> short 'b' <> value BlpJpeg <> help "Specifies what BLP internal format to use when converting to BLP. Values: jpg uncompressedWithAlpha uncompressedWithoutAlpha")+      <*> option blpKindR (long "blpFormat" <> short 'b' <> value BlpJpeg <> help "Specifies what BLP internal format to use when converting to BLP. Values: jpg uncompressedWithAlpha uncompressedWithoutAlpha (BLP1), dxt1 dxt3 dxt5 bgra (BLP2)")       <*> option auto (long "min-mipmap-size" <> value 1 <> help "Smallest mipmap side to keep when writing BLP (default 1)")       <*> optional (option auto (long "timeout" <> metavar "SECONDS" <> help "Give up on a file after this many seconds; some corrupt JPEG data makes decoding hang"))       <*> fmap not (switch (long "no-dither" <> help "Do not dither paletted BLP textures; better for icons and pixel art"))       <*> optional (option auto (long "max-size" <> metavar "PIXELS" <> help "When writing BLP, halve the image until its larger side is at most this; Warcraft III shows mip levels up to 512 only"))+      <*> switch (long "blp2" <> help "Write BLP2 (World of Warcraft) even for a paletted format; DXT and BGRA always write BLP2")     addCommand "stats" "Groups the BLPs in a folder by header values and saves one example of each group" (uncurry collectStatistics) $ (,)       <$> strArgument (metavar "DIRECTORY" <> help "Input folder that stores blps (subfolders are processed)")       <*> strArgument (metavar "OUTPUT_PATH" <> help "Here blp samples would be stored")@@ -55,4 +56,4 @@   where     ver = showVersion Paths.version     headerDesc = ""-    desc = "Converting BLP textures to and from PNG, JPEG, TIFF, GIF and BMP. Reads BLP1 (Warcraft III) and BLP2 (World of Warcraft), writes BLP1"+    desc = "Converting BLP textures to and from PNG, JPEG, TIFF, GIF and BMP. Reads BLP1 (Warcraft III) and BLP2 (World of Warcraft), writes both"
blp2any/Statistics.hs view
@@ -5,6 +5,7 @@   ) where  import Codec.Picture.Blp.Internal.Data+import Codec.Picture.Blp (BlpVersion(..), displayBlpDecodeError) import Codec.Picture.Blp.Internal.Parser import Control.Monad import Data.Char (toLower)@@ -19,7 +20,11 @@  import File  -type BlpInfo = (BlpCompression, Word32, Word32, Word32)+-- | What files are grouped by: version, content type and the header fields+-- that vary between files, with their names.+data BlpInfo = BlpInfo BlpVersion BlpCompression [(String, Word32)]+  deriving (Eq, Ord)+ type BlpStat = (FilePath, Int)  type StatMap = M.Map BlpInfo BlpStat@@ -33,7 +38,7 @@ collectSample :: StatMap -> FilePath -> IO StatMap collectSample stats inputFile = do   putStrLn $ "Sampling " ++ inputFile-  result <- tryAll (parseBlp <$> BS.readFile inputFile)+  result <- tryAll (either (Left . displayBlpDecodeError) Right . parseBlpFile <$> BS.readFile inputFile)   case join result of     Left err -> do       -- One unreadable file should not lose the statistics of the rest.@@ -41,8 +46,11 @@       return stats     Right blp -> return $ adjust' (updateStat inputFile) (blpType blp) stats -blpType :: BlpStruct -> BlpInfo -blpType blp@BlpStruct{..} = (blpCompression blp, blpAlphaBits, blpExtra, blpHasMipmaps)+blpType :: BlpFile -> BlpInfo+blpType (BlpFile1 blp@BlpStruct{..}) = BlpInfo Blp1 (blpCompression blp)+  [("alpha", blpAlphaBits), ("extra", blpExtra), ("mips", blpHasMipmaps)]+blpType (BlpFile2 blp@Blp2Struct{..}) = BlpInfo Blp2 (blp2Compression blp)+  [("alpha", fromIntegral blp2AlphaDepth), ("alphaType", fromIntegral blp2AlphaType), ("mips", fromIntegral blp2HasMips)]  adjust' :: (Maybe BlpStat -> BlpStat) -> BlpInfo -> StatMap -> StatMap  adjust' f k m = M.insert k (f $ M.lookup k m) m@@ -54,8 +62,8 @@ printStatistics :: FilePath -> StatMap -> IO ()  printStatistics dir stats = do     createDirectoryIfMissing True dir-    forM_ (M.toList stats) $ \((compression, alphaBits, extra, hasMipmaps), (path, i)) -> do -      let postfix = L.intercalate "-" [show compression, "alpha" ++ show alphaBits, "extra" ++ show extra, "mips" ++ show hasMipmaps]+    forM_ (M.toList stats) $ \(BlpInfo version compression fields, (path, i)) -> do+      let postfix = L.intercalate "-" $ [show version | version /= Blp1] ++ show compression : [name ++ show v | (name, v) <- fields]       copyFile path $ dir </> dropExtension (takeFileName path) ++ "-" ++ postfix ++ ".blp"       putStrLn $ postfix ++ ": " ++ show i ++ " images" 
docs/format.md view
@@ -1,11 +1,10 @@ # The BLP format  BLP ("Blizzard Picture") is the texture format of Warcraft III (BLP1) and-World of Warcraft (BLP2). This library reads and writes **BLP1** and reads-**BLP2**. This page describes the format as the community has documented-it, and states for each part what the library does with it, including where-it falls short. The sections up to [BLP2](#blp2-world-of-warcraft) are about-BLP1.+World of Warcraft (BLP2). This library reads and writes both. This page+describes the format as the community has documented it, and states for+each part what the library does with it, including where it falls short.+The sections up to [BLP2](#blp2-world-of-warcraft) are about BLP1.  Primary sources: @@ -122,8 +121,8 @@   (8192×8192 by default) before allocating anything. `blpInfo` reads the   size without decoding. - **JuicyPixels.** The JPEG streams themselves are decoded by JuicyPixels.-  Errors it raises are turned into `Left`, but some corrupt streams make it-  loop forever+  Errors it raises on mip 0 become a `BlpCorruptImage`, and on a later level+  end the list of levels, but some corrupt streams make it loop forever   ([Juicy.Pixels#230](https://github.com/haskell-github-trust/Juicy.Pixels/issues/230)),   so decoding untrusted files needs a timeout. - **Header size.** Warcraft III crashes on a `jpegHeaderSize` above 624.@@ -164,7 +163,7 @@  ## BLP2 (World of Warcraft) -The library reads BLP2 files; it does not write them. The sections above+The library reads and writes BLP2 files. The sections above apply where noted.  ### Header@@ -230,6 +229,31 @@  Four bytes per pixel: blue, green, red and alpha. +### Writing++`encodeBlpWith` with `blpVersion = Blp2` writes type 1 files. The header+values follow files from World of Warcraft:++| `blpFormat` | encoding | alphaDepth | alphaType |+|---|---|---|---|+| `BlpFormatPaletted True` / `False` | 1 | 8 / 0 | 8 |+| `BlpFormatDxt1` | 2 | 1 if some alpha is below 128, else 0 | 0 |+| `BlpFormatDxt3` | 2 | 8 | 1 |+| `BlpFormatDxt5` | 2 | 8 | 7 |+| `BlpFormatBgra` | 3 | 8 | 8 |++hasMips is 1, or 0 for a single level. The palette is written in full for+paletted content and as zeros otherwise, and the levels follow it. Mipmaps+are made as for BLP1, by averaging 2×2 blocks weighted by alpha.++The DXT encoder fits each block's colours along their principal axis,+refines the two endpoints by least squares while that lowers the error, and+compares the result with endpoints from a single-colour table, which decodes+flat colours exactly where rounding to RGB565 would not. Errors are measured+on what the decoder above gives back. DXT1 with alpha makes blocks with+pixels below alpha 128 three-colour, those pixels transparent. DXT3 rounds+alpha to the nearest of its 16 levels, and DXT5 tries both alpha modes.+ ### Damaged levels  As in BLP1, a level 0 too short for its size fails the file, and the list of@@ -249,7 +273,7 @@ ## Not supported  - **BLP0**, which stores each mip level in a separate `.b00` … `.b15` file.-- **Writing BLP2**, and BLP2 files with JPEG content (`type = 0`).+- **BLP2 files with JPEG content** (`type = 0`). - **Writing 1-bit or 4-bit alpha**, or a chosen `extra` or `hasMipmaps`   value, through the public encoders. `Codec.Picture.Blp.Internal.Encoder`   can write any `BlpStruct`.
src/Codec/Picture/Blp.hs view
@@ -1,7 +1,7 @@ {-| Decoding and encoding of BLP, the texture format of Warcraft III (BLP1) and-World of Warcraft (BLP2), on top of JuicyPixels. BLP1 is read and written,-BLP2 is read.+World of Warcraft (BLP2), on top of JuicyPixels. Both versions are read and+written.  BLP1 comes in two flavours: JPEG compressed and uncompressed with a 256 colour palette. Uncompressed textures either carry an alpha plane or not.@@ -9,12 +9,13 @@ 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.+Decoding yields t'Image' t'PixelRGBA8' whatever the content, and reports+files it cannot read as a 'BlpDecodeError'. 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)+> writePng "war3mapMap.png" img > > Right png <- readImage "icon.png" > result <- writeBlpWith "icon.blp" defaultBlpEncodeOptions png@@ -32,6 +33,8 @@   , decodeBlpMipmapsWith   , BlpDecodeOptions(..)   , defaultBlpDecodeOptions+  , BlpDecodeError(..)+  , displayBlpDecodeError   -- * Inspecting   , blpInfo   , BlpInfo(..)@@ -45,15 +48,6 @@   , BlpFormat(..)   , BlpEncodeError(..)   , mipMapsUpTo-  -- * Deprecated encoders-  -- | These predate 'encodeBlpWith'. They clamp the JPEG quality to 1..100-  -- and throw on images 'encodeBlpWith' rejects.-  , writeBlpJpeg-  , writeBlpUncompressedWithAlpha-  , writeBlpUncompressedWithoutAlpha-  , encodeBlpJpeg-  , encodeBlpUncompressedWithAlpha-  , encodeBlpUncompressedWithoutAlpha   ) where  import Codec.Picture@@ -70,44 +64,43 @@  -- | 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 :: FilePath -> IO (Either BlpDecodeError (Image PixelRGBA8)) readBlp fp = decodeBlp `fmap` BS.readFile fp  -- | Read a BLP file and decode the full size image followed by its mipmaps. -- See 'decodeBlpMipmaps'.-readBlpMipmaps :: FilePath -> IO (Either String [DynamicImage])+readBlpMipmaps :: FilePath -> IO (Either BlpDecodeError [Image PixelRGBA8]) readBlpMipmaps fp = decodeBlpMipmaps `fmap` BS.readFile fp  -- | Decode the full size image of a BLP file with 'defaultBlpDecodeOptions'.--- The result is 'ImageRGBA8'.-decodeBlp :: ByteString -> Either String DynamicImage+decodeBlp :: ByteString -> Either BlpDecodeError (Image PixelRGBA8) decodeBlp = decodeBlpWith defaultBlpDecodeOptions --- | Decode the full size image of a BLP file. The result is 'ImageRGBA8'.-decodeBlpWith :: BlpDecodeOptions -> ByteString -> Either String DynamicImage+-- | Decode the full size image of a BLP file.+decodeBlpWith :: BlpDecodeOptions -> ByteString -> Either BlpDecodeError (Image PixelRGBA8) decodeBlpWith opts bs = do   is <- decodeBlpMipmapsWith opts bs   case is of     i : _ -> Right i-    [] -> Left "No data in BLP"+    [] -> Left BlpNoImageData  -- | Decode the full size image followed by its mipmaps with -- 'defaultBlpDecodeOptions'. See 'decodeBlpMipmapsWith'.-decodeBlpMipmaps :: ByteString -> Either String [DynamicImage]+decodeBlpMipmaps :: ByteString -> Either BlpDecodeError [Image PixelRGBA8] decodeBlpMipmaps = decodeBlpMipmapsWith defaultBlpDecodeOptions --- | Decode the full size image followed by its mipmaps, all 'ImageRGBA8'.+-- | Decode the full size image followed by its mipmaps. -- -- Element @i@ is always mip level @i@. 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.-decodeBlpMipmapsWith :: BlpDecodeOptions -> ByteString -> Either String [DynamicImage]+decodeBlpMipmapsWith :: BlpDecodeOptions -> ByteString -> Either BlpDecodeError [Image PixelRGBA8] decodeBlpMipmapsWith opts bs = parseBlpFile bs >>= decodeBlpFile opts  -- | Read the header of a BLP file. This checks what 'decodeBlp' checks before -- decoding (format, size, mipmap slots) but decodes no pixels, so it is cheap -- on any input. Use it to reject textures that are too large.-blpInfo :: ByteString -> Either String BlpInfo+blpInfo :: ByteString -> Either BlpDecodeError BlpInfo blpInfo bs = blpFileInfo <$> parseBlpFile bs  -- | Number of mip levels, mip 0 included, whose larger side is at least the@@ -119,65 +112,35 @@   -> Int mipMapsUpTo minSize img = max 1 $ mipLevelsDownTo minSize (dynamicMap imageWidth img) (dynamicMap imageHeight img) --- | Encode an image as a BLP1 file.+-- | Encode an image as a BLP1 or BLP2 file, as 'blpVersion' says. encodeBlpWith :: BlpEncodeOptions -> DynamicImage -> Either BlpEncodeError BSL.ByteString encodeBlpWith opts@BlpEncodeOptions{..} img   | dynamicMap imageWidth img < 1 || dynamicMap imageHeight img < 1 = Left BlpEmptyImage+  | not (formatInVersion blpVersion blpFormat) = Left $ BlpUnsupportedFormat blpVersion blpFormat   | Just limit <- blpMaxSize, limit < 1 = Left $ BlpInvalidMaxSize limit   | w > 65535 || h > 65535 = Left $ BlpImageTooLarge w h   | BlpFormatJpeg quality <- blpFormat, quality < 1 || quality > 100 = Left $ BlpInvalidJpegQuality quality-  | BSL.length file > 0xFFFFFFFF = Left BlpFileTooLarge-  | otherwise = Right file+  | otherwise = do+      file <- case blpVersion of+        Blp1 -> encodeBlp <$> toBlpStruct opts source+        Blp2 -> encodeBlp2 <$> toBlp2Struct opts source+      if BSL.length file > 0xFFFFFFFF then Left BlpFileTooLarge else Right file   where     source = maybe img (`shrinkTo` img) blpMaxSize     w = dynamicMap imageWidth source     h = dynamicMap imageHeight source-    file = encodeBlp (toBlpStruct opts source) +-- | Whether a version of the format can hold the content type.+formatInVersion :: BlpVersion -> BlpFormat -> Bool+formatInVersion version format = case (version, format) of+  (_, BlpFormatPaletted _) -> True+  (Blp1, BlpFormatJpeg _) -> True+  (Blp2, BlpFormatDxt1) -> True+  (Blp2, BlpFormatDxt3) -> True+  (Blp2, BlpFormatDxt5) -> True+  (Blp2, BlpFormatBgra) -> True+  _ -> False+ -- | Encode an image with 'encodeBlpWith' and write it to a file. writeBlpWith :: FilePath -> BlpEncodeOptions -> DynamicImage -> IO (Either BlpEncodeError ()) writeBlpWith fp opts img = traverse (BSL.writeFile fp) (encodeBlpWith opts img)---- | Encode with 'encodeBlpJpeg' and write the result to a file.-writeBlpJpeg :: FilePath -> Int -> Int -> DynamicImage -> IO ()-writeBlpJpeg fp quality numMips img = BSL.writeFile fp $ encodeBlpJpeg quality numMips img-{-# DEPRECATED writeBlpJpeg "Use writeBlpWith" #-}---- | Encode with 'encodeBlpUncompressedWithAlpha' and write the result to a file.-writeBlpUncompressedWithAlpha :: FilePath -> Int -> DynamicImage -> IO ()-writeBlpUncompressedWithAlpha fp numMips img = BSL.writeFile fp $ encodeBlpUncompressedWithAlpha numMips img-{-# DEPRECATED writeBlpUncompressedWithAlpha "Use writeBlpWith" #-}---- | Encode with 'encodeBlpUncompressedWithoutAlpha' and write the result to a--- file.-writeBlpUncompressedWithoutAlpha :: FilePath -> Int -> DynamicImage -> IO ()-writeBlpUncompressedWithoutAlpha fp numMips img = BSL.writeFile fp $ encodeBlpUncompressedWithoutAlpha numMips img-{-# DEPRECATED writeBlpUncompressedWithoutAlpha "Use writeBlpWith" #-}---- | Encode as a JPEG compressed BLP; see 'BlpFormatJpeg'.-encodeBlpJpeg :: Int -- ^ JPEG quality, clamped to 1..100-  -> Int -- ^ Number of mip levels to write-  -> DynamicImage-  -> BSL.ByteString-encodeBlpJpeg quality numMips =-  orThrow "encodeBlpJpeg" . encodeBlpWith defaultBlpEncodeOptions { blpFormat = BlpFormatJpeg (max 1 (min 100 quality)), blpMipLevels = numMips }-{-# DEPRECATED encodeBlpJpeg "Use encodeBlpWith with BlpFormatJpeg" #-}---- | Encode with a 256 colour palette and 8-bit alpha; see 'BlpFormatPaletted'.-encodeBlpUncompressedWithAlpha :: Int -- ^ Number of mip levels to write-  -> DynamicImage-  -> BSL.ByteString-encodeBlpUncompressedWithAlpha numMips =-  orThrow "encodeBlpUncompressedWithAlpha" . encodeBlpWith defaultBlpEncodeOptions { blpFormat = BlpFormatPaletted True, blpMipLevels = numMips }-{-# DEPRECATED encodeBlpUncompressedWithAlpha "Use encodeBlpWith with BlpFormatPaletted True" #-}---- | Encode with a 256 colour palette and no alpha; see 'BlpFormatPaletted'.-encodeBlpUncompressedWithoutAlpha :: Int -- ^ Number of mip levels to write-  -> DynamicImage-  -> BSL.ByteString-encodeBlpUncompressedWithoutAlpha numMips =-  orThrow "encodeBlpUncompressedWithoutAlpha" . encodeBlpWith defaultBlpEncodeOptions { blpFormat = BlpFormatPaletted False, blpMipLevels = numMips }-{-# DEPRECATED encodeBlpUncompressedWithoutAlpha "Use encodeBlpWith with BlpFormatPaletted False" #-}--orThrow :: String -> Either BlpEncodeError a -> a-orThrow name = either (\e -> error $ name ++ ": " ++ show e) id
src/Codec/Picture/Blp/Internal/Decoder.hs view
@@ -33,22 +33,22 @@ import Codec.Picture.Blp.Internal.Parser import Codec.Picture.Blp.Types --- | Decode the full size image of a parsed file followed by its mipmaps, all--- 'ImageRGBA8'. See 'Codec.Picture.Blp.decodeBlpMipmapsWith'.-decodeBlpStruct :: BlpDecodeOptions -> BlpStruct -> Either String [DynamicImage]+-- | Decode the full size image of a parsed file followed by its mipmaps.+-- See 'Codec.Picture.Blp.decodeBlpMipmapsWith'.+decodeBlpStruct :: BlpDecodeOptions -> BlpStruct -> Either BlpDecodeError [Image PixelRGBA8] decodeBlpStruct opts blp = do   let width = fromIntegral $ blpWidth blp       height = fromIntegral $ blpHeight blp   checkSize opts width height   case blpExt blp of     BlpJpeg {..} -> case blpJpegData of-      [] -> Left "No JPEG mipmap data in BLP"+      [] -> Left BlpNoImageData       level0 : rest -> do         -- Mipmap 0 is the full-resolution image; a failure there fails the         -- file. Tail mipmaps are decoded lazily and the chain stops at the         -- first one that fails, so a corrupt tail costs only the levels from         -- it on, and element i stays level i.-        mip0 <- decodeLevel 0 level0+        mip0 <- either (Left . BlpCorruptImage) Right (decodeLevel 0 level0)         let validPrefix l (j : js) | Right m <- decodeLevel l j = m : validPrefix (l + 1) js             validPrefix _ _ = []             -- The alpha decision is taken once, from the full size mipmap, so that@@ -69,11 +69,11 @@         if fw > lw || fh > lh           then Left $ "JPEG mipmap " ++ show l ++ " is " ++ show fw ++ "x" ++ show fh             ++ ", larger than its level size " ++ show lw ++ "x" ++ show lh-          else safeDecodeJpeg jpeg+          else either (Left . (("JPEG mipmap " ++ show l ++ ": ") ++)) Right (safeDecodeJpeg jpeg)       toLevel useAlpha (l, img) = let         (lw, lh) = levelSize width height l         rgba = convertRGBA8 (fromBlpJpeg useAlpha img)-        in ImageRGBA8 $ if imageWidth rgba == lw && imageHeight rgba == lh+        in if imageWidth rgba == lw && imageHeight rgba == lh           then rgba           else generateImage (\x y -> if x < imageWidth rgba && y < imageHeight rgba             then pixelAt rgba x y else PixelRGBA8 0 0 0 0) lw lh@@ -81,10 +81,10 @@     BlpPaletted {..} -> palettedImages width height (fromIntegral (blpAlphaBits blp)) blpPalette blpPalettedMipMaps  -- | Decode the full size image of a parsed BLP2 file followed by its--- mipmaps, all 'ImageRGBA8'. Levels are taken as in BLP1: a damaged level 0+-- mipmaps. Levels are taken as in BLP1: a damaged level 0 -- fails the file, and the list stops at the first damaged mipmap. An alpha -- depth of 0 makes every level opaque.-decodeBlp2Struct :: BlpDecodeOptions -> Blp2Struct -> Either String [DynamicImage]+decodeBlp2Struct :: BlpDecodeOptions -> Blp2Struct -> Either BlpDecodeError [Image PixelRGBA8] decodeBlp2Struct opts blp = do   checkSize opts width height   case blp2Content blp of@@ -96,10 +96,10 @@     height = fromIntegral $ blp2Height blp     finish = if blp2AlphaDepth blp == 0 then setOpaque else id     levels decode = case blp2MipMaps blp of-      [] -> Left "No mipmap data in BLP2"+      [] -> Left BlpNoImageData       mips -> case [decode (levelSize width height l) mip | (l, mip) <- zip [0 ..] mips] of-        Left e : _ -> Left $ "Mipmap 0: " ++ e-        decoded -> Right [ImageRGBA8 (finish img) | Right img <- takeWhile isRight decoded]+        Left e : _ -> Left (BlpCorruptImage e)+        decoded -> Right [finish img | Right img <- takeWhile isRight decoded]  -- | A level of four bytes per pixel, B, G, R and A. bgraLevel :: (Int, Int) -> ByteString -> Either String (Image PixelRGBA8)@@ -115,15 +115,14 @@ setOpaque = pixelMap (\(PixelRGBA8 r g b _) -> PixelRGBA8 r g b 255)  -- | Reject an image above the size limit before anything is allocated.-checkSize :: BlpDecodeOptions -> Int -> Int -> Either String ()+checkSize :: BlpDecodeOptions -> Int -> Int -> Either BlpDecodeError () checkSize opts width height = when (width * height > blpMaxPixels opts) . Left $-  "Image size " ++ show width ++ "x" ++ show height ++ " is above the limit of "-  ++ show (blpMaxPixels opts) ++ " pixels"+  BlpTooLarge width height (blpMaxPixels opts)  -- | Paletted levels with the declared alpha depth, if it is valid for -- paletted content. A mip 0 too short to hold that much alpha has none: -- some tools declare alpha and then write only the index list.-palettedImages :: Int -> Int -> Int -> V.Vector PixelRGBA8 -> [ByteString] -> Either String [DynamicImage]+palettedImages :: Int -> Int -> Int -> V.Vector PixelRGBA8 -> [ByteString] -> Either BlpDecodeError [Image PixelRGBA8] palettedImages width height declaredBits palette mips = palettedLevels width height alphaBits palette mips   where     declared = if declaredBits `elem` [1, 4, 8] then declaredBits else 0@@ -132,7 +131,7 @@       _ -> declared  -- | Decode a parsed BLP1 or BLP2 file.-decodeBlpFile :: BlpDecodeOptions -> BlpFile -> Either String [DynamicImage]+decodeBlpFile :: BlpDecodeOptions -> BlpFile -> Either BlpDecodeError [Image PixelRGBA8] decodeBlpFile opts (BlpFile1 blp) = decodeBlpStruct opts blp decodeBlpFile opts (BlpFile2 blp) = decodeBlp2Struct opts blp @@ -180,16 +179,16 @@ -- @w / 2^l@ by @h / 2^l@, but never smaller than 1x1. The list stops at the -- first level whose data is too short for its size, so the result is always a -- prefix of the mip levels; a short level 0 fails the file.-palettedLevels :: Int -> Int -> Int -> V.Vector PixelRGBA8 -> [ByteString] -> Either String [DynamicImage]+palettedLevels :: Int -> Int -> Int -> V.Vector PixelRGBA8 -> [ByteString] -> Either BlpDecodeError [Image PixelRGBA8] palettedLevels width height alphaBits palette mips-  | null mips = Right []+  | null mips = Left BlpNoImageData   | otherwise = case go 0 mips of-      [] -> Left "Mipmap 0 is too short for the image size"+      [] -> Left (BlpCorruptImage "mip 0 is too short for the image size")       levels -> Right levels   where     go l (mip : rest)       | BS.length mip >= levelBytes (w * h) alphaBits =-          ImageRGBA8 (generateImage (pixel w (BS.drop (w * h) mip) mip) w h) : go (l + 1) rest+          generateImage (pixel w (BS.drop (w * h) mip) mip) w h : go (l + 1) rest       where         (w, h) = levelSize width height l     go _ _ = []
src/Codec/Picture/Blp/Internal/Dxt.hs view
@@ -26,18 +26,22 @@     DxtFormat(..)   , dxtLevelBytes   , decodeDxt+  , encodeDxt   ) where  import Codec.Picture-import Control.Monad (when)+import Control.Monad (forM_, when) import Control.Monad.ST (ST) import Data.Bits+import qualified Data.List as L import Data.ByteString (ByteString) import Data.Word  import qualified Data.ByteString as BS+import qualified Data.ByteString.Internal as BI import qualified Data.ByteString.Unsafe as BSU import qualified Data.Vector.Storable as VS+import qualified Data.Vector.Unboxed as VU import qualified Data.Vector.Storable.Mutable as MVS  -- | Block compression formats.@@ -142,14 +146,7 @@       Dxt5 -> word (o + 2) 6     !a0 = byte o     !a1 = byte (o + 1)-    dxt5Alpha :: Int -> Int-    dxt5Alpha j = case j of-      0 -> a0-      1 -> a1-      _ | a0 > a1 -> ((8 - j) * a0 + (j - 1) * a1) `quot` 7-        | j < 6 -> ((6 - j) * a0 + (j - 1) * a1) `quot` 5-        | j == 6 -> 0-        | otherwise -> 255+    dxt5Alpha = dxt5AlphaValue a0 a1      -- Copying a pixel as one 32-bit word keeps its bytes in order on any     -- platform.@@ -180,3 +177,304 @@  asWord32 :: MVS.MVector s Word8 -> MVS.MVector s Word32 asWord32 = MVS.unsafeCast++-- | Alpha value of 3-bit index @j@ in a DXT5 block with endpoints @a0@ and+-- @a1@: six interpolated values between them if @a0 > a1@, otherwise four+-- plus 0 and 255.+dxt5AlphaValue :: Int -> Int -> Int -> Int+dxt5AlphaValue a0 a1 j = case j of+  0 -> a0+  1 -> a1+  _ | a0 > a1 -> ((8 - j) * a0 + (j - 1) * a1) `quot` 7+    | j < 6 -> ((6 - j) * a0 + (j - 1) * a1) `quot` 5+    | j == 6 -> 0+    | otherwise -> 255++------------------------------------------------------------------------------+-- Encoding++-- | Encode an image as DXT blocks, 'dxtLevelBytes' long.+--+-- Colours are fitted per block along the principal axis of the block's+-- pixels (range fit), and the endpoints are then refined by least squares+-- for the chosen indices while that lowers the error. Errors are measured+-- on what 'decodeDxt' gives back. DXT1 with 'True' writes blocks with+-- pixels whose alpha is below 128 in three-colour mode, those pixels+-- transparent; with 'False' alpha is ignored. DXT3 alpha is rounded to 4+-- bits; DXT5 alpha takes whichever of its two modes fits the block better.+-- Pixels outside the image, in blocks on the right and bottom edges, do not+-- count.+encodeDxt :: DxtFormat -> Image PixelRGBA8 -> ByteString+encodeDxt fmt img = BI.fromForeignPtr fptr 0 len+  where+    w = imageWidth img+    h = imageHeight img+    src = imageData img+    blocksWide = (w + 3) `div` 4+    blocksHigh = (h + 3) `div` 4+    size = blockBytes fmt+    (fptr, len) = VS.unsafeToForeignPtr0 $ VS.create $ do+      dst <- MVS.new (blocksWide * blocksHigh * size)+      forM_ [0 .. blocksHigh - 1] $ \by -> forM_ [0 .. blocksWide - 1] $ \bx ->+        encodeBlock fmt (blockPixels bx by) dst ((by * blocksWide + bx) * size)+      return dst+    -- The pixels of a block inside the image, in order, with their index in+    -- the block.+    blockPixels bx by+      | 4 * bx + 3 < w && 4 * by + 3 < h = gather wholeBlock+      | otherwise = gather (VU.filter (\i -> 4 * bx + i .&. 3 < w && 4 * by + i `shiftR` 2 < h) wholeBlock)+      where+        gather pos =+          let channel c = VU.map (\i -> fromIntegral (VS.unsafeIndex src (4 * ((4 * by + i `shiftR` 2) * w + 4 * bx + i .&. 3) + c))) pos+          in Pixels pos (channel 0) (channel 1) (channel 2) (channel 3)++-- | The positions of a block's 16 pixels.+wholeBlock :: VU.Vector Int+wholeBlock = VU.enumFromN 0 16++-- | Some of a block's pixels: their index in the block and their channels.+data Pixels = Pixels !(VU.Vector Int) !(VU.Vector Int) !(VU.Vector Int) !(VU.Vector Int) !(VU.Vector Int)++-- | Write one block at the given byte offset.+encodeBlock :: DxtFormat -> Pixels -> MVS.MVector s Word8 -> Int -> ST s ()+encodeBlock fmt px dst o = case fmt of+  Dxt1 transparent -> encodeColour transparent False px dst o+  Dxt3 -> dxt3AlphaBlock px dst o >> encodeColour False True px dst (o + 8)+  Dxt5 -> dxt5AlphaBlock px dst o >> encodeColour False True px dst (o + 8)++-- | Write the low n bytes of a number, least significant first.+putLE :: MVS.MVector s Word8 -> Int -> Int -> Int -> ST s ()+putLE dst o n v = forM_ [0 .. n - 1] $ \k -> MVS.unsafeWrite dst (o + k) (fromIntegral (v `shiftR` (8 * k)))++-- | A colour block. With @transparent@, pixels with alpha below 128 make the+-- block three-colour with them at index 3. With @fourAlways@ (DXT3 and+-- DXT5), the decoder uses four colours whatever the endpoint order.+encodeColour :: Bool -> Bool -> Pixels -> MVS.MVector s Word8 -> Int -> ST s ()+encodeColour transparent fourAlways (Pixels pos rs gs bs as) dst o = do+  putLE dst o 2 c0+  putLE dst (o + 2) 2 c1+  putLE dst (o + 4) 4 (indices .|. clearBits)+  where+    isClear j = transparent && VU.unsafeIndex as j < 128+    keep = VU.filter (not . isClear) (VU.enumFromN 0 (VU.length pos))+    clearBits = VU.foldl' (\acc j -> if isClear j then acc .|. 3 `shiftL` (2 * VU.unsafeIndex pos j) else acc) 0 (VU.enumFromN 0 (VU.length pos))+    threeColour = VU.length keep < VU.length pos+    -- The pixels the colours are fitted to.+    fpos = VU.map (VU.unsafeIndex pos) keep+    pr = VU.map (VU.unsafeIndex rs) keep+    pg = VU.map (VU.unsafeIndex gs) keep+    pb = VU.map (VU.unsafeIndex bs) keep+    n = VU.length keep++    -- A candidate replaces the best only with a strictly lower error, so+    -- none is tried once the error is 0, nor endpoints already tried.+    Fit c0 c1 _ indices+      | n == 0 = Fit 0 0 0 0 -- all transparent+      | otherwise =+          let fitted = refine (3 :: Int) (assign (endpoints (rangeFit pr pg pb)))+          in if fitError fitted == 0 then fitted else better fitted (assign single)+    better a b = if fitError b < fitError a then b else a+    refine 0 best = best+    refine k best@(Fit e0 e1 err _)+      | err == 0 || ends == (e0, e1) = best+      | fitError next < err = refine (k - 1) next+      | otherwise = best+      where+        ends = endpoints (leastSquares best)+        next = assign ends++    -- Endpoints in the order the mode needs: c0 > c1 for four colours,+    -- c0 <= c1 for three.+    endpoints (a, b)+      | threeColour = (min qa qb, max qa qb)+      | otherwise = (max qa qb, min qa qb)+      where+        qa = pack565 a+        qb = pack565 b++    -- The nearest usable colour for every pixel, packed as 2-bit indices by+    -- position in the block, and the total error.+    assign (e0, e1) =+      let four = fourAlways || e0 > e1+          pal = palette four e0 e1+          -- The error of colour k, times 4, plus k: the smallest is the+          -- nearest colour, the lower index on ties. One number instead of a+          -- pair keeps the loop free of allocation.+          nearest j =+            let r = VU.unsafeIndex pr j; g = VU.unsafeIndex pg j; b = VU.unsafeIndex pb j+                cost k = 4 * (sq (r - VU.unsafeIndex pal (3 * k)) + sq (g - VU.unsafeIndex pal (3 * k + 1)) + sq (b - VU.unsafeIndex pal (3 * k + 2))) + k+                three = min (cost 0) (min (cost 1) (cost 2))+            in if four then min three (cost 3) else three+          loop j !err !bits+            | j >= n = Fit e0 e1 err bits+            | otherwise =+                let c = nearest j+                in loop (j + 1) (err + c `shiftR` 2) (bits .|. (c .&. 3) `shiftL` (2 * VU.unsafeIndex fpos j))+      in loop 0 0 0++    -- Endpoints that best reproduce the pixels with their current indices:+    -- index k stands for weight w_k of the first endpoint and 1 - w_k of the+    -- second.+    leastSquares (Fit e0 e1 _ packed) =+      let four = fourAlways || e0 > e1+          weight :: Int -> Double+          weight k = case k of+            0 -> 1+            1 -> 0+            2 -> if four then 2 / 3 else 1 / 2+            _ -> 1 / 3+          step (Sums aa bb2 ab2 ar0 ag0 ab0 br0 bg0 bb0) j =+            let a = weight ((packed `shiftR` (2 * VU.unsafeIndex fpos j)) .&. 3)+                b' = 1 - a+                fr = fromIntegral (VU.unsafeIndex pr j)+                fg = fromIntegral (VU.unsafeIndex pg j)+                fb = fromIntegral (VU.unsafeIndex pb j)+            in Sums (aa + a * a) (bb2 + b' * b') (ab2 + a * b')+                 (ar0 + a * fr) (ag0 + a * fg) (ab0 + a * fb)+                 (br0 + b' * fr) (bg0 + b' * fg) (bb0 + b' * fb)+          Sums saa sbb sab ar ag ab br bg bb = L.foldl' step (Sums 0 0 0 0 0 0 0 0 0) [0 .. n - 1]+          det = saa * sbb - sab * sab+          sap = (ar, ag, ab)+          sbp = (br, bg, bb)+      in if abs det < 1e-9+           then rangeFit pr pg pb+           else ( scale (1 / det) (sub (scale sbb sap) (scale sab sbp))+                , scale (1 / det) (sub (scale saa sbp) (scale sab sap)) )++    -- A block of nearly one colour is best served by endpoints whose+    -- intermediate colour decodes to it exactly, which the range fit cannot+    -- find: it rounds both endpoints to the nearest RGB565 colour.+    single =+      let avg v = (VU.sum v + n `div` 2) `div` n+          table5 = if threeColour then three5 else four5+          table6 = if threeColour then three6 else four6+          (r0, r1) = table5 VU.! avg pr+          (g0, g1) = table6 VU.! avg pg+          (b0, b1) = table5 VU.! avg pb+          e0 = r0 `shiftL` 11 .|. g0 `shiftL` 5 .|. b0+          e1 = r1 `shiftL` 11 .|. g1 `shiftL` 5 .|. b1+      in if threeColour then (min e0 e1, max e0 e1) else (max e0 e1, min e0 e1)++sq :: Int -> Int+sq x = x * x++-- | Endpoints of a colour block, the error of the pixels, and their 2-bit+-- indices packed by position in the block.+data Fit = Fit !Int !Int !Int !Int++fitError :: Fit -> Int+fitError (Fit _ _ err _) = err++-- | Nine running sums, for the least squares fit and the covariance.+data Sums = Sums !Double !Double !Double !Double !Double !Double !Double !Double !Double++-- | Two points spanning the pixels along their principal axis.+rangeFit :: VU.Vector Int -> VU.Vector Int -> VU.Vector Int -> (V3, V3)+rangeFit pr pg pb = (along hi, along lo)+  where+    count = VU.length pr+    pointAt j = (fromIntegral (VU.unsafeIndex pr j), fromIntegral (VU.unsafeIndex pg j), fromIntegral (VU.unsafeIndex pb j)) :: V3+    -- Sums of the channels and of their products, in one pass.+    moment (Sums r g b rr gg bb rg rb gb) j =+      let (x, y, z) = pointAt j+      in Sums (r + x) (g + y) (b + z) (rr + x * x) (gg + y * y) (bb + z * z) (rg + x * y) (rb + x * z) (gb + y * z)+    Sums sr sg sb srr sgg sbb srg srb sgb = L.foldl' moment (Sums 0 0 0 0 0 0 0 0 0) [0 .. count - 1]+    n = fromIntegral count+    m@(mr, mg, mb) = (sr / n, sg / n, sb / n)+    -- Covariance matrix, times n.+    cxx = srr - n * mr * mr; cyy = sgg - n * mg * mg; czz = sbb - n * mb * mb+    cxy = srg - n * mr * mg; cxz = srb - n * mr * mb; cyz = sgb - n * mg * mb+    cov (x, y, z) = (cxx * x + cxy * y + cxz * z, cxy * x + cyy * y + cyz * z, cxz * x + cyz * y + czz * z)+    power v = let v' = cov v; l = norm v' in if l < 1e-9 then v else scale (1 / l) v'+    -- Start from the pixel farthest from the mean: unlike a fixed vector, it+    -- is never orthogonal to the spread, which would collapse the fit.+    farthest = snd $ L.foldl' (\best@(bd, _) j -> let d = sub (pointAt j) m; l = dot d d in if l > bd then (l, d) else best) (0, (1, 1, 1)) [0 .. count - 1]+    axis = iterate power farthest !! 8+    (lo, hi) = L.foldl' (\(a, b) j -> let t = dot (sub (pointAt j) m) axis in (min a t, max b t)) (1 / 0, -1 / 0) [0 .. count - 1]+    along t = add m (scale t axis)++type V3 = (Double, Double, Double)++add, sub :: V3 -> V3 -> V3+add (a, b, c) (d, e, f) = (a + d, b + e, c + f)+sub (a, b, c) (d, e, f) = (a - d, b - e, c - f)++scale :: Double -> V3 -> V3+scale k (a, b, c) = (k * a, k * b, k * c)++dot :: V3 -> V3 -> Double+dot (a, b, c) (d, e, f) = a * d + b * e + c * f++norm :: V3 -> Double+norm v = sqrt (dot v v)++-- | The nearest RGB565 colour.+pack565 :: V3 -> Int+pack565 (r, g, b) = q r 31 `shiftL` 11 .|. q g 63 `shiftL` 5 .|. q b 31+  where q v m = max 0 . min m $ round (v * fromIntegral m / 255)++-- | The four colours of a block as 'decodeDxt' computes them, as R, G, B+-- triples.+palette :: Bool -> Int -> Int -> VU.Vector Int+palette four e0 e1 = VU.fromListN 12+  [ r0, g0, b0, r1, g1, b1+  , mix2 r0 r1, mix2 g0 g1, mix2 b0 b1+  , mix3 r0 r1, mix3 g0 g1, mix3 b0 b1 ]+  where+    (r0, g0, b0) = (expand5 (e0 `shiftR` 11), expand6 (e0 `shiftR` 5), expand5 e0)+    (r1, g1, b1) = (expand5 (e1 `shiftR` 11), expand6 (e1 `shiftR` 5), expand5 e1)+    mix2 a b = if four then (2 * a + b) `quot` 3 else (a + b) `quot` 2+    mix3 a b = if four then (a + 2 * b) `quot` 3 else 0++-- | DXT3 alpha: 4 bits per pixel, rounded.+dxt3AlphaBlock :: Pixels -> MVS.MVector s Word8 -> Int -> ST s ()+dxt3AlphaBlock (Pixels pos _ _ _ as) dst o = putLE dst o 8 bits+  where+    bits = VU.ifoldl' (\acc j a -> acc .|. min 15 ((a + 8) `div` 17) `shiftL` (4 * VU.unsafeIndex pos j)) 0 as++-- | DXT5 alpha: endpoints and 3-bit indices, in whichever mode has the+-- smaller squared error. The six-step mode spans the block's alphas; the+-- four-step mode spans those other than 0 and 255, which it has exactly.+dxt5AlphaBlock :: Pixels -> MVS.MVector s Word8 -> Int -> ST s ()+dxt5AlphaBlock (Pixels pos _ _ _ as) dst o = do+  MVS.unsafeWrite dst o (fromIntegral a0)+  MVS.unsafeWrite dst (o + 1) (fromIntegral a1)+  putLE dst (o + 2) 6 bits+  where+    inner = VU.filter (\v -> v /= 0 && v /= 255) as+    six = (VU.maximum as, VU.minimum as)+    four = if VU.null inner then (0, 0) else (VU.minimum inner, VU.maximum inner)+    -- The nearest table entry for every alpha, packed as 3-bit indices by+    -- position, and the total error. As for colours, the error times 8+    -- plus the index finds both at once.+    assign (e0, e1) =+      let table = VU.generate 8 (dxt5AlphaValue e0 e1)+          nearest v = VU.minimum (VU.imap (\j t -> 8 * sq (v - t) + j) table)+          loop j !err !packed+            | j >= VU.length as = (err, packed)+            | otherwise =+                let c = nearest (VU.unsafeIndex as j)+                in loop (j + 1) (err + c `shiftR` 3) (packed .|. (c .&. 7) `shiftL` (3 * VU.unsafeIndex pos j))+      in loop 0 (0 :: Int) (0 :: Int)+    ((a0, a1), (_, bits))+      | fst sixFit <= fst fourFit = (six, sixFit)+      | otherwise = (four, fourFit)+      where+        sixFit = assign six+        fourFit = assign four++-- | For each 8-bit value, the pair of 5 or 6-bit endpoints whose+-- intermediate colour decodes nearest to it: the first third point in+-- four-colour mode, the midpoint in three-colour mode.+four5, four6, three5, three6 :: VU.Vector (Int, Int)+four5 = singleColourTable 5 (\a b -> (2 * a + b) `quot` 3)+four6 = singleColourTable 6 (\a b -> (2 * a + b) `quot` 3)+three5 = singleColourTable 5 (\a b -> (a + b) `quot` 2)+three6 = singleColourTable 6 (\a b -> (a + b) `quot` 2)++singleColourTable :: Int -> (Int -> Int -> Int) -> VU.Vector (Int, Int)+singleColourTable bits mix = VU.generate 256 best+  where+    expand = if bits == 5 then expand5 else expand6+    values = [0 .. 2 ^ bits - 1]+    best v = snd $ minimum [ (abs (mix (expand a) (expand b) - v), (a, b)) | a <- values, b <- values ]
src/Codec/Picture/Blp/Internal/Encoder.hs view
@@ -8,6 +8,9 @@     encodeBlp   , blpEncoder   , toBlpStruct+  , toBlp2Struct+  , encodeBlp2+  , blp2Encoder   , shrinkTo   , createMipMaps   , createMipMapsIndexed@@ -33,6 +36,8 @@  import Codec.Picture.Blp.Internal.Convert import Codec.Picture.Blp.Internal.Data+import Codec.Picture.Blp.Internal.Dxt (DxtFormat(..), encodeDxt)+import Codec.Picture.Blp.Types  -- | Serialise a BLP structure. The content type follows from 'blpExt'; at -- most 16 mip levels are written, and unused slots are zero.@@ -84,31 +89,109 @@ -- | Build the BLP structure for an image. The number of mip levels is clamped -- to between 1 and the image's full chain, at most 16. The image's size is -- not checked, and 'blpMaxSize' is applied by the caller (see 'shrinkTo').-toBlpStruct :: BlpEncodeOptions -> DynamicImage -> BlpStruct-toBlpStruct BlpEncodeOptions{..} img = BlpStruct {-    blpAlphaBits = if alpha then 8 else 0-  , blpWidth = fromIntegral width-  , blpHeight = fromIntegral height-  -- Readers that follow the old description of the format pick alpha by-  -- this field (3 or 4 with alpha, 5 without), so keep it consistent.-  , blpExtra = if alpha then 3 else 5-  -- WorldEdit writes 5; a single level says it has no mipmaps.-  , blpHasMipmaps = if numLevels > 1 then 5 else 0-  , blpExt = case blpFormat of-      BlpFormatJpeg quality -> toBlpJpg (fromIntegral quality) numLevels alpha rgba-      BlpFormatPaletted _ -> toBlpPaletted blpDither alpha numLevels (convertRGB8 img) rgba-  }+toBlpStruct :: BlpEncodeOptions -> DynamicImage -> Either BlpEncodeError BlpStruct+toBlpStruct BlpEncodeOptions{..} img = do+  ext <- case blpFormat of+    BlpFormatJpeg quality -> Right $ toBlpJpg (fromIntegral quality) numLevels alpha rgba+    BlpFormatPaletted _ -> Right $ toBlpPaletted blpDither alpha numLevels (convertRGB8 img) rgba+    other -> Left $ BlpUnsupportedFormat Blp1 other+  return BlpStruct {+      blpAlphaBits = if alpha then 8 else 0+    , blpWidth = fromIntegral width+    , blpHeight = fromIntegral height+    -- Readers that follow the old description of the format pick alpha by+    -- this field (3 or 4 with alpha, 5 without), so keep it consistent.+    , blpExtra = if alpha then 3 else 5+    -- WorldEdit writes 5; a single level says it has no mipmaps.+    , blpHasMipmaps = if numLevels > 1 then 5 else 0+    , blpExt = ext+    }   where     -- JPEG keeps alpha only if some pixel is not fully opaque.     alpha = case blpFormat of       BlpFormatJpeg _ -> hasAlpha img && anyTransparent rgba       BlpFormatPaletted withAlpha -> withAlpha+      _ -> False     -- Only built when needed: paletted textures without alpha go from RGB.     rgba = convertRGBA8 img     width = dynamicMap imageWidth img     height = dynamicMap imageHeight img     numLevels = max 1 $ min (mipChainLength width height) blpMipLevels +-- | Build the BLP2 structure for an image, as 'toBlpStruct' does for BLP1.+-- The header values follow files from World of Warcraft: alpha depth 0 or+-- 1 for DXT1, 8 for DXT3, DXT5 and BGRA, 8 or 0 for a palette; alpha type+-- 0, 1 and 7 for the DXT formats and 8 otherwise.+toBlp2Struct :: BlpEncodeOptions -> DynamicImage -> Either BlpEncodeError Blp2Struct+toBlp2Struct BlpEncodeOptions{..} img = case blpFormat of+  BlpFormatPaletted withAlpha ->+    let (palette, mips) = quantiseLevels blpDither withAlpha numLevels (convertRGB8 img) rgba+    in Right $ struct (if withAlpha then 8 else 0) 8 (Blp2Paletted palette) mips+  BlpFormatDxt1 ->+    let punch = hasAlpha img && anyBelowHalf rgba+    in Right $ struct (if punch then 1 else 0) 0 (Blp2Dxt (Dxt1 punch)) (encodeDxt (Dxt1 punch) <$> levels)+  BlpFormatDxt3 -> Right $ struct 8 1 (Blp2Dxt Dxt3) (encodeDxt Dxt3 <$> levels)+  BlpFormatDxt5 -> Right $ struct 8 7 (Blp2Dxt Dxt5) (encodeDxt Dxt5 <$> levels)+  BlpFormatBgra -> Right $ struct 8 8 Blp2Bgra (bgraBytes <$> levels)+  other -> Left $ BlpUnsupportedFormat Blp2 other+  where+    rgba = convertRGBA8 img+    width = dynamicMap imageWidth img+    height = dynamicMap imageHeight img+    numLevels = max 1 $ min (mipChainLength width height) blpMipLevels+    levels = take numLevels (createMipMaps rgba)+    struct depth alphaType content mips = Blp2Struct {+        blp2AlphaDepth = depth+      , blp2AlphaType = alphaType+      , blp2HasMips = if numLevels > 1 then 1 else 0+      , blp2Width = fromIntegral width+      , blp2Height = fromIntegral height+      , blp2Content = content+      , blp2MipMaps = mips+      }++-- | Whether any pixel's alpha is below 128, so DXT1 needs 1-bit alpha.+anyBelowHalf :: Image PixelRGBA8 -> Bool+anyBelowHalf img = VS.any id $ VS.generate (imageWidth img * imageHeight img) (\i -> VS.unsafeIndex (imageData img) (4 * i + 3) < 128)++-- | Pixels as B, G, R and A.+bgraBytes :: Image PixelRGBA8 -> BS.ByteString+bgraBytes img = BS.pack . concat $ [[b, g, r, a] | PixelRGBA8 r g b a <- pixels]+  where pixels = [pixelAt img x y | y <- [0 .. imageHeight img - 1], x <- [0 .. imageWidth img - 1]]++-- | Serialise a BLP2 structure. At most 16 mip levels are written, unused+-- slots are zero, and the palette is zeros unless the content is paletted.+encodeBlp2 :: Blp2Struct -> ByteString+encodeBlp2 = runPut . blp2Encoder++-- | Raw encoder for BLP2.+blp2Encoder :: Blp2Struct -> Put+blp2Encoder Blp2Struct{..} = do+  putByteString "BLP2"+  putWord32le 1+  putWord8 $ case blp2Content of+    Blp2Paletted _ -> 1+    Blp2Dxt _ -> 2+    Blp2Bgra -> 3+  putWord8 blp2AlphaDepth+  putWord8 blp2AlphaType+  putWord8 blp2HasMips+  putWord32le blp2Width+  putWord32le blp2Height+  traverse_ putWord32le . slots $ take (length levels) (scanl (+) contentStart levelSizes)+  traverse_ putWord32le $ slots levelSizes+  traverse_ putRgba8 . V.take 256 $ palette <> V.replicate 256 (PixelRGBA8 0 0 0 0)+  traverse_ putByteString levels+  where+    contentStart = 148 + 4 * 256+    levels = take 16 blp2MipMaps+    levelSizes = BS.length <$> levels+    palette = case blp2Content of+      Blp2Paletted p -> p+      _ -> V.empty+    slots xs = take 16 $ (fromIntegral <$> xs) ++ repeat 0+    putRgba8 (PixelRGBA8 r g b a) = putWord8 r >> putWord8 g >> putWord8 b >> putWord8 a+ -- | Whether any pixel is less than fully opaque. anyTransparent :: Image PixelRGBA8 -> Bool anyTransparent img = go 3@@ -245,12 +328,17 @@ -- | Quantise a picture to a 256 colour palette and create mipmaps, with an -- 8-bit alpha list after each index list when asked to keep alpha. toBlpPaletted :: Bool -> Bool -> Int -> Image PixelRGB8 -> Image PixelRGBA8 -> BlpExt-toBlpPaletted dither keepAlpha numLevels rgb rgba = BlpPaletted {-    blpPalette = convertPalette palette-  , blpPalettedMipMaps = if keepAlpha+toBlpPaletted dither keepAlpha numLevels rgb rgba =+  uncurry BlpPaletted (quantiseLevels dither keepAlpha numLevels rgb rgba)++-- | The palette and the raw mip levels of 'toBlpPaletted', shared with BLP2.+quantiseLevels :: Bool -> Bool -> Int -> Image PixelRGB8 -> Image PixelRGBA8 -> (V.Vector PixelRGBA8, [BS.ByteString])+quantiseLevels dither keepAlpha numLevels rgb rgba =+  ( convertPalette palette+  , if keepAlpha       then zipWith (<>) (convertIndexed <$> mipmaps) (convertIndexed <$> alphaMaps)       else convertIndexed <$> mipmaps-  }+  )   where     img'    :: Image Pixel8     palette :: Palette
src/Codec/Picture/Blp/Internal/Parser.hs view
@@ -11,7 +11,7 @@   , parseBlp2   , parseBlpFile   , readPalette-  , blpVersion+  , blp1Tag   , dword   , compressionParser   , maxSide@@ -32,6 +32,7 @@  import Codec.Picture.Blp.Internal.Data import Codec.Picture.Blp.Internal.Dxt (DxtFormat(..))+import Codec.Picture.Blp.Types (BlpDecodeError(..))  -- | Size of the fixed BLP1 header in bytes. headerSize :: Int@@ -45,33 +46,52 @@ -- -- Width and height must be between 1 and 'maxSide'. Mip levels are taken -- slot by slot, see 'mipLevels'.-parseBlp :: ByteString -> Either String BlpStruct+parseBlp :: ByteString -> Either BlpDecodeError BlpStruct parseBlp input = do-  (mkStruct, compression, width, height, slots) <- parseOnly headerParser input-  let w = fromIntegral width-      h = fromIntegral height-  if w < 1 || h < 1 || w > maxSide || h > maxSide-    then Left $ "Invalid image size " ++ show width ++ "x" ++ show height-    else Right ()-  let levelsFrom start = mipLevels (BS.length input) start (mipChainLength w h) slots-  fmap mkStruct $ case compression of-    BlpCompressionJPEG -> do-      jpegHeaderSize <- parseOnly (AT.take headerSize *> dword <?> "jpeg header size") input+  checkMagic "BLP1" input+  (mkStruct, content, width, height, slots) <- truncated $ parseOnly headerParser input+  checkSize width height+  let levelsFrom start = mipLevels (BS.length input) start (mipChainLength (fromIntegral width) (fromIntegral height)) slots+  fmap mkStruct $ case content of+    0 -> do+      jpegHeaderSize <- truncated $ parseOnly (AT.take headerSize *> dword) input       let headerEnd = headerSize + 4 + fromIntegral jpegHeaderSize       if headerEnd > BS.length input-        then Left "jpeg header: not enough input"+        then Left BlpTruncatedHeader         else Right BlpJpeg {             blpJpegHeader = slice input (headerSize + 4, fromIntegral jpegHeaderSize)           , blpJpegData = slice input <$> levelsFrom headerEnd           }-    _ -> do-      -- compressionParser gives JPEG or paletted only.+    1 -> do       let levels = levelsFrom headerSize       Right BlpPaletted {           blpPalette = readPalette input headerSize levels         , blpPalettedMipMaps = slice input <$> levels         }+    c -> Left . BlpUnsupported $ "BLP1 content type " ++ show c +-- | The file must start with the given magic. A non-empty file shorter than+-- that is cut short if it is the start of the magic.+checkMagic :: ByteString -> ByteString -> Either BlpDecodeError ()+checkMagic magic input+  | BS.take 4 input == magic = Right ()+  | not (BS.null input) && BS.length input < 4 && input `BS.isPrefixOf` magic = Left BlpTruncatedHeader+  | otherwise = Left BlpNotBlp++-- | Width and height must be between 1 and 'maxSide'.+checkSize :: Word32 -> Word32 -> Either BlpDecodeError ()+checkSize width height+  | w < 1 || h < 1 || w > maxSide || h > maxSide = Left $ BlpInvalidSize w h+  | otherwise = Right ()+  where+    w = fromIntegral width+    h = fromIntegral height++-- | Once the magic is checked, the header parsers fail only when the input+-- ends.+truncated :: Either String a -> Either BlpDecodeError a+truncated = either (const (Left BlpTruncatedHeader)) Right+ -- | The 256 entry palette that starts at the given offset. Some tools write -- only the palette entries they use, so the palette ends where mip 0 -- starts. Missing entries are padded with transparent black, so any index@@ -94,7 +114,7 @@  -- | Parse a BLP1 or BLP2 file, told apart by their magic. Anything that is -- not BLP2 is parsed, and rejected, as BLP1.-parseBlpFile :: ByteString -> Either String BlpFile+parseBlpFile :: ByteString -> Either BlpDecodeError BlpFile parseBlpFile input   | BS.take 4 input == "BLP2" = BlpFile2 <$> parseBlp2 input   | otherwise = BlpFile1 <$> parseBlp input@@ -111,29 +131,26 @@ -- levels are taken slot by slot, as in BLP1 (see 'mipLevels'). Levels may -- start right after the header: the palette that follows it is only read -- for paletted content, and cut short where mip 0 starts.-parseBlp2 :: ByteString -> Either String Blp2Struct+parseBlp2 :: ByteString -> Either BlpDecodeError Blp2Struct parseBlp2 input = do+  checkMagic "BLP2" input   (blpType, encoding, blp2AlphaDepth, blp2AlphaType, blp2HasMips, blp2Width, blp2Height, slots) <--    parseOnly blp2HeaderParser input-  let w = fromIntegral blp2Width-      h = fromIntegral blp2Height+    truncated $ parseOnly blp2HeaderParser input   case blpType of     1 -> Right ()-    0 -> Left "BLP2 files with JPEG content are not supported"-    t -> Left $ "Unknown BLP2 type " ++ show t-  if w < 1 || h < 1 || w > maxSide || h > maxSide-    then Left $ "Invalid image size " ++ show blp2Width ++ "x" ++ show blp2Height-    else Right ()-  let levels = mipLevels (BS.length input) blp2HeaderSize (mipChainLength w h) slots+    0 -> Left $ BlpUnsupported "BLP2 with JPEG content (type 0)"+    t -> Left . BlpUnsupported $ "BLP2 type " ++ show t+  checkSize blp2Width blp2Height+  let levels = mipLevels (BS.length input) blp2HeaderSize (mipChainLength (fromIntegral blp2Width) (fromIntegral blp2Height)) slots   blp2Content <- case encoding of     1 -> Right . Blp2Paletted $ readPalette input blp2HeaderSize levels     2 -> case blp2AlphaType of       0 -> Right . Blp2Dxt $ Dxt1 (blp2AlphaDepth /= 0)       1 -> Right $ Blp2Dxt Dxt3       7 -> Right $ Blp2Dxt Dxt5-      t -> Left $ "Unknown BLP2 DXT format (alpha type " ++ show t ++ ")"+      t -> Left . BlpUnsupported $ "BLP2 DXT alpha type " ++ show t     3 -> Right Blp2Bgra-    e -> Left $ "Unknown BLP2 encoding " ++ show e+    e -> Left . BlpUnsupported $ "BLP2 encoding " ++ show e   return Blp2Struct { blp2MipMaps = slice input <$> levels, .. }  -- | The BLP2 header: type, encoding, alpha depth, alpha type, has mips,@@ -154,10 +171,10 @@  -- | The fixed header: a function completing the struct with its content, -- plus the fields needed to read the content.-headerParser :: Parser (BlpExt -> BlpStruct, BlpCompression, Word32, Word32, [(Word32, Word32)])+headerParser :: Parser (BlpExt -> BlpStruct, Word32, Word32, Word32, [(Word32, Word32)]) headerParser = do-  _ <- blpVersion-  compression <- compressionParser+  _ <- blp1Tag+  content <- dword <?> "content"   blpAlphaBits <- dword <?> "alpha bits"   blpWidth <- dword <?> "width"   blpHeight <- dword <?> "height"@@ -166,11 +183,11 @@   offsets <- count 16 dword <?> "mipmaps offsets"   sizes <- count 16 dword <?> "mipmaps sizes"   let mkStruct blpExt = BlpStruct {..}-  return (mkStruct, compression, blpWidth, blpHeight, offsets `zip` sizes)+  return (mkStruct, content, blpWidth, blpHeight, offsets `zip` sizes)  -- | The @BLP1@ magic. BLP0 and BLP2 files fail here.-blpVersion :: Parser ByteString-blpVersion = string "BLP1" <?> "BLP1 version tag"+blp1Tag :: Parser ByteString+blp1Tag = string "BLP1" <?> "BLP1 version tag"  -- | A little-endian 32-bit word. dword :: Parser Word32
src/Codec/Picture/Blp/Types.hs view
@@ -6,6 +6,8 @@ module Codec.Picture.Blp.Types(     BlpDecodeOptions(..)   , defaultBlpDecodeOptions+  , BlpDecodeError(..)+  , displayBlpDecodeError   , BlpInfo(..)   , BlpVersion(..)   , BlpCompression(..)@@ -15,6 +17,8 @@   , BlpEncodeError(..)   ) where +import Control.DeepSeq (NFData)+import Control.Exception (Exception(..)) import GHC.Generics  -- | Options for decoding.@@ -29,6 +33,46 @@ defaultBlpDecodeOptions :: BlpDecodeOptions defaultBlpDecodeOptions = BlpDecodeOptions { blpMaxPixels = 8192 * 8192 } +-- | Why a BLP file cannot be decoded. 'displayBlpDecodeError' gives a+-- readable message.+data BlpDecodeError =+    -- | The file starts with neither @BLP1@ nor @BLP2@. BLP0 files are among+    -- these.+    BlpNotBlp+    -- | The file ends inside its header.+  | BlpTruncatedHeader+    -- | The header asks for something this library does not read, such as+    -- an unknown content type or BLP2 with JPEG content. The text says what.+  | BlpUnsupported String+    -- | Width or height outside 1 to 65535.+  | BlpInvalidSize !Int !Int+    -- | Width and height whose product is above 'blpMaxPixels', the limit+    -- given last.+  | BlpTooLarge !Int !Int !Int+    -- | No mipmap slot points at usable data.+  | BlpNoImageData+    -- | The full size image cannot be decoded: its data is too short, its+    -- JPEG stream is broken, and so on. The text says what. Damaged+    -- mipmaps after the first are not errors; the list of levels stops+    -- before them.+  | BlpCorruptImage String+  deriving (Eq, Show, Generic)++instance Exception BlpDecodeError where+  displayException = displayBlpDecodeError++-- | A message for an error, in plain words.+displayBlpDecodeError :: BlpDecodeError -> String+displayBlpDecodeError e = case e of+  BlpNotBlp -> "Not a BLP file: it starts with neither BLP1 nor BLP2"+  BlpTruncatedHeader -> "The file ends inside its header"+  BlpUnsupported what -> "Unsupported BLP file: " ++ what+  BlpInvalidSize w h -> "Invalid image size " ++ show w ++ "x" ++ show h+  BlpTooLarge w h limit -> "Image size " ++ show w ++ "x" ++ show h+    ++ " is above the limit of " ++ show limit ++ " pixels"+  BlpNoImageData -> "The file has no image data"+  BlpCorruptImage what -> "The full size image is damaged: " ++ what+ -- | What a BLP file holds, read from its header without decoding any pixels. data BlpInfo = BlpInfo {     blpInfoVersion :: !BlpVersion -- ^ BLP1 or BLP2@@ -37,7 +81,7 @@   , blpInfoHeight :: !Int -- ^ Height of the full size image   , blpInfoAlphaBits :: !Int -- ^ The alpha depth header field (@alphaBits@ in BLP1, @alphaDepth@ in BLP2), as stored   , blpInfoMipLevels :: !Int -- ^ Mip levels with usable slots, the full size image included-  } deriving (Eq, Show)+  } deriving (Eq, Show, Generic)  -- | Version of a BLP file: BLP1 is used by Warcraft III, BLP2 by World of -- Warcraft.@@ -54,7 +98,8 @@   | BlpCompressionBgra -- ^ 32-bit BGRA pixels (BLP2 only)   deriving (Eq, Ord, Enum, Bounded, Show, Generic) --- | How to encode a texture.+-- | How to encode a texture. JPEG is BLP1 only; DXT and BGRA are BLP2 only;+-- a palette works in both, see 'blpVersion'. data BlpFormat =     -- | JPEG with the given quality, from 1 to 100. The texture gets alpha     -- when the image has an alpha channel.@@ -62,7 +107,17 @@     -- | 256 colour palette, with 8-bit alpha or without alpha. Colours are     -- quantised with dithering.   | BlpFormatPaletted !Bool-  deriving (Eq, Show)+    -- | DXT1 blocks, 4 bits per pixel. The texture gets 1-bit alpha when+    -- some pixel's alpha is below 128; those pixels become transparent.+  | BlpFormatDxt1+    -- | DXT3 blocks: DXT1 colour plus 4-bit alpha per pixel.+  | BlpFormatDxt3+    -- | DXT5 blocks: DXT1 colour plus interpolated 8-bit alpha, smoother+    -- than DXT3 for gradual alpha.+  | BlpFormatDxt5+    -- | Uncompressed 32-bit pixels, lossless and 4 bytes per pixel.+  | BlpFormatBgra+  deriving (Eq, Show, Generic)  -- | Options for encoding a texture. data BlpEncodeOptions = BlpEncodeOptions {@@ -78,16 +133,20 @@   -- | Shrink the image, halving both sides, until the larger side is at most   -- this many pixels. Warcraft III shows mip levels up to 512 pixels only. , blpMaxSize :: !(Maybe Int)+  -- | File version to write: 'Blp1' for Warcraft III, 'Blp2' for World of+  -- Warcraft. The format must exist in that version, see 'BlpFormat'.+, blpVersion :: !BlpVersion } deriving (Eq, Show) --- | JPEG at quality 90 with the full mip chain, dithered palettes, and no--- size limit.+-- | BLP1, JPEG at quality 90 with the full mip chain, dithered palettes, and+-- no size limit. defaultBlpEncodeOptions :: BlpEncodeOptions defaultBlpEncodeOptions = BlpEncodeOptions {     blpFormat = BlpFormatJpeg 90   , blpMipLevels = 16   , blpDither = True   , blpMaxSize = Nothing+  , blpVersion = Blp1   }  -- | Why an image cannot be encoded.@@ -97,4 +156,14 @@   | BlpInvalidJpegQuality !Int -- ^ JPEG quality outside 1 to 100.   | BlpFileTooLarge -- ^ The file would exceed 4 GiB, the most its offsets can address.   | BlpInvalidMaxSize !Int -- ^ A 'blpMaxSize' below 1.-  deriving (Eq, Show)+  | BlpUnsupportedFormat !BlpVersion !BlpFormat -- ^ The format does not exist in this version, such as JPEG in BLP2.+  deriving (Eq, Show, Generic)++instance Exception BlpEncodeError++instance NFData BlpDecodeError+instance NFData BlpEncodeError+instance NFData BlpInfo+instance NFData BlpVersion+instance NFData BlpCompression+instance NFData BlpFormat
test/Blp2.hs view
@@ -1,16 +1,21 @@ -- | BLP2 files, built byte by byte: every content type, alpha depths, and -- damaged or unsupported files.-module Blp2(blp2Tests, blp2Seeds) where+module Blp2(blp2Tests, blp2EncodeTests, blp2Seeds) where  import Codec.Picture import Codec.Picture.Blp+import Codec.Picture.Blp.Internal.Data (Blp2Content(..), Blp2Struct(..))+import Codec.Picture.Blp.Internal.Encoder (createMipMaps, encodeBlp2, toBlp2Struct)+import Codec.Picture.Blp.Internal.Parser (parseBlp2)+import Control.Monad (forM_) import Data.ByteString (ByteString)-import Data.List (isInfixOf) import Data.Word import Test.Tasty import Test.Tasty.HUnit  import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as BSL+import qualified Data.Vector as V  import Fixture @@ -61,19 +66,19 @@   , testCase "a damaged mipmap ends the list, a damaged level 0 fails the file" $ do       levels <- decoded $ blp2 dxt1Header blankPalette [dxt1Mip0, BS.take 4 (colourBlock 0xFFFF 0 []), dxt1Block 0x07E0, dxt1Block 0x001F]       map size levels @?= [(8, 8)]-      assertLeft "Mipmap 0" . decodeBlp $ blp2 dxt1Header blankPalette [BS.take 31 dxt1Mip0]-      assertLeft "BGRA" . decodeBlp $ blp2 defHeader2 { h2Encoding = 3, h2Width = 2, h2Height = 1 } blankPalette [BS.pack [1 .. 7]]+      assertCorrupt "DXT data" . decodeBlp $ blp2 dxt1Header blankPalette [BS.take 31 dxt1Mip0]+      assertCorrupt "BGRA" . decodeBlp $ blp2 defHeader2 { h2Encoding = 3, h2Width = 2, h2Height = 1 } blankPalette [BS.pack [1 .. 7]]   , testCase "unsupported and malformed headers are errors" $ do-      assertLeft "JPEG" . decodeBlp $ setDword 4 0 dxt1File-      assertLeft "type" . decodeBlp $ setDword 4 5 dxt1File-      assertLeft "encoding" . decodeBlp $ setByte 8 4 dxt1File-      assertLeft "alpha type" . decodeBlp $ setByte 10 3 dxt1File-      assertLeft "Invalid image size" . decodeBlp $ setDword 12 0 dxt1File-      assertLeft "No mipmap data" . decodeBlp $ blp2 dxt1Header blankPalette []-      assertLeft "not enough input" . decodeBlp $ BS.take 100 dxt1File-      assertLeft "JPEG" . blpInfo $ setDword 4 0 dxt1File+      assertError (BlpUnsupported "BLP2 with JPEG content (type 0)") . decodeBlp $ setDword 4 0 dxt1File+      assertError (BlpUnsupported "BLP2 type 5") . decodeBlp $ setDword 4 5 dxt1File+      assertError (BlpUnsupported "BLP2 encoding 4") . decodeBlp $ setByte 8 4 dxt1File+      assertError (BlpUnsupported "BLP2 DXT alpha type 3") . decodeBlp $ setByte 10 3 dxt1File+      assertError (BlpInvalidSize 0 8) . decodeBlp $ setDword 12 0 dxt1File+      assertError BlpNoImageData . decodeBlp $ blp2 dxt1Header blankPalette []+      assertError BlpTruncatedHeader . decodeBlp $ BS.take 100 dxt1File+      assertError (BlpUnsupported "BLP2 with JPEG content (type 0)") . blpInfo $ setDword 4 0 dxt1File   , testCase "the size limit applies to BLP2" $-      assertLeft "limit" $ decodeBlpWith (BlpDecodeOptions 63) dxt1File+      assertError (BlpTooLarge 8 8 63) $ decodeBlpWith (BlpDecodeOptions 63) dxt1File   ]  -- | Valid BLP2 files of every content type, for the fuzz properties.@@ -82,16 +87,12 @@  decoded :: ByteString -> IO [Image PixelRGBA8] decoded file = case decodeBlpMipmaps file of-  Left e -> assertFailure e-  Right levels -> return (map convertRGBA8 levels)+  Left e -> assertFailure (show e)+  Right levels -> return levels  size :: Image a -> (Int, Int) size img = (imageWidth img, imageHeight img) -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"  setByte :: Int -> Word8 -> ByteString -> ByteString setByte o v bs = BS.take o bs <> BS.singleton v <> BS.drop (o + 1) bs@@ -145,3 +146,69 @@ level0 :: [Image PixelRGBA8] -> Image PixelRGBA8 level0 (l : _) = l level0 [] = error "no levels decoded"++blp2EncodeTests :: TestTree+blp2EncodeTests = testGroup "blp2 encoding"+  [ testCase "every format writes a BLP2 that blpInfo reads back" $+      forM_ [ (BlpFormatPaletted True, BlpCompressionUncompressed, 8)+            , (BlpFormatPaletted False, BlpCompressionUncompressed, 0)+            , (BlpFormatDxt1, BlpCompressionDxt1, 1)+            , (BlpFormatDxt3, BlpCompressionDxt3, 8)+            , (BlpFormatDxt5, BlpCompressionDxt5, 8)+            , (BlpFormatBgra, BlpCompressionBgra, 8) ] $ \(fmt, compression, depth) ->+        fmap blpInfo (encode2 fmt sprite) @?= Right (Right (BlpInfo Blp2 compression 16 8 depth 5))+  , testCase "header bytes follow World of Warcraft files" $+      forM_ [ (BlpFormatPaletted True, 1, 8), (BlpFormatDxt1, 2, 0), (BlpFormatDxt3, 2, 1)+            , (BlpFormatDxt5, 2, 7), (BlpFormatBgra, 3, 8) ] $ \(fmt, encoding, alphaType) -> do+        Right file <- return (encode2 fmt sprite)+        (BS.index file 8, BS.index file 10, BS.index file 11) @?= (encoding, alphaType, 1)+  , testCase "a single level says it has no mipmaps" $ do+      Right file <- return (encodeBlpWithStrict defaultBlpEncodeOptions { blpVersion = Blp2, blpFormat = BlpFormatDxt5, blpMipLevels = 1 } sprite)+      BS.index file 11 @?= 0+  , testCase "BGRA is lossless at every level" $ do+      Right file <- return (encode2 BlpFormatBgra sprite)+      levels <- decoded file+      map imageData levels @?= map imageData (take 5 (createMipMaps (convertRGBA8 sprite)))+  , testCase "DXT1 has 1-bit alpha only when some alpha is below 128" $ do+      let opaque = ImageRGBA8 $ generateImage (\x y -> PixelRGBA8 (fromIntegral x) (fromIntegral y) 0 200) 8 8+      fmap (fmap blpInfoAlphaBits . blpInfo) (encode2 BlpFormatDxt1 opaque) @?= Right (Right 0)+      Right file <- return (encode2 BlpFormatDxt1 sprite)+      level <- level0 <$> decoded file+      [a | PixelRGBA8 _ _ _ a <- [pixelAt level 0 0, pixelAt level 8 4]] @?= [0, 255]+  , testCase "formats a version does not have are errors" $ do+      encodeBlpWith defaultBlpEncodeOptions { blpVersion = Blp2 } sprite+        `errorIs` BlpUnsupportedFormat Blp2 (BlpFormatJpeg 90)+      encodeBlpWith defaultBlpEncodeOptions { blpFormat = BlpFormatDxt5 } sprite+        `errorIs` BlpUnsupportedFormat Blp1 BlpFormatDxt5+  , testCase "the size limit and blpMaxSize apply to BLP2" $ do+      Right file <- return (encodeBlpWithStrict defaultBlpEncodeOptions { blpVersion = Blp2, blpFormat = BlpFormatDxt1, blpMaxSize = Just 4 } sprite)+      fmap (\i -> (blpInfoWidth i, blpInfoHeight i)) (blpInfo file) @?= Right (4, 2)+      let wide = ImageRGBA8 $ generateImage (\_ _ -> PixelRGBA8 0 0 0 255) 65536 1+      encodeBlpWith defaultBlpEncodeOptions { blpVersion = Blp2, blpFormat = BlpFormatBgra } wide+        `errorIs` BlpImageTooLarge 65536 1+  , testCase "parsing a written BLP2 gives back its structure" $+      forM_ [BlpFormatPaletted True, BlpFormatDxt1, BlpFormatDxt5, BlpFormatBgra] $ \fmt -> do+        Right struct <- return (toBlp2Struct defaultBlpEncodeOptions { blpVersion = Blp2, blpFormat = fmt } sprite)+        Right parsed <- return (parseBlp2 (BSL.toStrict (encodeBlp2 struct)))+        -- The quantiser may give fewer than 256 entries; the file pads them.+        let content s = case blp2Content s of+              Blp2Paletted p -> show (V.take 256 (p <> V.replicate 256 (PixelRGBA8 0 0 0 0)))+              c -> show c+            summary s = (blp2AlphaDepth s, blp2AlphaType s, blp2HasMips s, blp2Width s, blp2Height s, blp2MipMaps s, content s)+        summary parsed @?= summary struct+  ]++-- | 16x8 with a transparent left half and a gradient on the right.+sprite :: DynamicImage+sprite = ImageRGBA8 $ generateImage (\x y -> PixelRGBA8 (fromIntegral (x * 16)) (fromIntegral (y * 32)) 90 (if x < 8 then 0 else 255)) 16 8++encode2 :: BlpFormat -> DynamicImage -> Either BlpEncodeError ByteString+encode2 fmt = encodeBlpWithStrict defaultBlpEncodeOptions { blpVersion = Blp2, blpFormat = fmt }++encodeBlpWithStrict :: BlpEncodeOptions -> DynamicImage -> Either BlpEncodeError ByteString+encodeBlpWithStrict opts = fmap BSL.toStrict . encodeBlpWith opts++errorIs :: Either BlpEncodeError a -> BlpEncodeError -> Assertion+errorIs r expected = case r of+  Left e -> e @?= expected+  Right _ -> assertFailure $ "expected " ++ show expected
− test/Deprecated.hs
@@ -1,31 +0,0 @@-{-# OPTIONS_GHC -Wno-deprecations #-}--- | Tests for the encoders that 'encodeBlpWith' replaced. They live in their--- own module so that deprecation warnings stay errors everywhere else.-module Deprecated(deprecatedTests) where--import Codec.Picture-import Codec.Picture.Blp-import Control.Exception (ErrorCall, evaluate, try)-import Data.Either (isLeft)-import Data.Int (Int64)-import Test.Tasty-import Test.Tasty.HUnit--import qualified Data.ByteString.Lazy as BSL--deprecatedTests :: TestTree-deprecatedTests = testGroup "deprecated encoders"-  [ testCase "match encodeBlpWith" $ do-      let with f n = either (error . show) id $ encodeBlpWith defaultBlpEncodeOptions { blpFormat = f, blpMipLevels = n } img-      encodeBlpJpeg 90 3 img @?= with (BlpFormatJpeg 90) 3-      encodeBlpUncompressedWithAlpha 3 img @?= with (BlpFormatPaletted True) 3-      encodeBlpUncompressedWithoutAlpha 3 img @?= with (BlpFormatPaletted False) 3-  , testCase "clamp the JPEG quality" $ do-      encodeBlpJpeg 300 1 img @?= encodeBlpJpeg 100 1 img-      encodeBlpJpeg (-5) 1 img @?= encodeBlpJpeg 1 1 img-  , testCase "throw on an empty image" $ do-      r <- try (evaluate . BSL.length $ encodeBlpJpeg 90 1 (ImageRGBA8 $ generateImage (\_ _ -> PixelRGBA8 0 0 0 0) 0 3))-      assertBool "expected an error" (isLeft (r :: Either ErrorCall Int64))-  ]-  where-    img = ImageRGBA8 $ generateImage (\x y -> PixelRGBA8 (fromIntegral $ x * 16) (fromIntegral $ y * 16) 128 200) 16 16
test/Dxt.hs view
@@ -8,6 +8,7 @@ import Fixture (colourBlock, dxt3Alpha, dxt5Alpha) import Data.ByteString (ByteString) import Data.Either (isLeft)+import Control.Monad (forM_) import Data.Word import Test.QuickCheck import Test.Tasty@@ -62,6 +63,7 @@       fmap imageData (decodeDxt (Dxt1 False) 4 4 (block <> BS.replicate 9 0xAB))         @?= fmap imageData (decodeDxt (Dxt1 False) 4 4 block)   , testProperty "matches a direct transcription of the format" matchesReference+  , encoderTests   ]  rgba :: Word8 -> Word8 -> Word8 -> Word8 -> PixelRGBA8@@ -140,3 +142,118 @@         table           | a0 > a1 = [a0, a1] ++ [((7 - j) * a0 + j * a1) `div` 7 | j <- [1 .. 6]]           | otherwise = [a0, a1] ++ [((5 - j) * a0 + j * a1) `div` 5 | j <- [1 .. 4]] ++ [0, 255]++encoderTests :: TestTree+encoderTests = testGroup "encoding"+  [ testCase "output is whole blocks and decodes to the same size" $+      forM_ [Dxt1 False, Dxt1 True, Dxt3, Dxt5] $ \fmt -> do+        let img = generateImage (\x y -> PixelRGBA8 (fromIntegral $ x * 40) (fromIntegral $ y * 70) 99 200) 5 3+            bytes = encodeDxt fmt img+        BS.length bytes @?= dxtLevelBytes fmt 5 3+        fmap (\d -> (imageWidth d, imageHeight d)) (decodeDxt fmt 5 3 bytes) @?= Right (5, 3)+  , testProperty "blocks of at most two RGB565 colours round-trip exactly" twoColours+  , testProperty "a block of one colour is within 1 per channel" oneColour+  , testProperty "DXT1 with alpha: below 128 is transparent, the rest opaque" dxt1Alpha+  , testProperty "DXT3 alpha is the nearest multiple of 17" dxt3Rounding+  , testProperty "DXT5 alpha with two values per block is exact" dxt5TwoAlphas+  , testCase "DXT5 uses the mode with 0 and 255 when it fits better" $ do+      let img = generateImage (\x y -> PixelRGBA8 9 9 9 ([0, 100, 110, 255] !! ((x + y) `mod` 4))) 4 4+          alpha (PixelRGBA8 _ _ _ a) = a+      map alpha (pixels (roundTrip Dxt5 img)) @?= map alpha (pixels img)+  , testCase "two colours whose difference is orthogonal to grey round-trip" $ do+      -- (16, -16, 0) is orthogonal to (1, 1, 1); a principal axis search+      -- started from (1, 1, 1) collapsed here to the mean colour.+      let img = generateImage (\x y -> if even (x + y) then PixelRGBA8 239 239 41 255 else PixelRGBA8 255 223 41 255) 4 4+      pixels (roundTrip (Dxt1 False) img) @?= pixels img+  , testCase "endpoints are refined by least squares" $ do+      -- One black, fourteen mid-grey and one white pixel: the range fit puts+      -- the endpoints at black and white, which no index decodes near the+      -- grey. The squared error was 12802 when this was written; without+      -- refinement it is 19851.+      let grey i = case i of { 0 -> 0; 15 -> 255; _ -> 128 } :: Word8+          img = generateImage (\x y -> let v = grey (4 * y + x) in PixelRGBA8 v v v 255) 4 4+          chans (PixelRGBA8 r g b _) = map fromIntegral [r, g, b] :: [Int]+          err = sum [(a - b) ^ (2 :: Int) | (p, q) <- zip (pixels img) (pixels (roundTrip (Dxt1 False) img)), (a, b) <- zip (chans p) (chans q)]+      assertBool ("squared error " ++ show err) (err < 15000)+  , testCase "a smooth gradient keeps its quality" $ do+      -- 44.0 dB (DXT1) and 45.3 dB (DXT5, with alpha) when this was written.+      let img = generateImage (\x y -> PixelRGBA8 (fromIntegral x) (fromIntegral (y * 2)) (fromIntegral (x + y)) (fromIntegral (255 - y))) 256 128+          quality fmt alpha = psnr alpha img (roundTrip fmt img)+      assertBool "DXT1" (quality (Dxt1 False) False > 42)+      assertBool "DXT5" (quality Dxt5 True > 42)+  ]++roundTrip :: DxtFormat -> Image PixelRGBA8 -> Image PixelRGBA8+roundTrip fmt img = either error id $ decodeDxt fmt (imageWidth img) (imageHeight img) (encodeDxt fmt img)++pixels :: Image PixelRGBA8 -> [PixelRGBA8]+pixels img = [pixelAt img x y | y <- [0 .. imageHeight img - 1], x <- [0 .. imageWidth img - 1]]++psnr :: Bool -> Image PixelRGBA8 -> Image PixelRGBA8 -> Double+psnr alpha a b = 10 * logBase 10 (255 * 255 / (fromIntegral (sum errs) / fromIntegral (length errs)))+  where+    chans (PixelRGBA8 r g bl al) = map fromIntegral ([r, g, bl] ++ [al | alpha]) :: [Int]+    errs = [(x - y) ^ (2 :: Int) | (p, q) <- zip (pixels a) (pixels b), (x, y) <- zip (chans p) (chans q)]++-- | A random image of w x h, each pixel from the given generator.+blocky :: Int -> Int -> (Int -> Int -> Gen PixelRGBA8) -> Gen (Image PixelRGBA8)+blocky w h pixelFor = do+  rows <- sequence [sequence [pixelFor x y | x <- [0 .. w - 1]] | y <- [0 .. h - 1]]+  return $ generateImage (\x y -> rows !! y !! x) w h+++-- | One random value per 4x4 block of a w x h image, looked up by pixel.+perBlock :: Int -> Int -> Gen a -> Gen (Int -> Int -> a)+perBlock w h gen = do+  let across = (w + 3) `div` 4+  values <- vectorOf (across * ((h + 3) `div` 4)) gen+  return $ \x y -> values !! ((y `div` 4) * across + x `div` 4)+-- | A colour whose RGB565 form expands back to itself.+exact565 :: Gen (Word8, Word8, Word8)+exact565 = do+  r <- choose (0, 31 :: Int)+  g <- choose (0, 63 :: Int)+  b <- choose (0, 31 :: Int)+  let e5 v = fromIntegral (v * 8 + v `div` 4)+      e6 v = fromIntegral (v * 4 + v `div` 16)+  return (e5 r, e6 g, e5 b)++sizes :: Gen (Int, Int)+sizes = (,) <$> choose (1, 13) <*> choose (1, 13)++twoColours :: Property+twoColours = forAll sizes $ \(w, h) -> forAll (elements [Dxt1 False, Dxt3, Dxt5]) $ \fmt -> do+  pairOf <- perBlock w h ((,) <$> exact565 <*> exact565)+  img <- blocky w h $ \x y -> do+    let (c1, c2) = pairOf x y+    (r, g, b) <- elements [c1, c2]+    return (PixelRGBA8 r g b 255)+  let rgb (PixelRGBA8 r g b _) = (r, g, b)+  return $ map rgb (pixels (roundTrip fmt img)) === map rgb (pixels img)++oneColour :: Property+oneColour = forAll (elements [Dxt1 False, Dxt1 True, Dxt3, Dxt5]) $ \fmt ->+  forAll ((,,) <$> arbitrary <*> arbitrary <*> arbitrary) $ \(r, g, b) ->+    let img = generateImage (\_ _ -> PixelRGBA8 r g b 255) 4 4+        close a a' = abs (fromIntegral a - fromIntegral a' :: Int) <= 1+    in conjoin [ counterexample (show p) (close r r' && close g g' && close b b')+               | p@(PixelRGBA8 r' g' b' _) <- pixels (roundTrip fmt img) ]++dxt1Alpha :: Property+dxt1Alpha = forAll sizes $ \(w, h) -> do+  img <- blocky w h $ \_ _ -> PixelRGBA8 <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary+  let alpha (PixelRGBA8 _ _ _ a) = a+  return $ map alpha (pixels (roundTrip (Dxt1 True) img)) === [if alpha p < 128 then 0 else 255 | p <- pixels img]++dxt3Rounding :: Property+dxt3Rounding = forAll sizes $ \(w, h) -> do+  img <- blocky w h $ \_ _ -> PixelRGBA8 0 0 0 <$> arbitrary+  let alpha (PixelRGBA8 _ _ _ a) = fromIntegral a :: Int+  return $ map alpha (pixels (roundTrip Dxt3 img)) === [17 * ((alpha p + 8) `div` 17) | p <- pixels img]++dxt5TwoAlphas :: Property+dxt5TwoAlphas = forAll sizes $ \(w, h) -> do+  pairOf <- perBlock w h ((,) <$> arbitrary <*> arbitrary)+  img <- blocky w h $ \x y -> PixelRGBA8 9 9 9 <$> (let (a1, a2) = pairOf x y in elements [a1, a2])+  let alpha (PixelRGBA8 _ _ _ a) = a+  return $ map alpha (pixels (roundTrip Dxt5 img)) === map alpha (pixels img)
test/Fixture.hs view
@@ -24,9 +24,14 @@   , colourBlock   , dxt3Alpha   , dxt5Alpha+  , assertError+  , assertCorrupt   ) where +import Codec.Picture.Blp (BlpDecodeError(..)) import Data.Bits+import Data.List (isInfixOf)+import Test.Tasty.HUnit import Data.ByteString (ByteString) import Data.Word @@ -172,3 +177,17 @@  le :: Integral a => Int -> a -> [Word8] le n v = [fromIntegral (toInteger v `shiftR` (8 * k)) | k <- [0 .. n - 1]]++-- | Decoding must fail with exactly this error.+assertError :: BlpDecodeError -> Either BlpDecodeError a -> Assertion+assertError expected r = case r of+  Left e -> e @?= expected+  Right _ -> assertFailure $ "expected " ++ show expected++-- | Decoding must fail with 'BlpCorruptImage' whose text mentions the needle.+assertCorrupt :: String -> Either BlpDecodeError a -> Assertion+assertCorrupt needle r = case r of+  Left (BlpCorruptImage what) ->+    assertBool (show what ++ " should mention " ++ show needle) (needle `isInfixOf` what)+  Left e -> assertFailure $ "expected BlpCorruptImage, got " ++ show e+  Right _ -> assertFailure "expected BlpCorruptImage"
test/Main.hs view
@@ -4,10 +4,9 @@ import Codec.Picture.Blp import Codec.Picture.Blp.Internal.Encoder (createMipMaps) import Codec.Picture.Types (convertImage)-import Control.Exception (bracket_, try)+import Control.Exception (bracket_, displayException, throwIO, try) import Control.Monad (forM_) import Data.ByteString (ByteString)-import Data.List (isInfixOf) import Data.Word import System.Directory (getTemporaryDirectory, removeFile) import System.FilePath ((</>))@@ -18,7 +17,6 @@ import qualified Data.ByteString.Lazy as BSL  import Blp2-import Deprecated import Dxt import Properties import Fixture@@ -29,12 +27,13 @@   , malformedTests   , jpegTests   , limitTests+  , errorTests   , qualityTests   , encoderTests   , fileTests-  , deprecatedTests   , dxtTests   , blp2Tests+  , blp2EncodeTests   , propertyTests   ] @@ -66,10 +65,10 @@ 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+decodeRGBA bs = either (assertFailure . ("decodeBlp: " ++) . show) return $ decodeBlp bs  decodeLevels :: ByteString -> IO [Image PixelRGBA8]-decodeLevels bs = either (assertFailure . ("decodeBlpMipmaps: " ++)) (return . map convertRGBA8) $ decodeBlpMipmaps bs+decodeLevels bs = either (assertFailure . ("decodeBlpMipmaps: " ++) . show) return $ decodeBlpMipmaps bs  assertPixels :: (Word8 -> PixelRGBA8) -> ByteString -> Assertion assertPixels expected bs = do@@ -77,11 +76,6 @@   (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)@@ -147,37 +141,37 @@       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 { hAlphaBits = 0, hExtra = 5 } (palette 256) [BS.take 8 indices]+      assertCorrupt "too short" . decodeBlp $ uncompressed defHeader { hAlphaBits = 0, hExtra = 5 } (palette 256) [BS.take 8 indices]   ]  malformedTests :: TestTree malformedTests = testGroup "malformed input"   [ testCase "wrong version tag" $-      assertLeft "BLP1" . decodeBlp $ "BLP0" <> BS.drop 4 (withAlphaFile 4)+      assertError BlpNotBlp . decodeBlp $ "BLP0" <> BS.drop 4 (withAlphaFile 4)   , testCase "unknown compression" $-      assertLeft "compression" . decodeBlp $ uncompressed defHeader { hCompression = 7 } (palette 256) [indices]+      assertError (BlpUnsupported "BLP1 content type 7") . decodeBlp $ uncompressed defHeader { hCompression = 7 } (palette 256) [indices]   , testCase "truncated header" $-      assertLeft "" . decodeBlp $ BS.take 100 (withAlphaFile 4)+      assertError BlpTruncatedHeader . decodeBlp $ BS.take 100 (withAlphaFile 4)   , testCase "empty input" $-      assertLeft "" $ decodeBlp BS.empty+      assertError BlpNotBlp $ decodeBlp BS.empty   , testCase "file cut inside the header names the field" $-      assertLeft "mipmaps sizes" . decodeBlp $ BS.take 100 (withAlphaFile 4)+      assertError BlpTruncatedHeader . decodeBlp $ BS.take 100 (withAlphaFile 4)   , testCase "JPEG header longer than the file" $-      assertLeft "jpeg header" . decodeBlp $ setDword headerSize 100000 jpegFile+      assertError BlpTruncatedHeader . decodeBlp $ setDword headerSize 100000 jpegFile   , testCase "width or height outside 1..65535 is an error" $ do       let sized w h = uncompressed defHeader { hAlphaBits = 0, hWidth = w, hHeight = h } (palette 256) [BS.replicate 4 0]-      assertLeft "Invalid image size" . decodeBlp $ sized 0xFFFFFFFF 0xFFFFFFFF-      assertLeft "Invalid image size" . decodeBlp $ sized 0 4-      assertLeft "Invalid image size" . decodeBlp $ sized 4 65536+      assertError (BlpInvalidSize 0xFFFFFFFF 0xFFFFFFFF) . decodeBlp $ sized 0xFFFFFFFF 0xFFFFFFFF+      assertError (BlpInvalidSize 0 4) . decodeBlp $ sized 0 4+      assertError (BlpInvalidSize 4 65536) . decodeBlp $ sized 4 65536   , testCase "garbage slot before mip 0 leaves the palette intact" $ do       let bs = setDword (offsetSlot 1) 200 . setDword (sizeSlot 1) 4 $ withAlphaFile 4       assertPixels withAlphaPixel bs       levels <- decodeLevels bs       length levels @?= 1   , testCase "uncompressed file without mipmaps" $-      assertLeft "No data" . decodeBlp $ blpFile defHeader [] (paletteBytes $ palette 256)+      assertError BlpNoImageData . 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"+      assertError BlpNoImageData . decodeBlp $ blpFile defHeader { hCompression = 0 } [] "\0\0\0\0"   ]  -- | 16x16 texture with colour and alpha gradients, enough for three mip levels.@@ -210,6 +204,12 @@       img <- decodeRGBA . BSL.toStrict $ enc (BlpFormatJpeg 100) 1 (ImageRGBA8 opaqueGradient)       let d = maxDiff opaqueGradient img       assertBool ("max channel difference " ++ show d) (d <= 8)+  , testCase "a JPEG level without a frame header is an error" $ do+      -- Turn the shared header's SOF0 segment into an APP1 segment of the+      -- same length, so the stream reaches the scan without a frame size.+      let (before, sof) = BS.breakSubstring (BS.pack [0xFF, 0xC0]) jpegFile+      assertBool "the encoder writes SOF0" (not (BS.null sof))+      assertCorrupt "no frame header" . decodeBlp $ before <> BS.pack [0xFF, 0xE1] <> BS.drop 2 sof   , testCase "all three mip levels decode" $ do       levels <- decodeLevels jpegFile       sizes levels @?= [(16, 16), (8, 8), (4, 4)]@@ -229,7 +229,7 @@   , testCase "JPEG frame larger than its level is rejected before decoding" $ do       let sof = frameHeaderAt (fromIntegral headerSize + 4) jpegFile           huge = foldr (\(i, v) bs -> setByte i v bs) jpegFile [(sof + k, 0xFF) | k <- [5 .. 8]]-      assertLeft "larger than its level size" $ decodeBlp huge+      assertCorrupt "larger than its level size" $ decodeBlp huge   , testCase "JPEG frame smaller than its level is padded with transparent black" $ do       let big = setDword 12 32 (setDword 16 32 jpegFile)       levels <- decodeLevels big@@ -244,24 +244,21 @@       levels <- decodeLevels (setDword (sizeSlot 1) 0 jpegFile)       sizes levels @?= [(16, 16)]   , testCase "a JPEG decoder error in mip 0 is a Left" $-      assertLeft "JPEG decoding failed" . decodeBlp $ jpegBlp 1 1 [badJpeg]+      assertCorrupt "JPEG decoding failed" . decodeBlp $ jpegBlp 1 1 [badJpeg]   , testCase "a JPEG decoder error in a tail mip ends the list" $ do       let good = BSL.toStrict . encodeJpeg $ (convertImage (generateImage (\_ _ -> PixelRGB8 10 20 30) 2 2 :: Image PixelRGB8) :: Image PixelYCbCr8)       levels <- decodeLevels (jpegBlp 2 2 [good, badJpeg])       sizes levels @?= [(2, 2)]   , testCase "broken mip 0 fails the file" $-      assertLeft "" . decodeBlp $ setDword (sizeSlot 0) 12 jpegFile+      assertCorrupt "JPEG" . 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, hAlphaBits = 0, hWidth = 16, hHeight = 16, hExtra = 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"+      img <- decodeRGBA bs+      direct <- either (assertFailure . ("decodeJpeg: " ++)) (return . convertRGBA8) $ decodeJpeg jpeg+      pixelsOf img @?= pixelsOf direct   , testCase "semi-transparent texture round-trips through the encoder (#17)" $ do       img <- decodeRGBA . BSL.toStrict $ enc (BlpFormatJpeg 100) 1 (ImageRGBA8 gradient)       let d = maxDiff gradient img@@ -427,10 +424,10 @@           img = ImageRGBA8 opaqueGradient           check write = bracket_ (write path) (removeFile path) $ do             Right single <- readBlp path-            let d = maxDiff opaqueGradient (convertRGBA8 single)+            let d = maxDiff opaqueGradient single             assertBool ("max channel difference " ++ show d) (d <= 8)             Right levels <- readBlpMipmaps path-            sizes (map convertRGBA8 levels) @?= [(16, 16), (8, 8)]+            sizes levels @?= [(16, 16), (8, 8)]       check $ \p -> writeOk p (BlpFormatJpeg 100) img       check $ \p -> writeOk p (BlpFormatPaletted True) img       check $ \p -> writeOk p (BlpFormatPaletted False) (ImageRGB8 $ convertRGB8 img)@@ -487,12 +484,12 @@ limitTests :: TestTree limitTests = testGroup "limits and inspection"   [ testCase "a small file claiming a huge size is rejected before allocating" $-      assertLeft "above the limit" . decodeBlp $ inflated 16384+      assertError (BlpTooLarge 16384 16384 (8192 * 8192)) . decodeBlp $ inflated 16384   , testCase "blpMaxPixels is checked for JPEG and paletted content" $ do       let limited n = decodeBlpWith (BlpDecodeOptions n)-      assertLeft "above the limit" $ limited 255 jpegFile+      assertError (BlpTooLarge 16 16 255) $ limited 255 jpegFile       assertBool "16x16 JPEG fits 256 pixels" . either (const False) (const True) $ limited 256 jpegFile-      assertLeft "above the limit" $ limited 15 (withAlphaFile 4)+      assertError (BlpTooLarge 4 4 15) $ limited 15 (withAlphaFile 4)       assertBool "4x4 paletted fits 16 pixels" . either (const False) (const True) $ limited 16 (withAlphaFile 4)   , testCase "blpInfo reads the header" $ do       blpInfo jpegFile @?= Right (BlpInfo Blp1 BlpCompressionJPEG 16 16 8 3)@@ -501,8 +498,8 @@   , testCase "blpInfo decodes no pixels" $       fmap (\i -> (blpInfoWidth i, blpInfoHeight i)) (blpInfo (inflated 16384)) @?= Right (16384, 16384)   , testCase "blpInfo rejects what decodeBlp rejects" $ do-      assertLeft "BLP1" . blpInfo $ "BLP0" <> BS.drop 4 (withAlphaFile 4)-      assertLeft "Invalid image size" . blpInfo $ setDword 12 0 (withAlphaFile 4)+      assertError BlpNotBlp . blpInfo $ "BLP0" <> BS.drop 4 (withAlphaFile 4)+      assertError (BlpInvalidSize 0 4) . blpInfo $ setDword 12 0 (withAlphaFile 4)   ]  -- | PSNR in dB between two equally sized images, on premultiplied RGBA so@@ -573,4 +570,24 @@   , testCase "JPEG from fully opaque RGBA has no alpha" $ do       let bs = BSL.toStrict $ enc (BlpFormatJpeg 90) 1 (ImageRGBA8 smoothTexture)       (getDword 8 bs, getDword extraOffset bs) @?= (0, 5)+  ]++errorTests :: TestTree+errorTests = testGroup "errors"+  [ testCase "a cut-off magic is a truncated header, other starts are not BLP" $ do+      assertError BlpTruncatedHeader $ decodeBlp "BL"+      assertError BlpNotBlp $ decodeBlp "PNG\r\n"+      assertError BlpNotBlp $ blpInfo BS.empty+  , testCase "a file without image data is an error for every decoder" $ do+      let file = blpFile defHeader [] (paletteBytes $ palette 256)+      assertError BlpNoImageData $ decodeBlpMipmaps file+      fmap blpInfoMipLevels (blpInfo file) @?= Right 0+  , testCase "errors have readable messages" $ do+      displayBlpDecodeError (BlpTooLarge 16384 16384 100) @?= "Image size 16384x16384 is above the limit of 100 pixels"+      displayBlpDecodeError (BlpUnsupported "BLP2 encoding 4") @?= "Unsupported BLP file: BLP2 encoding 4"+      displayBlpDecodeError BlpNotBlp @?= "Not a BLP file: it starts with neither BLP1 nor BLP2"+  , testCase "errors can be thrown and caught" $ do+      r <- try (throwIO BlpNoImageData)+      r @?= (Left BlpNoImageData :: Either BlpDecodeError ())+      displayException BlpNoImageData @?= displayBlpDecodeError BlpNoImageData   ]
test/Properties.hs view
@@ -38,6 +38,10 @@   , encode (BlpFormatJpeg 90) 16 (opaque 8 8)   , encode (BlpFormatPaletted True) 16 (texture 16 16)   , encode (BlpFormatPaletted False) 16 (texture 9 4)+  , encodeAs Blp2 BlpFormatDxt1 16 (texture 9 4)+  , encodeAs Blp2 BlpFormatDxt5 16 (texture 16 8)+  , encodeAs Blp2 BlpFormatBgra 16 (texture 5 7)+  , encodeAs Blp2 (BlpFormatPaletted True) 16 (texture 8 8)   ]   where     opaque w h = ImageRGBA8 . pixelMap (\(PixelRGBA8 r g b _) -> PixelRGBA8 r g b 255) . convertRGBA8 $ texture w h@@ -48,8 +52,11 @@     px x y = PixelRGBA8 (fromIntegral $ x * 37) (fromIntegral $ y * 59) (fromIntegral $ x * y) (fromIntegral $ 255 - x * 13)  encode :: BlpFormat -> Int -> DynamicImage -> ByteString-encode format levels = BSL.toStrict . either (error . show) id . encodeBlpWith defaultBlpEncodeOptions { blpFormat = format, blpMipLevels = levels }+encode = encodeAs Blp1 +encodeAs :: BlpVersion -> BlpFormat -> Int -> DynamicImage -> ByteString+encodeAs version format levels = BSL.toStrict . either (error . show) id . encodeBlpWith defaultBlpEncodeOptions { blpFormat = format, blpMipLevels = levels, blpVersion = version }+ -- | One way of damaging a file. data Damage   = Flip Int Word8 -- ^ XOR a byte@@ -88,7 +95,7 @@ decodingNeverThrows = forAllShow damagedFile showCase $ \(seed, ds) -> monadicIO $ do   let file = foldl damage seed ds   result <- run . timeout 1000000 . trySync . evaluate . force $-    fmap (map (imageData . convertRGBA8)) (decodeBlpMipmaps file)+    fmap (map imageData) (decodeBlpMipmaps file)   case result of     Nothing -> monitor (label "JuicyPixels hang (Juicy.Pixels#230)")     Just (Left e) -> do@@ -118,12 +125,13 @@ -- | Level l of a w x h texture is max 1 (w >> l) by max 1 (h >> l), down to -- 1x1, at most 16 levels. levelSizes :: Property-levelSizes = forAll ((,,) <$> choose (1, 40) <*> choose (1, 40) <*> format) $ \(w, h, f) ->+levelSizes = forAll ((,,) <$> choose (1, 40) <*> choose (1, 40) <*> format) $ \(w, h, (v, f)) ->   let expected = take 16 (chain w h)-      decoded = decodeBlpMipmaps (encode f 16 (texture w h))-  in either (const []) (map (\i -> (dynamicMap imageWidth i, dynamicMap imageHeight i))) decoded === expected+      decoded = decodeBlpMipmaps (encodeAs v f 16 (texture w h))+  in either (const []) (map (\i -> (imageWidth i, imageHeight i))) decoded === expected   where-    format = elements [BlpFormatJpeg 90, BlpFormatPaletted True, BlpFormatPaletted False]+    format = elements $ [(Blp1, f) | f <- [BlpFormatJpeg 90, BlpFormatPaletted True, BlpFormatPaletted False]]+      ++ [(Blp2, f) | f <- [BlpFormatPaletted True, BlpFormatPaletted False, BlpFormatDxt1, BlpFormatDxt3, BlpFormatDxt5, BlpFormatBgra]]     chain w h = go 0       where         go l = let s = (max 1 (w `shiftR` l), max 1 (h `shiftR` l))@@ -135,7 +143,7 @@ fewColoursExact = forAll gen $ \(w, h, colours, picks) ->   let img = generateImage (\x y -> colours !! (picks !! (y * w + x))) w h       decoded = decodeBlp (encode (BlpFormatPaletted False) 1 (ImageRGBA8 img))-  in fmap (imageData . convertRGBA8) decoded === Right (imageData img)+  in fmap imageData decoded === Right (imageData img)   where     gen = do       w <- choose (1, 24)