* @copyright 2002-2025 Nicola Asuni - Tecnick.com LTD * @license http://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE.TXT) * @link https://github.com/tecnickcom/tc-lib-pdf * * This file is part of tc-lib-pdf software library. */ namespace Com\Tecnick\Pdf; use Com\Tecnick\Pdf\Exception as PdfException; use Com\Tecnick\Unicode\Bidi; use Com\Tecnick\Unicode\Data\Type as UnicodeType; /** * Com\Tecnick\Pdf\Text * * Text PDF data * * @since 2002-08-03 * @category Library * @package Pdf * @author Nicola Asuni * @copyright 2002-2025 Nicola Asuni - Tecnick.com LTD * @license http://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE.TXT) * @link https://github.com/tecnickcom/tc-lib-pdf * * @phpstan-import-type TTextDims from \Com\Tecnick\Pdf\Font\Stack * @phpstan-import-type StyleDataOpt from \Com\Tecnick\Pdf\Cell * @phpstan-import-type TCellDef from \Com\Tecnick\Pdf\Cell * @phpstan-import-type PageInputData from \Com\Tecnick\Pdf\Page\Box * @phpstan-import-type PageData from \Com\Tecnick\Pdf\Page\Box * @phpstan-import-type TFontMetric from \Com\Tecnick\Pdf\Font\Stack * @phpstan-import-type TBBox from \Com\Tecnick\Pdf\Base * @phpstan-import-type TStackBBox from \Com\Tecnick\Pdf\Base * * @phpstan-type TextShadow array{ * 'xoffset': float, * 'yoffset': float, * 'opacity': float, * 'mode': string, * 'color': string, * } * * @phpstan-type TextLinePos array{ * 'pos': int, * 'chars': int, * 'spaces': int, * 'septype': string, * 'totwidth': float, * 'totspacewidth': float, * 'words': int, * } */ abstract class Text extends \Com\Tecnick\Pdf\Cell { /** * The Unicode character used for hyphenation. * (45) '-' * Type: 'ES' (European Number Separator) */ protected const ORD_HYPHEN = 0x002D; /* * The Unicode character used for non-breaking space. * (160) 'NO-BREAK SPACE' * Type: 'CS' (Common Separator) */ protected const ORD_NO_BREAK_SPACE = 0x00A0; /* * The Unicode character used for soft hyphen. * (173) 'SHY' (SOFT HYPHEN) * Type: 'BN' (Boundary Neutral) */ protected const ORD_SOFT_HYPHEN = 0x00AD; /* * The Unicode character used for zero width space. * (8203) 'ZERO WIDTH SPACE' * Type: 'BN' (Boundary Neutral) */ protected const ORD_ZERO_WIDTH_SPACE = 0x200B; /** * The array of hyphenation patterns used for text processing. * * @var array Array of hyphenation patterns. */ protected $hyphen_patterns = []; /** * Dafault value for $dim array. * * @var TTextDims */ protected const DIM_DEFAULT = [ 'chars' => 0, 'spaces' => 0, 'words' => 0, 'totwidth' => 0.0, 'totspacewidth' => 0.0, 'split' => [], ]; /** * If true, ZERO-WIDTH-SPACE characters are automatically added * to the text to allow line breaking after some non-letter characters. * * @var bool */ protected $autozerowidthbreaks = false; /** * Returns the PDF code to render a text block inside a rectangular cell. * * @param string $txt Text string to be processed. * @param float $posx Abscissa of upper-left corner. * @param float $posy Ordinate of upper-left corner. * @param float $width Width. * @param float $height Height. * @param float $offset Horizontal offset to apply to the line start. * @param float $linespace Additional space to add between lines. * @param string $valign Text vertical alignment inside the cell: T=top; C=center; B=bottom. * @param string $halign Text horizontal alignment inside the cell: L=left; C=center; R=right; J=justify. * @param ?TCellDef $cell Optional to overwrite cell parameters for padding, margin etc. * @param array $styles Cell border styles (see: getCurrentStyleArray). * @param float $strokewidth Stroke width. * @param float $wordspacing Word spacing (use it only when justify == false). * @param float $leading Leading. * @param float $rise Text rise. * @param bool $jlast If true does not justify the last line when $halign == J. * @param bool $fill If true fills the text. * @param bool $stroke If true stroke the text. * @param bool $underline If true underline the text. * @param bool $linethrough If true line through the text. * @param bool $overline If true overline the text. * @param bool $clip If true activate clipping mode. * @param bool $drawcell If true draw the cell border. * @param string $forcedir If 'R' forces RTL, if 'L' forces LTR. * @param ?TextShadow $shadow Text shadow parameters. */ public function getTextCell( string $txt, float $posx = 0, float $posy = 0, float $width = 0, float $height = 0, float $offset = 0, float $linespace = 0, string $valign = 'C', string $halign = 'C', ?array $cell = null, array $styles = [], float $strokewidth = 0, float $wordspacing = 0, float $leading = 0, float $rise = 0, bool $jlast = true, bool $fill = true, bool $stroke = false, bool $underline = false, bool $linethrough = false, bool $overline = false, bool $clip = false, bool $drawcell = true, string $forcedir = '', ?array $shadow = null, ): string { if ($txt === '') { return ''; } $ordarr = []; $dim = self::DIM_DEFAULT; $this->prepareText($txt, $ordarr, $dim, $forcedir); $txt_pwidth = $dim['totwidth']; $cell = $this->adjustMinCellPadding($styles, $cell); $pntx = $this->toPoints($posx); $cell_pwidth = $this->toPoints($width); if ($width <= 0) { $cell_pwidth = min( $this->cellMaxWidth($pntx, $cell), $this->cellMinWidth( $txt_pwidth, $halign, $cell ), ); } $txt_pwidth = $this->textMaxWidth($cell_pwidth, $cell); $line_width = $this->toUnit($txt_pwidth); $curfont = $this->font->getCurrentFont(); $fontascent = $this->toUnit($curfont['ascent']); $lines = $this->splitLines( $ordarr, $dim, $txt_pwidth, $this->toPoints($offset) ); $numlines = count($lines); $txt_pheight = (($numlines * $curfont['height']) + (($numlines - 1) * $this->toPoints($linespace))); $cell_pheight = $this->toPoints($height); if ($height <= 0) { $cell_pheight = $this->cellMinHeight($txt_pheight, $valign, $cell); } $pnty = $this->toYPoints($posy); $cell_pnty = ($pnty - $cell['margin']['T']); $txt_pnty = $this->textVPosFromCell( $cell_pnty, $cell_pheight, $txt_pheight, $valign, $cell ); $cell_pntx = $this->cellHPos($pntx, $cell_pwidth, 'L', $cell); $txt_pntx = $this->textHPosFromCell( $cell_pntx, $cell_pwidth, $txt_pwidth, $halign, $cell ); $txt_out = $this->outTextLines( $ordarr, $lines, $this->toUnit($txt_pntx), $this->toYUnit($txt_pnty), $line_width, $offset, $fontascent, $linespace, $strokewidth, $wordspacing, $leading, $rise, $halign, $jlast, $fill, $stroke, $underline, $linethrough, $overline, $clip, $shadow, ); if (!$drawcell) { return $txt_out; } $cell_out = $this->drawCell( $cell_pntx, $cell_pnty, $cell_pwidth, $cell_pheight, $styles, $cell, ); return $cell_out . $txt_out; } /** * Adds a text block inside a rectangular cell. * Accounts for automatic line, page and region breaks. * * @param string $txt Text string to be processed. * @param int $pid Page index. Omit or set it to -1 for the current page ID. * @param float $posx Abscissa of upper-left corner relative to the region origin X coordinate. * @param float $posy Ordinate of upper-left corner relative to the region origin Y coordinate. * @param float $width Width. * @param float $height Height. * @param float $offset Horizontal offset to apply to the line start. * @param float $linespace Additional space to add between lines. * @param string $valign Text vertical alignment inside the cell: T=top; C=center; B=bottom. * @param string $halign Text horizontal alignment inside the cell: L=left; C=center; R=right; J=justify. * @param ?TCellDef $cell Optional to overwrite cell parameters for padding, margin etc. * @param array $styles Cell border styles (see: getCurrentStyleArray). * @param float $strokewidth Stroke width. * @param float $wordspacing Word spacing (use it only when justify == false). * @param float $leading Leading. * @param float $rise Text rise. * @param bool $jlast If true does not justify the last line when $halign == J. * @param bool $fill If true fills the text. * @param bool $stroke If true stroke the text. * @param bool $underline If true underline the text. * @param bool $linethrough If true line through the text. * @param bool $overline If true overline the text. * @param bool $clip If true activate clipping mode. * @param bool $drawcell If true draw the cell border. * @param string $forcedir If 'R' forces RTL, if 'L' forces LTR. * @param ?TextShadow $shadow Text shadow parameters. */ public function addTextCell( string $txt, int $pid = -1, float $posx = 0, float $posy = 0, float $width = 0, float $height = 0, float $offset = 0, float $linespace = 0, string $valign = 'T', string $halign = '', ?array $cell = null, array $styles = [], float $strokewidth = 0, float $wordspacing = 0, float $leading = 0, float $rise = 0, bool $jlast = true, bool $fill = true, bool $stroke = false, bool $underline = false, bool $linethrough = false, bool $overline = false, bool $clip = false, bool $drawcell = true, string $forcedir = '', ?array $shadow = null, ): void { if ($txt === '') { return; } if ($pid < 0) { $pid = $this->page->getPageId(); } if ($halign == '') { $halign = $this->rtl ? 'R' : 'L'; } $cstyles = $styles; if ($cstyles === []) { $cstyles = ['all' => $this->graph->getCurrentStyleArray()]; } if ($drawcell && (count($cstyles) == 1) && (!empty($cstyles['all']))) { $cstyles[0] = $cstyles['all']; $cstyles[1] = $cstyles['all']; $cstyles[2] = $cstyles['all']; $cstyles[3] = $cstyles['all']; } $ordarr = []; $dim = self::DIM_DEFAULT; $this->prepareText($txt, $ordarr, $dim, $forcedir); $txt_pwidth = $dim['totwidth']; $curfont = $this->font->getCurrentFont(); $fontascent = $this->toUnit($curfont['ascent']); $fontheight = $this->toUnit($curfont['height']); $ocell = $this->adjustMinCellPadding($cstyles, $cell); $cell = $ocell; $cell_pntw = $this->toPoints($width); $cell_pnth = $this->toPoints($height); $region_max_lines = 1; $num_blocks = 0; // loop through the regions to fit all available text while ($region_max_lines > 0) { $region = $this->page->getRegion($pid); $rposy = ($posy + $region['RY']); $cell_pnty = ($this->toYPoints($rposy) - $cell['margin']['T']); $cell_posy = $this->toYUnit($cell_pnty); $rposx = ($posx + $region['RX']); $rpntx = $this->toPoints($rposx); $cell_pwidth = $cell_pntw; if ($width <= 0) { $cell_pwidth = min( $this->cellMaxWidth($rpntx, $cell), $this->cellMinWidth( $txt_pwidth, $halign, $cell ), ); } $txt_pwidth = $this->textMaxWidth($cell_pwidth, $cell); $line_width = $this->toUnit($txt_pwidth); $cell_pntx = ($rpntx + $cell['margin']['L']); $txt_pntx = $this->textHPosFromCell( $cell_pntx, $cell_pwidth, $txt_pwidth, $halign, $cell ); $line_posx = $this->toUnit($txt_pntx); $lines = $this->splitLines( $ordarr, $dim, $txt_pwidth, $this->toPoints($offset) ); $numlines = count($lines); $vspace = $this->textMaxHeight($region['RH'] + $cell['margin']['B'] + $cell['padding']['B'] - $cell_posy); $region_max_lines = (int)(($vspace + $linespace) / ($fontheight + $linespace)); $lastblock = ($numlines <= $region_max_lines); $rlines = $lines; if ($numlines > $region_max_lines) { $rlines = array_slice($lines, 0, $region_max_lines); } $txt_pheight = (($numlines * $curfont['height']) + (($numlines - 1) * $this->toPoints($linespace))); $cell_pheight = $cell_pnth; if ($height <= 0) { $cell_pheight = $this->cellMinHeight($txt_pheight, $valign, $cell); } $txt_pnty = $this->textVPosFromCell( $cell_pnty, $cell_pheight, $txt_pheight, $valign, $cell ); $line_posy = $this->toYUnit($txt_pnty); $out = $this->outTextLines( $ordarr, $rlines, $line_posx, $line_posy, $line_width, $offset, $fontascent, $linespace, $strokewidth, $wordspacing, $leading, $rise, $halign, ($lastblock and $jlast), $fill, $stroke, $underline, $linethrough, $overline, $clip, $shadow, ); if ($drawcell) { $styles = $cstyles; if ($num_blocks > 0) { $styles[0]['lineWidth'] = 0; empty($styles[0]['fillColor']) ? null : ($styles[0]['lineColor'] = $styles[0]['fillColor']); if (!$lastblock) { $styles[2]['lineWidth'] = 0; empty($styles[2]['fillColor']) ? null : ($styles[2]['lineColor'] = $styles[2]['fillColor']); } } $out = $this->drawCell( $cell_pntx, $cell_pnty, $cell_pwidth, $cell_pheight, $styles, $cell, ) . $out; } $this->page->addContent($out, $pid); if ($lastblock) { return; } $ordarr = array_slice($ordarr, $lines[$region_max_lines]['pos']); $dim = $this->font->getOrdArrDims($ordarr); // @phpstan-ignore argument.type $posy = 0; $offset = 0; $num_blocks++; $cell = $ocell; $cell['margin']['T'] = 0; $cell['margin']['B'] = 0; $this->page->getNextRegion($pid); $curpid = $this->page->getPageId(); if ($curpid > $pid) { $pid = $curpid; $this->setPageContext($pid); } } } /** * Returns the PDF code to render a contiguous text block with automatic line breaks. * * @param array $ordarr Array of UTF-8 codepoints (integer values). * @param array $lines Array of lines metrics. * @param float $posx Abscissa of upper-left corner. * @param float $posy Ordinate of upper-left corner. * @param float $width Width. * @param float $offset Horizontal offset to apply to the line start. * @param float $fontascent Font ascent in user units. * @param float $linespace Additional space to add between lines. * @param float $strokewidth Stroke width. * @param float $wordspacing Word spacing (use it only when justify == false). * @param float $leading Leading. * @param float $rise Text rise. * @param string $halign Text horizontal alignment inside the cell: L=left; C=center; R=right; J=justify. * @param bool $jlast If true does not justify the last line when $halign == J. * @param bool $fill If true fills the text. * @param bool $stroke If true stroke the text. * @param bool $underline If true underline the text. * @param bool $linethrough If true line through the text. * @param bool $overline If true overline the text. * @param bool $clip If true activate clipping mode. * @param ?TextShadow $shadow Text shadow parameters. * * @return string PDF code to render the text. */ protected function outTextLines( array $ordarr, array $lines, float $posx, float $posy, float $width, float $offset, float $fontascent, float $linespace = 0, float $strokewidth = 0, float $wordspacing = 0, float $leading = 0, float $rise = 0, string $halign = '', bool $jlast = true, bool $fill = true, bool $stroke = false, bool $underline = false, bool $linethrough = false, bool $overline = false, bool $clip = false, ?array $shadow = null, ): string { if ($ordarr === [] || $lines === []) { return ''; } if ($halign == '') { $halign = $this->rtl ? 'R' : 'L'; } $num_lines = count($lines); $lastline = ($num_lines - 1); $line_posx = $posx + $offset; $line_posy = $posy + $fontascent; $out = ''; foreach ($lines as $i => $data) { $line_ordarr = array_slice($ordarr, $data['pos'], $data['chars']); $line_ordarr = $this->removeOrdArrSoftHyphens($line_ordarr); $line_txt = implode('', $this->uniconv->ordArrToChrArr($line_ordarr)); $line_dim = [ 'chars' => $data['chars'], 'spaces' => $data['spaces'], 'totwidth' => $data['totwidth'], 'totspacewidth' => $data['totspacewidth'], 'words' => $data['words'], 'split' => [], ]; $cell_width = ($width - $offset); $txt_posx = $this->toUnit( $this->textHPosFromCell( $this->toPoints($line_posx), $this->toPoints($cell_width), $line_dim['totwidth'], $halign, static::ZEROCELL, // @phpstan-ignore argument.type ) ); $jwidth = 0; if (($halign == 'J') && ($data['septype'] != 'B') && (($i < $lastline) || !$jlast)) { $jwidth = $cell_width; } $out .= $this->getOutTextLine( $line_txt, $line_ordarr, $line_dim, $txt_posx, $line_posy, $jwidth, $strokewidth, $wordspacing, $leading, $rise, $fill, $stroke, $underline, $linethrough, $overline, $clip, $shadow, ); $offset = 0; $line_posx = $posx; $bbox = $this->getLastBBox(); $line_posy = ($bbox['y'] + $bbox['h'] + $fontascent + $linespace); } return $out; } /** * Returns the PDF code to render a single line of text. * * @param string $txt Text string to be processed. * @param float $posx X position relative to the start of the current line. * @param float $posy Y position relative to the start of the current line (font baseline). * @param float $width Desired string width to force justification via word spacing (0 = automatic). * @param float $strokewidth Stroke width. * @param float $wordspacing Word spacing (use it only when width == 0). * @param float $leading Leading. * @param float $rise Text rise. * @param bool $fill If true fills the text. * @param bool $stroke If true stroke the text. * @param bool $underline If true underline the text. * @param bool $linethrough If true line through the text. * @param bool $overline If true overline the text. * @param bool $clip If true activate clipping mode. * @param string $forcedir If 'R' forces RTL, if 'L' forces LTR. * @param ?TextShadow $shadow Text shadow parameters. */ public function getTextLine( string $txt, float $posx = 0, float $posy = 0, float $width = 0, float $strokewidth = 0, float $wordspacing = 0, float $leading = 0, float $rise = 0, bool $fill = true, bool $stroke = false, bool $underline = false, bool $linethrough = false, bool $overline = false, bool $clip = false, string $forcedir = '', ?array $shadow = null, ): string { if ($txt === '') { return ''; } $ordarr = []; $dim = self::DIM_DEFAULT; $this->prepareText($txt, $ordarr, $dim, $forcedir); return $this->getOutTextLine( $txt, $ordarr, $dim, // @phpstan-ignore argument.type $posx, $posy, $width, $strokewidth, $wordspacing, $leading, $rise, $fill, $stroke, $underline, $linethrough, $overline, $clip, $shadow, ); } /** * Returns the PDF code to render a single line of text. * * @param string $txt Text string to be processed. * @param array $ordarr Array of UTF-8 codepoints (integer values). * @param TTextDims $dim Array of dimensions * @param float $posx X position relative to the start of the current line. * @param float $posy Y position relative to the start of the current line (font baseline). * @param float $width Desired string width to force justification via word spacing (0 = automatic). * @param float $strokewidth Stroke width. * @param float $wordspacing Word spacing (use it only when width == 0). * @param float $leading Leading. * @param float $rise Text rise. * @param bool $fill If true fills the text. * @param bool $stroke If true stroke the text. * @param bool $underline If true underline the text. * @param bool $linethrough If true line through the text. * @param bool $overline If true overline the text. * @param bool $clip If true activate clipping mode. * @param ?TextShadow $shadow Text shadow parameters. */ protected function getOutTextLine( string $txt, array $ordarr, array $dim, float $posx = 0, float $posy = 0, float $width = 0, float $strokewidth = 0, float $wordspacing = 0, float $leading = 0, float $rise = 0, bool $fill = true, bool $stroke = false, bool $underline = false, bool $linethrough = false, bool $overline = false, bool $clip = false, ?array $shadow = null, ): string { if ($txt === '' || $ordarr === [] || $dim === []) { return ''; } $out = ''; if (!empty($shadow)) { if ($shadow['xoffset'] < 0) { $posx += $shadow['xoffset']; } if ($shadow['yoffset'] < 0) { $posy += $shadow['yoffset']; } $out .= $this->graph->getStartTransform(); $out .= $this->color->getPdfColor($shadow['color'], false); $out .= $this->graph->getAlpha($shadow['opacity'], $shadow['mode']); $out .= $this->outTextLine( $txt, $ordarr, $dim, $posx + $shadow['xoffset'], $posy + $shadow['yoffset'], $width, 0, $wordspacing, $leading, $rise, true, false, false, false, false, false, ); $out .= $this->graph->getStopTransform(); } return $out . $this->outTextLine( $txt, $ordarr, $dim, $posx, $posy, $width, $strokewidth, $wordspacing, $leading, $rise, $fill, $stroke, $underline, $linethrough, $overline, $clip, ); } /** * Cleanup the input text, convert it to UTF-8 array and get the dimensions. * * @param string $txt Clean text string to be processed. * @param array $ordarr Array of UTF-8 codepoints (integer values). * @param TTextDims $dim Array of dimensions * @param string $forcedir If 'R' forces RTL, if 'L' forces LTR. */ protected function prepareText( string &$txt, array &$ordarr, array &$dim, string $forcedir = '', ): void { if ($txt === '') { return; } $txt = $this->cleanupText($txt); $ordarr = $this->uniconv->strToOrdArr($txt); // @phpstan-ignore parameterByRef.type if ($this->isunicode && !$this->font->isCurrentByteFont()) { $bidi = new Bidi($txt, null, $ordarr, $forcedir); $ordarr = $this->replaceUnicodeChars($bidi->getOrdArray()); // @phpstan-ignore argument.type } if (!empty($this->hyphen_patterns)) { $ordarr = $this->hyphenateTextOrdArr( $this->hyphen_patterns, $ordarr, // @phpstan-ignore argument.type ); } if ($this->autozerowidthbreaks) { $ordarr = $this->addOrdArrBreakPoints($ordarr); // @phpstan-ignore argument.type } $dim = $this->font->getOrdArrDims($ordarr); // @phpstan-ignore argument.type } /** * Split the text into lines to fit the specified width. * * @param array $ordarr Array of UTF-8 codepoints (integer values). * @param TTextDims $dim Array of dimensions. * @param float $pwidth Max line width in internal points. * @param float $poffset Horizontal offset to apply to the line start in internal points. * * @return array Array of lines metrics. */ protected function splitLines( array $ordarr, array $dim, float $pwidth, float $poffset = 0, ): array { if (empty($ordarr)) { // no lines return []; } $line_width = ($pwidth - $poffset); if ($dim['totwidth'] <= $line_width) { // the input text fits in a single line return [[ 'pos' => 0, 'chars' => $dim['chars'], 'spaces' => $dim['spaces'], 'septype' => 'BN', 'totwidth' => $dim['totwidth'], 'totspacewidth' => $dim['totspacewidth'], 'words' => $dim['words'], ]]; } $lines = []; $posstart = 0; $posend = 0; $prev_spaces = 0; $prev_totwidth = 0; $prev_totspacewidth = 0; $prev_words = 0; $num_words = count($dim['split']); $soft_hyphen_width = $this->font->getCharWidth(static::ORD_HYPHEN); // @phpstan-ignore argument.type for ($word = 0; $word < $num_words; $word++) { $data = $dim['split'][$word]; // current word data $curwidth = ($data['totwidth'] - $prev_totwidth); $overline = ($curwidth > $line_width); if (($data['septype'] == 'B') || $overline) { // the current word is a line break or does not fit in the current line if ($overline && ($word > 0)) { // the current word does not fit in the current line $data = $dim['split'][($word - 1)]; --$word; } $posend = $data['pos']; $totwidth = $data['totwidth']; $totspacewidth = $data['totspacewidth']; $spaces = $data['spaces']; $septype = $data['septype']; $sepend = 0; $sepwidth = 0; if ($data['ord'] == static::ORD_SOFT_HYPHEN) { $sepend = 1; $sepwidth = $soft_hyphen_width; } $lines[] = [ 'pos' => $posstart, 'chars' => ($posend - $posstart) + $sepend, 'spaces' => ($spaces - $prev_spaces), 'septype' => $septype, 'totwidth' => ($totwidth - $prev_totwidth) + $sepwidth, 'totspacewidth' => ($totspacewidth - $prev_totspacewidth), 'words' => ($word - $prev_words), ]; $chrwidth = $this->font->getCharWidth($data['ord']); $prev_totwidth = $totwidth + $chrwidth; $prev_totspacewidth = $totspacewidth; $prev_spaces = $spaces; if ($septype == 'WS') { ++$prev_spaces; $prev_totspacewidth += $chrwidth; } $prev_words = $word; $line_width = $pwidth; $posstart = $posend + 1; // skip word separator } } if ($posstart < $dim['chars']) { $last = $dim['split'][$dim['words'] - 1]; $lines[] = [ 'pos' => $posstart, 'chars' => ($dim['chars'] - $posstart), 'spaces' => ($last['spaces'] - $prev_spaces), 'septype' => $last['septype'], 'totwidth' => ($last['totwidth'] - $prev_totwidth), 'totspacewidth' => ($last['totspacewidth'] - $prev_totspacewidth), 'words' => ($dim['words'] - $prev_words), ]; } return $lines; } /** * Returns the PDF code to render a line of text. * * @param string $txt Clean text string to be processed. * @param array $ordarr Array of UTF-8 codepoints (integer values). * @param TTextDims $dim Array of dimensions. * @param float $posx X position relative to the start of the current line. * @param float $posy Y position relative to the start of the current line (font baseline). * @param float $width Desired string width to force justification via word spacing (0 = automatic). * @param float $strokewidth Stroke width. * @param float $wordspacing Word spacing (use it only when width == 0). * @param float $leading Leading. * @param float $rise Text rise. * @param bool $fill If true fills the text. * @param bool $stroke If true stroke the text. * @param bool $underline If true underline the text. * @param bool $linethrough If true line through the text. * @param bool $overline If true overline the text. * @param bool $clip If true activate clipping mode. */ protected function outTextLine( string $txt, array $ordarr, array $dim, float $posx = 0, float $posy = 0, float $width = 0, float $strokewidth = 0, float $wordspacing = 0, float $leading = 0, float $rise = 0, bool $fill = true, bool $stroke = false, bool $underline = false, bool $linethrough = false, bool $overline = false, bool $clip = false, ): string { if ($txt === '' || $ordarr === [] || $dim === []) { return ''; } $width = $width > 0 ? $width : 0; $curfont = $this->font->getCurrentFont(); $this->bbox[] = [ 'x' => $posx, 'y' => ($posy - $this->toUnit($curfont['ascent'])), 'w' => $width, 'h' => $this->toUnit($curfont['height']), ]; $out = $this->getJustifiedString($txt, $ordarr, $dim, $width); $out = $this->getOutTextPosXY($out, $posx, $posy, 'Td'); $trmode = $this->getTextRenderingMode($fill, $stroke, $clip); $out = $this->getOutTextStateOperatorw($out, $this->toPoints($strokewidth)); $out = $this->getOutTextStateOperatorTr($out, $trmode); $out = $this->getOutTextStateOperatorTw($out, $this->toPoints($wordspacing)); $out = $this->getOutTextStateOperatorTc($out, $curfont['spacing']); $out = $this->getOutTextStateOperatorTz($out, $curfont['stretching']); $out = $this->getOutTextStateOperatorTL($out, $this->toPoints($leading)); $out = $this->getOutTextStateOperatorTs($out, $this->toPoints($rise)); $out = $this->getOutTextObject($out); $bbox = $this->getLastBBox(); if ($underline) { $out .= $this->getOutUTOLine( $this->toPoints($bbox['x']), $this->toYPoints($bbox['y'] + $bbox['h']), $this->toPoints($bbox['w']), $curfont['ut'], ); } if ($linethrough) { $out .= $this->getOutUTOLine( $this->toPoints($bbox['x']), $this->toYPoints($bbox['y'] + ($bbox['h'] / 2)), $this->toPoints($bbox['w']), $curfont['ut'], ); } if ($overline) { $out .= $this->getOutUTOLine( $this->toPoints($bbox['x']), $this->toYPoints($bbox['y']), $this->toPoints($bbox['w']), $curfont['ut'], ); } return $out; } /** * Return the raw PDF command to print a graphic line. * This is used for text underline, overline and line-through. * * @param float $pntx X position in internal points. * @param float $pnty Y position in internal points. * @param float $pwidth Line width in internal points. * @param float $psize Line tickness in internal points. * * @return string Raw PDF data. */ protected function getOutUTOLine( float $pntx, float $pnty, float $pwidth, float $psize, ): string { return sprintf('%F %F %F %F re f' . "\n", $pntx, $pnty, $pwidth, $psize); } /** * Returns the last text bounding box [llx, lly, urx, ury]. * * @return TBBox Array of bounding box values. */ public function getLastBBox(): array { return $this->bbox[array_key_last($this->bbox)]; } /** * Remove special chacters from the text string: * - 'CARRIAGE RETURN' (U+000D) * - 'NO-BREAK SPACE' (U+00A0) * - 'SHY' (U+00AD) SOFT HYPHEN * * @param string $txt Text string to be processed. */ protected function cleanupText(string $txt): string { $txt = str_replace("\r", ' ', $txt); $txt = str_replace($this->uniconv->chr(self::ORD_NO_BREAK_SPACE), ' ', $txt); $txt = str_replace($this->uniconv->chr(self::ORD_SOFT_HYPHEN), '', $txt); return $txt; } /** * Returns the string to be used as input for getOutTextShowing(). * * @param string $txt Clean text string to be processed. * @param array $ordarr Array of UTF-8 codepoints (integer values). * @param TTextDims $dim Array of dimensions * @param float $width Desired string width in points (0 = automatic). */ protected function getJustifiedString( string $txt, array $ordarr, array $dim, float $width = 0, ): string { $pwidth = $this->toPoints($width); $this->bbox[] = $this->getLastBBox(); $bboxid = array_key_last($this->bbox); if ((!$this->isunicode) || $this->font->isCurrentByteFont()) { if ($this->isunicode) { $txt = $this->uniconv->latinArrToStr($this->uniconv->uniArrToLatinArr($ordarr)); } $txt = $this->encrypt->escapeString($txt); $txt = $this->getOutTextShowing($txt, 'Tj'); if ($pwidth > 0) { $spacewidth = (($pwidth - $dim['totwidth']) / ($dim['spaces'] ?: 1)); return $this->getOutTextStateOperatorTw($txt, $spacewidth); } $this->bbox[$bboxid]['w'] = $this->toUnit($dim['totwidth']); return $txt; } $unistr = implode('', $this->uniconv->ordArrToChrArr($ordarr)); $txt = $this->uniconv->toUTF16BE($unistr); $txt = $this->encrypt->escapeString($txt); if ($pwidth <= 0) { $this->bbox[$bboxid]['w'] = $this->toUnit($dim['totwidth']); return $this->getOutTextShowing($txt, 'Tj'); } $fontsize = $this->font->getCurrentFont()['size'] ?: 1; $spacewidth = (($pwidth - $dim['totwidth'] + $dim['totspacewidth']) / ($dim['spaces'] ?: 1)); $spacewidth = -1000 * $spacewidth / $fontsize; $txt = str_replace(chr(0) . chr(32), ') ' . sprintf('%F', $spacewidth) . ' (', $txt); return $this->getOutTextShowing($txt, 'TJ'); } /** * Get the PDF code for the specified Text Positioning Operator mode. * * @param string $raw Raw PDf data to be wrapped by this command. * @param float $posx X position relative to the start of the current line. * @param float $posy Y position relative to the start of the current line. * @param string $mode Text state parameter to apply (one of: Td, TD, T*). */ protected function getOutTextPosXY( string $raw, float $posx = 0, float $posy = 0, string $mode = 'Td' ): string { $pntx = $this->toPoints($posx); $pnty = $this->toYPoints($posy); return match ($mode) { 'Td' => sprintf('%F %F Td ' . $this->escapePerc($raw), $pntx, $pnty), 'TD' => sprintf('%F %F TD ' . $this->escapePerc($raw), $pntx, $pnty), 'T*' => 'T* ' . $raw, default => '', }; } /** * Get the text rendering mode. * * @param bool $fill If true fills the text. * @param bool $stroke If true stroke the text. * @param bool $clip If true activate clipping mode. * * @return int Text rendering mode as in PDF 32000-1:2008 - 9.3.6 Text Rendering Mode. */ protected function getTextRenderingMode( bool $fill = true, bool $stroke = false, bool $clip = false ): int { $mode = ((int) $clip << 2) + ((int) $stroke << 1) + ((int) $fill); return match ($mode) { 0 => 3, 4 => 7, default => $mode - 1, }; } /** * Get the PDF code for the Tc (character spacing) Text State Operator. * * @param string $raw Raw PDf data to be wrapped by this command. * @param int|float $value Raw value to apply in internal units. */ protected function getOutTextStateOperatorTc( string $raw, int|float $value = 0 ): string { if ($value == 0) { return $raw; } return sprintf('%F Tc ' . $this->escapePerc($raw) . ' 0 Tc', $value); } /** * Get the PDF code for the Tw (word spacing) Text State Operator. * * @param string $raw Raw PDf data to be wrapped by this command. * @param int|float $value Raw value to apply in internal units. */ protected function getOutTextStateOperatorTw( string $raw, int|float $value = 0 ): string { if ($value == 0) { return $raw; } return sprintf('%F Tw ' . $this->escapePerc($raw) . ' 0 Tw', $value); } /** * Get the PDF code for the Tz (horizontal scaling) Text State Operator. * * @param string $raw Raw PDf data to be wrapped by this command. * @param int|float $value Raw value to apply in internal units. */ protected function getOutTextStateOperatorTz( string $raw, int|float $value = 0 ): string { if ($value == 1) { return $raw; } return sprintf('%F Tz ' . $this->escapePerc($raw) . ' 100 Tz', $value); } /** * Get the PDF code for the TL (text leading) Text State Operator. * * @param string $raw Raw PDf data to be wrapped by this command. * @param int|float $value Raw value to apply in internal units. */ protected function getOutTextStateOperatorTL( string $raw, int|float $value = 0 ): string { if ($value == 0) { return $raw; } return sprintf('%F TL ' . $this->escapePerc($raw) . ' 0 TL', $value); } /** * Get the PDF code for the Tr (text rendering) Text State Operator. * * @param string $raw Raw PDf data to be wrapped by this command. * @param int|float $value Raw value to apply in internal units. */ protected function getOutTextStateOperatorTr( string $raw, int|float $value = 0 ): string { if (($value < 0) || ($value > 7)) { return $raw; } return sprintf('%d Tr ' . $this->escapePerc($raw), $value); } /** * Get the PDF code for the Ts (text rise) Text State Operator. * * @param string $raw Raw PDf data to be wrapped by this command. * @param int|float $value Raw value to apply in internal units. */ protected function getOutTextStateOperatorTs( string $raw, int|float $value = 0 ): string { if ($value == 0) { return $raw; } return sprintf('%F Ts ' . $this->escapePerc($raw) . ' 0 Ts', $value); } /** * Get the PDF code for the w (stroke width) Text State Operator. * * @param string $raw Raw PDf data to be wrapped by this command. * @param int|float $value Raw value to apply in internal units. */ protected function getOutTextStateOperatorw( string $raw, int|float $value = 0 ): string { return sprintf('%F w ' . $this->escapePerc($raw), ($value > 0 ? $value : 0)); } /** * Get the PDF code for the Text Positioning Operator Matrix. * * @param string $raw Raw PDf data to be wrapped by this command. * @param array{float, float, float, float, float, float} $matrix Text Positioning Operator Matrix. */ protected function getOutTextPosMatrix( string $raw, array $matrix = [1, 0, 0, 1, 0, 0] ): string { if (count($matrix) != 6) { return ''; } return sprintf( '%F %F %F %F %F %F Tm ' . $this->escapePerc($raw), $matrix[0], $matrix[1], $matrix[2], $matrix[3], $matrix[4], $matrix[5] ); } /** * Get the PDF code for showing a string. * * @param string $str String to show. * @param string $mode Text-showing operator to apply (one of: Tj, TJ, '). */ protected function getOutTextShowing(string $str, string $mode = 'Tj'): string { return match ($mode) { 'Tj' => '(' . $str . ') Tj', 'TJ' => '[(' . $str . ')] TJ', "'" => '(' . $str . ") '", default => '', }; } /** * Returns a text oject by wrapping the $raw input. * * @param string $raw Raw PDf data to be wrapped by this command. */ protected function getOutTextObject(string $raw = ''): string { return 'BT ' . $raw . ' ET' . "\n"; } /** * Replace characters for languages like Thai. * * @param array $ordarr Array of UTF-8 codepoints (integer values). * * @return array Array of UTF-8 codepoints (integer values). */ protected function replaceUnicodeChars(array $ordarr): array { // @TODO return $ordarr; } // ===| HYPENATION |==================================================== /** * Returns an array of hyphenation patterns. * * @param string $file TEX file containing hypenation patterns. * TEX patterns can be downloaded from * https://www.ctan.org/tex-archive/language/hyph-utf8/tex/generic/hyph-utf8/patterns/tex * See https://www.ctan.org/tex-archive/language/hyph-utf8/ for more information. * * @return array Array of hyphenation patterns. */ public function loadTexHyphenPatterns(string $file): array { $pattern = []; $data = $this->file->fileGetContents($file); // remove comments $data = preg_replace('/\%[^\n]*+/', '', $data); if ($data === null) { throw new PdfException('Unable to load hyphenation patterns from file: ' . $file); } // extract the patterns part if (preg_match('/\\\\patterns\{([^\}]*+)\}/i', $data, $matches) !== 1) { throw new PdfException('Invalid hyphenation pattern section from file: ' . $file); } $data = trim(substr($matches[0], 10, -1)); // extract each pattern $list = preg_split('/[\s]+/', $data); if ($list === false) { throw new PdfException('Invalid hyphenation patterns from file: ' . $file); } // map patterns $pattern = []; foreach ($list as $val) { if ($val === '') { continue; } $val = str_replace("'", '\\\'', trim($val)); $key = preg_replace('/\d+/', '', $val); $pattern[$key] = $val; } return $pattern; } /** * Sets the hyphen patterns for text. * * @param array $patterns Array of hyphenation patterns. * * @return void * * @see loadTexHyphenPatterns() */ public function setTexHyphenPatterns(array $patterns): void { $this->hyphen_patterns = $patterns; } /** * Removes soft hyphens from an array of Unicode code points. * * @param array $ordarr The array of Unicode code points. * * @return array The filtered array with soft hyphens removed. */ protected function removeOrdArrSoftHyphens(array $ordarr): array { $keeplast = ((count($ordarr) > 0) && ($ordarr[(count($ordarr) - 1)] == self::ORD_SOFT_HYPHEN)); $retarr = array_filter( $ordarr, fn($ord) => ( ($ord != self::ORD_SOFT_HYPHEN) && ($ord != self::ORD_ZERO_WIDTH_SPACE) ) ); if ($keeplast) { $retarr[] = self::ORD_SOFT_HYPHEN; } return $retarr; } /** * Hyphenate a text array of UTF-8 codepoints by adding SOFT-HYPHEN (U+00AD) characters. * * @param array $phyphens An array of hyphenation patterns. * @param array $ordarr Array of UTF-8 codepoints (integer values). * * @return array The modified array with SOFT-HYPHEN (U+00AD) characters. */ protected function hyphenateTextOrdArr(array $phyphens, array $ordarr): array { $txtarr = []; $word = []; foreach ($ordarr as $ord) { $unitype = UnicodeType::UNI[$ord]; switch ($unitype) { case 'L': $word[] = $ord; break; default: if (count($word) > 0) { $txtarr = array_merge($txtarr, $this->hyphenateWordOrdArr($phyphens, $word)); $word = []; } $txtarr[] = $ord; break; } } return $txtarr; } /** * Enable or disable automatic line breaking points after some non-letter character types. * * @param bool $enabled */ public function enableZeroWidthBreakPoints(bool $enabled): void { $this->autozerowidthbreaks = $enabled; } /** * Add artificial line breaking points to an array of UTF-8 codepoints. * This method adds ZERO-WIDTH-SPACE (U+200B) characters after certain Unicode types. * * @param array $ordarr Array of UTF-8 codepoints (integer values). * * @return array The modified array with SOFT-HYPHEN (U+00AD) characters. */ protected function addOrdArrBreakPoints(array $ordarr): array { $txtarr = []; foreach ($ordarr as $ord) { switch (UnicodeType::UNI[$ord]) { case 'ES': case 'ET': case 'CS': case 'BN': case 'ON': $txtarr[] = $ord; $txtarr[] = self::ORD_ZERO_WIDTH_SPACE; break; default: $txtarr[] = $ord; break; } } return $txtarr; } /** * Hyphenate a word array of UTF-8 codepoints by adding SOFT-HYPHEN (U+00AD) characters. * * @param array $phyphens An array of hyphenation patterns. * @param array $ordarr Array of UTF-8 codepoints (integer values). * @param int $leftmin Minimum number of characters before the hyphen. * @param int $rightmin Minimum number of characters after the hyphen. * @param int $charmin Minimum number of characters to consider for hyphenation. * @param int $charmax Maximum number of characters to consider for hyphenation. * * @return array The modified array with SOFT-HYPHEN (U+00AD) characters. */ protected function hyphenateWordOrdArr( array $phyphens, array $ordarr, $leftmin = 1, $rightmin = 2, $charmin = 1, $charmax = 8, ): array { $numchars = count($ordarr); if (empty($phyphens) || ($numchars < $charmin)) { return $ordarr; } $hyphenpos = []; // hyphens positions $pad = array(46); // 46 = Period, dot or full stop $tmpword = array_merge($pad, $ordarr, $pad); $tmpnumchars = $numchars + 2; $maxpos = $tmpnumchars - 1; for ($pos = 0; $pos < $maxpos; ++$pos) { $imax = min(($tmpnumchars - $pos), $charmax); for ($i = 1; $i <= $imax; ++$i) { $subword = mb_strtolower( $this->uniconv->getSubUniArrStr( $this->uniconv->ordArrToChrArr($tmpword), $pos, ($pos + $i) ) ); if (isset($phyphens[$subword])) { $pattern = $this->uniconv->strToOrdArr($phyphens[$subword]); $pattern_length = count($pattern); $digits = 1; for ($j = 0; $j < $pattern_length; ++$j) { // check if $pattern[$j] is a number = hyphenation level // (only numbers from 1 to 5 are valid) if (($pattern[$j] >= 48) and ($pattern[$j] <= 57)) { $zero = ($j == 0) ? ($pos - 1) : ($pos + $j - $digits); // get hyphenation level $level = ($pattern[$j] - 48); // if two levels from two different patterns match at the same point, // the higher one is selected. if (!isset($hyphenpos[$zero]) or ($hyphenpos[$zero] < $level)) { $hyphenpos[$zero] = $level; } ++$digits; } } } } } $inserted = 0; $maxpos = $numchars - $rightmin; for ($i = $leftmin; $i <= $maxpos; ++$i) { // only odd levels indicate allowed hyphenation points if (isset($hyphenpos[$i]) && (($hyphenpos[$i] % 2) != 0)) { array_splice($ordarr, $i + $inserted, 0, self::ORD_SOFT_HYPHEN); ++$inserted; } } return $ordarr; } // ===| PAGE |========================================================== /** * Add a new page (wrapper function for $this->page->add()). * * @param PageInputData $data Page data. * @return PageData Page data with additional Page ID property 'pid'. */ public function addPage(array $data = []): array { $ret = $this->page->add($data); $this->setPageContext($ret['pid']); return $ret; } /** * Sets the page context by adding the previous page font and graphic settings. * * @param int $pid Page index. Omit or set it to -1 for the current page ID. * * @return void */ protected function setPageContext(int $pid = -1): void { $this->page->addContent($this->font->getOutCurrentFont(), $pid); if ($this->defPageContentEnabled) { $this->page->addContent($this->defaultPageContent($pid), $pid); } } /** * Sets the page common content like Header and Footer. * Override this method to add custom content to all pages. * * @param int $pid Page index. Omit or set it to -1 for the current page ID. * * @return string PDF output code. */ public function defaultPageContent(int $pid = -1): string { if ($pid < 0) { $pid = $this->page->getPageId(); } if ($this->defaultfont === null) { $this->defaultfont = $this->font->insert($this->pon, 'helvetica', '', 10); } $page = $this->page->getPage($pid); // print page number in the footer $out = $this->graph->getStartTransform(); $out .= $this->defaultfont['out']; $out .= $this->color->getPdfColor('black'); $prevcell = $this->defcell; $this->defcell = $this::ZEROCELL; // @phpstan-ignore assign.propertyType $out .= $this->getTextCell( (string) ($pid + 1), $this->toUnit($this->defaultfont['dw']), $page['height'] - (2 * $this->toUnit($this->defaultfont['height'])), $page['width'] - (4 * $this->toUnit($this->defaultfont['dw'])), 0, 0, 0, 'T', ($this->rtl ? 'L' : 'R'), ); $out .= $this->graph->getStopTransform(); $this->defcell = $prevcell; return $out; } /** * Escape percent signs in a string for use with sprintf. * * @param string $str The input string to escape. * * @return string The escaped string with percent signs replaced by double percent signs. */ protected function escapePerc(string $str): string { return str_replace('%', '%%', $str); } }