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