Added formulas from LaTeX code blocks and buttons to copy and save formulas (#1154)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-04 08:41:19 +01:00
1 parent f4a86c2fb8
commit acc7d09fa2
18 files changed
+942 -313

No files matched your search

@@ -239,23 +239,23 @@
{ {
<div @ref="this.mathContentContainer" class="chat-math-container"> <div @ref="this.mathContentContainer" class="chat-math-container">
<ContentPresentationView Presentation="@textContent.Presentation"> <ContentPresentationView Presentation="@textContent.Presentation">
@{ var renderPlan = this.GetMarkdownRenderPlan(textContent.Text); } @{
var renderPlan = this.GetMarkdownRenderPlan(textContent.Text);
RenderFragment<MessageFile> saveButton = file =>
@<MudTooltip Text="@this.SaveAsLabel(file)" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.Save" Color="Color.Primary" Variant="Variant.Filled" Class="ma-2" OnClick="@(() => this.ExportFile(file))"/>
</MudTooltip>;
}
@foreach (var segment in renderPlan.Segments) @foreach (var segment in renderPlan.Segments)
{ {
var segmentContent = segment.GetContent(renderPlan.Source); var segmentContent = segment.GetContent(renderPlan.Source);
if (segment.Type is MarkdownRenderSegmentType.MARKDOWN) if (segment.Type is MarkdownRenderSegmentType.MARKDOWN)
{ {
<ExportableMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" Files="@this.FilesWithSaveButton" ValueStart="@segment.Start"> <ExportableMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" Files="@this.FilesWithSaveButton" ValueStart="@segment.Start" CodeBlockActions="@saveButton"/>
<CodeBlockActions Context="file">
<MudTooltip Text="@this.SaveAsLabel(file)" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.Save" Color="Color.Primary" Variant="Variant.Filled" Class="ma-2" OnClick="@(() => this.ExportFile(file))"/>
</MudTooltip>
</CodeBlockActions>
</ExportableMarkdown>
} }
else else
{ {
<MathJaxBlock @key="@segment.RenderKey" Value="@segmentContent" Class="mb-5" /> <MathJaxBlock @key="@segment.RenderKey" Value="@segmentContent" Class="mb-5" File="@ExportableMarkdown.FileAt(this.FilesWithSaveButton, segment.FileStart)" Actions="@saveButton"/>
} }
} }
</ContentPresentationView> </ContentPresentationView>
@@ -13,33 +13,12 @@ public partial class ContentBlockComponent : MSGComponentBase
{ {
private const string CHAT_MATH_SYNC_FUNCTION = "chatMath.syncContainer"; private const string CHAT_MATH_SYNC_FUNCTION = "chatMath.syncContainer";
private const string CHAT_MATH_DISPOSE_FUNCTION = "chatMath.disposeContainer"; private const string CHAT_MATH_DISPOSE_FUNCTION = "chatMath.disposeContainer";
private const string HTML_START_TAG = "<";
private const string HTML_END_TAG = "</";
private const string HTML_SELF_CLOSING_TAG = "/>";
private const string CODE_FENCE_MARKER_BACKTICK = "```";
private const string CODE_FENCE_MARKER_TILDE = "~~~";
private const string MATH_BLOCK_MARKER_DOLLAR = "$$";
private const string MATH_BLOCK_MARKER_BRACKET_OPEN = """\[""";
private const string MATH_BLOCK_MARKER_BRACKET_CLOSE = """\]""";
private const string HTML_CODE_FENCE_PREFIX = "```html";
/// <summary> /// <summary>
/// How many files of an answer the export menu lists one by one; beyond that, it groups them by kind. /// How many files of an answer the export menu lists one by one; beyond that, it groups them by kind.
/// </summary> /// </summary>
private const int MAX_FILES_WITHOUT_SUBMENUS = 5; private const int MAX_FILES_WITHOUT_SUBMENUS = 5;
private static readonly string[] HTML_TAG_MARKERS =
[
"<!doctype",
"<html",
"<head",
"<body",
"<style",
"<script",
"<iframe",
"<svg",
];
/// <summary> /// <summary>
/// The role of the chat content block. /// The role of the chat content block.
/// </summary> /// </summary>
@@ -239,7 +218,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private bool GetsAIGeneratedContentDisclosure => this.Role is ChatRole.AI && this.AddsAIGeneratedContentDisclosure; private bool GetsAIGeneratedContentDisclosure => this.Role is ChatRole.AI && this.AddsAIGeneratedContentDisclosure;
/// <summary> /// <summary>
/// The files this block holds, tables and code blocks, so that the export menu can offer each of them. /// The files this block holds, tables, code blocks, and formulas, so that the export menu can offer each of them.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Cached the same way the Markdown render plan is: reading the files means parsing the whole /// Cached the same way the Markdown render plan is: reading the files means parsing the whole
@@ -261,7 +240,7 @@ public partial class ContentBlockComponent : MSGComponentBase
return this.cachedMessageFiles; return this.cachedMessageFiles;
this.cachedMessageFilesInput = markdown; this.cachedMessageFilesInput = markdown;
this.cachedMessageFiles = PlainFileExport.ExtractFiles(this.GetMarkdownRenderPlan(markdown).Source, this.csvSeparator); this.cachedMessageFiles = PlainFileExport.ExtractFiles(this.GetMarkdownRenderPlan(markdown), this.csvSeparator);
return this.cachedMessageFiles; return this.cachedMessageFiles;
} }
} }
@@ -559,7 +538,7 @@ public partial class ContentBlockComponent : MSGComponentBase
return this.cachedMarkdownRenderPlan; return this.cachedMarkdownRenderPlan;
this.cachedMarkdownRenderPlanInput = text; this.cachedMarkdownRenderPlanInput = text;
this.cachedMarkdownRenderPlan = BuildMarkdownRenderPlan(text); this.cachedMarkdownRenderPlan = MarkdownRenderPlanner.Build(text);
return this.cachedMarkdownRenderPlan; return this.cachedMarkdownRenderPlan;
} }
@@ -635,231 +614,6 @@ public partial class ContentBlockComponent : MSGComponentBase
: $"{mathSegmentCount}:{hash.ToHashCode()}"; : $"{mathSegmentCount}:{hash.ToHashCode()}";
} }
private static MarkdownRenderPlan BuildMarkdownRenderPlan(string text)
{
var normalized = NormalizeMarkdownForRendering(text);
if (string.IsNullOrWhiteSpace(normalized))
return MarkdownRenderPlan.EMPTY;
var normalizedSpan = normalized.AsSpan();
var segments = new List<MarkdownRenderSegment>();
var activeCodeFenceMarker = '\0';
var activeMathBlockFenceType = MathBlockFenceType.NONE;
var markdownSegmentStart = 0;
var mathContentStart = 0;
for (var lineStart = 0; lineStart < normalizedSpan.Length;)
{
var lineEnd = lineStart;
while (lineEnd < normalizedSpan.Length && normalizedSpan[lineEnd] is not '\r' and not '\n')
lineEnd++;
var nextLineStart = lineEnd;
if (nextLineStart < normalizedSpan.Length)
{
if (normalizedSpan[nextLineStart] == '\r')
nextLineStart++;
if (nextLineStart < normalizedSpan.Length && normalizedSpan[nextLineStart] == '\n')
nextLineStart++;
}
var trimmedLine = TrimWhitespace(normalizedSpan[lineStart..lineEnd]);
if (activeMathBlockFenceType is MathBlockFenceType.NONE && TryUpdateCodeFenceState(trimmedLine, ref activeCodeFenceMarker))
{
lineStart = nextLineStart;
continue;
}
if (activeCodeFenceMarker != '\0')
{
lineStart = nextLineStart;
continue;
}
if (activeMathBlockFenceType is MathBlockFenceType.NONE)
{
if (trimmedLine.SequenceEqual(MATH_BLOCK_MARKER_DOLLAR.AsSpan()))
{
AddMarkdownSegment(markdownSegmentStart, lineStart);
mathContentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.DOLLAR;
lineStart = nextLineStart;
continue;
}
if (trimmedLine.SequenceEqual(MATH_BLOCK_MARKER_BRACKET_OPEN.AsSpan()))
{
AddMarkdownSegment(markdownSegmentStart, lineStart);
mathContentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.BRACKET;
}
}
else if (activeMathBlockFenceType is MathBlockFenceType.DOLLAR && trimmedLine.SequenceEqual(MATH_BLOCK_MARKER_DOLLAR.AsSpan()))
{
var (start, end) = TrimLineBreaks(normalizedSpan, mathContentStart, lineStart);
segments.Add(new(MarkdownRenderSegmentType.MATH_BLOCK, start, end - start));
markdownSegmentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.NONE;
}
else if (activeMathBlockFenceType is MathBlockFenceType.BRACKET && trimmedLine.SequenceEqual(MATH_BLOCK_MARKER_BRACKET_CLOSE.AsSpan()))
{
var (start, end) = TrimLineBreaks(normalizedSpan, mathContentStart, lineStart);
segments.Add(new(MarkdownRenderSegmentType.MATH_BLOCK, start, end - start));
markdownSegmentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.NONE;
}
lineStart = nextLineStart;
}
if (activeMathBlockFenceType is not MathBlockFenceType.NONE)
return new(normalized, [new(MarkdownRenderSegmentType.MARKDOWN, 0, normalized.Length)]);
AddMarkdownSegment(markdownSegmentStart, normalized.Length);
if (segments.Count == 0)
segments.Add(new(MarkdownRenderSegmentType.MARKDOWN, 0, normalized.Length));
return new(normalized, segments);
void AddMarkdownSegment(int start, int end)
{
if (end <= start)
return;
segments.Add(new(MarkdownRenderSegmentType.MARKDOWN, start, end - start));
}
}
private static string NormalizeMarkdownForRendering(string text)
{
var textWithoutThinkTags = text.RemoveThinkTags();
var trimmed = TrimWhitespace(textWithoutThinkTags.AsSpan());
if (trimmed.IsEmpty)
return string.Empty;
var cleaned = trimmed.Length == textWithoutThinkTags.Length
? textWithoutThinkTags
: trimmed.ToString();
if (cleaned.Contains(CODE_FENCE_MARKER_BACKTICK, StringComparison.Ordinal))
return cleaned;
if (LooksLikeRawHtml(cleaned))
return $"{HTML_CODE_FENCE_PREFIX}{Environment.NewLine}{cleaned}{Environment.NewLine}{CODE_FENCE_MARKER_BACKTICK}";
return cleaned;
}
private static bool LooksLikeRawHtml(string text)
{
var content = text.AsSpan();
var start = 0;
while (start < content.Length && char.IsWhiteSpace(content[start]))
start++;
content = content[start..];
if (!content.StartsWith(HTML_START_TAG.AsSpan(), StringComparison.Ordinal))
return false;
foreach (var marker in HTML_TAG_MARKERS)
if (content.IndexOf(marker.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0)
return true;
return content.IndexOf(HTML_END_TAG.AsSpan(), StringComparison.Ordinal) >= 0
|| content.IndexOf(HTML_SELF_CLOSING_TAG.AsSpan(), StringComparison.Ordinal) >= 0;
}
private static bool TryUpdateCodeFenceState(ReadOnlySpan<char> trimmedLine, ref char activeCodeFenceMarker)
{
var fenceMarker = '\0';
if (trimmedLine.StartsWith(CODE_FENCE_MARKER_BACKTICK.AsSpan(), StringComparison.Ordinal))
fenceMarker = '`';
else if (trimmedLine.StartsWith(CODE_FENCE_MARKER_TILDE.AsSpan(), StringComparison.Ordinal))
fenceMarker = '~';
if (fenceMarker == '\0')
return false;
activeCodeFenceMarker = activeCodeFenceMarker == '\0'
? fenceMarker
: activeCodeFenceMarker == fenceMarker
? '\0'
: activeCodeFenceMarker;
return true;
}
private static ReadOnlySpan<char> TrimWhitespace(ReadOnlySpan<char> text)
{
var start = 0;
var end = text.Length - 1;
while (start < text.Length && char.IsWhiteSpace(text[start]))
start++;
while (end >= start && char.IsWhiteSpace(text[end]))
end--;
return start > end ? ReadOnlySpan<char>.Empty : text[start..(end + 1)];
}
private static (int Start, int End) TrimLineBreaks(ReadOnlySpan<char> text, int start, int end)
{
while (start < end && text[start] is '\r' or '\n')
start++;
while (end > start && text[end - 1] is '\r' or '\n')
end--;
return (start, end);
}
private enum MarkdownRenderSegmentType
{
MARKDOWN,
MATH_BLOCK,
}
private enum MathBlockFenceType
{
NONE,
DOLLAR,
BRACKET,
}
private sealed record MarkdownRenderPlan(string Source, IReadOnlyList<MarkdownRenderSegment> Segments)
{
public static readonly MarkdownRenderPlan EMPTY = new(string.Empty, []);
}
private sealed class MarkdownRenderSegment(MarkdownRenderSegmentType type, int start, int length)
{
private string? cachedContent;
public MarkdownRenderSegmentType Type { get; } = type;
public int Start { get; } = start;
public int Length { get; } = length;
public int RenderKey { get; } = HashCode.Combine(type, start, length);
public string GetContent(string source)
{
if (this.cachedContent is not null)
return this.cachedContent;
this.cachedContent = this.Start == 0 && this.Length == source.Length
? source
: source.Substring(this.Start, this.Length);
return this.cachedContent;
}
}
private async Task RemoveBlock() private async Task RemoveBlock()
{ {
if (this.RemoveBlockFunc is null || !this.BlockActionsEnabled()) if (this.RemoveBlockFunc is null || !this.BlockActionsEnabled())
+227
View File
@@ -0,0 +1,227 @@
using System.Collections.Frozen;
namespace AIStudio.Chat;
/// <summary>
/// Finds the formula in a code block which a model marked as latex, tex, or math.
/// </summary>
/// <remarks>
/// Models often put a single formula into such a block instead of between $$. The chat shows it as a
/// formula then, as it shows one between $$. A block of LaTeX which holds more than a formula, such as
/// a document or text with formulas in it, stays code: MathJax would typeset its text as one formula,
/// without spaces and in italics. When in doubt, a block stays code, since code shows what the model
/// wrote, and a broken formula does not.
/// </remarks>
internal static class LatexFormula
{
private const string LATEX_LANGUAGE = "latex";
private const string TEX_LANGUAGE = "tex";
/// <summary>
/// The language behind the fence of a block which holds nothing but a formula, as GitHub reads it.
/// </summary>
private const string MATH_LANGUAGE = "math";
private const string BEGIN_ENVIRONMENT = @"\begin{";
/// <summary>
/// How many letters a word of text has at least: shorter ones, such as mc in E = mc^2, are
/// variables of a formula.
/// </summary>
private const int MIN_LETTERS_OF_A_WORD = 3;
/// <summary>
/// From how many words on LaTeX is text: a formula names its functions with commands, such as
/// \sin, and writes words inside braces, such as \text{if}.
/// </summary>
private const int MIN_WORDS_OF_TEXT = 2;
/// <summary>
/// Commands which belong to a document or to its text, never to a formula.
/// </summary>
private static readonly string[] TEXT_COMMANDS =
[
@"\documentclass",
@"\usepackage",
@"\chapter",
@"\section",
@"\subsection",
@"\subsubsection",
@"\paragraph",
@"\title",
@"\author",
@"\maketitle",
@"\input",
@"\include",
@"\bibliography",
@"\item",
@"\caption",
];
/// <summary>
/// The environments of formulas which MathJax typesets. Any other environment, such as document,
/// itemize, or tabular, belongs to a document.
/// </summary>
private static readonly FrozenSet<string> MATH_ENVIRONMENTS = FrozenSet.Create(StringComparer.Ordinal,
"align", "align*", "alignat", "alignat*", "aligned", "alignedat", "array", "Bmatrix", "bmatrix", "cases", "CD",
"eqnarray", "eqnarray*", "equation", "equation*", "flalign", "flalign*", "gather", "gather*", "gathered", "matrix",
"multline", "multline*", "pmatrix", "smallmatrix", "split", "subarray", "Vmatrix", "vmatrix");
/// <summary>
/// The markers a model sometimes puts around the formula inside the block, too.
/// </summary>
private static readonly Delimiters[] MATH_DELIMITERS =
[
new("$$", "$$"),
new(@"\[", @"\]"),
new(@"\(", @"\)"),
new("$", "$"),
];
/// <summary>
/// Finds the formula in a code block.
/// </summary>
/// <param name="fenceLanguage">The language behind the opening fence, for example latex.</param>
/// <param name="code">The content of the block.</param>
/// <param name="formula">Where the formula stands in the content, without the markers around it.</param>
/// <returns>True when the block holds nothing but a formula.</returns>
public static bool TryFind(ReadOnlySpan<char> fenceLanguage, ReadOnlySpan<char> code, out Range formula)
{
formula = default;
var isMath = fenceLanguage.Equals(MATH_LANGUAGE, StringComparison.OrdinalIgnoreCase);
if (!isMath && !fenceLanguage.Equals(LATEX_LANGUAGE, StringComparison.OrdinalIgnoreCase) && !fenceLanguage.Equals(TEX_LANGUAGE, StringComparison.OrdinalIgnoreCase))
return false;
var start = 0;
var end = code.Length;
Trim(code, ref start, ref end);
foreach (var delimiters in MATH_DELIMITERS)
{
var text = code[start..end];
if (text.Length < delimiters.Open.Length + delimiters.Close.Length || !text.StartsWith(delimiters.Open, StringComparison.Ordinal) || !text.EndsWith(delimiters.Close, StringComparison.Ordinal))
continue;
start += delimiters.Open.Length;
end -= delimiters.Close.Length;
Trim(code, ref start, ref end);
break;
}
if (start >= end || (!isMath && !IsFormulaOnly(code[start..end])))
return false;
formula = start..end;
return true;
}
/// <summary>
/// Checks whether LaTeX is a formula and nothing else.
/// </summary>
/// <param name="latex">The LaTeX, without the markers around a formula.</param>
/// <returns>True when MathJax can typeset it as one formula.</returns>
internal static bool IsFormulaOnly(ReadOnlySpan<char> latex)
{
//
// Markers inside mean text with formulas in it, and so does an empty line, which ends a paragraph:
//
if (latex.Contains('$') || latex.Contains(@"\(", StringComparison.Ordinal) || HasEmptyLine(latex) || HasWordsOfText(latex))
return false;
foreach (var command in TEXT_COMMANDS)
if (latex.Contains(command, StringComparison.Ordinal))
return false;
var environments = MATH_ENVIRONMENTS.GetAlternateLookup<ReadOnlySpan<char>>();
var rest = latex;
while (true)
{
var begin = rest.IndexOf(BEGIN_ENVIRONMENT, StringComparison.Ordinal);
if (begin < 0)
return true;
rest = rest[(begin + BEGIN_ENVIRONMENT.Length)..];
var nameEnd = rest.IndexOf('}');
if (nameEnd < 0 || !environments.Contains(rest[..nameEnd]))
return false;
rest = rest[(nameEnd + 1)..];
}
}
/// <summary>
/// Checks whether LaTeX holds words of text outside of braces, such as a sentence.
/// </summary>
private static bool HasWordsOfText(ReadOnlySpan<char> latex)
{
var depth = 0;
var words = 0;
for (var position = 0; position < latex.Length;)
{
var character = latex[position];
if (character is '\\')
{
//
// A command, such as \frac, or a character after a backslash, such as \{, is no word:
//
position++;
if (position < latex.Length && char.IsLetter(latex[position]))
position = EndOfWord(latex, position);
else
position++;
continue;
}
if (char.IsLetter(character))
{
var end = EndOfWord(latex, position);
if (depth is 0 && end - position >= MIN_LETTERS_OF_A_WORD && ++words >= MIN_WORDS_OF_TEXT)
return true;
position = end;
continue;
}
if (character is '{')
depth++;
else if (character is '}' && depth > 0)
depth--;
position++;
}
return false;
}
private static int EndOfWord(ReadOnlySpan<char> latex, int start)
{
var end = start;
while (end < latex.Length && char.IsLetter(latex[end]))
end++;
return end;
}
private static bool HasEmptyLine(ReadOnlySpan<char> latex)
{
foreach (var line in latex.EnumerateLines())
if (line.IsWhiteSpace())
return true;
return false;
}
private static void Trim(ReadOnlySpan<char> text, ref int start, ref int end)
{
while (start < end && char.IsWhiteSpace(text[start]))
start++;
while (end > start && char.IsWhiteSpace(text[end - 1]))
end--;
}
/// <summary>
/// The markers before and after a formula.
/// </summary>
private sealed record Delimiters(string Open, string Close);
}
@@ -0,0 +1,12 @@
namespace AIStudio.Chat;
/// <summary>
/// How the chat renders the text of a message: in parts, Markdown and the formulas between them, see
/// MarkdownRenderPlanner.
/// </summary>
/// <param name="Source">The text as the chat renders it. The parts refer to it.</param>
/// <param name="Segments">The parts, in the order of the text.</param>
internal sealed record MarkdownRenderPlan(string Source, IReadOnlyList<MarkdownRenderSegment> Segments)
{
public static readonly MarkdownRenderPlan EMPTY = new(string.Empty, []);
}
@@ -0,0 +1,265 @@
namespace AIStudio.Chat;
/// <summary>
/// Cuts the text of a message into the parts the chat renders one by one: Markdown, and the formulas
/// between $$ or \[ and \] on lines of their own, or in a code block of latex, tex, or math which holds
/// nothing but a formula, see LatexFormula.
/// </summary>
/// <remarks>
/// MudMarkdown shows a formula as it is, and MathJax typesets only what the chat hands it as a
/// formula block. A formula therefore has to leave the Markdown around it. Inside a code block, $$ is
/// code, not a formula, and a formula block the model never closed leaves the whole text as Markdown.
/// </remarks>
internal static class MarkdownRenderPlanner
{
private const string HTML_START_TAG = "<";
private const string HTML_END_TAG = "</";
private const string HTML_SELF_CLOSING_TAG = "/>";
private const string CODE_FENCE_MARKER_BACKTICK = "```";
private const string CODE_FENCE_MARKER_TILDE = "~~~";
private const string MATH_BLOCK_MARKER_DOLLAR = "$$";
private const string MATH_BLOCK_MARKER_BRACKET_OPEN = """\[""";
private const string MATH_BLOCK_MARKER_BRACKET_CLOSE = """\]""";
private const string HTML_CODE_FENCE_PREFIX = "```html";
private static readonly string[] HTML_TAG_MARKERS =
[
"<!doctype",
"<html",
"<head",
"<body",
"<style",
"<script",
"<iframe",
"<svg",
];
/// <summary>
/// Cuts a text into the parts the chat renders.
/// </summary>
/// <param name="text">The text of a message, as stored.</param>
/// <returns>The plan, or MarkdownRenderPlan.EMPTY when the text shows nothing.</returns>
public static MarkdownRenderPlan Build(string text)
{
var normalized = NormalizeMarkdownForRendering(text);
if (string.IsNullOrWhiteSpace(normalized))
return MarkdownRenderPlan.EMPTY;
var normalizedSpan = normalized.AsSpan();
var segments = new List<MarkdownRenderSegment>();
var activeCodeFenceMarker = '\0';
var activeMathBlockFenceType = MathBlockFenceType.NONE;
var markdownSegmentStart = 0;
var mathContentStart = 0;
var mathBlockStart = 0;
var codeFenceStart = 0;
var codeContentStart = 0;
var codeFenceLanguage = ReadOnlySpan<char>.Empty;
for (var lineStart = 0; lineStart < normalizedSpan.Length;)
{
var lineEnd = lineStart;
while (lineEnd < normalizedSpan.Length && normalizedSpan[lineEnd] is not '\r' and not '\n')
lineEnd++;
var nextLineStart = lineEnd;
if (nextLineStart < normalizedSpan.Length)
{
if (normalizedSpan[nextLineStart] == '\r')
nextLineStart++;
if (nextLineStart < normalizedSpan.Length && normalizedSpan[nextLineStart] == '\n')
nextLineStart++;
}
var trimmedLine = TrimWhitespace(normalizedSpan[lineStart..lineEnd]);
var wasInCodeBlock = activeCodeFenceMarker != '\0';
if (activeMathBlockFenceType is MathBlockFenceType.NONE && TryUpdateCodeFenceState(trimmedLine, ref activeCodeFenceMarker))
{
if (!wasInCodeBlock)
{
codeFenceStart = lineStart;
codeContentStart = nextLineStart;
codeFenceLanguage = FenceLanguageOf(trimmedLine);
}
else if (activeCodeFenceMarker == '\0' && LatexFormula.TryFind(codeFenceLanguage, normalizedSpan[codeContentStart..lineStart], out var formula))
{
var (formulaStart, formulaLength) = formula.GetOffsetAndLength(lineStart - codeContentStart);
AddMarkdownSegment(markdownSegmentStart, codeFenceStart);
segments.Add(new(MarkdownRenderSegmentType.MATH_BLOCK, codeContentStart + formulaStart, formulaLength, codeFenceStart));
markdownSegmentStart = nextLineStart;
}
lineStart = nextLineStart;
continue;
}
if (activeCodeFenceMarker != '\0')
{
lineStart = nextLineStart;
continue;
}
if (activeMathBlockFenceType is MathBlockFenceType.NONE)
{
if (trimmedLine.SequenceEqual(MATH_BLOCK_MARKER_DOLLAR.AsSpan()))
{
AddMarkdownSegment(markdownSegmentStart, lineStart);
mathBlockStart = lineStart;
mathContentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.DOLLAR;
lineStart = nextLineStart;
continue;
}
if (trimmedLine.SequenceEqual(MATH_BLOCK_MARKER_BRACKET_OPEN.AsSpan()))
{
AddMarkdownSegment(markdownSegmentStart, lineStart);
mathBlockStart = lineStart;
mathContentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.BRACKET;
}
}
else if (activeMathBlockFenceType is MathBlockFenceType.DOLLAR && trimmedLine.SequenceEqual(MATH_BLOCK_MARKER_DOLLAR.AsSpan()))
{
segments.Add(MathSegment(normalizedSpan, mathContentStart, lineStart, mathBlockStart));
markdownSegmentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.NONE;
}
else if (activeMathBlockFenceType is MathBlockFenceType.BRACKET && trimmedLine.SequenceEqual(MATH_BLOCK_MARKER_BRACKET_CLOSE.AsSpan()))
{
segments.Add(MathSegment(normalizedSpan, mathContentStart, lineStart, mathBlockStart));
markdownSegmentStart = nextLineStart;
activeMathBlockFenceType = MathBlockFenceType.NONE;
}
lineStart = nextLineStart;
}
if (activeMathBlockFenceType is not MathBlockFenceType.NONE)
return new(normalized, [new(MarkdownRenderSegmentType.MARKDOWN, 0, normalized.Length, 0)]);
AddMarkdownSegment(markdownSegmentStart, normalized.Length);
if (segments.Count == 0)
segments.Add(new(MarkdownRenderSegmentType.MARKDOWN, 0, normalized.Length, 0));
return new(normalized, segments);
void AddMarkdownSegment(int start, int end)
{
if (end <= start)
return;
segments.Add(new(MarkdownRenderSegmentType.MARKDOWN, start, end - start, start));
}
}
private static string NormalizeMarkdownForRendering(string text)
{
var textWithoutThinkTags = text.RemoveThinkTags();
var trimmed = TrimWhitespace(textWithoutThinkTags.AsSpan());
if (trimmed.IsEmpty)
return string.Empty;
var cleaned = trimmed.Length == textWithoutThinkTags.Length
? textWithoutThinkTags
: trimmed.ToString();
if (cleaned.Contains(CODE_FENCE_MARKER_BACKTICK, StringComparison.Ordinal))
return cleaned;
if (LooksLikeRawHtml(cleaned))
return $"{HTML_CODE_FENCE_PREFIX}{Environment.NewLine}{cleaned}{Environment.NewLine}{CODE_FENCE_MARKER_BACKTICK}";
return cleaned;
}
private static bool LooksLikeRawHtml(string text)
{
var content = text.AsSpan();
var start = 0;
while (start < content.Length && char.IsWhiteSpace(content[start]))
start++;
content = content[start..];
if (!content.StartsWith(HTML_START_TAG.AsSpan(), StringComparison.Ordinal))
return false;
foreach (var marker in HTML_TAG_MARKERS)
if (content.IndexOf(marker.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0)
return true;
return content.IndexOf(HTML_END_TAG.AsSpan(), StringComparison.Ordinal) >= 0
|| content.IndexOf(HTML_SELF_CLOSING_TAG.AsSpan(), StringComparison.Ordinal) >= 0;
}
private static bool TryUpdateCodeFenceState(ReadOnlySpan<char> trimmedLine, ref char activeCodeFenceMarker)
{
var fenceMarker = '\0';
if (trimmedLine.StartsWith(CODE_FENCE_MARKER_BACKTICK.AsSpan(), StringComparison.Ordinal))
fenceMarker = '`';
else if (trimmedLine.StartsWith(CODE_FENCE_MARKER_TILDE.AsSpan(), StringComparison.Ordinal))
fenceMarker = '~';
if (fenceMarker == '\0')
return false;
activeCodeFenceMarker = activeCodeFenceMarker == '\0'
? fenceMarker
: activeCodeFenceMarker == fenceMarker
? '\0'
: activeCodeFenceMarker;
return true;
}
private static ReadOnlySpan<char> TrimWhitespace(ReadOnlySpan<char> text)
{
var start = 0;
var end = text.Length - 1;
while (start < text.Length && char.IsWhiteSpace(text[start]))
start++;
while (end >= start && char.IsWhiteSpace(text[end]))
end--;
return start > end ? ReadOnlySpan<char>.Empty : text[start..(end + 1)];
}
/// <summary>
/// Reads the language behind an opening fence, such as latex in ```latex.
/// </summary>
private static ReadOnlySpan<char> FenceLanguageOf(ReadOnlySpan<char> fence)
{
var info = TrimWhitespace(fence.TrimStart(fence[0]));
var end = 0;
while (end < info.Length && !char.IsWhiteSpace(info[end]))
end++;
return info[..end];
}
/// <summary>
/// The part of a formula block between its markers, without the line breaks around it.
/// </summary>
private static MarkdownRenderSegment MathSegment(ReadOnlySpan<char> text, int start, int end, int blockStart)
{
while (start < end && text[start] is '\r' or '\n')
start++;
while (end > start && text[end - 1] is '\r' or '\n')
end--;
return new(MarkdownRenderSegmentType.MATH_BLOCK, start, end - start, blockStart);
}
private enum MathBlockFenceType
{
NONE,
DOLLAR,
BRACKET,
}
}
@@ -0,0 +1,36 @@
namespace AIStudio.Chat;
/// <summary>
/// A part of a message which the chat renders on its own, see MarkdownRenderPlan.
/// </summary>
/// <param name="type">What the part is.</param>
/// <param name="start">Where the part starts in the source of its plan.</param>
/// <param name="length">How long the part is.</param>
/// <param name="fileStart">Where the block of the part starts in the source, its markers included,
/// such as the $$ before a formula. A formula finds its file by it, see PlainFileExport.ExtractFiles.</param>
internal sealed class MarkdownRenderSegment(MarkdownRenderSegmentType type, int start, int length, int fileStart)
{
private string? cachedContent;
public MarkdownRenderSegmentType Type { get; } = type;
public int Start { get; } = start;
public int Length { get; } = length;
public int FileStart { get; } = fileStart;
public int RenderKey { get; } = HashCode.Combine(type, start, length);
public string GetContent(string source)
{
if (this.cachedContent is not null)
return this.cachedContent;
this.cachedContent = this.Start == 0 && this.Length == source.Length
? source
: source.Substring(this.Start, this.Length);
return this.cachedContent;
}
}
@@ -0,0 +1,17 @@
namespace AIStudio.Chat;
/// <summary>
/// What a part of a message is, see MarkdownRenderPlan.
/// </summary>
internal enum MarkdownRenderSegmentType
{
/// <summary>
/// Markdown, which the chat renders with ExportableMarkdown.
/// </summary>
MARKDOWN,
/// <summary>
/// A formula of its own, which the chat renders with MathJaxBlock.
/// </summary>
MATH_BLOCK,
}
+11 -1
View File
@@ -1,5 +1,15 @@
@namespace AIStudio.Chat @namespace AIStudio.Chat
@using AIStudio.Components
<div class="@this.RootClass" data-chat-math-block="true" style="white-space: pre-wrap;"> <div class="@this.RootClass">
<div class="chat-mathjax-block" data-chat-math-block="true" style="white-space: pre-wrap;">
@this.MathText @this.MathText
</div>
<div class="formula-block-actions">
@if (this.File is not null && this.Actions is not null)
{
@this.Actions(this.File)
}
<CodeBlockCopyButton Text="@this.Value"/>
</div>
</div> </div>
@@ -2,17 +2,41 @@ using Microsoft.AspNetCore.Components;
namespace AIStudio.Chat; namespace AIStudio.Chat;
/// <summary>
/// A formula of its own in the chat, which MathJax typesets, with a button to copy it and the actions
/// of its file, such as a button to save it.
/// </summary>
/// <remarks>
/// The buttons stand next to the element MathJax typesets, not inside it: MathJax replaces the content
/// of that element, see chat-math.js.
/// </remarks>
public partial class MathJaxBlock public partial class MathJaxBlock
{ {
/// <summary>
/// The formula, without the markers around it. The copy button copies it like this.
/// </summary>
[Parameter] [Parameter]
public string Value { get; init; } = string.Empty; public string Value { get; init; } = string.Empty;
[Parameter] [Parameter]
public string Class { get; init; } = string.Empty; public string Class { get; init; } = string.Empty;
/// <summary>
/// The file of the formula, as PlainFileExport.ExtractFiles read it, or null when the formula
/// cannot be saved, for example in a message whose code blocks cannot be saved either.
/// </summary>
[Parameter]
public MessageFile? File { get; init; }
/// <summary>
/// What to show next to the copy button for the file, for example a button to save it.
/// </summary>
[Parameter]
public RenderFragment<MessageFile>? Actions { get; init; }
private string RootClass => string.IsNullOrWhiteSpace(this.Class) private string RootClass => string.IsNullOrWhiteSpace(this.Class)
? "chat-mathjax-block" ? "formula-block"
: $"chat-mathjax-block {this.Class}"; : $"formula-block {this.Class}";
private string MathText => $"$${Environment.NewLine}{this.Value}{Environment.NewLine}$$"; private string MathText => $"$${Environment.NewLine}{this.Value}{Environment.NewLine}$$";
} }
@@ -16,8 +16,8 @@ namespace AIStudio.Components;
/// A block finds its file by where it starts. The chat renders an answer in parts, cut around its /// A block finds its file by where it starts. The chat renders an answer in parts, cut around its
/// formulas, and Markdig reads each part on its own: a block knows where it starts within its part /// formulas, and Markdig reads each part on its own: a block knows where it starts within its part
/// only. Added to where the part starts in the answer, that is the position the file was read with. /// only. Added to where the part starts in the answer, that is the position the file was read with.
/// A block without a file, such as a formula or a block the model never closed, gets no actions, /// A block without a file, such as a block the model never closed, gets no actions, just as the export
/// just as the export menu does not offer it. /// menu does not offer it. The formulas between the parts get the same actions from MathJaxBlock.
/// </remarks> /// </remarks>
public sealed class ExportableMarkdown : HighlightedMarkdown public sealed class ExportableMarkdown : HighlightedMarkdown
{ {
+35 -23
View File
@@ -1,5 +1,6 @@
using System.Text; using System.Text;
using AIStudio.Chat;
using AIStudio.Tools.CodeHighlighting; using AIStudio.Tools.CodeHighlighting;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust; using AIStudio.Tools.Rust;
@@ -22,7 +23,7 @@ public static class PlainFileExport
/// Reads every file a message holds, in the order they appear in it. /// Reads every file a message holds, in the order they appear in it.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Two kinds of files end up in an answer. Almost always it is a Markdown table written with /// Three kinds of files end up in an answer. Almost always it is a Markdown table written with
/// pipes, which is what a model produces on its own; we turn its cells into a file. Besides, /// pipes, which is what a model produces on its own; we turn its cells into a file. Besides,
/// every fenced code block is a file. A model marks it as a format we write, such as html, latex, /// every fenced code block is a file. A model marks it as a format we write, such as html, latex,
/// markdown, or csv, whenever it was asked for a web page, a document, or data; any other block /// markdown, or csv, whenever it was asked for a web page, a document, or data; any other block
@@ -30,14 +31,16 @@ public static class PlainFileExport
/// the finished file: we hand it through untouched rather than taking it apart and reassembling /// the finished file: we hand it through untouched rather than taking it apart and reassembling
/// it. We do not judge what the block holds, either. A browser shows a fragment of HTML just as /// it. We do not judge what the block holds, either. A browser shows a fragment of HTML just as
/// well as an entire page, and a LaTeX fragment or a few lines of code are still what the user /// well as an entire page, and a LaTeX fragment or a few lines of code are still what the user
/// asked for. /// asked for. Last, every formula the chat shows on its own is a file of LaTeX, see
/// MarkdownRenderPlanner, whether the model wrote it between $$ or into a code block of LaTeX.
/// </remarks> /// </remarks>
/// <param name="markdown">The Markdown text of the message, as the chat renders it: the position /// <param name="plan">The text of the message as the chat renders it: the position of each file
/// of each file refers to this text.</param> /// refers to its source.</param>
/// <param name="separator">The separator to write a Markdown table with, see CsvWriter.SeparatorFor.</param> /// <param name="separator">The separator to write a Markdown table with, see CsvWriter.SeparatorFor.</param>
/// <returns>The files, or an empty list when the message holds none.</returns> /// <returns>The files, or an empty list when the message holds none.</returns>
public static IReadOnlyList<MessageFile> ExtractFiles(string markdown, char separator) internal static IReadOnlyList<MessageFile> ExtractFiles(MarkdownRenderPlan plan, char separator)
{ {
var markdown = plan.Source;
if (string.IsNullOrWhiteSpace(markdown)) if (string.IsNullOrWhiteSpace(markdown))
return []; return [];
@@ -50,48 +53,51 @@ public static class PlainFileExport
// //
// What a file is about stands above it, not in it: models introduce their tables and code // What a file is about stands above it, not in it: models introduce their tables and code
// blocks with a heading. We remember every heading with its line so that each file can take // blocks with a heading. We remember every heading with its position so that each file can
// the last one before it. A table falls back to its own first column heading when there is // take the last one before it. A table falls back to its own first column heading when there
// none; a code block has nothing comparable and stays without a caption. // is none; a code block has nothing comparable and stays without a caption.
// //
var headings = document.Descendants<HeadingBlock>() var headings = document.Descendants<HeadingBlock>()
.Select(heading => (heading.Line, Text: ToPlainText(heading))) .Select(heading => (Position: heading.Span.Start, Text: ToPlainText(heading)))
.Where(heading => !string.IsNullOrWhiteSpace(heading.Text)) .Where(heading => !string.IsNullOrWhiteSpace(heading.Text))
.OrderBy(heading => heading.Line) .OrderBy(heading => heading.Position)
.ToList(); .ToList();
var formulas = plan.Segments.Where(segment => segment.Type is MarkdownRenderSegmentType.MATH_BLOCK).ToList();
var tables = document.Descendants<Table>() var tables = document.Descendants<Table>()
.Select(table => (table.Line, Position: table.Span.Start, File: Read(table, separator))); .Select(table => (Position: table.Span.Start, File: Read(table, separator)));
// //
// Markdig reads a formula between $$ as a MathBlock, which is a fenced code block as well. // Markdig reads a formula between $$ as a MathBlock, which is a fenced code block as well, and
// Nobody saves a formula on its own, and as a block without a language, every formula of an // a code block of LaTeX the chat shows as a formula is a fenced code block, too. Each of them
// answer would end up in the menu as a text file: // is a formula of the plan instead, which the chat renders with the button to save it:
// //
var codeBlocks = document.Descendants<FencedCodeBlock>() var codeBlocks = document.Descendants<FencedCodeBlock>()
.Where(block => block is not MathBlock) .Where(block => block is not MathBlock && !formulas.Any(formula => formula.FileStart <= block.Span.Start && block.Span.Start < formula.Start))
.Select(block => (block.Line, Position: block.Span.Start, File: Read(block))); .Select(block => (Position: block.Span.Start, File: Read(block)));
var formulaFiles = formulas.Select(formula => (Position: formula.FileStart, File: (FoundFile?)ReadFormula(formula.GetContent(markdown))));
// //
// Tables and code blocks are counted apart. The menu falls back to that number when a // Tables and code blocks are counted apart, formulas among the code blocks. The menu falls
// heading cannot tell two files apart, and "Table 2" has to be the second table of the // back to that number when a heading cannot tell two files apart, and "Table 2" has to be
// answer, not the second entry of the menu. // the second table of the answer, not the second entry of the menu.
// //
var numberOfTables = 0; var numberOfTables = 0;
var numberOfCodeBlocks = 0; var numberOfCodeBlocks = 0;
return tables.Concat(codeBlocks) return tables.Concat(codeBlocks).Concat(formulaFiles)
.Where(entry => entry.File is not null) .Where(entry => entry.File is not null)
.OrderBy(entry => entry.Line) .OrderBy(entry => entry.Position)
.Select(entry => new MessageFile( .Select(entry => new MessageFile(
entry.File!.Format.IsTabular() ? ++numberOfTables : ++numberOfCodeBlocks, entry.File!.Format.IsTabular() ? ++numberOfTables : ++numberOfCodeBlocks,
entry.Position, entry.Position,
Caption: HeadingAbove(entry.Line) is { Length: > 0 } heading ? heading : entry.File!.Fallback, Caption: HeadingAbove(entry.Position) is { Length: > 0 } heading ? heading : entry.File!.Fallback,
entry.File!.Format, entry.File!.Format,
entry.File!.Language, entry.File!.Language,
entry.File!.Content)) entry.File!.Content))
.ToList(); .ToList();
string HeadingAbove(int line) => headings.LastOrDefault(heading => heading.Line < line).Text ?? string.Empty; string HeadingAbove(int position) => headings.LastOrDefault(heading => heading.Position < position).Text ?? string.Empty;
} }
/// <summary> /// <summary>
@@ -103,6 +109,12 @@ public static class PlainFileExport
/// <param name="Content">The finished file content.</param> /// <param name="Content">The finished file content.</param>
private sealed record FoundFile(string Fallback, FileExportFormat Format, SourceCodeLanguage Language, string Content); private sealed record FoundFile(string Fallback, FileExportFormat Format, SourceCodeLanguage Language, string Content);
/// <summary>
/// Turns a formula into a file of LaTeX, between \[ and \] as a formula of its own, so that it can be
/// pasted into a document as it is.
/// </summary>
private static FoundFile ReadFormula(string formula) => new(string.Empty, FileExportFormat.LATEX, SourceCodeLanguage.NONE, $@"\[{Environment.NewLine}{formula}{Environment.NewLine}\]");
/// <summary> /// <summary>
/// Turns a Markdown table into a file. /// Turns a Markdown table into a file.
/// </summary> /// </summary>
+22
View File
@@ -306,6 +306,28 @@
margin-right: 0 !important; margin-right: 0 !important;
} }
/* The buttons of a formula, such as copying and saving it, appear while the pointer is on it, as they do at a code block */
.formula-block {
position: relative;
}
.formula-block > .formula-block-actions {
position: absolute;
display: none;
top: 0;
right: 0;
}
.formula-block:hover > .formula-block-actions {
display: flex;
}
/* The copy button stands in the row of the other buttons, not in the corner of a code block */
.formula-block > .formula-block-actions .mud-markdown-code-highlight-copybtn {
position: static;
display: block;
}
.log-viewer-shell { .log-viewer-shell {
min-width: 0; min-width: 0;
} }
@@ -1,5 +1,7 @@
# v26.11.1, build 260 (2026-11-xx xx:xx UTC) # v26.11.1, build 260 (2026-11-xx xx:xx UTC)
- Added a setting for the size of the interface, which you find in the app settings. You can choose a size for each of your screens, and AI Studio switches to it when you move its window to another screen. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution. - Added a setting for the size of the interface, which you find in the app settings. You can choose a size for each of your screens, and AI Studio switches to it when you move its window to another screen. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution.
- Added formulas in code blocks to the chat: when an AI model puts a single formula into a code block marked as LaTeX, the chat now shows the formula instead of its code. A code block with a LaTeX document or with text in it stays code.
- Added buttons to copy and save the formulas in the chat. When you point at a formula, you can copy its LaTeX code or save it as a LaTeX file, and the export menu of an answer offers its formulas as well.
- Improved the documentation for IT departments, which now lives in our [wiki](https://github.com/MindWorkAI/AI-Studio/wiki/Enterprise-IT). It is sorted by task, explains the setup on Windows, Linux, and macOS, and offers complete configurations for typical needs, such as using only local models. The link in the app settings leads there, and the wiki also holds the installation guide in English and German. - Improved the documentation for IT departments, which now lives in our [wiki](https://github.com/MindWorkAI/AI-Studio/wiki/Enterprise-IT). It is sorted by task, explains the setup on Windows, Linux, and macOS, and offers complete configurations for typical needs, such as using only local models. The link in the app settings leads there, and the wiki also holds the installation guide in English and German.
- Improved the German translation throughout: we proofread all texts and corrected wrong labels as well as spelling and grammar mistakes. The texts now always address you formally and use the same terms everywhere. - Improved the German translation throughout: we proofread all texts and corrected wrong labels as well as spelling and grammar mistakes. The texts now always address you formally and use the same terms everywhere.
- Improved the security of the connection between the app window and AI Studio. - Improved the security of the connection between the app window and AI Studio.
+1 -1
View File
@@ -327,7 +327,7 @@ public sealed class IContentExtensionsTests
private static IReadOnlyList<MessageFile> FilesOf(IContent content) private static IReadOnlyList<MessageFile> FilesOf(IContent content)
{ {
content.TryGetMarkdownText(out var markdown); content.TryGetMarkdownText(out var markdown);
return PlainFileExport.ExtractFiles(markdown, ','); return PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(markdown), ',');
} }
/// <summary> /// <summary>
+69
View File
@@ -0,0 +1,69 @@
using AIStudio.Chat;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks which code blocks of LaTeX the chat shows as a formula.
/// </summary>
[TestFixture]
public sealed class LatexFormulaTests
{
[TestCase(@"\frac{a}{b}")]
[TestCase(@"\iint_D f(x,y)\,\mathrm{d}x\,\mathrm{d}y")]
[TestCase(@"\text{if } x > 0", Description = "Text inside a formula is part of it.")]
[TestCase("a^2\n+ b^2\n= c^2", Description = "A formula may take several lines.")]
[TestCase(@"\begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix}")]
[TestCase("\\begin{align}\n a &= b \\\\\n c &= d\n\\end{align}")]
[TestCase(@"f(x) = \begin{cases} 1 & x > 0 \\ 0 & \text{otherwise} \end{cases}")]
[TestCase(@"x_{max} = \max_i x_i", Description = "A word inside braces is part of the formula.")]
[TestCase(@"\lim_{n \to \infty} \left\{ a_n \right\}")]
public void AFormulaIsAFormula(string latex)
{
Assert.That(LatexFormula.IsFormulaOnly(latex), Is.True);
}
[TestCase("\\documentclass{article}\n\\begin{document}\nHello\n\\end{document}", TestName = "A document")]
[TestCase(@"\section{Results} The energy grows.", TestName = "A section")]
[TestCase(@"\begin{itemize} \item One \end{itemize}", TestName = "A list")]
[TestCase(@"\begin{tabular}{ll} a & b \end{tabular}", TestName = "A table")]
[TestCase(@"\includegraphics{plot.png}", TestName = "A figure")]
[TestCase("The value $x$ grows.", TestName = "Text with a formula in it")]
[TestCase(@"The value \(x\) grows.", TestName = "Text with a formula in parentheses in it")]
[TestCase("a^2 + b^2 = c^2\n\nE = mc^2", TestName = "Two formulas apart")]
[TestCase("The content.", TestName = "A sentence")]
[TestCase("Let x be a number.", TestName = "A sentence with short words")]
[TestCase(@"\begin{align", TestName = "An environment which never opens")]
public void MoreThanAFormulaIsNoFormula(string latex)
{
Assert.That(LatexFormula.IsFormulaOnly(latex), Is.False, "MathJax would typeset it all as one formula.");
}
[TestCase("latex", " E = mc^2 ", "E = mc^2")]
[TestCase("tex", "E = mc^2", "E = mc^2")]
[TestCase("LaTeX", "E = mc^2", "E = mc^2", Description = "The language is read in any case.")]
[TestCase("latex", "$$\nE = mc^2\n$$", "E = mc^2", Description = "The markers inside the block are no part of the formula.")]
[TestCase("latex", @"\[ E = mc^2 \]", "E = mc^2")]
[TestCase("latex", @"\(E = mc^2\)", "E = mc^2")]
[TestCase("latex", "$E = mc^2$", "E = mc^2")]
[TestCase("math", @"\section{x}", @"\section{x}", Description = "A block of math holds a formula only, whatever it looks like.")]
public void TheFormulaOfACodeBlockIsFound(string fenceLanguage, string code, string expected)
{
var found = LatexFormula.TryFind(fenceLanguage, code, out var formula);
using (Assert.EnterMultipleScope())
{
Assert.That(found, Is.True);
Assert.That(found ? code[formula] : null, Is.EqualTo(expected));
}
}
[TestCase("latex", "", TestName = "An empty block")]
[TestCase("math", "$$ $$", TestName = "Nothing but markers")]
[TestCase("latex", @"\section{Results}", TestName = "A section of a document")]
[TestCase("python", "E = mc^2", TestName = "Code in another language")]
[TestCase("", "E = mc^2", TestName = "A block without a language")]
public void ACodeBlockWithoutAFormulaHasNone(string fenceLanguage, string code)
{
Assert.That(LatexFormula.TryFind(fenceLanguage, code, out _), Is.False);
}
}
@@ -0,0 +1,125 @@
using AIStudio.Chat;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks how the chat cuts a message into the parts it renders: Markdown, and the formulas between them.
/// </summary>
[TestFixture]
public sealed class MarkdownRenderPlannerTests
{
[Test]
public void ATextWithoutFormulasStaysOnePart()
{
Assert.That(Parts("Hello, **world**."), Is.EqualTo(new[] { "MARKDOWN: Hello, **world**." }));
}
[TestCase("$$", "$$")]
[TestCase(@"\[", @"\]")]
public void AFormulaOnLinesOfItsOwnBecomesAPartOfItsOwn(string openingMarker, string closingMarker)
{
var parts = Parts($"The energy follows from\n{openingMarker}\nE = mc^2\n{closingMarker}\nfor every body at rest.");
Assert.That(parts, Is.EqualTo(new[] { "MARKDOWN: The energy follows from\n", "MATH_BLOCK: E = mc^2", "MARKDOWN: for every body at rest." }));
}
[Test]
public void AFormulaInAListIsFoundAsWell()
{
var parts = Parts("1. The energy:\n $$\n E = mc^2\n $$\n2. The mass");
Assert.That(parts, Is.EqualTo(new[] { "MARKDOWN: 1. The energy:\n", "MATH_BLOCK: E = mc^2", "MARKDOWN: 2. The mass" }));
}
[TestCase("```")]
[TestCase("~~~")]
public void AFormulaInACodeBlockIsCode(string fence)
{
var text = $"Write it like this:\n{fence}\n$$\nE = mc^2\n$$\n{fence}";
Assert.That(Parts(text), Is.EqualTo(new[] { $"MARKDOWN: {text}" }));
}
[TestCase("```latex")]
[TestCase("```tex")]
[TestCase("```math")]
[TestCase("```LaTeX")]
[TestCase("``` latex")]
[TestCase("~~~latex")]
public void ACodeBlockOfLatexWithNothingButAFormulaBecomesAFormula(string openingFence)
{
var parts = Parts($"The integral:\n{openingFence}\n\\iint_D f(x,y)\\,\\mathrm{{d}}x\\,\\mathrm{{d}}y\n{openingFence[..3]}\nover the area D.");
Assert.That(parts, Is.EqualTo(new[] { "MARKDOWN: The integral:\n", @"MATH_BLOCK: \iint_D f(x,y)\,\mathrm{d}x\,\mathrm{d}y", "MARKDOWN: over the area D." }));
}
[Test]
public void TheMarkersAroundAFormulaInACodeBlockOfLatexAreNoPartOfIt()
{
Assert.That(Parts("```latex\n$$\nE = mc^2\n$$\n```"), Is.EqualTo(new[] { "MATH_BLOCK: E = mc^2" }));
}
[TestCase("```latex\n\\documentclass{article}\n\\begin{document}\nHello\n\\end{document}\n```", TestName = "A document")]
[TestCase("```latex\nThe value $x$ grows.\n```", TestName = "Text with a formula in it")]
[TestCase("```latex\n```", TestName = "An empty block")]
[TestCase("```python\nE = mc^2\n```", TestName = "Code in another language")]
[TestCase("```latex\nE = mc^2", TestName = "A block the model never closed")]
public void ACodeBlockOfLatexWithMoreThanAFormulaStaysCode(string text)
{
Assert.That(Parts(text), Is.EqualTo(new[] { $"MARKDOWN: {text}" }));
}
[Test]
public void EveryFormulaKnowsWhereItsBlockStarts()
{
var text = "First:\n$$\na = b\n$$\nSecond:\n\\[\nc = d\n\\]\nThird:\n```latex\ne = f\n```\nFourth:\n1. Item\n $$\n g = h\n $$";
var formulas = MarkdownRenderPlanner.Build(text).Segments.Where(segment => segment.Type is MarkdownRenderSegmentType.MATH_BLOCK).Select(segment => segment.FileStart);
Assert.That(formulas, Is.EqualTo(new[] { text.IndexOf("$$", StringComparison.Ordinal), text.IndexOf(@"\[", StringComparison.Ordinal), text.IndexOf("```latex", StringComparison.Ordinal), text.IndexOf(" $$", StringComparison.Ordinal) }), "A formula starts with its markers, an indented one with the line of its marker.");
}
[Test]
public void MarkdownStartsWhereItsBlockStarts()
{
var plan = MarkdownRenderPlanner.Build("Before\n$$\na = b\n$$\nAfter");
Assert.That(plan.Segments.Where(segment => segment.Type is MarkdownRenderSegmentType.MARKDOWN).Select(segment => segment.FileStart - segment.Start), Is.All.Zero);
}
[Test]
public void AFormulaTheModelNeverClosedLeavesEverythingAsMarkdown()
{
var text = "The first formula:\n$$\na^2 + b^2 = c^2\n$$\nThe second one broke off:\n$$\nE = mc^2";
Assert.That(Parts(text), Is.EqualTo(new[] { $"MARKDOWN: {text}" }));
}
[Test]
public void AnAnswerOfRawHtmlBecomesACodeBlock()
{
var parts = Parts("<html><body><p>Hi</p></body></html>");
Assert.That(parts, Is.EqualTo(new[] { $"MARKDOWN: ```html{Environment.NewLine}<html><body><p>Hi</p></body></html>{Environment.NewLine}```" }));
}
[Test]
public void TheThinkingOfAModelIsNotShown()
{
Assert.That(Parts("<think>Let me see.</think>\n\nThe answer."), Is.EqualTo(new[] { "MARKDOWN: The answer." }));
}
[TestCase("")]
[TestCase(" \n ")]
[TestCase("<think>Still thinking", Description = "A model which is still thinking shows nothing yet.")]
public void NothingToShowHasNoParts(string text)
{
Assert.That(MarkdownRenderPlanner.Build(text), Is.SameAs(MarkdownRenderPlan.EMPTY));
}
private static IEnumerable<string> Parts(string text)
{
var plan = MarkdownRenderPlanner.Build(text);
return plan.Segments.Select(segment => $"{segment.Type}: {segment.GetContent(plan.Source)}");
}
}
@@ -1,3 +1,4 @@
using AIStudio.Chat;
using AIStudio.Components; using AIStudio.Components;
using AIStudio.Tools; using AIStudio.Tools;
@@ -33,20 +34,50 @@ public sealed class ExportableMarkdownTests
"<p>100</p>", "<p>100</p>",
"```"); "```");
var files = PlainFileExport.ExtractFiles(answer, ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(answer), ',');
var foundFiles = Render(answer).Descendants<FencedCodeBlock>().Select(block => ExportableMarkdown.FileAt(files, block.Span.Start)); var foundFiles = Render(answer).Descendants<FencedCodeBlock>().Select(block => ExportableMarkdown.FileAt(files, block.Span.Start));
Assert.That(foundFiles, Is.EqualTo(new[] { files[1], files[2] }), "The table comes first among the files, but it is no code block."); Assert.That(foundFiles, Is.EqualTo(new[] { files[1], files[2] }), "The table comes first among the files, but it is no code block.");
} }
[Test] [Test]
public void AFormulaFindsNoFile() public void AFormulaIsNoPartOfTheMarkdown()
{ {
var answer = Lines("1. The energy follows from", string.Empty, " $$", " E = mc^2", " $$"); var answer = Lines("1. The energy follows from", string.Empty, " $$", " E = mc^2", " $$");
var formula = Render(answer).Descendants<MathBlock>().Single(); var plan = MarkdownRenderPlanner.Build(answer);
var formulasOfTheMarkdown = plan.Segments
.Where(segment => segment.Type is MarkdownRenderSegmentType.MARKDOWN)
.SelectMany(segment => Render(segment.GetContent(plan.Source)).Descendants<MathBlock>());
Assert.That(ExportableMarkdown.FileAt(PlainFileExport.ExtractFiles(answer, ','), formula.Span.Start), Is.Null, "Markdig reads a formula as a fenced block, but nobody saves a formula as a text file."); Assert.That(formulasOfTheMarkdown, Is.Empty, "The chat renders a formula and its actions itself, so the Markdown around it holds no formula to give a code block's actions to.");
}
[Test]
public void EveryFormulaFindsItsFile()
{
var answer = Lines(
"$$",
"a = b",
"$$",
@"\[",
"c = d",
@"\]",
"```latex",
"e = f",
"```",
"1. Item",
" $$",
" g = h",
" $$");
var plan = MarkdownRenderPlanner.Build(answer);
var files = PlainFileExport.ExtractFiles(plan, ',');
var foundFiles = plan.Segments
.Where(segment => segment.Type is MarkdownRenderSegmentType.MATH_BLOCK)
.Select(segment => ExportableMarkdown.FileAt(files, segment.FileStart)?.Format);
Assert.That(foundFiles, Is.EqualTo(Enumerable.Repeat<FileExportFormat?>(FileExportFormat.LATEX, 4)), "The chat looks a formula's file up where its block starts, so that the formula gets the button to save it.");
} }
[Test] [Test]
@@ -56,7 +87,7 @@ public sealed class ExportableMarkdownTests
var block = Render(answer).Descendants<FencedCodeBlock>().Single(); var block = Render(answer).Descendants<FencedCodeBlock>().Single();
Assert.That(ExportableMarkdown.FileAt(PlainFileExport.ExtractFiles(answer, ','), block.Span.Start), Is.Null, "The file would end wherever the answer broke off."); Assert.That(ExportableMarkdown.FileAt(PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(answer), ','), block.Span.Start), Is.Null, "The file would end wherever the answer broke off.");
} }
private static MarkdownDocument Render(string answer) => Markdig.Markdown.Parse(answer, Markdown.CHAT_MARKDOWN_PIPELINE); private static MarkdownDocument Render(string answer) => Markdig.Markdown.Parse(answer, Markdown.CHAT_MARKDOWN_PIPELINE);
+44 -21
View File
@@ -1,5 +1,6 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using AIStudio.Chat;
using AIStudio.Tools; using AIStudio.Tools;
using Markdig.Syntax; using Markdig.Syntax;
@@ -23,7 +24,7 @@ public sealed class PlainFileExportTests
[Test] [Test]
public void AnAnswerMadeOfOneWebPageOffersThatPage() public void AnAnswerMadeOfOneWebPageOffersThatPage()
{ {
var files = PlainFileExport.ExtractFiles(Lines("```html", PAGE, "```"), ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(Lines("```html", PAGE, "```")), ',');
using (Assert.EnterMultipleScope()) using (Assert.EnterMultipleScope())
{ {
@@ -39,7 +40,7 @@ public sealed class PlainFileExportTests
{ {
var answer = Lines("Here is your page:", string.Empty, "```html", PAGE, "```", string.Empty, "Save it and open it in your browser."); var answer = Lines("Here is your page:", string.Empty, "```html", PAGE, "```", string.Empty, "Save it and open it in your browser.");
var files = PlainFileExport.ExtractFiles(answer, ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(answer), ',');
using (Assert.EnterMultipleScope()) using (Assert.EnterMultipleScope())
{ {
@@ -54,7 +55,7 @@ public sealed class PlainFileExportTests
[TestCase("html title=\"index.html\"", FileExportFormat.HTML, Description = "Whatever follows the language is an argument, not part of it.")] [TestCase("html title=\"index.html\"", FileExportFormat.HTML, Description = "Whatever follows the language is an argument, not part of it.")]
public void ACodeBlockIsOfferedInTheFormatItsLanguageNames(string infoString, FileExportFormat expectedFormat) public void ACodeBlockIsOfferedInTheFormatItsLanguageNames(string infoString, FileExportFormat expectedFormat)
{ {
var files = PlainFileExport.ExtractFiles(Lines($"```{infoString}", "The content.", "```"), ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(Lines($"```{infoString}", "The content.", "```")), ',');
using (Assert.EnterMultipleScope()) using (Assert.EnterMultipleScope())
{ {
@@ -67,7 +68,7 @@ public sealed class PlainFileExportTests
[Test] [Test]
public void ATildeFenceIsOfferedAsWell() public void ATildeFenceIsOfferedAsWell()
{ {
var files = PlainFileExport.ExtractFiles(Lines("~~~latex", @"\section{Results}", "~~~"), ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(Lines("~~~latex", @"\section{Results}", "~~~")), ',');
Assert.That(files.Select(file => file.Format), Is.EqualTo(new[] { FileExportFormat.LATEX })); Assert.That(files.Select(file => file.Format), Is.EqualTo(new[] { FileExportFormat.LATEX }));
} }
@@ -79,7 +80,7 @@ public sealed class PlainFileExportTests
[TestCase("```foo", "foo (.txt)", Description = "A language AI Studio does not know still names the block.")] [TestCase("```foo", "foo (.txt)", Description = "A language AI Studio does not know still names the block.")]
public void ACodeBlockInAnyOtherLanguageIsOfferedAsCode(string openingFence, string expectedName) public void ACodeBlockInAnyOtherLanguageIsOfferedAsCode(string openingFence, string expectedName)
{ {
var files = PlainFileExport.ExtractFiles(Lines(openingFence, "print(42)", openingFence[..3]), ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(Lines(openingFence, "print(42)", openingFence[..3])), ',');
using (Assert.EnterMultipleScope()) using (Assert.EnterMultipleScope())
{ {
@@ -96,7 +97,7 @@ public sealed class PlainFileExportTests
[TestCase("Just a few words the model put into a block.", "Text (.txt)", Description = "A block whose language is not clear stays text.")] [TestCase("Just a few words the model put into a block.", "Text (.txt)", Description = "A block whose language is not clear stays text.")]
public void ACodeBlockWithoutALanguageIsOfferedInTheLanguageOfItsCode(string code, string expectedName) public void ACodeBlockWithoutALanguageIsOfferedInTheLanguageOfItsCode(string code, string expectedName)
{ {
var files = PlainFileExport.ExtractFiles(Lines("```", code, "```"), ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(Lines("```", code, "```")), ',');
using (Assert.EnterMultipleScope()) using (Assert.EnterMultipleScope())
{ {
@@ -110,7 +111,7 @@ public sealed class PlainFileExportTests
[Test] [Test]
public void ACodeBlockMarkedAsTextStaysText() public void ACodeBlockMarkedAsTextStaysText()
{ {
var files = PlainFileExport.ExtractFiles(Lines("```text", "using System;", string.Empty, "Console.WriteLine(\"Hello\");", "```"), ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(Lines("```text", "using System;", string.Empty, "Console.WriteLine(\"Hello\");", "```")), ',');
Assert.That(files.Select(file => file.ToName()), Is.EqualTo(new[] { "Text (.txt)" }), "Whoever marks a block as text gets a text file, whatever its code looks like."); Assert.That(files.Select(file => file.ToName()), Is.EqualTo(new[] { "Text (.txt)" }), "Whoever marks a block as text gets a text file, whatever its code looks like.");
} }
@@ -118,19 +119,41 @@ public sealed class PlainFileExportTests
[Test] [Test]
public void AWebPageWithoutALanguageIsOfferedAsAWebPage() public void AWebPageWithoutALanguageIsOfferedAsAWebPage()
{ {
var files = PlainFileExport.ExtractFiles(Lines("```", "<div class=\"card\">", " <p>Hello</p>", "</div>", "```"), ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(Lines("```", "<div class=\"card\">", " <p>Hello</p>", "</div>", "```")), ',');
Assert.That(files.Select(file => file.Format), Is.EqualTo(new[] { FileExportFormat.HTML })); Assert.That(files.Select(file => file.Format), Is.EqualTo(new[] { FileExportFormat.HTML }));
} }
[Test] [TestCase("$$", "$$")]
public void AFormulaIsNoCodeBlock() [TestCase(@"\[", @"\]")]
[TestCase("```latex", "```", Description = "A code block of LaTeX which holds nothing but a formula is a formula, too.")]
public void AFormulaIsOfferedAsLatex(string openingMarker, string closingMarker)
{ {
var answer = Lines("The energy follows from", string.Empty, "$$", "E = mc^2", "$$", string.Empty, "for every body at rest."); var answer = Lines("The energy follows from", string.Empty, openingMarker, "E = mc^2", closingMarker, string.Empty, "for every body at rest.");
var files = PlainFileExport.ExtractFiles(answer, ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(answer), ',');
Assert.That(files, Is.Empty, "Markdig reads a formula as a fenced block, but nobody saves a formula as a text file."); using (Assert.EnterMultipleScope())
{
Assert.That(files, Has.Count.EqualTo(1));
Assert.That(files[0].ToName(), Is.EqualTo("LaTeX (.tex)"));
Assert.That(files[0].Content, Is.EqualTo(Lines(@"\[", "E = mc^2", @"\]")), "Between these markers, the formula can be pasted into a document as it is.");
Assert.That(files[0].Position, Is.EqualTo(answer.IndexOf(openingMarker, StringComparison.Ordinal)), "A formula finds its file where its markers start.");
}
}
[Test]
public void AFormulaIsCountedAmongTheCodeBlocks()
{
var answer = Lines("# Energy", string.Empty, "$$", "E = mc^2", "$$", string.Empty, "```python", "print(1)", "```");
var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(answer), ',');
Assert.That(files.Select(file => (file.Ordinal, file.Caption, file.Format)), Is.EqualTo(new[]
{
(1, "Energy", FileExportFormat.LATEX),
(2, "Energy", FileExportFormat.SOURCE_CODE),
}));
} }
[TestCase("```html", "<html><body><p>The answer broke off here", TestName = "Half a web page")] [TestCase("```html", "<html><body><p>The answer broke off here", TestName = "Half a web page")]
@@ -139,7 +162,7 @@ public sealed class PlainFileExportTests
[TestCase("```", "The answer broke off here", TestName = "Half a text")] [TestCase("```", "The answer broke off here", TestName = "Half a text")]
public void ACodeBlockTheModelNeverClosedIsNotOffered(string openingFence, string content) public void ACodeBlockTheModelNeverClosedIsNotOffered(string openingFence, string content)
{ {
var files = PlainFileExport.ExtractFiles(Lines("The answer starts normally.", string.Empty, openingFence, content), ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(Lines("The answer starts normally.", string.Empty, openingFence, content)), ',');
Assert.That(files, Is.Empty, "The file would end wherever the answer broke off."); Assert.That(files, Is.Empty, "The file would end wherever the answer broke off.");
} }
@@ -170,7 +193,7 @@ public sealed class PlainFileExportTests
"print('Third block')", "print('Third block')",
"```"); "```");
var files = PlainFileExport.ExtractFiles(answer, ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(answer), ',');
Assert.That(files.Select(file => (file.Ordinal, file.Caption, file.Format)), Is.EqualTo(new[] Assert.That(files.Select(file => (file.Ordinal, file.Caption, file.Format)), Is.EqualTo(new[]
{ {
@@ -197,7 +220,7 @@ public sealed class PlainFileExportTests
"print(100)", "print(100)",
"```"); "```");
var files = PlainFileExport.ExtractFiles(answer, ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(answer), ',');
Assert.That(files.Select(file => file.Position), Is.EqualTo(new[] { answer.IndexOf("| Quarter", StringComparison.Ordinal), answer.IndexOf("```python", StringComparison.Ordinal) })); Assert.That(files.Select(file => file.Position), Is.EqualTo(new[] { answer.IndexOf("| Quarter", StringComparison.Ordinal), answer.IndexOf("```python", StringComparison.Ordinal) }));
} }
@@ -213,7 +236,7 @@ public sealed class PlainFileExportTests
" ```", " ```",
"2. Run your script."); "2. Run your script.");
var files = PlainFileExport.ExtractFiles(answer, ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(answer), ',');
Assert.That(files.Select(file => file.Position), Is.EqualTo(new[] { answer.IndexOf("```bash", StringComparison.Ordinal) })); Assert.That(files.Select(file => file.Position), Is.EqualTo(new[] { answer.IndexOf("```bash", StringComparison.Ordinal) }));
} }
@@ -244,16 +267,16 @@ public sealed class PlainFileExportTests
"fn main() {}", "fn main() {}",
"```"); "```");
var files = PlainFileExport.ExtractFiles(answer, ','); var files = PlainFileExport.ExtractFiles(MarkdownRenderPlanner.Build(answer), ',');
var partStart = answer.LastIndexOf("$$", StringComparison.Ordinal) + "$$".Length + Environment.NewLine.Length; var partStart = answer.LastIndexOf("$$", StringComparison.Ordinal) + "$$".Length + Environment.NewLine.Length;
var part = Markdig.Markdown.Parse(answer[partStart..], Markdown.CHAT_MARKDOWN_PIPELINE); var part = Markdig.Markdown.Parse(answer[partStart..], Markdown.CHAT_MARKDOWN_PIPELINE);
var blockOfThePart = part.Descendants<FencedCodeBlock>().Single(); var blockOfThePart = part.Descendants<FencedCodeBlock>().Single();
using (Assert.EnterMultipleScope()) using (Assert.EnterMultipleScope())
{ {
Assert.That(files, Has.Count.EqualTo(2)); Assert.That(files, Has.Count.EqualTo(3), "The formula between the two code blocks is a file, too.");
Assert.That(partStart + blockOfThePart.Span.Start, Is.EqualTo(files[1].Position)); Assert.That(partStart + blockOfThePart.Span.Start, Is.EqualTo(files[2].Position));
Assert.That(files[1].ToName(), Is.EqualTo("Rust (.rs)")); Assert.That(files[2].ToName(), Is.EqualTo("Rust (.rs)"));
} }
} }