aboutsummaryrefslogtreecommitdiff
path: root/tests/test_cover.py
blob: f19db5a0aa28b8d55607aa22fe49c87f20d2e2e0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
"""Tests for stdlib-only cover generation: PNG structure, gradient, text."""

import random
import struct
import tempfile
import unittest
import zlib
from pathlib import Path

from converter.cover import (
    _random_light_color,
    _text_width,
    _wrap_title,
    generate_cover,
)


def _decode_png(data: bytes):
    """Parse a PNG into (width, height, rows of RGB tuples)."""
    assert data[:8] == b"\x89PNG\r\n\x1a\n", "bad PNG signature"
    pos = 8
    idat = b""
    width = height = None
    while pos < len(data):
        length, chunk_type = struct.unpack(">I4s", data[pos:pos + 8])
        chunk_data = data[pos + 8:pos + 8 + length]
        crc = struct.unpack(">I", data[pos + 8 + length:pos + 12 + length])[0]
        assert crc == zlib.crc32(chunk_type + chunk_data) & 0xFFFFFFFF, "bad CRC"
        if chunk_type == b"IHDR":
            width, height, depth, color_type = struct.unpack(">IIBB", chunk_data[:10])
            assert depth == 8 and color_type == 2  # 8-bit RGB
        elif chunk_type == b"IDAT":
            idat += chunk_data
        pos += 12 + length
    raw = zlib.decompress(idat)
    stride = 1 + width * 3
    assert len(raw) == height * stride, "unexpected decompressed size"
    rows = []
    for y in range(height):
        row = raw[y * stride + 1:(y + 1) * stride]
        rows.append([tuple(row[x * 3:x * 3 + 3]) for x in range(width)])
    return width, height, rows


def _black_pixels(rows):
    return sum(1 for row in rows for pixel in row if pixel == (0, 0, 0))


class GenerateCoverTests(unittest.TestCase):
    def _write(self, title, width=120, height=180, seed=7):
        with tempfile.TemporaryDirectory() as tmp:
            path = Path(tmp) / "cover.png"
            result = generate_cover(title, path, width=width, height=height, seed=seed)
            data = path.read_bytes()
        return result, data

    def test_valid_png_with_requested_dimensions(self):
        _, data = self._write("Hello")
        width, height, rows = _decode_png(data)
        self.assertEqual((width, height), (120, 180))
        self.assertEqual(len(rows), 180)

    def test_gradient_matches_seeded_colors(self):
        _, data = self._write("Hello", seed=42)
        width, height, rows = _decode_png(data)
        rng = random.Random(42)
        top = _random_light_color(rng)
        bottom = _random_light_color(rng)
        # Corners of the text-free top/bottom rows match the endpoints
        self.assertEqual(rows[0][0], top)
        self.assertEqual(rows[0][-1], top)
        self.assertEqual(rows[height - 1][0], bottom)
        self.assertEqual(rows[height - 1][-1], bottom)

    def test_gradient_colors_are_light(self):
        # Text-free bottom row: every channel must stay in pastel territory
        _, data = self._write("Hello", seed=1)
        _, height, rows = _decode_png(data)
        for channel in rows[height - 1][0]:
            self.assertGreaterEqual(channel, 90)

    def test_title_renders_black_pixels(self):
        _, data = self._write("Hello")
        _, _, rows = _decode_png(data)
        self.assertGreater(_black_pixels(rows), 50)

    def test_title_renders_white_pixels(self):
        _, data = self._write("Hello")
        _, _, rows = _decode_png(data)
        white = sum(1 for row in rows for pixel in row if pixel == (255, 255, 255))
        self.assertGreater(white, 50)

    def test_white_text_sits_on_black_stroke(self):
        # Directly above a white pixel row there must be a black stroke row:
        # sample white pixels and confirm black neighbors within stroke width.
        _, data = self._write("Hi", width=200, height=100, seed=3)
        _, _, rows = _decode_png(data)
        whites = [(x, y) for y, row in enumerate(rows)
                  for x, pixel in enumerate(row) if pixel == (255, 255, 255)]
        self.assertTrue(whites)
        checked = near_stroke = 0
        for x, y in whites[::5]:
            neighborhood = []
            for dy in range(-3, 4):
                for dx in range(-3, 4):
                    if 0 <= y + dy < len(rows) and 0 <= x + dx < len(rows[0]):
                        neighborhood.append(rows[y + dy][x + dx])
            checked += 1
            if (0, 0, 0) in neighborhood:
                near_stroke += 1
        # Interior white pixels are surrounded by white; every sampled pixel
        # should still see stroke black within 3px (font strokes are 5-6 px thick)
        self.assertEqual(near_stroke, checked)

    def test_empty_title_renders_gradient_only(self):
        _, data = self._write("")
        _, _, rows = _decode_png(data)
        self.assertEqual(_black_pixels(rows), 0)

    def test_unrenderable_title_degrades_to_gradient(self):
        # CJK glyphs are not in the bitmap font; no crash, no text pixels
        _, data = self._write("书名")
        _, _, rows = _decode_png(data)
        self.assertEqual(_black_pixels(rows), 0)

    def test_write_failure_returns_none(self):
        result = generate_cover("Hello", Path("/nonexistent_dir/cover.png"))
        self.assertIsNone(result)


class WrapTitleTests(unittest.TestCase):
    def test_short_title_one_line(self):
        self.assertEqual(len(_wrap_title("Dune", 500)), 1)

    def test_long_title_wraps(self):
        lines = _wrap_title("The Extremely Long Windy Title of a Very Long Book", 600)
        self.assertGreater(len(lines), 1)
        for line in lines:
            self.assertLessEqual(_text_width(line), 600)

    def test_single_long_word_kept_intact(self):
        lines = _wrap_title("Antidisestablishmentarianism", 10)
        self.assertEqual(lines, ["Antidisestablishmentarianism"])

    def test_empty_title_no_lines(self):
        self.assertEqual(_wrap_title("", 500), [])


class TextWidthTests(unittest.TestCase):
    def test_empty(self):
        self.assertEqual(_text_width(""), 0)

    def test_single_char_is_scaled_glyph(self):
        self.assertEqual(_text_width("A"), 30)  # 5 px * scale 6

    def test_chars_include_spacing(self):
        self.assertEqual(_text_width("AB"), 66)  # (2 glyphs * 6 - 1) * 6


class DropShadowTests(unittest.TestCase):
    def _cover_rows(self, title, seed=7):
        with tempfile.TemporaryDirectory() as tmp:
            path = Path(tmp) / "cover.png"
            generate_cover(title, path, width=200, height=200, seed=seed)
            return _decode_png(path.read_bytes())[2]

    def test_shadow_pixels_survive_next_to_text(self):
        rows = self._cover_rows("Hi")
        # The shadow lives down-right of the glyphs: there must be darkened
        # (but not pure black, not full-brightness) pixels beyond the text
        # block's bottom edge.
        blacks = {(x, y) for y, row in enumerate(rows)
                  for x, pixel in enumerate(row) if pixel == (0, 0, 0)}
        self.assertTrue(blacks, "no text rendered")
        text_bottom = max(y for _, y in blacks)
        darkened = [pixel for y, row in enumerate(rows)
                    if y > text_bottom for pixel in row
                    if pixel != (0, 0, 0) and max(pixel) < 130]
        self.assertTrue(darkened, "no shadow pixels below the text")

    def test_empty_title_has_no_shadow(self):
        rows = self._cover_rows("")
        for row in rows:
            for pixel in row:
                self.assertNotEqual(pixel, (0, 0, 0))


if __name__ == "__main__":
    unittest.main()