Thomas Bruederli
2012-11-17 6ddb16d181e285d4f0ef0ef55bdd0ba787f1b583
commit | author | age
b4edf7 1 <?php
A 2
3 /*
4  +-----------------------------------------------------------------------+
5  | program/include/rcube_spellchecker.php                                |
6  |                                                                       |
7  | This file is part of the Roundcube Webmail client                     |
8  | Copyright (C) 2011, Kolab Systems AG                                  |
9  | Copyright (C) 2008-2011, The Roundcube Dev Team                       |
7fe381 10  |                                                                       |
T 11  | Licensed under the GNU General Public License version 3 or            |
12  | any later version with exceptions for skins & plugins.                |
13  | See the README file for a full license statement.                     |
b4edf7 14  |                                                                       |
A 15  | PURPOSE:                                                              |
16  |   Spellchecking using different backends                              |
17  |                                                                       |
18  +-----------------------------------------------------------------------+
19  | Author: Aleksander Machniak <machniak@kolabsys.com>                   |
20  | Author: Thomas Bruederli <roundcube@gmail.com>                        |
21  +-----------------------------------------------------------------------+
22 */
23
24
25 /**
26  * Helper class for spellchecking with Googielspell and PSpell support.
27  *
9ab346 28  * @package    Framework
AM 29  * @subpackage Utils
b4edf7 30  */
A 31 class rcube_spellchecker
32 {
33     private $matches = array();
34     private $engine;
35     private $lang;
36     private $rc;
37     private $error;
66df08 38     private $separator = '/[\s\r\n\t\(\)\/\[\]{}<>\\"]+|[:;?!,\.]([^\w]|$)/';
A 39     private $options = array();
40     private $dict;
41     private $have_dict;
42
b4edf7 43
A 44     // default settings
45     const GOOGLE_HOST = 'ssl://www.google.com';
46     const GOOGLE_PORT = 443;
47     const MAX_SUGGESTIONS = 10;
48
49
50     /**
51      * Constructor
52      *
53      * @param string $lang Language code
54      */
55     function __construct($lang = 'en')
56     {
be98df 57         $this->rc     = rcube::get_instance();
b4edf7 58         $this->engine = $this->rc->config->get('spellcheck_engine', 'googie');
66df08 59         $this->lang   = $lang ? $lang : 'en';
b4edf7 60
66df08 61         $this->options = array(
A 62             'ignore_syms' => $this->rc->config->get('spellcheck_ignore_syms'),
63             'ignore_nums' => $this->rc->config->get('spellcheck_ignore_nums'),
64             'ignore_caps' => $this->rc->config->get('spellcheck_ignore_caps'),
65             'dictionary'  => $this->rc->config->get('spellcheck_dictionary'),
66         );
b4edf7 67     }
A 68
69
70     /**
71      * Set content and check spelling
72      *
73      * @param string $text    Text content for spellchecking
74      * @param bool   $is_html Enables HTML-to-Text conversion
75      *
76      * @return bool True when no mispelling found, otherwise false
77      */
66df08 78     function check($text, $is_html = false)
b4edf7 79     {
A 80         // convert to plain text
81         if ($is_html) {
82             $this->content = $this->html2text($text);
83         }
84         else {
85             $this->content = $text;
86         }
87
88         if ($this->engine == 'pspell') {
89             $this->matches = $this->_pspell_check($this->content);
90         }
91         else {
92             $this->matches = $this->_googie_check($this->content);
93         }
94
95         return $this->found() == 0;
96     }
97
98
99     /**
100      * Number of mispellings found (after check)
101      *
102      * @return int Number of mispellings
103      */
104     function found()
105     {
106         return count($this->matches);
107     }
108
109
110     /**
111      * Returns suggestions for the specified word
112      *
113      * @param string $word The word
114      *
115      * @return array Suggestions list
116      */
117     function get_suggestions($word)
118     {
119         if ($this->engine == 'pspell') {
120             return $this->_pspell_suggestions($word);
121         }
122
66df08 123         return $this->_googie_suggestions($word);
b4edf7 124     }
66df08 125
b4edf7 126
A 127     /**
654ac1 128      * Returns misspelled words
b4edf7 129      *
A 130      * @param string $text The content for spellchecking. If empty content
131      *                     used for check() method will be used.
132      *
654ac1 133      * @return array List of misspelled words
b4edf7 134      */
A 135     function get_words($text = null, $is_html=false)
136     {
137         if ($this->engine == 'pspell') {
138             return $this->_pspell_words($text, $is_html);
139         }
140
141         return $this->_googie_words($text, $is_html);
142     }
143
144
145     /**
146      * Returns checking result in XML (Googiespell) format
147      *
148      * @return string XML content
149      */
150     function get_xml()
151     {
152         // send output
153         $out = '<?xml version="1.0" encoding="'.RCMAIL_CHARSET.'"?><spellresult charschecked="'.mb_strlen($this->content).'">';
154
155         foreach ($this->matches as $item) {
156             $out .= '<c o="'.$item[1].'" l="'.$item[2].'">';
157             $out .= is_array($item[4]) ? implode("\t", $item[4]) : $item[4];
158             $out .= '</c>';
159         }
160
161         $out .= '</spellresult>';
162
163         return $out;
164     }
165
166
167     /**
654ac1 168      * Returns checking result (misspelled words with suggestions)
644e3a 169      *
A 170      * @return array Spellchecking result. An array indexed by word.
171      */
172     function get()
173     {
174         $result = array();
175
176         foreach ($this->matches as $item) {
177             if ($this->engine == 'pspell') {
178                 $word = $item[0];
179             }
180             else {
181                 $word = mb_substr($this->content, $item[1], $item[2], RCMAIL_CHARSET);
182             }
183             $result[$word] = is_array($item[4]) ? implode("\t", $item[4]) : $item[4];
184         }
185
66df08 186         return $result;
644e3a 187     }
A 188
189
190     /**
b4edf7 191      * Returns error message
A 192      *
193      * @return string Error message
194      */
195     function error()
196     {
197         return $this->error;
198     }
199
200
201     /**
202      * Checks the text using pspell
203      *
204      * @param string $text Text content for spellchecking
205      */
206     private function _pspell_check($text)
207     {
208         // init spellchecker
209         $this->_pspell_init();
210
211         if (!$this->plink) {
212             return array();
213         }
214
215         // tokenize
216         $text = preg_split($this->separator, $text, NULL, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE);
217
66df08 218         $diff       = 0;
A 219         $matches    = array();
b4edf7 220
A 221         foreach ($text as $w) {
222             $word = trim($w[0]);
223             $pos  = $w[1] - $diff;
224             $len  = mb_strlen($word);
225
66df08 226             // skip exceptions
A 227             if ($this->is_exception($word)) {
228             }
229             else if (!pspell_check($this->plink, $word)) {
b4edf7 230                 $suggestions = pspell_suggest($this->plink, $word);
A 231
cb190c 232                 if (sizeof($suggestions) > self::MAX_SUGGESTIONS) {
ec78f9 233                     $suggestions = array_slice($suggestions, 0, self::MAX_SUGGESTIONS);
cb190c 234                 }
b4edf7 235
A 236                 $matches[] = array($word, $pos, $len, null, $suggestions);
237             }
238
239             $diff += (strlen($word) - $len);
240         }
241
242         return $matches;
243     }
244
245
246     /**
654ac1 247      * Returns the misspelled words
b4edf7 248      */
A 249     private function _pspell_words($text = null, $is_html=false)
250     {
66df08 251         $result = array();
A 252
b4edf7 253         if ($text) {
A 254             // init spellchecker
255             $this->_pspell_init();
256
257             if (!$this->plink) {
258                 return array();
259             }
260
654ac1 261             // With PSpell we don't need to get suggestions to return misspelled words
b4edf7 262             if ($is_html) {
A 263                 $text = $this->html2text($text);
264             }
265
266             $text = preg_split($this->separator, $text, NULL, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE);
267
268             foreach ($text as $w) {
269                 $word = trim($w[0]);
66df08 270
A 271                 // skip exceptions
272                 if ($this->is_exception($word)) {
273                     continue;
274                 }
275
276                 if (!pspell_check($this->plink, $word)) {
b4edf7 277                     $result[] = $word;
A 278                 }
279             }
280
281             return $result;
282         }
283
284         foreach ($this->matches as $m) {
285             $result[] = $m[0];
286         }
287
288         return $result;
289     }
290
291
292     /**
654ac1 293      * Returns suggestions for misspelled word
b4edf7 294      */
A 295     private function _pspell_suggestions($word)
296     {
297         // init spellchecker
298         $this->_pspell_init();
299
300         if (!$this->plink) {
301             return array();
302         }
303
304         $suggestions = pspell_suggest($this->plink, $word);
305
306         if (sizeof($suggestions) > self::MAX_SUGGESTIONS)
307             $suggestions = array_slice($suggestions, 0, self::MAX_SUGGESTIONS);
308
309         return is_array($suggestions) ? $suggestions : array();
310     }
311
312
313     /**
314      * Initializes PSpell dictionary
315      */
316     private function _pspell_init()
317     {
318         if (!$this->plink) {
ec78f9 319             if (!extension_loaded('pspell')) {
AM 320                 $this->error = "Pspell extension not available";
321                 rcube::raise_error(array(
322                     'code' => 500, 'type' => 'php',
323                     'file' => __FILE__, 'line' => __LINE__,
324                     'message' => $this->error), true, false);
325
326                 return;
327             }
328
b4edf7 329             $this->plink = pspell_new($this->lang, null, null, RCMAIL_CHARSET, PSPELL_FAST);
A 330         }
331
332         if (!$this->plink) {
333             $this->error = "Unable to load Pspell engine for selected language";
334         }
335     }
336
337
338     private function _googie_check($text)
339     {
340         // spell check uri is configured
341         $url = $this->rc->config->get('spellcheck_uri');
342
343         if ($url) {
344             $a_uri = parse_url($url);
345             $ssl   = ($a_uri['scheme'] == 'https' || $a_uri['scheme'] == 'ssl');
346             $port  = $a_uri['port'] ? $a_uri['port'] : ($ssl ? 443 : 80);
347             $host  = ($ssl ? 'ssl://' : '') . $a_uri['host'];
348             $path  = $a_uri['path'] . ($a_uri['query'] ? '?'.$a_uri['query'] : '') . $this->lang;
349         }
350         else {
351             $host = self::GOOGLE_HOST;
352             $port = self::GOOGLE_PORT;
353             $path = '/tbproxy/spell?lang=' . $this->lang;
354         }
355
356         // Google has some problem with spaces, use \n instead
66df08 357         $gtext = str_replace(' ', "\n", $text);
b4edf7 358
66df08 359         $gtext = '<?xml version="1.0" encoding="utf-8" ?>'
b4edf7 360             .'<spellrequest textalreadyclipped="0" ignoredups="0" ignoredigits="1" ignoreallcaps="1">'
66df08 361             .'<text>' . $gtext . '</text>'
b4edf7 362             .'</spellrequest>';
A 363
364         $store = '';
365         if ($fp = fsockopen($host, $port, $errno, $errstr, 30)) {
366             $out = "POST $path HTTP/1.0\r\n";
367             $out .= "Host: " . str_replace('ssl://', '', $host) . "\r\n";
66df08 368             $out .= "Content-Length: " . strlen($gtext) . "\r\n";
b4edf7 369             $out .= "Content-Type: application/x-www-form-urlencoded\r\n";
A 370             $out .= "Connection: Close\r\n\r\n";
66df08 371             $out .= $gtext;
b4edf7 372             fwrite($fp, $out);
A 373
374             while (!feof($fp))
375                 $store .= fgets($fp, 128);
376             fclose($fp);
377         }
378
379         if (!$store) {
380             $this->error = "Empty result from spelling engine";
381         }
382
383         preg_match_all('/<c o="([^"]*)" l="([^"]*)" s="([^"]*)">([^<]*)<\/c>/', $store, $matches, PREG_SET_ORDER);
66df08 384
A 385         // skip exceptions (if appropriate options are enabled)
386         if (!empty($this->options['ignore_syms']) || !empty($this->options['ignore_nums'])
387             || !empty($this->options['ignore_caps']) || !empty($this->options['dictionary'])
388         ) {
389             foreach ($matches as $idx => $m) {
390                 $word = mb_substr($text, $m[1], $m[2], RCMAIL_CHARSET);
391                 // skip  exceptions
392                 if ($this->is_exception($word)) {
393                     unset($matches[$idx]);
394                 }
395             }
396         }
b4edf7 397
A 398         return $matches;
399     }
400
401
402     private function _googie_words($text = null, $is_html=false)
403     {
404         if ($text) {
405             if ($is_html) {
406                 $text = $this->html2text($text);
407             }
408
409             $matches = $this->_googie_check($text);
410         }
411         else {
412             $matches = $this->matches;
413             $text    = $this->content;
414         }
415
416         $result = array();
417
418         foreach ($matches as $m) {
419             $result[] = mb_substr($text, $m[1], $m[2], RCMAIL_CHARSET);
420         }
421
422         return $result;
423     }
424
425
426     private function _googie_suggestions($word)
427     {
428         if ($word) {
429             $matches = $this->_googie_check($word);
430         }
431         else {
432             $matches = $this->matches;
433         }
434
435         if ($matches[0][4]) {
436             $suggestions = explode("\t", $matches[0][4]);
437             if (sizeof($suggestions) > self::MAX_SUGGESTIONS) {
438                 $suggestions = array_slice($suggestions, 0, MAX_SUGGESTIONS);
439             }
440
441             return $suggestions;
442         }
443
444         return array();
445     }
446
447
448     private function html2text($text)
449     {
450         $h2t = new html2text($text, false, true, 0);
451         return $h2t->get_text();
452     }
66df08 453
A 454
455     /**
456      * Check if the specified word is an exception accoring to 
457      * spellcheck options.
458      *
459      * @param string  $word  The word
460      *
461      * @return bool True if the word is an exception, False otherwise
462      */
463     public function is_exception($word)
464     {
465         // Contain only symbols (e.g. "+9,0", "2:2")
466         if (!$word || preg_match('/^[0-9@#$%^&_+~*=:;?!,.-]+$/', $word))
467             return true;
468
469         // Contain symbols (e.g. "g@@gle"), all symbols excluding separators
470         if (!empty($this->options['ignore_syms']) && preg_match('/[@#$%^&_+~*=-]/', $word))
471             return true;
472
473         // Contain numbers (e.g. "g00g13")
474         if (!empty($this->options['ignore_nums']) && preg_match('/[0-9]/', $word))
475             return true;
476
477         // Blocked caps (e.g. "GOOGLE")
478         if (!empty($this->options['ignore_caps']) && $word == mb_strtoupper($word))
479             return true;
480
481         // Use exceptions from dictionary
482         if (!empty($this->options['dictionary'])) {
483             $this->load_dict();
484
485             // @TODO: should dictionary be case-insensitive?
486             if (!empty($this->dict) && in_array($word, $this->dict))
487                 return true;
488         }
489
490         return false;
491     }
492
493
494     /**
495      * Add a word to dictionary
496      *
497      * @param string  $word  The word to add
498      */
499     public function add_word($word)
500     {
501         $this->load_dict();
502
503         foreach (explode(' ', $word) as $word) {
504             // sanity check
505             if (strlen($word) < 512) {
506                 $this->dict[] = $word;
507                 $valid = true;
508             }
509         }
510
511         if ($valid) {
512             $this->dict = array_unique($this->dict);
513             $this->update_dict();
514         }
515     }
516
517
518     /**
519      * Remove a word from dictionary
520      *
521      * @param string  $word  The word to remove
522      */
523     public function remove_word($word)
524     {
525         $this->load_dict();
526
527         if (($key = array_search($word, $this->dict)) !== false) {
528             unset($this->dict[$key]);
529             $this->update_dict();
530         }
531     }
532
533
534     /**
535      * Update dictionary row in DB
536      */
537     private function update_dict()
538     {
539         if (strcasecmp($this->options['dictionary'], 'shared') != 0) {
0c2596 540             $userid = $this->rc->get_user_id();
66df08 541         }
A 542
543         $plugin = $this->rc->plugins->exec_hook('spell_dictionary_save', array(
544             'userid' => $userid, 'language' => $this->lang, 'dictionary' => $this->dict));
545
546         if (!empty($plugin['abort'])) {
547             return;
548         }
549
550         if ($this->have_dict) {
551             if (!empty($this->dict)) {
552                 $this->rc->db->query(
0c2596 553                     "UPDATE ".$this->rc->db->table_name('dictionary')
66df08 554                     ." SET data = ?"
0c2596 555                     ." WHERE user_id " . ($plugin['userid'] ? "= ".$this->rc->db->quote($plugin['userid']) : "IS NULL")
66df08 556                         ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?",
A 557                     implode(' ', $plugin['dictionary']), $plugin['language']);
558             }
559             // don't store empty dict
560             else {
561                 $this->rc->db->query(
0c2596 562                     "DELETE FROM " . $this->rc->db->table_name('dictionary')
A 563                     ." WHERE user_id " . ($plugin['userid'] ? "= ".$this->rc->db->quote($plugin['userid']) : "IS NULL")
66df08 564                         ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?",
A 565                     $plugin['language']);
566             }
567         }
568         else if (!empty($this->dict)) {
569             $this->rc->db->query(
0c2596 570                 "INSERT INTO " .$this->rc->db->table_name('dictionary')
66df08 571                 ." (user_id, " . $this->rc->db->quoteIdentifier('language') . ", data) VALUES (?, ?, ?)",
A 572                 $plugin['userid'], $plugin['language'], implode(' ', $plugin['dictionary']));
573         }
574     }
575
576
577     /**
578      * Get dictionary from DB
579      */
580     private function load_dict()
581     {
582         if (is_array($this->dict)) {
583             return $this->dict;
584         }
585
586         if (strcasecmp($this->options['dictionary'], 'shared') != 0) {
0c2596 587             $userid = $this->rc->get_user_id();
66df08 588         }
A 589
590         $plugin = $this->rc->plugins->exec_hook('spell_dictionary_get', array(
591             'userid' => $userid, 'language' => $this->lang, 'dictionary' => array()));
592
593         if (empty($plugin['abort'])) {
594             $dict = array();
595             $this->rc->db->query(
0c2596 596                 "SELECT data FROM ".$this->rc->db->table_name('dictionary')
A 597                 ." WHERE user_id ". ($plugin['userid'] ? "= ".$this->rc->db->quote($plugin['userid']) : "IS NULL")
66df08 598                     ." AND " . $this->rc->db->quoteIdentifier('language') . " = ?",
A 599                 $plugin['language']);
600
601             if ($sql_arr = $this->rc->db->fetch_assoc($sql_result)) {
602                 $this->have_dict = true;
603                 if (!empty($sql_arr['data'])) {
604                     $dict = explode(' ', $sql_arr['data']);
605                 }
606             }
607
608             $plugin['dictionary'] = array_merge((array)$plugin['dictionary'], $dict);
609         }
610
611         if (!empty($plugin['dictionary']) && is_array($plugin['dictionary'])) {
612             $this->dict = $plugin['dictionary'];
613         }
614         else {
615             $this->dict = array();
616         }
617
618         return $this->dict;
619     }
620
b4edf7 621 }