Completed
Push — master ( 317d6d...1d0462 )
by mw
34:54 queued 09:21
created

src/IntlNumberFormatter.php (1 issue)

Labels
Severity

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
namespace SMW;
4
5
use InvalidArgumentException;
6
7
/**
8
 * @license GNU GPL v2+
9
 * @since 2.1
10
 *
11
 * @author mwjames
12
 * @author Markus Krötzsch
13
 */
14
class IntlNumberFormatter {
15
16
	/**
17
	 * Localization related constants
18
	 */
19
	const CONTENT_LANGUAGE = Message::CONTENT_LANGUAGE;
20
	const USER_LANGUAGE = Message::USER_LANGUAGE;
21
22
	/**
23
	 * Separator related constants
24
	 */
25
	const DECIMAL_SEPARATOR = 'DS';
26
	const THOUSANDS_SEPARATOR = 'TS';
27
28
	/**
29
	 * Format related constants
30
	 */
31
	const DEFAULT_FORMAT = 'DF';
32
	const VALUE_FORMAT = 'VF';
33
34
	/**
35
	 * @var IntlNumberFormatter
36
	 */
37
	private static $instance = null;
38
39
	/**
40
	 * @var Options
41
	 */
42
	private $options = null;
43
44
	/**
45
	 * @var integer
46
	 */
47
	private $maxNonExpNumber = null;
48
49
	/**
50
	 * @var integer
51
	 */
52
	private $defaultPrecision = 3;
53
54
	/**
55
	 * @since 2.1
56
	 *
57
	 * @param integer $maxNonExpNumber
58
	 */
59 26
	public function __construct( $maxNonExpNumber ) {
60 26
		$this->maxNonExpNumber = $maxNonExpNumber;
61 26
		$this->options = new Options();
62 26
	}
63
64
	/**
65
	 * @since 2.1
66
	 *
67
	 * @return IntlNumberFormatter
68
	 */
69 49
	public static function getInstance() {
70
71 49
		if ( self::$instance === null ) {
72
			self::$instance = new self(
73
				$GLOBALS['smwgMaxNonExpNumber']
74
			);
75
		}
76
77 49
		return self::$instance;
78
	}
79
80
	/**
81
	 * @since 2.1
82
	 */
83
	public function clear() {
84
		self::$instance = null;
85
	}
86
87
	/**
88
	 * @since 2.4
89
	 */
90 48
	public function initialize() {
91 48
		$this->options->set( 'separator.decimal', false );
92 48
		$this->options->set( 'separator.thousands', false );
93 48
		$this->options->set( 'user.language', false );
94 48
		$this->options->set( 'content.language', false );
95 48
		$this->options->set( 'preferred.language', false );
96 48
	}
97
98
	/**
99
	 * @since 2.4
100
	 *
101
	 * @return string $key
102
	 * @param mixed $value
103
	 */
104 71
	public function setOption( $key, $value ) {
105 71
		$this->options->set( $key, $value );
106 71
	}
107
108
	/**
109
	 * @since 2.4
110
	 *
111
	 * @param integer $type
112
	 * @param string|integer $locale
113
	 *
114
	 * @return string
115
	 */
116 72
	public function getSeparator( $type, $locale = '' ) {
117
118 72
		$language = $locale === self::USER_LANGUAGE ? $this->getUserLanguage() : $this->getContentLanguage();
119
120 72
		if ( $type === self::DECIMAL_SEPARATOR ) {
121 67
			return $this->getPreferredLocalizedSeparator( 'separator.decimal', 'smw_decseparator', $language );
122
		}
123
124 56
		if ( $type === self::THOUSANDS_SEPARATOR ) {
125 55
			return $this->getPreferredLocalizedSeparator( 'separator.thousands', 'smw_kiloseparator', $language );
126
		}
127
128 1
		throw new InvalidArgumentException( $type . " is unknown" );
129
	}
130
131
	/**
132
	 * This method formats a float number value according to the given language and
133
	 * precision settings, with some intelligence to produce readable output. Used
134
	 * to format a number that was not hand-formatted by a user.
135
	 *
136
	 * @param mixed $value input number
137
	 * @param integer|false $precision optional positive integer, controls how many digits after
138
	 * the decimal point are shown
139
	 * @param string|integer $format
140
	 *
141
	 * @since 2.1
142
	 *
143
	 * @return string
144
	 */
145 48
	public function format( $value, $precision = false, $format = '' ) {
146
147 48
		if ( $format === self::VALUE_FORMAT ) {
148 26
			return $this->getValueFormattedNumberWithPrecision( $value, $precision );
149
		}
150
151 37
		if ( $precision !== false || $format === self::DEFAULT_FORMAT ) {
152 4
			return $this->getDefaultFormattedNumberWithPrecision( $value, $precision );
153
		}
154
155 36
		return $this->getFormattedNumberByHeuristicRule( $value, $precision );
156
	}
157
158
	/**
159
	 * This method formats a float number value according to the given language and
160
	 * precision settings, with some intelligence to produce readable output. Used
161
	 * to format a number that was not hand-formatted by a user.
162
	 *
163
	 * @param mixed $value input number
164
	 * @param integer|false $precision optional positive integer, controls how many digits after
165
	 * the decimal point are shown
166
	 *
167
	 * @since 2.1
168
	 *
169
	 * @return string
170
	 */
171 36
	private function getFormattedNumberByHeuristicRule( $value, $precision = false ) {
172
173
		// BC configuration to keep default behaviour
174 36
		$precision = $this->defaultPrecision;
175
176 36
		$decseparator = $this->getSeparator(
177 36
			self::DECIMAL_SEPARATOR,
178 36
			self::USER_LANGUAGE
179
		);
180
181
		// If number is a trillion or more, then switch to scientific
182
		// notation. If number is less than 0.0000001 (i.e. twice precision),
183
		// then switch to scientific notation. Otherwise print number
184
		// using number_format. This may lead to 1.200, so then use trim to
185
		// remove trailing zeroes.
186 36
		$doScientific = false;
187
188
		// @todo: Don't do all this magic for integers, since the formatting does not fit there
189
		//       correctly. E.g. one would have integers formatted as 1234e6, not as 1.234e9, right?
190
		// The "$value!=0" is relevant: we want to scientify numbers that are close to 0, but never 0!
191 36
		if ( ( $precision > 0 ) && ( $value != 0 ) ) {
192 36
			$absValue = abs( $value );
193 36
			if ( $absValue >= $this->maxNonExpNumber ) {
194 1
				$doScientific = true;
195 35
			} elseif ( $absValue < pow( 10, - $precision ) ) {
196 5
				$doScientific = true;
197 35
			} elseif ( $absValue < 1 ) {
198 7
				if ( $absValue < pow( 10, - $precision ) ) {
199
					$doScientific = true;
200
				} else {
201
					// Increase decimal places for small numbers, e.g. .00123 should be 5 places.
202 7
					for ( $i = 0.1; $absValue <= $i; $i *= 0.1 ) {
203 4
						$precision++;
204
					}
205
				}
206
			}
207
		}
208
209 36
		if ( $doScientific ) {
210
			// Should we use decimal places here?
211 6
			$value = sprintf( "%1.6e", $value );
212
			// Make it more readable by removing trailing zeroes from n.n00e7.
213 6
			$value = preg_replace( '/(\\.\\d+?)0*e/u', '${1}e', $value, 1 );
214
			// NOTE: do not use the optional $count parameter with preg_replace. We need to
215
			//      remain compatible with PHP 4.something.
216 6
			if ( $decseparator !== '.' ) {
217 6
				$value = str_replace( '.', $decseparator, $value );
218
			}
219
		} else {
220 35
			$value = $this->doFormatWithPrecision(
221
				$value,
222
				$precision,
223
				$decseparator,
224 35
				$this->getSeparator( self::THOUSANDS_SEPARATOR, self::USER_LANGUAGE )
225
			);
226
227
			// Make it more readable by removing ending .000 from nnn.000
228
			//    Assumes substr is faster than a regular expression replacement.
229 35
			$end = $decseparator . str_repeat( '0', $precision );
230 35
			$lenEnd = strlen( $end );
231
232 35
			if ( substr( $value, - $lenEnd ) === $end ) {
233 31
				$value = substr( $value, 0, - $lenEnd );
234
			} else {
235
				// If above replacement occurred, no need to do the next one.
236
				// Make it more readable by removing trailing zeroes from nn.n00.
237 21
				$value = preg_replace( "/(\\$decseparator\\d+?)0*$/u", '$1', $value, 1 );
238
			}
239
		}
240
241 36
		return $value;
242
	}
243
244 26
	private function getValueFormattedNumberWithPrecision( $value, $precision = false ) {
245
246
		// The decimal are in ISO format (.), the separator as plain representation
247
		// may collide with the content language (FR) therefore use the content language
248
		// to match the decimal separator
249 26
		if ( $this->isScientific( $value ) ) {
250 5
			return $this->doFormatExponentialNotation( $value );
251
		}
252
253 24
		$precision = $precision === false ? $this->getPrecisionFrom( $value ) : $precision;
254
255 24
		return $this->doFormatWithPrecision(
256
			$value,
257
			$precision,
0 ignored issues
show
It seems like $precision defined by $precision === false ? $...om($value) : $precision on line 253 can also be of type integer; however, SMW\IntlNumberFormatter::doFormatWithPrecision() does only seem to accept boolean, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
258 24
			$this->getSeparator( self::DECIMAL_SEPARATOR, self::CONTENT_LANGUAGE ),
259 24
			''
260
		);
261
	}
262
263 4
	private function getDefaultFormattedNumberWithPrecision( $value, $precision = false ) {
264
265 4
		if ( $precision === false ) {
266
			return $this->isDecimal( $value ) ? $this->applyDefaultPrecision( $value ) : floatval( $value );
267
		}
268
269 4
		return $this->doFormatWithPrecision(
270
			$value,
271
			$precision,
272 4
			$this->getSeparator( self::DECIMAL_SEPARATOR, self::USER_LANGUAGE ),
273 4
			$this->getSeparator( self::THOUSANDS_SEPARATOR, self::USER_LANGUAGE )
274
		);
275
	}
276
277
	private function isDecimal( $value ) {
278
		return floor( $value ) !== $value;
279
	}
280
281 36
	private function isScientific( $value ) {
282 36
		return strpos( $value, 'E' ) !== false || strpos( $value, 'e' ) !== false;
283
	}
284
285
	private function applyDefaultPrecision( $value ) {
286
		return round( $value, $this->defaultPrecision );
287
	}
288
289 45
	private function getPrecisionFrom( $value ) {
290 45
		return strlen( strrchr( $value, "." ) ) - 1;
291
	}
292
293 5
	private function doFormatExponentialNotation( $value ) {
294 5
		return str_replace(
295 5
			array( '.', 'E' ),
296 5
			array( $this->getSeparator( self::DECIMAL_SEPARATOR, self::CONTENT_LANGUAGE ), 'e' ),
297
			$value
298
		);
299
	}
300
301 45
	private function doFormatWithPrecision( $value, $precision = false, $decimal, $thousand ) {
302
303 45
		$replacement = 0;
304
305
		// Don't try to be more precise than the actual value (e.g avoid turning
306
		// 72.769482308 into 72.76948230799999350892904)
307 45
		if ( ( $actualPrecision = $this->getPrecisionFrom( $value ) ) < $precision && $actualPrecision > 0 && !$this->isScientific( $value ) ) {
308 18
			$replacement = $precision - $actualPrecision;
309 18
			$precision = $actualPrecision;
310
		}
311
312
		// Format to some level of precision; number_format does rounding and
313
		// locale formatting, x and y are used temporarily since number_format
314
		// supports only single characters for either
315 45
		$value = number_format( (float)$value, $precision, 'x', 'y' );
316 45
		$value = str_replace(
317 45
			array( 'x', 'y' ),
318
			array(
319 45
				$decimal,
320 45
				$thousand
321
			),
322
			$value
323
		);
324
325 45
		if ( $replacement > 0 ) {
326 18
			 $value .= str_repeat( '0', $replacement );
327
		}
328
329 45
		return $value;
330
	}
331
332 41
	private function getUserLanguage() {
333
334 41
		$language = Message::USER_LANGUAGE;
335
336
		// The preferred language is set when the output formatter contained
337
		// something like LOCL@es
338
339 41
		if ( $this->options->has( 'preferred.language' ) && $this->options->get( 'preferred.language' ) ) {
340 1
			$language = $this->options->get( 'preferred.language' );
341 41
		} elseif ( $this->options->has( 'user.language' ) && $this->options->get( 'user.language' ) ) {
342 41
			$language = $this->options->get( 'user.language' );
343
		}
344
345 41
		return $language;
346
	}
347
348 64
	private function getContentLanguage() {
349
350 64
		$language = Message::CONTENT_LANGUAGE;
351
352 64
		if ( $this->options->has( 'content.language' ) && $this->options->get( 'content.language' ) ) {
353 61
			$language = $this->options->get( 'content.language' );
354
		}
355
356 64
		return $language;
357
	}
358
359 71
	private function getPreferredLocalizedSeparator( $custom, $standard, $language ) {
360
361 71
		if ( $this->options->has( $custom ) && ( $separator = $this->options->get( $custom ) ) !== false ) {
362 1
			return $separator;
363
		}
364
365 70
		return Message::get( $standard, Message::TEXT, $language );
366
	}
367
368
}
369