Merge branch 'master' of /home/git/private/web.mtrack
[web.mtrack] / MTrack / Wiki / HTMLFormatter.php
1 <?php
2
3 require_once 'MTrack/Wiki.php';
4 require_once 'MTrack/Wiki/Parser.php';
5 //require_once 'MTrack/Milestone.php';
6 require_once 'MTrack/Interface/WikiLinkHandler.php';
7
8 class MTrack_Wiki_HTMLFormatter
9 {
10     var $parser;
11     var $out;
12     var $in_table_row;
13     var $table_row_count = 0;
14     var $open_tags;
15     var $list_stack;
16     var $quote_stack;
17     var $tabstops;
18     var $in_code_block;
19     var $in_table;
20     var $in_def_list;
21     var $in_table_cell;
22     var $paragraph_open;
23     static $linkHandler; // the link handler.. (used with register...)
24   
25
26     function __construct() 
27     {
28         $this->parser = new MTrack_Wiki_Parser;
29     }
30     
31     static function registerLinkHandler(MTrack_Interface_WikiLinkHandler $li)
32     {
33         self::$linkHandler = $li;
34     }
35
36
37   function reset() {
38     $this->open_tags = array();
39     $this->list_stack = array();
40     $this->quote_stack = array();
41     $this->tabstops = array();
42     $this->in_code_block = 0;
43     $this->in_table = false;
44     $this->in_def_list = false;
45     $this->in_table_cell = false;
46     $this->paragraph_open = false;
47   }
48
49   function _apply_rules($line) {
50     $rules = $this->parser->get_rules();
51     /* slightly tricky bit of code here, because preg_replace_callback
52      * doesn't seem to support named groups */
53     $matches = array();
54     if (preg_match_all($rules, $line, $matches, PREG_OFFSET_CAPTURE)) {
55       $repl = array();
56       foreach ($matches as $key => $info) {
57         if (is_string($key)) {
58           foreach ($info as $nmatch => $item) {
59             if (!is_array($item)) {
60               continue;
61             }
62             $match = $item[0];
63             $offset = $item[1];
64
65             if (strlen($match) && $offset >= 0) {
66               if ($match[0] == '!') {
67                 $repl[$offset] = array(null, $match, null);
68               } else {
69                 $func = '_' . $key . '_formatter';
70                 if (method_exists($this, $func)) {
71                   $repl[$offset] = array($func, $match, $nmatch);
72                 } else {
73                   @$this->missing[$func]++;
74                 }
75               }
76             }
77           }
78         }
79       }
80       if (count($repl)) {
81         /* order matches by match offset */
82         ksort($repl);
83         /* and now we can generate for each fragment */
84         $sol = 0;
85         foreach ($repl as $offset => $bits) {
86           list($func, $match, $nmatch) = $bits;
87
88           if ($offset > $sol) {
89             /* emit verbatim */
90             //              $this->out .= "Copying from $sol to $offset\n";
91             $this->out .= substr($line, $sol, $offset - $sol);
92           }
93
94           if ($func === null) {
95             $this->out .= htmlspecialchars(substr($match, 1),
96                             ENT_COMPAT, 'utf-8');
97           } else {
98             //              $this->out .= "invoking $func on $match of len " . strlen($match) . "\n";
99             //print_r($matches);
100             //              $this->out .= var_export($matches, true) . "\n";
101             $this->$func($match, $matches, $nmatch);
102           }
103
104           $sol = $offset + strlen($match);
105         }
106         $this->out .= substr($line, $sol);
107         $result = '';
108       } else {
109         $result = $line;
110       }
111     } else {
112       $result = $line;
113     }
114     return $result;
115   }
116
117   function format($text, $escape_newlines = false) {
118     $this->out = '';
119     $this->reset();
120     foreach (preg_split("!\r?\n!", $text) as $line) {
121       if ($this->in_code_block || trim($line) == MTrack_Wiki_Parser::STARTBLOCK) {
122         $this->handle_code_block($line);
123         continue;
124       }
125       if (!strncmp($line, "----", 4)) {
126         $this->close_table();
127         $this->close_paragraph();
128         $this->close_indentation();
129         $this->close_list();
130         $this->close_def_list();
131         $this->out .= "<hr />\n";
132         continue;
133       }
134       if (strlen($line) == 0) {
135         $this->close_paragraph();
136         $this->close_indentation();
137         $this->close_list();
138         $this->close_def_list();
139         $this->close_table();
140         continue;
141       }
142       if (strncmp($line, "||", 2)) {
143         // Doesn't look like a valid table row line, so break any || in the line
144         $line = str_replace("||", "|", $line);
145       }
146       // Tag expansion and clear tabstops if no indent
147       $line = str_replace("\t", "        ", $line);
148       if ($line[0] != ' ') {
149         $this->tabstops = array();
150       }
151
152       $this->in_list_item = false;
153       $this->in_quote = false;
154
155       $save = $this->out;
156       $this->out = '';
157       $result = $this->_apply_rules($line);
158       $newbit = $this->out;
159       $this->out = $save;
160       if (!($this->in_list_item || $this->in_def_list || $this->in_table)) {
161         $this->open_paragraph();
162       }
163
164       if (!$this->in_list_item) {
165         $this->close_list();
166       }
167       if (!$this->in_quote) {
168         $this->close_indentation();
169       }
170       if ($this->in_def_list && $line[0] != ' ') {
171         $this->close_def_list();
172       }
173       if ($this->in_table && strncmp(ltrim($line), '||', 2)) {
174         $this->close_table();
175       }
176       $this->out .= $newbit;
177       $sep = "\n";
178       if (!($this->in_list_item || $this->in_def_list || $this->in_table)) {
179         if (strlen($result)) {
180           $this->open_paragraph();
181         }
182         if ($escape_newlines && !preg_match(",<br />\s*$,", $line)) {
183           $sep = "<br />\n";
184         }
185       }
186       $this->out .= $result . $sep;
187       $this->close_table_row();
188     }
189     $this->close_table();
190     $this->close_paragraph();
191     $this->close_indentation();
192     $this->close_list();
193     $this->close_def_list();
194     $this->close_code_blocks();
195   }
196
197   function _parse_heading($match, $info, $nmatch, $shorten) {
198     $match = trim($match);
199     $depth = min(strlen($info['hdepth'][$nmatch][0]), 5);
200     if (isset($info['hanchor']) && is_array($info['hanchor'])
201         && is_array($info['hanchor'][$nmatch])
202         && strlen($info['hanchor'][$nmatch][0])) {
203       $anchor = $info['hanchor'][$nmatch][0];
204     } else {
205       $anchor = '';
206     }
207     $heading_text = substr($match, $depth+1, - $depth - 1 - strlen($anchor));
208     $heading = MTrack_Wiki::format_to_oneliner($heading_text);
209     if ($anchor) {
210       $anchor = substr($anchor, 1);
211     } else {
212       $anchor = preg_replace("/[^\w:.-]+/", "", $heading_text);
213       if (ctype_digit($anchor[0])) {
214         $anchor = 'a' . $anchor;
215       }
216     }
217     return array($depth, $heading, $anchor);
218   }
219
220   function _heading_formatter($match, $info, $nmatch) {
221     $this->close_table();
222     $this->close_paragraph();
223     $this->close_indentation();
224     $this->close_list();
225     $this->close_def_list();
226     list($depth, $heading, $anchor) = 
227       $this->_parse_heading($match, $info, $nmatch, false);
228     
229     $this->out .= sprintf('<h%d id="%s"><a class="wiki" name="%s">%s</a></h%d>',
230       $depth, $anchor, $anchor, $heading, $depth);
231   }
232
233   function tag_open_p($tag) {
234     /* do we currently have any open tag with $tag as end-tag? */
235     return in_array($tag, $this->open_tags);
236   }
237
238   function open_tag($open_tag, $close_tag) {
239     $this->open_tags[] = array($open_tag, $close_tag);
240   }
241
242   function simple_tag_handler($match, $open_tag, $close_tag) {
243     if ($this->tag_open_p(array($open_tag, $close_tag))) {
244       $this->out .= $this->close_tag($close_tag);
245       return;
246     }
247     $this->open_tag($open_tag, $close_tag);
248     $this->out .= $open_tag;
249   }
250
251   function close_tag($tag) {
252     $tmp = '';
253     /* walk backwards until we find the tag, closing out
254      * as we go */
255     $keys = array_reverse(array_keys($this->open_tags));
256     foreach ($keys as $k) {
257       $pair = $this->open_tags[$k];
258       $tmp .= $pair[1];
259       if ($pair[1] == $tag) {
260         unset($this->open_tags[$k]);
261         foreach ($this->open_tags as $k2 => $pair) {
262           if ($k2 == $k) {
263             break;
264           }
265           $tmp .= $pair[0];
266         }
267         break;
268       }
269     }
270     return $tmp;
271   }
272
273   function _bolditalic_formatter($match, $info) {
274     $italic = array('<i>', '</i>');
275     $open = $this->tag_open_p($italic);
276     $tmp = '';
277     if ($open) {
278       $this->out .= $italic[1];
279       $this->close_tag($italic[1]);
280     }
281     $this->_bold_formatter($match, $info);
282     if (!$open) {
283       $this->out .= $italic[0];
284       $this->open_tag($italic[0], $italic[1]);
285     }
286   }
287
288   function _bold_formatter($match, $info) {
289     $this->simple_tag_handler($match, '<strong>', '</strong>');
290   }
291   function _italic_formatter($match, $info) {
292     $this->simple_tag_handler($match, '<i>', '</i>');
293   }
294   function _underline_formatter($match, $info) {
295     $this->simple_tag_handler($match,
296       '<span class="underline">', '</span>');
297   }
298   function _strike_formatter($match, $info) {
299     $this->simple_tag_handler($match, '<del>', '</del>');
300   }
301   function _subscript_formatter($match, $info) {
302     $this->simple_tag_handler($match, '<sub>', '</sub>');
303   }
304   function _superscript_formatter($match, $info) {
305     $this->simple_tag_handler($match, '<sup>', '</sup>');
306   }
307
308   function _email_formatter($match, $info) {
309     $this->out .= "<a href=\"mailto:" . 
310       htmlspecialchars($match, ENT_QUOTES, 'utf-8') .
311       "\">" . htmlspecialchars($match, ENT_COMPAT, 'utf-8') . "</a>";
312   }
313
314   function _htmlspecialcharsape_formatter($match, $info) {
315     $this->out .= htmlspecialchars($match, ENT_QUOTES, 'utf-8');
316   }
317
318   
319   
320   function _make_link($ns, $target, $match, $label) {
321     
322     if ($label[0] == '"' || $label[0] == "'") {
323         $label = substr($label, 1, -1);
324     }
325     if (preg_match('/^(.*)#(.*)$/', $target, $M)) {
326         $target = $M[1];
327         $anchor = $M[2];
328     } else {
329         $anchor = null;
330     }
331
332     if (strlen($ns)) {
333
334           /* special cases */
335         if ($ns == 'ticket' && 
336               (strpos($target, '-') !== false || strpos($target, ',') !== false)) {
337             /* ranged ticket query */
338             $ns = 'query';
339             $target = 'id=' . $target;
340         }
341
342         switch ($ns) {
343             case 'ticket':
344                 $this->out .= self::$linkHandler->ticket($target, array(
345                     'display' => $label,
346                     '#' => $anchor,
347                 ));
348                 return;
349
350             case 'changeset':
351                 if (strpos($target, ',') !== false) {
352                     list($repo, $cs) = explode(',', $target, 2);
353                     $this->out .= self::$linkHandler->changeset($cs, $repo);
354                     return;
355                 } 
356                 $this->out .= self::$linkHandler->changeset($target);
357                 return;
358
359             case 'milestone':
360                 $this->out .= self::$linkHandler->milestone($target);
361                 return;
362
363             case 'wiki':
364                 $this->out .= self::$linkHandler->wiki($target, array(
365                     '#' => $anchor,
366                     'display' => $label
367                 ));
368                 return;
369
370             case 'help':
371               $this->out .= self::$linkHandler->help($target,$label,$anchor);
372               return;
373
374             case 'user':
375               $this->out .= self::$linkHandler->username($target);
376               return;
377
378             case 'repo':
379               $this->out .= self::$linkHandler->browse($target,$label);
380               return;
381              
382             case 'log':
383                 
384                 //if ($target == '/') {
385                 //    $target = MTrack_Repo::defaultRepo(
386                 //        empty($ff->MTrack['default_repo']) ? null : $ff->MTrack['default_repo']
387                 //    ); ///???
388                 //}
389                 $this->out .= $this->log($target, $label);
390                 break;
391
392             case 'query':
393             case 'report':
394                 $this->out .= self::$linkHandler->{$ns}($target,$label);
395                 return;
396             
397             case 'source':
398                 // wiki should not know about repo's.. -
399                 
400                 @list($file, $rev) = explode('#', $target, 2);
401                 $file = ltrim($file, '/');
402                   /* some legacy handling here; there are three cases:
403                    * owner/repo/path -> repo = owner/repo
404                    * repo/path       -> repo = default/repo
405                    * path            -> repo = config.ini default repo
406                    */
407                 $bits = explode('/', $file);
408                 $repo = null;
409                  
410                  
411                 //if (count($bits) > 2) {
412                     /* maybe owner/repo */
413                     
414                     //$repo = MTrack_Repo::loadByName($bits[0] . '/' . $bits[1]);
415                     //if ($repo) {
416                     //  $repo = $repo->getBrowseRootName();
417                     //}
418                 //}
419                 //if ($repo === null && count($bits) > 1) {
420                     //$repo = MTrack_Repo::loadByName('default/' . $bits[0]);
421                     //if ($repo) {
422                     //  $repo = $repo->getBrowseRootName();
423                     //  array_unshift($bits, 'default');
424                     //}
425                 //}
426                 //if ($repo === null) {
427                     //$target = MTrack_Repo::defaultRepo(
428                     //        empty($ff->MTrack['default_repo']) ? null : $ff->MTrack['default_repo']
429                     //    ); ///???
430                     //if ($defrep) {
431                     //  if (strpos($defrep, '/') === false) {
432                     //    $defrep = "default/$defrep";
433                     //  }
434                   //    $repo = MTrack_Repo::loadByName($defrep);
435                     //  if ($repo) {
436                     //    $repo = $repo->getBrowseRootName();
437                     //    array_unshift($bits, $repo);
438                     //  }
439                     //}
440                 //}
441                 $file = join($bits, '/');
442                 $out .= self::$linkHandler->file($file . ($rev ? '@'. $rev : ''));
443                 return;
444                 
445                 
446             case 'comment':
447               if (preg_match('/^(\d+):ticket:(.*)$/', $target, $M)) {
448                 $this->out .= self::$linkHandler->ticket($M[2], 
449                   array(
450                     '#' => 'comment:' . $M[1],
451                     'display' => $label
452                   )
453                 );
454                 return;
455               }
456               $this->out .= '<a href="#comment:'. htmlspecialchars($target). '">' .htmlspecialchars($label). '</a>';
457               return;
458
459             case 'http':
460                 if (strlen($anchor)) {
461                     $target .= "#$anchor";
462                 }
463                 $this->out .= '<a href="http:'. htmlspecialchars($target). '">' .htmlspecialchars($label). '</a>';
464                 return;
465             case 'file': // not sure if this should be supported...
466                 $this->out .= '<span class="file-link">' .htmlspecialchars($label). '</a>';
467                 return;
468             
469             default:
470                 $this->out .=   htmlspecialchars($label);
471                 return;
472               throw new Exception("unknown target " . $ns);
473               $target = "$ns:$target";
474               if (strlen($anchor)) {
475                 $target .= "#$anchor";
476               }
477               break;
478           }
479     }
480     
481   }
482   
483    
484   function _ticket_formatter($match, $info, $nmatch) {
485     $ticket = substr($match, 1);
486     $this->_make_link('ticket', $ticket, $ticket, $match);
487   }
488
489   function _report_formatter($match, $info, $nmatch) {
490     $ticket = substr($match, 1, -1);
491     $this->_make_link('report', $ticket, $ticket, $match);
492   }
493
494   function _svnchangeset_formatter($match, $info, $nmatch) {
495     $rev = substr($match, 1, -1);
496     $this->_make_link('changeset', $rev, $rev, $match);
497   }
498
499   function _wikipagename_formatter($match, $info, $nmatch) {
500     $this->_make_link('wiki', $match, $match, $match);
501   }
502   function _wikipagenamewithlabel_formatter($match, $info, $nmatch) {
503     $match = substr($match, 1, -1);
504     list($page, $label) = explode(" ", $match, 2);
505     $label = $this->_unquote(trim($label));
506     $this->_make_link('wiki', $page, $match, $label);
507   }
508
509
510   function _shref_formatter($match, $info, $nmatch) {
511     $ns = $info['sns'][$nmatch][0];
512     $target = $this->_unquote($info['stgt'][$nmatch][0]);
513     $shref = $info['shref'][$nmatch][0];
514     $this->_make_link($ns, $target, $match, $match);
515   }
516
517   function _lhref_formatter($match, $info, $nmatch) {
518     $rel = $info['rel'][$nmatch][0];
519     $ns = $info['lns'][$nmatch][0];
520     $target = $info['ltgt'][$nmatch][0];
521     $label = isset($info['label'][$nmatch][0]) ? $info['label'][$nmatch][0] : '';
522
523 //    var_dump($rel, $ns, $target, $label);
524
525     if (!strlen($label)) {
526       /* [http://target] or [wiki:target] */
527       if (strlen($target)) {
528         if (!strncmp($target, "//", 2)) {
529           /* for [http://target], label is http://target */
530           $label = "$ns:$target";
531         } else {
532           /* for [wiki:target], label is target */
533           $label = $target;
534         }
535       } else {
536         /* [search:] */
537         $label = $ns;
538       }
539     } else {
540       $label = $this->_unquote($label);
541     }
542     if (strlen($rel)) {
543       list($path, $query, $frag) = $this->split_link($rel);
544       if (!strncmp($path, '//', 2)) {
545         $path = '/' . ltrim($path, '/');
546       } elseif ($path[0] == '/') {
547         $path = $GLOBALS['ABSWEB'] . substr($path, 1);
548       }
549       $target = $path;
550       if (strlen($query)) {
551         $target .= "?$query";
552       }
553       if (strlen($frag)) {
554         $target .= "#$frag";
555       }
556       $this->out .= "<a href=\"$target\">$label</a>";
557     } else {
558       $this->_make_link($ns, $target, $match, $label);
559     }
560   }
561
562   function _inlinecode_formatter($match, $info, $nmatch) {
563     $this->out .= "<tt>" . 
564       nl2br(htmlspecialchars($info['inline'][$nmatch][0],
565         ENT_COMPAT, 'utf-8')) .
566         "</tt>";
567   }
568   function _inlinecode2_formatter($match, $info, $nmatch) {
569     $this->out .= "<tt>" . 
570       nl2br(htmlspecialchars($info['inline2'][$nmatch][0],
571         ENT_COMPAT, 'utf-8')) .
572         "</tt>";
573   }
574
575   function _macro_formatter($match, $info, $nmatch) {
576     $name = $info['macroname'][$nmatch][0];
577     if (!strcasecmp($name, 'br')) {
578       $this->out .= "<br />";
579       return;
580     }
581     if (isset(MTrack_Wiki::$macros[$name])) {
582       $args = explode(',', $info['macroargs'][$nmatch][0]);
583       $this->out .= call_user_func_array(MTrack_Wiki::$macros[$name], $args);
584     } else {
585       $this->out .= "<tt>" . 
586         htmlspecialchars($match, ENT_QUOTES, 'utf-8') . "</tt>";
587     }
588   }
589
590
591   function split_link($target) {
592     @list($query, $frag) = explode('#', $target, 2);
593     @list($target, $query) = explode('?', $query, 2);
594     return array($target, $query, $frag);
595   }
596
597   function _unquote($text) {
598     return preg_replace("/^(['\"])(.*)(\\1)$/", "\\2", $text);
599   }
600
601   function close_list() {
602     $this->_set_list_depth(0, null, null, null);
603   }
604
605   private function _get_list_depth() {
606     // Return the space offset associated to the deepest opened list
607     if (count($this->list_stack)) {
608       $e = end($this->list_stack);
609       return $e[1];
610     }
611     return 0;
612   }
613
614   private function _open_list($depth, $new_type, $list_class, $start) {
615     $this->close_table();
616     $this->close_paragraph();
617     $this->close_indentation();
618     $this->list_stack[] = array($new_type, $depth);
619     $this->_set_tab($depth);
620     if ($list_class) {
621       $list_class = "wikilist $list_class";
622     } else {
623       $list_class = "wikilist";
624     }
625     $class_attr = $list_class ? sprintf(' class="%s"', $list_class) : '';
626     $start_attr = $start ? sprintf(' start="%s"', $start) : '';
627     $this->out .= "<$new_type$class_attr$start_attr><li>";
628   }
629   private function _close_list($type) {
630     array_pop($this->list_stack);
631     $this->out .= "</li></$type>";
632   }
633
634   private function _set_list_depth($depth, $new_type, $list_class, $start) {
635     if ($depth > $this->_get_list_depth()) {
636       $this->_open_list($depth, $new_type, $list_class, $start);
637       return;
638     }
639     while (count($this->list_stack)) {
640       list($deepest_type, $deepest_offset) = end($this->list_stack);
641       if ($depth >= $deepest_offset) {
642         break;
643       }
644       $this->_close_list($deepest_type);
645     }
646     if ($depth > 0) {
647       if (count($this->list_stack)) {
648         list($old_type, $old_offset) = end($this->list_stack);
649         if ($new_type && $new_type != $old_type) {
650           $this->_close_list($old_type);
651           $this->_open_list($depth, $new_type, $list_class, $start);
652         } else {
653           if ($old_offset != $depth) {
654             array_pop($this->list_stack);
655             $this->list_stack[] = array($old_type, $depth);
656           }
657           $this->out .= "</li><li>";
658         }
659       } else {
660         $this->_open_list($depth, $new_type, $list_class, $start);
661       }
662     }
663   }
664
665   function close_indentation() {
666     $this->_set_quote_depth(0);
667   }
668
669   private function _get_quote_depth() {
670     // Return the space offset associated to the deepest opened quote
671     if (count($this->quote_stack)) {
672       $e = end($this->quote_stack);
673       return $e;
674     }
675     return 0;
676   }
677
678   private function _open_one_quote($d, $citation) {
679     $this->quote_stack[] = $d;
680     $this->_set_tab($d);
681     $class_attr = $citation ? ' class="citation"' : '';
682     $this->out .= "<blockquote$class_attr>\n";
683   }
684
685   private function _open_quote($quote_depth, $depth, $citation) {
686     $this->close_table();
687     $this->close_paragraph();
688     $this->close_list();
689
690     if ($citation) {
691       for ($d = $quote_depth + 1; $d < $depth+1; $d++) {
692         $this->_open_one_quote($d, $citation);
693       }
694     } else {
695       $this->_open_one_quote($depth, $citation);
696     }
697   }
698
699   private function _close_quote() {
700     $this->close_table();
701     $this->close_paragraph();
702     array_pop($this->quote_stack);
703     $this->out .= "</blockquote>\n";
704   }
705
706   private function _set_quote_depth($depth, $citation = false) {
707     $quote_depth = $this->_get_quote_depth();
708     if ($depth > $quote_depth) {
709       $this->_set_tab($depth);
710       $tabstops = $this->tabstops;
711
712       while (count($tabstops)) {
713         $tab = array_pop($tabstops);
714         if ($tab > $quote_depth) {
715           $this->_open_quote($quote_depth, $tab, $citation);
716         }
717       }
718     } else {
719       while ($this->quote_stack) {
720         $deepest_offset = end($this->quote_stack);
721         if ($depth >= $deepest_offset) {
722           break;
723         }
724         $this->_close_quote();
725       }
726       if (!$citation && $depth > 0) {
727         if (count($this->quote_stack)) {
728           $old_offset = end($this->quote_stack);
729           if ($old_offset != $depth) {
730             array_pop($this->quote_stack);
731             $this->quote_stack[] = $depth;
732           }
733         } else {
734           $this->_open_quote($quote_depth, $depth, $citation);
735         }
736       }
737     }
738     if ($depth > 0) {
739       $this->in_quote = true;
740     }
741   }
742
743   function open_paragraph() {
744     if (!$this->paragraph_open) {
745       $this->out .= "<p>\n";
746       $this->paragraph_open = true;
747     }
748   }
749
750   function close_paragraph() {
751     if ($this->paragraph_open) {
752       while (count($this->open_tags)) {
753         $t = array_pop($this->open_tags);
754         $this->out .= $t[1];
755       }
756       $this->out .= "</p>\n";
757       $this->paragraph_open = false;
758     }
759   }
760
761   function _last_table_cell_formatter($match, $info, $nmatch) {
762     return;
763   }
764
765   function _table_cell_formatter($match, $info, $nmatch) {
766     $this->open_table();
767     $this->open_table_row();
768     $tag = $this->table_row_count == 1 ? 'th' : 'td';
769     if ($this->in_table_cell) {
770       $this->out .= "</$tag><$tag>";
771       return;
772     }
773     $this->in_table_cell = 1;
774     $this->out .= "<$tag>";
775   }
776
777
778   function open_table() {
779     if (!$this->in_table) {
780       $this->close_paragraph();
781       $this->close_list();
782       $this->close_def_list();
783       $this->in_table = 1;
784       $this->table_row_count = 0;
785       $this->out .= "<table class='report wiki'>\n";
786     }
787   }
788
789   function open_table_row() {
790     if (!$this->in_table_row) {
791       $this->open_table();
792       if ($this->table_row_count == 0) {
793         $this->out .= "<thead><tr>";
794       } else if ($this->table_row_count == 1) {
795         $this->out .= "<tbody><tr>";
796       } else {
797         $this->out .= "<tr>";
798       }
799       $this->in_table_row = 1;
800       $this->table_row_count++;
801     }
802   }
803
804   function close_table_row() {
805     if ($this->in_table_row) {
806       $tag = $this->table_row_count == 1 ? 'th' : 'td';
807       $this->in_table_row = 0;
808       if ($this->in_table_cell) {
809         $this->in_table_cell = 0;
810         $this->out .= "</$tag>";
811       }
812       if ($this->table_row_count == 1) {
813         $this->out .= "</tr></thead>";
814       } else {
815         $this->out .= "</tr>";
816       }
817     }
818   }
819
820   function close_table() {
821     if ($this->in_table) {
822       $this->close_table_row();
823       if ($this->table_row_count == 1) {
824         $this->out .= "</thead></table>\n";
825       } else {
826         $this->out .= "</tbody></table>\n";
827       }
828       $this->in_table = 0;
829     }
830   }
831
832   function close_def_list() {
833     if ($this->in_def_list) {
834       $this->out .= "</dd></dl>\n";
835     }
836     $this->in_def_list = false;
837   }
838
839   function handle_code_block($line) {
840     if (trim($line) == MTrack_Wiki_Parser::STARTBLOCK) {
841       $this->in_code_block++;
842       if ($this->in_code_block == 1) {
843         $this->code_buf = array();
844       } else {
845         $this->code_buf[] = $line;
846       }
847     } elseif (trim($line) == MTrack_Wiki_Parser::ENDBLOCK) {
848       $this->in_code_block--;
849       if ($this->in_code_block == 0) {
850         // FIXME process the code here
851         if (preg_match("/^#!(\S+)$/", $this->code_buf[0], $M)
852             && isset(MTrack_Wiki::$processors[$M[1]])) {
853           $func = MTrack_Wiki::$processors[$M[1]];
854           array_shift($this->code_buf);
855           $this->out .= call_user_func($func, $M[1], $this->code_buf);
856         } else {
857           $this->out .= "<pre>" .
858             htmlspecialchars(join("\n", $this->code_buf), ENT_COMPAT, 'utf-8') .
859             "</pre>";
860         }
861       } else {
862         $this->code_buf[] = $line;
863       }
864     } else {
865       $this->code_buf[] = $line;
866     }
867   }
868
869   function close_code_blocks() {
870     while ($this->in_code_block) {
871       $this->handle_code_block(MTrack_Wiki_Parser::ENDBLOCK);
872     }
873   }
874
875   function _set_tab($depth) {
876     /* Append a new tab if needed and truncate tabs deeper than `depth`
877       given:       -*-----*--*---*--
878       setting:              *
879       results in:  -*-----*-*-------
880     */
881     $tabstops = array();
882     foreach ($this->tabstops as $ts) {
883       if ($ts >= $depth) {
884         break;
885       }
886       $tabstops[] = $ts;
887     }
888     $tabstops[] = $depth;
889     $this->tabstops = $tabstops;
890   }
891
892   function _list_formatter($match, $info, $nmatch) {
893     $ldepth = strlen($info['ldepth'][$nmatch][0]);
894     $listid = $match[$ldepth];
895     $this->in_list_item = true;
896     $class = '';
897     $start = '';
898     if ($listid == '-' || $listid == '*') {
899       $type = 'ul';
900     } else {
901       $type = 'ol';
902       switch ($listid) {
903         case '1': break;
904         case '0': $class = 'arabiczero'; break;
905         case 'i': $class = 'lowerroman'; break;
906         case 'I': $class = 'upperroman'; break;
907         default:
908           if (preg_match("/(\d+)\./", substr($match, $ldepth), $d)) {
909             $start = (int)$d[1];
910           } elseif (ctype_lower($listid)) {
911             $class = 'loweralpha';
912           } elseif (ctype_upper($listid)) {
913             $class = 'upperalpha';
914           }
915       }
916     }
917     $this->_set_list_depth($ldepth, $type, $class, $start);
918   }
919
920   function _definition_formatter($match, $info, $nmatch) {
921     $tmp = $this->in_def_list ? '</dd>' : '<dl class="wikidl">';
922     list($def) = explode('::', $match, 2);
923     $tmp .= sprintf("<dt>%s</dt><dd>",
924       MTrack_Wiki::format_to_oneliner(trim($def)));
925     $this->in_def_list = true;
926     $this->out .= $tmp;
927   }
928
929   function _indent_formatter($match, $info, $nmatch) {
930     $idepth = strlen($info['idepth'][$nmatch][0]);
931     if (count($this->list_stack)) {
932       list($ltype, $ldepth) = end($this->list_stack);
933       if ($idepth < $ldepth) {
934         foreach ($this->list_stack as $pair) {
935           $ldepth = $pair[1];
936           if ($idepth > $ldepth) {
937             $this->in_list_item = true;
938             $this->_set_list_depth($idepth, null, null, null);
939             return;
940           }
941         }
942       } elseif ($idepth <= $ldepth + ($ltype == 'ol' ? 3 : 2)) {
943         $this->in_list_item = true;
944         return;
945       }
946     }
947     if (!$this->in_def_list) {
948       $this->_set_quote_depth($idepth);
949     }
950   }
951
952   function _citation_formatter($match, $info, $nmatch) {
953     $cdepth = strlen(str_replace(' ', '', $info['cdepth'][$nmatch][0]));
954     $this->_set_quote_depth($cdepth, true);
955   }
956
957
958 }