Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.46% covered (warning)
86.46%
198 / 229
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
TsumegoImagesController
86.34% covered (warning)
86.34%
196 / 227
33.33% covered (danger)
33.33%
2 / 6
71.79
0.00% covered (danger)
0.00%
0 / 1
 tsumegoImage
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
6.03
 _generatePuzzleImage
89.13% covered (warning)
89.13%
123 / 138
0.00% covered (danger)
0.00%
0 / 1
43.16
 _drawStone
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
4
 _getStarPoints
31.58% covered (danger)
31.58%
6 / 19
0.00% covered (danger)
0.00%
0 / 1
9.12
 _parseSgfStones
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 _sgfCoordToXY
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
1<?php
2
3App::uses('AppController', 'Controller');
4App::uses('Constants', 'Utility');
5
6/**
7 * TsumegoImagesController
8 *
9 * Generates PNG images of tsumego puzzles for Open Graph social sharing.
10 *
11 * IMPORTANT: When changing the rendering algorithm below, bump
12 * Constants::$TSUMEGO_IMAGE_VERSION to invalidate cached OG images.
13 */
14class TsumegoImagesController extends AppController
15{
16    /**
17     * Generate a PNG image for a tsumego puzzle for Open Graph sharing
18     *
19     * @param int|null $setConnectionId The SetConnection ID (same as /id route)
20     * @return CakeResponse PNG image with caching headers
21     */
22    public function tsumegoImage($setConnectionId = null)
23    {
24        if (!$setConnectionId || !is_numeric($setConnectionId))
25            throw new NotFoundException('Invalid set connection ID');
26
27        // Single query: join SetConnection → Tsumego → Sgf + Set, fetch only needed fields
28        $data = ClassRegistry::init('SetConnection')->query(
29            'SELECT s.title AS set_title, t.description, t.created, sgf.sgf
30            FROM set_connection sc
31            JOIN tsumego t ON t.id = sc.tsumego_id
32            JOIN `set` s ON s.id = sc.set_id
33            JOIN sgf ON sgf.tsumego_id = t.id AND sgf.accepted = 1
34            WHERE sc.id = ?
35            ORDER BY sgf.id DESC
36            LIMIT 1',
37            [(int) $setConnectionId]
38        );
39
40        if (empty($data))
41            throw new NotFoundException('Puzzle not found');
42
43        $row = $data[0];
44        $sgfString = $row['sgf']['sgf'];
45        $setTitle = $row['s']['set_title'] ?? 'Tsumego';
46        $description = $row['t']['description'] ?? '';
47
48        if (empty($sgfString))
49            throw new NotFoundException('SGF not found');
50
51        $imageData = $this->_generatePuzzleImage($sgfString, $setTitle, $description);
52
53        $this->response->type('png');
54        $this->response->body($imageData);
55        $this->response->etag(md5(Constants::$TSUMEGO_IMAGE_VERSION . $sgfString));
56        $modified = !empty($row['t']['created']) ? $row['t']['created'] : '-1 day';
57        $this->response->cache($modified, '+1 year');
58
59        return $this->response;
60    }
61
62    /**
63     * Generate puzzle image using PHP GD
64     *
65     * @param string $sgfString SGF content
66     * @param string $setTitle Set/collection title (e.g., "Life & Death - Intermediate")
67     * @param string $description Problem description (e.g., "[b] to live.")
68     * @return string PNG image data
69     */
70    private function _generatePuzzleImage($sgfString, $setTitle, $description)
71    {
72        // Parse board size from SGF (default 19)
73        $boardSize = 19;
74        if (preg_match('/SZ\[(\d+)\]/', $sgfString, $szMatch))
75            $boardSize = (int) $szMatch[1];
76
77        // Parse SGF and extract stone positions
78        $stones = $this->_parseSgfStones($sgfString);
79        if (empty($stones))
80            throw new InternalErrorException('No stones found in SGF');
81
82        // Calculate bounding box of stones
83        $minX = $minY = $boardSize - 1;
84        $maxX = $maxY = 0;
85        foreach ($stones as $stone)
86        {
87            $minX = min($minX, $stone['x']);
88            $maxX = max($maxX, $stone['x']);
89            $minY = min($minY, $stone['y']);
90            $maxY = max($maxY, $stone['y']);
91        }
92
93        // Add padding (2 intersections on each side, but don't go outside board)
94        $padding = 2;
95        $minX = max(0, $minX - $padding);
96        $minY = max(0, $minY - $padding);
97        $maxX = min($boardSize - 1, $maxX + $padding);
98        $maxY = min($boardSize - 1, $maxY + $padding);
99
100        $cropWidth = $maxX - $minX + 1;  // Number of intersections
101        $cropHeight = $maxY - $minY + 1;
102
103        // Determine if board should be rotated (portrait -> landscape)
104        $rotate = $cropHeight > $cropWidth;
105
106        // If rotating, swap width/height for calculations
107        if ($rotate)
108        {
109            $temp = $cropWidth;
110            $cropWidth = $cropHeight;
111            $cropHeight = $temp;
112        }
113
114        // Image dimensions
115        $width = Constants::$OG_IMAGE_WIDTH;
116        $height = Constants::$OG_IMAGE_HEIGHT;
117
118        // Calculate cell size — account for stone overhang and coordinate labels
119        // Stones extend 0.45*cellSize beyond grid + shadow adds ~0.1*cellSize more
120        $stoneOverhang = 0.55; // safety margin for stone radius + shadow
121        // Labels need extra space: right (row numbers) and bottom (column letters)
122        $labelSpace = 1.0; // ~1 cellSize worth of space for labels
123        $effectiveCropWidth = ($cropWidth - 1) + 2 * $stoneOverhang + $labelSpace;
124        $effectiveCropHeight = ($cropHeight - 1) + 2 * $stoneOverhang + $labelSpace;
125
126        $margin = 20;
127        $availableWidth = $width - (2 * $margin);
128        $availableHeight = $height - (2 * $margin);
129
130        $cellSize = min(
131            $availableWidth / $effectiveCropWidth,
132            $availableHeight / $effectiveCropHeight
133        );
134
135        // Calculate board dimensions
136        $boardPixelsWidth = ($cropWidth - 1) * $cellSize;
137        $boardPixelsHeight = ($cropHeight - 1) * $cellSize;
138
139        // Offset board center: shift LEFT for right labels, shift UP for bottom labels
140        $labelPixels = $cellSize * $labelSpace * 0.5;
141        $boardX = ($width - $boardPixelsWidth) / 2 - $labelPixels;
142        $boardY = ($height - $boardPixelsHeight) / 2 - $labelPixels;
143
144        // Create image
145        $img = imagecreatetruecolor($width, $height);
146        imageantialias($img, true);
147
148        // Define colors
149        $bgColor = imagecolorallocate($img, 220, 179, 92); // Wood/tan background
150        $boardColor = imagecolorallocate($img, 210, 170, 85); // Slightly darker for board
151        $lineColor = imagecolorallocate($img, 0, 0, 0); // Black lines
152        $borderColor = imagecolorallocate($img, 60, 40, 10); // Dark brown board frame
153
154        // Fill background
155        imagefill($img, 0, 0, $bgColor);
156
157        // Draw board background; frame only on sides that are actual board edges
158        $frameWidth = 4;
159        $isLeftEdge = ($rotate ? $minY : $minX) === 0;
160        $isRightEdge = ($rotate ? $maxY : $maxX) === $boardSize - 1;
161        $isTopEdge = ($rotate ? $minX : $minY) === 0;
162        $isBottomEdge = ($rotate ? $maxX : $maxY) === $boardSize - 1;
163        $bx0 = (int) $boardX;
164        $by0 = (int) $boardY;
165        $bx1 = (int) ($boardX + ($cropWidth - 1) * $cellSize);
166        $by1 = (int) ($boardY + ($cropHeight - 1) * $cellSize);
167        imagefilledrectangle($img, $bx0, $by0, $bx1, $by1, $boardColor);
168        if ($isLeftEdge)
169            imagefilledrectangle($img, $bx0 - $frameWidth, $by0 - ($isTopEdge ? $frameWidth : 0), $bx0 - 1, $by1 + ($isBottomEdge ? $frameWidth : 0), $borderColor);
170        if ($isRightEdge)
171            imagefilledrectangle($img, $bx1 + 1, $by0 - ($isTopEdge ? $frameWidth : 0), $bx1 + $frameWidth, $by1 + ($isBottomEdge ? $frameWidth : 0), $borderColor);
172        if ($isTopEdge)
173            imagefilledrectangle($img, $bx0 - ($isLeftEdge ? $frameWidth : 0), $by0 - $frameWidth, $bx1 + ($isRightEdge ? $frameWidth : 0), $by0 - 1, $borderColor);
174        if ($isBottomEdge)
175            imagefilledrectangle($img, $bx0 - ($isLeftEdge ? $frameWidth : 0), $by1 + 1, $bx1 + ($isRightEdge ? $frameWidth : 0), $by1 + $frameWidth, $borderColor);
176
177        // Draw grid lines - thicker at board edges
178
179        // Interior grid lines (thin)
180        imagesetthickness($img, 1);
181        for ($i = 0; $i < $cropWidth; $i++)
182        {
183            $pos = $boardX + $i * $cellSize;
184            imageline($img, (int) $pos, (int) $boardY, (int) $pos, (int) ($boardY + ($cropHeight - 1) * $cellSize), $lineColor);
185        }
186        for ($i = 0; $i < $cropHeight; $i++)
187        {
188            $pos = $boardY + $i * $cellSize;
189            imageline($img, (int) $boardX, (int) $pos, (int) ($boardX + ($cropWidth - 1) * $cellSize), (int) $pos, $lineColor);
190        }
191
192        // Thicker edge lines where crop touches actual board edge
193        imagesetthickness($img, 3);
194        if ($isLeftEdge)
195            imageline($img, (int) $boardX, (int) $boardY, (int) $boardX, (int) ($boardY + ($cropHeight - 1) * $cellSize), $lineColor);
196        if ($isRightEdge)
197            imageline($img, (int) ($boardX + ($cropWidth - 1) * $cellSize), (int) $boardY, (int) ($boardX + ($cropWidth - 1) * $cellSize), (int) ($boardY + ($cropHeight - 1) * $cellSize), $lineColor);
198        if ($isTopEdge)
199            imageline($img, (int) $boardX, (int) $boardY, (int) ($boardX + ($cropWidth - 1) * $cellSize), (int) $boardY, $lineColor);
200        if ($isBottomEdge)
201            imageline($img, (int) $boardX, (int) ($boardY + ($cropHeight - 1) * $cellSize), (int) ($boardX + ($cropWidth - 1) * $cellSize), (int) ($boardY + ($cropHeight - 1) * $cellSize), $lineColor);
202        imagesetthickness($img, 1);
203
204        // Draw star points (hoshi) - only if they're in the cropped area
205        $starPoints = $this->_getStarPoints($boardSize);
206        foreach ($starPoints as $point)
207            if ($point[0] >= $minX && $point[0] <= $maxX && $point[1] >= $minY && $point[1] <= $maxY)
208            {
209                if ($rotate)
210                {
211                    // Swap coordinates for rotated board
212                    $x = $boardX + ($point[1] - $minY) * $cellSize;
213                    $y = $boardY + ($point[0] - $minX) * $cellSize;
214                }
215                else
216                {
217                    $x = $boardX + ($point[0] - $minX) * $cellSize;
218                    $y = $boardY + ($point[1] - $minY) * $cellSize;
219                }
220                $starSize = (int) ($cellSize * 0.2);
221                imagefilledellipse($img, (int) $x, (int) $y, $starSize, $starSize, $lineColor);
222            }
223
224        // Draw coordinate labels BEFORE stones (so stones naturally cover labels at edges)
225        $labelColor = imagecolorallocate($img, 140, 115, 60); // Warm brown, visible on wood
226        $fontPath = dirname(__DIR__, 2) . '/resources/fonts/NimbusSans-Bold.otf';
227        $labelFontSize = max(10, $cellSize * 0.45); // ~45% of cell size - large and readable
228        $colLetters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T'];
229        $labelGap = $frameWidth + $cellSize * 0.55; // Clear stone overhang at board edges
230
231        // Column letters along bottom
232        for ($i = 0; $i < $cropWidth; $i++)
233        {
234            $col = $rotate ? ($minY + $i) : ($minX + $i);
235            if ($col < 0 || $col >= count($colLetters))
236                continue;
237            $letter = $colLetters[$col];
238            $x = $boardX + $i * $cellSize;
239            $bbox = imagettfbbox($labelFontSize, 0, $fontPath, $letter);
240            $textWidth = $bbox[2] - $bbox[0];
241            $textHeight = $bbox[1] - $bbox[7];
242            imagettftext($img, $labelFontSize, 0,
243                (int) ($x - $textWidth / 2),
244                (int) ($boardY + ($cropHeight - 1) * $cellSize + $labelGap + $textHeight),
245                $labelColor, $fontPath, $letter);
246        }
247
248        // Row numbers along right side (Go convention: row 1 is at bottom of board)
249        for ($i = 0; $i < $cropHeight; $i++)
250        {
251            $row = $rotate ? ($minX + $i) : ($minY + $i);
252            $rowNum = $boardSize - $row;
253            $label = (string) $rowNum;
254            $y = $boardY + $i * $cellSize;
255            $bbox = imagettfbbox($labelFontSize, 0, $fontPath, $label);
256            $textWidth = $bbox[2] - $bbox[0];
257            $textHeight = $bbox[1] - $bbox[7];
258            imagettftext($img, $labelFontSize, 0,
259                (int) ($boardX + ($cropWidth - 1) * $cellSize + $labelGap),
260                (int) ($y + $textHeight / 2),
261                $labelColor, $fontPath, $label);
262        }
263
264        // Draw stones with 3D gradient effect and shadows
265        $stoneRadius = $cellSize * 0.45;
266        foreach ($stones as $stone)
267        {
268            if ($rotate)
269            {
270                $x = $boardX + ($stone['y'] - $minY) * $cellSize;
271                $y = $boardY + ($stone['x'] - $minX) * $cellSize;
272            }
273            else
274            {
275                $x = $boardX + ($stone['x'] - $minX) * $cellSize;
276                $y = $boardY + ($stone['y'] - $minY) * $cellSize;
277            }
278
279            $this->_drawStone($img, (int) $x, (int) $y, $stoneRadius, $stone['color'] === 'B');
280        }
281
282        // Convert to PNG and return
283        ob_start();
284        imagepng($img, null, 6); // Compression level 6 (balance speed/size)
285        $imageData = ob_get_clean();
286        imagedestroy($img);
287
288        return $imageData;
289    }
290
291    /**
292     * Draw a single stone with 3D radial gradient and shadow
293     *
294     * Creates a realistic-looking stone using concentric circles that
295     * interpolate from base color at edges to highlight color at an
296     * offset point, simulating light reflection.
297     *
298     * @param \GdImage $img GD image resource
299     * @param int $cx Center X coordinate
300     * @param int $cy Center Y coordinate
301     * @param float $radius Stone radius in pixels
302     * @param bool $isBlack True for black stone, false for white
303     */
304    private function _drawStone(\GdImage $img, int $cx, int $cy, float $radius, bool $isBlack)
305    {
306        // Shadow (slightly offset, darkened board color)
307        $shadowOffset = max(2, (int) ($radius * 0.08));
308        $shadowDiameter = (int) ($radius * 2.05);
309        $shadowColor = imagecolorallocate($img, 160, 130, 60);
310        imagefilledellipse($img, $cx + $shadowOffset, $cy + $shadowOffset, $shadowDiameter, $shadowDiameter, $shadowColor);
311
312        // Gradient parameters
313        if ($isBlack)
314        {
315            $baseR = 10;
316            $baseG = 10;
317            $baseB = 10;
318            $highlightR = 100;
319            $highlightG = 100;
320            $highlightB = 100;
321        }
322        else
323        {
324            $baseR = 195;
325            $baseG = 195;
326            $baseB = 200;
327            $highlightR = 255;
328            $highlightG = 255;
329            $highlightB = 255;
330        }
331
332        // Draw concentric circles from outside to inside
333        $steps = max(8, (int) ($radius * 0.6));
334        $highlightOffsetX = -$radius * 0.3;
335        $highlightOffsetY = -$radius * 0.35;
336
337        for ($i = $steps; $i >= 0; $i--)
338        {
339            $ratio = ($steps - $i) / $steps; // 0 at edge, 1 at highlight center
340
341            // Position: interpolate from stone center toward highlight
342            $x = $cx + $highlightOffsetX * $ratio;
343            $y = $cy + $highlightOffsetY * $ratio;
344
345            // Color: interpolate from base to highlight
346            $r = (int) ($baseR + ($highlightR - $baseR) * $ratio);
347            $g = (int) ($baseG + ($highlightG - $baseG) * $ratio);
348            $b = (int) ($baseB + ($highlightB - $baseB) * $ratio);
349
350            $color = imagecolorallocate($img, $r, $g, $b);
351            $currentDiameter = (int) ($radius * 2.0 * ($i + 1) / ($steps + 1));
352            imagefilledellipse($img, (int) $x, (int) $y, $currentDiameter, $currentDiameter, $color);
353        }
354
355        // White stone needs a subtle outline
356        if (!$isBlack)
357        {
358            $outlineColor = imagecolorallocate($img, 100, 100, 100);
359            imageellipse($img, $cx, $cy, (int) ($radius * 2), (int) ($radius * 2), $outlineColor);
360        }
361    }
362
363    /**
364     * Get star point coordinates for given board size
365     *
366     * @param int $size Board size (9, 13, or 19)
367     * @return array Array of [x, y] coordinates
368     */
369    private function _getStarPoints($size)
370    {
371        if ($size === 19)
372        {
373            return [
374                [3, 3], [9, 3], [15, 3],
375                [3, 9], [9, 9], [15, 9],
376                [3, 15], [9, 15], [15, 15]
377            ];
378        }
379        elseif ($size === 13)
380        {
381            return [
382                [3, 3], [9, 3],
383                [6, 6],
384                [3, 9], [9, 9]
385            ];
386        }
387        elseif ($size === 9)
388        {
389            return [
390                [2, 2], [6, 2],
391                [4, 4],
392                [2, 6], [6, 6]
393            ];
394        }
395
396        return [];
397    }
398
399    /**
400     * Parse SGF string to extract stone positions
401     *
402     * Extracts AB (add black), AW (add white) tags from SGF.
403     * Handles both single format AB[fa] and compact format AB[fa][bb][fc].
404     * Returns initial position suitable for tsumego problems.
405     *
406     * @param string $sgf SGF string
407     * @return array Array of ['x' => int, 'y' => int, 'color' => 'B'|'W']
408     */
409    private function _parseSgfStones($sgf)
410    {
411        $stones = [];
412
413        // Match AB[coords][coords]... (add black stones) - compact format
414        // Find AB tag, then capture all following [xx] coordinates
415        if (preg_match('/AB((?:\[[a-s]{2}\])+)/', $sgf, $match))
416        {
417            // Extract all [xx] coordinates from the captured group
418            preg_match_all('/\[([a-s]{2})\]/', $match[1], $coords);
419            foreach ($coords[1] as $coord)
420                $stones[] = array_merge($this->_sgfCoordToXY($coord), ['color' => 'B']);
421        }
422
423        // Match AW[coords][coords]... (add white stones) - compact format
424        if (preg_match('/AW((?:\[[a-s]{2}\])+)/', $sgf, $match))
425        {
426            preg_match_all('/\[([a-s]{2})\]/', $match[1], $coords);
427            foreach ($coords[1] as $coord)
428                $stones[] = array_merge($this->_sgfCoordToXY($coord), ['color' => 'W']);
429        }
430
431        return $stones;
432    }
433
434    /**
435     * Convert SGF coordinate (e.g., "aa", "pd") to x,y array coordinates
436     *
437     * SGF uses lowercase letters: a=0, b=1, ..., s=18
438     *
439     * @param string $coord Two-letter SGF coordinate
440     * @return array ['x' => int, 'y' => int]
441     */
442    private function _sgfCoordToXY($coord)
443    {
444        if (strlen($coord) !== 2)
445            return ['x' => 0, 'y' => 0];
446
447        $x = ord($coord[0]) - ord('a');
448        $y = ord($coord[1]) - ord('a');
449
450        return ['x' => $x, 'y' => $y];
451    }
452}