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