Add custom RtfRenderer

This commit is contained in:
Caelan Sayler
2025-05-27 20:51:17 +01:00
parent 86269827bc
commit 76c551fda7
16 changed files with 528 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
#nullable enable
using Markdig.Syntax.Inlines;
namespace Velopack.Packaging.Rtf.Inlines;
/// <summary>
/// An RTF renderer for an <see cref="AutolinkInline"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{AutolinkInline}" />
public class RtfAutolinkInlineRenderer : RtfObjectRenderer<AutolinkInline>
{
public string? Rel { get; set; }
protected override void Write(RtfRenderer renderer, AutolinkInline obj)
{
// Render as underlined, blue text (simulating a link in RTF)
renderer.Write("{\\ul\\cf1 ");
renderer.WriteEscape(obj.Url);
renderer.Write("}");
}
}

View File

@@ -0,0 +1,19 @@
#nullable enable
using Markdig.Syntax.Inlines;
namespace Velopack.Packaging.Rtf.Inlines;
/// <summary>
/// An RTF renderer for a <see cref="CodeInline"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{CodeInline}" />
public class RtfCodeInlineRenderer : RtfObjectRenderer<CodeInline>
{
protected override void Write(RtfRenderer renderer, CodeInline obj)
{
renderer.Write("{\\fmodern ");
renderer.WriteEscape(obj.Content);
renderer.Write("}");
}
}

View File

@@ -0,0 +1,18 @@
#nullable enable
using Markdig.Syntax.Inlines;
namespace Velopack.Packaging.Rtf.Inlines;
/// <summary>
/// An RTF renderer for a <see cref="DelimiterInline"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{DelimiterInline}" />
public class RtfDelimiterInlineRenderer : RtfObjectRenderer<DelimiterInline>
{
protected override void Write(RtfRenderer renderer, DelimiterInline obj)
{
renderer.WriteEscape(obj.ToLiteral());
renderer.WriteChildren(obj);
}
}

View File

@@ -0,0 +1,32 @@
#nullable enable
using Markdig.Syntax.Inlines;
namespace Velopack.Packaging.Rtf.Inlines;
/// <summary>
/// An RTF renderer for an <see cref="EmphasisInline"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{EmphasisInline}" />
public class RtfEmphasisInlineRenderer : RtfObjectRenderer<EmphasisInline>
{
public delegate string? GetTagDelegate(EmphasisInline obj);
public GetTagDelegate? GetTag { get; set; }
protected override void Write(RtfRenderer renderer, EmphasisInline obj)
{
// Use bold or italic for emphasis
var tag = GetTag?.Invoke(obj) ?? GetDefaultTag(obj);
if (tag == "b")
renderer.Write("{\\b ");
else if (tag == "i")
renderer.Write("{\\i ");
renderer.WriteChildren(obj);
if (tag == "b" || tag == "i")
renderer.Write("}");
}
public static string? GetDefaultTag(EmphasisInline obj)
{
return obj.DelimiterChar == '*' || obj.DelimiterChar == '_' ? (obj.DelimiterCount == 2 ? "b" : "i") : null;
}
}

View File

@@ -0,0 +1,21 @@
#nullable enable
using Markdig.Syntax.Inlines;
namespace Velopack.Packaging.Rtf.Inlines;
/// <summary>
/// An RTF renderer for a <see cref="LineBreakInline"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{LineBreakInline}" />
public class RtfLineBreakInlineRenderer : RtfObjectRenderer<LineBreakInline>
{
public bool RenderAsHardlineBreak { get; set; }
protected override void Write(RtfRenderer renderer, LineBreakInline obj)
{
if (renderer.IsLastInContainer) return;
renderer.Write("\\line "); // RTF line break
renderer.EnsureLine();
}
}

View File

@@ -0,0 +1,30 @@
#nullable enable
using Markdig.Syntax.Inlines;
namespace Velopack.Packaging.Rtf.Inlines;
/// <summary>
/// An RTF renderer for a <see cref="LinkInline"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{LinkInline}" />
public class RtfLinkInlineRenderer : RtfObjectRenderer<LinkInline>
{
public string? Rel { get; set; }
protected override void Write(RtfRenderer renderer, LinkInline link)
{
if (link.IsImage)
{
// Skip images entirely in RTF output
return;
}
else
{
// Simulate link: underline, blue
renderer.Write("{\\ul\\cf1 ");
renderer.WriteChildren(link);
renderer.Write("}");
}
}
}

View File

@@ -0,0 +1,17 @@
#nullable enable
using Markdig.Syntax.Inlines;
namespace Velopack.Packaging.Rtf.Inlines;
/// <summary>
/// An RTF renderer for a <see cref="LiteralInline"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{LiteralInline}" />
public class RtfLiteralInlineRenderer : RtfObjectRenderer<LiteralInline>
{
protected override void Write(RtfRenderer renderer, LiteralInline obj)
{
renderer.WriteEscape(ref obj.Content);
}
}

View File

@@ -0,0 +1,20 @@
#nullable enable
using Markdig.Syntax;
namespace Velopack.Packaging.Rtf;
/// <summary>
/// An RTF renderer for a <see cref="HtmlBlock"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{HtmlBlock}" />
public class RtfBlockRenderer : RtfObjectRenderer<HtmlBlock>
{
protected override void Write(RtfRenderer renderer, HtmlBlock obj)
{
// Placeholder: Write the block as RTF raw text (should be adapted for real RTF output)
renderer.Write("{\\rtf1 ");
renderer.WriteLeafRawLines(obj, true, false);
renderer.Write("}\n");
}
}

View File

@@ -0,0 +1,32 @@
#nullable enable
using Markdig.Syntax;
namespace Velopack.Packaging.Rtf;
/// <summary>
/// An RTF renderer for a <see cref="CodeBlock"/> and <see cref="FencedCodeBlock"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{CodeBlock}" />
public class RtfCodeBlockRenderer : RtfObjectRenderer<CodeBlock>
{
public bool OutputAttributesOnPre { get; set; }
protected override void Write(RtfRenderer renderer, CodeBlock obj)
{
renderer.EnsureLine();
if (obj is FencedCodeBlock { Info: string info })
{
// For RTF, just render code in a monospaced block with a border
renderer.Write("{\\pard\\fmodern\\brdrb\\brdrs ");
renderer.WriteLeafRawLines(obj, true, true);
renderer.Write(" \\par}\n");
}
else
{
renderer.Write("{\\pard\\fmodern ");
renderer.WriteLeafRawLines(obj, true, true);
renderer.Write(" \\par}\n");
}
}
}

View File

@@ -0,0 +1,24 @@
#nullable enable
using Markdig.Syntax;
namespace Velopack.Packaging.Rtf;
/// <summary>
/// An RTF renderer for a <see cref="HeadingBlock"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{HeadingBlock}" />
public class RtfHeadingRenderer : RtfObjectRenderer<HeadingBlock>
{
protected override void Write(RtfRenderer renderer, HeadingBlock obj)
{
// Map heading levels to RTF font sizes (example: h1 = 32pt, h2 = 28pt, ...)
int[] fontSizes = [36, 30, 24, 20, 16, 14];
int level = obj.Level - 1;
int fontSize = (level >= 0 && level < fontSizes.Length) ? fontSizes[level] : 12;
renderer.Write($"\\line{{\\pard\\b\\fs{fontSize} "); // RTF font size is half-points
renderer.WriteLeafInline(obj);
renderer.Write(" \\par}\n");
}
}

View File

@@ -0,0 +1,37 @@
#nullable enable
using Markdig.Syntax;
namespace Velopack.Packaging.Rtf;
/// <summary>
/// An RTF renderer for a <see cref="ListBlock"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{ListBlock}" />
public class RtfListRenderer : RtfObjectRenderer<ListBlock>
{
protected override void Write(RtfRenderer renderer, ListBlock listBlock)
{
renderer.EnsureLine();
if (listBlock.IsOrdered)
{
renderer.Write("{\\pard ");
}
else
{
renderer.Write("{\\pard ");
}
foreach (var item in listBlock)
{
var listItem = (ListItemBlock)item;
var previousImplicit = renderer.ImplicitParagraph;
renderer.ImplicitParagraph = !listBlock.IsLoose;
renderer.EnsureLine();
renderer.Write("\\bullet "); // RTF bullet character
renderer.WriteChildren(listItem);
renderer.Write(" \\par");
renderer.ImplicitParagraph = previousImplicit;
}
renderer.Write("}\n");
}
}

View File

@@ -0,0 +1,16 @@
#nullable enable
using Markdig.Renderers;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Velopack.Packaging.Rtf;
/// <summary>
/// A base class for RTF rendering <see cref="Block"/> and <see cref="Inline"/> Markdown objects.
/// </summary>
/// <typeparam name="TObject">The type of the object.</typeparam>
/// <seealso cref="IMarkdownObjectRenderer" />
public abstract class RtfObjectRenderer<TObject> : MarkdownObjectRenderer<RtfRenderer, TObject> where TObject : MarkdownObject
{
}

View File

@@ -0,0 +1,42 @@
#nullable enable
using Markdig.Syntax;
namespace Velopack.Packaging.Rtf;
/// <summary>
/// An RTF renderer for a <see cref="ParagraphBlock"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{ParagraphBlock}" />
public class RtfParagraphRenderer : RtfObjectRenderer<ParagraphBlock>
{
protected override void Write(RtfRenderer renderer, ParagraphBlock obj)
{
if (!renderer.ImplicitParagraph)
{
renderer.Write("{\\pard "); // RTF paragraph start
}
renderer.WriteLeafInline(obj);
if (!renderer.ImplicitParagraph)
{
renderer.Write(" \\par}"); // RTF paragraph end
// Only write \line if next block is not a heading
bool nextIsHeading = false;
if (obj.Parent is ContainerBlock parent)
{
int index = parent.IndexOf(obj);
if (index >= 0 && index + 1 < parent.Count)
{
var next = parent[index + 1];
nextIsHeading = next is Markdig.Syntax.HeadingBlock;
}
}
if (!nextIsHeading)
{
renderer.Write("\\line ");
}
renderer.EnsureLine();
}
}
}

View File

@@ -0,0 +1,24 @@
#nullable enable
using Markdig.Syntax;
namespace Velopack.Packaging.Rtf;
/// <summary>
/// An RTF renderer for a <see cref="QuoteBlock"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{QuoteBlock}" />
public class RtfQuoteBlockRenderer : RtfObjectRenderer<QuoteBlock>
{
protected override void Write(RtfRenderer renderer, QuoteBlock obj)
{
renderer.EnsureLine();
renderer.Write("{\\pard\\li720 "); // Indent for quote
var savedImplicitParagraph = renderer.ImplicitParagraph;
renderer.ImplicitParagraph = false;
renderer.WriteChildren(obj);
renderer.ImplicitParagraph = savedImplicitParagraph;
renderer.Write(" \\par}\n");
renderer.EnsureLine();
}
}

View File

@@ -0,0 +1,157 @@
#nullable enable
using Markdig.Helpers;
using Markdig.Renderers;
using Markdig.Syntax;
namespace Velopack.Packaging.Rtf;
/// <summary>
/// Default RTF renderer for a Markdown <see cref="MarkdownDocument"/> object.
/// </summary>
/// <seealso cref="TextRendererBase{RtfRenderer}" />
public class RtfRenderer : TextRendererBase<RtfRenderer>
{
/// <summary>
/// Initializes a new instance of the <see cref="RtfRenderer"/> class.
/// </summary>
/// <param name="writer">The writer.</param>
public RtfRenderer(TextWriter writer) : base(writer)
{
// Default block renderers
ObjectRenderers.Add(new RtfCodeBlockRenderer());
ObjectRenderers.Add(new RtfListRenderer());
ObjectRenderers.Add(new RtfHeadingRenderer());
ObjectRenderers.Add(new RtfBlockRenderer());
ObjectRenderers.Add(new RtfParagraphRenderer());
ObjectRenderers.Add(new RtfQuoteBlockRenderer());
ObjectRenderers.Add(new RtfThematicBreakRenderer());
// Inline renderers
ObjectRenderers.Add(new Inlines.RtfAutolinkInlineRenderer());
ObjectRenderers.Add(new Inlines.RtfCodeInlineRenderer());
ObjectRenderers.Add(new Inlines.RtfDelimiterInlineRenderer());
ObjectRenderers.Add(new Inlines.RtfEmphasisInlineRenderer());
ObjectRenderers.Add(new Inlines.RtfLineBreakInlineRenderer());
ObjectRenderers.Add(new Inlines.RtfLinkInlineRenderer());
ObjectRenderers.Add(new Inlines.RtfLiteralInlineRenderer());
}
public bool ImplicitParagraph { get; set; }
/// <summary>
/// Writes the lines of a <see cref="LeafBlock"/>
/// </summary>
/// <param name="leafBlock">The leaf block.</param>
/// <param name="writeEndOfLines">if set to <c>true</c> write end of lines.</param>
/// <param name="escape">if set to <c>true</c> escape the content for RTF</param>
/// <param name="softEscape">Only escape minimal RTF chars</param>
/// <returns>This instance</returns>
public RtfRenderer WriteLeafRawLines(LeafBlock leafBlock, bool writeEndOfLines, bool escape, bool softEscape = false)
{
if (leafBlock is null) throw new ArgumentNullException(nameof(leafBlock));
var slices = leafBlock.Lines.Lines;
if (slices is not null) {
for (int i = 0; i < slices.Length; i++) {
ref StringSlice slice = ref slices[i].Slice;
if (slice.Text is null) {
break;
}
if (!writeEndOfLines && i > 0) {
WriteLine();
}
ReadOnlySpan<char> span = slice.AsSpan();
if (escape) {
WriteEscape(span, softEscape);
} else {
Write(span);
}
if (writeEndOfLines) {
WriteLine();
}
}
}
return this;
}
/// <summary>
/// Writes the content escaped for RTF, converting Unicode to RTF Unicode escapes.
/// </summary>
/// <param name="content">The string content.</param>
/// <param name="softEscape">If true, only escape minimal RTF chars.</param>
public void WriteEscape(string? content, bool softEscape = false)
{
if (content == null) return;
for (int i = 0; i < content.Length; i++) {
char c = content[i];
if (char.IsHighSurrogate(c) && i + 1 < content.Length && char.IsLowSurrogate(content[i + 1])) {
int codepoint = char.ConvertToUtf32(c, content[i + 1]);
Write($"\\u{codepoint}?"); // RTF spec: use '?' as fallback char
i++; // skip low surrogate
} else if (c == '\\') Write("\\\\");
else if (c == '{') Write("\\{");
else if (c == '}') Write("\\}");
else if (c < 0x20 || (c >= 0x7F && c <= 0x9F)) { } // skip control characters
else if (c <= 0x7F) // ASCII
Write(c);
else // BMP Unicode
Write($"\\u{(int) c}{c}");
}
}
/// <summary>
/// Writes the content escaped for RTF, converting Unicode to RTF Unicode escapes.
/// </summary>
/// <param name="span">The character span.</param>
/// <param name="softEscape">If true, only escape minimal RTF chars.</param>
public void WriteEscape(ReadOnlySpan<char> span, bool softEscape = false)
{
for (int i = 0; i < span.Length; i++) {
char c = span[i];
if (char.IsHighSurrogate(c) && i + 1 < span.Length && char.IsLowSurrogate(span[i + 1])) {
int codepoint = char.ConvertToUtf32(c, span[i + 1]);
Write($"\\u{codepoint}?"); // RTF spec: use '?' as fallback char
i++; // skip low surrogate
} else if (c == '\\') Write("\\\\");
else if (c == '{') Write("\\{");
else if (c == '}') Write("\\}");
else if (c < 0x20 || (c >= 0x7F && c <= 0x9F)) { } // skip control characters
else if (c <= 0x7F) // ASCII
Write(c);
else // BMP Unicode
Write($"\\u{(int) c}{c}");
}
}
public void WriteEscape(ref Markdig.Helpers.StringSlice slice, bool softEscape = false)
{
WriteEscape(slice.AsSpan(), softEscape);
}
public void WriteEscape(Markdig.Helpers.StringSlice slice, bool softEscape = false)
{
WriteEscape(slice.AsSpan(), softEscape);
}
/// <summary>
/// Writes the RTF document start block.
/// </summary>
public void WriteRtfStart()
{
WriteLine(@"{\rtf1\ansi\ansicpg1252\deff0\nouicompat\deflang1031{\fonttbl{\f0\fnil\fcharset0 Calibri;}}");
WriteLine(@"{\colortbl ;\red0\green0\blue0;}");
WriteLine(@"\viewkind4\uc1\pard\sa200\sl276\slmult1\f0\fs19\lang7");
}
/// <summary>
/// Writes the RTF document end block.
/// </summary>
public void WriteRtfEnd()
{
WriteLine("}");
}
}

View File

@@ -0,0 +1,17 @@
#nullable enable
using Markdig.Syntax;
namespace Velopack.Packaging.Rtf;
/// <summary>
/// An RTF renderer for a <see cref="ThematicBreakBlock"/>.
/// </summary>
/// <seealso cref="RtfObjectRenderer{ThematicBreakBlock}" />
public class RtfThematicBreakRenderer : RtfObjectRenderer<ThematicBreakBlock>
{
protected override void Write(RtfRenderer renderer, ThematicBreakBlock obj)
{
renderer.Write("{\\pard\\qr\\sl0\\slmult1\\line}\n"); // RTF horizontal rule (simulated with a line break)
}
}