From 5510d542cc1bf165afb0d7f889cc22804a932e59 Mon Sep 17 00:00:00 2001 From: Patrick Maher Date: Sun, 6 Oct 2024 23:20:14 -0400 Subject: [PATCH 1/3] move to weasyprint --- .github/workflows/build-and-test.yaml | 7 +- README.md | 6 +- pyproject.toml | 11 +-- requirements.txt | 39 ++++++++- src/blacksquare/crossword.py | 120 ++++++++++++++------------ src/blacksquare/html.py | 54 ++++++++++++ 6 files changed, 163 insertions(+), 74 deletions(-) create mode 100644 src/blacksquare/html.py diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 9116b38..1f17c31 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -22,13 +22,8 @@ jobs: pip install --upgrade pip pip install -r requirements.txt - - name: Install wkhtmltopdf - run: | - sudo apt-get update - sudo apt-get -y install wkhtmltopdf - - name: Install package - run: pip install .[dev] + run: pip install .[dev,pdf] - name: Run pre-commit run: pre-commit run --show-diff-on-failure --color=always --all-files diff --git a/README.md b/README.md index 9f5b904..c78892d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Blacksquare ![Build Status](https://github.com/pmaher86/blacksquare/actions/workflows/build-and-test.yaml/badge.svg) ![Documentation Status](https://readthedocs.org/projects/blacksquare/badge/?version=latest) -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](https://github.com/alexdej/puzpy), as well as .pdf export in the [New York Times submission format](https://www.nytimes.com/puzzles/submissions/crossword) (requires [wkhtmltopdf](https://wkhtmltopdf.org/)). +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](https://github.com/alexdej/puzpy), as well as .pdf export in the [New York Times submission format](https://www.nytimes.com/puzzles/submissions/crossword) (requires the [pdf] extra). ## Native HTML rendering in Jupyter ![Jupyter example](assets/jupyter.png?raw=true) @@ -173,7 +173,9 @@ There's clearly some extra curation that could be done to improve the word list, ## Installation `pip install blacksquare` -You'll also need to install [wkhtmltopdf](https://wkhtmltopdf.org/) for .pdf export to work. +or if you want to enable pdf export + +`pip install blacksquare[pdf]` ## 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. diff --git a/pyproject.toml b/pyproject.toml index 8e4b94f..f4b05d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,15 +6,7 @@ name = "blacksquare" description = "A package for creating crossword puzzles" authors = [{ name = "Patrick Maher", email = "pmaher86@gmail.com" }] readme = "README.md" -dependencies = [ - "networkx", - "numpy", - "pandas", - "pdfkit", - "puzpy", - "pypdf", - "rich", -] +dependencies = ["networkx", "numpy", "pandas", "puzpy", "pypdf", "rich"] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -28,6 +20,7 @@ Source = "https://github.com/pmaher86/blacksquare" [project.optional-dependencies] dev = ["pre-commit", "pytest", "uv"] +pdf = ["weasyprint"] [tool.setuptools_scm] local_scheme = "no-local-version" diff --git a/requirements.txt b/requirements.txt index f6bdf17..ddff6b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,21 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --extra=dev pyproject.toml -o requirements.txt +# uv pip compile --extra dev --extra pdf pyproject.toml -o requirements.txt +brotli==1.1.0 + # via fonttools +cffi==1.17.1 + # via weasyprint cfgv==3.4.0 # via pre-commit +cssselect2==0.7.0 + # via weasyprint distlib==0.3.8 # via virtualenv filelock==3.16.1 # via virtualenv +fonttools==4.54.1 + # via weasyprint +html5lib==1.1 + # via weasyprint identify==2.6.1 # via pre-commit iniconfig==2.0.0 @@ -26,8 +36,8 @@ packaging==23.1 # via pytest pandas==2.1.1 # via blacksquare (pyproject.toml) -pdfkit==1.0.0 - # via blacksquare (pyproject.toml) +pillow==10.4.0 + # via weasyprint platformdirs==4.3.6 # via virtualenv pluggy==1.3.0 @@ -36,10 +46,16 @@ pre-commit==3.8.0 # via blacksquare (pyproject.toml) puzpy==0.2.5 # via blacksquare (pyproject.toml) +pycparser==2.22 + # via cffi +pydyf==0.11.0 + # via weasyprint pygments==2.16.1 # via rich pypdf==3.16.2 # via blacksquare (pyproject.toml) +pyphen==0.16.0 + # via weasyprint pytest==7.4.2 # via blacksquare (pyproject.toml) python-dateutil==2.8.2 @@ -51,10 +67,25 @@ pyyaml==6.0.2 rich==13.5.3 # via blacksquare (pyproject.toml) six==1.16.0 - # via python-dateutil + # via + # html5lib + # python-dateutil +tinycss2==1.3.0 + # via + # cssselect2 + # weasyprint tzdata==2023.3 # via pandas uv==0.4.18 # via blacksquare (pyproject.toml) virtualenv==20.26.6 # via pre-commit +weasyprint==62.3 + # via blacksquare (pyproject.toml) +webencodings==0.5.1 + # via + # cssselect2 + # html5lib + # tinycss2 +zopfli==0.2.3 + # via fonttools diff --git a/src/blacksquare/crossword.py b/src/blacksquare/crossword.py index ec02b30..8d592bc 100644 --- a/src/blacksquare/crossword.py +++ b/src/blacksquare/crossword.py @@ -8,7 +8,6 @@ import networkx as nx import numpy as np -import pdfkit import puz import rich.box from pypdf import PdfReader, PdfWriter @@ -17,6 +16,7 @@ from rich.table import Table from blacksquare.cell import Cell +from blacksquare.html import CSS_TEMPLATE from blacksquare.symmetry import Symmetry from blacksquare.types import ( CellIndex, @@ -29,6 +29,11 @@ from blacksquare.word import Word from blacksquare.word_list import DEFAULT_WORDLIST, WordList +try: + import weasyprint +except ImportError: + weasyprint = None + BLACK, EMPTY = SpecialCellValue.BLACK, SpecialCellValue.EMPTY ACROSS, DOWN = Direction.ACROSS, Direction.DOWN @@ -203,15 +208,36 @@ def to_pdf( name, address, etc.). Each list element will be one line in the header. """ + if weasyprint is None: + raise ImportError( + "Can't import weasyprint, run pip install blacksquare[pdf] to install." + ) + header_html = "
".join(header) if header else "" grid_html = f""" - + + + -
+
{header_html}
-
+



+
{self._grid_html(size_px=600)}
@@ -235,7 +261,7 @@ def clue_rows(direction): table {{ text-align:left; width:100%; - font-size:18pt; + font-size:16pt; border-spacing:1rem; }} @@ -252,18 +278,7 @@ def clue_rows(direction): """ merger = PdfWriter() for html_page in [grid_html, clue_html]: - pdf = pdfkit.from_string( - html_page, - False, - options={ - "quiet": None, - "margin-top": "0.5in", - "margin-right": "0.5in", - "margin-bottom": "0.5in", - "margin-left": "0.5in", - "encoding": "UTF-8", - }, - ) + pdf = weasyprint.HTML(string=html_page, encoding="UTF-8").write_pdf() merger.append(PdfReader(io.BytesIO(pdf))) merger.write(str(filename)) merger.close() @@ -761,45 +776,44 @@ def _grid_html(self, size_px: Optional[int] = None) -> str: size_px = size_px or self.display_size_px # Random suffix is a hack to ensure correct display in Jupyter settings suffix = token_hex(4) - circle_string = f"
" - row_elems = [] - for r in range(self._num_rows): - cells = [] - for c in range(self._num_cols): - number = self._numbers[r, c] - cells.append( - f""" -
{number if number else ""}
-
- {self._grid[r,c].str if self._grid[r,c] != BLACK else ""} -
- {circle_string if self._grid[r,c].circled else ""} - """ - ) - row_elems.append(f"{''.join(cells)}") + cells = [] + for c in self.itercells(): + c.number + cell_number_span = f'{c.number or ""}' + letter_span = f'{c.value if c!=BLACK else ""}' + circle_span = '' + if c == BLACK: + extra_class = " black" + elif c.shaded: + extra_class = " gray" + else: + extra_class = "" + cell_div = f""" +
+ {cell_number_span} + {letter_span} + {circle_span if c.circled else ""} +
+ """ + cells.append(cell_div) aspect_ratio = self.num_rows / self.num_cols cell_size = size_px / max(self.num_rows, self.num_cols) - return """ -
- - - {rows} - - - """.format( + css = CSS_TEMPLATE.format( + num_cols=self.num_cols, height=size_px * min(1, aspect_ratio), width=size_px * min(1, 1 / aspect_ratio), - rows="\n".join(row_elems), + num_font_size=int(cell_size * 0.3), + val_font_size=int(cell_size * 0.6), + circle_dim=cell_size - 1, suffix=suffix, - num_font=int(cell_size * 0.3), - val_font=int(cell_size * 0.6), ) + return f""" +
+ +
+ {"\n".join(cells)} +
+
+ """ diff --git a/src/blacksquare/html.py b/src/blacksquare/html.py new file mode 100644 index 0000000..f23af1d --- /dev/null +++ b/src/blacksquare/html.py @@ -0,0 +1,54 @@ +CSS_TEMPLATE = """ +.crossword{suffix} {{ + display: grid; + grid-template-columns: repeat({num_cols}, 1fr); + grid-auto-rows: 1fr; + gap: 0px; + width: {width}px; + height: {height}px; +}} + +.crossword-cell{suffix} {{ + position: relative; + background-color: white; + outline: 1px solid black; + font-family: Arial, Helvetica, sans-serif; + aspect-ratio: 1; +}} + +.crossword-cell{suffix} .cell-number {{ + position: absolute; + top: 2px; + left: 2px; + font-size: {num_font_size}px; + color: black; + user-select: none; +}} + +.crossword-cell{suffix} .letter {{ + font-size: {val_font_size}px; + position: absolute; + bottom: 0; + left: 50%; + transform: translate(-50%, 0%); + color: black; +}} + +.black {{ + background-color: black; + outline: 1px solid gray; +}} + +.gray {{ + background-color: lightgray; +}} + +.crossword-cell{suffix} .circle {{ + position: absolute; + border-radius: 50%; + border: 1px solid black; + height: {circle_dim}px; + width: {circle_dim}px; + margin: -1px; +}} +""" From 833b0c59fd5e650e49491058c42d039487af8f3e Mon Sep 17 00:00:00 2001 From: Patrick Maher Date: Sun, 6 Oct 2024 23:25:06 -0400 Subject: [PATCH 2/3] fixing f-string --- src/blacksquare/crossword.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blacksquare/crossword.py b/src/blacksquare/crossword.py index 8d592bc..2880c75 100644 --- a/src/blacksquare/crossword.py +++ b/src/blacksquare/crossword.py @@ -807,13 +807,14 @@ def _grid_html(self, size_px: Optional[int] = None) -> str: circle_dim=cell_size - 1, suffix=suffix, ) + cells_html = "\n".join(cells) return f"""
- {"\n".join(cells)} + {cells_html}
""" From 22ac3ddb8b87e4224843a3384ccf1e12ee364fdc Mon Sep 17 00:00:00 2001 From: Patrick Maher Date: Sun, 6 Oct 2024 23:29:05 -0400 Subject: [PATCH 3/3] move pypdf to conditional --- pyproject.toml | 4 ++-- src/blacksquare/crossword.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f4b05d2..7bca974 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ name = "blacksquare" description = "A package for creating crossword puzzles" authors = [{ name = "Patrick Maher", email = "pmaher86@gmail.com" }] readme = "README.md" -dependencies = ["networkx", "numpy", "pandas", "puzpy", "pypdf", "rich"] +dependencies = ["networkx", "numpy", "pandas", "puzpy", "rich"] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -20,7 +20,7 @@ Source = "https://github.com/pmaher86/blacksquare" [project.optional-dependencies] dev = ["pre-commit", "pytest", "uv"] -pdf = ["weasyprint"] +pdf = ["pypdf", "weasyprint"] [tool.setuptools_scm] local_scheme = "no-local-version" diff --git a/src/blacksquare/crossword.py b/src/blacksquare/crossword.py index 2880c75..6f0ae65 100644 --- a/src/blacksquare/crossword.py +++ b/src/blacksquare/crossword.py @@ -10,7 +10,6 @@ import numpy as np import puz import rich.box -from pypdf import PdfReader, PdfWriter from rich.console import Console from rich.live import Live from rich.table import Table @@ -30,9 +29,11 @@ from blacksquare.word_list import DEFAULT_WORDLIST, WordList try: + import pypdf import weasyprint except ImportError: weasyprint = None + pypdf = None BLACK, EMPTY = SpecialCellValue.BLACK, SpecialCellValue.EMPTY ACROSS, DOWN = Direction.ACROSS, Direction.DOWN @@ -276,10 +277,10 @@ def clue_rows(direction):
""" - merger = PdfWriter() + merger = pypdf.PdfWriter() for html_page in [grid_html, clue_html]: pdf = weasyprint.HTML(string=html_page, encoding="UTF-8").write_pdf() - merger.append(PdfReader(io.BytesIO(pdf))) + merger.append(pypdf.PdfReader(io.BytesIO(pdf))) merger.write(str(filename)) merger.close()