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
← All changes | classes/class-import-phpspreadsheet.php +92 -50 2.1.7 → 3.4 View file →
@@ -7,8 +7,12 @@
7 7 * @author Tobias Bäthge
8 8 * @since 2.0.0
9 9 */
10 10
11 +declare(strict_types=1);
12 +
13 +use TablePress\Import\File;
14 +
11 15 // Prohibit direct script loading.
12 16 defined( 'ABSPATH' ) || die( 'No direct script access allowed!' );
13 17
14 18 /**
@@ -26,10 +30,10 @@
26 30 *
27 31 * @since 2.0.0
28 32 */
29 33 public function __construct() {
30 - // Load PHPSpreadsheet via the Composer autoloading mechanism.
31 - TablePress::load_file( 'autoload.php', 'libraries' );
34 + // Load PHPSpreadsheet via its autoloading mechanism.
35 + TablePress::load_file( 'autoload.php', 'libraries/vendor' );
32 36 }
33 37
34 38 /**
35 39 * Imports a table from a file.
@@ -35,34 +39,34 @@
35 39 * Imports a table from a file.
36 40 *
37 41 * @since 2.0.0
38 42 *
39 - * @param array $file File to import.
40 - * @return array|WP_Error Table array on success, WP_Error on error.
43 + * @param File $file File to import.
44 + * @return array<string, mixed>|WP_Error Table array on success, WP_Error on error.
41 45 */
42 - public function import_table( array $file ) {
43 - $data = file_get_contents( $file['location'] );
46 + public function import_table( File $file ) /* : array|WP_Error */ {
47 + $data = file_get_contents( $file->location );
44 48 if ( false === $data ) {
45 - return new WP_Error( 'table_import_phpspreadsheet_data_read', '', $file['location'] );
49 + return new WP_Error( 'table_import_phpspreadsheet_data_read', '', $file->location );
46 50 }
47 51
48 52 // Remove a possible UTF-8 Byte-Order Mark (BOM).
49 53 $bom = pack( 'CCC', 0xef, 0xbb, 0xbf );
50 - if ( 0 === strncmp( $data, $bom, 3 ) ) {
54 + if ( str_starts_with( $data, $bom ) ) {
51 55 $data = substr( $data, 3 );
52 56 }
53 57
54 58 if ( '' === $data ) {
55 - return new WP_Error( 'table_import_phpspreadsheet_data_empty', '', $file['location'] );
59 + return new WP_Error( 'table_import_phpspreadsheet_data_empty', '', $file->location );
56 60 }
57 61
58 62 $table = $this->_maybe_import_json( $data );
59 - if ( false !== $table ) {
63 + if ( is_array( $table ) ) {
60 64 return $table;
61 65 }
62 66
63 67 $table = $this->_maybe_import_html( $data );
64 - if ( false !== $table ) {
68 + if ( is_array( $table ) ) {
65 69 return $table;
66 70 }
67 71
68 72 return $this->_import_phpspreadsheet( $file );
@@ -73,15 +77,17 @@
73 77 *
74 78 * @since 2.0.0
75 79 *
76 80 * @param string $data Data to import.
77 - * @return array|WP_Error|false Table array on success, WP_Error on error, false if the file is not a JSON file.
81 + * @return array<string, mixed>|false Table array on success, false if the file is not a JSON file.
78 82 */
79 - protected function _maybe_import_json( $data ) {
80 - // If the first non-whitespace character is not a { or [, the file is not a supported JSON file.
81 - $data = ltrim( $data );
83 + protected function _maybe_import_json( string $data ) /* : array|false */ {
84 + $data = trim( $data );
85 +
86 + // If the file does not begin / end with [ / ] or { / }, it's not a supported JSON file.
82 87 $first_character = $data[0];
83 - if ( '{' !== $first_character && '[' !== $first_character ) {
88 + $last_character = $data[-1];
89 + if ( ! ( '[' === $first_character && ']' === $last_character ) && ! ( '{' === $first_character && '}' === $last_character ) ) {
84 90 return false;
85 91 }
86 92
87 93 $json_table = json_decode( $data, true );
@@ -125,17 +131,17 @@
125 131 *
126 132 * @since 2.0.0
127 133 *
128 134 * @param string $data Data to import.
129 - * @return array|WP_Error|false Table array on success, WP_Error on error, false if the file is not a JSON file.
135 + * @return array<string, mixed>|WP_Error Table array on success, WP_Error if the file is not an HTML file.
130 136 */
131 - protected function _maybe_import_html( $data ) {
137 + protected function _maybe_import_html( string $data ) /* : array|false */ {
132 138 TablePress::load_file( 'html-parser.class.php', 'libraries' );
133 139 $table = HTML_Parser::parse( $data );
134 140
135 141 // Check if the HTML code could be parsed. If not, this is probably not an HTML file.
136 142 if ( is_wp_error( $table ) ) {
137 - return false;
143 + return $table;
138 144 }
139 145
140 146 $this->pad_array_to_max_cols( $table['data'] );
141 147 return $table;
@@ -145,19 +151,28 @@
145 151 * Tries to import a table via PHPSpreadsheet.
146 152 *
147 153 * @since 2.0.0
148 154 *
149 - * @param array $file File to import.
150 - * @return array|WP_Error Table array on success, WP_Error on error.
155 + * @param File $file File to import.
156 + * @return array<string, mixed>|WP_Error Table array on success, WP_Error on error.
151 157 */
152 - protected function _import_phpspreadsheet( array $file ) {
158 + protected function _import_phpspreadsheet( File $file ) /* : array|WP_Error */ {
153 159 // Rename the temporary file, as PHPSpreadsheet tries to infer the format from the file's extension.
154 - if ( '' !== $file['extension'] ) {
155 - $temp_file = pathinfo( $file['location'] );
156 - if ( ! isset( $temp_file['extension'] ) || $file['extension'] !== $temp_file['extension'] ) {
157 - $new_location = "{$temp_file['dirname']}/{$temp_file['filename']}.{$file['extension']}";
158 - if ( rename( $file['location'], $new_location ) ) {
159 - $file['location'] = $new_location;
160 + if ( '' !== $file->extension ) {
161 + $file_data = pathinfo( $file->location );
162 + if ( ! isset( $file_data['extension'] ) || $file->extension !== $file_data['extension'] ) {
163 + $temp_file = wp_tempnam();
164 + $new_location = "{$temp_file}.{$file->extension}";
165 + if ( $file->keep_file ) {
166 + // Copy the file, as the original should be kept.
167 + if ( copy( $file->location, $new_location ) ) {
168 + $file->location = $new_location;
169 + $file->keep_file = false; // Delete the newly created file after the import.
170 + }
171 + } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
172 + if ( rename( $file->location, $new_location ) ) {
173 + $file->location = $new_location;
174 + }
160 175 }
161 176 }
162 177 }
163 178
@@ -163,9 +178,9 @@
163 178
164 179 try {
165 180 // Treat all cell values as strings, except for formulas (due to recognition of quoted/escaped formulas like `'=A2`).
166 181 \TablePress\PhpOffice\PhpSpreadsheet\Cell\Cell::setValueBinder( new \TablePress\PhpOffice\PhpSpreadsheet\Cell\StringValueBinder() );
167 - \TablePress\PhpOffice\PhpSpreadsheet\Cell\Cell::getValueBinder()->setFormulaConversion( false );
182 + \TablePress\PhpOffice\PhpSpreadsheet\Cell\Cell::getValueBinder()->setFormulaConversion( false ); // @phpstan-ignore method.notFound
168 183
169 184 /*
170 185 * Try to detect a reader from the file extension and MIME type.
171 186 * Fall back to CSV if no reader could be determined.
@@ -170,15 +185,24 @@
170 185 * Try to detect a reader from the file extension and MIME type.
171 186 * Fall back to CSV if no reader could be determined.
172 187 */
173 188 try {
174 - $reader = \TablePress\PhpOffice\PhpSpreadsheet\IOFactory::createReaderForFile( $file['location'] );
189 + $reader = \TablePress\PhpOffice\PhpSpreadsheet\IOFactory::createReaderForFile( $file->location );
175 190 } catch ( \TablePress\PhpOffice\PhpSpreadsheet\Reader\Exception $exception ) {
176 191 $reader = \TablePress\PhpOffice\PhpSpreadsheet\IOFactory::createReader( 'Csv' );
177 - // Append .csv to the file name, so that \TablePress\PhpOffice\PhpSpreadsheet\Reader\Csv::canRead() returns true.
178 - $new_location = $file['location'] . '.csv';
179 - if ( rename( $file['location'], $new_location ) ) {
180 - $file['location'] = $new_location;
192 + // Change the file extension to .csv, so that \TablePress\PhpOffice\PhpSpreadsheet\Reader\Csv::canRead() returns true.
193 + $temp_file = wp_tempnam();
194 + $new_location = "{$temp_file}.csv";
195 + if ( $file->keep_file ) {
196 + // Copy the file, as the original should be kept.
197 + if ( copy( $file->location, $new_location ) ) {
198 + $file->location = $new_location;
199 + $file->keep_file = false; // Delete the newly created file after the import.
200 + }
201 + } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
202 + if ( rename( $file->location, $new_location ) ) {
203 + $file->location = $new_location;
204 + }
181 205 }
182 206 }
183 207
184 208 $class_name = get_class( $reader );
@@ -185,9 +209,10 @@
185 209 $class_type = explode( '\\', $class_name );
186 210 $detected_format = strtolower( array_pop( $class_type ) );
187 211
188 212 if ( 'csv' === $detected_format ) {
189 - $reader->setInputEncoding( \TablePress\PhpOffice\PhpSpreadsheet\Reader\Csv::GUESS_ENCODING );
213 + $reader->setInputEncoding( \TablePress\PhpOffice\PhpSpreadsheet\Reader\Csv::GUESS_ENCODING ); // @phpstan-ignore method.notFound
214 + // @phpstan-ignore method.notFound, smaller.alwaysFalse (PHPStan thinks that the Composer minimum version will always be fulfilled.)
190 215 $reader->setEscapeCharacter( ( PHP_VERSION_ID < 70400 ) ? "\x0" : '' ); // Disable the proprietary escape mechanism of PHP's fgetcsv() in PHP >= 7.4.
191 216 }
192 217
193 218 $reader->setIncludeCharts( false );
@@ -199,12 +224,12 @@
199 224 }
200 225
201 226 // For formats where it's supported, import only the first sheet.
202 227 if ( in_array( $detected_format, array( 'csv', 'html', 'slk' ), true ) ) {
203 - $reader->setSheetIndex( 0 );
228 + $reader->setSheetIndex( 0 ); // @phpstan-ignore method.notFound
204 229 }
205 230
206 - $spreadsheet = $reader->load( $file['location'] );
231 + $spreadsheet = $reader->load( $file->location );
207 232 $worksheet = $spreadsheet->getActiveSheet();
208 233 $cell_collection = $worksheet->getCellCollection();
209 234 $comments = $worksheet->getComments();
210 235
@@ -217,12 +242,12 @@
217 242 $max_col = $worksheet->getHighestColumn();
218 243 $max_row = $worksheet->getHighestRow();
219 244
220 245 // Adapted from \TablePress\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet::rangeToArray().
221 - ++$max_col; // Due to for-loop with characters for columns.
246 + \TablePress\PhpOffice\PhpSpreadsheet\Shared\StringHelper::stringIncrement( $max_col ); // Due to for-loop with characters for columns.
222 247 for ( $row = $min_row; $row <= $max_row; $row++ ) {
223 248 $row_data = array();
224 - for ( $col = $min_col; $col !== $max_col; $col++ ) {
249 + for ( $col = $min_col; $col !== $max_col; \TablePress\PhpOffice\PhpSpreadsheet\Shared\StringHelper::stringIncrement( $col ) ) {
225 250 $cell_reference = $col . $row;
226 251 if ( ! $cell_collection->has( $cell_reference ) ) {
227 252 $row_data[] = '';
228 253 continue;
@@ -234,10 +259,12 @@
234 259 $row_data[] = '';
235 260 continue;
236 261 }
237 262
263 + $cell_has_hyperlink = $worksheet->hyperlinkExists( $cell_reference ) && ! $worksheet->getHyperlink( $cell_reference )->isInternal();
264 +
238 265 if ( $value instanceof \TablePress\PhpOffice\PhpSpreadsheet\RichText\RichText ) {
239 - $cell_data = $this->parse_rich_text( $value );
266 + $cell_data = $this->parse_rich_text( $value, $cell_has_hyperlink );
240 267 } else {
241 268 $cell_data = (string) $value;
242 269 }
243 270
@@ -242,16 +269,31 @@
242 269 }
243 270
244 271 // Apply data type formatting.
245 272 $style = $spreadsheet->getCellXfByIndex( $cell->getXfIndex() );
273 +
274 + $format = $style->getNumberFormat()->getFormatCode() ?? \TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_GENERAL;
275 +
276 + /*
277 + * When cells in Excel files are formatted as "Text", quotation marks are removed, due to https://github.com/PHPOffice/PhpSpreadsheet/pull/3344.
278 + * Setting the format to "General" seems to prevent that.
279 + */
280 + if ( \TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_TEXT === $format && ! is_numeric( $cell_data ) ) {
281 + $format = \TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_GENERAL;
282 + }
283 +
284 + // Fix floating point precision issues with numbers in the "General" Excel .xlsx format.
285 + if ( 'xlsx' === $detected_format && \TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_GENERAL === $format && is_numeric( $cell_data ) ) {
286 + $cell_data = (string) (float) $cell_data; // Type-cast strings to float and back.
287 + }
246 288 $cell_data = \TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat::toFormattedString(
247 289 $cell_data,
248 - $style->getNumberFormat() ? $style->getNumberFormat()->getFormatCode() : \TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_GENERAL,
249 - array( $this, 'format_color' )
290 + $format,
291 + array( $this, 'format_color' ),
250 292 );
251 293
252 294 if ( strlen( $cell_data ) > 1 && '=' === $cell_data[0] ) {
253 - if ( $style->getQuotePrefix() ) {
295 + if ( 'xlsx' === $detected_format && $style->getQuotePrefix() ) {
254 296 // Prepend a ' to quoted/escaped formulas (so that they are shown as text). This is currently not supported (at least) for the XLS format.
255 297 $cell_data = "'{$cell_data}";
256 298 } else {
257 299 // Bail early, to not add inline HTML styling around formulas, as they won't work anymore then.
@@ -259,10 +301,8 @@
259 301 continue;
260 302 }
261 303 }
262 304
263 - $cell_has_hyperlink = $worksheet->hyperlinkExists( $cell_reference ) && ! $worksheet->getHyperlink( $cell_reference )->isInternal();
264 -
265 305 $font = $style->getFont();
266 306
267 307 if ( $font->getSuperscript() ) {
268 308 $cell_data = "<sup>{$cell_data}</sup>";
@@ -340,9 +380,9 @@
340 380 $spreadsheet->disconnectWorksheets();
341 381 unset( $comments, $cell_collection, $worksheet, $spreadsheet );
342 382
343 383 return $table;
344 - } catch ( \TablePress\PhpOffice\PhpSpreadsheet\Reader\Exception $exception ) {
384 + } catch ( \TablePress\PhpOffice\PhpSpreadsheet\Reader\Exception | \TablePress\PhpOffice\PhpSpreadsheet\Exception $exception ) {
345 385 return new WP_Error( 'table_import_phpspreadsheet_failed', '', 'Exception: ' . $exception->getMessage() );
346 386 }
347 387 }
348 388
@@ -348,12 +388,13 @@
348 388
349 389 /**
350 390 * Parses PHPSpreadsheet RichText elements and converts formatting to HTML tags.
351 391 *
352 - * @param RichText $value RichText element.
392 + * @param \TablePress\PhpOffice\PhpSpreadsheet\RichText\RichText $value RichText element.
393 + * @param bool $cell_has_hyperlink Whether the cell has a hyperlink.
353 394 * @return string Cell value with HTML formatting.
354 395 */
355 - protected function parse_rich_text( $value ) {
396 + protected function parse_rich_text( \TablePress\PhpOffice\PhpSpreadsheet\RichText\RichText $value, bool $cell_has_hyperlink ): string {
356 397 $cell_data = '';
357 398 $elements = $value->getRichTextElements();
358 399 foreach ( $elements as $element ) {
359 400 $element_data = $element->getText();
@@ -380,9 +421,10 @@
380 421 if ( $font->getItalic() ) {
381 422 $element_data = "<em>{$element_data}</em>";
382 423 }
383 424 $color = $font->getColor()->getRGB();
384 - if ( '' !== $color ) {
425 + if ( '' !== $color && '000000' !== $color && ! $cell_has_hyperlink ) {
426 + // Don't add the span if the color is black, as that's the default, or if it's in a hyperlink.
385 427 $color_css = esc_attr( "color:#{$color};" );
386 428 $element_data = "<span style=\"{$color_css}\">{$element_data}</span>";
387 429 }
388 430 }
@@ -398,9 +440,9 @@
398 440 * @param string $value Plain formatted value without color.
399 441 * @param string $format_code Format code.
400 442 * @return string Value with color format applied.
401 443 */
402 - public function format_color( $value, $format_code ) {
444 + public function format_color( string $value, string $format_code ): string {
403 445 // Color information, e.g. [Red] is always at the beginning of the format code.
404 446 $color = '';
405 447 if ( 1 === preg_match( '/^\\[[a-zA-Z]+\\]/', $format_code, $matches ) ) {
406 448 $color = str_replace( array( '[', ']' ), '', $matches[0] );