From c91bae3fed220878d623dc6a97877cf815a67e18 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:39:37 -0700 Subject: [PATCH] Minor Xml Reader Improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six years ago, my PR #1557 was merged. It was written mainly to improve code coverage in Xml Reader, but some changes and enhancements were made to the Reader code. I noted in that ticket: "File templates/Excel2003XMLTest.xml, used in some tests, is not readable by a current version of Excel. I have substituted a new file excel2003.xml to be used in its place. I have not deleted the original in case someone in future (possibly me) wants to see what it needs to make it usable." That time has finally arrived. Excel could not open the file. LibreOffice could, and did a pretty decent job, but something seemed wrong with how it handled formulas. PhpSpreadsheet could not read it, because a field identified as a date/timestamp was wrong - it specified a time-of-day hour as `288`. I made a copy of the file, changed the invalid hour to something valid. It turns out that was not Excel's problem - it still could not read the file. But, for the first time that I can recall, Excel produced decent diagnostic messages! They were in a location that you can't get to through Windows Explorer, even with "hide system files" turned off. However, you could get to it through Windows Command Prompt. There were 56 problems, all involving formulas (which was probably LibreOffice's problem). The formulas were all of the form: ``` of:=[.B1]+[.C1] ``` Now, XML spreadsheets use RC format rather than A1 format for its cells. The formula above is of a style that LibreOffice uses, but Excel does not understand it. It is looking for something like: ``` ss:Formula="=RC[-6]+RC[-5] ``` Excel2003Xml doesn't understand the formula in the file, but PhpSpreadsheet does. But, before it could do so, we had to eliminate the Exception when we tried to parse the invalid date/time. So, the first order of business was to wrap the Xml Reader date conversion logic in a try/catch. This allowed PhpSpreadsheet to read the file, and then to save it as an Xlsx file. Then we could open the Xlsx file in Excel, and save it as Xml. Voilà. We have converted the unreadable Xml file to a readable one. LibreOffice can also read the new file, and the formulas are now correct. BTW, I have no idea what was intended on sheet "Report Data" cells G17:G29. They all subtract a value from an empty cell, so the result is always negative, and try to format it as a date (which Excel doesn't like for negative numbers). Whatever that problem is, there is no need to fix it at this time. At this point, a little cleanup was still needed. The Xml file was using some strange named styles, e.g. `Medium Date`. I was able to get a list of these with the google query `excel 2003 ooxml named number formats`. However, the list came only in the AI portion of the response; I could not find a link to anything permanent. So I went with what it showed me. The earlier PR suggested that handling UTF-16 for this format would be difficult. However, saving the now-usable UTF-8 Xml file as UTF-16 with BOM results in a file which PhpSpreadsheet (and Excel and LibreOffice) can read without any code changes. --- src/PhpSpreadsheet/Reader/Xml.php | 21 +- src/PhpSpreadsheet/Reader/Xml/Style.php | 5 +- .../Reader/Xml/Style/NumberFormat.php | 48 +- .../Reader/Xml/BadDateTest.php | 87 ++ .../Reader/Xml/Excel2003XMLTest.corrected.xml | 1315 +++++++++++++++++ .../data/Reader/Xml}/Excel2003XMLTest.xml | 0 tests/data/Reader/Xml/formats.utf16lebom.xml | Bin 0 -> 10182 bytes tests/data/Reader/Xml/formats.xml | 141 ++ 8 files changed, 1602 insertions(+), 15 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Xml/BadDateTest.php create mode 100644 tests/data/Reader/Xml/Excel2003XMLTest.corrected.xml rename {samples/templates => tests/data/Reader/Xml}/Excel2003XMLTest.xml (100%) create mode 100644 tests/data/Reader/Xml/formats.utf16lebom.xml create mode 100644 tests/data/Reader/Xml/formats.xml 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 + + + + + + +
+ + +
+