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