PluginProbe
TablePress – Tables in WordPress made easy / 3.4
TablePress – Tables in WordPress made easy v3.4
3.4 3.3.4 3.3.3 3.3.2 3.3.1 trunk 1.12 1.14 1.9.2 2.0.4 2.1.7 2.1.8 2.2 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3 2.3.1 2.3.2 2.4 2.4.1 2.4.2 2.4.3 All 45 releases
tablepress / libraries / csv-parser.class.php

csv-parser.class.php in TablePress – Tables in WordPress made easy 3.4, at libraries/csv-parser.class.php

334 lines 10.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * CSV Parsing class for TablePress, used for import of CSV files
4 *
5 * @package TablePress
6 * @subpackage Import
7 * @author Tobias Bäthge
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 // Prohibit direct script loading.
14 defined( 'ABSPATH' ) || die( 'No direct script access allowed!' );
15
16 /**
17 * CSV Parsing class
18 *
19 * @package TablePress
20 * @subpackage Import
21 * @author Tobias Bäthge
22 * @since 1.0.0
23 */
24 class CSV_Parser {
25
26 /**
27 * The used character for the enclosure of a cell. Defaults to quotation mark ".
28 *
29 * @since 1.0.0
30 */
31 protected string $enclosure = '"';
32
33 /**
34 * Number of rows to analyze when attempting to auto-detect the CSV delimiter.
35 *
36 * @since 1.0.0
37 */
38 protected int $delimiter_search_max_lines = 15;
39
40 /**
41 * Characters to ignore when attempting to auto-detect delimiter.
42 *
43 * @since 1.0.0
44 */
45 protected string $non_delimiter_chars = "a-zA-Z0-9\n\r";
46
47 /**
48 * The preferred delimiter characters, only used when all filtering method return multiple possible delimiters (happens very rarely).
49 * There must not be more than 9 characters in the preferred delimiter character list, see `_check_delimiter_count()`.
50 *
51 * @since 1.0.0
52 */
53 protected string $preferred_delimiter_chars = ";,\t";
54
55 /**
56 * The CSV data string that shall be parsed to an array.
57 *
58 * @since 1.0.0
59 */
60 protected string $import_data = '';
61
62 /**
63 * The error state while parsing input data.
64 *
65 * 0 = No errors found. Everything should be fine.
66 * 1 = A hopefully correctable syntax error was found.
67 * 2 = The enclosure character was found in a non-enclosed field. This means the file is either corrupt,
68 * or does not follow the common CSV standard. Please validate the parsed data manually.
69 *
70 * @since 1.0.0
71 */
72 public int $error = 0;
73
74 /**
75 * Detailed error information.
76 *
77 * @since 1.0.0
78 * @var array<string, array<string, int|string>>
79 */
80 public array $error_info = array();
81
82 /**
83 * Class Constructor.
84 *
85 * @since 1.0.0
86 */
87 public function __construct() {
88 // Unused.
89 }
90
91 /**
92 * Load CSV data that shall be parsed.
93 *
94 * @since 1.0.0
95 *
96 * @param string $data Data to be parsed.
97 */
98 public function load_data( string $data ): void {
99 // Check for mandatory trailing line break.
100 if ( ! str_ends_with( $data, "\n" ) ) {
101 $data .= "\n";
102 }
103 $this->import_data = $data;
104 }
105
106 /**
107 * Detect the CSV delimiter, by analyzing some rows to determine the most probable delimiter character.
108 *
109 * @since 1.0.0
110 *
111 * @return string Most probable delimiter character.
112 */
113 public function find_delimiter(): string {
114 $data = &$this->import_data;
115
116 $delimiter_count = array();
117 $enclosed = false;
118 $current_line = 0;
119
120 // Walk through each character in the CSV string (up to $this->delimiter_search_max_lines) and search potential delimiter characters.
121 $data_length = strlen( $data );
122 for ( $i = 0; $i < $data_length; $i++ ) {
123 $prev_char = ( $i - 1 >= 0 ) ? $data[ $i - 1 ] : '';
124 $curr_char = $data[ $i ];
125 $next_char = ( $i + 1 < $data_length ) ? $data[ $i + 1 ] : '';
126
127 if ( $curr_char === $this->enclosure ) {
128 // Open and closing quotes.
129 if ( ! $enclosed || $next_char !== $this->enclosure ) {
130 $enclosed = ! $enclosed; // Flip bool.
131 } elseif ( $enclosed ) {
132 ++$i; // Skip next character.
133 }
134 } elseif ( ( ( "\n" === $curr_char && "\r" !== $prev_char ) || "\r" === $curr_char ) && ! $enclosed ) {
135 // Reached end of a line.
136 ++$current_line;
137 if ( $current_line >= $this->delimiter_search_max_lines ) {
138 break;
139 }
140 } elseif ( ! $enclosed ) {
141 // At this point, $curr_char seems to be used as a delimiter, as it is not enclosed.
142 // Count $curr_char if it is not in the $this->non_delimiter_chars list.
143 if ( 0 === preg_match( '#[' . $this->non_delimiter_chars . ']#i', $curr_char ) ) {
144 if ( ! isset( $delimiter_count[ $curr_char ][ $current_line ] ) ) {
145 $delimiter_count[ $curr_char ][ $current_line ] = 0; // Initialize empty.
146 }
147 ++$delimiter_count[ $curr_char ][ $current_line ];
148 }
149 }
150 }
151
152 // Find most probable delimiter, by sorting their counts.
153 $potential_delimiters = array();
154 foreach ( $delimiter_count as $char => $line_counts ) {
155 $is_possible_delimiter = $this->_check_delimiter_count( $char, $line_counts, $current_line );
156 if ( false !== $is_possible_delimiter ) {
157 $potential_delimiters[ $is_possible_delimiter ] = $char;
158 }
159 }
160 ksort( $potential_delimiters );
161
162 // If no valid delimiter was found, use the character that was found in most rows.
163 if ( empty( $potential_delimiters ) ) {
164 $delimiter_counts = array_map( 'count', $delimiter_count );
165 arsort( $delimiter_counts, SORT_NUMERIC );
166 $potential_delimiters = array_keys( $delimiter_counts );
167 }
168
169 // If still no delimiter was found, fall back to a comma.
170 if ( empty( $potential_delimiters ) ) {
171 $potential_delimiters = array( ',' );
172 }
173
174 // Return first array element, as that has the highest count.
175 return array_shift( $potential_delimiters );
176 }
177
178 /**
179 * Check if passed character can be a delimiter, by checking counts in each line.
180 *
181 * @since 1.0.0
182 *
183 * @param string $char Character to check.
184 * @param int[] $line_counts Counts for the characters in the lines.
185 * @param int $number_lines Number of lines.
186 * @return bool|string False if delimiter is not possible, string to be used as a sort key if character could be a delimiter.
187 */
188 protected function _check_delimiter_count( string $char, array $line_counts, int $number_lines ) /* : bool|string */ {
189 // Was the potential delimiter found in every line?
190 if ( count( $line_counts ) !== $number_lines ) {
191 return false;
192 }
193
194 // Check if the count in every line is the same (or one higher for an "almost").
195 $first = null;
196 $equal = null;
197 $almost = false;
198 foreach ( $line_counts as $count ) {
199 if ( is_null( $first ) ) {
200 $first = $count;
201 } elseif ( $count === $first && false !== $equal ) {
202 $equal = true;
203 } elseif ( $count === $first + 1 && false !== $equal ) {
204 $equal = true;
205 $almost = true;
206 } else {
207 $equal = false;
208 }
209 }
210 // Check equality only if there's more than one line.
211 if ( $number_lines > 1 && ! $equal ) {
212 return false;
213 }
214
215 // At this point, count is equal in all lines, so determine a string to sort priority.
216 $match = ( $almost ) ? 2 : 1;
217 // There must not be more than 9 characters in the preferred delimiter character list.
218 $pref = strpos( $this->preferred_delimiter_chars, $char );
219 if ( false === $pref ) {
220 $pref = 9;
221 }
222 return $pref . $match . '.' . ( 99999 - $first );
223 }
224
225 /**
226 * Parse CSV string into a two-dimensional array.
227 *
228 * @since 1.0.0
229 *
230 * @param string $delimiter Delimiter character for the CSV parsing.
231 * @return array<int, array<int, string>> Two-dimensional array with the data from the CSV string.
232 */
233 public function parse( string $delimiter ): array {
234 $data = &$this->import_data;
235
236 // Filter delimiter from the list, if it is a whitespace character.
237 $white_spaces = str_replace( $delimiter, '', " \t\x0B\0" );
238
239 $rows = array(); // Complete rows.
240 $row = array(); // Row that is currently built.
241 $column = 0; // Current column index.
242 $cell_content = ''; // Content of the currently processed cell.
243 $enclosed = false;
244 $was_enclosed = false; // To determine if the cell content will be trimmed of whitespace (only for enclosed cells).
245
246 // Walk through each character in the CSV string.
247 $data_length = strlen( $data );
248 for ( $i = 0; $i < $data_length; $i++ ) {
249 $curr_char = $data[ $i ];
250 $next_char = ( $i + 1 < $data_length ) ? $data[ $i + 1 ] : '';
251
252 if ( $curr_char === $this->enclosure ) {
253 // Open/close quotes, and inline quotes.
254 if ( ! $enclosed ) {
255 if ( '' === ltrim( $cell_content, $white_spaces ) ) {
256 $enclosed = true;
257 $was_enclosed = true;
258 } else {
259 $this->error = 2;
260 $error_line = count( $rows ) + 1;
261 $error_column = $column + 1;
262 if ( ! isset( $this->error_info[ "{$error_line}-{$error_column}" ] ) ) {
263 $this->error_info[ "{$error_line}-{$error_column}" ] = array(
264 'type' => 2,
265 'info' => "Syntax error found in line {$error_line}. Non-enclosed fields can not contain double-quotes.",
266 'line' => $error_line,
267 'column' => $error_column,
268 );
269 }
270 $cell_content .= $curr_char;
271 }
272 } elseif ( $next_char === $this->enclosure ) {
273 // Enclosure character within enclosed cell (" encoded as "").
274 $cell_content .= $curr_char;
275 ++$i; // Skip next character.
276 } elseif ( $next_char !== $delimiter && "\r" !== $next_char && "\n" !== $next_char ) {
277 // for-loop (instead of while-loop) that skips whitespace.
278 for ( $x = ( $i + 1 ); isset( $data[ $x ] ) && '' === ltrim( $data[ $x ], $white_spaces ); $x++ ) { // phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed,Generic.CodeAnalysis.EmptyStatement.DetectedFor
279 // Action is in iterator check.
280 }
281 if ( $data[ $x ] === $delimiter ) {
282 $enclosed = false;
283 $i = $x;
284 } else {
285 if ( $this->error < 1 ) {
286 $this->error = 1;
287 }
288 $error_line = count( $rows ) + 1;
289 $error_column = $column + 1;
290 if ( ! isset( $this->error_info[ "{$error_line}-{$error_column}" ] ) ) {
291 $this->error_info[ "{$error_line}-{$error_column}" ] = array(
292 'type' => 1,
293 'info' => "Syntax error found in line {$error_line}. A single double-quote was found within an enclosed string. Enclosed double-quotes must be escaped with a second double-quote.",
294 'line' => $error_line,
295 'column' => $error_column,
296 );
297 }
298 $cell_content .= $curr_char;
299 $enclosed = false;
300 }
301 } else {
302 // The " was the closing one for the cell.
303 $enclosed = false;
304 }
305 } elseif ( ( $curr_char === $delimiter || "\n" === $curr_char || "\r" === $curr_char ) && ! $enclosed ) {
306 // End of cell (by $delimiter), or end of line (by line break, and not enclosed!).
307
308 $row[ $column ] = ( $was_enclosed ) ? $cell_content : trim( $cell_content );
309 $cell_content = '';
310 $was_enclosed = false;
311 ++$column;
312
313 // End of line.
314 if ( "\n" === $curr_char || "\r" === $curr_char ) {
315 // Append completed row.
316 $rows[] = $row;
317 $row = array();
318 $column = 0;
319 if ( "\r" === $curr_char && "\n" === $next_char ) {
320 // Skip next character in \r\n line breaks.
321 ++$i;
322 }
323 }
324 } else {
325 // Append character to current cell.
326 $cell_content .= $curr_char;
327 }
328 }
329
330 return $rows;
331 }
332
333 } // class CSV_Parser
334