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";
100 // $this->out .= var_export($matches, true) . "\n";
101 $this->$func($match, $matches, $nmatch);
104 $sol = $offset + strlen($match);
106 $this->out .= substr($line, $sol);
117 function format($text, $escape_newlines = false) {
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);
125 if (!strncmp($line, "----", 4)) {
126 $this->close_table();
127 $this->close_paragraph();
128 $this->close_indentation();
130 $this->close_def_list();
131 $this->out .= "<hr />\n";
134 if (strlen($line) == 0) {
135 $this->close_paragraph();
136 $this->close_indentation();
138 $this->close_def_list();
139 $this->close_table();
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);
146 // Tag expansion and clear tabstops if no indent
147 $line = str_replace("\t", " ", $line);
148 if ($line[0] != ' ') {
149 $this->tabstops = array();
152 $this->in_list_item = false;
153 $this->in_quote = false;
157 $result = $this->_apply_rules($line);
158 $newbit = $this->out;
160 if (!($this->in_list_item || $this->in_def_list || $this->in_table)) {
161 $this->open_paragraph();
164 if (!$this->in_list_item) {
167 if (!$this->in_quote) {
168 $this->close_indentation();
170 if ($this->in_def_list && $line[0] != ' ') {
171 $this->close_def_list();
173 if ($this->in_table && strncmp(ltrim($line), '||', 2)) {
174 $this->close_table();
176 $this->out .= $newbit;
178 if (!($this->in_list_item || $this->in_def_list || $this->in_table)) {
179 if (strlen($result)) {
180 $this->open_paragraph();
182 if ($escape_newlines && !preg_match(",<br />\s*$,", $line)) {
186 $this->out .= $result . $sep;
187 $this->close_table_row();
189 $this->close_table();
190 $this->close_paragraph();
191 $this->close_indentation();
193 $this->close_def_list();
194 $this->close_code_blocks();
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];
207 $heading_text = substr($match, $depth+1, - $depth - 1 - strlen($anchor));
208 $heading = MTrack_Wiki::format_to_oneliner($heading_text);
210 $anchor = substr($anchor, 1);
212 $anchor = preg_replace("/[^\w:.-]+/", "", $heading_text);
213 if (ctype_digit($anchor[0])) {
214 $anchor = 'a' . $anchor;
217 return array($depth, $heading, $anchor);
220 function _heading_formatter($match, $info, $nmatch) {
221 $this->close_table();
222 $this->close_paragraph();
223 $this->close_indentation();
225 $this->close_def_list();
226 list($depth, $heading, $anchor) =
227 $this->_parse_heading($match, $info, $nmatch, false);
229 $this->out .= sprintf('<h%d id="%s"><a class="wiki" name="%s">%s</a></h%d>',
230 $depth, $anchor, $anchor, $heading, $depth);
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);
238 function open_tag($open_tag, $close_tag) {
239 $this->open_tags[] = array($open_tag, $close_tag);
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);
247 $this->open_tag($open_tag, $close_tag);
248 $this->out .= $open_tag;
251 function close_tag($tag) {
253 /* walk backwards until we find the tag, closing out
255 $keys = array_reverse(array_keys($this->open_tags));
256 foreach ($keys as $k) {
257 $pair = $this->open_tags[$k];
259 if ($pair[1] == $tag) {
260 unset($this->open_tags[$k]);
261 foreach ($this->open_tags as $k2 => $pair) {
273 function _bolditalic_formatter($match, $info) {
274 $italic = array('<i>', '</i>');
275 $open = $this->tag_open_p($italic);
278 $this->out .= $italic[1];
279 $this->close_tag($italic[1]);
281 $this->_bold_formatter($match, $info);
283 $this->out .= $italic[0];
284 $this->open_tag($italic[0], $italic[1]);
288 function _bold_formatter($match, $info) {
289 $this->simple_tag_handler($match, '<strong>', '</strong>');
291 function _italic_formatter($match, $info) {
292 $this->simple_tag_handler($match, '<i>', '</i>');
294 function _underline_formatter($match, $info) {
295 $this->simple_tag_handler($match,
296 '<span class="underline">', '</span>');
298 function _strike_formatter($match, $info) {
299 $this->simple_tag_handler($match, '<del>', '</del>');
301 function _subscript_formatter($match, $info) {
302 $this->simple_tag_handler($match, '<sub>', '</sub>');
304 function _superscript_formatter($match, $info) {
305 $this->simple_tag_handler($match, '<sup>', '</sup>');
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>";
314 function _htmlspecialcharsape_formatter($match, $info) {
315 $this->out .= htmlspecialchars($match, ENT_QUOTES, 'utf-8');
320 function _make_link($ns, $target, $match, $label) {
322 if ($label[0] == '"' || $label[0] == "'") {
323 $label = substr($label, 1, -1);
325 if (preg_match('/^(.*)#(.*)$/', $target, $M)) {
335 if ($ns == 'ticket' &&
336 (strpos($target, '-') !== false || strpos($target, ',') !== false)) {
337 /* ranged ticket query */
339 $target = 'id=' . $target;
344 $this->out .= self::$linkHandler->ticket($target, array(
351 if (strpos($target, ',') !== false) {
352 list($repo, $cs) = explode(',', $target, 2);
353 $this->out .= self::$linkHandler->changeset($cs, $repo);
356 $this->out .= self::$linkHandler->changeset($target);
360 $this->out .= self::$linkHandler->milestone($target);
364 $this->out .= self::$linkHandler->wiki($target, array(
371 $this->out .= self::$linkHandler->help($target,$label,$anchor);
375 $this->out .= self::$linkHandler->username($target);
379 $this->out .= self::$linkHandler->browse($target,$label);
384 //if ($target == '/') {
385 // $target = MTrack_Repo::defaultRepo(
386 // empty($ff->MTrack['default_repo']) ? null : $ff->MTrack['default_repo']
389 $this->out .= $this->log($target, $label);
394 $this->out .= self::$linkHandler->{$ns}($target,$label);
398 // wiki should not know about repo's.. -
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
407 $bits = explode('/', $file);
411 //if (count($bits) > 2) {
412 /* maybe owner/repo */
414 //$repo = MTrack_Repo::loadByName($bits[0] . '/' . $bits[1]);
416 // $repo = $repo->getBrowseRootName();
419 //if ($repo === null && count($bits) > 1) {
420 //$repo = MTrack_Repo::loadByName('default/' . $bits[0]);
422 // $repo = $repo->getBrowseRootName();
423 // array_unshift($bits, 'default');
426 //if ($repo === null) {
427 //$target = MTrack_Repo::defaultRepo(
428 // empty($ff->MTrack['default_repo']) ? null : $ff->MTrack['default_repo']
431 // if (strpos($defrep, '/') === false) {
432 // $defrep = "default/$defrep";
434 // $repo = MTrack_Repo::loadByName($defrep);
436 // $repo = $repo->getBrowseRootName();
437 // array_unshift($bits, $repo);
441 $file = join($bits, '/');
442 $out .= self::$linkHandler->file($file . ($rev ? '@'. $rev : ''));
447 if (preg_match('/^(\d+):ticket:(.*)$/', $target, $M)) {
448 $this->out .= self::$linkHandler->ticket($M[2],
450 '#' => 'comment:' . $M[1],
456 $this->out .= '<a href="#comment:'. htmlspecialchars($target). '">' .htmlspecialchars($label). '</a>';
460 if (strlen($anchor)) {
461 $target .= "#$anchor";
463 $this->out .= '<a href="http:'. htmlspecialchars($target). '">' .htmlspecialchars($label). '</a>';
465 case 'file': // not sure if this should be supported...
466 $this->out .= '<span class="file-link">' .htmlspecialchars($label). '</a>';
470 $this->out .= htmlspecialchars($label);
472 throw new Exception("unknown target " . $ns);
473 $target = "$ns:$target";
474 if (strlen($anchor)) {
475 $target .= "#$anchor";
484 function _ticket_formatter($match, $info, $nmatch) {
485 $ticket = substr($match, 1);
486 $this->_make_link('ticket', $ticket, $ticket, $match);
489 function _report_formatter($match, $info, $nmatch) {
490 $ticket = substr($match, 1, -1);
491 $this->_make_link('report', $ticket, $ticket, $match);
494 function _svnchangeset_formatter($match, $info, $nmatch) {
495 $rev = substr($match, 1, -1);
496 $this->_make_link('changeset', $rev, $rev, $match);
499 function _wikipagename_formatter($match, $info, $nmatch) {
500 $this->_make_link('wiki', $match, $match, $match);
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);
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);
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] : '';
523 // var_dump($rel, $ns, $target, $label);
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";
532 /* for [wiki:target], label is target */
540 $label = $this->_unquote($label);
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);
550 if (strlen($query)) {
551 $target .= "?$query";
556 $this->out .= "<a href=\"$target\">$label</a>";
558 $this->_make_link($ns, $target, $match, $label);
562 function _inlinecode_formatter($match, $info, $nmatch) {
563 $this->out .= "<tt>" .
564 nl2br(htmlspecialchars($info['inline'][$nmatch][0],
565 ENT_COMPAT, 'utf-8')) .
568 function _inlinecode2_formatter($match, $info, $nmatch) {
569 $this->out .= "<tt>" .
570 nl2br(htmlspecialchars($info['inline2'][$nmatch][0],
571 ENT_COMPAT, 'utf-8')) .
575 function _macro_formatter($match, $info, $nmatch) {
576 $name = $info['macroname'][$nmatch][0];
577 if (!strcasecmp($name, 'br')) {
578 $this->out .= "<br />";
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);
585 $this->out .= "<tt>" .
586 htmlspecialchars($match, ENT_QUOTES, 'utf-8') . "</tt>";
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);
597 function _unquote($text) {
598 return preg_replace("/^(['\"])(.*)(\\1)$/", "\\2", $text);
601 function close_list() {
602 $this->_set_list_depth(0, null, null, null);
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);
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);
621 $list_class = "wikilist $list_class";
623 $list_class = "wikilist";
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>";
629 private function _close_list($type) {
630 array_pop($this->list_stack);
631 $this->out .= "</li></$type>";
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);
639 while (count($this->list_stack)) {
640 list($deepest_type, $deepest_offset) = end($this->list_stack);
641 if ($depth >= $deepest_offset) {
644 $this->_close_list($deepest_type);
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);
653 if ($old_offset != $depth) {
654 array_pop($this->list_stack);
655 $this->list_stack[] = array($old_type, $depth);
657 $this->out .= "</li><li>";
660 $this->_open_list($depth, $new_type, $list_class, $start);
665 function close_indentation() {
666 $this->_set_quote_depth(0);
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);
678 private function _open_one_quote($d, $citation) {
679 $this->quote_stack[] = $d;
681 $class_attr = $citation ? ' class="citation"' : '';
682 $this->out .= "<blockquote$class_attr>\n";
685 private function _open_quote($quote_depth, $depth, $citation) {
686 $this->close_table();
687 $this->close_paragraph();
691 for ($d = $quote_depth + 1; $d < $depth+1; $d++) {
692 $this->_open_one_quote($d, $citation);
695 $this->_open_one_quote($depth, $citation);
699 private function _close_quote() {
700 $this->close_table();
701 $this->close_paragraph();
702 array_pop($this->quote_stack);
703 $this->out .= "</blockquote>\n";
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;
712 while (count($tabstops)) {
713 $tab = array_pop($tabstops);
714 if ($tab > $quote_depth) {
715 $this->_open_quote($quote_depth, $tab, $citation);
719 while ($this->quote_stack) {
720 $deepest_offset = end($this->quote_stack);
721 if ($depth >= $deepest_offset) {
724 $this->_close_quote();
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;
734 $this->_open_quote($quote_depth, $depth, $citation);
739 $this->in_quote = true;
743 function open_paragraph() {
744 if (!$this->paragraph_open) {
745 $this->out .= "<p>\n";
746 $this->paragraph_open = true;
750 function close_paragraph() {
751 if ($this->paragraph_open) {
752 while (count($this->open_tags)) {
753 $t = array_pop($this->open_tags);
756 $this->out .= "</p>\n";
757 $this->paragraph_open = false;
761 function _last_table_cell_formatter($match, $info, $nmatch) {
765 function _table_cell_formatter($match, $info, $nmatch) {
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>";
773 $this->in_table_cell = 1;
774 $this->out .= "<$tag>";
778 function open_table() {
779 if (!$this->in_table) {
780 $this->close_paragraph();
782 $this->close_def_list();
784 $this->table_row_count = 0;
785 $this->out .= "<table class='report wiki'>\n";
789 function open_table_row() {
790 if (!$this->in_table_row) {
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>";
797 $this->out .= "<tr>";
799 $this->in_table_row = 1;
800 $this->table_row_count++;
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>";
812 if ($this->table_row_count == 1) {
813 $this->out .= "</tr></thead>";
815 $this->out .= "</tr>";
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";
826 $this->out .= "</tbody></table>\n";
832 function close_def_list() {
833 if ($this->in_def_list) {
834 $this->out .= "</dd></dl>\n";
836 $this->in_def_list = false;
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();
845 $this->code_buf[] = $line;
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);
857 $this->out .= "<pre>" .
858 htmlspecialchars(join("\n", $this->code_buf), ENT_COMPAT, 'utf-8') .
862 $this->code_buf[] = $line;
865 $this->code_buf[] = $line;
869 function close_code_blocks() {
870 while ($this->in_code_block) {
871 $this->handle_code_block(MTrack_Wiki_Parser::ENDBLOCK);
875 function _set_tab($depth) {
876 /* Append a new tab if needed and truncate tabs deeper than `depth`
877 given: -*-----*--*---*--
879 results in: -*-----*-*-------
882 foreach ($this->tabstops as $ts) {
888 $tabstops[] = $depth;
889 $this->tabstops = $tabstops;
892 function _list_formatter($match, $info, $nmatch) {
893 $ldepth = strlen($info['ldepth'][$nmatch][0]);
894 $listid = $match[$ldepth];
895 $this->in_list_item = true;
898 if ($listid == '-' || $listid == '*') {
904 case '0': $class = 'arabiczero'; break;
905 case 'i': $class = 'lowerroman'; break;
906 case 'I': $class = 'upperroman'; break;
908 if (preg_match("/(\d+)\./", substr($match, $ldepth), $d)) {
910 } elseif (ctype_lower($listid)) {
911 $class = 'loweralpha';
912 } elseif (ctype_upper($listid)) {
913 $class = 'upperalpha';
917 $this->_set_list_depth($ldepth, $type, $class, $start);
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;
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) {
936 if ($idepth > $ldepth) {
937 $this->in_list_item = true;
938 $this->_set_list_depth($idepth, null, null, null);
942 } elseif ($idepth <= $ldepth + ($ltype == 'ol' ? 3 : 2)) {
943 $this->in_list_item = true;
947 if (!$this->in_def_list) {
948 $this->_set_quote_depth($idepth);
952 function _citation_formatter($match, $info, $nmatch) {
953 $cdepth = strlen(str_replace(' ', '', $info['cdepth'][$nmatch][0]));
954 $this->_set_quote_depth($cdepth, true);