mmark-ext-0.3.0.0: tests/Text/MMark/Extension/ImageSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Text.MMark.Extension.ImageSpec (spec) where
import Data.Bits (shiftR, (.&.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as B
import Data.ByteString.Char8 qualified as B8
import Data.Map.Strict qualified as M
import Data.Text (Text)
import Data.Word (Word8)
import System.FilePath ((</>))
import Test.Hspec
import Text.MMark qualified as MMark
import Text.MMark.Extension.Image
import Text.MMark.Extension.TestUtils
import Text.MMark.Trans (Span)
import Text.URI qualified as URI
spec :: Spec
spec = do
describe "checkAltText" $ do
it "reports an image with no description" $
transErrors checkAltText ""
`shouldReturn` ["1:1: this image has no description for the alt attribute"]
it "accepts an image with a description" $
transErrors checkAltText "" `shouldReturn` []
it "reports an image nested in a link" $
transErrors checkAltText "[](/x)"
`shouldReturn` ["1:2: this image has no description for the alt attribute"]
it "finds an image inside a block quote" $
transErrors checkAltText "> "
`shouldReturn` ["1:3: this image has no description for the alt attribute"]
it "reports every undescribed image, once each" $
transErrors checkAltText "  "
`shouldReturn` [ "1:1: this image has no description for the alt attribute",
"1:26: this image has no description for the alt attribute"
]
describe "lazyImages" $ do
it "adds the loading and decoding attributes" $
withExt
lazyImages
""
"<p><img loading=\"lazy\" decoding=\"async\" alt=\"a cat\" src=\"/a.png\"></p>\n"
it "leaves other inlines alone" $
withExt lazyImages "[a link](/x)" "<p><a href=\"/x\">a link</a></p>\n"
describe "imageScanner" $ do
it "collects the URI of an image" $
scanned "" `shouldBe` ["/a.png"]
it "collects every image of a document" $
scanned "\n\n" `shouldBe` ["/a.png", "/b.png"]
it "collects an image nested in a link and in a quote" $
scanned "[](/x)\n\n> "
`shouldBe` ["/a.png", "/b.png"]
it "keeps two images with the same URI apart" $
length (M.toList (scan " ")) `shouldBe` 2
it "collects nothing from a document with no images" $
scanned "just some text" `shouldBe` []
describe "imageDimensions" $ do
it "gives an image the size it was measured to have" $
withSizes
(Just (640, 480))
""
"<p><img width=\"640\" height=\"480\" alt=\"a cat\" src=\"/a.png\"></p>\n"
it "leaves an image that could not be measured alone" $
withSizes
Nothing
""
"<p><img alt=\"a cat\" src=\"/a.png\"></p>\n"
it "leaves an image with no measurement at all alone" $
withExt
(imageDimensions M.empty)
""
"<p><img alt=\"a cat\" src=\"/a.png\"></p>\n"
it "composes with lazyImages" $
withSizesUsing
(lazyImages <>)
(Just (7, 3))
""
"<p><img loading=\"lazy\" decoding=\"async\" width=\"7\" height=\"3\" alt=\"a cat\" src=\"/a.png\"></p>\n"
describe "imageSizeOf" $ do
it "measures a PNG" $
measuring (pngBytes 7 3) `shouldReturn` Just (7, 3)
it "measures a PNG larger than a byte in each direction" $
measuring (pngBytes 1920 1080) `shouldReturn` Just (1920, 1080)
it "measures a GIF" $
measuring (gifBytes 11 5) `shouldReturn` Just (11, 5)
it "measures a GIF larger than a byte in each direction" $
measuring (gifBytes 800 600) `shouldReturn` Just (800, 600)
it "measures a JPEG" $
measuring (jpegBytes [] 13 9) `shouldReturn` Just (13, 9)
it "measures a JPEG behind a segment it does not care about" $
measuring (jpegBytes [app0, comment 40] 320 240)
`shouldReturn` Just (320, 240)
it "measures a JPEG behind a marker that carries no payload" $
-- 0xD8 is SOI, whose two following bytes are not a length; a walk
-- that reads them as one lands in the middle of nothing.
measuring (jpegBytes [app0, standalone 0xD8, comment 8] 64 48)
`shouldReturn` Just (64, 48)
it "measures a JPEG whose frame is not the baseline one" $
-- SOF2, the progressive frame header
measuring (jpegBytesWith 0xC2 [app0] 21 12) `shouldReturn` Just (21, 12)
it "does not mistake a huffman table for a frame" $
-- 0xC4 is in the SOF range by number but is not a frame
measuring (jpegBytes [tableNotAFrame] 30 20) `shouldReturn` Just (30, 20)
it "gives up on a JPEG that ends before its frame" $
measuring (B.pack [0xFF, 0xD8] <> app0) `shouldReturn` Nothing
it "gives up on a JPEG whose segment lengths are nonsense" $
-- a segment that claims to be no bytes long, then one that claims to
-- run past the end of the file
measuring
( B.pack [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x00]
<> B.replicate 8 0x20
<> B.pack [0xFF, 0xC0]
<> be16 11
<> B.pack [8]
<> be16 99
<> be16 99
<> B.pack [1, 1, 0x11, 0]
)
`shouldReturn` Nothing
it "gives up on a file that is not an image" $
measuring (B8.pack "just some text, not an image at all")
`shouldReturn` Nothing
it "gives up on an empty file" $
measuring B.empty `shouldReturn` Nothing
it "gives up on a truncated PNG" $
measuring (B.take 20 (pngBytes 7 3)) `shouldReturn` Nothing
it "gives up on a truncated GIF" $
measuring (B.take 8 (gifBytes 11 5)) `shouldReturn` Nothing
it "gives up on a file that is not there instead of throwing" $
withTempDir (\dir -> imageSizeOf (dir </> "nope.png"))
`shouldReturn` Nothing
it "gives up on a directory instead of throwing" $
withTempDir imageSizeOf `shouldReturn` Nothing
----------------------------------------------------------------------------
-- Helpers
-- | Scan a document and return the URI of every image it has, in order.
scanned :: Text -> [Text]
scanned = fmap URI.render . M.elems . scan
scan :: Text -> M.Map Span URI.URI
scan input = case MMark.parse "" input of
Left _ -> error "the test input does not parse"
Right doc -> MMark.runScanner imageScanner doc
-- | Render a document with every image measured as the given size.
withSizes :: Maybe (Int, Int) -> Text -> Text -> Expectation
withSizes = withSizesUsing id
withSizesUsing ::
-- | What else to render with
(MMark.RenderExtension -> MMark.RenderExtension) ->
-- | The size every image is measured to have
Maybe (Int, Int) ->
-- | Input for the parser
Text ->
-- | Expected output of the render
Text ->
Expectation
withSizesUsing f size input expected =
withExt (f (imageDimensions (size <$ scan input))) input expected
-- | The bytes of a PNG of the given size: the signature and the @IHDR@
-- chunk, which is all that states the size.
pngBytes :: Int -> Int -> ByteString
pngBytes w h =
B.pack [137, 80, 78, 71, 13, 10, 26, 10]
<> be32 13
<> B8.pack "IHDR"
<> be32 w
<> be32 h
<> B.pack [8, 2, 0, 0, 0]
-- | The bytes of a GIF of the given size: the signature and the logical
-- screen descriptor.
gifBytes :: Int -> Int -> ByteString
gifBytes w h = B8.pack "GIF89a" <> le16 w <> le16 h <> B.pack [0, 0, 0]
-- | The bytes of a JPEG of the given size: @SOI@, the given segments, then
-- a baseline frame header.
jpegBytes :: [ByteString] -> Int -> Int -> ByteString
jpegBytes = jpegBytesWith 0xC0
-- | Like 'jpegBytes', but you choose which frame header states the size.
jpegBytesWith :: Word8 -> [ByteString] -> Int -> Int -> ByteString
jpegBytesWith marker leading w h =
B.pack [0xFF, 0xD8] <> B.concat leading <> sof <> B.pack [0xFF, 0xD9]
where
sof =
B.pack [0xFF, marker]
<> be16 11
<> B.pack [8]
<> be16 h
<> be16 w
<> B.pack [1, 1, 0x11, 0]
-- | A @JFIF@ header, the segment that usually comes first.
app0 :: ByteString
app0 =
B.pack [0xFF, 0xE0]
<> be16 16
<> B8.pack "JFIF\NUL"
<> B.pack [1, 1, 0, 0, 1, 0, 1, 0, 0]
-- | A comment segment carrying the given number of bytes of padding.
comment :: Int -> ByteString
comment n = B.pack [0xFF, 0xFE] <> be16 (n + 2) <> B.replicate n 0x20
-- | A marker that carries no payload at all.
standalone :: Word8 -> ByteString
standalone m = B.pack [0xFF, m]
-- | A huffman table, which sits in the range the frame headers occupy but
-- is not one of them.
tableNotAFrame :: ByteString
tableNotAFrame = B.pack [0xFF, 0xC4] <> be16 6 <> B.replicate 4 0
-- | Write the given bytes to a file and measure it.
measuring :: ByteString -> IO (Maybe (Int, Int))
measuring bs = withTempDir $ \dir -> do
let path = dir </> "image"
B.writeFile path bs
imageSizeOf path
be32 :: Int -> ByteString
be32 n = B.pack (fmap (byte n) [24, 16, 8, 0])
be16 :: Int -> ByteString
be16 n = B.pack (fmap (byte n) [8, 0])
le16 :: Int -> ByteString
le16 n = B.pack (fmap (byte n) [0, 8])
byte :: Int -> Int -> Word8
byte n s = fromIntegral ((n `shiftR` s) .&. 0xFF)