diff --git a/minipdf-java/README.md b/minipdf-java/README.md new file mode 100644 index 00000000..d2891656 --- /dev/null +++ b/minipdf-java/README.md @@ -0,0 +1,70 @@ +# MiniPdf for Java + +MiniPdf for Java is a Java 17 library and command-line tool for converting +DOCX, XLSX, and PPTX files to PDF without Microsoft Office or LibreOffice. + +## Install + +The library is available from Maven Central: + +```xml + + io.github.shps951023 + minipdf + 0.1.1 + +``` + +## Library + +```java +import io.github.minisoftware.minipdf.MiniPdf; + +import java.nio.file.Path; + +MiniPdf.convertToPdf( + Path.of("document.docx"), + Path.of("document.pdf")); +``` + +Use `MiniPdf.convertBytesToPdf` when the Office document is already in memory. +`ConversionOptions.withPageSize` overrides the output page size. + +## CLI + +Download the executable JAR with Maven: + +```powershell +mvn dependency:copy ` + -Dartifact=io.github.shps951023:minipdf-cli:0.1.1 ` + -DoutputDirectory=. +``` + +Convert a document: + +```powershell +java -jar minipdf-cli-0.1.1.jar input.pptx -o output.pdf +``` + +The CLI accepts `.docx`, `.xlsx`, and `.pptx` files. Run it with `--help` for +page-size and font-directory options. + +## Current Scope + +The Java implementation currently provides text-first Office conversion: + +- DOCX paragraphs, tabs, and line breaks +- XLSX shared strings, inline strings, numbers, and booleans +- PPTX slide text, native slide dimensions, and one PDF page per slide +- bounded OOXML ZIP loading and XML external-entity protection + +Advanced Office layout, images, charts, and embedded fonts are not yet fully +reproduced by the Java renderer. + +## Build and Test + +From this directory: + +```powershell +mvn -B -ntp verify +``` \ No newline at end of file diff --git a/minipdf-java/minipdf-cli/pom.xml b/minipdf-java/minipdf-cli/pom.xml index c72d7221..22050e46 100644 --- a/minipdf-java/minipdf-cli/pom.xml +++ b/minipdf-java/minipdf-cli/pom.xml @@ -7,7 +7,7 @@ io.github.shps951023 minipdf-java-parent - 0.1.0 + 0.1.1 minipdf-cli 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 index 931938d8..609e275e 100644 --- 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 @@ -20,8 +20,8 @@ @Command( name = "minipdf", - version = "minipdf-java 0.1.0", - description = "Convert XLSX and DOCX files to PDF with the Java MiniPdf engine.", + version = "minipdf-java 0.1.1", + description = "Convert XLSX, DOCX, and PPTX files to PDF with the Java MiniPdf engine.", mixinStandardHelpOptions = true, subcommands = MiniPdfCommand.ConvertCommand.class) public final class MiniPdfCommand implements Callable { @@ -131,10 +131,10 @@ private static int convert(Path input, ConversionArguments arguments, CommandLin 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")) { + if (!extension.equals("xlsx") && !extension.equals("docx") && !extension.equals("pptx")) { throw new CommandLine.ParameterException( commandLine, - "unsupported file type '." + extension + "'. Supported: .xlsx, .docx"); + "unsupported file type '." + extension + "'. Supported: .xlsx, .docx, .pptx"); } if (arguments.fonts != null) { 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 index 3cb26100..07787162 100644 --- 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 @@ -47,6 +47,18 @@ void convertsDocxWithSubcommandAndCustomSize() throws Exception { .contains("/MediaBox [0 0 400 500]")); } + @Test + void convertsPptxWithDirectSyntax() throws Exception { + Path source = REPOSITORY_ROOT.resolve("tests/Issue_Files/pptx/Asian Pacific.pptx"); + Path output = temporaryDirectory.resolve("slides.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 rejectsConflictingPageOptions() { Path source = REPOSITORY_ROOT.resolve( diff --git a/minipdf-java/minipdf/pom.xml b/minipdf-java/minipdf/pom.xml index a8ea27cd..cc233b70 100644 --- a/minipdf-java/minipdf/pom.xml +++ b/minipdf-java/minipdf/pom.xml @@ -7,7 +7,7 @@ io.github.shps951023 minipdf-java-parent - 0.1.0 + 0.1.1 minipdf 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 index 214943b5..52f60fa3 100644 --- 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 @@ -2,6 +2,7 @@ import io.github.minisoftware.minipdf.internal.OfficePackageDetector; import io.github.minisoftware.minipdf.internal.docx.DocxConverter; +import io.github.minisoftware.minipdf.internal.pptx.PptxConverter; import io.github.minisoftware.minipdf.internal.xlsx.XlsxConverter; import java.io.IOException; @@ -59,7 +60,8 @@ public static byte[] convertBytesToPdf(byte[] input, ConversionOptions options) return switch (format) { case DOCX -> DocxConverter.convert(input, options); case XLSX -> XlsxConverter.convert(input, options); - case PPTX, UNKNOWN -> throw new MiniPdfException( + case PPTX -> PptxConverter.convert(input, options); + case UNKNOWN -> throw new MiniPdfException( MiniPdfException.Kind.UNSUPPORTED_FORMAT, "unsupported or unknown Office document format"); }; 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 index 7b8d4340..94b63d21 100644 --- 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 @@ -18,20 +18,29 @@ private SimplePdfTextRenderer() { } public static byte[] render(List sourceLines, ConversionOptions options) { - PageSize size = options.pageSize().orElse(PageSize.A4); + return renderPages(List.of(sourceLines), options, PageSize.A4); + } + + public static byte[] renderPages( + List> sourcePages, + ConversionOptions options, + PageSize defaultPageSize) { + PageSize size = options.pageSize().orElse(defaultPageSize); 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; + for (List sourcePage : sourcePages) { + PdfPage page = document.addPage(size.width(), size.height()); + float y = size.height() - MARGIN; + for (String sourceLine : sourcePage) { + 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; } - page.addText(line, MARGIN, y, FONT_SIZE, PdfColor.BLACK, false); - y -= LINE_HEIGHT; } } return document.toBytes(); diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/pptx/PptxConverter.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/pptx/PptxConverter.java new file mode 100644 index 00000000..8965d09e --- /dev/null +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/pptx/PptxConverter.java @@ -0,0 +1,245 @@ +package io.github.minisoftware.minipdf.internal.pptx; + +import io.github.minisoftware.minipdf.ConversionOptions; +import io.github.minisoftware.minipdf.MiniPdfException; +import io.github.minisoftware.minipdf.PageSize; +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.ArrayDeque; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class PptxConverter { + private static final float EMUS_PER_POINT = 12_700.0f; + private static final float DEFAULT_SLIDE_WIDTH = 720.0f; + private static final float DEFAULT_SLIDE_HEIGHT = 540.0f; + + private PptxConverter() { + } + + public static byte[] convert(byte[] input, ConversionOptions options) throws MiniPdfException { + OoxmlPackage presentation = OoxmlPackage.open(input); + byte[] presentationXml = presentation.entry("ppt/presentation.xml") + .orElseThrow(() -> new MiniPdfException( + MiniPdfException.Kind.INVALID_INPUT, + "PPTX package does not contain ppt/presentation.xml")); + PageSize slideSize = readSlideSize(presentationXml); + List slideNames = readSlideNames(presentation, presentationXml); + if (slideNames.isEmpty()) { + throw new MiniPdfException( + MiniPdfException.Kind.INVALID_INPUT, + "PPTX package does not contain a slide"); + } + + List> slides = new ArrayList<>(); + for (String slideName : slideNames) { + byte[] slideXml = presentation.entry(slideName) + .orElseThrow(() -> new MiniPdfException( + MiniPdfException.Kind.INVALID_INPUT, + "PPTX slide part is missing: " + slideName)); + slides.add(readSlide(slideXml)); + } + return SimplePdfTextRenderer.renderPages(slides, options, slideSize); + } + + private static PageSize readSlideSize(byte[] xml) throws MiniPdfException { + XMLStreamReader reader = null; + try { + reader = SecureXml.reader(xml); + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT && reader.getLocalName().equals("sldSz")) { + float width = emusToPoints(reader.getAttributeValue(null, "cx"), DEFAULT_SLIDE_WIDTH); + float height = emusToPoints(reader.getAttributeValue(null, "cy"), DEFAULT_SLIDE_HEIGHT); + return PageSize.of(width, height); + } + } + return PageSize.of(DEFAULT_SLIDE_WIDTH, DEFAULT_SLIDE_HEIGHT); + } catch (XMLStreamException exception) { + throw SecureXml.parseError(exception); + } finally { + closeReader(reader); + } + } + + private static List readSlide(byte[] xml) throws MiniPdfException { + List paragraphs = new ArrayList<>(); + StringBuilder paragraph = null; + XMLStreamReader reader = null; + try { + reader = SecureXml.reader(xml); + 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("br") && paragraph != null) { + paragraph.append(' '); + } else if (event == XMLStreamConstants.END_ELEMENT + && reader.getLocalName().equals("p") && paragraph != null) { + paragraphs.add(paragraph.toString()); + paragraph = null; + } + } + return paragraphs; + } catch (XMLStreamException exception) { + throw SecureXml.parseError(exception); + } finally { + closeReader(reader); + } + } + + private static List readSlideNames(OoxmlPackage presentation, byte[] presentationXml) + throws MiniPdfException { + List relationshipIds = readSlideRelationshipIds(presentationXml); + if (relationshipIds.isEmpty()) { + return List.of(); + } + byte[] relationshipsXml = presentation.entry("ppt/_rels/presentation.xml.rels") + .orElseThrow(() -> new MiniPdfException( + MiniPdfException.Kind.INVALID_INPUT, + "PPTX package does not contain presentation relationships")); + Map relationships = readSlideRelationships(relationshipsXml); + List slideNames = new ArrayList<>(); + for (String relationshipId : relationshipIds) { + String target = relationships.get(relationshipId); + if (target == null) { + throw new MiniPdfException( + MiniPdfException.Kind.INVALID_INPUT, + "PPTX slide relationship is missing: " + relationshipId); + } + slideNames.add(resolvePartName("ppt/presentation.xml", target)); + } + return slideNames; + } + + private static List readSlideRelationshipIds(byte[] xml) throws MiniPdfException { + List relationshipIds = new ArrayList<>(); + XMLStreamReader reader = null; + try { + reader = SecureXml.reader(xml); + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT && reader.getLocalName().equals("sldId")) { + String relationshipId = namespacedAttribute(reader, "id"); + if (relationshipId != null) { + relationshipIds.add(relationshipId); + } + } + } + return relationshipIds; + } catch (XMLStreamException exception) { + throw SecureXml.parseError(exception); + } finally { + closeReader(reader); + } + } + + private static Map readSlideRelationships(byte[] xml) throws MiniPdfException { + Map relationships = new LinkedHashMap<>(); + XMLStreamReader reader = null; + try { + reader = SecureXml.reader(xml); + while (reader.hasNext()) { + int event = reader.next(); + if (event != XMLStreamConstants.START_ELEMENT + || !reader.getLocalName().equals("Relationship")) { + continue; + } + String type = attribute(reader, "Type"); + String targetMode = attribute(reader, "TargetMode"); + if (type == null || !type.endsWith("/slide") + || "external".equalsIgnoreCase(targetMode)) { + continue; + } + String relationshipId = attribute(reader, "Id"); + String target = attribute(reader, "Target"); + if (relationshipId != null && target != null) { + relationships.put(relationshipId, target); + } + } + return relationships; + } catch (XMLStreamException exception) { + throw SecureXml.parseError(exception); + } finally { + closeReader(reader); + } + } + + private static float emusToPoints(String value, float defaultValue) { + if (value == null) { + return defaultValue; + } + try { + long emus = Long.parseLong(value); + return emus > 0 ? emus / EMUS_PER_POINT : defaultValue; + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + + private static String attribute(XMLStreamReader reader, String localName) { + for (int index = 0; index < reader.getAttributeCount(); index++) { + if (reader.getAttributeLocalName(index).equals(localName)) { + return reader.getAttributeValue(index); + } + } + return null; + } + + private static String namespacedAttribute(XMLStreamReader reader, String localName) { + for (int index = 0; index < reader.getAttributeCount(); index++) { + String namespace = reader.getAttributeNamespace(index); + if (namespace != null && !namespace.isEmpty() + && reader.getAttributeLocalName(index).equals(localName)) { + return reader.getAttributeValue(index); + } + } + return null; + } + + private static String resolvePartName(String sourcePart, String target) throws MiniPdfException { + String normalizedTarget = target.replace('\\', '/'); + String sourceDirectory = sourcePart.substring(0, sourcePart.lastIndexOf('/') + 1); + String combined = normalizedTarget.startsWith("/") + ? normalizedTarget.substring(1) + : sourceDirectory + normalizedTarget; + ArrayDeque segments = new ArrayDeque<>(); + for (String segment : combined.split("/")) { + if (segment.isEmpty() || segment.equals(".")) { + continue; + } + if (segment.equals("..")) { + if (segments.isEmpty()) { + throw new MiniPdfException( + MiniPdfException.Kind.INVALID_INPUT, + "PPTX relationship target escapes the package: " + target); + } + segments.removeLast(); + } else { + segments.addLast(segment); + } + } + return String.join("/", segments); + } + + private static void closeReader(XMLStreamReader reader) { + if (reader == null) { + return; + } + try { + reader.close(); + } catch (XMLStreamException ignored) { + } + } +} \ 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 index ab1e7f68..5cbea46e 100644 --- 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 @@ -1,9 +1,12 @@ package io.github.minisoftware.minipdf; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.Map; import java.util.zip.ZipEntry; @@ -14,6 +17,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; class BasicOfficeConversionTest { + @TempDir + Path temporaryDirectory; + @Test void convertsBasicDocxText() throws Exception { byte[] docx = packageWith(Map.of( @@ -57,6 +63,67 @@ void honorsPageSizeOverride() throws Exception { assertTrue(pdfText(pdf).contains("/MediaBox [0 0 400 500]")); } + @Test + void convertsPptxSlidesToSeparatePages() throws Exception { + Map entries = new LinkedHashMap<>(); + entries.put("ppt/presentation.xml", + "" + + "" + + ""); + entries.put("ppt/_rels/presentation.xml.rels", + ""); + entries.put("ppt/slides/slide1.xml", + "Second" + + ""); + entries.put("ppt/slides/slide2.xml", + "First" + + ""); + entries.put("ppt/slides/slide10.xml", + "Orphan" + + ""); + + String pdf = pdfText(MiniPdf.convertBytesToPdf(packageWith(entries))); + + assertTrue(pdf.startsWith("%PDF-1.4")); + assertTrue(pdf.contains("/MediaBox [0 0 720 540]")); + assertTrue(pdf.contains("/Count 2")); + assertTrue(pdf.indexOf("(First) Tj") < pdf.indexOf("(Second) Tj")); + assertTrue(!pdf.contains("(Orphan) Tj")); + } + + @Test + void rejectsPptxWithoutSlides() throws Exception { + byte[] pptx = packageWith(Map.of( + "ppt/presentation.xml", + "")); + + MiniPdfException exception = assertThrows( + MiniPdfException.class, + () -> MiniPdf.convertBytesToPdf(pptx)); + + assertSame(MiniPdfException.Kind.INVALID_INPUT, exception.kind()); + } + + @Test + void classifiesMalformedPptxSlideAsXmlError() throws Exception { + Map entries = new LinkedHashMap<>(); + entries.put("ppt/presentation.xml", + "" + + ""); + entries.put("ppt/_rels/presentation.xml.rels", + ""); + entries.put("ppt/slides/slide1.xml", ""); + + MiniPdfException exception = assertThrows( + MiniPdfException.class, + () -> MiniPdf.convertBytesToPdf(packageWith(entries))); + + assertSame(MiniPdfException.Kind.XML_PARSE, exception.kind()); + } + @Test void classifiesMalformedSharedStringsAsXmlError() throws Exception { Map entries = new LinkedHashMap<>(); @@ -70,6 +137,32 @@ void classifiesMalformedSharedStringsAsXmlError() throws Exception { assertSame(MiniPdfException.Kind.XML_PARSE, exception.kind()); } + @Test + void convertsPathToRequestedOutput() throws Exception { + Path input = temporaryDirectory.resolve("input.docx"); + Path output = temporaryDirectory.resolve("output.pdf"); + Files.write(input, packageWith(Map.of( + "word/document.xml", + "Path API" + + ""))); + + MiniPdf.convertToPdf(input, output); + + assertTrue(Files.isRegularFile(output)); + assertTrue(Files.readString(output, StandardCharsets.ISO_8859_1).contains("(Path API) Tj")); + } + + @Test + void classifiesMissingInputAsIoError() { + Path missing = temporaryDirectory.resolve("missing.docx"); + + MiniPdfException exception = assertThrows( + MiniPdfException.class, + () -> MiniPdf.convertToPdfBytes(missing)); + + assertSame(MiniPdfException.Kind.IO, exception.kind()); + } + private static byte[] packageWith(Map entries) throws Exception { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (ZipOutputStream archive = new ZipOutputStream(bytes)) { 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 index ddcf7041..dc6a695b 100644 --- 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 @@ -36,6 +36,17 @@ void convertsClassic01Docx() throws Exception { assertTrue(pdf.endsWith("%%EOF\n")); } + @Test + void convertsIssuePptx() throws Exception { + Path fixture = REPOSITORY_ROOT.resolve("tests/Issue_Files/pptx/Asian Pacific.pptx"); + + String pdf = pdfText(MiniPdf.convertToPdfBytes(fixture)); + + assertTrue(pdf.startsWith("%PDF-1.4")); + assertTrue(pdf.contains("/Type /Pages")); + assertTrue(pdf.endsWith("%%EOF\n")); + } + private static String pdfText(byte[] pdf) { return new String(pdf, StandardCharsets.ISO_8859_1); } 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 index 6dc1ac72..4bbfd4d9 100644 --- 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 @@ -40,13 +40,13 @@ void rejectsNonZipInput() { } @Test - void unsupportedPptxReportsTheFormatBoundary() throws Exception { + void pptxWithoutSlidesReportsInvalidInput() 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()); + assertSame(MiniPdfException.Kind.INVALID_INPUT, exception.kind()); + assertEquals("PPTX package does not contain a slide", exception.getMessage()); } private static byte[] packageWith(String entryName) throws IOException { diff --git a/minipdf-java/pom.xml b/minipdf-java/pom.xml index 0e937c10..36b5484c 100644 --- a/minipdf-java/pom.xml +++ b/minipdf-java/pom.xml @@ -6,7 +6,7 @@ io.github.shps951023 minipdf-java-parent - 0.1.0 + 0.1.1 pom MiniPdf for Java diff --git a/minipdf-python/README.md b/minipdf-python/README.md index 7b939559..ffed5901 100644 --- a/minipdf-python/README.md +++ b/minipdf-python/README.md @@ -1,11 +1,10 @@ # MiniPdf for Python -Experimental pure Python implementation of MiniPdf. The initial release -converts basic DOCX paragraphs to PDF 1.4 without requiring Microsoft Office, +Experimental pure Python implementation of MiniPdf. It converts basic DOCX, +XLSX, and PPTX content to PDF 1.4 without requiring Microsoft Office, LibreOffice, .NET, or Rust at runtime. -The package requires Python 3.10 or later. XLSX support is planned; PPTX is not -currently supported. +The package requires Python 3.10 or later. ## Install @@ -26,6 +25,8 @@ pdf_bytes = minipdf.convert_to_pdf_bytes("report.docx") ```bash minipdf report.docx +minipdf workbook.xlsx -o workbook.pdf +minipdf slides.pptx -o slides.pdf minipdf convert report.docx -o report.pdf minipdf report.docx --paper-size a4 minipdf report.docx --page-width 400 --page-height 500 @@ -36,4 +37,6 @@ minipdf report.docx --page-width 400 --page-height 500 Version 0.1 supports document page geometry, margins, paragraphs, explicit page breaks, and basic bold, italic, and font-size run formatting. Text currently uses PDF built-in Latin fonts. Tables, images, embedded fonts, CJK/RTL shaping, -headers, footers, lists, XLSX, and PPTX are not yet supported. +headers, footers, and lists are not yet supported. XLSX rendering currently +extracts common cell value types into text rows, while PPTX rendering extracts +paragraph text and preserves slide page dimensions and boundaries. diff --git a/minipdf-python/src/minipdf/api.py b/minipdf-python/src/minipdf/api.py index 464973ee..c1c76488 100644 --- a/minipdf-python/src/minipdf/api.py +++ b/minipdf-python/src/minipdf/api.py @@ -8,6 +8,8 @@ from .errors import UnsupportedFormatError from .office import OfficeFormat, detect_office_format from .options import ConversionOptions +from .pptx import convert_pptx +from .xlsx import convert_xlsx PathLike = str | os.PathLike[str] _font_lock = RLock() @@ -33,9 +35,9 @@ def convert_bytes_to_pdf( if document_format is OfficeFormat.DOCX: return convert_docx(source, options or ConversionOptions()) if document_format is OfficeFormat.XLSX: - raise UnsupportedFormatError("XLSX conversion is planned but not yet supported") + return convert_xlsx(source, options or ConversionOptions()) if document_format is OfficeFormat.PPTX: - raise UnsupportedFormatError("PPTX conversion is not supported") + return convert_pptx(source, options or ConversionOptions()) raise UnsupportedFormatError("unsupported or unknown Office document format") diff --git a/minipdf-python/src/minipdf/office.py b/minipdf-python/src/minipdf/office.py index 6b3349de..976e00c7 100644 --- a/minipdf-python/src/minipdf/office.py +++ b/minipdf-python/src/minipdf/office.py @@ -1,8 +1,12 @@ from __future__ import annotations import io +import posixpath +import xml.etree.ElementTree as ET import zipfile +from dataclasses import dataclass from enum import Enum +from urllib.parse import unquote, urlsplit from .errors import PackageError @@ -17,6 +21,12 @@ class OfficeFormat(Enum): PPTX = "pptx" +@dataclass(frozen=True, slots=True) +class OfficeRelationship: + relationship_type: str + target: str + + class OfficePackage: def __init__(self, data: bytes) -> None: try: @@ -24,14 +34,21 @@ def __init__(self, data: bytes) -> None: except (OSError, zipfile.BadZipFile) as error: raise PackageError("input is not a valid Office ZIP package") from error self._validate() + self._entries = { + entry.filename.replace("\\", "/"): entry for entry in self._archive.infolist() + } def _validate(self) -> None: total_size = 0 + normalized_names: set[str] = set() for entry in self._archive.infolist(): normalized = entry.filename.replace("\\", "/") parts = normalized.split("/") if normalized.startswith("/") or ".." in parts: raise PackageError(f"unsafe package path: {entry.filename}") + if normalized in normalized_names: + raise PackageError(f"duplicate package path: {entry.filename}") + normalized_names.add(normalized) if entry.flag_bits & 0x1: raise PackageError("encrypted Office packages are not supported") if entry.file_size > MAX_ENTRY_SIZE: @@ -42,8 +59,7 @@ def _validate(self) -> None: @property def format(self) -> OfficeFormat: - names = (entry.filename.replace("\\", "/") for entry in self._archive.infolist()) - roots = {name.split("/", 1)[0] for name in names if "/" in name} + roots = {name.split("/", 1)[0] for name in self._entries if "/" in name} if "word" in roots: return OfficeFormat.DOCX if "xl" in roots: @@ -52,14 +68,53 @@ def format(self) -> OfficeFormat: return OfficeFormat.PPTX return OfficeFormat.UNKNOWN + @property + def names(self) -> tuple[str, ...]: + return tuple(self._entries) + def read(self, name: str) -> bytes | None: + entry = self._entries.get(name.replace("\\", "/")) + if entry is None: + return None try: - return self._archive.read(name) + return self._archive.read(entry) except KeyError: return None except (OSError, RuntimeError, zipfile.BadZipFile) as error: raise PackageError(f"cannot read package entry: {name}") from error + def relationships(self, source_name: str) -> dict[str, OfficeRelationship]: + directory, filename = posixpath.split(source_name) + relationships_name = posixpath.join(directory, "_rels", f"{filename}.rels") + data = self.read(relationships_name) + if data is None: + return {} + try: + root = ET.fromstring(data) + except ET.ParseError as error: + raise PackageError(f"{relationships_name} is malformed") from error + + relationships: dict[str, OfficeRelationship] = {} + for node in root.iter(): + if node.tag.rsplit("}", 1)[-1] != "Relationship": + continue + relationship_id = node.get("Id") + relationship_type = node.get("Type") + target = node.get("Target") + if not relationship_id or not relationship_type or not target: + continue + if node.get("TargetMode", "").lower() == "external": + continue + target_path = unquote(urlsplit(target.replace("\\", "/")).path) + if target_path.startswith("/"): + resolved = posixpath.normpath(target_path.lstrip("/")) + else: + resolved = posixpath.normpath(posixpath.join(directory, target_path)) + if resolved == ".." or resolved.startswith("../"): + raise PackageError(f"unsafe relationship target: {target}") + relationships[relationship_id] = OfficeRelationship(relationship_type, resolved) + return relationships + def detect_office_format(data: bytes) -> OfficeFormat: return OfficePackage(data).format diff --git a/minipdf-python/src/minipdf/pptx.py b/minipdf-python/src/minipdf/pptx.py new file mode 100644 index 00000000..edb27237 --- /dev/null +++ b/minipdf-python/src/minipdf/pptx.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + +from .errors import PackageError +from .office import OfficePackage +from .options import ConversionOptions, PageSize +from .pdf import PdfDocument, TextStyle + +EMU_PER_POINT = 12700.0 +DEFAULT_SLIDE_SIZE = PageSize(720.0, 540.0) +MARGIN = 36.0 + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _parse_xml(data: bytes, name: str) -> ET.Element: + try: + return ET.fromstring(data) + except ET.ParseError as error: + raise PackageError(f"{name} is malformed") from error + + +def _slide_size(root: ET.Element) -> PageSize: + size = next((node for node in root.iter() if _local_name(node.tag) == "sldSz"), None) + if size is None: + return DEFAULT_SLIDE_SIZE + try: + return PageSize( + float(size.get("cx", "")) / EMU_PER_POINT, + float(size.get("cy", "")) / EMU_PER_POINT, + ) + except ValueError: + return DEFAULT_SLIDE_SIZE + + +def _paragraph_text(paragraph: ET.Element) -> str: + chunks: list[str] = [] + for node in paragraph.iter(): + name = _local_name(node.tag) + if name == "t": + chunks.append(node.text or "") + elif name == "br": + chunks.append("\n") + return "".join(chunks) + + +def _relationship_id(node: ET.Element) -> str | None: + return next( + ( + value + for name, value in node.attrib.items() + if name.startswith("{") and _local_name(name) == "id" + ), + None, + ) + + +def convert_pptx(data: bytes, options: ConversionOptions) -> bytes: + package = OfficePackage(data) + presentation_name = "ppt/presentation.xml" + presentation_data = package.read(presentation_name) + if presentation_data is None: + raise PackageError("PPTX package is missing ppt/presentation.xml") + presentation = _parse_xml(presentation_data, presentation_name) + relationships = package.relationships(presentation_name) + slide_names: list[str] = [] + slide_list = next( + (node for node in presentation if _local_name(node.tag) == "sldIdLst"), + None, + ) + if slide_list is not None: + for node in slide_list: + if _local_name(node.tag) != "sldId": + continue + relationship_id = _relationship_id(node) + relationship = relationships.get(relationship_id or "") + if relationship is None or not relationship.relationship_type.endswith("/slide"): + raise PackageError("PPTX slide relationship is missing or invalid") + slide_names.append(relationship.target) + if not slide_names: + raise PackageError("PPTX package does not contain any slides") + + page_size = options.page_size or _slide_size(presentation) + pdf = PdfDocument() + style = TextStyle(size=18.0) + for slide_name in slide_names: + slide_data = package.read(slide_name) + if slide_data is None: + raise PackageError(f"PPTX slide part is missing: {slide_name}") + root = _parse_xml(slide_data, slide_name) + page = pdf.add_page(page_size.width, page_size.height) + cursor_y = page_size.height - MARGIN + for paragraph in (node for node in root.iter() if _local_name(node.tag) == "p"): + text = _paragraph_text(paragraph) + if text: + if cursor_y - 24.0 < MARGIN: + break + cursor_y -= 24.0 + page.add_text(text, MARGIN, cursor_y, style) + return pdf.to_bytes() diff --git a/minipdf-python/src/minipdf/xlsx.py b/minipdf-python/src/minipdf/xlsx.py new file mode 100644 index 00000000..fe5e7b0f --- /dev/null +++ b/minipdf-python/src/minipdf/xlsx.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET + +from .errors import PackageError +from .office import OfficePackage +from .options import ConversionOptions, PageSize +from .pdf import PdfDocument, TextStyle + +MARGIN = 36.0 +ROW_HEIGHT = 16.0 + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _parse_xml(data: bytes, name: str) -> ET.Element: + try: + return ET.fromstring(data) + except ET.ParseError as error: + raise PackageError(f"{name} is malformed") from error + + +def _text_content(node: ET.Element) -> str: + return "".join(child.text or "" for child in node.iter() if _local_name(child.tag) == "t") + + +def _shared_strings(package: OfficePackage, part_name: str | None) -> tuple[str, ...]: + data = package.read(part_name) if part_name else None + if data is None: + return () + root = _parse_xml(data, part_name or "shared strings") + return tuple(_text_content(node) for node in root.iter() if _local_name(node.tag) == "si") + + +def _cell_text(cell: ET.Element, shared_strings: tuple[str, ...]) -> str: + cell_type = cell.get("t") + if cell_type == "inlineStr": + return _text_content(cell) + value = next((node.text or "" for node in cell if _local_name(node.tag) == "v"), "") + if cell_type == "s": + try: + return shared_strings[int(value)] + except (ValueError, IndexError): + return value + if cell_type == "b": + return "TRUE" if value == "1" else "FALSE" + return value + + +def _relationship_id(node: ET.Element) -> str | None: + return next( + ( + value + for name, value in node.attrib.items() + if name.startswith("{") and _local_name(name) == "id" + ), + None, + ) + + +def convert_xlsx(data: bytes, options: ConversionOptions) -> bytes: + package = OfficePackage(data) + workbook_name = "xl/workbook.xml" + workbook_data = package.read(workbook_name) + if workbook_data is None: + raise PackageError("XLSX package is missing xl/workbook.xml") + workbook = _parse_xml(workbook_data, workbook_name) + relationships = package.relationships(workbook_name) + sheet_names: list[str] = [] + sheets = next((node for node in workbook if _local_name(node.tag) == "sheets"), None) + if sheets is not None: + for node in sheets: + if _local_name(node.tag) != "sheet": + continue + relationship_id = _relationship_id(node) + relationship = relationships.get(relationship_id or "") + if relationship is None or not relationship.relationship_type.endswith("/worksheet"): + raise PackageError("XLSX worksheet relationship is missing or invalid") + sheet_names.append(relationship.target) + if not sheet_names: + raise PackageError("XLSX package does not contain any worksheets") + + shared_strings_name = next( + ( + relationship.target + for relationship in relationships.values() + if relationship.relationship_type.endswith("/sharedStrings") + ), + None, + ) + shared_strings = _shared_strings(package, shared_strings_name) + page_size = options.page_size or PageSize.A4 + pdf = PdfDocument() + style = TextStyle(size=10.0) + for sheet_name in sheet_names: + sheet_data = package.read(sheet_name) + if sheet_data is None: + raise PackageError(f"XLSX worksheet part is missing: {sheet_name}") + root = _parse_xml(sheet_data, sheet_name) + page = pdf.add_page(page_size.width, page_size.height) + cursor_y = page_size.height - MARGIN + for row in (node for node in root.iter() if _local_name(node.tag) == "row"): + values = [ + _cell_text(cell, shared_strings) for cell in row if _local_name(cell.tag) == "c" + ] + if cursor_y - ROW_HEIGHT < MARGIN: + page = pdf.add_page(page_size.width, page_size.height) + cursor_y = page_size.height - MARGIN + cursor_y -= ROW_HEIGHT + page.add_text(" | ".join(values), MARGIN, cursor_y, style) + return pdf.to_bytes() diff --git a/minipdf-python/tests/helpers.py b/minipdf-python/tests/helpers.py index 4e0b72de..c0d0a69d 100644 --- a/minipdf-python/tests/helpers.py +++ b/minipdf-python/tests/helpers.py @@ -31,3 +31,71 @@ def create_docx(*, page_break: bool = False) -> bytes: archive.writestr("[Content_Types].xml", content_types) archive.writestr("word/document.xml", document) return output.getvalue() + + +def create_xlsx() -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "xl/workbook.xml", + '' + "", + ) + archive.writestr( + "xl/_rels/workbook.xml.rels", + '', + ) + archive.writestr("xl/strings/custom.xml", "Hello XLSX") + archive.writestr( + "xl/worksheets/sheet1.xml", + "Second Sheet", + ) + archive.writestr( + "xl/worksheets/sheet2.xml", + '0' + 'Cell B', + ) + archive.writestr( + "xl/worksheets/sheet3.xml", + "Orphan Sheet", + ) + return output.getvalue() + + +def create_pptx(*, extra_paragraphs: int = 0) -> bytes: + overflow_content = "".join( + f"Extra {index}" for index in range(extra_paragraphs) + ) + output = io.BytesIO() + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "ppt/presentation.xml", + '' + '' + '', + ) + archive.writestr( + "ppt/_rels/presentation.xml.rels", + '', + ) + archive.writestr( + "ppt/slides/slide1.xml", + 'Second Slide' + "", + ) + archive.writestr( + "ppt/slides/slide2.xml", + 'Hello PPTX' + f"{overflow_content}", + ) + archive.writestr( + "ppt/slides/slide3.xml", + 'Orphan Slide' + "", + ) + return output.getvalue() diff --git a/minipdf-python/tests/test_api.py b/minipdf-python/tests/test_api.py index 6c6d6d17..d994057f 100644 --- a/minipdf-python/tests/test_api.py +++ b/minipdf-python/tests/test_api.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from helpers import create_docx +from helpers import create_docx, create_pptx, create_xlsx from minipdf import ( OfficeFormat, @@ -16,6 +16,7 @@ convert_to_pdf_bytes, detect_office_format, ) +from minipdf.office import OfficePackage def test_path_and_bytes_apis_produce_identical_output(tmp_path: Path) -> None: @@ -33,6 +34,44 @@ def test_detects_docx_package() -> None: assert detect_office_format(create_docx()) is OfficeFormat.DOCX +def test_converts_xlsx() -> None: + pdf = convert_bytes_to_pdf(create_xlsx()) + + assert pdf.startswith(b"%PDF-1.4") + assert b"Hello XLSX" in pdf + assert b"Cell B" in pdf + assert pdf.index(b"Hello XLSX") < pdf.index(b"Second Sheet") + assert b"Orphan Sheet" not in pdf + + +def test_converts_pptx() -> None: + pdf = convert_bytes_to_pdf(create_pptx()) + + assert pdf.startswith(b"%PDF-1.4") + assert b"Hello PPTX" in pdf + assert pdf.index(b"Hello PPTX") < pdf.index(b"Second Slide") + assert b"Orphan Slide" not in pdf + assert b"/MediaBox [0 0 720 540]" in pdf + + +def test_pptx_text_stays_inside_slide_page() -> None: + pdf = convert_bytes_to_pdf(create_pptx(extra_paragraphs=30)) + + assert b" 36 -" not in pdf + assert b"/Count 2" in pdf + + +def test_reads_normalized_package_entry_names() -> None: + package_bytes = io.BytesIO() + with zipfile.ZipFile(package_bytes, "w") as archive: + archive.writestr("word\\document.xml", "") + + package = OfficePackage(package_bytes.getvalue()) + + assert package.names == ("word/document.xml",) + assert package.read("word/document.xml") == b"" + + def test_rejects_unknown_office_package() -> None: package = io.BytesIO() with zipfile.ZipFile(package, "w") as archive: diff --git a/scripts/Invoke-LanguageVisualBenchmark.ps1 b/scripts/Invoke-LanguageVisualBenchmark.ps1 new file mode 100644 index 00000000..5bfdbec4 --- /dev/null +++ b/scripts/Invoke-LanguageVisualBenchmark.ps1 @@ -0,0 +1,407 @@ +<# +.SYNOPSIS + Runs one MiniPdf implementation against the shared XLSX, DOCX, and PPTX corpus. + +.DESCRIPTION + Every language reads tests/MiniPdf.Benchmark/shared-office-corpus.json and writes + isolated candidates and reports below artifacts/benchmark/. LibreOffice + reference PDFs are shared by content hash across all language runs. + +.EXAMPLE + .\scripts\Run-Java-VisualBenchmark.ps1 -Format all -MaxCasesPerFormat 1 + .\scripts\Run-Python-VisualBenchmark.ps1 -Format pptx -Filter "Asian Pacific" +#> + +param( + [Parameter(Mandatory = $true)] + [ValidateSet("dotnet", "rust", "java", "go", "python", "node")] + [string]$Language, + [ValidateSet("all", "xlsx", "docx", "pptx")] + [string]$Format = "all", + [string]$Filter, + [int]$MaxCasesPerFormat = 0, + [int]$MaxComparePages = 0, + [double]$MinimumScore = 0.0, + [string]$CorpusManifest = "tests/MiniPdf.Benchmark/shared-office-corpus.json", + [string]$ArtifactRoot, + [switch]$SkipBuild, + [switch]$SkipReference, + [switch]$SkipCompare, + [switch]$ForceReference +) + +$ErrorActionPreference = "Stop" +$RepoRoot = Split-Path -Parent $PSScriptRoot + +if ($MaxCasesPerFormat -lt 0) { throw "MaxCasesPerFormat cannot be negative." } +if ($MaxComparePages -lt 0) { throw "MaxComparePages cannot be negative." } +if ($MinimumScore -lt 0.0 -or $MinimumScore -gt 1.0) { + throw "MinimumScore must be between 0.0 and 1.0." +} + +function Resolve-RepoPath([string]$PathValue) { + if ([System.IO.Path]::IsPathRooted($PathValue)) { return $PathValue } + return Join-Path $RepoRoot $PathValue +} + +function Write-Json([object]$Value, [string]$Path) { + $Json = $Value | ConvertTo-Json -Depth 8 + [System.IO.File]::WriteAllText($Path, $Json, [System.Text.UTF8Encoding]::new($false)) +} + +function Assert-CommandSucceeded([string]$Description) { + if ($LASTEXITCODE -ne 0) { throw "$Description failed with exit code $LASTEXITCODE." } +} + +function Find-Command([string]$Name) { + return (Get-Command $Name -ErrorAction Stop).Source +} + +function Find-Python { + $RelativePath = if ($IsWindows) { ".venv/Scripts/python.exe" } else { ".venv/bin/python" } + $Candidate = Join-Path $RepoRoot $RelativePath + if (Test-Path -LiteralPath $Candidate) { return $Candidate } + return Find-Command "python" +} + +function Find-Maven { + $Command = Get-Command mvn -ErrorAction SilentlyContinue + if ($Command) { return $Command.Source } + foreach ($MavenRoot in @($env:MAVEN_HOME, $env:M2_HOME)) { + if ($MavenRoot) { + $Candidate = Join-Path $MavenRoot $(if ($IsWindows) { "bin/mvn.cmd" } else { "bin/mvn" }) + if (Test-Path -LiteralPath $Candidate) { return $Candidate } + } + } + if ($IsWindows) { + $Candidate = Get-ChildItem (Join-Path $env:LOCALAPPDATA "Programs/Apache Maven") ` + -Recurse -Filter "mvn.cmd" -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName + if ($Candidate) { return $Candidate } + } + throw "Maven was not found. Add mvn to PATH or set MAVEN_HOME." +} + +function Find-JavaExecutable { + $ExecutableName = if ($IsWindows) { "java.exe" } else { "java" } + if ($IsWindows) { + foreach ($JavaRoot in @( + (Join-Path $env:ProgramFiles "Eclipse Adoptium"), + (Join-Path $env:ProgramFiles "Java") + )) { + if (-not (Test-Path -LiteralPath $JavaRoot)) { continue } + $Candidate = Get-ChildItem -LiteralPath $JavaRoot -Directory -Filter "jdk*" | + Sort-Object Name -Descending | + ForEach-Object { Join-Path $_.FullName "bin/java.exe" } | + Where-Object { Test-Path -LiteralPath $_ } | + Select-Object -First 1 + if ($Candidate) { return $Candidate } + } + } + if ($env:JAVA_HOME) { + $Candidate = Join-Path $env:JAVA_HOME "bin/$ExecutableName" + if (Test-Path -LiteralPath $Candidate) { return $Candidate } + } + $Command = Get-Command java -ErrorAction SilentlyContinue + if ($Command) { return $Command.Source } + throw "Java was not found. Install JDK 17 or newer, or set JAVA_HOME." +} + +function Find-Go { + $ExecutableName = if ($IsWindows) { "go.exe" } else { "go" } + if ($env:GOROOT) { + $Candidate = Join-Path $env:GOROOT "bin/$ExecutableName" + if (Test-Path -LiteralPath $Candidate) { return $Candidate } + } + if ($IsWindows) { + foreach ($Candidate in @( + (Join-Path $env:ProgramFiles "Go/bin/go.exe"), + (Join-Path $env:LOCALAPPDATA "Programs/Go/bin/go.exe") + )) { + if (Test-Path -LiteralPath $Candidate) { return $Candidate } + } + } + $Command = Get-Command go -ErrorAction SilentlyContinue + if ($Command) { return $Command.Source } + throw "Go was not found. Install Go 1.22 or newer, or set GOROOT." +} + +function Test-Pdf([string]$Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } + $Bytes = [System.IO.File]::ReadAllBytes($Path) + return $Bytes.Length -ge 5 -and [System.Text.Encoding]::ASCII.GetString($Bytes, 0, 5) -eq "%PDF-" +} + +function Get-LibreOfficePath { + $Command = Get-Command soffice -ErrorAction SilentlyContinue + if ($Command) { + $ConsoleLauncher = [System.IO.Path]::ChangeExtension($Command.Source, ".com") + if ($IsWindows -and (Test-Path -LiteralPath $ConsoleLauncher)) { return $ConsoleLauncher } + return $Command.Source + } + $Candidates = @( + (Join-Path $env:ProgramFiles "LibreOffice/program/soffice.com"), + (Join-Path ${env:ProgramFiles(x86)} "LibreOffice/program/soffice.com"), + (Join-Path $env:ProgramFiles "LibreOffice/program/soffice.exe"), + (Join-Path ${env:ProgramFiles(x86)} "LibreOffice/program/soffice.exe") + ) + foreach ($Candidate in $Candidates) { + if ($Candidate -and (Test-Path -LiteralPath $Candidate)) { return $Candidate } + } + throw "LibreOffice soffice was not found. Install LibreOffice or use -SkipReference." +} + +$CorpusManifest = Resolve-RepoPath $CorpusManifest +if (-not (Test-Path -LiteralPath $CorpusManifest)) { + throw "Shared corpus manifest not found: $CorpusManifest" +} +$Corpus = Get-Content -LiteralPath $CorpusManifest -Raw | ConvertFrom-Json +$SelectedCases = [System.Collections.Generic.List[object]]::new() +$CaseSources = @{} + +foreach ($Source in $Corpus.sources) { + if ($Format -ne "all" -and $Source.format -ne $Format) { continue } + $SourceRoot = Resolve-RepoPath $Source.root + if (-not (Test-Path -LiteralPath $SourceRoot -PathType Container)) { + throw "Corpus source directory not found: $SourceRoot" + } + $Files = @(Get-ChildItem -LiteralPath $SourceRoot -File -Filter $Source.pattern | Where-Object { + -not $Filter -or $_.Name -like "*$Filter*" + } | Sort-Object Name) + if ($MaxCasesPerFormat -gt 0) { + $Files = @($Files | Select-Object -First $MaxCasesPerFormat) + } + foreach ($File in $Files) { + $Hash = (Get-FileHash -LiteralPath $File.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $SafeStem = ($File.BaseName -replace '[^A-Za-z0-9._-]', '_').Trim('_') + if (-not $SafeStem) { $SafeStem = "fixture" } + $CaseId = "$($Source.format)--$SafeStem--$($Hash.Substring(0, 24))" + if ($CaseSources.ContainsKey($CaseId)) { throw "Duplicate benchmark case id: $CaseId" } + $RelativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $File.FullName).Replace("\", "/") + $Case = [pscustomobject]@{ + name = $CaseId + case_id = $CaseId + display_name = $File.BaseName + suite = "shared-office" + format = $Source.format + source_path = $RelativePath + source_sha256 = $Hash + conversion_status = "pending" + conversion_exit_code = $null + candidate_exists = $false + reference_exists = $false + } + $SelectedCases.Add($Case) + $CaseSources[$CaseId] = $File.FullName + } +} + +if ($SelectedCases.Count -eq 0) { + throw "No shared corpus cases matched format=$Format filter='$Filter'." +} + +$ArtifactRoot = Resolve-RepoPath $(if ($ArtifactRoot) { $ArtifactRoot } else { "artifacts/benchmark" }) +$SharedRoot = Join-Path $ArtifactRoot "shared" +$LanguageRoot = Join-Path $ArtifactRoot $Language +$CandidateDir = Join-Path $LanguageRoot "candidates" +$ReportDir = Join-Path $LanguageRoot "report" +$ReferenceDir = Join-Path $SharedRoot "libreoffice-reference" +$ResolvedManifest = Join-Path $LanguageRoot "resolved-manifest.json" +$CoverageManifest = Join-Path $LanguageRoot "benchmark-coverage.json" +$ReferenceWorkDir = Join-Path $SharedRoot "reference-work" + +New-Item -ItemType Directory -Force -Path $CandidateDir, $ReportDir, $ReferenceDir, $ReferenceWorkDir | Out-Null +Write-Json ([pscustomobject]@{ + corpus = [System.IO.Path]::GetRelativePath($RepoRoot, $CorpusManifest).Replace("\", "/") + corpus_version = $Corpus.version + cases = $SelectedCases +}) $ResolvedManifest + +$Tools = @{} +if (-not $SkipBuild) { + switch ($Language) { + "dotnet" { + $Tools.dotnet = Find-Command "dotnet" + & $Tools.dotnet build (Join-Path $RepoRoot "src/MiniPdf.Cli/MiniPdf.Cli.csproj") -c Release + Assert-CommandSucceeded ".NET CLI build" + } + "rust" { + $Tools.cargo = Find-Command "cargo" + & $Tools.cargo build --release --manifest-path (Join-Path $RepoRoot "minipdf-rs/Cargo.toml") -p minipdf-cli + Assert-CommandSucceeded "Rust CLI build" + } + "java" { + $Tools.java = Find-JavaExecutable + $env:JAVA_HOME = Split-Path -Parent (Split-Path -Parent $Tools.java) + $Tools.maven = Find-Maven + & $Tools.maven -B -ntp -f (Join-Path $RepoRoot "minipdf-java/pom.xml") package -DskipTests + Assert-CommandSucceeded "Java CLI build" + } + "go" { + $Tools.go = Find-Go + $GoOutput = Join-Path $LanguageRoot $(if ($IsWindows) { "minipdf-go.exe" } else { "minipdf-go" }) + Push-Location (Join-Path $RepoRoot "minipdf-go") + try { & $Tools.go build -o $GoOutput ./cmd/minipdf } finally { Pop-Location } + Assert-CommandSucceeded "Go CLI build" + } + "python" { + $Tools.python = Find-Python + } + "node" { + $Tools.npm = Find-Command "npm" + Push-Location (Join-Path $RepoRoot "minipdf-node") + try { + if (Test-Path "package-lock.json") { & $Tools.npm ci } else { & $Tools.npm install } + Assert-CommandSucceeded "Node dependency install" + & $Tools.npm run build + Assert-CommandSucceeded "Node native module build" + } finally { Pop-Location } + } + } +} + +switch ($Language) { + "dotnet" { + if (-not $Tools.dotnet) { $Tools.dotnet = Find-Command "dotnet" } + $Tools.cli = Get-ChildItem (Join-Path $RepoRoot "src/MiniPdf.Cli/bin/Release") -Recurse -Filter "MiniPdf.Cli.dll" | + Where-Object FullName -Match 'net9\.0' | Select-Object -First 1 -ExpandProperty FullName + } + "rust" { + $RustName = if ($IsWindows) { "minipdf.exe" } else { "minipdf" } + $Tools.cli = Join-Path $RepoRoot "minipdf-rs/target/release/$RustName" + } + "java" { + if (-not $Tools.java) { $Tools.java = Find-JavaExecutable } + $Tools.cli = Get-ChildItem (Join-Path $RepoRoot "minipdf-java/minipdf-cli/target") -Filter "minipdf-cli-*.jar" | + Where-Object { $_.Name -notmatch '(sources|javadoc|original)' } | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName + } + "go" { + $Tools.cli = Join-Path $LanguageRoot $(if ($IsWindows) { "minipdf-go.exe" } else { "minipdf-go" }) + } + "python" { + if (-not $Tools.python) { + $Tools.python = Find-Python + } + $env:PYTHONPATH = Join-Path $RepoRoot "minipdf-python/src" + } + "node" { $Tools.node = Find-Command "node" } +} + +if ($Language -notin @("python", "node") -and (-not $Tools.cli -or -not (Test-Path -LiteralPath $Tools.cli))) { + throw "$Language CLI artifact was not found. Run without -SkipBuild first." +} + +foreach ($Case in $SelectedCases) { + $InputPath = $CaseSources[$Case.case_id] + $OutputPath = Join-Path $CandidateDir ($Case.name + ".pdf") + if (Test-Path -LiteralPath $OutputPath) { Remove-Item -LiteralPath $OutputPath -Force } + switch ($Language) { + "dotnet" { & $Tools.dotnet $Tools.cli $InputPath -o $OutputPath } + "rust" { & $Tools.cli $InputPath -o $OutputPath } + "java" { & $Tools.java -jar $Tools.cli $InputPath -o $OutputPath } + "go" { & $Tools.cli $InputPath -o $OutputPath } + "python" { & $Tools.python -m minipdf $InputPath -o $OutputPath } + "node" { + & $Tools.node -e "require(process.argv[1]).convertToPdf(process.argv[2], process.argv[3])" ` + (Join-Path $RepoRoot "minipdf-node") $InputPath $OutputPath + } + } + $Case.conversion_exit_code = $LASTEXITCODE + $Case.candidate_exists = Test-Pdf $OutputPath + $Case.conversion_status = if ($LASTEXITCODE -eq 0 -and $Case.candidate_exists) { "passed" } else { "failed" } +} + +if (-not $SkipReference) { + $Soffice = Get-LibreOfficePath + foreach ($Case in $SelectedCases) { + $ReferencePath = Join-Path $ReferenceDir ($Case.name + ".pdf") + if ($ForceReference -or -not (Test-Pdf $ReferencePath)) { + Get-ChildItem -LiteralPath $ReferenceWorkDir -File -Filter "*.pdf" | Remove-Item -Force + $ProfileDir = Join-Path $ReferenceWorkDir ("profile-" + [System.Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $ProfileDir | Out-Null + $ProfileUri = ([System.Uri]$ProfileDir).AbsoluteUri + try { + & $Soffice --headless --norestore "-env:UserInstallation=$ProfileUri" ` + --convert-to pdf --outdir $ReferenceWorkDir $CaseSources[$Case.case_id] + Assert-CommandSucceeded "LibreOffice conversion for $($Case.source_path)" + } finally { + Remove-Item -LiteralPath $ProfileDir -Recurse -Force -ErrorAction SilentlyContinue + } + $GeneratedPath = Join-Path $ReferenceWorkDir ([System.IO.Path]::GetFileNameWithoutExtension($CaseSources[$Case.case_id]) + ".pdf") + if (-not (Test-Pdf $GeneratedPath)) { throw "LibreOffice did not produce a valid PDF for $($Case.source_path)." } + Move-Item -LiteralPath $GeneratedPath -Destination $ReferencePath -Force + } + } +} + +foreach ($Case in $SelectedCases) { + $Case.reference_exists = Test-Pdf (Join-Path $ReferenceDir ($Case.name + ".pdf")) +} + +$PassedConversions = @($SelectedCases | Where-Object conversion_status -eq "passed").Count +$MissingReferences = @($SelectedCases | Where-Object reference_exists -eq $false).Count +$Coverage = [pscustomobject]@{ + language = $Language + corpus_manifest = [System.IO.Path]::GetRelativePath($RepoRoot, $CorpusManifest).Replace("\", "/") + resolved_manifest = [System.IO.Path]::GetRelativePath($RepoRoot, $ResolvedManifest).Replace("\", "/") + fixture_scope = "shared-git-tracked-office-corpus" + selected_cases = $SelectedCases.Count + passed_conversions = $PassedConversions + failed_conversions = $SelectedCases.Count - $PassedConversions + missing_references = $MissingReferences + comparison_completed = $false + comparison_results = 0 + average_score = $null + cases = $SelectedCases +} +Write-Json $Coverage $CoverageManifest +Write-Json ([pscustomobject]@{ + corpus = [System.IO.Path]::GetRelativePath($RepoRoot, $CorpusManifest).Replace("\", "/") + corpus_version = $Corpus.version + cases = $SelectedCases +}) $ResolvedManifest + +if (-not $SkipCompare) { + if ($MissingReferences -gt 0) { + throw "$MissingReferences shared reference PDFs are missing. Run without -SkipReference." + } + $Python = if (Test-Path (Join-Path $RepoRoot ".venv/Scripts/python.exe")) { + Join-Path $RepoRoot ".venv/Scripts/python.exe" + } else { Find-Command "python" } + $CompareArgs = @( + (Join-Path $RepoRoot "tests/MiniPdf.Benchmark/compare_pdfs.py"), + "--minipdf-dir", $CandidateDir, + "--reference-dir", $ReferenceDir, + "--report-dir", $ReportDir, + "--manifest", $ResolvedManifest, + "--report-scope", "$Language-shared-office", + "--candidate-label", "$Language MiniPdf", + "--reference-label", "LibreOffice", + "--composite-images", + "--heatmaps" + ) + if ($MaxComparePages -gt 0) { $CompareArgs += @("--max-pages", $MaxComparePages) } + & $Python -X utf8 @CompareArgs + Assert-CommandSucceeded "$Language visual comparison" + $Results = @(Get-Content (Join-Path $ReportDir "comparison_report.json") -Raw | ConvertFrom-Json) + $Scores = @($Results | Where-Object { $null -ne $_.overall_score }) + $Coverage.comparison_completed = $true + $Coverage.comparison_results = ($Results | Measure-Object).Count + $Coverage.average_score = if ($Scores.Count -gt 0) { + ($Scores | Measure-Object -Property overall_score -Average).Average + } else { $null } + Write-Json $Coverage $CoverageManifest + $BelowThreshold = @($Results | Where-Object { $null -eq $_.overall_score -or $_.overall_score -lt $MinimumScore }) + if ($BelowThreshold.Count -gt 0) { + throw "$($BelowThreshold.Count) cases scored below MinimumScore=$MinimumScore." + } +} + +Write-Host "$Language benchmark: selected=$($SelectedCases.Count), converted=$PassedConversions, missing references=$MissingReferences" +Write-Host "Coverage: $CoverageManifest" +if (-not $SkipCompare) { Write-Host "Report: $(Join-Path $ReportDir 'comparison_report.md')" } + +if ($PassedConversions -ne $SelectedCases.Count) { + throw "$Language candidate conversion failed for $($SelectedCases.Count - $PassedConversions) cases." +} \ No newline at end of file diff --git a/scripts/Run-All-Language-VisualBenchmarks.ps1 b/scripts/Run-All-Language-VisualBenchmarks.ps1 new file mode 100644 index 00000000..9830c128 --- /dev/null +++ b/scripts/Run-All-Language-VisualBenchmarks.ps1 @@ -0,0 +1,8 @@ +param() + +$ErrorActionPreference = "Stop" +foreach ($Language in @("dotnet", "rust", "java", "go", "python", "node")) { + Write-Host "`n=== $Language visual benchmark ===" + & (Join-Path $PSScriptRoot "Invoke-LanguageVisualBenchmark.ps1") -Language $Language @args + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} \ No newline at end of file diff --git a/scripts/Run-DotNet-VisualBenchmark.ps1 b/scripts/Run-DotNet-VisualBenchmark.ps1 new file mode 100644 index 00000000..bb78cd33 --- /dev/null +++ b/scripts/Run-DotNet-VisualBenchmark.ps1 @@ -0,0 +1,2 @@ +param() +& (Join-Path $PSScriptRoot "Invoke-LanguageVisualBenchmark.ps1") -Language dotnet @args \ No newline at end of file diff --git a/scripts/Run-Go-VisualBenchmark.ps1 b/scripts/Run-Go-VisualBenchmark.ps1 new file mode 100644 index 00000000..e4e60c55 --- /dev/null +++ b/scripts/Run-Go-VisualBenchmark.ps1 @@ -0,0 +1,2 @@ +param() +& (Join-Path $PSScriptRoot "Invoke-LanguageVisualBenchmark.ps1") -Language go @args \ No newline at end of file diff --git a/scripts/Run-Java-VisualBenchmark.ps1 b/scripts/Run-Java-VisualBenchmark.ps1 new file mode 100644 index 00000000..caea1875 --- /dev/null +++ b/scripts/Run-Java-VisualBenchmark.ps1 @@ -0,0 +1,2 @@ +param() +& (Join-Path $PSScriptRoot "Invoke-LanguageVisualBenchmark.ps1") -Language java @args \ No newline at end of file diff --git a/scripts/Run-Node-VisualBenchmark.ps1 b/scripts/Run-Node-VisualBenchmark.ps1 new file mode 100644 index 00000000..ba0fcd66 --- /dev/null +++ b/scripts/Run-Node-VisualBenchmark.ps1 @@ -0,0 +1,2 @@ +param() +& (Join-Path $PSScriptRoot "Invoke-LanguageVisualBenchmark.ps1") -Language node @args \ No newline at end of file diff --git a/scripts/Run-Python-VisualBenchmark.ps1 b/scripts/Run-Python-VisualBenchmark.ps1 new file mode 100644 index 00000000..4c1559a7 --- /dev/null +++ b/scripts/Run-Python-VisualBenchmark.ps1 @@ -0,0 +1,2 @@ +param() +& (Join-Path $PSScriptRoot "Invoke-LanguageVisualBenchmark.ps1") -Language python @args \ No newline at end of file diff --git a/scripts/Run-Rust-VisualBenchmark.ps1 b/scripts/Run-Rust-VisualBenchmark.ps1 new file mode 100644 index 00000000..bdd5e07c --- /dev/null +++ b/scripts/Run-Rust-VisualBenchmark.ps1 @@ -0,0 +1,2 @@ +param() +& (Join-Path $PSScriptRoot "Invoke-LanguageVisualBenchmark.ps1") -Language rust @args \ No newline at end of file diff --git a/tests/MiniPdf.Benchmark/README.md b/tests/MiniPdf.Benchmark/README.md index e43c1681..26ac8b4f 100644 --- a/tests/MiniPdf.Benchmark/README.md +++ b/tests/MiniPdf.Benchmark/README.md @@ -37,6 +37,34 @@ Automatically compares PDFs generated by MiniPdf against LibreOffice (reference ## Quick Start +### Shared Cross-Language Corpus + +All implementations use the Git-tracked fixture roots declared in +`shared-office-corpus.json`. The runner resolves the same XLSX, DOCX, and PPTX +files for every language, records each source SHA-256, and reuses one set of +LibreOffice reference PDFs. + +Each implementation has an independent entry point and isolated output under +`artifacts/benchmark//`: + +```powershell +.\scripts\Run-DotNet-VisualBenchmark.ps1 -Format all +.\scripts\Run-Rust-VisualBenchmark.ps1 -Format all +.\scripts\Run-Java-VisualBenchmark.ps1 -Format all +.\scripts\Run-Go-VisualBenchmark.ps1 -Format all +.\scripts\Run-Python-VisualBenchmark.ps1 -Format all +.\scripts\Run-Node-VisualBenchmark.ps1 -Format all + +# Run all six implementations against the same selected cases. +.\scripts\Run-All-Language-VisualBenchmarks.ps1 -Format all +``` + +Use `-Filter`, `-MaxCasesPerFormat`, and `-MaxComparePages` for focused runs. +Use `-SkipReference` to reuse shared references and `-SkipBuild` to reuse an +existing language artifact. Each language receives its own resolved manifest, +coverage JSON, candidate PDFs, comparison JSON, Markdown report, images, and +heatmaps. + ### Prerequisites ```bash diff --git a/tests/MiniPdf.Benchmark/compare_pdfs.py b/tests/MiniPdf.Benchmark/compare_pdfs.py index a1159f98..5f4cc6a1 100644 --- a/tests/MiniPdf.Benchmark/compare_pdfs.py +++ b/tests/MiniPdf.Benchmark/compare_pdfs.py @@ -180,6 +180,7 @@ def apply_case_metadata(result: dict, case_metadata: Optional[dict], report_scop "case_id", "format", "source_path", + "source_sha256", "content_language", "display_name", "tags", diff --git a/tests/MiniPdf.Benchmark/shared-office-corpus.json b/tests/MiniPdf.Benchmark/shared-office-corpus.json new file mode 100644 index 00000000..09800f2c --- /dev/null +++ b/tests/MiniPdf.Benchmark/shared-office-corpus.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "description": "Git-tracked Office fixtures shared by every language visual benchmark.", + "sources": [ + { + "format": "xlsx", + "root": "tests/Issue_Files/xlsx", + "pattern": "*.xlsx" + }, + { + "format": "docx", + "root": "tests/Issue_Files/docx", + "pattern": "*.docx" + }, + { + "format": "pptx", + "root": "tests/Issue_Files/pptx", + "pattern": "*.pptx" + } + ] +} \ No newline at end of file