Skip to content

Commit d00a1ba

Browse files
committed
Stage cloud PDF sources before synchronous parsing
1 parent 5a36845 commit d00a1ba

12 files changed

Lines changed: 315 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.14
6+
7+
- Stage seekable cloud PDF streams asynchronously to bounded temporary files before synchronous parser reads and seeks.
8+
- Preserve local file/memory sources by default and expose typed staging mode, buffer size and temporary-directory options.
9+
- Return pooled staging buffers and delete temporary sources on completion, cancellation and failures.
10+
511
## 1.0.13
612

713
- Parse PDF text, pages and embedded images from bounded seekable streams instead of whole-document arrays.

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

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,12 @@ Results include `StartLine`, `EndLine`, `HasMore`, and `TotalLines` when the end
185185

186186
## Read PDFs and send pages to vision models
187187

188+
Storage-backed PDF tools stage seekable cloud streams asynchronously before parsing. PdfPig's
189+
synchronous reads and seeks then use a temporary file rather than repeated network ranges. Local
190+
seekable files and memory streams remain reusable. `PdfSourceStagingMode` can force `TemporaryFile`
191+
staging, `PdfSourceBufferBytes` bounds the copy buffer, and `PdfTemporaryDirectory` optionally selects
192+
an existing host directory. Disposal, cancellation and staging failures remove temporary sources.
193+
188194
`IFileContextPdf.ReadPdfTextAsync(path)` returns bounded text, `PageCount`, and one-based `PagesWithoutText`. It does not perform OCR. A scanned page can instead be rendered with `RenderPdfPageAsync(path, pageNumber)`, which returns PNG `DataContent`. Use `CountPdfPageImagesAsync` and `ExtractPdfImageAsync` when the original embedded pictures are needed rather than the complete page. The four read-only `file_context_pdf_*` tools expose the same operations from scoped storage.
189195

190196
For an authenticated PDF already held as bytes, `FileContextPdfTextExtractor.Extract`, `FileContextPdfImages.RenderPagePng`, and `FileContextPdfImages.ExtractPageImagesPng` work without storing it. PDF source reads default to 100 MiB and accept `FileContextOptions` for a different limit; page rasterization also uses configured pixel and PNG limits. `FileContextImageContent` creates model-visible `DataContent` from PNG bytes or base64 and `UriContent` from an HTTPS URL. A URL reference is not fetched by FileContext, so the model provider must be able to access it. A host must pass image content to its model as image content. A generic OpenAI Chat function result serializes it as text, so hosts must explicitly bridge image tool results into a multimodal model message.

‎docs/Architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ source and retaining it through parsing/rendering. `MaximumConcurrentPdfOperatio
8282
`FileContextPdfRenderDocument` exposes page count and sequential page rendering from one parsed PDF;
8383
dispose it after the batch. The low-level synchronous image helpers remain caller-scheduled APIs.
8484

85-
PDF text, page rendering and embedded-image APIs accept bounded seekable streams. Storage-backed PDF tools keep seekable provider streams directly and stage non-seekable sources to an automatically deleted temporary file, never a whole-document managed array. Native raster decoding has a separate per-page source-image pixel budget; lowering output scale does not reduce source bitmap allocation.
85+
PDF text, page rendering and embedded-image APIs accept bounded seekable streams. Storage-backed PDF tools retain seekable local `FileStream`/`MemoryStream` inputs but asynchronously stage other streams, including seekable cloud streams, to an automatically deleted temporary file. PdfPig's synchronous byte reads and seeks then remain local, without blocking on repeated network ranges. `PdfSourceStagingMode.TemporaryFile` also stages local inputs. `PdfSourceBufferBytes` bounds every copy read and `PdfTemporaryDirectory` optionally selects an existing host directory. No whole-document managed array is created. Native raster decoding has a separate per-page source-image pixel budget; lowering output scale does not reduce source bitmap allocation.
8686

8787
All potentially large operations are controlled by `IOptions<FileContextOptions>`: PDF source/page/image budgets, full-read bytes, range bytes, files scanned, bytes per searched file, matches per file, total search results, graph documents, graph source bytes, and exported graph characters. Non-seekable cloud streams are supported by sequential streaming.
8888

‎docs/Features/file-context.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ Verification: DocumentCreationTests and DocumentValidationTests reopen real form
115115

116116
## PDF reads and vision images
117117

118+
PDF source staging keeps synchronous parser I/O local. The default `Automatic` mode reuses
119+
seekable `FileStream` and `MemoryStream` sources and asynchronously stages all other inputs,
120+
including seekable cloud streams. `TemporaryFile` stages every source. The configured
121+
`PdfSourceBufferBytes` bounds each asynchronous copy read; `PdfTemporaryDirectory` may name an
122+
existing host directory. Size rejection, cancellation and copy failures dispose the input and
123+
delete any staged file. Source ownership continues through document rendering and caller disposal.
124+
118125
DOCX reading uses the native `file_context_docx_text` tool. It reads ordinary paragraph and table
119126
text from the scoped `.docx` package in bounded windows. Each result includes the next paragraph
120127
and character offset when more text remains, so an agent can continue without loading a long

‎docs/Testing/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The suite is integration-first:
1010
- timeout tests cover configured operation expiry, cancellation of every public operation, disabled deadlines, duration validation, and timeout tool results through restored sessions;
1111
- concurrent storage tests write and range-read eight independent files through one shared adapter/service;
1212
- a sparse 1 GiB filesystem test reads bounded line windows repeatedly, rejects full-file loading, caps allocations, and proves that an oversized line fails before it can be buffered in memory.
13+
- PDF cloud-source tests use real files behind an async-only seekable stream, proving parsing uses the staged local file; they cover large inputs, configured buffers/directories, forced staging, local-file reuse, limits, mid-copy cancellation, failure cleanup and private Unix permissions.
1314

1415
Every filesystem test owns a unique temporary root and removes it on disposal. Test execution is serialized so process-wide allocation assertions cannot be distorted by another test. No `IStorage`, Agent Framework, Markdown-LD, or LlmTck mocks are used.
1516

‎src/ManagedCode.FileContext/FileContextDefaults.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public static class FileContextDefaults
88
public const int FirstLineNumber = 1;
99
public const int MaximumPdfReadBytes = 100 * 1024 * 1024;
1010
public const int MaximumConcurrentPdfOperations = 1;
11+
public const int PdfSourceBufferBytes = 81920;
1112
public const int MaximumImageBytes = 8 * 1024 * 1024;
1213
public const int MaximumDecodedPdfImagePixels = 32_000_000;
1314
public const int MaximumRenderedPagePixels = 4_000_000;

‎src/ManagedCode.FileContext/FileContextOptions.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using ManagedCode.FileContext.Pdf;
2+
13
namespace ManagedCode.FileContext;
24

35
/// <summary>Controls file access, approval, search, and graph limits for one context provider.</summary>
@@ -23,6 +25,15 @@ public sealed class FileContextOptions
2325

2426
public int MaximumPdfReadBytes { get; set; } = FileContextDefaults.MaximumPdfReadBytes;
2527

28+
/// <summary>Stages cloud streams before synchronous parsing; Automatic reuses local files and memory.</summary>
29+
public FileContextPdfSourceStagingMode PdfSourceStagingMode { get; set; } = FileContextPdfSourceStagingMode.Automatic;
30+
31+
/// <summary>Maximum bytes requested by one asynchronous PDF source staging read.</summary>
32+
public int PdfSourceBufferBytes { get; set; } = FileContextDefaults.PdfSourceBufferBytes;
33+
34+
/// <summary>Existing directory for temporary PDF sources. Null uses the operating system's temp directory.</summary>
35+
public string? PdfTemporaryDirectory { get; set; }
36+
2637
/// <summary>Maximum simultaneous PDF reads/renders per shared processor, including source buffering.</summary>
2738
public int MaximumConcurrentPdfOperations { get; set; } = FileContextDefaults.MaximumConcurrentPdfOperations;
2839

@@ -81,6 +92,16 @@ internal void Validate()
8192
{
8293
ValidatePositive(MaximumGeneratedFileBytes, nameof(MaximumGeneratedFileBytes));
8394
ValidatePositive(MaximumPdfReadBytes, nameof(MaximumPdfReadBytes));
95+
ValidatePositive(PdfSourceBufferBytes, nameof(PdfSourceBufferBytes));
96+
if (PdfSourceStagingMode is not FileContextPdfSourceStagingMode.Automatic
97+
and not FileContextPdfSourceStagingMode.TemporaryFile)
98+
{
99+
throw new InvalidOperationException("The PDF source staging mode is invalid.");
100+
}
101+
if (PdfTemporaryDirectory is not null && string.IsNullOrWhiteSpace(PdfTemporaryDirectory))
102+
{
103+
throw new InvalidOperationException("The PDF temporary directory must be a nonempty path or null.");
104+
}
84105
ValidatePositive(MaximumConcurrentPdfOperations, nameof(MaximumConcurrentPdfOperations));
85106
ValidatePositive(MaximumImageBytes, nameof(MaximumImageBytes));
86107
ValidatePositive(MaximumDecodedPdfImagePixels, nameof(MaximumDecodedPdfImagePixels));

‎src/ManagedCode.FileContext/Pdf/FileContextPdfSource.cs‎

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
using System.Buffers;
2+
13
namespace ManagedCode.FileContext.Pdf;
24

3-
/// <summary>Owns a bounded seekable PDF source; non-seekable inputs are staged to disk.</summary>
5+
/// <summary>Owns a bounded local PDF source; cloud inputs are staged before synchronous random reads.</summary>
46
public sealed class FileContextPdfSource : IAsyncDisposable
57
{
6-
private const int CopyBufferBytes = 81920;
78
private const string TemporaryFilePrefix = "filecontext-pdf-";
89

910
private FileContextPdfSource(Stream stream) => Stream = stream;
@@ -24,28 +25,26 @@ public static async Task<FileContextPdfSource> OpenAsync(Stream source, FileCont
2425
if (source.CanSeek)
2526
{
2627
Validate(source, options);
27-
source.Position = 0;
28-
return new FileContextPdfSource(source);
2928
}
30-
staged = CreateTemporaryFile();
31-
var buffer = new byte[CopyBufferBytes];
32-
int read;
33-
while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
29+
else if (!source.CanRead)
3430
{
35-
if (staged.Length + read > options.MaximumPdfReadBytes)
36-
{
37-
throw new IOException("The PDF exceeds the read limit.");
38-
}
39-
await staged.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
31+
throw new ArgumentException("The PDF source must be readable.", nameof(source));
4032
}
33+
if (options.PdfSourceStagingMode == FileContextPdfSourceStagingMode.Automatic
34+
&& source.CanSeek && source is FileStream or MemoryStream)
35+
{
36+
return new FileContextPdfSource(source);
37+
}
38+
staged = CreateTemporaryFile(options);
39+
await CopyAsync(source, staged, options, cancellationToken).ConfigureAwait(false);
4140
staged.Position = 0;
4241
await source.DisposeAsync().ConfigureAwait(false);
4342
return new FileContextPdfSource(staged);
4443
}
4544
catch
4645
{
47-
if (staged is not null) { await staged.DisposeAsync().ConfigureAwait(false); }
48-
await source.DisposeAsync().ConfigureAwait(false);
46+
try { if (staged is not null) { await staged.DisposeAsync().ConfigureAwait(false); } }
47+
finally { await source.DisposeAsync().ConfigureAwait(false); }
4948
throw;
5049
}
5150
}
@@ -64,10 +63,43 @@ internal static void Validate(Stream source, FileContextOptions options)
6463
source.Position = 0;
6564
}
6665

67-
private static FileStream CreateTemporaryFile() => new(
68-
Path.Combine(Path.GetTempPath(), TemporaryFilePrefix + Guid.NewGuid().ToString("N")),
69-
FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, CopyBufferBytes,
70-
FileOptions.Asynchronous | FileOptions.DeleteOnClose);
66+
private static async Task CopyAsync(Stream source, Stream staged, FileContextOptions options,
67+
CancellationToken cancellationToken)
68+
{
69+
var buffer = ArrayPool<byte>.Shared.Rent(options.PdfSourceBufferBytes);
70+
try
71+
{
72+
int read;
73+
while ((read = await source.ReadAsync(buffer.AsMemory(0, options.PdfSourceBufferBytes), cancellationToken)
74+
.ConfigureAwait(false)) > 0)
75+
{
76+
if (staged.Length + read > options.MaximumPdfReadBytes)
77+
{
78+
throw new IOException("The PDF exceeds the read limit.");
79+
}
80+
await staged.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
81+
}
82+
}
83+
finally { ArrayPool<byte>.Shared.Return(buffer, clearArray: true); }
84+
}
85+
86+
private static FileStream CreateTemporaryFile(FileContextOptions options)
87+
{
88+
var fileOptions = new FileStreamOptions
89+
{
90+
Mode = FileMode.CreateNew,
91+
Access = FileAccess.ReadWrite,
92+
Share = FileShare.None,
93+
BufferSize = options.PdfSourceBufferBytes,
94+
Options = FileOptions.Asynchronous | FileOptions.DeleteOnClose
95+
};
96+
if (!OperatingSystem.IsWindows())
97+
{
98+
fileOptions.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
99+
}
100+
return new FileStream(Path.Combine(options.PdfTemporaryDirectory ?? Path.GetTempPath(),
101+
TemporaryFilePrefix + Guid.NewGuid().ToString("N")), fileOptions);
102+
}
71103

72104
public ValueTask DisposeAsync() => Stream.DisposeAsync();
73105
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace ManagedCode.FileContext.Pdf;
2+
3+
/// <summary>Controls where synchronous PDF parsers perform random reads.</summary>
4+
public enum FileContextPdfSourceStagingMode
5+
{
6+
/// <summary>Reuse seekable local files or memory; stage other streams asynchronously to disk.</summary>
7+
Automatic,
8+
9+
/// <summary>Stage every input to a temporary file, including seekable local sources.</summary>
10+
TemporaryFile
11+
}

0 commit comments

Comments
 (0)