kb-text-shape-0.2.1.0: src/KB/Text/Shape/Segmentation.hs
{-| Unicode segmentation over the kbts entire-string API.
All positions are UTF-8 byte offsets into the input 'Text'.
-}
module KB.Text.Shape.Segmentation
( Break (..)
, breaks
, breaksWith
-- * Grapheme clusters
, clusters
, boundaries
-- * Line break opportunities
, softBreaks
, softBreaksWith
-- * Word boundaries
, wordBreaks
) where
import Data.Bits ((.&.))
import Data.Char (ord)
import Data.IntSet qualified as IntSet
import Data.List (sort)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Foreign qualified as Text
import Foreign (alloca, allocaArray, fillBytes, nullPtr, peek, peekArray, sizeOf)
import System.IO.Unsafe (unsafePerformIO)
import KB.Text.Shape.FFI.API.Segmentation qualified as Segmentation
import KB.Text.Shape.FFI.Enums (JapaneseLineBreakStyle, pattern DIRECTION_DONT_KNOW, pattern JAPANESE_LINE_BREAK_STYLE_NORMAL)
import KB.Text.Shape.FFI.Flags (BreakFlags, pattern BREAK_FLAG_GRAPHEME, pattern BREAK_FLAG_LINE_SOFT, pattern BREAK_FLAG_WORD)
import KB.Text.Shape.FFI.Structs (Break (..))
{- | Extended grapheme cluster texts, in order.
The engine skips UAX #29 GB11, so 'boundaries' refuses to cut adjacent
to a ZWJ; emoji ZWJ sequences stay whole.
-}
clusters :: Text -> [Text]
clusters t = go t 0 (boundaries t)
where
go rest from cuts = case dropWhile (<= from) cuts of
cut : more ->
let n = fromIntegral (cut - from)
in Text.takeWord8 n rest : go (Text.dropWord8 n rest) cut more
[] -> [rest | not (Text.null rest)]
-- | Extended grapheme cluster boundaries.
boundaries :: Text -> [Int]
boundaries t = zwjFilter t (positions BREAK_FLAG_GRAPHEME (breaks t))
{- | Positions where a line may break, at 'JAPANESE_LINE_BREAK_STYLE_NORMAL'.
The engine skips UAX #14 LB8a, so positions adjacent to a ZWJ are dropped.
-}
softBreaks :: Text -> [Int]
softBreaks = softBreaksWith JAPANESE_LINE_BREAK_STYLE_NORMAL
-- | 'softBreaks' with an explicit kinsoku strictness.
softBreaksWith :: JapaneseLineBreakStyle -> Text -> [Int]
softBreaksWith style t = zwjFilter t (positions BREAK_FLAG_LINE_SOFT (breaksWith style t))
-- | UAX #29 word boundaries.
wordBreaks :: Text -> [Int]
wordBreaks = positions BREAK_FLAG_WORD . breaks
positions :: BreakFlags -> [Break] -> [Int]
positions flag found = dedupe (sort [b.position | b <- found, b.flags .&. flag /= mempty])
where
dedupe = \case
x : rest@(y : _)
| x == y -> dedupe rest
| otherwise -> x : dedupe rest
rest -> rest
zwjFilter :: Text -> [Int] -> [Int]
zwjFilter t
| IntSet.null zwjs = id
| otherwise = filter safe
where
zwjs = IntSet.fromList (zwjOffsets 0 (Text.unpack t))
zwjOffsets off = \case
[] -> []
c : rest -> [off | c == '\x200D'] <> zwjOffsets (off + utf8Length c) rest
total = Text.lengthWord8 t
safe b = b <= 0 || b >= total || not (IntSet.member (b - 3) zwjs || IntSet.member b zwjs)
utf8Length :: Char -> Int
utf8Length c
| o < 0x80 = 1
| o < 0x800 = 2
| o < 0x10000 = 3
| otherwise = 4
where
o = ord c
-- | All breaks at 'JAPANESE_LINE_BREAK_STYLE_NORMAL'.
breaks :: Text -> [Break]
breaks = breaksWith JAPANESE_LINE_BREAK_STYLE_NORMAL
{- | 'breaks' with an explicit kinsoku strictness, in one engine pass.
The entire-string API merges the flags of same-position breaks into one
record; the 2.24 write-cursor caveat on
'Segmentation.kbts_BreakEntireString' is compensated by zeroing the output
buffer and dropping the records with empty flags.
Delayed break emissions rely on the @LOCAL PATCH(break_position_skew)@
in @cbits\/kb_text_shape.inc@: unpatched, positions of breaks pending on
lookahead skew away from the fed byte increments in both directions (see
@upstream-fixme\/break_position_skew.c@).
-}
breaksWith :: JapaneseLineBreakStyle -> Text -> [Break]
breaksWith style t
| Text.null t = []
| otherwise = unsafePerformIO $
Text.withCStringLen t \(utf8Ptr, len) ->
attempt utf8Ptr len (4 * len + 16)
where
attempt utf8Ptr len capacity =
allocaArray capacity \breaksPtr ->
alloca \breakCountPtr -> do
fillBytes breaksPtr 0 (capacity * sizeOf (undefined :: Break))
Segmentation.kbts_BreakEntireStringUtf8
DIRECTION_DONT_KNOW
style
mempty
utf8Ptr
(fromIntegral len)
breaksPtr
(fromIntegral capacity)
breakCountPtr
nullPtr
0
nullPtr
count <- fromIntegral <$> peek breakCountPtr
if count > capacity
then
attempt utf8Ptr len count
else
filter (\b -> b.flags /= mempty) <$> peekArray count breaksPtr
{-# NOINLINE breaksWith #-}