Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.71% covered (warning)
68.71%
101 / 147
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
FloatContext
68.71% covered (warning)
68.71%
101 / 147
64.29% covered (warning)
64.29%
9 / 14
184.62
0.00% covered (danger)
0.00%
0 / 1
 addLeft
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addRight
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fitSlot
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
4.50
 clearTo
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 placeLeft
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 placeRight
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 leftEdgeAt
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 itemRightEdgeAt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 itemLeftEdgeAt
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 shapeRightEdgeLocal
84.38% covered (warning)
84.38%
27 / 32
0.00% covered (danger)
0.00%
0 / 1
9.31
 shapeLeftEdgeLocal
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
90
 polygonEdgesAt
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
11.14
 rightEdgeAt
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 nextFloatBottomBelow
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf\Layout;
6
7/**
8 * Tracks the active floats inside a single block formatting context per
9 * CSS 2.1 §9.5. Floats are added as they're laid out; subsequent inline
10 * content queries `availableSlotAt` to learn how much horizontal space
11 * is free at a given line Y, and `clearTo` is used by block layout to
12 * skip a child past floats on the indicated side.
13 *
14 * Coordinates are layout-space (top-down, content-box of the containing
15 * block as origin) — same as the rest of {@see BlockLayout}.
16 */
17final class FloatContext
18{
19    /** @var list<FloatItem> */
20    private array $items = [];
21
22    /** @param array<string, mixed>|null $shape */
23    public function addLeft(float $left, float $top, float $width, float $height, ?array $shape = null): void
24    {
25        $this->items[] = new FloatItem('left', $left, $top, $width, $height, $shape);
26    }
27
28    /** @param array<string, mixed>|null $shape */
29    public function addRight(float $left, float $top, float $width, float $height, ?array $shape = null): void
30    {
31        $this->items[] = new FloatItem('right', $left, $top, $width, $height, $shape);
32    }
33
34    /**
35     * Find the next free horizontal slot wide enough for `$desiredWidth`
36     * starting at `$y`, considering all active floats. Returns the
37     * (lineLeft, lineRight, lineY) tuple where lineY may have been
38     * shifted downward to skip past floats that would have made the
39     * available width insufficient.
40     *
41     * `$containingLeft` and `$containingRight` are the parent block's
42     * content-edge X bounds.
43     *
44     * @return array{left: float, right: float, y: float}
45     */
46    public function fitSlot(
47        float $y,
48        float $containingLeft,
49        float $containingRight,
50        float $desiredWidth,
51    ): array {
52        $currentY = $y;
53        // Iterate over candidate Y positions: every existing float's top
54        // and bottom edge is a candidate where availability might change.
55        // Bounded loop — at most O(items) iterations.
56        $checked = 0;
57        $limit = max(1, count($this->items) * 2 + 2);
58        while ($checked < $limit) {
59            $left = $this->leftEdgeAt($currentY, $containingLeft);
60            $right = $this->rightEdgeAt($currentY, $containingRight);
61            $available = $right - $left;
62            if ($available + 0.001 >= $desiredWidth) {
63                return ['left' => $left, 'right' => $right, 'y' => $currentY];
64            }
65            $nextY = $this->nextFloatBottomBelow($currentY);
66            if ($nextY === null) {
67                // No more floats to skip past — return whatever slot is
68                // available even if narrower than desired (the caller
69                // accepts narrower lines; word wrap deals with overflow).
70                return ['left' => $left, 'right' => $right, 'y' => $currentY];
71            }
72            $currentY = $nextY;
73            $checked++;
74        }
75        return [
76            'left' => $this->leftEdgeAt($currentY, $containingLeft),
77            'right' => $this->rightEdgeAt($currentY, $containingRight),
78            'y' => $currentY,
79        ];
80    }
81
82    /**
83     * Y position past every float on `$side` (or both sides for
84     * `clear: both`) that intersects the half-open range `[$minY, ∞)`.
85     * Used by `clear: left | right | both` to advance the cursor past
86     * the appropriate floats.
87     */
88    public function clearTo(string $side, float $minY): float
89    {
90        $y = $minY;
91        foreach ($this->items as $item) {
92            if ($side !== 'both' && $item->side !== $side) {
93                continue;
94            }
95            $bottom = $item->top + $item->height;
96            if ($bottom > $y) {
97                $y = $bottom;
98            }
99        }
100        return $y;
101    }
102
103    /**
104     * Pick the X coordinate where a new left float of `$width × $height`
105     * should be placed at flow position `$y` inside container bounds
106     * [containingLeft, containingRight]. Returns the (left-edge X, Y)
107     * — the float may need to drop below existing floats to find a
108     * wide-enough slot.
109     *
110     * @return array{x: float, y: float}
111     */
112    public function placeLeft(
113        float $y,
114        float $containingLeft,
115        float $containingRight,
116        float $width,
117    ): array {
118        $slot = $this->fitSlot($y, $containingLeft, $containingRight, $width);
119        return ['x' => $slot['left'], 'y' => $slot['y']];
120    }
121
122    /**
123     * Symmetric to `placeLeft` for right floats. Returns the float's
124     * left-edge X (= right edge − width).
125     *
126     * @return array{x: float, y: float}
127     */
128    public function placeRight(
129        float $y,
130        float $containingLeft,
131        float $containingRight,
132        float $width,
133    ): array {
134        $slot = $this->fitSlot($y, $containingLeft, $containingRight, $width);
135        return ['x' => $slot['right'] - $width, 'y' => $slot['y']];
136    }
137
138    /**
139     * Sum of left-float right edges at `$y` (clamped to ≥ `$containingLeft`)
140     * — i.e. the X coordinate where a line of inline content should start.
141     */
142    public function leftEdgeAt(float $y, float $containingLeft): float
143    {
144        $edge = $containingLeft;
145        foreach ($this->items as $item) {
146            if ($item->side !== 'left') {
147                continue;
148            }
149            if ($y + 0.001 >= $item->top && $y + 0.001 < $item->top + $item->height) {
150                $rightEdge = $this->itemRightEdgeAt($item, $y);
151                if ($rightEdge > $edge) {
152                    $edge = $rightEdge;
153                }
154            }
155        }
156        return $edge;
157    }
158
159    /**
160     * Right edge of a left-float's exclusion region at `$y`. When the
161     * item carries a `shape` (CSS Shapes 1 §3) the edge tracks the
162     * shape's contour; otherwise it's the bounding rect's right edge.
163     */
164    private function itemRightEdgeAt(FloatItem $item, float $y): float
165    {
166        if ($item->shape === null) {
167            return $item->left + $item->width;
168        }
169        return $item->left + $this->shapeRightEdgeLocal($item, $y);
170    }
171
172    /**
173     * Left edge of a right-float's exclusion region at `$y`.
174     */
175    private function itemLeftEdgeAt(FloatItem $item, float $y): float
176    {
177        if ($item->shape === null) {
178            return $item->left;
179        }
180        return $item->left + $this->shapeLeftEdgeLocal($item, $y);
181    }
182
183    /**
184     * Right edge of the shape (in item-local coords) at `$y`. For a
185     * left-float, this is the X past which inline content can flow.
186     * Returns `width` (full bounding-rect edge) when the shape doesn't
187     * intersect this Y, so the float still pushes text down past its
188     * bottom edge as in the rect case.
189     */
190    private function shapeRightEdgeLocal(FloatItem $item, float $y): float
191    {
192        $yLocal = $y - $item->top;
193        $shape = $item->shape;
194        if ($shape === null) {
195            return $item->width;
196        }
197        $kind = $shape['kind'] ?? null;
198        if ($kind === 'circle') {
199            $cx = (float) ($shape['cx'] ?? 0.0);
200            $cy = (float) ($shape['cy'] ?? 0.0);
201            $r = (float) ($shape['r'] ?? 0.0);
202            $dy = $yLocal - $cy;
203            if (abs($dy) > $r) {
204                return 0.0;
205            }
206            $dx = sqrt(max(0.0, $r * $r - $dy * $dy));
207            return $cx + $dx;
208        }
209        if ($kind === 'ellipse') {
210            $cx = (float) ($shape['cx'] ?? 0.0);
211            $cy = (float) ($shape['cy'] ?? 0.0);
212            $rx = (float) ($shape['rx'] ?? 0.0);
213            $ry = (float) ($shape['ry'] ?? 0.0);
214            if ($rx <= 0.0 || $ry <= 0.0) {
215                return $item->width;
216            }
217            $dy = $yLocal - $cy;
218            if (abs($dy) > $ry) {
219                return 0.0;
220            }
221            // x = rx · sqrt(1 - (dy/ry)²)
222            $factor = sqrt(max(0.0, 1.0 - ($dy * $dy) / ($ry * $ry)));
223            $dx = $rx * $factor;
224            return $cx + $dx;
225        }
226        if ($kind === 'polygon') {
227            /** @var list<array{float, float}> $vertices */
228            $vertices = $shape['vertices'] ?? [];
229            $maxX = $this->polygonEdgesAt($vertices, $yLocal, max: true);
230            return $maxX ?? 0.0;
231        }
232        return $item->width;
233    }
234
235    /**
236     * Left edge of the shape (in item-local coords) at `$y`, used by
237     * right-floats. Returns 0 when the shape doesn't intersect this Y.
238     */
239    private function shapeLeftEdgeLocal(FloatItem $item, float $y): float
240    {
241        $yLocal = $y - $item->top;
242        $shape = $item->shape;
243        if ($shape === null) {
244            return 0.0;
245        }
246        $kind = $shape['kind'] ?? null;
247        if ($kind === 'circle') {
248            $cx = (float) ($shape['cx'] ?? 0.0);
249            $cy = (float) ($shape['cy'] ?? 0.0);
250            $r = (float) ($shape['r'] ?? 0.0);
251            $dy = $yLocal - $cy;
252            if (abs($dy) > $r) {
253                return $item->width;
254            }
255            $dx = sqrt(max(0.0, $r * $r - $dy * $dy));
256            return $cx - $dx;
257        }
258        if ($kind === 'ellipse') {
259            $cx = (float) ($shape['cx'] ?? 0.0);
260            $cy = (float) ($shape['cy'] ?? 0.0);
261            $rx = (float) ($shape['rx'] ?? 0.0);
262            $ry = (float) ($shape['ry'] ?? 0.0);
263            if ($rx <= 0.0 || $ry <= 0.0) {
264                return 0.0;
265            }
266            $dy = $yLocal - $cy;
267            if (abs($dy) > $ry) {
268                return $item->width;
269            }
270            $factor = sqrt(max(0.0, 1.0 - ($dy * $dy) / ($ry * $ry)));
271            $dx = $rx * $factor;
272            return $cx - $dx;
273        }
274        if ($kind === 'polygon') {
275            /** @var list<array{float, float}> $vertices */
276            $vertices = $shape['vertices'] ?? [];
277            $minX = $this->polygonEdgesAt($vertices, $yLocal, max: false);
278            return $minX ?? $item->width;
279        }
280        return 0.0;
281    }
282
283    /**
284     * Scan a polygon's edges for those that cross the horizontal
285     * line `y = $yLocal`. Return the max or min x crossing — for a
286     * left float, the right-most crossing pushes inline text away;
287     * for a right float, the left-most crossing pulls it back.
288     *
289     * Returns `null` when no edge crosses (the polygon doesn't
290     * intersect this Y row at all). Callers treat `null` as "no
291     * exclusion at this Y" — text flows freely.
292     *
293     * @param list<array{float, float}> $vertices
294     */
295    private function polygonEdgesAt(array $vertices, float $yLocal, bool $max): ?float
296    {
297        $n = count($vertices);
298        if ($n < 2) {
299            return null;
300        }
301        $best = null;
302        for ($i = 0; $i < $n; $i++) {
303            [$x1, $y1] = $vertices[$i];
304            [$x2, $y2] = $vertices[($i + 1) % $n];
305            // Skip horizontal edges — they don't cross a horizontal
306            // sample line (infinitely many crossings).
307            if (abs($y2 - $y1) < 0.0001) {
308                continue;
309            }
310            $minY = min($y1, $y2);
311            $maxY = max($y1, $y2);
312            if ($yLocal + 0.0001 < $minY || $yLocal - 0.0001 > $maxY) {
313                continue;
314            }
315            // Linear interpolation: x(y) = x1 + (y - y1) · (x2 - x1) / (y2 - y1)
316            $x = $x1 + ($yLocal - $y1) * ($x2 - $x1) / ($y2 - $y1);
317            if ($best === null
318                || ($max && $x > $best)
319                || (!$max && $x < $best)
320            ) {
321                $best = $x;
322            }
323        }
324        return $best;
325    }
326
327    /**
328     * Minimum of right-float left edges at `$y` (clamped to ≤
329     * `$containingRight`) — where a line of inline content must end.
330     */
331    public function rightEdgeAt(float $y, float $containingRight): float
332    {
333        $edge = $containingRight;
334        foreach ($this->items as $item) {
335            if ($item->side !== 'right') {
336                continue;
337            }
338            if ($y + 0.001 >= $item->top && $y + 0.001 < $item->top + $item->height) {
339                $leftEdge = $this->itemLeftEdgeAt($item, $y);
340                if ($leftEdge < $edge) {
341                    $edge = $leftEdge;
342                }
343            }
344        }
345        return $edge;
346    }
347
348    /**
349     * Smallest float-bottom that is strictly greater than `$y`. Returns
350     * null when no active float ends below `$y`.
351     */
352    private function nextFloatBottomBelow(float $y): ?float
353    {
354        $next = null;
355        foreach ($this->items as $item) {
356            $bottom = $item->top + $item->height;
357            if ($bottom > $y + 0.001) {
358                if ($next === null || $bottom < $next) {
359                    $next = $bottom;
360                }
361            }
362        }
363        return $next;
364    }
365}