ReportParserGenerate::addDescendancy()   F
last analyzed

Complexity

Conditions 16
Paths 485

Size

Total Lines 50
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 16
eloc 34
nc 485
nop 4
dl 0
loc 50
rs 2.1152
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2025 webtrees development team
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Report;
21
22
use DomainException;
23
use Fisharebest\Webtrees\Auth;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Auth was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
24
use Fisharebest\Webtrees\Date;
25
use Fisharebest\Webtrees\DB;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\DB was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
26
use Fisharebest\Webtrees\Elements\UnknownElement;
27
use Fisharebest\Webtrees\Factories\MarkdownFactory;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Factories\MarkdownFactory was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
28
use Fisharebest\Webtrees\Family;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Family was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
29
use Fisharebest\Webtrees\Gedcom;
30
use Fisharebest\Webtrees\GedcomRecord;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\GedcomRecord was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
31
use Fisharebest\Webtrees\I18N;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\I18N was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
32
use Fisharebest\Webtrees\Individual;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Individual was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
33
use Fisharebest\Webtrees\Log;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Log was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
34
use Fisharebest\Webtrees\MediaFile;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\MediaFile was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
35
use Fisharebest\Webtrees\Note;
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Note was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
36
use Fisharebest\Webtrees\Place;
37
use Fisharebest\Webtrees\Registry;
38
use Fisharebest\Webtrees\Tree;
39
use Illuminate\Database\Query\Builder;
40
use Illuminate\Database\Query\Expression;
41
use Illuminate\Database\Query\JoinClause;
42
use Illuminate\Support\Str;
43
use LogicException;
44
use Symfony\Component\Cache\Adapter\NullAdapter;
45
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
46
use XMLParser;
47
48
use function addcslashes;
49
use function addslashes;
50
use function array_pop;
51
use function array_shift;
52
use function assert;
53
use function count;
54
use function end;
55
use function explode;
56
use function file;
57
use function file_exists;
58
use function getimagesize;
59
use function imagecreatefromstring;
60
use function imagesx;
61
use function imagesy;
62
use function in_array;
63
use function ltrim;
64
use function method_exists;
65
use function preg_match;
66
use function preg_match_all;
67
use function preg_replace;
68
use function preg_replace_callback;
69
use function preg_split;
70
use function reset;
71
use function round;
72
use function sprintf;
73
use function str_contains;
74
use function str_ends_with;
75
use function str_replace;
76
use function str_starts_with;
77
use function strip_tags;
78
use function strlen;
79
use function strpos;
80
use function strtoupper;
81
use function substr;
82
use function trim;
83
use function uasort;
84
use function xml_error_string;
85
use function xml_get_current_line_number;
86
use function xml_get_error_code;
87
use function xml_parse;
88
use function xml_parser_create;
89
use function xml_parser_free;
90
use function xml_parser_set_option;
91
use function xml_set_character_data_handler;
92
use function xml_set_element_handler;
93
94
use const PREG_OFFSET_CAPTURE;
95
use const PREG_SET_ORDER;
96
use const XML_OPTION_CASE_FOLDING;
97
98
/**
99
 * Class ReportParserGenerate - parse a report.xml file and generate the report.
100
 */
101
class ReportParserGenerate extends ReportParserBase
102
{
103
    /** Are we collecting data from <Footnote> elements */
104
    private bool $process_footnote = true;
105
106
    /** Are we currently outputting data? */
107
    private bool $print_data = false;
108
109
    /** @var array<int,bool> Push-down stack of $print_data */
110
    private array $print_data_stack = [];
111
112
    /** Are we processing GEDCOM data */
113
    private int $process_gedcoms = 0;
114
115
    /** Are we processing conditionals */
116
    private int $process_ifs = 0;
117
118
    /** Are we processing repeats */
119
    private int $process_repeats = 0;
120
121
    /** Quantity of data to repeat during loops */
122
    private int $repeat_bytes = 0;
123
124
    /** @var array<string> Repeated data when iterating over loops */
125
    private array $repeats = [];
126
127
    /** @var array<int,array<int,array<string>|int>> Nested repeating data */
128
    private array $repeats_stack = [];
129
130
    /** @var array<AbstractRenderer> Nested repeating data */
131
    private array $wt_report_stack = [];
132
133
    // Nested repeating data
134
    private XMLParser $parser;
135
136
    /** @var XMLParser[] (resource[] before PHP 8.0) Nested repeating data */
137
    private array $parser_stack = [];
138
139
    /** The current GEDCOM record */
140
    private string $gedrec = '';
141
142
    /** @var array<int,array<int,string>> Nested GEDCOM records */
143
    private array $gedrec_stack = [];
144
145
    /** @var ReportBaseElement The currently processed element */
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Report\ReportBaseElement was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
146
    private $current_element;
147
148
    /** @var ReportBaseElement The currently processed element */
149
    private $footnote_element;
150
151
    /** The GEDCOM fact currently being processed */
152
    private string $fact = '';
153
154
    /** The GEDCOM value currently being processed */
155
    private string $desc = '';
156
157
    /** The GEDCOM type currently being processed */
158
    private string $type = '';
159
160
    /** The current generational level */
161
    private int $generation = 1;
162
163
    /** @var array<static|GedcomRecord> Source data for processing lists */
164
    private array $list = [];
165
166
    /** Number of items in lists */
167
    private int $list_total = 0;
168
169
    /** Number of items filtered from lists */
170
    private int $list_private = 0;
171
172
    /** @var string The filename of the XML report */
173
    protected $report;
174
175
    /** @var AbstractRenderer A factory for creating report elements */
0 ignored issues
show
Bug introduced by
The type Fisharebest\Webtrees\Report\AbstractRenderer was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
176
    private $report_root;
177
178
    /** @var AbstractRenderer Nested report elements */
179
    private $wt_report;
180
181
    /** @var array<array<string>> Variables defined in the report at run-time */
182
    private array $vars;
183
184
    private Tree $tree;
185
186
    /**
187
     * Create a parser for a report
188
     *
189
     * @param string               $report The XML filename
190
     * @param AbstractRenderer     $report_root
191
     * @param array<array<string>> $vars
192
     * @param Tree                 $tree
193
     */
194
    public function __construct(string $report, AbstractRenderer $report_root, array $vars, Tree $tree)
195
    {
196
        $this->report          = $report;
197
        $this->report_root     = $report_root;
198
        $this->wt_report       = $report_root;
199
        $this->current_element = new ReportBaseElement();
200
        $this->vars            = $vars;
201
        $this->tree            = $tree;
202
203
        parent::__construct($report);
204
    }
205
206
    /**
207
     * get a gedcom subrecord
208
     *
209
     * searches a gedcom record and returns a subrecord of it. A subrecord is defined starting at a
210
     * line with level N and all subsequent lines greater than N until the next N level is reached.
211
     * For example, the following is a BIRT subrecord:
212
     * <code>1 BIRT
213
     * 2 DATE 1 JAN 1900
214
     * 2 PLAC Phoenix, Maricopa, Arizona</code>
215
     * The following example is the DATE subrecord of the above BIRT subrecord:
216
     * <code>2 DATE 1 JAN 1900</code>
217
     *
218
     * @param int    $level   the N level of the subrecord to get
219
     * @param string $tag     a gedcom tag or string to search for in the record (ie 1 BIRT or 2 DATE)
220
     * @param string $gedrec  the parent gedcom record to search in
221
     * @param int    $num     this allows you to specify which matching <var>$tag</var> to get. Oftentimes a
222
     *                        gedcom record will have more that 1 of the same type of subrecord. An individual may have
223
     *                        multiple events for example. Passing $num=1 would get the first 1. Passing $num=2 would get the
224
     *                        second one, etc.
225
     *
226
     * @return string the subrecord that was found or an empty string "" if not found.
227
     */
228
    public static function getSubRecord(int $level, string $tag, string $gedrec, int $num = 1): string
229
    {
230
        if ($gedrec === '') {
231
            return '';
232
        }
233
        // -- adding \n before and after gedrec
234
        $gedrec       = "\n" . $gedrec . "\n";
235
        $tag          = trim($tag);
236
        $searchTarget = "~[\n]" . $tag . "[\s]~";
237
        $ct           = preg_match_all($searchTarget, $gedrec, $match, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
238
        if ($ct === 0) {
239
            return '';
240
        }
241
        if ($ct < $num) {
242
            return '';
243
        }
244
        $pos1 = (int) $match[$num - 1][0][1];
245
        $pos2 = strpos($gedrec, "\n$level", $pos1 + 1);
246
        if (!$pos2) {
247
            $pos2 = strpos($gedrec, "\n1", $pos1 + 1);
248
        }
249
        if (!$pos2) {
250
            $pos2 = strpos($gedrec, "\nWT_", $pos1 + 1); // WT_SPOUSE, WT_FAMILY_ID ...
251
        }
252
        if (!$pos2) {
253
            return ltrim(substr($gedrec, $pos1));
254
        }
255
        $subrec = substr($gedrec, $pos1, $pos2 - $pos1);
256
257
        return ltrim($subrec);
258
    }
259
260
    /**
261
     * get CONT lines
262
     *
263
     * get the N+1 CONT or CONC lines of a gedcom subrecord
264
     *
265
     * @param int    $nlevel the level of the CONT lines to get
266
     * @param string $nrec   the gedcom subrecord to search in
267
     *
268
     * @return string a string with all CONT lines merged
269
     */
270
    public static function getCont(int $nlevel, string $nrec): string
271
    {
272
        $text = '';
273
274
        $subrecords = explode("\n", $nrec);
275
        foreach ($subrecords as $thisSubrecord) {
276
            if (substr($thisSubrecord, 0, 2) !== $nlevel . ' ') {
277
                continue;
278
            }
279
            $subrecordType = substr($thisSubrecord, 2, 4);
280
            if ($subrecordType === 'CONT') {
281
                $text .= "\n" . substr($thisSubrecord, 7);
282
            }
283
        }
284
285
        return $text;
286
    }
287
288
    /**
289
     * XML start element handler
290
     * This function is called whenever a starting element is reached
291
     * The element handler will be called if found, otherwise it must be HTML
292
     *
293
     * @param resource      $parser the resource handler for the XML parser
294
     * @param string        $name   the name of the XML element parsed
295
     * @param array<string> $attrs  an array of key value pairs for the attributes
296
     *
297
     * @return void
298
     */
299
    protected function startElement($parser, string $name, array $attrs): void
300
    {
301
        $newattrs = [];
302
303
        foreach ($attrs as $key => $value) {
304
            if (preg_match("/^\\$(\w+)$/", $value, $match)) {
305
                if (isset($this->vars[$match[1]]['id']) && !isset($this->vars[$match[1]]['gedcom'])) {
306
                    $value = $this->vars[$match[1]]['id'];
307
                }
308
            }
309
            $newattrs[$key] = $value;
310
        }
311
        $attrs = $newattrs;
312
        if ($this->process_footnote && ($this->process_ifs === 0 || $name === 'if') && ($this->process_gedcoms === 0 || $name === 'Gedcom') && ($this->process_repeats === 0 || $name === 'Facts' || $name === 'RepeatTag')) {
313
            $method = $name . 'StartHandler';
314
315
            if (method_exists($this, $method)) {
316
                $this->{$method}($attrs);
317
            }
318
        }
319
    }
320
321
    /**
322
     * XML end element handler
323
     * This function is called whenever an ending element is reached
324
     * The element handler will be called if found, otherwise it must be HTML
325
     *
326
     * @param resource $parser the resource handler for the XML parser
327
     * @param string   $name   the name of the XML element parsed
328
     *
329
     * @return void
330
     */
331
    protected function endElement($parser, string $name): void
332
    {
333
        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')) {
334
            $method = $name . 'EndHandler';
335
336
            if (method_exists($this, $method)) {
337
                $this->{$method}();
338
            }
339
        }
340
    }
341
342
    /**
343
     * XML character data handler
344
     *
345
     * @param resource $parser the resource handler for the XML parser
346
     * @param string   $data   the name of the XML element parsed
347
     *
348
     * @return void
349
     */
350
    protected function characterData($parser, string $data): void
351
    {
352
        if ($this->print_data && $this->process_gedcoms === 0 && $this->process_ifs === 0 && $this->process_repeats === 0) {
353
            $this->current_element->addText($data);
354
        }
355
    }
356
357
    /**
358
     * Handle <style>
359
     *
360
     * @param array<string> $attrs
361
     *
362
     * @return void
363
     */
364
    protected function styleStartHandler(array $attrs): void
365
    {
366
        if (empty($attrs['name'])) {
367
            throw new DomainException('REPORT ERROR Style: The "name" of the style is missing or not set in the XML file.');
368
        }
369
370
        $style = [
371
            'name'  => $attrs['name'],
372
            'font'  => $attrs['font'] ?? $this->wt_report->default_font,
373
            'size'  => (float) ($attrs['size'] ?? $this->wt_report->default_font_size),
374
            'style' => $attrs['style'] ?? '',
375
        ];
376
377
        $this->wt_report->addStyle($style);
378
    }
379
380
    /**
381
     * Handle <doc>
382
     * Sets up the basics of the document proparties
383
     *
384
     * @param array<string> $attrs
385
     *
386
     * @return void
387
     */
388
    protected function docStartHandler(array $attrs): void
389
    {
390
        $this->parser = $this->xml_parser;
391
392
        // Custom page width
393
        if (!empty($attrs['customwidth'])) {
394
            $this->wt_report->page_width = (float) $attrs['customwidth'];
395
        }
396
        // Custom Page height
397
        if (!empty($attrs['customheight'])) {
398
            $this->wt_report->page_height = (float) $attrs['customheight'];
399
        }
400
401
        // Left Margin
402
        if (isset($attrs['leftmargin'])) {
403
            if ($attrs['leftmargin'] === '0') {
404
                $this->wt_report->left_margin = 0;
405
            } elseif (!empty($attrs['leftmargin'])) {
406
                $this->wt_report->left_margin = (float) $attrs['leftmargin'];
407
            }
408
        }
409
        // Right Margin
410
        if (isset($attrs['rightmargin'])) {
411
            if ($attrs['rightmargin'] === '0') {
412
                $this->wt_report->right_margin = 0;
413
            } elseif (!empty($attrs['rightmargin'])) {
414
                $this->wt_report->right_margin = (float) $attrs['rightmargin'];
415
            }
416
        }
417
        // Top Margin
418
        if (isset($attrs['topmargin'])) {
419
            if ($attrs['topmargin'] === '0') {
420
                $this->wt_report->top_margin = 0;
421
            } elseif (!empty($attrs['topmargin'])) {
422
                $this->wt_report->top_margin = (float) $attrs['topmargin'];
423
            }
424
        }
425
        // Bottom Margin
426
        if (isset($attrs['bottommargin'])) {
427
            if ($attrs['bottommargin'] === '0') {
428
                $this->wt_report->bottom_margin = 0;
429
            } elseif (!empty($attrs['bottommargin'])) {
430
                $this->wt_report->bottom_margin = (float) $attrs['bottommargin'];
431
            }
432
        }
433
        // Header Margin
434
        if (isset($attrs['headermargin'])) {
435
            if ($attrs['headermargin'] === '0') {
436
                $this->wt_report->header_margin = 0;
437
            } elseif (!empty($attrs['headermargin'])) {
438
                $this->wt_report->header_margin = (float) $attrs['headermargin'];
439
            }
440
        }
441
        // Footer Margin
442
        if (isset($attrs['footermargin'])) {
443
            if ($attrs['footermargin'] === '0') {
444
                $this->wt_report->footer_margin = 0;
445
            } elseif (!empty($attrs['footermargin'])) {
446
                $this->wt_report->footer_margin = (float) $attrs['footermargin'];
447
            }
448
        }
449
450
        // Page Orientation
451
        if (!empty($attrs['orientation'])) {
452
            if ($attrs['orientation'] === 'landscape') {
453
                $this->wt_report->orientation = 'landscape';
454
            } elseif ($attrs['orientation'] === 'portrait') {
455
                $this->wt_report->orientation = 'portrait';
456
            }
457
        }
458
        // Page Size
459
        if (!empty($attrs['pageSize'])) {
460
            $this->wt_report->page_format = strtoupper($attrs['pageSize']);
461
        }
462
463
        // Show Generated By...
464
        if (isset($attrs['showGeneratedBy'])) {
465
            if ($attrs['showGeneratedBy'] === '0') {
466
                $this->wt_report->show_generated_by = false;
467
            } elseif ($attrs['showGeneratedBy'] === '1') {
468
                $this->wt_report->show_generated_by = true;
469
            }
470
        }
471
472
        $this->wt_report->setup();
473
    }
474
475
    /**
476
     * Handle </doc>
477
     *
478
     * @return void
479
     */
480
    protected function docEndHandler(): void
481
    {
482
        $this->wt_report->run();
483
    }
484
485
    /**
486
     * Handle <header>
487
     *
488
     * @return void
489
     */
490
    protected function headerStartHandler(): void
491
    {
492
        // Clear the Header before any new elements are added
493
        $this->wt_report->clearHeader();
494
        $this->wt_report->setProcessing('H');
495
    }
496
497
    /**
498
     * Handle <body>
499
     *
500
     * @return void
501
     */
502
    protected function bodyStartHandler(): void
503
    {
504
        $this->wt_report->setProcessing('B');
505
    }
506
507
    /**
508
     * Handle <footer>
509
     *
510
     * @return void
511
     */
512
    protected function footerStartHandler(): void
513
    {
514
        $this->wt_report->setProcessing('F');
515
    }
516
517
    /**
518
     * Handle <cell>
519
     *
520
     * @param array<string,string> $attrs
521
     *
522
     * @return void
523
     */
524
    protected function cellStartHandler(array $attrs): void
525
    {
526
        // string The text alignment of the text in this box.
527
        $align = $attrs['align'] ?? '';
528
        // RTL supported left/right alignment
529
        if ($align === 'rightrtl') {
530
            if ($this->wt_report->rtl) {
531
                $align = 'left';
532
            } else {
533
                $align = 'right';
534
            }
535
        } elseif ($align === 'leftrtl') {
536
            if ($this->wt_report->rtl) {
537
                $align = 'right';
538
            } else {
539
                $align = 'left';
540
            }
541
        }
542
543
        // The color to fill the background of this cell
544
        $bgcolor = $attrs['bgcolor'] ?? '';
545
546
        // Whether the background should be painted
547
        $fill = (bool) ($attrs['fill'] ?? '0');
548
549
        // If true reset the last cell height
550
        $reseth = (bool) ($attrs['reseth'] ?? '1');
551
552
        // Whether a border should be printed around this box
553
        $border = $attrs['border'] ?? '';
554
555
        // string Border color in HTML code
556
        $bocolor = $attrs['bocolor'] ?? '';
557
558
        // Cell height (expressed in points) The starting height of this cell. If the text wraps the height will automatically be adjusted.
559
        $height = (int) ($attrs['height'] ?? '0');
560
561
        // int Cell width (expressed in points) Setting the width to 0 will make it the width from the current location to the right margin.
562
        $width = (int) ($attrs['width'] ?? '0');
563
564
        // Stretch character mode
565
        $stretch = (int) ($attrs['stretch'] ?? '0');
566
567
        // mixed Position the left corner of this box on the page. The default is the current position.
568
        $left = ReportBaseElement::CURRENT_POSITION;
569
        if (isset($attrs['left'])) {
570
            if ($attrs['left'] === '.') {
571
                $left = ReportBaseElement::CURRENT_POSITION;
572
            } elseif (!empty($attrs['left'])) {
573
                $left = (float) $attrs['left'];
574
            } elseif ($attrs['left'] === '0') {
575
                $left = 0.0;
576
            }
577
        }
578
        // mixed Position the top corner of this box on the page. the default is the current position
579
        $top = ReportBaseElement::CURRENT_POSITION;
580
        if (isset($attrs['top'])) {
581
            if ($attrs['top'] === '.') {
582
                $top = ReportBaseElement::CURRENT_POSITION;
583
            } elseif (!empty($attrs['top'])) {
584
                $top = (float) $attrs['top'];
585
            } elseif ($attrs['top'] === '0') {
586
                $top = 0.0;
587
            }
588
        }
589
590
        // The name of the Style that should be used to render the text.
591
        $style = $attrs['style'] ?? '';
592
593
        // string Text color in html code
594
        $tcolor = $attrs['tcolor'] ?? '';
595
596
        // int Indicates where the current position should go after the call.
597
        $ln = 0;
598
        if (isset($attrs['newline'])) {
599
            if (!empty($attrs['newline'])) {
600
                $ln = (int) $attrs['newline'];
601
            } elseif ($attrs['newline'] === '0') {
602
                $ln = 0;
603
            }
604
        }
605
606
        if ($align === 'left') {
607
            $align = 'L';
608
        } elseif ($align === 'right') {
609
            $align = 'R';
610
        } elseif ($align === 'center') {
611
            $align = 'C';
612
        } elseif ($align === 'justify') {
613
            $align = 'J';
614
        }
615
616
        $this->print_data_stack[] = $this->print_data;
617
        $this->print_data         = true;
618
619
        $this->current_element = $this->report_root->createCell(
620
            $width,
621
            $height,
622
            $border,
623
            $align,
624
            $bgcolor,
625
            $style,
626
            $ln,
627
            $top,
628
            $left,
629
            $fill,
630
            $stretch,
631
            $bocolor,
632
            $tcolor,
633
            $reseth
634
        );
635
    }
636
637
    /**
638
     * Handle </cell>
639
     *
640
     * @return void
641
     */
642
    protected function cellEndHandler(): void
643
    {
644
        $this->print_data = array_pop($this->print_data_stack);
645
        $this->wt_report->addElement($this->current_element);
646
    }
647
648
    /**
649
     * Handle <now />
650
     *
651
     * @return void
652
     */
653
    protected function nowStartHandler(): void
654
    {
655
        $this->current_element->addText(Registry::timestampFactory()->now()->isoFormat('LLLL'));
656
    }
657
658
    /**
659
     * Handle <pageNum />
660
     *
661
     * @return void
662
     */
663
    protected function pageNumStartHandler(): void
664
    {
665
        $this->current_element->addText('#PAGENUM#');
666
    }
667
668
    /**
669
     * Handle <totalPages />
670
     *
671
     * @return void
672
     */
673
    protected function totalPagesStartHandler(): void
674
    {
675
        $this->current_element->addText('{{:ptp:}}');
676
    }
677
678
    /**
679
     * Called at the start of an element.
680
     *
681
     * @param array<string> $attrs an array of key value pairs for the attributes
682
     *
683
     * @return void
684
     */
685
    protected function gedcomStartHandler(array $attrs): void
686
    {
687
        if ($this->process_gedcoms > 0) {
688
            $this->process_gedcoms++;
689
690
            return;
691
        }
692
693
        $tag       = $attrs['id'];
694
        $tag       = str_replace('@fact', $this->fact, $tag);
695
        $tags      = explode(':', $tag);
696
        $newgedrec = '';
697
        if (count($tags) < 2) {
698
            $tmp       = Registry::gedcomRecordFactory()->make($attrs['id'], $this->tree);
699
            $newgedrec = $tmp ? $tmp->privatizeGedcom(Auth::accessLevel($this->tree)) : '';
700
        }
701
        if (empty($newgedrec)) {
702
            $tgedrec   = $this->gedrec;
703
            $newgedrec = '';
704
            foreach ($tags as $tag) {
705
                if (preg_match('/\$(.+)/', $tag, $match)) {
706
                    if (isset($this->vars[$match[1]]['gedcom'])) {
707
                        $newgedrec = $this->vars[$match[1]]['gedcom'];
708
                    } else {
709
                        $tmp       = Registry::gedcomRecordFactory()->make($match[1], $this->tree);
710
                        $newgedrec = $tmp ? $tmp->privatizeGedcom(Auth::accessLevel($this->tree)) : '';
711
                    }
712
                } elseif (preg_match('/@(.+)/', $tag, $match)) {
713
                    $gmatch = [];
714
                    if (preg_match("/\d $match[1] @([^@]+)@/", $tgedrec, $gmatch)) {
715
                        $tmp       = Registry::gedcomRecordFactory()->make($gmatch[1], $this->tree);
716
                        $newgedrec = $tmp ? $tmp->privatizeGedcom(Auth::accessLevel($this->tree)) : '';
717
                        $tgedrec   = $newgedrec;
718
                    } else {
719
                        $newgedrec = '';
720
                        break;
721
                    }
722
                } else {
723
                    $level     = 1 + (int) explode(' ', trim($tgedrec))[0];
724
                    $newgedrec = self::getSubRecord($level, "$level $tag", $tgedrec);
725
                    $tgedrec   = $newgedrec;
726
                }
727
            }
728
        }
729
        if (!empty($newgedrec)) {
730
            $this->gedrec_stack[] = [$this->gedrec, $this->fact, $this->desc];
731
            $this->gedrec         = $newgedrec;
732
            if (preg_match("/(\d+) (_?[A-Z0-9]+) (.*)/", $this->gedrec, $match)) {
733
                $this->fact = $match[2];
734
                $this->desc = trim($match[3]);
735
            }
736
        } else {
737
            $this->process_gedcoms++;
738
        }
739
    }
740
741
    /**
742
     * Called at the end of an element.
743
     *
744
     * @return void
745
     */
746
    protected function gedcomEndHandler(): void
747
    {
748
        if ($this->process_gedcoms > 0) {
749
            $this->process_gedcoms--;
750
        } else {
751
            [$this->gedrec, $this->fact, $this->desc] = array_pop($this->gedrec_stack);
752
        }
753
    }
754
755
    /**
756
     * Handle <textBox>
757
     *
758
     * @param array<string> $attrs
759
     *
760
     * @return void
761
     */
762
    protected function textBoxStartHandler(array $attrs): void
763
    {
764
        // string Background color code
765
        $bgcolor = '';
766
        if (!empty($attrs['bgcolor'])) {
767
            $bgcolor = $attrs['bgcolor'];
768
        }
769
770
        // boolean Wether or not fill the background color
771
        $fill = true;
772
        if (isset($attrs['fill'])) {
773
            if ($attrs['fill'] === '0') {
774
                $fill = false;
775
            } elseif ($attrs['fill'] === '1') {
776
                $fill = true;
777
            }
778
        }
779
780
        // var boolean Whether or not a border should be printed around this box. 0 = no border, 1 = border. Default is 0
781
        $border = false;
782
        if (isset($attrs['border'])) {
783
            if ($attrs['border'] === '1') {
784
                $border = true;
785
            } elseif ($attrs['border'] === '0') {
786
                $border = false;
787
            }
788
        }
789
790
        // int The starting height of this cell. If the text wraps the height will automatically be adjusted
791
        $height = 0;
792
        if (!empty($attrs['height'])) {
793
            $height = (int) $attrs['height'];
794
        }
795
        // int Setting the width to 0 will make it the width from the current location to the margin
796
        $width = 0;
797
        if (!empty($attrs['width'])) {
798
            $width = (int) $attrs['width'];
799
        }
800
801
        // mixed Position the left corner of this box on the page. The default is the current position.
802
        $left = ReportBaseElement::CURRENT_POSITION;
803
        if (isset($attrs['left'])) {
804
            if ($attrs['left'] === '.') {
805
                $left = ReportBaseElement::CURRENT_POSITION;
806
            } elseif (!empty($attrs['left'])) {
807
                $left = (int) $attrs['left'];
808
            } elseif ($attrs['left'] === '0') {
809
                $left = 0;
810
            }
811
        }
812
        // mixed Position the top corner of this box on the page. the default is the current position
813
        $top = ReportBaseElement::CURRENT_POSITION;
814
        if (isset($attrs['top'])) {
815
            if ($attrs['top'] === '.') {
816
                $top = ReportBaseElement::CURRENT_POSITION;
817
            } elseif (!empty($attrs['top'])) {
818
                $top = (int) $attrs['top'];
819
            } elseif ($attrs['top'] === '0') {
820
                $top = 0;
821
            }
822
        }
823
        // 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
824
        $newline = false;
825
        if (isset($attrs['newline'])) {
826
            if ($attrs['newline'] === '1') {
827
                $newline = true;
828
            } elseif ($attrs['newline'] === '0') {
829
                $newline = false;
830
            }
831
        }
832
        // boolean
833
        $pagecheck = true;
834
        if (isset($attrs['pagecheck'])) {
835
            if ($attrs['pagecheck'] === '0') {
836
                $pagecheck = false;
837
            } elseif ($attrs['pagecheck'] === '1') {
838
                $pagecheck = true;
839
            }
840
        }
841
        // boolean Cell padding
842
        $padding = true;
843
        if (isset($attrs['padding'])) {
844
            if ($attrs['padding'] === '0') {
845
                $padding = false;
846
            } elseif ($attrs['padding'] === '1') {
847
                $padding = true;
848
            }
849
        }
850
        // boolean Reset this box Height
851
        $reseth = false;
852
        if (isset($attrs['reseth'])) {
853
            if ($attrs['reseth'] === '1') {
854
                $reseth = true;
855
            } elseif ($attrs['reseth'] === '0') {
856
                $reseth = false;
857
            }
858
        }
859
860
        // string Style of rendering
861
        $style = '';
862
863
        $this->print_data_stack[] = $this->print_data;
864
        $this->print_data         = false;
865
866
        $this->wt_report_stack[] = $this->wt_report;
867
        $this->wt_report         = $this->report_root->createTextBox(
868
            $width,
869
            $height,
870
            $border,
871
            $bgcolor,
872
            $newline,
873
            $left,
874
            $top,
875
            $pagecheck,
876
            $style,
877
            $fill,
878
            $padding,
879
            $reseth
880
        );
881
    }
882
883
    /**
884
     * Handle <textBox>
885
     *
886
     * @return void
887
     */
888
    protected function textBoxEndHandler(): void
889
    {
890
        $this->print_data      = array_pop($this->print_data_stack);
891
        $this->current_element = $this->wt_report;
892
893
        // The TextBox handler is mis-using the wt_report attribute to store an element.
894
        // Until this can be re-designed, we need this assertion to help static analysis tools.
895
        assert($this->current_element instanceof ReportBaseElement, new LogicException());
896
897
        $this->wt_report = array_pop($this->wt_report_stack);
898
        $this->wt_report->addElement($this->current_element);
899
    }
900
901
    /**
902
     * XLM <Text>.
903
     *
904
     * @param array<string> $attrs an array of key value pairs for the attributes
905
     *
906
     * @return void
907
     */
908
    protected function textStartHandler(array $attrs): void
909
    {
910
        $this->print_data_stack[] = $this->print_data;
911
        $this->print_data         = true;
912
913
        // string The name of the Style that should be used to render the text.
914
        $style = '';
915
        if (!empty($attrs['style'])) {
916
            $style = $attrs['style'];
917
        }
918
919
        // string  The color of the text - Keep the black color as default
920
        $color = '';
921
        if (!empty($attrs['color'])) {
922
            $color = $attrs['color'];
923
        }
924
925
        $this->current_element = $this->report_root->createText($style, $color);
926
    }
927
928
    /**
929
     * Handle </text>
930
     *
931
     * @return void
932
     */
933
    protected function textEndHandler(): void
934
    {
935
        $this->print_data = array_pop($this->print_data_stack);
936
        $this->wt_report->addElement($this->current_element);
937
    }
938
939
    /**
940
     * Handle <getPersonName />
941
     * Get the name
942
     * 1. id is empty - current GEDCOM record
943
     * 2. id is set with a record id
944
     *
945
     * @param array<string> $attrs an array of key value pairs for the attributes
946
     *
947
     * @return void
948
     */
949
    protected function getPersonNameStartHandler(array $attrs): void
950
    {
951
        $id    = '';
952
        $match = [];
953
        if (empty($attrs['id'])) {
954
            if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
955
                $id = $match[1];
956
            }
957
        } elseif (preg_match('/\$(.+)/', $attrs['id'], $match)) {
958
            if (isset($this->vars[$match[1]]['id'])) {
959
                $id = $this->vars[$match[1]]['id'];
960
            }
961
        } elseif (preg_match('/@(.+)/', $attrs['id'], $match)) {
962
            $gmatch = [];
963
            if (preg_match("/\d $match[1] @([^@]+)@/", $this->gedrec, $gmatch)) {
964
                $id = $gmatch[1];
965
            }
966
        } else {
967
            $id = $attrs['id'];
968
        }
969
        if (!empty($id)) {
970
            $record = Registry::gedcomRecordFactory()->make($id, $this->tree);
971
            if ($record === null) {
972
                return;
973
            }
974
            if (!$record->canShowName()) {
975
                $this->current_element->addText(I18N::translate('Private'));
976
            } else {
977
                $name = $record->fullName();
978
                $name = strip_tags($name);
979
                if (!empty($attrs['truncate'])) {
980
                    $name = Str::limit($name, (int) $attrs['truncate'], I18N::translate('…'));
981
                } else {
982
                    $addname = (string) $record->alternateName();
983
                    $addname = strip_tags($addname);
984
                    if (!empty($addname)) {
985
                        $name .= ' ' . $addname;
986
                    }
987
                }
988
                $this->current_element->addText(trim($name));
989
            }
990
        }
991
    }
992
993
    /**
994
     * Handle <gedcomValue />
995
     *
996
     * @param array<string> $attrs
997
     *
998
     * @return void
999
     */
1000
    protected function gedcomValueStartHandler(array $attrs): void
1001
    {
1002
        $id    = '';
1003
        $match = [];
1004
        if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
1005
            $id = $match[1];
1006
        }
1007
1008
        if (isset($attrs['newline']) && $attrs['newline'] === '1') {
1009
            $useBreak = '1';
1010
        } else {
1011
            $useBreak = '0';
1012
        }
1013
1014
        $tag = $attrs['tag'];
1015
        if (!empty($tag)) {
1016
            if ($tag === '@desc') {
1017
                $value = $this->desc;
1018
                $value = trim($value);
1019
                $this->current_element->addText($value);
1020
            }
1021
            if ($tag === '@id') {
1022
                $this->current_element->addText($id);
1023
            } else {
1024
                $tag = str_replace('@fact', $this->fact, $tag);
1025
                if (empty($attrs['level'])) {
1026
                    $level = (int) explode(' ', trim($this->gedrec))[0];
1027
                    if ($level === 0) {
1028
                        $level++;
1029
                    }
1030
                } else {
1031
                    $level = (int) $attrs['level'];
1032
                }
1033
                $tags  = preg_split('/[: ]/', $tag);
1034
                $value = $this->getGedcomValue($tag, $level, $this->gedrec);
1035
                switch (end($tags)) {
1036
                    case 'DATE':
1037
                        $tmp   = new Date($value);
1038
                        $value = strip_tags($tmp->display());
1039
                        break;
1040
                    case 'PLAC':
1041
                        $tmp   = new Place($value, $this->tree);
1042
                        $value = $tmp->shortName();
1043
                        break;
1044
                }
1045
                if ($useBreak === '1') {
1046
                    // Insert <br> when multiple dates exist.
1047
                    // This works around a TCPDF bug that incorrectly wraps RTL dates on LTR pages
1048
                    $value = str_replace('(', '<br>(', $value);
1049
                    $value = str_replace('<span dir="ltr"><br>', '<br><span dir="ltr">', $value);
1050
                    $value = str_replace('<span dir="rtl"><br>', '<br><span dir="rtl">', $value);
1051
                    if (substr($value, 0, 4) === '<br>') {
1052
                        $value = substr($value, 4);
1053
                    }
1054
                }
1055
                $tmp = explode(':', $tag);
1056
                if (in_array(end($tmp), ['NOTE', 'TEXT'], true)) {
1057
                    if ($this->tree->getPreference('FORMAT_TEXT') === 'markdown') {
1058
                        $value = strip_tags(Registry::markdownFactory()->markdown($value, $this->tree), ['br']);
1059
                    } else {
1060
                        $value = strip_tags(Registry::markdownFactory()->autolink($value, $this->tree), ['br']);
1061
                    }
1062
                    $value = strtr($value, [MarkdownFactory::BREAK => ' ']);
1063
                }
1064
1065
                if (!empty($attrs['truncate'])) {
1066
                    $value = Str::limit($value, (int) $attrs['truncate'], I18N::translate('…'));
1067
                }
1068
                $this->current_element->addText($value);
1069
            }
1070
        }
1071
    }
1072
1073
    /**
1074
     * Handle <repeatTag>
1075
     *
1076
     * @param array<string> $attrs
1077
     *
1078
     * @return void
1079
     */
1080
    protected function repeatTagStartHandler(array $attrs): void
1081
    {
1082
        $this->process_repeats++;
1083
        if ($this->process_repeats > 1) {
1084
            return;
1085
        }
1086
1087
        $this->repeats_stack[] = [$this->repeats, $this->repeat_bytes];
1088
        $this->repeats         = [];
1089
        $this->repeat_bytes    = xml_get_current_line_number($this->parser);
1090
1091
        $tag = $attrs['tag'] ?? '';
1092
        if (!empty($tag)) {
1093
            if ($tag === '@desc') {
1094
                $value = $this->desc;
1095
                $value = trim($value);
1096
                $this->current_element->addText($value);
1097
            } else {
1098
                $tag   = str_replace('@fact', $this->fact, $tag);
1099
                $tags  = explode(':', $tag);
1100
                $level = (int) explode(' ', trim($this->gedrec))[0];
1101
                if ($level === 0) {
1102
                    $level++;
1103
                }
1104
                $subrec = $this->gedrec;
1105
                $t      = $tag;
1106
                $count  = count($tags);
1107
                $i      = 0;
1108
                while ($i < $count) {
1109
                    $t = $tags[$i];
1110
                    if (!empty($t)) {
1111
                        if ($i < ($count - 1)) {
1112
                            $subrec = self::getSubRecord($level, "$level $t", $subrec);
1113
                            if (empty($subrec)) {
1114
                                $level--;
1115
                                $subrec = self::getSubRecord($level, "@ $t", $this->gedrec);
1116
                                if (empty($subrec)) {
1117
                                    return;
1118
                                }
1119
                            }
1120
                        }
1121
                        $level++;
1122
                    }
1123
                    $i++;
1124
                }
1125
                $level--;
1126
                $count = preg_match_all("/$level $t(.*)/", $subrec, $match, PREG_SET_ORDER);
1127
                $i     = 0;
1128
                while ($i < $count) {
1129
                    $i++;
1130
                    // Privacy check - is this a link, and are we allowed to view the linked object?
1131
                    $subrecord = self::getSubRecord($level, "$level $t", $subrec, $i);
1132
                    if (preg_match('/^\d ' . Gedcom::REGEX_TAG . ' @(' . Gedcom::REGEX_XREF . ')@/', $subrecord, $xref_match)) {
1133
                        $linked_object = Registry::gedcomRecordFactory()->make($xref_match[1], $this->tree);
1134
                        if ($linked_object && !$linked_object->canShow()) {
1135
                            continue;
1136
                        }
1137
                    }
1138
                    $this->repeats[] = $subrecord;
1139
                }
1140
            }
1141
        }
1142
    }
1143
1144
    /**
1145
     * Handle </repeatTag>
1146
     *
1147
     * @return void
1148
     */
1149
    protected function repeatTagEndHandler(): void
1150
    {
1151
        $this->process_repeats--;
1152
        if ($this->process_repeats > 0) {
1153
            return;
1154
        }
1155
1156
        // Check if there is anything to repeat
1157
        if (count($this->repeats) > 0) {
1158
            // No need to load them if not used...
1159
1160
            $lineoffset = 0;
1161
            foreach ($this->repeats_stack as $rep) {
1162
                $lineoffset += $rep[1];
1163
            }
1164
            //-- read the xml from the file
1165
            $lines = file($this->report);
1166
            while (!str_contains($lines[$lineoffset + $this->repeat_bytes], '<RepeatTag')) {
1167
                $lineoffset--;
1168
            }
1169
            $lineoffset++;
1170
            $reportxml = "<tempdoc>\n";
1171
            $line_nr   = $lineoffset + $this->repeat_bytes;
1172
            // RepeatTag Level counter
1173
            $count = 1;
1174
            while (0 < $count) {
1175
                if (str_contains($lines[$line_nr], '<RepeatTag')) {
1176
                    $count++;
1177
                } elseif (str_contains($lines[$line_nr], '</RepeatTag')) {
1178
                    $count--;
1179
                }
1180
                if (0 < $count) {
1181
                    $reportxml .= $lines[$line_nr];
1182
                }
1183
                $line_nr++;
1184
            }
1185
            // No need to drag this
1186
            unset($lines);
1187
            $reportxml .= "</tempdoc>\n";
1188
            // Save original values
1189
            $this->parser_stack[] = $this->parser;
1190
            $oldgedrec            = $this->gedrec;
1191
            foreach ($this->repeats as $gedrec) {
1192
                $this->gedrec  = $gedrec;
1193
                $repeat_parser = xml_parser_create();
1194
                $this->parser  = $repeat_parser;
1195
                xml_parser_set_option($repeat_parser, XML_OPTION_CASE_FOLDING, 0);
1196
1197
                xml_set_element_handler(
1198
                    $repeat_parser,
1199
                    function ($parser, string $name, array $attrs): void {
1200
                        $this->startElement($parser, $name, $attrs);
1201
                    },
1202
                    function ($parser, string $name): void {
1203
                        $this->endElement($parser, $name);
1204
                    }
1205
                );
1206
1207
                xml_set_character_data_handler(
1208
                    $repeat_parser,
1209
                    function ($parser, string $data): void {
1210
                        $this->characterData($parser, $data);
1211
                    }
1212
                );
1213
1214
                if (!xml_parse($repeat_parser, $reportxml, true)) {
1215
                    throw new DomainException(sprintf(
1216
                        'RepeatTagEHandler XML error: %s at line %d',
1217
                        xml_error_string(xml_get_error_code($repeat_parser)),
1218
                        xml_get_current_line_number($repeat_parser)
1219
                    ));
1220
                }
1221
                xml_parser_free($repeat_parser);
1222
            }
1223
            // Restore original values
1224
            $this->gedrec = $oldgedrec;
1225
            $this->parser = array_pop($this->parser_stack);
1226
        }
1227
        [$this->repeats, $this->repeat_bytes] = array_pop($this->repeats_stack);
1228
    }
1229
1230
    /**
1231
     * Variable lookup
1232
     * Retrieve predefined variables :
1233
     * @ desc GEDCOM fact description, example:
1234
     *        1 EVEN This is a description
1235
     * @ fact GEDCOM fact tag, such as BIRT, DEAT etc.
1236
     * $ I18N::translate('....')
1237
     * $ language_settings[]
1238
     *
1239
     * @param array<string> $attrs an array of key value pairs for the attributes
1240
     *
1241
     * @return void
1242
     */
1243
    protected function varStartHandler(array $attrs): void
1244
    {
1245
        if (empty($attrs['var'])) {
1246
            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));
1247
        }
1248
1249
        $var = $attrs['var'];
1250
        // SetVar element preset variables
1251
        if (!empty($this->vars[$var]['id'])) {
1252
            $var = $this->vars[$var]['id'];
1253
        } else {
1254
            $tfact = $this->fact;
1255
            if (($this->fact === 'EVEN' || $this->fact === 'FACT') && $this->type !== '') {
1256
                // Use :
1257
                // n TYPE This text if string
1258
                $tfact = $this->type;
1259
            } else {
1260
                foreach ([Individual::RECORD_TYPE, Family::RECORD_TYPE] as $record_type) {
1261
                    $element = Registry::elementFactory()->make($record_type . ':' . $this->fact);
1262
1263
                    if (!$element instanceof UnknownElement) {
1264
                        $tfact = $element->label();
1265
                        break;
1266
                    }
1267
                }
1268
            }
1269
1270
            $var = strtr($var, ['@desc' => $this->desc, '@fact' => $tfact]);
1271
1272
            if (preg_match('/^I18N::number\((.+)\)$/', $var, $match)) {
1273
                $var = I18N::number((int) $match[1]);
1274
            } elseif (preg_match('/^I18N::translate\(\'(.+)\'\)$/', $var, $match)) {
1275
                $var = I18N::translate($match[1]);
1276
            } elseif (preg_match('/^I18N::translateContext\(\'(.+)\', *\'(.+)\'\)$/', $var, $match)) {
1277
                $var = I18N::translateContext($match[1], $match[2]);
1278
            }
1279
        }
1280
        // Check if variable is set as a date and reformat the date
1281
        if (isset($attrs['date'])) {
1282
            if ($attrs['date'] === '1') {
1283
                $g   = new Date($var);
1284
                $var = $g->display();
1285
            }
1286
        }
1287
        $this->current_element->addText($var);
1288
        $this->text = $var; // Used for title/descriptio
1289
    }
1290
1291
    /**
1292
     * Handle <facts>
1293
     *
1294
     * @param array<string> $attrs
1295
     *
1296
     * @return void
1297
     */
1298
    protected function factsStartHandler(array $attrs): void
1299
    {
1300
        $this->process_repeats++;
1301
        if ($this->process_repeats > 1) {
1302
            return;
1303
        }
1304
1305
        $this->repeats_stack[] = [$this->repeats, $this->repeat_bytes];
1306
        $this->repeats         = [];
1307
        $this->repeat_bytes    = xml_get_current_line_number($this->parser);
1308
1309
        $id    = '';
1310
        $match = [];
1311
        if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
1312
            $id = $match[1];
1313
        }
1314
        $tag = '';
1315
        if (isset($attrs['ignore'])) {
1316
            $tag .= $attrs['ignore'];
1317
        }
1318
        if (preg_match('/\$(.+)/', $tag, $match)) {
1319
            $tag = $this->vars[$match[1]]['id'];
1320
        }
1321
1322
        $record = Registry::gedcomRecordFactory()->make($id, $this->tree);
1323
        if (empty($attrs['diff']) && !empty($id)) {
1324
            $facts = $record->facts([], true);
1325
            $this->repeats = [];
1326
            $nonfacts      = explode(',', $tag);
1327
            foreach ($facts as $fact) {
1328
                $tag = explode(':', $fact->tag())[1];
1329
1330
                if (!in_array($tag, $nonfacts, true)) {
1331
                    $this->repeats[] = $fact->gedcom();
1332
                }
1333
            }
1334
        } else {
1335
            foreach ($record->facts() as $fact) {
1336
                if (($fact->isPendingAddition() || $fact->isPendingDeletion()) && !str_ends_with($fact->tag(), ':CHAN')) {
1337
                    $this->repeats[] = $fact->gedcom();
1338
                }
1339
            }
1340
        }
1341
    }
1342
1343
    /**
1344
     * Handle </facts>
1345
     *
1346
     * @return void
1347
     */
1348
    protected function factsEndHandler(): void
1349
    {
1350
        $this->process_repeats--;
1351
        if ($this->process_repeats > 0) {
1352
            return;
1353
        }
1354
1355
        // Check if there is anything to repeat
1356
        if (count($this->repeats) > 0) {
1357
            $line       = xml_get_current_line_number($this->parser) - 1;
1358
            $lineoffset = 0;
1359
            foreach ($this->repeats_stack as $rep) {
1360
                $lineoffset += $rep[1];
1361
            }
1362
1363
            //-- read the xml from the file
1364
            $lines = file($this->report);
1365
            while ($lineoffset + $this->repeat_bytes > 0 && !str_contains($lines[$lineoffset + $this->repeat_bytes], '<Facts ')) {
1366
                $lineoffset--;
1367
            }
1368
            $lineoffset++;
1369
            $reportxml = "<tempdoc>\n";
1370
            $i         = $line + $lineoffset;
1371
            $line_nr   = $this->repeat_bytes + $lineoffset;
1372
            while ($line_nr < $i) {
1373
                $reportxml .= $lines[$line_nr];
1374
                $line_nr++;
1375
            }
1376
            // No need to drag this
1377
            unset($lines);
1378
            $reportxml .= "</tempdoc>\n";
1379
            // Save original values
1380
            $this->parser_stack[] = $this->parser;
1381
            $oldgedrec            = $this->gedrec;
1382
            $count                = count($this->repeats);
1383
            $i                    = 0;
1384
            while ($i < $count) {
1385
                $this->gedrec = $this->repeats[$i];
1386
                $this->fact   = '';
1387
                $this->desc   = '';
1388
                if (preg_match('/1 (\w+)(.*)/', $this->gedrec, $match)) {
1389
                    $this->fact = $match[1];
1390
                    if ($this->fact === 'EVEN' || $this->fact === 'FACT') {
1391
                        $tmatch = [];
1392
                        if (preg_match('/2 TYPE (.+)/', $this->gedrec, $tmatch)) {
1393
                            $this->type = trim($tmatch[1]);
1394
                        } else {
1395
                            $this->type = ' ';
1396
                        }
1397
                    }
1398
                    $this->desc = trim($match[2]);
1399
                    $this->desc .= self::getCont(2, $this->gedrec);
1400
                }
1401
                $repeat_parser = xml_parser_create();
1402
                $this->parser  = $repeat_parser;
1403
                xml_parser_set_option($repeat_parser, XML_OPTION_CASE_FOLDING, 0);
1404
1405
                xml_set_element_handler(
1406
                    $repeat_parser,
1407
                    function ($parser, string $name, array $attrs): void {
1408
                        $this->startElement($parser, $name, $attrs);
1409
                    },
1410
                    function ($parser, string $name): void {
1411
                        $this->endElement($parser, $name);
1412
                    }
1413
                );
1414
1415
                xml_set_character_data_handler(
1416
                    $repeat_parser,
1417
                    function ($parser, string $data): void {
1418
                        $this->characterData($parser, $data);
1419
                    }
1420
                );
1421
1422
                if (!xml_parse($repeat_parser, $reportxml, true)) {
1423
                    throw new DomainException(sprintf(
1424
                        'FactsEHandler XML error: %s at line %d',
1425
                        xml_error_string(xml_get_error_code($repeat_parser)),
1426
                        xml_get_current_line_number($repeat_parser)
1427
                    ));
1428
                }
1429
                xml_parser_free($repeat_parser);
1430
                $i++;
1431
            }
1432
            // Restore original values
1433
            $this->parser = array_pop($this->parser_stack);
1434
            $this->gedrec = $oldgedrec;
1435
        }
1436
        [$this->repeats, $this->repeat_bytes] = array_pop($this->repeats_stack);
1437
    }
1438
1439
    /**
1440
     * Setting upp or changing variables in the XML
1441
     * The XML variable name and value is stored in $this->vars
1442
     *
1443
     * @param array<string> $attrs an array of key value pairs for the attributes
1444
     *
1445
     * @return void
1446
     */
1447
    protected function setVarStartHandler(array $attrs): void
1448
    {
1449
        if (empty($attrs['name'])) {
1450
            throw new DomainException('REPORT ERROR var: The attribute "name" is missing or not set in the XML file');
1451
        }
1452
1453
        $name  = $attrs['name'];
1454
        $value = $attrs['value'];
1455
        $match = [];
1456
        // Current GEDCOM record strings
1457
        if ($value === '@ID') {
1458
            if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
1459
                $value = $match[1];
1460
            }
1461
        } elseif ($value === '@fact') {
1462
            $value = $this->fact;
1463
        } elseif ($value === '@desc') {
1464
            $value = $this->desc;
1465
        } elseif ($value === '@generation') {
1466
            $value = (string) $this->generation;
1467
        } elseif (preg_match("/@(\w+)/", $value, $match)) {
1468
            $gmatch = [];
1469
            if (preg_match("/\d $match[1] (.+)/", $this->gedrec, $gmatch)) {
1470
                $value = str_replace('@', '', trim($gmatch[1]));
1471
            }
1472
        }
1473
        if (preg_match("/\\$(\w+)/", $name, $match)) {
1474
            $name = $this->vars["'" . $match[1] . "'"]['id'];
1475
        }
1476
        $count = preg_match_all("/\\$(\w+)/", $value, $match, PREG_SET_ORDER);
1477
        $i     = 0;
1478
        while ($i < $count) {
1479
            $t     = $this->vars[$match[$i][1]]['id'];
1480
            $value = preg_replace('/\$' . $match[$i][1] . '/', $t, $value, 1);
1481
            $i++;
1482
        }
1483
        if (preg_match('/^I18N::number\((.+)\)$/', $value, $match)) {
1484
            $value = I18N::number((int) $match[1]);
1485
        } elseif (preg_match('/^I18N::translate\(\'(.+)\'\)$/', $value, $match)) {
1486
            $value = I18N::translate($match[1]);
1487
        } elseif (preg_match('/^I18N::translateContext\(\'(.+)\', *\'(.+)\'\)$/', $value, $match)) {
1488
            $value = I18N::translateContext($match[1], $match[2]);
1489
        }
1490
1491
        // Arithmetic functions
1492
        if (preg_match("/(\d+)\s*([-+*\/])\s*(\d+)/", $value, $match)) {
1493
            // Create an expression language with the functions used by our reports.
1494
            $expression_provider  = new ReportExpressionLanguageProvider();
1495
            $expression_cache     = new NullAdapter();
1496
            $expression_language  = new ExpressionLanguage($expression_cache, [$expression_provider]);
1497
1498
            $value = (string) $expression_language->evaluate($value);
1499
        }
1500
1501
        if (str_contains($value, '@')) {
1502
            $value = '';
1503
        }
1504
        $this->vars[$name]['id'] = $value;
1505
    }
1506
1507
    /**
1508
     * Handle <if>
1509
     *
1510
     * @param array<string> $attrs
1511
     *
1512
     * @return void
1513
     */
1514
    protected function ifStartHandler(array $attrs): void
1515
    {
1516
        if ($this->process_ifs > 0) {
1517
            $this->process_ifs++;
1518
1519
            return;
1520
        }
1521
1522
        $condition = $attrs['condition'];
1523
        $condition = $this->substituteVars($condition, true);
1524
        $condition = str_replace([
1525
            ' LT ',
1526
            ' GT ',
1527
        ], [
1528
            '<',
1529
            '>',
1530
        ], $condition);
1531
        // Replace the first occurrence only once of @fact:DATE or in any other combinations to the current fact, such as BIRT
1532
        $condition = str_replace('@fact:', $this->fact . ':', $condition);
1533
        $match     = [];
1534
        $count     = preg_match_all("/@([\w:.]+)/", $condition, $match, PREG_SET_ORDER);
1535
        $i         = 0;
1536
        while ($i < $count) {
1537
            $id    = $match[$i][1];
1538
            $value = '""';
1539
            if ($id === 'ID') {
1540
                if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
1541
                    $value = "'" . $match[1] . "'";
1542
                }
1543
            } elseif ($id === 'fact') {
1544
                $value = '"' . $this->fact . '"';
1545
            } elseif ($id === 'desc') {
1546
                $value = '"' . addslashes($this->desc) . '"';
1547
            } elseif ($id === 'generation') {
1548
                $value = '"' . $this->generation . '"';
1549
            } else {
1550
                $level = (int) explode(' ', trim($this->gedrec))[0];
1551
                if ($level === 0) {
1552
                    $level++;
1553
                }
1554
                $value = $this->getGedcomValue($id, $level, $this->gedrec);
1555
                if (empty($value)) {
1556
                    $level++;
1557
                    $value = $this->getGedcomValue($id, $level, $this->gedrec);
1558
                }
1559
                $value = preg_replace('/^@(' . Gedcom::REGEX_XREF . ')@$/', '$1', $value);
1560
                $value = '"' . addslashes($value) . '"';
1561
            }
1562
            $condition = str_replace("@$id", $value, $condition);
1563
            $i++;
1564
        }
1565
1566
        // Create an expression language with the functions used by our reports.
1567
        $expression_provider  = new ReportExpressionLanguageProvider();
1568
        $expression_cache     = new NullAdapter();
1569
        $expression_language  = new ExpressionLanguage($expression_cache, [$expression_provider]);
1570
1571
        $ret = $expression_language->evaluate($condition);
1572
1573
        if (!$ret) {
1574
            $this->process_ifs++;
1575
        }
1576
    }
1577
1578
    /**
1579
     * Handle </if>
1580
     *
1581
     * @return void
1582
     */
1583
    protected function ifEndHandler(): void
1584
    {
1585
        if ($this->process_ifs > 0) {
1586
            $this->process_ifs--;
1587
        }
1588
    }
1589
1590
    /**
1591
     * Handle <footnote>
1592
     * Collect the Footnote links
1593
     * GEDCOM Records that are protected by Privacy setting will be ignored
1594
     *
1595
     * @param array<string> $attrs
1596
     *
1597
     * @return void
1598
     */
1599
    protected function footnoteStartHandler(array $attrs): void
1600
    {
1601
        $id = '';
1602
        if (preg_match('/[0-9] (.+) @(.+)@/', $this->gedrec, $match)) {
1603
            $id = $match[2];
1604
        }
1605
        $record = Registry::gedcomRecordFactory()->make($id, $this->tree);
1606
        if ($record && $record->canShow()) {
1607
            $this->print_data_stack[] = $this->print_data;
1608
            $this->print_data         = true;
1609
            $style                    = '';
1610
            if (!empty($attrs['style'])) {
1611
                $style = $attrs['style'];
1612
            }
1613
            $this->footnote_element = $this->current_element;
1614
            $this->current_element  = $this->report_root->createFootnote($style);
1615
        } else {
1616
            $this->print_data       = false;
1617
            $this->process_footnote = false;
1618
        }
1619
    }
1620
1621
    /**
1622
     * Handle </footnote>
1623
     * Print the collected Footnote data
1624
     *
1625
     * @return void
1626
     */
1627
    protected function footnoteEndHandler(): void
1628
    {
1629
        if ($this->process_footnote) {
1630
            $this->print_data = array_pop($this->print_data_stack);
1631
            $temp             = trim($this->current_element->getValue());
1632
            if (strlen($temp) > 3) {
1633
                $this->wt_report->addElement($this->current_element);
1634
            }
1635
            $this->current_element = $this->footnote_element;
1636
        } else {
1637
            $this->process_footnote = true;
1638
        }
1639
    }
1640
1641
    /**
1642
     * Handle <footnoteTexts />
1643
     *
1644
     * @return void
1645
     */
1646
    protected function footnoteTextsStartHandler(): void
1647
    {
1648
        $temp = 'footnotetexts';
1649
        $this->wt_report->addElement($temp);
1650
    }
1651
1652
    /**
1653
     * XML element Forced line break handler - HTML code
1654
     *
1655
     * @return void
1656
     */
1657
    protected function brStartHandler(): void
1658
    {
1659
        if ($this->print_data && $this->process_gedcoms === 0) {
1660
            $this->current_element->addText('<br>');
1661
        }
1662
    }
1663
1664
    /**
1665
     * Handle <sp />
1666
     * Forced space
1667
     *
1668
     * @return void
1669
     */
1670
    protected function spStartHandler(): void
1671
    {
1672
        if ($this->print_data && $this->process_gedcoms === 0) {
1673
            $this->current_element->addText(' ');
1674
        }
1675
    }
1676
1677
    /**
1678
     * Handle <highlightedImage />
1679
     *
1680
     * @param array<string> $attrs
1681
     *
1682
     * @return void
1683
     */
1684
    protected function highlightedImageStartHandler(array $attrs): void
1685
    {
1686
        $id = '';
1687
        if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
1688
            $id = $match[1];
1689
        }
1690
1691
        // Position the top corner of this box on the page
1692
        $top = (float) ($attrs['top'] ?? ReportBaseElement::CURRENT_POSITION);
1693
1694
        // Position the left corner of this box on the page
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
        $person     = Registry::individualFactory()->make($id, $this->tree);
1708
        $media_file = $person->findHighlightedMediaFile();
1709
1710
        if ($media_file instanceof MediaFile && $media_file->fileExists()) {
1711
            $image      = imagecreatefromstring($media_file->fileContents());
1712
            $attributes = [imagesx($image), imagesy($image)];
1713
1714
            if ($width > 0 && $height == 0) {
1715
                $perc   = $width / $attributes[0];
1716
                $height = round($attributes[1] * $perc);
1717
            } elseif ($height > 0 && $width == 0) {
1718
                $perc  = $height / $attributes[1];
1719
                $width = round($attributes[0] * $perc);
1720
            } else {
1721
                $width  = (float) $attributes[0];
1722
                $height = (float) $attributes[1];
1723
            }
1724
            $image = $this->report_root->createImageFromObject($media_file, $left, $top, $width, $height, $align, $ln);
1725
            $this->wt_report->addElement($image);
1726
        }
1727
    }
1728
1729
    /**
1730
     * Handle <image/>
1731
     *
1732
     * @param array<string> $attrs
1733
     *
1734
     * @return void
1735
     */
1736
    protected function imageStartHandler(array $attrs): void
1737
    {
1738
        // Position the top corner of this box on the page. the default is the current position
1739
        $top = (float) ($attrs['top'] ?? ReportBaseElement::CURRENT_POSITION);
1740
1741
        // mixed Position the left corner of this box on the page. the default is the current position
1742
        $left = (float) ($attrs['left'] ?? ReportBaseElement::CURRENT_POSITION);
1743
1744
        // string Align the image in left, center, right (or empty to use x/y position).
1745
        $align = $attrs['align'] ?? '';
1746
1747
        // string Next Line should be T:next to the image, N:next line
1748
        $ln = $attrs['ln'] ?? 'T';
1749
1750
        // Width, height (or both).
1751
        $width  = (float) ($attrs['width'] ?? 0.0);
1752
        $height = (float) ($attrs['height'] ?? 0.0);
1753
1754
        $file = $attrs['file'] ?? '';
1755
1756
        if ($file === '@FILE') {
1757
            $match = [];
1758
            if (preg_match("/\d OBJE @(.+)@/", $this->gedrec, $match)) {
1759
                $mediaobject = Registry::mediaFactory()->make($match[1], $this->tree);
1760
                $media_file  = $mediaobject->firstImageFile();
1761
1762
                if ($media_file instanceof MediaFile && $media_file->fileExists()) {
1763
                    $image      = imagecreatefromstring($media_file->fileContents());
1764
                    $attributes = [imagesx($image), imagesy($image)];
1765
1766
                    if ($width > 0 && $height == 0) {
1767
                        $perc   = $width / $attributes[0];
1768
                        $height = round($attributes[1] * $perc);
1769
                    } elseif ($height > 0 && $width == 0) {
1770
                        $perc  = $height / $attributes[1];
1771
                        $width = round($attributes[0] * $perc);
1772
                    } else {
1773
                        $width  = (float) $attributes[0];
1774
                        $height = (float) $attributes[1];
1775
                    }
1776
                    $image = $this->report_root->createImageFromObject($media_file, $left, $top, $width, $height, $align, $ln);
1777
                    $this->wt_report->addElement($image);
1778
                }
1779
            }
1780
        } elseif (file_exists($file) && preg_match('/(jpg|jpeg|png|gif)$/i', $file)) {
1781
            $size = getimagesize($file);
1782
            if ($width > 0 && $height == 0) {
1783
                $perc   = $width / $size[0];
1784
                $height = round($size[1] * $perc);
1785
            } elseif ($height > 0 && $width == 0) {
1786
                $perc  = $height / $size[1];
1787
                $width = round($size[0] * $perc);
1788
            } else {
1789
                $width  = $size[0];
1790
                $height = $size[1];
1791
            }
1792
            $image = $this->report_root->createImage($file, $left, $top, $width, $height, $align, $ln);
1793
            $this->wt_report->addElement($image);
1794
        }
1795
    }
1796
1797
    /**
1798
     * Handle <line>
1799
     *
1800
     * @param array<string> $attrs
1801
     *
1802
     * @return void
1803
     */
1804
    protected function lineStartHandler(array $attrs): void
1805
    {
1806
        // Start horizontal position, current position (default)
1807
        $x1 = ReportBaseElement::CURRENT_POSITION;
1808
        if (isset($attrs['x1'])) {
1809
            if ($attrs['x1'] === '0') {
1810
                $x1 = 0;
1811
            } elseif ($attrs['x1'] === '.') {
1812
                $x1 = ReportBaseElement::CURRENT_POSITION;
1813
            } elseif (!empty($attrs['x1'])) {
1814
                $x1 = (float) $attrs['x1'];
1815
            }
1816
        }
1817
        // Start vertical position, current position (default)
1818
        $y1 = ReportBaseElement::CURRENT_POSITION;
1819
        if (isset($attrs['y1'])) {
1820
            if ($attrs['y1'] === '0') {
1821
                $y1 = 0;
1822
            } elseif ($attrs['y1'] === '.') {
1823
                $y1 = ReportBaseElement::CURRENT_POSITION;
1824
            } elseif (!empty($attrs['y1'])) {
1825
                $y1 = (float) $attrs['y1'];
1826
            }
1827
        }
1828
        // End horizontal position, maximum width (default)
1829
        $x2 = ReportBaseElement::CURRENT_POSITION;
1830
        if (isset($attrs['x2'])) {
1831
            if ($attrs['x2'] === '0') {
1832
                $x2 = 0;
1833
            } elseif ($attrs['x2'] === '.') {
1834
                $x2 = ReportBaseElement::CURRENT_POSITION;
1835
            } elseif (!empty($attrs['x2'])) {
1836
                $x2 = (float) $attrs['x2'];
1837
            }
1838
        }
1839
        // End vertical position
1840
        $y2 = ReportBaseElement::CURRENT_POSITION;
1841
        if (isset($attrs['y2'])) {
1842
            if ($attrs['y2'] === '0') {
1843
                $y2 = 0;
1844
            } elseif ($attrs['y2'] === '.') {
1845
                $y2 = ReportBaseElement::CURRENT_POSITION;
1846
            } elseif (!empty($attrs['y2'])) {
1847
                $y2 = (float) $attrs['y2'];
1848
            }
1849
        }
1850
1851
        $line = $this->report_root->createLine($x1, $y1, $x2, $y2);
1852
        $this->wt_report->addElement($line);
1853
    }
1854
1855
    /**
1856
     * Handle <list>
1857
     *
1858
     * @param array<string> $attrs
1859
     *
1860
     * @return void
1861
     */
1862
    protected function listStartHandler(array $attrs): void
1863
    {
1864
        $this->process_repeats++;
1865
        if ($this->process_repeats > 1) {
1866
            return;
1867
        }
1868
1869
        $match = [];
1870
        if (isset($attrs['sortby'])) {
1871
            $sortby = $attrs['sortby'];
1872
            if (preg_match("/\\$(\w+)/", $sortby, $match)) {
1873
                $sortby = $this->vars[$match[1]]['id'];
1874
                $sortby = trim($sortby);
1875
            }
1876
        } else {
1877
            $sortby = 'NAME';
1878
        }
1879
1880
        $listname = $attrs['list'] ?? 'individual';
1881
1882
        // Some filters/sorts can be applied using SQL, while others require PHP
1883
        switch ($listname) {
1884
            case 'pending':
1885
                $this->list = DB::table('change')
1886
                    ->whereIn('change_id', function (Builder $query): void {
1887
                        $query->select([new Expression('MAX(change_id)')])
0 ignored issues
show
Bug introduced by
'MAX(change_id)' of type string is incompatible with the type Illuminate\Database\Query\TValue expected by parameter $value of Illuminate\Database\Quer...pression::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1887
                        $query->select([new Expression(/** @scrutinizer ignore-type */ 'MAX(change_id)')])
Loading history...
1888
                            ->from('change')
0 ignored issues
show
Bug introduced by
'change' of type string is incompatible with the type Closure|Illuminate\Datab...\Database\Query\Builder expected by parameter $table of Illuminate\Database\Query\Builder::from(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1888
                            ->from(/** @scrutinizer ignore-type */ 'change')
Loading history...
1889
                            ->where('gedcom_id', '=', $this->tree->id())
1890
                            ->where('status', '=', 'pending')
1891
                            ->groupBy(['xref']);
1892
                    })
1893
                    ->get()
1894
                    ->map(fn (object $row): GedcomRecord|null => Registry::gedcomRecordFactory()->make($row->xref, $this->tree, $row->new_gedcom ?: $row->old_gedcom))
1895
                    ->filter()
1896
                    ->all();
1897
                break;
1898
1899
            case 'individual':
1900
                $query = DB::table('individuals')
1901
                    ->where('i_file', '=', $this->tree->id())
1902
                    ->select(['i_id AS xref', 'i_gedcom AS gedcom'])
1903
                    ->distinct();
1904
1905
                foreach ($attrs as $attr => $value) {
1906
                    if (str_starts_with($attr, 'filter') && $value !== '') {
1907
                        $value = $this->substituteVars($value, false);
1908
                        // Convert the various filters into SQL
1909
                        if (preg_match('/^(\w+):DATE (LTE|GTE) (.+)$/', $value, $match)) {
1910
                            $query->join('dates AS ' . $attr, static function (JoinClause $join) use ($attr): void {
1911
                                $join
1912
                                    ->on($attr . '.d_gid', '=', 'i_id')
1913
                                    ->on($attr . '.d_file', '=', 'i_file');
1914
                            });
1915
1916
                            $query->where($attr . '.d_fact', '=', $match[1]);
1917
1918
                            $date = new Date($match[3]);
1919
1920
                            if ($match[2] === 'LTE') {
1921
                                $query->where($attr . '.d_julianday2', '<=', $date->maximumJulianDay());
1922
                            } else {
1923
                                $query->where($attr . '.d_julianday1', '>=', $date->minimumJulianDay());
1924
                            }
1925
1926
                            // This filter has been fully processed
1927
                            unset($attrs[$attr]);
1928
                        } elseif (preg_match('/^NAME CONTAINS (.+)$/', $value, $match)) {
1929
                            $query->join('name AS ' . $attr, static function (JoinClause $join) use ($attr): void {
1930
                                $join
1931
                                    ->on($attr . '.n_id', '=', 'i_id')
1932
                                    ->on($attr . '.n_file', '=', 'i_file');
1933
                            });
1934
                            // Search the DB only if there is any name supplied
1935
                            $names = explode(' ', $match[1]);
1936
                            foreach ($names as $name) {
1937
                                $query->where($attr . '.n_full', 'LIKE', '%' . addcslashes($name, '\\%_') . '%');
1938
                            }
1939
1940
                            // This filter has been fully processed
1941
                            unset($attrs[$attr]);
1942
                        } elseif (preg_match('/^LIKE \/(.+)\/$/', $value, $match)) {
1943
                            // Convert newline escape sequences to actual new lines
1944
                            $match[1] = str_replace('\n', "\n", $match[1]);
1945
1946
                            $query->where('i_gedcom', 'LIKE', $match[1]);
1947
1948
                            // This filter has been fully processed
1949
                            unset($attrs[$attr]);
1950
                        } elseif (preg_match('/^(?:\w*):PLAC CONTAINS (.+)$/', $value, $match)) {
1951
                            // Don't unset this filter. This is just initial filtering for performance
1952
                            $query
1953
                                ->join('placelinks AS ' . $attr . 'a', static function (JoinClause $join) use ($attr): void {
1954
                                    $join
1955
                                        ->on($attr . 'a.pl_file', '=', 'i_file')
1956
                                        ->on($attr . 'a.pl_gid', '=', 'i_id');
1957
                                })
1958
                                ->join('places AS ' . $attr . 'b', static function (JoinClause $join) use ($attr): void {
1959
                                    $join
1960
                                        ->on($attr . 'b.p_file', '=', $attr . 'a.pl_file')
1961
                                        ->on($attr . 'b.p_id', '=', $attr . 'a.pl_p_id');
1962
                                })
1963
                                ->where($attr . 'b.p_place', 'LIKE', '%' . addcslashes($match[1], '\\%_') . '%');
1964
                        } elseif (preg_match('/^(\w*):(\w+) CONTAINS (.+)$/', $value, $match)) {
1965
                            // Don't unset this filter. This is just initial filtering for performance
1966
                            $match[3] = strtr($match[3], ['\\' => '\\\\', '%'  => '\\%', '_'  => '\\_', ' ' => '%']);
1967
                            $like = "%\n1 " . $match[1] . "%\n2 " . $match[2] . '%' . $match[3] . '%';
1968
                            $query->where('i_gedcom', 'LIKE', $like);
1969
                        } elseif (preg_match('/^(\w+) CONTAINS (.*)$/', $value, $match)) {
1970
                            // Don't unset this filter. This is just initial filtering for performance
1971
                            $match[2] = strtr($match[2], ['\\' => '\\\\', '%'  => '\\%', '_'  => '\\_', ' ' => '%']);
1972
                            $like = "%\n1 " . $match[1] . '%' . $match[2] . '%';
1973
                            $query->where('i_gedcom', 'LIKE', $like);
1974
                        }
1975
                    }
1976
                }
1977
1978
                $this->list = [];
1979
1980
                foreach ($query->get() as $row) {
1981
                    $this->list[$row->xref] = Registry::individualFactory()->make($row->xref, $this->tree, $row->gedcom);
1982
                }
1983
                break;
1984
1985
            case 'family':
1986
                $query = DB::table('families')
1987
                    ->where('f_file', '=', $this->tree->id())
1988
                    ->select(['f_id AS xref', 'f_gedcom AS gedcom'])
1989
                    ->distinct();
1990
1991
                foreach ($attrs as $attr => $value) {
1992
                    if (str_starts_with($attr, 'filter') && $value !== '') {
1993
                        $value = $this->substituteVars($value, false);
1994
                        // Convert the various filters into SQL
1995
                        if (preg_match('/^(\w+):DATE (LTE|GTE) (.+)$/', $value, $match)) {
1996
                            $query->join('dates AS ' . $attr, static function (JoinClause $join) use ($attr): void {
1997
                                $join
1998
                                    ->on($attr . '.d_gid', '=', 'f_id')
1999
                                    ->on($attr . '.d_file', '=', 'f_file');
2000
                            });
2001
2002
                            $query->where($attr . '.d_fact', '=', $match[1]);
2003
2004
                            $date = new Date($match[3]);
2005
2006
                            if ($match[2] === 'LTE') {
2007
                                $query->where($attr . '.d_julianday2', '<=', $date->maximumJulianDay());
2008
                            } else {
2009
                                $query->where($attr . '.d_julianday1', '>=', $date->minimumJulianDay());
2010
                            }
2011
2012
                            // This filter has been fully processed
2013
                            unset($attrs[$attr]);
2014
                        } elseif (preg_match('/^LIKE \/(.+)\/$/', $value, $match)) {
2015
                            // Convert newline escape sequences to actual new lines
2016
                            $match[1] = str_replace('\n', "\n", $match[1]);
2017
2018
                            $query->where('f_gedcom', 'LIKE', $match[1]);
2019
2020
                            // This filter has been fully processed
2021
                            unset($attrs[$attr]);
2022
                        } elseif (preg_match('/^NAME CONTAINS (.*)$/', $value, $match)) {
2023
                            if ($sortby === 'NAME' || $match[1] !== '') {
2024
                                $query->join('name AS ' . $attr, static function (JoinClause $join) use ($attr): void {
2025
                                    $join
2026
                                        ->on($attr . '.n_file', '=', 'f_file')
2027
                                        ->where(static function (Builder $query): void {
2028
                                            $query
2029
                                                ->whereColumn('n_id', '=', 'f_husb')
2030
                                                ->orWhereColumn('n_id', '=', 'f_wife');
2031
                                        });
2032
                                });
2033
                                // Search the DB only if there is any name supplied
2034
                                if ($match[1] != '') {
2035
                                    $names = explode(' ', $match[1]);
2036
                                    foreach ($names as $name) {
2037
                                        $query->where($attr . '.n_full', 'LIKE', '%' . addcslashes($name, '\\%_') . '%');
2038
                                    }
2039
                                }
2040
                            }
2041
2042
                            // This filter has been fully processed
2043
                            unset($attrs[$attr]);
2044
                        } elseif (preg_match('/^(?:\w*):PLAC CONTAINS (.+)$/', $value, $match)) {
2045
                            // Don't unset this filter. This is just initial filtering for performance
2046
                            $query
2047
                                ->join('placelinks AS ' . $attr . 'a', static function (JoinClause $join) use ($attr): void {
2048
                                    $join
2049
                                        ->on($attr . 'a.pl_file', '=', 'f_file')
2050
                                        ->on($attr . 'a.pl_gid', '=', 'f_id');
2051
                                })
2052
                                ->join('places AS ' . $attr . 'b', static function (JoinClause $join) use ($attr): void {
2053
                                    $join
2054
                                        ->on($attr . 'b.p_file', '=', $attr . 'a.pl_file')
2055
                                        ->on($attr . 'b.p_id', '=', $attr . 'a.pl_p_id');
2056
                                })
2057
                                ->where($attr . 'b.p_place', 'LIKE', '%' . addcslashes($match[1], '\\%_') . '%');
2058
                        } elseif (preg_match('/^(\w*):(\w+) CONTAINS (.+)$/', $value, $match)) {
2059
                            // Don't unset this filter. This is just initial filtering for performance
2060
                            $match[3] = strtr($match[3], ['\\' => '\\\\', '%'  => '\\%', '_'  => '\\_', ' ' => '%']);
2061
                            $like = "%\n1 " . $match[1] . "%\n2 " . $match[2] . '%' . $match[3] . '%';
2062
                            $query->where('f_gedcom', 'LIKE', $like);
2063
                        } elseif (preg_match('/^(\w+) CONTAINS (.+)$/', $value, $match)) {
2064
                            // Don't unset this filter. This is just initial filtering for performance
2065
                            $match[2] = strtr($match[2], ['\\' => '\\\\', '%'  => '\\%', '_'  => '\\_', ' ' => '%']);
2066
                            $like = "%\n1 " . $match[1] . '%' . $match[2] . '%';
2067
                            $query->where('f_gedcom', 'LIKE', $like);
2068
                        }
2069
                    }
2070
                }
2071
2072
                $this->list = [];
2073
2074
                foreach ($query->get() as $row) {
2075
                    $this->list[$row->xref] = Registry::familyFactory()->make($row->xref, $this->tree, $row->gedcom);
2076
                }
2077
                break;
2078
2079
            default:
2080
                throw new DomainException('Invalid list name: ' . $listname);
2081
        }
2082
2083
        $filters  = [];
2084
        $filters2 = [];
2085
        if (isset($attrs['filter1']) && count($this->list) > 0) {
2086
            foreach ($attrs as $key => $value) {
2087
                if (preg_match("/filter(\d)/", $key)) {
2088
                    $condition = $value;
2089
                    if (preg_match("/@(\w+)/", $condition, $match)) {
2090
                        $id    = $match[1];
2091
                        $value = "''";
2092
                        if ($id === 'ID') {
2093
                            if (preg_match('/0 @(.+)@/', $this->gedrec, $match)) {
2094
                                $value = "'" . $match[1] . "'";
2095
                            }
2096
                        } elseif ($id === 'fact') {
2097
                            $value = "'" . $this->fact . "'";
2098
                        } elseif ($id === 'desc') {
2099
                            $value = "'" . $this->desc . "'";
2100
                        } elseif (preg_match("/\d $id (.+)/", $this->gedrec, $match)) {
2101
                            $value = "'" . str_replace('@', '', trim($match[1])) . "'";
2102
                        }
2103
                        $condition = preg_replace("/@$id/", $value, $condition);
2104
                    }
2105
                    //-- handle regular expressions
2106
                    if (preg_match("/([A-Z:]+)\s*([^\s]+)\s*(.+)/", $condition, $match)) {
2107
                        $tag  = trim($match[1]);
2108
                        $expr = trim($match[2]);
2109
                        $val  = trim($match[3]);
2110
                        if (preg_match("/\\$(\w+)/", $val, $match)) {
2111
                            $val = $this->vars[$match[1]]['id'];
2112
                            $val = trim($val);
2113
                        }
2114
                        if ($val !== '') {
2115
                            $searchstr = '';
2116
                            $tags      = explode(':', $tag);
2117
                            //-- only limit to a level number if we are specifically looking at a level
2118
                            if (count($tags) > 1) {
2119
                                $level = 1;
2120
                                $t = 'XXXX';
2121
                                foreach ($tags as $t) {
2122
                                    if (!empty($searchstr)) {
2123
                                        $searchstr .= "[^\n]*(\n[2-9][^\n]*)*\n";
2124
                                    }
2125
                                    //-- search for both EMAIL and _EMAIL... silly double gedcom standard
2126
                                    if ($t === 'EMAIL' || $t === '_EMAIL') {
2127
                                        $t = '_?EMAIL';
2128
                                    }
2129
                                    $searchstr .= $level . ' ' . $t;
2130
                                    $level++;
2131
                                }
2132
                            } else {
2133
                                if ($tag === 'EMAIL' || $tag === '_EMAIL') {
2134
                                    $tag = '_?EMAIL';
2135
                                }
2136
                                $t         = $tag;
2137
                                $searchstr = '1 ' . $tag;
2138
                            }
2139
                            switch ($expr) {
2140
                                case 'CONTAINS':
2141
                                    if ($t === 'PLAC') {
2142
                                        $searchstr .= "[^\n]*[, ]*" . $val;
2143
                                    } else {
2144
                                        $searchstr .= "[^\n]*" . $val;
2145
                                    }
2146
                                    $filters[] = $searchstr;
2147
                                    break;
2148
                                default:
2149
                                    $filters2[] = [
2150
                                        'tag'  => $tag,
2151
                                        'expr' => $expr,
2152
                                        'val'  => $val,
2153
                                    ];
2154
                                    break;
2155
                            }
2156
                        }
2157
                    }
2158
                }
2159
            }
2160
        }
2161
        //-- apply other filters to the list that could not be added to the search string
2162
        if ($filters !== []) {
2163
            foreach ($this->list as $key => $record) {
2164
                foreach ($filters as $filter) {
2165
                    if (!preg_match('/' . $filter . '/i', $record->privatizeGedcom(Auth::accessLevel($this->tree)))) {
2166
                        unset($this->list[$key]);
2167
                        break;
2168
                    }
2169
                }
2170
            }
2171
        }
2172
        if ($filters2 !== []) {
2173
            $mylist = [];
2174
            foreach ($this->list as $indi) {
2175
                $key  = $indi->xref();
2176
                $grec = $indi->privatizeGedcom(Auth::accessLevel($this->tree));
2177
                $keep = true;
2178
                foreach ($filters2 as $filter) {
2179
                    if ($keep) {
2180
                        $tag  = $filter['tag'];
2181
                        $expr = $filter['expr'];
2182
                        $val  = $filter['val'];
2183
                        if ($val === "''") {
2184
                            $val = '';
2185
                        }
2186
                        $tags = explode(':', $tag);
2187
                        $t    = end($tags);
2188
                        $v    = $this->getGedcomValue($tag, 1, $grec);
2189
                        //-- check for EMAIL and _EMAIL (silly double gedcom standard :P)
2190
                        if ($t === 'EMAIL' && empty($v)) {
2191
                            $tag  = str_replace('EMAIL', '_EMAIL', $tag);
2192
                            $tags = explode(':', $tag);
2193
                            $t    = end($tags);
2194
                            $v    = self::getSubRecord(1, $tag, $grec);
2195
                        }
2196
2197
                        switch ($expr) {
2198
                            case 'GTE':
2199
                                if ($t === 'DATE') {
2200
                                    $date1 = new Date($v);
2201
                                    $date2 = new Date($val);
2202
                                    $keep  = (Date::compare($date1, $date2) >= 0);
2203
                                } elseif ($val >= $v) {
2204
                                    $keep = true;
2205
                                }
2206
                                break;
2207
                            case 'LTE':
2208
                                if ($t === 'DATE') {
2209
                                    $date1 = new Date($v);
2210
                                    $date2 = new Date($val);
2211
                                    $keep  = (Date::compare($date1, $date2) <= 0);
2212
                                } elseif ($val >= $v) {
2213
                                    $keep = true;
2214
                                }
2215
                                break;
2216
                            default:
2217
                                if ($v == $val) {
2218
                                    $keep = true;
2219
                                } else {
2220
                                    $keep = false;
2221
                                }
2222
                                break;
2223
                        }
2224
                    }
2225
                }
2226
                if ($keep) {
2227
                    $mylist[$key] = $indi;
2228
                }
2229
            }
2230
            $this->list = $mylist;
2231
        }
2232
2233
        switch ($sortby) {
2234
            case 'NAME':
2235
                uasort($this->list, GedcomRecord::nameComparator());
2236
                break;
2237
            case 'CHAN':
2238
                uasort($this->list, GedcomRecord::lastChangeComparator());
2239
                break;
2240
            case 'BIRT:DATE':
2241
                uasort($this->list, Individual::birthDateComparator());
2242
                break;
2243
            case 'DEAT:DATE':
2244
                uasort($this->list, Individual::deathDateComparator());
2245
                break;
2246
            case 'MARR:DATE':
2247
                uasort($this->list, Family::marriageDateComparator());
2248
                break;
2249
            default:
2250
                // unsorted or already sorted by SQL
2251
                break;
2252
        }
2253
2254
        $this->repeats_stack[] = [$this->repeats, $this->repeat_bytes];
2255
        $this->repeat_bytes    = xml_get_current_line_number($this->parser) + 1;
2256
    }
2257
2258
    /**
2259
     * Handle </list>
2260
     *
2261
     * @return void
2262
     */
2263
    protected function listEndHandler(): void
2264
    {
2265
        $this->process_repeats--;
2266
        if ($this->process_repeats > 0) {
2267
            return;
2268
        }
2269
2270
        // Check if there is any list
2271
        if (count($this->list) > 0) {
2272
            $lineoffset = 0;
2273
            foreach ($this->repeats_stack as $rep) {
2274
                $lineoffset += $rep[1];
2275
            }
2276
            //-- read the xml from the file
2277
            $lines = file($this->report);
2278
            while ((!str_contains($lines[$lineoffset + $this->repeat_bytes], '<List')) && (($lineoffset + $this->repeat_bytes) > 0)) {
2279
                $lineoffset--;
2280
            }
2281
            $lineoffset++;
2282
            $reportxml = "<tempdoc>\n";
2283
            $line_nr   = $lineoffset + $this->repeat_bytes;
2284
            // List Level counter
2285
            $count = 1;
2286
            while (0 < $count) {
2287
                if (str_contains($lines[$line_nr], '<List')) {
2288
                    $count++;
2289
                } elseif (str_contains($lines[$line_nr], '</List')) {
2290
                    $count--;
2291
                }
2292
                if (0 < $count) {
2293
                    $reportxml .= $lines[$line_nr];
2294
                }
2295
                $line_nr++;
2296
            }
2297
            // No need to drag this
2298
            unset($lines);
2299
            $reportxml .= '</tempdoc>';
2300
            // Save original values
2301
            $this->parser_stack[] = $this->parser;
2302
            $oldgedrec            = $this->gedrec;
2303
2304
            $this->list_total   = count($this->list);
2305
            $this->list_private = 0;
2306
            foreach ($this->list as $record) {
2307
                if ($record->canShow()) {
2308
                    $this->gedrec = $record->privatizeGedcom(Auth::accessLevel($record->tree()));
2309
                    //-- start the sax parser
2310
                    $repeat_parser = xml_parser_create();
2311
                    $this->parser  = $repeat_parser;
2312
                    xml_parser_set_option($repeat_parser, XML_OPTION_CASE_FOLDING, 0);
2313
2314
                    xml_set_element_handler(
2315
                        $repeat_parser,
2316
                        function ($parser, string $name, array $attrs): void {
2317
                            $this->startElement($parser, $name, $attrs);
2318
                        },
2319
                        function ($parser, string $name): void {
2320
                            $this->endElement($parser, $name);
2321
                        }
2322
                    );
2323
2324
                    xml_set_character_data_handler(
2325
                        $repeat_parser,
2326
                        function ($parser, string $data): void {
2327
                            $this->characterData($parser, $data);
2328
                        }
2329
                    );
2330
2331
                    if (!xml_parse($repeat_parser, $reportxml, true)) {
2332
                        throw new DomainException(sprintf(
2333
                            'ListEHandler XML error: %s at line %d',
2334
                            xml_error_string(xml_get_error_code($repeat_parser)),
2335
                            xml_get_current_line_number($repeat_parser)
2336
                        ));
2337
                    }
2338
                    xml_parser_free($repeat_parser);
2339
                } else {
2340
                    $this->list_private++;
2341
                }
2342
            }
2343
            $this->list   = [];
2344
            $this->parser = array_pop($this->parser_stack);
2345
            $this->gedrec = $oldgedrec;
2346
        }
2347
        [$this->repeats, $this->repeat_bytes] = array_pop($this->repeats_stack);
2348
    }
2349
2350
    /**
2351
     * Handle <listTotal>
2352
     * Prints the total number of records in a list
2353
     * The total number is collected from <list> and <relatives>
2354
     *
2355
     * @return void
2356
     */
2357
    protected function listTotalStartHandler(): void
2358
    {
2359
        if ($this->list_private == 0) {
2360
            $this->current_element->addText((string) $this->list_total);
2361
        } else {
2362
            $this->current_element->addText(($this->list_total - $this->list_private) . ' / ' . $this->list_total);
2363
        }
2364
    }
2365
2366
    /**
2367
     * Handle <relatives>
2368
     *
2369
     * @param array<string> $attrs
2370
     *
2371
     * @return void
2372
     */
2373
    protected function relativesStartHandler(array $attrs): void
2374
    {
2375
        $this->process_repeats++;
2376
        if ($this->process_repeats > 1) {
2377
            return;
2378
        }
2379
2380
        $sortby = $attrs['sortby'] ?? 'NAME';
2381
2382
        $match = [];
2383
        if (preg_match("/\\$(\w+)/", $sortby, $match)) {
2384
            $sortby = $this->vars[$match[1]]['id'];
2385
            $sortby = trim($sortby);
2386
        }
2387
2388
        $maxgen = -1;
2389
        if (isset($attrs['maxgen'])) {
2390
            $maxgen = (int) $attrs['maxgen'];
2391
        }
2392
2393
        $group = $attrs['group'] ?? 'child-family';
2394
2395
        if (preg_match("/\\$(\w+)/", $group, $match)) {
2396
            $group = $this->vars[$match[1]]['id'];
2397
            $group = trim($group);
2398
        }
2399
2400
        $id = $attrs['id'] ?? '';
2401
2402
        if (preg_match("/\\$(\w+)/", $id, $match)) {
2403
            $id = $this->vars[$match[1]]['id'];
2404
            $id = trim($id);
2405
        }
2406
2407
        $this->list = [];
2408
        $person     = Registry::individualFactory()->make($id, $this->tree);
2409
        if ($person instanceof Individual) {
2410
            $this->list[$id] = $person;
2411
            switch ($group) {
2412
                case 'child-family':
2413
                    foreach ($person->childFamilies() as $family) {
2414
                        foreach ($family->spouses() as $spouse) {
2415
                            $this->list[$spouse->xref()] = $spouse;
2416
                        }
2417
2418
                        foreach ($family->children() as $child) {
2419
                            $this->list[$child->xref()] = $child;
2420
                        }
2421
                    }
2422
                    break;
2423
                case 'spouse-family':
2424
                    foreach ($person->spouseFamilies() as $family) {
2425
                        foreach ($family->spouses() as $spouse) {
2426
                            $this->list[$spouse->xref()] = $spouse;
2427
                        }
2428
2429
                        foreach ($family->children() as $child) {
2430
                            $this->list[$child->xref()] = $child;
2431
                        }
2432
                    }
2433
                    break;
2434
                case 'direct-ancestors':
2435
                    $this->addAncestors($this->list, $id, false, $maxgen);
2436
                    break;
2437
                case 'ancestors':
2438
                    $this->addAncestors($this->list, $id, true, $maxgen);
2439
                    break;
2440
                case 'descendants':
2441
                    $this->list[$id]->generation = 1;
2442
                    $this->addDescendancy($this->list, $id, false, $maxgen);
2443
                    break;
2444
                case 'all':
2445
                    $this->addAncestors($this->list, $id, true, $maxgen);
2446
                    $this->addDescendancy($this->list, $id, true, $maxgen);
2447
                    break;
2448
            }
2449
        }
2450
2451
        switch ($sortby) {
2452
            case 'NAME':
2453
                uasort($this->list, GedcomRecord::nameComparator());
2454
                break;
2455
            case 'BIRT:DATE':
2456
                uasort($this->list, Individual::birthDateComparator());
2457
                break;
2458
            case 'DEAT:DATE':
2459
                uasort($this->list, Individual::deathDateComparator());
2460
                break;
2461
            case 'generation':
2462
                $newarray = [];
2463
                reset($this->list);
2464
                $genCounter = 1;
2465
                while (count($newarray) < count($this->list)) {
2466
                    foreach ($this->list as $key => $value) {
2467
                        $this->generation = $value->generation;
2468
                        if ($this->generation == $genCounter) {
2469
                            $newarray[$key] = (object) ['generation' => $this->generation];
2470
                        }
2471
                    }
2472
                    $genCounter++;
2473
                }
2474
                $this->list = $newarray;
2475
                break;
2476
            default:
2477
                // unsorted
2478
                break;
2479
        }
2480
        $this->repeats_stack[] = [$this->repeats, $this->repeat_bytes];
2481
        $this->repeat_bytes    = xml_get_current_line_number($this->parser) + 1;
2482
    }
2483
2484
    /**
2485
     * Handle </relatives>
2486
     *
2487
     * @return void
2488
     */
2489
    protected function relativesEndHandler(): void
2490
    {
2491
        $this->process_repeats--;
2492
        if ($this->process_repeats > 0) {
2493
            return;
2494
        }
2495
2496
        // Check if there is any relatives
2497
        if (count($this->list) > 0) {
2498
            $lineoffset = 0;
2499
            foreach ($this->repeats_stack as $rep) {
2500
                $lineoffset += $rep[1];
2501
            }
2502
            //-- read the xml from the file
2503
            $lines = file($this->report);
2504
            while (!str_contains($lines[$lineoffset + $this->repeat_bytes], '<Relatives') && $lineoffset + $this->repeat_bytes > 0) {
2505
                $lineoffset--;
2506
            }
2507
            $lineoffset++;
2508
            $reportxml = "<tempdoc>\n";
2509
            $line_nr   = $lineoffset + $this->repeat_bytes;
2510
            // Relatives Level counter
2511
            $count = 1;
2512
            while (0 < $count) {
2513
                if (str_contains($lines[$line_nr], '<Relatives')) {
2514
                    $count++;
2515
                } elseif (str_contains($lines[$line_nr], '</Relatives')) {
2516
                    $count--;
2517
                }
2518
                if (0 < $count) {
2519
                    $reportxml .= $lines[$line_nr];
2520
                }
2521
                $line_nr++;
2522
            }
2523
            // No need to drag this
2524
            unset($lines);
2525
            $reportxml .= "</tempdoc>\n";
2526
            // Save original values
2527
            $this->parser_stack[] = $this->parser;
2528
            $oldgedrec            = $this->gedrec;
2529
2530
            $this->list_total   = count($this->list);
2531
            $this->list_private = 0;
2532
            foreach ($this->list as $xref => $value) {
2533
                if (isset($value->generation)) {
2534
                    $this->generation = $value->generation;
2535
                }
2536
                $tmp          = Registry::gedcomRecordFactory()->make((string) $xref, $this->tree);
2537
                $this->gedrec = $tmp->privatizeGedcom(Auth::accessLevel($this->tree));
2538
2539
                $repeat_parser = xml_parser_create();
2540
                $this->parser  = $repeat_parser;
2541
                xml_parser_set_option($repeat_parser, XML_OPTION_CASE_FOLDING, 0);
2542
2543
                xml_set_element_handler(
2544
                    $repeat_parser,
2545
                    function ($parser, string $name, array $attrs): void {
2546
                        $this->startElement($parser, $name, $attrs);
2547
                    },
2548
                    function ($parser, string $name): void {
2549
                        $this->endElement($parser, $name);
2550
                    }
2551
                );
2552
2553
                xml_set_character_data_handler(
2554
                    $repeat_parser,
2555
                    function ($parser, string $data): void {
2556
                        $this->characterData($parser, $data);
2557
                    }
2558
                );
2559
2560
                if (!xml_parse($repeat_parser, $reportxml, true)) {
2561
                    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)));
2562
                }
2563
                xml_parser_free($repeat_parser);
2564
            }
2565
            // Clean up the list array
2566
            $this->list   = [];
2567
            $this->parser = array_pop($this->parser_stack);
2568
            $this->gedrec = $oldgedrec;
2569
        }
2570
        [$this->repeats, $this->repeat_bytes] = array_pop($this->repeats_stack);
2571
    }
2572
2573
    /**
2574
     * Handle <generation />
2575
     * Prints the number of generations
2576
     *
2577
     * @return void
2578
     */
2579
    protected function generationStartHandler(): void
2580
    {
2581
        $this->current_element->addText((string) $this->generation);
2582
    }
2583
2584
    /**
2585
     * Handle <newPage />
2586
     * Has to be placed in an element (header, body or footer)
2587
     *
2588
     * @return void
2589
     */
2590
    protected function newPageStartHandler(): void
2591
    {
2592
        $temp = 'addpage';
2593
        $this->wt_report->addElement($temp);
2594
    }
2595
2596
    /**
2597
     * Handle </title>
2598
     *
2599
     * @return void
2600
     */
2601
    protected function titleEndHandler(): void
2602
    {
2603
        $this->report_root->addTitle($this->text);
2604
    }
2605
2606
    /**
2607
     * Handle </description>
2608
     *
2609
     * @return void
2610
     */
2611
    protected function descriptionEndHandler(): void
2612
    {
2613
        $this->report_root->addDescription($this->text);
2614
    }
2615
2616
    /**
2617
     * Create a list of all descendants.
2618
     *
2619
     * @param array<Individual> $list
2620
     * @param string            $pid
2621
     * @param bool              $parents
2622
     * @param int               $generations
2623
     *
2624
     * @return void
2625
     */
2626
    private function addDescendancy(&$list, $pid, $parents = false, $generations = -1): void
2627
    {
2628
        $person = Registry::individualFactory()->make($pid, $this->tree);
2629
        if ($person === null) {
2630
            return;
2631
        }
2632
        if (!isset($list[$pid])) {
2633
            $list[$pid] = $person;
2634
        }
2635
        if (!isset($list[$pid]->generation)) {
2636
            $list[$pid]->generation = 0;
2637
        }
2638
        foreach ($person->spouseFamilies() as $family) {
2639
            if ($parents) {
2640
                $husband = $family->husband();
2641
                $wife    = $family->wife();
2642
                if ($husband) {
2643
                    $list[$husband->xref()] = $husband;
2644
                    if (isset($list[$pid]->generation)) {
2645
                        $list[$husband->xref()]->generation = $list[$pid]->generation - 1;
2646
                    } else {
2647
                        $list[$husband->xref()]->generation = 1;
2648
                    }
2649
                }
2650
                if ($wife) {
2651
                    $list[$wife->xref()] = $wife;
2652
                    if (isset($list[$pid]->generation)) {
2653
                        $list[$wife->xref()]->generation = $list[$pid]->generation - 1;
2654
                    } else {
2655
                        $list[$wife->xref()]->generation = 1;
2656
                    }
2657
                }
2658
            }
2659
2660
            $children = $family->children();
2661
2662
            foreach ($children as $child) {
2663
                if ($child) {
2664
                    $list[$child->xref()] = $child;
2665
2666
                    if (isset($list[$pid]->generation)) {
2667
                        $list[$child->xref()]->generation = $list[$pid]->generation + 1;
2668
                    } else {
2669
                        $list[$child->xref()]->generation = 2;
2670
                    }
2671
                }
2672
            }
2673
            if ($generations == -1 || $list[$pid]->generation + 1 < $generations) {
2674
                foreach ($children as $child) {
2675
                    $this->addDescendancy($list, $child->xref(), $parents, $generations); // recurse on the childs family
2676
                }
2677
            }
2678
        }
2679
    }
2680
2681
    /**
2682
     * Create a list of all ancestors.
2683
     *
2684
     * @param array<Individual> $list
2685
     * @param string            $pid
2686
     * @param bool              $children
2687
     * @param int               $generations
2688
     *
2689
     * @return void
2690
     */
2691
    private function addAncestors(array &$list, string $pid, bool $children = false, int $generations = -1): void
2692
    {
2693
        $genlist                = [$pid];
2694
        $list[$pid]->generation = 1;
2695
        while (count($genlist) > 0) {
2696
            $id = array_shift($genlist);
2697
            if (str_starts_with($id, 'empty')) {
2698
                continue; // id can be something like “empty7”
2699
            }
2700
            $person = Registry::individualFactory()->make($id, $this->tree);
2701
            foreach ($person->childFamilies() as $family) {
2702
                $husband = $family->husband();
2703
                $wife    = $family->wife();
2704
                if ($husband) {
2705
                    $list[$husband->xref()]             = $husband;
2706
                    $list[$husband->xref()]->generation = $list[$id]->generation + 1;
2707
                }
2708
                if ($wife) {
2709
                    $list[$wife->xref()]             = $wife;
2710
                    $list[$wife->xref()]->generation = $list[$id]->generation + 1;
2711
                }
2712
                if ($generations == -1 || $list[$id]->generation + 1 < $generations) {
2713
                    if ($husband) {
2714
                        $genlist[] = $husband->xref();
2715
                    }
2716
                    if ($wife) {
2717
                        $genlist[] = $wife->xref();
2718
                    }
2719
                }
2720
                if ($children) {
2721
                    foreach ($family->children() as $child) {
2722
                        $list[$child->xref()] = $child;
2723
                        $list[$child->xref()]->generation = $list[$id]->generation ?? 1;
2724
                    }
2725
                }
2726
            }
2727
        }
2728
    }
2729
2730
    /**
2731
     * get gedcom tag value
2732
     *
2733
     * @param string $tag    The tag to find, use : to delineate subtags
2734
     * @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
2735
     * @param string $gedrec The gedcom record to get the value from
2736
     *
2737
     * @return string the value of a gedcom tag from the given gedcom record
2738
     */
2739
    private function getGedcomValue(string $tag, int $level, string $gedrec): string
2740
    {
2741
        if ($gedrec === '') {
2742
            return '';
2743
        }
2744
        $tags      = explode(':', $tag);
2745
        $origlevel = $level;
2746
        if ($level === 0) {
2747
            $level = 1 + (int) $gedrec[0];
2748
        }
2749
2750
        $subrec = $gedrec;
2751
        $t = 'XXXX';
2752
        foreach ($tags as $t) {
2753
            $lastsubrec = $subrec;
2754
            $subrec     = self::getSubRecord($level, "$level $t", $subrec);
2755
            if (empty($subrec) && $origlevel == 0) {
2756
                $level--;
2757
                $subrec = self::getSubRecord($level, "$level $t", $lastsubrec);
2758
            }
2759
            if (empty($subrec)) {
2760
                if ($t === 'TITL') {
2761
                    $subrec = self::getSubRecord($level, "$level ABBR", $lastsubrec);
2762
                    if (!empty($subrec)) {
2763
                        $t = 'ABBR';
2764
                    }
2765
                }
2766
                if ($subrec === '') {
2767
                    if ($level > 0) {
2768
                        $level--;
2769
                    }
2770
                    $subrec = self::getSubRecord($level, "@ $t", $gedrec);
2771
                    if ($subrec === '') {
2772
                        return '';
2773
                    }
2774
                }
2775
            }
2776
            $level++;
2777
        }
2778
        $level--;
2779
        $ct = preg_match("/$level $t(.*)/", $subrec, $match);
2780
        if ($ct === 0) {
2781
            $ct = preg_match("/$level @.+@ (.+)/", $subrec, $match);
2782
        }
2783
        if ($ct === 0) {
2784
            $ct = preg_match("/@ $t (.+)/", $subrec, $match);
2785
        }
2786
        if ($ct > 0) {
2787
            $value = trim($match[1]);
2788
            if ($t === 'NOTE' && preg_match('/^@(.+)@$/', $value, $match)) {
2789
                $note = Registry::noteFactory()->make($match[1], $this->tree);
2790
                if ($note instanceof Note) {
2791
                    $value = $note->getNote();
2792
                } else {
2793
                    //-- set the value to the id without the @
2794
                    $value = $match[1];
2795
                }
2796
            }
2797
            if ($level !== 0 || $t !== 'NOTE') {
2798
                $value .= self::getCont($level + 1, $subrec);
2799
            }
2800
2801
            if ($tag === 'NAME' || $tag === '_MARNM' || $tag === '_AKA') {
2802
                return strtr($value, ['/' => '']);
2803
            }
2804
2805
            return $value;
2806
        }
2807
2808
        return '';
2809
    }
2810
2811
    /**
2812
     * Replace variable identifiers with their values.
2813
     *
2814
     * @param string $expression An expression such as "$foo == 123"
2815
     * @param bool   $quote      Whether to add quotation marks
2816
     *
2817
     * @return string
2818
     */
2819
    private function substituteVars($expression, $quote): string
2820
    {
2821
        return preg_replace_callback(
2822
            '/\$(\w+)/',
2823
            function (array $matches) use ($quote): string {
2824
                if (isset($this->vars[$matches[1]]['id'])) {
2825
                    if ($quote) {
2826
                        return "'" . addcslashes($this->vars[$matches[1]]['id'], "'") . "'";
2827
                    }
2828
2829
                    return $this->vars[$matches[1]]['id'];
2830
                }
2831
2832
                Log::addErrorLog(sprintf('Undefined variable $%s in report', $matches[1]));
2833
2834
                return '$' . $matches[1];
2835
            },
2836
            $expression
2837
        );
2838
    }
2839
}
2840