├── README.md ├── css └── default.css └── ledger2html /README.md: -------------------------------------------------------------------------------- 1 | # Ledger2html # 2 | 3 | Ledger2html is a simple Ruby script that helps you obtain beautiful HTML5 reports 4 | from [Ledger](http://ledger-cli.org/) output. 5 | 6 | ## Requirements 7 | 8 | - Ledger 3.0 9 | - Ruby 1.8.7 (Ruby 2.0.0 or later recommended) 10 | 11 | ## Synopsis ## 12 | 13 | Usage: ledger2html 14 | Options: 15 | -h, --help Show this help message and exit. 16 | -o, --output Save the report to a file. 17 | --pre Use
 instead of  to wrap Ledger's output.
18 |             --style                Path to a CSS file (this option can be used multiple times).
19 |             --title               Title of the report.
20 |             --version                    Print ledger2html version and exit.
21 |             --debug                      Enable debugging.
22 | 
23 | ## Examples
24 | 
25 | A sample register report:
26 | 
27 |     ledger2html -f drewr3.dat reg groceries
28 | 
29 | <table>
30 | <thead>
31 | <tr>
32 | <th scope="col" class="header-"></th>
33 | <th scope="col" class="header-date">Date</th>
34 | <th scope="col" class="header-payee">Payee</th>
35 | <th scope="col" class="header-account">Account</th>
36 | <th scope="col" class="header-amount">Amount</th>
37 | <th scope="col" class="header-balance">Balance</th>
38 | </tr>
39 | </thead>
40 | <tr class="first-posting"><td><input name="status" value="" type="checkbox"></td><td class="date">10-Dec-20</td><td class="payee">Organic Co-op</td><td class="account">Expenses:Food:Groceries</td><td class="amount">$ 37.50</td><td class="amount">$ 37.50</td></tr><tr><td></td><td></td><td></td><td class="account">Expenses:Food:Groceries</td><td class="amount">$ 37.50</td><td class="amount">$ 75.00</td></tr><tr><td></td><td></td><td></td><td class="account">Expenses:Food:Groceries</td><td class="amount">$ 37.50</td><td class="amount">$ 112.50</td></tr><tr><td></td><td></td><td></td><td class="account">Expenses:Food:Groceries</td><td class="amount">$ 37.50</td><td class="amount">$ 150.00</td></tr><tr><td></td><td></td><td></td><td class="account">Expenses:Food:Groceries</td><td class="amount">$ 37.50</td><td class="amount">$ 187.50</td></tr><tr><td></td><td></td><td></td><td class="account">Expenses:Food:Groceries</td><td class="amount">$ 37.50</td><td class="amount">$ 225.00</td></tr><tr class="first-posting pending"><td><input name="status" value="" type="checkbox"></td><td class="date">11-Jan-02</td><td class="payee">Grocery Store</td><td class="account">Expenses:Food:Groceries</td><td class="amount">$ 65.00</td><td class="amount">$ 290.00</td></tr><tr class="first-posting pending"><td><input name="status" value="" type="checkbox"></td><td class="date">11-Jan-19</td><td class="payee">Grocery Store</td><td class="account">Expenses:Food:Groceries</td><td class="amount">$ 44.00</td><td class="amount">$ 334.00</td></tr>
41 | </table>
42 | 
43 | 
44 | A sample cash flow report:
45 | 
46 |     ledger2html -f drewr3.dat bal --dc --related checking
47 | 
48 | <table>
49 | <thead>
50 | <tr>
51 | <th scope="col" class="header-debit">Debit</th>
52 | <th scope="col" class="header-credit">Credit</th>
53 | <th scope="col" class="header-balance">Balance</th>
54 | </tr>
55 | </thead>
56 | <tr><td class="amount">$ 300.00</td><td class="amount">$ 5,500.00</td><td class="amount partial">$ <span class="neg">-5,200.00</span></td><td class="account">Assets</td></tr><tr><td class="amount">0</td><td class="amount">$ 1,000.00</td><td class="amount partial">$ <span class="neg">-1,000.00</span></td><td class="account">Equity</td></tr><tr><td class="amount">$ 6,634.00</td><td class="amount">0</td><td class="amount partial">$ 6,634.00</td><td class="account">Expenses</td></tr><tr><td class="amount">0</td><td class="amount">$ 2,030.00</td><td class="amount partial">$ <span class="neg">-2,030.00</span></td><td class="account">Income</td></tr><tr><td class="amount">$ 200.00</td><td class="amount">0</td><td class="amount partial">$ 200.00</td><td class="account">Liabilities</td></tr><tr><td class="amount total">$ 7,134.00</td><td class="amount total">$ 8,530.00</td><td class="amount total">$ <span class="neg">-1,396.00</span></td><td></td></tr>
57 | </tr>
58 | </table>
59 | 
60 | 


--------------------------------------------------------------------------------
/css/default.css:
--------------------------------------------------------------------------------
  1 | /* Style copi… inspired by http://coding.smashingmagazine.com/2008/08/13/top-10-css-table-designs/ */
  2 | ::selection {
  3 |   background: rgb(51,51,153);
  4 |   color: rgb(252,252,252);
  5 | }
  6 | body {
  7 |   width: 95%;
  8 |   margin-top: 4em;
  9 |   margin-bottom: 6em;
 10 |   font-family: 'Lucida Grande', 'Helvetica Neue', sans-serif;
 11 |   font-size: 12px;
 12 |   font-style: normal;
 13 |   font-weight: normal;
 14 |   text-align: left;
 15 |   vertical-align: baseline;
 16 |   color: rgb(51,51,153);
 17 |   background-color: rgb(252,252,252);
 18 | }
 19 | figure {
 20 |   width: 100%;
 21 |   margin: 0px;
 22 |   padding: 0px;
 23 | }
 24 | h1, h2, h3, h4, h5, h6 {
 25 |   text-align: center;
 26 | }
 27 | pre {
 28 |   font-family: Menlo, Monaco, Courier, monospace;
 29 | 	color: rgb(102,102,153);
 30 | }
 31 | table {
 32 |   display: table;
 33 |   line-height: 22px;
 34 |   width:90%;
 35 |   margin: auto;
 36 |   -webkit-border-horizontal-spacing: 0px;
 37 |   -webkit-border-vertical-spacing: 0px;
 38 | }
 39 | thead {
 40 |   display: table-header-group;
 41 |   height: 43px;
 42 |   line-height: 22px;
 43 |   margin: 0px;
 44 |   padding: 0px;
 45 |   color: rgb(51,51,153);
 46 |   background-color: rgba(0,0,0,0);
 47 |   outline-color: rgb(51,51,51);
 48 |   outline-style: none;
 49 |   outline-width: 0px;
 50 | }
 51 | th {
 52 |   display: table-cell;
 53 |   font-size: 14px;
 54 |   margin: 0px;
 55 |   padding: 10px 8px;
 56 |   font-weight: normal;
 57 |   color: rgb(51,51,153);
 58 | 	border-bottom: 2px solid rgb(102,120,177);
 59 |   border-collapse: collapse;
 60 | }
 61 | tr {
 62 |   display: table-row;
 63 |   margin: 0px;
 64 |   padding: 0px;
 65 | }
 66 | tr:nth-child(even) {
 67 |  /* background-color: rgb(250, 250, 255);*/
 68 | }
 69 | tr:hover td {
 70 |   color: rgb(0,0,153);
 71 |   background-color: rgb(242,243,255);
 72 | }
 73 | td {
 74 |   display: table-cell;
 75 |   vertical-align: top;
 76 | 	padding: 5px 8px;
 77 | 	color: rgb(102,102,153);
 78 | }
 79 | .first-posting td {
 80 |   border-top: 1px dotted rgb(182,190,216);
 81 |   border-collapse: collapse;
 82 | }
 83 | tr:first-child > td {
 84 |   border: none;
 85 | }
 86 | td.amount {
 87 |   white-space: pre;
 88 |   text-align: right;
 89 |   vertical-align: top;
 90 | }
 91 | /*svg {
 92 |   width: 100%;
 93 | }*/
 94 | #monthly-expenses .first-period td,
 95 | #monthly-expenses-related .first-period td {
 96 | 	border-top: 2px solid rgb(102,120,177);
 97 |   border-collapse: collapse;
 98 | }
 99 | #monthly-expenses th, #monthly-expenses-related th {
100 |   border-bottom: none;
101 | }
102 | #cash-flow-balance th {
103 |   border-bottom: none;
104 | }
105 | div#monthly-net-worth,
106 | div#weekly-net-worth {
107 |   width:30%;
108 |   margin:auto;
109 |   float:right;
110 | }
111 | .account {
112 | }
113 | .amount {
114 |   font-family: Menlo, Monaco, Courier, monospace;
115 | }
116 | .balance-report table {
117 |   line-height: 16px;
118 | }
119 | .balance-report td {
120 |   vertical-align: top;
121 |   padding-top: 5px;
122 |   padding-bottom: 5px;
123 | 	padding-left: 8px;
124 |   padding-right: 8px;
125 | }
126 | .balance-report .account,
127 | .budget-report .account {
128 |   white-space: pre;
129 | }
130 | .balance-report td.amount {
131 |   width: 25%;
132 |   min-width: 130px;
133 | }
134 | .date {
135 |   min-width: 100px;
136 | }
137 | .balance-report td.total,
138 | .budget-report td.total {
139 |   padding-top: 10px;
140 | 	border-top: 2px solid rgb(102,120,177);
141 | }
142 | .date {
143 | }
144 | .future {
145 |   font-style: italic;
146 | }
147 | .header-amount, .header-average, .header-balance,
148 | .header-debit, .header-credit, .header-deviation,
149 | .header-net-worth, .header-total,
150 | .header-actual, .header-budgeted,
151 | .header-remaining, .header-used,
152 | .header-cleared, .header-pending {
153 |   text-align: right;
154 | }
155 | .improper {
156 |   color: rgb(153,26,0);
157 |   font-weight: bold;
158 | }
159 | .ledger-command {
160 |   display: none;
161 | }
162 | .neg {
163 |   color: rgb(153,26,0);
164 | }
165 | .payee {
166 | }
167 | .pending .payee {
168 |   color: rgb(153,77,0);
169 |   font-weight: bold;
170 | }
171 | .perc {
172 |   text-align: right;
173 | }
174 | .total {
175 |   font-weight: bold;
176 | }
177 | 


--------------------------------------------------------------------------------
/ledger2html:
--------------------------------------------------------------------------------
  1 | #!/usr/bin/env ruby
  2 | # -*- coding: utf-8 -*-
  3 | 
  4 | # Copyright (c) 2013 Lifepillar
  5 | # 
  6 | # Permission is hereby granted, free of charge, to any person obtaining a copy
  7 | # of this software and associated documentation files (the "Software"), to deal
  8 | # in the Software without restriction, including without limitation the rights
  9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 10 | # copies of the Software, and to permit persons to whom the Software is
 11 | # furnished to do so, subject to the following conditions:
 12 | # 
 13 | # The above copyright notice and this permission notice shall be included in all
 14 | # copies or substantial portions of the Software.
 15 | # 
 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 22 | # SOFTWARE.
 23 | 
 24 | require 'shellwords'
 25 | 
 26 | VERSION = '0.0.1'
 27 | 
 28 | def debug title, *info
 29 |   return unless $DEBUG
 30 |   puts "\033[1;35m[DEBUG]\033[1;39m #{title}\033[0m"
 31 |   info.each do |chunk|
 32 |     chunk.each_line do |l|
 33 |       puts "\033[1;35m[DEBUG]\033[0m #{l.chomp!}"
 34 |     end
 35 |   end
 36 | end
 37 | 
 38 | format = {
 39 |   :pre => {
 40 |     :balance => '<span class="amount">%(justify(scrub(display_total), 20, 20 + int(prepend_width), true, false))</span>' +
 41 |       '  %(!options.flat ? depth_spacer : "")' +
 42 |       '<span class="account">%-(partial_account(options.flat))</span>\n%/' +
 43 |       '<span class="amount total">%$2</span>\n%/' +
 44 |       '%(prepend_width ? " " * int(prepend_width) : "")' +
 45 |       '--------------------\n',
 46 |   
 47 |     :budget => '<span class="amount">%(justify(scrub(get_at(display_total, 0)), 20, -1, true, false))</span>' +
 48 |      ' <span class="amount">%(justify(get_at(display_total, 1) ? -scrub(get_at(display_total, 1)) : 0.0, 20, ' +
 49 |       '           20 + 1 + 20, true, false))</span>' +
 50 |       ' <span class="amount">%(justify(get_at(display_total, 1) ? (get_at(display_total, 0) ?' +
 51 |       '           -(scrub(get_at(display_total, 1) + get_at(display_total, 0))) :' +
 52 |       '           -(scrub(get_at(display_total, 1)))) : -(scrub(get_at(display_total, 0))), 20, ' +
 53 |       '           20 + 1 + 20 + 1 + 20, true, false))</span>' +
 54 |       '%(get_at(display_total, 1) and (abs(quantity(scrub(get_at(display_total, 0))) / ' +
 55 |       '          quantity(scrub(get_at(display_total, 1)))) >= 1) ? ' +  
 56 |       ' " <span class=\"perc improper\">" : " <span class=\"perc\">")' +
 57 |       '%(justify((get_at(display_total, 1) ? ' +
 58 |       '          (100% * (get_at(display_total, 0) ? scrub(get_at(display_total, 0)) : 0.0)) / ' +
 59 |       '             -scrub(get_at(display_total, 1)) : "na"), ' +
 60 |       '           5, -1, true, false))</span>' +
 61 |       '  %(!options.flat ? depth_spacer : "")' +
 62 |       '<span class="account">%-(partial_account(options.flat))</span>\n' +
 63 |       '%/<span class="amount total">%$2</span> <span class="amount total">%$3</span>' +
 64 |       ' <span class="amount total">%$4</span> <span class="perc total">%$6</span>\n%/' +
 65 |       '%(prepend_width ? " " * int(prepend_width) : "")' +
 66 |       '    ----------------     ----------------     ---------------- -----\n',
 67 |   
 68 |     :cleared => '<span class="amount">%(justify(scrub(get_at(display_total, 0)), 20, 20 + int(prepend_width), ' +
 69 |       ' true, false))</span>  <span class="amount">%(justify(scrub(get_at(display_total, 1)), 20, ' +
 70 |       ' 42 + int(prepend_width), true, false))</span>' +
 71 |       '    %(latest_cleared ? "<span class=\"date\">" + format_date(latest_cleared) + "</span>" : "         ")' +
 72 |       '    %(!options.flat ? depth_spacer : "")' +
 73 |       '<span class="account">%-(partial_account(options.flat))</span>\n%/' +
 74 |       '<span class="amount total">%$2</span>  <span class="amount total">%$3</span>' +
 75 |       '    %$4\n%/' +
 76 |       '%(prepend_width ? " " * int(prepend_width) : "")' +
 77 |       '--------------------  --------------------    ---------\n',
 78 | 
 79 |     :register => '%(date > today ? "<span class=\"future date\">" : "<span class=\"date\">")' +
 80 |       '%(justify(format_date(date), int(date_width)))</span>' +
 81 |       ' %(!cleared and actual ? "<span class=\"pending payee\">" : "<span class=\"payee\">")' +
 82 |       '%(justify(truncated(payee, int(payee_width)), int(payee_width)))</span>' +
 83 |       ' <span class="account">%(justify(truncated(display_account, int(account_width),' +
 84 |       '                               int(abbrev_len)), int(account_width)))</span>' +
 85 |       ' <span class="amount">%(justify(scrub(display_amount), int(amount_width),' +
 86 |       '           3 + int(meta_width) + int(date_width) + int(payee_width)' +
 87 |       '             + int(account_width) + int(amount_width) + int(prepend_width),' +
 88 |       '           true, false))</span>' +
 89 |       ' <span class="amount">%(justify(scrub(display_total), int(total_width),' +
 90 |       '            4 + int(meta_width) + int(date_width) + int(payee_width)' +
 91 |       '             + int(account_width) + int(amount_width) + int(total_width)' +
 92 |       '             + int(prepend_width), true, false))</span>' +
 93 |       '\n%/' +
 94 |       '%(justify(" ", int(date_width)))' +
 95 |       ' %(justify(truncated(has_tag("Payee") ? "<span class=\"payee\">" + payee + "</span>" : " ", ' +
 96 |       '                     int(payee_width)), int(payee_width)))' +
 97 |       ' <span class="account">%$4</span>' +
 98 |       '  <span class="amount">%$6</span>' +
 99 |       ' <span class="amount">%$7</span>\n',
100 |   
101 |     :register_dc => '%(date > today ? "<span class=\"future date\">" : "<span class=\"date\">")' +
102 |       '%(justify(format_date(date), int(date_width)))</span>' +
103 |       '%(!cleared and actual ? "<span class=\"pending payee\">" : "<span class=\"payee\">")' +
104 |       ' %(justify(truncated(payee, int(payee_width)), int(payee_width)))</span>' +
105 |       ' <span class="account">%(justify(truncated(display_account, int(account_width),' +
106 |       '                               int(abbrev_len)), int(account_width)))</span>' +
107 |       ' <span class="amount">%(justify(get_at(display_amount, 0) ? scrub(abs(get_at(display_amount, 0))) : 0.0, int(amount_width), ' +
108 |       '           3 + int(meta_width) + int(date_width) + int(payee_width)' +
109 |       '           + int(account_width) + int(amount_width) + int(prepend_width),' +
110 |       '           true, color))</span>' +
111 |       ' <span class="amount">%(justify(get_at(display_amount, 1) ? scrub(abs(get_at(display_amount, 1))) : 0.0, int(amount_width), ' +
112 |       '           4 + int(meta_width) + int(date_width) + int(payee_width)' +
113 |       '             + int(account_width) + int(amount_width) + int(amount_width) + int(prepend_width),' +
114 |       '           true, color))</span>' +
115 |       '   <span class="amount total">%(justify(scrub(get_at(display_total, 0) ? (get_at(display_total, 1) ? get_at(display_total, 0) + get_at(display_total, 1) : get_at(display_total, 0)) : get_at(display_total, 1)), int(total_width), ' +
116 |       '           5 + int(meta_width) + int(date_width) + int(payee_width)' +
117 |       '            + int(account_width) + int(amount_width) + int(amount_width) + int(total_width)' +
118 |       '             + int(prepend_width), true, color))</span>' +
119 |       '\n%/' +
120 |       '%(justify(" ", int(date_width)))' +
121 |       '   %(justify(truncated(has_tag("Payee") ? "<span class=\"payee\">" + payee + "</span>" : " ", ' +
122 |       '                     int(payee_width)), int(payee_width)))' +
123 |       '%$5 %$6 %$7 %$8\n'
124 |   },
125 | 
126 |   :table => {
127 |     :balance => '<tr><td class="amount">%(scrub(display_total))</td>' +
128 |       '<td class="account">%(!options.flat ? depth_spacer : "")%(partial_account(options.flat))</td></tr>%/' +
129 |       '<tr class="total"><td class="amount total">%$2</td><td></td></tr>%/',
130 | 
131 |     :budget => '<tr><td class="amount">%(get_at(display_total, 0))</td>' +
132 |       '<td class="amount">%(get_at(display_total, 1) ? -scrub(get_at(display_total, 1)) : 0.0)</td>' +
133 |       '<td class="amount">%(get_at(display_total, 1) ? (get_at(display_total, 0) ? -(get_at(display_total, 1) + get_at(display_total, 0)) : -(scrub(get_at(display_total, 1)))) : -(scrub(get_at(display_total, 0))))</td>' +
134 |       '%(get_at(display_total, 1) and (abs(quantity(scrub(get_at(display_total, 0))) / ' +
135 |       'quantity(scrub(get_at(display_total, 1)))) >= 1) ? ' +  
136 |       '"<td class=\"perc improper\">" : "<td class=\"perc\">")' +
137 |       '%(get_at(display_total, 1) ? ' +
138 |       '          (100% * (get_at(display_total, 0) ? scrub(get_at(display_total, 0)) : 0.0)) / ' +
139 |       '             -scrub(get_at(display_total, 1)) : "na")</td>' +
140 |       '<td class="account">%(!options.flat ? depth_spacer : "")%-(partial_account(options.flat))</td>' +
141 |       '</tr>\n%/' +
142 |       '<tr class="total"><td class="amount total">%$2</td><td class="amount total">%$3</td>' +
143 |       '<td class="amount total">%$4</td> <td class="perc total">%$6</td><td class="total"></td></tr>\n%/',
144 | 
145 | 
146 |     :cleared => '<tr><td class="amount">%(scrub(get_at(display_total, 0)))</td>' +
147 |       '<td class="amount">%(scrub(get_at(display_total, 1)))</td>' +
148 |       '%(latest_cleared ? "<td class=\"date\">" + format_date(latest_cleared) + "</td>" : "<td></td>")' +
149 |       '<td class="account">%(!options.flat ? depth_spacer : "")%-(partial_account(options.flat))</td></tr>\n%/' +
150 |       '<tr class="total"><td class="amount total">%$2</td><td class="amount total">%$3</td>' +
151 |       '<td>%$4</td></tr>\n%/',
152 | 
153 |     :register_dc => '<tr><td><input name="status" value="" type="checkbox"></td>' +
154 |       '%(date > today ? "<td class=\"future date\">" : "<td class=\"date\">")%(format_date(date))</td>' +
155 |       '%(!cleared and actual ? "<td class=\"pending payee\">" : "<td class=\"payee\">")%(payee)</td>' +
156 |       '<td class="account">%(display_account)</td>' +
157 |       '<td class="amount">%(get_at(display_amount, 0) ? scrub(abs(get_at(display_amount, 0))) : 0.0)</td>' +
158 |       '<td class="amount">%(get_at(display_amount, 1) ? scrub(abs(get_at(display_amount, 1))) : 0.0)</td>' +
159 |       '<td class="amount total">%(scrub(get_at(display_total, 0) ? (get_at(display_total, 1) ? get_at(display_total, 0) + get_at(display_total, 1) : get_at(display_total, 0)) : get_at(display_total, 1)))</td>' +
160 |       '</tr>\n%/' +
161 |       '<tr><td></td><td></td>' +
162 |       '%(has_tag("Payee") ? "<td class=\"payee\">" + payee + "</td>" : "<td></td>")' +
163 |       '<td class="account">%$6</td><td class="amount">%$7</td><td class="amount">%$8</td><td class="amount total">%$9</td></tr>\n',
164 | 
165 |     :balance_dc => '<tr><td class="amount">%(get_at(display_total, 0) ? scrub(abs(get_at(display_total, 0))) : 0.0)</td>' +
166 |       '<td class="amount">%(get_at(display_total, 1) ? scrub(abs(get_at(display_total, 1))) : 0.0)</td>' +
167 |       '<td class="amount partial">%(scrub(get_at(display_total, 0) ? (get_at(display_total, 1) ? get_at(display_total, 0) + get_at(display_total, 1) : get_at(display_total, 0)) : get_at(display_total, 1)))</td>' +
168 |       '<td class="account">%(!options.flat ? depth_spacer : "")%(partial_account(options.flat))</td></tr>%/' +
169 |       '</tr>\n%/' +
170 |       '<tr><td class="amount total">%$2</td><td class="amount total">%$3</td><td class="amount total">%$4</td><td></td></tr>\n',
171 | 
172 |     :register => '%(!cleared and actual ? "<tr class=\"first-posting pending\">" : "<tr class=\"first-posting\">")' +
173 |       '<td><input name="status" value="" type="checkbox"></td>' +
174 |       '%(date > today ? "<td class=\"future date\">" : "<td class=\"date\">")%(format_date(date))</td>' +
175 |       '<td class="payee">%(payee)</td>' +
176 |       '<td class="account">%(display_account)</td>' +
177 |       '<td class="amount">%(scrub(display_amount))</td>' +
178 |       '<td class="amount">%(scrub(display_total))</td>' +
179 |       '</tr>%/' +
180 |       '<tr><td></td><td></td>' +
181 |       '%(has_tag("Payee") ? "<td class=\"payee\">" + payee + "</td>" : "<td></td>")' +
182 |       '<td class="account">%$5</td>' +
183 |       '<td class="amount">%$6</td>' +
184 |       '<td class="amount">%$7</td>' +
185 |       '</tr>',
186 | 
187 |     # In periodic reports, such as monthly expenses, Ledger uses the payee to store the end date of a period.
188 |     :periodic => '<tr class="first-period">%(date > today ? "<td class=\"future date period\">" : "<td class=\"date period\">")%(format_date(date))' +
189 |       ' %(payee)</td>' +
190 |       '<td class="account">%(display_account)</td>' +
191 |       '<td class="amount">%(scrub(display_amount))</td>' +
192 |       '<td class="amount">%(scrub(display_total))</td>' +
193 |       '</tr>\n%/' +
194 |       '<tr>%(has_tag("Payee") ? "<td class=\"payee\">" + payee + "</td>" : "<td></td>")' +
195 |       '<td class="account">%$5</td>' +
196 |       '<td class="amount">%$6</td>' +
197 |       '<td class="amount">%$7</td>' +
198 |       '</tr>\n'
199 |   }
200 | }
201 | 
202 | module Html5
203 | 
204 |   # Turns a set of key-value pairs into a string of html attributes.
205 |   # For example, { :class => ['foo','baz'], :id => 'abc'} is translated into
206 |   # ' class="foo baz" id="abc"'.
207 |   def self.attributes hash
208 |     return '' if hash.nil?
209 |     s = ''
210 |     hash.each_pair do |k,v|
211 |       unless v.nil? or v.empty?
212 |         s << " #{k.to_s}=\"#{v.instance_of?(String) ? v : v.join(' ')}\""
213 |       end
214 |     end
215 |     return s
216 |   end
217 | 
218 |   class Page
219 | 
220 |     attr :title
221 | 
222 |     def initialize title, options = {}
223 |       @title = title
224 |       @attrs = { :class => [], :id => '', :css => [] }.merge!(options)
225 |       @css = Array.new(@attrs[:css])
226 |       @attrs.delete(:css)
227 |       @snippets = []
228 |     end
229 | 
230 |     def << snippet
231 |       if snippet.instance_of?(String)
232 |         @snippets << Html5::Snippet.new(nil, snippet)
233 |       else
234 |         @snippets << snippet
235 |       end
236 |       return self
237 |     end
238 | 
239 |     # Returns the last added snippet.
240 |     def last_snippet
241 |       @snippets.last
242 |     end
243 | 
244 |     def to_s
245 |       c = start_html
246 |       @snippets.each { |s| c << s.to_s }
247 |       c << end_html
248 |       return c
249 |     end
250 | 
251 |     def save path
252 |       File.open(path, "w").write(self.to_s)
253 |     end
254 | 
255 |     def start_html
256 |       attrs = Html5.attributes(@attrs)
257 |       return "<!DOCTYPE html>\n<html>\n" + header + "<body#{attrs}>\n"
258 |     end
259 | 
260 |     def end_html
261 |       "</body>\n</html>\n"
262 |     end
263 | 
264 |     def header
265 |       "<head>\n<meta charset=\"UTF-8\">\n<title>#{title}\n#{css}\n"
266 |     end
267 | 
268 |     def css
269 |       return '' if @css.empty?
270 |       style = "\n"
275 |       return style
276 |     end
277 | 
278 |   end # class Page
279 | 
280 |   class Snippet
281 |     
282 |     # Creates a new html snippet. The content can be a single string or snippet,
283 |     # or a list of strings/snippets.
284 |     #
285 |     # Example:
286 |     #
287 |     #   sect = Snippet.new('section', '

Title

Blah blah

', :class => 'foo') 288 | def initialize tag, content, attrs = {} 289 | @tag = tag 290 | if content.nil? 291 | @content = [] 292 | elsif content.instance_of?(Array) 293 | @content = content 294 | else 295 | @content = [content] 296 | end 297 | @attrs = attrs 298 | end 299 | 300 | def << snippet 301 | @content << snippet 302 | end 303 | 304 | def to_s 305 | s = '' 306 | s << '<' + @tag + Html5.attributes(@attrs) + ">\n" unless @tag.nil? 307 | @content.each do |c| 308 | s << c.to_s 309 | end 310 | s << '\n" unless @tag.nil? 311 | return s 312 | end 313 | 314 | end # class Snippet 315 | 316 | class Svg 317 | 318 | @@unique_id = 100000 319 | 320 | attr :id 321 | attr :name 322 | 323 | def initialize path 324 | @xml = File.open(path, 'r').read 325 | @name = File.basename(path, '.svg') 326 | @id = @name.downcase.gsub(/\s/,'-') 327 | end 328 | 329 | # Returns code suitable to be embedded into an HTML5 page. 330 | def to_s 331 | # Make xlink:hrefs unique across all the SVG images generated in one run. 332 | ideez =[] 333 | # 1) Collect all id's 334 | @xml.scan(/id\s*=\s*"(.+?)"/) do |match| 335 | ideez << match[0] 336 | end 337 | # 2) For each id, replace all of its occurrences with a new, unique, id 338 | ideez.each do |id| 339 | @xml.gsub!(/#{id}/, @@unique_id.to_s) 340 | @@unique_id += 1 341 | end 342 | @xml.gsub!(/^\s*<\?xml.*?\?>\n?/,'') 343 | @xml.sub!(/width=".+?"/, 'width="100%"') 344 | # Apparently, there is a bug in Safari 6 and in some versions of Chrome preventing labels 345 | # to be displayed when SVG images are inlined in HTML5 documents. The problem resides in the tag. 346 | # See for example: 347 | # 348 | # http://stackoverflow.com/questions/12686247/safari-6-svg-tag-use-issues 349 | # http://stackoverflow.com/questions/11514248/svg-use-elements-in-chrome-not-displayed 350 | # 351 | # The last link described a workaround: instead of , write . 352 | # This is what we do here: 353 | @xml.gsub!(//) { |m| '' } 354 | return @xml 355 | end 356 | 357 | end # class Svg 358 | 359 | end # end module Html5 360 | 361 | 362 | # Runs the report and returns the result as an HTML document. 363 | def run arguments, options = {} 364 | opts = { :wrapper => :table }.merge!(options) 365 | @warnings = [] 366 | command = "ledger #{arguments.shelljoin}" 367 | debug command 368 | output = %x|#{command}| 369 | unless $?.success? 370 | puts "\nLedger command failed." 371 | exit(1) 372 | end 373 | # Clean up 374 | output.gsub!(/<(Total|Unspecified payee|Revalued|Adjustment|None)>/) { |m| '<' + $1 + '>' } 375 | output = output.gsub(/class="[^"]*?amount.*?".*?<\//m) do |match| 376 | match.gsub(/(-\d+([,\.]\d+)*)/) do |amount| 377 | '' + $1 + '' 378 | end 379 | end 380 | attrs = {} 381 | attrs[:id] = opts[:id] if opts[:id] 382 | attrs[:class] = opts[:class] if opts[:class] 383 | container = Html5::Snippet.new('section', nil, attrs) 384 | container << "

#{options[:title]}

\n" 385 | content = Html5::Snippet.new(opts[:wrapper].to_s, nil) 386 | if opts[:header] and :table == opts[:wrapper] 387 | header = "
\n\n" 388 | opts[:header].each do |h| 389 | header << "\n" 390 | end 391 | header << "\n\n" 392 | content << header 393 | end 394 | content << output 395 | container << content 396 | footer = Html5::Snippet.new('footer', nil) 397 | unless @warnings.empty? 398 | warnings = Html5::Snippet.new('ul', nil, :class => 'warnings') 399 | @warnings.each { |w| warnings << Html5::Snippet.new('li', w) } 400 | footer << warnings 401 | end 402 | command.gsub!(//, '>') 404 | footer << Html5::Snippet.new('pre', command, :class => 'ledger-command') 405 | container << footer 406 | html = Html5::Page.new(options[:title], :css => options[:css]) 407 | html << container 408 | return html 409 | end 410 | 411 | def help; <<-HELP 412 | Usage: ledger2html 413 | Options: 414 | -h, --help Show this help message and exit. 415 | -o, --output Save the report to a file. 416 | --pre Use
 instead of 
#{h}
to wrap Ledger's output. 417 | --style Path to a CSS file (this option can be used multiple times). 418 | --title Title of the report. 419 | --version Print ledger2html version and exit. 420 | --debug Enable debugging. 421 | 422 | Example: 423 | ledger2html --title 'Balance Report' --style css/default.css bal ^assets ^liab 424 | HELP 425 | end 426 | 427 | # Parse options 428 | report_type = nil 429 | outfile = nil 430 | debit_credit = false 431 | last_col = 'Balance' # Title for the last column in register and periodic reports 432 | styles = [] 433 | arguments = [] 434 | options = { :css => [], :wrapper => :table, :title => 'Report' } 435 | n = ARGV.length 436 | i = 0 437 | while i < n 438 | case ARGV[i] 439 | when /^--debug$/ 440 | $DEBUG = true 441 | when /^-h|--help$/ 442 | puts help 443 | exit(0) 444 | when /^-o|--output$/ 445 | i += 1 446 | if i < n 447 | outfile = ARGV[i] 448 | else 449 | puts 'Please specify the output file.' 450 | exit(1) 451 | end 452 | when /^--pre$/ 453 | options[:wrapper] = :pre 454 | when /^--style$/ 455 | i += 1 456 | if i < n 457 | options[:css] << ARGV[i] 458 | else 459 | puts '--style requires an argument.' 460 | exit(1) 461 | end 462 | when /^--title$/ 463 | i += 1 464 | if i < n 465 | options[:title] = ARGV[i] 466 | else 467 | puts '--title requires an argument.' 468 | exit(1) 469 | end 470 | when /^--version$/ 471 | puts VERSION 472 | exit(0) 473 | when /^b(al(ance)?)?$/ 474 | report_type = debit_credit ? :balance_dc : :balance 475 | arguments << ARGV[i] 476 | when /^cleared$/ 477 | report_type = :cleared 478 | arguments << ARGV[i] 479 | when /^reg(ister)?$/ 480 | report_type = debit_credit ? :register_dc : :register 481 | arguments << ARGV[i] 482 | when /^budget$/ 483 | report_type = :budget 484 | arguments << ARGV[i] 485 | when /^--dc$/ 486 | debit_credit = true 487 | if :balance == report_type 488 | report_type = :balance_dc 489 | elsif :register == report_type 490 | report_type = :register_dc 491 | end 492 | arguments << ARGV[i] 493 | when /^--(dai|week|month|quarter|year)ly$/ 494 | report_type = :periodic 495 | arguments << ARGV[i] 496 | when /^-A|--average$/ 497 | last_col = 'Average' 498 | arguments << ARGV[i] 499 | when /^--deviation$/ 500 | last_col = 'Deviation' 501 | arguments << ARGV[i] 502 | when /^-F|--format|--balance-format|--register-format$/ 503 | i += 1 # skip format options 504 | else # Pass to Ledger as is 505 | arguments << ARGV[i] 506 | end 507 | i += 1 508 | end 509 | 510 | unless report_type 511 | puts 'Sorry, the only supported report commands are:' 512 | puts 'balance, budget, cleared, register' 513 | exit(1) 514 | end 515 | 516 | case report_type 517 | when :balance 518 | options[:class] = 'balance-report' 519 | arguments << '-F' << format[options[:wrapper]][:balance] 520 | when :cleared 521 | options[:class] = ['balance-report', 'cleared-report'] 522 | options[:header] = ['Pending', 'Cleared'] 523 | arguments << '-F' << format[options[:wrapper]][:cleared] 524 | when :register 525 | options[:class] = 'register-report' 526 | options[:header] = ['','Date','Payee','Account','Amount','Balance'] 527 | arguments << '-F' << format[options[:wrapper]][:register] 528 | when :budget 529 | options[:class] = 'budget-report' 530 | options[:header] = ['Actual', 'Budgeted', 'Remaining', 'Used', 'Account'] 531 | arguments << '-F' << format[options[:wrapper]][:budget] 532 | when :balance_dc 533 | options[:class] = ['balance-report', 'debit-credit-report'] 534 | options[:header] = ['Debit', 'Credit', 'Balance'] 535 | arguments << '-F' << format[options[:wrapper]][:balance_dc] 536 | when :register_dc 537 | options[:class] = ['register-report', 'debit-credit-report'] 538 | options[:header] = ['', 'Date', 'Payee', 'Account', 'Debit', 'Credit', last_col] 539 | arguments << '-F' << format[options[:wrapper]][:register_dc] 540 | when :periodic 541 | options[:class] = ['periodic-report'] 542 | options[:header] = ['Period','Account','Amount', last_col] 543 | arguments << '-F' << format[options[:wrapper]][:periodic] 544 | end 545 | 546 | # Execute Ledger 547 | html = run(arguments, options) 548 | if outfile 549 | html.save(outfile) 550 | else 551 | print html.to_s 552 | end 553 | --------------------------------------------------------------------------------