Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<AnalysisMode>Recommended</AnalysisMode>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);CS1591;MAAI001</NoWarn>
<Version>1.0.5</Version>
<Version>1.0.6</Version>
<PackageVersion>$(Version)</PackageVersion>
</PropertyGroup>

Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<ItemGroup>
<PackageVersion Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageVersion Include="PdfPig" Version="0.1.16" />
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="coverlet.msbuild" Version="10.0.1" />
<PackageVersion Include="DotNet.ReproducibleBuilds" Version="2.0.5" />
Expand Down
4 changes: 2 additions & 2 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
```
Expand Down
29 changes: 29 additions & 0 deletions docs/Features/ReportRendering.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,19 @@ private static string RequireExtension(string path, string extension)
}
return path;
}
/// <summary>Renders all outputs before storing any file in the scoped FileContext.</summary>
public async Task<FileContextReportResult> CreateReportAsync(FileContextReport report,
CancellationToken cancellationToken = default)
{
var outputs = FileContextReportRenderer.Render(report, cancellationToken);
var files = new List<FileContextCreatedFile>(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);
}

}
13 changes: 13 additions & 0 deletions src/ManagedCode.FileContext/Documents/Reports/FileContextReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Text.Json;

namespace ManagedCode.FileContext;

/// <summary>Content-only request for a branded, paginated report.</summary>
public sealed record FileContextReport(
string Title,
string FileName,
IReadOnlyList<FileContextReportBlock> Blocks,
IReadOnlyList<string> Outputs,
string PageSize = "Letter",
string Orientation = "portrait",
FileContextReportTheme? Theme = null);
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
using System.Text.Json;
namespace ManagedCode.FileContext;
/// <summary>A report block. Content follows the block JSON schema.</summary>
public sealed record FileContextReportBlock(string Type, JsonElement Content);
Original file line number Diff line number Diff line change
@@ -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<ChartSeries> ParseSeries(JsonElement content, int labelCount)
{
var result = new List<ChartSeries>();
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<string> labels,
IReadOnlyList<ChartSeries> 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<string> labels,
IReadOnlyList<double> 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<double> Values);
}
Loading
Loading