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