From 39511e03b58e994997cd31063ae1176045e4fc21 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Sat, 10 May 2025 21:40:28 -0700 Subject: [PATCH] Ods Handling of Ceiling and Floor Fix #407, which went stale over 7 years ago, and is now reopened. Ods handling of CEILING and FLOOR functions is, apparently by design, subject to interoperability problems with Excel. See [FLOOR](https://wiki.documentfoundation.org/Documentation/Calc_Functions/FLOOR) and [CEILING](https://wiki.documentfoundation.org/Documentation/Calc_Functions/CEILING). Ods implements its own `CEILING.XCL` and `FLOOR.XCL` functions for interoperability purposes. PhpSpreadsheet will implement those, and `CEILING.ODS` and `FLOOR.ODS` psuedo-functions as well; none of these four will be listed in the official Functions documentation. In all of the descriptions below, FLOOR follows CEILING exactly. Ods writes its functions to Xml as follows: - CEILING.XCL becomes COM.MICROSOFT.CEILING - CEILING.MATH becomes COM.MICROSOFT.CEILING.MATH - CEILING.PRECISE becomes COM.MICROSOFT.CEILING.PRECISE - CEILING remains unchanged PhpSpreadsheet Ods Writer will do the same, plus: - CEILING.ODS becomes CEILING PhpSpreadsheet Ods Reader will act as follows: - CEILING (not preceded by 'COM.MICROSOFT.') becomes CEILING.ODS - COM.MICROSOFT.CEILING.MATH becomes CEILING.MATH - COM.MICROSOFT.CEILING.PRECISE becomes CEILING.PRECISE - COM.MICROSOFT.CEILING becomes CEILING PhpSpreadsheet Xlsx Writer will act as follows: - CEILING.ODS becomes CEILING.MATH - CEILING.XCL becomes CEILING PhpSpreadsheet Xls Writer will recognize only the CEILING and FLOOR functions. Note that the only difference between Ods CEILING and CEILING.MATH is that CEILING will return an error if the number and significance operands have opposite signs. PhpSpreadsheet implements this as `#VALUE!`. --- infra/DocumentGenerator.php | 13 +++ .../Calculation/FunctionArray.php | 24 +++++ .../Calculation/MathTrig/Ceiling.php | 38 ++++++-- .../Calculation/MathTrig/Floor.php | 37 +++++-- .../Reader/Ods/FormulaTranslator.php | 18 +++- src/PhpSpreadsheet/Writer/Ods/Formula.php | 19 ++++ .../Writer/Xlsx/FunctionPrefix.php | 12 +++ .../Reader/Ods/CeilingFloorTest.php | 91 ++++++++++++++++++ tests/data/Reader/Ods/issue.407.ods | Bin 0 -> 11580 bytes 9 files changed, 235 insertions(+), 17 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Reader/Ods/CeilingFloorTest.php create mode 100644 tests/data/Reader/Ods/issue.407.ods diff --git a/infra/DocumentGenerator.php b/infra/DocumentGenerator.php index 85742d278..c142c434b 100644 --- a/infra/DocumentGenerator.php +++ b/infra/DocumentGenerator.php @@ -9,6 +9,13 @@ use UnexpectedValueException; class DocumentGenerator { + private const EXCLUDED_FUNCTIONS = [ + 'CEILING.ODS', + 'CEILING.XCL', + 'FLOOR.ODS', + 'FLOOR.XCL', + ]; + /** * @param array $phpSpreadsheetFunctions */ @@ -23,6 +30,9 @@ class DocumentGenerator $result .= self::tableRow($lengths, ['Excel Function', 'PhpSpreadsheet Function']) . "\n"; $result .= self::tableRow($lengths, null) . "\n"; foreach ($phpSpreadsheetFunctions as $excelFunction => $functionInfo) { + if (in_array($excelFunction, self::EXCLUDED_FUNCTIONS, true)) { + continue; + } if ($category === $functionInfo['category']) { $phpFunction = self::getPhpSpreadsheetFunctionText($functionInfo['functionCall']); $result .= self::tableRow($lengths, [$excelFunction, $phpFunction]) . "\n"; @@ -87,6 +97,9 @@ class DocumentGenerator $result = "# Function list by name\n"; $lastAlphabet = null; foreach ($phpSpreadsheetFunctions as $excelFunction => $functionInfo) { + if (in_array($excelFunction, self::EXCLUDED_FUNCTIONS, true)) { + continue; + } $lengths = [25, 31, 37]; if ($lastAlphabet !== $excelFunction[0]) { $lastAlphabet = $excelFunction[0]; diff --git a/src/PhpSpreadsheet/Calculation/FunctionArray.php b/src/PhpSpreadsheet/Calculation/FunctionArray.php index c7cc13b45..0692d34a8 100644 --- a/src/PhpSpreadsheet/Calculation/FunctionArray.php +++ b/src/PhpSpreadsheet/Calculation/FunctionArray.php @@ -277,11 +277,23 @@ class FunctionArray extends CalculationBase 'functionCall' => [MathTrig\Ceiling::class, 'math'], 'argumentCount' => '1-3', ], + // pseudo-function to help with Ods + 'CEILING.ODS' => [ + 'category' => Category::CATEGORY_MATH_AND_TRIG, + 'functionCall' => [MathTrig\Ceiling::class, 'mathOds'], + 'argumentCount' => '1-3', + ], 'CEILING.PRECISE' => [ 'category' => Category::CATEGORY_MATH_AND_TRIG, 'functionCall' => [MathTrig\Ceiling::class, 'precise'], 'argumentCount' => '1,2', ], + // pseudo-function implemented in Ods + 'CEILING.XCL' => [ + 'category' => Category::CATEGORY_MATH_AND_TRIG, + 'functionCall' => [MathTrig\Ceiling::class, 'ceiling'], + 'argumentCount' => '2', + ], 'CELL' => [ 'category' => Category::CATEGORY_INFORMATION, 'functionCall' => [Functions::class, 'DUMMY'], @@ -914,11 +926,23 @@ class FunctionArray extends CalculationBase 'functionCall' => [MathTrig\Floor::class, 'math'], 'argumentCount' => '1-3', ], + // pseudo-function to help with Ods + 'FLOOR.ODS' => [ + 'category' => Category::CATEGORY_MATH_AND_TRIG, + 'functionCall' => [MathTrig\Floor::class, 'mathOds'], + 'argumentCount' => '1-3', + ], 'FLOOR.PRECISE' => [ 'category' => Category::CATEGORY_MATH_AND_TRIG, 'functionCall' => [MathTrig\Floor::class, 'precise'], 'argumentCount' => '1-2', ], + // pseudo-function implemented in Ods + 'FLOOR.XCL' => [ + 'category' => Category::CATEGORY_MATH_AND_TRIG, + 'functionCall' => [MathTrig\Floor::class, 'floor'], + 'argumentCount' => '2', + ], 'FORECAST' => [ 'category' => Category::CATEGORY_STATISTICAL, 'functionCall' => [Statistical\Trends::class, 'FORECAST'], diff --git a/src/PhpSpreadsheet/Calculation/MathTrig/Ceiling.php b/src/PhpSpreadsheet/Calculation/MathTrig/Ceiling.php index 365ec2e98..8657086ad 100644 --- a/src/PhpSpreadsheet/Calculation/MathTrig/Ceiling.php +++ b/src/PhpSpreadsheet/Calculation/MathTrig/Ceiling.php @@ -22,12 +22,12 @@ class Ceiling * Excel Function: * CEILING(number[,significance]) * - * @param array|float $number the number you want the ceiling + * @param array|float $number the number you want the ceiling * Or can be an array of values - * @param array|float $significance the multiple to which you want to round + * @param array|float $significance the multiple to which you want to round * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * If an array of numbers is passed as an argument, then the returned result will also be an array * with the same dimensions */ @@ -63,14 +63,14 @@ class Ceiling * Or can be an array of values * @param mixed $significance Significance * Or can be an array of values - * @param array|int $mode direction to round negative numbers + * @param array|int $mode direction to round negative numbers * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * If an array of numbers is passed as an argument, then the returned result will also be an array * with the same dimensions */ - public static function math(mixed $number, mixed $significance = null, $mode = 0): array|string|float + public static function math(mixed $number, mixed $significance = null, $mode = 0, bool $checkSigns = false): array|string|float { if (is_array($number) || is_array($significance) || is_array($mode)) { return self::evaluateArrayArguments([self::class, __FUNCTION__], $number, $significance, $mode); @@ -87,6 +87,11 @@ class Ceiling if (empty($significance * $number)) { return 0.0; } + if ($checkSigns) { + if (($number > 0 && $significance < 0) || ($number < 0 && $significance > 0)) { + return ExcelError::VALUE(); + } + } if (self::ceilingMathTest((float) $significance, (float) $number, (int) $mode)) { return floor($number / $significance) * $significance; } @@ -104,10 +109,10 @@ class Ceiling * * @param mixed $number the number you want to round * Or can be an array of values - * @param array|float $significance the multiple to which you want to round + * @param array|float $significance the multiple to which you want to round * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * If an array of numbers is passed as an argument, then the returned result will also be an array * with the same dimensions */ @@ -132,6 +137,23 @@ class Ceiling return ceil($result) * $significance * (($significance < 0) ? -1 : 1); } + /** + * CEILING.ODS, pseudo-function - CEILING as implemented in ODS. + * + * ODS Function (theoretical): + * CEILING.ODS(number[,significance[,mode]]) + * + * @param mixed $number Number to round + * @param mixed $significance Significance + * @param array|int $mode direction to round negative numbers + * + * @return array|float|string Rounded Number, or a string containing an error + */ + public static function mathOds(mixed $number, mixed $significance = null, $mode = 0): array|string|float + { + return self::math($number, $significance, $mode, true); + } + /** * Let CEILINGMATH complexity pass Scrutinizer. */ diff --git a/src/PhpSpreadsheet/Calculation/MathTrig/Floor.php b/src/PhpSpreadsheet/Calculation/MathTrig/Floor.php index 83cf0515a..320d5cbc0 100644 --- a/src/PhpSpreadsheet/Calculation/MathTrig/Floor.php +++ b/src/PhpSpreadsheet/Calculation/MathTrig/Floor.php @@ -32,7 +32,7 @@ class Floor * @param mixed $significance Expect float. Significance * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * If an array of numbers is passed as an argument, then the returned result will also be an array * with the same dimensions */ @@ -71,11 +71,11 @@ class Floor * @param mixed $mode direction to round negative numbers * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * If an array of numbers is passed as an argument, then the returned result will also be an array * with the same dimensions */ - public static function math(mixed $number, mixed $significance = null, mixed $mode = 0) + public static function math(mixed $number, mixed $significance = null, mixed $mode = 0, bool $checkSigns = false) { if (is_array($number) || is_array($significance) || is_array($mode)) { return self::evaluateArrayArguments([self::class, __FUNCTION__], $number, $significance, $mode); @@ -89,9 +89,34 @@ class Floor return $e->getMessage(); } + if ($checkSigns) { + if (($number > 0 && $significance < 0) || ($number < 0 && $significance > 0)) { + return ExcelError::VALUE(); + } + } + return self::argsOk((float) $number, (float) $significance, (int) $mode); } + /** + * FLOOR.ODS, pseudo-function - FLOOR as implemented in ODS. + * + * Round a number down to the nearest integer or to the nearest multiple of significance. + * + * ODS Function (theoretical): + * FLOOR.ODS(number[,significance[,mode]]) + * + * @param mixed $number Number to round + * @param mixed $significance Significance + * @param array|int $mode direction to round negative numbers + * + * @return array|float|string Rounded Number, or a string containing an error + */ + public static function mathOds(mixed $number, mixed $significance = null, mixed $mode = 0) + { + return self::math($number, $significance, $mode, true); + } + /** * FLOOR.PRECISE. * @@ -100,12 +125,12 @@ class Floor * Excel Function: * FLOOR.PRECISE(number[,significance]) * - * @param array|float $number Number to round + * @param array|float $number Number to round * Or can be an array of values - * @param array|float $significance Significance + * @param array|float $significance Significance * Or can be an array of values * - * @return array|float|string Rounded Number, or a string containing an error + * @return array|float|string Rounded Number, or a string containing an error * If an array of numbers is passed as an argument, then the returned result will also be an array * with the same dimensions */ diff --git a/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php b/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php index 123f909d3..032c04a6c 100644 --- a/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php +++ b/src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Reader\Ods; +use Composer\Pcre\Preg; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; class FormulaTranslator @@ -27,7 +28,7 @@ class FormulaTranslator // Cell range 3-d reference // As we don't support 3-d ranges, we're just going to take a quick and dirty approach // and assume that the second worksheet reference is the same as the first - $excelAddress = (string) preg_replace( + $excelAddress = Preg::replace( [ '/\$?([^\.]+)\.([^\.]+):\$?([^\.]+)\.([^\.]+)/miu', '/\$?([^\.]+)\.([^\.]+):\.([^\.]+)/miu', // Cell range reference in another sheet @@ -62,7 +63,7 @@ class FormulaTranslator // so that conversion isn't done in string values $tKey = $tKey === false; if ($tKey) { - $value = (string) preg_replace( + $value = Preg::replace( [ '/\[\$?([^\.]+)\.([^\.]+):\.([^\.]+)\]/miu', // Cell range reference in another sheet '/\[\$?([^\.]+)\.([^\.]+)\]/miu', // Cell reference in another sheet @@ -103,7 +104,18 @@ class FormulaTranslator Calculation::FORMULA_CLOSE_MATRIX_BRACE ); - $value = (string) preg_replace('/COM\.MICROSOFT\./ui', '', $value); + $value = Preg::replace( + [ + '/\b(?convertCellReferences($formula, $worksheetName); $formula = $this->convertDefinedNames($formula); + $formula = $this->convertFunctionNames($formula); if (!str_starts_with($formula, '=')) { $formula = '=' . $formula; @@ -117,4 +118,22 @@ class Formula return $formula; } + + private function convertFunctionNames(string $formula): string + { + return Preg::replace( + [ + '/\b((CEILING|FLOOR)' + . '([.](MATH|PRECISE))?)\s*[(]/ui', + '/\b(CEILING|FLOOR)[.]XCL\s*[(]/ui', + '/\b(CEILING|FLOOR)[.]ODS\s*[(]/ui', + ], + [ + 'COM.MICROSOFT.$1(', + 'COM.MICROSOFT.$1(', + '$1(', + ], + $formula + ); + } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php b/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php index 31e1e3e0b..80f0636f8 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/FunctionPrefix.php @@ -216,6 +216,18 @@ class FunctionPrefix */ public static function addFunctionPrefixStripEquals(string $functionString): string { + $functionString = Preg::replace( + [ + '/\b(CEILING|FLOOR)[.]ODS\s*[(]/', + '/\b(CEILING|FLOOR)[.]XCL\s*[(]/', + ], + [ + '$1.MATH(', + '$1(', + ], + $functionString + ); + return self::addFunctionPrefix(substr($functionString, 1)); } } diff --git a/tests/PhpSpreadsheetTests/Reader/Ods/CeilingFloorTest.php b/tests/PhpSpreadsheetTests/Reader/Ods/CeilingFloorTest.php new file mode 100644 index 000000000..b7af5edc0 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Ods/CeilingFloorTest.php @@ -0,0 +1,91 @@ + '=FLOOR(2.5,1)', + 'A2' => '=FLOOR.MATH(2.5,1)', + 'A3' => '=FLOOR.PRECISE(2.5,1)', + 'A4' => '=FLOOR.ODS(2.5,1,0)', + 'C1' => '=CEILING(2.5,1)', + 'C2' => '=CEILING.MATH(2.5,1)', + 'C3' => '=CEILING.PRECISE(2.5,1)', + 'C4' => '=CEILING.ODS(2.5,1)', + 'E1' => '=FLOOR.ODS(37, -1, 0)', + 'F1' => '=CEILING.ODS(-37.2,1,0)', + ]; + + private const EXPECTED_XLSX = [ + 'A1' => '=FLOOR(2.5,1)', + 'A2' => '=FLOOR.MATH(2.5,1)', + 'A3' => '=FLOOR.PRECISE(2.5,1)', + 'A4' => '=FLOOR.MATH(2.5,1,0)', // note different from ODS + 'C1' => '=CEILING(2.5,1)', + 'C2' => '=CEILING.MATH(2.5,1)', + 'C3' => '=CEILING.PRECISE(2.5,1)', + 'C4' => '=CEILING.MATH(2.5,1)', // different from ODS + 'E1' => '=FLOOR.MATH(37, -1, 0)', // different from ODS + 'F1' => '=CEILING.MATH(-37.2,1,0)', // different from ODS + ]; + + public function testReadAndWriteOds(): void + { + $reader = new OdsReader(); + $spreadsheetOld = $reader->load(self::INFILE); + $oldSheet = $spreadsheetOld->getActiveSheet(); + foreach (self::EXPECTED_ODS as $key => $value) { + self::assertSame( + $value, + $oldSheet->getCell($key)->getValue(), + "Error in cell $key" + ); + } + self::assertSame('#VALUE!', $oldSheet->getCell('E1')->getCalculatedValue()); + self::assertSame('#VALUE!', $oldSheet->getCell('F1')->getCalculatedValue()); + + $spreadsheet = $this->writeAndReload($spreadsheetOld, 'Ods'); + $spreadsheetOld->disconnectWorksheets(); + $sheet = $spreadsheet->getActiveSheet(); + foreach (self::EXPECTED_ODS as $key => $value) { + self::assertSame( + $value, + $sheet->getCell($key)->getValue(), + "Error in cell $key" + ); + } + self::assertSame('#VALUE!', $sheet->getCell('E1')->getCalculatedValue()); + self::assertSame('#VALUE!', $sheet->getCell('F1')->getCalculatedValue()); + + $spreadsheet->disconnectWorksheets(); + } + + public function testReadAndWriteXlsx(): void + { + $reader = new OdsReader(); + $spreadsheetOld = $reader->load(self::INFILE); + + $spreadsheet = $this->writeAndReload($spreadsheetOld, 'Xlsx'); + $spreadsheetOld->disconnectWorksheets(); + $sheet = $spreadsheet->getActiveSheet(); + foreach (self::EXPECTED_XLSX as $key => $value) { + self::assertSame( + $value, + $sheet->getCell($key)->getValue(), + "Error in cell $key" + ); + } + self::assertSame(37.0, $sheet->getCell('E1')->getCalculatedValue()); // different from ODS + self::assertSame(-37.0, $sheet->getCell('F1')->getCalculatedValue()); // different from ODS + + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Reader/Ods/issue.407.ods b/tests/data/Reader/Ods/issue.407.ods new file mode 100644 index 0000000000000000000000000000000000000000..3f3a0554261f9ea3599dc1c6e4a7c46f834efeb5 GIT binary patch literal 11580 zcmb_?1yo$g)^+1ff(3$ma0{N`?(W)niM=FQ8@dzo4P z`qw+XZuhyp?%s87Ro$vvr?#vF1SC2D01E(^5*4cZn{x!y0RVvKdpbL7TSK6Mor$5Lz3g8&pJ@JV zxKEydm8B8L*unNUHajMIYg;R0TSGfLJ)kZ9-y^YpfqW|0;h$01zd%`ASvy!iL;s<) zU*YWRf%XpnA5q#{S(*QHlmx$s5@Z21HngMX2iaQyt?m9H)L&JMk+~Jn-teEQ^H(?c z9H=$W($M@LO8Zp<0`>I`%?+R0)9Q~BVPRo^>rPMi^>0Ib@+^RsAR|LNdpcVKqoL>_ zOJ9bUe#h_q4TooW>7k%(Df;V7(^^>qRH5k45>c0$!=shuvJ3XoCf7@Z-!6w&B^?5B zdqXPn+7y}fDarapO{#aPIUDHezASLg&(=^AB;uW#`hH2Hs&}%a;?Rlnr)5JlAF0fw za85jbFVjfeHpOluir8Hh4hx4L#t*vOaL48tUV}4Ik+a`Vk94^v{1|N&ZhQCQ`wQG z@3MQWiR+QRtHVUs=`u--ZZgx6%wEGgm&fe~x{=4%Yo%3=g66!#S;pTZx2w(wltfBp zC17Byu1&_FzyJVW2ms*s{{O4$gMX?%J9`&%LpwTW3-d#*{qXe~jECi1hUA)AsDiPs z`0^Ay`luQZxB(lVqc3sGE8L)(pjbh^sGhgCbKAQLmty^eM=>C^tJ&xjNU0$^QGCyB zaW(SgMi0chHi%F(zmA3s4}ByU2K(}l?U#!uGZD+LD_0jb)0S4I;OnecflfHB3@lL> zeINTi*ds?ny;(x4wfWjg$k#^<2`2d}zo8Wc(J)u&lUrL3DlOJB5*rAcRx;Xg1(%Io zJ1N{SOzJ_+wzTX>G9t*TsBCqO5S+69S|XbJJH=hHRKvodr6URJ7p}AsDdE_n)TC3P zg;`_GR5N~p@jldv=5nb%oL2Omei4@tVn$hqcW<}!j?uYu>QgAK%B^s}&|)VfFos$u z8##CC=DtJQB&$(O9t;sl^}PP1;!R?t@m{nrbn-qn8YS(cus92jXi11Mk{##^#Vfr?NiAxnfdNixkQ{Z5r3*6&0{kzf03rPq9K}-wJL8kj6tqu2c>D^JbD?uiE}iqcK*RD=j{?wOOSjr z;*u|Wcjt{LVYEBgS=v#67Zi$gGab`u+-f>Ag(2%S1F0)H7~xz7DW#1dg+mZO0dHtR z;papC`#{kZy#YLT05CJAu-)m^SPk7-D}K2xKHjI)D0Jwa-V5Y9M(GnOygTUTkD`<+ z{>L;G9v{pQVOk#2(Z!jPg%TrleIZ5(C4|`>9ThhHGE-D4<;7o=v`%BPl;Elbc1A_5 z@-GvJU%__j;`y5B3z9mwvkPT2Y>~E03AcS(%^-y$D*jZQ1P5;?Xu?^1t#3=!Xz9Xg;W0#XA1+`d_ z3Jp@gd3oui#ZT*VyBE2d8%~E7+AM|E^G$tzJO1MM^E}Q)ewIOw)P@oEt27B$;+n*4 z#s|sGQ%1_2X6&$BGzNTRlP=z<++*Vy4dZ586-nxMOflXhsV4m9qa&FT6=3*FuRBT6 z60jpC70um@(9A%YJQ%y0lImBV++ZpO&% zT1;^2cjMHEqZ4oNGfj-Y^ARy0>BIKs1QYsdW^!nEFuU*-P73+*X25{Xz9dPrLd8Z| zNUOBU=;n}ch(+x;frbt}Yo*fp9I4TbSnFhUv8^&R5pyPT(eGO}n)9STkct;G`)bw^)rQ`@A2`aRcp#~xie?_rE_ zsC<0#k8bgEWv476=M1iHIw?kGP%XYbR$2=rBmC$^V|@93E1ABP%(u*=H(T?K!*|7|?@x8S?Fxb#t3y`mt}Zs^QQ^4Ke7 zB6zV*noetym9boDXd6B96%%e@V32LZM9lPMm>@xkJQU@Y-bNMwdosEJ)Xw*k82&h} zv6*G**O3!4I-+cGFYpQ=7%Nu}l>7RZ3b3We?N(X(qajR@h@`rmZn%k#3pEA0Gqi>3 zzV)rg1}fYIeR8%oU>3wPQw3rN%7DSorFu$zcQFYY_Zw^@H^EZSCw4QqHy67lD3dXd z=7!ThN^!`YZn?8#h59i|gP-3f2XH4j6Ix z9Dtl)az?_SUl^{w{uL(91OSxR&sqc-1DYfDQu^}PHaNyTjw_POERu_%v~4yA7>kj_?TPp3kWqRMig!B@UeIOuEaRa}LKv9KT>w%f(MmeUf2v3Pe zQ&S_{dsD%LxldHV)Uk`Nma8IP)*M-k)%N2%!u0m+A|BX{Ax6=*v_bgYcP>B#w2pSl zreJvbFx&)Y3|h|prXNU#1L!I#onYdrba^**g&+nGd4efxp`)No7^tIKeNRTqP~DhZ z#Zc}lfk4B35&0@%sy5dj9jZ)?#jyliPi6s~e7q5BeQ~sBwSKQO#E6V6GPnf0wWu$X zNE=@#5c4CQXoj}{1W&8;=7dV@26coF$hX!e^UVQSiK9KfVIFuylgLR;@4I0k8BM`P z^x{afDj3cNmeIMye1G{>eLsQk*ua$JPqS7ofdY$2BCl=y^)X#tt|5y%-U~TM3nVri zZ(1?zQlEn?dEHVfm@eGheXJLBFt|~aoVPDcJ>kCIi6(w;S3M6Y^V}{9y~hhHerFu^%f8wD(EB{M|q7zOJgMC_Je9`H>Ja?;t zb%e)ftbQKi$VPtxRGP=@v`3>%C1}d^qCArYg>?=`tvxI7{=7XOkIn^(p_ljG4tBw{ zx;fIfv{?ReTl@*_+fQAk> z7(YCq+tPXG#+jVrF+ujdP*Y>g+0a5Yj$|xJMlp5a)Qe2Kt2K zD1MCluahI=L z_x&5)g&5yKCV86e8h^YYB`YrLAgOtX<5GTXEU=RBB+@aLAEcX#Ds`|UKRC(T#xp>? zw5mj|-RvKpYfM)iKh8o3)_`$GcDA<>n@#C;6;XeId9}-T-%v__wGg~;-nEVpa4VR@ zebhBMrGuI}G+BB~`L^qjiT@G<6MI7Y;QA`$-m&9w@98}0yG29(d9#BMrF5ey`#ytJ z0fi`Tb!WJMtDI@)S*eeAd5Y&;kmxh7reK4++w{aYSqQ`_1m`;g4~iAci)5*mdCe4b!N`qi zkn`6q?&LHB_19!v&ZTith2Kvq==Ft1N6#|0zr1;kkhCgW z9IPn3)XM zo)g#Y0#OL9+T)?u$bwtlU-5>fw~r1}B+j?47T?eDK#j zgUoDWZ8G)?fl0Ja;mdDqwP45L5sh4pLpvd6)zkeHlHuliiNkNQjXkN@sosJFzI!Xi zE^Mg#PPH&xarV*0Es|;0(Y;W~t&H7`IUZz&VEvY3laa@XhR9@^1|M*H$AHxgWn2ph z-%3ejtyL#7?B>pM{`Xk54(U6 zTo#VQ*H~&<_jvnJ;W>8cV>XJ1u)-vHZ`Z%h+X&b14zlMv+Hc#{;wdiKs2}glvYPLI z?ISRr#DLRiC@DK$$Gbq>#EK3=GP)kb*oW4(94x)mJ4#&M67rP;$Zudrm0gqZY`;Hd zrdoE2eyxXugeq{tN*i=d2Y+_Hgdm9&ADYfiG!)}6W6hS`Bbv_8a+2%597TY$>yb)p z(pex*Hm{5|A>r4b??dl*~Fi@l{nh@g=SOO1y7=`+PZGQo%~3FaY`P} zCRIS*g-=RputL;GN({cy1gf9AF#_+3VF^C_xgQGvf z4mh~z<}t_iX}$!HG!QVD`5q+9DZhY2Cvl`28ure9-YK3vN5qxvqsGO|AT&WQcontyx?;77 zVQ&9>V!C9jv3P|E%t!PN7LV1ye$PC37vDPfu1B3<`Ig>(o|Y1zN@DqBXL2Sn)cxUS z!C>t2^oUoH*KXgwJ|y4@0jO4Zdw&YWa6bY$kRgAKZ6rx@#Y_F3>Fs?2Pk_j--(n$`ItC zP~&fJg&LL!=VB`*i_6&Mj+WyWMP&Ey2*kJ;FRMWf3rvrlXMo015wpTFU;G zSTeK+J}+d$q6bPQ7|?~b-GvMi$ZO8SW4DH1LcA13Z!5Arn9vhF?689@htJZXd6`RaM z>1$*%1zEkp%7MhC;#9lkU*Lv>q&wBS()=XAW+J{iHGf>9L>a7Kw}8oD@OAWLG(mx~ zUsNI~G?~v2mm-H{V`*L>^1NtaZUK94V#U(Jg5sxag~dN+D-fQt75Y||_RopIr&eB6 zv#?rVefiL=12kzZ^GM%}H25t9eGI8+&5QK_)&uz!-z->z5B z;0>))ZK&+qP^rBs86_g9pHOB(wGI(JVV8H$^Tjz*gir8Zg7 z&`g9glJ!1D6}~VUPYKVp^bZSa7q|tPr$i~rm|J5ZeJv^ORYp>po~P|!hMSfT*zPlr(&``A2*^T;(k0>6oVaYAf zC2t?SjT%A+2XkxKg#`y46kVbP3l!!hx`Klb9ND;48|ytd%e$FIe>L2oXv6XSga3-G z)o4AnrsUj2`z>6qX6iIwSMXa?tSQ|pa=2tz6BnjjdDfSdO5DlckzP&OV_q}lfs z-|*D;1RZQnwKu_659WeBAQs~p!G3v@FM3Co95;!nc_th7(0vSd_}dk=ZWUVw7tV_k z1>lYFNieuJi%eHvRomi^j;=6(b>VNJa47dk^m~+ZZ|7F_&hrH{_5VvK02y;-I zc#0CycnC_yzUc0d@kBJofoFmZIow))CZkbaNr|9vs(||yJ!)DrhbP$*yc0~LdMHqx z4E&3;lx)W7J2;ze#ew`pfi6E>+0RUOif5F?yBx2UkSVsnRQ15~r>qOOci+|em5WE| zWu#SZtrG%^Cpo9;TJz!Zo8d2eO7<=n8`797``<#zNX)+JlvI}q6D=jdfJ-eVQEY}n zOT`7`=T7Qcad#6xkCPc0r zyRs(GVPwNEt~N2Hj*bjKq`b#9G*~z~kBf;FsgfE9E@=o<1MyVj2L{mwO~B@Pnc}f$ z(ZObs9SiQ%m6|tYu&k`=*6aig_MEE^0*9K@b9NYaHj#U+fVTyy`%Bo z9L<1}rrvao9`mTmP|;4tleK${QyyAI7SDrhrfdstUEipMM?oJdG*QvgD(+KWhQ!m@ zGpl}@ZDV=w<9rxsaq(S4LKZfiT|8`@+`QZ_8Jg?snwoyNuyS*zJXE;R>c)MlQ1B1c zpNgTu{R&subIbk8#Dve4`<>mu^6)p-N{^Bwi5(qH?PX1`oAo*5K^P7OuQ&)T7%;jj zZ%>S%gcc6GjNM(6@xu^B@ZiqiU>U{llZU9@@$s{D_LP23%r4qSoD;Ip_(RK~#<>#{ zk1=Hn1rep%4C|W>AFr1^rWyxBN>w?VhHNb@(^N*z(wyQkXRhOyy)t$7dLNl*swGNG zZ9ME;?yun;+ z3IG5Hz&=e$0M|S`=BFPcSxH492nYyxcz854G+bO*?v4nVH$!+q=2B`S|#RgoMP!#igXAWMyR)78X`kRyH*?b#!zL3=E8qkAM62 zZGCg!AW11T zZAX7C9@vW;`ys0{9HPK^?7F~m89C}^gVz(`zy8uG`uP&7w1Pgx}JH#@pM}5gxBb z?pGe;=Z4MKGCAM(K20`olWi=;+_qf4UmBuXC8=I5Q`>sqydc!Q-?p6l-F>O2K*od% zkC-xTM5mCB=-T1*`z#w=)4tZsft2`0@Z=K&C zl)v<&-yWU+`se=m8g0UAFuv-gQQ*@+0uU3D7A)b{_4)hFz~{YGJ41VWkfrhO+ku*g zPMMr&j}LbkT%Jw2`x*hv_o zTHZ=k+BQ^nl>DeotC2bzsFNrkv9PP5-Dcq0nrIPvr6 ze6d^y7AMB5fJ%$-k}Qg_^%dDC{B^Mp{IQGZggM8MgRbBM+!6^2xJYOb_4vn$Ax*v= zm%*)E{>#Sk0#j<(&Sn&YsGZ)3F~&aYp<^VUesl()yP#1Mry6JO#KtVYXIhJ6T-A_V z0cYCA5lJY;gFmsRGYTMy+9gQi2H4!8WC-yK@PD>L_oa{Sm3fc;LayrltiMxYOG596 zoY306Zo;=s9F-yA^|L+ponkpi^h2#^tK7YEIp{9Q!_F6qQCAEVb6K;yVI6)3pCbMH z!AF0bdhol%C78QMJD)-l3`!lNL`luz;{YOFfFm5sw~{4meijW@9YBO6K(fiU|PaD8LIv5&1BsXPSys&h3U zMy3M%%z-@Mx}#%%4XP>lKKwSxdzn$^5vjZW1=9>|5j_;0_9Mc+L>d=ng-AO`xN7}= z=#Mw%>yv267E8?2$QDQNy|*4y0-Zt@8cY9j@6gZn_-Rj6TaZj)94@u zc@RdiMfj2TD$kjd&{(6VyTldRFzd0wXuBh6Q4=$Mo~R7H4vng`5%G+#VuZIL73nng zjBRvZ_H5Ta^cP`lu7CLrAe7W!Oqt-NWOjK{&Y`Ln7H1XWqj#&_UczEWLQ!x5>xBwZ zI1bsYO&#nmnvVBJO4P0>tk+v6n{w&qgo@N{9Cf<(h%~-40#N}v>Rqj})ozU=O+TQo zB3yje&1pxoF9mMFvb+Sdf5|$kI3MoMDr7Q9Y8J1yYgL=V^#cs z2?`y{_aJbk%+^zCPDvb?!@$AiufqnFU?nkLTPkjeT|)nuXIn4_aWCdFhX=~k!S=|_ z`jFswF6KAL?dw_>b+;Cg&+In2%2rZp{ z2natovj!$NVI(OHxQ@#rb?Tv;X3=zJ_3}B~(7}NI6D*zDOV=V)g=tzKXVm0a?Jd2! z9>6lft7!z>=A+l$X0LvE)~#dl*zL}&$1V0=_>h?&aGEA!?tUBmjOta~YUOz~cgH|y zU&KN=z)0s+OM^fehBv>;$a;N|Z!A21#3M}!eLCC5wNxq$jfiS{hT}NPY<}d4TX!z6 z%GzOSy7B4z_A}`C;WNk%7fF9kd$ac?Z{9akH#XNoB7D6gqBp*M(*ftgMYnDsMi#3m z|BJ8jG=8*5Vf~?(kOG!s0Yr#O`iwIdaoGg|vTnkrj$sHE^XkLQwuX}QBokIFQg5KN z@DZ71VjW5yN_QO89Q{xd!&!~&suH1sv#y~t`br$H=f}vZsdId?>6`LR5x3^$@$>iA zWjryP>L{cAVMD zPpRFqwcp|1H2a3Y)Y__e-B|hWWq750z5gxa*`@UHyCVz$P|N?vYUWqs9gL^>T1rTf zk5)`tnEu})sHenOaWtQ02jNShYYzXe8m8>S9DbXMyh*fdnDna^!nO~(fl&*X8a8VM z!RQimj0}yBw8&!GzT#_>t zK#t4{s?Vfy9HLR`yAZnD3kLwPhr{@+@#@6a&c>Kkm)412%j3~Re}txFj(ec^D10yp zATj(hGhan$>?a3mD6o_n?dI2-U%xftH8$H%-x+1FnVF%5+zBn%#Z3oNL7L5vMY8Ml z^dR2c?Y{SiEs%f#=;;N;%0$dxaD-o6kmFZHGMz;MMgpF;K$uQJ5k1m8gqro*!8=T0 zg2o6S1~(D!e#kDpA_#ezk8u>BgssZD9|Dd%d7)B1Ib<(pmr)p{F9`$A4aFejZYLs) znou4+@2)VPlaS4l16^;Ql8`h{D@9oeFmQCh-%A*u->81tAA-jJME$%10C?88{WQfV z_n#$;e=Ywf^7mEj&-3tKF#VJ;{yodHR_~{EKDqxD&z~fX|F1lEzu@_^#PRQWp4a9- zjrpk^{xhDRlE=Sic@{nXG}B+O{Fems@5%leQtmIveo7+$p5?C9Z>XZHUO;NRtWegZw7THx=pK7X+M zakGCOdH-5XNl#tt&#R;VME-g7cpjO5n#7yG8K-3>pq^4z007F<$M9*;X`^^P`#*c| Bdq@BP literal 0 HcmV?d00001