diff --git a/报关单/11.pdf b/bgd/11.pdf
similarity index 100%
rename from 报关单/11.pdf
rename to bgd/11.pdf
diff --git a/报关单/22-1.pdf b/bgd/22-1.pdf
similarity index 100%
rename from 报关单/22-1.pdf
rename to bgd/22-1.pdf
diff --git a/报关单/33-1.pdf b/bgd/33-1.pdf
similarity index 100%
rename from 报关单/33-1.pdf
rename to bgd/33-1.pdf
diff --git a/报关单/44-1.pdf b/bgd/44-1.pdf
similarity index 100%
rename from 报关单/44-1.pdf
rename to bgd/44-1.pdf
diff --git a/报关单/55-1.pdf b/bgd/55-1.pdf
similarity index 100%
rename from 报关单/55-1.pdf
rename to bgd/55-1.pdf
diff --git a/报关单/66-1.pdf b/bgd/66-1.pdf
similarity index 100%
rename from 报关单/66-1.pdf
rename to bgd/66-1.pdf
diff --git a/报关单/77-1.pdf b/bgd/77-1.pdf
similarity index 100%
rename from 报关单/77-1.pdf
rename to bgd/77-1.pdf
diff --git a/报关单/990400报.pdf b/bgd/990400报.pdf
similarity index 100%
rename from 报关单/990400报.pdf
rename to bgd/990400报.pdf
diff --git a/报关单/EGLV072100006229-裕锦出库报关单.pdf b/bgd/EGLV072100006229-裕锦出库报关单.pdf
similarity index 100%
rename from 报关单/EGLV072100006229-裕锦出库报关单.pdf
rename to bgd/EGLV072100006229-裕锦出库报关单.pdf
diff --git a/报关单/HDMUCGPA26415100报.pdf b/bgd/HDMUCGPA26415100报.pdf
similarity index 100%
rename from 报关单/HDMUCGPA26415100报.pdf
rename to bgd/HDMUCGPA26415100报.pdf
diff --git a/报关单/I20210000769540727.pdf b/bgd/I20210000769540727.pdf
similarity index 100%
rename from 报关单/I20210000769540727.pdf
rename to bgd/I20210000769540727.pdf
diff --git a/报关单/I20210000769558253.pdf b/bgd/I20210000769558253.pdf
similarity index 100%
rename from 报关单/I20210000769558253.pdf
rename to bgd/I20210000769558253.pdf
diff --git a/报关单/I20220000786622159.pdf b/bgd/I20220000786622159.pdf
similarity index 100%
rename from 报关单/I20220000786622159.pdf
rename to bgd/I20220000786622159.pdf
diff --git a/报关单/bgd-01.pdf b/bgd/bgd-01.pdf
similarity index 100%
rename from 报关单/bgd-01.pdf
rename to bgd/bgd-01.pdf
diff --git a/报关单/bgd-02.pdf b/bgd/bgd-02.pdf
similarity index 100%
rename from 报关单/bgd-02.pdf
rename to bgd/bgd-02.pdf
diff --git a/报关单/bgd-03.pdf b/bgd/bgd-03.pdf
similarity index 100%
rename from 报关单/bgd-03.pdf
rename to bgd/bgd-03.pdf
diff --git a/报关单/bgd-04.pdf b/bgd/bgd-04.pdf
similarity index 100%
rename from 报关单/bgd-04.pdf
rename to bgd/bgd-04.pdf
diff --git a/报关单/bgd-05.pdf b/bgd/bgd-05.pdf
similarity index 100%
rename from 报关单/bgd-05.pdf
rename to bgd/bgd-05.pdf
diff --git a/报关单/bgd-06.pdf b/bgd/bgd-06.pdf
similarity index 100%
rename from 报关单/bgd-06.pdf
rename to bgd/bgd-06.pdf
diff --git a/报关单/bgd-07.pdf b/bgd/bgd-07.pdf
similarity index 100%
rename from 报关单/bgd-07.pdf
rename to bgd/bgd-07.pdf
diff --git a/报关单/bgd.pdf b/bgd/bgd.pdf
similarity index 100%
rename from 报关单/bgd.pdf
rename to bgd/bgd.pdf
diff --git a/报关单/报关单.pdf b/bgd/报关单.pdf
similarity index 100%
rename from 报关单/报关单.pdf
rename to bgd/报关单.pdf
diff --git a/code/ai/test/.gitignore b/code/ai/test/.gitignore
new file mode 100644
index 00000000..480bdf52
--- /dev/null
+++ b/code/ai/test/.gitignore
@@ -0,0 +1,39 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+.kotlin
+
+### IntelliJ IDEA ###
+.idea/modules.xml
+.idea/jarRepositories.xml
+.idea/compiler.xml
+.idea/libraries/
+*.iws
+*.iml
+*.ipr
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
\ No newline at end of file
diff --git a/code/ai/test/.idea/.gitignore b/code/ai/test/.idea/.gitignore
new file mode 100644
index 00000000..30cf57ed
--- /dev/null
+++ b/code/ai/test/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/code/ai/test/.idea/encodings.xml b/code/ai/test/.idea/encodings.xml
new file mode 100644
index 00000000..aa00ffab
--- /dev/null
+++ b/code/ai/test/.idea/encodings.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/code/ai/test/.idea/misc.xml b/code/ai/test/.idea/misc.xml
new file mode 100644
index 00000000..7ace0970
--- /dev/null
+++ b/code/ai/test/.idea/misc.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/code/ai/test/.idea/vcs.xml b/code/ai/test/.idea/vcs.xml
new file mode 100644
index 00000000..c2365ab1
--- /dev/null
+++ b/code/ai/test/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/code/ai/test/.m2/repository/org/springframework/boot/spring-boot-starter-parent/4.0.6/spring-boot-starter-parent-4.0.6.pom.lastUpdated b/code/ai/test/.m2/repository/org/springframework/boot/spring-boot-starter-parent/4.0.6/spring-boot-starter-parent-4.0.6.pom.lastUpdated
new file mode 100644
index 00000000..614545f9
--- /dev/null
+++ b/code/ai/test/.m2/repository/org/springframework/boot/spring-boot-starter-parent/4.0.6/spring-boot-starter-parent-4.0.6.pom.lastUpdated
@@ -0,0 +1,4 @@
+#NOTE: This is a Maven Resolver internal implementation file, its format can be changed without prior notice.
+#Sun Jun 07 19:41:40 CST 2026
+@default-aliyunmaven-https\://maven.aliyun.com/repository/central/.lastUpdated=1780832500060
+https\://maven.aliyun.com/repository/central/.error=Could not transfer artifact org.springframework.boot\:spring-boot-starter-parent\:pom\:4.0.6 from/to aliyunmaven (https\://maven.aliyun.com/repository/central)\: Permission denied\: getsockopt
diff --git a/code/ai/test/pom.xml b/code/ai/test/pom.xml
new file mode 100644
index 00000000..64760a5e
--- /dev/null
+++ b/code/ai/test/pom.xml
@@ -0,0 +1,77 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.0.6
+
+
+
+ org.example
+ springboot4-api
+ 0.0.1-SNAPSHOT
+ springboot4-api
+ Spring Boot 4 REST API project
+
+
+ 21
+ UTF-8
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+ com.squareup.okhttp3
+ okhttp
+ 5.1.0
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ -Xshare:off -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+
diff --git a/code/ai/test/src/main/java/org/example/Main.java b/code/ai/test/src/main/java/org/example/Main.java
new file mode 100644
index 00000000..6a4bd6d1
--- /dev/null
+++ b/code/ai/test/src/main/java/org/example/Main.java
@@ -0,0 +1,125 @@
+package org.example;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.springframework.http.*;
+import org.springframework.web.client.RestTemplate;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.nio.file.Files;
+import java.util.Base64;
+
+public class Main {
+
+ public static void main(String[] args) throws Exception {
+
+ String apiUrl = "http://localhost:8866/layout-parsing";
+ String pdfPath = "D:/workspace/bgd/bgd-01.pdf";
+
+ // 读取PDF
+ File file = new File(pdfPath);
+ byte[] bytes = Files.readAllBytes(file.toPath());
+
+ // Base64编码
+ String base64 = Base64.getEncoder().encodeToString(bytes);
+
+ // JSON请求体
+ ObjectMapper mapper = new ObjectMapper();
+
+ ObjectNode payload = mapper.createObjectNode();
+ payload.put("file", base64);
+ payload.put("fileType", 0);
+
+ // 打印请求大小
+ System.out.println("Base64 Length: " + base64.length());
+
+ RestTemplate restTemplate = new RestTemplate();
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ HttpEntity request =
+ new HttpEntity<>(payload.toString(), headers);
+
+ ResponseEntity response =
+ restTemplate.exchange(
+ apiUrl,
+ HttpMethod.POST,
+ request,
+ String.class
+ );
+
+ System.out.println("HTTP Status: "
+ + response.getStatusCode());
+
+ String responseBody = response.getBody();
+
+ System.out.println("Response:");
+ System.out.println(responseBody);
+
+ // 解析返回结果
+ JsonNode root = mapper.readTree(responseBody);
+
+ JsonNode result = root.path("result");
+
+ JsonNode layoutParsingResults =
+ result.path("layoutParsingResults");
+
+ for (int i = 0; i < layoutParsingResults.size(); i++) {
+
+ JsonNode item = layoutParsingResults.get(i);
+
+ System.out.println(
+ "\n========== Page " + i + " =========="
+ );
+
+ JsonNode prunedResult =
+ item.path("prunedResult");
+
+ System.out.println(
+ mapper.writerWithDefaultPrettyPrinter()
+ .writeValueAsString(prunedResult)
+ );
+
+ JsonNode outputImages =
+ item.path("outputImages");
+
+ outputImages.fieldNames()
+ .forEachRemaining(imgName -> {
+
+ try {
+
+ String imgBase64 =
+ outputImages.get(imgName)
+ .asText();
+
+ byte[] imgBytes =
+ Base64.getDecoder()
+ .decode(imgBase64);
+
+ String imgPath =
+ imgName + "_" + i + ".jpg";
+
+ try (FileOutputStream fos =
+ new FileOutputStream(imgPath)) {
+
+ fos.write(imgBytes);
+ }
+
+ System.out.println(
+ "Saved image: " + imgPath
+ );
+
+ } catch (Exception e) {
+
+ System.err.println(
+ "Save image failed: "
+ + e.getMessage()
+ );
+ }
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/code/ai/test/src/main/java/org/example/Springboot4ApiApplication.java b/code/ai/test/src/main/java/org/example/Springboot4ApiApplication.java
new file mode 100644
index 00000000..a81e0669
--- /dev/null
+++ b/code/ai/test/src/main/java/org/example/Springboot4ApiApplication.java
@@ -0,0 +1,12 @@
+package org.example;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class Springboot4ApiApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(Springboot4ApiApplication.class, args);
+ }
+}
diff --git a/code/ai/test/src/main/java/org/example/api/HealthController.java b/code/ai/test/src/main/java/org/example/api/HealthController.java
new file mode 100644
index 00000000..c3e821e2
--- /dev/null
+++ b/code/ai/test/src/main/java/org/example/api/HealthController.java
@@ -0,0 +1,20 @@
+package org.example.api;
+
+import java.time.Instant;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api")
+public class HealthController {
+
+ @GetMapping("/health")
+ public HealthResponse health() {
+ return new HealthResponse("UP", Instant.now());
+ }
+
+ public record HealthResponse(String status, Instant timestamp) {
+ }
+}
diff --git a/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingController.java b/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingController.java
new file mode 100644
index 00000000..35909649
--- /dev/null
+++ b/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingController.java
@@ -0,0 +1,37 @@
+package org.example.api.layout;
+
+import jakarta.validation.Valid;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/layout-parsing")
+public class LayoutParsingController {
+
+ private final LayoutParsingUseCase layoutParsingUseCase;
+ private final String defaultFilePath;
+
+ public LayoutParsingController(
+ LayoutParsingUseCase layoutParsingUseCase,
+ @Value("${layout.parsing.default-file-path}") String defaultFilePath
+ ) {
+ this.layoutParsingUseCase = layoutParsingUseCase;
+ this.defaultFilePath = defaultFilePath;
+ }
+
+ @GetMapping
+ public LayoutParsingResponse parseDefault() {
+ LayoutParsingRequest request = LayoutParsingRequest.pdf(defaultFilePath);
+ return layoutParsingUseCase.parse(request);
+ }
+
+ @PostMapping
+ public LayoutParsingResponse parse(@Valid @RequestBody LayoutParsingRequest request) {
+ return layoutParsingUseCase.parse(request);
+ }
+}
diff --git a/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingRequest.java b/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingRequest.java
new file mode 100644
index 00000000..f3a9354c
--- /dev/null
+++ b/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingRequest.java
@@ -0,0 +1,68 @@
+package org.example.api.layout;
+
+import java.nio.file.Path;
+import java.util.List;
+
+public record LayoutParsingRequest(
+ String filePath,
+ String imagePath,
+ Integer fileType,
+ String outputDirectory,
+ Boolean useDocOrientationClassify,
+ Boolean useDocUnwarping,
+ Boolean useTextlineOrientation,
+ Boolean useSealRecognition,
+ Boolean useTableRecognition,
+ Boolean useFormulaRecognition,
+ Boolean useChartRecognition,
+ Boolean useRegionDetection,
+ Boolean formatBlockContent,
+ Boolean returnMarkdownImages,
+ Boolean visualize,
+ List outputFormats
+) {
+
+ public static LayoutParsingRequest pdf(String filePath) {
+ return new LayoutParsingRequest(
+ filePath,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ true,
+ false,
+ false,
+ null
+ );
+ }
+
+ public String resolvedFilePath() {
+ if (hasText(filePath)) {
+ return filePath;
+ }
+ if (hasText(imagePath)) {
+ return imagePath;
+ }
+ throw new IllegalArgumentException("filePath is required");
+ }
+
+ public int resolvedFileType() {
+ if (fileType != null) {
+ return fileType;
+ }
+
+ String fileName = Path.of(resolvedFilePath()).getFileName().toString().toLowerCase();
+ return fileName.endsWith(".pdf") ? 0 : 1;
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingResponse.java b/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingResponse.java
new file mode 100644
index 00000000..9ddb752f
--- /dev/null
+++ b/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingResponse.java
@@ -0,0 +1,23 @@
+package org.example.api.layout;
+
+import java.util.List;
+
+import tools.jackson.databind.JsonNode;
+
+public record LayoutParsingResponse(
+ String filePath,
+ int fileType,
+ List- items
+) {
+
+ public record Item(
+ JsonNode prunedResult,
+ String recognizedText,
+ String markdownText,
+ String savedPrunedResult,
+ String savedMarkdown,
+ String savedText,
+ List savedImages
+ ) {
+ }
+}
diff --git a/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingService.java b/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingService.java
new file mode 100644
index 00000000..9b65c6c7
--- /dev/null
+++ b/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingService.java
@@ -0,0 +1,216 @@
+package org.example.api.layout;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map.Entry;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import org.springframework.web.client.RestClient;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.node.ObjectNode;
+
+@Service
+public class LayoutParsingService implements LayoutParsingUseCase {
+
+ private final RestClient restClient;
+ private final ObjectMapper objectMapper;
+ private final String apiUrl;
+ private final Path defaultOutputDirectory;
+
+ public LayoutParsingService(
+ ObjectMapper objectMapper,
+ @Value("${layout.parsing.api-url}") String apiUrl,
+ @Value("${layout.parsing.output-directory:output}") String defaultOutputDirectory
+ ) {
+ this.restClient = RestClient.create();
+ this.objectMapper = objectMapper;
+ this.apiUrl = apiUrl;
+ this.defaultOutputDirectory = Path.of(defaultOutputDirectory);
+ }
+
+ @Override
+ public LayoutParsingResponse parse(LayoutParsingRequest request) {
+ Path filePath = Path.of(request.resolvedFilePath()).toAbsolutePath().normalize();
+ if (!Files.isRegularFile(filePath)) {
+ throw new IllegalArgumentException("File does not exist: " + filePath);
+ }
+
+ JsonNode root = callLayoutParsingApi(filePath, request);
+ JsonNode layoutParsingResults = root.path("result").path("layoutParsingResults");
+ if (!layoutParsingResults.isArray()) {
+ throw new IllegalStateException("Invalid layout parsing response: result.layoutParsingResults is missing");
+ }
+
+ Path outputDirectory = resolveOutputDirectory(request.outputDirectory());
+ List items = new ArrayList<>();
+ System.out.println("Layout parsing file: " + filePath);
+ System.out.println("Layout parsing result count: " + layoutParsingResults.size());
+ for (int i = 0; i < layoutParsingResults.size(); i++) {
+ JsonNode item = layoutParsingResults.get(i);
+ JsonNode prunedResult = item.path("prunedResult");
+ String markdownText = item.path("markdown").path("text").asText();
+ String recognizedText = extractRecognizedText(prunedResult);
+ String savedPrunedResult = savePrunedResult(prunedResult, outputDirectory, i);
+ String savedMarkdown = saveTextFile(markdownText, outputDirectory, "markdown_" + i + ".md");
+ String savedText = saveTextFile(recognizedText, outputDirectory, "recognized_text_" + i + ".txt");
+ List savedImages = saveOutputImages(item.path("outputImages"), outputDirectory, i);
+ System.out.println("Pruned Result [" + i + "]: " + prunedResult);
+ System.out.println("Recognized Text [" + i + "]:");
+ System.out.println(recognizedText);
+ System.out.println("Markdown Text [" + i + "]:");
+ System.out.println(markdownText);
+ System.out.println("Saved pruned result: " + savedPrunedResult);
+ System.out.println("Saved markdown: " + savedMarkdown);
+ System.out.println("Saved text: " + savedText);
+ savedImages.forEach(savedImage -> System.out.println("Saved image: " + savedImage));
+ items.add(new LayoutParsingResponse.Item(
+ prunedResult,
+ recognizedText,
+ markdownText,
+ savedPrunedResult,
+ savedMarkdown,
+ savedText,
+ savedImages
+ ));
+ }
+
+ return new LayoutParsingResponse(filePath.toString(), request.resolvedFileType(), items);
+ }
+
+ private JsonNode callLayoutParsingApi(Path filePath, LayoutParsingRequest request) {
+ try {
+ String base64File = Base64.getEncoder().encodeToString(Files.readAllBytes(filePath));
+
+ ObjectNode payload = objectMapper.createObjectNode();
+ payload.put("file", base64File);
+ payload.put("fileType", request.resolvedFileType());
+ putIfNotNull(payload, "useDocOrientationClassify", request.useDocOrientationClassify());
+ putIfNotNull(payload, "useDocUnwarping", request.useDocUnwarping());
+ putIfNotNull(payload, "useTextlineOrientation", request.useTextlineOrientation());
+ putIfNotNull(payload, "useSealRecognition", request.useSealRecognition());
+ putIfNotNull(payload, "useTableRecognition", request.useTableRecognition());
+ putIfNotNull(payload, "useFormulaRecognition", request.useFormulaRecognition());
+ putIfNotNull(payload, "useChartRecognition", request.useChartRecognition());
+ putIfNotNull(payload, "useRegionDetection", request.useRegionDetection());
+ putIfNotNull(payload, "formatBlockContent", request.formatBlockContent());
+ putIfNotNull(payload, "returnMarkdownImages", request.returnMarkdownImages());
+ putIfNotNull(payload, "visualize", request.visualize());
+ if (request.outputFormats() != null && !request.outputFormats().isEmpty()) {
+ payload.set("outputFormats", objectMapper.valueToTree(request.outputFormats()));
+ }
+
+ return restClient.post()
+ .uri(apiUrl)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(payload)
+ .retrieve()
+ .body(JsonNode.class);
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to read file: " + filePath, e);
+ }
+ }
+
+ private void putIfNotNull(ObjectNode payload, String name, Boolean value) {
+ if (value != null) {
+ payload.put(name, value);
+ }
+ }
+
+ private Path resolveOutputDirectory(String outputDirectory) {
+ Path directory = StringUtils.hasText(outputDirectory)
+ ? Path.of(outputDirectory)
+ : defaultOutputDirectory;
+ try {
+ Files.createDirectories(directory);
+ return directory.toAbsolutePath().normalize();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to create output directory: " + directory, e);
+ }
+ }
+
+ private List saveOutputImages(JsonNode outputImages, Path outputDirectory, int resultIndex) {
+ List savedImages = new ArrayList<>();
+ if (!outputImages.isObject()) {
+ return savedImages;
+ }
+
+ Iterator> fields = outputImages.properties().iterator();
+ while (fields.hasNext()) {
+ Entry field = fields.next();
+ String imageName = sanitizeFileName(field.getKey());
+ Path outputPath = outputDirectory.resolve(imageName + "_" + resultIndex + ".jpg");
+ try {
+ byte[] imageBytes = Base64.getDecoder().decode(field.getValue().asText());
+ Files.write(outputPath, imageBytes);
+ savedImages.add(outputPath.toString());
+ } catch (IllegalArgumentException | IOException e) {
+ throw new IllegalStateException("Failed to save output image: " + outputPath, e);
+ }
+ }
+ return savedImages;
+ }
+
+ private String savePrunedResult(JsonNode prunedResult, Path outputDirectory, int resultIndex) {
+ Path outputPath = outputDirectory.resolve("pruned_result_" + resultIndex + ".json");
+ try {
+ Files.writeString(outputPath, prunedResult.toString());
+ return outputPath.toString();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to save pruned result: " + outputPath, e);
+ }
+ }
+
+ private String saveTextFile(String content, Path outputDirectory, String fileName) {
+ if (!StringUtils.hasText(content)) {
+ return null;
+ }
+
+ Path outputPath = outputDirectory.resolve(fileName);
+ try {
+ Files.writeString(outputPath, content);
+ return outputPath.toString();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to save text file: " + outputPath, e);
+ }
+ }
+
+ private String extractRecognizedText(JsonNode prunedResult) {
+ List lines = new ArrayList<>();
+
+ JsonNode parsingResults = prunedResult.path("parsing_res_list");
+ if (parsingResults.isArray()) {
+ for (JsonNode block : parsingResults) {
+ String blockContent = block.path("block_content").asText();
+ if (StringUtils.hasText(blockContent)) {
+ lines.add(blockContent);
+ }
+ }
+ }
+
+ JsonNode recognizedTexts = prunedResult.path("overall_ocr_res").path("rec_texts");
+ if (lines.isEmpty() && recognizedTexts.isArray()) {
+ for (JsonNode text : recognizedTexts) {
+ String value = text.asText();
+ if (StringUtils.hasText(value)) {
+ lines.add(value);
+ }
+ }
+ }
+
+ return String.join(System.lineSeparator(), lines);
+ }
+
+ private String sanitizeFileName(String value) {
+ String sanitized = value.replaceAll("[^a-zA-Z0-9._-]", "_");
+ return StringUtils.hasText(sanitized) ? sanitized : "image";
+ }
+}
diff --git a/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingUseCase.java b/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingUseCase.java
new file mode 100644
index 00000000..d96482b9
--- /dev/null
+++ b/code/ai/test/src/main/java/org/example/api/layout/LayoutParsingUseCase.java
@@ -0,0 +1,6 @@
+package org.example.api.layout;
+
+public interface LayoutParsingUseCase {
+
+ LayoutParsingResponse parse(LayoutParsingRequest request);
+}
diff --git a/code/ai/test/src/main/java/org/example/test.http b/code/ai/test/src/main/java/org/example/test.http
new file mode 100644
index 00000000..b3d873ee
--- /dev/null
+++ b/code/ai/test/src/main/java/org/example/test.http
@@ -0,0 +1,9 @@
+### Send POST request with json body
+POST http://localhost:8866/layout-parsing
+Content-Type: application/json
+
+{
+ "file": "D:\\workspace\\bgd\\bgd-01.pdf",
+ "fileType": 0
+}
+
diff --git a/code/ai/test/src/main/resources/application.yml b/code/ai/test/src/main/resources/application.yml
new file mode 100644
index 00000000..da73f664
--- /dev/null
+++ b/code/ai/test/src/main/resources/application.yml
@@ -0,0 +1,12 @@
+spring:
+ application:
+ name: springboot4-api
+
+server:
+ port: 8080
+
+layout:
+ parsing:
+ api-url: http://localhost:8866/layout-parsing
+ default-file-path: D:/workspace/bgd/bgd-01.pdf
+ output-directory: output
diff --git a/code/ai/test/src/test/java/org/example/Springboot4ApiApplicationTests.java b/code/ai/test/src/test/java/org/example/Springboot4ApiApplicationTests.java
new file mode 100644
index 00000000..14ea63b8
--- /dev/null
+++ b/code/ai/test/src/test/java/org/example/Springboot4ApiApplicationTests.java
@@ -0,0 +1,12 @@
+package org.example;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class Springboot4ApiApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+}
diff --git a/code/ai/test/src/test/java/org/example/api/HealthControllerTests.java b/code/ai/test/src/test/java/org/example/api/HealthControllerTests.java
new file mode 100644
index 00000000..75d3d078
--- /dev/null
+++ b/code/ai/test/src/test/java/org/example/api/HealthControllerTests.java
@@ -0,0 +1,25 @@
+package org.example.api;
+
+import static org.hamcrest.Matchers.is;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.test.web.servlet.MockMvc;
+
+@WebMvcTest(HealthController.class)
+class HealthControllerTests {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ void healthReturnsUp() throws Exception {
+ mockMvc.perform(get("/api/health"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status", is("UP")));
+ }
+}
diff --git a/code/ai/test/src/test/java/org/example/api/layout/LayoutParsingControllerTests.java b/code/ai/test/src/test/java/org/example/api/layout/LayoutParsingControllerTests.java
new file mode 100644
index 00000000..348e87a7
--- /dev/null
+++ b/code/ai/test/src/test/java/org/example/api/layout/LayoutParsingControllerTests.java
@@ -0,0 +1,73 @@
+package org.example.api.layout;
+
+import static org.hamcrest.Matchers.is;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import tools.jackson.databind.ObjectMapper;
+
+@WebMvcTest(LayoutParsingController.class)
+class LayoutParsingControllerTests {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Test
+ void parseAcceptsLocalPdfPath() throws Exception {
+ String request = """
+ {
+ "filePath": "D:/workspace/bgd/bgd-01.pdf",
+ "outputDirectory": "output",
+ "returnMarkdownImages": false,
+ "visualize": false
+ }
+ """;
+
+ mockMvc.perform(post("/api/layout-parsing")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(request))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.filePath", is("D:/workspace/bgd/bgd-01.pdf")))
+ .andExpect(jsonPath("$.fileType", is(0)))
+ .andExpect(jsonPath("$.items[0].recognizedText", is("demo text")))
+ .andExpect(jsonPath("$.items[0].markdownText", is("# demo")))
+ .andExpect(jsonPath("$.items[0].savedPrunedResult", is("output/pruned_result_0.json")))
+ .andExpect(jsonPath("$.items[0].savedMarkdown", is("output/markdown_0.md")))
+ .andExpect(jsonPath("$.items[0].savedText", is("output/recognized_text_0.txt")))
+ .andExpect(jsonPath("$.items[0].savedImages[0]", is("output/table_0.jpg")));
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+
+ @Bean
+ LayoutParsingUseCase layoutParsingUseCase(ObjectMapper objectMapper) {
+ return request -> new LayoutParsingResponse(
+ request.resolvedFilePath(),
+ request.resolvedFileType(),
+ List.of(new LayoutParsingResponse.Item(
+ objectMapper.createObjectNode().put("text", "demo"),
+ "demo text",
+ "# demo",
+ "output/pruned_result_0.json",
+ "output/markdown_0.md",
+ "output/recognized_text_0.txt",
+ List.of("output/table_0.jpg")
+ ))
+ );
+ }
+ }
+}