]> CyberLeo.Net >> Repos - SourceForge/phpwiki.git/blob - lib/BlockParser.php
Yet another refactor. This is about twice as fast as it used to be (but
[SourceForge/phpwiki.git] / lib / BlockParser.php
1 <?php rcs_id('$Id: BlockParser.php,v 1.18 2002-02-01 06:00:28 dairiki Exp $');
2 /* Copyright (C) 2002, Geoffrey T. Dairiki <dairiki@dairiki.org>
3  *
4  * This file is part of PhpWiki.
5  * 
6  * PhpWiki is free software; you can redistribute it and/or modify
7  * it under the terms of the GNU General Public License as published by
8  * the Free Software Foundation; either version 2 of the License, or
9  * (at your option) any later version.
10  * 
11  * PhpWiki is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU General Public License for more details.
15  * 
16  * You should have received a copy of the GNU General Public License
17  * along with PhpWiki; if not, write to the Free Software
18  * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
19  */
20 require_once('lib/HtmlElement.php');
21 require_once('lib/InlineParser.php');
22
23 require_once('lib/transform.php');
24
25 ////////////////////////////////////////////////////////////////
26 //
27 //
28
29 /**
30  * FIXME:
31  *  Still to do:
32  *    (old-style) tables
33  */
34
35 // FIXME: unify this with the RegexpSet in InlinePArser.
36
37 /**
38  * Return type from RegexpSet::match and RegexpSet::nextMatch.
39  *
40  * @see RegexpSet
41  */
42 class AnchoredRegexpSet_match {
43     /**
44      * The matched text.
45      */
46     var $match;
47
48     /**
49      * The text following the matched text.
50      */
51     var $postmatch;
52
53     /**
54      * Index of the regular expression which matched.
55      */
56     var $regexp_ind;
57 }
58
59 /**
60  * A set of regular expressions.
61  *
62  * This class is probably only useful for InlineTransformer.
63  */
64 class AnchoredRegexpSet
65 {
66     /** Constructor
67      *
68      * @param $regexps array A list of regular expressions.  The
69      * regular expressions should not include any sub-pattern groups
70      * "(...)".  (Anonymous groups, like "(?:...)", as well as
71      * look-ahead and look-behind assertions are fine.)
72      */
73     function AnchoredRegexpSet ($regexps) {
74         $this->_regexps = $regexps;
75         $this->_re = "/((" . join(")|(", $regexps) . "))/Ax";
76     }
77
78     /**
79      * Search text for the next matching regexp from the Regexp Set.
80      *
81      * @param $text string The text to search.
82      *
83      * @return object  A RegexpSet_match object, or false if no match.
84      */
85     function match ($text) {
86         if (! preg_match($this->_re, $text, $m)) {
87             return false;
88         }
89         
90         $match = new AnchoredRegexpSet_match;
91         $match->postmatch = substr($text, strlen($m[0]));
92         $match->match = $m[1];
93         $match->regexp_ind = count($m) - 3;
94         return $match;
95     }
96
97     /**
98      * Search for next matching regexp.
99      *
100      * Here, 'next' has two meanings:
101      *
102      * Match the next regexp(s) in the set, at the same position as the last match.
103      *
104      * If that fails, match the whole RegexpSet, starting after the position of the
105      * previous match.
106      *
107      * @param $text string Text to search.
108      *
109      * @param $prevMatch A RegexpSet_match object
110      *
111      * $prevMatch should be a match object obtained by a previous
112      * match upon the same value of $text.
113      *
114      * @return object  A RegexpSet_match object, or false if no match.
115      */
116     function nextMatch ($text, $prevMatch) {
117         // Try to find match at same position.
118         $regexps = array_slice($this->_regexps, $prevMatch->regexp_ind + 1);
119         if (!$regexps) {
120             return false;
121         }
122
123         $pat= "/ ( (" . join(')|(', $regexps) . ") ) /Axs";
124
125         if (! preg_match($pat, $text, $m)) {
126             return false;
127         }
128         
129         $match = new AnchoredRegexpSet_match;
130         $match->postmatch = substr($text, strlen($m[0]));
131         $match->match = $m[1];
132         $match->regexp_ind = count($m) - 3 + $prevMatch->regexp_ind + 1;;
133         return $match;
134     }
135 }
136
137
138     
139 class BlockParser_Input {
140
141     function BlockParser_Input ($text) {
142         
143         // Expand leading tabs.
144         // FIXME: do this better.
145         //
146         // We want to ensure the only characters matching \s are ' ' and "\n".
147         //
148         $text = preg_replace('/(?![ \n])\s/', ' ', $text);
149         assert(!preg_match('/(?![ \n])\s/', $text));
150
151         $this->_lines = preg_split('/[^\S\n]*\n/', $text);
152         $this->_pos = 0;
153
154         $this->_initBlockTypes();
155     }
156
157     function currentLine () {
158         if ($this->_pos >= count($this->_lines))
159             return false;
160         return $this->_lines[$this->_pos];
161     }
162         
163     function nextLine () {
164         $this->_pos++;
165         if ($this->_pos >= count($this->_lines))
166             return false;
167         return $this->_lines[$this->_pos];
168     }
169
170     function advance () {
171         $this->_pos++;
172     }
173     
174     function getPos () {
175         return $this->_pos;
176     }
177
178     function setPos ($pos) {
179         $this->_pos = $pos;
180     }
181
182     function getPrefix () {
183         return '';
184     }
185
186     function getDepth () {
187         return 0;
188     }
189
190     function where () {
191         if ($this->_pos < count($this->_lines))
192             return $this->_lines[$this->_pos];
193         else
194             return "<EOF>";
195     }
196
197     // FIXME: hackish
198     function _initBlockTypes () {
199         foreach (array('oldlists', 'list', 'dl', 'table_dl',
200                        'blockquote', 'heading', 'hr', 'pre', 'email_blockquote',
201                        'plugin', 'p')
202                  as $type) {
203             $class = "Block_$type";
204             $proto = new $class;
205             $this->_block_types[] = $proto;
206             $this->_regexps[] = $proto->_re;
207         }
208         $this->_regexpset = new AnchoredRegexpSet($this->_regexps);
209     }
210     
211     function getBlock() {
212         $atSpace = false;
213         
214         $line = $this->currentLine();
215         if ($line === '') {
216             $atSpace = $this->getPos();
217             while ( ($line = $this->nextLine()) === '' )
218                 ;
219         }
220         if ($line === false) {
221             if ($atSpace)
222                 $this->setPos($atSpace);
223             return false;
224         }
225
226         $re_set = &$this->_regexpset;
227         for ($m = $re_set->match($line); $m; $m = $re_set->nextMatch($line, $m)) {
228             $block = $this->_block_types[$m->regexp_ind];
229             //$this->_debug('>', get_class($block));
230             
231             if ($block->_match($this, $m)) {
232                 //$this->_debug('<', get_class($block));
233                 $block->_follows_space = (bool) $atSpace;
234                 return $block;
235             }
236             //$this->_debug('[', "_match failed");
237         }
238
239
240         //if ($input->getDepth() == 0) {
241         // We should never get here.
242         //preg_match('/.*/A', substr($this->_text, $this->_pos), $m);// get first line
243         trigger_error("Couldn't match block: '$line'", E_USER_NOTICE);
244         //}
245         
246         return false;
247     }
248
249     
250     function _debug ($tab, $msg) {
251         //return ;
252         $where = $this->where();
253         $tab = str_repeat('____', $this->getDepth() ) . $tab;
254         printXML(HTML::div("$tab $msg: at: '",
255                            HTML::tt($where),
256                            "'"));
257     }
258 }
259
260 class BlockParser_InputSubBlock extends BlockParser_Input
261 {
262     function BlockParser_InputSubBlock (&$input, $prefix_re, $initial_prefix = false) {
263         $this->_input = &$input;
264         $this->_prefix_pat = "/$prefix_re|\\s*\$/Ax";
265         $this->_prefix = $initial_prefix;
266
267         // FIXME: hackish
268         $this->_block_types = &$input->_block_types;
269         $this->_regexpset = &$input->_regexpset;
270     }
271
272     function currentLine () {
273         if (($line = $this->_input->currentLine()) === false)
274             return false;
275         if ($this->_prefix === false) {
276             if (!preg_match($this->_prefix_pat, $line, $m))
277                 return false;
278             $this->_prefix = $m[0];
279         }
280         return (string) substr($line, strlen($this->_prefix));
281     }
282         
283     function nextLine () {
284         if (($line = $this->_input->nextLine()) === false) {
285             $this->_prefix = false;
286             return false;
287         }
288         if (!preg_match($this->_prefix_pat, $line, $m)) {
289             $this->_prefix = false;
290             return false;
291         }
292         $this->_prefix = $m[0];
293         return (string) substr($line, strlen($this->_prefix));
294     }
295
296     function advance () {
297         $this->_prefix = false;
298         $this->_input->advance();
299     }
300     
301     function getPos () {
302         return array($this->_prefix, $this->_input->getPos());
303     }
304
305     function setPos ($pos) {
306         $this->_prefix = $pos[0];
307         $this->_input->setPos($pos[1]);
308     }
309     
310     function getPrefix () {
311         assert ($this->_prefix !== false);
312         return $this->_input->getPrefix() . $this->_prefix;
313     }
314
315     function getDepth () {
316         return $this->_input->getDepth() + 1;
317     }
318
319     function where () {
320         return $this->_input->where();
321     }
322 }
323     
324
325 class BlockMarkup extends XmlContent {
326     var $_tag;
327     var $_attr = false;
328     var $_re;
329
330         
331     function _match (&$input, $match) {
332         trigger_error('pure virtual', E_USER_ERROR);
333     }
334
335     function merge ($followingBlock) {
336         return false;
337     }
338
339     function finish (/*$tighten*/) {
340         return new HtmlElement($this->_tag, $this->_attr, $this->getContent());
341     }
342 }
343
344 class Block extends BlockMarkup {
345     function Block ($text = false) {
346         $this->XmlContent();
347         if ($text)
348             $this->_parse(new BlockParser_Input($text));
349     }
350     
351     function _parse (&$input) {
352         for ($block = $input->getBlock(); $block; $block = $nextBlock) {
353             while ($nextBlock = $input->getBlock()) {
354                 // Attempt to merge current with following block.
355                 if (! ($merged = $block->merge($nextBlock)) ) {
356                     break;      // can't merge
357                 }
358                 $block = $merged;
359             }
360             $this->pushContent($block->finish());
361         }
362     }
363 }
364
365 class SubBlock extends Block {
366     function SubBlock (&$input, $indent_re, $initial_indent = false) {
367         $this->Block();
368         $this->_parse(new BlockParser_InputSubBlock($input,
369                                                     $indent_re,
370                                                     $initial_indent));
371     }
372 }
373
374     
375 class Block_blockquote extends Block
376 {
377     var $_tag ='blockquote';
378     var $_depth;
379
380     var $_re = '\ +(?=\S)';
381     
382     function _match (&$input, $m) {
383         $this->_depth = strlen($m->match);
384         $indent = sprintf("\\ {%d}", $this->_depth);
385         $this->pushContent(new SubBlock($input, $indent, $m->match));
386         return true;
387     }
388
389     function merge ($nextBlock) {
390         if (get_class($nextBlock) == 'block_blockquote') {
391             assert ($nextBlock->_depth < $this->_depth);
392             $nextBlock->unshiftContent($this->finish());
393             return $nextBlock;
394         }
395         return false;
396     }
397 }
398
399 class Block_list extends Block
400 {
401     //var $_tag = 'ol' or 'ul';
402     var $_re = '\ {0,4}
403                 (?: [+#] | -(?!-) | [o](?=\ )
404                   | [*] (?! \S[^*]*(?<=\S)[*](?!\S) )
405                 )\ *(?=\S)';
406
407     function _match (&$input, $m) {
408         // A list as the first content in a list is not allowed.
409         // E.g.:
410         //   *  * Item
411         // Should markup as <ul><li>* Item</li></ul>,
412         // not <ul><li><ul><li>Item</li></ul>/li></ul>.
413         //
414         if (preg_match('/[*#+-o]/', $input->getPrefix())) {
415             return false;
416         }
417         
418         $prefix = $m->match;
419         $indent = sprintf("\\ {%d}", strlen($prefix));
420
421         $bullet = trim($m->match);
422         $this->_tag = $bullet == '#' ? 'ol' : 'ul';
423         
424         $this->pushContent(HTML::li(new SubBlock($input, $indent, $m->match)));
425         return true;
426     }
427     
428     function merge ($nextBlock) {
429         if (isa($nextBlock, 'Block_list') && $this->_tag == $nextBlock->_tag) {
430             $this->pushContent($nextBlock->getContent());
431             return $this;
432         }
433         return false;
434     }
435 }
436
437 class Block_dl extends Block_list
438 {
439     var $_tag = 'dl';
440     var $_re = '\ {0,4}\S.*:\s*$';
441
442     function _match (&$input, $m) {
443         if (!($p = $this->_do_match($input, $m)))
444             return false;
445         list ($term, $defn) = $p;
446         
447         $this->pushContent(HTML::dt($term), HTML::dd($defn));
448         return true;
449     }
450
451     function _do_match (&$input, $m) {
452         $pos = $input->getPos();
453
454         while ( ($line = $input->nextLine()) !== false ) {
455             if (preg_match('/\ *(?=\S)/A', $line, $mm)) {
456                 $indent = strlen($mm[0]);
457                 break;
458             }
459         }
460         $input->setPos($pos);
461         
462         if (!isset($indent))
463             return false;       // No body found.
464
465         $input->advance();      // Skip the first line.
466
467         $term = TransformInline(rtrim(substr(trim($m->match),0,-1)));
468         $defn = new SubBlock($input, sprintf("\\ {%d}", $indent));
469         return array($term, $defn);
470     }
471 }
472
473
474
475 class Block_table_dl_defn extends XmlContent
476 {
477     var $nrows;
478     var $ncols;
479     
480     function Block_table_dl_defn ($term, $defn) {
481         $this->XmlContent();
482         if (!is_array($defn))
483             $defn = $defn->getContent();
484
485         $this->_ncols = $this->_ComputeNcols($defn);
486         
487         $this->_nrows = 0;
488         foreach ($defn as $item) {
489             if ($this->_IsASubtable($item))
490                 $this->_addSubtable($item);
491             else
492                 $this->_addToRow($item);
493         }
494         $this->_flushRow();
495
496         $th = HTML::th($term);
497         if ($this->_nrows > 1)
498             $th->setAttr('rowspan', $this->_nrows);
499         $this->_setTerm($th);
500     }
501
502     function _addToRow ($item) {
503         if (empty($this->_accum)) {
504             $this->_accum = HTML::td();
505             if ($this->_ncols > 2)
506                 $this->_accum->setAttr('colspan', $this->_ncols - 1);
507         }
508         $this->_accum->pushContent($item);
509     }
510
511     function _flushRow () {
512         if (!empty($this->_accum)) {
513             $this->pushContent(HTML::tr($this->_accum));
514             $this->_accum = false;
515             $this->_nrows++;
516         }
517     }
518
519     function _addSubtable ($table) {
520         $this->_flushRow();
521         foreach ($table->getContent() as $subdef) {
522             $this->pushContent($subdef);
523             $this->_nrows += $subdef->nrows();
524         }
525     }
526
527     function _setTerm ($th) {
528         $first_row = &$this->_content[0];
529         if (isa($first_row, 'Block_table_dl_defn'))
530             $first_row->_setTerm($th);
531         else
532             $first_row->unshiftContent($th);
533     }
534     
535     function _ComputeNcols ($defn) {
536         $ncols = 2;
537         foreach ($defn as $item) {
538             if ($this->_IsASubtable($item)) {
539                 $row = $this->_FirstDefn($item);
540                 $ncols = max($ncols, $row->ncols() + 1);
541             }
542         }
543         return $ncols;
544     }
545
546     function _IsASubtable ($item) {
547         return isa($item, 'HtmlElement')
548             && $item->getTag() == 'table'
549             && $item->getAttr('class') == 'wiki-dl-table';
550     }
551
552     function _FirstDefn ($subtable) {
553         $defs = $subtable->getContent();
554         return $defs[0];
555     }
556
557     function ncols () {
558         return $this->_ncols;
559     }
560
561     function nrows () {
562         return $this->_nrows;
563     }
564
565     function setWidth ($ncols) {
566         assert($ncols >= $this->_ncols);
567         if ($ncols <= $this->_ncols)
568             return;
569         $rows = &$this->_content;
570         for ($i = 0; $i < count($rows); $i++) {
571             $row = &$rows[$i];
572             if (isa($row, 'Block_table_dl_defn'))
573                 $row->setWidth($ncols - 1);
574             else {
575                 $n = count($row->_content);
576                 $lastcol = &$row->_content[$n - 1];
577                 $lastcol->setAttr('colspan', $ncols - 1);
578             }
579         }
580     }
581 }
582
583 class Block_table_dl extends Block_dl
584 {
585     var $_tag = 'table';
586     var $_attr = array('class' => 'wiki-dl-table',
587                        'border' => 2, // FIXME: CSS?
588                        'cellspacing' => 0,
589                        'cellpadding' => 6);
590     
591
592     var $_re = '\ {0,4} (?:\S.*)? \| \s* $';
593
594     function _match (&$input, $m) {
595         if (!($p = $this->_do_match($input, $m)))
596             return false;
597         list ($term, $defn) = $p;
598
599         $this->pushContent(new Block_table_dl_defn($term, $defn));
600         return true;
601     }
602             
603     function finish () {
604         $defs = &$this->_content;
605
606         $ncols = 0;
607         foreach ($defs as $defn)
608             $ncols = max($ncols, $defn->ncols());
609         foreach ($defs as $key => $defn)
610             $defs[$key]->setWidth($ncols);
611
612         return parent::finish();
613     }
614 }
615
616 class Block_oldlists extends Block_list
617 {
618     //var $_tag = 'ol', 'ul', or 'dl';
619     var $_re = '(?: [*] (?! \S[^*]* (?<=\S) [*](?!\S) )
620                   | [#]
621                   | ; .* :
622                 ) .*? (?=\S)';
623
624     
625     function _match (&$input, $m) {
626         // FIXME:
627         if (!preg_match('/[*#;]*$/A', $input->getPrefix())) {
628             return false;
629         }
630         
631
632         $prefix = $m->match;
633         $oldindent = '[*#;](?=[#*]|;.*:.*\S)';
634         $newindent = sprintf('\\ {%d}', strlen($prefix));
635         $indent = "(?:$oldindent|$newindent)";
636
637         $bullet = $prefix[0];
638         if ($bullet == '*') {
639             $this->_tag = 'ul';
640             $item = HTML::li();
641         }
642         elseif ($bullet == '#') {
643             $this->_tag = 'ol';
644             $item = HTML::li();
645         }
646         else {
647             $this->_tag = 'dl';
648             list ($term,) = explode(':', substr($prefix, 1), 2);
649             $term = trim($term);
650             if ($term)
651                 $this->pushContent(HTML::dt(false, TransformInline($term)));
652             $item = HTML::dd();
653         }
654
655         $item->pushContent(new SubBlock($input, $indent, $m->match));
656         $this->pushContent($item);
657         return true;
658     }
659 }
660
661 class Block_pre extends BlockMarkup
662 {
663     var $_tag = 'pre';
664     var $_re = '<(?:pre|verbatim)>';
665
666     function _match (&$input, $m) {
667         $endtag = '</' . substr($m->match, 1);
668
669         if (($text = $this->_getBlock($input, $endtag)) === false)
670             return false;
671         
672         if (ltrim($m->postmatch))
673             array_unshift($text, $m->postmatch);
674         $text = join("\n", $text);
675         
676         // FIXME: no <img>, <big>, <small>, <sup>, or <sub>'s allowed
677         // in a <pre>.
678         if ($m->match == '<pre>')
679             $text = TransformInline($text);
680
681         $this->pushContent($text);
682         return true;
683     }
684
685     function _getBlock (&$input, $end_tag) {
686         $pos = $input->getPos();
687         $text = array();
688
689         while ( ($line = $input->nextLine()) !== false ) {
690             if (rtrim($line) == $end_tag) {
691                 $input->advance();
692                 return $text;
693             }
694             $text[] = $line;
695         }
696         $input->setPos($pos);
697         return false;
698     }
699 }
700
701
702 class Block_plugin extends Block_pre
703 {
704     var $_tag = 'div';
705     var $_attr = array('class' => 'plugin');
706     var $_re = '<\?plugin(?:-form)?\s';
707     
708     function _match (&$input, $m) {
709         if (preg_match('/( .*? (?<!~) \?> ) (.*)/x', $m->postmatch, $mm)) {
710             if (ltrim($mm[2]))
711                 return false;
712             $pi = $m->match . $m->postmatch;
713             $input->advance();
714             printXML(HTML::p("PI:", $pi));
715             
716         }
717         else {
718             if (($text = $this->_getBlock($input, '?>')) === false)
719                 return false;
720             $pi = $m->match . $m->postmatch . "\n" . join("\n", $text) . "\n?>";
721         }
722             
723         global $request;
724         $loader = new WikiPluginLoader;
725         $this->pushContent($loader->expandPI($pi, $request));
726         return true;
727     }
728 }
729
730 class Block_email_blockquote extends Block
731 {
732     // FIXME: move CSS to CSS.
733     var $_tag ='blockquote';
734     var $_attr = array('style' => 'border-left-width: medium; border-left-color: #0f0; border-left-style: ridge; padding-left: 1em; margin-left: 0em; margin-right: 0em;');
735     var $_depth;
736     var $_re = '>\ ?';
737     
738     function _match (&$input, $m) {
739         $indent = str_replace(' ', '\\ ', $m->match);
740         $this->pushContent(new SubBlock($input, $indent, $m->match));
741         return true;
742     }
743 }
744
745 class Block_hr extends BlockMarkup
746 {
747     var $_tag = 'hr';
748     var $_re = '-{4,}\s*$';
749
750     function _match (&$input, $m) {
751         $input->advance();
752         return true;
753     }
754 }
755
756 class Block_heading extends BlockMarkup
757 {
758     var $_re = '!{1,3}';
759     
760     function _match (&$input, $m) {
761         $this->_tag = "h" . (5 - strlen($m->match));
762         $this->pushContent(TransformInline(trim($m->postmatch)));
763         $input->advance();
764         return true;
765     }
766 }
767
768 class Block_p extends BlockMarkup
769 {
770     var $_tag = 'p';
771     var $_re = '\S.*';
772
773     function _match (&$input, $m) {
774         $this->_text = $m->match;
775         $input->advance();
776         return true;
777     }
778     
779     function merge ($nextBlock) {
780         $class = get_class($nextBlock);
781         if ($class == 'block_p' && !$nextBlock->_follows_space) {
782             $this->_text .= $nextBlock->_text;
783             return $this;
784         }
785         return false;
786     }
787             
788     function finish () {
789         $this->pushContent(TransformInline(trim($this->_text)));
790         return parent::finish();
791     }
792 }
793
794 ////////////////////////////////////////////////////////////////
795 //
796
797
798
799
800 // FIXME: This is temporary, too...
801 function NewTransform ($text) {
802
803     set_time_limit(5);
804     
805     // Expand leading tabs.
806     // FIXME: do this better. also move  it...
807     $text = preg_replace('/^\ *[^\ \S\n][^\S\n]*/me', "str_repeat(' ', strlen('\\0'))", $text);
808     assert(!preg_match('/^\ *\t/', $text));
809
810     $output = new Block($text);
811     return $output;
812 }
813
814
815 // FIXME: bad name
816 function TransformRevision ($revision) {
817     if ($revision->get('markup') == 'new') {
818         return NewTransform($revision->getPackedContent());
819     }
820     else {
821         return do_transform($revision->getContent());
822     }
823 }
824
825
826 // (c-file-style: "gnu")
827 // Local Variables:
828 // mode: php
829 // tab-width: 8
830 // c-basic-offset: 4
831 // c-hanging-comment-ender-p: nil
832 // indent-tabs-mode: nil
833 // End:   
834 ?>