PluginProbe
TablePress – Tables in WordPress made easy / 2.4.2
TablePress – Tables in WordPress made easy v2.4.2
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 2.4.4 All 44 releases
tablepress / libraries / csv-parser.class.php

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

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