From 9718427611945f3d4e7fba44ebe8fe7b422639db Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Mon, 31 Aug 2026 23:17:54 -0500 Subject: [PATCH 1/4] Add minimal XLSX export engine --- .github/workflows/ci.yml | 2 +- .mddoc.xml.dist | 1 + README.md | 22 ++++- composer.json | 1 + src/Engines/XlsxEngine.php | 166 ++++++++++++++++++++++++++++++++++ test/Integration/XlsxTest.php | 95 +++++++++++++++++++ 6 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 src/Engines/XlsxEngine.php create mode 100644 test/Integration/XlsxTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da1d390..b0dfa7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: sockets, json, curl + extensions: sockets, json, curl, zip - name: Install dependencies with composer run: composer install diff --git a/.mddoc.xml.dist b/.mddoc.xml.dist index 5413a32..aa1f95c 100644 --- a/.mddoc.xml.dist +++ b/.mddoc.xml.dist @@ -13,6 +13,7 @@ A Streamed Data Export Tool Supported formats: - CSV / TSV - SpreadsheetML "Excel 2004 XML Spreadsheet" +- XLSX "Office Open XML Spreadsheet" - More to come. ]]> diff --git a/README.md b/README.md index d534d8d..8d390aa 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A Streamed Data Export Tool Supported formats: - CSV / TSV - SpreadsheetML "Excel 2004 XML Spreadsheet" +- XLSX "Office Open XML Spreadsheet" - More to come. @@ -377,10 +378,29 @@ function setCreatedTime(?int $createdTime): void - ***int*** | ***null*** `$createdTime` - The timestamp to use for the created time. If null, the current time will be used. +### Class: Quorum\Exporter\Engines\XlsxEngine + +Writes a minimal Office Open XML spreadsheet (XLSX) archive. + +This intentionally mirrors SpreadsheetMLEngine: values are either numbers or +strings, empty values leave gaps in their rows, and multiline strings wrap. + +--- + +#### Method: XlsxEngine->setCreatedTime + +```php +function setCreatedTime(?int $createdTime): void +``` + +##### Parameters + +- ***int*** | ***null*** `$createdTime` - The timestamp to use for the created time. If null, the current time will be used. + ### Class: Quorum\Exporter\Exceptions\ExportException ### Class: Quorum\Exporter\Exceptions\InvalidDataTypeException ### Class: Quorum\Exporter\Exceptions\OutputException -### Class: Quorum\Exporter\Exceptions\WritableException \ No newline at end of file +### Class: Quorum\Exporter\Exceptions\WritableException diff --git a/composer.json b/composer.json index 8385206..b4abebd 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "php": ">=7.4" }, "require-dev": { + "ext-zip": "*", "donatj/drop": "*", "phpunit/phpunit": "4.* | ~8.5 | ^9.0", "friendsofphp/php-cs-fixer": "^3.16", diff --git a/src/Engines/XlsxEngine.php b/src/Engines/XlsxEngine.php new file mode 100644 index 0000000..29a5b69 --- /dev/null +++ b/src/Engines/XlsxEngine.php @@ -0,0 +1,166 @@ + */ + protected array $worksheetData = []; + + protected int $autoIndex = 1; + + protected ?int $createdTime = null; + + public function processSheet( DataSheet $sheet ) : void { + $outputStream = fopen('php://temp', 'r+'); + fwrite($outputStream, ''); + + $rowIndex = 1; + foreach( $sheet as $dataRow ) { + fwrite($outputStream, ''); + + $columnIndex = 0; + foreach( $dataRow as $value ) { + if( $this->not_null($value) ) { + $cellReference = $this->columnReference($columnIndex) . $rowIndex; + if( is_numeric($value) ) { + fwrite($outputStream, '' . $this->xmlEscape($value) . ''); + } else { + $style = strpbrk((string)$value, "\r\n") === false ? '' : ' s="1"'; + fwrite($outputStream, '' . $this->xmlEscape($value) . ''); + } + } + + $columnIndex++; + } + + fwrite($outputStream, ''); + $rowIndex++; + } + + fwrite($outputStream, ''); + + $this->worksheetData[] = [ + 'name' => $sheet->getName() ?: 'Sheet' . ($this->autoIndex++), + 'stream' => $outputStream, + ]; + } + + /** + * @throws OutputException + */ + public function outputToStream( $outputStream ) : void { + $options = new Archive; + $options->setOutputStream($outputStream); + $zip = new ZipStream('export.xlsx', $options); + + $zip->addFile('[Content_Types].xml', $this->contentTypesXml()); + $zip->addFile('_rels/.rels', $this->rootRelationshipsXml()); + $zip->addFile('docProps/core.xml', $this->corePropertiesXml()); + $zip->addFile('xl/workbook.xml', $this->workbookXml()); + $zip->addFile('xl/_rels/workbook.xml.rels', $this->workbookRelationshipsXml()); + $zip->addFile('xl/styles.xml', $this->stylesXml()); + + foreach( $this->worksheetData as $index => $sheetData ) { + rewind($sheetData['stream']); + $zip->addFileFromStream('xl/worksheets/sheet' . ($index + 1) . '.xml', $sheetData['stream']); + } + + try { + $zip->finish(); + }catch( OverflowException $exception ) { + throw new OutputException('Zip Overflow', $exception->getCode(), $exception); + } + } + + private function not_null( $value ) : bool { + if( is_array($value) ) { + return sizeof($value) > 0; + } + + return (is_string($value) || is_int($value)) && ($value != '') && ($value != 'NULL') && (strlen(trim($value)) > 0); + } + + private function columnReference( int $columnIndex ) : string { + $reference = ''; + $columnIndex++; + + while( $columnIndex > 0 ) { + $remainder = ($columnIndex - 1) % 26; + $reference = chr(65 + $remainder) . $reference; + $columnIndex = intdiv($columnIndex - 1, 26); + } + + return $reference; + } + + private function xmlEscape( $value ) : string { + return htmlspecialchars((string)$value, ENT_QUOTES | ENT_XML1, 'UTF-8'); + } + + private function contentTypesXml() : string { + $sheetOverrides = ''; + foreach( $this->worksheetData as $index => $sheetData ) { + $sheetOverrides .= ''; + } + + return '' . $sheetOverrides . ''; + } + + private function rootRelationshipsXml() : string { + return ''; + } + + private function corePropertiesXml() : string { + $createdTime = gmdate('Y-m-d\TH:i:s\Z', $this->createdTime ?: time()); + + return '' . $createdTime . '' . $createdTime . ''; + } + + private function workbookXml() : string { + $sheets = ''; + foreach( $this->worksheetData as $index => $sheetData ) { + $sheetIndex = $index + 1; + $sheets .= ''; + } + + return '' . $sheets . ''; + } + + private function workbookRelationshipsXml() : string { + $relationships = ''; + foreach( $this->worksheetData as $index => $sheetData ) { + $sheetIndex = $index + 1; + $relationships .= ''; + } + + $relationships .= ''; + + return '' . $relationships . ''; + } + + private function stylesXml() : string { + return ''; + } + + /** + * @param int|null $createdTime The timestamp to use for the created time. If null, the current time will be used. + */ + public function setCreatedTime( ?int $createdTime ) : void { + $this->createdTime = $createdTime; + } + +} diff --git a/test/Integration/XlsxTest.php b/test/Integration/XlsxTest.php new file mode 100644 index 0000000..5135865 --- /dev/null +++ b/test/Integration/XlsxTest.php @@ -0,0 +1,95 @@ +setCreatedTime(518395400); + $export = new DataExport($engine); + + $firstSheet = new DataSheet('First & Last'); + $firstSheet->addRow([ 'test one', '日本語', 'test two' ]); + $firstSheet->addRow([ 'one', '', 3, "multi\nline" ]); + $export->addSheet($firstSheet); + + $secondSheet = new DataSheet('Second'); + $secondSheet->addRow([ 5 => 'another sheet' ]); + $export->addSheet($secondSheet); + + $temp = tmpfile(); + $meta = stream_get_meta_data($temp); + $export->export($temp); + fflush($temp); + + $zip = new \ZipArchive; + $this->assertSame(true, $zip->open($meta['uri']) === true); + + $this->assertSame([ + '[Content_Types].xml', + '_rels/.rels', + 'docProps/core.xml', + 'xl/workbook.xml', + 'xl/_rels/workbook.xml.rels', + 'xl/styles.xml', + 'xl/worksheets/sheet1.xml', + 'xl/worksheets/sheet2.xml', + ], $this->zipFileNames($zip)); + + $workbook = $this->xmlDocument($zip->getFromName('xl/workbook.xml')); + $workbookXPath = new \DOMXPath($workbook); + $workbookXPath->registerNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $this->assertSame('First & Last', $workbookXPath->evaluate('string(/x:workbook/x:sheets/x:sheet[1]/@name)')); + $this->assertSame('Second', $workbookXPath->evaluate('string(/x:workbook/x:sheets/x:sheet[2]/@name)')); + + $sheet = $this->xmlDocument($zip->getFromName('xl/worksheets/sheet1.xml')); + $sheetXPath = new \DOMXPath($sheet); + $sheetXPath->registerNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $this->assertSame('test one', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[1]/x:c[@r="A1"]/x:is/x:t)')); + $this->assertSame('日本語', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[1]/x:c[@r="B1"]/x:is/x:t)')); + $this->assertSame('3', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[2]/x:c[@r="C2"]/x:v)')); + $this->assertSame('n', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[2]/x:c[@r="C2"]/@t)')); + $this->assertSame('', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[2]/x:c[@r="B2"]/@r)')); + $this->assertSame('1', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[2]/x:c[@r="D2"]/@s)')); + $this->assertSame("multi\nline", $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[2]/x:c[@r="D2"]/x:is/x:t)')); + + $secondSheetXml = $this->xmlDocument($zip->getFromName('xl/worksheets/sheet2.xml')); + $secondSheetXPath = new \DOMXPath($secondSheetXml); + $secondSheetXPath->registerNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $this->assertSame('another sheet', $secondSheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row/x:c[@r="A1"]/x:is/x:t)')); + + $coreProperties = $this->xmlDocument($zip->getFromName('docProps/core.xml')); + $corePropertiesXPath = new \DOMXPath($coreProperties); + $corePropertiesXPath->registerNamespace('dcterms', 'http://purl.org/dc/terms/'); + $this->assertSame('1986-06-05T22:43:20Z', $corePropertiesXPath->evaluate('string(/*/dcterms:created)')); + + $zip->close(); + fclose($temp); + } + + private function xmlDocument( string $xml ) : \DOMDocument { + $document = new \DOMDocument; + $this->assertTrue($document->loadXML($xml)); + + return $document; + } + + /** + * @return string[] + */ + private function zipFileNames( \ZipArchive $zip ) : array { + $fileNames = []; + for( $index = 0; $index < $zip->numFiles; $index++ ) { + $fileNames[] = $zip->getNameIndex($index); + } + + return $fileNames; + } + +} From 14eaffcf9c691ce92f29732d5fcfa2f24b03fb5e Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Mon, 31 Aug 2026 23:31:12 -0500 Subject: [PATCH 2/4] Build XLSX XML with DOMDocument --- src/Engines/XlsxEngine.php | 214 +++++++++++++++++++++++++++++++++---- 1 file changed, 192 insertions(+), 22 deletions(-) diff --git a/src/Engines/XlsxEngine.php b/src/Engines/XlsxEngine.php index 29a5b69..ad32653 100644 --- a/src/Engines/XlsxEngine.php +++ b/src/Engines/XlsxEngine.php @@ -17,6 +17,15 @@ */ class XlsxEngine implements EngineInterface { + private const CONTENT_TYPES_NAMESPACE = 'http://schemas.openxmlformats.org/package/2006/content-types'; + private const CORE_PROPERTIES_NAMESPACE = 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties'; + private const DCTERMS_NAMESPACE = 'http://purl.org/dc/terms/'; + private const RELATIONSHIPS_NAMESPACE = 'http://schemas.openxmlformats.org/package/2006/relationships'; + private const SPREADSHEET_NAMESPACE = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'; + private const WORKBOOK_RELATIONSHIPS_NAMESPACE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; + private const XML_NAMESPACE = 'http://www.w3.org/XML/1998/namespace'; + private const XML_SCHEMA_INSTANCE_NAMESPACE = 'http://www.w3.org/2001/XMLSchema-instance'; + /** @var array */ protected array $worksheetData = []; @@ -26,32 +35,53 @@ class XlsxEngine implements EngineInterface { public function processSheet( DataSheet $sheet ) : void { $outputStream = fopen('php://temp', 'r+'); - fwrite($outputStream, ''); + $worksheet = $this->newDocument(); + $worksheetRoot = $worksheet->createElementNS(self::SPREADSHEET_NAMESPACE, 'worksheet'); + $worksheet->appendChild($worksheetRoot); + $sheetData = $this->appendElement($worksheet, $worksheetRoot, self::SPREADSHEET_NAMESPACE, 'sheetData'); + $this->appendElement($worksheet, $sheetData, self::SPREADSHEET_NAMESPACE, 'Replace_This_Element_With_Rows'); + $documentParts = preg_split('%(?:){1,2}%', $worksheet->saveXML()); + fwrite($outputStream, $documentParts[0]); $rowIndex = 1; foreach( $sheet as $dataRow ) { - fwrite($outputStream, ''); + $rowDocument = $this->newDocument(); + $row = $rowDocument->createElementNS(self::SPREADSHEET_NAMESPACE, 'row'); + $row->setAttribute('r', (string)$rowIndex); + $rowDocument->appendChild($row); $columnIndex = 0; foreach( $dataRow as $value ) { if( $this->not_null($value) ) { $cellReference = $this->columnReference($columnIndex) . $rowIndex; + $cell = $this->appendElement($rowDocument, $row, self::SPREADSHEET_NAMESPACE, 'c', [ + 'r' => $cellReference, + 't' => is_numeric($value) ? 'n' : 'inlineStr', + ]); + if( is_numeric($value) ) { - fwrite($outputStream, '' . $this->xmlEscape($value) . ''); + $cellValue = $this->appendElement($rowDocument, $cell, self::SPREADSHEET_NAMESPACE, 'v'); + $cellValue->appendChild($rowDocument->createTextNode((string)$value)); } else { - $style = strpbrk((string)$value, "\r\n") === false ? '' : ' s="1"'; - fwrite($outputStream, '' . $this->xmlEscape($value) . ''); + if( strpbrk((string)$value, "\r\n") !== false ) { + $cell->setAttribute('s', '1'); + } + + $inlineString = $this->appendElement($rowDocument, $cell, self::SPREADSHEET_NAMESPACE, 'is'); + $text = $this->appendElement($rowDocument, $inlineString, self::SPREADSHEET_NAMESPACE, 't'); + $text->setAttributeNS(self::XML_NAMESPACE, 'xml:space', 'preserve'); + $text->appendChild($rowDocument->createTextNode((string)$value)); } } $columnIndex++; } - fwrite($outputStream, ''); + fwrite($outputStream, $rowDocument->saveXML($row)); $rowIndex++; } - fwrite($outputStream, ''); + fwrite($outputStream, end($documentParts)); $this->worksheetData[] = [ 'name' => $sheet->getName() ?: 'Sheet' . ($this->autoIndex++), @@ -107,53 +137,193 @@ private function columnReference( int $columnIndex ) : string { return $reference; } - private function xmlEscape( $value ) : string { - return htmlspecialchars((string)$value, ENT_QUOTES | ENT_XML1, 'UTF-8'); + private function newDocument() : \DOMDocument { + return new \DOMDocument('1.0', 'UTF-8'); + } + + /** + * @param array $attributes + */ + private function appendElement( + \DOMDocument $document, + \DOMNode $parent, + string $namespace, + string $name, + array $attributes = [] + ) : \DOMElement { + $element = $document->createElementNS($namespace, $name); + foreach( $attributes as $attribute => $value ) { + $element->setAttribute($attribute, $value); + } + + $parent->appendChild($element); + + return $element; } private function contentTypesXml() : string { - $sheetOverrides = ''; + $document = $this->newDocument(); + $types = $document->createElementNS(self::CONTENT_TYPES_NAMESPACE, 'Types'); + $document->appendChild($types); + + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Default', [ + 'Extension' => 'rels', + 'ContentType' => 'application/vnd.openxmlformats-package.relationships+xml', + ]); + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Default', [ + 'Extension' => 'xml', + 'ContentType' => 'application/xml', + ]); + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Override', [ + 'PartName' => '/docProps/core.xml', + 'ContentType' => 'application/vnd.openxmlformats-package.core-properties+xml', + ]); + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Override', [ + 'PartName' => '/xl/workbook.xml', + 'ContentType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml', + ]); + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Override', [ + 'PartName' => '/xl/styles.xml', + 'ContentType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml', + ]); + foreach( $this->worksheetData as $index => $sheetData ) { - $sheetOverrides .= ''; + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Override', [ + 'PartName' => '/xl/worksheets/sheet' . ($index + 1) . '.xml', + 'ContentType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml', + ]); } - return '' . $sheetOverrides . ''; + return $document->saveXML(); } private function rootRelationshipsXml() : string { - return ''; + $document = $this->newDocument(); + $relationships = $document->createElementNS(self::RELATIONSHIPS_NAMESPACE, 'Relationships'); + $document->appendChild($relationships); + $this->appendElement($document, $relationships, self::RELATIONSHIPS_NAMESPACE, 'Relationship', [ + 'Id' => 'rId1', + 'Type' => self::WORKBOOK_RELATIONSHIPS_NAMESPACE . '/officeDocument', + 'Target' => 'xl/workbook.xml', + ]); + $this->appendElement($document, $relationships, self::RELATIONSHIPS_NAMESPACE, 'Relationship', [ + 'Id' => 'rId2', + 'Type' => self::WORKBOOK_RELATIONSHIPS_NAMESPACE . '/metadata/core-properties', + 'Target' => 'docProps/core.xml', + ]); + + return $document->saveXML(); } private function corePropertiesXml() : string { + $document = $this->newDocument(); + $coreProperties = $document->createElementNS(self::CORE_PROPERTIES_NAMESPACE, 'cp:coreProperties'); + $coreProperties->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:dcterms', self::DCTERMS_NAMESPACE); + $coreProperties->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xsi', self::XML_SCHEMA_INSTANCE_NAMESPACE); + $document->appendChild($coreProperties); + $createdTime = gmdate('Y-m-d\TH:i:s\Z', $this->createdTime ?: time()); + foreach( [ 'created', 'modified' ] as $property ) { + $date = $this->appendElement($document, $coreProperties, self::DCTERMS_NAMESPACE, 'dcterms:' . $property); + $date->setAttributeNS(self::XML_SCHEMA_INSTANCE_NAMESPACE, 'xsi:type', 'dcterms:W3CDTF'); + $date->appendChild($document->createTextNode($createdTime)); + } - return '' . $createdTime . '' . $createdTime . ''; + return $document->saveXML(); } private function workbookXml() : string { - $sheets = ''; + $document = $this->newDocument(); + $workbook = $document->createElementNS(self::SPREADSHEET_NAMESPACE, 'workbook'); + $workbook->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:r', self::WORKBOOK_RELATIONSHIPS_NAMESPACE); + $document->appendChild($workbook); + $sheets = $this->appendElement($document, $workbook, self::SPREADSHEET_NAMESPACE, 'sheets'); + foreach( $this->worksheetData as $index => $sheetData ) { $sheetIndex = $index + 1; - $sheets .= ''; + $sheet = $this->appendElement($document, $sheets, self::SPREADSHEET_NAMESPACE, 'sheet', [ + 'name' => $sheetData['name'], + 'sheetId' => (string)$sheetIndex, + ]); + $sheet->setAttributeNS(self::WORKBOOK_RELATIONSHIPS_NAMESPACE, 'r:id', 'rId' . $sheetIndex); } - return '' . $sheets . ''; + return $document->saveXML(); } private function workbookRelationshipsXml() : string { - $relationships = ''; + $document = $this->newDocument(); + $relationships = $document->createElementNS(self::RELATIONSHIPS_NAMESPACE, 'Relationships'); + $document->appendChild($relationships); + foreach( $this->worksheetData as $index => $sheetData ) { $sheetIndex = $index + 1; - $relationships .= ''; + $this->appendElement($document, $relationships, self::RELATIONSHIPS_NAMESPACE, 'Relationship', [ + 'Id' => 'rId' . $sheetIndex, + 'Type' => self::WORKBOOK_RELATIONSHIPS_NAMESPACE . '/worksheet', + 'Target' => 'worksheets/sheet' . $sheetIndex . '.xml', + ]); } - $relationships .= ''; + $this->appendElement($document, $relationships, self::RELATIONSHIPS_NAMESPACE, 'Relationship', [ + 'Id' => 'rId' . (count($this->worksheetData) + 1), + 'Type' => self::WORKBOOK_RELATIONSHIPS_NAMESPACE . '/styles', + 'Target' => 'styles.xml', + ]); - return '' . $relationships . ''; + return $document->saveXML(); } private function stylesXml() : string { - return ''; + $document = $this->newDocument(); + $styleSheet = $document->createElementNS(self::SPREADSHEET_NAMESPACE, 'styleSheet'); + $document->appendChild($styleSheet); + + $fonts = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'fonts', [ 'count' => '1' ]); + $this->appendElement($document, $fonts, self::SPREADSHEET_NAMESPACE, 'font'); + $fills = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'fills', [ 'count' => '2' ]); + foreach( [ 'none', 'gray125' ] as $patternType ) { + $fill = $this->appendElement($document, $fills, self::SPREADSHEET_NAMESPACE, 'fill'); + $this->appendElement($document, $fill, self::SPREADSHEET_NAMESPACE, 'patternFill', [ 'patternType' => $patternType ]); + } + + $borders = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'borders', [ 'count' => '1' ]); + $this->appendElement($document, $borders, self::SPREADSHEET_NAMESPACE, 'border'); + $cellStyleXfs = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'cellStyleXfs', [ 'count' => '1' ]); + $this->appendElement($document, $cellStyleXfs, self::SPREADSHEET_NAMESPACE, 'xf', [ + 'numFmtId' => '0', + 'fontId' => '0', + 'fillId' => '0', + 'borderId' => '0', + ]); + $cellXfs = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'cellXfs', [ 'count' => '2' ]); + $this->appendElement($document, $cellXfs, self::SPREADSHEET_NAMESPACE, 'xf', [ + 'numFmtId' => '0', + 'fontId' => '0', + 'fillId' => '0', + 'borderId' => '0', + 'xfId' => '0', + ]); + $wrappedText = $this->appendElement($document, $cellXfs, self::SPREADSHEET_NAMESPACE, 'xf', [ + 'numFmtId' => '0', + 'fontId' => '0', + 'fillId' => '0', + 'borderId' => '0', + 'xfId' => '0', + 'applyAlignment' => '1', + ]); + $this->appendElement($document, $wrappedText, self::SPREADSHEET_NAMESPACE, 'alignment', [ + 'vertical' => 'bottom', + 'wrapText' => '1', + ]); + $cellStyles = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'cellStyles', [ 'count' => '1' ]); + $this->appendElement($document, $cellStyles, self::SPREADSHEET_NAMESPACE, 'cellStyle', [ + 'name' => 'Normal', + 'xfId' => '0', + 'builtinId' => '0', + ]); + + return $document->saveXML(); } /** From c6c7c5917aad01bba4e5964e5d88e07330f03a37 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Mon, 5 Oct 2026 22:47:40 -0500 Subject: [PATCH 3/4] Address XLSX export review feedback --- README.md | 2 +- src/Engines/XlsxEngine.php | 98 ++++++++++++++++++++++++----------- test/Integration/XlsxTest.php | 14 ++++- 3 files changed, 81 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 8d390aa..813672c 100644 --- a/README.md +++ b/README.md @@ -403,4 +403,4 @@ function setCreatedTime(?int $createdTime): void ### Class: Quorum\Exporter\Exceptions\OutputException -### Class: Quorum\Exporter\Exceptions\WritableException +### Class: Quorum\Exporter\Exceptions\WritableException \ No newline at end of file diff --git a/src/Engines/XlsxEngine.php b/src/Engines/XlsxEngine.php index ad32653..5d4bb6c 100644 --- a/src/Engines/XlsxEngine.php +++ b/src/Engines/XlsxEngine.php @@ -34,14 +34,18 @@ class XlsxEngine implements EngineInterface { protected ?int $createdTime = null; public function processSheet( DataSheet $sheet ) : void { - $outputStream = fopen('php://temp', 'r+'); + $outputStream = $this->temporaryStream(); $worksheet = $this->newDocument(); $worksheetRoot = $worksheet->createElementNS(self::SPREADSHEET_NAMESPACE, 'worksheet'); $worksheet->appendChild($worksheetRoot); $sheetData = $this->appendElement($worksheet, $worksheetRoot, self::SPREADSHEET_NAMESPACE, 'sheetData'); $this->appendElement($worksheet, $sheetData, self::SPREADSHEET_NAMESPACE, 'Replace_This_Element_With_Rows'); - $documentParts = preg_split('%(?:){1,2}%', $worksheet->saveXML()); - fwrite($outputStream, $documentParts[0]); + $documentParts = preg_split('%(?:){1,2}%', $this->documentXml($worksheet)); + if( $documentParts === false || !isset($documentParts[0], $documentParts[1]) ) { + throw new \RuntimeException('Unable to split worksheet XML'); + } + + $this->write($outputStream, $documentParts[0]); $rowIndex = 1; foreach( $sheet as $dataRow ) { @@ -77,11 +81,11 @@ public function processSheet( DataSheet $sheet ) : void { $columnIndex++; } - fwrite($outputStream, $rowDocument->saveXML($row)); + $this->write($outputStream, $this->documentXml($rowDocument, $row)); $rowIndex++; } - fwrite($outputStream, end($documentParts)); + $this->write($outputStream, $documentParts[1]); $this->worksheetData[] = [ 'name' => $sheet->getName() ?: 'Sheet' . ($this->autoIndex++), @@ -97,31 +101,35 @@ public function outputToStream( $outputStream ) : void { $options->setOutputStream($outputStream); $zip = new ZipStream('export.xlsx', $options); - $zip->addFile('[Content_Types].xml', $this->contentTypesXml()); - $zip->addFile('_rels/.rels', $this->rootRelationshipsXml()); - $zip->addFile('docProps/core.xml', $this->corePropertiesXml()); - $zip->addFile('xl/workbook.xml', $this->workbookXml()); - $zip->addFile('xl/_rels/workbook.xml.rels', $this->workbookRelationshipsXml()); - $zip->addFile('xl/styles.xml', $this->stylesXml()); - - foreach( $this->worksheetData as $index => $sheetData ) { - rewind($sheetData['stream']); - $zip->addFileFromStream('xl/worksheets/sheet' . ($index + 1) . '.xml', $sheetData['stream']); - } - try { + $zip->addFile('[Content_Types].xml', $this->contentTypesXml()); + $zip->addFile('_rels/.rels', $this->rootRelationshipsXml()); + $zip->addFile('docProps/core.xml', $this->corePropertiesXml()); + $zip->addFile('xl/workbook.xml', $this->workbookXml()); + $zip->addFile('xl/_rels/workbook.xml.rels', $this->workbookRelationshipsXml()); + $zip->addFile('xl/styles.xml', $this->stylesXml()); + + foreach( $this->worksheetData as $index => $sheetData ) { + rewind($sheetData['stream']); + $zip->addFileFromStream('xl/worksheets/sheet' . ($index + 1) . '.xml', $sheetData['stream']); + } + $zip->finish(); }catch( OverflowException $exception ) { throw new OutputException('Zip Overflow', $exception->getCode(), $exception); - } - } + }finally { + foreach( $this->worksheetData as $sheetData ) { + if( is_resource($sheetData['stream']) ) { + fclose($sheetData['stream']); + } + } - private function not_null( $value ) : bool { - if( is_array($value) ) { - return sizeof($value) > 0; + $this->worksheetData = []; } + } - return (is_string($value) || is_int($value)) && ($value != '') && ($value != 'NULL') && (strlen(trim($value)) > 0); + private function not_null( string $value ) : bool { + return $value != '' && $value != 'NULL' && strlen(trim($value)) > 0; } private function columnReference( int $columnIndex ) : string { @@ -141,6 +149,36 @@ private function newDocument() : \DOMDocument { return new \DOMDocument('1.0', 'UTF-8'); } + /** + * @return resource + */ + private function temporaryStream() { + $stream = fopen('php://temp', 'r+'); + if( !is_resource($stream) ) { + throw new \RuntimeException('Unable to open temporary worksheet stream'); + } + + return $stream; + } + + private function documentXml( \DOMDocument $document, ?\DOMNode $node = null ) : string { + $xml = $node === null ? $document->saveXML() : $document->saveXML($node); + if( $xml === false ) { + throw new \RuntimeException('Unable to serialize XML document'); + } + + return $xml; + } + + /** + * @param resource $stream + */ + private function write( $stream, string $data ) : void { + if( fwrite($stream, $data) === false ) { + throw new \RuntimeException('Unable to write worksheet XML'); + } + } + /** * @param array $attributes */ @@ -194,7 +232,7 @@ private function contentTypesXml() : string { ]); } - return $document->saveXML(); + return $this->documentXml($document); } private function rootRelationshipsXml() : string { @@ -212,7 +250,7 @@ private function rootRelationshipsXml() : string { 'Target' => 'docProps/core.xml', ]); - return $document->saveXML(); + return $this->documentXml($document); } private function corePropertiesXml() : string { @@ -222,14 +260,14 @@ private function corePropertiesXml() : string { $coreProperties->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xsi', self::XML_SCHEMA_INSTANCE_NAMESPACE); $document->appendChild($coreProperties); - $createdTime = gmdate('Y-m-d\TH:i:s\Z', $this->createdTime ?: time()); + $createdTime = gmdate('Y-m-d\TH:i:s\Z', $this->createdTime ?? time()); foreach( [ 'created', 'modified' ] as $property ) { $date = $this->appendElement($document, $coreProperties, self::DCTERMS_NAMESPACE, 'dcterms:' . $property); $date->setAttributeNS(self::XML_SCHEMA_INSTANCE_NAMESPACE, 'xsi:type', 'dcterms:W3CDTF'); $date->appendChild($document->createTextNode($createdTime)); } - return $document->saveXML(); + return $this->documentXml($document); } private function workbookXml() : string { @@ -248,7 +286,7 @@ private function workbookXml() : string { $sheet->setAttributeNS(self::WORKBOOK_RELATIONSHIPS_NAMESPACE, 'r:id', 'rId' . $sheetIndex); } - return $document->saveXML(); + return $this->documentXml($document); } private function workbookRelationshipsXml() : string { @@ -271,7 +309,7 @@ private function workbookRelationshipsXml() : string { 'Target' => 'styles.xml', ]); - return $document->saveXML(); + return $this->documentXml($document); } private function stylesXml() : string { @@ -323,7 +361,7 @@ private function stylesXml() : string { 'builtinId' => '0', ]); - return $document->saveXML(); + return $this->documentXml($document); } /** diff --git a/test/Integration/XlsxTest.php b/test/Integration/XlsxTest.php index 5135865..a29aa54 100644 --- a/test/Integration/XlsxTest.php +++ b/test/Integration/XlsxTest.php @@ -11,7 +11,7 @@ class XlsxTest extends TestCase { public function test_Xlsx() : void { $engine = new XlsxEngine; - $engine->setCreatedTime(518395400); + $engine->setCreatedTime(0); $export = new DataExport($engine); $firstSheet = new DataSheet('First & Last'); @@ -67,10 +67,20 @@ public function test_Xlsx() : void { $coreProperties = $this->xmlDocument($zip->getFromName('docProps/core.xml')); $corePropertiesXPath = new \DOMXPath($coreProperties); $corePropertiesXPath->registerNamespace('dcterms', 'http://purl.org/dc/terms/'); - $this->assertSame('1986-06-05T22:43:20Z', $corePropertiesXPath->evaluate('string(/*/dcterms:created)')); + $this->assertSame('1970-01-01T00:00:00Z', $corePropertiesXPath->evaluate('string(/*/dcterms:created)')); $zip->close(); fclose($temp); + + $repeatTemp = tmpfile(); + $repeatMeta = stream_get_meta_data($repeatTemp); + $export->export($repeatTemp); + fflush($repeatTemp); + $repeatZip = new \ZipArchive; + $this->assertSame(true, $repeatZip->open($repeatMeta['uri']) === true); + $this->assertSame(8, $repeatZip->numFiles); + $repeatZip->close(); + fclose($repeatTemp); } private function xmlDocument( string $xml ) : \DOMDocument { From eaa016fe348637af098e180960e2681b8a84a361 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Mon, 5 Oct 2026 23:19:15 -0500 Subject: [PATCH 4/4] Validate XLSX worksheet names --- src/Engines/XlsxEngine.php | 45 ++++++++++++++++++++++++++++++++++- test/Integration/XlsxTest.php | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/Engines/XlsxEngine.php b/src/Engines/XlsxEngine.php index 5d4bb6c..4b7947e 100644 --- a/src/Engines/XlsxEngine.php +++ b/src/Engines/XlsxEngine.php @@ -34,6 +34,7 @@ class XlsxEngine implements EngineInterface { protected ?int $createdTime = null; public function processSheet( DataSheet $sheet ) : void { + $worksheetName = $this->worksheetName($sheet); $outputStream = $this->temporaryStream(); $worksheet = $this->newDocument(); $worksheetRoot = $worksheet->createElementNS(self::SPREADSHEET_NAMESPACE, 'worksheet'); @@ -88,7 +89,7 @@ public function processSheet( DataSheet $sheet ) : void { $this->write($outputStream, $documentParts[1]); $this->worksheetData[] = [ - 'name' => $sheet->getName() ?: 'Sheet' . ($this->autoIndex++), + 'name' => $worksheetName, 'stream' => $outputStream, ]; } @@ -145,6 +146,48 @@ private function columnReference( int $columnIndex ) : string { return $reference; } + private function worksheetName( DataSheet $sheet ) : string { + $name = $sheet->getName(); + if( $name === null || $name === '' ) { + do { + $name = 'Sheet' . $this->autoIndex++; + } while( $this->worksheetNameExists($name) ); + } + + if( !mb_check_encoding($name, 'UTF-8') ) { + throw new \InvalidArgumentException('Worksheet name must be UTF-8'); + } + + if( trim($name) === '' || mb_strlen($name, 'UTF-8') > 31 ) { + throw new \InvalidArgumentException('Worksheet name must contain 1 to 31 characters'); + } + + if( strpbrk($name, '[]:*?/\\') !== false || preg_match('/[\x00-\x1F]/', $name) === 1 ) { + throw new \InvalidArgumentException('Worksheet name contains invalid characters'); + } + + if( $name[0] === "'" || substr($name, -1) === "'" ) { + throw new \InvalidArgumentException('Worksheet name cannot begin or end with an apostrophe'); + } + + if( $this->worksheetNameExists($name) ) { + throw new \InvalidArgumentException('Worksheet names must be unique'); + } + + return $name; + } + + private function worksheetNameExists( string $name ) : bool { + $name = mb_strtolower($name, 'UTF-8'); + foreach( $this->worksheetData as $sheetData ) { + if( mb_strtolower($sheetData['name'], 'UTF-8') === $name ) { + return true; + } + } + + return false; + } + private function newDocument() : \DOMDocument { return new \DOMDocument('1.0', 'UTF-8'); } diff --git a/test/Integration/XlsxTest.php b/test/Integration/XlsxTest.php index a29aa54..3be9d59 100644 --- a/test/Integration/XlsxTest.php +++ b/test/Integration/XlsxTest.php @@ -83,6 +83,51 @@ public function test_Xlsx() : void { fclose($repeatTemp); } + public function test_XlsxRejectsInvalidWorksheetNames() : void { + foreach( [ + ' ', + str_repeat('a', 32), + "'quoted'", + 'not/valid', + ] as $name ) { + $engine = new XlsxEngine; + + try { + $engine->processSheet(new DataSheet($name)); + $this->fail('Expected an invalid worksheet name to throw'); + }catch( \InvalidArgumentException $exception ) { + $this->assertNotSame('', $exception->getMessage()); + } + } + } + + public function test_XlsxRejectsDuplicateWorksheetNames() : void { + $engine = new XlsxEngine; + $engine->processSheet(new DataSheet('Sales')); + + $this->expectException(\InvalidArgumentException::class); + $engine->processSheet(new DataSheet('sales')); + } + + public function test_XlsxAvoidsAutomaticWorksheetNameCollisions() : void { + $engine = new XlsxEngine; + $engine->processSheet(new DataSheet('Sheet1')); + $engine->processSheet(new DataSheet); + + $temp = tmpfile(); + $meta = stream_get_meta_data($temp); + $engine->outputToStream($temp); + fflush($temp); + $zip = new \ZipArchive; + $this->assertSame(true, $zip->open($meta['uri']) === true); + $workbook = $this->xmlDocument($zip->getFromName('xl/workbook.xml')); + $workbookXPath = new \DOMXPath($workbook); + $workbookXPath->registerNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $this->assertSame('Sheet2', $workbookXPath->evaluate('string(/x:workbook/x:sheets/x:sheet[2]/@name)')); + $zip->close(); + fclose($temp); + } + private function xmlDocument( string $xml ) : \DOMDocument { $document = new \DOMDocument; $this->assertTrue($document->loadXML($xml));