Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
af0af1a
Add IOcrClient OCR/document-extraction capability to Microsoft.Extens…
luisquintanilla Jun 25, 2026
b89c7da
Add OcrImage type and OcrPage.Images for extracted figures
luisquintanilla Jul 1, 2026
996b442
Add UriContent overload to OcrClientExtensions
luisquintanilla Jul 1, 2026
ac53093
Rename IOcrClient.GetTextAsync to ExtractAsync
luisquintanilla Jul 1, 2026
e871445
Add OcrImage to IngestionDocumentImage reader mapping
luisquintanilla Jul 2, 2026
ed72dc8
Add ExtractFromUriAsync opt-in remote downloader to IOcrClient extens…
luisquintanilla Jul 7, 2026
4f7db5d
Finish GetTextAsync to ExtractAsync rename in OCR tests and API basel…
luisquintanilla Jul 9, 2026
49e55e2
Fix net462 build: use buffered Stream.CopyToAsync overload in OCR test
luisquintanilla Jul 13, 2026
fc6b00d
Shape OCR bounding geometry as points and 1-base page numbers
luisquintanilla Jul 14, 2026
6ee41c0
Align IOcrClient surface with the MEAI family ahead of API review
luisquintanilla Jul 15, 2026
5e69678
Reshape OCR result model: reading-order elements, doc-level coordinat…
luisquintanilla Jul 23, 2026
9c7a205
Move OCR coordinate frame per-page; add OcrPageDimensions (SPIKE-07/08)
luisquintanilla Jul 23, 2026
108134e
Add per-cell geometry and OcrPage.RawRepresentation (SPIKE-06)
luisquintanilla Jul 23, 2026
039fae3
Extract OCR into Microsoft.Extensions.DocumentExtraction peer library
luisquintanilla Aug 3, 2026
638058a
Fix CI: remove dangling TestOcrClient.cs <Compile> link
luisquintanilla Aug 3, 2026
a215825
Add README.md for the DocumentExtraction packages
luisquintanilla Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4851,4 +4851,4 @@
]
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

<ItemGroup>
<ProjectReference Include="..\Microsoft.Extensions.DataIngestion.Abstractions\Microsoft.Extensions.DataIngestion.Abstractions.csproj" />
<ProjectReference Include="..\Microsoft.Extensions.DocumentExtraction.Abstractions\Microsoft.Extensions.DocumentExtraction.Abstractions.csproj" />
<ProjectReference Include="..\Microsoft.Extensions.AI\Microsoft.Extensions.AI.csproj" />
</ItemGroup>

Expand Down
114 changes: 114 additions & 0 deletions src/Libraries/Microsoft.Extensions.DataIngestion/OcrDocumentReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#pragma warning disable MEDE0001 // Document extraction abstractions are experimental.

using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DocumentExtraction;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.DataIngestion;

/// <summary>
/// Reads documents by extracting structured OCR output using an <see cref="IDocumentExtractionClient"/>.
/// </summary>
public sealed class OcrDocumentReader : IngestionDocumentReader
{
private const string BoundingBoxMetadataKey = "bounding_box";
private const string BoundingRegionMetadataKey = "bounding_region";

private readonly IDocumentExtractionClient _documentExtractionClient;
private readonly DocumentExtractionOptions _options;

/// <summary>
/// Initializes a new instance of the <see cref="OcrDocumentReader"/> class.
/// </summary>
/// <param name="documentExtractionClient">The OCR client to use for document extraction.</param>
/// <param name="options">Optional OCR options.</param>
public OcrDocumentReader(IDocumentExtractionClient documentExtractionClient, DocumentExtractionOptions? options = null)
{
_documentExtractionClient = Throw.IfNull(documentExtractionClient);
_options = options?.Clone() ?? new DocumentExtractionOptions();
}

/// <inheritdoc/>
public override async Task<IngestionDocument> ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(source);
_ = Throw.IfNullOrEmpty(identifier);
_ = Throw.IfNullOrEmpty(mediaType);

DocumentExtractionResult documentExtractionResult = await _documentExtractionClient
.ExtractAsync(source, mediaType, _options.Clone(), cancellationToken: cancellationToken)
.ConfigureAwait(false);

return Map(documentExtractionResult, identifier);
}

private static IngestionDocument Map(DocumentExtractionResult documentExtractionResult, string identifier)
{
IngestionDocument document = new(identifier);

foreach (DocumentPage page in documentExtractionResult.Pages)
{
IngestionDocumentSection section = new();
int pageNumber = page.PageNumber;

if (!string.IsNullOrWhiteSpace(page.Text))
{
section.Elements.Add(new IngestionDocumentParagraph(page.Text)
{
Text = page.Text,
PageNumber = pageNumber
});
}

foreach (DocumentImage image in page.Elements.OfType<DocumentImage>())
{
section.Elements.Add(MapImage(image, pageNumber));
}

document.Sections.Add(section);
}

return document;
}

private static IngestionDocumentImage MapImage(DocumentImage image, int pageNumber)
{
DataContent? content = image.Content;
IngestionDocumentImage element = new(CreateImageMarkdown(image))
{
Content = content?.Data,
MediaType = content?.MediaType,
AlternativeText = image.Caption,
PageNumber = image.BoundingRegion?.PageNumber ?? pageNumber
};

if (image.BoundingRegion is not null)
{
if (image.BoundingRegion.GetBounds() is { } bounds)
{
(float left, float top, float right, float bottom) = bounds;
element.Metadata[BoundingBoxMetadataKey] = new[] { left, top, right, bottom };
}

element.Metadata[BoundingRegionMetadataKey] = image.BoundingRegion.Polygon.SelectMany(static p => new[] { p.X, p.Y }).ToArray();
}

return element;
}

private static string CreateImageMarkdown(DocumentImage image)
{
string altText = image.Caption ?? string.Empty;
string uri = image.Content?.Uri ?? string.Empty;

return $"![{altText}]({uri})";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.DocumentExtraction;

/// <summary>Provides an optional base class for an <see cref="IDocumentExtractionClient"/> that passes through calls to another instance.</summary>
/// <remarks>
/// This is recommended as a base type when building clients that can be chained in any order around an
/// underlying <see cref="IDocumentExtractionClient"/>. The default implementation simply passes each call to the inner
/// client instance.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.DocumentExtraction, UrlFormat = DiagnosticIds.UrlFormat)]
public class DelegatingDocumentExtractionClient : IDocumentExtractionClient
{
/// <summary>Initializes a new instance of the <see cref="DelegatingDocumentExtractionClient"/> class.</summary>
/// <param name="innerClient">The wrapped client instance.</param>
/// <exception cref="ArgumentNullException"><paramref name="innerClient"/> is <see langword="null"/>.</exception>
protected DelegatingDocumentExtractionClient(IDocumentExtractionClient innerClient)
{
InnerClient = Throw.IfNull(innerClient);
}

/// <summary>Gets the inner <see cref="IDocumentExtractionClient"/>.</summary>
protected IDocumentExtractionClient InnerClient { get; }

/// <inheritdoc />
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}

/// <inheritdoc />
public virtual Task<DocumentExtractionResult> ExtractAsync(
Stream document,
string mediaType,
DocumentExtractionOptions? options = null,
CancellationToken cancellationToken = default)
{
return InnerClient.ExtractAsync(document, mediaType, options, cancellationToken);
}

/// <inheritdoc />
public virtual IAsyncEnumerable<DocumentExtractionPageResult> ExtractPagesAsync(
Stream document,
string mediaType,
DocumentExtractionOptions? options = null,
CancellationToken cancellationToken = default)
{
return InnerClient.ExtractPagesAsync(document, mediaType, options, cancellationToken);
}

/// <inheritdoc />
public virtual object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);

// If the key is non-null, we don't know what it means so pass through to the inner service.
return
serviceKey is null && serviceType.IsInstanceOfType(this) ? this :
InnerClient.GetService(serviceType, serviceKey);
}

/// <summary>Provides a mechanism for releasing unmanaged resources.</summary>
/// <param name="disposing"><see langword="true"/> if being called from <see cref="Dispose()"/>; otherwise, <see langword="false"/>.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
InnerClient.Dispose();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.DocumentExtraction;

/// <summary>Represents a positioned layout block, such as a paragraph, heading, or figure.</summary>
[Experimental(DiagnosticIds.Experiments.DocumentExtraction, UrlFormat = DiagnosticIds.UrlFormat)]
public class DocumentBlock : DocumentElement
{
/// <summary>Initializes a new instance of the <see cref="DocumentBlock"/> class.</summary>
/// <param name="text">The text content of the block.</param>
/// <exception cref="System.ArgumentNullException"><paramref name="text"/> is <see langword="null"/>.</exception>
public DocumentBlock(string text)
{
Text = Throw.IfNull(text);
}

/// <summary>Gets the text content of the block.</summary>
public string Text { get; }

/// <summary>Gets or sets the kind of block, for example <see cref="DocumentBlockKind.Paragraph"/>, <see cref="DocumentBlockKind.Title"/>, or <see cref="DocumentBlockKind.Figure"/>.</summary>
public DocumentBlockKind? Kind { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.DocumentExtraction;

/// <summary>Describes the kind of an <see cref="DocumentBlock"/>, such as a paragraph, title, or figure.</summary>
/// <remarks>
/// This is a small open set modeled on <see cref="Microsoft.Extensions.AI.ChatRole"/>: the well-known values cover the common
/// layout categories, and a provider may introduce its own value when an engine reports a kind that is
/// not represented here.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.DocumentExtraction, UrlFormat = DiagnosticIds.UrlFormat)]
[JsonConverter(typeof(Converter))]
[DebuggerDisplay("{Value,nq}")]
public readonly struct DocumentBlockKind : IEquatable<DocumentBlockKind>
{
/// <summary>Gets the kind representing a paragraph of body text.</summary>
public static DocumentBlockKind Paragraph { get; } = new("paragraph");

/// <summary>Gets the kind representing a title or heading.</summary>
public static DocumentBlockKind Title { get; } = new("title");

/// <summary>Gets the kind representing a figure or image region.</summary>
public static DocumentBlockKind Figure { get; } = new("figure");

/// <summary>Gets the value associated with this <see cref="DocumentBlockKind"/>.</summary>
public string Value { get; }

/// <summary>Initializes a new instance of the <see cref="DocumentBlockKind"/> struct with the provided value.</summary>
/// <param name="value">The value to associate with this <see cref="DocumentBlockKind"/>.</param>
/// <exception cref="ArgumentNullException"><paramref name="value"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="value"/> is empty or composed entirely of whitespace.</exception>
[JsonConstructor]
public DocumentBlockKind(string value)
{
Value = Throw.IfNullOrWhitespace(value);
}

/// <summary>Returns a value indicating whether two <see cref="DocumentBlockKind"/> instances are equivalent, using a case-insensitive comparison.</summary>
/// <param name="left">The first <see cref="DocumentBlockKind"/> instance to compare.</param>
/// <param name="right">The second <see cref="DocumentBlockKind"/> instance to compare.</param>
/// <returns><see langword="true"/> if left and right have equivalent values; otherwise, <see langword="false"/>.</returns>
public static bool operator ==(DocumentBlockKind left, DocumentBlockKind right)
{
return left.Equals(right);
}

/// <summary>Returns a value indicating whether two <see cref="DocumentBlockKind"/> instances are not equivalent, using a case-insensitive comparison.</summary>
/// <param name="left">The first <see cref="DocumentBlockKind"/> instance to compare.</param>
/// <param name="right">The second <see cref="DocumentBlockKind"/> instance to compare.</param>
/// <returns><see langword="true"/> if left and right have different values; otherwise, <see langword="false"/>.</returns>
public static bool operator !=(DocumentBlockKind left, DocumentBlockKind right)
{
return !(left == right);
}

/// <inheritdoc/>
public override bool Equals([NotNullWhen(true)] object? obj)
=> obj is DocumentBlockKind otherKind && Equals(otherKind);

/// <inheritdoc/>
public bool Equals(DocumentBlockKind other)
=> string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase);

/// <inheritdoc/>
public override int GetHashCode()
=> StringComparer.OrdinalIgnoreCase.GetHashCode(Value);

/// <inheritdoc/>
public override string ToString() => Value;

/// <summary>Provides a <see cref="JsonConverter{DocumentBlockKind}"/> for serializing <see cref="DocumentBlockKind"/> instances.</summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class Converter : JsonConverter<DocumentBlockKind>
{
/// <inheritdoc/>
public override DocumentBlockKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
new(reader.GetString()!);

/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, DocumentBlockKind value, JsonSerializerOptions options) =>
Throw.IfNull(writer).WriteStringValue(value.Value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;

namespace Microsoft.Extensions.DocumentExtraction;

/// <summary>Represents the axis-aligned bounds of a bounding polygon, in the coordinate space defined by the OCR engine.</summary>
/// <param name="Left">The minimum horizontal coordinate.</param>
/// <param name="Top">The minimum vertical coordinate.</param>
/// <param name="Right">The maximum horizontal coordinate.</param>
/// <param name="Bottom">The maximum vertical coordinate.</param>
[Experimental(DiagnosticIds.Experiments.DocumentExtraction, UrlFormat = DiagnosticIds.UrlFormat)]
public readonly record struct DocumentBoundingBox(float Left, float Top, float Right, float Bottom);
Loading
Loading