GuidesOctober 28, 20262 min read
Call the PaddleOCR API from Java and Spring Boot
Parse PDFs with hosted PaddleOCR-VL or GLM-OCR from a Spring Boot app: multipart upload with RestClient, typed JSON records, and async jobs for large files.
PaddleOCR ships Python pipelines, so Java teams usually end up running a Python sidecar or a GPU server next to their app. A hosted API avoids both. This guide calls hosted PaddleOCR-VL from Spring Boot over HTTPS and maps the response to Java records. The same code works for GLM-OCR by changing one string.
You need Spring Boot 3.2 or later (for RestClient) and an OpenParser API key from Studio. New accounts get $10 in credits, about 10,000 pages.
Configure the client
Put the key in an environment variable and build one RestClient for the API:
@Configuration
class OpenParserConfig {
@Bean
RestClient openParser(@Value("${OPENPARSER_API_KEY}") String apiKey) {
return RestClient.builder()
.baseUrl("https://api.openparser.dev")
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
.build();
}
}Map the response
POST /parse returns an openparser@1 document. You rarely need all of it. These records keep the parts most apps use; Spring's default Jackson setup ignores the other fields.
record ParsedDocument(String text, String markdown, List<Page> pages, List<Element> elements) {}
record Page(int number, double width, double height) {}
record Element(String id, String kind, String role, String text, List<Location> locations) {}
record Location(@JsonProperty("page_number") int pageNumber, Box bbox) {}
record Box(double left, double top, double right, double bottom) {}Send a PDF
The endpoint takes multipart/form-data with two parts: request, a JSON object naming the model, and file.
@Service
class OcrService {
private final RestClient openParser;
OcrService(RestClient openParser) {
this.openParser = openParser;
}
ParsedDocument parse(Resource pdf, String ocrModel) {
var body = new MultipartBodyBuilder();
body.part("request", Map.of("ocr_model", ocrModel, "output_format", "openparser@1"),
MediaType.APPLICATION_JSON);
body.part("file", pdf);
return openParser.post()
.uri("/parse")
.header("Idempotency-Key", UUID.randomUUID().toString())
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(body.build())
.retrieve()
.body(ParsedDocument.class);
}
}Call it with ocrService.parse(new FileSystemResource("invoice.pdf"), "paddleocr-vl-1.6"). To try GLM-OCR on the same file, pass "glm-ocr". The records stay the same because every model returns the same format.
The API requires an Idempotency-Key on every parse request. If a request times out and you resend it with the same key, the API returns the original job instead of starting and billing a new one, so generate the key once per document and reuse it on retries.
Large PDFs: use async jobs
Synchronous parsing waits up to the server limit. For long documents, queue a job with POST /parse/async and poll GET /jobs/{id} until the status is succeeded:
record JobRef(String id, String status) {}
record Job(String id, String status, ParsedDocument result) {}
ParsedDocument parseLarge(Resource pdf, String ocrModel) throws InterruptedException {
var body = new MultipartBodyBuilder();
body.part("request", Map.of("ocr_model", ocrModel, "output_format", "openparser@1"),
MediaType.APPLICATION_JSON);
body.part("file", pdf);
JobRef ref = openParser.post()
.uri("/parse/async")
.header("Idempotency-Key", UUID.randomUUID().toString())
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(body.build())
.retrieve()
.body(JobRef.class);
while (true) {
Job job = openParser.get().uri("/jobs/{id}", ref.id()).retrieve().body(Job.class);
switch (job.status()) {
case "succeeded" -> { return job.result(); }
case "failed", "partial", "indeterminate" ->
throw new IllegalStateException("OCR job ended as " + job.status());
default -> Thread.sleep(2_000);
}
}
}In a web request handler, run this on a background executor or a queue consumer so a 200-page PDF does not hold an HTTP thread.
Read the result
markdown is the quickest way to feed text into search or a language model. For structured work, walk elements: each has a kind (text, table, formula, figure), a role such as heading or paragraph, and locations with page coordinates you can use to highlight the source in a viewer.
doc.elements().stream()
.filter(e -> "table".equals(e.kind()))
.forEach(e -> log.info("table on page {}", e.locations().get(0).pageNumber()));If you need fields instead of text, such as an invoice total or a contract date, call /extract with a JSON schema; each returned field cites the elements it came from. The API reference lists both endpoints, and the PaddleOCR API page covers pricing.