Skip to content

Commit 570660f

Browse files
authored
Column Widths, Especially for ODS (#3610)
Fix #3609. Reporter created a spreadsheet with non-adjacent columns having non-default widths. Ods Writer needs to generate entries for the missing columns with default width. The fix was fairly simple. Testing it was not. Ods Reader basically ignores all styles; they are complicated, declared in (at least) 2 places (content.xml and styles.xml). This change deals only with the problem as reported, in which the missing declarations should be in content.xml. If someone reports a real-world example of this involving styles.xml, I can look at that then. In the meantime, this toehold might serve as a template for adding other style processing to Ods Reader. Looking at other formats, processing of column widths was also missing from Html Reader, and is now added. Note that this will work only with inline Css declarations on the `col` tags, which can be generated by Html Writer using `setUseInlineCss(true)`. This creates a much larger file than one created without inline CSS. A general problem became evident when studying the code. Worksheet `columnDimensions` is an unsorted array. This is not a problem per se, but it can easily lead to unexpected results from a `getColumnDimensions` call. The code is changed to sort the array before returning it in `getColumnDimensions`. One existing test failed as a result of this change. It errorneously tested `getHighestColumn` instead of `getHighestDataColumn`, which caused a problem because the final column declaration included a `number-columns-repeated` attribute. The new result for `getHighestColumn` is correct, and the test is changed to use `getHighestDataColumn` instead, which was certainly its intent.
1 parent eba7271 commit 570660f

File tree

6 files changed

+145
-10
lines changed

6 files changed

+145
-10
lines changed

src/PhpSpreadsheet/Reader/Html.php

+20-8
Original file line numberDiff line numberDiff line change
@@ -491,9 +491,12 @@ private function processDomElementImg(Worksheet $sheet, int &$row, string &$colu
491491
}
492492
}
493493

494+
private string $currentColumn = 'A';
495+
494496
private function processDomElementTable(Worksheet $sheet, int &$row, string &$column, string &$cellContent, DOMElement $child, array &$attributeArray): void
495497
{
496498
if ($child->nodeName === 'table') {
499+
$this->currentColumn = 'A';
497500
$this->flushCell($sheet, $column, $row, $cellContent, $attributeArray);
498501
$column = $this->setTableStartColumn($column);
499502
if ($this->tableLevel > 1 && $row > 1) {
@@ -513,7 +516,10 @@ private function processDomElementTable(Worksheet $sheet, int &$row, string &$co
513516

514517
private function processDomElementTr(Worksheet $sheet, int &$row, string &$column, string &$cellContent, DOMElement $child, array &$attributeArray): void
515518
{
516-
if ($child->nodeName === 'tr') {
519+
if ($child->nodeName === 'col') {
520+
$this->applyInlineStyle($sheet, -1, $this->currentColumn, $attributeArray);
521+
++$this->currentColumn;
522+
} elseif ($child->nodeName === 'tr') {
517523
$column = $this->getTableStartColumn();
518524
$cellContent = '';
519525
$this->processDomElement($child, $sheet, $row, $column, $cellContent);
@@ -877,7 +883,9 @@ private function applyInlineStyle(Worksheet &$sheet, $row, $column, $attributeAr
877883
return;
878884
}
879885

880-
if (isset($attributeArray['rowspan'], $attributeArray['colspan'])) {
886+
if ($row <= 0 || $column === '') {
887+
$cellStyle = new Style();
888+
} elseif (isset($attributeArray['rowspan'], $attributeArray['colspan'])) {
881889
$columnTo = $column;
882890
for ($i = 0; $i < (int) $attributeArray['colspan'] - 1; ++$i) {
883891
++$columnTo;
@@ -1009,16 +1017,20 @@ private function applyInlineStyle(Worksheet &$sheet, $row, $column, $attributeAr
10091017
break;
10101018

10111019
case 'width':
1012-
$sheet->getColumnDimension($column)->setWidth(
1013-
(new CssDimension($styleValue ?? ''))->width()
1014-
);
1020+
if ($column !== '') {
1021+
$sheet->getColumnDimension($column)->setWidth(
1022+
(new CssDimension($styleValue ?? ''))->width()
1023+
);
1024+
}
10151025

10161026
break;
10171027

10181028
case 'height':
1019-
$sheet->getRowDimension($row)->setRowHeight(
1020-
(new CssDimension($styleValue ?? ''))->height()
1021-
);
1029+
if ($row > 0) {
1030+
$sheet->getRowDimension($row)->setRowHeight(
1031+
(new CssDimension($styleValue ?? ''))->height()
1032+
);
1033+
}
10221034

10231035
break;
10241036

src/PhpSpreadsheet/Reader/Ods.php

+40
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use DOMNode;
99
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
1010
use PhpOffice\PhpSpreadsheet\Cell\DataType;
11+
use PhpOffice\PhpSpreadsheet\Helper\Dimension as HelperDimension;
1112
use PhpOffice\PhpSpreadsheet\Reader\Ods\AutoFilter;
1213
use PhpOffice\PhpSpreadsheet\Reader\Ods\DefinedNames;
1314
use PhpOffice\PhpSpreadsheet\Reader\Ods\FormulaTranslator;
@@ -295,11 +296,29 @@ public function loadIntoExisting($filename, Spreadsheet $spreadsheet)
295296
$tableNs = $dom->lookupNamespaceUri('table');
296297
$textNs = $dom->lookupNamespaceUri('text');
297298
$xlinkNs = $dom->lookupNamespaceUri('xlink');
299+
$styleNs = $dom->lookupNamespaceUri('style');
298300

299301
$pageSettings->readStyleCrossReferences($dom);
300302

301303
$autoFilterReader = new AutoFilter($spreadsheet, $tableNs);
302304
$definedNameReader = new DefinedNames($spreadsheet, $tableNs);
305+
$columnWidths = [];
306+
$automaticStyle0 = $dom->getElementsByTagNameNS($officeNs, 'automatic-styles')->item(0);
307+
$automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'style');
308+
foreach ($automaticStyles as $automaticStyle) {
309+
$styleName = $automaticStyle->getAttributeNS($styleNs, 'name');
310+
$styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
311+
if ($styleFamily === 'table-column') {
312+
$tcprops = $automaticStyle->getElementsByTagNameNS($styleNs, 'table-column-properties');
313+
if ($tcprops !== null) {
314+
$tcprop = $tcprops->item(0);
315+
if ($tcprop !== null) {
316+
$columnWidth = $tcprop->getAttributeNs($styleNs, 'column-width');
317+
$columnWidths[$styleName] = $columnWidth;
318+
}
319+
}
320+
}
321+
}
303322

304323
// Content
305324
$item0 = $dom->getElementsByTagNameNS($officeNs, 'body')->item(0);
@@ -340,6 +359,7 @@ public function loadIntoExisting($filename, Spreadsheet $spreadsheet)
340359

341360
// Go through every child of table element
342361
$rowID = 1;
362+
$tableColumnIndex = 1;
343363
foreach ($worksheetDataSet->childNodes as $childNode) {
344364
/** @var DOMElement $childNode */
345365

@@ -366,6 +386,26 @@ public function loadIntoExisting($filename, Spreadsheet $spreadsheet)
366386
// $rowData = $cellData;
367387
// break;
368388
// }
389+
break;
390+
case 'table-column':
391+
if ($childNode->hasAttributeNS($tableNs, 'number-columns-repeated')) {
392+
$rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-columns-repeated');
393+
} else {
394+
$rowRepeats = 1;
395+
}
396+
$tableStyleName = $childNode->getAttributeNS($tableNs, 'style-name');
397+
if (isset($columnWidths[$tableStyleName])) {
398+
$columnWidth = new HelperDimension($columnWidths[$tableStyleName]);
399+
$tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex);
400+
for ($rowRepeats2 = $rowRepeats; $rowRepeats2 > 0; --$rowRepeats2) {
401+
$spreadsheet->getActiveSheet()
402+
->getColumnDimension($tableColumnString)
403+
->setWidth($columnWidth->toUnit('cm'), 'cm');
404+
++$tableColumnString;
405+
}
406+
}
407+
$tableColumnIndex += $rowRepeats;
408+
369409
break;
370410
case 'table-row':
371411
if ($childNode->hasAttributeNS($tableNs, 'number-rows-repeated')) {

src/PhpSpreadsheet/Worksheet/Worksheet.php

+9
Original file line numberDiff line numberDiff line change
@@ -543,9 +543,18 @@ public function getDefaultRowDimension()
543543
*/
544544
public function getColumnDimensions()
545545
{
546+
/** @var callable */
547+
$callable = [self::class, 'columnDimensionCompare'];
548+
uasort($this->columnDimensions, $callable);
549+
546550
return $this->columnDimensions;
547551
}
548552

553+
private static function columnDimensionCompare(ColumnDimension $a, ColumnDimension $b): int
554+
{
555+
return $a->getColumnNumeric() - $b->getColumnNumeric();
556+
}
557+
549558
/**
550559
* Get default column dimension.
551560
*

src/PhpSpreadsheet/Writer/Ods/Content.php

+9
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,16 @@ private function writeSheets(XMLWriter $objWriter): void
126126
$objWriter->writeAttribute('table:name', $spreadsheet->getSheet($sheetIndex)->getTitle());
127127
$objWriter->writeAttribute('table:style-name', Style::TABLE_STYLE_PREFIX . (string) ($sheetIndex + 1));
128128
$objWriter->writeElement('office:forms');
129+
$lastColumn = 0;
129130
foreach ($spreadsheet->getSheet($sheetIndex)->getColumnDimensions() as $columnDimension) {
131+
$thisColumn = $columnDimension->getColumnNumeric();
132+
$emptyColumns = $thisColumn - $lastColumn - 1;
133+
if ($emptyColumns > 0) {
134+
$objWriter->startElement('table:table-column');
135+
$objWriter->writeAttribute('table:number-columns-repeated', (string) $emptyColumns);
136+
$objWriter->endElement();
137+
}
138+
$lastColumn = $thisColumn;
130139
$objWriter->startElement('table:table-column');
131140
$objWriter->writeAttribute(
132141
'table:style-name',

tests/PhpSpreadsheetTests/Reader/Ods/OdsTest.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ public function testReadValueAndComments(): void
141141

142142
$firstSheet = $spreadsheet->getSheet(0);
143143

144-
self::assertEquals(29, $firstSheet->getHighestRow());
145-
self::assertEquals('N', $firstSheet->getHighestColumn());
144+
self::assertEquals(29, $firstSheet->getHighestDataRow());
145+
self::assertEquals('N', $firstSheet->getHighestDataColumn());
146146

147147
// Simple cell value
148148
self::assertEquals('Test String 1', $firstSheet->getCell('A1')->getValue());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheetTests\Worksheet;
4+
5+
use PhpOffice\PhpSpreadsheet\Helper\Dimension;
6+
use PhpOffice\PhpSpreadsheet\Spreadsheet;
7+
use PhpOffice\PhpSpreadsheet\Writer\Html;
8+
use PhpOffice\PhpSpreadsheetTests\Functional\AbstractFunctional;
9+
10+
class ColumnDimension2Test extends AbstractFunctional
11+
{
12+
/**
13+
* @dataProvider providerType
14+
*/
15+
public function testSetsAndDefaults(string $type): void
16+
{
17+
$columns = ['J', 'A', 'F', 'M', 'N', 'T', 'S'];
18+
$spreadsheet = new Spreadsheet();
19+
$sheet = $spreadsheet->getActiveSheet();
20+
$expectedCm = 0.45;
21+
foreach ($columns as $column) {
22+
$sheet->getColumnDimension($column)->setWidth($expectedCm, 'cm');
23+
}
24+
if ($type === 'Html') {
25+
$reloadedSpreadsheet = $this->writeAndReload($spreadsheet, $type, null, [self::class, 'inlineCss']);
26+
} else {
27+
$reloadedSpreadsheet = $this->writeAndReload($spreadsheet, $type);
28+
}
29+
$spreadsheet->disconnectWorksheets();
30+
$sheet = $reloadedSpreadsheet->getActiveSheet();
31+
for ($column = 'A'; $column !== 'Z'; ++$column) {
32+
if (in_array($column, $columns, true)) {
33+
self::assertEqualsWithDelta($expectedCm, $sheet->getColumnDimension($column)->getWidth(Dimension::UOM_CENTIMETERS), 1E-3, "column $column");
34+
} elseif ($type === 'Xls' && $column <= 'T') {
35+
// Xls is a bit weird. Columns through max used
36+
// actually set a width in the spreadsheet file.
37+
// Columns above max used obviously do not.
38+
self::assertEqualsWithDelta(9.140625, $sheet->getColumnDimension($column)->getWidth(), 1E-3, "column $column");
39+
} elseif ($type === 'Html' && $column <= 'T') {
40+
// Html is a lot like Xls. Columns through max used
41+
// actually set a width in the spreadsheet file.
42+
// Columns above max used obviously do not.
43+
self::assertEqualsWithDelta(7.998, $sheet->getColumnDimension($column)->getWidth(), 1E-3, "column $column");
44+
} else {
45+
self::assertSame(-1.0, $sheet->getColumnDimension($column)->getWidth(), "column $column");
46+
}
47+
}
48+
$reloadedSpreadsheet->disconnectWorksheets();
49+
}
50+
51+
public static function providerType(): array
52+
{
53+
return [
54+
['Xlsx'],
55+
['Xls'],
56+
['Ods'],
57+
['Html'],
58+
];
59+
}
60+
61+
public static function inlineCss(Html $writer): void
62+
{
63+
$writer->setUseInlineCss(true);
64+
}
65+
}

0 commit comments

Comments
 (0)