Files
radzen-blazor/Radzen.Blazor/Documents/Spreadsheet/Format.cs
Atanas Korchev 5ce7e12e6b Fix Format.Merge silently dropping explicit Left/Top alignments
Format.Merge treated TextAlign != Left and VerticalAlign != Top as
'has value to merge'. So an overlay format that explicitly set
TextAlign.Left or VerticalAlign.Top was silently ignored.

Make TextAlign and VerticalAlign nullable enums. Null means 'unset',
any value (including Left/Top) overrides during merge. Xlsx round-trip
preserves the unset state instead of normalizing to Left/Top.

Also drives Format.Clone/IsDefault/Merge from a property-descriptor
table so adding a new format property requires one entry, not edits
in four parallel lists. Includes a CopyValue hook for BorderStyle
properties to preserve deep-clone semantics.

Also fixes the XlsxWriter float-equality comparison on column widths
and row heights (Math.Abs(a - b) > 1e-6).
2026-06-15 18:45:16 +03:00

532 lines
15 KiB
C#

using Radzen.Blazor.Rendering;
using System;
using System.Linq;
namespace Radzen.Documents.Spreadsheet;
#nullable enable
internal static class FormatColorExtensions
{
public static string ToXLSXColor(this string color)
{
ArgumentNullException.ThrowIfNull(color);
var parsed = RGB.Parse(color) ?? throw new ArgumentException($"Invalid color value: {color}", nameof(color));
return $"FF{parsed.ToHex()}";
}
}
/// <summary>
/// Represents the line style for a cell border.
/// </summary>
public enum BorderLineStyle
{
/// <summary>No border.</summary>
None,
/// <summary>Thin border.</summary>
Thin,
/// <summary>Medium border.</summary>
Medium,
/// <summary>Thick border.</summary>
Thick,
/// <summary>Dashed border.</summary>
Dashed,
/// <summary>Dotted border.</summary>
Dotted,
/// <summary>Double border.</summary>
Double
}
/// <summary>
/// Represents a border style with color and line style.
/// </summary>
public class BorderStyle
{
/// <summary>
/// Gets or sets the border color.
/// </summary>
public string Color { get; set; } = "#000000";
/// <summary>
/// Gets or sets the border line style.
/// </summary>
public BorderLineStyle LineStyle { get; set; } = BorderLineStyle.Thin;
/// <summary>
/// Creates a copy of this border style.
/// </summary>
public BorderStyle Clone() => new() { Color = Color, LineStyle = LineStyle };
internal string ToXlsxStyle() => LineStyle switch
{
BorderLineStyle.Thin => "thin",
BorderLineStyle.Medium => "medium",
BorderLineStyle.Thick => "thick",
BorderLineStyle.Dashed => "dashed",
BorderLineStyle.Dotted => "dotted",
BorderLineStyle.Double => "double",
_ => "thin"
};
internal static BorderLineStyle FromXlsxStyle(string? style) => style switch
{
"thin" => BorderLineStyle.Thin,
"medium" => BorderLineStyle.Medium,
"thick" => BorderLineStyle.Thick,
"dashed" => BorderLineStyle.Dashed,
"dotted" => BorderLineStyle.Dotted,
"double" => BorderLineStyle.Double,
"hair" => BorderLineStyle.Dotted,
"mediumDashed" => BorderLineStyle.Dashed,
_ => BorderLineStyle.None
};
}
/// <summary>
/// Represents a format that can be applied to cells in a spreadsheet.
/// </summary>
public class Format
{
private string? color;
private string? background;
private string? numberFormat;
private string? fontFamily;
private double? fontSize;
/// <summary>
/// Gets or sets whether the text in the format should be bold.
/// </summary>
public bool Bold { get; set; }
/// <summary>
/// Gets or sets whether the text in the format should be italicized.
/// </summary>
public bool Italic { get; set; }
/// <summary>
/// Gets or sets whether the text in the format should be underlined.
/// </summary>
public bool Underline { get; set; }
/// <summary>
/// Gets or sets whether the text in the format should have a strikethrough.
/// </summary>
public bool Strikethrough { get; set; }
/// <summary>
/// Gets or sets whether text should wrap within the cell.
/// </summary>
public bool WrapText { get; set; }
/// <summary>
/// Gets or sets whether the cell is locked when sheet protection is active.
/// Null means use the default (locked). Only takes effect when sheet protection is enabled.
/// </summary>
public bool? Locked { get; set; }
/// <summary>
/// Gets or sets whether the cell's formula is hidden in the formula bar when sheet protection is active.
/// Null means use the default (not hidden). Only takes effect when sheet protection is enabled.
/// </summary>
public bool? FormulaHidden { get; set; }
/// <summary>
/// Gets the effective locked state (true if Locked is null, since XLSX defaults to locked).
/// </summary>
public bool IsLocked => Locked ?? true;
/// <summary>
/// Gets the effective formula hidden state (false if FormulaHidden is null).
/// </summary>
public bool IsFormulaHidden => FormulaHidden ?? false;
/// <summary>
/// Gets or sets the text alignment in the format. Null means unset (default left).
/// </summary>
public TextAlign? TextAlign { get; set; }
/// <summary>
/// Gets or sets the vertical alignment in the format. Null means unset (default top).
/// </summary>
public VerticalAlign? VerticalAlign { get; set; }
/// <summary>
/// Gets or sets the font family.
/// </summary>
public string? FontFamily
{
get => fontFamily;
set
{
if (fontFamily != value)
{
fontFamily = value;
Changed?.Invoke();
}
}
}
/// <summary>
/// Gets or sets the font size in points.
/// </summary>
public double? FontSize
{
get => fontSize;
set
{
if (fontSize != value)
{
fontSize = value;
Changed?.Invoke();
}
}
}
/// <summary>
/// Gets or sets the top border style.
/// </summary>
public BorderStyle? BorderTop { get; set; }
/// <summary>
/// Gets or sets the right border style.
/// </summary>
public BorderStyle? BorderRight { get; set; }
/// <summary>
/// Gets or sets the bottom border style.
/// </summary>
public BorderStyle? BorderBottom { get; set; }
/// <summary>
/// Gets or sets the left border style.
/// </summary>
public BorderStyle? BorderLeft { get; set; }
/// <summary>
/// Gets or sets the color of the text in the format.
/// </summary>
public string? Color
{
get => color;
set
{
if (color != value)
{
color = value;
Changed?.Invoke();
}
}
}
/// <summary>
/// Gets or sets the background color of the format.
/// </summary>
public string? BackgroundColor
{
get => background;
set
{
if (background != value)
{
background = value;
Changed?.Invoke();
}
}
}
/// <summary>
/// Gets or sets the number format code (e.g. "#,##0.00", "0%", "yyyy-mm-dd").
/// </summary>
public string? NumberFormat
{
get => numberFormat;
set
{
if (numberFormat != value)
{
numberFormat = value;
Changed?.Invoke();
}
}
}
internal event Action? Changed;
private sealed record FormatPropertyDescriptor(
string Name,
Func<Format, object?> Getter,
Action<Format, object?> Setter,
Func<Format, bool> IsSet,
Func<object?, object?>? CopyValue = null)
{
public object? Copy(object? value) => CopyValue is null ? value : CopyValue(value);
}
private static readonly FormatPropertyDescriptor[] Properties =
[
new("Bold",
f => f.Bold,
(f, v) => f.Bold = (bool)v!,
f => f.Bold),
new("Italic",
f => f.Italic,
(f, v) => f.Italic = (bool)v!,
f => f.Italic),
new("Underline",
f => f.Underline,
(f, v) => f.Underline = (bool)v!,
f => f.Underline),
new("Strikethrough",
f => f.Strikethrough,
(f, v) => f.Strikethrough = (bool)v!,
f => f.Strikethrough),
new("WrapText",
f => f.WrapText,
(f, v) => f.WrapText = (bool)v!,
f => f.WrapText),
new("Locked",
f => f.Locked,
(f, v) => f.Locked = (bool?)v,
f => f.Locked is not null),
new("FormulaHidden",
f => f.FormulaHidden,
(f, v) => f.FormulaHidden = (bool?)v,
f => f.FormulaHidden is not null),
new("TextAlign",
f => f.TextAlign,
(f, v) => f.TextAlign = (TextAlign?)v,
f => f.TextAlign is not null),
new("VerticalAlign",
f => f.VerticalAlign,
(f, v) => f.VerticalAlign = (VerticalAlign?)v,
f => f.VerticalAlign is not null),
new("FontFamily",
f => f.FontFamily,
(f, v) => f.FontFamily = (string?)v,
f => f.FontFamily is not null),
new("FontSize",
f => f.FontSize,
(f, v) => f.FontSize = (double?)v,
f => f.FontSize is not null),
new("Color",
f => f.Color,
(f, v) => f.Color = (string?)v,
f => f.Color is not null),
new("BackgroundColor",
f => f.BackgroundColor,
(f, v) => f.BackgroundColor = (string?)v,
f => f.BackgroundColor is not null),
// NumberFormat uses string.IsNullOrEmpty for IsSet to preserve original IsDefault semantics
// (empty string was considered default even though the setter fires Changed for null -> "").
new("NumberFormat",
f => f.NumberFormat,
(f, v) => f.NumberFormat = (string?)v,
f => !string.IsNullOrEmpty(f.NumberFormat)),
new("BorderTop",
f => f.BorderTop,
(f, v) => f.BorderTop = (BorderStyle?)v,
f => f.BorderTop is not null,
v => ((BorderStyle?)v)?.Clone()),
new("BorderRight",
f => f.BorderRight,
(f, v) => f.BorderRight = (BorderStyle?)v,
f => f.BorderRight is not null,
v => ((BorderStyle?)v)?.Clone()),
new("BorderBottom",
f => f.BorderBottom,
(f, v) => f.BorderBottom = (BorderStyle?)v,
f => f.BorderBottom is not null,
v => ((BorderStyle?)v)?.Clone()),
new("BorderLeft",
f => f.BorderLeft,
(f, v) => f.BorderLeft = (BorderStyle?)v,
f => f.BorderLeft is not null,
v => ((BorderStyle?)v)?.Clone()),
];
/// <summary>
/// Gets a value indicating whether all format properties are at their default values.
/// </summary>
public bool IsDefault => Properties.All(p => !p.IsSet(this));
internal Format Merge(Format format)
{
ArgumentNullException.ThrowIfNull(format);
var merged = Clone();
foreach (var p in Properties)
{
if (p.IsSet(format))
{
p.Setter(merged, p.Copy(p.Getter(format)));
}
}
return merged;
}
/// <summary>
/// This method is used to create a copy of the current format with a new color.
/// </summary>
public Format WithColor(string? color)
{
var clone = Clone();
clone.Color = color;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with a new background color.
/// </summary>
public Format WithBackgroundColor(string? backgroundColor)
{
var clone = Clone();
clone.BackgroundColor = backgroundColor;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with a new bold setting.
/// </summary>
public Format WithBold(bool bold)
{
var clone = Clone();
clone.Bold = bold;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with a new italic setting.
/// </summary>
public Format WithItalic(bool italic)
{
var clone = Clone();
clone.Italic = italic;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with a new underline setting.
/// </summary>
public Format WithUnderline(bool underline)
{
var clone = Clone();
clone.Underline = underline;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with a new text alignment setting.
/// </summary>
public Format WithTextAlign(TextAlign? textAlign)
{
var clone = Clone();
clone.TextAlign = textAlign;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with a new vertical alignment setting.
/// </summary>
public Format WithVerticalAlign(VerticalAlign? verticalAlign)
{
var clone = Clone();
clone.VerticalAlign = verticalAlign;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with a new number format code.
/// </summary>
public Format WithNumberFormat(string? numberFormat)
{
var clone = Clone();
clone.NumberFormat = numberFormat;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with a new strikethrough setting.
/// </summary>
public Format WithStrikethrough(bool strikethrough)
{
var clone = Clone();
clone.Strikethrough = strikethrough;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with a new font family.
/// </summary>
public Format WithFontFamily(string? fontFamily)
{
var clone = Clone();
clone.FontFamily = fontFamily;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with a new font size.
/// </summary>
public Format WithFontSize(double? fontSize)
{
var clone = Clone();
clone.FontSize = fontSize;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with a new wrap text setting.
/// </summary>
public Format WithWrapText(bool wrapText)
{
var clone = Clone();
clone.WrapText = wrapText;
return clone;
}
/// <summary>
/// This method is used to create a copy of the current format with new border styles.
/// </summary>
public Format WithBorders(BorderStyle? top, BorderStyle? right, BorderStyle? bottom, BorderStyle? left)
{
var clone = Clone();
clone.BorderTop = top?.Clone();
clone.BorderRight = right?.Clone();
clone.BorderBottom = bottom?.Clone();
clone.BorderLeft = left?.Clone();
return clone;
}
/// <summary>
/// Creates a new instance of the Format class that is a copy of the current instance.
/// </summary>
public Format Clone()
{
var clone = new Format();
foreach (var p in Properties)
{
p.Setter(clone, p.Copy(p.Getter(this)));
}
return clone;
}
/// <summary>
/// Creates a copy of the current format with a new locked setting.
/// </summary>
public Format WithLocked(bool? locked)
{
var clone = Clone();
clone.Locked = locked;
return clone;
}
/// <summary>
/// Creates a copy of the current format with a new formula hidden setting.
/// </summary>
public Format WithFormulaHidden(bool? formulaHidden)
{
var clone = Clone();
clone.FormulaHidden = formulaHidden;
return clone;
}
}