Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.52% covered (warning)
84.52%
202 / 239
72.00% covered (warning)
72.00%
18 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
FloatContext
84.52% covered (warning)
84.52%
202 / 239
72.00% covered (warning)
72.00%
18 / 25
158.54
0.00% covered (danger)
0.00%
0 / 1
 snapshot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 restore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addLeft
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 addRight
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 marginLeftEdgeAt
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 marginRightEdgeAt
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 leftEdgeInBand
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 hasEmptyFloatArea
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 itemRightEdgeInBand
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 itemLeftEdgeInBand
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 bandLocalRange
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 shapeRightEdgeInBand
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 shapeLeftEdgeInBand
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 shapeRightEdgeLocalAt
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
10.41
 shapeLeftEdgeLocalAt
37.04% covered (danger)
37.04%
10 / 27
0.00% covered (danger)
0.00%
0 / 1
29.22
 polygonEdgesInBand
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
11
 polygonEdgesAt
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
11.02
 rightEdgeAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rightEdgeInBand
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    /**
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}