Skip to content

SVG Renderer

SVG rendering engine for converting diagrams to SVG files.

render_svg

SVG rendering for GPIO diagrams.

SVGRenderer

SVGRenderer(layout_config: LayoutConfig | None = None)

Render GPIO wiring diagrams to SVG format.

Converts a Diagram object into a scalable SVG image showing the Raspberry Pi board, connected devices, wire connections, and optional GPIO reference diagram.

The renderer handles
  • Board SVG asset embedding
  • Device box rendering with labeled pins
  • Wire routing with rounded corners
  • Inline component symbols (resistors, capacitors, diodes)
  • GPIO pin numbering and color coding
  • Optional GPIO reference diagram
  • Automatic layout via LayoutEngine

Examples:

>>> from pinviz import boards, devices, Connection, Diagram, SVGRenderer
>>>
>>> diagram = Diagram(
...     title="LED Circuit",
...     board=boards.raspberry_pi_5(),
...     devices=[devices.led()],
...     connections=[
...         Connection(11, "LED", "Anode"),
...         Connection(6, "LED", "Cathode")
...     ]
... )
>>>
>>> renderer = SVGRenderer()
>>> renderer.render(diagram, "led_circuit.svg")

Initialize SVG renderer with optional layout configuration.

PARAMETER DESCRIPTION
layout_config

Layout configuration for spacing and margins. If None, uses default LayoutConfig.

TYPE: LayoutConfig | None DEFAULT: None

Source code in src/pinviz/render_svg.py
def __init__(self, layout_config: LayoutConfig | None = None):
    """
    Initialize SVG renderer with optional layout configuration.

    Args:
        layout_config: Layout configuration for spacing and margins.
            If None, uses default LayoutConfig.
    """
    self.layout_config = layout_config or LayoutConfig()
    self.layout_engine = LayoutEngine(self.layout_config)
    self._init_svg_handlers()

draw_gpio_pin_numbers

draw_gpio_pin_numbers(
    dwg: Drawing,
    board: Board,
    x: float,
    y: float,
    color_scheme,
) -> None

Draw pin numbers on GPIO header with color-coded backgrounds.

Creates small circles at each pin location with the physical pin number (1-40) and color-coded background based on pin role (power, ground, GPIO, I2C, SPI, etc.).

PARAMETER DESCRIPTION
dwg

The SVG drawing object

TYPE: Drawing

board

The board containing pin definitions

TYPE: Board

x

Board X offset

TYPE: float

y

Board Y offset

TYPE: float

color_scheme

Theme color scheme

Source code in src/pinviz/render_svg.py
def draw_gpio_pin_numbers(
    self, dwg: draw.Drawing, board: Board, x: float, y: float, color_scheme
) -> None:
    """
    Draw pin numbers on GPIO header with color-coded backgrounds.

    Creates small circles at each pin location with the physical pin number (1-40)
    and color-coded background based on pin role (power, ground, GPIO, I2C, SPI, etc.).

    Args:
        dwg: The SVG drawing object
        board: The board containing pin definitions
        x: Board X offset
        y: Board Y offset
        color_scheme: Theme color scheme
    """
    # Color mapping for pin backgrounds based on pin role

    role_colors = {
        PinRole.POWER_3V3: "#FFA500",  # Orange
        PinRole.POWER_5V: "#FF0000",  # Red
        PinRole.POWER_EXT: "#8B0000",  # Dark red
        PinRole.GROUND: "#D3D3D3",  # Light gray
        PinRole.I2C_SDA: "#FF00FF",  # Magenta
        PinRole.I2C_SCL: "#FF00FF",  # Magenta
        PinRole.I2C_EEPROM: "#FFFF00",  # Yellow
        PinRole.SPI_MOSI: "#0000FF",  # Blue
        PinRole.SPI_MISO: "#0000FF",  # Blue
        PinRole.SPI_SCLK: "#0000FF",  # Blue
        PinRole.SPI_CE0: "#0000FF",  # Blue
        PinRole.SPI_CE1: "#0000FF",  # Blue
        PinRole.UART_TX: "#0000FF",  # Blue
        PinRole.UART_RX: "#0000FF",  # Blue
        PinRole.PWM: "#00FF00",  # Green
        PinRole.GPIO: "#00FF00",  # Green
        PinRole.PCM_CLK: "#00FF00",  # Green
        PinRole.PCM_FS: "#00FF00",  # Green
        PinRole.PCM_DIN: "#00FF00",  # Green
        PinRole.PCM_DOUT: "#00FF00",  # Green
    }

    # Pin size configuration - don't scale with svg_scale since positions are already scaled
    # (svg_scale is used for board SVG transform but not for pin rendering)
    pin_radius = RENDER_CONSTANTS.PIN_RADIUS  # Keep constant size
    pin_font_size_val = _parse_font_size(RENDER_CONSTANTS.PIN_FONT_SIZE)
    pin_font_size = f"{pin_font_size_val}px"

    for pin in board.pins:
        pin_x = x + pin.position.x
        pin_y = y + pin.position.y

        # Draw circle background for pin number
        # Use color based on pin role
        bg_color = role_colors.get(pin.role, "#FFFFFF")  # Default to white if role not found

        dwg.append(
            draw.Circle(
                pin_x,
                pin_y,
                pin_radius,
                fill=bg_color,
                stroke=color_scheme.pin_circle_stroke,
                stroke_width=RENDER_CONSTANTS.PIN_STROKE_WIDTH,
                opacity=RENDER_CONSTANTS.PIN_OPACITY,
            )
        )

        # Draw pin label - scaled to fit in circle.
        # Boards with show_pin_names (e.g. ESP32-S3) show the pin's short name
        # (GPIO number / power rail) instead of the meaningless physical index.
        if getattr(board, "show_pin_names", False):
            pin_text = _short_pin_label(pin)
        else:
            pin_text = str(pin.number)
        # Shrink the font for longer labels (e.g. "3V3", "46") so they stay
        # inside the pin circle; 1-2 chars keep the full size.
        font_val = _parse_font_size(pin_font_size)
        if len(pin_text) >= 4:
            font_val *= 0.55
        elif len(pin_text) == 3:
            font_val *= 0.72
        # Use appropriate text color based on background for readability
        text_color = "#FFFFFF" if bg_color in ["#0000FF", "#FF00FF"] else "#000000"
        # Adjust x for text centering (compensate for renderer quirks)
        text_x_offset = -(font_val * len(pin_text) * 0.3)
        dwg.append(
            draw.Text(
                pin_text,
                font_val,
                pin_x + text_x_offset,
                pin_y + RENDER_CONSTANTS.PIN_TEXT_Y_OFFSET,
                font_family="Arial, sans-serif",
                font_weight="bold",
                fill=text_color,
            )
        )

draw_specs_table

draw_specs_table(
    dwg: Drawing,
    devices_with_specs: list[Device],
    table_x: float,
    table_y: float,
    table_width: float,
    color_scheme,
) -> None

Draw the "Device Specifications" table at a given position and width.

Source code in src/pinviz/render_svg.py
def draw_specs_table(
    self,
    dwg: draw.Drawing,
    devices_with_specs: list[Device],
    table_x: float,
    table_y: float,
    table_width: float,
    color_scheme,
) -> None:
    """Draw the "Device Specifications" table at a given position and width."""
    line_spacing = TABLE_LAYOUT.LINE_SPACING
    header_height = TABLE_LAYOUT.HEADER_HEIGHT
    padding_left = TABLE_LAYOUT.PADDING_LEFT
    name_column_width, desc_max_width, row_heights = self._specs_table_metrics(
        devices_with_specs, table_width
    )
    desc_column_start = table_x + padding_left + name_column_width
    total_table_height = header_height + sum(row_heights)

    # Draw table background
    dwg.append(
        draw.Rectangle(
            table_x,
            table_y,
            table_width,
            total_table_height,
            fill=color_scheme.table_background,
            stroke=color_scheme.legend_stroke,
            stroke_width=1,
            rx=4,
            ry=4,
        )
    )

    # Draw header
    dwg.append(
        draw.Rectangle(
            table_x,
            table_y,
            table_width,
            header_height,
            fill=color_scheme.table_header_background,
            stroke=color_scheme.legend_stroke,
            stroke_width=1,
        )
    )

    # Header text
    dwg.append(
        draw.Text(
            "Device Specifications",
            12,
            table_x + padding_left,
            table_y + 20,
            font_family="Arial, sans-serif",
            font_weight="bold",
            fill=color_scheme.text_primary,
        )
    )

    # Draw rows
    y_pos = table_y + header_height
    for idx, device in enumerate(devices_with_specs):
        row_height = row_heights[idx]

        # Device name (bold, left column)
        dwg.append(
            draw.Text(
                device.name,
                9,
                table_x + padding_left,
                y_pos + 20,
                font_family="Arial, sans-serif",
                font_weight="bold",
                fill=color_scheme.text_primary,
            )
        )

        # Device description (right column) - with text wrapping
        desc_lines = self._wrap_text(device.description, desc_max_width, 9)

        for i, line in enumerate(desc_lines):
            dwg.append(
                draw.Text(
                    line,
                    9,
                    desc_column_start,
                    y_pos + 20 + (i * line_spacing),
                    font_family="Arial, sans-serif",
                    fill=color_scheme.text_secondary,
                )
            )

        # Separator line
        if device != devices_with_specs[-1]:  # Don't draw line after last row
            dwg.append(
                draw.Line(
                    table_x,
                    y_pos + row_height,
                    table_x + table_width,
                    y_pos + row_height,
                    stroke=color_scheme.table_separator,
                    stroke_width=1,
                )
            )

        y_pos += row_height

inline_svg_elements

inline_svg_elements(
    parent_group,
    svg_root,
    dwg: Drawing,
    show_board_name: bool = True,
) -> None

Inline SVG elements from external SVG file.

Recursively processes all elements in the SVG and adds them to the parent group.

PARAMETER DESCRIPTION
parent_group

Parent drawing group to add elements to

svg_root

Root element of the SVG to inline

dwg

Main drawing object

TYPE: Drawing

show_board_name

Whether to include board name text from SVG

TYPE: bool DEFAULT: True

Source code in src/pinviz/render_svg.py
def inline_svg_elements(
    self, parent_group, svg_root, dwg: draw.Drawing, show_board_name: bool = True
) -> None:
    """
    Inline SVG elements from external SVG file.

    Recursively processes all elements in the SVG and adds them to the parent group.

    Args:
        parent_group: Parent drawing group to add elements to
        svg_root: Root element of the SVG to inline
        dwg: Main drawing object
        show_board_name: Whether to include board name text from SVG
    """
    # SVG namespace
    svg_ns = "{http://www.w3.org/2000/svg}"

    # Process all children of the SVG root (skip root itself)
    for child in svg_root:
        # Strip namespace from tag
        tag = child.tag.replace(svg_ns, "") if svg_ns in child.tag else child.tag

        # Get all attributes
        attribs = {k.replace(svg_ns, ""): v for k, v in child.attrib.items()}

        # Create element based on tag type
        if tag == "defs":
            # Handle defs section - add to main drawing's defs
            for def_child in child:
                def_tag = (
                    def_child.tag.replace(svg_ns, "")
                    if svg_ns in def_child.tag
                    else def_child.tag
                )
                def_attribs = {k.replace(svg_ns, ""): v for k, v in def_child.attrib.items()}

                # Handle gradients
                if def_tag == "linearGradient":
                    gradient = draw.LinearGradient(**def_attribs)
                    for stop in def_child:
                        stop_attribs = self._parse_stop_attributes(stop)
                        gradient.add_stop(**stop_attribs)
                    # Auto-added to defs when referenced
                elif def_tag == "radialGradient":
                    gradient = draw.RadialGradient(**def_attribs)
                    for stop in def_child:
                        stop_attribs = self._parse_stop_attributes(stop)
                        gradient.add_stop(**stop_attribs)
                    # Auto-added to defs when referenced
        elif tag == "g":
            # Handle group
            g = draw.Group(**attribs)
            # Recursively process children
            for grandchild in child:
                self._add_svg_element(g, grandchild, dwg, svg_ns, show_board_name)
            parent_group.append(g)
        else:
            # Handle other elements
            self._add_svg_element(parent_group, child, dwg, svg_ns, show_board_name)

render

render(diagram: Diagram, output_path: str | Path) -> None

Render a diagram to an SVG file.

PARAMETER DESCRIPTION
diagram

The diagram to render

TYPE: Diagram

output_path

Output file path

TYPE: str | Path

Source code in src/pinviz/render_svg.py
def render(self, diagram: Diagram, output_path: str | Path) -> None:
    """
    Render a diagram to an SVG file.

    Args:
        diagram: The diagram to render
        output_path: Output file path
    """
    log.info(
        "render_started",
        output_path=str(output_path),
        title=diagram.title,
        device_count=len(diagram.devices),
        connection_count=len(diagram.connections),
    )

    if diagram.layout_mode == LayoutMode.BREADBOARD:
        # Lazy: breadboard.py imports this module for inline_svg_elements.
        from .breadboard import BreadboardRenderer

        BreadboardRenderer().render(diagram, output_path)
        log.info("render_completed", output_path=str(output_path), layout="breadboard")
        return

    # Get color scheme from theme
    color_scheme = get_color_scheme(diagram.theme)

    # Initialize renderers with color scheme
    wire_renderer = WireRenderer(self.layout_config, color_scheme)
    component_renderer = ComponentRenderer(color_scheme)

    # Calculate layout (returns immutable LayoutResult)
    log.debug("calculating_layout")
    layout_result = self.layout_engine.layout_diagram(diagram)
    log.debug(
        "layout_calculated",
        canvas_width=layout_result.canvas_width,
        canvas_height=layout_result.canvas_height,
        wire_count=len(layout_result.routed_wires),
    )

    # Extract layout data for rendering
    canvas_width = layout_result.canvas_width
    canvas_height = layout_result.canvas_height
    routed_wires = layout_result.routed_wires
    board_margin_top = layout_result.board_margin_top

    # Create SVG drawing
    dwg = draw.Drawing(canvas_width, canvas_height)

    # Add background
    dwg.append(
        draw.Rectangle(0, 0, canvas_width, canvas_height, fill=color_scheme.canvas_background)
    )

    # Draw title
    if diagram.title and diagram.show_title:
        dwg.append(
            draw.Text(
                diagram.title,
                RENDER_CONSTANTS.TITLE_FONT_SIZE,
                canvas_width / 2,
                RENDER_CONSTANTS.TITLE_Y_OFFSET,
                text_anchor="middle",
                font_family="Arial, sans-serif",
                font_weight="bold",
                fill=color_scheme.text_primary,
            )
        )

    # Draw board
    log.debug("drawing_board", board_name=diagram.board.name)
    self._draw_board(
        dwg,
        diagram.board,
        board_margin_top,
        diagram.show_board_name,
        color_scheme,
        component_renderer,
    )

    # Draw GPIO pin numbers on the header
    x = self.layout_config.board_margin_left
    y = board_margin_top
    self.draw_gpio_pin_numbers(dwg, diagram.board, x, y, color_scheme)

    # Draw wires first so they appear behind devices
    # Sort wires for proper z-order to prevent overlapping/hiding
    # Primary: source pin X position (left column pins first, right column on top)
    # Secondary: destination Y (lower devices first)
    # Tertiary: rail X position (further right first)
    # This ensures wires from right GPIO column are always on top
    log.debug("drawing_wires", wire_count=len(routed_wires))
    sorted_wires = sorted(
        routed_wires,
        key=lambda w: (
            w.from_pin_pos.x,  # Left column first, right column on top
            -w.to_pin_pos.y,  # Lower Y (higher devices) drawn last
            -(w.path_points[2].x if len(w.path_points) > 2 else w.path_points[0].x),
        ),
    )
    for wire in sorted_wires:
        wire_renderer.draw_wire(dwg, wire, draw_connection_segment=False)

    # Draw device boxes (without pins yet)
    log.debug("drawing_devices", device_count=len(diagram.devices))
    for device in diagram.devices:
        component_renderer.draw_device_box(dwg, device)

    # Draw wire connection segments on top of device boxes
    for wire in sorted_wires:
        wire_renderer.draw_wire_connection_segment(dwg, wire)

    # Draw device pins on top of everything
    for device in diagram.devices:
        component_renderer.draw_device_pins(dwg, device)

    # Draw device specifications table if legend is enabled
    if diagram.show_legend:
        self._draw_device_specs_table(dwg, diagram, board_margin_top, color_scheme)

    # Save
    log.debug("saving_svg", output_path=str(output_path))
    dwg.save_svg(str(output_path))
    log.info("render_completed", output_path=str(output_path))

render_to_string

render_to_string(diagram: Diagram) -> str

Render a diagram to an SVG string.

PARAMETER DESCRIPTION
diagram

The diagram to render

TYPE: Diagram

RETURNS DESCRIPTION
str

SVG content as string

Source code in src/pinviz/render_svg.py
def render_to_string(self, diagram: Diagram) -> str:
    """
    Render a diagram to an SVG string.

    Args:
        diagram: The diagram to render

    Returns:
        SVG content as string
    """
    import tempfile

    with tempfile.NamedTemporaryFile(mode="w", suffix=".svg", delete=False) as f:
        temp_path = f.name

    try:
        self.render(diagram, temp_path)
        with open(temp_path) as f:
            return f.read()
    finally:
        Path(temp_path).unlink(missing_ok=True)

specs_table_height

specs_table_height(
    devices_with_specs: list[Device], table_width: float
) -> float

Height of the specifications table for these devices at this width.

Source code in src/pinviz/render_svg.py
def specs_table_height(self, devices_with_specs: list[Device], table_width: float) -> float:
    """Height of the specifications table for these devices at this width."""
    _name_width, _desc_width, row_heights = self._specs_table_metrics(
        devices_with_specs, table_width
    )
    return TABLE_LAYOUT.HEADER_HEIGHT + sum(row_heights)