xref: /plugin/avmathtable/syntax.php (revision 07316ee8740af8a332be22f4fb0f6941164af173)
1<?php
2
3/**
4 * Plugin AVMathTable
5 *
6 * Adds math to columns for Dokuwiki tables.
7 * Supported Math:
8 *    AVG - Calculate average of the column.
9 *    SUM - Calculate total of the entire column so far.
10 *    TOT - Calculate the total since the last total (TOT) was shown. Like a section subtotal.
11 *    ROW - Works like SUM but for the current row. Sums the cells to the left of the one where this command is called. Does not work with other commands in the row. Ie can't sum a row of totals (yet).
12 *    CNT - Number of numeric values in the column above this cell.
13 *    MAX - Maximum value in the column.
14 *    MIN - Minimum value in the column.
15 *
16 * USAGE:
17<mathtable>
18^ Name ^ Deposited ^ Balance ^
19| John | 25        | 500     |
20| Mary | 40        | 680     |
21| Lex  | 10        | 140     |
22| TOTAL| =AVG      | =SUM    |
23</mathtable>
24 *
25 * @license    GPL-2.0 (https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html)
26 * @author     Sherri W. (http://syntaxseed.com)
27 */
28
29if (!defined('DOKU_INC')) {
30    define('DOKU_INC', realpath(dirname(__FILE__) . '/../../') . '/');
31}
32if (!defined('DOKU_PLUGIN')) {
33    define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/');
34}
35
36
37/**
38 * All DokuWiki plugins to extend the parser/rendering mechanism
39 * need to inherit from this class
40 */
41class syntax_plugin_avmathtable extends DokuWiki_Syntax_Plugin
42{
43    private array $infoTable = [];
44
45    /**
46     * What kind of syntax are we?
47     */
48    public function getType()
49    {
50        return 'substition';
51    }
52
53    /**
54     * Where to sort in?
55     */
56    public function getSort()
57    {
58        return 999;
59    }
60
61
62    /**
63     * Connect pattern to lexer
64     */
65    public function connectTo($mode)
66    {
67        $this->Lexer->addEntryPattern('\<mathtable\>', $mode, 'plugin_avmathtable');
68    }
69
70    public function postConnect()
71    {
72        $this->Lexer->addExitPattern('\</mathtable\>', 'plugin_avmathtable');
73    }
74
75
76    /**
77     * Handle the match
78     */
79    public function handle($match, $state, $pos, Doku_Handler $handler)
80    {
81        switch ($state) {
82            case DOKU_LEXER_ENTER:
83                return array($state, '');
84            case DOKU_LEXER_MATCHED:
85                break;
86            case DOKU_LEXER_UNMATCHED:
87
88                $tables = $this->parseTable($match);
89                [$table, $info] = $tables;
90
91                $this->infoTable = $info;
92
93                return array($state, $tables);
94
95            case DOKU_LEXER_EXIT:
96                return array($state, '');
97            case DOKU_LEXER_SPECIAL:
98                break;
99        }
100        return array();
101    }
102
103
104    /**
105     * Create output
106     */
107    public function render($mode, Doku_Renderer $renderer, $data)
108    {
109        if ($mode == 'xhtml') {
110
111            if (empty($data[1])) {
112                return;
113            }
114
115            list($state, $tables) = $data;
116
117            [$match, $info] = $tables;
118            $this->infoTable = $info;
119
120            switch ($state) {
121                case DOKU_LEXER_ENTER:
122                    //$renderer->doc .= "<div class='avMathTable'>";
123                    break;
124
125                case DOKU_LEXER_MATCHED:
126                    break;
127
128                case DOKU_LEXER_UNMATCHED:
129                    $info = [];
130
131                    $output = $this->renderArrayIntoTable($match);
132                    $html = p_render('xhtml', p_get_instructions($output), $info);
133
134                    $renderer->doc .= "<div class='avmathtable'>" . $html . "</div>";
135
136                    break;
137
138                case DOKU_LEXER_EXIT:
139                    //$renderer->doc .= "</div>";
140                    break;
141
142                case DOKU_LEXER_SPECIAL:
143                    break;
144            }
145            return true;
146        }
147        return false;
148    }
149
150    /**
151     * Parse the table syntax into an array.
152     */
153    private function parseTable(string $tableSyntax): array
154    {
155        // Parse the wiki table text into a collection of instructions.
156        $calls = p_get_instructions($tableSyntax);
157
158        // Convert to a multidimensional array
159        $table = [];
160        $row   = [];
161        $cell  = null;
162
163        $infoTable = [];    // Keep track of things like if it's a header cell or a regular cell, alignment, etc.
164        $infoRow   = [];
165        $infoCell  = ['type' => 'plain', 'alignment' => 'left'];
166
167        foreach ($calls as $call) {
168            [$cmd, $data] = $call;
169
170            switch ($cmd) {
171
172                case 'tableheader_open':
173                    $cell = '';
174                    $infoCell  = ['type' => 'header', 'alignment' => (is_null($data[1]) ? 'left' : $data[1])];
175
176                    break;
177                case 'tablecell_open':
178                    $cell = '';
179                    $infoCell  = ['type' => 'plain', 'alignment' => (is_null($data[1]) ? 'left' : $data[1])];
180                    break;
181
182                case 'cdata':
183                    if ($cell !== null) {
184                        $cell .= $data[0];
185                    }
186                    break;
187
188                case 'tableheader_close':
189                case 'tablecell_close':
190                    $row[] = trim($cell);
191                    $infoRow[] = $infoCell;
192                    $cell  = null;  // Reset
193                    $infoCell  = ['type' => 'plain', 'alignment' => 'left']; // Reset
194                    break;
195
196                case 'tablerow_close':
197                    $table[] = $row;
198                    $infoTable[] = $infoRow;
199                    $row = [];
200                    $infoRow = [];
201                    break;
202            }
203        }
204
205        return [$table, $infoTable];
206    }
207
208
209    private function renderArrayIntoTable(array $table): string
210    {
211        $output = '';
212
213        $columnData = [];
214        $rowData = [];
215        $rowNum = 1;
216
217        // Create each row:
218        foreach ($table as $i => $row) {
219            $colNum = 1; // First column of a new row.
220            // Create each cell:
221            foreach ($row as $j => $cell) {
222
223                // Initialize info about this column.
224                if ($rowNum == 1) {
225                    $columnData[$j] = [
226                        'sum' => 0,
227                        'count' => 0,
228                        'total' => 0,
229                        'precision' => 0,
230                        'min' => null,
231                        'max' => null
232                    ];
233                }
234
235                // Initialize info about this row.
236                if ($colNum == 1) {
237                    $rowData[$i] = [
238                        'sum' => 0,
239                        'count' => 0,
240                        'precision' => 0,
241                        'min' => null,
242                        'max' => null
243                    ];
244                }
245
246                // Open up the cell wiki syntax.
247                if ($this->infoTable[$i][$j]['type'] == 'header') {
248                    $output .= "^ ";
249                } else {
250                    $output .= "| ";
251                }
252                if ($this->infoTable[$i][$j]['alignment'] == 'right' || $this->infoTable[$i][$j]['alignment'] == 'center') {
253                    $output .= " ";
254                }
255
256                // Gather info about the numbers in this cell.
257                if (is_numeric($cell)) {
258                    $columnData[$j]['count'] += 1;
259                    $rowData[$i]['count'] += 1;
260                    $columnData[$j]['precision'] = max($columnData[$j]['precision'], $this->countDecimalPlaces($cell));
261                    $rowData[$i]['precision'] = max($rowData[$i]['precision'], $this->countDecimalPlaces($cell));
262                    if ((int)$cell == $cell) {
263                        $numericCell = intval($cell);
264                    } elseif ((float)$cell == $cell) {
265                        $numericCell = floatval($cell);
266                    }
267                    $columnData[$j]['sum'] += $numericCell;
268                    $rowData[$i]['sum'] += $numericCell;
269                    $columnData[$j]['total'] += $numericCell;
270                    $columnData[$j]['max'] = is_null($columnData[$j]['max']) ? $numericCell : max($columnData[$j]['max'], $numericCell);
271                    $columnData[$j]['min'] = is_null($columnData[$j]['min']) ? $numericCell : min($columnData[$j]['min'], $numericCell);
272                    $rowData[$i]['max'] = is_null($rowData[$i]['max']) ? $numericCell : max($rowData[$i]['max'], $numericCell);
273                    $rowData[$i]['min'] = is_null($rowData[$i]['min']) ? $numericCell : min($rowData[$i]['min'], $numericCell);
274                }
275
276                // Insert the cell value. TODO : Handle special math features.
277                $output .= $this->insertCellData($cell, $columnData, $rowData, $j, $i);
278
279
280                // Close up the cell wiki syntax.
281                if ($this->infoTable[$i][$j]['alignment'] == 'left' || $this->infoTable[$i][$j]['alignment'] == 'center') {
282                    $output .= " ";
283                }
284                if ($this->infoTable[$i][$j]['type'] == 'header') {
285                    $output .= " ^";
286                } else {
287                    $output .= " |";
288                }
289                $colNum++;
290            }
291            $output .= "\n"; // End of a row.
292            $rowNum++;
293        }
294
295        //$dump = var_export($table, true);
296
297        return $output;
298    }
299
300    /**
301     * Put the value back in the cell. Substitute math where applicable.
302     */
303    private function insertCellData(mixed $cell, array &$columnData, array &$rowData, int $colNum, int $rowNum,)
304    {
305
306        // echo('<pre>');
307        // var_dump($columnData);
308        // echo('</pre>');
309
310        switch (trim($cell)) {
311            case '=SUM':
312                return '<span class="avmathtablevalue">' . number_format($columnData[$colNum]['sum'], $columnData[$colNum]['precision']) .'</span>';
313                break;
314            case '=ROW':
315                return '<span class="avmathtablevalue">' . number_format($rowData[$rowNum]['sum'], $rowData[$rowNum]['precision']) .'</span>';
316                break;
317            case '=TOT':
318                $temp = number_format($columnData[$colNum]['total'], $columnData[$colNum]['precision']);
319                $columnData[$colNum]['total'] = 0; // Reset to begin a new total.
320                return '<span class="avmathtablevalue">' . $temp .'</span>';
321                break;
322            case '=CNT':
323                return '<span class="avmathtablevalue">' . $columnData[$colNum]['count'] . '</span>';
324                break;
325            case '=AVG':
326                return '<span class="avmathtablevalue">' . number_format(round(($columnData[$colNum]['sum'] / $columnData[$colNum]['count']), $columnData[$colNum]['precision']+1), $columnData[$colNum]['precision']+1) . '</span>';
327                break;
328            case '=MAX':
329                return '<span class="avmathtablevalue">' . $columnData[$colNum]['max'] . '</span>';
330                break;
331            case '=MIN':
332                return '<span class="avmathtablevalue">' . $columnData[$colNum]['min'] . '</span>';
333                break;
334            default:
335                return $cell;
336        }
337    }
338
339    /**
340     * Count the decimal places after the period.
341     * Note that 50.00 gets treated as 50, so we need to count zeros separately and take the largest number.
342     */
343    private function countDecimalPlaces(mixed $num): int
344    {
345        // Number of 0s after the decimal:
346        $numZeros = 0;
347        if (strpos($num, '.') !== false) {
348            preg_match("/^(0+)/", explode('.', $num)[1], $matches);
349            $numZeros = strlen($matches[0]);
350        }
351
352        // Count number of significant digits after the decimal:
353        $fNumber = floatval($num);
354        for ($iDecimals = 0; $fNumber != round($fNumber, $iDecimals); $iDecimals++);
355        return max($iDecimals, $numZeros);
356    }
357} // End class
358