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