Skip to content

Commit 2c3a792

Browse files
committed
Bound PDF processing and reuse parsed render documents
1 parent 7bd388a commit 2c3a792

12 files changed

Lines changed: 231 additions & 31 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.12
6+
7+
- Bound native PDF reads and rendering before source buffering through a shared, configurable PDF processor.
8+
- Expose a disposable render document with page count and sequential page rendering from one parse.
9+
510
## 1.0.11
611

712
- Bind and validate FileContext limits through `IOptions<FileContextOptions>`, including keyed registrations and host configuration.

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

‎docs/Architecture.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ flowchart TD
7777

7878
## Operational limits
7979

80+
PDF callers share `FileContextPdfProcessor`, acquiring a permit before reading or downloading the
81+
source and retaining it through parsing/rendering. `MaximumConcurrentPdfOperations` defaults to one.
82+
`FileContextPdfRenderDocument` exposes page count and sequential page rendering from one parsed PDF;
83+
dispose it after the batch. The low-level synchronous image helpers remain caller-scheduled APIs.
84+
8085
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.
8186

8287
## Start here

‎src/ManagedCode.FileContext/FileContextDefaults.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public static class FileContextDefaults
77
public const long MaximumGeneratedFileBytes = 64L * 1024L * 1024L;
88
public const int FirstLineNumber = 1;
99
public const int MaximumPdfReadBytes = 100 * 1024 * 1024;
10+
public const int MaximumConcurrentPdfOperations = 1;
1011
public const int MaximumImageBytes = 8 * 1024 * 1024;
1112
public const int MaximumRenderedPagePixels = 4_000_000;
1213
public const int MaximumImagesPerPdfPage = 20;

‎src/ManagedCode.FileContext/FileContextOptions.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ public sealed class FileContextOptions
2323

2424
public int MaximumPdfReadBytes { get; set; } = FileContextDefaults.MaximumPdfReadBytes;
2525

26+
/// <summary>Maximum simultaneous PDF reads/renders per shared processor, including source buffering.</summary>
27+
public int MaximumConcurrentPdfOperations { get; set; } = FileContextDefaults.MaximumConcurrentPdfOperations;
28+
2629
public int MaximumImageBytes { get; set; } = FileContextDefaults.MaximumImageBytes;
2730

2831
public int MaximumRenderedPagePixels { get; set; } = FileContextDefaults.MaximumRenderedPagePixels;
@@ -75,6 +78,7 @@ internal void Validate()
7578
{
7679
ValidatePositive(MaximumGeneratedFileBytes, nameof(MaximumGeneratedFileBytes));
7780
ValidatePositive(MaximumPdfReadBytes, nameof(MaximumPdfReadBytes));
81+
ValidatePositive(MaximumConcurrentPdfOperations, nameof(MaximumConcurrentPdfOperations));
7882
ValidatePositive(MaximumImageBytes, nameof(MaximumImageBytes));
7983
ValidatePositive(MaximumRenderedPagePixels, nameof(MaximumRenderedPagePixels));
8084
ValidatePositive(MaximumImagesPerPdfPage, nameof(MaximumImagesPerPdfPage));

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,37 @@ public Task<FileContextPdfText> ReadPdfTextAsync(string path,
1717
throw new ArgumentOutOfRangeException(nameof(maxCharacters));
1818
}
1919
return FileContextOperation.RunAsync(_options.OperationTimeout, async token =>
20-
FileContextPdfTextExtractor.Extract(await ReadPdfBytesAsync(path, token).ConfigureAwait(false), maxCharacters), cancellationToken);
20+
{
21+
using var permit = await _pdfProcessor.AcquireAsync(token).ConfigureAwait(false);
22+
return FileContextPdfTextExtractor.Extract(await ReadPdfBytesAsync(path, token).ConfigureAwait(false), maxCharacters);
23+
}, cancellationToken);
2124
}
2225

2326
public Task<DataContent> RenderPdfPageAsync(string path, int pageNumber,
2427
CancellationToken cancellationToken = default) =>
2528
FileContextOperation.RunAsync(_options.OperationTimeout, async token =>
26-
FileContextImageContent.FromPngBytes(FileContextPdfImages.RenderPagePng(
27-
await ReadPdfBytesAsync(path, token).ConfigureAwait(false), pageNumber, _options), _options), cancellationToken);
29+
{
30+
using var permit = await _pdfProcessor.AcquireAsync(token).ConfigureAwait(false);
31+
return FileContextImageContent.FromPngBytes(FileContextPdfImages.RenderPagePng(
32+
await ReadPdfBytesAsync(path, token).ConfigureAwait(false), pageNumber, _options), _options);
33+
}, cancellationToken);
2834

2935
public Task<int> CountPdfPageImagesAsync(string path, int pageNumber,
3036
CancellationToken cancellationToken = default) =>
3137
FileContextOperation.RunAsync(_options.OperationTimeout, async token =>
32-
FileContextPdfImages.ExtractPageImagesPng(
33-
await ReadPdfBytesAsync(path, token).ConfigureAwait(false), pageNumber, _options).Count, cancellationToken);
38+
{
39+
using var permit = await _pdfProcessor.AcquireAsync(token).ConfigureAwait(false);
40+
return FileContextPdfImages.ExtractPageImagesPng(
41+
await ReadPdfBytesAsync(path, token).ConfigureAwait(false), pageNumber, _options).Count;
42+
}, cancellationToken);
3443

3544
public Task<DataContent> ExtractPdfImageAsync(string path, int pageNumber, int imageNumber,
3645
CancellationToken cancellationToken = default)
3746
{
3847
ArgumentOutOfRangeException.ThrowIfLessThan(imageNumber, 1);
3948
return FileContextOperation.RunAsync(_options.OperationTimeout, async token =>
4049
{
50+
using var permit = await _pdfProcessor.AcquireAsync(token).ConfigureAwait(false);
4151
var images = FileContextPdfImages.ExtractPageImagesPng(
4252
await ReadPdfBytesAsync(path, token).ConfigureAwait(false), pageNumber, _options);
4353
if (imageNumber > images.Count)

‎src/ManagedCode.FileContext/FileContextService.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@ public sealed partial class FileContextService : IFileContext, IFileContextPdf
99
{
1010
private readonly ManagedCodeStorageFileStore _fileStore;
1111
private readonly FileContextOptions _options;
12+
private readonly Pdf.FileContextPdfProcessor _pdfProcessor;
1213

1314
/// <summary>Creates the extended file-context service.</summary>
14-
public FileContextService(ManagedCodeStorageFileStore fileStore, FileContextOptions? options = null)
15+
public FileContextService(ManagedCodeStorageFileStore fileStore, FileContextOptions? options = null,
16+
Pdf.FileContextPdfProcessor? pdfProcessor = null)
1517
{
1618
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
1719
_options = options ?? new FileContextOptions();
1820
_options.Validate();
21+
_pdfProcessor = pdfProcessor ?? Pdf.FileContextPdfProcessor.Shared;
1922
Documents = new FileContextDocumentService(_fileStore, _options);
2023
}
2124

‎src/ManagedCode.FileContext/FileContextServiceCollectionExtensions.cs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using ManagedCode.FileContext.Pdf;
12
using ManagedCode.Storage.Core;
23
using Microsoft.Agents.AI;
34
using Microsoft.Extensions.Configuration;
@@ -21,6 +22,7 @@ public static IServiceCollection AddManagedCodeFileContext(
2122
builder.Configure(configure);
2223
}
2324
AddOptionsValidator(services);
25+
AddPdfProcessor(services);
2426

2527
services.TryAddSingleton(static provider => provider.GetRequiredService<IOptions<FileContextOptions>>().Value);
2628
services.TryAddSingleton<ManagedCodeStorageFileStore>();
@@ -58,14 +60,16 @@ public static IServiceCollection AddKeyedManagedCodeFileContext(
5860
}
5961
AddOptionsValidator(services);
6062

63+
AddPdfProcessor(services);
6164
services.AddKeyedSingleton<FileContextOptions>(serviceKey, (provider, _) =>
6265
provider.GetRequiredService<IOptionsMonitor<FileContextOptions>>().Get(optionsName));
6366
services.AddKeyedSingleton<ManagedCodeStorageFileStore>(serviceKey, (provider, key) =>
6467
new ManagedCodeStorageFileStore(provider.GetRequiredKeyedService<IStorage>(key),
6568
provider.GetRequiredKeyedService<FileContextOptions>(key)));
6669
services.AddKeyedSingleton<IFileContext>(serviceKey, (provider, key) =>
6770
new FileContextService(provider.GetRequiredKeyedService<ManagedCodeStorageFileStore>(key),
68-
provider.GetRequiredKeyedService<FileContextOptions>(key)));
71+
provider.GetRequiredKeyedService<FileContextOptions>(key),
72+
provider.GetRequiredService<FileContextPdfProcessor>()));
6973
services.AddKeyedSingleton<IFileContextPdf>(serviceKey, (provider, key) =>
7074
(IFileContextPdf)provider.GetRequiredKeyedService<IFileContext>(key));
7175
services.AddKeyedSingleton<FileContextProvider>(serviceKey, (provider, key) =>
@@ -88,9 +92,14 @@ public static IServiceCollection AddFileContextOptions(this IServiceCollection s
8892
services.AddOptions<FileContextOptions>()
8993
.Bind(configuration.GetSection(FileContextOptions.SectionName));
9094
AddOptionsValidator(services);
95+
AddPdfProcessor(services);
9196
return services;
9297
}
9398

99+
private static void AddPdfProcessor(IServiceCollection services) =>
100+
services.TryAddSingleton(static provider => new FileContextPdfProcessor(
101+
provider.GetRequiredService<IOptions<FileContextOptions>>().Value));
102+
94103
private static void AddOptionsValidator(IServiceCollection services) =>
95104
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<FileContextOptions>, FileContextOptionsValidator>());
96105
}

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

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,29 +24,8 @@ public static byte[] RenderPagePng(byte[] pdf, int pageNumber, FileContextOption
2424
double? scale = null)
2525
{
2626
ArgumentNullException.ThrowIfNull(options);
27-
var settings = ValidateInput(pdf, options);
28-
var resolvedScale = scale ?? settings.DefaultPdfPageScale;
29-
if (resolvedScale < settings.MinimumPdfPageScale || resolvedScale > settings.MaximumPdfPageScale
30-
|| !double.IsFinite(resolvedScale))
31-
{
32-
throw new ArgumentOutOfRangeException(nameof(scale));
33-
}
34-
35-
using var document = PdfDocument.Open(pdf, SkiaRenderingParsingOptions.Instance);
36-
ValidatePage(pageNumber, document.NumberOfPages);
37-
var page = document.GetPage(pageNumber);
38-
if (page.Width * resolvedScale * page.Height * resolvedScale > settings.MaximumRenderedPagePixels)
39-
{
40-
throw new IOException("The rendered PDF page exceeds the pixel limit.");
41-
}
42-
43-
document.AddSkiaPageFactory();
44-
using var image = document.GetPageAsPng(pageNumber, (float)resolvedScale, settings.PdfPngQuality);
45-
if (image.Length > settings.MaximumImageBytes)
46-
{
47-
throw new IOException("The rendered PDF page exceeds the image byte limit.");
48-
}
49-
return image.ToArray();
27+
using var document = new FileContextPdfRenderDocument(pdf, options);
28+
return document.RenderPagePng(pageNumber, scale);
5029
}
5130

5231
/// <summary>Extracts the embedded images on one page; these do not include page text or vector drawings.</summary>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
namespace ManagedCode.FileContext.Pdf;
2+
3+
/// <summary>Bounds concurrent PDF source reads and processing across scoped file contexts.</summary>
4+
public sealed class FileContextPdfProcessor : IDisposable
5+
{
6+
/// <summary>Shared default processor for manually composed file contexts.</summary>
7+
public static FileContextPdfProcessor Shared { get; } = new(new FileContextOptions());
8+
9+
private readonly SemaphoreSlim _permits;
10+
11+
public FileContextPdfProcessor(FileContextOptions options)
12+
{
13+
ArgumentNullException.ThrowIfNull(options);
14+
options.Validate();
15+
_permits = new SemaphoreSlim(options.MaximumConcurrentPdfOperations, options.MaximumConcurrentPdfOperations);
16+
}
17+
18+
/// <summary>Waits before downloading or buffering a PDF. Dispose the permit after processing completes.</summary>
19+
public async Task<IDisposable> AcquireAsync(CancellationToken cancellationToken = default)
20+
{
21+
await _permits.WaitAsync(cancellationToken).ConfigureAwait(false);
22+
return new Permit(_permits);
23+
}
24+
25+
public void Dispose() => _permits.Dispose();
26+
27+
private sealed class Permit(SemaphoreSlim permits) : IDisposable
28+
{
29+
private bool _disposed;
30+
31+
public void Dispose()
32+
{
33+
if (_disposed)
34+
{
35+
return;
36+
}
37+
_disposed = true;
38+
permits.Release();
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)