1 <?php
2
3 /*
4 * This file is part of the ICanBoogie package.
5 *
6 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 *
11 * Original code: http://code.google.com/p/yii/source/browse/tags/1.1.6/framework/i18n/CNumberFormatter.php
12 */
13
14 namespace ICanBoogie\I18n;
15
16 /**
17 * NumberFormatter provides number localization features.
18 *
19 * NumberFormatter formats a number (integer or float) and outputs a string
20 * based on the specified format. A NumberFormatter instance is associated with a locale,
21 * and thus generates the string representation of the number in a locale-dependent fashion.
22 *
23 * NumberFormatter currently supports currency format, percentage format, decimal format,
24 * and custom format. The first three formats are specified in the locale data, while the custom
25 * format allows you to enter an arbitrary format string.
26 *
27 * A format string may consist of the following special characters:
28 * <ul>
29 * <li>dot (.): the decimal point. It will be replaced with the localized decimal point.</li>
30 * <li>comma (,): the grouping separator. It will be replaced with the localized grouping separator.</li>
31 * <li>zero (0): required digit. This specifies the places where a digit must appear (will pad 0 if not).</li>
32 * <li>hash (#): optional digit. This is mainly used to specify the location of decimal point and grouping separators.</li>
33 * <li>currency (¤): the currency placeholder. It will be replaced with the localized currency symbol.</li>
34 * <li>percentage (%): the percetage mark. If appearing, the number will be multiplied by 100 before being formatted.</li>
35 * <li>permillage (‰): the permillage mark. If appearing, the number will be multiplied by 1000 before being formatted.</li>
36 * <li>semicolon (;): the character separating positive and negative number sub-patterns.</li>
37 * </ul>
38 *
39 * Anything surrounding the pattern (or sub-patterns) will be kept.
40 *
41 * The followings are some examples:
42 * <pre>
43 * Pattern "#,##0.00" will format 12345.678 as "12,345.68".
44 * Pattern "#,#,#0.00" will format 12345.6 as "1,2,3,45.60".
45 * </pre>
46 * Note, in the first example, the number is rounded first before applying the formatting.
47 * And in the second example, the pattern specifies two grouping sizes.
48 *
49 * NumberFormatter attempts to implement number formatting according to
50 * the {@link http://www.unicode.org/reports/tr35/ Unicode Technical Standard #35}.
51 * The following features are NOT implemented:
52 * <ul>
53 * <li>significant digit</li>
54 * <li>scientific format</li>
55 * <li>arbitrary literal characters</li>
56 * <li>arbitrary padding</li>
57 * </ul>
58 *
59 * @author Wei Zhuo <weizhuo[at]gmail[dot]com>
60 * @author Qiang Xue <qiang.xue@gmail.com>
61 * @author Olivier Laviale <gmail@olvlvl.com>
62 */
63 class NumberFormatter
64 {
65 /**
66 * Locale.
67 *
68 * @var Locale
69 */
70 protected $locale;
71
72 /**
73 * Shortcut to the locale `numbers` convention.
74 *
75 * @var array
76 */
77 protected $conventions;
78
79 /**
80 * Parsed formats cache.
81 *
82 * @var array
83 */
84 private $formats = array();
85
86
87 /**
88 * Constructor.
89 *
90 * @param Locale $locale Locale instance.
91 */
92 public function __construct(Locale $locale)
93 {
94 $this->locale = $locale;
95 $this->conventions = $locale['numbers'];
96 }
97
98 /**
99 * Formats a number based on the specified pattern.
100 * Note, if the format contains '%', the number will be multiplied by 100 first.
101 * If the format contains '‰', the number will be multiplied by 1000.
102 * If the format contains currency placeholder, it will be replaced by
103 * the specified localized currency symbol.
104 * @param string $pattern format pattern
105 * @param mixed $value the number to be formatted
106 * @param string $currency 3-letter ISO 4217 code. For example, the code "USD" represents the US Dollar and "EUR" represents the Euro currency.
107 * The currency placeholder in the pattern will be replaced with the currency symbol.
108 * If null, no replacement will be done.
109 * @return string the formatting result.
110 */
111 public function format($value, $pattern=null, $currency=null)
112 {
113 if (!$pattern)
114 {
115 $pattern = $this->conventions['decimalFormats']['decimalFormatLength']['decimalFormat']['pattern'];
116 }
117
118 $format = $this->parse_format($pattern);
119 $result = $this->format_number($value, $format);
120
121 if ($currency === null)
122 {
123 return $result;
124 }
125
126 $currencies = $this->conventions['currencies'];
127 $symbol = isset($currencies[$currency]['symbol']) ? $currencies[$currency]['symbol'] : $currency;
128
129 return str_replace('¤', $symbol, $result);
130 }
131
132 /**
133 * Formats a number using the currency format defined in the locale.
134 * @param mixed $value the number to be formatted
135 * @param string $currency 3-letter ISO 4217 code. For example, the code "USD" represents the US Dollar and "EUR" represents the Euro currency.
136 * The currency placeholder in the pattern will be replaced with the currency symbol.
137 * @return string the formatting result.
138 */
139 public function format_currency($value, $currency)
140 {
141 return $this->format($value, $this->conventions['currencyFormats']['currencyFormatLength']['currencyFormat']['pattern'], $currency);
142 }
143
144 /**
145 * Formats a number using the percentage format defined in the locale.
146 * Note, if the percentage format contains '%', the number will be multiplied by 100 first.
147 * If the percentage format contains '‰', the number will be multiplied by 1000.
148 * @param mixed $value the number to be formatted
149 * @return string the formatting result.
150 */
151 public function format_percentage($value)
152 {
153 return $this->format($this->locale->getPercentFormat(), $value);
154 }
155
156 /**
157 * Formats a number using the decimal format defined in the locale.
158 * @param mixed $value the number to be formatted
159 * @return string the formatting result.
160 */
161 public function format_decimal($value)
162 {
163 return $this->format($this->locale->getDecimalFormat(),$value);
164 }
165
166 /**
167 * Formats a number based on a format.
168 * This is the method that does actual number formatting.
169 * @param array $format format with the following structure:
170 * <pre>
171 * array(
172 * 'decimalDigits'=>2, // number of required digits after decimal point; 0s will be padded if not enough digits; if -1, it means we should drop decimal point
173 * 'maxDecimalDigits'=>3, // maximum number of digits after decimal point. Additional digits will be truncated.
174 * 'integerDigits'=>1, // number of required digits before decimal point; 0s will be padded if not enough digits
175 * 'groupSize1'=>3, // the primary grouping size; if 0, it means no grouping
176 * 'groupSize2'=>0, // the secondary grouping size; if 0, it means no secondary grouping
177 * 'positivePrefix'=>'+', // prefix to positive number
178 * 'positiveSuffix'=>'', // suffix to positive number
179 * 'negativePrefix'=>'(', // prefix to negative number
180 * 'negativeSuffix'=>')', // suffix to negative number
181 * 'multiplier'=>1, // 100 for percent, 1000 for per mille
182 * );
183 * </pre>
184 * @param mixed $value the number to be formatted
185 * @return string the formatted result
186 */
187 protected function format_number($value, $format)
188 {
189 $negative=$value<0;
190 $value=abs($value*$format['multiplier']);
191 if($format['maxDecimalDigits']>=0)
192 $value=round($value,$format['maxDecimalDigits']);
193 $value="$value";
194 if(($pos=strpos($value,'.'))!==false)
195 {
196 $integer=substr($value,0,$pos);
197 $decimal=substr($value,$pos+1);
198 }
199 else
200 {
201 $integer=$value;
202 $decimal='';
203 }
204
205 if($format['decimalDigits']>strlen($decimal))
206 $decimal=str_pad($decimal,$format['decimalDigits'],'0');
207
208 if (strlen($decimal))
209 {
210 $decimal = $this->conventions['symbols']['decimal'] . $decimal;
211 }
212
213 $integer=str_pad($integer,$format['integerDigits'],'0',STR_PAD_LEFT);
214 if($format['groupSize1']>0 && strlen($integer)>$format['groupSize1'])
215 {
216 $str1=substr($integer,0,-$format['groupSize1']);
217 $str2=substr($integer,-$format['groupSize1']);
218 $size=$format['groupSize2']>0?$format['groupSize2']:$format['groupSize1'];
219 $str1=str_pad($str1,(int)((strlen($str1)+$size-1)/$size)*$size,' ',STR_PAD_LEFT);
220 $integer=ltrim(implode($this->conventions['symbols']['group'],str_split($str1,$size))).$this->conventions['symbols']['group'].$str2;
221 }
222
223 if($negative)
224 $number=$format['negativePrefix'].$integer.$decimal.$format['negativeSuffix'];
225 else
226 $number=$format['positivePrefix'].$integer.$decimal.$format['positiveSuffix'];
227
228 return strtr($number,array('%'=>$this->conventions['symbols']['percentSign'],'‰'=>$this->conventions['symbols']['perMille']));
229 }
230
231 /**
232 * Parses a given string pattern.
233 *
234 * @param string $pattern the pattern to be parsed
235 *
236 * @return array the parsed pattern
237 *
238 * @see format_number
239 */
240 protected function parse_format($pattern)
241 {
242 if (isset($this->formats[$pattern]))
243 {
244 return $this->formats[$pattern];
245 }
246
247 $format = array
248 (
249 'positivePrefix' => '',
250 'positiveSuffix' => '',
251 'negativePrefix' => '',
252 'negativeSuffix' => ''
253 );
254
255 // find out prefix and suffix for positive and negative patterns
256 $patterns = explode(';',$pattern);
257
258 if (preg_match('/^(.*?)[#,\.0]+(.*?)$/', $patterns[0], $matches))
259 {
260 $format['positivePrefix'] = $matches[1];
261 $format['positiveSuffix'] = $matches[2];
262 }
263
264 if (isset($patterns[1]) && preg_match('/^(.*?)[#,\.0]+(.*?)$/', $patterns[1], $matches)) // with a negative pattern
265 {
266 $format['negativePrefix'] = $matches[1];
267 $format['negativeSuffix'] = $matches[2];
268 }
269 else
270 {
271 $format['negativePrefix'] = $this->conventions['symbols']['minusSign'] . $format['positivePrefix'];
272 $format['negativeSuffix'] = $format['positiveSuffix'];
273 }
274
275 $pat = $patterns[0];
276
277 // find out multiplier
278 if (strpos($pat,'%') !== false)
279 {
280 $format['multiplier'] = 100;
281 }
282 else if (strpos($pat,'‰') !== false)
283 {
284 $format['multiplier'] = 1000;
285 }
286 else
287 {
288 $format['multiplier'] = 1;
289 }
290
291 // find out things about decimal part
292 if (($pos = strpos($pat,'.')) !== false)
293 {
294 if (($pos2 = strrpos($pat,'0')) > $pos)
295 {
296 $format['decimalDigits'] = $pos2-$pos;
297 }
298 else
299 {
300 $format['decimalDigits'] = 0;
301 }
302
303 if (($pos3 = strrpos($pat,'#')) >= $pos2)
304 {
305 $format['maxDecimalDigits'] = $pos3 - $pos;
306 }
307 else
308 {
309 $format['maxDecimalDigits'] = $format['decimalDigits'];
310 }
311
312 $pat = substr($pat, 0, $pos);
313 }
314 else // no decimal part
315 {
316 $format['decimalDigits'] = 0;
317 $format['maxDecimalDigits'] = 0;
318 }
319
320 // find out things about integer part
321 $p = str_replace(',','',$pat);
322
323 if (($pos=strpos($p,'0')) !== false)
324 {
325 $format['integerDigits'] = strrpos($p, '0') - $pos + 1;
326 }
327 else
328 {
329 $format['integerDigits'] = 0;
330 }
331
332 // find out group sizes. some patterns may have two different group sizes
333
334 $p = str_replace('#', '0', $pat);
335
336 if (($pos = strrpos($pat, ',')) !== false)
337 {
338 $format['groupSize1'] = strrpos($p, '0') - $pos;
339
340 if (($pos2 = strrpos(substr($p, 0, $pos), ',')) !== false)
341 {
342 $format['groupSize2'] = $pos - $pos2 - 1;
343 }
344 else
345 {
346 $format['groupSize2'] = 0;
347 }
348 }
349 else
350 {
351 $format['groupSize1'] = $format['groupSize2'] = 0;
352 }
353
354 return $this->formats[$pattern] = $format;
355 }
356 }