From 8ab1fb658affce739fcfb27d20451bac6668e588 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 12 Oct 2026 08:20:25 +1100 Subject: [PATCH 1/2] Break or keep long words and CJK text by word-break and overflow-wrap (#607) mPDF ignored word-break, overflow-wrap and word-wrap, and always cut a word too long for its line where the line was full. In a table cell the column widened to hold the longest word, whatever the style said. Under cssMode standard both properties are now read and inherited, with word-wrap as overflow-wrap. With neither set, a long word is still cut at the edge, the better default on paper. Once either is set, the word overflows the line unless they let it break: break-all fills each line to the edge, break-word and anywhere cut the word at the edge, and keep-all stops CJK text breaking between its letters. An overflowing line keeps its width and starts at the start of the line. In a table cell break-all, anywhere and word-break: break-word narrow the column to its widest letter. Legacy mode ignores the properties as before. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-GRAVITYPDF.md | 3 + src/Css/CssWideKeywords.php | 2 + src/Css/InheritedProperties.php | 12 +- src/Css/NormalizeProperties.php | 23 ++ src/Css/WordBreak.php | 112 +++++++ src/Mpdf.php | 66 +++- tests/Mpdf/WordBreakTest.php | 361 ++++++++++++++++++++++ tests/Snapshots/WordBreakSnapshotTest.php | 107 +++++++ tests/data/snapshots/word-break.pdf | Bin 0 -> 69768 bytes 9 files changed, 678 insertions(+), 8 deletions(-) create mode 100644 src/Css/WordBreak.php create mode 100644 tests/Mpdf/WordBreakTest.php create mode 100644 tests/Snapshots/WordBreakSnapshotTest.php create mode 100644 tests/data/snapshots/word-break.pdf 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..0e4be41ff --- /dev/null +++ b/src/Css/WordBreak.php @@ -0,0 +1,112 @@ + ['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'; + } + + /** + * @param array|null $textparam + * + * @return string + */ + private static function wordBreak($textparam) + { + return isset($textparam['word-break']) ? $textparam['word-break'] : 'normal'; + } + + /** + * @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..e5b32bbce 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; @@ -74,6 +75,12 @@ class Mpdf implements \Psr\Log\LoggerAwareInterface */ const SCRIPT_RUN_ATTRIBUTE = 'DATA-MPDF-SCRIPT-RUN'; + /** + * CJK punctuation, as a character class for preg_match() with /u. Under word-break: keep-all a line of CJK text + * breaks only beside one of these, besides at spaces + */ + const CJK_PUNCTUATION = '\x{3001}-\x{3003}\x{3008}-\x{3011}\x{3014}-\x{301f}\x{fe10}-\x{fe19}\x{fe30}-\x{fe6b}\x{ff01}-\x{ff0f}\x{ff1a}-\x{ff20}\x{ff3b}-\x{ff40}\x{ff5b}-\x{ff65}'; + /** * A zero-width space (U+200B) in UTF-8, as Unicode-font text carries it */ @@ -7355,6 +7362,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 +7792,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 +7851,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; @@ -9088,6 +9104,7 @@ function WriteFlowingBlock($s, $sOTLdata) $Oikomi = false; $breakfound = true; } + $keepAll = WordBreak::keepsAll($this->textparam); if (!$this->usingCoreFont && !$breakfound && $this->checkCJK) { // Get next/following character (in this chunk) @@ -9107,7 +9124,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 +9139,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 !== ' ' && WordBreak::breaksAll($this->textparam)) { + $breakfound = true; + } + /* -- HYPHENATION -- */ // AUTOMATIC HYPHENATION @@ -9217,6 +9240,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('/[' . self::CJK_PUNCTUATION . ']/u', $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 +9256,15 @@ 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 && !WordBreak::breaksLongWords($this->textparam)) { + $lineCount--; + $this->flowingBlockAttr['overflowing'] = true; + $currContent .= $c; + $contentWidth += $cw; + continue; + } + // If a line-break opportunity found: if (is_array($breakfound)) { $contentctr = $breakfound[0]; @@ -9491,7 +9524,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 +9631,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 +9800,7 @@ function WriteFlowingBlock($s, $sOTLdata) // Reset lineheight $stackHeight = $this->divheight; $valign = 'M'; + $this->flowingBlockAttr['overflowing'] = false; $font = []; $content = []; @@ -20118,6 +20159,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 +20493,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 0000000000000000000000000000000000000000..7ce6a4a010810dabb503184c9e8dd337e6d5f7f1 GIT binary patch literal 69768 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 Hyx9K(40O}g literal 0 HcmV?d00001 From 9da256b8a8251f0cfe23d48251ca6fc57b31eeb5 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 12 Oct 2026 08:24:08 +1100 Subject: [PATCH 2/2] Search an overflowing word once and keep the keep-all punctuation with WordBreak (#607) Co-Authored-By: Claude Opus 5.5 --- src/Css/WordBreak.php | 10 ++++++++++ src/Mpdf.php | 24 ++++++++++++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/Css/WordBreak.php b/src/Css/WordBreak.php index 0e4be41ff..ce2dec0bc 100644 --- a/src/Css/WordBreak.php +++ b/src/Css/WordBreak.php @@ -13,6 +13,12 @@ final class WordBreak { + /** + * CJK punctuation, as a pattern for preg_match(). Under word-break: keep-all a line of CJK text breaks only beside + * one of these, or at a space + */ + const CJK_PUNCTUATION = '/[\x{3001}-\x{3003}\x{3008}-\x{3011}\x{3014}-\x{301f}\x{fe10}-\x{fe19}\x{fe30}-\x{fe6b}\x{ff01}-\x{ff0f}\x{ff1a}-\x{ff20}\x{ff3b}-\x{ff40}\x{ff5b}-\x{ff65}]/u'; + /** * The values each property takes, as NormalizeProperties keeps them */ @@ -91,6 +97,8 @@ public static function narrowsMinContent($textparam) } /** + * The word-break the text is set in, normal when no element sets it + * * @param array|null $textparam * * @return string @@ -101,6 +109,8 @@ private static function wordBreak($textparam) } /** + * The overflow-wrap the text is set in, normal when no element sets it + * * @param array|null $textparam * * @return string diff --git a/src/Mpdf.php b/src/Mpdf.php index e5b32bbce..0196e7797 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -75,12 +75,6 @@ class Mpdf implements \Psr\Log\LoggerAwareInterface */ const SCRIPT_RUN_ATTRIBUTE = 'DATA-MPDF-SCRIPT-RUN'; - /** - * CJK punctuation, as a character class for preg_match() with /u. Under word-break: keep-all a line of CJK text - * breaks only beside one of these, besides at spaces - */ - const CJK_PUNCTUATION = '\x{3001}-\x{3003}\x{3008}-\x{3011}\x{3014}-\x{301f}\x{fe10}-\x{fe19}\x{fe30}-\x{fe6b}\x{ff01}-\x{ff0f}\x{ff1a}-\x{ff20}\x{ff3b}-\x{ff40}\x{ff5b}-\x{ff65}'; - /** * A zero-width space (U+200B) in UTF-8, as Unicode-font text carries it */ @@ -8871,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) { @@ -9104,7 +9102,6 @@ function WriteFlowingBlock($s, $sOTLdata) $Oikomi = false; $breakfound = true; } - $keepAll = WordBreak::keepsAll($this->textparam); if (!$this->usingCoreFont && !$breakfound && $this->checkCJK) { // Get next/following character (in this chunk) @@ -9141,7 +9138,7 @@ 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 !== ' ' && WordBreak::breaksAll($this->textparam)) { + if (!$breakfound && $prevchar !== false && $prevchar !== ' ' && $breakAll) { $breakfound = true; } @@ -9184,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 ///////////////////// @@ -9240,7 +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('/[' . self::CJK_PUNCTUATION . ']/u', $prevchar . $checkchar)) && + (!$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']; } @@ -9257,9 +9260,10 @@ function WriteFlowingBlock($s, $sOTLdata) } // A word with nowhere to break overflows the line, unless word-break or overflow-wrap lets it break - if ($breakfound === false && !WordBreak::breaksLongWords($this->textparam)) { + if ($breakfound === false && !$breaksLongWords) { $lineCount--; $this->flowingBlockAttr['overflowing'] = true; + $this->flowingBlockAttr['overflowSearched'] = $lineEnd; $currContent .= $c; $contentWidth += $cw; continue;