PluginProbe
M Chart / 2.2.2
M Chart v2.2.2
2.3.2 2.3.1 2.3 2.2.2 2.2.1 2.2 trunk 1.0 1.1 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.10 1.10.1 1.11 1.11.1 1.11.2 1.12 1.2 1.2.1 1.3 1.3.1 1.3.2 All 53 releases
m-chart / components / class-m-chart-parse.php

class-m-chart-parse.php in M Chart 2.2.2, at components/class-m-chart-parse.php

484 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if ( ! defined( 'ABSPATH' ) ) {
4 exit;
5 }
6
7 class M_Chart_Parse {
8 const LABELS_NONE = 'none';
9 const LABELS_FIRST_ROW = 'first_row';
10 const LABELS_FIRST_COLUMN = 'first_column';
11 const LABELS_BOTH = 'both';
12 const PARSE_ROWS = 'rows';
13 const PARSE_COLUMNS = 'columns';
14
15 public array $data = [];
16 public array $value_labels = [];
17 public string $value_labels_position = '';
18 public array $set_data = [];
19 public array $raw_data = [];
20 public string $parse_in = '';
21
22 private ?NumberFormatter $formatter = null;
23
24 /**
25 * Initializes the parse class
26 */
27 public function __construct() {
28
29 }
30
31 /**
32 * Parses a chart's data array for labels, data sets, and raw values
33 *
34 * @param array $data the raw two-dimensional data array from the chart post
35 * @param string $parse_in whether to parse by rows or columns (PARSE_ROWS or PARSE_COLUMNS)
36 *
37 * @return static $this
38 */
39 public function parse_data( array $data, string $parse_in ): static {
40 $this->data = $data;
41 $this->parse_in = $parse_in;
42 $this->value_labels_position = $this->get_value_labels_position();
43 $this->parse_value_labels();
44 $this->parse_set_data();
45
46 return $this;
47 }
48
49 /**
50 * Populates $this->value_labels by reading label values from the data array based on the detected labels position
51 */
52 private function parse_value_labels(): void {
53 $this->value_labels = [];
54
55 switch ( $this->value_labels_position ) {
56 case self::LABELS_NONE:
57 return;
58
59 case self::LABELS_FIRST_COLUMN:
60 foreach ( (array) $this->data as $columns ) {
61 if ( '' != trim( (string) $columns[0] ) ) {
62 $this->value_labels[] = $this->clean_labels( $columns[0] );
63 }
64 }
65 break;
66
67 case self::LABELS_FIRST_ROW:
68 foreach ( (array) $this->data[0] as $column ) {
69 if ( '' != trim( (string) $column ) ) {
70 $this->value_labels[] = $this->clean_labels( $column );
71 }
72 }
73 break;
74
75 case self::LABELS_BOTH:
76 foreach ( (array) $this->data as $columns ) {
77 if ( '' != trim( (string) $columns[0] ) ) {
78 $this->value_labels[ self::LABELS_FIRST_COLUMN ][] = $this->clean_labels( $columns[0] );
79 }
80 }
81
82 foreach ( (array) $this->data[0] as $column ) {
83 if ( '' != trim( (string) $column ) ) {
84 $this->value_labels[ self::LABELS_FIRST_ROW ][] = $this->clean_labels( $column );
85 }
86 }
87 break;
88 }
89
90 $this->value_labels = apply_filters( 'm_chart_value_labels', $this->value_labels, $this->value_labels_position, $this->data );
91 }
92
93 /**
94 * Helper function returns a string describing where the value labels are (first_row, first_column, both)
95 *
96 * @return string the position of the labels in the given data set
97 */
98 private function get_value_labels_position(): string {
99 if ( ! isset( $this->data[0][0] ) && ! isset( $this->data[1][0] ) ) {
100 return self::LABELS_NONE;
101 }
102
103 if ( '' == $this->data[0][0] ) {
104 return self::LABELS_BOTH;
105 }
106
107 // Structural pre-check
108 // The simple single-series shape from creating-a-chart.md is unambiguous 2 effective columns (rows mode) or 2 effective rows (columns mode)
109 // Works regardless of whether the labels look numeric (years, ordinals, etc)
110 if ( self::PARSE_ROWS === $this->parse_in && 2 === $this->effective_max_columns() ) {
111 return self::LABELS_FIRST_COLUMN;
112 }
113
114 if ( self::PARSE_COLUMNS === $this->parse_in && 2 === $this->effective_row_count() ) {
115 return self::LABELS_FIRST_ROW;
116 }
117
118 // Existing content-based heuristic for 3+ effective columns/rows
119 if ( ! is_numeric( trim( (string) $this->data[0][0] ) ) ) {
120 // If the first row has multiple non-numeric headers and the data rows start with numeric values the entire first row is column labels (e.g. scatter format)
121 if (
122 isset( $this->data[0][1] ) && ! is_numeric( trim( (string) $this->data[0][1] ) )
123 && isset( $this->data[1][0] ) && is_numeric( trim( (string) $this->data[1][0] ) )
124 ) {
125 return self::LABELS_FIRST_ROW;
126 }
127
128 return self::LABELS_FIRST_COLUMN;
129 }
130
131 return self::LABELS_FIRST_ROW;
132 }
133
134 /**
135 * Max effective column count across rows
136 * Rightmost non-empty cell index + 1, taken as a max over all rows
137 * Trailing empty cells (typical of Jspreadsheet's minDimensions padding) don't count
138 *
139 * @return int
140 */
141 private function effective_max_columns(): int {
142 if ( ! is_array( $this->data ) ) {
143 return 0;
144 }
145
146 $max = 0;
147
148 foreach ( $this->data as $row ) {
149 if ( ! is_array( $row ) ) {
150 continue;
151 }
152
153 for ( $i = count( $row ) - 1; $i >= 0; $i-- ) {
154 if ( '' !== trim( (string) ( $row[ $i ] ?? '' ) ) ) {
155 if ( $i + 1 > $max ) {
156 $max = $i + 1;
157 }
158
159 break;
160 }
161 }
162 }
163
164 return $max;
165 }
166
167 /**
168 * Count of rows that contain at least one non-empty cell
169 * Trailing empty rows (typical of Jspreadsheet's minDimensions padding) don't count
170 *
171 * @return int
172 */
173 private function effective_row_count(): int {
174 if ( ! is_array( $this->data ) ) {
175 return 0;
176 }
177
178 $last = -1;
179
180 foreach ( $this->data as $i => $row ) {
181 if ( ! is_array( $row ) ) {
182 continue;
183 }
184
185 foreach ( $row as $cell ) {
186 if ( '' !== trim( (string) ( $cell ?? '' ) ) ) {
187 $last = $i;
188
189 break;
190 }
191 }
192 }
193
194 return $last + 1;
195 }
196
197 /**
198 * Helper function cleans data point values
199 *
200 * @param mixed $data_point a data point that may need to be cleaned or typed as an int
201 *
202 * @return float|string a float of the cleaned data point or string if the cleaned value was not numeric
203 */
204 public function clean_data_point( mixed $data_point ): float|string {
205 $data_point = trim( (string) $data_point );
206
207 if ( preg_match( '/-?\d[\d,]*(?:\.\d+)?/', $data_point, $matches ) ) {
208 return floatval( str_replace( ',', '', $matches[0] ) );
209 }
210
211 return $data_point;
212 }
213
214 /**
215 * Helper function parses a data point into an M_Chart_Parsed_Data_Point value object for localized display
216 * Splits the cell string into prefix, numeric value, and suffix
217 * This means the number can be reformatted for any locale while preserving surrounding context
218 *
219 * @param mixed $data_point a raw cell value
220 *
221 * @return M_Chart_Parsed_Data_Point
222 */
223 public function parse_data_point( mixed $data_point ): M_Chart_Parsed_Data_Point {
224 $data_point = trim( (string) $data_point );
225
226 if ( preg_match( '/(-?\d[\d,]*(?:\.\d+)?)/', $data_point, $matches, PREG_OFFSET_CAPTURE ) ) {
227 $number = $matches[1][0];
228 $offset = $matches[1][1];
229
230 return M_Chart_Parsed_Data_Point::numeric(
231 floatval( str_replace( ',', '', $number ) ),
232 substr( $data_point, 0, $offset ),
233 substr( $data_point, $offset + strlen( $number ) )
234 );
235 }
236
237 return M_Chart_Parsed_Data_Point::text( $data_point );
238 }
239
240 /**
241 * Helper function cleans out label values
242 *
243 * @param mixed $label a label string
244 *
245 * @return string the label string cleaned of any problem content
246 */
247 public function clean_labels( mixed $label ): string {
248 $label = trim( html_entity_decode( (string) $label, ENT_QUOTES ) );
249
250 // PHP's strip_tags() is case-insensitive, recursive, handles unclosed/malformed tags, and is XSS-safe
251 return strip_tags( $label );
252 }
253
254 /**
255 * Populates $this->set_data and $this->raw_data by delegating to the appropriate collector based on the parse direction and labels position, then normalizing both arrays
256 */
257 private function parse_set_data(): void {
258 if ( self::PARSE_ROWS == $this->parse_in && self::LABELS_FIRST_COLUMN == $this->value_labels_position ) {
259 [ $set_data_array, $raw_data_array ] = $this->collect_rows_first_column();
260 } elseif ( self::PARSE_ROWS == $this->parse_in && self::LABELS_BOTH == $this->value_labels_position ) {
261 [ $set_data_array, $raw_data_array ] = $this->collect_rows_both();
262 } elseif ( self::PARSE_COLUMNS == $this->parse_in && self::LABELS_BOTH == $this->value_labels_position ) {
263 [ $set_data_array, $raw_data_array ] = $this->collect_columns_both();
264 } else {
265 [ $set_data_array, $raw_data_array ] = $this->collect_default();
266 }
267
268 $set_data_array = $this->normalize_data_array( $set_data_array );
269 $raw_data_array = $this->normalize_data_array( $raw_data_array );
270
271 $this->set_data = apply_filters( 'm_chart_set_data', $set_data_array, $this->data, $this->parse_in );
272 $this->raw_data = apply_filters( 'm_chart_raw_data', $raw_data_array, $this->data, $this->parse_in );
273 }
274
275 /**
276 * Collects data when parsing rows with labels in the first column
277 *
278 * @return array {0: array, 1: array} Two-element array of [set_data, raw_data]
279 */
280 private function collect_rows_first_column(): array {
281 $set_data_array = [];
282 $raw_data_array = [];
283
284 foreach ( $this->data as $row ) {
285 foreach ( $row as $key => $column ) {
286 if ( '' == $column || 0 == $key ) {
287 continue;
288 }
289
290 $set_data_array[] = $this->clean_data_point( $column );
291 $raw_data_array[] = $this->parse_data_point( $column );
292 }
293 }
294
295 return [ $set_data_array, $raw_data_array ];
296 }
297
298 /**
299 * Collects data when parsing rows with labels in both the first row and first column
300 *
301 * @return array {0: array, 1: array} Two-element array of [set_data, raw_data]
302 */
303 private function collect_rows_both(): array {
304 $set_data_array = [];
305 $raw_data_array = [];
306 $limit = count( $this->data );
307 $this_sets = [];
308 $this_raw = [];
309
310 for ( $i = 1; $i < $limit; $i++ ) {
311 foreach ( $this->data[ $i ] as $c_key => $column ) {
312 if ( 0 != $c_key ) {
313 $data_point = $this->clean_data_point( $column );
314 $key = $i - 1;
315
316 if ( ! isset( $this_sets[ $key ]['is_null'] ) ) {
317 $this_sets[ $key ]['is_null'] = true;
318 }
319
320 if ( is_numeric( $data_point ) ) {
321 $this_sets[ $key ]['is_null'] = false;
322 }
323
324 $this_sets[ $key ]['data'][] = $data_point;
325 $this_raw[ $key ][] = $this->parse_data_point( $column );
326 }
327 }
328 }
329
330 foreach ( $this_sets as $key => $set ) {
331 if ( false == $set['is_null'] ) {
332 $set_data_array[ $key ] = $set['data'];
333 $raw_data_array[ $key ] = $this_raw[ $key ];
334 }
335 }
336
337 return [ $set_data_array, $raw_data_array ];
338 }
339
340 /**
341 * Collects data when parsing columns with labels in both the first row and first column
342 *
343 * @return array {0: array, 1: array} Two-element array of [set_data, raw_data]
344 */
345 private function collect_columns_both(): array {
346 $set_data_array = [];
347 $raw_data_array = [];
348 $limit = count( $this->data );
349 $this_sets = [];
350 $this_raw = [];
351
352 for ( $i = 1; $i < $limit; $i++ ) {
353 foreach ( $this->data[ $i ] as $key => $column ) {
354 if ( 0 == $key ) {
355 continue;
356 }
357
358 $data_point = $this->clean_data_point( $column );
359 $a_key = $key - 1;
360
361 if ( ! isset( $this_sets[ $a_key ]['is_null'] ) ) {
362 $this_sets[ $a_key ]['is_null'] = true;
363 }
364
365 if ( is_numeric( $data_point ) ) {
366 $this_sets[ $a_key ]['is_null'] = false;
367 }
368
369 $this_sets[ $a_key ]['data'][] = $data_point;
370 $this_raw[ $a_key ][] = $this->parse_data_point( $column );
371 }
372 }
373
374 foreach ( $this_sets as $key => $set ) {
375 if ( false == $set['is_null'] ) {
376 $set_data_array[ $key ] = $set['data'];
377 $raw_data_array[ $key ] = $this_raw[ $key ];
378 }
379 }
380
381 return [ $set_data_array, $raw_data_array ];
382 }
383
384 /**
385 * Collects data for the default case (first-row labels only, or no labels)
386 *
387 * @return array {0: array, 1: array} Two-element array of [set_data, raw_data]
388 */
389 private function collect_default(): array {
390 $set_data_array = [];
391 $raw_data_array = [];
392
393 if ( ! isset( $this->data[1] ) ) {
394 return [ $set_data_array, $raw_data_array ];
395 }
396
397 foreach ( $this->data as $key => $columns ) {
398 foreach ( $columns as $column ) {
399 if ( '' == $column || 0 == $key ) {
400 continue;
401 }
402
403 $set_data_array[] = $this->clean_data_point( $column );
404 $raw_data_array[] = $this->parse_data_point( $column );
405 }
406 }
407
408 return [ $set_data_array, $raw_data_array ];
409 }
410
411 /**
412 * Helper function normalizes the data array so that the number of data values matches the number of value labels
413 *
414 * @param array $data_array an already parsed array of data
415 *
416 * @return array a normalized array of parsed data
417 */
418 private function normalize_data_array( array $data_array ): array {
419 if ( self::PARSE_ROWS == $this->parse_in && self::LABELS_BOTH == $this->value_labels_position ) {
420 $first_row_labels = $this->value_labels[ self::LABELS_FIRST_ROW ] ?? [];
421 $label_count = is_array( $first_row_labels ) ? count( $first_row_labels ) - 1 : 0;
422
423 foreach ( $data_array as $key => $data ) {
424 foreach ( $data as $t_key => $value ) {
425 if ( $t_key > $label_count ) {
426 unset( $data_array[ $key ][ $t_key ] );
427 }
428 }
429 }
430 } elseif ( self::PARSE_COLUMNS == $this->parse_in && self::LABELS_BOTH == $this->value_labels_position ) {
431 $first_col_labels = $this->value_labels[ self::LABELS_FIRST_COLUMN ] ?? [];
432 $label_count = is_array( $first_col_labels ) ? count( $first_col_labels ) - 1 : 0;
433
434 foreach ( $data_array as $key => $data ) {
435 foreach ( $data as $t_key => $value ) {
436 if ( $t_key > $label_count ) {
437 unset( $data_array[ $key ][ $t_key ] );
438 }
439 }
440 }
441 }
442
443 return $data_array;
444 }
445
446 /**
447 * Formats an M_Chart_Parsed_Data_Point for table display
448 * Numeric cells are formatted with the locale-aware NumberFormatter non-numeric cells are returned as plain text
449 *
450 * @param ?M_Chart_Parsed_Data_Point $raw the parsed data point to format, or null for an empty cell
451 *
452 * @return string the formatted cell value
453 */
454 public function format_raw( ?M_Chart_Parsed_Data_Point $raw ): string {
455 if ( null === $raw ) {
456 return '';
457 }
458
459 // If the value is a number return the formatted and prefixed/suffixed version of it
460 if ( $raw->is_numeric() ) {
461 $formatter = $this->get_formatter();
462 $number = $formatter ? $formatter->format( $raw->value ) : (string) $raw->value;
463
464 return $raw->prefix . $number . $raw->suffix;
465 }
466
467 return $raw->text;
468 }
469
470 /**
471 * Returns a locale-aware NumberFormatter, creating and caching it on first use
472 *
473 * @return ?NumberFormatter a NumberFormatter instance, or null if the intl extension is unavailable
474 */
475 private function get_formatter(): ?NumberFormatter {
476 if ( null === $this->formatter ) {
477 $locale = m_chart()->get_settings( 'locale' );
478 $this->formatter = class_exists( 'NumberFormatter' ) ? new NumberFormatter( $locale, NumberFormatter::DECIMAL ) : null;
479 }
480
481 return $this->formatter;
482 }
483 }
484