| 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 |
|