# clerk
`clerk` provides a Haskell eDSL in a library for declarative spreadsheet generation. `clerk` is built on top of the [xlsx](https://hackage.haskell.org/package/xlsx) package and extends upon the [work](https://youtu.be/1xGoa-zEOrQ) of Nickolay Kudasov by making the tables' layout more flexible.
## Features
`clerk` can be used to produce a styled spreadsheet with some data and formulas on it. These formulas are evaluated when the document is loaded into a target spreadsheet system.
The library supports:
- Typed cell references. Example: `CellRef Double`.
- Type-safe arithmetic operations with them. Example: `(a :: CellRef Double) + (b :: CellRef Double)` produces a `CellRef Double`.
- Constructing expressions with given types. Example: `(e :: Expr Double) = "SUM" |$| [a |:| b]`, `e` translates to `SUM(A1:B1)` (actual value depends on the values of `a` and `b`).
- Conditional styles, formatting, column widths.
The example below demonstrates most of these features.
## Example
The goal: describe and generate a spreadsheet that calculates the pressure data given some volume data and constants.
The source code for this example is available in the [example](./example) directory.
The program produces an `xlsx` file that looks as follows:
Alternatively, with formulas enabled:
The below sections describe how such a spreadsheet can be constructed.
### Extensions
We'll need several language extensions.
```haskell
{-# LANGUAGE OverloadedRecordDot #-} -- access the fields of records like a.b
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE InstanceSigs #-}
```
### Imports
And import the necessary stuff.
```haskell
module Main (main) where
import Clerk
import Codec.Xlsx qualified as X
import Codec.Xlsx.Formatted qualified as X
import Control.Lens ((%~), (&), (?~))
import Control.Monad (void)
import Data.ByteString.Lazy qualified as L
import Data.Text qualified as T
import Data.Time.Clock.POSIX (getPOSIXTime)
```
### Tables
The tables that we'd like to construct are:
- A table per a constant's value (three of them)
- Volume & pressure table
- Constants' header
- Volume & pressure header
#### Constants' values
In our case, each constant has the same type of the numeric value - `Double`.
However, it might be the case that in another set of constants, they'll have different types.
That's why, in our case, we'll construct a table with a single row per a constant and later stack the constants' tables together.
We can keep a constant's data in a record.
```haskell
data ConstantData a = ConstantData
{ constantName :: String
, constantSymbol :: String
, constantValue :: a
, constantUnits :: String
}
```
Next, we can group the constants.
```haskell
data Constants f = Constants
{ gasConstant :: f Double
, numberOfMoles :: f Double
, temperature :: f Double
}
type ConstantsInput = Constants ConstantData
constants :: ConstantsInput
constants =
Constants
{ gasConstant = ConstantData "GAS CONSTANT" "R" 0.08206 "L.atm/mol.K"
, numberOfMoles = ConstantData "NUMBER OF MOLES" "n" 1 "moles"
, temperature = ConstantData "TEMPERATURE(K)" "T" 273.2 "K"
}
```
Furthermore, we'd like to style the constants' tables, so let's prepare the styles. We'll reuse these styles in other tables.
```haskell
data Colors = LightBlue | LightGreen | Blue | Green
instance Show Colors where
show :: Colors -> String
show = \case
LightBlue -> "90CCFFFF"
LightGreen -> "90CCFFCC"
Blue -> "FF99CCFF"
Green -> "FF00FF00"
blue :: FormatCell
blue = mkColorStyle Blue
lightBlue :: FormatCell
lightBlue = mkColorStyle LightBlue
green :: FormatCell
green = mkColorStyle Green
mixed :: FormatCell
mixed coords idx = mkColorStyle (if even idx then LightGreen else LightBlue) coords idx
```
Additionally, we compose a transformation of a `FormatCell` for the number format
```haskell
use2decimalDigits :: FCTransform
use2decimalDigits fcTransform =
fcTransform & X.formattedFormat %~ (\format -> format & X.formatNumberFormat ?~ X.StdNumberFormat X.Nf2Decimal)
```
And a transform for centering the cell contents
```haskell
alignCenter :: FCTransform
alignCenter = horizontalAlignment X.CellHorizontalAlignmentCenter
```
Now, we can make a `RowBuilder` for a constant.
We'll later use this builder for each constant separately.
We get a pair of outputs:
- Top left cell of a constant's table. That is, the cell with that constant's name.
- The value of the constant.
Later, the outputs of this and other `RowBuilder`s will be used to relate the positions of tables on a sheet.
```haskell
constantBuilder :: ToCellData a => RowBuilder (ConstantData a) CellData (Coords, CellRef a)
constantBuilder = do
refTopLeft <- column lightBlue constantName
column_ lightBlue constantSymbol
refValue <- column (lightBlue +> use2decimalDigits) constantValue
column_ lightBlue constantUnits
return (unCell refTopLeft, refValue)
```
#### Volume & Pressure values
To fill this table, we'll take the some data and combine it with the constants.
```haskell
newtype Volume = Volume {volume :: Double}
volumeData :: [Volume]
volumeData = take 10 $ Volume <$> [1 ..]
```
To pass the constants' references in a structured way, we make a helper type.
```haskell
data ConstantsRefs = ConstantsRefs
{ refGas :: CellRef Double
, refNumberOfMoles :: CellRef Double
, refTemperature :: CellRef Double
}
```
Next, we define a function to produce a builder for volume and pressure.
```haskell
valuesBuilder :: ConstantsRefs -> RowBuilder Volume CellData ()
valuesBuilder ConstantsRefs{..} = do
refVolume <- column mixed volume
let pressure' = refGas |*| refNumberOfMoles |*| refTemperature |/| refVolume
column_ (mixed +> use2decimalDigits) (const pressure')
```
#### Constants' header
We won't use records here. Instead, we'll put the names of the columns straight into the `RowBuilder`.
The outputs will be the coordinates of the top left cell and the top right cell of this table.
```haskell
constantsHeaderBuilder :: RowBuilder () CellData (Coords, Coords)
constantsHeaderBuilder = do
refTopLeft <- columnWidth 20 (blue +> alignCenter) (const "constant")
columnWidth_ 8 (blue +> alignCenter) (const "symbol")
column_ (blue +> alignCenter) (const "value")
refTopRight <- columnWidth 13 (blue +> alignCenter) (const "units")
return (unCell refTopLeft, unCell refTopRight)
```
#### Volume & Pressure header
For this header, we'll also put the names of columns straight inside the builder.
```haskell
valuesHeaderBuilder :: RowBuilder () CellData Coords
valuesHeaderBuilder = do
tl <- columnWidth 12 green (const "VOLUME (L)")
columnWidth_ 16 green (const "PRESSURE (atm)")
return (unCell tl)
```
### Sheet builder
The `SheetBuilder` is used to place `RowBuilder`s onto a sheet and glue them together.
Inside `SheetBuilder`, when a `RowBuilder` is placed onto a sheet, we can use the
references that it produces in the subsequent expressions.
```haskell
full :: SheetBuilder ()
full = do
(constantsHeaderTL, constantsHeaderTR) <- placeInput (Coords 2 2) () constantsHeaderBuilder
(gasTL, gas) <- placeInput (overRow (+ 2) constantsHeaderTL) constants.gasConstant constantBuilder
(nMolesTL, nMoles) <- placeInput (overRow (+ 1) gasTL) constants.numberOfMoles constantBuilder
temperature <- snd <$> placeInput (overRow (+ 1) nMolesTL) constants.temperature constantBuilder
valuesHeaderTL <- placeInput (overCol (+ 2) constantsHeaderTR) () valuesHeaderBuilder
placeInputs_ (overRow (+ 2) valuesHeaderTL) volumeData (valuesBuilder $ ConstantsRefs gas nMoles temperature)
```
### Result
Finally, we can write the result and get the spreadsheet like the one that you've seen at the top of this tutorial.
```haskell
writeWorksheet :: SheetBuilder a -> String -> IO ()
writeWorksheet tb name = do
ct <- getPOSIXTime
let xlsx = composeXlsx [(T.pack "List 1", void tb)]
L.writeFile ("example-" <> name <> ".xlsx") $ X.fromXlsx ct xlsx
writeEx :: IO ()
writeEx = writeWorksheet full "1"
main :: IO ()
main = writeEx
```
Run
```console
cd example
nix develop
cabal run
```
to get `example/example-1.xlsx`.
With formulas enabled, `example-1.xlsx` looks like this:
## Contribute
This project provides a dev environment via a `Nix` flake.
1. With [flakes enabled](https://nixos.wiki/wiki/Flakes#Enable_flakes), run:
```console
nix develop
cabal build
```
1. This `README.md` is generated from several files. If you edit them, re-generate it.
```console
cabal test docs
```
1. (Optionally) Start `VSCodium` with `Haskell` extensions.
1. Write settings and run `VSCodium`.
```console
nix run .#writeSettings
nix run .#codium .
```
1. Open a `Haskell` file. `Haskell Language Server` should soon start giving you hints.
1. Study these links if you'd like to learn more about the tools used in this flake:
- [Prerequisites](https://github.com/deemp/flakes#prerequisites)
- `Haskell` project [template](https://github.com/deemp/flakes/tree/main/templates/codium/haskell#readme)
- [Haskell](https://github.com/deemp/flakes/blob/main/README/Haskell.md)