Skip to content

Commit 5f42638

Browse files
committed
Table Borders Fixes
Fix #2402. Fix #2474. Both issues deal with borders around tables when they aren't wanted. There are 3 big issues in the code, and several minor ones. First big issue - Word table styles can have both a `styleId` and a `name`, which are often different from each other, and each of which is used by various Word functions, and what documentation I can find is far from clear on the difference. I have added a `tableStyle` property (for styleId) to Style/Table, and the reader will now preserve both `styleId` and `name`. It will similarly preserve `basedOn`, which in now a private property in Style/Paragraph, but is changed to be a protected property in Style. Second big issue - Word2007 Reader assumes that table style can be specified either by name or by inline declarations, but not both. Guess what? It is now changed to support both. This makes the delta for Reader/Word2007/AbstractPart appear to be much more complicated than it actually is. The change is almost entirely of the form: ``` if (condition) {short_code_block} else {long_code_block} ``` to ``` long_code_block ``` Third big issue. In html, td does not inherit border styles from table. In word, cell border styles are specified in table styles (as insideH/V), so they do, in effect, inherit. This is resolved, as best as I can, by having each td/th without its own style use the table border style. So adding an html border style should produce a consistent result in Html and Docx output. Minor issues: - Html table (not css) attribute border=0 should set borderStyle none on all borders; any other value should set borderStyle single. - PhpWord accepts named colors from html styles. According to the documentation that I can find, Word does not recognize those, but, in practice, it often does. Nevertheless, I have added translation to hex (borrowed from PhpSpreadsheet). If nothing else, this will increase interoperability (e.g. RTF doesn't accept named colors, and html 3-hex-digit short forms are now permitted). If a color is not found in the translation table, it will be left unchanged, so there should be no impact. - Writer/Html/Style/Table now accepts colors as 6 hex digits, as well as strings. - The parsing of border css attributes is not accurate. It rejects legitimate values. One example is `2px solid red`, since PhpWord, unlike html, insists on color before style. It rejects `2px #ff0000 solid` because it doesn't accept colors as hex strings. It does not allow the omission of the size and color attributes, but css does. The parsing is rewritten to try to overcome these deficiencies. Note, BTW, that css `border:0` is not acceptable css (size needs a unit and style is omitted); this was mentioned in one of the issues as not being handled correctly, but, since it is invalid, there should be no expectation of its being handed in any particular way. - Style/Border::hasBorder is expanded to test all of Size, Color, and Style, rather than limiting its test to Size. - Properties insideHStyle and insideVStyle are added to Style/Table. Their Color and Size equivalents already existed. - If border is not specified as an Html or css attribute on a table, it is not the same as specifying html border=0 or css border:none. The end result will be whatever the app that reads the result defaults to. The results may not be consistent between, say, Html and Docx. This is already addressed in part by setting default styling for table and td in the html head section to match the Word defaults. However, there may still be differences; the way to (mostly) avoid them is to specify a table style.
1 parent 11a7aaa commit 5f42638

File tree

20 files changed

+1087
-112
lines changed

20 files changed

+1087
-112
lines changed

phpstan-baseline.neon

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -405,11 +405,6 @@ parameters:
405405
count: 1
406406
path: src/PhpWord/Shared/Html.php
407407

408-
-
409-
message: "#^Cannot call method setBorderSize\\(\\) on PhpOffice\\\\PhpWord\\\\Style\\\\Table\\|string\\.$#"
410-
count: 1
411-
path: src/PhpWord/Shared/Html.php
412-
413408
-
414409
message: "#^Cannot call method setStyleName\\(\\) on PhpOffice\\\\PhpWord\\\\Style\\\\Table\\|string\\.$#"
415410
count: 1

src/PhpWord/Reader/Word2007/AbstractPart.php

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -592,35 +592,46 @@ protected function readTableStyle(XMLReader $xmlReader, DOMElement $domNode)
592592
$borders = array_merge($margins, ['insideH', 'insideV']);
593593

594594
if ($xmlReader->elementExists('w:tblPr', $domNode)) {
595+
$tblStyleName = '';
595596
if ($xmlReader->elementExists('w:tblPr/w:tblStyle', $domNode)) {
596-
$style = $xmlReader->getAttribute('w:val', $domNode, 'w:tblPr/w:tblStyle');
597-
} else {
598-
$styleNode = $xmlReader->getElement('w:tblPr', $domNode);
599-
$styleDefs = [];
600-
foreach ($margins as $side) {
601-
$ucfSide = ucfirst($side);
602-
$styleDefs["cellMargin$ucfSide"] = [self::READ_VALUE, "w:tblCellMar/w:$side", 'w:w'];
603-
}
604-
foreach ($borders as $side) {
605-
$ucfSide = ucfirst($side);
606-
$styleDefs["border{$ucfSide}Size"] = [self::READ_VALUE, "w:tblBorders/w:$side", 'w:sz'];
607-
$styleDefs["border{$ucfSide}Color"] = [self::READ_VALUE, "w:tblBorders/w:$side", 'w:color'];
608-
$styleDefs["border{$ucfSide}Style"] = [self::READ_VALUE, "w:tblBorders/w:$side", 'w:val'];
609-
}
610-
$styleDefs['layout'] = [self::READ_VALUE, 'w:tblLayout', 'w:type'];
611-
$styleDefs['bidiVisual'] = [self::READ_TRUE, 'w:bidiVisual'];
612-
$styleDefs['cellSpacing'] = [self::READ_VALUE, 'w:tblCellSpacing', 'w:w'];
613-
$style = $this->readStyleDefs($xmlReader, $styleNode, $styleDefs);
614-
615-
$tablePositionNode = $xmlReader->getElement('w:tblpPr', $styleNode);
616-
if ($tablePositionNode !== null) {
617-
$style['position'] = $this->readTablePosition($xmlReader, $tablePositionNode);
618-
}
597+
$tblStyleName = $xmlReader->getAttribute('w:val', $domNode, 'w:tblPr/w:tblStyle');
598+
}
599+
$styleNode = $xmlReader->getElement('w:tblPr', $domNode);
600+
$styleDefs = [];
619601

620-
$indentNode = $xmlReader->getElement('w:tblInd', $styleNode);
621-
if ($indentNode !== null) {
622-
$style['indent'] = $this->readTableIndent($xmlReader, $indentNode);
623-
}
602+
foreach ($margins as $side) {
603+
$ucfSide = ucfirst($side);
604+
$styleDefs["cellMargin$ucfSide"] = [self::READ_VALUE, "w:tblCellMar/w:$side", 'w:w'];
605+
}
606+
foreach ($borders as $side) {
607+
$ucfSide = ucfirst($side);
608+
$styleDefs["border{$ucfSide}Size"] = [self::READ_VALUE, "w:tblBorders/w:$side", 'w:sz'];
609+
$styleDefs["border{$ucfSide}Color"] = [self::READ_VALUE, "w:tblBorders/w:$side", 'w:color'];
610+
$styleDefs["border{$ucfSide}Style"] = [self::READ_VALUE, "w:tblBorders/w:$side", 'w:val'];
611+
}
612+
$styleDefs['layout'] = [self::READ_VALUE, 'w:tblLayout', 'w:type'];
613+
$styleDefs['bidiVisual'] = [self::READ_TRUE, 'w:bidiVisual'];
614+
$styleDefs['cellSpacing'] = [self::READ_VALUE, 'w:tblCellSpacing', 'w:w'];
615+
$style = $this->readStyleDefs($xmlReader, $styleNode, $styleDefs);
616+
617+
$tablePositionNode = $xmlReader->getElement('w:tblpPr', $styleNode);
618+
if ($tablePositionNode !== null) {
619+
$style['position'] = $this->readTablePosition($xmlReader, $tablePositionNode);
620+
}
621+
622+
$indentNode = $xmlReader->getElement('w:tblInd', $styleNode);
623+
if ($indentNode !== null) {
624+
$style['indent'] = $this->readTableIndent($xmlReader, $indentNode);
625+
}
626+
if ($xmlReader->elementExists('w:basedOn', $domNode)) {
627+
$style['basedOn'] = $xmlReader->getAttribute('w:val', $domNode, 'w:basedOn');
628+
}
629+
if ($tblStyleName !== '') {
630+
$style['tblStyle'] = $tblStyleName;
631+
}
632+
// this may be unneeded
633+
if ($xmlReader->elementExists('w:name', $domNode)) {
634+
$style['styleName'] = $xmlReader->getAttribute('w:val', $domNode, 'w:name');
624635
}
625636
}
626637

src/PhpWord/Reader/Word2007/Styles.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ public function read(PhpWord $phpWord): void
6363
foreach ($nodes as $node) {
6464
$type = $xmlReader->getAttribute('w:type', $node);
6565
$name = $xmlReader->getAttribute('w:val', $node, 'w:name');
66+
$styleId = $xmlReader->getAttribute('w:styleId', $node);
6667
if (null === $name) {
67-
$name = $xmlReader->getAttribute('w:styleId', $node);
68+
$name = $styleId;
6869
}
6970
$headingMatches = [];
7071
preg_match('/Heading\s*(\d)/i', $name, $headingMatches);
@@ -96,7 +97,8 @@ public function read(PhpWord $phpWord): void
9697
case 'table':
9798
$tStyle = $this->readTableStyle($xmlReader, $node);
9899
if (!empty($tStyle)) {
99-
$phpWord->addTableStyle($name, $tStyle);
100+
$newTable = $phpWord->addTableStyle($styleId, $tStyle);
101+
$newTable->setStyleName($name);
100102
}
101103

102104
break;

src/PhpWord/Shared/Html.php

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use PhpOffice\PhpWord\Element\Row;
2727
use PhpOffice\PhpWord\Element\Table;
2828
use PhpOffice\PhpWord\Settings;
29+
use PhpOffice\PhpWord\SimpleType\Border;
2930
use PhpOffice\PhpWord\SimpleType\Jc;
3031
use PhpOffice\PhpWord\SimpleType\NumberFormat;
3132
use PhpOffice\PhpWord\Style\Paragraph;
@@ -37,6 +38,8 @@
3738
*/
3839
class Html
3940
{
41+
private const SPECIAL_BORDER_WIDTHS = ['thin' => '0.5pt', 'thick' => '3.5pt', 'medium' => '2.0pt'];
42+
4043
protected static $listIndex = 0;
4144

4245
protected static $xpath;
@@ -142,7 +145,7 @@ protected static function parseInlineStyle($node, $styles = [])
142145
break;
143146
case 'bgcolor':
144147
// tables, rows, cells e.g. <tr bgColor="#FF0000">
145-
$styles['bgColor'] = trim($val, '# ');
148+
HtmlColours::setArrayColour($styles, 'bgColor', $val);
146149

147150
break;
148151
case 'valign':
@@ -421,9 +424,10 @@ protected static function parseTable($node, $element, &$styles)
421424
}
422425

423426
$attributes = $node->attributes;
424-
if ($attributes->getNamedItem('border') !== null) {
427+
if ($attributes->getNamedItem('border') !== null && is_object($newElement->getStyle())) {
425428
$border = (int) $attributes->getNamedItem('border')->value;
426-
$newElement->getStyle()->setBorderSize(Converter::pixelToTwip($border));
429+
$newElement->getStyle()->setBorderSize((int) Converter::pixelToTwip($border));
430+
$newElement->getStyle()->setBorderStyle(($border === 0) ? 'none' : 'single');
427431
}
428432

429433
return $newElement;
@@ -720,11 +724,11 @@ protected static function parseStyleDeclarations(array $selectors, array $styles
720724

721725
break;
722726
case 'color':
723-
$styles['color'] = trim($value, '#');
727+
HtmlColours::setArrayColour($styles, 'color', $value);
724728

725729
break;
726730
case 'background-color':
727-
$styles['bgColor'] = trim($value, '#');
731+
HtmlColours::setArrayColour($styles, 'bgColor', $value);
728732

729733
break;
730734
case 'line-height':
@@ -804,7 +808,7 @@ protected static function parseStyleDeclarations(array $selectors, array $styles
804808

805809
break;
806810
case 'border-width':
807-
$styles['borderSize'] = Converter::cssToPoint($value);
811+
$styles['borderSize'] = Converter::cssToPoint(self::SPECIAL_BORDER_WIDTHS[$value] ?? $value);
808812

809813
break;
810814
case 'border-style':
@@ -834,29 +838,46 @@ protected static function parseStyleDeclarations(array $selectors, array $styles
834838
case 'border-bottom':
835839
case 'border-right':
836840
case 'border-left':
837-
// must have exact order [width color style], e.g. "1px #0011CC solid" or "2pt green solid"
838-
// Word does not accept shortened hex colors e.g. #CCC, only full e.g. #CCCCCC
839-
if (preg_match('/([0-9]+[^0-9]*)\s+(\#[a-fA-F0-9]+|[a-zA-Z]+)\s+([a-z]+)/', $value, $matches)) {
840-
if (false !== strpos($property, '-')) {
841-
$tmp = explode('-', $property);
842-
$which = $tmp[1];
843-
$which = ucfirst($which); // e.g. bottom -> Bottom
844-
} else {
845-
$which = '';
846-
}
847-
// Note - border width normalization:
848-
// Width of border in Word is calculated differently than HTML borders, usually showing up too bold.
849-
// Smallest 1px (or 1pt) appears in Word like 2-3px/pt in HTML once converted to twips.
850-
// Therefore we need to normalize converted twip value to cca 1/2 of value.
851-
// This may be adjusted, if better ratio or formula found.
852-
// BC change: up to ver. 0.17.0 was $size converted to points - Converter::cssToPoint($size)
853-
$size = Converter::cssToTwip($matches[1]);
841+
$stylePattern = '/(^|\\s)(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)(\\s|$)/';
842+
if (!preg_match($stylePattern, $value, $matches)) {
843+
break;
844+
}
845+
$borderStyle = $matches[2];
846+
$value = preg_replace($stylePattern, ' ', $value) ?? '';
847+
$borderSize = $borderColor = null;
848+
$sizePattern = '/(^|\\s)([0-9]+([.][0-9]+)?+(%|[a-z]*)|thick|thin|medium)(\\s|$)/';
849+
if (preg_match($sizePattern, $value, $matches)) {
850+
$borderSize = $matches[2];
851+
$borderSize = self::SPECIAL_BORDER_WIDTHS[$borderSize] ?? $borderSize;
852+
$value = preg_replace($sizePattern, ' ', $value) ?? '';
853+
}
854+
$colorPattern = '/(^|\\s)([#][a-fA-F0-9]{6}|[#][a-fA-F0-9]{3}|[a-z][a-z0-9]+)(\\s|$)/';
855+
if (preg_match($colorPattern, $value, $matches)) {
856+
$borderColor = HtmlColours::convertColour($matches[2]);
857+
}
858+
if (false !== strpos($property, '-')) {
859+
$tmp = explode('-', $property);
860+
$which = $tmp[1];
861+
$which = ucfirst($which); // e.g. bottom -> Bottom
862+
} else {
863+
$which = '';
864+
}
865+
// Note - border width normalization:
866+
// Width of border in Word is calculated differently than HTML borders, usually showing up too bold.
867+
// Smallest 1px (or 1pt) appears in Word like 2-3px/pt in HTML once converted to twips.
868+
// Therefore we need to normalize converted twip value to cca 1/2 of value.
869+
// This may be adjusted, if better ratio or formula found.
870+
// BC change: up to ver. 0.17.0 was $size converted to points - Converter::cssToPoint($size)
871+
if ($borderSize !== null) {
872+
$size = Converter::cssToTwip($borderSize);
854873
$size = (int) ($size / 2);
855874
// valid variants may be e.g. borderSize, borderTopSize, borderLeftColor, etc ..
856875
$styles["border{$which}Size"] = $size; // twips
857-
$styles["border{$which}Color"] = trim($matches[2], '#');
858-
$styles["border{$which}Style"] = self::mapBorderStyle($matches[3]);
859876
}
877+
if (!empty($borderColor)) {
878+
$styles["border{$which}Color"] = $borderColor;
879+
}
880+
$styles["border{$which}Style"] = self::mapBorderStyle($borderStyle);
860881

861882
break;
862883
case 'vertical-align':
@@ -1006,21 +1027,23 @@ protected static function mapBorderStyle($cssBorderStyle)
10061027
case 'dotted':
10071028
case 'double':
10081029
return $cssBorderStyle;
1030+
case 'hidden':
1031+
return 'none';
10091032
default:
10101033
return 'single';
10111034
}
10121035
}
10131036

10141037
protected static function mapBorderColor(&$styles, $cssBorderColor): void
10151038
{
1016-
$numColors = substr_count($cssBorderColor, '#');
1039+
$colors = explode(' ', $cssBorderColor);
1040+
$numColors = count($colors);
10171041
if ($numColors === 1) {
1018-
$styles['borderColor'] = trim($cssBorderColor, '#');
1019-
} elseif ($numColors > 1) {
1020-
$colors = explode(' ', $cssBorderColor);
1042+
HtmlColours::setArrayColour($styles, 'borderColor', $cssBorderColor);
1043+
} else {
10211044
$borders = ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'];
10221045
for ($i = 0; $i < min(4, $numColors, count($colors)); ++$i) {
1023-
$styles[$borders[$i]] = trim($colors[$i], '#');
1046+
HtmlColours::setArrayColour($styles, $borders[$i], $colors[$i]);
10241047
}
10251048
}
10261049
}

0 commit comments

Comments
 (0)