PluginProbe
M Chart / trunk
M Chart vtrunk
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 trunk, at components/class-m-chart-parse.php

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