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