Html.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. <?php
  2. /**
  3. * Zend Framework
  4. *
  5. * LICENSE
  6. *
  7. * This source file is subject to the new BSD license that is bundled
  8. * with this package in the file LICENSE.txt.
  9. * It is also available through the world-wide-web at this URL:
  10. * http://framework.zend.com/license/new-bsd
  11. * If you did not receive a copy of the license and are unable to
  12. * obtain it through the world-wide-web, please send an email
  13. * to license@zend.com so we can send you a copy immediately.
  14. *
  15. * @category Zend
  16. * @package Zend_Search_Lucene
  17. * @subpackage Document
  18. * @copyright Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
  19. * @license http://framework.zend.com/license/new-bsd New BSD License
  20. * @version $Id$
  21. */
  22. /** Zend_Search_Lucene_Document */
  23. require_once 'Zend/Search/Lucene/Document.php';
  24. /**
  25. * HTML document.
  26. *
  27. * @category Zend
  28. * @package Zend_Search_Lucene
  29. * @subpackage Document
  30. * @copyright Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
  31. * @license http://framework.zend.com/license/new-bsd New BSD License
  32. */
  33. class Zend_Search_Lucene_Document_Html extends Zend_Search_Lucene_Document
  34. {
  35. /**
  36. * List of document links
  37. *
  38. * @var array
  39. */
  40. private $_links = array();
  41. /**
  42. * List of document header links
  43. *
  44. * @var array
  45. */
  46. private $_headerLinks = array();
  47. /**
  48. * Stored DOM representation
  49. *
  50. * @var DOMDocument
  51. */
  52. private $_doc;
  53. /**
  54. * Exclud nofollow links flag
  55. *
  56. * If true then links with rel='nofollow' attribute are not included into
  57. * document links.
  58. *
  59. * @var boolean
  60. */
  61. private static $_excludeNoFollowLinks = false;
  62. /**
  63. *
  64. * List of inline tags
  65. *
  66. * @var array
  67. */
  68. private $_inlineTags = array('a', 'abbr', 'acronym', 'dfn', 'em', 'strong', 'code',
  69. 'samp', 'kbd', 'var', 'b', 'i', 'big', 'small', 'strike',
  70. 'tt', 'u', 'font', 'span', 'bdo', 'cite', 'del', 'ins',
  71. 'q', 'sub', 'sup');
  72. /**
  73. * Object constructor
  74. *
  75. * @param string $data HTML string (may be HTML fragment, )
  76. * @param boolean $isFile
  77. * @param boolean $storeContent
  78. * @param string $defaultEncoding HTML encoding, is used if it's not specified using Content-type HTTP-EQUIV meta tag.
  79. */
  80. private function __construct($data, $isFile, $storeContent, $defaultEncoding = '')
  81. {
  82. $this->_doc = new DOMDocument();
  83. $this->_doc->substituteEntities = true;
  84. if ($isFile) {
  85. $htmlData = file_get_contents($data);
  86. } else {
  87. $htmlData = $data;
  88. }
  89. @$this->_doc->loadHTML($htmlData);
  90. if ($this->_doc->encoding === null) {
  91. // Document encoding is not recognized
  92. /** @todo improve HTML vs HTML fragment recognition */
  93. if (preg_match('/<html>/i', $htmlData, $matches, PREG_OFFSET_CAPTURE)) {
  94. // It's an HTML document
  95. // Add additional HEAD section and recognize document
  96. $htmlTagOffset = $matches[0][1] + strlen($matches[0][0]);
  97. @$this->_doc->loadHTML(iconv($defaultEncoding, 'UTF-8//IGNORE', substr($htmlData, 0, $htmlTagOffset))
  98. . '<head><META HTTP-EQUIV="Content-type" CONTENT="text/html; charset=UTF-8"/></head>'
  99. . iconv($defaultEncoding, 'UTF-8//IGNORE', substr($htmlData, $htmlTagOffset)));
  100. // Remove additional HEAD section
  101. $xpath = new DOMXPath($this->_doc);
  102. $head = $xpath->query('/html/head')->item(0);
  103. $head->parentNode->removeChild($head);
  104. } else {
  105. // It's an HTML fragment
  106. @$this->_doc->loadHTML('<html><head><META HTTP-EQUIV="Content-type" CONTENT="text/html; charset=UTF-8"/></head><body>'
  107. . iconv($defaultEncoding, 'UTF-8//IGNORE', $htmlData)
  108. . '</body></html>');
  109. }
  110. }
  111. /** @todo Add correction of wrong HTML encoding recognition processing
  112. * The case is:
  113. * Content-type HTTP-EQUIV meta tag is presented, but ISO-8859-5 encoding is actually used,
  114. * even $this->_doc->encoding demonstrates another recognized encoding
  115. */
  116. $xpath = new DOMXPath($this->_doc);
  117. $docTitle = '';
  118. $titleNodes = $xpath->query('/html/head/title');
  119. foreach ($titleNodes as $titleNode) {
  120. // title should always have only one entry, but we process all nodeset entries
  121. $docTitle .= $titleNode->nodeValue . ' ';
  122. }
  123. $this->addField(Zend_Search_Lucene_Field::Text('title', $docTitle, 'UTF-8'));
  124. $metaNodes = $xpath->query('/html/head/meta[@name]');
  125. foreach ($metaNodes as $metaNode) {
  126. $this->addField(Zend_Search_Lucene_Field::Text($metaNode->getAttribute('name'),
  127. $metaNode->getAttribute('content'),
  128. 'UTF-8'));
  129. }
  130. $docBody = '';
  131. $bodyNodes = $xpath->query('/html/body');
  132. foreach ($bodyNodes as $bodyNode) {
  133. // body should always have only one entry, but we process all nodeset entries
  134. $this->_retrieveNodeText($bodyNode, $docBody);
  135. }
  136. if ($storeContent) {
  137. $this->addField(Zend_Search_Lucene_Field::Text('body', $docBody, 'UTF-8'));
  138. } else {
  139. $this->addField(Zend_Search_Lucene_Field::UnStored('body', $docBody, 'UTF-8'));
  140. }
  141. $linkNodes = $this->_doc->getElementsByTagName('a');
  142. foreach ($linkNodes as $linkNode) {
  143. if (($href = $linkNode->getAttribute('href')) != '' &&
  144. (!self::$_excludeNoFollowLinks || strtolower($linkNode->getAttribute('rel')) != 'nofollow' )
  145. ) {
  146. $this->_links[] = $href;
  147. }
  148. }
  149. $this->_links = array_unique($this->_links);
  150. $linkNodes = $xpath->query('/html/head/link');
  151. foreach ($linkNodes as $linkNode) {
  152. if (($href = $linkNode->getAttribute('href')) != '') {
  153. $this->_headerLinks[] = $href;
  154. }
  155. }
  156. $this->_headerLinks = array_unique($this->_headerLinks);
  157. }
  158. /**
  159. * Set exclude nofollow links flag
  160. *
  161. * @param boolean $newValue
  162. */
  163. public static function setExcludeNoFollowLinks($newValue)
  164. {
  165. self::$_excludeNoFollowLinks = $newValue;
  166. }
  167. /**
  168. * Get exclude nofollow links flag
  169. *
  170. * @return boolean
  171. */
  172. public static function getExcludeNoFollowLinks()
  173. {
  174. return self::$_excludeNoFollowLinks;
  175. }
  176. /**
  177. * Get node text
  178. *
  179. * We should exclude scripts, which may be not included into comment tags, CDATA sections,
  180. *
  181. * @param DOMNode $node
  182. * @param string &$text
  183. */
  184. private function _retrieveNodeText(DOMNode $node, &$text)
  185. {
  186. if ($node->nodeType == XML_TEXT_NODE) {
  187. $text .= $node->nodeValue;
  188. if(!in_array($node->parentNode->tagName, $this->_inlineTags)) {
  189. $text .= ' ';
  190. }
  191. } else if ($node->nodeType == XML_ELEMENT_NODE && $node->nodeName != 'script') {
  192. foreach ($node->childNodes as $childNode) {
  193. $this->_retrieveNodeText($childNode, $text);
  194. }
  195. }
  196. }
  197. /**
  198. * Get document HREF links
  199. *
  200. * @return array
  201. */
  202. public function getLinks()
  203. {
  204. return $this->_links;
  205. }
  206. /**
  207. * Get document header links
  208. *
  209. * @return array
  210. */
  211. public function getHeaderLinks()
  212. {
  213. return $this->_headerLinks;
  214. }
  215. /**
  216. * Load HTML document from a string
  217. *
  218. * @param string $data
  219. * @param boolean $storeContent
  220. * @param string $defaultEncoding HTML encoding, is used if it's not specified using Content-type HTTP-EQUIV meta tag.
  221. * @return Zend_Search_Lucene_Document_Html
  222. */
  223. public static function loadHTML($data, $storeContent = false, $defaultEncoding = '')
  224. {
  225. return new Zend_Search_Lucene_Document_Html($data, false, $storeContent, $defaultEncoding);
  226. }
  227. /**
  228. * Load HTML document from a file
  229. *
  230. * @param string $file
  231. * @param boolean $storeContent
  232. * @param string $defaultEncoding HTML encoding, is used if it's not specified using Content-type HTTP-EQUIV meta tag.
  233. * @return Zend_Search_Lucene_Document_Html
  234. */
  235. public static function loadHTMLFile($file, $storeContent = false, $defaultEncoding = '')
  236. {
  237. return new Zend_Search_Lucene_Document_Html($file, true, $storeContent, $defaultEncoding);
  238. }
  239. /**
  240. * Highlight text in text node
  241. *
  242. * @param DOMText $node
  243. * @param array $wordsToHighlight
  244. * @param callback $callback Callback method, used to transform (highlighting) text.
  245. * @param array $params Array of additionall callback parameters (first non-optional parameter is a text to transform)
  246. * @throws Zend_Search_Lucene_Exception
  247. */
  248. protected function _highlightTextNode(DOMText $node, $wordsToHighlight, $callback, $params)
  249. {
  250. /** Zend_Search_Lucene_Analysis_Analyzer */
  251. require_once 'Zend/Search/Lucene/Analysis/Analyzer.php';
  252. $analyzer = Zend_Search_Lucene_Analysis_Analyzer::getDefault();
  253. $analyzer->setInput($node->nodeValue, 'UTF-8');
  254. $matchedTokens = array();
  255. while (($token = $analyzer->nextToken()) !== null) {
  256. if (isset($wordsToHighlight[$token->getTermText()])) {
  257. $matchedTokens[] = $token;
  258. }
  259. }
  260. if (count($matchedTokens) == 0) {
  261. return;
  262. }
  263. $matchedTokens = array_reverse($matchedTokens);
  264. foreach ($matchedTokens as $token) {
  265. // Cut text after matched token
  266. $node->splitText($token->getEndOffset());
  267. // Cut matched node
  268. $matchedWordNode = $node->splitText($token->getStartOffset());
  269. // Retrieve HTML string representation for highlihted word
  270. $fullCallbackparamsList = $params;
  271. array_unshift($fullCallbackparamsList, $matchedWordNode->nodeValue);
  272. $highlightedWordNodeSetHtml = call_user_func_array($callback, $fullCallbackparamsList);
  273. // Transform HTML string to a DOM representation and automatically transform retrieved string
  274. // into valid XHTML (It's automatically done by loadHTML() method)
  275. $highlightedWordNodeSetDomDocument = new DOMDocument('1.0', 'UTF-8');
  276. $success = @$highlightedWordNodeSetDomDocument->
  277. loadHTML('<html><head><meta http-equiv="Content-type" content="text/html; charset=UTF-8"/></head><body>'
  278. . $highlightedWordNodeSetHtml
  279. . '</body></html>');
  280. if (!$success) {
  281. require_once 'Zend/Search/Lucene/Exception.php';
  282. throw new Zend_Search_Lucene_Exception("Error occured while loading highlighted text fragment: '$highlightedNodeHtml'.");
  283. }
  284. $highlightedWordNodeSetXpath = new DOMXPath($highlightedWordNodeSetDomDocument);
  285. $highlightedWordNodeSet = $highlightedWordNodeSetXpath->query('/html/body')->item(0)->childNodes;
  286. for ($count = 0; $count < $highlightedWordNodeSet->length; $count++) {
  287. $nodeToImport = $highlightedWordNodeSet->item($count);
  288. $node->parentNode->insertBefore($this->_doc->importNode($nodeToImport, true /* deep copy */),
  289. $matchedWordNode);
  290. }
  291. $node->parentNode->removeChild($matchedWordNode);
  292. }
  293. }
  294. /**
  295. * highlight words in content of the specified node
  296. *
  297. * @param DOMNode $contextNode
  298. * @param array $wordsToHighlight
  299. * @param callback $callback Callback method, used to transform (highlighting) text.
  300. * @param array $params Array of additionall callback parameters (first non-optional parameter is a text to transform)
  301. */
  302. protected function _highlightNodeRecursive(DOMNode $contextNode, $wordsToHighlight, $callback, $params)
  303. {
  304. $textNodes = array();
  305. if (!$contextNode->hasChildNodes()) {
  306. return;
  307. }
  308. foreach ($contextNode->childNodes as $childNode) {
  309. if ($childNode->nodeType == XML_TEXT_NODE) {
  310. // process node later to leave childNodes structure untouched
  311. $textNodes[] = $childNode;
  312. } else {
  313. // Process node if it's not a script node
  314. if ($childNode->nodeName != 'script') {
  315. $this->_highlightNodeRecursive($childNode, $wordsToHighlight, $callback, $params);
  316. }
  317. }
  318. }
  319. foreach ($textNodes as $textNode) {
  320. $this->_highlightTextNode($textNode, $wordsToHighlight, $callback, $params);
  321. }
  322. }
  323. /**
  324. * Standard callback method used to highlight words.
  325. *
  326. * @param string $stringToHighlight
  327. * @return string
  328. * @internal
  329. */
  330. public function applyColour($stringToHighlight, $colour)
  331. {
  332. return '<b style="color:black;background-color:' . $colour . '">' . $stringToHighlight . '</b>';
  333. }
  334. /**
  335. * Highlight text with specified color
  336. *
  337. * @param string|array $words
  338. * @param string $colour
  339. * @return string
  340. */
  341. public function highlight($words, $colour = '#66ffff')
  342. {
  343. return $this->highlightExtended($words, array($this, 'applyColour'), array($colour));
  344. }
  345. /**
  346. * Highlight text using specified View helper or callback function.
  347. *
  348. * @param string|array $words Words to highlight. Words could be organized using the array or string.
  349. * @param callback $callback Callback method, used to transform (highlighting) text.
  350. * @param array $params Array of additionall callback parameters passed through into it
  351. * (first non-optional parameter is an HTML fragment for highlighting)
  352. * @return string
  353. * @throws Zend_Search_Lucene_Exception
  354. */
  355. public function highlightExtended($words, $callback, $params = array())
  356. {
  357. /** Zend_Search_Lucene_Analysis_Analyzer */
  358. require_once 'Zend/Search/Lucene/Analysis/Analyzer.php';
  359. if (!is_array($words)) {
  360. $words = array($words);
  361. }
  362. $wordsToHighlightList = array();
  363. $analyzer = Zend_Search_Lucene_Analysis_Analyzer::getDefault();
  364. foreach ($words as $wordString) {
  365. $wordsToHighlightList[] = $analyzer->tokenize($wordString);
  366. }
  367. $wordsToHighlight = call_user_func_array('array_merge', $wordsToHighlightList);
  368. if (count($wordsToHighlight) == 0) {
  369. return $this->_doc->saveHTML();
  370. }
  371. $wordsToHighlightFlipped = array();
  372. foreach ($wordsToHighlight as $id => $token) {
  373. $wordsToHighlightFlipped[$token->getTermText()] = $id;
  374. }
  375. if (!is_callable($callback)) {
  376. require_once 'Zend/Search/Lucene/Exception.php';
  377. throw new Zend_Search_Lucene_Exception('$viewHelper parameter mast be a View Helper name, View Helper object or callback.');
  378. }
  379. $xpath = new DOMXPath($this->_doc);
  380. $matchedNodes = $xpath->query("/html/body");
  381. foreach ($matchedNodes as $matchedNode) {
  382. $this->_highlightNodeRecursive($matchedNode, $wordsToHighlightFlipped, $callback, $params);
  383. }
  384. }
  385. /**
  386. * Get HTML
  387. *
  388. * @return string
  389. */
  390. public function getHTML()
  391. {
  392. return $this->_doc->saveHTML();
  393. }
  394. /**
  395. * Get HTML body
  396. *
  397. * @return string
  398. */
  399. public function getHtmlBody()
  400. {
  401. $xpath = new DOMXPath($this->_doc);
  402. $bodyNodes = $xpath->query('/html/body')->item(0)->childNodes;
  403. $outputFragments = array();
  404. for ($count = 0; $count < $bodyNodes->length; $count++) {
  405. $outputFragments[] = $this->_doc->saveXML($bodyNodes->item($count));
  406. }
  407. return implode($outputFragments);
  408. }
  409. }