Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.77% covered (success)
96.77%
90 / 93
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExcelExport
96.77% covered (success)
96.77%
90 / 93
66.67% covered (warning)
66.67%
2 / 3
16
0.00% covered (danger)
0.00%
0 / 1
 streamFromSheets
100.00% covered (success)
100.00%
84 / 84
100.00% covered (success)
100.00%
1 / 1
13
 getSupportedExtensions
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getExcelColumnName
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2namespace Apie\Export;
3
4use Apie\Export\Concerns\FlattensValues;
5use Apie\Export\Lists\FileExtensionList;
6use Apie\Export\ValueObjects\FileExtension;
7use Nyholm\Psr7\Stream;
8use Psr\Http\Message\StreamInterface;
9use ZipStream\ZipStream;
10
11/**
12 * This version is broken right now as Excel refuses to read this file. It would be my preference
13 * to generate the Excel file like this for there is no delay in sending the file already.
14 */
15class ExcelExport implements ExportInterface
16{
17    use FlattensValues;
18
19    public function streamFromSheets(array $sheets, string $outputFilename = 'export.xlsx'): StreamInterface
20    {
21        // Sanitize and reindex sheet names
22        $sheetNames = [];
23        $i = 1;
24        foreach ($sheets as $name => $gen) {
25            $clean = substr(preg_replace('/[\\\\\/\?\*\[\]:]/', '', (string)$name), 0, 31) ?: "Sheet{$i}";
26            $sheetNames[] = $clean;
27            $i++;
28        }
29
30        // Initialize ZIP stream
31        $stream = fopen('php://temp', 'r+');
32        $outputStream = new Stream($stream);
33
34        $zip = new ZipStream(
35            outputName: $outputFilename,
36            outputStream: $outputStream,
37            sendHttpHeaders: false,
38        );
39
40        // === [Content_Types].xml ===
41        $types = [
42            '<?xml version="1.0" encoding="UTF-8"?>',
43            '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
44            '  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>',
45            '  <Default Extension="xml" ContentType="application/xml"/>',
46            '  <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
47            '  <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>',
48        ];
49        foreach ($sheetNames as $i => $_) {
50            $types[] = '  <Override PartName="/xl/worksheets/sheet' . ($i + 1) . '.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
51        }
52        $types[] = '</Types>';
53        $zip->addFile('[Content_Types].xml', implode("\n", $types));
54
55        // === _rels/.rels ===
56        $zip->addFile('_rels/.rels', <<<XML
57<?xml version="1.0" encoding="UTF-8"?>
58<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
59  <Relationship Id="rId1"
60    Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
61    Target="xl/workbook.xml"/>
62</Relationships>
63XML);
64
65        // === xl/workbook.xml ===
66        $sheetsXml = [];
67        foreach ($sheetNames as $i => $name) {
68            $sheetsXml[] = '    <sheet name="' . htmlspecialchars($name, ENT_XML1) . '" sheetId="' . ($i + 1) . '" r:id="rId' . ($i + 1) . '"/>';
69        }
70        $workbookXml = <<<XML
71<?xml version="1.0" encoding="UTF-8"?>
72<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
73          xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
74  <sheets>
75    {SHEETS}
76  </sheets>
77</workbook>
78XML;
79        $workbookXml = str_replace('{SHEETS}', implode("\n", $sheetsXml), $workbookXml);
80        $zip->addFile('xl/workbook.xml', $workbookXml);
81
82        // === xl/_rels/workbook.xml.rels ===
83        $relsXml = [
84            '<?xml version="1.0" encoding="UTF-8"?>',
85            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
86        ];
87        foreach ($sheetNames as $i => $_) {
88            $relsXml[] = '  <Relationship Id="rId' . ($i + 1) .
89                         '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"' .
90                         ' Target="worksheets/sheet' . ($i + 1) . '.xml"/>';
91        }
92        $relsXml[] = '  <Relationship Id="rId' . (count($sheetNames) + 1) .
93                     '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"' .
94                     ' Target="styles.xml"/>';
95        $relsXml[] = '</Relationships>';
96        $zip->addFile('xl/_rels/workbook.xml.rels', implode("\n", $relsXml));
97
98        // === xl/styles.xml (minimal) ===
99        $stylesXml = <<<XML
100<?xml version="1.0" encoding="UTF-8"?>
101<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
102  <fonts count="1">
103    <font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/></font>
104  </fonts>
105  <fills count="1">
106    <fill><patternFill patternType="none"/></fill>
107  </fills>
108  <borders count="1">
109    <border><left/><right/><top/><bottom/><diagonal/></border>
110  </borders>
111  <cellStyleXfs count="1">
112    <xf numFmtId="0" fontId="0" fillId="0" borderId="0"/>
113  </cellStyleXfs>
114  <cellXfs count="2">
115    <xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/> <!-- style 0: text -->
116    <xf numFmtId="1" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/> <!-- style 1: numeric -->
117  </cellXfs>
118</styleSheet>
119XML;
120        $zip->addFile('xl/styles.xml', $stylesXml);
121
122        // === Add each worksheet ===
123        $index = 1;
124        foreach ($sheets as $name => $rowsGenerator) {
125            $zip->addFileFromCallback("xl/worksheets/sheet{$index}.xml", function () use ($rowsGenerator) {
126                $stream = fopen('php://temp', 'r+');
127                fwrite($stream, '<?xml version="1.0" encoding="UTF-8"?>' . "\n");
128                fwrite($stream, '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' . "\n");
129                fwrite($stream, '<sheetData>' . "\n");
130
131                $rowIndex = 1;
132                foreach ($rowsGenerator as $row) {
133                    fwrite($stream, '<row r="' . $rowIndex . '">');
134                    $colIndex = 1;
135                    foreach ($row as $cellValue) {
136                        $cellValue = $this->toSingleValue($cellValue);
137                        if ($cellValue === null) {
138                            continue;
139                        }
140                        $colLetter = $this->getExcelColumnName($colIndex);
141                        if (is_bool($cellValue)) {
142                            fwrite($stream, '<c r="' . $colLetter . $rowIndex . '" s="1"><v>' . $cellValue ? '1' : '0' . '</v></c>');
143                        } elseif (is_numeric($cellValue)) {
144                            fwrite($stream, '<c r="' . $colLetter . $rowIndex . '" s="1"><v>' . $cellValue . '</v></c>');
145                        } else {
146                            $escaped = htmlspecialchars((string)$cellValue, ENT_XML1);
147                            fwrite($stream, '<c t="inlineStr" r="' . $colLetter . $rowIndex . '" s="0"><is><t>' . $escaped . '</t></is></c>');
148                        }
149                        $colIndex++;
150                    }
151                    fwrite($stream, '</row>' . "\n");
152                    $rowIndex++;
153                }
154
155                fwrite($stream, '</sheetData>' . "\n");
156                fwrite($stream, '</worksheet>' . "\n");
157                rewind($stream);
158                return $stream;
159            });
160            $index++;
161        }
162
163        $zip->finish();
164        rewind($stream);
165        return $outputStream;
166    }
167
168    public function getSupportedExtensions(): FileExtensionList
169    {
170        return new FileExtensionList([
171            new FileExtension('xlsx'),
172        ]);
173    }
174
175    /**
176     * Convert 1-based column index to Excel column name (A, B, ..., Z, AA, AB, ...).
177     */
178    private function getExcelColumnName(int $index): string
179    {
180        $name = '';
181        while ($index > 0) {
182            $index--;
183            $name = chr(65 + ($index % 26)) . $name;
184            $index = intdiv($index, 26);
185        }
186        return $name;
187    }
188}