Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.47% covered (warning)
89.47%
51 / 57
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
FloatContext
89.47% covered (warning)
89.47%
51 / 57
88.89% covered (warning)
88.89%
8 / 9
31.05
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
 rightEdgeAt
100.00% covered (success)
100.00%
8 / 8
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    public function addLeft(float $left, float $top, float $width, float $height): void
23    {
24        $this->items[] = new FloatItem('left', $left, $top, $width, $height);
25    }
26
27    public function addRight(float $left, float $top, float $width, float $height): void
28    {
29        $this->items[] = new FloatItem('right', $left, $top, $width, $height);
30    }
31
32    /**
33     * Find the next free horizontal slot wide enough for `$desiredWidth`
34     * starting at `$y`, considering all active floats. Returns the
35     * (lineLeft, lineRight, lineY) tuple where lineY may have been
36     * shifted downward to skip past floats that would have made the
37     * available width insufficient.
38     *
39     * `$containingLeft` and `$containingRight` are the parent block's
40     * content-edge X bounds.
41     *
42     * @return array{left: float, right: float, y: float}
43     */
44    public function fitSlot(
45        float $y,
46        float $containingLeft,
47        float $containingRight,
48        float $desiredWidth,
49    ): array {
50        $currentY = $y;
51        // Iterate over candidate Y positions: every existing float's top
52        // and bottom edge is a candidate where availability might change.
53        // Bounded loop — at most O(items) iterations.
54        $checked = 0;
55        $limit = max(1, count($this->items) * 2 + 2);
56        while ($checked < $limit) {
57            $left = $this->leftEdgeAt($currentY, $containingLeft);
58            $right = $this->rightEdgeAt($currentY, $containingRight);
59            $available = $right - $left;
60            if ($available + 0.001 >= $desiredWidth) {
61                return ['left' => $left, 'right' => $right, 'y' => $currentY];
62            }
63            $nextY = $this->nextFloatBottomBelow($currentY);
64            if ($nextY === null) {
65                // No more floats to skip past — return whatever slot is
66                // available even if narrower than desired (the caller
67                // accepts narrower lines; word wrap deals with overflow).
68                return ['left' => $left, 'right' => $right, 'y' => $currentY];
69            }
70            $currentY = $nextY;
71            $checked++;
72        }
73        return [
74            'left' => $this->leftEdgeAt($currentY, $containingLeft),
75            'right' => $this->rightEdgeAt($currentY, $containingRight),
76            'y' => $currentY,
77        ];
78    }
79
80    /**
81     * Y position past every float on `$side` (or both sides for
82     * `clear: both`) that intersects the half-open range `[$minY, ∞)`.
83     * Used by `clear: left | right | both` to advance the cursor past
84     * the appropriate floats.
85     */
86    public function clearTo(string $side, float $minY): float
87    {
88        $y = $minY;
89        foreach ($this->items as $item) {
90            if ($side !== 'both' && $item->side !== $side) {
91                continue;
92            }
93            $bottom = $item->top + $item->height;
94            if ($bottom > $y) {
95                $y = $bottom;
96            }
97        }
98        return $y;
99    }
100
101    /**
102     * Pick the X coordinate where a new left float of `$width × $height`
103     * should be placed at flow position `$y` inside container bounds
104     * [containingLeft, containingRight]. Returns the (left-edge X, Y)
105     * — the float may need to drop below existing floats to find a
106     * wide-enough slot.
107     *
108     * @return array{x: float, y: float}
109     */
110    public function placeLeft(
111        float $y,
112        float $containingLeft,
113        float $containingRight,
114        float $width,
115    ): array {
116        $slot = $this->fitSlot($y, $containingLeft, $containingRight, $width);
117        return ['x' => $slot['left'], 'y' => $slot['y']];
118    }
119
120    /**
121     * Symmetric to `placeLeft` for right floats. Returns the float's
122     * left-edge X (= right edge − width).
123     *
124     * @return array{x: float, y: float}
125     */
126    public function placeRight(
127        float $y,
128        float $containingLeft,
129        float $containingRight,
130        float $width,
131    ): array {
132        $slot = $this->fitSlot($y, $containingLeft, $containingRight, $width);
133        return ['x' => $slot['right'] - $width, 'y' => $slot['y']];
134    }
135
136    /**
137     * Sum of left-float right edges at `$y` (clamped to ≥ `$containingLeft`)
138     * — i.e. the X coordinate where a line of inline content should start.
139     */
140    public function leftEdgeAt(float $y, float $containingLeft): float
141    {
142        $edge = $containingLeft;
143        foreach ($this->items as $item) {
144            if ($item->side !== 'left') {
145                continue;
146            }
147            if ($y + 0.001 >= $item->top && $y + 0.001 < $item->top + $item->height) {
148                $rightEdge = $item->left + $item->width;
149                if ($rightEdge > $edge) {
150                    $edge = $rightEdge;
151                }
152            }
153        }
154        return $edge;
155    }
156
157    /**
158     * Minimum of right-float left edges at `$y` (clamped to ≤
159     * `$containingRight`) — where a line of inline content must end.
160     */
161    public function rightEdgeAt(float $y, float $containingRight): float
162    {
163        $edge = $containingRight;
164        foreach ($this->items as $item) {
165            if ($item->side !== 'right') {
166                continue;
167            }
168            if ($y + 0.001 >= $item->top && $y + 0.001 < $item->top + $item->height) {
169                if ($item->left < $edge) {
170                    $edge = $item->left;
171                }
172            }
173        }
174        return $edge;
175    }
176
177    /**
178     * Smallest float-bottom that is strictly greater than `$y`. Returns
179     * null when no active float ends below `$y`.
180     */
181    private function nextFloatBottomBelow(float $y): ?float
182    {
183        $next = null;
184        foreach ($this->items as $item) {
185            $bottom = $item->top + $item->height;
186            if ($bottom > $y + 0.001) {
187                if ($next === null || $bottom < $next) {
188                    $next = $bottom;
189                }
190            }
191        }
192        return $next;
193    }
194}