Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
}
}
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
edeandrea marked this conversation as resolved.
@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<String> 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 {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<SectionHeaderItem> 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()
Expand Down
Loading
Loading