Crossword Puzzle Grid — Pygal

A crossword puzzle grid visualization with white entry cells for letters, black blocking cells, and numbered starting positions for across and down words. The symmetric black cell pattern follows traditional newspaper-style crossword conventions, creating a clean and recognizable puzzle layout suitable for printing or digital display.

Crossword Puzzle Grid rendered with Pygal

Python source (Pygal)

""" anyplot.ai
crossword-basic: Crossword Puzzle Grid
Library: pygal 3.1.0 | Python 3.13.13
Quality: 87/100 | Updated: 2026-05-20
"""

import os as _os
import sys as _sys


# This file is named pygal.py — remove its directory from sys.path first so
# Python resolves `pygal` to the installed package, not this file itself.
_here = _os.path.abspath(_os.path.dirname(__file__))
_sys.path = [p for p in _sys.path if _os.path.abspath(p or ".") != _here]

import os

import cairosvg
import numpy as np
from pygal.graph.graph import Graph
from pygal.style import Style


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

CANVAS = 2400
CELL = 140
GRID_SIZE = 15
GRID_W = CELL * GRID_SIZE  # 2100 px — fills the plot area exactly
NUM_SZ = 28

TITLE = "crossword-basic · python · pygal · anyplot.ai"

# Pygal Style — applied to the chart for theme-consistent background, title, and chrome
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_SOFT,
    colors=("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477"),
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=2.5,
)

# Crossword grid data — 15×15 with 180° rotational symmetry
np.random.seed(42)
grid = np.zeros((GRID_SIZE, GRID_SIZE), dtype=int)

black_positions_top = [
    (0, 4),
    (0, 10),
    (1, 4),
    (1, 10),
    (2, 7),
    (3, 0),
    (3, 1),
    (3, 6),
    (4, 5),
    (4, 9),
    (5, 3),
    (5, 11),
    (6, 0),
    (6, 8),
    (6, 14),
]
black_positions_center = [(7, 2), (7, 7), (7, 12)]

for r, c in black_positions_top:
    grid[r, c] = 1
    grid[GRID_SIZE - 1 - r, GRID_SIZE - 1 - c] = 1
for r, c in black_positions_center:
    grid[r, c] = 1

# Clue numbering — cells that start an across or down word
numbers = {}
clue_num = 1
for row in range(GRID_SIZE):
    for col in range(GRID_SIZE):
        if grid[row, col] == 0:
            starts_across = (col == 0 or grid[row, col - 1] == 1) and (col < GRID_SIZE - 1 and grid[row, col + 1] == 0)
            starts_down = (row == 0 or grid[row - 1, col] == 1) and (row < GRID_SIZE - 1 and grid[row + 1, col] == 0)
            if starts_across or starts_down:
                numbers[(row, col)] = clue_num
                clue_num += 1


class CrosswordGrid(Graph):
    """Custom pygal chart type that renders a crossword puzzle grid.

    Subclasses Graph to use pygal's SVG infrastructure (style, background,
    title, render pipeline) while drawing grid cells via pygal's SVG node API.
    """

    def _compute(self):
        pass

    def _compute_x_labels(self):
        pass

    def _compute_y_labels(self):
        pass

    def _draw(self):
        """Draw chart elements; always calls _plot since no series data is needed."""
        self._compute()
        self._compute_x_labels()
        self._compute_x_labels_major()
        self._compute_y_labels()
        self._compute_y_labels_major()
        self._compute_secondary()
        self._post_compute()
        self._compute_margin()
        self._decorate()
        self._plot()

    def _plot(self):
        """Render crossword cells using pygal's SVG node API.

        Coordinates are relative to the plot group (margin already applied),
        so (0, 0) maps to the top-left corner of the grid area on the canvas.
        """
        plot = self.nodes["plot"]
        for row in range(GRID_SIZE):
            for col in range(GRID_SIZE):
                x = col * CELL
                y = row * CELL
                fill = INK if grid[row, col] == 1 else ELEVATED_BG

                rect = self.svg.node(plot, "rect", x=x, y=y, width=CELL, height=CELL, fill=fill)
                rect.set("stroke", INK_SOFT)
                rect.set("stroke-width", "2")

                if (row, col) in numbers:
                    txt = self.svg.node(plot, "text", x=x + 6, y=y + NUM_SZ + 3, fill=INK)
                    txt.set("font-family", "Arial, sans-serif")
                    txt.set("font-size", str(NUM_SZ))
                    txt.text = str(numbers[(row, col)])


# margin=150 → equal 150 px margins on all sides; plot area = 2100×2100 = grid size
chart = CrosswordGrid(style=custom_style, width=CANVAS, height=CANVAS, title=TITLE, show_legend=False, margin=150)

# Render via pygal's pipeline: render() produces the SVG, cairosvg converts to PNG
svg_bytes = chart.render()
cairosvg.svg2png(bytestring=svg_bytes, write_to=f"plot-{THEME}.png", output_width=CANVAS, output_height=CANVAS)

# HTML: pygal's interactive SVG output (embedded in a minimal page)
with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:
    f.write(chart.render(is_unicode=True))

Part of Crossword Puzzle Grid on anyplot.ai.

Other implementations