class.html2text.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677
  1. <?php
  2. /*************************************************************************
  3. * *
  4. * Converts HTML to formatted plain text *
  5. * *
  6. * Portions Copyright (c) 2005-2007 Jon Abernathy <jon@chuggnutt.com> *
  7. * *
  8. * This script is free software; you can redistribute it and/or modify *
  9. * it under the terms of the GNU General Public License as published by *
  10. * the Free Software Foundation; either version 2 of the License, or *
  11. * (at your option) any later version. *
  12. * *
  13. * The GNU General Public License can be found at *
  14. * http://www.gnu.org/copyleft/gpl.html. *
  15. * *
  16. * This script is distributed in the hope that it will be useful, *
  17. * but WITHOUT ANY WARRANTY; without even the implied warranty of *
  18. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
  19. * GNU General Public License for more details. *
  20. * *
  21. *************************************************************************/
  22. /**
  23. * Converts HTML to formatted plain text
  24. */
  25. class Html2Text
  26. {
  27. /**
  28. * Contains the HTML content to convert.
  29. *
  30. * @type string
  31. */
  32. protected $html;
  33. /**
  34. * Contains the converted, formatted text.
  35. *
  36. * @type string
  37. */
  38. protected $text;
  39. /**
  40. * Maximum width of the formatted text, in columns.
  41. *
  42. * Set this value to 0 (or less) to ignore word wrapping
  43. * and not constrain text to a fixed-width column.
  44. *
  45. * @type integer
  46. */
  47. protected $width = 70;
  48. /**
  49. * List of preg* regular expression patterns to search for,
  50. * used in conjunction with $replace.
  51. *
  52. * @type array
  53. * @see $replace
  54. */
  55. protected $search = array(
  56. "/\r/", // Non-legal carriage return
  57. "/[\n\t]+/", // Newlines and tabs
  58. '/<head[^>]*>.*?<\/head>/i', // <head>
  59. '/<script[^>]*>.*?<\/script>/i', // <script>s -- which strip_tags supposedly has problems with
  60. '/<style[^>]*>.*?<\/style>/i', // <style>s -- which strip_tags supposedly has problems with
  61. '/<p[^>]*>/i', // <P>
  62. '/<br[^>]*>/i', // <br>
  63. '/<i[^>]*>(.*?)<\/i>/i', // <i>
  64. '/<em[^>]*>(.*?)<\/em>/i', // <em>
  65. '/(<ul[^>]*>|<\/ul>)/i', // <ul> and </ul>
  66. '/(<ol[^>]*>|<\/ol>)/i', // <ol> and </ol>
  67. '/(<dl[^>]*>|<\/dl>)/i', // <dl> and </dl>
  68. '/<li[^>]*>(.*?)<\/li>/i', // <li> and </li>
  69. '/<dd[^>]*>(.*?)<\/dd>/i', // <dd> and </dd>
  70. '/<dt[^>]*>(.*?)<\/dt>/i', // <dt> and </dt>
  71. '/<li[^>]*>/i', // <li>
  72. '/<hr[^>]*>/i', // <hr>
  73. '/<div[^>]*>/i', // <div>
  74. '/(<table[^>]*>|<\/table>)/i', // <table> and </table>
  75. '/(<tr[^>]*>|<\/tr>)/i', // <tr> and </tr>
  76. '/<td[^>]*>(.*?)<\/td>/i', // <td> and </td>
  77. '/<span class="_html2text_ignore">.+?<\/span>/i' // <span class="_html2text_ignore">...</span>
  78. );
  79. /**
  80. * List of pattern replacements corresponding to patterns searched.
  81. *
  82. * @type array
  83. * @see $search
  84. */
  85. protected $replace = array(
  86. '', // Non-legal carriage return
  87. ' ', // Newlines and tabs
  88. '', // <head>
  89. '', // <script>s -- which strip_tags supposedly has problems with
  90. '', // <style>s -- which strip_tags supposedly has problems with
  91. "\n\n", // <P>
  92. "\n", // <br>
  93. '_\\1_', // <i>
  94. '_\\1_', // <em>
  95. "\n\n", // <ul> and </ul>
  96. "\n\n", // <ol> and </ol>
  97. "\n\n", // <dl> and </dl>
  98. "\t* \\1\n", // <li> and </li>
  99. " \\1\n", // <dd> and </dd>
  100. "\t* \\1", // <dt> and </dt>
  101. "\n\t* ", // <li>
  102. "\n-------------------------\n", // <hr>
  103. "<div>\n", // <div>
  104. "\n\n", // <table> and </table>
  105. "\n", // <tr> and </tr>
  106. "\t\t\\1\n", // <td> and </td>
  107. "" // <span class="_html2text_ignore">...</span>
  108. );
  109. /**
  110. * List of preg* regular expression patterns to search for,
  111. * used in conjunction with $ent_replace.
  112. *
  113. * @type array
  114. * @see $ent_replace
  115. */
  116. protected $ent_search = array(
  117. '/&(nbsp|#160);/i', // Non-breaking space
  118. '/&(quot|rdquo|ldquo|#8220|#8221|#147|#148);/i',
  119. // Double quotes
  120. '/&(apos|rsquo|lsquo|#8216|#8217);/i', // Single quotes
  121. '/&gt;/i', // Greater-than
  122. '/&lt;/i', // Less-than
  123. '/&(copy|#169);/i', // Copyright
  124. '/&(trade|#8482|#153);/i', // Trademark
  125. '/&(reg|#174);/i', // Registered
  126. '/&(mdash|#151|#8212);/i', // mdash
  127. '/&(ndash|minus|#8211|#8722);/i', // ndash
  128. '/&(bull|#149|#8226);/i', // Bullet
  129. '/&(pound|#163);/i', // Pound sign
  130. '/&(euro|#8364);/i', // Euro sign
  131. '/&(amp|#38);/i', // Ampersand: see _converter()
  132. '/[ ]{2,}/', // Runs of spaces, post-handling
  133. );
  134. /**
  135. * List of pattern replacements corresponding to patterns searched.
  136. *
  137. * @type array
  138. * @see $ent_search
  139. */
  140. protected $ent_replace = array(
  141. ' ', // Non-breaking space
  142. '"', // Double quotes
  143. "'", // Single quotes
  144. '>',
  145. '<',
  146. '(c)',
  147. '(tm)',
  148. '(R)',
  149. '--',
  150. '-',
  151. '*',
  152. '£',
  153. 'EUR', // Euro sign. € ?
  154. '|+|amp|+|', // Ampersand: see _converter()
  155. ' ', // Runs of spaces, post-handling
  156. );
  157. /**
  158. * List of preg* regular expression patterns to search for
  159. * and replace using callback function.
  160. *
  161. * @type array
  162. */
  163. protected $callback_search = array(
  164. '/<(a) [^>]*href=("|\')([^"\']+)\2([^>]*)>(.*?)<\/a>/i', // <a href="">
  165. '/<(h)[123456]( [^>]*)?>(.*?)<\/h[123456]>/i', // h1 - h6
  166. '/<(b)( [^>]*)?>(.*?)<\/b>/i', // <b>
  167. '/<(strong)( [^>]*)?>(.*?)<\/strong>/i', // <strong>
  168. '/<(th)( [^>]*)?>(.*?)<\/th>/i', // <th> and </th>
  169. );
  170. /**
  171. * List of preg* regular expression patterns to search for in PRE body,
  172. * used in conjunction with $pre_replace.
  173. *
  174. * @type array
  175. * @see $pre_replace
  176. */
  177. protected $pre_search = array(
  178. "/\n/",
  179. "/\t/",
  180. '/ /',
  181. '/<pre[^>]*>/',
  182. '/<\/pre>/'
  183. );
  184. /**
  185. * List of pattern replacements corresponding to patterns searched for PRE body.
  186. *
  187. * @type array
  188. * @see $pre_search
  189. */
  190. protected $pre_replace = array(
  191. '<br>',
  192. '&nbsp;&nbsp;&nbsp;&nbsp;',
  193. '&nbsp;',
  194. '',
  195. ''
  196. );
  197. /**
  198. * Temporary workspace used during PRE processing.
  199. *
  200. * @type string
  201. */
  202. protected $pre_content = '';
  203. /**
  204. * Contains a list of HTML tags to allow in the resulting text.
  205. *
  206. * @type string
  207. * @see set_allowed_tags()
  208. */
  209. protected $allowed_tags = '';
  210. /**
  211. * Contains the base URL that relative links should resolve to.
  212. *
  213. * @type string
  214. */
  215. protected $url;
  216. /**
  217. * Indicates whether content in the $html variable has been converted yet.
  218. *
  219. * @type boolean
  220. * @see $html, $text
  221. */
  222. protected $_converted = false;
  223. /**
  224. * Contains URL addresses from links to be rendered in plain text.
  225. *
  226. * @type array
  227. * @see _build_link_list()
  228. */
  229. protected $_link_list = array();
  230. /**
  231. * Various configuration options (able to be set in the constructor)
  232. *
  233. * @type array
  234. */
  235. protected $_options = array(
  236. // 'none'
  237. // 'inline' (show links inline)
  238. // 'nextline' (show links on the next line)
  239. // 'table' (if a table of link URLs should be listed after the text.
  240. 'do_links' => 'inline',
  241. // Maximum width of the formatted text, in columns.
  242. // Set this value to 0 (or less) to ignore word wrapping
  243. // and not constrain text to a fixed-width column.
  244. 'width' => 70,
  245. );
  246. /**
  247. * Constructor.
  248. *
  249. * If the HTML source string (or file) is supplied, the class
  250. * will instantiate with that source propagated, all that has
  251. * to be done it to call get_text().
  252. *
  253. * @param string $source HTML content
  254. * @param boolean $from_file Indicates $source is a file to pull content from
  255. * @param array $options Set configuration options
  256. */
  257. public function __construct($source = '', $from_file = false, $options = array())
  258. {
  259. $this->_options = array_merge($this->_options, $options);
  260. if (!empty($source)) {
  261. $this->set_html($source, $from_file);
  262. }
  263. $this->set_base_url();
  264. }
  265. /**
  266. * Loads source HTML into memory, either from $source string or a file.
  267. *
  268. * @param string $source HTML content
  269. * @param boolean $from_file Indicates $source is a file to pull content from
  270. */
  271. public function set_html($source, $from_file = false)
  272. {
  273. if ($from_file && file_exists($source)) {
  274. $this->html = file_get_contents($source);
  275. } else {
  276. $this->html = $source;
  277. }
  278. $this->_converted = false;
  279. }
  280. /**
  281. * Returns the text, converted from HTML.
  282. *
  283. * @return string
  284. */
  285. public function get_text()
  286. {
  287. if (!$this->_converted) {
  288. $this->_convert();
  289. }
  290. return $this->text;
  291. }
  292. /**
  293. * Prints the text, converted from HTML.
  294. */
  295. public function print_text()
  296. {
  297. print $this->get_text();
  298. }
  299. /**
  300. * Alias to print_text(), operates identically.
  301. *
  302. * @see print_text()
  303. */
  304. public function p()
  305. {
  306. print $this->get_text();
  307. }
  308. /**
  309. * Sets the allowed HTML tags to pass through to the resulting text.
  310. *
  311. * Tags should be in the form "<p>", with no corresponding closing tag.
  312. * @param string $allowed_tags
  313. */
  314. public function set_allowed_tags($allowed_tags = '')
  315. {
  316. if (!empty($allowed_tags)) {
  317. $this->allowed_tags = $allowed_tags;
  318. }
  319. }
  320. /**
  321. * Sets a base URL to handle relative links.
  322. *
  323. * @param string $url
  324. */
  325. public function set_base_url($url = '')
  326. {
  327. if (empty($url)) {
  328. if (!empty($_SERVER['HTTP_HOST'])) {
  329. $this->url = 'http://' . $_SERVER['HTTP_HOST'];
  330. } else {
  331. $this->url = '';
  332. }
  333. } else {
  334. // Strip any trailing slashes for consistency (relative
  335. // URLs may already start with a slash like "/file.html")
  336. if (substr($url, -1) == '/') {
  337. $url = substr($url, 0, -1);
  338. }
  339. $this->url = $url;
  340. }
  341. }
  342. /**
  343. * Workhorse function that does actual conversion (calls _converter() method).
  344. */
  345. protected function _convert()
  346. {
  347. // Variables used for building the link list
  348. $this->_link_list = array();
  349. $text = trim(stripslashes($this->html));
  350. // Convert HTML to TXT
  351. $this->_converter($text);
  352. // Add link list
  353. if (!empty($this->_link_list)) {
  354. $text .= "\n\nLinks:\n------\n";
  355. foreach ($this->_link_list as $idx => $url) {
  356. $text .= '[' . ($idx + 1) . '] ' . $url . "\n";
  357. }
  358. }
  359. $this->text = $text;
  360. $this->_converted = true;
  361. }
  362. /**
  363. * Workhorse function that does actual conversion.
  364. *
  365. * First performs custom tag replacement specified by $search and
  366. * $replace arrays. Then strips any remaining HTML tags, reduces whitespace
  367. * and newlines to a readable format, and word wraps the text to
  368. * $this->_options['width'] characters.
  369. *
  370. * @param string $text Reference to HTML content string
  371. */
  372. protected function _converter(&$text)
  373. {
  374. // Convert <BLOCKQUOTE> (before PRE!)
  375. $this->_convert_blockquotes($text);
  376. // Convert <PRE>
  377. $this->_convert_pre($text);
  378. // Run our defined tags search-and-replace
  379. $text = preg_replace($this->search, $this->replace, $text);
  380. // Run our defined tags search-and-replace with callback
  381. $text = preg_replace_callback($this->callback_search, array($this, '_preg_callback'), $text);
  382. // Strip any other HTML tags
  383. $text = strip_tags($text, $this->allowed_tags);
  384. // Run our defined entities/characters search-and-replace
  385. $text = preg_replace($this->ent_search, $this->ent_replace, $text);
  386. // Replace known html entities
  387. $text = html_entity_decode($text, ENT_QUOTES);
  388. // Remove unknown/unhandled entities (this cannot be done in search-and-replace block)
  389. $text = preg_replace('/&([a-zA-Z0-9]{2,6}|#[0-9]{2,4});/', '', $text);
  390. // Convert "|+|amp|+|" into "&", need to be done after handling of unknown entities
  391. // This properly handles situation of "&amp;quot;" in input string
  392. $text = str_replace('|+|amp|+|', '&', $text);
  393. // Bring down number of empty lines to 2 max
  394. $text = preg_replace("/\n\s+\n/", "\n\n", $text);
  395. $text = preg_replace("/[\n]{3,}/", "\n\n", $text);
  396. // remove leading empty lines (can be produced by eg. P tag on the beginning)
  397. $text = ltrim($text, "\n");
  398. // Wrap the text to a readable format
  399. // for PHP versions >= 4.0.2. Default width is 75
  400. // If width is 0 or less, don't wrap the text.
  401. if ($this->_options['width'] > 0) {
  402. $text = wordwrap($text, $this->_options['width']);
  403. }
  404. }
  405. /**
  406. * Helper function called by preg_replace() on link replacement.
  407. *
  408. * Maintains an internal list of links to be displayed at the end of the
  409. * text, with numeric indices to the original point in the text they
  410. * appeared. Also makes an effort at identifying and handling absolute
  411. * and relative links.
  412. *
  413. * @param string $link URL of the link
  414. * @param string $display Part of the text to associate number with
  415. * @param null $link_override
  416. * @return string
  417. */
  418. protected function _build_link_list($link, $display, $link_override = null)
  419. {
  420. $link_method = ($link_override) ? $link_override : $this->_options['do_links'];
  421. if ($link_method == 'none') {
  422. return $display;
  423. }
  424. // Ignored link types
  425. if (preg_match('!^(javascript:|mailto:|#)!i', $link)) {
  426. return $display;
  427. }
  428. if (preg_match('!^([a-z][a-z0-9.+-]+:)!i', $link)) {
  429. $url = $link;
  430. } else {
  431. $url = $this->url;
  432. if (substr($link, 0, 1) != '/') {
  433. $url .= '/';
  434. }
  435. $url .= "$link";
  436. }
  437. if ($link_method == 'table') {
  438. if (($index = array_search($url, $this->_link_list)) === false) {
  439. $index = count($this->_link_list);
  440. $this->_link_list[] = $url;
  441. }
  442. return $display . ' [' . ($index + 1) . ']';
  443. } elseif ($link_method == 'nextline') {
  444. return $display . "\n[" . $url . ']';
  445. } else { // link_method defaults to inline
  446. return $display . ' [' . $url . ']';
  447. }
  448. }
  449. /**
  450. * Helper function for PRE body conversion.
  451. *
  452. * @param string $text HTML content
  453. */
  454. protected function _convert_pre(&$text)
  455. {
  456. // get the content of PRE element
  457. while (preg_match('/<pre[^>]*>(.*)<\/pre>/ismU', $text, $matches)) {
  458. $this->pre_content = $matches[1];
  459. // Run our defined tags search-and-replace with callback
  460. $this->pre_content = preg_replace_callback(
  461. $this->callback_search,
  462. array($this, '_preg_callback'),
  463. $this->pre_content
  464. );
  465. // convert the content
  466. $this->pre_content = sprintf(
  467. '<div><br>%s<br></div>',
  468. preg_replace($this->pre_search, $this->pre_replace, $this->pre_content)
  469. );
  470. // replace the content (use callback because content can contain $0 variable)
  471. $text = preg_replace_callback(
  472. '/<pre[^>]*>.*<\/pre>/ismU',
  473. array($this, '_preg_pre_callback'),
  474. $text,
  475. 1
  476. );
  477. // free memory
  478. $this->pre_content = '';
  479. }
  480. }
  481. /**
  482. * Helper function for BLOCKQUOTE body conversion.
  483. *
  484. * @param string $text HTML content
  485. */
  486. protected function _convert_blockquotes(&$text)
  487. {
  488. if (preg_match_all('/<\/*blockquote[^>]*>/i', $text, $matches, PREG_OFFSET_CAPTURE)) {
  489. $start = 0;
  490. $taglen = 0;
  491. $level = 0;
  492. $diff = 0;
  493. foreach ($matches[0] as $m) {
  494. if ($m[0][0] == '<' && $m[0][1] == '/') {
  495. $level--;
  496. if ($level < 0) {
  497. $level = 0; // malformed HTML: go to next blockquote
  498. } elseif ($level > 0) {
  499. // skip inner blockquote
  500. } else {
  501. $end = $m[1];
  502. $len = $end - $taglen - $start;
  503. // Get blockquote content
  504. $body = substr($text, $start + $taglen - $diff, $len);
  505. // Set text width
  506. $p_width = $this->_options['width'];
  507. if ($this->_options['width'] > 0) $this->_options['width'] -= 2;
  508. // Convert blockquote content
  509. $body = trim($body);
  510. $this->_converter($body);
  511. // Add citation markers and create PRE block
  512. $body = preg_replace('/((^|\n)>*)/', '\\1> ', trim($body));
  513. $body = '<pre>' . htmlspecialchars($body) . '</pre>';
  514. // Re-set text width
  515. $this->_options['width'] = $p_width;
  516. // Replace content
  517. $text = substr($text, 0, $start - $diff)
  518. . $body . substr($text, $end + strlen($m[0]) - $diff);
  519. $diff = $len + $taglen + strlen($m[0]) - strlen($body);
  520. unset($body);
  521. }
  522. } else {
  523. if ($level == 0) {
  524. $start = $m[1];
  525. $taglen = strlen($m[0]);
  526. }
  527. $level++;
  528. }
  529. }
  530. }
  531. }
  532. /**
  533. * Callback function for preg_replace_callback use.
  534. *
  535. * @param array $matches PREG matches
  536. * @return string
  537. */
  538. protected function _preg_callback($matches)
  539. {
  540. switch (strtolower($matches[1])) {
  541. case 'b':
  542. case 'strong':
  543. return $this->_toupper($matches[3]);
  544. case 'th':
  545. return $this->_toupper("\t\t" . $matches[3] . "\n");
  546. case 'h':
  547. return $this->_toupper("\n\n" . $matches[3] . "\n\n");
  548. case 'a':
  549. // override the link method
  550. $link_override = null;
  551. if (preg_match('/_html2text_link_(\w+)/', $matches[4], $link_override_match)) {
  552. $link_override = $link_override_match[1];
  553. }
  554. // Remove spaces in URL (#1487805)
  555. $url = str_replace(' ', '', $matches[3]);
  556. return $this->_build_link_list($url, $matches[5], $link_override);
  557. }
  558. return '';
  559. }
  560. /**
  561. * Callback function for preg_replace_callback use in PRE content handler.
  562. *
  563. * @param array $matches PREG matches
  564. * @return string
  565. */
  566. protected function _preg_pre_callback(
  567. /** @noinspection PhpUnusedParameterInspection */
  568. $matches)
  569. {
  570. return $this->pre_content;
  571. }
  572. /**
  573. * Strtoupper function with HTML tags and entities handling.
  574. *
  575. * @param string $str Text to convert
  576. * @return string Converted text
  577. */
  578. private function _toupper($str)
  579. {
  580. // string can contain HTML tags
  581. $chunks = preg_split('/(<[^>]*>)/', $str, null, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
  582. // convert toupper only the text between HTML tags
  583. foreach ($chunks as $idx => $chunk) {
  584. if ($chunk[0] != '<') {
  585. $chunks[$idx] = $this->_strtoupper($chunk);
  586. }
  587. }
  588. return implode($chunks);
  589. }
  590. /**
  591. * Strtoupper multibyte wrapper function with HTML entities handling.
  592. * Forces mb_strtoupper-call to UTF-8.
  593. *
  594. * @param string $str Text to convert
  595. * @return string Converted text
  596. */
  597. private function _strtoupper($str)
  598. {
  599. $str = html_entity_decode($str, ENT_COMPAT);
  600. if (function_exists('mb_strtoupper'))
  601. $str = mb_strtoupper($str, 'UTF-8');
  602. else
  603. $str = strtoupper($str);
  604. $str = htmlspecialchars($str, ENT_COMPAT);
  605. return $str;
  606. }
  607. }