A game framework written with osu! in mind.
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2// See the LICENCE file in the repository root for full licence text.
3
4using System;
5using System.Linq;
6using Markdig;
7using Markdig.Extensions.AutoIdentifiers;
8using Markdig.Extensions.Tables;
9using Markdig.Syntax;
10using Markdig.Syntax.Inlines;
11using osu.Framework.Allocation;
12using osu.Framework.Caching;
13using osu.Framework.Extensions.EnumExtensions;
14using osu.Framework.Graphics.Sprites;
15using osu.Framework.Utils;
16using osuTK;
17
18namespace osu.Framework.Graphics.Containers.Markdown
19{
20 /// <summary>
21 /// Visualises a markdown text document.
22 /// </summary>
23 [Cached(Type = typeof(IMarkdownTextComponent))]
24 [Cached(Type = typeof(IMarkdownTextFlowComponent))]
25 public class MarkdownContainer : CompositeDrawable, IMarkdownTextComponent, IMarkdownTextFlowComponent
26 {
27 private const int root_level = 0;
28
29 /// <summary>
30 /// Controls which <see cref="Axes"/> are automatically sized w.r.t. <see cref="CompositeDrawable.InternalChildren"/>.
31 /// Children's <see cref="Drawable.BypassAutoSizeAxes"/> are ignored for automatic sizing.
32 /// Most notably, <see cref="Drawable.RelativePositionAxes"/> and <see cref="Drawable.RelativeSizeAxes"/> of children
33 /// do not affect automatic sizing to avoid circular size dependencies.
34 /// It is not allowed to manually set <see cref="Drawable.Size"/> (or <see cref="Drawable.Width"/> / <see cref="Drawable.Height"/>)
35 /// on any <see cref="Axes"/> which are automatically sized.
36 /// </summary>
37 public new Axes AutoSizeAxes
38 {
39 get => base.AutoSizeAxes;
40 set
41 {
42 if (value.HasFlagFast(Axes.X))
43 throw new ArgumentException($"{nameof(MarkdownContainer)} does not support an {nameof(AutoSizeAxes)} of {value}");
44
45 base.AutoSizeAxes = value;
46 }
47 }
48
49 private string text = string.Empty;
50
51 /// <summary>
52 /// The text to visualise.
53 /// </summary>
54 public string Text
55 {
56 get => text;
57 set
58 {
59 if (text == value)
60 return;
61
62 text = value;
63
64 contentCache.Invalidate();
65 }
66 }
67
68 /// <summary>
69 /// The vertical spacing between lines.
70 /// </summary>
71 public virtual float LineSpacing
72 {
73 get => document.Spacing.Y;
74 set => document.Spacing = new Vector2(0, value);
75 }
76
77 /// <summary>
78 /// The margins of the contained document.
79 /// </summary>
80 public MarginPadding DocumentMargin
81 {
82 get => document.Margin;
83 set => document.Margin = value;
84 }
85
86 /// <summary>
87 /// The padding of the contained document.
88 /// </summary>
89 public MarginPadding DocumentPadding
90 {
91 get => document.Padding;
92 set => document.Padding = value;
93 }
94
95 private Uri documentUri;
96
97 /// <summary>
98 /// The URL of the loaded document.
99 /// </summary>
100 /// <exception cref="ArgumentException">If the provided URL was not a valid absolute URI.</exception>
101 protected string DocumentUrl
102 {
103 get => documentUri?.AbsoluteUri;
104 set
105 {
106 if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
107 throw new ArgumentException($"Document URL ({value}) must be an absolute URI.");
108
109 if (documentUri == uri)
110 return;
111
112 documentUri = uri;
113
114 contentCache.Invalidate();
115 }
116 }
117
118 private Uri rootUri;
119
120 /// <summary>
121 /// The base URL for all root-relative links.
122 /// </summary>
123 /// <exception cref="ArgumentException">If the provided URL was not a valid absolute URI.</exception>
124 protected string RootUrl
125 {
126 get => rootUri?.AbsoluteUri;
127 set
128 {
129 if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
130 throw new ArgumentException($"Root URL ({value}) must be an absolute URI.");
131
132 if (rootUri == uri)
133 return;
134
135 rootUri = uri;
136
137 contentCache.Invalidate();
138 }
139 }
140
141 private readonly Cached contentCache = new Cached();
142
143 private readonly FillFlowContainer document;
144
145 public MarkdownContainer()
146 {
147 InternalChild = document = new FillFlowContainer
148 {
149 AutoSizeAxes = Axes.Y,
150 RelativeSizeAxes = Axes.X,
151 Direction = FillDirection.Vertical,
152 };
153
154 LineSpacing = 25;
155 DocumentPadding = new MarginPadding { Left = 10, Right = 30 };
156 DocumentMargin = new MarginPadding { Left = 10, Right = 30 };
157 }
158
159 [BackgroundDependencyLoader]
160 private void load()
161 {
162 validateContent();
163 }
164
165 private void validateContent()
166 {
167 if (!contentCache.IsValid)
168 {
169 var markdownText = Text;
170 var pipeline = CreateBuilder();
171 var parsed = Markdig.Markdown.Parse(markdownText, pipeline);
172
173 // Turn all relative URIs in the document into absolute URIs
174 foreach (var link in parsed.Descendants().OfType<LinkInline>())
175 {
176 string url = link.Url;
177
178 if (string.IsNullOrEmpty(url))
179 continue;
180
181 if (!Validation.TryParseUri(url, out Uri linkUri))
182 continue;
183
184 if (linkUri.IsAbsoluteUri)
185 continue;
186
187 if (documentUri != null)
188 {
189 link.Url = rootUri != null && url.StartsWith('/')
190 // Ensure the URI is document-relative by removing all trailing slashes
191 ? new Uri(rootUri, new Uri(url.TrimStart('/'), UriKind.Relative)).AbsoluteUri
192 : new Uri(documentUri, new Uri(url, UriKind.Relative)).AbsoluteUri;
193 }
194 }
195
196 document.Clear();
197 foreach (var component in parsed)
198 AddMarkdownComponent(component, document, root_level);
199
200 contentCache.Validate();
201 }
202 }
203
204 protected override void Update()
205 {
206 base.Update();
207
208 validateContent();
209 }
210
211 public virtual MarkdownTextFlowContainer CreateTextFlow() => new MarkdownTextFlowContainer();
212
213 public virtual SpriteText CreateSpriteText() => new SpriteText();
214
215 /// <summary>
216 /// Adds a component that visualises a <see cref="IMarkdownObject"/> to the document.
217 /// </summary>
218 /// <param name="markdownObject">The <see cref="IMarkdownObject"/> to visualise.</param>
219 /// <param name="container">The container to add the visualisation to.</param>
220 /// <param name="level">The level in the document of <paramref name="markdownObject"/>.
221 /// 0 for the root level, 1 for first-level items in a list, 2 for second-level items in a list, etc.</param>
222 protected virtual void AddMarkdownComponent(IMarkdownObject markdownObject, FillFlowContainer container, int level)
223 {
224 switch (markdownObject)
225 {
226 case ThematicBreakBlock thematicBlock:
227 container.Add(CreateSeparator(thematicBlock));
228 break;
229
230 case HeadingBlock headingBlock:
231 container.Add(CreateHeading(headingBlock));
232 break;
233
234 case ParagraphBlock paragraphBlock:
235 container.Add(CreateParagraph(paragraphBlock, level));
236 break;
237
238 case QuoteBlock quoteBlock:
239 container.Add(CreateQuoteBlock(quoteBlock));
240 break;
241
242 case FencedCodeBlock fencedCodeBlock:
243 container.Add(CreateFencedCodeBlock(fencedCodeBlock));
244 break;
245
246 case Table table:
247 container.Add(CreateTable(table));
248 break;
249
250 case ListBlock listBlock:
251 var childContainer = CreateList(listBlock);
252 container.Add(childContainer);
253 foreach (var single in listBlock)
254 AddMarkdownComponent(single, childContainer, level + 1);
255 break;
256
257 case ListItemBlock listItemBlock:
258 foreach (var single in listItemBlock)
259 AddMarkdownComponent(single, container, level);
260 break;
261
262 case HtmlBlock _:
263 // HTML is not supported
264 break;
265
266 case LinkReferenceDefinitionGroup _:
267 // Link reference doesn't need to be displayed.
268 break;
269
270 default:
271 container.Add(CreateNotImplemented(markdownObject));
272 break;
273 }
274 }
275
276 /// <summary>
277 /// Creates the visualiser for a <see cref="HeadingBlock"/>.
278 /// </summary>
279 /// <param name="headingBlock">The <see cref="HeadingBlock"/> to visualise.</param>
280 /// <returns>The visualiser.</returns>
281 protected virtual MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new MarkdownHeading(headingBlock);
282
283 /// <summary>
284 /// Creates the visualiser for a <see cref="ParagraphBlock"/>.
285 /// </summary>
286 /// <param name="paragraphBlock">The <see cref="ParagraphBlock"/> to visualise.</param>
287 /// <param name="level">The level in the document of <paramref name="paragraphBlock"/>.
288 /// 0 for the root level, 1 for first-level items in a list, 2 for second-level items in a list, etc.</param>
289 /// <returns>The visualiser.</returns>
290 protected virtual MarkdownParagraph CreateParagraph(ParagraphBlock paragraphBlock, int level) => new MarkdownParagraph(paragraphBlock);
291
292 /// <summary>
293 /// Creates the visualiser for a <see cref="QuoteBlock"/>.
294 /// </summary>
295 /// <param name="quoteBlock">The <see cref="QuoteBlock"/> to visualise.</param>
296 /// <returns>The visualiser.</returns>
297 protected virtual MarkdownQuoteBlock CreateQuoteBlock(QuoteBlock quoteBlock) => new MarkdownQuoteBlock(quoteBlock);
298
299 /// <summary>
300 /// Creates the visualiser for a <see cref="FencedCodeBlock"/>.
301 /// </summary>
302 /// <param name="fencedCodeBlock">The <see cref="FencedCodeBlock"/> to visualise.</param>
303 /// <returns>The visualiser.</returns>
304 protected virtual MarkdownFencedCodeBlock CreateFencedCodeBlock(FencedCodeBlock fencedCodeBlock) => new MarkdownFencedCodeBlock(fencedCodeBlock);
305
306 /// <summary>
307 /// Creates the visualiser for a <see cref="Table"/>.
308 /// </summary>
309 /// <param name="table">The <see cref="Table"/> to visualise.</param>
310 /// <returns>The visualiser.</returns>
311 protected virtual MarkdownTable CreateTable(Table table) => new MarkdownTable(table);
312
313 /// <summary>
314 /// Creates the visualiser for a <see cref="ListBlock"/>.
315 /// </summary>
316 /// <returns>The visualiser.</returns>
317 protected virtual MarkdownList CreateList(ListBlock listBlock) => new MarkdownList();
318
319 /// <summary>
320 /// Creates the visualiser for a horizontal separator.
321 /// </summary>
322 /// <returns>The visualiser.</returns>
323 protected virtual MarkdownSeparator CreateSeparator(ThematicBreakBlock thematicBlock) => new MarkdownSeparator();
324
325 /// <summary>
326 /// Creates the visualiser for an element that isn't implemented.
327 /// </summary>
328 /// <param name="markdownObject">The <see cref="MarkdownObject"/> that isn't implemented.</param>
329 /// <returns>The visualiser.</returns>
330 protected virtual NotImplementedMarkdown CreateNotImplemented(IMarkdownObject markdownObject) => new NotImplementedMarkdown(markdownObject);
331
332 protected virtual MarkdownPipeline CreateBuilder()
333 => new MarkdownPipelineBuilder().UseAutoIdentifiers(AutoIdentifierOptions.GitHub)
334 .UseEmojiAndSmiley()
335 .UseAdvancedExtensions().Build();
336 }
337}