1<?php
2/**
3 * DokuWiki Plugin ExtList (Syntax component)
4 *
5 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author     Satoshi Sahara <sahara.satoshi@gmail.com>
7 *
8 */
9if (!defined('DOKU_INC')) die();
10
11class syntax_plugin_extlist extends DokuWiki_Syntax_Plugin
12{
13    public function getType()
14    {   // Syntax Type
15        return 'container';
16    }
17
18    public function getAllowedTypes()
19    {   // Allowed Mode Types
20        return array(
21            'formatting',
22            'substition',
23            'disabled',
24            'protected',
25        );
26    }
27
28    public function getPType()
29    {   // Paragraph Type
30        return 'block';
31    }
32
33
34    protected $stack = array();
35    protected $list_class = array(); // store class specified by macro
36
37    // Enable hierarchical numbering for nested ordered lists
38    protected $olist_level = 0;
39    protected $olist_info = array();
40
41    protected $use_div = true;
42
43
44
45    /**
46     * Connect pattern to lexer
47     */
48    protected $mode;
49    protected $macro_pattern;
50    protected $entry_pattern, $match_pattern, $extra_pattern, $exit_pattern;
51
52    public function preConnect()
53    {
54        // drop 'syntax_' from class name
55        $this->mode = substr(get_class($this), 7);
56
57        // macro to specify list class
58        $this->macro_pattern = '\n(?: {2,}|\t{1,})~~(?:dl|ol|ul):[\w -]*?~~';
59
60        // list patterns
61        $olist_pattern    = '\-?\d+[.:] |-:?'; // ordered list item
62        $ulist_pattern    = '\*:?';            // unordered list items
63        $dlist_pattern    = ';;?|::?';         // description list item
64        $continue_pattern = '\+:?';            // continued contents to the previous item
65
66        $this->entry_pattern = '\n(?: {2,}|\t{1,})'.'(?:'
67                       . $olist_pattern .'|'
68                       . $ulist_pattern .'|'
69                       . $dlist_pattern . ') *';
70        $this->match_pattern = '\n(?: {2,}|\t{1,})'.'(?:'
71                       . $olist_pattern .'|'
72                       . $ulist_pattern .'|'
73                       . $dlist_pattern .'|'
74                       . $continue_pattern . ') *';
75
76        // continued item content by indentation
77        $this->extra_pattern = '\n(?: {2,}|\t{1,})(?![-*;:?+~])';
78
79        $this->exit_pattern  = '\n';
80    }
81
82    public function connectTo($mode)
83    {
84        $this->Lexer->addEntryPattern('[ \t]*'.$this->entry_pattern, $mode, $this->mode);
85
86        // macro syntax to specify class for next list [ul|ol|dl]
87        $this->Lexer->addSpecialPattern('[ \t]*'.$this->macro_pattern, $mode, $this->mode);
88        $this->Lexer->addPattern($this->macro_pattern, $this->mode);
89    }
90
91    public function postConnect()
92    {
93        // subsequent list item
94        $this->Lexer->addPattern($this->match_pattern, $this->mode);
95        $this->Lexer->addPattern('  ::? ', $this->mode);  // dt and dd in one line
96
97        // continued list item content, indented by at least two spaces
98        $this->Lexer->addPattern($this->extra_pattern, $this->mode);
99
100        // terminate a list block
101        $this->Lexer->addExitPattern($this->exit_pattern, $this->mode);
102    }
103
104    public function getSort()
105    {   // sort number used to determine priority of this mode
106        return 9; // just before listblock (10)
107    }
108
109    /**
110     * get markup and depth from the match
111     *
112     * @param $match string
113     * @return array
114     */
115    protected function interpret($match)
116    {
117        // depth: count double spaces indent after '\n'
118        $depth = substr_count(str_replace("\t", '  ', ltrim($match,' ')),'  ');
119        $match = trim($match);
120
121        $m = array('depth' => $depth);
122
123        // check order list markup with number
124        if (preg_match('/^(-?\d+)([.:])/', $match, $matches)) {
125            $m += array(
126                    'mk' => ($matches[2] == '.') ? '-' : '-:',
127                    'list' => 'ol', 'item' => 'li', 'num' => $matches[1]
128            );
129            if ($matches[2] == ':') $m += array('p' => 1);
130        } else {
131            $m += array('mk' => $match);
132
133            switch ($match[0]) {
134                case '' :
135                    $m += array('list' => NULL, 'item' => NULL);
136                    break;
137                case '+':
138                    $m += array('list' => NULL, 'item' => NULL);
139                    if ($match == '+:') {
140                        $m += array('p' => 1);
141                    } else {
142                        $m['mk'] = '';
143                    }
144                    break;
145                case '-': // ordered list
146                    $m += array('list' => 'ol', 'item' => 'li');
147                    if ($match == '-:') $m += array('p' => 1);
148                    break;
149                case '*': // unordered list
150                    $m += array('list' => 'ul', 'item' => 'li');
151                    if ($match == '*:') $m += array('p' => 1);
152                    break;
153                case ';': // description list term
154                    $m += array('list' => 'dl', 'item' => 'dt');
155                    if ($match == ';') $m += array('class' => 'compact');
156                    break;
157                case ':': // description list desc
158                    $m += array('list' => 'dl', 'item' => 'dd');
159                    if ($match == '::') $m += array('p' => 1);
160                    break;
161            }
162        }
163        //error_log('extlist intpret: $m='.var_export($m,1));
164        return $m;
165    }
166
167
168    /**
169     * check whether list type has changed
170     */
171    private function isListTypeChanged($m0, $m1)
172    {
173        return (strncmp($m0['list'], $m1['list'], 1) !== 0);
174    }
175
176    /**
177     * create marker for ordered list items
178     */
179    private function olist_marker($level)
180    {
181        $num = $this->olist_info[$level];
182        //error_log('olist lv='.$level.' list_class='.$this->list_class['ol'].' num='.$num);
183
184        // Parenthesized latin small letter marker: ⒜,⒝,⒞, … ,⒵
185        if (strpos($this->list_class['ol'], 'alphabet') !== false){
186            $modulus = ($num -1) % 26;
187            $marker = '&#'.(9372 + $modulus).';';
188            return $marker;
189        }
190
191        // Hierarchical numbering (default): eg. 1. | 2.1 | 3.2.9
192        $marker = $this->olist_info[1];
193        if ($level == 1) {
194            return $marker.'.';
195        } else {
196            for ($i = 2; $i <= $level; $i++) {
197                $marker .= '.'.$this->olist_info[$i];
198            }
199            return $marker;
200        }
201    }
202
203    /**
204     * srore class attribute for lists [ul|ol|dl] specfied by macro pattern
205     * macro_pattern = ~~(?:dl|ol|ul):[\w -]*?~~
206     */
207    private function storeListClass($str)
208    {
209            $str = trim($str);
210            $this->list_class[substr($str,2,2)] = trim(substr($str,5,-2));
211    }
212
213
214    /**
215     * helper function to simplify writing plugin calls to the instruction list
216     * first three arguments are passed to function render as $data
217     * Note: this function was used in the DW exttab3 plugin.
218     */
219    protected function _writeCall($tag, $attr, $state, $pos, $match, $handler)
220    {
221        $handler->addPluginCall($this->getPluginName(),
222            array($state, $tag, $attr), $state, $pos, $match
223        );
224
225    }
226
227    /**
228     * write call to open a list block [ul|ol|dl]
229     */
230    private function _openList($m, $pos, $match, $handler)
231    {
232        $tag = $m['list'];
233        // start value only for ordered list
234        if ($tag == 'ol') {
235            $attr = isset($m['num']) ? 'start="'.$m['num'].'"' : '';
236            $this->olist_level++; // increase olist level
237        }
238        // list class
239        $class = 'extlist';
240        if (isset($this->list_class[$tag])) {
241            // Note: list_class can be empty
242            $class.= ' '.$this->list_class[$tag];
243        } else {
244            $class.= ' '.$this->getConf($tag.'_class');
245        }
246        $class = rtrim($class);
247        $attr.= !empty($attr) ? ' ' : '';
248        $attr.= ' class="'.$class.'"';
249        $this->_writeCall($tag,$attr,DOKU_LEXER_ENTER, $pos,$match,$handler);
250    }
251
252    /**
253     * write call to close a list block [ul|ol|dl]
254     */
255    private function _closeList($m, $pos, $match, $handler)
256    {
257        $tag = $m['list'];
258        if ($tag == 'ol') {
259            $this->olist_level--; // reduce olist level
260        }
261        $this->_writeCall($tag,'',DOKU_LEXER_EXIT, $pos,$match,$handler);
262    }
263
264    /**
265     * write call to open a list item [li|dt|dd]
266     */
267    private function _openItem($m, $pos, $match, $handler)
268    {
269        $tag = $m['item'];
270        switch ($m['mk']) {
271            case '-':
272            case '-:':
273                // prepare hierarchical marker for nested ordered list item
274                $this->olist_info[$this->olist_level] = $m['num'];
275                $lv = $this->olist_level;
276                $attr = ' value="'.$m['num'].'"';
277                $attr.= ' data-marker="'.$this->olist_marker($lv).'"';
278                break;
279            case ';':
280                $attr = 'class="'.$m['class'].'"';
281                break;
282            default:
283                $attr = '';
284        }
285        $this->_writeCall($tag,$attr,DOKU_LEXER_ENTER, $pos,$match,$handler);
286    }
287
288    /**
289     * write call to close a list item [li|dt|dd]
290     */
291    private function _closeItem($m, $pos, $match, $handler)
292    {
293        $tag = $m['item'];
294        $this->_writeCall($tag,'',DOKU_LEXER_EXIT, $pos,$match,$handler);
295    }
296
297    /**
298     * write call to open inner wrapper [div|span]
299     */
300    private function _openWrapper($m, $pos, $match, $handler)
301    {
302        switch ($m['mk']) {
303            case ';':  // dl dt
304            case ';;': // dl dt, explicitly no-compact
305                $tag = 'span'; $attr = '';
306                break;
307            case ':':  // dl dd
308            case '::': // dl dd p
309                return;
310                break;
311            default:
312                if (!$this->use_div) return;
313                $tag = 'div';  $attr = 'class="li"';
314                break;
315        }
316        $this->_writeCall($tag,$attr,DOKU_LEXER_ENTER, $pos,$match,$handler);
317    }
318
319    /**
320     * write call to close inner wrapper [div|span]
321     */
322    private function _closeWrapper($m, $pos, $match, $handler)
323    {
324        switch ($m['mk']) {
325            case ';':  // dl dt
326            case ';;': // dl dt, explicitly no-compact
327                $tag = 'span';
328                break;
329            case ':':  // dl dd
330            case '::': // dl dd p
331                return;
332                break;
333            default:
334                if (!$this->use_div) return;
335                $tag = 'div';
336                break;
337        }
338        $this->_writeCall($tag,'',DOKU_LEXER_EXIT, $pos,$match,$handler);
339    }
340
341    /**
342     * write call to open paragraph (p tag)
343     */
344    private function _openParagraph($pos, $match, $handler)
345    {
346        $this->_writeCall('p','',DOKU_LEXER_ENTER, $pos,$match,$handler);
347    }
348
349    /**
350     * write call to close paragraph (p tag)
351     */
352    private function _closeParagraph($pos, $match, $handler)
353    {
354        $this->_writeCall('p','',DOKU_LEXER_EXIT, $pos,$match,$handler);
355    }
356
357    /**
358     * Handle the match
359     */
360    public function handle($match, $state, $pos, Doku_Handler $handler)
361    {
362        switch ($state) {
363        case DOKU_LEXER_SPECIAL:
364            //  specify class attribute for lists [ul|ol|dl]
365            $this->storeListClass($match);
366            break;
367
368        case DOKU_LEXER_ENTER:
369            $m1 = $this->interpret($match);
370            if (($m1['list'] == 'ol') && !isset($m1['num'])) {
371                $m1['num'] = 1;
372            }
373
374            // open list tag [ul|ol|dl]
375            $this->_openList($m1, $pos,$match,$handler);
376            // open item tag [li|dt|dd]
377            $this->_openItem($m1, $pos,$match,$handler);
378            // open inner wrapper [div|span]
379            $this->_openWrapper($m1, $pos,$match,$handler);
380            // open p if necessary
381            if (isset($m1['p'])) $this->_openParagraph($pos,$match,$handler);
382
383            // add to stack
384            array_push($this->stack, $m1);
385            break;
386
387        case DOKU_LEXER_UNMATCHED:
388            // cdata --- use base() as _writeCall() is prefixed for private/protected
389            $handler->base($match, $state, $pos);
390            break;
391
392        case DOKU_LEXER_EXIT:
393            // clear list_class
394            $this->list_class = array();
395            // do not break here!
396
397        case DOKU_LEXER_MATCHED:
398            //  specify class attribute for lists [ul|ol|dl]
399            if (substr($match, -2) == '~~') {
400                $this->storeListClass($match);
401                break;
402            }
403
404            // retrieve previous list item from stack
405            $m0 = array_pop($this->stack);
406            $m1 = $this->interpret($match);
407
408            // set m1 depth if dt and dd are in one line
409            if (($m1['depth'] == 0) && ($m0['item'] == 'dt')) {
410                $m1['depth'] = $m0['depth'];
411            }
412
413            // continued list item content, indented by at least two spaces
414            if (empty($m1['mk']) && ($m1['depth'] > 0)) {
415                // !!EXPERIMENTAL SCRIPTIO CONTINUA concerns!!
416                // replace indent to single space, but leave it for LineBreak2 plugin
417                $handler->base("\n",  DOKU_LEXER_UNMATCHED, $pos);
418
419                // restore stack
420                array_push($this->stack, $m0);
421                break;
422            }
423
424            // close p if necessary
425            if (isset($m0['p'])) $this->_closeParagraph($pos,$match,$handler);
426
427            // close inner wrapper [div|span] if necessary
428            if ($m1['mk'] == '+:') {
429                // Paragraph markup
430                if ($m0['depth'] > $m1['depth']) {
431                    $this->_closeWrapper($m0, $pos,$match,$handler);
432                } else {
433                    // new paragraph can not be deeper than previous depth
434                    // fix current depth quietly
435                    $m1['depth'] = min($m0['depth'], $m1['depth']);
436                }
437                // fix previous p type
438                $m0['p'] = 1;
439            } else {
440                // List item markup
441                if ($m0['depth'] >= $m1['depth']) {
442                    $this->_closeWrapper($m0, $pos,$match,$handler);
443                }
444            }
445
446            // List item becomes shallower - close deeper list
447            while ($m0['depth'] > $m1['depth']) {
448                // close item [li|dt|dd]
449                $this->_closeItem($m0, $pos,$match,$handler);
450                // close list [ul|ol|dl]
451                $this->_closeList($m0, $pos,$match,$handler);
452
453                $m0 = array_pop($this->stack);
454            }
455
456            // Break out of switch structure if end of list block
457            if ($state == DOKU_LEXER_EXIT) {
458                break;
459            }
460
461            // Paragraph markup
462            if ($m1['mk'] == '+:') {
463                $this->_openParagraph($pos,$match,$handler);
464                $m1['depth'] = $m0['depth'];
465                $m1 = $m0 + array('p' => 1);
466
467                // restore stack
468                array_push($this->stack, $m1);
469                break;
470            }
471
472            // List item markup
473            if ($m0['depth'] < $m1['depth']) { // list becomes deeper
474                // restore stack
475                array_push($this->stack, $m0);
476
477            } else if ($m0['depth'] == $m1['depth']) {
478                // close item [li|dt|dd]
479                $this->_closeItem($m0, $pos,$match,$handler);
480                // close list [ul|ol|dl] if necessary
481                if ($this->isListTypeChanged($m0, $m1)) {
482                    $this->_closeList($m0, $pos,$match,$handler);
483                    $m0['num'] = 0;
484                }
485            }
486
487            // open list [ul|ol|dl] if necessary
488            if (($m0['depth'] < $m1['depth']) || ($m0['num'] === 0)) {
489                if (!is_numeric($m1['num'])) $m1['num'] = 1;
490                $this->_openList($m1, $pos,$match,$handler);
491            } else {
492                if (!is_numeric($m1['num'])) $m1['num'] = $m0['num']  +1;
493            }
494
495            // open item [li|dt|dd]
496            $this->_openItem($m1, $pos,$match,$handler);
497            // open inner wrapper [div|span]
498            $this->_openWrapper($m1, $pos,$match,$handler);
499            // open p if necessary
500            if (isset($m1['p'])) $this->_openParagraph($pos,$match,$handler);
501
502            // add to stack
503            array_push($this->stack, $m1);
504
505        } // end of switch
506        return false;
507    }
508
509
510    /**
511     * Create output
512     */
513    public function render($format, Doku_Renderer $renderer, $data)
514    {
515        switch ($format) {
516            case 'xhtml':
517                return $this->render_xhtml($renderer, $data);
518            //case 'latex':
519            //    $latex = $this->loadHelper('extlist_latex');
520            //    return $latex->render($renderer, $data);
521            //case 'odt':
522            //    $odt = $this->loadHelper('extlist_odt');
523            //    return $odt->render($renderer, $data);
524            default:
525                return false;
526        }
527    }
528
529
530    /**
531     * Create xhtml output
532     */
533    protected function render_xhtml(Doku_Renderer $renderer, $data)
534    {
535        list($state, $tag, $attr) = $data;
536        switch ($state) {
537            case DOKU_LEXER_ENTER:   // open tag
538                $renderer->doc.= $this->_open($tag, $attr);
539                break;
540            case DOKU_LEXER_MATCHED: // defensive, shouldn't occur
541            case DOKU_LEXER_UNMATCHED:
542                $renderer->cdata($tag);
543                break;
544            case DOKU_LEXER_EXIT:    // close tag
545                $renderer->doc.= $this->_close($tag);
546                break;
547        }
548        return true;
549    }
550
551    /**
552     * open a tag, a utility for render_xhtml()
553     */
554    protected function _open($tag, $attr = null)
555    {
556        if (!empty($attr)) $attr = ' '.$attr;
557        list($before, $after) = $this->_tag_indent($tag);
558        return $before.'<'.$tag.$attr.'>'.$after;
559    }
560
561    /**
562     * close a tag, a utility for render_xhtml()
563     */
564    protected function _close($tag)
565    {
566        list($before, $after) = $this->_tag_indent('/'.$tag);
567        return $before.'</'.$tag.'>'.$after;
568    }
569
570    /**
571     * indent tags for readability if HTML source
572     *
573     * @param string $tag tag name
574     * @return array
575     */
576    private function _tag_indent($tag)
577    {
578        // prefix and surffix of html tags
579        $indent = array(
580            'ol' => array("\n","\n"),  '/ol' => array("","\n"),
581            'ul' => array("\n","\n"),  '/ul' => array("","\n"),
582            'dl' => array("\n","\n"),  '/dl' => array("","\n"),
583            'li' => array("  ",""),    '/li' => array("","\n"),
584            'dt' => array("  ",""),    '/dt' => array("","\n"),
585            'dd' => array("  ","\n"),  '/dd' => array("","\n"),
586            'p'  => array("\n",""),    '/p'  => array("","\n"),
587        );
588        return $indent[$tag];
589    }
590
591}
592