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