Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.71% covered (warning)
79.71%
55 / 69
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
XlsxTabularStreamReader
79.41% covered (warning)
79.41%
54 / 68
28.57% covered (danger)
28.57%
2 / 7
44.09
0.00% covered (danger)
0.00%
0 / 1
 supports
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHeaders
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getSampleRows
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 iterateRows
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
9.04
 loadSharedStrings
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
15.55
 readRowCells
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 extractInnerCellValue
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
7.23
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\DataExchange\Infrastructure\Reader;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\DataExchange\Domain\Exception\DataExchangeException;
12use Generator;
13use XMLReader;
14use ZipArchive;
15
16/**
17 * Memory-efficient streaming reader for Microsoft Excel (.xlsx) OpenXML workbooks.
18 *
19 * @package App\Core\DataExchange\Infrastructure\Reader
20 */
21final class XlsxTabularStreamReader implements TabularStreamReaderInterface
22{
23    /** {@inheritdoc} */
24    public function supports(string $filePath): bool
25    {
26        $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
27        return $ext === 'xlsx';
28    }
29
30    /** {@inheritdoc} */
31    public function getHeaders(string $filePath): array
32    {
33        foreach ($this->iterateRows($filePath) as $row) {
34            return array_keys($row);
35        }
36        return [];
37    }
38
39    /** {@inheritdoc} */
40    public function getSampleRows(string $filePath, int $limit = 3): array
41    {
42        $sample = [];
43        $count = 0;
44
45        foreach ($this->iterateRows($filePath) as $row) {
46            $sample[] = $row;
47            $count++;
48            if ($count >= $limit) {
49                break;
50            }
51        }
52
53        return $sample;
54    }
55
56    /** {@inheritdoc} */
57    public function iterateRows(string $filePath): Generator
58    {
59        $zip = new ZipArchive();
60        if ($zip->open($filePath) !== true) {
61            throw new DataExchangeException("Failed to open XLSX workbook: {$filePath}");
62        }
63
64        try {
65            $sharedStrings = $this->loadSharedStrings($zip);
66            $sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml');
67            if ($sheetXml === false) {
68                return;
69            }
70
71            $reader = new XMLReader();
72            $reader->XML($sheetXml);
73
74            $headers = [];
75            $rowNum = 0;
76
77            while ($reader->read()) {
78                if ($reader->nodeType === XMLReader::ELEMENT && $reader->name === 'row') {
79                    $rowValues = $this->readRowCells($reader, $sharedStrings);
80                    $rowNum++;
81
82                    if ($rowNum === 1) {
83                        $headers = array_values(array_map('trim', $rowValues));
84                        continue;
85                    }
86
87                    if (empty($headers)) {
88                        continue;
89                    }
90
91                    $mapped = [];
92                    foreach ($headers as $idx => $headerKey) {
93                        $mapped[$headerKey] = $rowValues[$idx] ?? '';
94                    }
95
96                    yield $rowNum => $mapped;
97                }
98            }
99            $reader->close();
100        } finally {
101            $zip->close();
102        }
103    }
104
105    /**
106     * Reads and caches the shared strings table from OpenXML workbook.
107     *
108     * @param ZipArchive $zip Open ZIP archive.
109     * @return list<string> Shared string indexed cache.
110     */
111    private function loadSharedStrings(ZipArchive $zip): array
112    {
113        $xmlContent = $zip->getFromName('xl/sharedStrings.xml');
114        if ($xmlContent === false) {
115            return [];
116        }
117
118        $strings = [];
119        $xml = new XMLReader();
120        $xml->XML($xmlContent);
121
122        while ($xml->read()) {
123            if ($xml->nodeType === XMLReader::ELEMENT && $xml->name === 'si') {
124                $subXml = $xml->readOuterXml();
125                $strings[] = strip_tags($subXml);
126            }
127        }
128        $xml->close();
129
130        return $strings;
131    }
132
133    /**
134     * Extracts values of all cells in a single OpenXML row.
135     *
136     * @param XMLReader    $reader        Active sheet XML reader.
137     * @param list<string> $sharedStrings Shared strings cache.
138     * @return list<string> Row cell values.
139     */
140    private function readRowCells(XMLReader $reader, array $sharedStrings): array
141    {
142        $cells = [];
143        $rowSubtree = $reader->readOuterXml();
144
145        $cellXml = new XMLReader();
146        $cellXml->XML($rowSubtree);
147
148        while ($cellXml->read()) {
149            if ($cellXml->nodeType === XMLReader::ELEMENT && $cellXml->name === 'c') {
150                $type = $cellXml->getAttribute('t');
151                $val = $this->extractInnerCellValue($cellXml);
152
153                if ($type === 's' && is_numeric($val) && isset($sharedStrings[(int) $val])) {
154                    $val = $sharedStrings[(int) $val];
155                }
156
157                $cells[] = trim($val);
158            }
159        }
160        $cellXml->close();
161
162        return $cells;
163    }
164
165    /**
166     * Reads inner string value from cell element node.
167     *
168     * @param XMLReader $cellXml Reader positioned on cell element.
169     * @return string Extracted raw value string.
170     */
171    private function extractInnerCellValue(XMLReader $cellXml): string
172    {
173        while ($cellXml->read()) {
174            if ($cellXml->nodeType === XMLReader::ELEMENT
175                && ($cellXml->name === 'v' || $cellXml->name === 't')
176            ) {
177                return $cellXml->readString();
178            }
179            if ($cellXml->nodeType === XMLReader::END_ELEMENT && $cellXml->name === 'c') {
180                break;
181            }
182        }
183
184        return '';
185    }
186}