Completed
Pull Request — master (#66)
by Daniel
05:21 queued 02:25
created

QuantityFormatter::formatUnit()   B

Complexity

Conditions 5
Paths 2

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 11
rs 8.8571
cc 5
eloc 7
nc 2
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
	 * Value for the OPT_SHOW_UNCERTAINTY_MARGIN indicating that the uncertainty margin
31
	 * should not be shown.
32
	 */
33
	const SHOW_UNCERTAINTY_MARGIN_NEVER = 'never';
34
35
	/**
36
	 * Value for the OPT_SHOW_UNCERTAINTY_MARGIN indicating that the uncertainty margin
37
	 * should be shown if we are displaying a QuantityValue.
38
	 */
39
	const SHOW_UNCERTAINTY_MARGIN_IF_KNOWN = 'if-known';
40
41
	/**
42
	 * Value for the OPT_SHOW_UNCERTAINTY_MARGIN indicating that the uncertainty margin
43
	 * should be shown if we are displaying a non-exact QuantityValue.
44
	 */
45
	const SHOW_UNCERTAINTY_MARGIN_IF_NOT_ZERO = 'if-not-zero';
46
47
	/**
48
	 * Option key for determining what level of rounding to apply to the numbers
49
	 * included in the output. The value of this option must be an integer or a boolean.
50
	 *
51
	 * If an integer is given, this is the exponent of the last significant decimal digits
52
	 * - that is, -2 would round to two digits after the decimal point, and 1 would round
53
	 * to two digits before the decimal point. 0 would indicate rounding to integers.
54
	 *
55
	 * If the value is a boolean, false means no rounding at all (useful e.g. in diffs),
56
	 * and true means automatic rounding based on what $quantity->getOrderOfUncertainty()
57
	 * returns.
58
	 */
59
	const OPT_APPLY_ROUNDING = 'applyRounding';
60
61
	/**
62
	 * Option key controlling whether the quantity's unit of measurement should be included
63
	 * in the output.
64
	 *
65
	 * @since 0.5
66
	 */
67
	const OPT_APPLY_UNIT = 'applyUnit';
68
69
	/**
70
	 * @var DecimalMath
71
	 */
72
	private $decimalMath;
73
74
	/**
75
	 * @var DecimalFormatter
76
	 */
77
	private $decimalFormatter;
78
79
	/**
80
	 * @var ValueFormatter|null
81
	 */
82
	private $vocabularyUriFormatter;
83
84
	/**
85
	 * @var string
86
	 */
87
	private $quantityWithUnitFormat;
88
89
	/**
90
	 * @since 0.6
91
	 *
92
	 * @param FormatterOptions|null $options
93
	 * @param DecimalFormatter|null $decimalFormatter
94
	 * @param ValueFormatter|null $vocabularyUriFormatter
95
	 * @param string|null $quantityWithUnitFormat Format string with two placeholders, $1 for the
96
	 * number and $2 for the unit. Warning, this must be under the control of the application, not
97
	 * under the control of the user, because it allows HTML injections in subclasses that return
98
	 * HTML.
99
	 */
100
	public function __construct(
101
		FormatterOptions $options = null,
102
		DecimalFormatter $decimalFormatter = null,
103
		ValueFormatter $vocabularyUriFormatter = null,
104
		$quantityWithUnitFormat = null
105
	) {
106
		parent::__construct( $options );
107
108
		$this->defaultOption(
109
			self::OPT_SHOW_UNCERTAINTY_MARGIN,
110
			self::SHOW_UNCERTAINTY_MARGIN_IF_KNOWN
111
		);
112
		$this->defaultOption( self::OPT_APPLY_ROUNDING, true );
113
		$this->defaultOption( self::OPT_APPLY_UNIT, true );
114
115
		$this->decimalFormatter = $decimalFormatter ?: new DecimalFormatter( $this->options );
116
		$this->vocabularyUriFormatter = $vocabularyUriFormatter;
117
		$this->quantityWithUnitFormat = $quantityWithUnitFormat ?: '$1 $2';
118
119
		// plain composition should be sufficient
120
		$this->decimalMath = new DecimalMath();
121
	}
122
123
	/**
124
	 * @since 0.6
125
	 *
126
	 * @return string
127
	 */
128
	final protected function getQuantityWithUnitFormat() {
129
		return $this->quantityWithUnitFormat;
130
	}
131
132
	/**
133
	 * @see ValueFormatter::format
134
	 *
135
	 * @param UnboundedQuantityValue $value
136
	 *
137
	 * @throws InvalidArgumentException
138
	 * @return string Text
139
	 */
140
	public function format( $value ) {
141
		if ( !( $value instanceof UnboundedQuantityValue ) ) {
142
			throw new InvalidArgumentException( 'Data value type mismatch. Expected a QuantityValue.' );
143
		}
144
145
		return $this->formatQuantityValue( $value );
146
	}
147
148
	/**
149
	 * @since 0.6
150
	 *
151
	 * @param UnboundedQuantityValue $quantity
152
	 *
153
	 * @return string Text
154
	 */
155
	protected function formatQuantityValue( UnboundedQuantityValue $quantity ) {
156
		$formatted = $this->formatNumber( $quantity );
157
		$unit = $this->formatUnit( $quantity->getUnit() );
158
159
		if ( $unit !== null ) {
160
			$formatted = strtr(
161
				$this->quantityWithUnitFormat,
162
				array(
163
					'$1' => $formatted,
164
					'$2' => $unit
165
				)
166
			);
167
		}
168
169
		return $formatted;
170
	}
171
172
	/**
173
	 * @since 0.6
174
	 *
175
	 * @param UnboundedQuantityValue $quantity
176
	 *
177
	 * @return string Text
178
	 */
179
	protected function formatNumber( UnboundedQuantityValue $quantity ) {
180
		$marginMode = $this->options->getOption( self::OPT_SHOW_UNCERTAINTY_MARGIN );
181
182
		// map legacy option values
183
		if ( $marginMode === true ) {
184
			// old default behavior
185
			$marginMode = self::SHOW_UNCERTAINTY_MARGIN_IF_NOT_ZERO;
186
		} elseif ( $marginMode === false ) {
187
			$marginMode = self::SHOW_UNCERTAINTY_MARGIN_NEVER;
188
		}
189
190
		// if the mergin is shown, never apply rounding
191
		if ( $marginMode == self::SHOW_UNCERTAINTY_MARGIN_NEVER ) {
192
			$roundingMode = $this->options->getOption( self::OPT_APPLY_ROUNDING );
193
			$roundingExponent = $this->getRoundingExponent( $quantity, $roundingMode );
194
		} else {
195
			$roundingExponent = null;
196
		}
197
198
		$amount = $quantity->getAmount();
199
		$margin = $this->formatMargin( $quantity, $marginMode );
200
201
		// round if desired
202
		if ( $roundingExponent !== null ) {
203
			$roundedAmount = $this->decimalMath->roundToExponent( $amount, $roundingExponent );
204
			$formatted = $this->decimalFormatter->format( $roundedAmount );
205
		} elseif ( $margin !== null ) {
206
			// if we will show a margin, strip trailing decimals
207
			$formatted = $this->formatMinimalDecimal( $amount );
208
		} else {
209
			// no rounding and no margin - format plain
210
			$formatted = $this->decimalFormatter->format( $amount );
211
		}
212
213
		if ( $margin !== null ) {
214
			// TODO: use localizable pattern for constructing the output.
215
			$formatted .= '±' . $margin;
216
		}
217
218
		return $formatted;
219
	}
220
221
	/**
222
	 * Returns the rounding exponent based on the given $quantity.
223
	 *
224
	 * @param UnboundedQuantityValue $quantity
225
	 * @param bool|int $roundingMode The rounding exponent, or true for rounding to the order of
226
	 *        uncertainty (significant digits) of a QuantityValue, or false to not apply
227
	 *        rounding (that is, round to all digits).
228
	 *
229
	 * @return int|null
230
	 */
231
	private function getRoundingExponent( UnboundedQuantityValue $quantity, $roundingMode ) {
232
		if ( is_int( $roundingMode ) ) {
233
			// round to the given exponent
234
			return $roundingMode;
235
		} elseif ( $roundingMode && ( $quantity instanceof  QuantityValue ) ) {
236
			// round to the order of uncertainty (QuantityValue only)
237
			return $quantity->getOrderOfUncertainty();
238
		} else {
239
			// keep all digits
240
			return null;
241
		}
242
	}
243
244
	/**
245
	 * @param DecimalValue $decimal
246
	 *
247
	 * @return string
248
	 */
249
	private function formatMinimalDecimal( DecimalValue $decimal ) {
250
		// TODO: This should be an option of DecimalFormatter.
251
		return preg_replace( '/(\.\d+?)0+$/', '$1',
252
			preg_replace( '/(?<=\d)\.0*$/', '', $this->decimalFormatter->format( $decimal ) )
253
		);
254
	}
255
256
	/**
257
	 * @param UnboundedQuantityValue $quantity
258
	 * @param string $marginMode
259
	 *
260
	 * @return string|null Text
261
	 */
262
	private function formatMargin( UnboundedQuantityValue $quantity, $marginMode ) {
263
		if ( !( $quantity instanceof QuantityValue ) ) {
264
			return null;
265
		}
266
267
		$margin = $quantity->getUncertaintyMargin();
268
269
		if ( $marginMode === self::SHOW_UNCERTAINTY_MARGIN_NEVER ) {
270
			return null;
271
		}
272
273
		if ( $marginMode === self::SHOW_UNCERTAINTY_MARGIN_IF_NOT_ZERO
274
			&& $margin->isZero()
275
		) {
276
			return null;
277
		}
278
279
		// TODO: never round to 0! See bug #56892
280
		return $this->formatMinimalDecimal( $margin );
281
	}
282
283
	/**
284
	 * @since 0.6
285
	 *
286
	 * @param string $unit URI
287
	 *
288
	 * @return string|null Text
289
	 */
290
	protected function formatUnit( $unit ) {
291
		if ( $this->vocabularyUriFormatter === null
292
			|| !$this->options->getOption( self::OPT_APPLY_UNIT )
293
			|| $unit === ''
294
			|| $unit === '1'
295
		) {
296
			return null;
297
		}
298
299
		return $this->vocabularyUriFormatter->format( $unit );
300
	}
301
302
}
303