PluginProbe
TablePress – Tables in WordPress made easy / 2.2.4
TablePress – Tables in WordPress made easy v2.2.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 2.4.4 All 44 releases
tablepress / classes / class-import-phpspreadsheet.php

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

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