diff --git a/Core/Printing/MarkdownTableStencil.cs b/Core/Printing/MarkdownTableStencil.cs
new file mode 100644
index 0000000..b93c264
--- /dev/null
+++ b/Core/Printing/MarkdownTableStencil.cs
@@ -0,0 +1,108 @@
+namespace Core.Printing;
+
+///
+/// Splits a markdown template that contains exactly one table so a roster can
+/// share one header and append merged body rows per record.
+///
+public sealed class MarkdownTableStencil
+{
+ public required string Prefix { get; init; }
+
+ public required string Header { get; init; }
+
+ public required string Body { get; init; }
+
+ public required string Suffix { get; init; }
+
+ public static bool TryParse(string? template, out MarkdownTableStencil? stencil)
+ {
+ stencil = null;
+ if (string.IsNullOrWhiteSpace(template))
+ return false;
+
+ var lines = SplitLines(template);
+ if (!TryFindTable(lines, 0, out var headerIndex, out var bodyStart, out var bodyEnd))
+ return false;
+
+ if (bodyStart >= bodyEnd)
+ return false;
+
+ if (TryFindTable(lines, bodyEnd, out _, out _, out _))
+ return false;
+
+ stencil = new MarkdownTableStencil
+ {
+ Prefix = JoinLines(lines[..headerIndex]),
+ Header = JoinLines(lines[headerIndex..bodyStart]),
+ Body = JoinLines(lines[bodyStart..bodyEnd]),
+ Suffix = JoinLines(lines[bodyEnd..])
+ };
+ return true;
+ }
+
+ public string Stitch(string prefix, IEnumerable bodies, string suffix)
+ {
+ ArgumentNullException.ThrowIfNull(bodies);
+
+ List parts = [];
+ AppendPart(parts, prefix);
+ AppendPart(parts, Header);
+ foreach (var body in bodies)
+ AppendPart(parts, body);
+ AppendPart(parts, suffix);
+
+ return parts.Count == 0 ? string.Empty : string.Join('\n', parts) + "\n";
+ }
+
+ private static void AppendPart(List parts, string? part)
+ {
+ if (string.IsNullOrEmpty(part))
+ return;
+
+ var trimmed = part.TrimEnd('\r', '\n');
+ if (trimmed.Length > 0)
+ parts.Add(trimmed);
+ }
+
+ private static bool TryFindTable(
+ string[] lines,
+ int start,
+ out int headerIndex,
+ out int bodyStart,
+ out int bodyEnd)
+ {
+ headerIndex = -1;
+ bodyStart = -1;
+ bodyEnd = -1;
+
+ for (var i = start; i < lines.Length - 1; i++)
+ {
+ if (!IsTableLine(lines[i]) || !IsSeparatorLine(lines[i + 1]))
+ continue;
+
+ headerIndex = i;
+ bodyStart = i + 2;
+ bodyEnd = bodyStart;
+ while (bodyEnd < lines.Length && IsTableLine(lines[bodyEnd]) && !IsSeparatorLine(lines[bodyEnd]))
+ bodyEnd++;
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsTableLine(string line)
+ {
+ var trimmed = line.TrimStart();
+ return trimmed.StartsWith('|');
+ }
+
+ private static bool IsSeparatorLine(string line) =>
+ IsTableLine(line) && line.Contains("---", StringComparison.Ordinal);
+
+ private static string[] SplitLines(string text) =>
+ text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
+
+ private static string JoinLines(string[] lines) =>
+ lines.Length == 0 ? string.Empty : string.Join('\n', lines);
+}
diff --git a/Core/Printing/PrintFieldCatalog.cs b/Core/Printing/PrintFieldCatalog.cs
index 2b3ef03..13963f0 100644
--- a/Core/Printing/PrintFieldCatalog.cs
+++ b/Core/Printing/PrintFieldCatalog.cs
@@ -51,7 +51,9 @@ public static class PrintFieldCatalog
"EventShortName",
"EventFormat",
"TeamSize",
- "Eligibility",
+ "NationalEligibility",
+ "RegionalTeamCount",
+ "StateTeamCount",
"Description",
"Theme",
"EventAttributes"
@@ -63,7 +65,9 @@ public static class PrintFieldCatalog
"ShortName",
"EventFormat",
"TeamSize",
- "Eligibility",
+ "NationalEligibility",
+ "RegionalTeamCount",
+ "StateTeamCount",
"LevelOfEffort",
"SemifinalistActivity",
"RegionalEvent",
diff --git a/Tests/Printing/MarkdownTableStencil_Tests.cs b/Tests/Printing/MarkdownTableStencil_Tests.cs
new file mode 100644
index 0000000..9a18f68
--- /dev/null
+++ b/Tests/Printing/MarkdownTableStencil_Tests.cs
@@ -0,0 +1,129 @@
+using Core.Printing;
+
+namespace Tests.Printing;
+
+[TestFixture]
+public class MarkdownTableStencil_Tests
+{
+ private const string Roster = """
+ | Name | Grade |
+ | --- | --- |
+ | {{LastNameFirstName}} | {{Grade}} |
+ """;
+
+ [Test]
+ public void TryParse_SingleTable_SplitsPrefixHeaderBodySuffix()
+ {
+ var template = """
+ # Rankings
+
+ | Name | 1st |
+ | --- | --- |
+ | {{LastNameFirstName}} | {{Rank1}} |
+
+ {{Legend}}
+ """;
+
+ Assert.That(MarkdownTableStencil.TryParse(template, out var stencil), Is.True);
+ Assert.That(stencil!.Prefix, Does.Contain("# Rankings"));
+ Assert.That(stencil.Header, Does.Contain("| Name | 1st |"));
+ Assert.That(stencil.Header, Does.Contain("| --- | --- |"));
+ Assert.That(stencil.Body.Trim(), Is.EqualTo("| {{LastNameFirstName}} | {{Rank1}} |"));
+ Assert.That(stencil.Suffix, Does.Contain("{{Legend}}"));
+ }
+
+ [Test]
+ public void TryParse_NoTable_Fails()
+ {
+ Assert.That(MarkdownTableStencil.TryParse("# Interview\n\n{{FirstName}}", out _), Is.False);
+ }
+
+ [Test]
+ public void TryParse_TwoTables_Fails()
+ {
+ var template = """
+ | Grade | Time |
+ | --- | --- |
+ | {{Grade}} | {{Interview Time}} |
+
+ | 1st | 2nd |
+ | --- | --- |
+ | {{Rank1}} | {{Rank2}} |
+ """;
+
+ Assert.That(MarkdownTableStencil.TryParse(template, out _), Is.False);
+ }
+
+ [Test]
+ public void TryParse_HeaderOnly_Fails()
+ {
+ Assert.That(MarkdownTableStencil.TryParse("| Name |\n| --- |\n", out _), Is.False);
+ }
+
+ [Test]
+ public void Stitch_TwoStudents_HeaderOnceAndTwoBodyRows()
+ {
+ Assert.That(MarkdownTableStencil.TryParse(Roster, out var stencil), Is.True);
+
+ var merged = stencil!.Stitch(
+ string.Empty,
+ [
+ NoteTemplateMerger.Merge(stencil.Body, Map(("LastNameFirstName", "Cole, Aria"), ("Grade", "6"))),
+ NoteTemplateMerger.Merge(stencil.Body, Map(("LastNameFirstName", "Dean, Lucas"), ("Grade", "7")))
+ ],
+ string.Empty);
+
+ Assert.That(CountOccurrences(merged, "| Name | Grade |"), Is.EqualTo(1));
+ Assert.That(CountOccurrences(merged, "| --- | --- |"), Is.EqualTo(1));
+ Assert.That(merged, Does.Contain("| Cole, Aria | 6 |"));
+ Assert.That(merged, Does.Contain("| Dean, Lucas | 7 |"));
+ }
+
+ [Test]
+ public void Stitch_AttributesRow_RepeatsPerRecord()
+ {
+ var template = """
+ | 1st | 2nd |
+ | --- | --- |
+ | {{Rank1}} | {{Rank2}} |
+ | {{Rank1.Attributes}} | {{Rank2.Attributes}} |
+ """;
+
+ Assert.That(MarkdownTableStencil.TryParse(template, out var stencil), Is.True);
+
+ var merged = stencil!.Stitch(
+ string.Empty,
+ [
+ NoteTemplateMerger.Merge(stencil.Body, Map(("Rank1", "Coding"), ("Rank2", "Flight"), ("Rank1.Attributes", "I"), ("Rank2.Attributes", "T"))),
+ NoteTemplateMerger.Merge(stencil.Body, Map(("Rank1", "Drone"), ("Rank2", "Robotics"), ("Rank1.Attributes", "R"), ("Rank2.Attributes", "O")))
+ ],
+ string.Empty);
+
+ Assert.That(CountOccurrences(merged, "| Coding | Flight |"), Is.EqualTo(1));
+ Assert.That(CountOccurrences(merged, "| I | T |"), Is.EqualTo(1));
+ Assert.That(CountOccurrences(merged, "| Drone | Robotics |"), Is.EqualTo(1));
+ Assert.That(CountOccurrences(merged, "| R | O |"), Is.EqualTo(1));
+ Assert.That(CountOccurrences(merged, "| --- | --- |"), Is.EqualTo(1));
+ }
+
+ private static Dictionary Map(params (string Key, string Value)[] pairs)
+ {
+ var tokens = PrintTokenMap.Create();
+ foreach (var (key, value) in pairs)
+ tokens[key] = value;
+ return tokens;
+ }
+
+ private static int CountOccurrences(string haystack, string needle)
+ {
+ var count = 0;
+ var index = 0;
+ while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ index += needle.Length;
+ }
+
+ return count;
+ }
+}
diff --git a/Tests/Printing/PrintFieldCatalog_Tests.cs b/Tests/Printing/PrintFieldCatalog_Tests.cs
index 38cd95d..924c3ed 100644
--- a/Tests/Printing/PrintFieldCatalog_Tests.cs
+++ b/Tests/Printing/PrintFieldCatalog_Tests.cs
@@ -38,7 +38,13 @@ public class PrintFieldCatalog_Tests
{
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventName"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventAttributes"));
+ Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("NationalEligibility"));
+ Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("RegionalTeamCount"));
+ Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("StateTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalEvent"));
+ Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("NationalEligibility"));
+ Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalTeamCount"));
+ Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("StateTeamCount"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain(NoteTemplateMerger.RankedStudentsToken));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("EventAttributes"));
}
diff --git a/WebApp/Services/NotePrintService.cs b/WebApp/Services/NotePrintService.cs
index 051fcde..768ea51 100644
--- a/WebApp/Services/NotePrintService.cs
+++ b/WebApp/Services/NotePrintService.cs
@@ -74,16 +74,16 @@ public class NotePrintService : INotePrintService
rows.Add((student, ImportedTokens(parsed, request.ImportedFieldCatalog)));
}
- return
- [
- .. rows.Select(row => MergePage(
+ return FinishPreview(
+ request,
+ template,
+ chapter,
+ rows.Count == 1 ? "1 student" : $"{rows.Count} students",
+ rows.Select(row => new RecordMerge(
row.Student.LastNameFirstName,
- template,
row.Imported,
StudentTokens(row.Student),
- chapter,
- StudentHtmlFragments(row.Student)))
- ];
+ StudentHtmlFragments(row.Student))));
}
private async Task> PreviewTeamsAsync(
@@ -110,10 +110,12 @@ public class NotePrintService : INotePrintService
.ThenBy(t => t.Identifier)
.ToListAsync(cancellationToken);
- return
- [
- .. teams.Select(team => MergePage(team.ToString(), template, null, TeamTokens(team), chapter))
- ];
+ return FinishPreview(
+ request,
+ template,
+ chapter,
+ teams.Count == 1 ? "1 team" : $"{teams.Count} teams",
+ teams.Select(team => new RecordMerge(team.ToString(), null, TeamTokens(team), null)));
}
private async Task> PreviewEventsAsync(
@@ -153,15 +155,67 @@ public class NotePrintService : INotePrintService
.GroupBy(r => r.EventDefinition.Id)
.ToDictionary(g => g.Key, g => g.ToList());
- return
- [
- .. events.Select(evt => MergePage(
+ return FinishPreview(
+ request,
+ template,
+ chapter,
+ events.Count == 1 ? "1 event" : $"{events.Count} events",
+ events.Select(evt => new RecordMerge(
evt.Name,
- template,
null,
EventTokens(evt),
+ EventHtmlFragments(rankingsByEventId.GetValueOrDefault(evt.Id)))));
+ }
+
+ private readonly record struct RecordMerge(
+ string DisplayName,
+ Dictionary? Imported,
+ Dictionary Entity,
+ IReadOnlyDictionary? HtmlFragments);
+
+ private static IReadOnlyList FinishPreview(
+ NotePrintRequest request,
+ string template,
+ Dictionary chapter,
+ string combinedDisplayName,
+ IEnumerable records)
+ {
+ var list = records.ToList();
+ if (list.Count == 0)
+ return [];
+
+ if (!request.Filters.NewPagePerRecord
+ && MarkdownTableStencil.TryParse(template, out var stencil)
+ && stencil is not null)
+ {
+ var chapterMap = PrintTokenMap.Build(null, null, chapter);
+ var prefix = NoteTemplateMerger.Merge(stencil.Prefix, chapterMap);
+ var suffix = NoteTemplateMerger.Merge(stencil.Suffix, chapterMap);
+ var bodies = list.Select(record =>
+ NoteTemplateMerger.Merge(
+ stencil.Body,
+ PrintTokenMap.Build(record.Imported, record.Entity, chapter)));
+
+ return
+ [
+ new NotePrintPage
+ {
+ DisplayName = combinedDisplayName,
+ Html = NoteTemplateMerger.ApplyLayout(
+ MarkdownHelper.ToHtml(stencil.Stitch(prefix, bodies, suffix)))
+ }
+ ];
+ }
+
+ return
+ [
+ .. list.Select(record => MergePage(
+ record.DisplayName,
+ template,
+ record.Imported,
+ record.Entity,
chapter,
- EventHtmlFragments(rankingsByEventId.GetValueOrDefault(evt.Id))))
+ record.HtmlFragments))
];
}
@@ -261,7 +315,10 @@ public class NotePrintService : INotePrintService
{
["EventFormat"] = evt?.EventFormat.ToString(),
["TeamSize"] = evt?.TeamSize,
+ ["NationalEligibility"] = evt?.Eligibility,
["Eligibility"] = evt?.Eligibility,
+ ["RegionalTeamCount"] = evt?.ChapterEligibilityCountRegionals.ToString(),
+ ["StateTeamCount"] = evt?.ChapterEligibilityCountState.ToString(),
["Description"] = evt?.Description,
["Theme"] = evt?.Theme,
["EventAttributes"] = EventAttributeMarks.For(evt)
diff --git a/docs/instructions/page-printer.md b/docs/instructions/page-printer.md
index ea52c8d..a496488 100644
--- a/docs/instructions/page-printer.md
+++ b/docs/instructions/page-printer.md
@@ -1,7 +1,7 @@
# Page printer
**Created:** 2026-08-29
-**Last updated:** 2026-08-30
+**Last updated:** 2026-08-31
**Description:** Merge a markdown note onto students, teams, or events and print one page per match. Save the recipe as a print preset.
## Where to open it
@@ -54,6 +54,8 @@ Event pages can put the matching student list under the event name:
`{{RankedStudents}}` prints everyone who ranked that event as badges (colored rank dot and first name), same sort as **Events by Student** on the ranking index: rank, then grade + TSA year. `{{EventAttributes}}` is also available on team pages.
+On event and team pages, `{{RegionalTeamCount}}` and `{{StateTeamCount}}` print how many teams the chapter may send at regionals and state. `{{NationalEligibility}}` is the nationals eligibility text (not a team count). `{{Eligibility}}` still works as the same value.
+
Put `{{Legend}}` where you want the attribute-mark key (same marks as the Teams printout legend).
Put `{{PageBreak}}` on its own line to force a new printed sheet **inside** one record (for example, questions on page 1 and a scoring rubric on page 2).
@@ -85,6 +87,8 @@ If the template note was removed, the filters still load. Choose another note be
**New page per record** (on by default, saved with the preset) starts each match on a new sheet. Turn it off to flow records together; `{{PageBreak}}` in the template still works. Content that is taller than one sheet continues naturally.
+If that option is off and the template has **exactly one** markdown table, the header prints once and each record appends only the table body row(s). That is the roster layout (name, grade, ranks on one row). Put student/team/event tokens in those body rows. Put `{{Legend}}` above or below the table. Two tables (for example an interview sheet plus a rank table) still print as separate stacked documents. `{{RankedEvents}}`, `{{RankedStudents}}`, and `{{PageBreak}}` inside a stitched table are not supported; use them in the lines around the table, or leave New page per record on.
+
**Font size (pt)** (default 12) and **Answer lines** (default 3) apply to every page-printer recipe. They are saved on the preset.
If Preview finds more than 75 matches, a warning is shown first.
\ No newline at end of file