` 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 = '
';
+ $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' => ['
', 'filled'],
+ 'to an inline element' => ['
{URL}
', 'overflows'],
+ 'to a table\'s cells' => ['
', 'filled'],
+ 'to a cell\'s child block' => ['
', 'filled'],
+ 'a child sets its own' => ['
', 'cut'],
+ 'inherit' => ['
', '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 . '
';
+ $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 .= '
';
+ } 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