Skip to content

Commit b13e5ca

Browse files
committed
Added tests, improved style semantics, and readme to excel-clj
1 parent d47bdd0 commit b13e5ca

File tree

10 files changed

+350
-75
lines changed

10 files changed

+350
-75
lines changed

README.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
## excel-clj
2+
3+
Purpose: declarative creation of Excel spreadsheets / PDFs with Clojure from
4+
higher level abstractions (tree, table) or via a manual grid specification, with
5+
boilerplate-free common sense styling.
6+
7+
All of the namespaces have an `example` function at the end; they're intended to
8+
be browsable and easy to interact with to glean information beyond what's here
9+
in a the readme.
10+
11+
Start by skimming this and then browsing [core.clj](src/excel_clj/core.clj).
12+
13+
### Tables
14+
Though Excel is much more than a program for designing tabular layouts, a table
15+
is a common abstraction that we impose on our data.
16+
17+
```clojure
18+
(require '[excel-clj.core :as excel])
19+
=> nil
20+
(def table-data
21+
[{"Date" "2018-01-01" "% Return" 0.05M "USD" 1500.5005M}
22+
{"Date" "2018-02-01" "% Return" 0.04M "USD" 1300.20M}
23+
{"Date" "2018-03-01" "% Return" 0.07M "USD" 2100.66666666M}])
24+
=> #'user/table-data
25+
(let [;; A workbook is any [key value] seq of [sheet-name, sheet-grid].
26+
;; Convert the table to a grid with the table function.
27+
workbook {"My Generated Sheet" (excel/table table-data)}]
28+
(excel/quick-open workbook))
29+
```
30+
31+
![An excel sheet is opened](resources/quick-open-table.png)
32+
33+
34+
### Trees
35+
36+
Sometimes -- frequently for accounting documents -- we use spreadsheets to sum
37+
categories of numbers which are themselves broken down into subcategories.
38+
39+
For example, a balance sheet shows a company's assets & liabilities by summing
40+
the balances corresponding to an account hierarchy.
41+
42+
```clojure
43+
(def balance-sheet
44+
["Mock Balance Sheet"
45+
[["Assets"
46+
[["Current Assets"
47+
[["Cash" {2018 100M, 2017 85M}]
48+
["Accounts Receivable" {2018 5M, 2017 45M}]]]
49+
["Investments" {2018 100M, 2017 10M}]
50+
["Other" {2018 12M, 2017 8M}]]]
51+
["Liabilities & Stockholders' Equity"
52+
[["Liabilities"
53+
[["Current Liabilities"
54+
[["Notes payable" {2018 5M, 2017 8M}]
55+
["Accounts payable" {2018 10M, 2017 10M}]]]
56+
["Long-term liabilities" {2018 100M, 2017 50M}]]]
57+
["Equity"
58+
[["Common Stock" {2018 102M, 2017 80M}]]]]]]])
59+
60+
=> #'user/balance-sheet
61+
(excel/quick-open {"Balance Sheet" (excel/tree balance-sheet)})
62+
```
63+
64+
![An excel sheet is opened](resources/quick-open-tree.png)
65+
66+
67+
### PDF Generation
68+
69+
If you're on a system that uses an OpenOffice implementation of Excel, PDF
70+
generation is similarly simple.
71+
72+
```clojure
73+
(excel/quick-open-pdf
74+
{"Mock Balance Sheet" (excel/tree balance-sheet)
75+
"Some Table Data" (excel/table table-data)})
76+
```
77+
78+
![A PDF is opened](resources/quick-open-pdf.png)
79+
80+
81+
### Table Styling
82+
83+
The `table` function provides hooks to add custom styling without touching
84+
the generated grid, within the context of the table abstraction.
85+
86+
(More on the syntax of the style data in _Manual Styling_.)
87+
88+
- The `:data-style` keyword arg is a fn `(row-map, column-name) => style map`
89+
- The `:header-style` keyword arg is a fn `(column name) => style map`
90+
91+
For instance, to highlight rows in our table where percent return is less than
92+
5%:
93+
94+
```clojure
95+
(letfn [(highlight-below-5% [row-data col-name]
96+
(when (< (row-data "% Return") 0.05M)
97+
{:fill-pattern :solid-foreground
98+
:fill-foreground-color :yellow}))]
99+
(excel/quick-open
100+
{"My Generated Sheet" (excel/table table-data :data-style highlight-below-5%)}))
101+
```
102+
103+
![An excel spreadsheet with one row highlighted](resources/manual-formatting.png)
104+
105+
### Tree Styling
106+
107+
The tree function provides similar hooks to style the tree elements based on
108+
their nested depth. Both keyword arguments expect a fn
109+
`(integer-depth) => style map` where the integer depth increases with nesting.
110+
111+
- `:total-formatters` is a function that controls styling for tree rows that
112+
display totals of multiple subcategories
113+
- `:formatters` is a function that controls styling for the rest of the tree
114+
115+
### Manual Styling
116+
117+
The code in this library wraps [Apache POI](https://poi.apache.org/). For
118+
styling, the relevant POI object is [CellStyle](https://poi.apache.org/apidocs/dev/org/apache/poi/ss/usermodel/CellStyle.html).
119+
120+
In order to insulate code from Java objects, style specification is done via maps,
121+
for instance the style we saw above to highlight a row was:
122+
```clojure
123+
{:fill-pattern :solid-foreground, :fill-foreground-color :yellow}
124+
```
125+
126+
Under the hood however, all of the key/value pairs in the style maps correspond
127+
directly to setters within the POI objects. So if you browse the CellStyle
128+
documentation, you'll see `CellStyle::setFillPattern` and
129+
`CellStyle::setFillForegroundColor` methods.
130+
131+
The map attributes are camel cased to find the appropriate setters, and the
132+
corresponding values are run through the multimethod
133+
[excel-clj.style/coerce-to-obj](src/excel_clj/style.clj) which dispatches on the
134+
attribute name and returns some value that's appropriate to hand to POI.
135+
136+
If you're interested in greater detail, see the namespace documentation for
137+
[style.clj](src/excel_clj/style.clj), otherwise it's sufficient to know that enums are keyword-ized and
138+
colors are either given as keywords (`:yellow`) or as RGB three-tuples
139+
(`[255 255 255]`).
140+
141+
### Grid Format & Cell Merging
142+
143+
The functions `table` and `tree` convert the source data into the grid format
144+
which can go directly into the workbook map. The grid is `[[cell]]`, where each
145+
`[cell]` represents a row.
146+
147+
The cell data are either plain values (String, Date, Number, etc.) or a map
148+
that includes optional style data / cell merging instructions.
149+
150+
```clojure
151+
(let [title-style {:font {:bold true :font-height-in-points 10} :alignment :center}]
152+
(excel/quick-open
153+
{"Some Grid"
154+
[ [{:value "This is a Title" :width 5 :style title-style}] ;; Title row
155+
["This" "Is" "A" "Row"] ;; Another row
156+
]}))
157+
```
158+
159+
![A spreadsheet with a merged title](resources/manual-grid.png)

resources/manual-formatting.png

88.6 KB
Loading

resources/manual-grid.png

83.5 KB
Loading

resources/quick-open-pdf.png

55.4 KB
Loading

resources/quick-open-table.png

88.5 KB
Loading

resources/quick-open-tree.png

120 KB
Loading

src/excel_clj/core.clj

Lines changed: 40 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@
150150
data-style is a function that takes (datum-map, column name) and returns
151151
a style specification or nil for the default style."
152152
[tabular-data & {:keys [headers header-style data-style]
153-
:or {data-style style/best-guess-row-format}}]
153+
:or {data-style (constantly {})}}]
154154
(let [;; add the headers either in the order they're provided or in the order
155155
;; of (seq) on the first datum
156156
headers (let [direction (if (> (count (last tabular-data))
@@ -164,7 +164,9 @@
164164
;; justify, and therefore which headers to right justify by default
165165
numeric? (volatile! #{})
166166
data-cell (fn [col-name row]
167-
(let [style (data-style row col-name)]
167+
(let [style (style/merge-all
168+
(or (data-style row col-name) {})
169+
(style/best-guess-row-format row col-name))]
168170
(when (or (= (:data-format style) :accounting)
169171
(number? (get row col-name "")))
170172
(vswap! numeric? conj col-name))
@@ -198,46 +200,42 @@
198200
If provided, the formatters argument is a function that takes the integer
199201
depth of a category (increases with nesting) and returns a cell format for
200202
the row, and total-formatters is the same for rows that are totals."
201-
([t]
202-
(tree t nil))
203-
([t headers]
204-
(tree
205-
t headers
206-
style/default-tree-formatters style/default-tree-total-formatters))
207-
([t headers formatters total-formatters]
208-
(try
209-
(let [tabular (apply tree/render-table (second t))
210-
fmt-or-max (fn [fs n]
211-
(or (get fs n) (second (apply max-key first fs))))
212-
all-colls (or headers
213-
(sequence
214-
(comp
215-
(mapcat keys)
216-
(filter (complement #{:depth :label}))
217-
(distinct))
218-
tabular))
219-
header-style {:font {:bold true} :alignment :right}]
220-
(concat
221-
;; Title
222-
[[{:value (first t) :style {:alignment :center}
223-
:width (inc (count all-colls))}]]
224-
225-
;; Headers
226-
[(into [""] (map #(->{:value % :style header-style})) all-colls)]
227-
228-
;; Line items
229-
(for [line tabular]
230-
(let [total? (empty? (str (:label line)))
231-
format (or
232-
(fmt-or-max
233-
(if total? total-formatters formatters)
234-
(:depth line))
235-
{})
236-
style (style/merge-all format {:data-format :accounting})]
237-
(into [{:value (:label line) :style (if total? {} style)}]
238-
(map #(->{:value (get line %) :style style})) all-colls)))))
239-
(catch Exception e
240-
(throw (ex-info "Failed to render tree" {:tree t} e))))))
203+
[t & {:keys [headers formatters total-formatters]
204+
:or {formatters style/default-tree-formatters
205+
total-formatters style/default-tree-total-formatters}}]
206+
(try
207+
(let [tabular (apply tree/render-table (second t))
208+
fmt-or-max (fn [fs n]
209+
(or (get fs n) (second (apply max-key first fs))))
210+
all-colls (or headers
211+
(sequence
212+
(comp
213+
(mapcat keys)
214+
(filter (complement #{:depth :label}))
215+
(distinct))
216+
tabular))
217+
header-style {:font {:bold true} :alignment :right}]
218+
(concat
219+
;; Title
220+
[[{:value (first t) :style {:alignment :center}
221+
:width (inc (count all-colls))}]]
222+
223+
;; Headers
224+
[(into [""] (map #(->{:value % :style header-style})) all-colls)]
225+
226+
;; Line items
227+
(for [line tabular]
228+
(let [total? (empty? (str (:label line)))
229+
format (or
230+
(fmt-or-max
231+
(if total? total-formatters formatters)
232+
(:depth line))
233+
{})
234+
style (style/merge-all format {:data-format :accounting})]
235+
(into [{:value (:label line) :style (if total? {} style)}]
236+
(map #(->{:value (get line %) :style style})) all-colls)))))
237+
(catch Exception e
238+
(throw (ex-info "Failed to render tree" {:tree t} e)))))
241239

242240
(defn with-title
243241
"Write a title above the given grid with a width equal to the widest row."

src/excel_clj/style.clj

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
:style {:data-format :percent,
77
:font {:bold true :font-height-in-points 10}}}
88
9-
The goal of the style map is to reuse all of the functionality built in to the
10-
underlying Apache POI objects, but with immutable data structures.
9+
The goal of the style map is to reuse all of the functionality built in to
10+
the underlying Apache POI objects, but with immutable data structures.
1111
1212
The primary advantage -- beyond the things we're accustomed to loving about
1313
clojure data maps as opposed to mutable objects with getters/setters -- other
@@ -58,9 +58,12 @@
5858
(coerce-to-obj
5959
workbook :font {:bold true :font-height-in-points 10}))))"
6060
:author "Matthew Downey"} excel-clj.style
61-
(:require [clojure.string :as string])
61+
(:require [clojure.string :as string]
62+
[clojure.reflect :as reflect]
63+
[rhizome.viz :as viz])
6264
(:import (org.apache.poi.ss.usermodel
63-
DataFormat BorderStyle HorizontalAlignment FontUnderline)
65+
DataFormat BorderStyle HorizontalAlignment FontUnderline
66+
FillPatternType)
6467
(org.apache.poi.xssf.usermodel
6568
XSSFWorkbook XSSFColor DefaultIndexedColorMap XSSFCell)))
6669

@@ -149,6 +152,27 @@
149152
:medium-dash-dot-dot BorderStyle/MEDIUM_DASH_DOT_DOT
150153
:slanted-dash-dot BorderStyle/SLANTED_DASH_DOT})
151154

155+
(def fill-patterns
156+
{:no-fill FillPatternType/NO_FILL
157+
:solid-foreground FillPatternType/SOLID_FOREGROUND
158+
:fine-dots FillPatternType/FINE_DOTS
159+
:alt-bars FillPatternType/ALT_BARS
160+
:sparse-dots FillPatternType/SPARSE_DOTS
161+
:thick-horz-bands FillPatternType/THICK_HORZ_BANDS
162+
:thick-vert-bands FillPatternType/THICK_VERT_BANDS
163+
:thick-backward-diag FillPatternType/THICK_BACKWARD_DIAG
164+
:thick-forward-diag FillPatternType/THICK_FORWARD_DIAG
165+
:big-spots FillPatternType/BIG_SPOTS
166+
:bricks FillPatternType/BRICKS
167+
:thin-horz-bands FillPatternType/THIN_HORZ_BANDS
168+
:thin-vert-bands FillPatternType/THIN_VERT_BANDS
169+
:thin-backward-diag FillPatternType/THIN_BACKWARD_DIAG
170+
:thin-forward-diag FillPatternType/THIN_FORWARD_DIAG
171+
:squares FillPatternType/SQUARES
172+
:diamonds FillPatternType/DIAMONDS
173+
:less_dots FillPatternType/LESS_DOTS
174+
:least_dots FillPatternType/LEAST_DOTS})
175+
152176
(def data-formats
153177
{:accounting "_($* #,##0.00_);_($* (#,##0.00);_($* \"-\"??_);_(@_)"
154178
:ymd "yyyy-MM-dd"
@@ -171,15 +195,21 @@
171195
(coerce-from-map :border-left borders)
172196
(coerce-from-map :border-right borders)
173197
(coerce-from-map :border-bottom borders)
174-
175-
(coerce-from-map :color colors
176-
;; If there's nothing in the map ...
177-
(fn [_ _ color]
178-
(if (and (coll? color) (= (count color) 3))
179-
(apply rgb-color color)
180-
(-> "Can only create colors from rgb three-tuples or keywords."
181-
(ex-info {:given color})
182-
(throw)))))
198+
(coerce-from-map :fill-pattern fill-patterns)
199+
200+
(letfn [(if-color-not-found [_ _ color]
201+
(if (and (coll? color) (= (count color) 3))
202+
(apply rgb-color color)
203+
(-> "Can only create colors from rgb three-tuples or keywords."
204+
(ex-info {:given color})
205+
(throw))))]
206+
(coerce-from-map :color colors if-color-not-found)
207+
(coerce-from-map :fill-background-color colors if-color-not-found)
208+
(coerce-from-map :fill-foreground-color colors if-color-not-found)
209+
(coerce-from-map :left-border-color colors if-color-not-found)
210+
(coerce-from-map :right-border-color colors if-color-not-found)
211+
(coerce-from-map :top-border-color colors if-color-not-found)
212+
(coerce-from-map :bottom-border-color colors if-color-not-found))
183213

184214
(defmethod coerce-to-obj :font
185215
[^XSSFWorkbook wb _ font-attrs]
@@ -272,22 +302,6 @@
272302
(if (map? cell) cell {:value cell})
273303
:style (fn [s] (if-not s style (merge-all s style)))))
274304

275-
(comment
276-
;; If one wanted to visualize all of the nested setters & POI objects...
277-
(let [param-type (fn [setter] (resolve (first (:parameter-types setter))))
278-
is-setter? (fn [{:keys [name parameter-types]}]
279-
(and (string/starts-with? (str name) "set")
280-
(= 1 (count parameter-types))))
281-
setters (fn [class]
282-
(filter
283-
is-setter?
284-
(#'clojure.reflect/declared-methods class)))
285-
cell-style (first (filter #(= 'setCellStyle (:name %)) (setters XSSFCell)))]
286-
;; Keep in mind that this requires $ apt-get install graphviz
287-
(rhizome.viz/view-tree
288-
#(instance? Class (param-type %)) (comp setters param-type) cell-style
289-
:node->descriptor #(->{:label ((juxt :name :parameter-types) %)}))))
290-
291305
;;; Default table formatting functions to produce styles
292306

293307
(defn best-guess-row-format
@@ -312,7 +326,7 @@
312326

313327
(def default-header-style
314328
(constantly
315-
{:border-bottom BorderStyle/THIN :font {:bold true}}))
329+
{:border-bottom :thin :font {:bold true}}))
316330

317331
;;; Default tree formatting functions to produce styles
318332

@@ -325,3 +339,19 @@
325339
(def default-tree-total-formatters
326340
{0 {:font {:bold true} :border-top :medium}
327341
1 {:border-top :thin :border-bottom :thin}})
342+
343+
(defn example
344+
"If one wanted to visualize all of the nested setters & POI objects...
345+
Keep in mind that this requires $ apt-get install graphviz"
346+
[]
347+
(let [param-type (fn [setter] (resolve (first (:parameter-types setter))))
348+
is-setter? (fn [{:keys [name parameter-types]}]
349+
(and (string/starts-with? (str name) "set")
350+
(= 1 (count parameter-types))))
351+
setters (fn [class]
352+
(filter is-setter? (#'reflect/declared-methods class)))
353+
cell-style (first
354+
(filter #(= 'setCellStyle (:name %)) (setters XSSFCell)))]
355+
(viz/view-tree
356+
#(instance? Class (param-type %)) (comp setters param-type) cell-style
357+
:node->descriptor #(->{:label ((juxt :name :parameter-types) %)}))))

0 commit comments

Comments
 (0)