Test Failed
Push — BoundedQuantityValue ( 72482a...61280d )
by Daniel
02:23
created

QuantityFormatter::formatNumber()   B

Complexity

Conditions 5
Paths 7

Size

Total Lines 26
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 6
Bugs 2 Features 1
Metric Value
c 6
b 2
f 1
dl 0
loc 26
rs 8.439
cc 5
eloc 17
nc 7
nop 1
1
<?php
2
3
namespace ValueFormatters;
4
5
use DataValues\DecimalMath;
6
use DataValues\DecimalValue;
7
use DataValues\QuantityValue;
8
use DataValues\UnboundedQuantityValue;
9
use InvalidArgumentException;
10
11
/**
12
 * Plain text formatter for quantity values.
13
 *
14
 * @since 0.1
15
 *
16
 * @license GPL-2.0+
17
 * @author Daniel Kinzler
18
 * @author Thiemo Mättig
19
 */
20
class QuantityFormatter extends ValueFormatterBase {
21
22
	/**
23
	 * Option key for enabling or disabling output of the uncertainty margin (e.g. "+/-5").
24
	 * Per default, the uncertainty margin is included in the output.
25
	 * This option must have a boolean value.
26
	 */
27
	const OPT_SHOW_UNCERTAINTY_MARGIN = 'showQuantityUncertaintyMargin';
28
29
	/**
30
	 * Option key for determining what level of rounding to apply to the numbers
31
	 * included in the output. The value of this option must be an integer or a boolean.
32
	 *
33
	 * If an integer is given, this is the exponent of the last significant decimal digits
34
	 * - that is, -2 would round to two digits after the decimal point, and 1 would round
35
	 * to two digits before the decimal point. 0 would indicate rounding to integers.
36
	 *
37
	 * If the value is a boolean, false means no rounding at all (useful e.g. in diffs),
38
	 * and true means automatic rounding based on what $quantity->getOrderOfUncertainty()
39
	 * returns.
40
	 */
41
	const OPT_APPLY_ROUNDING = 'applyRounding';
42
43
	/**
44
	 * Option key controlling whether the quantity's unit of measurement should be included
45
	 * in the output.
46
	 *
47
	 * @since 0.5
48
	 */
49
	const OPT_APPLY_UNIT = 'applyUnit';
50
51
	/**
52
	 * @var DecimalMath
53
	 */
54
	private $decimalMath;
55
56
	/**
57
	 * @var DecimalFormatter
58
	 */
59
	private $decimalFormatter;
60
61
	/**
62
	 * @var ValueFormatter|null
63
	 */
64
	private $vocabularyUriFormatter;
65
66
	/**
67
	 * @var string
68
	 */
69
	private $quantityWithUnitFormat;
70
71
	/**
72
	 * @since 0.6
73
	 *
74
	 * @param FormatterOptions|null $options
75
	 * @param DecimalFormatter|null $decimalFormatter
76
	 * @param ValueFormatter|null $vocabularyUriFormatter
77
	 * @param string|null $quantityWithUnitFormat Format string with two placeholders, $1 for the
78
	 * number and $2 for the unit. Warning, this must be under the control of the application, not
79
	 * under the control of the user, because it allows HTML injections in subclasses that return
80
	 * HTML.
81
	 */
82
	public function __construct(
83
		FormatterOptions $options = null,
84
		DecimalFormatter $decimalFormatter = null,
85
		ValueFormatter $vocabularyUriFormatter = null,
86
		$quantityWithUnitFormat = null
87
	) {
88
		parent::__construct( $options );
89
90
		$this->defaultOption( self::OPT_SHOW_UNCERTAINTY_MARGIN, true );
91
		$this->defaultOption( self::OPT_APPLY_ROUNDING, true );
92
		$this->defaultOption( self::OPT_APPLY_UNIT, true );
93
94
		$this->decimalFormatter = $decimalFormatter ?: new DecimalFormatter( $this->options );
95
		$this->vocabularyUriFormatter = $vocabularyUriFormatter;
96
		$this->quantityWithUnitFormat = $quantityWithUnitFormat ?: '$1 $2';
97
98
		// plain composition should be sufficient
99
		$this->decimalMath = new DecimalMath();
100
	}
101
102
	/**
103
	 * @since 0.6
104
	 *
105
	 * @return string
106
	 */
107
	final protected function getQuantityWithUnitFormat() {
108
		return $this->quantityWithUnitFormat;
109
	}
110
111
	/**
112
	 * @see ValueFormatter::format
113
	 *
114
	 * @param UnboundedQuantityValue $value
115
	 *
116
	 * @throws InvalidArgumentException
117
	 * @return string Text
118
	 */
119
	public function format( $value ) {
120
		if ( !( $value instanceof UnboundedQuantityValue ) ) {
121
			throw new InvalidArgumentException( 'Data value type mismatch. Expected a QuantityValue.' );
122
		}
123
124
		return $this->formatQuantityValue( $value );
125
	}
126
127
	/**
128
	 * @since 0.6
129
	 *
130
	 * @param UnboundedQuantityValue $quantity
131
	 *
132
	 * @return string Text
133
	 */
134
	protected function formatQuantityValue( UnboundedQuantityValue $quantity ) {
135
		$formatted = $this->formatNumber( $quantity );
136
		$unit = $this->formatUnit( $quantity->getUnit() );
137
138
		if ( $unit !== null ) {
139
			$formatted = strtr(
140
				$this->quantityWithUnitFormat,
141
				array(
142
					'$1' => $formatted,
143
					'$2' => $unit
144
				)
145
			);
146
		}
147
148
		return $formatted;
149
	}
150
151
	/**
152
	 * @since 0.6
153
	 *
154
	 * @param UnboundedQuantityValue $quantity
155
	 *
156
	 * @return string Text
157
	 */
158
	protected function formatNumber( UnboundedQuantityValue $quantity ) {
159
		$roundingExponent = $this->getRoundingExponent( $quantity );
160
161
		$amount = $quantity->getAmount();
162
163
		if ( $quantity instanceof QuantityValue ) {
164
			if ( $roundingExponent === null ) {
165
				$formatted = $this->formatMinimalDecimal( $amount );
166
				$margin = $quantity->getUncertaintyMargin();
167
				$margin = $margin->isZero() ? null : $this->formatMinimalDecimal( $margin );
168
			} else {
169
				$roundedAmount = $this->decimalMath->roundToExponent( $amount, $roundingExponent );
170
				$formatted = $this->decimalFormatter->format( $roundedAmount );
171
				$margin = $this->formatMargin( $quantity->getUncertaintyMargin(), $roundingExponent );
172
			}
173
174
			if ( $margin !== null ) {
175
				// TODO: use localizable pattern for constructing the output.
176
				$formatted .= '±' . $margin;
177
			}
178
		} else {
179
			$formatted = $this->decimalFormatter->format( $amount );
180
		}
181
182
		return $formatted;
183
	}
184
185
	/**
186
	 * Returns the rounding exponent based on the given $quantity
187
	 * and the @see QuantityFormatter::OPT_APPLY_ROUNDING option.
188
	 *
189
	 * @param QuantityValue $quantity
190
	 *
191
	 * @return int|null
192
	 */
193
	private function getRoundingExponent( UnboundedQuantityValue $quantity ) {
194
		if ( $this->options->getOption( self::OPT_APPLY_ROUNDING ) === true ) {
195
			return $this->options->getOption( self::OPT_SHOW_UNCERTAINTY_MARGIN )
196
					|| !( $quantity instanceof QuantityValue )
197
				? null
198
				// round to the order of uncertainty
199
				: $quantity->getOrderOfUncertainty();
200
		} elseif ( $this->options->getOption( self::OPT_APPLY_ROUNDING ) === false ) {
201
			return null;
202
		} else {
203
			return (int)$this->options->getOption( self::OPT_APPLY_ROUNDING );
204
		}
205
	}
206
207
	/**
208
	 * @param DecimalValue $decimal
209
	 *
210
	 * @return string
211
	 */
212
	private function formatMinimalDecimal( DecimalValue $decimal ) {
213
		// TODO: This should be an option of DecimalFormatter.
214
		return preg_replace( '/(\.\d+?)0+$/', '$1',
215
			preg_replace( '/(?<=\d)\.0*$/', '', $this->decimalFormatter->format( $decimal ) )
216
		);
217
	}
218
219
	/**
220
	 * @param DecimalValue $margin
221
	 * @param int $roundingExponent
222
	 *
223
	 * @return string|null Text
224
	 */
225
	private function formatMargin( DecimalValue $margin, $roundingExponent ) {
226
		if ( $this->options->getOption( self::OPT_SHOW_UNCERTAINTY_MARGIN ) ) {
227
			// TODO: never round to 0! See bug #56892
228
			$roundedMargin = $this->decimalMath->roundToExponent( $margin, $roundingExponent );
229
230
			if ( !$roundedMargin->isZero() ) {
231
				return $this->decimalFormatter->format( $roundedMargin );
232
			}
233
		}
234
235
		return null;
236
	}
237
238
	/**
239
	 * @since 0.6
240
	 *
241
	 * @param string $unit URI
242
	 *
243
	 * @return string|null Text
244
	 */
245
	protected function formatUnit( $unit ) {
246
		if ( $this->vocabularyUriFormatter === null
247
			|| !$this->options->getOption( self::OPT_APPLY_UNIT )
248
			|| $unit === ''
249
			|| $unit === '1'
250
		) {
251
			return null;
252
		}
253
254
		return $this->vocabularyUriFormatter->format( $unit );
255
	}
256
257
}
258