Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
84.52% |
202 / 239 |
|
72.00% |
18 / 25 |
CRAP | |
0.00% |
0 / 1 |
| FloatContext | |
84.52% |
202 / 239 |
|
72.00% |
18 / 25 |
158.54 | |
0.00% |
0 / 1 |
| snapshot | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| restore | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| addLeft | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
| addRight | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
| marginLeftEdgeAt | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
6 | |||
| marginRightEdgeAt | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
6 | |||
| 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% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| leftEdgeInBand | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
6 | |||
| hasEmptyFloatArea | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| itemRightEdgeInBand | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
| itemLeftEdgeInBand | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
| bandLocalRange | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| shapeRightEdgeInBand | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
5.03 | |||
| shapeLeftEdgeInBand | |
55.56% |
5 / 9 |
|
0.00% |
0 / 1 |
5.40 | |||
| shapeRightEdgeLocalAt | |
74.07% |
20 / 27 |
|
0.00% |
0 / 1 |
10.41 | |||
| shapeLeftEdgeLocalAt | |
37.04% |
10 / 27 |
|
0.00% |
0 / 1 |
29.22 | |||
| polygonEdgesInBand | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
11 | |||
| polygonEdgesAt | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
11.02 | |||
| rightEdgeAt | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| rightEdgeInBand | |
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 | /** |
| 20 | * Tolerance (CSS px) for band / extent comparisons, and the amount a |
| 21 | * non-degenerate line band is shrunk by before the contour is |
| 22 | * evaluated — see {@see bandLocalRange}. |
| 23 | */ |
| 24 | private const EPS = 0.001; |
| 25 | |
| 26 | /** @var list<FloatItem> */ |
| 27 | private array $items = []; |
| 28 | |
| 29 | /** |
| 30 | * Capture the current exclusion set so a speculative or repeated |
| 31 | * layout pass over the same subtree can be rolled back. |
| 32 | * |
| 33 | * A box that an ancestor stretches is laid out twice (once to measure, |
| 34 | * once against the stretched block size). Floats inside it would |
| 35 | * otherwise be registered by BOTH passes, and the second pass would |
| 36 | * then place them around the first pass' phantom copies of themselves. |
| 37 | * |
| 38 | * @return list<FloatItem> |
| 39 | */ |
| 40 | public function snapshot(): array |
| 41 | { |
| 42 | return $this->items; |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Roll the exclusion set back to a {@see snapshot}. |
| 47 | * |
| 48 | * @param list<FloatItem> $items |
| 49 | */ |
| 50 | public function restore(array $items): void |
| 51 | { |
| 52 | $this->items = $items; |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * @param array<string, mixed>|null $shape |
| 57 | * @param array{x: float, y: float, width: float, height: float}|null $marginBox |
| 58 | * CSS 2.1 §9.5.1 margin box, when it differs from the |
| 59 | * exclusion rect (i.e. `shape-outside` contracted it). |
| 60 | */ |
| 61 | public function addLeft( |
| 62 | float $left, |
| 63 | float $top, |
| 64 | float $width, |
| 65 | float $height, |
| 66 | ?array $shape = null, |
| 67 | ?array $marginBox = null, |
| 68 | ): void { |
| 69 | $this->items[] = new FloatItem( |
| 70 | 'left', |
| 71 | $left, |
| 72 | $top, |
| 73 | $width, |
| 74 | $height, |
| 75 | $shape, |
| 76 | $marginBox['x'] ?? null, |
| 77 | $marginBox['y'] ?? null, |
| 78 | $marginBox['width'] ?? null, |
| 79 | $marginBox['height'] ?? null, |
| 80 | ); |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * @param array<string, mixed>|null $shape |
| 85 | * @param array{x: float, y: float, width: float, height: float}|null $marginBox |
| 86 | */ |
| 87 | public function addRight( |
| 88 | float $left, |
| 89 | float $top, |
| 90 | float $width, |
| 91 | float $height, |
| 92 | ?array $shape = null, |
| 93 | ?array $marginBox = null, |
| 94 | ): void { |
| 95 | $this->items[] = new FloatItem( |
| 96 | 'right', |
| 97 | $left, |
| 98 | $top, |
| 99 | $width, |
| 100 | $height, |
| 101 | $shape, |
| 102 | $marginBox['x'] ?? null, |
| 103 | $marginBox['y'] ?? null, |
| 104 | $marginBox['width'] ?? null, |
| 105 | $marginBox['height'] ?? null, |
| 106 | ); |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Right edge of the left floats' MARGIN boxes at `$y` — the |
| 111 | * coordinate a new float must start at. Separate from |
| 112 | * {@see leftEdgeAt}, which reports the exclusion contour that |
| 113 | * LINE boxes flow around. |
| 114 | */ |
| 115 | private function marginLeftEdgeAt(float $y, float $containingLeft): float |
| 116 | { |
| 117 | $edge = $containingLeft; |
| 118 | foreach ($this->items as $item) { |
| 119 | if ($item->side !== 'left') { |
| 120 | continue; |
| 121 | } |
| 122 | $top = $item->marginBoxTop(); |
| 123 | if ($y + 0.001 >= $top && $y + 0.001 < $top + $item->marginBoxHeight()) { |
| 124 | $rightEdge = $item->marginBoxLeft() + $item->marginBoxWidth(); |
| 125 | if ($rightEdge > $edge) { |
| 126 | $edge = $rightEdge; |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | return $edge; |
| 131 | } |
| 132 | |
| 133 | /** Symmetric to {@see marginLeftEdgeAt} for right floats. */ |
| 134 | private function marginRightEdgeAt(float $y, float $containingRight): float |
| 135 | { |
| 136 | $edge = $containingRight; |
| 137 | foreach ($this->items as $item) { |
| 138 | if ($item->side !== 'right') { |
| 139 | continue; |
| 140 | } |
| 141 | $top = $item->marginBoxTop(); |
| 142 | if ($y + 0.001 >= $top && $y + 0.001 < $top + $item->marginBoxHeight()) { |
| 143 | $leftEdge = $item->marginBoxLeft(); |
| 144 | if ($leftEdge < $edge) { |
| 145 | $edge = $leftEdge; |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | return $edge; |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Find the next free horizontal slot wide enough for `$desiredWidth` |
| 154 | * starting at `$y`, considering all active floats. Returns the |
| 155 | * (lineLeft, lineRight, lineY) tuple where lineY may have been |
| 156 | * shifted downward to skip past floats that would have made the |
| 157 | * available width insufficient. |
| 158 | * |
| 159 | * `$containingLeft` and `$containingRight` are the parent block's |
| 160 | * content-edge X bounds. |
| 161 | * |
| 162 | * @return array{left: float, right: float, y: float} |
| 163 | */ |
| 164 | public function fitSlot( |
| 165 | float $y, |
| 166 | float $containingLeft, |
| 167 | float $containingRight, |
| 168 | float $desiredWidth, |
| 169 | ): array { |
| 170 | $currentY = $y; |
| 171 | // CSS Shapes 1 — `shape-outside` defines the float area that |
| 172 | // excludes LINE BOXES; it does not move the float itself or change |
| 173 | // how floats stack against each other, which CSS 2.1 §9.5.1 settles |
| 174 | // with margin boxes. So this search is deliberately shape-blind: |
| 175 | // letting a concave shape report free space here squeezes a second |
| 176 | // float in beside one it should have dropped below. |
| 177 | // |
| 178 | // Iterate over candidate Y positions: every existing float's top |
| 179 | // and bottom edge is a candidate where availability might change. |
| 180 | // Bounded loop — at most O(items) iterations. |
| 181 | $checked = 0; |
| 182 | $limit = max(1, count($this->items) * 2 + 2); |
| 183 | while ($checked < $limit) { |
| 184 | $left = $this->marginLeftEdgeAt($currentY, $containingLeft); |
| 185 | $right = $this->marginRightEdgeAt($currentY, $containingRight); |
| 186 | $available = $right - $left; |
| 187 | if ($available + 0.001 >= $desiredWidth) { |
| 188 | return ['left' => $left, 'right' => $right, 'y' => $currentY]; |
| 189 | } |
| 190 | $nextY = $this->nextFloatBottomBelow($currentY); |
| 191 | if ($nextY === null) { |
| 192 | // No more floats to skip past — return whatever slot is |
| 193 | // available even if narrower than desired (the caller |
| 194 | // accepts narrower lines; word wrap deals with overflow). |
| 195 | return ['left' => $left, 'right' => $right, 'y' => $currentY]; |
| 196 | } |
| 197 | $currentY = $nextY; |
| 198 | $checked++; |
| 199 | } |
| 200 | return [ |
| 201 | 'left' => $this->marginLeftEdgeAt($currentY, $containingLeft), |
| 202 | 'right' => $this->marginRightEdgeAt($currentY, $containingRight), |
| 203 | 'y' => $currentY, |
| 204 | ]; |
| 205 | } |
| 206 | |
| 207 | /** |
| 208 | * Y position past every float on `$side` (or both sides for |
| 209 | * `clear: both`) that intersects the half-open range `[$minY, ∞)`. |
| 210 | * Used by `clear: left | right | both` to advance the cursor past |
| 211 | * the appropriate floats. |
| 212 | */ |
| 213 | public function clearTo(string $side, float $minY): float |
| 214 | { |
| 215 | $y = $minY; |
| 216 | foreach ($this->items as $item) { |
| 217 | if ($side !== 'both' && $item->side !== $side) { |
| 218 | continue; |
| 219 | } |
| 220 | // §9.5.2 — clearance is past the float's MARGIN box. |
| 221 | $bottom = $item->marginBoxTop() + $item->marginBoxHeight(); |
| 222 | if ($bottom > $y) { |
| 223 | $y = $bottom; |
| 224 | } |
| 225 | } |
| 226 | return $y; |
| 227 | } |
| 228 | |
| 229 | /** |
| 230 | * Pick the X coordinate where a new left float of `$width × $height` |
| 231 | * should be placed at flow position `$y` inside container bounds |
| 232 | * [containingLeft, containingRight]. Returns the (left-edge X, Y) |
| 233 | * — the float may need to drop below existing floats to find a |
| 234 | * wide-enough slot. |
| 235 | * |
| 236 | * @return array{x: float, y: float} |
| 237 | */ |
| 238 | public function placeLeft( |
| 239 | float $y, |
| 240 | float $containingLeft, |
| 241 | float $containingRight, |
| 242 | float $width, |
| 243 | ): array { |
| 244 | $slot = $this->fitSlot($y, $containingLeft, $containingRight, $width); |
| 245 | return ['x' => $slot['left'], 'y' => $slot['y']]; |
| 246 | } |
| 247 | |
| 248 | /** |
| 249 | * Symmetric to `placeLeft` for right floats. Returns the float's |
| 250 | * left-edge X (= right edge − width). |
| 251 | * |
| 252 | * @return array{x: float, y: float} |
| 253 | */ |
| 254 | public function placeRight( |
| 255 | float $y, |
| 256 | float $containingLeft, |
| 257 | float $containingRight, |
| 258 | float $width, |
| 259 | ): array { |
| 260 | $slot = $this->fitSlot($y, $containingLeft, $containingRight, $width); |
| 261 | return ['x' => $slot['right'] - $width, 'y' => $slot['y']]; |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * Sum of left-float right edges at `$y` (clamped to ≥ `$containingLeft`) |
| 266 | * — i.e. the X coordinate where a line of inline content should start. |
| 267 | */ |
| 268 | public function leftEdgeAt(float $y, float $containingLeft, bool $ignoreShape = false): float |
| 269 | { |
| 270 | return $this->leftEdgeInBand($y, $y, $containingLeft, $ignoreShape); |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * Right edge of the left floats' exclusion contour over the block-axis |
| 275 | * band `[$bandTop, $bandBottom]` — the X where a LINE BOX of that |
| 276 | * vertical extent must start. |
| 277 | * |
| 278 | * CSS Shapes 1 §1.2 defines the float area per LINE BOX, not per scan |
| 279 | * line: the line is shortened by the float's MAXIMUM intrusion anywhere |
| 280 | * over the line's own block extent. Evaluating the contour at the line's |
| 281 | * top edge alone — or at a handful of sample points across the band — |
| 282 | * under- or over-states a curved or sloped contour by a fraction of a |
| 283 | * line, which is precisely the residual the `shape-outside` reftests |
| 284 | * measure. This takes the true extremum instead: analytic for `inset()` |
| 285 | * and `polygon()`, and for `circle()` / `ellipse()` the value at |
| 286 | * whichever of the band's endpoints (or the vertical centre, when the |
| 287 | * band straddles it) intrudes furthest. |
| 288 | */ |
| 289 | public function leftEdgeInBand( |
| 290 | float $bandTop, |
| 291 | float $bandBottom, |
| 292 | float $containingLeft, |
| 293 | bool $ignoreShape = false, |
| 294 | ): float { |
| 295 | $edge = $containingLeft; |
| 296 | foreach ($this->items as $item) { |
| 297 | if ($item->side !== 'left' || $this->hasEmptyFloatArea($item, $ignoreShape)) { |
| 298 | continue; |
| 299 | } |
| 300 | $rightEdge = $this->itemRightEdgeInBand($item, $bandTop, $bandBottom, $ignoreShape); |
| 301 | if ($rightEdge !== null && $rightEdge > $edge) { |
| 302 | $edge = $rightEdge; |
| 303 | } |
| 304 | } |
| 305 | return $edge; |
| 306 | } |
| 307 | |
| 308 | /** |
| 309 | * CSS Shapes 1 §3 — a degenerate basic shape (`circle(0)`, |
| 310 | * `ellipse(0% 0%)`, a sub-triangular `polygon()`) is well-formed |
| 311 | * but encloses no area, so it "defines an empty float area": line |
| 312 | * boxes flow straight through the float as if it declared no |
| 313 | * exclusion at all. Such an item is skipped entirely rather than |
| 314 | * contributing its bounding rect. |
| 315 | */ |
| 316 | private function hasEmptyFloatArea(FloatItem $item, bool $ignoreShape): bool |
| 317 | { |
| 318 | return !$ignoreShape && ($item->shape['kind'] ?? null) === 'empty'; |
| 319 | } |
| 320 | |
| 321 | /** |
| 322 | * Right edge of a left-float's exclusion region over the band, or null |
| 323 | * when the band never touches the float's area. When the item carries a |
| 324 | * `shape` (CSS Shapes 1 §3) the edge tracks the shape's contour; |
| 325 | * otherwise it's the bounding rect's right edge. |
| 326 | */ |
| 327 | private function itemRightEdgeInBand( |
| 328 | FloatItem $item, |
| 329 | float $bandTop, |
| 330 | float $bandBottom, |
| 331 | bool $ignoreShape, |
| 332 | ): ?float { |
| 333 | $range = $this->bandLocalRange($item, $bandTop, $bandBottom); |
| 334 | if ($range === null) { |
| 335 | return null; |
| 336 | } |
| 337 | if ($ignoreShape || $item->shape === null) { |
| 338 | return $item->left + $item->width; |
| 339 | } |
| 340 | $local = $this->shapeRightEdgeInBand($item, $range[0], $range[1]); |
| 341 | if ($local === null) { |
| 342 | return null; |
| 343 | } |
| 344 | // CSS Shapes 1 §1.1 — the float area is CLIPPED to the float's |
| 345 | // margin box, so a shape larger than the float itself (say a |
| 346 | // `circle(150px)` on a 50x50 float) cannot push content past |
| 347 | // the float's own outer edge. |
| 348 | return min( |
| 349 | $item->left + $local, |
| 350 | $item->marginBoxLeft() + $item->marginBoxWidth(), |
| 351 | ); |
| 352 | } |
| 353 | |
| 354 | /** Left edge of a right-float's exclusion region over the band. */ |
| 355 | private function itemLeftEdgeInBand( |
| 356 | FloatItem $item, |
| 357 | float $bandTop, |
| 358 | float $bandBottom, |
| 359 | bool $ignoreShape, |
| 360 | ): ?float { |
| 361 | $range = $this->bandLocalRange($item, $bandTop, $bandBottom); |
| 362 | if ($range === null) { |
| 363 | return null; |
| 364 | } |
| 365 | if ($ignoreShape || $item->shape === null) { |
| 366 | return $item->left; |
| 367 | } |
| 368 | $local = $this->shapeLeftEdgeInBand($item, $range[0], $range[1]); |
| 369 | if ($local === null) { |
| 370 | return null; |
| 371 | } |
| 372 | // §1.1 clip, mirrored for a right float. |
| 373 | return max($item->left + $local, $item->marginBoxLeft()); |
| 374 | } |
| 375 | |
| 376 | /** |
| 377 | * Intersect the line band `[$bandTop, $bandBottom]` with `$item`'s own |
| 378 | * vertical extent and return the item-LOCAL `[lo, hi]` the contour |
| 379 | * should be evaluated over — or null when the band misses the item. |
| 380 | * |
| 381 | * The band is treated as OPEN: a float whose bottom edge lands exactly |
| 382 | * on a line's top (or whose top edge lands exactly on its bottom) does |
| 383 | * not shorten that line, and a self-intersecting `polygon()`'s |
| 384 | * single-point spike at a shared line boundary stops leaking into the |
| 385 | * line below it. A degenerate (zero-height) band keeps the old |
| 386 | * single-point semantics. |
| 387 | * |
| 388 | * @return array{float, float}|null |
| 389 | */ |
| 390 | private function bandLocalRange(FloatItem $item, float $bandTop, float $bandBottom): ?array |
| 391 | { |
| 392 | if ($bandBottom > $bandTop + 2.0 * self::EPS) { |
| 393 | // Non-degenerate band: OPEN interval against the item's own |
| 394 | // half-open extent. A line box whose BOTTOM edge lands exactly |
| 395 | // on the float's top is not overlapped by it and must keep its |
| 396 | // full measure — the tolerance below belongs to the degenerate |
| 397 | // single-point query, and carrying it over to a band let a |
| 398 | // float narrow the line immediately above itself. |
| 399 | $lo = max($bandTop + self::EPS, $item->top); |
| 400 | $hi = min($bandBottom - self::EPS, $item->top + $item->height); |
| 401 | if ($lo > $hi) { |
| 402 | return null; |
| 403 | } |
| 404 | return [$lo - $item->top, $hi - $item->top]; |
| 405 | } |
| 406 | // Degenerate band — keep the legacy single-point semantics exactly, |
| 407 | // so the `leftEdgeAt` / `rightEdgeAt` callers are unaffected. |
| 408 | if ($bandTop + self::EPS < $item->top |
| 409 | || $bandTop + self::EPS >= $item->top + $item->height |
| 410 | ) { |
| 411 | return null; |
| 412 | } |
| 413 | return [$bandTop - $item->top, $bandTop - $item->top]; |
| 414 | } |
| 415 | |
| 416 | /** |
| 417 | * Furthest-right point of the shape (item-local coords) anywhere in the |
| 418 | * local band `[$lo, $hi]`. Null means the shape encloses no area over |
| 419 | * that band, so the float does not shorten the line at all — distinct |
| 420 | * from "the contour sits at x = 0", which would still pin the line to |
| 421 | * the float's own left edge. |
| 422 | */ |
| 423 | private function shapeRightEdgeInBand(FloatItem $item, float $lo, float $hi): ?float |
| 424 | { |
| 425 | $shape = $item->shape; |
| 426 | $kind = $shape['kind'] ?? null; |
| 427 | if ($kind === 'circle' || $kind === 'ellipse') { |
| 428 | $cy = (float) ($shape['cy'] ?? 0.0); |
| 429 | $ry = (float) ($shape[$kind === 'circle' ? 'r' : 'ry'] ?? 0.0); |
| 430 | // A circle / ellipse is widest at its vertical centre and |
| 431 | // narrows monotonically away from it, so the extremum over the |
| 432 | // band is at `cy` when the band straddles it and at the nearer |
| 433 | // endpoint otherwise. |
| 434 | return $this->shapeRightEdgeLocalAt($item, max($lo, min($hi, $cy))); |
| 435 | } |
| 436 | if ($kind === 'polygon') { |
| 437 | /** @var list<array{float, float}> $vertices */ |
| 438 | $vertices = $shape['vertices'] ?? []; |
| 439 | return $this->polygonEdgesInBand($vertices, $lo, $hi, max: true); |
| 440 | } |
| 441 | return $item->width; |
| 442 | } |
| 443 | |
| 444 | /** Mirror of {@see shapeRightEdgeInBand} for a right float. */ |
| 445 | private function shapeLeftEdgeInBand(FloatItem $item, float $lo, float $hi): ?float |
| 446 | { |
| 447 | $shape = $item->shape; |
| 448 | $kind = $shape['kind'] ?? null; |
| 449 | if ($kind === 'circle' || $kind === 'ellipse') { |
| 450 | $cy = (float) ($shape['cy'] ?? 0.0); |
| 451 | return $this->shapeLeftEdgeLocalAt($item, max($lo, min($hi, $cy))); |
| 452 | } |
| 453 | if ($kind === 'polygon') { |
| 454 | /** @var list<array{float, float}> $vertices */ |
| 455 | $vertices = $shape['vertices'] ?? []; |
| 456 | return $this->polygonEdgesInBand($vertices, $lo, $hi, max: false); |
| 457 | } |
| 458 | return 0.0; |
| 459 | } |
| 460 | |
| 461 | /** |
| 462 | * Right edge of the shape (item-local coords) at a single local `$y`, |
| 463 | * or null when the shape has no area there. |
| 464 | */ |
| 465 | private function shapeRightEdgeLocalAt(FloatItem $item, float $yLocal): ?float |
| 466 | { |
| 467 | $shape = $item->shape; |
| 468 | if ($shape === null) { |
| 469 | return $item->width; |
| 470 | } |
| 471 | $kind = $shape['kind'] ?? null; |
| 472 | if ($kind === 'circle') { |
| 473 | $cx = (float) ($shape['cx'] ?? 0.0); |
| 474 | $cy = (float) ($shape['cy'] ?? 0.0); |
| 475 | $r = (float) ($shape['r'] ?? 0.0); |
| 476 | $dy = $yLocal - $cy; |
| 477 | if (abs($dy) > $r) { |
| 478 | return null; |
| 479 | } |
| 480 | return $cx + sqrt(max(0.0, $r * $r - $dy * $dy)); |
| 481 | } |
| 482 | if ($kind === 'ellipse') { |
| 483 | $cx = (float) ($shape['cx'] ?? 0.0); |
| 484 | $cy = (float) ($shape['cy'] ?? 0.0); |
| 485 | $rx = (float) ($shape['rx'] ?? 0.0); |
| 486 | $ry = (float) ($shape['ry'] ?? 0.0); |
| 487 | if ($rx <= 0.0 || $ry <= 0.0) { |
| 488 | return $item->width; |
| 489 | } |
| 490 | $dy = $yLocal - $cy; |
| 491 | if (abs($dy) > $ry) { |
| 492 | return null; |
| 493 | } |
| 494 | // x = rx · sqrt(1 - (dy/ry)²) |
| 495 | return $cx + $rx * sqrt(max(0.0, 1.0 - ($dy * $dy) / ($ry * $ry))); |
| 496 | } |
| 497 | if ($kind === 'polygon') { |
| 498 | /** @var list<array{float, float}> $vertices */ |
| 499 | $vertices = $shape['vertices'] ?? []; |
| 500 | return $this->polygonEdgesAt($vertices, $yLocal, max: true); |
| 501 | } |
| 502 | return $item->width; |
| 503 | } |
| 504 | |
| 505 | /** Mirror of {@see shapeRightEdgeLocalAt} for a right float. */ |
| 506 | private function shapeLeftEdgeLocalAt(FloatItem $item, float $yLocal): ?float |
| 507 | { |
| 508 | $shape = $item->shape; |
| 509 | if ($shape === null) { |
| 510 | return 0.0; |
| 511 | } |
| 512 | $kind = $shape['kind'] ?? null; |
| 513 | if ($kind === 'circle') { |
| 514 | $cx = (float) ($shape['cx'] ?? 0.0); |
| 515 | $cy = (float) ($shape['cy'] ?? 0.0); |
| 516 | $r = (float) ($shape['r'] ?? 0.0); |
| 517 | $dy = $yLocal - $cy; |
| 518 | if (abs($dy) > $r) { |
| 519 | return null; |
| 520 | } |
| 521 | return $cx - sqrt(max(0.0, $r * $r - $dy * $dy)); |
| 522 | } |
| 523 | if ($kind === 'ellipse') { |
| 524 | $cx = (float) ($shape['cx'] ?? 0.0); |
| 525 | $cy = (float) ($shape['cy'] ?? 0.0); |
| 526 | $rx = (float) ($shape['rx'] ?? 0.0); |
| 527 | $ry = (float) ($shape['ry'] ?? 0.0); |
| 528 | if ($rx <= 0.0 || $ry <= 0.0) { |
| 529 | return 0.0; |
| 530 | } |
| 531 | $dy = $yLocal - $cy; |
| 532 | if (abs($dy) > $ry) { |
| 533 | return null; |
| 534 | } |
| 535 | return $cx - $rx * sqrt(max(0.0, 1.0 - ($dy * $dy) / ($ry * $ry))); |
| 536 | } |
| 537 | if ($kind === 'polygon') { |
| 538 | /** @var list<array{float, float}> $vertices */ |
| 539 | $vertices = $shape['vertices'] ?? []; |
| 540 | return $this->polygonEdgesAt($vertices, $yLocal, max: false); |
| 541 | } |
| 542 | return 0.0; |
| 543 | } |
| 544 | |
| 545 | /** |
| 546 | * Extremum of a polygon's right-most (or left-most) crossing over the |
| 547 | * local band `[$lo, $hi]`. |
| 548 | * |
| 549 | * Between two consecutive vertex ordinates the set of edges crossing a |
| 550 | * scan line is fixed and each crossing is LINEAR in y, so the band's |
| 551 | * right-most crossing is a max of linear functions — convex, hence |
| 552 | * maximised at a sub-interval endpoint. Evaluating at the band's own |
| 553 | * ends plus every vertex ordinate strictly inside it is therefore exact, |
| 554 | * not a sample. (The left-most crossing is the mirror: a min of linear |
| 555 | * functions is concave and minimised at the same points.) |
| 556 | * |
| 557 | * @param list<array{float, float}> $vertices |
| 558 | */ |
| 559 | private function polygonEdgesInBand(array $vertices, float $lo, float $hi, bool $max): ?float |
| 560 | { |
| 561 | $ys = [$lo, $hi]; |
| 562 | foreach ($vertices as $vertex) { |
| 563 | $vy = $vertex[1]; |
| 564 | if ($vy > $lo && $vy < $hi) { |
| 565 | $ys[] = $vy; |
| 566 | } |
| 567 | } |
| 568 | $best = null; |
| 569 | foreach ($ys as $y) { |
| 570 | $x = $this->polygonEdgesAt($vertices, $y, $max); |
| 571 | if ($x === null) { |
| 572 | continue; |
| 573 | } |
| 574 | if ($best === null || ($max && $x > $best) || (!$max && $x < $best)) { |
| 575 | $best = $x; |
| 576 | } |
| 577 | } |
| 578 | return $best; |
| 579 | } |
| 580 | |
| 581 | /** |
| 582 | * Scan a polygon's edges for those that cross the horizontal |
| 583 | * line `y = $yLocal`. Return the max or min x crossing — for a |
| 584 | * left float, the right-most crossing pushes inline text away; |
| 585 | * for a right float, the left-most crossing pulls it back. |
| 586 | * |
| 587 | * Returns `null` when no edge crosses (the polygon doesn't |
| 588 | * intersect this Y row at all). Callers treat `null` as "no |
| 589 | * exclusion at this Y" — text flows freely. |
| 590 | * |
| 591 | * @param list<array{float, float}> $vertices |
| 592 | */ |
| 593 | private function polygonEdgesAt(array $vertices, float $yLocal, bool $max): ?float |
| 594 | { |
| 595 | $n = count($vertices); |
| 596 | if ($n < 2) { |
| 597 | return null; |
| 598 | } |
| 599 | $best = null; |
| 600 | for ($i = 0; $i < $n; $i++) { |
| 601 | [$x1, $y1] = $vertices[$i]; |
| 602 | [$x2, $y2] = $vertices[($i + 1) % $n]; |
| 603 | // Skip horizontal edges — they don't cross a horizontal |
| 604 | // sample line (infinitely many crossings). |
| 605 | if (abs($y2 - $y1) < 0.0001) { |
| 606 | continue; |
| 607 | } |
| 608 | $minY = min($y1, $y2); |
| 609 | $maxY = max($y1, $y2); |
| 610 | if ($yLocal + 0.0001 < $minY || $yLocal - 0.0001 > $maxY) { |
| 611 | continue; |
| 612 | } |
| 613 | // Linear interpolation: x(y) = x1 + (y - y1) · (x2 - x1) / (y2 - y1) |
| 614 | $x = $x1 + ($yLocal - $y1) * ($x2 - $x1) / ($y2 - $y1); |
| 615 | if ($best === null |
| 616 | || ($max && $x > $best) |
| 617 | || (!$max && $x < $best) |
| 618 | ) { |
| 619 | $best = $x; |
| 620 | } |
| 621 | } |
| 622 | return $best; |
| 623 | } |
| 624 | |
| 625 | /** |
| 626 | * Minimum of right-float left edges at `$y` (clamped to ≤ |
| 627 | * `$containingRight`) — where a line of inline content must end. |
| 628 | */ |
| 629 | public function rightEdgeAt(float $y, float $containingRight, bool $ignoreShape = false): float |
| 630 | { |
| 631 | return $this->rightEdgeInBand($y, $y, $containingRight, $ignoreShape); |
| 632 | } |
| 633 | |
| 634 | /** Symmetric to {@see leftEdgeInBand} for right floats. */ |
| 635 | public function rightEdgeInBand( |
| 636 | float $bandTop, |
| 637 | float $bandBottom, |
| 638 | float $containingRight, |
| 639 | bool $ignoreShape = false, |
| 640 | ): float { |
| 641 | $edge = $containingRight; |
| 642 | foreach ($this->items as $item) { |
| 643 | if ($item->side !== 'right' || $this->hasEmptyFloatArea($item, $ignoreShape)) { |
| 644 | continue; |
| 645 | } |
| 646 | $leftEdge = $this->itemLeftEdgeInBand($item, $bandTop, $bandBottom, $ignoreShape); |
| 647 | if ($leftEdge !== null && $leftEdge < $edge) { |
| 648 | $edge = $leftEdge; |
| 649 | } |
| 650 | } |
| 651 | return $edge; |
| 652 | } |
| 653 | |
| 654 | /** |
| 655 | * Smallest float-bottom that is strictly greater than `$y`. Returns |
| 656 | * null when no active float ends below `$y`. |
| 657 | */ |
| 658 | private function nextFloatBottomBelow(float $y): ?float |
| 659 | { |
| 660 | $next = null; |
| 661 | foreach ($this->items as $item) { |
| 662 | $bottom = $item->marginBoxTop() + $item->marginBoxHeight(); |
| 663 | if ($bottom > $y + 0.001) { |
| 664 | if ($next === null || $bottom < $next) { |
| 665 | $next = $bottom; |
| 666 | } |
| 667 | } |
| 668 | } |
| 669 | return $next; |
| 670 | } |
| 671 | } |