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 _render_pages(
420    config: FullConfig,
421    orientation: str,
422    label_positions: dict[str, tuple[int, int]] | None = None,
423    hide_unassigned: bool = False,
424    icon_loader=None,
425) -> list[Image.Image]:
426    """Build the list of PIL pages for an export.  Shared by file and
427    in-memory exporters so they stay byte-stable with each other."""
428    controllers = [config.controllers[p]
429                   for p in sorted(config.controllers.keys())]
430    if not controllers:
431        raise ValueError("No controllers to export")
432
433    pages: list[Image.Image] = []
434    if orientation == "portrait":
435        for i in range(0, len(controllers), 2):
436            batch = controllers[i:i + 2]
437            pages.append(render_portrait_page(
438                batch, label_positions, hide_unassigned, icon_loader))
439    else:
440        for ctrl in controllers:
441            pages.append(render_landscape_page(
442                ctrl, label_positions, hide_unassigned, icon_loader))
443    return pages
444
445
446def render_to_bytes(
447    config: FullConfig,
448    orientation: str,
449    fmt: str,
450    label_positions: dict[str, tuple[int, int]] | None = None,
451    hide_unassigned: bool = False,
452    icon_loader=None,
453) -> bytes:
454    """Render the export and return the raw bytes.
455
456    Used by the web editor's HTTP export endpoint so the result can be
457    streamed without touching the filesystem.  PNG multi-page exports
458    (more than one controller per page in portrait or one per page in
459    landscape) raise ``ValueError`` — request PDF instead.
460    """
461    fmt = fmt.lower().lstrip(".")
462    if fmt not in ("png", "pdf"):
463        raise ValueError(f"Unsupported format: {fmt} (use png or pdf)")
464
465    pages = _render_pages(config, orientation, label_positions,
466                          hide_unassigned, icon_loader)
467
468    buf = io.BytesIO()
469    if fmt == "pdf":
470        first = pages[0]
471        rest = pages[1:] if len(pages) > 1 else []
472        first.save(buf, "PDF", resolution=DPI,
473                   save_all=True, append_images=rest)
474    else:
475        if len(pages) > 1:
476            raise ValueError(
477                "PNG export only supports a single page; "
478                "use PDF for multi-controller layouts.")
479        pages[0].save(buf, "PNG")
480    return buf.getvalue()
481
482
483def export_pages(
484    config: FullConfig,
485    orientation: str,
486    output_path: str | Path,
487    label_positions: dict[str, tuple[int, int]] | None = None,
488    hide_unassigned: bool = False,
489    icon_loader=None,
490):
491    """Export all controllers as PNG or multi-page PDF.
492
493    Args:
494        config: Full controller configuration.
495        orientation: "portrait" (2 per page) or "landscape" (1 per page).
496        output_path: Destination file path (.png or .pdf).
497        label_positions: Optional custom label positions.
498        hide_unassigned: If True, skip inputs with no bindings.
499    """
500    output_path = Path(output_path)
501    fmt = output_path.suffix.lower().lstrip(".")
502    if fmt not in ("png", "pdf"):
503        raise ValueError(f"Unsupported format: {fmt} (use .png or .pdf)")
504
505    pages = _render_pages(config, orientation, label_positions,
506                          hide_unassigned, icon_loader)
507
508    if fmt == "pdf":
509        # Pillow multi-page PDF
510        first = pages[0]
511        rest = pages[1:] if len(pages) > 1 else []
512        first.save(str(output_path), "PDF", resolution=DPI,
513                   save_all=True, append_images=rest)
514    else:
515        # PNG — single page or numbered pages
516        if len(pages) == 1:
517            pages[0].save(str(output_path), "PNG")
518        else:
519            stem = output_path.stem
520            parent = output_path.parent
521            for i, page in enumerate(pages, 1):
522                name = f"{stem}_page{i}.png"
523                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 render_to_bytes( config: utils.controller.FullConfig, orientation: str, fmt: str, label_positions: dict[str, tuple[int, int]] | None = None, hide_unassigned: bool = False, icon_loader=None) -> bytes:
447def render_to_bytes(
448    config: FullConfig,
449    orientation: str,
450    fmt: str,
451    label_positions: dict[str, tuple[int, int]] | None = None,
452    hide_unassigned: bool = False,
453    icon_loader=None,
454) -> bytes:
455    """Render the export and return the raw bytes.
456
457    Used by the web editor's HTTP export endpoint so the result can be
458    streamed without touching the filesystem.  PNG multi-page exports
459    (more than one controller per page in portrait or one per page in
460    landscape) raise ``ValueError`` — request PDF instead.
461    """
462    fmt = fmt.lower().lstrip(".")
463    if fmt not in ("png", "pdf"):
464        raise ValueError(f"Unsupported format: {fmt} (use png or pdf)")
465
466    pages = _render_pages(config, orientation, label_positions,
467                          hide_unassigned, icon_loader)
468
469    buf = io.BytesIO()
470    if fmt == "pdf":
471        first = pages[0]
472        rest = pages[1:] if len(pages) > 1 else []
473        first.save(buf, "PDF", resolution=DPI,
474                   save_all=True, append_images=rest)
475    else:
476        if len(pages) > 1:
477            raise ValueError(
478                "PNG export only supports a single page; "
479                "use PDF for multi-controller layouts.")
480        pages[0].save(buf, "PNG")
481    return buf.getvalue()

Render the export and return the raw bytes.

Used by the web editor's HTTP export endpoint so the result can be streamed without touching the filesystem. PNG multi-page exports (more than one controller per page in portrait or one per page in landscape) raise ValueError — request PDF instead.

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):
484def export_pages(
485    config: FullConfig,
486    orientation: str,
487    output_path: str | Path,
488    label_positions: dict[str, tuple[int, int]] | None = None,
489    hide_unassigned: bool = False,
490    icon_loader=None,
491):
492    """Export all controllers as PNG or multi-page PDF.
493
494    Args:
495        config: Full controller configuration.
496        orientation: "portrait" (2 per page) or "landscape" (1 per page).
497        output_path: Destination file path (.png or .pdf).
498        label_positions: Optional custom label positions.
499        hide_unassigned: If True, skip inputs with no bindings.
500    """
501    output_path = Path(output_path)
502    fmt = output_path.suffix.lower().lstrip(".")
503    if fmt not in ("png", "pdf"):
504        raise ValueError(f"Unsupported format: {fmt} (use .png or .pdf)")
505
506    pages = _render_pages(config, orientation, label_positions,
507                          hide_unassigned, icon_loader)
508
509    if fmt == "pdf":
510        # Pillow multi-page PDF
511        first = pages[0]
512        rest = pages[1:] if len(pages) > 1 else []
513        first.save(str(output_path), "PDF", resolution=DPI,
514                   save_all=True, append_images=rest)
515    else:
516        # PNG — single page or numbered pages
517        if len(pages) == 1:
518            pages[0].save(str(output_path), "PNG")
519        else:
520            stem = output_path.stem
521            parent = output_path.parent
522            for i, page in enumerate(pages, 1):
523                name = f"{stem}_page{i}.png"
524                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.