PluginProbe
TablePress – Tables in WordPress made easy / 3.3.3
TablePress – Tables in WordPress made easy v3.3.3
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 / classes / class-import-phpspreadsheet.php

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

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