Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions Private/XlFnFormula.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Functions added to Excel after the 2007 file format was defined are stored inside the xlsx file
# with an "_xlfn." prefix (SORT and FILTER use "_xlfn._xlws."). Excel adds the prefix invisibly when
# a formula is typed in, but when a formula is written straight into the file - as EPPlus does - the
# prefix must already be present: without it Excel treats the name as an unknown defined name and the
# cell shows #NAME? until it is manually re-entered (see issue #1728 and
# https://support.microsoft.com/en-us/office/issue-an-xlfn-prefix-is-displayed-in-front-of-a-formula-882f1ef7-68fb-4fcd-8d54-9fbb77fd5025 ).
# This table maps each affected function to its prefix. It was generated by entering candidate
# functions into Excel (Microsoft 365) via COM, reading back what Excel stored in the worksheet XML,
# and cross-checking against the future-function lists other xlsx writers (XlsxWriter, PhpSpreadsheet)
# use. NETWORKDAYS.INTL, WORKDAY.INTL and ISO.CEILING are absent deliberately - Excel stores them
# unprefixed, although PhpSpreadsheet believes otherwise. The script which regenerates this table from
# the installed Excel - and verifies the table against it - is __tests__\Get-XlFnFunctionList.ps1,
# with its latest output in __tests__\XlFnFunctionList.txt.
$script:XlFnFunctionPrefix = @{}
foreach ($functionName in @(
#Added in Excel 2010
'AGGREGATE', 'BETA.DIST', 'BETA.INV', 'BINOM.DIST', 'BINOM.INV', 'CEILING.PRECISE', 'CHISQ.DIST',
'CHISQ.DIST.RT', 'CHISQ.INV', 'CHISQ.INV.RT', 'CHISQ.TEST', 'CONFIDENCE.NORM', 'CONFIDENCE.T',
'COVARIANCE.P', 'COVARIANCE.S', 'ERF.PRECISE', 'ERFC.PRECISE', 'EXPON.DIST', 'F.DIST', 'F.DIST.RT',
'F.INV', 'F.INV.RT', 'F.TEST', 'FLOOR.PRECISE', 'GAMMA.DIST', 'GAMMA.INV', 'GAMMALN.PRECISE',
'HYPGEOM.DIST', 'LOGNORM.DIST', 'LOGNORM.INV', 'MODE.MULT', 'MODE.SNGL', 'NEGBINOM.DIST', 'NORM.DIST',
'NORM.INV', 'NORM.S.DIST', 'NORM.S.INV', 'PERCENTILE.EXC', 'PERCENTILE.INC', 'PERCENTRANK.EXC',
'PERCENTRANK.INC', 'POISSON.DIST', 'QUARTILE.EXC', 'QUARTILE.INC', 'RANK.AVG', 'RANK.EQ', 'STDEV.P',
'STDEV.S', 'T.DIST', 'T.DIST.2T', 'T.DIST.RT', 'T.INV', 'T.INV.2T', 'T.TEST', 'VAR.P', 'VAR.S',
'WEIBULL.DIST', 'Z.TEST',
#Added in Excel 2013
'ACOT', 'ACOTH', 'ARABIC', 'BASE', 'BINOM.DIST.RANGE', 'BITAND', 'BITLSHIFT', 'BITOR', 'BITRSHIFT',
'BITXOR', 'CEILING.MATH', 'COMBINA', 'COT', 'COTH', 'CSC', 'CSCH', 'DAYS', 'DECIMAL', 'ENCODEURL',
'FILTERXML', 'FLOOR.MATH', 'FORMULATEXT', 'GAMMA', 'GAUSS', 'IFNA', 'IMCOSH', 'IMCOT', 'IMCSC',
'IMCSCH', 'IMSEC', 'IMSECH', 'IMSINH', 'IMTAN', 'ISFORMULA', 'ISOWEEKNUM', 'MUNIT', 'NUMBERVALUE',
'PDURATION', 'PERMUTATIONA', 'PHI', 'RRI', 'SEC', 'SECH', 'SHEET', 'SHEETS', 'SKEW.P', 'UNICHAR',
'UNICODE', 'WEBSERVICE', 'XOR',
#Added in Excel 2016
'FORECAST.ETS', 'FORECAST.ETS.CONFINT', 'FORECAST.ETS.SEASONALITY', 'FORECAST.ETS.STAT',
'FORECAST.LINEAR',
#Added in Excel 2019
'CONCAT', 'IFS', 'MAXIFS', 'MINIFS', 'SWITCH', 'TEXTJOIN',
#Added in Excel 2021 / Microsoft 365
'ARRAYTOTEXT', 'BYCOL', 'BYROW', 'CHOOSECOLS', 'CHOOSEROWS', 'DROP', 'EXPAND', 'GROUPBY', 'HSTACK',
'IMAGE', 'ISOMITTED', 'LAMBDA', 'LET', 'MAKEARRAY', 'MAP', 'PERCENTOF', 'PIVOTBY', 'RANDARRAY',
'REDUCE', 'REGEXEXTRACT', 'REGEXREPLACE', 'REGEXTEST', 'SCAN', 'SEQUENCE', 'SORTBY', 'STOCKHISTORY',
'TAKE', 'TEXTAFTER', 'TEXTBEFORE', 'TEXTSPLIT', 'TOCOL', 'TOROW', 'TRIMRANGE', 'UNIQUE',
'VALUETOTEXT', 'VSTACK', 'WRAPCOLS', 'WRAPROWS', 'XLOOKUP', 'XMATCH'
)) { $script:XlFnFunctionPrefix[$functionName] = '_xlfn.' }
foreach ($functionName in @('FILTER', 'SORT')) { $script:XlFnFunctionPrefix[$functionName] = '_xlfn._xlws.' }

#A function name counts as a match only when it is not part of a longer or already-prefixed name
#(no word character or "." before it) and a "(" follows it. Names are sorted longest first so that
#e.g. CHISQ.DIST.RT is preferred over CHISQ.DIST.
#The Compiled option is deliberately not used: it makes construction ~20x slower (paid at module load)
#and measures no faster on these patterns - .NET's interpreted engine handles the alternation well.
$script:XlFnRegexOptions = [System.Text.RegularExpressions.RegexOptions]'IgnoreCase, CultureInvariant'
$script:XlFnEscapedNames = ($script:XlFnFunctionPrefix.Keys | Sort-Object -Property Length -Descending |
ForEach-Object { [regex]::Escape($_) }) -join '|'
$script:XlFnNameTestRegex = [regex]::new("(?<![\w.])(?:$script:XlFnEscapedNames)(?=\s*\()", $script:XlFnRegexOptions)
#Matches the constructs a function name must not be rewritten inside: "string literals" and 'quoted
#sheet names' (a doubled quote is an escape) and [structured references] (where ' escapes the next
#character; nested forms like [[#This Row],[Name]] are covered because every name lives in an
#innermost bracket pair).
$script:XlFnSkipRegex = [regex]::new('"(?:[^"]|"")*"|''(?:[^'']|'''')*''|\[(?:[^\[\]'']|''.)*\]', $script:XlFnRegexOptions)
#Two replacement patterns, one per prefix, both run after the skip sections are cut out. The two name
#sets are disjoint and the "(?=\s*\()" lookahead keeps e.g. SORTBY( from matching SORT, so the passes
#cannot interfere with each other.
$script:XlFnPlainNames = ($script:XlFnFunctionPrefix.GetEnumerator() | Where-Object Value -eq '_xlfn.' |
Sort-Object { $_.Key.Length } -Descending | ForEach-Object { [regex]::Escape($_.Key) }) -join '|'
$script:XlFnNameRegex = [regex]::new("(?<![\w.])(?:$script:XlFnPlainNames)(?=\s*\()", $script:XlFnRegexOptions)
$script:XlFnXlwsNameRegex = [regex]::new('(?<![\w.])(?:FILTER|SORT)(?=\s*\()', $script:XlFnRegexOptions)

function Expand-XlFnFormula {
<#
.SYNOPSIS
Inserts the "_xlfn." prefixes Excel needs in front of post-2007 function names in a formula.
.DESCRIPTION
Given a formula string (without its leading "="), returns the formula with function names
like IFS or CONCAT rewritten as _xlfn.IFS / _xlfn.CONCAT so Excel recognizes them when the
file is opened; Excel displays and calculates the formula as if the prefix were not there.
"String literals", 'quoted sheet names' and [structured references] are left untouched, as
are names that already carry a prefix. Setting $env:NoXlFn disables the transformation.
#>
param(
[AllowEmptyString()][AllowNull()][String]$Formula
)
if (-not $Formula -or $env:NoXlFn) { return $Formula }
#Most formulas contain no affected function: test cheaply before rewriting. A false positive here
#(the name only occurs inside a string or bracket) costs a rewrite pass which changes nothing.
if (-not $script:XlFnNameTestRegex.IsMatch($Formula)) { return $Formula }
$skips = $script:XlFnSkipRegex.Matches($Formula)
if ($skips.Count -eq 0) {
return $script:XlFnXlwsNameRegex.Replace($script:XlFnNameRegex.Replace($Formula, '_xlfn.$&'), '_xlfn._xlws.$&')
}
#Rewrite the stretches between the protected sections, and copy the protected sections through.
$builder = [System.Text.StringBuilder]::new($Formula.Length + 16)
$position = 0
foreach ($skip in $skips) {
if ($skip.Index -gt $position) {
$segment = $Formula.Substring($position, $skip.Index - $position)
[void]$builder.Append($script:XlFnXlwsNameRegex.Replace($script:XlFnNameRegex.Replace($segment, '_xlfn.$&'), '_xlfn._xlws.$&'))
}
[void]$builder.Append($skip.Value)
$position = $skip.Index + $skip.Length
}
if ($position -lt $Formula.Length) {
$segment = $Formula.Substring($position)
[void]$builder.Append($script:XlFnXlwsNameRegex.Replace($script:XlFnNameRegex.Replace($segment, '_xlfn.$&'), '_xlfn._xlws.$&'))
}
return $builder.ToString()
}

function Register-XlFnFunction {
<#
.SYNOPSIS
Teaches EPPlus's calculation engine the "_xlfn."-prefixed aliases of the functions it implements.
.DESCRIPTION
The calculation engine looks functions up by the name stored in the formula, so _xlfn.IFNA would
not be found even though IFNA is implemented. Registering each implemented function again under
its _xlfn. name lets Calculate() work on formulas written by Expand-XlFnFormula. Functions the
engine does not implement still produce #NAME? when calculated, exactly as they did before.
#>
param(
[Parameter(Mandatory = $true)][OfficeOpenXml.ExcelWorkbook]$Workbook
)
$parserManager = $Workbook.FormulaParserManager
foreach ($implemented in @($parserManager.GetImplementedFunctions())) {
if ($implemented.Key -notlike '_xlfn.*') {
$parserManager.AddOrReplaceFunction("_xlfn.$($implemented.Key)", $implemented.Value)
}
}
Comment thread
Mike-Crowley marked this conversation as resolved.
}
5 changes: 4 additions & 1 deletion Public/Close-ExcelPackage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ function Close-ExcelPackage {
if ( $NoSave) { $ExcelPackage.Dispose() }
else {
if ($Calculate) {
try { [OfficeOpenXml.CalculationExtension]::Calculate($ExcelPackage.Workbook) }
try {
Register-XlFnFunction -Workbook $ExcelPackage.Workbook
[OfficeOpenXml.CalculationExtension]::Calculate($ExcelPackage.Workbook)
}
catch { Write-Warning "One or more errors occured while calculating, save will continue, but there may be errors in the workbook." }
}
if ($SaveAs) {
Expand Down
10 changes: 8 additions & 2 deletions Public/Export-Excel.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,10 @@
if ($null -ne $v) { $ws.Cells[$row, $ColumnIndex].Value = $v.toString() }
}
elseif ($v[0] -eq '=') {
$ws.Cells[$row, $ColumnIndex].Formula = ($v -replace '^=', '')
$formulaText = $v -replace '^=', ''
#The IsMatch test repeats the one inside Expand-XlFnFormula: this loop runs once per cell, so formulas which need no rewrite (most) should not pay for a function call.
if ($script:XlFnNameTestRegex.IsMatch($formulaText)) { $formulaText = Expand-XlFnFormula $formulaText }
$ws.Cells[$row, $ColumnIndex].Formula = $formulaText
if ($setNumformat) { $ws.Cells[$row, $ColumnIndex].Style.Numberformat.Format = $Numberformat }
}
elseif ( $NoHyperLinkConversion -ne '*' -and # Put the check for 'NoHyperLinkConversion is null' first to skip checking for wellformedstring
Expand Down Expand Up @@ -590,7 +593,10 @@
}

if ($Calculate) {
try { [OfficeOpenXml.CalculationExtension]::Calculate($ws) }
try {
Register-XlFnFunction -Workbook $ws.Workbook
[OfficeOpenXml.CalculationExtension]::Calculate($ws)
}
catch { Write-Warning "One or more errors occured while calculating, save will continue, but there may be errors in the workbook. $_" }
}

Expand Down
2 changes: 1 addition & 1 deletion Public/Set-ExcelColumn.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
else {Write-Verbose -Message "Script block evaluates to '$cellData'"}
}
else { $cellData = $Value}
if ($cellData -match "^=") { $Worksheet.Cells[$Row, $Column].Formula = ($cellData -replace '^=','') } #EPPlus likes formulas with no = sign; Excel doesn't care
if ($cellData -match "^=") { $Worksheet.Cells[$Row, $Column].Formula = (Expand-XlFnFormula ($cellData -replace '^=','')) } #EPPlus likes formulas with no = sign; Excel doesn't care
elseif ( [System.Uri]::IsWellFormedUriString($cellData , [System.UriKind]::Absolute)) {
# Save a hyperlink : internal links can be in the form xl://sheet!E419 (use A1 as goto sheet), or xl://RangeName
if ($cellData -match "^xl://internal/") {
Expand Down
7 changes: 4 additions & 3 deletions Public/Set-ExcelRange.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,17 @@
$Range.Merge = [boolean]$Merge
}
if ($PSBoundParameters.ContainsKey('Value')) {
if ($Value -match '^=') {$PSBoundParameters["Formula"] = $Value -replace '^=','' }
if ($Value -match '^=') {if (-not $PSBoundParameters.ContainsKey('Formula')) {$Formula = $Value ; $PSBoundParameters["Formula"] = $Value} }
else {
$Range.Value = $Value
if ($Value -is [datetime]) { $Range.Style.Numberformat.Format = 'm/d/yy h:mm' }# This is not a custom format, but a preset recognized as date and localized. It might be overwritten in a moment
if ($Value -is [timespan]) { $Range.Style.Numberformat.Format = '[h]:mm:ss' }
}
}
if ($PSBoundParameters.ContainsKey('Formula')) {
if ($ArrayFormula) {$Range.CreateArrayFormula(($Formula -replace '^=','')) }
else {$Range.Formula = ($Formula -replace '^=','') }
$expandedFormula = Expand-XlFnFormula ($Formula -replace '^=','')
if ($ArrayFormula) {$Range.CreateArrayFormula($expandedFormula) }
else {$Range.Formula = $expandedFormula }
}
if ($PSBoundParameters.ContainsKey('NumberFormat')) {
$Range.Style.Numberformat.Format = (Expand-NumberFormat $NumberFormat)
Expand Down
2 changes: 1 addition & 1 deletion Public/Set-ExcelRow.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
else {Write-Verbose -Message "Script block evaluates to '$cellData'"}
}
else{$cellData = $Value}
if ($cellData -match "^=") { $Worksheet.Cells[$Row, $column].Formula = ($cellData -replace '^=','') } #EPPlus likes formulas with no = sign; Excel doesn't care
if ($cellData -match "^=") { $Worksheet.Cells[$Row, $column].Formula = (Expand-XlFnFormula ($cellData -replace '^=','')) } #EPPlus likes formulas with no = sign; Excel doesn't care
elseif ( [System.Uri]::IsWellFormedUriString($cellData , [System.UriKind]::Absolute)) {
# Save a hyperlink : internal links can be in the form xl://sheet!E419 (use A1 as goto sheet), or xl://RangeName
if ($cellData -match "^xl://internal/") {
Expand Down
Loading
Loading