ReportParserGenerate::textBoxStartHandler()   F
last analyzed

Complexity

Conditions 30
Paths > 20000

Size

Total Lines 118
Code Lines 78

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 30
eloc 78
nc 819200
nop 1
dl 0
loc 118
rs 0
c 0
b 0
f 0

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

1885
                        $query->select([new Expression(/** @scrutinizer ignore-type */ 'MAX(change_id)')])
Loading history...
1886
                            ->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

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