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