This commit is contained in:
Jun Pataleta
2025-03-06 11:22:36 +08:00
65 changed files with 772 additions and 242 deletions
+8 -8
View File
@@ -28,7 +28,7 @@
],
"homepage": "https://github.com/openspout/openspout",
"require": {
"php": "~8.1.0 || ~8.2.0 || ~8.3.0",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
@@ -38,13 +38,13 @@
},
"require-dev": {
"ext-zlib": "*",
"friendsofphp/php-cs-fixer": "^3.46.0",
"infection/infection": "^0.27.9",
"phpbench/phpbench": "^1.2.15",
"phpstan/phpstan": "^1.10.55",
"phpstan/phpstan-phpunit": "^1.3.15",
"phpstan/phpstan-strict-rules": "^1.5.2",
"phpunit/phpunit": "^10.5.5"
"friendsofphp/php-cs-fixer": "^3.68.3",
"infection/infection": "^0.29.10",
"phpbench/phpbench": "^1.4.0",
"phpstan/phpstan": "^2.1.2",
"phpstan/phpstan-phpunit": "^2.0.4",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^11.5.4"
},
"suggest": {
"ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)",
+1 -1
View File
@@ -6,5 +6,5 @@ Downloaded from: https://github.com/openspout/openspout
Import procedure:
* Copy all the files from the folder 'src' directory.
* Copy the LICENSE & README.md files from the project root.
* Copy the LICENSE, README.md and composer.json files from the root of the project.
* Update lib/thirdpartylibs.xml with the latest version.
@@ -14,7 +14,7 @@ final class FormulaCell extends Cell
public function __construct(
private readonly string $value,
?Style $style,
private readonly null|DateInterval|DateTimeImmutable|float|int|string $computedValue = null,
private readonly null|bool|DateInterval|DateTimeImmutable|float|int|string $computedValue = null,
) {
parent::__construct($style);
}
@@ -24,7 +24,7 @@ final class FormulaCell extends Cell
return $this->value;
}
public function getComputedValue(): null|DateInterval|DateTimeImmutable|float|int|string
public function getComputedValue(): null|bool|DateInterval|DateTimeImmutable|float|int|string
{
return $this->computedValue;
}
+13
View File
@@ -48,6 +48,19 @@ final class Row
return new self($cells, $rowStyle);
}
/**
* @param array<array-key, null|bool|DateInterval|DateTimeInterface|float|int|string> $cellValues
* @param array<array-key, Style> $columnStyles
*/
public static function fromValuesWithStyles(array $cellValues = [], ?Style $rowStyle = null, array $columnStyles = []): self
{
$cells = array_map(static function (null|bool|DateInterval|DateTimeInterface|float|int|string $cellValue, int|string $key) use ($columnStyles): Cell {
return Cell::fromValue($cellValue, $columnStyles[$key] ?? null);
}, $cellValues, array_keys($cellValues));
return new self($cells, $rowStyle);
}
/**
* @return Cell[] $cells
*/
@@ -22,7 +22,7 @@ final class Border
public const WIDTH_THICK = 'thick';
/** @var array<string, BorderPart> */
private array $parts;
private array $parts = [];
public function __construct(BorderPart ...$borderParts)
{
@@ -8,7 +8,7 @@ use OpenSpout\Writer\Exception\Border\InvalidNameException;
use OpenSpout\Writer\Exception\Border\InvalidStyleException;
use OpenSpout\Writer\Exception\Border\InvalidWidthException;
final class BorderPart
final readonly class BorderPart
{
public const allowedStyles = [
Border::STYLE_NONE,
@@ -31,10 +31,10 @@ final class BorderPart
Border::WIDTH_THICK,
];
private readonly string $style;
private readonly string $name;
private readonly string $color;
private readonly string $width;
private string $style;
private string $name;
private string $color;
private string $width;
/**
* @param string $name @see BorderPart::allowedNames
@@ -90,6 +90,12 @@ final class Style
/** @var bool Whether the wrap text property was set */
private bool $hasSetWrapText = false;
/** @var int Text rotation */
private int $textRotation = 0;
/** @var bool Whether the text rotation property was set */
private bool $hasSetTextRotation = false;
/** @var bool Whether the cell should shrink to fit to content */
private bool $shouldShrinkToFit = false;
@@ -383,6 +389,28 @@ final class Style
return $this->hasSetWrapText;
}
public function textRotation(): int
{
return $this->textRotation;
}
/**
* @param int $rotation Rotate text
*/
public function setTextRotation(int $rotation): self
{
$this->textRotation = $rotation;
$this->hasSetTextRotation = true;
$this->isEmpty = false;
return $this;
}
public function hasSetTextRotation(): bool
{
return $this->hasSetTextRotation;
}
/**
* @return bool Whether specific font properties should be applied
*/
@@ -10,7 +10,7 @@ use OpenSpout\Common\Exception\EncodingConversionException;
/**
* @internal
*/
final class EncodingHelper
final readonly class EncodingHelper
{
/**
* Definition of the encodings that can have a BOM.
@@ -33,9 +33,9 @@ final class EncodingHelper
/** @var array<string, string> Map representing the encodings supporting BOMs (key) and their associated BOM (value) */
private array $supportedEncodingsWithBom;
private readonly bool $canUseIconv;
private bool $canUseIconv;
private readonly bool $canUseMbString;
private bool $canUseMbString;
public function __construct(bool $canUseIconv, bool $canUseMbString)
{
@@ -107,7 +107,7 @@ final class XLSX implements EscaperInterface
$character = \chr($charValue);
if (1 === preg_match("/{$this->escapableControlCharactersPattern}/", $character)) {
$charHexValue = dechex($charValue);
$escapedChar = '_x'.sprintf('%04s', strtoupper($charHexValue)).'_';
$escapedChar = '_x'.\sprintf('%04s', strtoupper($charHexValue)).'_';
$controlCharactersEscapingMap[$escapedChar] = $character;
}
}
@@ -11,10 +11,10 @@ use RecursiveIteratorIterator;
/**
* @internal
*/
final class FileSystemHelper implements FileSystemHelperInterface
final readonly class FileSystemHelper implements FileSystemHelperInterface
{
/** @var string Real path of the base folder where all the I/O can occur */
private readonly string $baseFolderRealPath;
private string $baseFolderRealPath;
/**
* @param string $baseFolderPath The path of the base folder where all the I/O can occur
@@ -7,10 +7,10 @@ namespace OpenSpout\Common\Helper;
/**
* @internal
*/
final class StringHelper
final readonly class StringHelper
{
/** @var bool Whether the mbstring extension is loaded */
private readonly bool $hasMbstringSupport;
private bool $hasMbstringSupport;
public function __construct(bool $hasMbstringSupport)
{
+1 -1
View File
@@ -134,7 +134,7 @@ final class RowIterator implements RowIteratorInterface
if (false !== $rowData) {
// array_map will replace NULL values by empty strings
$rowDataBufferAsArray = array_map('\\strval', $rowData);
$rowDataBufferAsArray = array_map('\strval', $rowData);
$this->rowBuffer = new Row(array_map(static function ($cellValue) {
return Cell::fromValue($cellValue);
}, $rowDataBufferAsArray), null);
+2 -2
View File
@@ -9,10 +9,10 @@ use OpenSpout\Reader\SheetInterface;
/**
* @implements SheetInterface<RowIterator>
*/
final class Sheet implements SheetInterface
final readonly class Sheet implements SheetInterface
{
/** @var RowIterator To iterate over the CSV's rows */
private readonly RowIterator $rowIterator;
private RowIterator $rowIterator;
/**
* @param RowIterator $rowIterator Corresponding row iterator
@@ -7,15 +7,15 @@ namespace OpenSpout\Reader\Common;
/**
* @internal
*/
final class ColumnWidth
final readonly class ColumnWidth
{
/**
* @param positive-int $start
* @param positive-int $end
*/
public function __construct(
public readonly int $start,
public readonly int $end,
public readonly float $width,
public int $start,
public int $end,
public float $width,
) {}
}
@@ -16,7 +16,7 @@ use OpenSpout\Reader\Exception\InvalidValueException;
/**
* @internal
*/
final class CellValueFormatter
final readonly class CellValueFormatter
{
/**
* Definition of all possible cell types.
@@ -61,10 +61,10 @@ final class CellValueFormatter
];
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
private readonly bool $shouldFormatDates;
private bool $shouldFormatDates;
/** @var ODS Used to unescape XML data */
private readonly ODS $escaper;
private ODS $escaper;
/**
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
+2 -2
View File
@@ -37,8 +37,8 @@ final class RowIterator implements RowIteratorInterface
/** @var XMLProcessor Helper Object to process XML nodes */
private readonly XMLProcessor $xmlProcessor;
/** @var Helper\CellValueFormatter Helper to format cell values */
private readonly Helper\CellValueFormatter $cellValueFormatter;
/** @var CellValueFormatter Helper to format cell values */
private readonly CellValueFormatter $cellValueFormatter;
/** @var bool Whether the iterator has already been rewound once */
private bool $hasAlreadyBeenRewound = false;
+6 -6
View File
@@ -9,22 +9,22 @@ use OpenSpout\Reader\SheetWithVisibilityInterface;
/**
* @implements SheetWithVisibilityInterface<RowIterator>
*/
final class Sheet implements SheetWithVisibilityInterface
final readonly class Sheet implements SheetWithVisibilityInterface
{
/** @var RowIterator To iterate over sheet's rows */
private readonly RowIterator $rowIterator;
private RowIterator $rowIterator;
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
private readonly int $index;
private int $index;
/** @var string Name of the sheet */
private readonly string $name;
private string $name;
/** @var bool Whether the sheet was the active one */
private readonly bool $isActive;
private bool $isActive;
/** @var bool Whether the sheet is visible */
private readonly bool $isVisible;
private bool $isVisible;
/**
* @param RowIterator $rowIterator The corresponding row iterator
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace OpenSpout\Reader;
/**
* @template T of RowIteratorInterface
*
* @extends SheetInterface<T>
*/
interface SheetWithMergeCellsInterface extends SheetInterface
{
/**
* @return list<string> Merge cells list ["C7:E7", "A9:D10"]
*/
public function getMergeCells(): array;
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace OpenSpout\Reader\Wrapper;
use OpenSpout\Common\Exception\IOException;
use OpenSpout\Reader\Exception\XMLProcessingException;
use ZipArchive;
@@ -52,7 +53,12 @@ final class XMLReader extends \XMLReader
// The file path should not start with a '/', otherwise it won't be found
$fileInsideZipPathWithoutLeadingSlash = ltrim($fileInsideZipPath, '/');
return self::ZIP_WRAPPER.realpath($zipFilePath).'#'.$fileInsideZipPathWithoutLeadingSlash;
$realpath = realpath($zipFilePath);
if (false === $realpath) {
throw new IOException("Could not open {$zipFilePath} for reading! File does not exist.");
}
return self::ZIP_WRAPPER.$realpath.'#'.$fileInsideZipPathWithoutLeadingSlash;
}
/**
@@ -17,7 +17,7 @@ use OpenSpout\Reader\XLSX\Manager\StyleManagerInterface;
/**
* This class provides helper functions to format cell values.
*/
final class CellValueFormatter
final readonly class CellValueFormatter
{
/**
* Definition of all possible cell types.
@@ -49,19 +49,19 @@ final class CellValueFormatter
public const NUM_SECONDS_IN_ONE_DAY = 86400;
/** @var SharedStringsManager Manages shared strings */
private readonly SharedStringsManager $sharedStringsManager;
private SharedStringsManager $sharedStringsManager;
/** @var StyleManagerInterface Manages styles */
private readonly StyleManagerInterface $styleManager;
private StyleManagerInterface $styleManager;
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
private readonly bool $shouldFormatDates;
private bool $shouldFormatDates;
/** @var bool Whether date/time values should use a calendar starting in 1904 instead of 1900 */
private readonly bool $shouldUse1904Dates;
private bool $shouldUse1904Dates;
/** @var XLSX Used to unescape XML data */
private readonly XLSX $escaper;
private XLSX $escaper;
/**
* @param SharedStringsManager $sharedStringsManager Manages shared strings
@@ -96,28 +96,22 @@ final class CellValueFormatter
}
$vNodeValue = $this->getVNodeValue($node);
if (self::CELL_TYPE_NUMERIC === $cellType) {
$fNodeValue = $node->getElementsByTagName(self::XML_NODE_FORMULA)->item(0)?->nodeValue;
if (null !== $fNodeValue) {
$computedValue = $this->formatNumericCellValue($vNodeValue, (int) $node->getAttribute(self::XML_ATTRIBUTE_STYLE_ID));
$fNodeValue = $node->getElementsByTagName(self::XML_NODE_FORMULA)->item(0)?->nodeValue;
if (null !== $fNodeValue) {
$computedValue = $this->formatRawValueForCellType($cellType, $node, $vNodeValue);
return new Cell\FormulaCell('='.$fNodeValue, null, $computedValue);
}
return new Cell\FormulaCell(
'='.$fNodeValue,
null,
$computedValue instanceof Cell\ErrorCell ? null : $computedValue
);
}
if ('' === $vNodeValue && self::CELL_TYPE_INLINE_STRING !== $cellType) {
return Cell::fromValue($vNodeValue);
}
$rawValue = match ($cellType) {
self::CELL_TYPE_INLINE_STRING => $this->formatInlineStringCellValue($node),
self::CELL_TYPE_SHARED_STRING => $this->formatSharedStringCellValue($vNodeValue),
self::CELL_TYPE_STR => $this->formatStrCellValue($vNodeValue),
self::CELL_TYPE_BOOLEAN => $this->formatBooleanCellValue($vNodeValue),
self::CELL_TYPE_NUMERIC => $this->formatNumericCellValue($vNodeValue, (int) $node->getAttribute(self::XML_ATTRIBUTE_STYLE_ID)),
self::CELL_TYPE_DATE => $this->formatDateCellValue($vNodeValue),
default => new Cell\ErrorCell($vNodeValue, null),
};
$rawValue = $this->formatRawValueForCellType($cellType, $node, $vNodeValue);
if ($rawValue instanceof Cell) {
return $rawValue;
@@ -328,4 +322,23 @@ final class CellValueFormatter
return $cellValue;
}
private function formatRawValueForCellType(
string $cellType,
DOMElement $node,
string $vNodeValue
): bool|Cell\ErrorCell|DateInterval|DateTimeImmutable|float|int|string {
return match ($cellType) {
self::CELL_TYPE_INLINE_STRING => $this->formatInlineStringCellValue($node),
self::CELL_TYPE_SHARED_STRING => $this->formatSharedStringCellValue($vNodeValue),
self::CELL_TYPE_STR => $this->formatStrCellValue($vNodeValue),
self::CELL_TYPE_BOOLEAN => $this->formatBooleanCellValue($vNodeValue),
self::CELL_TYPE_NUMERIC => $this->formatNumericCellValue(
$vNodeValue,
(int) $node->getAttribute(self::XML_ATTRIBUTE_STYLE_ID)
),
self::CELL_TYPE_DATE => $this->formatDateCellValue($vNodeValue),
default => new Cell\ErrorCell($vNodeValue, null),
};
}
}
@@ -61,13 +61,16 @@ final class DateIntervalFormatHelper
return 1 === preg_match('/^(\[hh?](:mm(:ss)?)?|\[mm?](:ss)?|\[ss?])$/', $excelFormat);
}
public static function toPHPDateIntervalFormat(string $excelDateFormat, ?string &$startUnit = null): string
public static function toPHPDateIntervalFormat(string $excelDateFormat, string &$startUnit): string
{
$startUnit = null;
$startUnitStarted = false;
$phpFormatParts = [];
$formatParts = explode(':', str_replace(['[', ']'], '', $excelDateFormat));
foreach ($formatParts as $formatPart) {
$startUnit ??= $formatPart;
if (false === $startUnitStarted) {
$startUnit = $formatPart;
$startUnitStarted = true;
}
$phpFormatParts[] = self::dateIntervalFormats[$formatPart];
}
@@ -77,6 +80,7 @@ final class DateIntervalFormatHelper
public static function formatDateInterval(DateInterval $dateInterval, string $excelDateFormat): string
{
$startUnit = '';
$phpFormat = self::toPHPDateIntervalFormat($excelDateFormat, $startUnit);
// We have to move the hours to minutes or hours+minutes to seconds if the format in Excel did the same:
@@ -7,7 +7,7 @@ namespace OpenSpout\Reader\XLSX\Manager\SharedStringsCaching;
/**
* @internal
*/
final class CachingStrategyFactory implements CachingStrategyFactoryInterface
final readonly class CachingStrategyFactory implements CachingStrategyFactoryInterface
{
/**
* The memory amount needed to store a string was obtained empirically from this data:.
@@ -50,7 +50,7 @@ final class CachingStrategyFactory implements CachingStrategyFactoryInterface
*/
public const MAX_NUM_STRINGS_PER_TEMP_FILE = 10000;
private readonly MemoryLimit $memoryLimit;
private MemoryLimit $memoryLimit;
public function __construct(MemoryLimit $memoryLimit)
{
@@ -42,7 +42,10 @@ final class FileBasedStrategy implements CachingStrategyInterface
*
* @see CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE
*/
private string $inMemoryTempFilePath = '';
private string $readMemoryTempFilePath = '';
/** @var string Path of the temporary file whose contents is currently being written to */
private string $writeMemoryTempFilePath = '';
/**
* @see CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE
@@ -73,13 +76,14 @@ final class FileBasedStrategy implements CachingStrategyInterface
{
$tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex);
if (!file_exists($tempFilePath)) {
if ($this->writeMemoryTempFilePath !== $tempFilePath) {
if (null !== $this->tempFilePointer) {
fclose($this->tempFilePointer);
}
$resource = fopen($tempFilePath, 'w');
\assert(false !== $resource);
$this->tempFilePointer = $resource;
$this->writeMemoryTempFilePath = $tempFilePath;
}
// The shared string retrieval logic expects each cell data to be on one line only
@@ -97,6 +101,7 @@ final class FileBasedStrategy implements CachingStrategyInterface
{
// close pointer to the last temp file that was written
if (null !== $this->tempFilePointer) {
$this->writeMemoryTempFilePath = '';
fclose($this->tempFilePointer);
}
}
@@ -115,17 +120,13 @@ final class FileBasedStrategy implements CachingStrategyInterface
$tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex);
$indexInFile = $sharedStringIndex % $this->maxNumStringsPerTempFile;
if (!file_exists($tempFilePath)) {
throw new SharedStringNotFoundException("Shared string temp file not found: {$tempFilePath} ; for index: {$sharedStringIndex}");
}
if ($this->inMemoryTempFilePath !== $tempFilePath) {
$tempFilePath = realpath($tempFilePath);
\assert(false !== $tempFilePath);
$contents = file_get_contents($tempFilePath);
\assert(false !== $contents);
if ($this->readMemoryTempFilePath !== $tempFilePath) {
$contents = @file_get_contents($tempFilePath);
if (false === $contents) {
throw new SharedStringNotFoundException("Shared string temp file could not be read: {$tempFilePath} ; for index: {$sharedStringIndex}");
}
$this->inMemoryTempFileContents = explode(PHP_EOL, $contents);
$this->inMemoryTempFilePath = $tempFilePath;
$this->readMemoryTempFilePath = $tempFilePath;
}
$sharedString = null;
@@ -7,9 +7,9 @@ namespace OpenSpout\Reader\XLSX\Manager\SharedStringsCaching;
/**
* @internal
*/
final class MemoryLimit
final readonly class MemoryLimit
{
private readonly string $memoryLimit;
private string $memoryLimit;
public function __construct(string $memoryLimit)
{
@@ -13,6 +13,7 @@ use OpenSpout\Reader\XLSX\Options;
use OpenSpout\Reader\XLSX\RowIterator;
use OpenSpout\Reader\XLSX\Sheet;
use OpenSpout\Reader\XLSX\SheetHeaderReader;
use OpenSpout\Reader\XLSX\SheetMergeCellsReader;
/**
* @internal
@@ -49,6 +50,7 @@ final class SheetManager
* State value to represent a hidden sheet.
*/
public const SHEET_STATE_HIDDEN = 'hidden';
public const SHEET_STATE_VERY_HIDDEN = 'veryHidden';
/** @var string Path of the XLSX file being read */
private readonly string $filePath;
@@ -178,7 +180,7 @@ final class SheetManager
\assert(null !== $sheetId);
$sheetState = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_STATE);
$isSheetVisible = (self::SHEET_STATE_HIDDEN !== $sheetState);
$isSheetVisible = (self::SHEET_STATE_HIDDEN !== $sheetState && self::SHEET_STATE_VERY_HIDDEN !== $sheetState);
$escapedSheetName = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_NAME);
\assert(null !== $escapedSheetName);
@@ -186,13 +188,24 @@ final class SheetManager
$sheetDataXMLFilePath = $this->getSheetDataXMLFilePathForSheetId($sheetId);
$mergeCells = [];
if ($this->options->SHOULD_LOAD_MERGE_CELLS) {
$mergeCells = (new SheetMergeCellsReader(
$this->filePath,
$sheetDataXMLFilePath,
$xmlReader = new XMLReader(),
new XMLProcessor($xmlReader)
))->getMergeCells();
}
return new Sheet(
$this->createRowIterator($this->filePath, $sheetDataXMLFilePath, $this->options, $this->sharedStringsManager),
$this->createSheetHeaderReader($this->filePath, $sheetDataXMLFilePath),
$sheetIndexZeroBased,
$sheetName,
$isSheetActive,
$isSheetVisible
$isSheetVisible,
$mergeCells
);
}
@@ -240,8 +253,6 @@ final class SheetManager
Options $options,
SharedStringsManager $sharedStringsManager
): RowIterator {
$xmlReader = new XMLReader();
$workbookRelationshipsManager = new WorkbookRelationshipsManager($filePath);
$styleManager = new StyleManager(
$filePath,
@@ -262,7 +273,7 @@ final class SheetManager
$filePath,
$sheetDataXMLFilePath,
$options->SHOULD_PRESERVE_EMPTY_ROWS,
$xmlReader,
$xmlReader = new XMLReader(),
new XMLProcessor($xmlReader),
$cellValueFormatter,
new RowManager()
@@ -67,8 +67,7 @@ class StyleManager implements StyleManagerInterface
private array $numFmtIdToIsDateFormatCache = [];
/**
* @param string $filePath Path of the XLSX file being read
* @param ?string $stylesXMLFilePath
* @param string $filePath Path of the XLSX file being read
*/
public function __construct(string $filePath, ?string $stylesXMLFilePath)
{
@@ -98,7 +97,16 @@ class StyleManager implements StyleManagerInterface
public function getNumberFormatCode(int $styleId): string
{
if (null === $this->stylesXMLFilePath) {
return '';
}
$stylesAttributes = $this->getStylesAttributes();
if (!isset($stylesAttributes[$styleId])) {
return '';
}
$styleAttributes = $stylesAttributes[$styleId];
$numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
\assert(\is_int($numFmtId));
@@ -13,4 +13,5 @@ final class Options
public bool $SHOULD_FORMAT_DATES = false;
public bool $SHOULD_PRESERVE_EMPTY_ROWS = false;
public bool $SHOULD_USE_1904_DATES = false;
public bool $SHOULD_LOAD_MERGE_CELLS = false;
}
@@ -47,8 +47,8 @@ final class RowIterator implements RowIteratorInterface
/** @var XMLProcessor Helper Object to process XML nodes */
private readonly XMLProcessor $xmlProcessor;
/** @var Helper\CellValueFormatter Helper to format cell values */
private readonly Helper\CellValueFormatter $cellValueFormatter;
/** @var CellValueFormatter Helper to format cell values */
private readonly CellValueFormatter $cellValueFormatter;
/** @var RowManager Manages rows */
private readonly RowManager $rowManager;
+36 -14
View File
@@ -5,46 +5,60 @@ declare(strict_types=1);
namespace OpenSpout\Reader\XLSX;
use OpenSpout\Reader\Common\ColumnWidth;
use OpenSpout\Reader\SheetWithMergeCellsInterface;
use OpenSpout\Reader\SheetWithVisibilityInterface;
/**
* @implements SheetWithVisibilityInterface<RowIterator>
* @implements SheetWithMergeCellsInterface<RowIterator>
*/
final class Sheet implements SheetWithVisibilityInterface
final readonly class Sheet implements SheetWithVisibilityInterface, SheetWithMergeCellsInterface
{
/** @var RowIterator To iterate over sheet's rows */
private readonly RowIterator $rowIterator;
private RowIterator $rowIterator;
/** @var SheetHeaderReader To read the header of the sheet, containing for instance the col widths */
private readonly SheetHeaderReader $headerReader;
private SheetHeaderReader $headerReader;
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
private readonly int $index;
private int $index;
/** @var string Name of the sheet */
private readonly string $name;
private string $name;
/** @var bool Whether the sheet was the active one */
private readonly bool $isActive;
private bool $isActive;
/** @var bool Whether the sheet is visible */
private readonly bool $isVisible;
private bool $isVisible;
/** @var list<string> Merge cells list ["C7:E7", "A9:D10"] */
private array $mergeCells;
/**
* @param RowIterator $rowIterator The corresponding row iterator
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
* @param string $sheetName Name of the sheet
* @param bool $isSheetActive Whether the sheet was defined as active
* @param bool $isSheetVisible Whether the sheet is visible
* @param RowIterator $rowIterator The corresponding row iterator
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
* @param string $sheetName Name of the sheet
* @param bool $isSheetActive Whether the sheet was defined as active
* @param bool $isSheetVisible Whether the sheet is visible
* @param list<string> $mergeCells Merge cells list ["C7:E7", "A9:D10"]
*/
public function __construct(RowIterator $rowIterator, SheetHeaderReader $headerReader, int $sheetIndex, string $sheetName, bool $isSheetActive, bool $isSheetVisible)
{
public function __construct(
RowIterator $rowIterator,
SheetHeaderReader $headerReader,
int $sheetIndex,
string $sheetName,
bool $isSheetActive,
bool $isSheetVisible,
array $mergeCells
) {
$this->rowIterator = $rowIterator;
$this->headerReader = $headerReader;
$this->index = $sheetIndex;
$this->name = $sheetName;
$this->isActive = $isSheetActive;
$this->isVisible = $isSheetVisible;
$this->mergeCells = $mergeCells;
}
public function getRowIterator(): RowIterator
@@ -91,4 +105,12 @@ final class Sheet implements SheetWithVisibilityInterface
{
return $this->isVisible;
}
/**
* @return list<string> Merge cells list ["C7:E7", "A9:D10"]
*/
public function getMergeCells(): array
{
return $this->mergeCells;
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace OpenSpout\Reader\XLSX;
use OpenSpout\Common\Exception\IOException;
use OpenSpout\Reader\Common\XMLProcessor;
use OpenSpout\Reader\Wrapper\XMLReader;
use function ltrim;
/**
* @internal
*/
final class SheetMergeCellsReader
{
public const XML_NODE_MERGE_CELL = 'mergeCell';
public const XML_ATTRIBUTE_REF = 'ref';
/** @var list<string> Merged cells list */
private array $mergeCells = [];
/**
* @param string $filePath Path of the XLSX file being read
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
* @param XMLProcessor $xmlProcessor Helper to process XML files
*/
public function __construct(
string $filePath,
string $sheetDataXMLFilePath,
XMLReader $xmlReader,
XMLProcessor $xmlProcessor
) {
$sheetDataXMLFilePath = ltrim($sheetDataXMLFilePath, '/');
// Register all callbacks to process different nodes when reading the XML file
$xmlProcessor->registerCallback(self::XML_NODE_MERGE_CELL, XMLProcessor::NODE_TYPE_START, [$this, 'processMergeCellsStartingNode']);
$xmlReader->close();
if (false === $xmlReader->openFileInZip($filePath, $sheetDataXMLFilePath)) {
throw new IOException("Could not open \"{$sheetDataXMLFilePath}\".");
}
// Now read the entire header of the sheet, until we reach the <sheetData> element
$xmlProcessor->readUntilStopped();
$xmlReader->close();
}
/**
* @return list<string>
*/
public function getMergeCells(): array
{
return $this->mergeCells;
}
/**
* @param XMLReader $xmlReader XMLReader object, positioned on a "<mergeCells>" starting node
*
* @return int A return code that indicates what action should the processor take next
*/
private function processMergeCellsStartingNode(XMLReader $xmlReader): int
{
$this->mergeCells[] = $xmlReader->getAttribute(self::XML_ATTRIBUTE_REF);
return XMLProcessor::PROCESSING_CONTINUE;
}
}
@@ -13,9 +13,6 @@ abstract class AbstractWriter implements WriterInterface
/** @var resource Pointer to the file/stream we will write to */
protected $filePointer;
/** @var string document creator */
protected string $creator = 'OpenSpout';
/** @var string Content-Type value for the header - to be defined by child class */
protected static string $headerContentType;
@@ -122,11 +119,6 @@ abstract class AbstractWriter implements WriterInterface
}
}
final public function setCreator(string $creator): void
{
$this->creator = $creator;
}
final public function getWrittenRowCount(): int
{
return $this->writtenRowCount;
+5 -8
View File
@@ -4,10 +4,7 @@ declare(strict_types=1);
namespace OpenSpout\Writer;
/**
* @readonly
*/
final class AutoFilter
final readonly class AutoFilter
{
/**
* @param 0|positive-int $fromColumnIndex
@@ -16,9 +13,9 @@ final class AutoFilter
* @param positive-int $toRow
*/
public function __construct(
public readonly int $fromColumnIndex,
public readonly int $fromRow,
public readonly int $toColumnIndex,
public readonly int $toRow
public int $fromColumnIndex,
public int $fromRow,
public int $toColumnIndex,
public int $toRow
) {}
}
+6
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace OpenSpout\Writer\CSV;
use Exception;
use OpenSpout\Common\Entity\Cell;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Common\Exception\IOException;
@@ -29,6 +30,11 @@ final class Writer extends AbstractWriter
return $this->options;
}
public function setCreator(string $creator): void
{
throw new Exception('Method unsopported for CSV documents');
}
/**
* Opens the CSV streamer and makes it ready to accept data.
*/
@@ -7,15 +7,15 @@ namespace OpenSpout\Writer\Common;
/**
* @internal
*/
final class ColumnWidth
final readonly class ColumnWidth
{
/**
* @param positive-int $start
* @param positive-int $end
*/
public function __construct(
public readonly int $start,
public readonly int $end,
public readonly float $width,
public int $start,
public int $end,
public float $width,
) {}
}
@@ -9,6 +9,7 @@ use OpenSpout\Writer\Common\ColumnWidth;
use OpenSpout\Writer\Common\Manager\SheetManager;
use OpenSpout\Writer\Exception\InvalidSheetNameException;
use OpenSpout\Writer\XLSX\Entity\SheetView;
use OpenSpout\Writer\XLSX\Options\SheetProtection;
/**
* External representation of a worksheet.
@@ -45,6 +46,8 @@ final class Sheet
/** @var string rows to repeat at top */
private ?string $printTitleRows = null;
private ?SheetProtection $sheetProtection = null;
/**
* @param 0|positive-int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
* @param string $associatedWorkbookId ID of the sheet's associated workbook
@@ -219,4 +222,19 @@ final class Sheet
{
$this->printTitleRows = $printTitleRows;
}
/**
* @return $this
*/
public function setSheetProtection(SheetProtection $sheetProtection): self
{
$this->sheetProtection = $sheetProtection;
return $this;
}
public function getSheetProtection(): ?SheetProtection
{
return $this->sheetProtection;
}
}
@@ -11,11 +11,11 @@ use OpenSpout\Common\Entity\Style\Style;
*
* @internal
*/
final class RegisteredStyle
final readonly class RegisteredStyle
{
private readonly Style $style;
private Style $style;
private readonly bool $isMatchingRowStyle;
private bool $isMatchingRowStyle;
public function __construct(Style $style, bool $isMatchingRowStyle)
{
@@ -60,7 +60,7 @@ final class SheetManager
}
if ($this->doesContainInvalidCharacters($name)) {
$failedRequirements[] = 'It should not contain these characters: \\ / ? * : [ or ]';
$failedRequirements[] = 'It should not contain these characters: \ / ? * : [ or ]';
}
if ($this->doesStartOrEndWithSingleQuote($name)) {
@@ -9,10 +9,10 @@ use OpenSpout\Common\Entity\Style\Style;
/**
* @internal
*/
final class PossiblyUpdatedStyle
final readonly class PossiblyUpdatedStyle
{
private readonly Style $style;
private readonly bool $isUpdated;
private Style $style;
private bool $isUpdated;
public function __construct(Style $style, bool $isUpdated)
{
@@ -75,6 +75,9 @@ final class StyleMerger
if (!$style->hasSetWrapText() && $baseStyle->hasSetWrapText()) {
$styleToUpdate->setShouldWrapText($baseStyle->shouldWrapText());
}
if (!$style->hasSetTextRotation() && $baseStyle->hasSetTextRotation()) {
$styleToUpdate->setTextRotation($baseStyle->textRotation());
}
if (!$style->hasSetShrinkToFit() && $baseStyle->shouldShrinkToFit()) {
$styleToUpdate->setShouldShrinkToFit();
}
@@ -13,6 +13,6 @@ final class InvalidNameException extends WriterException
{
$msg = '%s is not a valid name identifier for a border. Valid identifiers are: %s.';
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::allowedNames)));
parent::__construct(\sprintf($msg, $name, implode(',', BorderPart::allowedNames)));
}
}
@@ -13,6 +13,6 @@ final class InvalidStyleException extends WriterException
{
$msg = '%s is not a valid style identifier for a border. Valid identifiers are: %s.';
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::allowedStyles)));
parent::__construct(\sprintf($msg, $name, implode(',', BorderPart::allowedStyles)));
}
}
@@ -13,6 +13,6 @@ final class InvalidWidthException extends WriterException
{
$msg = '%s is not a valid width identifier for a border. Valid identifiers are: %s.';
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::allowedWidths)));
parent::__construct(\sprintf($msg, $name, implode(',', BorderPart::allowedWidths)));
}
}
@@ -47,14 +47,14 @@ final class BorderHelper
$definition = 'fo:border-%s="%s"';
if (Border::STYLE_NONE === $borderPart->getStyle()) {
$borderPartDefinition = sprintf($definition, $borderPart->getName(), '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));
$borderPartDefinition = \sprintf($definition, $borderPart->getName(), implode(' ', $attributes));
}
return $borderPartDefinition;
@@ -28,11 +28,11 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
public const MIMETYPE_FILE_NAME = 'mimetype';
public const STYLES_XML_FILE_NAME = 'styles.xml';
private readonly string $baseFolderRealPath;
private string $baseFolderRealPath;
/** @var string document creator */
private readonly string $creator;
private readonly CommonFileSystemHelper $baseFileSystemHelper;
private string $creator;
private CommonFileSystemHelper $baseFileSystemHelper;
/** @var string Path to the root folder inside the temp folder where the files to create the ODS will be stored */
private string $rootFolder;
@@ -336,7 +336,7 @@ final class StyleManager extends CommonStyleManager
return '';
}
return sprintf(
return \sprintf(
' fo:text-align="%s" ',
$this->transformCellAlignment($style->getCellAlignment())
);
@@ -351,7 +351,7 @@ final class StyleManager extends CommonStyleManager
return '';
}
return sprintf(
return \sprintf(
' fo:vertical-align="%s" ',
$this->transformCellVerticalAlignment($style->getCellVerticalAlignment())
);
@@ -423,7 +423,7 @@ final class StyleManager extends CommonStyleManager
return BorderHelper::serializeBorderPart($borderPart);
}, $border->getParts());
return sprintf(' %s ', implode(' ', $borders));
return \sprintf(' %s ', implode(' ', $borders));
}
/**
@@ -431,6 +431,6 @@ final class StyleManager extends CommonStyleManager
*/
private function getBackgroundColorXMLContent(string $bgColor): string
{
return sprintf(' fo:background-color="#%s" ', $bgColor);
return \sprintf(' fo:background-color="#%s" ', $bgColor);
}
}
@@ -22,16 +22,16 @@ use OpenSpout\Writer\ODS\Manager\Style\StyleManager;
/**
* @internal
*/
final class WorksheetManager implements WorksheetManagerInterface
final readonly class WorksheetManager implements WorksheetManagerInterface
{
/** @var ODSEscaper Strings escaper */
private readonly ODSEscaper $stringsEscaper;
private ODSEscaper $stringsEscaper;
/** @var StyleManager Manages styles */
private readonly StyleManager $styleManager;
private StyleManager $styleManager;
/** @var StyleMerger Helper to merge styles together */
private readonly StyleMerger $styleMerger;
private StyleMerger $styleMerger;
/**
* WorksheetManager constructor.
@@ -88,7 +88,7 @@ final class WorksheetManager implements WorksheetManagerInterface
$databaseRange = '';
if (null !== $autofilter = $externalSheet->getAutoFilter()) {
$rangeAddress = sprintf(
$rangeAddress = \sprintf(
'\'%s\'.%s%s:\'%s\'.%s%s',
$escapedSheetName,
CellHelper::getColumnLettersFromColumnIndex($autofilter->fromColumnIndex),
+8
View File
@@ -19,6 +19,9 @@ final class Writer extends AbstractWriterMultiSheets
{
/** @var string Content-Type value for the header */
protected static string $headerContentType = 'application/vnd.oasis.opendocument.spreadsheet';
/** @var string document creator */
protected string $creator = 'OpenSpout';
private readonly Options $options;
public function __construct(?Options $options = null)
@@ -31,6 +34,11 @@ final class Writer extends AbstractWriterMultiSheets
return $this->options;
}
public function setCreator(string $creator): void
{
$this->creator = $creator;
}
protected function createWorkbookManager(): WorkbookManager
{
$workbook = new Workbook();
+7 -7
View File
@@ -40,6 +40,13 @@ interface WriterInterface
*/
public function addRow(Row $row): void;
/**
* Set document creator.
*
* @param string $creator document creator
*/
public function setCreator(string $creator): void;
/**
* Appends the rows to the end of the stream.
*
@@ -51,13 +58,6 @@ interface WriterInterface
*/
public function addRows(array $rows): void;
/**
* Set document creator.
*
* @param string $creator document creator
*/
public function setCreator(string $creator): void;
/**
* @return 0|positive-int
*/
@@ -48,8 +48,8 @@ final class BorderHelper
$borderStyle = self::getBorderStyle($borderPart);
$colorEl = sprintf('<color rgb="%s"/>', $borderPart->getColor());
$partEl = sprintf(
$colorEl = \sprintf('<color rgb="%s"/>', $borderPart->getColor());
$partEl = \sprintf(
'<%s style="%s">%s</%s>',
$borderPart->getName(),
$borderStyle,
@@ -16,6 +16,7 @@ use OpenSpout\Writer\Common\Helper\ZipHelper;
use OpenSpout\Writer\XLSX\Manager\Style\StyleManager;
use OpenSpout\Writer\XLSX\MergeCell;
use OpenSpout\Writer\XLSX\Options;
use OpenSpout\Writer\XLSX\Properties;
/**
* @internal
@@ -31,6 +32,7 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
public const RELS_FILE_NAME = '.rels';
public const APP_XML_FILE_NAME = 'app.xml';
public const CORE_XML_FILE_NAME = 'core.xml';
public const CUSTOM_XML_FILE_NAME = 'custom.xml';
public const CONTENT_TYPES_XML_FILE_NAME = '[Content_Types].xml';
public const WORKBOOK_XML_FILE_NAME = 'workbook.xml';
public const WORKBOOK_RELS_XML_FILE_NAME = 'workbook.xml.rels';
@@ -47,8 +49,8 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
/** @var ZipHelper Helper to perform tasks with Zip archive */
private readonly ZipHelper $zipHelper;
/** @var string document creator */
private readonly string $creator;
/** @var Properties document properties */
private readonly Properties $properties;
/** @var XLSX Used to escape XML data */
private readonly XLSX $escaper;
@@ -75,18 +77,18 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
private string $sheetsContentTempFolder;
/**
* @param string $baseFolderPath The path of the base folder where all the I/O can occur
* @param ZipHelper $zipHelper Helper to perform tasks with Zip archive
* @param XLSX $escaper Used to escape XML data
* @param string $creator document creator
* @param string $baseFolderPath The path of the base folder where all the I/O can occur
* @param ZipHelper $zipHelper Helper to perform tasks with Zip archive
* @param XLSX $escaper Used to escape XML data
* @param Properties $properties document properies
*/
public function __construct(string $baseFolderPath, ZipHelper $zipHelper, XLSX $escaper, string $creator)
public function __construct(string $baseFolderPath, ZipHelper $zipHelper, XLSX $escaper, Properties $properties)
{
$this->baseFileSystemHelper = new CommonFileSystemHelper($baseFolderPath);
$this->baseFolderRealPath = $this->baseFileSystemHelper->getBaseFolderRealPath();
$this->zipHelper = $zipHelper;
$this->escaper = $escaper;
$this->creator = $creator;
$this->properties = $properties;
}
public function createFolder(string $parentFolderPath, string $folderName): string
@@ -168,10 +170,19 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
}
$contentTypesXmlFileContents .= <<<'EOD'
<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" PartName="/xl/styles.xml"/>
<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml" PartName="/xl/sharedStrings.xml"/>
<Override ContentType="application/vnd.openxmlformats-package.core-properties+xml" PartName="/docProps/core.xml"/>
<Override ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml" PartName="/docProps/app.xml"/>
<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" PartName="/xl/styles.xml"/>
<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml" PartName="/xl/sharedStrings.xml"/>
<Override ContentType="application/vnd.openxmlformats-package.core-properties+xml" PartName="/docProps/core.xml"/>
<Override ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml" PartName="/docProps/app.xml"/>
EOD;
if ([] !== $this->properties->customProperties) {
$contentTypesXmlFileContents .= <<<'EOD'
<Override ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml" PartName="/docProps/custom.xml" />
EOD;
}
$contentTypesXmlFileContents .= <<<'EOD'
</Types>
EOD;
@@ -185,11 +196,18 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
*
* @param Worksheet[] $worksheets
*/
public function createWorkbookFile(array $worksheets): self
public function createWorkbookFile(Options $options, array $worksheets): self
{
$workbookXmlFileContents = <<<'EOD'
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
EOD;
if (null !== $options->getWorkbookProtection()) {
$workbookXmlFileContents .= $options->getWorkbookProtection()->getXml();
}
$workbookXmlFileContents .= <<<'EOD'
<sheets>
EOD;
@@ -212,7 +230,7 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
$sheet = $worksheet->getExternalSheet();
if (null !== $autofilter = $sheet->getAutoFilter()) {
$worksheetName = $sheet->getName();
$name = sprintf(
$name = \sprintf(
'\'%s\'!$%s$%s:$%s$%s',
$this->escaper->escape($worksheetName),
CellHelper::getColumnLettersFromColumnIndex($autofilter->fromColumnIndex),
@@ -321,24 +339,23 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
fwrite($worksheetFilePointer, self::SHEET_XML_FILE_HEADER);
// AutoFilter tags
$range = '';
if (null !== $autofilter = $sheet->getAutoFilter()) {
$range = sprintf(
'%s%s:%s%s',
CellHelper::getColumnLettersFromColumnIndex($autofilter->fromColumnIndex),
$autofilter->fromRow,
CellHelper::getColumnLettersFromColumnIndex($autofilter->toColumnIndex),
$autofilter->toRow
);
if (isset($pageSetup) && $pageSetup->fitToPage) {
fwrite($worksheetFilePointer, '<sheetPr filterMode="false"><pageSetUpPr fitToPage="true"/></sheetPr>');
} else {
fwrite($worksheetFilePointer, '<sheetPr filterMode="false"><pageSetUpPr fitToPage="false"/></sheetPr>');
}
fwrite($worksheetFilePointer, sprintf('<dimension ref="%s"/>', $range));
} elseif (isset($pageSetup) && $pageSetup->fitToPage) {
fwrite($worksheetFilePointer, '<sheetPr><pageSetUpPr fitToPage="true"/></sheetPr>');
}
$sheetRange = \sprintf(
'%s%s:%s%s',
CellHelper::getColumnLettersFromColumnIndex(0),
1,
CellHelper::getColumnLettersFromColumnIndex($worksheet->getMaxNumColumns() - 1),
$worksheet->getLastWrittenRowIndex()
);
fwrite($worksheetFilePointer, \sprintf('<dimension ref="%s"/>', $sheetRange));
if (null !== ($sheetView = $sheet->getSheetView())) {
fwrite($worksheetFilePointer, '<sheetViews>'.$sheetView->getXml().'</sheetViews>');
}
@@ -350,9 +367,20 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
$this->copyFileContentsToTarget($worksheetFilePath, $worksheetFilePointer);
fwrite($worksheetFilePointer, '</sheetData>');
if (null !== $sheet->getSheetProtection()) {
fwrite($worksheetFilePointer, $sheet->getSheetProtection()->getXml());
}
// AutoFilter tag
if ('' !== $range) {
fwrite($worksheetFilePointer, sprintf('<autoFilter ref="%s"/>', $range));
if (null !== $autofilter) {
$autoFilterRange = \sprintf(
'%s%s:%s%s',
CellHelper::getColumnLettersFromColumnIndex($autofilter->fromColumnIndex),
$autofilter->fromRow,
CellHelper::getColumnLettersFromColumnIndex($autofilter->toColumnIndex),
$autofilter->toRow
);
fwrite($worksheetFilePointer, \sprintf('<autoFilter ref="%s"/>', $autoFilterRange));
}
// create nodes for merge cells
@@ -365,7 +393,7 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
foreach ($mergeCells as $mergeCell) {
$topLeft = CellHelper::getColumnLettersFromColumnIndex($mergeCell->topLeftColumn).$mergeCell->topLeftRow;
$bottomRight = CellHelper::getColumnLettersFromColumnIndex($mergeCell->bottomRightColumn).$mergeCell->bottomRightRow;
$mergeCellString .= sprintf(
$mergeCellString .= \sprintf(
'<mergeCell ref="%s:%s"/>',
$topLeft,
$bottomRight
@@ -585,13 +613,21 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
*/
private function createRelsFile(): self
{
$relsFileContents = <<<'EOD'
$relationshipsXmlContents = <<<'EOD'
<Relationship Id="rIdWorkbook" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
<Relationship Id="rIdCore" Type="http://schemas.openxmlformats.org/officedocument/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rIdApp" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
EOD;
if ([] !== $this->properties->customProperties) {
$relationshipsXmlContents .= <<<'EOD'
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" Target="docProps/custom.xml"/>
EOD;
}
$relsFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rIdWorkbook" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
<Relationship Id="rIdCore" Type="http://schemas.openxmlformats.org/officedocument/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rIdApp" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{$relationshipsXmlContents}</Relationships>
EOD;
$this->createFileWithContents($this->relsFolder, self::RELS_FILE_NAME, $relsFileContents);
@@ -611,6 +647,10 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
$this->createAppXmlFile();
$this->createCoreXmlFile();
if ([] !== $this->properties->customProperties) {
$this->createCustomXmlFile();
}
return $this;
}
@@ -624,7 +664,7 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
$appXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
<Application>{$this->creator}</Application>
<Application>{$this->properties->application}</Application>
<TotalTime>0</TotalTime>
</Properties>
EOD;
@@ -645,6 +685,14 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
$coreXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:title>{$this->properties->title}</dc:title>
<dc:subject>{$this->properties->subject}</dc:subject>
<dc:creator>{$this->properties->creator}</dc:creator>
<cp:lastModifiedBy>{$this->properties->lastModifiedBy}</cp:lastModifiedBy>
<cp:keywords>{$this->properties->keywords}</cp:keywords>
<dc:description>{$this->properties->description}</dc:description>
<cp:category>{$this->properties->category}</cp:category>
<dc:language>{$this->properties->language}</dc:language>
<dcterms:created xsi:type="dcterms:W3CDTF">{$createdDate}</dcterms:created>
<dcterms:modified xsi:type="dcterms:W3CDTF">{$createdDate}</dcterms:modified>
<cp:revision>0</cp:revision>
@@ -656,6 +704,35 @@ final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
return $this;
}
/**
* Creates the "custom.xml" file under the "docProps" folder.
*
* @throws IOException If unable to create the file
*/
private function createCustomXmlFile(): self
{
/** The pid must increment for each property, starting with 2 */
$pid = 2;
$propertiesXmlContents = '';
foreach ($this->properties->customProperties as $name => $value) {
$propertiesXmlContents .= <<<EOD
<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="{$pid}" name="{$name}"><vt:lpwstr>{$value}</vt:lpwstr></property>
EOD;
++$pid;
}
$customXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">{$propertiesXmlContents}</Properties>
EOD;
$this->createFileWithContents($this->docPropsFolder, self::CUSTOM_XML_FILE_NAME, $customXmlFileContents);
return $this;
}
/**
* Creates the "xl" folder under the root folder as well as its subfolders.
*
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace OpenSpout\Writer\XLSX\Helper;
/**
* @internal
*/
final class PasswordHashHelper
{
public static function make(string $password): string
{
$verifier = 0;
$pwlen = \strlen($password);
$passwordArray = pack('c', $pwlen).$password;
for ($i = $pwlen; $i >= 0; --$i) {
$intermediate1 = (($verifier & 0x4000) === 0) ? 0 : 1;
$intermediate2 = 2 * $verifier;
$intermediate2 &= 0x7FFF;
$intermediate3 = $intermediate1 | $intermediate2;
$verifier = $intermediate3 ^ \ord($passwordArray[$i]);
}
$verifier ^= 0xCE4B;
return strtoupper(dechex($verifier));
}
}
@@ -79,7 +79,7 @@ final class SharedStringsManager
// Adding 1 to take into account the space between the last xml attribute and "count"
fseek($this->sharedStringsFilePointer, $firstPartHeaderLength + 1);
fwrite($this->sharedStringsFilePointer, sprintf("%-{$defaultStringsCountPartLength}s", 'count="'.$this->numSharedStrings.'" uniqueCount="'.$this->numSharedStrings.'"'));
fwrite($this->sharedStringsFilePointer, \sprintf("%-{$defaultStringsCountPartLength}s", 'count="'.$this->numSharedStrings.'" uniqueCount="'.$this->numSharedStrings.'"'));
fclose($this->sharedStringsFilePointer);
}
@@ -7,6 +7,7 @@ namespace OpenSpout\Writer\XLSX\Manager\Style;
use OpenSpout\Common\Entity\Style\BorderPart;
use OpenSpout\Common\Entity\Style\Color;
use OpenSpout\Common\Entity\Style\Style;
use OpenSpout\Common\Helper\Escaper\XLSX as XLSXEscaper;
use OpenSpout\Writer\Common\Manager\Style\AbstractStyleManager as CommonStyleManager;
use OpenSpout\Writer\XLSX\Helper\BorderHelper;
@@ -17,9 +18,13 @@ use OpenSpout\Writer\XLSX\Helper\BorderHelper;
*/
final class StyleManager extends CommonStyleManager
{
public function __construct(StyleRegistry $styleRegistry)
/** @var XLSXEscaper Strings escaper */
private readonly XLSXEscaper $stringsEscaper;
public function __construct(StyleRegistry $styleRegistry, XLSXEscaper $stringsEscaper)
{
parent::__construct($styleRegistry);
$this->stringsEscaper = $stringsEscaper;
}
/**
@@ -91,7 +96,7 @@ final class StyleManager extends CommonStyleManager
/** @var Style $style */
$style = $this->styleRegistry->getStyleFromStyleId($styleId);
$format = $style->getFormat();
$tags[] = '<numFmt numFmtId="'.$numFmtId.'" formatCode="'.$format.'"/>';
$tags[] = '<numFmt numFmtId="'.$numFmtId.'" formatCode="'.$this->stringsEscaper->escape($format).'"/>';
}
$content = '<numFmts count="'.\count($tags).'">';
$content .= implode('', $tags);
@@ -147,7 +152,7 @@ final class StyleManager extends CommonStyleManager
// Excel reserves two default fills
$fillsCount = \count($registeredFills) + 2;
$content = sprintf('<fills count="%d">', $fillsCount);
$content = \sprintf('<fills count="%d">', $fillsCount);
$content .= '<fill><patternFill patternType="none"/></fill>';
$content .= '<fill><patternFill patternType="gray125"/></fill>';
@@ -158,7 +163,7 @@ final class StyleManager extends CommonStyleManager
$style = $this->styleRegistry->getStyleFromStyleId($styleId);
$backgroundColor = $style->getBackgroundColor();
$content .= sprintf(
$content .= \sprintf(
'<fill><patternFill patternType="solid"><fgColor rgb="%s"/></patternFill></fill>',
$backgroundColor
);
@@ -236,16 +241,16 @@ final class StyleManager extends CommonStyleManager
$content .= ' applyFont="1"';
}
$content .= sprintf(' applyBorder="%d"', (bool) $style->getBorder());
$content .= \sprintf(' applyBorder="%d"', (bool) $style->getBorder());
if ($style->shouldApplyCellAlignment() || $style->shouldApplyCellVerticalAlignment() || $style->hasSetWrapText() || $style->shouldShrinkToFit()) {
if ($style->shouldApplyCellAlignment() || $style->shouldApplyCellVerticalAlignment() || $style->hasSetWrapText() || $style->shouldShrinkToFit() || $style->hasSetTextRotation()) {
$content .= ' applyAlignment="1">';
$content .= '<alignment';
if ($style->shouldApplyCellAlignment()) {
$content .= sprintf(' horizontal="%s"', $style->getCellAlignment());
$content .= \sprintf(' horizontal="%s"', $style->getCellAlignment());
}
if ($style->shouldApplyCellVerticalAlignment()) {
$content .= sprintf(' vertical="%s"', $style->getCellVerticalAlignment());
$content .= \sprintf(' vertical="%s"', $style->getCellVerticalAlignment());
}
if ($style->hasSetWrapText()) {
$content .= ' wrapText="'.($style->shouldWrapText() ? '1' : '0').'"';
@@ -253,6 +258,9 @@ final class StyleManager extends CommonStyleManager
if ($style->shouldShrinkToFit()) {
$content .= ' shrinkToFit="true"';
}
if ($style->hasSetTextRotation()) {
$content .= \sprintf(' textRotation="%s"', $style->textRotation());
}
$content .= '/>';
$content .= '</xf>';
@@ -75,7 +75,7 @@ final class WorkbookManager extends AbstractWorkbookManager
->createContentFiles($this->options, $worksheets)
->deleteWorksheetTempFolder()
->createContentTypesFile($worksheets)
->createWorkbookFile($worksheets)
->createWorkbookFile($this->options, $worksheets)
->createWorkbookRelsFile($worksheets)
->createWorksheetRelsFiles($worksheets)
->createStylesFile($this->styleManager)
@@ -24,7 +24,7 @@ use OpenSpout\Writer\XLSX\Options;
/**
* @internal
*/
final class WorksheetManager implements WorksheetManagerInterface
final readonly class WorksheetManager implements WorksheetManagerInterface
{
/**
* Maximum number of characters a cell can contain.
@@ -36,24 +36,24 @@ final class WorksheetManager implements WorksheetManagerInterface
public const MAX_CHARACTERS_PER_CELL = 32767;
/** @var CommentsManager Manages comments */
private readonly CommentsManager $commentsManager;
private CommentsManager $commentsManager;
private readonly Options $options;
private Options $options;
/** @var StyleManager Manages styles */
private readonly StyleManager $styleManager;
private StyleManager $styleManager;
/** @var StyleMerger Helper to merge styles together */
private readonly StyleMerger $styleMerger;
private StyleMerger $styleMerger;
/** @var SharedStringsManager Helper to write shared strings */
private readonly SharedStringsManager $sharedStringsManager;
private SharedStringsManager $sharedStringsManager;
/** @var XLSXEscaper Strings escaper */
private readonly XLSXEscaper $stringsEscaper;
private XLSXEscaper $stringsEscaper;
/** @var StringHelper String helper */
private readonly StringHelper $stringHelper;
private StringHelper $stringHelper;
/**
* WorksheetManager constructor.
@@ -198,14 +198,14 @@ final class WorksheetManager implements WorksheetManagerInterface
} elseif ($cell instanceof Cell\NumericCell) {
$cellXML .= '><v>'.$cell->getValue().'</v></c>';
} elseif ($cell instanceof Cell\FormulaCell) {
$cellXML .= '><f>'.substr($cell->getValue(), 1).'</f></c>';
$cellXML .= '><f>'.$this->stringsEscaper->escape(substr($cell->getValue(), 1)).'</f></c>';
} elseif ($cell instanceof Cell\DateTimeCell) {
$cellXML .= '><v>'.DateHelper::toExcel($cell->getValue()).'</v></c>';
} elseif ($cell instanceof Cell\DateIntervalCell) {
$cellXML .= '><v>'.DateIntervalHelper::toExcel($cell->getValue()).'</v></c>';
} elseif ($cell instanceof Cell\ErrorCell) {
// only writes the error value if it's a string
$cellXML .= ' t="e"><v>'.$cell->getRawValue().'</v></c>';
$cellXML .= ' t="e"><v>'.$this->stringsEscaper->escape($cell->getRawValue()).'</v></c>';
} elseif ($cell instanceof Cell\EmptyCell) {
if ($this->styleManager->shouldApplyStyleOnEmptyCell($styleId)) {
$cellXML .= '/>';
+6 -6
View File
@@ -7,7 +7,7 @@ namespace OpenSpout\Writer\XLSX;
/**
* @internal
*/
final class MergeCell
final readonly class MergeCell
{
/**
* @param 0|positive-int $sheetIndex
@@ -17,10 +17,10 @@ final class MergeCell
* @param positive-int $bottomRightRow
*/
public function __construct(
public readonly int $sheetIndex,
public readonly int $topLeftColumn,
public readonly int $topLeftRow,
public readonly int $bottomRightColumn,
public readonly int $bottomRightRow,
public int $sheetIndex,
public int $topLeftColumn,
public int $topLeftRow,
public int $bottomRightColumn,
public int $bottomRightRow,
) {}
}
+27
View File
@@ -9,6 +9,7 @@ use OpenSpout\Writer\Common\AbstractOptions;
use OpenSpout\Writer\XLSX\Options\HeaderFooter;
use OpenSpout\Writer\XLSX\Options\PageMargin;
use OpenSpout\Writer\XLSX\Options\PageSetup;
use OpenSpout\Writer\XLSX\Options\WorkbookProtection;
final class Options extends AbstractOptions
{
@@ -26,6 +27,10 @@ final class Options extends AbstractOptions
private ?HeaderFooter $headerFooter = null;
private ?WorkbookProtection $workbookProtection = null;
private Properties $properties;
public function __construct()
{
parent::__construct();
@@ -35,6 +40,8 @@ final class Options extends AbstractOptions
$defaultRowStyle->setFontName(self::DEFAULT_FONT_NAME);
$this->DEFAULT_ROW_STYLE = $defaultRowStyle;
$this->properties = new Properties();
}
/**
@@ -102,4 +109,24 @@ final class Options extends AbstractOptions
{
return $this->headerFooter;
}
public function getWorkbookProtection(): ?WorkbookProtection
{
return $this->workbookProtection;
}
public function setWorkbookProtection(WorkbookProtection $workbookProtection): void
{
$this->workbookProtection = $workbookProtection;
}
public function getProperties(): Properties
{
return $this->properties;
}
public function setProperties(Properties $properties): void
{
$this->properties = $properties;
}
}
@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace OpenSpout\Writer\XLSX\Options;
final class HeaderFooter
final readonly class HeaderFooter
{
public function __construct(
public readonly ?string $oddHeader = null,
public readonly ?string $oddFooter = null,
public readonly ?string $evenHeader = null,
public readonly ?string $evenFooter = null,
public readonly bool $differentOddEven = false,
public ?string $oddHeader = null,
public ?string $oddFooter = null,
public ?string $evenHeader = null,
public ?string $evenFooter = null,
public bool $differentOddEven = false,
) {}
}
@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace OpenSpout\Writer\XLSX\Options;
final class PageMargin
final readonly class PageMargin
{
public function __construct(
public readonly float $top = 0.75,
public readonly float $right = 0.7,
public readonly float $bottom = 0.75,
public readonly float $left = 0.7,
public readonly float $header = 0.3,
public readonly float $footer = 0.3
public float $top = 0.75,
public float $right = 0.7,
public float $bottom = 0.75,
public float $left = 0.7,
public float $header = 0.3,
public float $footer = 0.3
) {}
}
@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace OpenSpout\Writer\XLSX\Options;
final class PageSetup
final readonly class PageSetup
{
public readonly bool $fitToPage;
public bool $fitToPage;
public function __construct(
public readonly ?PageOrientation $pageOrientation,
public readonly ?PaperSize $paperSize,
public readonly ?int $fitToHeight = null,
public readonly ?int $fitToWidth = null,
public ?PageOrientation $pageOrientation,
public ?PaperSize $paperSize,
public ?int $fitToHeight = null,
public ?int $fitToWidth = null,
) {
$this->fitToPage = null !== $fitToHeight || null !== $fitToWidth;
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace OpenSpout\Writer\XLSX\Options;
use OpenSpout\Writer\XLSX\Helper\PasswordHashHelper;
final readonly class SheetProtection
{
public function __construct(
public ?string $password = null,
public bool $lockSheet = false,
public bool $lockColumnInsert = false,
public bool $lockColumnDelete = false,
public bool $lockColumnFormatting = false,
public bool $lockRowInsert = false,
public bool $lockRowDelete = false,
public bool $lockRowFormatting = false,
public bool $lockAutoFilter = false,
public bool $lockSort = false,
public bool $lockCellFormatting = false,
public bool $lockLockedCellSelection = false,
public bool $lockUnlockedCellsSelection = false,
public bool $lockObjects = false,
public bool $lockHyperlinkInsert = false,
public bool $lockPivotTables = false,
public bool $lockScenarios = false,
) {}
public function getXml(): string
{
return '<sheetProtection'.$this->getSheetViewAttributes().'/>';
}
private function getSheetViewAttributes(): string
{
return $this->generateAttributes([
'password' => null !== $this->password ? PasswordHashHelper::make($this->password) : '',
'sheet' => $this->lockSheet,
'objects' => $this->lockObjects,
'scenarios' => $this->lockScenarios,
'formatCells' => $this->lockCellFormatting,
'formatColumns' => $this->lockColumnFormatting,
'formatRows' => $this->lockRowFormatting,
'insertColumns' => $this->lockColumnInsert,
'insertRows' => $this->lockRowInsert,
'deleteColumns' => $this->lockColumnDelete,
'deleteRows' => $this->lockRowDelete,
'selectLockedCells' => $this->lockLockedCellSelection,
'selectUnlockedCells' => $this->lockUnlockedCellsSelection,
'autoFilter' => $this->lockAutoFilter,
'sort' => $this->lockSort,
'hyperlink' => $this->lockHyperlinkInsert,
'pivotTables' => $this->lockPivotTables,
]);
}
/**
* @param array<string, bool|string> $data with key containing the attribute name and value containing the attribute value
*/
private function generateAttributes(array $data): string
{
// Create attribute for each key
$attributes = array_map(static function (string $key, bool|string $value): string {
if (\is_bool($value)) {
$value = $value ? 'true' : 'false';
}
return $key.'="'.$value.'"';
}, array_keys($data), $data);
// Append all attributes
return ' '.implode(' ', $attributes);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace OpenSpout\Writer\XLSX\Options;
use OpenSpout\Writer\XLSX\Helper\PasswordHashHelper;
final readonly class WorkbookProtection
{
public function __construct(
public ?string $password = null,
public bool $lockStructure = false,
public bool $lockWindows = false,
public bool $lockRevisions = false,
) {}
public function getXml(): string
{
return '<workbookProtection'.$this->getSheetViewAttributes().'/>';
}
private function getSheetViewAttributes(): string
{
return $this->generateAttributes([
'workbookPassword' => null !== $this->password ? PasswordHashHelper::make($this->password) : '',
'lockStructure' => $this->lockStructure,
'lockWindows' => $this->lockWindows,
'lockRevisions' => $this->lockRevisions,
]);
}
/**
* @param array<string, bool|string> $data with key containing the attribute name and value containing the attribute value
*/
private function generateAttributes(array $data): string
{
// Create attribute for each key
$attributes = array_map(static function (string $key, bool|string $value): string {
if (\is_bool($value)) {
$value = $value ? 'true' : 'false';
}
return $key.'="'.$value.'"';
}, array_keys($data), $data);
// Append all attributes
return ' '.implode(' ', $attributes);
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace OpenSpout\Writer\XLSX;
final readonly class Properties
{
public function __construct(
public ?string $title = 'Untitled Spreadsheet',
public ?string $subject = null,
public ?string $application = 'OpenSpout',
public ?string $creator = 'OpenSpout',
public ?string $lastModifiedBy = 'OpenSpout',
public ?string $keywords = null,
public ?string $description = null,
public ?string $category = null,
public ?string $language = null,
/** @var array<string, string> $customProperties */
public array $customProperties = [],
) {}
}
+25 -3
View File
@@ -35,6 +35,23 @@ final class Writer extends AbstractWriterMultiSheets
return $this->options;
}
public function setCreator(string $creator): void
{
$props = $this->options->getProperties();
$this->options->setProperties(new Properties(
$props->title,
$props->subject,
$props->application,
$creator,
$props->lastModifiedBy,
$props->keywords,
$props->description,
$props->category,
$props->language,
$props->customProperties
));
}
protected function createWorkbookManager(): WorkbookManager
{
$workbook = new Workbook();
@@ -43,7 +60,7 @@ final class Writer extends AbstractWriterMultiSheets
$this->options->getTempFolder(),
new ZipHelper(),
new XLSX(),
$this->creator
$this->options->getProperties()
);
$fileSystemHelper->createBaseFilesAndFolders();
@@ -51,7 +68,12 @@ final class Writer extends AbstractWriterMultiSheets
$sharedStringsManager = new SharedStringsManager($xlFolder, new XLSX());
$styleMerger = new StyleMerger();
$styleManager = new StyleManager(new StyleRegistry($this->options->DEFAULT_ROW_STYLE));
$escaper = new XLSX();
$styleManager = new StyleManager(
new StyleRegistry($this->options->DEFAULT_ROW_STYLE),
$escaper
);
$commentsManager = new CommentsManager($xlFolder, new XLSX());
@@ -61,7 +83,7 @@ final class Writer extends AbstractWriterMultiSheets
$styleMerger,
$commentsManager,
$sharedStringsManager,
new XLSX(),
$escaper,
StringHelper::factory()
);
+1 -1
View File
@@ -365,7 +365,7 @@ All rights reserved.</copyright>
<location>openspout</location>
<name>OpenSpout</name>
<description>Library to read and write spreadsheet files (CSV, XLSX and ODS).</description>
<version>4.23.0</version>
<version>4.28.5</version>
<license>MIT</license>
<repository>https://github.com/openspout/openspout</repository>
<copyrights>