diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog for `msdf-atlas`
+
+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.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,6 @@
+# msdf-atlas
+
+https://github.com/Chlumsky/msdf-atlas-gen
+
+The package alsoo provides a compacted representation of the atlas
+to use with text shaping packages like ktx-font / kb-text-shape.
diff --git a/msdf-atlas.cabal b/msdf-atlas.cabal
new file mode 100644
--- /dev/null
+++ b/msdf-atlas.cabal
@@ -0,0 +1,103 @@
+cabal-version: 2.2
+
+-- This file has been generated from package.yaml by hpack version 0.38.1.
+--
+-- see: https://github.com/sol/hpack
+
+name:           msdf-atlas
+version:        0.1.0.0
+synopsis:       Types and parser for the MSDF atlas layout
+category:       Graphics
+author:         IC Rainbow
+maintainer:     aenor.realm@gmail.com
+copyright:      2025 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/msdf-atlas
+
+library
+  exposed-modules:
+      Graphics.MSDF.Atlas.CLI
+      Graphics.MSDF.Atlas.Compact
+      Graphics.MSDF.Atlas.Layout
+  other-modules:
+      Paths_msdf_atlas
+  autogen-modules:
+      Paths_msdf_atlas
+  hs-source-dirs:
+      src
+  default-extensions:
+      BlockArguments
+      DeriveAnyClass
+      DeriveGeneric
+      DerivingStrategies
+      DerivingVia
+      DuplicateRecordFields
+      ImportQualifiedPost
+      LambdaCase
+      NamedFieldPuns
+      OverloadedRecordDot
+      OverloadedStrings
+      PatternSynonyms
+      RecordWildCards
+      StandaloneDeriving
+      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.7 && <5
+    , containers
+    , text
+    , vector
+  default-language: Haskell2010
+
+test-suite msdf-atlas-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Paths_msdf_atlas
+  autogen-modules:
+      Paths_msdf_atlas
+  hs-source-dirs:
+      test
+  default-extensions:
+      BlockArguments
+      DeriveAnyClass
+      DeriveGeneric
+      DerivingStrategies
+      DerivingVia
+      DuplicateRecordFields
+      ImportQualifiedPost
+      LambdaCase
+      NamedFieldPuns
+      OverloadedRecordDot
+      OverloadedStrings
+      PatternSynonyms
+      RecordWildCards
+      StandaloneDeriving
+      StrictData
+      TupleSections
+      TypeApplications
+      ViewPatterns
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      aeson
+    , base >=4.7 && <5
+    , bytestring
+    , containers
+    , msdf-atlas
+    , text
+    , vector
+    , zstd
+  default-language: Haskell2010
diff --git a/src/Graphics/MSDF/Atlas/CLI.hs b/src/Graphics/MSDF/Atlas/CLI.hs
new file mode 100644
--- /dev/null
+++ b/src/Graphics/MSDF/Atlas/CLI.hs
@@ -0,0 +1,100 @@
+module Graphics.MSDF.Atlas.CLI where
+
+import Data.Text (Text)
+
+type Inputs = [Input] -- grouped together with `-and`
+
+data Input = Input
+  { font :: FilePath
+  , variation :: [(Text, Int)] -- optional variations, switching to varfont
+  , range :: Maybe Range
+  , fontscale :: Maybe Float
+  , fontname :: Maybe Text
+  }
+  deriving (Eq, Show)
+
+data Range
+  = Charset FilePath
+  | Chars SetSpec
+  | Glyphs SetSpec
+  | Allglyphs
+  deriving (Eq, Show)
+
+data SetSpec
+  = Single Char
+  | Range Char Char
+  | String [Char]
+  deriving (Eq, Show)
+
+data Format
+  = PNG
+  | BMP
+  | TIFF
+  | RGBA
+  | FL32
+  | TEXT
+  | TEXTFLOAT
+  | BIN
+  | BINFLOAT
+  | BINFLOATLE
+  deriving (Eq, Ord, Show, Enum, Bounded)
+
+data Dimensions
+  = Fixed Int Int
+  | Auto Constraint
+  deriving (Eq, Show)
+
+data Constraint
+  = PoTS
+  | PoTR
+  | Square
+  | Square2
+  | Square4 -- default
+  deriving (Eq, Show)
+
+data Uniform
+  = UniformCols Int
+  | UniformCell Int Int
+  | UniformCellConstraint Constraint
+  | UniformOrigin OriginConstraint
+  deriving (Eq, Show)
+
+data OriginConstraint
+  = Off
+  | On
+  | Horizontal
+  | Vertical
+  deriving (Eq, Show)
+
+type Outputs = [Output]
+
+data Output
+  = ImageOut FilePath
+  | JSON FilePath
+  | CSV FilePath
+  | ARFont FilePath
+  | ShadronPreview FilePath Text
+  deriving (Eq, Show)
+
+data Glyph = Glyph
+  { size :: Maybe Float -- pixels per em
+  , minsize :: Maybe Float
+  , emrange :: Maybe Float
+  , pxrange :: Maybe Int
+  , aemrange :: Maybe (Float, Float)
+  , pxalign :: OriginConstraint
+  , empadding :: Maybe Float
+  , pxpadding :: Maybe Int
+  , outerempadding :: Maybe Float
+  , outerpxpadding :: Maybe Int
+  , aempadding :: Maybe (LBTR Float)
+  , apxpadding :: Maybe (LBTR Int)
+  , aouterempadding :: Maybe (LBTR Float)
+  , aouterpxpadding :: Maybe (LBTR Int)
+  }
+  deriving (Eq, Show)
+
+data LBTR a = LBTR
+  { left, bottom, right, top :: a
+  }
+  deriving (Eq, Show)
diff --git a/src/Graphics/MSDF/Atlas/Compact.hs b/src/Graphics/MSDF/Atlas/Compact.hs
new file mode 100644
--- /dev/null
+++ b/src/Graphics/MSDF/Atlas/Compact.hs
@@ -0,0 +1,98 @@
+module Graphics.MSDF.Atlas.Compact
+  ( compact
+  , lookupGlyph
+  , Compact(..)
+  , Box(..)
+  , nullBox
+  , moveBox
+  , scaleBox
+  ) where
+
+import Data.Aeson.Types (FromJSON(..), ToJSON(..))
+import Data.Vector.Storable qualified as Storable
+import Data.Vector.Generic qualified as Vector
+import Foreign (Storable(..))
+import GHC.Generics (Generic)
+
+import Graphics.MSDF.Atlas.Layout (AtlasType, Layout (..), Atlas (..))
+import qualified Graphics.MSDF.Atlas.Layout as Atlas
+
+data Compact = Compact
+  { _atlasSize :: (Int, Int) -- ^ Atlas image size in pixels
+  , _size :: Float -- ^ Font size in pixels
+  , _type :: AtlasType
+  , _yOrigin :: Atlas.YOrigin
+  , glyphs :: Storable.Vector Box -- ^ Glyph boxes in the atlas UV space, normalized
+  , planes :: Storable.Vector Box -- ^ Quad boxes in word space
+  }
+  deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)
+
+data Box = Box {x, y, w, h :: Float}
+  deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)
+
+{-# INLINEABLE nullBox #-}
+nullBox :: Box
+nullBox = Box 0 0 0 0
+
+{-# INLINEABLE moveBox #-}
+moveBox :: Float -> Float -> Box -> Box
+moveBox tx ty box@Box{x, y} = box {x = x + tx, y = y + ty}
+
+{-# INLINEABLE scaleBox #-}
+scaleBox :: Float -> Box -> Box
+scaleBox s Box{x, y, w, h} = Box {x = x * s, y = y * s, w = w * s, h = h * s}
+
+instance Storable Box where
+  alignment ~_ = 16
+  sizeOf ~_ = 16
+  peek ptr = Box
+    <$> peekByteOff ptr 0
+    <*> peekByteOff ptr 4
+    <*> peekByteOff ptr 8
+    <*> peekByteOff ptr 12
+  poke ptr Box{..} = do
+    pokeByteOff ptr 0 x
+    pokeByteOff ptr 4 y
+    pokeByteOff ptr 8 w
+    pokeByteOff ptr 12 h
+
+compact :: Layout -> Compact
+compact Layout{atlas=Atlas{..}, glyphs=aGlyphs} =
+  Compact
+    { _atlasSize = (width, height)
+    , _size = size
+    , _type = aType
+    , _yOrigin = yOrigin
+    , glyphs = Storable.convert as
+    , planes = Storable.convert ps
+    }
+  where
+    u = 1 / fromIntegral width
+    v = 1 / fromIntegral height
+
+    (as, ps) = Vector.unzip do
+      Atlas.Glyph{planeBounds, atlasBounds} <- aGlyphs
+      pure
+        ( maybe nullBox atlasBox atlasBounds
+        , maybe nullBox planeBox planeBounds
+        )
+
+    atlasBox Atlas.Bounds{..} = Box{..}
+      where
+        x = left * u
+        y = (if yOrigin == Atlas.Top then top else bottom) * v
+        w = abs (right - left) * u
+        h = abs (top - bottom) * v
+
+    planeBox Atlas.Bounds{..} = Box{..}
+      where
+        x = left * 0.5 + right * 0.5
+        y = ySign (top * 0.5 + bottom * 0.5)
+        w = abs $ right - left
+        h = abs $ top - bottom
+        ySign =
+          if yOrigin == Atlas.Top then negate else id
+
+lookupGlyph :: Int -> Compact -> Maybe (Box, Box)
+lookupGlyph ix Compact{glyphs, planes} =
+  (,) <$> Vector.indexM glyphs ix <*> Vector.indexM planes ix
diff --git a/src/Graphics/MSDF/Atlas/Layout.hs b/src/Graphics/MSDF/Atlas/Layout.hs
new file mode 100644
--- /dev/null
+++ b/src/Graphics/MSDF/Atlas/Layout.hs
@@ -0,0 +1,180 @@
+module Graphics.MSDF.Atlas.Layout where
+
+import Data.Aeson
+import Data.Char (ord)
+import Data.IntMap (IntMap)
+import Data.IntMap qualified as IntMap
+import Data.Vector (Vector)
+import Data.Vector qualified as Vector
+import GHC.Generics (Generic)
+
+data Layout = Layout
+  { atlas :: Atlas
+  , metrics :: Metrics
+  , glyphs :: Vector Glyph
+  , kerning :: Vector Kerning
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance FromJSON Layout
+instance ToJSON Layout
+
+data Atlas = Atlas
+  { aType :: AtlasType
+  , distanceRange :: Maybe Float
+  , distanceRangeMiddle :: Maybe Float
+  , size :: Float -- ^ The `size` field represents the font size in pixels per em.
+  , width :: Int -- ^ Atlas image width in pixels
+  , height :: Int -- ^ Atlas image height in pixels
+  , yOrigin :: YOrigin
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance FromJSON Atlas where
+  parseJSON = withObject "Atlas" \o -> do
+    aType <- o .: "type"
+    distanceRange <- o .:? "distanceRange"
+    distanceRangeMiddle <- o .:? "distanceRangeMiddle"
+    size <- o .: "size"
+    width <- o .: "width"
+    height <- o .: "height"
+    yOrigin <- o .: "yOrigin"
+    pure Atlas{..}
+
+instance ToJSON Atlas where
+  toJSON Atlas{..} = object
+    [ "type" .= aType
+    , "distanceRange" .= distanceRange
+    , "distanceRangeMiddle" .= distanceRangeMiddle
+    , "size" .= size
+    , "width" .= width
+    , "height" .= height
+    , "yOrigin" .= yOrigin
+    ]
+
+data AtlasType
+  = Hardmask -- ^ non-anti-aliased binary image
+  | Softmask -- ^ anti-aliased image
+  | SDF -- ^ true signed distance field (SDF)
+  | PSDF -- ^ signed perpendicular distance field (PSDF)
+  | MSDF -- ^ multi-channel signed distance field (MSDF)
+  | MTSDF -- ^ combination of MSDF and true SDF in the alpha channel
+  deriving (Eq, Ord, Show, Enum, Bounded, Generic)
+
+instance FromJSON AtlasType where
+  parseJSON = withText "AtlasType" \case
+    "hardmask" -> pure Hardmask
+    "softmask" -> pure Softmask
+    "sdf" -> pure SDF
+    "psdf" -> pure PSDF
+    "msdf" -> pure MSDF
+    "mtsdf" -> pure MTSDF
+    huh -> fail $ "Unexpected AtlasType: " <> show huh
+
+instance ToJSON AtlasType where
+  toJSON = \case
+    Hardmask -> "hardmask"
+    Softmask -> "softmask"
+    SDF -> "sdf"
+    PSDF -> "psdf"
+    MSDF -> "msdf"
+    MTSDF -> "mtsdf"
+
+data YOrigin
+  = Top
+  | Bottom
+  deriving (Eq, Ord, Show, Enum, Bounded, Generic)
+
+instance FromJSON YOrigin where
+  parseJSON = withText "yOrigin" \case
+    "top" -> pure Top
+    "bottom" -> pure Bottom
+    huh -> fail $ "Unexpected yOrigin: " <> show huh
+
+instance ToJSON YOrigin where
+  toJSON = \case
+    Top -> String "top"
+    Bottom -> String "bottom"
+
+    -- - If there are multiple input fonts (`-and` parameter), the remaining data are grouped into `variants`, each representing an input font.
+    --
+
+-- | Useful font metric values retrieved from the font. All values are in em's.
+data Metrics = Metrics
+  { emSize :: Float
+  , lineHeight :: Float
+  , ascender :: Float
+  , descender :: Float
+  , underlineY :: Float
+  , underlineThickness :: Float
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance FromJSON Metrics
+instance ToJSON Metrics
+
+data Glyph = Glyph
+  { index :: Maybe Int
+  , unicode :: Maybe Int
+  , advance :: Float
+  , planeBounds :: Maybe Bounds
+  , atlasBounds :: Maybe Bounds
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance FromJSON Glyph
+instance ToJSON Glyph
+
+newtype ByCodepoint = ByCodepoint (IntMap Glyph)
+  deriving (Eq, Ord, Show, Generic)
+
+instance FromJSON ByCodepoint
+instance ToJSON ByCodepoint
+
+byCodepoint :: Foldable t => t Glyph -> ByCodepoint
+byCodepoint = ByCodepoint . foldr (\g@Glyph{unicode} rest -> maybe rest (\i -> IntMap.insert i g rest) unicode) mempty
+
+lookupCodepoint :: Char -> ByCodepoint -> Maybe Glyph
+lookupCodepoint c (ByCodepoint gs) = IntMap.lookup (ord c) gs
+
+newtype ByIndex = ByIndex (Vector Glyph)
+  deriving (Eq, Ord, Show, Generic)
+
+lookupGlyph :: Int -> ByIndex -> Maybe Glyph
+lookupGlyph ix (ByIndex gs) = Vector.indexM gs ix
+
+byIndex :: Foldable t => t Glyph -> Either String ByIndex
+byIndex = extract . foldr collect mempty
+  where
+    collect g@Glyph{index} next = maybe next (\i -> IntMap.insert i g next) index
+    extract m =
+      if IntMap.keys m == [0 .. IntMap.size m - 1] then
+        Right . ByIndex $ Vector.fromList (IntMap.elems m)
+      else
+        Left "Glyph indices aren't contiguous"
+
+data Bounds = Bounds
+  { left, top, right, bottom :: Float
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance FromJSON Bounds
+instance ToJSON Bounds
+
+data Kerning = Kerning
+  { advance :: Float
+  , unicode1 :: Maybe Int
+  , unicode2 :: Maybe Int
+  , index1 :: Maybe Int
+  , index2 :: Maybe Int
+  }
+  deriving (Eq, Ord, Show, Generic)
+
+instance FromJSON Kerning
+instance ToJSON Kerning
+
+    -- - `glyphs` is an array of individual glyphs identified by Unicode character index (`unicode`) or glyph index (`index`), depending on whether character set or glyph set mode is used.
+    --     - `advance` is the horizontal advance in em's.
+    --     - `planeBounds` represents the glyph quad's bounds in em's relative to the baseline and horizontal cursor position.
+    --     - `atlasBounds` represents the glyph's bounds in the atlas in pixels.
+    -- - If available, `kerning` lists all kerning pairs and their advance adjustment (which needs to be added to the base advance of the first glyph in the pair).
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,32 @@
+import Data.Aeson (eitherDecodeFileStrict, encodeFile)
+import Data.Char (chr)
+import Data.IntMap qualified as IntMap
+
+import Graphics.MSDF.Atlas.Layout
+import Graphics.MSDF.Atlas.Compact (compact)
+
+main :: IO ()
+main = do
+  testCompact
+  testAtlasCodepoints
+
+testCompact :: IO ()
+testCompact = do
+  layout <- eitherDecodeFileStrict "test/Ubuntu-R-codepoints.json" >>= either fail pure
+  encodeFile "test/compact.json" (compact layout)
+
+testAtlasCodepoints :: IO ()
+testAtlasCodepoints = do
+  Layout{glyphs, kerning} <- eitherDecodeFileStrict "test/Ubuntu-R-codepoints.json" >>= either fail pure
+  mapM_ (matchCodepoints $ byCodepoint glyphs) kerning
+
+matchCodepoints :: ByCodepoint -> Kerning -> IO ()
+matchCodepoints (ByCodepoint codepoints) Kerning{..} = sequence_ pair_
+  where
+    pair_ = do
+      a <- unicode1
+      b <- unicode2
+      Just do
+        Glyph{} <- maybe (fail "unicode1 codepoint not found in glyphs") pure $ IntMap.lookup a codepoints
+        Glyph{} <- maybe (fail "unicode2 codepoint not found in glyphs") pure $ IntMap.lookup b codepoints
+        putStrLn $ chr a : chr b : ' ' : show advance
