diff --git a/README.md b/README.md index a2054be..821c3b7 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ The writer always generate CSV files encoded in UTF-8, with a BOM. #### Row styling -It is possible to apply some formatting options to a row. Spout supports fonts as well as alignment styles. +It is possible to apply some formatting options to a row. Spout supports fonts, borders as well as alignment styles. ```php use Box\Spout\Common\Type; @@ -150,6 +150,33 @@ $writer->addRowsWithStyle($multipleRows, $style); // style will be applied to al $writer->close(); ``` +Adding borders to a row requires a ```Border``` object. + +```php +use Box\Spout\Common\Type; +use Box\Spout\Writer\Style\Border; +use Box\Spout\Writer\Style\BorderBuilder; +use Box\Spout\Writer\Style\Color; +use Box\Spout\Writer\Style\StyleBuilder; +use Box\Spout\Writer\WriterFactory; + +$border = (new BorderBuilder()) + ->setBorderBottom(Color::GREEN, Border::WIDTH_THIN, Border::STYLE_DASHED) + ->build(); + +$style = (new StyleBuilder()) + ->setBorder($border) + ->build(); + +$writer = WriterFactory::create(Type::ODS); +$writer->openToFile(__DIR__ . '/borders.xlsx'); + +$writer->addRowWithStyle(['Border Bottom Green Thin Dashed'], $style) + +$writer->close(); + +``` + Unfortunately, Spout does not support all the possible formatting options yet. But you can find the most important ones: Category | Property | API diff --git a/src/Spout/Writer/Exception/Border/InvalidNameException.php b/src/Spout/Writer/Exception/Border/InvalidNameException.php new file mode 100644 index 0000000..13ac06c --- /dev/null +++ b/src/Spout/Writer/Exception/Border/InvalidNameException.php @@ -0,0 +1,16 @@ + + */ +class BorderHelper +{ + /** + * Width mappings + * + * @var array + */ + protected static $widthMap = [ + Border::WIDTH_THIN => '0.75pt', + Border::WIDTH_MEDIUM => '1.75pt', + Border::WIDTH_THICK => '2.5pt', + ]; + + /** + * Style mapping + * + * @var array + */ + protected static $styleMap = [ + Border::STYLE_SOLID => 'solid', + Border::STYLE_DASHED => 'dashed', + Border::STYLE_DOTTED => 'dotted', + Border::STYLE_DOUBLE => 'double', + ]; + + /** + * @param BorderPart $borderPart + * @return string + */ + public static function serializeBorderPart(BorderPart $borderPart) + { + $definition = 'fo:border-%s="%s"'; + + if ($borderPart->getStyle() === Border::STYLE_NONE) { + $borderPartDefinition = sprintf($definition, $borderPart->getName(), 'none'); + } else { + $attributes = [ + self::$widthMap[$borderPart->getWidth()], + self::$styleMap[$borderPart->getStyle()], + '#' . $borderPart->getColor(), + ]; + $borderPartDefinition = sprintf($definition, $borderPart->getName(), implode(' ', $attributes)); + } + + return $borderPartDefinition; + } +} diff --git a/src/Spout/Writer/ODS/Helper/StyleHelper.php b/src/Spout/Writer/ODS/Helper/StyleHelper.php index f8b0c4d..7dfb828 100644 --- a/src/Spout/Writer/ODS/Helper/StyleHelper.php +++ b/src/Spout/Writer/ODS/Helper/StyleHelper.php @@ -3,6 +3,7 @@ namespace Box\Spout\Writer\ODS\Helper; use Box\Spout\Writer\Common\Helper\AbstractStyleHelper; +use Box\Spout\Writer\Style\BorderPart; /** * Class StyleHelper @@ -256,9 +257,16 @@ EOD; $content .= ''; } + if ($style->shouldApplyBorder()) { + $borderProperty = ''; + $borders = array_map(function (BorderPart $borderPart) { + return BorderHelper::serializeBorderPart($borderPart); + }, $style->getBorder()->getParts()); + $content .= sprintf($borderProperty, implode(' ', $borders)); + } + $content .= ''; return $content; } - } diff --git a/src/Spout/Writer/Style/Border.php b/src/Spout/Writer/Style/Border.php new file mode 100644 index 0000000..b68ec80 --- /dev/null +++ b/src/Spout/Writer/Style/Border.php @@ -0,0 +1,67 @@ +setParts($borderParts); + } + + /** + * @return array + */ + public function getParts() + { + return $this->parts; + } + + /** + * Set BorderParts + * @param array $parts + */ + public function setParts($parts) + { + unset($this->parts); + foreach ($parts as $part) { + $this->addPart($part); + } + } + + /** + * @param BorderPart $borderPart + * @return self + */ + public function addPart(BorderPart $borderPart) + { + $this->parts[$borderPart->getName()] = $borderPart; + return $this; + } +} diff --git a/src/Spout/Writer/Style/BorderBuilder.php b/src/Spout/Writer/Style/BorderBuilder.php new file mode 100644 index 0000000..c0b8aea --- /dev/null +++ b/src/Spout/Writer/Style/BorderBuilder.php @@ -0,0 +1,75 @@ +border = new Border(); + } + + /** + * @param string|void $color Border A RGB color code + * @param string|void $width Border width @see BorderPart::allowedWidths + * @param string|void $style Border style @see BorderPart::allowedStyles + * @return BorderBuilder + */ + public function setBorderTop($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID) + { + $this->border->addPart(new BorderPart(Border::TOP, $color, $width, $style)); + return $this; + } + + /** + * @param string|void $color Border A RGB color code + * @param string|void $width Border width @see BorderPart::allowedWidths + * @param string|void $style Border style @see BorderPart::allowedStyles + * @return BorderBuilder + */ + public function setBorderRight($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID) + { + $this->border->addPart(new BorderPart(Border::RIGHT, $color, $width, $style)); + return $this; + } + + /** + * @param string|void $color Border A RGB color code + * @param string|void $width Border width @see BorderPart::allowedWidths + * @param string|void $style Border style @see BorderPart::allowedStyles + * @return BorderBuilder + */ + public function setBorderBottom($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID) + { + $this->border->addPart(new BorderPart(Border::BOTTOM, $color, $width, $style)); + return $this; + } + + /** + * @param string|void $color Border A RGB color code + * @param string|void $width Border width @see BorderPart::allowedWidths + * @param string|void $style Border style @see BorderPart::allowedStyles + * @return BorderBuilder + */ + public function setBorderLeft($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID) + { + $this->border->addPart(new BorderPart(Border::LEFT, $color, $width, $style)); + return $this; + } + + /** + * @return Border + */ + public function build() + { + return $this->border; + } +} diff --git a/src/Spout/Writer/Style/BorderPart.php b/src/Spout/Writer/Style/BorderPart.php new file mode 100644 index 0000000..9ade797 --- /dev/null +++ b/src/Spout/Writer/Style/BorderPart.php @@ -0,0 +1,184 @@ +setName($name); + $this->setColor($color); + $this->setWidth($width); + $this->setStyle($style); + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @param string $name The name of the border part @see BorderPart::$allowedNames + * @throws InvalidNameException + * @return void + */ + public function setName($name) + { + if (!in_array($name, self::$allowedNames)) { + throw new InvalidNameException($name); + } + $this->name = $name; + } + + /** + * @return string + */ + public function getStyle() + { + return $this->style; + } + + /** + * @param string $style The style of the border part @see BorderPart::$allowedStyles + * @throws InvalidStyleException + * @return void + */ + public function setStyle($style) + { + if (!in_array($style, self::$allowedStyles)) { + throw new InvalidStyleException($style); + } + $this->style = $style; + } + + /** + * @return string + */ + public function getColor() + { + return $this->color; + } + + /** + * @param string $color The color of the border part @see Color::rgb() + * @return void + */ + public function setColor($color) + { + $this->color = $color; + } + + /** + * @return string + */ + public function getWidth() + { + return $this->width; + } + + /** + * @param string $width The width of the border part @see BorderPart::$allowedWidths + * @throws InvalidWidthException + * @return void + */ + public function setWidth($width) + { + if (!in_array($width, self::$allowedWidths)) { + throw new InvalidWidthException($width); + } + $this->width = $width; + } + + /** + * @return array + */ + public static function getAllowedStyles() + { + return self::$allowedStyles; + } + + /** + * @return array + */ + public static function getAllowedNames() + { + return self::$allowedNames; + } + + /** + * @return array + */ + public static function getAllowedWidths() + { + return self::$allowedWidths; + } +} diff --git a/src/Spout/Writer/Style/Style.php b/src/Spout/Writer/Style/Style.php index 91e9475..b595b3a 100644 --- a/src/Spout/Writer/Style/Style.php +++ b/src/Spout/Writer/Style/Style.php @@ -61,6 +61,16 @@ class Style /** @var bool Whether the wrap text property was set */ protected $hasSetWrapText = false; + /** + * @var Border + */ + protected $border = null; + + /** + * @var bool Whether border properties should be applied + */ + protected $shouldApplyBorder = false; + /** * @return int|null */ @@ -79,6 +89,32 @@ class Style return $this; } + /** + * @return Border + */ + public function getBorder() + { + return $this->border; + } + + /** + * @param Border $border + */ + public function setBorder(Border $border) + { + $this->shouldApplyBorder = true; + $this->border = $border; + return $this; + } + + /** + * @return boolean + */ + public function shouldApplyBorder() + { + return $this->shouldApplyBorder; + } + /** * @return boolean */ @@ -302,6 +338,9 @@ class Style if (!$this->hasSetWrapText && $baseStyle->shouldWrapText()) { $mergedStyle->setShouldWrapText(); } + if (!$this->getBorder() && $baseStyle->shouldApplyBorder()) { + $mergedStyle->setBorder($baseStyle->getBorder()); + } return $mergedStyle; } diff --git a/src/Spout/Writer/Style/StyleBuilder.php b/src/Spout/Writer/Style/StyleBuilder.php index 4619f43..6d6239c 100644 --- a/src/Spout/Writer/Style/StyleBuilder.php +++ b/src/Spout/Writer/Style/StyleBuilder.php @@ -121,6 +121,18 @@ class StyleBuilder return $this; } + /** + * Set a border + * + * @param Border $border + * @return $this + */ + public function setBorder(Border $border) + { + $this->style->setBorder($border); + return $this; + } + /** * Returns the configured style. The style is cached and can be reused. * diff --git a/src/Spout/Writer/XLSX/Helper/BorderHelper.php b/src/Spout/Writer/XLSX/Helper/BorderHelper.php new file mode 100644 index 0000000..ad63aea --- /dev/null +++ b/src/Spout/Writer/XLSX/Helper/BorderHelper.php @@ -0,0 +1,68 @@ + [ + Border::WIDTH_THIN => 'thin', + Border::WIDTH_MEDIUM => 'medium', + Border::WIDTH_THICK => 'thick' + ], + Border::STYLE_DOTTED => [ + Border::WIDTH_THIN => 'dotted', + Border::WIDTH_MEDIUM => 'dotted', + Border::WIDTH_THICK => 'dotted', + ], + Border::STYLE_DASHED => [ + Border::WIDTH_THIN => 'dashed', + Border::WIDTH_MEDIUM => 'mediumDashed', + Border::WIDTH_THICK => 'mediumDashed', + ], + Border::STYLE_DOUBLE => [ + Border::WIDTH_THIN => 'double', + Border::WIDTH_MEDIUM => 'double', + Border::WIDTH_THICK => 'double', + ], + Border::STYLE_NONE => [ + Border::WIDTH_THIN => 'none', + Border::WIDTH_MEDIUM => 'none', + Border::WIDTH_THICK => 'none', + ], + ]; + + /** + * @param BorderPart $borderPart + * @return string + */ + public static function serializeBorderPart(BorderPart $borderPart) + { + $borderStyle = self::getBorderStyle($borderPart); + + $colorEl = $borderPart->getColor() ? sprintf('', $borderPart->getColor()) : ''; + $partEl = sprintf( + '<%s style="%s">%s', + $borderPart->getName(), + $borderStyle, + $colorEl, + $borderPart->getName() + ); + + return $partEl . PHP_EOL; + } + + /** + * Get the style definition from the style map + * + * @param BorderPart $borderPart + * @return string + */ + protected static function getBorderStyle(BorderPart $borderPart) + { + return self::$xlsxStyleMap[$borderPart->getStyle()][$borderPart->getWidth()]; + } +} diff --git a/src/Spout/Writer/XLSX/Helper/StyleHelper.php b/src/Spout/Writer/XLSX/Helper/StyleHelper.php index f3da2b5..3c2b3d1 100644 --- a/src/Spout/Writer/XLSX/Helper/StyleHelper.php +++ b/src/Spout/Writer/XLSX/Helper/StyleHelper.php @@ -100,17 +100,29 @@ EOD; */ protected function getBordersSectionContent() { - return << - - - - - - - - -EOD; + $registeredStyles = $this->getRegisteredStyles(); + $registeredStylesCount = count($registeredStyles); + + $content = ''; + + /** @var \Box\Spout\Writer\Style\Style $style */ + foreach ($registeredStyles as $style) { + $border = $style->getBorder(); + if ($border) { + $content .= ''; + foreach ($border->getParts() as $part) { + /** @var $part \Box\Spout\Writer\Style\BorderPart */ + $content .= BorderHelper::serializeBorderPart($part); + } + $content .= ''; + } else { + $content .= ''; + } + } + + $content .= ''; + + return $content; } /** @@ -139,12 +151,16 @@ EOD; $content = ''; foreach ($registeredStyles as $style) { - $content .= 'getId() . '" fillId="0" borderId="' . $style->getId() . '" xfId="0"'; if ($style->shouldApplyFont()) { $content .= ' applyFont="1"'; } + if ($style->shouldApplyBorder()) { + $content .= ' applyBorder="1"'; + } + if ($style->shouldWrapText()) { $content .= ' applyAlignment="1">'; $content .= ''; diff --git a/tests/Spout/Writer/ODS/WriterWithStyleTest.php b/tests/Spout/Writer/ODS/WriterWithStyleTest.php index 9888bfc..7a7c190 100644 --- a/tests/Spout/Writer/ODS/WriterWithStyleTest.php +++ b/tests/Spout/Writer/ODS/WriterWithStyleTest.php @@ -5,6 +5,9 @@ namespace Box\Spout\Writer\ODS; use Box\Spout\Common\Type; use Box\Spout\Reader\Wrapper\XMLReader; use Box\Spout\TestUsingResource; +use Box\Spout\Writer\ODS\Helper\BorderHelper; +use Box\Spout\Writer\Style\Border; +use Box\Spout\Writer\Style\BorderBuilder; use Box\Spout\Writer\Style\Color; use Box\Spout\Writer\Style\Style; use Box\Spout\Writer\Style\StyleBuilder; @@ -208,7 +211,7 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase ]; $style = (new StyleBuilder())->setShouldWrapText()->build(); - $this->writeToODSFile($dataRows, $fileName,$style); + $this->writeToODSFile($dataRows, $fileName, $style); $styleElements = $this->getCellStyleElementsFromContentXmlFile($fileName); $this->assertEquals(2, count($styleElements), 'There should be 2 styles (default and custom)'); @@ -236,6 +239,72 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase $this->assertFirstChildHasAttributeEquals('wrap', $customStyleElement, 'table-cell-properties', 'fo:wrap-option'); } + /** + * @return void + */ + public function testBorders() + { + $fileName = 'test_borders.ods'; + + $dataRows = [ + ['row-with-border-bottom-green-thick-solid'], + ['row-without-border'], + ['row-with-border-top-red-thin-dashed'], + ]; + + $borderBottomGreenThickSolid = (new BorderBuilder()) + ->setBorderBottom(Color::GREEN, Border::WIDTH_THICK, Border::STYLE_SOLID)->build(); + + + $borderTopRedThinDashed = (new BorderBuilder()) + ->setBorderTop(Color::RED, Border::WIDTH_THIN, Border::STYLE_DASHED)->build(); + + $styles = [ + (new StyleBuilder())->setBorder($borderBottomGreenThickSolid)->build(), + (new StyleBuilder())->build(), + (new StyleBuilder())->setBorder($borderTopRedThinDashed)->build(), + ]; + + $this->writeToODSFileWithMultipleStyles($dataRows, $fileName, $styles); + + $styleElements = $this->getCellStyleElementsFromContentXmlFile($fileName); + + $this->assertEquals(3, count($styleElements), 'There should be 3 styles)'); + + // Use reflection for protected members here + $widthMap = \ReflectionHelper::getStaticValue('Box\Spout\Writer\ODS\Helper\BorderHelper', 'widthMap'); + $styleMap = \ReflectionHelper::getStaticValue('Box\Spout\Writer\ODS\Helper\BorderHelper', 'styleMap'); + + $expectedFirst = sprintf( + '%s %s #%s', + $widthMap[Border::WIDTH_THICK], + $styleMap[Border::STYLE_SOLID], + Color::GREEN + ); + + $actualFirst = $styleElements[1] + ->getElementsByTagName('table-cell-properties') + ->item(0) + ->getAttribute('fo:border-bottom'); + + $this->assertEquals($expectedFirst, $actualFirst); + + $expectedThird = sprintf( + '%s %s #%s', + $widthMap[Border::WIDTH_THIN], + $styleMap[Border::STYLE_DASHED], + Color::RED + ); + + $actualThird = $styleElements[2] + ->getElementsByTagName('table-cell-properties') + ->item(0) + ->getAttribute('fo:border-top'); + + $this->assertEquals($expectedThird, $actualThird); + } + + /** * @param array $allRows * @param string $fileName diff --git a/tests/Spout/Writer/Style/BorderTest.php b/tests/Spout/Writer/Style/BorderTest.php new file mode 100644 index 0000000..181d8cf --- /dev/null +++ b/tests/Spout/Writer/Style/BorderTest.php @@ -0,0 +1,110 @@ +addPart(new BorderPart(Border::LEFT)) + ->addPart(new BorderPart(Border::RIGHT)) + ->addPart(new BorderPart(Border::TOP)) + ->addPart(new BorderPart(Border::BOTTOM)) + ->addPart(new BorderPart(Border::LEFT)); + + $this->assertEquals(4, count($border->getParts()), 'There should never be more than 4 border parts'); + } + + /** + * @return void + */ + public function testSetParts() + { + $border = new Border(); + $border->setParts([ + new BorderPart(Border::LEFT) + ]); + + $this->assertEquals(1, count($border->getParts()), 'It should be possible to set the border parts'); + } + + /** + * @return void + */ + public function testBorderBuilderFluent() + { + $border = (new BorderBuilder()) + ->setBorderBottom() + ->setBorderTop() + ->setBorderLeft() + ->setBorderRight() + ->build(); + $this->assertEquals(4, count($border->getParts()), 'The border builder exposes a fluent interface'); + } + + /** + * :D :S + * @return void + */ + public function testAnyCombinationOfAllowedBorderPartsParams() + { + $color = Color::BLACK; + foreach (BorderPart::getAllowedNames() as $allowedName) { + foreach (BorderPart::getAllowedStyles() as $allowedStyle) { + foreach (BorderPart::getAllowedWidths() as $allowedWidth) { + $borderPart = new BorderPart($allowedName, $color, $allowedWidth, $allowedStyle); + $border = new Border(); + $border->addPart($borderPart); + $this->assertEquals(1, count($border->getParts())); + + /** @var $part BorderPart */ + $part = $border->getParts()[$allowedName]; + + $this->assertEquals($allowedStyle, $part->getStyle()); + $this->assertEquals($allowedWidth, $part->getWidth()); + $this->assertEquals($color, $part->getColor()); + } + } + } + } +} diff --git a/tests/Spout/Writer/Style/StyleTest.php b/tests/Spout/Writer/Style/StyleTest.php index fb93e9b..7d3ec36 100644 --- a/tests/Spout/Writer/Style/StyleTest.php +++ b/tests/Spout/Writer/Style/StyleTest.php @@ -129,4 +129,32 @@ class StyleTest extends \PHPUnit_Framework_TestCase $this->assertTrue($currentStyle->serialize() === $mergedStyle->serialize()); } + + /** + * @return void + */ + public function testStyleBuilderShouldApplyBorders() + { + $border = (new BorderBuilder()) + ->setBorderBottom() + ->build(); + $style = (new StyleBuilder())->setBorder($border)->build(); + $this->assertTrue($style->shouldApplyBorder()); + } + + /** + * @return void + */ + public function testStyleBuilderShouldMergeBorders() + { + $border = (new BorderBuilder())->setBorderBottom(Color::RED, Border::WIDTH_THIN, Border::STYLE_DASHED)->build(); + + $baseStyle = (new StyleBuilder())->setBorder($border)->build(); + $currentStyle = (new StyleBuilder())->build(); + $mergedStyle = $currentStyle->mergeWith($baseStyle); + + $this->assertEquals(null, $currentStyle->getBorder(), 'Current style has no border'); + $this->assertInstanceOf('Box\Spout\Writer\Style\Border', $baseStyle->getBorder(), 'Base style has a border'); + $this->assertInstanceOf('Box\Spout\Writer\Style\Border', $mergedStyle->getBorder(), 'Merged style has a border'); + } } diff --git a/tests/Spout/Writer/XLSX/WriterWithStyleTest.php b/tests/Spout/Writer/XLSX/WriterWithStyleTest.php index d43b566..df34a33 100644 --- a/tests/Spout/Writer/XLSX/WriterWithStyleTest.php +++ b/tests/Spout/Writer/XLSX/WriterWithStyleTest.php @@ -5,6 +5,8 @@ namespace Box\Spout\Writer\XLSX; use Box\Spout\Common\Type; use Box\Spout\Reader\Wrapper\XMLReader; use Box\Spout\TestUsingResource; +use Box\Spout\Writer\Style\Border; +use Box\Spout\Writer\Style\BorderBuilder; use Box\Spout\Writer\Style\Color; use Box\Spout\Writer\Style\Style; use Box\Spout\Writer\Style\StyleBuilder; @@ -205,7 +207,7 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase ]; $style = (new StyleBuilder())->setShouldWrapText()->build(); - $this->writeToXLSXFile($dataRows, $fileName,$style); + $this->writeToXLSXFile($dataRows, $fileName, $style); $cellXfsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs'); $xfElement = $cellXfsDomElement->getElementsByTagName('xf')->item(1); @@ -232,6 +234,40 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase $this->assertFirstChildHasAttributeEquals('1', $xfElement, 'alignment', 'wrapText'); } + /** + * @return void + */ + public function testBorders() + { + $fileName = 'test_borders.xlsx'; + + $dataRows = [ + ['row-with-border-bottom-green-thick-solid'], + ['row-without-border'], + ['row-with-border-top-red-thin-dashed'], + ]; + + $borderBottomGreenThickSolid = (new BorderBuilder()) + ->setBorderBottom(Color::GREEN, Border::WIDTH_THICK, Border::STYLE_SOLID)->build(); + + + $borderTopRedThinDashed = (new BorderBuilder()) + ->setBorderTop(Color::RED, Border::WIDTH_THIN, Border::STYLE_DASHED)->build(); + + $styles = [ + (new StyleBuilder())->setBorder($borderBottomGreenThickSolid)->build(), + (new StyleBuilder())->build(), + (new StyleBuilder())->setBorder($borderTopRedThinDashed)->build(), + ]; + + $this->writeToXLSXFileWithMultipleStyles($dataRows, $fileName, $styles); + $borderElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'borders'); + $this->assertEquals(3, $borderElements->getAttribute('count'), '3 borders present'); + + $styleXfsElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs'); + $this->assertEquals(3, $styleXfsElements->getAttribute('count'), '3 cell xfs present'); + } + /** * @param array $allRows * @param string $fileName