Skip to content

Commit 2a9610f

Browse files
committed
Add native XLSX worksheet and cell range reads
1 parent c96adbb commit 2a9610f

16 files changed

Lines changed: 374 additions & 2 deletions

‎CHANGELOG.md‎

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

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

5+
## 1.0.3
6+
7+
- Add read-only native XLSX worksheet metadata and sparse cell-range tools, with stored types, exact coordinates and cached formula results.
8+
- Preserve scoped storage, read approvals, cancellation and source/output budgets; verify native reads through the actual agent tool loop.
9+
510
## 1.0.1
611

712
- Add scoped creation of text, CSV, XLSX workbooks and Unicode PDF documents.

‎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.2</Version>
15+
<Version>1.0.3</Version>
1616
<PackageVersion>$(Version)</PackageVersion>
1717
</PropertyGroup>
1818

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,3 +385,15 @@ Document generation uses DocumentFormat.OpenXml (MIT), PdfPig (Apache-2.0), and
385385
### Reading large files
386386

387387
Use `file_context_info` first: it returns the exact byte length, content type and modification time without loading file contents. Search for relevant text, then request the needed line ranges. The context provider supplies this guidance to the model on invocation. Full reads remain available when the task requires complete contents; exhaustive processing should advance through ranges without omitting data or rereading unchanged ranges. Byte length is not a token count.
388+
389+
### Native Excel reads
390+
391+
Read-only contexts also expose `file_context_workbook_info` and `file_context_workbook_range`.
392+
Use `context.Documents.GetWorkbookInfoAsync(path)` to discover worksheet names, then
393+
`context.Documents.ReadWorkbookRangeAsync(path, sheet, startRow, rowCount, startColumn, columnCount)`.
394+
Rows and columns are one-based. Range results are sparse: absent cell coordinates are blank.
395+
Values retain stored numeric precision and text leading zeros. Formula text and the saved cached
396+
result are returned without evaluation; cached results may be absent or stale. Excel display
397+
formatting is not applied. These tools use the same relative-path scope, read approval policy,
398+
cancellation and configured source/range byte budgets as other reads. XLSX files must be present
399+
in the scoped store; text extraction is not required for these native reads.

‎docs/Architecture.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,18 @@ flowchart LR
100100
Documents --> Store[ManagedCodeStorageFileStore]
101101
Store --> Storage[IStorage]
102102
```
103+
104+
## Native workbook reads
105+
106+
`FileContextDocumentService` opens XLSX packages read-only from the scoped storage adapter.
107+
`FileContextWorkbookReader` maps worksheets and cell rectangles into package-owned result records.
108+
It preserves coordinates and stored types without evaluating formulas or accessing external links.
109+
110+
```mermaid
111+
flowchart LR
112+
Provider[FileContextProvider] --> Tools[Workbook metadata and range tools]
113+
Tools --> Documents[FileContextDocumentService]
114+
Documents --> Store[Scoped storage adapter]
115+
Documents --> Reader[Read-only Open XML reader]
116+
Reader --> Result[Sparse cells with coordinates and cached values]
117+
```
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using DocumentFormat.OpenXml.Packaging;
2+
3+
namespace ManagedCode.FileContext;
4+
5+
public sealed partial class FileContextDocumentService
6+
{
7+
public Task<FileContextWorkbookInfo> GetWorkbookInfoAsync(string path, CancellationToken cancellationToken = default) =>
8+
ReadWorkbookAsync(path, (document, _) => FileContextWorkbookReader.Inspect(document, path), cancellationToken);
9+
10+
public Task<FileContextWorkbookRange> ReadWorkbookRangeAsync(string path, string sheet, int startRow, int rowCount,
11+
int startColumn, int columnCount, CancellationToken cancellationToken = default)
12+
{
13+
ArgumentException.ThrowIfNullOrWhiteSpace(sheet);
14+
FileContextWorkbookReader.ValidateRange(startRow, rowCount, startColumn, columnCount);
15+
return ReadWorkbookAsync(path, (document, token) => FileContextWorkbookReader.Read(document, path, sheet,
16+
startRow, rowCount, startColumn, columnCount, options.MaximumRangeReadBytes, token), cancellationToken);
17+
}
18+
19+
private Task<T> ReadWorkbookAsync<T>(string path, Func<SpreadsheetDocument, CancellationToken, T> read, CancellationToken cancellationToken)
20+
{
21+
RequireExtension(path, ".xlsx");
22+
return FileContextOperation.RunAsync(options.OperationTimeout, async token =>
23+
{
24+
var metadata = await store.GetMetadataAsync(path, token).ConfigureAwait(false)
25+
?? throw new FileNotFoundException("The workbook was not found in this file context.", path);
26+
if (metadata.Length > (ulong)options.MaximumFullReadBytes)
27+
{ throw new IOException("Workbook exceeds the configured source read budget."); }
28+
var source = await store.OpenReadAsync(path, token).ConfigureAwait(false);
29+
await using var lifetime = source.ConfigureAwait(false);
30+
using var buffer = new MemoryStream();
31+
var chunk = new byte[81920];
32+
int count;
33+
while ((count = await source.ReadAsync(chunk, token).ConfigureAwait(false)) > 0)
34+
{
35+
if (buffer.Length + count > options.MaximumFullReadBytes)
36+
{ throw new IOException("Workbook exceeds the configured source read budget."); }
37+
buffer.Write(chunk, 0, count);
38+
}
39+
buffer.Position = 0;
40+
token.ThrowIfCancellationRequested();
41+
using var document = SpreadsheetDocument.Open(buffer, false);
42+
return read(document, token);
43+
}, cancellationToken);
44+
}
45+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
namespace ManagedCode.FileContext;
44

55
/// <summary>Creates new documents in the same scoped file store used by navigation and reads.</summary>
6-
public sealed class FileContextDocumentService(ManagedCodeStorageFileStore store, FileContextOptions options)
6+
public sealed partial class FileContextDocumentService(ManagedCodeStorageFileStore store, FileContextOptions options)
77
{
88
public Task<FileContextCreatedFile> CreateTextAsync(string fileName, string text, CancellationToken cancellationToken = default)
99
{
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
namespace ManagedCode.FileContext;
2+
3+
/// <summary>Formula is source text only. Value is the saved cached result and can be empty; formulas are never evaluated.</summary>
4+
public sealed record FileContextWorkbookCell(string Address, string Type, string Value, string? Formula);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace ManagedCode.FileContext;
2+
3+
public sealed record FileContextWorkbookInfo(string Path, IReadOnlyList<FileContextWorksheetInfo> Sheets);
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
namespace ManagedCode.FileContext;
2+
3+
/// <summary>A sparse rectangular range. Coordinates absent from Cells are blank; values are stored values, not formatted display text.</summary>
4+
public sealed record FileContextWorkbookRange(string Path, string Sheet, string Range, IReadOnlyList<FileContextWorkbookCell> Cells);
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using DocumentFormat.OpenXml;
4+
using DocumentFormat.OpenXml.Packaging;
5+
using DocumentFormat.OpenXml.Spreadsheet;
6+
7+
namespace ManagedCode.FileContext;
8+
9+
internal static class FileContextWorkbookReader
10+
{
11+
private const int AlphabetLength = 26;
12+
private const int MaximumRows = 1_048_576;
13+
private const int MaximumColumns = 16_384;
14+
15+
public static FileContextWorkbookInfo Inspect(SpreadsheetDocument document, string path)
16+
{
17+
var workbook = RequireWorkbook(document);
18+
var sheets = workbook.Workbook?.Sheets?.Elements<Sheet>() ?? [];
19+
return new(path, sheets.Select(sheet => new FileContextWorksheetInfo(
20+
sheet.Name?.Value ?? string.Empty,
21+
sheet.State?.InnerText ?? "visible",
22+
((WorksheetPart)workbook.GetPartById(sheet.Id!)).Worksheet?.GetFirstChild<SheetDimension>()?.Reference?.Value)).ToArray());
23+
}
24+
25+
public static FileContextWorkbookRange Read(SpreadsheetDocument document, string path, string sheetName,
26+
int startRow, int rowCount, int startColumn, int columnCount, long byteLimit, CancellationToken token)
27+
{
28+
ValidateRange(startRow, rowCount, startColumn, columnCount);
29+
var workbook = RequireWorkbook(document);
30+
var sheet = workbook.Workbook?.Sheets?.Elements<Sheet>().SingleOrDefault(item =>
31+
string.Equals(item.Name?.Value, sheetName, StringComparison.Ordinal))
32+
?? throw new ArgumentException("The requested worksheet does not exist.", nameof(sheetName));
33+
var worksheet = workbook.GetPartById(sheet.Id!) as WorksheetPart
34+
?? throw new InvalidDataException("The requested sheet is not a worksheet.");
35+
var strings = workbook.SharedStringTablePart?.SharedStringTable?.Elements<SharedStringItem>()
36+
.Select(static item => string.Concat(item.Descendants<Text>().Select(text => text.Text))).ToArray() ?? [];
37+
var cells = ReadCells(worksheet, strings, startRow, rowCount, startColumn, columnCount, byteLimit, token);
38+
var range = $"{ColumnName(startColumn)}{startRow}:{ColumnName(startColumn + columnCount - 1)}{startRow + rowCount - 1}";
39+
return new(path, sheetName, range, cells);
40+
}
41+
42+
public static void ValidateRange(int startRow, int rowCount, int startColumn, int columnCount)
43+
{
44+
if (startRow < 1 || rowCount < 1 || (long)startRow + rowCount - 1 > MaximumRows)
45+
{ throw new ArgumentOutOfRangeException(nameof(rowCount), "The range must fit Excel's 1048576 rows, starting at one."); }
46+
if (startColumn < 1 || columnCount < 1 || (long)startColumn + columnCount - 1 > MaximumColumns)
47+
{ throw new ArgumentOutOfRangeException(nameof(columnCount), "The range must fit Excel's 16384 columns, starting at one."); }
48+
}
49+
50+
private static List<FileContextWorkbookCell> ReadCells(WorksheetPart worksheet, string[] strings,
51+
int startRow, int rowCount, int startColumn, int columnCount, long byteLimit, CancellationToken token)
52+
{
53+
using var reader = OpenXmlReader.Create(worksheet);
54+
var cells = new List<FileContextWorkbookCell>();
55+
long bytes = 0;
56+
var rowNumber = 0;
57+
while (reader.Read())
58+
{
59+
token.ThrowIfCancellationRequested();
60+
if (reader.ElementType != typeof(Row) || !reader.IsStartElement) { continue; }
61+
var row = (Row)reader.LoadCurrentElement()!;
62+
rowNumber = row.RowIndex is null ? rowNumber + 1 : checked((int)row.RowIndex.Value);
63+
if (rowNumber < startRow || rowNumber >= startRow + rowCount) { continue; }
64+
var column = 0;
65+
foreach (var cell in row.Elements<Cell>())
66+
{
67+
token.ThrowIfCancellationRequested();
68+
column = cell.CellReference is null ? column + 1 : ReadColumn(cell.CellReference.Value!);
69+
if (column < startColumn || column >= startColumn + columnCount) { continue; }
70+
var value = ReadCell(cell, strings, $"{ColumnName(column)}{rowNumber}");
71+
bytes += Encoding.UTF8.GetByteCount(value.Value) + Encoding.UTF8.GetByteCount(value.Formula ?? string.Empty) + value.Address.Length + value.Type.Length;
72+
if (bytes > byteLimit) { throw new IOException("Workbook range exceeds the configured read budget. Request a smaller range."); }
73+
cells.Add(value);
74+
}
75+
}
76+
return cells;
77+
}
78+
79+
private static FileContextWorkbookCell ReadCell(Cell cell, string[] strings, string address)
80+
{
81+
var type = cell.DataType?.Value;
82+
var value = cell.CellValue?.Text ?? string.Empty;
83+
if (type == CellValues.SharedString)
84+
{
85+
if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var index) || index < 0 || index >= strings.Length)
86+
{ throw new InvalidDataException("Workbook cell contains an invalid shared-string reference."); }
87+
value = strings[index];
88+
}
89+
if (type == CellValues.InlineString)
90+
{ value = string.Concat(cell.InlineString?.Descendants<Text>().Select(static text => text.Text) ?? []); }
91+
var kind = "number";
92+
if (type == CellValues.SharedString || type == CellValues.InlineString || type == CellValues.String) { kind = "text"; }
93+
else if (type == CellValues.Boolean) { kind = "boolean"; }
94+
else if (type == CellValues.Error) { kind = "error"; }
95+
else if (type == CellValues.Date) { kind = "date"; }
96+
else if (value.Length == 0) { kind = "blank"; }
97+
return new(address, kind, value, cell.CellFormula?.Text);
98+
}
99+
100+
private static WorkbookPart RequireWorkbook(SpreadsheetDocument document) =>
101+
document.WorkbookPart ?? throw new InvalidDataException("The package has no Excel workbook.");
102+
103+
private static int ReadColumn(string address)
104+
{
105+
var result = 0;
106+
foreach (var character in address.TakeWhile(char.IsAsciiLetter))
107+
{ result = checked(result * AlphabetLength + char.ToUpperInvariant(character) - 'A' + 1); }
108+
return result;
109+
}
110+
111+
private static string ColumnName(int column)
112+
{
113+
var result = new StringBuilder();
114+
while (column > 0)
115+
{
116+
column--;
117+
result.Insert(0, (char)('A' + column % AlphabetLength));
118+
column /= AlphabetLength;
119+
}
120+
return result.ToString();
121+
}
122+
}

0 commit comments

Comments
 (0)