Java remains the backbone of enterprise document processing — insurance, legal, banking, healthcare. If your stack runs on the JVM, you've probably needed to normalise PDFs, DOCX files, and PPTX decks into Markdown for LLM ingestion, search indexing, or content migration.
This post shows you how to convert documents to clean Markdown from Java using AnyMD's REST API — with Apache HttpClient, connection pooling, async concurrency, and structured error handling for production-scale pipelines.
Why AnyMD from Java?
Java's ecosystem for document processing is fragmented. Apache POI handles Office formats but produces raw text that loses structure. PDFBox extracts PDF content but needs significant post-processing. Tika tries to unify them, but introduces its own parsing overhead and format-specific quirks. AnyMD replaces all of these with a single REST API call — one integration, one format guarantee, one latency SLA.
In benchmarks, a Java (Apache HttpClient 5) client adds ~6.3 ms of client-side overhead per conversion — comparable to Node.js (7.2 ms) and faster than Python (14.1 ms). At 10 concurrent connections, Java achieves 468 docs/minute, comfortably in the middle of the pack for most enterprise workloads.
Prerequisites
Add Apache HttpClient 5 and Jackson to your pom.xml:
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.4.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version>
</dependency>
Or with Gradle:
implementation 'org.apache.httpcomponents.client5:httpclient5:5.4.2'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.2'
Single document conversion
Here's a minimal AnyMD client that converts any supported document to Markdown:
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class AnyMDClient {
private static final String API_URL = "https://anymd.net/api/convert";
private final String apiKey;
private final CloseableHttpClient httpClient;
public AnyMDClient(String apiKey) {
this.apiKey = apiKey;
this.httpClient = HttpClients.createDefault();
}
public String convert(Path filePath) throws IOException {
HttpPost post = new HttpPost(API_URL);
post.setHeader("Authorization", "Bearer " + apiKey);
MultipartEntityBuilder builder = MultipartEntityBuilder.create();
builder.addBinaryBody("file", Files.readAllBytes(filePath),
org.apache.hc.core5.http.ContentType.DEFAULT_BINARY,
filePath.getFileName().toString());
post.setEntity(builder.build());
return httpClient.execute(post, response -> {
int status = response.getCode();
if (status != 200) {
throw new IOException("API returned " + status
+ ": " + EntityUtils.toString(response.getEntity()));
}
return EntityUtils.toString(response.getEntity());
});
}
}
Usage is straightforward:
AnyMDClient client = new AnyMDClient("your-api-key");
String markdown = client.convert(Path.of("report.docx"));
System.out.println(markdown);
The API auto-detects the input format from the filename — PDF, DOCX, ODT, PPTX, EPUB, RTF, and more. Files are converted in memory and discarded immediately; nothing is stored on disk server-side.
Connection pooling for throughput
For batch processing, reuse connections with a pool. Apache HttpClient 5's pooled manager is production-ready out of the box:
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.core5.util.TimeValue;
public class PooledAnyMDClient {
private final String apiKey;
private final CloseableHttpClient httpClient;
public PooledAnyMDClient(String apiKey, int maxTotal, int maxPerRoute) {
this.apiKey = apiKey;
PoolingHttpClientConnectionManager pool =
new PoolingHttpClientConnectionManager();
pool.setMaxTotal(maxTotal);
pool.setDefaultMaxPerRoute(maxPerRoute);
this.httpClient = HttpClientBuilder.create()
.setConnectionManager(pool)
.evictIdleConnections(TimeValue.ofSeconds(30))
.build();
}
public String convert(Path filePath) throws IOException {
// Same as single-document convert above
HttpPost post = new HttpPost(API_URL);
post.setHeader("Authorization", "Bearer " + apiKey);
MultipartEntityBuilder builder = MultipartEntityBuilder.create();
builder.addBinaryBody("file", Files.readAllBytes(filePath),
org.apache.hc.core5.http.ContentType.DEFAULT_BINARY,
filePath.getFileName().toString());
post.setEntity(builder.build());
return httpClient.execute(post, response -> {
int status = response.getCode();
if (status != 200) {
throw new IOException("API returned " + status);
}
return EntityUtils.toString(response.getEntity());
});
}
public void close() throws IOException {
httpClient.close();
}
}
Async batch conversion with CompletableFuture
Process hundreds of documents concurrently using Java's CompletableFuture and a bounded thread pool:
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.stream.Collectors;
public class BatchConverter {
private final PooledAnyMDClient client;
private final ExecutorService executor;
public BatchConverter(String apiKey, int poolSize) {
this.client = new PooledAnyMDClient(apiKey, poolSize, poolSize);
this.executor = Executors.newFixedThreadPool(poolSize);
}
public record ConversionResult(Path file, boolean success, String markdown) {}
public CompletableFuture<ConversionResult> convertAsync(Path file) {
return CompletableFuture.supplyAsync(() -> {
try {
String md = client.convert(file);
return new ConversionResult(file, true, md);
} catch (IOException e) {
System.err.println("FAIL " + file + ": " + e.getMessage());
return new ConversionResult(file, false, null);
}
}, executor);
}
public List<ConversionResult> convertAll(List<Path> files) {
List<CompletableFuture<ConversionResult>> futures =
files.stream().map(this::convertAsync).toList();
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}
}
Usage with a directory of files:
import java.nio.file.*;
import java.util.List;
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) throws Exception {
BatchConverter converter = new BatchConverter("your-api-key", 10);
try (Stream<Path> paths = Files.walk(Path.of("documents"))) {
List<Path> docs = paths
.filter(Files::isRegularFile)
.filter(p -> p.toString().matches(".*\\.(pdf|docx|pptx|xlsx)$"))
.toList();
List<BatchConverter.ConversionResult> results =
converter.convertAll(docs);
long ok = results.stream().filter(BatchConverter.ConversionResult::success).count();
System.out.println(ok + "/" + results.size() + " converted");
}
}
}
The bounded thread pool keeps memory flat, and CompletableFuture.join() propagates exceptions cleanly. For even larger batches, add a sliding window or reactive stream with Project Reactor.
Retry with exponential backoff
Transient network errors are inevitable. Here's a reusable retry helper:
import java.time.Duration;
import java.util.concurrent.Callable;
public class Retry {
@FunctionalInterface
public interface CheckedSupplier<T> {
T get() throws Exception;
}
public static <T> T withBackoff(CheckedSupplier<T> fn,
int maxRetries,
Duration baseDelay) throws Exception {
Exception last = null;
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
return fn.get();
} catch (Exception e) {
last = e;
// Don't retry client errors
if (e.getMessage() != null &&
(e.getMessage().contains("401") ||
e.getMessage().contains("413"))) {
throw e;
}
if (attempt < maxRetries) {
long wait = baseDelay.toMillis() * (1L << attempt)
+ (long)(Math.random() * 100);
Thread.sleep(wait);
}
}
}
throw last;
}
}
Wrapping the conversion call with retry:
String md = Retry.withBackoff(
() -> client.convert(Path.of("contract.pdf")),
3, Duration.ofMillis(200)
);
Handling errors and API responses
AnyMD returns meaningful HTTP status codes that your Java client should handle:
| Status | Meaning | Action |
|---|---|---|
| 200 | Success — body contains Markdown | Write to file or process |
| 401 | Invalid or missing API key | Check credentials |
| 413 | File too large for your plan | Upgrade or split file |
| 415 | Unsupported file format | Check extension list |
| 429 | Rate limit exceeded | Retry after Retry-After header |
| 5xx | Server error | Retry with backoff |
public record ApiResponse(int status, String body, String contentType) {}
public ApiResponse convertWithDetails(Path filePath) throws IOException {
HttpPost post = new HttpPost(API_URL);
post.setHeader("Authorization", "Bearer " + apiKey);
MultipartEntityBuilder builder = MultipartEntityBuilder.create();
builder.addBinaryBody("file", Files.readAllBytes(filePath),
org.apache.hc.core5.http.ContentType.DEFAULT_BINARY,
filePath.getFileName().toString());
post.setEntity(builder.build());
return httpClient.execute(post, response -> {
int status = response.getCode();
String body = EntityUtils.toString(response.getEntity());
String contentType = response.getFirstHeader("Content-Type") != null
? response.getFirstHeader("Content-Type").getValue()
: "";
return new ApiResponse(status, body, contentType);
});
}
What gets preserved
| Document feature | Markdown output |
|---|---|
| Headings (H1–H6) | # through ###### |
| Bold / Italic | **bold** / *italic* |
| Numbered lists | 1. item — correct nesting |
| Bullet lists | - item — correct nesting |
| Tables | GFM table syntax with alignment |
| Hyperlinks | [text](url) |
| Images |  |
| Code blocks | Fenced ``` blocks |
| Blockquotes | > quote |
Supported formats
| Category | Formats |
|---|---|
| Documents | PDF, DOCX, ODT, RTF, EPUB, CBR/CBZ |
| Presentations | PPTX, ODP |
| Spreadsheets | XLSX, ODS, CSV (rendered as tables) |
| Web | HTML, Markdown (pass-through) |
| Images | OCR extraction from JPG, PNG, TIFF |
From Java to RAG
Once your documents are Markdown, the next step is chunking and embedding. AnyMD output pairs naturally with LangChain4j, Spring AI, or any Java vector store. See our guides on semantic chunking and hybrid chunking for production RAG pipelines.
Java conversions benefit from the same zero-retention policy and sub-second latency as every other language integration.
Pricing
| Plan | Pages/month | Max file size | Price |
|---|---|---|---|
| Free | 100 | 10 MB | $0 |
| Starter | 500 | 25 MB | $19 |
| Pro | 5,000 | 50 MB | $99 |
| Enterprise | Custom | Custom | Custom |
Get your free API key — 100 pages/month, no credit card required.