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