packages feed

ktx-font (empty) → 0.1.0.0

raw patch · 6 files changed

+560/−0 lines, 6 filesdep +aesondep +basedep +bytestring

Dependencies added: aeson, base, bytestring, containers, kb-text-shape, ktx-codec, msdf-atlas, text, vector, zstd

Files

+ CHANGELOG.md view
@@ -0,0 +1,11 @@+# Changelog for `ktx-font`++All notable changes to this project will be documented in this file.++The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),+and this project adheres to the+[Haskell Package Versioning Policy](https://pvp.haskell.org/).++## 0.1.0.0 - 2025-12-30++Initial release.
+ LICENSE view
@@ -0,0 +1,26 @@+Copyright 2025 IC Rainbow++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1.  Redistributions of source code must retain the above copyright notice, this+    list of conditions and the following disclaimer.++2.  Redistributions in binary form must reproduce the above copyright notice,+    this list of conditions and the following disclaimer in the documentation+    and/or other materials provided with the distribution.++3.  Neither the name of the copyright holder nor the names of its contributors+    may be used to endorse or promote products derived from this software+    without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,62 @@+# ktx-font++A KTX2 format containing a font atlas texture, enriched with:+- [kb-text-shape](https://hackage.haskell.org/package/kb-text-shape) font shaping data.+- [msdf-astlas](https://hackage.haskell.org/package/msdf-astlas) compacted atlas.++The package also has a module to run the shaping using stacks of those font bundles.++## The pipeline++Typically you'd just use your rendering engine's utilities to do this.++To implement one, you have to address the three points in the font lifecycle:++## Preparing the font bundle++This part is the slowest one, as it takes care to distill and optimize the data for faster use later.++1. Generate an atlas with [msdf-atlas-gen](https://github.com/Chlumsky/msdf-atlas-gen)+2. [Transcode](https://gitlab.com/dpwiz/msdf-atlas/-/blob/main/test/regenerate.sh) into KTX file.+3. Preprocess and inject the extras as texture attributes.++## Loading++Since all the data are already preprocessed you only have to move it to the appropriate places on CPU/GPU.++For each font file:+* Load the texture like the usual (with [ktx-codec](https://hackage.haskell.org/package/ktx-codec)).+* Load font and atlas data from the same file/bytestring.++For each combination of fonts (font stack:+* Create a shaping context from a bunch of loaded fonts.++## Using++1. Run `Codec.Ktx2.Font.Shaping.shape` on your text.+2. Iterate over the result and convert the UV and world boxes to the data you use for rendering.+  * Since the shaping library does the font picking for you, you may have to look up the associated font data from the context, like texture id.++NB: Due to font format and design shenanigans you have to use a sane "font size" metric that is shared for all the fonts in the stack.+Typically that would be "cap height", and `msdf-atlas` takes care of converting "em" units into that.++For more details on the problem see:+- https://tonsky.me/blog/font-size/+- https://tonsky.me/blog/centering/++## Demo++The repo has a demo of using the shaping results in a Gloss (Brillo) context.++![sorry for the swearing, i was that mad making all this](./demo/screenshot.png)++- Green line: baseline, on which every font sits.+- Blue line: capital letter height, which is used for alignment. Every font rendered to have this one equal.+- Cyan circles: innermost (tiny) - cursor position. Then, "font size", then "line size".+- Red box: the bounding box for all glyph box.+- Yellow line: the initial cursor position (at 0,0) shifted to match the aligned box.++The glyphs have black boxes because the atlas is transcoded as having the single channel swizzled as `rrr1`.+This allows to see the glyph boxes against the grey background.++The `msdf-atlas-gen` is typically used to produce distance fields, but Gloss can't render them.
+ ktx-font.cabal view
@@ -0,0 +1,73 @@+cabal-version: 2.2++-- This file has been generated from package.yaml by hpack version 0.39.1.+--+-- see: https://github.com/sol/hpack++name:           ktx-font+version:        0.1.0.0+synopsis:       GPU-ready rasterized fonts+category:       Graphics+author:         IC Rainbow+maintainer:     aenor.realm@gmail.com+copyright:      2026 IC Rainbow+license:        BSD-3-Clause+license-file:   LICENSE+build-type:     Simple+extra-source-files:+    README.md+extra-doc-files:+    CHANGELOG.md++source-repository head+  type: git+  location: https://gitlab.com/dpwiz/ktx++flag executables+  manual: True+  default: False++library+  exposed-modules:+      Codec.Ktx2.Font+      Codec.Ktx2.Font.Shaping+  other-modules:+      Paths_ktx_font+  autogen-modules:+      Paths_ktx_font+  hs-source-dirs:+      src+  default-extensions:+      BlockArguments+      DeriveGeneric+      DeriveTraversable+      DerivingStrategies+      DerivingVia+      DuplicateRecordFields+      ImplicitParams+      ImportQualifiedPost+      LambdaCase+      NamedFieldPuns+      NoFieldSelectors+      OverloadedRecordDot+      OverloadedStrings+      PatternSynonyms+      RankNTypes+      RecordWildCards+      StrictData+      TupleSections+      TypeApplications+      ViewPatterns+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints+  build-depends:+      aeson+    , base >=4.11 && <5+    , bytestring+    , containers+    , kb-text-shape+    , ktx-codec >=0.1+    , msdf-atlas+    , text+    , vector+    , zstd+  default-language: Haskell2010
+ src/Codec/Ktx2/Font.hs view
@@ -0,0 +1,222 @@+module Codec.Ktx2.Font+  ( -- * KTX font bundles+    Bundle(..)+  , loadBundle+  , loadBundleBytes+  , loadBundleFile+  , freeBundle+  , bundleFont+    -- * Shaping context+  , StackContext(..)+  , createStackContext+  , pushBundleFont+  , destroyStackContext+    -- ** Associated data utilities+  , lookupAtlas+  , lookupBundled+  , mapWithBundle+  , mapBundled+    -- * Format internals+  , pattern KTX_KEY_atlas+  , pattern KTX_KEY_kbts+  , pattern KTX_KEY_kbts_version+  , kbtsVersion+  ) where++import Codec.Compression.Zstd qualified as Zstd+import Codec.Ktx.KeyValue qualified as KVD+import Codec.Ktx2 qualified as Ktx2+import Codec.Ktx2.Read qualified as Ktx2+import Codec.Ktx2.Write qualified as Ktx2+import Control.Concurrent (MVar, newMVar, takeMVar)+import Control.Exception (bracket)+import Control.Monad+import Data.Aeson (encode, eitherDecodeFileStrict, eitherDecodeStrict)+import Data.ByteString (ByteString)+import Data.ByteString qualified as ByteString+import Data.Foldable (toList)+import Data.IntMap (IntMap)+import Data.IntMap qualified as IntMap+import Data.Map qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Traversable (for)+import Data.Vector.Generic qualified as Vector+import Foreign.ForeignPtr.Unsafe (unsafeForeignPtrToPtr)+import GHC.Generics (Generic, Generic1)+import Graphics.MSDF.Atlas.Compact (Compact, compact)+import Graphics.MSDF.Atlas.Compact qualified as Compact+import KB.Text.Shape qualified as TextShape+import KB.Text.Shape.FFI.Enums qualified as KBTS+import KB.Text.Shape.FFI.Handles (Font(..), intHandle)+import KB.Text.Shape.Font (FontData(..), createFont, destroyFont, withFontData)+import KB.Text.Shape.Font qualified as Font++pattern KTX_KEY_atlas :: Text+pattern KTX_KEY_atlas = "msdf-atlas"++pattern KTX_KEY_kbts :: Text+pattern KTX_KEY_kbts = "kbts-blob"++pattern KTX_KEY_kbts_version :: Text+pattern KTX_KEY_kbts_version = "kbts-version"++kbtsVersion :: Text+kbtsVersion = Text.pack $ show KBTS.VERSION_CURRENT <> "," <> show KBTS.BLOB_VERSION_CURRENT++bundleFont+  :: FilePath -- ^ Source font (ttf etc)+  -> FilePath -- ^ Atlas JSON+  -> FilePath -- ^ KTX2 texture+  -> FilePath -- ^ Output+  -> IO ()+bundleFont pathTtf pathJson pathKtx2 pathKtxf = do+  layout <- eitherDecodeFileStrict pathJson >>= either fail pure++  ttfData <- ByteString.readFile pathTtf+  kbtsData <- Font.extractBlob ttfData 0++  font <- createFont kbtsData 0+  capsScale <- withFontData font $ pure . Font.emToCaps+  destroyFont font++  sourceKtx <- Ktx2.fromFile pathKtx2+  let+    atlas = capScalePlanes capsScale $ compact layout+    atlasData = ByteString.toStrict $ encode atlas+    kvd =+      KVD.insertBytes KTX_KEY_atlas (Zstd.compress 19 atlasData) $+      KVD.insertBytes KTX_KEY_kbts (Zstd.compress 19 kbtsData) $+      KVD.insertText KTX_KEY_kbts_version kbtsVersion $+      KVD.setWriterWith ("ktx-font 0.1.0.0 / " <>) sourceKtx.kvd+  Ktx2.toFile pathKtxf sourceKtx{Ktx2.kvd}++-- | Rescale planes to caps height instead of ems.+capScalePlanes :: Float -> Compact -> Compact+capScalePlanes capsScale a = a+  { Compact._size = a._size+  , Compact.planes = Vector.map (Compact.scaleBox capsScale) a.planes+  }++{- | Put already-loaded font to context.++The context will NOT keep a fontData reference.+-}+pushBundleFont :: TextShape.Context -> Bundle -> IO Int+pushBundleFont ctx Bundle{fontData} =+  withFontData fontData $ TextShape.pushFont ctx++data Bundle = Bundle+  { fontData :: FontData -- ^ The bundle holds the font memory too.+  , atlas :: Compact -- ^ Glyph atlas data for the texture.+  }+  deriving (Eq)++loadBundleFile :: FilePath -> IO Bundle+loadBundleFile fontFile =+  bracket (Ktx2.open fontFile) Ktx2.close loadBundle++loadBundleBytes :: ByteString -> IO Bundle+loadBundleBytes bytes =+  Ktx2.bytes bytes >>= loadBundle++-- | Load bundle data from already opened KTX2 reader context+loadBundle :: Ktx2.ReadChunk a => Ktx2.Context a -> IO Bundle+loadBundle ktx = do+  kvd <- Ktx2.keyValueData ktx+  let+    bundled = (,,)+      <$> (Map.lookup KTX_KEY_atlas kvd >>= KVD.fromValue)+      <*> (Map.lookup KTX_KEY_kbts kvd >>= KVD.fromValue)+      <*> (Map.lookup KTX_KEY_kbts_version kvd >>= KVD.fromValue)++  (mtsdfZstd, kbtsZstd, kbtsVer) <-+    case bundled of+      Nothing ->+        error "Missing KTXF metadata"+      Just compressed ->+        pure compressed++  unless (kbtsVer == kbtsVersion) $+    error $ "KBTS blob version mismatch: " <> show kbtsVer <> ", expected " <> show kbtsVersion++  atlas <-+    case Zstd.decompress mtsdfZstd of+      Zstd.Decompress json ->+        either error pure $ eitherDecodeStrict json+      Zstd.Skip ->+        error "Empty atlas data"+      Zstd.Error err ->+        error $ "Atlas decompression error: " <> err++  fontData <-+    case Zstd.decompress kbtsZstd of+      Zstd.Decompress fontData ->+        createFont fontData 0+      Zstd.Skip ->+        error $ "Empty font data"+      Zstd.Error err ->+        error $ "Font decompression error: " <> err++  pure Bundle{..}++freeBundle :: Bundle -> IO ()+freeBundle Bundle{fontData} = destroyFont fontData++-- * Shaping contexts++data StackContext a = StackContext+  { shapeContext :: MVar TextShape.Context+  , bundled :: IntMap a+  , atlases :: IntMap Compact -- XXX: Font.Handle ~ Ptr Font.Handle ~ Int+  }+  deriving stock (Functor, Foldable, Traversable, Generic, Generic1)++{- | Create shaping context and push fonts from all the bundles.+-}+createStackContext :: Foldable t => t Bundle -> IO (StackContext ())+createStackContext bundles = do+  ctx <- TextShape.createContext+  locals <- for (toList bundles) \Bundle{..} ->+    withFontData fontData \font -> do+      _refs <- TextShape.pushFont ctx font+      pure (intHandle font, atlas)+  let atlases = IntMap.fromList locals+  let bundled = IntMap.map (const ()) atlases+  shapeContext <- newMVar ctx+  pure StackContext{..}++{- | Update font annotations with the bundle collection annotated with update functions.++This may be used to attach more information, e.g. after all fonts were assigned texture slots.++The update collection should be a superset of what was initially bundled.+Otherwise you may see missing things downstream.+-}+mapWithBundle :: Foldable t => t (Bundle, Maybe a -> b) -> StackContext a -> StackContext b+mapWithBundle bundles StackContext{bundled = old, ..} = StackContext{bundled = new, ..}+  where+    new = IntMap.fromList do+      (Bundle{fontData=Font.FontData{fontData}}, f) <- toList bundles+      let key = intHandle $ unsafeForeignPtrToPtr fontData+      pure (key, f $ IntMap.lookup key old)++{- | Destroy shaping context.++NB: Does NOT free the fonts as they may be shared with other stacks. Use `freeBundle` for that.+-}+destroyStackContext :: StackContext a -> IO ()+destroyStackContext StackContext{shapeContext} = takeMVar shapeContext >>= TextShape.destroyContext++{-# INLINE lookupBundled #-}+lookupBundled :: Font -> StackContext a -> Maybe a+lookupBundled font StackContext{..} = IntMap.lookup (intHandle font) bundled++{-# INLINE lookupAtlas #-}+lookupAtlas :: Font -> StackContext a -> Maybe Compact+lookupAtlas font StackContext{atlases} = IntMap.lookup (intHandle font) atlases++mapBundled :: (a -> b) -> StackContext a -> StackContext b+mapBundled f ctx@StackContext{bundled} = ctx+  { bundled = IntMap.map f bundled+  }
+ src/Codec/Ktx2/Font/Shaping.hs view
@@ -0,0 +1,166 @@+module Codec.Ktx2.Font.Shaping+  ( -- * Input+    shapeText+  , shape+  , TextShape.text_+  , TextShape.char_+  , withFont_+  , shapeWith+  , Cursor(..)+  , initialCursorUp+  , initialCursorDown+    -- * Output+  , PlacedRun+  , PlacedGlyph(..)+  -- * Re-exports+  , KBTS.Font+  , Atlas.Compact(..)+  , Atlas.Box(..)+  ) where++import Codec.Ktx2.Font qualified as Font+import Control.Concurrent (withMVar)+import Data.List (mapAccumL)+import Data.Text (Text)+import Data.Text qualified as Text+import Graphics.MSDF.Atlas.Compact qualified as Atlas+import KB.Text.Shape qualified as TextShape+import KB.Text.Shape.FFI.Handles qualified as Handles+import KB.Text.Shape.Font qualified as KBTS++{- | Perform text segmentation and shaping on a block of text+-}+shapeText :: Cursor -> Font.StackContext a -> Text -> IO [PlacedRun]+shapeText cur ctx t =+  if Text.null t then+    pure []+  else+    shape cur ctx (TextShape.text_ t)++{- | Perform text segmentation and shaping.++Feed data using functions like 'TextShape.text_'.++The next step would be converting the resulting glyph "runs" into API specific data.++The atlas texture coordinates are normalized to 0..1 range.++The glyph "model" coordinates are normallized to fonts' "capital height".+Multiply by font size to match your projection settings.+-}+shape+  :: Cursor+  -> Font.StackContext a+  -> ((?shapeContext :: Handles.ShapeContext) => IO ())+  -> IO [PlacedRun]+shape cur ctx action  = snd <$> shapeWith (collectRun ctx) cur ctx action++{- | Run shaping and process results using a custom accumulator function+-}+shapeWith+  :: (acc -> (TextShape.Run, [TextShape.Glyph]) -> (acc, placed)) -- ^ Results-collecting function+  -> acc -- initial accumulator+  -> Font.StackContext a -- ^ shaping context with+  -> ((?shapeContext :: Handles.ShapeContext) => IO ())+  -> IO (acc, [placed])+shapeWith collectFun cur Font.StackContext{shapeContext} action =+  withMVar shapeContext \kbts ->+    mapAccumL collectFun cur <$> TextShape.run kbts action++withFont_ :: (?shapeContext :: Handles.ShapeContext) => Font.Bundle -> IO () -> IO ()+withFont_ Font.Bundle{fontData} action =+  KBTS.withFontData fontData \font ->+    TextShape.withFont_ font action++data Cursor = Cursor+  { curX, curY :: Float+  , lineHeight :: Float -- ^ Space between the baselines, as a multiple of the font size.+  , ySign :: Float -- ^ Should match atlas yOrigin and screen direction.+  }+  deriving (Eq, Show)++initialCursorUp+  :: Float -- ^ Line height multiplier.+  -> Cursor+initialCursorUp = initialCursor (-1)++initialCursorDown+  :: Float -- ^ Line height multiplier.+  -> Cursor+initialCursorDown = initialCursor 1++initialCursor+  :: Float -- ^ Y axis signum+  -> Float -- ^ Line height multiplier.+  -> Cursor+initialCursor ySign lineHeight = Cursor+  { curX = 0+  , curY = 0+  , lineHeight+  , ySign+  }++-- | Text runs with uniform direction and script.+type PlacedRun =+  ( (KBTS.Font, Maybe Atlas.Compact)+  , [PlacedGlyph]+  )++data PlacedGlyph = PlacedGlyph+  { codepoint :: Char -- ^ Unicode codepoint associated with the glyph. Mostly for debugging.+  , glyphId :: Int -- ^ Glyph ID in font and atlas. You can use this to look up the glyph boxes from GPU if you upload the glyph data as arrays.+  , glyph :: Atlas.Box -- ^ Glyph box on screen. The size and offsets are normalized so you can run the shaping once, then transform the whole block as you need.+  , plane :: Atlas.Box -- ^ Glyph box in atlas. The size is normalized to UV of the texure.+  }+  deriving (Eq, Show)++collectRun :: Font.StackContext a -> Cursor -> (TextShape.Run, [TextShape.Glyph]) -> (Cursor, PlacedRun)+collectRun ctx cur (TextShape.Run{font}, glyphs) = placeRun <$> mapAccumL (place fontUnitScale atlas_) cur glyphs+  where+    fontUnitScale = capHeightScale font+    atlas_ = Font.lookupAtlas font ctx++    placeRun :: [(Char, Int, Maybe (Atlas.Box, Atlas.Box))] -> PlacedRun+    placeRun placed =+      ( (font, atlas_)+      , do+          (codepoint, glyphId, Just (glyph, plane)) <- placed+          pure PlacedGlyph{codepoint, glyphId, glyph, plane}+      )++-- | Normalize font metrics using "cap height"+capHeightScale :: KBTS.Font -> Float+capHeightScale font = case KBTS.capHeight font of+ 0 -> error "capHeight not set"+ n -> 1 / n++-- -- | Normalize font metrics using "units per em"+-- emScale :: KBTS.Font -> Float+-- emScale font = 1 / KBTS.unitsPerEm font++place :: Float -> Maybe Atlas.Compact -> Cursor -> TextShape.Glyph -> (Cursor, (Char, Int, Maybe (Atlas.Box, Atlas.Box)))+place fontUnitScale atlas_ cur@Cursor{..} glyph@TextShape.Glyph{codepoint, offsetX, offsetY, id=glyphId} =+  ( nextCur+  , (codepoint, fromIntegral glyphId, if codepoint == '\n' then Nothing else params_)+  )+  where+    nextCur = advance fontUnitScale cur glyph++    params_ = atlas_ >>= Atlas.lookupGlyph (fromIntegral glyphId) >>= pure . fmap placeGlyph+    placeGlyph =+      Atlas.moveBox+        (curX + fromIntegral offsetX * fontUnitScale)+        (curY + fromIntegral offsetY * fontUnitScale * ySign)++advance :: Float -> Cursor -> TextShape.Glyph -> Cursor+advance fontUnitScale cur@Cursor{..} TextShape.Glyph{advanceX, advanceY, codepoint} =+  if codepoint == '\n' then+    cur+      { curX = 0+      , curY = curY + lineHeight * ySign+      }+  else+    cur+      { curX = curX + fromIntegral advanceX * fontUnitScale+      , curY = curY + fromIntegral advanceY * fontUnitScale * ySign+      }