Exporting Control Charts to PDF for Compliance Reporting

An interactive chart is for the shift; an audit needs a frozen artifact that renders identically years later, independent of any browser. This guide exports a Plotly figure to a static, byte-stable PDF, stamps it with the provenance an auditor will ask for, and assembles several charts into one report packet. It extends the dynamic Plotly control chart rendering layer into the archival step, turning a live figure into controlled documented information that satisfies ISO 9001:2015 clause 7.5.3 and stands as IATF 16949 evidence.

Prerequisites

Confirm the export toolchain and the metadata sources before you write a file:

  • Python 3.9+ with plotly and kaleido installed (pip install plotly kaleido); kaleido is Plotly's static image engine and needs no browser or network.
  • A fully rendered go.Figure with limits and any overlays already drawn — export freezes what is on the figure, so finish the UCL/LCL annotation overlays first.
  • Provenance fields available for the exact data window charted: process_id, timestamp_window (start and end), and a calculation_hash over the input partition and the frozen limits.
  • A deterministic layout: fixed width, height, and scale so two exports of the same figure produce identical bytes.
  • A write path under version control or object storage with retention, so the archived packet is retrievable for the full record-retention period your quality system mandates.

Step 1 — Export a single figure with kaleido

write_image with the kaleido engine renders the figure to a static PDF. Pin the dimensions and scale explicitly — leaving them to auto-size makes the output depend on runtime defaults and breaks byte stability. Set a fixed font so glyph metrics do not drift between machines.

import plotly.graph_objects as go


def export_figure_pdf(fig: go.Figure, path: str,
                      width: int = 1000, height: int = 700, scale: int = 2) -> str:
    """Write a Plotly figure to a static PDF via the kaleido engine.

    Dimensions and scale are pinned so repeated exports of an unchanged
    figure are byte-identical, which is what an audit trail requires.
    """
    # Freeze layout inputs that would otherwise vary by runtime default.
    fig.update_layout(width=width, height=height,
                      font=dict(family="DejaVu Sans", size=13),
                      template="plotly_white")
    fig.write_image(path, format="pdf", engine="kaleido",
                    width=width, height=height, scale=scale)
    return path

The plotly_white template and an explicit font keep the archived chart visually stable; the same figure exported on a laptop and a CI runner should differ by nothing.

Step 2 — Embed provenance metadata

A chart with no provenance is not evidence. Stamp the export with the identifiers that let an auditor reproduce it: which process, which time window, and a hash binding the charted data and limits together. The cleanest place for machine-readable provenance is a visible footer annotation plus a sidecar record; render the footer onto the figure before export so it travels inside the PDF itself.

import hashlib
import json
from datetime import datetime, timezone


def compute_calculation_hash(measurements, limits: dict) -> str:
    """Stable SHA-256 over the charted data and the frozen limits."""
    payload = {
        "measurements": [round(float(m), 6) for m in measurements],
        "limits": {k: round(float(v), 6) for k, v in limits.items()},
    }
    encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
    return hashlib.sha256(encoded).hexdigest()


def stamp_provenance(fig: go.Figure, process_id: str,
                     ts_start: str, ts_end: str, calc_hash: str) -> go.Figure:
    """Render a provenance footer onto the figure before PDF export."""
    exported_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    footer = (f"process_id={process_id}  |  window={ts_start} to {ts_end}  |  "
              f"calculation_hash={calc_hash[:12]}  |  exported={exported_at}")
    fig.add_annotation(text=footer, xref="paper", yref="paper",
                       x=0.0, y=-0.12, showarrow=False, xanchor="left",
                       font=dict(size=9, color="#6b7280"))
    fig.update_layout(margin=dict(b=90))
    return fig

Truncating the hash to twelve characters keeps the footer readable while the full digest lives in the sidecar record from Step 4. The window fields must be the exact bounds of the charted data, not the export time — an auditor reconstructs the dataset from process_id plus timestamp_window and re-hashes it to confirm the chart was never altered.

Step 3 — Assemble a multi-chart PDF packet

An audit packet usually holds several charts — one per characteristic or per line. Export each figure to a single-page PDF, then concatenate the pages into one document with pypdf. Keep each page's provenance footer so every chart in the packet is independently traceable.

from pathlib import Path
from pypdf import PdfWriter


def assemble_packet(figure_specs: list[dict], out_path: str) -> str:
    """Export several stamped figures and merge them into one audit PDF.

    Each spec: {"fig": go.Figure, "process_id": str, "ts_start": str,
                "ts_end": str, "measurements": seq, "limits": dict}
    """
    if not figure_specs:
        raise ValueError("No figures supplied for the packet.")

    writer = PdfWriter()
    tmp_dir = Path(out_path).with_suffix("")
    tmp_dir.mkdir(parents=True, exist_ok=True)

    for i, spec in enumerate(figure_specs):
        calc_hash = compute_calculation_hash(spec["measurements"], spec["limits"])
        fig = stamp_provenance(spec["fig"], spec["process_id"],
                               spec["ts_start"], spec["ts_end"], calc_hash)
        page_path = str(tmp_dir / f"page_{i:03d}.pdf")
        export_figure_pdf(fig, page_path)
        writer.append(page_path)

    with open(out_path, "wb") as fh:
        writer.write(fh)
    return out_path

Merging happens page-by-page so a corrupt or missing figure fails loudly at its own page rather than silently dropping out of the packet.

Step 4 — Write a sidecar provenance record

Alongside the PDF, write a JSON sidecar carrying the full untruncated hashes and window bounds. The sidecar is the machine-readable index a reproducibility check consumes; the PDF is the human-readable artifact. Keep them named as a pair so neither is archived without the other.

def write_sidecar(pdf_path: str, records: list[dict]) -> str:
    """Write the full provenance index next to the PDF packet."""
    sidecar_path = Path(pdf_path).with_suffix(".provenance.json")
    doc = {
        "pdf": Path(pdf_path).name,
        "exported_at": datetime.now(timezone.utc).isoformat(),
        "pages": records,   # each: process_id, timestamp_window, calculation_hash (full)
    }
    sidecar_path.write_text(json.dumps(doc, indent=2, sort_keys=True))
    return str(sidecar_path)

Verification

Prove byte stability and provenance integrity before trusting the export in an audit workflow. Two exports of an unchanged figure must be identical, and the recomputed hash must match the stamped one.

import numpy as np

measurements = list(np.round(np.linspace(9.5, 10.5, 20), 3))
limits = {"cl": 10.0, "ucl": 13.0, "lcl": 7.0}

# Deterministic hash: same inputs -> same digest.
h1 = compute_calculation_hash(measurements, limits)
h2 = compute_calculation_hash(measurements, limits)
assert h1 == h2, "Hash must be stable for identical inputs."

# Any change to the charted data changes the hash (tamper-evident).
tampered = measurements.copy(); tampered[0] += 0.001
assert compute_calculation_hash(tampered, limits) != h1

# Byte-stability: two exports of the same figure produce identical bytes.
fig = go.Figure(go.Scatter(y=measurements, mode="lines+markers"))
export_figure_pdf(fig, "a.pdf")
export_figure_pdf(fig, "b.pdf")
assert Path("a.pdf").read_bytes() == Path("b.pdf").read_bytes()
print("Export verified: hash stable, tamper-evident, byte-identical PDF.")

If the two PDFs differ, an unpinned layout value (auto width, a system font, or a timestamp baked into the figure title) is leaking into the output — remove it so the artifact is deterministic. If the tampered hash matches the original, the hash is not covering the field that changed and cannot be trusted as evidence.

Root-Cause Table

Symptom Cause Fix
ValueError: kaleido or blank PDF kaleido not installed or wrong engine name pip install kaleido and pass engine="kaleido" to write_image
Two exports differ byte-for-byte Auto-sized layout or a system-dependent font Pin width, height, scale, and an explicit font before export
Provenance footer missing from PDF Annotation added after export, or clipped by margin Call stamp_provenance before write_image and widen the bottom margin
Hash unchanged after data edit Hash omits a field or hashes unrounded floats inconsistently Hash a sorted, rounded, canonical JSON payload of data and limits together
Packet silently drops a chart Merge swallowed a failed page export Export each page separately and append page-by-page so a failure raises
PDF and provenance drift apart Sidecar written independently of the packet Name the sidecar from the PDF path and write both in the same step

Frequently Asked Questions

Why export with kaleido rather than a browser screenshot?

The kaleido engine renders the figure to a static image deterministically and without a browser or network, which matters for a build server producing audit artifacts unattended. A browser screenshot depends on window size, zoom, and rendering engine version, so it cannot be reproduced byte-for-byte. With pinned dimensions, scale, and font, write_image produces identical output on every machine, which is the property an audit trail requires.

What should the calculation_hash cover?

Everything that determines the chart: the charted measurements and the frozen limits, serialized to a canonical, sorted, rounded JSON payload before hashing. Rounding to a fixed precision before hashing avoids spurious mismatches from float representation, while covering both data and limits makes the hash tamper-evident — any change to the values that produced the chart changes the digest, so an auditor re-hashing the reconstructed dataset can confirm the chart was never altered.

How does this satisfy ISO 9001:2015 and IATF 16949 evidence requirements?

ISO 9001:2015 clause 7.5.3 requires controlled documented information that is retrievable and protected from unintended alteration; a byte-stable PDF with an embedded provenance footer and a tamper-evident hash meets both. IATF 16949, which extends ISO 9001 for automotive, expects control-limit changes to be traceable to a formal engineering change; because the export freezes the limits it was handed and records their provenance, the packet shows exactly which limits were in force for the charted window without regenerating them.

Up one level: this page is part of dynamic Plotly control chart rendering. For the broader pipeline, see Automated Control Chart Generation and Calculation.