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