Skip to content

Commit 82ba1c6

Browse files
committed
Improve line folding and serialization performance
- Update line folding to use StringBuilder and ArrayPool<char> for better performance and memory efficiency. - Ensure compliance with RFC 5545 by handling multi-byte characters correctly. - Add System.Buffers package for netstandard2.0 target framework. Resolves #693
1 parent 1b2e1d4 commit 82ba1c6

File tree

7 files changed

+185
-95
lines changed

7 files changed

+185
-95
lines changed

Ical.Net.Tests/PeriodListWrapperTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,9 @@ public void AddRPeriod_ShouldCreate_DedicatePeriodList()
213213

214214
Assert.That(serialized,
215215
Does.Contain(
216-
"RDATE;VALUE=PERIOD:20250102/20250105,20250501/20250510,20250501/P9D\r\n" +
217-
"RDATE;VALUE=PERIOD:20250202T000000Z/20250202T060000Z,20250601T120000Z/2025\r\n" +
218-
" 0601T140000Z\r\n" +
216+
"RDATE;VALUE=PERIOD:20250102/20250105,20250501/20250510,20250501/P9D" + SerializationConstants.LineBreak +
217+
"RDATE;VALUE=PERIOD:20250202T000000Z/20250202T060000Z,20250601T120000Z/20250" + SerializationConstants.LineBreak +
218+
" 601T140000Z" + SerializationConstants.LineBreak +
219219
"RDATE;TZID=Europe/Vienna;VALUE=PERIOD:20250601T120000/PT8H"));
220220

221221
// A flattened list of all dates

Ical.Net.Tests/RecurrenceWithRDateTests.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Ical.Net.Collections;
1111
using Ical.Net.DataTypes;
1212
using Ical.Net.Serialization;
13+
using Ical.Net.Utility;
1314
using NUnit.Framework;
1415

1516
namespace Ical.Net.Tests;
@@ -152,7 +153,7 @@ public void RDate_PeriodsWithTimezone_AreProcessedCorrectly()
152153
Assert.That(occurrences[1].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 4)));
153154
Assert.That(occurrences[2].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 5)));
154155
// Line folding is used for long lines
155-
Assert.That(ics, Does.Contain("RDATE;TZID=America/New_York;VALUE=PERIOD:20231002T100000/PT4H,20231003T100\r\n 000/PT5H"));
156+
Assert.That(ics, Does.Contain("RDATE;TZID=America/New_York;VALUE=PERIOD:20231002T100000/PT4H,20231003T1000\r\n 00/PT5H"));
156157
});
157158

158159
// Deserialization
@@ -342,7 +343,7 @@ public void RDate_LargeNumberOfDates_ShouldBeLineFolded()
342343
// First folded line is 75 characters long
343344
Assert.That(ics, Does.Contain("RDATE:20231002T100000,20231003T100000,20231004T100000,20231005T100000,2023"));
344345
// Last folded line
345-
Assert.That(ics, Does.Contain(" T100000,20240106T100000,20240107T100000,20240108T100000,20240109T100000"));
346+
Assert.That(ics, Does.Contain(" 00,20240107T100000,20240108T100000,20240109T100000"));
346347
});
347348
}
348349

Ical.Net.Tests/TextUtilTests.cs

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
//
55

66
using System.Collections;
7+
using System.Linq;
8+
using System.Text;
79
using Ical.Net.Utility;
810
using NUnit.Framework;
911

@@ -12,38 +14,63 @@ namespace Ical.Net.Tests;
1214
public class TextUtilTests
1315
{
1416
[Test, TestCaseSource(nameof(FoldLines_TestCases))]
15-
public string FoldLines_Tests(string incoming) => TextUtil.FoldLines(incoming);
17+
public string FoldLines_Tests(string input)
18+
{
19+
var sb = new StringBuilder(input.Length);
20+
sb.FoldLines(input);
21+
return sb.ToString();
22+
}
1623

1724
public static IEnumerable FoldLines_TestCases()
1825
{
19-
yield return new TestCaseData("Short")
20-
.Returns("Short" + SerializationConstants.LineBreak)
26+
yield return new TestCaseData("No folding")
27+
.Returns("No folding" + SerializationConstants.LineBreak)
2128
.SetName("Short string remains unfolded");
2229

23-
const string moderatelyLongReturns =
24-
"HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHell" + SerializationConstants.LineBreak
25-
+ " oWorld" + SerializationConstants.LineBreak;
30+
const string exactly85OctetsReturns =
31+
"HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHello" + SerializationConstants.LineBreak
32+
+ " WorldHello" + SerializationConstants.LineBreak;
33+
34+
yield return new TestCaseData("HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHello")
35+
.Returns(exactly85OctetsReturns)
36+
.SetName("10 Octets remainder gets folded");
37+
38+
yield return new TestCaseData(
39+
" HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorld ")
40+
.Returns(" HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorld " +
41+
SerializationConstants.LineBreak + " " + SerializationConstants.LineBreak)
42+
.SetName("85 Octets long string with leading and trailing whitespace is folded without trimming");
43+
44+
var exactly310Octets = string.Concat(Enumerable.Repeat("HelloWorld", 31));
45+
46+
const string exactly310OctetsReturns =
47+
"HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHello" + SerializationConstants.LineBreak +
48+
" WorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorl" + SerializationConstants.LineBreak +
49+
" dHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHel" + SerializationConstants.LineBreak +
50+
" loWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWo" + SerializationConstants.LineBreak +
51+
" rldHelloWorld" + SerializationConstants.LineBreak;
2652

27-
yield return new TestCaseData("HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorld")
28-
.Returns(moderatelyLongReturns)
29-
.SetName("Long string is folded");
53+
yield return new TestCaseData(exactly310Octets)
54+
.Returns(exactly310OctetsReturns)
55+
.SetName("310 Octets long string is split onto multiple lines at a width of 75 octets");
3056

31-
yield return new TestCaseData(" HelloWorldHelloWorldHelloWorldHelloWorldHelloWorld ")
32-
.Returns("HelloWorldHelloWorldHelloWorldHelloWorldHelloWorld" + SerializationConstants.LineBreak)
33-
.SetName("Long string with front and rear whitespace is trimmed and fits in the allotted width");
57+
const string exactly75Octets = "HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHello";
58+
const string exactly75OctetsReturns = "HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHello" + SerializationConstants.LineBreak;
3459

35-
const string reallyLong =
36-
"HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorld";
60+
yield return new TestCaseData(exactly75Octets)
61+
.Returns(exactly75OctetsReturns)
62+
.SetName("String with exactly 75 octets remains unfolded");
3763

38-
const string reallyLongReturns =
39-
"HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHell" + SerializationConstants.LineBreak
40-
+ " oWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWo" + SerializationConstants.LineBreak
41-
+ " rldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorld" + SerializationConstants.LineBreak
42-
+ " HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHel" + SerializationConstants.LineBreak
43-
+ " loWorldHelloWorld" + SerializationConstants.LineBreak;
64+
// String containing multi-byte characters with 210 Octets
65+
// (Chinese "Hello World" repeated 10 times)
66+
const string multiByteString210Octets = "こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界";
67+
const string multiByteString210OctetsReturns =
68+
"こんにちは世界こんにちは世界こんにちは世界こんにち" + SerializationConstants.LineBreak +
69+
" は世界こんにちは世界こんにちは世界こんにちは世界" + SerializationConstants.LineBreak +
70+
" こんにちは世界こんにちは世界こんにちは世界" + SerializationConstants.LineBreak;
4471

45-
yield return new TestCaseData(reallyLong)
46-
.Returns(reallyLongReturns)
47-
.SetName("Really long string is split onto multiple lines at a width of 75 chars, prefixed with a space");
72+
yield return new TestCaseData(multiByteString210Octets)
73+
.Returns(multiByteString210OctetsReturns)
74+
.SetName("String with multi-byte characters exceeding 75 octets is folded correctly");
4875
}
49-
}
76+
}

Ical.Net/Ical.Net.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0' OR '$(TargetFramework)' == 'netstandard2.1'">
1212
<PackageReference Include="Portable.System.DateTimeOnly" Version="8.0.1" />
1313
</ItemGroup>
14+
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
15+
<PackageReference Include="System.Buffers" Version="4.6.0" />
16+
</ItemGroup>
1417
<ItemGroup>
1518
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
1619
<_Parameter1>Ical.Net.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100a1f790f70176d52efbd248577bdb292be2d0acc62f3227c523e267d64767f207f81536c77bb91d17031a5afbc2d69cd3b5b3b9c98fa8df2cd363ec90a08639a1213ad70079eff666bcc14cf6574b899f4ad0eac672c8f763291cb1e0a2304d371053158cb398b2e6f9eeb45db7d1b4d2bbba1f985676c5ca4602fab3671d34bf</_Parameter1>

Ical.Net/Serialization/ComponentSerializer.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public override string SerializeToString(object obj)
3232

3333
var sb = new StringBuilder();
3434
var upperName = c.Name.ToUpperInvariant();
35-
sb.Append(TextUtil.FoldLines($"BEGIN:{upperName}"));
35+
sb.FoldLines($"BEGIN:{upperName}");
3636

3737
// Get a serializer factory
3838
var sf = GetService<ISerializerFactory>();
@@ -56,7 +56,7 @@ public override string SerializeToString(object obj)
5656
sb.Append(serializer.SerializeToString(child));
5757
}
5858

59-
sb.Append(TextUtil.FoldLines($"END:{upperName}"));
59+
sb.FoldLines($"END:{upperName}");
6060
return sb.ToString();
6161
}
6262

@@ -79,4 +79,4 @@ public int Compare(ICalendarProperty x, ICalendarProperty y)
7979
: string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
8080
}
8181
}
82-
}
82+
}

Ical.Net/Serialization/PropertySerializer.cs

Lines changed: 69 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -39,65 +39,83 @@ public PropertySerializer(SerializationContext ctx) : base(ctx) { }
3939
var result = new StringBuilder();
4040
foreach (var v in prop.Values.Where(value => value != null))
4141
{
42-
// Get a serializer to serialize the property's value.
43-
// If we can't serialize the property's value, the next step is worthless anyway.
44-
var valueSerializer = sf.Build(v.GetType(), SerializationContext) as IStringSerializer;
45-
46-
// Iterate through each value to be serialized,
47-
// and give it a property (with parameters).
48-
// FIXME: this isn't always the way this is accomplished.
49-
// Multiple values can often be serialized within the
50-
// same property. How should we fix this?
51-
52-
// NOTE:
53-
// We Serialize the property's value first, as during
54-
// serialization it may modify our parameters.
55-
// FIXME: the "parameter modification" operation should
56-
// be separated from serialization. Perhaps something
57-
// like PreSerialize(), etc.
58-
var value = valueSerializer?.SerializeToString(v);
59-
60-
// Get the list of parameters we'll be serializing
61-
var parameterList = prop.Parameters;
62-
if (v is ICalendarDataType)
63-
{
64-
parameterList = ((ICalendarDataType) v).Parameters;
65-
}
42+
SerializeValue(result, prop, v, sf);
43+
}
6644

67-
// The TZID property of an RDATE/EXDATE collection is owned by the PeriodList that contains it.
68-
// It is allowed to have multiple EXDATE or RDATE collections, each with a different TZID.
69-
// Using RecurrenceDate and ExceptionDate classes ensures, that all Periods in the
70-
// PeriodList have the same TZID and PeriodKind, which allows PeriodList be serialized in one go.
71-
// Here, to determine the timezone, we can safely use the first Period's timezone.
72-
if (v is PeriodList periodList && periodList[0].TzId != null && periodList[0].TzId != "UTC" &&
73-
parameterList.All(p => string.Equals("TZID", p.Value, StringComparison.OrdinalIgnoreCase)))
74-
{
75-
parameterList.Set("TZID", periodList[0].TzId);
76-
}
45+
// Pop the object off the serialization context.
46+
SerializationContext.Pop();
47+
return result.ToString();
48+
}
49+
50+
private void SerializeValue(StringBuilder result, ICalendarProperty prop, object value, ISerializerFactory sf)
51+
{
52+
// Get a serializer to serialize the property's value.
53+
// If we can't serialize the property's value, the next step is worthless anyway.
54+
var valueSerializer = sf.Build(value.GetType(), SerializationContext) as IStringSerializer;
55+
56+
// Iterate through each value to be serialized,
57+
// and give it a property (with parameters).
58+
// FIXME: this isn't always the way this is accomplished.
59+
// Multiple values can often be serialized within the
60+
// same property. How should we fix this?
61+
62+
// NOTE:
63+
// We Serialize the property's value first, as during
64+
// serialization it may modify our parameters.
65+
// FIXME: the "parameter modification" operation should
66+
// be separated from serialization. Perhaps something
67+
// like PreSerialize(), etc.
68+
var serializedValue = valueSerializer?.SerializeToString(value);
69+
70+
// Get the list of parameters we'll be serializing
71+
var parameterList = GetParameterList(prop, value);
72+
73+
// The TZID property of an RDATE/EXDATE collection is owned by the PeriodList that contains it.
74+
// It is allowed to have multiple EXDATE or RDATE collections, each with a different TZID.
75+
// Using RecurrenceDate and ExceptionDate classes ensures, that all Periods in the
76+
// PeriodList have the same TZID and PeriodKind, which allows PeriodList be serialized in one go.
77+
// Here, to determine the timezone, we can safely use the first Period's timezone.
78+
if (value is PeriodList periodList)
79+
{
80+
UpdateTzId(parameterList, periodList);
81+
}
7782

78-
var sb = new StringBuilder();
79-
sb.Append(prop.Name);
80-
if (parameterList.Any())
83+
var sb = new StringBuilder();
84+
sb.Append(prop.Name);
85+
if (parameterList.Count != 0)
86+
{
87+
// Get a serializer for parameters
88+
var parameterSerializer = sf.Build(typeof(CalendarParameter), SerializationContext) as IStringSerializer;
89+
if (parameterSerializer != null)
8190
{
82-
// Get a serializer for parameters
83-
var parameterSerializer = sf.Build(typeof(CalendarParameter), SerializationContext) as IStringSerializer;
84-
if (parameterSerializer != null)
91+
sb.Append(';');
92+
var first = true;
93+
// Serialize each parameter and append to the StringBuilder
94+
foreach (var param in parameterList)
8595
{
86-
// Serialize each parameter
87-
// Separate parameters with semicolons
88-
sb.Append(";");
89-
sb.Append(string.Join(";", parameterList.Select(param => parameterSerializer.SerializeToString(param))));
96+
if (!first) sb.Append(';');
97+
98+
sb.Append(parameterSerializer.SerializeToString(param));
99+
first = false;
90100
}
91101
}
92-
sb.Append(":");
93-
sb.Append(value);
94-
95-
result.Append(TextUtil.FoldLines(sb.ToString()));
96102
}
103+
sb.Append(':');
104+
sb.Append(serializedValue);
97105

98-
// Pop the object off the serialization context.
99-
SerializationContext.Pop();
100-
return result.ToString();
106+
result.FoldLines(sb.ToString());
107+
}
108+
109+
private static IParameterCollection GetParameterList(ICalendarProperty prop, object value)
110+
=> value is ICalendarDataType dataType ? dataType.Parameters : prop.Parameters;
111+
112+
private static void UpdateTzId(IParameterCollection parameterList, PeriodList periodList)
113+
{
114+
if (periodList[0].TzId != null && periodList[0].TzId != "UTC" &&
115+
parameterList.All(p => string.Equals("TZID", p.Value, StringComparison.OrdinalIgnoreCase)))
116+
{
117+
parameterList.Set("TZID", periodList[0].TzId);
118+
}
101119
}
102120

103121
public override object? Deserialize(TextReader tr) => null;

Ical.Net/Utility/TextUtil.cs

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,73 @@
44
//
55

66
using System;
7+
using System.Buffers;
78
using System.Collections.Generic;
89
using System.IO;
10+
using System.Text;
911
using System.Text.RegularExpressions;
1012
using Ical.Net.Serialization;
1113

1214
namespace Ical.Net.Utility;
1315

1416
internal static class TextUtil
1517
{
16-
/// <summary> Folds lines at 75 characters, and prepends the next line with a space per RFC https://tools.ietf.org/html/rfc5545#section-3.1 </summary>
17-
public static string FoldLines(string incoming)
18+
/// <summary>
19+
/// Folds lines to 75 octets. It appends a CrLf, and prepends the next line with a space.
20+
/// per RFC https://tools.ietf.org/html/rfc5545#section-3.1
21+
/// </summary>
22+
public static void FoldLines(this StringBuilder result, string inputLine)
1823
{
19-
//The spec says nothing about trimming, but it seems reasonable...
20-
var trimmed = incoming.Trim();
21-
if (trimmed.Length <= 75)
24+
if (Encoding.UTF8.GetByteCount(inputLine) <= 75)
2225
{
23-
return trimmed + SerializationConstants.LineBreak;
26+
result.Append(inputLine);
27+
result.Append(SerializationConstants.LineBreak);
28+
return;
2429
}
2530

26-
const int takeLimit = 74;
31+
// Use ArrayPool<char> to rent arrays for processing
32+
// Cannot use Span<char> and stackalloc because netstandard2.0 does not support it
33+
var arrayPool = ArrayPool<char>.Shared;
34+
var currentLineArray = arrayPool.Rent(76); // 75 characters + 1 space
35+
36+
try
37+
{
38+
var currentLineIndex = 0;
39+
var byteCount = 0;
40+
var charCount = 0;
41+
42+
while (charCount < inputLine.Length)
43+
{
44+
var currentCharByteCount = Encoding.UTF8.GetByteCount([inputLine[charCount]]);
45+
46+
if (byteCount + currentCharByteCount > 75)
47+
{
48+
result.Append(currentLineArray, 0, currentLineIndex);
49+
result.Append(SerializationConstants.LineBreak);
2750

28-
var firstLine = trimmed.Substring(0, takeLimit);
29-
var remainder = trimmed.Substring(takeLimit, trimmed.Length - takeLimit);
51+
currentLineIndex = 0;
52+
byteCount = 1;
53+
currentLineArray[currentLineIndex++] = ' ';
54+
}
3055

31-
var chunkedRemainder = string.Join(SerializationConstants.LineBreak + " ", Chunk(remainder));
32-
return firstLine + SerializationConstants.LineBreak + " " + chunkedRemainder + SerializationConstants.LineBreak;
56+
currentLineArray[currentLineIndex++] = inputLine[charCount];
57+
byteCount += currentCharByteCount;
58+
charCount++;
59+
}
60+
61+
// Append the remaining characters to the result
62+
if (currentLineIndex > 0)
63+
{
64+
result.Append(currentLineArray, 0, currentLineIndex);
65+
}
66+
67+
result.Append(SerializationConstants.LineBreak);
68+
}
69+
finally
70+
{
71+
// Return the rented array to the pool
72+
arrayPool.Return(currentLineArray);
73+
}
3374
}
3475

3576
public static IEnumerable<string> Chunk(string str, int chunkSize = 73)
@@ -74,4 +115,4 @@ public static TextReader Normalize(string s, SerializationContext ctx)
74115

75116
public static bool Contains(this string haystack, string needle, StringComparison stringComparison)
76117
=> haystack.IndexOf(needle, stringComparison) >= 0;
77-
}
118+
}

0 commit comments

Comments
 (0)