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