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