chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="OpenDataLoaderCli" type="Application"
|
||||
factoryName="Application">
|
||||
<option name="ALTERNATIVE_JRE_PATH" value="corretto-21" />
|
||||
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
|
||||
<option name="MAIN_CLASS_NAME" value="org.opendataloader.pdf.cli.CLIMain" />
|
||||
<module name="opendataloader-pdf-cli" />
|
||||
<option name="PROGRAM_PARAMETERS"
|
||||
value="../samples/pdf/1901.03003.pdf --markdown --html --pdf" />
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE module PUBLIC "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" "https://checkstyle.org/dtds/configuration_1_3.dtd">
|
||||
<module name="Checker">
|
||||
<module name="TreeWalker">
|
||||
<module name="UnusedImports"/>
|
||||
<module name="WhitespaceAfter"/>
|
||||
<module name="WhitespaceAround"/>
|
||||
<module name="NoWhitespaceAfter"/>
|
||||
<module name="NoWhitespaceBefore"/>
|
||||
<module name="NeedBraces"/>
|
||||
<module name="EmptyBlock"/>
|
||||
</module>
|
||||
</module>
|
||||
@@ -0,0 +1,137 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Copyright 2025 Hancom Inc.
|
||||
|
||||
Licensed 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.
|
||||
|
||||
-->
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.opendataloader</groupId>
|
||||
<artifactId>opendataloader-pdf-parent</artifactId>
|
||||
<version>0.0.0</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>opendataloader-pdf-cli</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>OpenDataLoader PDF CLI</name>
|
||||
<description>OpenDataLoader PDF CLI</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.opendataloader</groupId>
|
||||
<artifactId>opendataloader-pdf-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-cli</groupId>
|
||||
<artifactId>commons-cli</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>about.html</exclude>
|
||||
<exclude>module-info.class</exclude>
|
||||
<exclude>META-INF/*.MF</exclude>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
<exclude>META-INF/versions/**</exclude>
|
||||
<exclude>META-INF/LICENSE</exclude>
|
||||
<exclude>META-INF/LICENSE.txt</exclude>
|
||||
<exclude>META-INF/LICENSE.md</exclude>
|
||||
<exclude>META-INF/NOTICE</exclude>
|
||||
<exclude>META-INF/NOTICE.txt</exclude>
|
||||
<exclude>META-INF/NOTICE.md</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>com.sun.xml.bind:jaxb-impl</artifact>
|
||||
<excludes>
|
||||
<exclude>com/sun/xml/bind/**</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>org.jacoco:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/maven/**</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
<transformers>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>org.opendataloader.pdf.cli.CLIMain</mainClass>
|
||||
</transformer>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ComponentsXmlResourceTransformer"/>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
|
||||
<resource>META-INF/DEPENDENCIES</resource>
|
||||
</transformer>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.IncludeResourceTransformer">
|
||||
<resource>META-INF/LICENSE</resource>
|
||||
<file>${project.basedir}/../../LICENSE</file>
|
||||
</transformer>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.IncludeResourceTransformer">
|
||||
<resource>META-INF/NOTICE</resource>
|
||||
<file>${project.basedir}/../../NOTICE</file>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.cli;
|
||||
|
||||
import org.apache.commons.cli.*;
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
import org.opendataloader.pdf.api.OpenDataLoaderPDF;
|
||||
import org.opendataloader.pdf.api.cli.CLIOptions;
|
||||
import org.opendataloader.pdf.exceptions.EncryptedTaggedPdfNotSupportedException;
|
||||
import org.opendataloader.pdf.exceptions.InvalidPdfFileException;
|
||||
import org.verapdf.exceptions.InvalidPasswordException;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Locale;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class CLIMain {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(CLIMain.class.getCanonicalName());
|
||||
|
||||
private static final String HELP = "[options] <INPUT FILE OR FOLDER>...\n Options:";
|
||||
|
||||
private enum InputSource { CLI_ARGUMENT, DIRECTORY_CHILD }
|
||||
|
||||
/**
|
||||
* Result of processing a path: whether all files succeeded, and how many
|
||||
* PDF files were processed under it (counted recursively for directories,
|
||||
* 1 or 0 for a single file). Used by {@link #processDirectory} to print a
|
||||
* clear summary when a user-supplied folder contains no PDFs (PDFDLOSP-15).
|
||||
*/
|
||||
private static final class PathResult {
|
||||
final boolean allSucceeded;
|
||||
final int pdfCount;
|
||||
|
||||
PathResult(boolean allSucceeded, int pdfCount) {
|
||||
this.allSucceeded = allSucceeded;
|
||||
this.pdfCount = pdfCount;
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
int exitCode = run(args);
|
||||
if (exitCode != 0) {
|
||||
System.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the CLI with the given arguments and returns the exit code.
|
||||
*
|
||||
* @param args command-line arguments
|
||||
* @return 0 on success, non-zero on failure
|
||||
*/
|
||||
static int run(String[] args) {
|
||||
Options options = CLIOptions.defineOptions();
|
||||
HelpFormatter formatter = new HelpFormatter();
|
||||
CommandLine commandLine;
|
||||
try {
|
||||
commandLine = new DefaultParser().parse(options, args);
|
||||
} catch (ParseException e) {
|
||||
System.out.println(e.getMessage());
|
||||
formatter.printHelp(HELP, options);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Handle --export-options before requiring input files
|
||||
if (commandLine.hasOption(CLIOptions.EXPORT_OPTIONS_LONG_OPTION)) {
|
||||
CLIOptions.exportOptionsAsJson(System.out);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (commandLine.getArgs().length < 1) {
|
||||
formatter.printHelp(HELP, options);
|
||||
return 0;
|
||||
}
|
||||
|
||||
String[] arguments = commandLine.getArgs();
|
||||
|
||||
Config config;
|
||||
boolean quiet;
|
||||
try {
|
||||
config = CLIOptions.createConfigFromCommandLine(commandLine);
|
||||
quiet = commandLine.hasOption(CLIOptions.QUIET_OPTION) || commandLine.hasOption("quiet");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
System.out.println(exception.getMessage());
|
||||
formatter.printHelp(HELP, options);
|
||||
return 2;
|
||||
}
|
||||
configureLogging(quiet);
|
||||
boolean hasFailure = false;
|
||||
try {
|
||||
for (String argument : arguments) {
|
||||
if (!processPath(new File(argument), config, InputSource.CLI_ARGUMENT).allSucceeded) {
|
||||
hasFailure = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Release resources (e.g., hybrid client thread pools)
|
||||
OpenDataLoaderPDF.shutdown();
|
||||
}
|
||||
return hasFailure ? 1 : 0;
|
||||
}
|
||||
|
||||
private static void configureLogging(boolean quiet) {
|
||||
if (!quiet) {
|
||||
return;
|
||||
}
|
||||
Logger rootLogger = Logger.getLogger("");
|
||||
rootLogger.setLevel(Level.OFF);
|
||||
for (Handler handler : rootLogger.getHandlers()) {
|
||||
handler.setLevel(Level.OFF);
|
||||
}
|
||||
LOGGER.setLevel(Level.OFF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a file or directory, returning true if all files succeeded.
|
||||
*
|
||||
* <p>{@code source} distinguishes user-provided arguments
|
||||
* ({@link InputSource#CLI_ARGUMENT}) from files discovered during directory
|
||||
* traversal ({@link InputSource#DIRECTORY_CHILD}): a non-PDF given directly
|
||||
* on the command line is reported as an error, while non-PDF files inside a
|
||||
* directory are silently skipped (preserves batch-folder processing).
|
||||
*/
|
||||
private static PathResult processPath(File file, Config config, InputSource source) {
|
||||
if (!file.exists()) {
|
||||
LOGGER.log(Level.WARNING, "File or folder " + file.getAbsolutePath() + " not found.");
|
||||
return new PathResult(false, 0);
|
||||
}
|
||||
if (file.isDirectory()) {
|
||||
return processDirectory(file, config, source);
|
||||
}
|
||||
if (file.isFile()) {
|
||||
boolean isPdf = isPdfFile(file);
|
||||
if (source == InputSource.CLI_ARGUMENT && !isPdf) {
|
||||
System.out.println("Error: '" + file.getName()
|
||||
+ "' is not a PDF file. Input must be a PDF file or a folder containing PDF files.");
|
||||
return new PathResult(false, 0);
|
||||
}
|
||||
return new PathResult(processFile(file, config, source), isPdf ? 1 : 0);
|
||||
}
|
||||
return new PathResult(true, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts PDF files processed under the directory rooted at {@code file}
|
||||
* (recursively), so the CLI can surface a clear summary when a
|
||||
* user-supplied folder contains no PDFs. Without this feedback the program
|
||||
* would exit silently with status 0 and the user could not distinguish
|
||||
* "wrong folder", "empty folder", and "successful run" (PDFDLOSP-15).
|
||||
*
|
||||
* <p>The summary is only printed for folders given directly on the command
|
||||
* line ({@link InputSource#CLI_ARGUMENT}) — nested subdirectories aggregate
|
||||
* upward into the top-level count rather than each printing their own line.
|
||||
*
|
||||
* <p>The summary line is the final <em>result</em> of the run, not a log
|
||||
* entry, and is therefore intentionally emitted on stdout even under
|
||||
* {@code --quiet}. {@code --quiet} suppresses processing logs; users
|
||||
* still need to see whether anything was actually processed. The path
|
||||
* shown is {@link File#getPath()} (the literal argument the user typed,
|
||||
* e.g. {@code .} or {@code basic_images}) rather than {@link File#getName()},
|
||||
* which would be empty for {@code .} or trailing-slash inputs.
|
||||
*/
|
||||
private static PathResult processDirectory(File file, Config config, InputSource source) {
|
||||
File[] children = file.listFiles();
|
||||
if (children == null) {
|
||||
LOGGER.log(Level.WARNING, "Unable to read folder " + file.getAbsolutePath());
|
||||
return new PathResult(false, 0);
|
||||
}
|
||||
boolean allSucceeded = true;
|
||||
int pdfCount = 0;
|
||||
for (File child : children) {
|
||||
PathResult childResult = processPath(child, config, InputSource.DIRECTORY_CHILD);
|
||||
if (!childResult.allSucceeded) {
|
||||
allSucceeded = false;
|
||||
}
|
||||
pdfCount += childResult.pdfCount;
|
||||
}
|
||||
if (source == InputSource.CLI_ARGUMENT) {
|
||||
if (pdfCount == 0) {
|
||||
System.out.println("No PDF files found in '" + file.getPath() + "'.");
|
||||
} else {
|
||||
System.out.println("Processed " + pdfCount + " PDF file"
|
||||
+ (pdfCount == 1 ? "" : "s") + " in '" + file.getPath() + "'.");
|
||||
}
|
||||
}
|
||||
return new PathResult(allSucceeded, pdfCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single PDF file.
|
||||
*
|
||||
* <p>{@code source} controls how an {@link InvalidPdfFileException} from
|
||||
* the magic-number guard is surfaced: {@link InputSource#CLI_ARGUMENT}
|
||||
* routes it to stdout as a user-facing error and fails the run;
|
||||
* {@link InputSource#DIRECTORY_CHILD} logs a WARNING and treats the file
|
||||
* as silently skipped so batch-folder runs can still exit 0.
|
||||
*
|
||||
* @param file the file to process
|
||||
* @param config the processing configuration
|
||||
* @param source whether the file came from a CLI argument or directory traversal
|
||||
* @return true if processing succeeded (or the file was a silently
|
||||
* skipped non-PDF inside a directory), false on error.
|
||||
*/
|
||||
private static boolean processFile(File file, Config config, InputSource source) {
|
||||
if (!isPdfFile(file)) {
|
||||
LOGGER.log(Level.FINE, "Skipping non-PDF file " + file.getAbsolutePath());
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
OpenDataLoaderPDF.processFile(file.getAbsolutePath(), config);
|
||||
return true;
|
||||
} catch (InvalidPdfFileException invalid) {
|
||||
if (source == InputSource.CLI_ARGUMENT) {
|
||||
System.out.println("Error: " + invalid.getMessage());
|
||||
return false;
|
||||
}
|
||||
LOGGER.log(Level.WARNING, invalid.getMessage() + " Skipping.");
|
||||
return true;
|
||||
} catch (InvalidPasswordException exception) {
|
||||
String password = config.getPassword();
|
||||
String message = (password == null || password.isEmpty())
|
||||
? "Error: '" + file.getName() + "' is password-protected. Use --password option."
|
||||
: "Error: Incorrect password for '" + file.getName() + "'.";
|
||||
System.out.println(message);
|
||||
return false;
|
||||
} catch (EncryptedTaggedPdfNotSupportedException exception) {
|
||||
System.out.println("Error: " + exception.getMessage());
|
||||
return false;
|
||||
} catch (Exception exception) {
|
||||
LOGGER.log(Level.SEVERE, "Exception during processing file " + file.getAbsolutePath() + ": " +
|
||||
exception.getMessage(), exception);
|
||||
return false;
|
||||
} finally {
|
||||
StaticContainers.closeImagesUtils();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isPdfFile(File file) {
|
||||
if (!file.isFile()) {
|
||||
return false;
|
||||
}
|
||||
String name = file.getName();
|
||||
return name.toLowerCase(Locale.ROOT).endsWith(".pdf");
|
||||
}
|
||||
}
|
||||
+672
@@ -0,0 +1,672 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.cli;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
|
||||
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CLIMainTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
/**
|
||||
* When processing a PDF file throws any exception, CLIMain.run() must return
|
||||
* a non-zero exit code. This test uses a malformed PDF with hybrid mode
|
||||
* targeting an unreachable server, which triggers an exception during processing.
|
||||
*
|
||||
* <p>Before this fix, processFile() caught all exceptions and logged them at
|
||||
* SEVERE level but never propagated the failure to the exit code.
|
||||
*
|
||||
* <p>Regression test for https://github.com/opendataloader-project/opendataloader-pdf/issues/287
|
||||
*/
|
||||
@Test
|
||||
void testProcessingFailureReturnsNonZeroExitCode() throws IOException {
|
||||
// Create a minimal PDF file so processFile is actually invoked
|
||||
// (the file must exist and end in .pdf to pass the isPdfFile check)
|
||||
Path testPdf = tempDir.resolve("test.pdf");
|
||||
Files.write(testPdf, "%PDF-1.4 minimal".getBytes());
|
||||
|
||||
// Use an unreachable hybrid URL — the processing will fail either at
|
||||
// the hybrid availability check or during PDF parsing, both of which
|
||||
// must result in a non-zero exit code.
|
||||
int exitCode = CLIMain.run(new String[]{
|
||||
"--hybrid", "docling-fast",
|
||||
"--hybrid-url", "http://127.0.0.1:59999",
|
||||
testPdf.toString()
|
||||
});
|
||||
|
||||
assertNotEquals(0, exitCode,
|
||||
"Exit code must be non-zero when file processing fails");
|
||||
}
|
||||
|
||||
/**
|
||||
* When a directory contains a file that fails processing, run() must return
|
||||
* non-zero, even though other files in the directory may succeed. The
|
||||
* "failure" here must be a genuine processing error (unreachable hybrid
|
||||
* backend), not a corrupt-content classification — corrupt PDFs inside a
|
||||
* directory are silently skipped by design (see
|
||||
* {@link #testCorruptPdfInsideDirectoryIsSilentlySkipped()}), so the test
|
||||
* uses a parseable PDF and lets the hybrid availability check fail.
|
||||
*/
|
||||
@Test
|
||||
void testDirectoryWithFailingFileReturnsNonZeroExitCode() throws IOException {
|
||||
Path dir = tempDir.resolve("docs");
|
||||
Files.createDirectory(dir);
|
||||
Path testPdf = dir.resolve("bad.pdf");
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.save(testPdf.toFile());
|
||||
}
|
||||
|
||||
int exitCode = CLIMain.run(new String[]{
|
||||
"--hybrid", "docling-fast",
|
||||
"--hybrid-url", "http://127.0.0.1:59999",
|
||||
dir.toString()
|
||||
});
|
||||
|
||||
assertNotEquals(0, exitCode,
|
||||
"Exit code must be non-zero when any file in directory fails");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normal invocation with no arguments should return 0 (just prints help).
|
||||
*/
|
||||
@Test
|
||||
void testNoArgumentsReturnsZero() {
|
||||
int exitCode = CLIMain.run(new String[]{});
|
||||
assertEquals(0, exitCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid CLI arguments (e.g., unrecognized option) must return exit code 2,
|
||||
* following POSIX convention for command-line usage errors.
|
||||
*/
|
||||
@Test
|
||||
void testInvalidArgumentsReturnsExitCode2() {
|
||||
int exitCode = CLIMain.run(new String[]{"--no-such-option"});
|
||||
assertEquals(2, exitCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-existent input file must return non-zero exit code.
|
||||
*/
|
||||
@Test
|
||||
void testNonExistentFileReturnsNonZeroExitCode() {
|
||||
int exitCode = CLIMain.run(new String[]{"/nonexistent/path/file.pdf"});
|
||||
assertNotEquals(0, exitCode,
|
||||
"Exit code must be non-zero when input file does not exist");
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-PDF file given directly on the command line must produce a clear
|
||||
* error message on stdout and a non-zero exit code, instead of silently
|
||||
* exiting with success.
|
||||
*
|
||||
* <p>Regression test for PDFDLOSP-5.
|
||||
*/
|
||||
@Test
|
||||
void testNonPdfTopLevelArgumentEmitsErrorAndFails() throws IOException {
|
||||
Path png = tempDir.resolve("abcd.png");
|
||||
Files.write(png, new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47});
|
||||
|
||||
String stdout = captureStdoutOf(() -> CLIMain.run(new String[]{png.toString()}));
|
||||
|
||||
assertTrue(stdout.contains("'abcd.png' is not a PDF file"),
|
||||
"stdout must mention the offending file name and that it is not a PDF; got: " + stdout);
|
||||
assertTrue(stdout.contains("Input must be a PDF file or a folder containing PDF files"),
|
||||
"stdout must guide the user to valid input; got: " + stdout);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNonPdfTopLevelArgumentReturnsNonZeroExitCode() throws IOException {
|
||||
Path png = tempDir.resolve("abcd.png");
|
||||
Files.write(png, new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47});
|
||||
|
||||
int exitCode = CLIMain.run(new String[]{png.toString()});
|
||||
|
||||
assertNotEquals(0, exitCode,
|
||||
"Exit code must be non-zero when the top-level argument is not a PDF");
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-PDF files inside a directory must continue to be silently skipped —
|
||||
* batch-folder processing with mixed file types is a legitimate use case
|
||||
* and must not regress.
|
||||
*/
|
||||
@Test
|
||||
void testNonPdfFileInsideDirectoryIsSilentlySkipped() throws IOException {
|
||||
Path dir = tempDir.resolve("mixed");
|
||||
Files.createDirectory(dir);
|
||||
Files.write(dir.resolve("note.png"), new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47});
|
||||
Files.write(dir.resolve("readme.txt"), "hello".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String stdout = captureStdoutOf(() -> CLIMain.run(new String[]{dir.toString()}));
|
||||
|
||||
assertFalse(stdout.contains("is not a PDF file"),
|
||||
"Files discovered during directory traversal must not trigger the top-level error; got: " + stdout);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDirectoryWithOnlyNonPdfFilesReturnsZero() throws IOException {
|
||||
Path dir = tempDir.resolve("non-pdf-only");
|
||||
Files.createDirectory(dir);
|
||||
Files.write(dir.resolve("note.png"), new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47});
|
||||
|
||||
int exitCode = CLIMain.run(new String[]{dir.toString()});
|
||||
|
||||
assertEquals(0, exitCode,
|
||||
"Directories containing only non-PDF files must succeed (silent skip)");
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder containing zero processable PDFs must emit a clear "No PDF files
|
||||
* found" message instead of exiting silently with status 0 — without this
|
||||
* the user cannot distinguish "wrong folder", "empty folder", and
|
||||
* "successful run". The path shown is the literal argument the user typed
|
||||
* (File#getPath), so {@code .} and trailing-slash inputs render correctly.
|
||||
*
|
||||
* <p>Regression test for PDFDLOSP-15.
|
||||
*/
|
||||
@Test
|
||||
void testEmptyDirectoryEmitsNoPdfFoundMessage() throws IOException {
|
||||
Path dir = tempDir.resolve("empty");
|
||||
Files.createDirectory(dir);
|
||||
|
||||
String[] stdoutHolder = new String[1];
|
||||
int exitCode = (int) runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{dir.toString()}),
|
||||
stdoutHolder);
|
||||
|
||||
assertEquals(0, exitCode, "Empty folder is not an error");
|
||||
assertTrue(stdoutHolder[0].contains("No PDF files found in '" + dir + "'"),
|
||||
"stdout must report that no PDFs were found; got: " + stdoutHolder[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDirectoryWithOnlyNonPdfFilesEmitsNoPdfFoundMessage() throws IOException {
|
||||
Path dir = tempDir.resolve("basic_images");
|
||||
Files.createDirectory(dir);
|
||||
Files.write(dir.resolve("a.png"), new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47});
|
||||
Files.write(dir.resolve("b.jpg"), new byte[]{(byte) 0xFF, (byte) 0xD8});
|
||||
|
||||
String[] stdoutHolder = new String[1];
|
||||
int exitCode = (int) runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{dir.toString()}),
|
||||
stdoutHolder);
|
||||
|
||||
assertEquals(0, exitCode);
|
||||
assertTrue(stdoutHolder[0].contains("No PDF files found in '" + dir + "'"),
|
||||
"stdout must report that no PDFs were found; got: " + stdoutHolder[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdirectories must aggregate into the top-level summary rather than
|
||||
* emit their own "No PDF files found" / "Processed N..." lines — only the
|
||||
* user-supplied folder path appears in the output.
|
||||
*/
|
||||
@Test
|
||||
void testNestedSubdirectoriesAggregateIntoTopLevelSummary() throws IOException {
|
||||
Path top = tempDir.resolve("top");
|
||||
Path sub = top.resolve("sub");
|
||||
Files.createDirectories(sub);
|
||||
Files.write(sub.resolve("nested.pdf"), "%PDF-1.4 minimal".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String[] stdoutHolder = new String[1];
|
||||
runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{top.toString()}),
|
||||
stdoutHolder);
|
||||
|
||||
assertTrue(stdoutHolder[0].contains("Processed 1 PDF file in '" + top + "'"),
|
||||
"stdout must summarize at the top-level folder including nested PDFs; got: "
|
||||
+ stdoutHolder[0]);
|
||||
assertFalse(stdoutHolder[0].contains("in '" + sub + "'"),
|
||||
"stdout must not emit a separate summary for nested subdirectories; got: "
|
||||
+ stdoutHolder[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDirectoryWithMultiplePdfsEmitsProcessedSummary() throws IOException {
|
||||
Path dir = tempDir.resolve("docs");
|
||||
Files.createDirectory(dir);
|
||||
Files.write(dir.resolve("a.pdf"), "%PDF-1.4 minimal".getBytes(StandardCharsets.UTF_8));
|
||||
Files.write(dir.resolve("b.pdf"), "%PDF-1.4 minimal".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String[] stdoutHolder = new String[1];
|
||||
runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{dir.toString()}),
|
||||
stdoutHolder);
|
||||
|
||||
assertTrue(stdoutHolder[0].contains("Processed 2 PDF files in '" + dir + "'"),
|
||||
"stdout must summarize with plural 'files' when count > 1; got: "
|
||||
+ stdoutHolder[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the command line mixes a valid PDF argument with a top-level non-PDF
|
||||
* argument, the run must fail overall but only the non-PDF entry should
|
||||
* produce the user-facing error message.
|
||||
*/
|
||||
@Test
|
||||
void testMixedTopLevelArgumentsReportOnlyNonPdfAndFailOverall() throws IOException {
|
||||
Path png = tempDir.resolve("note.png");
|
||||
Files.write(png, new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47});
|
||||
Path pdf = tempDir.resolve("doc.pdf");
|
||||
Files.write(pdf, "%PDF-1.4 minimal".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String[] stdoutHolder = new String[1];
|
||||
int exitCode = (int) runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{pdf.toString(), png.toString()}),
|
||||
stdoutHolder);
|
||||
|
||||
assertNotEquals(0, exitCode,
|
||||
"Exit code must be non-zero when any top-level argument is not a PDF");
|
||||
assertTrue(stdoutHolder[0].contains("'note.png' is not a PDF file"),
|
||||
"stdout must call out the non-PDF argument by name; got: " + stdoutHolder[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Password-protected PDF with no password supplied must produce a single
|
||||
* user-friendly error message (no verapdf stack trace) and exit non-zero.
|
||||
*
|
||||
* <p>Regression test for PDFDLOSP-9.
|
||||
*/
|
||||
@Test
|
||||
void testPasswordProtectedWithoutPasswordEmitsFriendlyError() throws IOException {
|
||||
Path pdf = createPasswordProtectedPdf(tempDir.resolve("locked.pdf"), "1234");
|
||||
|
||||
String[] holder = new String[1];
|
||||
int exitCode = (int) runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{pdf.toString()}),
|
||||
holder);
|
||||
|
||||
assertNotEquals(0, exitCode, "exit code must be non-zero");
|
||||
assertTrue(holder[0].contains("'locked.pdf' is password-protected"),
|
||||
"stdout must call out the file by name and explain it is password-protected; got: " + holder[0]);
|
||||
assertTrue(holder[0].contains("--password"),
|
||||
"stdout must guide the user to the --password option; got: " + holder[0]);
|
||||
assertFalse(holder[0].contains("InvalidPasswordException"),
|
||||
"stdout must not expose internal exception type; got: " + holder[0]);
|
||||
assertFalse(holder[0].contains("at org.verapdf"),
|
||||
"stdout must not expose a stack trace; got: " + holder[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Password-protected PDF with an incorrect password must produce a single
|
||||
* user-friendly error message (no verapdf stack trace) and exit non-zero.
|
||||
*
|
||||
* <p>Regression test for PDFDLOSP-9.
|
||||
*/
|
||||
@Test
|
||||
void testPasswordProtectedWithWrongPasswordEmitsFriendlyError() throws IOException {
|
||||
Path pdf = createPasswordProtectedPdf(tempDir.resolve("locked.pdf"), "1234");
|
||||
|
||||
String[] holder = new String[1];
|
||||
int exitCode = (int) runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{pdf.toString(), "--password", "wrongpw"}),
|
||||
holder);
|
||||
|
||||
assertNotEquals(0, exitCode, "exit code must be non-zero");
|
||||
assertTrue(holder[0].contains("Incorrect password for 'locked.pdf'"),
|
||||
"stdout must report incorrect password and the file name; got: " + holder[0]);
|
||||
assertFalse(holder[0].contains("InvalidPasswordException"),
|
||||
"stdout must not expose internal exception type; got: " + holder[0]);
|
||||
assertFalse(holder[0].contains("at org.verapdf"),
|
||||
"stdout must not expose a stack trace; got: " + holder[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct password must keep the existing success path: exit code 0 and
|
||||
* the JSON output produced next to the input. Regression guard for the
|
||||
* happy case while fixing PDFDLOSP-9.
|
||||
*/
|
||||
@Test
|
||||
void testPasswordProtectedWithCorrectPasswordSucceeds() throws IOException {
|
||||
Path pdf = createPasswordProtectedPdf(tempDir.resolve("locked.pdf"), "1234");
|
||||
|
||||
int exitCode = CLIMain.run(new String[]{pdf.toString(), "--password", "1234"});
|
||||
|
||||
assertEquals(0, exitCode, "correct password must keep exit code 0");
|
||||
assertTrue(Files.exists(tempDir.resolve("locked.json")),
|
||||
"JSON output must be produced next to the input PDF");
|
||||
}
|
||||
|
||||
private static Path createPasswordProtectedPdf(Path target, String password) throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new PDPage());
|
||||
AccessPermission permissions = new AccessPermission();
|
||||
StandardProtectionPolicy policy = new StandardProtectionPolicy(password, password, permissions);
|
||||
policy.setEncryptionKeyLength(128);
|
||||
document.protect(policy);
|
||||
document.save(target.toFile());
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
// --- PDFDLOSP-21: hybrid backend unavailable, stack-trace guard -------
|
||||
|
||||
/**
|
||||
* When the hybrid backend is unreachable and {@code --hybrid-fallback} is
|
||||
* NOT supplied, CLIMain must surface the friendly fail-fast message
|
||||
* (including the new {@code --hybrid-fallback} hint) via the SEVERE log
|
||||
* record and must never leak a Java stack trace to stdout. Regression for
|
||||
* PDFDLOSP-21.
|
||||
*/
|
||||
@Test
|
||||
void testHybridUnavailableWithoutFallbackEmitsFriendlyMessageNoStackTrace() throws IOException {
|
||||
Path testPdf = tempDir.resolve("ok.pdf");
|
||||
// Minimal but valid-enough header so the file reaches HybridDocumentProcessor.
|
||||
// PDF parsing happens after health-check on this branch.
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.save(testPdf.toFile());
|
||||
}
|
||||
|
||||
// Reserve an ephemeral port and release it so connect() is refused.
|
||||
int closedPort;
|
||||
try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) {
|
||||
closedPort = socket.getLocalPort();
|
||||
}
|
||||
|
||||
String[] stdoutHolder = new String[1];
|
||||
StringBuilder logCapture = new StringBuilder();
|
||||
java.util.logging.Logger cliLogger = java.util.logging.Logger.getLogger(
|
||||
"org.opendataloader.pdf.cli.CLIMain");
|
||||
java.util.logging.Handler captureHandler = new java.util.logging.Handler() {
|
||||
@Override public void publish(java.util.logging.LogRecord record) {
|
||||
logCapture.append(record.getMessage()).append('\n');
|
||||
Throwable thrown = record.getThrown();
|
||||
while (thrown != null) {
|
||||
logCapture.append(thrown).append('\n');
|
||||
for (StackTraceElement frame : thrown.getStackTrace()) {
|
||||
logCapture.append("\tat ").append(frame).append('\n');
|
||||
}
|
||||
thrown = thrown.getCause();
|
||||
}
|
||||
}
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
};
|
||||
cliLogger.addHandler(captureHandler);
|
||||
int exitCode;
|
||||
try {
|
||||
exitCode = (int) runCapturingStdout(() -> CLIMain.run(new String[]{
|
||||
"--hybrid", "docling-fast",
|
||||
"--hybrid-mode", "full",
|
||||
"--hybrid-url", "http://127.0.0.1:" + closedPort,
|
||||
"--output", tempDir.toString(),
|
||||
testPdf.toString()
|
||||
}), stdoutHolder);
|
||||
} finally {
|
||||
cliLogger.removeHandler(captureHandler);
|
||||
}
|
||||
|
||||
assertNotEquals(0, exitCode,
|
||||
"Exit code must be non-zero when hybrid backend is unreachable and fallback is off");
|
||||
|
||||
String out = stdoutHolder[0];
|
||||
assertFalse(out.contains("\tat "),
|
||||
"stdout must not contain a Java stack trace ('\\tat '); got: " + out);
|
||||
|
||||
String logged = logCapture.toString();
|
||||
assertTrue(logged.contains("Hybrid server is not available"),
|
||||
"Log output should explain that the hybrid backend is unreachable; got: " + logged);
|
||||
assertTrue(logged.contains("--hybrid-fallback"),
|
||||
"Log output should include the --hybrid-fallback recovery hint; got: " + logged);
|
||||
}
|
||||
|
||||
// --- PDFDLOSP-14: magic-number guard regressions ----------------------
|
||||
|
||||
private static final byte[] JPEG_PREFIX = new byte[]{
|
||||
(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0,
|
||||
0x00, 0x10, 'J', 'F', 'I', 'F', 0x00
|
||||
};
|
||||
|
||||
/**
|
||||
* A .pdf-named file whose content is not actually PDF must produce the
|
||||
* user-facing magic-number error on stdout, fail with non-zero exit, AND
|
||||
* must not leak any veraPDF internal stack trace. Regression for
|
||||
* PDFDLOSP-14.
|
||||
*/
|
||||
@Test
|
||||
void testPdfExtensionWithNonPdfContentEmitsMagicNumberError() throws IOException {
|
||||
Path fakePdf = tempDir.resolve("fake.pdf");
|
||||
Files.write(fakePdf, JPEG_PREFIX);
|
||||
|
||||
String[] stdoutHolder = new String[1];
|
||||
int exitCode = (int) runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{fakePdf.toString()}),
|
||||
stdoutHolder);
|
||||
|
||||
assertNotEquals(0, exitCode,
|
||||
"Exit code must be non-zero when input content is not PDF");
|
||||
assertTrue(stdoutHolder[0].contains("'fake.pdf' is not a valid PDF file (missing %PDF- header)"),
|
||||
"stdout must contain the magic-number error; got: " + stdoutHolder[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack-trace leakage guard: the veraPDF internals must NEVER appear on
|
||||
* stdout for the non-PDF-content case. This is the core regression we
|
||||
* are preventing — if the catch branch is reverted or the guard moves
|
||||
* back behind PDDocument construction, this test fails.
|
||||
*/
|
||||
@Test
|
||||
void testPdfExtensionWithNonPdfContentDoesNotLeakStackTrace() throws IOException {
|
||||
Path fakePdf = tempDir.resolve("fake.pdf");
|
||||
Files.write(fakePdf, JPEG_PREFIX);
|
||||
|
||||
String[] stdoutHolder = new String[1];
|
||||
runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{fakePdf.toString()}),
|
||||
stdoutHolder);
|
||||
|
||||
String out = stdoutHolder[0];
|
||||
assertFalse(out.toLowerCase(java.util.Locale.ROOT).contains("verapdf"),
|
||||
"Output must not contain 'verapdf'; got: " + out);
|
||||
assertFalse(out.contains("java.io.IOException:"),
|
||||
"Output must not contain 'java.io.IOException:'; got: " + out);
|
||||
assertFalse(out.contains("\tat "),
|
||||
"Output must not contain a Java stack trace ('\\tat '); got: " + out);
|
||||
}
|
||||
|
||||
/**
|
||||
* A directory containing a .pdf-named non-PDF file alongside another
|
||||
* non-PDF file must (a) NOT print the top-level magic-number error to
|
||||
* stdout, (b) still exit 0 (silent skip preserves PR #496's batch-folder
|
||||
* semantics), and (c) emit a single WARNING log line so operators can
|
||||
* investigate why a file was skipped. Captures CLIMain's JUL logger
|
||||
* directly because java.util.logging routes WARNING to a ConsoleHandler
|
||||
* on stderr by default and may be suppressed entirely under --quiet.
|
||||
*/
|
||||
@Test
|
||||
void testPdfExtensionNonPdfInsideDirectoryIsSilentlySkipped() throws IOException {
|
||||
Path dir = tempDir.resolve("mixed");
|
||||
Files.createDirectory(dir);
|
||||
Files.write(dir.resolve("fake.pdf"), JPEG_PREFIX);
|
||||
Files.write(dir.resolve("note.png"), new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47});
|
||||
|
||||
List<LogRecord> records = new ArrayList<>();
|
||||
String[] stdoutHolder = new String[1];
|
||||
int exitCode = (int) captureLogsOf(records, () -> runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{dir.toString()}),
|
||||
stdoutHolder)).intValue();
|
||||
|
||||
assertEquals(0, exitCode,
|
||||
"Directory containing only invalid files must still exit 0 (silent skip)");
|
||||
assertFalse(stdoutHolder[0].contains("Error:"),
|
||||
"Directory traversal must not surface 'Error:' on stdout; got: " + stdoutHolder[0]);
|
||||
assertFalse(stdoutHolder[0].contains("missing %PDF- header"),
|
||||
"Directory traversal must not surface the magic-number error on stdout; got: "
|
||||
+ stdoutHolder[0]);
|
||||
long warningMatches = records.stream().filter(r ->
|
||||
r.getLevel() == Level.WARNING
|
||||
&& r.getMessage() != null
|
||||
&& r.getMessage().contains("'fake.pdf'")
|
||||
&& r.getMessage().contains("missing %PDF- header")).count();
|
||||
assertEquals(1L, warningMatches,
|
||||
"Exactly one WARNING log must name the offending file and the missing header; "
|
||||
+ "got: " + records);
|
||||
}
|
||||
|
||||
/**
|
||||
* A PDF with a valid {@code %PDF-} header but a corrupt or truncated body
|
||||
* must surface the friendly "corrupted or truncated content" error on
|
||||
* stdout, exit non-zero, AND must not leak a veraPDF stack trace. The
|
||||
* wording must also be distinct from the missing-header case so users
|
||||
* can tell the two failure modes apart.
|
||||
*/
|
||||
@Test
|
||||
void testCorruptPdfShowsCorruptedFileErrorWithoutStackTrace() throws IOException {
|
||||
Path corrupt = tempDir.resolve("corrupt.pdf");
|
||||
// Valid header, but nothing else — veraPDF will fail at parse time.
|
||||
Files.write(corrupt, "%PDF-1.4\n<garbage>".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String[] stdoutHolder = new String[1];
|
||||
int exitCode = (int) runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{corrupt.toString()}),
|
||||
stdoutHolder);
|
||||
|
||||
String out = stdoutHolder[0];
|
||||
assertNotEquals(0, exitCode,
|
||||
"Corrupt PDF must still fail (non-zero exit); got stdout: " + out);
|
||||
assertTrue(out.contains("'corrupt.pdf' is not a valid PDF file (corrupted or truncated content)"),
|
||||
"stdout must surface the friendly corrupted-content error; got: " + out);
|
||||
assertFalse(out.contains("missing %PDF- header"),
|
||||
"Corrupt-but-headered PDFs must NOT be misreported as missing the header; got: "
|
||||
+ out);
|
||||
assertFalse(out.toLowerCase(java.util.Locale.ROOT).contains("verapdf"),
|
||||
"Output must not contain 'verapdf'; got: " + out);
|
||||
assertFalse(out.contains("java.io.IOException:"),
|
||||
"Output must not contain 'java.io.IOException:'; got: " + out);
|
||||
assertFalse(out.contains("\tat "),
|
||||
"Output must not contain a Java stack trace ('\\tat '); got: " + out);
|
||||
}
|
||||
|
||||
/**
|
||||
* A corrupt-but-headered PDF inside a directory must follow the same
|
||||
* batch-folder semantics as a non-PDF-content file: WARNING log, no
|
||||
* {@code Error:} on stdout, exit 0. This mirrors
|
||||
* {@link #testPdfExtensionNonPdfInsideDirectoryIsSilentlySkipped()} for
|
||||
* the corrupted-body branch so both failure modes route through the
|
||||
* same {@link InvalidPdfFileException} handler in CLIMain.
|
||||
*/
|
||||
@Test
|
||||
void testCorruptPdfInsideDirectoryIsSilentlySkipped() throws IOException {
|
||||
Path dir = tempDir.resolve("mixed-corrupt");
|
||||
Files.createDirectory(dir);
|
||||
Files.write(dir.resolve("corrupt.pdf"),
|
||||
"%PDF-1.4\n<garbage>".getBytes(StandardCharsets.UTF_8));
|
||||
Files.write(dir.resolve("note.png"), new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47});
|
||||
|
||||
List<LogRecord> records = new ArrayList<>();
|
||||
String[] stdoutHolder = new String[1];
|
||||
int exitCode = (int) captureLogsOf(records, () -> runCapturingStdout(
|
||||
() -> CLIMain.run(new String[]{dir.toString()}),
|
||||
stdoutHolder)).intValue();
|
||||
|
||||
assertEquals(0, exitCode,
|
||||
"Directory traversal must exit 0 even when a child is a corrupt PDF "
|
||||
+ "(silent-skip semantics); got stdout: " + stdoutHolder[0]);
|
||||
assertFalse(stdoutHolder[0].contains("Error:"),
|
||||
"Directory traversal must not surface 'Error:' on stdout; got: " + stdoutHolder[0]);
|
||||
long warningMatches = records.stream().filter(r ->
|
||||
r.getLevel() == Level.WARNING
|
||||
&& r.getMessage() != null
|
||||
&& r.getMessage().contains("'corrupt.pdf'")
|
||||
&& r.getMessage().contains("corrupted or truncated")).count();
|
||||
assertEquals(1L, warningMatches,
|
||||
"Exactly one WARNING log must name the offending file and describe "
|
||||
+ "the corrupted-content failure; got: " + records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs {@code action} with a temporary {@link Handler} attached to
|
||||
* {@link CLIMain}'s JUL logger, collecting every {@link LogRecord} into
|
||||
* {@code sink}. Restores the prior handler list and level on exit.
|
||||
*
|
||||
* <p>JUL is global; this helper assumes sequential test execution
|
||||
* (JUnit 5 + Surefire default). Returns whatever {@code action} returned.
|
||||
*/
|
||||
private static <T> T captureLogsOf(List<LogRecord> sink, java.util.concurrent.Callable<T> action) {
|
||||
Logger logger = Logger.getLogger(CLIMain.class.getCanonicalName());
|
||||
Level priorLevel = logger.getLevel();
|
||||
boolean priorUseParent = logger.getUseParentHandlers();
|
||||
Handler capture = new Handler() {
|
||||
@Override public void publish(LogRecord record) { sink.add(record); }
|
||||
@Override public void flush() { }
|
||||
@Override public void close() { }
|
||||
};
|
||||
capture.setLevel(Level.ALL);
|
||||
logger.addHandler(capture);
|
||||
logger.setLevel(Level.ALL);
|
||||
logger.setUseParentHandlers(false);
|
||||
try {
|
||||
return action.call();
|
||||
} catch (Exception exception) {
|
||||
throw new RuntimeException(exception);
|
||||
} finally {
|
||||
logger.removeHandler(capture);
|
||||
logger.setLevel(priorLevel);
|
||||
logger.setUseParentHandlers(priorUseParent);
|
||||
}
|
||||
}
|
||||
|
||||
private static String captureStdoutOf(Runnable action) {
|
||||
String[] holder = new String[1];
|
||||
runCapturingStdout(() -> {
|
||||
action.run();
|
||||
return 0;
|
||||
}, holder);
|
||||
return holder[0];
|
||||
}
|
||||
|
||||
// System.setOut is JVM-global; this helper assumes sequential test
|
||||
// execution (JUnit 5 + maven-surefire default). Revisit if parallel
|
||||
// test execution is enabled — concurrent captures would interleave.
|
||||
private static long runCapturingStdout(java.util.concurrent.Callable<Integer> action, String[] stdoutHolder) {
|
||||
PrintStream originalOut = System.out;
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
System.setOut(new PrintStream(buffer, true, StandardCharsets.UTF_8));
|
||||
try {
|
||||
int exitCode = action.call();
|
||||
stdoutHolder[0] = buffer.toString(StandardCharsets.UTF_8);
|
||||
return exitCode;
|
||||
} catch (Exception exception) {
|
||||
throw new RuntimeException(exception);
|
||||
} finally {
|
||||
System.setOut(originalOut);
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.cli;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Regression test for PDFDLOSP-26: every {@code --format} value must emit
|
||||
* exactly one INFO-level "Created <path>" log line after writing its
|
||||
* output. Tagged-PDF previously skipped this log because
|
||||
* {@code AutoTaggingProcessor.createTaggedPDF()} had no {@code Logger}.
|
||||
*/
|
||||
class FormatLogRegressionTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@ParameterizedTest(name = "--format {0} emits exactly one Created log")
|
||||
@ValueSource(strings = {"json", "text", "html", "pdf", "markdown", "tagged-pdf"})
|
||||
void formatEmitsExactlyOneCreatedLog(String format) throws IOException {
|
||||
Path pdf = createMinimalPdf(tempDir.resolve("in.pdf"));
|
||||
Path outDir = tempDir.resolve(format);
|
||||
Files.createDirectories(outDir);
|
||||
|
||||
List<LogRecord> records = new ArrayList<>();
|
||||
int exitCode = captureAllLogsOf(records, () -> CLIMain.run(new String[]{
|
||||
"--format", format,
|
||||
"--output", outDir.toString(),
|
||||
pdf.toString()
|
||||
}));
|
||||
|
||||
assertEquals(0, exitCode, () ->
|
||||
"Exit code must be 0 for --format " + format + "; records: " + summarize(records));
|
||||
|
||||
long createdCount = records.stream()
|
||||
.filter(r -> r.getLevel() == Level.INFO)
|
||||
.filter(r -> r.getMessage() != null && r.getMessage().contains("Created"))
|
||||
.count();
|
||||
|
||||
assertEquals(1L, createdCount, () ->
|
||||
"Exactly one INFO 'Created ...' log expected for --format " + format
|
||||
+ "; got " + createdCount + ". Records: " + summarize(records));
|
||||
}
|
||||
|
||||
private static Path createMinimalPdf(Path target) throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new PDPage());
|
||||
document.save(target.toFile());
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures every {@link LogRecord} emitted by any logger under the
|
||||
* {@code org.opendataloader.pdf} hierarchy. Attaching to the root logger
|
||||
* would also include third-party noise; attaching to the package root
|
||||
* picks up CLIMain plus every generator/processor in core (JUL records
|
||||
* propagate to parent handlers).
|
||||
*
|
||||
* <p>JUL is global; this assumes sequential test execution
|
||||
* (JUnit 5 + Surefire default).
|
||||
*/
|
||||
private static <T> T captureAllLogsOf(List<LogRecord> sink, Callable<T> action) {
|
||||
Logger logger = Logger.getLogger("org.opendataloader.pdf");
|
||||
Level priorLevel = logger.getLevel();
|
||||
boolean priorUseParent = logger.getUseParentHandlers();
|
||||
Handler capture = new Handler() {
|
||||
@Override public void publish(LogRecord record) { sink.add(record); }
|
||||
@Override public void flush() { }
|
||||
@Override public void close() { }
|
||||
};
|
||||
capture.setLevel(Level.ALL);
|
||||
logger.addHandler(capture);
|
||||
logger.setLevel(Level.ALL);
|
||||
logger.setUseParentHandlers(false);
|
||||
try {
|
||||
return action.call();
|
||||
} catch (Exception exception) {
|
||||
throw new RuntimeException(exception);
|
||||
} finally {
|
||||
logger.removeHandler(capture);
|
||||
logger.setLevel(priorLevel);
|
||||
logger.setUseParentHandlers(priorUseParent);
|
||||
}
|
||||
}
|
||||
|
||||
private static String summarize(List<LogRecord> records) {
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
for (LogRecord r : records) {
|
||||
sb.append('(').append(r.getLevel()).append(": ").append(r.getMessage()).append(") ");
|
||||
}
|
||||
return sb.append(']').toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Copyright 2025-2026 Hancom Inc.
|
||||
|
||||
Licensed 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.
|
||||
|
||||
-->
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.opendataloader</groupId>
|
||||
<artifactId>opendataloader-pdf-parent</artifactId>
|
||||
<version>0.0.0</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>opendataloader-pdf-core</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>OpenDataLoader PDF Core</name>
|
||||
<description>OpenDataLoader PDF Core</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.verapdf</groupId>
|
||||
<artifactId>validation-model</artifactId>
|
||||
<version>${verapdf.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.verapdf</groupId>
|
||||
<artifactId>feature-reporting</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.verapdf</groupId>
|
||||
<artifactId>wcag-validation</artifactId>
|
||||
<version>${verapdf.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.verapdf</groupId>
|
||||
<artifactId>wcag-algorithms</artifactId>
|
||||
<version>${verapdf.wcag.algs.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.databind.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-cli</groupId>
|
||||
<artifactId>commons-cli</artifactId>
|
||||
</dependency>
|
||||
<!--
|
||||
okhttp 5's plain `okhttp` artifact is an empty Kotlin-Multiplatform
|
||||
metadata jar; the JVM classes live in `okhttp-jvm`, and Maven cannot
|
||||
resolve the JVM variant from Gradle module metadata — so depend on
|
||||
`okhttp-jvm` explicitly. Version is managed by the okhttp-bom in the
|
||||
parent pom (see java/pom.xml dependencyManagement).
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp-jvm</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver3</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>${project.basedir}/../../</directory>
|
||||
<includes>
|
||||
<include>LICENSE</include>
|
||||
<include>NOTICE</include>
|
||||
</includes>
|
||||
<targetPath>META-INF</targetPath>
|
||||
<filtering>false</filtering>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>${project.basedir}/../../THIRD_PARTY</directory>
|
||||
<includes>
|
||||
<include>**/*</include>
|
||||
</includes>
|
||||
<targetPath>META-INF/THIRD_PARTY</targetPath>
|
||||
<filtering>false</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>flatten-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<updatePomFile>true</updatePomFile>
|
||||
<flattenMode>oss</flattenMode>
|
||||
<pomElements>
|
||||
<name/>
|
||||
<description/>
|
||||
<url/>
|
||||
<scm/>
|
||||
<developers/>
|
||||
</pomElements>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>flatten</id>
|
||||
<phase>process-resources</phase>
|
||||
<goals>
|
||||
<goal>flatten</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>flatten.clean</id>
|
||||
<phase>clean</phase>
|
||||
<goals>
|
||||
<goal>clean</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-source-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-sources</id>
|
||||
<goals>
|
||||
<goal>jar-no-fork</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-javadoc-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-javadocs</id>
|
||||
<goals>
|
||||
<goal>jar</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>release</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.sonatype.central</groupId>
|
||||
<artifactId>central-publishing-maven-plugin</artifactId>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<publishingServerId>central</publishingServerId>
|
||||
<autoPublish>true</autoPublish>
|
||||
<waitUntil>published</waitUntil>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-artifacts</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>sign</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.api;
|
||||
|
||||
import org.opendataloader.pdf.processors.AutoTaggingProcessor;
|
||||
import org.opendataloader.pdf.processors.DocumentProcessor;
|
||||
import org.opendataloader.pdf.processors.ExtractionResult;
|
||||
import org.verapdf.pd.PDDocument;
|
||||
import org.verapdf.tools.StaticResources;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Public API for standalone PDF auto-tagging.
|
||||
*
|
||||
* <p>Returns an in-memory tagged {@link PDDocument} without writing intermediate files.
|
||||
* Use {@link OpenDataLoaderPDF#processFile} for the full extraction + output pipeline.
|
||||
*
|
||||
* <p>Example usage:
|
||||
* <pre>{@code
|
||||
* Config config = new Config();
|
||||
* config.setHybrid("docling-fast");
|
||||
*
|
||||
* try (TaggingResult result = AutoTagger.tag("input.pdf", config)) {
|
||||
* PDDocument tagged = result.getDocument();
|
||||
* // use tagged document...
|
||||
* result.saveTo("output_tagged.pdf");
|
||||
* }
|
||||
* AutoTagger.shutdown();
|
||||
* }</pre>
|
||||
*/
|
||||
public final class AutoTagger {
|
||||
|
||||
private AutoTagger() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content from a PDF and produce a tagged PDF document in-memory.
|
||||
* Output format flags in config are ignored — this method only produces
|
||||
* a tagged PDDocument.
|
||||
*
|
||||
* @param inputPdf path to the input PDF file
|
||||
* @param config configuration (extraction + hybrid fields are used;
|
||||
* output format flags are ignored)
|
||||
* @return result containing the tagged PDDocument and timing metadata
|
||||
* @throws IOException if unable to read or process the PDF
|
||||
*/
|
||||
public static TaggingResult tag(String inputPdf, Config config, Float pdfVersion) throws IOException {
|
||||
ExtractionResult extraction = DocumentProcessor.extractContents(inputPdf, config);
|
||||
return tag(extraction, pdfVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag a PDF document from a previously computed {@link ExtractionResult}.
|
||||
* Use this when you need both tagged PDF and other output formats from the
|
||||
* same extraction — call {@link DocumentProcessor#extractContents} once,
|
||||
* then pass the result here and to other output generators.
|
||||
*
|
||||
* @param extraction pre-computed extraction result
|
||||
* @return result containing the tagged PDDocument and timing metadata
|
||||
* @throws IOException if unable to tag the document
|
||||
*/
|
||||
public static TaggingResult tag(ExtractionResult extraction, Float pdfVersion) throws IOException {
|
||||
long t0 = System.nanoTime();
|
||||
PDDocument document = StaticResources.getDocument();
|
||||
AutoTaggingProcessor.tagDocument(document, extraction.getContents(), pdfVersion);
|
||||
long taggingNs = System.nanoTime() - t0;
|
||||
|
||||
return new TaggingResult(document, extraction.getExtractionNs(), taggingNs,
|
||||
extraction.getHybridTimings(), extraction.getElementMetadata());
|
||||
}
|
||||
|
||||
/**
|
||||
* Release hybrid client resources. Call when processing is complete.
|
||||
*/
|
||||
public static void shutdown() {
|
||||
OpenDataLoaderPDF.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.api;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.opendataloader.pdf.hybrid.HybridConfig;
|
||||
|
||||
/**
|
||||
* Configuration class for the PDF processing.
|
||||
* Use this class to specify output formats, text processing options, and other settings.
|
||||
*/
|
||||
public class Config {
|
||||
private static final Logger LOGGER = Logger.getLogger(Config.class.getCanonicalName());
|
||||
|
||||
/** Reading order option: no sorting, keeps PDF COS object order. */
|
||||
public static final String READING_ORDER_OFF = "off";
|
||||
/** Reading order option: XY-Cut++ algorithm for layout-aware sorting. */
|
||||
public static final String READING_ORDER_XYCUT = "xycut";
|
||||
private static Set<String> readingOrderOptions = new HashSet<>();
|
||||
|
||||
/** Hybrid mode: off (Java-only processing, no external dependency). */
|
||||
public static final String HYBRID_OFF = "off";
|
||||
/** Hybrid mode: docling backend (Docling FastAPI server). */
|
||||
public static final String HYBRID_DOCLING = "docling";
|
||||
/** Hybrid mode: docling-fast backend (deprecated alias for docling). */
|
||||
public static final String HYBRID_DOCLING_FAST = "docling-fast";
|
||||
/** Hybrid mode: hancom backend (Hancom Document AI). */
|
||||
public static final String HYBRID_HANCOM = "hancom";
|
||||
/** Hybrid mode: hancom-ai backend (Hancom AI HOCR SDK — individual modules). */
|
||||
public static final String HYBRID_HANCOM_AI = "hancom-ai";
|
||||
/** Hybrid mode: azure backend (Azure Document Intelligence). */
|
||||
public static final String HYBRID_AZURE = "azure";
|
||||
/** Hybrid mode: google backend (Google Document AI). */
|
||||
public static final String HYBRID_GOOGLE = "google";
|
||||
private static Set<String> hybridOptions = new HashSet<>();
|
||||
|
||||
/** Hybrid triage mode: auto (dynamic triage based on page content). */
|
||||
public static final String HYBRID_MODE_AUTO = "auto";
|
||||
/** Hybrid triage mode: full (skip triage, send all pages to backend). */
|
||||
public static final String HYBRID_MODE_FULL = "full";
|
||||
private static Set<String> hybridModeOptions = new HashSet<>();
|
||||
|
||||
/** Placeholder string for page number in separators. */
|
||||
public static final String PAGE_NUMBER_STRING = "%page-number%";
|
||||
private String password;
|
||||
private boolean isGenerateMarkdown = false;
|
||||
private boolean isGenerateHtml = false;
|
||||
private boolean isGeneratePDF = false;
|
||||
private boolean keepLineBreaks = false;
|
||||
private boolean isGenerateJSON = true;
|
||||
private boolean isGenerateText = false;
|
||||
private boolean isGenerateTaggedPDF = false;
|
||||
private boolean useStructTree = false;
|
||||
private boolean useHTMLInMarkdown = false;
|
||||
private boolean addImageToMarkdown = false;
|
||||
private String replaceInvalidChars = " ";
|
||||
private String outputFolder;
|
||||
private String tableMethod = TABLE_METHOD_DEFAULT;
|
||||
private String readingOrder = READING_ORDER_XYCUT;
|
||||
private String markdownPageSeparator = "";
|
||||
private String textPageSeparator = "";
|
||||
private String htmlPageSeparator = "";
|
||||
private String imageOutput = IMAGE_OUTPUT_EXTERNAL;
|
||||
private String imageFormat = IMAGE_FORMAT_PNG;
|
||||
private String imageDir;
|
||||
private String pages;
|
||||
private List<Integer> cachedPageNumbers;
|
||||
private final FilterConfig filterConfig = new FilterConfig();
|
||||
private String hybrid = HYBRID_OFF;
|
||||
private final HybridConfig hybridConfig = new HybridConfig();
|
||||
private boolean includeHeaderFooter = false;
|
||||
private boolean detectStrikethrough = false;
|
||||
|
||||
/** Table detection method: default (border-based detection). */
|
||||
public static final String TABLE_METHOD_DEFAULT = "default";
|
||||
/** Table detection method: cluster-based detection (includes border-based). */
|
||||
public static final String TABLE_METHOD_CLUSTER = "cluster";
|
||||
private static Set<String> tableMethodOptions = new HashSet<>();
|
||||
|
||||
/** Image format: PNG. */
|
||||
public static final String IMAGE_FORMAT_PNG = "png";
|
||||
/** Image format: JPEG. */
|
||||
public static final String IMAGE_FORMAT_JPEG = "jpeg";
|
||||
private static Set<String> imageFormatOptions = new HashSet<>();
|
||||
|
||||
/** Image output mode: no image extraction. */
|
||||
public static final String IMAGE_OUTPUT_OFF = "off";
|
||||
/** Image output mode: embedded as Base64 data URIs. */
|
||||
public static final String IMAGE_OUTPUT_EMBEDDED = "embedded";
|
||||
/** Image output mode: external file references. */
|
||||
public static final String IMAGE_OUTPUT_EXTERNAL = "external";
|
||||
private static Set<String> imageOutputOptions = new HashSet<>();
|
||||
|
||||
static {
|
||||
readingOrderOptions.add(READING_ORDER_OFF);
|
||||
readingOrderOptions.add(READING_ORDER_XYCUT);
|
||||
tableMethodOptions.add(TABLE_METHOD_DEFAULT);
|
||||
tableMethodOptions.add(TABLE_METHOD_CLUSTER);
|
||||
imageFormatOptions.add(IMAGE_FORMAT_PNG);
|
||||
imageFormatOptions.add(IMAGE_FORMAT_JPEG);
|
||||
imageOutputOptions.add(IMAGE_OUTPUT_OFF);
|
||||
imageOutputOptions.add(IMAGE_OUTPUT_EMBEDDED);
|
||||
imageOutputOptions.add(IMAGE_OUTPUT_EXTERNAL);
|
||||
hybridOptions.add(HYBRID_OFF);
|
||||
hybridOptions.add(HYBRID_DOCLING);
|
||||
hybridOptions.add(HYBRID_DOCLING_FAST); // deprecated alias
|
||||
hybridOptions.add(HYBRID_HANCOM);
|
||||
hybridOptions.add(HYBRID_HANCOM_AI);
|
||||
// azure, google added when implemented
|
||||
hybridModeOptions.add(HYBRID_MODE_AUTO);
|
||||
hybridModeOptions.add(HYBRID_MODE_FULL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the filter config.
|
||||
*
|
||||
* @return The FilterConfig.
|
||||
*/
|
||||
public FilterConfig getFilterConfig() {
|
||||
return filterConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default constructor initializing the configuration with default values.
|
||||
*/
|
||||
public Config() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the password for opening encrypted PDF files.
|
||||
*
|
||||
* @return The password, or null if not set.
|
||||
*/
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the password for opening encrypted PDF files.
|
||||
*
|
||||
* @param password The password to use.
|
||||
*/
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Markdown output generation is enabled.
|
||||
* <p>
|
||||
* Markdown generation is automatically enabled if {@link #isAddImageToMarkdown()} or
|
||||
* {@link #isUseHTMLInMarkdown()} is true.
|
||||
*
|
||||
* @return true if Markdown output should be generated, false otherwise.
|
||||
*/
|
||||
public boolean isGenerateMarkdown() {
|
||||
return isGenerateMarkdown || isAddImageToMarkdown() || isUseHTMLInMarkdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables Markdown output generation.
|
||||
*
|
||||
* @param generateMarkdown true to enable, false to disable.
|
||||
*/
|
||||
public void setGenerateMarkdown(boolean generateMarkdown) {
|
||||
isGenerateMarkdown = generateMarkdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if HTML output generation is enabled.
|
||||
*
|
||||
* @return true if HTML output should be generated, false otherwise.
|
||||
*/
|
||||
public boolean isGenerateHtml() {
|
||||
return isGenerateHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables HTML output generation.
|
||||
*
|
||||
* @param generateHtml true to enable, false to disable.
|
||||
*/
|
||||
public void setGenerateHtml(boolean generateHtml) {
|
||||
isGenerateHtml = generateHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a new PDF with tagged structure is generated.
|
||||
*
|
||||
* @return true if PDF generation is enabled, false otherwise.
|
||||
*/
|
||||
public boolean isGeneratePDF() {
|
||||
return isGeneratePDF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables generation of a new, tagged PDF.
|
||||
*
|
||||
* @param generatePDF true to enable, false to disable.
|
||||
*/
|
||||
public void setGeneratePDF(boolean generatePDF) {
|
||||
isGeneratePDF = generatePDF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if original line breaks within text blocks should be preserved.
|
||||
*
|
||||
* @return true if line breaks are preserved, false otherwise.
|
||||
*/
|
||||
public boolean isKeepLineBreaks() {
|
||||
return keepLineBreaks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether to preserve original line breaks within text blocks.
|
||||
*
|
||||
* @param keepLineBreaks true to preserve line breaks, false to merge lines into paragraphs.
|
||||
*/
|
||||
public void setKeepLineBreaks(boolean keepLineBreaks) {
|
||||
this.keepLineBreaks = keepLineBreaks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if JSON output generation is enabled. Defaults to true.
|
||||
*
|
||||
* @return true if JSON output should be generated, false otherwise.
|
||||
*/
|
||||
public boolean isGenerateJSON() {
|
||||
return isGenerateJSON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables JSON output generation.
|
||||
*
|
||||
* @param generateJSON true to enable, false to disable.
|
||||
*/
|
||||
public void setGenerateJSON(boolean generateJSON) {
|
||||
isGenerateJSON = generateJSON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if plain text output generation is enabled.
|
||||
*
|
||||
* @return true if plain text output should be generated, false otherwise.
|
||||
*/
|
||||
public boolean isGenerateText() {
|
||||
return isGenerateText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables plain text output generation.
|
||||
*
|
||||
* @param generateText true to enable, false to disable.
|
||||
*/
|
||||
public void setGenerateText(boolean generateText) {
|
||||
isGenerateText = generateText;
|
||||
}
|
||||
|
||||
public boolean isGenerateTaggedPDF() {
|
||||
return isGenerateTaggedPDF;
|
||||
}
|
||||
|
||||
public void setGenerateTaggedPDF(boolean generateTaggedPDF) {
|
||||
isGenerateTaggedPDF = generateTaggedPDF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if HTML tags should be used within the Markdown output for complex structures like tables.
|
||||
*
|
||||
* @return true if HTML is used in Markdown, false otherwise.
|
||||
*/
|
||||
public boolean isUseHTMLInMarkdown() {
|
||||
return useHTMLInMarkdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables the use of HTML tags in Markdown output.
|
||||
* Enabling this will also enable {@link #isGenerateMarkdown()}.
|
||||
*
|
||||
* @param useHTMLInMarkdown true to use HTML, false for pure Markdown.
|
||||
*/
|
||||
public void setUseHTMLInMarkdown(boolean useHTMLInMarkdown) {
|
||||
this.useHTMLInMarkdown = useHTMLInMarkdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if images should be extracted and included in the Markdown output.
|
||||
*
|
||||
* @return true if images are included in Markdown, false otherwise.
|
||||
*/
|
||||
public boolean isAddImageToMarkdown() {
|
||||
return addImageToMarkdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables the inclusion of extracted images in Markdown output.
|
||||
* Enabling this will also enable {@link #isGenerateMarkdown()}.
|
||||
*
|
||||
* @param addImageToMarkdown true to include images, false otherwise.
|
||||
*/
|
||||
public void setAddImageToMarkdown(boolean addImageToMarkdown) {
|
||||
this.addImageToMarkdown = addImageToMarkdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the output folder where generated files will be saved.
|
||||
*
|
||||
* @return The output folder path.
|
||||
*/
|
||||
public String getOutputFolder() {
|
||||
return outputFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the path to the output folder where generated files will be saved.
|
||||
* The directory will be created if it does not exist.
|
||||
*
|
||||
* @param outputFolder The path to the output folder.
|
||||
*/
|
||||
public void setOutputFolder(String outputFolder) {
|
||||
this.outputFolder = outputFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the character, that replaces invalid or unrecognized characters (e.g., �, \u0000).
|
||||
*
|
||||
* @return The specified replacement character.
|
||||
*/
|
||||
public String getReplaceInvalidChars() {
|
||||
return replaceInvalidChars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the character, that replaces invalid or unrecognized characters (e.g., �, \u0000).
|
||||
*
|
||||
* @param replaceInvalidChars The specified replacement character.
|
||||
*/
|
||||
public void setReplaceInvalidChars(String replaceInvalidChars) {
|
||||
this.replaceInvalidChars = replaceInvalidChars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the PDF structure tree should be used for document parsing.
|
||||
*
|
||||
* @return true if structure tree should be used, false otherwise.
|
||||
*/
|
||||
public boolean isUseStructTree() {
|
||||
return useStructTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables use of PDF structure tree for document parsing.
|
||||
*
|
||||
* @param useStructTree true to use structure tree, false otherwise.
|
||||
*/
|
||||
public void setUseStructTree(boolean useStructTree) {
|
||||
this.useStructTree = useStructTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if cluster-based table detection is enabled.
|
||||
*
|
||||
* @return true if cluster table detection is enabled, false otherwise.
|
||||
*/
|
||||
public boolean isClusterTableMethod() {
|
||||
return TABLE_METHOD_CLUSTER.equals(tableMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the table detection method.
|
||||
*
|
||||
* @return The table detection method (default or cluster).
|
||||
*/
|
||||
public String getTableMethod() {
|
||||
return tableMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the table detection method.
|
||||
*
|
||||
* @param tableMethod The table detection method (default or cluster).
|
||||
* @throws IllegalArgumentException if the method is not supported.
|
||||
*/
|
||||
public void setTableMethod(String tableMethod) {
|
||||
if (tableMethod != null && !isValidTableMethod(tableMethod)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unsupported table method '%s'. Supported values: %s",
|
||||
tableMethod, getTableMethodOptions(", ")));
|
||||
}
|
||||
this.tableMethod = tableMethod != null ? tableMethod.toLowerCase(Locale.ROOT) : TABLE_METHOD_DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of methods of table detection.
|
||||
*
|
||||
* @param delimiter the delimiter to use between options
|
||||
* @return the string with methods separated by the delimiter
|
||||
*/
|
||||
public static String getTableMethodOptions(CharSequence delimiter) {
|
||||
return String.join(delimiter, tableMethodOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given table method is valid.
|
||||
*
|
||||
* @param method The table method to check.
|
||||
* @return true if the method is valid, false otherwise.
|
||||
*/
|
||||
public static boolean isValidTableMethod(String method) {
|
||||
return method != null && tableMethodOptions.contains(method.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the reading order, that states in which order content should be processed.
|
||||
*
|
||||
* @return The specified order.
|
||||
*/
|
||||
public String getReadingOrder() {
|
||||
return readingOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the reading order, that states in which order content should be processed.
|
||||
*
|
||||
* @param readingOrder The specified order (off or xycut).
|
||||
* @throws IllegalArgumentException if the order is not supported.
|
||||
*/
|
||||
public void setReadingOrder(String readingOrder) {
|
||||
if (readingOrder != null && !isValidReadingOrder(readingOrder)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unsupported reading order '%s'. Supported values: %s",
|
||||
readingOrder, getReadingOrderOptions(", ")));
|
||||
}
|
||||
this.readingOrder = readingOrder != null ? readingOrder.toLowerCase(Locale.ROOT) : READING_ORDER_XYCUT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of reading order options.
|
||||
*
|
||||
* @param delimiter The delimiter to use between options.
|
||||
* @return The string with reading orders separated by the delimiter.
|
||||
*/
|
||||
public static String getReadingOrderOptions(CharSequence delimiter) {
|
||||
return String.join(delimiter, readingOrderOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given reading order is valid.
|
||||
*
|
||||
* @param order The reading order to check.
|
||||
* @return true if the order is valid, false otherwise.
|
||||
*/
|
||||
public static boolean isValidReadingOrder(String order) {
|
||||
return order != null && readingOrderOptions.contains(order.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the string, that separates content from different pages in markdown.
|
||||
*
|
||||
* @return The specified string.
|
||||
*/
|
||||
public String getMarkdownPageSeparator() {
|
||||
return markdownPageSeparator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the string, that separates content from different pages in markdown.
|
||||
*
|
||||
* @param markdownPageSeparator The specified string.
|
||||
*/
|
||||
public void setMarkdownPageSeparator(String markdownPageSeparator) {
|
||||
this.markdownPageSeparator = markdownPageSeparator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the string, that separates content from different pages in text.
|
||||
*
|
||||
* @return The specified string.
|
||||
*/
|
||||
public String getTextPageSeparator() {
|
||||
return textPageSeparator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the string, that separates content from different pages in text.
|
||||
*
|
||||
* @param textPageSeparator The specified string.
|
||||
*/
|
||||
public void setTextPageSeparator(String textPageSeparator) {
|
||||
this.textPageSeparator = textPageSeparator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the string, that separates content from different pages in html.
|
||||
*
|
||||
* @return The specified string.
|
||||
*/
|
||||
public String getHtmlPageSeparator() {
|
||||
return htmlPageSeparator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the string, that separates content from different pages in html.
|
||||
*
|
||||
* @param htmlPageSeparator The specified string.
|
||||
*/
|
||||
public void setHtmlPageSeparator(String htmlPageSeparator) {
|
||||
this.htmlPageSeparator = htmlPageSeparator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if images should be embedded as Base64 data URIs in the output.
|
||||
*
|
||||
* @return true if images should be embedded as Base64, false for file path references.
|
||||
*/
|
||||
public boolean isEmbedImages() {
|
||||
return IMAGE_OUTPUT_EMBEDDED.equals(imageOutput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if image extraction is disabled.
|
||||
*
|
||||
* @return true if image output is off, false otherwise.
|
||||
*/
|
||||
public boolean isImageOutputOff() {
|
||||
return IMAGE_OUTPUT_OFF.equals(imageOutput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the image output mode.
|
||||
*
|
||||
* @return The image output mode (off, embedded, or external).
|
||||
*/
|
||||
public String getImageOutput() {
|
||||
return imageOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the image output mode.
|
||||
*
|
||||
* @param imageOutput The image output mode (off, embedded, or external).
|
||||
* @throws IllegalArgumentException if the mode is not supported.
|
||||
*/
|
||||
public void setImageOutput(String imageOutput) {
|
||||
if (imageOutput != null && !isValidImageOutput(imageOutput)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unsupported image output mode '%s'. Supported values: %s",
|
||||
imageOutput, getImageOutputOptions(", ")));
|
||||
}
|
||||
this.imageOutput = imageOutput != null ? imageOutput.toLowerCase(Locale.ROOT) : IMAGE_OUTPUT_EXTERNAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of supported image output options.
|
||||
*
|
||||
* @param delimiter The delimiter to use between options.
|
||||
* @return The string with image output modes separated by the delimiter.
|
||||
*/
|
||||
public static String getImageOutputOptions(CharSequence delimiter) {
|
||||
return String.join(delimiter, imageOutputOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given image output mode is valid.
|
||||
*
|
||||
* @param mode The image output mode to check.
|
||||
* @return true if the mode is valid, false otherwise.
|
||||
*/
|
||||
public static boolean isValidImageOutput(String mode) {
|
||||
return mode != null && imageOutputOptions.contains(mode.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the image format for extracted images.
|
||||
*
|
||||
* @return The image format (png or jpeg).
|
||||
*/
|
||||
public String getImageFormat() {
|
||||
return imageFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the image format for extracted images.
|
||||
*
|
||||
* @param imageFormat The image format (png or jpeg).
|
||||
* @throws IllegalArgumentException if the format is not supported.
|
||||
*/
|
||||
public void setImageFormat(String imageFormat) {
|
||||
if (imageFormat != null && !isValidImageFormat(imageFormat)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unsupported image format '%s'. Supported values: %s",
|
||||
imageFormat, getImageFormatOptions(", ")));
|
||||
}
|
||||
this.imageFormat = imageFormat != null ? imageFormat.toLowerCase(Locale.ROOT) : IMAGE_FORMAT_PNG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of supported image format options.
|
||||
*
|
||||
* @param delimiter The delimiter to use between options.
|
||||
* @return The string with image formats separated by the delimiter.
|
||||
*/
|
||||
public static String getImageFormatOptions(CharSequence delimiter) {
|
||||
return String.join(delimiter, imageFormatOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given image format is valid.
|
||||
*
|
||||
* @param format The image format to check.
|
||||
* @return true if the format is valid, false otherwise.
|
||||
*/
|
||||
public static boolean isValidImageFormat(String format) {
|
||||
return format != null && imageFormatOptions.contains(format.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the directory for extracted images.
|
||||
*
|
||||
* @return The image directory path, or null for default.
|
||||
*/
|
||||
public String getImageDir() {
|
||||
return imageDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the directory for extracted images.
|
||||
* Empty or whitespace-only strings are treated as null (use default).
|
||||
*
|
||||
* @param imageDir The directory path for extracted images.
|
||||
*/
|
||||
public void setImageDir(String imageDir) {
|
||||
if (imageDir != null && imageDir.trim().isEmpty()) {
|
||||
this.imageDir = null;
|
||||
} else {
|
||||
this.imageDir = imageDir;
|
||||
}
|
||||
}
|
||||
|
||||
private static final String INVALID_PAGE_RANGE_FORMAT = "Invalid page range format: '%s'. Expected format: 1,3,5-7";
|
||||
/** Split limit to preserve trailing empty strings (e.g., "5-" splits to ["5", ""]). */
|
||||
private static final int SPLIT_KEEP_EMPTY_TRAILING = -1;
|
||||
|
||||
/**
|
||||
* Gets the pages to extract from the PDF.
|
||||
*
|
||||
* @return The page specification string (e.g., "1,3,5-7"), or null for all pages.
|
||||
*/
|
||||
public String getPages() {
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the pages to extract from the PDF.
|
||||
*
|
||||
* @param pages The page specification (e.g., "1,3,5-7"). Use null or empty for all pages.
|
||||
* @throws IllegalArgumentException if the format is invalid.
|
||||
*/
|
||||
public void setPages(String pages) {
|
||||
if (pages != null && !pages.trim().isEmpty()) {
|
||||
this.cachedPageNumbers = parsePageRanges(pages);
|
||||
} else {
|
||||
this.cachedPageNumbers = null;
|
||||
}
|
||||
this.pages = pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of page numbers to extract.
|
||||
*
|
||||
* @return List of 1-based page numbers, or empty list if all pages should be extracted.
|
||||
*/
|
||||
public List<Integer> getPageNumbers() {
|
||||
if (cachedPageNumbers == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return new ArrayList<>(cachedPageNumbers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a page range specification into a list of page numbers.
|
||||
*
|
||||
* @param pages The page specification (e.g., "1,3,5-7").
|
||||
* @return List of 1-based page numbers.
|
||||
* @throws IllegalArgumentException if the format is invalid.
|
||||
*/
|
||||
private static List<Integer> parsePageRanges(String pages) {
|
||||
List<Integer> result = new ArrayList<>();
|
||||
String[] parts = pages.split(",");
|
||||
|
||||
for (String part : parts) {
|
||||
String trimmed = part.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException(String.format(INVALID_PAGE_RANGE_FORMAT, pages));
|
||||
}
|
||||
|
||||
if (trimmed.contains("-")) {
|
||||
parseRange(trimmed, pages, result);
|
||||
} else {
|
||||
parseSinglePage(trimmed, pages, result);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void parseRange(String range, String fullInput, List<Integer> result) {
|
||||
String[] parts = range.split("-", SPLIT_KEEP_EMPTY_TRAILING);
|
||||
if (parts.length != 2 || parts[0].isEmpty() || parts[1].isEmpty()) {
|
||||
throw new IllegalArgumentException(String.format(INVALID_PAGE_RANGE_FORMAT, fullInput));
|
||||
}
|
||||
|
||||
try {
|
||||
int start = Integer.parseInt(parts[0].trim());
|
||||
int end = Integer.parseInt(parts[1].trim());
|
||||
|
||||
if (start < 1 || end < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Page numbers must be positive: '%s'", fullInput));
|
||||
}
|
||||
if (start > end) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Invalid page range '%s': start page cannot be greater than end page", range));
|
||||
}
|
||||
|
||||
for (int i = start; i <= end; i++) {
|
||||
result.add(i);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(String.format(INVALID_PAGE_RANGE_FORMAT, fullInput));
|
||||
}
|
||||
}
|
||||
|
||||
private static void parseSinglePage(String page, String fullInput, List<Integer> result) {
|
||||
try {
|
||||
int pageNum = Integer.parseInt(page);
|
||||
if (pageNum < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Page numbers must be positive: '%s'", fullInput));
|
||||
}
|
||||
result.add(pageNum);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(String.format(INVALID_PAGE_RANGE_FORMAT, fullInput));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the hybrid backend name.
|
||||
*
|
||||
* @return The hybrid backend (off, docling, hancom, azure, google).
|
||||
*/
|
||||
public String getHybrid() {
|
||||
return hybrid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the hybrid backend.
|
||||
*
|
||||
* @param hybrid The hybrid backend (off, docling, hancom, azure, google).
|
||||
* @throws IllegalArgumentException if the backend is not supported.
|
||||
*/
|
||||
public void setHybrid(String hybrid) {
|
||||
if (hybrid != null && !isValidHybrid(hybrid)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unsupported hybrid backend '%s'. Supported values: %s",
|
||||
hybrid, getHybridOptions(", ")));
|
||||
}
|
||||
this.hybrid = hybrid != null ? hybrid.toLowerCase(Locale.ROOT) : HYBRID_OFF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of supported hybrid backend options.
|
||||
*
|
||||
* @param delimiter The delimiter to use between options.
|
||||
* @return The string with hybrid backends separated by the delimiter.
|
||||
*/
|
||||
public static String getHybridOptions(CharSequence delimiter) {
|
||||
return String.join(delimiter, hybridOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given hybrid backend is valid.
|
||||
*
|
||||
* @param hybrid The hybrid backend to check.
|
||||
* @return true if the backend is valid, false otherwise.
|
||||
*/
|
||||
public static boolean isValidHybrid(String hybrid) {
|
||||
return hybrid != null && hybridOptions.contains(hybrid.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if hybrid processing is enabled.
|
||||
*
|
||||
* @return true if hybrid mode is not off, false otherwise.
|
||||
*/
|
||||
public boolean isHybridEnabled() {
|
||||
return !HYBRID_OFF.equals(hybrid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the hybrid configuration.
|
||||
*
|
||||
* @return The HybridConfig instance.
|
||||
*/
|
||||
public HybridConfig getHybridConfig() {
|
||||
return hybridConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of supported hybrid mode options.
|
||||
*
|
||||
* @param delimiter The delimiter to use between options.
|
||||
* @return The string with hybrid modes separated by the delimiter.
|
||||
*/
|
||||
public static String getHybridModeOptions(CharSequence delimiter) {
|
||||
return String.join(delimiter, hybridModeOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given hybrid mode is valid.
|
||||
*
|
||||
* @param mode The hybrid mode to check.
|
||||
* @return true if the mode is valid, false otherwise.
|
||||
*/
|
||||
public static boolean isValidHybridMode(String mode) {
|
||||
return mode != null && hybridModeOptions.contains(mode.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if page headers and footers should be included in output.
|
||||
*
|
||||
* @return true if headers and footers should be included, false otherwise.
|
||||
*/
|
||||
public boolean isIncludeHeaderFooter() {
|
||||
return includeHeaderFooter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables inclusion of page headers and footers in output.
|
||||
*
|
||||
* @param includeHeaderFooter true to include headers and footers, false to exclude.
|
||||
*/
|
||||
public void setIncludeHeaderFooter(boolean includeHeaderFooter) {
|
||||
this.includeHeaderFooter = includeHeaderFooter;
|
||||
}
|
||||
|
||||
public boolean isDetectStrikethrough() {
|
||||
return detectStrikethrough;
|
||||
}
|
||||
|
||||
public void setDetectStrikethrough(boolean detectStrikethrough) {
|
||||
this.detectStrikethrough = detectStrikethrough;
|
||||
}
|
||||
|
||||
private boolean outputStdout = false;
|
||||
|
||||
public boolean isOutputStdout() {
|
||||
return outputStdout;
|
||||
}
|
||||
|
||||
public void setOutputStdout(boolean outputStdout) {
|
||||
this.outputStdout = outputStdout;
|
||||
}
|
||||
|
||||
private int threads = 1;
|
||||
|
||||
public int getThreads() {
|
||||
return threads;
|
||||
}
|
||||
|
||||
public void setThreads(int threads) {
|
||||
if (threads < 1) {
|
||||
throw new IllegalArgumentException("threads must be >= 1, got " + threads);
|
||||
}
|
||||
this.threads = Math.min(threads, Runtime.getRuntime().availableProcessors());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any output format requires structured content
|
||||
* (reading order, heading levels, list detection, etc.).
|
||||
* Text-only output does not need these expensive processing steps.
|
||||
*/
|
||||
public boolean needsStructuredProcessing() {
|
||||
return isGenerateMarkdown() || isGenerateHtml() || isGenerateJSON() || isGeneratePDF();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves conflicts between individually valid option values.
|
||||
* Call once after all setters, before passing the Config to a processor.
|
||||
* Currently: in hybrid mode, forces {@code threads} to 1 because the hybrid
|
||||
* pipeline runs sequentially regardless of this value.
|
||||
*/
|
||||
public void normalize() {
|
||||
if (isHybridEnabled() && threads > 1) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"--threads={0} ignored in hybrid mode (forcing threads=1); "
|
||||
+ "the hybrid pipeline processes pages sequentially",
|
||||
threads);
|
||||
threads = 1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.api;
|
||||
|
||||
import org.opendataloader.pdf.utils.SanitizationRule;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Configuration class for content filtering options.
|
||||
* Controls filtering of hidden text, out-of-page content, tiny text, and hidden OCGs.
|
||||
*/
|
||||
public class FilterConfig {
|
||||
private boolean filterHiddenText = false;
|
||||
private boolean filterOutOfPage = true;
|
||||
private boolean filterTinyText = true;
|
||||
private boolean filterHiddenOCG = true;
|
||||
private boolean filterSensitiveData = false;
|
||||
private final List<SanitizationRule> filterRules;
|
||||
|
||||
/** Default rules */
|
||||
private void initializeDefaultRules() {
|
||||
filterRules.add(new SanitizationRule(
|
||||
Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"),
|
||||
"email@example.com"
|
||||
));
|
||||
filterRules.add(new SanitizationRule(
|
||||
Pattern.compile("[+]\\d+(?:-\\d+)+"),
|
||||
"+00-0000-0000"
|
||||
));
|
||||
filterRules.add(new SanitizationRule(
|
||||
Pattern.compile("[A-Z]{1,2}\\d{6,9}"),
|
||||
"AA0000000"
|
||||
));
|
||||
filterRules.add(new SanitizationRule(
|
||||
Pattern.compile("\\b\\d{4}-?\\d{4}-?\\d{4}-?\\d{4}\\b"),
|
||||
"0000-0000-0000-0000"
|
||||
));
|
||||
filterRules.add(new SanitizationRule(
|
||||
Pattern.compile("\\b\\d{10,18}\\b"),
|
||||
"0000000000000000"
|
||||
));
|
||||
filterRules.add(new SanitizationRule(
|
||||
Pattern.compile("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b"),
|
||||
"0.0.0.0"
|
||||
));
|
||||
filterRules.add(new SanitizationRule(
|
||||
Pattern.compile("\\b([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\b"),
|
||||
"0.0.0.0::1"
|
||||
));
|
||||
filterRules.add(new SanitizationRule(
|
||||
Pattern.compile("\\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\\b"),
|
||||
"00:00:00:00:00:00"
|
||||
));
|
||||
filterRules.add(new SanitizationRule(
|
||||
Pattern.compile("\\b\\d{15}\\b"),
|
||||
"000000000000000"
|
||||
));
|
||||
filterRules.add(new SanitizationRule(
|
||||
Pattern.compile("https?://[A-Za-z0-9.-]+(:\\d+)?(/\\S*)?"),
|
||||
"https://example.com"
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor initializing the configuration of filter.
|
||||
*/
|
||||
public FilterConfig() {
|
||||
this.filterRules = new ArrayList<>();
|
||||
initializeDefaultRules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables filter of hidden text.
|
||||
*
|
||||
* @param filterHiddenText true to enable filter, false to disable.
|
||||
*/
|
||||
public void setFilterHiddenText(boolean filterHiddenText) {
|
||||
this.filterHiddenText = filterHiddenText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the processor should attempt to find and extract hidden text.
|
||||
*
|
||||
* @return true if hidden text is filtered, false otherwise.
|
||||
*/
|
||||
public boolean isFilterHiddenText() {
|
||||
return filterHiddenText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables checking content that exceeds MediaBox or CropBox.
|
||||
*
|
||||
* @param filterOutOfPage true to enable, false to disable.
|
||||
*/
|
||||
public void setFilterOutOfPage(boolean filterOutOfPage) {
|
||||
this.filterOutOfPage = filterOutOfPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the processor should filter out of page content.
|
||||
*
|
||||
* @return true if filter is enabled, false otherwise.
|
||||
*/
|
||||
public boolean isFilterOutOfPage() {
|
||||
return filterOutOfPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the processor should filter out tiny text.
|
||||
*
|
||||
* @return true if filter is enabled, false otherwise.
|
||||
*/
|
||||
public boolean isFilterTinyText() {
|
||||
return filterTinyText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables filter of tiny text.
|
||||
*
|
||||
* @param filterTinyText true to enable filter, false to disable.
|
||||
*/
|
||||
public void setFilterTinyText(boolean filterTinyText) {
|
||||
this.filterTinyText = filterTinyText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the processor should filter out hidden OCGs.
|
||||
*
|
||||
* @return true if filter is enabled, false otherwise.
|
||||
*/
|
||||
public boolean isFilterHiddenOCG() {
|
||||
return filterHiddenOCG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables filter of hidden OCGs.
|
||||
*
|
||||
* @param filterHiddenOCG true to enable filter, false to disable.
|
||||
*/
|
||||
public void setFilterHiddenOCG(boolean filterHiddenOCG) {
|
||||
this.filterHiddenOCG = filterHiddenOCG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the processor should filter out sensitive data.
|
||||
*
|
||||
* @return true if filter is enabled, false otherwise.
|
||||
*/
|
||||
public boolean isFilterSensitiveData() {
|
||||
return filterSensitiveData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables filter of sensitive data.
|
||||
*
|
||||
* @param filterSensitiveData true to enable filter, false to disable.
|
||||
*/
|
||||
public void setFilterSensitiveData(boolean filterSensitiveData) {
|
||||
this.filterSensitiveData = filterSensitiveData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets custom filter sanitization rules.
|
||||
*
|
||||
* @return List of sanitization rules.
|
||||
*/
|
||||
public List<SanitizationRule> getFilterRules() {
|
||||
return filterRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom filter sanitization rule.
|
||||
*
|
||||
* @param pattern pattern string.
|
||||
* @param replacement pattern replacement string.
|
||||
*/
|
||||
public void addFilterRule(String pattern, String replacement) {
|
||||
filterRules.add(new SanitizationRule(Pattern.compile(pattern), replacement));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove filter sanitization rule.
|
||||
*
|
||||
* @param pattern pattern string.
|
||||
*/
|
||||
public void removeFilterRule(String pattern) {
|
||||
filterRules.removeIf(rule -> rule.getPattern().pattern().equals(pattern));
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.api;
|
||||
|
||||
import org.opendataloader.pdf.hybrid.HybridClientFactory;
|
||||
import org.opendataloader.pdf.processors.DocumentProcessor;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* The main entry point for the opendataloader-pdf library.
|
||||
* Use the static method {@link #processFile(String, Config)} to process a PDF.
|
||||
*/
|
||||
public final class OpenDataLoaderPDF {
|
||||
|
||||
private OpenDataLoaderPDF() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a PDF file to extract its content and structure based on the provided configuration.
|
||||
*
|
||||
* @param inputPdfName The path to the input PDF file.
|
||||
* @param config The configuration object specifying output formats and other options.
|
||||
* @throws IOException If an error occurs during file reading or processing.
|
||||
*/
|
||||
public static void processFile(String inputPdfName, Config config) throws IOException {
|
||||
DocumentProcessor.processFile(inputPdfName, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts down any cached resources used by the library.
|
||||
*
|
||||
* <p>This method should be called when processing is complete, typically at CLI exit.
|
||||
* It releases resources such as HTTP client thread pools used for hybrid mode backends.
|
||||
*/
|
||||
public static void shutdown() {
|
||||
HybridClientFactory.shutdown();
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.api;
|
||||
|
||||
import org.opendataloader.pdf.processors.DocumentProcessor;
|
||||
import org.opendataloader.pdf.processors.ExtractionResult;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Writes configured output files (JSON, Markdown, HTML, PDF, text, images,
|
||||
* tagged PDF) from a pre-computed {@link ExtractionResult}.
|
||||
*
|
||||
* <p>Use this when you have already run extraction once (e.g. via
|
||||
* {@link AutoTagger#tag(ExtractionResult, Float)}) and want to emit file
|
||||
* outputs from that same result without re-extracting.
|
||||
*
|
||||
* <p>Typical two-phase usage:
|
||||
* <pre>{@code
|
||||
* Config config = new Config();
|
||||
* config.setOutputFolder("/out");
|
||||
* config.setGenerateJSON(true);
|
||||
* config.setGenerateMarkdown(true);
|
||||
*
|
||||
* // Phase 1: extract once
|
||||
* ExtractionResult extraction =
|
||||
* org.opendataloader.pdf.processors.DocumentProcessor.extractContents(
|
||||
* "input.pdf", config);
|
||||
*
|
||||
* // Phase 2a: write output files
|
||||
* OutputWriter.writeOutputs("input.pdf", extraction, config);
|
||||
*
|
||||
* // Phase 2b: tag in-memory and reuse the same extraction
|
||||
* try (TaggingResult tagged = AutoTagger.tag("input.pdf", extraction)) {
|
||||
* // ... use tagged.getDocument()
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>For the single-call extraction-and-output pipeline, use
|
||||
* {@link OpenDataLoaderPDF#processFile} instead.
|
||||
*/
|
||||
public final class OutputWriter {
|
||||
|
||||
private OutputWriter() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the output files configured on {@code config} (e.g.
|
||||
* {@code generateJSON}, {@code generateMarkdown}, {@code generateHtml},
|
||||
* {@code generatePDF}, {@code generateTaggedPDF}, {@code generateText})
|
||||
* using the supplied pre-computed extraction.
|
||||
*
|
||||
* <p>This method does <em>not</em> re-run extraction. Output behaviour is
|
||||
* identical to {@link OpenDataLoaderPDF#processFile} for the same
|
||||
* {@link Config}, including stdout mode, image directory resolution, and
|
||||
* tagged-PDF generation.
|
||||
*
|
||||
* @param inputPdfName path to the input PDF file (used for filename derivation
|
||||
* and tagged-PDF / annotated-PDF re-saves; not re-parsed)
|
||||
* @param extraction pre-computed extraction result (from
|
||||
* {@code DocumentProcessor.extractContents})
|
||||
* @param config configuration controlling which output formats to emit
|
||||
* @throws IOException if writing any output file fails
|
||||
*/
|
||||
public static void writeOutputs(String inputPdfName, ExtractionResult extraction, Config config)
|
||||
throws IOException {
|
||||
DocumentProcessor.generateOutputs(inputPdfName, extraction.getContents(), config,
|
||||
extraction.getElementMetadata());
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package org.opendataloader.pdf.api;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.opendataloader.pdf.hybrid.ElementMetadata;
|
||||
import org.verapdf.pd.PDDocument;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Result of {@link AutoTagger#tag}. Contains the tagged PDF document in-memory
|
||||
* and processing timing metadata.
|
||||
*
|
||||
* <p>Implements {@link AutoCloseable} for use with try-with-resources.
|
||||
* The caller is responsible for closing this result, which releases the
|
||||
* underlying PDDocument resources.
|
||||
*/
|
||||
public class TaggingResult implements AutoCloseable {
|
||||
|
||||
private final PDDocument document;
|
||||
private final long extractionNs;
|
||||
private final long taggingNs;
|
||||
private final JsonNode hybridTimings;
|
||||
private final Map<Long, ElementMetadata> elementMetadata;
|
||||
|
||||
public TaggingResult(PDDocument document, long extractionNs, long taggingNs,
|
||||
JsonNode hybridTimings, Map<Long, ElementMetadata> elementMetadata) {
|
||||
if (document == null) {
|
||||
throw new IllegalArgumentException("document must not be null");
|
||||
}
|
||||
this.document = document;
|
||||
this.extractionNs = extractionNs;
|
||||
this.taggingNs = taggingNs;
|
||||
this.hybridTimings = hybridTimings;
|
||||
this.elementMetadata = elementMetadata != null ? elementMetadata : Collections.emptyMap();
|
||||
}
|
||||
|
||||
public TaggingResult(PDDocument document, long extractionNs, long taggingNs, JsonNode hybridTimings) {
|
||||
this(document, extractionNs, taggingNs, hybridTimings, Collections.emptyMap());
|
||||
}
|
||||
|
||||
/** The tagged PDF document. Do not close this directly — close the TaggingResult instead. */
|
||||
public PDDocument getDocument() {
|
||||
return document;
|
||||
}
|
||||
|
||||
/** Time spent on extraction (parsing + layout + content extraction) in nanoseconds. */
|
||||
public long getExtractionNs() {
|
||||
return extractionNs;
|
||||
}
|
||||
|
||||
/** Time spent on auto-tagging (structure tree creation) in nanoseconds. */
|
||||
public long getTaggingNs() {
|
||||
return taggingNs;
|
||||
}
|
||||
|
||||
/** Per-step hybrid server timings, or null if hybrid mode was not used. */
|
||||
public JsonNode getHybridTimings() {
|
||||
return hybridTimings;
|
||||
}
|
||||
|
||||
/** Element metadata from hybrid backend, or empty map if not available. */
|
||||
public Map<Long, ElementMetadata> getElementMetadata() {
|
||||
return elementMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the tagged PDF to a file.
|
||||
*
|
||||
* @param outputPath the output file path
|
||||
*/
|
||||
public void saveTo(String outputPath) throws IOException {
|
||||
document.saveAs(outputPath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (document != null) {
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+819
@@ -0,0 +1,819 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.api.cli;
|
||||
|
||||
import org.apache.commons.cli.CommandLine;
|
||||
import org.apache.commons.cli.Option;
|
||||
import org.apache.commons.cli.Options;
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
import org.opendataloader.pdf.hybrid.HybridConfig;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.PrintStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adapter that maps Apache Commons CLI options to {@link Config} / {@link HybridConfig}.
|
||||
*
|
||||
* <p><b>Stable API for downstream tools</b> (e.g. opendataloader-pdfua) — these four
|
||||
* members are the supported integration surface and will not break compatibly:
|
||||
* <ul>
|
||||
* <li>{@link #defineOptions()} — get a fully populated {@code Options} instance</li>
|
||||
* <li>{@link #addAllTo(Options)} — add core options into an externally-built {@code Options}</li>
|
||||
* <li>{@link #applyAllTo(Config, CommandLine)} — populate a {@code Config} from a parsed line</li>
|
||||
* <li>{@link #FOLDER_OPTION} — short option name for {@code --output-dir}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Everything else is internal.</b> The numerous other public {@code static} members
|
||||
* (option-name constants, helpers like {@code createConfigFromCommandLine},
|
||||
* {@code exportOptionsAsJson}) exist for the CLI module ({@code CLIMain}) and the
|
||||
* options-export tooling that drives Node/Python binding generation. Their visibility
|
||||
* is {@code public} only for cross-package access within this codebase; they are
|
||||
* <i>not</i> part of the supported API and may be renamed, moved, or removed in any
|
||||
* release. Downstream consumers depending on them do so at their own risk.
|
||||
*
|
||||
* <p>Pdfua's usage pattern (build your own {@code Options}, add core's, parse, then
|
||||
* populate {@code Config}):
|
||||
* <pre>{@code
|
||||
* Options options = new Options();
|
||||
* options.addOption(...); // your tool-specific options
|
||||
* CLIOptions.addAllTo(options); // add core's options
|
||||
* CommandLine cmd = parser.parse(options, args);
|
||||
* Config core = new Config();
|
||||
* CLIOptions.applyAllTo(core, cmd);
|
||||
* }</pre>
|
||||
*/
|
||||
public class CLIOptions {
|
||||
|
||||
// ===== Output Directory =====
|
||||
public static final String FOLDER_OPTION = "o";
|
||||
private static final String FOLDER_LONG_OPTION = "output-dir";
|
||||
private static final String FOLDER_DESC = "Directory where output files are written. Default: input file directory";
|
||||
|
||||
// ===== Password =====
|
||||
public static final String PASSWORD_OPTION = "p";
|
||||
private static final String PASSWORD_LONG_OPTION = "password";
|
||||
private static final String PASSWORD_DESC = "Password for encrypted PDF files";
|
||||
|
||||
// ===== Format =====
|
||||
public static final String FORMAT_OPTION = "f";
|
||||
public static final String FORMAT_LONG_OPTION = "format";
|
||||
private static final String FORMAT_DESC = "Output formats (comma-separated). "
|
||||
+ "Values: json, text, html, pdf, markdown, tagged-pdf. Default: json. "
|
||||
+ "For HTML inside Markdown use --markdown-with-html. "
|
||||
+ "For image extraction control use --image-output.";
|
||||
|
||||
// ===== Quiet =====
|
||||
public static final String QUIET_OPTION = "q";
|
||||
private static final String QUIET_LONG_OPTION = "quiet";
|
||||
private static final String QUIET_DESC = "Suppress console logging output";
|
||||
|
||||
// ===== Content Safety =====
|
||||
private static final String CONTENT_SAFETY_OFF_LONG_OPTION = "content-safety-off";
|
||||
private static final String CONTENT_SAFETY_OFF_DESC = "Disable content safety filters. "
|
||||
+ "Values: all, hidden-text, off-page, tiny, hidden-ocg";
|
||||
|
||||
// ===== Sanitize =====
|
||||
private static final String SANITIZE_LONG_OPTION = "sanitize";
|
||||
private static final String SANITIZE_DESC = "Enable sensitive data sanitization. "
|
||||
+ "Replaces emails, phone numbers, IPs, credit cards, and URLs with placeholders";
|
||||
|
||||
// ===== Keep Line Breaks =====
|
||||
private static final String KEEP_LINE_BREAKS_LONG_OPTION = "keep-line-breaks";
|
||||
private static final String KEEP_LINE_BREAKS_DESC = "Preserve original line breaks in extracted text";
|
||||
|
||||
// ===== Replace Invalid Chars =====
|
||||
private static final String REPLACE_INVALID_CHARS_LONG_OPTION = "replace-invalid-chars";
|
||||
private static final String REPLACE_INVALID_CHARS_DESC = "Replacement character for invalid/unrecognized characters. Default: space";
|
||||
|
||||
// ===== Use Struct Tree =====
|
||||
private static final String USE_STRUCT_TREE_LONG_OPTION = "use-struct-tree";
|
||||
private static final String USE_STRUCT_TREE_DESC = "Use PDF structure tree (tagged PDF) for reading order and semantic structure. Output quality depends on tag quality";
|
||||
|
||||
// ===== Table Method =====
|
||||
private static final String TABLE_METHOD_LONG_OPTION = "table-method";
|
||||
private static final String TABLE_METHOD_DESC = "Table detection method. Values: default (border-based), cluster (border + cluster). Default: default";
|
||||
|
||||
// ===== Reading Order =====
|
||||
private static final String READING_ORDER_LONG_OPTION = "reading-order";
|
||||
private static final String READING_ORDER_DESC = "Reading order algorithm. Values: off, xycut. Default: xycut";
|
||||
|
||||
// ===== Page Separators =====
|
||||
private static final String MARKDOWN_PAGE_SEPARATOR_LONG_OPTION = "markdown-page-separator";
|
||||
private static final String MARKDOWN_PAGE_SEPARATOR_DESC = "Separator between pages in Markdown output. Use %page-number% for page numbers. Default: none";
|
||||
|
||||
private static final String TEXT_PAGE_SEPARATOR_LONG_OPTION = "text-page-separator";
|
||||
private static final String TEXT_PAGE_SEPARATOR_DESC = "Separator between pages in text output. Use %page-number% for page numbers. Default: none";
|
||||
|
||||
private static final String HTML_PAGE_SEPARATOR_LONG_OPTION = "html-page-separator";
|
||||
private static final String HTML_PAGE_SEPARATOR_DESC = "Separator between pages in HTML output. Use %page-number% for page numbers. Default: none";
|
||||
|
||||
// ===== Image Options =====
|
||||
private static final String IMAGE_OUTPUT_LONG_OPTION = "image-output";
|
||||
private static final String IMAGE_OUTPUT_DESC = "Image output mode. Values: off (no images), embedded (Base64 data URIs), external (file references). Default: external";
|
||||
|
||||
private static final String IMAGE_FORMAT_LONG_OPTION = "image-format";
|
||||
private static final String IMAGE_FORMAT_DESC = "Output format for extracted images. Values: png, jpeg. Default: png";
|
||||
|
||||
private static final String IMAGE_DIR_LONG_OPTION = "image-dir";
|
||||
private static final String IMAGE_DIR_DESC = "Directory for extracted images (applies only with --image-output external)";
|
||||
|
||||
// ===== Pages =====
|
||||
private static final String PAGES_LONG_OPTION = "pages";
|
||||
private static final String PAGES_DESC = "Pages to extract (e.g., \"1,3,5-7\"). Default: all pages";
|
||||
|
||||
// ===== Include Header Footer =====
|
||||
private static final String INCLUDE_HEADER_FOOTER_LONG_OPTION = "include-header-footer";
|
||||
private static final String INCLUDE_HEADER_FOOTER_DESC = "Include page headers and footers in output";
|
||||
|
||||
// ===== Detect Strikethrough =====
|
||||
private static final String DETECT_STRIKETHROUGH_LONG_OPTION = "detect-strikethrough";
|
||||
private static final String DETECT_STRIKETHROUGH_DESC = "Detect strikethrough text and wrap with ~~ in Markdown output or <del></del> tag in HTML output (experimental)";
|
||||
|
||||
// ===== Hybrid Mode =====
|
||||
private static final String HYBRID_LONG_OPTION = "hybrid";
|
||||
private static final String HYBRID_DESC = "Hybrid backend (requires a running server). "
|
||||
+ "Quick start: pip install \"opendataloader-pdf[hybrid]\" && opendataloader-pdf-hybrid --port 5002. "
|
||||
+ "For remote servers use --hybrid-url. Values: off (default), docling-fast, hancom-ai";
|
||||
|
||||
private static final String HYBRID_MODE_LONG_OPTION = "hybrid-mode";
|
||||
private static final String HYBRID_MODE_DESC = "Hybrid triage mode. Values: auto (default, dynamic triage), full (skip triage, all pages to backend)";
|
||||
|
||||
// Deprecated: OCR settings are now configured on the hybrid server
|
||||
private static final String HYBRID_OCR_LONG_OPTION = "hybrid-ocr";
|
||||
private static final String HYBRID_OCR_DESC = "[Deprecated] OCR settings are now configured on the hybrid server (--ocr-lang, --force-ocr)";
|
||||
|
||||
private static final String HYBRID_URL_LONG_OPTION = "hybrid-url";
|
||||
private static final String HYBRID_URL_DESC = "Hybrid backend server URL (overrides default)";
|
||||
|
||||
private static final String HYBRID_TIMEOUT_LONG_OPTION = "hybrid-timeout";
|
||||
private static final String HYBRID_TIMEOUT_DESC = "Hybrid backend request timeout in milliseconds (0 = no timeout). Default: 0";
|
||||
|
||||
private static final String HYBRID_FALLBACK_LONG_OPTION = "hybrid-fallback";
|
||||
private static final String HYBRID_FALLBACK_DESC = "Opt in to Java fallback on hybrid backend error (default: disabled)";
|
||||
|
||||
// ===== Hybrid hancom-ai backend-specific =====
|
||||
private static final String HYBRID_HANCOM_AI_REGIONLIST_STRATEGY_LONG_OPTION =
|
||||
"hybrid-hancom-ai-regionlist-strategy";
|
||||
private static final String HYBRID_HANCOM_AI_REGIONLIST_STRATEGY_DESC =
|
||||
"DLA label 7 (regionlist) handling. Requires --hybrid=hancom-ai. "
|
||||
+ "Values: table-first (default; check TSR overlap), list-only (skip TSR, always treat as list)";
|
||||
|
||||
private static final String HYBRID_HANCOM_AI_OCR_STRATEGY_LONG_OPTION =
|
||||
"hybrid-hancom-ai-ocr-strategy";
|
||||
private static final String HYBRID_HANCOM_AI_OCR_STRATEGY_DESC =
|
||||
"OCR strategy. Requires --hybrid=hancom-ai. "
|
||||
+ "Values: off (stream-only), auto (default; stream first, OCR fallback), force (OCR-only)";
|
||||
|
||||
private static final String HYBRID_HANCOM_AI_IMAGE_CACHE_LONG_OPTION =
|
||||
"hybrid-hancom-ai-image-cache";
|
||||
private static final String HYBRID_HANCOM_AI_IMAGE_CACHE_DESC =
|
||||
"Page image cache backing. Requires --hybrid=hancom-ai. "
|
||||
+ "Values: memory (default), disk";
|
||||
|
||||
private static final String HYBRID_HANCOM_AI_SAVE_CROPS_LONG_OPTION =
|
||||
"hybrid-hancom-ai-save-crops";
|
||||
private static final String HYBRID_HANCOM_AI_SAVE_CROPS_DESC =
|
||||
"Persist cropped figure images to disk for debugging. Requires --hybrid=hancom-ai";
|
||||
|
||||
private static final String HYBRID_HANCOM_AI_CROP_OUTPUT_DIR_LONG_OPTION =
|
||||
"hybrid-hancom-ai-crop-output-dir";
|
||||
private static final String HYBRID_HANCOM_AI_CROP_OUTPUT_DIR_DESC =
|
||||
"Output directory for --hybrid-hancom-ai-save-crops. Requires --hybrid=hancom-ai";
|
||||
|
||||
// ===== Stdout Output =====
|
||||
private static final String TO_STDOUT_LONG_OPTION = "to-stdout";
|
||||
private static final String TO_STDOUT_DESC = "Write output to stdout instead of file (single format only)";
|
||||
|
||||
// ===== Threads =====
|
||||
private static final String THREADS_LONG_OPTION = "threads";
|
||||
private static final String THREADS_DESC = "Number of worker threads for per-page processing. "
|
||||
+ "Default: 1 (sequential, stable). Values >1 (experimental) run pages in parallel for faster throughput; "
|
||||
+ "output may vary slightly on some PDFs. Capped at the number of available CPU cores. "
|
||||
+ "Applies to the native Java pipeline only; ignored in --hybrid mode";
|
||||
|
||||
// ===== Markdown modifiers =====
|
||||
public static final String HTML_IN_MARKDOWN_LONG_OPTION = "markdown-with-html";
|
||||
private static final String HTML_IN_MARKDOWN_DESC =
|
||||
"Allow HTML tags inside Markdown output for complex structures such as multi-row-span tables. "
|
||||
+ "Implies --format markdown.";
|
||||
|
||||
// ===== Export Options (internal) =====
|
||||
public static final String EXPORT_OPTIONS_LONG_OPTION = "export-options";
|
||||
|
||||
// ===== Legacy Options (hidden, backward compatibility) =====
|
||||
public static final String PDF_REPORT_LONG_OPTION = "pdf";
|
||||
public static final String MARKDOWN_REPORT_LONG_OPTION = "markdown";
|
||||
public static final String HTML_REPORT_LONG_OPTION = "html";
|
||||
private static final String MARKDOWN_IMAGE_LONG_OPTION = "markdown-with-images";
|
||||
public static final String NO_JSON_REPORT_LONG_OPTION = "no-json";
|
||||
|
||||
/**
|
||||
* Single source of truth for all CLI option definitions.
|
||||
* Add new options here - they will automatically be available in both CLI and
|
||||
* JSON export.
|
||||
*/
|
||||
private static final List<OptionDefinition> OPTION_DEFINITIONS = Arrays.asList(
|
||||
// Primary options (exported to JSON)
|
||||
new OptionDefinition(FOLDER_LONG_OPTION, FOLDER_OPTION, "string", null, FOLDER_DESC, true),
|
||||
new OptionDefinition(PASSWORD_LONG_OPTION, PASSWORD_OPTION, "string", null, PASSWORD_DESC, true),
|
||||
new OptionDefinition(FORMAT_LONG_OPTION, FORMAT_OPTION, "string", null, FORMAT_DESC, true),
|
||||
new OptionDefinition(QUIET_LONG_OPTION, QUIET_OPTION, "boolean", false, QUIET_DESC, true),
|
||||
new OptionDefinition(CONTENT_SAFETY_OFF_LONG_OPTION, null, "string", null, CONTENT_SAFETY_OFF_DESC, true),
|
||||
new OptionDefinition(SANITIZE_LONG_OPTION, null, "boolean", false, SANITIZE_DESC, true),
|
||||
new OptionDefinition(KEEP_LINE_BREAKS_LONG_OPTION, null, "boolean", false, KEEP_LINE_BREAKS_DESC, true),
|
||||
new OptionDefinition(REPLACE_INVALID_CHARS_LONG_OPTION, null, "string", " ", REPLACE_INVALID_CHARS_DESC,
|
||||
true),
|
||||
new OptionDefinition(USE_STRUCT_TREE_LONG_OPTION, null, "boolean", false, USE_STRUCT_TREE_DESC, true),
|
||||
new OptionDefinition(TABLE_METHOD_LONG_OPTION, null, "string", "default", TABLE_METHOD_DESC, true),
|
||||
new OptionDefinition(READING_ORDER_LONG_OPTION, null, "string", "xycut", READING_ORDER_DESC, true),
|
||||
new OptionDefinition(MARKDOWN_PAGE_SEPARATOR_LONG_OPTION, null, "string", null,
|
||||
MARKDOWN_PAGE_SEPARATOR_DESC, true),
|
||||
new OptionDefinition(HTML_IN_MARKDOWN_LONG_OPTION, null, "boolean", false,
|
||||
HTML_IN_MARKDOWN_DESC, true),
|
||||
new OptionDefinition(TEXT_PAGE_SEPARATOR_LONG_OPTION, null, "string", null, TEXT_PAGE_SEPARATOR_DESC, true),
|
||||
new OptionDefinition(HTML_PAGE_SEPARATOR_LONG_OPTION, null, "string", null, HTML_PAGE_SEPARATOR_DESC, true),
|
||||
new OptionDefinition(IMAGE_OUTPUT_LONG_OPTION, null, "string", "external", IMAGE_OUTPUT_DESC, true),
|
||||
new OptionDefinition(IMAGE_FORMAT_LONG_OPTION, null, "string", "png", IMAGE_FORMAT_DESC, true),
|
||||
new OptionDefinition(IMAGE_DIR_LONG_OPTION, null, "string", null, IMAGE_DIR_DESC, true),
|
||||
new OptionDefinition(PAGES_LONG_OPTION, null, "string", null, PAGES_DESC, true),
|
||||
new OptionDefinition(INCLUDE_HEADER_FOOTER_LONG_OPTION, null, "boolean", false,
|
||||
INCLUDE_HEADER_FOOTER_DESC, true),
|
||||
new OptionDefinition(DETECT_STRIKETHROUGH_LONG_OPTION, null, "boolean", false,
|
||||
DETECT_STRIKETHROUGH_DESC, true),
|
||||
new OptionDefinition(HYBRID_LONG_OPTION, null, "string", "off", HYBRID_DESC, true),
|
||||
new OptionDefinition(HYBRID_MODE_LONG_OPTION, null, "string", "auto", HYBRID_MODE_DESC, true),
|
||||
new OptionDefinition(HYBRID_URL_LONG_OPTION, null, "string", null, HYBRID_URL_DESC, true),
|
||||
new OptionDefinition(HYBRID_TIMEOUT_LONG_OPTION, null, "string", "0", HYBRID_TIMEOUT_DESC, true),
|
||||
new OptionDefinition(HYBRID_FALLBACK_LONG_OPTION, null, "boolean", false, HYBRID_FALLBACK_DESC, true),
|
||||
new OptionDefinition(HYBRID_HANCOM_AI_REGIONLIST_STRATEGY_LONG_OPTION, null, "string",
|
||||
"table-first", HYBRID_HANCOM_AI_REGIONLIST_STRATEGY_DESC, true),
|
||||
new OptionDefinition(HYBRID_HANCOM_AI_OCR_STRATEGY_LONG_OPTION, null, "string",
|
||||
"auto", HYBRID_HANCOM_AI_OCR_STRATEGY_DESC, true),
|
||||
new OptionDefinition(HYBRID_HANCOM_AI_IMAGE_CACHE_LONG_OPTION, null, "string",
|
||||
"memory", HYBRID_HANCOM_AI_IMAGE_CACHE_DESC, true),
|
||||
new OptionDefinition(TO_STDOUT_LONG_OPTION, null, "boolean", false, TO_STDOUT_DESC, true),
|
||||
new OptionDefinition(THREADS_LONG_OPTION, null, "string", "1", THREADS_DESC, true),
|
||||
new OptionDefinition(EXPORT_OPTIONS_LONG_OPTION, null, "boolean", null, null, false),
|
||||
|
||||
// Legacy options (not exported, for backward compatibility)
|
||||
new OptionDefinition(HYBRID_OCR_LONG_OPTION, null, "string", null, HYBRID_OCR_DESC, false),
|
||||
new OptionDefinition(PDF_REPORT_LONG_OPTION, null, "boolean", null, null, false),
|
||||
new OptionDefinition(MARKDOWN_REPORT_LONG_OPTION, null, "boolean", null, null, false),
|
||||
new OptionDefinition(HTML_REPORT_LONG_OPTION, null, "boolean", null, null, false),
|
||||
new OptionDefinition(MARKDOWN_IMAGE_LONG_OPTION, null, "boolean", null, null, false),
|
||||
new OptionDefinition(NO_JSON_REPORT_LONG_OPTION, null, "boolean", null, null, false),
|
||||
new OptionDefinition(HYBRID_HANCOM_AI_SAVE_CROPS_LONG_OPTION, null, "boolean",
|
||||
false, HYBRID_HANCOM_AI_SAVE_CROPS_DESC, false),
|
||||
new OptionDefinition(HYBRID_HANCOM_AI_CROP_OUTPUT_DIR_LONG_OPTION, null, "string",
|
||||
null, HYBRID_HANCOM_AI_CROP_OUTPUT_DIR_DESC, false));
|
||||
|
||||
public static Options defineOptions() {
|
||||
Options options = new Options();
|
||||
addAllTo(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers every core CLI option onto an external {@link Options} instance.
|
||||
* Used by downstream CLIs (e.g. opendataloader-pdfua) that want to inherit
|
||||
* the entire core option set and add their own options on top.
|
||||
*
|
||||
* @param options the Options instance to populate
|
||||
*/
|
||||
public static void addAllTo(Options options) {
|
||||
for (OptionDefinition def : OPTION_DEFINITIONS) {
|
||||
options.addOption(def.toOption());
|
||||
}
|
||||
}
|
||||
|
||||
public static Config createConfigFromCommandLine(CommandLine commandLine) {
|
||||
Config config = new Config();
|
||||
if (commandLine.hasOption(CLIOptions.FOLDER_OPTION)) {
|
||||
config.setOutputFolder(commandLine.getOptionValue(CLIOptions.FOLDER_OPTION));
|
||||
} else {
|
||||
String argument = commandLine.getArgs()[0];
|
||||
File file = new File(argument);
|
||||
file = new File(file.getAbsolutePath());
|
||||
config.setOutputFolder(file.isDirectory() ? file.getAbsolutePath() : file.getParent());
|
||||
}
|
||||
applyAllTo(config, commandLine);
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies every core CLI option from the parsed command line onto the given Config.
|
||||
* Caller is responsible for setting required Config state that is not represented
|
||||
* by a CLI option (e.g. output folder when no positional input file is provided).
|
||||
*
|
||||
* Used by downstream CLIs that build their own Options + Config and want core
|
||||
* options applied without paying for the positional-arg-based output-folder
|
||||
* fallback that {@link #createConfigFromCommandLine} performs.
|
||||
*
|
||||
* @param config Config to populate
|
||||
* @param commandLine parsed CommandLine
|
||||
*/
|
||||
public static void applyAllTo(Config config, CommandLine commandLine) {
|
||||
if (commandLine.hasOption(CLIOptions.PASSWORD_OPTION)) {
|
||||
config.setPassword(commandLine.getOptionValue(CLIOptions.PASSWORD_OPTION));
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.KEEP_LINE_BREAKS_LONG_OPTION)) {
|
||||
config.setKeepLineBreaks(true);
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.PDF_REPORT_LONG_OPTION)) {
|
||||
config.setGeneratePDF(true);
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.MARKDOWN_REPORT_LONG_OPTION)) {
|
||||
config.setGenerateMarkdown(true);
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.HTML_REPORT_LONG_OPTION)) {
|
||||
config.setGenerateHtml(true);
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.HTML_IN_MARKDOWN_LONG_OPTION)) {
|
||||
config.setUseHTMLInMarkdown(true);
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.MARKDOWN_IMAGE_LONG_OPTION)) {
|
||||
config.setGenerateMarkdown(true);
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.NO_JSON_REPORT_LONG_OPTION)) {
|
||||
config.setGenerateJSON(false);
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.REPLACE_INVALID_CHARS_LONG_OPTION)) {
|
||||
config.setReplaceInvalidChars(commandLine.getOptionValue(CLIOptions.REPLACE_INVALID_CHARS_LONG_OPTION));
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.USE_STRUCT_TREE_LONG_OPTION)) {
|
||||
config.setUseStructTree(true);
|
||||
}
|
||||
if (commandLine.hasOption(INCLUDE_HEADER_FOOTER_LONG_OPTION)) {
|
||||
config.setIncludeHeaderFooter(true);
|
||||
}
|
||||
if (commandLine.hasOption(DETECT_STRIKETHROUGH_LONG_OPTION)) {
|
||||
config.setDetectStrikethrough(true);
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.READING_ORDER_LONG_OPTION)) {
|
||||
config.setReadingOrder(commandLine.getOptionValue(CLIOptions.READING_ORDER_LONG_OPTION));
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.MARKDOWN_PAGE_SEPARATOR_LONG_OPTION)) {
|
||||
config.setMarkdownPageSeparator(commandLine.getOptionValue(CLIOptions.MARKDOWN_PAGE_SEPARATOR_LONG_OPTION));
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.TEXT_PAGE_SEPARATOR_LONG_OPTION)) {
|
||||
config.setTextPageSeparator(commandLine.getOptionValue(CLIOptions.TEXT_PAGE_SEPARATOR_LONG_OPTION));
|
||||
}
|
||||
if (commandLine.hasOption(CLIOptions.HTML_PAGE_SEPARATOR_LONG_OPTION)) {
|
||||
config.setHtmlPageSeparator(commandLine.getOptionValue(CLIOptions.HTML_PAGE_SEPARATOR_LONG_OPTION));
|
||||
}
|
||||
applyContentSafetyOption(config, commandLine);
|
||||
applySanitizeOption(config, commandLine);
|
||||
applyFormatOption(config, commandLine);
|
||||
applyTableMethodOption(config, commandLine);
|
||||
applyImageOptions(config, commandLine);
|
||||
applyPagesOption(config, commandLine);
|
||||
applyHybridOptions(config, commandLine);
|
||||
applyThreadsOption(config, commandLine);
|
||||
config.normalize();
|
||||
}
|
||||
|
||||
private static void applyThreadsOption(Config config, CommandLine commandLine) {
|
||||
if (!commandLine.hasOption(THREADS_LONG_OPTION)) {
|
||||
return;
|
||||
}
|
||||
String value = commandLine.getOptionValue(THREADS_LONG_OPTION);
|
||||
int requested;
|
||||
try {
|
||||
requested = Integer.parseInt(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Option --threads requires an integer >= 1, got '%s'", value));
|
||||
}
|
||||
if (requested < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Option --threads requires an integer >= 1, got %d", requested));
|
||||
}
|
||||
config.setThreads(requested);
|
||||
int applied = config.getThreads();
|
||||
if (applied < requested) {
|
||||
System.err.println(String.format(
|
||||
"Warning: --threads=%d exceeds available CPU cores; capped to %d.",
|
||||
requested, applied));
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyImageOptions(Config config, CommandLine commandLine) {
|
||||
if (commandLine.hasOption(IMAGE_OUTPUT_LONG_OPTION)) {
|
||||
String outputValue = commandLine.getOptionValue(IMAGE_OUTPUT_LONG_OPTION);
|
||||
if (outputValue == null || outputValue.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Option --image-output requires a value. Supported values: %s",
|
||||
Config.getImageOutputOptions(", ")));
|
||||
}
|
||||
String output = outputValue.trim().toLowerCase(Locale.ROOT);
|
||||
if (!Config.isValidImageOutput(output)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unsupported image output mode '%s'. Supported values: %s",
|
||||
output, Config.getImageOutputOptions(", ")));
|
||||
}
|
||||
config.setImageOutput(output);
|
||||
}
|
||||
if (commandLine.hasOption(IMAGE_FORMAT_LONG_OPTION)) {
|
||||
String formatValue = commandLine.getOptionValue(IMAGE_FORMAT_LONG_OPTION);
|
||||
if (formatValue == null || formatValue.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Option --image-format requires a value. Supported values: png, jpeg");
|
||||
}
|
||||
String format = formatValue.trim().toLowerCase(Locale.ROOT);
|
||||
if (!Config.isValidImageFormat(format)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unsupported image format '%s'. Supported values: png, jpeg", format));
|
||||
}
|
||||
config.setImageFormat(format);
|
||||
}
|
||||
if (commandLine.hasOption(IMAGE_DIR_LONG_OPTION)) {
|
||||
config.setImageDir(commandLine.getOptionValue(IMAGE_DIR_LONG_OPTION));
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyPagesOption(Config config, CommandLine commandLine) {
|
||||
if (commandLine.hasOption(PAGES_LONG_OPTION)) {
|
||||
config.setPages(commandLine.getOptionValue(PAGES_LONG_OPTION));
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyTableMethodOption(Config config, CommandLine commandLine) {
|
||||
if (commandLine.hasOption(TABLE_METHOD_LONG_OPTION)) {
|
||||
String methodValue = commandLine.getOptionValue(TABLE_METHOD_LONG_OPTION);
|
||||
if (methodValue == null || methodValue.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Option --table-method requires a value. Supported values: %s",
|
||||
Config.getTableMethodOptions(", ")));
|
||||
}
|
||||
String method = methodValue.trim().toLowerCase(Locale.ROOT);
|
||||
if (!Config.isValidTableMethod(method)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unsupported table method '%s'. Supported values: %s",
|
||||
method, Config.getTableMethodOptions(", ")));
|
||||
}
|
||||
config.setTableMethod(method);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyContentSafetyOption(Config config, CommandLine commandLine) {
|
||||
if (!commandLine.hasOption(CONTENT_SAFETY_OFF_LONG_OPTION)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String[] optionValues = commandLine.getOptionValues(CONTENT_SAFETY_OFF_LONG_OPTION);
|
||||
if (optionValues == null || optionValues.length == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Option --content-safety-off requires at least one value. Supported values: all, hidden-text, off-page, tiny, hidden-ocg");
|
||||
}
|
||||
|
||||
Set<String> values = parseOptionValues(optionValues);
|
||||
if (values.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Option --content-safety-off requires at least one value. Supported values: all, hidden-text, off-page, tiny, hidden-ocg");
|
||||
}
|
||||
|
||||
for (String value : values) {
|
||||
switch (value) {
|
||||
case "hidden-text":
|
||||
config.getFilterConfig().setFilterHiddenText(false);
|
||||
break;
|
||||
case "off-page":
|
||||
config.getFilterConfig().setFilterOutOfPage(false);
|
||||
break;
|
||||
case "tiny":
|
||||
config.getFilterConfig().setFilterTinyText(false);
|
||||
break;
|
||||
case "hidden-ocg":
|
||||
config.getFilterConfig().setFilterHiddenOCG(false);
|
||||
break;
|
||||
case "sensitive-data":
|
||||
System.err.println("Warning: '--content-safety-off sensitive-data' is deprecated and has no effect. "
|
||||
+ "Sensitive data sanitization is now opt-in. "
|
||||
+ "Use '--sanitize' to enable masking.");
|
||||
break;
|
||||
case "all":
|
||||
config.getFilterConfig().setFilterHiddenText(false);
|
||||
config.getFilterConfig().setFilterOutOfPage(false);
|
||||
config.getFilterConfig().setFilterTinyText(false);
|
||||
config.getFilterConfig().setFilterHiddenOCG(false);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Unsupported value '%s'. Supported values: all, hidden-text, off-page, tiny, hidden-ocg",
|
||||
value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void applySanitizeOption(Config config, CommandLine commandLine) {
|
||||
if (commandLine.hasOption(SANITIZE_LONG_OPTION)) {
|
||||
config.getFilterConfig().setFilterSensitiveData(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyFormatOption(Config config, CommandLine commandLine) {
|
||||
if (!commandLine.hasOption(FORMAT_OPTION)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String[] optionValues = commandLine.getOptionValues(FORMAT_OPTION);
|
||||
if (optionValues == null || optionValues.length == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Option --format requires at least one value. Supported values: json, text, html, pdf, markdown, tagged-pdf");
|
||||
}
|
||||
|
||||
Set<String> values = parseOptionValues(optionValues);
|
||||
if (values.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Option --format requires at least one value. Supported values: json, text, html, pdf, markdown, tagged-pdf");
|
||||
}
|
||||
|
||||
config.setGenerateJSON(false);
|
||||
|
||||
for (String value : values) {
|
||||
switch (value) {
|
||||
case "json":
|
||||
config.setGenerateJSON(true);
|
||||
break;
|
||||
case "html":
|
||||
config.setGenerateHtml(true);
|
||||
break;
|
||||
case "text":
|
||||
config.setGenerateText(true);
|
||||
break;
|
||||
case "pdf":
|
||||
config.setGeneratePDF(true);
|
||||
break;
|
||||
case "markdown":
|
||||
config.setGenerateMarkdown(true);
|
||||
break;
|
||||
case "markdown-with-html":
|
||||
System.err.println("[WARN] --format markdown-with-html is deprecated and will be removed "
|
||||
+ "in the next major release. Use --format markdown --markdown-with-html instead.");
|
||||
config.setUseHTMLInMarkdown(true);
|
||||
break;
|
||||
case "markdown-with-images":
|
||||
System.err.println("[WARN] --format markdown-with-images is deprecated and will be removed "
|
||||
+ "in the next major release. Use --format markdown with --image-output "
|
||||
+ "(off|embedded|external) instead.");
|
||||
config.setGenerateMarkdown(true);
|
||||
break;
|
||||
case "tagged-pdf":
|
||||
config.setGenerateTaggedPDF(true);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Unsupported format '%s'. Supported values: json, text, html, pdf, markdown, tagged-pdf",
|
||||
value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> parseOptionValues(String[] optionValues) {
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
for (String rawValue : optionValues) {
|
||||
if (rawValue == null) {
|
||||
continue;
|
||||
}
|
||||
String[] splitValues = rawValue.split(",");
|
||||
for (String candidate : splitValues) {
|
||||
String format = candidate.trim().toLowerCase(Locale.ROOT);
|
||||
if (!format.isEmpty()) {
|
||||
values.add(format);
|
||||
}
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static void applyHybridOptions(Config config, CommandLine commandLine) {
|
||||
if (commandLine.hasOption(HYBRID_LONG_OPTION)) {
|
||||
String hybridValue = commandLine.getOptionValue(HYBRID_LONG_OPTION);
|
||||
if (hybridValue == null || hybridValue.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Option --hybrid requires a value. Supported values: %s",
|
||||
Config.getHybridOptions(", ")));
|
||||
}
|
||||
String hybrid = hybridValue.trim().toLowerCase(Locale.ROOT);
|
||||
if (!Config.isValidHybrid(hybrid)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unsupported hybrid backend '%s'. Supported values: %s",
|
||||
hybrid, Config.getHybridOptions(", ")));
|
||||
}
|
||||
config.setHybrid(hybrid);
|
||||
}
|
||||
if (commandLine.hasOption(HYBRID_MODE_LONG_OPTION)) {
|
||||
String modeValue = commandLine.getOptionValue(HYBRID_MODE_LONG_OPTION);
|
||||
if (modeValue == null || modeValue.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Option --hybrid-mode requires a value. Supported values: %s",
|
||||
Config.getHybridModeOptions(", ")));
|
||||
}
|
||||
String mode = modeValue.trim().toLowerCase(Locale.ROOT);
|
||||
if (!Config.isValidHybridMode(mode)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unsupported hybrid mode '%s'. Supported values: %s",
|
||||
mode, Config.getHybridModeOptions(", ")));
|
||||
}
|
||||
config.getHybridConfig().setMode(mode);
|
||||
}
|
||||
if (commandLine.hasOption(HYBRID_OCR_LONG_OPTION)) {
|
||||
// Deprecated: OCR settings are now configured on the hybrid server
|
||||
System.err.println("Warning: --hybrid-ocr is deprecated. "
|
||||
+ "Configure OCR settings on the hybrid server instead (--ocr-lang, --force-ocr).");
|
||||
}
|
||||
if (commandLine.hasOption(HYBRID_URL_LONG_OPTION)) {
|
||||
String url = commandLine.getOptionValue(HYBRID_URL_LONG_OPTION);
|
||||
if (url != null && !url.trim().isEmpty()) {
|
||||
config.getHybridConfig().setUrl(url.trim());
|
||||
}
|
||||
}
|
||||
if (commandLine.hasOption(HYBRID_TIMEOUT_LONG_OPTION)) {
|
||||
String timeoutValue = commandLine.getOptionValue(HYBRID_TIMEOUT_LONG_OPTION);
|
||||
if (timeoutValue != null && !timeoutValue.trim().isEmpty()) {
|
||||
try {
|
||||
int timeout = Integer.parseInt(timeoutValue.trim());
|
||||
config.getHybridConfig().setTimeoutMs(timeout);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Invalid timeout value '%s'. Must be a non-negative integer.", timeoutValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (commandLine.hasOption(HYBRID_FALLBACK_LONG_OPTION)) {
|
||||
config.getHybridConfig().setFallbackToJava(true);
|
||||
}
|
||||
if (commandLine.hasOption(HYBRID_HANCOM_AI_REGIONLIST_STRATEGY_LONG_OPTION)) {
|
||||
String value = commandLine.getOptionValue(HYBRID_HANCOM_AI_REGIONLIST_STRATEGY_LONG_OPTION);
|
||||
if (value != null && !value.trim().isEmpty()) {
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
if (!HybridConfig.REGIONLIST_TABLE_FIRST.equals(normalized)
|
||||
&& !HybridConfig.REGIONLIST_LIST_ONLY.equals(normalized)) {
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Option --%s: unsupported value '%s'. Supported values: %s, %s",
|
||||
HYBRID_HANCOM_AI_REGIONLIST_STRATEGY_LONG_OPTION, normalized,
|
||||
HybridConfig.REGIONLIST_TABLE_FIRST, HybridConfig.REGIONLIST_LIST_ONLY));
|
||||
}
|
||||
config.getHybridConfig().setRegionlistStrategy(normalized);
|
||||
}
|
||||
}
|
||||
if (commandLine.hasOption(HYBRID_HANCOM_AI_OCR_STRATEGY_LONG_OPTION)) {
|
||||
String value = commandLine.getOptionValue(HYBRID_HANCOM_AI_OCR_STRATEGY_LONG_OPTION);
|
||||
if (value != null && !value.trim().isEmpty()) {
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
if (!HybridConfig.OCR_OFF.equals(normalized)
|
||||
&& !HybridConfig.OCR_AUTO.equals(normalized)
|
||||
&& !HybridConfig.OCR_FORCE.equals(normalized)) {
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Option --%s: unsupported value '%s'. Supported values: %s, %s, %s",
|
||||
HYBRID_HANCOM_AI_OCR_STRATEGY_LONG_OPTION, normalized,
|
||||
HybridConfig.OCR_OFF, HybridConfig.OCR_AUTO, HybridConfig.OCR_FORCE));
|
||||
}
|
||||
config.getHybridConfig().setOcrStrategy(normalized);
|
||||
}
|
||||
}
|
||||
if (commandLine.hasOption(HYBRID_HANCOM_AI_IMAGE_CACHE_LONG_OPTION)) {
|
||||
String value = commandLine.getOptionValue(HYBRID_HANCOM_AI_IMAGE_CACHE_LONG_OPTION);
|
||||
if (value != null && !value.trim().isEmpty()) {
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
if (!"memory".equals(normalized) && !"disk".equals(normalized)) {
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Option --%s: unsupported value '%s'. Supported values: memory, disk",
|
||||
HYBRID_HANCOM_AI_IMAGE_CACHE_LONG_OPTION, normalized));
|
||||
}
|
||||
config.getHybridConfig().setImageCache(normalized);
|
||||
}
|
||||
}
|
||||
if (commandLine.hasOption(HYBRID_HANCOM_AI_SAVE_CROPS_LONG_OPTION)) {
|
||||
config.getHybridConfig().setSaveCrops(true);
|
||||
}
|
||||
if (commandLine.hasOption(HYBRID_HANCOM_AI_CROP_OUTPUT_DIR_LONG_OPTION)) {
|
||||
String value = commandLine.getOptionValue(HYBRID_HANCOM_AI_CROP_OUTPUT_DIR_LONG_OPTION);
|
||||
if (value != null && !value.trim().isEmpty()) {
|
||||
config.getHybridConfig().setCropOutputDir(value.trim());
|
||||
}
|
||||
}
|
||||
if (commandLine.hasOption(TO_STDOUT_LONG_OPTION)) {
|
||||
config.setOutputStdout(true);
|
||||
}
|
||||
// Keep in sync with all HYBRID_HANCOM_AI_*_LONG_OPTION constants above.
|
||||
boolean usesHancomAiOnly =
|
||||
commandLine.hasOption(HYBRID_HANCOM_AI_REGIONLIST_STRATEGY_LONG_OPTION) ||
|
||||
commandLine.hasOption(HYBRID_HANCOM_AI_OCR_STRATEGY_LONG_OPTION) ||
|
||||
commandLine.hasOption(HYBRID_HANCOM_AI_IMAGE_CACHE_LONG_OPTION) ||
|
||||
commandLine.hasOption(HYBRID_HANCOM_AI_SAVE_CROPS_LONG_OPTION) ||
|
||||
commandLine.hasOption(HYBRID_HANCOM_AI_CROP_OUTPUT_DIR_LONG_OPTION);
|
||||
if (usesHancomAiOnly && !Config.HYBRID_HANCOM_AI.equals(config.getHybrid())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Options --hybrid-hancom-ai-* require --hybrid=hancom-ai (got --hybrid="
|
||||
+ config.getHybrid() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports CLI option definitions as JSON for code generation.
|
||||
* This is used to generate Node.js, Python, and documentation from a single
|
||||
* source of truth.
|
||||
*
|
||||
* @param out The output stream to write JSON to
|
||||
*/
|
||||
public static void exportOptionsAsJson(PrintStream out) {
|
||||
List<OptionDefinition> exportable = OPTION_DEFINITIONS.stream()
|
||||
.filter(d -> d.exported)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Build JSON manually to avoid external dependencies
|
||||
StringBuilder json = new StringBuilder();
|
||||
json.append("{\n");
|
||||
json.append(" \"options\": [\n");
|
||||
|
||||
for (int i = 0; i < exportable.size(); i++) {
|
||||
OptionDefinition opt = exportable.get(i);
|
||||
json.append(" {\n");
|
||||
json.append(" \"name\": \"").append(opt.longName).append("\",\n");
|
||||
json.append(" \"shortName\": ").append(opt.shortName == null ? "null" : "\"" + opt.shortName + "\"")
|
||||
.append(",\n");
|
||||
json.append(" \"type\": \"").append(opt.type).append("\",\n");
|
||||
json.append(" \"required\": false,\n");
|
||||
if (opt.defaultValue == null) {
|
||||
json.append(" \"default\": null,\n");
|
||||
} else if (opt.defaultValue instanceof Boolean) {
|
||||
json.append(" \"default\": ").append(opt.defaultValue).append(",\n");
|
||||
} else {
|
||||
json.append(" \"default\": \"").append(escapeJson(opt.defaultValue.toString())).append("\",\n");
|
||||
}
|
||||
json.append(" \"description\": \"").append(escapeJson(opt.description)).append("\"\n");
|
||||
json.append(" }");
|
||||
if (i < exportable.size() - 1) {
|
||||
json.append(",");
|
||||
}
|
||||
json.append("\n");
|
||||
}
|
||||
|
||||
json.append(" ]\n");
|
||||
json.append("}\n");
|
||||
|
||||
out.print(json.toString());
|
||||
}
|
||||
|
||||
private static String escapeJson(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal class to hold option definition for both CLI and JSON export.
|
||||
* Single source of truth for all option metadata.
|
||||
*/
|
||||
private static class OptionDefinition {
|
||||
final String longName;
|
||||
final String shortName;
|
||||
final String type; // "string" | "boolean"
|
||||
final Object defaultValue;
|
||||
final String description;
|
||||
final boolean exported; // Whether to include in JSON export
|
||||
|
||||
OptionDefinition(String longName, String shortName, String type, Object defaultValue, String description,
|
||||
boolean exported) {
|
||||
this.longName = longName;
|
||||
this.shortName = shortName;
|
||||
this.type = type;
|
||||
this.defaultValue = defaultValue;
|
||||
this.description = description;
|
||||
this.exported = exported;
|
||||
}
|
||||
|
||||
/** Creates an Apache Commons CLI Option from this definition. */
|
||||
Option toOption() {
|
||||
boolean hasArg = "string".equals(type);
|
||||
return new Option(shortName, longName, hasArg, description);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
package org.opendataloader.pdf.autotagging;
|
||||
|
||||
import org.opendataloader.pdf.processors.AutoTaggingProcessor;
|
||||
import org.verapdf.as.ASAtom;
|
||||
import org.verapdf.as.io.ASInputStream;
|
||||
import org.verapdf.cos.*;
|
||||
import org.verapdf.gf.model.factory.chunks.ChunkParser;
|
||||
import org.verapdf.gf.model.factory.chunks.GraphicsState;
|
||||
import org.verapdf.gf.model.impl.sa.util.ResourceHandler;
|
||||
import org.verapdf.operator.Operator;
|
||||
import org.verapdf.parser.Operators;
|
||||
import org.verapdf.parser.PDFStreamParser;
|
||||
import org.verapdf.pd.PDContentStream;
|
||||
import org.verapdf.pd.PDExtGState;
|
||||
import org.verapdf.pd.images.PDXForm;
|
||||
import org.verapdf.pd.images.PDXObject;
|
||||
import org.verapdf.tools.StaticResources;
|
||||
import org.verapdf.tools.TaggedPDFConstants;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.StreamInfo;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.*;
|
||||
|
||||
public class ChunksWriter {
|
||||
|
||||
private static final java.util.logging.Logger CHUNKS_LOGGER = java.util.logging.Logger.getLogger(ChunksWriter.class.getName());
|
||||
|
||||
private final ResourceHandler resourceHandler;
|
||||
private final GraphicsState graphicsState;
|
||||
|
||||
public ChunksWriter(GraphicsState inheritedGraphicState, ResourceHandler resourceHandler) {
|
||||
this.graphicsState = inheritedGraphicState.clone();
|
||||
this.resourceHandler = resourceHandler;
|
||||
}
|
||||
|
||||
public static List<Object> getTokens(PDContentStream pdContentStream) {
|
||||
if (pdContentStream != null) {
|
||||
try {
|
||||
COSObject contentStream = pdContentStream.getContents();
|
||||
if (contentStream.getType() == COSObjType.COS_STREAM || contentStream.getType() == COSObjType.COS_ARRAY) {
|
||||
try (ASInputStream opStream = contentStream.getDirectBase().getData(COSStream.FilterFlags.DECODE)) {
|
||||
try (PDFStreamParser streamParser = new PDFStreamParser(opStream)) {
|
||||
streamParser.parseTokens();
|
||||
return streamParser.getTokens();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
CHUNKS_LOGGER.warning("Failed to read content stream tokens: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
public List<Object> processTokens(List<Object> processTokens, OperatorStreamKey operatorStreamKey) throws IOException {
|
||||
Map<Integer, Set<StreamInfo>> operatorIndexesToStreamInfosMap = AutoTaggingProcessor.getOperatorIndexesToStreamInfosMap().get(operatorStreamKey);
|
||||
if (operatorIndexesToStreamInfosMap == null) {
|
||||
operatorIndexesToStreamInfosMap = Collections.emptyMap();
|
||||
}
|
||||
List<Object> result = new ArrayList<>();
|
||||
List<COSBase> arguments = new ArrayList<>();
|
||||
for (int index = 0; index < processTokens.size(); index++) {
|
||||
Object token = processTokens.get(index);
|
||||
if (token instanceof COSBase) {
|
||||
arguments.add((COSBase) token);
|
||||
} else if (token instanceof Operator) {
|
||||
processOperator(result, (Operator)token, arguments, index, operatorIndexesToStreamInfosMap, operatorStreamKey);
|
||||
arguments.clear();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void processOperator(List<Object> result, Operator rawOperator, List<COSBase> arguments, int operatorIndex,
|
||||
Map<Integer, Set<StreamInfo>> operatorIndexesToStreamInfosMap,
|
||||
OperatorStreamKey operatorStreamKey) throws IOException {
|
||||
String operatorName = rawOperator.getOperator();
|
||||
switch (operatorName) {
|
||||
case Operators.BMC:
|
||||
case Operators.EMC:
|
||||
case Operators.BDC:
|
||||
break;
|
||||
case Operators.DO:
|
||||
OperatorStreamKey xObjectOperatorStreamKey = new OperatorStreamKey(operatorStreamKey.getPageNumber(), arguments.get(0).getString());
|
||||
Integer xObjectStructParent = AutoTaggingProcessor.getStructParentsIntegers().get(xObjectOperatorStreamKey);
|
||||
if (AutoTaggingProcessor.getOperatorIndexesToStreamInfosMap().containsKey(xObjectOperatorStreamKey)
|
||||
&& xObjectStructParent != null) {
|
||||
COSName xObjectName = getLastCOSName(arguments);
|
||||
PDXObject pdxObject = resourceHandler.getXObject(xObjectName);
|
||||
if (pdxObject == null) {
|
||||
processContentOperator(result, rawOperator, arguments, operatorIndex, operatorIndexesToStreamInfosMap, operatorName, operatorStreamKey);
|
||||
break;
|
||||
}
|
||||
pdxObject.setKey(ASAtom.STRUCT_PARENTS,
|
||||
COSInteger.construct(xObjectStructParent));
|
||||
StaticResources.getDocument().getDocument().addChangedObject(pdxObject.getObject());
|
||||
PDXForm pdxForm = (PDXForm)pdxObject;
|
||||
GraphicsState xFormGraphicsState = graphicsState.clone();
|
||||
AutoTaggingProcessor.setUpContents(pdxForm.getObject(), new ChunksWriter(xFormGraphicsState,
|
||||
resourceHandler.getExtendedResources(pdxForm.getResources())).processTokens(
|
||||
ChunksWriter.getTokens(pdxForm), xObjectOperatorStreamKey));
|
||||
// Preserve the Do operator in the parent stream so the XObject is still invoked
|
||||
result.addAll(arguments);
|
||||
result.add(rawOperator);
|
||||
} else {
|
||||
processContentOperator(result, rawOperator, arguments, operatorIndex, operatorIndexesToStreamInfosMap, operatorName, operatorStreamKey);
|
||||
}
|
||||
break;
|
||||
case Operators.TJ_SHOW:
|
||||
case Operators.TJ_SHOW_POS:
|
||||
case Operators.QUOTE:
|
||||
case Operators.DOUBLE_QUOTE:
|
||||
case Operators.BI:
|
||||
case Operators.F_FILL:
|
||||
case Operators.F_FILL_OBSOLETE:
|
||||
case Operators.F_STAR_FILL:
|
||||
case Operators.B_CLOSEPATH_FILL_STROKE:
|
||||
case Operators.B_STAR_CLOSEPATH_EOFILL_STROKE:
|
||||
case Operators.S_CLOSE_STROKE:
|
||||
case Operators.S_STROKE:
|
||||
processContentOperator(result, rawOperator, arguments, operatorIndex, operatorIndexesToStreamInfosMap, operatorName, operatorStreamKey);
|
||||
break;
|
||||
case org.verapdf.model.tools.constants.Operators.GS:
|
||||
PDExtGState extGState = this.resourceHandler.getExtGState(getLastCOSName(arguments));
|
||||
this.graphicsState.copyPropertiesFromExtGState(extGState);
|
||||
result.addAll(arguments);
|
||||
result.add(rawOperator);
|
||||
break;
|
||||
case org.verapdf.model.tools.constants.Operators.TF:
|
||||
this.graphicsState.getTextState().setTextFont(resourceHandler.getFont(getFirstCOSName(arguments)));
|
||||
result.addAll(arguments);
|
||||
result.add(rawOperator);
|
||||
break;
|
||||
default:
|
||||
// if (!Operators.D_SET_DASH.equals(operatorName)) {
|
||||
result.addAll(arguments);
|
||||
result.add(rawOperator);
|
||||
// }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void processContentOperator(List<Object> result, Operator rawOperator, List<COSBase> arguments, int operatorIndex,
|
||||
Map<Integer, Set<StreamInfo>> operatorIndexesToStreamInfosMap, String operatorName,
|
||||
OperatorStreamKey operatorStreamKey) {
|
||||
Set<StreamInfo> streamInfos = operatorIndexesToStreamInfosMap.get(operatorIndex);
|
||||
if (streamInfos == null || streamInfos.isEmpty()) {
|
||||
writeMarkedContent(result, arguments, rawOperator, operatorName, null, null);
|
||||
} else {
|
||||
if (streamInfos.size() == 1) {
|
||||
Integer mcid = streamInfos.iterator().next().getMcid();
|
||||
writeMarkedContent(result, arguments, rawOperator, operatorName, mcid, operatorStreamKey);
|
||||
} else {
|
||||
List<StreamInfo> streamInfosList = updateStreamInfos(streamInfos);
|
||||
Map<StreamInfo, COSObject> newArguments = getArguments(arguments.get(arguments.size() - 1), streamInfosList);
|
||||
for (Map.Entry<StreamInfo, COSObject> entry : newArguments.entrySet()) {
|
||||
arguments.set(arguments.size() - 1, entry.getValue().get());
|
||||
writeMarkedContent(result, arguments, rawOperator, operatorName, entry.getKey().getMcid(), operatorStreamKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String getStructureType(Integer mcid, OperatorStreamKey operatorStreamKey) {
|
||||
if (mcid == null || operatorStreamKey == null) return null;
|
||||
List<COSObject> parents = AutoTaggingProcessor.getStructParents().get(operatorStreamKey);
|
||||
if (parents == null) {
|
||||
CHUNKS_LOGGER.warning("structParents: no entry for key page=" + operatorStreamKey.getPageNumber() + " xobj=" + operatorStreamKey.getXObjectName() + " (available keys: " + AutoTaggingProcessor.getStructParents().keySet().stream().map(k -> "p"+k.getPageNumber()+"x"+k.getXObjectName()).collect(java.util.stream.Collectors.joining(",")) + ")");
|
||||
return null;
|
||||
}
|
||||
if (mcid >= parents.size()) {
|
||||
CHUNKS_LOGGER.warning("structParents: mcid=" + mcid + " out of range (size=" + parents.size() + ") for key page=" + operatorStreamKey.getPageNumber() + " xobj=" + operatorStreamKey.getXObjectName());
|
||||
return null;
|
||||
}
|
||||
COSObject structElem = parents.get(mcid);
|
||||
if (structElem == null) return null;
|
||||
COSObject typeObj = structElem.getKey(ASAtom.S);
|
||||
if (typeObj == null || typeObj.empty()) return null;
|
||||
return typeObj.getString();
|
||||
}
|
||||
|
||||
private static void writeMarkedContent(List<Object> result, List<COSBase> arguments, Operator token,
|
||||
String operatorName, Integer mcid, OperatorStreamKey operatorStreamKey) {
|
||||
if (mcid == null) {
|
||||
result.add(COSName.construct(TaggedPDFConstants.ARTIFACT).getDirectBase());
|
||||
result.add(Operator.getOperator(Operators.BMC));
|
||||
} else {
|
||||
String tagName;
|
||||
if (Operators.BI.equals(operatorName) || Operators.DO.equals(operatorName)) {
|
||||
tagName = TaggedPDFConstants.FIGURE;
|
||||
} else {
|
||||
String structType = getStructureType(mcid, operatorStreamKey);
|
||||
tagName = (structType != null) ? structType : TaggedPDFConstants.SPAN;
|
||||
}
|
||||
result.add(COSName.construct(tagName).getDirectBase());
|
||||
COSObject dictionary = COSDictionary.construct();
|
||||
dictionary.setKey(ASAtom.MCID, COSInteger.construct(mcid));
|
||||
result.add(dictionary.getDirectBase());
|
||||
result.add(Operator.getOperator(Operators.BDC));
|
||||
}
|
||||
result.addAll(arguments);
|
||||
result.add(token);
|
||||
result.add(Operator.getOperator(Operators.EMC));
|
||||
}
|
||||
|
||||
private Map<StreamInfo, COSObject> getArguments(COSBase object, List<StreamInfo> streamInfos) {
|
||||
Map<StreamInfo, COSObject> map = new TreeMap<>();
|
||||
if (object.getType() == COSObjType.COS_STRING) {
|
||||
Queue<StreamInfo> streamInfoQueue = new LinkedList<>(streamInfos);
|
||||
processString((COSString)object, map, streamInfoQueue, null, 0);
|
||||
} else if (object.getType() == COSObjType.COS_ARRAY) {
|
||||
int stringIndex = 0;
|
||||
List<COSObject> array = new ArrayList<>();
|
||||
Queue<StreamInfo> streamInfoQueue = new LinkedList<>(streamInfos);
|
||||
for (COSObject element : (COSArray)object.getDirectBase()) {
|
||||
if (element.getType() == COSObjType.COS_STRING) {
|
||||
stringIndex = processString((COSString)element.get(), map, streamInfoQueue, array, stringIndex);
|
||||
} else if (element.getType().isNumber()) {
|
||||
array.add(element);
|
||||
}
|
||||
}
|
||||
if (!array.isEmpty()) {
|
||||
StreamInfo peekInfo = streamInfoQueue.peek();
|
||||
COSObject target = peekInfo != null ? map.get(peekInfo) : null;
|
||||
if (target != null && target.getType() == COSObjType.COS_ARRAY) {
|
||||
COSArray currentArray = (COSArray) target.get();
|
||||
for (COSObject element : array) {
|
||||
currentArray.add(element);
|
||||
}
|
||||
} else {
|
||||
CHUNKS_LOGGER.warning("Issue during text operator processing");
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
public int processString(COSString string, Map<StreamInfo, COSObject> map, Queue<StreamInfo> streamInfoQueue,
|
||||
List<COSObject> array, int stringIndex) {
|
||||
int currentStringIndex = stringIndex;
|
||||
int currentBytesIndex = 0;
|
||||
int dif = 0;
|
||||
byte[] bytes = string.get();
|
||||
try (InputStream inputStream = new ByteArrayInputStream(bytes)) {
|
||||
int available = inputStream.available();
|
||||
while (inputStream.available() > 0) {
|
||||
int code = graphicsState.getTextState().getTextFont().readCode(inputStream);
|
||||
String value = graphicsState.getTextState().getTextFont().toUnicode(code);
|
||||
if (value == null) {
|
||||
value = StaticContainers.getIsIgnoreCharactersWithoutUnicode() ? "" : ChunkParser.REPLACEMENT_CHARACTER_STRING;
|
||||
}
|
||||
int newAvailable = inputStream.available();
|
||||
dif += available - newAvailable;
|
||||
available = newAvailable;
|
||||
int length = streamInfoQueue.peek().getEndIndex() - currentStringIndex;
|
||||
currentStringIndex += value.length();
|
||||
if (length <= value.length()) {
|
||||
COSObject cosString = COSString.construct(Arrays.copyOfRange(bytes, currentBytesIndex, currentBytesIndex + dif), string.isHexadecimal());
|
||||
if (array != null) {
|
||||
array.add(cosString);
|
||||
map.put(streamInfoQueue.peek(), new COSObject(new COSArray(array)));
|
||||
} else {
|
||||
map.put(streamInfoQueue.peek(), cosString);
|
||||
}
|
||||
currentBytesIndex += dif;
|
||||
dif = 0;
|
||||
if (array != null) {
|
||||
array.clear();
|
||||
}
|
||||
if (streamInfoQueue.size() > 1) {
|
||||
streamInfoQueue.poll();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
CHUNKS_LOGGER.warning("Failed to process font string: " + e.getMessage());
|
||||
}
|
||||
if (array != null && dif > 0) {
|
||||
array.add(COSString.construct(Arrays.copyOfRange(bytes, currentBytesIndex, currentBytesIndex + dif), string.isHexadecimal()));
|
||||
}
|
||||
return currentStringIndex;
|
||||
}
|
||||
|
||||
private static List<StreamInfo> updateStreamInfos(Set<StreamInfo> streamInfos) {
|
||||
Iterator<StreamInfo> streamInfoIterator = streamInfos.iterator();
|
||||
List<StreamInfo> newStreamInfos = new ArrayList<>();
|
||||
StreamInfo streamInfo = null;
|
||||
int currentIndex = 0;
|
||||
while (streamInfoIterator.hasNext()) {
|
||||
streamInfo = streamInfoIterator.next();
|
||||
if (currentIndex < streamInfo.getStartIndex()) {
|
||||
newStreamInfos.add(new StreamInfo(streamInfo.getOperatorIndex(), streamInfo.getXObjectName(),
|
||||
streamInfo.getXImageObjectKey(), currentIndex, streamInfo.getStartIndex(), streamInfo.getLength(), null));
|
||||
}
|
||||
currentIndex = streamInfo.getEndIndex();
|
||||
newStreamInfos.add(streamInfo);
|
||||
}
|
||||
if (currentIndex < streamInfo.getLength()) {
|
||||
newStreamInfos.add(new StreamInfo(streamInfo.getOperatorIndex(), streamInfo.getXObjectName(),
|
||||
streamInfo.getXImageObjectKey(), streamInfo.getEndIndex(), streamInfo.getLength(), streamInfo.getLength(), null));
|
||||
}
|
||||
return newStreamInfos;
|
||||
}
|
||||
|
||||
private static COSName getFirstCOSName(List<COSBase> arguments) {
|
||||
COSBase lastElement = arguments.isEmpty() ? null : arguments.get(0);
|
||||
if (lastElement instanceof COSName) {
|
||||
return (COSName) lastElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static COSName getLastCOSName(List<COSBase> arguments) {
|
||||
COSBase lastElement = arguments.isEmpty() ? null : arguments.get(arguments.size() - 1);
|
||||
if (lastElement instanceof COSName) {
|
||||
return (COSName) lastElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package org.opendataloader.pdf.autotagging;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class OperatorStreamKey {
|
||||
private final int pageNumber;
|
||||
private final String xObjectName;
|
||||
|
||||
public OperatorStreamKey(int pageNumber, String xObjectName) {
|
||||
this.pageNumber = pageNumber;
|
||||
this.xObjectName = xObjectName;
|
||||
}
|
||||
|
||||
public int getPageNumber() {
|
||||
return pageNumber;
|
||||
}
|
||||
|
||||
public String getXObjectName() {
|
||||
return xObjectName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
OperatorStreamKey that = (OperatorStreamKey) o;
|
||||
return pageNumber == that.pageNumber &&
|
||||
Objects.equals(xObjectName, that.xObjectName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(pageNumber, xObjectName);
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.containers;
|
||||
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticHeading;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class StaticLayoutContainers {
|
||||
protected static final Logger LOGGER = Logger.getLogger(StaticLayoutContainers.class.getCanonicalName());
|
||||
|
||||
private static final ThreadLocal<Long> currentContentId = new ThreadLocal<>();
|
||||
private static final ThreadLocal<List<SemanticHeading>> headings = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Integer> imageIndex = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Boolean> isUseStructTree = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> imagesDirectory = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Boolean> embedImages = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> imageFormat = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Map<Integer, Double>> replacementCharRatios = ThreadLocal.withInitial(ConcurrentHashMap::new);
|
||||
private static final ThreadLocal<Map<String, byte[]>> embeddedImageBytes = ThreadLocal.withInitial(ConcurrentHashMap::new);
|
||||
|
||||
public static void clearContainers() {
|
||||
currentContentId.set(1L);
|
||||
headings.set(Collections.synchronizedList(new LinkedList<>()));
|
||||
imageIndex.set(1);
|
||||
isUseStructTree.set(false);
|
||||
imagesDirectory.set("");
|
||||
embedImages.set(false);
|
||||
imageFormat.set(Config.IMAGE_FORMAT_PNG);
|
||||
replacementCharRatios.get().clear();
|
||||
embeddedImageBytes.get().clear();
|
||||
}
|
||||
|
||||
public static long getCurrentContentId() {
|
||||
return currentContentId.get();
|
||||
}
|
||||
|
||||
public static long incrementContentId() {
|
||||
long id = getCurrentContentId();
|
||||
StaticLayoutContainers.setCurrentContentId(id + 1);
|
||||
return id;
|
||||
}
|
||||
|
||||
public static void setCurrentContentId(long currentContentId) {
|
||||
StaticLayoutContainers.currentContentId.set(currentContentId);
|
||||
}
|
||||
|
||||
public static String getImagesDirectory() {
|
||||
return imagesDirectory.get();
|
||||
}
|
||||
|
||||
public static String getImagesDirectoryName() {
|
||||
String dir = imagesDirectory.get();
|
||||
return dir != null && !dir.isEmpty() ? new File(dir).getName() : "";
|
||||
}
|
||||
|
||||
public static void setImagesDirectory(String imagesDirectory) {
|
||||
StaticLayoutContainers.imagesDirectory.set(imagesDirectory);
|
||||
}
|
||||
|
||||
public static List<SemanticHeading> getHeadings() {
|
||||
return headings.get();
|
||||
}
|
||||
|
||||
public static void setHeadings(List<SemanticHeading> headings) {
|
||||
StaticLayoutContainers.headings.set(headings);
|
||||
}
|
||||
|
||||
public static Boolean isUseStructTree() {
|
||||
return isUseStructTree.get();
|
||||
}
|
||||
|
||||
public static void setIsUseStructTree(Boolean isUseStructTree) {
|
||||
StaticLayoutContainers.isUseStructTree.set(isUseStructTree);
|
||||
}
|
||||
|
||||
public static int incrementImageIndex() {
|
||||
int imageIndex = StaticLayoutContainers.imageIndex.get();
|
||||
StaticLayoutContainers.imageIndex.set(imageIndex + 1);
|
||||
return imageIndex;
|
||||
}
|
||||
|
||||
public static void resetImageIndex() {
|
||||
StaticLayoutContainers.imageIndex.set(1);
|
||||
}
|
||||
|
||||
public static boolean isEmbedImages() {
|
||||
return Boolean.TRUE.equals(embedImages.get());
|
||||
}
|
||||
|
||||
public static void setEmbedImages(boolean embedImages) {
|
||||
StaticLayoutContainers.embedImages.set(embedImages);
|
||||
}
|
||||
|
||||
public static String getImageFormat() {
|
||||
String format = imageFormat.get();
|
||||
return format != null ? format : Config.IMAGE_FORMAT_PNG;
|
||||
}
|
||||
|
||||
public static void setImageFormat(String format) {
|
||||
StaticLayoutContainers.imageFormat.set(format);
|
||||
}
|
||||
|
||||
public static void setReplacementCharRatio(int pageNumber, double ratio) {
|
||||
replacementCharRatios.get().put(pageNumber, ratio);
|
||||
}
|
||||
|
||||
public static double getReplacementCharRatio(int pageNumber) {
|
||||
return replacementCharRatios.get().getOrDefault(pageNumber, 0.0);
|
||||
}
|
||||
|
||||
public static void cacheEmbeddedImageBytes(String absolutePath, byte[] bytes) {
|
||||
embeddedImageBytes.get().put(normalizeImageKey(absolutePath), bytes);
|
||||
}
|
||||
|
||||
public static byte[] getEmbeddedImageBytes(String absolutePath) {
|
||||
return embeddedImageBytes.get().get(normalizeImageKey(absolutePath));
|
||||
}
|
||||
|
||||
public static boolean hasEmbeddedImageBytes(String absolutePath) {
|
||||
return embeddedImageBytes.get().containsKey(normalizeImageKey(absolutePath));
|
||||
}
|
||||
|
||||
// Map-level accessors are used by DocumentProcessor.propagateState so worker threads
|
||||
// share the main thread's cache instance. Today the cache is only touched on the main
|
||||
// thread, but propagating it eliminates a silent-data-loss trap if generators ever
|
||||
// run on workers — matches the CLAUDE.md ThreadLocal-propagation gotcha.
|
||||
public static Map<String, byte[]> getEmbeddedImageBytesMap() {
|
||||
return embeddedImageBytes.get();
|
||||
}
|
||||
|
||||
public static void setEmbeddedImageBytesMap(Map<String, byte[]> map) {
|
||||
embeddedImageBytes.set(map);
|
||||
}
|
||||
|
||||
// Image paths flow through String.format with File.separator (cache write side) and
|
||||
// through File.getPath() (cache read side via Base64ImageUtils.toDataUri). The two
|
||||
// forms can differ on Windows when the user passes forward-slash output paths
|
||||
// (e.g. /tmp/x). Collapse both forms to a single canonical key so writers and readers
|
||||
// agree on cache identity.
|
||||
private static String normalizeImageKey(String path) {
|
||||
return path == null ? null : new File(path).getPath();
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.entities;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.content.ImageChunk;
|
||||
|
||||
/**
|
||||
* An ImageChunk enriched with an AI-generated description (alt text).
|
||||
*
|
||||
* <p>Created when the hybrid backend returns a SemanticPicture whose bounding
|
||||
* box overlaps a Java-extracted ImageChunk. The description is matched by
|
||||
* bounding-box IoU in HybridDocumentProcessor and propagated to:
|
||||
* <ul>
|
||||
* <li>AutoTaggingProcessor — inserts /Alt into the Figure struct element</li>
|
||||
* <li>ImageSerializer — writes "alt" field to JSON output</li>
|
||||
* <li>MarkdownGenerator / HtmlGenerator — uses description as alt text</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class EnrichedImageChunk extends ImageChunk {
|
||||
|
||||
/**
|
||||
* Where the alt text came from. Drives the {@code alt_source} JSON field
|
||||
* and downstream evidence-report badging — a reviewer treats a human-authored
|
||||
* /Alt very differently from an AI-generated caption.
|
||||
*/
|
||||
public enum AltSource {
|
||||
/** Pulled from the source PDF's struct-tree /Alt entry. */
|
||||
ORIGINAL,
|
||||
/** Generated by the hybrid backend (Hancom AI / docling SmolVLM). */
|
||||
AI_GENERATED
|
||||
}
|
||||
|
||||
private final String description;
|
||||
private final AltSource altSource;
|
||||
|
||||
/**
|
||||
* Convenience constructor for author-authored /Alt sourced from the input
|
||||
* PDF's struct tree (used by {@link org.opendataloader.pdf.processors.TaggedDocumentProcessor}).
|
||||
* AI / backend-generated captions must use the 3-arg form with
|
||||
* {@link AltSource#AI_GENERATED} so downstream consumers can distinguish
|
||||
* human alt text from synthesised descriptions.
|
||||
*/
|
||||
public EnrichedImageChunk(ImageChunk source, String description) {
|
||||
this(source, description, AltSource.ORIGINAL);
|
||||
}
|
||||
|
||||
public EnrichedImageChunk(ImageChunk source, String description, AltSource altSource) {
|
||||
super(source.getBoundingBox());
|
||||
// Copy index so serializers can reference the image file
|
||||
setIndex(source.getIndex());
|
||||
// Copy stream infos so MCID / struct-tree linkage is preserved
|
||||
getStreamInfos().addAll(source.getStreamInfos());
|
||||
this.description = description;
|
||||
this.altSource = altSource;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description != null ? description : "";
|
||||
}
|
||||
|
||||
public boolean hasDescription() {
|
||||
return description != null && !description.isEmpty();
|
||||
}
|
||||
|
||||
public AltSource getAltSource() {
|
||||
return altSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitized description safe for use as PDF /Alt, Markdown alt text,
|
||||
* HTML alt attribute, and JSON value.
|
||||
*/
|
||||
public String sanitizeDescription() {
|
||||
if (!hasDescription()) return "";
|
||||
return description
|
||||
.replace("\r\n", " ")
|
||||
.replace("\n", " ")
|
||||
.replace("\r", " ")
|
||||
.replace("\"", "")
|
||||
.replace("[", "")
|
||||
.replace("]", "")
|
||||
.replace("<", "")
|
||||
.replace(">", "")
|
||||
.replace("&", "")
|
||||
.replace("\u0000", "")
|
||||
// PDF/UA-2 clause 8.4.3.3 forbids Private Use Area code points in /Alt.
|
||||
// Strip BMP PUA (U+E000–U+F8FF) and supplementary PUA (encoded as surrogate pairs
|
||||
// U+DB80–U+DBFF paired with U+DC00–U+DFFF).
|
||||
.replaceAll("[\\uE000-\\uF8FF]|[\\uDB80-\\uDBFF][\\uDC00-\\uDFFF]", "")
|
||||
.replaceAll("\\s{2,}", " ")
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.entities;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticParagraph;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.SemanticType;
|
||||
|
||||
/**
|
||||
* Represents a footnote/endnote element.
|
||||
* Maps to PDF 2.0 FENote structure element in AutoTaggingProcessor.
|
||||
*/
|
||||
public class SemanticFootnote extends SemanticParagraph {
|
||||
public SemanticFootnote() {
|
||||
super();
|
||||
}
|
||||
|
||||
public SemanticFootnote(SemanticParagraph node) {
|
||||
super(node);
|
||||
this.setSemanticType(SemanticType.NOTE);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.entities;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.BaseObject;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
|
||||
/**
|
||||
* Represents a mathematical formula element with LaTeX content.
|
||||
*
|
||||
* <p>This class stores formula content in LaTeX format, which can be rendered
|
||||
* using MathJax, KaTeX, or similar libraries in the output formats.
|
||||
*
|
||||
* <p>Extends BaseObject to leverage the standard IObject implementation.
|
||||
*/
|
||||
public class SemanticFormula extends BaseObject {
|
||||
|
||||
private final String latex;
|
||||
|
||||
/**
|
||||
* Creates a SemanticFormula with the given bounding box and LaTeX content.
|
||||
*
|
||||
* @param boundingBox The bounding box of the formula
|
||||
* @param latex The LaTeX representation of the formula
|
||||
*/
|
||||
public SemanticFormula(BoundingBox boundingBox, String latex) {
|
||||
super(boundingBox);
|
||||
this.latex = latex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the LaTeX representation of the formula.
|
||||
*
|
||||
* @return The LaTeX string, or empty string if null
|
||||
*/
|
||||
public String getLatex() {
|
||||
return latex != null ? latex : "";
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.entities;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.BaseObject;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
|
||||
/**
|
||||
* Represents a picture element with optional description (alt text).
|
||||
*
|
||||
* <p>This class stores picture metadata including AI-generated descriptions
|
||||
* for accessibility purposes. Descriptions are generated using vision-language
|
||||
* models when the hybrid server is configured with --enrich-picture-description.
|
||||
*
|
||||
* <p>Extends BaseObject to leverage the standard IObject implementation.
|
||||
*/
|
||||
public class SemanticPicture extends BaseObject {
|
||||
|
||||
private final int index;
|
||||
private final String description;
|
||||
|
||||
/**
|
||||
* Creates a SemanticPicture with the given bounding box and index.
|
||||
*
|
||||
* @param boundingBox The bounding box of the picture
|
||||
* @param index The sequential index of the picture
|
||||
*/
|
||||
public SemanticPicture(BoundingBox boundingBox, int index) {
|
||||
this(boundingBox, index, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SemanticPicture with the given bounding box, index, and description.
|
||||
*
|
||||
* @param boundingBox The bounding box of the picture
|
||||
* @param index The sequential index of the picture
|
||||
* @param description The AI-generated description (alt text) for accessibility
|
||||
*/
|
||||
public SemanticPicture(BoundingBox boundingBox, int index, String description) {
|
||||
super(boundingBox);
|
||||
this.index = index;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sequential index of this picture.
|
||||
*
|
||||
* @return The picture index
|
||||
*/
|
||||
public int getPictureIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the description (alt text) of this picture.
|
||||
*
|
||||
* @return The description string, or empty string if null
|
||||
*/
|
||||
public String getDescription() {
|
||||
return description != null ? description : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this picture has a description.
|
||||
*
|
||||
* @return true if description is non-null and non-empty
|
||||
*/
|
||||
public boolean hasDescription() {
|
||||
return description != null && !description.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a sanitized version of the description safe for use as alt text
|
||||
* across all output formats (Markdown, HTML, JSON) without format-specific escaping.
|
||||
*
|
||||
* <p>Removes characters that are structurally significant in at least one output format:
|
||||
* <ul>
|
||||
* <li>{@code "} — HTML attribute delimiter</li>
|
||||
* <li>{@code [}, {@code ]} — Markdown alt text delimiters</li>
|
||||
* <li>{@code <}, {@code >} — HTML tag delimiters</li>
|
||||
* <li>{@code &} — HTML entity prefix</li>
|
||||
* <li>{@code \u0000} — null character</li>
|
||||
* <li>Newlines ({@code \n}, {@code \r}) — replaced with a space</li>
|
||||
* </ul>
|
||||
* Consecutive whitespace is collapsed to a single space and the result is trimmed.
|
||||
*
|
||||
* @return sanitized description string, or empty string if no description
|
||||
*/
|
||||
public String sanitizeDescription() {
|
||||
if (!hasDescription()) {
|
||||
return "";
|
||||
}
|
||||
return description
|
||||
.replace("\r\n", " ")
|
||||
.replace("\n", " ")
|
||||
.replace("\r", " ")
|
||||
.replace("\"", "")
|
||||
.replace("[", "")
|
||||
.replace("]", "")
|
||||
.replace("<", "")
|
||||
.replace(">", "")
|
||||
.replace("&", "")
|
||||
.replace("\u0000", "")
|
||||
.replaceAll("\\s{2,}", " ")
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.exceptions;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Thrown when tagged-pdf generation is requested for an encrypted document.
|
||||
*
|
||||
* <p>Encrypted PDFs (those with an {@code /Encrypt} dictionary in the trailer)
|
||||
* are not supported for tagged-pdf output. Writing a tagged copy requires
|
||||
* re-serializing the document, which interacts poorly with the document's
|
||||
* encryption state and is also not permitted by the PDF specification for
|
||||
* documents whose permissions deny modification.
|
||||
*/
|
||||
public class EncryptedTaggedPdfNotSupportedException extends IOException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public EncryptedTaggedPdfNotSupportedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.exceptions;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Thrown when an input file cannot be processed as a PDF. Two failure modes
|
||||
* share this exception type, distinguished by message wording:
|
||||
* <ul>
|
||||
* <li><em>Missing header</em> — the {@code %PDF-} magic number is absent
|
||||
* from the first 1024 bytes (JPEG renamed to {@code .pdf}, empty file,
|
||||
* arbitrary text). Detected before veraPDF is invoked.</li>
|
||||
* <li><em>Corrupted or truncated content</em> — the magic number is
|
||||
* present but the body fails to parse (interrupted download, missing
|
||||
* trailing xref, garbage payload). Detected when {@code new PDDocument}
|
||||
* throws {@link IOException}; the original veraPDF exception is
|
||||
* preserved via {@link #getCause()} for diagnostics.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>This is a checked subtype of {@link IOException} so callers that already
|
||||
* handle {@code IOException} keep compiling, while callers that want to
|
||||
* distinguish "not a usable PDF" from other I/O failures can catch this type
|
||||
* specifically.
|
||||
*
|
||||
* <p>Public entry points that may surface this exception:
|
||||
* <ul>
|
||||
* <li>{@code OpenDataLoaderPDF.processFile(String, Config)}</li>
|
||||
* <li>{@code DocumentProcessor.processFile(String, Config)}</li>
|
||||
* <li>{@code DocumentProcessor.processFileWithResult(String, Config)}</li>
|
||||
* <li>{@code DocumentProcessor.extractContents(String, Config)}</li>
|
||||
* <li>{@code DocumentProcessor.preprocessing(String, Config)}</li>
|
||||
* <li>{@code AutoTagger.tag(String, Config, Float)}</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class InvalidPdfFileException extends IOException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public InvalidPdfFileException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public InvalidPdfFileException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+550
@@ -0,0 +1,550 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.html;
|
||||
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.opendataloader.pdf.entities.SemanticFormula;
|
||||
import org.opendataloader.pdf.entities.EnrichedImageChunk;
|
||||
import org.opendataloader.pdf.entities.SemanticPicture;
|
||||
import org.opendataloader.pdf.markdown.MarkdownSyntax;
|
||||
import org.opendataloader.pdf.utils.Base64ImageUtils;
|
||||
import org.opendataloader.pdf.utils.GeneratorUtils;
|
||||
import org.opendataloader.pdf.utils.ImagesUtils;
|
||||
import org.opendataloader.pdf.utils.OutputType;
|
||||
import org.verapdf.wcag.algorithms.entities.*;
|
||||
import org.verapdf.wcag.algorithms.entities.content.*;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListItem;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.PDFList;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.NodeUtils;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Generates HTML output from PDF document content.
|
||||
* Converts semantic elements like paragraphs, headings, tables, and images into HTML format.
|
||||
*/
|
||||
public class HtmlGenerator implements Closeable {
|
||||
|
||||
/** Logger for this class. */
|
||||
protected static final Logger LOGGER = Logger.getLogger(HtmlGenerator.class.getCanonicalName());
|
||||
|
||||
/** Writer for the HTML output file. */
|
||||
protected final FileWriter htmlWriter;
|
||||
/** Name of the input PDF file. */
|
||||
protected final String pdfFileName;
|
||||
/** Absolute path to the input PDF file. */
|
||||
protected final Path pdfFilePath;
|
||||
/** Name of the output HTML file. */
|
||||
protected final String htmlFileName;
|
||||
/** Absolute path to the output HTML file. */
|
||||
protected final Path htmlFilePath;
|
||||
/** Current table nesting level for tracking nested tables. */
|
||||
protected int tableNesting = 0;
|
||||
/** String to insert between pages in HTML output. */
|
||||
protected String htmlPageSeparator = "";
|
||||
/**
|
||||
* Page numbers (1-based) selected by --pages; an empty set means all pages.
|
||||
* Sourced from the raw {@link Config#getPageNumbers()} list (not the
|
||||
* validated set built by {@code DocumentProcessor.getValidPageNumbers}).
|
||||
* Safe to compare against {@code pageNumber + 1} because the surrounding
|
||||
* loop is bounded by the document's actual page count, so out-of-range
|
||||
* values from the raw list are never tested for membership.
|
||||
*/
|
||||
protected final Set<Integer> selectedPageNumbers;
|
||||
/** Whether to embed images as Base64 data URIs. */
|
||||
protected boolean embedImages = false;
|
||||
/** Format for extracted images (png or jpeg). */
|
||||
protected String imageFormat = Config.IMAGE_FORMAT_PNG;
|
||||
/** Whether to include page headers and footers in output. */
|
||||
protected boolean includeHeaderFooter = false;
|
||||
/** Opening tag for strikethrough text*/
|
||||
protected static final String strikethroughTextHtmlOpeningTag = "<del>";
|
||||
/** Closing tag for strikethrough text*/
|
||||
protected static final String strikethroughTextHtmlClosingTag = "</del>";;
|
||||
|
||||
/**
|
||||
* Creates a new HtmlGenerator for the specified PDF file.
|
||||
*
|
||||
* @param inputPdf the input PDF file
|
||||
* @param config the configuration settings
|
||||
* @throws IOException if unable to create the output file
|
||||
*/
|
||||
public HtmlGenerator(File inputPdf, Config config) throws IOException {
|
||||
this.pdfFileName = inputPdf.getName();
|
||||
this.pdfFilePath = inputPdf.toPath().toAbsolutePath();
|
||||
this.htmlFileName = pdfFileName.substring(0, pdfFileName.length() - 3) + "html";
|
||||
this.htmlFilePath = Path.of(config.getOutputFolder(), htmlFileName);
|
||||
this.htmlWriter = new FileWriter(htmlFilePath.toFile(), StandardCharsets.UTF_8);
|
||||
this.htmlPageSeparator = escapeHtmlAttribute(config.getHtmlPageSeparator());
|
||||
this.selectedPageNumbers = new HashSet<>(config.getPageNumbers());
|
||||
this.embedImages = config.isEmbedImages();
|
||||
this.imageFormat = config.getImageFormat();
|
||||
this.includeHeaderFooter = config.isIncludeHeaderFooter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the document contents to HTML format.
|
||||
*
|
||||
* @param contents the document contents organized by page
|
||||
*/
|
||||
public void writeToHtml(List<List<IObject>> contents) {
|
||||
try {
|
||||
htmlWriter.write("<!DOCTYPE html>\n");
|
||||
htmlWriter.write("<html lang=\"und\">\n<head>\n<meta charset=\"utf-8\">\n");
|
||||
htmlWriter.write("<title>" + escapeHtmlText(pdfFileName) + "</title>\n");
|
||||
htmlWriter.write("</head>\n<body>\n");
|
||||
|
||||
for (int pageNumber = 0; pageNumber < StaticContainers.getDocument().getNumberOfPages(); pageNumber++) {
|
||||
if (selectedPageNumbers.isEmpty() || selectedPageNumbers.contains(pageNumber + 1)) {
|
||||
writePageSeparator(pageNumber);
|
||||
}
|
||||
for (IObject content : contents.get(pageNumber)) {
|
||||
this.write(content);
|
||||
}
|
||||
}
|
||||
|
||||
htmlWriter.write("\n</body>\n</html>");
|
||||
LOGGER.log(Level.INFO, "Created {0}", htmlFilePath);
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.WARNING, "Unable to create html output: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a page separator to the HTML output if configured.
|
||||
*
|
||||
* @param pageNumber the current page number (0-indexed)
|
||||
* @throws IOException if unable to write to the output
|
||||
*/
|
||||
protected void writePageSeparator(int pageNumber) throws IOException {
|
||||
if (!htmlPageSeparator.isEmpty()) {
|
||||
htmlWriter.write(htmlPageSeparator.contains(Config.PAGE_NUMBER_STRING)
|
||||
? htmlPageSeparator.replace(Config.PAGE_NUMBER_STRING, String.valueOf(pageNumber + 1))
|
||||
: htmlPageSeparator);
|
||||
htmlWriter.write("\n");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a single content object to the HTML output.
|
||||
*
|
||||
* @param object the content object to write
|
||||
* @throws IOException if unable to write to the output
|
||||
*/
|
||||
protected void write(IObject object) throws IOException {
|
||||
if (object instanceof SemanticHeaderOrFooter) {
|
||||
if (includeHeaderFooter) {
|
||||
writeHeaderOrFooter((SemanticHeaderOrFooter) object);
|
||||
}
|
||||
return;
|
||||
} else if (object instanceof SemanticPicture) {
|
||||
writePicture((SemanticPicture) object);
|
||||
} else if (object instanceof ImageChunk) {
|
||||
writeImage((ImageChunk) object);
|
||||
} else if (object instanceof SemanticFormula) {
|
||||
writeFormula((SemanticFormula) object);
|
||||
} else if (object instanceof SemanticHeading) {
|
||||
writeHeading((SemanticHeading) object);
|
||||
} else if (object instanceof SemanticParagraph) {
|
||||
writeParagraph((SemanticParagraph) object);
|
||||
} else if (object instanceof SemanticTextNode) {
|
||||
writeSemanticTextNode((SemanticTextNode) object);
|
||||
} else if (object instanceof TableBorder) {
|
||||
writeTable((TableBorder) object);
|
||||
} else if (object instanceof PDFList) {
|
||||
writeList((PDFList) object);
|
||||
} else if (object instanceof SemanticTOC) {
|
||||
writeTOC((SemanticTOC) object);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInsideTable()) {
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a header or footer element to the HTML output.
|
||||
*
|
||||
* @param headerOrFooter the header or footer to write
|
||||
* @throws IOException if unable to write to the output
|
||||
*/
|
||||
protected void writeHeaderOrFooter(SemanticHeaderOrFooter headerOrFooter) throws IOException {
|
||||
for (IObject content : headerOrFooter.getContents()) {
|
||||
write(content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a formula element to the HTML output using MathJax-compatible markup.
|
||||
*
|
||||
* @param formula the formula to write
|
||||
* @throws IOException if unable to write to the output
|
||||
*/
|
||||
protected void writeFormula(SemanticFormula formula) throws IOException {
|
||||
htmlWriter.write(HtmlSyntax.HTML_MATH_DISPLAY_TAG);
|
||||
htmlWriter.write("\\[");
|
||||
htmlWriter.write(escapeHtmlText(formula.getLatex()));
|
||||
htmlWriter.write("\\]");
|
||||
htmlWriter.write(HtmlSyntax.HTML_MATH_DISPLAY_CLOSE_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an image element to the HTML output.
|
||||
*
|
||||
* @param image the image chunk to write
|
||||
*/
|
||||
protected void writeImage(ImageChunk image) {
|
||||
try {
|
||||
String absolutePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectory(), File.separator, image.getIndex(), imageFormat);
|
||||
String relativePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectoryName(), "/", image.getIndex(), imageFormat);
|
||||
|
||||
if (ImagesUtils.isImageFileExists(absolutePath)) {
|
||||
String imageSource;
|
||||
if (embedImages) {
|
||||
File imageFile = new File(absolutePath);
|
||||
imageSource = Base64ImageUtils.toDataUri(imageFile, imageFormat);
|
||||
if (imageSource == null) {
|
||||
LOGGER.log(Level.WARNING, "Failed to convert image to Base64: {0}", absolutePath);
|
||||
}
|
||||
} else {
|
||||
imageSource = relativePath;
|
||||
}
|
||||
if (imageSource != null) {
|
||||
String escapedSource = escapeHtmlAttribute(imageSource);
|
||||
// Empty alt is correct HTML for "missing description": screen
|
||||
// readers skip it, and our evidence-report flags it as
|
||||
// alt_source="missing". Never synthesize "figureN".
|
||||
String altText = (image instanceof EnrichedImageChunk && ((EnrichedImageChunk) image).hasDescription())
|
||||
? ((EnrichedImageChunk) image).sanitizeDescription()
|
||||
: "";
|
||||
String imageString = String.format("<img src=\"%s\" alt=\"%s\">", escapedSource, escapeHtmlAttribute(altText));
|
||||
htmlWriter.write(imageString);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.WARNING, "Unable to write image for html output: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a SemanticPicture element with figure/figcaption for description.
|
||||
*
|
||||
* @param picture the picture to write
|
||||
*/
|
||||
protected void writePicture(SemanticPicture picture) {
|
||||
try {
|
||||
String absolutePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectory(), File.separator, picture.getPictureIndex(), imageFormat);
|
||||
String relativePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectoryName(), "/", picture.getPictureIndex(), imageFormat);
|
||||
|
||||
if (ImagesUtils.isImageFileExists(absolutePath)) {
|
||||
String imageSource;
|
||||
if (embedImages) {
|
||||
File imageFile = new File(absolutePath);
|
||||
imageSource = Base64ImageUtils.toDataUri(imageFile, imageFormat);
|
||||
if (imageSource == null) {
|
||||
LOGGER.log(Level.WARNING, "Failed to convert image to Base64: {0}", absolutePath);
|
||||
}
|
||||
} else {
|
||||
imageSource = relativePath;
|
||||
}
|
||||
if (imageSource != null) {
|
||||
String altText = picture.hasDescription()
|
||||
? picture.sanitizeDescription()
|
||||
: "";
|
||||
String escapedSource = escapeHtmlAttribute(imageSource);
|
||||
|
||||
htmlWriter.write(HtmlSyntax.HTML_FIGURE_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
String imageString = String.format("<img src=\"%s\" alt=\"%s\">", escapedSource, escapeHtmlAttribute(altText));
|
||||
htmlWriter.write(imageString);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
htmlWriter.write(HtmlSyntax.HTML_FIGURE_CLOSE_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.WARNING, "Unable to write picture for html output: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a list element to the HTML output.
|
||||
*
|
||||
* @param list the PDF list to write
|
||||
* @throws IOException if unable to write to the output
|
||||
*/
|
||||
protected void writeList(PDFList list) throws IOException {
|
||||
htmlWriter.write(HtmlSyntax.HTML_UNORDERED_LIST_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
for (ListItem item : list.getListItems()) {
|
||||
htmlWriter.write(HtmlSyntax.HTML_LIST_ITEM_TAG);
|
||||
|
||||
htmlWriter.write(HtmlSyntax.HTML_PARAGRAPH_TAG);
|
||||
String value = GeneratorUtils.getTextFromLines(item.getLines(), OutputType.HTML);
|
||||
htmlWriter.write(value);
|
||||
htmlWriter.write(HtmlSyntax.HTML_PARAGRAPH_CLOSE_TAG);
|
||||
|
||||
for (IObject object : item.getContents()) {
|
||||
write(object);
|
||||
}
|
||||
htmlWriter.write(HtmlSyntax.HTML_LIST_ITEM_CLOSE_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
htmlWriter.write(HtmlSyntax.HTML_UNORDERED_LIST_CLOSE_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
|
||||
protected void writeTOC(SemanticTOC toc) throws IOException {
|
||||
htmlWriter.write(HtmlSyntax.HTML_UNORDERED_LIST_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
for (IObject item : toc.getTOCItems()) {
|
||||
if (item instanceof SemanticTOC) {
|
||||
htmlWriter.write(HtmlSyntax.HTML_LIST_ITEM_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
writeTOC((SemanticTOC) item);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LIST_ITEM_CLOSE_TAG);
|
||||
} else if (item instanceof SemanticTOCI) {
|
||||
SemanticTOCI tocItem = (SemanticTOCI) item;
|
||||
htmlWriter.write(HtmlSyntax.HTML_LIST_ITEM_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_PARAGRAPH_TAG);
|
||||
String value = GeneratorUtils.getTextFromLines(tocItem.getLines(), OutputType.HTML);
|
||||
htmlWriter.write(value);
|
||||
htmlWriter.write(HtmlSyntax.HTML_PARAGRAPH_CLOSE_TAG);
|
||||
for (IObject object : tocItem.getContents()) {
|
||||
write(object);
|
||||
}
|
||||
htmlWriter.write(HtmlSyntax.HTML_LIST_ITEM_CLOSE_TAG);
|
||||
}
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
htmlWriter.write(HtmlSyntax.HTML_UNORDERED_LIST_CLOSE_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a semantic text node as a figure caption to the HTML output.
|
||||
*
|
||||
* @param textNode the text node to write
|
||||
* @throws IOException if unable to write to the output
|
||||
*/
|
||||
protected void writeSemanticTextNode(SemanticTextNode textNode) throws IOException {
|
||||
htmlWriter.write(HtmlSyntax.HTML_FIGURE_CAPTION_TAG);
|
||||
htmlWriter.write(GeneratorUtils.getTextFromTextNode(textNode, OutputType.HTML));
|
||||
htmlWriter.write(HtmlSyntax.HTML_FIGURE_CAPTION_CLOSE_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a table element to the HTML output.
|
||||
*
|
||||
* @param table the table border to write
|
||||
* @throws IOException if unable to write to the output
|
||||
*/
|
||||
protected void writeTable(TableBorder table) throws IOException {
|
||||
enterTable();
|
||||
htmlWriter.write(HtmlSyntax.HTML_TABLE_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
for (int rowNumber = 0; rowNumber < table.getNumberOfRows(); rowNumber++) {
|
||||
TableBorderRow row = table.getRow(rowNumber);
|
||||
htmlWriter.write(HtmlSyntax.HTML_TABLE_ROW_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
for (int colNumber = 0; colNumber < table.getNumberOfColumns(); colNumber++) {
|
||||
TableBorderCell cell = row.getCell(colNumber);
|
||||
if (cell.getRowNumber() == rowNumber && cell.getColNumber() == colNumber) {
|
||||
writeCellTag(cell);
|
||||
List<IObject> contents = cell.getContents();
|
||||
if (!contents.isEmpty()) {
|
||||
for (IObject contentItem : contents) {
|
||||
this.write(contentItem);
|
||||
}
|
||||
}
|
||||
if (cell.isHeaderCell()) {
|
||||
htmlWriter.write(HtmlSyntax.HTML_TABLE_HEADER_CLOSE_TAG);
|
||||
} else {
|
||||
htmlWriter.write(HtmlSyntax.HTML_TABLE_CELL_CLOSE_TAG);
|
||||
}
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
}
|
||||
|
||||
htmlWriter.write(HtmlSyntax.HTML_TABLE_ROW_CLOSE_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
|
||||
htmlWriter.write(HtmlSyntax.HTML_TABLE_CLOSE_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
leaveTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a paragraph element to the HTML output.
|
||||
*
|
||||
* @param paragraph the semantic paragraph to write
|
||||
* @throws IOException if unable to write to the output
|
||||
*/
|
||||
protected void writeParagraph(SemanticParagraph paragraph) throws IOException {
|
||||
double paragraphIndent = paragraph.getColumns().get(0).getBlocks().get(0).getFirstLineIndent();
|
||||
|
||||
htmlWriter.write(HtmlSyntax.HTML_PARAGRAPH_TAG);
|
||||
if (paragraphIndent > 0) {
|
||||
htmlWriter.write(HtmlSyntax.HTML_INDENT);
|
||||
}
|
||||
String paragraphValue = GeneratorUtils.getTextFromTextNode(paragraph, OutputType.HTML);
|
||||
|
||||
if (isInsideTable() && StaticContainers.isKeepLineBreaks()) {
|
||||
paragraphValue = paragraphValue.replace(HtmlSyntax.HTML_LINE_BREAK, HtmlSyntax.HTML_LINE_BREAK_TAG);
|
||||
}
|
||||
|
||||
htmlWriter.write(paragraphValue);
|
||||
htmlWriter.write(HtmlSyntax.HTML_PARAGRAPH_CLOSE_TAG);
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a heading element to the HTML output.
|
||||
*
|
||||
* @param heading the semantic heading to write
|
||||
* @throws IOException if unable to write to the output
|
||||
*/
|
||||
protected void writeHeading(SemanticHeading heading) throws IOException {
|
||||
int headingLevel = Math.min(6, Math.max(1, heading.getHeadingLevel()));
|
||||
htmlWriter.write("<h" + headingLevel + ">");
|
||||
htmlWriter.write(GeneratorUtils.getTextFromTextNode(heading, OutputType.HTML));
|
||||
htmlWriter.write("</h" + headingLevel + ">");
|
||||
htmlWriter.write(HtmlSyntax.HTML_LINE_BREAK);
|
||||
}
|
||||
|
||||
private void writeCellTag(TableBorderCell cell) throws IOException {
|
||||
String tag = cell.isHeaderCell() ? "<th" : "<td";
|
||||
StringBuilder cellTag = new StringBuilder(tag);
|
||||
int colSpan = cell.getColSpan();
|
||||
if (colSpan != 1) {
|
||||
cellTag.append(" colspan=\"").append(colSpan).append("\"");
|
||||
}
|
||||
|
||||
int rowSpan = cell.getRowSpan();
|
||||
if (rowSpan != 1) {
|
||||
cellTag.append(" rowspan=\"").append(rowSpan).append("\"");
|
||||
}
|
||||
cellTag.append(">");
|
||||
htmlWriter.write(cellTag.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the table nesting level when entering a table.
|
||||
*/
|
||||
protected void enterTable() {
|
||||
tableNesting++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrements the table nesting level when leaving a table.
|
||||
*/
|
||||
protected void leaveTable() {
|
||||
if (tableNesting > 0) {
|
||||
tableNesting--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether currently writing inside a table.
|
||||
*
|
||||
* @return true if inside a table, false otherwise
|
||||
*/
|
||||
protected boolean isInsideTable() {
|
||||
return tableNesting > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes special characters for use in HTML attributes.
|
||||
* Handles quotes, ampersands, less-than, greater-than, and newlines.
|
||||
*
|
||||
* @param value the string to escape
|
||||
* @return the escaped string safe for HTML attribute values
|
||||
*/
|
||||
protected static String escapeHtmlAttribute(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
value = escapeHtmlText(value);
|
||||
return value
|
||||
.replace("\"", """)
|
||||
.replace("\n", " ")
|
||||
.replace("\r", "");
|
||||
}
|
||||
|
||||
protected static String escapeHtmlText(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value
|
||||
.replace("\u0000", "")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
}
|
||||
|
||||
public static void getTextFromLineForHTML(TextLine line, StringBuilder stringBuilder) {
|
||||
for (TextChunk chunk : line.getTextChunks()) {
|
||||
String style = getTextStyle(chunk);
|
||||
if (!style.isEmpty()) {
|
||||
String styleAttribute = String.format(HtmlSyntax.HTML_STYLE_ATTRIBUTE, style.trim());
|
||||
stringBuilder.append(String.format(HtmlSyntax.HTML_SPAN_START_TAG, styleAttribute));
|
||||
stringBuilder.append(escapeHtmlText(chunk.getValue()));
|
||||
stringBuilder.append(HtmlSyntax.HTML_SPAN_CLOSE_TAG);
|
||||
} else {
|
||||
stringBuilder.append(escapeHtmlText(chunk.getValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String getTextStyle(TextChunk chunk) {
|
||||
StringBuilder style = new StringBuilder();
|
||||
if (chunk.getIsStrikethroughText() || chunk.getIsUnderlinedText()) {
|
||||
style.append(String.format(HtmlSyntax.HTML_TEXT_DECORATION_STYLE_PROPERTY,
|
||||
(chunk.getIsStrikethroughText() ? HtmlSyntax.HTML_STRIKETHROUGH_VALUE : "") +
|
||||
(chunk.getIsUnderlinedText() ? HtmlSyntax.HTML_UNDERLINE_VALUE : "")));
|
||||
}
|
||||
return style.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (htmlWriter != null) {
|
||||
htmlWriter.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.html;
|
||||
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Factory class for creating HtmlGenerator instances.
|
||||
*/
|
||||
public class HtmlGeneratorFactory {
|
||||
|
||||
/**
|
||||
* Creates a new HtmlGenerator for the specified PDF file.
|
||||
*
|
||||
* @param inputPdf the input PDF file
|
||||
* @param config the configuration settings
|
||||
* @return a new HtmlGenerator instance
|
||||
* @throws IOException if unable to create the generator
|
||||
*/
|
||||
public static HtmlGenerator getHtmlGenerator(File inputPdf, Config config) throws IOException {
|
||||
return new HtmlGenerator(inputPdf, config);
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.html;
|
||||
|
||||
/**
|
||||
* Constants for HTML syntax elements used in HTML output generation.
|
||||
*/
|
||||
public class HtmlSyntax {
|
||||
/** Format string for image file names. */
|
||||
public static final String IMAGE_FILE_NAME_FORMAT = "figure%d.png";
|
||||
/** Line break character for HTML output. */
|
||||
public static final String HTML_LINE_BREAK = "\n";
|
||||
/** Opening table tag with border. */
|
||||
public static final String HTML_TABLE_TAG = "<table border=\"1\">";
|
||||
/** Closing table tag. */
|
||||
public static final String HTML_TABLE_CLOSE_TAG = "</table>";
|
||||
/** Opening table row tag. */
|
||||
public static final String HTML_TABLE_ROW_TAG = "<tr>";
|
||||
/** Closing table row tag. */
|
||||
public static final String HTML_TABLE_ROW_CLOSE_TAG = "</tr>";
|
||||
/** Opening table cell tag. */
|
||||
public static final String HTML_TABLE_CELL_TAG = "<td>";
|
||||
/** Closing table cell tag. */
|
||||
public static final String HTML_TABLE_CELL_CLOSE_TAG = "</td>";
|
||||
/** Opening table header cell tag. */
|
||||
public static final String HTML_TABLE_HEADER_TAG = "<th>";
|
||||
/** Closing table header cell tag. */
|
||||
public static final String HTML_TABLE_HEADER_CLOSE_TAG = "</th>";
|
||||
/** Opening ordered list tag. */
|
||||
public static final String HTML_ORDERED_LIST_TAG = "<ol>";
|
||||
/** Closing ordered list tag. */
|
||||
public static final String HTML_ORDERED_LIST_CLOSE_TAG = "</ol>";
|
||||
/** Opening unordered list tag. */
|
||||
public static final String HTML_UNORDERED_LIST_TAG = "<ul>";
|
||||
/** Closing unordered list tag. */
|
||||
public static final String HTML_UNORDERED_LIST_CLOSE_TAG = "</ul>";
|
||||
/** Opening list item tag. */
|
||||
public static final String HTML_LIST_ITEM_TAG = "<li>";
|
||||
/** Closing list item tag. */
|
||||
public static final String HTML_LIST_ITEM_CLOSE_TAG = "</li>";
|
||||
/** HTML line break tag. */
|
||||
public static final String HTML_LINE_BREAK_TAG = "<br>";
|
||||
/** Indentation string for paragraphs. */
|
||||
public static final String HTML_INDENT = "";
|
||||
/** Opening paragraph tag. */
|
||||
public static final String HTML_PARAGRAPH_TAG = "<p>";
|
||||
/** Closing paragraph tag. */
|
||||
public static final String HTML_PARAGRAPH_CLOSE_TAG = "</p>";
|
||||
/** Opening figure tag. */
|
||||
public static final String HTML_FIGURE_TAG = "<figure>";
|
||||
/** Closing figure tag. */
|
||||
public static final String HTML_FIGURE_CLOSE_TAG = "</figure>";
|
||||
/** Opening figure caption tag. */
|
||||
public static final String HTML_FIGURE_CAPTION_TAG = "<figcaption>";
|
||||
/** Closing figure caption tag. */
|
||||
public static final String HTML_FIGURE_CAPTION_CLOSE_TAG = "</figcaption>";
|
||||
/** Opening math display block tag for MathJax/KaTeX rendering. */
|
||||
public static final String HTML_MATH_DISPLAY_TAG = "<div class=\"math-display\">";
|
||||
/** Closing math display block tag. */
|
||||
public static final String HTML_MATH_DISPLAY_CLOSE_TAG = "</div>";
|
||||
/** Span opening tag. */
|
||||
public static final String HTML_SPAN_START_TAG = "<span%s>";
|
||||
/** Style attribute */
|
||||
public static final String HTML_STYLE_ATTRIBUTE = " style=\"%s\"";
|
||||
/** Span closing tag. */
|
||||
public static final String HTML_SPAN_CLOSE_TAG = "</span>";
|
||||
/** Text decoration property. */
|
||||
public static final String HTML_TEXT_DECORATION_STYLE_PROPERTY = "text-decoration:%s; ";
|
||||
/** Strikethrough text property. */
|
||||
public static final String HTML_STRIKETHROUGH_VALUE = " line-through";
|
||||
/** Underline text property. */
|
||||
public static final String HTML_UNDERLINE_VALUE = " underline";
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Disk-backed page image cache. Stores page images as PNG files in a
|
||||
* temporary directory. evict() is a no-op (keeps files for potential re-read).
|
||||
* close() deletes the temp directory and all files.
|
||||
*/
|
||||
public class DiskPageImageCache implements PageImageCache {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(DiskPageImageCache.class.getCanonicalName());
|
||||
|
||||
private final Path tempDir;
|
||||
|
||||
public DiskPageImageCache() throws IOException {
|
||||
this.tempDir = Files.createTempDirectory("odl-pages-");
|
||||
}
|
||||
|
||||
// Visible for testing
|
||||
DiskPageImageCache(Path tempDir) {
|
||||
this.tempDir = tempDir;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedImage getOrFetch(int pageIndex, PageImageFetcher fetcher) throws IOException {
|
||||
Path file = tempDir.resolve("page-" + pageIndex + ".png");
|
||||
if (Files.exists(file)) {
|
||||
BufferedImage cached = ImageIO.read(file.toFile());
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
// Cached file is unreadable (corrupt or no ImageReader) — re-fetch.
|
||||
LOGGER.log(Level.WARNING, "Cached page image is unreadable, re-fetching: {0}", file);
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
BufferedImage image = fetcher.fetch(pageIndex);
|
||||
if (image == null) {
|
||||
throw new IOException("Page image fetcher returned null for page " + pageIndex);
|
||||
}
|
||||
if (!ImageIO.write(image, "png", file.toFile())) {
|
||||
throw new IOException("No ImageIO writer accepted PNG output for page " + pageIndex);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evict(int pageIndex) {
|
||||
// no-op: keep on disk for potential re-read
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (!Files.exists(tempDir)) {
|
||||
return;
|
||||
}
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(tempDir)) {
|
||||
for (Path entry : stream) {
|
||||
try {
|
||||
Files.deleteIfExists(entry);
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.WARNING, "Failed to delete temp file: {0}", entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
Files.deleteIfExists(tempDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the temp directory path (for testing).
|
||||
*/
|
||||
Path getTempDir() {
|
||||
return tempDir;
|
||||
}
|
||||
}
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* HTTP client for docling-fast-server API.
|
||||
*
|
||||
* <p>This client communicates with the optimized FastAPI server (opendataloader-pdf-hybrid)
|
||||
* which provides 3.3x faster performance than docling-serve by using a DocumentConverter
|
||||
* singleton pattern.
|
||||
*
|
||||
* <p>The API is compatible with docling-serve, using the same /v1/convert/file endpoint
|
||||
* and response format.
|
||||
*
|
||||
* @see HybridClient
|
||||
* @see HybridConfig
|
||||
*/
|
||||
public class DoclingFastServerClient implements HybridClient {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(DoclingFastServerClient.class.getCanonicalName());
|
||||
|
||||
/** Default URL for docling-fast-server. */
|
||||
public static final String DEFAULT_URL = "http://localhost:5002";
|
||||
|
||||
private static final String CONVERT_ENDPOINT = "/v1/convert/file";
|
||||
private static final String HEALTH_ENDPOINT = "/health";
|
||||
private static final int HEALTH_CHECK_TIMEOUT_MS = 3000;
|
||||
private static final String DEFAULT_FILENAME = "document.pdf";
|
||||
private static final MediaType MEDIA_TYPE_PDF = MediaType.parse("application/pdf");
|
||||
|
||||
private final String baseUrl;
|
||||
private final OkHttpClient httpClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Creates a new DoclingFastServerClient with the specified configuration.
|
||||
*
|
||||
* @param config The hybrid configuration containing URL and timeout settings.
|
||||
*/
|
||||
public DoclingFastServerClient(HybridConfig config) {
|
||||
this.baseUrl = config.getEffectiveUrl("docling-fast");
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.httpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(config.getTimeoutMs(), TimeUnit.MILLISECONDS)
|
||||
.readTimeout(config.getTimeoutMs(), TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(config.getTimeoutMs(), TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new DoclingFastServerClient with a custom OkHttpClient (for testing).
|
||||
*
|
||||
* @param baseUrl The base URL of the docling-fast-server instance.
|
||||
* @param httpClient The OkHttp client to use for requests.
|
||||
* @param objectMapper The Jackson ObjectMapper for JSON parsing.
|
||||
*/
|
||||
DoclingFastServerClient(String baseUrl, OkHttpClient httpClient, ObjectMapper objectMapper) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.httpClient = httpClient;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkAvailability() throws IOException {
|
||||
OkHttpClient healthClient = httpClient.newBuilder()
|
||||
.connectTimeout(HEALTH_CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(HEALTH_CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
|
||||
Request healthRequest = new Request.Builder()
|
||||
.url(baseUrl + HEALTH_ENDPOINT)
|
||||
.get()
|
||||
.build();
|
||||
|
||||
Response response;
|
||||
try {
|
||||
response = healthClient.newCall(healthRequest).execute();
|
||||
} catch (IOException e) {
|
||||
throw new IOException(
|
||||
"Hybrid server is not available at " + baseUrl + "\n"
|
||||
+ "To start the local hybrid server:\n"
|
||||
+ " 1. Install: pip install \"opendataloader-pdf[hybrid]\"\n"
|
||||
+ " 2. Start: opendataloader-pdf-hybrid --port 5002\n"
|
||||
+ "To use a remote server or custom port: --hybrid-url http://host:port\n"
|
||||
+ "Or pass --hybrid-fallback to fall back to Java-only output for this run.\n"
|
||||
+ "Or run without --hybrid flag for Java-only processing.", e);
|
||||
}
|
||||
try (response) {
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IOException(
|
||||
"Hybrid server at " + baseUrl + " returned HTTP " + response.code()
|
||||
+ " during health check.\n"
|
||||
+ "The server is reachable but may be starting up or unhealthy.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public HybridResponse convert(HybridRequest request) throws IOException {
|
||||
Request httpRequest = buildConvertRequest(request);
|
||||
LOGGER.log(Level.FINE, "Sending request to {0}", baseUrl + CONVERT_ENDPOINT);
|
||||
|
||||
try (Response response = httpClient.newCall(httpRequest).execute()) {
|
||||
return parseResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<HybridResponse> convertAsync(HybridRequest request) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return convert(request);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Failed to convert", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base URL of this client.
|
||||
*
|
||||
* @return The base URL.
|
||||
*/
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a multipart/form-data HTTP request for the convert endpoint.
|
||||
*/
|
||||
private Request buildConvertRequest(HybridRequest request) {
|
||||
MultipartBody.Builder bodyBuilder = new MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("files", DEFAULT_FILENAME,
|
||||
RequestBody.create(request.getPdfBytes(), MEDIA_TYPE_PDF));
|
||||
|
||||
// Add page range if specified
|
||||
if (request.getPageNumbers() != null && !request.getPageNumbers().isEmpty()) {
|
||||
int minPage = request.getPageNumbers().stream().min(Integer::compareTo).orElse(1);
|
||||
int maxPage = request.getPageNumbers().stream().max(Integer::compareTo).orElse(Integer.MAX_VALUE);
|
||||
bodyBuilder.addFormDataPart("page_ranges", minPage + "-" + maxPage);
|
||||
}
|
||||
|
||||
return new Request.Builder()
|
||||
.url(baseUrl + CONVERT_ENDPOINT)
|
||||
.post(bodyBuilder.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the HTTP response into a HybridResponse.
|
||||
*/
|
||||
private HybridResponse parseResponse(Response response) throws IOException {
|
||||
if (!response.isSuccessful()) {
|
||||
ResponseBody body = response.body();
|
||||
String bodyStr = body != null ? body.string() : "";
|
||||
throw new IOException("Docling Fast Server request failed with status " + response.code() +
|
||||
": " + bodyStr);
|
||||
}
|
||||
|
||||
ResponseBody body = response.body();
|
||||
if (body == null) {
|
||||
throw new IOException("Empty response body");
|
||||
}
|
||||
|
||||
String responseStr = body.string();
|
||||
JsonNode root = objectMapper.readTree(responseStr);
|
||||
|
||||
// Check for API error status
|
||||
JsonNode statusNode = root.get("status");
|
||||
String status = statusNode != null ? statusNode.asText() : "";
|
||||
if ("failure".equals(status)) {
|
||||
JsonNode errorsNode = root.get("errors");
|
||||
String errorMessage = errorsNode != null ? errorsNode.toString() : "Unknown error";
|
||||
throw new IOException("Docling Fast Server processing failed: " + errorMessage);
|
||||
}
|
||||
|
||||
// Log partial_success status
|
||||
if ("partial_success".equals(status)) {
|
||||
JsonNode errorsNode = root.get("errors");
|
||||
LOGGER.log(Level.WARNING, "Backend returned partial_success: {0}",
|
||||
errorsNode != null ? errorsNode.toString() : "no error details");
|
||||
}
|
||||
|
||||
// Extract document content
|
||||
JsonNode documentNode = root.get("document");
|
||||
if (documentNode == null) {
|
||||
throw new IOException("Invalid response: missing 'document' field");
|
||||
}
|
||||
|
||||
JsonNode jsonContent = documentNode.get("json_content");
|
||||
|
||||
// Extract per-page content from json_content if available
|
||||
Map<Integer, JsonNode> pageContents = extractPageContents(jsonContent);
|
||||
|
||||
// Extract failed pages (1-indexed) from partial_success responses
|
||||
List<Integer> failedPages = extractFailedPages(root);
|
||||
|
||||
// Extract per-step pipeline timings (layout, ocr, table_structure, etc.)
|
||||
JsonNode timingsNode = root.get("timings");
|
||||
|
||||
return new HybridResponse(null, null, jsonContent, pageContents, failedPages, timingsNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts per-page content from the DoclingDocument JSON structure.
|
||||
*
|
||||
* <p>The DoclingDocument stores page information in the "pages" object,
|
||||
* keyed by page number (as string). This method extracts the content
|
||||
* elements for each page based on the "prov" (provenance) information.
|
||||
*/
|
||||
private Map<Integer, JsonNode> extractPageContents(JsonNode jsonContent) {
|
||||
Map<Integer, JsonNode> pageContents = new HashMap<>();
|
||||
|
||||
if (jsonContent == null) {
|
||||
return pageContents;
|
||||
}
|
||||
|
||||
// The pages node contains page metadata keyed by page number
|
||||
JsonNode pagesNode = jsonContent.get("pages");
|
||||
if (pagesNode != null && pagesNode.isObject()) {
|
||||
Iterator<String> fieldNames = pagesNode.fieldNames();
|
||||
while (fieldNames.hasNext()) {
|
||||
String pageNumStr = fieldNames.next();
|
||||
try {
|
||||
int pageNum = Integer.parseInt(pageNumStr);
|
||||
pageContents.put(pageNum, pagesNode.get(pageNumStr));
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Skip non-numeric page keys
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pageContents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the list of failed page numbers from the response.
|
||||
*
|
||||
* <p>When the backend returns partial_success, the failed_pages array contains
|
||||
* 1-indexed page numbers that failed during processing (e.g., due to Invalid code point
|
||||
* errors in PDF font encoding).
|
||||
*/
|
||||
private List<Integer> extractFailedPages(JsonNode root) {
|
||||
JsonNode failedPagesNode = root.get("failed_pages");
|
||||
if (failedPagesNode == null || !failedPagesNode.isArray() || failedPagesNode.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Integer> failedPages = new ArrayList<>();
|
||||
for (JsonNode pageNode : failedPagesNode) {
|
||||
if (pageNode.isNumber() && pageNode.canConvertToInt()) {
|
||||
failedPages.add(pageNode.asInt());
|
||||
}
|
||||
}
|
||||
return failedPages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts down the HTTP client and releases all resources.
|
||||
*
|
||||
* <p>This gracefully shuts down the dispatcher's executor service,
|
||||
* allowing the JVM to exit cleanly. Idle connections are evicted
|
||||
* from the connection pool.
|
||||
*/
|
||||
public void shutdown() {
|
||||
// Gracefully shutdown the dispatcher - allows pending requests to complete
|
||||
httpClient.dispatcher().executorService().shutdown();
|
||||
// Evict idle connections from pool (does not affect the server)
|
||||
httpClient.connectionPool().evictAll();
|
||||
// Close the cache if present
|
||||
if (httpClient.cache() != null) {
|
||||
try {
|
||||
httpClient.cache().close();
|
||||
} catch (Exception ignored) {
|
||||
// Ignore cache close errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+621
@@ -0,0 +1,621 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.opendataloader.pdf.entities.SemanticFormula;
|
||||
import org.opendataloader.pdf.entities.SemanticPicture;
|
||||
import org.opendataloader.pdf.hybrid.HybridClient.HybridResponse;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticHeading;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticParagraph;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextLine;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.SemanticType;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Transforms Docling JSON output to OpenDataLoader IObject hierarchy.
|
||||
*
|
||||
* <p>This transformer handles the DoclingDocument JSON format and converts
|
||||
* its elements (texts, tables, pictures) to the equivalent IObject types
|
||||
* used by OpenDataLoader's downstream processors and generators.
|
||||
*
|
||||
* <h2>Schema Mapping</h2>
|
||||
* <ul>
|
||||
* <li>texts (label: text) → SemanticParagraph</li>
|
||||
* <li>texts (label: section_header) → SemanticHeading</li>
|
||||
* <li>texts (label: caption, footnote) → SemanticParagraph</li>
|
||||
* <li>texts (label: page_header, page_footer) → Filtered out (furniture)</li>
|
||||
* <li>tables → TableBorder with rows and cells</li>
|
||||
* <li>pictures → SemanticPicture (with optional description)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Coordinate System</h2>
|
||||
* <p>Both Docling and OpenDataLoader use BOTTOMLEFT origin. Docling provides
|
||||
* bbox as {l, t, r, b} while OpenDataLoader uses [left, bottom, right, top].
|
||||
* When Docling uses TOPLEFT origin, coordinates are converted appropriately.
|
||||
*
|
||||
* <h2>Thread Safety</h2>
|
||||
* <p>This class is NOT thread-safe. The {@code transform()} method updates
|
||||
* internal state (pictureIndex) during each call. Concurrent calls
|
||||
* to transform() on the same instance may produce incorrect results.
|
||||
* Use separate instances for concurrent transformations.
|
||||
*/
|
||||
public class DoclingSchemaTransformer implements HybridSchemaTransformer {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(DoclingSchemaTransformer.class.getCanonicalName());
|
||||
|
||||
private static final String BACKEND_TYPE = "docling";
|
||||
|
||||
// Picture index counter — accumulates across transform() calls on the same instance
|
||||
// to ensure document-unique indices when processing chunked responses (#352).
|
||||
private int pictureIndex;
|
||||
|
||||
// Docling text labels
|
||||
private static final String LABEL_TEXT = "text";
|
||||
private static final String LABEL_SECTION_HEADER = "section_header";
|
||||
private static final String LABEL_CAPTION = "caption";
|
||||
private static final String LABEL_FOOTNOTE = "footnote";
|
||||
private static final String LABEL_PAGE_HEADER = "page_header";
|
||||
private static final String LABEL_PAGE_FOOTER = "page_footer";
|
||||
private static final String LABEL_LIST_ITEM = "list_item";
|
||||
private static final String LABEL_FORMULA = "formula";
|
||||
|
||||
// Docling coordinate origins
|
||||
private static final String COORD_ORIGIN_BOTTOMLEFT = "BOTTOMLEFT";
|
||||
private static final String COORD_ORIGIN_TOPLEFT = "TOPLEFT";
|
||||
|
||||
@Override
|
||||
public String getBackendType() {
|
||||
return BACKEND_TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<List<IObject>> transform(HybridResponse response, Map<Integer, Double> pageHeights) {
|
||||
JsonNode json = response.getJson();
|
||||
if (json == null) {
|
||||
LOGGER.log(Level.WARNING, "HybridResponse JSON is null, returning empty result");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// Note: pictureIndex is NOT reset here — it must accumulate across
|
||||
// multiple transform() calls when processing chunked responses (#352).
|
||||
// Each transformer instance starts with pictureIndex=0 (field default),
|
||||
// so single-call usage is unaffected.
|
||||
|
||||
// Determine number of pages from page info or content
|
||||
int numPages = determinePageCount(json, pageHeights);
|
||||
|
||||
// Initialize result list
|
||||
List<List<IObject>> result = new ArrayList<>(numPages);
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
result.add(new ArrayList<>());
|
||||
}
|
||||
|
||||
// Transform texts
|
||||
JsonNode texts = json.get("texts");
|
||||
if (texts != null && texts.isArray()) {
|
||||
for (JsonNode textNode : texts) {
|
||||
transformText(textNode, result, pageHeights);
|
||||
}
|
||||
}
|
||||
|
||||
// Transform tables
|
||||
JsonNode tables = json.get("tables");
|
||||
if (tables != null && tables.isArray()) {
|
||||
for (JsonNode tableNode : tables) {
|
||||
transformTable(tableNode, result, pageHeights);
|
||||
}
|
||||
}
|
||||
|
||||
// Transform pictures
|
||||
JsonNode pictures = json.get("pictures");
|
||||
if (pictures != null && pictures.isArray()) {
|
||||
for (JsonNode pictureNode : pictures) {
|
||||
transformPicture(pictureNode, result, pageHeights);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each page's contents by reading order (top to bottom, left to right)
|
||||
for (List<IObject> pageContents : result) {
|
||||
sortByReadingOrder(pageContents);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IObject> transformPage(int pageNumber, JsonNode pageContent, double pageHeight) {
|
||||
Map<Integer, Double> pageHeights = new HashMap<>();
|
||||
pageHeights.put(pageNumber, pageHeight);
|
||||
|
||||
// Create a wrapper response with just this page's content
|
||||
HybridResponse singlePageResponse = new HybridResponse("", pageContent, Collections.emptyMap());
|
||||
List<List<IObject>> result = transform(singlePageResponse, pageHeights);
|
||||
|
||||
if (result.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// Find the page in the result
|
||||
int pageIndex = pageNumber - 1;
|
||||
if (pageIndex >= 0 && pageIndex < result.size()) {
|
||||
return result.get(pageIndex);
|
||||
}
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the number of pages from the JSON response.
|
||||
*/
|
||||
private int determinePageCount(JsonNode json, Map<Integer, Double> pageHeights) {
|
||||
// First check pageHeights if provided
|
||||
if (pageHeights != null && !pageHeights.isEmpty()) {
|
||||
return pageHeights.keySet().stream().mapToInt(Integer::intValue).max().orElse(0);
|
||||
}
|
||||
|
||||
// Check pages array in JSON
|
||||
JsonNode pages = json.get("pages");
|
||||
if (pages != null && pages.isArray()) {
|
||||
return pages.size();
|
||||
}
|
||||
|
||||
// Check page_dimensions or similar
|
||||
JsonNode pageDimensions = json.get("page_dimensions");
|
||||
if (pageDimensions != null && pageDimensions.isObject()) {
|
||||
int maxPage = 0;
|
||||
Iterator<String> fieldNames = pageDimensions.fieldNames();
|
||||
while (fieldNames.hasNext()) {
|
||||
try {
|
||||
int pageNum = Integer.parseInt(fieldNames.next());
|
||||
maxPage = Math.max(maxPage, pageNum);
|
||||
} catch (NumberFormatException e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return maxPage;
|
||||
}
|
||||
|
||||
// Default to scanning content
|
||||
return scanContentForPageCount(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans content elements to determine page count.
|
||||
*/
|
||||
private int scanContentForPageCount(JsonNode json) {
|
||||
int maxPage = 0;
|
||||
|
||||
JsonNode texts = json.get("texts");
|
||||
if (texts != null && texts.isArray()) {
|
||||
for (JsonNode text : texts) {
|
||||
maxPage = Math.max(maxPage, getPageNumberFromProv(text));
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode tables = json.get("tables");
|
||||
if (tables != null && tables.isArray()) {
|
||||
for (JsonNode table : tables) {
|
||||
maxPage = Math.max(maxPage, getPageNumberFromProv(table));
|
||||
}
|
||||
}
|
||||
|
||||
return maxPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts page number from provenance info.
|
||||
*/
|
||||
private int getPageNumberFromProv(JsonNode node) {
|
||||
JsonNode prov = node.get("prov");
|
||||
if (prov != null && prov.isArray() && prov.size() > 0) {
|
||||
JsonNode firstProv = prov.get(0);
|
||||
JsonNode pageNo = firstProv.get("page_no");
|
||||
if (pageNo != null && pageNo.isInt()) {
|
||||
return pageNo.asInt();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a Docling text element to an IObject.
|
||||
*/
|
||||
private void transformText(JsonNode textNode, List<List<IObject>> result, Map<Integer, Double> pageHeights) {
|
||||
String label = getTextValue(textNode, "label");
|
||||
|
||||
// Skip furniture elements (page headers/footers)
|
||||
if (LABEL_PAGE_HEADER.equals(label) || LABEL_PAGE_FOOTER.equals(label)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get provenance for position info
|
||||
JsonNode prov = textNode.get("prov");
|
||||
if (prov == null || !prov.isArray() || prov.size() == 0) {
|
||||
LOGGER.log(Level.FINE, "Text element missing provenance, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonNode firstProv = prov.get(0);
|
||||
int pageNo = firstProv.has("page_no") ? firstProv.get("page_no").asInt() : 1;
|
||||
int pageIndex = pageNo - 1;
|
||||
|
||||
// Ensure result list is large enough
|
||||
while (result.size() <= pageIndex) {
|
||||
result.add(new ArrayList<>());
|
||||
}
|
||||
|
||||
// Get bounding box
|
||||
BoundingBox bbox = extractBoundingBox(firstProv.get("bbox"), pageIndex, pageHeights.get(pageNo));
|
||||
|
||||
// Get text content
|
||||
String text = getTextValue(textNode, "text");
|
||||
if (text == null || text.isEmpty()) {
|
||||
text = getTextValue(textNode, "orig");
|
||||
}
|
||||
|
||||
// Create appropriate IObject based on label
|
||||
IObject object;
|
||||
if (LABEL_SECTION_HEADER.equals(label)) {
|
||||
object = createHeading(text, bbox, textNode);
|
||||
} else if (LABEL_FORMULA.equals(label)) {
|
||||
object = createFormula(text, bbox);
|
||||
} else {
|
||||
object = createParagraph(text, bbox);
|
||||
}
|
||||
|
||||
if (object != null) {
|
||||
result.get(pageIndex).add(object);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SemanticHeading from Docling section_header.
|
||||
*/
|
||||
private SemanticHeading createHeading(String text, BoundingBox bbox, JsonNode textNode) {
|
||||
int level = 1; // Default level
|
||||
|
||||
// Try to extract level from node metadata
|
||||
JsonNode meta = textNode.get("meta");
|
||||
if (meta != null && meta.has("level")) {
|
||||
level = meta.get("level").asInt(1);
|
||||
}
|
||||
|
||||
// Create a text chunk and wrap in TextLine
|
||||
TextChunk textChunk = new TextChunk(bbox, text, 12.0, 12.0);
|
||||
textChunk.adjustSymbolEndsToBoundingBox(null);
|
||||
TextLine textLine = new TextLine(textChunk);
|
||||
|
||||
// Create heading using default constructor and add content
|
||||
SemanticHeading heading = new SemanticHeading();
|
||||
heading.add(textLine);
|
||||
heading.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
heading.setHeadingLevel(level);
|
||||
|
||||
return heading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SemanticParagraph from Docling text element.
|
||||
*/
|
||||
private SemanticParagraph createParagraph(String text, BoundingBox bbox) {
|
||||
// Create a text chunk and wrap in TextLine
|
||||
TextChunk textChunk = new TextChunk(bbox, text, 12.0, 12.0);
|
||||
textChunk.adjustSymbolEndsToBoundingBox(null);
|
||||
TextLine textLine = new TextLine(textChunk);
|
||||
|
||||
// Create paragraph using default constructor and add content
|
||||
SemanticParagraph paragraph = new SemanticParagraph();
|
||||
paragraph.add(textLine);
|
||||
paragraph.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SemanticFormula from Docling formula element.
|
||||
*
|
||||
* @param latex The LaTeX representation of the formula
|
||||
* @param bbox The bounding box
|
||||
* @return A SemanticFormula object
|
||||
*/
|
||||
private SemanticFormula createFormula(String latex, BoundingBox bbox) {
|
||||
SemanticFormula formula = new SemanticFormula(bbox, latex);
|
||||
formula.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
return formula;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a Docling picture element to a SemanticPicture.
|
||||
*/
|
||||
private void transformPicture(JsonNode pictureNode, List<List<IObject>> result, Map<Integer, Double> pageHeights) {
|
||||
// Get provenance for position info
|
||||
JsonNode prov = pictureNode.get("prov");
|
||||
if (prov == null || !prov.isArray() || prov.size() == 0) {
|
||||
LOGGER.log(Level.FINE, "Picture element missing provenance, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonNode firstProv = prov.get(0);
|
||||
int pageNo = firstProv.has("page_no") ? firstProv.get("page_no").asInt() : 1;
|
||||
int pageIndex = pageNo - 1;
|
||||
|
||||
// Ensure result list is large enough
|
||||
while (result.size() <= pageIndex) {
|
||||
result.add(new ArrayList<>());
|
||||
}
|
||||
|
||||
// Get bounding box
|
||||
BoundingBox bbox = extractBoundingBox(firstProv.get("bbox"), pageIndex, pageHeights.get(pageNo));
|
||||
|
||||
// Extract description from annotations (if available)
|
||||
String description = extractPictureDescription(pictureNode);
|
||||
|
||||
// Create SemanticPicture with description
|
||||
SemanticPicture picture = new SemanticPicture(bbox, ++pictureIndex, description);
|
||||
picture.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
|
||||
result.get(pageIndex).add(picture);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts picture description from annotations array.
|
||||
*
|
||||
* <p>Docling stores picture descriptions in the annotations array with kind="description".
|
||||
*
|
||||
* @param pictureNode The picture JSON node
|
||||
* @return The description text, or null if not available
|
||||
*/
|
||||
private String extractPictureDescription(JsonNode pictureNode) {
|
||||
JsonNode annotations = pictureNode.get("annotations");
|
||||
if (annotations != null && annotations.isArray()) {
|
||||
for (JsonNode annotation : annotations) {
|
||||
String kind = getTextValue(annotation, "kind");
|
||||
if ("description".equals(kind)) {
|
||||
return getTextValue(annotation, "text");
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a Docling table element to a TableBorder.
|
||||
*/
|
||||
private void transformTable(JsonNode tableNode, List<List<IObject>> result, Map<Integer, Double> pageHeights) {
|
||||
// Get provenance for position info
|
||||
JsonNode prov = tableNode.get("prov");
|
||||
if (prov == null || !prov.isArray() || prov.size() == 0) {
|
||||
LOGGER.log(Level.FINE, "Table element missing provenance, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonNode firstProv = prov.get(0);
|
||||
int pageNo = firstProv.has("page_no") ? firstProv.get("page_no").asInt() : 1;
|
||||
int pageIndex = pageNo - 1;
|
||||
|
||||
// Ensure result list is large enough
|
||||
while (result.size() <= pageIndex) {
|
||||
result.add(new ArrayList<>());
|
||||
}
|
||||
|
||||
// Get table data
|
||||
JsonNode data = tableNode.get("data");
|
||||
if (data == null) {
|
||||
LOGGER.log(Level.FINE, "Table element missing data, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get grid dimensions
|
||||
JsonNode gridNode = data.get("grid");
|
||||
if (gridNode == null || !gridNode.isArray()) {
|
||||
LOGGER.log(Level.FINE, "Table missing grid data, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
int numRows = gridNode.size();
|
||||
int numCols = 0;
|
||||
if (numRows > 0 && gridNode.get(0).isArray()) {
|
||||
numCols = gridNode.get(0).size();
|
||||
}
|
||||
|
||||
if (numRows == 0 || numCols == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get table bounding box
|
||||
BoundingBox tableBbox = extractBoundingBox(firstProv.get("bbox"), pageIndex, pageHeights.get(pageNo));
|
||||
|
||||
// Create TableBorder
|
||||
TableBorder table = new TableBorder(numRows, numCols);
|
||||
table.setBoundingBox(tableBbox);
|
||||
table.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
|
||||
// Get table cells from data
|
||||
JsonNode tableCells = data.get("table_cells");
|
||||
Map<String, JsonNode> cellMap = new HashMap<>();
|
||||
if (tableCells != null && tableCells.isArray()) {
|
||||
for (JsonNode cell : tableCells) {
|
||||
int startRow = cell.has("start_row_offset_idx") ? cell.get("start_row_offset_idx").asInt() : 0;
|
||||
int startCol = cell.has("start_col_offset_idx") ? cell.get("start_col_offset_idx").asInt() : 0;
|
||||
String key = startRow + "," + startCol;
|
||||
cellMap.put(key, cell);
|
||||
}
|
||||
}
|
||||
|
||||
// Build table structure
|
||||
double rowHeight = (tableBbox.getTopY() - tableBbox.getBottomY()) / numRows;
|
||||
double colWidth = (tableBbox.getRightX() - tableBbox.getLeftX()) / numCols;
|
||||
|
||||
for (int row = 0; row < numRows; row++) {
|
||||
TableBorderRow borderRow = new TableBorderRow(row, numCols, 0L);
|
||||
double rowTop = tableBbox.getTopY() - (row * rowHeight);
|
||||
double rowBottom = rowTop - rowHeight;
|
||||
borderRow.setBoundingBox(new BoundingBox(pageIndex, tableBbox.getLeftX(), rowBottom, tableBbox.getRightX(), rowTop));
|
||||
table.getRows()[row] = borderRow;
|
||||
}
|
||||
|
||||
for (int row = 0; row < numRows; row++) {
|
||||
for (int col = 0; col < numCols; col++) {
|
||||
// Slot already covered by a spanning cell from an earlier
|
||||
// iteration — do not overwrite with a fresh placeholder.
|
||||
if (table.getRows()[row].getCells()[col] != null) continue;
|
||||
|
||||
String key = row + "," + col;
|
||||
JsonNode cellNode = cellMap.get(key);
|
||||
|
||||
int rowSpan = 1;
|
||||
int colSpan = 1;
|
||||
String cellText = "";
|
||||
|
||||
if (cellNode != null) {
|
||||
rowSpan = cellNode.has("row_span") ? cellNode.get("row_span").asInt(1) : 1;
|
||||
colSpan = cellNode.has("col_span") ? cellNode.get("col_span").asInt(1) : 1;
|
||||
cellText = getTextValue(cellNode, "text");
|
||||
if (cellText == null) {
|
||||
cellText = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp spans to the declared table bounds. Docling occasionally
|
||||
// emits col_span/row_span values that, combined with the start
|
||||
// index, run past the table; let the spanning area be exactly
|
||||
// what stays inside the grid. Math.max(1, ...) defends against
|
||||
// malformed zero or negative span values that would otherwise
|
||||
// leave the slot unfilled.
|
||||
int effectiveColSpan = Math.max(1, Math.min(colSpan, numCols - col));
|
||||
int effectiveRowSpan = Math.max(1, Math.min(rowSpan, numRows - row));
|
||||
|
||||
TableBorderCell cell = new TableBorderCell(row, col, effectiveRowSpan, effectiveColSpan, 0L);
|
||||
cell.setSemanticType(row == 0 ? SemanticType.TABLE_HEADER : SemanticType.TABLE_CELL);
|
||||
double cellLeft = tableBbox.getLeftX() + (col * colWidth);
|
||||
double cellRight = cellLeft + (effectiveColSpan * colWidth);
|
||||
double cellTop = tableBbox.getTopY() - (row * rowHeight);
|
||||
double cellBottom = cellTop - (effectiveRowSpan * rowHeight);
|
||||
cell.setBoundingBox(new BoundingBox(pageIndex, cellLeft, cellBottom, cellRight, cellTop));
|
||||
|
||||
if (!cellText.isEmpty()) {
|
||||
SemanticParagraph content = createParagraph(cellText, cell.getBoundingBox());
|
||||
cell.addContentObject(content);
|
||||
}
|
||||
|
||||
// Mark every slot covered by this cell's span with the same
|
||||
// instance so AutoTaggingProcessor.addTableRow skips the
|
||||
// duplicates. Without this Docling produces redundant
|
||||
// 1x1 placeholders next to spanning cells, breaking
|
||||
// PDF/UA-1 §7.2 (test 43) / PDF/UA-2 §8.2.5.26 "Table rows
|
||||
// shall have the same number of columns (taking into account
|
||||
// column spans)".
|
||||
for (int r = row; r < row + effectiveRowSpan; r++) {
|
||||
for (int c = col; c < col + effectiveColSpan; c++) {
|
||||
table.getRows()[r].getCells()[c] = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.get(pageIndex).add(table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a BoundingBox from Docling bbox JSON.
|
||||
*
|
||||
* @param bboxNode The bbox JSON node with l, t, r, b, coord_origin fields
|
||||
* @param pageIndex The 0-indexed page number
|
||||
* @param pageHeight The page height for coordinate transformation
|
||||
* @return A BoundingBox in OpenDataLoader format
|
||||
*/
|
||||
private BoundingBox extractBoundingBox(JsonNode bboxNode, int pageIndex, Double pageHeight) {
|
||||
if (bboxNode == null) {
|
||||
return new BoundingBox(pageIndex, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
double l = bboxNode.has("l") ? bboxNode.get("l").asDouble() : 0;
|
||||
double t = bboxNode.has("t") ? bboxNode.get("t").asDouble() : 0;
|
||||
double r = bboxNode.has("r") ? bboxNode.get("r").asDouble() : 0;
|
||||
double b = bboxNode.has("b") ? bboxNode.get("b").asDouble() : 0;
|
||||
|
||||
String coordOrigin = bboxNode.has("coord_origin") ?
|
||||
bboxNode.get("coord_origin").asText() : COORD_ORIGIN_BOTTOMLEFT;
|
||||
|
||||
double left, bottom, right, top;
|
||||
|
||||
if (COORD_ORIGIN_TOPLEFT.equals(coordOrigin) && pageHeight != null) {
|
||||
// Convert from TOPLEFT to BOTTOMLEFT
|
||||
// In TOPLEFT: t is distance from top, b is distance from top (t < b since t is higher)
|
||||
// In BOTTOMLEFT: bottom is distance from bottom, top is distance from bottom
|
||||
left = l;
|
||||
right = r;
|
||||
top = pageHeight - t; // t was distance from top
|
||||
bottom = pageHeight - b; // b was distance from top
|
||||
} else {
|
||||
// BOTTOMLEFT origin - Docling uses {l, t, r, b} where t=top, b=bottom
|
||||
left = l;
|
||||
bottom = b;
|
||||
right = r;
|
||||
top = t;
|
||||
}
|
||||
|
||||
return new BoundingBox(pageIndex, left, bottom, right, top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a text value from a JSON node.
|
||||
*/
|
||||
private String getTextValue(JsonNode node, String fieldName) {
|
||||
if (node != null && node.has(fieldName)) {
|
||||
JsonNode field = node.get(fieldName);
|
||||
if (field.isTextual()) {
|
||||
return field.asText();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts page contents by reading order (top to bottom, left to right).
|
||||
*/
|
||||
private void sortByReadingOrder(List<IObject> contents) {
|
||||
contents.sort(new Comparator<IObject>() {
|
||||
@Override
|
||||
public int compare(IObject o1, IObject o2) {
|
||||
// Sort by top Y (descending - higher on page first)
|
||||
double topDiff = o2.getTopY() - o1.getTopY();
|
||||
if (Math.abs(topDiff) > 5.0) { // Use tolerance for same-line detection
|
||||
return topDiff > 0 ? 1 : -1;
|
||||
}
|
||||
// Same line, sort by left X (ascending)
|
||||
return Double.compare(o1.getLeftX(), o2.getLeftX());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
/**
|
||||
* Per-element metadata produced by hybrid backends.
|
||||
* Keyed by IObject.recognizedStructureId in a sidecar Map.
|
||||
* All fields are optional — backends populate what they can.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
|
||||
public class ElementMetadata {
|
||||
|
||||
/** DLA AI score (0.0~1.0). Negative sentinel means "not provided". */
|
||||
private double aiScore = -1.0;
|
||||
|
||||
/** Original Hancom AI label (0~17), -1 if not applicable. */
|
||||
private int sourceLabel = -1;
|
||||
|
||||
/**
|
||||
* The hybrid backend's per-page detection id for this element — the
|
||||
* same value that surfaces as {@code object_id} in the raw DLA JSON
|
||||
* and as {@code scored_items[].id} in the evidence report. Carrying
|
||||
* it through to the corrected extraction tree lets downstream
|
||||
* tooling line up a reading-ordered node with the original detection
|
||||
* row without re-matching by bbox. Negative sentinel ({@code -1})
|
||||
* means "not provided" — e.g. native pipeline output.
|
||||
*/
|
||||
private int dlaObjectId = -1;
|
||||
|
||||
/** Heading inference method: "bbox-height" or "fixed". */
|
||||
private String headingInferenceMethod;
|
||||
|
||||
/** Bounding box height in pixels, used for heading inference. */
|
||||
private Double bboxHeightPx;
|
||||
|
||||
/** Word-cell matching method: "bbox-intersection" or "tsr-text-fallback". */
|
||||
private String wordMatchMethod;
|
||||
|
||||
/** Number of words matched in the cell. */
|
||||
private int matchedWordCount;
|
||||
|
||||
/** TSR metadata for tables. */
|
||||
private TsrMetadata tsr;
|
||||
|
||||
/** Caption metadata for images. */
|
||||
private CaptionMetadata caption;
|
||||
|
||||
/** Regionlist resolution metadata for label 7. */
|
||||
private RegionlistResolution regionlistResolution;
|
||||
|
||||
/** Text source decision: "stream", "ocr", or "ocr-fallback". */
|
||||
private String textSource;
|
||||
|
||||
/** Stream vs OCR text similarity score (auto mode only). */
|
||||
private Double streamOcrSimilarity;
|
||||
|
||||
public double getAiScore() {
|
||||
return aiScore;
|
||||
}
|
||||
|
||||
public ElementMetadata setAiScore(double aiScore) {
|
||||
this.aiScore = aiScore;
|
||||
return this;
|
||||
}
|
||||
|
||||
public int getSourceLabel() {
|
||||
return sourceLabel;
|
||||
}
|
||||
|
||||
public ElementMetadata setSourceLabel(int sourceLabel) {
|
||||
this.sourceLabel = sourceLabel;
|
||||
return this;
|
||||
}
|
||||
|
||||
public int getDlaObjectId() {
|
||||
return dlaObjectId;
|
||||
}
|
||||
|
||||
public ElementMetadata setDlaObjectId(int dlaObjectId) {
|
||||
this.dlaObjectId = dlaObjectId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getHeadingInferenceMethod() {
|
||||
return headingInferenceMethod;
|
||||
}
|
||||
|
||||
public ElementMetadata setHeadingInferenceMethod(String headingInferenceMethod) {
|
||||
this.headingInferenceMethod = headingInferenceMethod;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Double getBboxHeightPx() {
|
||||
return bboxHeightPx;
|
||||
}
|
||||
|
||||
public ElementMetadata setBboxHeightPx(Double bboxHeightPx) {
|
||||
this.bboxHeightPx = bboxHeightPx;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getWordMatchMethod() {
|
||||
return wordMatchMethod;
|
||||
}
|
||||
|
||||
public ElementMetadata setWordMatchMethod(String wordMatchMethod) {
|
||||
this.wordMatchMethod = wordMatchMethod;
|
||||
return this;
|
||||
}
|
||||
|
||||
public int getMatchedWordCount() {
|
||||
return matchedWordCount;
|
||||
}
|
||||
|
||||
public ElementMetadata setMatchedWordCount(int matchedWordCount) {
|
||||
this.matchedWordCount = matchedWordCount;
|
||||
return this;
|
||||
}
|
||||
|
||||
public TsrMetadata getTsr() {
|
||||
return tsr;
|
||||
}
|
||||
|
||||
public ElementMetadata setTsr(TsrMetadata tsr) {
|
||||
this.tsr = tsr;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CaptionMetadata getCaption() {
|
||||
return caption;
|
||||
}
|
||||
|
||||
public ElementMetadata setCaption(CaptionMetadata caption) {
|
||||
this.caption = caption;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RegionlistResolution getRegionlistResolution() {
|
||||
return regionlistResolution;
|
||||
}
|
||||
|
||||
public ElementMetadata setRegionlistResolution(RegionlistResolution regionlistResolution) {
|
||||
this.regionlistResolution = regionlistResolution;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getTextSource() {
|
||||
return textSource;
|
||||
}
|
||||
|
||||
public ElementMetadata setTextSource(String textSource) {
|
||||
this.textSource = textSource;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Double getStreamOcrSimilarity() {
|
||||
return streamOcrSimilarity;
|
||||
}
|
||||
|
||||
public ElementMetadata setStreamOcrSimilarity(Double streamOcrSimilarity) {
|
||||
this.streamOcrSimilarity = streamOcrSimilarity;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* TSR (Table Structure Recognition) metadata for table elements.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
|
||||
public static class TsrMetadata {
|
||||
|
||||
private int numCells;
|
||||
private String html;
|
||||
private long runTimeMs;
|
||||
|
||||
public int getNumCells() {
|
||||
return numCells;
|
||||
}
|
||||
|
||||
public TsrMetadata setNumCells(int numCells) {
|
||||
this.numCells = numCells;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getHtml() {
|
||||
return html;
|
||||
}
|
||||
|
||||
public TsrMetadata setHtml(String html) {
|
||||
this.html = html;
|
||||
return this;
|
||||
}
|
||||
|
||||
public long getRunTimeMs() {
|
||||
return runTimeMs;
|
||||
}
|
||||
|
||||
public TsrMetadata setRunTimeMs(long runTimeMs) {
|
||||
this.runTimeMs = runTimeMs;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caption metadata for image elements.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
|
||||
public static class CaptionMetadata {
|
||||
|
||||
private String text;
|
||||
private String language;
|
||||
private long runTimeMs;
|
||||
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public CaptionMetadata setText(String text) {
|
||||
this.text = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getLanguage() {
|
||||
return language;
|
||||
}
|
||||
|
||||
public CaptionMetadata setLanguage(String language) {
|
||||
this.language = language;
|
||||
return this;
|
||||
}
|
||||
|
||||
public long getRunTimeMs() {
|
||||
return runTimeMs;
|
||||
}
|
||||
|
||||
public CaptionMetadata setRunTimeMs(long runTimeMs) {
|
||||
this.runTimeMs = runTimeMs;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution metadata for regionlist elements (label 7).
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
|
||||
public static class RegionlistResolution {
|
||||
|
||||
/** Strategy used: "table-first" or "list-only". */
|
||||
private String strategy;
|
||||
|
||||
/** Whether TSR was attempted. */
|
||||
private boolean tsrAttempted;
|
||||
|
||||
/** TSR result: "success", "no-cells", "failed", or null. */
|
||||
private String tsrResult;
|
||||
|
||||
public String getStrategy() {
|
||||
return strategy;
|
||||
}
|
||||
|
||||
public RegionlistResolution setStrategy(String strategy) {
|
||||
this.strategy = strategy;
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isTsrAttempted() {
|
||||
return tsrAttempted;
|
||||
}
|
||||
|
||||
public RegionlistResolution setTsrAttempted(boolean tsrAttempted) {
|
||||
this.tsrAttempted = tsrAttempted;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getTsrResult() {
|
||||
return tsrResult;
|
||||
}
|
||||
|
||||
public RegionlistResolution setTsrResult(String tsrResult) {
|
||||
this.tsrResult = tsrResult;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
+895
@@ -0,0 +1,895 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* HTTP client for Hancom AI HOCR SDK API.
|
||||
*
|
||||
* <p>Pipeline:
|
||||
* <ol>
|
||||
* <li>pdf2img — convert each page to PNG image</li>
|
||||
* <li>DOCUMENT_LAYOUT_WITH_OCR — layout analysis + OCR on full PDF</li>
|
||||
* <li>TABLE_STRUCTURE_RECOGNITION — crop each Table/Regionlist from page image, send to TSR individually</li>
|
||||
* <li>IMAGE_CAPTIONING — crop each Figure region from page image, caption individually</li>
|
||||
* </ol>
|
||||
*
|
||||
* @see HancomAISchemaTransformer
|
||||
*/
|
||||
public class HancomAIClient implements HybridClient {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(HancomAIClient.class.getCanonicalName());
|
||||
|
||||
public static final String DEFAULT_URL = "http://localhost:18008";
|
||||
|
||||
private static final String SDK_ENDPOINT = "/hocr/sdk";
|
||||
private static final String PDF2IMG_ENDPOINT = "/support/pdf2img";
|
||||
private static final String PING_ENDPOINT = "/ping";
|
||||
private static final String HEALTH_ENDPOINT = "/health";
|
||||
private static final int HEALTH_CHECK_TIMEOUT_MS = 3000;
|
||||
private static final String DEFAULT_FILENAME = "document.pdf";
|
||||
private static final MediaType MEDIA_TYPE_PDF = MediaType.parse("application/pdf");
|
||||
private static final MediaType MEDIA_TYPE_PNG = MediaType.parse("image/png");
|
||||
|
||||
// DLA label 7 = Regionlist (may be actual table or a list region)
|
||||
private static final int LABEL_REGIONLIST = 7;
|
||||
|
||||
// DLA label 9 = Table
|
||||
private static final int LABEL_TABLE = 9;
|
||||
|
||||
// DLA label 10 = Figure
|
||||
private static final int LABEL_FIGURE = 10;
|
||||
|
||||
/** Padding (pixels) added around table crops before sending to TSR. */
|
||||
private static final int TSR_CROP_PADDING = 20;
|
||||
|
||||
private final String baseUrl;
|
||||
private final OkHttpClient httpClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HybridConfig config;
|
||||
|
||||
private String sourcePdfShaShort = "unknown";
|
||||
|
||||
private static final java.util.Map<String, String> MODULE_SHORT;
|
||||
static {
|
||||
java.util.Map<String, String> m = new java.util.HashMap<>();
|
||||
m.put("DOCUMENT_LAYOUT_WITH_OCR", "dla-ocr");
|
||||
m.put("DOCUMENT_LAYOUT_ANALYSIS", "dla");
|
||||
m.put("TEXT_RECOGNITION", "ocr");
|
||||
m.put("TABLE_STRUCTURE_RECOGNITION", "tsr");
|
||||
m.put("IMAGE_CAPTIONING", "caption");
|
||||
m.put("CHART_IMAGE_UNDERSTANDING", "chart");
|
||||
MODULE_SHORT = java.util.Collections.unmodifiableMap(m);
|
||||
}
|
||||
|
||||
private static String sha256ShortHex(byte[] data) {
|
||||
try {
|
||||
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest(data);
|
||||
StringBuilder sb = new StringBuilder(12);
|
||||
for (int i = 0; i < 6; i++) sb.append(String.format("%02x", hash[i]));
|
||||
return sb.toString();
|
||||
} catch (java.security.NoSuchAlgorithmException e) {
|
||||
return "nohash000000";
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook
|
||||
void setSourcePdfShaShort(String s) { this.sourcePdfShaShort = s; }
|
||||
|
||||
public HancomAIClient(HybridConfig config) {
|
||||
this.config = config;
|
||||
this.baseUrl = config.getEffectiveUrl("hancom-ai");
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.httpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(config.getTimeoutMs(), TimeUnit.MILLISECONDS)
|
||||
.readTimeout(config.getTimeoutMs(), TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(config.getTimeoutMs(), TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
// Visible for testing
|
||||
HancomAIClient(String baseUrl, OkHttpClient httpClient, ObjectMapper objectMapper) {
|
||||
this.config = new HybridConfig();
|
||||
this.baseUrl = baseUrl;
|
||||
this.httpClient = httpClient;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode fetchHealth() {
|
||||
OkHttpClient healthClient = httpClient.newBuilder()
|
||||
.connectTimeout(HEALTH_CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(HEALTH_CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + HEALTH_ENDPOINT)
|
||||
.get()
|
||||
.build();
|
||||
try (Response response = healthClient.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) return null;
|
||||
ResponseBody body = response.body();
|
||||
if (body == null) return null;
|
||||
return objectMapper.readTree(body.string());
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.FINE,
|
||||
"Hancom AI /health unavailable: {0}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkAvailability() throws IOException {
|
||||
OkHttpClient healthClient = httpClient.newBuilder()
|
||||
.connectTimeout(HEALTH_CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(HEALTH_CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + PING_ENDPOINT)
|
||||
.get()
|
||||
.build();
|
||||
|
||||
try (Response response = healthClient.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IOException("Hancom AI server at " + baseUrl +
|
||||
" returned HTTP " + response.code());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IOException(
|
||||
"Hancom AI server is not available at " + baseUrl + "\n"
|
||||
+ "Check that the server is running and accessible.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public HybridResponse convert(HybridRequest request) throws IOException {
|
||||
byte[] pdfBytes = request.getPdfBytes();
|
||||
this.sourcePdfShaShort = sha256ShortHex(pdfBytes);
|
||||
LOGGER.log(Level.INFO, "Hancom AI: processing PDF ({0} bytes)", pdfBytes.length);
|
||||
|
||||
// Crop / page-image destination travels with the request, not the
|
||||
// cached client's config, so the per-document target is correct even
|
||||
// when the client is reused across documents (and is concurrency-safe
|
||||
// since nothing shared is mutated).
|
||||
CropOutput cropOutput = request.getCropOutput();
|
||||
|
||||
try (PageImageCache pageImageCache = createPageImageCache()) {
|
||||
ObjectNode merged = objectMapper.createObjectNode();
|
||||
ObjectNode timingsNode = objectMapper.createObjectNode();
|
||||
|
||||
// Step 1: DLA + OCR. This is required — downstream steps have nothing
|
||||
// to process without it, so treat an empty response as a failure so the
|
||||
// caller can fall back to the Java pipeline instead of silently emitting
|
||||
// an empty document.
|
||||
JsonNode dlaOcrResult = callModule(pdfBytes, "DOCUMENT_LAYOUT_WITH_OCR");
|
||||
if (dlaOcrResult == null || !dlaOcrResult.isArray() || dlaOcrResult.size() == 0) {
|
||||
throw new IOException(
|
||||
"Hancom AI DOCUMENT_LAYOUT_WITH_OCR returned empty result — "
|
||||
+ "backend unavailable or rejected the document");
|
||||
}
|
||||
merged.set("DOCUMENT_LAYOUT_WITH_OCR", dlaOcrResult);
|
||||
addTimings(timingsNode, "DOCUMENT_LAYOUT_WITH_OCR", dlaOcrResult);
|
||||
|
||||
// Step 2: Table Structure — crop each Table region from page image, send to TSR individually
|
||||
long tsrStartMs = System.currentTimeMillis();
|
||||
ArrayNode tsrResults = recognizeTableStructures(pdfBytes, dlaOcrResult, pageImageCache, cropOutput);
|
||||
long tsrMs = System.currentTimeMillis() - tsrStartMs;
|
||||
merged.set("TABLE_STRUCTURE_RECOGNITION", tsrResults);
|
||||
|
||||
ObjectNode tsrTiming = objectMapper.createObjectNode();
|
||||
tsrTiming.put("total_ms", tsrMs);
|
||||
tsrTiming.put("count", tsrResults.size());
|
||||
if (tsrResults.size() > 0) {
|
||||
tsrTiming.put("avg_ms", tsrMs / tsrResults.size());
|
||||
}
|
||||
timingsNode.set("TABLE_STRUCTURE_RECOGNITION", tsrTiming);
|
||||
|
||||
// Step 3: Figure captioning — pdf2img → crop figures → caption each
|
||||
long captionStartMs = System.currentTimeMillis();
|
||||
ArrayNode figureCaptions = captionFigures(pdfBytes, dlaOcrResult, pageImageCache, cropOutput);
|
||||
long captionMs = System.currentTimeMillis() - captionStartMs;
|
||||
merged.set("FIGURE_CAPTIONS", figureCaptions);
|
||||
|
||||
// Evidence-report consumers need the same rendered page image that
|
||||
// DLA bboxes are expressed against. TSR/FIGURE fetches already save
|
||||
// their pages; this pass fills only pages that were not otherwise
|
||||
// rendered.
|
||||
saveDlaPageImages(pdfBytes, dlaOcrResult, pageImageCache, cropOutput);
|
||||
|
||||
ObjectNode captionTiming = objectMapper.createObjectNode();
|
||||
captionTiming.put("total_ms", captionMs);
|
||||
captionTiming.put("count", figureCaptions.size());
|
||||
if (figureCaptions.size() > 0) {
|
||||
captionTiming.put("avg_ms", captionMs / figureCaptions.size());
|
||||
}
|
||||
timingsNode.set("IMAGE_CAPTIONING", captionTiming);
|
||||
|
||||
merged.set("timings", timingsNode);
|
||||
|
||||
LOGGER.log(Level.INFO, "Hancom AI: completed — {0} table crops, {1} figure captions",
|
||||
new Object[]{tsrResults.size(), figureCaptions.size()});
|
||||
|
||||
return new HybridResponse(null, null, merged, Collections.emptyMap(),
|
||||
Collections.emptyList(), timingsNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a PageImageCache based on config.
|
||||
*/
|
||||
private PageImageCache createPageImageCache() throws IOException {
|
||||
if ("disk".equalsIgnoreCase(config.getImageCache())) {
|
||||
return new DiskPageImageCache();
|
||||
}
|
||||
return new MemoryPageImageCache();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<HybridResponse> convertAsync(HybridRequest request) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return convert(request);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Failed to convert via Hancom AI", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Captions each Figure found by DLA:
|
||||
* 1. Get page images via pdf2img
|
||||
* 2. Find Figure objects (label 10) from DLA results
|
||||
* 3. Crop each Figure from page image
|
||||
* 4. Send cropped image to IMAGE_CAPTIONING
|
||||
*
|
||||
* @return ArrayNode of {page_number, object_id, bbox, caption}
|
||||
*/
|
||||
private ArrayNode captionFigures(byte[] pdfBytes, JsonNode dlaResult,
|
||||
PageImageCache pageImageCache, CropOutput cropOutput) {
|
||||
ArrayNode captions = objectMapper.createArrayNode();
|
||||
|
||||
// Extract pages from DLA result
|
||||
List<JsonNode> dlaPages = extractPages(dlaResult);
|
||||
if (dlaPages.isEmpty()) return captions;
|
||||
|
||||
// Collect Figure objects per page
|
||||
Map<Integer, List<JsonNode>> figuresByPage = new HashMap<>();
|
||||
for (JsonNode page : dlaPages) {
|
||||
int pageNum = page.has("page_number") ? page.get("page_number").asInt() : -1;
|
||||
if (pageNum < 0) continue;
|
||||
|
||||
JsonNode objects = page.get("objects");
|
||||
if (objects == null || !objects.isArray()) continue;
|
||||
|
||||
for (JsonNode obj : objects) {
|
||||
int label = obj.has("label") ? obj.get("label").asInt() : -1;
|
||||
if (label == LABEL_FIGURE) {
|
||||
figuresByPage.computeIfAbsent(pageNum, k -> new ArrayList<>()).add(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (figuresByPage.isEmpty()) {
|
||||
LOGGER.log(Level.INFO, "Hancom AI: no Figure objects found, skipping captioning");
|
||||
return captions;
|
||||
}
|
||||
|
||||
LOGGER.log(Level.INFO, "Hancom AI: captioning {0} figures across {1} pages",
|
||||
new Object[]{figuresByPage.values().stream().mapToInt(List::size).sum(),
|
||||
figuresByPage.size()});
|
||||
|
||||
for (Map.Entry<Integer, List<JsonNode>> entry : figuresByPage.entrySet()) {
|
||||
int pageNum = entry.getKey();
|
||||
List<JsonNode> figures = entry.getValue();
|
||||
|
||||
// Get page image via cache
|
||||
BufferedImage pageImage;
|
||||
try {
|
||||
pageImage = pageImageCache.getOrFetch(pageNum,
|
||||
idx -> fetchPageImage(pdfBytes, idx, cropOutput));
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.WARNING, "Failed to get page {0} image: {1}",
|
||||
new Object[]{pageNum, e.getMessage()});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Caption each figure
|
||||
for (JsonNode fig : figures) {
|
||||
JsonNode bboxNode = fig.get("bbox");
|
||||
if (bboxNode == null || !bboxNode.isArray() || bboxNode.size() < 4) continue;
|
||||
|
||||
int left = bboxNode.get(0).asInt();
|
||||
int top = bboxNode.get(1).asInt();
|
||||
int right = bboxNode.get(2).asInt();
|
||||
int bottom = bboxNode.get(3).asInt();
|
||||
|
||||
// Clamp to image bounds
|
||||
left = Math.max(0, left);
|
||||
top = Math.max(0, top);
|
||||
right = Math.min(pageImage.getWidth(), right);
|
||||
bottom = Math.min(pageImage.getHeight(), bottom);
|
||||
|
||||
if (right <= left || bottom <= top) continue;
|
||||
|
||||
try {
|
||||
BufferedImage cropped = pageImage.getSubimage(left, top, right - left, bottom - top);
|
||||
byte[] croppedPng = imageToPng(cropped);
|
||||
|
||||
// Save crop if configured
|
||||
if (cropOutput.active()) {
|
||||
int objId = fig.has("object_id") ? fig.get("object_id").asInt() : -1;
|
||||
saveCropFile(cropOutput.directory(), pageNum, objId, "figure", croppedPng);
|
||||
}
|
||||
|
||||
int objIdForCaption = fig.has("object_id") ? fig.get("object_id").asInt() : -1;
|
||||
CaptionResult captionResult = callImageCaptioning(croppedPng, pageNum, objIdForCaption);
|
||||
String caption = captionResult != null ? captionResult.caption : null;
|
||||
|
||||
ObjectNode capNode = objectMapper.createObjectNode();
|
||||
capNode.put("page_number", pageNum);
|
||||
capNode.put("object_id", fig.has("object_id") ? fig.get("object_id").asInt() : -1);
|
||||
ArrayNode bboxArr = objectMapper.createArrayNode();
|
||||
bboxArr.add(left).add(top).add(right).add(bottom);
|
||||
capNode.set("bbox", bboxArr);
|
||||
capNode.put("caption", caption != null ? caption : "");
|
||||
if (captionResult != null && captionResult.confidence != null) {
|
||||
capNode.put("confidence", captionResult.confidence);
|
||||
}
|
||||
captions.add(capNode);
|
||||
|
||||
LOGGER.log(Level.FINE, "Captioned figure page={0} bbox=[{1},{2},{3},{4}]: {5}",
|
||||
new Object[]{pageNum, left, top, right, bottom,
|
||||
caption != null ? caption.substring(0, Math.min(50, caption.length())) : ""});
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.WARNING, "Failed to caption figure on page {0}: {1}",
|
||||
new Object[]{pageNum, e.getMessage()});
|
||||
}
|
||||
}
|
||||
|
||||
// Done with this page — allow cache to reclaim memory
|
||||
pageImageCache.evict(pageNum);
|
||||
}
|
||||
|
||||
return captions;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each Table region (label 7) found by DLA, crop the region from the
|
||||
* page image and send the crop to TABLE_STRUCTURE_RECOGNITION individually.
|
||||
*
|
||||
* <p>When regionlist strategy is "list-only", label 7 is always treated as a
|
||||
* list and TSR is skipped entirely.
|
||||
*
|
||||
* @param pdfBytes the original PDF bytes (needed for pdf2img)
|
||||
* @param dlaResult the DLA+OCR result containing detected objects
|
||||
* @param pageImageCache shared cache for page images
|
||||
* @param cropOutput per-document destination for saved table crops
|
||||
* @return ArrayNode of per-table results:
|
||||
* [{page_number, object_id, label, dla_bbox, tsr: {cells, num_cells, html, ...}}]
|
||||
*/
|
||||
private ArrayNode recognizeTableStructures(byte[] pdfBytes, JsonNode dlaResult,
|
||||
PageImageCache pageImageCache, CropOutput cropOutput) {
|
||||
ArrayNode results = objectMapper.createArrayNode();
|
||||
|
||||
// In list-only mode, LABEL_REGIONLIST is always rendered as a list and
|
||||
// does not need TSR. LABEL_TABLE still needs TSR — without it the
|
||||
// transformer's LABEL_TABLE branch returns null and real tables drop out
|
||||
// of the structured output entirely.
|
||||
boolean skipRegionlistTsr = config.isRegionlistListOnly();
|
||||
if (skipRegionlistTsr) {
|
||||
LOGGER.log(Level.INFO, "Hancom AI: regionlist strategy is list-only, "
|
||||
+ "skipping TSR for Regionlist (label 7); Table (label 9) TSR still runs");
|
||||
}
|
||||
|
||||
List<JsonNode> dlaPages = extractPages(dlaResult);
|
||||
if (dlaPages.isEmpty()) return results;
|
||||
|
||||
for (JsonNode page : dlaPages) {
|
||||
int pageNum = page.has("page_number") ? page.get("page_number").asInt() : -1;
|
||||
if (pageNum < 0) continue;
|
||||
|
||||
JsonNode objects = page.get("objects");
|
||||
if (objects == null || !objects.isArray()) continue;
|
||||
|
||||
// Check if any table/regionlist objects exist on this page
|
||||
boolean needsPageImage = false;
|
||||
for (JsonNode obj : objects) {
|
||||
int label = obj.has("label") ? obj.get("label").asInt() : -1;
|
||||
if (label == LABEL_TABLE
|
||||
|| (label == LABEL_REGIONLIST && !skipRegionlistTsr)) {
|
||||
needsPageImage = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!needsPageImage) continue;
|
||||
|
||||
// Get page image
|
||||
BufferedImage pageImage;
|
||||
try {
|
||||
pageImage = pageImageCache.getOrFetch(pageNum,
|
||||
idx -> fetchPageImage(pdfBytes, idx, cropOutput));
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.WARNING, "Failed to get page {0} image for TSR: {1}",
|
||||
new Object[]{pageNum, e.getMessage()});
|
||||
continue;
|
||||
}
|
||||
|
||||
int imgWidth = pageImage.getWidth();
|
||||
int imgHeight = pageImage.getHeight();
|
||||
|
||||
for (JsonNode obj : objects) {
|
||||
int label = obj.has("label") ? obj.get("label").asInt() : -1;
|
||||
if (label != LABEL_REGIONLIST && label != LABEL_TABLE) continue;
|
||||
if (label == LABEL_REGIONLIST && skipRegionlistTsr) continue;
|
||||
|
||||
JsonNode bboxNode = obj.get("bbox");
|
||||
if (bboxNode == null || !bboxNode.isArray() || bboxNode.size() < 4) continue;
|
||||
|
||||
int left = bboxNode.get(0).asInt();
|
||||
int top = bboxNode.get(1).asInt();
|
||||
int right = bboxNode.get(2).asInt();
|
||||
int bottom = bboxNode.get(3).asInt();
|
||||
|
||||
// Add padding around crop
|
||||
left = Math.max(0, left - TSR_CROP_PADDING);
|
||||
top = Math.max(0, top - TSR_CROP_PADDING);
|
||||
right = Math.min(imgWidth, right + TSR_CROP_PADDING);
|
||||
bottom = Math.min(imgHeight, bottom + TSR_CROP_PADDING);
|
||||
|
||||
if (right <= left || bottom <= top) continue;
|
||||
|
||||
try {
|
||||
BufferedImage crop = pageImage.getSubimage(left, top, right - left, bottom - top);
|
||||
byte[] cropPng = imageToPng(crop);
|
||||
|
||||
// Save crop if configured
|
||||
if (cropOutput.active()) {
|
||||
int objectId = obj.has("object_id") ? obj.get("object_id").asInt() : -1;
|
||||
saveCropFile(cropOutput.directory(), pageNum, objectId, "table", cropPng);
|
||||
}
|
||||
|
||||
// Call TSR with crop image
|
||||
int objId = obj.has("object_id") ? obj.get("object_id").asInt() : -1;
|
||||
JsonNode tsrResult = callModuleImage(cropPng, "TABLE_STRUCTURE_RECOGNITION", pageNum, objId);
|
||||
|
||||
// Build result entry
|
||||
ObjectNode entry = objectMapper.createObjectNode();
|
||||
entry.put("page_number", pageNum);
|
||||
entry.put("object_id",
|
||||
obj.has("object_id") ? obj.get("object_id").asInt() : -1);
|
||||
entry.put("label", label);
|
||||
|
||||
// Store the DLA bbox (padded, page-level pixels) for coordinate offset later
|
||||
ArrayNode dlaBbox = objectMapper.createArrayNode();
|
||||
dlaBbox.add(left).add(top).add(right).add(bottom);
|
||||
entry.set("dla_bbox", dlaBbox);
|
||||
|
||||
// Extract TSR page result. The HOCR envelope wraps results
|
||||
// as RESULT=[[page]], so the page node is where any
|
||||
// top-level self-score lands.
|
||||
List<JsonNode> tsrPages = extractPages(tsrResult);
|
||||
if (!tsrPages.isEmpty()) {
|
||||
JsonNode tsrPage = tsrPages.get(0);
|
||||
JsonNode conf = tsrPage.get("confidence");
|
||||
if (conf != null && conf.isNumber()) {
|
||||
// doubleValue() returns the numeric value directly;
|
||||
// asDouble() has a silent 0.0 fallback we don't want
|
||||
// even though the isNumber() guard makes it unreachable.
|
||||
entry.put("confidence", conf.doubleValue());
|
||||
}
|
||||
entry.set("tsr", tsrPage);
|
||||
} else {
|
||||
entry.set("tsr", objectMapper.createObjectNode());
|
||||
}
|
||||
|
||||
results.add(entry);
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.WARNING, "TSR failed for page {0} object: {1}",
|
||||
new Object[]{pageNum, e.getMessage()});
|
||||
}
|
||||
}
|
||||
// Evict the page image here only if captionFigures() will not revisit
|
||||
// this page. captionFigures() iterates pages that contain at least one
|
||||
// LABEL_FIGURE object; for table-only pages (no figures) the cached
|
||||
// full-resolution image would otherwise sit in memory until the
|
||||
// try-with-resources in convert() closes the cache — ~25MB per page
|
||||
// for the memory cache, which is costly on large table-heavy PDFs.
|
||||
boolean hasFigure = false;
|
||||
for (JsonNode obj : objects) {
|
||||
int objLabel = obj.has("label") ? obj.get("label").asInt() : -1;
|
||||
if (objLabel == LABEL_FIGURE) {
|
||||
hasFigure = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasFigure) {
|
||||
pageImageCache.evict(pageNum);
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.log(Level.INFO, "Hancom AI: TSR processed {0} table crops", results.size());
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a single HOCR SDK module with image (PNG) input.
|
||||
* Similar to {@link #callModule} but sends image data instead of PDF.
|
||||
*/
|
||||
private JsonNode callModuleImage(byte[] pngBytes, String moduleName, int pageNum, int objectId) throws IOException {
|
||||
String moduleShort = MODULE_SHORT.getOrDefault(moduleName, moduleName);
|
||||
String requestId = "odl-" + sourcePdfShaShort + "-p" + pageNum + "-o" + objectId + "-" + moduleShort;
|
||||
MultipartBody body = new MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("REQUEST_ID", requestId)
|
||||
.addFormDataPart("OPEN_API_NAME", moduleName)
|
||||
.addFormDataPart("DATA_FORMAT", "image")
|
||||
.addFormDataPart("FILE", "crop.png",
|
||||
RequestBody.create(pngBytes, MEDIA_TYPE_PNG))
|
||||
.build();
|
||||
|
||||
Request httpRequest = new Request.Builder()
|
||||
.url(baseUrl + SDK_ENDPOINT)
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
LOGGER.log(Level.FINE, "Calling Hancom AI module (image): {0} [{1}]",
|
||||
new Object[]{moduleName, requestId});
|
||||
|
||||
try (Response response = httpClient.newCall(httpRequest).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
ResponseBody respBody = response.body();
|
||||
String errorMsg = respBody != null ? respBody.string() : "";
|
||||
LOGGER.log(Level.WARNING, "Hancom AI module {0} (image) returned HTTP {1}: {2}",
|
||||
new Object[]{moduleName, response.code(), errorMsg});
|
||||
return objectMapper.createArrayNode();
|
||||
}
|
||||
|
||||
ResponseBody respBody = response.body();
|
||||
if (respBody == null) {
|
||||
return objectMapper.createArrayNode();
|
||||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(respBody.string());
|
||||
boolean success = root.has("SUCCESS") && root.get("SUCCESS").asBoolean();
|
||||
if (!success) {
|
||||
LOGGER.log(Level.WARNING, "Hancom AI module {0} (image) returned SUCCESS=false: {1}",
|
||||
new Object[]{moduleName, root.has("MSG") ? root.get("MSG").asText() : ""});
|
||||
return objectMapper.createArrayNode();
|
||||
}
|
||||
|
||||
JsonNode result = root.get("RESULT");
|
||||
return result != null ? result : objectMapper.createArrayNode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves full-page render images for every DLA page when evidence image
|
||||
* capture is enabled.
|
||||
*/
|
||||
private void saveDlaPageImages(byte[] pdfBytes, JsonNode dlaResult,
|
||||
PageImageCache pageImageCache, CropOutput cropOutput) {
|
||||
if (!cropOutput.active()) return;
|
||||
|
||||
for (JsonNode page : extractPages(dlaResult)) {
|
||||
int pageNum = page.has("page_number") ? page.get("page_number").asInt() : -1;
|
||||
if (pageNum < 0) continue;
|
||||
if (isPageImageFileSaved(cropOutput.directory(), pageNum)) continue;
|
||||
try {
|
||||
pageImageCache.getOrFetch(pageNum, idx -> fetchPageImage(pdfBytes, idx, cropOutput));
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.FINE, "Failed to save DLA page image for page "
|
||||
+ pageNum, e);
|
||||
} finally {
|
||||
pageImageCache.evict(pageNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPageImageFileSaved(String outputDir, int pageNum) {
|
||||
if (outputDir == null) return false;
|
||||
File file = new File(new File(outputDir, "page-images"),
|
||||
String.format("page-%d.png", pageNum));
|
||||
return file.isFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a cropped image to disk for debugging.
|
||||
*/
|
||||
private void saveCropFile(String outputDir, int pageNum, int objectId,
|
||||
String labelName, byte[] pngBytes) {
|
||||
try {
|
||||
File dir = new File(outputDir, "crops");
|
||||
dir.mkdirs();
|
||||
String filename = String.format("page-%d_%s-o%d.png", pageNum, labelName, objectId);
|
||||
Files.write(new File(dir, filename).toPath(), pngBytes);
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.FINE, "Failed to save crop file", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a full rendered PDF page image to disk for evidence overlays.
|
||||
*/
|
||||
private void savePageImageFile(String outputDir, int pageNum, byte[] pngBytes) {
|
||||
try {
|
||||
File dir = new File(outputDir, "page-images");
|
||||
dir.mkdirs();
|
||||
String filename = String.format("page-%d.png", pageNum);
|
||||
Files.write(new File(dir, filename).toPath(), pngBytes);
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.FINE, "Failed to save page image file", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a page image from the pdf2img endpoint.
|
||||
*/
|
||||
private BufferedImage fetchPageImage(byte[] pdfBytes, int pageIndex, CropOutput cropOutput)
|
||||
throws IOException {
|
||||
MultipartBody body = new MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("REQUEST_ID",
|
||||
"odl-" + sourcePdfShaShort + "-pdf2img-p" + pageIndex)
|
||||
.addFormDataPart("PAGE_INDEX", String.valueOf(pageIndex))
|
||||
.addFormDataPart("FILE", DEFAULT_FILENAME,
|
||||
RequestBody.create(pdfBytes, MEDIA_TYPE_PDF))
|
||||
.build();
|
||||
|
||||
Request httpRequest = new Request.Builder()
|
||||
.url(baseUrl + PDF2IMG_ENDPOINT)
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
try (Response response = httpClient.newCall(httpRequest).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IOException("pdf2img returned HTTP " + response.code());
|
||||
}
|
||||
|
||||
ResponseBody respBody = response.body();
|
||||
if (respBody == null) {
|
||||
throw new IOException("pdf2img returned empty body");
|
||||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(respBody.string());
|
||||
// Navigate: RESULT[0].RESULT.PAGE_PNG_DATA
|
||||
JsonNode resultArr = root.get("RESULT");
|
||||
if (resultArr == null || !resultArr.isArray() || resultArr.size() == 0) {
|
||||
throw new IOException("pdf2img RESULT is empty");
|
||||
}
|
||||
|
||||
JsonNode pageResult = resultArr.get(0);
|
||||
JsonNode innerResult = pageResult.get("RESULT");
|
||||
if (innerResult == null) {
|
||||
throw new IOException("pdf2img inner RESULT is null");
|
||||
}
|
||||
|
||||
String pngBase64 = innerResult.has("PAGE_PNG_DATA")
|
||||
? innerResult.get("PAGE_PNG_DATA").asText() : null;
|
||||
if (pngBase64 == null || pngBase64.isEmpty()) {
|
||||
throw new IOException("pdf2img PAGE_PNG_DATA is empty");
|
||||
}
|
||||
|
||||
byte[] pngBytes;
|
||||
try {
|
||||
pngBytes = Base64.getDecoder().decode(pngBase64);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// fetchPageImage is declared to throw IOException and callers catch
|
||||
// only IOException. Escaping IAE would abort the whole conversion
|
||||
// instead of skipping the failed page.
|
||||
throw new IOException("pdf2img PAGE_PNG_DATA is not valid Base64", e);
|
||||
}
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(pngBytes));
|
||||
if (image == null) {
|
||||
throw new IOException("pdf2img PAGE_PNG_DATA is not a readable image");
|
||||
}
|
||||
if (cropOutput.active()) {
|
||||
savePageImageFile(cropOutput.directory(), pageIndex, pngBytes);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a cropped image to IMAGE_CAPTIONING and returns the caption text.
|
||||
*/
|
||||
/** Image-captioning result: caption text + the model's self-reported confidence. */
|
||||
static final class CaptionResult {
|
||||
final String caption;
|
||||
final Double confidence;
|
||||
CaptionResult(String caption, Double confidence) {
|
||||
this.caption = caption;
|
||||
this.confidence = confidence;
|
||||
}
|
||||
}
|
||||
|
||||
private CaptionResult callImageCaptioning(byte[] pngBytes, int pageNum, int objectId) throws IOException {
|
||||
String requestId = "odl-" + sourcePdfShaShort + "-p" + pageNum + "-o" + objectId + "-caption";
|
||||
MultipartBody body = new MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("REQUEST_ID", requestId)
|
||||
.addFormDataPart("OPEN_API_NAME", "IMAGE_CAPTIONING")
|
||||
.addFormDataPart("DATA_FORMAT", "image")
|
||||
.addFormDataPart("FILE", "figure.png",
|
||||
RequestBody.create(pngBytes, MEDIA_TYPE_PNG))
|
||||
.build();
|
||||
|
||||
Request httpRequest = new Request.Builder()
|
||||
.url(baseUrl + SDK_ENDPOINT)
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
try (Response response = httpClient.newCall(httpRequest).execute()) {
|
||||
if (!response.isSuccessful()) return null;
|
||||
|
||||
ResponseBody respBody = response.body();
|
||||
if (respBody == null) return null;
|
||||
|
||||
JsonNode root = objectMapper.readTree(respBody.string());
|
||||
if (!root.has("SUCCESS") || !root.get("SUCCESS").asBoolean()) return null;
|
||||
|
||||
JsonNode result = root.get("RESULT");
|
||||
if (result == null || !result.isArray() || result.size() == 0) return null;
|
||||
|
||||
JsonNode page = result.get(0);
|
||||
if (page.isArray() && page.size() > 0) page = page.get(0);
|
||||
|
||||
String caption = page.has("caption") ? page.get("caption").asText("") : null;
|
||||
JsonNode confNode = page.get("confidence");
|
||||
Double confidence = confNode != null && confNode.isNumber()
|
||||
? confNode.doubleValue() : null;
|
||||
return new CaptionResult(caption, confidence);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a single HOCR SDK module with PDF input.
|
||||
*/
|
||||
private JsonNode callModule(byte[] pdfBytes, String moduleName) throws IOException {
|
||||
MultipartBody body = new MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("REQUEST_ID",
|
||||
"odl-" + sourcePdfShaShort + "-" + MODULE_SHORT.getOrDefault(moduleName, moduleName))
|
||||
.addFormDataPart("OPEN_API_NAME", moduleName)
|
||||
.addFormDataPart("DATA_FORMAT", "pdf")
|
||||
.addFormDataPart("FILE", DEFAULT_FILENAME,
|
||||
RequestBody.create(pdfBytes, MEDIA_TYPE_PDF))
|
||||
.build();
|
||||
|
||||
Request httpRequest = new Request.Builder()
|
||||
.url(baseUrl + SDK_ENDPOINT)
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
LOGGER.log(Level.INFO, "Calling Hancom AI module: {0}", moduleName);
|
||||
|
||||
try (Response response = httpClient.newCall(httpRequest).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
ResponseBody respBody = response.body();
|
||||
String errorMsg = respBody != null ? respBody.string() : "";
|
||||
LOGGER.log(Level.WARNING, "Hancom AI module {0} returned HTTP {1}: {2}",
|
||||
new Object[]{moduleName, response.code(), errorMsg});
|
||||
return objectMapper.createArrayNode();
|
||||
}
|
||||
|
||||
ResponseBody respBody = response.body();
|
||||
if (respBody == null) {
|
||||
return objectMapper.createArrayNode();
|
||||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(respBody.string());
|
||||
boolean success = root.has("SUCCESS") && root.get("SUCCESS").asBoolean();
|
||||
if (!success) {
|
||||
LOGGER.log(Level.WARNING, "Hancom AI module {0} returned SUCCESS=false: {1}",
|
||||
new Object[]{moduleName, root.has("MSG") ? root.get("MSG").asText() : ""});
|
||||
return objectMapper.createArrayNode();
|
||||
}
|
||||
|
||||
JsonNode result = root.get("RESULT");
|
||||
return result != null ? result : objectMapper.createArrayNode();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private List<JsonNode> extractPages(JsonNode moduleResult) {
|
||||
List<JsonNode> pages = new ArrayList<>();
|
||||
if (moduleResult == null || !moduleResult.isArray()) return pages;
|
||||
JsonNode inner = moduleResult.size() > 0 && moduleResult.get(0).isArray()
|
||||
? moduleResult.get(0) : moduleResult;
|
||||
for (JsonNode page : inner) {
|
||||
if (page.isObject()) pages.add(page);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
private byte[] imageToPng(BufferedImage image) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private void addTimings(ObjectNode timingsNode, String moduleName, JsonNode result) {
|
||||
long totalMs = 0;
|
||||
int pageCount = 0;
|
||||
if (result.isArray() && result.size() > 0) {
|
||||
JsonNode pages = result.get(0).isArray() ? result.get(0) : result;
|
||||
for (JsonNode page : pages) {
|
||||
if (page.has("run_time")) {
|
||||
totalMs += page.get("run_time").asLong();
|
||||
pageCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
ObjectNode timing = objectMapper.createObjectNode();
|
||||
timing.put("total_ms", totalMs);
|
||||
timing.put("count", pageCount);
|
||||
if (pageCount > 0) timing.put("avg_ms", totalMs / pageCount);
|
||||
timingsNode.set(moduleName, timing);
|
||||
}
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
httpClient.dispatcher().executorService().shutdown();
|
||||
httpClient.connectionPool().evictAll();
|
||||
if (httpClient.cache() != null) {
|
||||
try { httpClient.cache().close(); } catch (Exception ignored) { }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test hooks (package-private) ---
|
||||
|
||||
void invokeCallModule(byte[] pdfBytes, String moduleName) throws IOException {
|
||||
this.sourcePdfShaShort = sha256ShortHex(pdfBytes);
|
||||
callModule(pdfBytes, moduleName);
|
||||
}
|
||||
|
||||
void invokeCallImageCaptioning(byte[] pngBytes, int pageNum, int objectId) throws IOException {
|
||||
callImageCaptioning(pngBytes, pageNum, objectId);
|
||||
}
|
||||
}
|
||||
+1352
File diff suppressed because it is too large
Load Diff
+306
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* HTTP client for Hancom Document AI API.
|
||||
*
|
||||
* <p>This client communicates with the Hancom Document AI backend service
|
||||
* for PDF processing. The workflow is:
|
||||
* <ol>
|
||||
* <li>Upload PDF file to /v1/dl/files/upload</li>
|
||||
* <li>Request visual info extraction from /v1/dl/files/{fileId}/visualinfo</li>
|
||||
* <li>Delete the file from server after processing</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>The client ensures cleanup (file deletion) even when processing fails.
|
||||
*
|
||||
* @see HybridClient
|
||||
* @see HybridConfig
|
||||
*/
|
||||
public class HancomClient implements HybridClient {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(HancomClient.class.getCanonicalName());
|
||||
|
||||
/** Default URL for Hancom Document AI API. */
|
||||
public static final String DEFAULT_URL = "https://dataloader.cloud.hancom.com/studio-lite/api";
|
||||
|
||||
private static final String UPLOAD_ENDPOINT = "/v1/dl/files/upload";
|
||||
private static final String VISUALINFO_ENDPOINT = "/v1/dl/files/%s/visualinfo";
|
||||
private static final String DELETE_ENDPOINT = "/v1/dl/files/%s";
|
||||
|
||||
private static final String DEFAULT_FILENAME = "document.pdf";
|
||||
private static final MediaType MEDIA_TYPE_PDF = MediaType.parse("application/pdf");
|
||||
|
||||
// Query parameters for visualinfo
|
||||
private static final String ENGINE = "pdf_ai_dl";
|
||||
private static final String DLA_MODE = "ENABLED";
|
||||
private static final String OCR_MODE = "FORCE";
|
||||
|
||||
private static final int HEALTH_CHECK_TIMEOUT_MS = 3000;
|
||||
|
||||
private final String baseUrl;
|
||||
private final OkHttpClient httpClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Creates a new HancomClient with the specified configuration.
|
||||
*
|
||||
* @param config The hybrid configuration containing URL and timeout settings.
|
||||
*/
|
||||
public HancomClient(HybridConfig config) {
|
||||
String effectiveUrl = config.getEffectiveUrl("hancom");
|
||||
this.baseUrl = effectiveUrl != null ? normalizeUrl(effectiveUrl) : DEFAULT_URL;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.httpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(config.getTimeoutMs(), TimeUnit.MILLISECONDS)
|
||||
.readTimeout(config.getTimeoutMs(), TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(config.getTimeoutMs(), TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new HancomClient with a custom OkHttpClient (for testing).
|
||||
*
|
||||
* @param baseUrl The base URL of the Hancom API.
|
||||
* @param httpClient The OkHttp client to use for requests.
|
||||
* @param objectMapper The Jackson ObjectMapper for JSON parsing.
|
||||
*/
|
||||
HancomClient(String baseUrl, OkHttpClient httpClient, ObjectMapper objectMapper) {
|
||||
this.baseUrl = normalizeUrl(baseUrl);
|
||||
this.httpClient = httpClient;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkAvailability() throws IOException {
|
||||
OkHttpClient healthClient = httpClient.newBuilder()
|
||||
.connectTimeout(HEALTH_CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(HEALTH_CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl)
|
||||
.head()
|
||||
.build();
|
||||
|
||||
try (Response response = healthClient.newCall(request).execute()) {
|
||||
// Any HTTP response (including 4xx/5xx) means the server is reachable.
|
||||
// Hancom API requires authentication for all endpoints, so a 401/403
|
||||
// is expected and still proves connectivity.
|
||||
} catch (IOException e) {
|
||||
throw new IOException(
|
||||
"Hybrid server is not available at " + baseUrl + "\n"
|
||||
+ "Please check the server URL and ensure the Hancom API is accessible.\n"
|
||||
+ "Or pass --hybrid-fallback to fall back to Java-only output for this run.\n"
|
||||
+ "Or run without --hybrid flag for Java-only processing.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public HybridResponse convert(HybridRequest request) throws IOException {
|
||||
String fileId = null;
|
||||
try {
|
||||
// Step 1: Upload PDF
|
||||
fileId = uploadFile(request.getPdfBytes());
|
||||
LOGGER.log(Level.FINE, "Uploaded file with ID: {0}", fileId);
|
||||
|
||||
// Step 2: Get visual info
|
||||
JsonNode visualInfo = getVisualInfo(fileId);
|
||||
LOGGER.log(Level.FINE, "Retrieved visual info for file: {0}", fileId);
|
||||
|
||||
return new HybridResponse(null, visualInfo, null);
|
||||
} finally {
|
||||
// Step 3: Always cleanup
|
||||
if (fileId != null) {
|
||||
deleteFile(fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<HybridResponse> convertAsync(HybridRequest request) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return convert(request);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Failed to convert", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base URL of this client.
|
||||
*
|
||||
* @return The base URL.
|
||||
*/
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a PDF file to the Hancom API.
|
||||
*
|
||||
* @param pdfBytes The PDF file bytes.
|
||||
* @return The file ID assigned by the server.
|
||||
* @throws IOException If the upload fails.
|
||||
*/
|
||||
private String uploadFile(byte[] pdfBytes) throws IOException {
|
||||
MultipartBody requestBody = new MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("file", DEFAULT_FILENAME,
|
||||
RequestBody.create(pdfBytes, MEDIA_TYPE_PDF))
|
||||
.build();
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + UPLOAD_ENDPOINT)
|
||||
.post(requestBody)
|
||||
.build();
|
||||
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
ResponseBody body = response.body();
|
||||
String bodyStr = body != null ? body.string() : "";
|
||||
throw new IOException("Hancom upload failed with status " + response.code() + ": " + bodyStr);
|
||||
}
|
||||
|
||||
ResponseBody body = response.body();
|
||||
if (body == null) {
|
||||
throw new IOException("Empty response body from upload");
|
||||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(body.string());
|
||||
// Response format: {"codeNum":0,"code":"file.upload.success","data":{"fileId":"...",...}}
|
||||
JsonNode dataNode = root.get("data");
|
||||
if (dataNode == null) {
|
||||
throw new IOException("Invalid upload response: missing data field");
|
||||
}
|
||||
JsonNode fileIdNode = dataNode.get("fileId");
|
||||
if (fileIdNode == null || !fileIdNode.isTextual()) {
|
||||
throw new IOException("Invalid upload response: missing fileId in data");
|
||||
}
|
||||
|
||||
return fileIdNode.asText();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves visual info for an uploaded file.
|
||||
*
|
||||
* @param fileId The file ID from upload.
|
||||
* @return The visual info JSON response.
|
||||
* @throws IOException If the request fails.
|
||||
*/
|
||||
private JsonNode getVisualInfo(String fileId) throws IOException {
|
||||
String url = baseUrl + String.format(VISUALINFO_ENDPOINT, fileId) +
|
||||
"?engine=" + ENGINE +
|
||||
"&dlaMode=" + DLA_MODE +
|
||||
"&ocrMode=" + OCR_MODE;
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build();
|
||||
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
ResponseBody body = response.body();
|
||||
String bodyStr = body != null ? body.string() : "";
|
||||
throw new IOException("Hancom visualinfo failed with status " + response.code() + ": " + bodyStr);
|
||||
}
|
||||
|
||||
ResponseBody body = response.body();
|
||||
if (body == null) {
|
||||
throw new IOException("Empty response body from visualinfo");
|
||||
}
|
||||
|
||||
return objectMapper.readTree(body.string());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an uploaded file from the server.
|
||||
*
|
||||
* <p>This method silently ignores any errors to ensure cleanup
|
||||
* doesn't interfere with the main processing result.
|
||||
*
|
||||
* @param fileId The file ID to delete.
|
||||
*/
|
||||
private void deleteFile(String fileId) {
|
||||
String url = baseUrl + String.format(DELETE_ENDPOINT, fileId);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.delete()
|
||||
.build();
|
||||
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
if (response.isSuccessful()) {
|
||||
LOGGER.log(Level.FINE, "Deleted file: {0}", fileId);
|
||||
} else {
|
||||
LOGGER.log(Level.WARNING, "Failed to delete file {0}: {1}",
|
||||
new Object[]{fileId, response.code()});
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.WARNING, "Error deleting file " + fileId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a URL by removing trailing slashes.
|
||||
*/
|
||||
private static String normalizeUrl(String url) {
|
||||
if (url != null && url.endsWith("/")) {
|
||||
return url.substring(0, url.length() - 1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts down the HTTP client and releases all resources.
|
||||
*
|
||||
* <p>This gracefully shuts down the dispatcher's executor service,
|
||||
* allowing the JVM to exit cleanly. Idle connections are evicted
|
||||
* from the connection pool.
|
||||
*/
|
||||
public void shutdown() {
|
||||
httpClient.dispatcher().executorService().shutdown();
|
||||
httpClient.connectionPool().evictAll();
|
||||
if (httpClient.cache() != null) {
|
||||
try {
|
||||
httpClient.cache().close();
|
||||
} catch (Exception ignored) {
|
||||
// Ignore cache close errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+554
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.opendataloader.pdf.entities.SemanticFormula;
|
||||
import org.opendataloader.pdf.entities.SemanticPicture;
|
||||
import org.opendataloader.pdf.hybrid.HybridClient.HybridResponse;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticHeading;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticParagraph;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextLine;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.SemanticType;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Transforms Hancom VisualInfoDto JSON output to OpenDataLoader IObject hierarchy.
|
||||
*
|
||||
* <p>This transformer handles the Hancom Document AI response format and converts
|
||||
* its elements (PARAGRAPH, HEADING, TABLE, FIGURE, etc.) to the equivalent IObject
|
||||
* types used by OpenDataLoader's downstream processors and generators.
|
||||
*
|
||||
* <h2>Schema Mapping</h2>
|
||||
* <ul>
|
||||
* <li>PARAGRAPH → SemanticParagraph</li>
|
||||
* <li>HEADING → SemanticHeading</li>
|
||||
* <li>TABLE → TableBorder with rows and cells</li>
|
||||
* <li>FIGURE → SemanticPicture</li>
|
||||
* <li>FORMULA → SemanticFormula</li>
|
||||
* <li>LIST_ITEM → SemanticParagraph</li>
|
||||
* <li>PAGE_HEADER, PAGE_FOOTER → Filtered out (furniture)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Coordinate System</h2>
|
||||
* <p>Hancom uses TOPLEFT origin with (left, top, width, height) format.
|
||||
* OpenDataLoader uses BOTTOMLEFT origin with (left, bottom, right, top) format.
|
||||
* This transformer handles the coordinate conversion.
|
||||
*
|
||||
* <h2>Thread Safety</h2>
|
||||
* <p>This class is NOT thread-safe. The {@code transform()} method resets
|
||||
* internal state (pictureIndex) at the start of each call. Concurrent calls
|
||||
* to transform() on the same instance may produce incorrect results.
|
||||
* Use separate instances for concurrent transformations.
|
||||
*/
|
||||
public class HancomSchemaTransformer implements HybridSchemaTransformer {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(HancomSchemaTransformer.class.getCanonicalName());
|
||||
|
||||
private static final String BACKEND_TYPE = "hancom";
|
||||
|
||||
// Picture index counter (reset per transform call)
|
||||
private int pictureIndex;
|
||||
|
||||
// Hancom element types
|
||||
private static final String TYPE_PARAGRAPH = "PARAGRAPH";
|
||||
private static final String TYPE_HEADING = "HEADING";
|
||||
private static final String TYPE_TABLE = "TABLE";
|
||||
private static final String TYPE_FIGURE = "FIGURE";
|
||||
private static final String TYPE_FORMULA = "FORMULA";
|
||||
private static final String TYPE_LIST_ITEM = "LIST_ITEM";
|
||||
private static final String TYPE_PAGE_HEADER = "PAGE_HEADER";
|
||||
private static final String TYPE_PAGE_FOOTER = "PAGE_FOOTER";
|
||||
|
||||
@Override
|
||||
public String getBackendType() {
|
||||
return BACKEND_TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<List<IObject>> transform(HybridResponse response, Map<Integer, Double> pageHeights) {
|
||||
JsonNode json = response.getJson();
|
||||
if (json == null) {
|
||||
LOGGER.log(Level.WARNING, "HybridResponse JSON is null, returning empty result");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// Reset picture index for each transform call
|
||||
pictureIndex = 0;
|
||||
|
||||
// Determine number of pages
|
||||
int numPages = determinePageCount(json, pageHeights);
|
||||
|
||||
// Initialize result list
|
||||
List<List<IObject>> result = new ArrayList<>(numPages);
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
result.add(new ArrayList<>());
|
||||
}
|
||||
|
||||
// Transform elements
|
||||
JsonNode elements = json.get("elements");
|
||||
if (elements != null && elements.isArray()) {
|
||||
for (JsonNode element : elements) {
|
||||
transformElement(element, result, pageHeights);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each page's contents by reading order (top to bottom, left to right)
|
||||
for (List<IObject> pageContents : result) {
|
||||
sortByReadingOrder(pageContents);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IObject> transformPage(int pageNumber, JsonNode pageContent, double pageHeight) {
|
||||
Map<Integer, Double> pageHeights = new HashMap<>();
|
||||
pageHeights.put(pageNumber, pageHeight);
|
||||
|
||||
// Create a wrapper response with just this page's content
|
||||
HybridResponse singlePageResponse = new HybridResponse("", pageContent, Collections.emptyMap());
|
||||
List<List<IObject>> result = transform(singlePageResponse, pageHeights);
|
||||
|
||||
if (result.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// Find the page in the result (pageIndex is 0-based, pageNumber is 1-based)
|
||||
int pageIndex = pageNumber - 1;
|
||||
if (pageIndex >= 0 && pageIndex < result.size()) {
|
||||
return result.get(pageIndex);
|
||||
}
|
||||
|
||||
// If result has content but page index doesn't match, return first page
|
||||
if (!result.isEmpty() && !result.get(0).isEmpty()) {
|
||||
return result.get(0);
|
||||
}
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the number of pages from the JSON response.
|
||||
*/
|
||||
private int determinePageCount(JsonNode json, Map<Integer, Double> pageHeights) {
|
||||
// First check pageHeights if provided
|
||||
if (pageHeights != null && !pageHeights.isEmpty()) {
|
||||
return pageHeights.keySet().stream().mapToInt(Integer::intValue).max().orElse(0);
|
||||
}
|
||||
|
||||
// Check pageSizes array
|
||||
JsonNode pageSizes = json.get("pageSizes");
|
||||
if (pageSizes != null && pageSizes.isArray()) {
|
||||
return pageSizes.size();
|
||||
}
|
||||
|
||||
// Scan elements for max pageIndex
|
||||
return scanElementsForPageCount(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans elements to determine page count.
|
||||
*/
|
||||
private int scanElementsForPageCount(JsonNode json) {
|
||||
int maxPage = 0;
|
||||
|
||||
JsonNode elements = json.get("elements");
|
||||
if (elements != null && elements.isArray()) {
|
||||
for (JsonNode element : elements) {
|
||||
JsonNode pageIndex = element.get("pageIndex");
|
||||
if (pageIndex != null && pageIndex.isInt()) {
|
||||
maxPage = Math.max(maxPage, pageIndex.asInt() + 1); // pageIndex is 0-based
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(maxPage, 1); // At least 1 page
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a single Hancom element to an IObject.
|
||||
*/
|
||||
private void transformElement(JsonNode element, List<List<IObject>> result, Map<Integer, Double> pageHeights) {
|
||||
// Get category type
|
||||
JsonNode category = element.get("category");
|
||||
if (category == null) {
|
||||
LOGGER.log(Level.FINE, "Element missing category, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
String type = getTextValue(category, "type");
|
||||
if (type == null) {
|
||||
LOGGER.log(Level.FINE, "Element category missing type, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip furniture elements (page headers/footers)
|
||||
if (TYPE_PAGE_HEADER.equals(type) || TYPE_PAGE_FOOTER.equals(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get page index (0-based)
|
||||
int pageIndex = element.has("pageIndex") ? element.get("pageIndex").asInt() : 0;
|
||||
|
||||
// Ensure result list is large enough
|
||||
while (result.size() <= pageIndex) {
|
||||
result.add(new ArrayList<>());
|
||||
}
|
||||
|
||||
// Get bounding box
|
||||
JsonNode bboxNode = element.get("bbox");
|
||||
if (bboxNode == null) {
|
||||
LOGGER.log(Level.FINE, "Element missing bbox, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get page height for coordinate conversion
|
||||
Double pageHeight = pageHeights != null ? pageHeights.get(pageIndex + 1) : null;
|
||||
if (pageHeight == null) {
|
||||
// Try to get from pageSizes
|
||||
pageHeight = 842.0; // Default A4 height
|
||||
}
|
||||
|
||||
BoundingBox bbox = extractBoundingBox(bboxNode, pageIndex, pageHeight);
|
||||
|
||||
// Get content
|
||||
JsonNode contentNode = element.get("content");
|
||||
String text = contentNode != null ? getTextValue(contentNode, "text") : null;
|
||||
if (text == null) {
|
||||
text = "";
|
||||
}
|
||||
|
||||
// Create appropriate IObject based on type
|
||||
IObject object = null;
|
||||
|
||||
switch (type) {
|
||||
case TYPE_PARAGRAPH:
|
||||
case TYPE_LIST_ITEM:
|
||||
object = createParagraph(text, bbox);
|
||||
break;
|
||||
case TYPE_HEADING:
|
||||
object = createHeading(text, bbox);
|
||||
break;
|
||||
case TYPE_TABLE:
|
||||
object = transformTable(element, bbox, pageIndex, pageHeight);
|
||||
break;
|
||||
case TYPE_FIGURE:
|
||||
object = createPicture(bbox);
|
||||
break;
|
||||
case TYPE_FORMULA:
|
||||
object = createFormula(text, bbox);
|
||||
break;
|
||||
default:
|
||||
// Unknown type, treat as paragraph if has text
|
||||
if (!text.isEmpty()) {
|
||||
object = createParagraph(text, bbox);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (object != null) {
|
||||
result.get(pageIndex).add(object);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SemanticParagraph.
|
||||
*/
|
||||
private SemanticParagraph createParagraph(String text, BoundingBox bbox) {
|
||||
TextChunk textChunk = new TextChunk(bbox, text, 12.0, 12.0);
|
||||
textChunk.adjustSymbolEndsToBoundingBox(null);
|
||||
TextLine textLine = new TextLine(textChunk);
|
||||
|
||||
SemanticParagraph paragraph = new SemanticParagraph();
|
||||
paragraph.add(textLine);
|
||||
paragraph.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
// Set semantic score to avoid NullPointerException in ListUtils.isContainsHeading()
|
||||
paragraph.setCorrectSemanticScore(1.0);
|
||||
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SemanticHeading.
|
||||
*/
|
||||
private SemanticHeading createHeading(String text, BoundingBox bbox) {
|
||||
TextChunk textChunk = new TextChunk(bbox, text, 12.0, 12.0);
|
||||
textChunk.adjustSymbolEndsToBoundingBox(null);
|
||||
TextLine textLine = new TextLine(textChunk);
|
||||
|
||||
SemanticHeading heading = new SemanticHeading();
|
||||
heading.add(textLine);
|
||||
heading.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
heading.setHeadingLevel(1); // Default level
|
||||
// Set semantic score to avoid NullPointerException in ListUtils.isContainsHeading()
|
||||
heading.setCorrectSemanticScore(1.0);
|
||||
|
||||
return heading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SemanticFormula.
|
||||
*/
|
||||
private SemanticFormula createFormula(String latex, BoundingBox bbox) {
|
||||
SemanticFormula formula = new SemanticFormula(bbox, latex);
|
||||
formula.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
return formula;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SemanticPicture.
|
||||
*/
|
||||
private SemanticPicture createPicture(BoundingBox bbox) {
|
||||
SemanticPicture picture = new SemanticPicture(bbox, ++pictureIndex, null);
|
||||
picture.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
return picture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a Hancom table element to a TableBorder.
|
||||
*
|
||||
* <p>Hancom API returns table data in this structure:
|
||||
* <pre>
|
||||
* {
|
||||
* "content": {
|
||||
* "text": "...",
|
||||
* "html": "<table>...</table>",
|
||||
* "table": {
|
||||
* "cells": [
|
||||
* {"cellId": "0", "rowspan": [0], "colspan": [0], "bbox": {...}, "text": "..."},
|
||||
* ...
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* </pre>
|
||||
*/
|
||||
private TableBorder transformTable(JsonNode element, BoundingBox tableBbox,
|
||||
int pageIndex, double pageHeight) {
|
||||
// Get table cells from content.table.cells
|
||||
JsonNode contentNode = element.get("content");
|
||||
if (contentNode == null) {
|
||||
LOGGER.log(Level.FINE, "Table element missing content, skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hancom API: cells are in content.table.cells
|
||||
JsonNode tableNode = contentNode.get("table");
|
||||
if (tableNode == null) {
|
||||
LOGGER.log(Level.FINE, "Table element missing content.table, skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonNode cellsNode = tableNode.get("cells");
|
||||
if (cellsNode == null || !cellsNode.isArray() || cellsNode.size() == 0) {
|
||||
LOGGER.log(Level.FINE, "Table missing cells, skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine table dimensions from cells
|
||||
int numRows = 0;
|
||||
int numCols = 0;
|
||||
Map<String, JsonNode> cellMap = new HashMap<>();
|
||||
|
||||
for (JsonNode cell : cellsNode) {
|
||||
JsonNode rowspanNode = cell.get("rowspan");
|
||||
JsonNode colspanNode = cell.get("colspan");
|
||||
|
||||
if (rowspanNode != null && rowspanNode.isArray() && rowspanNode.size() > 0) {
|
||||
int maxRow = 0;
|
||||
for (JsonNode r : rowspanNode) {
|
||||
maxRow = Math.max(maxRow, r.asInt() + 1);
|
||||
}
|
||||
numRows = Math.max(numRows, maxRow);
|
||||
}
|
||||
|
||||
if (colspanNode != null && colspanNode.isArray() && colspanNode.size() > 0) {
|
||||
int maxCol = 0;
|
||||
for (JsonNode c : colspanNode) {
|
||||
maxCol = Math.max(maxCol, c.asInt() + 1);
|
||||
}
|
||||
numCols = Math.max(numCols, maxCol);
|
||||
}
|
||||
|
||||
// Store cell by row,col key
|
||||
int row = rowspanNode != null && rowspanNode.size() > 0 ? rowspanNode.get(0).asInt() : 0;
|
||||
int col = colspanNode != null && colspanNode.size() > 0 ? colspanNode.get(0).asInt() : 0;
|
||||
String key = row + "," + col;
|
||||
cellMap.put(key, cell);
|
||||
}
|
||||
|
||||
if (numRows == 0 || numCols == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create TableBorder
|
||||
TableBorder table = new TableBorder(numRows, numCols);
|
||||
table.setBoundingBox(tableBbox);
|
||||
table.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
|
||||
// Build table structure
|
||||
double rowHeight = (tableBbox.getTopY() - tableBbox.getBottomY()) / numRows;
|
||||
double colWidth = (tableBbox.getRightX() - tableBbox.getLeftX()) / numCols;
|
||||
|
||||
// Pass 1: Initialize all row containers to prevent NPE when backfilling spans
|
||||
for (int row = 0; row < numRows; row++) {
|
||||
TableBorderRow borderRow = new TableBorderRow(row, numCols, 0L);
|
||||
double rowTop = tableBbox.getTopY() - (row * rowHeight);
|
||||
double rowBottom = rowTop - rowHeight;
|
||||
borderRow.setBoundingBox(new BoundingBox(pageIndex,
|
||||
tableBbox.getLeftX(), rowBottom, tableBbox.getRightX(), rowTop));
|
||||
table.getRows()[row] = borderRow;
|
||||
}
|
||||
|
||||
// Pass 2: Fill cells with proper span backfill
|
||||
for (int row = 0; row < numRows; row++) {
|
||||
TableBorderRow borderRow = table.getRows()[row];
|
||||
|
||||
for (int col = 0; col < numCols; col++) {
|
||||
if (borderRow.getCells()[col] != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = row + "," + col;
|
||||
JsonNode cellNode = cellMap.get(key);
|
||||
|
||||
int rowSpan = 1;
|
||||
int colSpan = 1;
|
||||
String cellText = "";
|
||||
|
||||
if (cellNode != null) {
|
||||
JsonNode rowspanNode = cellNode.get("rowspan");
|
||||
JsonNode colspanNode = cellNode.get("colspan");
|
||||
|
||||
rowSpan = rowspanNode != null ? rowspanNode.size() : 1;
|
||||
colSpan = colspanNode != null ? colspanNode.size() : 1;
|
||||
|
||||
cellText = getTextValue(cellNode, "text");
|
||||
if (cellText == null) {
|
||||
cellText = "";
|
||||
}
|
||||
}
|
||||
|
||||
int effectiveRowSpan = Math.max(1, Math.min(rowSpan, numRows - row));
|
||||
int effectiveColSpan = Math.max(1, Math.min(colSpan, numCols - col));
|
||||
|
||||
TableBorderCell cell = new TableBorderCell(row, col, effectiveRowSpan, effectiveColSpan, 0L);
|
||||
cell.setSemanticType(row == 0 ? SemanticType.TABLE_HEADER : SemanticType.TABLE_CELL);
|
||||
double cellLeft = tableBbox.getLeftX() + (col * colWidth);
|
||||
double cellRight = cellLeft + (effectiveColSpan * colWidth);
|
||||
double cellTop = tableBbox.getTopY() - (row * rowHeight);
|
||||
double cellBottom = cellTop - (effectiveRowSpan * rowHeight);
|
||||
cell.setBoundingBox(new BoundingBox(pageIndex, cellLeft, cellBottom, cellRight, cellTop));
|
||||
|
||||
// Add cell content if present
|
||||
if (!cellText.isEmpty()) {
|
||||
SemanticParagraph content = createParagraph(cellText, cell.getBoundingBox());
|
||||
cell.addContentObject(content);
|
||||
}
|
||||
|
||||
borderRow.getCells()[col] = cell;
|
||||
|
||||
// Backfill: assign same cell instance to all slots it covers
|
||||
// All target rows are guaranteed to exist from Pass 1
|
||||
for (int r = row; r < row + effectiveRowSpan; r++) {
|
||||
for (int c = col; c < col + effectiveColSpan; c++) {
|
||||
table.getRows()[r].getCells()[c] = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a BoundingBox from Hancom bbox JSON.
|
||||
*
|
||||
* <p>Hancom uses TOPLEFT origin with {left, top, width, height} format.
|
||||
* OpenDataLoader uses BOTTOMLEFT origin with {left, bottom, right, top} format.
|
||||
*
|
||||
* @param bboxNode The bbox JSON node with left, top, width, height fields
|
||||
* @param pageIndex The 0-indexed page number
|
||||
* @param pageHeight The page height for coordinate transformation
|
||||
* @return A BoundingBox in OpenDataLoader format
|
||||
*/
|
||||
private BoundingBox extractBoundingBox(JsonNode bboxNode, int pageIndex, double pageHeight) {
|
||||
if (bboxNode == null) {
|
||||
return new BoundingBox(pageIndex, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
double left = bboxNode.has("left") ? bboxNode.get("left").asDouble() : 0;
|
||||
double top = bboxNode.has("top") ? bboxNode.get("top").asDouble() : 0;
|
||||
double width = bboxNode.has("width") ? bboxNode.get("width").asDouble() : 0;
|
||||
double height = bboxNode.has("height") ? bboxNode.get("height").asDouble() : 0;
|
||||
|
||||
// Convert from TOPLEFT to BOTTOMLEFT origin
|
||||
// In TOPLEFT: top is distance from top of page
|
||||
// In BOTTOMLEFT: bottom is distance from bottom of page
|
||||
double right = left + width;
|
||||
double bottomY = pageHeight - top - height; // Convert top distance to bottom coordinate
|
||||
double topY = pageHeight - top; // Convert top distance to top coordinate
|
||||
|
||||
return new BoundingBox(pageIndex, left, bottomY, right, topY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a text value from a JSON node.
|
||||
*/
|
||||
private String getTextValue(JsonNode node, String fieldName) {
|
||||
if (node != null && node.has(fieldName)) {
|
||||
JsonNode field = node.get(fieldName);
|
||||
if (field.isTextual()) {
|
||||
return field.asText();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts page contents by reading order (top to bottom, left to right).
|
||||
*/
|
||||
private void sortByReadingOrder(List<IObject> contents) {
|
||||
contents.sort(new Comparator<IObject>() {
|
||||
@Override
|
||||
public int compare(IObject o1, IObject o2) {
|
||||
// Sort by top Y (descending - higher on page first)
|
||||
double topDiff = o2.getTopY() - o1.getTopY();
|
||||
if (Math.abs(topDiff) > 5.0) { // Use tolerance for same-line detection
|
||||
return topDiff > 0 ? 1 : -1;
|
||||
}
|
||||
// Same line, sort by left X (ascending)
|
||||
return Double.compare(o1.getLeftX(), o2.getLeftX());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Interface for hybrid PDF processing backends.
|
||||
*
|
||||
* <p>Hybrid processing routes pages to external AI backends (like docling, hancom, azure)
|
||||
* for advanced document parsing capabilities such as table structure extraction and OCR.
|
||||
*
|
||||
* <p>Implementations of this interface provide HTTP client integration with specific backends.
|
||||
*/
|
||||
public interface HybridClient {
|
||||
|
||||
/**
|
||||
* Optional: fetch the backend's {@code /health} (or equivalent) payload
|
||||
* for reference-environment metadata (hardware, model identifiers,
|
||||
* backend version). Best-effort — returns {@code null} when the backend
|
||||
* has no such endpoint or the call fails. The default returns
|
||||
* {@code null}; implementations that have a health endpoint should
|
||||
* override.
|
||||
*/
|
||||
default com.fasterxml.jackson.databind.JsonNode fetchHealth() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-document destination for crop / page-image artifacts.
|
||||
*
|
||||
* <p>Clients are cached and reused across documents
|
||||
* ({@link HybridClientFactory#getOrCreate}), so the {@link HybridConfig}
|
||||
* captured at construction reflects only the <em>first</em> document and
|
||||
* cannot be the source of truth for where the <em>current</em> document's
|
||||
* crops belong. This value travels with each {@link HybridRequest}
|
||||
* instead, so the destination tracks the document being processed without
|
||||
* any shared mutable state — safe even if convert calls run concurrently.
|
||||
*
|
||||
* <p>{@code enabled} controls whether crop / page-image artifacts are
|
||||
* written; {@code directory} is the destination for this document (or
|
||||
* {@code null} when none).
|
||||
*/
|
||||
final class CropOutput {
|
||||
/** Sentinel meaning "do not write crops for this request". */
|
||||
public static final CropOutput DISABLED = new CropOutput(false, null);
|
||||
|
||||
private final boolean enabled;
|
||||
private final String directory;
|
||||
|
||||
public CropOutput(boolean enabled, String directory) {
|
||||
this.enabled = enabled;
|
||||
this.directory = directory;
|
||||
}
|
||||
|
||||
/** Whether crop / page-image artifacts should be written. */
|
||||
public boolean enabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** Destination directory, or {@code null} when none is set. */
|
||||
public String directory() {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/** True when crops should be written and a destination is set. */
|
||||
public boolean active() {
|
||||
return enabled && directory != null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output formats that can be requested from the hybrid backend.
|
||||
*/
|
||||
enum OutputFormat {
|
||||
/** JSON structured document format (DoclingDocument). */
|
||||
JSON("json"),
|
||||
/** Markdown text format. */
|
||||
MARKDOWN("md"),
|
||||
/** HTML format. */
|
||||
HTML("html");
|
||||
|
||||
private final String apiValue;
|
||||
|
||||
OutputFormat(String apiValue) {
|
||||
this.apiValue = apiValue;
|
||||
}
|
||||
|
||||
/** Returns the API parameter value for this format. */
|
||||
public String getApiValue() {
|
||||
return apiValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request class containing PDF bytes and processing options.
|
||||
*
|
||||
* <p>Note: OCR and table structure detection are always enabled on the server side.
|
||||
* The DocumentConverter is initialized once at startup with fixed options for performance.
|
||||
*/
|
||||
final class HybridRequest {
|
||||
private final byte[] pdfBytes;
|
||||
private final Set<Integer> pageNumbers;
|
||||
private final Set<OutputFormat> outputFormats;
|
||||
private final CropOutput cropOutput;
|
||||
|
||||
/**
|
||||
* Creates a new HybridRequest.
|
||||
*
|
||||
* @param pdfBytes The raw PDF file bytes to process.
|
||||
* @param pageNumbers Set of 1-indexed page numbers to process. If empty, process all pages.
|
||||
* @param outputFormats Set of output formats to request. If empty, defaults to all formats.
|
||||
*/
|
||||
public HybridRequest(byte[] pdfBytes, Set<Integer> pageNumbers,
|
||||
Set<OutputFormat> outputFormats) {
|
||||
this(pdfBytes, pageNumbers, outputFormats, CropOutput.DISABLED);
|
||||
}
|
||||
|
||||
private HybridRequest(byte[] pdfBytes, Set<Integer> pageNumbers,
|
||||
Set<OutputFormat> outputFormats, CropOutput cropOutput) {
|
||||
this.pdfBytes = pdfBytes != null ? Arrays.copyOf(pdfBytes, pdfBytes.length) : null;
|
||||
this.pageNumbers = pageNumbers != null ? pageNumbers : Collections.emptySet();
|
||||
this.outputFormats = outputFormats != null && !outputFormats.isEmpty()
|
||||
? EnumSet.copyOf(outputFormats)
|
||||
: EnumSet.allOf(OutputFormat.class);
|
||||
this.cropOutput = cropOutput != null ? cropOutput : CropOutput.DISABLED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of this request with the given crop-output destination.
|
||||
* Lets the caller attach a per-document crop target without mutating the
|
||||
* cached client. The original request is unchanged.
|
||||
*
|
||||
* @param cropOutput where to write crops / page images for this document
|
||||
* @return a new request carrying {@code cropOutput}
|
||||
*/
|
||||
public HybridRequest withCropOutput(CropOutput cropOutput) {
|
||||
return new HybridRequest(pdfBytes, pageNumbers, outputFormats, cropOutput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-document crop / page-image destination. Never {@code null};
|
||||
* defaults to {@link CropOutput#DISABLED}.
|
||||
*/
|
||||
public CropOutput getCropOutput() {
|
||||
return cropOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a request to process all pages with default options.
|
||||
*
|
||||
* @param pdfBytes The PDF file bytes.
|
||||
* @return A new HybridRequest for all pages with all output formats.
|
||||
*/
|
||||
public static HybridRequest allPages(byte[] pdfBytes) {
|
||||
return new HybridRequest(pdfBytes, Collections.emptySet(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a request to process all pages with specified output formats.
|
||||
*
|
||||
* @param pdfBytes The PDF file bytes.
|
||||
* @param outputFormats The output formats to request.
|
||||
* @return A new HybridRequest for all pages.
|
||||
*/
|
||||
public static HybridRequest allPages(byte[] pdfBytes, Set<OutputFormat> outputFormats) {
|
||||
return new HybridRequest(pdfBytes, Collections.emptySet(), outputFormats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a request to process specific pages.
|
||||
*
|
||||
* @param pdfBytes The PDF file bytes.
|
||||
* @param pageNumbers The 1-indexed page numbers to process.
|
||||
* @return A new HybridRequest for the specified pages.
|
||||
*/
|
||||
public static HybridRequest forPages(byte[] pdfBytes, Set<Integer> pageNumbers) {
|
||||
return new HybridRequest(pdfBytes, pageNumbers, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a request to process specific pages with specified output formats.
|
||||
*
|
||||
* @param pdfBytes The PDF file bytes.
|
||||
* @param pageNumbers The 1-indexed page numbers to process.
|
||||
* @param outputFormats The output formats to request.
|
||||
* @return A new HybridRequest for the specified pages.
|
||||
*/
|
||||
public static HybridRequest forPages(byte[] pdfBytes, Set<Integer> pageNumbers,
|
||||
Set<OutputFormat> outputFormats) {
|
||||
return new HybridRequest(pdfBytes, pageNumbers, outputFormats);
|
||||
}
|
||||
|
||||
public byte[] getPdfBytes() {
|
||||
return pdfBytes != null ? Arrays.copyOf(pdfBytes, pdfBytes.length) : null;
|
||||
}
|
||||
|
||||
public Set<Integer> getPageNumbers() {
|
||||
return pageNumbers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the output formats to request from the backend.
|
||||
*
|
||||
* @return Set of output formats. Never empty.
|
||||
*/
|
||||
public Set<OutputFormat> getOutputFormats() {
|
||||
return outputFormats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if JSON output is requested.
|
||||
*
|
||||
* @return true if JSON format is included.
|
||||
*/
|
||||
public boolean wantsJson() {
|
||||
return outputFormats.contains(OutputFormat.JSON);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Markdown output is requested.
|
||||
*
|
||||
* @return true if Markdown format is included.
|
||||
*/
|
||||
public boolean wantsMarkdown() {
|
||||
return outputFormats.contains(OutputFormat.MARKDOWN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if HTML output is requested.
|
||||
*
|
||||
* @return true if HTML format is included.
|
||||
*/
|
||||
public boolean wantsHtml() {
|
||||
return outputFormats.contains(OutputFormat.HTML);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Response class containing parsed document content.
|
||||
*/
|
||||
final class HybridResponse {
|
||||
private final String markdown;
|
||||
private final String html;
|
||||
private final JsonNode json;
|
||||
private final Map<Integer, JsonNode> pageContents;
|
||||
private final List<Integer> failedPages;
|
||||
private final JsonNode timings;
|
||||
|
||||
/**
|
||||
* Creates a new HybridResponse.
|
||||
*
|
||||
* @param markdown The markdown representation of the document.
|
||||
* @param html The HTML representation of the document.
|
||||
* @param json The full structured JSON output (DoclingDocument format).
|
||||
* @param pageContents Per-page JSON content, keyed by 1-indexed page number.
|
||||
* @param failedPages List of 1-indexed page numbers that failed during backend processing.
|
||||
* @param timings Per-step pipeline timings from the hybrid server (may be null).
|
||||
*/
|
||||
public HybridResponse(String markdown, String html, JsonNode json,
|
||||
Map<Integer, JsonNode> pageContents, List<Integer> failedPages,
|
||||
JsonNode timings) {
|
||||
this.markdown = markdown != null ? markdown : "";
|
||||
this.html = html != null ? html : "";
|
||||
this.json = json;
|
||||
this.pageContents = pageContents != null ? pageContents : Collections.emptyMap();
|
||||
this.failedPages = failedPages != null
|
||||
? Collections.unmodifiableList(new ArrayList<>(failedPages))
|
||||
: Collections.emptyList();
|
||||
this.timings = timings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new HybridResponse (without timings, backward compatible).
|
||||
*/
|
||||
public HybridResponse(String markdown, String html, JsonNode json,
|
||||
Map<Integer, JsonNode> pageContents, List<Integer> failedPages) {
|
||||
this(markdown, html, json, pageContents, failedPages, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new HybridResponse (backward compatible constructor).
|
||||
*
|
||||
* @param markdown The markdown representation of the document.
|
||||
* @param html The HTML representation of the document.
|
||||
* @param json The full structured JSON output (DoclingDocument format).
|
||||
* @param pageContents Per-page JSON content, keyed by 1-indexed page number.
|
||||
*/
|
||||
public HybridResponse(String markdown, String html, JsonNode json, Map<Integer, JsonNode> pageContents) {
|
||||
this(markdown, html, json, pageContents, Collections.emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new HybridResponse (backward compatible constructor).
|
||||
*
|
||||
* @param markdown The markdown representation of the document.
|
||||
* @param json The full structured JSON output (DoclingDocument format).
|
||||
* @param pageContents Per-page JSON content, keyed by 1-indexed page number.
|
||||
*/
|
||||
public HybridResponse(String markdown, JsonNode json, Map<Integer, JsonNode> pageContents) {
|
||||
this(markdown, "", json, pageContents, Collections.emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty response.
|
||||
*
|
||||
* @return A new HybridResponse with empty/null values.
|
||||
*/
|
||||
public static HybridResponse empty() {
|
||||
return new HybridResponse("", "", null, Collections.emptyMap());
|
||||
}
|
||||
|
||||
public String getMarkdown() {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
public String getHtml() {
|
||||
return html;
|
||||
}
|
||||
|
||||
public JsonNode getJson() {
|
||||
return json;
|
||||
}
|
||||
|
||||
public Map<Integer, JsonNode> getPageContents() {
|
||||
return pageContents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns per-step pipeline timings from the hybrid server.
|
||||
*
|
||||
* <p>Keys are step names (e.g. "layout", "ocr", "table_structure",
|
||||
* "picture_description"). Each value contains "total_s", "avg_s", and "count".
|
||||
*
|
||||
* @return Timings JSON node, or null if profiling was not enabled on the server.
|
||||
*/
|
||||
public JsonNode getTimings() {
|
||||
return timings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of 1-indexed page numbers that failed during backend processing.
|
||||
*
|
||||
* <p>When the backend returns partial_success, some pages may have failed due to
|
||||
* issues like invalid code points in PDF font encoding. These pages can be retried
|
||||
* via the Java processing path as a fallback.
|
||||
*
|
||||
* @return List of failed page numbers (1-indexed), or empty list if all pages succeeded.
|
||||
*/
|
||||
public List<Integer> getFailedPages() {
|
||||
return failedPages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the backend reported any failed pages.
|
||||
*
|
||||
* @return true if at least one page failed during backend processing.
|
||||
*/
|
||||
public boolean hasFailedPages() {
|
||||
return !failedPages.isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
HybridResponse that = (HybridResponse) o;
|
||||
return Objects.equals(markdown, that.markdown) &&
|
||||
Objects.equals(html, that.html) &&
|
||||
Objects.equals(json, that.json) &&
|
||||
Objects.equals(pageContents, that.pageContents) &&
|
||||
Objects.equals(failedPages, that.failedPages) &&
|
||||
Objects.equals(timings, that.timings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(markdown, html, json, pageContents, failedPages, timings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the backend server is available and ready to accept requests.
|
||||
*
|
||||
* <p>This performs a lightweight health check (e.g., HTTP GET to /health) with a short
|
||||
* timeout to verify connectivity before sending actual conversion requests.
|
||||
*
|
||||
* @throws IOException If the server is unreachable or not ready.
|
||||
*/
|
||||
void checkAvailability() throws IOException;
|
||||
|
||||
/**
|
||||
* Converts a PDF document synchronously.
|
||||
*
|
||||
* @param request The conversion request containing PDF bytes and options.
|
||||
* @return The conversion response with parsed content.
|
||||
* @throws IOException If an I/O error occurs during the request.
|
||||
*/
|
||||
HybridResponse convert(HybridRequest request) throws IOException;
|
||||
|
||||
/**
|
||||
* Converts a PDF document asynchronously.
|
||||
*
|
||||
* <p>This method is useful for parallel processing where multiple pages
|
||||
* can be processed concurrently with the Java backend.
|
||||
*
|
||||
* @param request The conversion request containing PDF bytes and options.
|
||||
* @return A CompletableFuture that completes with the conversion response.
|
||||
*/
|
||||
CompletableFuture<HybridResponse> convertAsync(HybridRequest request);
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Factory for creating and managing hybrid client instances.
|
||||
*
|
||||
* <p>This factory provides a central point for instantiating HybridClient
|
||||
* implementations based on the specified backend type. Clients are cached
|
||||
* and reused to avoid creating multiple thread pools per document.
|
||||
*
|
||||
* <p>Supported backends:
|
||||
* <ul>
|
||||
* <li>{@code docling-fast} - Optimized docling SDK server</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Future backends (not yet implemented):
|
||||
* <ul>
|
||||
* <li>{@code hancom} - Hancom document parsing service</li>
|
||||
* <li>{@code azure} - Azure Document Intelligence</li>
|
||||
* <li>{@code google} - Google Document AI</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see HybridClient
|
||||
* @see HybridConfig
|
||||
*/
|
||||
public class HybridClientFactory {
|
||||
|
||||
/** Backend type constant for Docling Fast Server. */
|
||||
public static final String BACKEND_DOCLING_FAST = "docling-fast";
|
||||
|
||||
/** Backend type constant for Hancom (Studio Lite API). */
|
||||
public static final String BACKEND_HANCOM = "hancom";
|
||||
|
||||
/** Backend type constant for Hancom AI (HOCR SDK individual modules). */
|
||||
public static final String BACKEND_HANCOM_AI = "hancom-ai";
|
||||
|
||||
/** Backend type constant for Azure (not yet implemented). */
|
||||
public static final String BACKEND_AZURE = "azure";
|
||||
|
||||
/** Backend type constant for Google (not yet implemented). */
|
||||
public static final String BACKEND_GOOGLE = "google";
|
||||
|
||||
/** Cache of created clients, keyed by backend type. */
|
||||
private static final Map<String, HybridClient> CLIENT_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
private HybridClientFactory() {
|
||||
// Private constructor to prevent instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a hybrid client for the specified backend.
|
||||
*
|
||||
* <p>Clients are cached and reused across multiple documents to avoid
|
||||
* creating new thread pools for each document. Call {@link #shutdown()}
|
||||
* when processing is complete to release resources.
|
||||
*
|
||||
* @param hybrid The backend type (e.g., "docling", "hancom", "azure", "google").
|
||||
* @param config The configuration for the hybrid client.
|
||||
* @return A HybridClient instance for the specified backend.
|
||||
* @throws IllegalArgumentException If the backend type is unknown or not supported.
|
||||
*/
|
||||
public static HybridClient getOrCreate(String hybrid, HybridConfig config) {
|
||||
if (hybrid == null || hybrid.isEmpty()) {
|
||||
throw new IllegalArgumentException("Hybrid backend type cannot be null or empty");
|
||||
}
|
||||
|
||||
String lowerHybrid = hybrid.toLowerCase();
|
||||
|
||||
return CLIENT_CACHE.computeIfAbsent(lowerHybrid, key -> createClient(key, config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new hybrid client instance.
|
||||
*/
|
||||
private static HybridClient createClient(String hybrid, HybridConfig config) {
|
||||
if (BACKEND_DOCLING_FAST.equals(hybrid)) {
|
||||
return new DoclingFastServerClient(config);
|
||||
} else if (BACKEND_HANCOM.equals(hybrid)) {
|
||||
return new HancomClient(config);
|
||||
} else if (BACKEND_HANCOM_AI.equals(hybrid)) {
|
||||
return new HancomAIClient(config);
|
||||
} else if (BACKEND_AZURE.equals(hybrid)) {
|
||||
throw new UnsupportedOperationException("Azure Document Intelligence backend is not yet implemented");
|
||||
} else if (BACKEND_GOOGLE.equals(hybrid)) {
|
||||
throw new UnsupportedOperationException("Google Document AI backend is not yet implemented");
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown hybrid backend: " + hybrid +
|
||||
". Supported backends: " + getSupportedBackends());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a hybrid client for the specified backend.
|
||||
*
|
||||
* @param hybrid The backend type (e.g., "docling", "hancom", "azure", "google").
|
||||
* @param config The configuration for the hybrid client.
|
||||
* @return A new HybridClient instance for the specified backend.
|
||||
* @throws IllegalArgumentException If the backend type is unknown or not supported.
|
||||
* @deprecated Use {@link #getOrCreate(String, HybridConfig)} instead to reuse clients.
|
||||
*/
|
||||
@Deprecated
|
||||
public static HybridClient create(String hybrid, HybridConfig config) {
|
||||
return getOrCreate(hybrid, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a hybrid client for the specified backend with default configuration.
|
||||
*
|
||||
* @param hybrid The backend type (e.g., "docling").
|
||||
* @return A new HybridClient instance for the specified backend.
|
||||
* @throws IllegalArgumentException If the backend type is unknown or not supported.
|
||||
* @deprecated Use {@link #getOrCreate(String, HybridConfig)} instead to reuse clients.
|
||||
*/
|
||||
@Deprecated
|
||||
public static HybridClient create(String hybrid) {
|
||||
return getOrCreate(hybrid, new HybridConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts down all cached clients and releases resources.
|
||||
*
|
||||
* <p>This method should be called when all processing is complete,
|
||||
* typically at the end of the CLI main method.
|
||||
*/
|
||||
public static void shutdown() {
|
||||
for (HybridClient client : CLIENT_CACHE.values()) {
|
||||
if (client instanceof DoclingFastServerClient) {
|
||||
((DoclingFastServerClient) client).shutdown();
|
||||
} else if (client instanceof HancomClient) {
|
||||
((HancomClient) client).shutdown();
|
||||
} else if (client instanceof HancomAIClient) {
|
||||
((HancomAIClient) client).shutdown();
|
||||
}
|
||||
}
|
||||
CLIENT_CACHE.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a backend type is supported and implemented.
|
||||
*
|
||||
* @param hybrid The backend type to check.
|
||||
* @return true if the backend is supported and implemented, false otherwise.
|
||||
*/
|
||||
public static boolean isSupported(String hybrid) {
|
||||
if (hybrid == null || hybrid.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String lowerHybrid = hybrid.toLowerCase();
|
||||
return BACKEND_DOCLING_FAST.equals(lowerHybrid) || BACKEND_HANCOM.equals(lowerHybrid)
|
||||
|| BACKEND_HANCOM_AI.equals(lowerHybrid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a comma-separated list of supported backend types.
|
||||
*
|
||||
* @return A string listing all supported backends.
|
||||
*/
|
||||
public static String getSupportedBackends() {
|
||||
return String.join(", ", BACKEND_DOCLING_FAST, BACKEND_HANCOM, BACKEND_HANCOM_AI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a comma-separated list of all known backend types (including not yet implemented).
|
||||
*
|
||||
* @return A string listing all known backends.
|
||||
*/
|
||||
public static String getAllKnownBackends() {
|
||||
return String.join(", ", BACKEND_DOCLING_FAST, BACKEND_HANCOM, BACKEND_AZURE, BACKEND_GOOGLE);
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
/**
|
||||
* Configuration class for hybrid PDF processing with external AI backends.
|
||||
*
|
||||
* <p>Hybrid processing routes pages to either Java-based processing or external
|
||||
* AI backends (like docling, hancom, azure, google) based on page triage decisions.
|
||||
*/
|
||||
public class HybridConfig {
|
||||
|
||||
/** Default timeout for backend requests in milliseconds. */
|
||||
public static final int DEFAULT_TIMEOUT_MS = 0;
|
||||
|
||||
/** Default maximum concurrent requests to the backend. */
|
||||
public static final int DEFAULT_MAX_CONCURRENT_REQUESTS = 4;
|
||||
|
||||
/** Default URL for docling-serve. */
|
||||
public static final String DOCLING_DEFAULT_URL = "http://localhost:5001";
|
||||
|
||||
/** Default URL for docling-fast-server. */
|
||||
public static final String DOCLING_FAST_DEFAULT_URL = "http://localhost:5002";
|
||||
|
||||
/** Default URL for Hancom Document AI API. */
|
||||
public static final String HANCOM_DEFAULT_URL = "https://dataloader.cloud.hancom.com/studio-lite/api";
|
||||
|
||||
/** Default URL for Hancom AI HOCR SDK API. */
|
||||
public static final String HANCOM_AI_DEFAULT_URL = "http://localhost:18008/api/v1";
|
||||
|
||||
private String url;
|
||||
private int timeoutMs = DEFAULT_TIMEOUT_MS;
|
||||
private boolean fallbackToJava = false;
|
||||
private int maxConcurrentRequests = DEFAULT_MAX_CONCURRENT_REQUESTS;
|
||||
/** Hybrid triage mode: auto (dynamic triage based on page content). */
|
||||
public static final String MODE_AUTO = "auto";
|
||||
/** Hybrid triage mode: full (skip triage, send all pages to backend). */
|
||||
public static final String MODE_FULL = "full";
|
||||
|
||||
private String mode = MODE_AUTO;
|
||||
|
||||
/** Regionlist strategy: table-first (default) — check TSR overlap, skip if TSR exists. */
|
||||
public static final String REGIONLIST_TABLE_FIRST = "table-first";
|
||||
/** Regionlist strategy: list-only — always treat label 7 as list, skip TSR check. */
|
||||
public static final String REGIONLIST_LIST_ONLY = "list-only";
|
||||
|
||||
private String regionlistStrategy = REGIONLIST_TABLE_FIRST;
|
||||
|
||||
/** OCR strategy: off (stream only, no OCR fallback). */
|
||||
public static final String OCR_OFF = "off";
|
||||
/** OCR strategy: auto (stream first, OCR fallback when enrichment fails). */
|
||||
public static final String OCR_AUTO = "auto";
|
||||
/** OCR strategy: force (OCR only, skip stream-based enrichment). */
|
||||
public static final String OCR_FORCE = "force";
|
||||
|
||||
private String ocrStrategy = OCR_AUTO;
|
||||
|
||||
/** Page image cache strategy: "memory" (default) or "disk". */
|
||||
private String imageCache = "memory";
|
||||
|
||||
/** Whether to save cropped figure images to disk for debugging. */
|
||||
private boolean saveCrops = false;
|
||||
|
||||
/** Output directory for saved crops (set by CLI when --save-crops is used). */
|
||||
private String cropOutputDir = null;
|
||||
|
||||
/**
|
||||
* Default constructor initializing the configuration with default values.
|
||||
*/
|
||||
public HybridConfig() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the backend server URL.
|
||||
*
|
||||
* @return The backend URL, or null if using default for the backend type.
|
||||
*/
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the backend server URL.
|
||||
*
|
||||
* @param url The backend URL to use.
|
||||
*/
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the request timeout in milliseconds.
|
||||
*
|
||||
* @return The timeout in milliseconds.
|
||||
*/
|
||||
public int getTimeoutMs() {
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the request timeout in milliseconds. Use 0 for no timeout.
|
||||
*
|
||||
* @param timeoutMs The timeout in milliseconds (0 = no timeout).
|
||||
* @throws IllegalArgumentException if timeout is negative.
|
||||
*/
|
||||
public void setTimeoutMs(int timeoutMs) {
|
||||
if (timeoutMs < 0) {
|
||||
throw new IllegalArgumentException("Timeout must be non-negative: " + timeoutMs);
|
||||
}
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if fallback to Java processing is enabled when backend fails.
|
||||
*
|
||||
* @return true if fallback is enabled, false otherwise.
|
||||
*/
|
||||
public boolean isFallbackToJava() {
|
||||
return fallbackToJava;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether to fallback to Java processing when backend fails.
|
||||
*
|
||||
* @param fallbackToJava true to enable fallback, false to fail on backend error.
|
||||
*/
|
||||
public void setFallbackToJava(boolean fallbackToJava) {
|
||||
this.fallbackToJava = fallbackToJava;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the maximum number of concurrent requests to the backend.
|
||||
*
|
||||
* @return The maximum concurrent requests.
|
||||
*/
|
||||
public int getMaxConcurrentRequests() {
|
||||
return maxConcurrentRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum number of concurrent requests to the backend.
|
||||
*
|
||||
* @param maxConcurrentRequests The maximum concurrent requests.
|
||||
* @throws IllegalArgumentException if the value is not positive.
|
||||
*/
|
||||
public void setMaxConcurrentRequests(int maxConcurrentRequests) {
|
||||
if (maxConcurrentRequests <= 0) {
|
||||
throw new IllegalArgumentException("Max concurrent requests must be positive: " + maxConcurrentRequests);
|
||||
}
|
||||
this.maxConcurrentRequests = maxConcurrentRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default URL for a given hybrid backend.
|
||||
*
|
||||
* @param hybrid The hybrid backend name (docling, docling-fast, hancom, azure, google).
|
||||
* @return The default URL, or null if the backend requires explicit URL.
|
||||
*/
|
||||
public static String getDefaultUrl(String hybrid) {
|
||||
if (hybrid == null) {
|
||||
return null;
|
||||
}
|
||||
String lowerHybrid = hybrid.toLowerCase();
|
||||
// Both "docling" and "docling-fast" (deprecated) use the same server
|
||||
if ("docling".equals(lowerHybrid) || "docling-fast".equals(lowerHybrid)) {
|
||||
return DOCLING_FAST_DEFAULT_URL;
|
||||
}
|
||||
if ("hancom".equals(lowerHybrid)) {
|
||||
return HANCOM_DEFAULT_URL;
|
||||
}
|
||||
if ("hancom-ai".equals(lowerHybrid)) {
|
||||
return HANCOM_AI_DEFAULT_URL;
|
||||
}
|
||||
// azure, google require explicit URL
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the effective URL for a given hybrid backend.
|
||||
* Returns the configured URL if set, otherwise returns the default URL for the backend.
|
||||
*
|
||||
* @param hybrid The hybrid backend name.
|
||||
* @return The effective URL to use for the backend.
|
||||
*/
|
||||
public String getEffectiveUrl(String hybrid) {
|
||||
if (url != null && !url.isEmpty()) {
|
||||
return url;
|
||||
}
|
||||
return getDefaultUrl(hybrid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the hybrid triage mode.
|
||||
*
|
||||
* @return The mode (auto or full).
|
||||
*/
|
||||
public String getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the hybrid triage mode.
|
||||
*
|
||||
* @param mode The mode (auto or full).
|
||||
*/
|
||||
public void setMode(String mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if full mode is enabled (skip triage, send all pages to backend).
|
||||
*
|
||||
* @return true if mode is full, false otherwise.
|
||||
*/
|
||||
public boolean isFullMode() {
|
||||
return MODE_FULL.equals(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the regionlist strategy for label 7 (Table region) handling.
|
||||
*
|
||||
* @return The regionlist strategy (table-first or list-only).
|
||||
*/
|
||||
public String getRegionlistStrategy() {
|
||||
return regionlistStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the regionlist strategy for label 7 (Table region) handling.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code "table-first"} (default): check TSR overlap, skip if TSR exists, else treat as list</li>
|
||||
* <li>{@code "list-only"}: always treat as list, skip TSR check entirely</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param regionlistStrategy The regionlist strategy to use.
|
||||
*/
|
||||
public void setRegionlistStrategy(String regionlistStrategy) {
|
||||
if (regionlistStrategy != null
|
||||
&& !REGIONLIST_TABLE_FIRST.equals(regionlistStrategy)
|
||||
&& !REGIONLIST_LIST_ONLY.equals(regionlistStrategy)) {
|
||||
throw new IllegalArgumentException("Invalid regionlistStrategy: "
|
||||
+ regionlistStrategy + " (expected " + REGIONLIST_TABLE_FIRST
|
||||
+ " or " + REGIONLIST_LIST_ONLY + ")");
|
||||
}
|
||||
this.regionlistStrategy = regionlistStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if regionlist strategy is list-only (always treat label 7 as list).
|
||||
*
|
||||
* @return true if strategy is list-only, false otherwise.
|
||||
*/
|
||||
public boolean isRegionlistListOnly() {
|
||||
return REGIONLIST_LIST_ONLY.equals(regionlistStrategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the page image cache strategy.
|
||||
*
|
||||
* @return "memory" or "disk".
|
||||
*/
|
||||
public String getImageCache() {
|
||||
return imageCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the page image cache strategy.
|
||||
*
|
||||
* @param imageCache "memory" (in-heap HashMap) or "disk" (temp PNG files).
|
||||
*/
|
||||
public void setImageCache(String imageCache) {
|
||||
if (imageCache != null
|
||||
&& !"memory".equals(imageCache) && !"disk".equals(imageCache)) {
|
||||
throw new IllegalArgumentException("Invalid imageCache: "
|
||||
+ imageCache + " (expected \"memory\" or \"disk\")");
|
||||
}
|
||||
this.imageCache = imageCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if cropped figure images should be saved to disk.
|
||||
*
|
||||
* @return true if save-crops is enabled.
|
||||
*/
|
||||
public boolean isSaveCrops() {
|
||||
return saveCrops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether to save cropped figure images to disk.
|
||||
*
|
||||
* @param saveCrops true to save crops.
|
||||
*/
|
||||
public void setSaveCrops(boolean saveCrops) {
|
||||
this.saveCrops = saveCrops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the output directory for saved crops.
|
||||
*
|
||||
* @return the crop output directory path, or null if not set.
|
||||
*/
|
||||
public String getCropOutputDir() {
|
||||
return cropOutputDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the output directory for saved crops.
|
||||
*
|
||||
* @param cropOutputDir the directory path.
|
||||
*/
|
||||
public void setCropOutputDir(String cropOutputDir) {
|
||||
this.cropOutputDir = cropOutputDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the OCR strategy for enrichment fallback.
|
||||
*
|
||||
* @return The OCR strategy (off, auto, or force).
|
||||
*/
|
||||
public String getOcrStrategy() {
|
||||
return ocrStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the OCR strategy for enrichment fallback.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code "off"}: stream-based enrichment only, no OCR fallback</li>
|
||||
* <li>{@code "auto"} (default): try stream enrichment first, fall back to OCR words when no match</li>
|
||||
* <li>{@code "force"}: skip stream enrichment, always use OCR words</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param ocrStrategy The OCR strategy to use.
|
||||
*/
|
||||
public void setOcrStrategy(String ocrStrategy) {
|
||||
if (ocrStrategy != null
|
||||
&& !OCR_OFF.equals(ocrStrategy)
|
||||
&& !OCR_AUTO.equals(ocrStrategy)
|
||||
&& !OCR_FORCE.equals(ocrStrategy)) {
|
||||
throw new IllegalArgumentException("Invalid ocrStrategy: "
|
||||
+ ocrStrategy + " (expected " + OCR_OFF + ", " + OCR_AUTO
|
||||
+ ", or " + OCR_FORCE + ")");
|
||||
}
|
||||
this.ocrStrategy = ocrStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if OCR strategy is auto (stream first, OCR fallback).
|
||||
*
|
||||
* @return true if strategy is auto, false otherwise.
|
||||
*/
|
||||
public boolean isOcrAuto() {
|
||||
return OCR_AUTO.equals(ocrStrategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if OCR strategy is force (OCR only).
|
||||
*
|
||||
* @return true if strategy is force, false otherwise.
|
||||
*/
|
||||
public boolean isOcrForce() {
|
||||
return OCR_FORCE.equals(ocrStrategy);
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.opendataloader.pdf.hybrid.HybridClient.HybridResponse;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
|
||||
/**
|
||||
* Interface for transforming hybrid backend responses to IObject hierarchy.
|
||||
*
|
||||
* <p>Implementations of this interface convert backend-specific JSON output
|
||||
* (e.g., Docling's DoclingDocument format) to the OpenDataLoader IObject
|
||||
* structure that downstream processors and generators expect.
|
||||
*
|
||||
* <p>The transformer ensures schema compatibility between different backends
|
||||
* and the Java processing path, allowing seamless integration of results.
|
||||
*/
|
||||
public interface HybridSchemaTransformer {
|
||||
|
||||
/**
|
||||
* Transforms a hybrid backend response to a list of IObjects per page.
|
||||
*
|
||||
* <p>The returned structure matches the format expected by downstream
|
||||
* processors: a list indexed by page number (0-based), where each entry
|
||||
* contains the IObjects for that page.
|
||||
*
|
||||
* @param response The hybrid backend response containing JSON output.
|
||||
* @param pageHeights Map of page number (1-indexed) to page height in PDF points.
|
||||
* Used for coordinate transformation if needed.
|
||||
* @return A list of IObject lists, one per page (0-indexed).
|
||||
*/
|
||||
List<List<IObject>> transform(HybridResponse response, Map<Integer, Double> pageHeights);
|
||||
|
||||
/**
|
||||
* Transforms per-page JSON content to IObjects for a specific page.
|
||||
*
|
||||
* <p>This method is useful when processing pages individually or when
|
||||
* the backend provides separate responses per page.
|
||||
*
|
||||
* @param pageNumber The 1-indexed page number.
|
||||
* @param pageContent The JSON content for the page.
|
||||
* @param pageHeight The page height in PDF points.
|
||||
* @return A list of IObjects for the specified page.
|
||||
*/
|
||||
List<IObject> transformPage(int pageNumber, JsonNode pageContent, double pageHeight);
|
||||
|
||||
/**
|
||||
* Returns the backend type this transformer handles.
|
||||
*
|
||||
* @return The backend name (e.g., "docling", "hancom").
|
||||
*/
|
||||
String getBackendType();
|
||||
|
||||
/**
|
||||
* Returns per-element metadata produced during the last {@link #transform} call.
|
||||
* Keys are {@code IObject.recognizedStructureId} values.
|
||||
*
|
||||
* @return unmodifiable map of element metadata, empty by default
|
||||
*/
|
||||
default Map<Long, ElementMetadata> getElementMetadata() {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-key the in-memory {@link ElementMetadata} map after the host
|
||||
* pipeline renumbered structure IDs. {@code oldToNew} maps the
|
||||
* transformer-assigned ID to the post-{@code setIDs} ID for IObjects
|
||||
* the caller just renumbered; entries for unmapped IDs are left alone.
|
||||
*
|
||||
* <p>Backends that don't expose mutable metadata should leave the
|
||||
* default no-op; their downstream metadata lookups will continue to
|
||||
* miss.
|
||||
*/
|
||||
default void rekeyMetadata(Map<Long, Long> oldToNew) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns per-page OCR word data collected during the last {@link #transform} call.
|
||||
* Keys are 0-indexed page numbers. Each list contains word-level OCR data with
|
||||
* text and bounding box in PDF coordinate space.
|
||||
*
|
||||
* <p>Only populated by backends that perform OCR (e.g., hancom-ai).
|
||||
* Used by OCR enrichment fallback when Java TextChunks are not available.
|
||||
*
|
||||
* @return unmodifiable map of page number to OCR words, empty by default
|
||||
*/
|
||||
default Map<Integer, List<OcrWordInfo>> getOcrWordsByPage() {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* In-memory page image cache. Stores images in a HashMap; evict() removes
|
||||
* entries so GC can reclaim memory (~25MB per page image).
|
||||
*/
|
||||
public class MemoryPageImageCache implements PageImageCache {
|
||||
|
||||
private final Map<Integer, BufferedImage> cache = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public BufferedImage getOrFetch(int pageIndex, PageImageFetcher fetcher) throws IOException {
|
||||
BufferedImage image = cache.get(pageIndex);
|
||||
if (image == null) {
|
||||
image = fetcher.fetch(pageIndex);
|
||||
if (image == null) {
|
||||
throw new IOException("Page image fetcher returned null for page " + pageIndex);
|
||||
}
|
||||
cache.put(pageIndex, image);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evict(int pageIndex) {
|
||||
cache.remove(pageIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
|
||||
/**
|
||||
* Holds OCR-recognized text and its bounding box in PDF coordinate space.
|
||||
*
|
||||
* <p>Used to preserve word-level OCR data from the DLA+OCR pipeline so that
|
||||
* the enrichment step can create invisible text operators when Java TextChunks
|
||||
* are not available (e.g., scanned/image-based pages).
|
||||
*/
|
||||
public class OcrWordInfo {
|
||||
|
||||
private final String text;
|
||||
private final BoundingBox bbox;
|
||||
|
||||
public OcrWordInfo(String text, BoundingBox bbox) {
|
||||
this.text = text;
|
||||
this.bbox = bbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the OCR-recognized text for this word.
|
||||
*
|
||||
* @return the recognized text
|
||||
*/
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the bounding box in PDF coordinate space (72 DPI, bottom-left origin).
|
||||
*
|
||||
* @return the bounding box
|
||||
*/
|
||||
public BoundingBox getBbox() {
|
||||
return bbox;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Cache for page images (pdf2img results). Implementations control where
|
||||
* images are stored and when they are evicted.
|
||||
*/
|
||||
public interface PageImageCache extends AutoCloseable {
|
||||
|
||||
/**
|
||||
* Get a cached page image or fetch it using the supplier.
|
||||
*
|
||||
* @param pageIndex 0-indexed page number
|
||||
* @param fetcher called if image not in cache; may throw IOException
|
||||
* @return the page image, never null
|
||||
* @throws IOException if the fetcher fails
|
||||
*/
|
||||
BufferedImage getOrFetch(int pageIndex, PageImageFetcher fetcher) throws IOException;
|
||||
|
||||
/**
|
||||
* Hint that this page is no longer needed.
|
||||
* Memory impl evicts immediately; disk impl keeps for potential re-read.
|
||||
*
|
||||
* @param pageIndex 0-indexed page number
|
||||
*/
|
||||
void evict(int pageIndex);
|
||||
|
||||
/**
|
||||
* Clean up all resources. Disk impl deletes temp files.
|
||||
*/
|
||||
@Override
|
||||
void close() throws IOException;
|
||||
|
||||
/**
|
||||
* Functional interface for fetching a page image on cache miss.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
interface PageImageFetcher {
|
||||
BufferedImage fetch(int pageIndex) throws IOException;
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
/**
|
||||
* Computes text similarity for stream vs OCR comparison.
|
||||
* Used by enrichment to decide whether stream text is corrupted.
|
||||
*/
|
||||
public final class TextSimilarity {
|
||||
|
||||
/** Default similarity threshold. Below this, stream text is considered corrupted. */
|
||||
public static final double DEFAULT_THRESHOLD = 0.5;
|
||||
|
||||
private TextSimilarity() {}
|
||||
|
||||
/**
|
||||
* Computes normalized similarity between two strings (0.0 = completely different, 1.0 = identical).
|
||||
* Uses Levenshtein distance normalized by the longer string's length.
|
||||
*/
|
||||
public static double similarity(String a, String b) {
|
||||
if (a == null || b == null) return 0.0;
|
||||
if (a.equals(b)) return 1.0;
|
||||
if (a.isEmpty() || b.isEmpty()) return 0.0;
|
||||
|
||||
int distance = levenshteinDistance(a, b);
|
||||
int maxLen = Math.max(a.length(), b.length());
|
||||
return 1.0 - ((double) distance / maxLen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if stream text should be trusted over OCR text.
|
||||
*/
|
||||
public static boolean trustStream(String streamText, String ocrText, double threshold) {
|
||||
if (streamText == null || streamText.isEmpty()) return false;
|
||||
if (ocrText == null || ocrText.isEmpty()) return true;
|
||||
return similarity(streamText, ocrText) >= threshold;
|
||||
}
|
||||
|
||||
private static int levenshteinDistance(String a, String b) {
|
||||
int[] prev = new int[b.length() + 1];
|
||||
int[] curr = new int[b.length() + 1];
|
||||
for (int j = 0; j <= b.length(); j++) prev[j] = j;
|
||||
for (int i = 1; i <= a.length(); i++) {
|
||||
curr[0] = i;
|
||||
for (int j = 1; j <= b.length(); j++) {
|
||||
int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
|
||||
curr[j] = Math.min(Math.min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
|
||||
}
|
||||
int[] temp = prev; prev = curr; curr = temp;
|
||||
}
|
||||
return prev[b.length()];
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.hybrid;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.opendataloader.pdf.hybrid.TriageProcessor.TriageDecision;
|
||||
import org.opendataloader.pdf.hybrid.TriageProcessor.TriageResult;
|
||||
import org.opendataloader.pdf.hybrid.TriageProcessor.TriageSignals;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Writer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Logger for triage decisions to JSON format for benchmark evaluation.
|
||||
*
|
||||
* <p>Output format:
|
||||
* <pre>
|
||||
* {
|
||||
* "document": "example.pdf",
|
||||
* "hybrid": "docling",
|
||||
* "triage": [
|
||||
* {
|
||||
* "page": 1,
|
||||
* "decision": "JAVA",
|
||||
* "confidence": 0.95,
|
||||
* "signals": {
|
||||
* "lineChunkCount": 2,
|
||||
* "textChunkCount": 45,
|
||||
* "lineToTextRatio": 0.04,
|
||||
* "alignedLineGroups": 0,
|
||||
* "hasTableBorder": false,
|
||||
* "hasSuspiciousPattern": false
|
||||
* }
|
||||
* }
|
||||
* ],
|
||||
* "summary": {
|
||||
* "totalPages": 10,
|
||||
* "javaPages": 8,
|
||||
* "backendPages": 2
|
||||
* }
|
||||
* }
|
||||
* </pre>
|
||||
*/
|
||||
public class TriageLogger {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(TriageLogger.class.getCanonicalName());
|
||||
|
||||
/** Default filename for triage log output. */
|
||||
public static final String DEFAULT_FILENAME = "triage.json";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Creates a new TriageLogger with default settings.
|
||||
*/
|
||||
public TriageLogger() {
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs triage results to a JSON file.
|
||||
*
|
||||
* @param outputDir The output directory path.
|
||||
* @param documentName The name of the processed document.
|
||||
* @param hybridBackend The hybrid backend used (e.g., "docling").
|
||||
* @param triageResults Map of page number to triage result.
|
||||
* @throws IOException If writing the file fails.
|
||||
*/
|
||||
public void logToFile(
|
||||
Path outputDir,
|
||||
String documentName,
|
||||
String hybridBackend,
|
||||
Map<Integer, TriageResult> triageResults) throws IOException {
|
||||
|
||||
Path outputPath = outputDir.resolve(DEFAULT_FILENAME);
|
||||
Files.createDirectories(outputDir);
|
||||
|
||||
ObjectNode root = createTriageJson(documentName, hybridBackend, triageResults);
|
||||
|
||||
try (Writer writer = Files.newBufferedWriter(outputPath)) {
|
||||
objectMapper.writeValue(writer, root);
|
||||
}
|
||||
|
||||
LOGGER.log(Level.INFO, "Triage log written to {0}", outputPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes triage results to a Writer.
|
||||
*
|
||||
* @param writer The Writer to write to.
|
||||
* @param documentName The name of the processed document.
|
||||
* @param hybridBackend The hybrid backend used (e.g., "docling").
|
||||
* @param triageResults Map of page number to triage result.
|
||||
* @throws IOException If writing fails.
|
||||
*/
|
||||
public void logToWriter(
|
||||
Writer writer,
|
||||
String documentName,
|
||||
String hybridBackend,
|
||||
Map<Integer, TriageResult> triageResults) throws IOException {
|
||||
|
||||
ObjectNode root = createTriageJson(documentName, hybridBackend, triageResults);
|
||||
objectMapper.writeValue(writer, root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the triage JSON structure.
|
||||
*
|
||||
* @param documentName The name of the processed document.
|
||||
* @param hybridBackend The hybrid backend used.
|
||||
* @param triageResults Map of page number to triage result.
|
||||
* @return The root ObjectNode containing all triage data.
|
||||
*/
|
||||
public ObjectNode createTriageJson(
|
||||
String documentName,
|
||||
String hybridBackend,
|
||||
Map<Integer, TriageResult> triageResults) {
|
||||
|
||||
ObjectNode root = objectMapper.createObjectNode();
|
||||
root.put("document", documentName);
|
||||
root.put("hybrid", hybridBackend);
|
||||
|
||||
// Create triage array
|
||||
ArrayNode triageArray = objectMapper.createArrayNode();
|
||||
int javaCount = 0;
|
||||
int backendCount = 0;
|
||||
|
||||
List<Map.Entry<Integer, TriageResult>> sortedEntries = triageResults.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (Map.Entry<Integer, TriageResult> entry : sortedEntries) {
|
||||
int pageNumber = entry.getKey();
|
||||
TriageResult result = entry.getValue();
|
||||
|
||||
ObjectNode pageNode = objectMapper.createObjectNode();
|
||||
pageNode.put("page", pageNumber + 1); // Convert to 1-indexed for output
|
||||
pageNode.put("decision", result.getDecision().name());
|
||||
pageNode.put("confidence", result.getConfidence());
|
||||
|
||||
// Add signals
|
||||
ObjectNode signalsNode = createSignalsNode(result.getSignals());
|
||||
pageNode.set("signals", signalsNode);
|
||||
|
||||
triageArray.add(pageNode);
|
||||
|
||||
// Count decisions
|
||||
if (result.getDecision() == TriageDecision.JAVA) {
|
||||
javaCount++;
|
||||
} else {
|
||||
backendCount++;
|
||||
}
|
||||
}
|
||||
|
||||
root.set("triage", triageArray);
|
||||
|
||||
// Create summary
|
||||
ObjectNode summaryNode = objectMapper.createObjectNode();
|
||||
summaryNode.put("totalPages", triageResults.size());
|
||||
summaryNode.put("javaPages", javaCount);
|
||||
summaryNode.put("backendPages", backendCount);
|
||||
root.set("summary", summaryNode);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON node for triage signals.
|
||||
*
|
||||
* @param signals The triage signals.
|
||||
* @return The ObjectNode containing signal data.
|
||||
*/
|
||||
private ObjectNode createSignalsNode(TriageSignals signals) {
|
||||
ObjectNode signalsNode = objectMapper.createObjectNode();
|
||||
signalsNode.put("lineChunkCount", signals.getLineChunkCount());
|
||||
signalsNode.put("textChunkCount", signals.getTextChunkCount());
|
||||
signalsNode.put("lineToTextRatio", signals.getLineToTextRatio());
|
||||
signalsNode.put("alignedLineGroups", signals.getAlignedLineGroups());
|
||||
signalsNode.put("hasTableBorder", signals.hasTableBorder());
|
||||
signalsNode.put("hasSuspiciousPattern", signals.hasSuspiciousPattern());
|
||||
return signalsNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts triage results to JSON string.
|
||||
*
|
||||
* @param documentName The name of the processed document.
|
||||
* @param hybridBackend The hybrid backend used.
|
||||
* @param triageResults Map of page number to triage result.
|
||||
* @return JSON string representation.
|
||||
* @throws IOException If serialization fails.
|
||||
*/
|
||||
public String toJsonString(
|
||||
String documentName,
|
||||
String hybridBackend,
|
||||
Map<Integer, TriageResult> triageResults) throws IOException {
|
||||
|
||||
ObjectNode root = createTriageJson(documentName, hybridBackend, triageResults);
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
}
|
||||
+1132
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json;
|
||||
|
||||
public class JsonName {
|
||||
public static final String PAGE_NUMBER = "page number";
|
||||
public static final String LEVEL = "level";
|
||||
public static final String BOUNDING_BOX = "bounding box";
|
||||
public static final String TYPE = "type";
|
||||
public static final String ID = "id";
|
||||
public static final String IMAGE_CHUNK_TYPE = "image";
|
||||
public static final String LIST_ITEM_TYPE = "list item";
|
||||
public static final String LINE_CHUNK_TYPE = "line";
|
||||
public static final String CONTENT = "content";
|
||||
public static final String HIDDEN_TEXT = "hidden text";
|
||||
public static final String TEXT_CHUNK_TYPE = "text chunk";
|
||||
public static final String TABLE_TYPE = "table";
|
||||
public static final String TEXT_BLOCK = "text block";
|
||||
public static final String LIST_TYPE = "list";
|
||||
public static final String TOC_TYPE = "toc";
|
||||
public static final String TOC_ITEM_TYPE = "toc item";
|
||||
public static final String TABLE_CELL_TYPE = "table cell";
|
||||
public static final String ROW_TYPE = "table row";
|
||||
public static final String FONT_TYPE = "font";
|
||||
public static final String FONT_SIZE = "font size";
|
||||
public static final String TEXT_COLOR = "text color";
|
||||
public static final String PARAGRAPH_TYPE = "paragraph";
|
||||
public static final String HEADING_TYPE = "heading";
|
||||
public static final String CAPTION_TYPE = "caption";
|
||||
public static final String KIDS = "kids";
|
||||
public static final String LIST_ITEMS = "list items";
|
||||
public static final String TOC_ITEMS = "toc items";
|
||||
public static final String NUMBER_OF_LIST_ITEMS = "number of list items";
|
||||
public static final String PREVIOUS_LIST_ID = "previous list id";
|
||||
public static final String NEXT_LIST_ID = "next list id";
|
||||
public static final String PREVIOUS_TOC_ID = "previous toc id";
|
||||
public static final String NEXT_TOC_ID = "next toc id";
|
||||
public static final String PREVIOUS_TABLE_ID = "previous table id";
|
||||
public static final String NEXT_TABLE_ID = "next table id";
|
||||
public static final String AUTHOR = "author";
|
||||
public static final String TITLE = "title";
|
||||
public static final String COLUMN_NUMBER = "column number";
|
||||
public static final String ROW_NUMBER = "row number";
|
||||
public static final String COLUMN_SPAN = "column span";
|
||||
public static final String ROW_SPAN = "row span";
|
||||
public static final String IS_HEADER = "is_header";
|
||||
public static final String NUMBER_OF_ROWS = "number of rows";
|
||||
public static final String NUMBER_OF_COLUMNS = "number of columns";
|
||||
public static final String NUMBER_OF_PAGES = "number of pages";
|
||||
public static final String FILE_NAME = "file name";
|
||||
public static final String HEADING_LEVEL = "heading level";
|
||||
public static final String CREATION_DATE = "creation date";
|
||||
public static final String MODIFICATION_DATE = "modification date";
|
||||
public static final String ROWS = "rows";
|
||||
public static final String CELLS = "cells";
|
||||
public static final String NUMBERING_STYLE = "numbering style";
|
||||
public static final String SOURCE = "source";
|
||||
public static final String DATA = "data";
|
||||
public static final String IMAGE_FORMAT = "format";
|
||||
public static final String FORMULA_TYPE = "formula";
|
||||
public static final String DESCRIPTION = "description";
|
||||
public static final String ALT = "alt";
|
||||
public static final String ALT_SOURCE = "alt_source";
|
||||
public static final String AI_SCORE = "ai_score";
|
||||
public static final String PDFUA_TAG = "pdfua_tag";
|
||||
public static final String SOURCE_LABEL = "source label";
|
||||
public static final String HYBRID = "hybrid";
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonEncoding;
|
||||
import com.fasterxml.jackson.core.JsonFactory;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.verapdf.as.ASAtom;
|
||||
import org.verapdf.cos.COSDictionary;
|
||||
import org.verapdf.cos.COSObjType;
|
||||
import org.verapdf.cos.COSObject;
|
||||
import org.verapdf.cos.COSTrailer;
|
||||
import org.verapdf.gf.model.impl.cos.GFCosInfo;
|
||||
import org.verapdf.pd.PDDocument;
|
||||
import org.verapdf.tools.StaticResources;
|
||||
import org.opendataloader.pdf.hybrid.ElementMetadata;
|
||||
import org.opendataloader.pdf.json.serializers.SerializerUtil;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticHeaderOrFooter;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class JsonWriter {
|
||||
private static final Logger LOGGER = Logger.getLogger(JsonWriter.class.getCanonicalName());
|
||||
private static JsonGenerator getJsonGenerator(String fileName) throws IOException {
|
||||
JsonFactory jsonFactory = new JsonFactory();
|
||||
return jsonFactory.createGenerator(new File(fileName), JsonEncoding.UTF8)
|
||||
.setPrettyPrinter(new DefaultPrettyPrinter())
|
||||
.setCodec(ObjectMapperHolder.getObjectMapper());
|
||||
}
|
||||
|
||||
public static void writeToJson(File inputPDF, String outputFolder, List<List<IObject>> contents) throws IOException {
|
||||
writeToJson(inputPDF, outputFolder, contents, Collections.emptyMap(), null);
|
||||
}
|
||||
|
||||
public static void writeToJson(File inputPDF, String outputFolder, List<List<IObject>> contents,
|
||||
Map<Long, ElementMetadata> elementMetadata) throws IOException {
|
||||
writeToJson(inputPDF, outputFolder, contents, elementMetadata, null);
|
||||
}
|
||||
|
||||
public static void writeToJson(File inputPDF, String outputFolder, List<List<IObject>> contents,
|
||||
Map<Long, ElementMetadata> elementMetadata,
|
||||
Map<String, Object> hybridInfo) throws IOException {
|
||||
writeToJson(inputPDF, outputFolder, contents, elementMetadata, hybridInfo, false);
|
||||
}
|
||||
|
||||
public static void writeToJson(File inputPDF, String outputFolder, List<List<IObject>> contents,
|
||||
Map<Long, ElementMetadata> elementMetadata,
|
||||
Map<String, Object> hybridInfo,
|
||||
boolean includeHeaderFooter) throws IOException {
|
||||
StaticLayoutContainers.resetImageIndex();
|
||||
String jsonFileName = outputFolder + File.separator + inputPDF.getName().substring(0, inputPDF.getName().length() - 3) + "json";
|
||||
try (JsonGenerator jsonGenerator = getJsonGenerator(jsonFileName)) {
|
||||
jsonGenerator.writeStartObject();
|
||||
writeDocumentInfo(jsonGenerator, inputPDF.getName());
|
||||
|
||||
if (hybridInfo != null && !hybridInfo.isEmpty()) {
|
||||
writeHybridBlock(jsonGenerator, hybridInfo);
|
||||
}
|
||||
|
||||
SerializerUtil.setElementMetadata(elementMetadata);
|
||||
try {
|
||||
jsonGenerator.writeArrayFieldStart(JsonName.KIDS);
|
||||
for (int pageNumber = 0; pageNumber < StaticContainers.getDocument().getNumberOfPages(); pageNumber++) {
|
||||
for (IObject content : contents.get(pageNumber)) {
|
||||
if (content instanceof LineArtChunk) {
|
||||
continue;
|
||||
}
|
||||
if (!includeHeaderFooter && content instanceof SemanticHeaderOrFooter) {
|
||||
continue;
|
||||
}
|
||||
jsonGenerator.writePOJO(content);
|
||||
}
|
||||
}
|
||||
jsonGenerator.writeEndArray();
|
||||
} finally {
|
||||
SerializerUtil.clearElementMetadata();
|
||||
}
|
||||
|
||||
jsonGenerator.writeEndObject();
|
||||
LOGGER.log(Level.INFO, "Created {0}", jsonFileName);
|
||||
} catch (Exception ex) {
|
||||
LOGGER.log(Level.WARNING, "Unable to create JSON output: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeHybridBlock(JsonGenerator generator, Map<String, Object> hybridInfo) throws IOException {
|
||||
generator.writeObjectFieldStart(JsonName.HYBRID);
|
||||
for (Map.Entry<String, Object> entry : hybridInfo.entrySet()) {
|
||||
generator.writePOJOField(entry.getKey(), entry.getValue());
|
||||
}
|
||||
generator.writeEndObject();
|
||||
}
|
||||
|
||||
private static void writeDocumentInfo(JsonGenerator generator, String pdfName) throws IOException {
|
||||
PDDocument document = StaticResources.getDocument();
|
||||
generator.writeStringField(JsonName.FILE_NAME, pdfName);
|
||||
generator.writeNumberField(JsonName.NUMBER_OF_PAGES, document.getNumberOfPages());
|
||||
COSTrailer trailer = document.getDocument().getTrailer();
|
||||
COSObject object = trailer.getKey(ASAtom.INFO);
|
||||
GFCosInfo info = new GFCosInfo((COSDictionary)
|
||||
(object != null && object.getType() == COSObjType.COS_DICT ?
|
||||
object.getDirectBase() : COSDictionary.construct().get()));
|
||||
generator.writeStringField(JsonName.AUTHOR, info.getAuthor() != null ? info.getAuthor() : info.getXMPCreator());
|
||||
generator.writeStringField(JsonName.TITLE, info.getTitle() != null ? info.getTitle() : info.getXMPTitle());
|
||||
generator.writeStringField(JsonName.CREATION_DATE, info.getCreationDate() != null ?
|
||||
info.getCreationDate() : info.getXMPCreateDate());
|
||||
generator.writeStringField(JsonName.MODIFICATION_DATE, info.getModDate() != null ?
|
||||
info.getModDate() : info.getXMPModifyDate());
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json;
|
||||
|
||||
import com.fasterxml.jackson.core.Version;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import org.opendataloader.pdf.entities.SemanticFormula;
|
||||
import org.opendataloader.pdf.entities.SemanticPicture;
|
||||
import org.opendataloader.pdf.json.serializers.*;
|
||||
import org.verapdf.wcag.algorithms.entities.*;
|
||||
import org.verapdf.wcag.algorithms.entities.content.*;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListItem;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.PDFList;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
|
||||
public class ObjectMapperHolder {
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
static {
|
||||
|
||||
SimpleModule module = new SimpleModule("NodeSerializer", new Version(2, 1,
|
||||
3, null, null, null));
|
||||
|
||||
TextChunkSerializer textChunkSerializer = new TextChunkSerializer(TextChunk.class);
|
||||
module.addSerializer(TextChunk.class, textChunkSerializer);
|
||||
|
||||
TextLineSerializer textLineSerializer = new TextLineSerializer(TextLine.class);
|
||||
module.addSerializer(TextLine.class, textLineSerializer);
|
||||
|
||||
ImageSerializer imageSerializer = new ImageSerializer(ImageChunk.class);
|
||||
module.addSerializer(ImageChunk.class, imageSerializer);
|
||||
|
||||
TableSerializer tableSerializer = new TableSerializer(TableBorder.class);
|
||||
module.addSerializer(TableBorder.class, tableSerializer);
|
||||
|
||||
TableCellSerializer tableCellSerializer = new TableCellSerializer(TableBorderCell.class);
|
||||
module.addSerializer(TableBorderCell.class, tableCellSerializer);
|
||||
|
||||
ListSerializer listSerializer = new ListSerializer(PDFList.class);
|
||||
module.addSerializer(PDFList.class, listSerializer);
|
||||
|
||||
ListItemSerializer listItemSerializer = new ListItemSerializer(ListItem.class);
|
||||
module.addSerializer(ListItem.class, listItemSerializer);
|
||||
|
||||
TOCSerializer tocSerializer = new TOCSerializer(SemanticTOC.class);
|
||||
module.addSerializer(SemanticTOC.class, tocSerializer);
|
||||
|
||||
TOCItemSerializer tocItemSerializer = new TOCItemSerializer(SemanticTOCI.class);
|
||||
module.addSerializer(SemanticTOCI.class, tocItemSerializer);
|
||||
|
||||
LineChunkSerializer lineChunkSerializer = new LineChunkSerializer(LineChunk.class);
|
||||
module.addSerializer(LineChunk.class, lineChunkSerializer);
|
||||
|
||||
SemanticTextNodeSerializer semanticTextNodeSerializer = new SemanticTextNodeSerializer(SemanticTextNode.class);
|
||||
module.addSerializer(SemanticTextNode.class, semanticTextNodeSerializer);
|
||||
|
||||
TableRowSerializer tableRowSerializer = new TableRowSerializer(TableBorderRow.class);
|
||||
module.addSerializer(TableBorderRow.class, tableRowSerializer);
|
||||
|
||||
HeadingSerializer headingSerializer = new HeadingSerializer(SemanticHeading.class);
|
||||
module.addSerializer(SemanticHeading.class, headingSerializer);
|
||||
|
||||
CaptionSerializer captionSerializer = new CaptionSerializer(SemanticCaption.class);
|
||||
module.addSerializer(SemanticCaption.class, captionSerializer);
|
||||
|
||||
DoubleSerializer doubleSerializer = new DoubleSerializer(Double.class);
|
||||
module.addSerializer(Double.class, doubleSerializer);
|
||||
|
||||
HeaderFooterSerializer headerFooterSerializer = new HeaderFooterSerializer(SemanticHeaderOrFooter.class);
|
||||
module.addSerializer(SemanticHeaderOrFooter.class, headerFooterSerializer);
|
||||
|
||||
FormulaSerializer formulaSerializer = new FormulaSerializer(SemanticFormula.class);
|
||||
module.addSerializer(SemanticFormula.class, formulaSerializer);
|
||||
|
||||
PictureSerializer pictureSerializer = new PictureSerializer(SemanticPicture.class);
|
||||
module.addSerializer(SemanticPicture.class, pictureSerializer);
|
||||
|
||||
//ParagraphSerializer paragraphSerializer = new ParagraphSerializer(SemanticParagraph.class);
|
||||
//module.addSerializer(SemanticParagraph.class, paragraphSerializer);
|
||||
|
||||
objectMapper.registerModule(module);
|
||||
}
|
||||
|
||||
public static ObjectMapper getObjectMapper() {
|
||||
return objectMapper;
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticCaption;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Jackson serializer for SemanticCaption objects.
|
||||
* Serializes captions with their essential info and linked content ID.
|
||||
*/
|
||||
public class CaptionSerializer extends StdSerializer<SemanticCaption> {
|
||||
|
||||
/**
|
||||
* Creates a new CaptionSerializer.
|
||||
*
|
||||
* @param t the class type for SemanticCaption
|
||||
*/
|
||||
public CaptionSerializer(Class<SemanticCaption> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(SemanticCaption caption, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, caption, JsonName.CAPTION_TYPE);
|
||||
if (caption.getLinkedContentId() != null) {
|
||||
jsonGenerator.writeNumberField("linked content id", caption.getLinkedContentId());
|
||||
}
|
||||
SerializerUtil.writeTextInfo(jsonGenerator, caption);
|
||||
SerializerUtil.writeMetadataIfPresent(jsonGenerator, caption);
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* Jackson serializer for Double values.
|
||||
* Rounds double values to 3 decimal places for cleaner JSON output.
|
||||
*/
|
||||
public class DoubleSerializer extends StdSerializer<Double> {
|
||||
|
||||
/**
|
||||
* Creates a new DoubleSerializer.
|
||||
*
|
||||
* @param t the class type for Double
|
||||
*/
|
||||
public DoubleSerializer(Class<Double> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
private static final int DEFAULT_ROUNDING_VALUE = 3;
|
||||
|
||||
@Override
|
||||
public void serialize(Double number, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeNumber(round(number, DEFAULT_ROUNDING_VALUE));
|
||||
}
|
||||
|
||||
private static double round(double value, int decimalPlaces) {
|
||||
if (decimalPlaces < 0) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
|
||||
BigDecimal bigDecimalValue = new BigDecimal(Double.toString(value));
|
||||
bigDecimalValue = bigDecimalValue.setScale(decimalPlaces, RoundingMode.HALF_UP);
|
||||
return bigDecimalValue.doubleValue();
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.entities.SemanticFormula;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* JSON serializer for SemanticFormula objects.
|
||||
*
|
||||
* <p>Produces JSON output in the format:
|
||||
* <pre>
|
||||
* {
|
||||
* "type": "formula",
|
||||
* "id": 123,
|
||||
* "page number": 1,
|
||||
* "bounding box": [x1, y1, x2, y2],
|
||||
* "content": "\\frac{a}{b}"
|
||||
* }
|
||||
* </pre>
|
||||
*/
|
||||
public class FormulaSerializer extends StdSerializer<SemanticFormula> {
|
||||
|
||||
public FormulaSerializer(Class<SemanticFormula> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(SemanticFormula formula, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, formula, JsonName.FORMULA_TYPE);
|
||||
jsonGenerator.writeStringField(JsonName.CONTENT, formula.getLatex());
|
||||
SerializerUtil.writeMetadataIfPresent(jsonGenerator, formula);
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticHeaderOrFooter;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Jackson serializer for SemanticHeaderOrFooter objects.
|
||||
* Serializes headers and footers with their child contents.
|
||||
*/
|
||||
public class HeaderFooterSerializer extends StdSerializer<SemanticHeaderOrFooter> {
|
||||
|
||||
/**
|
||||
* Creates a new HeaderFooterSerializer.
|
||||
*
|
||||
* @param t the class type for SemanticHeaderOrFooter
|
||||
*/
|
||||
public HeaderFooterSerializer(Class<SemanticHeaderOrFooter> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(SemanticHeaderOrFooter header, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, header, header.getSemanticType().getValue().toLowerCase());
|
||||
jsonGenerator.writeArrayFieldStart(JsonName.KIDS);
|
||||
for (IObject content : header.getContents()) {
|
||||
if (!(content instanceof LineArtChunk)) {
|
||||
jsonGenerator.writePOJO(content);
|
||||
}
|
||||
}
|
||||
jsonGenerator.writeEndArray();
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticHeading;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Jackson serializer for SemanticHeading objects.
|
||||
* Serializes headings with their level and text content.
|
||||
*/
|
||||
public class HeadingSerializer extends StdSerializer<SemanticHeading> {
|
||||
|
||||
/**
|
||||
* Creates a new HeadingSerializer.
|
||||
*
|
||||
* @param t the class type for SemanticHeading
|
||||
*/
|
||||
public HeadingSerializer(Class<SemanticHeading> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(SemanticHeading heading, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, heading, JsonName.HEADING_TYPE);
|
||||
jsonGenerator.writeNumberField(JsonName.HEADING_LEVEL, heading.getHeadingLevel());
|
||||
SerializerUtil.writeTextInfo(jsonGenerator, heading);
|
||||
SerializerUtil.writeMetadataIfPresent(jsonGenerator, heading);
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.opendataloader.pdf.entities.EnrichedImageChunk;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.opendataloader.pdf.markdown.MarkdownSyntax;
|
||||
import org.opendataloader.pdf.utils.Base64ImageUtils;
|
||||
import org.opendataloader.pdf.utils.ImagesUtils;
|
||||
import org.verapdf.wcag.algorithms.entities.content.ImageChunk;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
public class ImageSerializer extends StdSerializer<ImageChunk> {
|
||||
|
||||
public ImageSerializer(Class<ImageChunk> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ImageChunk imageChunk, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
String imageFormat = StaticLayoutContainers.getImageFormat();
|
||||
String absolutePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectory(), File.separator, imageChunk.getIndex(), imageFormat);
|
||||
String relativePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectoryName(), "/", imageChunk.getIndex(), imageFormat);
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, imageChunk, JsonName.IMAGE_CHUNK_TYPE);
|
||||
// alt / alt_source policy (PDF/UA semantics):
|
||||
// - original /Alt present → alt = original, alt_source = "original"
|
||||
// - no /Alt, AI caption → alt = AI caption, alt_source = "ai-generated"
|
||||
// - neither present → no alt field, alt_source = "missing"
|
||||
// We never synthesize a placeholder like "Image N": PDF/UA forbids false
|
||||
// alternatives and a synthetic string defeats the evidence-report
|
||||
// signal a reviewer needs to find genuinely missing alt text.
|
||||
String alt = "";
|
||||
String altSource = "missing";
|
||||
if (imageChunk instanceof EnrichedImageChunk) {
|
||||
EnrichedImageChunk eic = (EnrichedImageChunk) imageChunk;
|
||||
alt = eic.sanitizeDescription();
|
||||
if (!alt.isEmpty()) {
|
||||
altSource = eic.getAltSource() == EnrichedImageChunk.AltSource.AI_GENERATED
|
||||
? "ai-generated" : "original";
|
||||
}
|
||||
}
|
||||
if (!alt.isEmpty()) {
|
||||
jsonGenerator.writeStringField(JsonName.ALT, alt);
|
||||
}
|
||||
jsonGenerator.writeStringField(JsonName.ALT_SOURCE, altSource);
|
||||
if (ImagesUtils.isImageFileExists(absolutePath)) {
|
||||
if (StaticLayoutContainers.isEmbedImages()) {
|
||||
File imageFile = new File(absolutePath);
|
||||
String dataUri = Base64ImageUtils.toDataUri(imageFile, imageFormat);
|
||||
if (dataUri != null) {
|
||||
jsonGenerator.writeStringField(JsonName.DATA, dataUri);
|
||||
jsonGenerator.writeStringField(JsonName.IMAGE_FORMAT, imageFormat);
|
||||
}
|
||||
} else {
|
||||
jsonGenerator.writeStringField(JsonName.SOURCE, relativePath);
|
||||
}
|
||||
}
|
||||
SerializerUtil.writeMetadataIfPresent(jsonGenerator, imageChunk);
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineChunk;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class LineChunkSerializer extends StdSerializer<LineChunk> {
|
||||
|
||||
public LineChunkSerializer(Class<LineChunk> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(LineChunk lineChunk, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, lineChunk, JsonName.LINE_CHUNK_TYPE);
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListItem;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ListItemSerializer extends StdSerializer<ListItem> {
|
||||
|
||||
public ListItemSerializer(Class<ListItem> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ListItem item, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, item, JsonName.LIST_ITEM_TYPE);
|
||||
SerializerUtil.writeTextInfo(jsonGenerator, item);
|
||||
jsonGenerator.writeArrayFieldStart(JsonName.KIDS);
|
||||
for (IObject content : item.getContents()) {
|
||||
if (!(content instanceof LineArtChunk)) {
|
||||
jsonGenerator.writePOJO(content);
|
||||
}
|
||||
}
|
||||
jsonGenerator.writeEndArray();
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListItem;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.PDFList;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ListSerializer extends StdSerializer<PDFList> {
|
||||
|
||||
public ListSerializer(Class<PDFList> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(PDFList list, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, list, JsonName.LIST_TYPE);
|
||||
jsonGenerator.writeStringField(JsonName.NUMBERING_STYLE, list.getNumberingStyle());
|
||||
jsonGenerator.writeNumberField(JsonName.NUMBER_OF_LIST_ITEMS, list.getNumberOfListItems());
|
||||
if (list.getPreviousListId() != null) {
|
||||
jsonGenerator.writeNumberField(JsonName.PREVIOUS_LIST_ID, list.getPreviousListId());
|
||||
}
|
||||
if (list.getNextListId() != null) {
|
||||
jsonGenerator.writeNumberField(JsonName.NEXT_LIST_ID, list.getNextListId());
|
||||
}
|
||||
jsonGenerator.writeArrayFieldStart(JsonName.LIST_ITEMS);
|
||||
for (ListItem item : list.getListItems()) {
|
||||
jsonGenerator.writePOJO(item);
|
||||
}
|
||||
|
||||
jsonGenerator.writeEndArray();
|
||||
SerializerUtil.writeMetadataIfPresent(jsonGenerator, list);
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticParagraph;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ParagraphSerializer extends StdSerializer<SemanticParagraph> {
|
||||
|
||||
public ParagraphSerializer(Class<SemanticParagraph> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(SemanticParagraph textParagraph, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, textParagraph, JsonName.PARAGRAPH_TYPE);
|
||||
SerializerUtil.writeTextInfo(jsonGenerator, textParagraph);
|
||||
SerializerUtil.writeMetadataIfPresent(jsonGenerator, textParagraph);
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.opendataloader.pdf.entities.SemanticPicture;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.opendataloader.pdf.markdown.MarkdownSyntax;
|
||||
import org.opendataloader.pdf.utils.Base64ImageUtils;
|
||||
import org.opendataloader.pdf.utils.ImagesUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* JSON serializer for SemanticPicture elements.
|
||||
*
|
||||
* <p>Serializes pictures with their description (alt text) and image source.
|
||||
*/
|
||||
public class PictureSerializer extends StdSerializer<SemanticPicture> {
|
||||
|
||||
public PictureSerializer(Class<SemanticPicture> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(SemanticPicture picture, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
String imageFormat = StaticLayoutContainers.getImageFormat();
|
||||
String absolutePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectory(), File.separator, picture.getPictureIndex(), imageFormat);
|
||||
String relativePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectoryName(), "/", picture.getPictureIndex(), imageFormat);
|
||||
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, picture, JsonName.IMAGE_CHUNK_TYPE);
|
||||
|
||||
// alt / alt_source — same policy as ImageSerializer. A SemanticPicture
|
||||
// only reaches this serializer when enrichBackendResults could not
|
||||
// match it to a Java ImageChunk, i.e. the backend (always AI for
|
||||
// SemanticPicture today) is the only source of alt text. Drop the
|
||||
// legacy `description` field in favor of the unified `alt` schema.
|
||||
String alt = picture.hasDescription() ? picture.sanitizeDescription() : "";
|
||||
if (!alt.isEmpty()) {
|
||||
jsonGenerator.writeStringField(JsonName.ALT, alt);
|
||||
jsonGenerator.writeStringField(JsonName.ALT_SOURCE, "ai-generated");
|
||||
} else {
|
||||
jsonGenerator.writeStringField(JsonName.ALT_SOURCE, "missing");
|
||||
}
|
||||
|
||||
if (ImagesUtils.isImageFileExists(absolutePath)) {
|
||||
if (StaticLayoutContainers.isEmbedImages()) {
|
||||
File imageFile = new File(absolutePath);
|
||||
String dataUri = Base64ImageUtils.toDataUri(imageFile, imageFormat);
|
||||
if (dataUri != null) {
|
||||
jsonGenerator.writeStringField(JsonName.DATA, dataUri);
|
||||
jsonGenerator.writeStringField(JsonName.IMAGE_FORMAT, imageFormat);
|
||||
}
|
||||
} else {
|
||||
jsonGenerator.writeStringField(JsonName.SOURCE, relativePath);
|
||||
}
|
||||
}
|
||||
SerializerUtil.writeMetadataIfPresent(jsonGenerator, picture);
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTextNode;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
//For now this class is used to process headers, footers, headings, captions.
|
||||
public class SemanticTextNodeSerializer extends StdSerializer<SemanticTextNode> {
|
||||
|
||||
public SemanticTextNodeSerializer(Class<SemanticTextNode> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(SemanticTextNode textNode, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, textNode, textNode.getSemanticType().toString().toLowerCase());
|
||||
SerializerUtil.writeTextInfo(jsonGenerator, textNode);
|
||||
SerializerUtil.writeMetadataIfPresent(jsonGenerator, textNode);
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import org.opendataloader.pdf.hybrid.ElementMetadata;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.opendataloader.pdf.utils.TextNodeUtils;
|
||||
import org.verapdf.tools.TaggedPDFConstants;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticHeading;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTextNode;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextBlock;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
public class SerializerUtil {
|
||||
|
||||
private static final ThreadLocal<Map<Long, ElementMetadata>> ELEMENT_METADATA =
|
||||
ThreadLocal.withInitial(Collections::emptyMap);
|
||||
|
||||
public static void setElementMetadata(Map<Long, ElementMetadata> metadata) {
|
||||
ELEMENT_METADATA.set(metadata != null ? metadata : Collections.emptyMap());
|
||||
}
|
||||
|
||||
public static void clearElementMetadata() {
|
||||
ELEMENT_METADATA.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes element-level metadata fields (confidence, source label, etc.) if available.
|
||||
* Call this before writeEndObject() in each serializer.
|
||||
*/
|
||||
public static void writeMetadataIfPresent(JsonGenerator gen, IObject object) throws IOException {
|
||||
if (object == null || object.getRecognizedStructureId() == null) return;
|
||||
Map<Long, ElementMetadata> metadata = ELEMENT_METADATA.get();
|
||||
if (metadata.isEmpty()) return;
|
||||
|
||||
ElementMetadata meta = metadata.get(object.getRecognizedStructureId());
|
||||
if (meta == null) return;
|
||||
|
||||
if (meta.getAiScore() >= 0.0) {
|
||||
gen.writeNumberField(JsonName.AI_SCORE, meta.getAiScore());
|
||||
}
|
||||
if (meta.getSourceLabel() >= 0) {
|
||||
gen.writeNumberField(JsonName.SOURCE_LABEL, meta.getSourceLabel());
|
||||
}
|
||||
|
||||
if (meta.getHeadingInferenceMethod() != null) {
|
||||
gen.writeObjectFieldStart("heading inference");
|
||||
gen.writeStringField("method", meta.getHeadingInferenceMethod());
|
||||
if (meta.getBboxHeightPx() != null) {
|
||||
gen.writeNumberField("bbox height px", meta.getBboxHeightPx());
|
||||
}
|
||||
gen.writeEndObject();
|
||||
}
|
||||
|
||||
if (meta.getTsr() != null) {
|
||||
gen.writeObjectFieldStart("tsr");
|
||||
gen.writeNumberField("num cells", meta.getTsr().getNumCells());
|
||||
if (meta.getTsr().getHtml() != null && !meta.getTsr().getHtml().isEmpty()) {
|
||||
gen.writeStringField("html", meta.getTsr().getHtml());
|
||||
}
|
||||
if (meta.getTsr().getRunTimeMs() > 0) {
|
||||
gen.writeNumberField("run time ms", meta.getTsr().getRunTimeMs());
|
||||
}
|
||||
gen.writeEndObject();
|
||||
}
|
||||
|
||||
if (meta.getCaption() != null) {
|
||||
gen.writeObjectFieldStart("caption");
|
||||
if (meta.getCaption().getText() != null) {
|
||||
gen.writeStringField("text", meta.getCaption().getText());
|
||||
}
|
||||
if (meta.getCaption().getLanguage() != null) {
|
||||
gen.writeStringField("language", meta.getCaption().getLanguage());
|
||||
}
|
||||
if (meta.getCaption().getRunTimeMs() > 0) {
|
||||
gen.writeNumberField("run time ms", meta.getCaption().getRunTimeMs());
|
||||
}
|
||||
gen.writeEndObject();
|
||||
}
|
||||
|
||||
if (meta.getRegionlistResolution() != null) {
|
||||
gen.writeObjectFieldStart("regionlist resolution");
|
||||
gen.writeStringField("strategy", meta.getRegionlistResolution().getStrategy());
|
||||
gen.writeBooleanField("tsr attempted", meta.getRegionlistResolution().isTsrAttempted());
|
||||
if (meta.getRegionlistResolution().getTsrResult() != null) {
|
||||
gen.writeStringField("tsr result", meta.getRegionlistResolution().getTsrResult());
|
||||
}
|
||||
gen.writeEndObject();
|
||||
}
|
||||
|
||||
if (meta.getWordMatchMethod() != null) {
|
||||
gen.writeObjectFieldStart("word match");
|
||||
gen.writeStringField("method", meta.getWordMatchMethod());
|
||||
gen.writeNumberField("matched words", meta.getMatchedWordCount());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
|
||||
if (meta.getTextSource() != null) {
|
||||
gen.writeStringField("text source", meta.getTextSource());
|
||||
}
|
||||
if (meta.getStreamOcrSimilarity() != null) {
|
||||
gen.writeNumberField("stream ocr similarity", meta.getStreamOcrSimilarity());
|
||||
}
|
||||
}
|
||||
|
||||
public static void writeEssentialInfo(JsonGenerator jsonGenerator, IObject object, String type) throws IOException {
|
||||
jsonGenerator.writeStringField(JsonName.TYPE, type);
|
||||
String pdfuaTag = pdfuaTagFor(type, object);
|
||||
if (pdfuaTag != null) {
|
||||
jsonGenerator.writeStringField(JsonName.PDFUA_TAG, pdfuaTag);
|
||||
}
|
||||
Long id = object.getRecognizedStructureId();
|
||||
if (id != null && id != 0L) {
|
||||
jsonGenerator.writeNumberField(JsonName.ID, id);
|
||||
}
|
||||
if (object.getLevel() != null) {
|
||||
jsonGenerator.writeStringField(JsonName.LEVEL, object.getLevel());
|
||||
}
|
||||
jsonGenerator.writeNumberField(JsonName.PAGE_NUMBER, object.getPageNumber() + 1);
|
||||
jsonGenerator.writeArrayFieldStart(JsonName.BOUNDING_BOX);
|
||||
jsonGenerator.writePOJO(object.getLeftX());
|
||||
jsonGenerator.writePOJO(object.getBottomY());
|
||||
jsonGenerator.writePOJO(object.getRightX());
|
||||
jsonGenerator.writePOJO(object.getTopY());
|
||||
jsonGenerator.writeEndArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an extraction JSON `type` (plus heading level when relevant) to the
|
||||
* PDF/UA structure tag that AutoTaggingProcessor will emit for the node.
|
||||
* Returns null when the node has no canonical PDF/UA tag (e.g. text chunks
|
||||
* that live below the structure-element granularity).
|
||||
*/
|
||||
static String pdfuaTagFor(String type, IObject object) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
switch (type) {
|
||||
case JsonName.HEADING_TYPE:
|
||||
if (object instanceof SemanticHeading) {
|
||||
int level = ((SemanticHeading) object).getHeadingLevel();
|
||||
if (level >= 1 && level <= 6) {
|
||||
return "H" + level;
|
||||
}
|
||||
}
|
||||
// Fallback for unleveled headings. PDF/UA-2 deprecates the
|
||||
// plain "H" tag in favor of H1..H6; emit it only because we
|
||||
// cannot infer a level. Downstream remediation may upgrade
|
||||
// this when more context is available.
|
||||
return TaggedPDFConstants.H;
|
||||
case JsonName.PARAGRAPH_TYPE:
|
||||
return TaggedPDFConstants.P;
|
||||
case JsonName.IMAGE_CHUNK_TYPE:
|
||||
return TaggedPDFConstants.FIGURE;
|
||||
case JsonName.FORMULA_TYPE:
|
||||
return TaggedPDFConstants.FORMULA;
|
||||
case JsonName.CAPTION_TYPE:
|
||||
return TaggedPDFConstants.CAPTION;
|
||||
case JsonName.LIST_TYPE:
|
||||
return TaggedPDFConstants.L;
|
||||
case JsonName.LIST_ITEM_TYPE:
|
||||
return TaggedPDFConstants.LI;
|
||||
case JsonName.TOC_TYPE:
|
||||
return TaggedPDFConstants.TOC;
|
||||
case JsonName.TOC_ITEM_TYPE:
|
||||
return TaggedPDFConstants.TOCI;
|
||||
case JsonName.TABLE_TYPE:
|
||||
return TaggedPDFConstants.TABLE;
|
||||
case JsonName.TABLE_CELL_TYPE:
|
||||
return ((TableBorderCell)object).isHeaderCell() ? TaggedPDFConstants.TH : TaggedPDFConstants.TD;
|
||||
default:
|
||||
// header/footer/footnote/line/text-chunk/text-block
|
||||
// either become Artifact or are not promoted to their own
|
||||
// PDF/UA structure element. Leave the tag unset for now.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void writeTextInfo(JsonGenerator jsonGenerator, SemanticTextNode textNode) throws IOException {
|
||||
jsonGenerator.writeStringField(JsonName.FONT_TYPE, textNode.getFontName());
|
||||
jsonGenerator.writePOJOField(JsonName.FONT_SIZE, textNode.getFontSize());
|
||||
double[] textColor = TextNodeUtils.getTextColorOrNull(textNode);
|
||||
if (textColor != null) {
|
||||
jsonGenerator.writeStringField(JsonName.TEXT_COLOR, Arrays.toString(textColor));
|
||||
}
|
||||
jsonGenerator.writeStringField(JsonName.CONTENT, textNode.getValue());
|
||||
if (textNode.isHiddenText()) {
|
||||
jsonGenerator.writeBooleanField(JsonName.HIDDEN_TEXT, true);
|
||||
}
|
||||
}
|
||||
|
||||
public static void writeTextInfo(JsonGenerator jsonGenerator, TextBlock textBlock) throws IOException {
|
||||
jsonGenerator.writeStringField(JsonName.FONT_TYPE, textBlock.getFirstLine().getFirstTextChunk().getFontName());
|
||||
jsonGenerator.writePOJOField(JsonName.FONT_SIZE, textBlock.getFontSize());
|
||||
jsonGenerator.writeStringField(JsonName.TEXT_COLOR, Arrays.toString(
|
||||
textBlock.getFirstLine().getFirstTextChunk().getFontColor()));
|
||||
jsonGenerator.writeStringField(JsonName.CONTENT, textBlock.toString());
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTOCI;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class TOCItemSerializer extends StdSerializer<SemanticTOCI> {
|
||||
|
||||
public TOCItemSerializer(Class<SemanticTOCI> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(SemanticTOCI item, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, item, JsonName.TOC_ITEM_TYPE);
|
||||
SerializerUtil.writeTextInfo(jsonGenerator, item);
|
||||
jsonGenerator.writeArrayFieldStart(JsonName.KIDS);
|
||||
for (IObject content : item.getContents()) {
|
||||
if (!(content instanceof LineArtChunk)) {
|
||||
jsonGenerator.writePOJO(content);
|
||||
}
|
||||
}
|
||||
jsonGenerator.writeEndArray();
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTOC;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class TOCSerializer extends StdSerializer<SemanticTOC> {
|
||||
|
||||
public TOCSerializer(Class<SemanticTOC> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(SemanticTOC toc, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, toc, JsonName.TOC_TYPE);
|
||||
if (toc.getPreviousTOCId() != null) {
|
||||
jsonGenerator.writeNumberField(JsonName.PREVIOUS_TOC_ID, toc.getPreviousTOCId());
|
||||
}
|
||||
if (toc.getNextTOCId() != null) {
|
||||
jsonGenerator.writeNumberField(JsonName.NEXT_TOC_ID, toc.getNextTOCId());
|
||||
}
|
||||
jsonGenerator.writeArrayFieldStart(JsonName.TOC_ITEMS);
|
||||
for (IObject child : toc.getTOCItems()) {
|
||||
jsonGenerator.writePOJO(child);
|
||||
}
|
||||
jsonGenerator.writeEndArray();
|
||||
SerializerUtil.writeMetadataIfPresent(jsonGenerator, toc);
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class TableCellSerializer extends StdSerializer<TableBorderCell> {
|
||||
|
||||
public TableCellSerializer(Class<TableBorderCell> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(TableBorderCell cell, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, cell, JsonName.TABLE_CELL_TYPE);
|
||||
jsonGenerator.writeNumberField(JsonName.ROW_NUMBER, cell.getRowNumber() + 1);
|
||||
jsonGenerator.writeNumberField(JsonName.COLUMN_NUMBER, cell.getColNumber() + 1);
|
||||
jsonGenerator.writeNumberField(JsonName.ROW_SPAN, cell.getRowSpan());
|
||||
jsonGenerator.writeNumberField(JsonName.COLUMN_SPAN, cell.getColSpan());
|
||||
if (cell.isHeaderCell()) {
|
||||
jsonGenerator.writeBooleanField(JsonName.IS_HEADER, true);
|
||||
}
|
||||
jsonGenerator.writeArrayFieldStart(JsonName.KIDS);
|
||||
for (IObject content : cell.getContents()) {
|
||||
if (!(content instanceof LineArtChunk)) {
|
||||
jsonGenerator.writePOJO(content);
|
||||
}
|
||||
}
|
||||
jsonGenerator.writeEndArray();
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class TableRowSerializer extends StdSerializer<TableBorderRow> {
|
||||
|
||||
public TableRowSerializer(Class<TableBorderRow> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(TableBorderRow row, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
jsonGenerator.writeStringField(JsonName.TYPE, JsonName.ROW_TYPE);
|
||||
jsonGenerator.writeNumberField(JsonName.ROW_NUMBER, row.getRowNumber() + 1);
|
||||
jsonGenerator.writeArrayFieldStart(JsonName.CELLS);
|
||||
TableBorderCell[] cells = row.getCells();
|
||||
for (int columnNumber = 0; columnNumber < cells.length; columnNumber++) {
|
||||
TableBorderCell cell = cells[columnNumber];
|
||||
if (cell.getColNumber() == columnNumber && cell.getRowNumber() == row.getRowNumber()) {
|
||||
jsonGenerator.writePOJO(cell);
|
||||
}
|
||||
}
|
||||
|
||||
jsonGenerator.writeEndArray();
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class TableSerializer extends StdSerializer<TableBorder> {
|
||||
|
||||
public TableSerializer(Class<TableBorder> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(TableBorder table, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, table, table.isTextBlock() ? JsonName.TEXT_BLOCK : JsonName.TABLE_TYPE);
|
||||
if (table.isTextBlock()) {
|
||||
jsonGenerator.writeArrayFieldStart(JsonName.KIDS);
|
||||
for (IObject content : table.getCell(0, 0).getContents()) {
|
||||
if (!(content instanceof LineArtChunk)) {
|
||||
jsonGenerator.writePOJO(content);
|
||||
}
|
||||
}
|
||||
jsonGenerator.writeEndArray();
|
||||
} else {
|
||||
jsonGenerator.writeNumberField(JsonName.NUMBER_OF_ROWS, table.getNumberOfRows());
|
||||
jsonGenerator.writeNumberField(JsonName.NUMBER_OF_COLUMNS, table.getNumberOfColumns());
|
||||
if (table.getPreviousTableId() != null) {
|
||||
jsonGenerator.writeNumberField(JsonName.PREVIOUS_TABLE_ID, table.getPreviousTableId());
|
||||
}
|
||||
if (table.getNextTableId() != null) {
|
||||
jsonGenerator.writeNumberField(JsonName.NEXT_TABLE_ID, table.getNextTableId());
|
||||
}
|
||||
jsonGenerator.writeArrayFieldStart(JsonName.ROWS);
|
||||
for (TableBorderRow row : table.getRows()) {
|
||||
jsonGenerator.writePOJO(row);
|
||||
}
|
||||
jsonGenerator.writeEndArray();
|
||||
}
|
||||
SerializerUtil.writeMetadataIfPresent(jsonGenerator, table);
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class TextChunkSerializer extends StdSerializer<TextChunk> {
|
||||
|
||||
public TextChunkSerializer(Class<TextChunk> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(TextChunk textChunk, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, textChunk, JsonName.TEXT_CHUNK_TYPE);
|
||||
jsonGenerator.writeStringField(JsonName.CONTENT, textChunk.getValue());
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.json.serializers;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import org.opendataloader.pdf.json.JsonName;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextLine;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class TextLineSerializer extends StdSerializer<TextLine> {
|
||||
|
||||
public TextLineSerializer(Class<TextLine> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(TextLine textLine, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
|
||||
throws IOException {
|
||||
jsonGenerator.writeStartObject();
|
||||
SerializerUtil.writeEssentialInfo(jsonGenerator, textLine, JsonName.TEXT_CHUNK_TYPE);
|
||||
jsonGenerator.writeStringField(JsonName.CONTENT, textLine.getValue());
|
||||
jsonGenerator.writeEndObject();
|
||||
}
|
||||
}
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.markdown;
|
||||
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.opendataloader.pdf.entities.SemanticFormula;
|
||||
import org.opendataloader.pdf.entities.EnrichedImageChunk;
|
||||
import org.opendataloader.pdf.entities.SemanticPicture;
|
||||
import org.opendataloader.pdf.utils.Base64ImageUtils;
|
||||
import org.opendataloader.pdf.utils.GeneratorUtils;
|
||||
import org.opendataloader.pdf.utils.ImagesUtils;
|
||||
import org.opendataloader.pdf.utils.OutputType;
|
||||
import org.verapdf.wcag.algorithms.entities.*;
|
||||
import org.verapdf.wcag.algorithms.entities.content.*;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListItem;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.PDFList;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class MarkdownGenerator implements Closeable {
|
||||
|
||||
protected static final Logger LOGGER = Logger.getLogger(MarkdownGenerator.class.getCanonicalName());
|
||||
protected final java.io.Writer markdownWriter;
|
||||
protected final String markdownFileName;
|
||||
protected int tableNesting = 0;
|
||||
protected boolean isImageSupported;
|
||||
protected String markdownPageSeparator;
|
||||
/**
|
||||
* Page numbers (1-based) selected by --pages; an empty set means all pages.
|
||||
* Sourced from the raw {@link Config#getPageNumbers()} list (not the
|
||||
* validated set built by {@code DocumentProcessor.getValidPageNumbers}).
|
||||
* Safe to compare against {@code pageNumber + 1} because the surrounding
|
||||
* loop is bounded by the document's actual page count, so out-of-range
|
||||
* values from the raw list are never tested for membership.
|
||||
*/
|
||||
protected final Set<Integer> selectedPageNumbers;
|
||||
protected boolean embedImages = false;
|
||||
protected String imageFormat = Config.IMAGE_FORMAT_PNG;
|
||||
protected boolean includeHeaderFooter = false;
|
||||
protected static final String strikethroughTextMD = "~~";
|
||||
|
||||
MarkdownGenerator(File inputPdf, Config config) throws IOException {
|
||||
String cutPdfFileName = inputPdf.getName();
|
||||
this.markdownFileName = config.getOutputFolder() + File.separator + cutPdfFileName.substring(0, cutPdfFileName.length() - 3) + "md";
|
||||
this.markdownWriter = new FileWriter(markdownFileName, StandardCharsets.UTF_8);
|
||||
this.isImageSupported = !config.isImageOutputOff() && config.isGenerateMarkdown();
|
||||
this.markdownPageSeparator = config.getMarkdownPageSeparator();
|
||||
this.selectedPageNumbers = new HashSet<>(config.getPageNumbers());
|
||||
this.embedImages = config.isEmbedImages();
|
||||
this.imageFormat = config.getImageFormat();
|
||||
this.includeHeaderFooter = config.isIncludeHeaderFooter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a MarkdownGenerator that writes to an arbitrary Writer (e.g., stdout).
|
||||
*/
|
||||
public MarkdownGenerator(java.io.Writer writer, Config config) {
|
||||
this.markdownFileName = null;
|
||||
this.markdownWriter = writer;
|
||||
this.isImageSupported = false;
|
||||
this.markdownPageSeparator = config.getMarkdownPageSeparator();
|
||||
this.selectedPageNumbers = new HashSet<>(config.getPageNumbers());
|
||||
this.embedImages = false;
|
||||
this.imageFormat = config.getImageFormat();
|
||||
this.includeHeaderFooter = config.isIncludeHeaderFooter();
|
||||
}
|
||||
|
||||
public void writeToMarkdown(List<List<IObject>> contents) {
|
||||
try {
|
||||
for (int pageNumber = 0; pageNumber < StaticContainers.getDocument().getNumberOfPages(); pageNumber++) {
|
||||
if (selectedPageNumbers.isEmpty() || selectedPageNumbers.contains(pageNumber + 1)) {
|
||||
writePageSeparator(pageNumber);
|
||||
}
|
||||
for (IObject content : contents.get(pageNumber)) {
|
||||
if (!isSupportedContent(content)) {
|
||||
continue;
|
||||
}
|
||||
this.write(content);
|
||||
writeContentsSeparator();
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.log(Level.INFO, "Created {0}", markdownFileName);
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.WARNING, "Unable to create markdown output: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected void writePageSeparator(int pageNumber) throws IOException {
|
||||
if (!markdownPageSeparator.isEmpty()) {
|
||||
markdownWriter.write(markdownPageSeparator.contains(Config.PAGE_NUMBER_STRING)
|
||||
? markdownPageSeparator.replace(Config.PAGE_NUMBER_STRING, String.valueOf(pageNumber + 1))
|
||||
: markdownPageSeparator);
|
||||
writeContentsSeparator();
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean isSupportedContent(IObject content) {
|
||||
if (content instanceof SemanticHeaderOrFooter) {
|
||||
return includeHeaderFooter;
|
||||
}
|
||||
return content instanceof SemanticTextNode || // Heading, Paragraph etc...
|
||||
content instanceof SemanticFormula ||
|
||||
content instanceof SemanticPicture ||
|
||||
content instanceof TableBorder ||
|
||||
content instanceof PDFList ||
|
||||
content instanceof SemanticTOC ||
|
||||
(content instanceof ImageChunk && isImageSupported);
|
||||
}
|
||||
|
||||
protected void writeContentsSeparator() throws IOException {
|
||||
writeLineBreak();
|
||||
writeLineBreak();
|
||||
}
|
||||
|
||||
protected void write(IObject object) throws IOException {
|
||||
if (object instanceof SemanticHeaderOrFooter) {
|
||||
writeHeaderOrFooter((SemanticHeaderOrFooter) object);
|
||||
} else if (object instanceof SemanticPicture) {
|
||||
writePicture((SemanticPicture) object);
|
||||
} else if (object instanceof ImageChunk) {
|
||||
writeImage((ImageChunk) object);
|
||||
} else if (object instanceof SemanticFormula) {
|
||||
writeFormula((SemanticFormula) object);
|
||||
} else if (object instanceof SemanticHeading) {
|
||||
writeHeading((SemanticHeading) object);
|
||||
} else if (object instanceof SemanticParagraph) {
|
||||
writeParagraph((SemanticParagraph) object);
|
||||
} else if (object instanceof SemanticTextNode) {
|
||||
writeSemanticTextNode((SemanticTextNode) object);
|
||||
} else if (object instanceof TableBorder) {
|
||||
writeTable((TableBorder) object);
|
||||
} else if (object instanceof PDFList) {
|
||||
writeList((PDFList) object);
|
||||
} else if (object instanceof SemanticTOC) {
|
||||
writeTOC((SemanticTOC) object);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an image relative path as a CommonMark angle-bracket link destination
|
||||
* (`<...>`). The bare form `(my paper.png)` is terminated by the first space or
|
||||
* unbalanced parenthesis, so paths inheriting filenames with spaces, parens, or
|
||||
* brackets break in renderers (#405). The angle-bracket form is the
|
||||
* spec-recommended way to embed such paths and lets the on-disk path stay
|
||||
* byte-identical to the rendered link, which preserves user intent for both the
|
||||
* default `<stem>_images/` directory and any `--image-dir` value.
|
||||
*
|
||||
* Only `<`, `>`, and `\` are reserved inside the angle-bracket form; escape
|
||||
* those with a backslash. Newlines have no representable form in a link
|
||||
* destination — replace them with spaces so the destination stays well-formed.
|
||||
*/
|
||||
static String formatMarkdownLinkDestination(String path) {
|
||||
if (path == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(path.length() + 2);
|
||||
sb.append('<');
|
||||
for (int i = 0; i < path.length(); i++) {
|
||||
char c = path.charAt(i);
|
||||
if (c == '<' || c == '>' || c == '\\') {
|
||||
sb.append('\\').append(c);
|
||||
} else if (c == '\n' || c == '\r') {
|
||||
sb.append(' ');
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
sb.append('>');
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
protected void writeImage(ImageChunk image) {
|
||||
try {
|
||||
String absolutePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectory(), File.separator, image.getIndex(), imageFormat);
|
||||
String relativePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectoryName(), "/", image.getIndex(), imageFormat);
|
||||
|
||||
if (ImagesUtils.isImageFileExists(absolutePath)) {
|
||||
String imageSource;
|
||||
if (embedImages) {
|
||||
File imageFile = new File(absolutePath);
|
||||
imageSource = Base64ImageUtils.toDataUri(imageFile, imageFormat);
|
||||
if (imageSource == null) {
|
||||
LOGGER.log(Level.WARNING, "Failed to convert image to Base64: {0}", absolutePath);
|
||||
}
|
||||
} else {
|
||||
imageSource = formatMarkdownLinkDestination(relativePath);
|
||||
}
|
||||
if (imageSource != null) {
|
||||
// No "image N" fallback: PDF/UA forbids false alternatives,
|
||||
// and an empty Markdown alt lets screen readers skip the
|
||||
// image as a decorative element rather than reading a
|
||||
// meaningless synthetic label.
|
||||
String altText = (image instanceof EnrichedImageChunk && ((EnrichedImageChunk) image).hasDescription())
|
||||
? ((EnrichedImageChunk) image).sanitizeDescription()
|
||||
: "";
|
||||
String imageString = String.format(MarkdownSyntax.IMAGE_FORMAT, altText, imageSource);
|
||||
markdownWriter.write(getCorrectMarkdownString(imageString));
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.WARNING, "Unable to write image for markdown output: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a SemanticPicture with its description as alt text.
|
||||
*
|
||||
* @param picture The picture to write
|
||||
*/
|
||||
protected void writePicture(SemanticPicture picture) {
|
||||
try {
|
||||
String absolutePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectory(), File.separator, picture.getPictureIndex(), imageFormat);
|
||||
String relativePath = String.format(MarkdownSyntax.IMAGE_FILE_NAME_FORMAT, StaticLayoutContainers.getImagesDirectoryName(), "/", picture.getPictureIndex(), imageFormat);
|
||||
|
||||
if (ImagesUtils.isImageFileExists(absolutePath)) {
|
||||
String imageSource;
|
||||
if (embedImages) {
|
||||
File imageFile = new File(absolutePath);
|
||||
imageSource = Base64ImageUtils.toDataUri(imageFile, imageFormat);
|
||||
if (imageSource == null) {
|
||||
LOGGER.log(Level.WARNING, "Failed to convert image to Base64: {0}", absolutePath);
|
||||
}
|
||||
} else {
|
||||
imageSource = formatMarkdownLinkDestination(relativePath);
|
||||
}
|
||||
if (imageSource != null) {
|
||||
String altText = picture.hasDescription()
|
||||
? picture.sanitizeDescription()
|
||||
: "";
|
||||
String imageString = String.format(MarkdownSyntax.IMAGE_FORMAT, altText, imageSource);
|
||||
markdownWriter.write(getCorrectMarkdownString(imageString));
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.WARNING, "Unable to write picture for markdown output: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a formula in LaTeX format wrapped in $$ delimiters.
|
||||
*
|
||||
* @param formula The formula to write
|
||||
*/
|
||||
protected void writeFormula(SemanticFormula formula) throws IOException {
|
||||
markdownWriter.write(MarkdownSyntax.MATH_BLOCK_START);
|
||||
markdownWriter.write(MarkdownSyntax.LINE_BREAK);
|
||||
markdownWriter.write(formula.getLatex());
|
||||
markdownWriter.write(MarkdownSyntax.LINE_BREAK);
|
||||
markdownWriter.write(MarkdownSyntax.MATH_BLOCK_END);
|
||||
}
|
||||
|
||||
protected void writeHeaderOrFooter(SemanticHeaderOrFooter headerOrFooter) throws IOException {
|
||||
for (IObject content : headerOrFooter.getContents()) {
|
||||
if (isSupportedContent(content)) {
|
||||
write(content);
|
||||
writeContentsSeparator();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void writeList(PDFList list) throws IOException {
|
||||
for (ListItem item : list.getListItems()) {
|
||||
if (!isInsideTable()) {
|
||||
markdownWriter.write(MarkdownSyntax.LIST_ITEM);
|
||||
markdownWriter.write(MarkdownSyntax.SPACE);
|
||||
}
|
||||
markdownWriter.write(getCorrectMarkdownString(GeneratorUtils.getTextFromLines(item.getLines(), OutputType.MD)));
|
||||
writeLineBreak();
|
||||
|
||||
List<IObject> itemContents = item.getContents();
|
||||
if (!itemContents.isEmpty()) {
|
||||
writeLineBreak();
|
||||
writeContents(itemContents, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void writeTOC(SemanticTOC toc) throws IOException {
|
||||
for (IObject item : toc.getTOCItems()) {
|
||||
if (item instanceof SemanticTOC) {
|
||||
writeTOC((SemanticTOC)item);
|
||||
} else if (item instanceof SemanticTOCI) {
|
||||
SemanticTOCI tocItem = (SemanticTOCI)item;
|
||||
markdownWriter.write(getCorrectMarkdownString(GeneratorUtils.getTextFromLines(tocItem.getLines(), OutputType.MD)));
|
||||
writeLineBreak();
|
||||
|
||||
List<IObject> itemContents = tocItem.getContents();
|
||||
if (!itemContents.isEmpty()) {
|
||||
writeLineBreak();
|
||||
writeContents(itemContents, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void writeSemanticTextNode(SemanticTextNode textNode) throws IOException {
|
||||
String value = GeneratorUtils.getTextFromTextNode(textNode, OutputType.MD);
|
||||
if (StaticContainers.isKeepLineBreaks()) {
|
||||
if (textNode instanceof SemanticHeading) {
|
||||
value = value.replace(MarkdownSyntax.LINE_BREAK, MarkdownSyntax.SPACE);
|
||||
} else if (isInsideTable()) {
|
||||
value = value.replace(MarkdownSyntax.LINE_BREAK, getLineBreak());
|
||||
}
|
||||
} else if (isInsideTable()) {
|
||||
// Always replace line breaks with space in table cells for proper markdown table formatting
|
||||
value = value.replace(MarkdownSyntax.LINE_BREAK, MarkdownSyntax.SPACE);
|
||||
}
|
||||
|
||||
markdownWriter.write(getCorrectMarkdownString(value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
protected void writeTable(TableBorder table) throws IOException {
|
||||
enterTable();
|
||||
for (int rowNumber = 0; rowNumber < table.getNumberOfRows(); rowNumber++) {
|
||||
TableBorderRow row = table.getRow(rowNumber);
|
||||
markdownWriter.write(MarkdownSyntax.TABLE_COLUMN_SEPARATOR);
|
||||
for (int colNumber = 0; colNumber < table.getNumberOfColumns(); colNumber++) {
|
||||
TableBorderCell cell = row.getCell(colNumber);
|
||||
if (cell.getRowNumber() == rowNumber && cell.getColNumber() == colNumber) {
|
||||
List<IObject> cellContents = cell.getContents();
|
||||
writeContents(cellContents, true);
|
||||
} else {
|
||||
writeSpace();
|
||||
}
|
||||
markdownWriter.write(MarkdownSyntax.TABLE_COLUMN_SEPARATOR);
|
||||
}
|
||||
markdownWriter.write(MarkdownSyntax.LINE_BREAK);
|
||||
//Due to markdown syntax we have to separate column headers
|
||||
if (rowNumber == 0) {
|
||||
markdownWriter.write(MarkdownSyntax.TABLE_COLUMN_SEPARATOR);
|
||||
for (int i = 0; i < table.getNumberOfColumns(); i++) {
|
||||
markdownWriter.write(MarkdownSyntax.TABLE_HEADER_SEPARATOR);
|
||||
markdownWriter.write(MarkdownSyntax.TABLE_COLUMN_SEPARATOR);
|
||||
}
|
||||
markdownWriter.write(MarkdownSyntax.LINE_BREAK);
|
||||
}
|
||||
}
|
||||
leaveTable();
|
||||
}
|
||||
|
||||
protected void writeContents(List<IObject> contents, boolean isTable) throws IOException {
|
||||
boolean wroteAnyContent = false;
|
||||
for (int i = 0; i < contents.size(); i++) {
|
||||
IObject content = contents.get(i);
|
||||
if (!isSupportedContent(content)) {
|
||||
continue;
|
||||
}
|
||||
this.write(content);
|
||||
boolean isLastContent = i == contents.size() - 1;
|
||||
if (!isTable || !isLastContent) {
|
||||
writeContentsSeparator();
|
||||
}
|
||||
wroteAnyContent = true;
|
||||
}
|
||||
if (!wroteAnyContent && isTable) {
|
||||
writeSpace();
|
||||
}
|
||||
}
|
||||
|
||||
protected void writeParagraph(SemanticParagraph textNode) throws IOException {
|
||||
writeSemanticTextNode(textNode);
|
||||
}
|
||||
|
||||
protected void writeHeading(SemanticHeading heading) throws IOException {
|
||||
if (!isInsideTable()) {
|
||||
// Cap heading level to 1-6 per Markdown specification
|
||||
int headingLevel = Math.min(6, Math.max(1, heading.getHeadingLevel()));
|
||||
for (int i = 0; i < headingLevel; i++) {
|
||||
markdownWriter.write(MarkdownSyntax.HEADING_LEVEL);
|
||||
}
|
||||
markdownWriter.write(MarkdownSyntax.SPACE);
|
||||
}
|
||||
writeSemanticTextNode(heading);
|
||||
}
|
||||
|
||||
protected void enterTable() {
|
||||
tableNesting++;
|
||||
}
|
||||
|
||||
protected void leaveTable() {
|
||||
if (tableNesting > 0) {
|
||||
tableNesting--;
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean isInsideTable() {
|
||||
return tableNesting > 0;
|
||||
}
|
||||
|
||||
protected String getLineBreak() {
|
||||
if (isInsideTable()) {
|
||||
return MarkdownSyntax.HTML_LINE_BREAK_TAG;
|
||||
} else {
|
||||
return MarkdownSyntax.LINE_BREAK;
|
||||
}
|
||||
}
|
||||
|
||||
protected void writeLineBreak() throws IOException {
|
||||
markdownWriter.write(getLineBreak());
|
||||
}
|
||||
|
||||
protected void writeSpace() throws IOException {
|
||||
markdownWriter.write(MarkdownSyntax.SPACE);
|
||||
}
|
||||
|
||||
protected String getCorrectMarkdownString(String value) {
|
||||
if (value != null) {
|
||||
return value.replace("\u0000", " ");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void getTextFromLineForMarkdown(TextLine line, StringBuilder stringBuilder) {
|
||||
for (TextChunk chunk : line.getTextChunks()) {
|
||||
if (chunk.getIsStrikethroughText()) {
|
||||
stringBuilder.append(strikethroughTextMD);
|
||||
}
|
||||
stringBuilder.append(chunk.getValue());
|
||||
if (chunk.getIsStrikethroughText()) {
|
||||
stringBuilder.append(strikethroughTextMD);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (markdownWriter != null) {
|
||||
markdownWriter.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.markdown;
|
||||
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
public class MarkdownGeneratorFactory {
|
||||
public static MarkdownGenerator getMarkdownGenerator(File inputPdf,
|
||||
Config config) throws IOException {
|
||||
if (config.isUseHTMLInMarkdown()) {
|
||||
return new MarkdownHTMLGenerator(inputPdf, config);
|
||||
}
|
||||
return new MarkdownGenerator(inputPdf, config);
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.markdown;
|
||||
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class MarkdownHTMLGenerator extends MarkdownGenerator {
|
||||
|
||||
protected MarkdownHTMLGenerator(File inputPdf, Config config) throws IOException {
|
||||
super(inputPdf, config);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeTable(TableBorder table) throws IOException {
|
||||
enterTable();
|
||||
markdownWriter.write(MarkdownSyntax.HTML_TABLE_TAG);
|
||||
markdownWriter.write(MarkdownSyntax.LINE_BREAK);
|
||||
for (int rowNumber = 0; rowNumber < table.getNumberOfRows(); rowNumber++) {
|
||||
TableBorderRow row = table.getRow(rowNumber);
|
||||
markdownWriter.write(MarkdownSyntax.INDENT);
|
||||
markdownWriter.write(MarkdownSyntax.HTML_TABLE_ROW_TAG);
|
||||
markdownWriter.write(MarkdownSyntax.LINE_BREAK);
|
||||
for (int colNumber = 0; colNumber < table.getNumberOfColumns(); colNumber++) {
|
||||
TableBorderCell cell = row.getCell(colNumber);
|
||||
if (cell.getRowNumber() == rowNumber && cell.getColNumber() == colNumber) {
|
||||
writeCellTagBegin(cell);
|
||||
|
||||
List<IObject> cellContents = cell.getContents();
|
||||
writeContents(cellContents, true);
|
||||
|
||||
writeCellTagEnd(cell.isHeaderCell());
|
||||
markdownWriter.write(MarkdownSyntax.LINE_BREAK);
|
||||
}
|
||||
}
|
||||
|
||||
markdownWriter.write(MarkdownSyntax.INDENT);
|
||||
markdownWriter.write(MarkdownSyntax.HTML_TABLE_ROW_CLOSE_TAG);
|
||||
markdownWriter.write(MarkdownSyntax.LINE_BREAK);
|
||||
}
|
||||
|
||||
markdownWriter.write(MarkdownSyntax.HTML_TABLE_CLOSE_TAG);
|
||||
markdownWriter.write(MarkdownSyntax.LINE_BREAK);
|
||||
leaveTable();
|
||||
}
|
||||
|
||||
private void writeCellTagBegin(TableBorderCell cell) throws IOException {
|
||||
markdownWriter.write(MarkdownSyntax.INDENT);
|
||||
markdownWriter.write(MarkdownSyntax.INDENT);
|
||||
String tag = cell.isHeaderCell() ? "<th" : "<td";
|
||||
StringBuilder cellTag = new StringBuilder(tag);
|
||||
int colSpan = cell.getColSpan();
|
||||
if (colSpan != 1) {
|
||||
cellTag.append(" colspan=\"").append(colSpan).append("\"");
|
||||
}
|
||||
|
||||
int rowSpan = cell.getRowSpan();
|
||||
if (rowSpan != 1) {
|
||||
cellTag.append(" rowspan=\"").append(rowSpan).append("\"");
|
||||
}
|
||||
cellTag.append(">");
|
||||
markdownWriter.write(getCorrectMarkdownString(cellTag.toString()));
|
||||
}
|
||||
|
||||
private void writeCellTagEnd(boolean isHeader) throws IOException {
|
||||
if (isHeader) {
|
||||
markdownWriter.write(MarkdownSyntax.HTML_TABLE_HEADER_CLOSE_TAG);
|
||||
} else {
|
||||
markdownWriter.write(MarkdownSyntax.HTML_TABLE_CELL_CLOSE_TAG);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.markdown;
|
||||
|
||||
public class MarkdownSyntax {
|
||||
public static final String TABLE_COLUMN_SEPARATOR = "|";
|
||||
public static final String TABLE_HEADER_SEPARATOR = "---";
|
||||
public static final String DOUBLE_LINE_BREAK = "\n\n";
|
||||
public static final String LINE_BREAK = "\n";
|
||||
public static final String SPACE = " ";
|
||||
public static final String INDENT = " ";
|
||||
public static final String HEADING_LEVEL = "#";
|
||||
public static final String LIST_ITEM = "-";
|
||||
public static final String IMAGES_DIRECTORY_SUFFIX = "_images";
|
||||
public static final String IMAGE_FILE_NAME_FORMAT = "%s%simageFile%d.%s";
|
||||
public static final String IMAGE_FORMAT = "";
|
||||
public static final String HTML_TABLE_TAG = "<table>";
|
||||
public static final String HTML_TABLE_CLOSE_TAG = "</table>";
|
||||
public static final String HTML_TABLE_ROW_TAG = "<tr>";
|
||||
public static final String HTML_TABLE_ROW_CLOSE_TAG = "</tr>";
|
||||
public static final String HTML_TABLE_CELL_TAG = "<td>";
|
||||
public static final String HTML_TABLE_CELL_CLOSE_TAG = "</td>";
|
||||
public static final String HTML_TABLE_HEADER_TAG = "<th>";
|
||||
public static final String HTML_TABLE_HEADER_CLOSE_TAG = "</th>";
|
||||
public static final String HTML_ORDERED_LIST_TAG = "<ol style=\"list-style-type:none;\">";
|
||||
public static final String HTML_ORDERED_LIST_CLOSE_TAG = "</ol>";
|
||||
public static final String HTML_UNORDERED_LIST_TAG = "<ul style=\"list-style-type:none;\">";
|
||||
public static final String HTML_UNORDERED_LIST_CLOSE_TAG = "</ul>";
|
||||
public static final String HTML_LIST_ITEM_TAG = "<li>";
|
||||
public static final String HTML_LIST_ITEM_CLOSE_TAG = "</li>";
|
||||
public static final String HTML_LINE_BREAK_TAG = "<br>";
|
||||
public static final String HTML_INDENT = " ";
|
||||
public static final String HTML_PARAGRAPH_TAG = "<p>";
|
||||
public static final String HTML_PARAGRAPH_CLOSE_TAG = "</p>";
|
||||
public static final String MATH_BLOCK_START = "$$";
|
||||
public static final String MATH_BLOCK_END = "$$";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.pdf;
|
||||
|
||||
public enum PDFLayer {
|
||||
CONTENT("content"),
|
||||
TABLE_CELLS("table cells"),
|
||||
LIST_ITEMS("list items"),
|
||||
TOC_ITEMS("toc items"),
|
||||
TABLE_CONTENT("table content"),
|
||||
LIST_CONTENT("list content"),
|
||||
TOC_CONTENT("toc content"),
|
||||
TEXT_BLOCK_CONTENT("text blocks content"),
|
||||
HEADER_AND_FOOTER_CONTENT("header and footer content");
|
||||
|
||||
private final String value;
|
||||
|
||||
PDFLayer(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.pdf;
|
||||
|
||||
import org.opendataloader.pdf.processors.DocumentProcessor;
|
||||
import org.opendataloader.pdf.utils.FileUtils;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.documentinterchange.markedcontent.PDPropertyList;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
|
||||
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentGroup;
|
||||
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentProperties;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationSquare;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationSquareCircle;
|
||||
import org.verapdf.wcag.algorithms.entities.*;
|
||||
import org.verapdf.wcag.algorithms.entities.content.ImageChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.SemanticType;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.MultiBoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListItem;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.PDFList;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class PDFWriter {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(PDFWriter.class.getCanonicalName());
|
||||
|
||||
private final Map<PDFLayer, PDOptionalContentGroup> optionalContents = new HashMap<>();
|
||||
private final List<List<PDAnnotation>> annotations = new ArrayList<>();
|
||||
private final List<BoundingBox> pageBoundingBoxes = new ArrayList<>();
|
||||
|
||||
public void updatePDF(File inputPDF, String password, String outputFolder, List<List<IObject>> contents) throws IOException {
|
||||
try (PDDocument document = Loader.loadPDF(inputPDF, password)) {
|
||||
for (int pageNumber = 0; pageNumber < StaticContainers.getDocument().getNumberOfPages(); pageNumber++) {
|
||||
annotations.add(new ArrayList<>());
|
||||
pageBoundingBoxes.add(DocumentProcessor.getPageBoundingBox(pageNumber));
|
||||
}
|
||||
for (int pageNumber = 0; pageNumber < StaticContainers.getDocument().getNumberOfPages(); pageNumber++) {
|
||||
for (IObject content : contents.get(pageNumber)) {
|
||||
drawContent(content, PDFLayer.CONTENT);
|
||||
}
|
||||
}
|
||||
for (int pageNumber = 0; pageNumber < StaticContainers.getDocument().getNumberOfPages(); pageNumber++) {
|
||||
document.getPage(pageNumber).getAnnotations().addAll(annotations.get(pageNumber));
|
||||
}
|
||||
annotations.clear();
|
||||
pageBoundingBoxes.clear();
|
||||
createOptContentsForAnnotations(document);
|
||||
document.setAllSecurityToBeRemoved(true);
|
||||
|
||||
String outputFileName = outputFolder + File.separator +
|
||||
FileUtils.getBaseName(inputPDF.getName()) + "_annotated.pdf";
|
||||
document.save(outputFileName);
|
||||
LOGGER.log(Level.INFO, "Created {0}", outputFileName);
|
||||
} catch (Exception ex) {
|
||||
LOGGER.log(Level.WARNING, "Unable to create annotated PDF output: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void drawContent(IObject content, PDFLayer layer) throws IOException {
|
||||
drawContent(content, layer, null);
|
||||
}
|
||||
|
||||
private void drawContent(IObject content, PDFLayer layer, Map<Integer, PDAnnotation> linkedAnnots) throws IOException {
|
||||
if ((content instanceof LineChunk)) {
|
||||
return;
|
||||
}
|
||||
Map<Integer, PDAnnotation> annots = draw(content.getBoundingBox(), getColor(content), getContents(content),
|
||||
content.getRecognizedStructureId(), linkedAnnots, content.getLevel(), layer);
|
||||
if (content instanceof TableBorder) {
|
||||
drawTableCells((TableBorder) content, annots);
|
||||
} else if (content instanceof PDFList) {
|
||||
drawListItems((PDFList) content, annots);
|
||||
} else if (content instanceof SemanticTOC) {
|
||||
drawTOCItems((SemanticTOC) content, layer, annots);
|
||||
} else if (content instanceof SemanticHeaderOrFooter) {
|
||||
for (IObject contentItem : ((SemanticHeaderOrFooter) content).getContents()) {
|
||||
drawContent(contentItem, PDFLayer.HEADER_AND_FOOTER_CONTENT, annots);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawTableCells(TableBorder table, Map<Integer, PDAnnotation> annots) throws IOException {
|
||||
if (table.isTextBlock()) {
|
||||
for (IObject content : table.getCell(0, 0).getContents()) {
|
||||
drawContent(content, PDFLayer.TEXT_BLOCK_CONTENT);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (int rowNumber = 0; rowNumber < table.getNumberOfRows(); rowNumber++) {
|
||||
TableBorderRow row = table.getRow(rowNumber);
|
||||
for (int colNumber = 0; colNumber < table.getNumberOfColumns(); colNumber++) {
|
||||
TableBorderCell cell = row.getCell(colNumber);
|
||||
if (cell.getRowNumber() == rowNumber && cell.getColNumber() == colNumber) {
|
||||
StringBuilder contentValue = new StringBuilder();
|
||||
for (IObject object : cell.getContents()) {
|
||||
if (object instanceof SemanticTextNode) {
|
||||
contentValue.append(((SemanticTextNode) object).getValue());
|
||||
}
|
||||
}
|
||||
String cellValue = String.format("Table %scell: row number %s, column number %s, row span %s, column span %s, text content \"%s\"",
|
||||
cell.isHeaderCell() ? "header " : "", cell.getRowNumber() + 1, cell.getColNumber() + 1,
|
||||
cell.getRowSpan(), cell.getColSpan(), contentValue);
|
||||
draw(cell.getBoundingBox(), getColor(SemanticType.TABLE), cellValue, null, annots, cell.getLevel(), PDFLayer.TABLE_CELLS);
|
||||
for (IObject content : cell.getContents()) {
|
||||
drawContent(content, PDFLayer.TABLE_CONTENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawListItems(PDFList list, Map<Integer, PDAnnotation> annots) throws IOException {
|
||||
for (ListItem listItem : list.getListItems()) {
|
||||
String contentValue = String.format("List item: text content \"%s\"", listItem.toString());
|
||||
draw(listItem.getBoundingBox(), getColor(SemanticType.LIST), contentValue, null, annots, listItem.getLevel(), PDFLayer.LIST_ITEMS);
|
||||
for (IObject content : listItem.getContents()) {
|
||||
drawContent(content, PDFLayer.LIST_CONTENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawTOCItems(SemanticTOC toc, PDFLayer layer, Map<Integer, PDAnnotation> annots) throws IOException {
|
||||
for (IObject tocItem : toc.getTOCItems()) {
|
||||
if (tocItem instanceof SemanticTOC) {
|
||||
drawContent(tocItem, layer, annots);
|
||||
} else if (tocItem instanceof SemanticTOCI) {
|
||||
String contentValue = String.format("TOC item: text content \"%s\"", tocItem.toString());
|
||||
draw(tocItem.getBoundingBox(), getColor(SemanticType.TABLE_OF_CONTENT), contentValue, null, annots, tocItem.getLevel(), PDFLayer.TOC_ITEMS);
|
||||
for (IObject content : ((SemanticTOCI)tocItem).getContents()) {
|
||||
drawContent(content, PDFLayer.TOC_CONTENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Map<Integer, PDAnnotation> draw(BoundingBox boundingBox, float[] colorArray, String contents, Long id,
|
||||
Map<Integer, PDAnnotation> linkedAnnots, String level, PDFLayer layerName) {
|
||||
Map<Integer, PDAnnotation> result = new HashMap<>();
|
||||
if (!Objects.equals(boundingBox.getPageNumber(), boundingBox.getLastPageNumber())) {
|
||||
if (boundingBox instanceof MultiBoundingBox) {
|
||||
for (int pageNumber = boundingBox.getPageNumber(); pageNumber <= boundingBox.getLastPageNumber(); pageNumber++) {
|
||||
BoundingBox boundingBoxForPage = boundingBox.getBoundingBox(pageNumber);
|
||||
if (boundingBoxForPage != null) {
|
||||
result.putAll(draw(boundingBoxForPage, colorArray, contents, id, linkedAnnots, level, layerName));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
LOGGER.log(Level.WARNING, "Bounding box on several pages cannot be split");
|
||||
}
|
||||
}
|
||||
|
||||
BoundingBox movedBoundingBox = new BoundingBox(boundingBox);
|
||||
BoundingBox pageBoundingBox = pageBoundingBoxes.get(boundingBox.getPageNumber());
|
||||
if (pageBoundingBox != null) {
|
||||
movedBoundingBox.move(pageBoundingBox.getLeftX(), pageBoundingBox.getBottomY());
|
||||
}
|
||||
PDAnnotationSquareCircle square = new PDAnnotationSquare();
|
||||
square.setRectangle(new PDRectangle(getFloat(movedBoundingBox.getLeftX()), getFloat(movedBoundingBox.getBottomY()),
|
||||
getFloat(movedBoundingBox.getWidth()), getFloat(movedBoundingBox.getHeight())));
|
||||
square.setConstantOpacity(0.4f);
|
||||
PDColor color = new PDColor(colorArray, PDDeviceRGB.INSTANCE);
|
||||
square.setColor(color);
|
||||
square.setInteriorColor(color);
|
||||
square.setContents((id != null ? "id = " + id + ", " : "") + (level != null ? "level = " + level + ", " : "") + contents);
|
||||
if (linkedAnnots != null) {
|
||||
square.setInReplyTo(linkedAnnots.get(boundingBox.getPageNumber()));
|
||||
}
|
||||
square.setOptionalContent(getOptionalContent(layerName));
|
||||
annotations.get(boundingBox.getPageNumber()).add(square);
|
||||
result.put(boundingBox.getPageNumber(), square);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static float getFloat(double value) {
|
||||
float floatValue = (float) value;
|
||||
if (floatValue == Float.POSITIVE_INFINITY) {
|
||||
return Float.MAX_VALUE;
|
||||
}
|
||||
if (floatValue == Float.NEGATIVE_INFINITY) {
|
||||
return -Float.MAX_VALUE;
|
||||
}
|
||||
return floatValue;
|
||||
}
|
||||
|
||||
public static String getContents(IObject content) {
|
||||
if (content instanceof TableBorder) {
|
||||
TableBorder border = (TableBorder) content;
|
||||
if (border.isTextBlock()) {
|
||||
return "Text block";
|
||||
}
|
||||
return String.format("Table: %s rows, %s columns, previous table id %s, next table id %s",
|
||||
border.getNumberOfRows(), border.getNumberOfColumns(), border.getPreviousTableId(), border.getNextTableId());
|
||||
}
|
||||
if (content instanceof PDFList) {
|
||||
PDFList list = (PDFList) content;
|
||||
return String.format("List: number of items %s, previous list id %s, next list id %s",
|
||||
list.getNumberOfListItems(), list.getPreviousListId(), list.getNextListId());
|
||||
}
|
||||
if (content instanceof SemanticTOC) {
|
||||
SemanticTOC toc = (SemanticTOC) content;
|
||||
return String.format("TOC: previous toc id %s, next toc id %s", toc.getPreviousTOCId(), toc.getNextTOCId());
|
||||
}
|
||||
if (content instanceof INode) {
|
||||
INode node = (INode) content;
|
||||
if (node.getSemanticType() == SemanticType.HEADER || node.getSemanticType() == SemanticType.FOOTER) {
|
||||
return node.getSemanticType().getValue();
|
||||
}
|
||||
if (node.getSemanticType() == SemanticType.CAPTION) {
|
||||
SemanticCaption caption = (SemanticCaption) node;
|
||||
return DocumentProcessor.getContentsValueForTextNode(caption) + ", connected with object with id = " +
|
||||
caption.getLinkedContentId();
|
||||
}
|
||||
if (node.getSemanticType() == SemanticType.HEADING) {
|
||||
SemanticHeading heading = (SemanticHeading) node;
|
||||
return DocumentProcessor.getContentsValueForTextNode(heading) +
|
||||
", heading level " + heading.getHeadingLevel();
|
||||
}
|
||||
if (node instanceof SemanticTextNode) {
|
||||
return DocumentProcessor.getContentsValueForTextNode((SemanticTextNode) node);
|
||||
}
|
||||
}
|
||||
if (content instanceof ImageChunk) {
|
||||
return String.format("Image: height %.2f, width %.2f", content.getHeight(), content.getWidth());
|
||||
}
|
||||
if (content instanceof LineArtChunk) {
|
||||
return String.format("Line Art: height %.2f, width %.2f", content.getHeight(), content.getWidth());
|
||||
}
|
||||
if (content instanceof LineChunk) {
|
||||
return "Line";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public static float[] getColor(IObject content) {
|
||||
if (content instanceof TableBorder) {
|
||||
return getColor(SemanticType.TABLE);
|
||||
}
|
||||
if (content instanceof PDFList) {
|
||||
return getColor(SemanticType.LIST);
|
||||
}
|
||||
if (content instanceof SemanticTOC) {
|
||||
return getColor(SemanticType.TABLE_OF_CONTENT);
|
||||
}
|
||||
if (content instanceof INode) {
|
||||
INode node = (INode) content;
|
||||
return getColor(node.getSemanticType());
|
||||
}
|
||||
if (content instanceof ImageChunk) {
|
||||
return getColor(SemanticType.FIGURE);
|
||||
}
|
||||
if (content instanceof LineArtChunk || content instanceof LineChunk) {
|
||||
return getColor(SemanticType.PART);
|
||||
}
|
||||
return new float[]{};
|
||||
}
|
||||
|
||||
public static float[] getColor(SemanticType semanticType) {
|
||||
if (semanticType == SemanticType.HEADING || semanticType == SemanticType.HEADER || semanticType == SemanticType.FOOTER) {
|
||||
return new float[]{0, 0, 1};
|
||||
}
|
||||
if (semanticType == SemanticType.LIST) {
|
||||
return new float[]{0, 1, 0};
|
||||
}
|
||||
if (semanticType == SemanticType.PARAGRAPH) {
|
||||
return new float[]{0, 1, 1};
|
||||
}
|
||||
if (semanticType == SemanticType.FIGURE) {
|
||||
return new float[]{1, 0, 0};
|
||||
}
|
||||
if (semanticType == SemanticType.TABLE) {
|
||||
return new float[]{1, 0, 1};
|
||||
}
|
||||
if (semanticType == SemanticType.TABLE_OF_CONTENT) {
|
||||
return new float[]{0.8f, 0.2f, 0.8f};
|
||||
}
|
||||
if (semanticType == SemanticType.CAPTION) {
|
||||
return new float[]{1, 1, 0};
|
||||
}
|
||||
if (semanticType == SemanticType.PART) {
|
||||
return new float[]{0.9f, 0.9f, 0.9f};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void createOptContentsForAnnotations(PDDocument document) {
|
||||
if (optionalContents.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
PDOptionalContentProperties oldOCProperties = catalog.getOCProperties();
|
||||
if (oldOCProperties == null) {
|
||||
oldOCProperties = new PDOptionalContentProperties();
|
||||
catalog.setOCProperties(oldOCProperties);
|
||||
}
|
||||
|
||||
for (PDOptionalContentGroup group : optionalContents.values()) {
|
||||
oldOCProperties.addGroup(group);
|
||||
oldOCProperties.setGroupEnabled(group, true);
|
||||
}
|
||||
optionalContents.clear();
|
||||
}
|
||||
|
||||
public PDOptionalContentGroup getOptionalContent(PDFLayer layer) {
|
||||
PDOptionalContentGroup group = optionalContents.get(layer);
|
||||
if (group == null) {
|
||||
COSDictionary cosDictionary = new COSDictionary();
|
||||
cosDictionary.setItem(COSName.TYPE, COSName.OCG);
|
||||
cosDictionary.setItem(COSName.NAME, new COSString(layer.getValue()));
|
||||
group = (PDOptionalContentGroup) PDPropertyList.create(cosDictionary);
|
||||
optionalContents.put(layer, group);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.TableBordersCollection;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.NodeUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.SortedSet;
|
||||
|
||||
/**
|
||||
* Abstract base class for table detection processors.
|
||||
* Provides common functionality for detecting and processing tables in PDF documents.
|
||||
*/
|
||||
public abstract class AbstractTableProcessor {
|
||||
|
||||
private static final double Y_DIFFERENCE_EPSILON = 0.1;
|
||||
private static final double X_DIFFERENCE_EPSILON = 3;
|
||||
private static final double TABLE_INTERSECTION_PERCENT = 0.01;
|
||||
|
||||
/**
|
||||
* Processes tables across all pages that may contain tables.
|
||||
*
|
||||
* @param contents the document contents organized by page
|
||||
*/
|
||||
public void processTables(List<List<IObject>> contents) {
|
||||
List<Integer> pageNumbers = getPagesWithPossibleTables(contents);
|
||||
processTables(contents, pageNumbers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes tables on specified pages.
|
||||
*
|
||||
* @param contents the document contents organized by page
|
||||
* @param pageNumbers the list of page numbers to process
|
||||
*/
|
||||
public void processTables(List<List<IObject>> contents, List<Integer> pageNumbers) {
|
||||
if (!pageNumbers.isEmpty()) {
|
||||
List<List<TableBorder>> tables = getTables(contents, pageNumbers);
|
||||
addTablesToTableCollection(tables, pageNumbers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects tables on the specified pages.
|
||||
*
|
||||
* @param contents the document contents organized by page
|
||||
* @param pageNumbers the list of page numbers to process
|
||||
* @return a list of detected tables for each page
|
||||
*/
|
||||
protected abstract List<List<TableBorder>> getTables(List<List<IObject>> contents, List<Integer> pageNumbers);
|
||||
|
||||
private static void addTablesToTableCollection(List<List<TableBorder>> detectedTables, List<Integer> pageNumbers) {
|
||||
if (detectedTables != null) {
|
||||
TableBordersCollection tableCollection = StaticContainers.getTableBordersCollection();
|
||||
for (int index = 0; index < pageNumbers.size(); index++) {
|
||||
SortedSet<TableBorder> tables = tableCollection.getTableBorders(pageNumbers.get(index));
|
||||
for (TableBorder border : detectedTables.get(index)) {
|
||||
boolean hasIntersections = false;
|
||||
for (TableBorder table : tables) {
|
||||
if (table.getBoundingBox().getIntersectionPercent(border.getBoundingBox()) > TABLE_INTERSECTION_PERCENT) {
|
||||
hasIntersections = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasIntersections) {
|
||||
tables.add(border);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies pages that may contain tables based on text chunk patterns.
|
||||
*
|
||||
* @param contents the document contents organized by page
|
||||
* @return a list of page numbers that may contain tables
|
||||
*/
|
||||
public static List<Integer> getPagesWithPossibleTables(List<List<IObject>> contents) {
|
||||
List<Integer> pageNumbers = new ArrayList<>();
|
||||
for (int pageNumber = 0; pageNumber < StaticContainers.getDocument().getNumberOfPages(); pageNumber++) {
|
||||
TextChunk previousTextChunk = null;
|
||||
for (IObject content : contents.get(pageNumber)) {
|
||||
if (content instanceof TextChunk) {
|
||||
TextChunk currentTextChunk = (TextChunk) content;
|
||||
if (currentTextChunk.isWhiteSpaceChunk()) {
|
||||
continue;
|
||||
}
|
||||
if (previousTextChunk != null && areSuspiciousTextChunks(previousTextChunk, currentTextChunk)) {
|
||||
pageNumbers.add(pageNumber);
|
||||
break;
|
||||
}
|
||||
previousTextChunk = currentTextChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
return pageNumbers;
|
||||
}
|
||||
|
||||
private static boolean areSuspiciousTextChunks(TextChunk previousTextChunk, TextChunk currentTextChunk) {
|
||||
if (previousTextChunk.getTopY() < currentTextChunk.getBottomY()) {
|
||||
return true;
|
||||
}
|
||||
if (NodeUtils.areCloseNumbers(previousTextChunk.getBaseLine(), currentTextChunk.getBaseLine(),
|
||||
currentTextChunk.getHeight() * Y_DIFFERENCE_EPSILON)) {
|
||||
if (currentTextChunk.getLeftX() - previousTextChunk.getRightX() >
|
||||
currentTextChunk.getHeight() * X_DIFFERENCE_EPSILON) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+1178
File diff suppressed because it is too large
Load Diff
+151
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticCaption;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticFigure;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTextNode;
|
||||
import org.verapdf.wcag.algorithms.entities.content.ImageChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.CaptionUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.NodeUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Processor for detecting and linking captions to figures and tables.
|
||||
* Identifies text nodes that are likely captions based on proximity and content.
|
||||
*/
|
||||
public class CaptionProcessor {
|
||||
|
||||
private static final double CAPTION_PROBABILITY = 0.75;
|
||||
|
||||
private static final double CAPTION_VERTICAL_OFFSET_RATIO = 1;
|
||||
private static final double CAPTION_HORIZONTAL_OFFSET_RATIO = 1;
|
||||
private static final double SUBTLE_IMAGE_RATIO_THRESHOLD = 0.01;
|
||||
|
||||
/**
|
||||
* Processes content to identify and link captions to images and tables.
|
||||
*
|
||||
* @param contents the list of content objects to process
|
||||
*/
|
||||
public static void processCaptions(List<IObject> contents) {
|
||||
DocumentProcessor.setIndexesForContentsList(contents);
|
||||
SemanticFigure imageNode = null;
|
||||
SemanticTextNode lastTextNode = null;
|
||||
for (IObject content : contents) {
|
||||
if (content == null) {
|
||||
continue;
|
||||
}
|
||||
if (content instanceof SemanticTextNode) {
|
||||
SemanticTextNode textNode = (SemanticTextNode) content;
|
||||
if (textNode.isSpaceNode() || textNode.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (imageNode != null && isTextNotContainedInImage(imageNode, textNode)) {
|
||||
acceptImageCaption(contents, imageNode, lastTextNode, textNode);
|
||||
imageNode = null;
|
||||
}
|
||||
|
||||
lastTextNode = textNode;
|
||||
} else if (content instanceof ImageChunk && !isImageSubtle((ImageChunk) content)) {
|
||||
if (imageNode != null && isTextNotContainedInImage(imageNode, lastTextNode)) {
|
||||
acceptImageCaption(contents, imageNode, lastTextNode, null);
|
||||
lastTextNode = null;
|
||||
}
|
||||
imageNode = new SemanticFigure((ImageChunk) content);
|
||||
imageNode.setRecognizedStructureId(content.getRecognizedStructureId());
|
||||
} else if (content instanceof TableBorder && !((TableBorder) content).isTextBlock()) {
|
||||
if (imageNode != null && isTextNotContainedInImage(imageNode, lastTextNode)) {
|
||||
acceptImageCaption(contents, imageNode, lastTextNode, null);
|
||||
lastTextNode = null;
|
||||
}
|
||||
ImageChunk imageChunk = new ImageChunk(content.getBoundingBox());
|
||||
imageChunk.setRecognizedStructureId(content.getRecognizedStructureId());
|
||||
imageNode = new SemanticFigure(imageChunk);
|
||||
imageNode.setRecognizedStructureId(content.getRecognizedStructureId());
|
||||
}
|
||||
}
|
||||
if (imageNode != null) {
|
||||
acceptImageCaption(contents, imageNode, lastTextNode, null);
|
||||
}
|
||||
// for (IObject content1 : contents) {
|
||||
// if (content1 instanceof SemanticTextNode) {
|
||||
// SemanticTextNode textNode = (SemanticTextNode)content1;
|
||||
// for (IObject content2 : contents) {
|
||||
// if (content2 instanceof ImageChunk) {
|
||||
// SemanticFigure imageNode = new SemanticFigure((ImageChunk) content2);
|
||||
// acceptImageCaption(imageNode, textNode, textNode);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
private static boolean isImageSubtle(ImageChunk imageChunk) {
|
||||
double imageHeight = imageChunk.getHeight();
|
||||
double imageWidth = imageChunk.getWidth();
|
||||
if (NodeUtils.areCloseNumbers(imageWidth, 0) || NodeUtils.areCloseNumbers(imageHeight, 0)) {
|
||||
return true;
|
||||
}
|
||||
double aspectRatio = Math.min(imageWidth, imageHeight) / Math.max(imageWidth, imageHeight);
|
||||
return aspectRatio < SUBTLE_IMAGE_RATIO_THRESHOLD;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if a text node is not contained within an image's bounding box.
|
||||
*
|
||||
* @param image the image to check against
|
||||
* @param text the text node to check
|
||||
* @return true if the text is outside the image bounds, false otherwise
|
||||
*/
|
||||
public static boolean isTextNotContainedInImage(SemanticFigure image, SemanticTextNode text) {
|
||||
if (text == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
double textSize = text.getFontSize();
|
||||
return !image.getBoundingBox().contains(text.getBoundingBox(),
|
||||
textSize * CAPTION_HORIZONTAL_OFFSET_RATIO,
|
||||
textSize * CAPTION_VERTICAL_OFFSET_RATIO);
|
||||
}
|
||||
|
||||
private static void acceptImageCaption(List<IObject> contents, SemanticFigure imageNode,
|
||||
SemanticTextNode previousNode, SemanticTextNode nextNode) {
|
||||
if (imageNode.getImages().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
double previousCaptionProbability = CaptionUtils.imageCaptionProbability(previousNode, imageNode);
|
||||
double nextCaptionProbability = CaptionUtils.imageCaptionProbability(nextNode, imageNode);
|
||||
double captionProbability;
|
||||
SemanticTextNode captionNode;
|
||||
if (previousCaptionProbability > nextCaptionProbability) {
|
||||
captionProbability = previousCaptionProbability;
|
||||
captionNode = previousNode;
|
||||
} else {
|
||||
captionProbability = nextCaptionProbability;
|
||||
captionNode = nextNode;
|
||||
}
|
||||
if (captionProbability >= CAPTION_PROBABILITY) {
|
||||
SemanticCaption semanticCaption = new SemanticCaption(captionNode);
|
||||
contents.set(captionNode.getIndex(), semanticCaption);
|
||||
semanticCaption.setLinkedContentId(imageNode.getRecognizedStructureId());
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTextNode;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.SemanticType;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.Table;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.TableToken;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.consumers.ClusterTableConsumer;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.TextChunkUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Table processor that uses clustering algorithms to detect tables.
|
||||
* Identifies tables by analyzing spatial relationships between text chunks.
|
||||
*/
|
||||
public class ClusterTableProcessor extends AbstractTableProcessor {
|
||||
|
||||
@Override
|
||||
protected List<List<TableBorder>> getTables(List<List<IObject>> contents, List<Integer> pageNumbers) {
|
||||
List<List<TableBorder>> tables = new ArrayList<>();
|
||||
for (int pageNumber : pageNumbers) {
|
||||
tables.add(processClusterDetectionTables(contents.get(pageNumber)));
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects tables on a single page using cluster-based detection.
|
||||
*
|
||||
* @param contents the page contents to analyze
|
||||
* @return a list of detected table borders
|
||||
*/
|
||||
public static List<TableBorder> processClusterDetectionTables(List<IObject> contents) {
|
||||
ClusterTableConsumer clusterTableConsumer = new ClusterTableConsumer();
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof TextChunk) {
|
||||
TextChunk textChunk = (TextChunk) content;
|
||||
if (textChunk.isWhiteSpaceChunk() || textChunk.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
List<TextChunk> splitChunks = TextChunkUtils.splitTextChunkByWhiteSpaces(textChunk);
|
||||
for (TextChunk splitChunk : splitChunks) {
|
||||
SemanticTextNode semanticTextNode = new SemanticTextNode(splitChunk);
|
||||
clusterTableConsumer.accept(new TableToken(splitChunk, semanticTextNode), semanticTextNode);
|
||||
}
|
||||
// } else if (content instanceof ImageChunk) {
|
||||
// SemanticFigure semanticFigure = new SemanticFigure((ImageChunk) content);
|
||||
// clusterTableConsumer.accept(new TableToken((ImageChunk) content, semanticFigure), semanticFigure);
|
||||
}
|
||||
}
|
||||
clusterTableConsumer.processEnd();
|
||||
List<TableBorder> result = new ArrayList<>();
|
||||
for (Table table : clusterTableConsumer.getTables()) {
|
||||
TableBorder tableBorder = table.createTableBorderFromTable();
|
||||
if (tableBorder != null) {
|
||||
setTableCellsSemanticTypes(tableBorder);
|
||||
result.add(tableBorder);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void setTableCellsSemanticTypes(TableBorder table) {
|
||||
for (int rowNumber = 0; rowNumber < table.getNumberOfRows(); rowNumber++) {
|
||||
for (int colNumber = 0; colNumber < table.getNumberOfColumns(); colNumber++) {
|
||||
TableBorderCell cell = table.getCell(rowNumber, colNumber);
|
||||
if (cell.getColNumber() == colNumber && cell.getRowNumber() == rowNumber) {
|
||||
cell.setSemanticType(rowNumber == 0 ? SemanticType.TABLE_HEADER : SemanticType.TABLE_CELL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// public static void findListAndTablesImageMethod(List<SemanticTextNode> nodes) {
|
||||
// ClusterTableConsumer clusterTableConsumer = new ClusterTableConsumer();
|
||||
// for (SemanticTextNode textNode : nodes) {
|
||||
// ImageChunk imageChunk = new ImageChunk(textNode.getBoundingBox());
|
||||
// SemanticFigure figure = new SemanticFigure(imageChunk);
|
||||
// clusterTableConsumer.accept(new TableToken(imageChunk, figure), figure);
|
||||
//// if (chunk instanceof TextChunk) {
|
||||
//// SemanticTextNode semanticTextNode = new SemanticTextNode((TextChunk)chunk);
|
||||
//// clusterTableConsumer.accept(new TableToken((TextChunk)chunk, semanticTextNode), semanticTextNode);
|
||||
//// } else if (chunk instanceof ImageChunk) {
|
||||
//// SemanticFigure semanticFigure = new SemanticFigure((ImageChunk) chunk);
|
||||
//// clusterTableConsumer.accept(new TableToken((ImageChunk)chunk, semanticFigure), semanticFigure);
|
||||
//// }
|
||||
//
|
||||
// }
|
||||
// // if (recognitionArea.isValid()) {
|
||||
//// List<INode> restNodes = new ArrayList<>(recognize());
|
||||
//// init();
|
||||
//// restNodes.add(root);
|
||||
//// for (INode restNode : restNodes) {
|
||||
//// accept(restNode);
|
||||
//// }
|
||||
//// }
|
||||
// clusterTableConsumer.processEnd();
|
||||
// System.out.println("test");
|
||||
// }
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.content.IChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.TextChunkUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Processor for filtering and cleaning PDF content.
|
||||
* Removes hidden text, out-of-page content, backgrounds, and other artifacts.
|
||||
*/
|
||||
public class ContentFilterProcessor {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(ContentFilterProcessor.class.getCanonicalName());
|
||||
|
||||
/**
|
||||
* Filters and cleans page contents based on configuration.
|
||||
*
|
||||
* @param inputPdfName the path to the PDF file
|
||||
* @param contents the raw page contents
|
||||
* @param pageNumber the page number (0-indexed)
|
||||
* @param config the configuration settings
|
||||
* @return the filtered list of content objects
|
||||
* @throws IOException if unable to process the content
|
||||
*/
|
||||
public static List<IObject> getFilteredContents(String inputPdfName, List<IChunk> contents, int pageNumber,
|
||||
Config config) throws IOException {
|
||||
List<IObject> pageContents = new ArrayList<>(contents);
|
||||
TextProcessor.removeSameTextChunks(pageContents);
|
||||
pageContents = DocumentProcessor.removeNullObjectsFromList(pageContents);
|
||||
TextProcessor.removeTextDecorationImages(pageContents);
|
||||
pageContents = DocumentProcessor.removeNullObjectsFromList(pageContents);
|
||||
if (config.getFilterConfig().isFilterTinyText()) {
|
||||
TextProcessor.filterTinyText(pageContents);
|
||||
pageContents = DocumentProcessor.removeNullObjectsFromList(pageContents);
|
||||
}
|
||||
if (config.getFilterConfig().isFilterOutOfPage()) {
|
||||
filterOutOfPageContents(pageNumber, pageContents);
|
||||
pageContents = DocumentProcessor.removeNullObjectsFromList(pageContents);
|
||||
}
|
||||
TextProcessor.mergeCloseTextChunks(pageContents);
|
||||
pageContents = DocumentProcessor.removeNullObjectsFromList(pageContents);
|
||||
TextProcessor.trimTextChunksWhiteSpaces(pageContents);
|
||||
filterConsecutiveSpaces(pageContents);
|
||||
pageContents = splitTextChunksByWhiteSpacesInPageContents(pageContents);
|
||||
// HiddenText detection moved to DocumentProcessor (sequential post-processing)
|
||||
// to avoid ContrastRatioConsumer per-thread PDF rendering overhead
|
||||
double replacementCharRatio = TextProcessor.measureReplacementCharRatio(pageContents);
|
||||
StaticLayoutContainers.setReplacementCharRatio(pageNumber, replacementCharRatio);
|
||||
if (replacementCharRatio >= 0.3) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"Page {0}: {1,number,#.#%} of characters are replacement characters (U+FFFD). "
|
||||
+ "This PDF likely contains CID-keyed fonts without ToUnicode mappings. "
|
||||
+ "Text extraction may be incomplete. Consider enabling hybrid OCR fallback with --hybrid docling-fast.",
|
||||
new Object[]{pageNumber + 1, replacementCharRatio});
|
||||
}
|
||||
TextProcessor.replaceUndefinedCharacters(pageContents, config.getReplaceInvalidChars());
|
||||
processBackgrounds(pageNumber, pageContents);
|
||||
return pageContents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and removes background elements from page contents.
|
||||
*
|
||||
* @param pageNumber the page number (0-indexed)
|
||||
* @param contents the page contents to process
|
||||
*/
|
||||
public static void processBackgrounds(int pageNumber, List<IObject> contents) {
|
||||
BoundingBox pageBoundingBox = DocumentProcessor.getPageBoundingBox(pageNumber);
|
||||
if (pageBoundingBox == null) {
|
||||
return;
|
||||
}
|
||||
Set<LineArtChunk> backgrounds = new HashSet<>();
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof LineArtChunk) {
|
||||
if (isBackground(content, pageBoundingBox)) {
|
||||
backgrounds.add((LineArtChunk) content);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!backgrounds.isEmpty()) {
|
||||
LOGGER.log(Level.WARNING, "Detected background on page " + pageNumber);
|
||||
contents.removeAll(backgrounds);
|
||||
}
|
||||
}
|
||||
|
||||
private static void filterConsecutiveSpaces(List<IObject> pageContents) {
|
||||
for (IObject object : pageContents) {
|
||||
if (object instanceof TextChunk) {
|
||||
((TextChunk) object).compressSpaces();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBackground(IObject content, BoundingBox pageBoundingBox) {
|
||||
return (content.getBoundingBox().getWidth() > 0.5 * pageBoundingBox.getWidth() &&
|
||||
content.getBoundingBox().getHeight() > 0.1 * pageBoundingBox.getHeight()) ||
|
||||
(content.getBoundingBox().getWidth() > 0.1 * pageBoundingBox.getWidth() &&
|
||||
content.getBoundingBox().getHeight() > 0.5 * pageBoundingBox.getHeight());
|
||||
}
|
||||
|
||||
private static void filterOutOfPageContents(int pageNumber, List<IObject> contents) {
|
||||
BoundingBox pageBoundingBox = DocumentProcessor.getPageBoundingBox(pageNumber);
|
||||
if (pageBoundingBox == null) {
|
||||
return;
|
||||
}
|
||||
pageBoundingBox.move(-pageBoundingBox.getLeftX(), -pageBoundingBox.getBottomY());
|
||||
for (int index = 0; index < contents.size(); index++) {
|
||||
IObject object = contents.get(index);
|
||||
if (object != null && pageBoundingBox.notOverlaps(object.getBoundingBox())) {
|
||||
contents.set(index, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<IObject> splitTextChunksByWhiteSpacesInPageContents(List<IObject> contents) {
|
||||
List<IObject> newContents = new ArrayList<>();
|
||||
for (IObject object : contents) {
|
||||
if (object instanceof TextChunk) {
|
||||
TextChunk textChunk = (TextChunk) object;
|
||||
List<TextChunk> splitChunks = TextChunkUtils.splitTextChunkByWhiteSpaces(textChunk);
|
||||
newContents.addAll(splitChunks);
|
||||
} else {
|
||||
newContents.add(object);
|
||||
}
|
||||
}
|
||||
return newContents;
|
||||
}
|
||||
}
|
||||
+882
@@ -0,0 +1,882 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.opendataloader.pdf.hybrid.ElementMetadata;
|
||||
import org.opendataloader.pdf.processors.readingorder.XYCutPlusPlusSorter;
|
||||
import org.opendataloader.pdf.json.JsonWriter;
|
||||
import org.opendataloader.pdf.markdown.MarkdownGenerator;
|
||||
import org.opendataloader.pdf.markdown.MarkdownGeneratorFactory;
|
||||
import org.opendataloader.pdf.markdown.MarkdownSyntax;
|
||||
import org.opendataloader.pdf.html.HtmlGenerator;
|
||||
import org.opendataloader.pdf.html.HtmlGeneratorFactory;
|
||||
import org.opendataloader.pdf.pdf.PDFWriter;
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
import org.opendataloader.pdf.text.TextGenerator;
|
||||
import org.opendataloader.pdf.utils.ContentSanitizer;
|
||||
import org.opendataloader.pdf.utils.FileUtils;
|
||||
import org.opendataloader.pdf.utils.ImagesUtils;
|
||||
import org.opendataloader.pdf.utils.TextNodeUtils;
|
||||
import org.verapdf.as.ASAtom;
|
||||
import org.verapdf.containers.StaticCoreContainers;
|
||||
import org.verapdf.cos.COSDictionary;
|
||||
import org.verapdf.cos.COSObjType;
|
||||
import org.verapdf.cos.COSObject;
|
||||
import org.verapdf.cos.COSTrailer;
|
||||
import org.verapdf.exceptions.InvalidPasswordException;
|
||||
import org.verapdf.gf.model.impl.containers.StaticStorages;
|
||||
import org.verapdf.gf.model.impl.cos.GFCosInfo;
|
||||
import org.verapdf.gf.model.impl.sa.GFSAPDFDocument;
|
||||
import org.verapdf.parser.PDFFlavour;
|
||||
import org.verapdf.pd.PDDocument;
|
||||
import org.verapdf.tools.StaticResources;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTextNode;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.TableBordersCollection;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.consumers.LinesPreprocessingConsumer;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
import org.verapdf.xmp.containers.StaticXmpCoreContainers;
|
||||
|
||||
import org.opendataloader.pdf.exceptions.InvalidPdfFileException;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Main processor for PDF document analysis and output generation.
|
||||
* Coordinates the extraction, processing, and generation of various output formats.
|
||||
*/
|
||||
public class DocumentProcessor {
|
||||
private static final Logger LOGGER = Logger.getLogger(DocumentProcessor.class.getCanonicalName());
|
||||
|
||||
/**
|
||||
* Releases PDF resources to prevent file locks and memory leaks.
|
||||
* - Closes PDDocument to free OS file handles (required for file deletion)
|
||||
* - Clears static containers to remove lingering references
|
||||
* Should always be called in a finally block.
|
||||
*/
|
||||
private static void closePdfResources() {
|
||||
clearCleanupStep("PDDocument", () -> {
|
||||
PDDocument document = StaticResources.getDocument();
|
||||
if (document != null) {
|
||||
document.close();
|
||||
}
|
||||
});
|
||||
clearCleanupStep("ImagesUtils", StaticContainers::closeImagesUtils);
|
||||
|
||||
clearCleanupStep("StaticResources", StaticResources::clear);
|
||||
clearCleanupStep("StaticContainers", () -> StaticContainers.updateContainers(null));
|
||||
clearCleanupStep(
|
||||
"GFStaticContainers",
|
||||
org.verapdf.gf.model.impl.containers.StaticContainers::clearAllContainers
|
||||
);
|
||||
clearCleanupStep("StaticLayoutContainers", StaticLayoutContainers::clearContainers);
|
||||
clearCleanupStep("StaticStorages", StaticStorages::clearAllContainers);
|
||||
clearCleanupStep("StaticCoreContainers", StaticCoreContainers::clearAllContainers);
|
||||
clearCleanupStep("StaticXmpCoreContainers", StaticXmpCoreContainers::clearAllContainers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a cleanup step safely without interrupting subsequent steps.
|
||||
*
|
||||
* Each cleanup action is isolated so that a failure in one step
|
||||
* does not prevent the remaining cleanup operations from running.
|
||||
* Errors are logged for debugging purposes.
|
||||
*/
|
||||
private static void clearCleanupStep(String name, Runnable cleanup) {
|
||||
try {
|
||||
cleanup.run();
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.WARNING, "Error clearing " + name, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a PDF file and generates the configured outputs.
|
||||
*
|
||||
* @param inputPdfName the path to the input PDF file
|
||||
* @param config the configuration settings
|
||||
* @throws IOException if unable to process the file
|
||||
*/
|
||||
public static void processFile(String inputPdfName, Config config) throws IOException {
|
||||
processFileWithResult(inputPdfName, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a PDF file and returns a {@link ProcessingResult} containing
|
||||
* metadata collected during processing (e.g., hybrid server timings).
|
||||
*
|
||||
* @param inputPdfName the path to the input PDF file
|
||||
* @param config the configuration settings
|
||||
* @return processing result with optional metadata
|
||||
* @throws IOException if unable to process the file
|
||||
*/
|
||||
public static ProcessingResult processFileWithResult(String inputPdfName, Config config) throws IOException {
|
||||
try {
|
||||
// Phase 1: Extract
|
||||
ExtractionResult extraction = extractContents(inputPdfName, config);
|
||||
|
||||
// Phase 2: Output (JSON/MD/HTML/PDF/Text)
|
||||
long t0 = System.nanoTime();
|
||||
generateOutputs(inputPdfName, extraction.getContents(), config, extraction.getElementMetadata());
|
||||
long outputNs = System.nanoTime() - t0;
|
||||
|
||||
return new ProcessingResult(extraction.getHybridTimings(), extraction.getExtractionNs(), outputNs);
|
||||
} finally {
|
||||
// Always release resources, even if processing threw. closePdfResources
|
||||
// logs and swallows per-step failures so cleanup cannot mask the original
|
||||
// processing exception.
|
||||
closePdfResources();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the extraction pipeline only (preprocessing + content extraction + sanitization).
|
||||
* Does not generate any output files. The returned {@link ExtractionResult} can be
|
||||
* passed to {@link org.opendataloader.pdf.api.AutoTagger} or used to generate
|
||||
* specific output formats.
|
||||
*
|
||||
* <p>Structured processing (headings, lists, tables, captions) is always enabled
|
||||
* because auto-tagging and all structured output formats depend on it.
|
||||
*
|
||||
* @param inputPdfName path to the input PDF file
|
||||
* @param config configuration
|
||||
* @return extraction result with contents and timing metadata
|
||||
*/
|
||||
public static ExtractionResult extractContents(String inputPdfName, Config config) throws IOException {
|
||||
long t0 = System.nanoTime();
|
||||
preprocessing(inputPdfName, config);
|
||||
calculateDocumentInfo();
|
||||
Set<Integer> pagesToProcess = getValidPageNumbers(config);
|
||||
List<List<IObject>> contents;
|
||||
if (StaticLayoutContainers.isUseStructTree()) {
|
||||
contents = TaggedDocumentProcessor.processDocument(inputPdfName, config, pagesToProcess);
|
||||
} else if (config.isHybridEnabled()) {
|
||||
contents = HybridDocumentProcessor.processDocument(inputPdfName, config, pagesToProcess);
|
||||
} else {
|
||||
contents = processDocument(inputPdfName, config, pagesToProcess);
|
||||
}
|
||||
sortContents(contents, config);
|
||||
ContentSanitizer contentSanitizer = new ContentSanitizer(config.getFilterConfig().getFilterRules(),
|
||||
config.getFilterConfig().isFilterSensitiveData());
|
||||
contentSanitizer.sanitizeContents(contents);
|
||||
long extractionNs = System.nanoTime() - t0;
|
||||
|
||||
// Re-key metadata by actual IObject IDs in contents.
|
||||
// After enrichment, IObject recognizedStructureIds may differ from transformer-assigned IDs.
|
||||
// Match metadata to IObjects by bbox proximity.
|
||||
Map<Long, ElementMetadata> rawMetadata = HybridDocumentProcessor.getLastElementMetadata();
|
||||
Map<Long, ElementMetadata> remappedMetadata = remapMetadataToContents(rawMetadata, contents);
|
||||
|
||||
return new ExtractionResult(contents, extractionNs, HybridDocumentProcessor.getLastHybridTimings(),
|
||||
remappedMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and filters page numbers from config against actual document pages.
|
||||
* Logs warnings for pages that don't exist in the document.
|
||||
*
|
||||
* @param config the configuration containing page selection
|
||||
* @return Set of valid 0-indexed page numbers to process, or null for all pages
|
||||
*/
|
||||
private static Set<Integer> getValidPageNumbers(Config config) {
|
||||
List<Integer> requestedPages = config.getPageNumbers();
|
||||
if (requestedPages.isEmpty()) {
|
||||
return null; // null means process all pages
|
||||
}
|
||||
|
||||
int totalPages = StaticContainers.getDocument().getNumberOfPages();
|
||||
Set<Integer> validPages = new LinkedHashSet<>();
|
||||
List<Integer> invalidPages = new ArrayList<>();
|
||||
|
||||
for (Integer page : requestedPages) {
|
||||
int zeroIndexed = page - 1; // Convert 1-based to 0-based
|
||||
if (zeroIndexed >= 0 && zeroIndexed < totalPages) {
|
||||
validPages.add(zeroIndexed);
|
||||
} else {
|
||||
invalidPages.add(page);
|
||||
}
|
||||
}
|
||||
|
||||
if (!invalidPages.isEmpty()) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"Requested pages {0} do not exist in document (total pages: {1}). Processing only existing pages: {2}",
|
||||
new Object[]{invalidPages, totalPages,
|
||||
validPages.stream().map(p -> p + 1).collect(Collectors.toList())});
|
||||
}
|
||||
|
||||
if (validPages.isEmpty()) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"No valid pages to process. Document has {0} pages but requested: {1}",
|
||||
new Object[]{totalPages, requestedPages});
|
||||
}
|
||||
|
||||
return validPages;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<List<IObject>> processDocument(String inputPdfName, Config config, Set<Integer> pagesToProcess) throws IOException {
|
||||
int totalPages = StaticContainers.getDocument().getNumberOfPages();
|
||||
List<List<IObject>> contents = new ArrayList<>(Collections.nCopies(totalPages, null));
|
||||
|
||||
// Capture ALL ThreadLocal state from main thread for propagation to workers
|
||||
final var document = StaticContainers.getDocument();
|
||||
final var pdDocument = StaticResources.getDocument();
|
||||
final var tableBordersCollection = StaticContainers.getTableBordersCollection();
|
||||
final var accumulatedNodeMapper = StaticContainers.getAccumulatedNodeMapper();
|
||||
final var objectKeyMapper = StaticContainers.getObjectKeyMapper();
|
||||
final var linesCollection = StaticContainers.getLinesCollection();
|
||||
final boolean keepLineBreaks = StaticContainers.isKeepLineBreaks();
|
||||
final boolean isDataLoader = StaticContainers.isDataLoader();
|
||||
final var isIgnoreCharsWithoutUnicode = StaticContainers.getIsIgnoreCharactersWithoutUnicode();
|
||||
|
||||
// Capture StaticLayoutContainers state (shared mutable — synchronized list for headings)
|
||||
final var headings = StaticLayoutContainers.getHeadings();
|
||||
final long contentId = StaticLayoutContainers.getCurrentContentId();
|
||||
final boolean useStructTree = StaticLayoutContainers.isUseStructTree();
|
||||
final var embeddedImageBytesMap = StaticLayoutContainers.getEmbeddedImageBytesMap();
|
||||
|
||||
// Runnable that propagates ThreadLocal state to the current (worker) thread
|
||||
final Runnable propagateState = () -> {
|
||||
StaticResources.setDocument(pdDocument);
|
||||
// veraPDF StaticContainers
|
||||
StaticContainers.setDocument(document);
|
||||
StaticContainers.setTableBordersCollection(tableBordersCollection);
|
||||
StaticContainers.setAccumulatedNodeMapper(accumulatedNodeMapper);
|
||||
StaticContainers.setObjectKeyMapper(objectKeyMapper);
|
||||
StaticContainers.setLinesCollection(linesCollection);
|
||||
StaticContainers.setKeepLineBreaks(keepLineBreaks);
|
||||
StaticContainers.setIsDataLoader(isDataLoader);
|
||||
StaticContainers.setIsIgnoreCharactersWithoutUnicode(isIgnoreCharsWithoutUnicode);
|
||||
StaticContainers.setFileName(inputPdfName);
|
||||
StaticContainers.setPassword(config.getPassword());
|
||||
// Project StaticLayoutContainers — share the same headings list across workers
|
||||
StaticLayoutContainers.setHeadings(headings);
|
||||
StaticLayoutContainers.setCurrentContentId(contentId);
|
||||
StaticLayoutContainers.setIsUseStructTree(useStructTree);
|
||||
StaticLayoutContainers.setEmbeddedImageBytesMap(embeddedImageBytesMap);
|
||||
};
|
||||
|
||||
// Pre-fetch all page artifacts on main thread (document access is ThreadLocal)
|
||||
List<?>[] pageArtifacts = new List<?>[totalPages];
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
pageArtifacts[i] = document.getArtifacts(i);
|
||||
}
|
||||
|
||||
int parallelism = config.getThreads();
|
||||
ForkJoinPool pool = new ForkJoinPool(parallelism);
|
||||
int pagesToProcessCount = (pagesToProcess != null) ? pagesToProcess.size() : totalPages;
|
||||
LOGGER.log(Level.INFO, "Processing {0} pages with {1} threads", new Object[]{pagesToProcessCount, parallelism});
|
||||
|
||||
try {
|
||||
// Loop 1: ContentFilter per-page (largest bottleneck)
|
||||
pool.submit(() ->
|
||||
IntStream.range(0, totalPages).parallel().forEach(pageNumber -> {
|
||||
try {
|
||||
propagateState.run();
|
||||
if (shouldProcessPage(pageNumber, pagesToProcess)) {
|
||||
List<IObject> pageContents = ContentFilterProcessor.getFilteredContents(inputPdfName,
|
||||
(List) pageArtifacts[pageNumber], pageNumber, config);
|
||||
contents.set(pageNumber, pageContents);
|
||||
} else {
|
||||
contents.set(pageNumber, new ArrayList<>());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
})
|
||||
).get();
|
||||
|
||||
// Hidden text detection: sequential post-processing (requires ContrastRatioConsumer
|
||||
// which renders PDF pages — not safe to parallelize due to per-thread PDF file I/O)
|
||||
if (config.getFilterConfig().isFilterHiddenText()) {
|
||||
for (int pageNumber = 0; pageNumber < totalPages; pageNumber++) {
|
||||
if (shouldProcessPage(pageNumber, pagesToProcess)) {
|
||||
List<IObject> pageContents = HiddenTextProcessor.findHiddenText(contents.get(pageNumber), true);
|
||||
contents.set(pageNumber, pageContents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Structured processing is always enabled — auto-tagging needs headings,
|
||||
// lists, tables, and captions regardless of output format flags.
|
||||
boolean structured = true;
|
||||
|
||||
// ClusterTableProcessor: whole-document (must be sequential)
|
||||
if (structured && config.isClusterTableMethod()) {
|
||||
new ClusterTableProcessor().processTables(contents);
|
||||
}
|
||||
|
||||
// Loop 2: TableBorder + TextLine per-page
|
||||
pool.submit(() ->
|
||||
IntStream.range(0, totalPages).parallel().forEach(pageNumber -> {
|
||||
if (!shouldProcessPage(pageNumber, pagesToProcess)) {
|
||||
return;
|
||||
}
|
||||
propagateState.run();
|
||||
List<IObject> pageContents = contents.get(pageNumber);
|
||||
if (structured) {
|
||||
TextDecorationProcessor.processStrikethroughAndUnderlinedText(pageContents, pageNumber, config.isDetectStrikethrough());
|
||||
pageContents = TableBorderProcessor.processTableBorders(pageContents, pageNumber);
|
||||
pageContents = pageContents.stream().filter(x -> !(x instanceof LineChunk)).collect(Collectors.toList());
|
||||
pageContents = SpecialTableProcessor.detectSpecialTables(pageContents);
|
||||
}
|
||||
pageContents = TextLineProcessor.processTextLines(pageContents);
|
||||
contents.set(pageNumber, pageContents);
|
||||
})
|
||||
).get();
|
||||
|
||||
if (structured) {
|
||||
// Cross-page operations (must be sequential)
|
||||
HeaderFooterProcessor.processHeadersAndFooters(contents, false);
|
||||
new TableOfContentsProcessor().processTableOfContents(contents);
|
||||
ListProcessor.processLists(contents, false);
|
||||
}
|
||||
|
||||
// Loop 3: Paragraph + Heading per-page (always need ParagraphProcessor for text output)
|
||||
pool.submit(() ->
|
||||
IntStream.range(0, totalPages).parallel().forEach(pageNumber -> {
|
||||
if (!shouldProcessPage(pageNumber, pagesToProcess)) {
|
||||
return;
|
||||
}
|
||||
propagateState.run();
|
||||
List<IObject> pageContents = contents.get(pageNumber);
|
||||
pageContents = ParagraphProcessor.processParagraphs(pageContents);
|
||||
if (structured) {
|
||||
pageContents = ListProcessor.processListsFromTextNodes(pageContents);
|
||||
HeadingProcessor.processHeadings(pageContents, false);
|
||||
}
|
||||
contents.set(pageNumber, pageContents);
|
||||
})
|
||||
).get();
|
||||
|
||||
// Sequential ID assignment (must be in page order, before CaptionProcessor)
|
||||
for (int pageNumber = 0; pageNumber < totalPages; pageNumber++) {
|
||||
if (shouldProcessPage(pageNumber, pagesToProcess)) {
|
||||
setIDs(contents.get(pageNumber));
|
||||
}
|
||||
}
|
||||
|
||||
// Caption detection runs after setIDs so that recognizedStructureId is available
|
||||
// for linking captions to figures/tables
|
||||
if (structured) {
|
||||
for (int pageNumber = 0; pageNumber < totalPages; pageNumber++) {
|
||||
if (shouldProcessPage(pageNumber, pagesToProcess)) {
|
||||
CaptionProcessor.processCaptions(contents.get(pageNumber));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (structured) {
|
||||
// Cross-page post-processing (must be sequential)
|
||||
ListProcessor.checkNeighborLists(contents);
|
||||
TableBorderProcessor.checkNeighborTables(contents);
|
||||
HeadingProcessor.detectHeadingsLevels();
|
||||
LevelProcessor.detectLevels(contents);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Parallel page processing failed", e);
|
||||
} finally {
|
||||
pool.shutdown();
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a page should be processed based on the filter.
|
||||
*
|
||||
* @param pageNumber 0-indexed page number
|
||||
* @param pagesToProcess set of valid page numbers to process, or null for all pages
|
||||
* @return true if the page should be processed
|
||||
*/
|
||||
/**
|
||||
* Filters ElementMetadata down to entries whose transformer-assigned ID still
|
||||
* matches an IObject in the post-enrichment contents. This is deliberately
|
||||
* ID-based (not positional): sorting, filtering, and enrichment can reorder
|
||||
* or drop IObjects, so positional matching would attach the wrong
|
||||
* confidence/source label to an element. IObjects whose ID was rewritten
|
||||
* during enrichment simply lose their metadata — preferable to a wrong one.
|
||||
*/
|
||||
private static Map<Long, ElementMetadata> remapMetadataToContents(
|
||||
Map<Long, ElementMetadata> rawMetadata, List<List<IObject>> contents) {
|
||||
if (rawMetadata == null || rawMetadata.isEmpty()) return Collections.emptyMap();
|
||||
|
||||
Map<Long, ElementMetadata> remapped = new LinkedHashMap<>();
|
||||
for (List<IObject> pageContents : contents) {
|
||||
for (IObject obj : pageContents) {
|
||||
collectMetadata(obj, rawMetadata, remapped);
|
||||
}
|
||||
}
|
||||
return remapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks an IObject tree and copies any metadata entry keyed by its
|
||||
* recognized structure id into {@code remapped}. Containers like
|
||||
* {@code ListItem} hold their own children via {@code getContents()}, so
|
||||
* a shallow iteration over the top-level page list would miss nested
|
||||
* images / pictures — their metadata (ai_score, source label, caption)
|
||||
* would silently disappear from the JSON output. We recurse through the
|
||||
* containers we actually emit at this level (lists, tables, headers,
|
||||
* footers); leaf nodes terminate naturally.
|
||||
*/
|
||||
private static void collectMetadata(IObject obj,
|
||||
Map<Long, ElementMetadata> rawMetadata,
|
||||
Map<Long, ElementMetadata> remapped) {
|
||||
if (obj == null) return;
|
||||
Long id = obj.getRecognizedStructureId();
|
||||
if (id != null && id != 0L) {
|
||||
ElementMetadata meta = rawMetadata.get(id);
|
||||
if (meta != null) {
|
||||
remapped.put(id, meta);
|
||||
}
|
||||
}
|
||||
// Recurse into every container the JSON serializers walk. This keeps
|
||||
// the metadata visibility surface aligned with the serialized tree —
|
||||
// any image / picture / heading that ends up in the JSON output also
|
||||
// gets its ElementMetadata copied through. Add new container types
|
||||
// here when their serializer descends into child IObjects.
|
||||
if (obj instanceof org.verapdf.wcag.algorithms.entities.lists.ListItem) {
|
||||
for (IObject child : ((org.verapdf.wcag.algorithms.entities.lists.ListItem) obj).getContents()) {
|
||||
collectMetadata(child, rawMetadata, remapped);
|
||||
}
|
||||
} else if (obj instanceof org.verapdf.wcag.algorithms.entities.lists.PDFList) {
|
||||
for (org.verapdf.wcag.algorithms.entities.lists.ListItem item :
|
||||
((org.verapdf.wcag.algorithms.entities.lists.PDFList) obj).getListItems()) {
|
||||
collectMetadata(item, rawMetadata, remapped);
|
||||
}
|
||||
} else if (obj instanceof org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder) {
|
||||
org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder table =
|
||||
(org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder) obj;
|
||||
if (table.isTextBlock()) {
|
||||
// Text-block tables serialize as a single anonymous cell. Recurse
|
||||
// through the cell IObject itself so its own structureId metadata
|
||||
// is captured alongside the children — going straight to
|
||||
// getContents() would skip the cell-level entry.
|
||||
org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell cell = table.getCell(0, 0);
|
||||
if (cell != null) {
|
||||
collectMetadata(cell, rawMetadata, remapped);
|
||||
}
|
||||
} else {
|
||||
for (org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow row : table.getRows()) {
|
||||
for (org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell cell : row.getCells()) {
|
||||
collectMetadata(cell, rawMetadata, remapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (obj instanceof org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell) {
|
||||
for (IObject child : ((org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell) obj).getContents()) {
|
||||
collectMetadata(child, rawMetadata, remapped);
|
||||
}
|
||||
} else if (obj instanceof org.verapdf.wcag.algorithms.entities.SemanticHeaderOrFooter) {
|
||||
for (IObject child : ((org.verapdf.wcag.algorithms.entities.SemanticHeaderOrFooter) obj).getContents()) {
|
||||
collectMetadata(child, rawMetadata, remapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean shouldProcessPage(int pageNumber, Set<Integer> pagesToProcess) {
|
||||
return pagesToProcess == null || pagesToProcess.contains(pageNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the configured output files (JSON/MD/HTML/PDF/Text/images/tagged PDF)
|
||||
* from already-extracted contents.
|
||||
*
|
||||
* <p><strong>Internal API. Do not call directly.</strong> This method is
|
||||
* {@code public} only so the {@link org.opendataloader.pdf.api.OutputWriter}
|
||||
* facade in the {@code api} package can delegate to it. The signature
|
||||
* (notably the {@code List<List<IObject>>} and
|
||||
* {@code Map<Long, ElementMetadata>} parameters) is an implementation
|
||||
* detail and may change in any release. External callers must use
|
||||
* {@link org.opendataloader.pdf.api.OutputWriter#writeOutputs}, which is
|
||||
* the stable public API.
|
||||
*/
|
||||
public static void generateOutputs(String inputPdfName, List<List<IObject>> contents, Config config,
|
||||
Map<Long, ElementMetadata> elementMetadata) throws IOException {
|
||||
// Stdout mode: write primary format to stdout, skip file I/O
|
||||
if (config.isOutputStdout()) {
|
||||
java.io.Writer stdoutWriter = new java.io.BufferedWriter(
|
||||
new java.io.OutputStreamWriter(System.out, java.nio.charset.StandardCharsets.UTF_8));
|
||||
if (config.isGenerateText()) {
|
||||
TextGenerator textGenerator = new TextGenerator(stdoutWriter, config);
|
||||
textGenerator.writeToText(contents);
|
||||
stdoutWriter.flush();
|
||||
} else if (config.isGenerateMarkdown()) {
|
||||
MarkdownGenerator markdownGenerator = new MarkdownGenerator(stdoutWriter, config);
|
||||
markdownGenerator.writeToMarkdown(contents);
|
||||
stdoutWriter.flush();
|
||||
}
|
||||
// JSON and HTML stdout not yet supported
|
||||
return;
|
||||
}
|
||||
|
||||
File inputPDF = new File(inputPdfName);
|
||||
new File(config.getOutputFolder()).mkdirs();
|
||||
if (!config.isImageOutputOff() && (config.isGenerateHtml() || config.isGenerateMarkdown() || config.isGenerateJSON())) {
|
||||
String imagesDirectory;
|
||||
if (config.getImageDir() != null && !config.getImageDir().isEmpty()) {
|
||||
imagesDirectory = config.getImageDir();
|
||||
} else {
|
||||
String fileName = Paths.get(inputPdfName).getFileName().toString();
|
||||
imagesDirectory = config.getOutputFolder() + File.separator + FileUtils.getBaseName(fileName) + MarkdownSyntax.IMAGES_DIRECTORY_SUFFIX;
|
||||
}
|
||||
StaticLayoutContainers.setImagesDirectory(imagesDirectory);
|
||||
ImagesUtils imagesUtils = new ImagesUtils();
|
||||
imagesUtils.write(contents);
|
||||
}
|
||||
if (config.isGenerateTaggedPDF()) {
|
||||
AutoTaggingProcessor.createTaggedPDF(inputPDF, config.getOutputFolder(),
|
||||
StaticResources.getDocument(), contents);
|
||||
}
|
||||
if (config.isGeneratePDF()) {
|
||||
PDFWriter pdfWriter = new PDFWriter();
|
||||
pdfWriter.updatePDF(inputPDF, config.getPassword(), config.getOutputFolder(), contents);
|
||||
}
|
||||
if (config.isGenerateJSON()) {
|
||||
JsonWriter.writeToJson(inputPDF, config.getOutputFolder(), contents, elementMetadata,
|
||||
null, config.isIncludeHeaderFooter());
|
||||
}
|
||||
if (config.isGenerateMarkdown()) {
|
||||
try (MarkdownGenerator markdownGenerator = MarkdownGeneratorFactory.getMarkdownGenerator(inputPDF,
|
||||
config)) {
|
||||
markdownGenerator.writeToMarkdown(contents);
|
||||
}
|
||||
}
|
||||
if (config.isGenerateHtml()) {
|
||||
try (HtmlGenerator htmlGenerator = HtmlGeneratorFactory.getHtmlGenerator(inputPDF, config)) {
|
||||
htmlGenerator.writeToHtml(contents);
|
||||
}
|
||||
}
|
||||
if (config.isGenerateText()) {
|
||||
try (TextGenerator textGenerator = new TextGenerator(inputPDF, config)) {
|
||||
textGenerator.writeToText(contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs preprocessing on a PDF document.
|
||||
* Initializes static containers and parses the document structure.
|
||||
*
|
||||
* @param pdfName the path to the PDF file
|
||||
* @param config the configuration settings
|
||||
* @throws IOException if unable to read the PDF file
|
||||
*/
|
||||
public static void preprocessing(String pdfName, Config config) throws IOException {
|
||||
LOGGER.log(Level.INFO, () -> "File name: " + pdfName);
|
||||
validatePdfMagicNumber(pdfName);
|
||||
updateStaticContainers(config);
|
||||
PDDocument pdDocument;
|
||||
try {
|
||||
pdDocument = new PDDocument(pdfName);
|
||||
} catch (InvalidPasswordException pw) {
|
||||
// Encrypted PDFs are not a content-validity failure — let the
|
||||
// password-handling branch in callers (e.g. CLIMain) take over.
|
||||
throw pw;
|
||||
} catch (IOException cause) {
|
||||
// Magic number was present, so the user expected a real PDF, but
|
||||
// veraPDF could not parse the document (truncated download, body
|
||||
// corruption, missing xref). Surface a friendly message instead
|
||||
// of letting the raw veraPDF IOException leak as a stack trace.
|
||||
throw new InvalidPdfFileException(
|
||||
"'" + displayName(pdfName) + "' is not a valid PDF file (corrupted or truncated content).",
|
||||
cause);
|
||||
}
|
||||
StaticResources.setDocument(pdDocument);
|
||||
GFSAPDFDocument document = new GFSAPDFDocument(pdDocument);
|
||||
// org.verapdf.gf.model.impl.containers.StaticContainers.setFlavour(Collections.singletonList(PDFAFlavour.WCAG_2_2));
|
||||
StaticResources.setFlavour(Collections.singletonList(Objects.equals(pdDocument.getVersion(), 2.0F) ?
|
||||
PDFFlavour.WCAG_2_2_PDF_2_0_HUMAN : PDFFlavour.WCAG_2_2_HUMAN));
|
||||
StaticStorages.setIsFilterInvisibleLayers(config.getFilterConfig().isFilterHiddenOCG());
|
||||
StaticContainers.setDocument(document);
|
||||
if (config.isUseStructTree()) {
|
||||
document.parseStructureTreeRoot();
|
||||
if (document.getTree() != null) {
|
||||
StaticLayoutContainers.setIsUseStructTree(true);
|
||||
} else {
|
||||
StaticLayoutContainers.setIsUseStructTree(false);
|
||||
LOGGER.log(Level.WARNING, "The document has no structure tree. The 'use-struct-tree' option will be ignored.");
|
||||
}
|
||||
}
|
||||
StaticContainers.setFileName(pdfName);
|
||||
StaticContainers.setPassword(config.getPassword());
|
||||
StaticContainers.setIsDataLoader(true);
|
||||
StaticContainers.setIsIgnoreCharactersWithoutUnicode(false);
|
||||
StaticResources.setIsFontProgramsParsing(true);
|
||||
StaticStorages.setIsIgnoreMCIDs(!StaticLayoutContainers.isUseStructTree());
|
||||
StaticStorages.setIsAddSpacesBetweenTextPieces(true);
|
||||
document.parseChunks();
|
||||
LinesPreprocessingConsumer linesPreprocessingConsumer = new LinesPreprocessingConsumer();
|
||||
linesPreprocessingConsumer.findTableBorders();
|
||||
StaticContainers.setTableBordersCollection(new TableBordersCollection(linesPreprocessingConsumer.getTableBorders()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the input file contains the PDF magic number ({@code %PDF-})
|
||||
* within its first 1024 bytes.
|
||||
*
|
||||
* <p>ISO 32000-1 §7.5.2 allows the {@code %PDF-} header to appear "near
|
||||
* the beginning" of the file rather than strictly at byte 0; real-world
|
||||
* PDFs sometimes have a leading UTF-8 BOM or whitespace. A 1024-byte
|
||||
* search window matches that tolerance while still rejecting any
|
||||
* JPG/PNG/HTML/empty file.
|
||||
*
|
||||
* @throws InvalidPdfFileException if the magic number is not present
|
||||
* @throws IOException if the file cannot be opened or read
|
||||
*/
|
||||
private static void validatePdfMagicNumber(String pdfName) throws IOException {
|
||||
Path path = Path.of(pdfName);
|
||||
byte[] head;
|
||||
try (InputStream in = Files.newInputStream(path)) {
|
||||
head = in.readNBytes(1024);
|
||||
}
|
||||
byte[] marker = "%PDF-".getBytes(StandardCharsets.US_ASCII);
|
||||
if (indexOfBytes(head, marker) < 0) {
|
||||
throw new InvalidPdfFileException(
|
||||
"'" + displayName(pdfName) + "' is not a valid PDF file (missing %PDF- header).");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path.getFileName() returns null for filesystem roots (e.g. {@code C:\}).
|
||||
* Fall back to the original input string in that case so the user-facing
|
||||
* error message is never empty.
|
||||
*/
|
||||
private static String displayName(String pdfName) {
|
||||
Path fileName = Path.of(pdfName).getFileName();
|
||||
return fileName != null ? fileName.toString() : pdfName;
|
||||
}
|
||||
|
||||
private static int indexOfBytes(byte[] haystack, byte[] needle) {
|
||||
if (needle.length == 0 || haystack.length < needle.length) {
|
||||
return -1;
|
||||
}
|
||||
int last = haystack.length - needle.length;
|
||||
outer:
|
||||
for (int i = 0; i <= last; i++) {
|
||||
for (int j = 0; j < needle.length; j++) {
|
||||
if (haystack[i + j] != needle[j]) {
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void updateStaticContainers(Config config) {
|
||||
StaticResources.clear();
|
||||
StaticContainers.updateContainers(null);
|
||||
StaticLayoutContainers.clearContainers();
|
||||
org.verapdf.gf.model.impl.containers.StaticContainers.clearAllContainers();
|
||||
StaticCoreContainers.clearAllContainers();
|
||||
StaticXmpCoreContainers.clearAllContainers();
|
||||
StaticContainers.setKeepLineBreaks(config.isKeepLineBreaks());
|
||||
StaticLayoutContainers.setCurrentContentId(1);
|
||||
StaticLayoutContainers.setEmbedImages(config.isEmbedImages());
|
||||
StaticLayoutContainers.setImageFormat(config.getImageFormat());
|
||||
StaticResources.setPassword(config.getPassword());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns unique IDs to each content object.
|
||||
*
|
||||
* @param contents the list of content objects
|
||||
*/
|
||||
public static void setIDs(List<IObject> contents) {
|
||||
for (IObject object : contents) {
|
||||
object.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets index values for all content objects across all pages.
|
||||
*
|
||||
* @param contents the document contents organized by page
|
||||
*/
|
||||
public static void setIndexesForDocumentContents(List<List<IObject>> contents) {
|
||||
for (List<IObject> pageContents : contents) {
|
||||
setIndexesForContentsList(pageContents);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets index values for content objects in a list.
|
||||
*
|
||||
* @param contents the list of content objects
|
||||
*/
|
||||
public static void setIndexesForContentsList(List<IObject> contents) {
|
||||
for (int index = 0; index < contents.size(); index++) {
|
||||
contents.get(index).setIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new list with null objects removed.
|
||||
*
|
||||
* @param contents the list that may contain null objects
|
||||
* @return a new list without null objects
|
||||
*/
|
||||
public static List<IObject> removeNullObjectsFromList(List<IObject> contents) {
|
||||
List<IObject> newContents = new ArrayList<>();
|
||||
for (IObject content : contents) {
|
||||
if (content != null) {
|
||||
newContents.add(content);
|
||||
}
|
||||
}
|
||||
return newContents;
|
||||
}
|
||||
|
||||
private static void calculateDocumentInfo() {
|
||||
PDDocument document = StaticResources.getDocument();
|
||||
LOGGER.log(Level.INFO, () -> "Number of pages: " + document.getNumberOfPages());
|
||||
COSTrailer trailer = document.getDocument().getTrailer();
|
||||
GFCosInfo info = getInfo(trailer);
|
||||
LOGGER.log(Level.INFO, () -> "Author: " + (info.getAuthor() != null ? info.getAuthor() : info.getXMPCreator()));
|
||||
LOGGER.log(Level.INFO, () -> "Title: " + (info.getTitle() != null ? info.getTitle() : info.getXMPTitle()));
|
||||
LOGGER.log(Level.INFO, () -> "Creation date: " + (info.getCreationDate() != null ? info.getCreationDate() : info.getXMPCreateDate()));
|
||||
LOGGER.log(Level.INFO, () -> "Modification date: " + (info.getModDate() != null ? info.getModDate() : info.getXMPModifyDate()));
|
||||
}
|
||||
|
||||
private static GFCosInfo getInfo(COSTrailer trailer) {
|
||||
COSObject object = trailer.getKey(ASAtom.INFO);
|
||||
return new GFCosInfo((COSDictionary) (object != null && object.getType() == COSObjType.COS_DICT ? object.getDirectBase() : COSDictionary.construct().get()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a debug string representation of a text node.
|
||||
*
|
||||
* @param textNode the text node to describe
|
||||
* @return a string with font, size, color, and content information
|
||||
*/
|
||||
public static String getContentsValueForTextNode(SemanticTextNode textNode) {
|
||||
return String.format("%s: font %s, text size %.2f, text color %s, text content \"%s\"",
|
||||
textNode.getSemanticType().getValue(), textNode.getFontName(),
|
||||
textNode.getFontSize(), Arrays.toString(TextNodeUtils.getTextColorOrDefault(textNode)),
|
||||
textNode.getValue().length() > 15 ? textNode.getValue().substring(0, 15) + "..." : textNode.getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the bounding box for a page.
|
||||
*
|
||||
* @param pageNumber the page number (0-indexed)
|
||||
* @return the page bounding box, or null if not available
|
||||
*/
|
||||
public static BoundingBox getPageBoundingBox(int pageNumber) {
|
||||
PDDocument document = StaticResources.getDocument();
|
||||
if (document == null) {
|
||||
return null;
|
||||
}
|
||||
double[] cropBox = document.getPage(pageNumber).getCropBox();
|
||||
if (cropBox == null) {
|
||||
return null;
|
||||
}
|
||||
return new BoundingBox(pageNumber, cropBox);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts page contents by their bounding box positions.
|
||||
*
|
||||
* @param contents the list of content objects to sort
|
||||
* @return a new sorted list of content objects
|
||||
*/
|
||||
public static List<IObject> sortPageContents(List<IObject> contents) {
|
||||
if (contents == null || contents.isEmpty()) {
|
||||
return contents;
|
||||
}
|
||||
List<IObject> sortedContents = new ArrayList<>(contents);
|
||||
sortedContents.sort((o1, o2) -> {
|
||||
BoundingBox b1 = o1.getBoundingBox();
|
||||
BoundingBox b2 = o2.getBoundingBox();
|
||||
if (b1 == null && b2 == null) {
|
||||
return 0;
|
||||
}
|
||||
if (b1 == null) {
|
||||
return 1;
|
||||
}
|
||||
if (b2 == null) {
|
||||
return -1;
|
||||
}
|
||||
if (!Objects.equals(b1.getPageNumber(), b2.getPageNumber())) {
|
||||
return b1.getPageNumber() - b2.getPageNumber();
|
||||
}
|
||||
if (!Objects.equals(b1.getLastPageNumber(), b2.getLastPageNumber())) {
|
||||
return b1.getLastPageNumber() - b2.getLastPageNumber();
|
||||
}
|
||||
if (!Objects.equals(b1.getTopY(), b2.getTopY())) {
|
||||
return b2.getTopY() - b1.getTopY() > 0 ? 1 : -1;
|
||||
}
|
||||
if (!Objects.equals(b1.getLeftX(), b2.getLeftX())) {
|
||||
return b1.getLeftX() - b2.getLeftX() > 0 ? 1 : -1;
|
||||
}
|
||||
if (!Objects.equals(b1.getBottomY(), b2.getBottomY())) {
|
||||
return b1.getBottomY() - b2.getBottomY() > 0 ? 1 : -1;
|
||||
}
|
||||
if (!Objects.equals(b1.getRightX(), b2.getRightX())) {
|
||||
return b1.getRightX() - b2.getRightX() > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return sortedContents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts document contents according to the configured reading order.
|
||||
*
|
||||
* @param contents the document contents organized by page
|
||||
* @param config the configuration containing reading order settings
|
||||
*/
|
||||
public static void sortContents(List<List<IObject>> contents, Config config) {
|
||||
String readingOrder = config.getReadingOrder();
|
||||
|
||||
// xycut: XY-Cut++ sorting (per-page, stateless — safe to parallelize)
|
||||
if (Config.READING_ORDER_XYCUT.equals(readingOrder)) {
|
||||
int totalPages = StaticContainers.getDocument().getNumberOfPages();
|
||||
IntStream pages = IntStream.range(0, totalPages);
|
||||
if (config.getThreads() > 1) {
|
||||
pages.parallel().forEach(pageNumber ->
|
||||
contents.set(pageNumber, XYCutPlusPlusSorter.sort(contents.get(pageNumber))));
|
||||
} else {
|
||||
pages.forEach(pageNumber ->
|
||||
contents.set(pageNumber, XYCutPlusPlusSorter.sort(contents.get(pageNumber))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Log warning for unknown reading order values
|
||||
if (!Config.READING_ORDER_OFF.equals(readingOrder)) {
|
||||
LOGGER.log(Level.WARNING, "Unknown reading order value ''{0}'', using default ''off''", readingOrder);
|
||||
}
|
||||
|
||||
// off: skip sorting (keep PDF COS object order)
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.opendataloader.pdf.hybrid.ElementMetadata;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Internal result of the extraction pipeline (preprocessing + content extraction + sanitization).
|
||||
* Carries the extracted contents and timing metadata for use by AutoTagger.
|
||||
*/
|
||||
public class ExtractionResult {
|
||||
|
||||
private final List<List<IObject>> contents;
|
||||
private final long extractionNs;
|
||||
private final JsonNode hybridTimings;
|
||||
private final Map<Long, ElementMetadata> elementMetadata;
|
||||
|
||||
public ExtractionResult(List<List<IObject>> contents, long extractionNs, JsonNode hybridTimings,
|
||||
Map<Long, ElementMetadata> elementMetadata) {
|
||||
this.contents = contents;
|
||||
this.extractionNs = extractionNs;
|
||||
this.hybridTimings = hybridTimings;
|
||||
this.elementMetadata = elementMetadata != null ? elementMetadata : Collections.emptyMap();
|
||||
}
|
||||
|
||||
public ExtractionResult(List<List<IObject>> contents, long extractionNs, JsonNode hybridTimings) {
|
||||
this(contents, extractionNs, hybridTimings, Collections.emptyMap());
|
||||
}
|
||||
|
||||
public List<List<IObject>> getContents() {
|
||||
return contents;
|
||||
}
|
||||
|
||||
public long getExtractionNs() {
|
||||
return extractionNs;
|
||||
}
|
||||
|
||||
public JsonNode getHybridTimings() {
|
||||
return hybridTimings;
|
||||
}
|
||||
|
||||
public Map<Long, ElementMetadata> getElementMetadata() {
|
||||
return elementMetadata;
|
||||
}
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.verapdf.wcag.algorithms.entities.INode;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticHeaderOrFooter;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTextNode;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextLine;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.SemanticType;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListInterval;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListIntervalsCollection;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.info.ListItemInfo;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.info.ListItemTextInfo;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.ListLabelsUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.NodeUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.listLabelsDetection.*;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Processor for detecting and extracting headers and footers from PDF documents.
|
||||
* Identifies repeating content at the top and bottom of pages.
|
||||
*/
|
||||
public class HeaderFooterProcessor {
|
||||
|
||||
/**
|
||||
* Processes document contents to detect headers and footers.
|
||||
*
|
||||
* @param contents the document contents organized by page
|
||||
* @param isTagged whether the document is tagged
|
||||
*/
|
||||
public static void processHeadersAndFooters(List<List<IObject>> contents, boolean isTagged) {
|
||||
DocumentProcessor.setIndexesForDocumentContents(contents);
|
||||
List<List<IObject>> sortedContents = new ArrayList<>();
|
||||
for (List<IObject> content : contents) {
|
||||
sortedContents.add(DocumentProcessor.sortPageContents(content));
|
||||
}
|
||||
List<List<IObject>> filteredSortedContents = new ArrayList<>();
|
||||
for (List<IObject> content : sortedContents) {
|
||||
filteredSortedContents.add(content.stream().filter(c -> !(c instanceof LineChunk) && !(c instanceof LineArtChunk)).collect(Collectors.toList()));
|
||||
}
|
||||
List<SemanticHeaderOrFooter> footers = getHeadersOrFooters(filteredSortedContents, false);
|
||||
List<SemanticHeaderOrFooter> headers = getHeadersOrFooters(filteredSortedContents, true);
|
||||
for (int pageNumber = 0; pageNumber < contents.size(); pageNumber++) {
|
||||
contents.set(pageNumber, updatePageContents(contents.get(pageNumber), headers.get(pageNumber), footers.get(pageNumber)));
|
||||
}
|
||||
if (!isTagged) {
|
||||
processHeadersOrFootersContents(footers);
|
||||
processHeadersOrFootersContents(headers);
|
||||
}
|
||||
}
|
||||
|
||||
private static void processHeadersOrFootersContents(List<SemanticHeaderOrFooter> headersOrFooters) {
|
||||
for (SemanticHeaderOrFooter headerOrFooter : headersOrFooters) {
|
||||
if (headerOrFooter != null) {
|
||||
headerOrFooter.setContents(processHeaderOrFooterContent(headerOrFooter.getContents()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<IObject> updatePageContents(List<IObject> pageContents, SemanticHeaderOrFooter header, SemanticHeaderOrFooter footer) {
|
||||
SortedSet<Integer> headerAndFooterIndexes = new TreeSet<>();
|
||||
headerAndFooterIndexes.addAll(getHeaderOrFooterContentsIndexes(header));
|
||||
headerAndFooterIndexes.addAll(getHeaderOrFooterContentsIndexes(footer));
|
||||
if (headerAndFooterIndexes.isEmpty()) {
|
||||
return pageContents;
|
||||
}
|
||||
List<IObject> result = new ArrayList<>();
|
||||
if (header != null) {
|
||||
result.add(header);
|
||||
}
|
||||
Iterator<Integer> iterator = headerAndFooterIndexes.iterator();
|
||||
int nextHeaderOrFooterIndex = iterator.hasNext() ? iterator.next() : pageContents.size();
|
||||
for (int index = 0; index < pageContents.size(); index++) {
|
||||
if (index < nextHeaderOrFooterIndex) {
|
||||
result.add(pageContents.get(index));
|
||||
} else {
|
||||
nextHeaderOrFooterIndex = iterator.hasNext() ? iterator.next() : pageContents.size();
|
||||
}
|
||||
}
|
||||
if (footer != null) {
|
||||
result.add(footer);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Set<Integer> getHeaderOrFooterContentsIndexes(SemanticHeaderOrFooter header) {
|
||||
if (header == null) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
SortedSet<Integer> set = new TreeSet<>();
|
||||
for (IObject content : header.getContents()) {
|
||||
set.add(content.getIndex());
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private static List<SemanticHeaderOrFooter> getHeadersOrFooters(List<List<IObject>> sortedContents, boolean isHeaderDetection) {
|
||||
List<SemanticHeaderOrFooter> headersOrFooters = new ArrayList<>(sortedContents.size());
|
||||
List<Integer> numberOfHeaderOrFooterContentsForEachPage = getNumberOfHeaderOrFooterContentsForEachPage(sortedContents, isHeaderDetection);
|
||||
for (int pageNumber = 0; pageNumber < sortedContents.size(); pageNumber++) {
|
||||
Integer currentIndex = numberOfHeaderOrFooterContentsForEachPage.get(pageNumber);
|
||||
if (currentIndex == 0) {
|
||||
headersOrFooters.add(null);
|
||||
continue;
|
||||
}
|
||||
List<IObject> pageContents = sortedContents.get(pageNumber);
|
||||
List<IObject> headerContents = filterHeaderOrFooterContents(isHeaderDetection ? pageContents.subList(0, currentIndex) :
|
||||
pageContents.subList(pageContents.size() - currentIndex, pageContents.size()), pageNumber, isHeaderDetection);
|
||||
if (headerContents.isEmpty()) {
|
||||
headersOrFooters.add(null);
|
||||
continue;
|
||||
}
|
||||
SemanticHeaderOrFooter semanticHeaderOrFooter = new SemanticHeaderOrFooter(isHeaderDetection ? SemanticType.HEADER : SemanticType.FOOTER);
|
||||
semanticHeaderOrFooter.addContents(headerContents);
|
||||
semanticHeaderOrFooter.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
headersOrFooters.add(semanticHeaderOrFooter);
|
||||
}
|
||||
return headersOrFooters;
|
||||
}
|
||||
|
||||
private static List<IObject> processHeaderOrFooterContent(List<IObject> contents) {
|
||||
List<IObject> newContents = ParagraphProcessor.processParagraphs(contents);
|
||||
newContents = ListProcessor.processListsFromTextNodes(newContents);
|
||||
HeadingProcessor.processHeadings(newContents, false);
|
||||
DocumentProcessor.setIDs(newContents);
|
||||
CaptionProcessor.processCaptions(newContents);
|
||||
return newContents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum vertical gap (in points) allowed between consecutive footer/header elements.
|
||||
* If a candidate element is farther than this from the previously accepted element,
|
||||
* it is not included in the header/footer region. This prevents body text that happens
|
||||
* to repeat across pages from being absorbed into the footer.
|
||||
*/
|
||||
private static final double MAX_HEADER_FOOTER_GAP = 30.0;
|
||||
|
||||
private static List<Integer> getNumberOfHeaderOrFooterContentsForEachPage(List<List<IObject>> sortedContents, boolean isHeaderDetection) {
|
||||
List<Integer> numberOfHeaderOrFooterContentsForEachPage = new ArrayList<>(sortedContents.size());
|
||||
for (int pageNumber = 0; pageNumber < sortedContents.size(); pageNumber++) {
|
||||
numberOfHeaderOrFooterContentsForEachPage.add(0);
|
||||
}
|
||||
int currentIndex = 0;
|
||||
while (true) {
|
||||
List<IObject> contents = new ArrayList<>(sortedContents.size());
|
||||
for (int pageNumber = 0; pageNumber < sortedContents.size(); pageNumber++) {
|
||||
if (numberOfHeaderOrFooterContentsForEachPage.get(pageNumber) != currentIndex) {
|
||||
contents.add(null);
|
||||
continue;
|
||||
}
|
||||
List<IObject> pageContents = sortedContents.get(pageNumber);
|
||||
int index = isHeaderDetection ? currentIndex : pageContents.size() - 1 - currentIndex;
|
||||
if (index >= 0 && index < pageContents.size()) {
|
||||
IObject candidate = pageContents.get(index);
|
||||
if (currentIndex > 0 && !isAdjacentToExistingHeaderOrFooter(
|
||||
pageContents, currentIndex, isHeaderDetection, candidate)) {
|
||||
contents.add(null);
|
||||
} else {
|
||||
contents.add(candidate);
|
||||
}
|
||||
} else {
|
||||
contents.add(null);
|
||||
}
|
||||
}
|
||||
Set<Integer> newIndexes = getIndexesOfHeaderOrFootersContents(contents);
|
||||
if (newIndexes.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
for (Integer newIndex : newIndexes) {
|
||||
numberOfHeaderOrFooterContentsForEachPage.set(newIndex, currentIndex + 1);
|
||||
}
|
||||
currentIndex++;
|
||||
}
|
||||
return numberOfHeaderOrFooterContentsForEachPage;
|
||||
}
|
||||
|
||||
private static boolean isAdjacentToExistingHeaderOrFooter(
|
||||
List<IObject> pageContents, int currentIndex, boolean isHeaderDetection, IObject candidate) {
|
||||
int previousIndex = isHeaderDetection ? currentIndex - 1 : pageContents.size() - currentIndex;
|
||||
if (previousIndex < 0 || previousIndex >= pageContents.size()) {
|
||||
return true;
|
||||
}
|
||||
IObject previousElement = pageContents.get(previousIndex);
|
||||
double gap;
|
||||
if (isHeaderDetection) {
|
||||
gap = previousElement.getBottomY() - candidate.getTopY();
|
||||
} else {
|
||||
gap = candidate.getBottomY() - previousElement.getTopY();
|
||||
}
|
||||
return gap <= MAX_HEADER_FOOTER_GAP;
|
||||
}
|
||||
|
||||
private static Set<Integer> getIndexesOfHeaderOrFootersContents(List<IObject> contents) {
|
||||
Set<Integer> result = new HashSet<>(contents.size());
|
||||
for (int pageNumber = 0; pageNumber < contents.size() - 1; pageNumber++) {
|
||||
IObject currentObject = contents.get(pageNumber);
|
||||
IObject nextObject = contents.get(pageNumber + 1);
|
||||
if (currentObject != null && nextObject != null) {
|
||||
if (arePossibleHeadersOrFooters(currentObject, nextObject, 1)) {
|
||||
result.add(pageNumber);
|
||||
result.add(pageNumber + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
//2-page style
|
||||
for (int pageNumber = 0; pageNumber < contents.size() - 2; pageNumber++) {
|
||||
IObject currentObject = contents.get(pageNumber);
|
||||
IObject nextObject = contents.get(pageNumber + 2);
|
||||
if (currentObject != null && nextObject != null) {
|
||||
if (arePossibleHeadersOrFooters(currentObject, nextObject, 2)) {
|
||||
result.add(pageNumber);
|
||||
result.add(pageNumber + 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a content object is a header or footer.
|
||||
*
|
||||
* @param content the content object to check
|
||||
* @return true if the content is a header or footer, false otherwise
|
||||
*/
|
||||
public static boolean isHeaderOrFooter(IObject content) {
|
||||
if (content instanceof INode) {
|
||||
INode node = (INode) content;
|
||||
if (node.getSemanticType() == SemanticType.HEADER || node.getSemanticType() == SemanticType.FOOTER) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<IObject> filterHeaderOrFooterContents(List<IObject> contents, int pageNumber, boolean isHeaderDetection) {
|
||||
BoundingBox boundingBox = DocumentProcessor.getPageBoundingBox(pageNumber);
|
||||
if (boundingBox == null) {
|
||||
return contents;
|
||||
}
|
||||
List<IObject> result = new ArrayList<>();
|
||||
for (IObject content : contents) {
|
||||
if (isHeaderDetection) {
|
||||
if (content.getBottomY() < boundingBox.getHeight() * 2 / 3) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (content.getTopY() > boundingBox.getHeight() / 3) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.add(content);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean arePossibleHeadersOrFooters(IObject object1, IObject object2, int increment) {
|
||||
if (object1 instanceof SemanticTextNode && object2 instanceof SemanticTextNode) {
|
||||
SemanticTextNode textNode1 = (SemanticTextNode) object1;
|
||||
SemanticTextNode textNode2 = (SemanticTextNode) object2;
|
||||
if (!BoundingBox.areOverlapsBoundingBoxesExcludingPages(object1.getBoundingBox(), object2.getBoundingBox())) {
|
||||
return false;
|
||||
}
|
||||
if (!NodeUtils.areCloseNumbers(textNode1.getFontSize(), textNode2.getFontSize())) {
|
||||
return false;
|
||||
}
|
||||
if (Objects.equals(textNode1.getValue(), textNode2.getValue())) {
|
||||
return true;
|
||||
}
|
||||
List<SemanticTextNode> textNodes = new ArrayList<>(2);
|
||||
textNodes.add(textNode1);
|
||||
textNodes.add(textNode2);
|
||||
if (getHeadersOrFootersIntervals(textNodes, increment).size() == 1) {
|
||||
return true;
|
||||
}
|
||||
} else if (object1 instanceof TextLine && object2 instanceof TextLine) {
|
||||
TextLine line1 = (TextLine) object1;
|
||||
TextLine line2 = (TextLine) object2;
|
||||
SemanticTextNode textNode1 = new SemanticTextNode();
|
||||
textNode1.add(line1);
|
||||
SemanticTextNode textNode2 = new SemanticTextNode();
|
||||
textNode2.add(line2);
|
||||
return arePossibleHeadersOrFooters(textNode1, textNode2, increment);
|
||||
} else {
|
||||
if (BoundingBox.areSameBoundingBoxesExcludingPages(object1.getBoundingBox(), object2.getBoundingBox())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Set<ListInterval> getHeadersOrFootersIntervals(List<SemanticTextNode> textNodes, int increment) {
|
||||
List<ListItemTextInfo> textChildrenInfo = new ArrayList<>(textNodes.size());
|
||||
for (int i = 0; i < textNodes.size(); i++) {
|
||||
SemanticTextNode textNode = textNodes.get(i);
|
||||
TextLine line = textNode.getFirstNonSpaceLine();
|
||||
TextLine secondLine = textNode.getNonSpaceLine(1);
|
||||
textChildrenInfo.add(new ListItemTextInfo(i, textNode.getSemanticType(),
|
||||
line, line.getValue().trim(), secondLine == null));
|
||||
}
|
||||
Set<ListInterval> intervals = getHeadersOfFooterIntervals(textChildrenInfo, increment);
|
||||
return intervals;
|
||||
}
|
||||
|
||||
private static Set<ListInterval> getHeadersOfFooterIntervals(List<ListItemTextInfo> itemsInfo, int increment) {
|
||||
ListIntervalsCollection listIntervals = new ListIntervalsCollection();
|
||||
listIntervals.putAll((new AlfaLettersListLabelsDetectionAlgorithm1(increment)).getItemsIntervals(itemsInfo));
|
||||
listIntervals.putAll((new AlfaLettersListLabelsDetectionAlgorithm2(increment)).getItemsIntervals(itemsInfo));
|
||||
listIntervals.putAll((new KoreanLettersListLabelsDetectionAlgorithm(increment)).getItemsIntervals(itemsInfo));
|
||||
listIntervals.putAll((new RomanNumbersListLabelsDetectionAlgorithm(increment)).getItemsIntervals(itemsInfo));
|
||||
ArabicNumbersListLabelsDetectionAlgorithm arabicNumbersListLabelsDetectionAlgorithm = new ArabicNumbersListLabelsDetectionAlgorithm(increment);
|
||||
arabicNumbersListLabelsDetectionAlgorithm.setHeaderOrFooterDetection(true);
|
||||
listIntervals.putAll((arabicNumbersListLabelsDetectionAlgorithm).getItemsIntervals(itemsInfo));
|
||||
ListIntervalsCollection correctIntervals = new ListIntervalsCollection(getEqualsItems(itemsInfo));
|
||||
for (ListInterval listInterval : listIntervals.getSet()) {
|
||||
List<String> labels = new LinkedList<>();
|
||||
for (ListItemInfo info : listInterval.getListItemsInfos()) {
|
||||
labels.add(((ListItemTextInfo) info).getListItem());
|
||||
}
|
||||
if (ListLabelsUtils.isListLabels(labels, increment)) {
|
||||
correctIntervals.put(listInterval);
|
||||
}
|
||||
}
|
||||
return correctIntervals.getSet();
|
||||
}
|
||||
|
||||
private static Set<ListInterval> getEqualsItems(List<ListItemTextInfo> itemsInfo) {
|
||||
Set<ListInterval> listIntervals = new HashSet<>();
|
||||
String value = null;
|
||||
ListInterval interval = new ListInterval();
|
||||
for (ListItemTextInfo info : itemsInfo) {
|
||||
if (!Objects.equals(info.getListItem(), value)) {
|
||||
if (interval.getNumberOfListItems() > 1) {
|
||||
listIntervals.add(interval);
|
||||
}
|
||||
value = info.getListItem();
|
||||
interval = new ListInterval();
|
||||
}
|
||||
interval.getListItemsInfos().add(info);
|
||||
}
|
||||
if (interval.getNumberOfListItems() > 1) {
|
||||
listIntervals.add(interval);
|
||||
}
|
||||
return listIntervals;
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.opendataloader.pdf.utils.BulletedParagraphUtils;
|
||||
import org.opendataloader.pdf.utils.TextNodeStatistics;
|
||||
import org.opendataloader.pdf.utils.TextNodeUtils;
|
||||
import org.verapdf.wcag.algorithms.entities.INode;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticHeading;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTextNode;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextBlock;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextLine;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.SemanticType;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListItem;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.PDFList;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.text.TextStyle;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.NodeUtils;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Processor for detecting and classifying headings in PDF content.
|
||||
* Uses font size, weight, and position to identify potential headings.
|
||||
*/
|
||||
public class HeadingProcessor {
|
||||
private static final double HEADING_PROBABILITY = 0.75;
|
||||
private static final double BULLETED_HEADING_PROBABILITY = 0.1;
|
||||
|
||||
/**
|
||||
* Processes content to identify and mark headings.
|
||||
*
|
||||
* @param contents the list of content objects to process
|
||||
* @param isTableCell whether the content is inside a table cell
|
||||
*/
|
||||
public static void processHeadings(List<IObject> contents, boolean isTableCell) {
|
||||
TextNodeStatistics textNodeStatistics = new TextNodeStatistics();
|
||||
List<SemanticTextNode> textNodes = new LinkedList<>();
|
||||
Map<SemanticTextNode, PDFList> textNodeToListMap = new HashMap<>();
|
||||
for (IObject content : contents) {
|
||||
processContent(textNodes, content, textNodeStatistics, textNodeToListMap);
|
||||
}
|
||||
|
||||
int textNodesCount = textNodes.size();
|
||||
if (isTableCell && textNodesCount < 2) {
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < textNodesCount; index++) {
|
||||
SemanticTextNode textNode = textNodes.get(index);
|
||||
if (textNode.getSemanticType() == SemanticType.HEADING) {
|
||||
continue;
|
||||
}
|
||||
SemanticTextNode prevNode = index != 0 ? textNodes.get(index - 1) : null;
|
||||
SemanticTextNode nextNode = index + 1 < textNodesCount ? textNodes.get(index + 1) : null;
|
||||
double probability = NodeUtils.headingProbability(textNode, prevNode, nextNode, textNode);
|
||||
|
||||
probability += textNodeStatistics.fontSizeRarityBoost(textNode);
|
||||
probability += textNodeStatistics.fontWeightRarityBoost(textNode);
|
||||
|
||||
if (BulletedParagraphUtils.isBulletedParagraph(textNode)) {
|
||||
probability += BULLETED_HEADING_PROBABILITY;
|
||||
}
|
||||
if (probability > HEADING_PROBABILITY && textNode.getSemanticType() != SemanticType.LIST) {
|
||||
textNode.setSemanticType(SemanticType.HEADING);
|
||||
}
|
||||
if (textNode.getSemanticType() == SemanticType.HEADING && textNode.getInitialSemanticType() == SemanticType.LIST) {
|
||||
PDFList list = textNodeToListMap.get(textNode);
|
||||
if (isNotHeadings(list)) {
|
||||
continue;
|
||||
}
|
||||
int listIndex = contents.indexOf(list);
|
||||
contents.remove(listIndex);
|
||||
contents.addAll(listIndex, disassemblePDFList(list));
|
||||
}
|
||||
}
|
||||
setHeadings(contents);
|
||||
}
|
||||
|
||||
private static List<IObject> disassemblePDFList(PDFList list) {
|
||||
List<IObject> contents = new LinkedList<>();
|
||||
for (ListItem item : list.getListItems()) {
|
||||
SemanticTextNode node = convertListItemToSemanticTextNode(item);
|
||||
node.setSemanticType(SemanticType.HEADING);
|
||||
contents.add(node);
|
||||
contents.addAll(item.getContents());
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
private static SemanticTextNode convertListItemToSemanticTextNode(TextBlock textBlock) {
|
||||
SemanticTextNode semanticTextNode = new SemanticTextNode(SemanticType.LIST);
|
||||
for (TextLine line : textBlock.getLines()) {
|
||||
semanticTextNode.add(line);
|
||||
}
|
||||
return semanticTextNode;
|
||||
}
|
||||
|
||||
private static List<SemanticTextNode> getTextNodesFromContents(List<IObject> contents) {
|
||||
List<SemanticTextNode> textNodes = new LinkedList<>();
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof SemanticTextNode) {
|
||||
textNodes.add((SemanticTextNode) content);
|
||||
}
|
||||
}
|
||||
return textNodes;
|
||||
}
|
||||
|
||||
private static void processContent(List<SemanticTextNode> textNodes, IObject content, TextNodeStatistics textNodeStatistics,
|
||||
Map<SemanticTextNode, PDFList> possibleHeadingsInList) {
|
||||
if (content instanceof SemanticTextNode) {
|
||||
SemanticTextNode textNode = (SemanticTextNode) content;
|
||||
if (!textNode.isSpaceNode()) {
|
||||
textNodes.add(textNode);
|
||||
textNodeStatistics.addTextNode(textNode);
|
||||
}
|
||||
} else if (content instanceof TableBorder && ((TableBorder) content).isTextBlock()) {
|
||||
TableBorder textBlock = (TableBorder) content;
|
||||
TableBorderCell cell = textBlock.getCell(0, 0);
|
||||
List<SemanticTextNode> cellTextNodes = getTextNodesFromContents(cell.getContents());
|
||||
if (cellTextNodes.size() == 1) {
|
||||
processContent(textNodes, cellTextNodes.get(0), textNodeStatistics, possibleHeadingsInList);
|
||||
}
|
||||
} else if (content instanceof PDFList) {
|
||||
PDFList list = (PDFList) content;
|
||||
ListItem listItem = list.getFirstListItem();
|
||||
SemanticTextNode textNode = convertListItemToSemanticTextNode(listItem);
|
||||
textNodes.add(textNode);
|
||||
textNodeStatistics.addTextNode(textNode);
|
||||
possibleHeadingsInList.put(textNode, list);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isNotHeadings(PDFList list) {
|
||||
for (int i = 0; i < list.getListItems().size() - 1; i++) {
|
||||
boolean onlyLineArtChunks = true;
|
||||
List<ListItem> listItems = list.getListItems();
|
||||
if (listItems.get(i).getContents().isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (IObject item : listItems.get(i).getContents()) {
|
||||
if (!(item instanceof LineArtChunk)) {
|
||||
onlyLineArtChunks = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (onlyLineArtChunks) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void setHeadings(List<IObject> contents) {
|
||||
for (int index = 0; index < contents.size(); index++) {
|
||||
IObject content = contents.get(index);
|
||||
if (content instanceof SemanticTextNode && ((INode) content).getSemanticType() == SemanticType.HEADING && !(content instanceof SemanticHeading)) {
|
||||
SemanticHeading heading = new SemanticHeading((SemanticTextNode) content);
|
||||
contents.set(index, heading);
|
||||
StaticLayoutContainers.getHeadings().add(heading);
|
||||
}
|
||||
if (content instanceof TableBorder) {
|
||||
TableBorder table = (TableBorder) content;
|
||||
if (table.isTextBlock()) {
|
||||
List<IObject> textBlockContents = table.getCell(0, 0).getContents();
|
||||
setHeadings(textBlockContents);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and assigns heading levels based on text style.
|
||||
* Groups headings by text style and assigns levels from 1 upwards.
|
||||
*/
|
||||
public static void detectHeadingsLevels() {
|
||||
SortedMap<TextStyle, Set<SemanticHeading>> map = new TreeMap<>();
|
||||
List<SemanticHeading> headings = StaticLayoutContainers.getHeadings();
|
||||
List<SemanticHeading> colorlessHeadings = new ArrayList<>();
|
||||
for (SemanticHeading heading : headings) {
|
||||
if (TextNodeUtils.getTextColorOrNull(heading) == null) {
|
||||
colorlessHeadings.add(heading);
|
||||
continue;
|
||||
}
|
||||
TextStyle textStyle = TextStyle.getTextStyle(heading);
|
||||
map.computeIfAbsent(textStyle, k -> new HashSet<>()).add(heading);
|
||||
}
|
||||
int level = 1;
|
||||
TextStyle previousTextStyle = null;
|
||||
for (Map.Entry<TextStyle, Set<SemanticHeading>> entry : map.entrySet()) {
|
||||
if (previousTextStyle != null && previousTextStyle.compareTo(entry.getKey()) != 0) {
|
||||
level++;
|
||||
}
|
||||
previousTextStyle = entry.getKey();
|
||||
for (SemanticHeading heading : entry.getValue()) {
|
||||
heading.setHeadingLevel(level);
|
||||
}
|
||||
}
|
||||
// Headings without color info get level based on font size relative to existing levels
|
||||
for (SemanticHeading heading : colorlessHeadings) {
|
||||
heading.setHeadingLevel(findClosestLevel(heading, map));
|
||||
}
|
||||
}
|
||||
|
||||
private static int findClosestLevel(SemanticHeading heading, SortedMap<TextStyle, Set<SemanticHeading>> map) {
|
||||
if (map.isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
double fontSize = heading.getFontSize();
|
||||
int bestLevel = 1;
|
||||
double bestDiff = Double.MAX_VALUE;
|
||||
int level = 1;
|
||||
TextStyle previousStyle = null;
|
||||
for (Map.Entry<TextStyle, Set<SemanticHeading>> entry : map.entrySet()) {
|
||||
if (previousStyle != null && previousStyle.compareTo(entry.getKey()) != 0) {
|
||||
level++;
|
||||
}
|
||||
previousStyle = entry.getKey();
|
||||
SemanticHeading representative = entry.getValue().iterator().next();
|
||||
double diff = Math.abs(representative.getFontSize() - fontSize);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
bestLevel = level;
|
||||
}
|
||||
}
|
||||
return bestLevel;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.consumers.ContrastRatioConsumer;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Processor for detecting hidden text in PDF documents.
|
||||
* Identifies text with low contrast ratio against the background.
|
||||
*/
|
||||
public class HiddenTextProcessor {
|
||||
private static final Logger LOGGER = Logger.getLogger(HiddenTextProcessor.class.getCanonicalName());
|
||||
|
||||
private static final double MIN_CONTRAST_RATIO = 1.2d;
|
||||
|
||||
/**
|
||||
* Finds and marks or filters hidden text based on contrast ratio.
|
||||
*
|
||||
* @param contents the page contents to process
|
||||
* @param isFilterHiddenText whether to filter out hidden text or just mark it
|
||||
* @return the processed list of content objects
|
||||
*/
|
||||
public static List<IObject> findHiddenText(List<IObject> contents, boolean isFilterHiddenText) {
|
||||
if (StaticContainers.getImagesUtils() == null) {
|
||||
LOGGER.log(Level.WARNING, "Hidden-text filtering will be skipped for this document.");
|
||||
return contents;
|
||||
}
|
||||
List<IObject> result = new LinkedList<>();
|
||||
try {
|
||||
ContrastRatioConsumer contrastRatioConsumer = new ContrastRatioConsumer();
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof TextChunk) {
|
||||
TextChunk textChunk = (TextChunk) content;
|
||||
contrastRatioConsumer.calculateContrastRatio(textChunk);
|
||||
if (textChunk.getContrastRatio() < MIN_CONTRAST_RATIO) {
|
||||
if (!isFilterHiddenText) {
|
||||
textChunk.setHiddenText(true);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.add(content);
|
||||
}
|
||||
} finally {
|
||||
StaticContainers.getImagesUtils().clearRenderedPages();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+1529
File diff suppressed because it is too large
Load Diff
+148
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.opendataloader.pdf.utils.BulletedParagraphUtils;
|
||||
import org.opendataloader.pdf.utils.levels.*;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticHeading;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTextNode;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListItem;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.PDFList;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Stack;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class LevelProcessor {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(LevelProcessor.class.getCanonicalName());
|
||||
|
||||
private static boolean isDocTitleSet = false;
|
||||
|
||||
public static void detectLevels(List<List<IObject>> contents) {
|
||||
setLevels(contents, new Stack<>());
|
||||
}
|
||||
|
||||
private static void setLevels(List<List<IObject>> contents, Stack<LevelInfo> levelInfos) {
|
||||
int levelInfosSize = levelInfos.size();
|
||||
for (List<IObject> pageContents : contents) {
|
||||
for (IObject content : pageContents) {
|
||||
if (content instanceof SemanticHeading) {
|
||||
setLevelForHeading((SemanticHeading) content);
|
||||
continue;
|
||||
}
|
||||
LevelInfo levelInfo = null;
|
||||
Integer index = null;
|
||||
if (content instanceof PDFList) {
|
||||
PDFList previousList = ((PDFList) content).getPreviousList();
|
||||
if (previousList != null) {
|
||||
if (previousList.getLevel() == null) {
|
||||
LOGGER.log(Level.WARNING, "List without detected level");
|
||||
} else {
|
||||
index = Integer.parseInt(previousList.getLevel()) - 1;
|
||||
}
|
||||
}
|
||||
if (index == null) {
|
||||
levelInfo = new ListLevelInfo((PDFList) content);
|
||||
}
|
||||
} else if (content instanceof TableBorder) {
|
||||
TableBorder previousTable = ((TableBorder) content).getPreviousTable();
|
||||
if (previousTable != null) {
|
||||
if (previousTable.getLevel() == null) {
|
||||
LOGGER.log(Level.WARNING, "Table without detected level");
|
||||
} else {
|
||||
index = Integer.parseInt(previousTable.getLevel()) - 1;
|
||||
}
|
||||
}
|
||||
if (index == null) {
|
||||
TableBorder table = (TableBorder) content;
|
||||
setLevelForTable(table);
|
||||
if (!table.isTextBlock()) {
|
||||
levelInfo = new TableLevelInfo(table);
|
||||
}
|
||||
}
|
||||
} else if (content instanceof SemanticTextNode) {
|
||||
if (BulletedParagraphUtils.isBulletedParagraph((SemanticTextNode) content)) {
|
||||
if (BulletedParagraphUtils.isBulletedLineArtParagraph((SemanticTextNode) content)) {
|
||||
levelInfo = new LineArtBulletParagraphLevelInfo((SemanticTextNode) content);
|
||||
} else {
|
||||
levelInfo = new TextBulletParagraphLevelInfo((SemanticTextNode) content);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (levelInfo == null && index == null) {
|
||||
continue;
|
||||
}
|
||||
if (index == null) {
|
||||
index = getLevelInfoIndex(levelInfos, levelInfo);
|
||||
}
|
||||
if (index == null) {
|
||||
content.setLevel(String.valueOf(levelInfos.size() + 1));
|
||||
levelInfos.add(levelInfo);
|
||||
} else {
|
||||
content.setLevel(String.valueOf(index + 1));
|
||||
for (int i = Math.max(index + 1, levelInfosSize); i < levelInfos.size(); i++) {
|
||||
levelInfos.pop();
|
||||
}
|
||||
}
|
||||
if (content instanceof PDFList) {
|
||||
for (ListItem listItem : ((PDFList) content).getListItems()) {
|
||||
setLevels(Collections.singletonList(listItem.getContents()), levelInfos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
isDocTitleSet = false;
|
||||
}
|
||||
|
||||
private static void setLevelForHeading(SemanticHeading heading) {
|
||||
if (heading.getHeadingLevel() == 1 && !isDocTitleSet) {
|
||||
heading.setLevel("Doctitle");
|
||||
isDocTitleSet = true;
|
||||
} else {
|
||||
heading.setLevel("Subtitle");
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer getLevelInfoIndex(Stack<LevelInfo> levelInfos, LevelInfo levelInfo) {
|
||||
for (int index = 0; index < levelInfos.size(); index++) {
|
||||
LevelInfo currentLevelInfo = levelInfos.get(index);
|
||||
if (LevelInfo.areSameLevelsInfos(currentLevelInfo, levelInfo)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void setLevelForTable(TableBorder tableBorder) {
|
||||
for (int rowNumber = 0; rowNumber < tableBorder.getNumberOfRows(); rowNumber++) {
|
||||
TableBorderRow row = tableBorder.getRow(rowNumber);
|
||||
for (int colNumber = 0; colNumber < tableBorder.getNumberOfColumns(); colNumber++) {
|
||||
TableBorderCell tableBorderCell = row.getCell(colNumber);
|
||||
if (tableBorderCell.getRowNumber() == rowNumber && tableBorderCell.getColNumber() == colNumber) {
|
||||
setLevels(Collections.singletonList(tableBorderCell.getContents()), new Stack<>());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+573
@@ -0,0 +1,573 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.opendataloader.pdf.utils.BulletedParagraphUtils;
|
||||
import org.verapdf.as.ASAtom;
|
||||
import org.verapdf.wcag.algorithms.entities.INode;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTextNode;
|
||||
import org.verapdf.wcag.algorithms.entities.content.*;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.SemanticType;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.TextAlignment;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListInterval;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListItem;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.PDFList;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.TextListInterval;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.info.ListItemInfo;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.info.ListItemTextInfo;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.ChunksMergeUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.ListLabelsUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.ListUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.NodeUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.listLabelsDetection.NumberingStyleNames;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class ListProcessor {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(ListProcessor.class.getCanonicalName());
|
||||
|
||||
private static final double LIST_ITEM_PROBABILITY = 0.7;
|
||||
private static final double LIST_ITEM_BASELINE_DIFFERENCE = 1.2;
|
||||
private static final double LIST_ITEM_X_INTERVAL_RATIO = 0.3;
|
||||
private static final Pattern ATTACHMENTS_PATTERN = Pattern.compile("^붙\\s*임\\s*(?=.)");
|
||||
|
||||
/**
|
||||
* Maximum number of intervals to scan backward when matching a TextLine to an existing list.
|
||||
* Prevents O(n²) scaling on large documents. A higher value is safer but slower.
|
||||
*/
|
||||
private static final int MAX_LIST_INTERVAL_LOOKBACK = 500;
|
||||
|
||||
private static final Map<String, ASAtom> listNumberingMap = new HashMap<>();
|
||||
|
||||
static {
|
||||
listNumberingMap.put(NumberingStyleNames.ENGLISH_LETTERS, ASAtom.ORDERED);
|
||||
listNumberingMap.put(NumberingStyleNames.ENGLISH_LETTERS_UPPER_CASE, ASAtom.UPPER_ALPHA);
|
||||
listNumberingMap.put(NumberingStyleNames.ENGLISH_LETTERS_LOWER_CASE, ASAtom.LOWER_ALPHA);
|
||||
listNumberingMap.put(NumberingStyleNames.ROMAN_NUMBERS_LOWER_CASE, ASAtom.LOWER_ROMAN);
|
||||
listNumberingMap.put(NumberingStyleNames.ROMAN_NUMBERS, ASAtom.ORDERED);
|
||||
listNumberingMap.put(NumberingStyleNames.ROMAN_NUMBERS_UPPER_CASE, ASAtom.UPPER_ROMAN);
|
||||
listNumberingMap.put(NumberingStyleNames.KOREAN_LETTERS, ASAtom.ORDERED);
|
||||
listNumberingMap.put(NumberingStyleNames.ARABIC_NUMBERS, ASAtom.DECIMAL);
|
||||
listNumberingMap.put(NumberingStyleNames.CIRCLED_ARABIC_NUMBERS, ASAtom.ORDERED);
|
||||
listNumberingMap.put(NumberingStyleNames.UNORDERED,ASAtom.UNORDERED);
|
||||
listNumberingMap.put(NumberingStyleNames.UNKNOWN, ASAtom.NONE);
|
||||
}
|
||||
|
||||
public static ASAtom getListNumbering(String numberingStyle) {
|
||||
return listNumberingMap.get(numberingStyle);
|
||||
}
|
||||
|
||||
public static void processLists(List<List<IObject>> contents, boolean isTableCell) {
|
||||
List<TextListInterval> intervalsList = getTextLabelListIntervals(contents);
|
||||
for (TextListInterval interval : intervalsList) {
|
||||
for (ListItemTextInfo info : interval.getListItemsInfos()) {
|
||||
info.getListItemValue().setListLine(true);
|
||||
}
|
||||
}
|
||||
for (TextListInterval interval : intervalsList) {
|
||||
// if (interval.getNumberOfColumns() > 1/*== interval.getNumberOfListItems()*/) {//to fix bounding box for multi-column lists
|
||||
// continue;
|
||||
// }
|
||||
if (!isCorrectList(interval)) {//todo move to arabic number list recognition
|
||||
continue;
|
||||
}
|
||||
Integer currentPageNumber = interval.getListItemsInfos().get(0).getPageNumber();
|
||||
int index = 0;
|
||||
PDFList previousList = null;
|
||||
interval.setCommonSuffixLengthToAllInfos();
|
||||
for (int i = 0; i < interval.getNumberOfListItems(); i++) {
|
||||
ListItemInfo currentInfo = interval.getListItemsInfos().get(i);
|
||||
if (!Objects.equals(currentInfo.getPageNumber(), currentPageNumber)) {
|
||||
PDFList list = calculateList(interval, index, i - 1, contents.get(isTableCell ? 0 : currentPageNumber));
|
||||
for (ListItem listItem : list.getListItems()) {
|
||||
listItem.setContents(processListItemContent(listItem.getContents()));
|
||||
}
|
||||
if (previousList != null) {
|
||||
PDFList.setListConnected(previousList, list);
|
||||
}
|
||||
currentPageNumber = currentInfo.getPageNumber();
|
||||
index = i;
|
||||
previousList = list;
|
||||
}
|
||||
}
|
||||
PDFList list = calculateList(interval, index, interval.getNumberOfListItems() - 1, contents.get(isTableCell ? 0 : currentPageNumber));
|
||||
for (ListItem listItem : list.getListItems()) {
|
||||
listItem.setContents(processListItemContent(listItem.getContents()));
|
||||
}
|
||||
if (previousList != null) {
|
||||
PDFList.setListConnected(previousList, list);
|
||||
}
|
||||
}
|
||||
contents.replaceAll(DocumentProcessor::removeNullObjectsFromList);
|
||||
}
|
||||
|
||||
private static List<IObject> processListItemContent(List<IObject> contents) {
|
||||
List<IObject> newContents = ParagraphProcessor.processParagraphs(contents);
|
||||
newContents = ListProcessor.processListsFromTextNodes(newContents);
|
||||
DocumentProcessor.setIDs(newContents);
|
||||
List<List<IObject>> contentsList = new ArrayList<>(1);
|
||||
contentsList.add(newContents);
|
||||
ListProcessor.checkNeighborLists(contentsList);
|
||||
newContents = contentsList.get(0);
|
||||
return newContents;
|
||||
}
|
||||
|
||||
private static void processTextNodeListItemContent(List<IObject> contents) {
|
||||
DocumentProcessor.setIDs(contents);
|
||||
}
|
||||
|
||||
private static List<TextListInterval> getTextLabelListIntervals(List<List<IObject>> contents) {
|
||||
List<TextListInterval> listIntervals = new ArrayList<>();
|
||||
for (List<IObject> pageContents : contents) {
|
||||
for (int i = 0; i < pageContents.size(); i++) {
|
||||
IObject content = pageContents.get(i);
|
||||
if (!(content instanceof TextLine)) {
|
||||
continue;
|
||||
}
|
||||
TextLine line = (TextLine) content;
|
||||
String value = line.getValue();
|
||||
if (value.isEmpty() || line.isHiddenText()) {
|
||||
continue;
|
||||
}
|
||||
ListItemTextInfo listItemTextInfo = createListItemTextInfo(i, line, value);
|
||||
processListItem(listIntervals, listItemTextInfo);
|
||||
}
|
||||
}
|
||||
LinkedHashSet<TextListInterval> intervalsList = new LinkedHashSet<>();
|
||||
for (TextListInterval interval : listIntervals) {
|
||||
if (interval != null && interval.getListItemsInfos().size() > 1) {
|
||||
intervalsList.add(interval);
|
||||
}
|
||||
}
|
||||
List<TextListInterval> result = new ArrayList<>(intervalsList);
|
||||
Collections.reverse(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void processListItem(List<TextListInterval> listIntervals, ListItemTextInfo listItemTextInfo) {
|
||||
double maxXGap = getMaxXGap(listItemTextInfo.getListItemValue().getFontSize());
|
||||
boolean isSingle = true;
|
||||
boolean shouldHaveSameLeft = false;
|
||||
boolean shouldHaveSameLeftDifference = false;
|
||||
boolean isUnordered = true;
|
||||
Double previousLeftDifference = null;
|
||||
int minIndex = Math.max(0, listIntervals.size() - MAX_LIST_INTERVAL_LOOKBACK);
|
||||
for (int index = listIntervals.size() - 1; index >= minIndex; index--) {
|
||||
TextListInterval interval = listIntervals.get(index);
|
||||
ListItemTextInfo preivousListItemTextInfo = interval.getLastListItemInfo();
|
||||
if (Objects.equals(listItemTextInfo.getPageNumber(), preivousListItemTextInfo.getPageNumber()) &&
|
||||
listItemTextInfo.getListItemValue().getTopY() > preivousListItemTextInfo.getListItemValue().getTopY()) {
|
||||
break;
|
||||
}
|
||||
double leftDifference = listItemTextInfo.getListItemValue().getLeftX() -
|
||||
preivousListItemTextInfo.getListItemValue().getLeftX();
|
||||
boolean haveSameLeft = NodeUtils.areCloseNumbers(leftDifference, 0, maxXGap);
|
||||
try {
|
||||
if (NodeUtils.areCloseNumbers(leftDifference, 0, 4 * maxXGap) &&
|
||||
ListLabelsUtils.isTwoListItemsOfOneList(interval, listItemTextInfo,
|
||||
!haveSameLeft, isUnordered)) {
|
||||
listIntervals.add(interval);
|
||||
isSingle = false;
|
||||
break;
|
||||
}
|
||||
} catch (StringIndexOutOfBoundsException e) {
|
||||
// Malformed label cannot be matched; treat as new list (isSingle remains true)
|
||||
LOGGER.log(Level.WARNING, "Malformed list label, starting new list: " + listItemTextInfo.getListItemValue().getValue(), e);
|
||||
break;
|
||||
}
|
||||
if (shouldHaveSameLeftDifference && !NodeUtils.areCloseNumbers(previousLeftDifference, leftDifference)) {
|
||||
break;
|
||||
}
|
||||
if (leftDifference > maxXGap) {
|
||||
isUnordered = false;
|
||||
shouldHaveSameLeftDifference = true;
|
||||
}
|
||||
previousLeftDifference = leftDifference;
|
||||
if (haveSameLeft) {
|
||||
shouldHaveSameLeft = true;
|
||||
} else if (shouldHaveSameLeft) {
|
||||
isUnordered = false;
|
||||
// break;
|
||||
}
|
||||
if (interval.getListItemsInfos().size() > 1 && haveSameLeft &&
|
||||
!NumberingStyleNames.UNORDERED.equals(interval.getNumberingStyle())) {
|
||||
isUnordered = false;
|
||||
}
|
||||
}
|
||||
if (isSingle) {
|
||||
TextListInterval listInterval = new TextListInterval();
|
||||
listInterval.getListItemsInfos().add(listItemTextInfo);
|
||||
listIntervals.add(listInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private static ListItemTextInfo createListItemTextInfo(int i, TextLine line, String value) {
|
||||
Matcher matcher = ATTACHMENTS_PATTERN.matcher(value);
|
||||
if (matcher.find()) {
|
||||
int length = matcher.group().length();
|
||||
line = new TextLine(line);
|
||||
line.getBoundingBox().setLeftX(line.getSymbolStartCoordinate(length));
|
||||
value = value.substring(length);
|
||||
}
|
||||
return new ListItemTextInfo(i, SemanticType.PARAGRAPH,
|
||||
line, value, true);
|
||||
}
|
||||
|
||||
private static PDFList calculateList(TextListInterval interval, int startIndex, int endIndex, List<IObject> pageContents) {
|
||||
PDFList list = new PDFList();
|
||||
list.setNumberingStyle(interval.getNumberingStyle());
|
||||
list.setCommonPrefix(interval.getCommonPrefix());
|
||||
boolean isListSet = false;
|
||||
for (int index = startIndex; index <= endIndex; index++) {
|
||||
ListItemTextInfo currentInfo = interval.getListItemsInfos().get(index);
|
||||
int nextIndex = index != endIndex ? interval.getListItemsInfos().get(index + 1).getIndex() : pageContents.size();
|
||||
ListItem listItem = new ListItem(new BoundingBox(), null);
|
||||
IObject object = pageContents.get(currentInfo.getIndex());
|
||||
if (object == null || object instanceof PDFList) {
|
||||
LOGGER.log(Level.INFO, "List item is connected with different lists");
|
||||
continue;
|
||||
}
|
||||
pageContents.set(currentInfo.getIndex(), isListSet ? null : list);
|
||||
isListSet = true;
|
||||
if (object instanceof SemanticTextNode) {
|
||||
SemanticTextNode textNode = (SemanticTextNode) object;
|
||||
for (TextLine textLine : textNode.getFirstColumn().getLines()) {
|
||||
listItem.add(textLine);
|
||||
}
|
||||
} else {
|
||||
TextLine textLine = (TextLine) object;
|
||||
listItem.add(textLine);
|
||||
}
|
||||
if (index != endIndex) {
|
||||
addContentToListItem(nextIndex, currentInfo, pageContents, listItem);
|
||||
} else {
|
||||
addContentToLastPageListItem(nextIndex, currentInfo, pageContents, listItem);
|
||||
}
|
||||
listItem.setLabelLength(currentInfo.getLabelLength());
|
||||
list.add(listItem);
|
||||
}
|
||||
if (list.getListItems().isEmpty()) {
|
||||
LOGGER.log(Level.WARNING, "List is not added to contents");
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static void addContentToListItem(int nextIndex, ListItemInfo currentInfo, List<IObject> pageContents,
|
||||
ListItem listItem) {
|
||||
boolean isListItem = true;
|
||||
TextLine previousTextLine = null;
|
||||
for (int index = currentInfo.getIndex() + 1; index < nextIndex; index++) {
|
||||
IObject content = pageContents.get(index);
|
||||
if (content instanceof TextLine) {
|
||||
TextLine currentTextLine = (TextLine) content;
|
||||
if (previousTextLine != null) {
|
||||
if (isListItem && isListItemLine(listItem, previousTextLine, currentTextLine)) {
|
||||
listItem.add(previousTextLine);
|
||||
} else {
|
||||
isListItem = false;
|
||||
listItem.getContents().add(previousTextLine);
|
||||
}
|
||||
}
|
||||
previousTextLine = currentTextLine;
|
||||
} else if (content != null) {
|
||||
if (previousTextLine != null) {
|
||||
if (isListItem && isListItemLine(listItem, previousTextLine, null)) {
|
||||
listItem.add(previousTextLine);
|
||||
} else {
|
||||
isListItem = false;
|
||||
listItem.getContents().add(previousTextLine);
|
||||
}
|
||||
previousTextLine = null;
|
||||
}
|
||||
listItem.getContents().add(content);
|
||||
}
|
||||
pageContents.set(index, null);
|
||||
}
|
||||
if (previousTextLine != null) {
|
||||
if (isListItem && isListItemLine(listItem, previousTextLine, null)) {
|
||||
listItem.add(previousTextLine);
|
||||
} else {
|
||||
listItem.getContents().add(previousTextLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addContentToLastPageListItem(int nextIndex, ListItemInfo currentInfo, List<IObject> pageContents,
|
||||
ListItem listItem) {
|
||||
TextLine previousTextLine = null;
|
||||
Integer previousIndex = null;
|
||||
for (int index = currentInfo.getIndex() + 1; index < nextIndex; index++) {
|
||||
IObject content = pageContents.get(index);
|
||||
if (!(content instanceof TextLine)) {
|
||||
continue;
|
||||
}
|
||||
TextLine nextLine = (TextLine) content;
|
||||
if (previousTextLine != null) {
|
||||
if (isListItemLine(listItem, previousTextLine, nextLine)) {
|
||||
listItem.add(previousTextLine);
|
||||
pageContents.set(previousIndex, null);
|
||||
} else {
|
||||
previousTextLine = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
previousTextLine = nextLine;
|
||||
previousIndex = index;
|
||||
}
|
||||
if (previousTextLine != null) {
|
||||
if (isListItemLine(listItem, previousTextLine, null)) {
|
||||
listItem.add(previousTextLine);
|
||||
pageContents.set(previousIndex, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isListItemLine(ListItem listItem, TextLine currentLine, TextLine nextLine) {
|
||||
TextLine listLine = listItem.getLastLine();
|
||||
if (ChunksMergeUtils.mergeLeadingProbability(listLine, currentLine) < LIST_ITEM_PROBABILITY) {
|
||||
return false;
|
||||
}
|
||||
if (nextLine != null) {
|
||||
if (Math.abs(listLine.getBaseLine() - currentLine.getBaseLine()) >
|
||||
LIST_ITEM_BASELINE_DIFFERENCE * Math.abs(currentLine.getBaseLine() - nextLine.getBaseLine())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (listItem.getLinesNumber() > 1) {
|
||||
TextAlignment alignment = ChunksMergeUtils.getAlignment(listLine, currentLine);
|
||||
if (alignment != TextAlignment.JUSTIFY && alignment != TextAlignment.LEFT) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
double maxXGap = getMaxXGap(listLine.getFontSize());
|
||||
if (currentLine.getLeftX() < listLine.getLeftX() - maxXGap) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (BulletedParagraphUtils.isLabeledLine(currentLine)) {
|
||||
return false;
|
||||
}
|
||||
if (currentLine.isListLine()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double getMaxXGap(double fontSize) {
|
||||
return fontSize * LIST_ITEM_X_INTERVAL_RATIO;
|
||||
}
|
||||
|
||||
public static List<IObject> processListsFromTextNodes(List<IObject> contents) {
|
||||
List<SemanticTextNode> textNodes = new ArrayList<>();
|
||||
List<Integer> textNodesIndexes = new ArrayList<>();
|
||||
for (int index = 0; index < contents.size(); index++) {
|
||||
IObject content = contents.get(index);
|
||||
if (content instanceof SemanticTextNode) {
|
||||
textNodes.add((SemanticTextNode) content);
|
||||
textNodesIndexes.add(index);
|
||||
}
|
||||
}
|
||||
List<ListItemTextInfo> textChildrenInfo = calculateTextChildrenInfo(textNodes);
|
||||
List<INode> nodes = new LinkedList<>(textNodes);
|
||||
Set<ListInterval> intervals = ListUtils.getChildrenListIntervals(ListLabelsUtils.getListItemsIntervals(textChildrenInfo), nodes);
|
||||
for (ListInterval interval : intervals) {
|
||||
updateListInterval(interval, textNodesIndexes);
|
||||
TextListInterval textListInterval = new TextListInterval(interval);
|
||||
if (!isCorrectList(textListInterval)) {
|
||||
continue;
|
||||
}
|
||||
textListInterval.setCommonSuffixLengthToAllInfos();
|
||||
PDFList list = calculateList(textListInterval, 0, interval.getNumberOfListItems() - 1, contents);
|
||||
for (ListItem listItem : list.getListItems()) {
|
||||
processTextNodeListItemContent(listItem.getContents());
|
||||
}
|
||||
}
|
||||
return DocumentProcessor.removeNullObjectsFromList(contents);
|
||||
}
|
||||
|
||||
private static List<ListItemTextInfo> calculateTextChildrenInfo(List<SemanticTextNode> textNodes) {
|
||||
List<ListItemTextInfo> textChildrenInfo = new ArrayList<>(textNodes.size());
|
||||
for (int i = 0; i < textNodes.size(); i++) {
|
||||
SemanticTextNode textNode = textNodes.get(i);
|
||||
if (textNode.isSpaceNode() || textNode.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
TextLine line = textNode.getFirstNonSpaceLine();
|
||||
TextLine secondLine = textNode.getNonSpaceLine(1);
|
||||
textChildrenInfo.add(new ListItemTextInfo(i, textNode.getSemanticType(),
|
||||
line, line.getValue(), secondLine == null));
|
||||
}
|
||||
return textChildrenInfo;
|
||||
}
|
||||
|
||||
private static void updateListInterval(ListInterval interval, List<Integer> textNodesIndexes) {
|
||||
for (ListItemInfo itemInfo : interval.getListItemsInfos()) {
|
||||
itemInfo.setIndex(textNodesIndexes.get(itemInfo.getIndex()));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isCorrectList(TextListInterval interval) {//move inside arabic numeration detection
|
||||
return !isDoubles(interval);
|
||||
}
|
||||
|
||||
private static boolean isDoubles(TextListInterval interval) {
|
||||
for (ListItemTextInfo listItemTextInfo : interval.getListItemsInfos()) {
|
||||
if (listItemTextInfo != null) {
|
||||
if (!listItemTextInfo.getListItemValue().getValue().matches("^\\d+\\.\\d+$")) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void checkNeighborLists(List<List<IObject>> contents) {
|
||||
PDFList previousList = null;
|
||||
SemanticTextNode middleContent = null;
|
||||
for (List<IObject> pageContents : contents) {
|
||||
DocumentProcessor.setIndexesForContentsList(pageContents);
|
||||
for (IObject content : pageContents) {
|
||||
if (content instanceof PDFList) {
|
||||
PDFList currentList = (PDFList) content;
|
||||
if (previousList != null) {
|
||||
if (previousList.getNextList() == null && currentList.getPreviousList() == null) {
|
||||
if (isNeighborLists(previousList, currentList, middleContent)) {
|
||||
if (middleContent != null) {
|
||||
addMiddleContentToList(previousList, currentList, middleContent, pageContents);
|
||||
}
|
||||
if (Objects.equals(previousList.getPageNumber(), currentList.getPageNumber()) &&
|
||||
BoundingBox.areHorizontalOverlapping(previousList.getBoundingBox(), currentList.getBoundingBox())) {
|
||||
previousList.add(currentList);
|
||||
pageContents.set(currentList.getIndex(), null);
|
||||
currentList = null;
|
||||
} else {
|
||||
PDFList.setListConnected(previousList, currentList);
|
||||
}
|
||||
}
|
||||
} else if (Objects.equals(previousList.getNextListId(), currentList.getRecognizedStructureId())) {
|
||||
if (middleContent != null && isMiddleContentPartOfList(previousList, middleContent, currentList)) {
|
||||
addMiddleContentToList(previousList, currentList, middleContent, pageContents);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentList != null) {
|
||||
previousList = currentList;
|
||||
}
|
||||
middleContent = null;
|
||||
} else {
|
||||
if (!HeaderFooterProcessor.isHeaderOrFooter(content) &&
|
||||
!(content instanceof LineChunk) && !(content instanceof LineArtChunk) &&
|
||||
!(content instanceof ImageChunk)) {
|
||||
if (middleContent == null && content instanceof SemanticTextNode) {
|
||||
middleContent = (SemanticTextNode) content;
|
||||
} else {
|
||||
middleContent = null;
|
||||
previousList = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
contents.replaceAll(DocumentProcessor::removeNullObjectsFromList);
|
||||
}
|
||||
|
||||
private static void addMiddleContentToList(PDFList previousList, PDFList currentList, SemanticTextNode middleContent, List<IObject> pageContents) {
|
||||
ListItem lastListItem = previousList.getLastListItem();
|
||||
if (Objects.equals(lastListItem.getPageNumber(), middleContent.getPageNumber()) &&
|
||||
BoundingBox.areHorizontalOverlapping(lastListItem.getBoundingBox(), middleContent.getBoundingBox())) {
|
||||
for (TextColumn textColumn : middleContent.getColumns()) {
|
||||
lastListItem.add(textColumn.getLines());
|
||||
}
|
||||
previousList.getBoundingBox().union(middleContent.getBoundingBox());
|
||||
pageContents.set(middleContent.getIndex(), null);
|
||||
} else {
|
||||
if (middleContent.getTopY() > currentList.getTopY()) {
|
||||
addFirstLBodyToList(currentList, middleContent);
|
||||
pageContents.set(middleContent.getIndex(), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addFirstLBodyToList(PDFList currentList, SemanticTextNode middleContent) {
|
||||
ListItem listItem = new ListItem(new BoundingBox(), middleContent.getRecognizedStructureId());
|
||||
for (TextColumn textColumn : middleContent.getColumns()) {
|
||||
listItem.add(textColumn.getLines());
|
||||
}
|
||||
currentList.add(0, listItem);
|
||||
}
|
||||
|
||||
public static boolean isNeighborLists(PDFList previousList, PDFList currentList, SemanticTextNode middleContent) {
|
||||
List<ListItemTextInfo> textChildrenInfo = getTextChildrenInfosForNeighborLists(previousList, currentList);
|
||||
Set<ListInterval> listIntervals = ListLabelsUtils.getListItemsIntervals(textChildrenInfo);
|
||||
if (listIntervals.size() != 1) {
|
||||
return false;
|
||||
}
|
||||
ListInterval interval = listIntervals.iterator().next();
|
||||
if (interval.getNumberOfListItems() != textChildrenInfo.size()) {
|
||||
return false;
|
||||
}
|
||||
if (middleContent != null && !isMiddleContentPartOfList(previousList, middleContent, currentList)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isMiddleContentPartOfList(PDFList previousList, SemanticTextNode middleContent, PDFList currentList) {
|
||||
if (middleContent.getLeftX() < currentList.getLeftX()) {
|
||||
return false;
|
||||
}
|
||||
if (!Objects.equals(middleContent.getPageNumber(), currentList.getPageNumber())) {
|
||||
return false;
|
||||
}
|
||||
for (ListItem listItem : currentList.getListItems()) {
|
||||
if (listItem.getLinesNumber() > 1) {
|
||||
double xInterval = getMaxXGap(Math.max(listItem.getFontSize(), middleContent.getFontSize()));
|
||||
if (!NodeUtils.areCloseNumbers(listItem.getSecondLine().getLeftX(), middleContent.getLeftX(), xInterval)) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<ListItemTextInfo> getTextChildrenInfosForNeighborLists(PDFList previousList, PDFList currentList) {
|
||||
List<ListItemTextInfo> textChildrenInfo = new ArrayList<>(4);
|
||||
if (previousList.getNumberOfListItems() > 1) {
|
||||
textChildrenInfo.add(createListItemTextInfoFromListItem(0, previousList.getPenultListItem()));
|
||||
}
|
||||
textChildrenInfo.add(createListItemTextInfoFromListItem(1, previousList.getLastListItem()));
|
||||
textChildrenInfo.add(createListItemTextInfoFromListItem(2, currentList.getFirstListItem()));
|
||||
if (currentList.getNumberOfListItems() > 1) {
|
||||
textChildrenInfo.add(createListItemTextInfoFromListItem(3, currentList.getSecondListItem()));
|
||||
}
|
||||
return textChildrenInfo;
|
||||
}
|
||||
|
||||
private static ListItemTextInfo createListItemTextInfoFromListItem(int index, ListItem listItem) {
|
||||
TextLine line = listItem.getFirstLine();
|
||||
return new ListItemTextInfo(index, SemanticType.LIST_ITEM, line, line.getValue(), listItem.getLinesNumber() == 1);
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package org.opendataloader.pdf.processors;
|
||||
|
||||
import org.apache.pdfbox.io.IOUtils;
|
||||
import org.verapdf.as.ASAtom;
|
||||
import org.verapdf.cos.*;
|
||||
import org.verapdf.operator.InlineImageOperator;
|
||||
import org.verapdf.operator.Operator;
|
||||
import org.verapdf.parser.Operators;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.PrintWriter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class PDFStreamWriter {
|
||||
|
||||
public PDFStreamWriter() {
|
||||
|
||||
}
|
||||
|
||||
public byte[] write(List<Object> tokens) throws IOException {
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
PrintWriter printWriter = new PrintWriter(out)) {
|
||||
for (Object rawToken : tokens) {
|
||||
write(rawToken, printWriter, out);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void write(Object rawToken, PrintWriter printWriter, OutputStream out) throws IOException {
|
||||
if (rawToken instanceof COSArray) {
|
||||
out.write("[".getBytes());
|
||||
for (COSObject item : (COSArray) rawToken) {
|
||||
write(item.getDirectBase(), printWriter, out);
|
||||
}
|
||||
out.write("]".getBytes());
|
||||
out.write(" ".getBytes());
|
||||
} else if (rawToken instanceof COSDictionary) {
|
||||
out.write("<<".getBytes());
|
||||
for (Map.Entry<ASAtom, COSObject> item : ((COSDictionary)rawToken).getEntrySet()) {
|
||||
out.write(item.getKey().toString().getBytes());
|
||||
out.write(" ".getBytes());
|
||||
write(item.getValue(), printWriter, out);
|
||||
}
|
||||
out.write(">>".getBytes());
|
||||
out.write(" ".getBytes());
|
||||
} else if (rawToken instanceof COSString) {
|
||||
COSString string = (COSString) rawToken;
|
||||
if (string.isHexadecimal()) {
|
||||
out.write("<".getBytes());
|
||||
out.write(string.getHexString().getBytes());
|
||||
out.write(">".getBytes());
|
||||
out.write(" ".getBytes());
|
||||
} else {
|
||||
out.write("(".getBytes());
|
||||
byte[] bytes = ((COSString) rawToken).get();
|
||||
for (byte currentByte : bytes) {
|
||||
if (currentByte == 40 || currentByte == 41 || currentByte == 92) {
|
||||
out.write('\\');
|
||||
}
|
||||
if (currentByte == 13) {
|
||||
out.write('\\');
|
||||
out.write('r');
|
||||
} else {
|
||||
out.write(currentByte);
|
||||
}
|
||||
}
|
||||
out.write(")".getBytes());
|
||||
out.write(" ".getBytes());
|
||||
}
|
||||
} else if (rawToken instanceof COSBoolean) {
|
||||
out.write(((COSBoolean) rawToken).getBoolean().toString().getBytes());
|
||||
out.write(" ".getBytes());
|
||||
} else if (rawToken instanceof COSBase) {
|
||||
out.write(rawToken.toString().getBytes());
|
||||
out.write(" ".getBytes());
|
||||
} else if (rawToken instanceof COSObject) {
|
||||
write(((COSObject) rawToken).getDirectBase(), printWriter, out);
|
||||
} else if (rawToken instanceof Operator) {
|
||||
Operator operator = (Operator) rawToken;
|
||||
out.write(((Operator) rawToken).getOperator().getBytes());
|
||||
out.write("\n".getBytes());
|
||||
if (Operators.BI.equals(operator.getOperator())) {
|
||||
InlineImageOperator inlineImageOperator = (InlineImageOperator) operator;
|
||||
for (Map.Entry<ASAtom, COSObject> item : inlineImageOperator.getImageParameters().getEntrySet()) {
|
||||
out.write(item.getKey().toString().getBytes());
|
||||
out.write(" ".getBytes());
|
||||
write(item.getValue(), printWriter, out);
|
||||
}
|
||||
out.write(Operators.ID.getBytes());
|
||||
out.write("\n".getBytes());
|
||||
IOUtils.copy(inlineImageOperator.getImageData(), out);
|
||||
out.write("\n".getBytes());
|
||||
out.write(Operators.EI.getBytes());
|
||||
out.write("\n".getBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+542
@@ -0,0 +1,542 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.opendataloader.pdf.utils.BulletedParagraphUtils;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticParagraph;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextBlock;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextColumn;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextLine;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.TextAlignment;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.CaptionUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.ChunksMergeUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.NodeUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.TextChunkUtils;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class ParagraphProcessor {
|
||||
|
||||
public static final double DIFFERENT_LINES_PROBABILITY = 0.75;
|
||||
|
||||
public static List<IObject> processParagraphs(List<IObject> contents) {
|
||||
DocumentProcessor.setIndexesForContentsList(contents);
|
||||
List<TextBlock> blocks = new ArrayList<>();
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof TextLine) {
|
||||
blocks.add(new TextBlock((TextLine) content));
|
||||
}
|
||||
}
|
||||
blocks = detectParagraphsWithJustifyAlignments(blocks);
|
||||
blocks = detectFirstAndLastLinesOfParagraphsWithJustifyAlignments(blocks);
|
||||
blocks = detectParagraphsWithLeftAlignments(blocks, true);
|
||||
blocks = detectParagraphsWithLeftAlignments(blocks, false);
|
||||
blocks = detectFirstLinesOfParagraphWithLeftAlignments(blocks);
|
||||
blocks = detectParagraphsWithCenterAlignments(blocks);
|
||||
blocks = detectParagraphsWithRightAlignments(blocks);
|
||||
blocks = detectTwoLinesParagraphs(blocks);
|
||||
blocks = processOtherLines(blocks);
|
||||
return getContentsWithDetectedParagraphs(contents, blocks);
|
||||
}
|
||||
|
||||
private static List<IObject> getContentsWithDetectedParagraphs(List<IObject> contents, List<TextBlock> blocks) {
|
||||
List<IObject> newContents = new ArrayList<>();
|
||||
Iterator<TextBlock> iterator = blocks.iterator();
|
||||
TextBlock currentBlock = iterator.hasNext() ? iterator.next() : null;
|
||||
Integer currentIndex = currentBlock != null ? currentBlock.getFirstLine().getIndex() : null;
|
||||
for (int index = 0; index < contents.size(); index++) {
|
||||
IObject content = contents.get(index);
|
||||
if (!(content instanceof TextLine)) {
|
||||
newContents.add(content);
|
||||
} else if (Objects.equals(currentIndex, index)) {
|
||||
newContents.add(createParagraphFromTextBlock(currentBlock));
|
||||
currentBlock = iterator.hasNext() ? iterator.next() : null;
|
||||
currentIndex = currentBlock != null ? currentBlock.getFirstLine().getIndex() : null;
|
||||
}
|
||||
}
|
||||
return newContents;
|
||||
}
|
||||
|
||||
private static List<TextBlock> detectParagraphsWithJustifyAlignments(List<TextBlock> textBlocks) {
|
||||
List<TextBlock> newBlocks = new ArrayList<>();
|
||||
if (!textBlocks.isEmpty()) {
|
||||
newBlocks.add(textBlocks.get(0));
|
||||
}
|
||||
if (textBlocks.size() > 1) {
|
||||
for (int i = 1; i < textBlocks.size(); i++) {
|
||||
TextBlock previousBlock = newBlocks.get(newBlocks.size() - 1);
|
||||
TextBlock nextBlock = textBlocks.get(i);
|
||||
TextAlignment textAlignment = ChunksMergeUtils.getAlignment(previousBlock.getLastLine(), nextBlock.getFirstLine());
|
||||
double probability = getDifferentLinesProbability(previousBlock, nextBlock, false, false);
|
||||
if (textAlignment == TextAlignment.JUSTIFY && probability > DIFFERENT_LINES_PROBABILITY &&
|
||||
areTextBlocksHaveSameTextSize(previousBlock, nextBlock)) {
|
||||
previousBlock.add(nextBlock.getLines());
|
||||
previousBlock.setTextAlignment(TextAlignment.JUSTIFY);
|
||||
} else {
|
||||
newBlocks.add(nextBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newBlocks;
|
||||
}
|
||||
|
||||
private static List<TextBlock> detectParagraphsWithCenterAlignments(List<TextBlock> textBlocks) {
|
||||
List<TextBlock> newBlocks = new ArrayList<>();
|
||||
if (!textBlocks.isEmpty()) {
|
||||
newBlocks.add(textBlocks.get(0));
|
||||
}
|
||||
if (textBlocks.size() > 1) {
|
||||
for (int i = 1; i < textBlocks.size(); i++) {
|
||||
TextBlock previousBlock = newBlocks.get(newBlocks.size() - 1);
|
||||
TextBlock nextBlock = textBlocks.get(i);
|
||||
if (areLinesOfParagraphsWithCenterAlignments(previousBlock, nextBlock)) {
|
||||
previousBlock.add(nextBlock.getLines());
|
||||
previousBlock.setTextAlignment(TextAlignment.CENTER);
|
||||
} else {
|
||||
newBlocks.add(nextBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newBlocks;
|
||||
}
|
||||
|
||||
private static boolean areLinesOfParagraphsWithCenterAlignments(TextBlock previousBlock, TextBlock nextBlock) {
|
||||
TextAlignment textAlignment = ChunksMergeUtils.getAlignment(previousBlock.getLastLine(), nextBlock.getFirstLine());
|
||||
if (textAlignment != TextAlignment.CENTER) {
|
||||
return false;
|
||||
}
|
||||
double probability = getDifferentLinesProbability(previousBlock, nextBlock, true, false);
|
||||
if (probability < DIFFERENT_LINES_PROBABILITY) {
|
||||
return false;
|
||||
}
|
||||
if (!areTextBlocksHaveSameTextSize(previousBlock, nextBlock)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<TextBlock> detectFirstAndLastLinesOfParagraphsWithJustifyAlignments(List<TextBlock> textBlocks) {
|
||||
List<TextBlock> newBlocks = new ArrayList<>();
|
||||
if (!textBlocks.isEmpty()) {
|
||||
newBlocks.add(textBlocks.get(0));
|
||||
}
|
||||
if (textBlocks.size() > 1) {
|
||||
for (int i = 1; i < textBlocks.size(); i++) {
|
||||
TextBlock previousBlock = newBlocks.get(newBlocks.size() - 1);
|
||||
TextBlock nextBlock = textBlocks.get(i);
|
||||
TextAlignment textAlignment = ChunksMergeUtils.getAlignment(previousBlock.getLastLine(), nextBlock.getFirstLine());
|
||||
double probability = getDifferentLinesProbability(previousBlock, nextBlock, false, false);
|
||||
if (isFirstLineOfBlock(previousBlock, nextBlock, textAlignment, probability)) {
|
||||
previousBlock.add(nextBlock.getLines());
|
||||
previousBlock.setTextAlignment(TextAlignment.JUSTIFY);
|
||||
previousBlock.setHasStartLine(true);
|
||||
previousBlock.setHasEndLine(nextBlock.isHasEndLine());
|
||||
} else if (isLastLineOfBlock(previousBlock, nextBlock, textAlignment, probability)) {
|
||||
previousBlock.add(nextBlock.getLines());
|
||||
previousBlock.setHasEndLine(true);
|
||||
} else {
|
||||
newBlocks.add(nextBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newBlocks;
|
||||
}
|
||||
|
||||
private static List<TextBlock> detectParagraphsWithLeftAlignments(List<TextBlock> textBlocks, boolean checkStyle) {
|
||||
List<TextBlock> newBlocks = new ArrayList<>();
|
||||
if (!textBlocks.isEmpty()) {
|
||||
newBlocks.add(textBlocks.get(0));
|
||||
}
|
||||
if (textBlocks.size() > 1) {
|
||||
for (int i = 1; i < textBlocks.size(); i++) {
|
||||
TextBlock previousBlock = newBlocks.get(newBlocks.size() - 1);
|
||||
TextBlock nextBlock = textBlocks.get(i);
|
||||
if (areLinesOfParagraphsWithLeftAlignments(previousBlock, nextBlock, checkStyle)) {
|
||||
previousBlock.add(nextBlock.getLines());
|
||||
previousBlock.setTextAlignment(TextAlignment.LEFT);
|
||||
previousBlock.setHasEndLine(false);
|
||||
} else {
|
||||
newBlocks.add(nextBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newBlocks;
|
||||
}
|
||||
|
||||
private static boolean areLinesOfParagraphsWithRightAlignments(TextBlock previousBlock, TextBlock nextBlock) {
|
||||
TextAlignment textAlignment = ChunksMergeUtils.getAlignment(previousBlock.getLastLine(), nextBlock.getFirstLine());
|
||||
if (textAlignment != TextAlignment.RIGHT) {
|
||||
return false;
|
||||
}
|
||||
double probability = getDifferentLinesProbability(previousBlock, nextBlock, false, false);
|
||||
if (probability < DIFFERENT_LINES_PROBABILITY) {
|
||||
return false;
|
||||
}
|
||||
if (previousBlock.getLinesNumber() != 1 && previousBlock.getTextAlignment() != TextAlignment.RIGHT) {
|
||||
return false;
|
||||
}
|
||||
if (!areTextBlocksHaveSameTextSize(previousBlock, nextBlock)) {
|
||||
return false;
|
||||
}
|
||||
if (nextBlock.getLinesNumber() != 1 && nextBlock.getTextAlignment() != TextAlignment.RIGHT) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean areLinesOfParagraphsWithLeftAlignments(TextBlock previousBlock, TextBlock nextBlock, boolean checkStyle) {
|
||||
TextAlignment textAlignment = ChunksMergeUtils.getAlignment(previousBlock.getLastLine(), nextBlock.getFirstLine());
|
||||
if (textAlignment != TextAlignment.LEFT) {
|
||||
return false;
|
||||
}
|
||||
boolean haveSameStyle = TextChunkUtils.areTextChunksHaveSameStyle(previousBlock.getLastLine().getFirstTextChunk(),
|
||||
nextBlock.getFirstLine().getFirstTextChunk());
|
||||
if (checkStyle && !haveSameStyle) {
|
||||
return false;
|
||||
}
|
||||
if (!areTextBlocksHaveSameTextSize(previousBlock, nextBlock)) {
|
||||
return false;
|
||||
}
|
||||
if (BulletedParagraphUtils.isLabeledLine(nextBlock.getFirstLine())) {
|
||||
return false;
|
||||
}
|
||||
boolean areShouldBeCloseLines = false;
|
||||
if (previousBlock.getLinesNumber() != 1) {
|
||||
if (previousBlock.getTextAlignment() == TextAlignment.JUSTIFY) {
|
||||
if (!haveSameStyle) {
|
||||
return false;
|
||||
}
|
||||
areShouldBeCloseLines = true;
|
||||
} else if (previousBlock.getTextAlignment() != TextAlignment.LEFT) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (nextBlock.getLinesNumber() != 1) {
|
||||
if (nextBlock.getTextAlignment() == TextAlignment.JUSTIFY) {
|
||||
if (!haveSameStyle) {
|
||||
return false;
|
||||
}
|
||||
areShouldBeCloseLines = true;
|
||||
} else if (nextBlock.getTextAlignment() != TextAlignment.LEFT) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
double probability = getDifferentLinesProbability(previousBlock, nextBlock, true, areShouldBeCloseLines);
|
||||
if (probability < DIFFERENT_LINES_PROBABILITY) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<TextBlock> detectFirstLinesOfParagraphWithLeftAlignments(List<TextBlock> textBlocks) {
|
||||
List<TextBlock> newBlocks = new ArrayList<>();
|
||||
if (!textBlocks.isEmpty()) {
|
||||
newBlocks.add(textBlocks.get(0));
|
||||
}
|
||||
if (textBlocks.size() > 1) {
|
||||
for (int i = 1; i < textBlocks.size(); i++) {
|
||||
TextBlock previousBlock = newBlocks.get(newBlocks.size() - 1);
|
||||
TextBlock nextBlock = textBlocks.get(i);
|
||||
if (isFirstLineOfParagraphWithLeftAlignment(previousBlock, nextBlock)) {
|
||||
previousBlock.add(nextBlock.getLines());
|
||||
previousBlock.setTextAlignment(TextAlignment.LEFT);
|
||||
previousBlock.setHasStartLine(true);
|
||||
} else {
|
||||
newBlocks.add(nextBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newBlocks;
|
||||
}
|
||||
|
||||
private static boolean isFirstLineOfParagraphWithLeftAlignment(TextBlock previousBlock, TextBlock nextBlock) {
|
||||
double probability = getDifferentLinesProbability(previousBlock, nextBlock, false, false);
|
||||
if (previousBlock.getLinesNumber() != 1) {
|
||||
return false;
|
||||
}
|
||||
if (probability < DIFFERENT_LINES_PROBABILITY) {
|
||||
return false;
|
||||
}
|
||||
if (!areTextBlocksHaveSameTextSize(previousBlock, nextBlock)) {
|
||||
return false;
|
||||
}
|
||||
if (BulletedParagraphUtils.isLabeledLine(nextBlock.getFirstLine())) {
|
||||
return false;
|
||||
}
|
||||
if (nextBlock.isHasStartLine()) {
|
||||
return false;
|
||||
}
|
||||
if (nextBlock.getTextAlignment() != TextAlignment.LEFT) {
|
||||
return false;
|
||||
}
|
||||
if (!CaptionUtils.areOverlapping(previousBlock.getLastLine(), nextBlock.getFirstLine().getBoundingBox())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<TextBlock> detectTwoLinesParagraphs(List<TextBlock> textBlocks) {
|
||||
List<TextBlock> newBlocks = new ArrayList<>();
|
||||
if (!textBlocks.isEmpty()) {
|
||||
newBlocks.add(textBlocks.get(0));
|
||||
}
|
||||
if (textBlocks.size() > 1) {
|
||||
for (int i = 1; i < textBlocks.size(); i++) {
|
||||
TextBlock previousBlock = newBlocks.get(newBlocks.size() - 1);
|
||||
TextBlock nextBlock = textBlocks.get(i);
|
||||
if (isTwoLinesParagraph(previousBlock, nextBlock)) {
|
||||
previousBlock.add(nextBlock.getLines());
|
||||
previousBlock.setTextAlignment(TextAlignment.LEFT);
|
||||
previousBlock.setHasStartLine(true);
|
||||
previousBlock.setHasEndLine(true);
|
||||
} else {
|
||||
newBlocks.add(nextBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newBlocks;
|
||||
}
|
||||
|
||||
private static boolean isTwoLinesParagraph(TextBlock previousBlock, TextBlock nextBlock) {
|
||||
if (previousBlock.getLinesNumber() != 1 || nextBlock.getLinesNumber() != 1) {
|
||||
return false;
|
||||
}
|
||||
double probability = getDifferentLinesProbability(previousBlock, nextBlock, false, false);
|
||||
if (probability < DIFFERENT_LINES_PROBABILITY) {
|
||||
return false;
|
||||
}
|
||||
if (!areTextBlocksHaveSameTextSize(previousBlock, nextBlock)) {
|
||||
return false;
|
||||
}
|
||||
if (BulletedParagraphUtils.isLabeledLine(nextBlock.getFirstLine())) {
|
||||
return false;
|
||||
}
|
||||
if (previousBlock.getLastLine().getLeftX() < nextBlock.getFirstLine().getLeftX() ||
|
||||
previousBlock.getLastLine().getRightX() < nextBlock.getFirstLine().getRightX()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isFirstLineOfBulletedParagraphWithLeftAlignment(TextBlock previousBlock, TextBlock nextBlock) {
|
||||
double probability = getDifferentLinesProbability(previousBlock, nextBlock, false, false);
|
||||
if (probability < DIFFERENT_LINES_PROBABILITY) {
|
||||
return false;
|
||||
}
|
||||
if (previousBlock.getLinesNumber() != 1) {
|
||||
return false;
|
||||
}
|
||||
if (nextBlock.isHasStartLine()) {
|
||||
return false;
|
||||
}
|
||||
if (BulletedParagraphUtils.isLabeledLine(nextBlock.getFirstLine())) {
|
||||
return false;
|
||||
}
|
||||
if (!BulletedParagraphUtils.isLabeledLine(previousBlock.getFirstLine())) {
|
||||
return false;
|
||||
}
|
||||
if (previousBlock.getLastLine().getLeftX() > nextBlock.getFirstLine().getLeftX()) {
|
||||
return false;
|
||||
}
|
||||
if (nextBlock.getTextAlignment() != TextAlignment.LEFT && nextBlock.getLinesNumber() != 1) {
|
||||
return false;
|
||||
}
|
||||
if (!CaptionUtils.areOverlapping(previousBlock.getLastLine(), nextBlock.getFirstLine().getBoundingBox())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<TextBlock> detectParagraphsWithRightAlignments(List<TextBlock> textBlocks) {
|
||||
List<TextBlock> newBlocks = new ArrayList<>();
|
||||
if (!textBlocks.isEmpty()) {
|
||||
newBlocks.add(textBlocks.get(0));
|
||||
}
|
||||
if (textBlocks.size() > 1) {
|
||||
for (int i = 1; i < textBlocks.size(); i++) {
|
||||
TextBlock previousBlock = newBlocks.get(newBlocks.size() - 1);
|
||||
TextBlock nextBlock = textBlocks.get(i);
|
||||
if (areLinesOfParagraphsWithRightAlignments(previousBlock, nextBlock)) {
|
||||
previousBlock.add(nextBlock.getLines());
|
||||
previousBlock.setTextAlignment(TextAlignment.RIGHT);
|
||||
} else {
|
||||
newBlocks.add(nextBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newBlocks;
|
||||
}
|
||||
|
||||
private static List<TextBlock> detectBulletedParagraphsWithLeftAlignments(List<TextBlock> textBlocks) {
|
||||
List<TextBlock> newBlocks = new ArrayList<>();
|
||||
if (!textBlocks.isEmpty()) {
|
||||
newBlocks.add(textBlocks.get(0));
|
||||
}
|
||||
if (textBlocks.size() > 1) {
|
||||
for (int i = 1; i < textBlocks.size(); i++) {
|
||||
TextBlock previousBlock = newBlocks.get(newBlocks.size() - 1);
|
||||
TextBlock nextBlock = textBlocks.get(i);
|
||||
if (isFirstLineOfBulletedParagraphWithLeftAlignment(previousBlock, nextBlock)) {
|
||||
previousBlock.add(nextBlock.getLines());
|
||||
previousBlock.setTextAlignment(TextAlignment.LEFT);
|
||||
previousBlock.setHasStartLine(true);
|
||||
} else {
|
||||
newBlocks.add(nextBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newBlocks;
|
||||
}
|
||||
|
||||
private static List<TextBlock> processOtherLines(List<TextBlock> textBlocks) {
|
||||
List<TextBlock> newBlocks = new ArrayList<>();
|
||||
if (!textBlocks.isEmpty()) {
|
||||
newBlocks.add(textBlocks.get(0));
|
||||
}
|
||||
if (textBlocks.size() > 1) {
|
||||
for (int i = 1; i < textBlocks.size(); i++) {
|
||||
TextBlock previousBlock = newBlocks.get(newBlocks.size() - 1);
|
||||
TextBlock nextBlock = textBlocks.get(i);
|
||||
if (isOneParagraph(previousBlock, nextBlock)) {
|
||||
previousBlock.add(nextBlock.getLines());
|
||||
} else {
|
||||
newBlocks.add(nextBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newBlocks;
|
||||
}
|
||||
|
||||
private static boolean isOneParagraph(TextBlock previousBlock, TextBlock nextBlock) {
|
||||
if (!areCloseStyle(previousBlock, nextBlock)) {
|
||||
return false;
|
||||
}
|
||||
double probability = getDifferentLinesProbability(previousBlock, nextBlock, false, false);
|
||||
if (probability < DIFFERENT_LINES_PROBABILITY) {
|
||||
return false;
|
||||
}
|
||||
if (!areTextBlocksHaveSameTextSize(previousBlock, nextBlock)) {
|
||||
return false;
|
||||
}
|
||||
if (BulletedParagraphUtils.isLabeledLine(nextBlock.getFirstLine())) {
|
||||
return false;
|
||||
}
|
||||
if (!CaptionUtils.areOverlapping(previousBlock.getLastLine(), nextBlock.getFirstLine().getBoundingBox())) {
|
||||
return false;
|
||||
}
|
||||
if (previousBlock.getLinesNumber() != 1 && previousBlock.getTextAlignment() != null) {
|
||||
return false;
|
||||
}
|
||||
if (nextBlock.getLinesNumber() != 1 && nextBlock.getTextAlignment() != null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isFirstLineOfBlock(TextBlock previousBlock, TextBlock nextBlock, TextAlignment textAlignment,
|
||||
double probability) {
|
||||
if (previousBlock.getLinesNumber() != 1) {
|
||||
return false;
|
||||
}
|
||||
if (textAlignment != TextAlignment.RIGHT) {
|
||||
return false;
|
||||
}
|
||||
if (!areTextBlocksHaveSameTextSize(previousBlock, nextBlock)) {
|
||||
return false;
|
||||
}
|
||||
if (nextBlock.getTextAlignment() != TextAlignment.JUSTIFY) {
|
||||
return false;
|
||||
}
|
||||
if (nextBlock.isHasStartLine()) {
|
||||
return false;
|
||||
}
|
||||
if (probability < DIFFERENT_LINES_PROBABILITY) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isLastLineOfBlock(TextBlock previousBlock, TextBlock nextBlock, TextAlignment textAlignment,
|
||||
double probability) {
|
||||
if (nextBlock.getLinesNumber() != 1) {
|
||||
return false;
|
||||
}
|
||||
if (textAlignment != TextAlignment.LEFT) {
|
||||
return false;
|
||||
}
|
||||
if (!areTextBlocksHaveSameTextSize(previousBlock, nextBlock)) {
|
||||
return false;
|
||||
}
|
||||
if (previousBlock.getTextAlignment() != TextAlignment.JUSTIFY) {
|
||||
return false;
|
||||
}
|
||||
if (previousBlock.isHasEndLine()) {
|
||||
return false;
|
||||
}
|
||||
if (probability < DIFFERENT_LINES_PROBABILITY) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static SemanticParagraph createParagraphFromTextBlock(TextBlock textBlock) {
|
||||
SemanticParagraph textParagraph = new SemanticParagraph();
|
||||
textParagraph.getColumns().add(new TextColumn());
|
||||
textParagraph.getLastColumn().getBlocks().add(textBlock);
|
||||
textParagraph.setBoundingBox(textBlock.getBoundingBox());
|
||||
textParagraph.setCorrectSemanticScore(1.0);
|
||||
textParagraph.setHiddenText(textBlock.isHiddenText());
|
||||
return textParagraph;
|
||||
}
|
||||
|
||||
private static double getDifferentLinesProbability(TextBlock previousBlock, TextBlock nextBlock,
|
||||
boolean areSupportNotSingleLines, boolean areShouldBeCloseLines) {
|
||||
if (previousBlock.isHiddenText() != nextBlock.isHiddenText()) {
|
||||
return 0;
|
||||
}
|
||||
if (previousBlock.getLinesNumber() == 1 && nextBlock.getLinesNumber() == 1) {
|
||||
return ChunksMergeUtils.mergeLeadingProbability(previousBlock.getLastLine(), nextBlock.getFirstLine());
|
||||
}
|
||||
if (previousBlock.getLinesNumber() == 1) {
|
||||
return ChunksMergeUtils.mergeLeadingProbability(previousBlock.getLastLine(), nextBlock, areShouldBeCloseLines);
|
||||
}
|
||||
if (nextBlock.getLinesNumber() == 1) {
|
||||
return ChunksMergeUtils.mergeLeadingProbability(previousBlock, nextBlock.getFirstLine(), areShouldBeCloseLines);
|
||||
}
|
||||
if (areSupportNotSingleLines) {
|
||||
return ChunksMergeUtils.mergeLeadingProbability(previousBlock, nextBlock);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static boolean areCloseStyle(TextBlock previousBlock, TextBlock nextBlock) {
|
||||
return NodeUtils.areCloseNumbers(previousBlock.getFontSize(), nextBlock.getFontSize(), 1e-1) &&
|
||||
NodeUtils.areCloseNumbers(previousBlock.getFirstLine().getFirstTextChunk().getFontWeight(),
|
||||
nextBlock.getFirstLine().getFirstTextChunk().getFontWeight(), 1e-1);
|
||||
}
|
||||
|
||||
private static boolean areTextBlocksHaveSameTextSize(TextBlock firstBlock, TextBlock secondBlock) {
|
||||
for (Double textSize1 : firstBlock.getTextSizes()) {
|
||||
for (Double textSize2 : secondBlock.getTextSizes()) {
|
||||
if (NodeUtils.areCloseNumbers(textSize1, textSize2)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Result of {@link DocumentProcessor#processFile} containing optional metadata
|
||||
* collected during processing.
|
||||
*
|
||||
* <p>Currently carries hybrid-server pipeline timings when hybrid mode is used.
|
||||
* The timings JSON has the structure:
|
||||
* <pre>{
|
||||
* "layout": {"total_s": 1.2, "avg_s": 0.12, "count": 10},
|
||||
* "ocr": {"total_s": 2.1, "avg_s": 0.21, "count": 10},
|
||||
* "table_structure": {"total_s": 0.8, "avg_s": 0.40, "count": 2},
|
||||
* "doc_enrich": {"total_s": 5.3, "avg_s": 5.30, "count": 1},
|
||||
* ...
|
||||
* }</pre>
|
||||
*/
|
||||
public class ProcessingResult {
|
||||
|
||||
private static final ProcessingResult EMPTY = new ProcessingResult(null, 0, 0);
|
||||
|
||||
private final JsonNode hybridTimings;
|
||||
private final long extractionNs;
|
||||
private final long outputNs;
|
||||
|
||||
public ProcessingResult(JsonNode hybridTimings, long extractionNs, long outputNs) {
|
||||
this.hybridTimings = hybridTimings;
|
||||
this.extractionNs = extractionNs;
|
||||
this.outputNs = outputNs;
|
||||
}
|
||||
|
||||
/** Returns an empty result. */
|
||||
public static ProcessingResult empty() {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-step pipeline timings from the hybrid server, or {@code null} if
|
||||
* hybrid mode was not used or the server did not return timings.
|
||||
*/
|
||||
public JsonNode getHybridTimings() {
|
||||
return hybridTimings;
|
||||
}
|
||||
|
||||
/** Time spent on data extraction (parsing + layout analysis + content extraction), in nanoseconds. */
|
||||
public long getExtractionNs() {
|
||||
return extractionNs;
|
||||
}
|
||||
|
||||
/** Time spent on output generation (auto-tagging + JSON/MD/HTML export), in nanoseconds. */
|
||||
public long getOutputNs() {
|
||||
return outputNs;
|
||||
}
|
||||
|
||||
/** Extraction time in milliseconds. */
|
||||
public long getExtractionMs() {
|
||||
return extractionNs / 1_000_000;
|
||||
}
|
||||
|
||||
/** Output generation time in milliseconds. */
|
||||
public long getOutputMs() {
|
||||
return outputNs / 1_000_000;
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextLine;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.SemanticType;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SpecialTableProcessor {
|
||||
|
||||
private static final String KOREAN_TABLE_REGEX = "\\(?(수신|경유|제목)\\)?.*";
|
||||
|
||||
public static List<IObject> detectSpecialTables(List<IObject> contents) {
|
||||
detectSpecialKoreanTables(contents);
|
||||
return DocumentProcessor.removeNullObjectsFromList(contents);
|
||||
}
|
||||
|
||||
private static void detectSpecialKoreanTables(List<IObject> contents) {
|
||||
List<TextLine> lines = new ArrayList<>();
|
||||
Integer index = null;
|
||||
for (int currentIndex = 0; currentIndex < contents.size(); currentIndex++) {
|
||||
IObject content = contents.get(currentIndex);
|
||||
if (content instanceof TextLine) {
|
||||
TextLine line = ((TextLine) content);
|
||||
if (line.getValue().matches(KOREAN_TABLE_REGEX)) {
|
||||
lines.add(line);
|
||||
contents.set(currentIndex, null);
|
||||
if (index == null) {
|
||||
index = currentIndex;
|
||||
}
|
||||
} else if (!lines.isEmpty()) {
|
||||
contents.set(index, detectSpecialKoreanTable(lines));
|
||||
lines.clear();
|
||||
}
|
||||
} else if (!lines.isEmpty()) {
|
||||
contents.set(index, detectSpecialKoreanTable(lines));
|
||||
lines.clear();
|
||||
}
|
||||
}
|
||||
if (!lines.isEmpty()) {
|
||||
contents.set(index, detectSpecialKoreanTable(lines));
|
||||
}
|
||||
}
|
||||
|
||||
private static TableBorder detectSpecialKoreanTable(List<TextLine> lines) {
|
||||
TableBorder table = new TableBorder(lines.size(), 2);
|
||||
for (int rowNumber = 0; rowNumber < lines.size(); rowNumber++) {
|
||||
TextLine line = lines.get(rowNumber);
|
||||
BoundingBox box = line.getBoundingBox();
|
||||
int index = line.getValue().indexOf(":");
|
||||
boolean isOneCellRow = index == -1;
|
||||
TableBorderRow tableBorderRow = new TableBorderRow(rowNumber, 2, null);
|
||||
table.getRows()[rowNumber] = tableBorderRow;
|
||||
if (isOneCellRow) {
|
||||
TableBorderCell tableBorderCell = new TableBorderCell(rowNumber, 0, 1, 2, null);
|
||||
tableBorderCell.setSemanticType(SemanticType.TABLE_CELL);
|
||||
tableBorderCell.addContentObject(line);
|
||||
tableBorderCell.setBoundingBox(box);
|
||||
tableBorderRow.getCells()[0] = tableBorderCell;
|
||||
tableBorderRow.getCells()[1] = tableBorderCell;
|
||||
} else {
|
||||
TableBorderCell cell1 = new TableBorderCell(rowNumber, 0, 1, 1, null);
|
||||
cell1.setSemanticType(SemanticType.TABLE_CELL);
|
||||
TextLine line1 = new TextLine(line, 0, index - 1);
|
||||
cell1.addContentObject(line1);
|
||||
cell1.setBoundingBox(line1.getBoundingBox());
|
||||
tableBorderRow.getCells()[0] = cell1;
|
||||
TableBorderCell cell2 = new TableBorderCell(rowNumber, 1, 1, 1, null);
|
||||
cell2.setSemanticType(SemanticType.TABLE_CELL);
|
||||
TextLine line2 = new TextLine(line, index + 1, line.getValue().length());
|
||||
cell2.addContentObject(line2);
|
||||
cell2.setBoundingBox(line2.getBoundingBox());
|
||||
tableBorderRow.getCells()[1] = cell2;
|
||||
}
|
||||
tableBorderRow.setBoundingBox(box);
|
||||
table.getBoundingBox().union(box);
|
||||
}
|
||||
return TableBorderProcessor.normalizeAndProcessTableBorder(new ArrayList<>(lines), table, table.getPageNumber());
|
||||
}
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.NodeUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.TextChunkUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class TableBorderProcessor {
|
||||
|
||||
private static final double LINE_ART_PERCENT = 0.9;
|
||||
private static final double NEIGHBOUR_TABLE_EPSILON = 0.2;
|
||||
|
||||
/**
|
||||
* Maximum depth for nested table processing.
|
||||
* Real-world PDFs rarely have tables nested more than 2-3 levels.
|
||||
* This limit prevents stack overflow from malicious or malformed PDFs.
|
||||
*/
|
||||
private static final int MAX_NESTED_TABLE_DEPTH = 10;
|
||||
|
||||
/**
|
||||
* Thread-local counter for tracking current nesting depth.
|
||||
*/
|
||||
private static final ThreadLocal<Integer> currentDepth = ThreadLocal.withInitial(() -> 0);
|
||||
|
||||
public static List<IObject> processTableBorders(List<IObject> contents, int pageNumber) {
|
||||
// Check if TableBordersCollection exists (may be null if no borders detected during preprocessing)
|
||||
if (StaticContainers.getTableBordersCollection() == null) {
|
||||
return new ArrayList<>(contents);
|
||||
}
|
||||
|
||||
// Check depth limit to prevent stack overflow from deeply nested tables
|
||||
int depth = currentDepth.get();
|
||||
if (depth >= MAX_NESTED_TABLE_DEPTH) {
|
||||
// Exceeded maximum nesting depth - return contents without further table processing
|
||||
return new ArrayList<>(contents);
|
||||
}
|
||||
|
||||
try {
|
||||
currentDepth.set(depth + 1);
|
||||
|
||||
List<IObject> newContents = new ArrayList<>();
|
||||
Set<TableBorder> processedTableBorders = new LinkedHashSet<>();
|
||||
for (IObject content : contents) {
|
||||
TableBorder tableBorder = addContentToTableBorder(content);
|
||||
if (tableBorder != null) {
|
||||
if (content instanceof LineChunk && tableBorder.isOneCellTable()) {
|
||||
continue;
|
||||
}
|
||||
if (!processedTableBorders.contains(tableBorder)) {
|
||||
processedTableBorders.add(tableBorder);
|
||||
newContents.add(tableBorder);
|
||||
}
|
||||
if (content instanceof TextChunk) {
|
||||
TextChunk textChunk = (TextChunk) content;
|
||||
TextChunk textChunkPart = getTextChunkPartBeforeTable(textChunk, tableBorder);
|
||||
if (textChunkPart != null && !textChunkPart.isEmpty() && !textChunkPart.isWhiteSpaceChunk()) {
|
||||
newContents.add(textChunkPart);
|
||||
}
|
||||
textChunkPart = getTextChunkPartAfterTable(textChunk, tableBorder);
|
||||
if (textChunkPart != null && !textChunkPart.isEmpty() && !textChunkPart.isWhiteSpaceChunk()) {
|
||||
newContents.add(textChunkPart);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newContents.add(content);
|
||||
}
|
||||
}
|
||||
Map<TableBorder, TableBorder> normalizedTables = new HashMap<>();
|
||||
for (TableBorder border : processedTableBorders) {
|
||||
StaticContainers.getTableBordersCollection().removeTableBorder(border, pageNumber);
|
||||
TableBorder normalizedTable = normalizeAndProcessTableBorder(contents, border, pageNumber);
|
||||
normalizedTables.put(border, normalizedTable);
|
||||
// Remove the outer table while processing its contents, then restore the page index
|
||||
// with the final instance so later lookups still see the normalized table.
|
||||
// StaticContainers.getTableBordersCollection().getTableBorders(pageNumber).add(normalizedTable);
|
||||
}
|
||||
for (int index = 0; index < newContents.size(); index++) {
|
||||
IObject content = newContents.get(index);
|
||||
if (content instanceof TableBorder && normalizedTables.containsKey(content)) {
|
||||
newContents.set(index, normalizedTables.get(content));
|
||||
}
|
||||
}
|
||||
return newContents;
|
||||
} finally {
|
||||
// Reset depth when exiting this level (clean up ThreadLocal)
|
||||
if (depth == 0) {
|
||||
currentDepth.remove();
|
||||
} else {
|
||||
currentDepth.set(depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static TableBorder addContentToTableBorder(IObject content) {
|
||||
if (StaticContainers.getTableBordersCollection() == null) {
|
||||
return null;
|
||||
}
|
||||
TableBorder tableBorder = StaticContainers.getTableBordersCollection().getTableBorder(content.getBoundingBox());
|
||||
if (tableBorder != null) {
|
||||
if (content instanceof LineChunk) {
|
||||
return tableBorder;
|
||||
}
|
||||
if (content instanceof LineArtChunk && BoundingBox.areSameBoundingBoxes(tableBorder.getBoundingBox(), content.getBoundingBox())) {
|
||||
return tableBorder;
|
||||
}
|
||||
Set<TableBorderCell> tableBorderCells = tableBorder.getTableBorderCells(content);
|
||||
if (!tableBorderCells.isEmpty()) {
|
||||
if (tableBorderCells.size() > 1 && content instanceof TextChunk) {
|
||||
TextChunk textChunk = (TextChunk) content;
|
||||
for (TableBorderCell tableBorderCell : tableBorderCells) {
|
||||
TextChunk currentTextChunk = getTextChunkPartForTableCell(textChunk, tableBorderCell);
|
||||
if (currentTextChunk != null && !currentTextChunk.isEmpty()) {
|
||||
tableBorderCell.addContentObject(currentTextChunk);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (TableBorderCell tableBorderCell : tableBorderCells) {
|
||||
if (content instanceof LineArtChunk &&
|
||||
tableBorderCell.getBoundingBox().getIntersectionPercent(content.getBoundingBox()) > LINE_ART_PERCENT) {
|
||||
return tableBorder;
|
||||
}
|
||||
tableBorderCell.addContentObject(content);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return tableBorder;
|
||||
}
|
||||
if (content instanceof LineArtChunk) {
|
||||
return tableBorder;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void processTableBorder(TableBorder tableBorder, int pageNumber) {
|
||||
processTableBorderContents(tableBorder, pageNumber);
|
||||
}
|
||||
|
||||
static TableBorder normalizeAndProcessTableBorder(List<IObject> rawPageContents, TableBorder tableBorder, int pageNumber) {
|
||||
TableBorder normalizedTable = TableStructureNormalizer.normalize(rawPageContents, tableBorder);
|
||||
processTableBorderContents(normalizedTable, pageNumber);
|
||||
return normalizedTable;
|
||||
}
|
||||
|
||||
private static void processTableBorderContents(TableBorder tableBorder, int pageNumber) {
|
||||
for (int rowNumber = 0; rowNumber < tableBorder.getNumberOfRows(); rowNumber++) {
|
||||
TableBorderRow row = tableBorder.getRow(rowNumber);
|
||||
for (int colNumber = 0; colNumber < tableBorder.getNumberOfColumns(); colNumber++) {
|
||||
TableBorderCell tableBorderCell = row.getCell(colNumber);
|
||||
if (tableBorderCell.getRowNumber() == rowNumber && tableBorderCell.getColNumber() == colNumber) {
|
||||
tableBorderCell.setContents(processTableCellContent(tableBorderCell.getContents(), pageNumber));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<IObject> processTableCellContent(List<IObject> contents, int pageNumber) {
|
||||
List<IObject> newContents = TableBorderProcessor.processTableBorders(contents, pageNumber);
|
||||
newContents = TextLineProcessor.processTextLines(newContents);
|
||||
List<List<IObject>> contentsList = new ArrayList<>(1);
|
||||
contentsList.add(newContents);
|
||||
ListProcessor.processLists(contentsList, true);
|
||||
newContents = contentsList.get(0);
|
||||
newContents = ParagraphProcessor.processParagraphs(newContents);
|
||||
newContents = ListProcessor.processListsFromTextNodes(newContents);
|
||||
HeadingProcessor.processHeadings(newContents, true);
|
||||
DocumentProcessor.setIDs(newContents);
|
||||
CaptionProcessor.processCaptions(newContents);
|
||||
contentsList.set(0, newContents);
|
||||
ListProcessor.checkNeighborLists(contentsList);
|
||||
newContents = contentsList.get(0);
|
||||
return newContents;
|
||||
}
|
||||
|
||||
public static void checkNeighborTables(List<List<IObject>> contents) {
|
||||
TableBorder previousTable = null;
|
||||
for (List<IObject> iObjects : contents) {
|
||||
for (IObject content : iObjects) {
|
||||
if (content instanceof TableBorder && !((TableBorder) content).isTextBlock()) {
|
||||
TableBorder currentTable = (TableBorder) content;
|
||||
if (previousTable != null) {
|
||||
checkNeighborTables(previousTable, currentTable);
|
||||
}
|
||||
previousTable = currentTable;
|
||||
} else {
|
||||
if (!HeaderFooterProcessor.isHeaderOrFooter(content) &&
|
||||
!(content instanceof LineChunk) && !(content instanceof LineArtChunk)) {
|
||||
previousTable = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkNeighborTables(TableBorder previousTable, TableBorder currentTable) {
|
||||
if (currentTable.getNumberOfColumns() != previousTable.getNumberOfColumns()) {
|
||||
return;
|
||||
}
|
||||
if (!NodeUtils.areCloseNumbers(currentTable.getWidth(), previousTable.getWidth(), NEIGHBOUR_TABLE_EPSILON)) {
|
||||
return;
|
||||
}
|
||||
for (int columnNumber = 0; columnNumber < previousTable.getNumberOfColumns(); columnNumber++) {
|
||||
TableBorderCell cell1 = previousTable.getCell(0, columnNumber);
|
||||
TableBorderCell cell2 = currentTable.getCell(0, columnNumber);
|
||||
if (!NodeUtils.areCloseNumbers(cell1.getWidth(), cell2.getWidth(), NEIGHBOUR_TABLE_EPSILON)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
previousTable.setNextTable(currentTable);
|
||||
currentTable.setPreviousTable(previousTable);
|
||||
}
|
||||
|
||||
private static TextChunk getTextChunkPartForTableCell(TextChunk textChunk, TableBorderCell cell) {
|
||||
return TextChunkUtils.getTextChunkPartForRange(textChunk, cell.getLeftX(), cell.getRightX(), true);
|
||||
}
|
||||
|
||||
public static TextChunk getTextChunkPartBeforeTable(TextChunk textChunk, TableBorder table) {
|
||||
return TextChunkUtils.getTextChunkPartBeforeBoundingBox(textChunk, table.getBoundingBox());
|
||||
}
|
||||
|
||||
public static TextChunk getTextChunkPartAfterTable(TextChunk textChunk, TableBorder table) {
|
||||
return TextChunkUtils.getTextChunkPartAfterBoundingBox(textChunk, table.getBoundingBox());
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.*;
|
||||
import org.verapdf.wcag.algorithms.entities.content.*;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.tocs.TOCIInfo;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.tocs.TOCInterval;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.consumers.TOCDetectionConsumer;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.NodeUtils;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class TableOfContentsProcessor {
|
||||
|
||||
private static final double TOC_ITEM_X_INTERVAL_RATIO = 0.3;
|
||||
|
||||
private final Set<String> pageLabels = new HashSet<>();
|
||||
|
||||
public void processTableOfContents(List<List<IObject>> contents) {
|
||||
pageLabels.clear();
|
||||
calculatePageLabels();
|
||||
List<TOCInterval> intervalsList = getTocIntervals(contents);
|
||||
for (TOCInterval interval : intervalsList) {
|
||||
for (TOCIInfo info : interval.getTOCItemsInfos()) {
|
||||
info.getTOCItemValue().setListLine(true);
|
||||
}
|
||||
}
|
||||
for (TOCInterval interval : intervalsList) {
|
||||
Integer currentPageNumber = interval.getTOCItemsInfos().get(0).getPageNumber();
|
||||
int index = 0;
|
||||
SemanticTOC previousTOC = null;
|
||||
for (int i = 0; i < interval.getNumberOfTOCItems(); i++) {
|
||||
TOCIInfo currentInfo = interval.getTOCItemsInfos().get(i);
|
||||
if (!Objects.equals(currentInfo.getPageNumber(), currentPageNumber)) {
|
||||
SemanticTOC toc = calculateTableOfContents(interval, index, i - 1,
|
||||
contents.get(currentPageNumber));
|
||||
processTOC(previousTOC, toc);
|
||||
currentPageNumber = currentInfo.getPageNumber();
|
||||
index = i;
|
||||
previousTOC = toc;
|
||||
}
|
||||
}
|
||||
SemanticTOC toc = calculateTableOfContents(interval, index, interval.getNumberOfTOCItems() - 1,
|
||||
contents.get(currentPageNumber));
|
||||
processTOC(previousTOC, toc);
|
||||
}
|
||||
contents.replaceAll(DocumentProcessor::removeNullObjectsFromList);
|
||||
}
|
||||
|
||||
private static void processTOC(SemanticTOC previousTOC, SemanticTOC toc) {
|
||||
for (IObject tocItem : toc.getTOCItems()) {
|
||||
if (tocItem instanceof SemanticTOCI) {
|
||||
SemanticTOCI toci = (SemanticTOCI) tocItem;
|
||||
toci.setContents(processTOCItemContent(toci.getContents()));
|
||||
} else {
|
||||
//
|
||||
}
|
||||
}
|
||||
if (previousTOC != null) {
|
||||
SemanticTOC.setTOCConnected(previousTOC, toc);
|
||||
}
|
||||
}
|
||||
|
||||
private void calculatePageLabels() {
|
||||
for (int pageNumber = 0; pageNumber < StaticContainers.getDocument().getNumberOfPages(); pageNumber++) {
|
||||
String pageLabel = StaticContainers.getDocument().getPage(pageNumber).getPageLabel();
|
||||
if (pageLabel != null) {
|
||||
pageLabels.add(pageLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<IObject> processTOCItemContent(List<IObject> contents) {
|
||||
List<IObject> newContents = ParagraphProcessor.processParagraphs(contents);
|
||||
DocumentProcessor.setIDs(newContents);
|
||||
return newContents;
|
||||
}
|
||||
|
||||
private List<TOCInterval> getTocIntervals(List<List<IObject>> contents) {
|
||||
List<TOCInterval> tocIntervals = new ArrayList<>();
|
||||
for (List<IObject> pageContents : contents) {
|
||||
for (int i = 0; i < pageContents.size(); i++) {
|
||||
IObject content = pageContents.get(i);
|
||||
if (!(content instanceof TextLine)) {
|
||||
continue;
|
||||
}
|
||||
TextLine line = (TextLine) content;
|
||||
String value = line.getValue();
|
||||
if (value.isEmpty() || line.isHiddenText()) {
|
||||
continue;
|
||||
}
|
||||
TOCIInfo tocItemTextInfo = createTOCIInfo(i, line, value);
|
||||
processTOCItem(tocIntervals, tocItemTextInfo);
|
||||
}
|
||||
}
|
||||
LinkedHashSet<TOCInterval> intervalsList = new LinkedHashSet<>();
|
||||
for (TOCInterval interval : tocIntervals) {
|
||||
if (interval != null && interval.getTOCItemsInfos().size() > 2) {
|
||||
intervalsList.add(interval);
|
||||
}
|
||||
}
|
||||
List<TOCInterval> result = new ArrayList<>(intervalsList);
|
||||
Collections.reverse(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void processTOCItem(List<TOCInterval> tocIntervals, TOCIInfo tocItemTextInfo) {
|
||||
if (!hasPageNumber(tocItemTextInfo)) {
|
||||
return;
|
||||
}
|
||||
boolean isSingle = true;
|
||||
if (!tocIntervals.isEmpty()) {
|
||||
TOCInterval interval = tocIntervals.get(tocIntervals.size() - 1);
|
||||
if (isTwoTOCItemsOfOneTOC(interval, tocItemTextInfo)) {
|
||||
tocIntervals.add(interval);
|
||||
isSingle = false;
|
||||
}
|
||||
}
|
||||
if (isSingle) {
|
||||
TOCInterval tocInterval = new TOCInterval();
|
||||
tocInterval.getTOCItemsInfos().add(tocItemTextInfo);
|
||||
tocIntervals.add(tocInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isTwoTOCItemsOfOneTOC(TOCInterval interval, TOCIInfo currentItem) {
|
||||
double maxXGap = getMaxXGap(currentItem.getTOCItemValue().getFontSize());
|
||||
TOCIInfo previousItem = interval.getLastTOCItemInfo();
|
||||
if (previousItem == null) {
|
||||
return false;
|
||||
}
|
||||
if (Objects.equals(currentItem.getPageNumber(), previousItem.getPageNumber()) &&
|
||||
currentItem.getTOCItemValue().getTopY() > previousItem.getTOCItemValue().getTopY()) {
|
||||
return false;
|
||||
}
|
||||
if (!NodeUtils.areCloseNumbers(previousItem.getTOCItemValue().getRightX(),
|
||||
currentItem.getTOCItemValue().getRightX(), maxXGap)) {
|
||||
return false;
|
||||
}
|
||||
interval.getTOCItemsInfos().add(currentItem);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean hasPageNumber(TOCIInfo tocItem) {
|
||||
String textValue = tocItem.getText();
|
||||
if (textValue.matches(".+[" + TOCDetectionConsumer.SPACES + "\\.]\\d+") && !textValue.matches(".*\\d+\\.\\d+")) {
|
||||
return true;
|
||||
}
|
||||
for (String pageLabel : pageLabels) {
|
||||
if (textValue.length() > pageLabel.length() && textValue.endsWith(pageLabel)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static TOCIInfo createTOCIInfo(int i, TextLine line, String value) {
|
||||
TOCIInfo tociInfo = new TOCIInfo();
|
||||
tociInfo.setIndex(i);
|
||||
tociInfo.setTOCItemValue(line);
|
||||
tociInfo.setText(value);
|
||||
return tociInfo;
|
||||
}
|
||||
|
||||
private static SemanticTOC calculateTableOfContents(TOCInterval interval, int startIndex, int endIndex,
|
||||
List<IObject> pageContents) {
|
||||
SemanticTOC toc = new SemanticTOC();
|
||||
for (int index = startIndex; index <= endIndex; index++) {
|
||||
TOCIInfo currentInfo = interval.getTOCItemsInfos().get(index);
|
||||
SemanticTOCI tocItem = new SemanticTOCI(new BoundingBox(), null);
|
||||
TextLine textLine = (TextLine) pageContents.get(currentInfo.getIndex());
|
||||
if (index != startIndex) {
|
||||
int nextIndex = interval.getTOCItemsInfos().get(index - 1).getIndex();
|
||||
addContentToTOCItem(nextIndex, currentInfo, pageContents, tocItem);
|
||||
} else {
|
||||
//
|
||||
}
|
||||
pageContents.set(interval.getTOCItemsInfos().get(index).getIndex(), index == startIndex ? toc : null);
|
||||
tocItem.add(textLine);
|
||||
toc.add(tocItem);
|
||||
}
|
||||
return toc;
|
||||
}
|
||||
|
||||
private static void addContentToTOCItem(int nextIndex, TOCIInfo currentInfo, List<IObject> pageContents,
|
||||
SemanticTOCI tocItem) {
|
||||
|
||||
List<TextLine> lines = new LinkedList<>();
|
||||
for (int index = currentInfo.getIndex() - 1; index > nextIndex; index--) {
|
||||
IObject content = pageContents.get(index);
|
||||
if (content instanceof TextLine) {
|
||||
TextLine currentTextLine = (TextLine) content;
|
||||
lines.add(0, currentTextLine);
|
||||
} else if (content != null) {
|
||||
tocItem.getContents().add(0, content);
|
||||
}
|
||||
pageContents.set(index, null);
|
||||
}
|
||||
tocItem.add(lines);
|
||||
}
|
||||
|
||||
private static double getMaxXGap(double fontSize) {
|
||||
return fontSize * TOC_ITEM_X_INTERVAL_RATIO;
|
||||
}
|
||||
}
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextLine;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.SemanticType;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.TextChunkUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
class TableStructureNormalizer {
|
||||
|
||||
private static final int MAX_UNDERSEGMENTED_ROWS = 2;
|
||||
private static final int MIN_UNDERSEGMENTED_COLUMNS = 3;
|
||||
private static final int MIN_UNDERSEGMENTED_TEXT_LINES = 8;
|
||||
private static final int MIN_ROW_BAND_MISMATCH = 2;
|
||||
private static final int OVERSIZED_CELL_LINE_COUNT = 4;
|
||||
private static final double MIN_ROW_BAND_EPSILON = 3.0;
|
||||
private static final double ROW_BAND_EPSILON_RATIO = 0.6;
|
||||
private static final double ROW_BAND_ASSIGNMENT_EPSILON = 6.0;
|
||||
private static final double ROW_ORDER_EPSILON = 1.5;
|
||||
private static final Comparator<IObject> CONTENT_COMPARATOR =
|
||||
Comparator.comparingDouble(IObject::getCenterY).reversed()
|
||||
.thenComparingDouble(IObject::getLeftX);
|
||||
private static final Comparator<TextLine> TEXT_LINE_COMPARATOR =
|
||||
Comparator.comparingDouble(TextLine::getCenterY).reversed()
|
||||
.thenComparingDouble(TextLine::getLeftX);
|
||||
|
||||
static TableBorder normalize(List<IObject> rawPageContents, TableBorder tableBorder) {
|
||||
if (rawPageContents == null || rawPageContents.isEmpty()) {
|
||||
return tableBorder;
|
||||
}
|
||||
if (tableBorder.isTextBlock()) {
|
||||
return tableBorder;
|
||||
}
|
||||
if (tableBorder.getNumberOfRows() > MAX_UNDERSEGMENTED_ROWS ||
|
||||
tableBorder.getNumberOfColumns() < MIN_UNDERSEGMENTED_COLUMNS) {
|
||||
return tableBorder;
|
||||
}
|
||||
|
||||
List<ColumnSnapshot> columnSnapshots = collectColumnSnapshots(rawPageContents, tableBorder);
|
||||
int denseColumns = countDenseColumns(columnSnapshots);
|
||||
if (denseColumns < 2) {
|
||||
return tableBorder;
|
||||
}
|
||||
|
||||
List<RowBand> rowBands = collectRowBands(tableBorder, columnSnapshots);
|
||||
if (rowBands.size() < tableBorder.getNumberOfRows() + MIN_ROW_BAND_MISMATCH) {
|
||||
return tableBorder;
|
||||
}
|
||||
|
||||
TableBorder rebuiltTable = rebuildTable(tableBorder, rowBands);
|
||||
if (!isReplacementQualityBetter(tableBorder, rebuiltTable)) {
|
||||
return tableBorder;
|
||||
}
|
||||
|
||||
return rebuiltTable;
|
||||
}
|
||||
|
||||
private static List<ColumnSnapshot> collectColumnSnapshots(List<IObject> rawPageContents, TableBorder tableBorder) {
|
||||
List<ColumnSnapshot> columnSnapshots = new ArrayList<>(tableBorder.getNumberOfColumns());
|
||||
for (int columnNumber = 0; columnNumber < tableBorder.getNumberOfColumns(); columnNumber++) {
|
||||
columnSnapshots.add(new ColumnSnapshot());
|
||||
}
|
||||
|
||||
for (IObject content : rawPageContents) {
|
||||
if (content == null || !isInsideTableBounds(content, tableBorder)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content instanceof TextChunk) {
|
||||
addTextChunkToColumns((TextChunk) content, tableBorder, columnSnapshots);
|
||||
} else if (!(content instanceof LineChunk) && !(content instanceof LineArtChunk)) {
|
||||
int columnNumber = findBestColumn(content, tableBorder);
|
||||
if (columnNumber >= 0) {
|
||||
columnSnapshots.get(columnNumber).addContent(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (ColumnSnapshot columnSnapshot : columnSnapshots) {
|
||||
columnSnapshot.finalizeSnapshot();
|
||||
}
|
||||
return columnSnapshots;
|
||||
}
|
||||
|
||||
private static void addTextChunkToColumns(TextChunk textChunk, TableBorder tableBorder,
|
||||
List<ColumnSnapshot> columnSnapshots) {
|
||||
for (int columnNumber = 0; columnNumber < tableBorder.getNumberOfColumns(); columnNumber++) {
|
||||
TextChunk columnTextChunk = TextChunkUtils.getTextChunkPartForRange(textChunk,
|
||||
tableBorder.getLeftX(columnNumber), tableBorder.getRightX(columnNumber), true);
|
||||
if (columnTextChunk != null && !columnTextChunk.isEmpty() && !columnTextChunk.isWhiteSpaceChunk()) {
|
||||
columnSnapshots.get(columnNumber).addContent(columnTextChunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int findBestColumn(IObject content, TableBorder tableBorder) {
|
||||
double centerX = content.getCenterX();
|
||||
for (int columnNumber = 0; columnNumber < tableBorder.getNumberOfColumns(); columnNumber++) {
|
||||
if (centerX >= tableBorder.getLeftX(columnNumber) && centerX <= tableBorder.getRightX(columnNumber)) {
|
||||
return columnNumber;
|
||||
}
|
||||
}
|
||||
|
||||
int closestColumn = -1;
|
||||
double closestDistance = Double.MAX_VALUE;
|
||||
for (int columnNumber = 0; columnNumber < tableBorder.getNumberOfColumns(); columnNumber++) {
|
||||
double columnCenter = (tableBorder.getLeftX(columnNumber) + tableBorder.getRightX(columnNumber)) / 2;
|
||||
double distance = Math.abs(centerX - columnCenter);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestColumn = columnNumber;
|
||||
}
|
||||
}
|
||||
return closestColumn;
|
||||
}
|
||||
|
||||
private static boolean isInsideTableBounds(IObject content, TableBorder tableBorder) {
|
||||
return content.getCenterX() >= tableBorder.getLeftX() && content.getCenterX() <= tableBorder.getRightX() &&
|
||||
content.getCenterY() >= tableBorder.getBottomY() && content.getCenterY() <= tableBorder.getTopY();
|
||||
}
|
||||
|
||||
private static int countDenseColumns(List<ColumnSnapshot> columnSnapshots) {
|
||||
int denseColumns = 0;
|
||||
for (ColumnSnapshot columnSnapshot : columnSnapshots) {
|
||||
if (columnSnapshot.meaningfulLineCount >= MIN_UNDERSEGMENTED_TEXT_LINES) {
|
||||
denseColumns++;
|
||||
}
|
||||
}
|
||||
return denseColumns;
|
||||
}
|
||||
|
||||
private static List<RowBand> collectRowBands(TableBorder tableBorder, List<ColumnSnapshot> columnSnapshots) {
|
||||
List<TextLine> textLines = new ArrayList<>();
|
||||
for (ColumnSnapshot columnSnapshot : columnSnapshots) {
|
||||
textLines.addAll(columnSnapshot.textLines);
|
||||
}
|
||||
textLines.sort(TEXT_LINE_COMPARATOR);
|
||||
|
||||
List<RowBand> rowBands = new ArrayList<>();
|
||||
for (TextLine textLine : textLines) {
|
||||
RowBand matchingBand = findMatchingRowBand(rowBands, textLine);
|
||||
if (matchingBand == null) {
|
||||
matchingBand = new RowBand(tableBorder.getNumberOfColumns());
|
||||
rowBands.add(matchingBand);
|
||||
}
|
||||
matchingBand.addLine(textLine);
|
||||
}
|
||||
|
||||
for (int columnNumber = 0; columnNumber < columnSnapshots.size(); columnNumber++) {
|
||||
for (IObject content : columnSnapshots.get(columnNumber).contents) {
|
||||
RowBand matchingBand = findBestRowBand(rowBands, content);
|
||||
if (matchingBand != null) {
|
||||
matchingBand.addContent(columnNumber, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rowBands.removeIf(rowBand -> rowBand.isEmpty());
|
||||
rowBands.sort(Comparator.comparingDouble(RowBand::getCenterY).reversed());
|
||||
rowBands.forEach(RowBand::sortContents);
|
||||
return rowBands;
|
||||
}
|
||||
|
||||
private static RowBand findMatchingRowBand(List<RowBand> rowBands, TextLine textLine) {
|
||||
for (RowBand rowBand : rowBands) {
|
||||
double epsilon = Math.max(MIN_ROW_BAND_EPSILON,
|
||||
Math.min(rowBand.getAverageHeight(), textLine.getHeight()) * ROW_BAND_EPSILON_RATIO);
|
||||
if (Math.abs(rowBand.getCenterY() - textLine.getCenterY()) <= epsilon ||
|
||||
rowBand.hasVerticalOverlap(textLine.getTopY(), textLine.getBottomY())) {
|
||||
return rowBand;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RowBand findBestRowBand(List<RowBand> rowBands, IObject content) {
|
||||
RowBand bestBand = null;
|
||||
double bestDistance = Double.MAX_VALUE;
|
||||
for (RowBand rowBand : rowBands) {
|
||||
if (rowBand.hasVerticalOverlap(content.getTopY(), content.getBottomY())) {
|
||||
double distance = Math.abs(rowBand.getCenterY() - content.getCenterY());
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestBand = rowBand;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestBand != null) {
|
||||
return bestBand;
|
||||
}
|
||||
|
||||
for (RowBand rowBand : rowBands) {
|
||||
double distance = Math.abs(rowBand.getCenterY() - content.getCenterY());
|
||||
if (distance < bestDistance && distance <= ROW_BAND_ASSIGNMENT_EPSILON + rowBand.getAverageHeight()) {
|
||||
bestDistance = distance;
|
||||
bestBand = rowBand;
|
||||
}
|
||||
}
|
||||
return bestBand;
|
||||
}
|
||||
|
||||
private static TableBorder rebuildTable(TableBorder originalTable, List<RowBand> rowBands) {
|
||||
TableBorder rebuiltTable = new TableBorder(rowBands.size(), originalTable.getNumberOfColumns());
|
||||
rebuiltTable.setRecognizedStructureId(originalTable.getRecognizedStructureId());
|
||||
rebuiltTable.setBoundingBox(new BoundingBox(originalTable.getBoundingBox()));
|
||||
rebuiltTable.setNode(originalTable.getNode());
|
||||
rebuiltTable.setIndex(originalTable.getIndex());
|
||||
rebuiltTable.setLevel(originalTable.getLevel());
|
||||
rebuiltTable.setPreviousTable(originalTable.getPreviousTable());
|
||||
rebuiltTable.setNextTable(originalTable.getNextTable());
|
||||
|
||||
for (int rowNumber = 0; rowNumber < rowBands.size(); rowNumber++) {
|
||||
RowBand rowBand = rowBands.get(rowNumber);
|
||||
TableBorderRow rebuiltRow = new TableBorderRow(rowNumber, originalTable.getNumberOfColumns(),
|
||||
originalTable.getRecognizedStructureId());
|
||||
rebuiltRow.setBoundingBox(rowBand.createRowBoundingBox(originalTable));
|
||||
rebuiltTable.getRows()[rowNumber] = rebuiltRow;
|
||||
|
||||
for (int columnNumber = 0; columnNumber < originalTable.getNumberOfColumns(); columnNumber++) {
|
||||
TableBorderCell rebuiltCell = new TableBorderCell(rowNumber, columnNumber, 1, 1,
|
||||
originalTable.getRecognizedStructureId());
|
||||
rebuiltCell.setSemanticType(rowNumber == 0 ? SemanticType.TABLE_HEADER : SemanticType.TABLE_CELL);
|
||||
rebuiltCell.setContents(rowBand.getContents(columnNumber));
|
||||
rebuiltCell.setBoundingBox(rowBand.createCellBoundingBox(originalTable, columnNumber));
|
||||
rebuiltRow.getCells()[columnNumber] = rebuiltCell;
|
||||
}
|
||||
}
|
||||
|
||||
rebuiltTable.calculateCoordinatesUsingBoundingBoxesOfRowsAndColumns();
|
||||
return rebuiltTable;
|
||||
}
|
||||
|
||||
private static boolean isReplacementQualityBetter(TableBorder originalTable, TableBorder rebuiltTable) {
|
||||
int originalNonEmptyRows = countNonEmptyRows(originalTable);
|
||||
int rebuiltNonEmptyRows = countNonEmptyRows(rebuiltTable);
|
||||
if (rebuiltNonEmptyRows <= originalNonEmptyRows) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int originalNonEmptyColumns = countNonEmptyColumns(originalTable);
|
||||
int rebuiltNonEmptyColumns = countNonEmptyColumns(rebuiltTable);
|
||||
if (rebuiltNonEmptyColumns < originalNonEmptyColumns) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasMonotonicRowOrder(rebuiltTable)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
TableLineStats originalLineStats = collectTableLineStats(originalTable);
|
||||
TableLineStats rebuiltLineStats = collectTableLineStats(rebuiltTable);
|
||||
|
||||
return rebuiltLineStats.oversizedCellCount < originalLineStats.oversizedCellCount ||
|
||||
rebuiltLineStats.maxMeaningfulTextLines < originalLineStats.maxMeaningfulTextLines;
|
||||
}
|
||||
|
||||
private static int countNonEmptyRows(TableBorder tableBorder) {
|
||||
int count = 0;
|
||||
for (int rowNumber = 0; rowNumber < tableBorder.getNumberOfRows(); rowNumber++) {
|
||||
boolean hasContent = false;
|
||||
for (int columnNumber = 0; columnNumber < tableBorder.getNumberOfColumns(); columnNumber++) {
|
||||
TableBorderCell cell = tableBorder.getRow(rowNumber).getCell(columnNumber);
|
||||
if (cell != null && cell.getRowNumber() == rowNumber && cell.getColNumber() == columnNumber &&
|
||||
hasMeaningfulContent(cell.getContents())) {
|
||||
hasContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasContent) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int countNonEmptyColumns(TableBorder tableBorder) {
|
||||
int count = 0;
|
||||
for (int columnNumber = 0; columnNumber < tableBorder.getNumberOfColumns(); columnNumber++) {
|
||||
boolean hasContent = false;
|
||||
for (int rowNumber = 0; rowNumber < tableBorder.getNumberOfRows(); rowNumber++) {
|
||||
TableBorderCell cell = tableBorder.getRow(rowNumber).getCell(columnNumber);
|
||||
if (cell != null && cell.getRowNumber() == rowNumber && cell.getColNumber() == columnNumber &&
|
||||
hasMeaningfulContent(cell.getContents())) {
|
||||
hasContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasContent) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static boolean hasMeaningfulContent(List<IObject> contents) {
|
||||
if (contents == null) {
|
||||
return false;
|
||||
}
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof TextChunk) {
|
||||
if (!((TextChunk) content).isWhiteSpaceChunk() && !((TextChunk) content).isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
} else if (content instanceof TextLine) {
|
||||
if (!((TextLine) content).isSpaceLine() && !((TextLine) content).isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
} else if (!(content instanceof LineChunk) && !(content instanceof LineArtChunk)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasMonotonicRowOrder(TableBorder tableBorder) {
|
||||
double previousCenterY = Double.POSITIVE_INFINITY;
|
||||
double previousBottomY = Double.POSITIVE_INFINITY;
|
||||
for (int rowNumber = 0; rowNumber < tableBorder.getNumberOfRows(); rowNumber++) {
|
||||
TableBorderRow row = tableBorder.getRow(rowNumber);
|
||||
double currentCenterY = row.getBoundingBox().getCenterY();
|
||||
if (currentCenterY >= previousCenterY) {
|
||||
return false;
|
||||
}
|
||||
if (row.getTopY() > previousBottomY + ROW_ORDER_EPSILON) {
|
||||
return false;
|
||||
}
|
||||
previousCenterY = currentCenterY;
|
||||
previousBottomY = row.getBottomY();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static TableLineStats collectTableLineStats(TableBorder tableBorder) {
|
||||
int oversizedCellCount = 0;
|
||||
int maxMeaningfulTextLines = 0;
|
||||
for (int rowNumber = 0; rowNumber < tableBorder.getNumberOfRows(); rowNumber++) {
|
||||
for (int columnNumber = 0; columnNumber < tableBorder.getNumberOfColumns(); columnNumber++) {
|
||||
TableBorderCell cell = tableBorder.getRow(rowNumber).getCell(columnNumber);
|
||||
if (cell != null && cell.getRowNumber() == rowNumber && cell.getColNumber() == columnNumber) {
|
||||
int meaningfulTextLines = countMeaningfulTextLines(cell.getContents());
|
||||
if (meaningfulTextLines >= OVERSIZED_CELL_LINE_COUNT) {
|
||||
oversizedCellCount++;
|
||||
}
|
||||
maxMeaningfulTextLines = Math.max(maxMeaningfulTextLines, meaningfulTextLines);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new TableLineStats(oversizedCellCount, maxMeaningfulTextLines);
|
||||
}
|
||||
|
||||
private static int countMeaningfulTextLines(List<IObject> contents) {
|
||||
if (contents == null || contents.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<IObject> orderedContents = new ArrayList<>(contents);
|
||||
orderedContents.sort(CONTENT_COMPARATOR);
|
||||
int count = 0;
|
||||
for (IObject content : TextLineProcessor.processTextLines(orderedContents)) {
|
||||
if (content instanceof TextLine) {
|
||||
TextLine textLine = (TextLine) content;
|
||||
if (!textLine.isEmpty() && !textLine.isSpaceLine()) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static final class ColumnSnapshot {
|
||||
|
||||
private final List<IObject> contents = new ArrayList<>();
|
||||
private final List<TextLine> textLines = new ArrayList<>();
|
||||
private int meaningfulLineCount;
|
||||
|
||||
private void addContent(IObject content) {
|
||||
contents.add(content);
|
||||
}
|
||||
|
||||
private void finalizeSnapshot() {
|
||||
contents.sort(CONTENT_COMPARATOR);
|
||||
List<IObject> textCandidates = new ArrayList<>();
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof TextChunk || content instanceof TextLine) {
|
||||
textCandidates.add(content);
|
||||
}
|
||||
}
|
||||
for (IObject content : TextLineProcessor.processTextLines(textCandidates)) {
|
||||
if (content instanceof TextLine) {
|
||||
TextLine textLine = (TextLine) content;
|
||||
if (!textLine.isEmpty() && !textLine.isSpaceLine()) {
|
||||
textLines.add(textLine);
|
||||
meaningfulLineCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TableLineStats {
|
||||
|
||||
private final int oversizedCellCount;
|
||||
private final int maxMeaningfulTextLines;
|
||||
|
||||
private TableLineStats(int oversizedCellCount, int maxMeaningfulTextLines) {
|
||||
this.oversizedCellCount = oversizedCellCount;
|
||||
this.maxMeaningfulTextLines = maxMeaningfulTextLines;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RowBand {
|
||||
|
||||
private final List<List<IObject>> contentsByColumn;
|
||||
private double topY = Double.NEGATIVE_INFINITY;
|
||||
private double bottomY = Double.POSITIVE_INFINITY;
|
||||
private double centerY;
|
||||
private double averageHeight;
|
||||
private int lineCount;
|
||||
|
||||
private RowBand(int columnCount) {
|
||||
this.contentsByColumn = new ArrayList<>(columnCount);
|
||||
for (int columnNumber = 0; columnNumber < columnCount; columnNumber++) {
|
||||
this.contentsByColumn.add(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
private void addLine(TextLine textLine) {
|
||||
updateBounds(textLine.getTopY(), textLine.getBottomY(), textLine.getCenterY(), textLine.getHeight());
|
||||
}
|
||||
|
||||
private void addContent(int columnNumber, IObject content) {
|
||||
contentsByColumn.get(columnNumber).add(content);
|
||||
updateBounds(content.getTopY(), content.getBottomY(), content.getCenterY(), content.getHeight());
|
||||
}
|
||||
|
||||
private void updateBounds(double contentTopY, double contentBottomY, double contentCenterY, double height) {
|
||||
topY = Math.max(topY, contentTopY);
|
||||
bottomY = Math.min(bottomY, contentBottomY);
|
||||
centerY = ((centerY * lineCount) + contentCenterY) / (lineCount + 1);
|
||||
averageHeight = ((averageHeight * lineCount) + height) / (lineCount + 1);
|
||||
lineCount++;
|
||||
}
|
||||
|
||||
private boolean hasVerticalOverlap(double contentTopY, double contentBottomY) {
|
||||
return contentBottomY <= topY + ROW_ORDER_EPSILON && contentTopY >= bottomY - ROW_ORDER_EPSILON;
|
||||
}
|
||||
|
||||
private boolean isEmpty() {
|
||||
for (List<IObject> contents : contentsByColumn) {
|
||||
if (!contents.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void sortContents() {
|
||||
for (List<IObject> contents : contentsByColumn) {
|
||||
contents.sort(CONTENT_COMPARATOR);
|
||||
}
|
||||
}
|
||||
|
||||
private List<IObject> getContents(int columnNumber) {
|
||||
return new ArrayList<>(contentsByColumn.get(columnNumber));
|
||||
}
|
||||
|
||||
private BoundingBox createRowBoundingBox(TableBorder tableBorder) {
|
||||
return new BoundingBox(tableBorder.getPageNumber(), tableBorder.getLeftX(), bottomY,
|
||||
tableBorder.getRightX(), topY);
|
||||
}
|
||||
|
||||
private BoundingBox createCellBoundingBox(TableBorder tableBorder, int columnNumber) {
|
||||
return new BoundingBox(tableBorder.getPageNumber(), tableBorder.getLeftX(columnNumber), bottomY,
|
||||
tableBorder.getRightX(columnNumber), topY);
|
||||
}
|
||||
|
||||
private double getCenterY() {
|
||||
return centerY;
|
||||
}
|
||||
|
||||
private double getAverageHeight() {
|
||||
return averageHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
+497
@@ -0,0 +1,497 @@
|
||||
package org.opendataloader.pdf.processors;
|
||||
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
import org.opendataloader.pdf.entities.EnrichedImageChunk;
|
||||
import org.opendataloader.pdf.entities.SemanticFootnote;
|
||||
import org.verapdf.gf.model.impl.sa.GFSANode;
|
||||
import org.verapdf.wcag.algorithms.entities.*;
|
||||
import org.verapdf.wcag.algorithms.entities.content.ImageChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextBlock;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextLine;
|
||||
import org.verapdf.wcag.algorithms.entities.enums.SemanticType;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.MultiBoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListItem;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.PDFList;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderCell;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.consumers.TableChecker;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class TaggedDocumentProcessor {
|
||||
|
||||
private static List<List<IObject>> contents;
|
||||
private static Stack<List<IObject>> contentsStack = new Stack<>();
|
||||
private static Set<Integer> pagesToProcess;
|
||||
|
||||
public static List<List<IObject>> processDocument(String inputPdfName, Config config, Set<Integer> pages) {
|
||||
pagesToProcess = pages;
|
||||
contentsStack.clear();
|
||||
contents = new ArrayList<>();
|
||||
int totalPages = StaticContainers.getDocument().getNumberOfPages();
|
||||
for (int pageNumber = 0; pageNumber < totalPages; pageNumber++) {
|
||||
contents.add(new ArrayList<>());
|
||||
}
|
||||
ITree tree = StaticContainers.getDocument().getTree();
|
||||
processStructElem(tree.getRoot(), null);
|
||||
List<List<IObject>> artifacts = collectArtifacts(totalPages);
|
||||
for (int pageNumber = 0; pageNumber < totalPages; pageNumber++) {
|
||||
if (!shouldProcessPage(pageNumber)) {
|
||||
continue;
|
||||
}
|
||||
artifacts.set(pageNumber, TextLineProcessor.processTextLines(artifacts.get(pageNumber)));
|
||||
}
|
||||
|
||||
HeaderFooterProcessor.processHeadersAndFooters(artifacts, true);
|
||||
for (int pageNumber = 0; pageNumber < totalPages; pageNumber++) {
|
||||
if (!shouldProcessPage(pageNumber)) {
|
||||
continue;
|
||||
}
|
||||
DocumentProcessor.setIDs(artifacts.get(pageNumber));
|
||||
contents.get(pageNumber).addAll(artifacts.get(pageNumber));
|
||||
}
|
||||
for (int pageNumber = 0; pageNumber < totalPages; pageNumber++) {
|
||||
if (!shouldProcessPage(pageNumber)) {
|
||||
continue;
|
||||
}
|
||||
List<IObject> pageContents = TextLineProcessor.processTextLines(contents.get(pageNumber));
|
||||
contents.set(pageNumber, ParagraphProcessor.processParagraphs(pageContents));
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
private static List<List<IObject>> collectArtifacts(int totalPages) {
|
||||
List<List<IObject>> artifacts = new ArrayList<>();
|
||||
for (int pageNumber = 0; pageNumber < totalPages; pageNumber++) {
|
||||
artifacts.add(new ArrayList<>());
|
||||
if (!shouldProcessPage(pageNumber)) {
|
||||
continue;
|
||||
}
|
||||
for (IObject content : StaticContainers.getDocument().getArtifacts(pageNumber)) {
|
||||
if (content instanceof ImageChunk) {
|
||||
artifacts.get(pageNumber).add(content);
|
||||
} else if (content instanceof TextChunk) {
|
||||
TextChunk textChunk = (TextChunk) content;
|
||||
if (!textChunk.isWhiteSpaceChunk() && !textChunk.isEmpty()) {
|
||||
artifacts.get(pageNumber).add(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a page should be processed based on the filter.
|
||||
*
|
||||
* @param pageNumber 0-indexed page number
|
||||
* @return true if the page should be processed
|
||||
*/
|
||||
private static boolean shouldProcessPage(int pageNumber) {
|
||||
return pagesToProcess == null || pagesToProcess.contains(pageNumber);
|
||||
}
|
||||
|
||||
private static void processStructElem(INode node, INode parent) {
|
||||
if (node instanceof SemanticFigure) {
|
||||
processImage((SemanticFigure) node, parent);
|
||||
return;
|
||||
}
|
||||
if (node instanceof SemanticSpan) {
|
||||
processTextChunk((SemanticSpan) node);
|
||||
}
|
||||
if (node.getInitialSemanticType() == null) {
|
||||
for (INode child : node.getChildren()) {
|
||||
processStructElem(child, node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (node.getInitialSemanticType()) {
|
||||
case CAPTION:
|
||||
processCaption(node);
|
||||
break;
|
||||
case HEADING:
|
||||
case TITLE:
|
||||
processHeading(node);
|
||||
break;
|
||||
case LIST:
|
||||
processList(node);
|
||||
break;
|
||||
case NUMBER_HEADING:
|
||||
processNumberedHeading(node);
|
||||
break;
|
||||
case PARAGRAPH:
|
||||
processParagraph(node);
|
||||
break;
|
||||
case TABLE:
|
||||
processTable(node);
|
||||
break;
|
||||
case TABLE_OF_CONTENT:
|
||||
addObjectToContent(processTOC(node));
|
||||
break;
|
||||
case NOTE:
|
||||
case FENOTE:
|
||||
processFootnote(node);
|
||||
break;
|
||||
default:
|
||||
for (INode child : node.getChildren()) {
|
||||
processStructElem(child, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addObjectToContent(IObject object) {
|
||||
Integer pageNumber = object.getPageNumber();
|
||||
if (pageNumber != null && shouldProcessPage(pageNumber)) {
|
||||
if (contentsStack.isEmpty()) {
|
||||
contents.get(pageNumber).add(object);
|
||||
} else {
|
||||
contentsStack.peek().add(object);
|
||||
}
|
||||
}
|
||||
object.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
}
|
||||
|
||||
private static void processParagraph(INode paragraph) {
|
||||
addObjectToContent(createParagraph(paragraph));
|
||||
}
|
||||
|
||||
private static SemanticParagraph createParagraph(INode paragraph) {
|
||||
List<IObject> contents = new ArrayList<>();
|
||||
processChildContents(paragraph, contents);
|
||||
contents = TextLineProcessor.processTextLines(contents);
|
||||
TextBlock textBlock = new TextBlock(new MultiBoundingBox());
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof TextLine) {
|
||||
textBlock.add((TextLine)content);
|
||||
} else {
|
||||
addObjectToContent(content);
|
||||
}
|
||||
}
|
||||
return ParagraphProcessor.createParagraphFromTextBlock(textBlock);
|
||||
}
|
||||
|
||||
private static void processHeading(INode node) {
|
||||
SemanticHeading heading = new SemanticHeading(createParagraph(node));
|
||||
heading.setHeadingLevel(1);//update
|
||||
addObjectToContent(heading);
|
||||
}
|
||||
|
||||
private static void processFootnote(INode node) {
|
||||
SemanticFootnote footnote = new SemanticFootnote(createParagraph(node));
|
||||
addObjectToContent(footnote);
|
||||
}
|
||||
|
||||
private static void processNumberedHeading(INode node) {
|
||||
SemanticHeading heading = new SemanticHeading(createParagraph(node));
|
||||
GFSANode gfsaNode = (GFSANode) node;
|
||||
String headingLevel = gfsaNode.getStructElem().getstandardType();
|
||||
heading.setHeadingLevel(Integer.parseInt(headingLevel.substring(1)));
|
||||
addObjectToContent(heading);
|
||||
}
|
||||
|
||||
private static void processList(INode node) {
|
||||
PDFList list = new PDFList();
|
||||
list.setBoundingBox(new MultiBoundingBox());
|
||||
for (INode child : node.getChildren()) {
|
||||
if (child.getInitialSemanticType() == SemanticType.LIST) {
|
||||
processList(child);
|
||||
} else if (child.getInitialSemanticType() == SemanticType.LIST_ITEM) {
|
||||
ListItem listItem = processListItem(child);
|
||||
if (listItem.getPageNumber() != null) {
|
||||
list.add(listItem);
|
||||
}
|
||||
} else {
|
||||
processStructElem(child, node);
|
||||
}
|
||||
}
|
||||
addObjectToContent(list);
|
||||
}
|
||||
|
||||
private static ListItem processListItem(INode node) {
|
||||
ListItem listItem = new ListItem(new MultiBoundingBox(), null);
|
||||
List<IObject> contents = new ArrayList<>();
|
||||
listItem.setLabelLength(calculateLabelLength(node));
|
||||
processChildContents(node, contents);
|
||||
contents = TextLineProcessor.processTextLines(contents);
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof TextLine) {
|
||||
listItem.add((TextLine)content);
|
||||
} else {
|
||||
listItem.getContents().add(content);
|
||||
}
|
||||
}
|
||||
listItem.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
return listItem;
|
||||
}
|
||||
|
||||
private static int calculateLabelLength(INode listItem) {
|
||||
List<INode> children = listItem.getChildren();
|
||||
if (!children.isEmpty()) {
|
||||
INode firstChild = children.get(0);
|
||||
if (firstChild.getInitialSemanticType() == SemanticType.LIST_LABEL) {
|
||||
return getTextChunksLength(firstChild);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int getTextChunksLength(INode node) {
|
||||
int result = 0;
|
||||
for (INode child : node.getChildren()) {
|
||||
if (child instanceof SemanticSpan) {
|
||||
result += (((SemanticSpan)child).getColumns().get(0).getFirstLine().getFirstTextChunk().getValue().length());
|
||||
} else {
|
||||
result += getTextChunksLength(child);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void processTable(INode tableNode) {
|
||||
List<INode> tableRows = processTableRows(tableNode);
|
||||
if (tableRows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int numberOfRows = tableRows.size();
|
||||
int numberOfColumns = TableChecker.getNumberOfColumns(tableRows.get(0));
|
||||
List<List<TableBorderCell>> table = new ArrayList<>(numberOfRows);
|
||||
for (int rowNumber = 0; rowNumber < numberOfRows; rowNumber++) {
|
||||
addTableRow(numberOfColumns, table);
|
||||
}
|
||||
BoundingBox tableBoundingBox = new MultiBoundingBox();
|
||||
for (int rowNumber = 0; rowNumber < tableRows.size(); rowNumber++) {
|
||||
int columnNumber = 0;
|
||||
for (INode elem : tableRows.get(rowNumber).getChildren()) {
|
||||
SemanticType type = elem.getInitialSemanticType();
|
||||
if (SemanticType.TABLE_CELL != type && SemanticType.TABLE_HEADER != type) {
|
||||
continue;
|
||||
}
|
||||
while (columnNumber < numberOfColumns && table.get(rowNumber).get(columnNumber) != null) {
|
||||
++columnNumber;
|
||||
}
|
||||
TableBorderCell cell = new TableBorderCell(elem, rowNumber, columnNumber);
|
||||
cell.setSemanticType(type);
|
||||
processTableCell(cell, elem);
|
||||
tableBoundingBox.union(cell.getBoundingBox());
|
||||
for (int i = 0; i < cell.getRowSpan(); i++) {
|
||||
if (rowNumber + i >= numberOfRows) {
|
||||
numberOfRows++;
|
||||
addTableRow(numberOfColumns, table);
|
||||
}
|
||||
for (int j = 0; j < cell.getColSpan(); j++) {
|
||||
if (columnNumber + j >= numberOfColumns) {
|
||||
addTableColumn(table);
|
||||
numberOfColumns++;
|
||||
}
|
||||
table.get(rowNumber + i).set(columnNumber + j, cell);
|
||||
}
|
||||
}
|
||||
columnNumber += cell.getColSpan();
|
||||
}
|
||||
}
|
||||
if (tableBoundingBox.isEmpty()) {
|
||||
//empty table
|
||||
return;
|
||||
}
|
||||
TableBorder tableBorder = new TableBorder(tableBoundingBox, createRowsForTable(table, numberOfRows, numberOfColumns),
|
||||
numberOfRows, numberOfColumns);
|
||||
setBoundingBoxesForTableRowsAndTableCells(tableBorder);
|
||||
addObjectToContent(tableBorder);
|
||||
}
|
||||
|
||||
private static List<INode> processTableRows(INode table) {
|
||||
List<INode> listTR = new LinkedList<>();
|
||||
for (INode elem : table.getChildren()) {
|
||||
SemanticType type = elem.getInitialSemanticType();
|
||||
if (SemanticType.TABLE_ROW == type) {
|
||||
listTR.add(elem);
|
||||
processTableRowsChildren(elem);
|
||||
} else if (SemanticType.TABLE_FOOTER == type || SemanticType.TABLE_BODY == type ||
|
||||
SemanticType.TABLE_HEADERS == type) {
|
||||
for (INode child : elem.getChildren()) {
|
||||
if (SemanticType.TABLE_ROW == child.getInitialSemanticType()) {
|
||||
listTR.add(child);
|
||||
processTableRowsChildren(child);
|
||||
} else {
|
||||
processStructElem(child, elem);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
processStructElem(elem, table);
|
||||
}
|
||||
}
|
||||
return listTR;
|
||||
}
|
||||
|
||||
private static void processTableRowsChildren(INode tableRow) {
|
||||
for (INode tableCell : tableRow.getChildren()) {
|
||||
SemanticType tableCellType = tableCell.getInitialSemanticType();
|
||||
if (SemanticType.TABLE_CELL != tableCellType && SemanticType.TABLE_HEADER != tableCellType) {
|
||||
processStructElem(tableCell, tableRow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addTableRow(int numberOfColumns, List<List<TableBorderCell>> table) {
|
||||
List<TableBorderCell> row = new ArrayList<>(numberOfColumns);
|
||||
table.add(row);
|
||||
for (int columnNumber = 0; columnNumber < numberOfColumns; columnNumber++) {
|
||||
row.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addTableColumn(List<List<TableBorderCell>> table) {
|
||||
for (List<TableBorderCell> tableBorderCells : table) {
|
||||
tableBorderCells.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static void processTableCell(TableBorderCell cell, INode elem) {
|
||||
List<IObject> rawContents = new ArrayList<>();
|
||||
processChildContents(elem, rawContents);
|
||||
List<IObject> processed = TextLineProcessor.processTextLines(rawContents);
|
||||
TextBlock textBlock = new TextBlock(new MultiBoundingBox());
|
||||
for (IObject content : processed) {
|
||||
if (content instanceof TextLine) {
|
||||
textBlock.add((TextLine) content);
|
||||
} else {
|
||||
cell.getContents().add(content);
|
||||
}
|
||||
}
|
||||
if (!textBlock.isEmpty()) {
|
||||
cell.getContents().add(ParagraphProcessor.createParagraphFromTextBlock(textBlock));
|
||||
}
|
||||
BoundingBox cellBoundingBox = new MultiBoundingBox();
|
||||
for (IObject content : cell.getContents()) {
|
||||
cellBoundingBox.union(content.getBoundingBox());
|
||||
}
|
||||
cell.setBoundingBox(cellBoundingBox);
|
||||
cell.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
}
|
||||
|
||||
private static void processChildContents(INode elem, List<IObject> contents) {
|
||||
contentsStack.add(contents);
|
||||
for (INode childChild : elem.getChildren()) {
|
||||
processStructElem(childChild, elem);
|
||||
}
|
||||
contentsStack.pop();
|
||||
}
|
||||
|
||||
private static TableBorderRow[] createRowsForTable(List<List<TableBorderCell>> table, int numberOfRows, int numberOfColumns) {
|
||||
TableBorderRow[] rows = new TableBorderRow[numberOfRows];
|
||||
for (int rowNumber = 0; rowNumber < numberOfRows; rowNumber++) {
|
||||
rows[rowNumber] = new TableBorderRow(rowNumber, numberOfColumns, null);
|
||||
}
|
||||
for (int rowNumber = 0; rowNumber < numberOfRows; rowNumber++) {
|
||||
for (int colNumber = 0; colNumber < numberOfColumns; colNumber++) {
|
||||
rows[rowNumber].getCells()[colNumber] = table.get(rowNumber).get(colNumber);
|
||||
if (rows[rowNumber].getCell(colNumber) == null) {
|
||||
TableBorderCell cell = new TableBorderCell(rowNumber, colNumber, 1, 1, 0L);
|
||||
cell.setSemanticType(SemanticType.TABLE_CELL);
|
||||
cell.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
rows[rowNumber].getCells()[colNumber] = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static void setBoundingBoxesForTableRowsAndTableCells(TableBorder tableBorder) {
|
||||
BoundingBox boundingBox = new BoundingBox(tableBorder.getPageNumber(),
|
||||
tableBorder.getTopY(), tableBorder.getLeftX(), tableBorder.getTopY(), tableBorder.getLeftX());
|
||||
for (int rowNumber = 0; rowNumber < tableBorder.getNumberOfRows(); rowNumber++) {
|
||||
BoundingBox rowBoundingBox = new MultiBoundingBox();
|
||||
for (int colNumber = 0; colNumber < tableBorder.getNumberOfColumns(); colNumber++) {
|
||||
TableBorderCell cell = tableBorder.getCell(rowNumber, colNumber);
|
||||
if (cell.getColNumber() == colNumber && cell.getRowNumber() == rowNumber) {
|
||||
if (cell.getBoundingBox().isEmpty()) {
|
||||
cell.setBoundingBox(boundingBox);
|
||||
} else {
|
||||
rowBoundingBox.union(tableBorder.getCell(rowNumber, colNumber).getBoundingBox());
|
||||
}
|
||||
}
|
||||
}
|
||||
tableBorder.getRow(rowNumber).setBoundingBox(rowBoundingBox.isEmpty() ? boundingBox : rowBoundingBox);
|
||||
}
|
||||
for (int rowNumber = 0; rowNumber < tableBorder.getNumberOfRows(); rowNumber++) {
|
||||
for (int columnNumber = 0; columnNumber < tableBorder.getNumberOfColumns(); columnNumber++) {
|
||||
TableBorderCell cell = tableBorder.getCell(rowNumber, columnNumber);
|
||||
if (cell.getRowNumber() == rowNumber && cell.getColNumber() == columnNumber && cell.getBoundingBox().isEmpty()) {
|
||||
cell.setBoundingBox(boundingBox);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void processCaption(INode node) {
|
||||
SemanticCaption caption = new SemanticCaption(createParagraph(node));
|
||||
addObjectToContent(caption);
|
||||
}
|
||||
|
||||
private static SemanticTOC processTOC(INode node) {
|
||||
SemanticTOC toc = new SemanticTOC();
|
||||
toc.setBoundingBox(new MultiBoundingBox());
|
||||
for (INode child : node.getChildren()) {
|
||||
if (child.getInitialSemanticType() == SemanticType.TABLE_OF_CONTENT) {
|
||||
toc.add(processTOC(child));
|
||||
} else if (child.getInitialSemanticType() == SemanticType.TABLE_OF_CONTENT_ITEM) {
|
||||
SemanticTOCI tocItem = processTOCItem(child);
|
||||
if (tocItem.getPageNumber() != null) {
|
||||
toc.add(tocItem);
|
||||
}
|
||||
} else {
|
||||
processStructElem(child, node);
|
||||
}
|
||||
}
|
||||
return toc;
|
||||
}
|
||||
|
||||
private static SemanticTOCI processTOCItem(INode node) {
|
||||
SemanticTOCI tocItem = new SemanticTOCI(new MultiBoundingBox(), null);
|
||||
List<IObject> contents = new ArrayList<>();
|
||||
processChildContents(node, contents);
|
||||
contents = TextLineProcessor.processTextLines(contents);
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof TextLine) {
|
||||
tocItem.add((TextLine)content);
|
||||
} else {
|
||||
tocItem.getContents().add(content);
|
||||
}
|
||||
}
|
||||
tocItem.setRecognizedStructureId(StaticLayoutContainers.incrementContentId());
|
||||
return tocItem;
|
||||
}
|
||||
|
||||
private static void processImage(SemanticFigure image, INode parent) {
|
||||
GFSANode parentNode = (GFSANode) parent;
|
||||
List<ImageChunk> images = image.getImages();
|
||||
if (!images.isEmpty()) {
|
||||
String alt = parentNode.getStructElem().getStructElemDictionary().getAlternateDescription();
|
||||
ImageChunk imageChunk = images.get(0);
|
||||
addObjectToContent(alt == null ? imageChunk : new EnrichedImageChunk(imageChunk, alt));
|
||||
}
|
||||
}
|
||||
|
||||
private static void processTextChunk(SemanticSpan semanticSpan) {
|
||||
addObjectToContent(semanticSpan.getColumns().get(0).getFirstLine().getFirstTextChunk());
|
||||
}
|
||||
|
||||
private static List<IObject> getContents(INode node) {
|
||||
List<IObject> result = new ArrayList<>();
|
||||
for (INode child : node.getChildren()) {
|
||||
if (child instanceof SemanticSpan) {
|
||||
result.add(((SemanticSpan)child).getColumns().get(0).getFirstLine().getFirstTextChunk());
|
||||
} else if (child instanceof SemanticFigure) {
|
||||
processImage((SemanticFigure)child, node);
|
||||
} else {
|
||||
result.addAll(getContents(child));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.NodeUtils;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Detects strikethrough and underlined text by finding horizontal line or line-art rules that
|
||||
* pass through the vertical center or baseline of text chunks. Marks affected TextChunks by
|
||||
* setting their isStrikethroughText or isUnderlinedText fields to true.
|
||||
*
|
||||
* Filters to avoid false positives:
|
||||
* 1. Rule-to-text-height ratio (rejects thick background fills/borders)
|
||||
* 2. Rule-to-text width ratio (rejects rules wider than text)
|
||||
* 3. Vertical center or baseline alignment
|
||||
* 4. Horizontal overlap requirement
|
||||
* 5. Multi-chunk matching using the combined text width
|
||||
*/
|
||||
public class TextDecorationProcessor {
|
||||
|
||||
private static final double VERTICAL_CENTER_TOLERANCE = 0.2;
|
||||
private static final double MIN_HORIZONTAL_OVERLAP_RATIO = 0.8;
|
||||
private static final double MAX_LINE_TO_TEXT_WIDTH_RATIO = 1.5;
|
||||
|
||||
// Real strikethrough marks are thin rules. Text-height rectangles commonly
|
||||
// come from glyph bounds, filled artifacts, backgrounds, or table artwork.
|
||||
private static final double MAX_RULE_THICKNESS = 2.0;
|
||||
private static final double MAX_RULE_TO_TEXT_HEIGHT_RATIO = 0.25;
|
||||
|
||||
/**
|
||||
* Detects strikethrough and underlined lines among page contents and sets affected
|
||||
* TextChunk isStrikethroughText or isUnderlinedText field to true.
|
||||
*
|
||||
* @param pageContents the list of content objects for a page
|
||||
* @param pageNumber the number of a page
|
||||
* @param detectStrikethrough wether strikethrough text should be detected
|
||||
*/
|
||||
public static void processStrikethroughAndUnderlinedText(List<IObject> pageContents, int pageNumber, boolean detectStrikethrough) {
|
||||
if (pageContents == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
SortedSet<LineChunk> horizontalLines = StaticContainers.getLinesCollection() != null ?
|
||||
StaticContainers.getLinesCollection().getHorizontalLines(pageNumber) : Collections.emptySortedSet();
|
||||
List<HorizontalRuleCandidate> horizontalRules = new ArrayList<>();
|
||||
|
||||
for (LineChunk lineChunk : horizontalLines) {
|
||||
HorizontalRuleCandidate rule = HorizontalRuleCandidate.fromLineChunk(lineChunk);
|
||||
if (rule != null) {
|
||||
horizontalRules.add(rule);
|
||||
}
|
||||
}
|
||||
|
||||
List<TextChunk> textChunks = new ArrayList<>();
|
||||
for (IObject content : pageContents) {
|
||||
// if (content instanceof LineArtChunk) {
|
||||
// HorizontalRuleCandidate rule = HorizontalRuleCandidate.fromLineArtChunk((LineArtChunk) content);
|
||||
// if (rule != null) {
|
||||
// horizontalRules.add(rule);
|
||||
// }
|
||||
// } else
|
||||
if (content instanceof TextChunk) {
|
||||
textChunks.add((TextChunk) content);
|
||||
}
|
||||
}
|
||||
|
||||
if (horizontalRules.isEmpty() || textChunks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (HorizontalRuleCandidate rule : horizontalRules) {
|
||||
List<TextChunk> strikethroughMatches = new ArrayList<>();
|
||||
List<TextChunk> underlineMatches = new ArrayList<>();
|
||||
|
||||
for (TextChunk textChunk : textChunks) {
|
||||
if (textChunk.isWhiteSpaceChunk() || textChunk.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (detectStrikethrough && isStrikethroughRule(rule, textChunk)) {
|
||||
strikethroughMatches.add(textChunk);
|
||||
}
|
||||
if (isUnderlineRule(rule, textChunk)) {
|
||||
underlineMatches.add(textChunk);
|
||||
}
|
||||
}
|
||||
|
||||
if (detectStrikethrough && isValidMatch(rule, strikethroughMatches)) {
|
||||
for (TextChunk chunk : strikethroughMatches) {
|
||||
if (!chunk.getIsStrikethroughText()) {
|
||||
chunk.setIsStrikethroughText();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isValidMatch(rule, underlineMatches)) {
|
||||
for (TextChunk chunk : underlineMatches) {
|
||||
if (!chunk.getIsUnderlinedText()) {
|
||||
chunk.setIsUnderlinedText();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a horizontal line is a strikethrough for the given text chunk.
|
||||
*/
|
||||
static boolean isStrikethroughLine(LineChunk line, TextChunk textChunk) {
|
||||
HorizontalRuleCandidate rule = HorizontalRuleCandidate.fromLineChunk(line);
|
||||
return rule != null && isStrikethroughRule(rule, textChunk) && isValidMatch(rule, Collections.singletonList(textChunk));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a line-art rectangle is a strikethrough for the given text chunk.
|
||||
*/
|
||||
// static boolean isStrikethroughLineArt(LineArtChunk lineArt, TextChunk textChunk) {
|
||||
// HorizontalRuleCandidate rule = HorizontalRuleCandidate.fromLineArtChunk(lineArt);
|
||||
// // Line-art rectangles are also used for table/background artwork, so
|
||||
// // apply the width-ratio guard even for direct single-chunk checks.
|
||||
// return rule != null && isStrikethroughRule(rule, textChunk) && isValidMatch(rule, List.of(textChunk));
|
||||
// }
|
||||
|
||||
/**
|
||||
* Determines whether a horizontal line is an underlined for the given text chunk.
|
||||
*/
|
||||
public static boolean isUnderlineLine(LineChunk line, TextChunk textChunk) {
|
||||
HorizontalRuleCandidate rule = HorizontalRuleCandidate.fromLineChunk(line);
|
||||
return rule != null && isUnderlineRule(rule, textChunk) && isValidMatch(rule, Collections.singletonList(textChunk));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a line-art rectangle is an underlined for the given text chunk.
|
||||
*/
|
||||
// public static boolean isUnderlineLineArt(LineArtChunk lineArt, TextChunk textChunk) {
|
||||
// HorizontalRuleCandidate rule = HorizontalRuleCandidate.fromLineArtChunk(lineArt);
|
||||
// return rule != null && isUnderlineRule(rule, textChunk) && isValidMatch(rule, List.of(textChunk));
|
||||
// }
|
||||
|
||||
// private static boolean isHorizontalLineArt(LineArtChunk lineArt) {
|
||||
// BoundingBox bbox = lineArt.getBoundingBox();
|
||||
// return bbox != null && bbox.getWidth() > bbox.getHeight();
|
||||
// }
|
||||
|
||||
private static boolean isStrikethroughRule(HorizontalRuleCandidate rule, TextChunk textChunk) {
|
||||
if (rule == null || textChunk == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
double textHeight = textChunk.getHeight();
|
||||
|
||||
if (textHeight <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// LineChunk thickness is stroke width; LineArtChunk thickness is rectangle
|
||||
// height. In both cases, strikethrough marks should stay thin relative to text.
|
||||
double maxRuleThickness = Math.min(MAX_RULE_THICKNESS, textHeight * MAX_RULE_TO_TEXT_HEIGHT_RATIO);
|
||||
if (rule.thickness > maxRuleThickness) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check vertical position: the line's Y should be near the vertical center of the text
|
||||
double textCenterY = textChunk.getCenterY();
|
||||
double lineY = rule.centerY;
|
||||
double tolerance = textHeight * VERTICAL_CENTER_TOLERANCE;
|
||||
|
||||
if (Math.abs(lineY - textCenterY) > tolerance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check horizontal overlap
|
||||
double textLeftX = textChunk.getLeftX();
|
||||
double textRightX = textChunk.getRightX();
|
||||
double lineLeftX = rule.leftX;
|
||||
double lineRightX = rule.rightX;
|
||||
|
||||
double overlapLeft = Math.max(textLeftX, lineLeftX);
|
||||
double overlapRight = Math.min(textRightX, lineRightX);
|
||||
double overlapWidth = overlapRight - overlapLeft;
|
||||
|
||||
if (overlapWidth <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
double textWidth = textChunk.getWidth();
|
||||
if (textWidth <= 0 || (overlapWidth / textWidth) < MIN_HORIZONTAL_OVERLAP_RATIO) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isUnderlineRule(HorizontalRuleCandidate rule, TextChunk textChunk) {
|
||||
if (rule == null || textChunk == null) {
|
||||
return false;
|
||||
}
|
||||
double textHeight = textChunk.getHeight();
|
||||
if (textHeight <= 0) return false;
|
||||
|
||||
// Thickness: line width must be less than eps[2] * textHeight
|
||||
// (eps[2] = 0.3, i.e. 30% of text height)
|
||||
if (rule.thickness >= NodeUtils.UNDERLINED_TEXT_EPSILONS[2] * textHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vertical position: line must be between the baseline and
|
||||
// (baseline - eps[1] * textHeight)
|
||||
double baseline = textChunk.getBaseLine();
|
||||
double lineY = rule.centerY;
|
||||
double lowerBound = baseline - NodeUtils.UNDERLINED_TEXT_EPSILONS[1] * textHeight;
|
||||
// Assuming Y increases upward, baseline is above the descender zone.
|
||||
// lineY should be <= baseline and >= lowerBound.
|
||||
if (lineY > baseline || lineY < lowerBound) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Horizontal overlap: at least eps[0] * textWidth overlap
|
||||
double textLeftX = textChunk.getLeftX();
|
||||
double textRightX = textChunk.getRightX();
|
||||
double overlap = Math.min(rule.rightX - textLeftX, textRightX - rule.leftX);
|
||||
if (overlap <= NodeUtils.UNDERLINED_TEXT_EPSILONS[0] * textChunk.getWidth()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isValidMatch(HorizontalRuleCandidate rule, List<TextChunk> matchingChunks) {
|
||||
if (matchingChunks.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate against the combined span of all matched chunks. This accepts
|
||||
// split text on one visual line while still rejecting table separators
|
||||
// and background rules that extend far past the text group.
|
||||
double textGroupWidth = 0.0;
|
||||
for (TextChunk chunk : matchingChunks) {
|
||||
textGroupWidth += chunk.getWidth();
|
||||
}
|
||||
return textGroupWidth > 0 && rule.width / textGroupWidth <= MAX_LINE_TO_TEXT_WIDTH_RATIO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized geometry for either LineChunk or LineArtChunk.
|
||||
*/
|
||||
private static class HorizontalRuleCandidate {
|
||||
private final double leftX;
|
||||
private final double rightX;
|
||||
private final double centerY;
|
||||
private final double width;
|
||||
private final double thickness;
|
||||
|
||||
private HorizontalRuleCandidate(double leftX, double rightX, double centerY, double width, double thickness) {
|
||||
this.leftX = leftX;
|
||||
this.rightX = rightX;
|
||||
this.centerY = centerY;
|
||||
this.width = width;
|
||||
this.thickness = thickness;
|
||||
}
|
||||
|
||||
private static HorizontalRuleCandidate fromLineChunk(LineChunk line) {
|
||||
if (line == null || line.getBoundingBox() == null || !line.isHorizontalLine()) {
|
||||
return null;
|
||||
}
|
||||
BoundingBox bbox = line.getBoundingBox();
|
||||
return new HorizontalRuleCandidate(line.getLeftX(), line.getRightX(), line.getCenterY(),
|
||||
bbox.getWidth(), line.getWidth());
|
||||
}
|
||||
|
||||
// private static HorizontalRuleCandidate fromLineArtChunk(LineArtChunk lineArt) {
|
||||
// if (lineArt == null || !isHorizontalLineArt(lineArt)) {
|
||||
// return null;
|
||||
// }
|
||||
// BoundingBox bbox = lineArt.getBoundingBox();
|
||||
// return new HorizontalRuleCandidate(lineArt.getLeftX(), lineArt.getRightX(), lineArt.getCenterY(),
|
||||
// bbox.getWidth(), bbox.getHeight());
|
||||
// }
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.SemanticTextNode;
|
||||
import org.verapdf.wcag.algorithms.entities.content.LineArtChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextLine;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.ChunksMergeUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.ListUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.TextChunkUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class TextLineProcessor {
|
||||
|
||||
private static final double ONE_LINE_PROBABILITY = 0.75;
|
||||
private static final Comparator<TextChunk> TEXT_CHUNK_COMPARATOR =
|
||||
Comparator.comparingDouble(o -> o.getBoundingBox().getLeftX());
|
||||
|
||||
public static List<IObject> processTextLines(List<IObject> contents) {
|
||||
List<IObject> newContents = new ArrayList<>();
|
||||
// Track which TextChunk immediately follows a whitespace chunk in stream order,
|
||||
// using reference identity so lookups are immune to TextChunk.equals() semantics.
|
||||
// Stream order may differ from visual (leftX) order in rare PDFs, but whitespace
|
||||
// chunks originate from the same PDF text operator as their adjacent text chunks,
|
||||
// so stream-order adjacency is reliable for this signal.
|
||||
Set<TextChunk> chunksAfterWhitespace = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
TextLine previousLine = new TextLine(new TextChunk(""));
|
||||
boolean isSeparateLine = false;
|
||||
boolean pendingWhitespace = false;
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof TextChunk) {
|
||||
TextChunk textChunk = (TextChunk) content;
|
||||
if (textChunk.isWhiteSpaceChunk() || textChunk.isEmpty()) {
|
||||
if (textChunk.isWhiteSpaceChunk()) {
|
||||
pendingWhitespace = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (pendingWhitespace) {
|
||||
chunksAfterWhitespace.add(textChunk);
|
||||
pendingWhitespace = false;
|
||||
}
|
||||
TextLine currentLine = new TextLine(textChunk);
|
||||
double oneLineProbability = ChunksMergeUtils.countOneLineProbability(new SemanticTextNode(), previousLine, currentLine);
|
||||
isSeparateLine |= (oneLineProbability < ONE_LINE_PROBABILITY) || previousLine.isHiddenText() != currentLine.isHiddenText();
|
||||
if (isSeparateLine) {
|
||||
previousLine.setBoundingBox(new BoundingBox(previousLine.getBoundingBox()));
|
||||
previousLine = currentLine;
|
||||
newContents.add(previousLine);
|
||||
} else {
|
||||
previousLine.add(currentLine);
|
||||
}
|
||||
isSeparateLine = false;
|
||||
} else {
|
||||
if (content instanceof TableBorder) {
|
||||
isSeparateLine = true;
|
||||
}
|
||||
newContents.add(content);
|
||||
pendingWhitespace = false;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < newContents.size(); i++) {
|
||||
IObject content = newContents.get(i);
|
||||
if (content instanceof TextLine) {
|
||||
TextLine textLine = (TextLine) content;
|
||||
textLine.getTextChunks().sort(TEXT_CHUNK_COMPARATOR);
|
||||
double threshold = textLine.getFontSize() * TextChunkUtils.TEXT_LINE_SPACE_RATIO;
|
||||
newContents.set(i, getTextLineWithSpaces(textLine, threshold, chunksAfterWhitespace));
|
||||
}
|
||||
}
|
||||
linkTextLinesWithConnectedLineArtBullet(newContents);
|
||||
return newContents;
|
||||
}
|
||||
|
||||
private static TextLine getTextLineWithSpaces(TextLine textLine, double threshold,
|
||||
Set<TextChunk> chunksAfterWhitespace) {
|
||||
List<TextChunk> textChunks = textLine.getTextChunks();
|
||||
TextChunk currentTextChunk = textChunks.get(0);
|
||||
double previousEnd = currentTextChunk.getTextEnd();
|
||||
TextLine newLine = new TextLine();
|
||||
newLine.add(currentTextChunk);
|
||||
for (int i = 1; i < textChunks.size(); i++) {
|
||||
currentTextChunk = textChunks.get(i);
|
||||
double currentStart = currentTextChunk.getTextStart();
|
||||
boolean hasGap = currentStart - previousEnd > threshold;
|
||||
boolean hadWhitespace = chunksAfterWhitespace.contains(currentTextChunk);
|
||||
if (hasGap || hadWhitespace) {
|
||||
TextChunk spaceChunk = new TextChunk(new BoundingBox(currentTextChunk.getBoundingBox()), " ", currentTextChunk.getFontName(),
|
||||
currentTextChunk.getFontSize(), currentTextChunk.getFontWeight(), currentTextChunk.getItalicAngle(), currentTextChunk.getBaseLine(),
|
||||
currentTextChunk.getFontColor(), null, currentTextChunk.getSlantDegree());
|
||||
spaceChunk.setTextStart(previousEnd);
|
||||
spaceChunk.setTextEnd(currentStart);
|
||||
spaceChunk.adjustSymbolEndsToBoundingBox(null);
|
||||
newLine.add(spaceChunk);
|
||||
}
|
||||
previousEnd = currentTextChunk.getTextEnd();
|
||||
newLine.add(currentTextChunk);
|
||||
}
|
||||
|
||||
return newLine;
|
||||
}
|
||||
|
||||
private static void linkTextLinesWithConnectedLineArtBullet(List<IObject> contents) {
|
||||
LineArtChunk lineArtChunk = null;
|
||||
for (IObject content : contents) {
|
||||
if (content instanceof LineArtChunk) {
|
||||
lineArtChunk = (LineArtChunk) content;
|
||||
continue;
|
||||
}
|
||||
if (content instanceof TableBorder) {
|
||||
lineArtChunk = null;
|
||||
}
|
||||
if (content instanceof TextLine && lineArtChunk != null) {
|
||||
TextLine textLine = (TextLine) content;
|
||||
if (isLineConnectedWithLineArt(textLine, lineArtChunk)) {
|
||||
textLine.setConnectedLineArtLabel(lineArtChunk);
|
||||
}
|
||||
lineArtChunk = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isLineConnectedWithLineArt(TextLine textLine, LineArtChunk lineArt) {
|
||||
return lineArt.getRightX() <= textLine.getLeftX() && lineArt.getBoundingBox().getHeight() <
|
||||
ListUtils.LIST_LABEL_HEIGHT_EPSILON * textLine.getBoundingBox().getHeight();
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors;
|
||||
|
||||
import org.verapdf.gf.model.factory.chunks.ChunkParser;
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.content.ImageChunk;
|
||||
import org.verapdf.wcag.algorithms.entities.content.TextChunk;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.ChunksMergeUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.NodeUtils;
|
||||
import org.verapdf.wcag.algorithms.semanticalgorithms.utils.TextChunkUtils;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class TextProcessor {
|
||||
|
||||
private static final double MIN_TEXT_INTERSECTION_PERCENT = 0.5;
|
||||
private static final double MAX_TOP_DECORATION_IMAGE_EPSILON = 0.3;
|
||||
private static final double MAX_BOTTOM_DECORATION_IMAGE_EPSILON = 0.1;
|
||||
private static final double MAX_LEFT_DECORATION_IMAGE_EPSILON = 0.1;
|
||||
private static final double MAX_RIGHT_DECORATION_IMAGE_EPSILON = 1.5;
|
||||
private static final double NEIGHBORS_TEXT_CHUNKS_EPSILON = 0.1;
|
||||
private static final double TEXT_MIN_HEIGHT = 1;
|
||||
|
||||
public static void replaceUndefinedCharacters(List<IObject> contents, String replacementCharacterString) {
|
||||
if (ChunkParser.REPLACEMENT_CHARACTER_STRING.equals(replacementCharacterString)) {
|
||||
return;
|
||||
}
|
||||
for (IObject object : contents) {
|
||||
if (object instanceof TextChunk) {
|
||||
TextChunk textChunk = ((TextChunk) object);
|
||||
if (textChunk.getValue().contains(ChunkParser.REPLACEMENT_CHARACTER_STRING)) {
|
||||
textChunk.setValue(textChunk.getValue().replace(ChunkParser.REPLACEMENT_CHARACTER_STRING, replacementCharacterString));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static double measureReplacementCharRatio(List<IObject> contents) {
|
||||
char replacementChar = ChunkParser.REPLACEMENT_CHARACTER_STRING.charAt(0);
|
||||
int totalChars = 0;
|
||||
int replacementChars = 0;
|
||||
for (IObject object : contents) {
|
||||
if (object instanceof TextChunk) {
|
||||
String value = ((TextChunk) object).getValue();
|
||||
totalChars += value.length();
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
if (value.charAt(i) == replacementChar) {
|
||||
replacementChars++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (totalChars == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
return (double) replacementChars / totalChars;
|
||||
}
|
||||
|
||||
public static void filterTinyText(List<IObject> contents) {
|
||||
for (int i = 0; i < contents.size(); i++) {
|
||||
IObject object = contents.get(i);
|
||||
if (object instanceof TextChunk) {
|
||||
TextChunk textChunk = ((TextChunk) object);
|
||||
if (textChunk.getBoundingBox().getHeight() <= TEXT_MIN_HEIGHT) {
|
||||
contents.set(i, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void trimTextChunksWhiteSpaces(List<IObject> contents) {
|
||||
for (int i = 0; i < contents.size(); i++) {
|
||||
IObject object = contents.get(i);
|
||||
if (object instanceof TextChunk) {
|
||||
contents.set(i, ChunksMergeUtils.getTrimTextChunk((TextChunk) object));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void mergeCloseTextChunks(List<IObject> contents) {
|
||||
for (int i = 0; i < contents.size() - 1; i++) {
|
||||
IObject object = contents.get(i);
|
||||
IObject nextObject = contents.get(i + 1);
|
||||
if (object instanceof TextChunk && nextObject instanceof TextChunk) {
|
||||
TextChunk textChunk = (TextChunk) object;
|
||||
TextChunk nextTextChunk = (TextChunk) nextObject;
|
||||
if (TextChunkUtils.areTextChunksHaveSameStyle(textChunk, nextTextChunk) &&
|
||||
TextChunkUtils.areTextChunksHaveSameBaseLine(textChunk, nextTextChunk) &&
|
||||
areNeighborsTextChunks(textChunk, nextTextChunk)) {
|
||||
contents.set(i, null);
|
||||
contents.set(i + 1, TextChunkUtils.unionTextChunks(textChunk, nextTextChunk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void removeSameTextChunks(List<IObject> contents) {
|
||||
DocumentProcessor.setIndexesForContentsList(contents);
|
||||
List<IObject> sortedTextChunks = contents.stream().filter(c -> c instanceof TextChunk).sorted(
|
||||
Comparator.comparing(x -> ((TextChunk) x).getValue())).collect(Collectors.toList());
|
||||
TextChunk lastTextChunk = null;
|
||||
for (IObject object : sortedTextChunks) {
|
||||
if (object instanceof TextChunk) {
|
||||
TextChunk currentTextChunk = (TextChunk) object;
|
||||
if (lastTextChunk != null && areSameTextChunks(lastTextChunk, currentTextChunk)) {
|
||||
contents.set(lastTextChunk.getIndex(), null);
|
||||
}
|
||||
lastTextChunk = currentTextChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean areSameTextChunks(TextChunk firstTextChunk, TextChunk secondTextChunk) {
|
||||
return Objects.equals(firstTextChunk.getValue(), secondTextChunk.getValue()) &&
|
||||
NodeUtils.areCloseNumbers(firstTextChunk.getWidth(), secondTextChunk.getWidth()) &&
|
||||
NodeUtils.areCloseNumbers(firstTextChunk.getHeight(), secondTextChunk.getHeight()) &&
|
||||
firstTextChunk.getBoundingBox().getIntersectionPercent(secondTextChunk.getBoundingBox()) > MIN_TEXT_INTERSECTION_PERCENT;
|
||||
}
|
||||
|
||||
public static void removeTextDecorationImages(List<IObject> contents) {
|
||||
TextChunk lastTextChunk = null;
|
||||
for (int index = 0; index < contents.size(); index++) {
|
||||
IObject object = contents.get(index);
|
||||
if (object instanceof TextChunk) {
|
||||
lastTextChunk = (TextChunk) object;
|
||||
} else if (object instanceof ImageChunk && lastTextChunk != null &&
|
||||
isTextChunkDecorationImage((ImageChunk) object, lastTextChunk)) {
|
||||
contents.set(index, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isTextChunkDecorationImage(ImageChunk imageChunk, TextChunk textChunk) {
|
||||
return NodeUtils.areCloseNumbers(imageChunk.getTopY(), textChunk.getTopY(), MAX_TOP_DECORATION_IMAGE_EPSILON * textChunk.getHeight()) &&
|
||||
NodeUtils.areCloseNumbers(imageChunk.getBottomY(), textChunk.getBottomY(), MAX_BOTTOM_DECORATION_IMAGE_EPSILON * textChunk.getHeight()) &&
|
||||
(NodeUtils.areCloseNumbers(imageChunk.getLeftX(), textChunk.getLeftX(), MAX_LEFT_DECORATION_IMAGE_EPSILON * textChunk.getHeight()) || imageChunk.getLeftX() > textChunk.getLeftX()) &&
|
||||
(NodeUtils.areCloseNumbers(imageChunk.getRightX(), textChunk.getRightX(), MAX_RIGHT_DECORATION_IMAGE_EPSILON * textChunk.getHeight()) || imageChunk.getRightX() < textChunk.getRightX());
|
||||
}
|
||||
|
||||
private static boolean areNeighborsTextChunks(TextChunk firstTextChunk, TextChunk secondTextChunk) {
|
||||
return NodeUtils.areCloseNumbers(firstTextChunk.getTextEnd(), secondTextChunk.getTextStart(),
|
||||
NEIGHBORS_TEXT_CHUNKS_EPSILON * firstTextChunk.getBoundingBox().getHeight());
|
||||
}
|
||||
}
|
||||
+651
@@ -0,0 +1,651 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.processors.readingorder;
|
||||
|
||||
import org.verapdf.wcag.algorithms.entities.IObject;
|
||||
import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* XY-Cut++ algorithm for reading order detection based on arXiv:2504.10258.
|
||||
* <p>
|
||||
* An enhanced XY-Cut implementation that handles:
|
||||
* <ul>
|
||||
* <li>Cross-layout elements (headers, footers spanning multiple columns)</li>
|
||||
* <li>Adaptive axis selection based on density ratios</li>
|
||||
* <li>L-shaped region handling</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* This is a simplified geometric implementation without semantic type priorities.
|
||||
* <p>
|
||||
* Algorithm overview:
|
||||
* <ol>
|
||||
* <li>Pre-mask: Identify cross-layout elements (width > beta * maxWidth, overlaps >= 2)</li>
|
||||
* <li>Compute density ratio to determine split direction preference</li>
|
||||
* <li>Recursive segmentation with adaptive XY/YX-Cut</li>
|
||||
* <li>Merge cross-layout elements at appropriate positions</li>
|
||||
* </ol>
|
||||
*/
|
||||
public class XYCutPlusPlusSorter {
|
||||
|
||||
/** Default beta multiplier for cross-layout detection threshold.
|
||||
* Higher value = fewer elements detected as cross-layout.
|
||||
* 2.0 means element must be 2x wider than maxWidth to be considered cross-layout (effectively disabled). */
|
||||
static final double DEFAULT_BETA = 2.0;
|
||||
|
||||
/** Default density threshold for adaptive axis selection. */
|
||||
static final double DEFAULT_DENSITY_THRESHOLD = 0.9;
|
||||
|
||||
/** Minimum horizontal overlap ratio to count as overlapping. */
|
||||
static final double OVERLAP_THRESHOLD = 0.1;
|
||||
|
||||
/** Minimum number of overlaps required for cross-layout classification. */
|
||||
static final int MIN_OVERLAP_COUNT = 2;
|
||||
|
||||
/** Minimum gap size (in points) required to perform a cut.
|
||||
* Prevents splitting on insignificant gaps (e.g., 1-pixel gaps). */
|
||||
static final double MIN_GAP_THRESHOLD = 5.0;
|
||||
|
||||
/** Width ratio threshold for narrow outlier filtering.
|
||||
* Elements narrower than this fraction of the region width are considered
|
||||
* potential outliers that may bridge column gaps (e.g., page numbers, footnote markers). */
|
||||
static final double NARROW_ELEMENT_WIDTH_RATIO = 0.1;
|
||||
|
||||
private XYCutPlusPlusSorter() {
|
||||
// Utility class - prevent instantiation
|
||||
}
|
||||
|
||||
// ========== PUBLIC API ==========
|
||||
|
||||
/**
|
||||
* Sort objects using XY-Cut++ algorithm with default parameters.
|
||||
*
|
||||
* @param objects List of objects to sort
|
||||
* @return Sorted list of objects in reading order
|
||||
*/
|
||||
public static List<IObject> sort(List<IObject> objects) {
|
||||
return sort(objects, DEFAULT_BETA, DEFAULT_DENSITY_THRESHOLD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort objects using XY-Cut++ algorithm with custom parameters.
|
||||
*
|
||||
* @param objects List of objects to sort
|
||||
* @param beta Cross-layout detection threshold multiplier
|
||||
* @param densityThreshold Density ratio threshold for axis selection
|
||||
* @return Sorted list of objects in reading order
|
||||
*/
|
||||
public static List<IObject> sort(List<IObject> objects, double beta, double densityThreshold) {
|
||||
if (objects == null || objects.size() <= 1) {
|
||||
return objects;
|
||||
}
|
||||
|
||||
// Filter out objects with null bounding boxes
|
||||
List<IObject> validObjects = new ArrayList<>();
|
||||
for (IObject obj : objects) {
|
||||
if (obj != null && obj.getBoundingBox() != null) {
|
||||
validObjects.add(obj);
|
||||
}
|
||||
}
|
||||
if (validObjects.size() <= 1) {
|
||||
return validObjects;
|
||||
}
|
||||
|
||||
// Phase 1: Pre-mask cross-layout elements
|
||||
List<IObject> crossLayoutElements = identifyCrossLayoutElements(validObjects, beta);
|
||||
List<IObject> remainingObjects = new ArrayList<>(validObjects);
|
||||
remainingObjects.removeAll(crossLayoutElements);
|
||||
|
||||
if (remainingObjects.isEmpty()) {
|
||||
// All objects are cross-layout, just sort by Y
|
||||
return sortByYThenX(validObjects);
|
||||
}
|
||||
|
||||
// Phase 2: Compute density ratio for adaptive axis selection
|
||||
double densityRatio = computeDensityRatio(remainingObjects);
|
||||
boolean preferHorizontalFirst = densityRatio > densityThreshold;
|
||||
|
||||
// Phase 3: Recursive segmentation with adaptive axis
|
||||
List<IObject> sortedMain = recursiveSegment(remainingObjects, preferHorizontalFirst);
|
||||
|
||||
// Phase 4: Merge cross-layout elements back at appropriate positions
|
||||
return mergeCrossLayoutElements(sortedMain, crossLayoutElements);
|
||||
}
|
||||
|
||||
// ========== PHASE 1: CROSS-LAYOUT DETECTION ==========
|
||||
|
||||
/**
|
||||
* Identify cross-layout elements that span multiple regions.
|
||||
* An element is cross-layout if:
|
||||
* 1. Its width exceeds beta * maxWidth (where maxWidth is the widest element)
|
||||
* 2. It horizontally overlaps with at least MIN_OVERLAP_COUNT other elements
|
||||
*
|
||||
* Using maxWidth instead of median ensures only truly wide elements
|
||||
* (like titles spanning the full page) are detected as cross-layout.
|
||||
*
|
||||
* @param objects List of objects to analyze
|
||||
* @param beta Threshold multiplier for width comparison (e.g., 0.7 = 70% of max width)
|
||||
* @return List of cross-layout elements
|
||||
*/
|
||||
static List<IObject> identifyCrossLayoutElements(List<IObject> objects, double beta) {
|
||||
List<IObject> crossLayoutElements = new ArrayList<>();
|
||||
|
||||
if (objects.size() < 3) {
|
||||
// Need at least 3 objects for meaningful cross-layout detection
|
||||
return crossLayoutElements;
|
||||
}
|
||||
|
||||
// Calculate max width among all objects
|
||||
double maxWidth = 0;
|
||||
for (IObject obj : objects) {
|
||||
BoundingBox bbox = obj.getBoundingBox();
|
||||
if (bbox != null) {
|
||||
double width = bbox.getWidth();
|
||||
maxWidth = Math.max(maxWidth, width);
|
||||
}
|
||||
}
|
||||
|
||||
// Threshold: element must be at least beta * maxWidth to be cross-layout
|
||||
// With beta=0.7, element must be at least 70% as wide as the widest element
|
||||
double threshold = beta * maxWidth;
|
||||
|
||||
for (IObject obj : objects) {
|
||||
BoundingBox bbox = obj.getBoundingBox();
|
||||
if (bbox == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
double width = bbox.getWidth();
|
||||
|
||||
// Criterion 1: Width exceeds threshold (close to max width)
|
||||
if (width >= threshold) {
|
||||
// Criterion 2: Overlaps with at least MIN_OVERLAP_COUNT other elements
|
||||
if (hasMinimumOverlaps(obj, objects, MIN_OVERLAP_COUNT)) {
|
||||
crossLayoutElements.add(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return crossLayoutElements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element horizontally overlaps with at least minCount other elements.
|
||||
*
|
||||
* @param element The element to check
|
||||
* @param objects All objects including the element
|
||||
* @param minCount Minimum number of overlaps required
|
||||
* @return true if the element overlaps with at least minCount other elements
|
||||
*/
|
||||
static boolean hasMinimumOverlaps(IObject element, List<IObject> objects, int minCount) {
|
||||
BoundingBox elementBox = element.getBoundingBox();
|
||||
if (elementBox == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int overlapCount = 0;
|
||||
for (IObject other : objects) {
|
||||
if (other == element) {
|
||||
continue;
|
||||
}
|
||||
|
||||
BoundingBox otherBox = other.getBoundingBox();
|
||||
if (otherBox == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
double overlapRatio = calculateHorizontalOverlapRatio(elementBox, otherBox);
|
||||
if (overlapRatio >= OVERLAP_THRESHOLD) {
|
||||
overlapCount++;
|
||||
if (overlapCount >= minCount) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the horizontal overlap ratio between two bounding boxes.
|
||||
* The ratio is relative to the smaller box's width.
|
||||
*
|
||||
* @param box1 First bounding box
|
||||
* @param box2 Second bounding box
|
||||
* @return Overlap ratio (0.0 to 1.0)
|
||||
*/
|
||||
static double calculateHorizontalOverlapRatio(BoundingBox box1, BoundingBox box2) {
|
||||
double overlapLeft = Math.max(box1.getLeftX(), box2.getLeftX());
|
||||
double overlapRight = Math.min(box1.getRightX(), box2.getRightX());
|
||||
double overlapWidth = Math.max(0, overlapRight - overlapLeft);
|
||||
|
||||
if (overlapWidth <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
double width1 = box1.getWidth();
|
||||
double width2 = box2.getWidth();
|
||||
double smallerWidth = Math.min(width1, width2);
|
||||
|
||||
return smallerWidth > 0 ? overlapWidth / smallerWidth : 0;
|
||||
}
|
||||
|
||||
// ========== PHASE 2: DENSITY RATIO COMPUTATION ==========
|
||||
|
||||
/**
|
||||
* Compute the density ratio to determine split direction preference.
|
||||
* Density = total content area / bounding region area.
|
||||
* Higher density suggests content-dense layouts (newspapers) -> prefer horizontal splits.
|
||||
* Lower density suggests sparse layouts -> prefer vertical splits.
|
||||
*
|
||||
* @param objects List of objects
|
||||
* @return Density ratio (0.0 to 1.0)
|
||||
*/
|
||||
static double computeDensityRatio(List<IObject> objects) {
|
||||
if (objects == null || objects.isEmpty()) {
|
||||
return 1.0; // Default to XY-Cut
|
||||
}
|
||||
|
||||
BoundingBox regionBounds = calculateBoundingRegion(objects);
|
||||
if (regionBounds == null) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
double regionArea = regionBounds.getArea();
|
||||
|
||||
if (regionArea <= 0) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
double contentArea = calculateTotalArea(objects);
|
||||
return Math.min(1.0, contentArea / regionArea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the bounding box that encompasses all objects.
|
||||
*
|
||||
* @param objects List of objects
|
||||
* @return Bounding box encompassing all objects, or null if no valid objects
|
||||
*/
|
||||
static BoundingBox calculateBoundingRegion(List<IObject> objects) {
|
||||
BoundingBox boundingBox = new BoundingBox();
|
||||
|
||||
for (IObject obj : objects) {
|
||||
BoundingBox bbox = obj.getBoundingBox();
|
||||
boundingBox.union(bbox);
|
||||
}
|
||||
|
||||
return boundingBox.isEmpty() ? null : boundingBox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the total area covered by all objects.
|
||||
*
|
||||
* @param objects List of objects
|
||||
* @return Total area
|
||||
*/
|
||||
static double calculateTotalArea(List<IObject> objects) {
|
||||
double totalArea = 0;
|
||||
for (IObject obj : objects) {
|
||||
BoundingBox bbox = obj.getBoundingBox();
|
||||
if (bbox != null) {
|
||||
totalArea += bbox.getArea();
|
||||
}
|
||||
}
|
||||
return totalArea;
|
||||
}
|
||||
|
||||
// ========== PHASE 3: RECURSIVE SEGMENTATION ==========
|
||||
|
||||
/**
|
||||
* Recursively segment and sort objects using adaptive XY/YX-Cut.
|
||||
* <p>
|
||||
* The algorithm uses projection-based gap detection to find clean cuts.
|
||||
* For two-column academic paper layouts:
|
||||
* 1. First try horizontal cut to separate header from body
|
||||
* 2. Then try vertical cut to separate columns
|
||||
* <p>
|
||||
* The algorithm prefers horizontal cuts first (Y-axis split) when there's a significant
|
||||
* horizontal gap, which properly handles layouts with wide headers followed by columns.
|
||||
*
|
||||
* @param objects List of objects to segment
|
||||
* @param preferHorizontalFirst Initial preference (used as tiebreaker)
|
||||
* @return Sorted list of objects
|
||||
*/
|
||||
static List<IObject> recursiveSegment(List<IObject> objects, boolean preferHorizontalFirst) {
|
||||
if (objects == null || objects.size() <= 1) {
|
||||
return objects != null ? new ArrayList<>(objects) : new ArrayList<>();
|
||||
}
|
||||
|
||||
// Find best cuts in both directions using projection-based detection
|
||||
CutInfo horizontalCut = findBestHorizontalCutWithProjection(objects);
|
||||
CutInfo verticalCut = findBestVerticalCutWithProjection(objects);
|
||||
|
||||
// Choose cut direction based on gap sizes
|
||||
// Apply minimum gap threshold to avoid splitting on insignificant gaps
|
||||
boolean hasValidHorizontalCut = horizontalCut.gap >= MIN_GAP_THRESHOLD;
|
||||
boolean hasValidVerticalCut = verticalCut.gap >= MIN_GAP_THRESHOLD;
|
||||
|
||||
boolean useHorizontalCut;
|
||||
if (hasValidHorizontalCut && hasValidVerticalCut) {
|
||||
// Both cuts available - prefer larger gap
|
||||
useHorizontalCut = horizontalCut.gap > verticalCut.gap;
|
||||
} else if (hasValidHorizontalCut) {
|
||||
useHorizontalCut = true;
|
||||
} else if (hasValidVerticalCut) {
|
||||
useHorizontalCut = false;
|
||||
} else {
|
||||
// No valid cuts found - sort by Y then X (reading order)
|
||||
return sortByYThenX(objects);
|
||||
}
|
||||
|
||||
if (useHorizontalCut) {
|
||||
List<List<IObject>> groups = splitByHorizontalCut(objects, horizontalCut.position);
|
||||
// Safety: if split produced only one group, fall back to prevent infinite recursion
|
||||
if (groups.size() <= 1) {
|
||||
return sortByYThenX(objects);
|
||||
}
|
||||
return flatMapRecursive(groups, preferHorizontalFirst);
|
||||
} else {
|
||||
List<List<IObject>> groups = splitByVerticalCut(objects, verticalCut.position);
|
||||
// Safety: if split produced only one group, fall back to prevent infinite recursion
|
||||
if (groups.size() <= 1) {
|
||||
return sortByYThenX(objects);
|
||||
}
|
||||
return flatMapRecursive(groups, preferHorizontalFirst);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for cut information including position and gap size.
|
||||
*/
|
||||
private static class CutInfo {
|
||||
final double position;
|
||||
final double gap;
|
||||
|
||||
CutInfo(double position, double gap) {
|
||||
this.position = position;
|
||||
this.gap = gap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively process groups and flatten results.
|
||||
*/
|
||||
private static List<IObject> flatMapRecursive(List<List<IObject>> groups, boolean preferHorizontalFirst) {
|
||||
List<IObject> result = new ArrayList<>();
|
||||
for (List<IObject> group : groups) {
|
||||
result.addAll(recursiveSegment(group, preferHorizontalFirst));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best vertical cut using projection profile.
|
||||
* Projects all objects onto the X-axis and finds the largest gap.
|
||||
*
|
||||
* @param objects List of objects
|
||||
* @return CutInfo containing position and gap size
|
||||
*/
|
||||
private static CutInfo findBestVerticalCutWithProjection(List<IObject> objects) {
|
||||
if (objects.size() < 2) {
|
||||
return new CutInfo(0, 0);
|
||||
}
|
||||
|
||||
CutInfo edgeCut = findVerticalCutByEdges(objects);
|
||||
|
||||
// If the edge gap is already significant, use it directly.
|
||||
if (edgeCut.gap >= MIN_GAP_THRESHOLD) {
|
||||
return edgeCut;
|
||||
}
|
||||
|
||||
// When edge gap is small, narrow outlier elements (e.g., page numbers,
|
||||
// footnote markers) may bridge an otherwise clear column gap.
|
||||
// Retry without elements narrower than 10% of the region width.
|
||||
if (objects.size() >= 3) {
|
||||
BoundingBox region = calculateBoundingRegion(objects);
|
||||
if (region != null) {
|
||||
double regionWidth = region.getWidth();
|
||||
double narrowThreshold = regionWidth * NARROW_ELEMENT_WIDTH_RATIO;
|
||||
List<IObject> filtered = new ArrayList<>();
|
||||
for (IObject obj : objects) {
|
||||
BoundingBox bbox = obj.getBoundingBox();
|
||||
double width = bbox.getWidth();
|
||||
if (width >= narrowThreshold) {
|
||||
filtered.add(obj);
|
||||
}
|
||||
}
|
||||
if (filtered.size() >= 2 && filtered.size() < objects.size()) {
|
||||
CutInfo filteredCut = findVerticalCutByEdges(filtered);
|
||||
if (filteredCut.gap > edgeCut.gap && filteredCut.gap >= MIN_GAP_THRESHOLD) {
|
||||
return filteredCut;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edgeCut;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find vertical cut by edge gaps.
|
||||
* Finds the largest gap between rightX of one element and leftX of the next.
|
||||
*/
|
||||
private static CutInfo findVerticalCutByEdges(List<IObject> objects) {
|
||||
List<IObject> sorted = new ArrayList<>(objects);
|
||||
sorted.sort(Comparator.comparingDouble((IObject o) -> o.getBoundingBox().getLeftX())
|
||||
.thenComparingDouble(o -> o.getBoundingBox().getRightX()));
|
||||
|
||||
double largestGap = 0;
|
||||
double cutPosition = 0;
|
||||
Double prevRight = null;
|
||||
|
||||
for (IObject obj : sorted) {
|
||||
double left = obj.getLeftX();
|
||||
double right = obj.getRightX();
|
||||
|
||||
if (prevRight != null && left > prevRight) {
|
||||
double gap = left - prevRight;
|
||||
if (gap > largestGap) {
|
||||
largestGap = gap;
|
||||
cutPosition = (prevRight + left) / 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
prevRight = (prevRight == null) ? right : Math.max(prevRight, right);
|
||||
}
|
||||
|
||||
return new CutInfo(cutPosition, largestGap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best horizontal cut using projection profile.
|
||||
* Projects all objects onto the Y-axis and finds the largest gap.
|
||||
*
|
||||
* @param objects List of objects
|
||||
* @return CutInfo containing position and gap size
|
||||
*/
|
||||
private static CutInfo findBestHorizontalCutWithProjection(List<IObject> objects) {
|
||||
if (objects.size() < 2) {
|
||||
return new CutInfo(0, 0);
|
||||
}
|
||||
|
||||
// Sort by topY descending (PDF: top to bottom)
|
||||
List<IObject> sorted = new ArrayList<>(objects);
|
||||
sorted.sort(Comparator.comparingDouble((IObject o) -> -o.getBoundingBox().getTopY())
|
||||
.thenComparingDouble(o -> -o.getBoundingBox().getBottomY()));
|
||||
|
||||
double largestGap = 0;
|
||||
double cutPosition = 0;
|
||||
Double prevBottom = null;
|
||||
|
||||
for (IObject obj : sorted) {
|
||||
double top = obj.getTopY();
|
||||
double bottom = obj.getBottomY();
|
||||
|
||||
if (prevBottom != null && prevBottom > top) {
|
||||
double gap = prevBottom - top;
|
||||
if (gap > largestGap) {
|
||||
largestGap = gap;
|
||||
cutPosition = (prevBottom + top) / 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
prevBottom = (prevBottom == null) ? bottom : Math.min(prevBottom, bottom);
|
||||
}
|
||||
|
||||
return new CutInfo(cutPosition, largestGap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split objects by a horizontal cut at the given Y coordinate.
|
||||
* Objects above the cut come first, then objects below.
|
||||
*
|
||||
* @param objects List of objects to split
|
||||
* @param cutY Y coordinate of the cut
|
||||
* @return List of two groups: [above, below]
|
||||
*/
|
||||
static List<List<IObject>> splitByHorizontalCut(List<IObject> objects, double cutY) {
|
||||
List<IObject> above = new ArrayList<>();
|
||||
List<IObject> below = new ArrayList<>();
|
||||
|
||||
for (IObject obj : objects) {
|
||||
// Use center Y to determine which group
|
||||
double centerY = obj.getCenterY();
|
||||
if (centerY > cutY) {
|
||||
above.add(obj);
|
||||
} else {
|
||||
below.add(obj);
|
||||
}
|
||||
}
|
||||
|
||||
List<List<IObject>> groups = new ArrayList<>();
|
||||
if (!above.isEmpty()) {
|
||||
groups.add(above);
|
||||
}
|
||||
if (!below.isEmpty()) {
|
||||
groups.add(below);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split objects by a vertical cut at the given X coordinate.
|
||||
* Objects to the left come first, then objects to the right.
|
||||
*
|
||||
* @param objects List of objects to split
|
||||
* @param cutX X coordinate of the cut
|
||||
* @return List of two groups: [left, right]
|
||||
*/
|
||||
static List<List<IObject>> splitByVerticalCut(List<IObject> objects, double cutX) {
|
||||
List<IObject> left = new ArrayList<>();
|
||||
List<IObject> right = new ArrayList<>();
|
||||
|
||||
for (IObject obj : objects) {
|
||||
// Use center X to determine which group
|
||||
double centerX = obj.getCenterX();
|
||||
if (centerX < cutX) {
|
||||
left.add(obj);
|
||||
} else {
|
||||
right.add(obj);
|
||||
}
|
||||
}
|
||||
|
||||
List<List<IObject>> groups = new ArrayList<>();
|
||||
if (!left.isEmpty()) {
|
||||
groups.add(left);
|
||||
}
|
||||
if (!right.isEmpty()) {
|
||||
groups.add(right);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ========== PHASE 4: MERGING ==========
|
||||
|
||||
/**
|
||||
* Merge cross-layout elements back into the sorted content at appropriate positions.
|
||||
* Cross-layout elements are inserted based on their Y position relative to surrounding content.
|
||||
*
|
||||
* @param sortedMain Main content sorted by reading order
|
||||
* @param crossLayoutElements Cross-layout elements to merge
|
||||
* @return Merged list with cross-layout elements in correct positions
|
||||
*/
|
||||
static List<IObject> mergeCrossLayoutElements(List<IObject> sortedMain, List<IObject> crossLayoutElements) {
|
||||
if (crossLayoutElements.isEmpty()) {
|
||||
return sortedMain;
|
||||
}
|
||||
|
||||
if (sortedMain.isEmpty()) {
|
||||
return sortByYThenX(crossLayoutElements);
|
||||
}
|
||||
|
||||
// Sort cross-layout elements by Y (top to bottom)
|
||||
List<IObject> sortedCrossLayout = sortByYThenX(crossLayoutElements);
|
||||
|
||||
List<IObject> result = new ArrayList<>();
|
||||
int mainIndex = 0;
|
||||
int crossIndex = 0;
|
||||
|
||||
while (mainIndex < sortedMain.size() || crossIndex < sortedCrossLayout.size()) {
|
||||
if (crossIndex >= sortedCrossLayout.size()) {
|
||||
// No more cross-layout elements, add remaining main
|
||||
result.add(sortedMain.get(mainIndex++));
|
||||
} else if (mainIndex >= sortedMain.size()) {
|
||||
// No more main elements, add remaining cross-layout
|
||||
result.add(sortedCrossLayout.get(crossIndex++));
|
||||
} else {
|
||||
// Compare Y positions (PDF: higher Y = top)
|
||||
IObject mainObj = sortedMain.get(mainIndex);
|
||||
IObject crossObj = sortedCrossLayout.get(crossIndex);
|
||||
|
||||
double mainTopY = mainObj.getTopY();
|
||||
double crossTopY = crossObj.getTopY();
|
||||
|
||||
if (crossTopY >= mainTopY) {
|
||||
// Cross-layout element is above or at same level, add it first
|
||||
result.add(crossObj);
|
||||
crossIndex++;
|
||||
} else {
|
||||
// Main element is above, add it first
|
||||
result.add(mainObj);
|
||||
mainIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ========== UTILITY METHODS ==========
|
||||
|
||||
/**
|
||||
* Sort objects by Y coordinate (top to bottom), then X coordinate (left to right).
|
||||
*
|
||||
* @param objects List of objects to sort
|
||||
* @return Sorted list
|
||||
*/
|
||||
static List<IObject> sortByYThenX(List<IObject> objects) {
|
||||
List<IObject> sorted = new ArrayList<>(objects);
|
||||
sorted.sort(Comparator
|
||||
.comparingDouble((IObject o) -> -o.getBoundingBox().getTopY()) // Higher Y first (top)
|
||||
.thenComparingDouble(o -> o.getBoundingBox().getLeftX())); // Lower X first (left)
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.text;
|
||||
|
||||
import org.opendataloader.pdf.api.Config;
|
||||
import org.verapdf.wcag.algorithms.entities.*;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.ListItem;
|
||||
import org.verapdf.wcag.algorithms.entities.lists.PDFList;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorder;
|
||||
import org.verapdf.wcag.algorithms.entities.tables.tableBorders.TableBorderRow;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Generates a plain text representation of the extracted PDF contents.
|
||||
*/
|
||||
public class TextGenerator implements Closeable {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(TextGenerator.class.getCanonicalName());
|
||||
private static final String INDENT = " ";
|
||||
|
||||
private final java.io.Writer textWriter;
|
||||
private final String textFileName;
|
||||
private final String lineSeparator = System.lineSeparator();
|
||||
private final String textPageSeparator;
|
||||
/**
|
||||
* Page numbers (1-based) selected by --pages; an empty set means all pages.
|
||||
* Sourced from the raw {@link Config#getPageNumbers()} list (not the
|
||||
* validated set built by {@code DocumentProcessor.getValidPageNumbers}).
|
||||
* Safe to compare against {@code pageIndex + 1} because the surrounding
|
||||
* loop is bounded by the document's actual page count, so out-of-range
|
||||
* values from the raw list are never tested for membership.
|
||||
*/
|
||||
private final Set<Integer> selectedPageNumbers;
|
||||
private final boolean includeHeaderFooter;
|
||||
|
||||
public TextGenerator(File inputPdf, Config config) throws IOException {
|
||||
String cutPdfFileName = inputPdf.getName();
|
||||
this.textFileName = config.getOutputFolder() + File.separator + cutPdfFileName.substring(0, cutPdfFileName.length() - 3) + "txt";
|
||||
this.textWriter = new FileWriter(textFileName, StandardCharsets.UTF_8);
|
||||
this.textPageSeparator = config.getTextPageSeparator();
|
||||
this.selectedPageNumbers = new HashSet<>(config.getPageNumbers());
|
||||
this.includeHeaderFooter = config.isIncludeHeaderFooter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a TextGenerator that writes to an arbitrary Writer (e.g., stdout).
|
||||
*/
|
||||
public TextGenerator(java.io.Writer writer, Config config) {
|
||||
this.textFileName = null;
|
||||
this.textWriter = writer;
|
||||
this.textPageSeparator = config.getTextPageSeparator();
|
||||
this.selectedPageNumbers = new HashSet<>(config.getPageNumbers());
|
||||
this.includeHeaderFooter = config.isIncludeHeaderFooter();
|
||||
}
|
||||
|
||||
public void writeToText(List<List<IObject>> contents) {
|
||||
try {
|
||||
for (int pageIndex = 0; pageIndex < contents.size(); pageIndex++) {
|
||||
if (selectedPageNumbers.isEmpty() || selectedPageNumbers.contains(pageIndex + 1)) {
|
||||
writePageSeparator(pageIndex);
|
||||
}
|
||||
List<IObject> pageContents = contents.get(pageIndex);
|
||||
writeContents(pageContents, 0);
|
||||
if (pageIndex < contents.size() - 1) {
|
||||
textWriter.write(lineSeparator);
|
||||
}
|
||||
}
|
||||
LOGGER.log(Level.INFO, "Created {0}", textFileName);
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.WARNING, "Unable to create text output: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void writePageSeparator(int pageIndex) throws IOException {
|
||||
if (!textPageSeparator.isEmpty()) {
|
||||
textWriter.write(textPageSeparator.contains(Config.PAGE_NUMBER_STRING)
|
||||
? textPageSeparator.replace(Config.PAGE_NUMBER_STRING, String.valueOf(pageIndex + 1))
|
||||
: textPageSeparator);
|
||||
textWriter.write(lineSeparator);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeContents(List<IObject> contents, int indentLevel) throws IOException {
|
||||
for (int index = 0; index < contents.size(); index++) {
|
||||
write(contents.get(index), indentLevel);
|
||||
if (index < contents.size() - 1) {
|
||||
textWriter.write(lineSeparator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void write(IObject object, int indentLevel) throws IOException {
|
||||
if (object instanceof SemanticHeaderOrFooter) {
|
||||
if (includeHeaderFooter) {
|
||||
writeHeaderOrFooter((SemanticHeaderOrFooter) object, indentLevel);
|
||||
}
|
||||
} else if (object instanceof SemanticHeading) {
|
||||
writeMultiline(((SemanticHeading) object).getValue(), indentLevel);
|
||||
} else if (object instanceof SemanticParagraph) {
|
||||
writeMultiline(((SemanticParagraph) object).getValue(), indentLevel);
|
||||
} else if (object instanceof SemanticTextNode) {
|
||||
writeMultiline(((SemanticTextNode) object).getValue(), indentLevel);
|
||||
} else if (object instanceof PDFList) {
|
||||
writeList((PDFList) object, indentLevel);
|
||||
} else if (object instanceof SemanticTOC) {
|
||||
writeTOC((SemanticTOC) object, indentLevel);
|
||||
} else if (object instanceof TableBorder) {
|
||||
writeTable((TableBorder) object, indentLevel);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeHeaderOrFooter(SemanticHeaderOrFooter headerOrFooter, int indentLevel) throws IOException {
|
||||
writeContents(headerOrFooter.getContents(), indentLevel);
|
||||
}
|
||||
|
||||
private void writeList(PDFList list, int indentLevel) throws IOException {
|
||||
for (ListItem item : list.getListItems()) {
|
||||
writeMultiline(item.toString(), indentLevel);
|
||||
if (!item.getContents().isEmpty()) {
|
||||
writeContents(item.getContents(), indentLevel + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeTOC(SemanticTOC toc, int indentLevel) throws IOException {
|
||||
for (IObject item : toc.getTOCItems()) {
|
||||
if (item instanceof SemanticTOC) {
|
||||
writeTOC((SemanticTOC)item, indentLevel + 1);
|
||||
} else if (item instanceof SemanticTOCI) {
|
||||
SemanticTOCI tocItem = (SemanticTOCI) item;
|
||||
writeMultiline(tocItem.toString(), indentLevel);
|
||||
if (!tocItem.getContents().isEmpty()) {
|
||||
writeContents(tocItem.getContents(), indentLevel + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeTable(TableBorder table, int indentLevel) throws IOException {
|
||||
String indent = indent(indentLevel);
|
||||
for (TableBorderRow row : table.getRows()) {
|
||||
String rowText = Arrays.stream(row.getCells())
|
||||
.map(cell -> compactWhitespace(collectPlainText(cell.getContents())))
|
||||
.filter(text -> !text.isEmpty())
|
||||
.collect(Collectors.joining("\t"));
|
||||
if (rowText.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
textWriter.write(indent);
|
||||
textWriter.write(rowText);
|
||||
textWriter.write(lineSeparator);
|
||||
}
|
||||
}
|
||||
|
||||
private String collectPlainText(List<IObject> contents) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (IObject content : contents) {
|
||||
String piece = extractPlainText(content);
|
||||
if (piece.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(' ');
|
||||
}
|
||||
builder.append(piece);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String extractPlainText(IObject content) {
|
||||
if (content instanceof SemanticHeaderOrFooter) {
|
||||
if (includeHeaderFooter) {
|
||||
return collectPlainText(((SemanticHeaderOrFooter) content).getContents());
|
||||
}
|
||||
return "";
|
||||
} else if (content instanceof SemanticHeading) {
|
||||
return sanitize(((SemanticHeading) content).getValue());
|
||||
} else if (content instanceof SemanticParagraph) {
|
||||
return sanitize(((SemanticParagraph) content).getValue());
|
||||
} else if (content instanceof SemanticTextNode) {
|
||||
return sanitize(((SemanticTextNode) content).getValue());
|
||||
} else if (content instanceof PDFList) {
|
||||
PDFList list = (PDFList) content;
|
||||
return list.getListItems().stream()
|
||||
.map(item -> compactWhitespace(collectPlainText(item.getContents())))
|
||||
.filter(text -> !text.isEmpty())
|
||||
.collect(Collectors.joining(" "));
|
||||
} else if (content instanceof TableBorder) {
|
||||
TableBorder table = (TableBorder) content;
|
||||
return Arrays.stream(table.getRows())
|
||||
.map(row -> Arrays.stream(row.getCells())
|
||||
.map(cell -> compactWhitespace(collectPlainText(cell.getContents())))
|
||||
.filter(text -> !text.isEmpty())
|
||||
.collect(Collectors.joining(" ")))
|
||||
.filter(text -> !text.isEmpty())
|
||||
.collect(Collectors.joining(" "));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void writeMultiline(String value, int indentLevel) throws IOException {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
String sanitized = sanitize(value);
|
||||
String indent = indent(indentLevel);
|
||||
String[] lines = sanitized.split("\r?\n", -1);
|
||||
for (String line : lines) {
|
||||
if (line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
textWriter.write(indent);
|
||||
textWriter.write(line);
|
||||
textWriter.write(lineSeparator);
|
||||
}
|
||||
}
|
||||
|
||||
private String indent(int level) {
|
||||
if (level <= 0) {
|
||||
return "";
|
||||
}
|
||||
return INDENT.repeat(level);
|
||||
}
|
||||
|
||||
private String sanitize(String value) {
|
||||
return value == null ? "" : value.replace("\u0000", " ");
|
||||
}
|
||||
|
||||
private String compactWhitespace(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String sanitized = sanitize(value);
|
||||
return sanitized.replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (textWriter != null) {
|
||||
textWriter.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2025-2026 Hancom Inc.
|
||||
*
|
||||
* Licensed 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.opendataloader.pdf.utils;
|
||||
|
||||
import org.opendataloader.pdf.containers.StaticLayoutContainers;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Base64;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Utility class for converting images to Base64 data URIs.
|
||||
*/
|
||||
public final class Base64ImageUtils {
|
||||
private static final Logger LOGGER = Logger.getLogger(Base64ImageUtils.class.getCanonicalName());
|
||||
|
||||
/**
|
||||
* Maximum image file size for Base64 embedding (10MB).
|
||||
* Larger images will be skipped to prevent memory exhaustion.
|
||||
*/
|
||||
public static final long MAX_EMBEDDED_IMAGE_SIZE = 10L * 1024 * 1024;
|
||||
|
||||
private Base64ImageUtils() {
|
||||
// Private constructor to prevent instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an image file to a Base64 data URI string.
|
||||
* Images larger than {@link #MAX_EMBEDDED_IMAGE_SIZE} will be skipped.
|
||||
*
|
||||
* @param imageFile The image file to convert
|
||||
* @param format The image format (png, jpeg)
|
||||
* @return The Base64 data URI string, or null if conversion fails or image is too large
|
||||
*/
|
||||
public static String toDataUri(File imageFile, String format) {
|
||||
try {
|
||||
// Embedded mode keeps encoded image bytes in memory keyed by the path
|
||||
// generators expect — no disk roundtrip in production.
|
||||
byte[] fileContent = StaticLayoutContainers.getEmbeddedImageBytes(imageFile.getPath());
|
||||
if (fileContent == null) {
|
||||
// Disk fallback: runs in external mode (where files exist on disk) and
|
||||
// also serves unit tests that prepare PNG files directly and bypass
|
||||
// the in-memory pipeline (see ImageSerializerTest, MarkdownGenerator tests).
|
||||
long fileSize = imageFile.length();
|
||||
if (fileSize > MAX_EMBEDDED_IMAGE_SIZE) {
|
||||
LOGGER.log(Level.WARNING, "Image too large to embed ({0} bytes, max {1} bytes): {2}",
|
||||
new Object[]{fileSize, MAX_EMBEDDED_IMAGE_SIZE, imageFile.getName()});
|
||||
return null;
|
||||
}
|
||||
fileContent = Files.readAllBytes(imageFile.toPath());
|
||||
} else if (fileContent.length > MAX_EMBEDDED_IMAGE_SIZE) {
|
||||
LOGGER.log(Level.WARNING, "Image too large to embed ({0} bytes, max {1} bytes): {2}",
|
||||
new Object[]{(long) fileContent.length, MAX_EMBEDDED_IMAGE_SIZE, imageFile.getName()});
|
||||
return null;
|
||||
}
|
||||
String base64 = Base64.getEncoder().encodeToString(fileContent);
|
||||
String mimeType = getMimeType(format);
|
||||
return String.format("data:%s;base64,%s", mimeType, base64);
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.WARNING, "Unable to convert image to Base64: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the MIME type for the given image format.
|
||||
*
|
||||
* @param format The image format (png, jpeg)
|
||||
* @return The corresponding MIME type
|
||||
*/
|
||||
public static String getMimeType(String format) {
|
||||
if (format == null) {
|
||||
return "image/png";
|
||||
}
|
||||
switch (format.toLowerCase()) {
|
||||
case "jpeg":
|
||||
case "jpg":
|
||||
return "image/jpeg";
|
||||
case "png":
|
||||
default:
|
||||
return "image/png";
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user