diff --git a/README.md b/README.md index b030267fc..16b63d97c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ The features of OpenPDF include: * Encryption: You can encrypt PDF documents for security purposes. * Page Layout: OpenPDF allows you to set the page size, orientation, and other layout properties. * PDF 2.0 support (ISO 32000-2). +* Brotli stream compression (`/BrotliDecode`) for creating and reading PDF streams compressed with + [Brotli](https://github.com/google/brotli) – see [Brotli compression](#brotli-compression). [![Maven Central](https://img.shields.io/maven-central/v/com.github.librepdf/openpdf.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/com.github.librepdf/openpdf) ![CI](https://github.com/LibrePDF/OpenPDF/actions/workflows/maven.yml/badge.svg) @@ -192,6 +194,11 @@ and use the class `org.librepdf.openpdf.fonts.Liberation`. ``` +### Brotli4j + +Brotli4j is a required dependency for Brotli stream compression support. + + ### Supporting complex glyph substitution/ Ligature substitution OpenPDF supports glyph substitution which is required for correct rendering of fonts ligature substitution requirements. @@ -204,6 +211,38 @@ OpenPDF supports OpenType layout, glyph positioning, reordering and substitution positioning of accents, the rendering of non-Latin and right-to-left scripts. OpenPDF supports DIN 91379. See: [wiki](https://github.com/LibrePDF/OpenPDF/wiki/Accents,-DIN-91379,-non-Latin-scripts) +### Brotli compression + +OpenPDF can read and write PDF streams compressed with +[Brotli](https://github.com/google/brotli) — exposed in the PDF as the +`/BrotliDecode` filter, which is being standardised for PDF 2.0 (ISO 32000-2) +through [ISO/TS 32001](https://www.iso.org/standard/45874.html). The codec is +backed by [brotli4j](https://github.com/hyperxpro/Brotli4j) (a required +dependency, with native binaries shipped for Linux / macOS / Windows on x86_64 +and aarch64). + +Enable Brotli for the page content streams produced by a writer: + +```java +PdfWriter writer = PdfWriter.getInstance(document, out); +writer.setUseBrotliCompression(true); // /BrotliDecode instead of /FlateDecode +``` + +…or globally for every subsequently-created `PdfWriter`: + +```java +Document.useBrotliCompression = true; +``` + +Reading is fully transparent: `PdfReader.getStreamBytes(...)` and +`PdfReader.getPageContent(...)` decode `/BrotliDecode` automatically, so OpenPDF +opens Brotli-compressed PDFs (e.g. those produced by AutoCAD's +`pdfplot11.hdi`) without any extra configuration. The raw codec is available as +`org.openpdf.text.pdf.codec.BrotliFilter` (`encode` / `decode`). + +Default compression remains `/FlateDecode` for compatibility with older +PDF readers that do not yet implement ISO/TS 32001. + ### Optional - [BouncyCastle](https://www.bouncycastle.org/) (BouncyCastle is used to sign PDF files, so it's a recommended diff --git a/openpdf-core/pom.xml b/openpdf-core/pom.xml index 0143fca0d..1f370ca3f 100644 --- a/openpdf-core/pom.xml +++ b/openpdf-core/pom.xml @@ -39,6 +39,11 @@ fop true + + com.aayushatharva.brotli4j + brotli4j + ${brotli4j.version} + diff --git a/openpdf-core/src/main/java/org/openpdf/text/Document.java b/openpdf-core/src/main/java/org/openpdf/text/Document.java index ba658a6d0..7348d635a 100644 --- a/openpdf-core/src/main/java/org/openpdf/text/Document.java +++ b/openpdf-core/src/main/java/org/openpdf/text/Document.java @@ -111,6 +111,14 @@ public class Document implements DocListener { * Allows the pdf documents to be produced without compression for debugging purposes. */ public static boolean compress = true; + /** + * When {@code true}, page content streams written by OpenPDF are compressed with the + * Brotli algorithm and tagged with the {@code /BrotliDecode} filter instead of the + * default {@code /FlateDecode}. Brotli is being standardised as a PDF 2.0 + * (ISO 32000-2) stream filter through ISO/TS 32001. This requires {@link #compress} + * to also be {@code true}. Default is {@code false}. + */ + public static boolean useBrotliCompression = false; /** * When true the file access is not done through a memory mapped file. Use it if the file is too big to be mapped in * your address space. diff --git a/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfContents.java b/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfContents.java index 8911e3ea2..95a359ec2 100644 --- a/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfContents.java +++ b/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfContents.java @@ -52,6 +52,7 @@ import org.openpdf.text.DocWriter; import org.openpdf.text.Document; import org.openpdf.text.Rectangle; +import org.openpdf.text.pdf.codec.BrotliFilter; import java.io.ByteArrayOutputStream; import java.io.OutputStream; import java.util.zip.Deflater; @@ -86,17 +87,26 @@ class PdfContents extends PdfStream { Rectangle page) throws BadPdfFormatException { super(); try { - OutputStream out = null; - Deflater deflater = null; + PdfWriter writer = text.getPdfWriter(); + boolean useBrotli = Document.compress + && writer != null + && writer.isUseBrotliCompression(); streamBytes = new ByteArrayOutputStream(); - if (Document.compress) { + + OutputStream out; + Deflater deflater = null; + if (useBrotli) { + compressed = true; + out = BrotliFilter.encoder(streamBytes); + } else if (Document.compress) { compressed = true; - compressionLevel = text.getPdfWriter().getCompressionLevel(); + compressionLevel = writer.getCompressionLevel(); deflater = new Deflater(compressionLevel); out = new DeflaterOutputStream(streamBytes, deflater); } else { out = streamBytes; } + int rotation = page.getRotation(); switch (rotation) { case 90: @@ -143,12 +153,13 @@ class PdfContents extends PdfStream { if (deflater != null) { deflater.end(); } + + put(PdfName.LENGTH, new PdfNumber(streamBytes.size())); + if (compressed) { + put(PdfName.FILTER, useBrotli ? PdfName.BROTLIDECODE : PdfName.FLATEDECODE); + } } catch (Exception e) { throw new BadPdfFormatException(e.getMessage()); } - put(PdfName.LENGTH, new PdfNumber(streamBytes.size())); - if (compressed) { - put(PdfName.FILTER, PdfName.FLATEDECODE); - } } } diff --git a/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfName.java b/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfName.java index 659ed4528..88d19a682 100644 --- a/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfName.java +++ b/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfName.java @@ -1142,6 +1142,11 @@ public class PdfName extends PdfObject implements Comparable { * A name */ public static final PdfName FLATEDECODE = new PdfName("FlateDecode"); + /** + * The {@code /BrotliDecode} filter — being standardised as a PDF 2.0 stream filter + * through ISO/TS 32001. + */ + public static final PdfName BROTLIDECODE = new PdfName("BrotliDecode"); /** * A name */ diff --git a/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfReader.java b/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfReader.java index 470a4726e..5b9a42cf1 100644 --- a/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfReader.java +++ b/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfReader.java @@ -54,6 +54,7 @@ import org.openpdf.text.PageSize; import org.openpdf.text.Rectangle; import org.openpdf.text.error_messages.MessageLocalization; +import org.openpdf.text.pdf.codec.BrotliFilter; import org.openpdf.text.exceptions.BadPasswordException; import org.openpdf.text.exceptions.InvalidPdfException; import org.openpdf.text.exceptions.UnsupportedPdfException; @@ -872,6 +873,9 @@ public static byte[] getStreamBytes(PRStream stream, } case "/Crypt": break; + case "/BrotliDecode": + b = BrotliFilter.decode(b); + break; default: throw new UnsupportedPdfException( MessageLocalization.getComposedMessage( diff --git a/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfWriter.java b/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfWriter.java index cab9de8aa..fc715075a 100644 --- a/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfWriter.java +++ b/openpdf-core/src/main/java/org/openpdf/text/pdf/PdfWriter.java @@ -60,6 +60,7 @@ import org.openpdf.text.Rectangle; import org.openpdf.text.Table; import org.openpdf.text.error_messages.MessageLocalization; +import org.openpdf.text.pdf.codec.BrotliFilter; import org.openpdf.text.pdf.collection.PdfCollection; import org.openpdf.text.pdf.events.PdfPageEventForwarder; import org.openpdf.text.pdf.interfaces.PdfAnnotations; @@ -611,6 +612,12 @@ public class PdfWriter extends DocWriter implements * @since 2.1.3 */ protected int compressionLevel = PdfStream.DEFAULT_COMPRESSION; + /** + * Whether streams written by this writer (notably page content streams) should be + * compressed using Brotli ({@code /BrotliDecode}) instead of Flate ({@code /FlateDecode}). + * Defaults to the value of {@link org.openpdf.text.Document#useBrotliCompression}. + */ + protected boolean useBrotliCompression = Document.useBrotliCompression; /** * The fonts of this document */ @@ -2050,6 +2057,44 @@ public void setCompressionLevel(int compressionLevel) { } } + /** + * Returns whether this writer compresses page content streams with Brotli + * ({@code /BrotliDecode}) instead of Flate ({@code /FlateDecode}). + * + * @return {@code true} if Brotli compression is enabled + */ + public boolean isUseBrotliCompression() { + return useBrotliCompression; + } + + /** + * Enables or disables Brotli compression for page content streams produced by this + * writer. When enabled, the streams use the {@code /BrotliDecode} filter — being + * standardised as a PDF 2.0 (ISO 32000-2) stream filter through ISO/TS 32001 — + * instead of {@code /FlateDecode}. Has no effect if {@link Document#compress} + * is {@code false}. + *

+ * This performs a fail-fast availability check via + * {@link BrotliFilter#ensureAvailable()} when enabling Brotli. If the brotli4j + * native library cannot be loaded, a warning is written to {@code System.err} + * and Brotli compression is left disabled. + * + * @param useBrotliCompression {@code true} to enable Brotli, {@code false} for Flate + */ + public void setUseBrotliCompression(boolean useBrotliCompression) { + if (useBrotliCompression) { + try { + BrotliFilter.ensureAvailable(); + } catch (IOException e) { + System.err.println("OpenPDF: Brotli compression requested but unavailable: " + + e.getMessage() + " - falling back to Flate compression."); + this.useBrotliCompression = false; + return; + } + } + this.useBrotliCompression = useBrotliCompression; + } + /** * Adds a BaseFont to the document but not to the page resources. It is used for templates. * diff --git a/openpdf-core/src/main/java/org/openpdf/text/pdf/codec/BrotliFilter.java b/openpdf-core/src/main/java/org/openpdf/text/pdf/codec/BrotliFilter.java new file mode 100644 index 000000000..7627c5fb9 --- /dev/null +++ b/openpdf-core/src/main/java/org/openpdf/text/pdf/codec/BrotliFilter.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026 OpenPDF + * + * Licensed under the LGPL 2.1 or MPL 2.0. + */ +package org.openpdf.text.pdf.codec; + +import com.aayushatharva.brotli4j.Brotli4jLoader; +import com.aayushatharva.brotli4j.decoder.DecoderJNI; +import com.aayushatharva.brotli4j.decoder.DirectDecompress; +import com.aayushatharva.brotli4j.encoder.BrotliOutputStream; +import com.aayushatharva.brotli4j.encoder.Encoder; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * Brotli compression / decompression helper used to support PDF streams that use the + * {@code /BrotliDecode} filter. Brotli is being standardised as a stream filter for + * PDF 2.0 (ISO 32000-2) through ISO/TS 32001. + *

+ * Backed by brotli4j. The native + * library is loaded lazily on first use; consumers that never touch Brotli pay no cost. + */ +public final class BrotliFilter { + + private BrotliFilter() { + } + + /** + * Ensures that the Brotli native library is available. Call this for a fail-fast + * check before relying on {@link #encode(byte[])} / {@link #decode(byte[])} or + * before enabling Brotli compression on a {@code PdfWriter}. + * + * @throws IOException if the brotli4j native library cannot be loaded + */ + public static void ensureAvailable() throws IOException { + try { + Brotli4jLoader.ensureAvailability(); + } catch (Exception e) { + throw new IOException("Brotli native library could not be loaded. " + + "Add a brotli4j native dependency for your platform.", e); + } + } + + /** + * Compresses the given data using Brotli (default quality). + * + * @param in the data to compress + * @return the Brotli-compressed bytes + * @throws IOException on compression error + */ + public static byte[] encode(byte[] in) throws IOException { + ensureAvailable(); + return Encoder.compress(in); + } + + /** + * Returns a new streaming Brotli encoder that writes its compressed output to + * {@code out}. Closing the returned stream flushes and finishes the Brotli frame. + * + * @param out the destination for the compressed bytes + * @return an {@link OutputStream} that Brotli-compresses everything written to it + * @throws IOException if the encoder cannot be created + */ + public static OutputStream encoder(OutputStream out) throws IOException { + ensureAvailable(); + return new BrotliOutputStream(out); + } + + /** + * Decompresses Brotli-encoded data. + * + * @param in the compressed data + * @return the decoded bytes + * @throws IOException if the data cannot be decoded + */ + public static byte[] decode(byte[] in) throws IOException { + ensureAvailable(); + DirectDecompress result = DirectDecompress.decompress(in); + if (result.getResultStatus() != DecoderJNI.Status.DONE) { + throw new IOException("Brotli decoding failed: " + result.getResultStatus()); + } + return result.getDecompressedData(); + } +} + diff --git a/openpdf-core/src/test/java/org/openpdf/text/pdf/codec/BrotliPDFTest.java b/openpdf-core/src/test/java/org/openpdf/text/pdf/codec/BrotliPDFTest.java new file mode 100644 index 000000000..15a82ee6b --- /dev/null +++ b/openpdf-core/src/test/java/org/openpdf/text/pdf/codec/BrotliPDFTest.java @@ -0,0 +1,190 @@ +/* + * Copyright 2026 OpenPDF + * + * Licensed under the LGPL 2.1 or MPL 2.0. + */ +package org.openpdf.text.pdf.codec; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.openpdf.text.Document; +import org.openpdf.text.Paragraph; +import org.openpdf.text.pdf.PRStream; +import org.openpdf.text.pdf.PdfName; +import org.openpdf.text.pdf.PdfObject; +import org.openpdf.text.pdf.PdfReader; +import org.openpdf.text.pdf.PdfWriter; + +/** + * Tests for Brotli compression support in OpenPDF: codec round-trip, + * round-trip through a PDF whose page content is written with Brotli, and + * reading an externally-produced PDF that uses the {@code /BrotliDecode} filter. + */ +class BrotliPDFTest { + + private static final String EXTERNAL_BROTLI_PDF = "/Brotli-Prototype-FileA.pdf"; + + @Test + void roundTripCompressDecompress() throws Exception { + byte[] original = ("OpenPDF Brotli round-trip test. " + + "The quick brown fox jumps over the lazy dog. ").repeat(50) + .getBytes(StandardCharsets.UTF_8); + + byte[] compressed = BrotliFilter.encode(original); + assertThat(compressed).isNotEmpty(); + assertThat(compressed.length).isLessThan(original.length); + + byte[] decoded = BrotliFilter.decode(compressed); + assertThat(decoded).isEqualTo(original); + } + + /** + * Writes a PDF with Brotli-compressed page content streams via {@code PdfWriter}, + * then reads it back and verifies the page content stream uses + * {@code /BrotliDecode} and decodes correctly. + */ + @Test + void pdfWithBrotliCompressedStreamCanBeRead() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Document document = new Document(); + PdfWriter writer = PdfWriter.getInstance(document, baos); + writer.setUseBrotliCompression(true); + document.open(); + document.add(new Paragraph( + "Hello Brotli! This page content stream is compressed with Brotli.")); + document.close(); + + // Sanity: the output PDF advertises the /BrotliDecode filter for the page content. + String raw = new String(baos.toByteArray(), StandardCharsets.ISO_8859_1); + assertThat(raw).contains("/BrotliDecode"); + + PdfReader reader = new PdfReader(baos.toByteArray()); + try { + assertThat(reader.getNumberOfPages()).isEqualTo(1); + + // The page content stream must use /BrotliDecode and decode to text containing + // the standard PDF text-showing operator "Tj" or "TJ". + byte[] pageContent = reader.getPageContent(1); + assertThat(pageContent).isNotEmpty(); + String pageContentText = new String(pageContent, StandardCharsets.ISO_8859_1); + assertThat(pageContentText).containsAnyOf("Tj", "TJ"); + + // Confirm the underlying stream is actually labeled /BrotliDecode. + int brotliLabeled = 0; + for (int i = 1; i < reader.getXrefSize(); i++) { + PdfObject obj = reader.getPdfObject(i); + if (!(obj instanceof PRStream prs)) { + continue; + } + PdfObject filter = PdfReader.getPdfObject(prs.get(PdfName.FILTER)); + if (filter != null && filter.toString().contains("BrotliDecode")) { + brotliLabeled++; + // Each such stream must decode without error. + assertThat(PdfReader.getStreamBytes(prs)).isNotNull(); + } + } + assertThat(brotliLabeled) + .as("at least one stream must be labeled /BrotliDecode") + .isGreaterThan(0); + } finally { + reader.close(); + } + } + + /** + * Reads an externally-produced PDF whose streams use the {@code /BrotliDecode} + * filter and verifies that every such stream can be decompressed by OpenPDF and + * that the resulting document is structurally sound. + *

+ * The fixture {@code Brotli-Prototype-FileA.pdf} is a PDF 2.0 file produced by + * AutoCAD's {@code pdfplot11.hdi} where every stream (xref stream, object stream, + * page content stream, font / image streams) is Brotli-compressed. + */ + @Test + void canReadExternalBrotliCompressedPdf() throws Exception { + byte[] pdfBytes; + try (var in = BrotliPDFTest.class.getResourceAsStream(EXTERNAL_BROTLI_PDF)) { + assertThat(in) + .as("test resource %s must exist", EXTERNAL_BROTLI_PDF) + .isNotNull(); + pdfBytes = in.readAllBytes(); + } + + // The fixture is a PDF 2.0 file whose streams are all Brotli-compressed. + String raw = new String(pdfBytes, StandardCharsets.ISO_8859_1); + assertThat(raw).startsWith("%PDF-2.0"); + assertThat(raw) + .as("fixture PDF should contain at least one /BrotliDecode filter") + .contains("/BrotliDecode"); + + PdfReader reader = new PdfReader(pdfBytes); + try { + // Document-level structure must parse correctly. The catalog and trailer + // both live inside Brotli-compressed object / xref streams. + assertThat(reader.getPdfVersion()).isEqualTo("2.0"); + assertThat(reader.getNumberOfPages()).isEqualTo(25); + + // Producer / Title come from the document info dictionary, which lives + // inside a Brotli-compressed object stream in this fixture. + var info = reader.getInfo(); + assertThat(info.get("Producer")).isEqualTo("pdfplot11.hdi 11.1.18.0"); + assertThat(info.get("Title")).isEqualTo("A5.0"); + assertThat(info.get("Creator")).contains("AutoCAD"); + + // Page 1's content stream must decode to real PDF content. AutoCAD plots + // are vector drawings, so we expect path operators rather than text. + byte[] pageContent = reader.getPageContent(1); + assertThat(pageContent) + .as("decoded page 1 content stream") + .isNotEmpty(); + String pageContentText = new String(pageContent, StandardCharsets.ISO_8859_1); + assertThat(pageContentText) + .as("page 1 content should contain PDF graphics-state operators") + .containsAnyOf(" q\n", " Q\n", " cm\n", " m\n", " l\n", " S\n", " re\n", "BT", "ET"); + + // Every page in the document must have a non-empty, decodable content stream. + for (int p = 1; p <= reader.getNumberOfPages(); p++) { + assertThat(reader.getPageContent(p)) + .as("decoded content stream for page %d", p) + .isNotEmpty(); + } + + // Walk the xref table: every Brotli-labeled stream must decode without + // error, and the total decoded payload must be considerably larger than + // the compressed file (Brotli must actually have compressed something). + int brotliStreamCount = 0; + long totalDecodedBytes = 0; + for (int i = 1; i < reader.getXrefSize(); i++) { + PdfObject obj = reader.getPdfObject(i); + if (!(obj instanceof PRStream prs)) { + continue; + } + PdfObject filter = PdfReader.getPdfObject(prs.get(PdfName.FILTER)); + if (filter == null || !filter.toString().contains("BrotliDecode")) { + continue; + } + byte[] decoded = PdfReader.getStreamBytes(prs); + assertThat(decoded) + .as("decoded Brotli stream #%d", i) + .isNotNull() + .isNotEmpty(); + brotliStreamCount++; + totalDecodedBytes += decoded.length; + } + + assertThat(brotliStreamCount) + .as("the fixture must contain many /BrotliDecode streams") + .isGreaterThanOrEqualTo(20); + assertThat(totalDecodedBytes) + .as("total decoded payload must exceed the compressed file size") + .isGreaterThan(pdfBytes.length); + } finally { + reader.close(); + } + } +} + diff --git a/openpdf-core/src/test/resources/Brotli-Prototype-FileA.pdf b/openpdf-core/src/test/resources/Brotli-Prototype-FileA.pdf new file mode 100644 index 000000000..a341672de Binary files /dev/null and b/openpdf-core/src/test/resources/Brotli-Prototype-FileA.pdf differ diff --git a/pom.xml b/pom.xml index 1d10dce29..f44d53cf2 100644 --- a/pom.xml +++ b/pom.xml @@ -87,6 +87,7 @@ 1.84 + 1.23.0 2.22.0 2.2.0 2.11