xref: /webtrees/app/Report/ReportParserGenerate.php (revision 67cf9a45054a643ca0b20b35b3cbb1cf82fec9b9)
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 DomainException;
23use Fisharebest\Webtrees\Auth;
24use Fisharebest\Webtrees\Carbon;
25use Fisharebest\Webtrees\Date;
26use Fisharebest\Webtrees\Elements\UnknownElement;
27use Fisharebest\Webtrees\Family;
28use Fisharebest\Webtrees\Functions\Functions;
29use Fisharebest\Webtrees\Gedcom;
30use Fisharebest\Webtrees\GedcomRecord;
31use Fisharebest\Webtrees\I18N;
32use Fisharebest\Webtrees\Individual;
33use Fisharebest\Webtrees\Log;
34use Fisharebest\Webtrees\MediaFile;
35use Fisharebest\Webtrees\Note;
36use Fisharebest\Webtrees\Place;
37use Fisharebest\Webtrees\Registry;
38use Fisharebest\Webtrees\Tree;
39use Illuminate\Database\Capsule\Manager as DB;
40use Illuminate\Database\Query\Builder;
41use Illuminate\Database\Query\Expression;
42use Illuminate\Database\Query\JoinClause;
43use Illuminate\Support\Str;
44use League\Flysystem\FilesystemOperator;
45use LogicException;
46use stdClass;
47use Symfony\Component\Cache\Adapter\NullAdapter;
48use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
49
50use function addcslashes;
51use function addslashes;
52use function array_pop;
53use function array_shift;
54use function assert;
55use function call_user_func;
56use function count;
57use function end;
58use function explode;
59use function file;
60use function file_exists;
61use function getimagesize;
62use function imagecreatefromstring;
63use function imagesx;
64use function imagesy;
65use function in_array;
66use function method_exists;
67use function preg_match;
68use function preg_match_all;
69use function preg_replace;
70use function preg_replace_callback;
71use function preg_split;
72use function reset;
73use function round;
74use function sprintf;
75use function str_contains;
76use function str_replace;
77use function str_starts_with;
78use function strip_tags;
79use function strlen;
80use function strtoupper;
81use function substr;
82use function trim;
83use function uasort;
84use function xml_error_string;
85use function xml_get_current_line_number;
86use function xml_get_error_code;
87use function xml_parse;
88use function xml_parser_create;
89use function xml_parser_free;
90use function xml_parser_set_option;
91use function xml_set_character_data_handler;
92use function xml_set_element_handler;
93
94use const PREG_SET_ORDER;
95use const XML_OPTION_CASE_FOLDING;
96
97/**
98 * Class ReportParserGenerate - parse a report.xml file and generate the report.
99 */
100class ReportParserGenerate extends ReportParserBase
101{
102    /** @var bool Are we collecting data from <Footnote> elements */
103    private $process_footnote = true;
104
105    /** @var bool Are we currently outputing data? */
106    private $print_data = false;
107
108    /** @var bool[] Push-down stack of $print_data */
109    private $print_data_stack = [];
110
111    /** @var int Are we processing GEDCOM data */
112    private $process_gedcoms = 0;
113
114    /** @var int Are we processing conditionals */
115    private $process_ifs = 0;
116
117    /** @var int Are we processing repeats */
118    private $process_repeats = 0;
119
120    /** @var int Quantity of data to repeat during loops */
121    private $repeat_bytes = 0;
122
123    /** @var string[] Repeated data when iterating over loops */
124    private $repeats = [];
125
126    /** @var array[] Nested repeating data */
127    private $repeats_stack = [];
128
129    /** @var AbstractRenderer[] Nested repeating data */
130    private $wt_report_stack = [];
131
132    /** @var resource Nested repeating data */
133    private $parser;
134
135    /** @var resource[] Nested repeating data */
136    private $parser_stack = [];
137
138    /** @var string The current GEDCOM record */
139    private $gedrec = '';
140
141    /** @var string[] Nested GEDCOM records */
142    private $gedrec_stack = [];
143
144    /** @var ReportBaseElement The currently processed element */
145    private $current_element;
146
147    /** @var ReportBaseElement The currently processed element */
148    private $footnote_element;
149
150    /** @var string The GEDCOM fact currently being processed */
151    private $fact = '';
152
153    /** @var string The GEDCOM value currently being processed */
154    private $desc = '';
155
156    /** @var string The GEDCOM type currently being processed */
157    private $type = '';
158
159    /** @var int The current generational level */
160    private $generation = 1;
161
162    /** @var array Source data for processing lists */
163    private $list = [];
164
165    /** @var int Number of items in lists */
166    private $list_total = 0;
167
168    /** @var int Number of items filtered from lists */
169    private $list_private = 0;
170
171    /** @var string The filename of the XML report */
172    protected $report;
173
174    /** @var AbstractRenderer A factory for creating report elements */
175    private $report_root;
176
177    /** @var AbstractRenderer Nested report elements */
178    private $wt_report;
179
180    /** @var string[][] Variables defined in the report at run-time */
181    private $vars;
182
183    /** @var Tree The current tree */
184    private $tree;
185
186    /** @var FilesystemOperator */
187    private $data_filesystem;
188
189    /**
190     * Create a parser for a report
191     *
192     * @param string             $report The XML filename
193     * @param AbstractRenderer   $report_root
194     * @param string[][]         $vars
195     * @param Tree               $tree
196     * @param FilesystemOperator $data_filesystem
197     */
198    public function __construct(
199        string $report,
200        AbstractRenderer $report_root,
201        array $vars,
202        Tree $tree,
203        FilesystemOperator $data_filesystem
204    ) {
205        $this->report          = $report;
206        $this->report_root     = $report_root;
207        $this->wt_report       = $report_root;
208        $this->current_element = new ReportBaseElement();
209        $this->vars            = $vars;
210        $this->tree            = $tree;
211        $this->data_filesystem = $data_filesystem;
212
213        parent::__construct($report);
214    }
215
216    /**
217     * XML start element handler
218     * This function is called whenever a starting element is reached
219     * The element handler will be called if found, otherwise it must be HTML
220     *
221     * @param resource $parser the resource handler for the XML parser
222     * @param string   $name   the name of the XML element parsed
223     * @param string[] $attrs  an array of key value pairs for the attributes
224     *
225     * @return void
226     */
227    protected function startElement($parser, string $name, array $attrs): void
228    {
229        $newattrs = [];
230
231        foreach ($attrs as $key => $value) {
232            if (preg_match("/^\\$(\w+)$/", $value, $match)) {
233                if (isset($this->vars[$match[1]]['id']) && !isset($this->vars[$match[1]]['gedcom'])) {
234                    $value = $this->vars[$match[1]]['id'];
235                }
236            }
237            $newattrs[$key] = $value;
238        }
239        $attrs = $newattrs;
240        if ($this->process_footnote && ($this->process_ifs === 0 || $name === 'if') && ($this->process_gedcoms === 0 || $name === 'Gedcom') && ($this->process_repeats === 0 || $name === 'Facts' || $name === 'RepeatTag')) {
241            $method = $name . 'StartHandler';
242
243            if (method_exists($this, $method)) {
244                call_user_func([$this, $method], $attrs);
245            }
246        }
247    }
248
249    /**
250     * XML end element handler
251     * This function is called whenever an ending element is reached
252     * The element handler will be called if found, otherwise it must be HTML
253     *
254     * @param resource $parser the resource handler for the XML parser
255     * @param string   $name   the name of the XML element parsed
256     *
257     * @return void
258     */
259    protected function endElement($parser, string $name): void
260    {
261        if (($this->process_footnote || $name === 'Footnote') && ($this->process_ifs === 0 || $name === 'if') && ($this->process_gedcoms === 0 || $name === 'Gedcom') && ($this->process_repeats === 0 || $name === 'Facts' || $name === 'RepeatTag' || $name === 'List' || $name === 'Relatives')) {
262            $method = $name . 'EndHandler';
263
264            if (method_exists($this, $method)) {
265                call_user_func([$this, $method]);
266            }
267        }
268    }
269
270    /**
271     * XML character data handler
272     *
273     * @param resource $parser the resource handler for the XML parser
274     * @param string   $data   the name of the XML element parsed
275     *
276     * @return void
277     */
278    protected function characterData($parser, string $data): void
279    {
280        if ($this->print_data && $this->process_gedcoms === 0 && $this->process_ifs === 0 && $this->process_repeats === 0) {
281            $this->current_element->addText($data);
282        }
283    }
284
285    /**
286     * Handle <style>
287     *
288     * @param string[] $attrs
289     *
290     * @return void
291     */
292    protected function styleStartHandler(array $attrs): void
293    {
294        if (empty($attrs['name'])) {
295            throw new DomainException('REPORT ERROR Style: The "name" of the style is missing or not set in the XML file.');
296        }
297
298        // array Style that will be passed on
299        $s = [];
300
301        // string Name af the style
302        $s['name'] = $attrs['name'];
303
304        // string Name of the DEFAULT font
305        $s['font'] = $this->wt_report->default_font;
306        if (!empty($attrs['font'])) {
307            $s['font'] = $attrs['font'];
308        }
309
310        // int The size of the font in points
311        $s['size'] = $this->wt_report->default_font_size;
312        if (!empty($attrs['size'])) {
313            // Get it as int to ignore all decimal points or text (if no text then 0)
314            $s['size'] = (string) (int) $attrs['size'];
315        }
316
317        // string B: bold, I: italic, U: underline, D: line trough, The default value is regular.
318        $s['style'] = '';
319        if (!empty($attrs['style'])) {
320            $s['style'] = $attrs['style'];
321        }
322
323        $this->wt_report->addStyle($s);
324    }
325
326    /**
327     * Handle <doc>
328     * Sets up the basics of the document proparties
329     *
330     * @param string[] $attrs
331     *
332     * @return void
333     */
334    protected function docStartHandler(array $attrs): void
335    {
336        $this->parser = $this->xml_parser;
337
338        // Custom page width
339        if (!empty($attrs['customwidth'])) {
340            $this->wt_report->page_width = (int) $attrs['customwidth'];
341        } // Get it as int to ignore all decimal points or text (if any text then int(0))
342        // Custom Page height
343        if (!empty($attrs['customheight'])) {
344            $this->wt_report->page_height = (int) $attrs['customheight'];
345        } // Get it as int to ignore all decimal points or text (if any text then int(0))
346
347        // Left Margin
348        if (isset($attrs['leftmargin'])) {
349            if ($attrs['leftmargin'] === '0') {
350                $this->wt_report->left_margin = 0;
351            } elseif (!empty($attrs['leftmargin'])) {
352                $this->wt_report->left_margin = (int) $attrs['leftmargin']; // Get it as int to ignore all decimal points or text (if any text then int(0))
353            }
354        }
355        // Right Margin
356        if (isset($attrs['rightmargin'])) {
357            if ($attrs['rightmargin'] === '0') {
358                $this->wt_report->right_margin = 0;
359            } elseif (!empty($attrs['rightmargin'])) {
360                $this->wt_report->right_margin = (int) $attrs['rightmargin']; // Get it as int to ignore all decimal points or text (if any text then int(0))
361            }
362        }
363        // Top Margin
364        if (isset($attrs['topmargin'])) {
365            if ($attrs['topmargin'] === '0') {
366                $this->wt_report->top_margin = 0;
367            } elseif (!empty($attrs['topmargin'])) {
368                $this->wt_report->top_margin = (int) $attrs['topmargin']; // Get it as int to ignore all decimal points or text (if any text then int(0))
369            }
370        }
371        // Bottom Margin
372        if (isset($attrs['bottommargin'])) {
373            if ($attrs['bottommargin'] === '0') {
374                $this->wt_report->bottom_margin = 0;
375            } elseif (!empty($attrs['bottommargin'])) {
376                $this->wt_report->bottom_margin = (int) $attrs['bottommargin']; // Get it as int to ignore all decimal points or text (if any text then int(0))
377            }
378        }
379        // Header Margin
380        if (isset($attrs['headermargin'])) {
381            if ($attrs['headermargin'] === '0') {
382                $this->wt_report->header_margin = 0;
383            } elseif (!empty($attrs['headermargin'])) {
384                $this->wt_report->header_margin = (int) $attrs['headermargin']; // Get it as int to ignore all decimal points or text (if any text then int(0))
385            }
386        }
387        // Footer Margin
388        if (isset($attrs['footermargin'])) {
389            if ($attrs['footermargin'] === '0') {
390                $this->wt_report->footer_margin = 0;
391            } elseif (!empty($attrs['footermargin'])) {
392                $this->wt_report->footer_margin = (int) $attrs['footermargin']; // Get it as int to ignore all decimal points or text (if any text then int(0))
393            }
394        }
395
396        // Page Orientation
397        if (!empty($attrs['orientation'])) {
398            if ($attrs['orientation'] === 'landscape') {
399                $this->wt_report->orientation = 'landscape';
400            } elseif ($attrs['orientation'] === 'portrait') {
401                $this->wt_report->orientation = 'portrait';
402            }
403        }
404        // Page Size
405        if (!empty($attrs['pageSize'])) {
406            $this->wt_report->page_format = strtoupper($attrs['pageSize']);
407        }
408
409        // Show Generated By...
410        if (isset($attrs['showGeneratedBy'])) {
411            if ($attrs['showGeneratedBy'] === '0') {
412                $this->wt_report->show_generated_by = false;
413            } elseif ($attrs['showGeneratedBy'] === '1') {
414                $this->wt_report->show_generated_by = true;
415            }
416        }
417
418        $this->wt_report->setup();
419    }
420
421    /**
422     * Handle </doc>
423     *
424     * @return void
425     */
426    protected function docEndHandler(): void
427    {
428        $this->wt_report->run();
429    }
430
431    /**
432     * Handle <header>
433     *
434     * @return void
435     */
436    protected function headerStartHandler(): void
437    {
438        // Clear the Header before any new elements are added
439        $this->wt_report->clearHeader();
440        $this->wt_report->setProcessing('H');
441    }
442
443    /**
444     * Handle <body>
445     *
446     * @return void
447     */
448    protected function bodyStartHandler(): void
449    {
450        $this->wt_report->setProcessing('B');
451    }
452
453    /**
454     * Handle <footer>
455     *
456     * @return void
457     */
458    protected function footerStartHandler(): void
459    {
460        $this->wt_report->setProcessing('F');
461    }
462
463    /**
464     * Handle <cell>
465     *
466     * @param string[] $attrs
467     *
468     * @return void
469     */
470    protected function cellStartHandler(array $attrs): void
471    {
472        // string The text alignment of the text in this box.
473        $align = '';
474        if (!empty($attrs['align'])) {
475            $align = $attrs['align'];
476            // RTL supported left/right alignment
477            if ($align === 'rightrtl') {
478                if ($this->wt_report->rtl) {
479                    $align = 'left';
480                } else {
481                    $align = 'right';
482                }
483            } elseif ($align === 'leftrtl') {
484                if ($this->wt_report->rtl) {
485                    $align = 'right';
486                } else {
487                    $align = 'left';
488                }
489            }
490        }
491
492        // string The color to fill the background of this cell
493        $bgcolor = '';
494        if (!empty($attrs['bgcolor'])) {
495            $bgcolor = $attrs['bgcolor'];
496        }
497
498        // int Whether or not the background should be painted
499        $fill = 1;
500        if (isset($attrs['fill'])) {
501            if ($attrs['fill'] === '0') {
502                $fill = 0;
503            } elseif ($attrs['fill'] === '1') {
504                $fill = 1;
505            }
506        }
507
508        $reseth = true;
509        // boolean   if true reset the last cell height (default true)
510        if (isset($attrs['reseth'])) {
511            if ($attrs['reseth'] === '0') {
512                $reseth = false;
513            } elseif ($attrs['reseth'] === '1') {
514                $reseth = true;
515            }
516        }
517
518        // mixed Whether or not a border should be printed around this box
519        $border = 0;
520        if (!empty($attrs['border'])) {
521            $border = $attrs['border'];
522        }
523        // string Border color in HTML code
524        $bocolor = '';
525        if (!empty($attrs['bocolor'])) {
526            $bocolor = $attrs['bocolor'];
527        }
528
529        // int Cell height (expressed in points) The starting height of this cell. If the text wraps the height will automatically be adjusted.
530        $height = 0;
531        if (!empty($attrs['height'])) {
532            $height = $attrs['height'];
533        }
534        // int Cell width (expressed in points) Setting the width to 0 will make it the width from the current location to the right margin.
535        $width = 0;
536        if (!empty($attrs['width'])) {
537            $width = $attrs['width'];
538        }
539
540        // int Stretch carachter mode
541        $stretch = 0;
542        if (!empty($attrs['stretch'])) {
543            $stretch = (int) $attrs['stretch'];
544        }
545
546        // mixed Position the left corner of this box on the page. The default is the current position.
547        $left = ReportBaseElement::CURRENT_POSITION;
548        if (isset($attrs['left'])) {
549            if ($attrs['left'] === '.') {
550                $left = ReportBaseElement::CURRENT_POSITION;
551            } elseif (!empty($attrs['left'])) {
552                $left = (int) $attrs['left'];
553            } elseif ($attrs['left'] === '0') {
554                $left = 0;
555            }
556        }
557        // mixed Position the top corner of this box on the page. the default is the current position
558        $top = ReportBaseElement::CURRENT_POSITION;
559        if (isset($attrs['top'])) {
560            if ($attrs['top'] === '.') {
561                $top = ReportBaseElement::CURRENT_POSITION;
562            } elseif (!empty($attrs['top'])) {
563                $top = (int) $attrs['top'];
564            } elseif ($attrs['top'] === '0') {
565                $top = 0;
566            }
567        }
568
569        // string The name of the Style that should be used to render the text.
570        $style = '';
571        if (!empty($attrs['style'])) {
572            $style = $attrs['style'];
573        }
574
575        // string Text color in html code
576        $tcolor = '';
577        if (!empty($attrs['tcolor'])) {
578            $tcolor = $attrs['tcolor'];
579        }
580
581        // int Indicates where the current position should go after the call.
582        $ln = 0;
583        if (isset($attrs['newline'])) {
584            if (!empty($attrs['newline'])) {
585                $ln = (int) $attrs['newline'];
586            } elseif ($attrs['newline'] === '0') {
587                $ln = 0;
588            }
589        }
590
591        if ($align === 'left') {
592            $align = 'L';
593        } elseif ($align === 'right') {
594            $align = 'R';
595        } elseif ($align === 'center') {
596            $align = 'C';
597        } elseif ($align === 'justify') {
598            $align = 'J';
599        }
600
601        $this->print_data_stack[] = $this->print_data;
602        $this->print_data         = true;
603
604        $this->current_element = $this->report_root->createCell(
605            (int) $width,
606            (int) $height,
607            $border,
608            $align,
609            $bgcolor,
610            $style,
611            $ln,
612            $top,
613            $left,
614            $fill,
615            $stretch,
616            $bocolor,
617            $tcolor,
618            $reseth
619        );
620    }
621
622    /**
623     * Handle </cell>
624     *
625     * @return void
626     */
627    protected function cellEndHandler(): void
628    {
629        $this->print_data = array_pop($this->print_data_stack);
630        $this->wt_report->addElement($this->current_element);
631    }
632
633    /**
634     * Handle <now />
635     *
636     * @return void
637     */
638    protected function nowStartHandler(): void
639    {
640        $this->current_element->addText(Carbon::now()->local()->isoFormat('LLLL'));
641    }
642
643    /**
644     * Handle <pageNum />
645     *
646     * @return void
647     */
648    protected function pageNumStartHandler(): void
649    {
650        $this->current_element->addText('#PAGENUM#');
651    }
652
653    /**
654     * Handle <totalPages />
655     *
656     * @return void
657     */
658    protected function totalPagesStartHandler(): void
659    {
660        $this->current_element->addText('{{:ptp:}}');
661    }
662
663    /**
664     * Called at the start of an element.
665     *
666     * @param string[] $attrs an array of key value pairs for the attributes
667     *
668     * @return void
669     */
670    protected function gedcomStartHandler(array $attrs): void
671    {
672        if ($this->process_gedcoms > 0) {
673            $this->process_gedcoms++;
674
675            return;
676        }
677
678        $tag       = $attrs['id'];
679        $tag       = str_replace('@fact', $this->fact, $tag);
680        $tags      = explode(':', $tag);
681        $newgedrec = '';
682        if (count($tags) < 2) {
683            $tmp       = Registry::gedcomRecordFactory()->make($attrs['id'], $this->tree);
684            $newgedrec = $tmp ? $tmp->privatizeGedcom(Auth::accessLevel($this->tree)) : '';
685        }
686        if (empty($newgedrec)) {
687            $tgedrec   = $this->gedrec;
688            $newgedrec = '';
689            foreach ($tags as $tag) {
690                if (preg_match('/\$(.+)/', $tag, $match)) {
691                    if (isset($this->vars[$match[1]]['gedcom'])) {
692                        $newgedrec = $this->vars[$match[1]]['gedcom'];
693                    } else {
694                        $tmp       = Registry::gedcomRecordFactory()->make($match[1], $this->tree);
695                        $newgedrec = $tmp ? $tmp->privatizeGedcom(Auth::accessLevel($this->tree)) : '';
696                    }
697                } else {
698                    if (preg_match('/@(.+)/', $tag, $match)) {
699                        $gmatch = [];
700                        if (preg_match("/\d $match[1] @([^@]+)@/", $tgedrec, $gmatch)) {
701                            $tmp       = Registry::gedcomRecordFactory()->make($gmatch[1], $this->tree);
702                            $newgedrec = $tmp ? $tmp->privatizeGedcom(Auth::accessLevel($this->tree)) : '';
703                            $tgedrec   = $newgedrec;
704                        } else {
705                            $newgedrec = '';
706                            break;
707                        }
708                    } else {
709                        $level     = 1 + (int) explode(' ', trim($tgedrec))[0];
710                        $newgedrec = Functions::getSubRecord($level, "$level $tag", $tgedrec);
711                        $tgedrec   = $newgedrec;
712                    }
713                }
714            }
715        }
716        if (!empty($newgedrec)) {
717            $this->gedrec_stack[] = [$this->gedrec, $this->fact, $this->desc];
718            $this->gedrec         = $newgedrec;
719            if (preg_match("/(\d+) (_?[A-Z0-9]+) (.*)/", $this->gedrec, $match)) {
720                $this->fact = $match[2];
721                $this->desc = trim($match[3]);
722            }
723        } else {
724            $this->process_gedcoms++;
725        }
726    }
727
728    /**
729     * Called at the end of an element.
730     *
731     * @return void
732     */
733    protected function gedcomEndHandler(): void
734    {
735        if ($this->process_gedcoms > 0) {
736            $this->process_gedcoms--;
737        } else {
738            [$this->gedrec, $this->fact, $this->desc] = array_pop($this->gedrec_stack);
739        }
740    }
741
742    /**
743     * Handle <textBox>
744     *
745     * @param string[] $attrs
746     *
747     * @return void
748     */
749    protected function textBoxStartHandler(array $attrs): void
750    {
751        // string Background color code
752        $bgcolor = '';
753        if (!empty($attrs['bgcolor'])) {
754            $bgcolor = $attrs['bgcolor'];
755        }
756
757        // boolean Wether or not fill the background color
758        $fill = true;
759        if (isset($attrs['fill'])) {
760            if ($attrs['fill'] === '0') {
761                $fill = false;
762            } elseif ($attrs['fill'] === '1') {
763                $fill = true;
764            }
765        }
766
767        // var boolean Whether or not a border should be printed around this box. 0 = no border, 1 = border. Default is 0
768        $border = false;
769        if (isset($attrs['border'])) {
770            if ($attrs['border'] === '1') {
771                $border = true;
772            } elseif ($attrs['border'] === '0') {
773                $border = false;
774            }
775        }
776
777        // int The starting height of this cell. If the text wraps the height will automatically be adjusted
778        $height = 0;
779        if (!empty($attrs['height'])) {
780            $height = (int) $attrs['height'];
781        }
782        // int Setting the width to 0 will make it the width from the current location to the margin
783        $width = 0;
784        if (!empty($attrs['width'])) {
785            $width = (int) $attrs['width'];
786        }
787
788        // mixed Position the left corner of this box on the page. The default is the current position.
789        $left = ReportBaseElement::CURRENT_POSITION;
790        if (isset($attrs['left'])) {
791            if ($attrs['left'] === '.') {
792                $left = ReportBaseElement::CURRENT_POSITION;
793            } elseif (!empty($attrs['left'])) {
794                $left = (int) $attrs['left'];
795            } elseif ($attrs['left'] === '0') {
796                $left = 0;
797            }
798        }
799        // mixed Position the top corner of this box on the page. the default is the current position
800        $top = ReportBaseElement::CURRENT_POSITION;
801        if (isset($attrs['top'])) {
802            if ($attrs['top'] === '.') {
803                $top = ReportBaseElement::CURRENT_POSITION;
804            } elseif (!empty($attrs['top'])) {
805                $top = (int) $attrs['top'];
806            } elseif ($attrs['top'] === '0') {
807                $top = 0;
808            }
809        }
810        // boolean After this box is finished rendering, should the next section of text start immediately after the this box or should it start on a new line under this box. 0 = no new line, 1 = force new line. Default is 0
811        $newline = false;
812        if (isset($attrs['newline'])) {
813            if ($attrs['newline'] === '1') {
814                $newline = true;
815            } elseif ($attrs['newline'] === '0') {
816                $newline = false;
817            }
818        }
819        // boolean
820        $pagecheck = true;
821        if (isset($attrs['pagecheck'])) {
822            if ($attrs['pagecheck'] === '0') {
823                $pagecheck = false;
824            } elseif ($attrs['pagecheck'] === '1') {
825                $pagecheck = true;
826            }
827        }
828        // boolean Cell padding
829        $padding = true;
830        if (isset($attrs['padding'])) {
831            if ($attrs['padding'] === '0') {
832                $padding = false;
833            } elseif ($attrs['padding'] === '1') {
834                $padding = true;
835            }
836        }
837        // boolean Reset this box Height
838        $reseth = false;
839        if (isset($attrs['reseth'])) {
840            if ($attrs['reseth'] === '1') {
841                $reseth = true;
842            } elseif ($attrs['reseth'] === '0') {
843                $reseth = false;
844            }
845        }
846
847        // string Style of rendering
848        $style = '';
849
850        $this->print_data_stack[] = $this->print_data;
851        $this->print_data         = false;
852
853        $this->wt_report_stack[] = $this->wt_report;
854        $this->wt_report         = $this->report_root->createTextBox(
855            $width,
856            $height,
857            $border,
858            $bgcolor,
859            $newline,
860            $left,
861            $top,
862            $pagecheck,
863            $style,
864            $fill,
865            $padding,
866            $reseth
867        );
868    }
869
870    /**
871     * Handle <textBox>
872     *
873     * @return void
874     */
875    protected function textBoxEndHandler(): void
876    {
877        $this->print_data      = array_pop($this->print_data_stack);
878        $this->current_element = $this->wt_report;
879
880        // The TextBox handler is mis-using the wt_report attribute to store an element.
881        // Until this can be re-designed, we need this assertion to help static analysis tools.
882        assert($this->current_element instanceof ReportBaseElement, new LogicException());
883
884        $this->wt_report       = array_pop($this->wt_report_stack);
885        $this->wt_report->addElement($this->current_element);
886    }
887
888    /**
889     * XLM <Text>.
890     *
891     * @param string[] $attrs an array of key value pairs for the attributes
892     *
893     * @return void
894     */
895    protected function textStartHandler(array $attrs): void
896    {
897        $this->print_data_stack[] = $this->print_data;
898        $this->print_data         = true;
899
900        // string The name of the Style that should be used to render the text.
901        $style = '';
902        if (!empty($attrs['style'])) {
903            $style = $attrs['style'];
904        }
905
906        // string  The color of the text - Keep the black color as default
907        $color = '';
908        if (!empty($attrs['color'])) {
909            $color = $attrs['color'];
910        }
911
912        $this->current_element = $this->report_root->createText($style, $color);
913    }
914
915    /**
916     * Handle </text>
917     *
918     * @return void
919     */
920    protected function textEndHandler(): void
921    {
922        $this->print_data = array_pop($this->print_data_stack);
923        $this->wt_report->addElement($this->current_element);
924    }
925
926    /**
927     * Handle <getPersonName />
928     * Get the name
929     * 1. id is empty - current GEDCOM record
930     * 2. id is set with a record id
931     *
932     * @param string[] $attrs an array of key value pairs for the attributes
933     *
934     * @return void
935     */
936    protected function getPersonNameStartHandler(array $attrs): void
937    {
938        $id    = '';
939        $match = [];
940        if (empty($attrs['id'])) {
941            if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
942                $id = $match[1];
943            }
944        } else {
945            if (preg_match('/\$(.+)/', $attrs['id'], $match)) {
946                if (isset($this->vars[$match[1]]['id'])) {
947                    $id = $this->vars[$match[1]]['id'];
948                }
949            } else {
950                if (preg_match('/@(.+)/', $attrs['id'], $match)) {
951                    $gmatch = [];
952                    if (preg_match("/\d $match[1] @([^@]+)@/", $this->gedrec, $gmatch)) {
953                        $id = $gmatch[1];
954                    }
955                } else {
956                    $id = $attrs['id'];
957                }
958            }
959        }
960        if (!empty($id)) {
961            $record = Registry::gedcomRecordFactory()->make($id, $this->tree);
962            if ($record === null) {
963                return;
964            }
965            if (!$record->canShowName()) {
966                $this->current_element->addText(I18N::translate('Private'));
967            } else {
968                $name = $record->fullName();
969                $name = strip_tags($name);
970                if (!empty($attrs['truncate'])) {
971                    $name = Str::limit($name, (int) $attrs['truncate'], I18N::translate('…'));
972                } else {
973                    $addname = (string) $record->alternateName();
974                    $addname = strip_tags($addname);
975                    if (!empty($addname)) {
976                        $name .= ' ' . $addname;
977                    }
978                }
979                $this->current_element->addText(trim($name));
980            }
981        }
982    }
983
984    /**
985     * Handle <gedcomValue />
986     *
987     * @param string[] $attrs
988     *
989     * @return void
990     */
991    protected function gedcomValueStartHandler(array $attrs): void
992    {
993        $id    = '';
994        $match = [];
995        if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
996            $id = $match[1];
997        }
998
999        if (isset($attrs['newline']) && $attrs['newline'] === '1') {
1000            $useBreak = '1';
1001        } else {
1002            $useBreak = '0';
1003        }
1004
1005        $tag = $attrs['tag'];
1006        if (!empty($tag)) {
1007            if ($tag === '@desc') {
1008                $value = $this->desc;
1009                $value = trim($value);
1010                $this->current_element->addText($value);
1011            }
1012            if ($tag === '@id') {
1013                $this->current_element->addText($id);
1014            } else {
1015                $tag = str_replace('@fact', $this->fact, $tag);
1016                if (empty($attrs['level'])) {
1017                    $level = (int) explode(' ', trim($this->gedrec))[0];
1018                    if ($level === 0) {
1019                        $level++;
1020                    }
1021                } else {
1022                    $level = (int) $attrs['level'];
1023                }
1024                $tags  = preg_split('/[: ]/', $tag);
1025                $value = $this->getGedcomValue($tag, $level, $this->gedrec);
1026                switch (end($tags)) {
1027                    case 'DATE':
1028                        $tmp   = new Date($value);
1029                        $value = $tmp->display();
1030                        break;
1031                    case 'PLAC':
1032                        $tmp   = new Place($value, $this->tree);
1033                        $value = $tmp->shortName();
1034                        break;
1035                }
1036                if ($useBreak === '1') {
1037                    // Insert <br> when multiple dates exist.
1038                    // This works around a TCPDF bug that incorrectly wraps RTL dates on LTR pages
1039                    $value = str_replace('(', '<br>(', $value);
1040                    $value = str_replace('<span dir="ltr"><br>', '<br><span dir="ltr">', $value);
1041                    $value = str_replace('<span dir="rtl"><br>', '<br><span dir="rtl">', $value);
1042                    if (substr($value, 0, 4) === '<br>') {
1043                        $value = substr($value, 4);
1044                    }
1045                }
1046                $tmp = explode(':', $tag);
1047                if (in_array(end($tmp), ['NOTE', 'TEXT'], true)) {
1048                    if ($this->tree->getPreference('FORMAT_TEXT') === 'markdown') {
1049                        $value = strip_tags(Registry::markdownFactory()->markdown($this->tree)->convertToHtml($value));
1050                    } else {
1051                        $value = strip_tags(Registry::markdownFactory()->autolink($this->tree)->convertToHtml($value));
1052                    }
1053                }
1054
1055                if (!empty($attrs['truncate'])) {
1056                    $value = Str::limit($value, (int) $attrs['truncate'], I18N::translate('…'));
1057                }
1058                $this->current_element->addText($value);
1059            }
1060        }
1061    }
1062
1063    /**
1064     * Handle <repeatTag>
1065     *
1066     * @param string[] $attrs
1067     *
1068     * @return void
1069     */
1070    protected function repeatTagStartHandler(array $attrs): void
1071    {
1072        $this->process_repeats++;
1073        if ($this->process_repeats > 1) {
1074            return;
1075        }
1076
1077        $this->repeats_stack[] = [$this->repeats, $this->repeat_bytes];
1078        $this->repeats         = [];
1079        $this->repeat_bytes    = xml_get_current_line_number($this->parser);
1080
1081        $tag = $attrs['tag'] ?? '';
1082        if (!empty($tag)) {
1083            if ($tag === '@desc') {
1084                $value = $this->desc;
1085                $value = trim($value);
1086                $this->current_element->addText($value);
1087            } else {
1088                $tag   = str_replace('@fact', $this->fact, $tag);
1089                $tags  = explode(':', $tag);
1090                $level = (int) explode(' ', trim($this->gedrec))[0];
1091                if ($level === 0) {
1092                    $level++;
1093                }
1094                $subrec = $this->gedrec;
1095                $t      = $tag;
1096                $count  = count($tags);
1097                $i      = 0;
1098                while ($i < $count) {
1099                    $t = $tags[$i];
1100                    if (!empty($t)) {
1101                        if ($i < ($count - 1)) {
1102                            $subrec = Functions::getSubRecord($level, "$level $t", $subrec);
1103                            if (empty($subrec)) {
1104                                $level--;
1105                                $subrec = Functions::getSubRecord($level, "@ $t", $this->gedrec);
1106                                if (empty($subrec)) {
1107                                    return;
1108                                }
1109                            }
1110                        }
1111                        $level++;
1112                    }
1113                    $i++;
1114                }
1115                $level--;
1116                $count = preg_match_all("/$level $t(.*)/", $subrec, $match, PREG_SET_ORDER);
1117                $i     = 0;
1118                while ($i < $count) {
1119                    $i++;
1120                    // Privacy check - is this a link, and are we allowed to view the linked object?
1121                    $subrecord = Functions::getSubRecord($level, "$level $t", $subrec, $i);
1122                    if (preg_match('/^\d ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@/', $subrecord, $xref_match)) {
1123                        $linked_object = Registry::gedcomRecordFactory()->make($xref_match[1], $this->tree);
1124                        if ($linked_object && !$linked_object->canShow()) {
1125                            continue;
1126                        }
1127                    }
1128                    $this->repeats[] = $subrecord;
1129                }
1130            }
1131        }
1132    }
1133
1134    /**
1135     * Handle </repeatTag>
1136     *
1137     * @return void
1138     */
1139    protected function repeatTagEndHandler(): void
1140    {
1141        $this->process_repeats--;
1142        if ($this->process_repeats > 0) {
1143            return;
1144        }
1145
1146        // Check if there is anything to repeat
1147        if (count($this->repeats) > 0) {
1148            // No need to load them if not used...
1149
1150            $lineoffset = 0;
1151            foreach ($this->repeats_stack as $rep) {
1152                $lineoffset += $rep[1];
1153            }
1154            //-- read the xml from the file
1155            $lines = file($this->report);
1156            while (!str_contains($lines[$lineoffset + $this->repeat_bytes], '<RepeatTag')) {
1157                $lineoffset--;
1158            }
1159            $lineoffset++;
1160            $reportxml = "<tempdoc>\n";
1161            $line_nr   = $lineoffset + $this->repeat_bytes;
1162            // RepeatTag Level counter
1163            $count = 1;
1164            while (0 < $count) {
1165                if (str_contains($lines[$line_nr], '<RepeatTag')) {
1166                    $count++;
1167                } elseif (str_contains($lines[$line_nr], '</RepeatTag')) {
1168                    $count--;
1169                }
1170                if (0 < $count) {
1171                    $reportxml .= $lines[$line_nr];
1172                }
1173                $line_nr++;
1174            }
1175            // No need to drag this
1176            unset($lines);
1177            $reportxml .= "</tempdoc>\n";
1178            // Save original values
1179            $this->parser_stack[] = $this->parser;
1180            $oldgedrec            = $this->gedrec;
1181            foreach ($this->repeats as $gedrec) {
1182                $this->gedrec  = $gedrec;
1183                $repeat_parser = xml_parser_create();
1184                $this->parser  = $repeat_parser;
1185                xml_parser_set_option($repeat_parser, XML_OPTION_CASE_FOLDING, false);
1186
1187                xml_set_element_handler(
1188                    $repeat_parser,
1189                    function ($parser, string $name, array $attrs): void {
1190                        $this->startElement($parser, $name, $attrs);
1191                    },
1192                    function ($parser, string $name): void {
1193                        $this->endElement($parser, $name);
1194                    }
1195                );
1196
1197                xml_set_character_data_handler(
1198                    $repeat_parser,
1199                    function ($parser, string $data): void {
1200                        $this->characterData($parser, $data);
1201                    }
1202                );
1203
1204                if (!xml_parse($repeat_parser, $reportxml, true)) {
1205                    throw new DomainException(sprintf(
1206                        'RepeatTagEHandler XML error: %s at line %d',
1207                        xml_error_string(xml_get_error_code($repeat_parser)),
1208                        xml_get_current_line_number($repeat_parser)
1209                    ));
1210                }
1211                xml_parser_free($repeat_parser);
1212            }
1213            // Restore original values
1214            $this->gedrec = $oldgedrec;
1215            $this->parser = array_pop($this->parser_stack);
1216        }
1217        [$this->repeats, $this->repeat_bytes] = array_pop($this->repeats_stack);
1218    }
1219
1220    /**
1221     * Variable lookup
1222     * Retrieve predefined variables :
1223     * @ desc GEDCOM fact description, example:
1224     *        1 EVEN This is a description
1225     * @ fact GEDCOM fact tag, such as BIRT, DEAT etc.
1226     * $ I18N::translate('....')
1227     * $ language_settings[]
1228     *
1229     * @param string[] $attrs an array of key value pairs for the attributes
1230     *
1231     * @return void
1232     */
1233    protected function varStartHandler(array $attrs): void
1234    {
1235        if (empty($attrs['var'])) {
1236            throw new DomainException('REPORT ERROR var: The attribute "var=" is missing or not set in the XML file on line: ' . xml_get_current_line_number($this->parser));
1237        }
1238
1239        $var = $attrs['var'];
1240        // SetVar element preset variables
1241        if (!empty($this->vars[$var]['id'])) {
1242            $var = $this->vars[$var]['id'];
1243        } else {
1244            $tfact = $this->fact;
1245            if (($this->fact === 'EVEN' || $this->fact === 'FACT') && $this->type !== '') {
1246                // Use :
1247                // n TYPE This text if string
1248                $tfact = $this->type;
1249            } else {
1250                foreach ([Individual::RECORD_TYPE, Family::RECORD_TYPE] as $record_type) {
1251                    $element = Registry::elementFactory()->make($record_type . ':' . $this->fact);
1252
1253                    if (!$element instanceof UnknownElement) {
1254                        $tfact = $element->label();
1255                        break;
1256                    }
1257                }
1258            }
1259
1260            $var = strtr($var, ['@desc' => $this->desc, '@fact' => $tfact]);
1261
1262            if (preg_match('/^I18N::number\((.+)\)$/', $var, $match)) {
1263                $var = I18N::number((int) $match[1]);
1264            } elseif (preg_match('/^I18N::translate\(\'(.+)\'\)$/', $var, $match)) {
1265                $var = I18N::translate($match[1]);
1266            } elseif (preg_match('/^I18N::translateContext\(\'(.+)\', *\'(.+)\'\)$/', $var, $match)) {
1267                $var = I18N::translateContext($match[1], $match[2]);
1268            }
1269        }
1270        // Check if variable is set as a date and reformat the date
1271        if (isset($attrs['date'])) {
1272            if ($attrs['date'] === '1') {
1273                $g   = new Date($var);
1274                $var = $g->display();
1275            }
1276        }
1277        $this->current_element->addText($var);
1278        $this->text = $var; // Used for title/descriptio
1279    }
1280
1281    /**
1282     * Handle <facts>
1283     *
1284     * @param string[] $attrs
1285     *
1286     * @return void
1287     */
1288    protected function factsStartHandler(array $attrs): void
1289    {
1290        $this->process_repeats++;
1291        if ($this->process_repeats > 1) {
1292            return;
1293        }
1294
1295        $this->repeats_stack[] = [$this->repeats, $this->repeat_bytes];
1296        $this->repeats         = [];
1297        $this->repeat_bytes    = xml_get_current_line_number($this->parser);
1298
1299        $id    = '';
1300        $match = [];
1301        if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
1302            $id = $match[1];
1303        }
1304        $tag = '';
1305        if (isset($attrs['ignore'])) {
1306            $tag .= $attrs['ignore'];
1307        }
1308        if (preg_match('/\$(.+)/', $tag, $match)) {
1309            $tag = $this->vars[$match[1]]['id'];
1310        }
1311
1312        $record = Registry::gedcomRecordFactory()->make($id, $this->tree);
1313        if (empty($attrs['diff']) && !empty($id)) {
1314            $facts = $record->facts([], true);
1315            $this->repeats = [];
1316            $nonfacts      = explode(',', $tag);
1317            foreach ($facts as $fact) {
1318                $tag = explode(':', $fact->tag())[1];
1319
1320                if (!in_array($tag, $nonfacts, true)) {
1321                    $this->repeats[] = $fact->gedcom();
1322                }
1323            }
1324        } else {
1325            foreach ($record->facts() as $fact) {
1326                if (($fact->isPendingAddition() || $fact->isPendingDeletion()) && !str_ends_with($fact->tag(), ':CHAN')) {
1327                    $this->repeats[] = $fact->gedcom();
1328                }
1329            }
1330        }
1331    }
1332
1333    /**
1334     * Handle </facts>
1335     *
1336     * @return void
1337     */
1338    protected function factsEndHandler(): void
1339    {
1340        $this->process_repeats--;
1341        if ($this->process_repeats > 0) {
1342            return;
1343        }
1344
1345        // Check if there is anything to repeat
1346        if (count($this->repeats) > 0) {
1347            $line       = xml_get_current_line_number($this->parser) - 1;
1348            $lineoffset = 0;
1349            foreach ($this->repeats_stack as $rep) {
1350                $lineoffset += $rep[1];
1351            }
1352
1353            //-- read the xml from the file
1354            $lines = file($this->report);
1355            while ($lineoffset + $this->repeat_bytes > 0 && !str_contains($lines[$lineoffset + $this->repeat_bytes], '<Facts ')) {
1356                $lineoffset--;
1357            }
1358            $lineoffset++;
1359            $reportxml = "<tempdoc>\n";
1360            $i         = $line + $lineoffset;
1361            $line_nr   = $this->repeat_bytes + $lineoffset;
1362            while ($line_nr < $i) {
1363                $reportxml .= $lines[$line_nr];
1364                $line_nr++;
1365            }
1366            // No need to drag this
1367            unset($lines);
1368            $reportxml .= "</tempdoc>\n";
1369            // Save original values
1370            $this->parser_stack[] = $this->parser;
1371            $oldgedrec            = $this->gedrec;
1372            $count                = count($this->repeats);
1373            $i                    = 0;
1374            while ($i < $count) {
1375                $this->gedrec = $this->repeats[$i];
1376                $this->fact   = '';
1377                $this->desc   = '';
1378                if (preg_match('/1 (\w+)(.*)/', $this->gedrec, $match)) {
1379                    $this->fact = $match[1];
1380                    if ($this->fact === 'EVEN' || $this->fact === 'FACT') {
1381                        $tmatch = [];
1382                        if (preg_match('/2 TYPE (.+)/', $this->gedrec, $tmatch)) {
1383                            $this->type = trim($tmatch[1]);
1384                        } else {
1385                            $this->type = ' ';
1386                        }
1387                    }
1388                    $this->desc = trim($match[2]);
1389                    $this->desc .= Functions::getCont(2, $this->gedrec);
1390                }
1391                $repeat_parser = xml_parser_create();
1392                $this->parser  = $repeat_parser;
1393                xml_parser_set_option($repeat_parser, XML_OPTION_CASE_FOLDING, false);
1394
1395                xml_set_element_handler(
1396                    $repeat_parser,
1397                    function ($parser, string $name, array $attrs): void {
1398                        $this->startElement($parser, $name, $attrs);
1399                    },
1400                    function ($parser, string $name): void {
1401                        $this->endElement($parser, $name);
1402                    }
1403                );
1404
1405                xml_set_character_data_handler(
1406                    $repeat_parser,
1407                    function ($parser, string $data): void {
1408                        $this->characterData($parser, $data);
1409                    }
1410                );
1411
1412                if (!xml_parse($repeat_parser, $reportxml, true)) {
1413                    throw new DomainException(sprintf(
1414                        'FactsEHandler XML error: %s at line %d',
1415                        xml_error_string(xml_get_error_code($repeat_parser)),
1416                        xml_get_current_line_number($repeat_parser)
1417                    ));
1418                }
1419                xml_parser_free($repeat_parser);
1420                $i++;
1421            }
1422            // Restore original values
1423            $this->parser = array_pop($this->parser_stack);
1424            $this->gedrec = $oldgedrec;
1425        }
1426        [$this->repeats, $this->repeat_bytes] = array_pop($this->repeats_stack);
1427    }
1428
1429    /**
1430     * Setting upp or changing variables in the XML
1431     * The XML variable name and value is stored in $this->vars
1432     *
1433     * @param string[] $attrs an array of key value pairs for the attributes
1434     *
1435     * @return void
1436     */
1437    protected function setVarStartHandler(array $attrs): void
1438    {
1439        if (empty($attrs['name'])) {
1440            throw new DomainException('REPORT ERROR var: The attribute "name" is missing or not set in the XML file');
1441        }
1442
1443        $name  = $attrs['name'];
1444        $value = $attrs['value'];
1445        $match = [];
1446        // Current GEDCOM record strings
1447        if ($value === '@ID') {
1448            if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
1449                $value = $match[1];
1450            }
1451        } elseif ($value === '@fact') {
1452            $value = $this->fact;
1453        } elseif ($value === '@desc') {
1454            $value = $this->desc;
1455        } elseif ($value === '@generation') {
1456            $value = (string) $this->generation;
1457        } elseif (preg_match("/@(\w+)/", $value, $match)) {
1458            $gmatch = [];
1459            if (preg_match("/\d $match[1] (.+)/", $this->gedrec, $gmatch)) {
1460                $value = str_replace('@', '', trim($gmatch[1]));
1461            }
1462        }
1463        if (preg_match("/\\$(\w+)/", $name, $match)) {
1464            $name = $this->vars["'" . $match[1] . "'"]['id'];
1465        }
1466        $count = preg_match_all("/\\$(\w+)/", $value, $match, PREG_SET_ORDER);
1467        $i     = 0;
1468        while ($i < $count) {
1469            $t     = $this->vars[$match[$i][1]]['id'];
1470            $value = preg_replace('/\$' . $match[$i][1] . '/', $t, $value, 1);
1471            $i++;
1472        }
1473        if (preg_match('/^I18N::number\((.+)\)$/', $value, $match)) {
1474            $value = I18N::number((int) $match[1]);
1475        } elseif (preg_match('/^I18N::translate\(\'(.+)\'\)$/', $value, $match)) {
1476            $value = I18N::translate($match[1]);
1477        } elseif (preg_match('/^I18N::translateContext\(\'(.+)\', *\'(.+)\'\)$/', $value, $match)) {
1478            $value = I18N::translateContext($match[1], $match[2]);
1479        }
1480
1481        // Arithmetic functions
1482        if (preg_match("/(\d+)\s*([-+*\/])\s*(\d+)/", $value, $match)) {
1483            // Create an expression language with the functions used by our reports.
1484            $expression_provider  = new ReportExpressionLanguageProvider();
1485            $expression_cache     = new NullAdapter();
1486            $expression_language  = new ExpressionLanguage($expression_cache, [$expression_provider]);
1487
1488            $value = (string) $expression_language->evaluate($value);
1489        }
1490
1491        if (str_contains($value, '@')) {
1492            $value = '';
1493        }
1494        $this->vars[$name]['id'] = $value;
1495    }
1496
1497    /**
1498     * Handle <if>
1499     *
1500     * @param string[] $attrs
1501     *
1502     * @return void
1503     */
1504    protected function ifStartHandler(array $attrs): void
1505    {
1506        if ($this->process_ifs > 0) {
1507            $this->process_ifs++;
1508
1509            return;
1510        }
1511
1512        $condition = $attrs['condition'];
1513        $condition = $this->substituteVars($condition, true);
1514        $condition = str_replace([
1515            ' LT ',
1516            ' GT ',
1517        ], [
1518            '<',
1519            '>',
1520        ], $condition);
1521        // Replace the first occurrence only once of @fact:DATE or in any other combinations to the current fact, such as BIRT
1522        $condition = str_replace('@fact:', $this->fact . ':', $condition);
1523        $match     = [];
1524        $count     = preg_match_all("/@([\w:.]+)/", $condition, $match, PREG_SET_ORDER);
1525        $i         = 0;
1526        while ($i < $count) {
1527            $id    = $match[$i][1];
1528            $value = '""';
1529            if ($id === 'ID') {
1530                if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
1531                    $value = "'" . $match[1] . "'";
1532                }
1533            } elseif ($id === 'fact') {
1534                $value = '"' . $this->fact . '"';
1535            } elseif ($id === 'desc') {
1536                $value = '"' . addslashes($this->desc) . '"';
1537            } elseif ($id === 'generation') {
1538                $value = '"' . $this->generation . '"';
1539            } else {
1540                $level = (int) explode(' ', trim($this->gedrec))[0];
1541                if ($level === 0) {
1542                    $level++;
1543                }
1544                $value = $this->getGedcomValue($id, $level, $this->gedrec);
1545                if (empty($value)) {
1546                    $level++;
1547                    $value = $this->getGedcomValue($id, $level, $this->gedrec);
1548                }
1549                $value = preg_replace('/^@(' . Gedcom::REGEX_XREF . ')@$/', '$1', $value);
1550                $value = '"' . addslashes($value) . '"';
1551            }
1552            $condition = str_replace("@$id", $value, $condition);
1553            $i++;
1554        }
1555
1556        // Create an expression language with the functions used by our reports.
1557        $expression_provider  = new ReportExpressionLanguageProvider();
1558        $expression_cache     = new NullAdapter();
1559        $expression_language  = new ExpressionLanguage($expression_cache, [$expression_provider]);
1560
1561        $ret = $expression_language->evaluate($condition);
1562
1563        if (!$ret) {
1564            $this->process_ifs++;
1565        }
1566    }
1567
1568    /**
1569     * Handle </if>
1570     *
1571     * @return void
1572     */
1573    protected function ifEndHandler(): void
1574    {
1575        if ($this->process_ifs > 0) {
1576            $this->process_ifs--;
1577        }
1578    }
1579
1580    /**
1581     * Handle <footnote>
1582     * Collect the Footnote links
1583     * GEDCOM Records that are protected by Privacy setting will be ignored
1584     *
1585     * @param string[] $attrs
1586     *
1587     * @return void
1588     */
1589    protected function footnoteStartHandler(array $attrs): void
1590    {
1591        $id = '';
1592        if (preg_match('/[0-9] (.+) @(.+)@/', $this->gedrec, $match)) {
1593            $id = $match[2];
1594        }
1595        $record = Registry::gedcomRecordFactory()->make($id, $this->tree);
1596        if ($record && $record->canShow()) {
1597            $this->print_data_stack[] = $this->print_data;
1598            $this->print_data         = true;
1599            $style                    = '';
1600            if (!empty($attrs['style'])) {
1601                $style = $attrs['style'];
1602            }
1603            $this->footnote_element = $this->current_element;
1604            $this->current_element  = $this->report_root->createFootnote($style);
1605        } else {
1606            $this->print_data       = false;
1607            $this->process_footnote = false;
1608        }
1609    }
1610
1611    /**
1612     * Handle </footnote>
1613     * Print the collected Footnote data
1614     *
1615     * @return void
1616     */
1617    protected function footnoteEndHandler(): void
1618    {
1619        if ($this->process_footnote) {
1620            $this->print_data = array_pop($this->print_data_stack);
1621            $temp             = trim($this->current_element->getValue());
1622            if (strlen($temp) > 3) {
1623                $this->wt_report->addElement($this->current_element);
1624            }
1625            $this->current_element = $this->footnote_element;
1626        } else {
1627            $this->process_footnote = true;
1628        }
1629    }
1630
1631    /**
1632     * Handle <footnoteTexts />
1633     *
1634     * @return void
1635     */
1636    protected function footnoteTextsStartHandler(): void
1637    {
1638        $temp = 'footnotetexts';
1639        $this->wt_report->addElement($temp);
1640    }
1641
1642    /**
1643     * XML element Forced line break handler - HTML code
1644     *
1645     * @return void
1646     */
1647    protected function brStartHandler(): void
1648    {
1649        if ($this->print_data && $this->process_gedcoms === 0) {
1650            $this->current_element->addText('<br>');
1651        }
1652    }
1653
1654    /**
1655     * Handle <sp />
1656     * Forced space
1657     *
1658     * @return void
1659     */
1660    protected function spStartHandler(): void
1661    {
1662        if ($this->print_data && $this->process_gedcoms === 0) {
1663            $this->current_element->addText(' ');
1664        }
1665    }
1666
1667    /**
1668     * Handle <highlightedImage />
1669     *
1670     * @param string[] $attrs
1671     *
1672     * @return void
1673     */
1674    protected function highlightedImageStartHandler(array $attrs): void
1675    {
1676        $id = '';
1677        if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
1678            $id = $match[1];
1679        }
1680
1681        // Position the top corner of this box on the page
1682        $top = (float) ($attrs['top'] ?? ReportBaseElement::CURRENT_POSITION);
1683
1684        // Position the left corner of this box on the page
1685        $left = (float) ($attrs['left'] ?? ReportBaseElement::CURRENT_POSITION);
1686
1687        // string Align the image in left, center, right (or empty to use x/y position).
1688        $align = $attrs['align'] ?? '';
1689
1690        // string Next Line should be T:next to the image, N:next line
1691        $ln = $attrs['ln'] ?? 'T';
1692
1693        // Width, height (or both).
1694        $width  = (float) ($attrs['width'] ?? 0.0);
1695        $height = (float) ($attrs['height'] ?? 0.0);
1696
1697        $person     = Registry::individualFactory()->make($id, $this->tree);
1698        $media_file = $person->findHighlightedMediaFile();
1699
1700        if ($media_file instanceof MediaFile && $media_file->fileExists($this->data_filesystem)) {
1701            $image      = imagecreatefromstring($media_file->fileContents($this->data_filesystem));
1702            $attributes = [imagesx($image), imagesy($image)];
1703
1704            if ($width > 0 && $height == 0) {
1705                $perc   = $width / $attributes[0];
1706                $height = round($attributes[1] * $perc);
1707            } elseif ($height > 0 && $width == 0) {
1708                $perc  = $height / $attributes[1];
1709                $width = round($attributes[0] * $perc);
1710            } else {
1711                $width  = $attributes[0];
1712                $height = $attributes[1];
1713            }
1714            $image = $this->report_root->createImageFromObject($media_file, $left, $top, $width, $height, $align, $ln, $this->data_filesystem);
1715            $this->wt_report->addElement($image);
1716        }
1717    }
1718
1719    /**
1720     * Handle <image/>
1721     *
1722     * @param string[] $attrs
1723     *
1724     * @return void
1725     */
1726    protected function imageStartHandler(array $attrs): void
1727    {
1728        // Position the top corner of this box on the page. the default is the current position
1729        $top = (float) ($attrs['top'] ?? ReportBaseElement::CURRENT_POSITION);
1730
1731        // mixed Position the left corner of this box on the page. the default is the current position
1732        $left = (float) ($attrs['left'] ?? ReportBaseElement::CURRENT_POSITION);
1733
1734        // string Align the image in left, center, right (or empty to use x/y position).
1735        $align = $attrs['align'] ?? '';
1736
1737        // string Next Line should be T:next to the image, N:next line
1738        $ln = $attrs['ln'] ?? 'T';
1739
1740        // Width, height (or both).
1741        $width  = (float) ($attrs['width'] ?? 0.0);
1742        $height = (float) ($attrs['height'] ?? 0.0);
1743
1744        $file = $attrs['file'] ?? '';
1745
1746        if ($file === '@FILE') {
1747            $match = [];
1748            if (preg_match("/\d OBJE @(.+)@/", $this->gedrec, $match)) {
1749                $mediaobject = Registry::mediaFactory()->make($match[1], $this->tree);
1750                $media_file  = $mediaobject->firstImageFile();
1751
1752                if ($media_file instanceof MediaFile && $media_file->fileExists($this->data_filesystem)) {
1753                    $image      = imagecreatefromstring($media_file->fileContents($this->data_filesystem));
1754                    $attributes = [imagesx($image), imagesy($image)];
1755
1756                    if ($width > 0 && $height == 0) {
1757                        $perc   = $width / $attributes[0];
1758                        $height = round($attributes[1] * $perc);
1759                    } elseif ($height > 0 && $width == 0) {
1760                        $perc  = $height / $attributes[1];
1761                        $width = round($attributes[0] * $perc);
1762                    } else {
1763                        $width  = $attributes[0];
1764                        $height = $attributes[1];
1765                    }
1766                    $image = $this->report_root->createImageFromObject($media_file, $left, $top, $width, $height, $align, $ln, $this->data_filesystem);
1767                    $this->wt_report->addElement($image);
1768                }
1769            }
1770        } else {
1771            if (file_exists($file) && preg_match('/(jpg|jpeg|png|gif)$/i', $file)) {
1772                $size = getimagesize($file);
1773                if ($width > 0 && $height == 0) {
1774                    $perc   = $width / $size[0];
1775                    $height = round($size[1] * $perc);
1776                } elseif ($height > 0 && $width == 0) {
1777                    $perc  = $height / $size[1];
1778                    $width = round($size[0] * $perc);
1779                } else {
1780                    $width  = $size[0];
1781                    $height = $size[1];
1782                }
1783                $image = $this->report_root->createImage($file, $left, $top, $width, $height, $align, $ln);
1784                $this->wt_report->addElement($image);
1785            }
1786        }
1787    }
1788
1789    /**
1790     * Handle <line>
1791     *
1792     * @param string[] $attrs
1793     *
1794     * @return void
1795     */
1796    protected function lineStartHandler(array $attrs): void
1797    {
1798        // Start horizontal position, current position (default)
1799        $x1 = ReportBaseElement::CURRENT_POSITION;
1800        if (isset($attrs['x1'])) {
1801            if ($attrs['x1'] === '0') {
1802                $x1 = 0;
1803            } elseif ($attrs['x1'] === '.') {
1804                $x1 = ReportBaseElement::CURRENT_POSITION;
1805            } elseif (!empty($attrs['x1'])) {
1806                $x1 = (float) $attrs['x1'];
1807            }
1808        }
1809        // Start vertical position, current position (default)
1810        $y1 = ReportBaseElement::CURRENT_POSITION;
1811        if (isset($attrs['y1'])) {
1812            if ($attrs['y1'] === '0') {
1813                $y1 = 0;
1814            } elseif ($attrs['y1'] === '.') {
1815                $y1 = ReportBaseElement::CURRENT_POSITION;
1816            } elseif (!empty($attrs['y1'])) {
1817                $y1 = (float) $attrs['y1'];
1818            }
1819        }
1820        // End horizontal position, maximum width (default)
1821        $x2 = ReportBaseElement::CURRENT_POSITION;
1822        if (isset($attrs['x2'])) {
1823            if ($attrs['x2'] === '0') {
1824                $x2 = 0;
1825            } elseif ($attrs['x2'] === '.') {
1826                $x2 = ReportBaseElement::CURRENT_POSITION;
1827            } elseif (!empty($attrs['x2'])) {
1828                $x2 = (float) $attrs['x2'];
1829            }
1830        }
1831        // End vertical position
1832        $y2 = ReportBaseElement::CURRENT_POSITION;
1833        if (isset($attrs['y2'])) {
1834            if ($attrs['y2'] === '0') {
1835                $y2 = 0;
1836            } elseif ($attrs['y2'] === '.') {
1837                $y2 = ReportBaseElement::CURRENT_POSITION;
1838            } elseif (!empty($attrs['y2'])) {
1839                $y2 = (float) $attrs['y2'];
1840            }
1841        }
1842
1843        $line = $this->report_root->createLine($x1, $y1, $x2, $y2);
1844        $this->wt_report->addElement($line);
1845    }
1846
1847    /**
1848     * Handle <list>
1849     *
1850     * @param string[] $attrs
1851     *
1852     * @return void
1853     */
1854    protected function listStartHandler(array $attrs): void
1855    {
1856        $this->process_repeats++;
1857        if ($this->process_repeats > 1) {
1858            return;
1859        }
1860
1861        $match = [];
1862        if (isset($attrs['sortby'])) {
1863            $sortby = $attrs['sortby'];
1864            if (preg_match("/\\$(\w+)/", $sortby, $match)) {
1865                $sortby = $this->vars[$match[1]]['id'];
1866                $sortby = trim($sortby);
1867            }
1868        } else {
1869            $sortby = 'NAME';
1870        }
1871
1872        $listname = $attrs['list'] ?? 'individual';
1873
1874        // Some filters/sorts can be applied using SQL, while others require PHP
1875        switch ($listname) {
1876            case 'pending':
1877                $xrefs = DB::table('change')
1878                    ->whereIn('change_id', function (Builder $query): void {
1879                        $query->select(new Expression('MAX(change_id)'))
1880                            ->from('change')
1881                            ->where('gedcom_id', '=', $this->tree->id())
1882                            ->where('status', '=', 'pending')
1883                            ->groupBy(['xref']);
1884                    })
1885                    ->pluck('xref');
1886
1887                $this->list = [];
1888                foreach ($xrefs as $xref) {
1889                    $this->list[] = Registry::gedcomRecordFactory()->make($xref, $this->tree);
1890                }
1891                break;
1892            case 'individual':
1893                $query = DB::table('individuals')
1894                    ->where('i_file', '=', $this->tree->id())
1895                    ->select(['i_id AS xref', 'i_gedcom AS gedcom'])
1896                    ->distinct();
1897
1898                foreach ($attrs as $attr => $value) {
1899                    if (str_starts_with($attr, 'filter') && $value !== '') {
1900                        $value = $this->substituteVars($value, false);
1901                        // Convert the various filters into SQL
1902                        if (preg_match('/^(\w+):DATE (LTE|GTE) (.+)$/', $value, $match)) {
1903                            $query->join('dates AS ' . $attr, static function (JoinClause $join) use ($attr): void {
1904                                $join
1905                                    ->on($attr . '.d_gid', '=', 'i_id')
1906                                    ->on($attr . '.d_file', '=', 'i_file');
1907                            });
1908
1909                            $query->where($attr . '.d_fact', '=', $match[1]);
1910
1911                            $date = new Date($match[3]);
1912
1913                            if ($match[2] === 'LTE') {
1914                                $query->where($attr . '.d_julianday2', '<=', $date->maximumJulianDay());
1915                            } else {
1916                                $query->where($attr . '.d_julianday1', '>=', $date->minimumJulianDay());
1917                            }
1918
1919                            // This filter has been fully processed
1920                            unset($attrs[$attr]);
1921                        } elseif (preg_match('/^NAME CONTAINS (.+)$/', $value, $match)) {
1922                            $query->join('name AS ' . $attr, static function (JoinClause $join) use ($attr): void {
1923                                $join
1924                                    ->on($attr . '.n_id', '=', 'i_id')
1925                                    ->on($attr . '.n_file', '=', 'i_file');
1926                            });
1927                            // Search the DB only if there is any name supplied
1928                            $names = explode(' ', $match[1]);
1929                            foreach ($names as $n => $name) {
1930                                $query->where($attr . '.n_full', 'LIKE', '%' . addcslashes($name, '\\%_') . '%');
1931                            }
1932
1933                            // This filter has been fully processed
1934                            unset($attrs[$attr]);
1935                        } elseif (preg_match('/^LIKE \/(.+)\/$/', $value, $match)) {
1936                            // Convert newline escape sequences to actual new lines
1937                            $match[1] = str_replace('\n', "\n", $match[1]);
1938
1939                            $query->where('i_gedcom', 'LIKE', $match[1]);
1940
1941                            // This filter has been fully processed
1942                            unset($attrs[$attr]);
1943                        } elseif (preg_match('/^(?:\w*):PLAC CONTAINS (.+)$/', $value, $match)) {
1944                            // Don't unset this filter. This is just initial filtering for performance
1945                            $query
1946                                ->join('placelinks AS ' . $attr . 'a', static function (JoinClause $join) use ($attr): void {
1947                                    $join
1948                                        ->on($attr . 'a.pl_file', '=', 'i_file')
1949                                        ->on($attr . 'a.pl_gid', '=', 'i_id');
1950                                })
1951                                ->join('places AS ' . $attr . 'b', static function (JoinClause $join) use ($attr): void {
1952                                    $join
1953                                        ->on($attr . 'b.p_file', '=', $attr . 'a.pl_file')
1954                                        ->on($attr . 'b.p_id', '=', $attr . 'a.pl_p_id');
1955                                })
1956                                ->where($attr . 'b.p_place', 'LIKE', '%' . addcslashes($match[1], '\\%_') . '%');
1957                        } elseif (preg_match('/^(\w*):(\w+) CONTAINS (.+)$/', $value, $match)) {
1958                            // Don't unset this filter. This is just initial filtering for performance
1959                            $match[3] = strtr($match[3], ['\\' => '\\\\', '%'  => '\\%', '_'  => '\\_', ' ' => '%']);
1960                            $like = "%\n1 " . $match[1] . "%\n2 " . $match[2] . '%' . $match[3] . '%';
1961                            $query->where('i_gedcom', 'LIKE', $like);
1962                        } elseif (preg_match('/^(\w+) CONTAINS (.*)$/', $value, $match)) {
1963                            // Don't unset this filter. This is just initial filtering for performance
1964                            $match[2] = strtr($match[2], ['\\' => '\\\\', '%'  => '\\%', '_'  => '\\_', ' ' => '%']);
1965                            $like = "%\n1 " . $match[1] . '%' . $match[2] . '%';
1966                            $query->where('i_gedcom', 'LIKE', $like);
1967                        }
1968                    }
1969                }
1970
1971                $this->list = [];
1972
1973                foreach ($query->get() as $row) {
1974                    $this->list[$row->xref] = Registry::individualFactory()->make($row->xref, $this->tree, $row->gedcom);
1975                }
1976                break;
1977
1978            case 'family':
1979                $query = DB::table('families')
1980                    ->where('f_file', '=', $this->tree->id())
1981                    ->select(['f_id AS xref', 'f_gedcom AS gedcom'])
1982                    ->distinct();
1983
1984                foreach ($attrs as $attr => $value) {
1985                    if (str_starts_with($attr, 'filter') && $value !== '') {
1986                        $value = $this->substituteVars($value, false);
1987                        // Convert the various filters into SQL
1988                        if (preg_match('/^(\w+):DATE (LTE|GTE) (.+)$/', $value, $match)) {
1989                            $query->join('dates AS ' . $attr, static function (JoinClause $join) use ($attr): void {
1990                                $join
1991                                    ->on($attr . '.d_gid', '=', 'f_id')
1992                                    ->on($attr . '.d_file', '=', 'f_file');
1993                            });
1994
1995                            $query->where($attr . '.d_fact', '=', $match[1]);
1996
1997                            $date = new Date($match[3]);
1998
1999                            if ($match[2] === 'LTE') {
2000                                $query->where($attr . '.d_julianday2', '<=', $date->maximumJulianDay());
2001                            } else {
2002                                $query->where($attr . '.d_julianday1', '>=', $date->minimumJulianDay());
2003                            }
2004
2005                            // This filter has been fully processed
2006                            unset($attrs[$attr]);
2007                        } elseif (preg_match('/^LIKE \/(.+)\/$/', $value, $match)) {
2008                            // Convert newline escape sequences to actual new lines
2009                            $match[1] = str_replace('\n', "\n", $match[1]);
2010
2011                            $query->where('f_gedcom', 'LIKE', $match[1]);
2012
2013                            // This filter has been fully processed
2014                            unset($attrs[$attr]);
2015                        } elseif (preg_match('/^NAME CONTAINS (.*)$/', $value, $match)) {
2016                            if ($sortby === 'NAME' || $match[1] !== '') {
2017                                $query->join('name AS ' . $attr, static function (JoinClause $join) use ($attr): void {
2018                                    $join
2019                                        ->on($attr . '.n_file', '=', 'f_file')
2020                                        ->where(static function (Builder $query): void {
2021                                            $query
2022                                                ->whereColumn('n_id', '=', 'f_husb')
2023                                                ->orWhereColumn('n_id', '=', 'f_wife');
2024                                        });
2025                                });
2026                                // Search the DB only if there is any name supplied
2027                                if ($match[1] != '') {
2028                                    $names = explode(' ', $match[1]);
2029                                    foreach ($names as $n => $name) {
2030                                        $query->where($attr . '.n_full', 'LIKE', '%' . addcslashes($name, '\\%_') . '%');
2031                                    }
2032                                }
2033                            }
2034
2035                            // This filter has been fully processed
2036                            unset($attrs[$attr]);
2037                        } elseif (preg_match('/^(?:\w*):PLAC CONTAINS (.+)$/', $value, $match)) {
2038                            // Don't unset this filter. This is just initial filtering for performance
2039                            $query
2040                                ->join('placelinks AS ' . $attr . 'a', static function (JoinClause $join) use ($attr): void {
2041                                    $join
2042                                        ->on($attr . 'a.pl_file', '=', 'f_file')
2043                                        ->on($attr . 'a.pl_gid', '=', 'f_id');
2044                                })
2045                                ->join('places AS ' . $attr . 'b', static function (JoinClause $join) use ($attr): void {
2046                                    $join
2047                                        ->on($attr . 'b.p_file', '=', $attr . 'a.pl_file')
2048                                        ->on($attr . 'b.p_id', '=', $attr . 'a.pl_p_id');
2049                                })
2050                                ->where($attr . 'b.p_place', 'LIKE', '%' . addcslashes($match[1], '\\%_') . '%');
2051                        } elseif (preg_match('/^(\w*):(\w+) CONTAINS (.+)$/', $value, $match)) {
2052                            // Don't unset this filter. This is just initial filtering for performance
2053                            $match[3] = strtr($match[3], ['\\' => '\\\\', '%'  => '\\%', '_'  => '\\_', ' ' => '%']);
2054                            $like = "%\n1 " . $match[1] . "%\n2 " . $match[2] . '%' . $match[3] . '%';
2055                            $query->where('f_gedcom', 'LIKE', $like);
2056                        } elseif (preg_match('/^(\w+) CONTAINS (.+)$/', $value, $match)) {
2057                            // Don't unset this filter. This is just initial filtering for performance
2058                            $match[2] = strtr($match[2], ['\\' => '\\\\', '%'  => '\\%', '_'  => '\\_', ' ' => '%']);
2059                            $like = "%\n1 " . $match[1] . '%' . $match[2] . '%';
2060                            $query->where('f_gedcom', 'LIKE', $like);
2061                        }
2062                    }
2063                }
2064
2065                $this->list = [];
2066
2067                foreach ($query->get() as $row) {
2068                    $this->list[$row->xref] = Registry::familyFactory()->make($row->xref, $this->tree, $row->gedcom);
2069                }
2070                break;
2071
2072            default:
2073                throw new DomainException('Invalid list name: ' . $listname);
2074        }
2075
2076        $filters  = [];
2077        $filters2 = [];
2078        if (isset($attrs['filter1']) && count($this->list) > 0) {
2079            foreach ($attrs as $key => $value) {
2080                if (preg_match("/filter(\d)/", $key)) {
2081                    $condition = $value;
2082                    if (preg_match("/@(\w+)/", $condition, $match)) {
2083                        $id    = $match[1];
2084                        $value = "''";
2085                        if ($id === 'ID') {
2086                            if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
2087                                $value = "'" . $match[1] . "'";
2088                            }
2089                        } elseif ($id === 'fact') {
2090                            $value = "'" . $this->fact . "'";
2091                        } elseif ($id === 'desc') {
2092                            $value = "'" . $this->desc . "'";
2093                        } else {
2094                            if (preg_match("/\d $id (.+)/", $this->gedrec, $match)) {
2095                                $value = "'" . str_replace('@', '', trim($match[1])) . "'";
2096                            }
2097                        }
2098                        $condition = preg_replace("/@$id/", $value, $condition);
2099                    }
2100                    //-- handle regular expressions
2101                    if (preg_match("/([A-Z:]+)\s*([^\s]+)\s*(.+)/", $condition, $match)) {
2102                        $tag  = trim($match[1]);
2103                        $expr = trim($match[2]);
2104                        $val  = trim($match[3]);
2105                        if (preg_match("/\\$(\w+)/", $val, $match)) {
2106                            $val = $this->vars[$match[1]]['id'];
2107                            $val = trim($val);
2108                        }
2109                        if ($val) {
2110                            $searchstr = '';
2111                            $tags      = explode(':', $tag);
2112                            //-- only limit to a level number if we are specifically looking at a level
2113                            if (count($tags) > 1) {
2114                                $level = 1;
2115                                $t = 'XXXX';
2116                                foreach ($tags as $t) {
2117                                    if (!empty($searchstr)) {
2118                                        $searchstr .= "[^\n]*(\n[2-9][^\n]*)*\n";
2119                                    }
2120                                    //-- search for both EMAIL and _EMAIL... silly double gedcom standard
2121                                    if ($t === 'EMAIL' || $t === '_EMAIL') {
2122                                        $t = '_?EMAIL';
2123                                    }
2124                                    $searchstr .= $level . ' ' . $t;
2125                                    $level++;
2126                                }
2127                            } else {
2128                                if ($tag === 'EMAIL' || $tag === '_EMAIL') {
2129                                    $tag = '_?EMAIL';
2130                                }
2131                                $t         = $tag;
2132                                $searchstr = '1 ' . $tag;
2133                            }
2134                            switch ($expr) {
2135                                case 'CONTAINS':
2136                                    if ($t === 'PLAC') {
2137                                        $searchstr .= "[^\n]*[, ]*" . $val;
2138                                    } else {
2139                                        $searchstr .= "[^\n]*" . $val;
2140                                    }
2141                                    $filters[] = $searchstr;
2142                                    break;
2143                                default:
2144                                    $filters2[] = [
2145                                        'tag'  => $tag,
2146                                        'expr' => $expr,
2147                                        'val'  => $val,
2148                                    ];
2149                                    break;
2150                            }
2151                        }
2152                    }
2153                }
2154            }
2155        }
2156        //-- apply other filters to the list that could not be added to the search string
2157        if ($filters) {
2158            foreach ($this->list as $key => $record) {
2159                foreach ($filters as $filter) {
2160                    if (!preg_match('/' . $filter . '/i', $record->privatizeGedcom(Auth::accessLevel($this->tree)))) {
2161                        unset($this->list[$key]);
2162                        break;
2163                    }
2164                }
2165            }
2166        }
2167        if ($filters2) {
2168            $mylist = [];
2169            foreach ($this->list as $indi) {
2170                $key  = $indi->xref();
2171                $grec = $indi->privatizeGedcom(Auth::accessLevel($this->tree));
2172                $keep = true;
2173                foreach ($filters2 as $filter) {
2174                    if ($keep) {
2175                        $tag  = $filter['tag'];
2176                        $expr = $filter['expr'];
2177                        $val  = $filter['val'];
2178                        if ($val === "''") {
2179                            $val = '';
2180                        }
2181                        $tags = explode(':', $tag);
2182                        $t    = end($tags);
2183                        $v    = $this->getGedcomValue($tag, 1, $grec);
2184                        //-- check for EMAIL and _EMAIL (silly double gedcom standard :P)
2185                        if ($t === 'EMAIL' && empty($v)) {
2186                            $tag  = str_replace('EMAIL', '_EMAIL', $tag);
2187                            $tags = explode(':', $tag);
2188                            $t    = end($tags);
2189                            $v    = Functions::getSubRecord(1, $tag, $grec);
2190                        }
2191
2192                        switch ($expr) {
2193                            case 'GTE':
2194                                if ($t === 'DATE') {
2195                                    $date1 = new Date($v);
2196                                    $date2 = new Date($val);
2197                                    $keep  = (Date::compare($date1, $date2) >= 0);
2198                                } elseif ($val >= $v) {
2199                                    $keep = true;
2200                                }
2201                                break;
2202                            case 'LTE':
2203                                if ($t === 'DATE') {
2204                                    $date1 = new Date($v);
2205                                    $date2 = new Date($val);
2206                                    $keep  = (Date::compare($date1, $date2) <= 0);
2207                                } elseif ($val >= $v) {
2208                                    $keep = true;
2209                                }
2210                                break;
2211                            default:
2212                                if ($v == $val) {
2213                                    $keep = true;
2214                                } else {
2215                                    $keep = false;
2216                                }
2217                                break;
2218                        }
2219                    }
2220                }
2221                if ($keep) {
2222                    $mylist[$key] = $indi;
2223                }
2224            }
2225            $this->list = $mylist;
2226        }
2227
2228        switch ($sortby) {
2229            case 'NAME':
2230                uasort($this->list, GedcomRecord::nameComparator());
2231                break;
2232            case 'CHAN':
2233                uasort($this->list, GedcomRecord::lastChangeComparator());
2234                break;
2235            case 'BIRT:DATE':
2236                uasort($this->list, Individual::birthDateComparator());
2237                break;
2238            case 'DEAT:DATE':
2239                uasort($this->list, Individual::deathDateComparator());
2240                break;
2241            case 'MARR:DATE':
2242                uasort($this->list, Family::marriageDateComparator());
2243                break;
2244            default:
2245                // unsorted or already sorted by SQL
2246                break;
2247        }
2248
2249        $this->repeats_stack[] = [$this->repeats, $this->repeat_bytes];
2250        $this->repeat_bytes    = xml_get_current_line_number($this->parser) + 1;
2251    }
2252
2253    /**
2254     * Handle </list>
2255     *
2256     * @return void
2257     */
2258    protected function listEndHandler(): void
2259    {
2260        $this->process_repeats--;
2261        if ($this->process_repeats > 0) {
2262            return;
2263        }
2264
2265        // Check if there is any list
2266        if (count($this->list) > 0) {
2267            $lineoffset = 0;
2268            foreach ($this->repeats_stack as $rep) {
2269                $lineoffset += $rep[1];
2270            }
2271            //-- read the xml from the file
2272            $lines = file($this->report);
2273            while ((!str_contains($lines[$lineoffset + $this->repeat_bytes], '<List')) && (($lineoffset + $this->repeat_bytes) > 0)) {
2274                $lineoffset--;
2275            }
2276            $lineoffset++;
2277            $reportxml = "<tempdoc>\n";
2278            $line_nr   = $lineoffset + $this->repeat_bytes;
2279            // List Level counter
2280            $count = 1;
2281            while (0 < $count) {
2282                if (str_contains($lines[$line_nr], '<List')) {
2283                    $count++;
2284                } elseif (str_contains($lines[$line_nr], '</List')) {
2285                    $count--;
2286                }
2287                if (0 < $count) {
2288                    $reportxml .= $lines[$line_nr];
2289                }
2290                $line_nr++;
2291            }
2292            // No need to drag this
2293            unset($lines);
2294            $reportxml .= '</tempdoc>';
2295            // Save original values
2296            $this->parser_stack[] = $this->parser;
2297            $oldgedrec            = $this->gedrec;
2298
2299            $this->list_total   = count($this->list);
2300            $this->list_private = 0;
2301            foreach ($this->list as $record) {
2302                if ($record->canShow()) {
2303                    $this->gedrec = $record->privatizeGedcom(Auth::accessLevel($record->tree()));
2304                    //-- start the sax parser
2305                    $repeat_parser = xml_parser_create();
2306                    $this->parser  = $repeat_parser;
2307                    xml_parser_set_option($repeat_parser, XML_OPTION_CASE_FOLDING, false);
2308
2309                    xml_set_element_handler(
2310                        $repeat_parser,
2311                        function ($parser, string $name, array $attrs): void {
2312                            $this->startElement($parser, $name, $attrs);
2313                        },
2314                        function ($parser, string $name): void {
2315                            $this->endElement($parser, $name);
2316                        }
2317                    );
2318
2319                    xml_set_character_data_handler(
2320                        $repeat_parser,
2321                        function ($parser, string $data): void {
2322                            $this->characterData($parser, $data);
2323                        }
2324                    );
2325
2326                    if (!xml_parse($repeat_parser, $reportxml, true)) {
2327                        throw new DomainException(sprintf(
2328                            'ListEHandler XML error: %s at line %d',
2329                            xml_error_string(xml_get_error_code($repeat_parser)),
2330                            xml_get_current_line_number($repeat_parser)
2331                        ));
2332                    }
2333                    xml_parser_free($repeat_parser);
2334                } else {
2335                    $this->list_private++;
2336                }
2337            }
2338            $this->list   = [];
2339            $this->parser = array_pop($this->parser_stack);
2340            $this->gedrec = $oldgedrec;
2341        }
2342        [$this->repeats, $this->repeat_bytes] = array_pop($this->repeats_stack);
2343    }
2344
2345    /**
2346     * Handle <listTotal>
2347     * Prints the total number of records in a list
2348     * The total number is collected from <list> and <relatives>
2349     *
2350     * @return void
2351     */
2352    protected function listTotalStartHandler(): void
2353    {
2354        if ($this->list_private == 0) {
2355            $this->current_element->addText((string) $this->list_total);
2356        } else {
2357            $this->current_element->addText(($this->list_total - $this->list_private) . ' / ' . $this->list_total);
2358        }
2359    }
2360
2361    /**
2362     * Handle <relatives>
2363     *
2364     * @param string[] $attrs
2365     *
2366     * @return void
2367     */
2368    protected function relativesStartHandler(array $attrs): void
2369    {
2370        $this->process_repeats++;
2371        if ($this->process_repeats > 1) {
2372            return;
2373        }
2374
2375        $sortby = $attrs['sortby'] ?? 'NAME';
2376
2377        $match = [];
2378        if (preg_match("/\\$(\w+)/", $sortby, $match)) {
2379            $sortby = $this->vars[$match[1]]['id'];
2380            $sortby = trim($sortby);
2381        }
2382
2383        $maxgen = -1;
2384        if (isset($attrs['maxgen'])) {
2385            $maxgen = (int) $attrs['maxgen'];
2386        }
2387
2388        $group = $attrs['group'] ?? 'child-family';
2389
2390        if (preg_match("/\\$(\w+)/", $group, $match)) {
2391            $group = $this->vars[$match[1]]['id'];
2392            $group = trim($group);
2393        }
2394
2395        $id = $attrs['id'] ?? '';
2396
2397        if (preg_match("/\\$(\w+)/", $id, $match)) {
2398            $id = $this->vars[$match[1]]['id'];
2399            $id = trim($id);
2400        }
2401
2402        $this->list = [];
2403        $person     = Registry::individualFactory()->make($id, $this->tree);
2404        if ($person instanceof Individual) {
2405            $this->list[$id] = $person;
2406            switch ($group) {
2407                case 'child-family':
2408                    foreach ($person->childFamilies() as $family) {
2409                        foreach ($family->spouses() as $spouse) {
2410                            $this->list[$spouse->xref()] = $spouse;
2411                        }
2412
2413                        foreach ($family->children() as $child) {
2414                            $this->list[$child->xref()] = $child;
2415                        }
2416                    }
2417                    break;
2418                case 'spouse-family':
2419                    foreach ($person->spouseFamilies() as $family) {
2420                        foreach ($family->spouses() as $spouse) {
2421                            $this->list[$spouse->xref()] = $spouse;
2422                        }
2423
2424                        foreach ($family->children() as $child) {
2425                            $this->list[$child->xref()] = $child;
2426                        }
2427                    }
2428                    break;
2429                case 'direct-ancestors':
2430                    $this->addAncestors($this->list, $id, false, $maxgen);
2431                    break;
2432                case 'ancestors':
2433                    $this->addAncestors($this->list, $id, true, $maxgen);
2434                    break;
2435                case 'descendants':
2436                    $this->list[$id]->generation = 1;
2437                    $this->addDescendancy($this->list, $id, false, $maxgen);
2438                    break;
2439                case 'all':
2440                    $this->addAncestors($this->list, $id, true, $maxgen);
2441                    $this->addDescendancy($this->list, $id, true, $maxgen);
2442                    break;
2443            }
2444        }
2445
2446        switch ($sortby) {
2447            case 'NAME':
2448                uasort($this->list, GedcomRecord::nameComparator());
2449                break;
2450            case 'BIRT:DATE':
2451                uasort($this->list, Individual::birthDateComparator());
2452                break;
2453            case 'DEAT:DATE':
2454                uasort($this->list, Individual::deathDateComparator());
2455                break;
2456            case 'generation':
2457                $newarray = [];
2458                reset($this->list);
2459                $genCounter = 1;
2460                while (count($newarray) < count($this->list)) {
2461                    foreach ($this->list as $key => $value) {
2462                        $this->generation = $value->generation;
2463                        if ($this->generation == $genCounter) {
2464                            $newarray[$key]             = new stdClass();
2465                            $newarray[$key]->generation = $this->generation;
2466                        }
2467                    }
2468                    $genCounter++;
2469                }
2470                $this->list = $newarray;
2471                break;
2472            default:
2473                // unsorted
2474                break;
2475        }
2476        $this->repeats_stack[] = [$this->repeats, $this->repeat_bytes];
2477        $this->repeat_bytes    = xml_get_current_line_number($this->parser) + 1;
2478    }
2479
2480    /**
2481     * Handle </relatives>
2482     *
2483     * @return void
2484     */
2485    protected function relativesEndHandler(): void
2486    {
2487        $this->process_repeats--;
2488        if ($this->process_repeats > 0) {
2489            return;
2490        }
2491
2492        // Check if there is any relatives
2493        if (count($this->list) > 0) {
2494            $lineoffset = 0;
2495            foreach ($this->repeats_stack as $rep) {
2496                $lineoffset += $rep[1];
2497            }
2498            //-- read the xml from the file
2499            $lines = file($this->report);
2500            while (!str_contains($lines[$lineoffset + $this->repeat_bytes], '<Relatives') && $lineoffset + $this->repeat_bytes > 0) {
2501                $lineoffset--;
2502            }
2503            $lineoffset++;
2504            $reportxml = "<tempdoc>\n";
2505            $line_nr   = $lineoffset + $this->repeat_bytes;
2506            // Relatives Level counter
2507            $count = 1;
2508            while (0 < $count) {
2509                if (str_contains($lines[$line_nr], '<Relatives')) {
2510                    $count++;
2511                } elseif (str_contains($lines[$line_nr], '</Relatives')) {
2512                    $count--;
2513                }
2514                if (0 < $count) {
2515                    $reportxml .= $lines[$line_nr];
2516                }
2517                $line_nr++;
2518            }
2519            // No need to drag this
2520            unset($lines);
2521            $reportxml .= "</tempdoc>\n";
2522            // Save original values
2523            $this->parser_stack[] = $this->parser;
2524            $oldgedrec            = $this->gedrec;
2525
2526            $this->list_total   = count($this->list);
2527            $this->list_private = 0;
2528            foreach ($this->list as $xref => $value) {
2529                if (isset($value->generation)) {
2530                    $this->generation = $value->generation;
2531                }
2532                $tmp          = Registry::gedcomRecordFactory()->make((string) $xref, $this->tree);
2533                $this->gedrec = $tmp->privatizeGedcom(Auth::accessLevel($this->tree));
2534
2535                $repeat_parser = xml_parser_create();
2536                $this->parser  = $repeat_parser;
2537                xml_parser_set_option($repeat_parser, XML_OPTION_CASE_FOLDING, false);
2538
2539                xml_set_element_handler(
2540                    $repeat_parser,
2541                    function ($parser, string $name, array $attrs): void {
2542                        $this->startElement($parser, $name, $attrs);
2543                    },
2544                    function ($parser, string $name): void {
2545                        $this->endElement($parser, $name);
2546                    }
2547                );
2548
2549                xml_set_character_data_handler(
2550                    $repeat_parser,
2551                    function ($parser, string $data): void {
2552                        $this->characterData($parser, $data);
2553                    }
2554                );
2555
2556                if (!xml_parse($repeat_parser, $reportxml, true)) {
2557                    throw new DomainException(sprintf('RelativesEHandler XML error: %s at line %d', xml_error_string(xml_get_error_code($repeat_parser)), xml_get_current_line_number($repeat_parser)));
2558                }
2559                xml_parser_free($repeat_parser);
2560            }
2561            // Clean up the list array
2562            $this->list   = [];
2563            $this->parser = array_pop($this->parser_stack);
2564            $this->gedrec = $oldgedrec;
2565        }
2566        [$this->repeats, $this->repeat_bytes] = array_pop($this->repeats_stack);
2567    }
2568
2569    /**
2570     * Handle <generation />
2571     * Prints the number of generations
2572     *
2573     * @return void
2574     */
2575    protected function generationStartHandler(): void
2576    {
2577        $this->current_element->addText((string) $this->generation);
2578    }
2579
2580    /**
2581     * Handle <newPage />
2582     * Has to be placed in an element (header, body or footer)
2583     *
2584     * @return void
2585     */
2586    protected function newPageStartHandler(): void
2587    {
2588        $temp = 'addpage';
2589        $this->wt_report->addElement($temp);
2590    }
2591
2592    /**
2593     * Handle </title>
2594     *
2595     * @return void
2596     */
2597    protected function titleEndHandler(): void
2598    {
2599        $this->report_root->addTitle($this->text);
2600    }
2601
2602    /**
2603     * Handle </description>
2604     *
2605     * @return void
2606     */
2607    protected function descriptionEndHandler(): void
2608    {
2609        $this->report_root->addDescription($this->text);
2610    }
2611
2612    /**
2613     * Create a list of all descendants.
2614     *
2615     * @param string[] $list
2616     * @param string   $pid
2617     * @param bool     $parents
2618     * @param int      $generations
2619     *
2620     * @return void
2621     */
2622    private function addDescendancy(&$list, $pid, $parents = false, $generations = -1): void
2623    {
2624        $person = Registry::individualFactory()->make($pid, $this->tree);
2625        if ($person === null) {
2626            return;
2627        }
2628        if (!isset($list[$pid])) {
2629            $list[$pid] = $person;
2630        }
2631        if (!isset($list[$pid]->generation)) {
2632            $list[$pid]->generation = 0;
2633        }
2634        foreach ($person->spouseFamilies() as $family) {
2635            if ($parents) {
2636                $husband = $family->husband();
2637                $wife    = $family->wife();
2638                if ($husband) {
2639                    $list[$husband->xref()] = $husband;
2640                    if (isset($list[$pid]->generation)) {
2641                        $list[$husband->xref()]->generation = $list[$pid]->generation - 1;
2642                    } else {
2643                        $list[$husband->xref()]->generation = 1;
2644                    }
2645                }
2646                if ($wife) {
2647                    $list[$wife->xref()] = $wife;
2648                    if (isset($list[$pid]->generation)) {
2649                        $list[$wife->xref()]->generation = $list[$pid]->generation - 1;
2650                    } else {
2651                        $list[$wife->xref()]->generation = 1;
2652                    }
2653                }
2654            }
2655
2656            $children = $family->children();
2657
2658            foreach ($children as $child) {
2659                if ($child) {
2660                    $list[$child->xref()] = $child;
2661
2662                    if (isset($list[$pid]->generation)) {
2663                        $list[$child->xref()]->generation = $list[$pid]->generation + 1;
2664                    } else {
2665                        $list[$child->xref()]->generation = 2;
2666                    }
2667                }
2668            }
2669            if ($generations == -1 || $list[$pid]->generation + 1 < $generations) {
2670                foreach ($children as $child) {
2671                    $this->addDescendancy($list, $child->xref(), $parents, $generations); // recurse on the childs family
2672                }
2673            }
2674        }
2675    }
2676
2677    /**
2678     * Create a list of all ancestors.
2679     *
2680     * @param array<stdClass> $list
2681     * @param string          $pid
2682     * @param bool            $children
2683     * @param int             $generations
2684     *
2685     * @return void
2686     */
2687    private function addAncestors(array &$list, string $pid, bool $children = false, int $generations = -1): void
2688    {
2689        $genlist                = [$pid];
2690        $list[$pid]->generation = 1;
2691        while (count($genlist) > 0) {
2692            $id = array_shift($genlist);
2693            if (str_starts_with($id, 'empty')) {
2694                continue; // id can be something like “empty7”
2695            }
2696            $person = Registry::individualFactory()->make($id, $this->tree);
2697            foreach ($person->childFamilies() as $family) {
2698                $husband = $family->husband();
2699                $wife    = $family->wife();
2700                if ($husband) {
2701                    $list[$husband->xref()]             = $husband;
2702                    $list[$husband->xref()]->generation = $list[$id]->generation + 1;
2703                }
2704                if ($wife) {
2705                    $list[$wife->xref()]             = $wife;
2706                    $list[$wife->xref()]->generation = $list[$id]->generation + 1;
2707                }
2708                if ($generations == -1 || $list[$id]->generation + 1 < $generations) {
2709                    if ($husband) {
2710                        $genlist[] = $husband->xref();
2711                    }
2712                    if ($wife) {
2713                        $genlist[] = $wife->xref();
2714                    }
2715                }
2716                if ($children) {
2717                    foreach ($family->children() as $child) {
2718                        $list[$child->xref()] = $child;
2719                        $list[$child->xref()]->generation = $list[$id]->generation ?? 1;
2720                    }
2721                }
2722            }
2723        }
2724    }
2725
2726    /**
2727     * get gedcom tag value
2728     *
2729     * @param string $tag    The tag to find, use : to delineate subtags
2730     * @param int    $level  The gedcom line level of the first tag to find, setting level to 0 will cause it to use 1+ the level of the incoming record
2731     * @param string $gedrec The gedcom record to get the value from
2732     *
2733     * @return string the value of a gedcom tag from the given gedcom record
2734     */
2735    private function getGedcomValue(string $tag, int $level, string $gedrec): string
2736    {
2737        if ($gedrec === '') {
2738            return '';
2739        }
2740        $tags      = explode(':', $tag);
2741        $origlevel = $level;
2742        if ($level === 0) {
2743            $level = $gedrec[0] + 1;
2744        }
2745
2746        $subrec = $gedrec;
2747        $t = 'XXXX';
2748        foreach ($tags as $t) {
2749            $lastsubrec = $subrec;
2750            $subrec     = Functions::getSubRecord($level, "$level $t", $subrec);
2751            if (empty($subrec) && $origlevel == 0) {
2752                $level--;
2753                $subrec = Functions::getSubRecord($level, "$level $t", $lastsubrec);
2754            }
2755            if (empty($subrec)) {
2756                if ($t === 'TITL') {
2757                    $subrec = Functions::getSubRecord($level, "$level ABBR", $lastsubrec);
2758                    if (!empty($subrec)) {
2759                        $t = 'ABBR';
2760                    }
2761                }
2762                if ($subrec === '') {
2763                    if ($level > 0) {
2764                        $level--;
2765                    }
2766                    $subrec = Functions::getSubRecord($level, "@ $t", $gedrec);
2767                    if ($subrec === '') {
2768                        return '';
2769                    }
2770                }
2771            }
2772            $level++;
2773        }
2774        $level--;
2775        $ct = preg_match("/$level $t(.*)/", $subrec, $match);
2776        if ($ct === 0) {
2777            $ct = preg_match("/$level @.+@ (.+)/", $subrec, $match);
2778        }
2779        if ($ct === 0) {
2780            $ct = preg_match("/@ $t (.+)/", $subrec, $match);
2781        }
2782        if ($ct > 0) {
2783            $value = trim($match[1]);
2784            if ($t === 'NOTE' && preg_match('/^@(.+)@$/', $value, $match)) {
2785                $note = Registry::noteFactory()->make($match[1], $this->tree);
2786                if ($note instanceof Note) {
2787                    $value = $note->getNote();
2788                } else {
2789                    //-- set the value to the id without the @
2790                    $value = $match[1];
2791                }
2792            }
2793            if ($level !== 0 || $t !== 'NOTE') {
2794                $value .= Functions::getCont($level + 1, $subrec);
2795            }
2796
2797            return $value;
2798        }
2799
2800        return '';
2801    }
2802
2803    /**
2804     * Replace variable identifiers with their values.
2805     *
2806     * @param string $expression An expression such as "$foo == 123"
2807     * @param bool   $quote      Whether to add quotation marks
2808     *
2809     * @return string
2810     */
2811    private function substituteVars($expression, $quote): string
2812    {
2813        return preg_replace_callback(
2814            '/\$(\w+)/',
2815            function (array $matches) use ($quote): string {
2816                if (isset($this->vars[$matches[1]]['id'])) {
2817                    if ($quote) {
2818                        return "'" . addcslashes($this->vars[$matches[1]]['id'], "'") . "'";
2819                    }
2820
2821                    return $this->vars[$matches[1]]['id'];
2822                }
2823
2824                Log::addErrorLog(sprintf('Undefined variable $%s in report', $matches[1]));
2825
2826                return '$' . $matches[1];
2827            },
2828            $expression
2829        );
2830    }
2831}
2832