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