specfile
[plewww.git] / includes / unicode.inc
1 <?php
2 // $Id: unicode.inc 144 2007-03-28 07:52:20Z thierry $
3
4 define('UNICODE_ERROR', -1);
5 define('UNICODE_SINGLEBYTE', 0);
6 define('UNICODE_MULTIBYTE', 1);
7
8 /**
9  * Wrapper around _unicode_check().
10  */
11 function unicode_check() {
12   $GLOBALS['multibyte'] = _unicode_check();
13 }
14
15 /**
16  * Perform checks about Unicode support in PHP, and set the right settings if
17  * needed.
18  *
19  * Because Drupal needs to be able to handle text in various encodings, we do
20  * not support mbstring function overloading. HTTP input/output conversion must
21  * be disabled for similar reasons.
22  *
23  * @param $errors
24  *   Whether to report any fatal errors with form_set_error().
25  */
26 function _unicode_check($errors = false) {
27   // Set the standard C locale to ensure consistent, ASCII-only string handling.
28   setlocale(LC_CTYPE, 'C');
29
30   // Check for outdated PCRE library
31   // Note: we check if U+E2 is in the range U+E0 - U+E1. This test returns TRUE on old PCRE versions.
32   if (preg_match('/[à-á]/u', 'â')) {
33     if ($errors) {
34       form_set_error('unicode', t('The PCRE library in your PHP installation is outdated. This will cause problems when handling Unicode text. If you are running PHP 4.3.3 or higher, make sure you are using the PCRE library supplied by PHP. Please refer to the <a href="%url">PHP PCRE documentation</a> for more information.', array('%url' => 'http://www.php.net/pcre')));
35     }
36     return UNICODE_ERROR;
37   }
38
39   // Check for mbstring extension
40   if (!function_exists('mb_strlen')) {
41     return UNICODE_SINGLEBYTE;
42   }
43
44   // Check mbstring configuration
45   if (ini_get('mbstring.func_overload') != 0) {
46     if ($errors) {
47       form_set_error('unicode', t('Multibyte string function overloading in PHP is active and must be disabled. Check the php.ini <em>mbstring.func_overload</em> setting. Please refer to the <a href="%url">PHP mbstring documentation</a> for more information.', array('%url' => 'http://www.php.net/mbstring')));
48     }
49     return UNICODE_ERROR;
50   }
51   if (ini_get('mbstring.encoding_translation') != 0) {
52     if ($errors) {
53       form_set_error('unicode', t('Multibyte string input conversion in PHP is active and must be disabled. Check the php.ini <em>mbstring.encoding_translation</em> setting. Please refer to the <a href="%url">PHP mbstring documentation</a> for more information.', array('%url' => 'http://www.php.net/mbstring')));
54     }
55     return UNICODE_ERROR;
56   }
57   if (ini_get('mbstring.http_input') != 'pass') {
58     if ($errors) {
59       form_set_error('unicode', t('Multibyte string input conversion in PHP is active and must be disabled. Check the php.ini <em>mbstring.http_input</em> setting. Please refer to the <a href="%url">PHP mbstring documentation</a> for more information.', array('%url' => 'http://www.php.net/mbstring')));
60     }
61     return UNICODE_ERROR;
62   }
63   if (ini_get('mbstring.http_output') != 'pass') {
64     if ($errors) {
65       form_set_error('unicode', t('Multibyte string output conversion in PHP is active and must be disabled. Check the php.ini <em>mbstring.http_output</em> setting. Please refer to the <a href="%url">PHP mbstring documentation</a> for more information.', array('%url' => 'http://www.php.net/mbstring')));
66     }
67     return UNICODE_ERROR;
68   }
69
70   // Set appropriate configuration
71   mb_internal_encoding('utf-8');
72   mb_language('uni');
73   return UNICODE_MULTIBYTE;
74 }
75
76 /**
77  * Return the required Unicode status and errors for admin/settings.
78  */
79 function unicode_settings() {
80   $status = _unicode_check(true);
81   $options = array(UNICODE_SINGLEBYTE => t('Standard PHP: operations on Unicode strings are emulated on a best-effort basis. Install the <a href="%url">PHP mbstring extension</a> for improved Unicode support.', array('%url' => 'http://www.php.net/mbstring')),
82                    UNICODE_MULTIBYTE => t('Multi-byte: operations on Unicode strings are supported through the <a href="%url">PHP mbstring extension</a>.', array('%url' => 'http://www.php.net/mbstring')),
83                    UNICODE_ERROR => t('Invalid: the current configuration is incompatible with Drupal.'));
84   $form['settings'] = array('#type' => 'item', '#title' => t('String handling method'), '#value' => $options[$status]);
85   return $form;
86 }
87
88 /**
89  * Prepare a new XML parser.
90  *
91  * This is a wrapper around xml_parser_create() which extracts the encoding from
92  * the XML data first and sets the output encoding to UTF-8. This function should
93  * be used instead of xml_parser_create(), because PHP 4's XML parser doesn't
94  * check the input encoding itself. "Starting from PHP 5, the input encoding is
95  * automatically detected, so that the encoding parameter specifies only the
96  * output encoding."
97  *
98  * This is also where unsupported encodings will be converted. Callers should
99  * take this into account: $data might have been changed after the call.
100  *
101  * @param &$data
102  *   The XML data which will be parsed later.
103  * @return
104  *   An XML parser object.
105  */
106 function drupal_xml_parser_create(&$data) {
107   // Default XML encoding is UTF-8
108   $encoding = 'utf-8';
109   $bom = false;
110
111   // Check for UTF-8 byte order mark (PHP5's XML parser doesn't handle it).
112   if (!strncmp($data, "\xEF\xBB\xBF", 3)) {
113     $bom = true;
114     $data = substr($data, 3);
115   }
116
117   // Check for an encoding declaration in the XML prolog if no BOM was found.
118   if (!$bom && ereg('^<\?xml[^>]+encoding="([^"]+)"', $data, $match)) {
119     $encoding = $match[1];
120   }
121
122   // Unsupported encodings are converted here into UTF-8.
123   $php_supported = array('utf-8', 'iso-8859-1', 'us-ascii');
124   if (!in_array(strtolower($encoding), $php_supported)) {
125     $out = drupal_convert_to_utf8($data, $encoding);
126     if ($out !== false) {
127       $encoding = 'utf-8';
128       $data = ereg_replace('^(<\?xml[^>]+encoding)="([^"]+)"', '\\1="utf-8"', $out);
129     }
130     else {
131       watchdog('php', t("Could not convert XML encoding '%s' to UTF-8.", array('%s' => $encoding)), WATCHDOG_WARNING);
132       return 0;
133     }
134   }
135
136   $xml_parser = xml_parser_create($encoding);
137   xml_parser_set_option($xml_parser, XML_OPTION_TARGET_ENCODING, 'utf-8');
138   return $xml_parser;
139 }
140
141 /**
142  * Convert data to UTF-8
143  *
144  * Requires the iconv, GNU recode or mbstring PHP extension.
145  *
146  * @param $data
147  *   The data to be converted.
148  * @param $encoding
149  *   The encoding that the data is in
150  * @return
151  *   Converted data or FALSE.
152  */
153 function drupal_convert_to_utf8($data, $encoding) {
154   if (function_exists('iconv')) {
155     $out = @iconv($encoding, 'utf-8', $data);
156   }
157   else if (function_exists('mb_convert_encoding')) {
158     $out = @mb_convert_encoding($data, 'utf-8', $encoding);
159   }
160   else if (function_exists('recode_string')) {
161     $out = @recode_string($encoding .'..utf-8', $data);
162   }
163   else {
164     watchdog('php', t("Unsupported encoding '%s'. Please install iconv, GNU recode or mbstring for PHP.", array('%s' => $encoding)), WATCHDOG_ERROR);
165     return FALSE;
166   }
167
168   return $out;
169 }
170
171 /**
172  * Truncate a UTF-8-encoded string safely to a number of bytes.
173  *
174  * If the end position is in the middle of a UTF-8 sequence, it scans backwards
175  * until the beginning of the byte sequence.
176  *
177  * Use this function whenever you want to chop off a string at an unsure
178  * location. On the other hand, if you're sure that you're splitting on a
179  * character boundary (e.g. after using strpos() or similar), you can safely use
180  * substr() instead.
181  *
182  * @param $string
183  *   The string to truncate.
184  * @param $len
185  *   An upper limit on the returned string length.
186  * @param $wordsafe
187  *   Flag to truncate at nearest space. Defaults to FALSE.
188  * @return
189  *   The truncated string.
190  */
191 function truncate_utf8($string, $len, $wordsafe = FALSE, $dots = FALSE) {
192   $slen = strlen($string);
193   if ($slen <= $len) {
194     return $string;
195   }
196   if ($wordsafe) {
197     $end = $len;
198     while (($string[--$len] != ' ') && ($len > 0)) {};
199     if ($len == 0) {
200       $len = $end;
201     }
202   }
203   if ((ord($string[$len]) < 0x80) || (ord($string[$len]) >= 0xC0)) {
204     return substr($string, 0, $len) . ($dots ? ' ...' : '');
205   }
206   while (--$len >= 0 && ord($string[$len]) >= 0x80 && ord($string[$len]) < 0xC0) {};
207   return substr($string, 0, $len) . ($dots ? ' ...' : '');
208 }
209
210 /**
211  * Encodes MIME/HTTP header values that contain non-ASCII, UTF-8 encoded
212  * characters.
213  *
214  * For example, mime_header_encode('tést.txt') returns "=?UTF-8?B?dMOpc3QudHh0?=".
215  *
216  * See http://www.rfc-editor.org/rfc/rfc2047.txt for more information.
217  *
218  * Notes:
219  * - Only encode strings that contain non-ASCII characters.
220  * - We progressively cut-off a chunk with truncate_utf8(). This is to ensure
221  *   each chunk starts and ends on a character boundary.
222  * - Using \n as the chunk separator may cause problems on some systems and may
223  *   have to be changed to \r\n or \r.
224  */
225 function mime_header_encode($string) {
226   if (preg_match('/[^\x20-\x7E]/', $string)) {
227     $chunk_size = 47; // floor((75 - strlen("=?UTF-8?B??=")) * 0.75);
228     $len = strlen($string);
229     $output = '';
230     while ($len > 0) {
231       $chunk = truncate_utf8($string, $chunk_size);
232       $output .= ' =?UTF-8?B?'. base64_encode($chunk) ."?=\n";
233       $c = strlen($chunk);
234       $string = substr($string, $c);
235       $len -= $c;
236     }
237     return trim($output);
238   }
239   return $string;
240 }
241
242 /**
243  * Complement to mime_header_encode
244  */
245 function mime_header_decode($header) {
246   // First step: encoded chunks followed by other encoded chunks (need to collapse whitespace)
247   $header = preg_replace_callback('/=\?([^?]+)\?(Q|B)\?([^?]+|\?(?!=))\?=\s+(?==\?)/', '_mime_header_decode', $header);
248   // Second step: remaining chunks (do not collapse whitespace)
249   return preg_replace_callback('/=\?([^?]+)\?(Q|B)\?([^?]+|\?(?!=))\?=/', '_mime_header_decode', $header);
250 }
251
252 /**
253  * Helper function to mime_header_decode
254  */
255 function _mime_header_decode($matches) {
256   // Regexp groups:
257   // 1: Character set name
258   // 2: Escaping method (Q or B)
259   // 3: Encoded data
260   $data = ($matches[2] == 'B') ? base64_decode($matches[3]) : str_replace('_', ' ', quoted_printable_decode($matches[3]));
261   if (strtolower($matches[1]) != 'utf-8') {
262     $data = drupal_convert_to_utf8($data, $matches[1]);
263   }
264   return $data;
265 }
266
267 /**
268  * Decode all HTML entities (including numerical ones) to regular UTF-8 bytes.
269  * Double-escaped entities will only be decoded once ("&amp;lt;" becomes "&lt;", not "<").
270  *
271  * @param $text
272  *   The text to decode entities in.
273  * @param $exclude
274  *   An array of characters which should not be decoded. For example,
275  *   array('<', '&', '"'). This affects both named and numerical entities.
276  */
277 function decode_entities($text, $exclude = array()) {
278   static $table;
279   // We store named entities in a table for quick processing.
280   if (!isset($table)) {
281     // Get all named HTML entities.
282     $table = array_flip(get_html_translation_table(HTML_ENTITIES));
283     // PHP gives us ISO-8859-1 data, we need UTF-8.
284     $table = array_map('utf8_encode', $table);
285     // Add apostrophe (XML)
286     $table['&apos;'] = "'";
287   }
288   $newtable = array_diff($table, $exclude);
289
290   // Use a regexp to select all entities in one pass, to avoid decoding double-escaped entities twice.
291   return preg_replace('/&(#x?)?([A-Za-z0-9]+);/e', '_decode_entities("$1", "$2", "$0", $newtable, $exclude)', $text);
292 }
293
294 /**
295  * Helper function for decode_entities
296  */
297 function _decode_entities($prefix, $codepoint, $original, &$table, &$exclude) {
298   // Named entity
299   if (!$prefix) {
300     if (isset($table[$original])) {
301       return $table[$original];
302     }
303     else {
304       return $original;
305     }
306   }
307   // Hexadecimal numerical entity
308   if ($prefix == '#x') {
309     $codepoint = base_convert($codepoint, 16, 10);
310   }
311   // Decimal numerical entity (strip leading zeros to avoid PHP octal notation)
312   else {
313     $codepoint = preg_replace('/^0+/', '', $codepoint);
314   }
315   // Encode codepoint as UTF-8 bytes
316   if ($codepoint < 0x80) {
317     $str = chr($codepoint);
318   }
319   else if ($codepoint < 0x800) {
320     $str = chr(0xC0 | ($codepoint >> 6))
321          . chr(0x80 | ($codepoint & 0x3F));
322   }
323   else if ($codepoint < 0x10000) {
324     $str = chr(0xE0 | ( $codepoint >> 12))
325          . chr(0x80 | (($codepoint >> 6) & 0x3F))
326          . chr(0x80 | ( $codepoint       & 0x3F));
327   }
328   else if ($codepoint < 0x200000) {
329     $str = chr(0xF0 | ( $codepoint >> 18))
330          . chr(0x80 | (($codepoint >> 12) & 0x3F))
331          . chr(0x80 | (($codepoint >> 6)  & 0x3F))
332          . chr(0x80 | ( $codepoint        & 0x3F));
333   }
334   // Check for excluded characters
335   if (in_array($str, $exclude)) {
336     return $original;
337   }
338   else {
339     return $str;
340   }
341 }
342
343 /**
344  * Count the amount of characters in a UTF-8 string. This is less than or
345  * equal to the byte count.
346  */
347 function drupal_strlen($text) {
348   global $multibyte;
349   if ($multibyte == UNICODE_MULTIBYTE) {
350     return mb_strlen($text);
351   }
352   else {
353     // Do not count UTF-8 continuation bytes.
354     return strlen(preg_replace("/[\x80-\xBF]/", '', $text));
355   }
356 }
357
358 /**
359  * Uppercase a UTF-8 string.
360  */
361 function drupal_strtoupper($text) {
362   global $multibyte;
363   if ($multibyte == UNICODE_MULTIBYTE) {
364     return mb_strtoupper($text);
365   }
366   else {
367     // Use C-locale for ASCII-only uppercase
368     $text = strtoupper($text);
369     // Case flip Latin-1 accented letters
370     $text = preg_replace_callback('/\xC3[\xA0-\xB6\xB8-\xBE]/', '_unicode_caseflip', $text);
371     return $text;
372   }
373 }
374
375 /**
376  * Lowercase a UTF-8 string.
377  */
378 function drupal_strtolower($text) {
379   global $multibyte;
380   if ($multibyte == UNICODE_MULTIBYTE) {
381     return mb_strtolower($text);
382   }
383   else {
384     // Use C-locale for ASCII-only lowercase
385     $text = strtolower($text);
386     // Case flip Latin-1 accented letters
387     $text = preg_replace_callback('/\xC3[\x80-\x96\x98-\x9E]/', '_unicode_caseflip', $text);
388     return $text;
389   }
390 }
391
392 /**
393  * Helper function for case conversion of Latin-1.
394  * Used for flipping U+C0-U+DE to U+E0-U+FD and back.
395  */
396 function _unicode_caseflip($matches) {
397   return $matches[0][0] . chr(ord($matches[0][1]) ^ 32);
398 }
399
400 /**
401  * Capitalize the first letter of a UTF-8 string.
402  */
403 function drupal_ucfirst($text) {
404   // Note: no mbstring equivalent!
405   return drupal_strtoupper(drupal_substr($text, 0, 1)) . drupal_substr($text, 1);
406 }
407
408 /**
409  * Cut off a piece of a string based on character indices and counts. Follows
410  * the same behaviour as PHP's own substr() function.
411  *
412  * Note that for cutting off a string at a known character/substring
413  * location, the usage of PHP's normal strpos/substr is safe and
414  * much faster.
415  */
416 function drupal_substr($text, $start, $length = NULL) {
417   global $multibyte;
418   if ($multibyte == UNICODE_MULTIBYTE) {
419     return $length === NULL ? mb_substr($text, $start) : mb_substr($text, $start, $length);
420   }
421   else {
422     $strlen = strlen($text);
423     // Find the starting byte offset
424     if ($start > 0) {
425       // Count all the continuation bytes from the start until we have found
426       // $start characters
427       $bytes = -1; $chars = -1;
428       while ($bytes < $strlen && $chars < $start) {
429         $bytes++;
430         $c = ord($text[$bytes]);
431         if ($c < 0x80 || $c >= 0xC0) {
432           $chars++;
433         }
434       }
435     }
436     else if ($start < 0) {
437       // Count all the continuation bytes from the end until we have found
438       // abs($start) characters
439       $start = abs($start);
440       $bytes = $strlen; $chars = 0;
441       while ($bytes > 0 && $chars < $start) {
442         $bytes--;
443         $c = ord($text[$bytes]);
444         if ($c < 0x80 || $c >= 0xC0) {
445           $chars++;
446         }
447       }
448     }
449     $istart = $bytes;
450
451     // Find the ending byte offset
452     if ($length === NULL) {
453       $bytes = $strlen - 1;
454     }
455     else if ($length > 0) {
456       // Count all the continuation bytes from the starting index until we have
457       // found $length + 1 characters. Then backtrack one byte.
458       $bytes = $istart; $chars = 0;
459       while ($bytes < $strlen && $chars < $length) {
460         $bytes++;
461         $c = ord($text[$bytes]);
462         if ($c < 0x80 || $c >= 0xC0) {
463           $chars++;
464         }
465       }
466       $bytes--;
467     }
468     else if ($length < 0) {
469       // Count all the continuation bytes from the end until we have found
470       // abs($length) characters
471       $length = abs($length);
472       $bytes = $strlen - 1; $chars = 0;
473       while ($bytes >= 0 && $chars < $length) {
474         $c = ord($text[$bytes]);
475         if ($c < 0x80 || $c >= 0xC0) {
476           $chars++;
477         }
478         $bytes--;
479       }
480     }
481     $iend = $bytes;
482
483     return substr($text, $istart, max(0, $iend - $istart + 1));
484   }
485 }
486
487