diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for forms-data-format
+
+## 0.1.0.0 -- YYYY-mm-dd
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) 2023, Mario
+
+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,15 @@
+Forms Data Format
+=================
+
+The Forms Data Format, or FDF for short, is a horrible data format thought up
+by Adobe. Its only redeeming feature is that in practice it's much simpler
+than PDF.
+
+This Haskell package is hacked together to parse and re-serialize *some* files
+in FDF format. It certainly does not support the full range of possible FDF
+files. I tried to follow the specification but gave up.
+
+The main purpose of the package is to allow parsing and serializing [Canadian
+tax forms](https://github.com/blamario/canadian-income-tax) in FDF format.
+For that purpose this hack seems sufficient. If you find it doesn't work for
+some other FDF files, feel free to contribute.
diff --git a/forms-data-format.cabal b/forms-data-format.cabal
new file mode 100644
--- /dev/null
+++ b/forms-data-format.cabal
@@ -0,0 +1,36 @@
+cabal-version:      2.4
+name:               forms-data-format
+version:            0.1
+
+synopsis: Parse and serialize FDF, the Forms Data Format
+
+description: This is a hacked-together library to parse and serialize FDF,
+             Adobe's [Forms Data
+             Format](https://helpx.adobe.com/acrobat/kb/acrobat-forms-form-data-web.html). It
+             does not follow the specification, but seems to work for simple
+             cases.
+
+category:           data, text
+license:            BSD-3-Clause
+license-files:      LICENSE
+copyright:          (c) 2023 Mario Blažević
+author:             Mario Blažević
+maintainer:         blamario@protonmail.com
+bug-reports:        https://github.com/blamario/forms-data-format/issues
+
+extra-source-files: CHANGELOG.md, README.md
+source-repository head
+  type:     git
+  location: https://github.com/blamario/forms-data-format
+
+library
+    exposed-modules:  Text.FDF
+
+    -- Modules included in this library but not exported.
+    -- other-modules:
+    other-extensions: ImportQualifiedPost NamedFieldPuns OverloadedStrings
+    build-depends:    base == 4.*, bytestring >=0.9 && < 0.12, text,
+                      monoid-subclasses == 1.*, rank2classes >= 1 && < 1.6,
+                      parsers < 0.13, grammatical-parsers >= 0.5 && < 0.8
+    hs-source-dirs:   src
+    default-language: Haskell2010
diff --git a/src/Text/FDF.hs b/src/Text/FDF.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/FDF.hs
@@ -0,0 +1,136 @@
+{-# LANGUAGE ImportQualifiedPost #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Text.FDF (FDF (FDF, body), Field (Field, name, value, kids),
+                 mapWithKey, mapFieldWithKey,
+                 foldMapWithKey, foldMapFieldWithKey,
+                 traverseWithKey, traverseFieldWithKey,
+                 parse, serialize) where
+
+import Control.Applicative ((<*), (<*>), (<|>), many, some, optional)
+import Data.Bifunctor (bimap)
+import Data.ByteString (ByteString)
+import Data.Char (isSpace)
+import Data.Monoid.Instances.ByteString.UTF8 (ByteStringUTF8 (ByteStringUTF8))
+import Data.Monoid.Textual (toString, toText)
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Text.Encoding (encodeUtf8)
+import Rank2 qualified
+import Text.Grampa
+import Text.Grampa.Combinators
+import Text.Parser.Combinators (manyTill)
+import Text.Grampa.PEG.Backtrack qualified as PEG
+
+type Parser = PEG.Parser (Rank2.Only FDF)
+
+data FDF = FDF {
+  header :: ByteString,
+  body :: Field,
+  trailer :: ByteString}
+  deriving (Show)
+
+data Field = Field {
+  name :: Text,
+  value :: Maybe Text,
+  kids :: [Field]}
+  deriving (Show)
+
+mapWithKey :: ([Text] -> Text -> Text) -> FDF -> FDF
+mapWithKey f x@FDF{body} = x{body = mapFieldWithKey f body}
+
+mapFieldWithKey :: ([Text] -> Text -> Text) -> Field -> Field
+mapFieldWithKey f x@Field{name, value, kids} =
+  x{value = f [name] <$> value,
+    kids = mapFieldWithKey (f . (name:)) <$> kids}
+
+foldMapWithKey :: Monoid a => ([Text] -> Text -> a) -> FDF -> a
+foldMapWithKey f x@FDF{body} = foldMapFieldWithKey f body
+
+foldMapFieldWithKey :: Monoid a => ([Text] -> Text -> a) -> Field -> a
+foldMapFieldWithKey f x@Field{name, value, kids} =
+  foldMap (f [name]) value <> foldMap (foldMapFieldWithKey $ f . (name:)) kids
+
+traverseWithKey :: Applicative f => ([Text] -> Text -> f Text) -> FDF -> f FDF
+traverseWithKey f x@FDF{body} = (\body'-> x{body = body'}) <$> traverseFieldWithKey f body
+
+traverseFieldWithKey :: Applicative f => ([Text] -> Text -> f Text) -> Field -> f Field
+traverseFieldWithKey f x@Field{name, value, kids} =
+  Field name <$> traverse (f [name]) value <*> traverse (traverseFieldWithKey $ f . (name:)) kids
+
+serialize :: FDF -> ByteString
+serialize FDF{header, body, trailer} =
+  "%FDF-1.2\n"
+  <> header
+  <> "<<\n"
+  <> "/FDF\n"
+  <> "<<\n"
+  <> "/Fields [\n"
+  <> encodeUtf8 (serializeField body) <> "\n"
+  <> "]\n"
+  <> ">>\n"
+  <> ">>\n"
+  <> trailer
+  <> "%%EOF\n"
+
+serializeField :: Field -> Text
+serializeField Field{name, value, kids} =
+  "<<\n"
+  <> "/T (" <> name <> ")\n"
+  <> foldMap (\v-> "/V (" <> v <> ")\n") value
+  <> (if null kids then "" else "/Kids [\n" <> Text.intercalate "\n" (serializeField <$> kids) <> "]\n")
+  <> ">>"
+
+parse :: ByteString -> Either String FDF
+parse input =
+  bimap (\failure-> toString (const "<?>") $ failureDescription s failure 4) id $ simply parseComplete parser s
+  where s = ByteStringUTF8 input
+
+parser :: Parser ByteStringUTF8 FDF
+parser = FDF
+  <$ (string "%FDF-1.2" <* lineEnd <?> "first line")
+  <*> extract ((takeWhile1 (`notElem` ["\r", "\n"]) <?> "bytes")
+               <> lineEnd <> (mconcat <$> manyTill line begin) <?> "header")
+  <* (string "/FDF" <* takeCharsWhile (== ' ') <* lineEnd <?> "end header")
+  <* begin
+  <* (string "/Fields [" <* takeCharsWhile (== ' ') <* lineEnd <?> "fields")
+  <*> field
+  <* (string "]" <* takeCharsWhile (== ' ') <* lineEnd <?> "end the fields")
+  <* (end <?> "end the body")
+  <*> extract ((end <?> "end the object")
+               <> string "endobj" <> lineEnd
+               <> takeCharsWhile isSpace
+               <> string "trailer" <> lineEnd
+               <> (mconcat <$> manyTill line (string "%%EOF" <?> "last line"))
+               <?> "trailer")
+  <* optional lineEnd
+
+field :: Parser ByteStringUTF8 Field
+field = Field <$ begin
+  <*> strictText (string "/T (" *> takeCharsWhile (`notElem` [')', '\r', '\n']) <* string ")" <* lineEnd <?> "name")
+  <*> optional (strictText $ admit (string "/V (" *> commit (takeCharsWhile (`notElem` [')', '\r', '\n']) <* string ")" <* lineEnd)
+                                    <|> string "/V /" *> commit (takeCharsWhile (`notElem` ['\r', '\n']) <* lineEnd)
+                                    <?> "value"))
+  <*> admit (string "/Kids [" *> commit (lineEnd *> takeSome field <* string "]" <* lineEnd <?> "kids")
+             <|> commit mempty)
+  <* end
+
+begin :: Parser ByteStringUTF8 ByteStringUTF8
+begin = string "<<" *> lineEnd <?> "<<"
+
+end :: Parser ByteStringUTF8 ByteStringUTF8
+end = string ">>" *> takeCharsWhile (== ' ') *> moptional lineEnd <?> ">>"
+
+line :: Parser ByteStringUTF8 ByteStringUTF8
+line = takeCharsWhile (`notElem` ['\r', '\n']) <> lineEnd <?> "line"
+
+lineEnd :: Parser ByteStringUTF8 ByteStringUTF8
+lineEnd = string "\r\n" <|> string "\r" <|> string "\n"
+
+strictText :: Parser ByteStringUTF8 ByteStringUTF8 -> Parser ByteStringUTF8 Text
+strictText = fmap $ toText (error . ("Invalid UTF-8 sequence: " ++) . show)
+
+extract :: Parser ByteStringUTF8 ByteStringUTF8 -> Parser ByteStringUTF8 ByteString
+extract = fmap $ \(ByteStringUTF8 bs) -> bs
