Skip to content

pmaher86/blacksquare

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

68 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Blacksquare

Build Status Documentation Status

Blacksquare is a Python package for crossword creators. It aims to be an intuitive interface for working with crossword puzzles programmatically. It also has tools for finding valid fills, and HTML rendering that plugs nicely into Jupyter notebooks. Blacksquare supports import and export from the .puz format via puzpy, as well as .pdf export in the New York Times submission format (requires wkhtmltopdf).

Native HTML rendering in Jupyter

Jupyter example

Basic features

The interface is built to use Python's indexing syntax to express high-level crossword concepts.

>>> from blacksquare import Crossword, BLACK, EMPTY, ACROSS, DOWN, DEFAULT_WORDLIST
>>> xw = Crossword(num_rows=7)
# (row, column) indexing for individual cells
>>> xw[3,3] = BLACK
>>> xw.pprint(numbers=True)
β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”
β”‚1  β”‚2  β”‚3  β”‚4  β”‚5  β”‚6  β”‚7  β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚8  β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚9  β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚10 β”‚   β”‚   β”‚β–ˆβ–ˆβ–ˆβ”‚11 β”‚   β”‚   β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚12 β”‚   β”‚   β”‚13 β”‚   β”‚   β”‚   β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚14 β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚15 β”‚   β”‚   β”‚   β”‚   β”‚   β”‚   β”‚
β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜
# (direction, number) indexing for words
>>> xw[ACROSS, 10] = 'DOE'
>>> xw[DOWN, 3] = xw[DOWN, 3].find_matches().words[0]
>>> xw.pprint()
β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”
β”‚^  β”‚^  β”‚^C β”‚^  β”‚^  β”‚^  β”‚^  β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^  β”‚   β”‚ A β”‚   β”‚   β”‚   β”‚   β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^  β”‚   β”‚ V β”‚   β”‚   β”‚   β”‚   β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^D β”‚ O β”‚ E β”‚β–ˆβ–ˆβ–ˆβ”‚^  β”‚   β”‚   β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^  β”‚   β”‚ A β”‚^  β”‚   β”‚   β”‚   β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^  β”‚   β”‚ R β”‚   β”‚   β”‚   β”‚   β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^  β”‚   β”‚ T β”‚   β”‚   β”‚   β”‚   β”‚
β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜
# We can also index into Word objects
>>> xw[DOWN, 3][1] = 'I'
>>> xw[DOWN, 3][0] = EMPTY
>>> xw[DOWN, 3].value
' IVEART'

Puzzles can be imported and exported easily.

>>> xw.to_puz('puzzle.puz')
>>> xw = Crossword.from_puz('puzzle.puz')
>>> xw.to_pdf('puzzle.pdf', header=['Name', 'Address', 'Email'])

There are useful utility functions for navigating.

>>> unfilled_words = list(xw.iterwords(only_open=True))
>>> xw[DOWN, 13].crosses
[Word(Across 12: "??A????)",
 Word(Across 14: "??R????)",
 Word(Across 15: "??T????)"]

Clues can be attached to words.

>>> xw[ACROSS, 10].clue = "A deer, a female deer"
>>> xw.clues
{(<Across>, 1): '',
 (<Across>, 8): '',
 (<Across>, 9): '',
 (<Across>, 10): 'A deer, a female deer',
 (<Across>, 11): '',
 (<Across>, 12): '',
 (<Across>, 14): '',
 (<Across>, 15): '',
 (<Down>, 1): '',
 (<Down>, 2): '',
 (<Down>, 3): '',
 (<Down>, 4): '',
 (<Down>, 5): '',
 (<Down>, 6): '',
 (<Down>, 7): '',
 (<Down>, 13): ''}

You can also copy grid objects, to support things like custom branching searches.

>>> new_xw = xw.copy()
>>> new_xw[ACROSS, 11] = 'ABC'

A main attraction are the utilities to help find valid fills. It implements an algorithm called cross_rank that tries to maximize the number of valid matches for each cross. Multiple clues can searched at once for mutually compatible solutions using a beam search method, and entire continuous unfilled areas can be searched from a single seed clue.

>>> matches = xw[DOWN, 1].find_matches()
>>> matches[0]
ScoredWord(word='MACDUFF', score=25.387070408819042)
# This returns a new valid Crossword fill, with optional randomness and word list control.
>>> filled = xw.fill(temperature=1, word_list=DEFAULT_WORDLIST.score_filter(0.5))

Custom word lists are supported and can be passed into the Crossword constructor or any of the solving methods. The default word list used is the Crossword Nexus Collaborative Word List.

Example: full symmetry puzzles

As an example of how blacksquare's abstractions allow for non-trivial crossword construction, consider the June 6 2023 NYT puzzle, which displays not only a rotationaly symmetric grid but a rotationally symmetric fill. While this might seem daunting to build, all we have to do is override the set_word method of Crossword to fill two words at once, and then restrict our wordlist to emordnilaps (words that are also a word when reversed).

class SymmetricCrossword(Crossword):
    def set_word(self, word_index: WordIndex, value: str) -> None:
        super().set_word(word_index, value)
        super().set_word(self.get_symmetric_word_index(word_index), value[::-1])

emordilaps = {}
for word, score in tqdm(bs.DEFAULT_WORDLIST):
    reverse_score = bs.DEFAULT_WORDLIST.get_score(word[::-1])
    if reverse_score:
        emordilaps[word] = min(score, reverse_score)
emordilaps_wordlist = bs.WordList(emordilaps)

# Now just construct the puzzle and fill!
xw = SymmetricCrossword(15)
filled = [
    (0, 3), (0, 4), (0, 5), (0, 11), (1, 4), (1, 5), (1, 11),
    (2, 4), (2, 11), (3, 4), (3, 9), (4, 0), (4, 1), (4, 2),
    (4, 7), (4, 8), (4, 14), (5, 6), (5, 12), (5, 13), (5, 14), 
    (6, 5), (6, 10), (7, 3),
]
for i in filled:
    xw[i] = bs.BLACK
xw.fill(emordnilap_wordlist, temperature=0.3)

β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”
β”‚^F β”‚^E β”‚^N β”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚^S β”‚^N β”‚^I β”‚^P β”‚^S β”‚β–ˆβ–ˆβ–ˆβ”‚^E β”‚^D β”‚^A β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^L β”‚ I β”‚ A β”‚^R β”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚^P β”‚ O β”‚ S β”‚ E β”‚ A β”‚β–ˆβ–ˆβ–ˆβ”‚^V β”‚ E β”‚ R β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^O β”‚ K β”‚ I β”‚ E β”‚β–ˆβ–ˆβ–ˆβ”‚^R β”‚ E β”‚ W β”‚ A β”‚ R β”‚ D β”‚β–ˆβ–ˆβ–ˆβ”‚^A β”‚ L β”‚ B β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^G β”‚ O β”‚ R β”‚ T β”‚β–ˆβ–ˆβ–ˆβ”‚^A β”‚ T β”‚ I β”‚ N β”‚β–ˆβ–ˆβ–ˆβ”‚^D β”‚^E β”‚ L β”‚ I β”‚ A β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚^R β”‚^A β”‚ P β”‚ S β”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚^R β”‚ E β”‚ E β”‚ S β”‚ A β”‚β–ˆβ–ˆβ–ˆβ”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^S β”‚^T β”‚^R β”‚ O β”‚ P β”‚ S β”‚β–ˆβ–ˆβ–ˆβ”‚^S β”‚^P β”‚ A β”‚ N β”‚ K β”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^R β”‚ E β”‚ E β”‚ S β”‚ A β”‚β–ˆβ–ˆβ–ˆβ”‚^S β”‚ T β”‚ O β”‚ M β”‚β–ˆβ–ˆβ–ˆβ”‚^S β”‚^E β”‚^P β”‚^S β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^A β”‚ R β”‚ M β”‚β–ˆβ–ˆβ–ˆβ”‚^R β”‚^O β”‚ T β”‚ A β”‚ T β”‚ O β”‚^R β”‚β–ˆβ–ˆβ–ˆβ”‚^M β”‚ R β”‚ A β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^S β”‚ P β”‚ E β”‚^S β”‚β–ˆβ–ˆβ–ˆβ”‚^M β”‚ O β”‚ T β”‚ S β”‚β–ˆβ–ˆβ–ˆβ”‚^A β”‚^S β”‚ E β”‚ E β”‚ R β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚^K β”‚^N β”‚ A β”‚ P β”‚ S β”‚β–ˆβ–ˆβ–ˆβ”‚^S β”‚ P β”‚ O β”‚ R β”‚ T β”‚ S β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚β–ˆβ–ˆβ–ˆβ”‚^A β”‚^S β”‚ E β”‚ E β”‚ R β”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚^S β”‚ P β”‚ A β”‚ R β”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^A β”‚ I β”‚ L β”‚ E β”‚ D β”‚β–ˆβ–ˆβ–ˆβ”‚^N β”‚^I β”‚ T β”‚ A β”‚β–ˆβ–ˆβ–ˆβ”‚^T β”‚^R β”‚^O β”‚^G β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^B β”‚ L β”‚ A β”‚β–ˆβ–ˆβ–ˆβ”‚^D β”‚^R β”‚ A β”‚ W β”‚ E β”‚ R β”‚β–ˆβ–ˆβ–ˆβ”‚^E β”‚ I β”‚ K β”‚ O β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^R β”‚ E β”‚ V β”‚β–ˆβ–ˆβ–ˆβ”‚^A β”‚ E β”‚ S β”‚ O β”‚ P β”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚^R β”‚ A β”‚ I β”‚ L β”‚
β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
β”‚^A β”‚ D β”‚ E β”‚β–ˆβ–ˆβ–ˆβ”‚^S β”‚ P β”‚ I β”‚ N β”‚ S β”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚β–ˆβ–ˆβ–ˆβ”‚^N β”‚ E β”‚ F β”‚
β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜

There's clearly some extra curation that could be done to improve the word list, and we'd need a little more logic to avoid repeat fills and using true palindromes outside of the center. But not bad for a few lines of code!

Installation

pip install blacksquare

You'll also need to install wkhtmltopdf for .pdf export to work.

Future plans

Blacksquare is not a GUI application and isn't intended to be one. Blacksquare is also not a package for solving crossword puzzles.

Possible extensions include:

  • Other file formats
  • Other/better filling heuristics
  • Verifying puzzle validity
  • Rebuses
  • Annotations for themed puzzles (circles etc.)