Documentation page: Mass-Converting HTML Documents to PDF. This page is also available as Markdown at mass-converting-html-documents-to-pdf.md.

Mass-Converting HTML Documents to PDF

HTML, as a source format for PDF generation, has a structural constraint that is easy to overlook: the entire source document has to be fully read and laid out in memory before PDF output can begin. For ordinary documents -- a handful to a few hundred pages -- this is a non-issue. But once a single source document is expected to produce well over 1,000 PDF pages, converting it in one pass starts to strain both memory and throughput.

When the document's structure allows it, a better strategy is to split it into a series of smaller, independent HTML fragments, convert each one to PDF individually, and merge the resulting PDFs into a single final document. Bank and telecom account statements are the textbook use case: they're often extremely long, but their structure is repetitive -- a predictable sequence of per-account or per-page content blocks -- which makes them easy to split cleanly.

This page was checked against the current PD4ML source rather than carried over unchanged. Two findings worth knowing before you use either version below:

  1. The original v3 sample still runs essentially unmodified today. org.zefer.pd4ml.PD4ML and org.zefer.pd4ml.PD4Document are maintained as compatibility wrappers over the current engine, and render(URL[], OutputStream) plus mergePDFs(...) are both still present on them.
  2. That said, the original sample's pd4ml.setHtmlWidth(800) call has no effect when combined with the multi-URL render(URL[], OutputStream) entry point -- this is documented on the method itself ("The method takes no effect for multi-URL PD4ML call") and was true even when the article was first written. It's harmless dead code in the sample, not a bug you introduced.

The v4 tab below isn't just a mechanical API translation -- it also takes advantage of com.pd4ml.pdf.merge.PdfMerger, which accepts an arbitrary number of sources per merge call. That removes the need for the original sample's binary-tree merge loop, and, as a side effect, restores setHtmlWidth() to actually taking effect, since v4 converts one document at a time rather than batching a whole chunk into a single render() call.

How the chunking works

The sample class below requests a new HTML document at a time via getNextDocument(), converts documents in batches (DOCS_PER_CHUNK, 20 by default), and keeps each batch's PDF bytes in a list. Once every document has been converted, the batches are merged into a single output PDF.

getNextDocument() is meant to be overridden with something more realistic -- for example, pulling the next XML data portion from a database, transforming it to HTML, and returning it as a StringReader/InputStream rather than a filesystem URL.

Because the documents are converted independently of one another, both the per-document conversion step and the merge step parallelize cleanly, which is worth exploiting on multi-core hardware if conversion throughput matters.

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.net.MalformedURLException;
import java.net.URL;

import com.pd4ml.PD4ML;
import com.pd4ml.pdf.merge.PdfMerger;

public class MassConvert {

	private final static int DOCS_PER_CHUNK = 20;

	public URL getNextDocument() throws MalformedURLException {
		// to be overridden
		//
		// if source HTML documents are generated on-the-fly, it's probably a good
		// idea to change this to return an InputStream/StringReader instead, and
		// adjust the readHTML() call below correspondingly
		return new URL("file:/O:/work/testarea/AccountStatement.htm");
	}

	public int getDocumentNumber() {
		// to be overridden
		return 1000;
	}

	public static void main(String[] args) throws Exception {
		new MassConvert().convert();
	}

	public void convert() throws Exception {

		long start = System.currentTimeMillis();
		int docNumber = getDocumentNumber();

		byte[] merged = null;

		for (int chunkStart = 0; chunkStart < docNumber; chunkStart += DOCS_PER_CHUNK) {
			int chunkEnd = Math.min(chunkStart + DOCS_PER_CHUNK, docNumber);
			PdfMerger chunkMerger = new PdfMerger();

			for (int i = chunkStart; i < chunkEnd; i++) {
				URL url = getNextDocument();

				PD4ML pd4ml = new PD4ML();
				pd4ml.setHtmlWidth(800);
				pd4ml.readHTML(url);

				ByteArrayOutputStream baos = new ByteArrayOutputStream();
				pd4ml.writePDF(baos);

				chunkMerger.addSource(baos.toByteArray());
			}

			byte[] chunkPdf = chunkMerger.mergeToBytes();

			if (merged == null) {
				merged = chunkPdf;
			} else {
				PdfMerger overallMerger = new PdfMerger();
				overallMerger.addSource(merged);
				overallMerger.addSource(chunkPdf);
				merged = overallMerger.mergeToBytes();
			}

			int perc = chunkEnd * 100 / docNumber;
			System.out.println(perc + "% " + (System.currentTimeMillis() - start) / 1000 + "sec");
		}

		System.out.println("done in " + (System.currentTimeMillis() - start) / 1000 + "sec");
		System.out.println("Resulting PDF size: " + merged.length + " bytes");

		File pdfFile = File.createTempFile("merge", ".pdf");
		try (FileOutputStream fos = new FileOutputStream(pdfFile)) {
			fos.write(merged);
		}
	}
}

Notes on the v4 rewrite

  • Per-document conversion instead of per-chunk. v4's PD4ML no longer exposes a render(URL[], OutputStream)-style entry point -- conversion is always one readHTML() + writePDF() pair per document. DOCS_PER_CHUNK still controls how many individually-converted PDFs accumulate before being folded into the running merged result, which keeps a bound on how many PDF byte arrays are held in memory at once, but the memory savings now come from converting one document at a time rather than from anything at the merge stage.
  • PdfMerger replaces the binary-tree merge loop. Because PdfMerger.addSource(...) accepts as many sources as you like before a single mergeToBytes()/merge(OutputStream) call, there's no need to pair documents off two at a time the way the original mergePDFs()-based loop did. Each chunk is merged in one call, and the accumulated result is folded into each new chunk with one more call.
  • readHTML()/writePDF() failures surface per document. Unlike the original single render(URL[], OutputStream) call, which failed or succeeded for an entire chunk at once, each document's readHTML()/writePDF() pair can be wrapped individually if you want one bad source document to be skipped or logged rather than aborting the whole run.

Example: launching the result

The original sample ends by writing the merged PDF to a temporary file and shelling out to a hard-coded Acrobat Reader path to open it -- convenient for a one-off demo run, but brittle across machines and Reader versions. If you want to open the result in whatever PDF viewer is actually registered on the current machine, see Launching a PDF Viewer After Conversion, which covers the portable, cross-platform ways of doing that.