using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Pdftract.Models;
namespace Pdftract;
///
/// pdftract SDK client for .NET.
///
public sealed partial class Pdftract : IAsyncDisposable, IDisposable
{
private readonly string _binaryPath;
private readonly JsonSerializerOptions _jsonOptions;
///
/// Creates a new Pdftract client with the specified binary path.
///
/// Path to the pdftract binary. If null, searches PATH.
public Pdftract(string? binaryPath = null)
{
_binaryPath = FindBinary(binaryPath);
_jsonOptions = PdftractJsonContext.Default.Options;
}
///
/// Extracts structured data from a PDF.
///
public async Task ExtractAsync(
Source source,
ExtractOptions? options = null,
CancellationToken cancellationToken = default)
{
var args = BuildArgs("extract", "--json", source, options);
var json = await InvokeAsync(source, args, cancellationToken);
return JsonSerializer.Deserialize(json, PdftractJsonContext.Default.Document)
?? throw new JsonException("Failed to deserialize Document");
}
///
/// Extracts plain text from a PDF.
///
public async Task ExtractTextAsync(
Source source,
ExtractOptions? options = null,
CancellationToken cancellationToken = default)
{
var args = BuildArgs("extract", "--text", source, options);
return await InvokeAsync(source, args, cancellationToken);
}
///
/// Extracts markdown-formatted text from a PDF.
///
public async Task ExtractMarkdownAsync(
Source source,
ExtractOptions? options = null,
CancellationToken cancellationToken = default)
{
var args = BuildArgs("extract", "--md", source, options);
return await InvokeAsync(source, args, cancellationToken);
}
///
/// Extracts pages from a PDF as a stream.
///
public async IAsyncEnumerable ExtractStreamAsync(
Source source,
ExtractOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var args = BuildArgs("extract", "--ndjson", source, options);
await foreach (var line in InvokeStreamAsync(source, args, cancellationToken))
{
var page = JsonSerializer.Deserialize(line, PdftractJsonContext.Default.Page)
?? throw new JsonException("Failed to deserialize Page");
yield return page;
}
}
///
/// Searches for a pattern in a PDF.
///
public async IAsyncEnumerable SearchAsync(
Source source,
string pattern,
SearchOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var args = BuildArgs("grep", pattern, source, options);
await foreach (var line in InvokeStreamAsync(source, args, cancellationToken))
{
var match = JsonSerializer.Deserialize(line, PdftractJsonContext.Default.Match)
?? throw new JsonException("Failed to deserialize Match");
yield return match;
}
}
///
/// Extracts metadata from a PDF.
///
public async Task GetMetadataAsync(
Source source,
ExtractOptions? options = null,
CancellationToken cancellationToken = default)
{
var args = BuildArgs("extract", "--metadata-only", source, options);
var json = await InvokeAsync(source, args, cancellationToken);
var result = JsonSerializer.Deserialize(json, PdftractJsonContext.Default.Document);
if (result is null) throw new JsonException("Failed to deserialize Document");
return result.Metadata;
}
///
/// Computes the fingerprint hash of a PDF.
///
public async Task HashAsync(
Source source,
HashOptions? options = null,
CancellationToken cancellationToken = default)
{
var args = new List { "hash" };
args.AddRange(source.ToArgs());
if (options != null)
{
args.AddRange(options.ToArgs());
}
var json = await InvokeAsync(source, args, cancellationToken);
return JsonSerializer.Deserialize(json, PdftractJsonContext.Default.Fingerprint)
?? throw new JsonException("Failed to deserialize Fingerprint");
}
///
/// Classifies a PDF document.
///
public async Task ClassifyAsync(
Source source,
CancellationToken cancellationToken = default)
{
var args = new List { "classify" };
args.AddRange(source.ToArgs());
var json = await InvokeAsync(source, args, cancellationToken);
return JsonSerializer.Deserialize(json, PdftractJsonContext.Default.Classification)
?? throw new JsonException("Failed to deserialize Classification");
}
///
/// Verifies a cryptographic receipt for a PDF.
///
public async Task VerifyReceiptAsync(
string path,
Receipt receipt,
CancellationToken cancellationToken = default)
{
var receiptPath = path + ".receipt.json";
var receiptJson = JsonSerializer.Serialize(receipt, PdftractJsonContext.Default.Receipt);
await File.WriteAllTextAsync(receiptPath, receiptJson, cancellationToken);
try
{
var args = new List { "verify-receipt", path, receiptPath };
await InvokeAsync(null, args, cancellationToken);
return true;
}
catch (ReceiptVerifyException)
{
return false;
}
finally
{
try
{
if (File.Exists(receiptPath))
{
File.Delete(receiptPath);
}
}
catch
{
// Ignore cleanup errors
}
}
}
///
/// Returns the path to the pdftract binary.
///
public string BinaryPath => _binaryPath;
///
/// Returns the pdftract binary version.
///
public async Task GetVersionAsync(CancellationToken cancellationToken = default)
{
var args = new List { "--version" };
return await InvokeAsync(null, args, cancellationToken);
}
private static List BuildArgs(
string command,
string flag,
Source source,
ExtractOptions? options)
{
var args = new List { command, flag };
args.AddRange(source.ToArgs());
if (options != null)
{
args.AddRange(options.ToArgs());
}
return args;
}
private static List BuildArgs(
string command,
string pattern,
Source source,
SearchOptions? options)
{
var args = new List { command, pattern };
args.AddRange(source.ToArgs());
if (options != null)
{
args.AddRange(options.ToArgs());
}
return args;
}
private async Task InvokeAsync(
Source? source,
List args,
CancellationToken cancellationToken)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = _binaryPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
var output = new StringBuilder();
var error = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data != null) { output.AppendLine(e.Data); } };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) { error.AppendLine(e.Data); } };
var tcs = new TaskCompletionSource();
cancellationToken.Register(() =>
{
try
{
process.Kill(entireProcessTree: true);
tcs.TrySetCanceled(cancellationToken);
}
catch
{
// Ignore
}
});
process.Exited += (_, _) =>
{
try
{
if (cancellationToken.IsCancellationRequested)
{
tcs.TrySetCanceled(cancellationToken);
return;
}
if (process.ExitCode != 0)
{
var exception = PdftractException.FromExitCode(process.ExitCode, error.ToString());
tcs.TrySetException(exception);
return;
}
tcs.TrySetResult(output.ToString());
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
};
process.EnableRaisingEvents = true;
if (!process.Start())
{
source?.Dispose();
throw new InvalidOperationException("Failed to start pdftract process");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
try
{
var result = await tcs.Task;
return result;
}
finally
{
source?.Dispose();
}
}
private async IAsyncEnumerable InvokeStreamAsync(
Source source,
List args,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = _binaryPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
var error = new StringBuilder();
var processExitCode = 0;
var processExited = false;
process.ErrorDataReceived += (_, e) => { if (e.Data != null) { error.AppendLine(e.Data); } };
cancellationToken.Register(() =>
{
try
{
process.Kill(entireProcessTree: true);
}
catch
{
// Ignore
}
});
process.Exited += (_, _) =>
{
processExitCode = process.ExitCode;
processExited = true;
};
process.EnableRaisingEvents = true;
if (!process.Start())
{
source.Dispose();
throw new InvalidOperationException("Failed to start pdftract process");
}
try
{
using var reader = process.StandardOutput;
process.BeginErrorReadLine();
string? line;
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
{
if (!string.IsNullOrWhiteSpace(line))
{
yield return line;
}
}
process.WaitForExit();
if (cancellationToken.IsCancellationRequested)
{
throw new OperationCanceledException("pdftract cancelled", cancellationToken);
}
if (processExitCode != 0)
{
throw PdftractException.FromExitCode(processExitCode, error.ToString());
}
}
finally
{
source.Dispose();
}
}
private static string FindBinary(string? path)
{
var binaryPath = path;
if (string.IsNullOrEmpty(binaryPath))
{
// Search in PATH
var pathEnv = Environment.GetEnvironmentVariable("PATH");
if (pathEnv != null)
{
var separators = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? new[] { ';' }
: new[] { ':' };
foreach (var dir in pathEnv.Split(separators, StringSplitOptions.RemoveEmptyEntries))
{
var candidate = Path.Combine(dir, "pdftract");
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
candidate += ".exe";
}
if (File.Exists(candidate))
{
binaryPath = candidate;
break;
}
}
}
}
if (string.IsNullOrEmpty(binaryPath))
{
throw new FileNotFoundException(
"pdftract binary not found. Please install pdftract or provide the binary path.");
}
if (!File.Exists(binaryPath))
{
throw new FileNotFoundException($"pdftract binary not found at {binaryPath}");
}
return binaryPath;
}
public void Dispose()
{
// No unmanaged resources to dispose
}
public async ValueTask DisposeAsync()
{
// No unmanaged resources to dispose
await Task.CompletedTask;
}
}