Skip to content

graflo.plot.render

Turning a networkx graph into a file, and the Graphviz details that needs.

Kept apart from the graphs themselves so every plot in this package builds a plain :mod:networkx graph — which any test can assert against — and only this module renders. Nothing here is specific to what is being drawn.

Rendering needs no system packages: Graphviz runs as WebAssembly (wasi-graphviz on wasmtime) and writes SVG; PDF is converted from the SVG (svglib + reportlab) and PNG rasterised from the PDF (pypdfium2). PDF and PNG text uses the Liberation fonts shipped in graflo/plot/fonts, which are metric-compatible with the Helvetica, Times and Courier metrics Graphviz sizes nodes by, so text fits the boxes drawn for it.

Graphviz's layout recurses as deep as the graph, and the WebAssembly stack is bounded, so a large enough graph cannot be laid out here: :func:draw raises a :class:RuntimeError saying so, and the DOT source (format dot) can still be written and laid out with a native Graphviz.

Attributes

DEFAULT_DPI = 150 module-attribute

OUTPUT_FORMATS = ('svg', 'pdf', 'png', 'dot') module-attribute

__all__ = ['DEFAULT_DPI', 'OUTPUT_FORMATS', 'StackExhausted', 'draw', 'header_band', 'pdf_to_png', 'record_escape', 'render_svg', 'resolve_format', 'sanitize_id', 'svg_to_pdf', 'to_dot'] module-attribute

Classes

StackExhausted

Bases: RuntimeError

Graphviz's layout recursed deeper than the WebAssembly stack allows.

Source code in graflo/plot/render.py
class StackExhausted(RuntimeError):
    """Graphviz's layout recursed deeper than the WebAssembly stack allows."""

Functions:

draw(dot, path, *, output_format=None, prog='dot', dpi=None)

Write dot to path, creating the directory if it is missing.

Parameters:

Name Type Description Default
dot DotGraph

The graph to write.

required
path str | PathLike[str]

Where to write it; its suffix chooses the format by default.

required
output_format str | None

Override the format the suffix implies.

None
prog str

Graphviz layout program.

'dot'
dpi int | None

Raster resolution, for png; :data:DEFAULT_DPI if omitted.

None

Returns:

Type Description
Path

The path written.

Raises:

Type Description
RuntimeError

Rendering is unavailable or failed; the message says why. :class:StackExhausted when the graph is too large to lay out.

ValueError

The format is not one this can write.

Source code in graflo/plot/render.py
def draw(
    dot: DotGraph,
    path: str | os.PathLike[str],
    *,
    output_format: str | None = None,
    prog: str = "dot",
    dpi: int | None = None,
) -> Path:
    """Write *dot* to *path*, creating the directory if it is missing.

    Args:
        dot: The graph to write.
        path: Where to write it; its suffix chooses the format by default.
        output_format: Override the format the suffix implies.
        prog: Graphviz layout program.
        dpi: Raster resolution, for ``png``; :data:`DEFAULT_DPI` if omitted.

    Returns:
        The path written.

    Raises:
        RuntimeError: Rendering is unavailable or failed; the message says why.
            :class:`StackExhausted` when the graph is too large to lay out.
        ValueError: The format is not one this can write.
    """
    out = Path(path)
    chosen = resolve_format(out, output_format)
    out.parent.mkdir(parents=True, exist_ok=True)
    source = dot.to_string()
    if chosen == "dot":
        # The source, not a rendering: no layout to run.
        out.write_text(source, encoding="utf-8")
        return out
    try:
        svg = render_svg(source, prog=prog)
    except StackExhausted as exc:
        size = f"a graph of {len(dot.nodes)} nodes and {len(dot.edges)} edges"
        raise StackExhausted(_STACK_EXHAUSTED.format(graph=size)) from exc
    if chosen == "svg":
        out.write_bytes(svg)
        return out
    pdf = svg_to_pdf(svg)
    if chosen == "pdf":
        out.write_bytes(pdf)
    else:
        out.write_bytes(pdf_to_png(pdf, dpi=dpi or DEFAULT_DPI))
    return out

header_band(colour, *, header_lines, rows, fontsize)

Fill attributes that colour only a record's first field, the rest white.

A record cannot fill one field, but a two-stop gradient whose weight is the header's share of the node height draws the same picture. Graphviz sizes a field as lines * fontsize * 1.2 plus 8 points of padding, so the share is exact for single-line rows stacked under rankdir=LR.

Parameters:

Name Type Description Default
colour str

The header colour.

required
header_lines int

Text lines in the header field.

required
rows int

Single-line fields below the header.

required
fontsize float

The node's font size.

required

Returns:

Type Description
dict[str, str]

fillcolor and gradientangle, to merge into the node's attributes.

Source code in graflo/plot/render.py
def header_band(
    colour: str, *, header_lines: int, rows: int, fontsize: float
) -> dict[str, str]:
    """Fill attributes that colour only a record's first field, the rest white.

    A record cannot fill one field, but a two-stop gradient whose weight is
    the header's share of the node height draws the same picture. Graphviz
    sizes a field as ``lines * fontsize * 1.2`` plus 8 points of padding, so
    the share is exact for single-line rows stacked under ``rankdir=LR``.

    Args:
        colour: The header colour.
        header_lines: Text lines in the header field.
        rows: Single-line fields below the header.
        fontsize: The node's font size.

    Returns:
        ``fillcolor`` and ``gradientangle``, to merge into the node's attributes.
    """
    header = header_lines * fontsize * 1.2 + 8
    share = header / (header + rows * (fontsize * 1.2 + 8))
    if share >= 1:
        return {"fillcolor": colour}
    return {"fillcolor": f"{colour};{share:.4f}:white", "gradientangle": "270"}

pdf_to_png(pdf, *, dpi=DEFAULT_DPI)

The first page of pdf rasterised at dpi on a white background.

Source code in graflo/plot/render.py
def pdf_to_png(pdf: bytes, *, dpi: int = DEFAULT_DPI) -> bytes:
    """The first page of *pdf* rasterised at *dpi* on a white background."""
    try:
        import pypdfium2 as pdfium
    except ImportError as exc:
        raise RuntimeError(_missing(exc)) from exc
    document = pdfium.PdfDocument(pdf)
    try:
        image = document[0].render(scale=dpi / 72).to_pil()
        out = io.BytesIO()
        image.save(out, format="PNG")
        return out.getvalue()
    finally:
        document.close()

record_escape(text)

text as a record-label field: { } | < >, \ and newlines escaped.

Source code in graflo/plot/render.py
def record_escape(text: Any) -> str:
    """*text* as a record-label field: ``{ } | < >``, ``\\`` and newlines escaped."""
    escaped = re.sub(r"([{}|<>\\])", r"\\\1", str(text))
    return escaped.replace("\n", "\\n")

render_svg(source, *, prog='dot')

Lay out and draw DOT source as SVG.

Raises:

Type Description
RuntimeError

The plot extra is missing, the WebAssembly runtime cannot load, Graphviz rejected the graph, or the graph is too large to lay out.

Source code in graflo/plot/render.py
def render_svg(source: str, *, prog: str = "dot") -> bytes:
    """Lay out and draw DOT *source* as SVG.

    Raises:
        RuntimeError: The ``plot`` extra is missing, the WebAssembly runtime
            cannot load, Graphviz rejected the graph, or the graph is too large
            to lay out.
    """
    try:
        from wasi_graphviz import RenderError
        from wasmtime import Trap
    except ImportError as exc:
        raise RuntimeError(_missing(exc)) from exc
    try:
        with _RENDER_LOCK:
            return _on_render_thread(
                lambda: _backend().render(source, format="svg", engine=prog)
            )
    except ImportError as exc:
        raise RuntimeError(_missing(exc)) from exc
    except OSError as exc:
        raise RuntimeError(_WASMTIME_UNLOADABLE.format(cause=exc)) from exc
    except RenderError as exc:
        raise RuntimeError(f"Graphviz could not draw the graph: {exc}") from exc
    except Trap as exc:
        # The trapped instance is unwound mid-call; the next render gets a fresh one.
        _backend.cache_clear()
        raise StackExhausted(_STACK_EXHAUSTED.format(graph="the graph")) from exc

resolve_format(path, output_format)

The format to write, from output_format or the path's own suffix.

Source code in graflo/plot/render.py
def resolve_format(path: Path, output_format: str | None) -> str:
    """The format to write, from *output_format* or the path's own suffix."""
    chosen = (output_format or path.suffix.lstrip(".")).lower()
    if chosen not in OUTPUT_FORMATS:
        raise ValueError(
            f"unsupported output format {chosen!r}; expected one of "
            f"{', '.join(OUTPUT_FORMATS)}"
        )
    return chosen

sanitize_id(node_id, taken=None)

A Graphviz-safe node id, one-to-one with node_id.

: separates a node from its port and . a record field, so an id like left:Firm.firm_id cannot be written as it stands. taken carries the mapping across a whole graph so two different ids that sanitize alike are still told apart.

Parameters:

Name Type Description Default
node_id str

The id to convert.

required
taken dict[str, str] | None

Accumulating {node_id: safe_id}; pass one per graph.

None

Returns:

Type Description
str

The safe id, stable for the same node_id within one taken.

Source code in graflo/plot/render.py
def sanitize_id(node_id: str, taken: dict[str, str] | None = None) -> str:
    """A Graphviz-safe node id, one-to-one with *node_id*.

    ``:`` separates a node from its port and ``.`` a record field, so an id
    like ``left:Firm.firm_id`` cannot be written as it stands. *taken* carries
    the mapping across a whole graph so two different ids that sanitize alike
    are still told apart.

    Args:
        node_id: The id to convert.
        taken: Accumulating ``{node_id: safe_id}``; pass one per graph.

    Returns:
        The safe id, stable for the same *node_id* within one *taken*.
    """
    if taken is not None and node_id in taken:
        return taken[node_id]
    safe = _UNSAFE.sub("_", node_id) or "n"
    if safe[0].isdigit():
        safe = f"n{safe}"
    if taken is not None:
        used = set(taken.values())
        if safe in used:
            ordinal = 2
            while f"{safe}_{ordinal}" in used:
                ordinal += 1
            safe = f"{safe}_{ordinal}"
        taken[node_id] = safe
    return safe

svg_to_pdf(svg)

svg as a one-page PDF whose text uses the shipped Liberation fonts.

Source code in graflo/plot/render.py
def svg_to_pdf(svg: bytes) -> bytes:
    """*svg* as a one-page PDF whose text uses the shipped Liberation fonts."""
    try:
        from reportlab.graphics import renderPDF
        from svglib.svglib import svg2rlg
    except ImportError as exc:
        raise RuntimeError(_missing(exc)) from exc
    drawing = svg2rlg(io.BytesIO(_use_shipped_fonts(svg)), font_map=_font_map())
    if drawing is None:  # pragma: no cover - svglib only fails on unparsable XML
        raise RuntimeError("the SVG Graphviz wrote could not be read back")
    out = io.BytesIO()
    # invariant: no timestamps or random ids, so the same figure is the same bytes
    renderPDF.drawToFile(drawing, out, canvasKwds={"invariant": 1})
    return out.getvalue()

to_dot(graph)

graph as a :class:~graflo.plot.dot.DotGraph, ready to group and draw.

Source code in graflo/plot/render.py
def to_dot(graph: nx.Graph) -> DotGraph:
    """*graph* as a :class:`~graflo.plot.dot.DotGraph`, ready to group and draw."""
    return DotGraph.from_networkx(graph)