mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 21:27:21 +00:00
227 lines
7.9 KiB
C#
227 lines
7.9 KiB
C#
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);
|
||
|
|
}
|