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")
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.
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.
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.
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.