major refactoring of the whole table aggregation

this is still completely untested
This commit is contained in:
Andreas Gohr
2016-03-09 15:09:26 +01:00
parent 53ed31251c
commit 0799375673
5 changed files with 431 additions and 384 deletions

395
meta/AggregationTable.php Normal file
View File

@ -0,0 +1,395 @@
<?php
namespace plugin\struct\meta;
class AggregationTable {
/**
* @var string the page id of the page this is rendered to
*/
protected $id;
/**
* @var string the Type of renderer used
*/
protected $mode;
/**
* @var \Doku_Renderer the DokuWiki renderer used to create the output
*/
protected $renderer;
/**
* @var SearchConfig the configured search - gives access to columns etc.
*/
protected $searchConfig;
/**
* @var Column[] the list of columns to be displayed
*/
protected $columns;
/**
* @var Value[][] the search result
*/
protected $result;
/**
* @var int number of all results
*/
protected $resultCount;
/**
* @var array for summing up columns
*/
protected $sums;
/**
* @todo we might be able to get rid of this helper and move this to SearchConfig
* @var \helper_plugin_struct_config
*/
protected $helper;
/**
* Initialize the Aggregation renderer and executes the search
*
* You need to call @see render() on the resulting object.
*
* @param string $id
* @param string $mode
* @param \Doku_Renderer $renderer
* @param SearchConfig $searchConfig
*/
public function __construct($id, $mode, \Doku_Renderer $renderer, SearchConfig $searchConfig) {
$this->id = $id;
$this->mode = $mode;
$this->renderer = $renderer;
$this->searchConfig = $searchConfig;
$this->data = $searchConfig->getConf();
$this->columns = $searchConfig->getColumns();
$this->result = $this->searchConfig->execute();
$this->resultCount = $this->searchConfig->getCount();
$this->helper = plugin_load('helper', 'struct_config');
}
/**
* Create the table on the renderer
*/
public function render() {
// table open
$this->startScope();
$this->showActiveFilters();
$this->renderer->table_open();
// header
$this->renderer->tablethead_open();
$this->buildColumnHeaders();
$this->addDynamicFilters();
$this->renderer->tablethead_close();
if($this->resultCount) {
// actual data
$this->renderRows();
// footer
$this->summarize();
$this->addLimitControls();
} else {
// nothing found
$this->nullRow();
}
// table close
$this->renderer->table_close();
$this->finishScope();
}
/**
* Adds additional info to document and renderer in XHTML mode
*
* @see finishScope()
*/
protected function startScope() {
if($this->mode != 'xhtml') return;
// wrapping div
$this->renderer->doc .= "<div class=\"structaggregation\">";
// unique identifier for this aggregation
$this->renderer->info['struct_table_hash'] = md5(var_export($this->data, true));
}
/**
* Closes the table and anything opened in startScope()
*
* @see startScope()
*/
protected function finishScope() {
if($this->mode != 'xhtml') return;
// wrapping div
$this->renderer->doc .= '</div>';
// remove identifier from renderer again
if(isset($this->renderer->info['struct_table_hash'])) {
unset($this->renderer->info['struct_table_hash']);
}
}
/**
* Displays info about the currently applied filters
*/
protected function showActiveFilters() {
if($this->mode != 'xhtml') return;
$dynamic = $this->searchConfig->getDynamicParameters();
$filters = $dynamic->getFilters();
if(!$filters) return;
$fltrs = array();
foreach($filters as $column => $filter) {
list($comp, $value) = $filter;
if(strpos($comp, '~') !== false) {
if(strpos($comp, '!~') !== false) {
$comparator_value = '!~' . str_replace('%', '*', $value);
} else {
$comparator_value = '~' . str_replace('%', '', $value);
}
$fltrs[] = $column . $comparator_value;
} else {
$fltrs[] = $column . $comp . $value;
}
}
$this->renderer->doc .= '<div class="filter">';
$this->renderer->doc .= '<h4>' . sprintf($this->helper->getLang('tablefilteredby'), hsc(implode(' & ', $fltrs))) . '</h4>';
$this->renderer->doc .= '<div class="resetfilter">';
$this->renderer->internallink($this->id, $this->helper->getLang('tableresetfilter'));
$this->renderer->doc .= '</div>';
$this->renderer->doc .= '</div>';
}
/**
* Shows the column headers with links to sort by column
*/
protected function buildColumnHeaders() {
$this->renderer->tablerow_open();
// additional column for row numbers
if($this->data['rownumbers']) {
$this->renderer->tableheader_open();
$this->renderer->cdata('#');
$this->renderer->tableheader_close();
}
// show all headers
foreach($this->data['headers'] as $num => $header) {
$column = $this->columns[$num];
// use field label if no header was set
if(blank($header)) {
if(is_a($column, 'plugin\struct\meta\PageColumn')) {
$header = $this->helper->getLang('pagelabel'); // @todo this could be part of PageColumn::getTranslatedLabel
} else if(is_a($column, 'plugin\struct\meta\Column')) {
$header = $column->getTranslatedLabel();
} else {
$header = 'column ' . $num; // this should never happen
}
}
// simple mode first
if($this->mode != 'xhtml') {
$this->renderer->tableheader_open();
$this->renderer->cdata($header);
$this->renderer->tableheader_close();
continue;
}
// still here? create custom header for more flexibility
// width setting
$width = '';
if(isset($data['widths'][$num]) && $data['widths'][$num] != '-') {
$width = ' style="width: ' . $data['widths'][$num] . ';"';
}
// sort indicator and link
$sortclass = '';
$sorts = $this->searchConfig->getSorts();
$dynamic = $this->searchConfig->getDynamicParameters();
if(isset($sorts[$column->getFullQualifiedLabel()])) {
list(, $currentSort) = $sorts[$column->getFullQualifiedLabel()];
if($currentSort[1]) {
$sortclass = 'sort-down';
$dynamic->setSort($column, false);
} else {
$sortclass = 'sort-up';
}
}
$dynamic->setSort($column, true);
$link = wl($this->id, $dynamic->getURLParameters());
// output XHTML header
$this->renderer->doc .= "<th $width >";
$this->renderer->doc .= '<a href="' . $link . '" class="' . $sortclass . '" title="' . $this->helper->getLang('sort') . '">' . hsc($header) . '</a>';
$this->renderer->doc .= '</th>';
}
$this->renderer->tablerow_close();
}
/**
* Add input fields for dynamic filtering
*/
protected function addDynamicFilters() {
if($this->mode != 'xhtml') return;
if(!$this->data['dynfilters']) return;
$this->renderer->doc .= '<tr class="dataflt">';
// add extra column for row numbers
if($this->data['rownumbers']) {
$this->renderer->doc .= '<th></th>';
}
// each column gets a form
foreach($this->columns as $column) {
$this->renderer->doc .= '<th>';
{
$form = new \Doku_Form(array('method' => 'GET', 'action' => wl($this->id)));
// current value
$dynamic = $this->searchConfig->getDynamicParameters();
$filters = $dynamic->getFilters();
if(isset($filters[$column->getFullQualifiedLabel()])) {
list(, $current) = $filters[$column->getFullQualifiedLabel()];
$dynamic->removeFilter($column);
} else {
$current = '';
}
// Add current request params
$params = $dynamic->getURLParameters();
foreach($params as $key => $val) {
$form->addHidden($key, $val);
}
// add input field
$key = $column->getFullQualifiedLabel() . '*~';
$form->addElement(form_makeField('text', 'dataflt[' . $key . ']', $current, ''));
$this->renderer->doc .= $form->getForm();
}
$this->renderer->doc .= '</th>';
}
$this->renderer->doc .= '</tr>';
}
/**
* Display the actual table data
*/
protected function renderRows() {
$this->renderer->tabletbody_open();
foreach($this->result as $rownum => $row) {
$this->renderer->tablerow_open();
// row number column
if($this->data['rownumbers']) {
$this->renderer->tablecell_open();
$this->renderer->doc .= $rownum + 1;
$this->renderer->tablecell_close();
}
/** @var Value $value */
foreach($row as $colnum => $value) {
$this->renderer->tablecell_open();
$value->render($this->renderer, $this->mode);
$this->renderer->tablecell_close();
// summarize
if($this->data['summarize'] && is_numeric($value->getValue())) {
if(!isset($this->sums[$colnum])) {
$this->sums[$colnum] = 0;
}
$this->sums[$colnum] += $value->getValue();
}
}
$this->renderer->tablerow_close();
}
$this->renderer->tabletbody_close();
}
/**
* Renders an information row for when no results were found
*/
protected function nullRow() {
$this->renderer->tablerow_open();
$this->renderer->tablecell_open(count($this->data['cols']) + $this->data['rownumbers'], 'center');
$this->renderer->cdata($this->helper->getLang('none'));
$this->renderer->tablecell_close();
$this->renderer->tablerow_close();
}
/**
* Add sums if wanted
*/
protected function summarize() {
if($this->data['summarize']) return;
$this->renderer->tablerow_open();
$len = count($this->data['cols']);
if($this->data['rownumbers']) {
$this->renderer->tablecell_open();
$this->renderer->tablecell_close();
}
for($i = 0; $i < $len; $i++) {
$this->renderer->tablecell_open(1, $this->data['align'][$i]);
if(!empty($sums[$i])) {
$this->renderer->cdata('∑ ' . $sums[$i]);
} else {
if($this->mode == 'xhtml') {
$this->renderer->doc .= '&nbsp;';
}
}
$this->renderer->tablecell_close();
}
$this->renderer->tablerow_close();
}
/**
* Adds pagin controls to the table
*/
protected function addLimitControls() {
if(empty($this->data['limit'])) return;
if($this->mode != 'xhtml') ;
$this->renderer->tablerow_open();
$this->renderer->tableheader_open((count($this->data['cols']) + ($this->data['rownumbers'] ? 1 : 0)));
$offset = $this->data['offset'];
// prev link
if($offset) {
$prev = $offset - $this->data['limit'];
if($prev < 0) {
$prev = 0;
}
$dynamic = $this->searchConfig->getDynamicParameters();
$dynamic->setOffset($prev);
$link = wl($this->id, $dynamic->getURLParameters());
$this->renderer->doc .= '<a href="' . $link . '" class="prev">' . $this->helper->getLang('prev') . '</a>';
}
// next link
if($this->resultCount > $offset + $this->data['limit']) {
$next = $offset + $this->data['limit'];
$dynamic = $this->searchConfig->getDynamicParameters();
$dynamic->setOffset($next);
$link = wl($this->id, $dynamic->getURLParameters());
$this->renderer->doc .= '<a href="' . $link . '" class="next">' . $this->helper->getLang('next') . '</a>';
}
$this->renderer->tableheader_close();
$this->renderer->tablerow_close();
}
}

View File

@ -96,7 +96,16 @@ class Search {
$col = $this->findColumn($colname);
if(!$col) return; //FIXME do we really want to ignore missing columns?
$this->sortby[] = array($col, $asc);
$this->sortby[$col->getFullQualifiedLabel()] = array($col, $asc);
}
/**
* Returns all set sort columns
*
* @return array
*/
public function getSorts() {
return $this->sortby;
}
/**

View File

@ -45,11 +45,11 @@ class SearchConfig extends Search {
$this->addSort($sort[0], $sort[1] === 'ASC');
}
if (!empty($config['limit'])) {
if(!empty($config['limit'])) {
$this->setLimit($config['limit']);
}
if (!empty($config['offset'])) {
if(!empty($config['offset'])) {
$this->setLimit($config['offset']);
}
}
@ -66,24 +66,10 @@ class SearchConfig extends Search {
}
/**
* Access the current config.
*
* When no key is given the whole configuration is returned. With a key only
* that key's value is returned. Returns NULL on a non-existing key
*
* @param string $key
* @return mixed
* @return array the current config
*/
public function getConf($key='') {
if($key) {
if(isset($this->config[$key])) {
return $this->config[$key];
} else {
return null;
}
} else {
public function getConf() {
return $this->config;
}
}
}

View File

@ -102,6 +102,9 @@ class SearchConfigParameters {
/**
* Adds another filter
*
* When there is a filter for that column already, the new filter overwrites it. Setting a
* blank value is the same as calling @see removeFilter()
*
* @param string|Column $column
* @param string $comp the comparator
* @param string $value the value to compare against
@ -110,8 +113,12 @@ class SearchConfigParameters {
$column = $this->resolveColumn($column);
if(!$column) return;
if(trim($value) === '') {
$this->removeFilter($column);
} else {
$this->filters[$column] = array($comp, $value);
}
}
/**
* Removes the filter for the given column
@ -131,6 +138,13 @@ class SearchConfigParameters {
$this->filters = array();
}
/**
* @return array the current filters
*/
public function getFilters() {
return $this->filters;
}
/**
* Get the current parameters in a form that can be used to create URLs
*/

View File

@ -6,12 +6,13 @@
* @author Andreas Gohr, Michael Große <dokuwiki@cosmocode.de>
*/
// must be run within Dokuwiki
use plugin\struct\meta\Column;
use plugin\struct\meta\AggregationTable;
use plugin\struct\meta\ConfigParser;
use plugin\struct\meta\SearchConfig;
use plugin\struct\meta\StructException;
// must be run within Dokuwiki
if (!defined('DOKU_INC')) die();
class syntax_plugin_struct_table extends DokuWiki_Syntax_Plugin {
@ -68,8 +69,6 @@ class syntax_plugin_struct_table extends DokuWiki_Syntax_Plugin {
}
}
protected $sums = array();
/**
* Render xhtml output or metadata
*
@ -80,379 +79,23 @@ class syntax_plugin_struct_table extends DokuWiki_Syntax_Plugin {
*/
public function render($mode, Doku_Renderer $renderer, $data) {
if(!$data) return false;
global $ID;
if($mode == 'metadata') {
/** @var Doku_Renderer_metadata $renderer */
$renderer->meta['plugin']['struct']['hasaggregation'] = true;
}
//reset counters
$this->sums = array();
try {
$search = new SearchConfig($data);
$data = $search->getConf();
$rows = $search->execute();
$cnt = $search->getCount();
$cols = $search->getColumns();
if ($cnt === 0) {
$this->nullList($data, $mode, $renderer, $cols);
return true;
}
$this->renderPreTable($mode, $renderer, $data, $cols);
$this->renderRows($mode, $renderer, $data, $rows);
$this->renderPostTable($mode, $renderer, $data, $cnt);
$table = new AggregationTable($ID, $mode, $renderer, $search);
$table->render();
} catch (StructException $e) {
msg($e->getMessage(), -1, $e->getLine(), $e->getFile());
}
return true;
}
/**
* create the pretext to the actual table rows
*
* @param string $mode
* @param Doku_Renderer $renderer
* @param array $data the configuration data
* @param Column[] $cols
*/
protected function renderPreTable($mode, Doku_Renderer $renderer, $data, $cols) {
$this->startScope($mode, $renderer, md5(serialize($data)));
$this->showActiveFilters($mode, $renderer);
$this->startTable($mode, $renderer);
$renderer->tablethead_open();
$this->buildColumnHeaders($mode, $renderer, $data, $cols);
$this->addDynamicFilters($mode, $renderer, $data);
$renderer->tablethead_close();
}
/**
* @param string $mode current render mode
* @param Doku_Renderer $renderer
* @param array $data
* @param int $rowcnt
* @return string
*/
private function renderPostTable($mode, Doku_Renderer $renderer, $data, $rowcnt) {
$this->summarize($mode, $renderer, $data, $this->sums);
$this->addLimitControls($mode, $renderer, $data, $rowcnt);
$this->finishTableAndScope($mode, $renderer);
}
/**
* if limit was set, add control
*
* @param string $mode the mode of the renderer
* @param Doku_Renderer $renderer the renderer
* @param array $data the configuration of the table/search
* @param $rowcnt
*/
protected function addLimitControls($mode, Doku_Renderer $renderer, $data, $rowcnt) {
global $ID;
if($data['limit']) {
$renderer->tablerow_open();
$renderer->tableheader_open((count($data['cols']) + ($data['rownumbers'] ? 1 : 0)));
$offset = (int) $_REQUEST['dataofs'];
// keep url params
$params = array();
if (!empty($data['current_params']['dataflt'])) {$params['dataflt'] = $data['current_params']['dataflt'];}
if (!empty($data['current_params']['datasrt'])) {$params['datasrt'] = $data['current_params']['datasrt'];}
if($offset) {
$prev = $offset - $data['limit'];
if($prev < 0) {
$prev = 0;
}
$params['dataofs'] = $prev;
$renderer->internallink($ID . '?' . http_build_query($params), $this->getLang('prev'));
}
if($rowcnt > $offset + $data['limit']) {
$next = $offset + $data['limit'];
$params['dataofs'] = $next;
$renderer->internallink($ID . '?' . http_build_query($params), $this->getLang('next'));
}
$renderer->tableheader_close();
$renderer->tablerow_close();
}
}
/**
* @param string $mode the mode of the renderer
* @param Doku_Renderer $renderer the renderer
*/
protected function showActiveFilters($mode, Doku_Renderer $renderer) {
global $ID;
if($mode == 'xhtml' && !empty($data['current_params']['dataflt'])) {
$filters = $data['current_params']['dataflt'];
/** @var helper_plugin_struct_config $confHelper */
$confHelper = $this->loadHelper('struct_config');
$fltrs = array();
foreach($filters as $colcomp => $filter) {
$filter = $confHelper->parseFilterLine('', $colcomp.$filter);
if(strpos($filter[1], '~') !== false) {
if(strpos($filter[1], '!~') !== false) {
$comparator_value = '!~' . str_replace('%', '*', $filter[2]);
} else {
$comparator_value = '~' . str_replace('%', '', $filter[2]);
}
$fltrs[] = $filter[0] . $comparator_value;
} else {
$fltrs[] = $filter[0] . $filter[1] . $filter[2];
}
}
$renderer->doc .= '<div class="filter">';
$renderer->doc .= '<h4>' . sprintf($this->getLang('tablefilteredby'), hsc(implode(' & ', $fltrs))) . '</h4>';
$renderer->doc .= '<div class="resetfilter">';
$renderer->internallink($ID, $this->getLang('tableresetfilter'));
$renderer->doc .= '</div>';
$renderer->doc .= '</div>';
}
}
/**
* @param string $mode the mode of the renderer
* @param Doku_Renderer $renderer the renderer
* @param array $data the configuration of the table/search
*/
protected function addDynamicFilters($mode, Doku_Renderer $renderer, $data) {
if ($mode != 'xhtml') return;
global $conf, $ID;
$cur_params = $data['current_params'];
$html = '';
if($data['dynfilters']) {
$html .= '<tr class="dataflt">';
if($data['rownumbers']) {
$html .= '<th></th>';
}
foreach($data['headers'] as $num => $head) {
$html .= '<th>';
$form = new Doku_Form(array('method' => 'GET',));
$form->_hidden = array();
if(!$conf['userewrite']) {
$form->addHidden('id', $ID);
}
$key = $data['cols'][$num] . '*~';
$val = isset($cur_params['dataflt'][$key]) ? $cur_params['dataflt'][$key] : '';
// Add current request params
if (!empty($cur_params['datasrt'])) {
$form->addHidden('datasrt', $cur_params['datasrt']);
}
if (!empty($cur_params['dataofs'])) {
$form->addHidden('dataofs', $cur_params['dataofs']);
}
if (!empty($cur_params['dataflt'])) foreach($cur_params['dataflt'] as $c_key => $c_val) {
if($c_val !== '' && $c_key !== $key) {
$form->addHidden('dataflt[' . $c_key . ']', $c_val);
}
}
$form->addElement(form_makeField('text', 'dataflt[' . $key . ']', $val, ''));
$html .= $form->getForm();
$html .= '</th>';
}
$html .= '</tr>';
$renderer->doc .= $html;
}
}
/**
* @param string $mode the mode of the renderer
* @param Doku_Renderer $renderer the renderer
*/
private function startTable($mode, Doku_Renderer $renderer) {
$renderer->table_open();
}
/**
* @param string $mode the mode of the renderer
* @param Doku_Renderer $renderer the renderer
* @param array $data the configuration of the table/search
* @param Column[] $cols
*/
protected function buildColumnHeaders($mode, Doku_Renderer $renderer, $data, $cols) {
global $ID;
$renderer->tablerow_open();
if($data['rownumbers']) {
$renderer->tableheader_open();
$renderer->cdata('#');
$renderer->tableheader_close();
}
foreach($data['headers'] as $num => $head) {
$ckey = $data['cols'][$num];
if(blank($head)) {
if(isset($cols[$num]) && is_a($cols[$num], 'plugin\struct\meta\PageColumn')) {
$head = $this->getLang('pagelabel');
}else if(isset($cols[$num]) && is_a($cols[$num], 'plugin\struct\meta\Column')) {
$head = $cols[$num]->getTranslatedLabel();
} else {
$head = 'column '.$num; // this should never happen
}
}
$width = '';
if(isset($data['widths'][$num]) AND $data['widths'][$num] != '-') {
$width = ' style="width: ' . $data['widths'][$num] . ';"';
}
if ($mode == 'xhmtl') {
$renderer->doc .= '<th' . $width . '>';
} else {
$renderer->tableheader_open();
}
// output header
if ($mode == 'xhtml') {
$sort = '';
if(isset($data['sort']) && $ckey == $data['sort'][0]) {
if($data['sort'][1] == 'ASC') {
$sort = 'sort-down';
$ckey = '^' . $ckey;
} else {
$sort = 'sort-up';
}
}
$params = $data['current_params'];
$params['datasrt'] = $ckey;
$link = wl($ID, $params);
$renderer->doc .= '<a href="'.$link.'" class="'.$sort.'" title="'.$this->getLang('sort').'">'.hsc($head).'</a>';
} else {
$renderer->cdata($head);
}
$renderer->tableheader_close();
}
$renderer->tablerow_close();
}
/**
* @param string $mode the mode of the renderer
* @param Doku_Renderer $renderer the renderer
* @param string $hash hash to identify the table and group images in gallery
*/
protected function startScope($mode, \Doku_Renderer $renderer, $hash) {
if ($mode == 'xhtml') {
$renderer->doc .= "<div class=\"structaggregation\">";
$renderer->info['struct_table_hash'] = $hash;
}
}
/**
* if summarize was set, add sums
*
* @param string $mode the mode of the renderer
* @param Doku_Renderer $renderer the renderer
* @param array $data the configuration of the table/search
* @param array $sums the summarized output of the numerical fields
*/
private function summarize($mode, \Doku_Renderer $renderer, $data, $sums) {
if($data['summarize']) {
$renderer->tablerow_open();
$len = count($data['cols']);
if($data['rownumbers']) {
$renderer->tablecell_open();
$renderer->tablecell_close();
}
for($i = 0; $i < $len; $i++) {
$renderer->tablecell_open(1, $data['align'][$i]);
if(!empty($sums[$i])) {
$renderer->cdata('∑ ' . $sums[$i]);
} else {
if ($mode == 'xhtml') {
$renderer->doc .= '&nbsp;';
}
}
$renderer->tablecell_close();
}
$renderer->tablerow_close();
}
}
/**
* @param string $mode the mode of the renderer
* @param Doku_Renderer $renderer the renderer
*
*/
private function finishTableAndScope($mode, Doku_Renderer $renderer) {
$renderer->table_close();
if ($mode == 'xhtml') {
$renderer->doc .= '</div>';
if(isset($renderer->info['struct_table_hash'])) {
unset($renderer->info['struct_table_hash']);
}
}
}
/**
* @param string $mode the mode of the renderer
* @param Doku_Renderer $renderer the renderer
* @param array $data the configuration of the table/search
* @param $rows
*
*/
private function renderRows($mode, Doku_Renderer $renderer, $data, $rows) {
$renderer->tabletbody_open();
foreach($rows as $rownum => $row) {
$renderer->tablerow_open();
if($data['rownumbers']) {
$renderer->tablecell_open();
$renderer->doc .= $rownum + 1;
$renderer->tablecell_close();
}
/** @var plugin\struct\meta\Value $value */
foreach($row as $colnum => $value) {
$renderer->tablecell_open();
$value->render($renderer, $mode);
$renderer->tablecell_close();
// summarize
if($data['summarize'] && is_numeric($value->getValue())) {
if(!isset($this->sums[$colnum])) {
$this->sums[$colnum] = 0;
}
$this->sums[$colnum] += $value->getValue();
}
}
$renderer->tablerow_close();
}
$renderer->tabletbody_close();
}
/**
* @param array $data the configuration of the table/search
* @param string $mode the mode of the renderer
* @param Doku_Renderer $renderer the renderer
* @param Column[] $cols
*/
private function nullList($data, $mode, Doku_Renderer $renderer, $cols) {
$this->renderPreTable($mode, $renderer, $data, $cols);
$renderer->tablerow_open();
$renderer->tablecell_open(count($data['cols']) + $data['rownumbers'], 'center');
$renderer->cdata($this->getLang('none'));
$renderer->tablecell_close();
$renderer->tablerow_close();
$renderer->table_close();
}
}
// vim:ts=4:sw=4:et: