packages feed

doclayout 0.3.0.2 → 0.3.1

raw patch · 4 files changed

+270/−55 lines, 4 filesdep +containersdep +emojisdep +tasty-quickcheckPVP ok

version bump matches the API change (PVP)

Dependencies added: containers, emojis, tasty-quickcheck

API changes (from Hackage documentation)

+ Text.DocLayout: instance GHC.Base.Semigroup Text.DocLayout.UnicodeWidthMatch
+ Text.DocLayout: instance GHC.Show.Show Text.DocLayout.Emoji
+ Text.DocLayout: instance GHC.Show.Show Text.DocLayout.UnicodeWidthMatch
+ Text.DocLayout: isEmojiJoiner :: Char -> Bool
+ Text.DocLayout: isEmojiModifier :: Char -> Bool
+ Text.DocLayout: isEmojiVariation :: Char -> Bool
+ Text.DocLayout: realLengthNoShortcut :: HasChars a => a -> Int

Files

changelog.md view
@@ -1,5 +1,15 @@ # doclayout +## 0.3.1++  * Improved handling of emojis.  Emojis are double-wide, but+    previously this library did not treat them as such.  We now+    have comprehensive support of emojis, including variation+    modifiers and zero-width joiners, verified by a test suite.+    Performance has been confirmed to be no worse for text without emojis.+    (Stephen Morgan, #1).  API changes: export `realLengthNoShortcut`,+    `isEmojiModifier`, `isEmojiVariation`, `isEmojiJoiner`.+ ## 0.3.0.2   * NOINLINE `literal` instead of `fromString` (#2, sjakobi).
doclayout.cabal view
@@ -1,5 +1,5 @@ name:                doclayout-version:             0.3.0.2+version:             0.3.1 synopsis:            A prettyprinting library for laying out text documents. description:         doclayout is a prettyprinting library for laying out                      text documents, with several features not present@@ -23,6 +23,8 @@   exposed-modules:     Text.DocLayout   build-depends:       base >= 4.9 && < 5,                        text,+                       containers,+                       emojis >=0.1.2,                        mtl,                        safe   if !impl(ghc >= 8.0)@@ -40,7 +42,9 @@                        tasty,                        tasty-golden,                        tasty-hunit,-                       text+                       tasty-quickcheck,+                       text,+                       emojis >=0.1.2   ghc-options:         -threaded -rtsopts -with-rtsopts=-N   default-language:    Haskell2010 
src/Text/DocLayout.hs view
@@ -69,6 +69,10 @@      , height      , charWidth      , realLength+     , realLengthNoShortcut+     , isEmojiModifier+     , isEmojiVariation+     , isEmojiJoiner      -- * Types      , Doc(..)      , HasChars(..)@@ -76,14 +80,16 @@  where import Prelude-import Data.List (foldl') import Data.Maybe (fromMaybe)+import Data.Monoid (Sum(..)) import Safe (lastMay, initSafe) import Control.Monad import Control.Monad.State.Strict import GHC.Generics-import Data.Char (isSpace)-import Data.List (intersperse)+import Data.Char (isDigit, isSpace, ord)+import Data.List (foldl', intersperse)+import Data.List.NonEmpty (NonEmpty(..))+import qualified Data.IntMap.Strict as IM import Data.Data (Data, Typeable) import Data.String import qualified Data.Text as T@@ -93,6 +99,7 @@ #else import Data.Semigroup #endif+import Text.Emoji (baseEmojis)  -- | Class abstracting over various string types that -- can fold over characters.  Minimal definition is 'foldrChar'@@ -676,58 +683,212 @@ -- | Returns width of a character in a monospace font:  0 for a combining -- character, 1 for a regular character, 2 for an East Asian wide character. charWidth :: Char -> Int-charWidth c =-  case c of-      _ | c <  '\x0300'                    -> 1-        | c >= '\x0300' && c <= '\x036F'   -> 0  -- combining-        | c >= '\x0370' && c <= '\x10FC'   -> 1-        | c >= '\x1100' && c <= '\x115F'   -> 2-        | c >= '\x1160' && c <= '\x11A2'   -> 1-        | c >= '\x11A3' && c <= '\x11A7'   -> 2-        | c >= '\x11A8' && c <= '\x11F9'   -> 1-        | c >= '\x11FA' && c <= '\x11FF'   -> 2-        | c >= '\x1200' && c <= '\x2328'   -> 1-        | c >= '\x2329' && c <= '\x232A'   -> 2-        | c >= '\x232B' && c <= '\x2E31'   -> 1-        | c >= '\x2E80' && c <= '\x303E'   -> 2-        | c == '\x303F'                    -> 1-        | c >= '\x3041' && c <= '\x3247'   -> 2-        | c >= '\x3248' && c <= '\x324F'   -> 1 -- ambiguous-        | c >= '\x3250' && c <= '\x4DBF'   -> 2-        | c >= '\x4DC0' && c <= '\x4DFF'   -> 1-        | c >= '\x4E00' && c <= '\xA4C6'   -> 2-        | c >= '\xA4D0' && c <= '\xA95F'   -> 1-        | c >= '\xA960' && c <= '\xA97C'   -> 2-        | c >= '\xA980' && c <= '\xABF9'   -> 1-        | c >= '\xAC00' && c <= '\xD7FB'   -> 2-        | c >= '\xD800' && c <= '\xDFFF'   -> 1-        | c >= '\xE000' && c <= '\xF8FF'   -> 1 -- ambiguous-        | c >= '\xF900' && c <= '\xFAFF'   -> 2-        | c >= '\xFB00' && c <= '\xFDFD'   -> 1-        | c >= '\xFE00' && c <= '\xFE0F'   -> 1 -- ambiguous-        | c >= '\xFE10' && c <= '\xFE19'   -> 2-        | c >= '\xFE20' && c <= '\xFE26'   -> 1-        | c >= '\xFE30' && c <= '\xFE6B'   -> 2-        | c >= '\xFE70' && c <= '\xFEFF'   -> 1-        | c >= '\xFF01' && c <= '\xFF60'   -> 2-        | c >= '\xFF61' && c <= '\x16A38'  -> 1-        | c >= '\x1B000' && c <= '\x1B001' -> 2-        | c >= '\x1D000' && c <= '\x1F1FF' -> 1-        | c >= '\x1F200' && c <= '\x1F251' -> 2-        | c >= '\x1F300' && c <= '\x1F773' -> 1-        | c >= '\x20000' && c <= '\x3FFFD' -> 2-        | otherwise                        -> 1+charWidth c = maybe 1 (specificWidth . snd) $ IM.lookupLE (ord c) unicodeWidthMap  -- | Get real length of string, taking into account combining and double-wide -- characters. realLength :: HasChars a => a -> Int-realLength s = fromMaybe 0 $ foldlChar go Nothing s+realLength = realLengthWith updateMatchState++-- | Get real length of string, taking into account combining and double-wide+-- characters, without taking any shortcuts. This should give the same answer+-- as 'updateMatchState', but will be slower. It is here to test that the+-- shortcuts are implemented correctly.+realLengthNoShortcut :: HasChars a => a -> Int+realLengthNoShortcut = realLengthWith updateMatchStateNoShortcut++-- | Get real length of string, taking into account combining and double-wide+-- characters, using the given accumulator.+realLengthWith :: HasChars a => (MatchState -> Char -> MatchState) -> a -> Int+realLengthWith f = extractLength . foldlChar f (MatchState True 0 0 mempty)   where-   -- Using a Maybe allows us to handle the case where the string-   -- starts with a combining character.  Since there is no preceding-   -- character, we count 0 width as 1 in this one case:-   go Nothing !c =-       case charWidth c of-         0  -> Just 1-         !n -> Just n-   go (Just !tot) !c = Just (tot + charWidth c)+    extractLength (MatchState _ tot w _) = tot + w++-- | Update a 'MatchState' by processing a character.+updateMatchState :: MatchState -> Char -> MatchState+updateMatchState (MatchState first tot _ Nothing) !c+    -- For efficiency, we isolate commonly used portions of the basic+    -- multilingual plane that do not have emoji in them.+    -- Maximum contiguous range containing ASCII alphabetic characters and no emoji+    | c <= '\x00A8'                                   = MatchState False (tot + 1) 0 Nothing+    -- Combining characters have width 0+    | c >= '\x0300' && c <= '\x036F'                  = MatchState False (if first then tot + 1 else tot) 0 Nothing+    -- A block of width 1+    | c >= '\x0370' && c <= '\x10FC'                  = MatchState False (tot + 1) 0 Nothing+    -- Hexagrams are width 1+    | c >= '\x4DC0' && c <= '\x4DFF'                  = MatchState False (tot + 1) 0 Nothing+    -- Maximum contiguous range of width 2 with no emoji containing CJK+    | c >= '\x329a' && c <= '\xA4C6'                  = MatchState False (tot + 2) 0 Nothing+    -- An ambiguous block; TODO: should be width 2 if surrounded by wide, 1 otherwise+    | c >= '\x3248' && c <= '\x324F'                  = MatchState False (tot + 1) 0 Nothing+    -- A width 1 straggler+    | c == '\x303F'                                   = MatchState False (tot + 1) 0 Nothing+updateMatchState s c = updateMatchStateNoShortcut s c++-- | Update a 'MatchState' by processing a character, without taking any+-- shortcuts. This should give the same answer as 'updateMatchState', but will+-- be slower. It is here to test that the shortcuts are implemented correctly.+updateMatchStateNoShortcut :: MatchState -> Char -> MatchState+updateMatchStateNoShortcut (MatchState first tot _ Nothing) !c =+    case IM.lookupLE oc unicodeWidthMap of+        -- If there is a specific match, record the tentative width, the map of+        -- continuations, and move to the next character+        Just (!oc', SpecificMatch r w m) | oc == oc' -> MatchState False tot (fromMaybe r w) (Just m)+        -- If there is only a range match, record the total width and move to+        -- the next character+        Just (!_, !match) -> let r = rangeWidth match+                                 -- If the string starts with a combining character.  Since there is no+                                 -- preceding character, we count 0 width as 1 in this one case:+                                 r' = if first && r == 0 then 1 else r+                              in MatchState False (tot + r') 0 Nothing+        -- M.lookupLE should not fail+        Nothing -> MatchState False (tot + 1) 0 Nothing+  where+    oc = ord c+updateMatchStateNoShortcut (MatchState _ tot w (Just !m)) !c+    -- Skin tone modifiers and variation modifiers modify the emoji up to this+    -- point, so can be discarded. However, they always make it width 2, so we+    -- set the tentative width to 2.+    | isEmojiModifier c || isEmojiVariation c = MatchState False tot 2 (Just m)+    -- Zero width joiners will join two emoji together, so let's discard the state and parse the next emoji+    | isEmojiJoiner c = MatchState False tot 2 Nothing+    -- Otherwise, lookup the emoji continuations+    | otherwise = case IM.lookup (ord c) m of+        -- Continuations match, move to the next step with new continuations+        Just (Emoji ew m') -> MatchState False tot ew (Just m')+        -- No continuations match, use the tentative width and process c without continuations+        -- I guess we use shortcuts here; that's probably fine.+        Nothing -> updateMatchState (MatchState False (tot + w) 0 Nothing) c++-- | Keeps track of state in length calculations, determining whether we're at+-- the first character, the width so far, the tentative width for this group,+-- and the Map for possible emoji continuations.+data MatchState = MatchState !Bool !Int !Int !(Maybe EmojiMap)++-- | A possible match for unicode characters; either within a range block, or a+-- specific match with a block range width, possibly a specific width, and a map of+-- continuations.+data UnicodeWidthMatch+    = RangeSeparator !Int                         -- This code point marks the boundary of a range+    | SpecificMatch !Int !(Maybe Int) !EmojiMap   -- This code point has a specific emoji with continuations+  deriving (Show)++instance Semigroup UnicodeWidthMatch where+    (SpecificMatch r w1 m1) <> (SpecificMatch _ w2 m2) = SpecificMatch r w $ concatEmojiMap m1 m2+      where+        w = getSum <$> (Sum <$> w1) <> (Sum <$> w2)+    s <> _ = s++-- | The width of the block in which the character lies, ignoring specific+-- matches.+rangeWidth :: UnicodeWidthMatch -> Int+rangeWidth (RangeSeparator !r)    = r+rangeWidth (SpecificMatch !r !_ !_) = r++-- | The specific width of a character.+specificWidth :: UnicodeWidthMatch -> Int+specificWidth (RangeSeparator r)    = r+specificWidth (SpecificMatch r w _) = fromMaybe r w++-- | Checks whether a character is a skin tone modifier+isEmojiModifier :: Char -> Bool+isEmojiModifier c = c >= '\x1F3FB' && c <= '\x1F3FF'++-- | Checks whether a character is an emoji variation modifier.+isEmojiVariation :: Char -> Bool+isEmojiVariation c = c == '\xFE0F'++-- | Checks whether a character is an emoji joiner.+isEmojiJoiner :: Char -> Bool+isEmojiJoiner c = c == '\x200D'++-- | A map for looking up the width of Unicode text.+unicodeWidthMap :: IM.IntMap UnicodeWidthMatch+unicodeWidthMap =+    foldr addEmoji unicodeRangeMap+    . filter (maybe True (not . isKeypad . fst) . T.uncons)  -- Keypad emoji can be handles by base rules+    $ filter (not . T.any isEmojiModifier)                   -- Emoji modifiers are inferred from the base emoji+    baseEmojis+  where+    isKeypad c = isDigit c || c == '*' || c == '#'++-- | Denotes the contiguous ranges of Unicode characters which have a given+-- width: 1 for a regular character, 2 for an East Asian wide character. Emoji+-- have different widths and lie within some of these blocks. And the emoji+-- will be added later.+unicodeRangeMap :: IM.IntMap UnicodeWidthMatch+unicodeRangeMap = IM.fromList $ map (\(c, x) -> (ord c, x))+    [ ('\x0000', RangeSeparator 1)+    , ('\x0300', RangeSeparator 0)  -- combining+    , ('\x0370', RangeSeparator 1)+    , ('\x1100', RangeSeparator 2)+    , ('\x1160', RangeSeparator 1)+    , ('\x11A3', RangeSeparator 2)+    , ('\x11A8', RangeSeparator 1)+    , ('\x11FA', RangeSeparator 2)+    , ('\x1200', RangeSeparator 1)+    , ('\x1AB0', RangeSeparator 0)  -- combining+    , ('\x1B00', RangeSeparator 1)+    , ('\x1DC0', RangeSeparator 0)  -- combining+    , ('\x1E00', RangeSeparator 1)+    , ('\x200B', RangeSeparator 0)  -- zero-width characters and directional overrides+    , ('\x2030', RangeSeparator 1)  -- combining+    , ('\x20D0', RangeSeparator 0)  -- combining+    , ('\x2100', RangeSeparator 1)+    , ('\x2329', RangeSeparator 2)+    , ('\x232B', RangeSeparator 1)+    , ('\x2E80', RangeSeparator 2)+    , ('\x303F', RangeSeparator 1)+    , ('\x3041', RangeSeparator 2)+    , ('\x3248', RangeSeparator 1)  -- ambiguous+    , ('\x3250', RangeSeparator 2)+    , ('\x4DC0', RangeSeparator 1)+    , ('\x4E00', RangeSeparator 2)+    , ('\xA4D0', RangeSeparator 1)+    , ('\xA960', RangeSeparator 2)+    , ('\xA980', RangeSeparator 1)+    , ('\xAC00', RangeSeparator 2)+    , ('\xD800', RangeSeparator 1)+    , ('\xE000', RangeSeparator 1)  -- ambiguous+    , ('\xF900', RangeSeparator 2)+    , ('\xFB00', RangeSeparator 1)+    , ('\xFE00', RangeSeparator 1)  -- ambiguous+    , ('\xFE10', RangeSeparator 2)+    , ('\xFE20', RangeSeparator 0)  -- combining+    , ('\xFE30', RangeSeparator 2)+    , ('\xFE70', RangeSeparator 1)+    , ('\xFF01', RangeSeparator 2)+    , ('\xFF61', RangeSeparator 1)+    , ('\x1B000', RangeSeparator 2)+    , ('\x1D000', RangeSeparator 1)+    , ('\x1F200', RangeSeparator 2)+    , ('\x1F300', RangeSeparator 1)+    , ('\x1F3FB', RangeSeparator 2)  -- skin tone modifiers+    , ('\x1F400', RangeSeparator 1)+    , ('\x20000', RangeSeparator 2)+    , ('\x3FFFD', RangeSeparator 1)+    ]++type EmojiMap = IM.IntMap Emoji+data Emoji = Emoji !Int !EmojiMap+  deriving (Show)++concatEmojiMap :: EmojiMap -> EmojiMap -> EmojiMap+concatEmojiMap = IM.unionWith (\(Emoji w e1) (Emoji _ e2) -> Emoji w $ concatEmojiMap e1 e2)++emojiToMatch :: IM.IntMap UnicodeWidthMatch -> NonEmpty Char -> UnicodeWidthMatch+emojiToMatch m (x:|xs) = SpecificMatch r w . emojiToMap $ filter (not . isEmojiVariation) xs+  where+    r = maybe 1 (rangeWidth . snd) $ IM.lookupLT (ord x) m+    -- If it is a single code point emoji, it is of width 2. Otherwise, don't+    -- overwrite the range width.+    w = if null xs then Just 2 else Nothing++addEmoji :: Text -> IM.IntMap UnicodeWidthMatch -> IM.IntMap UnicodeWidthMatch+addEmoji !emoji !m = case T.unpack emoji of+    []   -> m+    x:xs -> IM.insertWith (<>) (ord x) (emojiToMatch m (x:|xs)) m++emojiToMap :: String -> EmojiMap+emojiToMap []     = mempty+emojiToMap (x:xs) = IM.singleton (ord x) . Emoji 2 $ emojiToMap xs
test/test.hs view
@@ -3,9 +3,13 @@ {-# LANGUAGE ScopedTypeVariables #-}  import Text.DocLayout+import Text.Emoji import Test.Tasty import Test.Tasty.HUnit+import Test.Tasty.QuickCheck+import Data.Functor ((<&>)) import Data.Text (Text)+import qualified Data.Text as T #if MIN_VERSION_base(4,11,0) #else import Data.Semigroup@@ -268,4 +272,40 @@       Nothing       (text "\870" <> space <> text "a")       "\870 a"++  , testCase "length of normal text" $+      realLength ("This is going to be too long anyway" :: String) @?= 35++  , testCase "length of normal character, which could be continued to an emoji, but isn't" $+      realLength ("*a" :: String) @?= 2++  , testCase "length of normal character, which could be continued to an emoji, and is" $+      realLength ("*\xFE0F\x20E3\&a" :: String) @?= 3++  , testCase "length emoji consisting of one code point" $+      realLength ("\x231A" :: String) @?= 2++  , testCase "length of an emoji constructed using the variating modifier" $+      realLength ("\x00A9\xFE0F" :: String) @?= 2++  , testCase "length of a non-emoji which would be an emoji with a variation modifier" $+      realLength ("\x00A9" :: String) @?= 1++  , testCase "length of two emoji in a row" $+      realLength ("\x1F170\xFE0F\x1F1E6\x1F1E8" :: String) @?= 4++  , testCase "length of an emoji with skin tone modifier, where stripping results in a non-emoji" $+      realLength ("\x1F590\x1F3FF" :: String) @?= 2++  , testCase "a digit with a skin tone modifier is invalid but might appear, and shouldn't be mistaken for a variation modifier" $+      realLength ("1\x1F3FF" :: String) @?= 3++  , testGroup "all base emoji have width 2" $+      baseEmojis <&> \emoji -> testCase (T.unpack emoji) $ realLength emoji @?= 2++  , testGroup "all zero-width joiner emoji sequences have width 2" $+      zwjEmojis <&> \emoji -> testCase (T.unpack emoji) $ realLength emoji @?= 2++  , testProperty "shortcut provides same answer for string length" . withMaxSuccess 1000000 $+      \(x :: String) -> realLength x === realLengthNoShortcut x   ]