lipstick-0.1.0.0: internal/Lipstick/Internal/Width.hs
-- | The implementation of "Lipstick.Width", hidden from other packages: see
-- that module.
module Lipstick.Internal.Width
( charWidth
, textWidth
, clusters
, unicodeVersion
) where
import Data.Array.Base (unsafeAt)
import Data.Array.Unboxed (bounds)
import Data.Bits (shiftR, testBit, (.&.))
import Data.Char (ord)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Word (Word16)
import Lipstick.Internal.UnicodeTable
-- | The Unicode version the tables were generated from.
unicodeVersion :: String
unicodeVersion = unicodeTableVersion
-- | Display width of a whole text.
textWidth :: Text -> Int
textWidth = sum . map snd . clusters
-- | Display width of a single character, ignoring context.
charWidth :: Char -> Int
charWidth = codeWidth . props
-- | Split text into grapheme clusters, each with its display width. The
-- clusters are slices of the text, not copies.
clusters :: Text -> [(Text, Int)]
clusters = go
where
go t = case T.uncons t of
Nothing -> []
Just (c, rest) ->
let p = props c
(n, ext) = extend (start p) p 1 rest
(cl, more) = T.splitAt n t
in (cl, clusterWidth ((c, p) : ext)) : go more
-- How many characters the cluster has, and the ones after the first.
extend st prev n t = case T.uncons t of
Just (c, rest)
| let p = props c
, joins st prev p ->
let (n', ext) = extend (step st p) p (n + 1) rest in (n', (c, p) : ext)
_ -> (n :: Int, [])
-- Properties ------------------------------------------------------------------
-- | Packed properties of a character (see the generator for the layout).
props :: Char -> Word16
props c
| n < 0x7F = if n < 0x20 then asciiControl n else asciiProps n
| otherwise = unsafeAt runValues (search 0 hi)
where
n = ord c
(_, hi) = bounds runStarts
-- The last run starting at or before n.
search lo h
| lo >= h = lo
| otherwise =
let mid = (lo + h + 1) `div` 2
in if unsafeAt runStarts mid <= n then search mid h else search lo (mid - 1)
-- ASCII must agree with the table: these only skip the search.
asciiControl k
| k == 0x0D = gcbCR
| k == 0x0A = gcbLF
| otherwise = gcbControl
asciiProps k
-- '#', '*' and the digits have the Emoji property (keycaps), bit 6.
| k == 0x23 || k == 0x2A || (k >= 0x30 && k <= 0x39) = 0x40
| otherwise = 0
gcb :: Word16 -> Word16
gcb p = p .&. 0xF
isWide, isPictographic, isEmoji :: Word16 -> Bool
isWide p = testBit p 4
isPictographic p = testBit p 5
isEmoji p = testBit p 6
incb :: Word16 -> Word16
incb p = (p `shiftR` 7) .&. 3
-- These must match gcbCode and incbCode in scripts/GenUnicodeTable.hs.
gcbCR, gcbLF, gcbControl, gcbExtend, gcbZWJ, gcbRI :: Word16
gcbCR = 1
gcbLF = 2
gcbControl = 3
gcbExtend = 4
gcbZWJ = 5
gcbRI = 6
gcbPrepend, gcbSpacingMark, gcbL, gcbV, gcbT, gcbLV, gcbLVT :: Word16
gcbPrepend = 7
gcbSpacingMark = 8
gcbL = 9
gcbV = 10
gcbT = 11
gcbLV = 12
gcbLVT = 13
incbLinker, incbConsonant, incbExtend :: Word16
incbLinker = 1
incbConsonant = 2
incbExtend = 3
-- Segmentation ----------------------------------------------------------------
-- | What the current cluster has seen, for the rules that look back further
-- than one character.
data State = State
{ stRegional :: !Int -- ^ regional indicators in a row
, stPict :: !Pict
, stConjunct :: !Conjunct
}
-- | Progress through GB11's ExtPict Extend* ZWJ.
data Pict = NoPict | InPict | PictZWJ
deriving (Eq)
-- | Indic conjunct: a consonant followed by extends and linkers.
data Conjunct = NoConjunct | Consonant !Bool -- ^ a linker has been seen
deriving (Eq)
start :: Word16 -> State
start = step (State 0 NoPict NoConjunct)
step :: State -> Word16 -> State
step (State regional pict conjunct) p =
State
{ stRegional = if gcb p == gcbRI then regional + 1 else 0
, stPict = pict'
, stConjunct = conjunct'
}
where
pict'
| isPictographic p = InPict
| pict == InPict && gcb p == gcbExtend = InPict
| pict == InPict && gcb p == gcbZWJ = PictZWJ
| otherwise = NoPict
conjunct' = case conjunct of
_ | incb p == incbConsonant -> Consonant False
Consonant _ | incb p == incbLinker -> Consonant True
Consonant l | incb p == incbExtend -> Consonant l
_ -> NoConjunct
-- | Whether the next character continues the current cluster. Rule
-- numbers are UAX #29's, Unicode 16.0.
joins :: State -> Word16 -> Word16 -> Bool
joins st prevP nextP
| prev == gcbCR && next == gcbLF = True -- GB3
| prev `elem` [gcbControl, gcbCR, gcbLF] = False -- GB4
| next `elem` [gcbControl, gcbCR, gcbLF] = False -- GB5
| prev == gcbL && next `elem` [gcbL, gcbV, gcbLV, gcbLVT] = True -- GB6
| prev `elem` [gcbLV, gcbV] && next `elem` [gcbV, gcbT] = True -- GB7
| prev `elem` [gcbLVT, gcbT] && next == gcbT = True -- GB8
| next == gcbExtend || next == gcbZWJ = True -- GB9
| next == gcbSpacingMark = True -- GB9a
| prev == gcbPrepend = True -- GB9b
| incb nextP == incbConsonant && stConjunct st == Consonant True = True -- GB9c
| prev == gcbZWJ && isPictographic nextP && stPict st == PictZWJ = True -- GB11
| prev == gcbRI && next == gcbRI = odd (stRegional st) -- GB12, GB13
| otherwise = False -- GB999
where
prev = gcb prevP
next = gcb nextP
-- Width -----------------------------------------------------------------------
codeWidth :: Word16 -> Int
codeWidth p
| isEmojiModifier p = 2
| g `elem` [gcbControl, gcbCR, gcbLF, gcbExtend, gcbZWJ, gcbV, gcbT] = 0
| isWide p = 2
| otherwise = 1
where
g = gcb p
-- | Extend characters with the Emoji property: in Unicode 16.0, exactly
-- the skin tone modifiers U+1F3FB–1F3FF. They join the emoji before them,
-- but are wide on their own.
isEmojiModifier :: Word16 -> Bool
isEmojiModifier p = gcb p == gcbExtend && isEmoji p
-- | VS16 and a skin tone modifier both make an emoji wide (UTS #51), also
-- one that is narrow on its own, such as ☝. VS15 changes nothing: it asks
-- for text presentation, not for a width.
clusterWidth :: [(Char, Word16)] -> Int
clusterWidth cl = case cl of
[(_, p1), (_, p2)] | gcb p1 == gcbRI && gcb p2 == gcbRI -> 2
(_, p) : rest
| isEmoji p && any (\(c, q) -> c == '\xFE0F' || isEmojiModifier q) rest -> 2
_ -> case filter (> 0) (map (codeWidth . snd) cl) of
w : _ -> w
[] -> 0