diff --git a/ClosedXML.Tests/Excel/RichText/XLImmutableRichTextTests.cs b/ClosedXML.Tests/Excel/RichText/XLImmutableRichTextTests.cs new file mode 100644 index 000000000..c0a4679db --- /dev/null +++ b/ClosedXML.Tests/Excel/RichText/XLImmutableRichTextTests.cs @@ -0,0 +1,47 @@ +using System.Linq; +using ClosedXML.Excel; +using NUnit.Framework; + +namespace ClosedXML.Tests.Excel.RichText +{ + [TestFixture] + public class XLImmutableRichTextTests + { + [Test] + public void Equals_compares_text_runs_phonetic_runs_and_properties() + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet(); + var richText = (XLRichText)ws.Cell("A1").CreateRichText(); + richText + .AddText("こんにち").SetBold(true) // Hello in hiragana + .AddText("は,").SetBold(false) // object marker + .AddText("世界").SetFontSize(15); // world in kanji + richText.Phonetics + .SetAlignment(XLPhoneticAlignment.Distributed) + .Add(@"konnichi wa", 0, 6); // world in hiragana + + // Assert equal + var immutableRichText = new XLImmutableRichText(richText); + var equalImmutableRichText = new XLImmutableRichText(richText); + Assert.AreEqual(immutableRichText, equalImmutableRichText); + + // Different font of a first run + richText.ElementAt(0).SetBold(false); + var withDifferentTextRunFont = new XLImmutableRichText(richText); + Assert.AreNotEqual(immutableRichText, withDifferentTextRunFont); + richText.ElementAt(0).SetBold(true); + + // Different phonetic properties + richText.Phonetics.SetAlignment(XLPhoneticAlignment.Left); + var withDifferentPhoneticsProps = new XLImmutableRichText(richText); + Assert.AreNotEqual(immutableRichText, withDifferentPhoneticsProps); + richText.Phonetics.SetAlignment(XLPhoneticAlignment.Distributed); + + // Different phonetic runs + richText.Phonetics.Add("せかい", 6, 8); + var withDifferentTextPhonetics = new XLImmutableRichText(richText); + Assert.AreNotEqual(immutableRichText, withDifferentTextPhonetics); + } + } +} diff --git a/ClosedXML.sln.DotSettings b/ClosedXML.sln.DotSettings index e64c6c3a4..bdcc36582 100644 --- a/ClosedXML.sln.DotSettings +++ b/ClosedXML.sln.DotSettings @@ -23,6 +23,7 @@ True True True + True True True True diff --git a/ClosedXML/Excel/Cells/ValueSlice.cs b/ClosedXML/Excel/Cells/ValueSlice.cs index 369753e0f..35054e9cc 100644 --- a/ClosedXML/Excel/Cells/ValueSlice.cs +++ b/ClosedXML/Excel/Cells/ValueSlice.cs @@ -140,15 +140,15 @@ internal void SetCellValue(XLSheetPoint point, XLCellValue cellValue) _values.Set(point, in modified); } - internal XLRichText GetRichText(XLSheetPoint point) + internal XLImmutableRichText? GetRichText(XLSheetPoint point) { return _values[point].RichText; } - internal void SetRichText(XLSheetPoint point, XLRichText richText) + internal void SetRichText(XLSheetPoint point, XLImmutableRichText? richText) { ref readonly var original = ref _values[point]; - if (original.RichText != richText) + if (!ReferenceEquals(original.RichText, richText)) { var modified = new XLValueSliceContent(original.Value, original.Type, original.ModifiedAtVersion, original.SharedStringId, richText); _values.Set(point, in modified); @@ -220,9 +220,9 @@ private void DereferenceTextInRange(XLSheetRange range) internal readonly XLDataType Type; internal readonly long ModifiedAtVersion; internal readonly int SharedStringId; - internal readonly XLRichText RichText; + internal readonly XLImmutableRichText? RichText; - internal XLValueSliceContent(double value, XLDataType type, long modifiedAtVersion, int sharedStringId, XLRichText richText) + internal XLValueSliceContent(double value, XLDataType type, long modifiedAtVersion, int sharedStringId, XLImmutableRichText? richText) { Value = value; Type = type; diff --git a/ClosedXML/Excel/Cells/XLCell.cs b/ClosedXML/Excel/Cells/XLCell.cs index c5cf48371..cc8c766d8 100644 --- a/ClosedXML/Excel/Cells/XLCell.cs +++ b/ClosedXML/Excel/Cells/XLCell.cs @@ -138,7 +138,7 @@ private XLCellValue SliceCellValue set => _cellsCollection.ValueSlice.SetCellValue(SheetPoint, value); } - private XLRichText SliceRichText + private XLImmutableRichText SliceRichText { get => _cellsCollection.ValueSlice.GetRichText(SheetPoint); set => _cellsCollection.ValueSlice.SetRichText(SheetPoint, value); @@ -257,17 +257,24 @@ internal XLComment CreateComment(int? shapeId = null) public XLRichText GetRichText() { - return SliceRichText ?? CreateRichText(); + var sliceRichText = SliceRichText; + if (sliceRichText is not null) + return new XLRichText(this, sliceRichText); + + return CreateRichText(); } public XLRichText CreateRichText() { - var style = GetStyleForRead(); - SliceRichText = Value.Type == XLDataType.Blank - ? new XLRichText(this, new XLFont(Style as XLStyle, style.Font)) - : new XLRichText(this, GetFormattedString(), new XLFont(Style as XLStyle, style.Font)); - SliceCellValue = SliceRichText.Text; - return SliceRichText; + var font = new XLFont(GetStyleForRead().Font.Key); + + // Don't include rich text string with 0 length to a new rich text + var richText = DataType == XLDataType.Blank + ? new XLRichText(this, font) + : new XLRichText(this, GetFormattedString(), font); + SliceRichText = new XLImmutableRichText(richText); + SliceCellValue = richText.Text; + return richText; } #region IXLCell Members @@ -1079,10 +1086,7 @@ public bool NeedsRecalculation IXLRichText IXLCell.GetRichText() => GetRichText(); - public bool HasRichText - { - get { return SliceRichText != null; } - } + public bool HasRichText => SliceRichText is not null; IXLRichText IXLCell.CreateRichText() => CreateRichText(); @@ -1121,7 +1125,7 @@ public Boolean IsEmpty(XLCellsUsedOptions options) { bool isValueEmpty; if (HasRichText) - isValueEmpty = SliceRichText.Length == 0; + isValueEmpty = SliceRichText.Text.Length == 0; else { isValueEmpty = SliceCellValue.Type switch @@ -1587,7 +1591,7 @@ internal void CopyValuesFrom(XLCell source) { SliceCellValue = source.SliceCellValue; FormulaR1C1 = source.FormulaR1C1; - SliceRichText = source.SliceRichText == null ? null : new XLRichText(this, source.SliceRichText, source.Style.Font); + SliceRichText = source.SliceRichText; SliceComment = source.SliceComment == null ? null : new XLComment(this, source.SliceComment, source.Style.Font, source.SliceComment.Style); if (source.SliceHyperlink != null) { @@ -2246,10 +2250,11 @@ internal void GetGlyphBoxes(IXLGraphicEngine engine, Dpi dpi, List out var richText = SliceRichText; if (richText is not null) { - foreach (var richString in richText) + foreach (var richTextRun in richText.Runs) { - IXLFontBase font = richString; - AddGlyphs(richString.Text, font, engine, dpi, output); + var text = richText.GetRunText(richTextRun); + var font = new XLFont(richTextRun.Font.Key); + AddGlyphs(text, font, engine, dpi, output); } } else diff --git a/ClosedXML/Excel/Cells/XLCellsCollection.cs b/ClosedXML/Excel/Cells/XLCellsCollection.cs index 29f19c77e..841e422ed 100644 --- a/ClosedXML/Excel/Cells/XLCellsCollection.cs +++ b/ClosedXML/Excel/Cells/XLCellsCollection.cs @@ -70,7 +70,7 @@ internal HashSet RowsUsedKeys internal Slice FormulaSlice { get; } = new(); - internal Slice StyleSlice { get; } = new(); + internal Slice StyleSlice { get; } = new(); internal Slice MiscSlice { get; } = new(); diff --git a/ClosedXML/Excel/Cells/XLMiscSliceContent.cs b/ClosedXML/Excel/Cells/XLMiscSliceContent.cs index 4d6a80041..41380e0b6 100644 --- a/ClosedXML/Excel/Cells/XLMiscSliceContent.cs +++ b/ClosedXML/Excel/Cells/XLMiscSliceContent.cs @@ -11,9 +11,9 @@ internal bool ShareString set => _inlineString = !value; } - internal XLComment Comment { get; set; } + internal XLComment? Comment { get; set; } - internal XLHyperlink Hyperlink { get; set; } + internal XLHyperlink? Hyperlink { get; set; } internal uint? CellMetaIndex { get; set; } diff --git a/ClosedXML/Excel/PageSetup/XLHFItem.cs b/ClosedXML/Excel/PageSetup/XLHFItem.cs index 87eee1a51..b8bf4a286 100644 --- a/ClosedXML/Excel/PageSetup/XLHFItem.cs +++ b/ClosedXML/Excel/PageSetup/XLHFItem.cs @@ -42,7 +42,7 @@ public IXLRichString AddText(XLHFPredefinedText predefinedText) public IXLRichString AddText(String text, XLHFOccurrence occurrence) { - XLRichString richText = new XLRichString(text, this.HeaderFooter.Worksheet.Style.Font, this); + var richText = new XLRichString(text, HeaderFooter.Worksheet.Style.Font, this, null); var hfText = new XLHFText(richText, this); if (occurrence == XLHFOccurrence.AllPages) diff --git a/ClosedXML/Excel/RichText/IXLFormattedText.cs b/ClosedXML/Excel/RichText/IXLFormattedText.cs index 085210b27..f64edfc6a 100644 --- a/ClosedXML/Excel/RichText/IXLFormattedText.cs +++ b/ClosedXML/Excel/RichText/IXLFormattedText.cs @@ -36,17 +36,36 @@ public interface IXLFormattedText : IEnumerable, IEquatable Substring(Int32 index, Int32 length); /// - /// Copy the text and formatting from the original text. + /// Replace the text and formatting of this text by texts and formatting from the text. /// /// Original to copy from. /// This text. IXLFormattedText CopyFrom(IXLFormattedText original); + /// + /// How many rich strings is the formatted text composed of. + /// Int32 Count { get; } + + /// + /// Length of the whole formatted text. + /// Int32 Length { get; } + /// + /// Get text of the whole formatted text. + /// String Text { get; } - IXLPhonetics Phonetics { get; } + + /// + /// Does this text has phonetics? Unlike accessing the property, this method + /// doesn't create a new instance on access. + /// Boolean HasPhonetics { get; } + + /// + /// Get or create phonetics for the text. Use to check for existence to avoid unnecessary creation. + /// + IXLPhonetics Phonetics { get; } } } diff --git a/ClosedXML/Excel/RichText/IXLPhonetic.cs b/ClosedXML/Excel/RichText/IXLPhonetic.cs index 118147ebf..a7559a534 100644 --- a/ClosedXML/Excel/RichText/IXLPhonetic.cs +++ b/ClosedXML/Excel/RichText/IXLPhonetic.cs @@ -1,13 +1,11 @@ -#nullable disable - using System; namespace ClosedXML.Excel { public interface IXLPhonetic: IEquatable { - String Text { get; set; } - Int32 Start { get; set; } - Int32 End { get; set; } + String Text { get; } + Int32 Start { get; } + Int32 End { get; } } } diff --git a/ClosedXML/Excel/RichText/IXLPhonetics.cs b/ClosedXML/Excel/RichText/IXLPhonetics.cs index 3f648063b..5f2ee0816 100644 --- a/ClosedXML/Excel/RichText/IXLPhonetics.cs +++ b/ClosedXML/Excel/RichText/IXLPhonetics.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; @@ -20,10 +18,29 @@ public interface IXLPhonetics : IXLFontBase, IEnumerable, IEquatabl IXLPhonetics SetFontName(String value); IXLPhonetics SetFontFamilyNumbering(XLFontFamilyNumberingValues value); IXLPhonetics SetFontCharSet(XLFontCharSet value); + IXLPhonetics SetFontScheme(XLFontScheme value); + /// + /// Add a phonetic run above a base text. Phonetic runs can't overlap. + /// + /// Text to display above a section of a base text. Can't be empty. + /// Index of a first character of a base text above which should be displayed. Valid values are 0..length-1. + /// The excluded ending index in a base text (the hint is not displayed above the end). Must be > . Valid values are 1..length. IXLPhonetics Add(String text, Int32 start, Int32 end); + + /// + /// Remove all phonetic runs. Keeps font properties. + /// IXLPhonetics ClearText(); + + /// + /// Reset font properties to the default font of a container (likely IXLCell). Keeps phonetic runs, and . + /// IXLPhonetics ClearFont(); + + /// + /// Number of phonetic runs above the base text. + /// Int32 Count { get; } XLPhoneticAlignment Alignment { get; set; } diff --git a/ClosedXML/Excel/RichText/IXLRichText.cs b/ClosedXML/Excel/RichText/IXLRichText.cs index 9965cf67e..f1fbaede6 100644 --- a/ClosedXML/Excel/RichText/IXLRichText.cs +++ b/ClosedXML/Excel/RichText/IXLRichText.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace ClosedXML.Excel { public interface IXLRichText : IXLFormattedText diff --git a/ClosedXML/Excel/RichText/XLFormattedText.cs b/ClosedXML/Excel/RichText/XLFormattedText.cs index e71027c37..4d7035618 100644 --- a/ClosedXML/Excel/RichText/XLFormattedText.cs +++ b/ClosedXML/Excel/RichText/XLFormattedText.cs @@ -9,17 +9,14 @@ namespace ClosedXML.Excel { internal class XLFormattedText : IXLFormattedText { + /// + /// Font used for a new rich text run, never modified. It is generally provided by a container of the formatted text. + /// private readonly IXLFontBase _defaultFont; private List _richTexts = new(); - protected event EventHandler ContentChanged; + private XLPhonetics _phonetics; protected T Container; - public XLFormattedText(IXLFontBase defaultFont) - { - Length = 0; - _defaultFont = defaultFont; - } - public XLFormattedText(IXLFormattedText defaultRichText, IXLFontBase defaultFont) : this(defaultFont) { @@ -27,7 +24,7 @@ public XLFormattedText(IXLFormattedText defaultRichText, IXLFontBase defaultF AddText(rt.Text, rt); if (defaultRichText.HasPhonetics) { - _phonetics = new XLPhonetics(defaultRichText.Phonetics, defaultFont); + _phonetics = new XLPhonetics(defaultRichText.Phonetics, defaultFont, OnContentChanged); } } @@ -37,16 +34,33 @@ public XLFormattedText(String text, IXLFontBase defaultFont) AddText(text); } - public Int32 Count { get { return _richTexts.Count; } } + public XLFormattedText(IXLFontBase defaultFont) + { + Length = 0; + _defaultFont = defaultFont; + } + + IXLPhonetics IXLFormattedText.Phonetics => Phonetics; + + public Int32 Count => _richTexts.Count; + public int Length { get; private set; } + public String Text => ToString(); + + public Boolean HasPhonetics => _phonetics is not null; + + /// + internal XLPhonetics Phonetics => _phonetics ??= new XLPhonetics(_defaultFont, OnContentChanged); + public IXLRichString AddText(String text) { return AddText(text, _defaultFont); } + public IXLRichString AddText(String text, IXLFontBase font) { - var richText = new XLRichString(text, font, this); + var richText = new XLRichString(text, font, this, OnContentChanged); return AddText(richText); } @@ -54,7 +68,7 @@ public IXLRichString AddText(XLRichString richText) { _richTexts.Add(richText); Length += richText.Text.Length; - ContentChanged?.Invoke(this, EventArgs.Empty); + OnContentChanged(); return richText; } @@ -67,7 +81,7 @@ public IXLFormattedText ClearText() { _richTexts.Clear(); Length = 0; - ContentChanged?.Invoke(this, EventArgs.Empty); + OnContentChanged(); return this; } @@ -111,7 +125,7 @@ public IXLFormattedText Substring(Int32 index, Int32 length) Int32 startIndex = index - lastPosition; if (startIndex > 0) - newRichTexts.Add(new XLRichString(rt.Text.Substring(0, startIndex), rt, this)); + newRichTexts.Add(new XLRichString(rt.Text.Substring(0, startIndex), rt, this, OnContentChanged)); else if (startIndex < 0) startIndex = 0; @@ -119,12 +133,12 @@ public IXLFormattedText Substring(Int32 index, Int32 length) if (leftToTake > rt.Text.Length - startIndex) leftToTake = rt.Text.Length - startIndex; - XLRichString newRt = new XLRichString(rt.Text.Substring(startIndex, leftToTake), rt, this); + var newRt = new XLRichString(rt.Text.Substring(startIndex, leftToTake), rt, this, OnContentChanged); newRichTexts.Add(newRt); retVal.AddText(newRt); if (startIndex + leftToTake < rt.Text.Length) - newRichTexts.Add(new XLRichString(rt.Text.Substring(startIndex + leftToTake), rt, this)); + newRichTexts.Add(new XLRichString(rt.Text.Substring(startIndex + leftToTake), rt, this, OnContentChanged)); } else // We haven't reached the desired position yet { @@ -133,7 +147,7 @@ public IXLFormattedText Substring(Int32 index, Int32 length) lastPosition += rt.Text.Length; } _richTexts = newRichTexts; - ContentChanged?.Invoke(this, EventArgs.Empty); + OnContentChanged(); return retVal; } @@ -141,7 +155,7 @@ public IXLFormattedText CopyFrom(IXLFormattedText original) { ClearText(); foreach (var richText in original) - AddText(new XLRichString(richText.Text, richText, this)); + AddText(new XLRichString(richText.Text, richText, this, OnContentChanged)); return this; } @@ -181,27 +195,27 @@ public IXLFormattedText CopyFrom(IXLFormattedText original) public bool Equals(IXLFormattedText other) { - Int32 count = Count; - if (count != other.Count) + if (other is null) return false; - for (Int32 i = 0; i < count; i++) - { - if (_richTexts.ElementAt(i) != other.ElementAt(i)) - return false; - } + if (ReferenceEquals(this, other)) + return true; - return _phonetics == null || Phonetics.Equals(other.Phonetics); - } + if (Count != other.Count) + return false; + + if (!_richTexts.SequenceEqual(other)) + return false; - public String Text { get { return ToString(); } } + return (_phonetics is null && !other.HasPhonetics) || Phonetics.Equals(other.Phonetics); + } - private IXLPhonetics _phonetics; - public IXLPhonetics Phonetics + /// + /// This method is called every time the formatted text is changed (new runs, font props, phonetics...). + /// + protected virtual void OnContentChanged() { - get { return _phonetics ?? (_phonetics = new XLPhonetics(_defaultFont)); } + // Do nothing, intended to be overriden. } - - public Boolean HasPhonetics { get { return _phonetics != null; } } } } diff --git a/ClosedXML/Excel/RichText/XLImmutableRichText.cs b/ClosedXML/Excel/RichText/XLImmutableRichText.cs new file mode 100644 index 000000000..be08ca723 --- /dev/null +++ b/ClosedXML/Excel/RichText/XLImmutableRichText.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace ClosedXML.Excel +{ + /// + /// A class for holding in a . + /// It's immutable (keys in reverse dictionary can't change) and more memory efficient + /// than mutable rich text. + /// + [DebuggerDisplay("{Text}")] + internal sealed class XLImmutableRichText : IEquatable + { + private readonly RichTextRun[] _runs; + private readonly PhoneticRun[] _phoneticRuns; + + /// + /// Create an immutable rich text with same content as the original . + /// + internal XLImmutableRichText(XLRichText richText) + { + Text = richText.Text; + _runs = new RichTextRun[richText.Count]; + var runIdx = 0; + var charStartIdx = 0; + foreach (var richString in richText) + { + _runs[runIdx++] = new RichTextRun(richString, charStartIdx, richString.Text.Length); + charStartIdx += richString.Text.Length; + } + + if (richText.HasPhonetics) + { + var rtPhonetics = richText.Phonetics; + _phoneticRuns = new PhoneticRun[rtPhonetics.Count]; + var phoneticRunIdx = 0; + var prevPhoneticEndIdx = 0; + foreach (var phonetic in richText.Phonetics) + { + if (phonetic.Start >= Text.Length) + throw new ArgumentException("Phonetic run start index must be within the text boundaries."); + + if (phonetic.End > Text.Length) + throw new ArgumentException("Phonetic run end index must be at most length of a text."); + + if (phonetic.Start < prevPhoneticEndIdx) + throw new ArgumentException("Phonetic runs must be in ascending order and can't overlap."); + + _phoneticRuns[phoneticRunIdx++] = new PhoneticRun(phonetic.Text, phonetic.Start, phonetic.End); + prevPhoneticEndIdx = phonetic.End; + } + + PhoneticsProperties = new PhoneticProperties(rtPhonetics); + } + else + { + _phoneticRuns = Array.Empty(); + PhoneticsProperties = null; + } + } + + /// + /// A text of a whole rich text, without styling. + /// + public string Text { get; } + + /// + /// Individual rich text runs that make up the , in ascending order, non-overlapping. + /// + public IReadOnlyList Runs => _runs; + + /// + /// All phonetics runs of rich text. Empty array, if no phonetic run. In ascending order, non-overlapping. + /// + public IReadOnlyList PhoneticRuns => _phoneticRuns; + + /// + /// Properties used to display phonetic runs. + /// + public PhoneticProperties? PhoneticsProperties { get; } + + public bool Equals(XLImmutableRichText? other) + { + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + return Text == other.Text && + _runs.SequenceEqual(other._runs) && + _phoneticRuns.SequenceEqual(other._phoneticRuns) && + Nullable.Equals(PhoneticsProperties, other.PhoneticsProperties); + } + + public override bool Equals(object? obj) + { + return Equals(obj as XLImmutableRichText); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = Text.GetHashCode(); + hashCode = (hashCode * 397) ^ PhoneticsProperties.GetHashCode(); + foreach (var phoneticRun in _phoneticRuns) + hashCode = (hashCode * 397) ^ phoneticRun.GetHashCode(); + + foreach (var run in _runs) + hashCode = (hashCode * 397) ^ run.GetHashCode(); + + return hashCode; + } + } + + internal string GetRunText(RichTextRun run) => Text.Substring(run.StartIndex, run.Length); + + internal readonly struct RichTextRun : IEquatable + { + internal readonly int StartIndex; + internal readonly int Length; + internal readonly XLFontValue Font; + + internal RichTextRun(XLRichString richString, int startIndex, int length) + { + var key = XLFont.GenerateKey(richString); + Font = XLFontValue.FromKey(ref key); + StartIndex = startIndex; + Length = length; + } + + public bool Equals(RichTextRun other) + { + return StartIndex == other.StartIndex && Length == other.Length && Font.Equals(other.Font); + } + + public override bool Equals(object? obj) + { + return obj is RichTextRun other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = StartIndex; + hashCode = (hashCode * 397) ^ Length; + hashCode = (hashCode * 397) ^ Font.GetHashCode(); + return hashCode; + } + } + } + + /// + /// Phonetic runs can't overlap and must be in order (i.e. start index must be ascending). + /// + internal readonly struct PhoneticRun + { + /// + /// Text that is displayed above a segment indicating how should segment be read. + /// + internal readonly string Text; + + /// + /// Starting index of displayed phonetic (first character is 0). + /// + internal readonly int StartIndex; + + /// + /// End index, excluding (the last index is a length of the rich text). + /// + internal readonly int EndIndex; + + public PhoneticRun(string text, int startIndex, int endIndex) + { + if (text.Length == 0) + throw new ArgumentException("Phonetic run text can't be empty.", nameof(text)); + + if (startIndex < 0) + throw new ArgumentException("Start index index must be greater than 0.", nameof(startIndex)); + + if (startIndex >= endIndex) + throw new ArgumentException("Start index must be less than end index.", nameof(endIndex)); + + Text = text; + StartIndex = startIndex; + EndIndex = endIndex; + } + + public bool Equals(PhoneticRun other) + { + return Text == other.Text && StartIndex == other.StartIndex && EndIndex == other.EndIndex; + } + + public override bool Equals(object? obj) + { + return obj is PhoneticRun other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = Text.GetHashCode(); + hashCode = (hashCode * 397) ^ StartIndex; + hashCode = (hashCode * 397) ^ EndIndex; + return hashCode; + } + } + } + + /// + /// Properties of phonetic runs. All phonetic runs of a rich text have same font and other properties. + /// + internal readonly struct PhoneticProperties + { + /// + /// Font used for text of phonetic runs. All phonetic runs use same font. There can be no phonetic runs, + /// but with specified font (e.g. the mutable API has only specified font, but no text yet). + /// + public readonly XLFontValue Font; + + /// + /// Type of phonetics. Default is + /// + public readonly XLPhoneticType Type; + + /// + /// Alignment of phonetics. Default is + /// + public readonly XLPhoneticAlignment Alignment; + + public PhoneticProperties(XLPhonetics rtPhonetics) + { + var fontKey = XLFont.GenerateKey(rtPhonetics); + Font = XLFontValue.FromKey(ref fontKey); + Type = rtPhonetics.Type; + Alignment = rtPhonetics.Alignment; + } + + public bool Equals(PhoneticProperties other) + { + return Font.Equals(other.Font) && Type == other.Type && Alignment == other.Alignment; + } + + public override bool Equals(object? obj) + { + return obj is PhoneticProperties other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = Font.GetHashCode(); + hashCode = (hashCode * 397) ^ (int)Type; + hashCode = (hashCode * 397) ^ (int)Alignment; + return hashCode; + } + } + } + } +} diff --git a/ClosedXML/Excel/RichText/XLPhonetic.cs b/ClosedXML/Excel/RichText/XLPhonetic.cs index ee8535479..37c2f1723 100644 --- a/ClosedXML/Excel/RichText/XLPhonetic.cs +++ b/ClosedXML/Excel/RichText/XLPhonetic.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; namespace ClosedXML.Excel @@ -12,15 +10,18 @@ public XLPhonetic(String text, Int32 start, Int32 end) Start = start; End = end; } - public String Text { get; set; } - public Int32 Start { get; set; } - public Int32 End { get; set; } + public String Text { get; } + public Int32 Start { get; } + public Int32 End { get; } - public bool Equals(IXLPhonetic other) + public bool Equals(IXLPhonetic? other) { - if (other == null) + if (other is null) return false; + if (ReferenceEquals(this, other)) + return true; + return Text == other.Text && Start == other.Start && End == other.End; } } diff --git a/ClosedXML/Excel/RichText/XLPhonetics.cs b/ClosedXML/Excel/RichText/XLPhonetics.cs index 4617cc758..821998ce8 100644 --- a/ClosedXML/Excel/RichText/XLPhonetics.cs +++ b/ClosedXML/Excel/RichText/XLPhonetics.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -8,39 +6,172 @@ namespace ClosedXML.Excel { internal class XLPhonetics : IXLPhonetics { - private readonly List _phonetics = new List(); - + private readonly List _phonetics = new(); + private readonly XLFont _font; private readonly IXLFontBase _defaultFont; + private readonly Action _onChange; + private XLPhoneticAlignment _alignment; + private XLPhoneticType _type; - public XLPhonetics(IXLFontBase defaultFont) + public XLPhonetics(IXLFontBase defaultFont, Action onChange) { _defaultFont = defaultFont; - Type = XLPhoneticType.FullWidthKatakana; - Alignment = XLPhoneticAlignment.Left; - this.CopyFont(_defaultFont); + _font = new XLFont(defaultFont); + _type = XLPhoneticType.FullWidthKatakana; + _alignment = XLPhoneticAlignment.Left; + _onChange = onChange; } - public XLPhonetics(IXLPhonetics defaultPhonetics, IXLFontBase defaultFont) + public XLPhonetics(IXLPhonetics defaultPhonetics, IXLFontBase defaultFont, Action onChange) { _defaultFont = defaultFont; - Type = defaultPhonetics.Type; - Alignment = defaultPhonetics.Alignment; + _font = new XLFont(defaultPhonetics); + _type = defaultPhonetics.Type; + _alignment = defaultPhonetics.Alignment; + _onChange = onChange; + } + + public Int32 Count => _phonetics.Count; + + public Boolean Bold + { + get => _font.Bold; + set + { + _font.Bold = value; + _onChange(); + } + } + + public Boolean Italic + { + get => _font.Italic; + set + { + _font.Italic = value; + _onChange(); + } + } + + public XLFontUnderlineValues Underline + { + get => _font.Underline; + set + { + _font.Underline = value; + _onChange(); + } + } + + public Boolean Strikethrough + { + get => _font.Strikethrough; + set + { + _font.Strikethrough = value; + _onChange(); + } + } + + public XLFontVerticalTextAlignmentValues VerticalAlignment + { + get => _font.VerticalAlignment; + set + { + _font.VerticalAlignment = value; + _onChange(); + } + } + + public Boolean Shadow + { + get => _font.Shadow; + set + { + _font.Shadow = value; + _onChange(); + } + } - this.CopyFont(defaultPhonetics); + public Double FontSize + { + get => _font.FontSize; + set + { + _font.FontSize = value; + _onChange(); + } } - public Boolean Bold { get; set; } - public Boolean Italic { get; set; } - public XLFontUnderlineValues Underline { get; set; } - public Boolean Strikethrough { get; set; } - public XLFontVerticalTextAlignmentValues VerticalAlignment { get; set; } - public Boolean Shadow { get; set; } - public Double FontSize { get; set; } - public XLColor FontColor { get; set; } - public String FontName { get; set; } - public XLFontFamilyNumberingValues FontFamilyNumbering { get; set; } - public XLFontCharSet FontCharSet { get; set; } - public XLFontScheme FontScheme { get; set; } + public XLColor FontColor + { + get => _font.FontColor; + set + { + _font.FontColor = value; + _onChange(); + } + } + + public String FontName + { + get => _font.FontName; + set + { + _font.FontName = value; + _onChange(); + } + } + + public XLFontFamilyNumberingValues FontFamilyNumbering + { + get => _font.FontFamilyNumbering; + set + { + _font.FontFamilyNumbering = value; + _onChange(); + } + } + + public XLFontCharSet FontCharSet + { + get => _font.FontCharSet; + set + { + _font.FontCharSet = value; + _onChange(); + } + } + + public XLFontScheme FontScheme + { + get => _font.FontScheme; + set + { + _font.FontScheme = value; + _onChange(); + } + } + + public XLPhoneticAlignment Alignment + { + get => _alignment; + set + { + _alignment = value; + _onChange(); + } + } + + public XLPhoneticType Type + { + get => _type; + set + { + _type = value; + _onChange(); + } + } public IXLPhonetics SetBold() { Bold = true; return this; } @@ -76,33 +207,31 @@ public XLPhonetics(IXLPhonetics defaultPhonetics, IXLFontBase defaultFont) public IXLPhonetics SetFontScheme(XLFontScheme value) { FontScheme = value; return this; } + public IXLPhonetics SetAlignment(XLPhoneticAlignment phoneticAlignment) { Alignment = phoneticAlignment; return this; } + + public IXLPhonetics SetType(XLPhoneticType phoneticType) { Type = phoneticType; return this; } + public IXLPhonetics Add(String text, Int32 start, Int32 end) { _phonetics.Add(new XLPhonetic(text, start, end)); + _onChange(); return this; } public IXLPhonetics ClearText() { _phonetics.Clear(); + _onChange(); return this; } public IXLPhonetics ClearFont() { this.CopyFont(_defaultFont); + _onChange(); return this; } - public Int32 Count { get { return _phonetics.Count; } } - - public XLPhoneticAlignment Alignment { get; set; } - public XLPhoneticType Type { get; set; } - - public IXLPhonetics SetAlignment(XLPhoneticAlignment phoneticAlignment) { Alignment = phoneticAlignment; return this; } - - public IXLPhonetics SetType(XLPhoneticType phoneticType) { Type = phoneticType; return this; } - public IEnumerator GetEnumerator() { return _phonetics.GetEnumerator(); @@ -113,30 +242,23 @@ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() return GetEnumerator(); } - public bool Equals(IXLPhonetics other) + public bool Equals(IXLPhonetics? other) => Equals(other as XLPhonetics); + + public bool Equals(XLPhonetics? other) { - if (other == null) + if (other is null) return false; - Int32 phoneticsCount = _phonetics.Count; - for (Int32 i = 0; i < phoneticsCount; i++) - { - if (!_phonetics[i].Equals(other.ElementAt(i))) - return false; - } + if (ReferenceEquals(this, other)) + return true; + + if (!_phonetics.SequenceEqual(other._phonetics)) + return false; return - Bold == other.Bold - && Italic == other.Italic - && Underline == other.Underline - && Strikethrough == other.Strikethrough - && VerticalAlignment == other.VerticalAlignment - && Shadow == other.Shadow - && FontSize.Equals(other.FontSize) - && FontColor.Equals(other.FontColor) - && FontName == other.FontName - && FontFamilyNumbering == other.FontFamilyNumbering - && FontScheme == other.FontScheme; + _font.Key.Equals(other._font.Key) && + Type == other.Type && + Alignment == other.Alignment; } } } diff --git a/ClosedXML/Excel/RichText/XLRichString.cs b/ClosedXML/Excel/RichText/XLRichString.cs index 8bbbe9bcc..a564a7bb0 100644 --- a/ClosedXML/Excel/RichText/XLRichString.cs +++ b/ClosedXML/Excel/RichText/XLRichString.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Diagnostics; @@ -9,12 +7,15 @@ namespace ClosedXML.Excel internal class XLRichString : IXLRichString { private readonly IXLWithRichString _withRichString; + private readonly XLFont _font; + private readonly Action _onChange; - public XLRichString(String text, IXLFontBase font, IXLWithRichString withRichString) + public XLRichString(String text, IXLFontBase font, IXLWithRichString withRichString, Action? onChange) { Text = text; - this.CopyFont(font); + _font = new XLFont(font); _withRichString = withRichString; + _onChange = onChange ?? (() => { }); } public String Text { get; set; } @@ -29,18 +30,125 @@ public IXLRichString AddNewLine() return AddText(Environment.NewLine); } - public Boolean Bold { get; set; } - public Boolean Italic { get; set; } - public XLFontUnderlineValues Underline { get; set; } - public Boolean Strikethrough { get; set; } - public XLFontVerticalTextAlignmentValues VerticalAlignment { get; set; } - public Boolean Shadow { get; set; } - public Double FontSize { get; set; } - public XLColor FontColor { get; set; } - public String FontName { get; set; } - public XLFontFamilyNumberingValues FontFamilyNumbering { get; set; } - public XLFontCharSet FontCharSet { get; set; } - public XLFontScheme FontScheme { get; set; } + public Boolean Bold + { + get => _font.Bold; + set + { + _font.Bold = value; + _onChange(); + } + } + + public Boolean Italic + { + get => _font.Italic; + set + { + _font.Italic = value; + _onChange(); + } + } + + public XLFontUnderlineValues Underline + { + get => _font.Underline; + set + { + _font.Underline = value; + _onChange(); + } + } + + public Boolean Strikethrough + { + get => _font.Strikethrough; + set + { + _font.Strikethrough = value; + _onChange(); + } + } + + public XLFontVerticalTextAlignmentValues VerticalAlignment + { + get => _font.VerticalAlignment; + set + { + _font.VerticalAlignment = value; + _onChange(); + } + } + + public Boolean Shadow + { + get => _font.Shadow; + set + { + _font.Shadow = value; + _onChange(); + } + } + + public Double FontSize + { + get => _font.FontSize; + set + { + _font.FontSize = value; + _onChange(); + } + } + + public XLColor FontColor + { + get => _font.FontColor; + set + { + _font.FontColor = value; + _onChange(); + } + } + + public String FontName + { + get => _font.FontName; + set + { + _font.FontName = value; + _onChange(); + } + } + + public XLFontFamilyNumberingValues FontFamilyNumbering + { + get => _font.FontFamilyNumbering; + set + { + _font.FontFamilyNumbering = value; + _onChange(); + } + } + + public XLFontCharSet FontCharSet + { + get => _font.FontCharSet; + set + { + _font.FontCharSet = value; + _onChange(); + } + } + + public XLFontScheme FontScheme + { + get => _font.FontScheme; + set + { + _font.FontScheme = value; + _onChange(); + } + } public IXLRichString SetBold() { @@ -127,42 +235,26 @@ public IXLRichString SetFontScheme(XLFontScheme value) FontScheme = value; return this; } - public Boolean Equals(IXLRichString other) - { - return - Text == other.Text - && Bold.Equals(other.Bold) - && Italic.Equals(other.Italic) - && Underline.Equals(other.Underline) - && Strikethrough.Equals(other.Strikethrough) - && VerticalAlignment.Equals(other.VerticalAlignment) - && Shadow.Equals(other.Shadow) - && FontSize.Equals(other.FontSize) - && FontColor.Equals(other.FontColor) - && FontName.Equals(other.FontName) - && FontFamilyNumbering.Equals(other.FontFamilyNumbering) - && FontScheme.Equals(other.FontScheme) - ; - } + public override bool Equals(object obj) => Equals(obj as XLRichString); + + public Boolean Equals(IXLRichString? other) => Equals(other as XLRichString); - public override bool Equals(object obj) + public Boolean Equals(XLRichString? other) { - return Equals((XLRichString)obj); + if (other is null) + return false; + + if (ReferenceEquals(this, other)) + return true; + + return Text == other.Text && _font.Key.Equals(other._font.Key); } public override int GetHashCode() { - return Text.GetHashCode() - ^ Bold.GetHashCode() - ^ Italic.GetHashCode() - ^ (Int32)Underline - ^ Strikethrough.GetHashCode() - ^ (Int32)VerticalAlignment - ^ Shadow.GetHashCode() - ^ FontSize.GetHashCode() - ^ FontColor.GetHashCode() - ^ FontName.GetHashCode() - ^ (Int32)FontFamilyNumbering; + // Since all properties of type are mutable, can't have different hashcode for any instance. + // Don't ever use this class in a dictionary, e.g. SST. + return 4; // Chosen by fair dice roll. Guaranteed to be random. } } } diff --git a/ClosedXML/Excel/RichText/XLRichText.cs b/ClosedXML/Excel/RichText/XLRichText.cs index d2165858d..097729f0b 100644 --- a/ClosedXML/Excel/RichText/XLRichText.cs +++ b/ClosedXML/Excel/RichText/XLRichText.cs @@ -1,23 +1,49 @@ -#nullable disable - using System; +using System.Linq; namespace ClosedXML.Excel { internal class XLRichText : XLFormattedText, IXLRichText { - private readonly XLCell _cell; + // Should be set as the last thing in ctor to prevent firing changes to immutable rich text during ctor + private readonly XLCell? _cell; - public XLRichText(XLCell cell, IXLFontBase defaultFont) - : base(defaultFont) + /// + /// Copy ctor to return user modifiable rich text from immutable rich text stored + /// in the shared string table. + /// + public XLRichText(XLCell cell, XLImmutableRichText original) + : base(cell.Style.Font) { + foreach (var originalRun in original.Runs) + { + var runText = original.GetRunText(originalRun); + AddText(new XLRichString(runText, new XLFont(originalRun.Font.Key), this, OnContentChanged)); + } + + var hasPhonetics = original.PhoneticRuns.Any() || original.PhoneticsProperties.HasValue; + if (hasPhonetics) + { + // Access to phonetics instantiate a new instance. + var phonetics = Phonetics; + if (original.PhoneticsProperties.HasValue) + { + var phoneticProps = original.PhoneticsProperties.Value; + phonetics.CopyFont(new XLFont(phoneticProps.Font.Key)); + phonetics.Type = phoneticProps.Type; + phonetics.Alignment = phoneticProps.Alignment; + } + + foreach (var phoneticRun in original.PhoneticRuns) + phonetics.Add(phoneticRun.Text, phoneticRun.StartIndex, phoneticRun.EndIndex); + } + Container = this; _cell = cell; - ContentChanged += OnContentChanged; } - public XLRichText(XLCell cell, XLFormattedText defaultRichText, IXLFontBase defaultFont) - : base(defaultRichText, defaultFont) + public XLRichText(XLCell cell, IXLFontBase defaultFont) + : base(defaultFont) { Container = this; _cell = cell; @@ -30,12 +56,19 @@ public XLRichText(XLCell cell, String text, IXLFontBase defaultFont) _cell = cell; } - private void OnContentChanged(object sender, EventArgs e) + protected override void OnContentChanged() { - if (_cell.DataType != XLDataType.Text || !_cell.HasRichText || !ReferenceEquals(_cell.GetRichText(), this)) + // The rich text is still being created + if (_cell is null) + return; + + if (_cell.DataType != XLDataType.Text || !_cell.HasRichText) throw new InvalidOperationException("The rich text isn't a content of a cell."); _cell.SetOnlyValue(Text); + var point = _cell.SheetPoint; + var richText = new XLImmutableRichText(this); + _cell.Worksheet.Internals.CellsCollection.ValueSlice.SetRichText(point, richText); } } } diff --git a/ClosedXML/Excel/Style/XLFont.cs b/ClosedXML/Excel/Style/XLFont.cs index cc34f6247..e80b6f3ab 100644 --- a/ClosedXML/Excel/Style/XLFont.cs +++ b/ClosedXML/Excel/Style/XLFont.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Text; namespace ClosedXML.Excel @@ -82,7 +83,26 @@ public XLFont(XLStyle? style, XLFontKey key) : this(style, XLFontValue.FromKey(r { } - public XLFont(XLStyle? style = null, IXLFont? d = null) : this(style, GenerateKey(d)) + /// + /// Create a new font that is attached to a style and the changes to the font object are propagated to the style. + /// + /// The container style that will be modified by changes of created XLFont. + public XLFont(XLStyle style) : this(style, GenerateKey(style.Font)) + { + } + + /// + /// Create a new font. The changes to the object are not propagated to a style. + /// + public XLFont(IXLFontBase font) : this(null, GenerateKey(font)) + { + } + + public XLFont(XLFontKey key) : this(null, XLFontValue.FromKey(ref key)) + { + } + + private XLFont() : this(null, GenerateKey(null)) { } @@ -337,7 +357,7 @@ public override string ToString() sb.Append("-"); sb.Append(Shadow.ToString()); sb.Append("-"); - sb.Append(FontSize.ToString()); + sb.Append(FontSize.ToString(CultureInfo.InvariantCulture)); sb.Append("-"); sb.Append(FontColor); sb.Append("-"); diff --git a/ClosedXML/Extensions/FontBaseExtensions.cs b/ClosedXML/Extensions/FontBaseExtensions.cs index 35432094c..8c71b104f 100644 --- a/ClosedXML/Extensions/FontBaseExtensions.cs +++ b/ClosedXML/Extensions/FontBaseExtensions.cs @@ -1,5 +1,3 @@ -#nullable disable - // Keep this file CodeMaid organised and cleaned namespace ClosedXML.Excel @@ -19,6 +17,7 @@ public static void CopyFont(this IXLFontBase font, IXLFontBase sourceFont) font.FontName = sourceFont.FontName; font.FontFamilyNumbering = sourceFont.FontFamilyNumbering; font.FontCharSet = sourceFont.FontCharSet; + font.FontScheme = sourceFont.FontScheme; } } } diff --git a/docs/migrations/migrate-to-0.103.rst b/docs/migrations/migrate-to-0.103.rst new file mode 100644 index 000000000..37997afd2 --- /dev/null +++ b/docs/migrations/migrate-to-0.103.rst @@ -0,0 +1,14 @@ +############################# +Migration from 0.102 to 0.103 +############################# + +*********** +IXLPhonetic +*********** + +``IXLPhonetic`` no longer has a setter for its ``IXLPhonetic.Text``, +``IXLPhonetic.Start`` and ``IXLPhonetic.End`` properties. + +Use ``IXLPhonetics.ClearText()`` and ``IXLPhonetics.Add(String text, Int32 start, Int32 end)`` +to redo the phonetic hints. +