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