packages feed

kb-text-layout-0.1.2.0: src/KB/Text/Layout/Measure.hs

module KB.Text.Layout.Measure
  ( -- * Layout context
    createLayoutContext
  , clearCache
  , LayoutContext

    -- * Preparing text for layout
  , prepare
  , prepareWithAdvances
  , prepareStyled
  , PreparedText (..)
  , MeasuredSegment (..)
  , Grapheme (..)
  , Span (..)
  , Advances

    -- * Measuring
  , measure
  , charAdvances

    -- * Styles
  , newStyle
  , Style (..)
  ) where

import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as Text
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
import KB.Text.Shape.Font qualified as Font

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}
  where
    spanSegments = \case
      AtomSpan style label w ->
        pure
          [ MeasuredSegment
              { text = label
              , kind = Atomic
              , width = w
              , style = style.key
              , graphemes = [Grapheme{codepoints = Text.length label, width = w}]
              }
          ]
      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
  , 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

type Advances = Storable.Vector Float

charAdvances :: LayoutContext -> Style -> Text -> IO Advances
charAdvances ctx style t
  | 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 (Storable.map (* style.scale) (Storable.accum (+) (Storable.replicate n 0) contributions))
  where
    n = Text.length t
    runContributions (run, glyphs) = do
      info <- Font.getFontInfo run.font
      let toGrid = style.grid / fromIntegral info.unitsPerEm
      pure
        [ (g.codepointIndex, fromIntegral g.advanceX * toGrid)
        | g <- glyphs
        , g.codepointIndex >= 0
        , g.codepointIndex < n
        ]

measure :: LayoutContext -> Style -> Text -> IO Float
measure ctx style t
  | Text.null t = pure 0
  | otherwise =
      Map.lookup (style.key, t) <$> readIORef ctx.metrics >>= \case
        Just w -> pure w
        Nothing -> do
          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
      , grid
      , spaceWidth
      , hyphenWidth
      }

data Style = Style
  { key :: Int
  , font :: Font.Font
  , size :: Float
  , em :: Float
  , scale :: Float
  , grid :: Float
  , spaceWidth :: Float
  , hyphenWidth :: Float
  }

createLayoutContext :: TextShape.Context -> IO LayoutContext
createLayoutContext shape = do
  metrics <- newIORef Map.empty
  styleKeys <- newIORef 0
  pure LayoutContext{shape, metrics, styleKeys}

clearCache :: LayoutContext -> IO ()
clearCache ctx = writeIORef ctx.metrics Map.empty

data LayoutContext = LayoutContext
  { shape :: TextShape.Context
  , metrics :: IORef (Map (Int, Text) Float)
  , styleKeys :: IORef Int
  }