Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.80% covered (warning)
88.80%
222 / 250
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
TsumegoImagesController
88.71% covered (warning)
88.71%
220 / 248
33.33% covered (danger)
33.33%
2 / 6
60.51
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
92.45% covered (success)
92.45%
147 / 159
0.00% covered (danger)
0.00%
0 / 1
35.53
 _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        // Normalize to bottom-right corner for consistent display
94        // (like besogo's corner normalization, but always targeting bottom-right)
95        $centerX = ($minX + $maxX) / 2.0;
96        $centerY = ($minY + $maxY) / 2.0;
97        $hFlip = $centerX < ($boardSize - 1) / 2.0; // stones closer to left → flip right
98        $vFlip = $centerY < ($boardSize - 1) / 2.0; // stones closer to top → flip down
99        if ($hFlip || $vFlip)
100        {
101            foreach ($stones as &$stone)
102            {
103                if ($hFlip)
104                    $stone['x'] = $boardSize - 1 - $stone['x'];
105                if ($vFlip)
106                    $stone['y'] = $boardSize - 1 - $stone['y'];
107            }
108            unset($stone);
109            // Recalculate bounding box after flip
110            $minX = $minY = $boardSize - 1;
111            $maxX = $maxY = 0;
112            foreach ($stones as $stone)
113            {
114                $minX = min($minX, $stone['x']);
115                $maxX = max($maxX, $stone['x']);
116                $minY = min($minY, $stone['y']);
117                $maxY = max($maxY, $stone['y']);
118            }
119        }
120
121        // Add padding (2 intersections on each side, but don't go outside board)
122        $padding = 2;
123        $minX = max(0, $minX - $padding);
124        $minY = max(0, $minY - $padding);
125        $maxX = min($boardSize - 1, $maxX + $padding);
126        $maxY = min($boardSize - 1, $maxY + $padding);
127
128        $cropWidth = $maxX - $minX + 1;  // Number of intersections
129        $cropHeight = $maxY - $minY + 1;
130
131        // Determine if board should be rotated (portrait -> landscape)
132        $rotate = $cropHeight > $cropWidth;
133
134        // If rotating, swap width/height for calculations
135        if ($rotate)
136        {
137            $temp = $cropWidth;
138            $cropWidth = $cropHeight;
139            $cropHeight = $temp;
140        }
141
142        // Image dimensions
143        $width = Constants::$OG_IMAGE_WIDTH;
144        $height = Constants::$OG_IMAGE_HEIGHT;
145
146        // Calculate cell size — account for stone overhang and coordinate labels
147        // Stones extend 0.45*cellSize beyond grid + shadow adds ~0.1*cellSize more
148        $stoneOverhang = 0.55; // safety margin for stone radius + shadow
149        // Labels need extra space: right (row numbers) and bottom (column letters)
150        $labelSpace = 1.0; // ~1 cellSize worth of space for labels
151        $effectiveCropWidth = ($cropWidth - 1) + 2 * $stoneOverhang + $labelSpace;
152        $effectiveCropHeight = ($cropHeight - 1) + 2 * $stoneOverhang + $labelSpace;
153
154        $margin = 20;
155        $availableWidth = $width - (2 * $margin);
156        $availableHeight = $height - (2 * $margin);
157
158        $cellSize = min(
159            $availableWidth / $effectiveCropWidth,
160            $availableHeight / $effectiveCropHeight
161        );
162
163        // Calculate board dimensions
164        $boardPixelsWidth = ($cropWidth - 1) * $cellSize;
165        $boardPixelsHeight = ($cropHeight - 1) * $cellSize;
166
167        // Offset board center: shift LEFT for right labels, shift UP for bottom labels
168        $labelPixels = $cellSize * $labelSpace * 0.5;
169        $boardX = ($width - $boardPixelsWidth) / 2 - $labelPixels;
170        $boardY = ($height - $boardPixelsHeight) / 2 - $labelPixels;
171
172        // Create image
173        $img = imagecreatetruecolor($width, $height);
174        imageantialias($img, true);
175
176        // Define colors
177        $bgColor = imagecolorallocate($img, 220, 179, 92); // Wood/tan background
178        $boardColor = imagecolorallocate($img, 210, 170, 85); // Slightly darker for board
179        $lineColor = imagecolorallocate($img, 0, 0, 0); // Black lines
180        $borderColor = imagecolorallocate($img, 60, 40, 10); // Dark brown board frame
181
182        // Fill background
183        imagefill($img, 0, 0, $bgColor);
184
185        // Draw board background with subtle frame
186        $frameWidth = 4;
187        imagefilledrectangle(
188            $img,
189            (int) ($boardX - $frameWidth),
190            (int) ($boardY - $frameWidth),
191            (int) ($boardX + ($cropWidth - 1) * $cellSize + $frameWidth),
192            (int) ($boardY + ($cropHeight - 1) * $cellSize + $frameWidth),
193            $borderColor
194        );
195        imagefilledrectangle(
196            $img,
197            (int) ($boardX - $frameWidth + 2),
198            (int) ($boardY - $frameWidth + 2),
199            (int) ($boardX + ($cropWidth - 1) * $cellSize + $frameWidth - 2),
200            (int) ($boardY + ($cropHeight - 1) * $cellSize + $frameWidth - 2),
201            $boardColor
202        );
203
204        // Draw grid lines - thicker at board edges
205        $isLeftEdge = ($rotate ? $minY : $minX) === 0;
206        $isRightEdge = ($rotate ? $maxY : $maxX) === $boardSize - 1;
207        $isTopEdge = ($rotate ? $minX : $minY) === 0;
208        $isBottomEdge = ($rotate ? $maxX : $maxY) === $boardSize - 1;
209
210        // Interior grid lines (thin)
211        imagesetthickness($img, 1);
212        for ($i = 0; $i < $cropWidth; $i++)
213        {
214            $pos = $boardX + $i * $cellSize;
215            imageline($img, (int) $pos, (int) $boardY, (int) $pos, (int) ($boardY + ($cropHeight - 1) * $cellSize), $lineColor);
216        }
217        for ($i = 0; $i < $cropHeight; $i++)
218        {
219            $pos = $boardY + $i * $cellSize;
220            imageline($img, (int) $boardX, (int) $pos, (int) ($boardX + ($cropWidth - 1) * $cellSize), (int) $pos, $lineColor);
221        }
222
223        // Thicker edge lines where crop touches actual board edge
224        imagesetthickness($img, 3);
225        if ($isLeftEdge)
226            imageline($img, (int) $boardX, (int) $boardY, (int) $boardX, (int) ($boardY + ($cropHeight - 1) * $cellSize), $lineColor);
227        if ($isRightEdge)
228            imageline($img, (int) ($boardX + ($cropWidth - 1) * $cellSize), (int) $boardY, (int) ($boardX + ($cropWidth - 1) * $cellSize), (int) ($boardY + ($cropHeight - 1) * $cellSize), $lineColor);
229        if ($isTopEdge)
230            imageline($img, (int) $boardX, (int) $boardY, (int) ($boardX + ($cropWidth - 1) * $cellSize), (int) $boardY, $lineColor);
231        if ($isBottomEdge)
232            imageline($img, (int) $boardX, (int) ($boardY + ($cropHeight - 1) * $cellSize), (int) ($boardX + ($cropWidth - 1) * $cellSize), (int) ($boardY + ($cropHeight - 1) * $cellSize), $lineColor);
233        imagesetthickness($img, 1);
234
235        // Draw star points (hoshi) - only if they're in the cropped area
236        $starPoints = $this->_getStarPoints($boardSize);
237        foreach ($starPoints as $point)
238            if ($point[0] >= $minX && $point[0] <= $maxX && $point[1] >= $minY && $point[1] <= $maxY)
239            {
240                if ($rotate)
241                {
242                    // Swap coordinates for rotated board
243                    $x = $boardX + ($point[1] - $minY) * $cellSize;
244                    $y = $boardY + ($point[0] - $minX) * $cellSize;
245                }
246                else
247                {
248                    $x = $boardX + ($point[0] - $minX) * $cellSize;
249                    $y = $boardY + ($point[1] - $minY) * $cellSize;
250                }
251                $starSize = (int) ($cellSize * 0.2);
252                imagefilledellipse($img, (int) $x, (int) $y, $starSize, $starSize, $lineColor);
253            }
254
255        // Draw coordinate labels BEFORE stones (so stones naturally cover labels at edges)
256        $labelColor = imagecolorallocate($img, 140, 115, 60); // Warm brown, visible on wood
257        $fontPath = dirname(__DIR__, 2) . '/resources/fonts/NimbusSans-Bold.otf';
258        $labelFontSize = max(10, $cellSize * 0.45); // ~45% of cell size - large and readable
259        $colLetters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T'];
260        $labelGap = $frameWidth + $cellSize * 0.55; // Clear stone overhang at board edges
261
262        // Column letters along bottom
263        for ($i = 0; $i < $cropWidth; $i++)
264        {
265            $col = $rotate ? ($minY + $i) : ($minX + $i);
266            if ($col < 0 || $col >= count($colLetters))
267                continue;
268            $letter = $colLetters[$col];
269            $x = $boardX + $i * $cellSize;
270            $bbox = imagettfbbox($labelFontSize, 0, $fontPath, $letter);
271            $textWidth = $bbox[2] - $bbox[0];
272            $textHeight = $bbox[1] - $bbox[7];
273            imagettftext($img, $labelFontSize, 0,
274                (int) ($x - $textWidth / 2),
275                (int) ($boardY + ($cropHeight - 1) * $cellSize + $labelGap + $textHeight),
276                $labelColor, $fontPath, $letter);
277        }
278
279        // Row numbers along right side (Go convention: row 1 is at bottom of board)
280        for ($i = 0; $i < $cropHeight; $i++)
281        {
282            $row = $rotate ? ($minX + $i) : ($minY + $i);
283            $rowNum = $boardSize - $row;
284            $label = (string) $rowNum;
285            $y = $boardY + $i * $cellSize;
286            $bbox = imagettfbbox($labelFontSize, 0, $fontPath, $label);
287            $textWidth = $bbox[2] - $bbox[0];
288            $textHeight = $bbox[1] - $bbox[7];
289            imagettftext($img, $labelFontSize, 0,
290                (int) ($boardX + ($cropWidth - 1) * $cellSize + $labelGap),
291                (int) ($y + $textHeight / 2),
292                $labelColor, $fontPath, $label);
293        }
294
295        // Draw stones with 3D gradient effect and shadows
296        $stoneRadius = $cellSize * 0.45;
297        foreach ($stones as $stone)
298        {
299            if ($rotate)
300            {
301                $x = $boardX + ($stone['y'] - $minY) * $cellSize;
302                $y = $boardY + ($stone['x'] - $minX) * $cellSize;
303            }
304            else
305            {
306                $x = $boardX + ($stone['x'] - $minX) * $cellSize;
307                $y = $boardY + ($stone['y'] - $minY) * $cellSize;
308            }
309
310            $this->_drawStone($img, (int) $x, (int) $y, $stoneRadius, $stone['color'] === 'B');
311        }
312
313        // Convert to PNG and return
314        ob_start();
315        imagepng($img, null, 6); // Compression level 6 (balance speed/size)
316        $imageData = ob_get_clean();
317        imagedestroy($img);
318
319        return $imageData;
320    }
321
322    /**
323     * Draw a single stone with 3D radial gradient and shadow
324     *
325     * Creates a realistic-looking stone using concentric circles that
326     * interpolate from base color at edges to highlight color at an
327     * offset point, simulating light reflection.
328     *
329     * @param \GdImage $img GD image resource
330     * @param int $cx Center X coordinate
331     * @param int $cy Center Y coordinate
332     * @param float $radius Stone radius in pixels
333     * @param bool $isBlack True for black stone, false for white
334     */
335    private function _drawStone(\GdImage $img, int $cx, int $cy, float $radius, bool $isBlack)
336    {
337        // Shadow (slightly offset, darkened board color)
338        $shadowOffset = max(2, (int) ($radius * 0.08));
339        $shadowDiameter = (int) ($radius * 2.05);
340        $shadowColor = imagecolorallocate($img, 160, 130, 60);
341        imagefilledellipse($img, $cx + $shadowOffset, $cy + $shadowOffset, $shadowDiameter, $shadowDiameter, $shadowColor);
342
343        // Gradient parameters
344        if ($isBlack)
345        {
346            $baseR = 10;
347            $baseG = 10;
348            $baseB = 10;
349            $highlightR = 100;
350            $highlightG = 100;
351            $highlightB = 100;
352        }
353        else
354        {
355            $baseR = 195;
356            $baseG = 195;
357            $baseB = 200;
358            $highlightR = 255;
359            $highlightG = 255;
360            $highlightB = 255;
361        }
362
363        // Draw concentric circles from outside to inside
364        $steps = max(8, (int) ($radius * 0.6));
365        $highlightOffsetX = -$radius * 0.3;
366        $highlightOffsetY = -$radius * 0.35;
367
368        for ($i = $steps; $i >= 0; $i--)
369        {
370            $ratio = ($steps - $i) / $steps; // 0 at edge, 1 at highlight center
371
372            // Position: interpolate from stone center toward highlight
373            $x = $cx + $highlightOffsetX * $ratio;
374            $y = $cy + $highlightOffsetY * $ratio;
375
376            // Color: interpolate from base to highlight
377            $r = (int) ($baseR + ($highlightR - $baseR) * $ratio);
378            $g = (int) ($baseG + ($highlightG - $baseG) * $ratio);
379            $b = (int) ($baseB + ($highlightB - $baseB) * $ratio);
380
381            $color = imagecolorallocate($img, $r, $g, $b);
382            $currentDiameter = (int) ($radius * 2.0 * ($i + 1) / ($steps + 1));
383            imagefilledellipse($img, (int) $x, (int) $y, $currentDiameter, $currentDiameter, $color);
384        }
385
386        // White stone needs a subtle outline
387        if (!$isBlack)
388        {
389            $outlineColor = imagecolorallocate($img, 100, 100, 100);
390            imageellipse($img, $cx, $cy, (int) ($radius * 2), (int) ($radius * 2), $outlineColor);
391        }
392    }
393
394    /**
395     * Get star point coordinates for given board size
396     *
397     * @param int $size Board size (9, 13, or 19)
398     * @return array Array of [x, y] coordinates
399     */
400    private function _getStarPoints($size)
401    {
402        if ($size === 19)
403        {
404            return [
405                [3, 3], [9, 3], [15, 3],
406                [3, 9], [9, 9], [15, 9],
407                [3, 15], [9, 15], [15, 15]
408            ];
409        }
410        elseif ($size === 13)
411        {
412            return [
413                [3, 3], [9, 3],
414                [6, 6],
415                [3, 9], [9, 9]
416            ];
417        }
418        elseif ($size === 9)
419        {
420            return [
421                [2, 2], [6, 2],
422                [4, 4],
423                [2, 6], [6, 6]
424            ];
425        }
426
427        return [];
428    }
429
430    /**
431     * Parse SGF string to extract stone positions
432     *
433     * Extracts AB (add black), AW (add white) tags from SGF.
434     * Handles both single format AB[fa] and compact format AB[fa][bb][fc].
435     * Returns initial position suitable for tsumego problems.
436     *
437     * @param string $sgf SGF string
438     * @return array Array of ['x' => int, 'y' => int, 'color' => 'B'|'W']
439     */
440    private function _parseSgfStones($sgf)
441    {
442        $stones = [];
443
444        // Match AB[coords][coords]... (add black stones) - compact format
445        // Find AB tag, then capture all following [xx] coordinates
446        if (preg_match('/AB((?:\[[a-s]{2}\])+)/', $sgf, $match))
447        {
448            // Extract all [xx] coordinates from the captured group
449            preg_match_all('/\[([a-s]{2})\]/', $match[1], $coords);
450            foreach ($coords[1] as $coord)
451                $stones[] = array_merge($this->_sgfCoordToXY($coord), ['color' => 'B']);
452        }
453
454        // Match AW[coords][coords]... (add white stones) - compact format
455        if (preg_match('/AW((?:\[[a-s]{2}\])+)/', $sgf, $match))
456        {
457            preg_match_all('/\[([a-s]{2})\]/', $match[1], $coords);
458            foreach ($coords[1] as $coord)
459                $stones[] = array_merge($this->_sgfCoordToXY($coord), ['color' => 'W']);
460        }
461
462        return $stones;
463    }
464
465    /**
466     * Convert SGF coordinate (e.g., "aa", "pd") to x,y array coordinates
467     *
468     * SGF uses lowercase letters: a=0, b=1, ..., s=18
469     *
470     * @param string $coord Two-letter SGF coordinate
471     * @return array ['x' => int, 'y' => int]
472     */
473    private function _sgfCoordToXY($coord)
474    {
475        if (strlen($coord) !== 2)
476            return ['x' => 0, 'y' => 0];
477
478        $x = ord($coord[0]) - ord('a');
479        $y = ord($coord[1]) - ord('a');
480
481        return ['x' => $x, 'y' => $y];
482    }
483}