Passed
Push — master ( 730d12...ecd815 )
by adam
03:46 queued 02:06
created

LatLongParserBase   A

Complexity

Total Complexity 26

Size/Duplication

Total Lines 230
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 3

Test Coverage

Coverage 64.85%

Importance

Changes 0
Metric Value
wmc 26
lcom 1
cbo 3
dl 0
loc 230
ccs 48
cts 74
cp 0.6485
rs 10
c 0
b 0
f 0

8 Methods

Rating   Name   Duplication   Size   Complexity  
getParsedCoordinate() 0 1 ?
areValidCoordinates() 0 1 ?
B splitString() 0 53 9
A __construct() 0 12 2
A parse() 0 22 3
A removeInvalidChars() 0 17 6
A resolveDirection() 0 31 5
A getOption() 0 3 1
1
<?php
2
3
declare( strict_types = 1 );
4
5
namespace DataValues\Geo\Parsers;
6
7
use DataValues\Geo\Values\LatLongValue;
8
use ValueParsers\ParseException;
9
use ValueParsers\ParserOptions;
10
use ValueParsers\ValueParser;
11
12
/**
13
 * @since 0.1, renamed in 2.0
14
 *
15
 * @license GPL-2.0-or-later
16
 * @author H. Snater < [email protected] >
17
 * @author Jeroen De Dauw < [email protected] >
18
 */
19
abstract class LatLongParserBase implements ValueParser {
20
21
	public const FORMAT_NAME = 'geo-coordinate';
22
23
	/**
24
	 * The symbols representing the different directions for usage in directional notation.
25
	 */
26
	public const OPT_NORTH_SYMBOL = 'north';
27
	public const OPT_EAST_SYMBOL = 'east';
28
	public const OPT_SOUTH_SYMBOL = 'south';
29
	public const OPT_WEST_SYMBOL = 'west';
30
31
	/**
32
	 * The symbol to use as separator between latitude and longitude.
33
	 */
34
	public const OPT_SEPARATOR_SYMBOL = 'separator';
35
36
	/**
37
	 * Delimiters used to split a coordinate string when unable to split by using the separator.
38
	 * @var string[]
39
	 */
40
	protected $defaultDelimiters;
41
42
	/**
43
	 * @var ParserOptions
44
	 */
45
	private $options;
46
47 90
	public function __construct( ParserOptions $options = null ) {
48 90
		$this->options = $options ?: new ParserOptions();
49
50 90
		$this->options->defaultOption( ValueParser::OPT_LANG, 'en' );
51
52 90
		$this->options->defaultOption( self::OPT_NORTH_SYMBOL, 'N' );
53 90
		$this->options->defaultOption( self::OPT_EAST_SYMBOL, 'E' );
54 90
		$this->options->defaultOption( self::OPT_SOUTH_SYMBOL, 'S' );
55 90
		$this->options->defaultOption( self::OPT_WEST_SYMBOL, 'W' );
56
57 90
		$this->options->defaultOption( self::OPT_SEPARATOR_SYMBOL, ',' );
58 90
	}
59
60
	/**
61
	 * Parses a single coordinate segment (either latitude or longitude) and returns it as a float.
62
	 *
63
	 * @param string $coordinateSegment
64
	 *
65
	 * @throws ParseException
66
	 * @return float
67
	 */
68
	abstract protected function getParsedCoordinate( string $coordinateSegment ): float;
69
70
	/**
71
	 * Returns whether a coordinate split into its two segments is in the representation expected by
72
	 * this parser.
73
	 *
74
	 * @param string[] $normalizedCoordinateSegments
75
	 *
76
	 * @return bool
77
	 */
78
	abstract protected function areValidCoordinates( array $normalizedCoordinateSegments ): bool;
79
80
	/**
81
	 * @see ValueParser::parse
82
	 *
83
	 * @param string $value
84
	 *
85
	 * @throws ParseException
86
	 * @return LatLongValue
87
	 */
88 81
	public function parse( $value ): LatLongValue {
89 81
		if ( !is_string( $value ) ) {
90 3
			throw new ParseException( 'Not a string' );
91
		}
92
93 78
		$rawValue = $value;
94
95 78
		$value = $this->removeInvalidChars( $value );
96
97 78
		$normalizedCoordinateSegments = $this->splitString( $value );
98
99 76
		if ( !$this->areValidCoordinates( $normalizedCoordinateSegments ) ) {
100 6
			throw new ParseException( 'Not a valid geographical coordinate', $rawValue, static::FORMAT_NAME );
101
		}
102
103 70
		list( $latitude, $longitude ) = $normalizedCoordinateSegments;
104
105 70
		return new LatLongValue(
106 70
			$this->getParsedCoordinate( $latitude ),
107 70
			$this->getParsedCoordinate( $longitude )
108
		);
109
	}
110
111
	/**
112
	 * Returns a string trimmed and with control characters and characters with ASCII values above
113
	 * 126 removed. SPACE characters within the string are not removed to retain the option to split
114
	 * the string using that character.
115
	 *
116
	 * @param string $string
117
	 *
118
	 * @return string
119
	 */
120 78
	protected function removeInvalidChars( string $string ): string {
121 78
		$filtered = [];
122
123 78
		foreach ( str_split( $string ) as $character ) {
124 78
			$asciiValue = ord( $character );
125
126
			if (
127 78
				( $asciiValue >= 32 && $asciiValue < 127 )
128 53
				|| $asciiValue == 194
129 78
				|| $asciiValue == 176
130
			) {
131 78
				$filtered[] = $character;
132
			}
133
		}
134
135 78
		return trim( implode( '', $filtered ) );
136
	}
137
138
	/**
139
	 * Splits a string into two strings using the separator specified in the options. If the string
140
	 * could not be split using the separator, the method will try to split the string by analyzing
141
	 * the used symbols. If the string could not be split into two parts, an empty array is
142
	 * returned.
143
	 *
144
	 * @param string $normalizedCoordinateString
145
	 *
146
	 * @throws ParseException if unable to split input string into two segments
147
	 * @return string[]
148
	 */
149
	protected function splitString( string $normalizedCoordinateString ): array {
150
		$separator = $this->getOption( self::OPT_SEPARATOR_SYMBOL );
151
152
		$normalizedCoordinateSegments = explode( $separator, $normalizedCoordinateString );
153
154
		if ( count( $normalizedCoordinateSegments ) !== 2 ) {
155
			// Separator not present within the string, trying to figure out the segments by
156
			// splitting after the first direction character or degree symbol:
157
			$delimiters = $this->defaultDelimiters;
158
159
			$ns = [
160
				$this->getOption( self::OPT_NORTH_SYMBOL ),
161
				$this->getOption( self::OPT_SOUTH_SYMBOL )
162
			];
163
164
			$ew = [
165
				$this->getOption( self::OPT_EAST_SYMBOL ),
166
				$this->getOption( self::OPT_WEST_SYMBOL )
167
			];
168
169
			foreach ( $ns as $delimiter ) {
170
				if ( mb_strpos( $normalizedCoordinateString, $delimiter ) === 0 ) {
171
					// String starts with "north" or "west" symbol: Separation needs to be done
172
					// before the "east" or "west" symbol.
173
					$delimiters = array_merge( $ew, $delimiters );
174
					break;
175
				}
176
			}
177
178
			if ( count( $delimiters ) !== count( $this->defaultDelimiters ) + 2 ) {
179
				$delimiters = array_merge( $ns, $delimiters );
180
			}
181
182
			foreach ( $delimiters as $delimiter ) {
183
				$delimiterPos = mb_strpos( $normalizedCoordinateString, $delimiter );
184
				if ( $delimiterPos !== false ) {
185
					$adjustPos = ( in_array( $delimiter, $ew ) ) ? 0 : mb_strlen( $delimiter );
186
					$normalizedCoordinateSegments = [
187
						mb_substr( $normalizedCoordinateString, 0, $delimiterPos + $adjustPos ),
188
						mb_substr( $normalizedCoordinateString, $delimiterPos + $adjustPos )
189
					];
190
					break;
191
				}
192
			}
193
		}
194
195
		if ( count( $normalizedCoordinateSegments ) !== 2 ) {
196
			throw new ParseException( __CLASS__ . ': Unable to split string '
197
				. $normalizedCoordinateString . ' into two coordinate segments' );
198
		}
199
200
		return $normalizedCoordinateSegments;
201
	}
202
203
	/**
204
	 * Turns directional notation (N/E/S/W) of a single coordinate into non-directional notation
205
	 * (+/-).
206
	 * This method assumes there are no preceding or tailing spaces.
207
	 *
208
	 * @param string $coordinateSegment
209
	 *
210
	 * @return string
211
	 */
212 70
	protected function resolveDirection( string $coordinateSegment ): string {
213 70
		$n = $this->getOption( self::OPT_NORTH_SYMBOL );
214 70
		$e = $this->getOption( self::OPT_EAST_SYMBOL );
215 70
		$s = $this->getOption( self::OPT_SOUTH_SYMBOL );
216 70
		$w = $this->getOption( self::OPT_WEST_SYMBOL );
217
218
		// If there is a direction indicator, remove it, and prepend a minus sign for south and west
219
		// directions. If there is no direction indicator, the coordinate is already non-directional
220
		// and no work is required.
221 70
		foreach ( [ $n, $e, $s, $w ] as $direction ) {
222
			// The coordinate segment may either start or end with a direction symbol.
223 70
			preg_match(
224 70
				'/^(' . $direction . '|)([^' . $direction . ']+)(' . $direction . '|)$/i',
225 70
				$coordinateSegment,
226 70
				$matches
227
			);
228
229 70
			if ( $matches[1] !== '' || $matches[3] !== '' ) {
230 33
				$coordinateSegment = $matches[2];
231
232 33
				if ( in_array( $direction, [ $s, $w ] ) ) {
233 20
					$coordinateSegment = '-' . $coordinateSegment;
234
				}
235
236 70
				return $coordinateSegment;
237
			}
238
		}
239
240
		// Coordinate segment does not include a direction symbol.
241 37
		return $coordinateSegment;
242
	}
243
244 87
	protected function getOption( string $optionName ) {
245 87
		return $this->options->getOption( $optionName );
246
	}
247
248
}
249