-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathRichTextBuilder.cs
488 lines (428 loc) · 12 KB
/
RichTextBuilder.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace Sylphe.Utils
{
/// <summary>
/// A helper to build syntactically correct RTF documents.
/// Useful in conjunction with the WinForms RichTextBox.
/// A very small subset of RTF is accessible through this builder.
/// Next on my wishlist: colors (needs color table, similar to fonttbl).
/// <para/>
/// See the <a href="http://www.biblioscape.com/rtf15_spec.htm">RTF 1.5 Spec</a>
/// and <a href="https://en.wikipedia.org/wiki/Rich_Text_Format">Wikipedia</a>
/// </summary>
public class RichTextBuilder
{
private readonly IList<FontEntry> _fontTable;
private readonly StringBuilder _body;
private int _groupNesting; // not counting the outermost {\rtf1...} group
private bool _needDelimiter;
private bool _hasUnicodeChars;
private int _lastNewline;
// RTF is case sensitive and control words must consist of [a-z] only:
private static readonly Regex CommandRegex = new Regex(@"^[a-z]+$");
/// <summary>
/// Avoid lines in the RTF file that are longer than this value.
/// <para/>
/// The RTF spec does not mandate a maximum line length.
/// This parameter is merely a convenience in case the generated
/// RTF file is to be opened in a text editor.
/// <para/>
/// Because commands are not broken, lines may be longer than this limit.
/// </summary>
public int LineLimit { get; set; }
/// <summary>
/// If <c>true</c>, emit <c>'\t'</c> in text as "\tab" (the RTF tab command);
/// otherwise, emit <c>'\t'</c> as "\'09" (the usual hex escape).
/// </summary>
public bool ObeyTabs { get; set; }
/// <summary>
/// If <c>true</c>, emit any end-of-line in text as "\line" (the
/// RTF newline command); otherwise, emit end-of-lines as hex escapes.
/// </summary>
public bool ObeyLines { get; set; } // iff true, translate '\n' in text to "\line "
public RichTextBuilder()
{
_fontTable = new List<FontEntry>();
_body = new StringBuilder();
_groupNesting = 0;
_needDelimiter = false;
_hasUnicodeChars = false;
_lastNewline = 0;
LineLimit = 72;
ObeyTabs = false;
ObeyLines = false;
SetDefaultFont("Microsoft Sans Serif", "nil");
}
/// <remarks>
/// The <paramref name="family"/>, if present, should be one of:
/// nil, roman, swiss, modern, script, decor, tech, bidi.
/// It is supposed to help with font substitution, but appears
/// to be of limited use in practice and therefore may best be
/// omitted.
/// </remarks>
public void SetDefaultFont(string name, string family = null)
{
if (_fontTable.Count < 1)
{
_fontTable.Add(new FontEntry(0, name, family));
}
else
{
_fontTable[0] = new FontEntry(0, name, family);
}
}
/// <summary>
/// Emit plain text to RTF. Special characters in <paramref name="text"/>
/// will be escaped unless requested otherwise by <see cref="ObeyTabs"/>
/// and <see cref="ObeyLines"/>.
/// </summary>
public RichTextBuilder Text(string text)
{
text = text ?? string.Empty;
CheckLineLimit();
if (_needDelimiter)
{
_body.Append(' ');
}
// TODO if ObeyEscapes: keep \- and \_ and \~ (but still escape \ { } and non-7bit-ascii) ??
for (int i = 0; i < text.Length; i++)
{
char c = text[i];
// Convert Windows CRLF and old Macintosh CR
// end-of-line conventions to Unix LF only:
if (c == '\r')
{
if (i + 1 < text.Length && text[i + 1] == '\n')
continue;
c = '\n';
}
if (c == '\t' && ObeyTabs)
{
_body.Append(@"\tab ");
// todo omit trailing blank if next char is 'delimiting'
}
else if (c == '\n' && ObeyLines)
{
_body.Append(@"\line ");
// todo omit trailing blank if next char is 'delimiting'
}
else if (c == '\\' || c == '{' || c == '}')
{
_body.Append('\\');
_body.Append(c);
}
else if (c < 32)
{
_body.AppendFormat(@"\'{0:x2}", c & 0xFF);
}
else if (c < 127)
{
_body.Append(c);
}
else if (c < 256)
{
_body.AppendFormat(@"\'{0:x2}", c & 0xFF);
}
else
{
int u = c < 32768 ? c : c - 65536;
_body.AppendFormat(@"\u{0}*", u);
//_body.AppendFormat(@"\uc1\u{0}*", u);
_hasUnicodeChars = true;
}
CheckLineLimit();
}
_needDelimiter = false;
return this;
}
/// <summary>See <see cref="Text(string)"/>.</summary>
public RichTextBuilder TextFormat(string format, params object[] args)
{
return Text(string.Format(format, args));
}
/// <summary>
/// Append <c>\~</c> (backslash tilde),
/// which represents a non-breaking space.
/// </summary>
public RichTextBuilder NonBreakingSpace()
{
AppendControlSymbol('~');
return this;
}
/// <summary>
/// Append <c>\_</c> (backslash underscore),
/// which represents a non-breaking hyphen.
/// </summary>
public RichTextBuilder NonBreakingHyphen()
{
AppendControlSymbol('_');
return this;
}
/// <summary>
/// Append <c>\-</c> (backslash minus),
/// which represents an optional hyphen.
/// </summary>
public RichTextBuilder OptionalHyphen()
{
AppendControlSymbol('-');
return this;
}
/// <summary>
/// Append <c>\line</c>, causing a line break.
/// </summary>
public RichTextBuilder LineBreak()
{
AppendControlWord("line");
return this;
}
/// <summary>
/// Append <c>\tab</c> to skip to the next tab stop.
/// </summary>
public RichTextBuilder Tab()
{
AppendControlWord("tab");
return this;
}
/// <summary>
/// Emit <paramref name="text"/> verbatim to RTF.
/// Caller's duty to generate valid RTF.
/// </summary>
/// <remarks>
/// Caller's job to end <paramref name="text"/>
/// with a blank if necessary.
/// </remarks>
public RichTextBuilder Raw(string text)
{
if (_needDelimiter)
{
_body.Append(' ');
}
_body.Append(text ?? string.Empty);
_needDelimiter = false; // assumption (see remarks)
return this;
}
public RichTextBuilder Begin()
{
_body.Append("{");
_groupNesting += 1;
_needDelimiter = false;
return this;
}
public RichTextBuilder End()
{
if (_groupNesting < 1)
{
throw new InvalidOperationException("No open group");
}
_body.Append("}");
_groupNesting -= 1;
_needDelimiter = false;
return this;
}
/// <summary>
/// End a paragraph by appending <c>\par</c> and closing the group.
/// </summary>
/// <remarks>
/// The paragraph must have been started with <see cref="Begin"/>.
/// </remarks>
public RichTextBuilder EndParagraph()
{
AppendControlWord("par");
return End();
}
public RichTextBuilder Font(string fontName, string family = null)
{
int i = 0;
for (; i < _fontTable.Count; i++)
{
if (string.Equals(fontName, _fontTable[i].Name))
{
break;
}
}
if (i < _fontTable.Count)
{
_fontTable[i] = new FontEntry(i, fontName, family);
}
else
{
_fontTable.Add(new FontEntry(i, fontName, family));
}
_body.AppendFormat(CultureInfo.InvariantCulture, @"\f{0}", i);
_needDelimiter = true;
return this;
}
/// <param name="fontSize">In typographic points</param>
public RichTextBuilder FontSize(double fontSize)
{
fontSize *= 2;
int halfpoints = (int)Math.Round(fontSize);
if (halfpoints < 5)
halfpoints = 5;
else if (halfpoints > 288)
halfpoints = 288;
AppendControlWord("fs", halfpoints);
return this;
}
public RichTextBuilder Bold()
{
AppendControlWord("b");
return this;
}
public RichTextBuilder Bold(string text)
{
return Begin().Bold().Text(text).End();
}
public RichTextBuilder BoldFormat(string format, params object[] args)
{
return Begin().Bold().TextFormat(format, args).End();
}
public RichTextBuilder Italic()
{
AppendControlWord("i");
return this;
}
public RichTextBuilder Italic(string text)
{
return Begin().Italic().Text(text).End();
}
public RichTextBuilder ItalicFormat(string format, params object[] args)
{
return Begin().Italic().TextFormat(format, args).End();
}
public RichTextBuilder Plain()
{
AppendControlWord("plain");
return this;
}
public RichTextBuilder Control(string word)
{
AppendControlWord(word);
return this;
}
public RichTextBuilder Control(string word, int value)
{
AppendControlWord(word, value);
return this;
}
public RichTextBuilder Control(char symbol)
{
AppendControlSymbol(symbol);
return this;
}
public string ToRtf()
{
if (_groupNesting > 0)
{
throw new InvalidOperationException("Open groups");
}
var sb = new StringBuilder();
sb.Append(@"{\rtf1\ansi");
if (_hasUnicodeChars)
{
// emit \ansicpg1252?
sb.Append(@"\uc1"); // presently, we use '*' as the ANSI equivalent for any Unicode char
}
sb.Append(@"\deff0");
EmitFontTable(sb);
sb.Append(_body);
sb.Append(@"}");
return sb.ToString();
}
public override string ToString()
{
return ToRtf();
}
#region Private stuff
/// <remarks>
/// Avoid exceedingly long lines by inserting a newline
/// every once in a while. Newlines are ignored by RTF,
/// except that they delimit commands.
/// </remarks>
private void CheckLineLimit()
{
if (LineLimit <= 0)
{
return; // not configured, allow arbitrarily long lines
}
if (_body.Length - _lastNewline >= LineLimit)
{
_body.AppendLine();
_lastNewline = _body.Length;
_needDelimiter = false; // the newline delimits commands
}
}
private void AppendControlWord(string word)
{
if (word == null)
throw new ArgumentNullException();
if (!CommandRegex.IsMatch(word))
throw new ArgumentException("control word must consist of letters in [a-z] only");
// No need to check _needDelimiter: the control word's
// backslash delimits any dangling control word.
CheckLineLimit();
_body.Append('\\').Append(word);
_needDelimiter = true;
}
private void AppendControlWord(string word, int value)
{
if (word == null)
throw new ArgumentNullException();
if (!CommandRegex.IsMatch(word))
throw new ArgumentException("control word must consist of letters in [a-z] only");
// No need to check _needDelimiter: the control word's
// backslash delimits any dangling control word.
CheckLineLimit();
_body.AppendFormat(CultureInfo.InvariantCulture, "\\{0}{1}", word, value);
_needDelimiter = true;
}
private void AppendControlSymbol(char symbol)
{
if (char.IsLetter(symbol))
{
throw new ArgumentException("A letter cannot be a control symbol");
}
// No need to check _needDelimiter: the control symbol's
// backslash delimits any dangling control word.
CheckLineLimit();
_body.Append('\\').Append(symbol);
_needDelimiter = false;
}
private void EmitFontTable(StringBuilder sb)
{
sb.AppendLine(@"{\fonttbl");
foreach (var entry in _fontTable)
{
sb.Append(@"{\f").Append(entry.Id);
if (!string.IsNullOrEmpty(entry.Family))
{
sb.Append(@"\f").Append(entry.Family);
}
sb.Append(" ").Append(entry.Name);
sb.AppendLine(";}");
}
sb.AppendLine("}");
}
private readonly struct FontEntry
{
public readonly int Id;
public readonly string Family;
public readonly string Name;
public FontEntry(int id, string name, string family = null)
{
if (id < 0)
throw new ArgumentException(@"font id must not be negative");
if (string.IsNullOrEmpty(name))
throw new ArgumentException(@"font name is required");
Id = id;
Name = name;
Family = family;
}
}
#endregion
}
}