]> git.donarmstrong.com Git - roundcube.git/blob - program/lib/html2text.php
Imported Upstream version 0.7
[roundcube.git] / program / lib / html2text.php
1 <?php
2
3 /*************************************************************************
4  *                                                                       *
5  * class.html2text.inc                                                   *
6  *                                                                       *
7  *************************************************************************
8  *                                                                       *
9  * Converts HTML to formatted plain text                                 *
10  *                                                                       *
11  * Copyright (c) 2005-2007 Jon Abernathy <jon@chuggnutt.com>             *
12  * All rights reserved.                                                  *
13  *                                                                       *
14  * This script is free software; you can redistribute it and/or modify   *
15  * it under the terms of the GNU General Public License as published by  *
16  * the Free Software Foundation; either version 2 of the License, or     *
17  * (at your option) any later version.                                   *
18  *                                                                       *
19  * The GNU General Public License can be found at                        *
20  * http://www.gnu.org/copyleft/gpl.html.                                 *
21  *                                                                       *
22  * This script is distributed in the hope that it will be useful,        *
23  * but WITHOUT ANY WARRANTY; without even the implied warranty of        *
24  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the          *
25  * GNU General Public License for more details.                          *
26  *                                                                       *
27  * Author(s): Jon Abernathy <jon@chuggnutt.com>                          *
28  *                                                                       *
29  * Last modified: 08/08/07                                               *
30  *                                                                       *
31  *************************************************************************/
32
33
34 /**
35  *  Takes HTML and converts it to formatted, plain text.
36  *
37  *  Thanks to Alexander Krug (http://www.krugar.de/) to pointing out and
38  *  correcting an error in the regexp search array. Fixed 7/30/03.
39  *
40  *  Updated set_html() function's file reading mechanism, 9/25/03.
41  *
42  *  Thanks to Joss Sanglier (http://www.dancingbear.co.uk/) for adding
43  *  several more HTML entity codes to the $search and $replace arrays.
44  *  Updated 11/7/03.
45  *
46  *  Thanks to Darius Kasperavicius (http://www.dar.dar.lt/) for
47  *  suggesting the addition of $allowed_tags and its supporting function
48  *  (which I slightly modified). Updated 3/12/04.
49  *
50  *  Thanks to Justin Dearing for pointing out that a replacement for the
51  *  <TH> tag was missing, and suggesting an appropriate fix.
52  *  Updated 8/25/04.
53  *
54  *  Thanks to Mathieu Collas (http://www.myefarm.com/) for finding a
55  *  display/formatting bug in the _build_link_list() function: email
56  *  readers would show the left bracket and number ("[1") as part of the
57  *  rendered email address.
58  *  Updated 12/16/04.
59  *
60  *  Thanks to Wojciech Bajon (http://histeria.pl/) for submitting code
61  *  to handle relative links, which I hadn't considered. I modified his
62  *  code a bit to handle normal HTTP links and MAILTO links. Also for
63  *  suggesting three additional HTML entity codes to search for.
64  *  Updated 03/02/05.
65  *
66  *  Thanks to Jacob Chandler for pointing out another link condition
67  *  for the _build_link_list() function: "https".
68  *  Updated 04/06/05.
69  *
70  *  Thanks to Marc Bertrand (http://www.dresdensky.com/) for
71  *  suggesting a revision to the word wrapping functionality; if you
72  *  specify a $width of 0 or less, word wrapping will be ignored.
73  *  Updated 11/02/06.
74  *
75  *  *** Big housecleaning updates below:
76  *
77  *  Thanks to Colin Brown (http://www.sparkdriver.co.uk/) for
78  *  suggesting the fix to handle </li> and blank lines (whitespace).
79  *  Christian Basedau (http://www.movetheweb.de/) also suggested the
80  *  blank lines fix.
81  *
82  *  Special thanks to Marcus Bointon (http://www.synchromedia.co.uk/),
83  *  Christian Basedau, Norbert Laposa (http://ln5.co.uk/),
84  *  Bas van de Weijer, and Marijn van Butselaar
85  *  for pointing out my glaring error in the <th> handling. Marcus also
86  *  supplied a host of fixes.
87  *
88  *  Thanks to Jeffrey Silverman (http://www.newtnotes.com/) for pointing
89  *  out that extra spaces should be compressed--a problem addressed with
90  *  Marcus Bointon's fixes but that I had not yet incorporated.
91  *
92  *      Thanks to Daniel Schledermann (http://www.typoconsult.dk/) for
93  *  suggesting a valuable fix with <a> tag handling.
94  *
95  *  Thanks to Wojciech Bajon (again!) for suggesting fixes and additions,
96  *  including the <a> tag handling that Daniel Schledermann pointed
97  *  out but that I had not yet incorporated. I haven't (yet)
98  *  incorporated all of Wojciech's changes, though I may at some
99  *  future time.
100  *
101  *  *** End of the housecleaning updates. Updated 08/08/07.
102  *
103  *  @author Jon Abernathy <jon@chuggnutt.com>
104  *  @version 1.0.0
105  *  @since PHP 4.0.2
106  */
107 class html2text
108 {
109
110     /**
111      *  Contains the HTML content to convert.
112      *
113      *  @var string $html
114      *  @access public
115      */
116     var $html;
117
118     /**
119      *  Contains the converted, formatted text.
120      *
121      *  @var string $text
122      *  @access public
123      */
124     var $text;
125
126     /**
127      *  Maximum width of the formatted text, in columns.
128      *
129      *  Set this value to 0 (or less) to ignore word wrapping
130      *  and not constrain text to a fixed-width column.
131      *
132      *  @var integer $width
133      *  @access public
134      */
135     var $width = 70;
136
137     /**
138      *  List of preg* regular expression patterns to search for,
139      *  used in conjunction with $replace.
140      *
141      *  @var array $search
142      *  @access public
143      *  @see $replace
144      */
145     var $search = array(
146         "/\r/",                                  // Non-legal carriage return
147         "/[\n\t]+/",                             // Newlines and tabs
148         '/<script[^>]*>.*?<\/script>/i',         // <script>s -- which strip_tags supposedly has problems with
149         '/<style[^>]*>.*?<\/style>/i',           // <style>s -- which strip_tags supposedly has problems with
150         '/<p[^>]*>/i',                           // <P>
151         '/<br[^>]*>/i',                          // <br>
152         '/<i[^>]*>(.*?)<\/i>/i',                 // <i>
153         '/<em[^>]*>(.*?)<\/em>/i',               // <em>
154         '/(<ul[^>]*>|<\/ul>)/i',                 // <ul> and </ul>
155         '/(<ol[^>]*>|<\/ol>)/i',                 // <ol> and </ol>
156         '/<li[^>]*>(.*?)<\/li>/i',               // <li> and </li>
157         '/<li[^>]*>/i',                          // <li>
158         '/<hr[^>]*>/i',                          // <hr>
159         '/<div[^>]*>/i',                         // <div>
160         '/(<table[^>]*>|<\/table>)/i',           // <table> and </table>
161         '/(<tr[^>]*>|<\/tr>)/i',                 // <tr> and </tr>
162         '/<td[^>]*>(.*?)<\/td>/i',               // <td> and </td>
163     );
164
165     /**
166      *  List of pattern replacements corresponding to patterns searched.
167      *
168      *  @var array $replace
169      *  @access public
170      *  @see $search
171      */
172     var $replace = array(
173         '',                                     // Non-legal carriage return
174         ' ',                                    // Newlines and tabs
175         '',                                     // <script>s -- which strip_tags supposedly has problems with
176         '',                                     // <style>s -- which strip_tags supposedly has problems with
177         "\n\n",                                 // <P>
178         "\n",                                   // <br>
179         '_\\1_',                                // <i>
180         '_\\1_',                                // <em>
181         "\n\n",                                 // <ul> and </ul>
182         "\n\n",                                 // <ol> and </ol>
183         "\t* \\1\n",                            // <li> and </li>
184         "\n\t* ",                               // <li>
185         "\n-------------------------\n",        // <hr>
186         "<div>\n",                              // <div>
187         "\n\n",                                 // <table> and </table>
188         "\n",                                   // <tr> and </tr>
189         "\t\t\\1\n",                            // <td> and </td>
190     );
191
192     /**
193      *  List of preg* regular expression patterns to search for,
194      *  used in conjunction with $ent_replace.
195      *
196      *  @var array $ent_search
197      *  @access public
198      *  @see $ent_replace
199      */
200     var $ent_search = array(
201         '/&(nbsp|#160);/i',                      // Non-breaking space
202         '/&(quot|rdquo|ldquo|#8220|#8221|#147|#148);/i',
203                                                          // Double quotes
204         '/&(apos|rsquo|lsquo|#8216|#8217);/i',   // Single quotes
205         '/&gt;/i',                               // Greater-than
206         '/&lt;/i',                               // Less-than
207         '/&(copy|#169);/i',                      // Copyright
208         '/&(trade|#8482|#153);/i',               // Trademark
209         '/&(reg|#174);/i',                       // Registered
210         '/&(mdash|#151|#8212);/i',               // mdash
211         '/&(ndash|minus|#8211|#8722);/i',        // ndash
212         '/&(bull|#149|#8226);/i',                // Bullet
213         '/&(pound|#163);/i',                     // Pound sign
214         '/&(euro|#8364);/i',                     // Euro sign
215         '/&(amp|#38);/i',                        // Ampersand: see _converter()
216         '/[ ]{2,}/',                             // Runs of spaces, post-handling
217     );
218
219     /**
220      *  List of pattern replacements corresponding to patterns searched.
221      *
222      *  @var array $ent_replace
223      *  @access public
224      *  @see $ent_search
225      */
226     var $ent_replace = array(
227         ' ',                                    // Non-breaking space
228         '"',                                    // Double quotes
229         "'",                                    // Single quotes
230         '>',
231         '<',
232         '(c)',
233         '(tm)',
234         '(R)',
235         '--',
236         '-',
237         '*',
238         '£',
239         'EUR',                                  // Euro sign. \80 ?
240         '|+|amp|+|',                            // Ampersand: see _converter()
241         ' ',                                    // Runs of spaces, post-handling
242     );
243
244     /**
245      *  List of preg* regular expression patterns to search for
246      *  and replace using callback function.
247      *
248      *  @var array $callback_search
249      *  @access public
250      */
251     var $callback_search = array(
252         '/<(h)[123456][^>]*>(.*?)<\/h[123456]>/i', // H1 - H3
253         '/<(b)[^>]*>(.*?)<\/b>/i',                 // <b>
254         '/<(strong)[^>]*>(.*?)<\/strong>/i',       // <strong>
255         '/<(a) [^>]*href=("|\')([^"\']+)\2[^>]*>(.*?)<\/a>/i',
256                                                    // <a href="">
257         '/<(th)[^>]*>(.*?)<\/th>/i',               // <th> and </th>
258     );
259
260    /**
261     *  List of preg* regular expression patterns to search for in PRE body,
262     *  used in conjunction with $pre_replace.
263     *
264     *  @var array $pre_search
265     *  @access public
266     *  @see $pre_replace
267     */
268     var $pre_search = array(
269         "/\n/",
270         "/\t/",
271         '/ /',
272         '/<pre[^>]*>/',
273         '/<\/pre>/'
274     );
275
276     /**
277      *  List of pattern replacements corresponding to patterns searched for PRE body.
278      *
279      *  @var array $pre_replace
280      *  @access public
281      *  @see $pre_search
282      */
283     var $pre_replace = array(
284         '<br>',
285         '&nbsp;&nbsp;&nbsp;&nbsp;',
286         '&nbsp;',
287         '',
288         ''
289     );
290
291     /**
292      *  Contains a list of HTML tags to allow in the resulting text.
293      *
294      *  @var string $allowed_tags
295      *  @access public
296      *  @see set_allowed_tags()
297      */
298     var $allowed_tags = '';
299
300     /**
301      *  Contains the base URL that relative links should resolve to.
302      *
303      *  @var string $url
304      *  @access public
305      */
306     var $url;
307
308     /**
309      *  Indicates whether content in the $html variable has been converted yet.
310      *
311      *  @var boolean $_converted
312      *  @access private
313      *  @see $html, $text
314      */
315     var $_converted = false;
316
317     /**
318      *  Contains URL addresses from links to be rendered in plain text.
319      *
320      *  @var string $_link_list
321      *  @access private
322      *  @see _build_link_list()
323      */
324     var $_link_list = '';
325
326     /**
327      *  Number of valid links detected in the text, used for plain text
328      *  display (rendered similar to footnotes).
329      *
330      *  @var integer $_link_count
331      *  @access private
332      *  @see _build_link_list()
333      */
334     var $_link_count = 0;
335
336     /**
337      * Boolean flag, true if a table of link URLs should be listed after the text.
338      *
339      * @var boolean $_do_links
340      * @access private
341      * @see html2text()
342      */
343     var $_do_links = true;
344
345     /**
346      *  Constructor.
347      *
348      *  If the HTML source string (or file) is supplied, the class
349      *  will instantiate with that source propagated, all that has
350      *  to be done it to call get_text().
351      *
352      *  @param string $source HTML content
353      *  @param boolean $from_file Indicates $source is a file to pull content from
354      *  @param boolean $do_links Indicate whether a table of link URLs is desired
355      *  @param integer $width Maximum width of the formatted text, 0 for no limit
356      *  @access public
357      *  @return void
358      */
359     function html2text( $source = '', $from_file = false, $do_links = true, $width = 75 )
360     {
361         if ( !empty($source) ) {
362             $this->set_html($source, $from_file);
363         }
364
365         $this->set_base_url();
366         $this->_do_links = $do_links;
367         $this->width = $width;
368     }
369
370     /**
371      *  Loads source HTML into memory, either from $source string or a file.
372      *
373      *  @param string $source HTML content
374      *  @param boolean $from_file Indicates $source is a file to pull content from
375      *  @access public
376      *  @return void
377      */
378     function set_html( $source, $from_file = false )
379     {
380         if ( $from_file && file_exists($source) ) {
381             $this->html = file_get_contents($source); 
382         }
383         else
384             $this->html = $source;
385
386         $this->_converted = false;
387     }
388
389     /**
390      *  Returns the text, converted from HTML.
391      *
392      *  @access public
393      *  @return string
394      */
395     function get_text()
396     {
397         if ( !$this->_converted ) {
398             $this->_convert();
399         }
400
401         return $this->text;
402     }
403
404     /**
405      *  Prints the text, converted from HTML.
406      *
407      *  @access public
408      *  @return void
409      */
410     function print_text()
411     {
412         print $this->get_text();
413     }
414
415     /**
416      *  Alias to print_text(), operates identically.
417      *
418      *  @access public
419      *  @return void
420      *  @see print_text()
421      */
422     function p()
423     {
424         print $this->get_text();
425     }
426
427     /**
428      *  Sets the allowed HTML tags to pass through to the resulting text.
429      *
430      *  Tags should be in the form "<p>", with no corresponding closing tag.
431      *
432      *  @access public
433      *  @return void
434      */
435     function set_allowed_tags( $allowed_tags = '' )
436     {
437         if ( !empty($allowed_tags) ) {
438             $this->allowed_tags = $allowed_tags;
439         }
440     }
441
442     /**
443      *  Sets a base URL to handle relative links.
444      *
445      *  @access public
446      *  @return void
447      */
448     function set_base_url( $url = '' )
449     {
450         if ( empty($url) ) {
451                 if ( !empty($_SERVER['HTTP_HOST']) ) {
452                     $this->url = 'http://' . $_SERVER['HTTP_HOST'];
453                 } else {
454                     $this->url = '';
455                 }
456         } else {
457             // Strip any trailing slashes for consistency (relative
458             // URLs may already start with a slash like "/file.html")
459             if ( substr($url, -1) == '/' ) {
460                 $url = substr($url, 0, -1);
461             }
462             $this->url = $url;
463         }
464     }
465
466     /**
467      *  Workhorse function that does actual conversion (calls _converter() method).
468      *
469      *  @access private
470      *  @return void
471      */
472     function _convert()
473     {
474         // Variables used for building the link list
475         $this->_link_count = 0;
476         $this->_link_list = '';
477
478         $text = trim(stripslashes($this->html));
479
480         // Convert HTML to TXT
481         $this->_converter($text);
482
483         // Add link list
484         if ( !empty($this->_link_list) ) {
485             $text .= "\n\nLinks:\n------\n" . $this->_link_list;
486         }
487
488         $this->text = $text;
489
490         $this->_converted = true;
491     }
492
493     /**
494      *  Workhorse function that does actual conversion.
495      *
496      *  First performs custom tag replacement specified by $search and
497      *  $replace arrays. Then strips any remaining HTML tags, reduces whitespace
498      *  and newlines to a readable format, and word wraps the text to
499      *  $width characters.
500      *
501      *  @param string Reference to HTML content string
502      *
503      *  @access private
504      *  @return void
505      */
506     function _converter(&$text)
507     {
508         // Convert <BLOCKQUOTE> (before PRE!)
509         $this->_convert_blockquotes($text);
510
511         // Convert <PRE>
512         $this->_convert_pre($text);
513
514         // Run our defined tags search-and-replace
515         $text = preg_replace($this->search, $this->replace, $text);
516
517         // Run our defined tags search-and-replace with callback
518         $text = preg_replace_callback($this->callback_search, array('html2text', '_preg_callback'), $text);
519
520         // Strip any other HTML tags
521         $text = strip_tags($text, $this->allowed_tags);
522
523         // Run our defined entities/characters search-and-replace
524         $text = preg_replace($this->ent_search, $this->ent_replace, $text);
525
526         // Replace known html entities
527         $text = html_entity_decode($text, ENT_COMPAT, 'UTF-8');
528
529         // Remove unknown/unhandled entities (this cannot be done in search-and-replace block)
530         $text = preg_replace('/&([a-zA-Z0-9]{2,6}|#[0-9]{2,4});/', '', $text);
531
532         // Convert "|+|amp|+|" into "&", need to be done after handling of unknown entities
533         // This properly handles situation of "&amp;quot;" in input string
534         $text = str_replace('|+|amp|+|', '&', $text);
535
536         // Bring down number of empty lines to 2 max
537         $text = preg_replace("/\n\s+\n/", "\n\n", $text);
538         $text = preg_replace("/[\n]{3,}/", "\n\n", $text);
539
540         // remove leading empty lines (can be produced by eg. P tag on the beginning)
541         $text = ltrim($text, "\n");
542
543         // Wrap the text to a readable format
544         // for PHP versions >= 4.0.2. Default width is 75
545         // If width is 0 or less, don't wrap the text.
546         if ( $this->width > 0 ) {
547                 $text = wordwrap($text, $this->width);
548         }
549     }
550
551     /**
552      *  Helper function called by preg_replace() on link replacement.
553      *
554      *  Maintains an internal list of links to be displayed at the end of the
555      *  text, with numeric indices to the original point in the text they
556      *  appeared. Also makes an effort at identifying and handling absolute
557      *  and relative links.
558      *
559      *  @param string $link URL of the link
560      *  @param string $display Part of the text to associate number with
561      *  @access private
562      *  @return string
563      */
564     function _build_link_list( $link, $display )
565     {
566             if ( !$this->_do_links )
567                 return $display;
568
569             if ( preg_match('!^(https?://|mailto:)!', $link) ) {
570             $this->_link_count++;
571             $this->_link_list .= '[' . $this->_link_count . "] $link\n";
572             $additional = ' [' . $this->_link_count . ']';
573             } elseif ( substr($link, 0, 11) == 'javascript:' ) {
574                     // Don't count the link; ignore it
575                     $additional = '';
576                 // what about href="#anchor" ?
577         } else {
578             $this->_link_count++;
579             $this->_link_list .= '[' . $this->_link_count . '] ' . $this->url;
580             if ( substr($link, 0, 1) != '/' ) {
581                 $this->_link_list .= '/';
582             }
583             $this->_link_list .= "$link\n";
584             $additional = ' [' . $this->_link_count . ']';
585         }
586
587         return $display . $additional;
588     }
589
590     /**
591      *  Helper function for PRE body conversion.
592      *
593      *  @param string HTML content
594      *  @access private
595      */
596     function _convert_pre(&$text)
597     {
598         // get the content of PRE element
599         while (preg_match('/<pre[^>]*>(.*)<\/pre>/ismU', $text, $matches)) {
600             // convert the content
601             $this->pre_content = sprintf('<div><br>%s<br></div>',
602                 preg_replace($this->pre_search, $this->pre_replace, $matches[1]));
603             // replace the content (use callback because content can contain $0 variable)
604             $text = preg_replace_callback('/<pre[^>]*>.*<\/pre>/ismU', 
605                 array('html2text', '_preg_pre_callback'), $text, 1);
606             // free memory
607             $this->pre_content = '';
608         }
609     }
610
611     /**
612      *  Helper function for BLOCKQUOTE body conversion.
613      *
614      *  @param string HTML content
615      *  @access private
616      */
617     function _convert_blockquotes(&$text)
618     {
619         if (preg_match_all('/<\/*blockquote[^>]*>/i', $text, $matches, PREG_OFFSET_CAPTURE)) {
620             $level = 0;
621             $diff = 0;
622             foreach ($matches[0] as $m) {
623                 if ($m[0][0] == '<' && $m[0][1] == '/') {
624                     $level--;
625                     if ($level < 0) {
626                         $level = 0; // malformed HTML: go to next blockquote
627                     }
628                     else if ($level > 0) {
629                         // skip inner blockquote
630                     }
631                     else {
632                         $end  = $m[1];
633                         $len  = $end - $taglen - $start;
634                         // Get blockquote content
635                         $body = substr($text, $start + $taglen - $diff, $len);
636
637                         // Set text width
638                         $p_width = $this->width;
639                         if ($this->width > 0) $this->width -= 2;
640                         // Convert blockquote content
641                         $body = trim($body);
642                         $this->_converter($body);
643                         // Add citation markers and create PRE block
644                         $body = preg_replace('/((^|\n)>*)/', '\\1> ', trim($body));
645                         $body = '<pre>' . htmlspecialchars($body) . '</pre>';
646                         // Re-set text width
647                         $this->width = $p_width;
648                         // Replace content
649                         $text = substr($text, 0, $start - $diff)
650                             . $body . substr($text, $end + strlen($m[0]) - $diff);
651
652                         $diff = $len + $taglen + strlen($m[0]) - strlen($body);
653                         unset($body);
654                     }
655                 }
656                 else {
657                     if ($level == 0) {
658                         $start = $m[1];
659                         $taglen = strlen($m[0]);
660                     }
661                     $level ++;
662                 }
663             }
664         }
665     }
666
667     /**
668      *  Callback function for preg_replace_callback use.
669      *
670      *  @param  array PREG matches
671      *  @return string
672      */
673     private function _preg_callback($matches)
674     {
675         switch($matches[1]) {
676         case 'b':
677         case 'strong':
678             return $this->_strtoupper($matches[2]);
679         case 'th':
680             return $this->_strtoupper("\t\t". $matches[2] ."\n");
681         case 'h':
682             return $this->_strtoupper("\n\n". $matches[2] ."\n\n");
683         case 'a':
684             // Remove spaces in URL (#1487805)
685             $url = str_replace(' ', '', $matches[3]);
686             return $this->_build_link_list($url, $matches[4]);
687         }
688     }
689
690     /**
691      *  Callback function for preg_replace_callback use in PRE content handler.
692      *
693      *  @param  array PREG matches
694      *  @return string
695      */
696     private function _preg_pre_callback($matches)
697     {
698         return $this->pre_content;
699     }
700
701     /**
702      *  Strtoupper multibyte wrapper function
703      *
704      *  @param  string
705      *  @return string
706      */
707     private function _strtoupper($str)
708     {
709         if (function_exists('mb_strtoupper'))
710             return mb_strtoupper($str);
711         else
712             return strtoupper($str);
713     }
714 }