From d40a1cfa5255df2480ab44add96d77b50c272db5 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 30 Apr 2024 23:51:13 -0700 Subject: [PATCH] Xml Reader Rich Text Fix #4001. Thanks to @SlowFox71 who reported the problem and developed most of the solution. This PR adds Rich Text support to the XML reader. The Xml Spreadsheet stores Rich Text as Html tags, children of the ss:Data tag using a specific namespace. These can be parsed into a RichText object using existing method Helper/Html::toRichTextObject. There are 2 items which need special attention. First, for attributes like bold or italic, Excel uses the appropriate Html tag (e.g. ``). However, for an attribute like color, Excel uses ``, with a prefix on the Color tag. PhpSpreadsheet's Html parser cannot cope with the prefix. The parser is changed to strip `html:` from attribute names for the Font tag. The example cited by the user used a `
` to indicate a line break in the data. However, it appears that, at least some of the time, Excel will instead use ` ` to indicate a line break. The existing parser reduces one or more whitespace characters in the text to a single space, and so ` ` will wind up disappearing. I am not sure why the existing code does this, but I do know that I am not willing to break it. Instead, I've added an optional boolean parameter `$preserveWhiteSpace` to `toRichTextObject`. If false (default), the existing logic will be used; but if true, substitution for whitespace characters in the text will not happen. --- src/PhpSpreadsheet/Helper/Html.php | 21 ++- src/PhpSpreadsheet/Reader/Xml.php | 9 + .../Reader/Xml/XmlRichTextTest.php | 164 ++++++++++++++++++ 3 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Xml/XmlRichTextTest.php diff --git a/src/PhpSpreadsheet/Helper/Html.php b/src/PhpSpreadsheet/Helper/Html.php index 2a9d627bc..a0d272c77 100644 --- a/src/PhpSpreadsheet/Helper/Html.php +++ b/src/PhpSpreadsheet/Helper/Html.php @@ -595,6 +595,8 @@ class Html private RichText $richTextObject; + private bool $preserveWhiteSpace = false; + private function initialise(): void { $this->face = $this->size = $this->color = null; @@ -608,7 +610,7 @@ class Html /** * Parse HTML formatting and return the resulting RichText. */ - public function toRichTextObject(string $html): RichText + public function toRichTextObject(string $html, bool $preserveWhiteSpace = false): RichText { $this->initialise(); @@ -622,7 +624,9 @@ class Html $dom->preserveWhiteSpace = false; $this->richTextObject = new RichText(); + $this->preserveWhiteSpace = $preserveWhiteSpace; $this->parseElements($dom); + $this->preserveWhiteSpace = false; // Clean any further spurious whitespace $this->cleanWhitespace(); @@ -706,6 +710,7 @@ class Html if ($attrs !== null) { foreach ($attrs as $attribute) { $attributeName = strtolower($attribute->name); + $attributeName = preg_replace('/^html:/', '', $attributeName) ?? $attributeName; // in case from Xml spreadsheet $attributeValue = $attribute->value; if ($attributeName == 'color') { @@ -795,11 +800,15 @@ class Html private function parseTextNode(DOMText $textNode): void { - $domText = (string) preg_replace( - '/\s+/u', - ' ', - str_replace(["\r", "\n"], ' ', $textNode->nodeValue ?? '') - ); + if ($this->preserveWhiteSpace) { + $domText = $textNode->nodeValue ?? ''; + } else { + $domText = (string) preg_replace( + '/\s+/u', + ' ', + str_replace(["\r", "\n"], ' ', $textNode->nodeValue ?? '') + ); + } $this->stringData .= $domText; $this->buildTextRun(); } diff --git a/src/PhpSpreadsheet/Reader/Xml.php b/src/PhpSpreadsheet/Reader/Xml.php index 5199c44e9..cfeb2ab69 100644 --- a/src/PhpSpreadsheet/Reader/Xml.php +++ b/src/PhpSpreadsheet/Reader/Xml.php @@ -8,6 +8,7 @@ use PhpOffice\PhpSpreadsheet\Cell\AddressHelper; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\DefinedName; +use PhpOffice\PhpSpreadsheet\Helper\Html as HelperHtml; use PhpOffice\PhpSpreadsheet\Reader\Security\XmlScanner; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces; use PhpOffice\PhpSpreadsheet\Reader\Xml\PageSettings; @@ -426,6 +427,14 @@ class Xml extends BaseReader */ case 'String': $type = DataType::TYPE_STRING; + $rich = $cellData->children('http://www.w3.org/TR/REC-html40'); + if ($rich) { + // in case of HTML content we extract the payload + // and convert it into a rich text object + $content = $cellData->asXML() ?: ''; + $html = new HelperHtml(); + $cellValue = $html->toRichTextObject($content, true); + } break; case 'Number': diff --git a/tests/PhpSpreadsheetTests/Reader/Xml/XmlRichTextTest.php b/tests/PhpSpreadsheetTests/Reader/Xml/XmlRichTextTest.php new file mode 100644 index 000000000..08654a449 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xml/XmlRichTextTest.php @@ -0,0 +1,164 @@ + + + + + + + + italicbold
second line
+
+
+
+
+
+ EOT; + $reader = new Xml(); + $spreadsheet = $reader->loadSpreadsheetFromString($xmldata); + self::assertEquals(1, $spreadsheet->getSheetCount()); + + $sheet = $spreadsheet->getActiveSheet(); + self::assertEquals('Test', $sheet->getTitle()); + $richText = $sheet->getCell('A1')->getValue(); + self::assertInstanceOf(RichText::class, $richText); + $elements = $richText->getRichTextElements(); + self::assertCount(3, $elements); + $run = $elements[0]; + self::assertInstanceOf(Run::class, $run); + self::assertSame('italic', $run->getText()); + self::assertNotNull($run->getFont()); + self::assertTrue($run->getFont()->getItalic()); + self::assertFalse($run->getFont()->getBold()); + + $run = $elements[1]; + self::assertInstanceOf(Run::class, $run); + self::assertSame('bold', $run->getText()); + self::assertNotNull($run->getFont()); + self::assertFalse($run->getFont()->getItalic()); + self::assertTrue($run->getFont()->getBold()); + + $run = $elements[2]; + self::assertInstanceOf(Run::class, $run); + self::assertSame("\nsecond line", $run->getText()); + + $spreadsheet->disconnectWorksheets(); + } + + public function testNewlineAndFontTag(): void + { + $xmldata = <<< 'EOT' + + + + + Owen Leibman + 2024-04-28T06:03:14Z + 16.00 + + + + + + 6510 + 19200 + 32767 + 32767 + False + False + + + + + + + + + italicbold second line + +
+ + + + False + False + +
+
+ EOT; + $reader = new Xml(); + $spreadsheet = $reader->loadSpreadsheetFromString($xmldata); + self::assertEquals(1, $spreadsheet->getSheetCount()); + + $sheet = $spreadsheet->getActiveSheet(); + self::assertEquals('Test', $sheet->getTitle()); + $richText = $sheet->getCell('A1')->getValue(); + self::assertInstanceOf(RichText::class, $richText); + $elements = $richText->getRichTextElements(); + self::assertCount(4, $elements); + $run = $elements[0]; + self::assertInstanceOf(Run::class, $run); + self::assertSame('italic', $run->getText()); + self::assertNotNull($run->getFont()); + self::assertTrue($run->getFont()->getItalic()); + self::assertFalse($run->getFont()->getBold()); + + $run = $elements[1]; + self::assertInstanceOf(Run::class, $run); + self::assertSame("bold\n", $run->getText()); + self::assertNotNull($run->getFont()); + self::assertFalse($run->getFont()->getItalic()); + self::assertTrue($run->getFont()->getBold()); + + $run = $elements[2]; + self::assertInstanceOf(Run::class, $run); + self::assertSame('second', $run->getText()); + self::assertSame('FF0000', $run->getFont()?->getColor()->getRgb()); + + $run = $elements[3]; + self::assertInstanceOf(Run::class, $run); + self::assertSame(' line', $run->getText()); + + $spreadsheet->disconnectWorksheets(); + } +}