packages feed

kb-text-shape 0.2.0.0 → 0.2.1.0

raw patch · 9 files changed

+345/−65 lines, 9 filesdep +filepathdep +optparse-applicativenew-component:exe:ttf2kbtsPVP ok

version bump matches the API change (PVP)

Dependencies added: filepath, optparse-applicative

API changes (from Hackage documentation)

+ KB.Text.Shape.Segmentation: Break :: Int -> BreakFlags -> Direction -> Direction -> Script -> Break
+ KB.Text.Shape.Segmentation: [direction] :: Break -> Direction
+ KB.Text.Shape.Segmentation: [flags] :: Break -> BreakFlags
+ KB.Text.Shape.Segmentation: [paragraphDirection] :: Break -> Direction
+ KB.Text.Shape.Segmentation: [position] :: Break -> Int
+ KB.Text.Shape.Segmentation: [script] :: Break -> Script
+ KB.Text.Shape.Segmentation: boundaries :: Text -> [Int]
+ KB.Text.Shape.Segmentation: breaks :: Text -> [Break]
+ KB.Text.Shape.Segmentation: breaksWith :: JapaneseLineBreakStyle -> Text -> [Break]
+ KB.Text.Shape.Segmentation: clusters :: Text -> [Text]
+ KB.Text.Shape.Segmentation: data Break
+ KB.Text.Shape.Segmentation: softBreaks :: Text -> [Int]
+ KB.Text.Shape.Segmentation: softBreaksWith :: JapaneseLineBreakStyle -> Text -> [Int]
+ KB.Text.Shape.Segmentation: wordBreaks :: Text -> [Int]

Files

CHANGELOG.md view
@@ -6,6 +6,23 @@ and this project adheres to the [Haskell Package Versioning Policy](https://pvp.haskell.org/). +## 0.2.1.0 - 2026-08-15++Added:+- `KB.Text.Shape.Segmentation` higher-level wrapper.+- `ttf2kbts` mini-app to extract kbts blobs.++Fixed in upstream with local patches:+- `kbts_BreakEntireString*` no longer leaves unwritten holes in the output+  array or over-reports `*BreakCount` when breaks merge into a written position.+- Breaks flushed after look-ahead now use the fed position increments, so+  byte positions from `kbts_BreakEntireStringUtf8` are exact around+  multi-byte codepoints and combining marks.+- `kbts_ShapePopFeature` reports the removal and no longer corrupts the+  feature stack when popping a non-topmost override.+- `Font.getFontInfo` decodes name strings leniently, so fonts with non-UTF-8+  name records (e.g. Mac Roman) load instead of failing.+ ## 0.2.0.0 - 2026-07-28  Updated `kb_text_shape` to 2.24.
+ app/ttf2kbts/Main.hs view
@@ -0,0 +1,53 @@+module Main (main) where++import Data.ByteString qualified as ByteString+import Options.Applicative+import System.FilePath (replaceExtension)++import KB.Text.Shape.Font qualified as Font++main :: IO ()+main = do+  Options{..} <- execParser optionsInfo+  ttfData <- ByteString.readFile inputPath+  blobData <- Font.extractBlob ttfData fontIndex+  ByteString.writeFile (resolveOutput inputPath outputPath) blobData++data Options = Options+  { inputPath :: FilePath+  , outputPath :: Maybe FilePath+  , fontIndex :: Int+  }++resolveOutput :: FilePath -> Maybe FilePath -> FilePath+resolveOutput inputPath = \case+  Just explicit -> explicit+  Nothing -> replaceExtension inputPath "kbts"++optionsInfo :: ParserInfo Options+optionsInfo = info (helper <*> optionsParser) $ mconcat+  [ fullDesc+  , progDesc "Extract a pre-processed .kbts blob from a font file"+  ]++optionsParser :: Parser Options+optionsParser = do+  inputPath <- strArgument $ mconcat+    [ metavar "FONT"+    , help "Input font file (TTF/OTF)"+    ]+  outputPath <- optional . strOption $ mconcat+    [ long "output"+    , short 'o'+    , metavar "FILE"+    , help "Output .kbts file (default: FONT with the extension replaced by .kbts)"+    ]+  fontIndex <- option auto $ mconcat+    [ long "index"+    , short 'i'+    , metavar "N"+    , value 0+    , showDefault+    , help "Font index inside a collection"+    ]+  pure Options{..}
bench/Bench.hs view
@@ -5,7 +5,7 @@ import Prelude hiding (id)  import Control.Exception (bracket)-import Control.Monad (foldM, when)+import Control.Monad (when) import Data.ByteString (ByteString) import Data.ByteString qualified as ByteString import Data.ByteString.Unsafe qualified as ByteString@@ -22,6 +22,7 @@  import KB.Text.Shape qualified as TextShape import KB.Text.Shape.Font qualified as Font+import KB.Text.Shape.Segmentation qualified as TextSegmentation  main :: IO () main = do@@ -52,13 +53,13 @@               , bench "long text" $ whnfIO $ shapeVia ctx longText               ]           , bgroup "direct shaping"-              [ bench "cold oneshot" $ whnfIO $ oneshot font testCodepoints+              [ bench "cold oneshot" $ whnfIO $ oneshot font testText               , bcompare "$NF == \"cold oneshot\"" $-                bench "warm scratchpad" $ whnfIO $ warmShot font scratchpad testCodepoints+                bench "warm scratchpad" $ whnfIO $ warmShot font scratchpad testText               ]           , bgroup "segmentation"               [ bench "BreakEntireStringUtf8" $ whnfIO $ breakUtf8 longUtf8-              , bench "incremental breaks" $ whnfIO $ breakIncremental longString+              , bench "Segmentation.breaks" $ whnf (length . TextSegmentation.breaks) longText               ]           , bgroup "glyph mapping"               [ bench "kbts_CodepointToGlyphId" $ whnfIO $@@ -75,15 +76,9 @@ testText :: Text testText = "Hello, ሰላም።, שלמלך, नमस्ते world!" -testCodepoints :: [Char]-testCodepoints = Text.unpack testText- longText :: Text longText = Text.replicate 64 testText -longString :: [Char]-longString = Text.unpack longText- longUtf8 :: ByteString longUtf8 = Text.encodeUtf8 longText @@ -92,21 +87,21 @@ shapeVia ctx text = length . show <$> TextShape.run ctx (TextShape.text_ text)  -- | Direct shaping with the full per-call setup: config, scratchpad, storage.-oneshot :: Handles.Font -> [Char] -> IO Int-oneshot font codepoints =+oneshot :: Handles.Font -> Text -> IO Int+oneshot font text =   withShapeConfig font \config ->     withScratchpad config \scratchpad ->       -- Glyphs keep the config pointer, it has to outlive the shaping.       withGlyphConfig config \glyphConfig ->         withGlyphStorage \storagePtr -> do-          pushCodepoints font storagePtr glyphConfig codepoints+          pushCodepoints font storagePtr glyphConfig (Text.unpack text)           drainShape scratchpad storagePtr  -- | Direct shaping reusing a pre-built config and scratchpad.-warmShot :: Handles.Font -> Handles.ShapeScratchpad -> [Char] -> IO Int-warmShot font scratchpad codepoints =+warmShot :: Handles.Font -> Handles.ShapeScratchpad -> Text -> IO Int+warmShot font scratchpad text =   withGlyphStorage \storagePtr -> do-    pushCodepoints font storagePtr (Handles.GlyphConfig nullPtr) codepoints+    pushCodepoints font storagePtr (Handles.GlyphConfig nullPtr) (Text.unpack text)     drainShape scratchpad storagePtr  drainShape :: Handles.ShapeScratchpad -> Ptr Structs.GlyphStorage -> IO Int@@ -139,21 +134,6 @@               (fromIntegral len)               flagCountPtr             fromIntegral <$> peek breakCountPtr--breakIncremental :: [Char] -> IO Int-breakIncremental text =-  allocaBytes Handles.sizeOfBreakState \raw -> do-    fillBytes raw 0 Handles.sizeOfBreakState-    let state = Handles.BreakState (castPtr raw)-    Segmentation.kbts_BreakBegin state Enums.DIRECTION_DONT_KNOW Enums.JAPANESE_LINE_BREAK_STYLE_NORMAL mempty-    alloca \breakPtr -> do-      let drain !n = do-            more <- Segmentation.kbts_Break state breakPtr-            if more /= 0 then drain (n + 1) else pure n-          feed !n (c, isLast) = do-            Segmentation.kbts_BreakAddCodepoint state (fromIntegral (ord c)) 1 (if isLast then 1 else 0)-            drain n-      foldM feed (0 :: Int) $ zip text (replicate (length text - 1) False <> [True])  -- * Fixtures 
cbits/kb_text_shape.inc view
@@ -20819,12 +20819,15 @@        if(Override->Tag == Tag)       {+        // LOCAL PATCH(pop_feature_returns_zero): shift with MoveIndex (the loop used to+        // copy one slot onto itself, corrupting non-topmost pops) and report the removal.         KBTS__FOR(MoveIndex, ScratchFeatureOverrideIndex, Context->ScratchFeatureOverrideCount)         {-          Context->ScratchFeatureOverrides[ScratchFeatureOverrideIndex - 1] = Context->ScratchFeatureOverrides[ScratchFeatureOverrideIndex];+          Context->ScratchFeatureOverrides[MoveIndex - 1] = Context->ScratchFeatureOverrides[MoveIndex];         }          Context->ScratchFeatureOverrideCount -= 1;+        Result = 1;         break;       }     }@@ -23894,10 +23897,14 @@     {       kbts_u64 EffectiveLineBreaks = LineBreaks & ~(LineUnbreaks | LineUnbreaksAsync); -      kbts__DoLineBreak(State, PositionOffset3 + LineBreak3PositionOffset, EffectiveLineBreaks >> 48);+      // LOCAL PATCH(break_position_skew): LineBreak2/3PositionOffset are now full offsets+      // from CurrentPosition (like ScriptPositionOffset), accumulating the actual+      // PositionIncrements across absorbed characters instead of being added to+      // PositionOffset2/3, which only track the last two fed codepoints.+      kbts__DoLineBreak(State, LineBreak3PositionOffset, EffectiveLineBreaks >> 48);       if(EndOfText)       {-        kbts__DoLineBreak(State, PositionOffset2 + LineBreak2PositionOffset, EffectiveLineBreaks >> 32);+        kbts__DoLineBreak(State, LineBreak2PositionOffset, EffectiveLineBreaks >> 32);         { // @Cleanup: This is the same flag code as DoLineBreak, but we want to use FlagState buffering for this.           // The only places where we want to manually call DoBreak is for asynchronous/buffered guys.           kbts_u8 FlushedLineFlags = 0;@@ -23912,8 +23919,9 @@      State->LineBreaks = LineBreaks;     State->LineUnbreaks = LineUnbreaks;-    State->LineBreak2PositionOffset = 0;-    State->LineBreak3PositionOffset = LineBreak2PositionOffset;+    // LOCAL PATCH(break_position_skew): keep full offsets in step with the fed increments.+    State->LineBreak2PositionOffset = (kbts_s16)-(int)PositionIncrement;+    State->LineBreak3PositionOffset = (kbts_s16)(LineBreak2PositionOffset - (int)PositionIncrement);     State->LastLineBreakClass = LineBreakClass;     State->LineBreakHistory = LineBreakHistory; @@ -24231,7 +24239,9 @@        KBTS_C2(WSS, WSS):         // WSS x WSS is a special rule, because it is supposed to happen _before_ ignores.-        if(WordBreak2PositionOffset >= 0) WordUnbreaks |= KBTS_WORD_BREAK_BITS(0, 1);+        // LOCAL PATCH(break_position_skew): "no ignores since the last word step" is now+        // "the full offset still equals the last fed increment".+        if(WordBreak2PositionOffset == PositionOffset2) WordUnbreaks |= KBTS_WORD_BREAK_BITS(0, 1);         break;        // (RI RI)* RI x RI@@ -24267,7 +24277,8 @@     kbts_u32 EffectiveWordBreaks = WordBreaks & ~WordUnbreaks;     if(EffectiveWordBreaks & KBTS_WORD_BREAK_BITS(2, 2))     {-      kbts__DoBreak(State, PositionOffset2 + WordBreak2PositionOffset, KBTS_BREAK_FLAG_WORD, 0, 0, 0);+      // LOCAL PATCH(break_position_skew): WordBreak2PositionOffset is a full offset now.+      kbts__DoBreak(State, WordBreak2PositionOffset, KBTS_BREAK_FLAG_WORD, 0, 0, 0);     }     if(EndOfText)     {@@ -24279,7 +24290,8 @@     State->WordBreaks = (kbts_u16)WordBreaks;     State->WordUnbreaks = (kbts_u16)WordUnbreaks;     State->LastWordBreakClass = WordBreakClass;-    State->WordBreak2PositionOffset = 0;+    // LOCAL PATCH(break_position_skew): keep the full offset in step with the fed increment.+    State->WordBreak2PositionOffset = (kbts_s16)-(int)PositionIncrement;     State->WordBreakHistory = WordBreakHistory;   }   State->LastWordBreakClassIncludingIgnored = WordBreakClass;@@ -24568,9 +24580,14 @@     kbts_break Break;     while(kbts_Break(&BreakState, &Break))     {+      // LOCAL PATCH(break_entire_string_holes): only advance the write cursor when a+      // record was actually written; merged breaks used to leave unwritten holes and+      // over-report *BreakCount. With a null Breaks, Found stays 0 and the count keeps+      // its old upper-bound semantics for two-pass sizing.+      int Found = 0;+       if(Breaks && (BreaksWritten < BreakCapacity))       {-        int Found = 0;         kbts_un ExistingBreakCount = KBTS__MIN(BreaksWritten, BreakCapacity);          // @Speed: Binary search!@@ -24613,7 +24630,10 @@        MaxBreakPosition = KBTS__MAX(MaxBreakPosition, (kbts_u32)Break.Position); -      BreaksWritten += 1;+      if(!Found)+      {+        BreaksWritten += 1;+      }     }   } 
kb-text-shape.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:           kb-text-shape-version:        0.2.0.0+version:        0.2.1.0 synopsis:       Unicode segmentation and shaping using kb_text_shape category:       Font homepage:       https://github.com/dpwiz/kb-text-shape#readme@@ -49,6 +49,7 @@       KB.Text.Shape.FFI.Iterators       KB.Text.Shape.FFI.Structs       KB.Text.Shape.Font+      KB.Text.Shape.Segmentation   other-modules:       Paths_kb_text_shape   autogen-modules:@@ -65,7 +66,6 @@       LambdaCase       NamedFieldPuns       NoFieldSelectors-      NoFieldSelectors       OverloadedRecordDot       OverloadedStrings       PatternSynonyms@@ -87,6 +87,40 @@   if flag(trace-blocks)     cc-options: -DTRACE_BLOCKS +executable ttf2kbts+  main-is: Main.hs+  other-modules:+      Paths_kb_text_shape+  autogen-modules:+      Paths_kb_text_shape+  hs-source-dirs:+      app/ttf2kbts+  default-extensions:+      BlockArguments+      DataKinds+      DerivingStrategies+      DuplicateRecordFields+      GeneralizedNewtypeDeriving+      ImplicitParams+      LambdaCase+      NamedFieldPuns+      NoFieldSelectors+      OverloadedRecordDot+      OverloadedStrings+      PatternSynonyms+      RecordWildCards+      StrictData+      ViewPatterns+      ApplicativeDo+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-home-modules -Wredundant-constraints+  build-depends:+      base >=4.16 && <5+    , bytestring+    , filepath+    , kb-text-shape+    , optparse-applicative+  default-language: GHC2021+ test-suite kb-text-shape-test   type: exitcode-stdio-1.0   main-is: Spec.hs@@ -106,7 +140,6 @@       LambdaCase       NamedFieldPuns       NoFieldSelectors-      NoFieldSelectors       OverloadedRecordDot       OverloadedStrings       PatternSynonyms@@ -142,7 +175,6 @@       ImplicitParams       LambdaCase       NamedFieldPuns-      NoFieldSelectors       NoFieldSelectors       OverloadedRecordDot       OverloadedStrings
src/KB/Text/Shape.hs view
@@ -51,6 +51,7 @@ import GHC.Records (HasField(..))  import KB.Text.Shape.FFI.API.Context qualified as ShapeContext+import KB.Text.Shape.Font qualified as Font import KB.Text.Shape.FFI.Enums qualified as Enums import KB.Text.Shape.FFI.Flags qualified as Flags import KB.Text.Shape.FFI.Handles qualified as Handles@@ -102,6 +103,25 @@   when (err /= Enums.SHAPE_ERROR_NONE) $       error $ "kbts_ShapePushFontFromFile: failed to load font. " <> show err   _ <- keepFont ctx font mempty -- register the empty blob so the counters would look nicer+  validateFont ("kbts_ShapePushFontFromFile: " <> path) font++{- | Reject fonts that failed to load or whose metrics would poison layout downstream.++The loader hands back a null handle without setting the shape error when the+file is missing, unreadable, or not a font.+A zero 'KB.Text.Shape.Font.Info.unitsPerEm' means the font did not parse;+a missing capital height (OS/2 @sCapHeight@) breaks cap-normalized sizing+with no sensible fallback.+-}+validateFont :: String -> Handles.Font -> IO Handles.Font+validateFont origin font = do+  when (font == Handles.Font nullPtr) $+    error $ origin <> ": failed to load font"+  info <- Font.getFontInfo font+  when (info.unitsPerEm == 0) $+    error $ origin <> ": font reports zero unitsPerEm"+  when (info.capitalHeight <= 0) $+    error $ origin <> ": font has no capital height (OS/2 sCapHeight)"   pure font  pushFontFromMemory :: Context -> ByteString -> Int -> IO Handles.Font@@ -112,7 +132,7 @@     when (err /= Enums.SHAPE_ERROR_NONE) $         error $ "kbts_ShapePushFontFromMemory: failed to load font. " <> show err     _ <- keepFont ctx font fontData-    pure font+    validateFont "kbts_ShapePushFontFromMemory" font  keepFont :: Context -> Handles.Font -> ByteString -> IO Int keepFont ctx font bytes = atomicModifyIORef' ctx.fonts $ swap . IntMap.alterF addRef (Handles.intHandle font)@@ -277,6 +297,11 @@   , uid :: Word16    , codepointIndex :: Int -- was: userIdOrCodepointIndex+    {- ^ Index of the source codepoint this glyph maps to, in codepoints,+    relative to the whole shaped input (not the run). Several glyphs may+    share an index (marks), and a ligature carries the index of its first+    codepoint, leaving the rest without glyphs.+    -}    , offsetX :: Int     {- ^ This, and the next few are in the "font units".
src/KB/Text/Shape/Font.hs view
@@ -32,7 +32,8 @@ import Data.ByteString.Unsafe qualified as ByteString import Data.Maybe (catMaybes) import Data.Text (Text)-import Data.Text.Foreign qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Text.Encoding.Error (lenientDecode)  import KB.Text.Shape.FFI.API.Direct qualified as ShapeDirect import KB.Text.Shape.FFI.Flags qualified as Flags@@ -92,7 +93,7 @@   where     loadStrings (ix, ptr) = \case       0 -> pure Nothing-      len -> Just . (Enums.FontInfoStringId ix,) <$> Text.peekCStringLen (ptr, fromIntegral len)+      len -> Just . (Enums.FontInfoStringId ix,) . Text.decodeUtf8With lenientDecode <$> ByteString.unsafePackCStringLen (ptr, fromIntegral len)  data Info = Info   { strings :: [(Enums.FontInfoStringId, Text)]
+ src/KB/Text/Shape/Segmentation.hs view
@@ -0,0 +1,145 @@+{-| 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 #-}
test/Spec.hs view
@@ -33,6 +33,7 @@  import KB.Text.Shape qualified as TextShape import KB.Text.Shape.Font qualified as Font+import KB.Text.Shape.Segmentation qualified as TextSegmentation  testFontTtf :: FilePath testFontTtf = "test/Ubuntu-R.ttf"@@ -265,7 +266,7 @@           TextShape.popFeature_ Enums.FEATURE_TAG_liga >>= writeIORef popped         -- XXX: upstream kbts_ShapePopFeature loses the found-and-removed         -- result, it is always 0 as of 2.24.-        readIORef popped >>= (@?= 0)+        readIORef popped >>= (@?= 1)    , testCase "shaped codepoints can be retrieved by index" $       withFontContext ttfData \ctx _font -> do@@ -357,7 +358,7 @@           alloca @Structs.GlyphStorage \storagePtr -> do             ok <- ShapeDirect.kbts_InitializeGlyphStorageFixedMemory storagePtr (castPtr mem) (fromIntegral size)             assertBool "storage initialized" (ok /= 0)-            forM_ ("abc" :: String) \c -> do+            forM_ (Text.unpack "abc") \c -> do               glyphPtr <- ShapeDirect.kbts_PushGlyph storagePtr font (fromIntegral (ord c)) nullGlyphConfig 0               assertBool "glyph pushed" (glyphPtr /= nullPtr)             glyphs <- shapeStorage scratchpad storagePtr@@ -402,7 +403,7 @@   , testCase "clearing active glyphs empties the iterator" $       withShapeSetup ttfData \font _config _scratchpad ->         withGlyphStorage \storagePtr -> do-          forM_ ("abc" :: String) \c ->+          forM_ (Text.unpack "abc") \c ->             void $ ShapeDirect.kbts_PushGlyph storagePtr font (fromIntegral (ord c)) nullGlyphConfig 0           alloca \itPtr -> do             ShapeDirect.hs_ActiveGlyphIterator storagePtr itPtr@@ -457,6 +458,11 @@       -- real record, splitting the position across two records.       mergeBreaks incremental @?= mergeBreaks whole +  , testCase "Segmentation.breaks reports UTF-8 byte positions" $ do+      let text = "øne two\nthree fõur"+      whole <- breakEntireUtf8 text+      mergeBreaks (TextSegmentation.breaks text) @?= mergeBreaks whole+   , testCase "BreakEntireStringUtf8 agrees with UTF-32 on ASCII" $ do       let text = "one two\nthree four"       utf32 <- breakEntireUtf32 text@@ -495,13 +501,13 @@       coverage "汉字" >>= (@?= False)   ]   where-    coverage :: String -> IO Bool+    coverage :: Text -> IO Bool     coverage text =       withFont ttfData \font ->         alloca @Structs.FontCoverageTest \testPtr -> do           fillBytes testPtr 0 $ sizeOf (undefined :: Structs.FontCoverageTest)           Other.kbts_FontCoverageTestBegin testPtr font-          forM_ text \c ->+          forM_ (Text.unpack text) \c ->             Other.kbts_FontCoverageTestCodepoint testPtr (fromIntegral (ord c))           covered <- Other.kbts_FontCoverageTestEnd testPtr           pure $ covered /= 0@@ -599,10 +605,10 @@   [x] -> pure x   other -> assertFailure $ "expected one run, got " <> show (length other) -directShape :: Handles.Font -> Handles.ShapeScratchpad -> Handles.GlyphConfig -> String -> IO [TextShape.Glyph]+directShape :: Handles.Font -> Handles.ShapeScratchpad -> Handles.GlyphConfig -> Text -> IO [TextShape.Glyph] directShape font scratchpad glyphConfig text =   withGlyphStorage \storagePtr -> do-    forM_ text \c ->+    forM_ (Text.unpack text) \c ->       ShapeDirect.kbts_PushGlyph storagePtr font (fromIntegral (ord c)) glyphConfig 0     shapeStorage scratchpad storagePtr @@ -622,9 +628,9 @@       Segmentation.kbts_GuessTextPropertiesUtf8 ptr (fromIntegral len) directionPtr scriptPtr       (,) <$> peek directionPtr <*> peek scriptPtr -guessUtf32 :: String -> IO (Enums.Direction, Enums.Script)+guessUtf32 :: Text -> IO (Enums.Direction, Enums.Script) guessUtf32 text =-  withArrayLen (map (fromIntegral . ord) text :: [CInt]) \len ptr ->+  withArrayLen (map (fromIntegral . ord) (Text.unpack text) :: [CInt]) \len ptr ->     alloca \directionPtr -> alloca \scriptPtr -> do       poke directionPtr Enums.DIRECTION_DONT_KNOW       poke scriptPtr Enums.SCRIPT_DONT_KNOW@@ -635,9 +641,9 @@ hasBreakFlag flags flag = flags .&. flag == flag  -- | Collect breaks with 1-per-codepoint positions and a generous output capacity.-breakEntireUtf32 :: String -> IO [Structs.Break]+breakEntireUtf32 :: Text -> IO [Structs.Break] breakEntireUtf32 text =-  withArrayLen (map (fromIntegral . ord) text :: [CInt]) \len codepointsPtr ->+  withArrayLen (map (fromIntegral . ord) (Text.unpack text) :: [CInt]) \len codepointsPtr ->     withBreakBuffers len \breaksPtr breakCountPtr flagsPtr flagCountPtr -> do       Segmentation.kbts_BreakEntireStringUtf32         Enums.DIRECTION_DONT_KNOW@@ -652,9 +658,9 @@         (fromIntegral len)         flagCountPtr -breakEntireUtf8 :: String -> IO [Structs.Break]+breakEntireUtf8 :: Text -> IO [Structs.Break] breakEntireUtf8 text =-  Text.withCStringLen (Text.pack text) \(textPtr, len) ->+  Text.withCStringLen text \(textPtr, len) ->     withBreakBuffers len \breaksPtr breakCountPtr flagsPtr flagCountPtr -> do       Segmentation.kbts_BreakEntireStringUtf8         Enums.DIRECTION_DONT_KNOW@@ -705,7 +711,7 @@         (before, (position, flags) : next) -> before <> ((position, flags <> b.flags) : next)         (_, []) -> acc <> [(b.position, b.flags)] -breakIncremental :: String -> IO [Structs.Break]+breakIncremental :: Text -> IO [Structs.Break] breakIncremental text =   allocaBytes Handles.sizeOfBreakState \raw -> do     fillBytes raw 0 Handles.sizeOfBreakState@@ -722,11 +728,12 @@           feed acc (c, isLast) = do             Segmentation.kbts_BreakAddCodepoint state (fromIntegral (ord c)) 1 (if isLast then 1 else 0)             drain acc-          annotated = zip text $ map (== length text) [1 ..]+          codepoints = Text.unpack text+          annotated = zip codepoints $ map (== length codepoints) [1 ..]       reverse <$> foldM feed [] annotated -fourcc :: String -> Other.ScriptTag-fourcc = \case+fourcc :: Text -> Other.ScriptTag+fourcc tag = case Text.unpack tag of   [a, b, c, d] -> Other.ScriptTag $     fromIntegral (ord a)     .|. fromIntegral (ord b) `shiftL` 8