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