Files
poprhythmandCursor 3712dba974 feat: merge event rankings and attribute badges into the page printer
Interview notes can print each student's ranked events and a shared attribute legend from the same catalog as the ranking index, without leftover table markup or collapsed answer space.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 23:03:10 -04:00

111 lines
3.9 KiB
C#

using System.Text.RegularExpressions;
namespace Core.Printing;
/// <summary>
/// Replaces <c>{{Token}}</c> placeholders from a case-insensitive map.
/// Unknown tokens are left unchanged. Known empty values become blank.
/// <c>{{PageBreak}}</c> becomes a print page break after HTML conversion.
/// <c>{{AnswerSpace}}</c> becomes ruled write-in space after HTML conversion.
/// <c>{{RankedEvents}}</c> and <c>{{RankedStudents}}</c> become ranking-index badges.
/// <c>{{Legend}}</c> becomes the shared attribute-mark legend.
/// </summary>
public static class NoteTemplateMerger
{
public const string PageBreakToken = "PageBreak";
public const string PageBreakSentinel = "<!--tsa-page-break-->";
public const string PageBreakHtml = "<div class=\"pagebreak\"></div>";
public const string AnswerSpaceToken = "AnswerSpace";
public const string AnswerSpaceSentinel = "<!--tsa-answer-space-->";
public const string AnswerSpaceHtml = "<div class=\"print-answer-space\">&nbsp;</div>";
public const string LegendToken = "Legend";
public const string LegendSentinel = "<!--tsa-legend-->";
public const string RankedEventsToken = "RankedEvents";
public const string RankedStudentsToken = "RankedStudents";
public static readonly string[] HtmlFragmentTokens =
[
RankedEventsToken,
RankedStudentsToken
];
public static string HtmlFragmentSentinel(string name) => $"<!--tsa-html:{name}-->";
private readonly record struct LayoutToken(string Name, string Sentinel, string Html);
private static readonly LayoutToken[] LayoutTokens =
[
new(PageBreakToken, PageBreakSentinel, PageBreakHtml),
new(AnswerSpaceToken, AnswerSpaceSentinel, AnswerSpaceHtml),
new(LegendToken, LegendSentinel, PrintRankBadgeHtml.Legend())
];
private static readonly Regex TokenRegex = new(@"\{\{([^}]+)\}\}", RegexOptions.Compiled);
public static string Merge(string? template, IReadOnlyDictionary<string, string> tokens)
{
if (string.IsNullOrEmpty(template))
return string.Empty;
ArgumentNullException.ThrowIfNull(tokens);
return TokenRegex.Replace(template, match =>
{
var key = match.Groups[1].Value.Trim();
if (key.Length == 0)
return match.Value;
foreach (var layout in LayoutTokens)
{
if (key.Equals(layout.Name, StringComparison.OrdinalIgnoreCase))
return layout.Sentinel;
}
foreach (var htmlName in HtmlFragmentTokens)
{
if (key.Equals(htmlName, StringComparison.OrdinalIgnoreCase))
return HtmlFragmentSentinel(htmlName);
}
return tokens.TryGetValue(key, out var value)
? value ?? string.Empty
: match.Value;
});
}
/// <summary>
/// Turns layout sentinels into HTML after markdown has been rendered.
/// </summary>
public static string ApplyLayout(
string? html,
IReadOnlyDictionary<string, string>? htmlFragments = null)
{
if (string.IsNullOrEmpty(html))
return string.Empty;
foreach (var layout in LayoutTokens)
{
html = html
.Replace($"<p>{layout.Sentinel}</p>", layout.Html, StringComparison.Ordinal)
.Replace(layout.Sentinel, layout.Html, StringComparison.Ordinal);
}
foreach (var name in HtmlFragmentTokens)
{
var sentinel = HtmlFragmentSentinel(name);
var fragment = htmlFragments is not null
&& htmlFragments.TryGetValue(name, out var value)
? value ?? string.Empty
: string.Empty;
html = html
.Replace($"<p>{sentinel}</p>", fragment, StringComparison.Ordinal)
.Replace(sentinel, fragment, StringComparison.Ordinal);
}
return html;
}
}