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