| 1441 |
ariadna |
1 |
<?php
|
|
|
2 |
|
|
|
3 |
namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
|
|
4 |
|
|
|
5 |
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
|
|
|
6 |
use PhpOffice\PhpSpreadsheet\Reader\Xls\Color\BIFF8;
|
|
|
7 |
use PhpOffice\PhpSpreadsheet\RichText\RichText;
|
|
|
8 |
use PhpOffice\PhpSpreadsheet\Style\Color;
|
|
|
9 |
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
|
|
10 |
|
|
|
11 |
class Formatter extends BaseFormatter
|
|
|
12 |
{
|
|
|
13 |
/**
|
|
|
14 |
* Matches any @ symbol that isn't enclosed in quotes.
|
|
|
15 |
*/
|
|
|
16 |
private const SYMBOL_AT = '/@(?=(?:[^"]*"[^"]*")*[^"]*\Z)/miu';
|
|
|
17 |
private const QUOTE_REPLACEMENT = "\u{fffe}"; // invalid Unicode character
|
|
|
18 |
|
|
|
19 |
/**
|
|
|
20 |
* Matches any ; symbol that isn't enclosed in quotes, for a "section" split.
|
|
|
21 |
*/
|
|
|
22 |
private const SECTION_SPLIT = '/;(?=(?:[^"]*"[^"]*")*[^"]*\Z)/miu';
|
|
|
23 |
|
|
|
24 |
private static function splitFormatComparison(
|
|
|
25 |
mixed $value,
|
|
|
26 |
?string $condition,
|
|
|
27 |
mixed $comparisonValue,
|
|
|
28 |
string $defaultCondition,
|
|
|
29 |
mixed $defaultComparisonValue
|
|
|
30 |
): bool {
|
|
|
31 |
if (!$condition) {
|
|
|
32 |
$condition = $defaultCondition;
|
|
|
33 |
$comparisonValue = $defaultComparisonValue;
|
|
|
34 |
}
|
|
|
35 |
|
|
|
36 |
return match ($condition) {
|
|
|
37 |
'>' => $value > $comparisonValue,
|
|
|
38 |
'<' => $value < $comparisonValue,
|
|
|
39 |
'<=' => $value <= $comparisonValue,
|
|
|
40 |
'<>' => $value != $comparisonValue,
|
|
|
41 |
'=' => $value == $comparisonValue,
|
|
|
42 |
default => $value >= $comparisonValue,
|
|
|
43 |
};
|
|
|
44 |
}
|
|
|
45 |
|
|
|
46 |
/** @param float|int|numeric-string $value value to be formatted */
|
|
|
47 |
private static function splitFormatForSectionSelection(array $sections, mixed $value): array
|
|
|
48 |
{
|
|
|
49 |
// Extract the relevant section depending on whether number is positive, negative, or zero?
|
|
|
50 |
// Text not supported yet.
|
|
|
51 |
// Here is how the sections apply to various values in Excel:
|
|
|
52 |
// 1 section: [POSITIVE/NEGATIVE/ZERO/TEXT]
|
|
|
53 |
// 2 sections: [POSITIVE/ZERO/TEXT] [NEGATIVE]
|
|
|
54 |
// 3 sections: [POSITIVE/TEXT] [NEGATIVE] [ZERO]
|
|
|
55 |
// 4 sections: [POSITIVE] [NEGATIVE] [ZERO] [TEXT]
|
|
|
56 |
$sectionCount = count($sections);
|
|
|
57 |
// Colour could be a named colour, or a numeric index entry in the colour-palette
|
|
|
58 |
$color_regex = '/\[(' . implode('|', Color::NAMED_COLORS) . '|color\s*(\d+))\]/mui';
|
|
|
59 |
$cond_regex = '/\[(>|>=|<|<=|=|<>)([+-]?\d+([.]\d+)?)\]/';
|
|
|
60 |
$colors = ['', '', '', '', ''];
|
|
|
61 |
$conditionOperations = ['', '', '', '', ''];
|
|
|
62 |
$conditionComparisonValues = [0, 0, 0, 0, 0];
|
|
|
63 |
for ($idx = 0; $idx < $sectionCount; ++$idx) {
|
|
|
64 |
if (preg_match($color_regex, $sections[$idx], $matches)) {
|
|
|
65 |
if (isset($matches[2])) {
|
|
|
66 |
$colors[$idx] = '#' . BIFF8::lookup((int) $matches[2] + 7)['rgb'];
|
|
|
67 |
} else {
|
|
|
68 |
$colors[$idx] = $matches[0];
|
|
|
69 |
}
|
|
|
70 |
$sections[$idx] = (string) preg_replace($color_regex, '', $sections[$idx]);
|
|
|
71 |
}
|
|
|
72 |
if (preg_match($cond_regex, $sections[$idx], $matches)) {
|
|
|
73 |
$conditionOperations[$idx] = $matches[1];
|
|
|
74 |
$conditionComparisonValues[$idx] = $matches[2];
|
|
|
75 |
$sections[$idx] = (string) preg_replace($cond_regex, '', $sections[$idx]);
|
|
|
76 |
}
|
|
|
77 |
}
|
|
|
78 |
$color = $colors[0];
|
|
|
79 |
$format = $sections[0];
|
|
|
80 |
$absval = $value;
|
|
|
81 |
switch ($sectionCount) {
|
|
|
82 |
case 2:
|
|
|
83 |
$absval = abs($value + 0);
|
|
|
84 |
if (!self::splitFormatComparison($value, $conditionOperations[0], $conditionComparisonValues[0], '>=', 0)) {
|
|
|
85 |
$color = $colors[1];
|
|
|
86 |
$format = $sections[1];
|
|
|
87 |
}
|
|
|
88 |
|
|
|
89 |
break;
|
|
|
90 |
case 3:
|
|
|
91 |
case 4:
|
|
|
92 |
$absval = abs($value + 0);
|
|
|
93 |
if (!self::splitFormatComparison($value, $conditionOperations[0], $conditionComparisonValues[0], '>', 0)) {
|
|
|
94 |
if (self::splitFormatComparison($value, $conditionOperations[1], $conditionComparisonValues[1], '<', 0)) {
|
|
|
95 |
$color = $colors[1];
|
|
|
96 |
$format = $sections[1];
|
|
|
97 |
} else {
|
|
|
98 |
$color = $colors[2];
|
|
|
99 |
$format = $sections[2];
|
|
|
100 |
}
|
|
|
101 |
}
|
|
|
102 |
|
|
|
103 |
break;
|
|
|
104 |
}
|
|
|
105 |
|
|
|
106 |
return [$color, $format, $absval];
|
|
|
107 |
}
|
|
|
108 |
|
|
|
109 |
/**
|
|
|
110 |
* Convert a value in a pre-defined format to a PHP string.
|
|
|
111 |
*
|
|
|
112 |
* @param null|array|bool|float|int|RichText|string $value Value to format
|
|
|
113 |
* @param string $format Format code: see = self::FORMAT_* for predefined values;
|
|
|
114 |
* or can be any valid MS Excel custom format string
|
|
|
115 |
* @param null|array|callable $callBack Callback function for additional formatting of string
|
|
|
116 |
*
|
|
|
117 |
* @return string Formatted string
|
|
|
118 |
*/
|
|
|
119 |
public static function toFormattedString($value, string $format, null|array|callable $callBack = null): string
|
|
|
120 |
{
|
|
|
121 |
while (is_array($value)) {
|
|
|
122 |
$value = array_shift($value);
|
|
|
123 |
}
|
|
|
124 |
if (is_bool($value)) {
|
|
|
125 |
return $value ? Calculation::getTRUE() : Calculation::getFALSE();
|
|
|
126 |
}
|
|
|
127 |
// For now we do not treat strings in sections, although section 4 of a format code affects strings
|
|
|
128 |
// Process a single block format code containing @ for text substitution
|
|
|
129 |
$formatx = str_replace('\"', self::QUOTE_REPLACEMENT, $format);
|
|
|
130 |
if (preg_match(self::SECTION_SPLIT, $format) === 0 && preg_match(self::SYMBOL_AT, $formatx) === 1) {
|
|
|
131 |
if (!str_contains($format, '"')) {
|
|
|
132 |
return str_replace('@', $value, $format);
|
|
|
133 |
}
|
|
|
134 |
//escape any dollar signs on the string, so they are not replaced with an empty value
|
|
|
135 |
$value = str_replace(
|
|
|
136 |
['$', '"'],
|
|
|
137 |
['\$', self::QUOTE_REPLACEMENT],
|
|
|
138 |
(string) $value
|
|
|
139 |
);
|
|
|
140 |
|
|
|
141 |
return str_replace(
|
|
|
142 |
['"', self::QUOTE_REPLACEMENT],
|
|
|
143 |
['', '"'],
|
|
|
144 |
preg_replace(self::SYMBOL_AT, $value, $formatx) ?? $value
|
|
|
145 |
);
|
|
|
146 |
}
|
|
|
147 |
|
|
|
148 |
// If we have a text value, return it "as is"
|
|
|
149 |
if (!is_numeric($value)) {
|
|
|
150 |
return (string) $value;
|
|
|
151 |
}
|
|
|
152 |
|
|
|
153 |
// For 'General' format code, we just pass the value although this is not entirely the way Excel does it,
|
|
|
154 |
// it seems to round numbers to a total of 10 digits.
|
|
|
155 |
if (($format === NumberFormat::FORMAT_GENERAL) || ($format === NumberFormat::FORMAT_TEXT)) {
|
|
|
156 |
return self::adjustSeparators((string) $value);
|
|
|
157 |
}
|
|
|
158 |
|
|
|
159 |
// Ignore square-$-brackets prefix in format string, like "[$-411]ge.m.d", "[$-010419]0%", etc
|
|
|
160 |
$format = (string) preg_replace('/^\[\$-[^\]]*\]/', '', $format);
|
|
|
161 |
|
|
|
162 |
$format = (string) preg_replace_callback(
|
|
|
163 |
'/(["])(?:(?=(\\\?))\2.)*?\1/u',
|
|
|
164 |
fn (array $matches): string => str_replace('.', chr(0x00), $matches[0]),
|
|
|
165 |
$format
|
|
|
166 |
);
|
|
|
167 |
|
|
|
168 |
// Convert any other escaped characters to quoted strings, e.g. (\T to "T")
|
|
|
169 |
$format = (string) preg_replace('/(\\\(((.)(?!((AM\/PM)|(A\/P))))|([^ ])))(?=(?:[^"]|"[^"]*")*$)/ui', '"${2}"', $format);
|
|
|
170 |
|
|
|
171 |
// Get the sections, there can be up to four sections, separated with a semi-colon (but only if not a quoted literal)
|
|
|
172 |
$sections = preg_split(self::SECTION_SPLIT, $format) ?: [];
|
|
|
173 |
|
|
|
174 |
[$colors, $format, $value] = self::splitFormatForSectionSelection($sections, $value);
|
|
|
175 |
|
|
|
176 |
// In Excel formats, "_" is used to add spacing,
|
|
|
177 |
// The following character indicates the size of the spacing, which we can't do in HTML, so we just use a standard space
|
|
|
178 |
$format = (string) preg_replace('/_.?/ui', ' ', $format);
|
|
|
179 |
|
|
|
180 |
// Let's begin inspecting the format and converting the value to a formatted string
|
|
|
181 |
if (
|
|
|
182 |
// Check for date/time characters (not inside quotes)
|
|
|
183 |
(preg_match('/(\[\$[A-Z]*-[0-9A-F]*\])*[hmsdy](?=(?:[^"]|"[^"]*")*$)/miu', $format))
|
|
|
184 |
// Look out for Currency formats Issue 4124
|
|
|
185 |
&& !(preg_match('/\[\$[A-Z]{3}\]/miu', $format))
|
|
|
186 |
// A date/time with a decimal time shouldn't have a digit placeholder before the decimal point
|
|
|
187 |
&& (preg_match('/[0\?#]\.(?![^\[]*\])/miu', $format) === 0)
|
|
|
188 |
) {
|
|
|
189 |
// datetime format
|
|
|
190 |
$value = DateFormatter::format($value, $format);
|
|
|
191 |
} else {
|
|
|
192 |
if (str_starts_with($format, '"') && str_ends_with($format, '"') && substr_count($format, '"') === 2) {
|
|
|
193 |
$value = substr($format, 1, -1);
|
|
|
194 |
} elseif (preg_match('/[0#, ]%/', $format)) {
|
|
|
195 |
// % number format - avoid weird '-0' problem
|
|
|
196 |
$value = PercentageFormatter::format(0 + (float) $value, $format);
|
|
|
197 |
} else {
|
|
|
198 |
$value = NumberFormatter::format($value, $format);
|
|
|
199 |
}
|
|
|
200 |
}
|
|
|
201 |
|
|
|
202 |
// Additional formatting provided by callback function
|
|
|
203 |
if (is_callable($callBack)) {
|
|
|
204 |
$value = $callBack($value, $colors);
|
|
|
205 |
}
|
|
|
206 |
|
|
|
207 |
return str_replace(chr(0x00), '.', $value);
|
|
|
208 |
}
|
|
|
209 |
}
|