Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
89.47% |
51 / 57 |
|
88.89% |
8 / 9 |
CRAP | |
0.00% |
0 / 1 |
| FloatContext | |
89.47% |
51 / 57 |
|
88.89% |
8 / 9 |
31.05 | |
0.00% |
0 / 1 |
| addLeft | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| addRight | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| fitSlot | |
68.42% |
13 / 19 |
|
0.00% |
0 / 1 |
4.50 | |||
| clearTo | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
| placeLeft | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| placeRight | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| leftEdgeAt | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
6 | |||
| rightEdgeAt | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
6 | |||
| nextFloatBottomBelow | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace 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 | */ |
| 17 | final 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 | } |