diff --git a/README.md b/README.md
index 9c66250..a46fdb3 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,7 @@ This repository demonstrates complete business workflows through working code ex
- **redact_by_keyword.py** - Finds and permanently redacts specific keywords across documents
- **bulk_password_protect.py** - Adds password protection to multiple PDFs in batch
- **prepare_pdf_for_distribution.py** - Marketing workflow that converts Word to PDF, compresses files, and removes metadata for external sharing
+- **optimize_benchmark.py** - Evaluation workflow that runs a folder of PDFs through the Optimize API and reports per-file size reduction in CSVs and a shareable, self-contained HTML report
### eSignature Example
- **employee_policy_onboarding.py** - HR workflow that reads employee data from CSV, creates signature envelopes with policy documents, sends for signing, tracks status, and downloads signed documents organized by employee
diff --git a/samples/python/README.md b/samples/python/README.md
index e3ddfeb..17fc7d5 100644
--- a/samples/python/README.md
+++ b/samples/python/README.md
@@ -148,6 +148,7 @@ api/
- `batch_process.py` - Batch convert documents
- `bulk_password_protect.py` - Password protect multiple PDFs
- `prepare_pdf_for_distribution.py` - Prepare PDFs for external distribution (convert, compress, remove metadata)
+- `optimize_benchmark.py` - Benchmark the Optimize API on a folder of PDFs, with CSVs and a self-contained HTML report
### Sign API Tools (eSignature)
- `employee_policy_onboarding.py` - Complete HR workflow: send policy documents to employees for signature
@@ -199,6 +200,22 @@ uv run python batch_process.py ./input ./output pdf "*.docx"
task batch INPUT_DIR=./input OUTPUT_DIR=./output FORMAT=pdf PATTERN='*.docx'
```
+### Benchmark PDF Compression
+```bash
+# Run every PDF in a folder through the Optimize API (default profile: minimal-file-size)
+uv run python optimize_benchmark.py ./pdfs ./output
+
+# Benchmark several profiles side by side
+uv run python optimize_benchmark.py ./pdfs ./output -p minimal-file-size -p web
+
+# Or use Task command
+task optimize-benchmark INPUT_DIR=./pdfs OUTPUT_DIR=./output
+```
+
+The run writes the optimized PDFs, `results.csv`, `summary.csv` and a
+self-contained `report.html` (headline numbers, a size-reduction distribution
+chart and a filterable per-file table) that opens in your browser when done.
+
## Using the API Client
### Platform API Client
diff --git a/samples/python/Taskfile.yml b/samples/python/Taskfile.yml
index 3e34453..51050fe 100644
--- a/samples/python/Taskfile.yml
+++ b/samples/python/Taskfile.yml
@@ -48,6 +48,13 @@ tasks:
requires:
vars: [INPUT, OUTPUT, KEYWORDS]
+ optimize-benchmark:
+ desc: "Benchmark the Optimize API on a folder of PDFs, with an HTML report (e.g., task optimize-benchmark INPUT_DIR=./pdfs OUTPUT_DIR=./output)"
+ cmds:
+ - uv run python optimize_benchmark.py "{{.INPUT_DIR}}" "{{.OUTPUT_DIR}}"
+ requires:
+ vars: [INPUT_DIR, OUTPUT_DIR]
+
install:
desc: "Install Python dependencies using uv"
cmds:
diff --git a/samples/python/api/__init__.py b/samples/python/api/__init__.py
index d88fe23..015f2c4 100644
--- a/samples/python/api/__init__.py
+++ b/samples/python/api/__init__.py
@@ -1,7 +1,7 @@
"""API clients for Nitro Platform integrations."""
from .base_client import BaseOAuthClient
-from .platform_api import PlatformAPIClient
+from .platform_api import JobFailedError, PlatformAPIClient
from .sign_api import SignAPIClient
-__all__ = ["BaseOAuthClient", "PlatformAPIClient", "SignAPIClient"]
+__all__ = ["BaseOAuthClient", "JobFailedError", "PlatformAPIClient", "SignAPIClient"]
diff --git a/samples/python/api/platform_api.py b/samples/python/api/platform_api.py
index 4fa13a1..4ccf554 100644
--- a/samples/python/api/platform_api.py
+++ b/samples/python/api/platform_api.py
@@ -5,16 +5,66 @@
import json
import mimetypes
+import uuid
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Literal
+from typing import TYPE_CHECKING, Any, Literal, cast
import httpx
from .base_client import BaseOAuthClient
if TYPE_CHECKING:
+ from collections.abc import Iterator
from pathlib import Path
+# How long to wait for an asynchronous job to finish, in seconds.
+JOB_TIMEOUT_SECONDS = 900
+HTTP_OK = 200
+HTTP_ACCEPTED = 202
+
+
+class JobFailedError(RuntimeError):
+ """An asynchronous Platform API job failed.
+
+ Carries the detail needed to diagnose the failure: the HTTP status, the
+ error message returned by the API, and the request ID to quote to support.
+ """
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ status_code: int | None = None,
+ error_type: str | None = None,
+ request_id: str | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.message = message
+ self.status_code = status_code
+ self.error_type = error_type
+ self.request_id = request_id
+
+
+def _str_or_none(value: object) -> str | None:
+ """Return the value if it is a string, otherwise None."""
+ return value if isinstance(value, str) else None
+
+
+def _problem_detail(body: str) -> tuple[str | None, str | None]:
+ """Pull (error type, human message) out of a Platform API error body."""
+ try:
+ payload: object = json.loads(body)
+ except ValueError:
+ return None, None
+ if not isinstance(payload, dict):
+ return None, None
+ data = cast("dict[str, object]", payload)
+ error = data.get("error")
+ if isinstance(error, dict):
+ nested = cast("dict[str, object]", error)
+ return _str_or_none(nested.get("type")), _str_or_none(nested.get("title"))
+ return _str_or_none(data.get("type")), _str_or_none(data.get("title"))
+
@dataclass
class PlatformAPIClient(BaseOAuthClient):
@@ -80,6 +130,153 @@ def _request_bytes(
download_response.raise_for_status()
return download_response.content
+ def _iter_job_events(self, status_url: str, request_id: str) -> Iterator[dict[str, Any]]:
+ """Yield job events from the server-sent-events status stream."""
+ headers = {
+ "Authorization": f"Bearer {self._get_token()}",
+ "Accept": "text/event-stream",
+ "X-Analytics-Session-Id": request_id,
+ }
+ timeout = httpx.Timeout(30.0, read=float(JOB_TIMEOUT_SECONDS))
+ with self._client.stream("GET", status_url, headers=headers, timeout=timeout) as response:
+ if response.status_code not in {200, 202}:
+ response.read()
+ error_type, title = _problem_detail(response.text)
+ raise JobFailedError(
+ title or f"Job status request failed with HTTP {response.status_code}",
+ status_code=response.status_code,
+ error_type=error_type,
+ request_id=request_id,
+ )
+ data_lines: list[str] = []
+ for raw_line in response.iter_lines():
+ line = raw_line.rstrip("\r")
+ if line:
+ if line.startswith("data:"):
+ data_lines.append(line[5:].lstrip())
+ continue
+ if data_lines:
+ payload = "\n".join(data_lines).strip()
+ data_lines = []
+ if payload:
+ yield cast("dict[str, Any]", json.loads(payload))
+ if data_lines:
+ payload = "\n".join(data_lines).strip()
+ if payload:
+ yield cast("dict[str, Any]", json.loads(payload))
+
+ def _await_job(self, status_url: str, request_id: str) -> tuple[bool, str]:
+ """Follow a job to completion. Returns (failed, result location)."""
+ for event in self._iter_job_events(status_url, request_id):
+ status = event.get("status")
+ if status == "running":
+ continue
+ if status in {"completed", "failed"}:
+ return status == "failed", str(event["location"])
+ raise JobFailedError(
+ "The job status stream closed before the job finished.",
+ request_id=request_id,
+ )
+
+ def _request_async_bytes(
+ self,
+ endpoint: Literal["conversions", "extractions", "transformations"],
+ method: str,
+ file_path: Path,
+ params: dict[str, Any] | None = None,
+ ) -> bytes:
+ """Run an operation as an asynchronous job and return the resulting bytes.
+
+ Unlike the synchronous helpers, this submits the work with
+ ``Prefer: respond-async`` and then follows the job to completion, so
+ documents large enough to exceed the synchronous request window are
+ processed successfully instead of timing out on the client.
+
+ Raises:
+ JobFailedError: if the submission, the job, or the download fails.
+ """
+ request_id = str(uuid.uuid4())
+ headers = {
+ "Authorization": f"Bearer {self._get_token()}",
+ "Prefer": "respond-async",
+ "X-Analytics-Session-Id": request_id,
+ }
+ mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
+ files = {"file": (file_path.name, file_path.read_bytes(), mime_type)}
+ data = {"method": method, "params": json.dumps(params or {})}
+
+ submit = self._client.post(
+ f"{self._settings.platform_base_url}/{endpoint}",
+ headers=headers,
+ files=files,
+ data=data,
+ )
+ if submit.status_code != HTTP_ACCEPTED:
+ error_type, title = _problem_detail(submit.text)
+ raise JobFailedError(
+ title or f"Job submission failed with HTTP {submit.status_code}",
+ status_code=submit.status_code,
+ error_type=error_type,
+ request_id=request_id,
+ )
+
+ status_url = submit.headers.get("Location")
+ if not status_url:
+ raise JobFailedError(
+ "The job was accepted but no Location header was returned.",
+ status_code=submit.status_code,
+ request_id=request_id,
+ )
+
+ failed, result_url = self._await_job(status_url, request_id)
+ auth = {
+ "Authorization": f"Bearer {self._get_token()}",
+ "X-Analytics-Session-Id": request_id,
+ }
+
+ if failed:
+ error = self._client.get(result_url, headers=auth)
+ error_type, title = _problem_detail(error.text)
+ raise JobFailedError(
+ title or "The job failed.",
+ status_code=error.status_code,
+ error_type=error_type,
+ request_id=request_id,
+ )
+
+ # Without this Accept header the result endpoint returns the job's
+ # JSON representation rather than the output file itself.
+ result = self._client.get(
+ result_url, headers={**auth, "Accept": "application/octet-stream"}
+ )
+ if result.status_code != HTTP_OK:
+ error_type, title = _problem_detail(result.text)
+ raise JobFailedError(
+ title or f"Result download failed with HTTP {result.status_code}",
+ status_code=result.status_code,
+ error_type=error_type,
+ request_id=request_id,
+ )
+ return result.content
+
+ def optimize(self, file_path: Path, profile: str = "minimal-file-size") -> bytes:
+ """Optimize (compress) a PDF using an optimization profile.
+
+ Profiles are ``minimal-file-size``, ``web``, ``print``, ``archive`` and
+ ``mixed-raster-content``. This runs as an asynchronous job, so it works
+ for large documents as well as small ones.
+
+ Args:
+ file_path: The PDF to optimize.
+ profile: The optimization profile to apply.
+
+ Returns:
+ The optimized PDF as bytes.
+ """
+ return self._request_async_bytes(
+ "transformations", "optimize", file_path, {"profile": profile}
+ )
+
def convert(self, file_path: Path, to_format: str) -> bytes:
"""Convert document to specified format."""
return self._request_bytes("conversions", "convert", file_path, {"to": to_format})
diff --git a/samples/python/benchmark/__init__.py b/samples/python/benchmark/__init__.py
new file mode 100644
index 0000000..ef39504
--- /dev/null
+++ b/samples/python/benchmark/__init__.py
@@ -0,0 +1,15 @@
+"""Optimize API benchmark: operations, and CSV/HTML reporting."""
+
+from __future__ import annotations
+
+from .operations import OperationResult, run_optimize
+from .report import summarise, write_csv, write_html, write_summary_csv
+
+__all__ = [
+ "OperationResult",
+ "run_optimize",
+ "summarise",
+ "write_csv",
+ "write_html",
+ "write_summary_csv",
+]
diff --git a/samples/python/benchmark/assets/nitro_logo.png b/samples/python/benchmark/assets/nitro_logo.png
new file mode 100644
index 0000000..a9c5543
Binary files /dev/null and b/samples/python/benchmark/assets/nitro_logo.png differ
diff --git a/samples/python/benchmark/assets/report.css b/samples/python/benchmark/assets/report.css
new file mode 100644
index 0000000..0e9f797
--- /dev/null
+++ b/samples/python/benchmark/assets/report.css
@@ -0,0 +1,148 @@
+/* Styles for the benchmark report. Inlined into the generated HTML so the
+ report stays a single self-contained file. Colour tokens are substituted
+ from report.py. */
+
+:root {
+ --orange: __ORANGE__;
+ --orange-soft: __ORANGE_SOFT__;
+ --ink: __INK__;
+ --ink-soft: __INK_SOFT__;
+ --muted: __MUTED__;
+ --line: __LINE__;
+ --surface: __SURFACE__;
+ --canvas: __CANVAS__;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--canvas);
+ color: var(--ink);
+ font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ line-height: 1.45;
+}
+
+.hero {
+ background: var(--ink);
+ color: #fff;
+ padding: 24px 0 28px;
+ border-bottom: 4px solid var(--orange);
+}
+.hero .wrap { display: flex; align-items: center; gap: 18px; }
+.hero h1 { margin: 0; font-size: 22px; letter-spacing: -0.01em; }
+.hero .sub { margin: 3px 0 0; color: #c7cad6; font-size: 13px; }
+
+.logo { width: 52px; height: 52px; border-radius: 12px; background: #fff; padding: 6px; }
+.logo-fallback {
+ width: 52px;
+ height: 52px;
+ border-radius: 12px;
+ background: var(--orange);
+ color: #fff;
+ display: grid;
+ place-items: center;
+ font-weight: 800;
+ font-size: 26px;
+}
+
+.wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; }
+.content { padding: 22px 0 40px; }
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 20px 22px;
+ margin-bottom: 18px;
+ box-shadow: 0 1px 2px rgba(27, 31, 46, 0.04);
+}
+.card h2 { margin: 0 0 12px; font-size: 16px; }
+
+.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
+.kpi {
+ background: var(--canvas);
+ border-radius: 12px;
+ padding: 14px 16px;
+ border-left: 4px solid var(--line);
+}
+.kpi.hl { border-left-color: var(--orange); background: var(--orange-soft); }
+.kpi b { display: block; font-size: 26px; letter-spacing: -0.02em; }
+.kpi.hl b { color: var(--orange); }
+.kpi span { font-size: 12px; color: var(--muted); }
+
+table { border-collapse: collapse; width: 100%; font-size: 13px; }
+th, td {
+ padding: 9px 10px;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ white-space: nowrap;
+}
+th {
+ background: var(--canvas);
+ font-weight: 600;
+ color: var(--ink-soft);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+tr.fail td { background: #fff7f4; }
+.scroll { overflow-x: auto; }
+
+.pill {
+ display: inline-block;
+ padding: 2px 9px;
+ border-radius: 999px;
+ background: var(--orange-soft);
+ color: var(--orange);
+ font-weight: 600;
+ font-size: 12px;
+}
+.ok { color: #1f8f5f; font-weight: 600; }
+.bad { color: #c43d1e; font-weight: 600; }
+
+svg { max-width: 100%; height: auto; }
+
+.chart-head { display: flex; align-items: center; gap: 10px; margin: 0 0 6px; }
+.chart-head label { font-size: 13px; color: var(--muted); }
+
+.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 12px; }
+.toolbar .grow { flex: 1; }
+.toolbar label { font-size: 13px; color: var(--muted); }
+
+select, button {
+ font: inherit;
+ font-size: 13px;
+ padding: 6px 10px;
+ border-radius: 8px;
+ border: 1px solid var(--line);
+ background: #fff;
+ color: var(--ink);
+}
+button { cursor: pointer; }
+button:hover { border-color: #c9ccd6; }
+button.sm { font-size: 12px; padding: 4px 10px; border-radius: 6px; color: var(--ink-soft); }
+button:disabled { opacity: 0.45; cursor: default; }
+
+.pager {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: flex-end;
+ margin-top: 12px;
+ font-size: 13px;
+ color: var(--muted);
+}
+.foot { color: var(--muted); font-size: 12px; text-align: center; padding: 8px 0 24px; }
+
+@media print {
+ @page { margin: 14mm; }
+ body { background: #fff; }
+ .hero { padding: 14px 0 16px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+ .kpi, .pill, tr.fail td { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+ .toolbar label, .toolbar button, .pager button, .foot { display: none !important; }
+ .card { box-shadow: none; border-color: #ddd; break-inside: avoid; page-break-inside: avoid; }
+ .scroll { overflow: visible; }
+ table { font-size: 11px; }
+ th, td { white-space: normal; }
+}
diff --git a/samples/python/benchmark/assets/report.js b/samples/python/benchmark/assets/report.js
new file mode 100644
index 0000000..454c093
--- /dev/null
+++ b/samples/python/benchmark/assets/report.js
@@ -0,0 +1,148 @@
+/* Interactivity for the benchmark report: chart switching, and paging,
+ filtering and CSV export of the per-file table.
+
+ The first page of rows and every chart are rendered server-side, so the
+ report still shows its data if scripts are blocked. This script only
+ enhances what is already there. `__DATA__` is replaced by report.py with
+ the full result set as JSON. */
+
+const DATA = __DATA__;
+
+const $ = (id) => document.getElementById(id);
+
+const fmtBytes = (n) => {
+ if (n == null) return '-';
+ if (n >= 1048576) return (n / 1048576).toFixed(2) + ' MB';
+ if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
+ return n + ' B';
+};
+
+const fmtPct = (v) => (v == null ? '-' : v.toFixed(1) + '%');
+
+const esc = (s) =>
+ String(s ?? '').replace(/[&<>"]/g, (c) => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ }[c]));
+
+let page = 1;
+let savedPageSize = null;
+
+/* Chart picker: every profile's chart is pre-rendered, so switching is just
+ a matter of toggling which one is visible. */
+function initChartPicker() {
+ const picker = $('chartProfile');
+ if (!picker) return;
+ picker.onchange = () => {
+ document.querySelectorAll('.chartbox').forEach((el) => {
+ el.hidden = el.getAttribute('data-chart') !== picker.value;
+ });
+ };
+}
+
+function filteredRows() {
+ const profile = $('fVariant') ? $('fVariant').value : '';
+ const status = $('fStatus').value;
+ return DATA.filter((r) => (!profile || r.profile === profile) && (!status || r.status === status));
+}
+
+function rowHtml(r, index) {
+ const ok = r.status === 'success';
+ const status = ok
+ ? '● success '
+ : '● failed ';
+ const detail = ok
+ ? ''
+ : `HTTP ${r.http_status ?? '-'} · ${esc(r.error_type || '')} · ` +
+ `${esc(r.error_message || '')} · req ${esc(r.request_id || '-')}`;
+ const reduction = ok ? `${fmtPct(r.reduction_pct)} ` : '-';
+ return (
+ `
${index} ${esc(r.file)} ` +
+ `${esc(r.profile)} ${status} ` +
+ `${fmtBytes(r.input_bytes)} ${fmtBytes(r.output_bytes)} ` +
+ `${reduction} ${r.duration_s} s ${detail} `
+ );
+}
+
+function render() {
+ const rows = filteredRows();
+ const sizeValue = $('pageSize').value;
+ const size = sizeValue === 'all' ? rows.length || 1 : parseInt(sizeValue, 10);
+ const pages = Math.max(1, Math.ceil(rows.length / size));
+ page = Math.min(page, pages);
+
+ const start = (page - 1) * size;
+ const slice = rows.slice(start, start + size);
+
+ $('rows').innerHTML =
+ slice.map((r, i) => rowHtml(r, start + i + 1)).join('') ||
+ 'No rows match. ';
+
+ const shown = `Showing ${start + 1}-${Math.min(start + size, rows.length)} of ${rows.length}`;
+ $('range').textContent = rows.length ? shown : '0 rows';
+ $('pageInfo').textContent = `Page ${page} / ${pages}`;
+ $('prev').disabled = page <= 1;
+ $('next').disabled = page >= pages;
+}
+
+function toCsv(rows) {
+ const cols = Object.keys(rows[0]);
+ const quote = (v) => {
+ const s = v == null ? '' : String(v);
+ return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
+ };
+ const body = rows.map((r) => cols.map((c) => quote(r[c])).join(','));
+ return [cols.join(',')].concat(body).join('\n');
+}
+
+function initControls() {
+ $('prev').onclick = () => {
+ page--;
+ render();
+ };
+ $('next').onclick = () => {
+ page++;
+ render();
+ };
+ const reset = () => {
+ page = 1;
+ render();
+ };
+ $('pageSize').onchange = reset;
+ $('fStatus').onchange = reset;
+ if ($('fVariant')) $('fVariant').onchange = reset;
+
+ $('dl').onclick = () => {
+ const rows = filteredRows();
+ if (!rows.length) return;
+ const blob = new Blob([toCsv(rows)], { type: 'text/csv' });
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = 'nitro_optimize_results.csv';
+ link.click();
+ };
+}
+
+/* If the report is printed from the browser menu, show every row first so the
+ printed copy is complete, then put the page size back afterwards. */
+function initPrintHandlers() {
+ window.addEventListener('beforeprint', () => {
+ savedPageSize = $('pageSize').value;
+ $('pageSize').value = 'all';
+ page = 1;
+ render();
+ });
+ window.addEventListener('afterprint', () => {
+ if (savedPageSize === null) return;
+ $('pageSize').value = savedPageSize;
+ savedPageSize = null;
+ render();
+ });
+}
+
+initChartPicker();
+initControls();
+initPrintHandlers();
+render();
diff --git a/samples/python/benchmark/operations.py b/samples/python/benchmark/operations.py
new file mode 100644
index 0000000..d40b3c5
--- /dev/null
+++ b/samples/python/benchmark/operations.py
@@ -0,0 +1,192 @@
+"""Running one optimization job and recording an honest result for it.
+
+The point of this module is that a result is only ever recorded as a success
+when the API actually returned a usable PDF for the profile that was asked for.
+Anything else, including an empty or non-PDF response, is recorded as a failure
+with the detail needed to diagnose it.
+"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+import httpx
+
+from api.platform_api import JobFailedError
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from api.platform_api import PlatformAPIClient
+
+PDF_MAGIC = b"%PDF-"
+
+# Optimization profiles, with what each one is suited for.
+PROFILES: dict[str, str] = {
+ "minimal-file-size": (
+ "Aggressively downsamples images and strips redundant data to produce the "
+ "smallest possible file. Use when size is the priority."
+ ),
+ "web": (
+ "Balances file size against on-screen quality. Use for web publishing, "
+ "email and online viewing."
+ ),
+ "print": (
+ "Keeps print-resolution images, favouring fidelity over size. Use when the "
+ "document will be printed at high quality."
+ ),
+ "archive": (
+ "Reduces size while keeping the document suitable for long-term storage and "
+ "conversion to PDF/A. Use for archival and compliance."
+ ),
+ "mixed-raster-content": (
+ "MRC compression, which separates text from the background layer. Use for "
+ "scanned documents."
+ ),
+}
+
+DEFAULT_PROFILE = "minimal-file-size"
+
+
+@dataclass
+class OperationResult:
+ """The outcome of optimizing one file with one profile."""
+
+ file: str
+ operation: str
+ variant: str # the profile that actually produced this row, never assumed
+ status: str # "success" or "failed"
+ input_bytes: int
+ output_bytes: int | None = None
+ reduction_pct: float | None = None
+ duration_ms: int = 0
+ output_path: str | None = None
+ http_status: int | None = None
+ error_type: str | None = None
+ error_message: str | None = None
+ request_id: str | None = None
+ job_id: str | None = None
+
+
+def _is_pdf(content: bytes) -> bool:
+ """Report whether the bytes look like a real, non-empty PDF."""
+ return len(content) > 0 and content.lstrip()[:5] == PDF_MAGIC
+
+
+def _failure(
+ pdf_path: Path,
+ profile: str,
+ input_bytes: int,
+ duration_ms: int,
+ *,
+ http_status: int | None,
+ error_type: str | None,
+ error_message: str,
+ request_id: str | None,
+) -> OperationResult:
+ """Build a failed result."""
+ return OperationResult(
+ file=pdf_path.name,
+ operation="optimize",
+ variant=profile,
+ status="failed",
+ input_bytes=input_bytes,
+ duration_ms=duration_ms,
+ http_status=http_status,
+ error_type=error_type,
+ error_message=error_message,
+ request_id=request_id,
+ )
+
+
+def run_optimize(
+ client: PlatformAPIClient, pdf_path: Path, profile: str, output_dir: Path
+) -> OperationResult:
+ """Optimize one PDF with one profile and record what actually happened.
+
+ Args:
+ client: The Platform API client to run the job through.
+ pdf_path: The PDF to optimize.
+ profile: The optimization profile to apply.
+ output_dir: Where the optimized PDF is written.
+
+ Returns:
+ An OperationResult, marked failed unless a valid PDF came back.
+ """
+ input_bytes = pdf_path.stat().st_size
+ started = time.perf_counter()
+
+ try:
+ content = client.optimize(pdf_path, profile)
+ except JobFailedError as exc:
+ return _failure(
+ pdf_path,
+ profile,
+ input_bytes,
+ int((time.perf_counter() - started) * 1000),
+ http_status=exc.status_code,
+ error_type=exc.error_type,
+ error_message=exc.message,
+ request_id=exc.request_id,
+ )
+ except httpx.HTTPStatusError as exc:
+ # e.g. a 401 from the token endpoint: caught here so a failed run is
+ # recorded rather than the traceback (with its locals) being dumped.
+ return _failure(
+ pdf_path,
+ profile,
+ input_bytes,
+ int((time.perf_counter() - started) * 1000),
+ http_status=exc.response.status_code,
+ error_type="HTTPStatusError",
+ error_message=f"HTTP {exc.response.status_code} from {exc.request.url.path}",
+ request_id=None,
+ )
+ except httpx.HTTPError as exc:
+ return _failure(
+ pdf_path,
+ profile,
+ input_bytes,
+ int((time.perf_counter() - started) * 1000),
+ http_status=None,
+ error_type=type(exc).__name__,
+ error_message=str(exc) or "The request could not be completed.",
+ request_id=None,
+ )
+
+ duration_ms = int((time.perf_counter() - started) * 1000)
+
+ # A 2xx is not a success on its own: the body has to be a usable PDF.
+ if not _is_pdf(content):
+ return _failure(
+ pdf_path,
+ profile,
+ input_bytes,
+ duration_ms,
+ http_status=200,
+ error_type="InvalidOutput",
+ error_message=f"The response was not a valid PDF ({len(content)} bytes).",
+ request_id=None,
+ )
+
+ target_dir = output_dir / "optimize" / profile
+ target_dir.mkdir(parents=True, exist_ok=True)
+ output_path = target_dir / pdf_path.name
+ output_path.write_bytes(content)
+
+ output_bytes = len(content)
+ reduction = (1.0 - output_bytes / input_bytes) * 100.0 if input_bytes else 0.0
+
+ return OperationResult(
+ file=pdf_path.name,
+ operation="optimize",
+ variant=profile,
+ status="success",
+ input_bytes=input_bytes,
+ output_bytes=output_bytes,
+ reduction_pct=round(reduction, 2),
+ duration_ms=duration_ms,
+ output_path=str(output_path),
+ )
diff --git a/samples/python/benchmark/report.py b/samples/python/benchmark/report.py
new file mode 100644
index 0000000..9c82c94
--- /dev/null
+++ b/samples/python/benchmark/report.py
@@ -0,0 +1,649 @@
+"""Reporting: per-file CSV, aggregate summary, and a self-contained HTML report.
+
+The HTML report has no external dependencies. The stylesheet and script live
+alongside this module in ``assets/`` and are inlined at build time, as is the
+logo and every chart, so the finished report is one file that opens straight
+from disk and can be handed to someone else as-is.
+"""
+
+from __future__ import annotations
+
+import base64
+import csv
+import html
+import json
+import math
+import statistics
+from collections import defaultdict
+from dataclasses import asdict, fields
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from .operations import OperationResult
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+CSV_FIELDS = [f.name for f in fields(OperationResult)]
+HERE = Path(__file__).resolve().parent
+ASSETS = HERE / "assets"
+LOGO_PATH = ASSETS / "nitro_logo.png"
+
+# Nitro brand palette.
+ORANGE = "#f54811"
+ORANGE_SOFT = "#fde9e1"
+INK = "#1b1f2e"
+INK_SOFT = "#3d4257"
+MUTED = "#6b7084"
+LINE = "#e6e7ec"
+SURFACE = "#ffffff"
+CANVAS = "#f6f6f8"
+BAR = "#f3946e" # softer orange for chart bars; brand orange stays for accents
+BAD = "#b9bdcb"
+# Per-profile series colours, used when several profiles are charted together.
+SERIES_COLORS = ("#f3946e", "#4c72b0", "#55a868", "#8172b3", "#c9a227")
+
+BYTES_PER_MB = 1_048_576
+BYTES_PER_KB = 1024
+DEFAULT_PAGE_SIZE = 10
+MAX_COMPLEXITY_BINS = 100
+
+_TABLE_HEADERS = (
+ "#",
+ "File",
+ "Profile",
+ "Status",
+ "Input",
+ "Output",
+ "Reduction",
+ "Time",
+ "Failure detail",
+)
+
+
+# ----------------------------------------------------------------------- csv --
+def write_csv(results: list[OperationResult], path: Path) -> None:
+ """Write one CSV row per file and profile."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(handle, fieldnames=CSV_FIELDS)
+ writer.writeheader()
+ for result in results:
+ writer.writerow(asdict(result))
+
+
+def summarise(results: list[OperationResult]) -> list[dict[str, object]]:
+ """Aggregate the results into one row per profile."""
+ groups: dict[tuple[str, str], list[OperationResult]] = defaultdict(list)
+ for result in results:
+ groups[result.operation, result.variant].append(result)
+
+ rows: list[dict[str, object]] = []
+ for (operation, variant), items in sorted(groups.items()):
+ succeeded = [r for r in items if r.status == "success"]
+ reductions = [r.reduction_pct for r in succeeded if r.reduction_pct is not None]
+ total_in = sum(r.input_bytes for r in succeeded)
+ total_out = sum(r.output_bytes or 0 for r in succeeded)
+ overall = round((1 - total_out / total_in) * 100, 2) if total_in else None
+ mean_duration = (
+ round(statistics.mean(r.duration_ms for r in items) / 1000, 2) if items else None
+ )
+ rows.append({
+ "operation": operation,
+ "variant": variant,
+ "files": len(items),
+ "succeeded": len(succeeded),
+ "failed": len(items) - len(succeeded),
+ "mean_reduction_pct": round(statistics.mean(reductions), 2) if reductions else None,
+ "median_reduction_pct": (
+ round(statistics.median(reductions), 2) if reductions else None
+ ),
+ "total_input_mb": round(total_in / BYTES_PER_MB, 2),
+ "total_output_mb": round(total_out / BYTES_PER_MB, 2),
+ "overall_reduction_pct": overall,
+ "mean_duration_s": mean_duration,
+ })
+ return rows
+
+
+def write_summary_csv(summary: list[dict[str, object]], path: Path) -> None:
+ """Write the aggregate summary as CSV."""
+ if not summary:
+ return
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(handle, fieldnames=list(summary[0].keys()))
+ writer.writeheader()
+ writer.writerows(summary)
+
+
+# ------------------------------------------------------------------- helpers --
+def _logo_data_uri() -> str | None:
+ """Return the Nitro logo as a data URI, or None if it cannot be read."""
+ try:
+ encoded = base64.b64encode(LOGO_PATH.read_bytes()).decode("ascii")
+ except OSError:
+ return None
+ return f"data:image/png;base64,{encoded}"
+
+
+def _fmt_pct(value: object) -> str:
+ """Format a percentage, or a dash when there is nothing to show."""
+ return f"{value:.1f}%" if isinstance(value, (int, float)) else "-"
+
+
+def _fmt_bytes(value: object) -> str:
+ """Format a byte count in human-readable units."""
+ if not isinstance(value, (int, float)):
+ return "-"
+ if value >= BYTES_PER_MB:
+ return f"{value / BYTES_PER_MB:.2f} MB"
+ if value >= BYTES_PER_KB:
+ return f"{value / BYTES_PER_KB:.1f} KB"
+ return f"{int(value)} B"
+
+
+def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
+ """Render a plain HTML table."""
+ head = "".join(f"{html.escape(h)} " for h in headers)
+ body = "".join("" + "".join(f"{cell} " for cell in row) + " " for row in rows)
+ return f""
+
+
+# -------------------------------------------------------------------- charts --
+def _bin_labels(bin_width: float, n_bins: int, *, has_growth: bool) -> list[str]:
+ """Build the x-axis labels: an optional growth bin, then 0-100% in steps."""
+ steps = n_bins - (1 if has_growth else 0)
+ labels = ["< 0"] if has_growth else []
+ labels.extend(f"{int(i * bin_width)}-{int((i + 1) * bin_width)}" for i in range(steps))
+ return labels
+
+
+def _bin_counts(
+ values: list[float], bin_width: float, n_bins: int, *, has_growth: bool
+) -> list[int]:
+ """Count values into the fixed bins."""
+ offset = 1 if has_growth else 0
+ steps = n_bins - offset
+ counts = [0] * n_bins
+ for value in values:
+ if value < 0:
+ counts[0] += 1
+ else:
+ counts[min(steps - 1, int(value // bin_width)) + offset] += 1
+ return counts
+
+
+def _axis_and_grid(pad_l: int, pad_t: int, plot_w: int, plot_h: int, y_top: int) -> list[str]:
+ """Draw the horizontal gridlines and their y-axis labels."""
+ parts: list[str] = []
+ for step in range(5):
+ value = y_top * step / 4
+ y = pad_t + plot_h - (value / y_top) * plot_h
+ parts.append(
+ f' '
+ )
+ parts.append(
+ f'{value:.0f} '
+ )
+ return parts
+
+
+def _legend(names: Sequence[str]) -> list[str]:
+ """Draw a colour legend, one entry per profile."""
+ parts: list[str] = []
+ x = 0.0
+ for index, name in enumerate(names):
+ colour = SERIES_COLORS[index % len(SERIES_COLORS)]
+ parts.append(f' ')
+ parts.append(f'{html.escape(name)} ')
+ x += 16 + len(name) * 6.6 + 20
+ return parts
+
+
+def _hist_svg(series: dict[str, list[float]], title: str, bin_width: float = 10.0) -> str:
+ """Render the distribution of size reduction as an inline SVG histogram.
+
+ ``series`` maps profile name to that profile's per-file reduction
+ percentages. One profile gives a plain histogram; several gives grouped
+ bars with a legend, so the distributions can be compared side by side.
+
+ The x-axis is always 0-100%. A single leading "< 0" bin is added only if
+ some file actually came out larger than its input.
+ """
+ populated = {name: values for name, values in series.items() if values}
+ if not populated:
+ return ""
+
+ names = list(populated)
+ multi = len(names) > 1
+ all_values = [v for values in populated.values() for v in values]
+ has_growth = any(v < 0 for v in all_values)
+
+ steps = round(MAX_COMPLEXITY_BINS / bin_width)
+ n_bins = steps + (1 if has_growth else 0)
+ labels = _bin_labels(bin_width, n_bins, has_growth=has_growth)
+ binned = {
+ name: _bin_counts(values, bin_width, n_bins, has_growth=has_growth)
+ for name, values in populated.items()
+ }
+
+ max_count = max((c for counts in binned.values() for c in counts), default=0) or 1
+ # Headroom above the tallest bar so its count label never crowds the title.
+ y_top = max(4, math.ceil(max_count * 1.25))
+ y_top += -y_top % 4 # round up to a multiple of 4 for whole-number gridlines
+
+ legend_h = 22 if multi else 0
+ pad_l, pad_r, pad_t, pad_b = 46, 16, 66 + legend_h, 46
+ plot_w, plot_h = 720, 250
+ width, height = pad_l + plot_w + pad_r, pad_t + plot_h + pad_b
+ group_w = plot_w / n_bins
+
+ subtitle = (
+ f"{len(all_values)} results across {len(names)} profiles · bins of {bin_width:g}%"
+ if multi
+ else f"{len(all_values)} files · bins of {bin_width:g}%"
+ )
+ parts = [
+ f'',
+ f''
+ f"{html.escape(title)} ",
+ f'{subtitle} ',
+ ]
+ if multi:
+ parts.extend(_legend(names))
+ parts.extend(_axis_and_grid(pad_l, pad_t, plot_w, plot_h, y_top))
+ parts.extend(
+ _bars(
+ binned=binned,
+ labels=labels,
+ names=names,
+ geometry=(pad_l, pad_t, plot_h, group_w),
+ y_top=y_top,
+ multi=multi,
+ has_growth=has_growth,
+ )
+ )
+ centre_x = pad_l + plot_w / 2
+ centre_y = pad_t + plot_h / 2
+ parts.append(
+ f'size reduction (%) '
+ )
+ parts.append(
+ f'files '
+ )
+ parts.append(" ")
+ return "".join(parts)
+
+
+def _bars(
+ *,
+ binned: dict[str, list[int]],
+ labels: Sequence[str],
+ names: Sequence[str],
+ geometry: tuple[int, int, int, float],
+ y_top: int,
+ multi: bool,
+ has_growth: bool,
+) -> list[str]:
+ """Draw the bars and their x-axis labels."""
+ pad_l, pad_t, plot_h, group_w = geometry
+ inner_pad = 2.0
+ slot_w = (group_w - 2 * inner_pad) / len(names)
+ parts: list[str] = []
+
+ for index, label in enumerate(labels):
+ group_x = pad_l + index * group_w
+ for series_index, name in enumerate(names):
+ count = binned[name][index]
+ bar_h = (count / y_top) * plot_h
+ y = pad_t + plot_h - bar_h
+ x = group_x + inner_pad + series_index * slot_w
+ if multi:
+ colour = SERIES_COLORS[series_index % len(SERIES_COLORS)]
+ else:
+ colour = BAD if (has_growth and index == 0) else BAR
+ bar_w = max(slot_w - (1.5 if multi else 4), 1.5)
+ tooltip = f"{html.escape(name)}: {count} file(s), {html.escape(label)}%"
+ parts.append(
+ f'{tooltip} '
+ )
+ if count and not multi:
+ parts.append(
+ f'{count} '
+ )
+ parts.append(
+ f'{html.escape(label)} '
+ )
+ return parts
+
+
+# ---------------------------------------------------------------- html pieces --
+def _report_css() -> str:
+ """Load the stylesheet and substitute the palette."""
+ css = (ASSETS / "report.css").read_text(encoding="utf-8")
+ tokens = {
+ "__ORANGE__": ORANGE,
+ "__ORANGE_SOFT__": ORANGE_SOFT,
+ "__INK__": INK,
+ "__INK_SOFT__": INK_SOFT,
+ "__MUTED__": MUTED,
+ "__LINE__": LINE,
+ "__SURFACE__": SURFACE,
+ "__CANVAS__": CANVAS,
+ }
+ for token, colour in tokens.items():
+ css = css.replace(token, colour)
+ return css
+
+
+def _report_js(rows: list[dict[str, object]]) -> str:
+ """Load the script and embed the result data in it."""
+ script = (ASSETS / "report.js").read_text(encoding="utf-8")
+ # Escaping "" keeps a stray sequence in the data from closing the tag.
+ payload = json.dumps(rows).replace("", "<\\/")
+ return script.replace("__DATA__", payload)
+
+
+def _kpis(results: list[OperationResult]) -> str:
+ """Render the headline numbers."""
+ total = len(results)
+ succeeded = [r for r in results if r.status == "success"]
+ total_in = sum(r.input_bytes for r in succeeded)
+ total_out = sum(r.output_bytes or 0 for r in succeeded)
+ reductions = [r.reduction_pct for r in succeeded if r.reduction_pct is not None]
+
+ mean_reduction = f"{statistics.mean(reductions):.1f}%" if reductions else "-"
+ overall = f"{(1 - total_out / total_in) * 100:.1f}%" if total_in else "-"
+ saved_mb = (total_in - total_out) / BYTES_PER_MB if total_in else 0.0
+ failed = total - len(succeeded)
+
+ cells = [
+ ("hl", mean_reduction, "mean reduction per file"),
+ ("", overall, "overall size reduction"),
+ ("", f"{saved_mb:.1f} MB", "saved across successful files"),
+ ("", str(len({r.file for r in results})), "input files"),
+ ("", f"{len(succeeded)} / {total}", "successful runs"),
+ ("", str(failed), "failed runs"),
+ ]
+ tiles = "".join(
+ f'{value} {label}
'
+ for cls, value, label in cells
+ )
+ return f''
+
+
+def _summary_card(summary: list[dict[str, object]]) -> str:
+ """Render the per-profile summary table, shown only for multi-profile runs."""
+ if len(summary) <= 1:
+ return ""
+ rows = [
+ [
+ f'{html.escape(str(row["variant"]))} ',
+ str(row["files"]),
+ str(row["succeeded"]),
+ f'{row["failed"]} ',
+ f"{_fmt_pct(row['mean_reduction_pct'])} ",
+ _fmt_pct(row["median_reduction_pct"]),
+ f"{row['total_input_mb']} MB",
+ f"{row['total_output_mb']} MB",
+ f"{_fmt_pct(row['overall_reduction_pct'])} ",
+ f"{row['mean_duration_s']} s" if row["mean_duration_s"] is not None else "-",
+ ]
+ for row in summary
+ ]
+ headers = (
+ "Profile",
+ "Files",
+ "OK",
+ "Failed",
+ "Mean reduction",
+ "Median reduction",
+ "Total in",
+ "Total out",
+ "Overall reduction",
+ "Mean time / file",
+ )
+ table = _table(headers, rows)
+ return f'Summary by profile {table}
'
+
+
+def _chart_card(series: dict[str, list[float]], selected: str | None) -> str:
+ """Render one chart per profile plus a comparison view, with a picker."""
+ if not series:
+ return ""
+ names = list(series)
+ chosen = selected if selected in series else names[0]
+
+ boxes: list[str] = []
+ options: list[str] = []
+ for name in names:
+ svg = _hist_svg({name: series[name]}, f"Distribution of size reduction: {name}")
+ hidden = "" if name == chosen else " hidden"
+ boxes.append(f'{svg}
')
+ selected_attr = " selected" if name == chosen else ""
+ options.append(
+ f'{html.escape(name)} '
+ )
+
+ if len(names) > 1:
+ combined = _hist_svg(series, "Distribution of size reduction by profile")
+ boxes.append(f'{combined}
')
+ options.append('All profiles (compare) ')
+ picker = f'Profile {"".join(options)} '
+ else:
+ picker = ""
+
+ head = f''
+ return f'{head}{"".join(boxes)}
'
+
+
+def _rows_for_table(
+ results: list[OperationResult], preferred: str | None
+) -> list[dict[str, object]]:
+ """Flatten the results for the table, with the chosen profile listed first."""
+
+ def sort_key(result: OperationResult) -> tuple[bool, str, float, str]:
+ return (
+ result.variant != preferred,
+ result.variant,
+ -(result.reduction_pct if result.reduction_pct is not None else -1e9),
+ result.file,
+ )
+
+ return [
+ {
+ "file": r.file,
+ "profile": r.variant,
+ "status": r.status,
+ "input_bytes": r.input_bytes,
+ "output_bytes": r.output_bytes,
+ "reduction_pct": r.reduction_pct,
+ "duration_s": round(r.duration_ms / 1000, 1),
+ "http_status": r.http_status,
+ "error_type": r.error_type,
+ "error_message": r.error_message,
+ "request_id": r.request_id,
+ "job_id": r.job_id,
+ }
+ for r in sorted(results, key=sort_key)
+ ]
+
+
+def _initial_tbody(rows: list[dict[str, object]]) -> str:
+ """Render the first page of rows server-side.
+
+ The table therefore still shows data where scripts do not run, such as
+ preview panes and some mail clients. The script takes over from there.
+ """
+ out: list[str] = []
+ for index, row in enumerate(rows[:DEFAULT_PAGE_SIZE], start=1):
+ succeeded = row["status"] == "success"
+ status = (
+ '● success '
+ if succeeded
+ else '● failed '
+ )
+ detail = (
+ ""
+ if succeeded
+ else html.escape(
+ f"HTTP {row['http_status'] if row['http_status'] is not None else '-'} "
+ f"| {row['error_type'] or ''} | {row['error_message'] or ''} "
+ f"| req {row['request_id'] or '-'}"
+ )
+ )
+ reduction = f"{_fmt_pct(row['reduction_pct'])} " if succeeded else "-"
+ cls = "" if succeeded else ' class="fail"'
+ out.append(
+ f"{index} {html.escape(str(row['file']))} "
+ f'{html.escape(str(row["profile"]))} '
+ f"{status} {_fmt_bytes(row['input_bytes'])} "
+ f"{_fmt_bytes(row['output_bytes'])} {reduction} "
+ f"{row['duration_s']} s {detail} "
+ )
+ return "".join(out)
+
+
+def _table_card(rows: list[dict[str, object]], variants: Sequence[str]) -> str:
+ """Render the per-file table with its filters, pager and export button."""
+ profile_filter = ""
+ if len(variants) > 1:
+ options = "".join(
+ f'{html.escape(v)} ' for v in variants
+ )
+ profile_filter = (
+ f'Profile All '
+ f"{options} "
+ )
+
+ shown = min(DEFAULT_PAGE_SIZE, len(rows))
+ initial_range = f"Showing 1-{shown} of {len(rows)}" if rows else "0 rows"
+ pages = max(1, math.ceil(len(rows) / DEFAULT_PAGE_SIZE))
+ next_disabled = " disabled" if pages <= 1 else ""
+ headers = "".join(f"{html.escape(h)} " for h in _TABLE_HEADERS)
+
+ return (
+ '"
+ )
+
+
+def _hero(title: str, profiles: Sequence[str]) -> str:
+ """Render the header banner, naming every profile that was actually run."""
+ logo = _logo_data_uri()
+ logo_html = (
+ f' '
+ if logo
+ else 'N
'
+ )
+ generated = datetime.now(tz=UTC).astimezone().strftime("%d %b %Y, %H:%M")
+ label = "profile" if len(profiles) == 1 else "profiles"
+ names = ", ".join(profiles) if profiles else "-"
+ return (
+ f'{logo_html}'
+ f"
{html.escape(title)} "
+ f'
Generated {generated} · {label}: '
+ f"{html.escape(names)}
"
+ "
"
+ )
+
+
+def _profiles_in_run_order(results: list[OperationResult]) -> list[str]:
+ """List the profiles in the order they were run, for the header and chart."""
+ profiles: list[str] = []
+ for result in results:
+ if result.variant not in profiles:
+ profiles.append(result.variant)
+ return profiles
+
+
+def _reduction_series(
+ results: list[OperationResult], profiles: list[str]
+) -> dict[str, list[float]]:
+ """Collect each profile's successful reduction percentages for the chart."""
+ succeeded = [r for r in results if r.status == "success"]
+ series: dict[str, list[float]] = {}
+ for name in profiles:
+ values = [
+ float(r.reduction_pct)
+ for r in succeeded
+ if r.variant == name and r.reduction_pct is not None
+ ]
+ if values:
+ series[name] = values
+ return series
+
+
+# ----------------------------------------------------------------------- html --
+def write_html(
+ results: list[OperationResult],
+ summary: list[dict[str, object]],
+ path: Path,
+ *,
+ title: str = "Nitro Optimize API Benchmark Report",
+ default_variant: str | None = None,
+) -> None:
+ """Write the self-contained HTML report.
+
+ Args:
+ results: Every per-file result from the run.
+ summary: The aggregate rows from ``summarise``.
+ path: Where the report is written.
+ title: The report heading.
+ default_variant: The profile to show first in the chart and table.
+ """
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ variants = sorted({r.variant for r in results})
+ preferred = (
+ default_variant if default_variant in variants else (variants[0] if variants else None)
+ )
+
+ profiles_in_run = _profiles_in_run_order(results)
+ series = _reduction_series(results, profiles_in_run)
+ rows = _rows_for_table(results, preferred)
+ body = "".join([
+ _kpis(results),
+ _summary_card(summary),
+ _chart_card(series, preferred),
+ _table_card(rows, variants),
+ '",
+ ])
+
+ doc = (
+ ' '
+ ' '
+ f"{html.escape(title)} "
+ f""
+ f"{_hero(title, profiles_in_run)}"
+ f'{body}
'
+ f""
+ ""
+ )
+ path.write_text(doc, encoding="utf-8")
diff --git a/samples/python/optimize_benchmark.py b/samples/python/optimize_benchmark.py
new file mode 100644
index 0000000..499400e
--- /dev/null
+++ b/samples/python/optimize_benchmark.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""
+š OPTIMIZE API BENCHMARK
+=========================
+
+This script shows how well the Nitro Optimize API compresses your own PDFs.
+
+When you are evaluating a PDF compression service, published numbers only go
+so far: what matters is how much smaller YOUR documents get. This script runs
+a folder of PDFs through the Optimize API and reports the size reduction for
+every file, so you can judge the results on your own content.
+
+Each PDF is submitted as an asynchronous optimization job (so large documents
+work too), the optimized file is saved to the output folder, and the run ends
+with three artefacts: a per-file CSV, a per-profile summary CSV, and a
+self-contained HTML report with the headline numbers, a distribution chart
+and a filterable per-file table. The report opens in your browser when done.
+
+BENCHMARK FEATURES:
+ ā Runs every optimization profile you select (default: minimal-file-size)
+ ā Per-file size reduction, timing and failure detail
+ ā Self-contained HTML report you can share as one file
+
+USAGE:
+ python optimize_benchmark.py [--profile ...]
+
+EXAMPLES:
+ python optimize_benchmark.py ./sample_pdfs ./output
+ python optimize_benchmark.py ./sample_pdfs ./output -p minimal-file-size -p web
+"""
+
+import webbrowser
+from enum import Enum
+from pathlib import Path
+from typing import TYPE_CHECKING, Annotated
+
+import typer
+
+from api.platform_api import PlatformAPIClient
+from benchmark import run_optimize, summarise, write_csv, write_html, write_summary_csv
+from benchmark.operations import PROFILES
+from helper_functions.document_helpers import validate_and_setup
+
+if TYPE_CHECKING:
+ from benchmark import OperationResult
+
+
+class Profile(str, Enum):
+ """Optimization profiles offered by the Optimize API."""
+
+ MINIMAL_FILE_SIZE = "minimal-file-size"
+ WEB = "web"
+ PRINT = "print"
+ ARCHIVE = "archive"
+ MIXED_RASTER_CONTENT = "mixed-raster-content"
+
+
+app = typer.Typer()
+
+HTTP_UNAUTHORIZED = 401
+
+
+def _echo_profiles(profiles: list[Profile]) -> None:
+ """Show which profiles will run, with what each is suited for."""
+ typer.echo("Profiles to run:")
+ for profile in profiles:
+ typer.echo(f" ⢠{profile.value} ā {PROFILES[profile.value]}")
+ typer.echo("")
+
+
+def _run_all(
+ client: PlatformAPIClient,
+ files: list[Path],
+ profiles: list[Profile],
+ output_folder: Path,
+) -> list[OperationResult]:
+ """Run every file through every selected profile, echoing progress."""
+ results: list[OperationResult] = []
+ total_runs = len(files) * len(profiles)
+ run = 0
+ for chosen in profiles:
+ for file_path in files:
+ run += 1
+ typer.echo(f"[{run}/{total_runs}] {file_path.name} ({chosen.value}) ...", nl=False)
+ result = run_optimize(client, file_path, chosen.value, output_folder)
+ if result.status == "success" and result.reduction_pct is not None:
+ typer.echo(f" ā
{result.reduction_pct:.1f}% smaller")
+ else:
+ typer.echo(f" ā FAILED: {result.error_message}")
+ results.append(result)
+ if result.http_status == HTTP_UNAUTHORIZED and "/oauth/token" in (
+ result.error_message or ""
+ ):
+ typer.echo(
+ "\nā Authentication failed - check PLATFORM_CLIENT_ID and "
+ "PLATFORM_CLIENT_SECRET in your .env file."
+ )
+ raise typer.Exit(1)
+ return results
+
+
+@app.command()
+def main(
+ input_folder: Annotated[Path, typer.Argument(help="Folder containing the PDFs to benchmark")],
+ output_folder: Annotated[Path, typer.Argument(help="Folder for optimized PDFs and the report")],
+ profile: Annotated[
+ list[Profile] | None,
+ typer.Option(
+ "--profile",
+ "-p",
+ help="Optimization profile to run; repeat to benchmark several",
+ ),
+ ] = None,
+ *,
+ open_report: Annotated[
+ bool, typer.Option(help="Open the HTML report in a browser when done")
+ ] = True,
+) -> None:
+ """Benchmark the Optimize API on a folder of PDFs and produce an HTML report."""
+ profiles = profile or [Profile.MINIMAL_FILE_SIZE]
+ files = sorted(validate_and_setup(input_folder, output_folder, file_patterns=["*.pdf"]))
+ typer.echo(f"š Found {len(files)} PDF(s) in {input_folder}\n")
+ _echo_profiles(profiles)
+
+ # Initialize API client (loads credentials from .env)
+ client = PlatformAPIClient()
+ results = _run_all(client, files, profiles, output_folder)
+
+ summary = summarise(results)
+ report_path = output_folder / "report.html"
+ write_csv(results, output_folder / "results.csv")
+ write_summary_csv(summary, output_folder / "summary.csv")
+ write_html(results, summary, report_path, default_variant=profiles[0].value)
+
+ succeeded = sum(1 for r in results if r.status == "success")
+ typer.echo("\n" + "=" * 60)
+ typer.echo(f"ā
{succeeded}/{len(results)} run(s) succeeded")
+ typer.echo(f"š Report: {report_path.absolute()}")
+ typer.echo("=" * 60)
+
+ if open_report:
+ webbrowser.open(report_path.absolute().as_uri())
+
+
+if __name__ == "__main__":
+ app()
diff --git a/test_files/optimize-benchmark/image-heavy.pdf b/test_files/optimize-benchmark/image-heavy.pdf
new file mode 100644
index 0000000..04fd94f
Binary files /dev/null and b/test_files/optimize-benchmark/image-heavy.pdf differ
diff --git a/test_files/optimize-benchmark/mixed.pdf b/test_files/optimize-benchmark/mixed.pdf
new file mode 100644
index 0000000..bd58ecb
Binary files /dev/null and b/test_files/optimize-benchmark/mixed.pdf differ
diff --git a/test_files/optimize-benchmark/text-heavy.pdf b/test_files/optimize-benchmark/text-heavy.pdf
new file mode 100644
index 0000000..2c6faea
Binary files /dev/null and b/test_files/optimize-benchmark/text-heavy.pdf differ