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