Completed
Push — master ( e9cd7a...5a7c2c )
by Adrien
03:16
created

StyleHelper::isNumFmtIdBuiltInDateFormat()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 5
rs 9.4285
ccs 3
cts 3
cp 1
cc 1
eloc 3
nc 1
nop 1
crap 1
1
<?php
2
3
namespace Box\Spout\Reader\XLSX\Helper;
4
5
use Box\Spout\Reader\Wrapper\SimpleXMLElement;
6
use Box\Spout\Reader\Wrapper\XMLReader;
7
8
/**
9
 * Class StyleHelper
10
 * This class provides helper functions related to XLSX styles
11
 *
12
 * @package Box\Spout\Reader\XLSX\Helper
13
 */
14
class StyleHelper
15
{
16
    /** Paths of XML files relative to the XLSX file root */
17
    const STYLES_XML_FILE_PATH = 'xl/styles.xml';
18
19
    /** Nodes used to find relevant information in the styles XML file */
20
    const XML_NODE_NUM_FMTS = 'numFmts';
21
    const XML_NODE_NUM_FMT = 'numFmt';
22
    const XML_NODE_CELL_XFS = 'cellXfs';
23
    const XML_NODE_XF = 'xf';
24
25
    /** Attributes used to find relevant information in the styles XML file */
26
    const XML_ATTRIBUTE_NUM_FMT_ID = 'numFmtId';
27
    const XML_ATTRIBUTE_FORMAT_CODE = 'formatCode';
28
    const XML_ATTRIBUTE_APPLY_NUMBER_FORMAT = 'applyNumberFormat';
29
30
    /** By convention, default style ID is 0 */
31
    const DEFAULT_STYLE_ID = 0;
32
33
    /** @var string Path of the XLSX file being read */
34
    protected $filePath;
35
36
    /** @var array Array containing a mapping NUM_FMT_ID => FORMAT_CODE */
37
    protected $customNumberFormats;
38
39
    /** @var array Array containing a mapping STYLE_ID => [STYLE_ATTRIBUTES] */
40
    protected $stylesAttributes;
41
42
    /**
43
     * @param string $filePath Path of the XLSX file being read
44
     */
45 72
    public function __construct($filePath)
46
    {
47 72
        $this->filePath = $filePath;
48 72
    }
49
50
    /**
51
     * Reads the styles.xml file and extract the relevant information from the file.
52
     *
53
     * @return void
54
     */
55 21
    protected function extractRelevantInfo()
56
    {
57 21
        $this->customNumberFormats = [];
58 21
        $this->stylesAttributes = [];
59
60 21
        $stylesXmlFilePath = $this->filePath .'#' . self::STYLES_XML_FILE_PATH;
61 21
        $xmlReader = new XMLReader();
62
63 21
        if ($xmlReader->open('zip://' . $stylesXmlFilePath)) {
64 21
            while ($xmlReader->read()) {
65 21
                if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMTS)) {
66 12
                    $numFmtsNode = new SimpleXMLElement($xmlReader->readOuterXml());
67 12
                    $this->extractNumberFormats($numFmtsNode);
68
69 21
                } else if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL_XFS)) {
70 21
                    $cellXfsNode = new SimpleXMLElement($xmlReader->readOuterXml());
71 21
                    $this->extractStyleAttributes($cellXfsNode);
72 21
                }
73 21
            }
74
75 21
            $xmlReader->close();
76 21
        }
77 21
    }
78
79
    /**
80
     * Extracts number formats from the "numFmt" nodes.
81
     * For simplicity, the styles attributes are kept in memory. This is possible thanks
82
     * to the reuse of formats. So 1 million cells should not use 1 million formats.
83
     *
84
     * @param SimpleXMLElement $numFmtsNode The "numFmts" node
85
     * @return void
86
     */
87 12
    protected function extractNumberFormats($numFmtsNode)
88
    {
89 12
        foreach ($numFmtsNode->children() as $numFmtNode) {
90 12
            $numFmtId = intval($numFmtNode->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID));
91 12
            $formatCode = $numFmtNode->getAttribute(self::XML_ATTRIBUTE_FORMAT_CODE);
92 12
            $this->customNumberFormats[$numFmtId] = $formatCode;
93 12
        }
94 12
    }
95
96
    /**
97
     * Extracts style attributes from the "xf" nodes, inside the "cellXfs" section.
98
     * For simplicity, the styles attributes are kept in memory. This is possible thanks
99
     * to the reuse of styles. So 1 million cells should not use 1 million styles.
100
     *
101
     * @param SimpleXMLElement $cellXfsNode The "cellXfs" node
102
     * @return void
103
     */
104 21
    protected function extractStyleAttributes($cellXfsNode)
105
    {
106 21
        foreach ($cellXfsNode->children() as $xfNode) {
107 21
            $this->stylesAttributes[] = [
108 21
                self::XML_ATTRIBUTE_NUM_FMT_ID => intval($xfNode->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID)),
109 21
                self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT => !!($xfNode->getAttribute(self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT)),
110
            ];
111 21
        }
112 21
    }
113
114
    /**
115
     * @return array The custom number formats
116
     */
117 6
    protected function getCustomNumberFormats()
118
    {
119 6
        if (!isset($this->customNumberFormats)) {
120
            $this->extractRelevantInfo();
121
        }
122
123 6
        return $this->customNumberFormats;
124
    }
125
126
    /**
127
     * @return array The styles attributes
128
     */
129 21
    protected function getStylesAttributes()
130
    {
131 21
        if (!isset($this->stylesAttributes)) {
132 21
            $this->extractRelevantInfo();
133 21
        }
134
135 21
        return $this->stylesAttributes;
136
    }
137
138
    /**
139
     * Returns whether the style with the given ID should consider
140
     * numeric values as timestamps and format the cell as a date.
141
     *
142
     * @param int $styleId Zero-based style ID
143
     * @return bool Whether the cell with the given cell should display a date instead of a numeric value
144
     */
145 102
    public function shouldFormatNumericValueAsDate($styleId)
146
    {
147 102
        $stylesAttributes = $this->getStylesAttributes();
148
149
        // Default style (0) does not format numeric values as timestamps. Only custom styles do.
150
        // Also if the style ID does not exist in the styles.xml file, format as numeric value.
151
        // Using isset here because it is way faster than array_key_exists...
152 102
        if ($styleId === self::DEFAULT_STYLE_ID || !isset($stylesAttributes[$styleId])) {
153 21
            return false;
154
        }
155
156 81
        $styleAttributes = $stylesAttributes[$styleId];
157
158 81
        $applyNumberFormat = $styleAttributes[self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT];
159 81
        if (!$applyNumberFormat) {
160 3
            return false;
161
        }
162
163 78
        $numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
164 78
        return $this->doesNumFmtIdIndicateDate($numFmtId);
165
    }
166
167
    /**
168
     * @param int $numFmtId
169
     * @return bool Whether the number format ID indicates that the number is a timestamp
170
     */
171 78
    protected function doesNumFmtIdIndicateDate($numFmtId)
172
    {
173
        return (
174 78
            !$this->doesNumFmtIdIndicateGeneralFormat($numFmtId) &&
175
            (
176 75
                $this->isNumFmtIdBuiltInDateFormat($numFmtId) ||
177 72
                $this->isNumFmtIdCustomDateFormat($numFmtId)
178 72
            )
179 78
        );
180
    }
181
182
    /**
183
     * @param int $numFmtId
184
     * @return bool Whether the number format ID indicates the "General" format (0 by convention)
185
     */
186 78
    protected function doesNumFmtIdIndicateGeneralFormat($numFmtId)
187
    {
188 78
        return ($numFmtId === 0);
189
    }
190
191
    /**
192
     * @param int $numFmtId
193
     * @return bool Whether the number format ID indicates that the number is a timestamp
194
     */
195 75
    protected function isNumFmtIdBuiltInDateFormat($numFmtId)
196
    {
197 75
        $builtInDateFormatIds = [14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47];
198 75
        return in_array($numFmtId, $builtInDateFormatIds);
199
    }
200
201
    /**
202
     * @param int $numFmtId
203
     * @return bool Whether the number format ID indicates that the number is a timestamp
204
     */
205 72
    protected function isNumFmtIdCustomDateFormat($numFmtId)
206
    {
207 72
        $customNumberFormats = $this->getCustomNumberFormats();
208
209
        // Using isset here because it is way faster than array_key_exists...
210 72
        if (!isset($customNumberFormats[$numFmtId])) {
211 3
            return false;
212
        }
213
214 69
        $customNumberFormat = $customNumberFormats[$numFmtId];
215
216
        // Remove extra formatting (what's between [ ], the brackets should not be preceded by a "\")
217 69
        $pattern = '((?<!\\\)\[.+?(?<!\\\)\])';
218 69
        $customNumberFormat = preg_replace($pattern, '', $customNumberFormat);
219
220
        // custom date formats contain specific characters to represent the date:
221
        // e - yy - m - d - h - s
222
        // and all of their variants (yyyy - mm - dd...)
223 69
        $dateFormatCharacters = ['e', 'yy', 'm', 'd', 'h', 's'];
224
225 69
        $hasFoundDateFormatCharacter = false;
226 69
        foreach ($dateFormatCharacters as $dateFormatCharacter) {
227
            // character not preceded by "\"
228 69
            $pattern = '/(?<!\\\)' . $dateFormatCharacter . '/';
229
230 69
            if (preg_match($pattern, $customNumberFormat)) {
231 60
                $hasFoundDateFormatCharacter = true;
232 60
                break;
233
            }
234 69
        }
235
236 69
        return $hasFoundDateFormatCharacter;
237
    }
238
}
239