PaintByChar 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 James Derrick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ include README.md
2
+ include LICENSE
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: PaintByChar
3
+ Version: 0.7.0
4
+ Summary: Convert grid strings to colored images with per-character coloring.
5
+ Home-page: https://github.com/jgd10/PaintByChar
6
+ Author: James Derrick
7
+ Author-email: James Derrick <jgd10.github@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2025 James Derrick
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+
30
+ Project-URL: Homepage, https://github.com/jgd10/PaintByChar
31
+ Requires-Python: >=3.12
32
+ Description-Content-Type: text/markdown
33
+ License-File: LICENSE
34
+ Requires-Dist: pillow
35
+ Requires-Dist: matplotlib
36
+ Dynamic: license-file
37
+
38
+ # PaintByChar
39
+
40
+ <img src="assets/logo.png" alt="PaintByChar logo" width="200" />
41
+
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
43
+ [![Python 3.12](https://img.shields.io/badge/python-3.12+-blue.svg)](https://python.org)
44
+ [![PyPI version](https://img.shields.io/pypi/v/PaintByChar.svg)](https://pypi.org/project/PaintByChar/)
45
+ [![Docs](https://readthedocs.org/projects/paintbychar/badge/?version=latest)](https://paintbychar.readthedocs.io/en/latest/)
46
+ [![Tests](https://github.com/jgd10/PaintByChar/actions/workflows/python-tests.yml/badge.svg)](https://github.com/jgd10/PaintByChar/actions/workflows/python-tests.yml)
47
+
48
+ A tiny Python library for turning ASCII grids into colorful images. It is designed for quick visualizations of mazes, maps, game boards, and Advent of Code-style 2D text puzzles.
49
+
50
+ ## Why PaintByChar?
51
+
52
+ - Turn a rectangular text grid into a rendered image in a few lines
53
+ - Map each character to a color automatically or via your own palette
54
+ - Support multiple rendering styles for cells, text, and background-label combinations
55
+ - Works well for generative art, puzzle visualizations, and debugging text-based layouts
56
+
57
+ ## Features
58
+
59
+ - `string_to_image()` for direct text-to-image conversion
60
+ - `file_to_image()` for processing a text file as a grid
61
+ - Preset colormaps such as `viridis`, `plasma`, and `terrain`
62
+ - Custom `value_colors` dictionaries for full control over mapping
63
+ - Render modes: filled cells, colored text, and colored cells with background text
64
+
65
+ ## Install
66
+
67
+ ```bash
68
+ pip install paintbychar
69
+ ```
70
+
71
+ ## Quick example
72
+
73
+ ```python
74
+ import paintbychar as pbc
75
+
76
+ grid = """0123
77
+ 4567
78
+ 89AB
79
+ """
80
+
81
+ img = pbc.string_to_image(
82
+ grid,
83
+ preset="viridis",
84
+ cell_size=24,
85
+ render_style=pbc.RenderStyle.COLORED_CELLS,
86
+ )
87
+
88
+ pbc.save_image(img, "output_basic.png")
89
+ ```
90
+
91
+ ## Example output
92
+
93
+ <div align="center">
94
+ <img src="README_assets/output_basic.png" alt="Basic example output" width="640" />
95
+ </div>
96
+
97
+ ### More example images
98
+
99
+ <div align="center">
100
+ <img src="README_assets/style_colored_text.png" alt="Styled text output" width="320" />
101
+ <img src="README_assets/output_preset_plasma.png" alt="Preset output" width="320" />
102
+ </div>
103
+
104
+ ## More examples
105
+
106
+ The repository includes several runnable examples in the [`examples/`](examples/) folder, including:
107
+
108
+ - `basic_example.py`
109
+ - `file_example.py`
110
+ - `styles_example.py`
111
+ - `preset_example.py`
112
+ - `advent_of_code_example.py`
113
+
114
+ ## API reference
115
+
116
+ See the [documentation](docs/api.md) for a quick overview of the public functions
117
+ and options, or browse the [live docs site](https://paintbychar.readthedocs.io/en/latest/).
118
+
119
+ There is also documentation on Read the Docs with installation instructions,
120
+ usage examples, and API reference. [Read the Docs](https://paintbychar.readthedocs.io/en/latest/index.html)
121
+
122
+ ## Contributing
123
+
124
+ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, testing guidance, and the pull request process.
125
+
126
+ ## Code of conduct
127
+
128
+ This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
129
+
130
+ ## License
131
+
132
+ This project is licensed under the [MIT License](LICENSE).
133
+
134
+ ## Project status
135
+
136
+ PaintByChar is currently a small, focused library aimed at grid-based text rendering and visualization. It is suitable for personal, educational, and open-source use, and is prepared for broader community contributions as it matures.
137
+
138
+ ## Social preview
139
+
140
+ A repository preview image is included in [`assets/social-preview.png`](assets/social-preview.png) and can be used as a GitHub social preview or banner asset.
@@ -0,0 +1,103 @@
1
+ # PaintByChar
2
+
3
+ <img src="assets/logo.png" alt="PaintByChar logo" width="200" />
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+ [![Python 3.12](https://img.shields.io/badge/python-3.12+-blue.svg)](https://python.org)
7
+ [![PyPI version](https://img.shields.io/pypi/v/PaintByChar.svg)](https://pypi.org/project/PaintByChar/)
8
+ [![Docs](https://readthedocs.org/projects/paintbychar/badge/?version=latest)](https://paintbychar.readthedocs.io/en/latest/)
9
+ [![Tests](https://github.com/jgd10/PaintByChar/actions/workflows/python-tests.yml/badge.svg)](https://github.com/jgd10/PaintByChar/actions/workflows/python-tests.yml)
10
+
11
+ A tiny Python library for turning ASCII grids into colorful images. It is designed for quick visualizations of mazes, maps, game boards, and Advent of Code-style 2D text puzzles.
12
+
13
+ ## Why PaintByChar?
14
+
15
+ - Turn a rectangular text grid into a rendered image in a few lines
16
+ - Map each character to a color automatically or via your own palette
17
+ - Support multiple rendering styles for cells, text, and background-label combinations
18
+ - Works well for generative art, puzzle visualizations, and debugging text-based layouts
19
+
20
+ ## Features
21
+
22
+ - `string_to_image()` for direct text-to-image conversion
23
+ - `file_to_image()` for processing a text file as a grid
24
+ - Preset colormaps such as `viridis`, `plasma`, and `terrain`
25
+ - Custom `value_colors` dictionaries for full control over mapping
26
+ - Render modes: filled cells, colored text, and colored cells with background text
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install paintbychar
32
+ ```
33
+
34
+ ## Quick example
35
+
36
+ ```python
37
+ import paintbychar as pbc
38
+
39
+ grid = """0123
40
+ 4567
41
+ 89AB
42
+ """
43
+
44
+ img = pbc.string_to_image(
45
+ grid,
46
+ preset="viridis",
47
+ cell_size=24,
48
+ render_style=pbc.RenderStyle.COLORED_CELLS,
49
+ )
50
+
51
+ pbc.save_image(img, "output_basic.png")
52
+ ```
53
+
54
+ ## Example output
55
+
56
+ <div align="center">
57
+ <img src="README_assets/output_basic.png" alt="Basic example output" width="640" />
58
+ </div>
59
+
60
+ ### More example images
61
+
62
+ <div align="center">
63
+ <img src="README_assets/style_colored_text.png" alt="Styled text output" width="320" />
64
+ <img src="README_assets/output_preset_plasma.png" alt="Preset output" width="320" />
65
+ </div>
66
+
67
+ ## More examples
68
+
69
+ The repository includes several runnable examples in the [`examples/`](examples/) folder, including:
70
+
71
+ - `basic_example.py`
72
+ - `file_example.py`
73
+ - `styles_example.py`
74
+ - `preset_example.py`
75
+ - `advent_of_code_example.py`
76
+
77
+ ## API reference
78
+
79
+ See the [documentation](docs/api.md) for a quick overview of the public functions
80
+ and options, or browse the [live docs site](https://paintbychar.readthedocs.io/en/latest/).
81
+
82
+ There is also documentation on Read the Docs with installation instructions,
83
+ usage examples, and API reference. [Read the Docs](https://paintbychar.readthedocs.io/en/latest/index.html)
84
+
85
+ ## Contributing
86
+
87
+ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, testing guidance, and the pull request process.
88
+
89
+ ## Code of conduct
90
+
91
+ This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
92
+
93
+ ## License
94
+
95
+ This project is licensed under the [MIT License](LICENSE).
96
+
97
+ ## Project status
98
+
99
+ PaintByChar is currently a small, focused library aimed at grid-based text rendering and visualization. It is suitable for personal, educational, and open-source use, and is prepared for broader community contributions as it matures.
100
+
101
+ ## Social preview
102
+
103
+ A repository preview image is included in [`assets/social-preview.png`](assets/social-preview.png) and can be used as a GitHub social preview or banner asset.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "PaintByChar"
7
+ version = "0.7.0"
8
+ description = "Convert grid strings to colored images with per-character coloring."
9
+ authors = [
10
+ { name = "James Derrick", email = "jgd10.github@gmail.com" }
11
+ ]
12
+ readme = "README.md"
13
+ license = { file = "LICENSE" }
14
+ requires-python = ">=3.12"
15
+ dependencies = [
16
+ "pillow",
17
+ "matplotlib"
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/jgd10/PaintByChar"
22
+
23
+ [tool.setuptools]
24
+ package-dir = {"" = "src"}
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
@@ -0,0 +1,30 @@
1
+ [metadata]
2
+ name = paintbychar
3
+ version = 0.1.0
4
+ description = Character-block image generator
5
+ long_description = file: README.md
6
+ long_description_content_type = text/markdown
7
+ author = James Derrick
8
+ author_email = jgd10.github@gmail.com
9
+ url = https://github.com/jgd10/PaintByChar
10
+ license = MIT
11
+ classifiers =
12
+ Programming Language :: Python :: 3
13
+ License :: OSI Approved :: MIT License
14
+ Operating System :: OS Independent
15
+
16
+ [options]
17
+ py_modules =
18
+ main
19
+ paintbychar
20
+ package_dir =
21
+ = src
22
+ install_requires =
23
+ pillow
24
+ matplotlib
25
+ python_requires = >=3.12
26
+
27
+ [egg_info]
28
+ tag_build =
29
+ tag_date = 0
30
+
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: PaintByChar
3
+ Version: 0.7.0
4
+ Summary: Convert grid strings to colored images with per-character coloring.
5
+ Home-page: https://github.com/jgd10/PaintByChar
6
+ Author: James Derrick
7
+ Author-email: James Derrick <jgd10.github@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2025 James Derrick
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+
30
+ Project-URL: Homepage, https://github.com/jgd10/PaintByChar
31
+ Requires-Python: >=3.12
32
+ Description-Content-Type: text/markdown
33
+ License-File: LICENSE
34
+ Requires-Dist: pillow
35
+ Requires-Dist: matplotlib
36
+ Dynamic: license-file
37
+
38
+ # PaintByChar
39
+
40
+ <img src="assets/logo.png" alt="PaintByChar logo" width="200" />
41
+
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
43
+ [![Python 3.12](https://img.shields.io/badge/python-3.12+-blue.svg)](https://python.org)
44
+ [![PyPI version](https://img.shields.io/pypi/v/PaintByChar.svg)](https://pypi.org/project/PaintByChar/)
45
+ [![Docs](https://readthedocs.org/projects/paintbychar/badge/?version=latest)](https://paintbychar.readthedocs.io/en/latest/)
46
+ [![Tests](https://github.com/jgd10/PaintByChar/actions/workflows/python-tests.yml/badge.svg)](https://github.com/jgd10/PaintByChar/actions/workflows/python-tests.yml)
47
+
48
+ A tiny Python library for turning ASCII grids into colorful images. It is designed for quick visualizations of mazes, maps, game boards, and Advent of Code-style 2D text puzzles.
49
+
50
+ ## Why PaintByChar?
51
+
52
+ - Turn a rectangular text grid into a rendered image in a few lines
53
+ - Map each character to a color automatically or via your own palette
54
+ - Support multiple rendering styles for cells, text, and background-label combinations
55
+ - Works well for generative art, puzzle visualizations, and debugging text-based layouts
56
+
57
+ ## Features
58
+
59
+ - `string_to_image()` for direct text-to-image conversion
60
+ - `file_to_image()` for processing a text file as a grid
61
+ - Preset colormaps such as `viridis`, `plasma`, and `terrain`
62
+ - Custom `value_colors` dictionaries for full control over mapping
63
+ - Render modes: filled cells, colored text, and colored cells with background text
64
+
65
+ ## Install
66
+
67
+ ```bash
68
+ pip install paintbychar
69
+ ```
70
+
71
+ ## Quick example
72
+
73
+ ```python
74
+ import paintbychar as pbc
75
+
76
+ grid = """0123
77
+ 4567
78
+ 89AB
79
+ """
80
+
81
+ img = pbc.string_to_image(
82
+ grid,
83
+ preset="viridis",
84
+ cell_size=24,
85
+ render_style=pbc.RenderStyle.COLORED_CELLS,
86
+ )
87
+
88
+ pbc.save_image(img, "output_basic.png")
89
+ ```
90
+
91
+ ## Example output
92
+
93
+ <div align="center">
94
+ <img src="README_assets/output_basic.png" alt="Basic example output" width="640" />
95
+ </div>
96
+
97
+ ### More example images
98
+
99
+ <div align="center">
100
+ <img src="README_assets/style_colored_text.png" alt="Styled text output" width="320" />
101
+ <img src="README_assets/output_preset_plasma.png" alt="Preset output" width="320" />
102
+ </div>
103
+
104
+ ## More examples
105
+
106
+ The repository includes several runnable examples in the [`examples/`](examples/) folder, including:
107
+
108
+ - `basic_example.py`
109
+ - `file_example.py`
110
+ - `styles_example.py`
111
+ - `preset_example.py`
112
+ - `advent_of_code_example.py`
113
+
114
+ ## API reference
115
+
116
+ See the [documentation](docs/api.md) for a quick overview of the public functions
117
+ and options, or browse the [live docs site](https://paintbychar.readthedocs.io/en/latest/).
118
+
119
+ There is also documentation on Read the Docs with installation instructions,
120
+ usage examples, and API reference. [Read the Docs](https://paintbychar.readthedocs.io/en/latest/index.html)
121
+
122
+ ## Contributing
123
+
124
+ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, testing guidance, and the pull request process.
125
+
126
+ ## Code of conduct
127
+
128
+ This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
129
+
130
+ ## License
131
+
132
+ This project is licensed under the [MIT License](LICENSE).
133
+
134
+ ## Project status
135
+
136
+ PaintByChar is currently a small, focused library aimed at grid-based text rendering and visualization. It is suitable for personal, educational, and open-source use, and is prepared for broader community contributions as it matures.
137
+
138
+ ## Social preview
139
+
140
+ A repository preview image is included in [`assets/social-preview.png`](assets/social-preview.png) and can be used as a GitHub social preview or banner asset.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ setup.cfg
6
+ src/paintbychar.py
7
+ src/PaintByChar.egg-info/PKG-INFO
8
+ src/PaintByChar.egg-info/SOURCES.txt
9
+ src/PaintByChar.egg-info/dependency_links.txt
10
+ src/PaintByChar.egg-info/requires.txt
11
+ src/PaintByChar.egg-info/top_level.txt
12
+ tests/test_main.py
@@ -0,0 +1,2 @@
1
+ pillow
2
+ matplotlib
@@ -0,0 +1,2 @@
1
+ main
2
+ paintbychar
@@ -0,0 +1,266 @@
1
+ from enum import Enum
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ import matplotlib.pyplot as plt
5
+ from PIL import Image, ImageDraw, ImageFont
6
+ from typing import Tuple, Union
7
+
8
+ from PIL.ImageFont import FreeTypeFont
9
+
10
+ __version__ = "0.7.0"
11
+
12
+
13
+ COLOR_PRESETS: dict[str, tuple[int, int, int]] = {
14
+ "white": (255, 255, 255),
15
+ "black": (0, 0, 0),
16
+ "light_gray": (245, 245, 245),
17
+ "dark_gray": (50, 50, 50),
18
+ "pastel_blue": (174, 198, 207),
19
+ "pastel_green": (152, 251, 152),
20
+ "pastel_pink": (255, 182, 193),
21
+ "cream": (255, 253, 208),
22
+ "beige": (245, 245, 220),
23
+ "mint": (189, 252, 201),
24
+ "navy": (10, 25, 47),
25
+ "charcoal": (34, 40, 49),
26
+ "soft_yellow": (255, 250, 205),
27
+ "gray": (128, 128, 128),
28
+ "blue": (70, 130, 180),
29
+ "green": (60, 179, 113),
30
+ "pink": (255, 182, 193),
31
+ "yellow": (255, 223, 0),
32
+ "teal": (0, 128, 128),
33
+ "brown": (139, 69, 19),
34
+ "red": (220, 20, 60),
35
+ }
36
+
37
+
38
+ def resolve_color(value: Union[str, Tuple[int, int, int]]) -> tuple[int, int, int]:
39
+ """
40
+ Resolve a color value which can be:
41
+ - a preset name from BG_PRESETS (e.g., 'cream')
42
+ - an RGB tuple already (e.g., (255, 255, 255))
43
+ Returns an (R, G, B) tuple.
44
+ """
45
+ if isinstance(value, tuple):
46
+ if len(value) != 3:
47
+ raise ValueError(f"RGB tuple must have 3 elements, got {len(value)}")
48
+ for channel in value:
49
+ if not isinstance(channel, int):
50
+ raise ValueError(f"RGB channel value {channel} is not an integer")
51
+ if not (0 <= channel <= 255):
52
+ raise ValueError(f"RGB channel value {channel} out of range 0-255")
53
+ return value
54
+ if isinstance(value, str):
55
+ if value.lower() in COLOR_PRESETS:
56
+ return COLOR_PRESETS[value]
57
+ raise ValueError(f"Unsupported bg color: {value}")
58
+
59
+
60
+
61
+ class RenderStyle(Enum):
62
+ """Enumeration for fill options in the image generation."""
63
+ COLORED_CELLS = "colored_cells"
64
+ COLORED_TEXT = "colored_text"
65
+ COLORED_CELLS_WITH_BACKGROUND_TEXT = (
66
+ "colored_cells_with_background_text"
67
+ )
68
+
69
+
70
+ def get_colormap_dict(colormap_name: str) -> dict[str, tuple[int, ...]]:
71
+ """Generate a color mapping dictionary from a matplotlib colormap.
72
+
73
+ Uses the characters '0'-'9' as keys and maps them to colors sampled from the
74
+ specified colormap. Supported colormaps include 'viridis', 'plasma',
75
+ 'inferno', 'magma', 'cividis', 'terrain', and 'coolwarm'.
76
+
77
+ Args:
78
+ colormap_name (str): Name of the matplotlib colormap to use.
79
+ Returns:
80
+ dict[str, tuple[int, ...]]: A dictionary mapping string digits '0'-'9' to RGB color tuples.
81
+ """
82
+ cmap = plt.get_cmap(colormap_name)
83
+ colors = [tuple(int(255 * c) for c in cmap(i / 9)[:3]) for i in range(10)]
84
+ return {str(i): colors[i] for i in range(10)}
85
+
86
+
87
+ PRESETS = {'viridis': get_colormap_dict('viridis'),
88
+ 'plasma': get_colormap_dict('plasma'),
89
+ 'inferno': get_colormap_dict('inferno'),
90
+ 'magma': get_colormap_dict('magma'),
91
+ 'cividis': get_colormap_dict('cividis'), #
92
+ 'terrain': get_colormap_dict('terrain'),
93
+ 'coolwarm': get_colormap_dict(
94
+ 'coolwarm')} # type: dict[str, dict[str, tuple[int, ...]]]
95
+
96
+
97
+ class InputError(Exception):
98
+ """Custom exception for invalid input data."""
99
+ pass
100
+
101
+
102
+ def check_grid_string(grid_str: str) -> bool:
103
+ """Check if all lines in the string block have the same length.
104
+
105
+ Input can only be a rectangular grid of single characters
106
+
107
+ Args:
108
+ grid_str (str): The string block representing the grid.
109
+ Raises:
110
+ InputError: If the lines have inconsistent lengths.
111
+ Returns:
112
+ bool: True if all lines have the same length.
113
+ """
114
+ lines = grid_str.strip().split('\n')
115
+ max_width = 0
116
+ for line in lines:
117
+ max_width = max(max_width, len(line))
118
+ if max_width == 0:
119
+ raise InputError("The string block must not be empty.")
120
+ return True
121
+
122
+
123
+ def file_to_image(file_path: Path | str,
124
+ value_colors: Optional[dict[str, tuple[int, ...]]] = None,
125
+ preset: Optional[str] = None,
126
+ background_color: tuple[int, int, int] = (255, 255, 255),
127
+ cell_size: int = 32,
128
+ render_style: RenderStyle = RenderStyle.COLORED_CELLS,
129
+ font_path: Path = None,
130
+ font_size: Optional[int] = None) -> Image:
131
+ """Read a string block from a file and convert it to an image.
132
+
133
+ Args:
134
+ file_path (Path | str): Path to the file containing the string block.
135
+ value_colors (Optional[dict[str, tuple[int, ...]]]): Mapping of values to RGB colors.
136
+ preset (Optional[str]): Name of a preset colormap to use.
137
+ background_color (tuple[int, int, int]): Background color as an RGB tuple.
138
+ cell_size (int): Size of each cell in pixels.
139
+ render_style (RenderStyle): Style for rendering the image.
140
+ font_path (Path): Path to the font file to use for rendering text.
141
+ font_size (Optional[int]): Size of the font to use for rendering text.
142
+ Returns:
143
+ Image: The generated image.
144
+ """
145
+ grid_str = Path(file_path).read_text()
146
+ img = string_to_image(grid_str, value_colors, preset, background_color, cell_size,
147
+ render_style, font_path, font_size)
148
+ return img
149
+
150
+
151
+ def string_to_image(grid_str: str,
152
+ value_colors: Optional[dict[str, tuple[int, ...]]] = None,
153
+ preset: Optional[str] = None,
154
+ background_color: tuple[int, int, int] | str = (255, 255, 255),
155
+ cell_size: int = 32,
156
+ render_style: RenderStyle = RenderStyle.COLORED_CELLS,
157
+ font_path: Path = None,
158
+ font_size: Optional[int] = None) -> Image:
159
+ """Convert a string block to an image.
160
+
161
+ Args:
162
+ grid_str (str): The string block representing the grid.
163
+ value_colors (Optional[dict[str, tuple[int, ...]]]): Mapping of values to RGB colors.
164
+ preset (Optional[str]): Name of a preset colormap to use.
165
+ background_color (tuple[int, int, int] | str): Background color as an RGB tuple
166
+ or one of the preset strings.
167
+ cell_size (int): Size of each cell in pixels.
168
+ render_style (RenderStyle): Style for rendering the image.
169
+ font_path (Path): Path to the font file to use for rendering text.
170
+ font_size (Optional[int]): Size of the font to use for rendering text.
171
+ Returns:
172
+ Image: The generated image.
173
+ """
174
+ check_grid_string(grid_str)
175
+ lines = grid_str.strip().split('\n')
176
+ height = len(lines)
177
+ width = max(len(line) for line in lines)
178
+
179
+ value_colors, font = get_set_mappings(cell_size, value_colors,
180
+ font_path, font_size, preset)
181
+ background_color = resolve_color(background_color)
182
+
183
+ img = Image.new('RGB', (width * cell_size, height * cell_size), background_color)
184
+ draw = ImageDraw.Draw(img)
185
+ for y, line in enumerate(lines):
186
+ for x, char in enumerate(line):
187
+ xy = [x * cell_size, y * cell_size, (x + 1) * cell_size,
188
+ (y + 1) * cell_size]
189
+ draw.rectangle(xy, fill=background_color)
190
+ match render_style:
191
+ case RenderStyle.COLORED_CELLS_WITH_BACKGROUND_TEXT:
192
+ color = resolve_color(value_colors.get(char, (0, 0, 0)))
193
+ draw.rectangle(xy, fill=color)
194
+ draw_character(background_color, cell_size, char, draw,
195
+ font, x, y)
196
+ case RenderStyle.COLORED_CELLS:
197
+ color = resolve_color(value_colors.get(char, (0, 0, 0)))
198
+ draw.rectangle(xy, fill=color)
199
+ case RenderStyle.COLORED_TEXT:
200
+ color = resolve_color(value_colors.get(char, (0, 0, 0)))
201
+ draw_character(color, cell_size, char, draw,
202
+ font, x, y)
203
+ case _:
204
+ raise ValueError(
205
+ f"Invalid show_chars option: {render_style}")
206
+ return img
207
+
208
+
209
+ def draw_character(background_color: tuple[int, int, int], cell_size: int,
210
+ char: str, draw: ImageDraw, font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
211
+ x: int, y: int):
212
+ bbox = draw.textbbox((0, 0), char, font=font)
213
+ w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
214
+ tx = x * cell_size + (cell_size - w) // 2
215
+ ty = y * cell_size + (cell_size - h) // 2
216
+ draw.text((tx, ty), char, fill=background_color, font=font)
217
+
218
+
219
+ def get_set_mappings(cell_size: int,
220
+ value_colors: Optional[dict[str, tuple[int, ...]]],
221
+ font_path: Optional[Path], font_size: int, preset: str)\
222
+ -> \
223
+ tuple[
224
+ dict[str, tuple[int, ...]], ImageFont.FreeTypeFont | ImageFont.ImageFont]:
225
+ """Get value color mapping and font.
226
+
227
+ Args:
228
+ cell_size (int): Size of each cell in pixels.
229
+ value_colors (Optional[dict[str, tuple[int, ...]]]): Mapping of values to RGB colors.
230
+ font_path (Optional[Path]): Path to the font file to use for rendering text.
231
+ font_size (int): Size of the font to use for rendering text.
232
+ preset (str): Name of a preset colormap to use.
233
+ Returns:
234
+ tuple[dict[str, tuple[int, ...]], ImageFont.FreeTypeFont | ImageFont.ImageFont]:
235
+ The value color mapping and the font object.
236
+ """
237
+ if preset:
238
+ value_colors = PRESETS.get(preset, {})
239
+ elif value_colors is None:
240
+ value_colors = {}
241
+ # Use bold Consolas if available, else fallback
242
+ if font_path is None:
243
+ font_path = "consolab.ttf" # Bold Consolas
244
+ if font_size is None:
245
+ font_size = int(cell_size)
246
+ try:
247
+ font = ImageFont.truetype(font_path, font_size)
248
+ except OSError:
249
+ try:
250
+ font = ImageFont.truetype("DejaVuSans.ttf", size=24)
251
+ except OSError:
252
+ font = ImageFont.load_default()
253
+ return value_colors, font
254
+
255
+
256
+ def save_image(img: Image, out_path: Path | str) -> None:
257
+ """Save the image to the specified path.
258
+
259
+ Args:
260
+ img (Image): The image to save.
261
+ out_path (Path | str): The path to save the image to.
262
+ Returns:
263
+ None
264
+ """
265
+ img.save(out_path)
266
+ print(f"Saved image to {out_path}")
@@ -0,0 +1,290 @@
1
+ import importlib.util
2
+ import sys
3
+ from pathlib import Path
4
+ import pytest
5
+ from PIL import ImageFont, ImageDraw
6
+
7
+
8
+ def load_main_module():
9
+ # Load src/main.py by path to avoid package import issues.
10
+ root = Path(__file__).resolve().parents[1]
11
+ src_path = root / "src" / "paintbychar.py"
12
+ spec = importlib.util.spec_from_file_location("project_main", src_path)
13
+ module = importlib.util.module_from_spec(spec)
14
+ sys.modules["project_main"] = module
15
+ spec.loader.exec_module(module)
16
+ return module
17
+
18
+
19
+ class TestGridString:
20
+ @pytest.mark.parametrize("grid_str", ["abc\ndef\nghi", "abc\ndef\nghi\n",
21
+ "1234\n5678\n9012", "A\nB\nC", "■\n■", "X Y Z\n1 2 3\n! @ #", "L",
22
+ "NOP"])
23
+ def test_grid_string_valid(self, grid_str):
24
+ m = load_main_module()
25
+ assert m.check_grid_string(grid_str) is True
26
+
27
+ @pytest.mark.parametrize("grid_str", ["", "\n", " "])
28
+ def test_grid_string_invalid(self, grid_str):
29
+ m = load_main_module()
30
+ with pytest.raises(m.InputError):
31
+ m.check_grid_string(grid_str)
32
+
33
+
34
+ def test_get_set_mappings_font_fallback():
35
+ m = load_main_module()
36
+ char_map, font = m.get_set_mappings(12, None, Path("nonexistent-font.ttf"),
37
+ None, None)
38
+ assert isinstance(char_map, dict)
39
+ # default font should be returned when truetype fails
40
+ assert (isinstance(font, ImageFont.FreeTypeFont) or isinstance(font,
41
+ ImageFont.ImageFont))
42
+
43
+
44
+ class TestRenderStyle:
45
+ @pytest.mark.parametrize("style", ["COLORED_CELLS", "COLORED_TEXT",
46
+ "COLORED_CELLS_WITH_BACKGROUND_TEXT"])
47
+ def test_render_style_valid(self, style):
48
+ m = load_main_module()
49
+ assert m.RenderStyle[style] is not None
50
+
51
+ @pytest.mark.parametrize("invalid_style",
52
+ ["INVALID_OPTION", "colored_cells",
53
+ "Colored_Text", "", None])
54
+ def test_render_style_invalid(self, invalid_style):
55
+ m = load_main_module()
56
+ with pytest.raises(KeyError):
57
+ _ = m.RenderStyle[invalid_style]
58
+
59
+ def test_string_to_image_background_color_is_used_for_empty_character_area(
60
+ self):
61
+ m = load_main_module()
62
+ img = m.string_to_image("O", background_color=(11, 22, 33),
63
+ cell_size=100,
64
+ render_style=m.RenderStyle.COLORED_TEXT,
65
+ value_colors={"O": (200, 201, 202)},
66
+ font_size=166)
67
+ pixels = set(img.getdata())
68
+ assert (11, 22, 33) in pixels
69
+ assert (200, 201, 202) in pixels
70
+
71
+ def test_string_to_image_background_mode_draws_character_in_background_color(
72
+ self):
73
+ m = load_main_module()
74
+ img = m.string_to_image("O", background_color=(11, 22, 33),
75
+ cell_size=100,
76
+ render_style=m.RenderStyle.COLORED_CELLS_WITH_BACKGROUND_TEXT,
77
+ value_colors={"O": (200, 201, 202)},
78
+ font_size=166)
79
+ pixels = set(img.getdata())
80
+ assert (200, 201, 202) in pixels
81
+ assert (11, 22, 33) in pixels
82
+
83
+ def test_string_to_image_COLORED_CELLS_mode_fills_every_pixel_in_cell(self):
84
+ m = load_main_module()
85
+ img = m.string_to_image("A", background_color=(11, 22, 33),
86
+ cell_size=10,
87
+ render_style=m.RenderStyle.COLORED_CELLS,
88
+ value_colors={"A": (200, 201, 202)})
89
+ assert set(img.getdata()) == {(200, 201, 202)}
90
+
91
+ def test_string_to_image_invalid_render_style(self):
92
+ m = load_main_module()
93
+ grid = "A"
94
+ with pytest.raises(ValueError):
95
+ m.string_to_image(grid, value_colors={"A": (0, 0, 0)},
96
+ cell_size=10, render_style="INVALID_OPTION")
97
+
98
+
99
+ @pytest.mark.parametrize("grid_str", ["", "\n"])
100
+ def test_string_to_image_rejects_invalid_grid(grid_str):
101
+ m = load_main_module()
102
+ with pytest.raises(m.InputError):
103
+ img = m.string_to_image(grid_str)
104
+
105
+
106
+ def test_string_to_image_rectangular_dimensions():
107
+ m = load_main_module()
108
+ img = m.string_to_image("AB\nCD", cell_size=7)
109
+ assert img.size == (14, 14)
110
+
111
+
112
+ def test_string_to_image_irregular_dimensions():
113
+ m = load_main_module()
114
+ img = m.string_to_image("ABCE\nCDE\nAC\n\nDEFGH", cell_size=5)
115
+ assert img.size == (25, 25)
116
+
117
+
118
+ @pytest.mark.parametrize("value",
119
+ [(1, 2), (1, 2, 3, 4), (1.5, 2, 3), ("1", 2, 3)])
120
+ def test_resolve_color_rejects_malformed_rgb_tuples(value):
121
+ m = load_main_module()
122
+ with pytest.raises(ValueError):
123
+ m.resolve_color(value)
124
+
125
+
126
+ @pytest.mark.parametrize("cell_size", [0, -1])
127
+ def test_string_to_image_rejects_non_positive_cell_size(cell_size):
128
+ m = load_main_module()
129
+ with pytest.raises(ValueError):
130
+ m.string_to_image("A", cell_size=cell_size)
131
+
132
+
133
+ def test_missing_character_mapping_uses_black():
134
+ m = load_main_module()
135
+ img = m.string_to_image("A", cell_size=10,
136
+ render_style=m.RenderStyle.COLORED_CELLS)
137
+ assert set(img.getdata()) == {(0, 0, 0)}
138
+
139
+
140
+ @pytest.mark.parametrize("value", [True, False])
141
+ def test_bool_rgb(value):
142
+ m = load_main_module()
143
+ img = m.string_to_image("A", cell_size=10,
144
+ value_colors={'A': (value, value, value)},
145
+ render_style=m.RenderStyle.COLORED_CELLS,
146
+ background_color='red')
147
+ assert set(img.getdata()) == {(value, value, value)}
148
+
149
+
150
+ def test_preset_takes_precedence_over_character_mapping():
151
+ m = load_main_module()
152
+ img = m.string_to_image("5", preset="viridis", cell_size=10,
153
+ render_style=m.RenderStyle.COLORED_CELLS,
154
+ value_colors={"5": (1, 2, 3)})
155
+ assert img.getpixel((5, 5)) == m.PRESETS["viridis"]["5"]
156
+
157
+
158
+ def test_file_to_image_accepts_path_object(tmp_path):
159
+ m = load_main_module()
160
+ path = tmp_path / "grid.txt"
161
+ path.write_text("AB\nCD")
162
+ img = m.file_to_image(path, cell_size=6,
163
+ value_colors={char: (1, 2, 3) for char in "ABCD"},
164
+ render_style=m.RenderStyle.COLORED_CELLS)
165
+ assert img.size == (12, 12)
166
+
167
+
168
+ def test_file_to_image_accepts_trailing_newline(tmp_path):
169
+ m = load_main_module()
170
+ path = tmp_path / "grid.txt"
171
+ path.write_text("A\n")
172
+ img = m.file_to_image(path, cell_size=6, value_colors={"A": (1, 2, 3)},
173
+ render_style=m.RenderStyle.COLORED_CELLS)
174
+ assert img.size == (6, 6)
175
+
176
+
177
+ @pytest.mark.parametrize("colormap_name",
178
+ ["viridis", "plasma", "inferno", "magma", "cividis",
179
+ "terrain", "coolwarm"])
180
+ def test_preset_applied_to_value_colors(colormap_name):
181
+ import matplotlib.pyplot as plt
182
+ m = load_main_module()
183
+ cmap = plt.get_cmap(colormap_name)
184
+ grid = "5"
185
+ img = m.string_to_image(grid, preset=colormap_name, cell_size=90,
186
+ render_style=m.RenderStyle.COLORED_CELLS)
187
+ assert img.size == (90, 90)
188
+ assert img.getpixel((50, 50)) == tuple(
189
+ [int(c * 255) for c in cmap(5 / 9)[:3]])
190
+
191
+
192
+ def test_file_to_image_reads_file(tmp_path):
193
+ m = load_main_module()
194
+ p = tmp_path / "grid.txt"
195
+ p.write_text("X")
196
+ # ensure the mapping for X is provided to get deterministic colors
197
+ img = m.file_to_image(str(p), value_colors={"X": (1, 2, 3)},
198
+ background_color=(255, 255, 255), cell_size=8,
199
+ render_style=m.RenderStyle.COLORED_CELLS)
200
+ assert img.size == (8, 8)
201
+ assert img.getpixel((4, 4)) == (1, 2, 3)
202
+
203
+
204
+ def test_file_to_image_invalid_file(tmp_path):
205
+ m = load_main_module()
206
+ p = tmp_path / "invalid_grid.txt"
207
+ p.write_text("\n") # inconsistent line lengths
208
+ with pytest.raises(m.InputError):
209
+ m.file_to_image(str(p), value_colors={"A": (0, 0, 0), "B": (0, 0, 0),
210
+ "C": (0, 0, 0)}, cell_size=10,
211
+ render_style=m.RenderStyle.COLORED_TEXT)
212
+
213
+
214
+ def test_file_to_image_nonexistent_file():
215
+ m = load_main_module()
216
+ with pytest.raises(FileNotFoundError):
217
+ m.file_to_image("nonexistent_file.txt", value_colors={"A": (0, 0, 0)},
218
+ cell_size=10, render_style=m.RenderStyle.COLORED_TEXT)
219
+
220
+
221
+ def test_get_colormap_dict_length_and_values():
222
+ m = load_main_module()
223
+ colormap_name = "viridis"
224
+ colormap_dict = m.get_colormap_dict(colormap_name)
225
+ assert len(colormap_dict) == 10
226
+ for i in range(10):
227
+ color = colormap_dict[str(i)]
228
+ assert isinstance(color, tuple)
229
+ assert len(color) == 3
230
+ for channel in color:
231
+ assert 0 <= channel <= 255
232
+
233
+
234
+ def test_get_colormap_dict_invalid_name():
235
+ m = load_main_module()
236
+ with pytest.raises(ValueError):
237
+ m.get_colormap_dict("invalid_colormap_name")
238
+
239
+
240
+ def test_presets_contain_expected_keys():
241
+ m = load_main_module()
242
+ expected_keys = {'viridis', 'plasma', 'inferno', 'magma', 'cividis',
243
+ 'terrain', 'coolwarm'}
244
+ assert set(m.PRESETS.keys()) == expected_keys
245
+
246
+
247
+ def test_save_image_to_path(tmp_path):
248
+ from PIL import Image
249
+ m = load_main_module()
250
+ grid = "A"
251
+ img = m.string_to_image(grid, value_colors={"A": (100, 150, 200)},
252
+ cell_size=10, render_style=m.RenderStyle.COLORED_CELLS)
253
+ output_path = tmp_path / "output_image.png"
254
+ m.save_image(img, output_path)
255
+ assert output_path.exists()
256
+ loaded_img = Image.open(output_path)
257
+ assert loaded_img.size == img.size
258
+ assert loaded_img.getpixel((5, 5)) == (100, 150, 200)
259
+
260
+
261
+ class TestColorPresets:
262
+ @pytest.mark.parametrize("preset_name",
263
+ ["white", "black", "light_gray", "dark_gray",
264
+ "pastel_blue", "pastel_green", "pastel_pink",
265
+ "cream", "beige", "mint", "navy", "charcoal",
266
+ "soft_yellow", "gray", "blue", "green",
267
+ "pink", "yellow", "teal", "brown", "red"])
268
+ def test_resolve_color_valid(self, preset_name):
269
+ m = load_main_module()
270
+ color = m.resolve_color(preset_name)
271
+ assert isinstance(color, tuple)
272
+ assert len(color) == 3
273
+ for channel in color:
274
+ assert 0 <= channel <= 255
275
+
276
+ @pytest.mark.parametrize("invalid_value",
277
+ ["unknown_color", (256, 0, 0), (-1, 0, 0),
278
+ "123,456,789", 12345, None])
279
+ def test_resolve_color_invalid(self, invalid_value):
280
+ m = load_main_module()
281
+ with pytest.raises(ValueError):
282
+ color = m.resolve_color(invalid_value)
283
+ for channel in color:
284
+ assert 0 <= channel <= 255
285
+
286
+ # TODO: Add a test once the expected behavior for an unknown preset is defined.
287
+ # TODO: Add tests for font_size=0, negative font_size, and a valid custom font.
288
+ # TODO: Add tests for empty files, CRLF files, and Unicode file contents.
289
+ # TODO: Add tests for save_image overwriting files and nonexistent parent
290
+ # paths.