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"{head}{body}
" + + +# -------------------------------------------------------------------- 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 " 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'
{tiles}
' + + +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'' + ) + + if len(names) > 1: + combined = _hist_svg(series, "Distribution of size reduction by profile") + boxes.append(f'') + options.append('') + picker = f'' + else: + picker = "" + + head = f'
{picker}
' + 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'' for v in variants + ) + profile_filter = ( + f'" + ) + + 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 ( + '
' + '
' + '

Per-file results

' + f"{profile_filter}" + '" + '' + '' + "
" + f'
{headers}' + f'{_initial_tbody(rows)}
' + f'
{initial_range}' + '' + f'Page 1 / {pages}' + f'
' + "
" + ) + + +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), + '

Nitro Optimize API Benchmark · ' + "results.csv and summary.csv accompany this report

", + ]) + + 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