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