processor_highlight.inc 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. <?php
  2. /**
  3. * @file
  4. * Contains the SearchApiHighlight class.
  5. */
  6. /**
  7. * Processor for highlighting search results.
  8. */
  9. class SearchApiHighlight extends SearchApiAbstractProcessor {
  10. /**
  11. * PREG regular expression for a word boundary.
  12. *
  13. * We highlight around non-indexable or CJK characters.
  14. *
  15. * @var string
  16. */
  17. protected static $boundary;
  18. /**
  19. * PREG regular expression for splitting words.
  20. *
  21. * @var string
  22. */
  23. protected static $split;
  24. /**
  25. * {@inheritdoc}
  26. */
  27. public function __construct(SearchApiIndex $index, array $options = array()) {
  28. parent::__construct($index, $options);
  29. $cjk = '\x{1100}-\x{11FF}\x{3040}-\x{309F}\x{30A1}-\x{318E}' .
  30. '\x{31A0}-\x{31B7}\x{31F0}-\x{31FF}\x{3400}-\x{4DBF}\x{4E00}-\x{9FCF}' .
  31. '\x{A000}-\x{A48F}\x{A4D0}-\x{A4FD}\x{A960}-\x{A97F}\x{AC00}-\x{D7FF}' .
  32. '\x{F900}-\x{FAFF}\x{FF21}-\x{FF3A}\x{FF41}-\x{FF5A}\x{FF66}-\x{FFDC}' .
  33. '\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}';
  34. self::$boundary = '(?:(?<=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . '])|(?=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . ']))';
  35. self::$split = '/[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . ']+/iu';
  36. }
  37. /**
  38. * {@inheritdoc}
  39. */
  40. public function configurationForm() {
  41. $this->options += array(
  42. 'prefix' => '<strong>',
  43. 'suffix' => '</strong>',
  44. 'excerpt' => TRUE,
  45. 'excerpt_length' => 256,
  46. 'highlight' => 'always',
  47. 'highlight_partial' => FALSE,
  48. 'exclude_fields' => array(),
  49. );
  50. $form['prefix'] = array(
  51. '#type' => 'textfield',
  52. '#title' => t('Highlighting prefix'),
  53. '#description' => t('Text/HTML that will be prepended to all occurrences of search keywords in highlighted text.'),
  54. '#default_value' => $this->options['prefix'],
  55. );
  56. $form['suffix'] = array(
  57. '#type' => 'textfield',
  58. '#title' => t('Highlighting suffix'),
  59. '#description' => t('Text/HTML that will be appended to all occurrences of search keywords in highlighted text.'),
  60. '#default_value' => $this->options['suffix'],
  61. );
  62. $form['excerpt'] = array(
  63. '#type' => 'checkbox',
  64. '#title' => t('Create excerpt'),
  65. '#description' => t('When enabled, an excerpt will be created for searches with keywords, containing all occurrences of keywords in a fulltext field.'),
  66. '#default_value' => $this->options['excerpt'],
  67. );
  68. $form['excerpt_length'] = array(
  69. '#type' => 'textfield',
  70. '#title' => t('Excerpt length'),
  71. '#description' => t('The requested length of the excerpt, in characters.'),
  72. '#default_value' => $this->options['excerpt_length'],
  73. '#element_validate' => array('element_validate_integer_positive'),
  74. '#states' => array(
  75. 'visible' => array(
  76. '#edit-processors-search-api-highlighting-settings-excerpt' => array(
  77. 'checked' => TRUE,
  78. ),
  79. ),
  80. ),
  81. );
  82. // Exclude certain fulltext fields.
  83. $fields = $this->index->getFields();
  84. $fulltext_fields = array();
  85. foreach ($this->index->getFulltextFields() as $field) {
  86. if (isset($fields[$field])) {
  87. $fulltext_fields[$field] = check_plain($fields[$field]['name'] . ' (' . $field . ')');
  88. }
  89. }
  90. $form['exclude_fields'] = array(
  91. '#type' => 'checkboxes',
  92. '#title' => t('Exclude fields from excerpt'),
  93. '#description' => t('Exclude certain fulltext fields from being displayed in the excerpt.'),
  94. '#options' => $fulltext_fields,
  95. '#default_value' => $this->options['exclude_fields'],
  96. '#attributes' => array('class' => array('search-api-checkboxes-list')),
  97. );
  98. $form['highlight'] = array(
  99. '#type' => 'select',
  100. '#title' => t('Highlight returned field data'),
  101. '#description' => t('Select whether returned fields should be highlighted.'),
  102. '#options' => array(
  103. 'always' => t('Always'),
  104. 'server' => t('If the server returns fields'),
  105. 'never' => t('Never'),
  106. ),
  107. '#default_value' => $this->options['highlight'],
  108. );
  109. $form['highlight_partial'] = array(
  110. '#type' => 'checkbox',
  111. '#title' => t('Highlight partial matches'),
  112. '#description' => t('When enabled, matches in parts of words will be highlighted as well.'),
  113. '#default_value' => $this->options['highlight_partial'],
  114. );
  115. return $form;
  116. }
  117. /**
  118. * {@inheritdoc}
  119. */
  120. public function configurationFormValidate(array $form, array &$values, array &$form_state) {
  121. $values['exclude_fields'] = array_filter($values['exclude_fields']);
  122. }
  123. /**
  124. * {@inheritdoc}
  125. */
  126. public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
  127. if (empty($response['results']) || !($keys = $this->getKeywords($query))) {
  128. return;
  129. }
  130. $fulltext_fields = $this->index->getFulltextFields();
  131. if (!empty($this->options['exclude_fields'])) {
  132. $fulltext_fields = drupal_map_assoc($fulltext_fields);
  133. foreach ($this->options['exclude_fields'] as $field) {
  134. unset($fulltext_fields[$field]);
  135. }
  136. }
  137. foreach ($response['results'] as $id => &$result) {
  138. if ($this->options['excerpt']) {
  139. $text = array();
  140. $fields = $this->getFulltextFields($response['results'], $id, $fulltext_fields);
  141. foreach ($fields as $data) {
  142. if (is_array($data)) {
  143. $text = array_merge($text, $data);
  144. }
  145. else {
  146. $text[] = $data;
  147. }
  148. }
  149. $result['excerpt'] = $this->createExcerpt($this->flattenArrayValues($text), $keys);
  150. }
  151. if ($this->options['highlight'] != 'never') {
  152. $fields = $this->getFulltextFields($response['results'], $id, $fulltext_fields, $this->options['highlight'] == 'always');
  153. foreach ($fields as $field => $data) {
  154. $result['fields'][$field] = array('#sanitize_callback' => FALSE);
  155. if (is_array($data)) {
  156. foreach ($data as $i => $text) {
  157. $result['fields'][$field]['#value'][$i] = $this->highlightField($text, $keys);
  158. }
  159. }
  160. else {
  161. $result['fields'][$field]['#value'] = $this->highlightField($data, $keys);
  162. }
  163. }
  164. }
  165. }
  166. }
  167. /**
  168. * Retrieves the fulltext data of a result.
  169. *
  170. * @param array $results
  171. * All results returned in the search, by reference.
  172. * @param int|string $i
  173. * The index in the results array of the result whose data should be
  174. * returned.
  175. * @param array $fulltext_fields
  176. * The fulltext fields from which the excerpt should be created.
  177. * @param bool $load
  178. * TRUE if the item should be loaded if necessary, FALSE if only fields
  179. * already returned in the results should be used.
  180. *
  181. * @return array
  182. * An array containing fulltext field names mapped to the text data
  183. * contained in them for the given result.
  184. */
  185. protected function getFulltextFields(array &$results, $i, array $fulltext_fields, $load = TRUE) {
  186. global $language;
  187. $data = array();
  188. $result = &$results[$i];
  189. // Act as if $load is TRUE if we have a loaded item.
  190. $load |= !empty($result['entity']);
  191. $result += array('fields' => array());
  192. // We only need detailed fields data if $load is TRUE.
  193. $fields = $load ? $this->index->getFields() : array();
  194. $needs_extraction = array();
  195. $returned_fields = search_api_get_sanitized_field_values(array_intersect_key($result['fields'], array_flip($fulltext_fields)));
  196. foreach ($fulltext_fields as $field) {
  197. if (array_key_exists($field, $returned_fields)) {
  198. $data[$field] = $returned_fields[$field];
  199. }
  200. elseif ($load) {
  201. $needs_extraction[$field] = $fields[$field];
  202. }
  203. }
  204. if (!$needs_extraction) {
  205. return $data;
  206. }
  207. if (empty($result['entity'])) {
  208. $items = $this->index->loadItems(array_keys($results));
  209. foreach ($items as $id => $item) {
  210. $results[$id]['entity'] = $item;
  211. }
  212. }
  213. // If we still don't have a loaded item, we should stop trying.
  214. if (empty($result['entity'])) {
  215. return $data;
  216. }
  217. $wrapper = $this->index->entityWrapper($result['entity'], FALSE);
  218. $wrapper->language($language->language);
  219. $extracted = search_api_extract_fields($wrapper, $needs_extraction, array('sanitize' => TRUE));
  220. foreach ($extracted as $field => $info) {
  221. if (isset($info['value'])) {
  222. $data[$field] = $info['value'];
  223. }
  224. }
  225. return $data;
  226. }
  227. /**
  228. * Extracts the positive keywords used in a search query.
  229. *
  230. * @param SearchApiQuery $query
  231. * The query from which to extract the keywords.
  232. *
  233. * @return array
  234. * An array of all unique positive keywords used in the query.
  235. */
  236. protected function getKeywords(SearchApiQuery $query) {
  237. $keys = $query->getKeys();
  238. if (!$keys) {
  239. return array();
  240. }
  241. if (is_array($keys)) {
  242. return $this->flattenKeysArray($keys);
  243. }
  244. $keywords = preg_split(self::$split, $keys);
  245. // Assure there are no duplicates. (This is actually faster than
  246. // array_unique() by a factor of 3 to 4.)
  247. $keywords = drupal_map_assoc(array_filter($keywords));
  248. // Remove quotes from keywords.
  249. foreach ($keywords as $key) {
  250. $keywords[$key] = trim($key, "'\"");
  251. }
  252. return drupal_map_assoc(array_filter($keywords));
  253. }
  254. /**
  255. * Extracts the positive keywords from a keys array.
  256. *
  257. * @param array $keys
  258. * A search keys array, as specified by SearchApiQueryInterface::getKeys().
  259. *
  260. * @return array
  261. * An array of all unique positive keywords contained in the keys.
  262. */
  263. protected function flattenKeysArray(array $keys) {
  264. if (!empty($keys['#negation'])) {
  265. return array();
  266. }
  267. $keywords = array();
  268. foreach ($keys as $i => $key) {
  269. if (!element_child($i)) {
  270. continue;
  271. }
  272. if (is_array($key)) {
  273. $keywords += $this->flattenKeysArray($key);
  274. }
  275. else {
  276. $keywords[$key] = $key;
  277. }
  278. }
  279. return $keywords;
  280. }
  281. /**
  282. * Returns snippets from a piece of text, with certain keywords highlighted.
  283. *
  284. * Largely copied from search_excerpt().
  285. *
  286. * @param string $text
  287. * The text to extract fragments from.
  288. * @param array $keys
  289. * Search keywords entered by the user.
  290. *
  291. * @return string|null
  292. * A string containing HTML for the excerpt, or NULL if none could be
  293. * created.
  294. */
  295. protected function createExcerpt($text, array $keys) {
  296. // Prepare text by stripping HTML tags and decoding HTML entities.
  297. $text = strip_tags(str_replace(array('<', '>'), array(' <', '> '), $text));
  298. $text = decode_entities($text);
  299. $text = preg_replace('/\s+/', ' ', $text);
  300. $text = trim($text, ' ');
  301. $text_length = strlen($text);
  302. // Try to reach the requested excerpt length with about two fragments (each
  303. // with a keyword and some context).
  304. $ranges = array();
  305. $length = 0;
  306. $look_start = array();
  307. $remaining_keys = $keys;
  308. // Get the set excerpt length from the configuration. If the length is too
  309. // small, only use one fragment.
  310. $excerpt_length = $this->options['excerpt_length'];
  311. $context_length = round($excerpt_length / 4) - 3;
  312. if ($context_length < 32) {
  313. $context_length = round($excerpt_length / 2) - 1;
  314. }
  315. while ($length < $excerpt_length && !empty($remaining_keys)) {
  316. $found_keys = array();
  317. foreach ($remaining_keys as $key) {
  318. if ($length >= $excerpt_length) {
  319. break;
  320. }
  321. // Remember where we last found $key, in case we are coming through a
  322. // second time.
  323. if (!isset($look_start[$key])) {
  324. $look_start[$key] = 0;
  325. }
  326. // See if we can find $key after where we found it the last time. Since
  327. // we are requiring a match on a word boundary, make sure $text starts
  328. // and ends with a space.
  329. $matches = array();
  330. if (!$this->options['highlight_partial']) {
  331. $found_position = FALSE;
  332. $regex = '/' . static::$boundary . preg_quote($key, '/') . static::$boundary . '/iu';
  333. if (preg_match($regex, ' ' . $text . ' ', $matches, PREG_OFFSET_CAPTURE, $look_start[$key])) {
  334. $found_position = $matches[0][1];
  335. }
  336. }
  337. else {
  338. $found_position = stripos($text, $key, $look_start[$key]);
  339. }
  340. if ($found_position !== FALSE) {
  341. $look_start[$key] = $found_position + 1;
  342. // Keep track of which keys we found this time, in case we need to
  343. // pass through again to find more text.
  344. $found_keys[] = $key;
  345. // Locate a space before and after this match, leaving some context on
  346. // each end.
  347. if ($found_position > $context_length) {
  348. $before = strpos($text, ' ', $found_position - $context_length);
  349. if ($before !== FALSE) {
  350. ++$before;
  351. }
  352. }
  353. else {
  354. $before = 0;
  355. }
  356. if ($before !== FALSE && $before <= $found_position) {
  357. if ($text_length > $found_position + $context_length) {
  358. $after = strrpos(substr($text, 0, $found_position + $context_length), ' ', $found_position);
  359. }
  360. else {
  361. $after = $text_length;
  362. }
  363. if ($after !== FALSE && $after > $found_position) {
  364. if ($before < $after) {
  365. // Save this range.
  366. $ranges[$before] = $after;
  367. $length += $after - $before;
  368. }
  369. }
  370. }
  371. }
  372. }
  373. // Next time through this loop, only look for keys we found this time,
  374. // if any.
  375. $remaining_keys = $found_keys;
  376. }
  377. if (!$ranges) {
  378. // We didn't find any keyword matches, return NULL.
  379. return NULL;
  380. }
  381. // Sort the text ranges by starting position.
  382. ksort($ranges);
  383. // Collapse overlapping text ranges into one. The sorting makes it O(n).
  384. $newranges = array();
  385. $from1 = $to1 = NULL;
  386. foreach ($ranges as $from2 => $to2) {
  387. if ($from1 === NULL) {
  388. // This is the first time through this loop: initialize.
  389. $from1 = $from2;
  390. $to1 = $to2;
  391. continue;
  392. }
  393. if ($from2 <= $to1) {
  394. // The ranges overlap: combine them.
  395. $to1 = max($to1, $to2);
  396. }
  397. else {
  398. // The ranges do not overlap: save the working range and start a new
  399. // one.
  400. $newranges[$from1] = $to1;
  401. $from1 = $from2;
  402. $to1 = $to2;
  403. }
  404. }
  405. // Save the remaining working range.
  406. $newranges[$from1] = $to1;
  407. // Fetch text within the combined ranges we found.
  408. $out = array();
  409. foreach ($newranges as $from => $to) {
  410. $out[] = substr($text, $from, $to - $from);
  411. }
  412. if (!$out) {
  413. return NULL;
  414. }
  415. // Let translators have the ... separator text as one chunk.
  416. $dots = explode('!excerpt', t('... !excerpt ... !excerpt ...'));
  417. $text = (isset($newranges[0]) ? '' : $dots[0]) . implode($dots[1], $out) . $dots[2];
  418. $text = check_plain($text);
  419. // Since we stripped the tags at the beginning, highlighting doesn't need to
  420. // handle HTML anymore.
  421. return $this->highlightField($text, $keys, FALSE);
  422. }
  423. /**
  424. * Marks occurrences of the search keywords in a text field.
  425. *
  426. * @param string $text
  427. * The text of the field.
  428. * @param array $keys
  429. * Search keywords entered by the user.
  430. * @param bool $html
  431. * Whether the text can contain HTML tags or not. In the former case, text
  432. * inside tags (i.e., tag names and attributes) won't be highlighted.
  433. *
  434. * @return string
  435. * The field's text with all occurrences of search keywords highlighted.
  436. */
  437. protected function highlightField($text, array $keys, $html = TRUE) {
  438. if (is_array($text)) {
  439. $text = $this->flattenArrayValues($text);
  440. }
  441. if ($html) {
  442. $texts = preg_split('#((?:</?[[:alpha:]](?:[^>"\']*|"[^"]*"|\'[^\']\')*>)+)#i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
  443. for ($i = 0; $i < count($texts); $i += 2) {
  444. $texts[$i] = $this->highlightField($texts[$i], $keys, FALSE);
  445. }
  446. return implode('', $texts);
  447. }
  448. $keys = implode('|', array_map('preg_quote', $keys, array_fill(0, count($keys), '/')));
  449. // If "Highlight partial matches" is disabled, we only want to highlight
  450. // matches that are complete words. Otherwise, we want all of them.
  451. $boundary = empty($this->options['highlight_partial']) ? self::$boundary : '';
  452. $regex = '/' . $boundary . '(?:' . $keys . ')' . $boundary . '/iu';
  453. $replace = $this->options['prefix'] . '\0' . $this->options['suffix'];
  454. $text = preg_replace($regex, $replace, ' ' . $text . ' ');
  455. return substr($text, 1, -1);
  456. }
  457. /**
  458. * Flattens a (possibly multidimensional) array into a string.
  459. *
  460. * @param array $array
  461. * The array to flatten.
  462. * @param string $glue
  463. * (optional) The separator to insert between individual array items.
  464. *
  465. * @return string
  466. * The glued string.
  467. */
  468. protected function flattenArrayValues(array $array, $glue = " \n\n ") {
  469. $ret = array();
  470. foreach ($array as $item) {
  471. if (is_array($item)) {
  472. $ret[] = $this->flattenArrayValues($item, $glue);
  473. }
  474. else {
  475. $ret[] = $item;
  476. }
  477. }
  478. return implode($glue, $ret);
  479. }
  480. }