Last active
June 2, 2026 08:37
-
-
Save felipem775/66984e81eea73e1deed6b23e1a57694a to your computer and use it in GitHub Desktop.
Convierte un sgf a un PDF de 2 páginas din-a4 con 8 diagramas. Dependencias pip: sgf y matplotlib
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import sys | |
| import math | |
| import sgf | |
| import matplotlib.pyplot as plt | |
| from matplotlib.patches import Circle | |
| from matplotlib.gridspec import GridSpec | |
| from matplotlib.backends.backend_pdf import PdfPages | |
| # Requires numpy and sgf packages | |
| def coord_to_go(row, col): | |
| col_letter = chr(ord("a") + col + (1 if col >= 8 else 0)) | |
| return f"{col_letter}{row+1}" | |
| def parse_sgf(sgf_content): | |
| collection = sgf.parse(sgf_content) | |
| game = collection[0] | |
| root = game.root | |
| props = root.properties | |
| board_size = int(props.get("SZ", ["19"])[0]) | |
| metadata = { | |
| "date": props.get("DT", ["?"])[0], | |
| "black": props.get("PB", ["?"])[0], | |
| "white": props.get("PW", ["?"])[0], | |
| "title": props.get("GN", ["?"])[0], | |
| "result": props.get("RE", [None])[0], | |
| } | |
| moves = [] | |
| node = root | |
| while node: | |
| if "B" in node.properties: | |
| color = "b" | |
| coord = node.properties["B"][0] | |
| elif "W" in node.properties: | |
| color = "w" | |
| coord = node.properties["W"][0] | |
| else: | |
| node = node.next | |
| continue | |
| if isinstance(coord, tuple): | |
| row, col = coord | |
| row -= 1 | |
| col -= 1 | |
| elif isinstance(coord, str) and len(coord) == 2: | |
| col = ord(coord[0]) - ord("a") | |
| row = (board_size - 1) - (ord(coord[1]) - ord("a")) | |
| else: | |
| node = node.next | |
| continue | |
| if 0 <= row < board_size and 0 <= col < board_size: | |
| moves.append((color, (row, col))) | |
| else: | |
| print(f"Advertencia: coordenada {coord} fuera de rango") | |
| node = node.next | |
| return board_size, moves, metadata | |
| def draw_board(ax, board, move_numbers, board_size, start_move, end_move): | |
| ax.clear() | |
| ax.set_aspect("equal") | |
| ax.axis("off") | |
| # Ajuste para coordenadas solo izquierda y arriba | |
| ax.set_xlim(-1.0, board_size - 1 + 0.4) | |
| ax.set_ylim(-0.8, board_size - 1 + 0.6) | |
| # Líneas | |
| for i in range(board_size): | |
| ax.plot([0, board_size - 1], [i, i], "k", lw=1, zorder=1) | |
| ax.plot([i, i], [0, board_size - 1], "k", lw=1, zorder=1) | |
| # Letras arriba | |
| letters = [] | |
| for j in range(board_size): | |
| l = chr(ord("a") + j) | |
| if l >= "i": | |
| l = chr(ord(l) + 1) | |
| letters.append(l) | |
| for col in range(board_size): | |
| ax.text( | |
| col, | |
| board_size - 0.6, | |
| letters[col], | |
| ha="center", | |
| va="bottom", | |
| fontsize=6, | |
| family="sans-serif", | |
| zorder=15, | |
| ) | |
| # Números izquierda | |
| numbers = [str(i + 1) for i in range(board_size)] | |
| for row in range(board_size): | |
| ax.text( | |
| -0.4, | |
| row, | |
| numbers[row], | |
| ha="right", | |
| va="center", | |
| fontsize=6, | |
| family="sans-serif", | |
| zorder=15, | |
| ) | |
| # Hoshi | |
| if board_size == 19: | |
| star_points = [ | |
| (3, 3), | |
| (15, 3), | |
| (3, 15), | |
| (15, 15), | |
| (9, 9), | |
| (3, 9), | |
| (15, 9), | |
| (9, 3), | |
| (9, 15), | |
| ] | |
| elif board_size == 13: | |
| star_points = [(3, 3), (9, 3), (3, 9), (9, 9), (6, 6)] | |
| elif board_size == 9: | |
| star_points = [(2, 2), (6, 2), (2, 6), (6, 6), (4, 4)] | |
| else: | |
| star_points = [] | |
| for x, y in star_points: | |
| ax.plot(x, y, "ko", markersize=4, zorder=1) | |
| # Piedras | |
| for row in range(board_size): | |
| for col in range(board_size): | |
| stone = board[row][col] | |
| if stone is None: | |
| continue | |
| color = "black" if stone == "b" else "white" | |
| circle = Circle( | |
| (col, row), | |
| 0.42, | |
| facecolor=color, | |
| edgecolor="black", | |
| linewidth=0.5, | |
| zorder=10, | |
| ) | |
| ax.add_patch(circle) | |
| move_num = move_numbers[row][col] | |
| if move_num is not None and start_move <= move_num <= end_move: | |
| text_color = "white" if stone == "b" else "black" | |
| ax.text( | |
| col, | |
| row, | |
| str(move_num), | |
| ha="center", | |
| va="center", | |
| fontsize=6, | |
| weight="normal", | |
| family="sans-serif", | |
| color=text_color, | |
| zorder=11, | |
| ) | |
| def render_diagram_moves( | |
| ax, | |
| sm, | |
| em, | |
| moves, | |
| captures_set, | |
| fontsize=7, | |
| items_per_line=7, | |
| result_text=None, | |
| ): | |
| """Renderiza los movimientos de un diagrama debajo de los ejes. | |
| Las jugadas en posiciones ya ocupadas se muestran subrayadas. | |
| Usa fuente monospace para estimar posición de cada token. | |
| Los números de movimiento se muestran en negrita. | |
| Si result_text está presente, se concatena al final de la secuencia. | |
| """ | |
| tokens = [] | |
| for j in range(sm - 1, em): | |
| _, (r, c) = moves[j] | |
| move_num = j + 1 | |
| coord = coord_to_go(r, c) | |
| tokens.append((move_num, coord, move_num in captures_set)) | |
| # Anchura de carácter en fracción de ejes (monospace, layout A4 2 col) | |
| # axes_width ≈ 0.44 * fig_width (2 cols, wspace=0.03, márgenes 0.05–0.95) | |
| # axes_height ≈ 0.80/(2+0.35) * fig_height (2 filas, hspace=0.35, top=0.90, bot=0.10) | |
| ax_w_pt = 0.44 * 8.27 * 72 # ≈ 262 pt | |
| ax_h_pt = 0.80 / 2.35 * 11.69 * 72 # ≈ 286 pt | |
| char_w = fontsize * 0.6 / ax_w_pt # ≈ 0.0137 fracción de eje por carácter | |
| sep_w = 2 * char_w # ", " | |
| underline_dy = ( | |
| fontsize * 1.05 / ax_h_pt | |
| ) # offset desde top hasta subrayado | |
| y_start = -0.04 | |
| line_spacing = 0.055 | |
| x_margin = 0.01 | |
| for chunk_start in range(0, len(tokens), items_per_line): | |
| chunk = tokens[chunk_start : chunk_start + items_per_line] | |
| line_num = chunk_start // items_per_line | |
| y = y_start - line_num * line_spacing | |
| # Renderizar cada token por separado para aplicar negrita al número | |
| x = x_margin | |
| for tok_idx, (move_num, coord, is_cap) in enumerate(chunk): | |
| if tok_idx > 0: | |
| # Renderizar separador ", " | |
| ax.text( | |
| x, | |
| y, | |
| ", ", | |
| ha="left", | |
| va="top", | |
| fontsize=fontsize, | |
| family="monospace", | |
| transform=ax.transAxes, | |
| clip_on=False, | |
| ) | |
| x += sep_w | |
| # Renderizar número de movimiento en negrita | |
| num_text = f"{move_num:>3}" | |
| ax.text( | |
| x, | |
| y, | |
| num_text, | |
| ha="left", | |
| va="top", | |
| fontsize=fontsize, | |
| weight="bold", | |
| family="monospace", | |
| transform=ax.transAxes, | |
| clip_on=False, | |
| ) | |
| x += len(num_text) * char_w | |
| # Renderizar ":" y coordenadas en normal | |
| coord_text = f":{coord:<3}" | |
| ax.text( | |
| x, | |
| y, | |
| coord_text, | |
| ha="left", | |
| va="top", | |
| fontsize=fontsize, | |
| family="monospace", | |
| transform=ax.transAxes, | |
| clip_on=False, | |
| ) | |
| # Dibujar subrayado si es captura | |
| tok_w = (len(num_text) + len(coord_text)) * char_w | |
| if is_cap: | |
| y_ul = y - underline_dy | |
| ax.plot( | |
| [x - len(num_text) * char_w, x + len(coord_text) * char_w], | |
| [y_ul, y_ul], | |
| "k-", | |
| linewidth=0.7, | |
| transform=ax.transAxes, | |
| clip_on=False, | |
| ) | |
| x += len(coord_text) * char_w | |
| # Agregar resultado al final si está presente | |
| if result_text: | |
| # Calcular en qué línea continuar | |
| total_tokens = len(tokens) | |
| last_line_tokens = total_tokens % items_per_line | |
| if last_line_tokens == 0: | |
| last_line_tokens = items_per_line | |
| last_line_num = (total_tokens - 1) // items_per_line | |
| y = y_start - last_line_num * line_spacing | |
| # Calcular posición x después del último movimiento | |
| x = x_margin | |
| last_chunk_start = (total_tokens // items_per_line) * items_per_line | |
| for tok_idx in range(last_line_tokens): | |
| if tok_idx > 0: | |
| x += sep_w | |
| x += 3 * char_w # número (3 chars) | |
| x += 4 * char_w # ":" + coordenadas (4 chars) | |
| # Renderizar separador y resultado | |
| ax.text( | |
| x, | |
| y, | |
| " — ", | |
| ha="left", | |
| va="top", | |
| fontsize=fontsize, | |
| family="monospace", | |
| transform=ax.transAxes, | |
| clip_on=False, | |
| ) | |
| x += 5 * char_w | |
| ax.text( | |
| x, | |
| y, | |
| result_text, | |
| ha="left", | |
| va="top", | |
| fontsize=fontsize, | |
| weight="bold", | |
| family="monospace", | |
| transform=ax.transAxes, | |
| clip_on=False, | |
| ) | |
| def main(sgf_file, pdf_output): | |
| with open(sgf_file, "r", encoding="utf-8") as f: | |
| sgf_content = f.read() | |
| board_size, moves, metadata = parse_sgf(sgf_content) | |
| if not moves: | |
| print("El SGF no contiene movimientos válidos.") | |
| sys.exit(1) | |
| total_moves = len(moves) | |
| # Calcular 8 puntos equidistantes en la partida | |
| indices = [ | |
| int(math.floor((i + 1) * (total_moves - 1) / 8.0)) for i in range(7) | |
| ] | |
| indices.append(total_moves - 1) # Posición final | |
| if len(set(indices)) < 8: | |
| indices = sorted(set(indices)) | |
| while len(indices) < 8: | |
| indices.append(total_moves - 1) | |
| indices = sorted(set(indices))[:8] | |
| # Generar estados para los 8 diagramas | |
| diagram_data = [] | |
| for i, idx in enumerate(indices): | |
| board = [[None for _ in range(board_size)] for _ in range(board_size)] | |
| move_numbers = [ | |
| [None for _ in range(board_size)] for _ in range(board_size) | |
| ] | |
| captures = [] # Lista de capturas en este diagrama | |
| start_move = 1 if i == 0 else indices[i - 1] + 2 | |
| for j in range(idx + 1): | |
| color, (row, col) = moves[j] | |
| move_num = j + 1 | |
| # Detectar si hay una piedra en esta posición | |
| if board[row][col] is not None and move_num >= start_move: | |
| # Captura detectada | |
| captures.append((move_num, coord_to_go(row, col))) | |
| board[row][col] = color | |
| move_numbers[row][col] = move_num | |
| end_move = idx + 1 | |
| diagram_data.append( | |
| (board, move_numbers, start_move, end_move, captures) | |
| ) | |
| # Crear PDF con 2 páginas | |
| with PdfPages(pdf_output) as pdf: | |
| # Página 1: primeros 4 diagramas | |
| fig = plt.figure(figsize=(8.27, 11.69)) | |
| fig.suptitle( | |
| f"{metadata['date']} {metadata['title']}\n{metadata['black']} (B) vs {metadata['white']} (W)", | |
| fontsize=12, | |
| y=0.96, | |
| ) | |
| gs = GridSpec( | |
| 1, | |
| 1, | |
| top=0.90, | |
| bottom=0.10, | |
| left=0.05, | |
| right=0.95, | |
| ) | |
| gs_diag = gs[0].subgridspec(2, 2, hspace=0.35, wspace=0.03) | |
| axes = [] | |
| for i in range(2): | |
| for j in range(2): | |
| ax = fig.add_subplot(gs_diag[i, j]) | |
| axes.append(ax) | |
| for i, ax in enumerate(axes): | |
| board, mn, sm, em, captures = diagram_data[i] | |
| draw_board(ax, board, mn, board_size, sm, em) | |
| ax.set_title(f"{sm} - {em}", fontsize=10, pad=12) | |
| captures_set = {num for num, _ in captures} | |
| render_diagram_moves(ax, sm, em, moves, captures_set) | |
| pdf.savefig(fig, dpi=300) | |
| plt.close(fig) | |
| # Página 2: últimos 4 diagramas | |
| fig = plt.figure(figsize=(8.27, 11.69)) | |
| fig.suptitle( | |
| f"{metadata['date']} {metadata['title']}\n{metadata['black']} (B) vs {metadata['white']} (W)", | |
| fontsize=12, | |
| y=0.96, | |
| ) | |
| gs = GridSpec( | |
| 1, | |
| 1, | |
| top=0.91, | |
| bottom=0.09, | |
| left=0.05, | |
| right=0.95, | |
| ) | |
| gs_diag = gs[0].subgridspec(2, 2, hspace=0.25, wspace=0.03) | |
| axes = [] | |
| for i in range(2): | |
| for j in range(2): | |
| ax = fig.add_subplot(gs_diag[i, j]) | |
| axes.append(ax) | |
| for i, ax in enumerate(axes): | |
| board, mn, sm, em, captures = diagram_data[i + 4] | |
| draw_board(ax, board, mn, board_size, sm, em) | |
| ax.set_title(f"{sm} - {em}", fontsize=10, pad=12) | |
| captures_set = {num for num, _ in captures} | |
| # Pasar resultado solo en el último diagrama | |
| result = ( | |
| metadata["result"] if (i == 3 and metadata["result"]) else None | |
| ) | |
| render_diagram_moves( | |
| ax, sm, em, moves, captures_set, result_text=result | |
| ) | |
| pdf.savefig(fig, dpi=300) | |
| plt.close(fig) | |
| print(f"PDF generado: {pdf_output}") | |
| if __name__ == "__main__": | |
| if len(sys.argv) != 3: | |
| print("Uso: python sgf_a4_kifu.py archivo.sgf salida.pdf") | |
| sys.exit(1) | |
| main(sys.argv[1], sys.argv[2]) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment