Initial commit of the ClosedXML library

Establishes the foundational structure, core Excel functionality, and tooling.

- Implements base components for cells, ranges, columns, and rows.
- Integrates a custom calculation engine with a wide range of Excel functions.
- Adds comprehensive support for styling, conditional formatting, data validation, comments, pictures, and charts.
- Configures build setup, project metadata (NuGet), and developer guidelines.
- Includes editor and Git attributes for consistent code style and line endings.
This commit is contained in:
Thom Lamb
2026-06-23 11:02:53 -05:00
parent 28bc05cf54
commit 2cd6df6481
1092 changed files with 97270 additions and 408 deletions
+384
View File
@@ -0,0 +1,384 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
namespace ClosedXML.Excel
{
/// <summary>
/// Common methods
/// </summary>
public static class XLHelper
{
public const int MinRowNumber = 1;
public const int MinColumnNumber = 1;
public const int MaxRowNumber = 1048576;
public const int MaxColumnNumber = 16384;
public const String MaxColumnLetter = "XFD";
public const Double Epsilon = 1e-10;
public static String LastCell { get { return $"{MaxColumnLetter}{MaxRowNumber}"; } }
private static readonly Lazy<Graphics> graphics = new Lazy<Graphics>(() => Graphics.FromImage(new Bitmap(200, 200)));
internal static Graphics Graphics { get => graphics.Value; }
internal static Double DpiX { get => Graphics.DpiX; }
internal static readonly NumberStyles NumberStyle = NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign | NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite | NumberStyles.AllowExponent;
internal static readonly CultureInfo ParseCulture = CultureInfo.InvariantCulture;
internal static readonly Regex RCSimpleRegex = new Regex(
@"^(r(((-\d)?\d*)|\[(-\d)?\d*\]))?(c(((-\d)?\d*)|\[(-\d)?\d*\]))?$"
, RegexOptions.IgnoreCase | RegexOptions.Compiled);
internal static readonly Regex A1SimpleRegex = new Regex(
@"\A"
+ @"(?<Reference>" // Start Group to pick
+ @"(?<Sheet>" // Start Sheet Name, optional
+ @"("
+ @"\'([^\[\]\*/\\\?:\']+|\'\')\'"
// Sheet name with special characters, surrounding apostrophes are required
+ @"|"
+ @"\'?\w+\'?" // Sheet name with letters and numbers, surrounding apostrophes are optional
+ @")"
+ @"!)?" // End Sheet Name, optional
+ @"(?<Range>" // Start range
+ @"\$?[a-zA-Z]{1,3}\$?\d{1,7}" // A1 Address 1
+ @"(?<RangeEnd>:\$?[a-zA-Z]{1,3}\$?\d{1,7})?" // A1 Address 2, optional
+ @"|"
+ @"(?<ColumnNumbers>\$?\d{1,7}:\$?\d{1,7})" // 1:1
+ @"|"
+ @"(?<ColumnLetters>\$?[a-zA-Z]{1,3}:\$?[a-zA-Z]{1,3})" // A:A
+ @")" // End Range
+ @")" // End Group to pick
+ @"\Z"
, RegexOptions.Compiled);
internal static readonly Regex NamedRangeReferenceRegex =
new Regex(@"^('?(?<Sheet>[^'!]+)'?!(?<Range>.+))|((?<Table>[^\[]+)\[(?<Column>[^\]]+)\])$",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture
);
private static readonly string[] letters = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z" };
private static readonly string[] allLetters;
private static readonly Dictionary<string, int> letterIndexes;
static XLHelper()
{
allLetters = new string[XLHelper.MaxColumnNumber];
letterIndexes = new Dictionary<string, int>(XLHelper.MaxColumnNumber, StringComparer.Create(ParseCulture, true));
for (int i = 0; i < XLHelper.MaxColumnNumber; i++)
{
string letter;
if (i < 26)
letter = letters[i];
else if (i < 26 * 27)
letter = letters[i / 26 - 1] + letters[i % 26];
else
letter = letters[(i - 26) / 26 / 26 - 1] + letters[(i / 26 - 1) % 26] + letters[i % 26];
allLetters[i] = letter;
letterIndexes.Add(letter, i + 1);
}
}
/// <summary>
/// Gets the column number of a given column letter.
/// </summary>
/// <param name="columnLetter"> The column letter to translate into a column number. </param>
public static int GetColumnNumberFromLetter(string columnLetter)
{
if (string.IsNullOrEmpty(columnLetter)) throw new ArgumentNullException("columnLetter");
//Extra check because we allow users to pass row col positions in as strings
if (columnLetter[0] <= '9')
{
return Int32.Parse(columnLetter, XLHelper.NumberStyle, XLHelper.ParseCulture);
}
if (letterIndexes.TryGetValue(columnLetter, out var retVal))
return retVal;
throw new ArgumentOutOfRangeException(columnLetter + " is not recognized as a column letter");
}
/// <summary>
/// Gets the column letter of a given column number.
/// </summary>
/// <param name="columnNumber">The column number to translate into a column letter.</param>
/// <param name="trimToAllowed">if set to <c>true</c> the column letter will be restricted to the allowed range.</param>
/// <returns></returns>
public static string GetColumnLetterFromNumber(int columnNumber, bool trimToAllowed = false)
{
if (trimToAllowed) columnNumber = TrimColumnNumber(columnNumber);
if (columnNumber <= 0 || columnNumber > allLetters.Length)
throw new ArgumentOutOfRangeException(nameof(columnNumber));
// Adjust for start on column 1
return allLetters[columnNumber - 1];
}
internal static int TrimColumnNumber(int columnNumber)
{
return Math.Max(XLHelper.MinColumnNumber, Math.Min(XLHelper.MaxColumnNumber, columnNumber));
}
internal static int TrimRowNumber(int rowNumber)
{
return Math.Max(XLHelper.MinRowNumber, Math.Min(XLHelper.MaxRowNumber, rowNumber));
}
public static bool IsValidColumn(string column)
{
if (String.IsNullOrWhiteSpace(column))
return false;
var length = column.Length;
if (length > 3)
return false;
var theColumn = column.ToUpper();
var isValid = theColumn[0] >= 'A' && theColumn[0] <= 'Z';
if (length == 1)
return isValid;
if (length == 2)
return isValid && theColumn[1] >= 'A' && theColumn[1] <= 'Z';
if (theColumn[0] >= 'A' && theColumn[0] < 'X')
return theColumn[1] >= 'A' && theColumn[1] <= 'Z'
&& theColumn[2] >= 'A' && theColumn[2] <= 'Z';
if (theColumn[0] != 'X') return false;
if (theColumn[1] < 'F')
return theColumn[2] >= 'A' && theColumn[2] <= 'Z';
if (theColumn[1] != 'F') return false;
return theColumn[2] >= 'A' && theColumn[2] <= 'D';
}
public static bool IsValidRow(string rowString)
{
if (Int32.TryParse(rowString, out int row))
return row > 0 && row <= MaxRowNumber;
return false;
}
public static bool IsValidA1Address(string address)
{
if (String.IsNullOrWhiteSpace(address))
return false;
address = address.Replace("$", "");
var rowPos = 0;
var addressLength = address.Length;
while (rowPos < addressLength && (address[rowPos] > '9' || address[rowPos] < '0'))
rowPos++;
return
rowPos < addressLength
&& IsValidRow(address.Substring(rowPos))
&& IsValidColumn(address.Substring(0, rowPos));
}
public static bool IsValidRCAddress(string address)
{
if (String.IsNullOrWhiteSpace(address))
return false;
return RCSimpleRegex.IsMatch(address);
}
public static Boolean IsValidRangeAddress(String rangeAddress)
{
return A1SimpleRegex.IsMatch(rangeAddress);
}
public static Boolean IsValidRangeAddress(IXLRangeAddress rangeAddress)
{
return rangeAddress.IsValid
&& rangeAddress.FirstAddress.RowNumber >= 1 && rangeAddress.LastAddress.RowNumber <= MaxRowNumber
&& rangeAddress.FirstAddress.ColumnNumber >= 1 && rangeAddress.LastAddress.ColumnNumber <= MaxColumnNumber
&& rangeAddress.FirstAddress.RowNumber <= rangeAddress.LastAddress.RowNumber
&& rangeAddress.FirstAddress.ColumnNumber <= rangeAddress.LastAddress.ColumnNumber;
}
public static int GetColumnNumberFromAddress(string cellAddressString)
{
var rowPos = 0;
while (cellAddressString[rowPos] > '9')
rowPos++;
return GetColumnNumberFromLetter(cellAddressString.Substring(0, rowPos));
}
internal static string[] SplitRange(string range)
{
return range.Contains('-') ? range.Replace('-', ':').Split(':') : range.Split(':');
}
public static Int32 GetPtFromPx(Double px)
{
return Convert.ToInt32(px * 72.0 / DpiX);
}
public static Double GetPxFromPt(Int32 pt)
{
return Convert.ToDouble(pt) * DpiX / 72.0;
}
internal static IXLTableRows InsertRowsWithoutEvents(Func<int, bool, IXLRangeRows> insertFunc,
XLTableRange tableRange, Int32 numberOfRows,
Boolean expandTable)
{
var ws = tableRange.Worksheet;
var tracking = ws.EventTrackingEnabled;
ws.EventTrackingEnabled = false;
var rows = new XLTableRows(ws.Style);
var inserted = insertFunc(numberOfRows, false);
inserted.ForEach(r => rows.Add(new XLTableRow(tableRange, r as XLRangeRow)));
if (expandTable)
tableRange.Table.ExpandTableRows(numberOfRows);
ws.EventTrackingEnabled = tracking;
return rows;
}
#if false
// Not using this anymore, but keeping it around for in case we bring back .NET3.5 support.
public static bool IsNullOrWhiteSpace(string value)
{
#if _NET35_
if (value == null) return true;
return value.All(c => char.IsWhiteSpace(c));
#else
return String.IsNullOrWhiteSpace(value);
#endif
}
#endif
private static readonly Regex A1RegexRelative = new Regex(
@"(?<=\W)(?<one>\$?[a-zA-Z]{1,3}\$?\d{1,7})(?=\W)" // A1
+ @"|(?<=\W)(?<two>\$?\d{1,7}:\$?\d{1,7})(?=\W)" // 1:1
+ @"|(?<=\W)(?<three>\$?[a-zA-Z]{1,3}:\$?[a-zA-Z]{1,3})(?=\W)", RegexOptions.Compiled); // A:A
private static string Evaluator(Match match, Int32 row, String column)
{
if (match.Groups["one"].Success)
{
var split = match.Groups["one"].Value.Split('$');
if (split.Length == 1) return column + row; // A1
if (split.Length == 3) return match.Groups["one"].Value; // $A$1
var a = XLAddress.Create(match.Groups["one"].Value);
if (split[0].Length == 0) return "$" + a.ColumnLetter + row; // $A1
return column + "$" + a.RowNumber;
}
if (match.Groups["two"].Success)
return ReplaceGroup(match.Groups["two"].Value, row.ToString());
return ReplaceGroup(match.Groups["three"].Value, column);
}
private static String ReplaceGroup(String value, String item)
{
var split = value.Split(':');
String ret1 = split[0].StartsWith("$") ? split[0] : item;
String ret2 = split[1].StartsWith("$") ? split[1] : item;
return ret1 + ":" + ret2;
}
internal static String ReplaceRelative(String value, Int32 row, String column)
{
var oldValue = ">" + value + "<";
var newVal = A1RegexRelative.Replace(oldValue, m => Evaluator(m, row, column));
return newVal.Substring(1, newVal.Length - 2);
}
public static Boolean AreEqual(Double d1, Double d2)
{
return Math.Abs(d1 - d2) < Epsilon;
}
public static DateTime GetDate(Object v)
{
// handle dates
if (v is DateTime)
{
return (DateTime)v;
}
// handle doubles
if (v is double && ((double)v).IsValidOADateNumber())
{
return DateTime.FromOADate((double)v);
}
// handle everything else
return (DateTime)Convert.ChangeType(v, typeof(DateTime));
}
internal static bool IsValidOADateNumber(this double d)
{
return -657435 <= d && d < 2958466;
}
/// <summary>
/// A backward compatible version of <see cref="TimeSpan.FromDays(double)"/> that returns a value
/// rounded to milliseconds. In .Net Core 3.0 the behavior has changed and timespan includes microseconds
/// as well. As a result, the value 1:12:30 saved on one machine could become 1:12:29.999999 on another.
/// </summary>
internal static TimeSpan GetTimeSpan(double totalDays)
{
var timeSpan = TimeSpan.FromDays(totalDays);
var roundedMilliseconds = Math.Round(timeSpan.TotalMilliseconds);
return TimeSpan.FromMilliseconds(roundedMilliseconds);
}
public static Boolean ValidateName(String objectType, String newName, String oldName, IEnumerable<String> existingNames, out String message)
{
message = "";
if (String.IsNullOrWhiteSpace(newName))
{
message = $"The {objectType} name '{newName}' is invalid";
return false;
}
// Table names are case insensitive
if (!oldName.Equals(newName, StringComparison.OrdinalIgnoreCase)
&& existingNames.Contains(newName, StringComparer.OrdinalIgnoreCase))
{
message = $"There is already a {objectType} named '{newName}'";
return false;
}
var allowedFirstCharacters = new[] { '_', '\\' };
if (!allowedFirstCharacters.Contains(newName[0]) && !char.IsLetter(newName[0]))
{
message = $"The {objectType} name '{newName}' does not begin with a letter, an underscore or a backslash.";
return false;
}
if (newName.Length > 255)
{
message = $"The {objectType} name is more than 255 characters";
return false;
}
if (new[] { 'C', 'R' }.Any(c => newName.ToUpper().Equals(c.ToString())))
{
message = $"The {objectType} name '{newName}' is invalid";
return false;
}
return true;
}
}
}