chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:59:42 +08:00
commit 59f8f60dad
348 changed files with 139133 additions and 0 deletions
+137
View File
@@ -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");
}
}
@@ -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);
}
}
}
@@ -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 &lt;path&gt;" 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();
}
}