Skip to content

Wrong rowspan for difficult tables #2859

@SainTHedgehog

Description

@SainTHedgehog

Describe the problem

When generating an HTML file from a DOCX using:

	\PhpOffice\PhpWord\Settings::setTempDir(CMS_FOLDER . TMP_DIR);
	$phpWord = \PhpOffice\PhpWord\IOFactory::load($this->_sourceDocx);
	$htmlWriter = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'HTML');

	$docProps = $phpWord->getDocInfo();
	$docProps->setTitle($this->_title);

	$this->_filePath = tempnam(CMS_FOLDER . TMP_DIR, 'HTM');
	$htmlWriter->save($this->_filePath);

If the source DOCX contains complex tables with row and column merging, the following issues occur:

  1. It incorrectly calculates the rowspan for merged cells.
  2. It applies styles only from the first cell, which can cause the loss of styles, such as cell borders, in merged cells.

Describe the expected behavior

Fixed \PHPOffice\PhpWord\Writer\HTML\Element\Table.php

<?php
/**
 * This file is part of PHPWord - A pure PHP library for reading and writing
 * word processing documents.
 *
 * PHPWord is free software distributed under the terms of the GNU Lesser
 * General Public License version 3 as published by the Free Software Foundation.
 *
 * For the full copyright and license information, please read the LICENSE
 * file that was distributed with this source code. For the full list of
 * contributors, visit https://github.com/PHPOffice/PHPWord/contributors.
 *
 * @see         https://github.com/PHPOffice/PHPWord
 * @copyright   2010-2018 PHPWord contributors
 * @license     http://www.gnu.org/licenses/lgpl.txt LGPL version 3
 */

namespace PhpOffice\PhpWord\Writer\HTML\Element;

use PhpOffice\PhpWord\Writer\HTML\Style\Table as TableStyleWriter;
use PhpOffice\PhpWord\Writer\HTML\Style\Cell as CellStyleWriter;
use PhpOffice\PhpWord\Writer\HTML\Style\Paragraph as ParagraphStyleWriter;


/**
 * Table element HTML writer
 *
 * @since 0.10.0
 */
class Table extends AbstractElement
{
    /**
     * Write table
     *
     * @return string
     */
	public function write()
	{
		if (!$this->element instanceof \PhpOffice\PhpWord\Element\Table) {
			return '';
		}

		$content = '';
		$rows = $this->element->getRows();
		$rowCount = count($rows);
		if ($rowCount > 0) {
			$tableStyle = self::getTableStyle($this->element->getStyle());

			$content .= '<table cellpadding="0" cellspacing="0"';

			if (is_object($tableStyle)) {
				$styleWriter = new TableStyleWriter($tableStyle);
				$content .= ' style="' . $styleWriter->write() . '"';
			} else {
				$content .= ' class="' . htmlspecialchars($tableStyle) . '"';
			}

			$content .= '>' . PHP_EOL;

			// Массив для отслеживания объединенных ячеек
			$mergedCells = [];

			for ($i = 0; $i < $rowCount; $i++) {
				/** @var $row \PhpOffice\PhpWord\Element\Row Type hint */
				$rowStyle = $rows[$i]->getStyle();
				$height = $rows[$i]->getHeight();
				$tblHeader = $rowStyle->isTblHeader();

				$sTrStyle = '';
				$height && $sTrStyle .= 'height: ' . intval($height * 0.2 / 3) . 'px';

				$content .= '<tr style="' . $sTrStyle . '">' . PHP_EOL;

				$rowCells = $rows[$i]->getCells();
				$rowCellCount = count($rowCells);

				// Учитываем смещение из-за объединенных ячеек из предыдущих строк
				$colOffset = 0;

				for ($j = 0; $j < $rowCellCount; $j++) {
					$cellStyle = $rowCells[$j]->getStyle();
					$width = $rowCells[$j]->getWidth();
					$cellColSpan = $cellStyle->getGridSpan();
					$cellRowSpan = 1;
					$cellVMerge = $cellStyle->getVMerge();

					// Проверяем, не является ли текущая ячейка продолжением объединения из предыдущих строк
					$isMergedFromAbove = false;
					foreach ($mergedCells as $mergedCell) {
						if ($mergedCell['row'] < $i &&
							$mergedCell['col'] == ($j + $colOffset) &&
							$mergedCell['rowSpan'] > ($i - $mergedCell['row'])) {
							$isMergedFromAbove = true;
							break;
						}
					}

					if ($isMergedFromAbove) {
						// Пропускаем ячейку, так как она уже объединена с ячейкой выше
						continue;
					}

					if ($cellVMerge === 'restart') {
						$cellRowSpan = $this->calculateCellRowSpan($rows, $i, $j);

						// Запоминаем объединенную ячейку
						$mergedCells[] = [
							'row' => $i,
							'col' => $j + $colOffset,
							'rowSpan' => $cellRowSpan
						];
					}

					if ($cellVMerge !== 'continue' && $cellVMerge !== '') {
						$cellTag = $tblHeader ? 'th' : 'td';
						$cellColSpanAttr = (is_numeric($cellColSpan) && ($cellColSpan > 1) ? " colspan=\"{$cellColSpan}\"" : '');
						$cellRowSpanAttr = ($cellRowSpan > 1 ? " rowspan=\"{$cellRowSpan}\"" : '');

						$sTdStyle = '';
						$width && $sTdStyle .= 'width: ' . intval($width * 0.2 / 3) . 'px; ';

						$mergedStyles = [];
						if ($cellRowSpan > 1) {
							for ($k = $i; $k < $i + $cellRowSpan; $k++) {
								if (isset($rows[$k])) {
									$kRowCells = $rows[$k]->getCells();
									$currentColIndex = 0;
									foreach ($kRowCells as $kCell) {
										if ($currentColIndex == $j + $colOffset) {
											$mergedCellStyle = $kCell->getStyle();
											$mergedStyles[] = $mergedCellStyle;
											break;
										}
										$currentColIndex += max(1, $kCell->getStyle()->getGridSpan());
									}
								}
							}
						} else {
							$mergedStyles[] = $cellStyle;
						}

						// Объединяем стили границ
						$finalCellStyle = clone $cellStyle;
						$hasBottomBorder = false;

						foreach ($mergedStyles as $style) {
							if ($style->getBorderBottomSize() > 0 ||
								$style->getBorderBottomColor() !== null ||
								$style->getBorderBottomStyle() !== null) {
								$hasBottomBorder = true;
								// Копируем нижнюю границу из любой ячейки, где она есть
								$finalCellStyle->setBorderBottomSize($style->getBorderBottomSize());
								$finalCellStyle->setBorderBottomColor($style->getBorderBottomColor());
								$finalCellStyle->setBorderBottomStyle($style->getBorderBottomStyle());
							}

							// Также проверяем другие границы
							if ($style->getBorderTopSize() > 0) {
								$finalCellStyle->setBorderTopSize($style->getBorderTopSize());
								$finalCellStyle->setBorderTopColor($style->getBorderTopColor());
								$finalCellStyle->setBorderTopStyle($style->getBorderTopStyle());
							}
							if ($style->getBorderLeftSize() > 0) {
								$finalCellStyle->setBorderLeftSize($style->getBorderLeftSize());
								$finalCellStyle->setBorderLeftColor($style->getBorderLeftColor());
								$finalCellStyle->setBorderLeftStyle($style->getBorderLeftStyle());
							}
							if ($style->getBorderRightSize() > 0) {
								$finalCellStyle->setBorderRightSize($style->getBorderRightSize());
								$finalCellStyle->setBorderRightColor($style->getBorderRightColor());
								$finalCellStyle->setBorderRightStyle($style->getBorderRightStyle());
							}
						}

						// Если в объединенных ячейках не было нижней границы, но нам нужно ее добавить
						if ($cellRowSpan > 1 && !$hasBottomBorder) {
							// Проверяем последнюю объединенную ячейку
							$lastRowIndex = $i + $cellRowSpan - 1;
							if (isset($rows[$lastRowIndex])) {
								$lastRowCells = $rows[$lastRowIndex]->getCells();
								$currentColIndex = 0;
								foreach ($lastRowCells as $lastCell) {
									if ($currentColIndex == $j + $colOffset) {
										$lastCellStyle = $lastCell->getStyle();
										if ($lastCellStyle->getBorderBottomSize() > 0) {
											$finalCellStyle->setBorderBottomSize($lastCellStyle->getBorderBottomSize());
											$finalCellStyle->setBorderBottomColor($lastCellStyle->getBorderBottomColor());
											$finalCellStyle->setBorderBottomStyle($lastCellStyle->getBorderBottomStyle());
										}
										break;
									}
									$currentColIndex += max(1, $lastCell->getStyle()->getGridSpan());
								}
							}
						}

						$styleWriter = new CellStyleWriter($finalCellStyle);
						$sTdStyle .= $styleWriter->write();

						// Добавляем стили параграфа
						$x = $rowCells[$j]->getElement(0);
						if ($x) {
							$paragraphStyle = $x->getParagraphStyle();
							$ParagraphStyleWriter = new ParagraphStyleWriter($paragraphStyle);
							$sTdStyle .= $ParagraphStyleWriter->write();
						}

						$content .= "<{$cellTag}{$cellColSpanAttr}{$cellRowSpanAttr} style=\"" . $sTdStyle . "\">" . PHP_EOL;

						$writer = new Container($this->parentWriter, $rowCells[$j]);
						$insideContent = $writer->write();

						if ($cellRowSpan > 1) {
							// Добавляем контент из объединенных ячеек
							for ($k = $i + 1; $k < $i + $cellRowSpan; $k++) {
								if (isset($rows[$k])) {
									$kRowCells = $rows[$k]->getCells();
									$currentColIndex = 0;
									foreach ($kRowCells as $kCell) {
										if ($currentColIndex == $j + $colOffset) {
											$writer = new Container($this->parentWriter, $kCell);
											$insideContent .= $writer->write();
											break;
										}
										$currentColIndex += max(1, $kCell->getStyle()->getGridSpan());
									}
								}
							}
						}

						// HostCMS
						//if (trim($insideContent) !== '<p>&nbsp;</p>')
						//{
						$insideContent = preg_replace('/(?:<p>&nbsp;<\/p>\s*)+$/i', '', $insideContent);
						//}

						// Default content for empty cell without defined height
						$content .= $insideContent != '' || $height
							? $insideContent
							: '<br />';

						$content .= "</{$cellTag}>" . PHP_EOL;
					}

					// Увеличиваем смещение для учета colspan
					$colOffset += max(1, $cellColSpan) - 1;
				}
				$content .= '</tr>' . PHP_EOL;
			}
			$content .= '</table>' . PHP_EOL;
		}

		return $content;
	}

    /**
     * Translates Table style in CSS equivalent
     *
     * @param string|\PhpOffice\PhpWord\Style\Table|null $tableStyle
     * @return string
     */
    private function getTableStyle($tableStyle = null)
    {
        if ($tableStyle == null) {
            return '';
        }
        if (is_string($tableStyle)) {
            $style = ' class="' . $tableStyle;
        } else {
            $style = ' style="';
            if ($tableStyle->getLayout() == \PhpOffice\PhpWord\Style\Table::LAYOUT_FIXED) {
                $style .= 'table-layout: fixed;';
            } elseif ($tableStyle->getLayout() == \PhpOffice\PhpWord\Style\Table::LAYOUT_AUTO) {
                $style .= 'table-layout: auto;';
            }
        }

        return $style . '"';
    }

    /**
     * Calculates cell rowspan.
     *
     * @param \PhpOffice\PhpWord\Element\Row[] $rows
     */
	private function calculateCellRowSpan(array $rows, int $rowIndex, int $colIndex): int
	{
		$currentRow = $rows[$rowIndex];
		$currentRowCells = $currentRow->getCells();
		$shiftedColIndex = 0;

		// Вычисляем смещенный индекс с учетом colspan в текущей строке
		for ($i = 0; $i < $colIndex; $i++) {
			$cell = $currentRowCells[$i];
			$colSpan = 1;
			if ($cell->getStyle()->getGridSpan() !== null) {
				$colSpan = $cell->getStyle()->getGridSpan();
			}
			$shiftedColIndex += $colSpan;
		}

		$rowCount = count($rows);
		$rowSpan = 1;

		for ($i = $rowIndex + 1; $i < $rowCount; ++$i) {
			$rowCells = $rows[$i]->getCells();
			$currentColIndex = 0;
			$found = false;

			foreach ($rowCells as $cell) {
				if ($currentColIndex === $shiftedColIndex) {
					$vMerge = $cell->getStyle()->getVMerge();
					if ($vMerge === 'continue' || $vMerge === '') {
						++$rowSpan;
						$found = true;
					}
					break;
				}

				$colSpan = 1;
				if ($cell->getStyle()->getGridSpan() !== null) {
					$colSpan = $cell->getStyle()->getGridSpan();
				}
				$currentColIndex += $colSpan;
			}

			if (!$found) {
				break;
			}
		}

		return $rowSpan;
	}
}

Priority

  • I want to crowdfund the feature (with @algora-io) and fund a community developer.
  • I want to pay the feature and fund a maintainer for that. (Contact @Progi1984)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions