diff --git a/QWERTYkez.ExcelProcessor/Editors/CellAddressHelper.cs b/QWERTYkez.ExcelProcessor/Editors/CellAddressHelper.cs new file mode 100644 index 0000000..924c65d --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/CellAddressHelper.cs @@ -0,0 +1,48 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +internal static class CellAddressHelper +{ + private const string letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static readonly uint[] _powers = [1, 26, 676]; + + public static uint ColumnLetterToIndex(string col) + { + uint index = 0; + foreach (char ch in col) + { + char c = char.ToUpperInvariant(ch); + if (c < 'A' || c > 'Z') return 0; + index = index * 26 + (uint)(c - 'A' + 1); + } + return index; + } + + public static string ColumnIndexToLetter(uint col) + { + if (col == 0) return ""; + uint n = col; + char[] buf = new char[3]; + int idx = 0; + while (n > 0) + { + n--; + buf[idx++] = (char)('A' + (n % 26)); + n /= 26; + } + return new string(buf, 0, idx); + } + + public static bool TryParseCellReference(string reference, out uint row, out uint col) + { + row = 0; col = 0; + if (string.IsNullOrEmpty(reference)) return false; + int i = 0; + while (i < reference.Length && char.IsLetter(reference[i])) i++; + if (i == 0) return false; + string colPart = reference.Substring(0, i); + string rowPart = reference.Substring(i); + if (!uint.TryParse(rowPart, out row)) return false; + col = CellAddressHelper.ColumnLetterToIndex(colPart); + return true; + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/CellAlign.cs b/QWERTYkez.ExcelProcessor/Editors/CellAlign.cs new file mode 100644 index 0000000..0d2d7ba --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/CellAlign.cs @@ -0,0 +1,223 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Определяет выравнивание содержимого ячейки: горизонтальное, вертикальное, перенос текста и уменьшение по размеру. +/// Все свойства опциональны. Если свойство не задано, соответствующий аспект не изменяется. +/// +public readonly struct CellAlign : IEquatable +{ + /// Горизонтальное выравнивание. + public CellAlignHorizontal? Horizontal { get; init; } + + /// Вертикальное выравнивание. + public CellAlignVertical? Vertical { get; init; } + + /// Переносить ли текст по словам (многострочный режим). + public bool? WrapText { get; init; } + + /// Уменьшать размер шрифта, чтобы текст поместился в ячейку. + public bool? ShrinkToFit { get; init; } + + /// Преобразует горизонтальное выравнивание в тип Open XML. + public bool TryGetExcelHorizontalAlignment(out HorizontalAlignmentValues value) + { + if (Horizontal.HasValue) + { + value = Horizontal.Value switch + { + CellAlignHorizontal.Left => HorizontalAlignmentValues.Left, + CellAlignHorizontal.Center => HorizontalAlignmentValues.Center, + CellAlignHorizontal.Right => HorizontalAlignmentValues.Right, + CellAlignHorizontal.Fill => HorizontalAlignmentValues.Fill, + CellAlignHorizontal.Justify => HorizontalAlignmentValues.Justify, + CellAlignHorizontal.CenterContinuous => HorizontalAlignmentValues.CenterContinuous, + CellAlignHorizontal.Distributed => HorizontalAlignmentValues.Distributed, + _ => throw new NotSupportedException($"Unsupported horizontal alignment: {Horizontal.Value}") + }; + return true; + } + value = default; + return false; + } + + /// Преобразует вертикальное выравнивание в тип Open XML. + public bool TryGetExcelVerticalAlignment(out VerticalAlignmentValues value) + { + if (Vertical.HasValue) + { + value = Vertical.Value switch + { + CellAlignVertical.Top => VerticalAlignmentValues.Top, + CellAlignVertical.Center => VerticalAlignmentValues.Center, + CellAlignVertical.Bottom => VerticalAlignmentValues.Bottom, + CellAlignVertical.Justify => VerticalAlignmentValues.Justify, + CellAlignVertical.Distributed => VerticalAlignmentValues.Distributed, + _ => throw new NotSupportedException($"Unsupported vertical alignment: {Vertical.Value}") + }; + return true; + } + value = default; + return false; + } + + /// Создаёт элемент Alignment для Open XML. + public Alignment? ToAlignment() + { + if (!Horizontal.HasValue && !Vertical.HasValue && !WrapText.HasValue && !ShrinkToFit.HasValue) + return null; + + var align = new Alignment(); + if (TryGetExcelHorizontalAlignment(out var hAlign)) + align.Horizontal = hAlign; + if (TryGetExcelVerticalAlignment(out var vAlign)) + align.Vertical = vAlign; + if (WrapText.HasValue) + align.WrapText = WrapText.Value; + if (ShrinkToFit.HasValue) + align.ShrinkToFit = ShrinkToFit.Value; + return align; + } + + /// Создаёт CellAlign из элемента Alignment Open XML. + public static CellAlign FromAlignment(Alignment? alignment) + { + if (alignment == null) + return default; + + var result = new CellAlign(); + if (alignment.Horizontal?.Value is { } horizontal) + { + result = result with { Horizontal = MapHorizontalFromExcel(horizontal) }; + } + if (alignment.Vertical?.Value is { } vertical) + { + result = result with { Vertical = MapVerticalFromExcel(vertical) }; + } + if (alignment.WrapText?.Value is { } wrapText) + { + result = result with { WrapText = wrapText }; + } + if (alignment.ShrinkToFit?.Value is { } shrinkToFit) + { + result = result with { ShrinkToFit = shrinkToFit }; + } + return result; + } + + private static CellAlignHorizontal MapHorizontalFromExcel(HorizontalAlignmentValues value) + { + if (value == HorizontalAlignmentValues.Left) + { + return CellAlignHorizontal.Left; + } + else if (value == HorizontalAlignmentValues.Center) + { + return CellAlignHorizontal.Center; + } + else if (value == HorizontalAlignmentValues.Right) + { + return CellAlignHorizontal.Right; + } + else if (value == HorizontalAlignmentValues.Fill) + { + return CellAlignHorizontal.Fill; + } + else if (value == HorizontalAlignmentValues.Justify) + { + return CellAlignHorizontal.Justify; + } + else if (value == HorizontalAlignmentValues.CenterContinuous) + { + return CellAlignHorizontal.CenterContinuous; + } + else if (value == HorizontalAlignmentValues.Distributed) + { + return CellAlignHorizontal.Distributed; + } + else throw new NotSupportedException($"Unsupported horizontal alignment: {value}"); + } + + private static CellAlignVertical MapVerticalFromExcel(VerticalAlignmentValues value) + { + if (value == VerticalAlignmentValues.Top) + { + return CellAlignVertical.Top; + } + else if (value == VerticalAlignmentValues.Center) + { + return CellAlignVertical.Center; + } + else if (value == VerticalAlignmentValues.Bottom) + { + return CellAlignVertical.Bottom; + } + else if (value == VerticalAlignmentValues.Justify) + { + return CellAlignVertical.Justify; + } + else if (value == VerticalAlignmentValues.Distributed) + { + return CellAlignVertical.Distributed; + } + else throw new NotSupportedException($"Unsupported vertical alignment: {value}"); + } + + public override bool Equals(object? obj) => obj is CellAlign other && Equals(other); + public bool Equals(CellAlign other) => this == other; + + public static bool operator ==(CellAlign left, CellAlign right) => + left.Horizontal == right.Horizontal && + left.Vertical == right.Vertical && + left.WrapText == right.WrapText && + left.ShrinkToFit == right.ShrinkToFit; + + public static bool operator !=(CellAlign left, CellAlign right) => !(left == right); + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 31 + (Horizontal?.GetHashCode() ?? 0); + hash = hash * 31 + (Vertical?.GetHashCode() ?? 0); + hash = hash * 31 + (WrapText?.GetHashCode() ?? 0); + hash = hash * 31 + (ShrinkToFit?.GetHashCode() ?? 0); + return hash; + } + } +} + + +/// Горизонтальное выравнивание содержимого ячейки. +public enum CellAlignHorizontal +{ + /// По левому краю. + Left, + /// По центру. + Center, + /// По правому краю. + Right, + /// Заполнение (повтор содержимого для заполнения ширины). + Fill, + /// По ширине (для многострочного текста). + Justify, + /// Центрирование по выделенным ячейкам (визуально, без объединения). + CenterContinuous, + /// Распределённый (выравнивание по ширине с пробелами). + Distributed, +} + +/// Вертикальное выравнивание содержимого ячейки. +public enum CellAlignVertical +{ + /// По верхнему краю. + Top, + /// По центру. + Center, + /// По нижнему краю (значение по умолчанию). + Bottom, + /// По высоте (для многострочного текста). + Justify, + /// Распределённый по вертикали. + Distributed, +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/CellBorder.cs b/QWERTYkez.ExcelProcessor/Editors/CellBorder.cs new file mode 100644 index 0000000..857ba67 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/CellBorder.cs @@ -0,0 +1,281 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Определяет границы ячейки: верхнюю, нижнюю, левую, правую и диагональные. +/// Каждая граница может иметь стиль и цвет. +/// +public readonly struct CellBorder : IEquatable +{ + /// Верхняя граница. + public BorderSide? TopBorder { get; init; } + + /// Нижняя граница. + public BorderSide? BottomBorder { get; init; } + + /// Левая граница. + public BorderSide? LeftBorder { get; init; } + + /// Правая граница. + public BorderSide? RightBorder { get; init; } + + /// Диагональная граница «из левого верхнего в правый нижний» (\\). + public BorderSide? DiagonalLeft { get; init; } + + /// Диагональная граница «из левого нижнего в правый верхний» (//). + public BorderSide? DiagonalRight { get; init; } + + /// Создаёт элемент Border для Open XML. + public Border? ToBorder() + { + bool hasAny = TopBorder.HasValue || BottomBorder.HasValue || LeftBorder.HasValue || + RightBorder.HasValue || DiagonalLeft.HasValue || DiagonalRight.HasValue; + if (!hasAny) return null; + + var border = new Border(); + if (TopBorder.HasValue) + border.TopBorder = TopBorder.Value.ToBorderElement(); + if (BottomBorder.HasValue) + border.BottomBorder = BottomBorder.Value.ToBorderElement(); + if (LeftBorder.HasValue) + border.LeftBorder = LeftBorder.Value.ToBorderElement(); + if (RightBorder.HasValue) + border.RightBorder = RightBorder.Value.ToBorderElement(); + + // Обработка диагональных границ + if (DiagonalLeft.HasValue || DiagonalRight.HasValue) + { + // Диагональ \\ (из левого верхнего в правый нижний) = DiagonalDown + border.DiagonalDown = DiagonalLeft.HasValue; + // Диагональ // (из левого нижнего в правый верхний) = DiagonalUp + border.DiagonalUp = DiagonalRight.HasValue; + + // Стиль и цвет берём из первой заданной диагонали (например, DiagonalLeft) + var diagSide = (DiagonalLeft ?? DiagonalRight)!.Value; + border.DiagonalBorder = diagSide.ToBorderElement(); + } + + return border; + } + + /// Создаёт CellBorder из элемента Border Open XML. + public static CellBorder FromBorder(Border? border) + { + if (border == null) + return default; + + var result = new CellBorder + { + TopBorder = BorderSide.FromBorderProperties(border.TopBorder), + BottomBorder = BorderSide.FromBorderProperties(border.BottomBorder), + LeftBorder = BorderSide.FromBorderProperties(border.LeftBorder), + RightBorder = BorderSide.FromBorderProperties(border.RightBorder) + }; + + // Диагональные границы + if (border.DiagonalDown?.Value == true) + result = result with { DiagonalLeft = BorderSide.FromBorderProperties(border.DiagonalBorder) }; + if (border.DiagonalUp?.Value == true) + result = result with { DiagonalRight = BorderSide.FromBorderProperties(border.DiagonalBorder) }; + + return result; + } + + public override bool Equals(object? obj) => obj is CellBorder other && Equals(other); + public bool Equals(CellBorder other) => this == other; + + public static bool operator ==(CellBorder left, CellBorder right) => + Equals(left.TopBorder, right.TopBorder) && + Equals(left.BottomBorder, right.BottomBorder) && + Equals(left.LeftBorder, right.LeftBorder) && + Equals(left.RightBorder, right.RightBorder) && + Equals(left.DiagonalLeft, right.DiagonalLeft) && + Equals(left.DiagonalRight, right.DiagonalRight); + + public static bool operator !=(CellBorder left, CellBorder right) => !(left == right); + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 31 + (TopBorder?.GetHashCode() ?? 0); + hash = hash * 31 + (BottomBorder?.GetHashCode() ?? 0); + hash = hash * 31 + (LeftBorder?.GetHashCode() ?? 0); + hash = hash * 31 + (RightBorder?.GetHashCode() ?? 0); + hash = hash * 31 + (DiagonalLeft?.GetHashCode() ?? 0); + hash = hash * 31 + (DiagonalRight?.GetHashCode() ?? 0); + return hash; + } + } +} + +/// Стиль и цвет границы. +public readonly struct BorderSide : IEquatable +{ + /// Стиль линии границы. + public BorderStyle? Style { get; init; } + + /// Цвет границы. + public ExColor? Color { get; init; } + + internal T ToBorderElement() where T : BorderPropertiesType, new() + { + var element = new T(); + if (Style.HasValue) + { + element.Style = Style.Value switch + { + BorderStyle.Thin => BorderStyleValues.Thin, + BorderStyle.Medium => BorderStyleValues.Medium, + BorderStyle.Dashed => BorderStyleValues.Dashed, + BorderStyle.Dotted => BorderStyleValues.Dotted, + BorderStyle.Thick => BorderStyleValues.Thick, + BorderStyle.Double => BorderStyleValues.Double, + BorderStyle.Hair => BorderStyleValues.Hair, + BorderStyle.MediumDashed => BorderStyleValues.MediumDashed, + BorderStyle.DashDot => BorderStyleValues.DashDot, + BorderStyle.MediumDashDot => BorderStyleValues.MediumDashDot, + BorderStyle.DashDotDot => BorderStyleValues.DashDotDot, + BorderStyle.MediumDashDotDot => BorderStyleValues.MediumDashDotDot, + BorderStyle.SlantDashDot => BorderStyleValues.SlantDashDot, + _ => throw new NotImplementedException(), + }; + } + if (Color.HasValue && Color.Value.Color.HasValue) + { + var c = Color.Value.Color.Value; + element.Color = new Color { Rgb = $"{c.R:X2}{c.G:X2}{c.B:X2}" }; + } + return element; + } + + /// Создаёт BorderSide из элемента границы Open XML (TopBorder, BottomBorder, LeftBorder, RightBorder, DiagonalBorder). + public static BorderSide FromBorderProperties(BorderPropertiesType? borderElement) + { + if (borderElement is null) + return default; + + var result = new BorderSide(); + if (borderElement.Style is not null) + { + result = result with { Style = MapBorderStyleFromExcel(borderElement.Style.Value) }; + } + if (borderElement.Color?.Rgb?.Value is { } rgb && rgb.Length >= 6) + { + var color = System.Drawing.Color.FromArgb( + Convert.ToByte(rgb.Substring(0, 2), 16), + Convert.ToByte(rgb.Substring(2, 2), 16), + Convert.ToByte(rgb.Substring(4, 2), 16) + ); + result = result with { Color = new ExColor(color) }; + } + return result; + } + + private static BorderStyle MapBorderStyleFromExcel(BorderStyleValues value) + { + if (value == BorderStyleValues.Thin) + { + return BorderStyle.Thin; + } + else if (value == BorderStyleValues.Medium) + { + return BorderStyle.Medium; + } + else if (value == BorderStyleValues.Dashed) + { + return BorderStyle.Dashed; + } + else if (value == BorderStyleValues.Dotted) + { + return BorderStyle.Dotted; + } + else if (value == BorderStyleValues.Thick) + { + return BorderStyle.Thick; + } + else if (value == BorderStyleValues.Double) + { + return BorderStyle.Double; + } + else if (value == BorderStyleValues.Hair) + { + return BorderStyle.Hair; + } + else if (value == BorderStyleValues.MediumDashed) + { + return BorderStyle.MediumDashed; + } + else if (value == BorderStyleValues.DashDot) + { + return BorderStyle.DashDot; + } + else if (value == BorderStyleValues.MediumDashDot) + { + return BorderStyle.MediumDashDot; + } + else if (value == BorderStyleValues.DashDotDot) + { + return BorderStyle.DashDotDot; + } + else if (value == BorderStyleValues.MediumDashDotDot) + { + return BorderStyle.MediumDashDotDot; + } + else if (value == BorderStyleValues.SlantDashDot) + { + return BorderStyle.SlantDashDot; + } + else throw new NotSupportedException($"Unsupported border style: {value}"); + } + + public override bool Equals(object? obj) => obj is BorderSide other && Equals(other); + public bool Equals(BorderSide other) => this == other; + + public static bool operator ==(BorderSide left, BorderSide right) => + left.Style == right.Style && Equals(left.Color, right.Color); + + public static bool operator !=(BorderSide left, BorderSide right) => !(left == right); + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 31 + Style.GetHashCode(); + hash = hash * 31 + (Color?.GetHashCode() ?? 0); + return hash; + } + } +} + +/// Тип линии границы +public enum BorderStyle +{ + /// Тонкая линия + Thin, + /// Средняя линия + Medium, + /// Штриховая + Dashed, + /// Точечная + Dotted, + /// Толстая линия + Thick, + /// Двойная линия + Double, + /// Волосяная (очень тонкая) + Hair, + /// Средняя штриховая + MediumDashed, + /// Штрих-пунктирная + DashDot, + /// Средняя штрих-пунктирная + MediumDashDot, + /// Штрих-пунктир-пунктир + DashDotDot, + /// Средняя штрих-пунктир-пунктир + MediumDashDotDot, + /// Наклонная штрих-пунктирная (для диагональных) + SlantDashDot, +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/CellFill.cs b/QWERTYkez.ExcelProcessor/Editors/CellFill.cs new file mode 100644 index 0000000..c2a978f --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/CellFill.cs @@ -0,0 +1,53 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Определяет заливку (фон) ячейки. +/// +public readonly struct CellFill : IEquatable +{ + /// Цвет фона. + public ExColor? BackgroundColor { get; init; } + + /// Создаёт элемент Fill для Open XML. + public Fill? ToFill() + { + if (!BackgroundColor.HasValue || !BackgroundColor.Value.Color.HasValue) + return null; + + var c = BackgroundColor.Value.Color.Value; + var fill = new Fill + { + PatternFill = new PatternFill + { + PatternType = PatternValues.Solid, + ForegroundColor = new ForegroundColor { Rgb = $"{c.R:X2}{c.G:X2}{c.B:X2}" } + } + }; + return fill; + } + + /// Создаёт CellFill из элемента Fill Open XML. + public static CellFill FromFill(Fill? fill) + { + if (fill?.PatternFill?.ForegroundColor?.Rgb?.Value is not string rgb || rgb.Length < 6) + return default; + + var color = System.Drawing.Color.FromArgb( + Convert.ToByte(rgb.Substring(0, 2), 16), + Convert.ToByte(rgb.Substring(2, 2), 16), + Convert.ToByte(rgb.Substring(4, 2), 16) + ); + return new CellFill { BackgroundColor = new ExColor(color) }; + } + + public override bool Equals(object? obj) => obj is CellFill other && Equals(other); + public bool Equals(CellFill other) => this == other; + + public static bool operator ==(CellFill left, CellFill right) => + Equals(left.BackgroundColor, right.BackgroundColor); + + public static bool operator !=(CellFill left, CellFill right) => !(left == right); + + public override int GetHashCode() => + BackgroundColor?.GetHashCode() ?? 0; +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/CellFont.cs b/QWERTYkez.ExcelProcessor/Editors/CellFont.cs new file mode 100644 index 0000000..48dab60 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/CellFont.cs @@ -0,0 +1,112 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Определяет шрифтовое оформление всей ячейки: размер, семейство, цвет, начертание. +/// Все свойства опциональны. +/// +public readonly struct CellFont : IEquatable +{ + /// Размер шрифта в пунктах (например, 11). + public double? FontSize { get; init; } + + /// Имя семейства шрифта (например, "Calibri", "Arial"). + public string? FontFamily { get; init; } + + /// Цвет текста. + public ExColor? FontColor { get; init; } + + /// Жирное начертание. + public bool? IsBold { get; init; } + + /// Курсив. + public bool? IsItalic { get; init; } + + /// Подчёркивание (одинарное). + public bool? IsUnderline { get; init; } + + /// Зачёркивание. + public bool? IsStrike { get; init; } + + /// Создаёт элемент Font для Open XML. + public Font? ToFont() + { + if (!FontSize.HasValue && FontFamily is null && FontColor is null && + !IsBold.HasValue && !IsItalic.HasValue && !IsUnderline.HasValue && !IsStrike.HasValue) + return null; + + var font = new Font(); + if (FontSize.HasValue) + font.FontSize = new FontSize { Val = FontSize.Value }; + if (FontFamily is not null) + font.FontName = new FontName { Val = FontFamily }; + if (FontColor.HasValue && FontColor.Value.Color.HasValue) + { + var c = FontColor.Value.Color.Value; + font.Color = new Color { Rgb = $"{c.R:X2}{c.G:X2}{c.B:X2}" }; + } + if (IsBold == true) font.Bold = new Bold(); + if (IsItalic == true) font.Italic = new Italic(); + if (IsUnderline == true) font.Underline = new Underline(); + if (IsStrike == true) font.Strike = new Strike(); + return font; + } + + /// Создаёт CellFont из элемента Font Open XML. + public static CellFont FromFont(Font? font) + { + if (font == null) + return default; + + var result = new CellFont + { + IsBold = font.Bold != null, + IsItalic = font.Italic != null, + IsUnderline = font.Underline != null, + IsStrike = font.Strike != null + }; + if (font.FontSize?.Val?.Value is { } size) + result = result with { FontSize = size }; + if (font.FontName?.Val?.Value is { } name) + result = result with { FontFamily = name }; + if (font.Color?.Rgb?.Value is { } rgb && rgb.Length >= 6) + { + var color = System.Drawing.Color.FromArgb( + Convert.ToByte(rgb.Substring(0, 2), 16), + Convert.ToByte(rgb.Substring(2, 2), 16), + Convert.ToByte(rgb.Substring(4, 2), 16) + ); + result = result with { FontColor = new ExColor(color) }; + } + return result; + } + + public override bool Equals(object? obj) => obj is CellFont other && Equals(other); + public bool Equals(CellFont other) => this == other; + + public static bool operator ==(CellFont left, CellFont right) => + left.FontSize == right.FontSize && + left.FontFamily == right.FontFamily && + Equals(left.FontColor, right.FontColor) && + left.IsBold == right.IsBold && + left.IsItalic == right.IsItalic && + left.IsUnderline == right.IsUnderline && + left.IsStrike == right.IsStrike; + + public static bool operator !=(CellFont left, CellFont right) => !(left == right); + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 31 + (FontSize?.GetHashCode() ?? 0); + hash = hash * 31 + (FontFamily?.GetHashCode() ?? 0); + hash = hash * 31 + (FontColor?.GetHashCode() ?? 0); + hash = hash * 31 + (IsBold?.GetHashCode() ?? 0); + hash = hash * 31 + (IsItalic?.GetHashCode() ?? 0); + hash = hash * 31 + (IsUnderline?.GetHashCode() ?? 0); + hash = hash * 31 + (IsStrike?.GetHashCode() ?? 0); + return hash; + } + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/ColumnWidth.cs b/QWERTYkez.ExcelProcessor/Editors/ColumnWidth.cs new file mode 100644 index 0000000..a0a7fc8 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/ColumnWidth.cs @@ -0,0 +1,63 @@ +namespace QWERTYkez.ExcelProcessor.Editors +{ + /// + /// Представляет ширину столбца в Excel. Поддерживает задание в символах, пунктах, сантиметрах и миллиметрах. + /// Для методов FromCM/FromMM/FromPoints требуется калибровка через , иначе точность не гарантирована. + /// + public readonly struct ColumnWidth + { + private readonly double _rawValue; + private readonly UnitType _unit; + + private enum UnitType { Characters, Points, Centimeters, Millimeters } + + /// Коэффициент перевода символов в пункты по умолчанию (используется, если нет калибровочной таблицы). + public static double DefaultPointsPerChar { get; set; } = 5.65; + + private ColumnWidth(double value, UnitType unit) + { + _rawValue = value; + _unit = unit; + } + + /// Задаёт ширину в character units (символах стандартного шрифта). + public static ColumnWidth FromCharacters(double characters) => new(characters, UnitType.Characters); + + /// Задаёт ширину в пунктах. + [Obsolete("Для правильной работы требуется установленный Excel, точность гарантирована только для этого компьютера")] + public static ColumnWidth FromPoints(double points) => new(points, UnitType.Points); + + /// Задаёт ширину в сантиметрах. + [Obsolete("Для правильной работы требуется установленный Excel, точность гарантирована только для этого компьютера")] + public static ColumnWidth FromCM(double cm) => new(cm, UnitType.Centimeters); + + /// Задаёт ширину в миллиметрах. + [Obsolete("Для правильной работы требуется установленный Excel, точность гарантирована только для этого компьютера")] + public static ColumnWidth FromMM(double mm) => new(mm, UnitType.Millimeters); + + /// Возвращает целевую ширину в пунктах (если ширина задана в пунктах, см или мм). + internal double GetTargetPoints() + { + return _unit switch + { + UnitType.Points => _rawValue, + UnitType.Centimeters => _rawValue * 28.3464566929, + UnitType.Millimeters => (_rawValue / 10.0) * 28.3464566929, + _ => double.NaN + }; + } + internal double GetTargetCentimeters() + { + return _unit switch + { + UnitType.Centimeters => _rawValue, + UnitType.Millimeters => _rawValue / 10.0, + UnitType.Points => _rawValue / 28.3464566929, + _ => double.NaN + }; + } + + internal bool IsCharacterUnit => _unit == UnitType.Characters; + internal double CharacterValue => IsCharacterUnit ? _rawValue : double.NaN; + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/ExColor.cs b/QWERTYkez.ExcelProcessor/Editors/ExColor.cs new file mode 100644 index 0000000..0835a65 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/ExColor.cs @@ -0,0 +1,62 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +public readonly struct ExColor(System.Drawing.Color? Color) +{ + private readonly System.Drawing.Color? color = Color; + + /// Проверяет, является ли цвет автоматическим (т.е. Color == null). + public bool IsAuto => color is null; + + public System.Drawing.Color? Color => color; + + public static ExColor FromArgb(byte r, byte g, byte b) => new(System.Drawing.Color.FromArgb(r, g, b)); + public static ExColor FromName(string knownColor) => new(System.Drawing.Color.FromName(knownColor)); + + + + public static implicit operator ExColor(Color exColor) => FromExcel(exColor); + public static implicit operator Color(ExColor color) => color.ToExcel(); + public static ExColor FromExcel(Color excelColor) + { + if (excelColor == null) + return new ExColor(null); + + // Если цвет автоматический + if (excelColor.Auto != null && excelColor.Auto.Value) + return new ExColor(null); + + // Если задан RGB + if (excelColor.Rgb?.Value is { } rgb && rgb.Length > 0) + { + if (rgb.Length == 6) // RRGGBB + { + byte r = Convert.ToByte(rgb.Substring(0, 2), 16); + byte g = Convert.ToByte(rgb.Substring(2, 2), 16); + byte b = Convert.ToByte(rgb.Substring(4, 2), 16); + return new ExColor(System.Drawing.Color.FromArgb(r, g, b)); + } + else if (rgb.Length == 8) // AARRGGBB (альфа игнорируется) + { + byte r = Convert.ToByte(rgb.Substring(2, 2), 16); + byte g = Convert.ToByte(rgb.Substring(4, 2), 16); + byte b = Convert.ToByte(rgb.Substring(6, 2), 16); + return new ExColor(System.Drawing.Color.FromArgb(r, g, b)); + } + } + + // По умолчанию — автоматический цвет + return new ExColor(null); + } + public Color ToExcel() + { + var excelColor = new Color(); + { + if (color.HasValue) + { + excelColor.Rgb = $"{color.Value.R:X2}{color.Value.G:X2}{color.Value.B:X2}"; + } + else excelColor.Auto = true; + } + return excelColor; + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/ExcelBook.cs b/QWERTYkez.ExcelProcessor/Editors/ExcelBook.cs new file mode 100644 index 0000000..4d032e1 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/ExcelBook.cs @@ -0,0 +1,43 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Внутренняя реализация , которая делегирует вызовы к . +/// Используется в ситуациях, когда требуется отдельный объект книги (например, в ). +/// +internal sealed class ExcelBook : IBook +{ + internal readonly ExcelWriter Writer; + + /// + /// Инициализирует обёртку книги. + /// + /// Реальный процессор Excel (реализует IBook). + internal ExcelBook(ExcelWriter writer) + { + Writer = writer; + } + + /// + public IReadOnlyList GetSheets() => Writer.GetSheets(); + + /// + public ISheet? Sheet(string name) => Writer.Sheet(name); + + /// + public bool TryGetSheet(string name, out ISheet sheet) => Writer.TryGetSheet(name, out sheet); + + /// + public bool TryAddSheet(string name, Action? edit = null) => Writer.TryAddSheet(name, edit); + + /// + public bool TryRemoveSheet(string name) => Writer.TryRemoveSheet(name); + + /// + public bool TryRemoveSheet(ISheet sheet) => Writer.TryRemoveSheet(sheet); + + /// + public IReadOnlyList GetNumberFormats() => Writer.GetNumberFormats(); + + /// + public NumberFormatPattern CreateNumberFormat(string format) => Writer.CreateNumberFormat(format); +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/ExcelCell.cs b/QWERTYkez.ExcelProcessor/Editors/ExcelCell.cs new file mode 100644 index 0000000..a474389 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/ExcelCell.cs @@ -0,0 +1,742 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Внутренняя реализация . +/// +internal sealed class ExcelCell : ICell +{ + private readonly ExcelWriter _writer; + private readonly ExcelSheet _sheet; + private uint _row; + private uint _col; + + internal ExcelCell(ExcelWriter writer, ExcelSheet sheet, uint row, uint col) + { + _writer = writer; + _sheet = sheet; + _row = row; + _col = col; + } + + public bool IsMerged + { + get + { + var range = GetMergedRange(); + return range != null; + } + } + + public IRange? GetMergedRange() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var mergeCells = GetMergeCells(); + if (mergeCells == null) return null; + foreach (var mergeCell in mergeCells.Elements()) + { + if (TryParseRangeReference(mergeCell.Reference?.Value ?? string.Empty, out var range)) + { + if (_row >= range.RowStart && _row <= range.RowEnd && + _col >= range.ColStart && _col <= range.ColEnd) + return range; + } + } + return null; + } + } + + public bool TryGetMergedRange(IRange range) + { + var merged = GetMergedRange(); + if (merged == null) return false; + return merged.Equals(range); + } + + public uint Row => _row; + public uint Col => _col; + public string ColLetter => CellAddressHelper.ColumnIndexToLetter(_col); + + public ICell MoveTo(uint rowIndex, uint colIndex) + { + if (rowIndex == _row && colIndex == _col) return this; + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + // Вырезать-вставить: копируем данные в новую позицию, очищаем старую + var srcCell = GetCellElement(); + if (srcCell != null) + { + var cloned = (Cell)srcCell.CloneNode(true); + InsertCellAt(rowIndex, colIndex, cloned); + srcCell.Remove(); + } + _row = rowIndex; + _col = colIndex; + } + return this; + } + + public ICell MoveTo(uint rowIndex, string colIndex) => MoveTo(rowIndex, CellAddressHelper.ColumnLetterToIndex(colIndex)); + + public RowHeight Height + { + get => ExcelRow.GetHeight(_writer, _sheet, _row); + set => ExcelRow.SetHeight(_writer, _sheet, _row, value); + } + + public ColumnWidth Width + { + get => ExcelColumn.GetWidth(_writer, _sheet, _col); + set => ExcelColumn.SetWidth(_writer, _sheet, _col, value); + } + + public bool IsNumber + { + get + { + var cell = GetCellElement(); + if (cell == null) return false; + if (cell.DataType != null && cell.DataType.Value == CellValues.Number) return true; + // Если тип не указан, но значение число – Excel считает числом + if (cell.DataType == null && cell.CellValue != null && double.TryParse(cell.CellValue.Text, out _)) return true; + return false; + } + } + + public bool IsBoolean + { + get + { + var cell = GetCellElement(); + return cell != null && cell.DataType != null && cell.DataType.Value == CellValues.Boolean; + } + } + + public bool IsError + { + get + { + var cell = GetCellElement(); + return cell != null && cell.DataType != null && cell.DataType.Value == CellValues.Error; + } + } + + public bool IsDate + { + get + { + if (!IsNumber) return false; + var format = GetNumberFormat(); + if (format == null) return false; + + // Встроенный формат: проверяем по ID + if (format.Id.HasValue && format.Id.Value < 164) + { + int id = format.Id.Value; + return (id >= 14 && id <= 22) || // даты + (id >= 27 && id <= 36) || // время и дата-время + (id >= 45 && id <= 47); // другие форматы времени + } + else + { + // Пользовательский формат: ищем символы даты/времени + string code = format.Format?.ToLowerInvariant() ?? string.Empty; + bool isDate = code.Contains('d') && code.Contains('m') && code.Contains('y'); + bool isTime = code.Contains('h') && code.Contains('m') && code.Contains('s'); + return isDate || isTime; + } + } + } + + public bool IsLocked + { + get + { + var cell = GetCellElement(); + if (cell?.StyleIndex?.Value is not uint styleIndex) + return false; + return _writer.IsCellLocked(styleIndex); + } + } + public bool HasFormula => GetCellElement()?.CellFormula != null; + + public string GetString() + { + var cell = GetCellElement(); + if (cell == null) return string.Empty; + if (cell.DataType != null && cell.DataType.Value == CellValues.SharedString) + { + // Общая таблица строк + if (uint.TryParse(cell.CellValue?.Text, out uint idx)) + return GetSharedString(idx); + return string.Empty; + } + if (cell.DataType != null && cell.DataType.Value == CellValues.InlineString) + { + // Rich text – извлекаем весь текст + return ExtractTextFromInlineString(cell.InlineString); + } + if (cell.DataType != null && cell.DataType.Value == CellValues.Boolean) + return cell.CellValue?.Text ?? "FALSE"; + if (cell.DataType != null && cell.DataType.Value == CellValues.Error) + return cell.CellValue?.Text ?? "#NULL!"; + // Число или общий тип + return cell.CellValue?.Text ?? string.Empty; + } + + /// + public NumberFormatPattern? GetNumberFormat() + { + var cell = GetCellElement(); + if (cell?.StyleIndex?.Value is not uint styleIndex) + return null; + return _writer.GetNumberFormat(styleIndex); + } + + /// + public CellAlign GetCellAlign() + { + var cell = GetCellElement(); + if (cell?.StyleIndex?.Value is not uint styleIndex) + return default; + return _writer.GetCellAlign(styleIndex); + } + + /// + public CellBorder GetCellBorder() + { + var cell = GetCellElement(); + if (cell?.StyleIndex?.Value is not uint styleIndex) + return default; + return _writer.GetCellBorder(styleIndex); + } + + /// + public CellFill GetCellFill() + { + var cell = GetCellElement(); + if (cell?.StyleIndex?.Value is not uint styleIndex) + return default; + return _writer.GetCellFill(styleIndex); + } + + /// + public CellFont GetCellFont() + { + var cell = GetCellElement(); + if (cell?.StyleIndex?.Value is not uint styleIndex) + return default; + return _writer.GetCellFont(styleIndex); + } + + public bool TryGetBoolean(out bool value) + { + value = false; + var cell = GetCellElement(); + if (cell == null) return false; + if (cell.DataType != null && cell.DataType.Value == CellValues.Boolean) + { + string txt = cell.CellValue?.Text ?? "0"; + value = txt == "1" || txt.Equals("true", StringComparison.OrdinalIgnoreCase); + return true; + } + return false; + } + + public bool? GetBoolean() => TryGetBoolean(out bool v) ? v : null; + + public bool TryGetDate(out DateTime value) + { + value = default; + if (!IsNumber) return false; + if (TryGetNumber(out double num)) + { + // Excel даты: 1 января 1900 = 1, 1 января 1904 = 0 в 1904 системе + // Предполагаем 1900 систему + value = DateTime.FromOADate(num); + return true; + } + return false; + } + + public DateTime? TryGetDate() => TryGetDate(out DateTime d) ? d : null; + + public bool TryGetNumber(out double value) + { + value = 0; + var cell = GetCellElement(); + if (cell == null) return false; + if (cell.DataType != null && cell.DataType.Value == CellValues.Number) + { + if (cell.CellValue != null && double.TryParse(cell.CellValue.Text, NumberStyles.Any, CultureInfo.InvariantCulture, out value)) + return true; + } + else if (cell.DataType == null && cell.CellValue != null && double.TryParse(cell.CellValue.Text, NumberStyles.Any, CultureInfo.InvariantCulture, out value)) + return true; + return false; + } + + public double? TryGetNumber() => TryGetNumber(out double v) ? v : null; + + public bool TrySet(string formula, NumberFormatPattern? format = null) + { + if (string.IsNullOrEmpty(formula)) return false; + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetOrCreateCellElement(); + cell.CellFormula = new CellFormula(formula); + cell.DataType = null; // формула сама определяет тип + if (format != null) + SetNumberFormatInternal(cell, format); + return true; + } + } + + public ICell Set(string formula, NumberFormatPattern? format = null) + { + if (!TrySet(formula, format)) + throw new InvalidOperationException("Failed to set formula"); + return this; + } + + public ICell Set(NumberFormatPattern format) + { + if (format == null) return this; + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetOrCreateCellElement(); + SetNumberFormatInternal(cell, format); + } + return this; + } + + /// + public ICell Set(CellAlign align) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetOrCreateCellElement(); + int styleIndex = _writer.GetOrCreateCellFormatId( + numberFormat: null, + font: null, + fill: null, + border: null, + align: align + ); + cell.StyleIndex = (uint)styleIndex; + return this; + } + } + + /// + public ICell Set(CellBorder border) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetOrCreateCellElement(); + int styleIndex = _writer.GetOrCreateCellFormatId( + numberFormat: null, + font: null, + fill: null, + border: border, + align: null + ); + cell.StyleIndex = (uint)styleIndex; + return this; + } + } + + /// + public ICell Set(CellFill fill) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetOrCreateCellElement(); + int styleIndex = _writer.GetOrCreateCellFormatId( + numberFormat: null, + font: null, + fill: fill, + border: null, + align: null + ); + cell.StyleIndex = (uint)styleIndex; + return this; + } + } + + /// + public ICell Set(CellFont font) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetOrCreateCellElement(); + int styleIndex = _writer.GetOrCreateCellFormatId( + numberFormat: null, + font: font, + fill: null, + border: null, + align: null + ); + cell.StyleIndex = (uint)styleIndex; + return this; + } + } + + public ICell Set(Action value) + { + if (value == null) return this; + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetOrCreateCellElement(); + var textObj = new ExcelCellText(); + value(textObj); + cell.InlineString = BuildInlineString(textObj); + cell.DataType = CellValues.InlineString; + cell.CellValue = null; + cell.CellFormula = null; + + // Если есть разрыв строки (символ \n), включаем перенос текста для ячейки + bool hasNewline = textObj.GetRuns().Any(r => r.Text != null && r.Text.Contains('\n')); + if (hasNewline) + { + var currentAlign = GetCellAlign(); + if (currentAlign.WrapText != true) + { + var newAlign = new CellAlign + { + Horizontal = currentAlign.Horizontal, + Vertical = currentAlign.Vertical, + WrapText = true, + ShrinkToFit = currentAlign.ShrinkToFit + }; + Set(newAlign); + } + } + } + return this; + } + + public ICell Set(string value) + { + if (value == null) return this; + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetOrCreateCellElement(); + // Проверяем, можно ли сохранить как число? + if (double.TryParse(value, out double num)) + { + cell.DataType = CellValues.Number; + cell.CellValue = new CellValue(num.ToString()); + } + else + { + // Используем общую таблицу строк + int idx = GetOrAddSharedString(value); + cell.DataType = CellValues.SharedString; + cell.CellValue = new CellValue(idx.ToString()); + } + cell.InlineString = null; + cell.CellFormula = null; + } + return this; + } + + public ICell Set(bool value) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetOrCreateCellElement(); + cell.DataType = CellValues.Boolean; + cell.CellValue = new CellValue(value ? "1" : "0"); + cell.InlineString = null; + cell.CellFormula = null; + } + return this; + } + + public ICell Set(DateTime value, NumberFormatPattern? format = null) + { + double oa = value.ToOADate(); + Set(oa, format); + return this; + } + + public ICell Set(decimal value, NumberFormatPattern? format = null) => Set((double)value, format); + public ICell Set(double value, NumberFormatPattern? format = null) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetOrCreateCellElement(); + cell.DataType = CellValues.Number; + cell.CellValue = new CellValue(value.ToString(CultureInfo.InvariantCulture)); + if (format != null) + SetNumberFormatInternal(cell, format); + } + return this; + } + public ICell Set(float value, NumberFormatPattern? format = null) => Set((double)value, format); + public ICell Set(int value, NumberFormatPattern? format = null) => Set((double)value, format); + public ICell Set(long value, NumberFormatPattern? format = null) => Set((double)value, format); + + public void ClearContent() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetCellElement(); + if (cell != null) + { + cell.CellValue = null; + cell.CellFormula = null; + cell.InlineString = null; + cell.DataType = null; + } + } + } + + public void ClearFormat() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var cell = GetCellElement(); + cell?.StyleIndex = null; + } + } + + public void Clear() + { + ClearContent(); + ClearFormat(); + } + + public ICell CopyTo(uint rowIndex, uint colIndex, out ICell copiedCell) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var srcCell = GetCellElement(); + if (srcCell != null) + { + var cloned = (Cell)srcCell.CloneNode(true); + InsertCellAt(rowIndex, colIndex, cloned); + copiedCell = new ExcelCell(_writer, _sheet, rowIndex, colIndex); + } + else + { + copiedCell = new ExcelCell(_writer, _sheet, rowIndex, colIndex); + } + return this; + } + } + + public ICell CopyTo(uint rowIndex, string colIndex, out ICell copiedCell) => + CopyTo(rowIndex, CellAddressHelper.ColumnLetterToIndex(colIndex), out copiedCell); + + // Private helpers + + private Cell? GetCellElement() + { + var sheetData = _sheet.GetSheetData(); + var row = FindRowElement(sheetData, _row); + if (row == null) return null; + return FindCellInRow(row, _col); + } + + private Cell GetOrCreateCellElement() + { + var sheetData = _sheet.GetSheetData(); + var row = GetOrCreateRowElement(sheetData, _row); + var cell = FindCellInRow(row, _col); + if (cell != null) return cell; + cell = new Cell(); + string cellRef = CellAddressHelper.ColumnIndexToLetter(_col) + _row.ToString(); + cell.CellReference = cellRef; + InsertCellInRow(row, cell, _col); + return cell; + } + + private static Row? FindRowElement(SheetData sheetData, uint rowIndex) + { + foreach (var row in sheetData.Elements()) + if (row.RowIndex?.Value == rowIndex) return row; + return null; + } + + private static Row GetOrCreateRowElement(SheetData sheetData, uint rowIndex) + { + var existing = FindRowElement(sheetData, rowIndex); + if (existing != null) return existing; + var newRow = new Row { RowIndex = rowIndex }; + InsertRowElement(sheetData, newRow, rowIndex); + return newRow; + } + + private static void InsertRowElement(SheetData sheetData, Row row, uint rowIndex) + { + bool inserted = false; + foreach (var existing in sheetData.Elements().ToList()) + { + if (existing.RowIndex?.Value > rowIndex) + { + existing.InsertBeforeSelf(row); + inserted = true; + break; + } + } + if (!inserted) sheetData.Append(row); + } + + private static Cell? FindCellInRow(Row row, uint colIndex) + { + foreach (var cell in row.Elements()) + { + if (CellAddressHelper.TryParseCellReference(cell.CellReference?.Value ?? string.Empty, out _, out uint col) && col == colIndex) + return cell; + } + return null; + } + + private static void InsertCellInRow(Row row, Cell cell, uint colIndex) + { + string newRef = CellAddressHelper.ColumnIndexToLetter(colIndex) + (row.RowIndex?.Value ?? 1).ToString(); + cell.CellReference = newRef; + bool inserted = false; + foreach (var existing in row.Elements().ToList()) + { + if (CellAddressHelper.TryParseCellReference(existing.CellReference?.Value ?? string.Empty, out _, out uint existingCol) && existingCol > colIndex) + { + existing.InsertBeforeSelf(cell); + inserted = true; + break; + } + } + if (!inserted) row.Append(cell); + } + + private void InsertCellAt(uint rowIndex, uint colIndex, Cell cell) + { + var sheetData = _sheet.GetSheetData(); + var row = GetOrCreateRowElement(sheetData, rowIndex); + InsertCellInRow(row, cell, colIndex); + } + + private void SetNumberFormatInternal(Cell cell, NumberFormatPattern format) + { + if (format == null) return; + int styleIndex = _writer.GetOrCreateCellFormatId( + numberFormat: format, + font: null, + fill: null, + border: null, + align: null + ); + cell.StyleIndex = (uint)styleIndex; + } + + private string GetSharedString(uint index) + { + return _writer.GetSharedString(index); + } + + private int GetOrAddSharedString(string value) + { + return _writer.GetOrAddSharedString(value); + } + + private string ExtractTextFromInlineString(InlineString? inlineString) + { + if (inlineString == null) return string.Empty; + var sb = new System.Text.StringBuilder(); + foreach (var run in inlineString.Elements()) + { + var text = run.GetFirstChild()?.Text ?? string.Empty; + sb.Append(text); + } + return sb.ToString(); + } + + private InlineString BuildInlineString(ExcelCellText textObj) + { + var inline = new InlineString(); + foreach (var run in textObj.GetRuns()) + { + var runElement = new Run(); + // Всегда создаём элемент Text, даже если строка состоит из "\n" + runElement.Append(new Text(run.Text)); + + if (run.Format is { } fmt) + { + var rPr = new RunProperties(); + if (fmt.IsBold == true) rPr.Append(new Bold()); + if (fmt.IsItalic == true) rPr.Append(new Italic()); + if (fmt.Underline.HasValue) + { + rPr.Append(new Underline + { + Val = fmt.Underline.Value switch + { + UnderlineStyle.Single => UnderlineValues.Single, + UnderlineStyle.Double => UnderlineValues.Double, + UnderlineStyle.SingleAccounting => UnderlineValues.SingleAccounting, + UnderlineStyle.DoubleAccounting => UnderlineValues.DoubleAccounting, + _ => throw new NotImplementedException(), + } + }); + } + if (fmt.IsStrike == true) rPr.Append(new Strike()); + if (fmt.Color.HasValue) + { + var excelColor = fmt.Color.Value.ToExcel(); + rPr.Append(excelColor); + } + if (fmt.FontSize.HasValue) + rPr.Append(new FontSize { Val = fmt.FontSize.Value }); + if (!string.IsNullOrEmpty(fmt.FontFamily)) + rPr.Append(new RunFont { Val = fmt.FontFamily }); + if (fmt.Vertical.HasValue) + { + var vertAlign = new VerticalTextAlignment + { + Val = fmt.Vertical.Value == VerticalTextRunAlignment.Superscript + ? VerticalAlignmentRunValues.Superscript + : VerticalAlignmentRunValues.Subscript + }; + rPr.Append(vertAlign); + } + runElement.RunProperties = rPr; + } + inline.Append(runElement); + } + return inline; + } + + private MergeCells? GetMergeCells() => + _sheet.Worksheet.GetFirstChild(); + + private bool TryParseRangeReference(string reference, out ExcelRange range) + { + range = null!; + if (string.IsNullOrEmpty(reference)) return false; + string[] parts = reference.Split(':'); + if (parts.Length != 2) return false; + if (!CellAddressHelper.TryParseCellReference(parts[0] + "1", out uint row1, out uint col1)) return false; + if (!CellAddressHelper.TryParseCellReference(parts[1] + "1", out uint row2, out uint col2)) return false; + uint rowStart = Math.Min(row1, row2), rowEnd = Math.Max(row1, row2); + uint colStart = Math.Min(col1, col2), colEnd = Math.Max(col1, col2); + range = new ExcelRange(_writer, _sheet, rowStart, colStart, rowEnd, colEnd); + return true; + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/ExcelCellText.cs b/QWERTYkez.ExcelProcessor/Editors/ExcelCellText.cs new file mode 100644 index 0000000..a80f2b3 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/ExcelCellText.cs @@ -0,0 +1,270 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Внутренняя реализация для работы с богатым текстом ячейки. +/// Хранит коллекцию фрагментов . +/// Минимизирует аллокации, не использует рефлексию. +/// +internal sealed class ExcelCellText : ICellText +{ + private List? _runs; + + /// + public int Count => _runs?.Count ?? 0; + + /// + public IEnumerable GetRuns() + { + if (_runs is null) + return []; + // Возвращаем сам список, чтобы избежать копирования. + // Вызывающий не должен модифицировать коллекцию. + return _runs; + } + + /// + public IRun? GetRunAt(int index) + { + if (_runs is null || index < 0 || index >= _runs.Count) + return null; + return _runs[index]; + } + + /// + public bool TryGetRunAt(int index, out IRun run) + { + run = GetRunAt(index)!; + return run != null; + } + + /// + public IRun? First() + { + if (_runs is null || _runs.Count == 0) + return null; + return _runs[0]; + } + + /// + public bool TryGetFirst(out IRun run) + { + run = First()!; + return run != null; + } + + /// + public IRun? Last() + { + if (_runs is null || _runs.Count == 0) + return null; + return _runs[_runs.Count - 1]; + } + + /// + public bool TryGetLast(out IRun run) + { + run = Last()!; + return run != null; + } + + /// + public bool TryRemoveRun(IRun run) + { + if (run is null || _runs is null) + return false; + return _runs.Remove(run); + } + + /// + public bool TryRemoveRun(int index) + { + if (_runs is null || index < 0 || index >= _runs.Count) + return false; + _runs.RemoveAt(index); + return true; + } + + /// + public bool TryRemoveRun(int index, out IRun? removed) + { + removed = GetRunAt(index); + if (removed is null) + return false; + return TryRemoveRun(index); + } + + /// + public ICellText AddBreak() + { + // Добавляем символ переноса строки в последний существующий Run + if (_runs != null && _runs.Count > 0) + { + var lastRun = _runs[_runs.Count - 1]; + lastRun.Text += "\n"; + } + else + { + // Если нет ни одного Run, создаём новый с символом переноса + AddRun("\n", null); + } + return this; + } + + /// + public ICellText AddRun(string text, RunFormat? format = null) + { + if (string.IsNullOrEmpty(text)) + return this; + _runs ??= []; + var run = new ExcelRun { Text = text, Format = format }; + _runs.Add(run); + return this; + } + + /// + public ICellText AddRunBreak(string text, RunFormat? format = null) + { + AddRun(text, format); + AddBreak(); + return this; + } + + /// + public ICellText AddSubRun(string text, RunFormat? format = null) + { + var subFormat = format is { } fmt + ? new RunFormat + { + IsBold = fmt.IsBold, + IsItalic = fmt.IsItalic, + Underline = fmt.Underline, + IsStrike = fmt.IsStrike, + Color = fmt.Color, + FontSize = fmt.FontSize, + FontFamily = fmt.FontFamily, + Vertical = VerticalTextRunAlignment.Subscript + } + : new RunFormat { Vertical = VerticalTextRunAlignment.Subscript }; + return AddRun(text, subFormat); + } + + /// + public ICellText AddSupRun(string text, RunFormat? format = null) + { + var supFormat = format is { } fmt + ? new RunFormat + { + IsBold = fmt.IsBold, + IsItalic = fmt.IsItalic, + Underline = fmt.Underline, + IsStrike = fmt.IsStrike, + Color = fmt.Color, + FontSize = fmt.FontSize, + FontFamily = fmt.FontFamily, + Vertical = VerticalTextRunAlignment.Subscript + } + : new RunFormat { Vertical = VerticalTextRunAlignment.Superscript }; + return AddRun(text, supFormat); + } + + /// + public bool TryInsertRun(int index, string text, RunFormat? format = null) + { + if (index < 0 || string.IsNullOrEmpty(text)) + return false; + _runs ??= []; + if (index > _runs.Count) + return false; + var run = new ExcelRun { Text = text, Format = format }; + _runs.Insert(index, run); + return true; + } + + /// + public bool TryInsertRunBreak(int index, string text, RunFormat? format = null) + { + if (!TryInsertRun(index, text, format)) + return false; + // После вставленного run добавляем break на следующей позиции + AddBreak(); + // Сдвигаем? Просто добавляем break в конец – неверно. Break должен быть сразу после вставленного. + // Но AddBreak добавляет в конец. Нужно вставить break на index+1. + return TryInsertRun(index + 1, "\n", null); + } + + /// + public bool TryInsertSubRun(int index, string text, RunFormat? format = null) + { + var subFormat = format is { } fmt + ? new RunFormat + { + IsBold = fmt.IsBold, + IsItalic = fmt.IsItalic, + Underline = fmt.Underline, + IsStrike = fmt.IsStrike, + Color = fmt.Color, + FontSize = fmt.FontSize, + FontFamily = fmt.FontFamily, + Vertical = VerticalTextRunAlignment.Subscript + } + : new RunFormat { Vertical = VerticalTextRunAlignment.Subscript }; + return TryInsertRun(index, text, subFormat); + } + + /// + public bool TryInsertSupRun(int index, string text, RunFormat? format = null) + { + var supFormat = format is { } fmt + ? new RunFormat + { + IsBold = fmt.IsBold, + IsItalic = fmt.IsItalic, + Underline = fmt.Underline, + IsStrike = fmt.IsStrike, + Color = fmt.Color, + FontSize = fmt.FontSize, + FontFamily = fmt.FontFamily, + Vertical = VerticalTextRunAlignment.Subscript + } + : new RunFormat { Vertical = VerticalTextRunAlignment.Superscript }; + return TryInsertRun(index, text, supFormat); + } + + /// + public void ApplyFormatToAllRuns(RunFormat format) + { + if (_runs is null || _runs.Count == 0) + return; + foreach (var run in _runs) + { + if (run is ExcelRun xRun) + { + // Объединение форматов: ненулевые свойства overlay заменяют значения в base. + var baseFmt = xRun.Format ?? new RunFormat(); + xRun.Format = MergeRunFormat(baseFmt, format); + } + } + } + + /// + public void Clear() + { + _runs?.Clear(); + _runs = null; + } + + private static RunFormat MergeRunFormat(RunFormat baseFmt, RunFormat overlay) + { + return new RunFormat + { + IsBold = overlay.IsBold ?? baseFmt.IsBold, + IsItalic = overlay.IsItalic ?? baseFmt.IsItalic, + Underline = overlay.Underline ?? baseFmt.Underline, + IsStrike = overlay.IsStrike ?? baseFmt.IsStrike, + Color = overlay.Color ?? baseFmt.Color, + FontSize = overlay.FontSize ?? baseFmt.FontSize, + FontFamily = overlay.FontFamily ?? baseFmt.FontFamily, + Vertical = overlay.Vertical ?? baseFmt.Vertical + }; + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/ExcelColumn.cs b/QWERTYkez.ExcelProcessor/Editors/ExcelColumn.cs new file mode 100644 index 0000000..5ccbfc2 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/ExcelColumn.cs @@ -0,0 +1,502 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Внутренняя реализация . +/// +internal sealed class ExcelColumn : IColumn +{ + internal readonly ExcelWriter _writer; + internal readonly ExcelSheet _sheet; + internal uint _colIndex; + + internal ExcelColumn(ExcelWriter writer, ExcelSheet sheet, uint colIndex) + { + _writer = writer; + _sheet = sheet; + _colIndex = colIndex; + } + + public uint Index => _colIndex; + public string IndexLetter => NumberToColumnLetter(_colIndex); + + + + /// Устанавливает ширину столбца без создания объекта IColumn. + internal static void SetWidth(ExcelWriter writer, ExcelSheet sheet, uint colIndex, ColumnWidth width) + { + writer.ThrowIfDisposed(); + lock (writer._syncLock) + { + var col = GetOrCreateColumnElementInternal(sheet, colIndex); + + double widthInChars; + + if (width.IsCharacterUnit) + { + // Если ширина задана в символах, используем напрямую + widthInChars = width.CharacterValue; + } + else + { + double targetPoints = width.GetTargetPoints(); + widthInChars = writer.TryGetCalibrateCoeff(targetPoints, out var closestCw) + ? closestCw : targetPoints / ColumnWidth.DefaultPointsPerChar; + } + + + col.Width = widthInChars; + col.CustomWidth = true; + } + } + + /// Возвращает ширину столбца. + internal static ColumnWidth GetWidth(ExcelWriter writer, ExcelSheet sheet, uint colIndex) + { + writer.ThrowIfDisposed(); + lock (writer._syncLock) + { + var col = GetColumnElementInternal(sheet, colIndex); + if (col is Column column + && column.CustomWidth?.Value == true + && column.Width?.Value is { } wid) + return ColumnWidth.FromCharacters(wid); + return ColumnWidth.FromCharacters(8.43); // стандартная ширина + } + } + + // Вспомогательные внутренние методы (перенести существующую логику) + private static Column? GetColumnElementInternal(ExcelSheet sheet, uint colIndex) + { + var cols = sheet.Worksheet.GetFirstChild(); + if (cols == null) return null; + foreach (Column col in cols.Elements()) + { + if (col?.Min?.Value is { } min + && col?.Max?.Value is { } max + && min <= colIndex + && max >= colIndex) + return col; + } + return null; + } + + private static Column GetOrCreateColumnElementInternal(ExcelSheet sheet, uint colIndex) + { + var existing = GetColumnElementInternal(sheet, colIndex); + if (existing != null) return existing; + + var worksheet = sheet.Worksheet; + var cols = worksheet.GetFirstChild(); + if (cols == null) + { + cols = new Columns(); + worksheet.InsertAt(cols, 0); + } + var newCol = new Column + { + Min = colIndex, + Max = colIndex, + Width = 8.43, + CustomWidth = true + }; + cols.Append(newCol); + return newCol; + } + + + + public IColumn MoveTo(uint index) + { + if (index == _colIndex) return this; + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + // Копируем данные в новую позицию + CopyColumnData(_colIndex, index); + // Очищаем исходный столбец + ClearColumnData(_colIndex); + _colIndex = index; + } + return this; + } + + public IColumn MoveTo(string index) + { + uint idx = CellAddressHelper.ColumnLetterToIndex(index); + return MoveTo(idx); + } + + public ColumnWidth Width +{ + get + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var column = GetColumnElement(); + if (column is Column col && col.Width?.Value is { } val && col.CustomWidth?.Value == true) + return ColumnWidth.FromCharacters(val); + return ColumnWidth.FromCharacters(8.43); // стандартная ширина + } + } + set + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var col = GetOrCreateColumnElement(); + double widthInChars; + + if (value.IsCharacterUnit) + { + // Если ширина задана в символах, используем напрямую + widthInChars = value.CharacterValue; + } + else + { + double targetCm = value.GetTargetCentimeters(); + widthInChars = _writer.TryGetCalibrateCoeff(targetCm, out var closestCw) + ? closestCw : targetCm * (ColumnWidth.DefaultPointsPerChar / 28.3464566929); + } + + col.Width = widthInChars; + col.CustomWidth = true; + } + } +} + + public IColumn SetNumberFormat(NumberFormatPattern format) + { + if (format == null) return this; + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + int fmtId = GetOrCreateNumberFormatId(format); + // Применяем ко всем ячейкам столбца + var sheetData = _sheet.GetSheetData(); + foreach (var row in sheetData.Elements()) + { + var cell = FindCellInRow(row, _colIndex); + cell?.StyleIndex = (uint)fmtId; + } + } + return this; + } + + public ICell Cell(uint row) + { + return new ExcelCell(_writer, _sheet, row, _colIndex); + } + + public IColumn Cell(uint row, Action edit) + { + var cell = Cell(row); + edit(cell); + return this; + } + + public IColumn Cell(uint row, string value) + { + Cell(row).Set(value); return this; + } + + public IColumn Cell(uint row, bool value) + { + Cell(row).Set(value); return this; + } + + public IColumn Cell(uint row, string value, NumberFormatPattern? format = null) + { + Cell(row).Set(value, format); return this; + } + + public IColumn Cell(uint row, DateTime value, NumberFormatPattern? format = null) + { + Cell(row).Set(value, format); return this; + } + + public IColumn Cell(uint row, decimal value, NumberFormatPattern? format = null) + { + Cell(row).Set(value, format); return this; + } + + public IColumn Cell(uint row, double value, NumberFormatPattern? format = null) + { + Cell(row).Set(value, format); return this; + } + + public IColumn Cell(uint row, float value, NumberFormatPattern? format = null) + { + Cell(row).Set(value, format); return this; + } + + public IColumn Cell(uint row, int value, NumberFormatPattern? format = null) + { + Cell(row).Set(value, format); return this; + } + + public IColumn Cell(uint row, long value, NumberFormatPattern? format = null) + { + Cell(row).Set(value, format); return this; + } + + + + + + + public void ClearContents() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + ClearColumnData(_colIndex); + } + } + + public void ClearFormats() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var sheetData = _sheet.GetSheetData(); + foreach (var row in sheetData.Elements()) + { + var cell = FindCellInRow(row, _colIndex); + cell?.StyleIndex = null; + } + } + } + + public void Clear() + { + ClearContents(); + ClearFormats(); + } + + public void Remove() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + // Очищаем данные + ClearColumnData(_colIndex); + // Удаляем элемент Column, если он существует + DeleteColumnElement(); + } + } + + public IColumn CopyTo(uint index, out IColumn copiedColumn) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + CopyColumnData(_colIndex, index); + copiedColumn = new ExcelColumn(_writer, _sheet, index); + return this; + } + } + + public IColumn CopyTo(string index, out IColumn copiedColumn) + { + uint idx = CellAddressHelper.ColumnLetterToIndex(index); + return CopyTo(idx, out copiedColumn); + } + + // Вспомогательные методы + + private Column? GetColumnElement() + { + var cols = _sheet.Worksheet.GetFirstChild(); + if (cols == null) return null; + foreach (Column col in cols.Elements()) + { + if (col?.Min?.Value is { } min + && col?.Max?.Value is { } max + && min <= _colIndex + && max >= _colIndex) + return col; + } + return null; + } + + private Column GetOrCreateColumnElement() + { + var existing = GetColumnElement(); + if (existing != null) return existing; + + var worksheet = _sheet.Worksheet; + var cols = worksheet.GetFirstChild(); + if (cols == null) + { + cols = new Columns(); + worksheet.InsertAt(cols, 0); + } + + var newCol = new Column + { + Min = _colIndex, + Max = _colIndex, + Width = 8.43, + CustomWidth = true + }; + cols.Append(newCol); + return newCol; + } + + private void DeleteColumnElement() + { + var col = GetColumnElement(); + col?.Remove(); + } + + private void CopyColumnData(uint sourceCol, uint targetCol) + { + if (sourceCol == targetCol) return; + var sheetData = _sheet.GetSheetData(); + // Сначала удаляем существующие данные в целевом столбце (если нужно перезаписать) + ClearColumnData(targetCol); + // Копируем значения и форматы из sourceCol в targetCol + foreach (var row in sheetData.Elements()) + { + var sourceCell = FindCellInRow(row, sourceCol); + if (sourceCell != null) + { + var targetCell = FindCellInRow(row, targetCol); + if (targetCell == null) + { + targetCell = new Cell(); + // Вставляем в нужное место (по порядку столбцов) + InsertCellInRow(row, targetCell, targetCol); + } + // Копируем содержимое и стиль + targetCell.DataType = sourceCell.DataType; + targetCell.CellValue = sourceCell.CellValue?.CloneNode(true) as CellValue; + targetCell.CellFormula = sourceCell.CellFormula?.CloneNode(true) as CellFormula; + targetCell.StyleIndex = sourceCell.StyleIndex; + // Если есть InlineString, клонируем + if (sourceCell.InlineString != null) + targetCell.InlineString = (InlineString)sourceCell.InlineString.CloneNode(true); + } + } + // Копируем ширину столбца + var sourceColElem = GetColumnElementForIndex(sourceCol); + if (sourceColElem?.Width is { } width) + { + var targetColElem = GetOrCreateColumnElementForIndex(targetCol); + targetColElem.Width = width; + targetColElem.CustomWidth = sourceColElem.CustomWidth; + } + } + + private void ClearColumnData(uint col) + { + var sheetData = _sheet.GetSheetData(); + foreach (var row in sheetData.Elements().ToList()) + { + var cell = FindCellInRow(row, col); + cell?.Remove(); + } + } + + private Cell? FindCellInRow(Row row, uint colIndex) + { + foreach (var cell in row.Elements()) + { + string cellRef = cell.CellReference?.Value ?? string.Empty; + if (TryParseCellReference(cellRef, out uint _, out uint colIdx) && colIdx == colIndex) + return cell; + } + return null; + } + + private void InsertCellInRow(Row row, Cell cell, uint colIndex) + { + string newRef = NumberToColumnLetter(colIndex) + (row.RowIndex?.Value ?? 1).ToString(); + cell.CellReference = newRef; + // Вставляем в правильном порядке (по возрастанию столбцов) + bool inserted = false; + foreach (var existing in row.Elements().ToList()) + { + if (TryParseCellReference(existing.CellReference?.Value ?? string.Empty, out _, out uint existingCol) && + existingCol > colIndex) + { + existing.InsertBeforeSelf(cell); + inserted = true; + break; + } + } + if (!inserted) + row.Append(cell); + } + + private Column? GetColumnElementForIndex(uint col) + { + var cols = _sheet.Worksheet.GetFirstChild(); + if (cols == null) return null; + foreach (Column c in cols.Elements()) + { + if (c.Min?.Value is { } min + && c.Max?.Value is { } max + && min <= col + && max >= col) + return c; + } + return null; + } + + private Column GetOrCreateColumnElementForIndex(uint col) + { + var existing = GetColumnElementForIndex(col); + if (existing != null) return existing; + + var worksheet = _sheet.Worksheet; + var cols = worksheet.GetFirstChild(); + if (cols == null) + { + cols = new Columns(); + worksheet.InsertAt(cols, 0); + } + var newCol = new Column + { + Min = col, + Max = col, + Width = 8.43, + CustomWidth = true + }; + cols.Append(newCol); + return newCol; + } + + private int GetOrCreateNumberFormatId(NumberFormatPattern format) + { + return _writer.GetOrCreateCellFormatId(numberFormat: format); + } + + private static bool TryParseCellReference(string reference, out uint row, out uint col) + { + row = 0; col = 0; + if (string.IsNullOrEmpty(reference)) return false; + int i = 0; + while (i < reference.Length && char.IsLetter(reference[i])) i++; + if (i == 0) return false; + string colPart = reference.Substring(0, i); + string rowPart = reference.Substring(i); + if (!uint.TryParse(rowPart, out row)) return false; + col = CellAddressHelper.ColumnLetterToIndex(colPart); + return true; + } + + private static string NumberToColumnLetter(uint col) + { + if (col == 0) throw new ArgumentException("Column number must be > 0"); + string result = ""; + while (col > 0) + { + col--; + result = (char)('A' + (col % 26)) + result; + col /= 26; + } + return result; + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/ExcelRange.cs b/QWERTYkez.ExcelProcessor/Editors/ExcelRange.cs new file mode 100644 index 0000000..e78e922 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/ExcelRange.cs @@ -0,0 +1,886 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Внутренняя реализация . +/// +internal sealed class ExcelRange : IRange +{ + private readonly ExcelWriter _writer; + private readonly ExcelSheet _sheet; + private uint _rowStart, _rowEnd; + private uint _colStart, _colEnd; + + internal ExcelRange(ExcelWriter writer, ExcelSheet sheet, uint rowStart, uint colStart, uint rowEnd, uint colEnd) + { + _writer = writer; + _sheet = sheet; + _rowStart = rowStart; + _rowEnd = rowEnd; + _colStart = colStart; + _colEnd = colEnd; + } + + public uint RowStart => _rowStart; + public uint RowEnd => _rowEnd; + public uint ColStart => _colStart; + public uint ColEnd => _colEnd; + public string ColStartLetter => CellAddressHelper.ColumnIndexToLetter(_colStart); + public string ColEndLetter => CellAddressHelper.ColumnIndexToLetter(_colEnd); + public uint Rows => _rowEnd - _rowStart + 1; + public uint Cols => _colEnd - _colStart + 1; + + public bool IsMerged + { + get + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var mergeCells = GetMergeCells(); + if (mergeCells == null) return false; + foreach (var mergeCell in mergeCells.Elements()) + { + if (TryParseRangeReference(mergeCell.Reference?.Value ?? string.Empty, out var refRange)) + { + if (refRange._rowStart == _rowStart && refRange._rowEnd == _rowEnd && + refRange._colStart == _colStart && refRange._colEnd == _colEnd) + return true; + } + } + return false; + } + } + } + + + + + + + + /// + /// Перемещает текущий диапазон в новую позицию (как "вырезать-вставить"). + /// Исходный диапазон очищается, а текущий объект IRange перемещается на новое место. + /// + /// Номер строки для нового верхнего левого угла. + /// Номер столбца для нового верхнего левого угла. + /// Тот же объект IRange с новыми координатами. + public IRange MoveTo(uint newRow, uint newCol) + { + if (newRow == _rowStart && newCol == _colStart) return this; + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + uint newRowEnd = newRow + (_rowEnd - _rowStart); + uint newColEnd = newCol + (_colEnd - _colStart); + bool overlap = RangesOverlap(_rowStart, _colStart, _rowEnd, _colEnd, newRow, newCol, newRowEnd, newColEnd); + bool horizontalShift = (newRow == _rowStart); + bool verticalShift = (newCol == _colStart); + + if (!overlap) + { + CopyCells(_rowStart, _colStart, _rowEnd, _colEnd, newRow, newCol, CopyOrder.Any); + ClearRangeData(_rowStart, _colStart, _rowEnd, _colEnd); + } + else if (horizontalShift) + { + CopyOrder order = (newCol > _colStart) ? CopyOrder.RightToLeft : CopyOrder.LeftToRight; + CopyCells(_rowStart, _colStart, _rowEnd, _colEnd, newRow, newCol, order); + ClearRangeData(_rowStart, _colStart, _rowEnd, _colEnd); + } + else if (verticalShift) + { + CopyOrder order = (newRow > _rowStart) ? CopyOrder.BottomToTop : CopyOrder.TopToBottom; + CopyCells(_rowStart, _colStart, _rowEnd, _colEnd, newRow, newCol, order); + ClearRangeData(_rowStart, _colStart, _rowEnd, _colEnd); + } + else + { + // Диагональное перемещение – два последовательных сдвига + MoveTo(_rowStart, newCol); + MoveTo(newRow, _colStart); + return this; + } + + _rowStart = newRow; + _rowEnd = newRowEnd; + _colStart = newCol; + _colEnd = newColEnd; + return this; + } + } + + public IRange MoveTo(uint rowIndex, string colIndex) + { + return MoveTo(rowIndex, CellAddressHelper.ColumnLetterToIndex(colIndex)); + } + + private enum CopyOrder + { + Any, + LeftToRight, + RightToLeft, + TopToBottom, + BottomToTop + } + + /// Копирует ячейки из исходного диапазона в целевой, поддерживая различные порядки обхода. + private void CopyCells(uint srcRowStart, uint srcColStart, uint srcRowEnd, uint srcColEnd, + uint dstRowStart, uint dstColStart, CopyOrder order) + { + // Определяем все строки, которые могут понадобиться (исходные и целевые) + var rowIndices = new HashSet(); + for (uint r = srcRowStart; r <= srcRowEnd; r++) rowIndices.Add(r); + uint dstRowEnd = dstRowStart + (srcRowEnd - srcRowStart); + for (uint r = dstRowStart; r <= dstRowEnd; r++) rowIndices.Add(r); + var rowsDict = GetRowDictionary(rowIndices); + + int rowStep = 1, colStep = 1; + int rowStart = (int)srcRowStart, rowEnd = (int)srcRowEnd; + int colStart = (int)srcColStart, colEnd = (int)srcColEnd; + + if (order == CopyOrder.BottomToTop) + { + rowStep = -1; + rowStart = (int)srcRowEnd; + rowEnd = (int)srcRowStart; + } + if (order == CopyOrder.RightToLeft) + { + colStep = -1; + colStart = (int)srcColEnd; + colEnd = (int)srcColStart; + } + + for (int r = rowStart; (rowStep > 0 ? r <= rowEnd : r >= rowEnd); r += rowStep) + { + if (!rowsDict.TryGetValue((uint)r, out var srcRow)) + continue; + + for (int c = colStart; (colStep > 0 ? c <= colEnd : c >= colEnd); c += colStep) + { + Cell? srcCell = FindCellInRowFast(srcRow, (uint)c); + if (srcCell == null) continue; + + Cell cloned = (Cell)srcCell.CloneNode(true); + uint dstRow = dstRowStart + (uint)(r - srcRowStart); + uint dstCol = dstColStart + (uint)(c - srcColStart); + cloned.CellReference = $"{CellAddressHelper.ColumnIndexToLetter(dstCol)}{dstRow}"; + InsertCellAt(dstRow, dstCol, cloned); + } + } + } + + /// Строит словарь строк для указанных индексов строк. + private Dictionary GetRowDictionary(HashSet rowIndices) + { + var dict = new Dictionary(); + var sheetData = _sheet.GetSheetData(); + foreach (var row in sheetData.Elements()) + { + if (row.RowIndex?.Value is uint idx && rowIndices.Contains(idx)) + dict[idx] = row; + } + return dict; + } + + /// Быстрый поиск ячейки в строке (линейный, подходит для типичного количества ячеек в строке). + private Cell? FindCellInRowFast(Row row, uint colIndex) + { + foreach (var cell in row.Elements()) + { + if (CellAddressHelper.TryParseCellReference(cell.CellReference?.Value ?? string.Empty, out _, out uint col) && col == colIndex) + return cell; + } + return null; + } + + /// Проверяет, пересекаются ли два прямоугольных диапазона. + private static bool RangesOverlap(uint r1s, uint c1s, uint r1e, uint c1e, + uint r2s, uint c2s, uint r2e, uint c2e) + { + return !(r1e < r2s || r2e < r1s || c1e < c2s || c2e < c1s); + } + + /// Вставляет ячейку в указанную позицию, предварительно удаляя существующую. + private void InsertCellAt(uint row, uint col, Cell cell) + { + DeleteCellAt(row, col); + var sheetData = _sheet.GetSheetData(); + var rowElement = GetOrCreateRowElement(sheetData, row); + InsertCellInRow(rowElement, cell, col); + } + + /// Удаляет ячейку, если она существует. + private void DeleteCellAt(uint row, uint col) + { + var sheetData = _sheet.GetSheetData(); + var rowElement = FindRowElement(sheetData, row); + if (rowElement == null) return; + var cell = FindCellInRow(rowElement, col); + cell?.Remove(); + } + + /// Получает или создаёт строку с указанным индексом. + private Row GetOrCreateRowElement(SheetData sheetData, uint rowIndex) + { + var existing = FindRowElement(sheetData, rowIndex); + if (existing != null) return existing; + var newRow = new Row { RowIndex = rowIndex }; + InsertRowElement(sheetData, newRow, rowIndex); + return newRow; + } + + /// Вставляет ячейку в строку с сохранением порядка столбцов. + private void InsertCellInRow(Row row, Cell cell, uint colIndex) + { + string newRef = $"{CellAddressHelper.ColumnIndexToLetter(colIndex)}{row.RowIndex?.Value ?? 1}"; + cell.CellReference = newRef; + bool inserted = false; + foreach (var existing in row.Elements().ToList()) + { + if (CellAddressHelper.TryParseCellReference(existing.CellReference?.Value ?? string.Empty, out _, out uint existingCol) && + existingCol > colIndex) + { + existing.InsertBeforeSelf(cell); + inserted = true; + break; + } + } + if (!inserted) row.Append(cell); + } + + /// Очищает данные (содержимое) в указанном диапазоне. + private void ClearRangeData(uint rowStart, uint colStart, uint rowEnd, uint colEnd) + { + for (uint r = rowStart; r <= rowEnd; r++) + { + var row = FindRowElement(_sheet.GetSheetData(), r); + if (row == null) continue; + for (uint c = colStart; c <= colEnd; c++) + { + var cell = FindCellInRow(row, c); + cell?.Remove(); + } + } + } + + + + + + + + + public bool TryMerge() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + if (IsMerged) return false; + var worksheet = _sheet.Worksheet; + + var mergeCells = worksheet.GetFirstChild(); + if (mergeCells == null) + { + mergeCells = new MergeCells(); + worksheet.Append(mergeCells); + } + string refStr = $"{ColStartLetter}{RowStart}:{ColEndLetter}{RowEnd}"; + mergeCells.Append(new MergeCell { Reference = refStr }); + return true; + } + } + + public void Unmerge() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var mergeCells = GetMergeCells(); + if (mergeCells == null) return; + var toRemove = mergeCells.Elements() + .FirstOrDefault(mc => TryParseRangeReference(mc.Reference?.Value ?? string.Empty, out var rng) && + rng._rowStart == _rowStart && rng._rowEnd == _rowEnd && + rng._colStart == _colStart && rng._colEnd == _colEnd); + toRemove?.Remove(); + } + } + + public IRange? GetMergedRange() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var mergeCells = GetMergeCells(); + if (mergeCells == null) return null; + foreach (var mergeCell in mergeCells.Elements()) + { + if (TryParseRangeReference(mergeCell.Reference?.Value ?? string.Empty, out var refRange)) + { + if (refRange._rowStart >= _rowStart && refRange._rowEnd <= _rowEnd && + refRange._colStart >= _colStart && refRange._colEnd <= _colEnd) + return refRange; + } + } + return null; + } + } + + public bool TryGetMergedRange(IRange range) + { + var merged = GetMergedRange(); + if (merged == null) return false; + return merged.Equals(range); + } + + public IRange SetNumberFormat(NumberFormatPattern format) + { + if (format == null) return this; + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + int fmtId = GetOrCreateNumberFormatId(format); + for (uint r = _rowStart; r <= _rowEnd; r++) + { + for (uint c = _colStart; c <= _colEnd; c++) + { + var cell = GetCellInternal(r, c); + cell?.StyleIndex = (uint)fmtId; + } + } + } + return this; + } + + /// + public IRange SetCellAlign(CellAlign align) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + int styleIndex = _writer.GetOrCreateCellFormatId( + numberFormat: null, + font: null, + fill: null, + border: null, + align: align + ); + ApplyStyleToRange((uint)styleIndex); + return this; + } + } + + /// + public IRange SetCellBorder(CellBorder border) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + int styleIndex = _writer.GetOrCreateCellFormatId( + numberFormat: null, + font: null, + fill: null, + border: border, + align: null + ); + ApplyStyleToRange((uint)styleIndex); + return this; + } + } + + /// + public IRange SetCellFill(CellFill fill) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + int styleIndex = _writer.GetOrCreateCellFormatId( + numberFormat: null, + font: null, + fill: fill, + border: null, + align: null + ); + ApplyStyleToRange((uint)styleIndex); + return this; + } + } + + /// + public IRange SetCellFont(CellFont font) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + int styleIndex = _writer.GetOrCreateCellFormatId( + numberFormat: null, + font: font, + fill: null, + border: null, + align: null + ); + ApplyStyleToRange((uint)styleIndex); + return this; + } + } + + /// + /// Применяет стиль ко всем ячейкам диапазона. + /// + /// Индекс стиля для применения. + /// Если true, создаёт недостающие ячейки (по умолчанию true). + private void ApplyStyleToRange(uint styleIndex, bool createIfMissing = true) + { + for (uint row = _rowStart; row <= _rowEnd; row++) + { + for (uint col = _colStart; col <= _colEnd; col++) + { + var cell = GetCellInternal(row, col); + if (cell != null) + { + cell.StyleIndex = styleIndex; + } + else if (createIfMissing) + { + var newCell = new Cell + { + CellReference = $"{CellAddressHelper.ColumnIndexToLetter(col)}{row}", + StyleIndex = styleIndex + }; + InsertCellAt(row, col, newCell); + } + } + } + } + + public IEnumerable Cells + { + get + { + for (uint r = _rowStart; r <= _rowEnd; r++) + for (uint c = _colStart; c <= _colEnd; c++) + yield return new ExcelCell(_writer, _sheet, r, c); + } + } + + public bool GetSubCell(uint row, uint col, out ICell cell) + { + uint globalRow = _rowStart + row - 1; + uint globalCol = _colStart + col - 1; + if (globalRow > _rowEnd || globalCol > _colEnd) + { + cell = default!; + return false; + } + cell = new ExcelCell(_writer, _sheet, globalRow, globalCol); + return true; + } + + public bool GetSubCell(uint row, string col, out ICell cell) + { + uint colIndex = CellAddressHelper.ColumnLetterToIndex(col); + return GetSubCell(row, colIndex, out cell); + } + + public bool TryEditSubCell(uint row, uint col, Action edit) + { + if (GetSubCell(row, col, out var cell)) + { + edit(cell); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, uint col, string value) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, uint col, bool value) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, uint col, string formula, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(formula, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, uint col, DateTime value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, uint col, decimal value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, uint col, double value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, uint col, float value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, uint col, int value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, uint col, long value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + + + public bool TryEditSubCell(uint row, string col, Action edit) + { + if (GetSubCell(row, col, out var cell)) + { + edit(cell); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, string col, string value) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, string col, bool value) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, string col, string formula, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(formula, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, string col, DateTime value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, string col, decimal value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, string col, double value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, string col, float value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, string col, int value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + public bool TryEditSubCell(uint row, string col, long value, NumberFormatPattern? format = null) + { + if (GetSubCell(row, col, out var cell)) + { + cell.Set(value, format); + return true; + } + return false; + } + + + + public void ClearContents() + { + ClearRangeData(_rowStart, _colStart, _rowEnd, _colEnd); + } + + public void ClearFormats() + { + for (uint r = _rowStart; r <= _rowEnd; r++) + for (uint c = _colStart; c <= _colEnd; c++) + { + var cell = GetCellInternal(r, c); + cell?.StyleIndex = null; + } + } + + public void Clear() + { + ClearContents(); + ClearFormats(); + } + + public IRange CopyTo(uint rowIndex, uint colIndex, out IRange copiedRange) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + CopyData(_rowStart, _colStart, _rowEnd, _colEnd, rowIndex, colIndex); + copiedRange = new ExcelRange(_writer, _sheet, rowIndex, colIndex, rowIndex + Rows - 1, colIndex + Cols - 1); + return this; + } + } + + public IRange CopyTo(uint rowIndex, string colIndex, out IRange copiedRange) + { + uint col = CellAddressHelper.ColumnLetterToIndex(colIndex); + return CopyTo(rowIndex, col, out copiedRange); + } + + // Вспомогательные методы + + private Cell? GetCellInternal(uint row, uint col) + { + var sheetData = _sheet.GetSheetData(); + var rowElement = FindRowElement(sheetData, row); + if (rowElement == null) return null; + return FindCellInRow(rowElement, col); + } + + private void CopyData(uint srcRowStart, uint srcColStart, uint srcRowEnd, uint srcColEnd, uint dstRowStart, uint dstColStart) + { + // Сохраняем все значения и форматы из исходного диапазона + var cellsData = new List<(uint row, uint col, Cell cell)>(); + for (uint r = srcRowStart; r <= srcRowEnd; r++) + { + for (uint c = srcColStart; c <= srcColEnd; c++) + { + var cell = GetCellInternal(r, c); + if (cell != null) + { + var cloned = (Cell)cell.CloneNode(true); + cellsData.Add((r - srcRowStart + dstRowStart, c - srcColStart + dstColStart, cloned)); + } + } + } + // Вставляем в новый диапазон + foreach (var (row, col, clonedCell) in cellsData) + { + InsertCellAt(row, col, clonedCell); + } + // Копируем ширины столбцов + for (uint c = srcColStart; c <= srcColEnd; c++) + { + var srcCol = GetColumnElementForIndex(c); + if (srcCol?.Width is { } width) + { + uint targetCol = c - srcColStart + dstColStart; + var dstCol = GetOrCreateColumnElementForIndex(targetCol); + dstCol.Width = width; + dstCol.CustomWidth = srcCol.CustomWidth; + } + } + // Копируем высоты строк + for (uint r = srcRowStart; r <= srcRowEnd; r++) + { + var srcRow = FindRowElement(_sheet.GetSheetData(), r); + if (srcRow is { } row + && row.Height?.Value is { } hei + && row.CustomHeight?.Value == true) + { + uint targetRow = r - srcRowStart + dstRowStart; + var dstRow = GetOrCreateRowElement(targetRow); + dstRow.Height = hei; + dstRow.CustomHeight = true; + } + } + } + + private Row GetOrCreateRowElement(uint rowIndex) + { + var existing = FindRowElement(_sheet.GetSheetData(), rowIndex); + if (existing != null) return existing; + var newRow = new Row { RowIndex = rowIndex }; + InsertRowElement(_sheet.GetSheetData(), newRow, rowIndex); + return newRow; + } + + private static Row? FindRowElement(SheetData sheetData, uint rowIndex) + { + foreach (var row in sheetData.Elements()) + if (row.RowIndex?.Value == rowIndex) return row; + return null; + } + + private static void InsertRowElement(SheetData sheetData, Row row, uint rowIndex) + { + bool inserted = false; + foreach (var existing in sheetData.Elements().ToList()) + { + if (existing.RowIndex?.Value > rowIndex) + { + existing.InsertBeforeSelf(row); + inserted = true; + break; + } + } + if (!inserted) sheetData.Append(row); + } + + private Cell? FindCellInRow(Row row, uint colIndex) + { + foreach (var cell in row.Elements()) + { + if (CellAddressHelper.TryParseCellReference(cell.CellReference?.Value ?? string.Empty, out _, out uint col) && col == colIndex) + return cell; + } + return null; + } + + private Column? GetColumnElementForIndex(uint col) + { + var worksheet = _sheet.Worksheet; + var cols = worksheet.GetFirstChild(); + if (cols == null) return null; + foreach (Column c in cols.Elements()) + if (c.Min?.Value is { } min + && c.Max?.Value is { } max + && min <= col + && max >= col) + return c; + return null; + } + + private Column GetOrCreateColumnElementForIndex(uint col) + { + var existing = GetColumnElementForIndex(col); + if (existing != null) return existing; + var worksheet = _sheet.Worksheet; + var cols = worksheet.GetFirstChild(); + if (cols == null) + { + cols = new Columns(); + worksheet.InsertAt(cols, 0); + } + var newCol = new Column + { + Min = col, + Max = col, + Width = 8.43, + CustomWidth = true + }; + cols.Append(newCol); + return newCol; + } + + private MergeCells? GetMergeCells() => + _sheet.Worksheet.GetFirstChild(); + + private bool TryParseRangeReference(string reference, out ExcelRange range) + { + range = null!; + if (string.IsNullOrEmpty(reference)) return false; + string[] parts = reference.Split(':'); + if (parts.Length != 2) return false; + if (!CellAddressHelper.TryParseCellReference(parts[0] + "1", out uint row1, out uint col1)) return false; + if (!CellAddressHelper.TryParseCellReference(parts[1] + "1", out uint row2, out uint col2)) return false; + uint rowStart = Math.Min(row1, row2), rowEnd = Math.Max(row1, row2); + uint colStart = Math.Min(col1, col2), colEnd = Math.Max(col1, col2); + range = new ExcelRange(_writer, _sheet, rowStart, colStart, rowEnd, colEnd); + return true; + } + + // создаёт стиль только с числовым форматом + private int GetOrCreateNumberFormatId(NumberFormatPattern format) + { + return _writer.GetOrCreateCellFormatId(numberFormat: format); + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/ExcelRow.cs b/QWERTYkez.ExcelProcessor/Editors/ExcelRow.cs new file mode 100644 index 0000000..6dea9b5 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/ExcelRow.cs @@ -0,0 +1,393 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Внутренняя реализация . +/// +internal sealed class ExcelRow : IRow +{ + private readonly ExcelWriter _writer; + private readonly ExcelSheet _sheet; + private uint _rowIndex; + + internal ExcelRow(ExcelWriter writer, ExcelSheet sheet, uint rowIndex) + { + _writer = writer; + _sheet = sheet; + _rowIndex = rowIndex; + } + + /// + public uint Index => _rowIndex; + + + + /// Устанавливает высоту строки без создания объекта IRow. + internal static void SetHeight(ExcelWriter writer, ExcelSheet sheet, uint rowIndex, RowHeight height) + { + writer.ThrowIfDisposed(); + lock (writer._syncLock) + { + var row = GetOrCreateRowElementInternal(sheet, rowIndex); + row.Height = height.Points; + row.CustomHeight = true; + } + } + + /// Возвращает высоту строки. + internal static RowHeight GetHeight(ExcelWriter writer, ExcelSheet sheet, uint rowIndex) + { + writer.ThrowIfDisposed(); + lock (writer._syncLock) + { + var row = GetRowElementInternal(sheet, rowIndex); + if (row is { } r && r.CustomHeight?.HasValue == true && r.Height?.Value is { } hei) + return RowHeight.FromPoints(hei); + return RowHeight.FromPoints(15.0); // стандартная высота + } + } + + // Вспомогательные методы + private static Row? GetRowElementInternal(ExcelSheet sheet, uint rowIndex) + { + var sheetData = sheet.GetSheetData(); + foreach (var row in sheetData.Elements()) + if (row.RowIndex?.Value == rowIndex) return row; + return null; + } + + private static Row GetOrCreateRowElementInternal(ExcelSheet sheet, uint rowIndex) + { + var existing = GetRowElementInternal(sheet, rowIndex); + if (existing != null) return existing; + + var sheetData = sheet.GetSheetData(); + var newRow = new Row { RowIndex = rowIndex }; + InsertRowElementInternal(sheetData, newRow, rowIndex); + return newRow; + } + + private static void InsertRowElementInternal(SheetData sheetData, Row row, uint rowIndex) + { + bool inserted = false; + foreach (var existing in sheetData.Elements().ToList()) + { + if (existing.RowIndex?.Value > rowIndex) + { + existing.InsertBeforeSelf(row); + inserted = true; + break; + } + } + if (!inserted) sheetData.Append(row); + } + + + + /// + public IRow MoveTo(uint index) + { + if (index == _rowIndex) return this; + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var sheetData = _sheet.GetSheetData(); + // Находим элемент строки + var rowElement = FindRowElement(sheetData, _rowIndex); + if (rowElement == null) + { + // Пустая строка — просто меняем индекс + _rowIndex = index; + return this; + } + // Удаляем из текущей позиции + rowElement.Remove(); + // Вставляем в новую позицию + InsertRowElement(sheetData, rowElement, index); + // Обновляем RowIndex в элементе + rowElement.RowIndex = index; + _rowIndex = index; + } + return this; + } + + /// + public RowHeight Height + { + get + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var rowElement = GetOrCreateRowElement(); + if (rowElement.Height?.Value is double h && rowElement.CustomHeight?.Value == true) + return RowHeight.FromPoints(h); + // Стандартная высота, если не задана (обычно 15 пунктов) + return RowHeight.FromPoints(15.0); + } + } + set + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var rowElement = GetOrCreateRowElement(); + rowElement.Height = value.Points; + rowElement.CustomHeight = true; + } + } + } + + /// + public IRow SetNumberFormat(NumberFormatPattern format) + { + if (format == null) return this; + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + // Находим все ячейки в этой строке и устанавливаем формат + var sheetData = _sheet.GetSheetData(); + var rowElement = FindRowElement(sheetData, _rowIndex); + if (rowElement == null) return this; + int formatIndex = GetOrCreateNumberFormatId(format); + foreach (var cell in rowElement.Elements()) + cell.StyleIndex = (uint)formatIndex; + } + return this; + } + + /// + public ICell Cell(uint col) => new ExcelCell(_writer, _sheet, _rowIndex, col); + + /// + public IRow Cell(uint col, Action edit) + { + edit(Cell(col)); return this; + } + + public IRow Cell(uint col, string value) + { + Cell(col).Set(value); return this; + } + + public IRow Cell(uint col, bool value) + { + Cell(col).Set(value); return this; + } + + public IRow Cell(uint col, string value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(uint col, DateTime value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(uint col, decimal value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(uint col, double value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(uint col, float value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(uint col, int value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(uint col, long value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(string col, Action edit) + { + edit(Cell(col)); return this; + } + + public IRow Cell(string col, string value) + { + Cell(col).Set(value); return this; + } + + public IRow Cell(string col, bool value) + { + Cell(col).Set(value); return this; + } + + public IRow Cell(string col, string value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(string col, DateTime value, NumberFormatPattern? format = null) + { + throw new NotImplementedException(); + } + + public IRow Cell(string col, decimal value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(string col, double value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(string col, float value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(string col, int value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + public IRow Cell(string col, long value, NumberFormatPattern? format = null) + { + Cell(col).Set(value, format); return this; + } + + + + + /// + public ICell Cell(string col) => Cell(CellAddressHelper.ColumnLetterToIndex(col)); + + /// + public IRow EditCell(string col, Action edit) + { + edit(Cell(col)); return this; + } + + /// + public void ClearContents() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var rowElement = FindRowElement(_sheet.GetSheetData(), _rowIndex); + if (rowElement == null) return; + foreach (var cell in rowElement.Elements().ToList()) + { + cell.Remove(); + } + } + } + + /// + public void ClearFormats() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var rowElement = FindRowElement(_sheet.GetSheetData(), _rowIndex); + if (rowElement == null) return; + foreach (var cell in rowElement.Elements()) + { + cell.StyleIndex = null; + } + } + } + + /// + public void Clear() + { + ClearContents(); + ClearFormats(); + } + + /// + public void Remove() + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var sheetData = _sheet.GetSheetData(); + var rowElement = FindRowElement(sheetData, _rowIndex); + rowElement?.Remove(); + // Сдвиг индексов последующих строк вниз в Open XML не требуется, т.к. они имеют свои RowIndex. + // Но при последующем доступе по индексам нужно будет корректировать. + } + } + + /// + public IRow CopyTo(uint index, out IRow copiedRow) + { + _writer.ThrowIfDisposed(); + lock (_writer._syncLock) + { + var sourceRow = FindRowElement(_sheet.GetSheetData(), _rowIndex); + if (sourceRow == null) + { + copiedRow = new ExcelRow(_writer, _sheet, index); + return this; + } + // Клонируем элемент строки + var clonedRow = (Row)sourceRow.CloneNode(true); + clonedRow.RowIndex = index; + // Вставляем в новую позицию + var sheetData = _sheet.GetSheetData(); + InsertRowElement(sheetData, clonedRow, index); + copiedRow = new ExcelRow(_writer, _sheet, index); + return this; + } + } + + // Вспомогательные методы + + private Row GetOrCreateRowElement() + { + var sheetData = _sheet.GetSheetData(); + var existing = FindRowElement(sheetData, _rowIndex); + if (existing != null) return existing; + var newRow = new Row { RowIndex = _rowIndex }; + InsertRowElement(sheetData, newRow, _rowIndex); + return newRow; + } + + private static Row? FindRowElement(SheetData sheetData, uint rowIndex) + { + // Поиск по атрибуту RowIndex. В Open XML строки могут идти не по порядку. + foreach (var row in sheetData.Elements()) + { + if (row.RowIndex?.Value == rowIndex) + return row; + } + return null; + } + + private static void InsertRowElement(SheetData sheetData, Row row, uint rowIndex) + { + // Вставка с сохранением сортировки по RowIndex + bool inserted = false; + foreach (var existing in sheetData.Elements().ToList()) + { + if (existing.RowIndex?.Value > rowIndex) + { + existing.InsertBeforeSelf(row); + inserted = true; + break; + } + } + if (!inserted) + sheetData.Append(row); + } + + private int GetOrCreateNumberFormatId(NumberFormatPattern format) + { + // Создаём стиль, содержащий только числовой формат, и возвращаем его индекс + return _writer.GetOrCreateCellFormatId(numberFormat: format); + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/ExcelRun.cs b/QWERTYkez.ExcelProcessor/Editors/ExcelRun.cs new file mode 100644 index 0000000..2bfbc8c --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/ExcelRun.cs @@ -0,0 +1,25 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Внутренняя реализация для хранения форматированного фрагмента текста. +/// Минимизирует аллокации, не использует рефлексию, все поля доступны напрямую внутри сборки. +/// +internal sealed class ExcelRun : IRun +{ + private string _text = string.Empty; + private RunFormat? _format; + + /// + public string Text + { + get => _text; + set => _text = value ?? string.Empty; + } + + /// + public RunFormat? Format + { + get => _format; + set => _format = value; + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/ExcelSheet.cs b/QWERTYkez.ExcelProcessor/Editors/ExcelSheet.cs new file mode 100644 index 0000000..4d4810d --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/ExcelSheet.cs @@ -0,0 +1,277 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Внутренняя реализация . +/// +internal sealed class ExcelSheet : ISheet +{ + internal readonly ExcelWriter Book; + internal readonly Sheet SheetElement; + internal readonly Worksheet Worksheet; + internal readonly uint SheetId; + + internal ExcelSheet(ExcelWriter book, Sheet sheetElement, uint sheetId) + { + Book = book; + SheetElement = sheetElement; + SheetId = sheetId; + + string partId = SheetElement.Id?.Value ?? string.Empty; + if (string.IsNullOrEmpty(partId)) + throw new InvalidOperationException("Sheet has no relationship ID"); + if (Book._doc.WorkbookPart?.GetPartById(partId) is not WorksheetPart part) + throw new InvalidOperationException("WorksheetPart not found"); + if (part.Worksheet is not Worksheet worksheet) + throw new InvalidOperationException("WorksheetPart not found"); + Worksheet = worksheet; + } + + public int Index => (int)SheetId; + public string Name => SheetElement.Name?.Value ?? string.Empty; + + public bool TrySetName(string name) + { + if (string.IsNullOrEmpty(name)) return false; + Book.ThrowIfDisposed(); + SheetElement.Name = name; + return true; + } + + public IRow Row(uint row) => new ExcelRow(Book, this, row); + public ISheet Row(uint row, Action edit) + { + edit(Row(row)); + return this; + } + + public IColumn Col(uint col) => new ExcelColumn(Book, this, col); + public ISheet Col(uint col, Action edit) + { + edit(Col(col)); + return this; + } + + public IColumn Col(string col) => Col(CellAddressHelper.ColumnLetterToIndex(col)); + public ISheet Col(string col, Action edit) + { + edit(Col(col)); + return this; + } + + public ICell Cell(uint row, uint col) => new ExcelCell(Book, this, row, col); + public ISheet Cell(uint row, uint col, Action edit) + { + edit(Cell(row, col)); + return this; + } + + public ISheet Cell(uint row, uint col, string value) + { + Cell(row, col).Set(value); return this; + } + + public ISheet Cell(uint row, uint col, bool value) + { + Cell(row, col).Set(value); return this; + } + + public ISheet Cell(uint row, uint col, string formula, NumberFormatPattern? format = null) + { + Cell(row, col).Set(formula, format); return this; + } + + public ISheet Cell(uint row, uint col, DateTime value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public ISheet Cell(uint row, uint col, decimal value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public ISheet Cell(uint row, uint col, double value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public ISheet Cell(uint row, uint col, float value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public ISheet Cell(uint row, uint col, int value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public ISheet Cell(uint row, uint col, long value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public ICell Cell(uint row, string col) => Cell(row, CellAddressHelper.ColumnLetterToIndex(col)); + public ISheet Cell(uint row, string col, Action edit) + { + edit(Cell(row, col)); + return this; + } + + public ISheet Cell(uint row, string col, string value) + { + Cell(row, col).Set(value); return this; + } + + public ISheet Cell(uint row, string col, bool value) + { + Cell(row, col).Set(value); return this; + } + + public ISheet Cell(uint row, string col, string formula, NumberFormatPattern? format = null) + { + Cell(row, col).Set(formula, format); return this; + } + + public ISheet Cell(uint row, string col, DateTime value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public ISheet Cell(uint row, string col, decimal value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public ISheet Cell(uint row, string col, double value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public ISheet Cell(uint row, string col, float value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public ISheet Cell(uint row, string col, int value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public ISheet Cell(uint row, string col, long value, NumberFormatPattern? format = null) + { + Cell(row, col).Set(value, format); return this; + } + + public void ClearContents() + { + var sheetData = GetSheetData(); + foreach (var row in sheetData.Elements().ToList()) + { + foreach (var cell in row.Elements().ToList()) + cell.Remove(); + } + } + + public void ClearFormats() + { + var sheetData = GetSheetData(); + foreach (var row in sheetData.Elements()) + { + foreach (var cell in row.Elements()) + cell.StyleIndex = null; + } + } + + public void Clear() + { + ClearContents(); + ClearFormats(); + } + + public void Remove() => Book.TryRemoveSheet(this); + + // Вспомогательные методы + + internal SheetData GetSheetData() + { + var worksheet = Worksheet; + var sheetData = worksheet.GetFirstChild(); + if (sheetData == null) + { + sheetData = new SheetData(); + worksheet.Append(sheetData); + } + return sheetData; + } + + + + + #region Range Operations + + /// + public IRange RangeByIndexes(uint startRow, uint startCol, uint endRow, uint endCol) + { + return new ExcelRange(Book, this, startRow, startCol, endRow, endCol); + } + + /// + public IRange RangeByIndexes(uint startRow, string startCol, uint endRow, string endCol) + { + uint startColIdx = CellAddressHelper.ColumnLetterToIndex(startCol); + uint endColIdx = CellAddressHelper.ColumnLetterToIndex(endCol); + return new ExcelRange(Book, this, startRow, startColIdx, endRow, endColIdx); + } + + /// + public ISheet RangeByIndexes(uint startRow, uint startCol, uint endRow, uint endCol, Action edit) + { + var range = RangeByIndexes(startRow, startCol, endRow, endCol); + edit(range); + return this; + } + + /// + public ISheet RangeByIndexes(uint startRow, string startCol, uint endRow, string endCol, Action edit) + { + var range = RangeByIndexes(startRow, startCol, endRow, endCol); + edit(range); + return this; + } + + /// + public IRange RangeByLength(uint startRow, uint startCol, uint rows, uint cols) + { + if (rows == 0 || cols == 0) + throw new ArgumentException("Rows and columns must be greater than 0"); + uint endRow = startRow + rows - 1; + uint endCol = startCol + cols - 1; + return new ExcelRange(Book, this, startRow, startCol, endRow, endCol); + } + + /// + public IRange RangeByLength(uint startRow, string startCol, uint rows, uint cols) + { + uint startColIdx = CellAddressHelper.ColumnLetterToIndex(startCol); + return RangeByLength(startRow, startColIdx, rows, cols); + } + + /// + public ISheet RangeByLength(uint startRow, uint startCol, uint rows, uint cols, Action edit) + { + var range = RangeByLength(startRow, startCol, rows, cols); + edit(range); + return this; + } + + /// + public ISheet RangeByLength(uint startRow, string startCol, uint rows, uint cols, Action edit) + { + var range = RangeByLength(startRow, startCol, rows, cols); + edit(range); + return this; + } + + #endregion + +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/Interfaces.cs b/QWERTYkez.ExcelProcessor/Editors/Interfaces.cs new file mode 100644 index 0000000..e61bbcc --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/Interfaces.cs @@ -0,0 +1,817 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// Представляет книгу Excel +public interface IBook +{ + /// Возвращает список всех листов в книге. + IReadOnlyList GetSheets(); + + /// Возвращает лист по имени или null, если лист не найден. + /// Имя листа (регистр учитывается). + ISheet? Sheet(string name); + + /// Пытается получить лист по имени. + /// Имя листа. + /// Найденный лист или null. + /// true, если лист найден, иначе false. + bool TryGetSheet(string name, out ISheet sheet); + + /// Пытается добавить новый лист с указанным именем и выполняет его настройку. + /// Имя нового листа (должно быть уникальным в книге). + /// Действие для редактирования листа (может быть null). + /// true, если лист успешно создан, иначе false (например, имя уже существует). + bool TryAddSheet(string name, Action? edit = null); + + /// Удаляет лист по имени. + /// Имя удаляемого листа. + /// true, если лист существовал и был удалён, иначе false. + bool TryRemoveSheet(string name); + + /// Удаляет лист. + /// Удаляемый лист. + /// true, если лист был удалён, иначе false. + bool TryRemoveSheet(ISheet sheet); + + /// Возвращает список всех числовых форматов, определённых в книге (встроенные и пользовательские). + IReadOnlyList GetNumberFormats(); + + /// Создаёт новый пользовательский числовой формат (если формат с таким кодом уже существует, может вернуть существующий). + /// Код формата (например, "# ##0,00"). + /// Объект формата, привязанный к книге. + NumberFormatPattern CreateNumberFormat(string format); +} + +/// Представляет лист Excel +public interface ISheet +{ + /// Индекс листа в книге (начиная с 1). + int Index { get; } + + /// Имя листа (уникальное в книге). + string Name { get; } + + /// Пытается изменить имя листа. + /// Новое имя (не должно совпадать с существующими). + /// true, если переименование выполнено, иначе false. + bool TrySetName(string name); + + /// Возвращает строку по её индексу (начиная с 1). Если строка отсутствует, создаёт пустую. + IRow Row(uint row); + + /// Редактирует строку, применяя делегат, и возвращает текущий лист (fluent). + /// Индекс строки (начиная с 1). + /// Действие над строкой. + ISheet Row(uint row, Action edit); + + /// Возвращает столбец по его индексу (начиная с 1). Если столбец отсутствует, создаёт пустой. + IColumn Col(uint col); + + /// Редактирует столбец по индексу. + ISheet Col(uint col, Action edit); + + /// Возвращает столбец по буквенному обозначению (например, "A", "AB"). + IColumn Col(string col); + + /// Редактирует столбец по буквенному обозначению. + ISheet Col(string col, Action edit); + + /// Возвращает диапазон ячеек по начальным и конечным индексам строк и столбцов. + IRange RangeByIndexes(uint startRow, uint startCol, uint endRow, uint endCol); + + /// Возвращает диапазон ячеек по начальным и конечным координатам с буквенным обозначением столбцов. + IRange RangeByIndexes(uint startRow, string startCol, uint endRow, string endCol); + + /// Редактирует диапазон, заданный начальными и конечными индексами. + ISheet RangeByIndexes(uint startRow, uint startCol, uint endRow, uint endCol, Action edit); + + /// Редактирует диапазон, заданный начальными и конечными координатами с буквенными столбцами. + ISheet RangeByIndexes(uint startRow, string startCol, uint endRow, string endCol, Action edit); + + /// Возвращает диапазон, начиная с указанной ячейки, заданной размером (строки x столбцы). + IRange RangeByLength(uint startRow, uint startCol, uint rows, uint cols); + + /// Возвращает диапазон по начальной ячейке и размеру с буквенным обозначением столбца. + IRange RangeByLength(uint startRow, string startCol, uint rows, uint cols); + + /// Редактирует диапазон, заданный начальной ячейкой и размером. + ISheet RangeByLength(uint startRow, uint startCol, uint rows, uint cols, Action edit); + + /// Редактирует диапазон, заданный начальной ячейкой и размером (буква столбца). + ISheet RangeByLength(uint startRow, string startCol, uint rows, uint cols, Action edit); + + /// Возвращает ячейку по номеру строки и столбца (оба начиная с 1). + ICell Cell(uint row, uint col); + + /// Редактирует ячейку по строке и столбцу. + ISheet Cell(uint row, uint col, Action edit); + + /// Устанавливает строковое значение в ячейку. + ISheet Cell(uint row, uint col, string value); + + /// Устанавливает логическое значение в ячейку. + ISheet Cell(uint row, uint col, bool value); + + /// Устанавливает формулу в ячейку с указанным числовым форматом. + ISheet Cell(uint row, uint col, string formula, NumberFormatPattern? format = null); + + /// Устанавливает дату в ячейку с указанным числовым форматом. + ISheet Cell(uint row, uint col, DateTime value, NumberFormatPattern? format = null); + + /// Устанавливает десятичное число в ячейку с указанным числовым форматом. + ISheet Cell(uint row, uint col, decimal value, NumberFormatPattern? format = null); + + /// Устанавливает число двойной точности в ячейку с указанным числовым форматом. + ISheet Cell(uint row, uint col, double value, NumberFormatPattern? format = null); + + /// Устанавливает число с плавающей точкой в ячейку с указанным числовым форматом. + ISheet Cell(uint row, uint col, float value, NumberFormatPattern? format = null); + + /// Устанавливает целое число в ячейку с указанным числовым форматом. + ISheet Cell(uint row, uint col, int value, NumberFormatPattern? format = null); + + /// Устанавливает длинное целое число в ячейку с указанным числовым форматом. + ISheet Cell(uint row, uint col, long value, NumberFormatPattern? format = null); + + /// Возвращает ячейку по строке и букве столбца (например, 1, "A"). + ICell Cell(uint row, string col); + + /// Редактирует ячейку по строке и букве столбца. + ISheet Cell(uint row, string col, Action edit); + + /// Устанавливает строковое значение в ячейку по адресу (строка, буква столбца). + ISheet Cell(uint row, string col, string value); + + /// Устанавливает логическое значение в ячейку по адресу (строка, буква столбца). + ISheet Cell(uint row, string col, bool value); + + /// Устанавливает формулу в ячейку с числовым форматом по адресу (строка, буква столбца). + ISheet Cell(uint row, string col, string formula, NumberFormatPattern? format = null); + + /// Устанавливает дату в ячейку по адресу (строка, буква столбца). + ISheet Cell(uint row, string col, DateTime value, NumberFormatPattern? format = null); + + /// Устанавливает десятичное число в ячейку по адресу (строка, буква столбца). + ISheet Cell(uint row, string col, decimal value, NumberFormatPattern? format = null); + + /// Устанавливает число двойной точности в ячейку по адресу (строка, буква столбца). + ISheet Cell(uint row, string col, double value, NumberFormatPattern? format = null); + + /// Устанавливает число с плавающей точкой в ячейку по адресу (строка, буква столбца). + ISheet Cell(uint row, string col, float value, NumberFormatPattern? format = null); + + /// Устанавливает целое число в ячейку по адресу (строка, буква столбца). + ISheet Cell(uint row, string col, int value, NumberFormatPattern? format = null); + + /// Устанавливает длинное целое число в ячейку по адресу (строка, буква столбца). + ISheet Cell(uint row, string col, long value, NumberFormatPattern? format = null); + + /// Очищает всё содержимое листа (значения, формулы, но не форматирование). + void ClearContents(); + + /// Очищает всё форматирование на листе (стили, шрифты, границы, но оставляет значения). + void ClearFormats(); + + /// Очищает и содержимое, и форматирование листа. + void Clear(); + + /// Удаляет лист из книги + void Remove(); +} + +/// Представляет строку на листе. +public interface IRow +{ + /// Индекс строки (начиная с 1). + uint Index { get; } + + /// + /// Копирует текущую строку в указанную позицию (как "копировать-вставить" в Excel). + /// Исходная строка остаётся без изменений. + /// + /// Индекс строки (начиная с 1), в которую будет вставлена копия. + /// Существующие строки, начиная с этой позиции, сдвигаются вниз. + /// Возвращает новую строку-копию, расположенную по указанному индексу. + /// Текущий объект IRow для цепочки вызовов (fluent). + IRow CopyTo(uint index, out IRow copiedRow); + + /// + /// Перемещает текущую строку в новую позицию (как "вырезать-вставить" в Excel). + /// Исходная строка удаляется, а перемещённая строка сохраняет все свои данные и форматирование. + /// + /// Новый индекс строки (начиная с 1). + /// Другие строки сдвигаются, освобождая место для перемещённой строки. + /// Тот же объект IRow, но уже с новым индексом (fluent). + IRow MoveTo(uint index); + + /// Высота строки + RowHeight Height { get; set; } + + /// Устанавливает числовой формат для всех ячеек строки. + IRow SetNumberFormat(NumberFormatPattern format); + + /// Возвращает ячейку в заданном столбце (индекс с 1). + ICell Cell(uint col); + + /// Редактирует ячейку в заданном столбце. + IRow Cell(uint col, Action edit); + + /// Устанавливает строковое значение в ячейку столбца. + IRow Cell(uint col, string value); + + /// Устанавливает логическое значение в ячейку столбца. + IRow Cell(uint col, bool value); + + /// Устанавливает формулу в ячейку столбца с числовым форматом. + IRow Cell(uint col, string formula, NumberFormatPattern? format = null); + + /// Устанавливает дату в ячейку столбца с числовым форматом. + IRow Cell(uint col, DateTime value, NumberFormatPattern? format = null); + + /// Устанавливает десятичное число в ячейку столбца с числовым форматом. + IRow Cell(uint col, decimal value, NumberFormatPattern? format = null); + + /// Устанавливает число двойной точности в ячейку столбца с числовым форматом. + IRow Cell(uint col, double value, NumberFormatPattern? format = null); + + /// Устанавливает число с плавающей точкой в ячейку столбца с числовым форматом. + IRow Cell(uint col, float value, NumberFormatPattern? format = null); + + /// Устанавливает целое число в ячейку столбца с числовым форматом. + IRow Cell(uint col, int value, NumberFormatPattern? format = null); + + /// Устанавливает длинное целое число в ячейку столбца с числовым форматом. + IRow Cell(uint col, long value, NumberFormatPattern? format = null); + + /// Возвращает ячейку по букве столбца. + ICell Cell(string col); + + /// Редактирует ячейку по букве столбца. + IRow Cell(string col, Action edit); + + /// Устанавливает строковое значение в ячейку по букве столбца. + IRow Cell(string col, string value); + + /// Устанавливает логическое значение в ячейку по букве столбца. + IRow Cell(string col, bool value); + + /// Устанавливает формулу в ячейку по букве столбца с числовым форматом. + IRow Cell(string col, string formula, NumberFormatPattern? format = null); + + /// Устанавливает дату в ячейку по букве столбца с числовым форматом. + IRow Cell(string col, DateTime value, NumberFormatPattern? format = null); + + /// Устанавливает десятичное число в ячейку по букве столбца с числовым форматом. + IRow Cell(string col, decimal value, NumberFormatPattern? format = null); + + /// Устанавливает число двойной точности в ячейку по букве столбца с числовым форматом. + IRow Cell(string col, double value, NumberFormatPattern? format = null); + + /// Устанавливает число с плавающей точкой в ячейку по букве столбца с числовым форматом. + IRow Cell(string col, float value, NumberFormatPattern? format = null); + + /// Устанавливает целое число в ячейку по букве столбца с числовым форматом. + IRow Cell(string col, int value, NumberFormatPattern? format = null); + + /// Устанавливает длинное целое число в ячейку по букве столбца с числовым форматом. + IRow Cell(string col, long value, NumberFormatPattern? format = null); + + /// Очищает содержимое всех ячеек строки (значения, формулы). + void ClearContents(); + + /// Очищает форматирование всех ячеек строки. + void ClearFormats(); + + /// Очищает содержимое и форматирование строки. + void Clear(); + + /// Удаляет строку с листа (сдвигая нижние строки вверх). + void Remove(); +} + +/// Представляет столбец на листе. +public interface IColumn +{ + /// Индекс столбца (начиная с 1). + uint Index { get; } + + /// Буквенное обозначение столбца (например, "A", "Z", "AA"). + string IndexLetter { get; } + + /// + /// Копирует текущий столбец в указанную позицию по числовому индексу (как "копировать-вставить"). + /// Исходный столбец остаётся без изменений. + /// + /// Числовой индекс столбца (начиная с 1), куда будет вставлена копия. + /// Существующие столбцы, начиная с этой позиции, сдвигаются вправо. + /// Возвращает новый столбец-копию, расположенный по указанному индексу. + /// Текущий объект IColumn для цепочки вызовов. + IColumn CopyTo(uint index, out IColumn copiedColumn); + + /// + /// Копирует текущий столбец в указанную позицию по буквенному обозначению (как "копировать-вставить"). + /// + /// Буквенное обозначение столбца (например, "D", "AA"), куда будет вставлена копия. + /// Возвращает новый столбец-копию. + /// Текущий объект IColumn. + IColumn CopyTo(string index, out IColumn copiedColumn); + + /// + /// Перемещает текущий столбец в новую позицию по числовому индексу (как "вырезать-вставить"). + /// Исходный столбец удаляется, а перемещённый столбец сохраняет свои данные и форматирование. + /// + /// Новый числовой индекс столбца (начиная с 1). + /// Тот же объект IColumn с новым индексом (fluent). + IColumn MoveTo(uint index); + + /// + /// Перемещает текущий столбец в новую позицию по буквенному обозначению (как "вырезать-вставить"). + /// + /// Новое буквенное обозначение столбца (например, "E"). + /// Тот же объект IColumn с новым индексом. + IColumn MoveTo(string index); + + /// Ширина столбца (чтение и запись). + ColumnWidth Width { get; set; } + + /// Устанавливает числовой формат для всех ячеек столбца. + IColumn SetNumberFormat(NumberFormatPattern format); + + /// Возвращает ячейку в заданной строке (индекс с 1). + ICell Cell(uint row); + + /// Редактирует ячейку в заданной строке. + IColumn Cell(uint row, Action edit); + + /// Устанавливает строковое значение в ячейку строки. + IColumn Cell(uint row, string value); + + /// Устанавливает логическое значение в ячейку строки. + IColumn Cell(uint row, bool value); + + /// Устанавливает формулу в ячейку строки с числовым форматом. + IColumn Cell(uint row, string formula, NumberFormatPattern? format = null); + + /// Устанавливает дату в ячейку строки с числовым форматом. + IColumn Cell(uint row, DateTime value, NumberFormatPattern? format = null); + + /// Устанавливает десятичное число в ячейку строки с числовым форматом. + IColumn Cell(uint row, decimal value, NumberFormatPattern? format = null); + + /// Устанавливает число двойной точности в ячейку строки с числовым форматом. + IColumn Cell(uint row, double value, NumberFormatPattern? format = null); + + /// Устанавливает число с плавающей точкой в ячейку строки с числовым форматом. + IColumn Cell(uint row, float value, NumberFormatPattern? format = null); + + /// Устанавливает целое число в ячейку строки с числовым форматом. + IColumn Cell(uint row, int value, NumberFormatPattern? format = null); + + /// Устанавливает длинное целое число в ячейку строки с числовым форматом. + IColumn Cell(uint row, long value, NumberFormatPattern? format = null); + + /// Очищает содержимое всех ячеек столбца. + void ClearContents(); + + /// Очищает форматирование всех ячеек столбца. + void ClearFormats(); + + /// Очищает содержимое и форматирование столбца. + void Clear(); + + /// Удаляет столбец с листа (сдвигая правые столбцы влево). + void Remove(); +} + +/// Представляет прямоугольный диапазон ячеек на листе. +public interface IRange +{ + /// Объединяет ячейки диапазона в одну (содержимое левой верхней ячейки сохраняется). + /// true, если объединение выполнено (диапазон не был объединён ранее). + bool TryMerge(); + + /// Разъединяет ранее объединённый диапазон (восстанавливает исходные ячейки, содержимое остаётся в левой верхней). + void Unmerge(); + + /// Проверяет, объединён ли диапазон (как единое целое). + bool IsMerged { get; } + + /// Возвращает диапазон, представляющий объединённую область, если текущий диапазон является частью объединения. + IRange? GetMergedRange(); + + /// Пытается получить объединённый диапазон, содержащий текущий. + bool TryGetMergedRange(IRange range); + + /// Номер начальной строки диапазона (1-based). + uint RowStart { get; } + + /// Номер конечной строки. + uint RowEnd { get; } + + /// Номер начального столбца. + uint ColStart { get; } + + /// Номер конечного столбца. + uint ColEnd { get; } + + /// Буквенное обозначение начального столбца. + string ColStartLetter { get; } + + /// Буквенное обозначение конечного столбца. + string ColEndLetter { get; } + + /// Количество строк в диапазоне. + uint Rows { get; } + + /// Количество столбцов в диапазоне. + uint Cols { get; } + + /// + /// Копирует текущий диапазон в новую позицию (верхний левый угол) – как "копировать-вставить". + /// Исходный диапазон остаётся неизменным. + /// + /// Номер строки (начиная с 1) для верхнего левого угла вставляемого диапазона. + /// Номер столбца (начиная с 1) для верхнего левого угла. + /// Возвращает новый диапазон-копию, расположенный по указанным координатам. + /// Текущий объект IRange (fluent). + IRange CopyTo(uint rowIndex, uint colIndex, out IRange copiedRange); + + /// + /// Копирует текущий диапазон в новую позицию (верхний левый угол) с указанием столбца буквой – как "копировать-вставить". + /// Исходный диапазон остаётся неизменным. + /// + /// Номер строки (начиная с 1). + /// Буквенное обозначение столбца (например, "C"). + /// Возвращает новый диапазон-копию. + /// Текущий объект IRange. + IRange CopyTo(uint rowIndex, string colIndex, out IRange copiedRange); + + /// + /// Перемещает текущий диапазон в новую позицию (верхний левый угол) – как "вырезать-вставить". + /// Исходный диапазон очищается, а текущий объект IRange перемещается на новое место. + /// + /// Номер строки для нового положения верхнего левого угла. + /// Номер столбца для нового положения. + /// Тот же объект IRange, но уже с новыми координатами (fluent). + IRange MoveTo(uint rowIndex, uint colIndex); + + /// + /// Перемещает текущий диапазон в новую позицию (верхний левый угол) с указанием столбца буквой – как "вырезать-вставить". + /// Исходный диапазон очищается, а текущий объект IRange перемещается на новое место. + /// + /// Номер строки. + /// Буквенное обозначение столбца. + /// Тот же объект IRange с новыми координатами. + IRange MoveTo(uint rowIndex, string colIndex); + + /// Устанавливает числовой формат для всех ячеек диапазона. + IRange SetNumberFormat(NumberFormatPattern format); + + /// Устанавливает выравнивание для всех ячеек диапазона. + IRange SetCellAlign(CellAlign format); + + /// Устанавливает границы для всех ячеек диапазона. + IRange SetCellBorder(CellBorder format); + + /// Устанавливает заливку для всех ячеек диапазона. + IRange SetCellFill(CellFill format); + + /// Устанавливает шрифт для всех ячеек диапазона. + IRange SetCellFont(CellFont format); + + /// Перечисляет все ячейки диапазона (по строкам). + IEnumerable Cells { get; } + + /// Получает ячейку внутри диапазона по относительным координатам (начиная с 1). + bool GetSubCell(uint row, uint col, out ICell cell); + + /// Редактирует ячейку внутри диапазона по относительным координатам. + bool TryEditSubCell(uint row, uint col, Action edit); + + /// Устанавливает строковое значение в ячейку по относительным координатам. + bool TryEditSubCell(uint row, uint col, string value); + + /// Устанавливает логическое значение в ячейку по относительным координатам. + bool TryEditSubCell(uint row, uint col, bool value); + + /// Устанавливает формулу в ячейку по относительным координатам. + bool TryEditSubCell(uint row, uint col, string formula, NumberFormatPattern? format = null); + + /// Устанавливает дату в ячейку по относительным координатам. + bool TryEditSubCell(uint row, uint col, DateTime value, NumberFormatPattern? format = null); + + /// Устанавливает десятичное число в ячейку по относительным координатам. + bool TryEditSubCell(uint row, uint col, decimal value, NumberFormatPattern? format = null); + + /// Устанавливает число двойной точности в ячейку по относительным координатам. + bool TryEditSubCell(uint row, uint col, double value, NumberFormatPattern? format = null); + + /// Устанавливает число с плавающей точкой в ячейку по относительным координатам. + bool TryEditSubCell(uint row, uint col, float value, NumberFormatPattern? format = null); + + /// Устанавливает целое число в ячейку по относительным координатам. + bool TryEditSubCell(uint row, uint col, int value, NumberFormatPattern? format = null); + + /// Устанавливает длинное целое число в ячейку по относительным координатам. + bool TryEditSubCell(uint row, uint col, long value, NumberFormatPattern? format = null); + + /// Получает ячейку по относительной строке и букве столбца. + bool GetSubCell(uint row, string col, out ICell cell); + + /// Редактирует ячейку по относительной строке и букве столбца. + bool TryEditSubCell(uint row, string col, Action edit); + + /// Устанавливает строковое значение в ячейку по относительной строке и букве столбца. + bool TryEditSubCell(uint row, string col, string value); + + /// Устанавливает логическое значение в ячейку по относительной строке и букве столбца. + bool TryEditSubCell(uint row, string col, bool value); + + /// Устанавливает формулу в ячейку по относительной строке и букве столбца. + bool TryEditSubCell(uint row, string col, string formula, NumberFormatPattern? format = null); + + /// Устанавливает дату в ячейку по относительной строке и букве столбца. + bool TryEditSubCell(uint row, string col, DateTime value, NumberFormatPattern? format = null); + + /// Устанавливает десятичное число в ячейку по относительной строке и букве столбца. + bool TryEditSubCell(uint row, string col, decimal value, NumberFormatPattern? format = null); + + /// Устанавливает число двойной точности в ячейку по относительной строке и букве столбца. + bool TryEditSubCell(uint row, string col, double value, NumberFormatPattern? format = null); + + /// Устанавливает число с плавающей точкой в ячейку по относительной строке и букве столбца. + bool TryEditSubCell(uint row, string col, float value, NumberFormatPattern? format = null); + + /// Устанавливает целое число в ячейку по относительной строке и букве столбца. + bool TryEditSubCell(uint row, string col, int value, NumberFormatPattern? format = null); + + /// Устанавливает длинное целое число в ячейку по относительной строке и букве столбца. + bool TryEditSubCell(uint row, string col, long value, NumberFormatPattern? format = null); + + /// Очищает содержимое всех ячеек диапазона. + void ClearContents(); + + /// Очищает форматирование всех ячеек диапазона. + void ClearFormats(); + + /// Очищает и содержимое, и форматирование. + void Clear(); +} + +/// Представляет одну ячейку на листе. +public interface ICell +{ + /// Проверяет, входит ли ячейка в объединённый диапазон. + bool IsMerged { get; } + + /// Возвращает объединённый диапазон, если ячейка объединена, иначе null. + IRange? GetMergedRange(); + + /// Пытается получить объединённый диапазон, содержащий эту ячейку. + bool TryGetMergedRange(IRange range); + + /// Номер строки (1-based). + uint Row { get; } + + /// Номер столбца (1-based). + uint Col { get; } + + /// Буква столбца. + string ColLetter { get; } + + /// + /// Копирует текущую ячейку в указанную позицию (как "копировать-вставить"). + /// Исходная ячейка остаётся без изменений. + /// + /// Номер строки (начиная с 1), куда будет вставлена копия. + /// Номер столбца (начиная с 1). + /// Возвращает новую ячейку-копию. + /// Текущий объект ICell для цепочки вызовов. + ICell CopyTo(uint rowIndex, uint colIndex, out ICell copiedCell); + + /// + /// Копирует текущую ячейку в указанную позицию с буквенным обозначением столбца (как "копировать-вставить"). + /// Исходная ячейка остаётся без изменений. + /// + /// Номер строки. + /// Буквенное обозначение столбца (например, "B"). + /// Возвращает новую ячейку-копию. + /// Текущий объект ICell. + ICell CopyTo(uint rowIndex, string colIndex, out ICell copiedCell); + + /// + /// Перемещает текущую ячейку в новую позицию (как "вырезать-вставить"). + /// Исходная ячейка очищается, а текущий объект ICell перемещается в новое место. + /// + /// Новый номер строки. + /// Новый номер столбца. + /// Тот же объект ICell с новыми координатами (fluent). + ICell MoveTo(uint rowIndex, uint colIndex); + + /// + /// Перемещает текущую ячейку в новую позицию с буквенным обозначением столбца (как "вырезать-вставить"). + /// Исходная ячейка очищается, а текущий объект ICell перемещается в новое место. + /// + /// Новый номер строки. + /// Буквенное обозначение столбца. + /// Тот же объект ICell с новыми координатами. + ICell MoveTo(uint rowIndex, string colIndex); + + /// Высота строки, содержащей ячейку (чтение и запись). + RowHeight Height { get; set; } + + /// Ширина столбца, содержащего ячейку. + ColumnWidth Width { get; set; } + + /// Проверяет, содержит ли ячейка числовое значение (целое или с плавающей точкой). + bool IsNumber { get; } + + /// Проверяет, является ли содержимое логическим (TRUE/FALSE). + bool IsBoolean { get; } + + /// Проверяет, содержит ли ячейка код ошибки (например, #DIV/0!). + bool IsError { get; } + + /// Проверяет, интерпретируется ли значение как дата (по числовому формату). + bool IsDate { get; } + + /// Проверяет, заблокирована ли ячейка (атрибут защиты). + bool IsLocked { get; } + + /// Проверяет, содержит ли ячейка формулу. + bool HasFormula { get; } + + /// Возвращает текстовое представление содержимого ячейки (как оно отображается). + string GetString(); + + /// Возвращает числовой формат ячейки. + NumberFormatPattern? GetNumberFormat(); + + /// Возвращает выравнивание текста ячейки. + CellAlign GetCellAlign(); + + /// Возвращает границы ячейки. + CellBorder GetCellBorder(); + + /// Возвращает заливку ячейки. + CellFill GetCellFill(); + + /// Возвращает шрифт ячейки. + CellFont GetCellFont(); + + /// Пытается извлечь логическое значение. + bool TryGetBoolean(out bool value); + + /// Возвращает логическое значение или null, если тип не соответствует. + bool? GetBoolean(); + + /// Пытается извлечь дату и время. + bool TryGetDate(out DateTime value); + + /// Возвращает дату или null. + DateTime? TryGetDate(); + + /// Пытается извлечь число (double). + bool TryGetNumber(out double value); + + /// Возвращает число или null. + double? TryGetNumber(); + + /// Пытается установить формулу (без вычисленного значения). + /// Текст формулы (например, "SUM(A1:A5)"). + /// Необязательный числовой формат для результата. + bool TrySet(string formula, NumberFormatPattern? format = null); + + /// Устанавливает формулу (выбрасывает исключение при ошибке). + ICell Set(string formula, NumberFormatPattern? format = null); + + /// Устанавливает числовой формат ячейки (не меняя значение). + ICell Set(NumberFormatPattern format); + + /// Устанавливает выравнивание текста ячейки. + ICell Set(CellAlign format); + + /// Устанавливает границы ячейки. + ICell Set(CellBorder format); + + /// Устанавливает заливку ячейки. + ICell Set(CellFill format); + + /// Устанавливает шрифт ячейки. + ICell Set(CellFont format); + + /// Устанавливает богатый текст (форматированный) с помощью делегата. + ICell Set(Action value); + + /// Устанавливает простое текстовое значение (без форматирования). + ICell Set(string value); + + /// Устанавливает логическое значение. + ICell Set(bool value); + + /// Устанавливает дату, опционально с числовым форматом. + ICell Set(DateTime value, NumberFormatPattern? format = null); + + /// Устанавливает десятичное число (сохраняется как double). + ICell Set(decimal value, NumberFormatPattern? format = null); + + /// Устанавливает число двойной точности. + ICell Set(double value, NumberFormatPattern? format = null); + + /// Устанавливает число с плавающей точкой. + ICell Set(float value, NumberFormatPattern? format = null); + + /// Устанавливает целое число. + ICell Set(int value, NumberFormatPattern? format = null); + + /// Устанавливает длинное целое число. + ICell Set(long value, NumberFormatPattern? format = null); + + /// Очищает содержимое ячейки (значение/формулу, но не форматирование). + void ClearContent(); + + /// Очищает форматирование ячейки (сбрасывает стиль). + void ClearFormat(); + + /// Очищает и содержимое, и форматирование. + void Clear(); +} + +/// Представляет богатый текст внутри ячейки (несколько форматированных фрагментов). +public interface ICellText +{ + /// Количество фрагментов (Run) в тексте. + int Count { get; } + + /// Возвращает все фрагменты. + IEnumerable GetRuns(); + + /// Возвращает фрагмент по индексу или null. + IRun? GetRunAt(int index); + + /// Пытается получить фрагмент по индексу. + bool TryGetRunAt(int index, out IRun run); + + /// Первый фрагмент или null. + IRun? First(); + + /// Пытается получить первый фрагмент. + bool TryGetFirst(out IRun run); + + /// Последний фрагмент или null. + IRun? Last(); + + /// Пытается получить последний фрагмент. + bool TryGetLast(out IRun run); + + /// Пытается удалить фрагмент. + bool TryRemoveRun(IRun run); + + /// Удаляет фрагмент по индексу. + bool TryRemoveRun(int index); + + /// Удаляет фрагмент по индексу и возвращает удалённый. + bool TryRemoveRun(int index, out IRun? removed); + + /// Добавляет разрыв строки (перенос внутри ячейки). + ICellText AddBreak(); + + /// Добавляет обычный текстовый фрагмент. + ICellText AddRun(string text, RunFormat? format = null); + + /// Добавляет фрагмент с последующим разрывом строки. + ICellText AddRunBreak(string text, RunFormat? format = null); + + /// Добавляет подстрочный фрагмент (эквивалентно AddRun с Vertical = Subscript). + ICellText AddSubRun(string text, RunFormat? format = null); + + /// Добавляет надстрочный фрагмент. + ICellText AddSupRun(string text, RunFormat? format = null); + + /// Вставляет фрагмент по индексу. + bool TryInsertRun(int index, string text, RunFormat? format = null); + + /// Вставляет фрагмент с последующим разрывом строки по индексу. + bool TryInsertRunBreak(int index, string text, RunFormat? format = null); + + /// Вставляет подстрочный фрагмент по индексу. + bool TryInsertSubRun(int index, string text, RunFormat? format = null); + + /// Вставляет надстрочный фрагмент по индексу. + bool TryInsertSupRun(int index, string text, RunFormat? format = null); + + /// Применяет заданный формат ко всем существующим фрагментам (поверх их текущего форматирования, заменяя неуказанные свойства). + void ApplyFormatToAllRuns(RunFormat format); + + /// Удаляет все фрагменты, очищая текст ячейки. + void Clear(); +} + +/// Представляет один форматированный фрагмент текста внутри ячейки. +public interface IRun +{ + /// Текст фрагмента. + string Text { get; set; } + + /// Форматирование фрагмента (может быть null, что означает отсутствие явного форматирования). + RunFormat? Format { get; set; } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/NumberFormatPattern.cs b/QWERTYkez.ExcelProcessor/Editors/NumberFormatPattern.cs new file mode 100644 index 0000000..7c54fd8 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/NumberFormatPattern.cs @@ -0,0 +1,20 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +public class NumberFormatPattern +{ + public string Format { get; } + internal int? Id { get; private set; } + + public NumberFormatPattern(string format, ushort id = 0) + { + Format = format ?? throw new ArgumentNullException(nameof(format)); + if (id != 0) Id = id; + } + + internal void Attach(ushort id) => Id = id; + + public override bool Equals(object? obj) => + obj is NumberFormatPattern other && Format == other.Format; + + public override int GetHashCode() => Format?.GetHashCode() ?? 0; +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/RowHeight.cs b/QWERTYkez.ExcelProcessor/Editors/RowHeight.cs new file mode 100644 index 0000000..a7453c0 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/RowHeight.cs @@ -0,0 +1,29 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Предоставляет типобезопасное представление высоты строки в Excel с поддержкой различных единиц измерения. +/// +public readonly struct RowHeight(double points) +{ + private const double POINTS_PER_INCH = 72.0; + private const double INCH_PER_CM = 1.0 / 2.54; + private const double POINTS_PER_CM = POINTS_PER_INCH * INCH_PER_CM; // ≈ 28.3464566929 + private const double POINTS_PER_MM = POINTS_PER_CM / 10.0; // ≈ 2.83464566929 + + private const double DXA_PER_POINT = 20.0; // 1 point = 20 dxa + private const double POINTS_PER_DXA = 1.0 / DXA_PER_POINT; + + /// Высота в пунктах (points). + public double Points { get; } = points; + + public static RowHeight FromPoints(double points) => new(points); + public static RowHeight FromInch(double inch) => new(inch * POINTS_PER_INCH); + public static RowHeight FromDXA(double dxa) => new(dxa * POINTS_PER_DXA); + public static RowHeight FromCM(double cm) => new(cm * POINTS_PER_CM); + public static RowHeight FromMM(double mm) => new(mm * POINTS_PER_MM); + + public double Inch => Points / POINTS_PER_INCH; + public double DXA => Points / POINTS_PER_DXA; + public double CM => Points / POINTS_PER_CM; + public double MM => Points / POINTS_PER_MM; +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/Editors/RunFormat.cs b/QWERTYkez.ExcelProcessor/Editors/RunFormat.cs new file mode 100644 index 0000000..e941fa9 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/Editors/RunFormat.cs @@ -0,0 +1,70 @@ +namespace QWERTYkez.ExcelProcessor.Editors; + +/// +/// Определяет форматирование отдельного фрагмента (Run) внутри ячейки с богатым текстом. +/// Применяется только к тексту внутри . +/// +public readonly struct RunFormat +{ + /// Жирное начертание фрагмента. + public bool? IsBold { get; init; } + + /// Курсив фрагмента. + public bool? IsItalic { get; init; } + + /// Стиль подчёркивания (одинарное, двойное, волнистое и т.д.). + public UnderlineStyle? Underline { get; init; } + + /// Одинарное зачёркивание. + public bool? IsStrike { get; init; } + + /// Цвет текста фрагмента. + public ExColor? Color { get; init; } + + /// Размер шрифта фрагмента в пунктах. + public double? FontSize { get; init; } + + /// Имя шрифта фрагмента (например, "Calibri"). + public string? FontFamily { get; init; } + + /// Вертикальное смещение (надстрочный или подстрочный). + public VerticalTextRunAlignment? Vertical { get; init; } + + /* + + методы для извлечения OpenXmlElement или других более удобных типов + + public bool TryExtract(out List<...> elements); + + или + + public bool TrySetFor(InlineString str) + + или + + public bool TrySetFor(ExcelRun str) + + */ +} + +/// Определяет стиль подчёркивания текста в ячейке или в части текста (Run). +public enum UnderlineStyle +{ + /// Одинарное сплошное подчёркивание. + Single, + /// Двойное сплошное подчёркивание. + Double, + /// Одинарное подчёркивание, используемое для бухгалтерских форматов (нижняя граница ячейки). + SingleAccounting, + /// Двойное подчёркивание для бухгалтерских форматов. + DoubleAccounting +} + +/// Вертикальное смещение текста внутри прогона (Run) – надстрочный или подстрочный. +public enum VerticalTextRunAlignment +{ + /// Надстрочный текст (верхний индекс). + Superscript, + /// Подстрочный текст (нижний индекс). + Subscript +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/ExcelProcessor.cs b/QWERTYkez.ExcelProcessor/ExcelProcessor.cs new file mode 100644 index 0000000..5628bc4 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/ExcelProcessor.cs @@ -0,0 +1,320 @@ +namespace QWERTYkez.ExcelProcessor; + +/// +/// Статический класс для работы с документами Excel через процессоры чтения/записи. +/// +public static class ExcelProcessor +{ + #region Read Operations + + /// + /// Пытается открыть документ только для чтения и выполнить действия. + /// + /// Путь к исходному файлу .xlsx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryRead(string sourcePath, Action read) + { + return TryRead(new FileInfo(sourcePath), read); + } + + /// + /// Пытается открыть документ только для чтения и выполнить действия. + /// + /// Объект исходного файла .xlsx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryRead(FileInfo sourceFile, Action read) + { + if (sourceFile is null || read is null) + { +#if DEBUG + Debug.WriteLine("[DEBUG] SourceFile or action is null"); +#endif + return false; + } + + using var processor = ExcelReader.CreateInternal(sourceFile); + if (processor is null) + return false; + + try + { + read(processor); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in read action: {ex.Message}"); +#endif + return false; + } + } + + #endregion + + #region Read Operations from Memory + + /// + /// Пытается открыть документ из массива байт только для чтения и выполнить действия. + /// + /// Массив байт, содержащий документ .xlsx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryRead(byte[] data, Action read) + => TryRead(new ReadOnlyMemory(data), read); + + /// + /// Пытается открыть документ из только для чтения и выполнить действия. + /// + /// Буфер с документом .xlsx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryRead(ReadOnlyMemory data, Action read) + { + if (data.IsEmpty || read is null) + { +#if DEBUG + Debug.WriteLine("[DEBUG] Data is empty or action is null"); +#endif + return false; + } + + using var processor = ExcelReader.CreateFromData(data); + if (processor is null) + return false; + + try + { + read(processor); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in read action from memory: {ex.Message}"); +#endif + return false; + } + } + + #endregion + + #region Write Operations + + /// + /// Пытается открыть документ для записи и выполнить действия с последующей перезаписью исходного файла. + /// + /// Путь к исходному файлу .xlsx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(string sourcePath, Action write) + { + return TryWrite(sourcePath, sourcePath, write); + } + + /// + /// Пытается открыть документ для записи и выполнить действия с последующей перезаписью исходного файла. + /// + /// Объект исходного файла .xlsx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(FileInfo sourceFile, Action write) + { + return TryWrite(sourceFile, sourceFile.FullName, write); + } + + /// + /// Пытается открыть документ для записи, выполнить действия и сохранить результат по новому пути. + /// + /// Путь к исходному файлу .xlsx + /// Путь для сохранения результата (null - перезапись исходного файла) + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(string sourcePath, string? destinationPath, Action write) + { + return TryWrite(new FileInfo(sourcePath), destinationPath, write); + } + + /// + /// Пытается открыть документ для записи, выполнить действия и сохранить результат по новому пути. + /// + /// Объект исходного файла .xlsx + /// Путь для сохранения результата (null - перезапись исходного файла) + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(FileInfo sourceFile, string? destinationPath, Action write) + { + if (sourceFile is null || write is null) + { +#if DEBUG + Debug.WriteLine("[DEBUG] SourceFile or action is null"); +#endif + return false; + } + + using var processor = ExcelWriter.CreateInternal(sourceFile, destinationPath); + if (processor is null) + return false; + + try + { + write(processor); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in write action: {ex.Message}"); +#endif + return false; + } + } + + #endregion + + #region Write Operations from Memory + + /// + /// Пытается открыть документ из массива байт для записи, выполнить действия и сохранить результат по указанному пути. + /// + /// Массив байт, содержащий документ .xlsx + /// Путь для сохранения результата + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(byte[] data, string destinationPath, Action write) + => TryWrite(new ReadOnlyMemory(data), destinationPath, write); + + /// + /// Пытается открыть документ из для записи, выполнить действия и сохранить результат по указанному пути. + /// + /// Буфер с документом .xlsx + /// Путь для сохранения результата + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(ReadOnlyMemory data, string destinationPath, Action write) + { + if (data.IsEmpty || write is null) + { +#if DEBUG + Debug.WriteLine("[DEBUG] Data is empty or action is null"); +#endif + return false; + } + + using var processor = ExcelWriter.CreateFromData(data, destinationPath); + if (processor is null) + return false; + + try + { + write(processor); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in write action from memory: {ex.Message}"); +#endif + return false; + } + } + + #endregion + + /// + /// Пытается создать новый документ Excel, отредактировать его и вернуть результат в виде массива байт. + /// + /// Результирующий массив байт (если операция успешна). + /// Действия для заполнения документа. + /// если операция выполнена успешно, иначе . + public static bool TryCreate(out byte[] result, Action write) + { + result = null!; + if (write is null) return false; + + try + { + using var writer = ExcelWriter.CreateNew(); + write(writer); + result = writer.GetDocumentBytes(); // добавим этот метод в ExcelWriter + return true; + } + catch + { + return false; + } + } + + /// + /// Пытается создать новый документ Excel, отредактировать его и записать в указанный поток. + /// + /// Поток, в который будет записан документ. + /// Действия для заполнения документа. + /// если операция выполнена успешно, иначе . + public static bool TryCreate(Stream outputStream, Action write) + { + if (write is null || outputStream is null) return false; + + try + { + using var writer = ExcelWriter.CreateNew(); + write(writer); + writer.SaveTo(outputStream); + return true; + } + catch + { + return false; + } + } + /// + /// Пытается создать новый документ Excel, отредактировать его и сохранить на диск. + /// + /// Путь для сохранения результата. + /// Действия для заполнения документа. + /// если операция выполнена успешно, иначе . + public static bool TryCreate(string destinationPath, Action write) + { + if (string.IsNullOrEmpty(destinationPath) || write is null) + return false; + + try + { + using var writer = ExcelWriter.CreateNew(destinationPath); + write(writer); + writer.Save(); + return true; + } + catch + { + return false; + } + } + + /// + /// Пытается создать новый документ Excel, отредактировать его и прочитать результат в памяти. + /// + /// Действия для заполнения документа. + /// Действия для чтения полученного документа. + /// если операция выполнена успешно, иначе . + public static bool TryRead(Action write, Action read) + { + if (write is null || read is null) + return false; + + try + { + using var writer = ExcelWriter.CreateNew(); + write(writer); + using var reader = writer.ToReader(); + read(reader); + return true; + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/ExcelReader.cs b/QWERTYkez.ExcelProcessor/ExcelReader.cs new file mode 100644 index 0000000..a4fa722 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/ExcelReader.cs @@ -0,0 +1,276 @@ +namespace QWERTYkez.ExcelProcessor; + +/// +/// Предоставляет потокобезопасный процессор только для чтения документов Excel (xlsx / xlsm) формата. +/// Не поддерживает операции изменения документа. +/// +internal class ExcelReader : IDisposable, IExcelReader +{ + internal MemoryStream _ms = null!; + internal SpreadsheetDocument _doc = null!; + internal bool _disposed; + internal readonly object _syncLock = new(); + internal string? _originalSourcePath; + + internal ExcelReader() { } + + #region Factory Methods + + internal static ExcelReader? CreateInternal(FileInfo sourceFile) + { + if (sourceFile is null || !sourceFile.Exists) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Source file is null or does not exist: {sourceFile?.FullName}"); +#endif + return null; + } + + MemoryStream? ms = null; + SpreadsheetDocument? doc = null; + + try + { + ms = new MemoryStream(); + using (var file = new FileStream(sourceFile.FullName, + FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + { + file.CopyTo(ms); + } + ms.Position = 0; + + doc = SpreadsheetDocument.Open(ms, isEditable: false, + new OpenSettings { AutoSave = false }); + + if (doc is not null) + { + var processor = new ExcelReader + { + _ms = ms, + _doc = doc, + _originalSourcePath = sourceFile.FullName, + FilePath = sourceFile.FullName + }; + + return processor; + } +#if DEBUG + Debug.WriteLine("[DEBUG] Document body is null or empty"); +#endif + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error creating read-only processor: {ex.GetType().Name}: {ex.Message}"); +#endif + } + + doc?.Dispose(); + ms?.Dispose(); + return null; + } + + internal static ExcelReader? CreateFromData(ReadOnlyMemory 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, false, + new OpenSettings { AutoSave = false }); + + if (doc is not null) + { + var processor = new ExcelReader + { + _ms = ms, + _doc = doc, + FilePath = null // из памяти – нет файла + }; + + return processor; + } + doc?.Dispose(); + } + catch { } + + ms?.Dispose(); + return null; + } + + #endregion + + #region Writers + + public bool TryWrite(string destinationPath, Action action) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(destinationPath)) + throw new ArgumentException("Destination path cannot be null or empty", nameof(destinationPath)); + if (action is null) + throw new ArgumentNullException(nameof(action)); + + // Копируем данные из текущего потока + byte[] data; + lock (_syncLock) + { + _ms.Position = 0; + data = _ms.ToArray(); + } + + using var writable = ExcelWriter.CreateFromData(new ReadOnlyMemory(data), destinationPath); + if (writable is null) + return false; + + try + { + action(writable); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in TryWrite action: {ex.Message}"); +#endif + return false; + } + } + + public bool TryWrite(Action write, Action read) + { + ThrowIfDisposed(); + if (write is null) throw new ArgumentNullException(nameof(write)); + if (read is null) throw new ArgumentNullException(nameof(read)); + + // Копируем текущие данные + byte[] data; + lock (_syncLock) + { + _ms.Position = 0; + data = _ms.ToArray(); + } + + ExcelWriter? writable = null; + ExcelReader? resultReader = null; + try + { + // Создаём редактируемую копию (без привязки к файлу) + writable = ExcelWriter.CreateFromData(data); + if (writable is null) + return false; + + // Применяем изменения + write(writable); + + // Сохраняем изменения в поток и создаём read-only процессор + resultReader = writable.ToReader(); + + if (resultReader is null) + return false; + + // Работаем с изменённой копией + read(resultReader); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in TryWrite(write, read): {ex.Message}"); +#endif + return false; + } + finally + { + // Гарантированно освобождаем созданные процессоры + resultReader?.Dispose(); + writable?.Dispose(); + } + } + + #endregion + + #region Properties + + /// + /// Получает путь к исходному файлу. + /// + public string? FilePath { get; protected set; } + + /// + /// Получает значение, указывающее, находится ли процессор в допустимом состоянии для операций. + /// + public bool IsValid => !_disposed && _doc is not null && _ms is not null; + + /// + /// Получает текущий размер документа в байтах. + /// + public long DocumentSize => _ms.Length; + + #endregion + + #region Read Operations + + /// + /// Находит все уникальные плейсхолдеры в формате $...$ в документе. + /// Ищет только внутри параграфов. Игнорирует вхождения, которые пересекают границы параграфов. + /// + /// Способ сравнения строк при поиске (по умолчанию: без учета регистра) + /// Коллекция уникальных найденных плейсхолдеров + public ISet FindPlaceholders() + { + ThrowIfDisposed(); + + lock (_syncLock) + { + return PlaceholderFinder.FindInDocument(_doc); + } + } + + #endregion + + #region Dispose Pattern + + internal void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(GetType().Name); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + + lock (_syncLock) + { + if (disposing) + { + _doc.Dispose(); + _ms.Dispose(); + } + + _doc = null!; + _ms = null!; + _disposed = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + ~ExcelReader() + { + Dispose(disposing: false); + } + + #endregion +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/ExcelWriter.cs b/QWERTYkez.ExcelProcessor/ExcelWriter.cs new file mode 100644 index 0000000..1a1d767 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/ExcelWriter.cs @@ -0,0 +1,1293 @@ +using QWERTYkez.ExcelProcessor.Editors; +using System.Runtime.InteropServices; + +namespace QWERTYkez.ExcelProcessor; + +/// +/// Предоставляет потокобезопасный процессор для чтения и записи документов Excel (xlsx / xlsm) формата. +/// Наследует от и добавляет операции изменения документа. +/// +internal sealed class ExcelWriter : ExcelReader, IExcelReader, IExcelWriter +{ + // Работа с общей таблицей строк + private SharedStringTablePart? _sharedStringPart; + private SharedStringTable? _sharedStringTable; + + internal static Dictionary? _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 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(); + 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().FirstOrDefault(); + if (_sharedStringPart == null) + { + _sharedStringPart = _doc.WorkbookPart?.AddNewPart(); + _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; + if (text != null) return text; + // Rich text + var sb = new StringBuilder(); + foreach (var run in si.Elements()) + { + sb.Append(run.GetFirstChild()?.Text); + } + return sb.ToString(); + } + + internal int GetOrAddSharedString(string value) + { + EnsureSharedStringTable(); + if (_sharedStringTable == null) return -1; + // Поиск существующей строки + int idx = 0; + foreach (var si in _sharedStringTable.Elements()) + { + var text = si.GetFirstChild()?.Text; + if (text == value) return idx; + idx++; + } + // Добавление новой + var newSi = new SharedStringItem(); + newSi.Append(new Text(value)); + _sharedStringTable.Append(newSi); + _sharedStringTable.Count = (uint)_sharedStringTable.Elements().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 _numberFormatCache = []; + private readonly Dictionary _numberFormatIdToPattern = []; + + // Кэши для компонентов стилей (чтобы не создавать дубликаты) + private readonly Dictionary _fontCache = []; + private readonly Dictionary _fillCache = []; + private readonly Dictionary _borderCache = []; + private readonly Dictionary _alignmentCache = []; + + // Кэш составных стилей (CellFormat) + private readonly Dictionary<(int fontId, int fillId, int borderId, int alignId, int numFmtId), int> _cellFormatCache = []; + + // Конструктор, фабричные методы – без изменений (опущены) + + #region Управление числовыми форматами (расширение IBook) + + public IReadOnlyList GetNumberFormats() + { + // Возвращаем все пользовательские форматы (Id >= 164), встроенные не включаем + var result = new List(); + var stylesheet = _doc.WorkbookPart?.WorkbookStylesPart?.Stylesheet; + if (stylesheet?.NumberingFormats != null) + { + foreach (var nf in stylesheet.NumberingFormats.Elements()) + { + 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()) + { + 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(); + 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 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 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; + } + + /// + /// Создаёт новый пустой документ Excel с одним листом. + /// + /// Путь, по которому будет сохранён документ (необязательный). + /// Экземпляр для редактирования нового документа. + 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.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 + + /// + /// Получает значение, указывающее, будет ли документ сохранен с перезаписью исходного файла. + /// + 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 replacements, StringComparison comparisonType) => + ((IExcelWriter)this).Replace((IEnumerable>)replacements, comparisonType); + void IExcelWriter.Replace(IEnumerable> 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 replacements, string? format, StringComparison comparisonType) => + ((IExcelWriter)this).Replace((IEnumerable>)replacements, format, comparisonType); + void IExcelWriter.Replace(IEnumerable> 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 replacements, string? format, StringComparison comparisonType) => + ((IExcelWriter)this).Replace((IEnumerable>)replacements, format, comparisonType); + void IExcelWriter.Replace(IEnumerable> 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 replacements, StringComparison comparisonType) => + ((IExcelWriter)this).Replace((IEnumerable>)replacements, comparisonType); + void IExcelWriter.Replace(IEnumerable> 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 replacements, StringComparison comparisonType) => + ((IExcelWriter)this).Replace((IEnumerable>)replacements, comparisonType); + void IExcelWriter.Replace(IEnumerable> replacements, StringComparison comparisonType) + { + ThrowIfDisposed(); + + if (replacements == null) return; + + lock (_syncLock) + { + _doc.Replace(replacements, comparisonType); + _isModified = true; + } + } + + #endregion + + #region Save Operations + + /// + /// Сохраняет документ в файл, указанный при создании процессора. + /// + public void Save() + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(FilePath)) + throw new InvalidOperationException("Cannot save - no file path specified"); + + SaveTo(FilePath!); + } + + /// + /// Сохраняет документ в указанный файл. + /// + 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); + } + } + + /// + /// Пытается сохранить документ в указанный файл. + /// + 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; + } + } + + /// + /// Асинхронно сохраняет документ в указанный файл. + /// + 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); + } + + /// Создаёт read-only процессор из текущего документа. + 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 + + /// + public IReadOnlyList GetSheets() + { + ThrowIfDisposed(); + lock (_syncLock) + { + var sheets = new List(); + var workbookPart = _doc.WorkbookPart; + if (workbookPart?.Workbook?.Sheets == null) + return sheets; + + foreach (Sheet sheetElement in workbookPart.Workbook.Sheets.Elements()) + { + var sheet = new ExcelSheet(this, sheetElement, sheetElement.SheetId?.Value ?? 0); + sheets.Add(sheet); + } + return sheets; + } + } + + /// + 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()) + { + if (sheetElement.Name?.Value == name) + return new ExcelSheet(this, sheetElement, sheetElement.SheetId?.Value ?? 0); + } + return null; + } + } + + /// + public bool TryGetSheet(string name, out ISheet sheet) + { + sheet = Sheet(name)!; + return sheet != null; + } + + /// + public bool TryAddSheet(string name, Action? 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()) + { + if (s.Name?.Value == name) + return false; + } + + // Создание нового листа + var worksheetPart = workbookPart.AddNewPart(); + worksheetPart.Worksheet = new Worksheet(new SheetData()); + + // Вычисление нового SheetId + uint maxSheetId = 0; + foreach (Sheet s in workbookPart.Workbook.Sheets.Elements()) + { + 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; + } + } + + /// + public bool TryRemoveSheet(string name) + { + var sheet = Sheet(name); + return sheet != null && TryRemoveSheet(sheet); + } + + /// + 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 + + /// + public void Edit(Action 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()) + { + 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().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().ToList() ?? []; + } + return borderId < _cachedBorders.Count ? _cachedBorders[(int)borderId] : null; + } + private List? _cachedBorders; + + private Fill? GetFillById(uint borderId) + { + if (_cachedFills == null) + { + var fills = EnsureStylesheet().Fills; + _cachedFills = fills?.Elements().ToList() ?? []; + } + return borderId < _cachedFills.Count ? _cachedFills[(int)borderId] : null; + } + private List? _cachedFills; + + private Font? GetFontById(uint borderId) + { + if (_cachedFonts == null) + { + var borders = EnsureStylesheet().Borders; + _cachedFonts = borders?.Elements().ToList() ?? []; + } + return borderId < _cachedFonts.Count ? _cachedFonts[(int)borderId] : null; + } + private List? _cachedFonts; +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/IExcelReader.cs b/QWERTYkez.ExcelProcessor/IExcelReader.cs new file mode 100644 index 0000000..0d10279 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/IExcelReader.cs @@ -0,0 +1,14 @@ +namespace QWERTYkez.ExcelProcessor; + +public interface IExcelReader +{ + long DocumentSize { get; } + string? FilePath { get; } + bool IsValid { get; } + + ISet FindPlaceholders(); + + + bool TryWrite(string destinationPath, Action action); + bool TryWrite(Action write, Action read); +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/IExcelWriter.cs b/QWERTYkez.ExcelProcessor/IExcelWriter.cs new file mode 100644 index 0000000..d599411 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/IExcelWriter.cs @@ -0,0 +1,29 @@ +using QWERTYkez.ExcelProcessor.Editors; + +namespace QWERTYkez.ExcelProcessor; + +public interface IExcelWriter : IBook +{ + void Save(); + void SaveTo(string path); + Task SaveToAsync(string path, CancellationToken cancellationToken = default); + bool TrySaveTo(string path, out Exception? error); + + void Replace(string oldValue, string newValue, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(string oldValue, double newValue, string? format = null, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(string oldValue, float newValue, string? format = null, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(string oldValue, int newValue, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(string oldValue, long newValue, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(IDictionary replacements, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(IDictionary replacements, string? format = null, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(IDictionary replacements, string? format = null, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(IDictionary replacements, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(IDictionary replacements, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(IEnumerable> replacements, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(IEnumerable> replacements, string? format = null, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(IEnumerable> replacements, string? format = null, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(IEnumerable> replacements, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + void Replace(IEnumerable> replacements, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase); + + void Edit(Action edit); +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/NormalizedSet.cs b/QWERTYkez.ExcelProcessor/NormalizedSet.cs new file mode 100644 index 0000000..d96e8f0 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/NormalizedSet.cs @@ -0,0 +1,135 @@ +using System.Collections; +using System.Globalization; + +namespace QWERTYkez.ExcelProcessor; + +/// +/// Множество строк, которое автоматически приводит все добавляемые элементы +/// к верхнему регистру и удаляет диакритические знаки (например, 'ё' -> 'Е'). +/// Реализует ISet<string>, поэтому может использоваться там, где ожидается этот интерфейс. +/// +public class NormalizedSet : ISet +{ + private readonly HashSet _inner; + + /// + /// Создаёт пустое нормализованное множество. + /// + public NormalizedSet() + { + _inner = []; + } + + /// + /// Создаёт нормализованное множество, заполненное элементами из указанной коллекции. + /// + /// Коллекция, элементы которой будут нормализованы и добавлены. + public NormalizedSet(IEnumerable collection) + { + _inner = [.. collection.Select(Normalize)]; + } + + /// + /// Нормализует строку: верхний регистр и удаление диакритики. + /// + private static string Normalize(string s) + { + if (string.IsNullOrEmpty(s)) + return s; + + var normalized = s.Normalize(NormalizationForm.FormD); + var sb = new StringBuilder(); + foreach (char c in normalized) + { + if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) + sb.Append(c); + } + return sb.ToString().Normalize(NormalizationForm.FormC).ToUpperInvariant(); + } + + // ---------- Реализация ISet ---------- + + public bool Add(string item) => _inner.Add(Normalize(item)); + + void ICollection.Add(string item) => Add(item); + + public void UnionWith(IEnumerable other) + { + foreach (var item in other) + Add(item); + } + + public void IntersectWith(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + _inner.IntersectWith(normalizedOther); + } + + public void ExceptWith(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + _inner.ExceptWith(normalizedOther); + } + + public void SymmetricExceptWith(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + _inner.SymmetricExceptWith(normalizedOther); + } + + public bool IsSubsetOf(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.IsSubsetOf(normalizedOther); + } + + public bool IsSupersetOf(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.IsSupersetOf(normalizedOther); + } + + public bool IsProperSupersetOf(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.IsProperSupersetOf(normalizedOther); + } + + public bool IsProperSubsetOf(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.IsProperSubsetOf(normalizedOther); + } + + public bool Overlaps(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.Overlaps(normalizedOther); + } + + public bool SetEquals(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.SetEquals(normalizedOther); + } + + // ---------- Реализация ICollection ---------- + + public int Count => _inner.Count; + + public bool IsReadOnly => false; + + public void Clear() => _inner.Clear(); + + public bool Contains(string item) => _inner.Contains(Normalize(item)); + + public void CopyTo(string[] array, int arrayIndex) => _inner.CopyTo(array, arrayIndex); + + public bool Remove(string item) => _inner.Remove(Normalize(item)); + + // ---------- Реализация IEnumerable и IEnumerable ---------- + + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/PlaceholderFinder.cs b/QWERTYkez.ExcelProcessor/PlaceholderFinder.cs new file mode 100644 index 0000000..f03375a --- /dev/null +++ b/QWERTYkez.ExcelProcessor/PlaceholderFinder.cs @@ -0,0 +1,143 @@ +namespace QWERTYkez.ExcelProcessor; + +internal static class PlaceholderFinder +{ + /// Находит плейсхолдеры $...$ в указанных листах. + public static ISet FindInDocument(SpreadsheetDocument doc, params WorksheetPart[] worksheets) + { + var result = new NormalizedSet(); + if (doc.WorkbookPart is null) return result; + + var sharedStringTable = doc.WorkbookPart.SharedStringTablePart?.SharedStringTable; + foreach (var ws in worksheets) + ProcessWorksheet(ws, sharedStringTable, result); + + return result; + } + + /// Находит плейсхолдеры во всех листах документа. + public static ISet FindInDocument(SpreadsheetDocument doc) + { + if (doc.WorkbookPart is null) return new NormalizedSet(); + return FindInDocument(doc, [.. doc.WorkbookPart.WorksheetParts]); + } + + private static void ProcessWorksheet(WorksheetPart wsPart, SharedStringTable? sharedStrings, ISet result) + { + var worksheet = wsPart.Worksheet; + if (worksheet is null) return; + + ProcessCells(worksheet, sharedStrings, result); + ProcessHeaderFooter(worksheet, result); + } + + private static void ProcessCells(Worksheet worksheet, SharedStringTable? sharedStrings, ISet result) + { + var sheetData = worksheet.GetFirstChild(); + if (sheetData is null) return; + + List? tempList = null; // буфер для плейсхолдеров из одного текста + + foreach (var row in sheetData.Elements()) + { + foreach (var cell in row.Elements()) + { + string text = GetCellValue(cell, sharedStrings); + if (string.IsNullOrEmpty(text)) continue; + +#if DEBUG + if (text.Contains('$')) + Debug.WriteLine($"[PlaceholderFinder] Cell {cell.CellReference}: '{text}'"); +#endif + if (FindPlaceholdersInText(text, ref tempList)) + { + foreach (var ph in tempList!) + result.Add(ph); + tempList.Clear(); + } + } + } + } + + private static void ProcessHeaderFooter(Worksheet worksheet, ISet result) + { + var hf = worksheet.Descendants().FirstOrDefault(); + if (hf is null) return; + + var texts = new[] + { + hf.OddHeader?.Text, hf.OddFooter?.Text, + hf.EvenHeader?.Text, hf.EvenFooter?.Text, + hf.FirstHeader?.Text, hf.FirstFooter?.Text + }; + + List? tempList = null; + foreach (var text in texts) + { + if (string.IsNullOrEmpty(text)) continue; + if (FindPlaceholdersInText(text!, ref tempList)) + { + foreach (var ph in tempList!) + result.Add(ph); + tempList.Clear(); + } + } + } + + private static string GetCellValue(Cell cell, SharedStringTable? sharedStrings) + { + if (cell?.CellValue is null && cell?.InlineString is null) + return string.Empty; + + // Встроенная строка + if (cell.InlineString is not null) + return string.Concat(cell.InlineString.Descendants().Select(t => t.Text)); + + // Обычное значение или общая строка + string raw = cell.CellValue!.InnerText; + if (cell.DataType?.Value == CellValues.SharedString) + { + if (sharedStrings is not null && int.TryParse(raw, out int idx) && idx >= 0 && idx < sharedStrings.Count!) + { + var si = sharedStrings.ElementAt(idx); + return string.Concat(si.Descendants().Select(t => t.Text)); + } + return string.Empty; + } + return raw; + } + + private static unsafe bool FindPlaceholdersInText(string text, ref List? output) + { + fixed (char* pText = text) + { + char* start = pText; + char* end = pText + text.Length; + bool found = false; + + while (start < end) + { + // Ищем '$' + while (start < end && *start != '$') start++; + if (start >= end) break; + char* open = start++; + if (start >= end) break; + + // Ищем закрывающий '$' + char* contentStart = start; + while (start < end && *start != '$') start++; + if (start >= end) break; + char* close = start++; + + int len = (int)(close - contentStart); + if (len > 0) + { + found = true; + output ??= []; + output.Add(new string(contentStart, 0, len)); + } + } + return found; + } + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/QWERTYkez.ExcelProcessor.csproj b/QWERTYkez.ExcelProcessor/QWERTYkez.ExcelProcessor.csproj new file mode 100644 index 0000000..c1d22ee --- /dev/null +++ b/QWERTYkez.ExcelProcessor/QWERTYkez.ExcelProcessor.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.0 + latest + enable + true + + + + + + + + + + + \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/ReplaceNumericExtensions.cs b/QWERTYkez.ExcelProcessor/ReplaceNumericExtensions.cs new file mode 100644 index 0000000..7f500cc --- /dev/null +++ b/QWERTYkez.ExcelProcessor/ReplaceNumericExtensions.cs @@ -0,0 +1,524 @@ +using System.Globalization; + +namespace QWERTYkez.ExcelProcessor; + +internal static class ReplaceNumericExtensions +{ + // =========================== МНОЖЕСТВЕННЫЕ ЗАМЕНЫ =========================== + + // --- Double --- + internal static void Replace(this SpreadsheetDocument doc, + IEnumerable> replacements, string? format = null, + StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) + { + if (replacements is null) return; + WorksheetPart[] worksheets = [.. doc.WorkbookPart?.WorksheetParts!]; + if (worksheets.Length < 1) return; + + var coll = replacements as ICollection>; + int count = coll?.Count ?? replacements.Count(); + if (count == 0) return; + + var workbookPart = doc.WorkbookPart!; + var stringReplacements = new Dictionary(count); + foreach (var kvp in replacements) + stringReplacements[kvp.Key] = kvp.Value.ToString(format ?? "G", CultureInfo.CurrentCulture); + + ReplaceNumericCore(workbookPart, worksheets, replacements, stringReplacements, format, comparisonType, + (val, fmt) => val.ToString(fmt ?? "G", CultureInfo.InvariantCulture)); + } + + // --- Float --- + internal static void Replace(this SpreadsheetDocument doc, + IEnumerable> replacements, string? format = null, + StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) + { + if (replacements is null) return; + WorksheetPart[] worksheets = [.. doc.WorkbookPart?.WorksheetParts!]; + if (worksheets.Length < 1) return; + + var coll = replacements as ICollection>; + int count = coll?.Count ?? replacements.Count(); + if (count == 0) return; + + var workbookPart = doc.WorkbookPart!; + var stringReplacements = new Dictionary(count); + foreach (var kvp in replacements) + stringReplacements[kvp.Key] = kvp.Value.ToString(format ?? "G", CultureInfo.CurrentCulture); + + ReplaceNumericCore(workbookPart, worksheets, replacements, stringReplacements, format, comparisonType, + (val, fmt) => val.ToString(fmt ?? "G", CultureInfo.InvariantCulture)); + } + + // --- Int --- + internal static void Replace(this SpreadsheetDocument doc, + IEnumerable> replacements, + StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) + { + if (replacements is null) return; + WorksheetPart[] worksheets = [.. doc.WorkbookPart?.WorksheetParts!]; + if (worksheets.Length < 1) return; + + var coll = replacements as ICollection>; + int count = coll?.Count ?? replacements.Count(); + if (count == 0) return; + + var workbookPart = doc.WorkbookPart!; + var stringReplacements = new Dictionary(count); + foreach (var kvp in replacements) + stringReplacements[kvp.Key] = kvp.Value.ToString(CultureInfo.CurrentCulture); + + ReplaceNumericCore(workbookPart, worksheets, replacements, stringReplacements, null, comparisonType, + (val, _) => val.ToString(CultureInfo.InvariantCulture)); + } + + // --- Long --- + internal static void Replace(this SpreadsheetDocument doc, + IEnumerable> replacements, + StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) + { + if (replacements is null) return; + WorksheetPart[] worksheets = [.. doc.WorkbookPart?.WorksheetParts!]; + if (worksheets.Length < 1) return; + + var coll = replacements as ICollection>; + int count = coll?.Count ?? replacements.Count(); + if (count == 0) return; + + var workbookPart = doc.WorkbookPart!; + var stringReplacements = new Dictionary(count); + foreach (var kvp in replacements) + stringReplacements[kvp.Key] = kvp.Value.ToString(CultureInfo.CurrentCulture); + + ReplaceNumericCore(workbookPart, worksheets, replacements, stringReplacements, null, comparisonType, + (val, _) => val.ToString(CultureInfo.InvariantCulture)); + } + + // =========================== ОДИНОЧНЫЕ ЗАМЕНЫ =========================== + + // --- Double --- + internal static void Replace(this SpreadsheetDocument doc, + string oldValue, double newValue, string? format = null, + StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) + { + if (string.IsNullOrEmpty(oldValue)) return; + WorksheetPart[] worksheets = [.. doc.WorkbookPart?.WorksheetParts!]; + if (worksheets.Length < 1) return; + + var workbookPart = doc.WorkbookPart!; + ReplaceSingleCore(workbookPart, worksheets, oldValue, newValue, format, comparisonType, + (val, fmt) => val.ToString(fmt ?? "G", CultureInfo.InvariantCulture), + (val, fmt) => val.ToString(fmt ?? "G", CultureInfo.CurrentCulture)); + } + + // --- Float --- + internal static void Replace(this SpreadsheetDocument doc, + string oldValue, float newValue, string? format = null, + StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) + { + if (string.IsNullOrEmpty(oldValue)) return; + WorksheetPart[] worksheets = [.. doc.WorkbookPart?.WorksheetParts!]; + if (worksheets.Length < 1) return; + + var workbookPart = doc.WorkbookPart!; + ReplaceSingleCore(workbookPart, worksheets, oldValue, newValue, format, comparisonType, + (val, fmt) => val.ToString(fmt ?? "G", CultureInfo.InvariantCulture), + (val, fmt) => val.ToString(fmt ?? "G", CultureInfo.CurrentCulture)); + } + + // --- Int --- + internal static void Replace(this SpreadsheetDocument doc, + string oldValue, int newValue, + StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) + { + if (string.IsNullOrEmpty(oldValue)) return; + WorksheetPart[] worksheets = [.. doc.WorkbookPart?.WorksheetParts!]; + if (worksheets.Length < 1) return; + + var workbookPart = doc.WorkbookPart!; + ReplaceSingleCore(workbookPart, worksheets, oldValue, newValue, null, comparisonType, + (val, _) => val.ToString(CultureInfo.InvariantCulture), + (val, _) => val.ToString(CultureInfo.CurrentCulture)); + } + + // --- Long --- + internal static void Replace(this SpreadsheetDocument doc, + string oldValue, long newValue, + StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) + { + if (string.IsNullOrEmpty(oldValue)) return; + WorksheetPart[] worksheets = [.. doc.WorkbookPart?.WorksheetParts!]; + if (worksheets.Length < 1) return; + + var workbookPart = doc.WorkbookPart!; + ReplaceSingleCore(workbookPart, worksheets, oldValue, newValue, null, comparisonType, + (val, _) => val.ToString(CultureInfo.InvariantCulture), + (val, _) => val.ToString(CultureInfo.CurrentCulture)); + } + + // =========================== ОБЩАЯ ЛОГИКА =========================== + + private static void ReplaceNumericCore( + WorkbookPart workbookPart, + WorksheetPart[] worksheets, + IEnumerable> numericReplacements, + Dictionary stringReplacements, + string? format, + StringComparison comparisonType, + Func numberToStringForNumberCell) + { + var formatCache = new Dictionary(); + + uint? GetOrCreateStyleIndex(string fmt) + { + if (string.IsNullOrEmpty(fmt)) return null; + if (formatCache.TryGetValue(fmt, out var idx)) return idx; + var newIdx = CreateNumberFormat(workbookPart, fmt); + formatCache[fmt] = newIdx; + return newIdx; + } + + // Инициализация SharedStringTable (один раз) + var allSharedStrings = new List(); + var sharedStringIndexMap = new Dictionary(); + var sharedStringTable = workbookPart.SharedStringTablePart?.SharedStringTable; + if (sharedStringTable != null) + { + foreach (var item in sharedStringTable.Elements()) + { + var text = ConcatTexts(item.Descendants()); + sharedStringIndexMap[text] = allSharedStrings.Count; + allSharedStrings.Add(text); + } + } + + foreach (var worksheetPart in worksheets) + { + var worksheet = worksheetPart.Worksheet; + if (worksheet == null) continue; + var sheetData = worksheet.GetFirstChild(); + if (sheetData == null) continue; + + // Обработка ячеек + foreach (var row in sheetData.Elements()) + { + foreach (var cell in row.Elements()) + { + string originalText = GetCellTextForNumeric(cell, allSharedStrings); + if (string.IsNullOrEmpty(originalText)) continue; + + string? matchedKey = null; + T matchedVal = default!; + int matchStart = -1, matchLength = 0; + + foreach (var kvp in numericReplacements) + { + int idx = originalText.IndexOf(kvp.Key, comparisonType); + if (idx >= 0 && kvp.Key.Length > matchLength) + { + matchedKey = kvp.Key; + matchedVal = kvp.Value; + matchStart = idx; + matchLength = kvp.Key.Length; + } + } + if (matchedKey == null) continue; + + bool isFullCell = (matchStart == 0 && matchLength == originalText.Length); + if (isFullCell) + { + cell.DataType = CellValues.Number; + string numStr = numberToStringForNumberCell(matchedVal, format); + cell.CellValue = new CellValue(numStr); + if (!string.IsNullOrEmpty(format)) + { + var styleIdx = GetOrCreateStyleIndex(format!); + if (styleIdx.HasValue) + cell.StyleIndex = styleIdx.Value; + } + } + else + { + string replacementStr = stringReplacements[matchedKey]; + string newText = ReplaceSubstring(originalText, matchStart, matchLength, replacementStr); + SetCellText(cell, newText, allSharedStrings, sharedStringIndexMap); + } + } + } + + // Колонтитулы и комментарии + ReplaceInHeadersFooters(worksheetPart, stringReplacements, comparisonType); + ReplaceInComments(worksheetPart, stringReplacements, comparisonType); + } + + // Сохраняем SharedStringTable + UpdateSharedStringTable(workbookPart, allSharedStrings); + } + + private static void ReplaceSingleCore( + WorkbookPart workbookPart, + WorksheetPart[] worksheets, + string oldValue, + T newValue, + string? format, + StringComparison comparisonType, + Func numberToStringForNumberCell, + Func numberToStringForTextCell) + { + var formatCache = new Dictionary(); + + uint? GetOrCreateStyleIndex(string fmt) + { + if (string.IsNullOrEmpty(fmt)) return null; + if (formatCache.TryGetValue(fmt, out var idx)) return idx; + var newIdx = CreateNumberFormat(workbookPart, fmt); + formatCache[fmt] = newIdx; + return newIdx; + } + + var singleStringReplacement = new Dictionary + { + { oldValue, numberToStringForTextCell(newValue, format) } + }; + + // Инициализация SharedStringTable + var allSharedStrings = new List(); + var sharedStringIndexMap = new Dictionary(); + var sharedStringTable = workbookPart.SharedStringTablePart?.SharedStringTable; + if (sharedStringTable != null) + { + foreach (var item in sharedStringTable.Elements()) + { + var text = ConcatTexts(item.Descendants()); + sharedStringIndexMap[text] = allSharedStrings.Count; + allSharedStrings.Add(text); + } + } + + foreach (var worksheetPart in worksheets) + { + var worksheet = worksheetPart.Worksheet; + if (worksheet == null) continue; + var sheetData = worksheet.GetFirstChild(); + if (sheetData == null) continue; + + foreach (var row in sheetData.Elements()) + { + foreach (var cell in row.Elements()) + { + string originalText = GetCellTextForNumeric(cell, allSharedStrings); + if (string.IsNullOrEmpty(originalText)) continue; + + int idx = originalText.IndexOf(oldValue, comparisonType); + if (idx < 0) continue; + + bool isFullCell = (idx == 0 && oldValue.Length == originalText.Length); + if (isFullCell) + { + cell.DataType = CellValues.Number; + string numStr = numberToStringForNumberCell(newValue, format); + cell.CellValue = new CellValue(numStr); + if (!string.IsNullOrEmpty(format)) + { + var styleIdx = GetOrCreateStyleIndex(format!); + if (styleIdx.HasValue) + cell.StyleIndex = styleIdx.Value; + } + } + else + { + string replacementStr = numberToStringForTextCell(newValue, format); + string newText = ReplaceSubstring(originalText, idx, oldValue.Length, replacementStr); + SetCellText(cell, newText, allSharedStrings, sharedStringIndexMap); + } + } + } + + ReplaceInHeadersFooters(worksheetPart, singleStringReplacement, comparisonType); + ReplaceInComments(worksheetPart, singleStringReplacement, comparisonType); + } + + UpdateSharedStringTable(workbookPart, allSharedStrings); + } + + // =========================== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ =========================== + + private static string GetCellTextForNumeric(Cell cell, List allSharedStrings) + { + if (cell?.CellValue == null) return string.Empty; + + // InlineString – без LINQ + if (cell.InlineString != null) + { + var sb = new StringBuilder(); + foreach (var t in cell.InlineString.Descendants()) + sb.Append(t.Text); + return sb.ToString(); + } + + string val = cell.CellValue.InnerText; + if (cell.DataType?.Value == CellValues.SharedString) + { + if (int.TryParse(val, out int idx) && idx >= 0 && idx < allSharedStrings.Count) + return allSharedStrings[idx]; + return string.Empty; + } + return val; + } + + private static void SetCellText(Cell cell, string newText, + List allSharedStrings, Dictionary sharedStringIndexMap) + { + if (cell.InlineString != null) + { + // Очищаем старые тексты + foreach (var t in cell.InlineString.Descendants().ToList()) + t.Remove(); + cell.InlineString.AppendChild(new Text(newText)); + return; + } + + if (!sharedStringIndexMap.TryGetValue(newText, out int index)) + { + index = allSharedStrings.Count; + allSharedStrings.Add(newText); + sharedStringIndexMap[newText] = index; + } + cell.DataType = CellValues.SharedString; + cell.CellValue = new CellValue(index.ToString()); + } + + private static void UpdateSharedStringTable(WorkbookPart workbookPart, List allSharedStrings) + { + var ssPart = workbookPart.SharedStringTablePart; + ssPart ??= workbookPart.AddNewPart(); + var sharedStringTable = ssPart.SharedStringTable ?? new SharedStringTable(); + sharedStringTable.RemoveAllChildren(); + foreach (var str in allSharedStrings) + sharedStringTable.AppendChild(new SharedStringItem(new Text(str))); + sharedStringTable.Save(); + } + + private static string ConcatTexts(IEnumerable texts) + { + var sb = new StringBuilder(); + foreach (var t in texts) + sb.Append(t.Text); + return sb.ToString(); + } + + // Оптимизированная замена подстроки через string.Create (без unsafe) + private static unsafe string ReplaceSubstring(string original, int start, int length, string replacement) + { + if (length == 0) return original; + int newLen = original.Length - length + replacement.Length; + if (newLen <= 0) return replacement; + + fixed (char* pOrig = original, pRep = replacement) + { + char* result = stackalloc char[newLen]; + int pos = 0; + for (int i = 0; i < start; i++) + result[pos++] = pOrig[i]; + for (int i = 0; i < replacement.Length; i++) + result[pos++] = pRep[i]; + for (int i = start + length; i < original.Length; i++) + result[pos++] = pOrig[i]; + return new string(result, 0, newLen); + } + } + + private static uint CreateNumberFormat(WorkbookPart workbookPart, string format) + { + var stylesPart = workbookPart.WorkbookStylesPart; + if (stylesPart == null) + { + stylesPart = workbookPart.AddNewPart(); + stylesPart.Stylesheet = new Stylesheet(); + } + var ss = stylesPart.Stylesheet!; + ss.NumberingFormats ??= new NumberingFormats(); + uint nextId = 164; + if (ss.NumberingFormats.Elements().Any()) + nextId = ss.NumberingFormats.Elements().Max(nf => nf.NumberFormatId!.Value) + 1; + var nf = new NumberingFormat { NumberFormatId = nextId, FormatCode = format }; + ss.NumberingFormats.AppendChild(nf); + ss.CellFormats ??= new CellFormats(); + var cf = new DocumentFormat.OpenXml.Spreadsheet.CellFormat + { + NumberFormatId = nextId, + FormatId = 0, + ApplyNumberFormat = true + }; + ss.CellFormats.AppendChild(cf); + ss.Save(); + return ss.CellFormats.Count!.Value - 1; + } + + // =========================== КОЛОНТИТУЛЫ И КОММЕНТАРИИ =========================== + + private static void ReplaceInHeadersFooters(WorksheetPart worksheetPart, Dictionary replacementDict, StringComparison comparisonType) + { + var worksheet = worksheetPart.Worksheet; + if (worksheet is null) return; + var headerFooter = worksheet.Descendants().FirstOrDefault(); + if (headerFooter is null) return; + + foreach (var elem in new OpenXmlLeafTextElement?[] { headerFooter.OddHeader, headerFooter.OddFooter, headerFooter.EvenHeader, headerFooter.EvenFooter, headerFooter.FirstHeader, headerFooter.FirstFooter }) + ReplaceHeaderFooter(elem, replacementDict, comparisonType); + } + + private static void ReplaceHeaderFooter(OpenXmlLeafTextElement? element, Dictionary replacementDict, StringComparison comparisonType) + { + if (element?.Text is null) return; + string original = element.Text; + string processed = ProcessReplacements(original, replacementDict, comparisonType); + if (processed != original) + element.Text = processed; + } + + private static void ReplaceInComments(WorksheetPart worksheetPart, Dictionary replacementDict, StringComparison comparisonType) + { + var commentsPart = worksheetPart.WorksheetCommentsPart; + if (commentsPart?.Comments is null) return; + foreach (var comment in commentsPart.Comments.Elements()) + { + var textElement = comment.Descendants().FirstOrDefault(); + if (textElement?.Text is null) continue; + string original = textElement.Text.Text; + if (string.IsNullOrEmpty(original)) continue; + string processed = ProcessReplacements(original, replacementDict, comparisonType); + if (processed != original) + textElement.Text.Text = processed; + } + } + + private static string ProcessReplacements(string input, Dictionary replacementDict, StringComparison comparisonType) + { + if (string.IsNullOrEmpty(input) || replacementDict.Count == 0) return input; + string result = input; + foreach (string key in replacementDict.Keys.OrderByDescending(k => k.Length)) + { + string value = replacementDict[key]; + result = ReplaceInString(result, key, value, comparisonType); + } + return result; + } + + private static string ReplaceInString(string original, string oldValue, string newValue, StringComparison comparisonType) + { + int idx = original.IndexOf(oldValue, comparisonType); + if (idx < 0) return original; + var sb = new StringBuilder(original.Length + newValue.Length - oldValue.Length); + int last = 0; + while (idx >= 0) + { + sb.Append(original, last, idx - last); + sb.Append(newValue); + last = idx + oldValue.Length; + idx = original.IndexOf(oldValue, last, comparisonType); + } + sb.Append(original, last, original.Length - last); + return sb.ToString(); + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/ReplaceStringExtensions.cs b/QWERTYkez.ExcelProcessor/ReplaceStringExtensions.cs new file mode 100644 index 0000000..b14f1e6 --- /dev/null +++ b/QWERTYkez.ExcelProcessor/ReplaceStringExtensions.cs @@ -0,0 +1,217 @@ +namespace QWERTYkez.ExcelProcessor; + +internal static class ReplaceStringExtensions +{ + // --- Публичный метод для одиночной замены --- + internal static void Replace(this SpreadsheetDocument doc, string oldValue, string newValue, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) + { + if (string.IsNullOrEmpty(oldValue)) return; + WorksheetPart[] worksheets = [.. doc.WorkbookPart?.WorksheetParts!]; + if (worksheets.Length < 1) return; + + var replacement = new Dictionary { [oldValue] = newValue }; + ReplaceCore(doc, worksheets, replacement, comparisonType); + } + + // --- Публичный метод для множественной замены --- + internal static void Replace(this SpreadsheetDocument doc, IEnumerable> replacements, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) + { + if (replacements is null) return; + WorksheetPart[] worksheets = [.. doc.WorkbookPart?.WorksheetParts!]; + if (worksheets.Length < 1) return; + + var comparer = GetComparerForStringComparison(comparisonType); + var replacementDict = new Dictionary(comparer); + foreach (var kvp in replacements) + if (!string.IsNullOrEmpty(kvp.Key)) + replacementDict[kvp.Key] = kvp.Value; + if (replacementDict.Count == 0) return; + ReplaceCore(doc, worksheets, replacementDict, comparisonType); + } + + // --- Общий приватный метод, содержащий всю логику замены --- + private static void ReplaceCore(SpreadsheetDocument doc, WorksheetPart[] worksheets, Dictionary replacementDict, StringComparison comparisonType) + { + var workbookPart = doc.WorkbookPart!; + + // 1. Собрать все изменения для ячеек + var cellChanges = new Dictionary(); + var allSharedStrings = new List(); + var sharedStringTable = workbookPart.SharedStringTablePart?.SharedStringTable; + if (sharedStringTable is not null) + { + foreach (var item in sharedStringTable.Elements()) + allSharedStrings.Add(string.Concat(item.Descendants().Select(t => t.Text))); + } + + foreach (var worksheetPart in worksheets) + CollectCellChanges(worksheetPart, replacementDict, comparisonType, cellChanges, allSharedStrings); + + // 2. Применить изменения к ячейкам + ApplyCellChanges(cellChanges, allSharedStrings); + + // 3. Обновить SharedStringTable + UpdateSharedStringTable(workbookPart, allSharedStrings); + + // 4. Обработать колонтитулы + foreach (var worksheetPart in worksheets) + ReplaceInHeadersFooters(worksheetPart, replacementDict, comparisonType); + + // 5. Обработать комментарии + foreach (var worksheetPart in worksheets) + ReplaceInComments(worksheetPart, replacementDict, comparisonType); + } + + // --- Остальные вспомогательные методы (без изменений) --- + private static IEqualityComparer GetComparerForStringComparison(StringComparison comparisonType) => + comparisonType switch + { + StringComparison.Ordinal => StringComparer.Ordinal, + StringComparison.OrdinalIgnoreCase => StringComparer.OrdinalIgnoreCase, + StringComparison.InvariantCulture => StringComparer.InvariantCulture, + StringComparison.InvariantCultureIgnoreCase => StringComparer.InvariantCultureIgnoreCase, + StringComparison.CurrentCulture => StringComparer.CurrentCulture, + StringComparison.CurrentCultureIgnoreCase => StringComparer.CurrentCultureIgnoreCase, + _ => StringComparer.OrdinalIgnoreCase, + }; + + private static void CollectCellChanges(WorksheetPart worksheetPart, Dictionary replacementDict, StringComparison comparisonType, Dictionary cellChanges, List allSharedStrings) + { + var worksheet = worksheetPart.Worksheet; + if (worksheet is null) return; + var sheetData = worksheet.GetFirstChild(); + if (sheetData is null) return; + + foreach (var row in sheetData.Elements()) + foreach (var cell in row.Elements()) + { + string originalText = GetCellText(cell, allSharedStrings); + if (string.IsNullOrEmpty(originalText)) continue; + string newText = ProcessReplacements(originalText, replacementDict, comparisonType); + if (newText != originalText) + cellChanges[cell] = newText; + } + } + + private static void ApplyCellChanges(Dictionary cellChanges, List allSharedStrings) + { + foreach (var kvp in cellChanges) + { + var cell = kvp.Key; + var newText = kvp.Value; + if (cell.InlineString is not null) + { + var texts = cell.InlineString.Descendants().ToList(); + foreach (var t in texts) t.Remove(); + cell.InlineString.AppendChild(new Text(newText)); + } + else + { + int newIndex = AddOrFindStringIndex(allSharedStrings, newText); + cell.DataType = new EnumValue(CellValues.SharedString); + cell.CellValue = new CellValue(newIndex.ToString()); + } + } + } + + private static void UpdateSharedStringTable(WorkbookPart workbookPart, List allSharedStrings) + { + var ssPart = workbookPart.SharedStringTablePart; + ssPart ??= workbookPart.AddNewPart(); + var sharedStringTable = ssPart.SharedStringTable ?? new SharedStringTable(); + sharedStringTable.RemoveAllChildren(); + foreach (var str in allSharedStrings) + sharedStringTable.AppendChild(new SharedStringItem(new Text(str))); + sharedStringTable.Save(); + } + + private static string ProcessReplacements(string input, Dictionary replacementDict, StringComparison comparisonType) + { + if (string.IsNullOrEmpty(input) || replacementDict.Count == 0) return input; + string result = input; + foreach (string key in replacementDict.Keys.OrderByDescending(k => k.Length)) + { + string value = replacementDict[key]; + result = ReplaceInString(result, key, value, comparisonType); + } + return result; + } + + private static string ReplaceInString(string original, string oldValue, string newValue, StringComparison comparisonType) + { + int idx = original.IndexOf(oldValue, comparisonType); + if (idx < 0) return original; + var sb = new StringBuilder(original.Length + newValue.Length - oldValue.Length); + int last = 0; + while (idx >= 0) + { + sb.Append(original, last, idx - last); + sb.Append(newValue); + last = idx + oldValue.Length; + idx = original.IndexOf(oldValue, last, comparisonType); + } + sb.Append(original, last, original.Length - last); + return sb.ToString(); + } + + private static string GetCellText(Cell cell, List allSharedStrings) + { + if (cell?.CellValue is null) return string.Empty; + if (cell.InlineString is not null) + return string.Concat(cell.InlineString.Descendants().Select(t => t.Text)); + string value = cell.CellValue.InnerText; + if (cell.DataType?.Value == CellValues.SharedString) + { + if (int.TryParse(value, out int idx) && idx >= 0 && idx < allSharedStrings.Count) + return allSharedStrings[idx]; + return string.Empty; + } + return value; + } + + private static int AddOrFindStringIndex(List allSharedStrings, string text) + { + int idx = allSharedStrings.IndexOf(text); + if (idx >= 0) return idx; + allSharedStrings.Add(text); + return allSharedStrings.Count - 1; + } + + // --- Колонтитулы (обобщённый метод) --- + private static void ReplaceInHeadersFooters(WorksheetPart worksheetPart, Dictionary replacementDict, StringComparison comparisonType) + { + var worksheet = worksheetPart.Worksheet; + if (worksheet is null) return; + var headerFooter = worksheet.Descendants().FirstOrDefault(); + if (headerFooter is null) return; + + foreach (var elem in new OpenXmlLeafTextElement?[] { headerFooter.OddHeader, headerFooter.OddFooter, headerFooter.EvenHeader, headerFooter.EvenFooter, headerFooter.FirstHeader, headerFooter.FirstFooter }) + ReplaceHeaderFooter(elem, replacementDict, comparisonType); + } + + private static void ReplaceHeaderFooter(OpenXmlLeafTextElement? element, Dictionary replacementDict, StringComparison comparisonType) + { + if (element?.Text is null) return; + string original = element.Text; + string processed = ProcessReplacements(original, replacementDict, comparisonType); + if (processed != original) + element.Text = processed; + } + + // --- Комментарии --- + private static void ReplaceInComments(WorksheetPart worksheetPart, Dictionary replacementDict, StringComparison comparisonType) + { + var commentsPart = worksheetPart.WorksheetCommentsPart; + if (commentsPart?.Comments is null) return; + foreach (var comment in commentsPart.Comments.Elements()) + { + var textElement = comment.Descendants().FirstOrDefault(); + if (textElement?.Text is null) continue; + string original = textElement.Text.Text; + if (string.IsNullOrEmpty(original)) continue; + string processed = ProcessReplacements(original, replacementDict, comparisonType); + if (processed != original) + textElement.Text.Text = processed; + } + } +} \ No newline at end of file diff --git a/QWERTYkez.ExcelProcessor/globals.cs b/QWERTYkez.ExcelProcessor/globals.cs new file mode 100644 index 0000000..abaae0b --- /dev/null +++ b/QWERTYkez.ExcelProcessor/globals.cs @@ -0,0 +1,12 @@ +global using DocumentFormat.OpenXml; +global using DocumentFormat.OpenXml.Packaging; +global using DocumentFormat.OpenXml.Spreadsheet; +global using System; +global using System.Collections.Generic; +global using System.Diagnostics; +global using System.Globalization; +global using System.IO; +global using System.Linq; +global using System.Text; +global using System.Threading; +global using System.Threading.Tasks; \ No newline at end of file diff --git a/QWERTYkez.OpenXmlProcessors.slnx b/QWERTYkez.OpenXmlProcessors.slnx new file mode 100644 index 0000000..8eaa487 --- /dev/null +++ b/QWERTYkez.OpenXmlProcessors.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/QWERTYkez.WordProcessor/Builders/CellProps.cs b/QWERTYkez.WordProcessor/Builders/CellProps.cs new file mode 100644 index 0000000..c529730 --- /dev/null +++ b/QWERTYkez.WordProcessor/Builders/CellProps.cs @@ -0,0 +1,89 @@ +namespace QWERTYkez.WordProcessor.Builders; + +public readonly struct CellProps(double? width = null!) +{ + public double? Width { get; init; } = width; + public TableVerticalAlignmentValues? VerticalAlignment { get; init; } = default; + public MergedCell? Merge { get; init; } = default; + + +#pragma warning disable IDE1006 // Стили именования + public static CellProps V_H_ { get; } = new() { Merge = MergedCell.V_H_ }; + public static CellProps H_ { get; } = new() { Merge = MergedCell.H_ }; + public static CellProps V_ { get; } = new() { Merge = MergedCell.V_ }; + public static CellProps _V_H { get; } = new() { Merge = MergedCell._V_H }; + public static CellProps _H { get; } = new() { Merge = MergedCell._H }; + public static CellProps _V { get; } = new() { Merge = MergedCell._V }; + public static CellProps _VH_ { get; } = new() { Merge = MergedCell._VH_ }; + public static CellProps V__H { get; } = new() { Merge = MergedCell.V__H }; +#pragma warning restore IDE1006 // Стили именования + + + internal bool TryExtract(out List list) + { + list = + [ + new TableCellVerticalAlignment() { Val = VerticalAlignment ?? TableVerticalAlignmentValues.Center }, + new TableCellMargin() + { + TopMargin = new TopMargin() { Width = "0" }, + BottomMargin = new BottomMargin() { Width = "0" }, + LeftMargin = new LeftMargin() { Width = "0" }, + RightMargin = new RightMargin() { Width = "0" } + } + ]; + + if (Width.HasValue) + list.Add(new TableCellWidth() + { + Type = TableWidthUnitValues.Dxa, + Width = ((uint)(Width.Value * 567d)).ToString() // 1 см = 567 DXA + }); + + if (Merge.HasValue) + { + switch (Merge.Value) + { + case MergedCell.V_H_: + list.Add(new HorizontalMerge() { Val = MergedCellValues.Restart }); + list.Add(new VerticalMerge() { Val = MergedCellValues.Restart }); + break; + case MergedCell.H_: + list.Add(new HorizontalMerge() { Val = MergedCellValues.Restart }); break; + case MergedCell.V_: + list.Add(new VerticalMerge() { Val = MergedCellValues.Restart }); break; + case MergedCell._V_H: + list.Add(new HorizontalMerge() { Val = MergedCellValues.Continue }); + list.Add(new VerticalMerge() { Val = MergedCellValues.Continue }); + break; + case MergedCell._H: + list.Add(new HorizontalMerge() { Val = MergedCellValues.Continue }); break; + case MergedCell._V: + list.Add(new VerticalMerge() { Val = MergedCellValues.Continue }); break; + case MergedCell._VH_: + list.Add(new HorizontalMerge() { Val = MergedCellValues.Restart }); + list.Add(new VerticalMerge() { Val = MergedCellValues.Continue }); + break; + case MergedCell.V__H: + list.Add(new HorizontalMerge() { Val = MergedCellValues.Continue }); + list.Add(new VerticalMerge() { Val = MergedCellValues.Restart }); + break; + } + } + + return list.Count > 0; + } +} +public enum MergedCell +{ + V_H_, + H_, + V_, + + _V_H, + _H, + _V, + + _VH_, + V__H, +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/Builders/FontProps.cs b/QWERTYkez.WordProcessor/Builders/FontProps.cs new file mode 100644 index 0000000..d881f61 --- /dev/null +++ b/QWERTYkez.WordProcessor/Builders/FontProps.cs @@ -0,0 +1,162 @@ +using MathStyle = DocumentFormat.OpenXml.Math.Style; +using MathStyleValues = DocumentFormat.OpenXml.Math.StyleValues; + +namespace QWERTYkez.WordProcessor.Builders; + +public record FontProps +{ + public FontProps(double? size = null) => Size = size ?? 12; + + public string? FontFamily { get; init; } + + public double? Size + { + get => _SizeD.HasValue ? _SizeD.Value / 2d : null; + init => _SizeD = value.HasValue ? (uint?)(value.Value * 2) : null; + } + uint? _SizeD; + + public bool IsItalic { get; init; } = false; + + public bool IsBold { get; init; } = false; + + public UnderlineValues? Underline { get; init; } + + public VerticalPositionValues? SubSup { get; init; } + + public string? Color { get; init; } + + + public bool TryExtract(out List list) + { + list = []; + + if (!string.IsNullOrWhiteSpace(FontFamily)) + list.Add(new RunFonts { Ascii = FontFamily, HighAnsi = FontFamily }); + + if (_SizeD is not null) + list.Add(new FontSize { Val = _SizeD.Value.ToString() }); + + if (IsBold) + list.Add(new Bold()); + + if (IsItalic) + list.Add(new Italic()); + + if (Underline is not null) + list.Add(new Underline { Val = Underline }); + + if (!string.IsNullOrWhiteSpace(Color)) + list.Add(new Color { Val = Color }); + + if (SubSup is not null) + list.Add(new VerticalTextAlignment { Val = SubSup }); + + return list.Count > 0; + } + public bool TryExtractWithoutFamily(out List list) + { + list = []; + + if (_SizeD is not null) + list.Add(new FontSize { Val = _SizeD.Value.ToString() }); + + if (IsBold) + list.Add(new Bold()); + + if (IsItalic) + list.Add(new Italic()); + + if (Underline is not null) + list.Add(new Underline { Val = Underline }); + + if (!string.IsNullOrWhiteSpace(Color)) + list.Add(new Color { Val = Color }); + + if (SubSup is not null) + list.Add(new VerticalTextAlignment { Val = SubSup }); + + return list.Count > 0; + } + public bool TrySupExtract(out List list) + { + list = []; + + if (!string.IsNullOrWhiteSpace(FontFamily)) + list.Add(new RunFonts { Ascii = FontFamily, HighAnsi = FontFamily }); + + if (_SizeD is not null) + list.Add(new FontSize { Val = _SizeD.Value.ToString() }); + + if (IsBold) + list.Add(new Bold()); + + if (IsItalic) + list.Add(new Italic()); + + if (Underline is not null) + list.Add(new Underline { Val = Underline }); + + if (!string.IsNullOrWhiteSpace(Color)) + list.Add(new Color { Val = Color }); + + list.Add(new VerticalTextAlignment { Val = VerticalPositionValues.Superscript }); + + return list.Count > 0; + } + public bool TrySubExtract(out List list) + { + list = []; + + if (!string.IsNullOrWhiteSpace(FontFamily)) + list.Add(new RunFonts { Ascii = FontFamily, HighAnsi = FontFamily }); + + if (_SizeD is not null) + list.Add(new FontSize { Val = _SizeD.Value.ToString() }); + + if (IsBold) + list.Add(new Bold()); + + if (IsItalic) + list.Add(new Italic()); + + if (Underline is not null) + list.Add(new Underline { Val = Underline }); + + if (!string.IsNullOrWhiteSpace(Color)) + list.Add(new Color { Val = Color }); + + list.Add(new VerticalTextAlignment { Val = VerticalPositionValues.Subscript }); + + return list.Count > 0; + } + + public bool TryExtractForMath(out List list) + { + list = []; + + // Для математики используем только элемент Style + if (IsBold) + list.Add(new MathStyle { Val = MathStyleValues.BoldItalic }); + else list.Add(new MathStyle { Val = MathStyleValues.Italic }); + // Обычное начертание можно не добавлять, так как по умолчанию и так обычное + // (но если нужно явно сбросить, можно добавить Style { Val = StyleValues.Plain }) + + return list.Count > 0; + } + + // Фабричные методы для создания FontProps + public static FontProps Default => new(); + + public static FontProps WithFont(string fontFamily, double? size = null) => + new(size) { FontFamily = fontFamily }; + + public static FontProps WithSize(double size) => + new(size); + + public static FontProps WithStyle(bool bold = false, bool italic = false) => + new() { IsBold = bold, IsItalic = italic }; + + public static FontProps WithColor(string hexColor) => + new() { Color = hexColor }; +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/Builders/FormulaHelper.cs b/QWERTYkez.WordProcessor/Builders/FormulaHelper.cs new file mode 100644 index 0000000..b689e93 --- /dev/null +++ b/QWERTYkez.WordProcessor/Builders/FormulaHelper.cs @@ -0,0 +1,357 @@ +using Base = DocumentFormat.OpenXml.Math.Base; +using ControlProperties = DocumentFormat.OpenXml.Math.ControlProperties; +using Degree = DocumentFormat.OpenXml.Math.Degree; +using Denominator = DocumentFormat.OpenXml.Math.Denominator; +using Fraction = DocumentFormat.OpenXml.Math.Fraction; +using FractionProperties = DocumentFormat.OpenXml.Math.FractionProperties; +using HideDegree = DocumentFormat.OpenXml.Math.HideDegree; +using MathRun = DocumentFormat.OpenXml.Math.Run; +using MathRunProperties = DocumentFormat.OpenXml.Math.RunProperties; +using MathText = DocumentFormat.OpenXml.Math.Text; +using Numerator = DocumentFormat.OpenXml.Math.Numerator; +using Radical = DocumentFormat.OpenXml.Math.Radical; +using RadicalProperties = DocumentFormat.OpenXml.Math.RadicalProperties; +using SubArgument = DocumentFormat.OpenXml.Math.SubArgument; +using Subscript = DocumentFormat.OpenXml.Math.Subscript; +using SubSuperscript = DocumentFormat.OpenXml.Math.SubSuperscript; +using SuperArgument = DocumentFormat.OpenXml.Math.SuperArgument; +using Superscript = DocumentFormat.OpenXml.Math.Superscript; + +namespace QWERTYkez.WordProcessor.Builders; + +internal static class FormulaHelper +{ + // Вспомогательный метод для создания MathRun с форматированием + public static MathRun CreateMathRun(FontProps? font) + { + var mathRun = new MathRun(); + + // 1. Математический стиль (полужирный/курсив) – добавляем первым + if (font is not null && font.TryExtractForMath(out var mathStyleElements)) + { + var mathPr = new MathRunProperties(mathStyleElements); + mathRun.AppendChild(mathPr); + } + + // 2. Wordprocessing: цвет, размер, подчёркивание (без семейства шрифта) + var wordPr = new RunProperties(); + if (font is not null && font.TryExtractWithoutFamily(out var wordElements)) + { + foreach (var elem in wordElements) + wordPr.AppendChild(elem.CloneNode(true)); + } + mathRun.AppendChild(wordPr); + + return mathRun; + } + + public static void AddText(OpenXmlElement parent, string text, FontProps? font) + { + if (parent is MathRun mathRun) + { + mathRun.AppendChild(new MathText(text)); + } + else + { + var run = CreateMathRun(font); + run.AppendChild(new MathText(text)); + parent.AppendChild(run); + } + } + + public static Fraction CreateFraction( + Action numeratorBuilder, + Action denominatorBuilder, + IFormula builder) + { + var fraction = new Fraction(); + var font = builder.BaseFont; + + // Добавляем свойства дроби с форматированием (для черты дроби) + var fPr = new FractionProperties(); + if (font.TryExtractWithoutFamily(out var wordElements)) + { + var ctrlPr = new ControlProperties(); + var rPr = new RunProperties(); + foreach (var elem in wordElements) + rPr.AppendChild(elem.CloneNode(true)); + rPr.AppendChild(new RunFonts { Ascii = "Cambria Math", HighAnsi = "Cambria Math" }); + ctrlPr.AppendChild(rPr); + fPr.AppendChild(ctrlPr); + } + fraction.AppendChild(fPr); + + // Числитель + var numeratorElem = new Numerator(); + fraction.AppendChild(numeratorElem); + builder.PushContext(numeratorElem); + numeratorBuilder(builder); + builder.PopContext(); + + // Знаменатель + var denominatorElem = new Denominator(); + fraction.AppendChild(denominatorElem); + builder.PushContext(denominatorElem); + denominatorBuilder(builder); + builder.PopContext(); + + return fraction; + } + + public static Radical CreateRadical( + Action radicandBuilder, + Action? degreeBuilder, + IFormula builder) + { + var radical = new Radical(); + var font = builder.BaseFont; + + var radPr = new RadicalProperties(); + + // Цвет, размер и подчёркивание для знака корня (ControlProperties) + if (font is not null && font.TryExtractWithoutFamily(out var wordElements)) + { + var ctrlPr = new ControlProperties(); + var rPr = new RunProperties(); + foreach (var elem in wordElements) + rPr.AppendChild(elem.CloneNode(true)); + rPr.AppendChild(new RunFonts { Ascii = "Cambria Math", HighAnsi = "Cambria Math" }); + ctrlPr.AppendChild(rPr); + radPr.AppendChild(ctrlPr); + } + + // Если степень не задана, скрываем её + if (degreeBuilder is null) + { + radPr.HideDegree = new HideDegree { Val = DocumentFormat.OpenXml.Math.BooleanValues.One }; + } + + // Добавляем свойства радикала (с цветом) + radical.AppendChild(radPr); + + // Степень (если есть) + if (degreeBuilder is not null) + { + var degree = new Degree(); + radical.AppendChild(degree); + builder.PushContext(degree); + degreeBuilder(builder); + builder.PopContext(); + } + + // Подкоренное выражение + var radicand = new Base(); + radical.AppendChild(radicand); + builder.PushContext(radicand); + radicandBuilder(builder); + builder.PopContext(); + + return radical; + } + + public static void AddIntegral( + OpenXmlElement currentContext, + Action functionBuilder, + Action differentialBuilder, + Action? lowerLimitBuilder, + Action? upperLimitBuilder, + IFormula builder) + { + var font = builder.BaseFont; + + if (lowerLimitBuilder is null && upperLimitBuilder is null) + { + var integralRun = CreateMathRun(font); + integralRun.AppendChild(new MathText("∫")); + currentContext.AppendChild(integralRun); + + var funcRun = CreateMathRun(font); + currentContext.AppendChild(funcRun); + builder.PushContext(funcRun); + functionBuilder(builder); + builder.PopContext(); + + var diffRun = CreateMathRun(font); + currentContext.AppendChild(diffRun); + builder.PushContext(diffRun); + differentialBuilder(builder); + builder.PopContext(); + } + else + { + var integralWithLimits = new SubSuperscript(); + currentContext.AppendChild(integralWithLimits); + + var baseElem = new Base(); + var integralRun = CreateMathRun(font); + integralRun.AppendChild(new MathText("∫")); + baseElem.AppendChild(integralRun); + integralWithLimits.AppendChild(baseElem); + + if (lowerLimitBuilder is not null) + { + var subArg = new SubArgument(); + integralWithLimits.AppendChild(subArg); + builder.PushContext(subArg); + lowerLimitBuilder(builder); + builder.PopContext(); + } + if (upperLimitBuilder is not null) + { + var superArg = new SuperArgument(); + integralWithLimits.AppendChild(superArg); + builder.PushContext(superArg); + upperLimitBuilder(builder); + builder.PopContext(); + } + + var funcRun = CreateMathRun(font); + currentContext.AppendChild(funcRun); + builder.PushContext(funcRun); + functionBuilder(builder); + builder.PopContext(); + + var diffRun = CreateMathRun(font); + currentContext.AppendChild(diffRun); + builder.PushContext(diffRun); + differentialBuilder(builder); + builder.PopContext(); + } + } + + public static void AddSum( + OpenXmlElement currentContext, + Action expressionBuilder, + Action? lowerLimitBuilder, + Action? upperLimitBuilder, + IFormula builder) + { + var font = builder.BaseFont; + + if (lowerLimitBuilder is null && upperLimitBuilder is null) + { + var sumRun = CreateMathRun(font); + sumRun.AppendChild(new MathText("∑")); + currentContext.AppendChild(sumRun); + + var exprRun = CreateMathRun(font); + currentContext.AppendChild(exprRun); + builder.PushContext(exprRun); + expressionBuilder(builder); + builder.PopContext(); + } + else + { + var sumWithLimits = new SubSuperscript(); + currentContext.AppendChild(sumWithLimits); + + var baseElem = new Base(); + var sumRun = CreateMathRun(font); + sumRun.AppendChild(new MathText("∑")); + baseElem.AppendChild(sumRun); + sumWithLimits.AppendChild(baseElem); + + if (lowerLimitBuilder is not null) + { + var subArg = new SubArgument(); + sumWithLimits.AppendChild(subArg); + builder.PushContext(subArg); + lowerLimitBuilder(builder); + builder.PopContext(); + } + if (upperLimitBuilder is not null) + { + var superArg = new SuperArgument(); + sumWithLimits.AppendChild(superArg); + builder.PushContext(superArg); + upperLimitBuilder(builder); + builder.PopContext(); + } + + var exprRun = CreateMathRun(font); + currentContext.AppendChild(exprRun); + builder.PushContext(exprRun); + expressionBuilder(builder); + builder.PopContext(); + } + } + + + /// Создаёт степень. + public static Superscript CreateSuperscript( + Action baseBuilder, + Action supBuilder, + IFormula builder) + { + var superscript = new Superscript(); + + // Основание + var baseElem = new Base(); + superscript.AppendChild(baseElem); + builder.PushContext(baseElem); + baseBuilder(builder); + builder.PopContext(); + + // Показатель + var superArg = new SuperArgument(); + superscript.AppendChild(superArg); + builder.PushContext(superArg); + supBuilder(builder); + builder.PopContext(); + + return superscript; + } + + /// Создаёт нижний индекс. + public static Subscript CreateSubscript( + Action baseBuilder, + Action subBuilder, + IFormula builder) + { + var subscript = new Subscript(); + + var baseElem = new Base(); + subscript.AppendChild(baseElem); + builder.PushContext(baseElem); + baseBuilder(builder); + builder.PopContext(); + + var subArg = new SubArgument(); + subscript.AppendChild(subArg); + builder.PushContext(subArg); + subBuilder(builder); + builder.PopContext(); + + return subscript; + } + + /// Создаёт одновременные нижний и верхний индексы. + public static SubSuperscript CreateSubSuperscript( + Action baseBuilder, + Action subBuilder, + Action supBuilder, + IFormula builder) + { + var subSup = new SubSuperscript(); + + var baseElem = new Base(); + subSup.AppendChild(baseElem); + builder.PushContext(baseElem); + baseBuilder(builder); + builder.PopContext(); + + var subArg = new SubArgument(); + subSup.AppendChild(subArg); + builder.PushContext(subArg); + subBuilder(builder); + builder.PopContext(); + + var superArg = new SuperArgument(); + subSup.AppendChild(superArg); + builder.PushContext(superArg); + supBuilder(builder); + builder.PopContext(); + + return subSup; + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/Builders/Interfaces.cs b/QWERTYkez.WordProcessor/Builders/Interfaces.cs new file mode 100644 index 0000000..d8069c6 --- /dev/null +++ b/QWERTYkez.WordProcessor/Builders/Interfaces.cs @@ -0,0 +1,315 @@ +namespace QWERTYkez.WordProcessor.Builders; + +public interface ITable +{ + /// Базовый шрифт, используемый по умолчанию для содержимого таблицы. + FontProps BaseFont { get; } + + /// Добавляет строку в таблицу, настраиваемую с помощью . + /// Делегат для конфигурации строки через . + /// Тот же экземпляр для цепочки вызовов. + ITable AddRow(Action configure); + + /// Добавляет строку с указанной высотой, настраиваемую с помощью . + /// Высота строки в сантиметрах. + /// Делегат для конфигурации строки через . + /// Тот же экземпляр для цепочки вызовов. + ITable AddRow(double height, Action configure); + + /// Добавляет строку с ячейками, содержащими заданные тексты, используя указанный шрифт. + /// Шрифт для всех ячеек строки. + /// Массив текстов для ячеек. + /// Тот же экземпляр для цепочки вызовов. + ITable AddRowWithCells(FontProps font, params string[] cellTexts); + + /// Добавляет строку с ячейками, содержащими заданные тексты, используя базовый шрифт таблицы. + /// Массив текстов для ячеек. + /// Тот же экземпляр для цепочки вызовов. + ITable AddRowWithCells(params string[] cellTexts); + + /// Задаёт свойства таблицы: ширину и стиль границ, выравнивание. + /// Ширина границ в пунктах (1/8 pt). По умолчанию 8. + /// Стиль границ. По умолчанию . + /// Выравнивание таблицы на странице. По умолчанию . + /// Тот же экземпляр для цепочки вызовов. + ITable Properties(uint borderWidth = 8, BorderValues? borderValues = null, TableRowAlignmentValues? tableAlignment = null); + + /// Создаёт объект на основе выполненных настроек. + /// Готовый объект для вставки в документ. + internal Table Build(); +} + +public interface IRow +{ + /// Базовый шрифт, используемый по умолчанию для содержимого строки. + FontProps BaseFont { get; } + + /// Добавляет ячейку, настраиваемую с помощью . + /// Делегат для конфигурации ячейки через . + /// Тот же экземпляр для цепочки вызовов. + IRow AddCell(Action configure); + + /// Добавляет ячейку с заданными свойствами (без текста). + /// Свойства ячейки (ширина, объединение, вертикальное выравнивание). + /// Тот же экземпляр для цепочки вызовов. + IRow AddCell(CellProps cellProps); + + /// Добавляет ячейку с заданными свойствами и текстом (используется базовый шрифт). + /// Свойства ячейки. + /// Текст ячейки. + /// Тот же экземпляр для цепочки вызовов. + IRow AddCell(CellProps cellProps, string text); + + /// Добавляет ячейку с заданными свойствами, текстом и указанным шрифтом. + /// Свойства ячейки. + /// Текст ячейки. + /// Шрифт для текста. Если , используется базовый шрифт. + /// Тот же экземпляр для цепочки вызовов. + IRow AddCell(CellProps cellProps, string text, FontProps font); + + /// Добавляет ячейку с простым текстом (используется базовый шрифт). + /// Текст ячейки. + /// Тот же экземпляр для цепочки вызовов. + IRow AddCell(string text); + + /// Добавляет ячейку с простым текстом и указанным шрифтом. + /// Текст ячейки. + /// Шрифт для текста. Если , используется базовый шрифт. + /// Тот же экземпляр для цепочки вызовов. + IRow AddCell(string text, FontProps font); + + /// Добавляет ячейку, содержащую один абзац, построенный с помощью . + /// Делегат для конфигурации абзаца через . + /// Тот же экземпляр для цепочки вызовов. + IRow AddCellWithPara(Action configure); + + /// Добавляет ячейку с указанными свойствами, содержащую один абзац, построенный с помощью . + /// Свойства ячейки. + /// Делегат для конфигурации абзаца через . + /// Тот же экземпляр для цепочки вызовов. + IRow AddCellWithPara(CellProps cellProps, Action configure); + + /// Устанавливает точную высоту строки (свойство Exact). + /// Высота в сантиметрах. + /// Тот же экземпляр для цепочки вызовов. + IRow SetExactHeight(double heightInCm); + + /// Устанавливает минимальную высоту строки (свойство AtLeast). + /// Высота в сантиметрах. + /// Тот же экземпляр для цепочки вызовов. + IRow SetHeight(double heightInCm); + + /// Позволяет напрямую настроить свойства строки . + /// Делегат для настройки свойств. + /// Тот же экземпляр для цепочки вызовов. + IRow SetProperties(Action configure); + + /// Создаёт объект на основе выполненных настроек. + /// Готовый объект для вставки в таблицу. + internal TableRow Build(); +} + +public interface ICell +{ + /// Базовый шрифт, используемый по умолчанию для содержимого ячейки. + FontProps BaseFont { get; } + + /// Добавляет в ячейку абзац, содержащий математическую формулу. + /// Делегат для конфигурации формулы через . + /// Тот же экземпляр для цепочки вызовов. + ICell AddFormula(Action configure); + + /// Добавляет в ячейку абзац, построенный с помощью . + /// Делегат для конфигурации абзаца через . + /// Тот же экземпляр для цепочки вызовов. + ICell AddParagraph(Action configure); + + /// Добавляет в ячейку абзац, состоящий из нескольких строк текста (разделённых переносами). + /// Массив строк, каждая строка будет размещена на новой строке абзаца. + /// Тот же экземпляр для цепочки вызовов. + ICell AddParagraph(params string[] lines); + + /// Добавляет в ячейку абзац с указанным текстом и опциональным шрифтом. + /// Текст абзаца. + /// Шрифт для текста. Если , используется базовый шрифт. + /// Тот же экземпляр для цепочки вызовов. + ICell AddParagraph(string text, FontProps? font = null); + + /// Устанавливает свойства ячейки (ширина, объединение, вертикальное выравнивание). + /// Свойства ячейки. + /// Тот же экземпляр для цепочки вызовов. + ICell SetCellProps(CellProps props); + + /// Создаёт объект на основе выполненных настроек. + /// Готовый объект для вставки в строку. + internal TableCell Build(); +} + +public interface IText +{ + /// Базовый шрифт, используемый по умолчанию для содержимого текстового блока. + FontProps BaseFont { get; } + + /// Добавляет в документ абзац, содержащий математическую формулу. + /// Делегат для конфигурации формулы через . + /// Тот же экземпляр для цепочки вызовов. + IText AddFormula(Action configure); + + /// Добавляет абзац, построенный с помощью . + /// Делегат для конфигурации абзаца через . + /// Тот же экземпляр для цепочки вызовов. + IText AddParagraph(Action configure); + + /// Добавляет абзац, состоящий из нескольких строк текста (разделённых переносами). + /// Массив строк, каждая строка будет размещена на новой строке абзаца. + /// Тот же экземпляр для цепочки вызовов. + IText AddParagraph(params string[] lines); + + /// Добавляет абзац с указанным текстом и опциональным шрифтом. + /// Текст абзаца. + /// Шрифт для текста. Если , используется базовый шрифт. + /// Тот же экземпляр для цепочки вызовов. + IText AddParagraph(string text, FontProps? font = null); + + /// Создаёт список абзацев на основе выполненных настроек. + /// Список готовых абзацев для вставки в документ. + internal List Build(); +} + +public interface IParagraph +{ + /// Базовый шрифт, используемый по умолчанию для содержимого абзаца. + FontProps BaseFont { get; } + + /// Добавляет в абзац математическую формулу (внутри отдельного ). + /// Делегат для конфигурации формулы через . + /// Тот же экземпляр для цепочки вызовов. + IParagraph AddFormula(Action configure); + + /// Добавляет в абзац математическую формулу (внутри отдельного ) и разрыв строки () в текущий абзац. + /// Делегат для конфигурации формулы через . + /// Тот же экземпляр для цепочки вызовов. + IParagraph AddFormulaBreak(Action configure); + + /// Добавляет разрыв строки () в текущий абзац. + /// Тот же экземпляр для цепочки вызовов. + IParagraph Break(); + + /// Добавляет разрыв строки () в текущий абзац. + /// Тот же экземпляр для цепочки вызовов. + IParagraph BreakPage(); + + /// Добавляет текстовый фрагмент () с указанным текстом и опциональным шрифтом. + /// Текст фрагмента. + /// Шрифт для текста. Если , используется базовый шрифт. + /// Тот же экземпляр для цепочки вызовов. + IParagraph AddRun(string text, FontProps? font = null); + + /// Добавляет текстовый фрагмент () с указанным текстом и опциональным шрифтом и разрыв строки () в текущий абзац. + /// Текст фрагмента. + /// Шрифт для текста. Если , используется базовый шрифт. + /// Тот же экземпляр для цепочки вызовов. + IParagraph AddRunBreak(string text, FontProps? font = null); + + /// Добавляет текстовый фрагмент с полным контролем над свойствами . + /// Текст фрагмента. + /// Делегат для настройки свойств фрагмента. + /// Тот же экземпляр для цепочки вызовов. + IParagraph AddRunWithCustomProps(string text, Action configure); + + /// Добавляет текстовый фрагмент, отформатированный как нижний индекс (). + /// Текст фрагмента. + /// Базовый шрифт (свойства будут переопределены). + /// Тот же экземпляр для цепочки вызовов. + IParagraph AddSubRun(string text, FontProps? font = null); + + /// Добавляет текстовый фрагмент, отформатированный как верхний индекс (). + /// Текст фрагмента. + /// Базовый шрифт (свойства будут переопределены). + /// Тот же экземпляр для цепочки вызовов. + IParagraph AddSupRun(string text, FontProps? font = null); + + /// Устанавливает выравнивание абзаца. + /// Тип выравнивания (лево, право, центр, по ширине). + /// Тот же экземпляр для цепочки вызовов. + IParagraph SetAlignment(JustificationValues alignment); + + /// Создаёт объект на основе выполненных настроек. + /// Готовый объект . + internal Paragraph Build(); +} + +public interface IFormula +{ + FontProps BaseFont { get; } + + IFormula Text(string text); + + + IFormula Division(string numerator, string denominator); + IFormula Division(string numerator, Action denominator); + IFormula Division(Action numerator, string denominator); + IFormula Division(Action numerator, Action denominator); + + + IFormula Sup(string baseText, string supText); + IFormula Sup(string baseText, Action supText); + IFormula Sup(Action baseText, string supText); + IFormula Sup(Action baseText, Action supText); + + + IFormula Sub(string baseText, string subText); + IFormula Sub(string baseText, Action subText); + IFormula Sub(Action baseText, string subText); + IFormula Sub(Action baseText, Action subText); + + + IFormula SubSup(string baseText, string subText, string supText); + IFormula SubSup(string baseText, string subText, Action supText); + IFormula SubSup(string baseText, Action subText, string supText); + IFormula SubSup(string baseText, Action subText, Action supText); + IFormula SubSup(Action baseText, string subText, string supText); + IFormula SubSup(Action baseText, string subText, Action supText); + IFormula SubSup(Action baseText, Action subText, string supText); + IFormula SubSup(Action baseText, Action subText, Action supText); + + + IFormula Root(string radicand); + IFormula Root(Action radicand); + IFormula Root(string radicand, string degree); + IFormula Root(string radicand, Action degree); + IFormula Root(Action radicand, string degree); + IFormula Root(Action radicand, Action degree); + + + IFormula Integral(string function, string differential, string? lowerLimit = null, string? upperLimit = null); + IFormula Integral(string function, string differential, string? lowerLimit = null, Action? upperLimit = null); + IFormula Integral(string function, string differential, Action? lowerLimit = null, string? upperLimit = null); + IFormula Integral(string function, string differential, Action? lowerLimit = null, Action? upperLimit = null); + IFormula Integral(string function, Action differential, string? lowerLimit = null, string? upperLimit = null); + IFormula Integral(string function, Action differential, string? lowerLimit = null, Action? upperLimit = null); + IFormula Integral(string function, Action differential, Action? lowerLimit = null, string? upperLimit = null); + IFormula Integral(string function, Action differential, Action? lowerLimit = null, Action? upperLimit = null); + IFormula Integral(Action function, string differential, string? lowerLimit = null, string? upperLimit = null); + IFormula Integral(Action function, string differential, string? lowerLimit = null, Action? upperLimit = null); + IFormula Integral(Action function, string differential, Action? lowerLimit = null, string? upperLimit = null); + IFormula Integral(Action function, string differential, Action? lowerLimit = null, Action? upperLimit = null); + IFormula Integral(Action function, Action differential, string? lowerLimit = null, string? upperLimit = null); + IFormula Integral(Action function, Action differential, string? lowerLimit = null, Action? upperLimit = null); + IFormula Integral(Action function, Action differential, Action? lowerLimit = null, string? upperLimit = null); + IFormula Integral(Action function, Action differential, Action? lowerLimit = null, Action? upperLimit = null); + + + IFormula Sum(string expression, string? lowerLimit = null, string? upperLimit = null); + IFormula Sum(string expression, string? lowerLimit = null, Action? upperLimit = null); + IFormula Sum(string expression, Action? lowerLimit = null, string? upperLimit = null); + IFormula Sum(string expression, Action? lowerLimit = null, Action? upperLimit = null); + IFormula Sum(Action expression, string? lowerLimit = null, string? upperLimit = null); + IFormula Sum(Action expression, string? lowerLimit = null, Action? upperLimit = null); + IFormula Sum(Action expression, Action? lowerLimit = null, string? upperLimit = null); + IFormula Sum(Action expression, Action? lowerLimit = null, Action? upperLimit = null); + + + internal void PushContext(OpenXmlElement element); + internal void PopContext(); +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/Builders/ParagraphBuilderBase.cs b/QWERTYkez.WordProcessor/Builders/ParagraphBuilderBase.cs new file mode 100644 index 0000000..11246e6 --- /dev/null +++ b/QWERTYkez.WordProcessor/Builders/ParagraphBuilderBase.cs @@ -0,0 +1,431 @@ +namespace QWERTYkez.WordProcessor.Builders; + +abstract class ParagraphBuilderBase : IParagraph, IFormula +{ + public FontProps BaseFont { get; internal set; } + + // Конструктор для установки базового шрифта + protected ParagraphBuilderBase(FontProps? baseFont = null) + { + BaseFont = baseFont is not null && baseFont.SubSup.HasValue + ? baseFont with { SubSup = null } + : baseFont ?? new FontProps(); + } + + + internal FontProps GetEffectiveFont(FontProps? overrideFont) => overrideFont ?? BaseFont; + + + + + + protected Paragraph _paragraph = null!; + protected List _runs = null!; + + internal IParagraph ParagraphBuilder => CreateParagraphBuilder(); + protected virtual IParagraph CreateParagraphBuilder() + { + _paragraph = new Paragraph + { + ParagraphProperties = new ParagraphProperties( + new Justification { Val = JustificationValues.Center }, + new SpacingBetweenLines + { + After = "0", + Before = "0", + LineRule = LineSpacingRuleValues.Auto + }, + new Indentation + { + Left = "0", + Right = "0", + FirstLine = "0", + Hanging = "0" + } + ) + }; + _runs = []; + return this; + } + + + Paragraph IParagraph.Build() + { + foreach (var run in _runs) + { + _paragraph.AppendChild(run); + } + return _paragraph; + } + + // Настройка выравнивания + public IParagraph SetAlignment(JustificationValues alignment) + { + var props = _paragraph.ParagraphProperties; + props?.Justification = new Justification { Val = alignment }; + return this; + } + + // Добавление текста + public IParagraph AddRun(string text, FontProps? font = null) + { + // Заменяем обычные пробелы на неразрывные для сохранения видимости + string processedText = text.Replace(' ', '\u00A0'); + + var effectiveFont = GetEffectiveFont(font); + Run run; + + if (effectiveFont.TryExtract(out var elements)) + { + run = new Run(new RunProperties(elements), new Text(processedText)); + } + else + { + run = new Run(new Text(processedText)); + } + + _runs.Add(run); + return this; + } + + // Добавление текста + public IParagraph AddRunBreak(string text, FontProps? font = null) + { + // Заменяем обычные пробелы на неразрывные для сохранения видимости + string processedText = text.Replace(' ', '\u00A0'); + + var effectiveFont = GetEffectiveFont(font); + Run run; + + if (effectiveFont.TryExtract(out var elements)) + { + run = new Run(new RunProperties(elements), new Text(processedText)); + } + else + { + run = new Run(new Text(processedText)); + } + + _runs.Add(run); + + _runs.Add(new Run(new Break())); + + return this; + } + + // Добавление текста + public IParagraph AddSupRun(string text, FontProps? font = null) + { + var effectiveFont = GetEffectiveFont(font); + Run run; + + if (effectiveFont.TrySupExtract(out var elements)) + { + run = new Run(new RunProperties(elements), new Text(text)); + } + else + { + run = new Run(new Text(text)); + } + + _runs.Add(run); + return this; + } + + // Добавление текста + public IParagraph AddSubRun(string text, FontProps? font = null) + { + var effectiveFont = GetEffectiveFont(font); + Run run; + + if (effectiveFont.TrySubExtract(out var elements)) + { + run = new Run(new RunProperties(elements), new Text(text)); + } + else + { + run = new Run(new Text(text)); + } + + _runs.Add(run); + return this; + } + + public IParagraph AddRunWithCustomProps(string text, Action configure) + { + var props = new RunProperties(); + configure(props); + _runs.Add(new Run(props, new Text(text))); + return this; + } + + // Добавление разрыва строки + public IParagraph Break() + { + _runs.Add(new Run(new Break())); + return this; + } + + // Добавление разрыва строки + public IParagraph BreakPage() + { + _runs.Add(new Run(new Break() { Type = BreakValues.Page })); + return this; + } + + // Метод AddFormula для IParagraphBuilder + IParagraph IParagraph.AddFormula(Action configure) + { + var math = new OfficeMath(); + try + { + _mathContextStack.Push(math); + configure(this); + } + finally + { + _mathContextStack.Pop(); + } + + var run = new Run(); // без RunProperties + run.AppendChild(math); + _runs.Add(run); + return this; + } + + // Метод AddFormula для IParagraphBuilder + IParagraph IParagraph.AddFormulaBreak(Action configure) + { + var math = new OfficeMath(); + try + { + _mathContextStack.Push(math); + configure(this); + } + finally + { + _mathContextStack.Pop(); + } + + var run = new Run(); // без RunProperties + run.AppendChild(math); + _runs.Add(run); + _runs.Add(new Run(new Break())); + return this; + } + + + + + + // Стек для вложенных математических конструкций + private readonly Stack _mathContextStack = new(); + bool TryGetCurrentMathContext(out OpenXmlElement element) + { + if (_mathContextStack.Count > 0) + { + element = _mathContextStack.Peek(); + return true; + } + else + { + element = null!; + return false; + } + } + + + // Вспомогательные методы для стека (используются FormulaHelper) + void IFormula.PushContext(OpenXmlElement element) => _mathContextStack.Push(element); + void IFormula.PopContext() => _mathContextStack.Pop(); + + + // Реализация IFormulaBuilder + public IFormula Text(string text) + { + if (TryGetCurrentMathContext(out var element)) + FormulaHelper.AddText(element, text, BaseFont); + return this; + } + + + // Division + public IFormula Division(string numerator, string denominator) => + Division(ToAction(numerator), ToAction(denominator)); + public IFormula Division(string numerator, Action denominator) => + Division(ToAction(numerator), denominator); + public IFormula Division(Action numerator, string denominator) => + Division(numerator, ToAction(denominator)); + public IFormula Division(Action numerator, Action denominator) + { + var fraction = FormulaHelper.CreateFraction(numerator, denominator, this); + if (TryGetCurrentMathContext(out var element)) + element.AppendChild(fraction); + return this; + } + + + // Sup + public IFormula Sup(string baseText, string supText) => + Sup(ToAction(baseText), ToAction(supText)); + public IFormula Sup(string baseText, Action supText) => + Sup(ToAction(baseText), supText); + public IFormula Sup(Action baseText, string supText) => + Sup(baseText, ToAction(supText)); + public IFormula Sup(Action baseText, Action supText) + { + var superscript = FormulaHelper.CreateSuperscript(baseText, supText, this); + if (TryGetCurrentMathContext(out var element)) + element.AppendChild(superscript); + return this; + } + + + // Sub + public IFormula Sub(string baseText, string subText) => + Sub(ToAction(baseText), ToAction(subText)); + public IFormula Sub(string baseText, Action subText) => + Sub(ToAction(baseText), subText); + public IFormula Sub(Action baseText, string subText) => + Sub(baseText, ToAction(subText)); + public IFormula Sub(Action baseText, Action subText) + { + var subscript = FormulaHelper.CreateSubscript(baseText, subText, this); + if (TryGetCurrentMathContext(out var element)) + element.AppendChild(subscript); + return this; + } + + + // SubSup + public IFormula SubSup(string baseText, string subText, string supText) => + SubSup(ToAction(baseText), ToAction(subText), ToAction(supText)); + public IFormula SubSup(string baseText, string subText, Action supText) => + SubSup(ToAction(baseText), ToAction(subText), supText); + public IFormula SubSup(string baseText, Action subText, string supText) => + SubSup(ToAction(baseText), subText, ToAction(supText)); + public IFormula SubSup(string baseText, Action subText, Action supText) => + SubSup(ToAction(baseText), subText, supText); + public IFormula SubSup(Action baseText, string subText, string supText) => + SubSup(baseText, ToAction(subText), ToAction(supText)); + public IFormula SubSup(Action baseText, string subText, Action supText) => + SubSup(baseText, ToAction(subText), supText); + public IFormula SubSup(Action baseText, Action subText, string supText) => + SubSup(baseText, subText, ToAction(supText)); + public IFormula SubSup(Action baseText, Action subText, Action supText) + { + var subSup = FormulaHelper.CreateSubSuperscript(baseText, subText, supText, this); + if (TryGetCurrentMathContext(out var element)) + element.AppendChild(subSup); + return this; + } + + + // Root + public IFormula Root(string radicand) => Root(ToAction(radicand)); + public IFormula Root(Action radicand) + { + var radical = FormulaHelper.CreateRadical(radicand, null, this); + if (TryGetCurrentMathContext(out var element)) + element.AppendChild(radical); + return this; + } + public IFormula Root(string radicand, string degree) => + Root(ToAction(radicand), ToAction(degree)); + public IFormula Root(string radicand, Action degree) => + Root(ToAction(radicand), degree); + public IFormula Root(Action radicand, string degree) => + Root(radicand, ToAction(degree)); + public IFormula Root(Action radicand, Action degree) + { + var radical = FormulaHelper.CreateRadical(radicand, degree, this); + if (TryGetCurrentMathContext(out var element)) + element.AppendChild(radical); + return this; + } + + + private void AddIntegralCore( + Action function, + Action differential, + Action? lowerLimit, + Action? upperLimit) + { + if (TryGetCurrentMathContext(out var element)) + FormulaHelper.AddIntegral(element, function, differential, lowerLimit, upperLimit, this); + } + + public IFormula Integral(string function, string differential, string? lowerLimit = null, string? upperLimit = null) => + Integral(ToAction(function), ToAction(differential), ToNullableAction(lowerLimit), ToNullableAction(upperLimit)); + public IFormula Integral(string function, string differential, string? lowerLimit = null, Action? upperLimit = null) => + Integral(ToAction(function), ToAction(differential), ToNullableAction(lowerLimit), upperLimit); + public IFormula Integral(string function, string differential, Action? lowerLimit = null, string? upperLimit = null) => + Integral(ToAction(function), ToAction(differential), lowerLimit, ToNullableAction(upperLimit)); + public IFormula Integral(string function, string differential, Action? lowerLimit = null, Action? upperLimit = null) => + Integral(ToAction(function), ToAction(differential), lowerLimit, upperLimit); + public IFormula Integral(string function, Action differential, string? lowerLimit = null, string? upperLimit = null) => + Integral(ToAction(function), differential, ToNullableAction(lowerLimit), ToNullableAction(upperLimit)); + public IFormula Integral(string function, Action differential, string? lowerLimit = null, Action? upperLimit = null) => + Integral(ToAction(function), differential, ToNullableAction(lowerLimit), upperLimit); + public IFormula Integral(string function, Action differential, Action? lowerLimit = null, string? upperLimit = null) => + Integral(ToAction(function), differential, lowerLimit, ToNullableAction(upperLimit)); + public IFormula Integral(string function, Action differential, Action? lowerLimit = null, Action? upperLimit = null) => + Integral(ToAction(function), differential, lowerLimit, upperLimit); + public IFormula Integral(Action function, string differential, string? lowerLimit = null, string? upperLimit = null) => + Integral(function, ToAction(differential), ToNullableAction(lowerLimit), ToNullableAction(upperLimit)); + public IFormula Integral(Action function, string differential, string? lowerLimit = null, Action? upperLimit = null) => + Integral(function, ToAction(differential), ToNullableAction(lowerLimit), upperLimit); + public IFormula Integral(Action function, string differential, Action? lowerLimit = null, string? upperLimit = null) => + Integral(function, ToAction(differential), lowerLimit, ToNullableAction(upperLimit)); + public IFormula Integral(Action function, string differential, Action? lowerLimit = null, Action? upperLimit = null) => + Integral(function, ToAction(differential), lowerLimit, upperLimit); + public IFormula Integral(Action function, Action differential, string? lowerLimit = null, string? upperLimit = null) => + Integral(function, differential, ToNullableAction(lowerLimit), ToNullableAction(upperLimit)); + public IFormula Integral(Action function, Action differential, string? lowerLimit = null, Action? upperLimit = null) => + Integral(function, differential, ToNullableAction(lowerLimit), upperLimit); + public IFormula Integral(Action function, Action differential, Action? lowerLimit = null, string? upperLimit = null) => + Integral(function, differential, lowerLimit, ToNullableAction(upperLimit)); + public IFormula Integral(Action function, Action differential, Action? lowerLimit = null, Action? upperLimit = null) + { + AddIntegralCore(function, differential, lowerLimit, upperLimit); + return this; + } + + + // Sum + private void AddSumCore( + Action expression, + Action? lowerLimit, + Action? upperLimit) + { + if (TryGetCurrentMathContext(out var element)) + FormulaHelper.AddSum(element, expression, lowerLimit, upperLimit, this); + } + + public IFormula Sum(string expression, string? lowerLimit = null, string? upperLimit = null) => + Sum(ToAction(expression), ToNullableAction(lowerLimit), ToNullableAction(upperLimit)); + public IFormula Sum(string expression, string? lowerLimit = null, Action? upperLimit = null) => + Sum(ToAction(expression), ToNullableAction(lowerLimit), upperLimit); + public IFormula Sum(string expression, Action? lowerLimit = null, string? upperLimit = null) => + Sum(ToAction(expression), lowerLimit, ToNullableAction(upperLimit)); + public IFormula Sum(string expression, Action? lowerLimit = null, Action? upperLimit = null) => + Sum(ToAction(expression), lowerLimit, upperLimit); + public IFormula Sum(Action expression, string? lowerLimit = null, string? upperLimit = null) => + Sum(expression, ToNullableAction(lowerLimit), ToNullableAction(upperLimit)); + public IFormula Sum(Action expression, string? lowerLimit = null, Action? upperLimit = null) => + Sum(expression, ToNullableAction(lowerLimit), upperLimit); + public IFormula Sum(Action expression, Action? lowerLimit = null, string? upperLimit = null) => + Sum(expression, lowerLimit, ToNullableAction(upperLimit)); + public IFormula Sum(Action expression, Action? lowerLimit = null, Action? upperLimit = null) + { + AddSumCore(expression, lowerLimit, upperLimit); + return this; + } + + + + + // Вспомогательный метод для преобразования строки в делегат + private static Action? ToNullableAction(string? text) => text is null ? null : (b => b.Text(text)); + private static Action ToAction(string text) => b => b.Text(text); +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/Builders/TableBuilder.cs b/QWERTYkez.WordProcessor/Builders/TableBuilder.cs new file mode 100644 index 0000000..087a760 --- /dev/null +++ b/QWERTYkez.WordProcessor/Builders/TableBuilder.cs @@ -0,0 +1,328 @@ +namespace QWERTYkez.WordProcessor.Builders; + +internal sealed class TableBuilder : ParagraphBuilderBase, ITable, IRow, ICell +{ + private readonly Table _table = new(); + + internal TableBuilder(FontProps? baseFont) : base(baseFont) + { + } + + internal static TableBuilder Create(FontProps? baseFont = null) => new(baseFont); + + + // Метод завершения сборки + public Table Build() => _table; + + // Публичные методы настройки таблицы + public ITable Properties( + uint borderWidth = 8, + BorderValues? borderValues = null, + TableRowAlignmentValues? tableAlignment = null) + { + var borderType = borderValues ?? BorderValues.Single; + var alignment = tableAlignment ?? TableRowAlignmentValues.Center; + + _table.AppendChild(new TableProperties( + new TableBorders( + new TopBorder() { Val = borderType, Size = borderWidth }, + new BottomBorder() { Val = borderType, Size = borderWidth }, + new LeftBorder() { Val = borderType, Size = borderWidth }, + new RightBorder() { Val = borderType, Size = borderWidth }, + new InsideHorizontalBorder() { Val = borderType, Size = borderWidth }, + new InsideVerticalBorder() { Val = borderType, Size = borderWidth } + ), + new TableJustification { Val = alignment }, + new TableLayout() { Type = TableLayoutValues.Autofit } + )); + + return this; + } + + // Добавление строк с использованием IRowBuilder + public ITable AddRow(Action configure) + { + var IRowBuilder = RowBuilder; + configure(this); + _table.AppendChild(IRowBuilder.Build()); + return this; + } + + public ITable AddRow(double height, Action configure) + { + var IRowBuilder = RowBuilder.SetHeight(height); + configure(this); + _table.AppendChild(IRowBuilder.Build()); + return this; + } + + // Быстрые методы для добавления строк с ячейками + public ITable AddRowWithCells(params string[] cellTexts) + { + AddRow(row => + { + foreach (var text in cellTexts) + { + row.AddCell(text); + } + }); + return this; + } + + public ITable AddRowWithCells(FontProps font, params string[] cellTexts) + { + AddRow(row => + { + foreach (var text in cellTexts) + { + row.AddCell(text, font); + } + }); + return this; + } + + + + + + + + + + + + private TableRow _row = null!; + internal IRow RowBuilder + { + get + { + _row = new(); + return this; + } + } + TableRow IRow.Build() => _row; + + + // Настройка высоты строки (исправленный метод) + public IRow SetHeight(double heightInCm) + { + var height = new TableRowHeight + { + Val = (UInt32Value)(uint)(heightInCm * 567), // 1 см = 567 DXA + HeightType = HeightRuleValues.AtLeast + }; + + // Убедимся, что TableRowProperties существует + if (_row.TableRowProperties is null) + { + _row.TableRowProperties = new TableRowProperties(); + } + else + { + // Удалим существующий TableRowHeight, если он есть + var existingHeight = _row.TableRowProperties.Elements().FirstOrDefault(); + existingHeight?.Remove(); + } + + // Добавляем TableRowHeight как дочерний элемент + _row.TableRowProperties.AppendChild(height); + return this; + } + + public IRow SetExactHeight(double heightInCm) + { + var height = new TableRowHeight + { + Val = (UInt32Value)(uint)(heightInCm * 567), + HeightType = HeightRuleValues.Exact + }; + + // Убедимся, что TableRowProperties существует + if (_row.TableRowProperties is null) + { + _row.TableRowProperties = new TableRowProperties(); + } + else + { + // Удалим существующий TableRowHeight, если он есть + var existingHeight = _row.TableRowProperties.Elements().FirstOrDefault(); + existingHeight?.Remove(); + } + + // Добавляем TableRowHeight как дочерний элемент + _row.TableRowProperties.AppendChild(height); + return this; + } + + // Добавление ячеек + public IRow AddCell(Action configure) + { + var cellBuilder = CellBuilder; + configure(this); + _row.AppendChild(cellBuilder.Build()); + return this; + } + + public IRow AddCell(string text) + { + var cellBuilder = CellBuilder; + cellBuilder.AddParagraph(p => p.AddRun(text)); + _row.AppendChild(cellBuilder.Build()); + return this; + } + + public IRow AddCell(string text, FontProps font) + { + var cellBuilder = CellBuilder; + + if (font is not null) + cellBuilder.AddParagraph(p => p.AddRun(text, font)); + else cellBuilder.AddParagraph(p => p.AddRun(text)); + + _row.AppendChild(cellBuilder.Build()); + return this; + } + + public IRow AddCell(CellProps cellProps, string text, FontProps font) + { + var cellBuilder = CellBuilder.SetCellProps(cellProps); + + if (font is not null) + cellBuilder.AddParagraph(p => p.AddRun(text, font)); + else cellBuilder.AddParagraph(p => p.AddRun(text)); + + _row.AppendChild(cellBuilder.Build()); + return this; + } + + public IRow AddCell(CellProps cellProps, string text) + { + var cellBuilder = CellBuilder.SetCellProps(cellProps); + cellBuilder.AddParagraph(p => p.AddRun(text)); + _row.AppendChild(cellBuilder.Build()); + return this; + } + + public IRow AddCell(CellProps cellProps) + { + var cellBuilder = CellBuilder.SetCellProps(cellProps); + _row.AppendChild(cellBuilder.Build()); + return this; + } + + // Метод для установки произвольных свойств строки + public IRow SetProperties(Action configure) + { + _row.TableRowProperties ??= new TableRowProperties(); + configure(_row.TableRowProperties); + return this; + } + + + public IRow AddCellWithPara(Action configure) + { + var cellBuilder = CellBuilder; + cellBuilder.AddParagraph(configure); + _row.AppendChild(cellBuilder.Build()); + return this; + } + public IRow AddCellWithPara(CellProps cellProps, Action configure) + { + var cellBuilder = CellBuilder.SetCellProps(cellProps); + cellBuilder.AddParagraph(configure); + _row.AppendChild(cellBuilder.Build()); + return this; + } + + + + + + + + + + + + + private TableCell _cell = null!; + private List _paragraphs = null!; + internal ICell CellBuilder + { + get + { + _cell = new(); + _paragraphs = []; + return this; + } + } + TableCell ICell.Build() + { + foreach (var paragraph in _paragraphs) + { + _cell.AppendChild(paragraph); + } + return _cell; + } + + // Установка свойств ячейки + public ICell SetCellProps(CellProps props) + { + if (props.TryExtract(out var elements)) + { + _cell.TableCellProperties = new TableCellProperties(elements); + } + return this; + } + + // Добавление параграфов + public ICell AddParagraph(Action configure) + { + var paraBuilder = ParagraphBuilder; + configure(paraBuilder); + _paragraphs.Add(paraBuilder.Build()); + return this; + } + + public ICell AddParagraph(string text, FontProps? font = null) + { + var paraBuilder = ParagraphBuilder; + + if (font is not null) + { + paraBuilder.AddRun(text, font); + } + else + { + paraBuilder.AddRun(text); + } + + _paragraphs.Add(paraBuilder.Build()); + return this; + } + + public ICell AddParagraph(params string[] lines) + { + var paraBuilder = ParagraphBuilder; + if (lines.Length > 0) + { + paraBuilder.AddRun(lines[0]); + for (int i = 1; i < lines.Length; i++) + { + paraBuilder.Break(); + paraBuilder.AddRun(lines[i]); + } + } + _paragraphs.Add(paraBuilder.Build()); + return this; + } + + // Метод AddFormula для ICellBuilder + ICell ICell.AddFormula(Action configure) + { + var paraBuilder = ParagraphBuilder; + paraBuilder.AddFormula(configure); + _paragraphs.Add(paraBuilder.Build()); + return this; + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/Builders/TextBuilder.cs b/QWERTYkez.WordProcessor/Builders/TextBuilder.cs new file mode 100644 index 0000000..9da8caf --- /dev/null +++ b/QWERTYkez.WordProcessor/Builders/TextBuilder.cs @@ -0,0 +1,110 @@ +namespace QWERTYkez.WordProcessor.Builders; + +internal sealed class TextBuilder : ParagraphBuilderBase, IText +{ + private readonly ParagraphProperties? _baseParagraphProperties; + + internal TextBuilder(FontProps? baseFont, ParagraphProperties? baseParagraphProperties = null) : base(baseFont) + { + _baseParagraphProperties = baseParagraphProperties?.CloneNode(true) as ParagraphProperties; + } + + internal static TextBuilder Create(FontProps? baseFont = null, ParagraphProperties? baseParagraphProperties = null) => + new(baseFont, baseParagraphProperties); + + + + + // Переопределяем создание параграфа + protected override IParagraph CreateParagraphBuilder() + { + _paragraph = new Paragraph(); + + if (_baseParagraphProperties is not null) + { + _paragraph.ParagraphProperties = _baseParagraphProperties.CloneNode(true) as ParagraphProperties; + } + else + { + // Используем стандартные свойства (как в базовом классе) + _paragraph.ParagraphProperties = new ParagraphProperties( + new Justification { Val = JustificationValues.Center }, + new SpacingBetweenLines + { + After = "0", + Before = "0", + LineRule = LineSpacingRuleValues.Auto + }, + new Indentation + { + Left = "0", + Right = "0", + FirstLine = "0", + Hanging = "0" + } + ); + } + + _runs = []; + return this; + } + + + + + + + + private readonly List _paragraphs = []; + public List Build() => _paragraphs; + + IText IText.AddFormula(Action configure) + { + var paraBuilder = ParagraphBuilder; + paraBuilder.AddFormula(configure); + _paragraphs.Add(paraBuilder.Build()); + return this; + } + + // Добавление параграфов + public IText AddParagraph(Action configure) + { + var paraBuilder = ParagraphBuilder; + configure(paraBuilder); + _paragraphs.Add(paraBuilder.Build()); + return this; + } + + public IText AddParagraph(string text, FontProps? font = null) + { + var paraBuilder = ParagraphBuilder; + + if (font is not null) + { + paraBuilder.AddRun(text, font); + } + else + { + paraBuilder.AddRun(text); + } + + _paragraphs.Add(paraBuilder.Build()); + return this; + } + + public IText AddParagraph(params string[] lines) + { + var paraBuilder = ParagraphBuilder; + if (lines.Length > 0) + { + paraBuilder.AddRun(lines[0]); + for (int i = 1; i < lines.Length; i++) + { + paraBuilder.Break(); + paraBuilder.AddRun(lines[i]); + } + } + _paragraphs.Add(paraBuilder.Build()); + return this; + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/HeaderFooterProcessor.cs b/QWERTYkez.WordProcessor/HeaderFooterProcessor.cs new file mode 100644 index 0000000..f70baed --- /dev/null +++ b/QWERTYkez.WordProcessor/HeaderFooterProcessor.cs @@ -0,0 +1,371 @@ +namespace QWERTYkez.WordProcessor; + +/// +/// Обработчик текста в верхних и нижних колонтитулах DOCX документов +/// +internal static class HeaderFooterProcessor +{ + /// + /// Заменяет текст во всех колонтитулах документа + /// + internal static void ReplaceInHeadersFooters(WordprocessingDocument document, string oldValue, IEnumerable newValues, StringComparison comparisonType) + { + if (document is null || string.IsNullOrEmpty(oldValue) || newValues is null) + return; + + var headers = GetAllHeaders(document).ToList(); + var footers = GetAllFooters(document).ToList(); + + if (headers.Count == 0 && footers.Count == 0) + return; + +#if DEBUG + int headerMatches = 0; + int footerMatches = 0; + + foreach (var header in headers) + { + if (ContainsText(header, oldValue, comparisonType)) + { + headerMatches++; + } + } + + foreach (var footer in footers) + { + if (ContainsText(footer, oldValue, comparisonType)) + { + footerMatches++; + } + } + + if (headerMatches > 0 || footerMatches > 0) + { + Debug.WriteLine($"[DEBUG] [HeaderFooterProcessor.ReplaceInHeadersFooters] Looking for '{oldValue}'"); + Debug.WriteLine($"[DEBUG] [HeaderFooterProcessor.ReplaceInHeadersFooters] Headers: {headers.Count} total, {headerMatches} with matches"); + Debug.WriteLine($"[DEBUG] [HeaderFooterProcessor.ReplaceInHeadersFooters] Footers: {footers.Count} total, {footerMatches} with matches"); + } +#endif + + foreach (var header in headers) + { + ReplaceInElement(header, oldValue, newValues, comparisonType); + } + + foreach (var footer in footers) + { + ReplaceInElement(footer, oldValue, newValues, comparisonType); + } + } + + /// + /// Выполняет словарную замену во всех колонтитулах + /// + internal static void ReplaceInHeadersFooters(WordprocessingDocument document, IEnumerable>> replacements, StringComparison comparisonType) + { + if (document is null || replacements is null || !replacements.Any()) + return; + + var headers = GetAllHeaders(document).ToList(); + var footers = GetAllFooters(document).ToList(); + + if (headers.Count == 0 && footers.Count == 0) + return; + +#if DEBUG + int headerMatches = 0; + int footerMatches = 0; + + foreach (var header in headers) + { + foreach (var kvp in replacements) + { + if (!string.IsNullOrEmpty(kvp.Key) && ContainsText(header, kvp.Key, comparisonType)) + { + headerMatches++; + break; + } + } + } + + foreach (var footer in footers) + { + foreach (var kvp in replacements) + { + if (!string.IsNullOrEmpty(kvp.Key) && ContainsText(footer, kvp.Key, comparisonType)) + { + footerMatches++; + break; + } + } + } + + if (headerMatches > 0 || footerMatches > 0) + { + Debug.WriteLine($"[DEBUG] [HeaderFooterProcessor.ReplaceInHeadersFooters(dict)] START with {replacements.Count()} items"); + Debug.WriteLine($"[DEBUG] [HeaderFooterProcessor.ReplaceInHeadersFooters(dict)] Headers: {headers.Count} total, {headerMatches} with matches"); + Debug.WriteLine($"[DEBUG] [HeaderFooterProcessor.ReplaceInHeadersFooters(dict)] Footers: {footers.Count} total, {footerMatches} with matches"); + } +#endif + + foreach (var header in headers) + { + ReplaceInElement(header, replacements, comparisonType); + } + + foreach (var footer in footers) + { + ReplaceInElement(footer, replacements, comparisonType); + } + } + + /// + /// Выполняет словарную замену во всех колонтитулах + /// + internal static void ReplaceInHeadersFooters(WordprocessingDocument document, IEnumerable> replacements, StringComparison comparisonType) + { + if (document is null || replacements is null || !replacements.Any()) + return; + + var headers = GetAllHeaders(document).ToList(); + var footers = GetAllFooters(document).ToList(); + + if (headers.Count == 0 && footers.Count == 0) + return; + +#if DEBUG + int headerMatches = 0; + int footerMatches = 0; + + foreach (var header in headers) + { + foreach (var kvp in replacements) + { + if (!string.IsNullOrEmpty(kvp.Key) && ContainsText(header, kvp.Key, comparisonType)) + { + headerMatches++; + break; + } + } + } + + foreach (var footer in footers) + { + foreach (var kvp in replacements) + { + if (!string.IsNullOrEmpty(kvp.Key) && ContainsText(footer, kvp.Key, comparisonType)) + { + footerMatches++; + break; + } + } + } + + if (headerMatches > 0 || footerMatches > 0) + { + Debug.WriteLine($"[DEBUG] [HeaderFooterProcessor.ReplaceInHeadersFooters(dict)] START with {replacements.Count()} items"); + Debug.WriteLine($"[DEBUG] [HeaderFooterProcessor.ReplaceInHeadersFooters(dict)] Headers: {headers.Count} total, {headerMatches} with matches"); + Debug.WriteLine($"[DEBUG] [HeaderFooterProcessor.ReplaceInHeadersFooters(dict)] Footers: {footers.Count} total, {footerMatches} with matches"); + } +#endif + + foreach (var header in headers) + { + ReplaceInElement(header, replacements, comparisonType); + } + + foreach (var footer in footers) + { + ReplaceInElement(footer, replacements, comparisonType); + } + } + + /// + /// Заменяет текст в указанном элементе (Header или Footer) + /// + private static void ReplaceInElement(OpenXmlElement element, string oldValue, IEnumerable newValues, StringComparison comparisonType) + { + if (element is null) + return; + + var paragraphs = element.Descendants().ToList(); + if (paragraphs.Count == 0) + return; + + var tables = TableTextProcessor.GetAllTables(element).ToList(); + var tableParagraphs = tables.SelectMany(t => TableTextProcessor.GetTableParagraphs(t)).ToList(); + + var allParagraphs = paragraphs.Concat(tableParagraphs).Distinct().ToList(); + + if (newValues.Count() == 1) + { + foreach (var paragraph in allParagraphs) + { + paragraph?.SimpleReplace(oldValue, newValues.First(), comparisonType); + } + } + else + { + foreach (var paragraph in allParagraphs) + { + if (paragraph is not null && paragraph.InnerText.IndexOf(oldValue, comparisonType) >= 0) + { + paragraph.ReplaceWithMultiple(oldValue, newValues, comparisonType); + } + } + } + } + + /// + /// Выполняет словарную замену в указанном элементе + /// + private static void ReplaceInElement(OpenXmlElement element, IEnumerable>> replacements, StringComparison comparisonType) + { + if (element is null || replacements is null || !replacements.Any()) + return; + + var paragraphs = element.Descendants().ToList(); + if (paragraphs.Count == 0) + return; + + var tables = TableTextProcessor.GetAllTables(element).ToList(); + var tableParagraphs = tables.SelectMany(t => TableTextProcessor.GetTableParagraphs(t)).ToList(); + + var allParagraphs = paragraphs.Concat(tableParagraphs).Distinct().ToList(); + + foreach (var paragraph in allParagraphs) + { + if (paragraph is null) + continue; + + var paraText = paragraph.InnerText; + bool hasMatch = false; + foreach (var kvp in replacements) + { + if (!string.IsNullOrEmpty(kvp.Key) && paraText.IndexOf(kvp.Key, comparisonType) >= 0) + { + hasMatch = true; + break; + } + } + + if (!hasMatch) + continue; + + var newParagraphs = MultiReplaceExt.ProcessParagraphWithAllReplacements(paragraph, replacements, comparisonType); + if (newParagraphs is not null && newParagraphs.Count > 0) + { + ReplaceParagraphInParent(paragraph, newParagraphs); + } + } + } + + /// + /// Выполняет словарную замену в указанном элементе + /// + private static void ReplaceInElement(OpenXmlElement element, IEnumerable> replacements, StringComparison comparisonType) + { + if (element is null || replacements is null || !replacements.Any()) + return; + + var paragraphs = element.Descendants().ToList(); + if (paragraphs.Count == 0) + return; + + var tables = TableTextProcessor.GetAllTables(element).ToList(); + var tableParagraphs = tables.SelectMany(t => TableTextProcessor.GetTableParagraphs(t)).ToList(); + + var allParagraphs = paragraphs.Concat(tableParagraphs).Distinct().ToList(); + + foreach (var paragraph in allParagraphs) + { + if (paragraph is null) + continue; + + var paraText = paragraph.InnerText; + bool hasMatch = false; + foreach (var kvp in replacements) + { + if (!string.IsNullOrEmpty(kvp.Key) && paraText.IndexOf(kvp.Key, comparisonType) >= 0) + { + hasMatch = true; + break; + } + } + + if (!hasMatch) + continue; + + var newParagraphs = MultiReplaceExt.ProcessParagraphWithAllReplacements(paragraph, replacements, comparisonType); + if (newParagraphs is not null && newParagraphs.Count > 0) + { + ReplaceParagraphInParent(paragraph, newParagraphs); + } + } + } + +#if DEBUG + private static bool ContainsText(OpenXmlElement element, string searchText, StringComparison comparisonType) + { + if (element is null || string.IsNullOrEmpty(searchText)) + return false; + + return element.InnerText?.IndexOf(searchText, comparisonType) >= 0; + } +#endif + + // Остальные методы остаются без изменений + + /// + /// Находит и возвращает все верхние колонтитулы в документе + /// + internal static IEnumerable GetAllHeaders(WordprocessingDocument document) + { + if (document?.MainDocumentPart is null) + yield break; + + foreach (var headerPart in document.MainDocumentPart.HeaderParts) + { + if (headerPart?.Header is not null) + { + yield return headerPart.Header; + } + } + } + + /// + /// Находит и возвращает все нижние колонтитулы в документе + /// + internal static IEnumerable GetAllFooters(WordprocessingDocument document) + { + if (document?.MainDocumentPart is null) + yield break; + + foreach (var footerPart in document.MainDocumentPart.FooterParts) + { + if (footerPart?.Footer is not null) + { + yield return footerPart.Footer; + } + } + } + + /// + /// Проверяет, содержит ли документ колонтитулы + /// + internal static bool HasHeadersOrFooters(WordprocessingDocument document) + { + if (document?.MainDocumentPart is null) + return false; + + return document.MainDocumentPart.HeaderParts.Any() || document.MainDocumentPart.FooterParts.Any(); + } + + /// + /// Заменяет параграф на новые параграфы в родительском элементе + /// + private static void ReplaceParagraphInParent(Paragraph oldParagraph, List newParagraphs) + { + ParagraphReplacer.ReplaceParagraph(oldParagraph, newParagraphs); + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/IWordReader.cs b/QWERTYkez.WordProcessor/IWordReader.cs new file mode 100644 index 0000000..d148f65 --- /dev/null +++ b/QWERTYkez.WordProcessor/IWordReader.cs @@ -0,0 +1,14 @@ +namespace QWERTYkez.WordProcessor; + +public interface IWordReader +{ + long DocumentSize { get; } + string? FilePath { get; } + bool IsValid { get; } + + ISet FindPlaceholders(); + + + bool TryWrite(string destinationPath, Action action); + bool TryWrite(Action write, Action read); +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/IWordWriter.cs b/QWERTYkez.WordProcessor/IWordWriter.cs new file mode 100644 index 0000000..f584f31 --- /dev/null +++ b/QWERTYkez.WordProcessor/IWordWriter.cs @@ -0,0 +1,29 @@ +using QWERTYkez.WordProcessor.Builders; + +namespace QWERTYkez.WordProcessor; + +public interface IWordWriter : IWordReader +{ + /// Добавляет новый параграф с указанным текстом в конец документа. + void AddParagraph(string text, bool preserveFormatting = true); + + + void ReplaceItem(IDictionary> replacements); + void ReplaceItem(IDictionary replacements); + void ReplaceItem(IEnumerable>> replacements); + void ReplaceItem(IEnumerable> replacements); + void ReplaceItem(string oldValue, IEnumerable newValues); + void ReplaceItem(string oldValue, params ReplaceItem[] newValues); + void ReplaceString(IDictionary> replacements); + void ReplaceString(IDictionary replacements); + void ReplaceString(IEnumerable>> replacements); + void ReplaceString(IEnumerable> replacements); + void ReplaceString(string oldValue, IEnumerable newValues); + void ReplaceString(string oldValue, params string[] newValues); + void ReplaceToTable(string oldValue, Action buildTable); + void ReplaceToText(string oldValue, Action buildText); + void Save(); + void SaveTo(string path); + Task SaveToAsync(string path, CancellationToken cancellationToken = default); + bool TrySaveTo(string path, out Exception? error); +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/IeExtension.cs b/QWERTYkez.WordProcessor/IeExtension.cs new file mode 100644 index 0000000..8344851 --- /dev/null +++ b/QWERTYkez.WordProcessor/IeExtension.cs @@ -0,0 +1,106 @@ +namespace QWERTYkez.WordProcessor; + +internal static class IeExtension +{ + /// + /// Выполняет указанные действия для первого и последующих элементов последовательности. + /// + /// Тип элементов последовательности. + /// Последовательность элементов. + /// Действие над первым элементом (если есть). + /// Действие над каждым последующим элементом, начиная со второго. + /// Возникает, если items или любой из делегатов равен null. + public static void ForFirstNext(this IEnumerable items, Action first, Action next) + { + if (items is null) throw new ArgumentNullException(nameof(items)); + if (first is null) throw new ArgumentNullException(nameof(first)); + if (next is null) throw new ArgumentNullException(nameof(next)); + + using var enumerator = items.GetEnumerator(); + + if (!enumerator.MoveNext()) + return; + + first(enumerator.Current); + + while (enumerator.MoveNext()) + { + next(enumerator.Current); + } + } + + /// + /// Выполняет указанные действия для первого, промежуточных и последнего элементов последовательности. + /// Если последовательность содержит только один элемент, то для него вызываются и first, и last. + /// + /// Тип элементов последовательности. + /// Последовательность элементов. + /// Действие над первым элементом. + /// Действие над элементами, которые не являются ни первыми, ни последними. + /// Действие над последним элементом. + /// Возникает, если items или любой из делегатов равен null. + public static void ForFirstNextLast(this IEnumerable items, Action first, Action next, Action last) + { + if (items is null) throw new ArgumentNullException(nameof(items)); + if (first is null) throw new ArgumentNullException(nameof(first)); + if (next is null) throw new ArgumentNullException(nameof(next)); + if (last is null) throw new ArgumentNullException(nameof(last)); + + using var enumerator = items.GetEnumerator(); + + if (!enumerator.MoveNext()) + return; + + T firstItem = enumerator.Current; + + // Если только один элемент + if (!enumerator.MoveNext()) + { + first(firstItem); + last(firstItem); + return; + } + + // Есть как минимум два элемента + first(firstItem); + + T prev = enumerator.Current; // второй элемент + while (enumerator.MoveNext()) + { + next(prev); // предыдущий элемент точно не последний + prev = enumerator.Current; + } + + last(prev); // последний элемент + } + + /// + /// Выполняет указанные действия для всех элементов, кроме последнего, и для последнего элемента. + /// Если последовательность содержит только один элемент, то вызывается только last. + /// + /// Тип элементов последовательности. + /// Последовательность элементов. + /// Действие над элементами, не являющимися последними. + /// Действие над последним элементом. + /// Возникает, если items или любой из делегатов равен null. + public static void ForNextLast(this IEnumerable items, Action next, Action last) + { + if (items is null) throw new ArgumentNullException(nameof(items)); + if (next is null) throw new ArgumentNullException(nameof(next)); + if (last is null) throw new ArgumentNullException(nameof(last)); + + using var enumerator = items.GetEnumerator(); + + if (!enumerator.MoveNext()) + return; + + T prev = enumerator.Current; + while (enumerator.MoveNext()) + { + next(prev); + prev = enumerator.Current; + } + + last(prev); + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/MultiReplace.cs b/QWERTYkez.WordProcessor/MultiReplace.cs new file mode 100644 index 0000000..ef99614 --- /dev/null +++ b/QWERTYkez.WordProcessor/MultiReplace.cs @@ -0,0 +1,494 @@ +namespace QWERTYkez.WordProcessor; + +/// +/// Выполняет замену всех вхождений ключей из словаря на соответствующие массивы значений. +/// Каждое значение из массива помещается в отдельный параграф, причём первое значение +/// остаётся в текущем параграфе, а последующие создают новые. +/// Текст между вхождениями и после последнего сохраняется в соответствующих параграфах. +/// +internal static class MultiReplaceExt +{ + // ---------- ПУБЛИЧНЫЕ МЕТОДЫ (СИГНАТУРЫ НЕИЗМЕННЫ) ---------- + + #region Body.Replace с одним ключом + + internal static void Replace(this Body body, string oldValue, IEnumerable newValues, StringComparison comparisonType) + { + if (body is null || string.IsNullOrEmpty(oldValue) || newValues is null) return; + var dict = new Dictionary> { { oldValue, newValues } }; + body.Replace(dict, comparisonType); + } + + internal static void Replace(this Body body, string oldValue, IEnumerable newValues, StringComparison comparisonType) + { + if (body is null || string.IsNullOrEmpty(oldValue) || newValues is null) return; + var dict = new Dictionary> { { oldValue, newValues } }; + body.Replace(dict, comparisonType); + } + + #endregion + + #region Body.Replace со словарём массивов + + internal static void Replace(this Body body, IEnumerable>> replacements, StringComparison comparisonType) + { + if (body is null || replacements is null) return; + var paragraphs = body.Elements().ToList(); + for (int i = paragraphs.Count - 1; i >= 0; i--) + { + var p = paragraphs[i]; + if (p?.Parent is null) continue; + var newParas = ProcessMultiReplacements(p, replacements, null, comparisonType); + if (newParas is not null && newParas.Count > 0) + ParagraphReplacer.ReplaceParagraph(p, newParas); + } + } + + internal static void Replace(this Body body, IEnumerable>> replacements, StringComparison comparisonType) + { + if (body is null || replacements is null) return; + var paragraphs = body.Elements().ToList(); + for (int i = paragraphs.Count - 1; i >= 0; i--) + { + var p = paragraphs[i]; + if (p?.Parent is null) continue; + var newParas = ProcessMultiReplacements(p, null, replacements, comparisonType); + if (newParas is not null && newParas.Count > 0) + ParagraphReplacer.ReplaceParagraph(p, newParas); + } + } + + #endregion + + #region Paragraph.ReplaceWithMultiple (один ключ) + + internal static bool ReplaceWithMultiple(this Paragraph? paragraph, string oldValue, IEnumerable newValues, StringComparison comparisonType) + { + if (paragraph is null || string.IsNullOrEmpty(oldValue) || newValues is null || newValues.Count() == 0) + return false; + + var dict = new Dictionary> { { oldValue, newValues } }; + var newParas = ProcessMultiReplacements(paragraph, dict, null, comparisonType); + if (newParas is null || newParas.Count == 0) return false; + + if (paragraph.Parent is not null) + { + ParagraphReplacer.ReplaceParagraph(paragraph, newParas); + return true; + } + return false; + } + + internal static bool ReplaceWithMultiple(this Paragraph? paragraph, string oldValue, IEnumerable newValues, StringComparison comparisonType) + { + if (paragraph is null || string.IsNullOrEmpty(oldValue) || newValues is null || newValues.Count() == 0) + return false; + + var dict = new Dictionary> { { oldValue, newValues } }; + var newParas = ProcessMultiReplacements(paragraph, null, dict, comparisonType); + if (newParas is null || newParas.Count == 0) return false; + + if (paragraph.Parent is not null) + { + ParagraphReplacer.ReplaceParagraph(paragraph, newParas); + return true; + } + return false; + } + + #endregion + + #region ProcessParagraphWithAllReplacements (для обратной совместимости) + + internal static List? ProcessParagraphWithAllReplacements( + Paragraph paragraph, + IEnumerable>> replacements, + StringComparison comparisonType) + { + return ProcessMultiReplacements(paragraph, replacements, null, comparisonType); + } + + internal static List? ProcessParagraphWithAllReplacements( + Paragraph paragraph, + IEnumerable>> replacements, + StringComparison comparisonType) + { + return ProcessMultiReplacements(paragraph, null, replacements, comparisonType); + } + + internal static List? ProcessParagraphWithAllReplacements( + Paragraph paragraph, + IEnumerable> replacements, + StringComparison comparisonType) + { + Dictionary> dict = replacements + .Where(kvp => !string.IsNullOrEmpty(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => (IEnumerable)[kvp.Value]); + return ProcessMultiReplacements(paragraph, dict, null, comparisonType); + } + + #endregion + + // ---------- ВНУТРЕННЯЯ РЕАЛИЗАЦИЯ ---------- + + private class MatchDefinition(string key, IEnumerable values) + { + public string Key { get; } = key; + public IEnumerable Values { get; } = values; + } + + private class Match + { + public MatchDefinition Definition { get; set; } = null!; + public int Start { get; set; } + public int End { get; set; } + } + + private class RunSegment(Run run, string text, int start, int end) + { + public Run Run { get; } = run; + public string Text { get; } = text; + public int Start { get; } = start; + public int End { get; } = end; + } + + private class ParagraphStructure(string fullText, List segments) + { + public string FullText { get; } = fullText; + public List Segments { get; } = segments; + } + + private static ParagraphStructure AnalyzeParagraphStructure(List runs) + { + var segments = new List(); + var sb = new StringBuilder(); + int pos = 0; + foreach (var run in runs) + { + string text = GetRunText(run); + if (string.IsNullOrEmpty(text)) continue; + segments.Add(new RunSegment(run, text, pos, pos + text.Length)); + sb.Append(text); + pos += text.Length; + } + return new ParagraphStructure(sb.ToString(), segments); + } + + private static string GetRunText(Run run) + { + var sb = new StringBuilder(); + foreach (var text in run.Elements()) + sb.Append(text.Text); + return sb.ToString(); + } + + private static Paragraph CloneParagraphProperties(Paragraph original) + { + var newPara = new Paragraph(); + if (original.ParagraphProperties is not null) + newPara.ParagraphProperties = (ParagraphProperties)original.ParagraphProperties.CloneNode(true); + return newPara; + } + + /// + /// Строит параграф, содержащий копии всех элементов исходного параграфа, + /// попадающих в текстовый диапазон [start, end). + /// + private static Paragraph? BuildRangeParagraph(Paragraph original, ParagraphStructure structure, int start, int end) + { + if (start >= end) return null; + + var newPara = CloneParagraphProperties(original); + + foreach (var child in original.ChildElements) + { + if (child is Run run) + { + var seg = structure.Segments.FirstOrDefault(s => s.Run == run); + if (seg is null) + { + // Run без текста (разрыв, поле) – копируем целиком, т.к. не можем привязать к позиции + newPara.AppendChild(run.CloneNode(true)); + continue; + } + + if (seg.End <= start || seg.Start >= end) + continue; + + if (seg.Start >= start && seg.End <= end) + { + // Полностью внутри диапазона + newPara.AppendChild(run.CloneNode(true)); + } + else + { + // Частичное пересечение – обрезаем текст + var runClone = (Run)run.CloneNode(true); + foreach (var t in runClone.Elements().ToList()) + t.Remove(); + + int cutStart = Math.Max(start, seg.Start) - seg.Start; + int cutEnd = Math.Min(end, seg.End) - seg.Start; + string newText = seg.Text.Substring(cutStart, cutEnd - cutStart); + runClone.AppendChild(new Text(newText)); + newPara.AppendChild(runClone); + } + } + else + { + // Не Run – копируем всегда (закладки, поля и т.п.), т.к. не можем определить позицию + newPara.AppendChild(child.CloneNode(true)); + } + } + + // Удаляем пустые Run + foreach (var run in newPara.Descendants().Where(r => !r.HasChildren).ToList()) + run.Remove(); + + if (!newPara.ChildElements.OfType().Any() && newPara.ParagraphProperties is null) + return null; + + return newPara; + } + + /// + /// Строит параграф, содержащий все элементы исходного параграфа, + /// которые находятся строго после указанной текстовой позиции, + /// пропуская нетекстовые элементы до первого текстового сегмента. + /// + private static Paragraph? BuildAfterParagraph(Paragraph original, ParagraphStructure structure, int position) + { + if (position >= structure.FullText.Length) return null; + + var newPara = CloneParagraphProperties(original); + + // Находим первый текстовый сегмент, который начинается на или после position + var firstTextSeg = structure.Segments.FirstOrDefault(s => s.Start >= position); + bool passedFirstText = false; + + foreach (var child in original.ChildElements) + { + if (child is Run run) + { + var seg = structure.Segments.FirstOrDefault(s => s.Run == run); + if (seg is null) + { + // Run без текста – добавляем только если уже прошли первый текстовый сегмент + if (passedFirstText) + newPara.AppendChild(run.CloneNode(true)); + continue; + } + + if (seg.Start >= position) + { + // Полностью после позиции + newPara.AppendChild(run.CloneNode(true)); + if (seg == firstTextSeg) + passedFirstText = true; + } + else if (seg.End > position) + { + // Частично пересекает – обрезаем текст + var runClone = (Run)run.CloneNode(true); + foreach (var t in runClone.Elements().ToList()) + t.Remove(); + + int offset = position - seg.Start; + string newText = seg.Text.Substring(offset); + runClone.AppendChild(new Text(newText)); + newPara.AppendChild(runClone); + passedFirstText = true; + } + // seg.End <= position – игнорируем + } + else + { + // Не Run – добавляем только если уже прошли первый текстовый сегмент + if (passedFirstText) + newPara.AppendChild(child.CloneNode(true)); + } + } + + foreach (var run in newPara.Descendants().Where(r => !r.HasChildren).ToList()) + run.Remove(); + + if (!newPara.ChildElements.OfType().Any() && newPara.ParagraphProperties is null) + return null; + + return newPara; + } + + /// + /// Вставляет в параграф новый Run с текстом из ReplaceItem, + /// копируя форматирование из сегмента, содержащего указанную позицию. + /// Если BreakPage == true, добавляет отдельный Run с разрывом страницы. + /// + private static void InsertFormattedRun(Paragraph para, ReplaceItem item, ParagraphStructure structure, int position) + { + var seg = structure.Segments.FirstOrDefault(s => position >= s.Start && position < s.End); + if (seg is null) return; + + var textRun = new Run(); + if (seg.Run.RunProperties is not null) + textRun.RunProperties = (RunProperties)seg.Run.RunProperties.CloneNode(true); + textRun.AppendChild(new Text(item.Text ?? string.Empty)); + para.AppendChild(textRun); + + if (item.BreakPage) + { + var breakRun = new Run(new Break() { Type = BreakValues.Page }); + if (seg.Run.RunProperties is not null) + breakRun.RunProperties = (RunProperties)seg.Run.RunProperties.CloneNode(true); + para.AppendChild(breakRun); + } + } + + /// + /// Добавляет содержимое одного параграфа в другой (клонируя элементы). + /// + private static void MergeParagraph(Paragraph target, Paragraph source) + { + foreach (var child in source.ChildElements) + target.AppendChild(child.CloneNode(true)); + } + + /// + /// Основной алгоритм: обрабатывает все вхождения всех ключей из предоставленных словарей. + /// + private static List? ProcessMultiReplacements( + Paragraph original, + IEnumerable>>? stringReplacements, + IEnumerable>>? itemReplacements, + StringComparison comparisonType) + { + // 1. Собираем определения замен + var definitions = new List(); + if (stringReplacements is not null) + { + foreach (var kvp in stringReplacements) + { + if (string.IsNullOrEmpty(kvp.Key) || kvp.Value is null || kvp.Value.Count() == 0) continue; + definitions.Add(new MatchDefinition(kvp.Key, [.. kvp.Value.Select(v => new ReplaceItem(v, false))])); + } + } + if (itemReplacements is not null) + { + foreach (var kvp in itemReplacements) + { + if (string.IsNullOrEmpty(kvp.Key) || kvp.Value is null || kvp.Value.Count() == 0) continue; + definitions.Add(new MatchDefinition(kvp.Key, kvp.Value)); + } + } + + if (definitions.Count == 0) return null; + + // 2. Анализ структуры параграфа + var runs = original.Descendants().ToList(); + if (runs.Count == 0) return null; + var structure = AnalyzeParagraphStructure(runs); + string fullText = structure.FullText; + if (fullText.Length == 0) return null; + + // 3. Находим все вхождения всех ключей + var matches = new List(); + foreach (var def in definitions) + { + int pos = 0; + while ((pos = fullText.IndexOf(def.Key, pos, comparisonType)) != -1) + { + matches.Add(new Match + { + Definition = def, + Start = pos, + End = pos + def.Key.Length + }); + pos += def.Key.Length; + } + } + + if (matches.Count == 0) return null; + + // 4. Сортируем по позиции + matches.Sort((a, b) => a.Start.CompareTo(b.Start)); + + // 5. Построение результирующих параграфов + var resultParas = new List(); + Paragraph? currentPara = null; + int currentPos = 0; + + for (int i = 0; i < matches.Count; i++) + { + var match = matches[i]; + + // Текст перед текущим совпадением (от currentPos до match.Start) + if (currentPos < match.Start) + { + var textPart = BuildRangeParagraph(original, structure, currentPos, match.Start); + if (textPart is not null) + { + if (currentPara is null) + { + currentPara = textPart; + resultParas.Add(currentPara); + } + else + { + MergeParagraph(currentPara, textPart); + } + } + } + + // Обрабатываем значения замены для этого совпадения + var values = match.Definition.Values; + + // Первое значение – в текущий параграф (или создаём новый) + if (currentPara is null) + { + currentPara = CloneParagraphProperties(original); + resultParas.Add(currentPara); + } + + values.ForFirstNext(first => + { + InsertFormattedRun(currentPara, first, structure, match.Start); + }, + next => + { + // Остальные значения – в новые параграфы + var newPara = CloneParagraphProperties(original); + InsertFormattedRun(newPara, next, structure, match.Start); + resultParas.Add(newPara); + currentPara = newPara; // теперь текущий параграф – последний созданный + }); + + currentPos = match.End; + } + + // Текст после последнего совпадения – используем BuildAfterParagraph, чтобы пропустить лишние разрывы + if (currentPos < fullText.Length) + { + var textPart = BuildAfterParagraph(original, structure, currentPos); + if (textPart is not null) + { + if (currentPara is null) + { + currentPara = textPart; + resultParas.Add(currentPara); + } + else + { + MergeParagraph(currentPara, textPart); + } + } + } + + // Удаляем пустые параграфы + for (int i = resultParas.Count - 1; i >= 0; i--) + { + if (!resultParas[i].ChildElements.OfType().Any() && resultParas[i].ParagraphProperties is null) + resultParas.RemoveAt(i); + } + + return resultParas.Count > 0 ? resultParas : null; + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/NormalizedSet.cs b/QWERTYkez.WordProcessor/NormalizedSet.cs new file mode 100644 index 0000000..012e768 --- /dev/null +++ b/QWERTYkez.WordProcessor/NormalizedSet.cs @@ -0,0 +1,135 @@ +using System.Collections; +using System.Globalization; + +namespace QWERTYkez.WordProcessor; + +/// +/// Множество строк, которое автоматически приводит все добавляемые элементы +/// к верхнему регистру и удаляет диакритические знаки (например, 'ё' -> 'Е'). +/// Реализует ISet<string>, поэтому может использоваться там, где ожидается этот интерфейс. +/// +public class NormalizedSet : ISet +{ + private readonly HashSet _inner; + + /// + /// Создаёт пустое нормализованное множество. + /// + public NormalizedSet() + { + _inner = []; + } + + /// + /// Создаёт нормализованное множество, заполненное элементами из указанной коллекции. + /// + /// Коллекция, элементы которой будут нормализованы и добавлены. + public NormalizedSet(IEnumerable collection) + { + _inner = [.. collection.Select(Normalize)]; + } + + /// + /// Нормализует строку: верхний регистр и удаление диакритики. + /// + private static string Normalize(string s) + { + if (string.IsNullOrEmpty(s)) + return s; + + var normalized = s.Normalize(NormalizationForm.FormD); + var sb = new StringBuilder(); + foreach (char c in normalized) + { + if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) + sb.Append(c); + } + return sb.ToString().Normalize(NormalizationForm.FormC).ToUpperInvariant(); + } + + // ---------- Реализация ISet ---------- + + public bool Add(string item) => _inner.Add(Normalize(item)); + + void ICollection.Add(string item) => Add(item); + + public void UnionWith(IEnumerable other) + { + foreach (var item in other) + Add(item); + } + + public void IntersectWith(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + _inner.IntersectWith(normalizedOther); + } + + public void ExceptWith(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + _inner.ExceptWith(normalizedOther); + } + + public void SymmetricExceptWith(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + _inner.SymmetricExceptWith(normalizedOther); + } + + public bool IsSubsetOf(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.IsSubsetOf(normalizedOther); + } + + public bool IsSupersetOf(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.IsSupersetOf(normalizedOther); + } + + public bool IsProperSupersetOf(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.IsProperSupersetOf(normalizedOther); + } + + public bool IsProperSubsetOf(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.IsProperSubsetOf(normalizedOther); + } + + public bool Overlaps(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.Overlaps(normalizedOther); + } + + public bool SetEquals(IEnumerable other) + { + var normalizedOther = new HashSet(other.Select(Normalize)); + return _inner.SetEquals(normalizedOther); + } + + // ---------- Реализация ICollection ---------- + + public int Count => _inner.Count; + + public bool IsReadOnly => false; + + public void Clear() => _inner.Clear(); + + public bool Contains(string item) => _inner.Contains(Normalize(item)); + + public void CopyTo(string[] array, int arrayIndex) => _inner.CopyTo(array, arrayIndex); + + public bool Remove(string item) => _inner.Remove(Normalize(item)); + + // ---------- Реализация IEnumerable и IEnumerable ---------- + + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/ParagraphReplacer.cs b/QWERTYkez.WordProcessor/ParagraphReplacer.cs new file mode 100644 index 0000000..82eaf88 --- /dev/null +++ b/QWERTYkez.WordProcessor/ParagraphReplacer.cs @@ -0,0 +1,47 @@ +namespace QWERTYkez.WordProcessor; + +/// +/// Вспомогательный класс для замены параграфов в различных контейнерах +/// +internal static class ParagraphReplacer +{ + /// + /// Заменяет параграф на новые параграфы в родительском элементе + /// + /// Старый параграф для замены + /// Новые параграфы + internal static void ReplaceParagraph(Paragraph oldParagraph, List newParagraphs) + { + if (oldParagraph is null || newParagraphs is null || newParagraphs.Count == 0) + return; + + var parent = oldParagraph.Parent; + if (parent is null) + return; + + int paraIndex = -1; + var children = parent.ChildElements; + + // Находим индекс параграфа + for (int i = 0; i < children.Count; i++) + { + if (children[i] == oldParagraph) + { + paraIndex = i; + break; + } + } + + if (paraIndex == -1) + return; + + // Удаляем старый параграф + parent.RemoveChild(oldParagraph); + + // Вставляем новые параграфы + for (int i = 0; i < newParagraphs.Count; i++) + { + parent.InsertAt(newParagraphs[i], paraIndex + i); + } + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/PlaceholderFinder.cs b/QWERTYkez.WordProcessor/PlaceholderFinder.cs new file mode 100644 index 0000000..24d4fa3 --- /dev/null +++ b/QWERTYkez.WordProcessor/PlaceholderFinder.cs @@ -0,0 +1,175 @@ +namespace QWERTYkez.WordProcessor; + +internal static class PlaceholderFinder +{ + public static ISet FindInDocument(WordprocessingDocument doc, Body body) + { + ISet result = new NormalizedSet(); + + // 1. Основной текст + FindInParagraphs(body.Elements(), result); + + // 2. Таблицы в основном тексте + foreach (var table in body.Descendants()) + { + FindInParagraphs(table.Descendants(), result); + } + + // 3. Колонтитулы + FindInHeadersAndFooters(doc, result); + + return result; + } + + private static void FindInParagraphs(IEnumerable paragraphs, ISet result) + { + // Локальная коллекция для каждого вызова - оптимизация для уменьшения аллокаций + List? tempList = null; + + foreach (var paragraph in paragraphs) + { + var text = GetParagraphText(paragraph); + if (string.IsNullOrEmpty(text)) continue; + + // Откладываем создание списка до первого найденного плейсхолдера + bool hasPlaceholders = FindPlaceholdersInText(text, ref tempList); + + if (hasPlaceholders && tempList is not null) + { + // Добавляем найденные плейсхолдеры с учетом регистра + foreach (var placeholder in tempList) + { + result.Add(placeholder); + } + tempList.Clear(); + } + } + } + + private static unsafe bool FindPlaceholdersInText(string text, ref List? output) + { + fixed (char* pText = text) + { + char* start = pText; + char* end = pText + text.Length; + bool foundAny = false; + + while (start < end) + { + // Ищем начало плейсхолдера + char* dollarStart = null; + while (start < end) + { + if (*start == '$') + { + dollarStart = start; + start++; + break; + } + start++; + } + + if (dollarStart is null || start >= end) break; + + // Ищем конец плейсхолдера + char* dollarEnd = null; + char* contentStart = start; // Начало содержимого (после первого $) + + while (start < end) + { + if (*start == '$') + { + dollarEnd = start; + start++; + break; + } + start++; + } + + if (dollarEnd is null) break; + + // Извлекаем содержимое между долларами + int contentLength = (int)(dollarEnd - contentStart); + if (contentLength > 0) // Игнорируем пустые "$$" + { + foundAny = true; + output ??= []; + + string content = new(contentStart, 0, contentLength); + output.Add(content); + } + } + + return foundAny; + } + } + + private static void FindInHeadersAndFooters(WordprocessingDocument doc, ISet result) + { + if (doc.MainDocumentPart is null) return; + + // Верхние колонтитулы + foreach (var headerPart in doc.MainDocumentPart.HeaderParts) + { + if (headerPart?.Header is not null) + { + FindInParagraphs(headerPart.Header.Descendants(), result); + } + } + + // Нижние колонтитулы + foreach (var footerPart in doc.MainDocumentPart.FooterParts) + { + if (footerPart?.Footer is not null) + { + FindInParagraphs(footerPart.Footer.Descendants(), result); + } + } + } + + private static string GetParagraphText(Paragraph paragraph) + { + if (paragraph is null) return string.Empty; + + // Используем Span для минимальных аллокаций + var texts = paragraph.Descendants(); + + // Быстрая проверка: если всего один WordText элемент + if (texts is ICollection collection && collection.Count == 1) + { + foreach (var text in collection) + { + return text.Text ?? string.Empty; + } + } + + // Для нескольких WordText элементов + int totalLength = 0; + + // Первый проход: подсчет общей длины + foreach (var text in texts) + { + if (text.Text is not null) + { + totalLength += text.Text.Length; + } + } + + if (totalLength == 0) return string.Empty; + + // Второй проход: копирование + var chars = new char[totalLength]; + int position = 0; + + foreach (var text in texts) + { + if (text.Text is not null) + { + text.Text.CopyTo(0, chars, position, text.Text.Length); + position += text.Text.Length; + } + } + + return new string(chars); + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/QWERTYkez.WordProcessor.csproj b/QWERTYkez.WordProcessor/QWERTYkez.WordProcessor.csproj new file mode 100644 index 0000000..5afe3a2 --- /dev/null +++ b/QWERTYkez.WordProcessor/QWERTYkez.WordProcessor.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0 + latest + enable + true + + + + + + + + \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/ReplaceItem.cs b/QWERTYkez.WordProcessor/ReplaceItem.cs new file mode 100644 index 0000000..acff729 --- /dev/null +++ b/QWERTYkez.WordProcessor/ReplaceItem.cs @@ -0,0 +1,24 @@ +namespace QWERTYkez.WordProcessor; + +public readonly struct ReplaceItem +{ + public ReplaceItem() { } + public ReplaceItem(string text) + { + Text = text; + } + public ReplaceItem(string text, bool breakPage) + { + Text = text; + BreakPage = breakPage; + } + + public string Text { get; init; } = string.Empty; + public bool BreakPage { get; init; } = false; + + + // Неявное преобразование из ReplaceItem в string + //public static implicit operator string(ReplaceItem item) => item.Text; + // Явное преобразование из string в ReplaceItem + public static explicit operator ReplaceItem(string text) => new() { Text = text }; +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/ReplaceToTableExt.cs b/QWERTYkez.WordProcessor/ReplaceToTableExt.cs new file mode 100644 index 0000000..062e776 --- /dev/null +++ b/QWERTYkez.WordProcessor/ReplaceToTableExt.cs @@ -0,0 +1,220 @@ +using QWERTYkez.WordProcessor.Builders; + +namespace QWERTYkez.WordProcessor; + +internal static class ReplaceToTableExt +{ + /// + /// Заменяет параграф на таблицу, созданную с помощью TableBuilder + /// + internal static void ReplaceWithTableBuilder(Paragraph paragraph, Action buildTable, FontProps? baseFont) + { + if (paragraph is null || paragraph.Parent is null || buildTable is null) + return; + + var parent = paragraph.Parent; + + // Создаем TableBuilder с базовым шрифтом + var builder = TableBuilder.Create(baseFont); + buildTable(builder); + var table = builder.Build(); + + // Находим индекс параграфа среди детей родителя + int paraIndex = -1; + var children = parent.ChildElements; + + for (int i = 0; i < children.Count; i++) + { + if (children[i] == paragraph) + { + paraIndex = i; + break; + } + } + + if (paraIndex == -1) + return; + + // Удаляем старый параграф и вставляем таблицу на его место + parent.RemoveChild(paragraph); + parent.InsertAt(table, paraIndex); + } + + /// + /// Заменяет все параграфы, содержащие указанный текст, на таблицы + /// + internal static void ReplaceParagraphsContainingTextToTable(Body body, + string oldValue, Action buildTable) + { + if (body is null || string.IsNullOrEmpty(oldValue) || buildTable is null) + return; + + var paragraphs = body.Elements().ToList(); + if (paragraphs.Count == 0) + return; + + for (int i = paragraphs.Count - 1; i >= 0; i--) + { + var paragraph = paragraphs[i]; + if (paragraph is null) continue; + + var paraText = paragraph.InnerText; + if (string.IsNullOrEmpty(paraText) || + paraText.IndexOf(oldValue, StringComparison.OrdinalIgnoreCase) < 0) + continue; + + // Извлекаем свойства шрифта для передачи в TableBuilder + var fontProps = ExtractFontPropsFromParagraph(paragraph, oldValue); + + // Заменяем параграф на таблицу + ReplaceWithTableBuilder(paragraph, buildTable, fontProps); + } + } + + /// + /// Извлекает свойства шрифта (FontProps) из Run, содержащего указанный текст в параграфе. + /// + private static FontProps? ExtractFontPropsFromParagraph(Paragraph paragraph, string searchText) + { + if (paragraph is null || string.IsNullOrEmpty(searchText)) + return null; + + var runs = paragraph.Descendants().ToList(); + if (runs.Count == 0) + return null; + + // Собираем полный текст параграфа и позиции каждого Run для анализа + var runInfos = new List<(Run Run, int Start, int End, string Text)>(); + int currentPosition = 0; + + foreach (var run in runs) + { + var runText = GetRunText(run); + if (!string.IsNullOrEmpty(runText)) + { + runInfos.Add((run, currentPosition, currentPosition + runText.Length, runText)); + currentPosition += runText.Length; + } + } + + // Ищем Run, который содержит искомый текст (регистронезависимо) + foreach (var (run, start, end, text) in runInfos) + { + // Проверяем, содержится ли искомый текст в этом Run + if (text.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) >= 0) + { + return CreateFontPropsFromRun(run); + } + } + + // Если точное вхождение не найдено, ищем пересечение текста + var fullText = string.Concat(runInfos.Select(r => r.Text)); + int textIndex = fullText.IndexOf(searchText, StringComparison.OrdinalIgnoreCase); + + if (textIndex >= 0) + { + // Находим первый Run, который пересекается с найденным текстом + var intersectingRun = runInfos.FirstOrDefault(r => + r.Start <= textIndex && r.End > textIndex); + + if (intersectingRun.Run is not null) + { + return CreateFontPropsFromRun(intersectingRun.Run); + } + } + + return null; // Не удалось найти подходящий Run + } + + /// + /// Создаёт FontProps на основе свойств указанного Run. + /// + private static FontProps CreateFontPropsFromRun(Run run) + { + var runProperties = run.RunProperties; + if (runProperties is null) + return new FontProps(); + + var fontProps = new FontProps(); + + // Font Family + var runFonts = runProperties.GetFirstChild(); + if (runFonts is not null) + { + fontProps = fontProps with + { + FontFamily = runFonts.Ascii ?? runFonts.HighAnsi ?? runFonts.ComplexScript + }; + } + + // Font Size + var fontSize = runProperties.GetFirstChild(); + if (fontSize is not null && !string.IsNullOrEmpty(fontSize.Val) && + uint.TryParse(fontSize.Val, out uint halfPoints)) + { + fontProps = fontProps with { Size = halfPoints / 2.0 }; + } + + // Bold + var bold = runProperties.GetFirstChild(); + if (bold is not null) + { + bool isBold = true; + if (bold.Val is not null) + { + isBold = bold.Val.Value; + } + fontProps = fontProps with { IsBold = isBold }; + } + + // Italic + var italic = runProperties.GetFirstChild(); + if (italic is not null) + { + bool isItalic = true; + if (italic.Val is not null) + { + isItalic = italic.Val.Value; + } + fontProps = fontProps with { IsItalic = isItalic }; + } + + // Underline + var underline = runProperties.GetFirstChild(); + if (underline is not null && underline.Val is not null) + { + fontProps = fontProps with { Underline = underline.Val.Value }; + } + + // Color + var color = runProperties.GetFirstChild(); + if (color is not null && !string.IsNullOrEmpty(color.Val)) + { + fontProps = fontProps with { Color = color.Val }; + } + + // Subscript/Superscript + var verticalAlignment = runProperties.GetFirstChild(); + if (verticalAlignment is not null && verticalAlignment.Val is not null) + { + fontProps = fontProps with { SubSup = verticalAlignment.Val.Value }; + } + + return fontProps; + } + + /// + /// Вспомогательный метод для получения текста из Run. + /// + private static string GetRunText(Run run) + { + if (run is null) return string.Empty; + + var sb = new StringBuilder(); + foreach (var text in run.Elements()) + { + sb.Append(text.Text ?? string.Empty); + } + return sb.ToString(); + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/ReplaceToTextExt.cs b/QWERTYkez.WordProcessor/ReplaceToTextExt.cs new file mode 100644 index 0000000..e6dfcc0 --- /dev/null +++ b/QWERTYkez.WordProcessor/ReplaceToTextExt.cs @@ -0,0 +1,231 @@ +using QWERTYkez.WordProcessor.Builders; + +namespace QWERTYkez.WordProcessor; + +/// +/// Вспомогательный класс для замены параграфов, содержащих указанный текст, на текст, построенный с помощью TextBuilder. +/// +internal static class ReplaceToTextExt +{ + /// + /// Заменяет все параграфы, содержащие указанный текст, на текст, построенный с помощью TextBuilder. + /// + internal static void ReplaceParagraphsContainingTextToText( + Body body, + string oldValue, + Action buildText) + { + if (body is null || string.IsNullOrEmpty(oldValue) || buildText is null) + return; + + var paragraphs = body.Elements().ToList(); + if (paragraphs.Count == 0) + return; + + for (int i = paragraphs.Count - 1; i >= 0; i--) + { + var paragraph = paragraphs[i]; + if (paragraph is null) continue; + + var paraText = paragraph.InnerText; + if (string.IsNullOrEmpty(paraText) || + paraText.IndexOf(oldValue, StringComparison.OrdinalIgnoreCase) < 0) + continue; + + // Извлекаем свойства шрифта для передачи в TextBuilder + var fontProps = ExtractFontPropsFromParagraph(paragraph, oldValue); + // Извлекаем свойства параграфа для наследования + var paragraphProps = paragraph.ParagraphProperties?.CloneNode(true) as ParagraphProperties; + + // Заменяем параграф на текст с наследованием форматирования + ReplaceWithTextBuilder(paragraph, buildText, fontProps, paragraphProps); + } + } + + private static void ReplaceWithTextBuilder( + Paragraph paragraph, + Action buildText, + FontProps? baseFont, + ParagraphProperties? baseParagraphProps) + { + if (paragraph is null || paragraph.Parent is null || buildText is null) + return; + + var parent = paragraph.Parent; + + // Создаем TextBuilder с базовым шрифтом и свойствами параграфа + var builder = TextBuilder.Create(baseFont, baseParagraphProps); + buildText(builder); + var newParagraphs = builder.Build(); + + // Находим индекс параграфа среди детей родителя + int paraIndex = -1; + var children = parent.ChildElements; + + for (int i = 0; i < children.Count; i++) + { + if (children[i] == paragraph) + { + paraIndex = i; + break; + } + } + + if (paraIndex == -1) + return; + + parent.RemoveChild(paragraph); + + for (int i = 0; i < newParagraphs.Count; i++) + { + parent.InsertAt(newParagraphs[i], paraIndex + i); + } + } + + /// + /// Извлекает свойства шрифта (FontProps) из Run, содержащего указанный текст в параграфе. + /// + private static FontProps? ExtractFontPropsFromParagraph(Paragraph paragraph, string searchText) + { + if (paragraph is null || string.IsNullOrEmpty(searchText)) + return null; + + var runs = paragraph.Descendants().ToList(); + if (runs.Count == 0) + return null; + + // Собираем полный текст параграфа и позиции каждого Run для анализа + var runInfos = new List<(Run Run, int Start, int End, string Text)>(); + int currentPosition = 0; + + foreach (var run in runs) + { + var runText = GetRunText(run); + if (!string.IsNullOrEmpty(runText)) + { + runInfos.Add((run, currentPosition, currentPosition + runText.Length, runText)); + currentPosition += runText.Length; + } + } + + // Ищем Run, который содержит искомый текст (регистронезависимо) + foreach (var (run, start, end, text) in runInfos) + { + // Проверяем, содержится ли искомый текст в этом Run + if (text.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) >= 0) + { + return CreateFontPropsFromRun(run); + } + } + + // Если точное вхождение не найдено, ищем пересечение текста + var fullText = string.Concat(runInfos.Select(r => r.Text)); + int textIndex = fullText.IndexOf(searchText, StringComparison.OrdinalIgnoreCase); + + if (textIndex >= 0) + { + // Находим первый Run, который пересекается с найденным текстом + var intersectingRun = runInfos.FirstOrDefault(r => + r.Start <= textIndex && r.End > textIndex); + + if (intersectingRun.Run is not null) + { + return CreateFontPropsFromRun(intersectingRun.Run); + } + } + + return null; // Не удалось найти подходящий Run + } + + /// + /// Создаёт FontProps на основе свойств указанного Run. + /// + private static FontProps CreateFontPropsFromRun(Run run) + { + var runProperties = run.RunProperties; + if (runProperties is null) + return new FontProps(); + + var fontProps = new FontProps(); + + // Font Family + var runFonts = runProperties.GetFirstChild(); + if (runFonts is not null) + { + fontProps = fontProps with + { + FontFamily = runFonts.Ascii ?? runFonts.HighAnsi ?? runFonts.ComplexScript + }; + } + + // Font Size + var fontSize = runProperties.GetFirstChild(); + if (fontSize is not null && !string.IsNullOrEmpty(fontSize.Val) && + uint.TryParse(fontSize.Val, out uint halfPoints)) + { + fontProps = fontProps with { Size = halfPoints / 2.0 }; + } + + // Bold + var bold = runProperties.GetFirstChild(); + if (bold is not null) + { + bool isBold = true; + if (bold.Val is not null) + { + isBold = bold.Val.Value; + } + fontProps = fontProps with { IsBold = isBold }; + } + + // Italic + var italic = runProperties.GetFirstChild(); + if (italic is not null) + { + bool isItalic = true; + if (italic.Val is not null) + { + isItalic = italic.Val.Value; + } + fontProps = fontProps with { IsItalic = isItalic }; + } + + // Underline + var underline = runProperties.GetFirstChild(); + if (underline is not null && underline.Val is not null) + { + fontProps = fontProps with { Underline = underline.Val.Value }; + } + + // Color + var color = runProperties.GetFirstChild(); + if (color is not null && !string.IsNullOrEmpty(color.Val)) + { + fontProps = fontProps with { Color = color.Val }; + } + + // Subscript/Superscript + var verticalAlignment = runProperties.GetFirstChild(); + if (verticalAlignment is not null && verticalAlignment.Val is not null) + { + fontProps = fontProps with { SubSup = verticalAlignment.Val.Value }; + } + + return fontProps; + } + + /// + /// Вспомогательный метод для получения текста из Run. + /// + private static string GetRunText(Run run) + { + if (run is null) return string.Empty; + + var sb = new StringBuilder(); + foreach (var text in run.Elements()) + { + sb.Append(text.Text ?? string.Empty); + } + return sb.ToString(); + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/SimplyReplace.cs b/QWERTYkez.WordProcessor/SimplyReplace.cs new file mode 100644 index 0000000..6463036 --- /dev/null +++ b/QWERTYkez.WordProcessor/SimplyReplace.cs @@ -0,0 +1,374 @@ +namespace QWERTYkez.WordProcessor; + +internal static class SimplyReplaceExt +{ + private readonly struct TextNodeInfo(Text text, int startIndex, int length) + { + internal readonly Text Text = text; + internal readonly int StartIndex = startIndex; + internal readonly int Length = length; + } + + private sealed class ParagraphStructure(string fullText, SimplyReplaceExt.TextNodeInfo[] textNodes) + { + internal readonly string FullText = fullText; + internal readonly TextNodeInfo[] TextNodes = textNodes; + + internal int FindFirstNodeIndexAtPosition(int position) + { + if (position < 0 || position >= FullText.Length) + return -1; + + int left = 0; + int right = TextNodes.Length - 1; + int result = -1; + + while (left <= right) + { + int mid = left + ((right - left) >> 1); + if (TextNodes[mid].StartIndex <= position) + { + result = mid; + left = mid + 1; + } + else + { + right = mid - 1; + } + } + + return result >= 0 && TextNodes[result].StartIndex + TextNodes[result].Length > position + ? result : -1; + } + } + + internal static void Replace(this Body body, string oldValue, string newValue, StringComparison comparisonType) + { + if (body is null || string.IsNullOrEmpty(oldValue)) return; + + var paragraphs = body.Elements(); + foreach (var paragraph in paragraphs) + { + paragraph?.SimpleReplace(oldValue, newValue, comparisonType); + } + } + + internal static void Replace(this Body body, IEnumerable> replacements, StringComparison comparisonType) + { + if (body is null || replacements is null || replacements.Count() == 0) + return; + + var paragraphs = body.Elements(); + foreach (var paragraph in paragraphs) + { + paragraph?.Replace(replacements, comparisonType); + } + } + + internal static void Replace(this Body body, IEnumerable> replacements, StringComparison comparisonType) + { + if (body is null || replacements is null || replacements.Count() == 0) + return; + + var paragraphs = body.Elements(); + foreach (var paragraph in paragraphs) + { + paragraph?.Replace(replacements, comparisonType); + } + } + + internal static bool SimpleReplace(this Paragraph? paragraph, string oldValue, string newValue, StringComparison comparisonType, bool breakPage = false) + { + if (paragraph is null || string.IsNullOrEmpty(oldValue)) + return false; + + var paragraphText = paragraph.InnerText; + if (string.IsNullOrEmpty(paragraphText)) + return false; + + int matchIndex = paragraphText.IndexOf(oldValue, comparisonType); + if (matchIndex == -1) + return false; + + var runs = paragraph.Elements(); + if (!runs.Any()) + return false; + + var structure = AnalyzeParagraphStructure(runs); + if (structure.FullText.Length == 0) + return false; + + int matchEnd = matchIndex + oldValue.Length; + var nodesToReplace = FindNodesToReplace(structure, matchIndex, matchEnd); + if (nodesToReplace.Count == 0) + return false; + + ExecuteReplacement(nodesToReplace, matchIndex, matchEnd, newValue, breakPage); + return true; + } + + internal static void Replace(this Paragraph paragraph, IEnumerable> replacements, StringComparison comparisonType) + { + if (paragraph is null || replacements is null || replacements.Count() == 0) + return; + + var runs = paragraph.Elements().ToList(); + if (runs.Count == 0) + return; + + var structure = AnalyzeParagraphStructure(runs); + if (structure.FullText.Length == 0) + return; + + // Используем List с предопределенной емкостью + var replacementsInParagraph = new List(replacements.Count() * 2); + + // Сначала находим все вхождения + var fullText = structure.FullText; + foreach (var kvp in replacements) + { + if (string.IsNullOrEmpty(kvp.Key)) + continue; + + int pos = 0; + while ((pos = fullText.IndexOf(kvp.Key, pos, comparisonType)) != -1) + { + replacementsInParagraph.Add(new ReplacementInfo + { + OldValue = kvp.Key, + NewValue = kvp.Value ?? string.Empty, + Index = pos + }); + pos += kvp.Key.Length; + } + } + + if (replacementsInParagraph.Count == 0) + return; + + // Сортируем по убыванию позиции + replacementsInParagraph.Sort((x, y) => y.Index.CompareTo(x.Index)); + + // Выполняем замены + for (int i = 0; i < replacementsInParagraph.Count; i++) + { + var replacement = replacementsInParagraph[i]; + int matchIndex = replacement.Index; + int matchEnd = matchIndex + replacement.OldValue.Length; + + var nodesToReplace = FindNodesToReplace(structure, matchIndex, matchEnd); + if (nodesToReplace.Count > 0) + { + ExecuteReplacement(nodesToReplace, matchIndex, matchEnd, replacement.NewValue); + } + } + } + + internal static void Replace(this Paragraph paragraph, IEnumerable> replacements, StringComparison comparisonType) + { + if (paragraph is null || replacements is null || replacements.Count() == 0) + return; + + var runs = paragraph.Elements().ToList(); + if (runs.Count == 0) + return; + + var structure = AnalyzeParagraphStructure(runs); + if (structure.FullText.Length == 0) + return; + + // Используем List с предопределенной емкостью + var replacementsInParagraph = new List(replacements.Count() * 2); + + // Сначала находим все вхождения + var fullText = structure.FullText; + foreach (var kvp in replacements) + { + if (string.IsNullOrEmpty(kvp.Key)) + continue; + + int pos = 0; + while ((pos = fullText.IndexOf(kvp.Key, pos, comparisonType)) != -1) + { + replacementsInParagraph.Add(new ReplacementInfo + { + OldValue = kvp.Key, + NewValue = kvp.Value.Text ?? string.Empty, + BreakPage = kvp.Value.BreakPage, + Index = pos + }); + pos += kvp.Key.Length; + } + } + + if (replacementsInParagraph.Count == 0) + return; + + // Сортируем по убыванию позиции + replacementsInParagraph.Sort((x, y) => y.Index.CompareTo(x.Index)); + + // Выполняем замены + for (int i = 0; i < replacementsInParagraph.Count; i++) + { + var replacement = replacementsInParagraph[i]; + int matchIndex = replacement.Index; + int matchEnd = matchIndex + replacement.OldValue.Length; + + var nodesToReplace = FindNodesToReplace(structure, matchIndex, matchEnd); + if (nodesToReplace.Count > 0) + { + ExecuteReplacement(nodesToReplace, matchIndex, matchEnd, replacement.NewValue, replacement.BreakPage); + } + } + } + + private class ReplacementInfo + { + internal string OldValue { get; set; } = null!; + internal string NewValue { get; set; } = null!; + internal int Index { get; set; } + internal bool BreakPage { get; set; } + } + + private static ParagraphStructure AnalyzeParagraphStructure(IEnumerable runs) + { + var textNodesList = new List(32); + var sb = new StringBuilder(256); + int currentIndex = 0; + + foreach (var run in runs) + { + var texts = run.Elements(); + foreach (var text in texts) + { + var textValue = text.Text; + if (string.IsNullOrEmpty(textValue)) + continue; + + textNodesList.Add(new TextNodeInfo( + text, + currentIndex, + textValue.Length + )); + + sb.Append(textValue); + currentIndex += textValue.Length; + } + } + + return new ParagraphStructure(sb.ToString(), [.. textNodesList]); + } + + private static List FindNodesToReplace( + ParagraphStructure structure, + int matchStart, + int matchEnd) + { + var result = new List(4); + int firstNodeIndex = structure.FindFirstNodeIndexAtPosition(matchStart); + + if (firstNodeIndex == -1) + return result; + + var textNodes = structure.TextNodes; + for (int i = firstNodeIndex; i < textNodes.Length; i++) + { + var node = textNodes[i]; + if (node.StartIndex >= matchEnd) + break; + + if (node.StartIndex + node.Length > matchStart) + { + result.Add(node); + } + } + + return result; + } + + private static void ExecuteReplacement( + List nodesToReplace, + int matchStart, + int matchEnd, + string newValue, + bool breakPage = false) + { + if (nodesToReplace.Count == 0) return; + + var firstNode = nodesToReplace[0]; + string oldText = firstNode.Text.Text ?? string.Empty; + int startInFirstNode = matchStart - firstNode.StartIndex; + int charsToReplaceInFirstNode = Math.Min( + oldText.Length - startInFirstNode, + matchEnd - matchStart + ); + + string processedNewValue = ReplaceSpacesWithNonBreaking(newValue); + firstNode.Text.Text = ReplaceSubstringOptimized( + oldText, + startInFirstNode, + charsToReplaceInFirstNode, + processedNewValue + ); + + // Очищаем остальные текстовые ноды + for (int i = 1; i < nodesToReplace.Count; i++) + { + nodesToReplace[i].Text.Text = string.Empty; + } + + if (breakPage) + { + if (nodesToReplace[0].Text.Parent is Run run && run.Parent is Paragraph para) + { + var breakRun = new Run(new Break() { Type = BreakValues.Page }); + if (run.RunProperties is not null) + breakRun.RunProperties = (RunProperties)run.RunProperties.CloneNode(true); + para.AppendChild(breakRun); + } + } + } + + private static unsafe string ReplaceSpacesWithNonBreaking(string input) + { + if (!input.Contains(' ')) + return input; + + fixed (char* pInput = input) + { + char* resultPtr = stackalloc char[input.Length]; + for (int i = 0; i < input.Length; i++) + { + resultPtr[i] = pInput[i] == ' ' ? '\u00A0' : pInput[i]; + } + return new string(resultPtr, 0, input.Length); + } + } + + private static string ReplaceSubstringOptimized(string original, int start, int length, string replacement) + { + if (string.IsNullOrEmpty(original)) + return replacement ?? string.Empty; + + if (start < 0 || start >= original.Length || length <= 0) + return original; + + if (start == 0 && length == original.Length) + return replacement; + + int end = Math.Min(start + length, original.Length); + + // Оптимизированная конкатенация + var sb = new StringBuilder(original.Length - length + replacement.Length); + if (start > 0) + { + sb.Append(original, 0, start); + } + sb.Append(replacement); + if (end < original.Length) + { + sb.Append(original, end, original.Length - end); + } + return sb.ToString(); + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/TableTextProcessor.cs b/QWERTYkez.WordProcessor/TableTextProcessor.cs new file mode 100644 index 0000000..8a07183 --- /dev/null +++ b/QWERTYkez.WordProcessor/TableTextProcessor.cs @@ -0,0 +1,307 @@ +namespace QWERTYkez.WordProcessor; + +/// +/// Обработчик текста в таблицах DOCX документов +/// +internal static class TableTextProcessor +{ + /// + /// Заменяет текст во всех таблицах указанного элемента + /// + internal static void ReplaceInTables(OpenXmlElement element, string oldValue, string newValue, StringComparison comparisonType) + { + if (element is null || string.IsNullOrEmpty(oldValue) || newValue is null) + return; + + var tables = GetAllTables(element).ToList(); + if (tables.Count == 0) + return; + +#if DEBUG + int totalTableMatches = 0; + int totalTableParagraphs = 0; + foreach (var table in tables) + { + var paragraphs = GetTableParagraphs(table).ToList(); + totalTableParagraphs += paragraphs.Count; + foreach (var para in paragraphs) + { + if (para?.InnerText?.IndexOf(oldValue, comparisonType) >= 0) + { + totalTableMatches++; + break; + } + } + } + + if (totalTableMatches > 0) + { + Debug.WriteLine($"[DEBUG] [TableTextProcessor.ReplaceInTables] Looking for '{oldValue}' in {tables.Count} tables"); + Debug.WriteLine($"[DEBUG] [TableTextProcessor.ReplaceInTables] Total paragraphs in tables: {totalTableParagraphs}"); + Debug.WriteLine($"[DEBUG] [TableTextProcessor.ReplaceInTables] Tables with matches: {totalTableMatches}"); + } +#endif + + foreach (var table in tables) + { + ReplaceInTable(table, oldValue, newValue, comparisonType); + } + } + + /// + /// Заменяет текст во всех таблицах указанного элемента + /// + internal static void ReplaceInTables(OpenXmlElement element, string oldValue, IEnumerable newValues, StringComparison comparisonType) + { + if (element is null || string.IsNullOrEmpty(oldValue) || newValues is null) + return; + + var tables = GetAllTables(element).ToList(); + if (tables.Count == 0) + return; + +#if DEBUG + int totalTableMatches = 0; + int totalTableParagraphs = 0; + foreach (var table in tables) + { + var paragraphs = GetTableParagraphs(table).ToList(); + totalTableParagraphs += paragraphs.Count; + foreach (var para in paragraphs) + { + if (para?.InnerText?.IndexOf(oldValue, comparisonType) >= 0) + { + totalTableMatches++; + break; + } + } + } + + if (totalTableMatches > 0) + { + Debug.WriteLine($"[DEBUG] [TableTextProcessor.ReplaceInTables] Looking for '{oldValue}' in {tables.Count} tables"); + Debug.WriteLine($"[DEBUG] [TableTextProcessor.ReplaceInTables] Total paragraphs in tables: {totalTableParagraphs}"); + Debug.WriteLine($"[DEBUG] [TableTextProcessor.ReplaceInTables] Tables with matches: {totalTableMatches}"); + } +#endif + + foreach (var table in tables) + { + ReplaceInTable(table, oldValue, newValues, comparisonType); + } + } + + /// + /// Выполняет словарную замену во всех таблицах + /// + internal static void ReplaceInTables(OpenXmlElement element, IEnumerable>> replacements, StringComparison comparisonType) + { + if (element is null || replacements is null || !replacements.Any()) + return; + + var tables = GetAllTables(element).ToList(); + if (tables.Count == 0) + return; + +#if DEBUG + int tablesWithMatches = 0; + foreach (var table in tables) + { + var paragraphs = GetTableParagraphs(table).ToList(); + foreach (var para in paragraphs) + { + var paraText = para.InnerText; + foreach (var kvp in replacements) + { + if (!string.IsNullOrEmpty(kvp.Key) && paraText.IndexOf(kvp.Key, comparisonType) >= 0) + { + tablesWithMatches++; + break; + } + } + if (tablesWithMatches > 0) break; + } + } + + if (tablesWithMatches > 0) + { + Debug.WriteLine($"[DEBUG] [TableTextProcessor.ReplaceInTables(dict)] START with {replacements.Count()} items"); + Debug.WriteLine($"[DEBUG] [TableTextProcessor.ReplaceInTables(dict)] Processing {tables.Count} tables, {tablesWithMatches} have matches"); + } +#endif + + foreach (var table in tables) + { + ReplaceInTable(table, replacements, comparisonType); + } + } + + /// + /// Заменяет текст в конкретной таблице + /// + private static void ReplaceInTable(Table table, string oldValue, string newValue, StringComparison comparisonType) + { + if (table is null) + return; + + var paragraphs = GetTableParagraphs(table).ToList(); + if (paragraphs.Count == 0) + return; + + foreach (var paragraph in paragraphs) + { + paragraph?.SimpleReplace(oldValue, newValue, comparisonType); + } + } + + /// + /// Заменяет текст в конкретной таблице + /// + private static void ReplaceInTable(Table table, string oldValue, IEnumerable newValues, StringComparison comparisonType) + { + if (table is null) + return; + + var paragraphs = GetTableParagraphs(table).ToList(); + if (paragraphs.Count == 0) + return; + + if (newValues.Count() == 1) + { + foreach (var paragraph in paragraphs) + { + paragraph?.SimpleReplace(oldValue, newValues.First(), comparisonType); + } + } + else + { + for (int i = paragraphs.Count - 1; i >= 0; i--) + { + var paragraph = paragraphs[i]; + if (paragraph is not null && paragraph.InnerText.IndexOf(oldValue, comparisonType) >= 0) + { + paragraph.ReplaceWithMultiple(oldValue, newValues, comparisonType); + } + } + } + } + + /// + /// Выполняет словарную замену в конкретной таблице + /// + private static void ReplaceInTable(Table table, IEnumerable>> replacements, StringComparison comparisonType) + { + if (table is null || replacements is null || !replacements.Any()) + return; + + var paragraphs = GetTableParagraphs(table).ToList(); + if (paragraphs.Count == 0) + return; + + for (int i = paragraphs.Count - 1; i >= 0; i--) + { + var paragraph = paragraphs[i]; + if (paragraph is null) + continue; + + var paraText = paragraph.InnerText; + bool hasMatch = false; + foreach (var kvp in replacements) + { + if (!string.IsNullOrEmpty(kvp.Key) && paraText.IndexOf(kvp.Key, comparisonType) >= 0) + { + hasMatch = true; + break; + } + } + + if (!hasMatch) + continue; + + var newParagraphs = MultiReplaceExt.ProcessParagraphWithAllReplacements(paragraph, replacements, comparisonType); + if (newParagraphs is not null && newParagraphs.Count > 0) + { + ReplaceParagraphsInTableCell(paragraph, newParagraphs); + } + } + } + + /// + /// Находит и возвращает все таблицы в указанном элементе + /// + internal static IEnumerable
GetAllTables(OpenXmlElement element) + { + if (element is null) + yield break; + + // Используем стек вместо очереди для рекурсивного поиска + var stack = new Stack(); + stack.Push(element); + + while (stack.Count > 0) + { + var current = stack.Pop(); + + if (current is Table table) + { + yield return table; + // Не ищем таблицы внутри таблиц (вложенные таблицы уже будут обработаны как дочерние элементы) + continue; + } + + // Добавляем дочерние элементы в обратном порядке для сохранения порядка + var children = current.ChildElements; + for (int i = children.Count - 1; i >= 0; i--) + { + stack.Push(children[i]); + } + } + } + + /// + /// Находит все параграфы внутри таблицы + /// + internal static IEnumerable GetTableParagraphs(Table table) + { + if (table is null) + yield break; + + // Используем обход в ширину для таблиц + var cells = new Queue(); + + foreach (var row in table.Elements()) + { + foreach (var cell in row.Elements()) + { + cells.Enqueue(cell); + } + } + + while (cells.Count > 0) + { + var cell = cells.Dequeue(); + + foreach (var paragraph in cell.Elements()) + { + yield return paragraph; + } + + // Ищем вложенные таблицы в ячейке + foreach (var nestedTable in cell.Elements
()) + { + foreach (var nestedPara in GetTableParagraphs(nestedTable)) + { + yield return nestedPara; + } + } + } + } + + /// + /// Заменяет параграф в ячейке таблицы на новые параграфы + /// + private static void ReplaceParagraphsInTableCell(Paragraph oldParagraph, List newParagraphs) + { + ParagraphReplacer.ReplaceParagraph(oldParagraph, newParagraphs); + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/WordProcessor.cs b/QWERTYkez.WordProcessor/WordProcessor.cs new file mode 100644 index 0000000..90977cd --- /dev/null +++ b/QWERTYkez.WordProcessor/WordProcessor.cs @@ -0,0 +1,321 @@ +namespace QWERTYkez.WordProcessor; + +/// +/// Статический класс для работы с документами Word через процессоры чтения/записи. +/// +public static class WordProcessor +{ + #region Read Operations + + /// + /// Пытается открыть документ только для чтения и выполнить действия. + /// + /// Путь к исходному файлу .docx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryRead(string sourcePath, Action read) + { + return TryRead(new FileInfo(sourcePath), read); + } + + /// + /// Пытается открыть документ только для чтения и выполнить действия. + /// + /// Объект исходного файла .docx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryRead(FileInfo sourceFile, Action read) + { + if (sourceFile is null || read is null) + { +#if DEBUG + Debug.WriteLine("[DEBUG] SourceFile or action is null"); +#endif + return false; + } + + using var processor = WordReader.CreateInternal(sourceFile); + if (processor is null) + return false; + + try + { + read(processor); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in read action: {ex.Message}"); +#endif + return false; + } + } + + #endregion + + #region Read Operations from Memory + + /// + /// Пытается открыть документ из массива байт только для чтения и выполнить действия. + /// + /// Массив байт, содержащий документ .docx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryRead(byte[] data, Action read) + => TryRead(new ReadOnlyMemory(data), read); + + /// + /// Пытается открыть документ из только для чтения и выполнить действия. + /// + /// Буфер с документом .docx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryRead(ReadOnlyMemory data, Action read) + { + if (data.IsEmpty || read is null) + { +#if DEBUG + Debug.WriteLine("[DEBUG] Data is empty or action is null"); +#endif + return false; + } + + using var processor = WordReader.CreateFromData(data); + if (processor is null) + return false; + + try + { + read(processor); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in read action from memory: {ex.Message}"); +#endif + return false; + } + } + + #endregion + + #region Write Operations + + /// + /// Пытается открыть документ для записи и выполнить действия с последующей перезаписью исходного файла. + /// + /// Путь к исходному файлу .docx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(string sourcePath, Action write) + { + return TryWrite(sourcePath, sourcePath, write); + } + + /// + /// Пытается открыть документ для записи и выполнить действия с последующей перезаписью исходного файла. + /// + /// Объект исходного файла .docx + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(FileInfo sourceFile, Action write) + { + return TryWrite(sourceFile, sourceFile.FullName, write); + } + + /// + /// Пытается открыть документ для записи, выполнить действия и сохранить результат по новому пути. + /// + /// Путь к исходному файлу .docx + /// Путь для сохранения результата (null - перезапись исходного файла) + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(string sourcePath, string? destinationPath, Action write) + { + return TryWrite(new FileInfo(sourcePath), destinationPath, write); + } + + /// + /// Пытается открыть документ для записи, выполнить действия и сохранить результат по новому пути. + /// + /// Объект исходного файла .docx + /// Путь для сохранения результата (null - перезапись исходного файла) + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(FileInfo sourceFile, string? destinationPath, Action write) + { + if (sourceFile is null || write is null) + { +#if DEBUG + Debug.WriteLine("[DEBUG] SourceFile or action is null"); +#endif + return false; + } + + using var processor = WordWriter.CreateInternal(sourceFile, destinationPath); + if (processor is null) + return false; + + try + { + write(processor); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in write action: {ex.Message}"); +#endif + return false; + } + } + + #endregion + + #region Write Operations from Memory + + /// + /// Пытается открыть документ из массива байт для записи, выполнить действия и сохранить результат по указанному пути. + /// + /// Массив байт, содержащий документ .docx + /// Путь для сохранения результата + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(byte[] data, string destinationPath, Action write) + => TryWrite(new ReadOnlyMemory(data), destinationPath, write); + + /// + /// Пытается открыть документ из для записи, выполнить действия и сохранить результат по указанному пути. + /// + /// Буфер с документом .docx + /// Путь для сохранения результата + /// Действия для выполнения над документом + /// если операция выполнена успешно, иначе + public static bool TryWrite(ReadOnlyMemory data, string destinationPath, Action write) + { + if (data.IsEmpty || write is null) + { +#if DEBUG + Debug.WriteLine("[DEBUG] Data is empty or write is null"); +#endif + return false; + } + + using var processor = WordWriter.CreateFromData(data, destinationPath); + if (processor is null) + return false; + + try + { + write(processor); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in write action from memory: {ex.Message}"); +#endif + return false; + } + } + + #endregion + + /// + /// Пытается создать новый документ Word, отредактировать его и вернуть результат в виде массива байт. + /// + /// Результирующий массив байт (если операция успешна). + /// Действия для заполнения документа. + /// если операция выполнена успешно, иначе . + public static bool TryCreate(out byte[] result, Action write) + { + result = null!; + if (write is null) return false; + + try + { + using var writer = WordWriter.CreateNew(); + write(writer); + result = writer.GetDocumentBytes(); + return true; + } + catch + { + return false; + } + } + + /// + /// Пытается создать новый документ Word, отредактировать его и записать в указанный поток. + /// + /// Поток, в который будет записан документ. + /// Действия для заполнения документа. + /// если операция выполнена успешно, иначе . + public static bool TryCreate(Stream outputStream, Action write) + { + if (write is null || outputStream is null) return false; + + try + { + using var writer = WordWriter.CreateNew(); + write(writer); + writer.SaveTo(outputStream); + return true; + } + catch + { + return false; + } + } + + /// + /// Пытается создать новый документ Word, отредактировать его и сохранить на диск. + /// + /// Путь для сохранения результата. + /// Действия для заполнения документа. + /// если операция выполнена успешно, иначе . + public static bool TryCreate(string destinationPath, Action write) + { + if (string.IsNullOrEmpty(destinationPath) || write is null) + return false; + + try + { + using var writer = WordWriter.CreateNew(destinationPath); + write(writer); + writer.Save(); + return true; + } + catch + { + return false; + } + } + + /// + /// Пытается создать новый документ Word, отредактировать его и прочитать результат в памяти. + /// + /// Действия для заполнения документа. + /// Действия для чтения полученного документа. + /// если операция выполнена успешно, иначе . + public static bool TryCreate(Action write, Action read) + { + if (write is null || read is null) + return false; + + try + { + using var writer = WordWriter.CreateNew(); // без привязки к файлу + write(writer); + using var reader = writer.ToReader(); // преобразуем в read-only + read(reader); + return true; + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/WordReader.cs b/QWERTYkez.WordProcessor/WordReader.cs new file mode 100644 index 0000000..9c9695e --- /dev/null +++ b/QWERTYkez.WordProcessor/WordReader.cs @@ -0,0 +1,285 @@ +namespace QWERTYkez.WordProcessor; + +/// +/// Предоставляет потокобезопасный процессор только для чтения документов Word (DOCX) формата. +/// Не поддерживает операции изменения документа. +/// +internal class WordReader : IDisposable, IWordReader +{ + protected MemoryStream _ms = null!; + protected WordprocessingDocument _doc = null!; + protected Body _body = null!; + protected bool _disposed; + protected readonly object _syncLock = new(); + protected string? _originalSourcePath; + + public Body Body => _body; + + internal WordReader() { } + + #region Factory Methods + + internal static WordReader? CreateInternal(FileInfo sourceFile) + { + if (sourceFile is null || !sourceFile.Exists) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Source file is null or does not exist: {sourceFile?.FullName}"); +#endif + return null; + } + + MemoryStream? ms = null; + WordprocessingDocument? doc = null; + + try + { + ms = new MemoryStream(); + using (var file = new FileStream(sourceFile.FullName, + FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + { + file.CopyTo(ms); + } + ms.Position = 0; + + doc = WordprocessingDocument.Open(ms, isEditable: false, + new OpenSettings { AutoSave = false }); + + if (doc.MainDocumentPart?.Document?.Body is not { } body) + { +#if DEBUG + Debug.WriteLine("[DEBUG] Document body is null or empty"); +#endif + doc.Dispose(); + ms.Dispose(); + return null; + } + + var processor = new WordReader + { + _ms = ms, + _doc = doc, + _body = body, + _originalSourcePath = sourceFile.FullName, + FilePath = sourceFile.FullName + }; + + return processor; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error creating read-only processor: {ex.GetType().Name}: {ex.Message}"); +#endif + doc?.Dispose(); + ms?.Dispose(); + return null; + } + } + + internal static WordReader? CreateFromData(ReadOnlyMemory data) + { + if (data.IsEmpty) + return null; + + var ms = new MemoryStream(); + try + { + ms.Write(data.ToArray(), 0, data.Length); + ms.Position = 0; + + var doc = WordprocessingDocument.Open(ms, false, new OpenSettings { AutoSave = false }); + if (doc.MainDocumentPart?.Document?.Body is not { } body) + { + doc.Dispose(); + ms.Dispose(); + return null; + } + + return new WordReader + { + _ms = ms, + _doc = doc, + _body = body, + FilePath = null // из памяти – нет файла + }; + } + catch + { + ms?.Dispose(); + return null; + } + } + + #endregion + + #region Writers + + public bool TryWrite(string destinationPath, Action action) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(destinationPath)) + throw new ArgumentException("Destination path cannot be null or empty", nameof(destinationPath)); + if (action is null) + throw new ArgumentNullException(nameof(action)); + + // Копируем данные из текущего потока + byte[] data; + lock (_syncLock) + { + _ms.Position = 0; + data = _ms.ToArray(); + } + + using var writable = WordWriter.CreateFromData(new ReadOnlyMemory(data), destinationPath); + if (writable is null) + return false; + + try + { + action(writable); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in TryWrite action: {ex.Message}"); +#endif + return false; + } + } + + public bool TryWrite(Action write, Action read) + { + ThrowIfDisposed(); + if (write is null) throw new ArgumentNullException(nameof(write)); + if (read is null) throw new ArgumentNullException(nameof(read)); + + // Копируем текущие данные + byte[] data; + lock (_syncLock) + { + _ms.Position = 0; + data = _ms.ToArray(); + } + + WordWriter? writable = null; + WordReader? resultReader = null; + try + { + // Создаём редактируемую копию (без привязки к файлу) + writable = WordWriter.CreateFromData(data); + if (writable is null) + return false; + + // Применяем изменения + write(writable); + + // Сохраняем изменения в поток и создаём read-only процессор + resultReader = writable.ToReader(); + + if (resultReader is null) + return false; + + // Работаем с изменённой копией + read(resultReader); + return true; + } + catch (Exception ex) + { +#if DEBUG + Debug.WriteLine($"[DEBUG] Error in TryWrite(write, read): {ex.Message}"); +#endif + return false; + } + finally + { + // Гарантированно освобождаем созданные процессоры + resultReader?.Dispose(); + writable?.Dispose(); + } + } + + #endregion + + #region Properties + + /// + /// Получает путь к исходному файлу. + /// + public string? FilePath { get; protected set; } + + /// + /// Получает значение, указывающее, находится ли процессор в допустимом состоянии для операций. + /// + public bool IsValid => !_disposed && _doc is not null && _body is not null && _ms is not null; + + /// + /// Получает текущий размер документа в байтах. + /// + public long DocumentSize => _ms.Length; + + #endregion + + #region Read Operations + + /// + /// Находит все уникальные плейсхолдеры в формате $...$ в документе. + /// Ищет только внутри параграфов. Игнорирует вхождения, которые пересекают границы параграфов. + /// + /// Способ сравнения строк при поиске (по умолчанию: без учета регистра) + /// Коллекция уникальных найденных плейсхолдеров + public ISet FindPlaceholders() + { + ThrowIfDisposed(); + + lock (_syncLock) + { + return PlaceholderFinder.FindInDocument(_doc, _body); + } + } + + #endregion + + #region Dispose Pattern + + protected void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(GetType().Name); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + + lock (_syncLock) + { + if (disposing) + { + _doc.Dispose(); + _ms.Dispose(); + } + + _doc = null!; + _body = null!; + _ms = null!; + _disposed = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + ~WordReader() + { + Dispose(disposing: false); + } + + #endregion +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/WordWriter.cs b/QWERTYkez.WordProcessor/WordWriter.cs new file mode 100644 index 0000000..6c17c50 --- /dev/null +++ b/QWERTYkez.WordProcessor/WordWriter.cs @@ -0,0 +1,626 @@ +using QWERTYkez.WordProcessor.Builders; + +namespace QWERTYkez.WordProcessor; + +/// +/// Предоставляет потокобезопасный процессор для чтения и записи документов Word (DOCX) формата. +/// Наследует от и добавляет операции изменения документа. +/// +internal sealed class WordWriter : WordReader, IWordWriter +{ + private bool _isModified = false; + + internal WordWriter() { } + + #region Factory Methods + + internal static WordWriter? CreateFromData(ReadOnlyMemory 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 = WordprocessingDocument.Open(ms, true, new OpenSettings { AutoSave = false }); + if (doc.MainDocumentPart?.Document?.Body is { } body) + { + return new WordWriter + { + _ms = ms, + _doc = doc, + _body = body, + FilePath = destinationPath, + _originalSourcePath = null // нет исходного файла + }; + } + + doc.Dispose(); + } + catch { } + + ms?.Dispose(); + return null; + } + + internal static new WordWriter? CreateFromData(ReadOnlyMemory data) + { + if (data.IsEmpty) + return null; + + var ms = new MemoryStream(); + try + { + ms.Write(data.ToArray(), 0, data.Length); + ms.Position = 0; + + var doc = WordprocessingDocument.Open(ms, true, new OpenSettings { AutoSave = false }); + if (doc.MainDocumentPart?.Document?.Body is { } body) + { + return new WordWriter + { + _ms = ms, + _doc = doc, + _body = body, + FilePath = null, // нет привязки к файлу + _originalSourcePath = null + }; + } + + doc.Dispose(); + } + catch { } + + ms.Dispose(); + return null; + } + + internal static WordWriter? 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 = WordprocessingDocument.Open(ms, isEditable: true, + new OpenSettings { AutoSave = false }); + + if (doc.MainDocumentPart?.Document?.Body is { } body) + { + var processor = new WordWriter + { + _ms = ms, + _doc = doc, + _body = body, + _originalSourcePath = sourceFile.FullName, + FilePath = destinationPath ?? sourceFile.FullName + }; + + return processor; + } + + doc?.Dispose(); + } + catch { } + + ms?.Dispose(); + return null; + } + + /// + /// Создаёт новый пустой документ Word. + /// + /// Путь, по которому будет сохранён документ (необязательный). + /// Экземпляр для редактирования нового документа. + internal static WordWriter CreateNew(string? destinationPath = null) + { + var ms = new MemoryStream(); + try + { + // Создаём документ, НЕ используем using + var doc = WordprocessingDocument.Create(ms, WordprocessingDocumentType.Document); + var mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(); + var body = new Body(); + mainPart.Document.AppendChild(body); + + var writer = new WordWriter + { + _ms = ms, + _doc = doc, + _body = body, + FilePath = destinationPath, + _originalSourcePath = null + }; + return writer; + } + catch + { + ms.Dispose(); + throw; + } + } + + #endregion + + #region Properties + + /// + /// Получает значение, указывающее, будет ли документ сохранен с перезаписью исходного файла. + /// + public bool WillOverwriteSource => FilePath == _originalSourcePath; + + #endregion + + #region Replace text + + public void ReplaceString(string oldValue, params string[] newValues) => ReplaceString(oldValue, (IEnumerable)newValues); + public void ReplaceString(string oldValue, IEnumerable newValues) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(oldValue)) + throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue)); + + if (newValues is null) return; + +#if DEBUG + Debug.WriteLine($"[DEBUG] [WordProcessor.Replace] START: '{oldValue}' -> [{string.Join(", ", newValues)}] (comparison: {StringComparison.OrdinalIgnoreCase})"); +#endif + + lock (_syncLock) + { + // 1. Основной текст + _body.Replace(oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + // 2. Таблицы в основном тексте + TableTextProcessor.ReplaceInTables(_body, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + // 3. Колонтитулы + HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + _isModified = true; + } + } + + public void ReplaceString(IDictionary replacements) => + /* */ReplaceString((IEnumerable>)replacements); + public void ReplaceString(IEnumerable> replacements) + { + ThrowIfDisposed(); + + if (replacements is null) return; + + lock (_syncLock) + { + // 1. Основной текст + _body.Replace(replacements, StringComparison.OrdinalIgnoreCase); + + // 2. Таблицы в основном тексте + foreach (var kvp in replacements) + { + if (!string.IsNullOrEmpty(kvp.Key)) + { + TableTextProcessor.ReplaceInTables(_body, kvp.Key, [kvp.Value ?? string.Empty], StringComparison.OrdinalIgnoreCase); + } + } + + HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, replacements, StringComparison.OrdinalIgnoreCase); + + _isModified = true; + } + } + + public void ReplaceString(IDictionary> replacements) => + /* */ReplaceString((IEnumerable>>)replacements); + public void ReplaceString(IEnumerable>> replacements) + { + ThrowIfDisposed(); + + if (replacements is null) return; + + lock (_syncLock) + { + // 1. Основной текст + _body.Replace(replacements, StringComparison.OrdinalIgnoreCase); + + // 2. Таблицы в основном тексте + TableTextProcessor.ReplaceInTables(_body, replacements, StringComparison.OrdinalIgnoreCase); + + // 3. Колонтитулы + HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, replacements, StringComparison.OrdinalIgnoreCase); + + _isModified = true; + } + } + + #endregion + + #region Replace ReplaceItem + public void ReplaceItem(string oldValue, params ReplaceItem[] newValues) => ReplaceItem(oldValue, (IEnumerable)newValues); + public void ReplaceItem(string oldValue, IEnumerable newValues) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(oldValue)) + throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue)); + + if (newValues is null) return; + +#if DEBUG + Debug.WriteLine($"[DEBUG] [WordProcessor.Replace] START: '{oldValue}' -> [{string.Join(", ", newValues)}] (comparison: {StringComparison.OrdinalIgnoreCase})"); +#endif + + lock (_syncLock) + { + // 1. Основной текст + _body.Replace(oldValue, newValues, StringComparison.OrdinalIgnoreCase); + + var texts = newValues.Select(val => val.Text).ToArray(); + + // 2. Таблицы в основном тексте + TableTextProcessor.ReplaceInTables(_body, oldValue, texts, StringComparison.OrdinalIgnoreCase); + + // 3. Колонтитулы + HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, oldValue, texts, StringComparison.OrdinalIgnoreCase); + + _isModified = true; + } + } + + public void ReplaceItem(IDictionary replacements) => + /* */ReplaceItem((IEnumerable>)replacements); + public void ReplaceItem(IEnumerable> replacements) + { + ThrowIfDisposed(); + + if (replacements is null) return; + + lock (_syncLock) + { + // 1. Основной текст + _body.Replace(replacements, StringComparison.OrdinalIgnoreCase); + + + var texts = replacements.ToDictionary(val => val.Key, val => val.Value.Text); + + // 2. Таблицы в основном тексте + foreach (var kvp in texts) + { + if (!string.IsNullOrEmpty(kvp.Key)) + { + TableTextProcessor.ReplaceInTables(_body, kvp.Key, kvp.Value, StringComparison.OrdinalIgnoreCase); + } + } + + // 3. Колонтитулы + HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, texts, StringComparison.OrdinalIgnoreCase); + + _isModified = true; + } + } + + public void ReplaceItem(IDictionary> replacements) => + /* */ReplaceItem((IEnumerable>>)replacements); + public void ReplaceItem(IEnumerable>> replacements) + { + ThrowIfDisposed(); + + if (replacements is null) return; + + lock (_syncLock) + { + // 1. Основной текст + _body.Replace(replacements, StringComparison.OrdinalIgnoreCase); + + + var texts = replacements.ToDictionary(val => val.Key, val => val.Value.Select(val => val.Text)); + + // 2. Таблицы в основном тексте + TableTextProcessor.ReplaceInTables(_body, texts, StringComparison.OrdinalIgnoreCase); + + // 3. Колонтитулы + HeaderFooterProcessor.ReplaceInHeadersFooters(_doc, texts, StringComparison.OrdinalIgnoreCase); + + _isModified = true; + } + } + + #endregion + + /// + /// Добавляет новый параграф с указанным текстом в конец документа. + /// + public void AddParagraph(string text, bool preserveFormatting = true) + { + ThrowIfDisposed(); + + if (_body is null) + return; + + lock (_syncLock) + { + var paragraph = new Paragraph(); + var run = new Run(); + + if (preserveFormatting && _body.Elements().FirstOrDefault() is { } firstPara) + { + if (firstPara.ParagraphProperties is not null) + { + paragraph.ParagraphProperties = firstPara.ParagraphProperties.CloneNode(true) as ParagraphProperties; + } + + if (firstPara.Elements().FirstOrDefault()?.RunProperties is not null) + { + run.RunProperties = firstPara.Elements().First().RunProperties?.CloneNode(true) as RunProperties; + } + } + + string processedText = text.Replace(' ', '\u00A0'); + run.AppendChild(new Text(processedText)); + paragraph.AppendChild(run); + _body.AppendChild(paragraph); + _isModified = true; + } + } + + #region Replace to table + + /// + /// Заменяет параграф, содержащий указанный текст, на таблицу, созданную с помощью TableBuilder + /// + /// Текст для поиска в параграфах + /// Действие для настройки таблицы через TableBuilder + public void ReplaceToTable(string oldValue, Action buildTable) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(oldValue)) + throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue)); + + if (buildTable is null) return; + +#if DEBUG + Debug.WriteLine($"[DEBUG] [WordProcessor.ReplaceToTable] Looking for '{oldValue}' to replace with custom table"); +#endif + + lock (_syncLock) + { + // Используем метод расширения для Body с TableBuilder + ReplaceToTableExt.ReplaceParagraphsContainingTextToTable(_body, oldValue, buildTable); + _isModified = true; + } + } + + #endregion + + #region Replace to text + + /// + /// Заменяет параграф, содержащий указанный текст, на таблицу, созданную с помощью TextBuilder + /// + /// Текст для поиска в параграфах + /// Действие для настройки таблицы через TextBuilder + public void ReplaceToText(string oldValue, Action buildText) + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(oldValue)) + throw new ArgumentException("Old value cannot be null or empty", nameof(oldValue)); + + if (buildText is null) return; + +#if DEBUG + Debug.WriteLine($"[DEBUG] [WordProcessor.ReplaceToText] Looking for '{oldValue}' to replace with custom text"); +#endif + + lock (_syncLock) + { + // Используем метод расширения для Body с TextBuilder + ReplaceToTextExt.ReplaceParagraphsContainingTextToText(_body, oldValue, buildText); + _isModified = true; + } + } + + #endregion + + #region Save Operations + + /// + /// Сохраняет документ в файл, указанный при создании процессора. + /// + public void Save() + { + ThrowIfDisposed(); + + if (string.IsNullOrEmpty(FilePath)) + throw new InvalidOperationException("Cannot save - no file path specified"); + + SaveTo(FilePath!); + } + + /// + /// Сохраняет документ в указанный файл. + /// + 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) + { + _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) + { + _doc.Save(); + _ms.Position = 0; + return _ms.ToArray(); + } + } + + public void SaveTo(Stream outputStream) + { + lock (_syncLock) + { + _doc.Save(); + _ms.Position = 0; + _ms.CopyTo(outputStream); + } + } + + /// + /// Пытается сохранить документ в указанный файл. + /// + 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; + } + } + + /// + /// Асинхронно сохраняет документ в указанный файл. + /// + 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) + { + _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); + } + + /// Создаёт read-only процессор из текущего документа. + internal WordReader ToReader() + { + lock (_syncLock) + { + _doc.Save(); + _ms.Position = 0; + var data = _ms.ToArray(); + return WordReader.CreateFromData(data) ?? throw new InvalidOperationException("Failed to create reader"); + } + } + + #endregion + + #region Dispose Pattern + + protected override void Dispose(bool disposing) + { + if (_disposed) + return; + + lock (_syncLock) + { + if (disposing) + { + if (_isModified && !string.IsNullOrEmpty(FilePath)) + { + try + { + _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 +} \ No newline at end of file diff --git a/QWERTYkez.WordProcessor/globals.cs b/QWERTYkez.WordProcessor/globals.cs new file mode 100644 index 0000000..f5f56eb --- /dev/null +++ b/QWERTYkez.WordProcessor/globals.cs @@ -0,0 +1,11 @@ +global using DocumentFormat.OpenXml; +global using DocumentFormat.OpenXml.Packaging; +global using DocumentFormat.OpenXml.Wordprocessing; +global using System; +global using System.Collections.Generic; +global using System.Diagnostics; +global using System.IO; +global using System.Linq; +global using System.Text; +global using System.Threading; +global using System.Threading.Tasks; \ No newline at end of file