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