# PD4ML Docker Conversion Service

PD4ML ships a ready-to-run **HTTP conversion service** in a Docker image:
[`com.pd4ml.tools.Pd4Server`](https://pd4ml.com/javadoc/com/pd4ml/tools/Pd4Server.html) wraps the same
engine as [Pd4Cmd](../pd4cmd-command-line-reference) and the [Programmer's Manual](../pd4ml-manual), but as a
long-lived service: one process with a warm engine (the font cache is primed once) and a bounded
worker pool, so the JVM and font warm-up are paid at start, not on every request. You POST HTML and
get a PDF (or DOCX/RTF) back.

It is **stateless** -- run as many replicas as you need behind a load balancer -- and it performs no
authentication of its own, so it is meant to sit behind an ingress or API gateway.

{% hint style="info" %}
Container execution requires a **site or enterprise** license. A single (node-locked) license is
detected inside a container and the output falls back to evaluation mode. See [Licensing](#licensing).
{% endhint %}

## Getting the image

Prebuilt images are distributed as tarballs at [https://pd4ml.com/docker/](https://pd4ml.com/docker/),
the same way the evaluation downloads are published -- one per PD4ML engine. Download the one that
matches your license and load it into Docker:

```bash
# PD4ML for Java
curl -fsSLO https://pd4ml.com/docker/pd4ml-service-latest.tar.gz
docker load -i pd4ml-service-latest.tar.gz

# PD4ML for .NET (pd4net)
curl -fsSLO https://pd4ml.com/docker/pd4ml-service-dotnet-latest.tar.gz
docker load -i pd4ml-service-dotnet-latest.tar.gz
```

That gives you the `pd4ml-service` image (Ubuntu + a Temurin 17 JRE + the Java engine and its
dependencies) or the `pd4ml-service-dotnet` image (the .NET 10 runtime + pd4net), each tagged `:latest`
and with its own version, for example `pd4ml-service:1.0.0`. The images are versioned independently of
the PD4ML engine inside them; `docker inspect` shows the engine in the `com.pd4ml.engine` and
`com.pd4ml.engine.version` labels. The license is **not** part of the image -- you mount it at run time.

### Which image: Java or .NET?

Both images expose the same HTTP API, so the choice comes down to the license you hold, not to your
client's language -- any language calls either service over HTTP:

* **You have a PD4ML license for Java** -- use `pd4ml-service`.
* **You have a PD4ML license for .NET** -- use `pd4ml-service-dotnet`. A .NET license does not license
  the Java engine, so without this image you would have to buy PD4ML for Java just to run the
  conversion service in Docker; the .NET image lets you deploy the license you already own.
* **You have a Java + .NET bundle** -- either works.

A license for the other platform runs the engine in evaluation mode (watermarked output).

### Building the image: Java or .NET engine

To build the image yourself, the engine is fetched from the PD4ML repositories at build time (no local
PD4ML build needed). From the `docker/` directory of the PD4ML distribution:

```bash
# PD4ML for Java (the default)
docker build -t pd4ml-service .

# pd4net, PD4ML's .NET port, on .NET 10
docker build -t pd4ml-service-dotnet --build-arg PD4ML_ENGINE=dotnet .
```

| Build argument | Default | Meaning |
| --- | --- | --- |
| `PD4ML_ENGINE` | `java` | `java`: PD4ML for Java from the Maven repository. `dotnet`: pd4net, the `Pd4mlNet` NuGet package from [pd4ml.com/v3-eval/dotnet](https://pd4ml.com/v3-eval/dotnet/). |
| `PD4ML_VERSION` | `[4.2.0-SNAPSHOT,)` | The engine version, or a version range (Maven or NuGet syntax), to fetch. The default is the latest 4.2.0+ build, snapshots included. |

Both engines serve the same HTTP API, parameters and environment variables, so clients can switch
without changes. The version must contain the HTTP service (4.2.0 and later); the build fails with a
clear message otherwise. For the Java engine, `fetch-pom.xml` pins the engine's runtime dependencies (the
published `com.pd4ml:pd4ml` is a thin jar with no declared dependencies); the .NET engine brings its own
dependencies, including the native SkiaSharp and HarfBuzz libraries for the image's Linux architecture.

{% hint style="info" %}
The .NET engine needs a PD4ML license for .NET (or a Java + .NET bundle); a Java-only license runs it in
evaluation mode.
{% endhint %}

## Quick start

```bash
docker run --rm -p 8080:8080 \
  -v "$PWD/pd4ml.lic:/etc/pd4ml/pd4ml.lic:ro" \
  pd4ml-service

# in another shell:
curl -fsS -X POST --data-binary @page.html \
  'http://localhost:8080/convert?page=A4&width=800' -o out.pdf
```

The license file is mounted **read-only** at the path the service reads (`/etc/pd4ml/pd4ml.lic` by
default). Nothing about the license is baked into the image, so the same image is safe to share and to
push to a registry.

## The HTTP API

| Method & path | Purpose |
| --- | --- |
| `POST /convert` | The request body is the HTML; the response is the converted document. |
| `GET /healthz` | Liveness / readiness probe. Returns `200 ok`. |
| `GET /` | A one-line usage string. |

`POST /convert` query parameters:

| Parameter | Default | Meaning |
| --- | --- | --- |
| `format` | `pdf` | Output format: `pdf`, `pdfa` (PDF/A-1b), `pdfua` (PDF/UA, accessible), `docx` or `rtf`. |
| `page` | `A4` | A page size name -- any field of `com.pd4ml.PageSize` (`A4`, `LETTER`, `LEGAL`, `A3`, `B4`, ...). |
| `orientation` | `portrait` | `portrait` or `landscape`. |
| `width` | `800` | The HTML layout width in pixels (the "virtual viewport"). |
| `insets` | -- | Page margins as `top,left,bottom,right,unit`, unit `mm` or `pt` (e.g. `20,20,20,20,mm`). |
| `encoding` | -- | The input character set (e.g. `UTF-8`, `windows-1251`); auto-detected if omitted. |
| `base` | -- | A base URL for resolving relative resources in the HTML. |
| `forms` | `false` | `true` emits interactive PDF form fields for `<input>`/`<select>`/`<textarea>`. |
| `bookmarks` | -- | Generate PDF bookmarks from `headings` or from named `anchors`. |
| `engine` | `v42` | Layout engine: `v42` (modern, the service default) or `v40` (legacy). |
| `bgcolor` | -- | Page background colour, e.g. `%23ffffcc` (`#ffffcc`; `#` must be URL-encoded as `%23`). |
| `nohyperlinks` | `false` | `true` drops hyperlink annotations from the output. |
| `kerning` | -- | `true`/`false` to force kerning on/off (v42 kerns by default, as browsers do). |
| `ligatures` | `false` | `true` enables OpenType ligatures. |
| `multicolumn` | -- | Newspaper-style columns as `columns,gap` with unit `mm`/`pt` (e.g. `2,10mm`; PRO+). |

Examples:

```bash
# PDF/A-1b, US Letter, wider layout:
curl -fsS -X POST --data-binary @invoice.html \
  'http://localhost:8080/convert?format=pdfa&page=LETTER&width=1000' -o invoice.pdf

# Word .docx, landscape, with heading bookmarks:
curl -fsS -X POST --data-binary @report.html \
  'http://localhost:8080/convert?format=docx&orientation=landscape&bookmarks=headings' -o report.docx

# resolve the page's relative images/CSS against a base URL:
curl -fsS -X POST --data-binary @page.html \
  'http://localhost:8080/convert?base=https://example.com/reports/' -o out.pdf
```

These are the common single-document options. Other [Pd4Cmd](../pd4cmd-command-line-reference)
capabilities -- HTML page headers and footers, PDF encryption and permissions, overlays and merging,
page-range extraction, per-page image output, and XSL transforms -- need a richer request than a URL
carries and are not exposed here; use Pd4Cmd or the library for those.

A body larger than `PD4ML_MAX_BYTES` is rejected with `413`; an empty body with `400`; a conversion
error with `500` and a short message.

## Configuration

All configuration is through environment variables:

| Variable | Default | Meaning |
| --- | --- | --- |
| `PD4ML_PORT` | `8080` | The port the service listens on. |
| `PD4ML_WORKERS` | CPU count | Maximum concurrent conversions (the worker-pool size). |
| `PD4ML_LICENSE` | `/etc/pd4ml/pd4ml.lic` | Path to the license file; mount it as a read-only secret. |
| `PD4ML_FONTS` | -- | A font-set directory to load (see [Providing fonts](#providing-fonts)). |
| `PD4ML_MAX_BYTES` | `20971520` | Maximum request-body size (20 MiB). |
| `PD4ML_WIDTH` | `800` | The default layout width when `width` is not given on a request. |
| `PD4ML_ALLOWED_LOCATIONS` | -- | Resource-location allow-list (see [External resources](#external-resources-and-ssrf)). |
| `PD4ML_CONTAINER` | -- | Override container detection (`true`/`false`); see [Licensing](#licensing). |

Two settings are fixed when the image is **built**, not when it runs -- they are `docker build`
arguments, and setting them with `docker run -e` has no effect:

| Build argument | Default | Meaning |
| --- | --- | --- |
| `PD4ML_ENGINE` | `java` | The engine: `java` (PD4ML for Java) or `dotnet` (pd4net, PD4ML for .NET). A built image reports it in its `PD4ML_ENGINE` environment variable and `com.pd4ml.engine` label. See [Which image: Java or .NET?](#which-image-java-or-.net) |
| `PD4ML_VERSION` | `[4.2.0-SNAPSHOT,)` | The engine version or version range fetched from the PD4ML repositories (`com.pd4ml.engine.version` label). See [Building the image](#building-the-image-java-or-.net-engine). |

The Java image runs the JVM headless with `-XX:MaxRAMPercentage=75`, so the heap tracks the container's
memory limit -- set that limit rather than tuning `-Xmx`. The .NET runtime follows the container's
memory limit on its own.

## Providing fonts

The image contains **no operating-system text fonts on purpose** -- only PD4ML's own bundled
Standard-14 faces (Helvetica/Arial, Times, Courier and the symbol fonts). That is enough for plain
documents, but to render other typefaces -- or to **embed** fonts into the PDF, or to support CJK,
Arabic, Hebrew and other scripts -- you provide your own font directory. This is the containerised form
of [`useTTF(...)`](../features/configuring-pdf-fonts); the full mechanics (the `pd4fonts.properties`
index, `@font-face`, subsetting) are described in **[Configuring PDF Fonts](../features/configuring-pdf-fonts)**.

Collect the `.ttf`/`.otf` files your documents need into one directory, mount it into the container,
and point `PD4ML_FONTS` at the mount:

```bash
docker run --rm -p 8080:8080 \
  -v "$PWD/pd4ml.lic:/etc/pd4ml/pd4ml.lic:ro" \
  -v "$PWD/fonts:/fonts" \
  -e PD4ML_FONTS=/fonts \
  pd4ml-service
```

PD4ML indexes the directory on start, writing a `pd4fonts.properties` mapping into it. That needs the
mount to be **writable**, and it re-indexes on every start.

{% hint style="success" %}
**For a read-only font mount (recommended for production, and required for a Kubernetes ConfigMap),
pre-build the index once** and then mount the directory read-only. You can do this with the image
itself -- no separate PD4ML install:

```bash
# index ./fonts once (writes ./fonts/pd4fonts.properties)
docker run --rm -v "$PWD/fonts:/fonts" --entrypoint java pd4ml-service \
  -cp '/opt/pd4ml/lib/*' com.pd4ml.tools.Pd4Cmd -configure.fonts /fonts

# then serve with the fonts mounted read-only
docker run --rm -p 8080:8080 \
  -v "$PWD/pd4ml.lic:/etc/pd4ml/pd4ml.lic:ro" \
  -v "$PWD/fonts:/fonts:ro" -e PD4ML_FONTS=/fonts \
  pd4ml-service
```
{% endhint %}

In the HTML, refer to a provided font by its family name as usual
(`font-family: "Noto Sans"; ...`), or declare it with `@font-face`. A face answers only to the CSS
family name it is indexed under -- see [Configuring PDF Fonts](../features/configuring-pdf-fonts) for naming and
`@font-face` details.

### Baking a font set into the image

To ship a self-contained image that needs no font mount at all, build a small image **on top of**
`pd4ml-service` that copies the fonts in, indexes them once at build time, and points `PD4ML_FONTS` at
them (this recipe and the indexing command above use the Java engine's `Pd4Cmd`, so they apply to the
Java image). Put your `.ttf`/`.otf` files in a `fonts/` directory next to this Dockerfile:

```dockerfile
FROM pd4ml-service
COPY fonts/ /opt/fonts/
# index the set once, at build time, so the directory can stay read-only at run time
RUN java -cp '/opt/pd4ml/lib/*' com.pd4ml.tools.Pd4Cmd -configure.fonts /opt/fonts
ENV PD4ML_FONTS=/opt/fonts
```

```bash
docker build -t pd4ml-service-fonts .
docker run --rm -p 8080:8080 -v "$PWD/pd4ml.lic:/etc/pd4ml/pd4ml.lic:ro" pd4ml-service-fonts
```

The fonts and their generated `pd4fonts.properties` index are now part of the image: no `-v` font
mount and no `PD4ML_FONTS` on `docker run` are needed, and the font directory is read-only at run time.
The entrypoint, the environment variables and the licensing behaviour are all inherited from the base
image -- only the fonts are added. The **license is still mounted at run time**; it is never baked in.

## External resources and SSRF

By default the engine may fetch the external resources an HTML document references (CSS, images,
fonts). When you convert HTML from untrusted sources, restrict that: set `PD4ML_ALLOWED_LOCATIONS` to a
comma- or semicolon-separated list of allowed location prefixes, and/or sandbox the container's network
egress. This maps to the engine's `addAllowedResourceLocation(...)`; the rationale and options are in
**[Restricting Resource Loading for Untrusted HTML](../features/restricting-resource-loading-for-untrusted-html)**.

```bash
docker run ... -e PD4ML_ALLOWED_LOCATIONS="https://cdn.example.com/,https://assets.example.com/" pd4ml-service
```

## Deployment

### Docker Compose (single host)

```yaml
services:
  pd4ml:
    image: pd4ml-service
    ports:
      - "8080:8080"
    environment:
      PD4ML_WORKERS: "4"
      HOME: /tmp
    volumes:
      - ./pd4ml.lic:/etc/pd4ml/pd4ml.lic:ro
      - ./fonts:/fonts:ro          # optional; with PD4ML_FONTS below
    # environment: { PD4ML_FONTS: /fonts }
    read_only: true
    tmpfs:
      - /tmp
    restart: unless-stopped
```

Scaling on one host needs a reverse proxy in front (the published port can't be shared): run
`docker compose up --scale pd4ml=N` behind nginx or traefik. For real scaling, use Kubernetes.

### Kubernetes

Create the license as a Secret (a site/enterprise license), then deploy N replicas behind a Service.
Mount the fonts from a ConfigMap or a volume and set `PD4ML_FONTS`.

```bash
kubectl create secret generic pd4ml-license --from-file=pd4ml.lic=./pd4ml.lic
```

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pd4ml
spec:
  replicas: 3
  selector: { matchLabels: { app: pd4ml } }
  template:
    metadata: { labels: { app: pd4ml } }
    spec:
      securityContext: { runAsNonRoot: true, runAsUser: 1000, seccompProfile: { type: RuntimeDefault } }
      containers:
        - name: pd4ml
          image: your-registry/pd4ml-service:1.0.0
          ports: [ { containerPort: 8080 } ]
          env:
            - { name: PD4ML_WORKERS, value: "2" }
            - { name: HOME, value: /tmp }
          resources:
            requests: { cpu: "500m", memory: 512Mi }
            limits:   { cpu: "2",    memory: 2Gi }
          readinessProbe: { httpGet: { path: /healthz, port: 8080 }, initialDelaySeconds: 10 }
          livenessProbe:  { httpGet: { path: /healthz, port: 8080 }, initialDelaySeconds: 25 }
          securityContext: { readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, capabilities: { drop: [ALL] } }
          volumeMounts:
            - { name: license, mountPath: /etc/pd4ml, readOnly: true }
            - { name: tmp, mountPath: /tmp }
      volumes:
        - { name: license, secret: { secretName: pd4ml-license } }
        - { name: tmp, emptyDir: {} }
---
apiVersion: v1
kind: Service
metadata: { name: pd4ml }
spec:
  selector: { app: pd4ml }
  ports: [ { port: 80, targetPort: 8080 } ]
```

Kubernetes pulls images from a registry, not from a tarball, so `docker load` the downloaded image and
push it to your own registry, then reference it (`image: your-registry/pd4ml-service:1.0.0`). Add a
`HorizontalPodAutoscaler` on CPU to scale replicas. Expose the Service through your ingress / gateway,
which is also where you add authentication.

## Scaling and performance

The service is stateless: scale by adding replicas. Within one instance, `PD4ML_WORKERS` bounds how
many conversions run at once -- keep it in step with the container's CPU limit. Give each replica
enough memory for the largest documents you convert (the heap is a percentage of the container limit).

A **single** license is the exception to horizontal scaling: it is meant to run as **one** instance
(scale it up with `PD4ML_WORKERS`, not out with replicas), and it would in any case fall back to
evaluation mode in additional containers.

## Licensing

Running in a container is a **site/enterprise** capability.

- A **site/enterprise** license runs normally in any number of containers. Deploy the same license
  file to every replica as a read-only secret.
- A **single** (node-locked) license is detected inside a container and the output drops to
  **evaluation mode** (the "PD4ML EVALUATION MODE" banner). Use it on bare metal, not in Docker.

If container detection is wrong for your environment, set `PD4ML_CONTAINER=false` to turn it off
(forcing it off where you are not licensed for containers is a licensing matter, not a technical one).

## Troubleshooting

| Symptom | Cause and fix |
| --- | --- |
| Output carries the **evaluation banner** | The license is missing, invalid, or not a site/enterprise license in a container. Check that the secret is mounted at `PD4ML_LICENSE`; confirm it is a site/enterprise license. |
| **Wrong or missing fonts** / boxes for CJK text | No font directory, or it isn't indexed. Mount your fonts and set `PD4ML_FONTS`; for a read-only mount, pre-index it (see [Providing fonts](#providing-fonts)). |
| First request returns **empty reply / connection reset** | The engine is still warming up (a few seconds at start). Use the `/healthz` readiness probe; it is already wired in the Compose and Kubernetes examples. |
| `413` on `POST /convert` | The body is larger than `PD4ML_MAX_BYTES`; raise it. |
| External images/CSS not loaded | They are blocked by `PD4ML_ALLOWED_LOCATIONS` or by network egress rules; allow the needed prefixes. |
