https://[^\s)]+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
+
+ public static string Render(FileContextReport report, FileContextReportTheme theme, CancellationToken token)
+ {
+ var html = new StringBuilder();
+ html.Append("")
+ .Append("")
+ .Append("").Append(E(report.Title)).Append("");
+ if (theme.LogoBase64 is { Length: > 0 })
+ {
+ html.Append("
");
+ }
+ else { html.Append("NY"); }
+ html.Append("")
+ .Append(E(report.Title)).Append("");
+ for (var index = 0; index < report.Blocks.Count; index++)
+ {
+ token.ThrowIfCancellationRequested();
+ try { Block(html, report.Blocks[index], theme, token); }
+ catch (Exception error) when (error is not OperationCanceledException)
+ { throw new ArgumentException($"blocks[{index}] ({report.Blocks[index].Type}) failed: {error.Message}", error); }
+ }
+ html.Append("");
+ return html.ToString();
+ }
+
+ private static void Block(StringBuilder html, FileContextReportBlock block, FileContextReportTheme theme, CancellationToken token)
+ {
+ var content = block.Content;
+ switch (block.Type)
+ {
+ case "heading":
+ html.Append("").Append(E(ReportJson.Text(content, "title")))
+ .Append("
").Append(E(ReportJson.Text(content, "subtitle"))).Append("
");
+ break;
+ case "text":
+ html.Append("");
+ foreach (var paragraph in ReportJson.Text(content, "text").Split("\n\n"))
+ { html.Append("").Append(Formatted(paragraph)).Append("
"); }
+ html.Append("");
+ break;
+ case "kpi-row": Kpis(html, content); break;
+ case "chart":
+ html.Append("
").Append(E(ReportJson.Text(content, "title"))).Append("");
+ break;
+ case "table": Table(html, content); break;
+ case "image":
+ var image = ReportJson.Text(content, "base64");
+ var bytes = Convert.FromBase64String(image);
+ var mime = FileContextReportImage.Validate(bytes);
+ html.Append("
").Append(E(ReportJson.Text(content, "caption"))).Append("");
+ break;
+ case "callout":
+ html.Append("");
+ break;
+ case "page-break": html.Append(""); break;
+ }
+ }
+
+ private static void Kpis(StringBuilder html, JsonElement content)
+ {
+ var items = ReportJson.Array(content, "items");
+ if (items.Count < MinimumKpis || items.Count > MaximumKpis) { throw new ArgumentException("kpi-row.items requires 2 to 6 cards."); }
+ html.Append("");
+ foreach (var item in items)
+ {
+ var indicator = ReportJson.Text(item, "indicator");
+ var trend = ReportJson.Text(item, "change");
+ if (indicator is "up") { trend = "↑ " + trend; }
+ else if (indicator is "down") { trend = "↓ " + trend; }
+ html.Append("").Append(E(ReportJson.Text(item, "label"))).Append("")
+ .Append(E(ReportJson.Text(item, "value"))).Append("")
+ .Append(E(trend)).Append("
");
+ }
+ html.Append("");
+ }
+
+ private static void Table(StringBuilder html, JsonElement content)
+ {
+ var columns = ReportJson.Array(content, "columns");
+ if (columns.Count == 0) { throw new ArgumentException("table.columns is required."); }
+ html.Append("");
+ foreach (var column in columns)
+ { html.Append("| ").Append(E(ReportJson.Text(column, "label", ReportJson.Text(column, "key")))).Append(" | "); }
+ html.Append("
");
+ var rowIndex = 0;
+ foreach (var row in ReportJson.Array(content, "rows"))
+ {
+ html.Append("");
+ for (var columnIndex = 0; columnIndex < columns.Count; columnIndex++)
+ {
+ var column = columns[columnIndex];
+ var highlighted = ReportJson.IsHighlighted(content, rowIndex, ReportJson.Text(column, "key"));
+ html.Append(highlighted ? "| " : " | ")
+ .Append(E(ReportJson.FormatCell(row, column))).Append(" | ");
+ }
+ html.Append("
");
+ rowIndex++;
+ }
+ html.Append("");
+ var totals = ReportJson.Property(content, "totals");
+ if (totals.ValueKind == JsonValueKind.Object)
+ {
+ html.Append("");
+ foreach (var column in columns)
+ { html.Append("| ").Append(E(ReportJson.FormatCell(totals, column))).Append(" | "); }
+ html.Append("
");
+ }
+ html.Append("
");
+ }
+
+ private static string Formatted(string raw)
+ {
+ var html = new StringBuilder();
+ var inList = false;
+ foreach (var line in raw.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'))
+ {
+ var bullet = line.StartsWith("- ", StringComparison.Ordinal) || line.StartsWith("* ", StringComparison.Ordinal);
+ if (bullet && !inList) { html.Append(""); inList = true; }
+ if (!bullet && inList) { html.Append("
"); inList = false; }
+ if (bullet) { html.Append("").Append(Inline(line[ListMarkerLength..])).Append(""); }
+ else { html.Append(Inline(line)).Append("
"); }
+ }
+ if (inList) { html.Append(""); }
+ return html.ToString();
+ }
+
+ private static string Inline(string raw)
+ {
+ var encoded = E(raw);
+ encoded = Bold.Replace(encoded, "${text}");
+ return Link.Replace(encoded, match => $"{match.Groups["text"].Value}");
+ }
+
+ private static string Style(FileContextReportTheme theme, FileContextReport report) =>
+ """
+ :root{--primary:$PRIMARY$;--accent:$ACCENT$;--text:$TEXT$;--muted:$MUTED$}
+ *{box-sizing:border-box}body{margin:0;background:#f1f4f7;color:var(--text);font:15px/1.5 Arial,sans-serif}
+ header,main,footer{max-width:1000px;margin:auto;background:white;padding:24px 42px}
+ header{display:flex;align-items:center;gap:14px;border-bottom:4px solid var(--accent);color:var(--primary);font-size:20px}
+ .mark{display:inline-grid;place-items:center;width:38px;height:38px;background:var(--accent);color:white;font-weight:bold;font-size:15px}
+ main{min-height:70vh;padding-top:34px;padding-bottom:40px}.heading h1{font-size:28px;margin:0;color:var(--primary)}
+ .heading p{margin:4px 0 24px;color:var(--muted)}.prose{margin:18px 0 24px}.prose p{margin:0 0 12px}
+ .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin:22px 0}
+ .kpi{background:#f0f4f8;border-radius:8px;padding:16px;display:flex;flex-direction:column;break-inside:avoid}
+ .kpi small{color:var(--muted)}.kpi b{font-size:26px;color:var(--primary)}.kpi em{font-style:normal;color:var(--muted)}
+ figure{margin:24px 0;break-inside:avoid}figure img{width:100%;height:auto;max-height:480px;object-fit:contain}
+ figcaption{font-size:12px;color:var(--muted)}table{width:100%;border-collapse:collapse;margin:22px 0;font-size:13px}
+ th{text-align:left;background:var(--primary);color:white}th,td{padding:9px 10px;border-bottom:1px solid #e3e8ee}
+ tr{break-inside:avoid}tbody tr:nth-child(even){background:#f8fafc}tfoot{font-weight:bold;background:#e8edf3}td.highlight{background:#f9f2da;color:var(--primary);font-weight:bold}
+ aside{background:#f9f2da;border-left:4px solid var(--accent);padding:16px;margin:20px 0;break-inside:avoid}
+ footer{border-top:1px solid #d6dce3;color:var(--muted);font-size:12px}.page-break{break-before:page}
+ @media print{body{background:white}header,main,footer{max-width:none}thead{display:table-header-group}
+ footer{position:fixed;bottom:0;left:0;right:0}.page:after{content:'Page ' counter(page)}
+ @page{size:$PAGESIZE$ $ORIENTATION$;margin:14mm}}
+ """.Replace("$PRIMARY$", E(theme.Primary), StringComparison.Ordinal)
+ .Replace("$ACCENT$", E(theme.Accent), StringComparison.Ordinal)
+ .Replace("$TEXT$", E(theme.Text), StringComparison.Ordinal)
+ .Replace("$MUTED$", E(theme.Muted), StringComparison.Ordinal)
+ .Replace("$PAGESIZE$", report.PageSize, StringComparison.Ordinal)
+ .Replace("$ORIENTATION$", report.Orientation, StringComparison.Ordinal);
+
+ private static string E(string value) => WebUtility.HtmlEncode(value);
+}
diff --git a/src/ManagedCode.FileContext/Documents/Reports/FileContextReportImage.cs b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportImage.cs
new file mode 100644
index 0000000..10eeb90
--- /dev/null
+++ b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportImage.cs
@@ -0,0 +1,20 @@
+namespace ManagedCode.FileContext;
+
+internal static class FileContextReportImage
+{
+ private const int MaximumImageBytes = 5_000_000;
+ private const int MinimumImageBytes = 8;
+ private static readonly byte[] PngSignature = [137, 80, 78, 71];
+ private static readonly byte[] JpegSignature = [255, 216];
+
+ public static string Validate(byte[] bytes)
+ {
+ if (bytes.Length is < MinimumImageBytes or > MaximumImageBytes)
+ { throw new ArgumentException("image must be a PNG or JPEG under 5 MB.", nameof(bytes)); }
+ if (bytes.AsSpan().StartsWith(PngSignature))
+ { return "image/png"; }
+ if (bytes.AsSpan().StartsWith(JpegSignature))
+ { return "image/jpeg"; }
+ throw new ArgumentException("image must be PNG or JPEG.", nameof(bytes));
+ }
+}
diff --git a/src/ManagedCode.FileContext/Documents/Reports/FileContextReportOutput.cs b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportOutput.cs
new file mode 100644
index 0000000..21bbe63
--- /dev/null
+++ b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportOutput.cs
@@ -0,0 +1,3 @@
+namespace ManagedCode.FileContext;
+
+internal sealed record FileContextReportOutput(string FileName, string ContentType, byte[] Bytes);
diff --git a/src/ManagedCode.FileContext/Documents/Reports/FileContextReportPdf.cs b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportPdf.cs
new file mode 100644
index 0000000..f4899e5
--- /dev/null
+++ b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportPdf.cs
@@ -0,0 +1,201 @@
+// Report geometry uses fixed typographic coordinates; block failures name their block index.
+#pragma warning disable S109, MA0015
+using System.Globalization;
+using System.Text.Json;
+using SkiaSharp;
+
+namespace ManagedCode.FileContext;
+
+internal sealed class FileContextReportPdf : IDisposable
+{
+ private readonly FileContextReport report;
+ private readonly FileContextReportTheme theme;
+ private readonly CancellationToken token;
+ private readonly int pageLimit;
+ private readonly SKDocument document;
+ private readonly MemoryStream stream = new();
+ private SKCanvas canvas = null!;
+ private readonly float pageWidth;
+ private readonly float pageHeight;
+ private int pageNumber;
+ internal const float Margin = 42;
+ internal SKCanvas Canvas => canvas;
+ internal float PageWidth => pageWidth;
+ internal float PageHeight => pageHeight;
+ internal float Bottom => pageHeight - FooterTop;
+ internal float Y { get; set; }
+ internal FileContextReportTheme Theme => theme;
+ internal CancellationToken Token => token;
+ private const float ContentTop = 93;
+ private const float FooterTop = 52;
+
+ private FileContextReportPdf(FileContextReport report, FileContextReportTheme theme, int pageLimit, CancellationToken token)
+ {
+ this.report = report;
+ this.theme = theme;
+ this.pageLimit = pageLimit;
+ this.token = token;
+ pageWidth = string.Equals(report.PageSize, "A4", StringComparison.Ordinal) ? 595 : 612;
+ pageHeight = string.Equals(report.PageSize, "A4", StringComparison.Ordinal) ? 842 : 792;
+ if (string.Equals(report.Orientation, "landscape", StringComparison.Ordinal)) { (pageWidth, pageHeight) = (pageHeight, pageWidth); }
+ document = SKDocument.CreatePdf(stream) ?? throw new InvalidOperationException("PDF renderer could not start.");
+ }
+
+ public static byte[] Render(FileContextReport report, FileContextReportTheme theme, int pageLimit, CancellationToken token)
+ {
+ using var renderer = new FileContextReportPdf(report, theme, pageLimit, token);
+ renderer.NewPage();
+ for (var index = 0; index < report.Blocks.Count; index++)
+ {
+ token.ThrowIfCancellationRequested();
+ try { renderer.DrawBlock(report.Blocks[index]); }
+ catch (Exception error) when (error is not OperationCanceledException)
+ { throw new ArgumentException($"blocks[{index}] ({report.Blocks[index].Type}) failed: {error.Message}", error); }
+ }
+ renderer.document.EndPage();
+ renderer.document.Close();
+ return renderer.stream.ToArray();
+ }
+
+ internal void NewPage()
+ {
+ if (++pageNumber > pageLimit) { throw new ArgumentException($"Report exceeds {pageLimit} pages."); }
+ if (pageNumber > 1) { document.EndPage(); }
+ canvas = document.BeginPage(pageWidth, pageHeight);
+ Y = ContentTop;
+ using var primary = ReportCanvas.Paint(ReportCanvas.Parse(theme.Primary), 17);
+ using var accent = ReportCanvas.Paint(ReportCanvas.Parse(theme.Accent));
+ using var muted = ReportCanvas.Paint(ReportCanvas.Parse(theme.Muted), 9);
+ if (theme.LogoBase64 is { Length: > 0 })
+ {
+ using var logo = SKBitmap.Decode(Convert.FromBase64String(theme.LogoBase64))
+ ?? throw new ArgumentException("Theme logo must be PNG or JPEG.");
+ canvas.DrawBitmap(logo, new SKRect(Margin, 26, Margin + 31, 57));
+ }
+ else
+ {
+ canvas.DrawRect(Margin, 26, 31, 31, accent);
+ using var white = ReportCanvas.Paint(SKColors.White, 12);
+ ReportCanvas.Text(canvas, "NY", Margin + 5, 47, white);
+ }
+ ReportCanvas.RequireFits(report.Title, primary, pageWidth - Margin * 2 - 43, "title");
+ ReportCanvas.Text(canvas, report.Title, Margin + 43, 48, primary);
+ canvas.DrawLine(Margin, 68, pageWidth - Margin, 68, accent);
+ canvas.DrawLine(Margin, pageHeight - FooterTop, pageWidth - Margin, pageHeight - FooterTop, muted);
+ ReportCanvas.Text(canvas, $"NYWD • {DateTime.UtcNow:yyyy-MM-dd}", Margin, pageHeight - 30, muted);
+ ReportCanvas.Text(canvas, $"Page {pageNumber}", pageWidth - Margin - 44, pageHeight - 30, muted);
+ }
+
+ internal void Ensure(float height)
+ {
+ if (height > pageHeight - ContentTop - FooterTop) { throw new ArgumentException("A block is taller than one page."); }
+ if (Y + height > pageHeight - FooterTop) { NewPage(); }
+ }
+
+ private void DrawBlock(FileContextReportBlock block)
+ {
+ switch (block.Type)
+ {
+ case "heading": DrawHeading(block.Content); break;
+ case "text": DrawText(block.Content); break;
+ case "kpi-row": ReportPdfKpiPainter.Draw(this, block.Content); break;
+ case "chart": DrawChart(block.Content); break;
+ case "table": ReportPdfTablePainter.Draw(this, block.Content); break;
+ case "image": DrawImage(block.Content); break;
+ case "callout": DrawCallout(block.Content); break;
+ case "page-break": NewPage(); break;
+ }
+ }
+
+ private void DrawHeading(JsonElement content)
+ {
+ using var title = ReportCanvas.Paint(ReportCanvas.Parse(theme.Primary), 22);
+ using var subtitle = ReportCanvas.Paint(ReportCanvas.Parse(theme.Muted), 11);
+ var titleLines = ReportCanvas.Wrap(ReportJson.Text(content, "title"), title, pageWidth - Margin * 2);
+ var subtitleLines = ReportCanvas.Wrap(ReportJson.Text(content, "subtitle"), subtitle, pageWidth - Margin * 2);
+ var height = titleLines.Count * 29 + subtitleLines.Count * 17 + 18;
+ Ensure(height);
+ foreach (var line in titleLines)
+ {
+ ReportCanvas.Text(canvas, line, Margin, Y + 22, title);
+ Y += 29;
+ }
+ foreach (var line in subtitleLines)
+ {
+ ReportCanvas.Text(canvas, line, Margin, Y + 12, subtitle);
+ Y += 17;
+ }
+ Y += 18;
+ }
+
+ private void DrawText(JsonElement content)
+ {
+ var source = ReportJson.Text(content, "text");
+ source = System.Text.RegularExpressions.Regex.Replace(source,
+ @"\[(?