diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/options/ConvertDocumentOptions.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/options/ConvertDocumentOptions.java index b0d6588c..c245cc6a 100644 --- a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/options/ConvertDocumentOptions.java +++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/options/ConvertDocumentOptions.java @@ -6,6 +6,7 @@ import org.jspecify.annotations.Nullable; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSetter; @@ -233,6 +234,26 @@ public class ConvertDocumentOptions { @Nullable private String vlmPipelineModelApi; + /** + * Whether heading hierarchy inference is enabled. Derived from {@link #getHeadingHierarchyOptions()} + * — {@code true} when options are present, {@code false} otherwise. + */ + @JsonProperty("do_pdf_heading_hierarchy") + public boolean doPdfHeadingHierarchy() { + return headingHierarchyOptions != null; + } + + /** + * Options for inferring PDF section-header levels (H1–H6) from bookmarks, numbering and style. + * Setting this enables heading hierarchy inference; leave it unset to keep all headings at + * level 1. Use the defaults ({@code HeadingHierarchyOptions.builder().build()}) to enable it + * without any fine-tuning. + */ + @JsonProperty("pdf_heading_hierarchy_options") + @Nullable + private HeadingHierarchyOptions headingHierarchyOptions; + + @JsonIgnoreProperties("do_pdf_heading_hierarchy") public static class Builder { } } diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/options/HeadingHierarchyOptions.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/options/HeadingHierarchyOptions.java new file mode 100644 index 00000000..436964ea --- /dev/null +++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/options/HeadingHierarchyOptions.java @@ -0,0 +1,119 @@ +package ai.docling.serve.api.convert.request.options; + +import java.util.List; + +import org.jspecify.annotations.Nullable; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +/** + * Options for configuring PDF heading hierarchy inference. + * This class mirrors the Python {@code HeadingHierarchyOptions} from docling-core and is + * used to reconstruct a true heading hierarchy (H1–H6) from PDF bookmarks, outline numbering, + * and visual style instead of emitting all detected section headers flat at level 1. + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@lombok.extern.jackson.Jacksonized +@lombok.Builder(toBuilder = true) +@lombok.Getter +@lombok.ToString +public class HeadingHierarchyOptions { + + /** + * Use the PDF outline / table of contents as the authoritative signal for heading levels. + * + * @param useBookmarks true to use bookmarks, or null for the default (true) + * @return the use-bookmarks flag + */ + @JsonProperty("use_bookmarks") + @Nullable + private Boolean useBookmarks; + + /** + * Use the leading numbering marker of the heading text (e.g., "1.", "1.1.2", "(a)") + * as a signal for heading levels. + * + * @param useNumbering true to use numbering, or null for the default (true) + * @return the use-numbering flag + */ + @JsonProperty("use_numbering") + @Nullable + private Boolean useNumbering; + + /** + * Fall back to the heading's visual style (font size, weight, slant, letter case) when + * neither bookmarks nor numbering provide a level. Requires {@code generate_parsed_pages=True} + * in the pipeline options. + * + * @param useStyle true to use visual style, or null for the default (true) + * @return the use-style flag + */ + @JsonProperty("use_style") + @Nullable + private Boolean useStyle; + + /** + * Refine the style fallback with font weight, slant and all-caps detection. Ignored when + * {@link #getUseStyle()} is off. + * + * @param useFontStyle true to use font style, or null for the default (true) + * @return the use-font-style flag + */ + @JsonProperty("use_font_style") + @Nullable + private Boolean useFontStyle; + + /** + * Relative difference below which two font sizes count as one. Higher values merge more + * sizes into a single level. + * + * @param styleSizeTolerance the tolerance value, or null for the default (0.05) + * @return the style-size-tolerance value + */ + @JsonProperty("style_size_tolerance") + @Nullable + private Double styleSizeTolerance; + + /** + * Override the numbering-scheme precedence order (highest level first). When null, the + * built-in ordering is used. + * + * @param numberingSchemes the ordered list of scheme names, or null for the default + * @return the numbering-schemes list + */ + @JsonProperty("numbering_schemes") + @JsonSetter(nulls = Nulls.AS_EMPTY) + @lombok.Singular("numberingScheme") + private List numberingSchemes; + + /** + * Deepest heading level assigned by the inference stage; headings deeper than this are + * clamped to this level. + * + * @param maxLevel the maximum level, or null for the default (6) + * @return the max-level value + */ + @JsonProperty("max_level") + @Nullable + private Integer maxLevel; + + /** + * Minimum title similarity (0–1) for a bookmark to claim a heading. Higher values are + * stricter. + * + * @param bookmarkMatchThreshold the threshold, or null for the default (0.8) + * @return the bookmark-match-threshold value + */ + @JsonProperty("bookmark_match_threshold") + @Nullable + private Double bookmarkMatchThreshold; + + /** + * Builder for creating {@link HeadingHierarchyOptions} instances. + */ + public static class Builder { + } +} diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/request/options/ConvertDocumentOptionsTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/request/options/ConvertDocumentOptionsTests.java index 0d80f91a..a942db48 100644 --- a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/request/options/ConvertDocumentOptionsTests.java +++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/request/options/ConvertDocumentOptionsTests.java @@ -39,4 +39,50 @@ void convertDocumentOptionsIsImmutable() { assertThat(options.getOcrLang()).containsExactly("en", "de"); } + @Test + void convertDocumentOptionsWithHeadingHierarchy() { + var hierarchyOptions = HeadingHierarchyOptions.builder() + .useBookmarks(true) + .useNumbering(false) + .maxLevel(4) + .bookmarkMatchThreshold(0.85) + .build(); + + var options = ConvertDocumentOptions.builder() + .headingHierarchyOptions(hierarchyOptions) + .build(); + + assertThat(options) + .extracting( + ConvertDocumentOptions::doPdfHeadingHierarchy, ConvertDocumentOptions::getHeadingHierarchyOptions, o -> o.getHeadingHierarchyOptions().getUseBookmarks(), o -> o + .getHeadingHierarchyOptions().getUseNumbering(), o -> o.getHeadingHierarchyOptions().getMaxLevel(), o -> o.getHeadingHierarchyOptions().getBookmarkMatchThreshold()) + .containsExactly(true, hierarchyOptions, true, false, 4, 0.85); + } + + @Test + void convertDocumentOptionsWithoutHeadingHierarchy() { + var options = ConvertDocumentOptions.builder().build(); + + assertThat(options) + .satisfies(o -> assertThat(o.doPdfHeadingHierarchy()).isFalse()) + .satisfies(o -> assertThat(o.getHeadingHierarchyOptions()).isNull()); + } + + @Test + void convertDocumentOptionsToBuilderWithHeadingHierarchy() { + var hierarchyOptions = HeadingHierarchyOptions.builder() + .maxLevel(3) + .build(); + + var original = ConvertDocumentOptions.builder() + .headingHierarchyOptions(hierarchyOptions) + .build(); + + var rebuilt = original.toBuilder().build(); + + assertThat(rebuilt) + .extracting( + ConvertDocumentOptions::doPdfHeadingHierarchy, ConvertDocumentOptions::getHeadingHierarchyOptions, o -> o.getHeadingHierarchyOptions().getMaxLevel()) + .containsExactly(original.doPdfHeadingHierarchy(), original.getHeadingHierarchyOptions(), 3); + } } diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/request/options/HeadingHierarchyOptionsTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/request/options/HeadingHierarchyOptionsTests.java new file mode 100644 index 00000000..0b12899c --- /dev/null +++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/request/options/HeadingHierarchyOptionsTests.java @@ -0,0 +1,117 @@ +package ai.docling.serve.api.convert.request.options; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link HeadingHierarchyOptions}. + */ +class HeadingHierarchyOptionsTests { + + @Test + void createWithAllFields() { + var options = HeadingHierarchyOptions.builder() + .useBookmarks(true) + .useNumbering(false) + .useStyle(true) + .useFontStyle(true) + .styleSizeTolerance(0.1) + .numberingScheme("chapter") + .numberingScheme("section") + .maxLevel(4) + .bookmarkMatchThreshold(0.9) + .build(); + + assertThat(options) + .extracting( + HeadingHierarchyOptions::getUseBookmarks, HeadingHierarchyOptions::getUseNumbering, HeadingHierarchyOptions::getUseStyle, HeadingHierarchyOptions::getUseFontStyle, HeadingHierarchyOptions::getStyleSizeTolerance, HeadingHierarchyOptions::getNumberingSchemes, HeadingHierarchyOptions::getMaxLevel, HeadingHierarchyOptions::getBookmarkMatchThreshold) + .containsExactly(true, false, true, true, 0.1, List.of("chapter", "section"), 4, 0.9); + } + + @Test + void createWithMinimalFields() { + var options = HeadingHierarchyOptions.builder() + .useBookmarks(false) + .build(); + + assertThat(options.getUseBookmarks()).isFalse(); + assertThat(options.getUseNumbering()).isNull(); + assertThat(options.getUseStyle()).isNull(); + assertThat(options.getUseFontStyle()).isNull(); + assertThat(options.getStyleSizeTolerance()).isNull(); + assertThat(options.getNumberingSchemes()).isEmpty(); + assertThat(options.getMaxLevel()).isNull(); + assertThat(options.getBookmarkMatchThreshold()).isNull(); + } + + @Test + void createWithDefaultValues() { + var options = HeadingHierarchyOptions.builder().build(); + + assertThat(options.getUseBookmarks()).isNull(); + assertThat(options.getUseNumbering()).isNull(); + assertThat(options.getUseStyle()).isNull(); + assertThat(options.getUseFontStyle()).isNull(); + assertThat(options.getStyleSizeTolerance()).isNull(); + assertThat(options.getNumberingSchemes()).isEmpty(); + assertThat(options.getMaxLevel()).isNull(); + assertThat(options.getBookmarkMatchThreshold()).isNull(); + } + + @Test + void headingHierarchyOptionsIsImmutable() { + var options = HeadingHierarchyOptions.builder() + .styleSizeTolerance(0.05) + .numberingScheme("chapter") + .build(); + + assertThat(options.getNumberingSchemes()).hasSize(1); + assertThat(options.getNumberingSchemes()).containsExactly("chapter"); + + // Mutating the returned list should not affect the options + assertThatThrownBy(() -> options.getNumberingSchemes().add("section")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void toBuilderReturnsEquivalentInstance() { + var original = HeadingHierarchyOptions.builder() + .useBookmarks(false) + .maxLevel(3) + .numberingScheme("article") + .numberingScheme("section") + .bookmarkMatchThreshold(0.85) + .build(); + + var rebuilt = original.toBuilder().build(); + + assertThat(rebuilt.getUseBookmarks()).isEqualTo(original.getUseBookmarks()); + assertThat(rebuilt.getUseNumbering()).isEqualTo(original.getUseNumbering()); + assertThat(rebuilt.getUseStyle()).isEqualTo(original.getUseStyle()); + assertThat(rebuilt.getUseFontStyle()).isEqualTo(original.getUseFontStyle()); + assertThat(rebuilt.getStyleSizeTolerance()).isEqualTo(original.getStyleSizeTolerance()); + assertThat(rebuilt.getNumberingSchemes()).containsExactlyElementsOf(original.getNumberingSchemes()); + assertThat(rebuilt.getMaxLevel()).isEqualTo(original.getMaxLevel()); + assertThat(rebuilt.getBookmarkMatchThreshold()).isEqualTo(original.getBookmarkMatchThreshold()); + } + + @Test + void nonNullFieldsOnlyArentOmittedFromJson() { + var options = HeadingHierarchyOptions.builder() + .useBookmarks(true) + .build(); + + assertThat(options.getUseBookmarks()).isTrue(); + assertThat(options.getUseNumbering()).isNull(); + assertThat(options.getUseStyle()).isNull(); + assertThat(options.getUseFontStyle()).isNull(); + assertThat(options.getStyleSizeTolerance()).isNull(); + assertThat(options.getNumberingSchemes()).isEmpty(); + assertThat(options.getMaxLevel()).isNull(); + assertThat(options.getBookmarkMatchThreshold()).isNull(); + } +} diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java index d67d38fb..f3aaffef 100644 --- a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java +++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java @@ -67,6 +67,7 @@ import ai.docling.core.DoclingDocument.DocItemLabel; import ai.docling.core.DoclingDocument.FormulaItem; import ai.docling.core.DoclingDocument.ListItem; +import ai.docling.core.DoclingDocument.SectionHeaderItem; import ai.docling.core.DoclingDocument.TableCell; import ai.docling.serve.api.DoclingServeApi; import ai.docling.serve.api.chunk.request.HierarchicalChunkDocumentRequest; @@ -82,6 +83,7 @@ import ai.docling.serve.api.convert.request.CallbackSpec; import ai.docling.serve.api.convert.request.ConvertDocumentRequest; import ai.docling.serve.api.convert.request.options.ConvertDocumentOptions; +import ai.docling.serve.api.convert.request.options.HeadingHierarchyOptions; import ai.docling.serve.api.convert.request.options.ImageRefMode; import ai.docling.serve.api.convert.request.options.OutputFormat; import ai.docling.serve.api.convert.request.options.TableFormerMode; @@ -1259,6 +1261,52 @@ void shouldHandleConversionWithDifferentDocumentOptions() { assertThat(inBodyResponse.getDocument()).isNotNull(); } + @Test + void shouldInferHeadingHierarchyFromPdfNumbering() { + var options = HeadingHierarchyOptions.builder() + .maxLevel(4) + .build(); + + var convOptions = ConvertDocumentOptions.builder() + .toFormat(OutputFormat.JSON) + .headingHierarchyOptions(options) + .build(); + + var request = ConvertDocumentRequest.builder() + .options(convOptions) + .build(); + + var response = getDoclingClient().convertFiles(request, Path.of("src", "test", "resources", "2408.09869.pdf")); + + assertThat(response).isNotNull(); + assertThat(response.getResponseType()).isEqualTo(ResponseType.IN_BODY); + + var inBodyResponse = (InBodyConvertDocumentResponse) response; + assertThat(inBodyResponse.getStatus()).isNotEmpty(); + + DoclingDocument doc = inBodyResponse.getDocument().getJsonContent(); + assertThat(doc).isNotNull(); + + List headers = doc.getTexts().stream() + .filter(SectionHeaderItem.class::isInstance) + .map(SectionHeaderItem.class::cast) + .toList(); + + assertThat(headers).isNotEmpty(); + + // Verify that numbering-based hierarchy was applied (all headings at level 1 would be a + // failure — this PDF has sections like "3.1 PDFbackends" that should be below "3") + assertThat(headers.stream().allMatch(h -> (h.getLevel() != null))) + .as("All section headers should have a level assigned") + .isTrue(); + assertThat(headers.stream().map(SectionHeaderItem::getLevel).distinct().count()) + .as("Multiple heading levels should be present after hierarchy inference") + .isGreaterThan(1); + // There should be at least one level-2 header (e.g. "3.1 PDFbackends") + assertThat(headers) + .anyMatch(h -> "3.1 PDFbackends".equals(h.getText()) && (h.getLevel() >= 2)); + } + @Test void shouldHandleResponseWithDoclingDocument() { ConvertDocumentOptions options = ConvertDocumentOptions.builder() diff --git a/docs/src/doc/docs/docling-serve/serve-api.md b/docs/src/doc/docs/docling-serve/serve-api.md index 5a1cf7a0..e7779a1b 100644 --- a/docs/src/doc/docs/docling-serve/serve-api.md +++ b/docs/src/doc/docs/docling-serve/serve-api.md @@ -177,6 +177,7 @@ Options (`ai.docling.serve.api.convert.request.options.ConvertDocumentOptions`) - OCR (e.g., `doOcr`, `forceOcr`, `ocrEngine`, `ocrLang`) - PDF processing (e.g., `pdfBackend`) - Tables (e.g., `tableMode`, `tableCellMatching`) +- PDF heading hierarchy inference (e.g., `headingHierarchyOptions`) - Pipelines and page ranges (e.g., `pipeline`, `pageRange`) - Timeouts and error behavior (e.g., `documentTimeout`, `abortOnError`) - Enrichments (code/formula/picture), image handling, scaling, page break placeholder diff --git a/docs/src/doc/docs/whats-new.md b/docs/src/doc/docs/whats-new.md index 0c149897..eb23be0f 100644 --- a/docs/src/doc/docs/whats-new.md +++ b/docs/src/doc/docs/whats-new.md @@ -26,6 +26,7 @@ Docling Java {{ gradle.project_version }} includes important breaking changes, a ### {{ gradle.project_version }} +* **New `HeadingHierarchyOptions` and `headingHierarchyOptions` on `ConvertDocumentOptions`** — When set, `headingHierarchyOptions` enables PDF section-header level inference from bookmarks, outline numbering and visual style. The helper getter `doPdfHeadingHierarchy()` is derived from the presence of these options (returns `true` when non-null, `false` otherwise), so callers only need to pass the options to enable hierarchy inference. The options class covers all docling-serve fields (`useBookmarks`, `useNumbering`, `useStyle`, `useFontStyle`, `styleSizeTolerance`, `numberingSchemes`, `maxLevel`, `bookmarkMatchThreshold`). * **Breaking: new `DoclingServeApiProvider` SPI** — Implementations are now provided through `ai.docling.serve.api.spi.DoclingServeApiProvider`, which receives an immutable `DoclingServeApiConfig`, so new options can be added without breaking implementations. A provider can declare the options it doesn't honor via `unsupportedOptions()`, to have them ignored, logged or rejected when a caller sets them. `DoclingServeApi` has a new abstract `config()` method. `DoclingServeApiBuilderFactory`, `DoclingServeApi.DoclingApiBuilder` and `DoclingServeApi.toBuilder()` are deprecated for removal; legacy factories are still used when no provider is found. `docling-serve-api` now depends on `slf4j-api`. See [Migrating to `DoclingServeApiProvider`](docling-serve/serve-api-provider-migration.md). * **Breaking: `DoclingServeApi.builder()` returns a `DoclingServeApiBuilder`** — It used to return the builder of the implementation through a generic ` B` return type. Fluent chains and `var` declarations are unaffected, but code declaring the builder type must be updated. Client-specific settings such as `httpClientBuilder` are available from `DoclingServeClient.builder()`, which detects the version of Jackson the same way, and the JSON mapper from `DoclingServeJackson3Client.builder()` / `DoclingServeJackson2Client.builder()`, which are now public. An API can be copied with `api.config().toBuilder()`. * **`DoclingServeClientBuilderFactory` is deprecated** — Use `DoclingServeApi.builder()` for the options shared by every implementation, or the new `DoclingServeClient.builder()` for client-specific settings: it returns the same builder as `DoclingServeClientBuilderFactory.newBuilder()`. The factory will be removed in a future release.