diff --git a/src/Mpdf.php b/src/Mpdf.php index 280ca36db..73bf3177a 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -424,6 +424,14 @@ class Mpdf implements \Psr\Log\LoggerAwareInterface var $floatbuffer; var $floatmargins; + /** + * The top and bottom of each floated image drawn in a column since the columns were last laid out, by column, + * so balancing does not split a column beside one + * + * @var array[] + */ + var $columnFloats; + var $bullet; var $bulletarray; @@ -1305,6 +1313,7 @@ public function __construct(array $config = [], $container = null) $this->PageAnnots = []; $this->PageNumSubstitutions = []; $this->breakpoints = []; // used in columnbuffer + $this->columnFloats = []; $this->tableLevel = 0; $this->tbctr = []; // counter for nested tables at each level $this->page_box = new PageBox(); @@ -17386,6 +17395,15 @@ function printbuffer($arrayaux, $blockstate = 0, $is_table = false, $table_draft /* -- END CSS-IMAGE-FLOAT -- */ list($skipln) = $this->inlineObject($objattr['type'], '', $this->y, $objattr, $this->lMargin, ($this->flowingBlockAttr['contentWidth'] / Mpdf::SCALE), $maxWidth, $this->flowingBlockAttr['height'], false, $is_table); + /* -- COLUMNS -- */ + // A floated image too tall or wide for an empty column is set in its line as before + if ($this->ColActive && isset($objattr['float'])) { + $columnHeight = $this->PageBreakTrigger - ($this->CurrCol < $this->NbCol - 1 ? $this->y0 : $this->tMargin); + if (abs($objattr['height']) > $columnHeight || $objattr['width'] > $this->blk[$this->blklvl]['inner_width']) { + unset($objattr['float']); + } + } + /* -- END COLUMNS -- */ // 1 -> New line needed because of width // -1 -> Will fit width on line but NEW PAGE REQUIRED because of height // -2 -> Will not fit on line therefore needs new line but thus NEW PAGE REQUIRED @@ -17504,6 +17522,12 @@ function printbuffer($arrayaux, $blockstate = 0, $is_table = false, $table_draft $objattr['OUTER-HEIGHT'] = $h; $objattr['OUTER-X'] = $fx; $objattr['OUTER-Y'] = $fy; + /* -- COLUMNS -- */ + if ($this->ColActive) { + $objattr['column'] = $this->CurrCol; + $objattr['columnbuffer_from'] = count($this->columnbuffer); + } + /* -- END COLUMNS -- */ if ($objattr['float'] == 'R') { // If R float already exists at this level $this->floatmargins['R']['skipline'] = false; @@ -25280,6 +25304,8 @@ function AcceptPageBreak() } // *TABLES* /* -- COLUMNS -- */ if ($this->ColActive == 1) { + // A floated image stays in the column it was placed in + $this->printfloatbuffer(); // *CSS-IMAGE-FLOAT* if ($this->CurrCol < $this->NbCol - 1) { // Go to the next column $this->CurrCol++; @@ -25326,6 +25352,10 @@ function AcceptPageBreak() function SetColumns($NbCol, $vAlign = '', $gap = 5) { // NbCol = number of columns + // A floated image is drawn in its column before the columns are laid out + if ($this->ColActive) { + $this->printfloatbuffer(); // *CSS-IMAGE-FLOAT* + } // Anything less than 2 turns columns off if ($NbCol < 2) { // SET COLUMNS OFF if ($this->ColActive) { @@ -25348,6 +25378,7 @@ function SetColumns($NbCol, $vAlign = '', $gap = 5) $this->col_BMoutlines = []; $this->col_toc = []; $this->breakpoints = []; + $this->columnFloats = []; } else { // SET COLUMNS ON if ($this->ColActive) { $this->ColActive = 0; @@ -25372,6 +25403,7 @@ function SetColumns($NbCol, $vAlign = '', $gap = 5) $this->col_BMoutlines = []; $this->col_toc = []; $this->breakpoints = []; + $this->columnFloats = []; if ((strtoupper($vAlign) == 'J') || (strtoupper($vAlign) == 'JUSTIFY')) { $vAlign = 'J'; } else { @@ -25428,6 +25460,8 @@ function AddColumn() function NewColumn() { if ($this->ColActive == 1) { + // A floated image stays in the column it was placed in + $this->printfloatbuffer(); // *CSS-IMAGE-FLOAT* if ($this->CurrCol < $this->NbCol - 1) { // Go to the next column $this->CurrCol++; @@ -25504,6 +25538,9 @@ function printcolumnbuffer() $breaks = []; foreach ($this->breakpoints as $c => $bpa) { foreach ($bpa as $rely) { + if ($this->besideColumnFloat($c, $rely)) { + continue; + } $breaks[] = $rely + $this->ColDetails[$c]['add_y'] - $this->y0; } } @@ -25925,6 +25962,7 @@ function printcolumnbuffer() $this->col_BMoutlines = []; $this->col_toc = []; $this->breakpoints = []; + $this->columnFloats = []; } // mPDF 5.7+ @@ -26362,6 +26400,15 @@ function printkwtbuffer() function printfloatbuffer() { if (count($this->floatbuffer)) { + /* -- COLUMNS -- */ + if ($this->ColActive) { + $this->printColumnFloats(); + $this->floatbuffer = []; + $this->floatmargins = []; + + return; + } + /* -- END COLUMNS -- */ $this->objectbuffer = $this->floatbuffer; $this->printobjectbuffer(false); $this->objectbuffer = []; @@ -26370,6 +26417,72 @@ function printfloatbuffer() } } + /* -- COLUMNS -- */ + /** + * Draw the floated images into the column buffer, each where balancing the columns will keep it with the lines + * beside it + * + * A float is drawn after the lines that wrap around it, but balancing assigns columns in buffer order, so it is + * filed before the first line level with or below its top. The object buffer and current column are put back. + */ + private function printColumnFloats() + { + $column = $this->CurrCol; + $objects = $this->objectbuffer; + + foreach ($this->floatbuffer as $float) { + $this->CurrCol = isset($float['column']) ? $float['column'] : $column; + $top = $float['OUTER-Y']; + $bottom = $top + $float['OUTER-HEIGHT']; + $columnBottom = isset($this->ColDetails[$this->CurrCol]['bottom_margin']) ? $this->ColDetails[$this->CurrCol]['bottom_margin'] : 0; + $drawnFrom = count($this->columnbuffer); + + $this->objectbuffer = [$float]; + $this->printobjectbuffer(false); + + $this->ColDetails[$this->CurrCol]['bottom_margin'] = max($columnBottom, $bottom); + $this->columnFloats[$this->CurrCol][] = [$top, $bottom]; + + // columnbuffer_from may sit before an earlier float's splice: the scan still finds the first level line + $drawn = array_splice($this->columnbuffer, $drawnFrom); + $at = count($this->columnbuffer); + for ($i = isset($float['columnbuffer_from']) ? $float['columnbuffer_from'] : $drawnFrom; $i < $drawnFrom; $i++) { + if ($this->columnbuffer[$i]['col'] == $this->CurrCol && $this->columnbuffer[$i]['y'] >= $top - 0.001) { + $at = $i; + break; + } + } + array_splice($this->columnbuffer, $at, 0, $drawn); + } + + $this->CurrCol = $column; + $this->objectbuffer = $objects; + } + + /** + * Whether a column break at this height would split the column beside a floated image + * + * @param int $column + * @param float $y + * + * @return bool + */ + private function besideColumnFloat($column, $y) + { + if (empty($this->columnFloats[$column])) { + return false; + } + + foreach ($this->columnFloats[$column] as $extent) { + if ($y > $extent[0] + 0.001 && $y < $extent[1] - 0.001) { + return true; + } + } + + return false; + } + /* -- END COLUMNS -- */ + function Circle($x, $y, $r, $style = 'S') { $this->Ellipse($x, $y, $r, $r, $style); diff --git a/src/Tag/Img.php b/src/Tag/Img.php index b2a02d89d..157bb1dd2 100644 --- a/src/Tag/Img.php +++ b/src/Tag/Img.php @@ -417,7 +417,7 @@ public function open($attr, &$ahtml, &$ihtml) } /* -- END BORDER-RADIUS -- */ /* -- CSS-IMAGE-FLOAT -- */ - if (!$this->mpdf->ColActive && !$this->mpdf->tableLevel && !$this->mpdf->listlvl && !$this->mpdf->kwt) { + if (!$this->mpdf->tableLevel && !$this->mpdf->listlvl && !$this->mpdf->kwt) { if (isset($properties['FLOAT']) && (strtoupper($properties['FLOAT']) === 'RIGHT' || strtoupper($properties['FLOAT']) === 'LEFT')) { $objattr['float'] = strtoupper(substr($properties['FLOAT'], 0, 1)); } diff --git a/tests/Mpdf/FloatImageInColumnsTest.php b/tests/Mpdf/FloatImageInColumnsTest.php new file mode 100644 index 000000000..ac47eaef0 --- /dev/null +++ b/tests/Mpdf/FloatImageInColumnsTest.php @@ -0,0 +1,236 @@ +ruler = $this->mpdf(); + $this->ruler->SetFont('ctimes', '', 11); + } + + /** + * Wherever balancing splits the columns, the lines level with the image in its column are set clear of it, and + * every other line, including those in the other column level with the image, has the column's full width + * + * @dataProvider layoutProvider + * + * @param string $side The side the image floats to, L or R + * @param string $dir The document's direction + * @param int $lead The words ahead of the image's paragraph + * @param string $columns The markup that starts the columns + * @param string $between Markup between the lead and the image's paragraph + * @param array $config + */ + public function testLinesAreNarrowedOnlyBesideTheImage($side, $dir, $lead, $columns = '', $between = '', $config = []) + { + $mpdf = $this->mpdf($config); + if ($dir === 'rtl') { + $mpdf->SetDirectionality('rtl'); + } + $mpdf->WriteHTML($columns . '

' . str_repeat('alpha ', $lead) . '

' . $between + . '

' + . str_repeat('beta ', 150) . '

' . str_repeat('gamma ', 150) . '

'); + $pages = $this->pageContents($this->output($mpdf)); + + $image = $this->image($pages[0]); + $lines = $this->lines($pages[0]); + $column = $this->columnOf($mpdf, $image['left'] + 1, $image['right'] - 1); + $this->assertNotNull($column, 'The image should lie within a column'); + + $beside = 0; + $levelInOtherColumn = 0; + foreach ($lines as $line) { + $lineColumn = $this->columnOf($mpdf, $line['x0'], $line['x1']); + $this->assertNotNull($lineColumn, 'Each line should lie within a column: ' . $line['text']); + + // A line is set beside the image when the top of its box is, which is some way above its baseline + $level = $line['baseline'] >= $image['top'] + self::LINE && $line['baseline'] <= $image['bottom']; + $edge = !$level && $line['baseline'] > $image['top'] - self::LINE && $line['baseline'] < $image['bottom'] + self::LINE; + if ($lineColumn === $column && $level) { + $beside++; + if ($side === 'L') { + $this->assertGreaterThanOrEqual($image['right'] - 0.01, $line['x0'], 'A line beside the image should start clear of it: ' . $line['text']); + } else { + $this->assertLessThanOrEqual($image['left'] + 0.01, $line['x1'], 'A line beside the image should end clear of it: ' . $line['text']); + } + continue; + } + + if ($lineColumn === $column && $edge) { + continue; + } + + if ($lineColumn !== $column && $level) { + $levelInOtherColumn++; + } + + if ($dir === 'rtl') { + $this->assertEqualsWithDelta($mpdf->ColR[$lineColumn] * Mpdf::SCALE, $line['x1'], 0.05, 'A line away from the image should end at its column\'s edge: ' . $line['text']); + } else { + $this->assertEqualsWithDelta($mpdf->ColL[$lineColumn] * Mpdf::SCALE, $line['x0'], 0.05, 'A line away from the image should start at its column\'s edge: ' . $line['text']); + } + } + + $this->assertGreaterThanOrEqual(4, $beside, 'The lines beside the image should have gone with it into its column'); + if ($between === '' && empty($config['keepColumns'])) { + $this->assertGreaterThan(0, $levelInOtherColumn, 'The other column should have lines level with the image'); + } + } + + /** + * The combinations of side, direction and column breaking, with the lead long enough to put the image near + * the top, the middle and the bottom of the first column, and past it + * + * @return array[] + */ + public function layoutProvider() + { + $cases = []; + foreach ([40, 120, 200, 260, 300, 340, 420] as $lead) { + $cases['left, ltr, balanced, lead ' . $lead] = ['L', 'ltr', $lead]; + $cases['right, rtl, balanced, lead ' . $lead] = ['R', 'rtl', $lead]; + $cases['right, ltr, balanced, lead ' . $lead] = ['R', 'ltr', $lead]; + $cases['left, ltr, vAlign J with no stretch, lead ' . $lead] = ['L', 'ltr', $lead, '', '', ['max_colH_correction' => 1]]; + $cases['left, ltr, kept columns, lead ' . $lead] = ['L', 'ltr', $lead, '', '', ['keepColumns' => true]]; + } + $cases['left, ltr, after a column break'] = ['L', 'ltr', 40, '', '']; + $cases['right, rtl, after a column break'] = ['R', 'rtl', 40, '', '']; + + return $cases; + } + + /** + * An image too tall for any column is set in its line as it was before floats were allowed in columns + */ + public function testAnImageTallerThanAColumnIsSetInline() + { + $mpdf = $this->mpdf(); + $mpdf->WriteHTML('

' . str_repeat('alpha ', 100) + . '' . str_repeat('beta ', 100) . '

'); + $pages = $this->pageContents($this->output($mpdf)); + + $drawn = 0; + foreach ($pages as $page) { + $drawn += $this->images($page); + foreach ($this->lines($page) as $line) { + $column = $this->columnOf($mpdf, $line['x0'], $line['x1']); + $this->assertNotNull($column, 'Each line should lie within a column: ' . $line['text']); + $this->assertEqualsWithDelta($mpdf->ColL[$column] * Mpdf::SCALE, $line['x0'], 0.05, 'No line should be narrowed: ' . $line['text']); + } + } + $this->assertSame(1, $drawn); + } + + /** + * The bottom of a column, which balancing measures the columns by, is taken down to the bottom of a floated + * image's margin where that is lower than the column's last line + */ + public function testTheColumnReachesDownToTheImage() + { + $mpdf = $this->mpdf(); + $mpdf->WriteHTML('

One line

'); + + $drawn = array_values(array_filter($mpdf->columnbuffer, static function ($entry) { + return preg_match('/\/I\d+ Do/', $entry['s']) === 1; + })); + $this->assertCount(1, $drawn, 'The image should wait in the column buffer'); + $this->assertEqualsWithDelta($drawn[0]['y'] + 40, $mpdf->ColDetails[0]['bottom_margin'], 0.01); + + $mpdf->cleanup(); + } + + /** + * The one image on a page, in points from the top left + * + * @param string $stream + * + * @return float[] + */ + private function image($stream) + { + $this->assertSame(1, preg_match_all('/q ([\d.]+) 0 0 ([\d.]+) ([\d.]+) ([\d.]+) cm \/I\d+ Do Q/', $stream, $m, PREG_SET_ORDER), 'The page should draw one image'); + list(, $w, $h, $x, $y) = $m[0]; + return ['left' => (float) $x, 'right' => $x + $w, 'top' => self::PAGE_HEIGHT - $y - $h, 'bottom' => self::PAGE_HEIGHT - $y]; + } + + /** + * The lines of text on a page, each with its horizontal extent and its baseline in points from the top left + * + * @param string $stream + * + * @return array[] + */ + private function lines($stream) + { + preg_match_all('/BT ([\d.]+) ([\d.]+) Td\s+\((.*?)\) Tj ET/', $stream, $m, PREG_SET_ORDER); + + $lines = []; + foreach ($m as $line) { + $lines[] = [ + 'x0' => (float) $line[1], + 'x1' => $line[1] + $this->ruler->GetStringWidth($line[3]) * Mpdf::SCALE, + 'baseline' => self::PAGE_HEIGHT - $line[2], + 'text' => $line[3], + ]; + } + + return $lines; + } + + /** + * The column a horizontal extent lies within, or null + * + * @param \Mpdf\Mpdf $mpdf + * @param float $x0 + * @param float $x1 + * + * @return int|null + */ + private function columnOf(Mpdf $mpdf, $x0, $x1) + { + foreach ($mpdf->ColL as $i => $left) { + if ($x0 >= $left * Mpdf::SCALE - 0.05 && $x1 <= $mpdf->ColR[$i] * Mpdf::SCALE + 0.05) { + return $i; + } + } + + return null; + } +} diff --git a/tests/Snapshots/FloatImagesInColumnsSnapshotTest.php b/tests/Snapshots/FloatImagesInColumnsSnapshotTest.php new file mode 100644 index 000000000..111d55dd9 --- /dev/null +++ b/tests/Snapshots/FloatImagesInColumnsSnapshotTest.php @@ -0,0 +1,51 @@ +mpdf and loading it with content + * + * @return void + */ + public function generatePdf() + { + $lorem = 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Sed auctor viverra diam. In lacinia lectus. Praesent tincidunt massa in dolor. Morbi viverra leo quis ipsum. In vitae velit. In aliquam nulla nec mi. Sed accumsan, justo id congue fringilla, diam mauris volutpat ligula, sed aliquet elit diam at felis. '; + + $this->mpdf = $this->createMpdf(['mode' => 'c']); + $this->mpdf->SetBasePath(__DIR__ . '/../data'); + + $this->mpdf->WriteHTML(' + +

Floated images in columns

+ +

' . str_repeat($lorem, 3) . '

+

Mid-paragraph, ' . $lorem . '' . str_repeat($lorem, 3) . '

+

' . str_repeat($lorem, 3) . '

+

' . str_repeat($lorem, 2) . '

+ +

After the columns.

+ '); + } +} diff --git a/tests/data/snapshots/float-images-in-columns.pdf b/tests/data/snapshots/float-images-in-columns.pdf new file mode 100644 index 000000000..44432af48 Binary files /dev/null and b/tests/data/snapshots/float-images-in-columns.pdf differ