Minor Xml Reader Improvements

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.
This commit is contained in:
oleibman
2026-09-14 07:39:37 -07:00
parent 9e6263ebe8
commit c91bae3fed
8 changed files with 1602 additions and 15 deletions
+18 -3
View File
@@ -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('<xml></xml>')
: ($simple->attributes($node) ?? new SimpleXMLElement('<xml></xml>'));
}
public function setNumberFormatMapping(string $key, string $value): self
{
$this->numberFormatMappings[$key] = $value;
return $this;
}
}
+3 -2
View File
@@ -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;
@@ -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;
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace PhpOffice\PhpSpreadsheetTests\Reader\Xml;
use PhpOffice\PhpSpreadsheet\Reader\Xml;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class BadDateTest extends TestCase
{
#[DataProvider('providerXmlSpreadsheet')]
public function testXml(string $filename): void
{
$reader = new Xml();
$spreadsheet = $reader->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();
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.
+141
View File
@@ -0,0 +1,141 @@
<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:html="http://www.w3.org/TR/REC-html40">
<DocumentProperties xmlns="urn:schemas-microsoft-com:office:office">
<Author>Owen Leibman</Author>
<LastAuthor>Owen Leibman</LastAuthor>
<Created>2026-09-12T08:55:46Z</Created>
<Version>16.00</Version>
</DocumentProperties>
<OfficeDocumentSettings xmlns="urn:schemas-microsoft-com:office:office">
<AllowPNG/>
</OfficeDocumentSettings>
<ExcelWorkbook xmlns="urn:schemas-microsoft-com:office:excel">
<WindowHeight>6510</WindowHeight>
<WindowWidth>19200</WindowWidth>
<WindowTopX>32767</WindowTopX>
<WindowTopY>32767</WindowTopY>
<ProtectStructure>False</ProtectStructure>
<ProtectWindows>False</ProtectWindows>
</ExcelWorkbook>
<Styles>
<Style ss:ID="Default" ss:Name="Normal">
<Alignment ss:Vertical="Bottom"/>
<Borders/>
<Font ss:FontName="Calibri" x:Family="Swiss" ss:Size="11" ss:Color="#000000"/>
<Interior/>
<NumberFormat/>
<Protection/>
</Style>
<Style ss:ID="s62">
<Font ss:FontName="Calibri" x:Family="Swiss" ss:Size="11" ss:Color="#FF0000"/>
<NumberFormat ss:Format="True/False"/>
</Style>
<Style ss:ID="s63">
<Font ss:FontName="Calibri" x:Family="Swiss" ss:Size="11" ss:Color="#FFCC00"/>
<NumberFormat ss:Format="Yes/No"/>
</Style>
<Style ss:ID="s64">
<Font ss:FontName="Calibri" x:Family="Swiss" ss:Size="11" ss:Color="#99CC00"/>
<NumberFormat ss:Format="On/Off"/>
</Style>
<Style ss:ID="s65">
<NumberFormat ss:Format="Long Time"/>
</Style>
<Style ss:ID="s66">
<NumberFormat ss:Format="Percent"/>
</Style>
<Style ss:ID="s67">
<NumberFormat ss:Format="Currency"/>
</Style>
<Style ss:ID="s670">
<NumberFormat ss:Format="Euro Currency"/>
</Style>
<Style ss:ID="s671">
<NumberFormat ss:Format="Scientific"/>
</Style>
<Style ss:ID="s68">
<Interior ss:Color="#FFFF00" ss:Pattern="Solid"/>
<NumberFormat ss:Format="Standard"/>
</Style>
<Style ss:ID="s69">
<Interior ss:Color="#92D050" ss:Pattern="Solid"/>
<NumberFormat ss:Format="Fixed"/>
</Style>
</Styles>
<Worksheet ss:Name="Sheet1">
<Table ss:ExpandedColumnCount="3" ss:ExpandedRowCount="10" x:FullColumns="1"
x:FullRows="1" ss:DefaultRowHeight="14.5">
<Column ss:Index="2" ss:Width="55.5"/>
<Row ss:AutoFitHeight="0">
<Cell><Data ss:Type="String">True/False</Data></Cell>
<Cell ss:StyleID="s62"><Data ss:Type="Number">1</Data></Cell>
<Cell ss:StyleID="s62"><Data ss:Type="Number">0</Data></Cell>
</Row>
<Row ss:AutoFitHeight="0">
<Cell><Data ss:Type="String">Yes/No</Data></Cell>
<Cell ss:StyleID="s63"><Data ss:Type="Number">1</Data></Cell>
<Cell ss:StyleID="s63"><Data ss:Type="Number">0</Data></Cell>
</Row>
<Row ss:AutoFitHeight="0">
<Cell><Data ss:Type="String">On/Off</Data></Cell>
<Cell ss:StyleID="s64"><Data ss:Type="Number">1</Data></Cell>
<Cell ss:StyleID="s64"><Data ss:Type="Number">0</Data></Cell>
</Row>
<Row ss:AutoFitHeight="0">
<Cell><Data ss:Type="String">Long Time</Data></Cell>
<Cell ss:StyleID="s65"><Data ss:Type="DateTime">1899-12-31T18:00:00.000</Data></Cell>
</Row>
<Row ss:AutoFitHeight="0">
<Cell><Data ss:Type="String">Percent</Data></Cell>
<Cell ss:StyleID="s66"><Data ss:Type="Number">0.97</Data></Cell>
</Row>
<Row ss:AutoFitHeight="0">
<Cell><Data ss:Type="String">Currency</Data></Cell>
<Cell ss:StyleID="s67"><Data ss:Type="Number">-1234.56</Data></Cell>
</Row>
<Row ss:AutoFitHeight="0">
<Cell><Data ss:Type="String">Standard</Data></Cell>
<Cell ss:StyleID="s68"><Data ss:Type="Number">1234.57</Data></Cell>
</Row>
<Row ss:AutoFitHeight="0">
<Cell><Data ss:Type="String">Fixed</Data></Cell>
<Cell ss:StyleID="s69"><Data ss:Type="Number">1234.58</Data></Cell>
</Row>
<Row ss:AutoFitHeight="0">
<Cell><Data ss:Type="String">Euro Currency</Data></Cell>
<Cell ss:StyleID="s670"><Data ss:Type="Number">-1234.5899999999999</Data></Cell>
</Row>
<Row ss:AutoFitHeight="0">
<Cell><Data ss:Type="String">Scientific</Data></Cell>
<Cell ss:StyleID="s671"><Data ss:Type="Number">-1234.5899999999999</Data></Cell>
</Row>
</Table>
<WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel">
<PageSetup>
<Header x:Margin="0.3"/>
<Footer x:Margin="0.3"/>
<PageMargins x:Bottom="0.75" x:Left="0.7" x:Right="0.7" x:Top="0.75"/>
</PageSetup>
<Unsynced/>
<Print>
<ValidPrinterInfo/>
<HorizontalResolution>600</HorizontalResolution>
<VerticalResolution>600</VerticalResolution>
</Print>
<Selected/>
<Panes>
<Pane>
<Number>3</Number>
<ActiveRow>10</ActiveRow>
</Pane>
</Panes>
<ProtectObjects>False</ProtectObjects>
<ProtectScenarios>False</ProtectScenarios>
</WorksheetOptions>
</Worksheet>
</Workbook>