diff --git a/src/PhpSpreadsheet/Reader/Xml.php b/src/PhpSpreadsheet/Reader/Xml.php index f5cdc8108..46d8f69f5 100644 --- a/src/PhpSpreadsheet/Reader/Xml.php +++ b/src/PhpSpreadsheet/Reader/Xml.php @@ -39,6 +39,9 @@ class Xml extends BaseReader */ protected array $styles = []; + /** @var string[] */ + private array $numberFormatMappings = Style\NumberFormat::FORMAT_MAPPINGS; + /** * Create a new Excel2003XML Reader instance. */ @@ -301,7 +304,7 @@ class Xml extends BaseReader (new Properties($spreadsheet))->readProperties($xml, $namespaces); - $this->styles = (new Style())->parseStyles($xml, $namespaces); + $this->styles = (new Style())->parseStyles($xml, $namespaces, $this->numberFormatMappings); if (isset($this->styles['Default']) && is_array($this->styles['Default'])) { $spreadsheet->getCellXfCollection()[0]->applyFromArray($this->styles['Default']); } @@ -507,8 +510,13 @@ class Xml extends BaseReader break; case 'DateTime': $type = DataType::TYPE_NUMERIC; - $dateTime = new DateTime($cellValue, new DateTimeZone('UTC')); - $cellValue = Date::PHPToExcel($dateTime); + + try { + $dateTime = new DateTime($cellValue, new DateTimeZone('UTC')); + $cellValue = Date::PHPToExcel($dateTime); + } catch (Throwable) { + $type = DataType::TYPE_STRING; + } break; case 'Error': @@ -755,4 +763,11 @@ class Xml extends BaseReader ? new SimpleXMLElement('') : ($simple->attributes($node) ?? new SimpleXMLElement('')); } + + public function setNumberFormatMapping(string $key, string $value): self + { + $this->numberFormatMappings[$key] = $value; + + return $this; + } } diff --git a/src/PhpSpreadsheet/Reader/Xml/Style.php b/src/PhpSpreadsheet/Reader/Xml/Style.php index 699d71828..f4ab459b8 100644 --- a/src/PhpSpreadsheet/Reader/Xml/Style.php +++ b/src/PhpSpreadsheet/Reader/Xml/Style.php @@ -16,10 +16,11 @@ class Style /** * @param string[] $namespaces + * @param string[] $numberFormatMappings * * @return mixed[] */ - public function parseStyles(SimpleXMLElement $xml, array $namespaces): array + public function parseStyles(SimpleXMLElement $xml, array $namespaces, array $numberFormatMappings = Style\NumberFormat::FORMAT_MAPPINGS): array { $children = $xml->children('urn:schemas-microsoft-com:office:spreadsheet'); $stylesXml = $children->Styles[0]; @@ -70,7 +71,7 @@ class Style break; case 'NumberFormat': if ($styleAttributes) { - $numberFormat = $numberFormatStyleParser->parseStyle($styleAttributes); + $numberFormat = $numberFormatStyleParser->parseStyle($styleAttributes, $numberFormatMappings); } break; diff --git a/src/PhpSpreadsheet/Reader/Xml/Style/NumberFormat.php b/src/PhpSpreadsheet/Reader/Xml/Style/NumberFormat.php index 1ca825a54..d852e66c0 100644 --- a/src/PhpSpreadsheet/Reader/Xml/Style/NumberFormat.php +++ b/src/PhpSpreadsheet/Reader/Xml/Style/NumberFormat.php @@ -2,12 +2,40 @@ namespace PhpOffice\PhpSpreadsheet\Reader\Xml\Style; +use PhpOffice\PhpSpreadsheet\Shared\StringHelper; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat as Format; use SimpleXMLElement; class NumberFormat extends StyleBase { - /** @return mixed[] */ - public function parseStyle(SimpleXMLElement $styleAttributes): array + /** @internal */ + public const FORMAT_MAPPINGS = [ + 'Euro Currency' => '€#,##0.00;[Red](€#,##0.00)', + 'Fixed' => Format::FORMAT_NUMBER_00, + 'General' => '', + 'General Number' => '', + 'General Date' => Format::FORMAT_DATE_DATETIME_BETTER, + 'Long Date' => Format::FORMAT_DATE_LONG_DATE, + 'Long Time' => Format::FORMAT_DATE_TIME2, + 'Medium Date' => Format::FORMAT_DATE_XLSX15_YYYY, + 'Medium Time' => Format::FORMAT_DATE_TIME1, + 'Percent' => Format::FORMAT_PERCENTAGE_00, + 'Scientific' => '0.00E+00', + 'Short Date' => Format::FORMAT_DATE_YYYYMMDD, + 'Short Time' => Format::FORMAT_DATE_TIME1, + 'Standard' => Format::FORMAT_NUMBER_COMMA_SEPARATED1, + // N.B. - following formats apply to numbers, not booleans + 'True/False' => '"True";"True";"False"', + 'Yes/No' => '"Yes";"Yes";"No"', + 'On/Off' => '"On";"On";"Off"', + ]; + + /** + * @param string[] $numberFormatMappings expected to be FORMAT_MAPPINGS, but individual entries can be overridden + * + * @return mixed[] + */ + public function parseStyle(SimpleXMLElement $styleAttributes, array $numberFormatMappings = self::FORMAT_MAPPINGS): array { $style = []; @@ -15,16 +43,16 @@ class NumberFormat extends StyleBase $toFormats = ['-', ' ']; foreach ($styleAttributes as $styleAttributeKey => $styleAttributeValue) { - $styleAttributeValue = str_replace($fromFormats, $toFormats, (string) $styleAttributeValue); - - switch ($styleAttributeValue) { - case 'Short Date': - $styleAttributeValue = 'dd/mm/yyyy'; - - break; + $styleAttributeValue = (string) $styleAttributeValue; + $styleAttributeValue = str_replace($fromFormats, $toFormats, $styleAttributeValue); + if (array_key_exists($styleAttributeValue, $numberFormatMappings)) { + $styleAttributeValue = $numberFormatMappings[$styleAttributeValue]; + } elseif ($styleAttributeValue === 'Currency') { + $currencyCode = StringHelper::getCurrencyCode(); + $styleAttributeValue = "{$currencyCode}#,##0.00;[Red]({$currencyCode}#,##0.00)"; } - if ($styleAttributeValue > '') { + if ($styleAttributeValue !== '') { $style['numberFormat']['formatCode'] = $styleAttributeValue; } } diff --git a/tests/PhpSpreadsheetTests/Reader/Xml/BadDateTest.php b/tests/PhpSpreadsheetTests/Reader/Xml/BadDateTest.php new file mode 100644 index 000000000..d0ad37372 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xml/BadDateTest.php @@ -0,0 +1,87 @@ +load($filename); + $sheet = $spreadsheet->getSheetByNameOrThrow('Sample Data'); + self::assertSame('1960-12-19', $sheet->getCell('A10')->getFormattedValue()); + self::assertSame('1.50', $sheet->getCell('A11')->getFormattedValue()); + self::assertSame('2:30 AM', $sheet->getCell('A13')->getFormattedValue()); + self::assertSame('1960-12-19 01:30', $sheet->getCell('A15')->getFormattedValue()); + $sheet = $spreadsheet->getSheetByNameOrThrow('Report Data'); + self::assertSame('11-Nov-2011', $sheet->getCell('D27')->getFormattedValue()); + self::assertSame('11:00 AM', $sheet->getCell('E27')->getFormattedValue()); + self::assertSame('12-Dec-2012', $sheet->getCell('D28')->getFormattedValue()); + self::assertSame('1899-12-31T288:00:00.000', $sheet->getCell('E28')->getValue(), 'invalid date/time-stamp treated as plain string'); + $spreadsheet->disconnectWorksheets(); + } + + /** @return string[][] */ + public static function providerXmlSpreadsheet(): array + { + return [ + 'spreadsheet with bad datetimestamp and formulas' => ['tests/data/Reader/Xml/Excel2003XMLTest.xml'], + 'spreadsheet with formulas corrected' => ['tests/data/Reader/Xml/Excel2003XMLTest.corrected.xml'], + ]; + } + + public function testFormats(): void + { + $reader = new Xml(); + $reader + ->setNumberFormatMapping('Currency', '¥#,##0;[Red](¥#,##0)') + ->setNumberFormatMapping('Yes/No', '"Oui";"Oui";"Non"') + ->setNumberFormatMapping('On/Off', '"Marche";"Marche";"Arrêt"'); + $filename = 'tests/data/Reader/Xml/formats.xml'; + $spreadsheet = $reader->load($filename); + $sheet = $spreadsheet->getActiveSheet(); + $expected = [ + ['True/False', 'True', 'False'], + ['Yes/No', 'Oui', 'Non'], + ['On/Off', 'Marche', 'Arrêt'], + ['Long Time', '6:00:00 PM', null], + ['Percent', '97.00%', null], + ['Currency', '(¥1,235)', null], + ['Standard', '1,234.57', null], + ['Fixed', '1234.58', null], + ['Euro Currency', '(€1,234.59)', null], + ['Scientific', '-1.23E+3', null], + ]; + self::assertSame($expected, $sheet->toArray()); + $spreadsheet->disconnectWorksheets(); + } + + public function testUtf16(): void + { + $reader = new Xml(); + $filename = 'tests/data/Reader/Xml/formats.utf16lebom.xml'; + $spreadsheet = $reader->load($filename); + $sheet = $spreadsheet->getActiveSheet(); + $expected = [ + ['True/False', 'True', 'False'], + ['Yes/No', 'Yes', 'No'], + ['On/Off', 'On', 'Off'], + ['Long Time', '6:00:00 PM', null], + ['Percent', '97.00%', null], + ['Currency', '($1,234.56)', null], + ['Standard', '1,234.57', null], + ['Fixed', '1234.58', null], + ['Euro Currency', '(€1,234.59)', null], + ['Scientific', '-1.23E+3', null], + ]; + self::assertSame($expected, $sheet->toArray()); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Reader/Xml/Excel2003XMLTest.corrected.xml b/tests/data/Reader/Xml/Excel2003XMLTest.corrected.xml new file mode 100644 index 000000000..f674775b5 --- /dev/null +++ b/tests/data/Reader/Xml/Excel2003XMLTest.corrected.xml @@ -0,0 +1,1315 @@ + + + + +Untitled Spreadsheet +Unknown Creator +Owen Leibman +2026-09-13T08:18:02Z +2026-09-13T08:18:02Z +16.00 + + + + + +6510 +19200 +32767 +32767 +False +False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Test String 1 + + + Test for a simple colour-formatted string + + + + +1 + + +5 + + +A + + +E + + +6 + + +AE + + +PhpSpreadsheet + + + + +Test - String 2 + + +2 + + +6 + + +B + + +F + + +8 + + +BF + + +Dot + + + + +Test #3 + + +3 + + +7 + + +C + + +G + + +10 + + +CG + + +Red + + +Red + + +Dash + + + + +Test with (") in string + + +4 + + +8 + + +D + + +H + + +12 + + +DH + + +Orange + + +Orange + + +Dash/Dot/Dot + + + + +10 + + +26 + + +36 + + +Yellow + + +Yellow + + +Dash/Dot + + + + +Test #3 + + +1.23 + + +1 + + +1 + + +Green + + +Green + + +Thin Line + + + + +Test #3 + + +2.34 + + +0 + + +0 + + +#NAME? + + +Blue + + +Blue + + +Thick Dash/Dot/Dot + + + + +Test #3 + + +3.45 + + +Purple + + +Purple + + +Variant Thick Dash/Dot/Dot + + + + +Pink + + +Pink + + +Thick Dash/Dot + + + + +1960-12-19T00:00:00.000 + + +TOP + + +0 + + +Brown + + +Brown + + +Thick Dash + + + + +1.5 + + +#DIV/0! + + +Thick Line + + + + +BOTTOM + + +Extra Thick Line + + + + +1900-01-01T02:30:00.000 + + +Мойва сушеная + + + Tests for UTF-8 content + + + + +Double Line + + + + +LEFT + + +Ärendetext + + + + +1960-12-19T01:30:00.000 + + +Højde + + + + +RIGHT + + + + +BOX + + +Test Column 1 + + + + + + + +Test Column 2 + + + +Patterned + + +Patterned 2 + + + + + + +Test Column 3 + + + + +Underline None + + +Rotate 90 + + +Rotate 45 + + +Rotate -90 + + +Rotate -45 + + + + + +Underline 1 + + +Subscript + + + + + +Underline 2 + + +Superscript + + + + + +Underline 3 + + + + + +Underline 4 + + + + + + +I don't know if Gnumeric supports + + Rich Text + + in the same way as + + Excel + + , And this row should be autofit height with text wrap + + + + + +PhpSpreadsheet + + + + + + +
+ + +
+