Completed
Push — unboundFormatter ( 9f130d...748150 )
by no
05:14 queued 02:52
created

DecimalMath::bumpDigitsForRounding()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 16
rs 9.4285
cc 2
eloc 8
nc 2
nop 3
1
<?php
2
3
namespace DataValues;
4
5
use InvalidArgumentException;
6
7
/**
8
 * Class for performing basic arithmetic and other transformations
9
 * on DecimalValues.
10
 *
11
 * This uses the bcmath library if available. Otherwise, it falls back on
12
 * using floating point operations.
13
 *
14
 * @note: this is not a genuine decimal arithmetics implementation,
15
 * and should not be used for financial computations, physical simulations, etc.
16
 *
17
 * @see DecimalValue
18
 *
19
 * @since 0.1
20
 *
21
 * @license GPL-2.0+
22
 * @author Daniel Kinzler
23
 */
24
class DecimalMath {
25
26
	/**
27
	 * Whether to use the bcmath library.
28
	 *
29
	 * @var bool
30
	 */
31
	private $useBC;
32
33
	/**
34
	 * @param bool|null $useBC Whether to use the bcmath library. If null,
35
	 *        bcmath will automatically be used if available.
36
	 */
37
	public function __construct( $useBC = null ) {
38
		if ( $useBC === null ) {
39
			$useBC = function_exists( 'bcscale' );
40
		}
41
42
		$this->useBC = $useBC;
43
	}
44
45
	/**
46
	 * Whether this is using the bcmath library.
47
	 *
48
	 * @return bool
49
	 */
50
	public function getUseBC() {
51
		return $this->useBC;
52
	}
53
54
	/**
55
	 * Returns the product of the two values.
56
	 *
57
	 * @param DecimalValue $a
58
	 * @param DecimalValue $b
59
	 *
60
	 * @return DecimalValue
61
	 */
62
	public function product( DecimalValue $a, DecimalValue $b ) {
63
		if ( $this->useBC ) {
64
			$scale = strlen( $a->getFractionalPart() ) + strlen( $b->getFractionalPart() );
65
			$product = bcmul( $a->getValue(), $b->getValue(), $scale );
66
		} else {
67
			$product = $a->getValueFloat() * $b->getValueFloat();
68
		}
69
70
		return new DecimalValue( $product );
71
	}
72
73
	/**
74
	 * Returns the sum of the two values.
75
	 *
76
	 * @param DecimalValue $a
77
	 * @param DecimalValue $b
78
	 *
79
	 * @return DecimalValue
80
	 */
81
	public function sum( DecimalValue $a, DecimalValue $b ) {
82
		if ( $this->useBC ) {
83
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
84
			$sum = bcadd( $a->getValue(), $b->getValue(), $scale );
85
		} else {
86
			$sum = $a->getValueFloat() + $b->getValueFloat();
87
		}
88
89
		return new DecimalValue( $sum );
90
	}
91
92
	/**
93
	 * Returns the minimum of the two values
94
	 *
95
	 * @param DecimalValue $a
96
	 * @param DecimalValue $b
97
	 *
98
	 * @return DecimalValue
99
	 */
100
	public function min( DecimalValue $a, DecimalValue $b ) {
101
		if ( $this->useBC ) {
102
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
103
			$comp = bccomp( $a->getValue(), $b->getValue(), $scale );
104
			$min = $comp > 0 ? $b : $a;
105
		} else {
106
			$min = min( $a->getValueFloat(), $b->getValueFloat() );
107
			$min = new DecimalValue( $min );
108
		}
109
110
		return $min;
111
	}
112
113
	/**
114
	 * Returns the maximum of the two values
115
	 *
116
	 * @param DecimalValue $a
117
	 * @param DecimalValue $b
118
	 *
119
	 * @return DecimalValue
120
	 */
121
	public function max( DecimalValue $a, DecimalValue $b ) {
122
		if ( $this->useBC ) {
123
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
124
			$comp = bccomp( $a->getValue(), $b->getValue(), $scale );
125
			$max = $comp > 0 ? $a : $b;
126
		} else {
127
			$max = max( $a->getValueFloat(), $b->getValueFloat() );
128
			$max = new DecimalValue( $max );
129
		}
130
131
		return $max;
132
	}
133
134
	/**
135
	 * Returns the given value, with any insignificant digits removed or zeroed.
136
	 *
137
	 * Rounding is applied  using the "round half away from zero" rule (that is, +0.5 is
138
	 * rounded to +1 and -0.5 is rounded to -1).
139
	 *
140
	 * @since 0.1
141
	 *
142
	 * @param DecimalValue $decimal
143
	 * @param int $significantDigits The number of digits to retain, counting the decimal point,
144
	 *        but not counting the leading sign.
145
	 *
146
	 * @throws InvalidArgumentException
147
	 * @return DecimalValue
148
	 */
149
	public function roundToDigit( DecimalValue $decimal, $significantDigits ) {
150
		$value = $decimal->getValue();
151
		$rounded = $this->roundDigits( $value, $significantDigits );
152
		return new DecimalValue( $rounded );
153
	}
154
155
	/**
156
	 * Returns the given value, with any insignificant digits removed or zeroed.
157
	 *
158
	 * Rounding is applied  using the "round half away from zero" rule (that is, +0.5 is
159
	 * rounded to +1 and -0.5 is rounded to -1).
160
	 *
161
	 * @since 0.1
162
	 *
163
	 * @param DecimalValue $decimal
164
	 * @param int $significantExponent 	 The exponent of the last significant digit,
165
	 *        e.g. -1 for "keep the first digit after the decimal point", or 2 for
166
	 *        "zero the last two digits before the decimal point".
167
	 *
168
	 * @throws InvalidArgumentException
169
	 * @return DecimalValue
170
	 */
171
	public function roundToExponent( DecimalValue $decimal, $significantExponent ) {
172
		//NOTE: the number of digits to keep (without the leading sign)
173
		//      is the same as the exponent's offset (with the leaqding sign).
174
		$digits = $this->getPositionForExponent( $significantExponent, $decimal );
175
		return $this->roundToDigit( $decimal, $digits );
176
	}
177
178
	/**
179
	 * Returns the (zero based) position for the given exponent in
180
	 * the given decimal string, counting the decimal point and the leading sign.
181
	 *
182
	 * @example: the position of exponent 0 in "+10.03" is 2.
183
	 * @example: the position of exponent 1 in "+210.03" is 2.
184
	 * @example: the position of exponent -2 in "+1.037" is 4.
185
	 *
186
	 * @param int $exponent
187
	 * @param DecimalValue $decimal
188
	 *
189
	 * @return int
190
	 */
191
	public function getPositionForExponent( $exponent, DecimalValue $decimal ) {
192
		$decimal = $decimal->getValue();
193
194
		$pointPos = strpos( $decimal, '.' );
195
		if ( $pointPos === false ) {
196
			$pointPos = strlen( $decimal );
197
		}
198
199
		// account for leading sign
200
		$pointPos--;
201
202
		if ( $exponent < 0 ) {
203
			// account for decimal point
204
			$position = $pointPos +1 - $exponent;
205
		} else {
206
			// make sure we don't remove more digits than are there
207
			$position = max( 0, $pointPos - $exponent );
208
		}
209
210
		return $position;
211
	}
212
213
	/**
214
	 * Returns the given value, with any insignificant digits removed or zeroed.
215
	 *
216
	 * Rounding is applied using the "round half away from zero" rule (that is, +0.5 is
217
	 * rounded to +1 and -0.5 is rounded to -1).
218
	 *
219
	 * @see round()
220
	 *
221
	 * @param string $value
222
	 * @param int $significantDigits
223
	 *
224
	 * @throws InvalidArgumentException if $significantDigits is smaller than 0
225
	 * @return string
226
	 */
227
	private function roundDigits( $value, $significantDigits ) {
228
		if ( !is_int( $significantDigits ) || $significantDigits < 0 ) {
229
			throw new InvalidArgumentException( '$significantDigits must be a non-negative integer' );
230
		}
231
232
		// keeping no digits results in zero.
233
		if ( $significantDigits === 0 ) {
234
			return '+0';
235
		}
236
237
		$len = strlen( $value );
238
239
		// keeping all digits means no rounding
240
		if ( $significantDigits >= ( $len -1 ) ) {
241
			return $value;
242
		}
243
244
		// whether the last character is already part of the integer part of the decimal value
245
		$i = min( $significantDigits +1, $len ); // account for the sign
246
		$ch = $i < $len ? $value[$i] : '0';
247
248
		if ( $ch === '.' ) {
249
			// NOTE: we expect the input to be well formed, so it cannot end with a '.'
250
			$i++;
251
			$ch = $i < $len ? $value[$i] : '0';
252
		}
253
254
		// split in significant and insignificant part
255
		$rounded = substr( $value, 0, $i );
256
257
		if ( strpos( $rounded, '.' ) === false ) {
258
			$suffix = substr( $value, $i );
259
260
			// strip insignificant digits after the decimal point
261
			$ppos = strpos( $suffix, '.' );
262
			if ( $ppos !== false ) {
263
				$suffix = substr( $suffix, 0, $ppos );
264
			}
265
266
			// zero out insignificant digits
267
			$suffix = strtr( $suffix, '123456789', '000000000' );
268
		} else {
269
			// decimal point is in $rounded, so $suffix is insignificant
270
			$suffix = '';
271
		}
272
273
		if ( $ch >= '5' ) {
274
			$rounded = $this->bumpDigits( $rounded );
275
		}
276
277
		$rounded .= $suffix;
278
279
		if ( $significantDigits > strlen( $rounded ) -1 ) {
280
			if ( strpos( $rounded, '.' ) !== false ) {
281
				$rounded = str_pad( $rounded, $significantDigits+1, '0', STR_PAD_RIGHT );
282
			}
283
		}
284
285
		// strip trailing decimal point
286
		$rounded = rtrim( $rounded, '.' );
287
288
		return $rounded;
289
	}
290
291
	/**
292
	 * Increment the least significant digit by one if it is less than 9, and
293
	 * set it to zero and continue to the next more significant digit if it is 9.
294
	 * Exception: bump( 0 ) == 1;
295
	 *
296
	 * E.g.: bump( 0.2 ) == 0.3, bump( -0.09 ) == -0.10, bump( 9.99 ) == 10.00
297
	 *
298
	 * This is the inverse of @see slump()
299
	 *
300
	 * @since 0.1
301
	 *
302
	 * @param DecimalValue $decimal
303
	 *
304
	 * @return DecimalValue
305
	 */
306
	public function bump( DecimalValue $decimal ) {
307
		$value = $decimal->getValue();
308
		$bumped = $this->bumpDigits( $value );
309
		return new DecimalValue( $bumped );
310
	}
311
312
	/**
313
	 * Increment the least significant digit by one if it is less than 9, and
314
	 * set it to zero and continue to the next more significant digit if it is 9.
315
	 *
316
	 * @see bump()
317
	 *
318
	 * @param string $value
319
	 * @return string
320
	 */
321
	private function bumpDigits( $value ) {
322
		if ( $value === '+0' ) {
323
			return '+1';
324
		}
325
326
		$bumped = '';
327
328
		for ( $i = strlen( $value ) -1; $i >= 0; $i-- ) {
329
			$ch = $value[$i];
330
331
			if ( $ch === '.' ) {
332
				$bumped = '.' . $bumped;
333
				continue;
334
			} elseif ( $ch === '9' ) {
335
				$bumped = '0' . $bumped;
336
				continue;
337
			} elseif ( $ch === '+' || $ch === '-' ) {
338
				$bumped = $ch . '1' . $bumped;
339
				break;
340
			} else {
341
				$bumped =  chr( ord( $ch ) + 1 ) . $bumped;
342
				break;
343
			}
344
		}
345
346
		$bumped = substr( $value, 0, $i ) . $bumped;
347
		return $bumped;
348
	}
349
350
	/**
351
	 * Decrement the least significant digit by one if it is more than 0, and
352
	 * set it to 9 and continue to the next more significant digit if it is 0.
353
	 * Exception: slump( 0 ) == -1;
354
	 *
355
	 * E.g.: slump( 0.2 ) == 0.1, slump( -0.10 ) == -0.01, slump( 0.0 ) == -1.0
356
	 *
357
	 * This is the inverse of @see bump()
358
	 *
359
	 * @since 0.1
360
	 *
361
	 * @param DecimalValue $decimal
362
	 *
363
	 * @return DecimalValue
364
	 */
365
	public function slump( DecimalValue $decimal ) {
366
		$value = $decimal->getValue();
367
		$slumped = $this->slumpDigits( $value );
368
		return new DecimalValue( $slumped );
369
	}
370
371
	/**
372
	 * Decrement the least significant digit by one if it is more than 0, and
373
	 * set it to 9 and continue to the next more significant digit if it is 0.
374
	 *
375
	 * @see slump()
376
	 *
377
	 * @param string $value
378
	 * @return string
379
	 */
380
	private function slumpDigits( $value ) {
381
		if ( $value === '+0' ) {
382
			return '-1';
383
		}
384
385
		// a "precise zero" will become negative
386
		if ( preg_match( '/^\+0\.(0*)0$/', $value, $m ) ) {
387
			return '-0.' . $m[1] . '1';
388
		}
389
390
		$slumped = '';
391
392
		for ( $i = strlen( $value ) -1; $i >= 0; $i-- ) {
393
			$ch = substr( $value, $i, 1 );
394
395
			if ( $ch === '.' ) {
396
				$slumped = '.' . $slumped;
397
				continue;
398
			} elseif ( $ch === '0' ) {
399
				$slumped = '9' . $slumped;
400
				continue;
401
			} elseif ( $ch === '+' || $ch === '-' ) {
402
				$slumped = '0';
403
				break;
404
			} else {
405
				$slumped =  chr( ord( $ch ) - 1 ) . $slumped;
406
				break;
407
			}
408
		}
409
410
		// preserve prefix
411
		$slumped = substr( $value, 0, $i ) . $slumped;
412
		$slumped = $this->stripLeadingZeros( $slumped );
413
414
		if ( $slumped === '-0' ) {
415
			$slumped = '+0';
416
		}
417
418
		return $slumped;
419
	}
420
421
	/**
422
	 * @param string $digits
423
	 *
424
	 * @return string
425
	 */
426
	private function stripLeadingZeros( $digits ) {
427
		$digits = preg_replace( '/^([-+])0+(?=\d)/', '\1', $digits );
428
		return $digits;
429
	}
430
431
	/**
432
	 * Shift the decimal point according to the given exponent.
433
	 *
434
	 * @param DecimalValue $decimal
435
	 * @param int $exponent The exponent to apply (digits to shift by). A Positive exponent
436
	 * shifts the decimal point to the right, a negative exponent shifts to the left.
437
	 *
438
	 * @throws InvalidArgumentException
439
	 * @return DecimalValue
440
	 */
441
	public function shift( DecimalValue $decimal, $exponent ) {
442
		if ( !is_int( $exponent ) ) {
443
			throw new InvalidArgumentException( '$exponent must be an integer' );
444
		}
445
446
		if ( $exponent == 0 ) {
447
			return $decimal;
448
		}
449
450
		$sign = $decimal->getSign();
451
		$intPart = $decimal->getIntegerPart();
452
		$fractPart = $decimal->getFractionalPart();
453
454
		if ( $exponent < 0 ) {
455
			$intPart = $this->shiftLeft( $intPart, $exponent );
456
		} else {
457
			$fractPart = $this->shiftRight( $fractPart, $exponent );
458
		}
459
460
		$digits = $sign . $intPart . $fractPart;
461
		$digits = $this->stripLeadingZeros( $digits );
462
463
		return new DecimalValue( $digits );
464
	}
465
466
	/**
467
	 * @param string $intPart
468
	 * @param int $exponent must be negative
469
	 *
470
	 * @return string
471
	 */
472
	private function shiftLeft( $intPart, $exponent ) {
473
		//note: $exponent is negative!
474
		if ( -$exponent < strlen( $intPart ) ) {
475
			$intPart = substr( $intPart, 0, $exponent ) . '.' . substr( $intPart, $exponent );
476
		} else {
477
			$intPart = '0.' . str_pad( $intPart, -$exponent, '0', STR_PAD_LEFT );
478
		}
479
480
		return $intPart;
481
	}
482
483
	/**
484
	 * @param string $fractPart
485
	 * @param int $exponent must be positive
486
	 *
487
	 * @return string
488
	 */
489
	private function shiftRight( $fractPart, $exponent ) {
490
		//note: $exponent is positive.
491
		if ( $exponent < strlen( $fractPart ) ) {
492
			$fractPart = substr( $fractPart, 0, $exponent ) . '.' . substr( $fractPart, $exponent );
493
		} else {
494
			$fractPart = str_pad( $fractPart, $exponent, '0', STR_PAD_RIGHT );
495
		}
496
497
		return $fractPart;
498
	}
499
500
}
501