From 9193c2a6ae01c4a30d4b5e7f17143e8f0bd6258b Mon Sep 17 00:00:00 2001 From: Changjian Wang Date: Tue, 31 Mar 2026 10:07:24 +0800 Subject: [PATCH 1/6] Add Sample_Advanced_ContentSource for document grounding sources --- .../Sample_Advanced_ContentSource.java | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java new file mode 100644 index 000000000000..98277f6e2753 --- /dev/null +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.contentunderstanding.samples; + +import com.azure.ai.contentunderstanding.ContentUnderstandingClient; +import com.azure.ai.contentunderstanding.ContentUnderstandingClientBuilder; +import com.azure.ai.contentunderstanding.models.AnalysisInput; +import com.azure.ai.contentunderstanding.models.AnalysisResult; +import com.azure.ai.contentunderstanding.models.AudioVisualSource; +import com.azure.ai.contentunderstanding.models.ContentAnalyzerAnalyzeOperationStatus; +import com.azure.ai.contentunderstanding.models.ContentField; +import com.azure.ai.contentunderstanding.models.ContentSource; +import com.azure.ai.contentunderstanding.models.DocumentContent; +import com.azure.ai.contentunderstanding.models.DocumentSource; +import com.azure.ai.contentunderstanding.models.PointF; +import com.azure.ai.contentunderstanding.models.Rectangle; +import com.azure.ai.contentunderstanding.models.RectangleF; +import com.azure.core.credential.AzureKeyCredential; +import com.azure.core.util.polling.SyncPoller; +import com.azure.identity.DefaultAzureCredentialBuilder; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Demonstrates how to access and use {@link ContentSource} grounding references + * from analysis results. Content sources identify the exact location in the original + * content where a field value was extracted from. + * + *

For document/image content, sources are {@link DocumentSource} instances + * with page number, polygon coordinates, and a computed bounding box.

+ * + *

For audio/video content, sources are {@link AudioVisualSource} instances + * with a timestamp and an optional bounding box.

+ */ +public class Sample_Advanced_ContentSource { + + public static void main(String[] args) { + String endpoint = System.getenv("CONTENTUNDERSTANDING_ENDPOINT"); + String key = System.getenv("CONTENTUNDERSTANDING_KEY"); + + ContentUnderstandingClientBuilder builder = new ContentUnderstandingClientBuilder().endpoint(endpoint); + + ContentUnderstandingClient client; + if (key != null && !key.trim().isEmpty()) { + client = builder.credential(new AzureKeyCredential(key)).buildClient(); + } else { + client = builder.credential(new DefaultAzureCredentialBuilder().build()).buildClient(); + } + + // ===================================================================== + // Part 1: Document ContentSource from analysis + // ===================================================================== + documentContentSourceFromAnalysis(client); + + // ===================================================================== + // Part 2: ContentSource.parseAll() round-trip and multi-segment parsing + // ===================================================================== + contentSourceParseRoundTrip(client); + } + + /** + * Analyzes an invoice and iterates over field grounding sources, + * casting each to {@link DocumentSource} to access page, polygon, and bounding box. + */ + // BEGIN: com.azure.ai.contentunderstanding.advanced.contentsource.fromanalysis + private static void documentContentSourceFromAnalysis(ContentUnderstandingClient client) { + // Analyze an invoice to get fields with grounding sources. + String invoiceUrl + = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf"; + + AnalysisInput input = new AnalysisInput(); + input.setUrl(invoiceUrl); + + SyncPoller operation + = client.beginAnalyze("prebuilt-invoice", Arrays.asList(input)); + + AnalysisResult result = operation.getFinalResult(); + DocumentContent documentContent = (DocumentContent) result.getContents().get(0); + + // Iterate over all fields and access their grounding sources. + for (Map.Entry entry : documentContent.getFields().entrySet()) { + String fieldName = entry.getKey(); + ContentField field = entry.getValue(); + + System.out.println("Field: " + fieldName + " = " + field.getValue()); + + // Sources identify where the field value appears in the original content. + // For documents, each source is a DocumentSource with page number and polygon. + List sources = field.getSources(); + if (sources != null) { + for (ContentSource source : sources) { + if (source instanceof DocumentSource) { + DocumentSource docSource = (DocumentSource) source; + System.out.println(" Source: page " + docSource.getPageNumber()); + + // Polygon: the precise region (rotated quadrilateral) around the text. + List polygon = docSource.getPolygon(); + String coords = polygon.stream() + .map(p -> String.format("(%.4f,%.4f)", p.getX(), p.getY())) + .collect(Collectors.joining(", ")); + System.out.println(" Polygon: [" + coords + "]"); + + // BoundingBox: axis-aligned rectangle computed from the polygon — + // convenient for drawing highlight overlays. + RectangleF bbox = docSource.getBoundingBox(); + System.out.printf(" BoundingBox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", + bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); + } + } + } + } + } + // END: com.azure.ai.contentunderstanding.advanced.contentsource.fromanalysis + + /** + * Demonstrates round-tripping field sources through {@link ContentSource#toRawString(List)} + * and {@link ContentSource#parseAll(String)}, plus multi-segment and page-only parsing. + */ + // BEGIN: com.azure.ai.contentunderstanding.advanced.contentsource.parse + private static void contentSourceParseRoundTrip(ContentUnderstandingClient client) { + // Analyze an invoice to get a field with grounding sources. + String invoiceUrl + = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf"; + + AnalysisInput input = new AnalysisInput(); + input.setUrl(invoiceUrl); + + SyncPoller operation + = client.beginAnalyze("prebuilt-invoice", Arrays.asList(input)); + + AnalysisResult result = operation.getFinalResult(); + DocumentContent documentContent = (DocumentContent) result.getContents().get(0); + + // Find a field that has grounding sources. + ContentField fieldWithSource = documentContent.getFields().values().stream() + .filter(f -> f.getSources() != null) + .findFirst() + .orElseThrow(); + + // Convert the parsed sources back to their wire-format string. + String sourceString = ContentSource.toRawString(fieldWithSource.getSources()); + System.out.println("Source wire format: " + sourceString); + + // Parse the wire-format string back into typed ContentSource instances. + List roundTripped = ContentSource.parseAll(sourceString); + DocumentSource roundTrippedDoc = (DocumentSource) roundTripped.get(0); + System.out.println("Round-tripped: page " + roundTrippedDoc.getPageNumber() + + ", polygon points: " + roundTrippedDoc.getPolygon().size()); + RectangleF bbox = roundTrippedDoc.getBoundingBox(); + System.out.printf(" BoundingBox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", + bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); + + // Find a field with multiple source segments (e.g., multi-line addresses). + ContentField multiSourceField = documentContent.getFields().values().stream() + .filter(f -> f.getSources() != null && f.getSources().size() > 1) + .findFirst() + .orElseThrow(); + String multiSourceString = ContentSource.toRawString(multiSourceField.getSources()); + System.out.println("Multi-segment wire format: " + multiSourceString); + + List multiParsed = ContentSource.parseAll(multiSourceString); + String pageNumbers = multiParsed.stream() + .filter(s -> s instanceof DocumentSource) + .map(s -> String.valueOf(((DocumentSource) s).getPageNumber())) + .collect(Collectors.joining(", ")); + System.out.println("Multi-segment: " + multiParsed.size() + " sources on pages " + pageNumbers); + + // ContentSource.parseAll() also handles page-only format (no polygon coordinates). + // However, DocumentSource.parse() requires all 9 parameters (page + 8 coordinates). + // Use the full wire format from a real source for round-trip demonstrations. + int realPageNumber = ((DocumentSource) fieldWithSource.getSources().get(0)).getPageNumber(); + System.out.println("Page number from source: " + realPageNumber); + } + // END: com.azure.ai.contentunderstanding.advanced.contentsource.parse + + // TODO: AudioVisualContentSource — demonstrate real AudioVisualSource grounding + // from audio/video analysis. The CU service does not currently return AudioVisualSource + // grounding (field.getSources()) for AI-generated audio fields. Once the service supports + // timestamp-level source grounding for audio/video content, implement a method here that: + // 1. Analyzes an audio/video file with a custom analyzer (estimateFieldSourceAndConfidence = true) + // 2. Iterates over fields and casts getSources() elements to AudioVisualSource + // 3. Shows AudioVisualSource.getTime() (Duration) and AudioVisualSource.getBoundingBox() (optional Rectangle) + // 4. Demonstrates ContentSource.parseAll() with AV(...) format strings + // + // Example of AudioVisualSource parsing (SDK-side API works, just no live source data yet): + // + // List avSources = AudioVisualSource.parse("AV(1500);AV(3200)"); + // for (AudioVisualSource avSource : avSources) { + // Duration time = avSource.getTime(); // e.g., PT1.5S (1500 ms) + // Rectangle box = avSource.getBoundingBox(); // null for audio-only + // System.out.println("Timestamp: " + time.toMillis() + " ms, BoundingBox: " + (box != null ? box : "none")); + // } + // + // // With bounding box (e.g., face detection in video): + // List avWithBox = AudioVisualSource.parse("AV(5000,100,200,50,60)"); + // Rectangle bbox = avWithBox.get(0).getBoundingBox(); // Rectangle(x=100, y=200, w=50, h=60) + // + // See AudioVisualSource and ContentSource.parseAll() for the SDK-side API. +} From 370ec1a7319906f0a4e997ac2537f1291441c0c9 Mon Sep 17 00:00:00 2001 From: Changjian Wang Date: Tue, 31 Mar 2026 15:21:09 +0800 Subject: [PATCH 2/6] Add Sample_Advanced_ContentSource: sync/async samples and tests Demonstrates ContentSource grounding from document analysis: - Part 1: DocumentSource from analysis (page, polygon, boundingBox) - Part 2: ContentSource.parseAll() round-trip, DocumentSource.parse() typed method, and D(page) page-only format Also fixes DocumentSource to support D(page) format (1 param) and variable polygon point counts (>=3 pairs). --- .../ContentUnderstandingCustomizations.java | 43 ++-- .../models/DocumentSource.java | 42 ++-- .../Sample_Advanced_ContentSource.java | 126 ++++++------ .../Sample_Advanced_ContentSourceAsync.java | 192 ++++++++++++++++++ ...ample_Advanced_ContentSourceAsyncTest.java | 188 +++++++++++++++++ .../Sample_Advanced_ContentSourceTest.java | 173 ++++++++++++++++ 6 files changed, 665 insertions(+), 99 deletions(-) create mode 100644 sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java create mode 100644 sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java create mode 100644 sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/customization/src/main/java/ContentUnderstandingCustomizations.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/customization/src/main/java/ContentUnderstandingCustomizations.java index 200991d14cec..35c7beccaf06 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/customization/src/main/java/ContentUnderstandingCustomizations.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/customization/src/main/java/ContentUnderstandingCustomizations.java @@ -1489,18 +1489,18 @@ private void addSourcesMethod(LibraryCustomization customization, Logger logger) + "import java.util.List;\n" + "import java.util.Objects;\n\n" + "/**\n" - + " * Represents a parsed document grounding source in the format {@code D(page,x1,y1,x2,y2,x3,y3,x4,y4)}.\n" + + " * Represents a parsed document grounding source in the format {@code D(page,x1,y1,...,xN,yN)} or {@code D(page)}.\n" + " *\n" - + " *

The page number is 1-based. The polygon is a quadrilateral defined by four points\n" - + " * with coordinates in the document's coordinate space.

\n" + + " *

The page number is 1-based. The polygon defines a region with three or more points\n" + + " * in the document's coordinate space. When only a page number is provided (no coordinates),\n" + + " * {@link #getPolygon()} and {@link #getBoundingBox()} return {@code null}.

\n" + " *\n" + " * @see ContentSource\n" + " */\n" + "@Immutable\n" + "public final class DocumentSource extends ContentSource {\n" + " private static final ClientLogger LOGGER = new ClientLogger(DocumentSource.class);\n" - + " private static final String PREFIX = \"D(\";\n" - + " private static final int EXPECTED_PARAM_COUNT = 9;\n\n" + + " private static final String PREFIX = \"D(\";\n\n" + " private final int pageNumber;\n" + " private final List polygon;\n" + " private final RectangleF boundingBox;\n\n" @@ -1512,11 +1512,6 @@ private void addSourcesMethod(LibraryCustomization customization, Logger logger) + " }\n" + " String inner = source.substring(PREFIX.length(), source.length() - 1);\n" + " String[] parts = inner.split(\",\");\n" - + " if (parts.length != EXPECTED_PARAM_COUNT) {\n" - + " throw LOGGER.logExceptionAsError(\n" - + " new IllegalArgumentException(\"Document source expected \" + EXPECTED_PARAM_COUNT\n" - + " + \" parameters (page + 8 coordinates), got \" + parts.length + \": '\" + source + \"'.\"));\n" - + " }\n" + " try {\n" + " this.pageNumber = Integer.parseInt(parts[0].trim());\n" + " } catch (NumberFormatException e) {\n" @@ -1527,10 +1522,23 @@ private void addSourcesMethod(LibraryCustomization customization, Logger logger) + " throw LOGGER.logExceptionAsError(\n" + " new IllegalArgumentException(\"Page number must be >= 1, got \" + this.pageNumber + \".\"));\n" + " }\n" - + " List points = new ArrayList<>(4);\n" + + " if (parts.length == 1) {\n" + + " // Page-only: D(page)\n" + + " this.polygon = null;\n" + + " this.boundingBox = null;\n" + + " return;\n" + + " }\n" + + " int coordCount = parts.length - 1;\n" + + " if (coordCount < 6 || coordCount % 2 != 0) {\n" + + " throw LOGGER.logExceptionAsError(\n" + + " new IllegalArgumentException(\"Document source expected page-only (1 param) or page + at least 3 coordinate pairs (7+ params), got \"\n" + + " + parts.length + \": '\" + source + \"'.\"));\n" + + " }\n" + + " int pointCount = coordCount / 2;\n" + + " List points = new ArrayList<>(pointCount);\n" + " float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE;\n" + " float maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE;\n" - + " for (int i = 0; i < 4; i++) {\n" + + " for (int i = 0; i < pointCount; i++) {\n" + " int xIndex = 1 + (i * 2);\n" + " int yIndex = 2 + (i * 2);\n" + " float x, y;\n" @@ -1564,18 +1572,19 @@ private void addSourcesMethod(LibraryCustomization customization, Logger logger) + " return pageNumber;\n" + " }\n\n" + " /**\n" - + " * Gets the polygon coordinates as four points defining a quadrilateral region.\n" + + " * Gets the polygon coordinates defining the region, or {@code null} when only a page number is available.\n" + " *\n" - + " * @return An unmodifiable list of four {@link PointF} values.\n" + + " * @return An unmodifiable list of {@link PointF} values, or {@code null} for page-only sources.\n" + " */\n" + " public List getPolygon() {\n" + " return polygon;\n" + " }\n\n" + " /**\n" - + " * Gets the axis-aligned bounding rectangle computed from the polygon coordinates.\n" + + " * Gets the axis-aligned bounding rectangle computed from the polygon coordinates,\n" + + " * or {@code null} when only a page number is available.\n" + " * Useful for drawing highlight rectangles over extracted fields.\n" + " *\n" - + " * @return The bounding box.\n" + + " * @return The bounding box, or {@code null} for page-only sources.\n" + " */\n" + " public RectangleF getBoundingBox() {\n" + " return boundingBox;\n" @@ -1583,7 +1592,7 @@ private void addSourcesMethod(LibraryCustomization customization, Logger logger) + " /**\n" + " * Parses a single document source segment.\n" + " *\n" - + " * @param source The source string in the format {@code D(page,x1,y1,...,x4,y4)}.\n" + + " * @param source The source string in the format {@code D(page)} or {@code D(page,x1,y1,...,xN,yN)}.\n" + " * @return A new {@link DocumentSource}.\n" + " * @throws NullPointerException if {@code source} is null.\n" + " * @throws IllegalArgumentException if the source string is not in the expected format.\n" diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java index 8ec1fb0f4b81..365b3cda4ed9 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java @@ -11,10 +11,11 @@ import java.util.Objects; /** - * Represents a parsed document grounding source in the format {@code D(page,x1,y1,x2,y2,x3,y3,x4,y4)}. + * Represents a parsed document grounding source in the format {@code D(page,x1,y1,...,xN,yN)} or {@code D(page)}. * - *

The page number is 1-based. The polygon is a quadrilateral defined by four points - * with coordinates in the document's coordinate space.

+ *

The page number is 1-based. The polygon defines a region with three or more points + * in the document's coordinate space. When only a page number is provided (no coordinates), + * {@link #getPolygon()} and {@link #getBoundingBox()} return {@code null}.

* * @see ContentSource */ @@ -22,7 +23,6 @@ public final class DocumentSource extends ContentSource { private static final ClientLogger LOGGER = new ClientLogger(DocumentSource.class); private static final String PREFIX = "D("; - private static final int EXPECTED_PARAM_COUNT = 9; private final int pageNumber; private final List polygon; @@ -36,11 +36,6 @@ private DocumentSource(String source) { } String inner = source.substring(PREFIX.length(), source.length() - 1); String[] parts = inner.split(","); - if (parts.length != EXPECTED_PARAM_COUNT) { - throw LOGGER - .logExceptionAsError(new IllegalArgumentException("Document source expected " + EXPECTED_PARAM_COUNT - + " parameters (page + 8 coordinates), got " + parts.length + ": '" + source + "'.")); - } try { this.pageNumber = Integer.parseInt(parts[0].trim()); } catch (NumberFormatException e) { @@ -51,10 +46,23 @@ private DocumentSource(String source) { throw LOGGER.logExceptionAsError( new IllegalArgumentException("Page number must be >= 1, got " + this.pageNumber + ".")); } - List points = new ArrayList<>(4); + if (parts.length == 1) { + // Page-only: D(page) + this.polygon = null; + this.boundingBox = null; + return; + } + int coordCount = parts.length - 1; + if (coordCount < 6 || coordCount % 2 != 0) { + throw LOGGER.logExceptionAsError(new IllegalArgumentException( + "Document source expected page-only (1 param) or page + at least 3 coordinate pairs (7+ params), got " + + parts.length + ": '" + source + "'.")); + } + int pointCount = coordCount / 2; + List points = new ArrayList<>(pointCount); float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE; float maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE; - for (int i = 0; i < 4; i++) { + for (int i = 0; i < pointCount; i++) { int xIndex = 1 + (i * 2); int yIndex = 2 + (i * 2); float x, y; @@ -90,19 +98,19 @@ public int getPageNumber() { } /** - * Gets the polygon coordinates as four points defining a quadrilateral region. + * Gets the polygon coordinates defining the region, or {@code null} when only a page number is available. * - * @return An unmodifiable list of four {@link PointF} values. + * @return An unmodifiable list of {@link PointF} values, or {@code null} for page-only sources. */ public List getPolygon() { return polygon; } /** - * Gets the axis-aligned bounding rectangle computed from the polygon coordinates. - * Useful for drawing highlight rectangles over extracted fields. + * Gets the axis-aligned bounding rectangle computed from the polygon coordinates, + * or {@code null} when no polygon is available. * - * @return The bounding box. + * @return The bounding box, or {@code null} for page-only sources. */ public RectangleF getBoundingBox() { return boundingBox; @@ -111,7 +119,7 @@ public RectangleF getBoundingBox() { /** * Parses a single document source segment. * - * @param source The source string in the format {@code D(page,x1,y1,...,x4,y4)}. + * @param source The source string in the format {@code D(page,x1,y1,...,xN,yN)} or {@code D(page)}. * @return A new {@link DocumentSource}. * @throws NullPointerException if {@code source} is null. * @throws IllegalArgumentException if the source string is not in the expected format. diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java index 98277f6e2753..23c8caaf02ae 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java @@ -52,15 +52,28 @@ public static void main(String[] args) { client = builder.credential(new DefaultAzureCredentialBuilder().build()).buildClient(); } + // Analyze an invoice once — reuse the result for all demonstrations. + String invoiceUrl + = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf"; + + AnalysisInput input = new AnalysisInput(); + input.setUrl(invoiceUrl); + + SyncPoller operation + = client.beginAnalyze("prebuilt-invoice", Arrays.asList(input)); + + AnalysisResult result = operation.getFinalResult(); + DocumentContent documentContent = (DocumentContent) result.getContents().get(0); + // ===================================================================== // Part 1: Document ContentSource from analysis // ===================================================================== - documentContentSourceFromAnalysis(client); + documentContentSourceFromAnalysis(documentContent); // ===================================================================== // Part 2: ContentSource.parseAll() round-trip and multi-segment parsing // ===================================================================== - contentSourceParseRoundTrip(client); + contentSourceParseRoundTrip(documentContent); } /** @@ -68,20 +81,7 @@ public static void main(String[] args) { * casting each to {@link DocumentSource} to access page, polygon, and bounding box. */ // BEGIN: com.azure.ai.contentunderstanding.advanced.contentsource.fromanalysis - private static void documentContentSourceFromAnalysis(ContentUnderstandingClient client) { - // Analyze an invoice to get fields with grounding sources. - String invoiceUrl - = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf"; - - AnalysisInput input = new AnalysisInput(); - input.setUrl(invoiceUrl); - - SyncPoller operation - = client.beginAnalyze("prebuilt-invoice", Arrays.asList(input)); - - AnalysisResult result = operation.getFinalResult(); - DocumentContent documentContent = (DocumentContent) result.getContents().get(0); - + private static void documentContentSourceFromAnalysis(DocumentContent documentContent) { // Iterate over all fields and access their grounding sources. for (Map.Entry entry : documentContent.getFields().entrySet()) { String fieldName = entry.getKey(); @@ -118,63 +118,59 @@ private static void documentContentSourceFromAnalysis(ContentUnderstandingClient // END: com.azure.ai.contentunderstanding.advanced.contentsource.fromanalysis /** - * Demonstrates round-tripping field sources through {@link ContentSource#toRawString(List)} - * and {@link ContentSource#parseAll(String)}, plus multi-segment and page-only parsing. + * Demonstrates the two public parse methods and {@link ContentSource#toRawString(List)}: + *
    + *
  • {@link ContentSource#parseAll(String)} — base-class method, returns {@code List}
  • + *
  • {@link DocumentSource#parse(String)} — typed method, returns {@code List}
  • + *
*/ // BEGIN: com.azure.ai.contentunderstanding.advanced.contentsource.parse - private static void contentSourceParseRoundTrip(ContentUnderstandingClient client) { - // Analyze an invoice to get a field with grounding sources. - String invoiceUrl - = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf"; - - AnalysisInput input = new AnalysisInput(); - input.setUrl(invoiceUrl); - - SyncPoller operation - = client.beginAnalyze("prebuilt-invoice", Arrays.asList(input)); - - AnalysisResult result = operation.getFinalResult(); - DocumentContent documentContent = (DocumentContent) result.getContents().get(0); - - // Find a field that has grounding sources. + private static void contentSourceParseRoundTrip(DocumentContent documentContent) { + // --- toRawString + ContentSource.parseAll() round-trip --- + // ContentSource.parseAll() is the base-class method that handles both D() and AV() formats. + // It returns List, so you cast each element to the appropriate subclass. ContentField fieldWithSource = documentContent.getFields().values().stream() .filter(f -> f.getSources() != null) .findFirst() - .orElseThrow(); - - // Convert the parsed sources back to their wire-format string. - String sourceString = ContentSource.toRawString(fieldWithSource.getSources()); - System.out.println("Source wire format: " + sourceString); - - // Parse the wire-format string back into typed ContentSource instances. - List roundTripped = ContentSource.parseAll(sourceString); - DocumentSource roundTrippedDoc = (DocumentSource) roundTripped.get(0); - System.out.println("Round-tripped: page " + roundTrippedDoc.getPageNumber() - + ", polygon points: " + roundTrippedDoc.getPolygon().size()); - RectangleF bbox = roundTrippedDoc.getBoundingBox(); - System.out.printf(" BoundingBox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", - bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); - - // Find a field with multiple source segments (e.g., multi-line addresses). + .orElseThrow(() -> new IllegalStateException("No field with sources found")); + + String wireFormat = ContentSource.toRawString(fieldWithSource.getSources()); + System.out.println("Wire format: " + wireFormat); + + List parsed = ContentSource.parseAll(wireFormat); + for (ContentSource cs : parsed) { + if (cs instanceof DocumentSource) { + DocumentSource ds = (DocumentSource) cs; + System.out.println(" parseAll -> DocumentSource: page " + ds.getPageNumber() + + ", polygon points: " + (ds.getPolygon() != null ? ds.getPolygon().size() : 0)); + } + // AudioVisualSource would be handled here once the service returns AV sources. + } + + // --- DocumentSource.parse() — typed method --- + // DocumentSource.parse() is the typed convenience method. It returns List + // directly — no casting needed. Use this when you know the source string contains only D() segments. ContentField multiSourceField = documentContent.getFields().values().stream() .filter(f -> f.getSources() != null && f.getSources().size() > 1) .findFirst() - .orElseThrow(); - String multiSourceString = ContentSource.toRawString(multiSourceField.getSources()); - System.out.println("Multi-segment wire format: " + multiSourceString); - - List multiParsed = ContentSource.parseAll(multiSourceString); - String pageNumbers = multiParsed.stream() - .filter(s -> s instanceof DocumentSource) - .map(s -> String.valueOf(((DocumentSource) s).getPageNumber())) - .collect(Collectors.joining(", ")); - System.out.println("Multi-segment: " + multiParsed.size() + " sources on pages " + pageNumbers); - - // ContentSource.parseAll() also handles page-only format (no polygon coordinates). - // However, DocumentSource.parse() requires all 9 parameters (page + 8 coordinates). - // Use the full wire format from a real source for round-trip demonstrations. - int realPageNumber = ((DocumentSource) fieldWithSource.getSources().get(0)).getPageNumber(); - System.out.println("Page number from source: " + realPageNumber); + .orElseThrow(() -> new IllegalStateException("No field with multiple sources found")); + String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); + System.out.println("Multi-segment wire format: " + multiWireFormat); + + List docSources = DocumentSource.parse(multiWireFormat); + for (DocumentSource ds : docSources) { + RectangleF bbox = ds.getBoundingBox(); + System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", + ds.getPageNumber(), bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); + } + + // --- Page-only format: D(page) --- + // Both parseAll() and parse() support the page-only format with no coordinates. + List pageOnly = DocumentSource.parse("D(1)"); + DocumentSource pageOnlyDoc = pageOnly.get(0); + System.out.println("Page-only: page=" + pageOnlyDoc.getPageNumber() + + ", polygon=" + pageOnlyDoc.getPolygon() + + ", boundingBox=" + pageOnlyDoc.getBoundingBox()); } // END: com.azure.ai.contentunderstanding.advanced.contentsource.parse diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java new file mode 100644 index 000000000000..bf178d68a950 --- /dev/null +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.contentunderstanding.samples; + +import com.azure.ai.contentunderstanding.ContentUnderstandingAsyncClient; +import com.azure.ai.contentunderstanding.ContentUnderstandingClientBuilder; +import com.azure.ai.contentunderstanding.models.AnalysisInput; +import com.azure.ai.contentunderstanding.models.AnalysisResult; +import com.azure.ai.contentunderstanding.models.ContentAnalyzerAnalyzeOperationStatus; +import com.azure.ai.contentunderstanding.models.ContentField; +import com.azure.ai.contentunderstanding.models.ContentSource; +import com.azure.ai.contentunderstanding.models.DocumentContent; +import com.azure.ai.contentunderstanding.models.DocumentSource; +import com.azure.ai.contentunderstanding.models.PointF; +import com.azure.ai.contentunderstanding.models.RectangleF; +import com.azure.core.credential.AzureKeyCredential; +import com.azure.core.util.polling.PollerFlux; +import com.azure.identity.DefaultAzureCredentialBuilder; +import reactor.core.publisher.Mono; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * Async version of {@link Sample_Advanced_ContentSource}. Demonstrates how to access + * and use {@link ContentSource} grounding references from analysis results using the + * async client. + * + *

For document/image content, sources are {@link DocumentSource} instances + * with page number, polygon coordinates, and a computed bounding box.

+ * + *

For audio/video content, sources are {@link AudioVisualSource} instances + * with a timestamp and an optional bounding box.

+ */ +public class Sample_Advanced_ContentSourceAsync { + + public static void main(String[] args) throws InterruptedException { + String endpoint = System.getenv("CONTENTUNDERSTANDING_ENDPOINT"); + String key = System.getenv("CONTENTUNDERSTANDING_KEY"); + + ContentUnderstandingClientBuilder builder = new ContentUnderstandingClientBuilder().endpoint(endpoint); + + ContentUnderstandingAsyncClient client; + if (key != null && !key.trim().isEmpty()) { + client = builder.credential(new AzureKeyCredential(key)).buildAsyncClient(); + } else { + client = builder.credential(new DefaultAzureCredentialBuilder().build()).buildAsyncClient(); + } + + // Analyze an invoice once — reuse the result for all demonstrations. + String invoiceUrl + = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf"; + + AnalysisInput input = new AnalysisInput(); + input.setUrl(invoiceUrl); + + PollerFlux operation + = client.beginAnalyze("prebuilt-invoice", Arrays.asList(input)); + + CountDownLatch latch = new CountDownLatch(1); + + operation.last() + .flatMap(pollResponse -> { + if (pollResponse.getStatus().isComplete()) { + return pollResponse.getFinalResult(); + } else { + return Mono.error(new RuntimeException( + "Polling completed unsuccessfully with status: " + pollResponse.getStatus())); + } + }) + .doOnNext(result -> { + DocumentContent documentContent = (DocumentContent) result.getContents().get(0); + + // Part 1: Document ContentSource from analysis + documentContentSourceFromAnalysis(documentContent); + + // Part 2: ContentSource.parseAll() round-trip and multi-segment parsing + contentSourceParseRoundTrip(documentContent); + }) + .doFinally(signal -> latch.countDown()) + .subscribe(); + + latch.await(5, TimeUnit.MINUTES); + } + + /** + * Analyzes an invoice asynchronously and iterates over field grounding sources, + * casting each to {@link DocumentSource} to access page, polygon, and bounding box. + */ + // BEGIN: com.azure.ai.contentunderstanding.advanced.contentsource.fromanalysis.async + private static void documentContentSourceFromAnalysis(DocumentContent documentContent) { + // Iterate over all fields and access their grounding sources. + for (Map.Entry entry : documentContent.getFields().entrySet()) { + String fieldName = entry.getKey(); + ContentField field = entry.getValue(); + + System.out.println("Field: " + fieldName + " = " + field.getValue()); + + List sources = field.getSources(); + if (sources != null) { + for (ContentSource source : sources) { + if (source instanceof DocumentSource) { + DocumentSource docSource = (DocumentSource) source; + System.out.println(" Source: page " + docSource.getPageNumber()); + + List polygon = docSource.getPolygon(); + String coords = polygon.stream() + .map(p -> String.format("(%.4f,%.4f)", p.getX(), p.getY())) + .collect(Collectors.joining(", ")); + System.out.println(" Polygon: [" + coords + "]"); + + RectangleF bbox = docSource.getBoundingBox(); + System.out.printf(" BoundingBox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", + bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); + } + } + } + } + } + // END: com.azure.ai.contentunderstanding.advanced.contentsource.fromanalysis.async + + /** + * Demonstrates the two public parse methods and {@link ContentSource#toRawString(List)}: + *
    + *
  • {@link ContentSource#parseAll(String)} — base-class method, returns {@code List}
  • + *
  • {@link DocumentSource#parse(String)} — typed method, returns {@code List}
  • + *
+ */ + // BEGIN: com.azure.ai.contentunderstanding.advanced.contentsource.parse.async + private static void contentSourceParseRoundTrip(DocumentContent documentContent) { + // --- toRawString + ContentSource.parseAll() round-trip --- + // ContentSource.parseAll() is the base-class method that handles both D() and AV() formats. + // It returns List, so you cast each element to the appropriate subclass. + ContentField fieldWithSource = documentContent.getFields().values().stream() + .filter(f -> f.getSources() != null) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No field with sources found")); + + String wireFormat = ContentSource.toRawString(fieldWithSource.getSources()); + System.out.println("Wire format: " + wireFormat); + + List parsed = ContentSource.parseAll(wireFormat); + for (ContentSource cs : parsed) { + if (cs instanceof DocumentSource) { + DocumentSource ds = (DocumentSource) cs; + System.out.println(" parseAll -> DocumentSource: page " + ds.getPageNumber() + + ", polygon points: " + (ds.getPolygon() != null ? ds.getPolygon().size() : 0)); + } + // AudioVisualSource would be handled here once the service returns AV sources. + } + + // --- DocumentSource.parse() — typed method --- + // DocumentSource.parse() is the typed convenience method. It returns List + // directly — no casting needed. Use this when you know the source string contains only D() segments. + ContentField multiSourceField = documentContent.getFields().values().stream() + .filter(f -> f.getSources() != null && f.getSources().size() > 1) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No field with multiple sources found")); + String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); + System.out.println("Multi-segment wire format: " + multiWireFormat); + + List docSources = DocumentSource.parse(multiWireFormat); + for (DocumentSource ds : docSources) { + RectangleF bbox = ds.getBoundingBox(); + System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", + ds.getPageNumber(), bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); + } + + // --- Page-only format: D(page) --- + // Both parseAll() and parse() support the page-only format with no coordinates. + List pageOnly = DocumentSource.parse("D(1)"); + DocumentSource pageOnlyDoc = pageOnly.get(0); + System.out.println("Page-only: page=" + pageOnlyDoc.getPageNumber() + + ", polygon=" + pageOnlyDoc.getPolygon() + + ", boundingBox=" + pageOnlyDoc.getBoundingBox()); + } + // END: com.azure.ai.contentunderstanding.advanced.contentsource.parse.async + + // TODO: AudioVisualContentSource — demonstrate real AudioVisualSource grounding + // from audio/video analysis. The CU service does not currently return AudioVisualSource + // grounding (field.getSources()) for AI-generated audio fields. Once the service supports + // timestamp-level source grounding for audio/video content, implement a method here that: + // 1. Analyzes an audio/video file with a custom analyzer (estimateFieldSourceAndConfidence = true) + // 2. Iterates over fields and casts getSources() elements to AudioVisualSource + // 3. Shows AudioVisualSource.getTime() (Duration) and AudioVisualSource.getBoundingBox() (optional Rectangle) + // 4. Demonstrates ContentSource.parseAll() with AV(...) format strings +} diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java new file mode 100644 index 000000000000..0114bb4580e1 --- /dev/null +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.contentunderstanding.tests.samples; + +import com.azure.ai.contentunderstanding.models.AnalysisInput; +import com.azure.ai.contentunderstanding.models.AnalysisResult; +import com.azure.ai.contentunderstanding.models.ContentAnalyzerAnalyzeOperationStatus; +import com.azure.ai.contentunderstanding.models.ContentField; +import com.azure.ai.contentunderstanding.models.ContentSource; +import com.azure.ai.contentunderstanding.models.DocumentContent; +import com.azure.ai.contentunderstanding.models.DocumentSource; +import com.azure.ai.contentunderstanding.models.PointF; +import com.azure.ai.contentunderstanding.models.RectangleF; +import com.azure.core.util.polling.PollerFlux; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Async tests for {@link com.azure.ai.contentunderstanding.samples.Sample_Advanced_ContentSourceAsync}. + * Verifies DocumentSource grounding and ContentSource round-trip parsing using the async client. + */ +public class Sample_Advanced_ContentSourceAsyncTest extends ContentUnderstandingClientTestBase { + + @Test + public void testDocumentContentSourceFromAnalysisAsync() { + String invoiceUrl + = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf"; + + AnalysisInput input = new AnalysisInput(); + input.setUrl(invoiceUrl); + + PollerFlux operation + = contentUnderstandingAsyncClient.beginAnalyze("prebuilt-invoice", Arrays.asList(input)); + + AnalysisResult result = operation.last().flatMap(pollResponse -> { + if (pollResponse.getStatus().isComplete()) { + return pollResponse.getFinalResult(); + } else { + return Mono.error( + new RuntimeException("Polling completed unsuccessfully with status: " + pollResponse.getStatus())); + } + }).block(); + + assertNotNull(result, "Analysis result should not be null"); + assertNotNull(result.getContents(), "Result should contain contents"); + assertEquals(1, result.getContents().size(), "Invoice should have exactly one content element"); + + assertInstanceOf(DocumentContent.class, result.getContents().get(0), "Content should be DocumentContent"); + DocumentContent documentContent = (DocumentContent) result.getContents().get(0); + assertNotNull(documentContent.getFields(), "Document should have fields"); + + boolean hasDocumentSource = false; + for (Map.Entry entry : documentContent.getFields().entrySet()) { + String fieldName = entry.getKey(); + ContentField field = entry.getValue(); + + System.out.println("Field: " + fieldName + " = " + field.getValue()); + + List sources = field.getSources(); + if (sources != null) { + for (ContentSource source : sources) { + assertInstanceOf(DocumentSource.class, source, + "Sources for document fields should be DocumentSource, got " + + source.getClass().getSimpleName()); + hasDocumentSource = true; + DocumentSource docSource = (DocumentSource) source; + System.out.println(" Source: page " + docSource.getPageNumber()); + assertTrue(docSource.getPageNumber() >= 1, + "Page number should be >= 1, got " + docSource.getPageNumber()); + + List polygon = docSource.getPolygon(); + assertNotNull(polygon, "Polygon should not be null for document sources with coordinates"); + assertTrue(polygon.size() >= 3, "Polygon should have at least 3 points, got " + polygon.size()); + String coords = polygon.stream() + .map(p -> String.format("(%.4f,%.4f)", p.getX(), p.getY())) + .collect(Collectors.joining(", ")); + System.out.println(" Polygon: [" + coords + "]"); + + RectangleF bbox = docSource.getBoundingBox(); + assertNotNull(bbox, "BoundingBox should be computed from polygon"); + assertTrue(bbox.getWidth() > 0, "BoundingBox width should be > 0"); + assertTrue(bbox.getHeight() > 0, "BoundingBox height should be > 0"); + System.out.printf(" BoundingBox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", bbox.getX(), bbox.getY(), + bbox.getWidth(), bbox.getHeight()); + } + } + } + assertTrue(hasDocumentSource, "At least one field should have DocumentSource grounding"); + } + + @Test + public void testContentSourceParseRoundTripAsync() { + String invoiceUrl + = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf"; + + AnalysisInput input = new AnalysisInput(); + input.setUrl(invoiceUrl); + + PollerFlux operation + = contentUnderstandingAsyncClient.beginAnalyze("prebuilt-invoice", Arrays.asList(input)); + + AnalysisResult result = operation.last().flatMap(pollResponse -> { + if (pollResponse.getStatus().isComplete()) { + return pollResponse.getFinalResult(); + } else { + return Mono.error( + new RuntimeException("Polling completed unsuccessfully with status: " + pollResponse.getStatus())); + } + }).block(); + + DocumentContent documentContent = (DocumentContent) result.getContents().get(0); + + // --- ContentSource.parseAll() round-trip --- + ContentField fieldWithSource = documentContent.getFields() + .values() + .stream() + .filter(f -> f.getSources() != null) + .findFirst() + .orElseThrow(() -> new AssertionError("No field with sources found")); + + String wireFormat = ContentSource.toRawString(fieldWithSource.getSources()); + assertNotNull(wireFormat, "Wire format should not be null"); + assertFalse(wireFormat.isEmpty(), "Wire format should not be empty"); + System.out.println("Wire format: " + wireFormat); + + List parsed = ContentSource.parseAll(wireFormat); + assertNotNull(parsed, "Parsed sources should not be null"); + assertTrue(parsed.size() >= 1, "Parsed should have at least one source"); + + for (ContentSource cs : parsed) { + assertInstanceOf(DocumentSource.class, cs, "Parsed source should be DocumentSource"); + DocumentSource ds = (DocumentSource) cs; + assertTrue(ds.getPageNumber() >= 1, "Page number should be >= 1"); + assertNotNull(ds.getPolygon(), "Polygon should not be null"); + assertTrue(ds.getPolygon().size() >= 3, + "Polygon should have at least 3 points, got " + ds.getPolygon().size()); + System.out + .println(" parseAll -> page " + ds.getPageNumber() + ", polygon points: " + ds.getPolygon().size()); + } + + // --- DocumentSource.parse() — typed method for multi-segment --- + ContentField multiSourceField = documentContent.getFields() + .values() + .stream() + .filter(f -> f.getSources() != null && f.getSources().size() > 1) + .findFirst() + .orElseThrow(() -> new AssertionError("No field with multiple sources found")); + String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); + System.out.println("Multi-segment wire format: " + multiWireFormat); + + List docSources = DocumentSource.parse(multiWireFormat); + assertEquals(multiSourceField.getSources().size(), docSources.size(), + "DocumentSource.parse() count should match original source count"); + for (DocumentSource ds : docSources) { + assertTrue(ds.getPageNumber() >= 1, "Page number should be >= 1"); + RectangleF bbox = ds.getBoundingBox(); + assertNotNull(bbox, "BoundingBox should not be null"); + assertTrue(bbox.getWidth() > 0, "BoundingBox width should be > 0"); + assertTrue(bbox.getHeight() > 0, "BoundingBox height should be > 0"); + System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", ds.getPageNumber(), + bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); + } + + // --- Page-only format: D(page) via DocumentSource.parse() --- + List pageOnly = DocumentSource.parse("D(1)"); + assertEquals(1, pageOnly.size(), "Page-only should parse to 1 source"); + DocumentSource pageOnlyDoc = pageOnly.get(0); + assertEquals(1, pageOnlyDoc.getPageNumber(), "Page-only page number should be 1"); + assertNull(pageOnlyDoc.getPolygon(), "Page-only polygon should be null"); + assertNull(pageOnlyDoc.getBoundingBox(), "Page-only boundingBox should be null"); + assertEquals("D(1)", pageOnlyDoc.getRawValue(), "Page-only round-trip should match"); + System.out.println("Page-only: D(1) -> page=" + pageOnlyDoc.getPageNumber() + ", polygon=" + + pageOnlyDoc.getPolygon() + ", boundingBox=" + pageOnlyDoc.getBoundingBox()); + } +} diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java new file mode 100644 index 000000000000..3b183cbe9e46 --- /dev/null +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.ai.contentunderstanding.tests.samples; + +import com.azure.ai.contentunderstanding.models.AnalysisInput; +import com.azure.ai.contentunderstanding.models.AnalysisResult; +import com.azure.ai.contentunderstanding.models.ContentAnalyzerAnalyzeOperationStatus; +import com.azure.ai.contentunderstanding.models.ContentField; +import com.azure.ai.contentunderstanding.models.ContentSource; +import com.azure.ai.contentunderstanding.models.DocumentContent; +import com.azure.ai.contentunderstanding.models.DocumentSource; +import com.azure.ai.contentunderstanding.models.PointF; +import com.azure.ai.contentunderstanding.models.RectangleF; +import com.azure.core.util.polling.SyncPoller; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Tests for {@link com.azure.ai.contentunderstanding.samples.Sample_Advanced_ContentSource}. + * Verifies DocumentSource grounding and ContentSource round-trip parsing. + */ +public class Sample_Advanced_ContentSourceTest extends ContentUnderstandingClientTestBase { + + @Test + public void testDocumentContentSourceFromAnalysis() { + // Analyze an invoice to get fields with grounding sources. + String invoiceUrl + = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf"; + + AnalysisInput input = new AnalysisInput(); + input.setUrl(invoiceUrl); + + SyncPoller operation + = contentUnderstandingClient.beginAnalyze("prebuilt-invoice", Arrays.asList(input)); + + AnalysisResult result = operation.getFinalResult(); + assertNotNull(result, "Analysis result should not be null"); + assertNotNull(result.getContents(), "Result should contain contents"); + assertEquals(1, result.getContents().size(), "Invoice should have exactly one content element"); + + assertInstanceOf(DocumentContent.class, result.getContents().get(0), "Content should be DocumentContent"); + DocumentContent documentContent = (DocumentContent) result.getContents().get(0); + assertNotNull(documentContent.getFields(), "Document should have fields"); + + boolean hasDocumentSource = false; + for (Map.Entry entry : documentContent.getFields().entrySet()) { + String fieldName = entry.getKey(); + ContentField field = entry.getValue(); + + System.out.println("Field: " + fieldName + " = " + field.getValue()); + + List sources = field.getSources(); + if (sources != null) { + for (ContentSource source : sources) { + assertInstanceOf(DocumentSource.class, source, + "Sources for document fields should be DocumentSource, got " + + source.getClass().getSimpleName()); + hasDocumentSource = true; + DocumentSource docSource = (DocumentSource) source; + System.out.println(" Source: page " + docSource.getPageNumber()); + assertTrue(docSource.getPageNumber() >= 1, + "Page number should be >= 1, got " + docSource.getPageNumber()); + + List polygon = docSource.getPolygon(); + assertNotNull(polygon, "Polygon should not be null for document sources with coordinates"); + assertTrue(polygon.size() >= 3, "Polygon should have at least 3 points, got " + polygon.size()); + String coords = polygon.stream() + .map(p -> String.format("(%.4f,%.4f)", p.getX(), p.getY())) + .collect(Collectors.joining(", ")); + System.out.println(" Polygon: [" + coords + "]"); + + RectangleF bbox = docSource.getBoundingBox(); + assertNotNull(bbox, "BoundingBox should be computed from polygon"); + assertTrue(bbox.getWidth() > 0, "BoundingBox width should be > 0"); + assertTrue(bbox.getHeight() > 0, "BoundingBox height should be > 0"); + System.out.printf(" BoundingBox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", bbox.getX(), bbox.getY(), + bbox.getWidth(), bbox.getHeight()); + } + } + } + assertTrue(hasDocumentSource, "At least one field should have DocumentSource grounding"); + } + + @Test + public void testContentSourceParseRoundTrip() { + // Analyze an invoice to get a field with grounding sources. + String invoiceUrl + = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf"; + + AnalysisInput input = new AnalysisInput(); + input.setUrl(invoiceUrl); + + SyncPoller operation + = contentUnderstandingClient.beginAnalyze("prebuilt-invoice", Arrays.asList(input)); + + AnalysisResult result = operation.getFinalResult(); + DocumentContent documentContent = (DocumentContent) result.getContents().get(0); + + // --- ContentSource.parseAll() round-trip --- + ContentField fieldWithSource = documentContent.getFields() + .values() + .stream() + .filter(f -> f.getSources() != null) + .findFirst() + .orElseThrow(() -> new AssertionError("No field with sources found")); + + String wireFormat = ContentSource.toRawString(fieldWithSource.getSources()); + assertNotNull(wireFormat, "Wire format should not be null"); + assertFalse(wireFormat.isEmpty(), "Wire format should not be empty"); + System.out.println("Wire format: " + wireFormat); + + List parsed = ContentSource.parseAll(wireFormat); + assertNotNull(parsed, "Parsed sources should not be null"); + assertTrue(parsed.size() >= 1, "Parsed should have at least one source"); + + for (ContentSource cs : parsed) { + assertInstanceOf(DocumentSource.class, cs, "Parsed source should be DocumentSource"); + DocumentSource ds = (DocumentSource) cs; + assertTrue(ds.getPageNumber() >= 1, "Page number should be >= 1"); + assertNotNull(ds.getPolygon(), "Polygon should not be null"); + assertTrue(ds.getPolygon().size() >= 3, + "Polygon should have at least 3 points, got " + ds.getPolygon().size()); + System.out + .println(" parseAll -> page " + ds.getPageNumber() + ", polygon points: " + ds.getPolygon().size()); + } + + // --- DocumentSource.parse() — typed method for multi-segment --- + ContentField multiSourceField = documentContent.getFields() + .values() + .stream() + .filter(f -> f.getSources() != null && f.getSources().size() > 1) + .findFirst() + .orElseThrow(() -> new AssertionError("No field with multiple sources found")); + String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); + System.out.println("Multi-segment wire format: " + multiWireFormat); + + List docSources = DocumentSource.parse(multiWireFormat); + assertEquals(multiSourceField.getSources().size(), docSources.size(), + "DocumentSource.parse() count should match original source count"); + for (DocumentSource ds : docSources) { + assertTrue(ds.getPageNumber() >= 1, "Page number should be >= 1"); + RectangleF bbox = ds.getBoundingBox(); + assertNotNull(bbox, "BoundingBox should not be null"); + assertTrue(bbox.getWidth() > 0, "BoundingBox width should be > 0"); + assertTrue(bbox.getHeight() > 0, "BoundingBox height should be > 0"); + System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", ds.getPageNumber(), + bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); + } + + // --- Page-only format: D(page) via DocumentSource.parse() --- + List pageOnly = DocumentSource.parse("D(1)"); + assertEquals(1, pageOnly.size(), "Page-only should parse to 1 source"); + DocumentSource pageOnlyDoc = pageOnly.get(0); + assertEquals(1, pageOnlyDoc.getPageNumber(), "Page-only page number should be 1"); + assertNull(pageOnlyDoc.getPolygon(), "Page-only polygon should be null"); + assertNull(pageOnlyDoc.getBoundingBox(), "Page-only boundingBox should be null"); + assertEquals("D(1)", pageOnlyDoc.getRawValue(), "Page-only round-trip should match"); + System.out.println("Page-only: D(1) -> page=" + pageOnlyDoc.getPageNumber() + ", polygon=" + + pageOnlyDoc.getPolygon() + ", boundingBox=" + pageOnlyDoc.getBoundingBox()); + } +} From 1fef6f4f8fa5d4f36b91e2b96485a968da617961 Mon Sep 17 00:00:00 2001 From: Changjian Wang Date: Tue, 31 Mar 2026 15:35:46 +0800 Subject: [PATCH 3/6] Refactor Sample_Advanced_ContentSource and Sample_Advanced_ContentSourceAsync to enhance multi-segment parsing examples and update documentation comments --- .../Sample_Advanced_ContentSource.java | 38 +++++++-------- .../Sample_Advanced_ContentSourceAsync.java | 38 +++++++-------- ...ample_Advanced_ContentSourceAsyncTest.java | 46 +++++++++---------- .../Sample_Advanced_ContentSourceTest.java | 46 +++++++++---------- 4 files changed, 84 insertions(+), 84 deletions(-) diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java index 23c8caaf02ae..c20f89302a9f 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java @@ -71,7 +71,7 @@ public static void main(String[] args) { documentContentSourceFromAnalysis(documentContent); // ===================================================================== - // Part 2: ContentSource.parseAll() round-trip and multi-segment parsing + // Part 2: DocumentSource.parse() and ContentSource.parseAll() round-trip // ===================================================================== contentSourceParseRoundTrip(documentContent); } @@ -120,12 +120,29 @@ private static void documentContentSourceFromAnalysis(DocumentContent documentCo /** * Demonstrates the two public parse methods and {@link ContentSource#toRawString(List)}: *
    - *
  • {@link ContentSource#parseAll(String)} — base-class method, returns {@code List}
  • *
  • {@link DocumentSource#parse(String)} — typed method, returns {@code List}
  • + *
  • {@link ContentSource#parseAll(String)} — base-class method, returns {@code List}
  • *
*/ // BEGIN: com.azure.ai.contentunderstanding.advanced.contentsource.parse private static void contentSourceParseRoundTrip(DocumentContent documentContent) { + // --- DocumentSource.parse() — typed method --- + // DocumentSource.parse() is the typed convenience method. It returns List + // directly — no casting needed. Use this when you know the source string contains only D() segments. + ContentField multiSourceField = documentContent.getFields().values().stream() + .filter(f -> f.getSources() != null && f.getSources().size() > 1) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No field with multiple sources found")); + String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); + System.out.println("Multi-segment wire format: " + multiWireFormat); + + List docSources = DocumentSource.parse(multiWireFormat); + for (DocumentSource ds : docSources) { + RectangleF bbox = ds.getBoundingBox(); + System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", + ds.getPageNumber(), bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); + } + // --- toRawString + ContentSource.parseAll() round-trip --- // ContentSource.parseAll() is the base-class method that handles both D() and AV() formats. // It returns List, so you cast each element to the appropriate subclass. @@ -147,23 +164,6 @@ private static void contentSourceParseRoundTrip(DocumentContent documentContent) // AudioVisualSource would be handled here once the service returns AV sources. } - // --- DocumentSource.parse() — typed method --- - // DocumentSource.parse() is the typed convenience method. It returns List - // directly — no casting needed. Use this when you know the source string contains only D() segments. - ContentField multiSourceField = documentContent.getFields().values().stream() - .filter(f -> f.getSources() != null && f.getSources().size() > 1) - .findFirst() - .orElseThrow(() -> new IllegalStateException("No field with multiple sources found")); - String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); - System.out.println("Multi-segment wire format: " + multiWireFormat); - - List docSources = DocumentSource.parse(multiWireFormat); - for (DocumentSource ds : docSources) { - RectangleF bbox = ds.getBoundingBox(); - System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", - ds.getPageNumber(), bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); - } - // --- Page-only format: D(page) --- // Both parseAll() and parse() support the page-only format with no coordinates. List pageOnly = DocumentSource.parse("D(1)"); diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java index bf178d68a950..fb76217dfc2f 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java @@ -79,7 +79,7 @@ public static void main(String[] args) throws InterruptedException { // Part 1: Document ContentSource from analysis documentContentSourceFromAnalysis(documentContent); - // Part 2: ContentSource.parseAll() round-trip and multi-segment parsing + // Part 2: DocumentSource.parse() and ContentSource.parseAll() round-trip contentSourceParseRoundTrip(documentContent); }) .doFinally(signal -> latch.countDown()) @@ -127,12 +127,29 @@ private static void documentContentSourceFromAnalysis(DocumentContent documentCo /** * Demonstrates the two public parse methods and {@link ContentSource#toRawString(List)}: *
    - *
  • {@link ContentSource#parseAll(String)} — base-class method, returns {@code List}
  • *
  • {@link DocumentSource#parse(String)} — typed method, returns {@code List}
  • + *
  • {@link ContentSource#parseAll(String)} — base-class method, returns {@code List}
  • *
*/ // BEGIN: com.azure.ai.contentunderstanding.advanced.contentsource.parse.async private static void contentSourceParseRoundTrip(DocumentContent documentContent) { + // --- DocumentSource.parse() — typed method --- + // DocumentSource.parse() is the typed convenience method. It returns List + // directly — no casting needed. Use this when you know the source string contains only D() segments. + ContentField multiSourceField = documentContent.getFields().values().stream() + .filter(f -> f.getSources() != null && f.getSources().size() > 1) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No field with multiple sources found")); + String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); + System.out.println("Multi-segment wire format: " + multiWireFormat); + + List docSources = DocumentSource.parse(multiWireFormat); + for (DocumentSource ds : docSources) { + RectangleF bbox = ds.getBoundingBox(); + System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", + ds.getPageNumber(), bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); + } + // --- toRawString + ContentSource.parseAll() round-trip --- // ContentSource.parseAll() is the base-class method that handles both D() and AV() formats. // It returns List, so you cast each element to the appropriate subclass. @@ -154,23 +171,6 @@ private static void contentSourceParseRoundTrip(DocumentContent documentContent) // AudioVisualSource would be handled here once the service returns AV sources. } - // --- DocumentSource.parse() — typed method --- - // DocumentSource.parse() is the typed convenience method. It returns List - // directly — no casting needed. Use this when you know the source string contains only D() segments. - ContentField multiSourceField = documentContent.getFields().values().stream() - .filter(f -> f.getSources() != null && f.getSources().size() > 1) - .findFirst() - .orElseThrow(() -> new IllegalStateException("No field with multiple sources found")); - String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); - System.out.println("Multi-segment wire format: " + multiWireFormat); - - List docSources = DocumentSource.parse(multiWireFormat); - for (DocumentSource ds : docSources) { - RectangleF bbox = ds.getBoundingBox(); - System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", - ds.getPageNumber(), bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); - } - // --- Page-only format: D(page) --- // Both parseAll() and parse() support the page-only format with no coordinates. List pageOnly = DocumentSource.parse("D(1)"); diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java index 0114bb4580e1..c96366f7fd8f 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java @@ -123,6 +123,29 @@ public void testContentSourceParseRoundTripAsync() { DocumentContent documentContent = (DocumentContent) result.getContents().get(0); + // --- DocumentSource.parse() — typed method for multi-segment --- + ContentField multiSourceField = documentContent.getFields() + .values() + .stream() + .filter(f -> f.getSources() != null && f.getSources().size() > 1) + .findFirst() + .orElseThrow(() -> new AssertionError("No field with multiple sources found")); + String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); + System.out.println("Multi-segment wire format: " + multiWireFormat); + + List docSources = DocumentSource.parse(multiWireFormat); + assertEquals(multiSourceField.getSources().size(), docSources.size(), + "DocumentSource.parse() count should match original source count"); + for (DocumentSource ds : docSources) { + assertTrue(ds.getPageNumber() >= 1, "Page number should be >= 1"); + RectangleF bbox = ds.getBoundingBox(); + assertNotNull(bbox, "BoundingBox should not be null"); + assertTrue(bbox.getWidth() > 0, "BoundingBox width should be > 0"); + assertTrue(bbox.getHeight() > 0, "BoundingBox height should be > 0"); + System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", ds.getPageNumber(), + bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); + } + // --- ContentSource.parseAll() round-trip --- ContentField fieldWithSource = documentContent.getFields() .values() @@ -151,29 +174,6 @@ public void testContentSourceParseRoundTripAsync() { .println(" parseAll -> page " + ds.getPageNumber() + ", polygon points: " + ds.getPolygon().size()); } - // --- DocumentSource.parse() — typed method for multi-segment --- - ContentField multiSourceField = documentContent.getFields() - .values() - .stream() - .filter(f -> f.getSources() != null && f.getSources().size() > 1) - .findFirst() - .orElseThrow(() -> new AssertionError("No field with multiple sources found")); - String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); - System.out.println("Multi-segment wire format: " + multiWireFormat); - - List docSources = DocumentSource.parse(multiWireFormat); - assertEquals(multiSourceField.getSources().size(), docSources.size(), - "DocumentSource.parse() count should match original source count"); - for (DocumentSource ds : docSources) { - assertTrue(ds.getPageNumber() >= 1, "Page number should be >= 1"); - RectangleF bbox = ds.getBoundingBox(); - assertNotNull(bbox, "BoundingBox should not be null"); - assertTrue(bbox.getWidth() > 0, "BoundingBox width should be > 0"); - assertTrue(bbox.getHeight() > 0, "BoundingBox height should be > 0"); - System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", ds.getPageNumber(), - bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); - } - // --- Page-only format: D(page) via DocumentSource.parse() --- List pageOnly = DocumentSource.parse("D(1)"); assertEquals(1, pageOnly.size(), "Page-only should parse to 1 source"); diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java index 3b183cbe9e46..82ec3df88aec 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java @@ -108,6 +108,29 @@ public void testContentSourceParseRoundTrip() { AnalysisResult result = operation.getFinalResult(); DocumentContent documentContent = (DocumentContent) result.getContents().get(0); + // --- DocumentSource.parse() — typed method for multi-segment --- + ContentField multiSourceField = documentContent.getFields() + .values() + .stream() + .filter(f -> f.getSources() != null && f.getSources().size() > 1) + .findFirst() + .orElseThrow(() -> new AssertionError("No field with multiple sources found")); + String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); + System.out.println("Multi-segment wire format: " + multiWireFormat); + + List docSources = DocumentSource.parse(multiWireFormat); + assertEquals(multiSourceField.getSources().size(), docSources.size(), + "DocumentSource.parse() count should match original source count"); + for (DocumentSource ds : docSources) { + assertTrue(ds.getPageNumber() >= 1, "Page number should be >= 1"); + RectangleF bbox = ds.getBoundingBox(); + assertNotNull(bbox, "BoundingBox should not be null"); + assertTrue(bbox.getWidth() > 0, "BoundingBox width should be > 0"); + assertTrue(bbox.getHeight() > 0, "BoundingBox height should be > 0"); + System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", ds.getPageNumber(), + bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); + } + // --- ContentSource.parseAll() round-trip --- ContentField fieldWithSource = documentContent.getFields() .values() @@ -136,29 +159,6 @@ public void testContentSourceParseRoundTrip() { .println(" parseAll -> page " + ds.getPageNumber() + ", polygon points: " + ds.getPolygon().size()); } - // --- DocumentSource.parse() — typed method for multi-segment --- - ContentField multiSourceField = documentContent.getFields() - .values() - .stream() - .filter(f -> f.getSources() != null && f.getSources().size() > 1) - .findFirst() - .orElseThrow(() -> new AssertionError("No field with multiple sources found")); - String multiWireFormat = ContentSource.toRawString(multiSourceField.getSources()); - System.out.println("Multi-segment wire format: " + multiWireFormat); - - List docSources = DocumentSource.parse(multiWireFormat); - assertEquals(multiSourceField.getSources().size(), docSources.size(), - "DocumentSource.parse() count should match original source count"); - for (DocumentSource ds : docSources) { - assertTrue(ds.getPageNumber() >= 1, "Page number should be >= 1"); - RectangleF bbox = ds.getBoundingBox(); - assertNotNull(bbox, "BoundingBox should not be null"); - assertTrue(bbox.getWidth() > 0, "BoundingBox width should be > 0"); - assertTrue(bbox.getHeight() > 0, "BoundingBox height should be > 0"); - System.out.printf(" parse -> page %d, bbox: x=%.4f, y=%.4f, w=%.4f, h=%.4f%n", ds.getPageNumber(), - bbox.getX(), bbox.getY(), bbox.getWidth(), bbox.getHeight()); - } - // --- Page-only format: D(page) via DocumentSource.parse() --- List pageOnly = DocumentSource.parse("D(1)"); assertEquals(1, pageOnly.size(), "Page-only should parse to 1 source"); From a77e3e51572c9f6d5810c65529de86377480833a Mon Sep 17 00:00:00 2001 From: Changjian Wang Date: Tue, 31 Mar 2026 16:13:38 +0800 Subject: [PATCH 4/6] Update documentation comments in DocumentSource for clarity on bounding box and source format --- .../azure/ai/contentunderstanding/models/DocumentSource.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java index 365b3cda4ed9..654f527fe113 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java @@ -108,7 +108,8 @@ public List getPolygon() { /** * Gets the axis-aligned bounding rectangle computed from the polygon coordinates, - * or {@code null} when no polygon is available. + * or {@code null} when only a page number is available. + * Useful for drawing highlight rectangles over extracted fields. * * @return The bounding box, or {@code null} for page-only sources. */ @@ -119,7 +120,7 @@ public RectangleF getBoundingBox() { /** * Parses a single document source segment. * - * @param source The source string in the format {@code D(page,x1,y1,...,xN,yN)} or {@code D(page)}. + * @param source The source string in the format {@code D(page)} or {@code D(page,x1,y1,...,xN,yN)}. * @return A new {@link DocumentSource}. * @throws NullPointerException if {@code source} is null. * @throws IllegalArgumentException if the source string is not in the expected format. From da27f07605487deb3d6cdff777ba5b227a302cb3 Mon Sep 17 00:00:00 2001 From: aluneth Date: Tue, 31 Mar 2026 22:53:56 +0800 Subject: [PATCH 5/6] Update DocumentSource to enforce parameter count and refine polygon handling; remove page-only format examples from samples and tests --- .../azure-ai-contentunderstanding/assets.json | 2 +- .../models/DocumentSource.java | 41 ++++++++----------- .../Sample_Advanced_ContentSource.java | 8 ---- .../Sample_Advanced_ContentSourceAsync.java | 8 ---- ...ample_Advanced_ContentSourceAsyncTest.java | 12 ------ .../Sample_Advanced_ContentSourceTest.java | 12 ------ 6 files changed, 17 insertions(+), 66 deletions(-) diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/assets.json b/sdk/contentunderstanding/azure-ai-contentunderstanding/assets.json index 58186b3555c8..784cecb42f4e 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/assets.json +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/contentunderstanding/azure-ai-contentunderstanding", - "Tag": "java/contentunderstanding/azure-ai-contentunderstanding_670ad2966f" + "Tag": "java/contentunderstanding/azure-ai-contentunderstanding_940a862f7e" } diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java index 654f527fe113..8ec1fb0f4b81 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/main/java/com/azure/ai/contentunderstanding/models/DocumentSource.java @@ -11,11 +11,10 @@ import java.util.Objects; /** - * Represents a parsed document grounding source in the format {@code D(page,x1,y1,...,xN,yN)} or {@code D(page)}. + * Represents a parsed document grounding source in the format {@code D(page,x1,y1,x2,y2,x3,y3,x4,y4)}. * - *

The page number is 1-based. The polygon defines a region with three or more points - * in the document's coordinate space. When only a page number is provided (no coordinates), - * {@link #getPolygon()} and {@link #getBoundingBox()} return {@code null}.

+ *

The page number is 1-based. The polygon is a quadrilateral defined by four points + * with coordinates in the document's coordinate space.

* * @see ContentSource */ @@ -23,6 +22,7 @@ public final class DocumentSource extends ContentSource { private static final ClientLogger LOGGER = new ClientLogger(DocumentSource.class); private static final String PREFIX = "D("; + private static final int EXPECTED_PARAM_COUNT = 9; private final int pageNumber; private final List polygon; @@ -36,6 +36,11 @@ private DocumentSource(String source) { } String inner = source.substring(PREFIX.length(), source.length() - 1); String[] parts = inner.split(","); + if (parts.length != EXPECTED_PARAM_COUNT) { + throw LOGGER + .logExceptionAsError(new IllegalArgumentException("Document source expected " + EXPECTED_PARAM_COUNT + + " parameters (page + 8 coordinates), got " + parts.length + ": '" + source + "'.")); + } try { this.pageNumber = Integer.parseInt(parts[0].trim()); } catch (NumberFormatException e) { @@ -46,23 +51,10 @@ private DocumentSource(String source) { throw LOGGER.logExceptionAsError( new IllegalArgumentException("Page number must be >= 1, got " + this.pageNumber + ".")); } - if (parts.length == 1) { - // Page-only: D(page) - this.polygon = null; - this.boundingBox = null; - return; - } - int coordCount = parts.length - 1; - if (coordCount < 6 || coordCount % 2 != 0) { - throw LOGGER.logExceptionAsError(new IllegalArgumentException( - "Document source expected page-only (1 param) or page + at least 3 coordinate pairs (7+ params), got " - + parts.length + ": '" + source + "'.")); - } - int pointCount = coordCount / 2; - List points = new ArrayList<>(pointCount); + List points = new ArrayList<>(4); float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE; float maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE; - for (int i = 0; i < pointCount; i++) { + for (int i = 0; i < 4; i++) { int xIndex = 1 + (i * 2); int yIndex = 2 + (i * 2); float x, y; @@ -98,20 +90,19 @@ public int getPageNumber() { } /** - * Gets the polygon coordinates defining the region, or {@code null} when only a page number is available. + * Gets the polygon coordinates as four points defining a quadrilateral region. * - * @return An unmodifiable list of {@link PointF} values, or {@code null} for page-only sources. + * @return An unmodifiable list of four {@link PointF} values. */ public List getPolygon() { return polygon; } /** - * Gets the axis-aligned bounding rectangle computed from the polygon coordinates, - * or {@code null} when only a page number is available. + * Gets the axis-aligned bounding rectangle computed from the polygon coordinates. * Useful for drawing highlight rectangles over extracted fields. * - * @return The bounding box, or {@code null} for page-only sources. + * @return The bounding box. */ public RectangleF getBoundingBox() { return boundingBox; @@ -120,7 +111,7 @@ public RectangleF getBoundingBox() { /** * Parses a single document source segment. * - * @param source The source string in the format {@code D(page)} or {@code D(page,x1,y1,...,xN,yN)}. + * @param source The source string in the format {@code D(page,x1,y1,...,x4,y4)}. * @return A new {@link DocumentSource}. * @throws NullPointerException if {@code source} is null. * @throws IllegalArgumentException if the source string is not in the expected format. diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java index c20f89302a9f..01cd09e109c9 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSource.java @@ -163,14 +163,6 @@ private static void contentSourceParseRoundTrip(DocumentContent documentContent) } // AudioVisualSource would be handled here once the service returns AV sources. } - - // --- Page-only format: D(page) --- - // Both parseAll() and parse() support the page-only format with no coordinates. - List pageOnly = DocumentSource.parse("D(1)"); - DocumentSource pageOnlyDoc = pageOnly.get(0); - System.out.println("Page-only: page=" + pageOnlyDoc.getPageNumber() - + ", polygon=" + pageOnlyDoc.getPolygon() - + ", boundingBox=" + pageOnlyDoc.getBoundingBox()); } // END: com.azure.ai.contentunderstanding.advanced.contentsource.parse diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java index fb76217dfc2f..4ab4e0572f7d 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/samples/java/com/azure/ai/contentunderstanding/samples/Sample_Advanced_ContentSourceAsync.java @@ -170,14 +170,6 @@ private static void contentSourceParseRoundTrip(DocumentContent documentContent) } // AudioVisualSource would be handled here once the service returns AV sources. } - - // --- Page-only format: D(page) --- - // Both parseAll() and parse() support the page-only format with no coordinates. - List pageOnly = DocumentSource.parse("D(1)"); - DocumentSource pageOnlyDoc = pageOnly.get(0); - System.out.println("Page-only: page=" + pageOnlyDoc.getPageNumber() - + ", polygon=" + pageOnlyDoc.getPolygon() - + ", boundingBox=" + pageOnlyDoc.getBoundingBox()); } // END: com.azure.ai.contentunderstanding.advanced.contentsource.parse.async diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java index c96366f7fd8f..43a22c874b0f 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceAsyncTest.java @@ -20,7 +20,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Arrays; @@ -173,16 +172,5 @@ public void testContentSourceParseRoundTripAsync() { System.out .println(" parseAll -> page " + ds.getPageNumber() + ", polygon points: " + ds.getPolygon().size()); } - - // --- Page-only format: D(page) via DocumentSource.parse() --- - List pageOnly = DocumentSource.parse("D(1)"); - assertEquals(1, pageOnly.size(), "Page-only should parse to 1 source"); - DocumentSource pageOnlyDoc = pageOnly.get(0); - assertEquals(1, pageOnlyDoc.getPageNumber(), "Page-only page number should be 1"); - assertNull(pageOnlyDoc.getPolygon(), "Page-only polygon should be null"); - assertNull(pageOnlyDoc.getBoundingBox(), "Page-only boundingBox should be null"); - assertEquals("D(1)", pageOnlyDoc.getRawValue(), "Page-only round-trip should match"); - System.out.println("Page-only: D(1) -> page=" + pageOnlyDoc.getPageNumber() + ", polygon=" - + pageOnlyDoc.getPolygon() + ", boundingBox=" + pageOnlyDoc.getBoundingBox()); } } diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java index 82ec3df88aec..fe4f38a80d06 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/src/test/java/com/azure/ai/contentunderstanding/tests/samples/Sample_Advanced_ContentSourceTest.java @@ -19,7 +19,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Arrays; @@ -158,16 +157,5 @@ public void testContentSourceParseRoundTrip() { System.out .println(" parseAll -> page " + ds.getPageNumber() + ", polygon points: " + ds.getPolygon().size()); } - - // --- Page-only format: D(page) via DocumentSource.parse() --- - List pageOnly = DocumentSource.parse("D(1)"); - assertEquals(1, pageOnly.size(), "Page-only should parse to 1 source"); - DocumentSource pageOnlyDoc = pageOnly.get(0); - assertEquals(1, pageOnlyDoc.getPageNumber(), "Page-only page number should be 1"); - assertNull(pageOnlyDoc.getPolygon(), "Page-only polygon should be null"); - assertNull(pageOnlyDoc.getBoundingBox(), "Page-only boundingBox should be null"); - assertEquals("D(1)", pageOnlyDoc.getRawValue(), "Page-only round-trip should match"); - System.out.println("Page-only: D(1) -> page=" + pageOnlyDoc.getPageNumber() + ", polygon=" - + pageOnlyDoc.getPolygon() + ", boundingBox=" + pageOnlyDoc.getBoundingBox()); } } From 918da3989f9d62fcccebc2254030e64a29d2fd10 Mon Sep 17 00:00:00 2001 From: aluneth Date: Tue, 31 Mar 2026 22:59:38 +0800 Subject: [PATCH 6/6] Refactor DocumentSource to enforce parameter count for polygon coordinates; update documentation for clarity on expected format and return values. --- .../ContentUnderstandingCustomizations.java | 43 ++++++++----------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/customization/src/main/java/ContentUnderstandingCustomizations.java b/sdk/contentunderstanding/azure-ai-contentunderstanding/customization/src/main/java/ContentUnderstandingCustomizations.java index 35c7beccaf06..200991d14cec 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/customization/src/main/java/ContentUnderstandingCustomizations.java +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/customization/src/main/java/ContentUnderstandingCustomizations.java @@ -1489,18 +1489,18 @@ private void addSourcesMethod(LibraryCustomization customization, Logger logger) + "import java.util.List;\n" + "import java.util.Objects;\n\n" + "/**\n" - + " * Represents a parsed document grounding source in the format {@code D(page,x1,y1,...,xN,yN)} or {@code D(page)}.\n" + + " * Represents a parsed document grounding source in the format {@code D(page,x1,y1,x2,y2,x3,y3,x4,y4)}.\n" + " *\n" - + " *

The page number is 1-based. The polygon defines a region with three or more points\n" - + " * in the document's coordinate space. When only a page number is provided (no coordinates),\n" - + " * {@link #getPolygon()} and {@link #getBoundingBox()} return {@code null}.

\n" + + " *

The page number is 1-based. The polygon is a quadrilateral defined by four points\n" + + " * with coordinates in the document's coordinate space.

\n" + " *\n" + " * @see ContentSource\n" + " */\n" + "@Immutable\n" + "public final class DocumentSource extends ContentSource {\n" + " private static final ClientLogger LOGGER = new ClientLogger(DocumentSource.class);\n" - + " private static final String PREFIX = \"D(\";\n\n" + + " private static final String PREFIX = \"D(\";\n" + + " private static final int EXPECTED_PARAM_COUNT = 9;\n\n" + " private final int pageNumber;\n" + " private final List polygon;\n" + " private final RectangleF boundingBox;\n\n" @@ -1512,6 +1512,11 @@ private void addSourcesMethod(LibraryCustomization customization, Logger logger) + " }\n" + " String inner = source.substring(PREFIX.length(), source.length() - 1);\n" + " String[] parts = inner.split(\",\");\n" + + " if (parts.length != EXPECTED_PARAM_COUNT) {\n" + + " throw LOGGER.logExceptionAsError(\n" + + " new IllegalArgumentException(\"Document source expected \" + EXPECTED_PARAM_COUNT\n" + + " + \" parameters (page + 8 coordinates), got \" + parts.length + \": '\" + source + \"'.\"));\n" + + " }\n" + " try {\n" + " this.pageNumber = Integer.parseInt(parts[0].trim());\n" + " } catch (NumberFormatException e) {\n" @@ -1522,23 +1527,10 @@ private void addSourcesMethod(LibraryCustomization customization, Logger logger) + " throw LOGGER.logExceptionAsError(\n" + " new IllegalArgumentException(\"Page number must be >= 1, got \" + this.pageNumber + \".\"));\n" + " }\n" - + " if (parts.length == 1) {\n" - + " // Page-only: D(page)\n" - + " this.polygon = null;\n" - + " this.boundingBox = null;\n" - + " return;\n" - + " }\n" - + " int coordCount = parts.length - 1;\n" - + " if (coordCount < 6 || coordCount % 2 != 0) {\n" - + " throw LOGGER.logExceptionAsError(\n" - + " new IllegalArgumentException(\"Document source expected page-only (1 param) or page + at least 3 coordinate pairs (7+ params), got \"\n" - + " + parts.length + \": '\" + source + \"'.\"));\n" - + " }\n" - + " int pointCount = coordCount / 2;\n" - + " List points = new ArrayList<>(pointCount);\n" + + " List points = new ArrayList<>(4);\n" + " float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE;\n" + " float maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE;\n" - + " for (int i = 0; i < pointCount; i++) {\n" + + " for (int i = 0; i < 4; i++) {\n" + " int xIndex = 1 + (i * 2);\n" + " int yIndex = 2 + (i * 2);\n" + " float x, y;\n" @@ -1572,19 +1564,18 @@ private void addSourcesMethod(LibraryCustomization customization, Logger logger) + " return pageNumber;\n" + " }\n\n" + " /**\n" - + " * Gets the polygon coordinates defining the region, or {@code null} when only a page number is available.\n" + + " * Gets the polygon coordinates as four points defining a quadrilateral region.\n" + " *\n" - + " * @return An unmodifiable list of {@link PointF} values, or {@code null} for page-only sources.\n" + + " * @return An unmodifiable list of four {@link PointF} values.\n" + " */\n" + " public List getPolygon() {\n" + " return polygon;\n" + " }\n\n" + " /**\n" - + " * Gets the axis-aligned bounding rectangle computed from the polygon coordinates,\n" - + " * or {@code null} when only a page number is available.\n" + + " * Gets the axis-aligned bounding rectangle computed from the polygon coordinates.\n" + " * Useful for drawing highlight rectangles over extracted fields.\n" + " *\n" - + " * @return The bounding box, or {@code null} for page-only sources.\n" + + " * @return The bounding box.\n" + " */\n" + " public RectangleF getBoundingBox() {\n" + " return boundingBox;\n" @@ -1592,7 +1583,7 @@ private void addSourcesMethod(LibraryCustomization customization, Logger logger) + " /**\n" + " * Parses a single document source segment.\n" + " *\n" - + " * @param source The source string in the format {@code D(page)} or {@code D(page,x1,y1,...,xN,yN)}.\n" + + " * @param source The source string in the format {@code D(page,x1,y1,...,x4,y4)}.\n" + " * @return A new {@link DocumentSource}.\n" + " * @throws NullPointerException if {@code source} is null.\n" + " * @throws IllegalArgumentException if the source string is not in the expected format.\n"