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';
8 class MTrack_Wiki_HTMLFormatter
13 var $table_row_count = 0;
23 static $linkHandler; // the link handler.. (used with register...)
26 function __construct()
28 $this->parser = new MTrack_Wiki_Parser;
31 static function registerLinkHandler(MTrack_Interface_WikiLinkHandler $li)
33 self::$linkHandler = $li;
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;
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 */
54 if (preg_match_all($rules, $line, $matches, PREG_OFFSET_CAPTURE)) {
56 foreach ($matches as $key => $info) {
57 if (is_string($key)) {
58 foreach ($info as $nmatch => $item) {
59 if (!is_array($item)) {
65 if (strlen($match) && $offset >= 0) {
66 if ($match[0] == '!') {
67 $repl[$offset] = array(null, $match, null);
69 $func = '_' . $key . '_formatter';
70 if (method_exists($this, $func)) {
71 $repl[$offset] = array($func, $match, $nmatch);
73 @$this->missing[$func]++;
81 /* order matches by match offset */
83 /* and now we can generate for each fragment */
85 foreach ($repl as $offset => $bits) {
86 list($func, $match, $nmatch) = $bits;
90 // $this->out .= "Copying from $sol to $offset\n";
91 $this->out .= substr($line, $sol, $offset - $sol);
95 $this->out .= htmlspecialchars(substr($match, 1),
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);
103 $sol = $offset + strlen($match);
105 $this->out .= substr($line, $sol);
116 function format($text, $escape_newlines = false) {
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);
124 if (!strncmp($line, "----", 4)) {
125 $this->close_table();
126 $this->close_paragraph();
127 $this->close_indentation();
129 $this->close_def_list();
130 $this->out .= "<hr />\n";
133 if (strlen($line) == 0) {
134 $this->close_paragraph();
135 $this->close_indentation();
137 $this->close_def_list();
138 $this->close_table();
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);
145 // Tag expansion and clear tabstops if no indent
146 $line = str_replace("\t", " ", $line);
147 if ($line[0] != ' ') {
148 $this->tabstops = array();
151 $this->in_list_item = false;
152 $this->in_quote = false;
156 $result = $this->_apply_rules($line);
157 $newbit = $this->out;
159 if (!($this->in_list_item || $this->in_def_list || $this->in_table)) {
160 $this->open_paragraph();
163 if (!$this->in_list_item) {
166 if (!$this->in_quote) {
167 $this->close_indentation();
169 if ($this->in_def_list && $line[0] != ' ') {
170 $this->close_def_list();
172 if ($this->in_table && strncmp(ltrim($line), '||', 2)) {
173 $this->close_table();
175 $this->out .= $newbit;
177 if (!($this->in_list_item || $this->in_def_list || $this->in_table)) {
178 if (strlen($result)) {
179 $this->open_paragraph();
181 if ($escape_newlines && !preg_match(",<br />\s*$,", $line)) {
185 $this->out .= $result . $sep;
186 $this->close_table_row();
188 $this->close_table();
189 $this->close_paragraph();
190 $this->close_indentation();
192 $this->close_def_list();
193 $this->close_code_blocks();
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];
206 $heading_text = substr($match, $depth+1, - $depth - 1 - strlen($anchor));
207 $heading = MTrack_Wiki::format_to_oneliner($heading_text);
209 $anchor = substr($anchor, 1);
211 $anchor = preg_replace("/[^\w:.-]+/", "", $heading_text);
212 if (ctype_digit($anchor[0])) {
213 $anchor = 'a' . $anchor;
216 return array($depth, $heading, $anchor);
219 function _heading_formatter($match, $info, $nmatch) {
220 $this->close_table();
221 $this->close_paragraph();
222 $this->close_indentation();
224 $this->close_def_list();
225 list($depth, $heading, $anchor) =
226 $this->_parse_heading($match, $info, $nmatch, false);
228 $this->out .= sprintf('<h%d id="%s"><a class="wiki" name="%s">%s</a></h%d>',
229 $depth, $anchor, $anchor, $heading, $depth);
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);
237 function open_tag($open_tag, $close_tag) {
238 $this->open_tags[] = array($open_tag, $close_tag);
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);
246 $this->open_tag($open_tag, $close_tag);
247 $this->out .= $open_tag;
250 function close_tag($tag) {
252 /* walk backwards until we find the tag, closing out
254 $keys = array_reverse(array_keys($this->open_tags));
255 foreach ($keys as $k) {
256 $pair = $this->open_tags[$k];
258 if ($pair[1] == $tag) {
259 unset($this->open_tags[$k]);
260 foreach ($this->open_tags as $k2 => $pair) {
272 function _bolditalic_formatter($match, $info) {
273 $italic = array('<i>', '</i>');
274 $open = $this->tag_open_p($italic);
277 $this->out .= $italic[1];
278 $this->close_tag($italic[1]);
280 $this->_bold_formatter($match, $info);
282 $this->out .= $italic[0];
283 $this->open_tag($italic[0], $italic[1]);
287 function _bold_formatter($match, $info) {
288 $this->simple_tag_handler($match, '<strong>', '</strong>');
290 function _italic_formatter($match, $info) {
291 $this->simple_tag_handler($match, '<i>', '</i>');
293 function _underline_formatter($match, $info) {
294 $this->simple_tag_handler($match,
295 '<span class="underline">', '</span>');
297 function _strike_formatter($match, $info) {
298 $this->simple_tag_handler($match, '<del>', '</del>');
300 function _subscript_formatter($match, $info) {
301 $this->simple_tag_handler($match, '<sub>', '</sub>');
303 function _superscript_formatter($match, $info) {
304 $this->simple_tag_handler($match, '<sup>', '</sup>');
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>";
313 function _htmlspecialcharsape_formatter($match, $info) {
314 $this->out .= htmlspecialchars($match, ENT_QUOTES, 'utf-8');
319 function _make_link($ns, $target, $match, $label) {
321 if ($label[0] == '"' || $label[0] == "'") {
322 $label = substr($label, 1, -1);
324 if (preg_match('/^(.*)#(.*)$/', $target, $M)) {
334 if ($ns == 'ticket' &&
335 (strpos($target, '-') !== false || strpos($target, ',') !== false)) {
336 /* ranged ticket query */
338 $target = 'id=' . $target;
343 $this->out .= self::$linkHandler->ticket($target, array(
350 if (strpos($target, ',') !== false) {
351 list($repo, $cs) = explode(',', $target, 2);
352 $this->out .= self::$linkHandler->changeset($cs, $repo);
355 $this->out .= self::$linkHandler->changeset($target);
359 $this->out .= self::$linkHandler->milestone($target);
363 $this->out .= self::$linkHandler->wiki($target, array(
370 $this->out .= self::$linkHandler->help($target,$label,$anchor);
374 $this->out .= self::$linkHandler->username($target);
378 $this->out .= self::$linkHandler->browse($target,$label);
382 if ($target == '/') {
383 $target = MtrackRepo::defaultRepo(
384 empty($ff->MTrack['default_repo']) ? null : $ff->MTrack['default_repo']
387 $this->out .= $this->log($target, $label);
392 $this->out .= self::$linkHandler->{$ns}($target,$label);
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
403 $bits = explode('/', $file);
405 if (count($bits) > 2) {
406 /* maybe owner/repo */
407 $repo = MTrackRepo::loadByName($bits[0] . '/' . $bits[1]);
409 $repo = $repo->getBrowseRootName();
412 if ($repo === null && count($bits) > 1) {
413 $repo = MTrackRepo::loadByName('default/' . $bits[0]);
415 $repo = $repo->getBrowseRootName();
416 array_unshift($bits, 'default');
419 if ($repo === null) {
420 $target = MtrackRepo::defaultRepo(
421 empty($ff->MTrack['default_repo']) ? null : $ff->MTrack['default_repo']
424 if (strpos($defrep, '/') === false) {
425 $defrep = "default/$defrep";
427 $repo = MTrackRepo::loadByName($defrep);
429 $repo = $repo->getBrowseRootName();
430 array_unshift($bits, $repo);
434 $file = join($bits, '/');
435 $out .= self::$linkHandler->file($file . ($rev ? '@'. $rev : ''));
440 if (preg_match('/^(\d+):ticket:(.*)$/', $target, $M)) {
441 $this->out .= self::$linkHandler->ticket($M[2],
443 '#' => 'comment:' . $M[1],
449 $this->out .= '<a href="#comment:'. htmlspecialchars($target). '">' .htmlspecialchars($label). '</a>';
453 if (strlen($anchor)) {
454 $target .= "#$anchor";
456 $this->out .= '<a href="http:'. htmlspecialchars($target). '">' .htmlspecialchars($label). '</a>';
458 case 'file': // not sure if this should be supported...
459 $this->out .= '<span class="file-link">' .htmlspecialchars($label). '</a>';
462 throw new Exception("unknown target " . $ns);
463 $target = "$ns:$target";
464 if (strlen($anchor)) {
465 $target .= "#$anchor";
474 function _ticket_formatter($match, $info, $nmatch) {
475 $ticket = substr($match, 1);
476 $this->_make_link('ticket', $ticket, $ticket, $match);
479 function _report_formatter($match, $info, $nmatch) {
480 $ticket = substr($match, 1, -1);
481 $this->_make_link('report', $ticket, $ticket, $match);
484 function _svnchangeset_formatter($match, $info, $nmatch) {
485 $rev = substr($match, 1, -1);
486 $this->_make_link('changeset', $rev, $rev, $match);
489 function _wikipagename_formatter($match, $info, $nmatch) {
490 $this->_make_link('wiki', $match, $match, $match);
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);
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);
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] : '';
513 // var_dump($rel, $ns, $target, $label);
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";
522 /* for [wiki:target], label is target */
530 $label = $this->_unquote($label);
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);
540 if (strlen($query)) {
541 $target .= "?$query";
546 $this->out .= "<a href=\"$target\">$label</a>";
548 $this->_make_link($ns, $target, $match, $label);
552 function _inlinecode_formatter($match, $info, $nmatch) {
553 $this->out .= "<tt>" .
554 nl2br(htmlspecialchars($info['inline'][$nmatch][0],
555 ENT_COMPAT, 'utf-8')) .
558 function _inlinecode2_formatter($match, $info, $nmatch) {
559 $this->out .= "<tt>" .
560 nl2br(htmlspecialchars($info['inline2'][$nmatch][0],
561 ENT_COMPAT, 'utf-8')) .
565 function _macro_formatter($match, $info, $nmatch) {
566 $name = $info['macroname'][$nmatch][0];
567 if (!strcasecmp($name, 'br')) {
568 $this->out .= "<br />";
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);
575 $this->out .= "<tt>" .
576 htmlspecialchars($match, ENT_QUOTES, 'utf-8') . "</tt>";
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);
587 function _unquote($text) {
588 return preg_replace("/^(['\"])(.*)(\\1)$/", "\\2", $text);
591 function close_list() {
592 $this->_set_list_depth(0, null, null, null);
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);
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);
611 $list_class = "wikilist $list_class";
613 $list_class = "wikilist";
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>";
619 private function _close_list($type) {
620 array_pop($this->list_stack);
621 $this->out .= "</li></$type>";
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);
629 while (count($this->list_stack)) {
630 list($deepest_type, $deepest_offset) = end($this->list_stack);
631 if ($depth >= $deepest_offset) {
634 $this->_close_list($deepest_type);
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);
643 if ($old_offset != $depth) {
644 array_pop($this->list_stack);
645 $this->list_stack[] = array($old_type, $depth);
647 $this->out .= "</li><li>";
650 $this->_open_list($depth, $new_type, $list_class, $start);
655 function close_indentation() {
656 $this->_set_quote_depth(0);
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);
668 private function _open_one_quote($d, $citation) {
669 $this->quote_stack[] = $d;
671 $class_attr = $citation ? ' class="citation"' : '';
672 $this->out .= "<blockquote$class_attr>\n";
675 private function _open_quote($quote_depth, $depth, $citation) {
676 $this->close_table();
677 $this->close_paragraph();
681 for ($d = $quote_depth + 1; $d < $depth+1; $d++) {
682 $this->_open_one_quote($d, $citation);
685 $this->_open_one_quote($depth, $citation);
689 private function _close_quote() {
690 $this->close_table();
691 $this->close_paragraph();
692 array_pop($this->quote_stack);
693 $this->out .= "</blockquote>\n";
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;
702 while (count($tabstops)) {
703 $tab = array_pop($tabstops);
704 if ($tab > $quote_depth) {
705 $this->_open_quote($quote_depth, $tab, $citation);
709 while ($this->quote_stack) {
710 $deepest_offset = end($this->quote_stack);
711 if ($depth >= $deepest_offset) {
714 $this->_close_quote();
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;
724 $this->_open_quote($quote_depth, $depth, $citation);
729 $this->in_quote = true;
733 function open_paragraph() {
734 if (!$this->paragraph_open) {
735 $this->out .= "<p>\n";
736 $this->paragraph_open = true;
740 function close_paragraph() {
741 if ($this->paragraph_open) {
742 while (count($this->open_tags)) {
743 $t = array_pop($this->open_tags);
746 $this->out .= "</p>\n";
747 $this->paragraph_open = false;
751 function _last_table_cell_formatter($match, $info, $nmatch) {
755 function _table_cell_formatter($match, $info, $nmatch) {
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>";
763 $this->in_table_cell = 1;
764 $this->out .= "<$tag>";
768 function open_table() {
769 if (!$this->in_table) {
770 $this->close_paragraph();
772 $this->close_def_list();
774 $this->table_row_count = 0;
775 $this->out .= "<table class='report wiki'>\n";
779 function open_table_row() {
780 if (!$this->in_table_row) {
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>";
787 $this->out .= "<tr>";
789 $this->in_table_row = 1;
790 $this->table_row_count++;
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>";
802 if ($this->table_row_count == 1) {
803 $this->out .= "</tr></thead>";
805 $this->out .= "</tr>";
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";
816 $this->out .= "</tbody></table>\n";
822 function close_def_list() {
823 if ($this->in_def_list) {
824 $this->out .= "</dd></dl>\n";
826 $this->in_def_list = false;
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();
835 $this->code_buf[] = $line;
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);
847 $this->out .= "<pre>" .
848 htmlspecialchars(join("\n", $this->code_buf), ENT_COMPAT, 'utf-8') .
852 $this->code_buf[] = $line;
855 $this->code_buf[] = $line;
859 function close_code_blocks() {
860 while ($this->in_code_block) {
861 $this->handle_code_block(MTrack_Wiki_Parser::ENDBLOCK);
865 function _set_tab($depth) {
866 /* Append a new tab if needed and truncate tabs deeper than `depth`
867 given: -*-----*--*---*--
869 results in: -*-----*-*-------
872 foreach ($this->tabstops as $ts) {
878 $tabstops[] = $depth;
879 $this->tabstops = $tabstops;
882 function _list_formatter($match, $info, $nmatch) {
883 $ldepth = strlen($info['ldepth'][$nmatch][0]);
884 $listid = $match[$ldepth];
885 $this->in_list_item = true;
888 if ($listid == '-' || $listid == '*') {
894 case '0': $class = 'arabiczero'; break;
895 case 'i': $class = 'lowerroman'; break;
896 case 'I': $class = 'upperroman'; break;
898 if (preg_match("/(\d+)\./", substr($match, $ldepth), $d)) {
900 } elseif (ctype_lower($listid)) {
901 $class = 'loweralpha';
902 } elseif (ctype_upper($listid)) {
903 $class = 'upperalpha';
907 $this->_set_list_depth($ldepth, $type, $class, $start);
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;
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) {
926 if ($idepth > $ldepth) {
927 $this->in_list_item = true;
928 $this->_set_list_depth($idepth, null, null, null);
932 } elseif ($idepth <= $ldepth + ($ltype == 'ol' ? 3 : 2)) {
933 $this->in_list_item = true;
937 if (!$this->in_def_list) {
938 $this->_set_quote_depth($idepth);
942 function _citation_formatter($match, $info, $nmatch) {
943 $cdepth = strlen(str_replace(' ', '', $info['cdepth'][$nmatch][0]));
944 $this->_set_quote_depth($cdepth, true);