host.controller_config.print_render

Off-screen PIL renderer for controller config export.

Renders controller layouts to PIL Images for PNG/PDF export. Mirrors the visual style of controller_canvas.py but draws entirely with PIL so it works headlessly (no tkinter window needed).

  1"""Off-screen PIL renderer for controller config export.
  2
  3Renders controller layouts to PIL Images for PNG/PDF export.
  4Mirrors the visual style of controller_canvas.py but draws entirely
  5with PIL so it works headlessly (no tkinter window needed).
  6"""
  7
  8import io
  9from pathlib import Path
 10
 11from PIL import Image, ImageDraw, ImageFont
 12
 13from utils.controller.model import ControllerConfig, FullConfig
 14from .layout_coords import (
 15    XBOX_INPUTS, XBOX_INPUT_MAP, InputCoord, _IMG_W, _IMG_H,
 16)
 17from .controller_canvas import (
 18    _find_image_path, _find_gear_icon, _find_rumble_icon,
 19    LINE_COLOR, BOX_OUTLINE, BOX_FILL, BOX_FILL_ASSIGNED,
 20    UNASSIGNED_TEXT, UNASSIGNED_COLOR, ASSIGNED_COLOR,
 21    AXIS_INDICATOR_COLORS,
 22    _REF_BOX_WIDTH, _REF_BOX_HEIGHT, _REF_BOX_PAD,
 23    _REF_LABEL_FONT, _REF_ACTION_FONT, _REF_PLUS_FONT,
 24    _REF_LABEL_Y, _REF_ACTION_STEP,
 25)
 26
 27try:
 28    import cairosvg
 29    HAS_CAIROSVG = True
 30except (ImportError, OSError):
 31    HAS_CAIROSVG = False
 32
 33# Page sizes at 150 DPI (US Letter 8.5 x 11 inches)
 34DPI = 150
 35PAGE_W_PORTRAIT = int(8.5 * DPI)   # 1275
 36PAGE_H_PORTRAIT = int(11 * DPI)    # 1650
 37PAGE_W_LANDSCAPE = int(11 * DPI)   # 1650
 38PAGE_H_LANDSCAPE = int(8.5 * DPI)  # 1275
 39
 40# Margins
 41MARGIN = int(0.4 * DPI)  # 60px at 150 DPI
 42
 43
 44def _get_font(size: int, bold: bool = False):
 45    """Load a TrueType font with fallback to default.
 46
 47    Prefers Verdana for its wide, heavy strokes that stay readable
 48    at small sizes and in print.
 49    """
 50    if bold:
 51        names = ["verdanab.ttf", "Verdana Bold.ttf",
 52                 "arialbd.ttf", "Arial Bold.ttf"]
 53    else:
 54        names = ["verdana.ttf", "Verdana.ttf",
 55                 "arial.ttf", "Arial.ttf"]
 56    for name in names:
 57        try:
 58            return ImageFont.truetype(name, size)
 59        except (OSError, IOError):
 60            pass  # Font not found at this path; try next candidate
 61    # Fallback
 62    return ImageFont.load_default()
 63
 64
 65def _load_controller_image() -> Image.Image:
 66    """Load the Xbox controller image for rendering."""
 67    img_path = _find_image_path()
 68    if img_path.suffix == ".svg" and HAS_CAIROSVG:
 69        png_data = cairosvg.svg2png(
 70            url=str(img_path), output_width=744, output_height=500,
 71        )
 72        return Image.open(io.BytesIO(png_data)).convert("RGBA")
 73    png_path = img_path.with_suffix(".svg.png")
 74    if not png_path.exists():
 75        png_path = img_path
 76    return Image.open(str(png_path)).convert("RGBA")
 77
 78
 79def _load_gear_icon() -> Image.Image | None:
 80    """Load the team gear logo."""
 81    path = _find_gear_icon()
 82    if path:
 83        try:
 84            return Image.open(str(path)).convert("RGBA")
 85        except Exception:
 86            pass  # Non-fatal: gear logo is optional decoration
 87    return None
 88
 89
 90def _load_rumble_icon() -> Image.Image | None:
 91    """Load the rumble icon."""
 92    path = _find_rumble_icon()
 93    if not path:
 94        return None
 95    try:
 96        if path.suffix == ".svg" and HAS_CAIROSVG:
 97            data = cairosvg.svg2png(
 98                url=str(path), output_width=64, output_height=64,
 99            )
100            return Image.open(io.BytesIO(data)).convert("RGBA")
101        elif path.suffix != ".svg":
102            return Image.open(str(path)).convert("RGBA")
103    except Exception:
104        pass  # Non-fatal: caller falls back to generated icon
105    return None
106
107
108def _make_rumble_fallback(size: int) -> Image.Image:
109    """Draw a simple rumble icon when SVG can't be loaded."""
110    img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
111    d = ImageDraw.Draw(img)
112    s = size / 24
113    d.rectangle([s * 2, s * 5, s * 15, s * 16], fill=(0, 0, 0, 255))
114    d.rectangle([s * 17, s * 8, s * 18, s * 11.5], fill=(0, 0, 0, 255))
115    d.rectangle([s * 17, s * 9.5, s * 22, s * 11.5], fill=(0, 0, 0, 255))
116    for y_top in [7, 9, 11, 13]:
117        d.rectangle([s * 5, s * y_top, s * 14, s * (y_top + 0.8)],
118                    fill=(255, 255, 255, 255))
119    d.rectangle([s * 3, s * 17, s * 16, s * 19], fill=(0, 0, 0, 255))
120    return img
121
122
123def render_controller(
124    ctrl: ControllerConfig,
125    width: int,
126    height: int,
127    label_positions: dict[str, tuple[int, int]] | None = None,
128    hide_unassigned: bool = False,
129    icon_loader=None,
130) -> Image.Image:
131    """Render a single controller layout to a PIL Image.
132
133    Args:
134        ctrl: Controller config with bindings.
135        width: Target image width in pixels.
136        height: Target image height in pixels.
137        label_positions: Optional custom label positions (img pixel coords).
138        hide_unassigned: If True, skip inputs with no bindings.
139
140    Returns:
141        RGB PIL Image with the rendered controller layout.
142    """
143    label_positions = label_positions or {}
144    page = Image.new("RGB", (width, height), "white")
145    draw = ImageDraw.Draw(page)
146
147    # Title
148    title_font = _get_font(28, bold=True)
149    title = ctrl.name or f"Controller {ctrl.port}"
150    title_text = f"{title} (Port {ctrl.port})"
151    draw.text((MARGIN, 4), title_text, fill="#333333", font=title_font)
152    title_h = 40
153
154    # Available area below title
155    area_top = title_h
156    area_w = width
157    area_h = height - area_top
158
159    # Load and scale controller image
160    ctrl_img = _load_controller_image()
161    img_w, img_h = ctrl_img.size
162
163    # First pass: estimate image scale to compute label sizes
164    est_pad_x = _REF_BOX_WIDTH + 30
165    est_avail_w = area_w - 2 * est_pad_x
166    est_avail_h = area_h - 40
167    img_scale = min(est_avail_w / img_w, est_avail_h / img_h, 1.5)
168
169    # Compute scaled label dimensions (same formula as controller_canvas.py)
170    _PRINT_SCALE_BOOST = 1.75
171    s = max(img_scale * _PRINT_SCALE_BOOST, 0.15)
172    box_w = int(_REF_BOX_WIDTH * s)
173    box_h = int(_REF_BOX_HEIGHT * s)
174    box_pad = max(2, int(_REF_BOX_PAD * s))
175    label_font_size = max(6, int(_REF_LABEL_FONT * s))
176    action_font_size = max(7, int(_REF_ACTION_FONT * s))
177    plus_font_size = max(10, int(_REF_PLUS_FONT * s))
178    label_y_offset = max(10, int(_REF_LABEL_Y * s))
179    action_step = max(12, int(_REF_ACTION_STEP * s))
180
181    # Fonts scaled to match label dimensions
182    label_font = _get_font(label_font_size, bold=True)
183    action_font = _get_font(action_font_size, bold=True)
184    unassigned_font = _get_font(label_font_size)
185
186    # Second pass: use computed box_w for image padding
187    pad_x = box_w + 30
188    avail_w = area_w - 2 * pad_x
189    avail_h = area_h - 40
190
191    scale = min(avail_w / img_w, avail_h / img_h, 1.5)
192    new_w = int(img_w * scale)
193    new_h = int(img_h * scale)
194
195    resized = ctrl_img.resize((new_w, new_h), Image.LANCZOS)
196
197    # Center the image in the area
198    img_left = (area_w - new_w) // 2
199    img_top = area_top + (area_h - new_h) // 2
200
201    page.paste(resized, (img_left, img_top), resized)
202
203    # Coordinate mapping helpers
204    def map_frac(frac_x, frac_y):
205        return (img_left + frac_x * new_w, img_top + frac_y * new_h)
206
207    def map_label(inp: InputCoord):
208        if inp.name in label_positions:
209            px, py = label_positions[inp.name]
210            lx = img_left + (px / _IMG_W) * new_w
211            ly = img_top + (py / _IMG_H) * new_h
212        else:
213            lx = img_left + inp.label_x * new_w
214            ly = img_top + inp.label_y * new_h
215        lx = max(2, min(lx, width - box_w - 2))
216        ly = max(2, min(ly, height - box_h - 2))
217        return lx, ly
218
219    # Draw rumble icons on the controller
220    rumble_icon = _load_rumble_icon()
221    if rumble_icon is None:
222        rumble_icon = _make_rumble_fallback(64)
223    icon_size = box_w // 4
224    rumble_resized = rumble_icon.resize((icon_size, icon_size), Image.LANCZOS)
225    for name in ["rumble_left", "rumble_both", "rumble_right"]:
226        inp = XBOX_INPUT_MAP.get(name)
227        if inp:
228            cx, cy = map_frac(inp.anchor_x, inp.anchor_y)
229            ix = int(cx - icon_size / 2)
230            iy = int(cy - icon_size / 2)
231            page.paste(rumble_resized, (ix, iy), rumble_resized)
232
233    # Draw gear logo in top-right
234    gear_img = _load_gear_icon()
235    if gear_img:
236        logo_size = 72
237        gear_resized = gear_img.resize((logo_size, logo_size), Image.LANCZOS)
238        page.paste(gear_resized,
239                   (width - logo_size - 8, 4), gear_resized)
240
241    # Connector groups: (prefix, anchor input name)
242    connector_groups = [
243        ("pov_", "pov_right"),
244        ("left_stick", "left_stick_x"),
245        ("right_stick", "right_stick_x"),
246    ]
247    group_boxes: dict[str, list[tuple]] = {p: [] for p, _ in connector_groups}
248
249    # Draw each input's leader line and binding box
250    # Track D-pad stack origin for tight packing
251    dpad_stack_origin = None
252    dpad_stack_idx = 0
253
254    for inp in XBOX_INPUTS:
255        all_actions = ctrl.bindings.get(inp.name, [])
256        is_dpad = inp.name.startswith("pov_")
257        is_stick = (inp.name.startswith("left_stick")
258                    or inp.name.startswith("right_stick"))
259        # D-pad: 1 action, sticks: max 2, others: all
260        if is_dpad:
261            actions = all_actions[:1]
262        elif is_stick:
263            actions = all_actions[:2]
264        else:
265            actions = all_actions
266        has_actions = len(actions) > 0
267
268        if hide_unassigned and not has_actions:
269            continue
270
271        ax, ay = map_frac(inp.anchor_x, inp.anchor_y)
272        lx, ly = map_label(inp)
273
274        # Stack D-pad labels at fixed pixel intervals
275        if is_dpad:
276            if dpad_stack_origin is None:
277                dpad_stack_origin = (lx, ly)
278            else:
279                lx, ly = (dpad_stack_origin[0],
280                           dpad_stack_origin[1] + dpad_stack_idx * box_h)
281            dpad_stack_idx += 1
282
283        box_cx = lx + box_w / 2
284        box_cy = ly + box_h / 2
285
286        # Leader line — skip for grouped inputs (connector bar drawn below)
287        is_grouped = (inp.name.startswith("pov_")
288                      or inp.name.startswith("left_stick")
289                      or inp.name.startswith("right_stick"))
290        if not is_grouped:
291            line_w = 4 if has_actions else 1
292            draw.line([(ax, ay), (box_cx, box_cy)],
293                      fill=LINE_COLOR, width=line_w)
294
295        fill = BOX_FILL_ASSIGNED if has_actions else BOX_FILL
296        if is_dpad:
297            total_height = box_h
298        elif has_actions:
299            total_height = label_y_offset + len(actions) * action_step
300        else:
301            total_height = box_h
302
303        # Box background
304        draw.rectangle(
305            [lx, ly, lx + box_w, ly + total_height],
306            fill=fill, outline=BOX_OUTLINE, width=1,
307        )
308
309        # Track boxes for connector groups
310        for prefix, _ in connector_groups:
311            if inp.name.startswith(prefix):
312                group_boxes[prefix].append(
313                    (lx, ly, lx + box_w, ly + total_height))
314
315        # Axis color indicator
316        axis_tag = None
317        if inp.name.endswith("_x"):
318            axis_tag = "X"
319        elif inp.name.endswith("_y"):
320            axis_tag = "Y"
321        label_color = (AXIS_INDICATOR_COLORS[axis_tag]
322                       if axis_tag else "#555555")
323
324        # Input icon (scaled to box height)
325        ico_size = max(8, box_h - int(8 * s))
326        text_x = lx + box_pad
327        if icon_loader:
328            icon = icon_loader.get_pil_icon(inp.name, ico_size)
329            if icon:
330                page.paste(icon, (int(lx + box_pad), int(ly + 1)), icon)
331                text_x = lx + box_pad + ico_size + 4
332
333        if is_dpad:
334            # D-pad compact: icon + label + action on one line
335            line_text = inp.display_name
336            if has_actions:
337                line_text += " : " + all_actions[0]
338            draw.text((text_x, ly + 1), line_text,
339                      fill=ASSIGNED_COLOR if has_actions else label_color,
340                      font=action_font if has_actions else label_font)
341            # Large "+" when extra bindings are hidden
342            if has_actions and len(all_actions) > 1:
343                plus_font = _get_font(plus_font_size)
344                draw.text((lx + box_w + 6, ly - 10), "+",
345                          fill=ASSIGNED_COLOR, font=plus_font)
346        else:
347            draw.text((text_x, ly + 1), inp.display_name,
348                      fill=label_color, font=label_font)
349
350            # Action names or unassigned
351            if has_actions:
352                for i, action in enumerate(actions):
353                    draw.text(
354                        (lx + box_pad,
355                         ly + label_y_offset + i * action_step),
356                        action, fill=ASSIGNED_COLOR, font=action_font)
357                # "+" when actions are truncated
358                if len(all_actions) > len(actions):
359                    plus_font = _get_font(plus_font_size)
360                    draw.text((lx + box_w + 6, ly - 10), "+",
361                              fill=ASSIGNED_COLOR, font=plus_font)
362            else:
363                draw.text((lx + box_pad, ly + label_y_offset),
364                          UNASSIGNED_TEXT, fill=UNASSIGNED_COLOR,
365                          font=unassigned_font)
366
367    # Connector bars: vertical bar + single leader line per group
368    for prefix, anchor_name in connector_groups:
369        boxes = group_boxes[prefix]
370        if not boxes:
371            continue
372        right_x = max(b[2] for b in boxes)
373        top_y = min(b[1] for b in boxes)
374        bottom_y = max(b[3] for b in boxes)
375        bar_x = right_x + 8
376        draw.line([(bar_x, top_y), (bar_x, bottom_y)],
377                  fill=LINE_COLOR, width=4)
378        anchor_inp = XBOX_INPUT_MAP.get(anchor_name)
379        if anchor_inp:
380            aax, aay = map_frac(anchor_inp.anchor_x, anchor_inp.anchor_y)
381            bar_mid_y = (top_y + bottom_y) / 2
382            draw.line([(aax, aay), (bar_x, bar_mid_y)],
383                      fill=LINE_COLOR, width=2)
384
385    return page
386
387
388def render_portrait_page(
389    controllers: list[ControllerConfig],
390    label_positions: dict[str, tuple[int, int]] | None = None,
391    hide_unassigned: bool = False,
392    icon_loader=None,
393) -> Image.Image:
394    """Render up to 2 controllers stacked vertically on a portrait page."""
395    page = Image.new("RGB", (PAGE_W_PORTRAIT, PAGE_H_PORTRAIT), "white")
396    slot_h = (PAGE_H_PORTRAIT - MARGIN) // 2
397
398    for i, ctrl in enumerate(controllers[:2]):
399        ctrl_img = render_controller(
400            ctrl, PAGE_W_PORTRAIT, slot_h, label_positions,
401            hide_unassigned, icon_loader)
402        page.paste(ctrl_img, (0, i * slot_h + (MARGIN // 2)))
403
404    return page
405
406
407def render_landscape_page(
408    ctrl: ControllerConfig,
409    label_positions: dict[str, tuple[int, int]] | None = None,
410    hide_unassigned: bool = False,
411    icon_loader=None,
412) -> Image.Image:
413    """Render 1 controller on a landscape page."""
414    return render_controller(
415        ctrl, PAGE_W_LANDSCAPE, PAGE_H_LANDSCAPE, label_positions,
416        hide_unassigned, icon_loader)
417
418
419def export_pages(
420    config: FullConfig,
421    orientation: str,
422    output_path: str | Path,
423    label_positions: dict[str, tuple[int, int]] | None = None,
424    hide_unassigned: bool = False,
425    icon_loader=None,
426):
427    """Export all controllers as PNG or multi-page PDF.
428
429    Args:
430        config: Full controller configuration.
431        orientation: "portrait" (2 per page) or "landscape" (1 per page).
432        output_path: Destination file path (.png or .pdf).
433        label_positions: Optional custom label positions.
434        hide_unassigned: If True, skip inputs with no bindings.
435    """
436    output_path = Path(output_path)
437    fmt = output_path.suffix.lower().lstrip(".")
438    if fmt not in ("png", "pdf"):
439        raise ValueError(f"Unsupported format: {fmt} (use .png or .pdf)")
440
441    controllers = [config.controllers[p]
442                   for p in sorted(config.controllers.keys())]
443    if not controllers:
444        raise ValueError("No controllers to export")
445
446    pages: list[Image.Image] = []
447
448    if orientation == "portrait":
449        # Group controllers in pairs
450        for i in range(0, len(controllers), 2):
451            batch = controllers[i:i + 2]
452            pages.append(render_portrait_page(
453                batch, label_positions, hide_unassigned, icon_loader))
454    else:
455        for ctrl in controllers:
456            pages.append(render_landscape_page(
457                ctrl, label_positions, hide_unassigned, icon_loader))
458
459    if fmt == "pdf":
460        # Pillow multi-page PDF
461        first = pages[0]
462        rest = pages[1:] if len(pages) > 1 else []
463        first.save(str(output_path), "PDF", resolution=DPI,
464                   save_all=True, append_images=rest)
465    else:
466        # PNG — single page or numbered pages
467        if len(pages) == 1:
468            pages[0].save(str(output_path), "PNG")
469        else:
470            stem = output_path.stem
471            parent = output_path.parent
472            for i, page in enumerate(pages, 1):
473                name = f"{stem}_page{i}.png"
474                page.save(str(parent / name), "PNG")
DPI = 150
PAGE_W_PORTRAIT = 1275
PAGE_H_PORTRAIT = 1650
PAGE_W_LANDSCAPE = 1650
PAGE_H_LANDSCAPE = 1275
MARGIN = 60
def render_controller( ctrl: utils.controller.ControllerConfig, width: int, height: int, label_positions: dict[str, tuple[int, int]] | None = None, hide_unassigned: bool = False, icon_loader=None) -> PIL.Image.Image:
124def render_controller(
125    ctrl: ControllerConfig,
126    width: int,
127    height: int,
128    label_positions: dict[str, tuple[int, int]] | None = None,
129    hide_unassigned: bool = False,
130    icon_loader=None,
131) -> Image.Image:
132    """Render a single controller layout to a PIL Image.
133
134    Args:
135        ctrl: Controller config with bindings.
136        width: Target image width in pixels.
137        height: Target image height in pixels.
138        label_positions: Optional custom label positions (img pixel coords).
139        hide_unassigned: If True, skip inputs with no bindings.
140
141    Returns:
142        RGB PIL Image with the rendered controller layout.
143    """
144    label_positions = label_positions or {}
145    page = Image.new("RGB", (width, height), "white")
146    draw = ImageDraw.Draw(page)
147
148    # Title
149    title_font = _get_font(28, bold=True)
150    title = ctrl.name or f"Controller {ctrl.port}"
151    title_text = f"{title} (Port {ctrl.port})"
152    draw.text((MARGIN, 4), title_text, fill="#333333", font=title_font)
153    title_h = 40
154
155    # Available area below title
156    area_top = title_h
157    area_w = width
158    area_h = height - area_top
159
160    # Load and scale controller image
161    ctrl_img = _load_controller_image()
162    img_w, img_h = ctrl_img.size
163
164    # First pass: estimate image scale to compute label sizes
165    est_pad_x = _REF_BOX_WIDTH + 30
166    est_avail_w = area_w - 2 * est_pad_x
167    est_avail_h = area_h - 40
168    img_scale = min(est_avail_w / img_w, est_avail_h / img_h, 1.5)
169
170    # Compute scaled label dimensions (same formula as controller_canvas.py)
171    _PRINT_SCALE_BOOST = 1.75
172    s = max(img_scale * _PRINT_SCALE_BOOST, 0.15)
173    box_w = int(_REF_BOX_WIDTH * s)
174    box_h = int(_REF_BOX_HEIGHT * s)
175    box_pad = max(2, int(_REF_BOX_PAD * s))
176    label_font_size = max(6, int(_REF_LABEL_FONT * s))
177    action_font_size = max(7, int(_REF_ACTION_FONT * s))
178    plus_font_size = max(10, int(_REF_PLUS_FONT * s))
179    label_y_offset = max(10, int(_REF_LABEL_Y * s))
180    action_step = max(12, int(_REF_ACTION_STEP * s))
181
182    # Fonts scaled to match label dimensions
183    label_font = _get_font(label_font_size, bold=True)
184    action_font = _get_font(action_font_size, bold=True)
185    unassigned_font = _get_font(label_font_size)
186
187    # Second pass: use computed box_w for image padding
188    pad_x = box_w + 30
189    avail_w = area_w - 2 * pad_x
190    avail_h = area_h - 40
191
192    scale = min(avail_w / img_w, avail_h / img_h, 1.5)
193    new_w = int(img_w * scale)
194    new_h = int(img_h * scale)
195
196    resized = ctrl_img.resize((new_w, new_h), Image.LANCZOS)
197
198    # Center the image in the area
199    img_left = (area_w - new_w) // 2
200    img_top = area_top + (area_h - new_h) // 2
201
202    page.paste(resized, (img_left, img_top), resized)
203
204    # Coordinate mapping helpers
205    def map_frac(frac_x, frac_y):
206        return (img_left + frac_x * new_w, img_top + frac_y * new_h)
207
208    def map_label(inp: InputCoord):
209        if inp.name in label_positions:
210            px, py = label_positions[inp.name]
211            lx = img_left + (px / _IMG_W) * new_w
212            ly = img_top + (py / _IMG_H) * new_h
213        else:
214            lx = img_left + inp.label_x * new_w
215            ly = img_top + inp.label_y * new_h
216        lx = max(2, min(lx, width - box_w - 2))
217        ly = max(2, min(ly, height - box_h - 2))
218        return lx, ly
219
220    # Draw rumble icons on the controller
221    rumble_icon = _load_rumble_icon()
222    if rumble_icon is None:
223        rumble_icon = _make_rumble_fallback(64)
224    icon_size = box_w // 4
225    rumble_resized = rumble_icon.resize((icon_size, icon_size), Image.LANCZOS)
226    for name in ["rumble_left", "rumble_both", "rumble_right"]:
227        inp = XBOX_INPUT_MAP.get(name)
228        if inp:
229            cx, cy = map_frac(inp.anchor_x, inp.anchor_y)
230            ix = int(cx - icon_size / 2)
231            iy = int(cy - icon_size / 2)
232            page.paste(rumble_resized, (ix, iy), rumble_resized)
233
234    # Draw gear logo in top-right
235    gear_img = _load_gear_icon()
236    if gear_img:
237        logo_size = 72
238        gear_resized = gear_img.resize((logo_size, logo_size), Image.LANCZOS)
239        page.paste(gear_resized,
240                   (width - logo_size - 8, 4), gear_resized)
241
242    # Connector groups: (prefix, anchor input name)
243    connector_groups = [
244        ("pov_", "pov_right"),
245        ("left_stick", "left_stick_x"),
246        ("right_stick", "right_stick_x"),
247    ]
248    group_boxes: dict[str, list[tuple]] = {p: [] for p, _ in connector_groups}
249
250    # Draw each input's leader line and binding box
251    # Track D-pad stack origin for tight packing
252    dpad_stack_origin = None
253    dpad_stack_idx = 0
254
255    for inp in XBOX_INPUTS:
256        all_actions = ctrl.bindings.get(inp.name, [])
257        is_dpad = inp.name.startswith("pov_")
258        is_stick = (inp.name.startswith("left_stick")
259                    or inp.name.startswith("right_stick"))
260        # D-pad: 1 action, sticks: max 2, others: all
261        if is_dpad:
262            actions = all_actions[:1]
263        elif is_stick:
264            actions = all_actions[:2]
265        else:
266            actions = all_actions
267        has_actions = len(actions) > 0
268
269        if hide_unassigned and not has_actions:
270            continue
271
272        ax, ay = map_frac(inp.anchor_x, inp.anchor_y)
273        lx, ly = map_label(inp)
274
275        # Stack D-pad labels at fixed pixel intervals
276        if is_dpad:
277            if dpad_stack_origin is None:
278                dpad_stack_origin = (lx, ly)
279            else:
280                lx, ly = (dpad_stack_origin[0],
281                           dpad_stack_origin[1] + dpad_stack_idx * box_h)
282            dpad_stack_idx += 1
283
284        box_cx = lx + box_w / 2
285        box_cy = ly + box_h / 2
286
287        # Leader line — skip for grouped inputs (connector bar drawn below)
288        is_grouped = (inp.name.startswith("pov_")
289                      or inp.name.startswith("left_stick")
290                      or inp.name.startswith("right_stick"))
291        if not is_grouped:
292            line_w = 4 if has_actions else 1
293            draw.line([(ax, ay), (box_cx, box_cy)],
294                      fill=LINE_COLOR, width=line_w)
295
296        fill = BOX_FILL_ASSIGNED if has_actions else BOX_FILL
297        if is_dpad:
298            total_height = box_h
299        elif has_actions:
300            total_height = label_y_offset + len(actions) * action_step
301        else:
302            total_height = box_h
303
304        # Box background
305        draw.rectangle(
306            [lx, ly, lx + box_w, ly + total_height],
307            fill=fill, outline=BOX_OUTLINE, width=1,
308        )
309
310        # Track boxes for connector groups
311        for prefix, _ in connector_groups:
312            if inp.name.startswith(prefix):
313                group_boxes[prefix].append(
314                    (lx, ly, lx + box_w, ly + total_height))
315
316        # Axis color indicator
317        axis_tag = None
318        if inp.name.endswith("_x"):
319            axis_tag = "X"
320        elif inp.name.endswith("_y"):
321            axis_tag = "Y"
322        label_color = (AXIS_INDICATOR_COLORS[axis_tag]
323                       if axis_tag else "#555555")
324
325        # Input icon (scaled to box height)
326        ico_size = max(8, box_h - int(8 * s))
327        text_x = lx + box_pad
328        if icon_loader:
329            icon = icon_loader.get_pil_icon(inp.name, ico_size)
330            if icon:
331                page.paste(icon, (int(lx + box_pad), int(ly + 1)), icon)
332                text_x = lx + box_pad + ico_size + 4
333
334        if is_dpad:
335            # D-pad compact: icon + label + action on one line
336            line_text = inp.display_name
337            if has_actions:
338                line_text += " : " + all_actions[0]
339            draw.text((text_x, ly + 1), line_text,
340                      fill=ASSIGNED_COLOR if has_actions else label_color,
341                      font=action_font if has_actions else label_font)
342            # Large "+" when extra bindings are hidden
343            if has_actions and len(all_actions) > 1:
344                plus_font = _get_font(plus_font_size)
345                draw.text((lx + box_w + 6, ly - 10), "+",
346                          fill=ASSIGNED_COLOR, font=plus_font)
347        else:
348            draw.text((text_x, ly + 1), inp.display_name,
349                      fill=label_color, font=label_font)
350
351            # Action names or unassigned
352            if has_actions:
353                for i, action in enumerate(actions):
354                    draw.text(
355                        (lx + box_pad,
356                         ly + label_y_offset + i * action_step),
357                        action, fill=ASSIGNED_COLOR, font=action_font)
358                # "+" when actions are truncated
359                if len(all_actions) > len(actions):
360                    plus_font = _get_font(plus_font_size)
361                    draw.text((lx + box_w + 6, ly - 10), "+",
362                              fill=ASSIGNED_COLOR, font=plus_font)
363            else:
364                draw.text((lx + box_pad, ly + label_y_offset),
365                          UNASSIGNED_TEXT, fill=UNASSIGNED_COLOR,
366                          font=unassigned_font)
367
368    # Connector bars: vertical bar + single leader line per group
369    for prefix, anchor_name in connector_groups:
370        boxes = group_boxes[prefix]
371        if not boxes:
372            continue
373        right_x = max(b[2] for b in boxes)
374        top_y = min(b[1] for b in boxes)
375        bottom_y = max(b[3] for b in boxes)
376        bar_x = right_x + 8
377        draw.line([(bar_x, top_y), (bar_x, bottom_y)],
378                  fill=LINE_COLOR, width=4)
379        anchor_inp = XBOX_INPUT_MAP.get(anchor_name)
380        if anchor_inp:
381            aax, aay = map_frac(anchor_inp.anchor_x, anchor_inp.anchor_y)
382            bar_mid_y = (top_y + bottom_y) / 2
383            draw.line([(aax, aay), (bar_x, bar_mid_y)],
384                      fill=LINE_COLOR, width=2)
385
386    return page

Render a single controller layout to a PIL Image.

Args: ctrl: Controller config with bindings. width: Target image width in pixels. height: Target image height in pixels. label_positions: Optional custom label positions (img pixel coords). hide_unassigned: If True, skip inputs with no bindings.

Returns: RGB PIL Image with the rendered controller layout.

def render_portrait_page( controllers: list[utils.controller.ControllerConfig], label_positions: dict[str, tuple[int, int]] | None = None, hide_unassigned: bool = False, icon_loader=None) -> PIL.Image.Image:
389def render_portrait_page(
390    controllers: list[ControllerConfig],
391    label_positions: dict[str, tuple[int, int]] | None = None,
392    hide_unassigned: bool = False,
393    icon_loader=None,
394) -> Image.Image:
395    """Render up to 2 controllers stacked vertically on a portrait page."""
396    page = Image.new("RGB", (PAGE_W_PORTRAIT, PAGE_H_PORTRAIT), "white")
397    slot_h = (PAGE_H_PORTRAIT - MARGIN) // 2
398
399    for i, ctrl in enumerate(controllers[:2]):
400        ctrl_img = render_controller(
401            ctrl, PAGE_W_PORTRAIT, slot_h, label_positions,
402            hide_unassigned, icon_loader)
403        page.paste(ctrl_img, (0, i * slot_h + (MARGIN // 2)))
404
405    return page

Render up to 2 controllers stacked vertically on a portrait page.

def render_landscape_page( ctrl: utils.controller.ControllerConfig, label_positions: dict[str, tuple[int, int]] | None = None, hide_unassigned: bool = False, icon_loader=None) -> PIL.Image.Image:
408def render_landscape_page(
409    ctrl: ControllerConfig,
410    label_positions: dict[str, tuple[int, int]] | None = None,
411    hide_unassigned: bool = False,
412    icon_loader=None,
413) -> Image.Image:
414    """Render 1 controller on a landscape page."""
415    return render_controller(
416        ctrl, PAGE_W_LANDSCAPE, PAGE_H_LANDSCAPE, label_positions,
417        hide_unassigned, icon_loader)

Render 1 controller on a landscape page.

def export_pages( config: utils.controller.FullConfig, orientation: str, output_path: str | pathlib.Path, label_positions: dict[str, tuple[int, int]] | None = None, hide_unassigned: bool = False, icon_loader=None):
420def export_pages(
421    config: FullConfig,
422    orientation: str,
423    output_path: str | Path,
424    label_positions: dict[str, tuple[int, int]] | None = None,
425    hide_unassigned: bool = False,
426    icon_loader=None,
427):
428    """Export all controllers as PNG or multi-page PDF.
429
430    Args:
431        config: Full controller configuration.
432        orientation: "portrait" (2 per page) or "landscape" (1 per page).
433        output_path: Destination file path (.png or .pdf).
434        label_positions: Optional custom label positions.
435        hide_unassigned: If True, skip inputs with no bindings.
436    """
437    output_path = Path(output_path)
438    fmt = output_path.suffix.lower().lstrip(".")
439    if fmt not in ("png", "pdf"):
440        raise ValueError(f"Unsupported format: {fmt} (use .png or .pdf)")
441
442    controllers = [config.controllers[p]
443                   for p in sorted(config.controllers.keys())]
444    if not controllers:
445        raise ValueError("No controllers to export")
446
447    pages: list[Image.Image] = []
448
449    if orientation == "portrait":
450        # Group controllers in pairs
451        for i in range(0, len(controllers), 2):
452            batch = controllers[i:i + 2]
453            pages.append(render_portrait_page(
454                batch, label_positions, hide_unassigned, icon_loader))
455    else:
456        for ctrl in controllers:
457            pages.append(render_landscape_page(
458                ctrl, label_positions, hide_unassigned, icon_loader))
459
460    if fmt == "pdf":
461        # Pillow multi-page PDF
462        first = pages[0]
463        rest = pages[1:] if len(pages) > 1 else []
464        first.save(str(output_path), "PDF", resolution=DPI,
465                   save_all=True, append_images=rest)
466    else:
467        # PNG — single page or numbered pages
468        if len(pages) == 1:
469            pages[0].save(str(output_path), "PNG")
470        else:
471            stem = output_path.stem
472            parent = output_path.parent
473            for i, page in enumerate(pages, 1):
474                name = f"{stem}_page{i}.png"
475                page.save(str(parent / name), "PNG")

Export all controllers as PNG or multi-page PDF.

Args: config: Full controller configuration. orientation: "portrait" (2 per page) or "landscape" (1 per page). output_path: Destination file path (.png or .pdf). label_positions: Optional custom label positions. hide_unassigned: If True, skip inputs with no bindings.