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 / classes / class-import-phpspreadsheet.php

class-import-phpspreadsheet.php in TablePress – Tables in WordPress made easy 3.4, at classes/class-import-phpspreadsheet.php

461 lines 16.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * TablePress Table Import PHPSpreadsheet Class
4 *
5 * @package TablePress
6 * @subpackage Export/Import
7 * @author Tobias Bäthge
8 * @since 2.0.0
9 */
10
11 declare(strict_types=1);
12
13 use TablePress\Import\File;
14
15 // Prohibit direct script loading.
16 defined( 'ABSPATH' ) || die( 'No direct script access allowed!' );
17
18 /**
19 * TablePress Table Import PHPSpreadsheet Class
20 *
21 * @package TablePress
22 * @subpackage Export/Import
23 * @author Tobias Bäthge
24 * @since 2.0.0
25 */
26 class TablePress_Import_PHPSpreadsheet extends TablePress_Import_Base {
27
28 /**
29 * Initializes the Import class.
30 *
31 * @since 2.0.0
32 */
33 public function __construct() {
34 // Load PHPSpreadsheet via its autoloading mechanism.
35 TablePress::load_file( 'autoload.php', 'libraries/vendor' );
36 }
37
38 /**
39 * Imports a table from a file.
40 *
41 * @since 2.0.0
42 *
43 * @param File $file File to import.
44 * @return array<string, mixed>|WP_Error Table array on success, WP_Error on error.
45 */
46 public function import_table( File $file ) /* : array|WP_Error */ {
47 $data = file_get_contents( $file->location );
48 if ( false === $data ) {
49 return new WP_Error( 'table_import_phpspreadsheet_data_read', '', $file->location );
50 }
51
52 // Remove a possible UTF-8 Byte-Order Mark (BOM).
53 $bom = pack( 'CCC', 0xef, 0xbb, 0xbf );
54 if ( str_starts_with( $data, $bom ) ) {
55 $data = substr( $data, 3 );
56 }
57
58 if ( '' === $data ) {
59 return new WP_Error( 'table_import_phpspreadsheet_data_empty', '', $file->location );
60 }
61
62 $table = $this->_maybe_import_json( $data );
63 if ( is_array( $table ) ) {
64 return $table;
65 }
66
67 $table = $this->_maybe_import_html( $data );
68 if ( is_array( $table ) ) {
69 return $table;
70 }
71
72 return $this->_import_phpspreadsheet( $file );
73 }
74
75 /**
76 * Tries to import a table with the JSON format.
77 *
78 * @since 2.0.0
79 *
80 * @param string $data Data to import.
81 * @return array<string, mixed>|false Table array on success, false if the file is not a JSON file.
82 */
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.
87 $first_character = $data[0];
88 $last_character = $data[-1];
89 if ( ! ( '[' === $first_character && ']' === $last_character ) && ! ( '{' === $first_character && '}' === $last_character ) ) {
90 return false;
91 }
92
93 $json_table = json_decode( $data, true );
94
95 // Check if JSON could be decoded. If not, this is probably not a JSON file.
96 if ( is_null( $json_table ) ) {
97 return false;
98 }
99
100 // Specifically cast to an array again.
101 $json_table = (array) $json_table;
102
103 if ( isset( $json_table['data'] ) ) {
104 // JSON data contained a full export.
105 $table = $json_table;
106 } else {
107 // JSON data contained only the data of a table, but no options.
108 $table = array( 'data' => array() );
109 foreach ( $json_table as $row ) {
110 // Turn row into indexed arrays with numeric keys.
111 $row = array_values( (array) $row );
112
113 // Remove entries of multi-dimensional arrays.
114 foreach ( $row as &$cell ) {
115 if ( is_array( $cell ) ) {
116 $cell = '';
117 }
118 }
119 unset( $cell ); // Unset use-by-reference parameter of foreach loop.
120
121 $table['data'][] = $row;
122 }
123 }
124
125 $this->pad_array_to_max_cols( $table['data'] );
126 return $table;
127 }
128
129 /**
130 * Tries to import a table with the HTML format.
131 *
132 * @since 2.0.0
133 *
134 * @param string $data Data to import.
135 * @return array<string, mixed>|WP_Error Table array on success, WP_Error if the file is not an HTML file.
136 */
137 protected function _maybe_import_html( string $data ) /* : array|false */ {
138 TablePress::load_file( 'html-parser.class.php', 'libraries' );
139 $table = HTML_Parser::parse( $data );
140
141 // Check if the HTML code could be parsed. If not, this is probably not an HTML file.
142 if ( is_wp_error( $table ) ) {
143 return $table;
144 }
145
146 $this->pad_array_to_max_cols( $table['data'] );
147 return $table;
148 }
149
150 /**
151 * Tries to import a table via PHPSpreadsheet.
152 *
153 * @since 2.0.0
154 *
155 * @param File $file File to import.
156 * @return array<string, mixed>|WP_Error Table array on success, WP_Error on error.
157 */
158 protected function _import_phpspreadsheet( File $file ) /* : array|WP_Error */ {
159 // Rename the temporary file, as PHPSpreadsheet tries to infer the format from the file's extension.
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 }
175 }
176 }
177 }
178
179 try {
180 // Treat all cell values as strings, except for formulas (due to recognition of quoted/escaped formulas like `'=A2`).
181 \TablePress\PhpOffice\PhpSpreadsheet\Cell\Cell::setValueBinder( new \TablePress\PhpOffice\PhpSpreadsheet\Cell\StringValueBinder() );
182 \TablePress\PhpOffice\PhpSpreadsheet\Cell\Cell::getValueBinder()->setFormulaConversion( false ); // @phpstan-ignore method.notFound
183
184 /*
185 * Try to detect a reader from the file extension and MIME type.
186 * Fall back to CSV if no reader could be determined.
187 */
188 try {
189 $reader = \TablePress\PhpOffice\PhpSpreadsheet\IOFactory::createReaderForFile( $file->location );
190 } catch ( \TablePress\PhpOffice\PhpSpreadsheet\Reader\Exception $exception ) {
191 $reader = \TablePress\PhpOffice\PhpSpreadsheet\IOFactory::createReader( 'Csv' );
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 }
205 }
206 }
207
208 $class_name = get_class( $reader );
209 $class_type = explode( '\\', $class_name );
210 $detected_format = strtolower( array_pop( $class_type ) );
211
212 if ( 'csv' === $detected_format ) {
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.)
215 $reader->setEscapeCharacter( ( PHP_VERSION_ID < 70400 ) ? "\x0" : '' ); // Disable the proprietary escape mechanism of PHP's fgetcsv() in PHP >= 7.4.
216 }
217
218 $reader->setIncludeCharts( false );
219 $reader->setReadEmptyCells( true );
220
221 // For non-Excel files, import only the data, but ignore formatting.
222 if ( ! in_array( $detected_format, array( 'xlsx', 'xls' ), true ) ) {
223 $reader->setReadDataOnly( true );
224 }
225
226 // For formats where it's supported, import only the first sheet.
227 if ( in_array( $detected_format, array( 'csv', 'html', 'slk' ), true ) ) {
228 $reader->setSheetIndex( 0 ); // @phpstan-ignore method.notFound
229 }
230
231 $spreadsheet = $reader->load( $file->location );
232 $worksheet = $spreadsheet->getActiveSheet();
233 $cell_collection = $worksheet->getCellCollection();
234 $comments = $worksheet->getComments();
235
236 $table = array(
237 'data' => array(),
238 );
239
240 $min_col = 'A';
241 $min_row = 1;
242 $max_col = $worksheet->getHighestColumn();
243 $max_row = $worksheet->getHighestRow();
244
245 // Adapted from \TablePress\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet::rangeToArray().
246 \TablePress\PhpOffice\PhpSpreadsheet\Shared\StringHelper::stringIncrement( $max_col ); // Due to for-loop with characters for columns.
247 for ( $row = $min_row; $row <= $max_row; $row++ ) {
248 $row_data = array();
249 for ( $col = $min_col; $col !== $max_col; \TablePress\PhpOffice\PhpSpreadsheet\Shared\StringHelper::stringIncrement( $col ) ) {
250 $cell_reference = $col . $row;
251 if ( ! $cell_collection->has( $cell_reference ) ) {
252 $row_data[] = '';
253 continue;
254 }
255
256 $cell = $cell_collection->get( $cell_reference );
257 $value = $cell->getValue();
258 if ( is_null( $value ) ) {
259 $row_data[] = '';
260 continue;
261 }
262
263 $cell_has_hyperlink = $worksheet->hyperlinkExists( $cell_reference ) && ! $worksheet->getHyperlink( $cell_reference )->isInternal();
264
265 if ( $value instanceof \TablePress\PhpOffice\PhpSpreadsheet\RichText\RichText ) {
266 $cell_data = $this->parse_rich_text( $value, $cell_has_hyperlink );
267 } else {
268 $cell_data = (string) $value;
269 }
270
271 // Apply data type formatting.
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 }
288 $cell_data = \TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat::toFormattedString(
289 $cell_data,
290 $format,
291 array( $this, 'format_color' ),
292 );
293
294 if ( strlen( $cell_data ) > 1 && '=' === $cell_data[0] ) {
295 if ( 'xlsx' === $detected_format && $style->getQuotePrefix() ) {
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.
297 $cell_data = "'{$cell_data}";
298 } else {
299 // Bail early, to not add inline HTML styling around formulas, as they won't work anymore then.
300 $row_data[] = $cell_data;
301 continue;
302 }
303 }
304
305 $font = $style->getFont();
306
307 if ( $font->getSuperscript() ) {
308 $cell_data = "<sup>{$cell_data}</sup>";
309 }
310 if ( $font->getSubscript() ) {
311 $cell_data = "<sub>{$cell_data}</sub>";
312 }
313 if ( $font->getStrikethrough() ) {
314 $cell_data = "<del>{$cell_data}</del>";
315 }
316 if ( $font->getUnderline() !== \TablePress\PhpOffice\PhpSpreadsheet\Style\Font::UNDERLINE_NONE && ! $cell_has_hyperlink ) {
317 $cell_data = "<u>{$cell_data}</u>";
318 }
319 if ( $font->getBold() ) {
320 $cell_data = "<strong>{$cell_data}</strong>";
321 }
322 if ( $font->getItalic() ) {
323 $cell_data = "<em>{$cell_data}</em>";
324 }
325 $color = $font->getColor()->getRGB();
326 if ( '' !== $color && '000000' !== $color && ! $cell_has_hyperlink ) {
327 // Don't add the span if the color is black, as that's the default, or if it's in a hyperlink.
328 $color_css = esc_attr( "color:#{$color};" );
329 $cell_data = "<span style=\"{$color_css}\">{$cell_data}</span>";
330 }
331
332 // Convert Hyperlinks to HTML code.
333 if ( $cell_has_hyperlink ) {
334 $url = $worksheet->getHyperlink( $cell_reference )->getUrl();
335 if ( '' !== $url ) {
336 $title = $worksheet->getHyperlink( $cell_reference )->getTooltip();
337 if ( '' !== $title ) {
338 $title = ' title="' . esc_attr( $title ) . '"';
339 }
340 $url = esc_url( $url );
341 $cell_data = "<a href=\"{$url}\"{$title}>{$cell_data}</a>";
342 }
343 }
344
345 // Add comments.
346 if ( isset( $comments[ $cell_reference ] ) ) {
347 $sanitized_comment = esc_html( $worksheet->getComment( $cell_reference )->getText()->getPlainText() );
348 if ( '' !== $sanitized_comment ) {
349 $cell_data .= '<div class="comment">' . $sanitized_comment . '</div>';
350 }
351 }
352
353 $row_data[] = $cell_data;
354 }
355 $table['data'][] = $row_data;
356 }
357
358 // Convert merged cells to trigger words.
359 $merged_cells = $worksheet->getMergeCells();
360 foreach ( $merged_cells as $merged_cells_range ) {
361 $cells = explode( ':', $merged_cells_range );
362 $first_cell = \TablePress\PhpOffice\PhpSpreadsheet\Cell\Coordinate::indexesFromString( $cells[0] );
363 $last_cell = \TablePress\PhpOffice\PhpSpreadsheet\Cell\Coordinate::indexesFromString( $cells[1] );
364 for ( $row_idx = $first_cell[1]; $row_idx <= $last_cell[1]; $row_idx++ ) {
365 for ( $column_idx = $first_cell[0]; $column_idx <= $last_cell[0]; $column_idx++ ) {
366 if ( $row_idx === $first_cell[1] && $column_idx === $first_cell[0] ) {
367 continue; // Keep value of first cell.
368 } elseif ( $row_idx === $first_cell[1] && $column_idx > $first_cell[0] ) {
369 $table['data'][ $row_idx - 1 ][ $column_idx - 1 ] = '#colspan#';
370 } elseif ( $row_idx > $first_cell[1] && $column_idx === $first_cell[0] ) {
371 $table['data'][ $row_idx - 1 ][ $column_idx - 1 ] = '#rowspan#';
372 } else {
373 $table['data'][ $row_idx - 1 ][ $column_idx - 1 ] = '#span#';
374 }
375 }
376 }
377 }
378
379 // Save PHP memory.
380 $spreadsheet->disconnectWorksheets();
381 unset( $comments, $cell_collection, $worksheet, $spreadsheet );
382
383 return $table;
384 } catch ( \TablePress\PhpOffice\PhpSpreadsheet\Reader\Exception |\TablePress\PhpOffice\PhpSpreadsheet\Exception $exception ) {
385 return new WP_Error( 'table_import_phpspreadsheet_failed', '', 'Exception: ' . $exception->getMessage() );
386 }
387 }
388
389 /**
390 * Parses PHPSpreadsheet RichText elements and converts formatting to HTML tags.
391 *
392 * @param \TablePress\PhpOffice\PhpSpreadsheet\RichText\RichText $value RichText element.
393 * @param bool $cell_has_hyperlink Whether the cell has a hyperlink.
394 * @return string Cell value with HTML formatting.
395 */
396 protected function parse_rich_text( \TablePress\PhpOffice\PhpSpreadsheet\RichText\RichText $value, bool $cell_has_hyperlink ): string {
397 $cell_data = '';
398 $elements = $value->getRichTextElements();
399 foreach ( $elements as $element ) {
400 $element_data = $element->getText();
401
402 // Rich text start?
403 if ( $element instanceof \TablePress\PhpOffice\PhpSpreadsheet\RichText\Run ) {
404 $font = $element->getFont();
405
406 if ( $font->getSuperscript() ) {
407 $element_data = "<sup>{$element_data}</sup>";
408 }
409 if ( $font->getSubscript() ) {
410 $element_data = "<sub>{$element_data}</sub>";
411 }
412 if ( $font->getStrikethrough() ) {
413 $element_data = "<del>{$element_data}</del>";
414 }
415 if ( $font->getUnderline() !== \TablePress\PhpOffice\PhpSpreadsheet\Style\Font::UNDERLINE_NONE ) {
416 $element_data = "<u>{$element_data}</u>";
417 }
418 if ( $font->getBold() ) {
419 $element_data = "<strong>{$element_data}</strong>";
420 }
421 if ( $font->getItalic() ) {
422 $element_data = "<em>{$element_data}</em>";
423 }
424 $color = $font->getColor()->getRGB();
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.
427 $color_css = esc_attr( "color:#{$color};" );
428 $element_data = "<span style=\"{$color_css}\">{$element_data}</span>";
429 }
430 }
431
432 $cell_data .= $element_data;
433 }
434 return $cell_data;
435 }
436
437 /**
438 * Adds color to formatted string as inline style, e.g. from conditional formatting.
439 *
440 * @param string $value Plain formatted value without color.
441 * @param string $format_code Format code.
442 * @return string Value with color format applied.
443 */
444 public function format_color( string $value, string $format_code ): string {
445 // Color information, e.g. [Red] is always at the beginning of the format code.
446 $color = '';
447 if ( 1 === preg_match( '/^\\[[a-zA-Z]+\\]/', $format_code, $matches ) ) {
448 $color = str_replace( array( '[', ']' ), '', $matches[0] );
449 $color = strtolower( $color );
450 }
451
452 if ( '' !== $color ) {
453 $color = esc_attr( "color:{$color};" );
454 $value = "<span style=\"{$color}\">{$value}</span>";
455 }
456
457 return $value;
458 }
459
460 } // class TablePress_Import_PHPSpreadsheet
461