Skip to content

Commit 9165f28

Browse files
committed
Add workbook formatting and scoped XLSX templates
1 parent 709fed1 commit 9165f28

10 files changed

Lines changed: 377 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to ManagedCode.FileContext are documented here.
44

5+
## 1.0.16
6+
7+
- Add optional workbook header styling, Excel number formats, column widths and frozen headers.
8+
- Support scoped macro-free XLSX templates while retaining styles, layout and untouched sheets.
9+
- Validate workbook style fields against the Excel format and preserve formula/value semantics.
10+
511
## 1.0.14
612

713
- Stage seekable cloud PDF streams asynchronously to bounded temporary files before synchronous parser reads and seeks.

‎Directory.Build.props‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<AnalysisMode>Recommended</AnalysisMode>
1313
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
1414
<NoWarn>$(NoWarn);CS1591;MAAI001</NoWarn>
15-
<Version>1.0.15</Version>
15+
<Version>1.0.16</Version>
1616
<PackageVersion>$(Version)</PackageVersion>
1717
</PropertyGroup>
1818

‎docs/Features/file-context.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,26 @@ document into one model call. The tool does not execute macros, fetch external l
129129
OCR on embedded images. Generic text reads refuse DOCX, and text search skips it.
130130

131131
`file_context_pdf_text` reports a bounded text-layer prefix, total page count, and one-based pages with almost no text. It performs no OCR. `file_context_pdf_page_image` renders a complete page as PNG. `file_context_pdf_images_info` counts embedded image objects, and `file_context_pdf_image` returns one object as PNG. The direct `IFileContextPdf` methods and public byte-oriented PDF APIs support the same operations. Storage-scoped PDF reads enforce a byte cap; page rendering enforces pixel and image-byte caps. Image tools return `DataContent`; host chat pipelines must forward it as image content rather than stringify a function result.
132+
133+
## Workbook styling and templates
134+
135+
`FileContextWorkbook` accepts an optional `TemplatePath` for an existing macro-free
136+
`.xlsx` in the same authorized storage scope. Each supplied sheet replaces that
137+
sheet's cell values/formulas, inherits existing cell styles by Excel coordinates,
138+
and keeps its layout and other package parts; sheets not supplied are retained.
139+
The template is never modified. A new unique output file is created.
140+
141+
Each worksheet has an optional `Style` object: `BoldHeader`, `HeaderFill` (`#RRGGBB`),
142+
`FreezeHeader`, `NumberFormats` and `ColumnWidths`. Dictionary keys are one-based
143+
Excel column numbers. Formats are Excel codes such as `$#,##0.00`, `0.0%`, and
144+
`yyyy-mm-dd`; date values use Excel serial numbers. Widths use Excel character
145+
units (positive, maximum 255). These are XLSX format contracts, not app limits.
146+
Formulas remain explicit and are calculated by Excel. New data starts at A1.
147+
148+
```mermaid
149+
flowchart LR
150+
Scope[Authorized FileContext] --> Template[Optional XLSX template]
151+
Template --> Writer[OOXML writer and styles]
152+
Input[Typed sheets and values] --> Writer
153+
Writer --> New[New scoped workbook]
154+
```
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
namespace ManagedCode.FileContext;
22

3-
public sealed record FileContextWorkbook(IReadOnlyList<FileContextWorksheet> Sheets);
3+
public sealed record FileContextWorkbook(IReadOnlyList<FileContextWorksheet> Sheets)
4+
{
5+
/// <summary>Optional existing .xlsx in this exact authorized file scope.</summary>
6+
public string? TemplatePath { get; init; }
7+
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
namespace ManagedCode.FileContext;
22

3-
public sealed record FileContextWorksheet(string Name, IReadOnlyList<IReadOnlyList<FileContextSpreadsheetCell>> Rows);
3+
public sealed record FileContextWorksheet(string Name, IReadOnlyList<IReadOnlyList<FileContextSpreadsheetCell>> Rows)
4+
{
5+
public FileContextWorksheetStyle? Style { get; init; }
6+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace ManagedCode.FileContext;
2+
3+
/// <summary>Column dictionaries use one-based Excel column numbers. Number formats use Excel format codes.</summary>
4+
public sealed record FileContextWorksheetStyle
5+
{
6+
public bool BoldHeader { get; init; }
7+
public string? HeaderFill { get; init; }
8+
public bool FreezeHeader { get; init; }
9+
public IReadOnlyDictionary<int, string> NumberFormats { get; init; } = new Dictionary<int, string>();
10+
public IReadOnlyDictionary<int, double> ColumnWidths { get; init; } = new Dictionary<int, double>();
11+
}

‎src/ManagedCode.FileContext/Documents/FileContextDocumentService.cs‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,23 @@ public Task<FileContextCreatedFile> CreateCsvAsync(string fileName, FileContextC
1515
CreateAsync(RequireExtension(fileName, ".csv"), "text/csv; charset=utf-8",
1616
token => FileContextCsvWriter.Create(document, token), cancellationToken);
1717

18-
public Task<FileContextCreatedFile> CreateWorkbookAsync(string fileName, FileContextWorkbook workbook, CancellationToken cancellationToken = default) =>
19-
CreateAsync(RequireExtension(fileName, ".xlsx"), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
20-
token => FileContextWorkbookWriter.Create(workbook, token), cancellationToken);
18+
public async Task<FileContextCreatedFile> CreateWorkbookAsync(string fileName, FileContextWorkbook workbook, CancellationToken cancellationToken = default)
19+
{
20+
ArgumentNullException.ThrowIfNull(workbook);
21+
options.Validate();
22+
if (!options.EnableWriteTools) { throw new InvalidOperationException("Document creation requires EnableWriteTools."); }
23+
byte[]? template = null;
24+
if (workbook.TemplatePath is { } path)
25+
{
26+
RequireExtension(path, ".xlsx");
27+
using var source = await store.OpenReadAsync(path, cancellationToken).ConfigureAwait(false);
28+
using var buffer = new MemoryStream();
29+
await source.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
30+
template = buffer.ToArray();
31+
}
32+
return await CreateAsync(RequireExtension(fileName, ".xlsx"), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
33+
token => FileContextWorkbookWriter.Create(workbook, template, token), cancellationToken).ConfigureAwait(false);
34+
}
2135

2236
public Task<FileContextCreatedFile> CreatePdfAsync(string fileName, FileContextPdfDocument document, CancellationToken cancellationToken = default) =>
2337
CreateAsync(RequireExtension(fileName, ".pdf"), "application/pdf",
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using DocumentFormat.OpenXml.Packaging;
2+
using DocumentFormat.OpenXml.Spreadsheet;
3+
4+
namespace ManagedCode.FileContext;
5+
6+
internal sealed class FileContextWorkbookStyles(WorkbookPart workbook)
7+
{
8+
private const uint SystemBackgroundColor = 64;
9+
private const uint LastBuiltInNumberFormat = 163;
10+
private const int MaximumColumns = 16384;
11+
private const int MaximumColumnWidth = 255;
12+
private const int HexColorLength = 7;
13+
private readonly Dictionary<(bool Bold, string? Fill, string? Format), uint> cache = [];
14+
15+
public uint Style(FileContextWorksheetStyle style, int row, int column)
16+
{
17+
var key = (row == 0 && style.BoldHeader, row == 0 ? style.HeaderFill : null,
18+
row == 0 ? null : style.NumberFormats.GetValueOrDefault(column));
19+
if (key == (false, null, null)) { return 0; }
20+
if (cache.TryGetValue(key, out var existing)) { return existing; }
21+
var sheet = EnsureStyles();
22+
uint font = 0, fill = 0, format = 0;
23+
if (key.Item1)
24+
{
25+
font = (uint)sheet.Fonts!.ChildElements.Count;
26+
sheet.Fonts.AppendChild(new Font { Bold = new Bold() });
27+
}
28+
if (key.Item2 is { } color)
29+
{
30+
if (color.Length != HexColorLength || color[0] != '#' || color.Skip(1).Any(c => !Uri.IsHexDigit(c)))
31+
{ throw new ArgumentException("HeaderFill must be #RRGGBB.", nameof(style)); }
32+
fill = (uint)sheet.Fills!.ChildElements.Count;
33+
sheet.Fills.AppendChild(new Fill
34+
{
35+
PatternFill = new PatternFill
36+
{
37+
ForegroundColor = new ForegroundColor { Rgb = "FF" + color[1..] },
38+
BackgroundColor = new BackgroundColor { Indexed = SystemBackgroundColor },
39+
PatternType = PatternValues.Solid
40+
}
41+
});
42+
}
43+
if (key.Item3 is { } code)
44+
{
45+
if (string.IsNullOrWhiteSpace(code)) { throw new ArgumentException("Number format cannot be empty.", nameof(style)); }
46+
format = Math.Max(LastBuiltInNumberFormat, sheet.NumberingFormats!.Elements<NumberingFormat>()
47+
.Select(item => item.NumberFormatId!.Value).DefaultIfEmpty(LastBuiltInNumberFormat).Max()) + 1;
48+
sheet.NumberingFormats.AppendChild(new NumberingFormat { NumberFormatId = format, FormatCode = code });
49+
}
50+
var index = (uint)sheet.CellFormats!.ChildElements.Count;
51+
sheet.CellFormats.AppendChild(new CellFormat
52+
{
53+
FontId = font,
54+
FillId = fill,
55+
BorderId = 0,
56+
FormatId = 0,
57+
NumberFormatId = format,
58+
ApplyFont = key.Item1,
59+
ApplyFill = fill != 0,
60+
ApplyNumberFormat = format != 0
61+
});
62+
sheet.Fonts!.Count = (uint)sheet.Fonts.ChildElements.Count;
63+
sheet.Fills!.Count = (uint)sheet.Fills.ChildElements.Count;
64+
sheet.NumberingFormats!.Count = (uint)sheet.NumberingFormats.ChildElements.Count;
65+
sheet.CellFormats.Count = (uint)sheet.CellFormats.ChildElements.Count;
66+
cache[key] = index;
67+
return index;
68+
}
69+
70+
public static void Layout(Worksheet sheet, FileContextWorksheetStyle style)
71+
{
72+
if (style.NumberFormats.Keys.Concat(style.ColumnWidths.Keys).Any(column => column is < 1 or > MaximumColumns))
73+
{ throw new ArgumentException("Style columns must be one-based Excel columns 1 through 16384.", nameof(style)); }
74+
if (style.FreezeHeader)
75+
{
76+
var views = sheet.GetFirstChild<SheetViews>() ?? sheet.InsertAt(new SheetViews(), 0);
77+
var view = views.Elements<SheetView>().FirstOrDefault() ?? views.AppendChild(new SheetView { WorkbookViewId = 0 });
78+
view.RemoveAllChildren<Pane>();
79+
view.PrependChild(new Pane { VerticalSplit = 1, TopLeftCell = "A2", ActivePane = PaneValues.BottomLeft, State = PaneStateValues.Frozen });
80+
}
81+
if (style.ColumnWidths.Count == 0) { return; }
82+
var columns = sheet.GetFirstChild<Columns>();
83+
if (columns is null)
84+
{
85+
columns = new Columns();
86+
sheet.InsertBefore(columns, sheet.GetFirstChild<SheetData>());
87+
}
88+
foreach (var (column, width) in style.ColumnWidths.OrderBy(pair => pair.Key))
89+
{
90+
if (!double.IsFinite(width) || width <= 0 || width > MaximumColumnWidth)
91+
{ throw new ArgumentException("Column widths must be positive and at most Excel's 255 characters.", nameof(style)); }
92+
// A template's overlapping range is split so unrelated widths remain intact.
93+
foreach (var prior in columns.Elements<Column>().Where(item => item.Min!.Value <= column && item.Max!.Value >= column).ToArray())
94+
{
95+
if (prior.Min!.Value < column)
96+
{ var left = (Column)prior.CloneNode(true); left.Max = (uint)column - 1; columns.InsertBefore(left, prior); }
97+
if (prior.Max!.Value > column)
98+
{ var right = (Column)prior.CloneNode(true); right.Min = (uint)column + 1; columns.InsertAfter(right, prior); }
99+
prior.Remove();
100+
}
101+
columns.AppendChild(new Column { Min = (uint)column, Max = (uint)column, Width = width, CustomWidth = true });
102+
}
103+
}
104+
105+
private Stylesheet EnsureStyles()
106+
{
107+
var part = workbook.WorkbookStylesPart ?? workbook.AddNewPart<WorkbookStylesPart>();
108+
var sheet = part.Stylesheet ??= new Stylesheet();
109+
if (sheet.NumberingFormats is null) { sheet.NumberingFormats = new NumberingFormats(); }
110+
if (sheet.Fonts is null) { sheet.Fonts = new Fonts(); sheet.Fonts.AppendChild(new Font()); }
111+
if (sheet.Fills is null) { sheet.Fills = new Fills(new Fill { PatternFill = new PatternFill { PatternType = PatternValues.None } }, new Fill { PatternFill = new PatternFill { PatternType = PatternValues.Gray125 } }); }
112+
if (sheet.Borders is null) { sheet.Borders = new Borders(); sheet.Borders.AppendChild(new Border()); }
113+
if (sheet.CellStyleFormats is null) { sheet.CellStyleFormats = new CellStyleFormats(); sheet.CellStyleFormats.AppendChild(new CellFormat()); }
114+
if (sheet.CellFormats is null) { sheet.CellFormats = new CellFormats(); sheet.CellFormats.AppendChild(new CellFormat()); }
115+
return sheet;
116+
}
117+
}

‎src/ManagedCode.FileContext/Documents/Generation/FileContextWorkbookWriter.cs‎

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,36 +12,86 @@ internal static class FileContextWorkbookWriter
1212
private const int MaximumColumns = 16_384;
1313
private const int MaximumCellTextLength = 32_767;
1414

15-
public static byte[] Create(FileContextWorkbook workbook, CancellationToken cancellationToken)
15+
public static byte[] Create(FileContextWorkbook workbook, byte[]? template, CancellationToken cancellationToken)
1616
{
1717
ArgumentNullException.ThrowIfNull(workbook);
1818
if (workbook.Sheets is null) { throw new ArgumentException("Workbook sheets are required.", nameof(workbook)); }
1919
if (workbook.Sheets.Count == 0) { throw new ArgumentException("A workbook needs at least one sheet.", nameof(workbook)); }
2020
using var stream = new MemoryStream();
21-
using (var document = SpreadsheetDocument.Create(stream, SpreadsheetDocumentType.Workbook))
21+
if (template is not null) { stream.Write(template); stream.Position = 0; }
22+
using (var document = template is null ? SpreadsheetDocument.Create(stream, SpreadsheetDocumentType.Workbook) : SpreadsheetDocument.Open(stream, true))
2223
{
23-
var part = document.AddWorkbookPart();
24-
part.Workbook = new Workbook { Sheets = new Sheets() };
24+
if (document.DocumentType != SpreadsheetDocumentType.Workbook)
25+
{ throw new ArgumentException("Template must be a macro-free XLSX workbook.", nameof(template)); }
26+
var part = document.WorkbookPart ?? document.AddWorkbookPart();
27+
part.Workbook ??= new Workbook { Sheets = new Sheets() };
2528
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
29+
var styles = new FileContextWorkbookStyles(part);
2630
foreach (var sheet in workbook.Sheets)
2731
{
2832
cancellationToken.ThrowIfCancellationRequested();
2933
ValidateSheet(sheet, names);
30-
var worksheet = part.AddNewPart<WorksheetPart>();
31-
worksheet.Worksheet = new Worksheet();
32-
worksheet.Worksheet.AppendChild(CreateRows(sheet.Rows, cancellationToken));
33-
part.Workbook.GetFirstChild<Sheets>()!.AppendChild(new Sheet
34-
{
35-
Id = part.GetIdOfPart(worksheet),
36-
SheetId = (uint)names.Count,
37-
Name = sheet.Name
38-
});
34+
WriteSheet(part, sheet, styles, cancellationToken);
3935
}
4036
part.Workbook.Save();
4137
}
4238
return stream.ToArray();
4339
}
4440

41+
private static void WriteSheet(WorkbookPart part, FileContextWorksheet sheet, FileContextWorkbookStyles styles, CancellationToken token)
42+
{
43+
if (sheet.Style is { } requestedStyle &&
44+
(requestedStyle.NumberFormats is null || requestedStyle.ColumnWidths is null))
45+
{ throw new ArgumentException("Style numberFormats and columnWidths must be objects.", nameof(sheet)); }
46+
var sheets = part.Workbook!.GetFirstChild<Sheets>() ?? part.Workbook.AppendChild(new Sheets());
47+
var existing = sheets.Elements<Sheet>().FirstOrDefault(item => string.Equals(item.Name, sheet.Name, StringComparison.OrdinalIgnoreCase));
48+
var worksheet = existing is null ? part.AddNewPart<WorksheetPart>() : (WorksheetPart)part.GetPartById(existing.Id!);
49+
worksheet.Worksheet ??= new Worksheet();
50+
var prior = worksheet.Worksheet.GetFirstChild<SheetData>();
51+
var data = CreateRows(sheet.Rows, sheet.Style, styles, token);
52+
if (prior is null) { worksheet.Worksheet.AppendChild(data); }
53+
else
54+
{
55+
InheritStyles(data, prior);
56+
worksheet.Worksheet.ReplaceChild(data, prior);
57+
}
58+
if (sheet.Style is { } style) { FileContextWorkbookStyles.Layout(worksheet.Worksheet, style); }
59+
if (existing is null)
60+
{
61+
var nextId = sheets.Elements<Sheet>().Select(item => item.SheetId!.Value).DefaultIfEmpty(0U).Max() + 1;
62+
sheets.AppendChild(new Sheet { Id = part.GetIdOfPart(worksheet), SheetId = nextId, Name = sheet.Name });
63+
}
64+
worksheet.Worksheet.Save();
65+
}
66+
67+
private static void InheritStyles(SheetData data, SheetData prior)
68+
{
69+
var inherited = new Dictionary<(uint Row, int Column), uint>();
70+
uint rowNumber = 0;
71+
foreach (var row in prior.Elements<Row>())
72+
{
73+
rowNumber = row.RowIndex?.Value ?? rowNumber + 1;
74+
var column = 0;
75+
foreach (var cell in row.Elements<Cell>())
76+
{
77+
column = cell.CellReference is null ? column + 1 : FileContextWorkbookReader.ReadColumn(cell.CellReference.Value!);
78+
if (cell.StyleIndex is { } style) { inherited[(rowNumber, column)] = style.Value; }
79+
}
80+
}
81+
rowNumber = 0;
82+
foreach (var row in data.Elements<Row>())
83+
{
84+
rowNumber++;
85+
var column = 0;
86+
foreach (var cell in row.Elements<Cell>())
87+
{
88+
column++;
89+
if (cell.StyleIndex is null && inherited.TryGetValue((rowNumber, column), out var style))
90+
{ cell.StyleIndex = style; }
91+
}
92+
}
93+
}
94+
4595
private static void ValidateSheet(FileContextWorksheet sheet, HashSet<string> names)
4696
{
4797
ArgumentNullException.ThrowIfNull(sheet);
@@ -55,15 +105,25 @@ private static void ValidateSheet(FileContextWorksheet sheet, HashSet<string> na
55105
if (sheet.Rows.Count > MaximumRows) { throw new ArgumentException("A sheet exceeds Excel's row limit.", nameof(sheet)); }
56106
}
57107

58-
private static SheetData CreateRows(IReadOnlyList<IReadOnlyList<FileContextSpreadsheetCell>> rows, CancellationToken token)
108+
private static SheetData CreateRows(IReadOnlyList<IReadOnlyList<FileContextSpreadsheetCell>> rows, FileContextWorksheetStyle? style, FileContextWorkbookStyles styles, CancellationToken token)
59109
{
60110
var data = new SheetData();
111+
var rowIndex = 0;
61112
foreach (var values in rows)
62113
{
63114
token.ThrowIfCancellationRequested();
64115
if (values is null) { throw new ArgumentException("A row must contain a cell list.", nameof(rows)); }
65116
if (values.Count > MaximumColumns) { throw new ArgumentException("A row exceeds Excel's column limit.", nameof(rows)); }
66-
data.AppendChild(new Row(values.Select(CreateCell)));
117+
var row = new Row();
118+
for (var column = 0; column < values.Count; column++)
119+
{
120+
var cell = CreateCell(values[column]);
121+
if (style is not null && styles.Style(style, rowIndex, column + 1) is var index && index != 0)
122+
{ cell.StyleIndex = index; }
123+
row.AppendChild(cell);
124+
}
125+
data.AppendChild(row);
126+
rowIndex++;
67127
}
68128
return data;
69129
}

0 commit comments

Comments
 (0)