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