612 lines
20 KiB
PHP
Executable File
612 lines
20 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* @package dompdf
|
|
* @link https://github.com/dompdf/dompdf
|
|
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
|
|
*/
|
|
namespace Dompdf\FrameReflower;
|
|
|
|
use Dompdf\FrameDecorator\Block as BlockFrameDecorator;
|
|
use Dompdf\FrameDecorator\Inline as InlineFrameDecorator;
|
|
use Dompdf\FrameDecorator\Text as TextFrameDecorator;
|
|
use Dompdf\FontMetrics;
|
|
use Dompdf\Helpers;
|
|
|
|
/**
|
|
* Reflows text frames.
|
|
*
|
|
* @package dompdf
|
|
*/
|
|
class Text extends AbstractFrameReflower
|
|
{
|
|
/**
|
|
* PHP string representation of HTML entity <shy>
|
|
*/
|
|
const SOFT_HYPHEN = "\xC2\xAD";
|
|
|
|
/**
|
|
* The regex splits on everything that's a separator (^\S double negative),
|
|
* excluding the following non-breaking space characters:
|
|
* * nbsp (\xA0)
|
|
* * narrow nbsp (\x{202F})
|
|
* * figure space (\x{2007})
|
|
*/
|
|
public static $_whitespace_pattern = '/([^\S\xA0\x{202F}\x{2007}]+)/u';
|
|
|
|
/**
|
|
* The regex splits on everything that's a separator (^\S double negative)
|
|
* plus dashes, excluding the following non-breaking space characters:
|
|
* * nbsp (\xA0)
|
|
* * narrow nbsp (\x{202F})
|
|
* * figure space (\x{2007})
|
|
*/
|
|
public static $_wordbreak_pattern = '/([^\S\xA0\x{202F}\x{2007}\n]+|\R|\-+|\xAD+)/u';
|
|
|
|
/**
|
|
* Frame for this reflower
|
|
*
|
|
* @var TextFrameDecorator
|
|
*/
|
|
protected $_frame;
|
|
|
|
/**
|
|
* Saves trailing whitespace trimmed after a line break, so it can be
|
|
* restored when needed.
|
|
*
|
|
* @var string|null
|
|
*/
|
|
protected $trailingWs = null;
|
|
|
|
/**
|
|
* @var FontMetrics
|
|
*/
|
|
private $fontMetrics;
|
|
|
|
/**
|
|
* @param TextFrameDecorator $frame
|
|
* @param FontMetrics $fontMetrics
|
|
*/
|
|
public function __construct(TextFrameDecorator $frame, FontMetrics $fontMetrics)
|
|
{
|
|
parent::__construct($frame);
|
|
$this->setFontMetrics($fontMetrics);
|
|
}
|
|
|
|
/**
|
|
* Apply text transform and white-space collapse according to style.
|
|
*
|
|
* * http://www.w3.org/TR/CSS21/text.html#propdef-text-transform
|
|
* * http://www.w3.org/TR/CSS21/text.html#propdef-white-space
|
|
*
|
|
* @param string $text
|
|
* @return string
|
|
*/
|
|
protected function pre_process_text(string $text): string
|
|
{
|
|
$style = $this->_frame->get_style();
|
|
|
|
// Handle text transform
|
|
switch ($style->text_transform) {
|
|
case "capitalize":
|
|
$text = Helpers::mb_ucwords($text);
|
|
break;
|
|
case "uppercase":
|
|
$text = mb_convert_case($text, MB_CASE_UPPER);
|
|
break;
|
|
case "lowercase":
|
|
$text = mb_convert_case($text, MB_CASE_LOWER);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
// Handle white-space collapse
|
|
switch ($style->white_space) {
|
|
default:
|
|
case "normal":
|
|
case "nowrap":
|
|
$text = preg_replace(self::$_whitespace_pattern, " ", $text) ?? "";
|
|
break;
|
|
|
|
case "pre-line":
|
|
// Collapse white space except for line breaks
|
|
$text = preg_replace('/([^\S\xA0\x{202F}\x{2007}\n]+)/u', " ", $text) ?? "";
|
|
break;
|
|
|
|
case "pre":
|
|
case "pre-wrap":
|
|
break;
|
|
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
|
|
/**
|
|
* @param string $text
|
|
* @param BlockFrameDecorator $block
|
|
* @param bool $nowrap
|
|
*
|
|
* @return bool|int
|
|
*/
|
|
protected function line_break(string $text, BlockFrameDecorator $block, bool $nowrap = false)
|
|
{
|
|
$fontMetrics = $this->getFontMetrics();
|
|
$frame = $this->_frame;
|
|
$style = $frame->get_style();
|
|
$font = $style->font_family;
|
|
$size = $style->font_size;
|
|
$word_spacing = $style->word_spacing;
|
|
$letter_spacing = $style->letter_spacing;
|
|
|
|
// Determine the available width
|
|
$current_line = $block->get_current_line_box();
|
|
$line_width = $frame->get_containing_block("w");
|
|
$current_line_width = $current_line->left + $current_line->w + $current_line->right;
|
|
$available_width = $line_width - $current_line_width;
|
|
|
|
// Determine the frame width including margin, padding & border
|
|
$visible_text = preg_replace('/\xAD/u', "", $text);
|
|
$text_width = $fontMetrics->getTextWidth($visible_text, $font, $size, $word_spacing, $letter_spacing);
|
|
$mbp_width = (float) $style->length_in_pt([
|
|
$style->margin_left,
|
|
$style->border_left_width,
|
|
$style->padding_left,
|
|
$style->padding_right,
|
|
$style->border_right_width,
|
|
$style->margin_right
|
|
], $line_width);
|
|
$frame_width = $text_width + $mbp_width;
|
|
|
|
if (Helpers::lengthLessOrEqual($frame_width, $available_width)) {
|
|
return false;
|
|
}
|
|
|
|
if ($nowrap) {
|
|
return $current_line_width == 0 ? false : 0;
|
|
}
|
|
|
|
// Split the text into words
|
|
$words = preg_split(self::$_wordbreak_pattern, $text, -1, PREG_SPLIT_DELIM_CAPTURE);
|
|
$wc = count($words);
|
|
|
|
// Determine the split point
|
|
$width = 0.0;
|
|
$str = "";
|
|
|
|
$space_width = $fontMetrics->getTextWidth(" ", $font, $size, $word_spacing, $letter_spacing);
|
|
$shy_width = $fontMetrics->getTextWidth(self::SOFT_HYPHEN, $font, $size);
|
|
|
|
// @todo support <wbr>
|
|
for ($i = 0; $i < $wc; $i += 2) {
|
|
// Allow trailing white space to overflow. White space is always
|
|
// collapsed to the standard space character currently, so only
|
|
// handle that for now
|
|
$sep = $words[$i + 1] ?? "";
|
|
$word = $sep === " " ? $words[$i] : $words[$i] . $sep;
|
|
$word_width = $fontMetrics->getTextWidth($word, $font, $size, $word_spacing, $letter_spacing);
|
|
$used_width = $width + $word_width + $mbp_width;
|
|
|
|
if (Helpers::lengthGreater($used_width, $available_width)) {
|
|
// If the previous split happened by soft hyphen, we have to
|
|
// append its width again because the last hyphen of a line
|
|
// won't be removed
|
|
if (isset($words[$i - 1]) && self::SOFT_HYPHEN === $words[$i - 1]) {
|
|
$width += $shy_width;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// If the word is splitted by soft hyphen, but no line break is needed
|
|
// we have to reduce the width. But the str is not modified, otherwise
|
|
// the wrong offset is calculated at the end of this method.
|
|
if ($sep === self::SOFT_HYPHEN) {
|
|
$width += $word_width - $shy_width;
|
|
$str .= $word;
|
|
} elseif ($sep === " ") {
|
|
$width += $word_width + $space_width;
|
|
$str .= $word . $sep;
|
|
} else {
|
|
$width += $word_width;
|
|
$str .= $word;
|
|
}
|
|
}
|
|
|
|
// The first word has overflowed. Force it onto the line, or as many
|
|
// characters as fit if breaking words is allowed
|
|
if ($current_line_width == 0 && $width === 0.0) {
|
|
if ($sep === " ") {
|
|
$word .= $sep;
|
|
}
|
|
|
|
// https://www.w3.org/TR/css-text-3/#overflow-wrap-property
|
|
$wrap = $style->overflow_wrap;
|
|
$break_word = $wrap === "anywhere" || $wrap === "break-word";
|
|
|
|
if ($break_word) {
|
|
$s = "";
|
|
|
|
for ($j = 0; $j < mb_strlen($word); $j++) {
|
|
$c = mb_substr($word, $j, 1);
|
|
$w = $fontMetrics->getTextWidth($s . $c, $font, $size, $word_spacing, $letter_spacing);
|
|
|
|
if (Helpers::lengthGreater($w, $available_width)) {
|
|
break;
|
|
}
|
|
|
|
$s .= $c;
|
|
}
|
|
|
|
// Always force the first character onto the line
|
|
$str = $j === 0 ? $s . $c : $s;
|
|
} else {
|
|
$str = $word;
|
|
}
|
|
}
|
|
|
|
$offset = mb_strlen($str);
|
|
return $offset;
|
|
}
|
|
|
|
/**
|
|
* @param string $text
|
|
* @return bool|int
|
|
*/
|
|
protected function newline_break(string $text)
|
|
{
|
|
if (($i = mb_strpos($text, "\n")) === false) {
|
|
return false;
|
|
}
|
|
|
|
return $i + 1;
|
|
}
|
|
|
|
/**
|
|
* @param BlockFrameDecorator $block
|
|
* @return bool|null Whether to add a new line at the end. `null` if reflow
|
|
* should be stopped.
|
|
*/
|
|
protected function layout_line(BlockFrameDecorator $block): ?bool
|
|
{
|
|
$frame = $this->_frame;
|
|
$style = $frame->get_style();
|
|
$current_line = $block->get_current_line_box();
|
|
$text = $frame->get_text();
|
|
|
|
// Trim leading white space if this is the first text on the line
|
|
if ($current_line->w === 0.0 && !$frame->is_pre()) {
|
|
$text = ltrim($text, " ");
|
|
}
|
|
|
|
if ($text === "") {
|
|
$frame->set_text("");
|
|
$style->set_used("width", 0.0);
|
|
return false;
|
|
}
|
|
|
|
// Determine the next line break
|
|
// http://www.w3.org/TR/CSS21/text.html#propdef-white-space
|
|
$white_space = $style->white_space;
|
|
$nowrap = $white_space === "nowrap" || $white_space === "pre";
|
|
|
|
switch ($white_space) {
|
|
default:
|
|
case "normal":
|
|
case "nowrap":
|
|
$split = $this->line_break($text, $block, $nowrap);
|
|
$add_line = false;
|
|
break;
|
|
|
|
case "pre":
|
|
case "pre-line":
|
|
case "pre-wrap":
|
|
$hard_split = $this->newline_break($text);
|
|
$first_line = $hard_split !== false
|
|
? mb_substr($text, 0, $hard_split)
|
|
: $text;
|
|
$soft_split = $this->line_break($first_line, $block, $nowrap);
|
|
|
|
$split = $soft_split !== false ? $soft_split : $hard_split;
|
|
$add_line = $hard_split !== false;
|
|
break;
|
|
}
|
|
|
|
if ($split === 0) {
|
|
// Make sure to move text when floating frames leave no space to
|
|
// place anything onto the line
|
|
// TODO: Would probably be better to move just below the current
|
|
// floating frame instead of trying to place text in line-height
|
|
// increments
|
|
if ($current_line->h === 0.0) {
|
|
// Line height might be 0
|
|
$h = max($frame->get_margin_height(), 1.0);
|
|
$block->maximize_line_height($h, $frame);
|
|
}
|
|
|
|
// Break line and repeat layout
|
|
$block->add_line();
|
|
|
|
// Find the appropriate inline ancestor to split
|
|
$child = $frame;
|
|
$p = $child->get_parent();
|
|
while ($p instanceof InlineFrameDecorator && !$child->get_prev_sibling()) {
|
|
$child = $p;
|
|
$p = $p->get_parent();
|
|
}
|
|
|
|
if ($p instanceof InlineFrameDecorator) {
|
|
// Split parent and stop current reflow. Reflow continues
|
|
// via child-reflow loop of split parent
|
|
$p->split($child);
|
|
return null;
|
|
}
|
|
|
|
return $this->layout_line($block);
|
|
}
|
|
|
|
// Final split point is determined
|
|
if ($split !== false && $split < mb_strlen($text)) {
|
|
// Split the line
|
|
$frame->set_text($text);
|
|
$frame->split_text($split);
|
|
$add_line = true;
|
|
|
|
// Remove inner soft hyphens
|
|
$t = $frame->get_text();
|
|
$shyPosition = mb_strpos($t, self::SOFT_HYPHEN);
|
|
if (false !== $shyPosition && $shyPosition < mb_strlen($t) - 1) {
|
|
$t = str_replace(self::SOFT_HYPHEN, "", mb_substr($t, 0, -1)) . mb_substr($t, -1);
|
|
$frame->set_text($t);
|
|
}
|
|
} else {
|
|
// No split required
|
|
// Remove soft hyphens
|
|
$text = str_replace(self::SOFT_HYPHEN, "", $text);
|
|
$frame->set_text($text);
|
|
}
|
|
|
|
// Set our new width
|
|
$frame->recalculate_width();
|
|
|
|
return $add_line;
|
|
}
|
|
|
|
/**
|
|
* @param BlockFrameDecorator|null $block
|
|
*/
|
|
function reflow(BlockFrameDecorator $block = null)
|
|
{
|
|
$frame = $this->_frame;
|
|
$page = $frame->get_root();
|
|
$page->check_forced_page_break($frame);
|
|
|
|
if ($page->is_full()) {
|
|
return;
|
|
}
|
|
|
|
// Determine the text height
|
|
$style = $frame->get_style();
|
|
$size = $style->font_size;
|
|
$font = $style->font_family;
|
|
$font_height = $this->getFontMetrics()->getFontHeight($font, $size);
|
|
$style->set_used("height", $font_height);
|
|
|
|
// Handle text transform and white space
|
|
$text = $this->pre_process_text($frame->get_text());
|
|
$frame->set_text($text);
|
|
|
|
if ($block === null) {
|
|
return;
|
|
}
|
|
|
|
$add_line = $this->layout_line($block);
|
|
|
|
if ($add_line === null) {
|
|
return;
|
|
}
|
|
|
|
$frame->position();
|
|
|
|
// Skip wrapped white space between block-level elements in case white
|
|
// space is collapsed
|
|
if ($frame->get_text() === "" && $frame->get_margin_width() === 0.0) {
|
|
return;
|
|
}
|
|
|
|
$line = $block->add_frame_to_line($frame);
|
|
$trimmed = trim($frame->get_text());
|
|
|
|
// Split the text into words (used to determine spacing between
|
|
// words on justified lines)
|
|
if ($trimmed !== "") {
|
|
$words = preg_split(self::$_whitespace_pattern, $trimmed);
|
|
$line->wc += count($words);
|
|
}
|
|
|
|
if ($add_line) {
|
|
$block->add_line();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trim trailing white space from the frame text.
|
|
*/
|
|
public function trim_trailing_ws(): void
|
|
{
|
|
$frame = $this->_frame;
|
|
$text = $frame->get_text();
|
|
$trailing = mb_substr($text, -1);
|
|
|
|
// White space is always collapsed to the standard space character
|
|
// currently, so only handle that for now
|
|
if ($trailing === " ") {
|
|
$this->trailingWs = $trailing;
|
|
$frame->set_text(mb_substr($text, 0, -1));
|
|
$frame->recalculate_width();
|
|
}
|
|
}
|
|
|
|
public function reset(): void
|
|
{
|
|
parent::reset();
|
|
|
|
// Restore trimmed trailing white space, as the frame will go through
|
|
// another reflow and line breaks might be different after a split
|
|
if ($this->trailingWs !== null) {
|
|
$text = $this->_frame->get_text();
|
|
$this->_frame->set_text($text . $this->trailingWs);
|
|
$this->trailingWs = null;
|
|
}
|
|
}
|
|
|
|
//........................................................................
|
|
|
|
public function get_min_max_width(): array
|
|
{
|
|
$fontMetrics = $this->getFontMetrics();
|
|
$frame = $this->_frame;
|
|
$style = $frame->get_style();
|
|
$text = $frame->get_text();
|
|
$font = $style->font_family;
|
|
$size = $style->font_size;
|
|
$word_spacing = $style->word_spacing;
|
|
$letter_spacing = $style->letter_spacing;
|
|
|
|
// Handle text transform and white space
|
|
$text = $this->pre_process_text($frame->get_text());
|
|
|
|
if (!$frame->is_pre()) {
|
|
// Determine whether the frame is at the start of its parent block.
|
|
// Trim leading white space in that case
|
|
$child = $frame;
|
|
$p = $frame->get_parent();
|
|
while (!$p->is_block() && !$child->get_prev_sibling()) {
|
|
$child = $p;
|
|
$p = $p->get_parent();
|
|
}
|
|
|
|
if (!$child->get_prev_sibling()) {
|
|
$text = ltrim($text, " ");
|
|
}
|
|
|
|
// Determine whether the frame is at the end of its parent block.
|
|
// Trim trailing white space in that case
|
|
$child = $frame;
|
|
$p = $frame->get_parent();
|
|
while (!$p->is_block() && !$child->get_next_sibling()) {
|
|
$child = $p;
|
|
$p = $p->get_parent();
|
|
}
|
|
|
|
if (!$child->get_next_sibling()) {
|
|
$text = rtrim($text, " ");
|
|
}
|
|
}
|
|
|
|
// Strip soft hyphens for max-line-width calculations
|
|
$visible_text = preg_replace('/\xAD/u', "", $text);
|
|
|
|
// Determine minimum text width
|
|
switch ($style->white_space) {
|
|
default:
|
|
case "normal":
|
|
case "pre-line":
|
|
case "pre-wrap":
|
|
// The min width is the longest word or, if breaking words is
|
|
// allowed with the `anywhere` keyword, the widest character.
|
|
// For performance reasons, we only check the first character in
|
|
// the latter case.
|
|
// https://www.w3.org/TR/css-text-3/#overflow-wrap-property
|
|
if ($style->overflow_wrap === "anywhere") {
|
|
$char = mb_substr($visible_text, 0, 1);
|
|
$min = $fontMetrics->getTextWidth($char, $font, $size, $word_spacing, $letter_spacing);
|
|
} else {
|
|
// Find the longest word
|
|
$words = preg_split(self::$_wordbreak_pattern, $text, -1, PREG_SPLIT_DELIM_CAPTURE);
|
|
$lengths = array_map(function ($chunk) use ($fontMetrics, $font, $size, $word_spacing, $letter_spacing) {
|
|
// Allow trailing white space to overflow. As in actual
|
|
// layout above, only handle a single space for now
|
|
$sep = $chunk[1] ?? "";
|
|
$word = $sep === " " ? $chunk[0] : $chunk[0] . $sep;
|
|
return $fontMetrics->getTextWidth($word, $font, $size, $word_spacing, $letter_spacing);
|
|
}, array_chunk($words, 2));
|
|
$min = max($lengths);
|
|
}
|
|
break;
|
|
|
|
case "pre":
|
|
// Find the longest line
|
|
$lines = array_flip(preg_split("/\R/u", $visible_text));
|
|
array_walk($lines, function (&$chunked_text_width, $chunked_text) use ($fontMetrics, $font, $size, $word_spacing, $letter_spacing) {
|
|
$chunked_text_width = $fontMetrics->getTextWidth($chunked_text, $font, $size, $word_spacing, $letter_spacing);
|
|
});
|
|
arsort($lines);
|
|
$min = reset($lines);
|
|
break;
|
|
|
|
case "nowrap":
|
|
$min = $fontMetrics->getTextWidth($visible_text, $font, $size, $word_spacing, $letter_spacing);
|
|
break;
|
|
}
|
|
|
|
// Determine maximum text width
|
|
switch ($style->white_space) {
|
|
default:
|
|
case "normal":
|
|
$max = $fontMetrics->getTextWidth($visible_text, $font, $size, $word_spacing, $letter_spacing);
|
|
break;
|
|
|
|
case "pre-line":
|
|
case "pre-wrap":
|
|
// Find the longest line
|
|
$lines = array_flip(preg_split("/\R/u", $visible_text));
|
|
array_walk($lines, function (&$chunked_text_width, $chunked_text) use ($fontMetrics, $font, $size, $word_spacing, $letter_spacing) {
|
|
$chunked_text_width = $fontMetrics->getTextWidth($chunked_text, $font, $size, $word_spacing, $letter_spacing);
|
|
});
|
|
arsort($lines);
|
|
$max = reset($lines);
|
|
break;
|
|
|
|
case "pre":
|
|
case "nowrap":
|
|
$max = $min;
|
|
break;
|
|
}
|
|
|
|
// Account for margins, borders, and padding
|
|
$dims = [
|
|
$style->padding_left,
|
|
$style->padding_right,
|
|
$style->border_left_width,
|
|
$style->border_right_width,
|
|
$style->margin_left,
|
|
$style->margin_right
|
|
];
|
|
|
|
// The containing block is not defined yet, treat percentages as 0
|
|
$delta = (float) $style->length_in_pt($dims, 0);
|
|
$min += $delta;
|
|
$max += $delta;
|
|
|
|
return [$min, $max, "min" => $min, "max" => $max];
|
|
}
|
|
|
|
/**
|
|
* @param FontMetrics $fontMetrics
|
|
* @return $this
|
|
*/
|
|
public function setFontMetrics(FontMetrics $fontMetrics)
|
|
{
|
|
$this->fontMetrics = $fontMetrics;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @return FontMetrics
|
|
*/
|
|
public function getFontMetrics()
|
|
{
|
|
return $this->fontMetrics;
|
|
}
|
|
}
|