diff --git a/minipdf-java/minipdf-cli/pom.xml b/minipdf-java/minipdf-cli/pom.xml new file mode 100644 index 00000000..9cd0e870 --- /dev/null +++ b/minipdf-java/minipdf-cli/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + + io.github.minisoftware + minipdf-java-parent + 0.1.0-SNAPSHOT + + + minipdf-cli + MiniPdf Java CLI + + + + ${project.groupId} + minipdf + ${project.version} + + + info.picocli + picocli + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.1 + + + package + + shade + + + false + + + io.github.minisoftware.minipdf.cli.MiniPdfCommand + + + + + + + + + \ No newline at end of file diff --git a/minipdf-java/minipdf-cli/src/main/java/io/github/minisoftware/minipdf/cli/MiniPdfCommand.java b/minipdf-java/minipdf-cli/src/main/java/io/github/minisoftware/minipdf/cli/MiniPdfCommand.java new file mode 100644 index 00000000..e4b26554 --- /dev/null +++ b/minipdf-java/minipdf-cli/src/main/java/io/github/minisoftware/minipdf/cli/MiniPdfCommand.java @@ -0,0 +1,171 @@ +package io.github.minisoftware.minipdf.cli; + +import io.github.minisoftware.minipdf.ConversionOptions; +import io.github.minisoftware.minipdf.MiniPdf; +import io.github.minisoftware.minipdf.MiniPdfException; +import io.github.minisoftware.minipdf.PageSize; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.Spec; +import picocli.CommandLine.Model.CommandSpec; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.concurrent.Callable; + +@Command( + name = "minipdf", + version = "minipdf-java 0.1.0-SNAPSHOT", + description = "Convert XLSX and DOCX files to PDF with the Java MiniPdf engine.", + mixinStandardHelpOptions = true, + subcommands = MiniPdfCommand.ConvertCommand.class) +public final class MiniPdfCommand implements Callable { + @Spec + private CommandSpec spec; + + @Parameters(index = "0", arity = "0..1", paramLabel = "INPUT") + private Path input; + + @Mixin + private ConversionArguments arguments = new ConversionArguments(); + + public static void main(String[] args) { + CommandLine commandLine = createCommandLine(); + int exitCode = commandLine.execute(args); + System.exit(exitCode); + } + + public static CommandLine createCommandLine() { + CommandLine commandLine = new CommandLine(new MiniPdfCommand()); + commandLine.setCaseInsensitiveEnumValuesAllowed(true); + commandLine.setExecutionExceptionHandler((exception, current, parseResult) -> { + current.getErr().println("Error: " + exception.getMessage()); + return 1; + }); + commandLine.setParameterExceptionHandler((exception, args) -> { + exception.getCommandLine().getErr().println("Error: " + exception.getMessage()); + return 1; + }); + return commandLine; + } + + @Override + public Integer call() throws Exception { + if (input == null) { + throw new CommandLine.ParameterException(spec.commandLine(), "input file is required"); + } + return convert(input, arguments, spec.commandLine()); + } + + @Command(name = "convert", description = "Convert an Office document to PDF.", mixinStandardHelpOptions = true) + static final class ConvertCommand implements Callable { + @Spec + private CommandSpec spec; + + @Parameters(index = "0", paramLabel = "INPUT") + private Path input; + + @Mixin + private ConversionArguments arguments = new ConversionArguments(); + + @Override + public Integer call() throws Exception { + return convert(input, arguments, spec.commandLine()); + } + } + + static final class ConversionArguments { + @Option(names = {"-o", "--output"}, paramLabel = "OUTPUT") + private Path output; + + @Option(names = "--fonts", paramLabel = "DIR") + private Path fonts; + + @Option(names = "--paper-size", paramLabel = "SIZE") + private PaperSizeArgument paperSize; + + @Option(names = "--page-width", paramLabel = "POINTS") + private Float pageWidth; + + @Option(names = "--page-height", paramLabel = "POINTS") + private Float pageHeight; + + private ConversionOptions conversionOptions(CommandLine commandLine) throws MiniPdfException { + if (paperSize != null && (pageWidth != null || pageHeight != null)) { + throw new CommandLine.ParameterException( + commandLine, + "use either --paper-size or --page-width/--page-height, not both"); + } + if ((pageWidth == null) != (pageHeight == null)) { + throw new CommandLine.ParameterException( + commandLine, + "--page-width and --page-height must be specified together"); + } + if (paperSize != null) { + return ConversionOptions.withPageSize(paperSize == PaperSizeArgument.A4 + ? PageSize.A4 + : PageSize.LETTER); + } + if (pageWidth != null) { + return ConversionOptions.withPageSize(PageSize.of(pageWidth, pageHeight)); + } + return ConversionOptions.defaults(); + } + } + + enum PaperSizeArgument { + A4, + LETTER + } + + private static int convert(Path input, ConversionArguments arguments, CommandLine commandLine) + throws MiniPdfException, IOException { + if (!Files.isRegularFile(input)) { + throw new CommandLine.ParameterException(commandLine, "file not found: " + input); + } + String fileName = input.getFileName().toString(); + int dot = fileName.lastIndexOf('.'); + String extension = dot < 0 ? "" : fileName.substring(dot + 1).toLowerCase(Locale.ROOT); + if (!extension.equals("xlsx") && !extension.equals("docx")) { + throw new CommandLine.ParameterException( + commandLine, + "unsupported file type '." + extension + "'. Supported: .xlsx, .docx"); + } + + if (arguments.fonts != null) { + registerFonts(arguments.fonts, commandLine); + } + Path output = arguments.output == null ? replaceExtension(input, "pdf") : arguments.output; + MiniPdf.convertToPdf(input, output, arguments.conversionOptions(commandLine)); + commandLine.getOut().println(output); + return 0; + } + + private static void registerFonts(Path directory, CommandLine commandLine) throws IOException { + if (!Files.isDirectory(directory)) { + throw new CommandLine.ParameterException(commandLine, "font directory not found: " + directory); + } + try (var paths = Files.list(directory)) { + for (Path path : paths.filter(Files::isRegularFile).toList()) { + String name = path.getFileName().toString(); + int dot = name.lastIndexOf('.'); + String extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT); + if (extension.equals("ttf") || extension.equals("ttc") || extension.equals("otf")) { + MiniPdf.registerFont(dot < 0 ? name : name.substring(0, dot), Files.readAllBytes(path)); + } + } + } + } + + private static Path replaceExtension(Path input, String extension) { + String fileName = input.getFileName().toString(); + int dot = fileName.lastIndexOf('.'); + String outputName = (dot < 0 ? fileName : fileName.substring(0, dot)) + '.' + extension; + return input.resolveSibling(outputName); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf-cli/src/test/java/io/github/minisoftware/minipdf/cli/MiniPdfCommandTest.java b/minipdf-java/minipdf-cli/src/test/java/io/github/minisoftware/minipdf/cli/MiniPdfCommandTest.java new file mode 100644 index 00000000..3cb26100 --- /dev/null +++ b/minipdf-java/minipdf-cli/src/test/java/io/github/minisoftware/minipdf/cli/MiniPdfCommandTest.java @@ -0,0 +1,74 @@ +package io.github.minisoftware.minipdf.cli; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MiniPdfCommandTest { + private static final Path REPOSITORY_ROOT = Path.of("..", "..").toAbsolutePath().normalize(); + + @TempDir + Path temporaryDirectory; + + @Test + void convertsXlsxWithDirectSyntax() throws Exception { + Path source = REPOSITORY_ROOT.resolve( + "tests/MiniPdf.Scripts/output/classic01_basic_table_with_headers.xlsx"); + Path output = temporaryDirectory.resolve("direct.pdf"); + + CommandResult result = execute(source.toString(), "-o", output.toString()); + + assertEquals(0, result.exitCode()); + assertTrue(Files.readString(output, StandardCharsets.ISO_8859_1).startsWith("%PDF-1.4")); + assertTrue(result.stdout().contains(output.toString())); + } + + @Test + void convertsDocxWithSubcommandAndCustomSize() throws Exception { + Path source = REPOSITORY_ROOT.resolve( + "tests/MiniPdf.Scripts/output_docx/docx_classic01_single_paragraph.docx"); + Path output = temporaryDirectory.resolve("subcommand.pdf"); + + CommandResult result = execute( + "convert", source.toString(), "-o", output.toString(), + "--page-width", "400", "--page-height", "500"); + + assertEquals(0, result.exitCode()); + assertTrue(Files.readString(output, StandardCharsets.ISO_8859_1) + .contains("/MediaBox [0 0 400 500]")); + } + + @Test + void rejectsConflictingPageOptions() { + Path source = REPOSITORY_ROOT.resolve( + "tests/MiniPdf.Scripts/output/classic01_basic_table_with_headers.xlsx"); + + CommandResult result = execute( + source.toString(), "--paper-size", "a4", "--page-width", "400", "--page-height", "500"); + + assertEquals(1, result.exitCode()); + assertTrue(result.stderr().contains("use either --paper-size")); + } + + private static CommandResult execute(String... arguments) { + CommandLine commandLine = MiniPdfCommand.createCommandLine(); + StringWriter stdout = new StringWriter(); + StringWriter stderr = new StringWriter(); + commandLine.setOut(new PrintWriter(stdout, true)); + commandLine.setErr(new PrintWriter(stderr, true)); + int exitCode = commandLine.execute(arguments); + return new CommandResult(exitCode, stdout.toString(), stderr.toString()); + } + + private record CommandResult(int exitCode, String stdout, String stderr) { + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/pom.xml b/minipdf-java/minipdf/pom.xml new file mode 100644 index 00000000..00f3f8d2 --- /dev/null +++ b/minipdf-java/minipdf/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + io.github.minisoftware + minipdf-java-parent + 0.1.0-SNAPSHOT + + + minipdf + MiniPdf Java Library + + + + org.junit.jupiter + junit-jupiter + test + + + \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/ConversionOptions.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/ConversionOptions.java new file mode 100644 index 00000000..2c029fd5 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/ConversionOptions.java @@ -0,0 +1,25 @@ +package io.github.minisoftware.minipdf; + +import java.util.Optional; + +public final class ConversionOptions { + private static final ConversionOptions DEFAULTS = new ConversionOptions(null); + + private final PageSize pageSize; + + private ConversionOptions(PageSize pageSize) { + this.pageSize = pageSize; + } + + public static ConversionOptions defaults() { + return DEFAULTS; + } + + public static ConversionOptions withPageSize(PageSize pageSize) { + return new ConversionOptions(java.util.Objects.requireNonNull(pageSize, "pageSize")); + } + + public Optional pageSize() { + return Optional.ofNullable(pageSize); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/MiniPdf.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/MiniPdf.java new file mode 100644 index 00000000..214943b5 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/MiniPdf.java @@ -0,0 +1,85 @@ +package io.github.minisoftware.minipdf; + +import io.github.minisoftware.minipdf.internal.OfficePackageDetector; +import io.github.minisoftware.minipdf.internal.docx.DocxConverter; +import io.github.minisoftware.minipdf.internal.xlsx.XlsxConverter; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; + +public final class MiniPdf { + private static final CopyOnWriteArrayList REGISTERED_FONTS = new CopyOnWriteArrayList<>(); + + private MiniPdf() { + } + + public static void registerFont(String name, byte[] fontData) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("font name must not be blank"); + } + REGISTERED_FONTS.add(new RegisteredFont(name, fontData)); + } + + public static List registeredFonts() { + return List.copyOf(REGISTERED_FONTS); + } + + public static OfficeFormat detectOfficeFormat(byte[] input) throws MiniPdfException { + return OfficePackageDetector.detect(Objects.requireNonNull(input, "input")); + } + + public static byte[] convertToPdfBytes(Path inputPath) throws MiniPdfException { + return convertToPdfBytes(inputPath, ConversionOptions.defaults()); + } + + public static byte[] convertToPdfBytes(Path inputPath, ConversionOptions options) throws MiniPdfException { + Objects.requireNonNull(inputPath, "inputPath"); + Objects.requireNonNull(options, "options"); + try { + return convertBytesToPdf(Files.readAllBytes(inputPath), options); + } catch (IOException exception) { + throw new MiniPdfException( + MiniPdfException.Kind.IO, + "I/O error: " + exception.getMessage(), + exception); + } + } + + public static byte[] convertBytesToPdf(byte[] input) throws MiniPdfException { + return convertBytesToPdf(input, ConversionOptions.defaults()); + } + + public static byte[] convertBytesToPdf(byte[] input, ConversionOptions options) throws MiniPdfException { + Objects.requireNonNull(options, "options"); + OfficeFormat format = detectOfficeFormat(input); + return switch (format) { + case DOCX -> DocxConverter.convert(input, options); + case XLSX -> XlsxConverter.convert(input, options); + case PPTX, UNKNOWN -> throw new MiniPdfException( + MiniPdfException.Kind.UNSUPPORTED_FORMAT, + "unsupported or unknown Office document format"); + }; + } + + public static void convertToPdf(Path inputPath, Path outputPath) throws MiniPdfException { + convertToPdf(inputPath, outputPath, ConversionOptions.defaults()); + } + + public static void convertToPdf(Path inputPath, Path outputPath, ConversionOptions options) + throws MiniPdfException { + Objects.requireNonNull(outputPath, "outputPath"); + byte[] pdf = convertToPdfBytes(inputPath, options); + try { + Files.write(outputPath, pdf); + } catch (IOException exception) { + throw new MiniPdfException( + MiniPdfException.Kind.IO, + "I/O error: " + exception.getMessage(), + exception); + } + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/MiniPdfException.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/MiniPdfException.java new file mode 100644 index 00000000..20a04a98 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/MiniPdfException.java @@ -0,0 +1,29 @@ +package io.github.minisoftware.minipdf; + +import java.util.Objects; + +public final class MiniPdfException extends Exception { + public enum Kind { + IO, + ZIP_PACKAGE, + XML_PARSE, + UNSUPPORTED_FORMAT, + INVALID_INPUT + } + + private final Kind kind; + + public MiniPdfException(Kind kind, String message) { + super(message); + this.kind = Objects.requireNonNull(kind, "kind"); + } + + public MiniPdfException(Kind kind, String message, Throwable cause) { + super(message, cause); + this.kind = Objects.requireNonNull(kind, "kind"); + } + + public Kind kind() { + return kind; + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/OfficeFormat.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/OfficeFormat.java new file mode 100644 index 00000000..11d96431 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/OfficeFormat.java @@ -0,0 +1,8 @@ +package io.github.minisoftware.minipdf; + +public enum OfficeFormat { + UNKNOWN, + XLSX, + DOCX, + PPTX +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PageSize.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PageSize.java new file mode 100644 index 00000000..20338684 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PageSize.java @@ -0,0 +1,54 @@ +package io.github.minisoftware.minipdf; + +import java.util.Objects; + +public final class PageSize { + public static final PageSize A4 = new PageSize(595.28f, 841.89f); + public static final PageSize LETTER = new PageSize(612.0f, 792.0f); + + private final float width; + private final float height; + + private PageSize(float width, float height) { + this.width = width; + this.height = height; + } + + public static PageSize of(float width, float height) throws MiniPdfException { + if (!Float.isFinite(width) || !Float.isFinite(height) || width <= 0.0f || height <= 0.0f) { + throw new MiniPdfException( + MiniPdfException.Kind.INVALID_INPUT, + "page width and height must be positive finite values"); + } + return new PageSize(width, height); + } + + public float width() { + return width; + } + + public float height() { + return height; + } + + @Override + public boolean equals(Object value) { + if (this == value) { + return true; + } + if (!(value instanceof PageSize other)) { + return false; + } + return Float.compare(width, other.width) == 0 && Float.compare(height, other.height) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(width, height); + } + + @Override + public String toString() { + return "PageSize[width=" + width + ", height=" + height + ']'; + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PdfColor.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PdfColor.java new file mode 100644 index 00000000..514f640d --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PdfColor.java @@ -0,0 +1,18 @@ +package io.github.minisoftware.minipdf; + +public record PdfColor(float red, float green, float blue) { + public static final PdfColor BLACK = new PdfColor(0.0f, 0.0f, 0.0f); + public static final PdfColor WHITE = new PdfColor(1.0f, 1.0f, 1.0f); + + public PdfColor { + validate(red, "red"); + validate(green, "green"); + validate(blue, "blue"); + } + + private static void validate(float component, String name) { + if (!Float.isFinite(component) || component < 0.0f || component > 1.0f) { + throw new IllegalArgumentException(name + " must be a finite value from 0 to 1"); + } + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PdfDocument.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PdfDocument.java new file mode 100644 index 00000000..ba8fc744 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PdfDocument.java @@ -0,0 +1,138 @@ +package io.github.minisoftware.minipdf; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public final class PdfDocument { + private static final Charset PDF_TEXT_ENCODING = Charset.forName("windows-1252"); + + private final List pages = new ArrayList<>(); + + public PdfPage addPage(float width, float height) { + if (!Float.isFinite(width) || !Float.isFinite(height) || width <= 0.0f || height <= 0.0f) { + throw new IllegalArgumentException("page width and height must be positive finite values"); + } + PdfPage page = new PdfPage(width, height); + pages.add(page); + return page; + } + + public List pages() { + return List.copyOf(pages); + } + + public byte[] toBytes() { + int objectCount = 4 + pages.size() * 2; + List objects = new ArrayList<>(objectCount); + objects.add(ascii("<< /Type /Catalog /Pages 2 0 R >>")); + objects.add(ascii(pagesObject())); + objects.add(ascii("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")); + objects.add(ascii("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")); + + for (int index = 0; index < pages.size(); index++) { + PdfPage page = pages.get(index); + int contentObjectNumber = 6 + index * 2; + objects.add(ascii(pageObject(page, contentObjectNumber))); + objects.add(streamObject(pageContent(page))); + } + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + write(output, "%PDF-1.4\n"); + output.writeBytes(new byte[]{'%', (byte) 0xE2, (byte) 0xE3, (byte) 0xCF, (byte) 0xD3, '\n'}); + + List offsets = new ArrayList<>(objectCount); + for (int index = 0; index < objects.size(); index++) { + offsets.add(output.size()); + write(output, (index + 1) + " 0 obj\n"); + output.writeBytes(objects.get(index)); + write(output, "\nendobj\n"); + } + + int xrefOffset = output.size(); + write(output, "xref\n0 " + (objectCount + 1) + "\n"); + write(output, "0000000000 65535 f \n"); + for (int offset : offsets) { + write(output, String.format(Locale.ROOT, "%010d 00000 n \n", offset)); + } + write(output, "trailer\n<< /Size " + (objectCount + 1) + " /Root 1 0 R >>\n"); + write(output, "startxref\n" + xrefOffset + "\n%%EOF\n"); + return output.toByteArray(); + } + + private String pagesObject() { + StringBuilder kids = new StringBuilder(); + for (int index = 0; index < pages.size(); index++) { + kids.append(5 + index * 2).append(" 0 R "); + } + return "<< /Type /Pages /Kids [ " + kids + "] /Count " + pages.size() + " >>"; + } + + private static String pageObject(PdfPage page, int contentObjectNumber) { + return "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + + number(page.width()) + ' ' + number(page.height()) + + "] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents " + + contentObjectNumber + " 0 R >>"; + } + + private static byte[] pageContent(PdfPage page) { + ByteArrayOutputStream content = new ByteArrayOutputStream(); + for (PdfPage.TextOperation operation : page.operations()) { + write(content, "BT\n"); + write(content, (operation.bold() ? "/F2 " : "/F1 ") + number(operation.size()) + " Tf\n"); + write(content, number(operation.color().red()) + ' ' + + number(operation.color().green()) + ' ' + + number(operation.color().blue()) + " rg\n"); + write(content, "1 0 0 1 " + number(operation.x()) + ' ' + number(operation.y()) + " Tm\n("); + content.writeBytes(escapeText(operation.text())); + write(content, ") Tj\nET\n"); + } + return content.toByteArray(); + } + + private static byte[] escapeText(String text) { + byte[] encoded = text.getBytes(PDF_TEXT_ENCODING); + ByteArrayOutputStream escaped = new ByteArrayOutputStream(encoded.length); + for (byte value : encoded) { + int unsigned = Byte.toUnsignedInt(value); + if (unsigned == '(' || unsigned == ')' || unsigned == '\\') { + escaped.write('\\'); + escaped.write(unsigned); + } else if (unsigned == '\r') { + escaped.writeBytes(ascii("\\r")); + } else if (unsigned == '\n') { + escaped.writeBytes(ascii("\\n")); + } else { + escaped.write(unsigned); + } + } + return escaped.toByteArray(); + } + + private static byte[] streamObject(byte[] stream) { + ByteArrayOutputStream object = new ByteArrayOutputStream(); + write(object, "<< /Length " + stream.length + " >>\nstream\n"); + object.writeBytes(stream); + write(object, "endstream"); + return object.toByteArray(); + } + + private static String number(float value) { + if (value == Math.rint(value)) { + return Long.toString((long) value); + } + String text = String.format(Locale.ROOT, "%.4f", value); + return text.replaceFirst("0+$", "").replaceFirst("\\.$", ""); + } + + private static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.ISO_8859_1); + } + + private static void write(ByteArrayOutputStream output, String value) { + output.writeBytes(ascii(value)); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PdfPage.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PdfPage.java new file mode 100644 index 00000000..20c119b3 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/PdfPage.java @@ -0,0 +1,43 @@ +package io.github.minisoftware.minipdf; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class PdfPage { + private final float width; + private final float height; + private final List operations = new ArrayList<>(); + + PdfPage(float width, float height) { + this.width = width; + this.height = height; + } + + public float width() { + return width; + } + + public float height() { + return height; + } + + public void addText(String text, float x, float y, float size, PdfColor color, boolean bold) { + Objects.requireNonNull(text, "text"); + Objects.requireNonNull(color, "color"); + if (!Float.isFinite(x) || !Float.isFinite(y)) { + throw new IllegalArgumentException("text coordinates must be finite"); + } + if (!Float.isFinite(size) || size <= 0.0f) { + throw new IllegalArgumentException("text size must be a positive finite value"); + } + operations.add(new TextOperation(text, x, y, size, color, bold)); + } + + List operations() { + return List.copyOf(operations); + } + + record TextOperation(String text, float x, float y, float size, PdfColor color, boolean bold) { + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/RegisteredFont.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/RegisteredFont.java new file mode 100644 index 00000000..9407f561 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/RegisteredFont.java @@ -0,0 +1,38 @@ +package io.github.minisoftware.minipdf; + +import java.util.Arrays; +import java.util.Objects; + +public final class RegisteredFont { + private final String name; + private final byte[] data; + + RegisteredFont(String name, byte[] data) { + this.name = Objects.requireNonNull(name, "name"); + this.data = Objects.requireNonNull(data, "data").clone(); + } + + public String name() { + return name; + } + + public byte[] data() { + return data.clone(); + } + + @Override + public boolean equals(Object value) { + if (this == value) { + return true; + } + if (!(value instanceof RegisteredFont other)) { + return false; + } + return name.equals(other.name) && Arrays.equals(data, other.data); + } + + @Override + public int hashCode() { + return 31 * name.hashCode() + Arrays.hashCode(data); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/OfficePackageDetector.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/OfficePackageDetector.java new file mode 100644 index 00000000..6b4d3678 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/OfficePackageDetector.java @@ -0,0 +1,28 @@ +package io.github.minisoftware.minipdf.internal; + +import io.github.minisoftware.minipdf.MiniPdfException; +import io.github.minisoftware.minipdf.OfficeFormat; + +import java.util.Locale; + +public final class OfficePackageDetector { + private OfficePackageDetector() { + } + + public static OfficeFormat detect(byte[] input) throws MiniPdfException { + OoxmlPackage archive = OoxmlPackage.open(input); + for (String entryName : archive.entryNames()) { + String name = entryName.toLowerCase(Locale.ROOT); + if (name.startsWith("word/")) { + return OfficeFormat.DOCX; + } + if (name.startsWith("xl/")) { + return OfficeFormat.XLSX; + } + if (name.startsWith("ppt/")) { + return OfficeFormat.PPTX; + } + } + return OfficeFormat.UNKNOWN; + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/OoxmlPackage.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/OoxmlPackage.java new file mode 100644 index 00000000..83490a7b --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/OoxmlPackage.java @@ -0,0 +1,120 @@ +package io.github.minisoftware.minipdf.internal; + +import io.github.minisoftware.minipdf.MiniPdfException; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipInputStream; + +public final class OoxmlPackage { + private static final int MAX_ENTRIES = 10_000; + private static final long MAX_ENTRY_SIZE = 128L * 1024L * 1024L; + private static final long MAX_TOTAL_SIZE = 512L * 1024L * 1024L; + private static final long MAX_EXPANSION_RATIO = 200L; + + private final Map entries; + + private OoxmlPackage(Map entries) { + this.entries = Collections.unmodifiableMap(entries); + } + + public static OoxmlPackage open(byte[] input) throws MiniPdfException { + if (!hasZipSignature(input)) { + throw invalidPackage("input is not a ZIP package"); + } + + Map entries = new LinkedHashMap<>(); + long totalSize = 0; + try (ZipInputStream archive = new ZipInputStream(new ByteArrayInputStream(input))) { + ZipEntry entry; + while ((entry = archive.getNextEntry()) != null) { + if (entries.size() >= MAX_ENTRIES) { + throw invalidPackage("ZIP package contains too many entries"); + } + String name = normalizeEntryName(entry.getName()); + if (entry.isDirectory()) { + continue; + } + if (entries.containsKey(name)) { + throw invalidPackage("ZIP package contains duplicate entry: " + name); + } + + ByteArrayOutputStream content = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = archive.read(buffer)) != -1) { + if ((long) content.size() + read > MAX_ENTRY_SIZE) { + throw invalidPackage("ZIP entry expands beyond the configured limit: " + name); + } + content.write(buffer, 0, read); + } + byte[] bytes = content.toByteArray(); + totalSize += bytes.length; + if (totalSize > MAX_TOTAL_SIZE + || (input.length > 0 && totalSize / input.length > MAX_EXPANSION_RATIO)) { + throw invalidPackage("ZIP package expands beyond the configured limit"); + } + entries.put(name, bytes); + } + } catch (ZipException exception) { + throw new MiniPdfException( + MiniPdfException.Kind.ZIP_PACKAGE, + "invalid ZIP package: " + exception.getMessage(), + exception); + } catch (IOException exception) { + throw new MiniPdfException( + MiniPdfException.Kind.IO, + "I/O error while reading ZIP package: " + exception.getMessage(), + exception); + } + return new OoxmlPackage(entries); + } + + public Set entryNames() { + return entries.keySet(); + } + + public Optional entry(String name) { + byte[] bytes = entries.get(name); + return bytes == null ? Optional.empty() : Optional.of(bytes.clone()); + } + + public Optional text(String name) { + return entry(name).map(bytes -> new String(bytes, StandardCharsets.UTF_8)); + } + + private static String normalizeEntryName(String entryName) throws MiniPdfException { + String normalized = entryName.replace('\\', '/'); + if (normalized.startsWith("/") || normalized.contains(":") || normalized.indexOf('\0') >= 0) { + throw invalidPackage("ZIP package contains an unsafe entry path: " + entryName); + } + for (String segment : normalized.split("/")) { + if (segment.equals("..")) { + throw invalidPackage("ZIP package contains an unsafe entry path: " + entryName); + } + } + return normalized; + } + + private static boolean hasZipSignature(byte[] input) { + if (input == null || input.length < 4 || input[0] != 'P' || input[1] != 'K') { + return false; + } + return (input[2] == 3 && input[3] == 4) + || (input[2] == 5 && input[3] == 6) + || (input[2] == 7 && input[3] == 8); + } + + private static MiniPdfException invalidPackage(String message) { + return new MiniPdfException(MiniPdfException.Kind.ZIP_PACKAGE, message); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/SecureXml.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/SecureXml.java new file mode 100644 index 00000000..2c563d87 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/SecureXml.java @@ -0,0 +1,31 @@ +package io.github.minisoftware.minipdf.internal; + +import io.github.minisoftware.minipdf.MiniPdfException; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import java.io.ByteArrayInputStream; + +public final class SecureXml { + private SecureXml() { + } + + public static XMLStreamReader reader(byte[] xml) throws MiniPdfException { + XMLInputFactory factory = XMLInputFactory.newFactory(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + try { + return factory.createXMLStreamReader(new ByteArrayInputStream(xml)); + } catch (XMLStreamException exception) { + throw parseError(exception); + } + } + + public static MiniPdfException parseError(XMLStreamException exception) { + return new MiniPdfException( + MiniPdfException.Kind.XML_PARSE, + "XML parse error: " + exception.getMessage(), + exception); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/SimplePdfTextRenderer.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/SimplePdfTextRenderer.java new file mode 100644 index 00000000..7b8d4340 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/SimplePdfTextRenderer.java @@ -0,0 +1,57 @@ +package io.github.minisoftware.minipdf.internal; + +import io.github.minisoftware.minipdf.ConversionOptions; +import io.github.minisoftware.minipdf.PageSize; +import io.github.minisoftware.minipdf.PdfColor; +import io.github.minisoftware.minipdf.PdfDocument; +import io.github.minisoftware.minipdf.PdfPage; + +import java.util.ArrayList; +import java.util.List; + +public final class SimplePdfTextRenderer { + private static final float MARGIN = 54.0f; + private static final float FONT_SIZE = 11.0f; + private static final float LINE_HEIGHT = 15.0f; + + private SimplePdfTextRenderer() { + } + + public static byte[] render(List sourceLines, ConversionOptions options) { + PageSize size = options.pageSize().orElse(PageSize.A4); + PdfDocument document = new PdfDocument(); + PdfPage page = document.addPage(size.width(), size.height()); + float y = size.height() - MARGIN; + int maxCharacters = Math.max(1, (int) ((size.width() - MARGIN * 2.0f) / (FONT_SIZE * 0.52f))); + + for (String sourceLine : sourceLines) { + for (String line : wrap(sourceLine, maxCharacters)) { + if (y < MARGIN) { + page = document.addPage(size.width(), size.height()); + y = size.height() - MARGIN; + } + page.addText(line, MARGIN, y, FONT_SIZE, PdfColor.BLACK, false); + y -= LINE_HEIGHT; + } + } + return document.toBytes(); + } + + private static List wrap(String value, int maxCharacters) { + if (value.isEmpty()) { + return List.of(""); + } + List lines = new ArrayList<>(); + String remaining = value; + while (remaining.length() > maxCharacters) { + int split = remaining.lastIndexOf(' ', maxCharacters); + if (split <= 0) { + split = maxCharacters; + } + lines.add(remaining.substring(0, split)); + remaining = remaining.substring(split).stripLeading(); + } + lines.add(remaining); + return lines; + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/DocxConverter.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/DocxConverter.java new file mode 100644 index 00000000..6aab3860 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/DocxConverter.java @@ -0,0 +1,58 @@ +package io.github.minisoftware.minipdf.internal.docx; + +import io.github.minisoftware.minipdf.ConversionOptions; +import io.github.minisoftware.minipdf.MiniPdfException; +import io.github.minisoftware.minipdf.internal.OoxmlPackage; +import io.github.minisoftware.minipdf.internal.SecureXml; +import io.github.minisoftware.minipdf.internal.SimplePdfTextRenderer; + +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import java.util.ArrayList; +import java.util.List; + +public final class DocxConverter { + private DocxConverter() { + } + + public static byte[] convert(byte[] input, ConversionOptions options) throws MiniPdfException { + OoxmlPackage document = OoxmlPackage.open(input); + byte[] documentXml = document.entry("word/document.xml") + .orElseThrow(() -> new MiniPdfException( + MiniPdfException.Kind.INVALID_INPUT, + "DOCX package does not contain word/document.xml")); + return SimplePdfTextRenderer.render(readParagraphs(documentXml), options); + } + + private static List readParagraphs(byte[] documentXml) throws MiniPdfException { + List paragraphs = new ArrayList<>(); + StringBuilder paragraph = null; + try { + XMLStreamReader reader = SecureXml.reader(documentXml); + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT && reader.getLocalName().equals("p")) { + paragraph = new StringBuilder(); + } else if (event == XMLStreamConstants.START_ELEMENT + && reader.getLocalName().equals("t") && paragraph != null) { + paragraph.append(reader.getElementText()); + } else if (event == XMLStreamConstants.START_ELEMENT + && reader.getLocalName().equals("tab") && paragraph != null) { + paragraph.append('\t'); + } else if (event == XMLStreamConstants.START_ELEMENT + && reader.getLocalName().equals("br") && paragraph != null) { + paragraph.append(' '); + } else if (event == XMLStreamConstants.END_ELEMENT + && reader.getLocalName().equals("p") && paragraph != null) { + paragraphs.add(paragraph.toString()); + paragraph = null; + } + } + reader.close(); + return paragraphs; + } catch (XMLStreamException exception) { + throw SecureXml.parseError(exception); + } + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/xlsx/XlsxConverter.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/xlsx/XlsxConverter.java new file mode 100644 index 00000000..0d96a0c2 --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/xlsx/XlsxConverter.java @@ -0,0 +1,118 @@ +package io.github.minisoftware.minipdf.internal.xlsx; + +import io.github.minisoftware.minipdf.ConversionOptions; +import io.github.minisoftware.minipdf.MiniPdfException; +import io.github.minisoftware.minipdf.internal.OoxmlPackage; +import io.github.minisoftware.minipdf.internal.SecureXml; +import io.github.minisoftware.minipdf.internal.SimplePdfTextRenderer; + +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public final class XlsxConverter { + private XlsxConverter() { + } + + public static byte[] convert(byte[] input, ConversionOptions options) throws MiniPdfException { + OoxmlPackage workbook = OoxmlPackage.open(input); + byte[] sharedStringsXml = workbook.entry("xl/sharedStrings.xml").orElse(null); + List sharedStrings = sharedStringsXml == null + ? List.of() + : readSharedStrings(sharedStringsXml); + List worksheetNames = workbook.entryNames().stream() + .filter(name -> name.startsWith("xl/worksheets/") && name.endsWith(".xml")) + .sorted(Comparator.naturalOrder()) + .toList(); + if (worksheetNames.isEmpty()) { + throw new MiniPdfException( + MiniPdfException.Kind.INVALID_INPUT, + "XLSX package does not contain a worksheet"); + } + + List lines = new ArrayList<>(); + for (String worksheetName : worksheetNames) { + byte[] worksheet = workbook.entry(worksheetName).orElseThrow(); + lines.addAll(readWorksheet(worksheet, sharedStrings)); + } + return SimplePdfTextRenderer.render(lines, options); + } + + private static List readSharedStrings(byte[] xml) throws MiniPdfException { + List strings = new ArrayList<>(); + StringBuilder value = null; + try { + XMLStreamReader reader = SecureXml.reader(xml); + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT && reader.getLocalName().equals("si")) { + value = new StringBuilder(); + } else if (event == XMLStreamConstants.START_ELEMENT + && reader.getLocalName().equals("t") && value != null) { + value.append(reader.getElementText()); + } else if (event == XMLStreamConstants.END_ELEMENT + && reader.getLocalName().equals("si") && value != null) { + strings.add(value.toString()); + value = null; + } + } + reader.close(); + return strings; + } catch (XMLStreamException exception) { + throw SecureXml.parseError(exception); + } + } + + private static List readWorksheet(byte[] xml, List sharedStrings) throws MiniPdfException { + List lines = new ArrayList<>(); + List row = null; + String cellType = null; + String cellValue = null; + try { + XMLStreamReader reader = SecureXml.reader(xml); + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT && reader.getLocalName().equals("row")) { + row = new ArrayList<>(); + } else if (event == XMLStreamConstants.START_ELEMENT && reader.getLocalName().equals("c")) { + cellType = reader.getAttributeValue(null, "t"); + cellValue = ""; + } else if (event == XMLStreamConstants.START_ELEMENT + && (reader.getLocalName().equals("v") || reader.getLocalName().equals("t")) + && row != null) { + cellValue = reader.getElementText(); + } else if (event == XMLStreamConstants.END_ELEMENT + && reader.getLocalName().equals("c") && row != null) { + row.add(resolveCellValue(cellType, cellValue, sharedStrings)); + } else if (event == XMLStreamConstants.END_ELEMENT + && reader.getLocalName().equals("row") && row != null) { + lines.add(String.join(" ", row)); + row = null; + } + } + reader.close(); + return lines; + } catch (XMLStreamException exception) { + throw SecureXml.parseError(exception); + } + } + + private static String resolveCellValue(String type, String value, List sharedStrings) { + if ("s".equals(type)) { + try { + int index = Integer.parseInt(value); + return index >= 0 && index < sharedStrings.size() ? sharedStrings.get(index) : value; + } catch (NumberFormatException ignored) { + return value; + } + } + if ("b".equals(type)) { + return "1".equals(value) ? "TRUE" : "FALSE"; + } + return value == null ? "" : value; + } + +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/BasicOfficeConversionTest.java b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/BasicOfficeConversionTest.java new file mode 100644 index 00000000..ab1e7f68 --- /dev/null +++ b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/BasicOfficeConversionTest.java @@ -0,0 +1,88 @@ +package io.github.minisoftware.minipdf; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BasicOfficeConversionTest { + @Test + void convertsBasicDocxText() throws Exception { + byte[] docx = packageWith(Map.of( + "word/document.xml", + "Hello DOCX" + + "")); + + String pdf = pdfText(MiniPdf.convertBytesToPdf(docx)); + + assertTrue(pdf.startsWith("%PDF-1.4")); + assertTrue(pdf.contains("(Hello DOCX) Tj")); + } + + @Test + void convertsBasicXlsxSharedStringsAndNumbers() throws Exception { + Map entries = new LinkedHashMap<>(); + entries.put("xl/sharedStrings.xml", + "NameAlice"); + entries.put("xl/worksheets/sheet1.xml", + "030" + + "1"); + + String pdf = pdfText(MiniPdf.convertBytesToPdf(packageWith(entries))); + + assertTrue(pdf.startsWith("%PDF-1.4")); + assertTrue(pdf.contains("(Name 30) Tj")); + assertTrue(pdf.contains("(Alice) Tj")); + } + + @Test + void honorsPageSizeOverride() throws Exception { + byte[] docx = packageWith(Map.of( + "word/document.xml", + "Size" + + "")); + + byte[] pdf = MiniPdf.convertBytesToPdf( + docx, + ConversionOptions.withPageSize(PageSize.of(400.0f, 500.0f))); + + assertTrue(pdfText(pdf).contains("/MediaBox [0 0 400 500]")); + } + + @Test + void classifiesMalformedSharedStringsAsXmlError() throws Exception { + Map entries = new LinkedHashMap<>(); + entries.put("xl/sharedStrings.xml", "broken"); + entries.put("xl/worksheets/sheet1.xml", ""); + + MiniPdfException exception = assertThrows( + MiniPdfException.class, + () -> MiniPdf.convertBytesToPdf(packageWith(entries))); + + assertSame(MiniPdfException.Kind.XML_PARSE, exception.kind()); + } + + private static byte[] packageWith(Map entries) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream archive = new ZipOutputStream(bytes)) { + for (Map.Entry entry : entries.entrySet()) { + archive.putNextEntry(new ZipEntry(entry.getKey())); + archive.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + archive.closeEntry(); + } + } + return bytes.toByteArray(); + } + + private static String pdfText(byte[] pdf) { + return new String(pdf, StandardCharsets.ISO_8859_1); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/ClassicFixtureSmokeTest.java b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/ClassicFixtureSmokeTest.java new file mode 100644 index 00000000..ddcf7041 --- /dev/null +++ b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/ClassicFixtureSmokeTest.java @@ -0,0 +1,42 @@ +package io.github.minisoftware.minipdf; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ClassicFixtureSmokeTest { + private static final Path REPOSITORY_ROOT = Path.of("..", "..").toAbsolutePath().normalize(); + + @Test + void convertsClassic01Xlsx() throws Exception { + Path fixture = REPOSITORY_ROOT.resolve( + "tests/MiniPdf.Scripts/output/classic01_basic_table_with_headers.xlsx"); + + String pdf = pdfText(MiniPdf.convertToPdfBytes(fixture)); + + assertTrue(pdf.contains("(Name Age City) Tj")); + assertTrue(pdf.contains("(Alice 30 New York) Tj")); + assertTrue(pdf.endsWith("%%EOF\n")); + } + + @Test + void convertsClassic01Docx() throws Exception { + Path fixture = REPOSITORY_ROOT.resolve( + "tests/MiniPdf.Scripts/output_docx/docx_classic01_single_paragraph.docx"); + + String pdf = pdfText(MiniPdf.convertToPdfBytes(fixture)); + + assertTrue(pdf.contains("Hello, World!")); + assertTrue(pdf.contains("benchmarking")); + assertTrue(pdf.contains("MiniPdf DOCX-to-PDF conversion.")); + assertTrue(pdf.endsWith("%%EOF\n")); + } + + private static String pdfText(byte[] pdf) { + return new String(pdf, StandardCharsets.ISO_8859_1); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/FontRegistrationTest.java b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/FontRegistrationTest.java new file mode 100644 index 00000000..a3bb700c --- /dev/null +++ b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/FontRegistrationTest.java @@ -0,0 +1,33 @@ +package io.github.minisoftware.minipdf; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class FontRegistrationTest { + @Test + void registrationDefensivelyCopiesFontData() { + byte[] data = {1, 2, 3}; + String name = "test-" + UUID.randomUUID(); + + MiniPdf.registerFont(name, data); + data[0] = 9; + + RegisteredFont registered = MiniPdf.registeredFonts().stream() + .filter(font -> font.name().equals(name)) + .findFirst() + .orElseThrow(); + byte[] returned = registered.data(); + returned[1] = 9; + + assertArrayEquals(new byte[]{1, 2, 3}, registered.data()); + } + + @Test + void rejectsBlankFontNames() { + assertThrows(IllegalArgumentException.class, () -> MiniPdf.registerFont(" ", new byte[]{1})); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/OfficeFormatDetectionTest.java b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/OfficeFormatDetectionTest.java new file mode 100644 index 00000000..6dc1ac72 --- /dev/null +++ b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/OfficeFormatDetectionTest.java @@ -0,0 +1,61 @@ +package io.github.minisoftware.minipdf; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OfficeFormatDetectionTest { + @Test + void detectsOfficeFormatsFromPackageEntries() throws Exception { + assertSame(OfficeFormat.DOCX, MiniPdf.detectOfficeFormat(packageWith("word/document.xml"))); + assertSame(OfficeFormat.XLSX, MiniPdf.detectOfficeFormat(packageWith("xl/workbook.xml"))); + assertSame(OfficeFormat.PPTX, MiniPdf.detectOfficeFormat(packageWith("ppt/presentation.xml"))); + } + + @Test + void returnsUnknownForOtherZipPackages() throws Exception { + assertSame(OfficeFormat.UNKNOWN, MiniPdf.detectOfficeFormat(packageWith("custom/data.xml"))); + } + + @Test + void normalizesBackslashesAndCase() throws Exception { + assertSame(OfficeFormat.DOCX, MiniPdf.detectOfficeFormat(packageWith("WORD\\document.xml"))); + } + + @Test + void rejectsNonZipInput() { + MiniPdfException exception = assertThrows( + MiniPdfException.class, + () -> MiniPdf.detectOfficeFormat("not a zip".getBytes())); + + assertSame(MiniPdfException.Kind.ZIP_PACKAGE, exception.kind()); + assertEquals("input is not a ZIP package", exception.getMessage()); + } + + @Test + void unsupportedPptxReportsTheFormatBoundary() throws Exception { + MiniPdfException exception = assertThrows( + MiniPdfException.class, + () -> MiniPdf.convertBytesToPdf(packageWith("ppt/presentation.xml"))); + + assertSame(MiniPdfException.Kind.UNSUPPORTED_FORMAT, exception.kind()); + assertEquals("unsupported or unknown Office document format", exception.getMessage()); + } + + private static byte[] packageWith(String entryName) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream archive = new ZipOutputStream(bytes)) { + archive.putNextEntry(new ZipEntry(entryName)); + archive.write("".getBytes()); + archive.closeEntry(); + } + return bytes.toByteArray(); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/OoxmlSecurityTest.java b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/OoxmlSecurityTest.java new file mode 100644 index 00000000..f35b2342 --- /dev/null +++ b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/OoxmlSecurityTest.java @@ -0,0 +1,51 @@ +package io.github.minisoftware.minipdf; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OoxmlSecurityTest { + @Test + void rejectsTraversalEntryNames() throws Exception { + byte[] archive = packageWith(Map.of("../word/document.xml", "")); + + MiniPdfException exception = assertThrows( + MiniPdfException.class, + () -> MiniPdf.detectOfficeFormat(archive)); + + assertSame(MiniPdfException.Kind.ZIP_PACKAGE, exception.kind()); + } + + @Test + void rejectsExternalEntities() throws Exception { + String document = "]>" + + "&xxe;" + + ""; + byte[] docx = packageWith(Map.of("word/document.xml", document)); + + MiniPdfException exception = assertThrows( + MiniPdfException.class, + () -> MiniPdf.convertBytesToPdf(docx)); + + assertSame(MiniPdfException.Kind.XML_PARSE, exception.kind()); + } + + private static byte[] packageWith(Map entries) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream archive = new ZipOutputStream(bytes)) { + for (Map.Entry entry : entries.entrySet()) { + archive.putNextEntry(new ZipEntry(entry.getKey())); + archive.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + archive.closeEntry(); + } + } + return bytes.toByteArray(); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/PageSizeTest.java b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/PageSizeTest.java new file mode 100644 index 00000000..a5276320 --- /dev/null +++ b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/PageSizeTest.java @@ -0,0 +1,35 @@ +package io.github.minisoftware.minipdf; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PageSizeTest { + @Test + void exposesStandardPageSizes() { + assertEquals(595.28f, PageSize.A4.width()); + assertEquals(841.89f, PageSize.A4.height()); + assertEquals(612.0f, PageSize.LETTER.width()); + assertEquals(792.0f, PageSize.LETTER.height()); + } + + @Test + void rejectsInvalidCustomDimensions() { + for (float invalid : new float[]{0.0f, -1.0f, Float.NaN, Float.POSITIVE_INFINITY}) { + MiniPdfException exception = assertThrows( + MiniPdfException.class, + () -> PageSize.of(invalid, 100.0f)); + assertSame(MiniPdfException.Kind.INVALID_INPUT, exception.kind()); + } + } + + @Test + void optionsExposeAnOptionalOverride() throws MiniPdfException { + assertTrue(ConversionOptions.defaults().pageSize().isEmpty()); + PageSize custom = PageSize.of(400.0f, 500.0f); + assertEquals(custom, ConversionOptions.withPageSize(custom).pageSize().orElseThrow()); + } +} \ No newline at end of file diff --git a/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/PdfDocumentTest.java b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/PdfDocumentTest.java new file mode 100644 index 00000000..9c834dcb --- /dev/null +++ b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/PdfDocumentTest.java @@ -0,0 +1,76 @@ +package io.github.minisoftware.minipdf; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PdfDocumentTest { + @Test + void writesBasicPdfDocument() { + PdfDocument document = new PdfDocument(); + PdfPage page = document.addPage(PageSize.A4.width(), PageSize.A4.height()); + page.addText("Hello from Java MiniPdf", 72.0f, 760.0f, 14.0f, PdfColor.BLACK, false); + + byte[] pdf = document.toBytes(); + String text = new String(pdf, StandardCharsets.ISO_8859_1); + + assertTrue(text.startsWith("%PDF-1.4\n")); + assertTrue(text.contains("(Hello from Java MiniPdf) Tj")); + assertTrue(text.endsWith("%%EOF\n")); + } + + @Test + void declaresStreamLengthsExactly() { + PdfDocument document = new PdfDocument(); + document.addPage(612.0f, 792.0f) + .addText("Hello", 72.0f, 700.0f, 12.0f, PdfColor.BLACK, true); + byte[] pdf = document.toBytes(); + String text = new String(pdf, StandardCharsets.ISO_8859_1); + Matcher matcher = Pattern.compile("/Length (\\d+) >>\\nstream\\n").matcher(text); + + int streams = 0; + while (matcher.find()) { + int streamStart = matcher.end(); + int streamEnd = text.indexOf("endstream", streamStart); + assertEquals(Integer.parseInt(matcher.group(1)), streamEnd - streamStart); + streams++; + } + assertTrue(streams > 0); + } + + @Test + void escapesPdfLiteralText() { + PdfDocument document = new PdfDocument(); + document.addPage(100.0f, 100.0f) + .addText("a(b)\\c", 10.0f, 10.0f, 10.0f, PdfColor.BLACK, false); + + String pdf = new String(document.toBytes(), StandardCharsets.ISO_8859_1); + + assertTrue(pdf.contains("(a\\(b\\)\\\\c) Tj")); + } + + @Test + void writesXrefAtDeclaredOffset() { + PdfDocument document = new PdfDocument(); + document.addPage(100.0f, 100.0f); + byte[] pdf = document.toBytes(); + String text = new String(pdf, StandardCharsets.ISO_8859_1); + Matcher matcher = Pattern.compile("startxref\\n(\\d+)\\n%%EOF").matcher(text); + + assertTrue(matcher.find()); + assertTrue(text.startsWith("xref\n", Integer.parseInt(matcher.group(1)))); + } + + @Test + void rejectsInvalidGeometryAndColor() { + PdfDocument document = new PdfDocument(); + assertThrows(IllegalArgumentException.class, () -> document.addPage(0.0f, 100.0f)); + assertThrows(IllegalArgumentException.class, () -> new PdfColor(1.1f, 0.0f, 0.0f)); + } +} \ No newline at end of file diff --git a/minipdf-java/pom.xml b/minipdf-java/pom.xml new file mode 100644 index 00000000..b6703b22 --- /dev/null +++ b/minipdf-java/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + io.github.minisoftware + minipdf-java-parent + 0.1.0-SNAPSHOT + pom + + MiniPdf for Java + Native Java Office-to-PDF conversion. + https://github.com/mini-software/MiniPdf + + + minipdf + minipdf-cli + + + + UTF-8 + 17 + 5.13.4 + 4.7.7 + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + info.picocli + picocli + ${picocli.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + false + + + + + + \ No newline at end of file