PluginProbe
TablePress – Tables in WordPress made easy / 3.1
TablePress – Tables in WordPress made easy v3.1
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 3.1, at libraries/csv-parser.class.php

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