diff --git a/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java index a68eb13156a4b..430581c930304 100644 --- a/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java +++ b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java @@ -335,7 +335,7 @@ private void processConvertToHTML(Exchange exchange) throws Exception { private void processConvertToJSON(Exchange exchange) throws Exception { String inputPath = getInputPath(exchange); if (configuration.isUseDoclingServe()) { - ConvertDocumentRequest request = buildConvertRequest(inputPath, "json"); + ConvertDocumentRequest request = buildConvertRequest(inputPath, "json", resolveOcrSettings(exchange)); exchange.getIn().setBody(convertToDoclingDocument(request, exchange)); } else { String result = executeDoclingCommand(inputPath, "json", exchange); @@ -357,7 +357,7 @@ private void processExtractText(Exchange exchange) throws Exception { private void processExtractStructuredData(Exchange exchange) throws Exception { String inputPath = getInputPath(exchange); if (configuration.isUseDoclingServe()) { - ConvertDocumentRequest request = buildStructuredDataRequest(inputPath); + ConvertDocumentRequest request = buildStructuredDataRequest(inputPath, resolveOcrSettings(exchange)); exchange.getIn().setBody(convertToDoclingDocument(request, exchange)); } else { String result = executeDoclingCommand(inputPath, "json", exchange); @@ -382,7 +382,7 @@ private void processSubmitAsyncConversion(Exchange exchange) throws Exception { } // Start async conversion - ConvertDocumentRequest request = buildConvertRequest(inputPath, outputFormat); + ConvertDocumentRequest request = buildConvertRequest(inputPath, outputFormat, resolveOcrSettings(exchange)); CompletableFuture asyncResult = doclingServeApi.convertSourceAsync(request).toCompletableFuture(); @@ -524,7 +524,7 @@ private void processExtractMetadata(Exchange exchange) throws Exception { if (configuration.isUseDoclingServe()) { // Use docling-serve API for metadata extraction - metadata = extractMetadataUsingApi(inputPath); + metadata = extractMetadataUsingApi(inputPath, exchange); } else { // Use CLI for metadata extraction metadata = extractMetadataUsingCLI(inputPath, exchange); @@ -665,11 +665,11 @@ private void addSourceToChunkRequest( } } - private DocumentMetadata extractMetadataUsingApi(String inputPath) throws IOException { + private DocumentMetadata extractMetadataUsingApi(String inputPath, Exchange exchange) throws IOException { LOG.debug("Extracting metadata using docling-java: {}", inputPath); // Convert the document to JSON format to get structured data including metadata - ConvertDocumentRequest request = buildConvertRequest(inputPath, "json"); + ConvertDocumentRequest request = buildConvertRequest(inputPath, "json", resolveOcrSettings(exchange)); ConvertDocumentResponse response; try { response = doclingServeApi.convertSource(request); @@ -876,9 +876,14 @@ private void processBatchConversion(Exchange exchange, String outputFormat) thro useAsync = asyncModeHeader; } + // The OCR headers are read here, on the thread that owns the exchange: the workers below run in parallel and + // an exchange must not be shared between threads. + OcrSettings ocrSettings = resolveOcrSettings(exchange); + // Process batch BatchProcessingResults results = convertDocumentsBatch( - documentPaths, outputFormat, batchSize, parallelism, failOnFirstError, useAsync, batchTimeout); + documentPaths, outputFormat, batchSize, parallelism, failOnFirstError, useAsync, batchTimeout, + ocrSettings); // Check if we should split results into individual exchanges boolean splitResults = configuration.isSplitBatchResults(); @@ -912,7 +917,7 @@ private void processBatchConversion(Exchange exchange, String outputFormat) thro private BatchProcessingResults convertDocumentsBatch( List inputSources, String outputFormat, int batchSize, int parallelism, - boolean failOnFirstError, boolean useAsync, long batchTimeout) { + boolean failOnFirstError, boolean useAsync, long batchTimeout, OcrSettings ocrSettings) { LOG.info( "Starting batch conversion of {} documents with batchSize={}, parallelism={}, failOnFirstError={}, timeout={}ms", @@ -964,9 +969,9 @@ private BatchProcessingResults convertDocumentsBatch( String converted; if (useAsync) { - converted = convertDocumentAsyncAndWait(inputSource, outputFormat); + converted = convertDocumentAsyncAndWait(inputSource, outputFormat, ocrSettings); } else { - converted = convertDocumentSync(inputSource, outputFormat); + converted = convertDocumentSync(inputSource, outputFormat, ocrSettings); } result.setResult(converted); @@ -1185,12 +1190,13 @@ private String convertUsingDoclingServe(String inputPath, String outputFormat, E } } + OcrSettings ocrSettings = resolveOcrSettings(exchange); if (useAsync) { LOG.debug("Using async mode for conversion"); - return convertDocumentAsyncAndWait(inputPath, outputFormat); + return convertDocumentAsyncAndWait(inputPath, outputFormat, ocrSettings); } else { LOG.debug("Using sync mode for conversion"); - return convertDocumentSync(inputPath, outputFormat); + return convertDocumentSync(inputPath, outputFormat, ocrSettings); } } @@ -1282,9 +1288,13 @@ private void processBatchStructuredData(Exchange exchange) throws Exception { useAsync = asyncModeHeader; } + // The OCR headers are read here, on the thread that owns the exchange: the workers below run in parallel and + // an exchange must not be shared between threads. + OcrSettings ocrSettings = resolveOcrSettings(exchange); + // Process batch using structured data extraction BatchProcessingResults results = convertStructuredDataBatch( - documentPaths, batchSize, parallelism, failOnFirstError, useAsync, batchTimeout); + documentPaths, batchSize, parallelism, failOnFirstError, useAsync, batchTimeout, ocrSettings); // Check if we should split results boolean splitResults = configuration.isSplitBatchResults(); @@ -1313,7 +1323,7 @@ private void processBatchStructuredData(Exchange exchange) throws Exception { private BatchProcessingResults convertStructuredDataBatch( List inputSources, int batchSize, int parallelism, - boolean failOnFirstError, boolean useAsync, long batchTimeout) { + boolean failOnFirstError, boolean useAsync, long batchTimeout, OcrSettings ocrSettings) { LOG.info( "Starting batch structured data extraction of {} documents with batchSize={}, parallelism={}, failOnFirstError={}, timeout={}ms", @@ -1363,7 +1373,7 @@ private BatchProcessingResults convertStructuredDataBatch( LOG.debug("Extracting structured data from document {} (index {}): {}", documentId, currentIndex, inputSource); - ConvertDocumentRequest request = buildStructuredDataRequest(inputSource); + ConvertDocumentRequest request = buildStructuredDataRequest(inputSource, ocrSettings); String converted; if (useAsync) { converted = convertDocumentAsyncAndWait(request); @@ -1458,10 +1468,11 @@ private BatchProcessingResults convertStructuredDataBatch( return results; } - private String convertDocumentSync(String inputSource, String outputFormat) throws IOException { + private String convertDocumentSync(String inputSource, String outputFormat, OcrSettings ocrSettings) + throws IOException { LOG.debug("Converting document using docling-java (sync): {}", inputSource); - ConvertDocumentRequest request = buildConvertRequest(inputSource, outputFormat); + ConvertDocumentRequest request = buildConvertRequest(inputSource, outputFormat, ocrSettings); try { ConvertDocumentResponse response = doclingServeApi.convertSource(request); @@ -1482,10 +1493,11 @@ private String convertDocumentSync(ConvertDocumentRequest request) throws IOExce } } - private String convertDocumentAsyncAndWait(String inputSource, String outputFormat) throws IOException { + private String convertDocumentAsyncAndWait(String inputSource, String outputFormat, OcrSettings ocrSettings) + throws IOException { LOG.debug("Converting document using docling-java (async): {}", inputSource); - ConvertDocumentRequest request = buildConvertRequest(inputSource, outputFormat); + ConvertDocumentRequest request = buildConvertRequest(inputSource, outputFormat, ocrSettings); try { CompletionStage asyncResult = doclingServeApi.convertSourceAsync(request); @@ -1532,7 +1544,9 @@ private String convertDocumentAsyncAndWait(ConvertDocumentRequest request) throw } } - private ConvertDocumentRequest buildConvertRequest(String inputSource, String outputFormat) throws IOException { + // package-private so DoclingOcrHeadersTest can assert the OCR options of the request without reflection + ConvertDocumentRequest buildConvertRequest(String inputSource, String outputFormat, OcrSettings ocrSettings) + throws IOException { ConvertDocumentRequest.Builder requestBuilder = ConvertDocumentRequest.builder(); addSourceToRequest(requestBuilder, inputSource); @@ -1542,13 +1556,14 @@ private ConvertDocumentRequest buildConvertRequest(String inputSource, String ou if (outputFormat != null && !outputFormat.isEmpty()) { optionsBuilder.toFormat(mapToOutputFormat(outputFormat)); } - applyConfigurationToOptions(optionsBuilder); + applyConfigurationToOptions(optionsBuilder, ocrSettings); requestBuilder.options(optionsBuilder.build()); return requestBuilder.build(); } - private ConvertDocumentRequest buildStructuredDataRequest(String inputSource) throws IOException { + private ConvertDocumentRequest buildStructuredDataRequest(String inputSource, OcrSettings ocrSettings) + throws IOException { ConvertDocumentRequest.Builder requestBuilder = ConvertDocumentRequest.builder(); addSourceToRequest(requestBuilder, inputSource); @@ -1561,7 +1576,7 @@ private ConvertDocumentRequest buildStructuredDataRequest(String inputSource) th .doTableStructure(true); // Apply user configuration (can enable additional enrichment features) - applyConfigurationToOptions(optionsBuilder); + applyConfigurationToOptions(optionsBuilder, ocrSettings); requestBuilder.options(optionsBuilder.build()); return requestBuilder.build(); @@ -1600,18 +1615,58 @@ private DoclingDocument parseDoclingDocument(String json) throws IOException { } } - private void applyConfigurationToOptions(ConvertDocumentOptions.Builder optionsBuilder) { + /** + * Resolves the OCR decisions of one message from the endpoint configuration and the two per-message headers. + *

+ * The headers are read here, on the calling thread, and not where the request is built: a batch reads them once and + * hands the result down to its workers, which run in parallel and must not share the exchange. Package-private so + * that DoclingOcrHeadersTest can resolve them the way the producer does. + */ + OcrSettings resolveOcrSettings(Exchange exchange) { // Send doOcr only when explicitly configured via the doOcr property, // or when enableOCR has been explicitly disabled. When both are at their // defaults (doOcr=null, enableOCR=true), let the server use its own defaults // to preserve backward compatibility. - if (configuration.getDoOcr() != null) { - optionsBuilder.doOcr(configuration.getDoOcr()); - if (configuration.getDoOcr() && configuration.getOcrLanguage() != null) { - optionsBuilder.ocrLang(configuration.getOcrLanguage()); + Boolean doOcr = configuration.getDoOcr(); + String ocrLanguage = configuration.getOcrLanguage(); + if (doOcr == null && !configuration.isEnableOCR()) { + doOcr = Boolean.FALSE; + } + + // The per-message headers win over the endpoint configuration, as they do on the CLI path: a scanned + // attachment in an otherwise text-based route, or a foreign-language one, can then be OCRed without a + // second endpoint. + if (exchange != null) { + Boolean headerEnableOcr = exchange.getIn().getHeader(DoclingHeaders.ENABLE_OCR, Boolean.class); + if (headerEnableOcr != null) { + doOcr = headerEnableOcr; + if (!headerEnableOcr) { + // an explicit "no OCR" must not carry a language along + ocrLanguage = null; + } + } + + String headerOcrLanguage = exchange.getIn().getHeader(DoclingHeaders.OCR_LANGUAGE, String.class); + if (ObjectHelper.isNotEmpty(headerOcrLanguage)) { + ocrLanguage = headerOcrLanguage; + if (doOcr == null) { + // asking for a language is asking for OCR + doOcr = Boolean.TRUE; + } + } + } + + return new OcrSettings(doOcr, ocrLanguage); + } + + private void applyConfigurationToOptions(ConvertDocumentOptions.Builder optionsBuilder, OcrSettings ocrSettings) { + Boolean doOcr = ocrSettings.getDoOcr(); + String ocrLanguage = ocrSettings.getOcrLanguage(); + if (doOcr != null) { + optionsBuilder.doOcr(doOcr); + if (doOcr && ObjectHelper.isNotEmpty(ocrLanguage)) { + optionsBuilder.ocrLang(ocrLanguage); } - } else if (!configuration.isEnableOCR()) { - optionsBuilder.doOcr(false); } if (configuration.getForceOcr() != null) { @@ -2152,7 +2207,7 @@ List buildDoclingCommand(String inputPath, String outputFormat, Exchange addOutputFormatArguments(command, outputFormat); // OCR configuration - addOcrArguments(command); + addOcrArguments(command, exchange); // Layout information addLayoutArguments(command); @@ -2287,12 +2342,26 @@ private void addOutputFormatArguments(List command, String outputFormat) } } - private void addOcrArguments(List command) { - if (!configuration.isEnableOCR()) { + /** + * Adds the OCR arguments, honouring the per-message headers when they are set: a scanned attachment in an otherwise + * text-based route, or a foreign-language one, can then be OCRed without a second endpoint. + */ + private void addOcrArguments(List command, Exchange exchange) { + Boolean enableOcr = exchange.getIn().getHeader(DoclingHeaders.ENABLE_OCR, Boolean.class); + if (enableOcr == null) { + enableOcr = configuration.isEnableOCR(); + } + + String ocrLanguage = exchange.getIn().getHeader(DoclingHeaders.OCR_LANGUAGE, String.class); + if (ObjectHelper.isEmpty(ocrLanguage)) { + ocrLanguage = configuration.getOcrLanguage(); + } + + if (!enableOcr) { command.add("--no-ocr"); - } else if (configuration.getOcrLanguage() != null) { + } else if (ObjectHelper.isNotEmpty(ocrLanguage)) { command.add("--ocr-lang"); - command.add(configuration.getOcrLanguage()); + command.add(ocrLanguage); } } @@ -2355,4 +2424,31 @@ private String mapToDoclingFormat(String outputFormat) { } } + /** + * The OCR decisions of one message, resolved from the endpoint configuration and the two OCR headers by + * {@link #resolveOcrSettings(Exchange)}. + *

+ * A {@code null} {@code doOcr} means "not decided here, leave it to the docling-serve defaults". The CLI path + * reaches the same outcome through {@link #addOcrArguments(List, Exchange)}, which renders it as {@code --no-ocr} / + * {@code --ocr-lang}. + */ + static final class OcrSettings { + + private final Boolean doOcr; + private final String ocrLanguage; + + OcrSettings(Boolean doOcr, String ocrLanguage) { + this.doOcr = doOcr; + this.ocrLanguage = ocrLanguage; + } + + Boolean getDoOcr() { + return doOcr; + } + + String getOcrLanguage() { + return ocrLanguage; + } + } + } diff --git a/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingOcrHeadersTest.java b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingOcrHeadersTest.java new file mode 100644 index 0000000000000..f323f1f433171 --- /dev/null +++ b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingOcrHeadersTest.java @@ -0,0 +1,196 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.docling; + +import java.util.List; + +import ai.docling.serve.api.convert.request.options.ConvertDocumentOptions; +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests the OCR of a single message, on both paths. + *

+ * The component documentation has a "Using headers to control processing" section that sets + * {@code CamelDoclingEnableOCR} and {@code CamelDoclingOCRLanguage} per message, next to {@code CamelDoclingOperation}, + * which is honoured. The two OCR headers were declared with {@code @Metadata} and documented but never read, so the + * example had no effect: every message was OCRed the way the endpoint was configured. Both paths now read them, the + * docling CLI command and the docling-serve request. + */ +class DoclingOcrHeadersTest extends CamelTestSupport { + + private static final String SOURCE = "https://example.com/report.pdf"; + private static final String DEFAULT = "docling:convert?operation=CONVERT_TO_MARKDOWN"; + private static final String OCR_OFF = "docling:convert?operation=CONVERT_TO_MARKDOWN&enableOCR=false&ocrLanguage=de"; + private static final String OCR_ON_IN_FRENCH + = "docling:convert?operation=CONVERT_TO_MARKDOWN&doOcr=true&ocrLanguage=fr"; + + // --- the docling CLI command + + @Test + void withoutHeadersTheEndpointConfigurationIsUsed() throws Exception { + List command = commandFor(DEFAULT, null, null); + + assertThat(command).contains("--ocr-lang", "en").doesNotContain("--no-ocr"); + } + + @Test + void enableOcrHeaderTurnsOcrOffForTheMessage() throws Exception { + // the endpoint has OCR on, this one message must not be OCRed, and no language may leak into the command + List command = commandFor(DEFAULT, false, null); + + assertThat(command).contains("--no-ocr").doesNotContain("--ocr-lang"); + } + + @Test + void ocrLanguageHeaderSelectsTheLanguageForTheMessage() throws Exception { + List command = commandFor(DEFAULT, null, "es"); + + assertThat(command).contains("--ocr-lang", "es"); + } + + @Test + void headersWinOverTheEndpointConfiguration() throws Exception { + // the endpoint has OCR off and German, the message asks for Spanish OCR + List command = commandFor(OCR_OFF, true, "es"); + + assertThat(command).contains("--ocr-lang", "es").doesNotContain("--no-ocr"); + } + + // --- the docling-serve request + + @Test + void withoutHeadersTheServeRequestKeepsTheServerDefaults() throws Exception { + ConvertDocumentOptions options = serveOptions(DEFAULT, null, null); + + assertThat(options.getDoOcr()).isNull(); + assertThat(options.getOcrLang()).isNullOrEmpty(); + } + + @Test + void enableOcrHeaderTurnsOcrOffForTheServeRequest() throws Exception { + ConvertDocumentOptions options = serveOptions(DEFAULT, false, null); + + assertThat(options.getDoOcr()).isFalse(); + assertThat(options.getOcrLang()).isNullOrEmpty(); + } + + @Test + void ocrLanguageHeaderSelectsTheLanguageOfTheServeRequest() throws Exception { + ConvertDocumentOptions options = serveOptions(DEFAULT, null, "es"); + + assertThat(options.getDoOcr()).isTrue(); + assertThat(options.getOcrLang()).containsExactly("es"); + } + + @Test + void theServeRequestFallsBackToTheLanguageOfTheEndpoint() throws Exception { + // the endpoint has OCR off and German, the message only asks for OCR + ConvertDocumentOptions options = serveOptions(OCR_OFF, true, null); + + assertThat(options.getDoOcr()).isTrue(); + assertThat(options.getOcrLang()).containsExactly("de"); + } + + @Test + void theDoOcrOptionOfTheEndpointIsStillSent() throws Exception { + ConvertDocumentOptions options = serveOptions(OCR_ON_IN_FRENCH, null, null); + + assertThat(options.getDoOcr()).isTrue(); + assertThat(options.getOcrLang()).containsExactly("fr"); + } + + @Test + void aLanguageHeaderDoesNotTurnOnOcrThatTheEndpointDisabledOnTheCli() throws Exception { + // the endpoint has OCR off, the message only names a language: OCR must stay off, as the upgrade guide says + List command = commandFor(OCR_OFF, null, "es"); + + assertThat(command).contains("--no-ocr").doesNotContain("--ocr-lang"); + } + + @Test + void aLanguageHeaderDoesNotTurnOnOcrThatTheEndpointDisabledInTheServeRequest() throws Exception { + ConvertDocumentOptions options = serveOptions(OCR_OFF, null, "es"); + + assertThat(options.getDoOcr()).isFalse(); + assertThat(options.getOcrLang()).isNullOrEmpty(); + } + + // --- the language is docling's own identifier, so it is passed on as it is + + @Test + void doclingLanguageIdentifiersReachTheCli() throws Exception { + // docling also takes BCP-47 tags with a delimiter, script names and Tesseract combinations + assertThat(commandFor(DEFAULT, null, "iso:zh-Hant")).contains("--ocr-lang", "iso:zh-Hant"); + assertThat(commandFor(DEFAULT, null, "script/Latin")).contains("--ocr-lang", "script/Latin"); + assertThat(commandFor(DEFAULT, null, "eng+deu")).contains("--ocr-lang", "eng+deu"); + } + + @Test + void doclingLanguageIdentifiersReachTheServeRequest() throws Exception { + assertThat(serveOptions(DEFAULT, null, "iso:zh-Hant").getOcrLang()).containsExactly("iso:zh-Hant"); + } + + @Test + void aLanguageListWithASpaceReachesTheCli() throws Exception { + assertThat(commandFor(DEFAULT, null, "en, de")).contains("--ocr-lang", "en, de"); + } + + private List commandFor(String endpointUri, Boolean enableOcr, String ocrLanguage) throws Exception { + DoclingEndpoint endpoint = context.getEndpoint(endpointUri, DoclingEndpoint.class); + DoclingProducer producer = (DoclingProducer) endpoint.createProducer(); + + return producer.buildDoclingCommand("input.pdf", "markdown", exchangeFor(endpoint, enableOcr, ocrLanguage), + "/tmp/managed"); + } + + private ConvertDocumentOptions serveOptions(String endpointUri, Boolean enableOcr, String ocrLanguage) + throws Exception { + DoclingEndpoint endpoint = context.getEndpoint(endpointUri, DoclingEndpoint.class); + DoclingProducer producer = (DoclingProducer) endpoint.createProducer(); + + Exchange exchange = exchangeFor(endpoint, enableOcr, ocrLanguage); + + return producer.buildConvertRequest(SOURCE, "markdown", producer.resolveOcrSettings(exchange)).getOptions(); + } + + private Exchange exchangeFor(DoclingEndpoint endpoint, Boolean enableOcr, String ocrLanguage) { + Exchange exchange = endpoint.createExchange(); + if (enableOcr != null) { + exchange.getIn().setHeader(DoclingHeaders.ENABLE_OCR, enableOcr); + } + if (ocrLanguage != null) { + exchange.getIn().setHeader(DoclingHeaders.OCR_LANGUAGE, ocrLanguage); + } + + return exchange; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:noop").to("mock:noop"); + } + }; + } +} diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index e53d680725de2..9519e1f714433 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -8106,3 +8106,15 @@ The `apiKeyHeader` option never had any effect: the name of the header that carr docling-serve client, which always sends it as `X-Api-Key`, and the producer never read the option. It is now deprecated, with a `deprecationNote` that says so. Remove `apiKeyHeader` from the endpoint URI or the configuration: with `authenticationScheme=API_KEY` and `authenticationToken` set, the key is still sent, as `X-Api-Key`. + +=== camel-docling - the CamelDoclingEnableOCR and CamelDoclingOCRLanguage headers are read + +The `CamelDoclingEnableOCR` and `CamelDoclingOCRLanguage` headers were declared and documented as per-message +overrides of `enableOCR` and `ocrLanguage`, but the component never read them: the message was OCRed the way the +endpoint was configured. They now override those two settings for the message, on the docling CLI path and on the +docling-serve path, as `CamelDoclingUseAsyncMode` already overrode `useAsyncMode`. A header that turns OCR off does +not pass a language on; a language header alone turns OCR on, unless the endpoint has disabled it with +`enableOCR=false`, which keeps winning over the header. + +The language itself is passed to docling untouched, in both forms: it is docling's own identifier (language codes such +as `en,de`, BCP-47 tags such as `iso:zh-Hant`, or `script/Latin`), and docling validates it.