From 958481f6db75bba11b6b03b2df7d628204ad53ec Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Mon, 7 Sep 2026 11:20:56 +1000 Subject: [PATCH] perf: drop float records the layout has already buried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $floatDivs is a document-global list of every float the document has closed. It was emptied only in the constructor, yet GetFloatDivInfo() reads all of it every time a float is placed, so a long document of floated rows degrades to O(n^2) — 1000 rows of a 9-column float grid took 34s, against 2.6s once the list stays bounded. Route both float records through addFloatDiv(), which first drops the records that ended at or above where the incoming float starts. Those can no longer satisfy GetFloatDivInfo()'s `$currpos < $f['endpos']` test, because layout only moves forward from there. Pruning is scoped to the incoming float's own block formatting context, since ClearFloats matches on blockContext alone and ignores position. The comparison is against the incoming float's start rather than the current cursor because the cursor rewinds to the top of the float just written; a backwards jump then prunes nothing rather than pruning wrongly. Co-Authored-By: Claude Opus 5 --- src/Mpdf.php | 27 +++++++ src/Tag/BlockTag.php | 8 +- tests/Mpdf/FloatDivTest.php | 126 ++++++++++++++++++++++++++++++ tests/Mpdf/UnprunedFloatsMpdf.php | 18 +++++ 4 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 tests/Mpdf/FloatDivTest.php create mode 100644 tests/Mpdf/UnprunedFloatsMpdf.php diff --git a/src/Mpdf.php b/src/Mpdf.php index c5ca99185..cdfeb1e3e 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -15375,6 +15375,33 @@ function GetFloatDivInfo($blklvl = 0, $clear = false) return [$l_exists, $r_exists, $l_max, $r_max, $l_width, $r_width]; } + /** + * Record a float that has just been closed, dropping the ones it has buried. + * + * $floatDivs is document-global and was emptied only in the constructor, yet GetFloatDivInfo() reads + * all of it every time a float is placed. A page of floats therefore costs O(n^2) over a long + * document, which is what a report of floated rows looks like. + * + * A float that ended at or above where this one starts can no longer satisfy the + * `$currpos < $f['endpos']` test, since layout only moves forward from here, so it is dropped. The + * comparison is against the incoming start rather than the current cursor because the cursor rewinds + * to the top of the float that has just been written; anything that jumps backwards prunes nothing + * rather than pruning wrongly. + * + * @param array $floatDiv + * + * @return void + */ + public function addFloatDiv(array $floatDiv) + { + $this->floatDivs = array_values(array_filter($this->floatDivs, function ($f) use ($floatDiv) { + // Only within the same block formatting context: a float elsewhere is still ClearFloats' to find. + return $f['blockContext'] !== $floatDiv['blockContext'] || $f['endpos'] > $floatDiv['startpos']; + })); + + $this->floatDivs[] = $floatDiv; + } + /* -- END CSS-FLOAT -- */ // LIST MARKERS // mPDF 6 Lists diff --git a/src/Tag/BlockTag.php b/src/Tag/BlockTag.php index 0b8cb13b0..2d8939ae9 100644 --- a/src/Tag/BlockTag.php +++ b/src/Tag/BlockTag.php @@ -1301,7 +1301,7 @@ public function close(&$ahtml, &$ihtml) } } - $this->mpdf->floatDivs[] = [ + $this->mpdf->addFloatDiv([ 'side' => 'R', 'startpage' => $this->mpdf->blk[$this->mpdf->blklvl]['startpage'], 'y0' => $this->mpdf->blk[$this->mpdf->blklvl]['float_start_y'], @@ -1312,7 +1312,7 @@ public function close(&$ahtml, &$ihtml) 'w' => $this->mpdf->blk[$this->mpdf->blklvl]['float_width'], 'blklvl' => $this->mpdf->blklvl, 'blockContext' => $this->mpdf->blk[$this->mpdf->blklvl - 1]['blockContext'] - ]; + ]); $this->mpdf->y = $this->mpdf->blk[$this->mpdf->blklvl]['float_start_y']; $this->mpdf->page = $this->mpdf->blk[$this->mpdf->blklvl]['startpage']; @@ -1336,7 +1336,7 @@ public function close(&$ahtml, &$ihtml) } } - $this->mpdf->floatDivs[] = [ + $this->mpdf->addFloatDiv([ 'side' => 'L', 'startpage' => $this->mpdf->blk[$this->mpdf->blklvl]['startpage'], 'y0' => $this->mpdf->blk[$this->mpdf->blklvl]['float_start_y'], @@ -1347,7 +1347,7 @@ public function close(&$ahtml, &$ihtml) 'w' => $this->mpdf->blk[$this->mpdf->blklvl]['float_width'], 'blklvl' => $this->mpdf->blklvl, 'blockContext' => $this->mpdf->blk[$this->mpdf->blklvl - 1]['blockContext'] - ]; + ]); $this->mpdf->y = $this->mpdf->blk[$this->mpdf->blklvl]['float_start_y']; $this->mpdf->page = $this->mpdf->blk[$this->mpdf->blklvl]['startpage']; diff --git a/tests/Mpdf/FloatDivTest.php b/tests/Mpdf/FloatDivTest.php new file mode 100644 index 000000000..f278d9ed5 --- /dev/null +++ b/tests/Mpdf/FloatDivTest.php @@ -0,0 +1,126 @@ + 'c']); + $pruned->WriteHTML($html); + + $unpruned = new UnprunedFloatsMpdf(['mode' => 'c']); + $unpruned->WriteHTML($html); + + /* The page content streams, not Output(), which carries a timestamp and a random file ID. */ + $this->assertSame($unpruned->pages, $pruned->pages); + $this->assertSame($unpruned->page, $pruned->page, 'Pruning should not change the page count.'); + + $pruned->cleanup(); + $unpruned->cleanup(); + } + + public function floatLayoutProvider() + { + $lorem = 'Body copy long enough to wrap, so each line box has to negotiate with whatever floats are live where it lands. '; + + $rows = static function ($count, $row) { + $html = ''; + for ($i = 0; $i < $count; $i++) { + $html .= sprintf($row, $i, $i, $i); + } + + return $html; + }; + + return [ + // The reported case: a report of floated columns, one cleared row per record. + 'grid of cleared rows' => [$rows(60, '
left %d
middle %d
right %d
')], + 'rows cleared by a trailing div' => [$rows(60, '
left %d
right %d
%d
')], + 'text flowing beside the floats' => [$rows(40, '
left %d
right %d

' . $lorem . '%d

')], + 'clear on one side only' => [$rows(40, '
left %d
right %d
after %d
')], + 'floats nested inside floats' => [$rows(30, '
inner %d
inner %d
outer %d
')], + 'floats that are never cleared' => [$rows(60, '
box %d%d%d
')], + 'floats spanning a page break' => [$rows(30, '
' . str_repeat($lorem, 4) . '%d
%d %d
')], + 'floats followed by tables' => [$rows(40, '
float %d
cell %d' . $lorem . '%d
')], + ]; + } + + public function testTheRenderedPagesAreUnchangedWhenRowsAreWrittenOneCallAtATime() + { + /* How a report renderer streams: many WriteHTML() calls into one document. */ + $write = static function (Mpdf $mpdf) { + $mpdf->WriteHTML('
head
', HTMLParserMode::HTML_BODY, true, false); + for ($i = 0; $i < 60; $i++) { + $mpdf->WriteHTML(sprintf('
left %d
right %d
', $i, $i), HTMLParserMode::HTML_BODY, false, false); + } + $mpdf->WriteHTML('
foot
', HTMLParserMode::HTML_BODY, false, true); + }; + + $pruned = new Mpdf(['mode' => 'c']); + $write($pruned); + + $unpruned = new UnprunedFloatsMpdf(['mode' => 'c']); + $write($unpruned); + + $this->assertSame($unpruned->pages, $pruned->pages); + + $pruned->cleanup(); + $unpruned->cleanup(); + } + + public function testOnlyTheRowBeingLaidOutIsKept() + { + $html = ''; + for ($i = 0; $i < 60; $i++) { + $html .= sprintf('
left %d
middle %d
right %d
', $i, $i, $i); + } + + $this->mpdf->WriteHTML($html); + + /* Three columns, not 60 rows of them. Before pruning this was 180. */ + $this->assertCount(3, $this->mpdf->floatDivs); + } + + public function testAFloatTheTextIsStillFlowingBesideIsKept() + { + $this->mpdf->WriteHTML('
sidebar

Body copy beside the float.

'); + + $this->assertCount(1, $this->mpdf->floatDivs); + } + + public function testAFloatIsDroppedOnlyOnceTheNextOneStartsBelowIt() + { + $below = ['side' => 'L', 'startpos' => 1010.0, 'endpos' => 1050.0, 'blockContext' => 2]; + $this->mpdf->addFloatDiv($below); + + /* Overlaps it, so both are live. */ + $this->mpdf->addFloatDiv(['side' => 'R', 'startpos' => 1020.0, 'endpos' => 1060.0, 'blockContext' => 2]); + $this->assertCount(2, $this->mpdf->floatDivs); + + /* Starts below both, so both are behind the cursor for good. */ + $this->mpdf->addFloatDiv(['side' => 'L', 'startpos' => 1080.0, 'endpos' => 1090.0, 'blockContext' => 2]); + $this->assertCount(1, $this->mpdf->floatDivs); + } + + public function testAFloatInAnotherBlockFormattingContextIsKept() + { + /* ClearFloats() matches on blockContext alone, ignoring position, so another context is not ours to drop. */ + $this->mpdf->addFloatDiv(['side' => 'L', 'startpos' => 1010.0, 'endpos' => 1050.0, 'blockContext' => 2]); + $this->mpdf->addFloatDiv(['side' => 'L', 'startpos' => 1080.0, 'endpos' => 1090.0, 'blockContext' => 3]); + + $this->assertCount(2, $this->mpdf->floatDivs); + } + +} diff --git a/tests/Mpdf/UnprunedFloatsMpdf.php b/tests/Mpdf/UnprunedFloatsMpdf.php new file mode 100644 index 000000000..929c86e05 --- /dev/null +++ b/tests/Mpdf/UnprunedFloatsMpdf.php @@ -0,0 +1,18 @@ +floatDivs[] = $floatDiv; + } + +}