diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index 116b6153c..f7d9f82e3 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -82,6 +82,7 @@ Read this section before upgrading from upstream mPDF. Each entry says what chan * **`min-width` and `max-width` hold the width of a block**, as in a browser, in standard mode. mPDF never read them on a block, so `max-width: 100mm; margin: 0 auto`, the usual centred container, filled the page, `width: 150mm; max-width: 100mm` was 150mm wide, and `width: 50mm; min-width: 80mm` was 50mm. Now the width is held within the two, `max-width` first, then `min-width`, a percentage taken of the container's inner width as `width` is, and the block's auto margins, direction or float place the narrowed box. Floats and positioned blocks take the same limits. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to ignore the two properties as before. [#588] [#691] * `box-sizing` is not read, so the limits measure the content, and a block with padding and borders is wider by them. `min-width` and `max-width` on a table or cell are still not read. * **`text-indent` indents the first line of a table cell**, as in a browser, in standard mode. mPDF ignored it in cells, including the value a cell inherits, so `body { text-indent: 10mm }` indented paragraphs and left cells alone. Now a cell's first line is indented by its own `text-indent` or the one it inherits from its table and the blocks around it, and a cell sized to its content is made wide enough for the indent. Set `td, th { text-indent: 0 }` to keep cells unindented, or set `'cssMode' => \Mpdf\CssMode::LEGACY`. [#608] +* **A word too long for its line overflows it when `word-break` or `overflow-wrap` leave it nowhere to break**, as in a browser, in standard mode. mPDF ignored both and always cut such a word where the line was full. That is still what happens when neither property is set, but `overflow-wrap: normal`, `word-break: normal` or `word-break: keep-all` on its own now let the word run out of its box. Set `overflow-wrap: anywhere` to have it cut, or set `'cssMode' => \Mpdf\CssMode::LEGACY`. [#607] * A `

` or `

` inside a cell, which mPDF lays out as part of the cell's text, is indented only when it starts the cell. New features @@ -163,6 +164,7 @@ New features * **`vertical-align: middle`, `top`, `bottom`, `text-top` and `text-bottom` on inline text**, in standard mode. They worked on images only, and left text on the baseline. Now `middle` puts the middle of the element's line box half the x-height of the block's font above the baseline, `text-top` and `text-bottom` line its top or bottom up with the top or bottom of the block's font, and `top` and `bottom` with the top or bottom of the line, which grows to hold it. The text, images, background, border and decorations inside the element move with it. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to leave text on the baseline as before. [#608] * Each element is aligned against the block's font, not against the inline element it is in, and an image inside an element aligned with `top` or `bottom` is aligned with the line on its own. * **`text-indent: hanging` and `each-line`**, in standard mode. `hanging` indents every line but the first, and `each-line` indents the first line after each `
` too. A value with either keyword was read as no indent before. [#608] +* **`word-break`, `overflow-wrap` and `word-wrap`**, in standard mode, in blocks and table cells. Both are inherited, and `word-wrap` is read as `overflow-wrap`. `word-break: break-all` breaks a line where it is full, inside a word or not. `keep-all` stops CJK text breaking between its letters, so it breaks at spaces and punctuation only. `overflow-wrap: break-word` and `anywhere`, and `word-break: break-word`, cut a word too long for its line at the edge. In a table cell `break-all`, `anywhere` and `word-break: break-word` let the column be as narrow as its widest letter, so `td { word-break: break-all }` keeps a 30mm table 30mm wide around a long URL, where the table used to widen to hold it. [#607] Performance ----------- @@ -970,4 +972,5 @@ These changes do not change output. [#691]: https://github.com/GravityPDF/mpdf/pull/691 [#584]: https://github.com/GravityPDF/mpdf/issues/584 [#689]: https://github.com/GravityPDF/mpdf/pull/689 +[#607]: https://github.com/GravityPDF/mpdf/issues/607 [#608]: https://github.com/GravityPDF/mpdf/issues/608 diff --git a/src/Css/CssWideKeywords.php b/src/Css/CssWideKeywords.php index 0c14443c0..77677a027 100644 --- a/src/Css/CssWideKeywords.php +++ b/src/Css/CssWideKeywords.php @@ -114,6 +114,7 @@ final class CssWideKeywords 'MARGIN-TOP' => '0', 'OPACITY' => '1', 'OVERFLOW' => 'visible', + 'OVERFLOW-WRAP' => 'normal', 'PADDING-BOTTOM' => '0', 'PADDING-LEFT' => '0', 'PADDING-RIGHT' => '0', @@ -131,6 +132,7 @@ final class CssWideKeywords 'VERTICAL-ALIGN' => 'baseline', 'VISIBILITY' => 'visible', 'WHITE-SPACE' => 'normal', + 'WORD-BREAK' => 'normal', 'WORD-SPACING' => 'normal', 'Z-INDEX' => 'auto', ]; diff --git a/src/Css/InheritedProperties.php b/src/Css/InheritedProperties.php index bfac198f8..36d384248 100644 --- a/src/Css/InheritedProperties.php +++ b/src/Css/InheritedProperties.php @@ -61,6 +61,14 @@ final class InheritedProperties 'LIST-STYLE-POSITION', ]; + /** + * Held in the text state like TEXT, but read only under CssMode::STANDARD, so no legacy channel hands them on + */ + const WORD_BREAK = [ + 'WORD-BREAK', + 'OVERFLOW-WRAP', + ]; + /** * What a positioned block handed its content under CssMode::LEGACY, in the order it was written. Text decorations * and the stacking order are not inherited, but the
that stands in for the block takes them too @@ -90,13 +98,13 @@ final class InheritedProperties ]; /** - * @return string[] Every inherited property mPDF supports: TEXT, then BLOCK + * @return string[] Every inherited property mPDF supports: TEXT, then BLOCK, then WORD_BREAK */ public static function names() { static $names = null; if ($names === null) { - $names = array_merge(self::TEXT, self::BLOCK); + $names = array_merge(self::TEXT, self::BLOCK, self::WORD_BREAK); } return $names; diff --git a/src/Css/NormalizeProperties.php b/src/Css/NormalizeProperties.php index e7263db21..2b7f55e89 100644 --- a/src/Css/NormalizeProperties.php +++ b/src/Css/NormalizeProperties.php @@ -143,6 +143,7 @@ class NormalizeProperties 'BREAK-BEFORE' => ['PAGE-BREAK-BEFORE'], 'BREAK-AFTER' => ['PAGE-BREAK-AFTER'], 'BREAK-INSIDE' => ['PAGE-BREAK-INSIDE'], + 'WORD-WRAP' => ['OVERFLOW-WRAP'], ]; public function __construct(Mpdf $mpdf, SizeConverter $sizeConverter, ColorConverter $colorConverter) @@ -234,6 +235,8 @@ public function normalize($prop) $this->processPageSizeProperty($k, $v); } elseif ($k === 'BREAK-BEFORE' || $k === 'BREAK-AFTER' || $k === 'BREAK-INSIDE') { $this->processBreakProperty($k, $v); + } elseif ($standard && ($k === 'WORD-BREAK' || $k === 'OVERFLOW-WRAP' || $k === 'WORD-WRAP')) { + $this->processWordBreakProperty($k, $v); } elseif (in_array($k, ['BACKGROUND', 'BACKGROUND-IMAGE', 'BACKGROUND-REPEAT', 'BACKGROUND-POSITION'], true)) { $this->processBackgroundProperty($k, $v); } elseif ($k === 'IMAGE-ORIENTATION') { @@ -1293,6 +1296,26 @@ private function processBreakProperty($property, $value) } } + /** + * Keeps word-break and overflow-wrap when the value is one they take, as a browser drops an invalid declaration. + * word-wrap is the old name of overflow-wrap + * + * @param string $property WORD-BREAK, OVERFLOW-WRAP or WORD-WRAP + * @param string $value + * @return void + */ + private function processWordBreakProperty($property, $value) + { + if ($property === 'WORD-WRAP') { + $property = 'OVERFLOW-WRAP'; + } + + $value = trim($value); + if (WordBreak::isValid($property, $value)) { + $this->properties[$property] = $value; + } + } + /** * Process image orientation CSS properties. * diff --git a/src/Css/WordBreak.php b/src/Css/WordBreak.php new file mode 100644 index 000000000..ce2dec0bc --- /dev/null +++ b/src/Css/WordBreak.php @@ -0,0 +1,122 @@ + ['normal', 'break-all', 'keep-all', 'break-word'], + 'OVERFLOW-WRAP' => ['normal', 'break-word', 'anywhere'], + ]; + + /** + * Whether a value is one the property takes + * + * @param string $property WORD-BREAK or OVERFLOW-WRAP + * @param string $value Lowercased + * + * @return bool + */ + public static function isValid($property, $value) + { + return in_array($value, self::VALUES[$property], true); + } + + /** + * Whether a line may break between any two letters, so it is filled to its edge (word-break: break-all) + * + * @param array|null $textparam + * + * @return bool + */ + public static function breaksAll($textparam) + { + return self::wordBreak($textparam) === 'break-all'; + } + + /** + * Whether CJK text keeps its letters together, breaking only at spaces and punctuation (word-break: keep-all) + * + * @param array|null $textparam + * + * @return bool + */ + public static function keepsAll($textparam) + { + return self::wordBreak($textparam) === 'keep-all'; + } + + /** + * Whether a word too long for its line is broken at the edge of the line rather than overflowing it + * + * @param array|null $textparam + * + * @return bool + */ + public static function breaksLongWords($textparam) + { + if (!isset($textparam['word-break']) && !isset($textparam['overflow-wrap'])) { + return true; + } + + return self::narrowsMinContent($textparam) || self::overflowWrap($textparam) === 'break-word'; + } + + /** + * Whether a table cell's narrowest width is taken from its widest letter rather than its widest word. Unlike + * overflow-wrap: break-word, anywhere and break-all count the breaks they allow in the narrowest width, and + * word-break: break-word is read as overflow-wrap: anywhere + * + * @param array|null $textparam + * + * @return bool + */ + public static function narrowsMinContent($textparam) + { + $wordBreak = self::wordBreak($textparam); + + return $wordBreak === 'break-all' || $wordBreak === 'break-word' || self::overflowWrap($textparam) === 'anywhere'; + } + + /** + * The word-break the text is set in, normal when no element sets it + * + * @param array|null $textparam + * + * @return string + */ + private static function wordBreak($textparam) + { + return isset($textparam['word-break']) ? $textparam['word-break'] : 'normal'; + } + + /** + * The overflow-wrap the text is set in, normal when no element sets it + * + * @param array|null $textparam + * + * @return string + */ + private static function overflowWrap($textparam) + { + return isset($textparam['overflow-wrap']) ? $textparam['overflow-wrap'] : 'normal'; + } +} diff --git a/src/Mpdf.php b/src/Mpdf.php index 45463ae96..0196e7797 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -17,6 +17,7 @@ use Mpdf\Css\TextIndent; use Mpdf\Css\TextVars; use Mpdf\Css\WidthConstraints; +use Mpdf\Css\WordBreak; use Mpdf\Fonts\Color\ColorFormats; use Mpdf\Fonts\FontRegistry; use Mpdf\Log\Context as LogContext; @@ -7355,6 +7356,7 @@ function newFlowingBlock($w, $h, $a = '', $is_table = false, $blockstate = 0, $n $this->flowingBlockAttr['newblock'] = $newblock; $this->flowingBlockAttr['forcedBreak'] = false; + $this->flowingBlockAttr['overflowing'] = false; $this->flowingBlockAttr['valign'] = 'M'; $this->flowingBlockAttr['blockdir'] = $blockdir; $this->flowingBlockAttr['cOTLdata'] = []; // mPDF 5.7.1 @@ -7784,7 +7786,11 @@ function finishFlowingBlock($endofblock = false, $next = '') } else { $CJKoverflow = false; } - if ((((($contentWidth + $lastitalic) > $maxWidth) && ($content[(count($chunkorder) - 1)] != ' ') ) || + $overflowing = $this->flowingBlockAttr['overflowing']; + $this->flowingBlockAttr['overflowing'] = false; + if ($overflowing) { + // A line a word overflows is not squeezed to fit + } elseif ((((($contentWidth + $lastitalic) > $maxWidth) && ($content[(count($chunkorder) - 1)] != ' ') ) || (!$endofblock && $align == 'J' && ($next == 'image' || $next == 'select' || $next == 'input' || $next == 'textarea' || ($next == 'br' && $this->justifyB4br)))) && !($CJKoverflow && $this->allowCJKoverflow)) { // WORD SPACING list($jcharspacing, $jws, $jkashida) = $this->GetJspacing($nb_carac, $nb_spaces, ($maxWidth - $lastitalic - $contentWidth - $WidthCorrection - (($this->cMarginL + $this->cMarginR) * Mpdf::SCALE) - ($paddingL + $paddingR + (($fpaddingL + $fpaddingR) * Mpdf::SCALE) )), $inclCursive, $cOTLdata); @@ -7839,7 +7845,11 @@ function finishFlowingBlock($endofblock = false, $next = '') } // false -> don't advance y $this->x = $currentx + $this->cMarginL + $ipaddingL + $fpaddingL; - if ($dottab !== false && $blockdir == 'rtl') { + if ($overflowing) { + if ($blockdir == 'rtl') { + $this->x += $empty; + } + } elseif ($dottab !== false && $blockdir == 'rtl') { $this->x -= $dottab; } elseif ($align == 'R') { $this->x += $empty; @@ -8855,6 +8865,10 @@ function WriteFlowingBlock($s, $sOTLdata) $Oikomi = false; // mPDF 6 $hanger = ''; + $keepAll = WordBreak::keepsAll($this->textparam); + $breakAll = WordBreak::breaksAll($this->textparam); + $breaksLongWords = WordBreak::breaksLongWords($this->textparam); + // COLS $oldcolumn = $this->CurrCol; if ($this->ColActive && !$is_table) { @@ -9107,7 +9121,7 @@ function WriteFlowingBlock($s, $sOTLdata) $contentWidth += $cw; $CJKoverflow = true; // Set flag continue; - } elseif (preg_match("/[" . $this->pregCJKchars . "]/u", $checkchar) && $this->allowCJKorphans && + } elseif (!$keepAll && preg_match("/[" . $this->pregCJKchars . "]/u", $checkchar) && $this->allowCJKorphans && (preg_match("/[" . $this->CJKleading . "]/u", $followingchar) || preg_match("/[" . $this->CJKfollowing . "]/u", $checkchar)) && !preg_match("/[" . $this->CJKleading . "]/u", $checkchar) && !preg_match("/[" . $this->CJKfollowing . "]/u", $followingchar) && !(preg_match("/[0-9\x{ff10}-\x{ff19}]/u", $followingchar) && preg_match("/[0-9\x{ff10}-\x{ff19}]/u", $checkchar))) { @@ -9122,6 +9136,12 @@ function WriteFlowingBlock($s, $sOTLdata) } } /* -- END CJK-FONTS -- */ + + // With word-break: break-all the line breaks where it is full, inside a word as anywhere else + if (!$breakfound && $prevchar !== false && $prevchar !== ' ' && $breakAll) { + $breakfound = true; + } + /* -- HYPHENATION -- */ // AUTOMATIC HYPHENATION @@ -9161,10 +9181,16 @@ function WriteFlowingBlock($s, $sOTLdata) // Search backwards to find first line-break opportunity $zeroWidthSpace = $this->zeroWidthSpace(); + $lineEnd = [$contentctr, $charctr]; + // While a word overflows, the line up to its last character has been searched already + $searched = $this->flowingBlockAttr['overflowing'] ? $this->flowingBlockAttr['overflowSearched'] : [-1, 0]; while ($breakfound == false && $prevchar !== false) { $cutcontentctr = $contentctr; $cutcharctr = $charctr; $prevchar = $this->_moveToPrevChar($contentctr, $charctr, $content); + if ($contentctr < $searched[0] || ($contentctr == $searched[0] && $charctr < $searched[1])) { + break; + } ///////////////////// // 3) Break at SPACE ///////////////////// @@ -9217,6 +9243,7 @@ function WriteFlowingBlock($s, $sOTLdata) ///////////////////// elseif (!$this->usingCoreFont && $this->checkCJK && preg_match("/[" . $this->pregCJKchars . "]/u", $checkchar) && !preg_match("/[" . $this->CJKfollowing . "]/u", $checkchar) && !preg_match("/[" . $this->CJKleading . "]/u", $prevchar) && + (!$keepAll || preg_match(WordBreak::CJK_PUNCTUATION, $prevchar . $checkchar)) && !(preg_match("/[0-9\x{ff10}-\x{ff19}]/u", $prevchar) && preg_match("/[0-9\x{ff10}-\x{ff19}]/u", $checkchar))) { $breakfound = [$cutcontentctr, $cutcharctr, $cutcontentctr, $cutcharctr, 'cut']; } @@ -9232,6 +9259,16 @@ function WriteFlowingBlock($s, $sOTLdata) $checkchar = $prevchar; } + // A word with nowhere to break overflows the line, unless word-break or overflow-wrap lets it break + if ($breakfound === false && !$breaksLongWords) { + $lineCount--; + $this->flowingBlockAttr['overflowing'] = true; + $this->flowingBlockAttr['overflowSearched'] = $lineEnd; + $currContent .= $c; + $contentWidth += $cw; + continue; + } + // If a line-break opportunity found: if (is_array($breakfound)) { $contentctr = $breakfound[0]; @@ -9491,7 +9528,9 @@ function WriteFlowingBlock($s, $sOTLdata) $nb_spaces = 0; $jkashida = 0; // if it's justified, we need to find the char/word spacing (or if hanger $this->CJKforceend) - if (($align == 'J' && !$CJKoverflow) || (($contentWidth + $lastitalic > $maxWidth - $WidthCorrection - (($this->cMarginL + $this->cMarginR) * Mpdf::SCALE) - ($paddingL + $paddingR + (($fpaddingL + $fpaddingR) * Mpdf::SCALE) ) + 0.001) && (!$CJKoverflow || ($CJKoverflow && !$this->allowCJKoverflow))) || $CJKoverflow && $align == 'J' && $this->allowCJKoverflow && $hanger && $this->CJKforceend) { // 0.001 is to correct for deviations converting mm=>pts + // A line a word overflows is not squeezed to fit, nor justified + $overflowing = $this->flowingBlockAttr['overflowing']; + if (!$overflowing && (($align == 'J' && !$CJKoverflow) || (($contentWidth + $lastitalic > $maxWidth - $WidthCorrection - (($this->cMarginL + $this->cMarginR) * Mpdf::SCALE) - ($paddingL + $paddingR + (($fpaddingL + $fpaddingR) * Mpdf::SCALE) ) + 0.001) && (!$CJKoverflow || ($CJKoverflow && !$this->allowCJKoverflow))) || $CJKoverflow && $align == 'J' && $this->allowCJKoverflow && $hanger && $this->CJKforceend)) { // 0.001 is to correct for deviations converting mm=>pts // JUSTIFY J (Use character spacing) // WORD SPACING // mPDF 5.7 @@ -9596,7 +9635,12 @@ function WriteFlowingBlock($s, $sOTLdata) } // false -> don't advance y $this->x = $currentx + $this->cMarginL + $ipaddingL + $fpaddingL; - if ($align == 'R') { + if ($overflowing) { + // An overflowing line is aligned to its start, and runs out of the block at its end + if ($blockdir == 'rtl') { + $this->x += $empty; + } + } elseif ($align == 'R') { $this->x += $empty; } elseif ($align == 'C') { $this->x += ($empty / 2); @@ -9760,6 +9804,7 @@ function WriteFlowingBlock($s, $sOTLdata) // Reset lineheight $stackHeight = $this->divheight; $valign = 'M'; + $this->flowingBlockAttr['overflowing'] = false; $font = []; $content = []; @@ -20118,6 +20163,13 @@ function setCSS($arrayaux, $type = '', $tag = '') } break; + case 'WORD-BREAK': + case 'OVERFLOW-WRAP': + if ($this->cssMode === CssMode::STANDARD) { + $this->textparam[strtolower($k)] = $v; + } + break; + case 'TEXT-OUTLINE': if (strtoupper($v) == 'NONE') { $this->textparam['outline-s'] = false; @@ -20445,7 +20497,13 @@ function TableCheckMinWidth($maxwidth, $forcewrap = 0, $textbuffer = [], $checkl // mPDF 6 // If overflow==wrap ($checkletter) OR (No word breaks and contains CJK) - if ($checkletter || (!preg_match('/(' . self::ZERO_WIDTH_SPACE_UTF8 . '| )/', trim($line)) && preg_match("/([" . $this->pregCJKchars . "])/u", $line) )) { + $textparam = isset($chunk[9]) ? $chunk[9] : null; + $breaksLetters = WordBreak::narrowsMinContent($textparam); + if ($breaksLetters) { + // The chunk's letters do not join a word the chunk before it ends with + $acclength = 0; + } + if ($checkletter || $breaksLetters || (!WordBreak::keepsAll($textparam) && !preg_match('/(' . self::ZERO_WIDTH_SPACE_UTF8 . '| )/', trim($line)) && preg_match("/([" . $this->pregCJKchars . "])/u", $line) )) { if (preg_match("/([" . $this->pregCJKchars . "])/u", $line)) { $checkCJK = true; } else { diff --git a/tests/Mpdf/WordBreakTest.php b/tests/Mpdf/WordBreakTest.php new file mode 100644 index 000000000..15408e898 --- /dev/null +++ b/tests/Mpdf/WordBreakTest.php @@ -0,0 +1,361 @@ +lines('
see ' . self::URL . ' now
'); + + $this->assertUrlLines($expected, $lines); + } + + /** + * @return array[] Declarations, and whether the URL overflows the line, is cut at its edge, or fills the first line + */ + public function providerBlocks() + { + return [ + 'neither set' => ['', 'cut'], + 'word-break: normal' => ['word-break: normal', 'overflows'], + 'word-break: keep-all' => ['word-break: keep-all', 'overflows'], + 'word-break: break-all' => ['word-break: break-all', 'filled'], + 'word-break: break-word' => ['word-break: break-word', 'cut'], + 'overflow-wrap: normal' => ['overflow-wrap: normal', 'overflows'], + 'overflow-wrap: break-word' => ['overflow-wrap: break-word', 'cut'], + 'overflow-wrap: anywhere' => ['overflow-wrap: anywhere', 'cut'], + 'word-wrap: normal' => ['word-wrap: normal', 'overflows'], + 'word-wrap: break-word' => ['word-wrap: break-word', 'cut'], + 'keep-all with overflow-wrap: anywhere' => ['word-break: keep-all; overflow-wrap: anywhere', 'cut'], + 'in capitals' => ['WORD-BREAK: BREAK-ALL', 'filled'], + 'the later of word-wrap and overflow-wrap' => ['overflow-wrap: anywhere; word-wrap: normal', 'overflows'], + 'an invalid value' => ['word-break: sometimes', 'cut'], + 'initial' => ['overflow-wrap: initial', 'overflows'], + ]; + } + + /** + * The lines of a URL in a 30mm table cell, under each value of the properties. The table is laid out at 30mm only + * when the column may be narrower than the URL + * + * @dataProvider providerCells + * + * @param string $css The cell's declarations + * @param bool $narrowed Whether the column is narrowed to the table's width + */ + public function testAUrlInACell($css, $narrowed) + { + $html = '
' . self::URL . '
'; + $lines = $this->lines($html); + + if ($narrowed) { + $this->assertGreaterThan(1, count($lines)); + $this->assertSame(self::URL, implode('', array_column($lines, 0))); + foreach ($lines as $line) { + $this->assertLessThan(30, $line[1]); + } + } else { + $this->assertSame([self::URL], array_column($lines, 0)); + } + } + + /** + * @return array[] Declarations, and whether the column is narrowed + */ + public function providerCells() + { + return [ + 'neither set' => ['', false], + 'word-break: normal' => ['word-break: normal', false], + 'word-break: keep-all' => ['word-break: keep-all', false], + 'word-break: break-all' => ['word-break: break-all', true], + 'word-break: break-word' => ['word-break: break-word', true], + 'overflow-wrap: normal' => ['overflow-wrap: normal', false], + 'overflow-wrap: break-word' => ['overflow-wrap: break-word', false], + 'overflow-wrap: anywhere' => ['overflow-wrap: anywhere', true], + 'word-wrap: anywhere' => ['word-wrap: anywhere', true], + ]; + } + + /** + * Both properties are inherited: by a child block, an inline element, a table's cells and a cell's child block. A + * sibling of the element that sets them keeps the default + * + * @dataProvider providerInheritance + * + * @param string $html A document with the URL in a 30mm box, with {URL} for the URL + * @param string $expected overflows, cut or filled + */ + public function testIsInherited($html, $expected) + { + $lines = array_filter($this->lines(strtr($html, ['{URL}' => 'see ' . self::URL . ' now'])), function ($line) { + return $line[0] !== 'x'; + }); + + $this->assertUrlLines($expected, array_values($lines)); + } + + /** + * @return array[] Documents, and whether the URL overflows the line, is cut at its edge, or fills the first line + */ + public function providerInheritance() + { + return [ + 'to a child block' => ['

{URL}

', 'filled'], + 'to an inline element' => ['
{URL}
', 'overflows'], + 'to a table\'s cells' => ['
{URL}
', 'filled'], + 'to a cell\'s child block' => ['
{URL}
', 'filled'], + 'a child sets its own' => ['

{URL}

', 'cut'], + 'inherit' => ['

{URL}

', 'cut'], + 'not to a sibling' => ['
x
{URL}
', 'cut'], + ]; + } + + /** + * An overflowing line keeps its width: justification does not squeeze it to fit. It starts at the start of the line + * whatever the alignment, so it runs out of the right of an ltr block and out of the left of an rtl one, which sits + * against the right margin + * + * @dataProvider providerOverflowingLines + * + * @param string $css + * @param string $edge left or right, the edge of the block the line is drawn from + */ + public function testAnOverflowingLineIsDrawnFromItsStart($css, $edge) + { + $word = 'Supercalifragilisticexpialidocious'; + $natural = $this->lines('
' . $word . '
'); + + foreach (['last line' => $word, 'line before another' => $word . ' and more'] as $case => $text) { + $lines = $this->lines('
' . $text . '
'); + + $this->assertSame($word, $lines[0][0], $case); + $this->assertEqualsWithDelta($natural[0][1], $lines[0][1], 0.01, $case); + if ($edge === 'left') { + $this->assertEqualsWithDelta(15, $lines[0][2], 0.01, $case); + } else { + $this->assertEqualsWithDelta(195, $lines[0][2] + $lines[0][1], 0.01, $case); + } + } + } + + /** + * @return array[] Declarations of direction and alignment, and the edge the line is drawn from + */ + public function providerOverflowingLines() + { + return [ + 'left' => ['', 'left'], + 'justified' => ['text-align: justify', 'left'], + 'right' => ['text-align: right', 'left'], + 'centred' => ['text-align: center', 'left'], + 'rtl' => ['direction: rtl', 'right'], + 'rtl, left' => ['direction: rtl; text-align: left', 'right'], + ]; + } + + /** + * CJK text breaks between any two letters, as before, unless word-break is keep-all: then it breaks only after + * punctuation, and a run too long for the line overflows it, or is cut at the edge with overflow-wrap: anywhere + */ + public function testKeepAllKeepsCjkLettersTogether() + { + $html = '
' . self::CJK . '
'; + + $default = $this->lineTexts(strtr($html, ['{CSS}' => ''])); + $this->assertSame(['日本語の文章を', '書きます。これ', 'は長い文です。'], $default); + $this->assertSame($default, $this->lineTexts(strtr($html, ['{CSS}' => 'word-break: break-all']))); + $this->assertSame($default, $this->lineTexts(strtr($html, ['{CSS}' => 'word-break: keep-all']), CssMode::LEGACY)); + + $this->assertSame(['日本語の文章を書きます。', 'これは長い文です。'], $this->lineTexts(strtr($html, ['{CSS}' => 'word-break: keep-all']))); + $this->assertSame( + ['日本語の文章を', '書きます。', 'これは長い文で', 'す。'], + $this->lineTexts(strtr($html, ['{CSS}' => 'word-break: keep-all; overflow-wrap: anywhere'])) + ); + } + + /** + * keep-all keeps CJK letters together in a table cell, so the cell is as wide as its longest run of them + */ + public function testKeepAllWidensACellToItsCjkText() + { + $html = '
' . self::CJK . '' . str_repeat('x ', 200) . '
'; + + $this->assertGreaterThan(1, count($this->lineTexts(strtr($html, ['{CSS}' => ''])))); + $this->assertSame([self::CJK], array_slice($this->lineTexts(strtr($html, ['{CSS}' => 'word-break: keep-all'])), 0, 1)); + } + + /** + * Under cssMode legacy the properties are ignored: every line is drawn as without them + */ + public function testLegacyIgnoresTheProperties() + { + $text = 'see ' . self::URL . ' now ' . self::CJK; + $plain = '
' . $text . '
' . $text . '
'; + $expected = $this->lines($plain, CssMode::LEGACY); + + foreach (['word-break: break-all', 'word-break: keep-all', 'overflow-wrap: normal', 'overflow-wrap: anywhere', 'word-wrap: normal'] as $css) { + $html = str_replace('style="width: 30mm"', 'style="width: 30mm; ' . $css . '"', $plain); + $this->assertSame($expected, $this->lines($html, CssMode::LEGACY), $css); + } + } + + /** + * word-wrap is read as overflow-wrap, and only the values the properties take are kept, under cssMode standard; + * under legacy the declarations are left as they are written + */ + public function testNormalisesTheDeclarations() + { + $standard = $this->normalizer(CssMode::STANDARD); + $this->assertSame( + ['OVERFLOW-WRAP' => 'anywhere', 'WORD-BREAK' => 'keep-all'], + $standard->normalize(['WORD-WRAP' => 'Anywhere', 'WORD-BREAK' => 'keep-all', 'OVERFLOW-WRAP' => 'nowhere']) + ); + $this->assertSame(['OVERFLOW-WRAP' => 'inherit'], $standard->normalize(['WORD-WRAP' => 'inherit'])); + + $this->assertSame(['WORD-WRAP' => 'anywhere'], $this->normalizer(CssMode::LEGACY)->normalize(['WORD-WRAP' => 'anywhere'])); + } + + /** + * @param string $mode + * + * @return NormalizeProperties Over a document in that CSS mode + */ + private function normalizer($mode) + { + $mpdf = new Mpdf(['cssMode' => $mode]); + $colorModeConverter = new ColorModeConverter(); + $colorConverter = new ColorConverter($mpdf, $colorModeConverter, new ColorSpaceRestrictor($mpdf, $colorModeConverter)); + + return new NormalizeProperties($mpdf, new SizeConverter(96, 11, $mpdf, new NullLogger()), $colorConverter); + } + + /** + * Where each property's values let a line break + */ + public function testWhereTheValuesBreak() + { + $this->assertTrue(WordBreak::breaksLongWords([])); + $this->assertTrue(WordBreak::breaksLongWords(null)); + $this->assertFalse(WordBreak::breaksLongWords(['word-break' => 'normal'])); + $this->assertFalse(WordBreak::breaksLongWords(['overflow-wrap' => 'normal', 'hyphens' => 1])); + $this->assertTrue(WordBreak::breaksLongWords(['overflow-wrap' => 'break-word'])); + + $this->assertTrue(WordBreak::narrowsMinContent(['overflow-wrap' => 'anywhere'])); + $this->assertFalse(WordBreak::narrowsMinContent(['overflow-wrap' => 'break-word'])); + $this->assertFalse(WordBreak::narrowsMinContent([])); + + $this->assertTrue(WordBreak::breaksAll(['word-break' => 'break-all'])); + $this->assertFalse(WordBreak::keepsAll(['word-break' => 'break-all'])); + $this->assertTrue(WordBreak::keepsAll(['word-break' => 'keep-all'])); + } + + /** + * Asserts how the URL, between "see" and "now", is laid out in a 30mm box starting at the left margin + * + * @param string $expected overflows: on a line of its own, wider than the box; cut: across lines no wider than the + * box, starting on a line of its own; filled: across lines no wider than the box, starting on the line with "see" + * @param array[] $lines As lines() gives them + */ + private function assertUrlLines($expected, array $lines) + { + $texts = array_column($lines, 0); + $this->assertSame('see' . self::URL . 'now', str_replace(' ', '', implode('', $texts))); + + if ($expected === 'overflows') { + $this->assertSame(['see', self::URL, 'now'], $texts); + $this->assertGreaterThan(30, $lines[1][1]); + + return; + } + + foreach ($lines as $line) { + $this->assertLessThanOrEqual(30.01, $line[1]); + } + if ($expected === 'cut') { + $this->assertSame('see', $texts[0]); + $this->assertGreaterThan(3, count($texts)); + } else { + $this->assertStringStartsWith('see https', $texts[0]); + $this->assertGreaterThan(25, $lines[0][1]); + } + } + + /** + * @param string $html + * @param string $mode + * + * @return string[] The text of each line + */ + private function lineTexts($html, $mode = CssMode::STANDARD) + { + return array_column($this->lines($html, $mode), 0); + } + + /** + * @param string $html + * @param string $mode + * + * @return array[] Each line drawn: its text, its width and its left edge, in mm. CJK text is drawn in pieces, one + * for each change of font, which are joined into their line + */ + private function lines($html, $mode = CssMode::STANDARD) + { + $mpdf = new TextRecordingMpdf(['mode' => 'utf-8', 'cssMode' => $mode, 'autoScriptToLang' => true, 'autoLangToFont' => true]); + $mpdf->WriteHTML($html); + + $lines = []; + foreach ($mpdf->drawnText as $i => $text) { + list($page, $left, $right, $top) = $mpdf->drawnBoxes[$i]; + $key = $page . ':' . round($top, 3); + if (isset($lines[$key])) { + $lines[$key][0] .= $text; + $lines[$key][1] = max($lines[$key][1], $right); + $lines[$key][2] = min($lines[$key][2], $left); + } else { + $lines[$key] = [$text, $right, $left]; + } + } + + return array_values(array_map(function ($line) { + return [$line[0], round($line[1] - $line[2], 3), round($line[2], 3)]; + }, $lines)); + } +} diff --git a/tests/Snapshots/WordBreakSnapshotTest.php b/tests/Snapshots/WordBreakSnapshotTest.php new file mode 100644 index 000000000..e126d5fd2 --- /dev/null +++ b/tests/Snapshots/WordBreakSnapshotTest.php @@ -0,0 +1,107 @@ + [ + ['', $url, 'Neither property set: the URL is cut where each line is full, as before.', 'block'], + ['overflow-wrap: normal', $url, 'overflow-wrap: normal: the URL on a line of its own, running out of the right of the box.', 'block'], + ['word-break: keep-all', $url, 'word-break: keep-all: the same, running out of the box.', 'block'], + ['overflow-wrap: break-word', $url, 'overflow-wrap: break-word: the URL starts on its own line and is cut at the edge.', 'block'], + ['word-wrap: anywhere', $url, 'word-wrap: anywhere: the same, read as overflow-wrap.', 'block'], + ['word-break: break-all', $url, 'word-break: break-all: the URL starts after "see" and every line is full to the edge.', 'block'], + ['overflow-wrap: normal; text-align: right', $url, 'Right-aligned: "see" and "now" at the right, the overflowing URL still starting at the left.', 'block'], + ['overflow-wrap: normal; direction: rtl', 'Supercalifragilisticexpialidocious', 'rtl: the word ends at the right of the box and runs out of its left.', 'block'], + ], + 'A URL in the cell of a 30mm table' => [ + ['', $url, 'Neither property set: the table widens to hold the URL on one line.', 'cell'], + ['overflow-wrap: break-word', $url, 'overflow-wrap: break-word: the table widens the same.', 'cell'], + ['overflow-wrap: anywhere', $url, 'overflow-wrap: anywhere: the table stays 30mm wide and the URL is cut at its edge.', 'cell'], + ['word-break: break-all', $url, 'word-break: break-all: the table stays 30mm wide and each line is full.', 'cell'], + ], + 'CJK text in a 30mm block' => [ + ['', $cjk, 'Neither property set: broken between letters, each line full.', 'block'], + ['word-break: keep-all', $cjk, 'word-break: keep-all: broken only after the full stop, the first sentence running out of the box.', 'block'], + ['word-break: keep-all; overflow-wrap: anywhere', $cjk, 'keep-all with overflow-wrap: anywhere: the first sentence cut at the edge, the second starting a line.', 'block'], + ], + ]; + } + + /** + * Each case's box under its caption, with a grey border to show the box's edges + */ + public function generatePdf() + { + $html = ' +

word-break and overflow-wrap

'; + + foreach ($this->cases() as $heading => $cases) { + $html .= '

' . $heading . '

'; + foreach ($cases as $case) { + list($css, $text, $caption, $box) = $case; + $html .= '

' . htmlspecialchars($caption) . '

'; + if ($box === 'cell') { + $html .= '
' . $text . '
'; + } else { + $html .= '
' . $text . '
'; + } + } + } + + $this->mpdf = $this->createMpdf(['cssMode' => CssMode::STANDARD, 'autoScriptToLang' => true, 'autoLangToFont' => true]); + + $this->mpdf->WriteHTML($html); + } +} diff --git a/tests/data/snapshots/word-break.pdf b/tests/data/snapshots/word-break.pdf new file mode 100644 index 000000000..7ce6a4a01 Binary files /dev/null and b/tests/data/snapshots/word-break.pdf differ