From 713539f355fcf8113edf5127268492a7a8329bc7 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 26 Sep 2026 00:05:03 +0200 Subject: [PATCH] Add branded report rendering to FileContext --- Directory.Build.props | 2 +- Directory.Packages.props | 2 + docs/Architecture.md | 4 +- docs/Features/ReportRendering.md | 29 +++ .../Documents/FileContextDocumentService.cs | 15 ++ .../Documents/Reports/FileContextReport.cs | 13 ++ .../Reports/FileContextReportBlock.cs | 4 + .../Reports/FileContextReportChart.cs | 182 ++++++++++++++++ .../Reports/FileContextReportHtml.cs | 186 ++++++++++++++++ .../Reports/FileContextReportImage.cs | 20 ++ .../Reports/FileContextReportOutput.cs | 3 + .../Documents/Reports/FileContextReportPdf.cs | 201 ++++++++++++++++++ .../Reports/FileContextReportRenderer.cs | 78 +++++++ .../Reports/FileContextReportResult.cs | 3 + .../Reports/FileContextReportTheme.cs | 3 + .../Documents/Reports/ReportCanvas.cs | 78 +++++++ .../Documents/Reports/ReportJson.cs | 44 ++++ .../Documents/Reports/ReportPdfKpiPainter.cs | 83 ++++++++ .../Reports/ReportPdfTablePainter.cs | 98 +++++++++ .../ManagedCode.FileContext.csproj | 2 + .../ReportCreationTests.cs | 158 ++++++++++++++ 21 files changed, 1205 insertions(+), 3 deletions(-) create mode 100644 docs/Features/ReportRendering.md create mode 100644 src/ManagedCode.FileContext/Documents/Reports/FileContextReport.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/FileContextReportBlock.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/FileContextReportChart.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/FileContextReportHtml.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/FileContextReportImage.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/FileContextReportOutput.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/FileContextReportPdf.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/FileContextReportRenderer.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/FileContextReportResult.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/FileContextReportTheme.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/ReportCanvas.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/ReportJson.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/ReportPdfKpiPainter.cs create mode 100644 src/ManagedCode.FileContext/Documents/Reports/ReportPdfTablePainter.cs create mode 100644 tests/ManagedCode.FileContext.Tests/ReportCreationTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 757ec20..8f12c3e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Recommended true $(NoWarn);CS1591;MAAI001 - 1.0.5 + 1.0.6 $(Version) diff --git a/Directory.Packages.props b/Directory.Packages.props index b6f4af4..8468ecb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,8 @@ + + diff --git a/docs/Architecture.md b/docs/Architecture.md index 58d113a..dea5c2b 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -89,14 +89,14 @@ All potentially large operations are controlled by `FileContextOptions`: full-re ## Generated documents -`FileContextDocumentService` shares `ManagedCodeStorageFileStore` with standard navigation and bounded reads. It creates new UTF-8 text/CSV, XLSX workbooks and paginated PDF documents under unique relative paths. `FileContextDocumentTools` supplies the same native functions to FileContextProvider or a host-owned tool registry. +`FileContextDocumentService` shares `ManagedCodeStorageFileStore` with standard navigation and bounded reads. It creates new UTF-8 text/CSV, XLSX workbooks, paginated PDF documents, and branded report PDF/HTML/chart PNG output under unique relative paths. Report rendering uses an embedded Noto Sans font and Skia, receives host-resolved image bytes, and never makes network requests. See [branded reports](Features/ReportRendering.md). `FileContextDocumentTools` supplies the same native functions to FileContextProvider or a host-owned tool registry. ```mermaid flowchart LR Provider[FileContextProvider] --> Tools[FileContextDocumentTools] Host[Host native and JavaScript tools] --> Tools Tools --> Documents[FileContextDocumentService] - Documents --> Writers[CSV OpenXml PdfPig] + Documents --> Writers[CSV OpenXml PdfPig Skia report renderer] Documents --> Store[ManagedCodeStorageFileStore] Store --> Storage[IStorage] ``` diff --git a/docs/Features/ReportRendering.md b/docs/Features/ReportRendering.md new file mode 100644 index 0000000..d498b4f --- /dev/null +++ b/docs/Features/ReportRendering.md @@ -0,0 +1,29 @@ +# Branded reports + +`FileContextDocumentService.CreateReportAsync` generates a `FileContextReportResult` containing scoped file references. The caller supplies a title, base file name, ordered blocks, output kinds (`pdf`, `html`, `png`), Letter/A4 page size, portrait/landscape orientation, and server-resolved visual theme. The library never fetches URLs or loads external fonts. A host must resolve and validate remote images before passing base64 PNG/JPEG bytes in an `image` block. + +```mermaid +flowchart LR + A[Host validates input and resolves images] --> B[FileContextReport] + B --> C[PDF pages] + B --> D[Self-contained HTML] + B --> E[PNG chart images] + C --> F[Scoped IStorage] + D --> F + E --> F +``` + +Block content is JSON: + +- `heading`: `title`, optional `subtitle`. +- `text`: `text` with bold spans (`**bold**`), dash bullets, and HTTPS Markdown links. +- `kpi-row`: `items` with 2-6 entries containing `label`, `value`, optional `change`, and optional `indicator` (`up` or `down`). +- `chart`: `title`, `chartType` (`line`, `bar`, `stacked-bar`, `pie`, `donut`), `labels`, `series` entries with `name` and numeric `values`, optional `xAxis` and `yAxis`. +- `table`: `columns` with `key`, `label`, optional .NET numeric `format`; `rows` as objects; optional `totals` object and `highlightCells` entries with zero-based `row` and column `key`. +- `image`: base64 PNG/JPEG bytes in `base64`, optional `caption`. +- `callout`: `text`. +- `page-break`: empty content. + +All requested outputs render in memory before FileContext stores any file. Validation caps the expanded report at 10 MB, PDF at 60 pages, and render time at 60 seconds. Image blocks are capped at 5 MB. PDF chart and table rows are kept intact across pages, table headers repeat after a page break, and each page has a title, NYWD mark or theme logo, UTC date, and page number. Failures name the block index and type where possible. + +Named template and theme persistence, network allowlists, operator authorization, and authenticated download links belong to the host. `AIAgents.App` implements that boundary in its `AgentFiles/Reports` module. diff --git a/src/ManagedCode.FileContext/Documents/FileContextDocumentService.cs b/src/ManagedCode.FileContext/Documents/FileContextDocumentService.cs index b9a4b2f..9cadf36 100644 --- a/src/ManagedCode.FileContext/Documents/FileContextDocumentService.cs +++ b/src/ManagedCode.FileContext/Documents/FileContextDocumentService.cs @@ -46,4 +46,19 @@ private static string RequireExtension(string path, string extension) } return path; } + /// Renders all outputs before storing any file in the scoped FileContext. + public async Task CreateReportAsync(FileContextReport report, + CancellationToken cancellationToken = default) + { + var outputs = FileContextReportRenderer.Render(report, cancellationToken); + var files = new List(outputs.Count); + foreach (var output in outputs) + { + cancellationToken.ThrowIfCancellationRequested(); + files.Add(await CreateAsync(output.FileName, output.ContentType, + _ => output.Bytes, cancellationToken).ConfigureAwait(false)); + } + return new FileContextReportResult(files); + } + } diff --git a/src/ManagedCode.FileContext/Documents/Reports/FileContextReport.cs b/src/ManagedCode.FileContext/Documents/Reports/FileContextReport.cs new file mode 100644 index 0000000..9e030b6 --- /dev/null +++ b/src/ManagedCode.FileContext/Documents/Reports/FileContextReport.cs @@ -0,0 +1,13 @@ +using System.Text.Json; + +namespace ManagedCode.FileContext; + +/// Content-only request for a branded, paginated report. +public sealed record FileContextReport( + string Title, + string FileName, + IReadOnlyList Blocks, + IReadOnlyList Outputs, + string PageSize = "Letter", + string Orientation = "portrait", + FileContextReportTheme? Theme = null); diff --git a/src/ManagedCode.FileContext/Documents/Reports/FileContextReportBlock.cs b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportBlock.cs new file mode 100644 index 0000000..2f2d253 --- /dev/null +++ b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportBlock.cs @@ -0,0 +1,4 @@ +using System.Text.Json; +namespace ManagedCode.FileContext; +/// A report block. Content follows the block JSON schema. +public sealed record FileContextReportBlock(string Type, JsonElement Content); diff --git a/src/ManagedCode.FileContext/Documents/Reports/FileContextReportChart.cs b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportChart.cs new file mode 100644 index 0000000..222d92e --- /dev/null +++ b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportChart.cs @@ -0,0 +1,182 @@ +// Report geometry uses fixed typographic coordinates; block failures name their block index. +#pragma warning disable S109, MA0015 +using System.Text.Json; +using SkiaSharp; + +namespace ManagedCode.FileContext; + +internal static class FileContextReportChart +{ + private static readonly SKColor[] Palette = + [new(31, 91, 143), new(211, 167, 71), new(54, 133, 109), new(138, 87, 155), new(212, 104, 90), new(75, 125, 165)]; + + public static byte[] RenderPng(JsonElement content, FileContextReportTheme theme, CancellationToken token) + { + using var surface = SKSurface.Create(new SKImageInfo(1200, 650)) + ?? throw new InvalidOperationException("Could not create chart surface."); + Draw(surface.Canvas, new SKRect(0, 0, 1200, 650), content, theme, token); + using var image = surface.Snapshot(); + using var bytes = image.Encode(SKEncodedImageFormat.Png, 95); + return bytes.ToArray(); + } + + public static void Draw(SKCanvas canvas, SKRect bounds, JsonElement content, + FileContextReportTheme theme, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + var chartType = ReportJson.Text(content, "chartType", "bar").ToLowerInvariant(); + if (chartType is not ("line" or "bar" or "stacked-bar" or "pie" or "donut")) + { throw new ArgumentException($"chart.chartType '{chartType}' is unsupported."); } + var labels = ReportJson.Strings(content, "labels"); + var series = ParseSeries(content, labels.Count); + if (labels.Count == 0 || series.Count == 0) { throw new ArgumentException("chart requires labels and series values."); } + using var background = ReportCanvas.Paint(ReportCanvas.Parse(theme.Background)); + canvas.DrawRect(bounds, background); + using var titlePaint = ReportCanvas.Paint(ReportCanvas.Parse(theme.Primary), 20); + var title = ReportJson.Text(content, "title", "Chart"); + ReportCanvas.RequireFits(title, titlePaint, bounds.Width - 56, "chart.title"); + ReportCanvas.Text(canvas, title, bounds.Left + 28, bounds.Top + 38, titlePaint); + if (chartType is "pie" or "donut") { DrawPie(canvas, bounds, labels, series[0].Values, string.Equals(chartType, "donut", StringComparison.Ordinal), token); } + else { DrawCartesian(canvas, bounds, content, labels, series, chartType, theme, token); } + } + + private static List ParseSeries(JsonElement content, int labelCount) + { + var result = new List(); + if (!content.TryGetProperty("series", out var array) || array.ValueKind != JsonValueKind.Array) { return result; } + foreach (var item in array.EnumerateArray()) + { + var values = ReportJson.Numbers(item, "values"); + if (values.Count != labelCount || values.Any(value => !double.IsFinite(value) || value < 0)) + { throw new ArgumentException("chart.series values must match labels and be finite nonnegative numbers."); } + result.Add(new(ReportJson.Text(item, "name", $"Series {result.Count + 1}"), values)); + } + return result; + } + + private static void DrawCartesian(SKCanvas canvas, SKRect bounds, JsonElement content, IReadOnlyList labels, + IReadOnlyList series, string chartType, FileContextReportTheme theme, CancellationToken token) + { + if (series.Count * 150 > bounds.Width - 72) + { throw new ArgumentException("chart.series legend does not fit the chart width."); } + var plot = new SKRect(bounds.Left + 72, bounds.Top + 72, bounds.Right - 26, bounds.Bottom - 90); + var max = string.Equals(chartType, "stacked-bar", StringComparison.Ordinal) + ? Enumerable.Range(0, labels.Count).Max(index => series.Sum(item => item.Values[index])) + : series.Max(item => item.Values.Max()); + max = Math.Max(1, max); + using var grid = ReportCanvas.Paint(new SKColor(220, 227, 233), stroke: 1); + using var axis = ReportCanvas.Paint(ReportCanvas.Parse(theme.Muted), 11); + for (var tick = 0; tick <= 4; tick++) + { + var y = plot.Bottom - tick * plot.Height / 4; + canvas.DrawLine(plot.Left, y, plot.Right, y, grid); + ReportCanvas.Text(canvas, (max * tick / 4).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture), + bounds.Left + 12, y + 4, axis); + } + for (var index = 0; index < labels.Count; index++) + { + token.ThrowIfCancellationRequested(); + var x = plot.Left + (index + .5f) * plot.Width / labels.Count; + ReportCanvas.RequireFits(labels[index], axis, plot.Width / labels.Count - 4, $"chart.labels[{index}]"); + ReportCanvas.Text(canvas, labels[index], x - ReportCanvas.MeasureText(labels[index], axis) / 2, plot.Bottom + 18, axis); + var cumulative = 0d; + for (var s = 0; s < series.Count; s++) + { + var value = series[s].Values[index]; + using var paint = ReportCanvas.Paint(Palette[s % Palette.Length], stroke: string.Equals(chartType, "line", StringComparison.Ordinal) ? 2 : 0); + var y = plot.Bottom - (float)(value / max * plot.Height); + if (string.Equals(chartType, "line", StringComparison.Ordinal)) + { + if (index > 0) + { + var priorX = plot.Left + (index - .5f) * plot.Width / labels.Count; + var priorY = plot.Bottom - (float)(series[s].Values[index - 1] / max * plot.Height); + canvas.DrawLine(priorX, priorY, x, y, paint); + } + canvas.DrawCircle(x, y, 3, paint); + } + else if (string.Equals(chartType, "stacked-bar", StringComparison.Ordinal)) + { + var h = (float)(value / max * plot.Height); + canvas.DrawRect(x - 14, plot.Bottom - (float)cumulative - h, 28, h, paint); + cumulative += h; + } + else + { + var groupWidth = Math.Min(50f, plot.Width / labels.Count * .7f); + var width = groupWidth / series.Count; + canvas.DrawRect(x - groupWidth / 2 + s * width, y, width - 2, plot.Bottom - y, paint); + } + } + } + DrawAxisLabels(canvas, bounds, plot, content, axis); + DrawLegend(canvas, bounds, series.Select(item => item.Name).ToArray()); + } + + private static void DrawAxisLabels(SKCanvas canvas, SKRect bounds, SKRect plot, + JsonElement content, SKPaint axis) + { + var xAxis = ReportJson.Text(content, "xAxis"); + var yAxis = ReportJson.Text(content, "yAxis"); + if (xAxis.Length > 0) + { + ReportCanvas.RequireFits(xAxis, axis, plot.Width, "chart.xAxis"); + ReportCanvas.Text(canvas, xAxis, plot.Left + (plot.Width - ReportCanvas.MeasureText(xAxis, axis)) / 2, plot.Bottom + 42, axis); + } + if (yAxis.Length > 0) + { + ReportCanvas.RequireFits(yAxis, axis, bounds.Width - 16, "chart.yAxis"); + ReportCanvas.Text(canvas, yAxis, bounds.Left + 8, plot.Top - 10, axis); + } + } + + private static void DrawPie(SKCanvas canvas, SKRect bounds, IReadOnlyList labels, + IReadOnlyList values, bool donut, CancellationToken token) + { + if (labels.Count * 24 > bounds.Height - 100) + { throw new ArgumentException("chart.labels legend does not fit the chart height."); } + var total = values.Sum(); + if (total <= 0) { throw new ArgumentException("Pie or donut chart requires a positive total."); } + var radius = Math.Min(bounds.Height - 140, bounds.Width * .55f) / 2; + var centerX = bounds.Left + bounds.Width * .42f; + var centerY = bounds.Top + bounds.Height * .53f; + var rect = new SKRect(centerX - radius, centerY - radius, centerX + radius, centerY + radius); + var start = -90f; + for (var index = 0; index < values.Count; index++) + { + token.ThrowIfCancellationRequested(); + using var paint = ReportCanvas.Paint(Palette[index % Palette.Length]); + var sweep = (float)(values[index] / total * 360); + canvas.DrawArc(rect, start, sweep, true, paint); + start += sweep; + } + if (donut) + { + using var hole = ReportCanvas.Paint(SKColors.White); + canvas.DrawCircle(centerX, centerY, radius * .53f, hole); + } + using var labelPaint = ReportCanvas.Paint(new SKColor(55, 70, 86), 13); + for (var index = 0; index < labels.Count; index++) + { + using var swatch = ReportCanvas.Paint(Palette[index % Palette.Length]); + canvas.DrawRect(bounds.Right - 245, bounds.Top + 95 + index * 24, 12, 12, swatch); + ReportCanvas.RequireFits(labels[index], labelPaint, 205, $"chart.labels[{index}]"); + ReportCanvas.Text(canvas, labels[index], bounds.Right - 225, bounds.Top + 106 + index * 24, labelPaint); + } + } + + private static void DrawLegend(SKCanvas canvas, SKRect bounds, string[] names) + { + using var text = ReportCanvas.Paint(new SKColor(55, 70, 86), 12); + for (var index = 0; index < names.Length; index++) + { + using var swatch = ReportCanvas.Paint(Palette[index % Palette.Length]); + var x = bounds.Left + 72 + index * 150; + canvas.DrawRect(x, bounds.Bottom - 30, 11, 11, swatch); + ReportCanvas.RequireFits(names[index], text, 125, $"chart.series[{index}].name"); + ReportCanvas.Text(canvas, names[index], x + 17, bounds.Bottom - 19, text); + } + } + + private sealed record ChartSeries(string Name, IReadOnlyList Values); +} diff --git a/src/ManagedCode.FileContext/Documents/Reports/FileContextReportHtml.cs b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportHtml.cs new file mode 100644 index 0000000..3e553be --- /dev/null +++ b/src/ManagedCode.FileContext/Documents/Reports/FileContextReportHtml.cs @@ -0,0 +1,186 @@ +// Block validation reports the relevant JSON field in the message. +#pragma warning disable MA0015 +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ManagedCode.FileContext; + +internal static partial class FileContextReportHtml +{ + private const int MinimumKpis = 2; + private const int MaximumKpis = 6; + private const int ListMarkerLength = 2; + private static readonly Regex Bold = new(@"\*\*(?.+?)\*\*", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + private static readonly Regex Link = new(@"\[(?[^\]]+)\]\((?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("\"NYWD"); + } + 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("
NYWD · ").Append(DateTime.UtcNow.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)) + .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,
").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,
").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(""); } + 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 ? ""); + } + 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(""); } + html.Append(""); + } + html.Append("
").Append(E(ReportJson.Text(column, "label", ReportJson.Text(column, "key")))).Append("
" : "") + .Append(E(ReportJson.FormatCell(row, column))).Append("
").Append(E(ReportJson.FormatCell(totals, column))).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, + @"\[(?