• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tito10047 / progressive-image-bundle / 21109351256

18 Jan 2026 09:16AM UTC coverage: 90.436% (+0.4%) from 90.011%
21109351256

push

github

Jozef Mostka
fix tests

851 of 941 relevant lines covered (90.44%)

322.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.73
/src/Service/ResponsiveAttributeGenerator.php
1
<?php
2

3
/*
4
 * This file is part of the Progressive Image Bundle.
5
 *
6
 * (c) Jozef Môstka <https://github.com/tito10047/progressive-image-bundle>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
namespace Tito10047\ProgressiveImageBundle\Service;
13

14
use Tito10047\ProgressiveImageBundle\DTO\BreakpointAssignment;
15
use Tito10047\ProgressiveImageBundle\DTO\ResponsiveAttributes;
16
use Tito10047\ProgressiveImageBundle\DTO\ResponsiveAttributesInterface;
17
use Tito10047\ProgressiveImageBundle\DTO\ResponsiveSource;
18
use Tito10047\ProgressiveImageBundle\Modifier\ModifierProvider;
19
use Tito10047\ProgressiveImageBundle\UrlGenerator\ResponsiveImageUrlGeneratorInterface;
20

21
final class ResponsiveAttributeGenerator
22
{
23
    /**
24
     * @param array{
25
     *      layouts: array<string, array{
26
     *      min_viewport: int,
27
     *      max_container: int|null
28
     *      }>,
29
     *      columns: int
30
     *      } $gridConfig
31
     * @param array<string, string> $ratioConfig
32
     * @param int[]                 $retinaMultipliers
33
     */
34
    public function __construct(
35
        private array $gridConfig,
36
        private array $ratioConfig,
37
        private readonly array $retinaMultipliers,
38
        private readonly PreloadCollector $preloadCollector,
39
        private ResponsiveImageUrlGeneratorInterface $urlGenerator,
40
        private readonly ?ModifierProvider $modifierProvider = null,
41
    ) {
42
    }
551✔
43

44
    /**
45
     * @param BreakpointAssignment[] $assignments
46
     */
47
    public function generate(string $path, array $assignments, int $originalWidth, bool $preload, ?string $pointInterest = null, array $context = [], bool $retina = false): ResponsiveAttributesInterface
48
    {
49
        $assignments = $this->sortAssignments($assignments);
388✔
50

51
        $sources = [];
388✔
52
        $variables = [];
388✔
53
        $defaultSource = null;
388✔
54

55
        foreach ($assignments as $assignment) {
388✔
56
            $layout = $this->gridConfig['layouts'][$assignment->breakpoint] ?? null;
388✔
57
            if (!$layout && 'default' === $assignment->breakpoint) {
388✔
58
                foreach ($this->gridConfig['layouts'] as $l) {
58✔
59
                    if (0 === $l['min_viewport']) {
58✔
60
                        $layout = $l;
58✔
61
                        break;
58✔
62
                    }
63
                }
64
            }
65

66
            if (!$layout) {
388✔
67
                throw new \InvalidArgumentException(sprintf('Breakpoint "%s" is not defined in the grid configuration.', $assignment->breakpoint));
×
68
            }
69

70
            [$pixelWidth, $sizeValue, $cssValue] = $this->calculateDimensions($assignment, $layout);
388✔
71

72
            $size = $this->formatSizePart($layout['min_viewport'], $sizeValue);
388✔
73

74
            $multipliers = $retina ? $this->retinaMultipliers : [1];
388✔
75
            $srcsetParts = [];
388✔
76

77
            foreach ($multipliers as $multiplier) {
388✔
78
                $mPixelWidth = (int) round($pixelWidth * $multiplier);
388✔
79
                $url = $this->generateUrl($path, $assignment, $mPixelWidth, $originalWidth, $pointInterest, $context);
388✔
80

81
                if ($url) {
388✔
82
                    if ($preload && 1 === $multiplier) {
388✔
83
                        $this->preloadCollector->add($url, 'image', 'high', "{$mPixelWidth}w", $size);
28✔
84
                    }
85

86
                    $srcsetParts[] = $url." {$mPixelWidth}w";
388✔
87
                }
88
            }
89

90
            $ratio = $this->resolveRatio($assignment);
388✔
91
            $suffix = 0 === $layout['min_viewport'] ? '' : '-'.$assignment->breakpoint;
388✔
92
            $variables['--img-width'.$suffix] = $cssValue;
388✔
93
            if ($ratio) {
388✔
94
                $variables['--img-aspect'.$suffix] = (string) $ratio;
233✔
95
            }
96

97
            $srcset = implode(', ', $srcsetParts);
388✔
98
            $media = $layout['min_viewport'] > 0 ? "(min-width: {$layout['min_viewport']}px)" : null;
388✔
99
            $source = new ResponsiveSource($media, $srcset, $sizeValue);
388✔
100

101
            if (null === $media) {
388✔
102
                $defaultSource = $source;
200✔
103
            } else {
104
                $sources[] = $source;
373✔
105
            }
106
        }
107

108
        if (null === $defaultSource) {
388✔
109
            $defaultSource = new ResponsiveSource(null, '', '');
203✔
110
        }
111

112
        return new ResponsiveAttributes($sources, $defaultSource, $variables);
388✔
113
    }
114

115
    private function formatSizePart(int $minViewport, string $sizeValue): string
116
    {
117
        return $minViewport > 0
388✔
118
            ? "(min-width: {$minViewport}px) {$sizeValue}"
373✔
119
            : $sizeValue;
388✔
120
    }
121

122
    /**
123
     * @param BreakpointAssignment[] $assignments
124
     *
125
     * @return BreakpointAssignment[]
126
     */
127
    private function sortAssignments(array $assignments): array
128
    {
129
        usort($assignments, fn ($a, $b) => ($this->gridConfig['layouts'][$b->breakpoint]['min_viewport'] ?? 0) <=>
388✔
130
            ($this->gridConfig['layouts'][$a->breakpoint]['min_viewport'] ?? 0)
185✔
131
        );
388✔
132

133
        return $assignments;
388✔
134
    }
135

136
    /**
137
     * @param array{min_viewport: int, max_container: int|null} $layout
138
     *
139
     * @return array{0: float, 1: string, 2: string}
140
     */
141
    private function calculateDimensions(BreakpointAssignment $assignment, array $layout): array
142
    {
143
        if (null !== $assignment->widthPercent) {
388✔
144
            $percentValue = (float) rtrim($assignment->widthPercent, '%');
44✔
145
            $cssValue = $assignment->widthPercent;
44✔
146

147
            $maxContainer = $layout['max_container'];
44✔
148
            if ($maxContainer) {
44✔
149
                $pixelWidth = ($percentValue / 100) * $maxContainer;
44✔
150
            } else {
151
                $pixelWidth = ($percentValue / 100) * 1920;
29✔
152
            }
153

154
            return [$pixelWidth, round($pixelWidth).'px', $cssValue];
44✔
155
        }
156

157
        if (null !== $assignment->width) {
344✔
158
            $pixelWidth = (float) $assignment->width;
15✔
159
            $sizeValue = $assignment->width.'px';
15✔
160

161
            return [$pixelWidth, $sizeValue, $sizeValue];
15✔
162
        }
163

164
        $totalCols = $this->gridConfig['columns'];
329✔
165
        $maxContainer = $layout['max_container'];
329✔
166

167
        if ($maxContainer) {
329✔
168
            // Fixed container (e.g. 1320px) -> width in px
169
            $pixelWidth = ($assignment->columns / $totalCols) * $maxContainer;
314✔
170
            $sizeValue = round($pixelWidth).'px';
314✔
171
        } else {
172
            // Fluid (null) -> width in vw
173
            $vwWidth = ($assignment->columns / $totalCols) * 100;
171✔
174
            $sizeValue = round($vwWidth).'vw';
171✔
175
            // For URL calculation we estimate px width from some reasonable max-width (e.g. 1920)
176
            $pixelWidth = ($vwWidth / 100) * 1920;
171✔
177
        }
178

179
        return [$pixelWidth, $sizeValue, $sizeValue];
329✔
180
    }
181

182
    private function generateUrl(
183
        string $path,
184
        BreakpointAssignment $assignment,
185
        int $basePixelWidth,
186
        int $originalWidth,
187
        ?string $pointInterest = null,
188
        array $context = [],
189
    ): string {
190
        $ratio = $this->resolveRatio($assignment);
388✔
191

192
        if ($this->modifierProvider && $assignment->modifiers) {
388✔
193
            $context = $this->modifierProvider->applyModifiers($assignment->modifiers, $context);
43✔
194
        }
195

196
        $requestedWidth = $basePixelWidth;
388✔
197
        if ($originalWidth > 0 && $basePixelWidth > $originalWidth) {
388✔
198
            $basePixelWidth = $originalWidth;
141✔
199
        }
200

201
        $targetH = $ratio ? (int) round($basePixelWidth / $ratio) : null;
388✔
202
        $url = $this->urlGenerator->generateUrl($path, $basePixelWidth, $targetH, $pointInterest, $context);
388✔
203

204
        return $url;
388✔
205
    }
206

207
    private function resolveRatio(BreakpointAssignment $assignment): ?float
208
    {
209
        $ratioString = $assignment->ratio ?? null;
388✔
210
        if (!$ratioString) {
388✔
211
            return null;
169✔
212
        }
213

214
        // If it's a key in ratioConfig, use that
215
        if (isset($this->ratioConfig[$ratioString])) {
233✔
216
            $ratioString = $this->ratioConfig[$ratioString];
161✔
217
        }
218

219
        if (is_numeric($ratioString)) {
233✔
220
            return (float) $ratioString;
148✔
221
        }
222

223
        // Otherwise try to parse format "16/9" or "3-4"
224
        if (preg_match('/^(\d+)[\/-](\d+)$/', $ratioString, $matches)) {
143✔
225
            return (float) $matches[1] / (float) $matches[2];
128✔
226
        }
227

228
        // Or format "400x500"
229
        if (preg_match('/^(\d+)x(\d+)$/', $ratioString, $matches)) {
58✔
230
            return (float) $matches[1] / (float) $matches[2];
58✔
231
        }
232

233
        throw new \InvalidArgumentException(sprintf('Invalid ratio format or missing ratio configuration for: "%s"', $ratioString));
×
234
    }
235
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc