chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user