packages feed

kb-text-layout 0.1.0.2 → 0.1.2.0

raw patch · 6 files changed

+412/−235 lines, 6 filesdep ~kb-text-shapePVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependency ranges changed: kb-text-shape

API changes (from Hackage documentation)

- KB.Text.Layout.Measure: [graphemeWidths] :: MeasuredSegment -> [(Int, Float)]
+ KB.Text.Layout.Measure: Grapheme :: Int -> Float -> Grapheme
+ KB.Text.Layout.Measure: ShapedSpan :: Style -> Text -> Advances -> Span
+ KB.Text.Layout.Measure: [codepoints] :: Grapheme -> Int
+ KB.Text.Layout.Measure: [graphemes] :: MeasuredSegment -> [Grapheme]
+ KB.Text.Layout.Measure: [hyphenWidth] :: Style -> Float
+ KB.Text.Layout.Measure: [spaceWidth] :: Style -> Float
+ KB.Text.Layout.Measure: charAdvances :: LayoutContext -> Style -> Text -> IO Advances
+ KB.Text.Layout.Measure: data Grapheme
+ KB.Text.Layout.Measure: instance GHC.Classes.Eq KB.Text.Layout.Measure.Grapheme
+ KB.Text.Layout.Measure: instance GHC.Classes.Eq KB.Text.Layout.Measure.MeasuredSegment
+ KB.Text.Layout.Measure: instance GHC.Classes.Eq KB.Text.Layout.Measure.PreparedText
+ KB.Text.Layout.Measure: instance GHC.Show.Show KB.Text.Layout.Measure.Grapheme
+ KB.Text.Layout.Measure: instance GHC.Show.Show KB.Text.Layout.Measure.MeasuredSegment
+ KB.Text.Layout.Measure: instance GHC.Show.Show KB.Text.Layout.Measure.PreparedText
+ KB.Text.Layout.Measure: prepareWithAdvances :: LayoutContext -> Style -> Text -> Advances -> IO PreparedText
+ KB.Text.Layout.Measure: type Advances = Vector Float
- KB.Text.Layout.Measure: MeasuredSegment :: Text -> BreakKind -> Float -> Int -> [(Int, Float)] -> MeasuredSegment
+ KB.Text.Layout.Measure: MeasuredSegment :: Text -> BreakKind -> Float -> Int -> [Grapheme] -> MeasuredSegment
- KB.Text.Layout.Measure: Style :: Int -> Font -> Float -> Float -> Float -> Float -> Style
+ KB.Text.Layout.Measure: Style :: Int -> Font -> Float -> Float -> Float -> Float -> Float -> Float -> Style
- KB.Text.Layout.Measure: [width] :: MeasuredSegment -> Float
+ KB.Text.Layout.Measure: [width] :: Grapheme -> Float

Files

CHANGELOG.md view
@@ -6,6 +6,14 @@ and this project adheres to the [Haskell Package Versioning Policy](https://pvp.haskell.org/). +## 0.1.2.0 - 2026-09-04++- Add `ShapedSpan` for runs with caller-supplied per-codepoint advances.+- Add `prepareWithAdvances`, the single-run shortcut for `ShapedSpan`.+- Export `charAdvances`.+- Replace `MeasuredSegment.graphemeWidths :: [(Int, Float)]` with `graphemes :: [Grapheme]`.+- Fix emergency breaks splitting a shaping cluster or a ligature.+ ## 0.1.0.2 - 2026-08-28  - Keep leading spaces after a hard break and at the start of the text; only fold them after a soft wrap.
kb-text-layout.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:           kb-text-layout-version:        0.1.0.2+version:        0.1.2.0 synopsis:       Multiline text measurement & layout. category:       Text author:         IC Rainbow@@ -277,6 +277,7 @@       base >=4.18 && <5     , demos     , kb-text-layout+    , kb-text-shape     , tasty     , tasty-hunit     , text
src/KB/Text/Layout/Analysis.hs view
@@ -57,8 +57,8 @@ tailorSoftBreaks t soft = merge (filter (not . insideRange) soft) querySplits   where     n = Text.length t-    chars = Vector.fromList (Text.unpack t)-    at i = chars Vector.! i+    codepoints = Vector.fromList (Text.unpack t)+    at i = codepoints Vector.! i     insideRange p =       p >= 2         && p < n
src/KB/Text/Layout/Break.hs view
@@ -22,7 +22,7 @@   , shrinkwrap   ) where -import Data.Foldable (foldl')+import Data.Foldable qualified as Foldable import Data.Maybe (mapMaybe) import Data.Text (Text) import Data.Text qualified as Text@@ -30,7 +30,7 @@ import Data.Vector qualified as Vector  import KB.Text.Layout.Analysis (BreakKind (..))-import KB.Text.Layout.Measure (MeasuredSegment (..), PreparedText (..))+import KB.Text.Layout.Measure (Grapheme (..), MeasuredSegment (..), PreparedText (..))  layoutGreedy :: PreparedText -> Float -> [LineRange] layoutGreedy prepared maxWidth = go (Cursor 0 0)@@ -223,7 +223,7 @@       | otherwise =           let             seg = segs Vector.! i-            segWidth = if g == 0 then seg.width else sum (map snd (dropChars g seg.graphemeWidths))+            segWidth = if g == 0 then seg.width else sum (map (.width) (dropCodepoints g seg.graphemes))             after = Cursor (i + 1) 0             spaceLike =               scanSeg@@ -274,14 +274,14 @@      emergency seg i g acc visible =       let-        ws = dropChars g seg.graphemeWidths+        ws = dropCodepoints g seg.graphemes         counted = countFits acc ws         count           | counted == 0 && visible <= epsilon = 1           | otherwise = counted         taken = take count ws-        g' = g + sum (map fst taken)-        takenWidth = sum (map snd taken)+        g' = g + sum (map (.codepoints) taken)+        takenWidth = sum (map (.width) taken)       in         if count == 0 then           (emit (Cursor i g) acc Overflowed, resume (Cursor i g))@@ -292,8 +292,8 @@       where         fits n w = \case           [] -> n-          (_, cw) : rest-            | w + cw <= maxWidth + epsilon -> fits (n + 1) (w + cw) rest+          g : rest+            | w + g.width <= maxWidth + epsilon -> fits (n + 1) (w + g.width) rest             | otherwise -> n      advanceCursor seg i g'@@ -336,9 +336,9 @@               | startG == 0 && endG == Text.length seg.text =                   seg.width               | otherwise =-                  sum . map snd $-                    takeChars (endG - startG) $-                      dropChars startG seg.graphemeWidths+                  sum . map (.width) $+                    takeCodepoints (endG - startG) $+                      dropCodepoints startG seg.graphemes           in             case seg.kind of               SoftHyphen -> Nothing@@ -402,7 +402,7 @@               _ -> Just (Text.take (endG - startG) (Text.drop startG seg.text))  layoutStats :: [LineRange] -> LayoutStats-layoutStats = foldl' step LayoutStats{lineCount = 0, maxLineWidth = 0}+layoutStats = Foldable.foldl' step LayoutStats{lineCount = 0, maxLineWidth = 0}   where     step stats line =       LayoutStats@@ -447,16 +447,16 @@       where         mid = (lo + hi) / 2 -dropChars :: Int -> [(Int, Float)] -> [(Int, Float)]-dropChars n xs+dropCodepoints :: Int -> [Grapheme] -> [Grapheme]+dropCodepoints n xs   | n <= 0 = xs   | otherwise = case xs of       [] -> []-      (len, _) : rest -> dropChars (n - len) rest+      Grapheme{codepoints} : rest -> dropCodepoints (n - codepoints) rest -takeChars :: Int -> [(Int, Float)] -> [(Int, Float)]-takeChars n xs+takeCodepoints :: Int -> [Grapheme] -> [Grapheme]+takeCodepoints n xs   | n <= 0 = []   | otherwise = case xs of       [] -> []-      x@(len, _) : rest -> x : takeChars (n - len) rest+      g@Grapheme{codepoints} : rest -> g : takeCodepoints (n - codepoints) rest
src/KB/Text/Layout/Measure.hs view
@@ -6,13 +6,17 @@      -- * Preparing text for layout   , prepare+  , prepareWithAdvances   , prepareStyled   , PreparedText (..)   , MeasuredSegment (..)+  , Grapheme (..)   , Span (..)+  , Advances      -- * Measuring   , measure+  , charAdvances      -- * Styles   , newStyle@@ -24,9 +28,9 @@ import Data.Map.Strict qualified as Map import Data.Text (Text) import Data.Text qualified as Text-import Data.Traversable (mapAccumL) import Data.Vector (Vector) import Data.Vector qualified as Vector+import Data.Vector.Storable qualified as Storable import KB.Text.Layout.Analysis (BreakKind (..), Segment (..), analyze) import KB.Text.Layout.Segmentation qualified as Segmentation import KB.Text.Shape qualified as TextShape@@ -35,10 +39,13 @@ prepare :: LayoutContext -> Style -> Text -> IO PreparedText prepare ctx style t = prepareStyled ctx [TextSpan style t] +prepareWithAdvances :: LayoutContext -> Style -> Text -> Advances -> IO PreparedText+prepareWithAdvances ctx style t advance = prepareStyled ctx [ShapedSpan style t advance]+ prepareStyled :: LayoutContext -> [Span] -> IO PreparedText prepareStyled ctx spans = do   segments <- Vector.fromList . concat <$> traverse spanSegments spans-  pure PreparedText{segments}+  pure $! PreparedText{segments}   where     spanSegments = \case       AtomSpan style label w ->@@ -48,63 +55,95 @@               , kind = Atomic               , width = w               , style = style.key-              , graphemeWidths = [(Text.length label, w)]+              , graphemes = [Grapheme{codepoints = Text.length label, width = w}]               }           ]-      TextSpan style t -> do-        advance <- charAdvances ctx style t-        spaceWidth <- measure ctx style " "-        hyphenWidth <- measure ctx style "-"-        let-          prefix = Vector.scanl' (+) 0 advance-          slice off n = prefix Vector.! (off + n) - prefix Vector.! off-          clusterWidths off = \case-            [] -> []-            c : rest ->-              let k = Text.length c-              in (k, slice off k) : clusterWidths (off + k) rest-          segmentAt off seg =-            let-              n = Text.length seg.text-              width = case seg.kind of-                SoftHyphen -> hyphenWidth-                ZeroWidthBreak -> 0-                HardBreak -> 0-                Tab -> fromIntegral n * spaceWidth-                _ -> slice off n-              graphemeWidths = case seg.kind of-                Word -> clusterWidths off (Segmentation.clusters seg.text)-                Glue -> clusterWidths off (Segmentation.clusters seg.text)-                _ -> [(n, width)]-            in-              ( off + n-              , MeasuredSegment{text = seg.text, kind = seg.kind, width, style = style.key, graphemeWidths}-              )-        pure (snd (mapAccumL segmentAt 0 (analyze t)))+      TextSpan style t ->+        textSegments style t <$> charAdvances ctx style t+      ShapedSpan style t advance+        | Storable.length advance /= Text.length t ->+            error $+              "ShapedSpan: expected one advance per codepoint, got "+                <> show (Storable.length advance)+                <> " advances for "+                <> show (Text.length t)+                <> " codepoints"+        | otherwise ->+            pure (textSegments style t advance) +textSegments :: Style -> Text -> Advances -> [MeasuredSegment]+textSegments style t advance = go 0 (analyze t)+  where+    prefix = Storable.scanl' (+) 0 advance+    slice off n = prefix Storable.! (off + n) - prefix Storable.! off+    clusterWidths off = \case+      [] -> []+      c : rest ->+        let (n, rest') = absorbZeroWidth (Text.length c) rest+        in Grapheme{codepoints = n, width = slice off n} : clusterWidths (off + n) rest'+      where+        absorbZeroWidth n = \case+          c : rest | slice (off + n) (Text.length c) == 0 -> absorbZeroWidth (n + Text.length c) rest+          rest -> (n, rest)+    go !off = \case+      [] -> []+      seg : rest ->+        let !measured = segmentAt off seg+        in measured : go (off + Text.length seg.text) rest+    segmentAt off seg =+      MeasuredSegment+        { text = seg.text+        , kind = seg.kind+        , width+        , style = style.key+        , graphemes+        }+      where+        n = Text.length seg.text+        width = case seg.kind of+          SoftHyphen -> style.hyphenWidth+          ZeroWidthBreak -> 0+          HardBreak -> 0+          Tab -> fromIntegral n * style.spaceWidth+          _ -> slice off n+        graphemes+          | seg.kind == Word || seg.kind == Glue = clusterWidths off (Segmentation.clusters seg.text)+          | otherwise = [Grapheme{codepoints = n, width}]+ data PreparedText = PreparedText   { segments :: Vector MeasuredSegment   }+  deriving stock (Eq, Show)  data MeasuredSegment = MeasuredSegment   { text :: Text   , kind :: BreakKind   , width :: Float   , style :: Int-  , graphemeWidths :: [(Int, Float)]+  , graphemes :: [Grapheme]   }+  deriving stock (Eq, Show) +data Grapheme = Grapheme+  { codepoints :: Int+  , width :: Float+  }+  deriving stock (Eq, Show)+ data Span   = TextSpan Style Text+  | ShapedSpan Style Text Advances   | AtomSpan Style Text Float -charAdvances :: LayoutContext -> Style -> Text -> IO (Vector Float)+type Advances = Storable.Vector Float++charAdvances :: LayoutContext -> Style -> Text -> IO Advances charAdvances ctx style t-  | Text.null t = pure Vector.empty+  | Text.null t = pure Storable.empty   | otherwise = do       runs <- TextShape.run ctx.shape (TextShape.withFont_ style.font (TextShape.text_ t))       contributions <- concat <$> traverse runContributions runs-      pure (Vector.map (* style.scale) (Vector.accum (+) (Vector.replicate n 0) contributions))+      pure (Storable.map (* style.scale) (Storable.accum (+) (Storable.replicate n 0) contributions))   where     n = Text.length t     runContributions (run, glyphs) = do@@ -124,27 +163,38 @@       Map.lookup (style.key, t) <$> readIORef ctx.metrics >>= \case         Just w -> pure w         Nothing -> do-          runs <- TextShape.run ctx.shape (TextShape.withFont_ style.font (TextShape.text_ t))-          let runWidth (run, glyphs) = do-                info <- Font.getFontInfo run.font-                let toGrid = style.grid / fromIntegral info.unitsPerEm-                pure (sum [fromIntegral g.advanceX | g <- glyphs] * toGrid)-          w <- (* style.scale) . sum <$> traverse runWidth runs+          w <- shapedWidth ctx style.font style.grid style.scale t           modifyIORef' ctx.metrics (Map.insert (style.key, t) w)           pure w +shapedWidth :: LayoutContext -> Font.Font -> Float -> Float -> Text -> IO Float+shapedWidth ctx font grid scale t = do+  runs <- TextShape.run ctx.shape (TextShape.withFont_ font (TextShape.text_ t))+  let runWidth (run, glyphs) = do+        info <- Font.getFontInfo run.font+        let toGrid = grid / fromIntegral info.unitsPerEm+        pure (sum [fromIntegral g.advanceX | g <- glyphs] * toGrid)+  (* scale) . sum <$> traverse runWidth runs+ newStyle :: LayoutContext -> Font.Font -> Float -> IO Style newStyle ctx font size = do   info <- Font.getFontInfo font   key <- atomicModifyIORef' ctx.styleKeys \n -> (n + 1, n)+  let+    scale = size / fromIntegral info.capitalHeight+    grid = fromIntegral info.unitsPerEm+  spaceWidth <- shapedWidth ctx font grid scale " "+  hyphenWidth <- shapedWidth ctx font grid scale "-"   pure     Style       { key       , font       , size       , em = size * Font.emToCaps info-      , scale = size / fromIntegral info.capitalHeight-      , grid = fromIntegral info.unitsPerEm+      , scale+      , grid+      , spaceWidth+      , hyphenWidth       }  data Style = Style@@ -154,6 +204,8 @@   , em :: Float   , scale :: Float   , grid :: Float+  , spaceWidth :: Float+  , hyphenWidth :: Float   }  createLayoutContext :: TextShape.Context -> IO LayoutContext
test/Spec.hs view
@@ -1,183 +1,299 @@ module Main (main) where -import Data.Foldable (for_)+import Control.Exception (ErrorCall, try)+import Data.Foldable (for_, toList) import Data.Text (Text) import Data.Text qualified as Text import Data.Vector qualified as Vector-import Test.Tasty (defaultMain, testGroup)-import Test.Tasty.HUnit (testCase, (@?=))+import Data.Vector.Storable qualified as Storable+import Test.Tasty (TestTree, defaultMain, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))  import KB.Text.Layout.Analysis (BreakKind (..)) import KB.Text.Layout.Analysis qualified as Analysis import KB.Text.Layout.Break (Cursor (..), LineEnd (..), LineStretch (..)) import KB.Text.Layout.Break qualified as Break import KB.Text.Layout.Html qualified as Html-import KB.Text.Layout.Measure (MeasuredSegment (..), PreparedText (..))+import KB.Text.Layout.Measure (Grapheme (..), LayoutContext, MeasuredSegment (..), PreparedText (..), Span (..), Style)+import KB.Text.Layout.Measure qualified as Measure import KB.Text.Layout.Segmentation qualified as Segmentation+import KB.Text.Shape qualified as TextShape  main :: IO () main =-  defaultMain $-    testGroup-      "kb-text-layout"-      [ testCase "single line" do-          (Break.layoutStats (Break.layoutGreedy (fromSegments prepared) 200)).lineCount @?= 1-      , testCase "break at space" do-          (Break.layoutStats (Break.layoutGreedy (fromSegments prepared) 100)).lineCount @?= 2-      , testCase "emergency graphemes" do-          materialized prepared 30 @?= ["hel", "lo", "wor", "ld"]-      , testCase "soft hyphen" do-          materialized hyphenated 30 @?= ["hy-", "phen"]-      , testCase "soft hyphen too wide to use" do-          materialized wideHyphen 30 @?= ["hyphen"]-      , testCase "hard break" do-          materialized broken 1000 @?= ["one", "two"]-      , testCase "glue holds" do-          materialized glued 60 @?= ["aaa\xA0\&bb", "b"]-      , testCase "atomic breaks before" do-          materialized [word 50 "word", atom 40 "[pill]"] 60 @?= ["word", "[pill]"]-      , testCase "atomic breaks after" do-          materialized [atom 40 "[pill]", word 50 "word"] 60 @?= ["[pill]", "word"]-      , testCase "atomic never splits" do-          materialized [word 30 "abc", space, atom 100 "[wide-pill]"] 60-            @?= ["abc", "[wide-pill]"]-      , testCase "stepping matches walk" do-          stepped prepared (replicate 10 30) @?= materialized prepared 30-      , testCase "variable widths route lines" do-          stepped rhythm [70, 30] @?= ["aaa bbb", "ccc"]-      , testCase "narrower route reflows" do-          stepped rhythm [40, 40, 40] @?= ["aaa", "bbb", "ccc"]-      , testCase "slices merge same-style runs" do-          let prep = fromSegments styled-          map (\s -> (s.text, s.style)) (concatMap (Break.materializeSlices prep) (Break.layoutGreedy prep 200))-            @?= [("aaa bbb ", 1), ("ccc", 2)]-      , testCase "slices mark atoms with widths" do-          let prep = fromSegments [styledWord 1 30 "hi", styledSpace 1, atom 40 "[pill]"]-          map (\s -> (s.text, s.atom, s.width)) (concatMap (Break.materializeSlices prep) (Break.layoutGreedy prep 200))-            @?= [("hi ", False, 40), ("[pill]", True, 40)]-      , testCase "slices absorb the soft hyphen" do-          let prep = fromSegments hyphenated-          map (\s -> (s.text, s.width)) (concatMap (Break.materializeSlices prep) (take 1 (Break.layoutGreedy prep 30)))-            @?= [("hy-", 25)]-      , testCase "slices concat to materialized text" do-          for_ [prepared, hyphenated, broken, glued, rhythm, styled] \xs ->-            for_ [25, 30, 60, 200] \w -> do-              let prep = fromSegments xs-              for_ (Break.layoutGreedy prep w) \line ->-                Text.concat (map (\s -> s.text) (Break.materializeSlices prep line))-                  @?= Break.materializeLineRange prep line-      , testCase "emergency breaks respect clusters" do-          let accented =-                [ MeasuredSegment-                    { text = "a\x301\&bc"-                    , kind = Word-                    , width = 30-                    , style = 0-                    , graphemeWidths = [(2, 10), (1, 10), (1, 10)]-                    }-                ]-          materialized accented 10 @?= ["a\x301", "b", "c"]-          materialized accented 20 @?= ["a\x301\&b", "c"]-      , testCase "kbts clusters marks, zwj sequences, and flags" do-          Segmentation.clusters "a\x301\&bc" @?= ["a\x301", "b", "c"]-          Segmentation.clusters "\128104\8205\128105\8205\128103 ok"-            @?= ["\128104\8205\128105\8205\128103", " ", "o", "k"]-          Segmentation.clusters "\127482\127462\127482\127462" @?= ["\127482\127462", "\127482\127462"]-      , testCase "kbts break positions arrive as Char offsets" do-          Segmentation.boundaries "" @?= []-          Segmentation.boundaries "\128512\&a" @?= [0, 1, 2]-          Segmentation.boundaries "a\128104\8205\128105\8205\128103b" @?= [0, 1, 6, 7]-          Segmentation.softBreaks "\26085\26412\35486 ok" @?= [1, 2, 4, 6]-          Segmentation.softBreaks "go \128105\127997\8205\128640 now" @?= [3, 8, 11]-          Segmentation.wordBreaks "one two" @?= [0, 3, 4, 7]-      , testCase "kbts soft breaks split words at line break opportunities" do-          map (\s -> (s.text, s.kind)) (Analysis.analyze "well-known")-            @?= [("well-", Word), ("", ZeroWidthBreak), ("known", Word)]-          map (\s -> s.text) (Analysis.analyze "\26085\26412\12290\35486")-            @?= ["\26085", "", "\26412\12290", "", "\35486"]-          map (\s -> s.text) (Analysis.analyze "ab\128104\8205\128105\8205\128103\&cd")-            @?= ["ab\128104\8205\128105\8205\128103", "", "cd"]-          map (\s -> (s.text, s.kind)) (Analysis.analyze "plain words")-            @?= [("plain", Word), (" ", Space), ("words", Word)]-      , testCase "tailored breaks split url queries and hold dash ranges" do-          map (\s -> s.text) (Analysis.analyze "see example.com/a?b=1&c#d now")-            @?= ["see", " ", "example.com/", "", "a?", "", "b", "", "=1", "", "&c", "", "#d", " ", "now"]-          map (\s -> s.text) (Analysis.analyze "pages 3\8211\&5 and 2+2=4 at AT&T")-            @?= ["pages", " ", "3\8211\&5", " ", "and", " ", "2+2=4", " ", "at", " ", "AT&T"]-          map (\s -> s.text) (Analysis.analyze "10:30-11:00") @?= ["10:30-11:00"]-      , testCase "line ends carry their reason" do-          let ends xs w = map (\l -> l.ended) (Break.layoutGreedy (fromSegments xs) w)-          ends prepared 30 @?= [Overflowed, Wrapped, Overflowed, Finished]-          ends hyphenated 30 @?= [Hyphenated, Finished]-          ends broken 1000 @?= [HardBroken, Finished]-          ends rhythm 70 @?= [Wrapped, Finished]-      , testCase "line stretch counts interior spaces only" do-          let-            prep = fromSegments rhythm-            stretches w = map (Break.lineStretch prep) (Break.layoutGreedy prep w)-          stretches 200 @?= [LineStretch{spaces = 2, width = 20}]-          stretches 70 @?= [LineStretch{spaces = 1, width = 10}, LineStretch{spaces = 0, width = 0}]-          map (Break.lineStretch (fromSegments indented)) (Break.layoutGreedy (fromSegments indented) 200)-            @?= [LineStretch{spaces = 0, width = 0}, LineStretch{spaces = 0, width = 0}, LineStretch{spaces = 0, width = 0}]-      , testCase "leading spaces survive hard breaks" do-          let-            prep = fromSegments indented-            lns = Break.layoutGreedy prep 200-          map (Break.materializeLineRange prep) lns @?= ["main", "do", "  putStrLn"]-          map (\l -> (l.from, l.width)) lns @?= [(Cursor 0 0, 40), (Cursor 2 0, 20), (Cursor 4 0, 100)]-          map (Break.materializeLineRange prep) (Break.layoutOptimal prep 200) @?= ["main", "do", "  putStrLn"]-          materialized [space, word 40 "word"] 200 @?= [" word"]-          materialized [indent, word 80 "putStrLn"] 90 @?= ["  putStrL", "n"]-          stepped indented [200, 200, 200] @?= ["main", "do", "  putStrLn"]-      , testCase "leading spaces still fold after a wrap" do-          let prep = fromSegments [word 30 "aaa", indent, word 30 "bbb"]-          materialized [word 30 "aaa", indent, word 30 "bbb"] 40 @?= ["aaa", "bbb"]-          map (Break.materializeLineRange prep) (Break.layoutOptimal prep 40) @?= ["aaa", "bbb"]-      , testCase "shrinkwrap tightens without adding lines" do-          Break.shrinkwrap (fromSegments rhythm) 80 @?= 70-          Break.shrinkwrap (fromSegments rhythm) 200 @?= 110-          Break.shrinkwrap (fromSegments prepared) 45 @?= 30-      , testCase "optimal layout beats greedy on loose middle lines" do-          let-            squeeze = [word 60 "aaaaaa", space, word 10 "b", space, word 10 "c", space, word 60 "dddddd"]-            prep = fromSegments squeeze-          materialized squeeze 77 @?= ["aaaaaa", "b c", "dddddd"]-          map (Break.materializeLineRange prep) (Break.layoutOptimal prep 77) @?= ["aaaaaa b", "c dddddd"]-      , testCase "optimal layout agrees with greedy where greedy is fine" do-          let optimal xs w = map (Break.materializeLineRange (fromSegments xs)) (Break.layoutOptimal (fromSegments xs) w)-          optimal rhythm 70 @?= ["aaa bbb", "ccc"]-          optimal hyphenated 30 @?= ["hy-", "phen"]-          optimal broken 1000 @?= ["one", "two"]-      , testCase "optimal layout falls back to greedy when infeasible" do-          map (Break.materializeLineRange (fromSegments glued)) (Break.layoutOptimal (fromSegments glued) 60)-            @?= materialized glued 60-      , testCase "html renderer escapes and anchors baselines" do-          let-            prep = fromSegments [styledWord 1 30 "a<b", styledSpace 1, styledWord 1 30 "cd"]-            opts = Html.Options{width = 40, lineHeight = 20, unit = 1, baseCap = 1, justify = False, baseCss = "font:16px serif", styleCss = const "font:16px serif"}-            html = Html.render opts prep (Break.layoutGreedy prep 40)-          Text.isInfixOf "a&lt;b" html @?= True-          Text.isInfixOf "top:0.00px" html @?= True-          Text.isInfixOf "top:20.00px" html @?= True-          Text.isInfixOf "height:21.00px" html @?= True-          Text.isInfixOf "text-box-trim:trim-both" html @?= True-      , testCase "html renderer scales layout units" do-          let-            prep = fromSegments [styledWord 1 30 "ab", styledSpace 1, styledWord 1 30 "cd"]-            opts = Html.Options{width = 40, lineHeight = 20, unit = 2, baseCap = 1, justify = False, baseCss = "", styleCss = const ""}-            html = Html.render opts prep (Break.layoutGreedy prep 40)-          Text.isInfixOf "width:80.00px" html @?= True-          Text.isInfixOf "top:40.00px" html @?= True-          Text.isInfixOf "height:42.00px" html @?= True-      , testCase "html renderer justifies wrapped lines only" do-          let-            prep = fromSegments rhythm-            opts = Html.Options{width = 80, lineHeight = 20, unit = 1, baseCap = 1, justify = True, baseCss = "", styleCss = const ""}-            html = Html.render opts prep (Break.layoutGreedy prep 80)-          Text.count "word-spacing" html @?= 1-          Text.isInfixOf "word-spacing:10.00px" html @?= True-      ]+  TextShape.withContext \shape -> do+    font <- TextShape.pushFontFromFile shape "demos/assets/NotoSans-Regular.ttf" 0+    ctx <- Measure.createLayoutContext shape+    body <- Measure.newStyle ctx font 1.0+    accent <- Measure.newStyle ctx font 1.5+    defaultMain $+      testGroup+        "kb-text-layout"+        [ layoutTests+        , prepareTests ctx body accent+        ] +layoutTests :: TestTree+layoutTests =+  testGroup+    "layout"+    [ testCase "single line" do+        (Break.layoutStats (Break.layoutGreedy (fromSegments prepared) 200)).lineCount @?= 1+    , testCase "break at space" do+        (Break.layoutStats (Break.layoutGreedy (fromSegments prepared) 100)).lineCount @?= 2+    , testCase "emergency graphemes" do+        materialized prepared 30 @?= ["hel", "lo", "wor", "ld"]+    , testCase "soft hyphen" do+        materialized hyphenated 30 @?= ["hy-", "phen"]+    , testCase "soft hyphen too wide to use" do+        materialized wideHyphen 30 @?= ["hyphen"]+    , testCase "hard break" do+        materialized broken 1000 @?= ["one", "two"]+    , testCase "glue holds" do+        materialized glued 60 @?= ["aaa\xA0\&bb", "b"]+    , testCase "atomic breaks before" do+        materialized [word 50 "word", atom 40 "[pill]"] 60 @?= ["word", "[pill]"]+    , testCase "atomic breaks after" do+        materialized [atom 40 "[pill]", word 50 "word"] 60 @?= ["[pill]", "word"]+    , testCase "atomic never splits" do+        materialized [word 30 "abc", space, atom 100 "[wide-pill]"] 60+          @?= ["abc", "[wide-pill]"]+    , testCase "stepping matches walk" do+        stepped prepared (replicate 10 30) @?= materialized prepared 30+    , testCase "variable widths route lines" do+        stepped rhythm [70, 30] @?= ["aaa bbb", "ccc"]+    , testCase "narrower route reflows" do+        stepped rhythm [40, 40, 40] @?= ["aaa", "bbb", "ccc"]+    , testCase "slices merge same-style runs" do+        let prep = fromSegments styled+        map (\s -> (s.text, s.style)) (concatMap (Break.materializeSlices prep) (Break.layoutGreedy prep 200))+          @?= [("aaa bbb ", 1), ("ccc", 2)]+    , testCase "slices mark atoms with widths" do+        let prep = fromSegments [styledWord 1 30 "hi", styledSpace 1, atom 40 "[pill]"]+        map (\s -> (s.text, s.atom, s.width)) (concatMap (Break.materializeSlices prep) (Break.layoutGreedy prep 200))+          @?= [("hi ", False, 40), ("[pill]", True, 40)]+    , testCase "slices absorb the soft hyphen" do+        let prep = fromSegments hyphenated+        map (\s -> (s.text, s.width)) (concatMap (Break.materializeSlices prep) (take 1 (Break.layoutGreedy prep 30)))+          @?= [("hy-", 25)]+    , testCase "slices concat to materialized text" do+        for_ [prepared, hyphenated, broken, glued, rhythm, styled] \xs ->+          for_ [25, 30, 60, 200] \w -> do+            let prep = fromSegments xs+            for_ (Break.layoutGreedy prep w) \line ->+              Text.concat (map (\s -> s.text) (Break.materializeSlices prep line))+                @?= Break.materializeLineRange prep line+    , testCase "emergency breaks respect clusters" do+        let accented =+              [ MeasuredSegment+                  { text = "a\x301\&bc"+                  , kind = Word+                  , width = 30+                  , style = 0+                  , graphemes = [Grapheme 2 10, Grapheme 1 10, Grapheme 1 10]+                  }+              ]+        materialized accented 10 @?= ["a\x301", "b", "c"]+        materialized accented 20 @?= ["a\x301\&b", "c"]+    , testCase "kbts clusters marks, zwj sequences, and flags" do+        Segmentation.clusters "a\x301\&bc" @?= ["a\x301", "b", "c"]+        Segmentation.clusters "\128104\8205\128105\8205\128103 ok"+          @?= ["\128104\8205\128105\8205\128103", " ", "o", "k"]+        Segmentation.clusters "\127482\127462\127482\127462" @?= ["\127482\127462", "\127482\127462"]+    , testCase "kbts break positions arrive as Char offsets" do+        Segmentation.boundaries "" @?= []+        Segmentation.boundaries "\128512\&a" @?= [0, 1, 2]+        Segmentation.boundaries "a\128104\8205\128105\8205\128103b" @?= [0, 1, 6, 7]+        Segmentation.softBreaks "\26085\26412\35486 ok" @?= [1, 2, 4, 6]+        Segmentation.softBreaks "go \128105\127997\8205\128640 now" @?= [3, 8, 11]+        Segmentation.wordBreaks "one two" @?= [0, 3, 4, 7]+    , testCase "kbts soft breaks split words at line break opportunities" do+        map (\s -> (s.text, s.kind)) (Analysis.analyze "well-known")+          @?= [("well-", Word), ("", ZeroWidthBreak), ("known", Word)]+        map (\s -> s.text) (Analysis.analyze "\26085\26412\12290\35486")+          @?= ["\26085", "", "\26412\12290", "", "\35486"]+        map (\s -> s.text) (Analysis.analyze "ab\128104\8205\128105\8205\128103\&cd")+          @?= ["ab\128104\8205\128105\8205\128103", "", "cd"]+        map (\s -> (s.text, s.kind)) (Analysis.analyze "plain words")+          @?= [("plain", Word), (" ", Space), ("words", Word)]+    , testCase "tailored breaks split url queries and hold dash ranges" do+        map (\s -> s.text) (Analysis.analyze "see example.com/a?b=1&c#d now")+          @?= ["see", " ", "example.com/", "", "a?", "", "b", "", "=1", "", "&c", "", "#d", " ", "now"]+        map (\s -> s.text) (Analysis.analyze "pages 3\8211\&5 and 2+2=4 at AT&T")+          @?= ["pages", " ", "3\8211\&5", " ", "and", " ", "2+2=4", " ", "at", " ", "AT&T"]+        map (\s -> s.text) (Analysis.analyze "10:30-11:00") @?= ["10:30-11:00"]+    , testCase "line ends carry their reason" do+        let ends xs w = map (\l -> l.ended) (Break.layoutGreedy (fromSegments xs) w)+        ends prepared 30 @?= [Overflowed, Wrapped, Overflowed, Finished]+        ends hyphenated 30 @?= [Hyphenated, Finished]+        ends broken 1000 @?= [HardBroken, Finished]+        ends rhythm 70 @?= [Wrapped, Finished]+    , testCase "line stretch counts interior spaces only" do+        let+          prep = fromSegments rhythm+          stretches w = map (Break.lineStretch prep) (Break.layoutGreedy prep w)+        stretches 200 @?= [LineStretch{spaces = 2, width = 20}]+        stretches 70 @?= [LineStretch{spaces = 1, width = 10}, LineStretch{spaces = 0, width = 0}]+        map (Break.lineStretch (fromSegments indented)) (Break.layoutGreedy (fromSegments indented) 200)+          @?= [LineStretch{spaces = 0, width = 0}, LineStretch{spaces = 0, width = 0}, LineStretch{spaces = 0, width = 0}]+    , testCase "leading spaces survive hard breaks" do+        let+          prep = fromSegments indented+          lns = Break.layoutGreedy prep 200+        map (Break.materializeLineRange prep) lns @?= ["main", "do", "  putStrLn"]+        map (\l -> (l.from, l.width)) lns @?= [(Cursor 0 0, 40), (Cursor 2 0, 20), (Cursor 4 0, 100)]+        map (Break.materializeLineRange prep) (Break.layoutOptimal prep 200) @?= ["main", "do", "  putStrLn"]+        materialized [space, word 40 "word"] 200 @?= [" word"]+        materialized [indent, word 80 "putStrLn"] 90 @?= ["  putStrL", "n"]+        stepped indented [200, 200, 200] @?= ["main", "do", "  putStrLn"]+    , testCase "leading spaces still fold after a wrap" do+        let prep = fromSegments [word 30 "aaa", indent, word 30 "bbb"]+        materialized [word 30 "aaa", indent, word 30 "bbb"] 40 @?= ["aaa", "bbb"]+        map (Break.materializeLineRange prep) (Break.layoutOptimal prep 40) @?= ["aaa", "bbb"]+    , testCase "shrinkwrap tightens without adding lines" do+        Break.shrinkwrap (fromSegments rhythm) 80 @?= 70+        Break.shrinkwrap (fromSegments rhythm) 200 @?= 110+        Break.shrinkwrap (fromSegments prepared) 45 @?= 30+    , testCase "optimal layout beats greedy on loose middle lines" do+        let+          squeeze = [word 60 "aaaaaa", space, word 10 "b", space, word 10 "c", space, word 60 "dddddd"]+          prep = fromSegments squeeze+        materialized squeeze 77 @?= ["aaaaaa", "b c", "dddddd"]+        map (Break.materializeLineRange prep) (Break.layoutOptimal prep 77) @?= ["aaaaaa b", "c dddddd"]+    , testCase "optimal layout agrees with greedy where greedy is fine" do+        let optimal xs w = map (Break.materializeLineRange (fromSegments xs)) (Break.layoutOptimal (fromSegments xs) w)+        optimal rhythm 70 @?= ["aaa bbb", "ccc"]+        optimal hyphenated 30 @?= ["hy-", "phen"]+        optimal broken 1000 @?= ["one", "two"]+    , testCase "optimal layout falls back to greedy when infeasible" do+        map (Break.materializeLineRange (fromSegments glued)) (Break.layoutOptimal (fromSegments glued) 60)+          @?= materialized glued 60+    , testCase "html renderer escapes and anchors baselines" do+        let+          prep = fromSegments [styledWord 1 30 "a<b", styledSpace 1, styledWord 1 30 "cd"]+          opts = Html.Options{width = 40, lineHeight = 20, unit = 1, baseCap = 1, justify = False, baseCss = "font:16px serif", styleCss = const "font:16px serif"}+          html = Html.render opts prep (Break.layoutGreedy prep 40)+        Text.isInfixOf "a&lt;b" html @?= True+        Text.isInfixOf "top:0.00px" html @?= True+        Text.isInfixOf "top:20.00px" html @?= True+        Text.isInfixOf "height:21.00px" html @?= True+        Text.isInfixOf "text-box-trim:trim-both" html @?= True+    , testCase "html renderer scales layout units" do+        let+          prep = fromSegments [styledWord 1 30 "ab", styledSpace 1, styledWord 1 30 "cd"]+          opts = Html.Options{width = 40, lineHeight = 20, unit = 2, baseCap = 1, justify = False, baseCss = "", styleCss = const ""}+          html = Html.render opts prep (Break.layoutGreedy prep 40)+        Text.isInfixOf "width:80.00px" html @?= True+        Text.isInfixOf "top:40.00px" html @?= True+        Text.isInfixOf "height:42.00px" html @?= True+    , testCase "html renderer justifies wrapped lines only" do+        let+          prep = fromSegments rhythm+          opts = Html.Options{width = 80, lineHeight = 20, unit = 1, baseCap = 1, justify = True, baseCss = "", styleCss = const ""}+          html = Html.render opts prep (Break.layoutGreedy prep 80)+        Text.count "word-spacing" html @?= 1+        Text.isInfixOf "word-spacing:10.00px" html @?= True+    ]++prepareTests :: LayoutContext -> Style -> Style -> TestTree+prepareTests ctx body accent =+  testGroup+    "prepare"+    [ testCase "segments follow analysis and carry the style key" do+        prep <- Measure.prepare ctx body "hello world"+        map (\s -> (s.text, s.kind, s.style)) (toList prep.segments)+          @?= [("hello", Word, body.key), (" ", Space, body.key), ("world", Word, body.key)]+    , testCase "empty text prepares to nothing" do+        prep <- Measure.prepare ctx body ""+        prep.segments @?= Vector.empty+        advances <- Measure.charAdvances ctx body ""+        advances @?= Storable.empty+    , testCase "widths add up to the shaped advances" do+        let t = "The quick brown fox"+        prep <- Measure.prepare ctx body t+        advances <- Measure.charAdvances ctx body t+        Storable.length advances @?= Text.length t+        assertBool "advances are positive" (Storable.all (> 0) advances)+        assertClose (Storable.sum advances) (sum (map (\s -> s.width) (toList prep.segments)))+        for_ prep.segments \s -> do+          sum (map (.codepoints) s.graphemes) @?= Text.length s.text+          assertClose s.width (sum (map (.width) s.graphemes))+    , testCase "prepareWithAdvances agrees with prepare" do+        let t = "shaped \xAD\&once,\tthen reused"+        prep <- Measure.prepare ctx body t+        reused <- Measure.prepareWithAdvances ctx body t =<< Measure.charAdvances ctx body t+        reused @?= prep+    , testCase "prepareWithAdvances rejects a codepoint/advance count mismatch" do+        attempt <- try (Measure.prepareWithAdvances ctx body "abc" (Storable.fromList [1, 2]))+        case attempt of+          Left (_ :: ErrorCall) -> pure ()+          Right prep -> assertFailure ("accepted " <> show prep)+    , testCase "custom advances flow into grapheme widths" do+        prep <- Measure.prepareWithAdvances ctx body "a\x301\&b c" (Storable.fromList [1, 0.5, 2, 4, 8])+        map (\s -> (s.text, s.width, s.graphemes)) (toList prep.segments)+          @?= [("a\x301\&b", 3.5, [Grapheme 2 1.5, Grapheme 1 2]), (" ", 4, [Grapheme 1 4]), ("c", 8, [Grapheme 1 8])]+    , testCase "zero-advance codepoints fold into the preceding cluster" do+        prep <- Measure.prepareWithAdvances ctx body "<=>ff \x301\&ab" (Storable.fromList [5, 0, 0, 3, 0, 4, 0, 1, 0])+        map (\s -> (s.text, s.graphemes)) (toList prep.segments)+          @?= [("<=>ff", [Grapheme 3 5, Grapheme 2 3]), (" ", [Grapheme 1 4]), ("\x301\&ab", [Grapheme 1 0, Grapheme 2 1])]+    , testCase "emergency breaks never split a shaped cluster" do+        prep <- Measure.prepareWithAdvances ctx body "<=>xy" (Storable.fromList [5, 0, 0, 2, 2])+        let lines' maxWidth = map (Break.materializeLineRange prep) (Break.layoutGreedy prep maxWidth)+        lines' 3 @?= ["<=>", "x", "y"]+        lines' 6 @?= ["<=>", "xy"]+        for_ (Break.layoutGreedy prep 3) \line ->+          for_ [line.from, line.to] \cursor ->+            assertBool ("cursor inside a cluster: " <> show cursor) (cursor.grapheme `elem` [0, 3, 4, 5])+    , testCase "font ligatures shape as one cluster and overflow whole" do+        advances <- Measure.charAdvances ctx body "ffi"+        Storable.toList (Storable.drop 1 advances) @?= [0, 0]+        prep <- Measure.prepare ctx body "ffi"+        map (.graphemes) (toList prep.segments) @?= [[Grapheme 3 (Storable.head advances)]]+        let narrow = Break.layoutGreedy prep (Storable.head advances / 2)+        map (Break.materializeLineRange prep) narrow @?= ["ffi"]+        map (.ended) narrow @?= [Overflowed]+    , testCase "soft hyphens and tabs take their widths from the style" do+        spaceWidth <- Measure.measure ctx body " "+        hyphenWidth <- Measure.measure ctx body "-"+        body.spaceWidth @?= spaceWidth+        body.hyphenWidth @?= hyphenWidth+        assertBool "space is not empty" (spaceWidth > 0)+        prep <- Measure.prepareWithAdvances ctx body "a\xAD\&b\t\tc\n" (Storable.replicate 7 1)+        map (\s -> (s.text, s.kind, s.width)) (toList prep.segments)+          @?= [ ("a", Word, 1)+              , ("\xAD", SoftHyphen, hyphenWidth)+              , ("b", Word, 1)+              , ("\t\t", Tab, 2 * spaceWidth)+              , ("c", Word, 1)+              , ("\n", HardBreak, 0)+              ]+    , testCase "styles scale their metrics" do+        assertClose accent.spaceWidth (1.5 * body.spaceWidth)+        assertClose accent.hyphenWidth (1.5 * body.hyphenWidth)+        assertBool "styles get distinct keys" (accent.key /= body.key)+    , testCase "prepareStyled mixes text, shaped, and atom spans" do+        prep <-+          Measure.prepareStyled+            ctx+            [ TextSpan body "ab "+            , AtomSpan accent "[pill]" 7+            , ShapedSpan accent " cd" (Storable.fromList [1, 2, 3])+            ]+        map (\s -> (s.text, s.kind, s.style)) (toList prep.segments)+          @?= [ ("ab", Word, body.key)+              , (" ", Space, body.key)+              , ("[pill]", Atomic, accent.key)+              , (" ", Space, accent.key)+              , ("cd", Word, accent.key)+              ]+        map (\s -> (s.width, s.graphemes)) (drop 2 (toList prep.segments))+          @?= [(7, [Grapheme 6 7]), (1, [Grapheme 1 1]), (5, [Grapheme 1 2, Grapheme 1 3])]+    ]++assertClose :: Float -> Float -> IO ()+assertClose expected actual =+  assertBool (show actual <> " is not close to " <> show expected) (abs (expected - actual) < 1e-3)+ materialized :: [MeasuredSegment] -> Float -> [Text] materialized xs maxWidth =   map (Break.materializeLineRange prep) (Break.layoutGreedy prep maxWidth)@@ -209,19 +325,19 @@ word = styledWord 0  styledWord :: Int -> Float -> Text -> MeasuredSegment-styledWord sk w t = MeasuredSegment{text = t, kind = Word, width = w, style = sk, graphemeWidths = graphemes}+styledWord sk w t = MeasuredSegment{text = t, kind = Word, width = w, style = sk, graphemes}   where     n = Text.length t-    graphemes = replicate n (1, w / fromIntegral n)+    graphemes = replicate n (Grapheme 1 (w / fromIntegral n))  styledSpace :: Int -> MeasuredSegment-styledSpace sk = MeasuredSegment{text = " ", kind = Space, width = 10, style = sk, graphemeWidths = [(1, 10)]}+styledSpace sk = MeasuredSegment{text = " ", kind = Space, width = 10, style = sk, graphemes = [Grapheme 1 10]}  seg :: BreakKind -> Float -> Text -> MeasuredSegment-seg kind w t = MeasuredSegment{text = t, kind, width = w, style = 0, graphemeWidths = [(Text.length t, w)]}+seg kind w t = MeasuredSegment{text = t, kind, width = w, style = 0, graphemes = [Grapheme (Text.length t) w]}  atom :: Float -> Text -> MeasuredSegment-atom w label = MeasuredSegment{text = label, kind = Atomic, width = w, style = 0, graphemeWidths = [(Text.length label, w)]}+atom w label = MeasuredSegment{text = label, kind = Atomic, width = w, style = 0, graphemes = [Grapheme (Text.length label) w]}  space, indent :: MeasuredSegment space = seg Space 10 " "