1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2021 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Report; 21 22use Fisharebest\Webtrees\I18N; 23use Fisharebest\Webtrees\MediaFile; 24use Fisharebest\Webtrees\Webtrees; 25use League\Flysystem\FilesystemOperator; 26 27use function array_map; 28use function ceil; 29use function count; 30use function explode; 31use function implode; 32use function preg_match; 33use function str_replace; 34use function stripos; 35use function strrpos; 36use function substr; 37use function substr_count; 38 39/** 40 * Class HtmlRenderer 41 */ 42class HtmlRenderer extends AbstractRenderer 43{ 44 /** 45 * Cell padding 46 * 47 * @var float 48 */ 49 public $cPadding = 2; 50 51 /** 52 * Cell height ratio 53 * 54 * @var float 55 */ 56 public $cellHeightRatio = 1.8; 57 58 /** 59 * Current horizontal position 60 * 61 * @var float 62 */ 63 public $X = 0.0; 64 65 /** 66 * Current vertical position 67 * 68 * @var float 69 */ 70 public $Y = 0.0; 71 72 /** 73 * Currently used style name 74 * 75 * @var string 76 */ 77 public $currentStyle = ''; 78 79 /** 80 * Page number counter 81 * 82 * @var int 83 */ 84 public $pageN = 1; 85 86 /** 87 * Store the page width without left and right margins 88 * 89 * In HTML, we don't need this 90 * 91 * @var float 92 */ 93 public $noMarginWidth = 0.0; 94 95 /** 96 * Last cell height 97 * 98 * @var float 99 */ 100 public $lastCellHeight = 0.0; 101 102 /** 103 * LTR or RTL alignement; "left" on LTR, "right" on RTL 104 * Used in <div> 105 * 106 * @var string 107 */ 108 public $alignRTL = 'left'; 109 110 /** 111 * LTR or RTL entity 112 * 113 * @var string 114 */ 115 public $entityRTL = '‎'; 116 117 /** 118 * Largest Font Height is used by TextBox etc. 119 * 120 * Use this to calculate a the text height. 121 * This makes sure that the text fits into the cell/box when different font sizes are used 122 * 123 * @var float 124 */ 125 public $largestFontHeight = 0; 126 127 /** 128 * Keep track of the highest Y position 129 * 130 * Used with Header div / Body div / Footer div / "addpage" / The bottom of the last image etc. 131 * 132 * @var float 133 */ 134 public $maxY = 0; 135 136 /** @var ReportBaseElement[] Array of elements in the header */ 137 public $headerElements = []; 138 139 /** @var ReportBaseElement[] Array of elements in the footer */ 140 public $footerElements = []; 141 142 /** @var ReportBaseElement[] Array of elements in the body */ 143 public $bodyElements = []; 144 145 /** @var ReportHtmlFootnote[] Array of elements in the footer notes */ 146 public $printedfootnotes = []; 147 148 /** 149 * HTML Setup - ReportHtml 150 * 151 * @return void 152 */ 153 public function setup(): void 154 { 155 parent::setup(); 156 157 // Setting up the correct dimensions if Portrait (default) or Landscape 158 if ($this->orientation === 'landscape') { 159 $tmpw = $this->page_width; 160 $this->page_width = $this->page_height; 161 $this->page_height = $tmpw; 162 } 163 // Store the pagewidth without margins 164 $this->noMarginWidth = $this->page_width - $this->left_margin - $this->right_margin; 165 // If RTL 166 if ($this->rtl) { 167 $this->alignRTL = 'right'; 168 $this->entityRTL = '‏'; 169 } 170 // Change the default HTML font name 171 $this->default_font = 'Arial'; 172 173 if ($this->show_generated_by) { 174 // The default style name for Generated by.... is 'genby' 175 $element = new ReportHtmlCell(0, 10, 0, 'C', '', 'genby', 1, ReportBaseElement::CURRENT_POSITION, ReportBaseElement::CURRENT_POSITION, 0, 0, '', '', true); 176 $element->addText($this->generated_by); 177 $element->setUrl(Webtrees::URL); 178 $this->footerElements[] = $element; 179 } 180 } 181 182 /** 183 * Add an element. 184 * 185 * @param ReportBaseElement|string $element 186 * 187 * @return void 188 */ 189 public function addElement($element): void 190 { 191 if ($this->processing === 'B') { 192 $this->bodyElements[] = $element; 193 } elseif ($this->processing === 'H') { 194 $this->headerElements[] = $element; 195 } elseif ($this->processing === 'F') { 196 $this->footerElements[] = $element; 197 } 198 } 199 200 /** 201 * Generate footnotes 202 * 203 * @return void 204 */ 205 public function footnotes(): void 206 { 207 $this->currentStyle = ''; 208 if (!empty($this->printedfootnotes)) { 209 foreach ($this->printedfootnotes as $element) { 210 $element->renderFootnote($this); 211 } 212 } 213 } 214 215 /** 216 * Run the report. 217 * 218 * @return void 219 */ 220 public function run(): void 221 { 222 // Setting up the styles 223 echo '<style>'; 224 echo '#bodydiv { font: 10px sans-serif;}'; 225 foreach ($this->styles as $class => $style) { 226 echo '.', $class, ' { '; 227 if ($style['font'] === 'dejavusans') { 228 $style['font'] = $this->default_font; 229 } 230 echo 'font-family: ', $style['font'], '; '; 231 echo 'font-size: ', $style['size'], 'pt; '; 232 // Case-insensitive 233 if (stripos($style['style'], 'B') !== false) { 234 echo 'font-weight: bold; '; 235 } 236 if (stripos($style['style'], 'I') !== false) { 237 echo 'font-style: italic; '; 238 } 239 if (stripos($style['style'], 'U') !== false) { 240 echo 'text-decoration: underline; '; 241 } 242 if (stripos($style['style'], 'D') !== false) { 243 echo 'text-decoration: line-through; '; 244 } 245 echo '}', PHP_EOL; 246 } 247 248 //-- header divider 249 echo '</style>', PHP_EOL; 250 echo '<div id="headermargin" style="position: relative; top: auto; height: ', $this->header_margin, 'pt; width: ', $this->noMarginWidth, 'pt;"></div>'; 251 echo '<div id="headerdiv" style="position: relative; top: auto; width: ', $this->noMarginWidth, 'pt;">'; 252 foreach ($this->headerElements as $element) { 253 if ($element instanceof ReportBaseElement) { 254 $element->render($this); 255 } elseif ($element === 'footnotetexts') { 256 $this->footnotes(); 257 } elseif ($element === 'addpage') { 258 $this->addPage(); 259 } 260 } 261 //-- body 262 echo '</div>'; 263 echo '<script>document.getElementById("headerdiv").style.height="', $this->top_margin - $this->header_margin - 6, 'pt";</script>'; 264 echo '<div id="bodydiv" style="position: relative; top: auto; width: ', $this->noMarginWidth, 'pt; height: 100%;">'; 265 $this->Y = 0; 266 $this->maxY = 0; 267 foreach ($this->bodyElements as $element) { 268 if ($element instanceof ReportBaseElement) { 269 $element->render($this); 270 } elseif ($element === 'footnotetexts') { 271 $this->footnotes(); 272 } elseif ($element === 'addpage') { 273 $this->addPage(); 274 } 275 } 276 //-- footer 277 echo '</div>'; 278 echo '<script>document.getElementById("bodydiv").style.height="', $this->maxY, 'pt";</script>'; 279 echo '<div id="bottommargin" style="position: relative; top: auto; height: ', $this->bottom_margin - $this->footer_margin, 'pt;width:', $this->noMarginWidth, 'pt;"></div>'; 280 echo '<div id="footerdiv" style="position: relative; top: auto; width: ', $this->noMarginWidth, 'pt;height:auto;">'; 281 $this->Y = 0; 282 $this->X = 0; 283 $this->maxY = 0; 284 foreach ($this->footerElements as $element) { 285 if ($element instanceof ReportBaseElement) { 286 $element->render($this); 287 } elseif ($element === 'footnotetexts') { 288 $this->footnotes(); 289 } elseif ($element === 'addpage') { 290 $this->addPage(); 291 } 292 } 293 echo '</div>'; 294 echo '<script>document.getElementById("footerdiv").style.height="', $this->maxY, 'pt";</script>'; 295 echo '<div id="footermargin" style="position: relative; top: auto; height: ', $this->footer_margin, 'pt;width:', $this->noMarginWidth, 'pt;"></div>'; 296 } 297 298 /** 299 * Create a new Cell object. 300 * 301 * @param int $width cell width (expressed in points) 302 * @param int $height cell height (expressed in points) 303 * @param mixed $border Border style 304 * @param string $align Text alignement 305 * @param string $bgcolor Background color code 306 * @param string $style The name of the text style 307 * @param int $ln Indicates where the current position should go after the call 308 * @param mixed $top Y-position 309 * @param mixed $left X-position 310 * @param int $fill Indicates if the cell background must be painted (1) or transparent (0). Default value: 1 311 * @param int $stretch Stretch character mode 312 * @param string $bocolor Border color 313 * @param string $tcolor Text color 314 * @param bool $reseth 315 * 316 * @return ReportBaseCell 317 */ 318 public function createCell(int $width, int $height, $border, string $align, string $bgcolor, string $style, int $ln, $top, $left, int $fill, int $stretch, string $bocolor, string $tcolor, bool $reseth): ReportBaseCell 319 { 320 return new ReportHtmlCell($width, $height, $border, $align, $bgcolor, $style, $ln, $top, $left, $fill, $stretch, $bocolor, $tcolor, $reseth); 321 } 322 323 /** 324 * Create a new TextBox object. 325 * 326 * @param float $width Text box width 327 * @param float $height Text box height 328 * @param bool $border 329 * @param string $bgcolor Background color code in HTML 330 * @param bool $newline 331 * @param float $left 332 * @param float $top 333 * @param bool $pagecheck 334 * @param string $style 335 * @param bool $fill 336 * @param bool $padding 337 * @param bool $reseth 338 * 339 * @return ReportBaseTextbox 340 */ 341 public function createTextBox( 342 float $width, 343 float $height, 344 bool $border, 345 string $bgcolor, 346 bool $newline, 347 float $left, 348 float $top, 349 bool $pagecheck, 350 string $style, 351 bool $fill, 352 bool $padding, 353 bool $reseth 354 ): ReportBaseTextbox { 355 return new ReportHtmlTextbox($width, $height, $border, $bgcolor, $newline, $left, $top, $pagecheck, $style, $fill, $padding, $reseth); 356 } 357 358 /** 359 * Create a text element. 360 * 361 * @param string $style 362 * @param string $color 363 * 364 * @return ReportBaseText 365 */ 366 public function createText(string $style, string $color): ReportBaseText 367 { 368 return new ReportHtmlText($style, $color); 369 } 370 371 /** 372 * Create a new Footnote object. 373 * 374 * @param string $style Style name 375 * 376 * @return ReportBaseFootnote 377 */ 378 public function createFootnote(string $style): ReportBaseFootnote 379 { 380 return new ReportHtmlFootnote($style); 381 } 382 383 /** 384 * Create a new image object. 385 * 386 * @param string $file Filename 387 * @param float $x 388 * @param float $y 389 * @param float $w Image width 390 * @param float $h Image height 391 * @param string $align L:left, C:center, R:right or empty to use x/y 392 * @param string $ln T:same line, N:next line 393 * 394 * @return ReportBaseImage 395 */ 396 public function createImage(string $file, float $x, float $y, float $w, float $h, string $align, string $ln): ReportBaseImage 397 { 398 return new ReportHtmlImage($file, $x, $y, $w, $h, $align, $ln); 399 } 400 401 /** 402 * Create a new image object from Media Object. 403 * 404 * @param MediaFile $media_file 405 * @param float $x 406 * @param float $y 407 * @param float $w Image width 408 * @param float $h Image height 409 * @param string $align L:left, C:center, R:right or empty to use x/y 410 * @param string $ln T:same line, N:next line 411 * @param FilesystemOperator $data_filesystem 412 * 413 * @return ReportBaseImage 414 */ 415 public function createImageFromObject( 416 MediaFile $media_file, 417 float $x, 418 float $y, 419 float $w, 420 float $h, 421 string $align, 422 string $ln, 423 FilesystemOperator $data_filesystem 424 ): ReportBaseImage { 425 return new ReportHtmlImage($media_file->imageUrl((int) $w, (int) $h, 'crop'), $x, $y, $w, $h, $align, $ln); 426 } 427 428 /** 429 * Create a line. 430 * 431 * @param float $x1 432 * @param float $y1 433 * @param float $x2 434 * @param float $y2 435 * 436 * @return ReportBaseLine 437 */ 438 public function createLine(float $x1, float $y1, float $x2, float $y2): ReportBaseLine 439 { 440 return new ReportHtmlLine($x1, $y1, $x2, $y2); 441 } 442 443 /** 444 * Clear the Header 445 * 446 * @return void 447 */ 448 public function clearHeader(): void 449 { 450 $this->headerElements = []; 451 } 452 453 /** 454 * Update the Page Number and set a new Y if max Y is larger - ReportHtml 455 * 456 * @return void 457 */ 458 public function addPage(): void 459 { 460 $this->pageN++; 461 462 // Add a little margin to max Y "between pages" 463 $this->maxY += 10; 464 465 // If Y is still heigher by any reason... 466 if ($this->maxY < $this->Y) { 467 // ... update max Y 468 $this->maxY = $this->Y; 469 } else { 470 // else update Y so that nothing will be overwritten, like images or cells... 471 $this->Y = $this->maxY; 472 } 473 } 474 475 /** 476 * Uppdate max Y to keep track it incase of a pagebreak - ReportHtml 477 * 478 * @param float $y 479 * 480 * @return void 481 */ 482 public function addMaxY($y): void 483 { 484 if ($this->maxY < $y) { 485 $this->maxY = $y; 486 } 487 } 488 489 /** 490 * Checks the Footnote and numbers them - ReportHtml 491 * 492 * @param ReportHtmlFootnote $footnote 493 * 494 * @return ReportHtmlFootnote|bool object if already numbered, false otherwise 495 */ 496 public function checkFootnote(ReportHtmlFootnote $footnote) 497 { 498 $ct = count($this->printedfootnotes); 499 $i = 0; 500 $val = $footnote->getValue(); 501 while ($i < $ct) { 502 if ($this->printedfootnotes[$i]->getValue() === $val) { 503 // If this footnote already exist then set up the numbers for this object 504 $footnote->setNum($i + 1); 505 $footnote->setAddlink((string) ($i + 1)); 506 507 return $this->printedfootnotes[$i]; 508 } 509 $i++; 510 } 511 // If this Footnote has not been set up yet 512 $footnote->setNum($ct + 1); 513 $footnote->setAddlink((string) ($ct + 1)); 514 $this->printedfootnotes[] = $footnote; 515 516 return false; 517 } 518 519 /** 520 * Count the number of lines - ReportHtml 521 * 522 * @param string $str 523 * 524 * @return int Number of lines. 0 if empty line 525 */ 526 public function countLines($str): int 527 { 528 if ($str === '') { 529 return 0; 530 } 531 532 return substr_count($str, "\n") + 1; 533 } 534 535 /** 536 * Get the current style. 537 * 538 * @return string 539 */ 540 public function getCurrentStyle(): string 541 { 542 return $this->currentStyle; 543 } 544 545 /** 546 * Get the current style height. 547 * 548 * @return float 549 */ 550 public function getCurrentStyleHeight(): float 551 { 552 if (empty($this->currentStyle)) { 553 return $this->default_font_size; 554 } 555 $style = $this->getStyle($this->currentStyle); 556 557 return (float) $style['size']; 558 } 559 560 /** 561 * Get the current footnotes height. 562 * 563 * @param float $cellWidth 564 * 565 * @return float 566 */ 567 public function getFootnotesHeight(float $cellWidth): float 568 { 569 $h = 0; 570 foreach ($this->printedfootnotes as $element) { 571 $h += $element->getFootnoteHeight($this, $cellWidth); 572 } 573 574 return $h; 575 } 576 577 /** 578 * Get the maximum width from current position to the margin - ReportHtml 579 * 580 * @return float 581 */ 582 public function getRemainingWidth(): float 583 { 584 return $this->noMarginWidth - $this->X; 585 } 586 587 /** 588 * Get the page height. 589 * 590 * @return float 591 */ 592 public function getPageHeight(): float 593 { 594 return $this->page_height - $this->top_margin; 595 } 596 597 /** 598 * Get the width of a string. 599 * 600 * @param string $text 601 * 602 * @return float 603 */ 604 public function getStringWidth(string $text): float 605 { 606 $style = $this->getStyle($this->currentStyle); 607 608 return mb_strlen($text) * ($style['size'] / 2); 609 } 610 611 /** 612 * Get a text height in points - ReportHtml 613 * 614 * @param string $str 615 * 616 * @return float 617 */ 618 public function getTextCellHeight(string $str): float 619 { 620 // Count the number of lines to calculate the height 621 $nl = $this->countLines($str); 622 623 // Calculate the cell height 624 return ceil($this->getCurrentStyleHeight() * $this->cellHeightRatio * $nl); 625 } 626 627 /** 628 * Get the current X position - ReportHtml 629 * 630 * @return float 631 */ 632 public function getX(): float 633 { 634 return $this->X; 635 } 636 637 /** 638 * Get the current Y position - ReportHtml 639 * 640 * @return float 641 */ 642 public function getY(): float 643 { 644 return $this->Y; 645 } 646 647 /** 648 * Get the current page number - ReportHtml 649 * 650 * @return int 651 */ 652 public function pageNo(): int 653 { 654 return $this->pageN; 655 } 656 657 /** 658 * Set the current style. 659 * 660 * @param string $s 661 * 662 * @void 663 */ 664 public function setCurrentStyle(string $s): void 665 { 666 $this->currentStyle = $s; 667 } 668 669 /** 670 * Set the X position - ReportHtml 671 * 672 * @param float $x 673 * 674 * @return void 675 */ 676 public function setX(float $x): void 677 { 678 $this->X = $x; 679 } 680 681 /** 682 * Set the Y position - ReportHtml 683 * 684 * Also updates Max Y position 685 * 686 * @param float $y 687 * 688 * @return void 689 */ 690 public function setY(float $y): void 691 { 692 $this->Y = $y; 693 if ($this->maxY < $y) { 694 $this->maxY = $y; 695 } 696 } 697 698 /** 699 * Set the X and Y position - ReportHtml 700 * 701 * Also updates Max Y position 702 * 703 * @param float $x 704 * @param float $y 705 * 706 * @return void 707 */ 708 public function setXy(float $x, float $y): void 709 { 710 $this->setX($x); 711 $this->setY($y); 712 } 713 714 /** 715 * Wrap text - ReportHtml 716 * 717 * @param string $str Text to wrap 718 * @param float $width Width in points the text has to fit into 719 * 720 * @return string 721 */ 722 public function textWrap(string $str, float $width): string 723 { 724 $line_width = (int) ($width / ($this->getCurrentStyleHeight() / 2)); 725 726 $lines = explode("\n", $str); 727 728 $lines = array_map(fn (string $string): string => $this->utf8WordWrap($string, $line_width), $lines); 729 730 return implode("\n", $lines); 731 } 732 733 /** 734 * Wrap text, similar to the PHP wordwrap() function. 735 * 736 * @param string $string 737 * @param int $width 738 * 739 * @return string 740 */ 741 private function utf8WordWrap(string $string, int $width): string 742 { 743 $out = ''; 744 while ($string) { 745 if (mb_strlen($string) <= $width) { 746 // Do not wrap any text that is less than the output area. 747 $out .= $string; 748 $string = ''; 749 } else { 750 $sub1 = mb_substr($string, 0, $width + 1); 751 if (mb_substr($string, mb_strlen($sub1) - 1, 1) === ' ') { 752 // include words that end by a space immediately after the area. 753 $sub = $sub1; 754 } else { 755 $sub = mb_substr($string, 0, $width); 756 } 757 $spacepos = strrpos($sub, ' '); 758 if ($spacepos === false) { 759 // No space on line? 760 $out .= $sub . "\n"; 761 $string = mb_substr($string, mb_strlen($sub)); 762 } else { 763 // Split at space; 764 $out .= substr($string, 0, $spacepos) . "\n"; 765 $string = substr($string, $spacepos + 1); 766 } 767 } 768 } 769 770 return $out; 771 } 772 773 /** 774 * Write text - ReportHtml 775 * 776 * @param string $text Text to print 777 * @param string $color HTML RGB color code (Ex: #001122) 778 * @param bool $useclass 779 * 780 * @return void 781 */ 782 public function write(string $text, string $color = '', bool $useclass = true): void 783 { 784 $style = $this->getStyle($this->getCurrentStyle()); 785 $htmlcode = '<span dir="' . I18N::direction() . '"'; 786 if ($useclass) { 787 $htmlcode .= ' class="' . $style['name'] . '"'; 788 } 789 // Check if Text Color is set and if it’s valid HTML color 790 if (preg_match('/#?(..)(..)(..)/', $color)) { 791 $htmlcode .= ' style="color:' . $color . ';"'; 792 } 793 794 $htmlcode .= '>' . $text . '</span>'; 795 $htmlcode = str_replace([ 796 "\n", 797 '> ', 798 ' <', 799 ], [ 800 '<br>', 801 '> ', 802 ' <', 803 ], $htmlcode); 804 echo $htmlcode; 805 } 806} 807