| @@ -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 ); |
| @@ -101,9 +107,19 @@ | ||
| 101 | 107 | // JSON data contained only the data of a table, but no options. |
| 102 | 108 | $table = array( 'data' => array() ); |
| 103 | 109 | foreach ( $json_table as $row ) { |
| 104 | 110 | // Turn row into indexed arrays with numeric keys. |
| 105 | - $table['data'][] = array_values( (array) $row ); | |
| 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; | |
| 106 | 122 | } |
| 107 | 123 | } |
| 108 | 124 | |
| 109 | 125 | $this->pad_array_to_max_cols( $table['data'] ); |
| @@ -115,17 +131,17 @@ | ||
| 115 | 131 | * |
| 116 | 132 | * @since 2.0.0 |
| 117 | 133 | * |
| 118 | 134 | * @param string $data Data to import. |
| 119 | - * @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. | |
| 120 | 136 | */ |
| 121 | - protected function _maybe_import_html( $data ) { | |
| 137 | + protected function _maybe_import_html( string $data ) /* : array|false */ { | |
| 122 | 138 | TablePress::load_file( 'html-parser.class.php', 'libraries' ); |
| 123 | 139 | $table = HTML_Parser::parse( $data ); |
| 124 | 140 | |
| 125 | 141 | // Check if the HTML code could be parsed. If not, this is probably not an HTML file. |
| 126 | 142 | if ( is_wp_error( $table ) ) { |
| 127 | - return false; | |
| 143 | + return $table; | |
| 128 | 144 | } |
| 129 | 145 | |
| 130 | 146 | $this->pad_array_to_max_cols( $table['data'] ); |
| 131 | 147 | return $table; |
| @@ -135,19 +151,28 @@ | ||
| 135 | 151 | * Tries to import a table via PHPSpreadsheet. |
| 136 | 152 | * |
| 137 | 153 | * @since 2.0.0 |
| 138 | 154 | * |
| 139 | - * @param array $file File to import. | |
| 140 | - * @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. | |
| 141 | 157 | */ |
| 142 | - protected function _import_phpspreadsheet( array $file ) { | |
| 158 | + protected function _import_phpspreadsheet( File $file ) /* : array|WP_Error */ { | |
| 143 | 159 | // Rename the temporary file, as PHPSpreadsheet tries to infer the format from the file's extension. |
| 144 | - if ( '' !== $file['extension'] ) { | |
| 145 | - $temp_file = pathinfo( $file['location'] ); | |
| 146 | - if ( ! isset( $temp_file['extension'] ) || $file['extension'] !== $temp_file['extension'] ) { | |
| 147 | - $new_location = "{$temp_file['dirname']}/{$temp_file['filename']}.{$file['extension']}"; | |
| 148 | - if ( rename( $file['location'], $new_location ) ) { | |
| 149 | - $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 | + } | |
| 150 | 175 | } |
| 151 | 176 | } |
| 152 | 177 | } |
| 153 | 178 | |
| @@ -153,9 +178,9 @@ | ||
| 153 | 178 | |
| 154 | 179 | try { |
| 155 | 180 | // Treat all cell values as strings, except for formulas (due to recognition of quoted/escaped formulas like `'=A2`). |
| 156 | 181 | \TablePress\PhpOffice\PhpSpreadsheet\Cell\Cell::setValueBinder( new \TablePress\PhpOffice\PhpSpreadsheet\Cell\StringValueBinder() ); |
| 157 | - \TablePress\PhpOffice\PhpSpreadsheet\Cell\Cell::getValueBinder()->setFormulaConversion( false ); | |
| 182 | + \TablePress\PhpOffice\PhpSpreadsheet\Cell\Cell::getValueBinder()->setFormulaConversion( false ); // @phpstan-ignore method.notFound | |
| 158 | 183 | |
| 159 | 184 | /* |
| 160 | 185 | * Try to detect a reader from the file extension and MIME type. |
| 161 | 186 | * Fall back to CSV if no reader could be determined. |
| @@ -160,15 +185,24 @@ | ||
| 160 | 185 | * Try to detect a reader from the file extension and MIME type. |
| 161 | 186 | * Fall back to CSV if no reader could be determined. |
| 162 | 187 | */ |
| 163 | 188 | try { |
| 164 | - $reader = \TablePress\PhpOffice\PhpSpreadsheet\IOFactory::createReaderForFile( $file['location'] ); | |
| 189 | + $reader = \TablePress\PhpOffice\PhpSpreadsheet\IOFactory::createReaderForFile( $file->location ); | |
| 165 | 190 | } catch ( \TablePress\PhpOffice\PhpSpreadsheet\Reader\Exception $exception ) { |
| 166 | 191 | $reader = \TablePress\PhpOffice\PhpSpreadsheet\IOFactory::createReader( 'Csv' ); |
| 167 | - // Append .csv to the file name, so that \TablePress\PhpOffice\PhpSpreadsheet\Reader\Csv::canRead() returns true. | |
| 168 | - $new_location = $file['location'] . '.csv'; | |
| 169 | - if ( rename( $file['location'], $new_location ) ) { | |
| 170 | - $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 | + } | |
| 171 | 205 | } |
| 172 | 206 | } |
| 173 | 207 | |
| 174 | 208 | $class_name = get_class( $reader ); |
| @@ -175,9 +209,10 @@ | ||
| 175 | 209 | $class_type = explode( '\\', $class_name ); |
| 176 | 210 | $detected_format = strtolower( array_pop( $class_type ) ); |
| 177 | 211 | |
| 178 | 212 | if ( 'csv' === $detected_format ) { |
| 179 | - $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.) | |
| 180 | 215 | $reader->setEscapeCharacter( ( PHP_VERSION_ID < 70400 ) ? "\x0" : '' ); // Disable the proprietary escape mechanism of PHP's fgetcsv() in PHP >= 7.4. |
| 181 | 216 | } |
| 182 | 217 | |
| 183 | 218 | $reader->setIncludeCharts( false ); |
| @@ -189,12 +224,12 @@ | ||
| 189 | 224 | } |
| 190 | 225 | |
| 191 | 226 | // For formats where it's supported, import only the first sheet. |
| 192 | 227 | if ( in_array( $detected_format, array( 'csv', 'html', 'slk' ), true ) ) { |
| 193 | - $reader->setSheetIndex( 0 ); | |
| 228 | + $reader->setSheetIndex( 0 ); // @phpstan-ignore method.notFound | |
| 194 | 229 | } |
| 195 | 230 | |
| 196 | - $spreadsheet = $reader->load( $file['location'] ); | |
| 231 | + $spreadsheet = $reader->load( $file->location ); | |
| 197 | 232 | $worksheet = $spreadsheet->getActiveSheet(); |
| 198 | 233 | $cell_collection = $worksheet->getCellCollection(); |
| 199 | 234 | $comments = $worksheet->getComments(); |
| 200 | 235 | |
| @@ -207,12 +242,12 @@ | ||
| 207 | 242 | $max_col = $worksheet->getHighestColumn(); |
| 208 | 243 | $max_row = $worksheet->getHighestRow(); |
| 209 | 244 | |
| 210 | 245 | // Adapted from \TablePress\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet::rangeToArray(). |
| 211 | - ++$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. | |
| 212 | 247 | for ( $row = $min_row; $row <= $max_row; $row++ ) { |
| 213 | 248 | $row_data = array(); |
| 214 | - for ( $col = $min_col; $col !== $max_col; $col++ ) { | |
| 249 | + for ( $col = $min_col; $col !== $max_col; \TablePress\PhpOffice\PhpSpreadsheet\Shared\StringHelper::stringIncrement( $col ) ) { | |
| 215 | 250 | $cell_reference = $col . $row; |
| 216 | 251 | if ( ! $cell_collection->has( $cell_reference ) ) { |
| 217 | 252 | $row_data[] = ''; |
| 218 | 253 | continue; |
| @@ -224,10 +259,12 @@ | ||
| 224 | 259 | $row_data[] = ''; |
| 225 | 260 | continue; |
| 226 | 261 | } |
| 227 | 262 | |
| 263 | + $cell_has_hyperlink = $worksheet->hyperlinkExists( $cell_reference ) && ! $worksheet->getHyperlink( $cell_reference )->isInternal(); | |
| 264 | + | |
| 228 | 265 | if ( $value instanceof \TablePress\PhpOffice\PhpSpreadsheet\RichText\RichText ) { |
| 229 | - $cell_data = $this->parse_rich_text( $value ); | |
| 266 | + $cell_data = $this->parse_rich_text( $value, $cell_has_hyperlink ); | |
| 230 | 267 | } else { |
| 231 | 268 | $cell_data = (string) $value; |
| 232 | 269 | } |
| 233 | 270 | |
| @@ -232,16 +269,31 @@ | ||
| 232 | 269 | } |
| 233 | 270 | |
| 234 | 271 | // Apply data type formatting. |
| 235 | 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 | + } | |
| 236 | 288 | $cell_data = \TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat::toFormattedString( |
| 237 | 289 | $cell_data, |
| 238 | - $style->getNumberFormat() ? $style->getNumberFormat()->getFormatCode() : \TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat::FORMAT_GENERAL, | |
| 239 | - array( $this, 'format_color' ) | |
| 290 | + $format, | |
| 291 | + array( $this, 'format_color' ), | |
| 240 | 292 | ); |
| 241 | 293 | |
| 242 | 294 | if ( strlen( $cell_data ) > 1 && '=' === $cell_data[0] ) { |
| 243 | - if ( $style->getQuotePrefix() ) { | |
| 295 | + if ( 'xlsx' === $detected_format && $style->getQuotePrefix() ) { | |
| 244 | 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. |
| 245 | 297 | $cell_data = "'{$cell_data}"; |
| 246 | 298 | } else { |
| 247 | 299 | // Bail early, to not add inline HTML styling around formulas, as they won't work anymore then. |
| @@ -249,10 +301,8 @@ | ||
| 249 | 301 | continue; |
| 250 | 302 | } |
| 251 | 303 | } |
| 252 | 304 | |
| 253 | - $cell_has_hyperlink = $worksheet->hyperlinkExists( $cell_reference ) && ! $worksheet->getHyperlink( $cell_reference )->isInternal(); | |
| 254 | - | |
| 255 | 305 | $font = $style->getFont(); |
| 256 | 306 | |
| 257 | 307 | if ( $font->getSuperscript() ) { |
| 258 | 308 | $cell_data = "<sup>{$cell_data}</sup>"; |
| @@ -280,9 +330,9 @@ | ||
| 280 | 330 | } |
| 281 | 331 | |
| 282 | 332 | // Convert Hyperlinks to HTML code. |
| 283 | 333 | if ( $cell_has_hyperlink ) { |
| 284 | - $url = esc_url( $worksheet->getHyperlink( $cell_reference )->getUrl() ); | |
| 334 | + $url = $worksheet->getHyperlink( $cell_reference )->getUrl(); | |
| 285 | 335 | if ( '' !== $url ) { |
| 286 | 336 | $title = $worksheet->getHyperlink( $cell_reference )->getTooltip(); |
| 287 | 337 | if ( '' !== $title ) { |
| 288 | 338 | $title = ' title="' . esc_attr( $title ) . '"'; |
| @@ -330,9 +380,9 @@ | ||
| 330 | 380 | $spreadsheet->disconnectWorksheets(); |
| 331 | 381 | unset( $comments, $cell_collection, $worksheet, $spreadsheet ); |
| 332 | 382 | |
| 333 | 383 | return $table; |
| 334 | - } catch ( \TablePress\PhpOffice\PhpSpreadsheet\Reader\Exception $exception ) { | |
| 384 | + } catch ( \TablePress\PhpOffice\PhpSpreadsheet\Reader\Exception | \TablePress\PhpOffice\PhpSpreadsheet\Exception $exception ) { | |
| 335 | 385 | return new WP_Error( 'table_import_phpspreadsheet_failed', '', 'Exception: ' . $exception->getMessage() ); |
| 336 | 386 | } |
| 337 | 387 | } |
| 338 | 388 | |
| @@ -338,12 +388,13 @@ | ||
| 338 | 388 | |
| 339 | 389 | /** |
| 340 | 390 | * Parses PHPSpreadsheet RichText elements and converts formatting to HTML tags. |
| 341 | 391 | * |
| 342 | - * @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. | |
| 343 | 394 | * @return string Cell value with HTML formatting. |
| 344 | 395 | */ |
| 345 | - protected function parse_rich_text( $value ) { | |
| 396 | + protected function parse_rich_text( \TablePress\PhpOffice\PhpSpreadsheet\RichText\RichText $value, bool $cell_has_hyperlink ): string { | |
| 346 | 397 | $cell_data = ''; |
| 347 | 398 | $elements = $value->getRichTextElements(); |
| 348 | 399 | foreach ( $elements as $element ) { |
| 349 | 400 | $element_data = $element->getText(); |
| @@ -370,9 +421,10 @@ | ||
| 370 | 421 | if ( $font->getItalic() ) { |
| 371 | 422 | $element_data = "<em>{$element_data}</em>"; |
| 372 | 423 | } |
| 373 | 424 | $color = $font->getColor()->getRGB(); |
| 374 | - 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. | |
| 375 | 427 | $color_css = esc_attr( "color:#{$color};" ); |
| 376 | 428 | $element_data = "<span style=\"{$color_css}\">{$element_data}</span>"; |
| 377 | 429 | } |
| 378 | 430 | } |
| @@ -388,9 +440,9 @@ | ||
| 388 | 440 | * @param string $value Plain formatted value without color. |
| 389 | 441 | * @param string $format_code Format code. |
| 390 | 442 | * @return string Value with color format applied. |
| 391 | 443 | */ |
| 392 | - public function format_color( $value, $format_code ) { | |
| 444 | + public function format_color( string $value, string $format_code ): string { | |
| 393 | 445 | // Color information, e.g. [Red] is always at the beginning of the format code. |
| 394 | 446 | $color = ''; |
| 395 | 447 | if ( 1 === preg_match( '/^\\[[a-zA-Z]+\\]/', $format_code, $matches ) ) { |
| 396 | 448 | $color = str_replace( array( '[', ']' ), '', $matches[0] ); |