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