Html Writer/Reader New data Attributes

Html Reader recognizes a `data-format` attribute for storing the number format associated with a cell. However, Html Writer does not set this attribute. This PR offers a new Writer property `preserveFormatAndValue` and setter; when `true`, for any cell which uses a number format other than the default `General` the writer will generate a `data-format` attribute and, for strings or numbers, it will also generate a `data-value` attribute. This will increase the accuracy of the spreadsheet when reading/writing Html.

To make this change less abstract, assume a cell which has a value of `1234` and a number format of `$#,###.00`. The cell displayed in the generated Html will appear, regardless of the setting of the new property, as `$1,234.00`. However, the Reader currently treats it as a string with that value. If the html has been generated with the new property set to `true`, the Reader will treat it as a numeric cell with a value of `1234` and a style of `#,###.00`. This permits a more accurate reproduction of the original spreadsheet.

I suspect that setting the new property, aside from causing a slight increase in file size, will not cause any significant breaks. However, I'm not totally convinced, so the property will default to `false` (no change in behavior). The default may change in a future breaking release.
This commit is contained in:
oleibman
2026-04-12 09:34:43 -07:00
parent 9b90dee03d
commit ed82b8b30b
4 changed files with 239 additions and 39 deletions
+11 -3
View File
@@ -340,13 +340,21 @@ class Html extends BaseReader
try {
if (isset($attributeArray['data-formula'])) {
$sheet->setCellValueExplicit($column . $row, $attributeArray['data-formula'], DataType::TYPE_FORMULA);
$sheet->setCellValueExplicit(
$column . $row,
$attributeArray['data-formula'],
DataType::TYPE_FORMULA
);
$sheet->getCell($column . $row)
->setCalculatedValue(
$cellContent
);
} else {
$sheet->setCellValueExplicit($column . $row, $cellContent, $attributeArray['data-type']);
$sheet->setCellValueExplicit(
$column . $row,
$attributeArray['data-value'] ?? $cellContent,
$attributeArray['data-type']
);
}
} catch (SpreadsheetException) {
$sheet->setCellValue($column . $row, $cellContent);
@@ -357,7 +365,7 @@ class Html extends BaseReader
if ($sheet->hyperlinkExists($column . $row)) {
$hyperlink = $sheet->getHyperlink($column . $row);
}
$sheet->setCellValue($column . $row, $cellContent);
$sheet->setCellValue($column . $row, $attributeArray['data-value'] ?? $cellContent);
$sheet->setHyperlink($column . $row, $hyperlink);
}
$this->dataArray[$row][$column] = $cellContent; // @phpstan-ignore-line
+37 -6
View File
@@ -67,12 +67,12 @@ class Html extends BaseWriter
/**
* Sheet index to write.
*/
private ?int $sheetIndex = 0;
protected ?int $sheetIndex = 0;
/**
* Images root.
*/
private string $imagesRoot = '';
protected string $imagesRoot = '';
/**
* embed images, or link to images.
@@ -105,6 +105,15 @@ class Html extends BaseWriter
return $this;
}
protected bool $preserveFormatAndValue = false;
public function setPreserveFormatAndValue(bool $preserveFormatAndValue): self
{
$this->preserveFormatAndValue = $preserveFormatAndValue;
return $this;
}
/**
* Use inline CSS?
*/
@@ -1714,6 +1723,9 @@ class Html extends BaseWriter
$html .= ' data-checkbox="1"';
}
$dataType = $worksheet->getCell($coordinate)->getDataType();
$numberFormat = $worksheet->getStyle($coordinate)
->getNumberFormat()
->getFormatCode() ?? NumberFormat::FORMAT_GENERAL;
if ($this->betterBoolean) {
if ($dataType === DataType::TYPE_BOOL) {
$html .= ' data-type="' . DataType::TYPE_BOOL . '"';
@@ -1732,13 +1744,31 @@ class Html extends BaseWriter
} catch (CalculationException) {
$html .= ' data-type="' . DataType::TYPE_ERROR . '"';
}
} elseif (is_numeric($cellData) && $worksheet->getCell($coordinate)->getDataType() === DataType::TYPE_STRING) {
} elseif ((is_numeric($cellData) || $this->preserveFormatAndValue) && $worksheet->getCell($coordinate)->getDataType() === DataType::TYPE_STRING) {
$html .= ' data-type="' . DataType::TYPE_STRING . '"';
} elseif ($dataType === DataType::TYPE_NUMERIC && $this->preserveFormatAndValue && $numberFormat !== NumberFormat::FORMAT_GENERAL) {
$html .= ' data-type="' . DataType::TYPE_NUMERIC . '"';
}
}
if ($dataType === DataType::TYPE_FORMULA && $this->dataFormula) {
if ($this->preCalculateFormulas) {
$html .= ' data-formula="'
if ($this->preserveFormatAndValue) {
if ($numberFormat !== NumberFormat::FORMAT_GENERAL) {
$html .= ' data-format="' . htmlspecialchars($numberFormat) . '"';
}
}
if ($dataType === DataType::TYPE_FORMULA) {
if ($this->dataFormula) {
if ($this->preCalculateFormulas) {
$html .= ' data-formula="'
. htmlspecialchars(
$worksheet->getCell($coordinate)
->getValueString()
)
. '"';
}
}
} elseif ($dataType === DataType::TYPE_NUMERIC || $dataType === DataType::TYPE_STRING) {
if ($this->preserveFormatAndValue && $numberFormat !== NumberFormat::FORMAT_GENERAL) {
$html .= ' data-value="'
. htmlspecialchars(
$worksheet->getCell($coordinate)
->getValueString()
@@ -1746,6 +1776,7 @@ class Html extends BaseWriter
. '"';
}
}
$holdCss = '';
if (!$this->useInlineCss && !$this->isPdf && is_string($cssClass)) {
$html .= ' class="' . $cssClass . '"';
@@ -210,27 +210,6 @@ class HtmlTest extends TestCase
$spreadsheet->disconnectWorksheets();
}
public function testCanApplyInlineDataFormat(): void
{
$html = '<table>
<tr>
<td data-format="mmm-yy">2019-02-02 12:34:00</td>
<td data-format="#.000">3</td>
<td data-format="#.000">x</td>
</tr>
</table>';
$spreadsheet = HtmlHelper::loadHtmlStringIntoSpreadsheet($html);
$sheet = $spreadsheet->getSheet(0);
self::assertEquals('mmm-yy', $sheet->getStyle('A1')->getNumberFormat()->getFormatCode());
self::assertEquals('2019-02-02 12:34:00', $sheet->getCell('A1')->getFormattedValue(), 'field is string not number so not formatted');
self::assertEquals('#.000', $sheet->getStyle('B1')->getNumberFormat()->getFormatCode());
self::assertEquals('3.000', $sheet->getCell('B1')->getFormattedValue(), 'format applied to numeric value');
self::assertEquals('#.000', $sheet->getStyle('C1')->getNumberFormat()->getFormatCode());
self::assertEquals('x', $sheet->getCell('C1')->getFormattedValue(), 'format not applied to non-numeric value');
$spreadsheet->disconnectWorksheets();
}
public function testCanApplyCellWrapping(): void
{
$html = '<table>
@@ -289,7 +268,7 @@ class HtmlTest extends TestCase
$spreadsheet = HtmlHelper::loadHtmlStringIntoSpreadsheet($html);
$firstSheet = $spreadsheet->getSheet(0);
$style = $firstSheet->getCell('C2')->getStyle();
self::assertEquals(1, $style->getAlignment()->getIndent());
self::assertSame(1, $style->getAlignment()->getIndent());
$spreadsheet->disconnectWorksheets();
}
@@ -320,7 +299,7 @@ class HtmlTest extends TestCase
];
foreach ($totalBorders as $border) {
self::assertEquals(Border::BORDER_THIN, $border->getBorderStyle());
self::assertSame(Border::BORDER_THIN, $border->getBorderStyle());
}
$spreadsheet->disconnectWorksheets();
}
@@ -352,7 +331,7 @@ class HtmlTest extends TestCase
];
foreach ($totalBorders as $border) {
self::assertEquals(Border::BORDER_THIN, $border->getBorderStyle());
self::assertSame(Border::BORDER_THIN, $border->getBorderStyle());
}
$spreadsheet->disconnectWorksheets();
}
@@ -377,28 +356,32 @@ class HtmlTest extends TestCase
$firstSheet = $spreadsheet->getSheet(0);
// check boolean data type and true
self::assertEquals(DataType::TYPE_BOOL, $firstSheet->getCell('A1')->getDataType());
self::assertSame(DataType::TYPE_BOOL, $firstSheet->getCell('A1')->getDataType());
self::assertIsBool($firstSheet->getCell('A1')->getValue());
self::assertTrue($firstSheet->getCell('A1')->getValue());
// check string data type
self::assertEquals(DataType::TYPE_STRING, $firstSheet->getCell('B1')->getDataType());
self::assertSame(DataType::TYPE_STRING, $firstSheet->getCell('B1')->getDataType());
self::assertIsString($firstSheet->getCell('B1')->getValue());
// check string with beginning equal sign (=B1) and string datatype,is not formula
self::assertEquals(DataType::TYPE_STRING, $firstSheet->getCell('C1')->getDataType());
self::assertEquals('=B1', $firstSheet->getCell('C1')->getValue());
self::assertSame(DataType::TYPE_STRING, $firstSheet->getCell('C1')->getDataType());
self::assertSame('=B1', $firstSheet->getCell('C1')->getValue());
self::assertTrue($firstSheet->getCell('C1')->getStyle()->getQuotePrefix());
//check iso date
self::assertEqualsWithDelta($firstSheet->getCell('D1')->getValue(), 44613.43090277778, 1.0e-12);
//null
self::assertEquals($firstSheet->getCell('E1')->getValue(), null);
self::assertNull($firstSheet->getCell('E1')->getValue());
// check boolean data type and true
self::assertEquals(DataType::TYPE_BOOL, $firstSheet->getCell('F1')->getDataType());
self::assertSame(DataType::TYPE_BOOL, $firstSheet->getCell('F1')->getDataType());
self::assertIsBool($firstSheet->getCell('F1')->getValue());
self::assertFalse($firstSheet->getCell('F1')->getValue());
self::assertSame(DataType::TYPE_STRING, $firstSheet->getCell('G1')->getDataType());
self::assertSame('text with invalid datatype', $firstSheet->getCell('G1')->getValue());
$spreadsheet->disconnectWorksheets();
}
}
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace PhpOffice\PhpSpreadsheetTests\Reader\Html;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Html as HtmlWriter;
use PHPUnit\Framework\TestCase;
class PreserveFormatTest extends TestCase
{
public function testCanApplyInlineDataFormat(): void
{
$html = '<table>
<tr>
<td data-format="mmm-yy">2019-02-02 12:34:00</td>
<td data-format="#.000">3</td>
<td data-format="#.000">x</td>
<td data-format="$#,###.00" data-value="1234">$1,234.00</td>
</tr>
</table>';
$spreadsheet = HtmlHelper::loadHtmlStringIntoSpreadsheet($html);
$sheet = $spreadsheet->getSheet(0);
self::assertSame('mmm-yy', $sheet->getStyle('A1')->getNumberFormat()->getFormatCode());
self::assertSame('2019-02-02 12:34:00', $sheet->getCell('A1')->getFormattedValue(), 'field is string not number so not formatted');
self::assertSame('#.000', $sheet->getStyle('B1')->getNumberFormat()->getFormatCode());
self::assertSame('3.000', $sheet->getCell('B1')->getFormattedValue(), 'format applied to numeric value');
self::assertSame('#.000', $sheet->getStyle('C1')->getNumberFormat()->getFormatCode());
self::assertSame('x', $sheet->getCell('C1')->getFormattedValue(), 'format not applied to non-numeric value');
self::assertSame('$#,###.00', $sheet->getStyle('D1')->getNumberFormat()->getFormatCode());
self::assertSame('$1,234.00', $sheet->getCell('D1')->getFormattedValue());
self::assertSame(1234, $sheet->getCell('D1')->getValue());
$spreadsheet->disconnectWorksheets();
}
public static function testPreserve(): void
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$payload = '<img src=x onerror=alert(document.domain)>';
$formatCode = '@';
$sheet->setCellValue('A1', $payload);
$sheet->getStyle('A1')
->getNumberFormat()
->setFormatCode($formatCode);
$sheet->setCellValue('A2', 3.00);
$sheet->setCellValue('A3', 3.00);
$sheet->getStyle('A3')
->getNumberFormat()
->setFormatCode('0.00');
$formatCode = '@ <"items">';
$sheet->setCellValue('B1', $payload);
$sheet->getStyle('B1')
->getNumberFormat()
->setFormatCode($formatCode);
$sheet->setCellValue('B2', 1234);
$sheet->getStyle('B2')
->getNumberFormat()
->setFormatCode('$#,###.00');
$sheet->setCellValue('B3', '=2*5');
$sheet->getStyle('B3')
->getNumberFormat()
->setFormatCode('0.0');
$writer = new HtmlWriter($spreadsheet);
$writer->setPreserveFormatAndValue(true)
->setDataFormula(true);
$html = $writer->generateHtmlAll();
$spreadsheet->disconnectWorksheets();
$expected = [
'A1' => '<td data-type="s" data-format="@" data-value="&lt;img src=x onerror=alert(document.domain)&gt;" class="column0 style1 s">&lt;img src=x onerror=alert(document.domain)&gt;</td>',
'B1' => '<td data-type="s" data-format="@ &lt;&quot;items&quot;&gt;" data-value="&lt;img src=x onerror=alert(document.domain)&gt;" class="column1 style3 s">&lt;img src=x onerror=alert(document.domain)&gt; &lt;items&gt;</td>',
'A2' => '<td class="column0 style0 n">3</td>',
'B2' => '<td data-type="n" data-format="$#,###.00" data-value="1234" class="column1 style4 n">$1,234.00</td>',
'C1' => '<td data-type="n" data-format="0.00" data-value="3" class="column0 style2 n">3.00</td>',
'C2' => '<td data-type="n" data-format="0.0" data-formula="=2*5" class="column1 style5 n">10.0</td>',
];
foreach ($expected as $key => $value) {
self::assertStringContainsString($value, $html, "Cell $key");
}
$spreadsheet2 = HtmlHelper::loadHtmlStringIntoSpreadsheet($html);
$sheet2 = $spreadsheet2->getActiveSheet();
self::assertSame('@', $sheet2->getStyle('A1')->getNumberFormat()->getFormatCode());
self::assertSame($payload, $sheet2->getCell('A1')->getValue());
self::assertSame($payload, $sheet2->getCell('A1')->getFormattedValue());
self::assertSame('@ <"items">', $sheet2->getStyle('B1')->getNumberFormat()->getFormatCode());
self::assertSame($payload, $sheet2->getCell('B1')->getValue());
self::assertSame($payload . ' <items>', $sheet2->getCell('B1')->getFormattedValue());
self::assertSame('General', $sheet2->getStyle('A2')->getNumberFormat()->getFormatCode());
self::assertSame(3, $sheet2->getCell('A2')->getValue());
self::assertSame('3', $sheet2->getCell('A2')->getFormattedValue());
self::assertSame('$#,###.00', $sheet2->getStyle('B2')->getNumberFormat()->getFormatCode());
self::assertSame(1234, $sheet2->getCell('B2')->getValue());
self::assertSame('$1,234.00', $sheet2->getCell('B2')->getFormattedValue());
self::assertSame('0.00', $sheet2->getStyle('A3')->getNumberFormat()->getFormatCode());
self::assertSame(3, $sheet2->getCell('A3')->getValue());
self::assertSame('3.00', $sheet2->getCell('A3')->getFormattedValue());
self::assertSame('0.0', $sheet2->getStyle('B3')->getNumberFormat()->getFormatCode());
self::assertSame('=2*5', $sheet2->getCell('B3')->getValue());
self::assertSame('10.0', $sheet2->getCell('B3')->getFormattedValue());
$spreadsheet2->disconnectWorksheets();
}
public static function testNoPreserve(): void
{
// Same as above, without preserveFormatAndValue
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$payload = '<img src=x onerror=alert(document.domain)>';
$formatCode = '@';
$sheet->setCellValue('A1', $payload);
$sheet->getStyle('A1')
->getNumberFormat()
->setFormatCode($formatCode);
$sheet->setCellValue('A2', 3.00);
$sheet->setCellValue('A3', 3.00);
$sheet->getStyle('A3')
->getNumberFormat()
->setFormatCode('0.00');
$formatCode = '@ <"items">';
$sheet->setCellValue('B1', $payload);
$sheet->getStyle('B1')
->getNumberFormat()
->setFormatCode($formatCode);
$sheet->setCellValue('B2', 1234);
$sheet->getStyle('B2')
->getNumberFormat()
->setFormatCode('$#,###.00');
$sheet->setCellValue('B3', '=2*5');
$sheet->getStyle('B3')
->getNumberFormat()
->setFormatCode('0.0');
$writer = new HtmlWriter($spreadsheet);
$html = $writer->generateHtmlAll();
$spreadsheet->disconnectWorksheets();
$expected = [
'A1' => '<td class="column0 style1 s">&lt;img src=x onerror=alert(document.domain)&gt;</td>',
'B1' => '<td class="column1 style3 s">&lt;img src=x onerror=alert(document.domain)&gt; &lt;items&gt;</td>',
'A2' => '<td class="column0 style0 n">3</td>',
'B2' => '<td class="column1 style4 n">$1,234.00</td>',
'C1' => '<td class="column0 style2 n">3.00</td>',
'C2' => '<td class="column1 style5 n">10.0</td>',
];
foreach ($expected as $key => $value) {
self::assertStringContainsString($value, $html, "Cell $key");
}
$spreadsheet2 = HtmlHelper::loadHtmlStringIntoSpreadsheet($html);
$sheet2 = $spreadsheet2->getActiveSheet();
self::assertSame('General', $sheet2->getStyle('A1')->getNumberFormat()->getFormatCode());
self::assertSame($payload, $sheet2->getCell('A1')->getValue());
self::assertSame($payload, $sheet2->getCell('A1')->getFormattedValue());
self::assertSame('General', $sheet2->getStyle('B1')->getNumberFormat()->getFormatCode());
self::assertSame($payload . ' <items>', $sheet2->getCell('B1')->getValue());
self::assertSame($payload . ' <items>', $sheet2->getCell('B1')->getFormattedValue());
self::assertSame('General', $sheet2->getStyle('A2')->getNumberFormat()->getFormatCode());
self::assertSame(3, $sheet2->getCell('A2')->getValue());
self::assertSame('3', $sheet2->getCell('A2')->getFormattedValue());
self::assertSame('General', $sheet2->getStyle('B2')->getNumberFormat()->getFormatCode());
self::assertSame('$1,234.00', $sheet2->getCell('B2')->getValue());
self::assertSame('$1,234.00', $sheet2->getCell('B2')->getFormattedValue());
self::assertSame('General', $sheet2->getStyle('A3')->getNumberFormat()->getFormatCode());
self::assertSame(3.00, $sheet2->getCell('A3')->getValue());
self::assertSame('3', $sheet2->getCell('A3')->getFormattedValue());
self::assertSame('General', $sheet2->getStyle('B3')->getNumberFormat()->getFormatCode());
self::assertSame(10.0, $sheet2->getCell('B3')->getValue());
self::assertSame('10', $sheet2->getCell('B3')->getFormattedValue());
$spreadsheet2->disconnectWorksheets();
}
}