From 21263a0730fa9564940ee2cc4ab5b740ff140052 Mon Sep 17 00:00:00 2001 From: Adrien Loison Date: Fri, 7 Aug 2015 08:25:04 -0700 Subject: [PATCH] Add support for styling Added top level methods on the Writer: - addRowWithStyle() - addRowsWithStyle() Added a style builder, to easily create new styles. Each writer can specify its own default style and all styles will automatically inherit from it. For now, the style properties supported are: - bold - italic - underline - strikethrough - font size - font name - wrap text (alignment) --- src/Spout/Writer/AbstractWriter.php | 108 +++++- src/Spout/Writer/CSV/Writer.php | 3 +- src/Spout/Writer/Style/Style.php | 277 ++++++++++++++ src/Spout/Writer/Style/StyleBuilder.php | 113 ++++++ src/Spout/Writer/WriterInterface.php | 26 ++ .../Writer/XLSX/Helper/FileSystemHelper.php | 17 + src/Spout/Writer/XLSX/Helper/StyleHelper.php | 230 ++++++++++++ src/Spout/Writer/XLSX/Internal/Workbook.php | 25 +- src/Spout/Writer/XLSX/Internal/Worksheet.php | 6 +- src/Spout/Writer/XLSX/Writer.php | 25 +- tests/Spout/Writer/Style/StyleTest.php | 132 +++++++ .../Writer/XLSX/Helper/StyleHelperTest.php | 59 +++ tests/Spout/Writer/XLSX/WriterTest.php | 8 +- .../Spout/Writer/XLSX/WriterWithStyleTest.php | 337 ++++++++++++++++++ 14 files changed, 1344 insertions(+), 22 deletions(-) create mode 100644 src/Spout/Writer/Style/Style.php create mode 100644 src/Spout/Writer/Style/StyleBuilder.php create mode 100644 src/Spout/Writer/XLSX/Helper/StyleHelper.php create mode 100644 tests/Spout/Writer/Style/StyleTest.php create mode 100644 tests/Spout/Writer/XLSX/Helper/StyleHelperTest.php create mode 100644 tests/Spout/Writer/XLSX/WriterWithStyleTest.php diff --git a/src/Spout/Writer/AbstractWriter.php b/src/Spout/Writer/AbstractWriter.php index e17e16a..00adce7 100644 --- a/src/Spout/Writer/AbstractWriter.php +++ b/src/Spout/Writer/AbstractWriter.php @@ -5,6 +5,7 @@ namespace Box\Spout\Writer; use Box\Spout\Common\Exception\IOException; use Box\Spout\Common\Exception\InvalidArgumentException; use Box\Spout\Writer\Exception\WriterNotOpenedException; +use Box\Spout\Writer\Style\StyleBuilder; /** * Class AbstractWriter @@ -26,6 +27,12 @@ abstract class AbstractWriter implements WriterInterface /** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */ protected $globalFunctionsHelper; + /** @var Style\Style Style to be applied to the next written row(s) */ + protected $rowStyle; + + /** @var Style\Style Default row style. Each writer can have its own default style */ + protected $defaultRowStyle; + /** @var string Content-Type value for the header - to be defined by child class */ protected static $headerContentType; @@ -39,13 +46,13 @@ abstract class AbstractWriter implements WriterInterface /** * Adds data to the currently openned writer. - * The data must be UTF-8 encoded. * * @param array $dataRow Array containing data to be streamed. * Example $dataRow = ['data1', 1234, null, '', 'data5']; + * @param Style\Style $style Style to be applied to the written row * @return void */ - abstract protected function addRowToWriter(array $dataRow); + abstract protected function addRowToWriter(array $dataRow, $style); /** * Closes the streamer, preventing any additional writing. @@ -55,7 +62,16 @@ abstract class AbstractWriter implements WriterInterface abstract protected function closeWriter(); /** - * @param $globalFunctionsHelper + * + */ + public function __construct() + { + $this->defaultRowStyle = $this->getDefaultRowStyle(); + $this->resetRowStyleToDefault(); + } + + /** + * @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper * @return AbstractWriter */ public function setGlobalFunctionsHelper($globalFunctionsHelper) @@ -138,7 +154,6 @@ abstract class AbstractWriter implements WriterInterface /** * Write given data to the output. New data will be appended to end of stream. - * The data must be UTF-8 encoded. * * @param array $dataRow Array containing data to be streamed. * If empty, no data is added (i.e. not even as a blank row) @@ -153,7 +168,7 @@ abstract class AbstractWriter implements WriterInterface if ($this->isWriterOpened) { // empty $dataRow should not add an empty line if (!empty($dataRow)) { - $this->addRowToWriter($dataRow); + $this->addRowToWriter($dataRow, $this->rowStyle); } } else { throw new WriterNotOpenedException('The writer needs to be opened before adding row.'); @@ -162,9 +177,32 @@ abstract class AbstractWriter implements WriterInterface return $this; } + /** + * Write given data to the output and apply the given style. + * @see addRow + * + * @param array $dataRow Array of array containing data to be streamed. + * @param Style\Style $style Style to be applied to the row. + * @return AbstractWriter + * @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid + * @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer + * @throws \Box\Spout\Common\Exception\IOException If unable to write data + */ + public function addRowWithStyle(array $dataRow, $style) + { + if (!$style instanceof Style\Style) { + throw new InvalidArgumentException('The "$style" argument must be a Style instance and cannot be NULL.'); + } + + $this->setRowStyle($style); + $this->addRow($dataRow); + $this->resetRowStyleToDefault(); + + return $this; + } + /** * Write given data to the output. New data will be appended to end of stream. - * The data must be UTF-8 encoded. * * @param array $dataRows Array of array containing data to be streamed. * If a row is empty, it won't be added (i.e. not even as a blank row) @@ -193,6 +231,64 @@ abstract class AbstractWriter implements WriterInterface return $this; } + /** + * Write given data to the output and apply the given style. + * @see addRows + * + * @param array $dataRows Array of array containing data to be streamed. + * @param Style\Style $style Style to be applied to the rows. + * @return AbstractWriter + * @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid + * @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer + * @throws \Box\Spout\Common\Exception\IOException If unable to write data + */ + public function addRowsWithStyle(array $dataRows, $style) + { + if (!$style instanceof Style\Style) { + throw new InvalidArgumentException('The "$style" argument must be a Style instance and cannot be NULL.'); + } + + $this->setRowStyle($style); + $this->addRows($dataRows); + $this->resetRowStyleToDefault(); + + return $this; + } + + /** + * Returns the default style to be applied to rows. + * Can be overriden by children to have a custom style. + * + * @return Style\Style + */ + protected function getDefaultRowStyle() + { + return (new StyleBuilder())->build(); + } + + /** + * Sets the style to be applied to the next written rows + * until it is changed or reset. + * + * @param Style\Style $style + * @return void + */ + private function setRowStyle($style) + { + // Merge given style with the default one to inherit custom properties + $this->rowStyle = $style->mergeWith($this->defaultRowStyle); + } + + /** + * Resets the style to be applied to the next written rows. + * + * @return void + */ + private function resetRowStyleToDefault() + { + $this->rowStyle = $this->defaultRowStyle; + } + /** * Closes the writer. This will close the streamer as well, preventing new data * to be written to the file. diff --git a/src/Spout/Writer/CSV/Writer.php b/src/Spout/Writer/CSV/Writer.php index d008096..0ce4f39 100644 --- a/src/Spout/Writer/CSV/Writer.php +++ b/src/Spout/Writer/CSV/Writer.php @@ -69,10 +69,11 @@ class Writer extends AbstractWriter * * @param array $dataRow Array containing data to be written. * Example $dataRow = ['data1', 1234, null, '', 'data5']; + * @param \Box\Spout\Writer\Style\Style $style Ignored here since CSV does not support styling. * @return void * @throws \Box\Spout\Common\Exception\IOException If unable to write data */ - protected function addRowToWriter(array $dataRow) + protected function addRowToWriter(array $dataRow, $style) { $wasWriteSuccessful = $this->globalFunctionsHelper->fputcsv($this->filePointer, $dataRow, $this->fieldDelimiter, $this->fieldEnclosure); if ($wasWriteSuccessful === false) { diff --git a/src/Spout/Writer/Style/Style.php b/src/Spout/Writer/Style/Style.php new file mode 100644 index 0000000..6aafee6 --- /dev/null +++ b/src/Spout/Writer/Style/Style.php @@ -0,0 +1,277 @@ +id; + } + + /** + * @param int $id + * @return Style + */ + public function setId($id) + { + $this->id = $id; + return $this; + } + + /** + * @return boolean + */ + public function isFontBold() + { + return $this->fontBold; + } + + /** + * @return Style + */ + public function setFontBold() + { + $this->fontBold = true; + $this->hasSetFontBold = true; + $this->shouldApplyFont = true; + return $this; + } + + /** + * @return boolean + */ + public function isFontItalic() + { + return $this->fontItalic; + } + + /** + * @return Style + */ + public function setFontItalic() + { + $this->fontItalic = true; + $this->hasSetFontItalic = true; + $this->shouldApplyFont = true; + return $this; + } + + /** + * @return boolean + */ + public function isFontUnderline() + { + return $this->fontUnderline; + } + + /** + * @return Style + */ + public function setFontUnderline() + { + $this->fontUnderline = true; + $this->hasSetFontUnderline = true; + $this->shouldApplyFont = true; + return $this; + } + + /** + * @return boolean + */ + public function isFontStrikeThrough() + { + return $this->fontStrikeThrough; + } + + /** + * @return Style + */ + public function setFontStrikeThrough() + { + $this->fontStrikeThrough = true; + $this->hasSetFontStrikeThrough = true; + $this->shouldApplyFont = true; + return $this; + } + + /** + * @return int + */ + public function getFontSize() + { + return $this->fontSize; + } + + /** + * @param int $fontSize Font size, in pixels + * @return Style + */ + public function setFontSize($fontSize) + { + $this->fontSize = $fontSize; + $this->hasSetFontSize = true; + $this->shouldApplyFont = true; + return $this; + } + + /** + * @return string + */ + public function getFontName() + { + return $this->fontName; + } + + /** + * @param string $fontName Name of the font to use + * @return Style + */ + public function setFontName($fontName) + { + $this->fontName = $fontName; + $this->hasSetFontName = true; + $this->shouldApplyFont = true; + return $this; + } + + /** + * @return boolean + */ + public function shouldWrapText() + { + return $this->shouldWrapText; + } + + /** + * @return Style + */ + public function setShouldWrapText() + { + $this->shouldWrapText = true; + $this->hasSetWrapText = true; + return $this; + } + + /** + * @return bool Whether specific font properties should be applied + */ + public function shouldApplyFont() + { + return $this->shouldApplyFont; + } + + /** + * Serializes the style for future comparison with other styles. + * The ID is excluded from the comparison, as we only care about + * actual style properties. + * + * @return string The serialized style + */ + public function serialize() + { + // In order to be able to properly compare style, set static ID value + $currentId = $this->id; + $this->setId(0); + + $serializedStyle = serialize($this); + + $this->setId($currentId); + + return $serializedStyle; + } + + /** + * Merges the current style with the given style, using the given style as a base. This means that: + * - if current style and base style both have property A set, use current style property's value + * - if current style has property A set but base style does not, use current style property's value + * - if base style has property A set but current style does not, use base style property's value + * + * @NOTE: This function returns a new style. + * + * @param Style $baseStyle + * @return Style New style corresponding to the merge of the 2 styles + */ + public function mergeWith($baseStyle) + { + $mergedStyle = clone $this; + + if (!$this->hasSetFontBold && $baseStyle->isFontBold()) { + $mergedStyle->setFontBold(); + } + if (!$this->hasSetFontItalic && $baseStyle->isFontItalic()) { + $mergedStyle->setFontItalic(); + } + if (!$this->hasSetFontUnderline && $baseStyle->isFontUnderline()) { + $mergedStyle->setFontUnderline(); + } + if (!$this->hasSetFontStrikeThrough && $baseStyle->isFontStrikeThrough()) { + $mergedStyle->setFontStrikeThrough(); + } + if (!$this->hasSetFontSize && $baseStyle->getFontSize() !== self::DEFAULT_FONT_SIZE) { + $mergedStyle->setFontSize($baseStyle->getFontSize()); + } + if (!$this->hasSetFontName && $baseStyle->getFontName() !== self::DEFAULT_FONT_NAME) { + $mergedStyle->setFontName($baseStyle->getFontName()); + } + if (!$this->hasSetWrapText && $baseStyle->shouldWrapText()) { + $mergedStyle->setShouldWrapText(); + } + + return $mergedStyle; + } +} diff --git a/src/Spout/Writer/Style/StyleBuilder.php b/src/Spout/Writer/Style/StyleBuilder.php new file mode 100644 index 0000000..b82198b --- /dev/null +++ b/src/Spout/Writer/Style/StyleBuilder.php @@ -0,0 +1,113 @@ +style = new Style(); + } + + /** + * Makes the font bold. + * + * @return StyleBuilder + */ + public function setFontBold() + { + $this->style->setFontBold(); + return $this; + } + + /** + * Makes the font italic. + * + * @return StyleBuilder + */ + public function setFontItalic() + { + $this->style->setFontItalic(); + return $this; + } + + /** + * Makes the font underlined. + * + * @return StyleBuilder + */ + public function setFontUnderline() + { + $this->style->setFontUnderline(); + return $this; + } + + /** + * Makes the font struck through. + * + * @return StyleBuilder + */ + public function setFontStrikeThrough() + { + $this->style->setFontStrikeThrough(); + return $this; + } + + /** + * Sets the font size. + * + * @param int $fontSize Font size, in pixels + * @return StyleBuilder + */ + public function setFontSize($fontSize) + { + $this->style->setFontSize($fontSize); + return $this; + } + + /** + * Sets the font name. + * + * @param string $fontName Name of the font to use + * @return StyleBuilder + */ + public function setFontName($fontName) + { + $this->style->setFontName($fontName); + return $this; + } + + /** + * Makes the text wrap in the cell if it's too long or + * on multiple lines. + * + * @return StyleBuilder + */ + public function setShouldWrapText() + { + $this->style->setShouldWrapText(); + return $this; + } + + /** + * Returns the configured style. The style is cached and can be reused. + * + * @return Style + */ + public function build() + { + return $this->style; + } +} diff --git a/src/Spout/Writer/WriterInterface.php b/src/Spout/Writer/WriterInterface.php index 5bee201..e2d9f8d 100644 --- a/src/Spout/Writer/WriterInterface.php +++ b/src/Spout/Writer/WriterInterface.php @@ -40,6 +40,19 @@ interface WriterInterface */ public function addRow(array $dataRow); + /** + * Write given data to the output and apply the given style. + * @see addRow + * + * @param array $dataRow Array of array containing data to be streamed. + * @param Style\Style $style Style to be applied to the row. + * @return WriterInterface + * @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid + * @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer + * @throws \Box\Spout\Common\Exception\IOException If unable to write data + */ + public function addRowWithStyle(array $dataRow, $style); + /** * Write given data to the output. New data will be appended to end of stream. * @@ -55,6 +68,19 @@ interface WriterInterface */ public function addRows(array $dataRows); + /** + * Write given data to the output and apply the given style. + * @see addRows + * + * @param array $dataRows Array of array containing data to be streamed. + * @param Style\Style $style Style to be applied to the rows. + * @return WriterInterface + * @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid + * @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer + * @throws \Box\Spout\Common\Exception\IOException If unable to write data + */ + public function addRowsWithStyle(array $dataRows, $style); + /** * Closes the writer. This will close the streamer as well, preventing new data * to be written to the file. diff --git a/src/Spout/Writer/XLSX/Helper/FileSystemHelper.php b/src/Spout/Writer/XLSX/Helper/FileSystemHelper.php index f49f8ee..afe337e 100644 --- a/src/Spout/Writer/XLSX/Helper/FileSystemHelper.php +++ b/src/Spout/Writer/XLSX/Helper/FileSystemHelper.php @@ -26,6 +26,7 @@ class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper const CONTENT_TYPES_XML_FILE_NAME = '[Content_Types].xml'; const WORKBOOK_XML_FILE_NAME = 'workbook.xml'; const WORKBOOK_RELS_XML_FILE_NAME = 'workbook.xml.rels'; + const STYLES_XML_FILE_NAME = 'styles.xml'; /** @var string Path to the root folder inside the temp folder where the files to create the XLSX will be stored */ protected $rootFolder; @@ -256,6 +257,7 @@ EOD; } $contentTypesXmlFileContents .= << @@ -312,6 +314,7 @@ EOD; $workbookRelsXmlFileContents = << + EOD; @@ -329,6 +332,20 @@ EOD; return $this; } + /** + * Creates the "styles.xml" file under the "xl" folder + * + * @param StyleHelper $styleHelper + * @return FileSystemHelper + */ + public function createStylesFile($styleHelper) + { + $stylesXmlFileContents = $styleHelper->getStylesXMLFileContent(); + $this->createFileWithContents($this->xlFolder, self::STYLES_XML_FILE_NAME, $stylesXmlFileContents); + + return $this; + } + /** * Zips the root folder and streams the contents of the zip into the given stream * diff --git a/src/Spout/Writer/XLSX/Helper/StyleHelper.php b/src/Spout/Writer/XLSX/Helper/StyleHelper.php new file mode 100644 index 0000000..55af351 --- /dev/null +++ b/src/Spout/Writer/XLSX/Helper/StyleHelper.php @@ -0,0 +1,230 @@ + [STYLE_ID] mapping table, keeping track of the registered styles */ + protected $serializedStyleToStyleIdMappingTable = []; + + /** @var array [STYLE_ID] => [STYLE] mapping table, keeping track of the registered styles */ + protected $styleIdToStyleMappingTable = []; + + /** + * @param \Box\Spout\Writer\Style\Style $defaultStyle + */ + public function __construct($defaultStyle) + { + // This ensures that the default style is the first one to be registered + $this->registerStyle($defaultStyle); + } + + /** + * Registers the given style as a used style. + * Duplicate styles won't be registered more than once. + * + * @param \Box\Spout\Writer\Style\Style $style The style to be registered + * @return \Box\Spout\Writer\Style\Style The registered style, updated with an internal ID. + */ + public function registerStyle($style) + { + $serializedStyle = $style->serialize(); + + if (!$this->hasStyleAlreadyBeenRegistered($style)) { + $nextStyleId = count($this->serializedStyleToStyleIdMappingTable); + $style->setId($nextStyleId); + + $this->serializedStyleToStyleIdMappingTable[$serializedStyle] = $nextStyleId; + $this->styleIdToStyleMappingTable[$nextStyleId] = $style; + } + + return $this->getStyleFromSerializedStyle($serializedStyle); + } + + /** + * Returns whether the given style has already been registered. + * + * @param \Box\Spout\Writer\Style\Style $style + * @return bool + */ + protected function hasStyleAlreadyBeenRegistered($style) + { + $serializedStyle = $style->serialize(); + return array_key_exists($serializedStyle, $this->serializedStyleToStyleIdMappingTable); + } + + /** + * Returns the registered style associated to the given serialization. + * + * @param string $serializedStyle The serialized style from which the actual style should be fetched from + * @return \Box\Spout\Writer\Style\Style + */ + protected function getStyleFromSerializedStyle($serializedStyle) + { + $styleId = $this->serializedStyleToStyleIdMappingTable[$serializedStyle]; + return $this->styleIdToStyleMappingTable[$styleId]; + } + + /** + * Returns the content of the "styles.xml" file, given a list of styles. + * @return string + */ + public function getStylesXMLFileContent() + { + $content = << + + +EOD; + + $content .= $this->getFontsSectionContent(); + $content .= $this->getFillsSectionContent(); + $content .= $this->getBordersSectionContent(); + $content .= $this->getCellStyleXfsSectionContent(); + $content .= $this->getCellXfsSectionContent(); + $content .= $this->getCellStylesSectionContent(); + + $content .= << +EOD; + + return $content; + } + + /** + * Returns the content of the "" section. + * @return string + */ + protected function getFontsSectionContent() + { + $content = ' ' . PHP_EOL; + + foreach ($this->styleIdToStyleMappingTable as $style) { + $content .= ' ' . PHP_EOL; + + if ($style->isFontBold()) { + $content .= ' ' . PHP_EOL; + } + if ($style->isFontItalic()) { + $content .= ' ' . PHP_EOL; + } + if ($style->isFontUnderline()) { + $content .= ' ' . PHP_EOL; + } + if ($style->isFontStrikeThrough()) { + $content .= ' ' . PHP_EOL; + } + + $content .= ' ' . PHP_EOL; + $content .= ' ' . PHP_EOL; + $content .= ' ' . PHP_EOL; + } + + $content .= ' ' . PHP_EOL; + + return $content; + } + + /** + * Returns the content of the "" section. + * + * @return string + */ + protected function getFillsSectionContent() + { + return << + + + + + +EOD; + } + + /** + * Returns the content of the "" section. + * + * @return string + */ + protected function getBordersSectionContent() + { + return << + + + + + + + + + +EOD; + } + + /** + * Returns the content of the "" section. + * + * @return string + */ + protected function getCellStyleXfsSectionContent() + { + return << + + + +EOD; + } + + /** + * Returns the content of the "" section. + * @return string + */ + protected function getCellXfsSectionContent() + { + $content = ' ' . PHP_EOL; + + foreach ($this->styleIdToStyleMappingTable as $styleId => $style) { + $content .= ' shouldApplyFont()) { + $content .= ' applyFont="1"'; + } + + if ($style->shouldWrapText()) { + $content .= ' applyAlignment="1">' . PHP_EOL; + $content .= ' ' . PHP_EOL; + $content .= ' ' . PHP_EOL; + } else { + $content .= '/>' . PHP_EOL; + } + } + + $content .= ' ' . PHP_EOL; + + return $content; + } + + /** + * Returns the content of the "" section. + * + * @return string + */ + protected function getCellStylesSectionContent() + { + return << + + + +EOD; + } +} diff --git a/src/Spout/Writer/XLSX/Internal/Workbook.php b/src/Spout/Writer/XLSX/Internal/Workbook.php index 59544b1..4cd7c3d 100644 --- a/src/Spout/Writer/XLSX/Internal/Workbook.php +++ b/src/Spout/Writer/XLSX/Internal/Workbook.php @@ -5,6 +5,7 @@ namespace Box\Spout\Writer\XLSX\Internal; use Box\Spout\Writer\Exception\SheetNotFoundException; use Box\Spout\Writer\XLSX\Helper\FileSystemHelper; use Box\Spout\Writer\XLSX\Helper\SharedStringsHelper; +use Box\Spout\Writer\XLSX\Helper\StyleHelper; use Box\Spout\Writer\XLSX\Sheet; /** @@ -34,19 +35,28 @@ class Workbook /** @var \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper Helper to write shared strings */ protected $sharedStringsHelper; + /** @var \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to apply styles */ + protected $styleHelper; + /** @var Worksheet[] Array containing the workbook's sheets */ protected $worksheets = []; /** @var Worksheet The worksheet where data will be written to */ protected $currentWorksheet; + protected $styles = []; + + + + /** * @param string $tempFolder * @param bool $shouldUseInlineStrings * @param bool $shouldCreateNewSheetsAutomatically + * @param \Box\Spout\Writer\Style\Style $defaultRowStyle * @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders */ - public function __construct($tempFolder, $shouldUseInlineStrings, $shouldCreateNewSheetsAutomatically) + public function __construct($tempFolder, $shouldUseInlineStrings, $shouldCreateNewSheetsAutomatically, $defaultRowStyle) { $this->shouldUseInlineStrings = $shouldUseInlineStrings; $this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically; @@ -54,6 +64,8 @@ class Workbook $this->fileSystemHelper = new FileSystemHelper($tempFolder); $this->fileSystemHelper->createBaseFilesAndFolders(); + $this->styleHelper = new StyleHelper($defaultRowStyle); + // This helper will be shared by all sheets $xlFolder = $this->fileSystemHelper->getXlFolder(); $this->sharedStringsHelper = new SharedStringsHelper($xlFolder); @@ -164,11 +176,12 @@ class Workbook * * @param array $dataRow Array containing data to be written. * Example $dataRow = ['data1', 1234, null, '', 'data5']; + * @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. * @return void * @throws \Box\Spout\Common\Exception\IOException If trying to create a new sheet and unable to open the sheet for writing * @throws \Box\Spout\Writer\Exception\WriterException If unable to write data */ - public function addRowToCurrentWorksheet($dataRow) + public function addRowToCurrentWorksheet($dataRow, $style) { $currentWorksheet = $this->getCurrentWorksheet(); $hasReachedMaxRows = $this->hasCurrentWorkseetReachedMaxRows(); @@ -178,12 +191,15 @@ class Workbook // ... continue writing in a new sheet if option set if ($this->shouldCreateNewSheetsAutomatically) { $currentWorksheet = $this->addNewSheetAndMakeItCurrent(); - $currentWorksheet->addRow($dataRow); + + $registeredStyle = $this->styleHelper->registerStyle($style); + $currentWorksheet->addRow($dataRow, $registeredStyle); } else { // otherwise, do nothing as the data won't be read anyways } } else { - $currentWorksheet->addRow($dataRow); + $registeredStyle = $this->styleHelper->registerStyle($style); + $currentWorksheet->addRow($dataRow, $registeredStyle); } } @@ -217,6 +233,7 @@ class Workbook ->createContentTypesFile($this->worksheets) ->createWorkbookFile($this->worksheets) ->createWorkbookRelsFile($this->worksheets) + ->createStylesFile($this->styleHelper) ->zipRootFolderAndCopyToStream($finalFilePointer); $this->cleanupTempFolder(); diff --git a/src/Spout/Writer/XLSX/Internal/Worksheet.php b/src/Spout/Writer/XLSX/Internal/Worksheet.php index ef41ec2..04996bc 100644 --- a/src/Spout/Writer/XLSX/Internal/Worksheet.php +++ b/src/Spout/Writer/XLSX/Internal/Worksheet.php @@ -38,7 +38,7 @@ EOD; /** @var Resource Pointer to the sheet data file (e.g. xl/worksheets/sheet1.xml) */ protected $sheetFilePointer; - /** @var int */ + /** @var int Index of the last written row */ protected $lastWrittenRowIndex = 0; /** @@ -118,11 +118,12 @@ EOD; * * @param array $dataRow Array containing data to be written. * Example $dataRow = ['data1', 1234, null, '', 'data5']; + * @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. NULL means use default style. * @return void * @throws \Box\Spout\Common\Exception\IOException If the data cannot be written * @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported */ - public function addRow($dataRow) + public function addRow($dataRow, $style) { $cellNumber = 0; $rowIndex = $this->lastWrittenRowIndex + 1; @@ -133,6 +134,7 @@ EOD; foreach($dataRow as $cellValue) { $columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber); $data .= ' getId() . '"'; if (CellHelper::isNonEmptyString($cellValue)) { if ($this->shouldUseInlineStrings) { diff --git a/src/Spout/Writer/XLSX/Writer.php b/src/Spout/Writer/XLSX/Writer.php index 40f9ae4..c92b99b 100644 --- a/src/Spout/Writer/XLSX/Writer.php +++ b/src/Spout/Writer/XLSX/Writer.php @@ -4,6 +4,7 @@ namespace Box\Spout\Writer\XLSX; use Box\Spout\Writer\AbstractWriter; use Box\Spout\Writer\Exception\WriterNotOpenedException; +use Box\Spout\Writer\Style\StyleBuilder; use Box\Spout\Writer\XLSX\Internal\Workbook; /** @@ -14,6 +15,10 @@ use Box\Spout\Writer\XLSX\Internal\Workbook; */ class Writer extends AbstractWriter { + /** Default style font values */ + const DEFAULT_FONT_SIZE = 12; + const DEFAULT_FONT_NAME = 'Calibri'; + /** @var string Content-Type value for the header */ protected static $headerContentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; @@ -74,7 +79,7 @@ class Writer extends AbstractWriter { if (!$this->book) { $tempFolder = ($this->tempFolder) ? : sys_get_temp_dir(); - $this->book = new Workbook($tempFolder, $this->shouldUseInlineStrings, $this->shouldCreateNewSheetsAutomatically); + $this->book = new Workbook($tempFolder, $this->shouldUseInlineStrings, $this->shouldCreateNewSheetsAutomatically, $this->defaultRowStyle); $this->book->addNewSheetAndMakeItCurrent(); } } @@ -161,14 +166,28 @@ class Writer extends AbstractWriter * * @param array $dataRow Array containing data to be written. * Example $dataRow = ['data1', 1234, null, '', 'data5']; + * @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. * @return void * @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the book is not created yet * @throws \Box\Spout\Common\Exception\IOException If unable to write data */ - protected function addRowToWriter(array $dataRow) + protected function addRowToWriter(array $dataRow, $style) { $this->throwIfBookIsNotAvailable(); - $this->book->addRowToCurrentWorksheet($dataRow); + $this->book->addRowToCurrentWorksheet($dataRow, $style); + } + + /** + * Returns the default style to be applied to rows. + * + * @return \Box\Spout\Writer\Style\Style + */ + protected function getDefaultRowStyle() + { + return (new StyleBuilder()) + ->setFontSize(self::DEFAULT_FONT_SIZE) + ->setFontName(self::DEFAULT_FONT_NAME) + ->build(); } /** diff --git a/tests/Spout/Writer/Style/StyleTest.php b/tests/Spout/Writer/Style/StyleTest.php new file mode 100644 index 0000000..5e991ec --- /dev/null +++ b/tests/Spout/Writer/Style/StyleTest.php @@ -0,0 +1,132 @@ +setFontBold()->build(); + $style1->setId(1); + + $style2 = (new StyleBuilder())->setFontBold()->build(); + $style2->setId(2); + + $this->assertEquals($style1->serialize(), $style2->serialize()); + } + + /** + * @return void + */ + public function testMergeWithShouldReturnACopy() + { + $baseStyle = (new StyleBuilder())->build(); + $currentStyle = (new StyleBuilder())->build(); + $mergedStyle = $currentStyle->mergeWith($baseStyle); + + $this->assertNotSame($mergedStyle, $currentStyle); + } + + /** + * @return void + */ + public function testMergeWithShouldMergeSetProperties() + { + $baseStyle = (new StyleBuilder())->setFontSize(99)->setFontBold()->build(); + $currentStyle = (new StyleBuilder())->setFontName('Font')->setFontUnderline()->build(); + $mergedStyle = $currentStyle->mergeWith($baseStyle); + + $this->assertNotEquals(99, $currentStyle->getFontSize()); + $this->assertFalse($currentStyle->isFontBold()); + + $this->assertEquals(99, $mergedStyle->getFontSize()); + $this->assertTrue($mergedStyle->isFontBold()); + $this->assertEquals('Font', $mergedStyle->getFontName()); + $this->assertTrue($mergedStyle->isFontUnderline()); + } + + /** + * @return void + */ + public function testMergeWithShouldPreferCurrentStylePropertyIfSetOnCurrentAndOnBase() + { + $baseStyle = (new StyleBuilder())->setFontSize(10)->build(); + $currentStyle = (new StyleBuilder())->setFontSize(99)->build(); + $mergedStyle = $currentStyle->mergeWith($baseStyle); + + $this->assertEquals(99, $mergedStyle->getFontSize()); + } + + /** + * @return void + */ + public function testMergeWithShouldPreferCurrentStylePropertyIfSetOnCurrentButNotOnBase() + { + $baseStyle = (new StyleBuilder())->build(); + $currentStyle = (new StyleBuilder())->setFontItalic()->setFontStrikeThrough()->build(); + $mergedStyle = $currentStyle->mergeWith($baseStyle); + + $this->assertFalse($baseStyle->isFontItalic()); + $this->assertFalse($baseStyle->isFontStrikeThrough()); + + $this->assertTrue($mergedStyle->isFontItalic()); + $this->assertTrue($mergedStyle->isFontStrikeThrough()); + } + + /** + * @return void + */ + public function testMergeWithShouldPreferBaseStylePropertyIfSetOnBaseButNotOnCurrent() + { + $baseStyle = (new StyleBuilder()) + ->setFontItalic() + ->setFontUnderline() + ->setFontStrikeThrough() + ->setShouldWrapText() + ->build(); + $currentStyle = (new StyleBuilder())->build(); + $mergedStyle = $currentStyle->mergeWith($baseStyle); + + $this->assertFalse($currentStyle->isFontUnderline()); + $this->assertTrue($mergedStyle->isFontUnderline()); + + $this->assertFalse($currentStyle->shouldWrapText()); + $this->assertTrue($mergedStyle->shouldWrapText()); + } + + /** + * @return void + */ + public function testMergeWithShouldDoNothingIfStylePropertyNotSetOnBaseNorCurrent() + { + $baseStyle = (new StyleBuilder())->build(); + $currentStyle = (new StyleBuilder())->build(); + $mergedStyle = $currentStyle->mergeWith($baseStyle); + + $this->assertTrue($baseStyle->serialize() === $currentStyle->serialize()); + $this->assertTrue($currentStyle->serialize() === $mergedStyle->serialize()); + } + + /** + * @return void + */ + public function testMergeWithShouldDoNothingIfStylePropertyNotSetOnCurrentAndIsDefaultValueOnBase() + { + $baseStyle = (new StyleBuilder()) + ->setFontName(Style::DEFAULT_FONT_NAME) + ->setFontSize(Style::DEFAULT_FONT_SIZE) + ->build(); + $currentStyle = (new StyleBuilder())->build(); + $mergedStyle = $currentStyle->mergeWith($baseStyle); + + $this->assertTrue($currentStyle->serialize() === $mergedStyle->serialize()); + } +} diff --git a/tests/Spout/Writer/XLSX/Helper/StyleHelperTest.php b/tests/Spout/Writer/XLSX/Helper/StyleHelperTest.php new file mode 100644 index 0000000..13d1575 --- /dev/null +++ b/tests/Spout/Writer/XLSX/Helper/StyleHelperTest.php @@ -0,0 +1,59 @@ +defaultStyle = (new StyleBuilder())->build(); + } + + /** + * @return void + */ + public function testRegisterStyleShouldUpdateId() + { + $style1 = (new StyleBuilder())->setFontBold()->build(); + $style2 = (new StyleBuilder())->setFontUnderline()->build(); + + $this->assertEquals(0, $this->defaultStyle->getId(), 'Default style ID should be 0'); + $this->assertNull($style1->getId()); + $this->assertNull($style2->getId()); + + $styleHelper = new StyleHelper($this->defaultStyle); + $registeredStyle1 = $styleHelper->registerStyle($style1); + $registeredStyle2 = $styleHelper->registerStyle($style2); + + $this->assertEquals(1, $registeredStyle1->getId()); + $this->assertEquals(2, $registeredStyle2->getId()); + } + + /** + * @return void + */ + public function testRegisterStyleShouldReuseAlreadyRegisteredStyles() + { + $style = (new StyleBuilder())->setFontBold()->build(); + + $styleHelper = new StyleHelper($this->defaultStyle); + $registeredStyle1 = $styleHelper->registerStyle($style); + $registeredStyle2 = $styleHelper->registerStyle($style); + + $this->assertEquals(1, $registeredStyle1->getId()); + $this->assertEquals(1, $registeredStyle2->getId()); + } +} diff --git a/tests/Spout/Writer/XLSX/WriterTest.php b/tests/Spout/Writer/XLSX/WriterTest.php index 7c6f5ea..7924248 100644 --- a/tests/Spout/Writer/XLSX/WriterTest.php +++ b/tests/Spout/Writer/XLSX/WriterTest.php @@ -9,7 +9,7 @@ use Box\Spout\Writer\WriterFactory; /** * Class XLSXTest * - * @package Box\Spout\Writer + * @package Box\Spout\Writer\XLSX */ class WriterTest extends \PHPUnit_Framework_TestCase { @@ -26,8 +26,6 @@ class WriterTest extends \PHPUnit_Framework_TestCase $writer = WriterFactory::create(Type::XLSX); @$writer->openToFile($filePath); - $writer->addRow(['xlsx--11', 'xlsx--12']); - $writer->close(); } /** @@ -37,17 +35,15 @@ class WriterTest extends \PHPUnit_Framework_TestCase { $writer = WriterFactory::create(Type::XLSX); $writer->addRow(['xlsx--11', 'xlsx--12']); - $writer->close(); } /** * @expectedException \Box\Spout\Writer\Exception\WriterNotOpenedException */ - public function testAddRowShouldThrowExceptionIfCallAddRowsBeforeOpeningWriter() + public function testAddRowShouldThrowExceptionIfCalledBeforeOpeningWriter() { $writer = WriterFactory::create(Type::XLSX); $writer->addRows([['xlsx--11', 'xlsx--12']]); - $writer->close(); } /** diff --git a/tests/Spout/Writer/XLSX/WriterWithStyleTest.php b/tests/Spout/Writer/XLSX/WriterWithStyleTest.php new file mode 100644 index 0000000..882e6d6 --- /dev/null +++ b/tests/Spout/Writer/XLSX/WriterWithStyleTest.php @@ -0,0 +1,337 @@ +defaultStyle = (new StyleBuilder())->build(); + } + + /** + * @expectedException \Box\Spout\Writer\Exception\WriterNotOpenedException + */ + public function testAddRowWithStyleShouldThrowExceptionIfCallAddRowBeforeOpeningWriter() + { + $writer = WriterFactory::create(Type::XLSX); + $writer->addRowWithStyle(['xlsx--11', 'xlsx--12'], $this->defaultStyle); + } + + /** + * @expectedException \Box\Spout\Writer\Exception\WriterNotOpenedException + */ + public function testAddRowWithStyleShouldThrowExceptionIfCalledBeforeOpeningWriter() + { + $writer = WriterFactory::create(Type::XLSX); + $writer->addRowWithStyle(['xlsx--11', 'xlsx--12'], $this->defaultStyle); + } + + /** + * @return array + */ + public function dataProviderForInvalidStyle() + { + return [ + ['style'], + [new \stdClass()], + [null], + ]; + } + + /** + * @dataProvider dataProviderForInvalidStyle + * @expectedException \Box\Spout\Common\Exception\InvalidArgumentException + * + * @param \Box\Spout\Writer\Style\Style $style + */ + public function testAddRowWithStyleShouldThrowExceptionIfInvalidStyleGiven($style) + { + $fileName = 'test_add_row_with_style_should_throw_exception.xlsx'; + $this->createGeneratedFolderIfNeeded($fileName); + $resourcePath = $this->getGeneratedResourcePath($fileName); + + $writer = WriterFactory::create(Type::XLSX); + $writer->openToFile($resourcePath); + $writer->addRowWithStyle(['xlsx--11', 'xlsx--12'], $style); + } + + /** + * @dataProvider dataProviderForInvalidStyle + * @expectedException \Box\Spout\Common\Exception\InvalidArgumentException + * + * @param \Box\Spout\Writer\Style\Style $style + */ + public function testAddRowsWithStyleShouldThrowExceptionIfInvalidStyleGiven($style) + { + $fileName = 'test_add_row_with_style_should_throw_exception.xlsx'; + $this->createGeneratedFolderIfNeeded($fileName); + $resourcePath = $this->getGeneratedResourcePath($fileName); + + $writer = WriterFactory::create(Type::XLSX); + $writer->openToFile($resourcePath); + $writer->addRowsWithStyle([['xlsx--11', 'xlsx--12']], $style); + } + + /** + * @return void + */ + public function testAddRowWithStyleShouldListAllUsedFontsInCreateStylesXmlFile() + { + $fileName = 'test_add_row_with_style_should_list_all_used_fonts.xlsx'; + $dataRows = [ + ['xlsx--11', 'xlsx--12'], + ['xlsx--21', 'xlsx--22'], + ]; + + $style = (new StyleBuilder()) + ->setFontBold() + ->setFontItalic() + ->setFontUnderline() + ->setFontStrikeThrough() + ->build(); + $style2 = (new StyleBuilder()) + ->setFontSize(15) + ->setFontName('Arial') + ->build(); + + $this->writeToXLSXFileWithMultipleStyles($dataRows, $fileName, [$style, $style2]); + + $fontsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'fonts'); + $this->assertEquals(3, $fontsDomElement->getAttribute('count'), 'There should be 3 fonts, including the default one.'); + + $fontElements = $fontsDomElement->getElementsByTagName('font'); + $this->assertEquals(3, $fontElements->length, 'There should be 3 associated "font" elements, including the default one.'); + + // First font should be the default one + $defaultFontElement = $fontElements->item(0); + $this->assertChildrenNumEquals(2, $defaultFontElement, 'The default font should only have 2 properties.'); + $this->assertFirstChildHasAttributeEquals((string) Writer::DEFAULT_FONT_SIZE, $defaultFontElement, 'sz', 'val'); + $this->assertFirstChildHasAttributeEquals(Writer::DEFAULT_FONT_NAME, $defaultFontElement, 'name', 'val'); + + // Second font should contain data from the first created style + $secondFontElement = $fontElements->item(1); + $this->assertChildrenNumEquals(6, $secondFontElement, 'The font should only have 6 properties (4 custom styles + 2 default styles).'); + $this->assertChildExists($secondFontElement, 'b'); + $this->assertChildExists($secondFontElement, 'i'); + $this->assertChildExists($secondFontElement, 'u'); + $this->assertChildExists($secondFontElement, 'strike'); + $this->assertFirstChildHasAttributeEquals((string) Writer::DEFAULT_FONT_SIZE, $secondFontElement, 'sz', 'val'); + $this->assertFirstChildHasAttributeEquals(Writer::DEFAULT_FONT_NAME, $secondFontElement, 'name', 'val'); + + // Third font should contain data from the second created style + $thirdFontElement = $fontElements->item(2); + $this->assertChildrenNumEquals(2, $thirdFontElement, 'The font should only have 2 properties.'); + $this->assertFirstChildHasAttributeEquals('15', $thirdFontElement, 'sz', 'val'); + $this->assertFirstChildHasAttributeEquals('Arial', $thirdFontElement, 'name', 'val'); + } + + /** + * @return void + */ + public function testAddRowWithStyleShouldAddWrapTextAlignmentInfoInStylesXmlFileIfSpecified() + { + $fileName = 'test_add_row_with_style_should_add_wrap_text_alignment.xlsx'; + $dataRows = [ + ['xlsx--11', 'xlsx--12'], + ]; + $style = (new StyleBuilder())->setShouldWrapText()->build(); + + $this->writeToXLSXFile($dataRows, $fileName,$style); + + $cellXfsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs'); + $xfElement = $cellXfsDomElement->getElementsByTagName('xf')->item(1); + $this->assertEquals(1, $xfElement->getAttribute('applyAlignment')); + $this->assertFirstChildHasAttributeEquals('1', $xfElement, 'alignment', 'wrapText'); + } + + /** + * @return void + */ + public function testAddRowWithStyleShouldApplyStyleToCells() + { + $fileName = 'test_add_row_with_style_should_apply_style_to_cells.xlsx'; + $dataRows = [ + ['xlsx--11'], + ['xlsx--21'], + ['xlsx--31'], + ]; + $style = (new StyleBuilder())->setFontBold()->build(); + $style2 = (new StyleBuilder())->setFontSize(15)->build(); + + $this->writeToXLSXFileWithMultipleStyles($dataRows, $fileName, [$style, $style2, null]); + + $cellDomElements = $this->getCellElementsFromSheetXmlFile($fileName); + $this->assertEquals(3, count($cellDomElements), 'There should be 3 cells.'); + + $this->assertEquals('1', $cellDomElements[0]->getAttribute('s')); + $this->assertEquals('2', $cellDomElements[1]->getAttribute('s')); + $this->assertEquals('0', $cellDomElements[2]->getAttribute('s')); + } + + /** + * @return void + */ + public function testAddRowWithStyleShouldReuseDuplicateStyles() + { + $fileName = 'test_add_row_with_style_should_reuse_duplicate_styles.xlsx'; + $dataRows = [ + ['xlsx--11'], + ['xlsx--21'], + ]; + $style = (new StyleBuilder())->setFontBold()->build(); + + $this->writeToXLSXFile($dataRows, $fileName, $style); + + $cellDomElements = $this->getCellElementsFromSheetXmlFile($fileName); + $this->assertEquals('1', $cellDomElements[0]->getAttribute('s')); + $this->assertEquals('1', $cellDomElements[1]->getAttribute('s')); + } + + /** + * @param array $allRows + * @param string $fileName + * @param \Box\Spout\Writer\Style\Style $style + * @return Writer + */ + private function writeToXLSXFile($allRows, $fileName, $style) + { + $this->createGeneratedFolderIfNeeded($fileName); + $resourcePath = $this->getGeneratedResourcePath($fileName); + + /** @var \Box\Spout\Writer\XLSX\Writer $writer */ + $writer = WriterFactory::create(Type::XLSX); + $writer->setShouldUseInlineStrings(true); + + $writer->openToFile($resourcePath); + $writer->addRowsWithStyle($allRows, $style); + $writer->close(); + + return $writer; + } + + /** + * @param array $allRows + * @param string $fileName + * @param \Box\Spout\Writer\Style\Style|null[] $styles + * @return Writer + */ + private function writeToXLSXFileWithMultipleStyles($allRows, $fileName, $styles) + { + // there should be as many rows as there are styles passed in + $this->assertEquals(count($allRows), count($styles)); + + $this->createGeneratedFolderIfNeeded($fileName); + $resourcePath = $this->getGeneratedResourcePath($fileName); + + /** @var \Box\Spout\Writer\XLSX\Writer $writer */ + $writer = WriterFactory::create(Type::XLSX); + $writer->setShouldUseInlineStrings(true); + + $writer->openToFile($resourcePath); + for ($i = 0; $i < count($allRows); $i++) { + if ($styles[$i] === null) { + $writer->addRow($allRows[$i]); + } else { + $writer->addRowWithStyle($allRows[$i], $styles[$i]); + } + } + $writer->close(); + + return $writer; + } + + /** + * @param string $fileName + * @param string $section + * @return \DomElement + */ + private function getXmlSectionFromStylesXmlFile($fileName, $section) + { + $resourcePath = $this->getGeneratedResourcePath($fileName); + $pathToStylesXmlFile = $resourcePath . '#xl/styles.xml'; + + $xmlReader = new \XMLReader(); + $xmlReader->open('zip://' . $pathToStylesXmlFile); + + while ($xmlReader->read() && ($xmlReader->nodeType !== \XMLReader::ELEMENT || $xmlReader->name !== $section)) { + // do nothing + } + + return $xmlReader->expand(); + } + + /** + * @param string $fileName + * @return \DOMNode[] + */ + private function getCellElementsFromSheetXmlFile($fileName) + { + $cellElements = []; + + $resourcePath = $this->getGeneratedResourcePath($fileName); + $pathToStylesXmlFile = $resourcePath . '#xl/worksheets/sheet1.xml'; + + $xmlReader = new \XMLReader(); + $xmlReader->open('zip://' . $pathToStylesXmlFile); + + while ($xmlReader->read()) { + if ($xmlReader->nodeType === \XMLReader::ELEMENT && $xmlReader->name === 'c') { + $cellElements[] = $xmlReader->expand(); + } + } + + return $cellElements; + } + + /** + * @param string $expectedValue + * @param \DOMNode $parentElement + * @param string $childTagName + * @param string $attributeName + * @return void + */ + private function assertFirstChildHasAttributeEquals($expectedValue, $parentElement, $childTagName, $attributeName) + { + $this->assertEquals($expectedValue, $parentElement->getElementsByTagName($childTagName)->item(0)->getAttribute($attributeName)); + } + + /** + * @param int $expectedNumber + * @param \DOMNode $parentElement + * @param string $message + * @return void + */ + private function assertChildrenNumEquals($expectedNumber, $parentElement, $message) + { + $this->assertEquals($expectedNumber, $parentElement->getElementsByTagName('*')->length, $message); + } + + /** + * @param \DOMNode $parentElement + * @param string $childTagName + * @return void + */ + private function assertChildExists($parentElement, $childTagName) + { + $this->assertEquals(1, $parentElement->getElementsByTagName($childTagName)->length); + } +}