1292 lines
42 KiB
C#
1292 lines
42 KiB
C#
using System.Runtime.InteropServices;
|
||
|
||
namespace QWERTYkez.ExcelProcessor;
|
||
|
||
/// <summary>
|
||
/// Предоставляет потокобезопасный процессор для чтения и записи документов Excel (xlsx / xlsm) формата.
|
||
/// <para>Наследует от <see cref="ExcelReader"/> и добавляет операции изменения документа.</para>
|
||
/// </summary>
|
||
internal sealed class ExcelWriter : ExcelReader, IExcelReader, IExcelWriter
|
||
{
|
||
// Работа с общей таблицей строк
|
||
private SharedStringTablePart? _sharedStringPart;
|
||
private SharedStringTable? _sharedStringTable;
|
||
|
||
internal static Dictionary<int, double>? _calibrationTable; // cw -> width_pts
|
||
|
||
internal bool TryGetCalibrateCoeff(double targetPoints, out double result)
|
||
{
|
||
result = 0;
|
||
try
|
||
{
|
||
// Используем Interop для точной калибровки
|
||
_calibrationTable ??= CalibrateWidthCoeffUsingInterop();
|
||
}
|
||
catch { }
|
||
|
||
_calibrationTable ??= [];
|
||
if (_calibrationTable.Count < 2)
|
||
return false;
|
||
|
||
// Получаем отсортированные по ключу пары
|
||
var keys = _calibrationTable.Keys.OrderBy(k => k).ToList();
|
||
int lastIndex = keys.Count - 1;
|
||
if (targetPoints <= _calibrationTable[keys[0]])
|
||
{
|
||
result = keys[0];
|
||
return true;
|
||
}
|
||
if (targetPoints >= _calibrationTable[keys[lastIndex]])
|
||
{
|
||
result = keys[lastIndex];
|
||
return true;
|
||
}
|
||
|
||
// Бинарный поиск интервала
|
||
int left = 0, right = keys.Count - 1;
|
||
while (right - left > 1)
|
||
{
|
||
int mid = (left + right) / 2;
|
||
if (_calibrationTable[keys[mid]] < targetPoints)
|
||
left = mid;
|
||
else right = mid;
|
||
}
|
||
|
||
int cwLow = keys[left];
|
||
int cwHigh = keys[right];
|
||
double widthLow = _calibrationTable[cwLow];
|
||
double widthHigh = _calibrationTable[cwHigh];
|
||
|
||
// Линейная интерполяция
|
||
result = cwLow + (targetPoints - widthLow) * (cwHigh - cwLow) / (widthHigh - widthLow);
|
||
return true;
|
||
}
|
||
|
||
private Dictionary<int, double> CalibrateWidthCoeffUsingInterop()
|
||
{
|
||
object? excelApp = null;
|
||
object? workbooks = null;
|
||
object? workbook = null;
|
||
object? sheets = null;
|
||
object? worksheet = null;
|
||
object? columns = null;
|
||
object? columnA = null;
|
||
|
||
try
|
||
{
|
||
Type? excelType = Type.GetTypeFromProgID("Excel.Application")
|
||
?? throw new InvalidOperationException("Excel не установлен");
|
||
|
||
excelApp = Activator.CreateInstance(excelType);
|
||
dynamic dynamicApp = excelApp;
|
||
dynamicApp.DisplayAlerts = false;
|
||
dynamicApp.ScreenUpdating = false;
|
||
|
||
workbooks = dynamicApp.Workbooks;
|
||
workbook = ((dynamic)workbooks).Add();
|
||
|
||
// Избегаем скрытых ссылок: сначала получаем коллекцию Sheets, потом элемент
|
||
sheets = ((dynamic)workbook).Sheets;
|
||
worksheet = ((dynamic)sheets)[1];
|
||
|
||
columns = ((dynamic)worksheet).Columns;
|
||
columnA = ((dynamic)columns)[1];
|
||
|
||
var table = new Dictionary<int, double>();
|
||
dynamic dynamicColumnA = columnA;
|
||
|
||
for (int cw = 1; cw <= 100; cw++)
|
||
{
|
||
dynamicColumnA.ColumnWidth = cw;
|
||
double widthPt = (double)dynamicColumnA.Width;
|
||
double widthCm = (double)dynamicApp.PointsToCentimeters(widthPt);
|
||
table.Add(cw, widthCm);
|
||
}
|
||
|
||
return table;
|
||
}
|
||
finally
|
||
{
|
||
// Освобождаем строго в обратном порядке создания
|
||
if (columnA != null) Marshal.ReleaseComObject(columnA);
|
||
if (columns != null) Marshal.ReleaseComObject(columns);
|
||
if (worksheet != null) Marshal.ReleaseComObject(worksheet);
|
||
if (sheets != null) Marshal.ReleaseComObject(sheets);
|
||
|
||
if (workbook != null)
|
||
{
|
||
try { ((dynamic)workbook).Close(false); } catch { }
|
||
Marshal.ReleaseComObject(workbook);
|
||
}
|
||
if (workbooks != null) Marshal.ReleaseComObject(workbooks);
|
||
|
||
if (excelApp != null)
|
||
{
|
||
try { ((dynamic)excelApp).Quit(); } catch { }
|
||
Marshal.ReleaseComObject(excelApp);
|
||
}
|
||
|
||
// Принудительный запуск сборщика мусора для очистки dynamic-оберток
|
||
GC.Collect();
|
||
GC.WaitForPendingFinalizers();
|
||
}
|
||
}
|
||
|
||
|
||
private void EnsureSharedStringTable()
|
||
{
|
||
if (_sharedStringPart != null) return;
|
||
_sharedStringPart = _doc.WorkbookPart?.GetPartsOfType<SharedStringTablePart>().FirstOrDefault();
|
||
if (_sharedStringPart == null)
|
||
{
|
||
_sharedStringPart = _doc.WorkbookPart?.AddNewPart<SharedStringTablePart>();
|
||
_sharedStringPart!.SharedStringTable = new SharedStringTable();
|
||
}
|
||
_sharedStringTable = _sharedStringPart.SharedStringTable;
|
||
}
|
||
|
||
internal string GetSharedString(uint index)
|
||
{
|
||
EnsureSharedStringTable();
|
||
if (_sharedStringTable?.Count?.Value is not { } val || index >= val)
|
||
return string.Empty;
|
||
var si = _sharedStringTable.ElementAt((int)index);
|
||
// Обычный текст
|
||
var text = si.GetFirstChild<Text>()?.Text;
|
||
if (text != null) return text;
|
||
// Rich text
|
||
var sb = new StringBuilder();
|
||
foreach (var run in si.Elements<Run>())
|
||
{
|
||
sb.Append(run.GetFirstChild<Text>()?.Text);
|
||
}
|
||
return sb.ToString();
|
||
}
|
||
|
||
internal int GetOrAddSharedString(string value)
|
||
{
|
||
EnsureSharedStringTable();
|
||
if (_sharedStringTable == null) return -1;
|
||
// Поиск существующей строки
|
||
int idx = 0;
|
||
foreach (var si in _sharedStringTable.Elements<SharedStringItem>())
|
||
{
|
||
var text = si.GetFirstChild<Text>()?.Text;
|
||
if (text == value) return idx;
|
||
idx++;
|
||
}
|
||
// Добавление новой
|
||
var newSi = new SharedStringItem();
|
||
newSi.Append(new Text(value));
|
||
_sharedStringTable.Append(newSi);
|
||
_sharedStringTable.Count = (uint)_sharedStringTable.Elements<SharedStringItem>().Count();
|
||
return idx;
|
||
}
|
||
|
||
// Определение заблокирована ли ячейка
|
||
internal bool IsCellLocked(uint styleIndex)
|
||
{
|
||
var cellFormat = GetCellFormatAt(styleIndex);
|
||
if (cellFormat == null) return false;
|
||
// По умолчанию ячейка заблокирована, если Protection не задан или Locked = true
|
||
return cellFormat.Protection == null || cellFormat.Protection.Locked == null || cellFormat.Protection.Locked.Value;
|
||
}
|
||
|
||
// Кэши числовых форматов
|
||
private readonly Dictionary<string, NumberFormatPattern> _numberFormatCache = [];
|
||
private readonly Dictionary<uint, NumberFormatPattern> _numberFormatIdToPattern = [];
|
||
|
||
// Кэши для компонентов стилей (чтобы не создавать дубликаты)
|
||
private readonly Dictionary<CellFont, int> _fontCache = [];
|
||
private readonly Dictionary<CellFill, int> _fillCache = [];
|
||
private readonly Dictionary<CellBorder, int> _borderCache = [];
|
||
private readonly Dictionary<CellAlign, int> _alignmentCache = [];
|
||
|
||
// Кэш составных стилей (CellFormat)
|
||
private readonly Dictionary<(int fontId, int fillId, int borderId, int alignId, int numFmtId), int> _cellFormatCache = [];
|
||
|
||
// Конструктор, фабричные методы – без изменений (опущены)
|
||
|
||
#region Управление числовыми форматами (расширение IBook)
|
||
|
||
public IReadOnlyList<NumberFormatPattern> GetNumberFormats()
|
||
{
|
||
// Возвращаем все пользовательские форматы (Id >= 164), встроенные не включаем
|
||
var result = new List<NumberFormatPattern>();
|
||
var stylesheet = _doc.WorkbookPart?.WorkbookStylesPart?.Stylesheet;
|
||
if (stylesheet?.NumberingFormats != null)
|
||
{
|
||
foreach (var nf in stylesheet.NumberingFormats.Elements<NumberingFormat>())
|
||
{
|
||
if (nf.NumberFormatId?.Value >= 164 && nf.FormatCode?.Value is string code)
|
||
{
|
||
var pattern = new NumberFormatPattern(code);
|
||
pattern.Attach((ushort)nf.NumberFormatId.Value);
|
||
result.Add(pattern);
|
||
}
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
public NumberFormatPattern CreateNumberFormat(string formatCode)
|
||
{
|
||
if (string.IsNullOrEmpty(formatCode)) throw new ArgumentException("Format code cannot be empty", nameof(formatCode));
|
||
|
||
lock (_syncLock)
|
||
{
|
||
return CreateNumberFormatInternal(formatCode);
|
||
}
|
||
}
|
||
|
||
// Внутренний метод получения NumberFormatPattern по индексу стиля ячейки
|
||
internal NumberFormatPattern? GetNumberFormat(uint cellStyleIndex)
|
||
{
|
||
var cellFormat = GetCellFormatAt(cellStyleIndex);
|
||
if (cellFormat?.NumberFormatId?.Value is uint numFmtId && cellFormat.ApplyNumberFormat?.Value == true)
|
||
{
|
||
if (_numberFormatIdToPattern.TryGetValue(numFmtId, out var pattern))
|
||
return pattern;
|
||
// Пытаемся найти в стилях книги
|
||
var stylesheet = _doc.WorkbookPart?.WorkbookStylesPart?.Stylesheet;
|
||
if (stylesheet?.NumberingFormats != null)
|
||
{
|
||
foreach (var nf in stylesheet.NumberingFormats.Elements<NumberingFormat>())
|
||
{
|
||
if (nf.NumberFormatId?.Value == numFmtId && nf.FormatCode?.Value is string code)
|
||
{
|
||
pattern = new NumberFormatPattern(code);
|
||
pattern.Attach((ushort)numFmtId);
|
||
_numberFormatIdToPattern[numFmtId] = pattern;
|
||
return pattern;
|
||
}
|
||
}
|
||
}
|
||
// Встроенный формат
|
||
if (numFmtId < 164)
|
||
{
|
||
// Для встроенных форматов код формата определяем по специальной таблице (не хардкодим!).
|
||
// Вместо этого возвращаем формат только с Id, без FormatCode.
|
||
pattern = new NumberFormatPattern(string.Empty);
|
||
pattern.Attach((ushort)numFmtId);
|
||
_numberFormatIdToPattern[numFmtId] = pattern;
|
||
return pattern;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Управление составными стилями (CellFormat)
|
||
|
||
// Получить индекс CellFormat для комбинации компонентов
|
||
internal int GetOrCreateCellFormatId(
|
||
NumberFormatPattern? numberFormat = null,
|
||
CellFont? font = null,
|
||
CellFill? fill = null,
|
||
CellBorder? border = null,
|
||
CellAlign? align = null)
|
||
{
|
||
lock (_syncLock)
|
||
{
|
||
// Получаем или создаём числовой формат ID
|
||
int numFmtId = -1;
|
||
if (numberFormat != null)
|
||
{
|
||
if (numberFormat.Id.HasValue && numberFormat.Id.Value < 164)
|
||
numFmtId = numberFormat.Id.Value;
|
||
else
|
||
numFmtId = (int)GetOrCreateNumberFormatId(numberFormat);
|
||
}
|
||
|
||
// Получаем или создаём Font, Fill, Border, Alignment
|
||
int fontId = font.HasValue ? GetOrCreateFontId(font.Value) : -1;
|
||
int fillId = fill.HasValue ? GetOrCreateFillId(fill.Value) : -1;
|
||
int borderId = border.HasValue ? GetOrCreateBorderId(border.Value) : -1;
|
||
int alignId = align.HasValue ? GetOrCreateAlignmentId(align.Value) : -1;
|
||
|
||
var key = (fontId, fillId, borderId, alignId, numFmtId);
|
||
if (_cellFormatCache.TryGetValue(key, out int existingIndex))
|
||
return existingIndex;
|
||
|
||
var stylesheet = EnsureStylesheet();
|
||
var cellFormats = stylesheet.CellFormats ?? CreateCellFormats(stylesheet);
|
||
|
||
var cellFormat = new CellFormat();
|
||
if (fontId >= 0)
|
||
{
|
||
cellFormat.FontId = (uint)fontId;
|
||
cellFormat.ApplyFont = true;
|
||
}
|
||
if (fillId >= 0)
|
||
{
|
||
cellFormat.FillId = (uint)fillId;
|
||
cellFormat.ApplyFill = true;
|
||
}
|
||
if (borderId >= 0)
|
||
{
|
||
cellFormat.BorderId = (uint)borderId;
|
||
cellFormat.ApplyBorder = true;
|
||
}
|
||
if (alignId >= 0)
|
||
{
|
||
cellFormat.Alignment = GetAlignmentFromCache(alignId);
|
||
cellFormat.ApplyAlignment = true;
|
||
}
|
||
if (numFmtId >= 0)
|
||
{
|
||
cellFormat.NumberFormatId = (uint)numFmtId;
|
||
cellFormat.ApplyNumberFormat = true;
|
||
}
|
||
|
||
cellFormats.Append(cellFormat);
|
||
int newIndex = (int)(cellFormats.Count?.Value ?? 0);
|
||
cellFormats.Count = (uint)(newIndex + 1);
|
||
_cellFormatCache[key] = newIndex;
|
||
return newIndex;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Вспомогательные методы для работы со стилями
|
||
|
||
private Stylesheet EnsureStylesheet()
|
||
{
|
||
var workbookPart = _doc.WorkbookPart ?? throw new InvalidOperationException("No WorkbookPart");
|
||
var stylesPart = workbookPart.WorkbookStylesPart;
|
||
if (stylesPart == null)
|
||
{
|
||
stylesPart = workbookPart.AddNewPart<WorkbookStylesPart>();
|
||
stylesPart.Stylesheet = new Stylesheet();
|
||
}
|
||
var stylesheet = stylesPart.Stylesheet!;
|
||
if (stylesheet.Fonts == null)
|
||
{
|
||
stylesheet.Fonts = new Fonts();
|
||
stylesheet.Fonts.Append(new Font()); // минимум один шрифт
|
||
stylesheet.Fonts.Count = 1;
|
||
}
|
||
if (stylesheet.Fills == null)
|
||
{
|
||
stylesheet.Fills = new Fills();
|
||
stylesheet.Fills.Append(new Fill { PatternFill = new PatternFill { PatternType = PatternValues.None } });
|
||
stylesheet.Fills.Append(new Fill { PatternFill = new PatternFill { PatternType = PatternValues.Gray125 } });
|
||
stylesheet.Fills.Count = 2;
|
||
}
|
||
if (stylesheet.Borders == null)
|
||
{
|
||
stylesheet.Borders = new Borders();
|
||
stylesheet.Borders.Append(new Border()); // пустая граница
|
||
stylesheet.Borders.Count = 1;
|
||
}
|
||
if (stylesheet.CellFormats == null)
|
||
{
|
||
stylesheet.CellFormats = new CellFormats();
|
||
stylesheet.CellFormats.Append(new CellFormat()); // стандартный стиль
|
||
stylesheet.CellFormats.Count = 1;
|
||
}
|
||
return stylesheet;
|
||
}
|
||
|
||
private uint GetOrCreateNumberFormatId(NumberFormatPattern pattern)
|
||
{
|
||
if (pattern.Id.HasValue)
|
||
return (uint)pattern.Id.Value;
|
||
|
||
// Создаём через внутренний метод (без дополнительного lock)
|
||
var created = CreateNumberFormatInternal(pattern.Format);
|
||
return (uint)created.Id!.Value;
|
||
}
|
||
|
||
private int GetOrCreateFontId(CellFont font)
|
||
{
|
||
if (_fontCache.TryGetValue(font, out int id))
|
||
return id;
|
||
|
||
var stylesheet = EnsureStylesheet();
|
||
var fonts = stylesheet.Fonts!;
|
||
var excelFont = font.ToFont() ?? new Font();
|
||
fonts.Append(excelFont);
|
||
uint newId = fonts.Count!.Value;
|
||
fonts.Count = newId + 1u;
|
||
return _fontCache[font] = (int)newId;
|
||
}
|
||
|
||
private int GetOrCreateFillId(CellFill fill)
|
||
{
|
||
if (_fillCache.TryGetValue(fill, out int id))
|
||
return id;
|
||
|
||
var stylesheet = EnsureStylesheet();
|
||
var fills = stylesheet.Fills!;
|
||
var excelFill = fill.ToFill() ?? new Fill { PatternFill = new PatternFill { PatternType = PatternValues.None } };
|
||
fills.Append(excelFill);
|
||
uint newId = fills.Count!.Value;
|
||
fills.Count = newId + 1;
|
||
return _fillCache[fill] = (int)newId;
|
||
}
|
||
|
||
private int GetOrCreateBorderId(CellBorder border)
|
||
{
|
||
if (_borderCache.TryGetValue(border, out int id))
|
||
return id;
|
||
|
||
var stylesheet = EnsureStylesheet();
|
||
var borders = stylesheet.Borders!;
|
||
var excelBorder = border.ToBorder() ?? new Border();
|
||
borders.Append(excelBorder);
|
||
int newId = (int)borders.Count!.Value;
|
||
borders.Count = (uint)(newId + 1);
|
||
_borderCache[border] = newId;
|
||
return newId;
|
||
}
|
||
|
||
private int GetOrCreateAlignmentId(CellAlign align)
|
||
{
|
||
if (_alignmentCache.TryGetValue(align, out int id))
|
||
return id;
|
||
|
||
// Выравнивание не хранится отдельно, а встраивается в CellFormat.
|
||
// Поэтому мы не создаём отдельный элемент, а возвращаем уникальный ID для кэша стилей.
|
||
// Но для целостности кэша сохраняем ID.
|
||
id = _alignmentCache.Count;
|
||
_alignmentCache[align] = id;
|
||
return id;
|
||
}
|
||
|
||
private Alignment GetAlignmentFromCache(int alignId)
|
||
{
|
||
foreach (var pair in _alignmentCache)
|
||
if (pair.Value == alignId)
|
||
return pair.Key.ToAlignment() ?? new Alignment();
|
||
return new Alignment();
|
||
}
|
||
|
||
private CellFormat? GetCellFormatAt(uint index)
|
||
{
|
||
var stylesheet = EnsureStylesheet();
|
||
if (stylesheet.CellFormats == null || index >= stylesheet.CellFormats.Count!.Value)
|
||
return null;
|
||
return (CellFormat)stylesheet.CellFormats.ElementAt((int)index);
|
||
}
|
||
|
||
// Вспомогательные создания коллекций
|
||
private static NumberingFormats CreateNumberingFormats(Stylesheet stylesheet)
|
||
{
|
||
var nfs = new NumberingFormats();
|
||
stylesheet.NumberingFormats = nfs;
|
||
return nfs;
|
||
}
|
||
|
||
private static CellFormats CreateCellFormats(Stylesheet stylesheet)
|
||
{
|
||
var cfs = new CellFormats();
|
||
stylesheet.CellFormats = cfs;
|
||
return cfs;
|
||
}
|
||
|
||
#endregion
|
||
|
||
|
||
|
||
private bool _isModified = false;
|
||
|
||
internal ExcelWriter() { }
|
||
|
||
#region Factory Methods
|
||
|
||
internal static ExcelWriter? CreateFromData(ReadOnlyMemory<byte> data, string destinationPath)
|
||
{
|
||
if (data.IsEmpty || string.IsNullOrEmpty(destinationPath))
|
||
return null;
|
||
|
||
var ms = new MemoryStream();
|
||
try
|
||
{
|
||
// Копируем данные в MemoryStream
|
||
ms.Write(data.ToArray(), 0, data.Length);
|
||
ms.Position = 0;
|
||
|
||
var doc = SpreadsheetDocument.Open(ms, true, new OpenSettings { AutoSave = false });
|
||
|
||
if (doc is not null)
|
||
{
|
||
return new ExcelWriter
|
||
{
|
||
_ms = ms,
|
||
_doc = doc,
|
||
FilePath = destinationPath,
|
||
_originalSourcePath = null // нет исходного файла
|
||
};
|
||
}
|
||
doc?.Dispose();
|
||
}
|
||
catch { }
|
||
|
||
ms?.Dispose();
|
||
return null;
|
||
}
|
||
|
||
internal static new ExcelWriter? CreateFromData(ReadOnlyMemory<byte> data)
|
||
{
|
||
if (data.IsEmpty)
|
||
return null;
|
||
|
||
var ms = new MemoryStream();
|
||
try
|
||
{
|
||
ms.Write(data.ToArray(), 0, data.Length);
|
||
ms.Position = 0;
|
||
|
||
var doc = SpreadsheetDocument.Open(ms, true, new OpenSettings { AutoSave = false });
|
||
|
||
if (doc is not null)
|
||
{
|
||
return new ExcelWriter
|
||
{
|
||
_ms = ms,
|
||
_doc = doc,
|
||
FilePath = null // из памяти – нет файла
|
||
};
|
||
}
|
||
doc?.Dispose();
|
||
}
|
||
catch { }
|
||
|
||
ms?.Dispose();
|
||
return null;
|
||
}
|
||
|
||
internal static ExcelWriter? CreateInternal(FileInfo sourceFile, string? destinationPath = null!)
|
||
{
|
||
if (sourceFile is null || !sourceFile.Exists) return null;
|
||
|
||
var ms = new MemoryStream();
|
||
try
|
||
{
|
||
using (var file = new FileStream(sourceFile.FullName,
|
||
FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||
{
|
||
file.CopyTo(ms);
|
||
}
|
||
ms.Position = 0;
|
||
|
||
var doc = SpreadsheetDocument.Open(ms, isEditable: true,
|
||
new OpenSettings { AutoSave = false });
|
||
|
||
if (doc is not null)
|
||
{
|
||
return new ExcelWriter
|
||
{
|
||
_ms = ms,
|
||
_doc = doc,
|
||
_originalSourcePath = sourceFile.FullName,
|
||
FilePath = destinationPath
|
||
};
|
||
}
|
||
doc?.Dispose();
|
||
}
|
||
catch { }
|
||
|
||
ms?.Dispose();
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Создаёт новый пустой документ Excel с одним листом.
|
||
/// </summary>
|
||
/// <param name="destinationPath">Путь, по которому будет сохранён документ (необязательный).</param>
|
||
/// <returns>Экземпляр <see cref="ExcelWriter"/> для редактирования нового документа.</returns>
|
||
internal static ExcelWriter CreateNew(string? destinationPath = null)
|
||
{
|
||
var ms = new MemoryStream();
|
||
try
|
||
{
|
||
var doc = SpreadsheetDocument.Create(ms, SpreadsheetDocumentType.Workbook);
|
||
var workbookPart = doc.AddWorkbookPart();
|
||
workbookPart.Workbook = new Workbook();
|
||
|
||
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
||
worksheetPart.Worksheet = new Worksheet(new SheetData());
|
||
|
||
var sheets = workbookPart.Workbook.AppendChild(new Sheets());
|
||
//sheets.AppendChild(new Sheet()
|
||
//{
|
||
// Id = workbookPart.GetIdOfPart(worksheetPart),
|
||
// SheetId = 1,
|
||
// Name = "Лист1"
|
||
//});
|
||
|
||
return new ExcelWriter
|
||
{
|
||
_ms = ms,
|
||
_doc = doc,
|
||
FilePath = destinationPath,
|
||
_originalSourcePath = null
|
||
};
|
||
}
|
||
catch
|
||
{
|
||
ms.Dispose();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Properties
|
||
|
||
/// <summary>
|
||
/// Получает значение, указывающее, будет ли документ сохранен с перезаписью исходного файла.
|
||
/// </summary>
|
||
public bool WillOverwriteSource => FilePath == _originalSourcePath;
|
||
|
||
#endregion
|
||
|
||
#region Replace
|
||
|
||
// string
|
||
|
||
void IExcelWriter.Replace(string oldValue, string newValue, StringComparison comparisonType)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (string.IsNullOrEmpty(oldValue))
|
||
throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue));
|
||
|
||
if (string.IsNullOrEmpty(newValue)) return;
|
||
|
||
lock (_syncLock)
|
||
{
|
||
_doc.Replace(oldValue, newValue, comparisonType);
|
||
_isModified = true;
|
||
}
|
||
}
|
||
|
||
void IExcelWriter.Replace(IDictionary<string, string> replacements, StringComparison comparisonType) =>
|
||
((IExcelWriter)this).Replace((IEnumerable<KeyValuePair<string, string>>)replacements, comparisonType);
|
||
void IExcelWriter.Replace(IEnumerable<KeyValuePair<string, string>> replacements, StringComparison comparisonType)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (replacements is null) return;
|
||
|
||
lock (_syncLock)
|
||
{
|
||
_doc.Replace(replacements, comparisonType);
|
||
_isModified = true;
|
||
}
|
||
}
|
||
|
||
|
||
// double
|
||
|
||
void IExcelWriter.Replace(string oldValue, double newValue, string? format, StringComparison comparisonType)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (string.IsNullOrEmpty(oldValue))
|
||
throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue));
|
||
|
||
lock (_syncLock)
|
||
{
|
||
_doc.Replace(oldValue, newValue, format);
|
||
_isModified = true;
|
||
}
|
||
}
|
||
|
||
void IExcelWriter.Replace(IDictionary<string, double> replacements, string? format, StringComparison comparisonType) =>
|
||
((IExcelWriter)this).Replace((IEnumerable<KeyValuePair<string, double>>)replacements, format, comparisonType);
|
||
void IExcelWriter.Replace(IEnumerable<KeyValuePair<string, double>> replacements, string? format, StringComparison comparisonType)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (replacements == null) return;
|
||
|
||
lock (_syncLock)
|
||
{
|
||
_doc.Replace(replacements, format, comparisonType);
|
||
_isModified = true;
|
||
}
|
||
}
|
||
|
||
|
||
// float
|
||
|
||
void IExcelWriter.Replace(string oldValue, float newValue, string? format, StringComparison comparisonType)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (string.IsNullOrEmpty(oldValue))
|
||
throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue));
|
||
|
||
lock (_syncLock)
|
||
{
|
||
_doc.Replace(oldValue, newValue, format);
|
||
_isModified = true;
|
||
}
|
||
}
|
||
|
||
void IExcelWriter.Replace(IDictionary<string, float> replacements, string? format, StringComparison comparisonType) =>
|
||
((IExcelWriter)this).Replace((IEnumerable<KeyValuePair<string, float>>)replacements, format, comparisonType);
|
||
void IExcelWriter.Replace(IEnumerable<KeyValuePair<string, float>> replacements, string? format, StringComparison comparisonType)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (replacements == null) return;
|
||
|
||
lock (_syncLock)
|
||
{
|
||
_doc.Replace(replacements, format, comparisonType);
|
||
_isModified = true;
|
||
}
|
||
}
|
||
|
||
|
||
// int
|
||
|
||
void IExcelWriter.Replace(string oldValue, int newValue, StringComparison comparisonType)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (string.IsNullOrEmpty(oldValue))
|
||
throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue));
|
||
|
||
lock (_syncLock)
|
||
{
|
||
_doc.Replace(oldValue, newValue);
|
||
_isModified = true;
|
||
}
|
||
}
|
||
|
||
void IExcelWriter.Replace(IDictionary<string, int> replacements, StringComparison comparisonType) =>
|
||
((IExcelWriter)this).Replace((IEnumerable<KeyValuePair<string, int>>)replacements, comparisonType);
|
||
void IExcelWriter.Replace(IEnumerable<KeyValuePair<string, int>> replacements, StringComparison comparisonType)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (replacements == null) return;
|
||
|
||
lock (_syncLock)
|
||
{
|
||
_doc.Replace(replacements, comparisonType);
|
||
_isModified = true;
|
||
}
|
||
}
|
||
|
||
|
||
// long
|
||
|
||
void IExcelWriter.Replace(string oldValue, long newValue, StringComparison comparisonType)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (string.IsNullOrEmpty(oldValue))
|
||
throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue));
|
||
|
||
lock (_syncLock)
|
||
{
|
||
_doc.Replace(oldValue, newValue);
|
||
_isModified = true;
|
||
}
|
||
}
|
||
|
||
void IExcelWriter.Replace(IDictionary<string, long> replacements, StringComparison comparisonType) =>
|
||
((IExcelWriter)this).Replace((IEnumerable<KeyValuePair<string, long>>)replacements, comparisonType);
|
||
void IExcelWriter.Replace(IEnumerable<KeyValuePair<string, long>> replacements, StringComparison comparisonType)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (replacements == null) return;
|
||
|
||
lock (_syncLock)
|
||
{
|
||
_doc.Replace(replacements, comparisonType);
|
||
_isModified = true;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Save Operations
|
||
|
||
/// <summary>
|
||
/// Сохраняет документ в файл, указанный при создании процессора.
|
||
/// </summary>
|
||
public void Save()
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (string.IsNullOrEmpty(FilePath))
|
||
throw new InvalidOperationException("Cannot save - no file path specified");
|
||
|
||
SaveTo(FilePath!);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Сохраняет документ в указанный файл.
|
||
/// </summary>
|
||
public void SaveTo(string path)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (string.IsNullOrEmpty(path))
|
||
throw new ArgumentException("Path cannot be null or empty", nameof(path));
|
||
|
||
lock (_syncLock)
|
||
{
|
||
try
|
||
{
|
||
if (_ms is not null)
|
||
{
|
||
EnsureFullCalculationOnLoad();
|
||
_doc.Save();
|
||
_ms.Position = 0;
|
||
|
||
using var fileStream = new FileStream(
|
||
path,
|
||
FileMode.Create,
|
||
FileAccess.Write,
|
||
FileShare.None,
|
||
bufferSize: 65536);
|
||
|
||
_ms.CopyTo(fileStream);
|
||
_isModified = false;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
throw new IOException($"Failed to save document to '{path}'", ex);
|
||
}
|
||
}
|
||
}
|
||
internal byte[] GetDocumentBytes()
|
||
{
|
||
lock (_syncLock)
|
||
{
|
||
EnsureFullCalculationOnLoad();
|
||
_doc.Save();
|
||
_ms.Position = 0;
|
||
return _ms.ToArray();
|
||
}
|
||
}
|
||
|
||
public void SaveTo(Stream outputStream)
|
||
{
|
||
lock (_syncLock)
|
||
{
|
||
EnsureFullCalculationOnLoad();
|
||
_doc.Save();
|
||
_ms.Position = 0;
|
||
_ms.CopyTo(outputStream);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Пытается сохранить документ в указанный файл.
|
||
/// </summary>
|
||
public bool TrySaveTo(string path, out Exception? error)
|
||
{
|
||
error = null;
|
||
|
||
if (!IsValid || string.IsNullOrEmpty(path))
|
||
{
|
||
error = new InvalidOperationException("Processor is not valid or path is empty");
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
SaveTo(path);
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
error = ex;
|
||
#if DEBUG
|
||
Debug.WriteLine($"[DEBUG] Failed to save to '{path}': {ex.Message}");
|
||
#endif
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Асинхронно сохраняет документ в указанный файл.
|
||
/// </summary>
|
||
public async Task SaveToAsync(string path, CancellationToken cancellationToken = default)
|
||
{
|
||
ThrowIfDisposed();
|
||
|
||
if (string.IsNullOrEmpty(path))
|
||
throw new ArgumentException("Path cannot be null or empty", nameof(path));
|
||
|
||
if (_ms is null)
|
||
throw new InvalidOperationException("Memory stream is not available");
|
||
|
||
byte[] buffer;
|
||
lock (_syncLock)
|
||
{
|
||
EnsureFullCalculationOnLoad();
|
||
_doc.Save();
|
||
_ms.Position = 0;
|
||
buffer = _ms.ToArray();
|
||
}
|
||
|
||
using var fileStream = new FileStream(
|
||
path,
|
||
FileMode.Create,
|
||
FileAccess.Write,
|
||
FileShare.None,
|
||
bufferSize: 81920,
|
||
useAsync: true);
|
||
|
||
await fileStream.WriteAsync(buffer, 0, buffer.Length, cancellationToken);
|
||
}
|
||
|
||
/// <summary> Создаёт read-only процессор из текущего документа. </summary>
|
||
internal ExcelReader ToReader()
|
||
{
|
||
lock (_syncLock)
|
||
{
|
||
EnsureFullCalculationOnLoad(); // важно для формул
|
||
_doc.Save();
|
||
_ms.Position = 0;
|
||
var data = _ms.ToArray();
|
||
return ExcelReader.CreateFromData(data)
|
||
?? throw new InvalidOperationException("Failed to create reader from the current document");
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Dispose Pattern
|
||
|
||
protected override void Dispose(bool disposing)
|
||
{
|
||
if (_disposed)
|
||
return;
|
||
|
||
lock (_syncLock)
|
||
{
|
||
if (disposing)
|
||
{
|
||
if (_isModified && !string.IsNullOrEmpty(FilePath))
|
||
{
|
||
try
|
||
{
|
||
EnsureFullCalculationOnLoad();
|
||
_doc.Save();
|
||
|
||
Directory.CreateDirectory(Path.GetDirectoryName(FilePath));
|
||
|
||
_ms.Position = 0;
|
||
using var fileStream = new FileStream(
|
||
FilePath!,
|
||
FileMode.Create,
|
||
FileAccess.Write,
|
||
FileShare.None,
|
||
bufferSize: 81920);
|
||
|
||
_ms.CopyTo(fileStream);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
#if DEBUG
|
||
Debug.WriteLine($"[DEBUG] Auto-save failed during Dispose: {ex.Message}");
|
||
#endif
|
||
}
|
||
}
|
||
}
|
||
|
||
base.Dispose(disposing);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
private void EnsureFullCalculationOnLoad()
|
||
{
|
||
if (_doc?.WorkbookPart?.Workbook == null) return;
|
||
var workbook = _doc.WorkbookPart.Workbook;
|
||
var calcProps = workbook.CalculationProperties;
|
||
if (calcProps == null)
|
||
{
|
||
calcProps = new CalculationProperties();
|
||
workbook.CalculationProperties = calcProps;
|
||
}
|
||
calcProps.ForceFullCalculation = true;
|
||
calcProps.FullCalculationOnLoad = true;
|
||
}
|
||
|
||
|
||
#region IBook Implementation
|
||
|
||
/// <inheritdoc />
|
||
public IReadOnlyList<ISheet> GetSheets()
|
||
{
|
||
ThrowIfDisposed();
|
||
lock (_syncLock)
|
||
{
|
||
var sheets = new List<ISheet>();
|
||
var workbookPart = _doc.WorkbookPart;
|
||
if (workbookPart?.Workbook?.Sheets == null)
|
||
return sheets;
|
||
|
||
foreach (Sheet sheetElement in workbookPart.Workbook.Sheets.Elements<Sheet>())
|
||
{
|
||
var sheet = new ExcelSheet(this, sheetElement, sheetElement.SheetId?.Value ?? 0);
|
||
sheets.Add(sheet);
|
||
}
|
||
return sheets;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public ISheet? Sheet(string name)
|
||
{
|
||
ThrowIfDisposed();
|
||
if (string.IsNullOrEmpty(name))
|
||
return null;
|
||
|
||
lock (_syncLock)
|
||
{
|
||
var workbookPart = _doc.WorkbookPart;
|
||
if (workbookPart?.Workbook?.Sheets == null)
|
||
return null;
|
||
|
||
foreach (Sheet sheetElement in workbookPart.Workbook.Sheets.Elements<Sheet>())
|
||
{
|
||
if (sheetElement.Name?.Value == name)
|
||
return new ExcelSheet(this, sheetElement, sheetElement.SheetId?.Value ?? 0);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public bool TryGetSheet(string name, out ISheet sheet)
|
||
{
|
||
sheet = Sheet(name)!;
|
||
return sheet != null;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public bool TryAddSheet(string name, Action<ISheet>? edit)
|
||
{
|
||
ThrowIfDisposed();
|
||
if (string.IsNullOrEmpty(name))
|
||
return false;
|
||
|
||
lock (_syncLock)
|
||
{
|
||
var workbookPart = _doc.WorkbookPart;
|
||
if (workbookPart?.Workbook?.Sheets == null)
|
||
return false;
|
||
|
||
// Проверка уникальности имени
|
||
foreach (Sheet s in workbookPart.Workbook.Sheets.Elements<Sheet>())
|
||
{
|
||
if (s.Name?.Value == name)
|
||
return false;
|
||
}
|
||
|
||
// Создание нового листа
|
||
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
||
worksheetPart.Worksheet = new Worksheet(new SheetData());
|
||
|
||
// Вычисление нового SheetId
|
||
uint maxSheetId = 0;
|
||
foreach (Sheet s in workbookPart.Workbook.Sheets.Elements<Sheet>())
|
||
{
|
||
if (s.SheetId?.Value > maxSheetId)
|
||
maxSheetId = s.SheetId.Value;
|
||
}
|
||
uint newSheetId = maxSheetId + 1;
|
||
|
||
var sheetElement = new Sheet
|
||
{
|
||
Id = workbookPart.GetIdOfPart(worksheetPart),
|
||
SheetId = newSheetId,
|
||
Name = name
|
||
};
|
||
workbookPart.Workbook.Sheets.Append(sheetElement);
|
||
_isModified = true;
|
||
|
||
var newSheet = new ExcelSheet(this, sheetElement, newSheetId);
|
||
edit?.Invoke(newSheet);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public bool TryRemoveSheet(string name)
|
||
{
|
||
var sheet = Sheet(name);
|
||
return sheet != null && TryRemoveSheet(sheet);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public bool TryRemoveSheet(ISheet sheet)
|
||
{
|
||
ThrowIfDisposed();
|
||
if (sheet is not ExcelSheet xSheet || xSheet.Book != this)
|
||
return false;
|
||
|
||
lock (_syncLock)
|
||
{
|
||
var workbookPart = _doc.WorkbookPart;
|
||
if (workbookPart?.Workbook?.Sheets == null)
|
||
return false;
|
||
|
||
var sheetElement = xSheet.SheetElement;
|
||
if (sheetElement?.Parent != workbookPart.Workbook.Sheets)
|
||
return false;
|
||
|
||
// Удаление связанной части
|
||
string partId = sheetElement.Id?.Value!;
|
||
if (!string.IsNullOrEmpty(partId))
|
||
{
|
||
var part = workbookPart.GetPartById(partId);
|
||
if (part != null)
|
||
workbookPart.DeletePart(part);
|
||
}
|
||
|
||
sheetElement.Remove();
|
||
_isModified = true;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region IExcelWriter
|
||
|
||
/// <inheritdoc />
|
||
public void Edit(Action<IBook> edit)
|
||
{
|
||
ThrowIfDisposed();
|
||
if (edit is null)
|
||
throw new ArgumentNullException(nameof(edit));
|
||
|
||
lock (_syncLock)
|
||
{
|
||
edit(this);
|
||
_isModified = true;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
|
||
// Добавить в класс ExcelWriter
|
||
|
||
internal CellAlign GetCellAlign(uint styleIndex)
|
||
{
|
||
var cellFormat = GetCellFormatAt(styleIndex);
|
||
return CellAlign.FromAlignment(cellFormat?.Alignment);
|
||
}
|
||
|
||
internal CellBorder GetCellBorder(uint styleIndex)
|
||
{
|
||
var cellFormat = GetCellFormatAt(styleIndex);
|
||
if (cellFormat?.BorderId?.Value is not uint borderId)
|
||
return default;
|
||
var borderElement = GetBorderById(borderId);
|
||
return CellBorder.FromBorder(borderElement);
|
||
}
|
||
|
||
internal CellFill GetCellFill(uint styleIndex)
|
||
{
|
||
var cellFormat = GetCellFormatAt(styleIndex);
|
||
if (cellFormat?.FillId?.Value is not uint fillId)
|
||
return default;
|
||
var fill = GetFillById(fillId);
|
||
return CellFill.FromFill(fill);
|
||
}
|
||
|
||
internal CellFont GetCellFont(uint styleIndex)
|
||
{
|
||
var cellFormat = GetCellFormatAt(styleIndex);
|
||
if (cellFormat?.FontId?.Value is not uint fontId)
|
||
return default;
|
||
var font = GetFontById(fontId);
|
||
return CellFont.FromFont(font);
|
||
}
|
||
|
||
|
||
// Внутренний метод, предполагает, что _syncLock уже захвачен вызывающим
|
||
private NumberFormatPattern CreateNumberFormatInternal(string formatCode)
|
||
{
|
||
// Проверяем кэш по коду
|
||
if (_numberFormatCache.TryGetValue(formatCode, out var existing))
|
||
return existing;
|
||
|
||
// Получаем или создаём NumberingFormat в стилях
|
||
var stylesheet = EnsureStylesheet();
|
||
var numberingFormats = stylesheet.NumberingFormats ?? CreateNumberingFormats(stylesheet);
|
||
uint newId = 164;
|
||
foreach (var nf in numberingFormats.Elements<NumberingFormat>())
|
||
{
|
||
if (nf.FormatCode?.Value == formatCode)
|
||
{
|
||
var pattern = new NumberFormatPattern(formatCode);
|
||
pattern.Attach((ushort)nf.NumberFormatId!.Value);
|
||
_numberFormatCache[formatCode] = pattern;
|
||
return pattern;
|
||
}
|
||
if (nf.NumberFormatId?.Value >= newId)
|
||
newId = nf.NumberFormatId.Value + 1;
|
||
}
|
||
|
||
var newNf = new NumberingFormat
|
||
{
|
||
NumberFormatId = newId,
|
||
FormatCode = formatCode
|
||
};
|
||
numberingFormats.Append(newNf);
|
||
numberingFormats.Count = (uint)numberingFormats.Elements<NumberingFormat>().Count();
|
||
|
||
var result = new NumberFormatPattern(formatCode);
|
||
result.Attach((ushort)newId);
|
||
_numberFormatCache[formatCode] = result;
|
||
return result;
|
||
}
|
||
|
||
|
||
// Вспомогательные методы для извлечения элементов из стилей (нужно закешировать или обращаться напрямую)
|
||
|
||
private Border? GetBorderById(uint borderId)
|
||
{
|
||
if (_cachedBorders == null)
|
||
{
|
||
var borders = EnsureStylesheet().Borders;
|
||
_cachedBorders = borders?.Elements<Border>().ToList() ?? [];
|
||
}
|
||
return borderId < _cachedBorders.Count ? _cachedBorders[(int)borderId] : null;
|
||
}
|
||
private List<Border>? _cachedBorders;
|
||
|
||
private Fill? GetFillById(uint borderId)
|
||
{
|
||
if (_cachedFills == null)
|
||
{
|
||
var fills = EnsureStylesheet().Fills;
|
||
_cachedFills = fills?.Elements<Fill>().ToList() ?? [];
|
||
}
|
||
return borderId < _cachedFills.Count ? _cachedFills[(int)borderId] : null;
|
||
}
|
||
private List<Fill>? _cachedFills;
|
||
|
||
private Font? GetFontById(uint borderId)
|
||
{
|
||
if (_cachedFonts == null)
|
||
{
|
||
var borders = EnsureStylesheet().Borders;
|
||
_cachedFonts = borders?.Elements<Font>().ToList() ?? [];
|
||
}
|
||
return borderId < _cachedFonts.Count ? _cachedFonts[(int)borderId] : null;
|
||
}
|
||
private List<Font>? _cachedFonts;
|
||
} |