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