1<?php 2/** 3 * @package dompdf 4 * @link http://dompdf.github.com/ 5 * @author Benj Carson <benjcarson@digitaljunkies.ca> 6 * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License 7 */ 8namespace Dompdf; 9 10use Dompdf\FrameDecorator\Table as TableFrameDecorator; 11use Dompdf\FrameDecorator\TableCell as TableCellFrameDecorator; 12 13/** 14 * Maps table cells to the table grid. 15 * 16 * This class resolves borders in tables with collapsed borders and helps 17 * place row & column spanned table cells. 18 * 19 * @package dompdf 20 */ 21class Cellmap 22{ 23 /** 24 * Border style weight lookup for collapsed border resolution. 25 * 26 * @var array 27 */ 28 protected static $_BORDER_STYLE_SCORE = array( 29 "inset" => 1, 30 "groove" => 2, 31 "outset" => 3, 32 "ridge" => 4, 33 "dotted" => 5, 34 "dashed" => 6, 35 "solid" => 7, 36 "double" => 8, 37 "hidden" => 9, 38 "none" => 0, 39 ); 40 41 /** 42 * The table object this cellmap is attached to. 43 * 44 * @var TableFrameDecorator 45 */ 46 protected $_table; 47 48 /** 49 * The total number of rows in the table 50 * 51 * @var int 52 */ 53 protected $_num_rows; 54 55 /** 56 * The total number of columns in the table 57 * 58 * @var int 59 */ 60 protected $_num_cols; 61 62 /** 63 * 2D array mapping <row,column> to frames 64 * 65 * @var Frame[][] 66 */ 67 protected $_cells; 68 69 /** 70 * 1D array of column dimensions 71 * 72 * @var array 73 */ 74 protected $_columns; 75 76 /** 77 * 1D array of row dimensions 78 * 79 * @var array 80 */ 81 protected $_rows; 82 83 /** 84 * 2D array of border specs 85 * 86 * @var array 87 */ 88 protected $_borders; 89 90 /** 91 * 1D Array mapping frames to (multiple) <row, col> pairs, keyed on frame_id. 92 * 93 * @var Frame[] 94 */ 95 protected $_frames; 96 97 /** 98 * Current column when adding cells, 0-based 99 * 100 * @var int 101 */ 102 private $__col; 103 104 /** 105 * Current row when adding cells, 0-based 106 * 107 * @var int 108 */ 109 private $__row; 110 111 /** 112 * Tells wether the columns' width can be modified 113 * 114 * @var bool 115 */ 116 private $_columns_locked = false; 117 118 /** 119 * Tells wether the table has table-layout:fixed 120 * 121 * @var bool 122 */ 123 private $_fixed_layout = false; 124 125 /** 126 * @param TableFrameDecorator $table 127 */ 128 public function __construct(TableFrameDecorator $table) 129 { 130 $this->_table = $table; 131 $this->reset(); 132 } 133 134 /** 135 * 136 */ 137 public function reset() 138 { 139 $this->_num_rows = 0; 140 $this->_num_cols = 0; 141 142 $this->_cells = array(); 143 $this->_frames = array(); 144 145 if (!$this->_columns_locked) { 146 $this->_columns = array(); 147 } 148 149 $this->_rows = array(); 150 151 $this->_borders = array(); 152 153 $this->__col = $this->__row = 0; 154 } 155 156 /** 157 * 158 */ 159 public function lock_columns() 160 { 161 $this->_columns_locked = true; 162 } 163 164 /** 165 * @return bool 166 */ 167 public function is_columns_locked() 168 { 169 return $this->_columns_locked; 170 } 171 172 /** 173 * @param $fixed 174 */ 175 public function set_layout_fixed($fixed) 176 { 177 $this->_fixed_layout = $fixed; 178 } 179 180 /** 181 * @return bool 182 */ 183 public function is_layout_fixed() 184 { 185 return $this->_fixed_layout; 186 } 187 188 /** 189 * @return int 190 */ 191 public function get_num_rows() 192 { 193 return $this->_num_rows; 194 } 195 196 /** 197 * @return int 198 */ 199 public function get_num_cols() 200 { 201 return $this->_num_cols; 202 } 203 204 /** 205 * @return array 206 */ 207 public function &get_columns() 208 { 209 return $this->_columns; 210 } 211 212 /** 213 * @param $columns 214 */ 215 public function set_columns($columns) 216 { 217 $this->_columns = $columns; 218 } 219 220 /** 221 * @param int $i 222 * 223 * @return mixed 224 */ 225 public function &get_column($i) 226 { 227 if (!isset($this->_columns[$i])) { 228 $this->_columns[$i] = array( 229 "x" => 0, 230 "min-width" => 0, 231 "max-width" => 0, 232 "used-width" => null, 233 "absolute" => 0, 234 "percent" => 0, 235 "auto" => true, 236 ); 237 } 238 239 return $this->_columns[$i]; 240 } 241 242 /** 243 * @return array 244 */ 245 public function &get_rows() 246 { 247 return $this->_rows; 248 } 249 250 /** 251 * @param int $j 252 * 253 * @return mixed 254 */ 255 public function &get_row($j) 256 { 257 if (!isset($this->_rows[$j])) { 258 $this->_rows[$j] = array( 259 "y" => 0, 260 "first-column" => 0, 261 "height" => null, 262 ); 263 } 264 265 return $this->_rows[$j]; 266 } 267 268 /** 269 * @param int $i 270 * @param int $j 271 * @param mixed $h_v 272 * @param null|mixed $prop 273 * 274 * @return mixed 275 */ 276 public function get_border($i, $j, $h_v, $prop = null) 277 { 278 if (!isset($this->_borders[$i][$j][$h_v])) { 279 $this->_borders[$i][$j][$h_v] = array( 280 "width" => 0, 281 "style" => "solid", 282 "color" => "black", 283 ); 284 } 285 286 if (isset($prop)) { 287 return $this->_borders[$i][$j][$h_v][$prop]; 288 } 289 290 return $this->_borders[$i][$j][$h_v]; 291 } 292 293 /** 294 * @param int $i 295 * @param int $j 296 * 297 * @return array 298 */ 299 public function get_border_properties($i, $j) 300 { 301 return array( 302 "top" => $this->get_border($i, $j, "horizontal"), 303 "right" => $this->get_border($i, $j + 1, "vertical"), 304 "bottom" => $this->get_border($i + 1, $j, "horizontal"), 305 "left" => $this->get_border($i, $j, "vertical"), 306 ); 307 } 308 309 /** 310 * @param Frame $frame 311 * 312 * @return null|Frame 313 */ 314 public function get_spanned_cells(Frame $frame) 315 { 316 $key = $frame->get_id(); 317 318 if (isset($this->_frames[$key])) { 319 return $this->_frames[$key]; 320 } 321 322 return null; 323 } 324 325 /** 326 * @param Frame $frame 327 * 328 * @return bool 329 */ 330 public function frame_exists_in_cellmap(Frame $frame) 331 { 332 $key = $frame->get_id(); 333 334 return isset($this->_frames[$key]); 335 } 336 337 /** 338 * @param Frame $frame 339 * 340 * @return array 341 * @throws Exception 342 */ 343 public function get_frame_position(Frame $frame) 344 { 345 global $_dompdf_warnings; 346 347 $key = $frame->get_id(); 348 349 if (!isset($this->_frames[$key])) { 350 throw new Exception("Frame not found in cellmap"); 351 } 352 353 $col = $this->_frames[$key]["columns"][0]; 354 $row = $this->_frames[$key]["rows"][0]; 355 356 if (!isset($this->_columns[$col])) { 357 $_dompdf_warnings[] = "Frame not found in columns array. Check your table layout for missing or extra TDs."; 358 $x = 0; 359 } else { 360 $x = $this->_columns[$col]["x"]; 361 } 362 363 if (!isset($this->_rows[$row])) { 364 $_dompdf_warnings[] = "Frame not found in row array. Check your table layout for missing or extra TDs."; 365 $y = 0; 366 } else { 367 $y = $this->_rows[$row]["y"]; 368 } 369 370 return array($x, $y, "x" => $x, "y" => $y); 371 } 372 373 /** 374 * @param Frame $frame 375 * 376 * @return int 377 * @throws Exception 378 */ 379 public function get_frame_width(Frame $frame) 380 { 381 $key = $frame->get_id(); 382 383 if (!isset($this->_frames[$key])) { 384 throw new Exception("Frame not found in cellmap"); 385 } 386 387 $cols = $this->_frames[$key]["columns"]; 388 $w = 0; 389 foreach ($cols as $i) { 390 $w += $this->_columns[$i]["used-width"]; 391 } 392 393 return $w; 394 } 395 396 /** 397 * @param Frame $frame 398 * 399 * @return int 400 * @throws Exception 401 * @throws Exception 402 */ 403 public function get_frame_height(Frame $frame) 404 { 405 $key = $frame->get_id(); 406 407 if (!isset($this->_frames[$key])) { 408 throw new Exception("Frame not found in cellmap"); 409 } 410 411 $rows = $this->_frames[$key]["rows"]; 412 $h = 0; 413 foreach ($rows as $i) { 414 if (!isset($this->_rows[$i])) { 415 throw new Exception("The row #$i could not be found, please file an issue in the tracker with the HTML code"); 416 } 417 418 $h += $this->_rows[$i]["height"]; 419 } 420 421 return $h; 422 } 423 424 /** 425 * @param int $j 426 * @param mixed $width 427 */ 428 public function set_column_width($j, $width) 429 { 430 if ($this->_columns_locked) { 431 return; 432 } 433 434 $col =& $this->get_column($j); 435 $col["used-width"] = $width; 436 $next_col =& $this->get_column($j + 1); 437 $next_col["x"] = $next_col["x"] + $width; 438 } 439 440 /** 441 * @param int $i 442 * @param mixed $height 443 */ 444 public function set_row_height($i, $height) 445 { 446 $row =& $this->get_row($i); 447 448 if ($row["height"] !== null && $height <= $row["height"]) { 449 return; 450 } 451 452 $row["height"] = $height; 453 $next_row =& $this->get_row($i + 1); 454 $next_row["y"] = $row["y"] + $height; 455 456 } 457 458 /** 459 * @param int $i 460 * @param int $j 461 * @param mixed $h_v 462 * @param mixed $border_spec 463 * 464 * @return mixed 465 */ 466 protected function _resolve_border($i, $j, $h_v, $border_spec) 467 { 468 $n_width = $border_spec["width"]; 469 $n_style = $border_spec["style"]; 470 471 if (!isset($this->_borders[$i][$j][$h_v])) { 472 $this->_borders[$i][$j][$h_v] = $border_spec; 473 474 return $this->_borders[$i][$j][$h_v]["width"]; 475 } 476 477 $border = & $this->_borders[$i][$j][$h_v]; 478 479 $o_width = $border["width"]; 480 $o_style = $border["style"]; 481 482 if (($n_style === "hidden" || 483 $n_width > $o_width || 484 $o_style === "none") 485 486 or 487 488 ($o_width == $n_width && 489 in_array($n_style, self::$_BORDER_STYLE_SCORE) && 490 self::$_BORDER_STYLE_SCORE[$n_style] > self::$_BORDER_STYLE_SCORE[$o_style]) 491 ) { 492 $border = $border_spec; 493 } 494 495 return $border["width"]; 496 } 497 498 /** 499 * @param Frame $frame 500 */ 501 public function add_frame(Frame $frame) 502 { 503 $style = $frame->get_style(); 504 $display = $style->display; 505 506 $collapse = $this->_table->get_style()->border_collapse == "collapse"; 507 508 // Recursively add the frames within tables, table-row-groups and table-rows 509 if ($display === "table-row" || 510 $display === "table" || 511 $display === "inline-table" || 512 in_array($display, TableFrameDecorator::$ROW_GROUPS) 513 ) { 514 $start_row = $this->__row; 515 foreach ($frame->get_children() as $child) { 516 // Ignore all Text frames and :before/:after pseudo-selector elements. 517 if (!($child instanceof FrameDecorator\Text) && $child->get_node()->nodeName !== 'dompdf_generated') { 518 $this->add_frame($child); 519 } 520 } 521 522 if ($display === "table-row") { 523 $this->add_row(); 524 } 525 526 $num_rows = $this->__row - $start_row - 1; 527 $key = $frame->get_id(); 528 529 // Row groups always span across the entire table 530 $this->_frames[$key]["columns"] = range(0, max(0, $this->_num_cols - 1)); 531 $this->_frames[$key]["rows"] = range($start_row, max(0, $this->__row - 1)); 532 $this->_frames[$key]["frame"] = $frame; 533 534 if ($display !== "table-row" && $collapse) { 535 $bp = $style->get_border_properties(); 536 537 // Resolve the borders 538 for ($i = 0; $i < $num_rows + 1; $i++) { 539 $this->_resolve_border($start_row + $i, 0, "vertical", $bp["left"]); 540 $this->_resolve_border($start_row + $i, $this->_num_cols, "vertical", $bp["right"]); 541 } 542 543 for ($j = 0; $j < $this->_num_cols; $j++) { 544 $this->_resolve_border($start_row, $j, "horizontal", $bp["top"]); 545 $this->_resolve_border($this->__row, $j, "horizontal", $bp["bottom"]); 546 } 547 } 548 return; 549 } 550 551 $node = $frame->get_node(); 552 553 // Determine where this cell is going 554 $colspan = $node->getAttribute("colspan"); 555 $rowspan = $node->getAttribute("rowspan"); 556 557 if (!$colspan) { 558 $colspan = 1; 559 $node->setAttribute("colspan", 1); 560 } 561 562 if (!$rowspan) { 563 $rowspan = 1; 564 $node->setAttribute("rowspan", 1); 565 } 566 $key = $frame->get_id(); 567 568 $bp = $style->get_border_properties(); 569 570 571 // Add the frame to the cellmap 572 $max_left = $max_right = 0; 573 574 // Find the next available column (fix by Ciro Mondueri) 575 $ac = $this->__col; 576 while (isset($this->_cells[$this->__row][$ac])) { 577 $ac++; 578 } 579 580 $this->__col = $ac; 581 582 // Rows: 583 for ($i = 0; $i < $rowspan; $i++) { 584 $row = $this->__row + $i; 585 586 $this->_frames[$key]["rows"][] = $row; 587 588 for ($j = 0; $j < $colspan; $j++) { 589 $this->_cells[$row][$this->__col + $j] = $frame; 590 } 591 592 if ($collapse) { 593 // Resolve vertical borders 594 $max_left = max($max_left, $this->_resolve_border($row, $this->__col, "vertical", $bp["left"])); 595 $max_right = max($max_right, $this->_resolve_border($row, $this->__col + $colspan, "vertical", $bp["right"])); 596 } 597 } 598 599 $max_top = $max_bottom = 0; 600 601 // Columns: 602 for ($j = 0; $j < $colspan; $j++) { 603 $col = $this->__col + $j; 604 $this->_frames[$key]["columns"][] = $col; 605 606 if ($collapse) { 607 // Resolve horizontal borders 608 $max_top = max($max_top, $this->_resolve_border($this->__row, $col, "horizontal", $bp["top"])); 609 $max_bottom = max($max_bottom, $this->_resolve_border($this->__row + $rowspan, $col, "horizontal", $bp["bottom"])); 610 } 611 } 612 613 $this->_frames[$key]["frame"] = $frame; 614 615 // Handle seperated border model 616 if (!$collapse) { 617 list($h, $v) = $this->_table->get_style()->border_spacing; 618 619 // Border spacing is effectively a margin between cells 620 $v = $style->length_in_pt($v); 621 if (is_numeric($v)) { 622 $v = $v / 2; 623 } 624 $h = $style->length_in_pt($h); 625 if (is_numeric($h)) { 626 $h = $h / 2; 627 } 628 $style->margin = "$v $h"; 629 630 // The additional 1/2 width gets added to the table proper 631 } else { 632 // Drop the frame's actual border 633 $style->border_left_width = $max_left / 2; 634 $style->border_right_width = $max_right / 2; 635 $style->border_top_width = $max_top / 2; 636 $style->border_bottom_width = $max_bottom / 2; 637 $style->margin = "none"; 638 } 639 640 if (!$this->_columns_locked) { 641 // Resolve the frame's width 642 if ($this->_fixed_layout) { 643 list($frame_min, $frame_max) = array(0, 10e-10); 644 } else { 645 list($frame_min, $frame_max) = $frame->get_min_max_width(); 646 } 647 648 $width = $style->width; 649 650 $val = null; 651 if (Helpers::is_percent($width)) { 652 $var = "percent"; 653 $val = (float)rtrim($width, "% ") / $colspan; 654 } else if ($width !== "auto") { 655 $var = "absolute"; 656 $val = $style->length_in_pt($frame_min) / $colspan; 657 } 658 659 $min = 0; 660 $max = 0; 661 for ($cs = 0; $cs < $colspan; $cs++) { 662 663 // Resolve the frame's width(s) with other cells 664 $col =& $this->get_column($this->__col + $cs); 665 666 // Note: $var is either 'percent' or 'absolute'. We compare the 667 // requested percentage or absolute values with the existing widths 668 // and adjust accordingly. 669 if (isset($var) && $val > $col[$var]) { 670 $col[$var] = $val; 671 $col["auto"] = false; 672 } 673 674 $min += $col["min-width"]; 675 $max += $col["max-width"]; 676 } 677 678 if ($frame_min > $min) { 679 // The frame needs more space. Expand each sub-column 680 // FIXME try to avoid putting this dummy value when table-layout:fixed 681 $inc = ($this->is_layout_fixed() ? 10e-10 : ($frame_min - $min) / $colspan); 682 for ($c = 0; $c < $colspan; $c++) { 683 $col =& $this->get_column($this->__col + $c); 684 $col["min-width"] += $inc; 685 } 686 } 687 688 if ($frame_max > $max) { 689 // FIXME try to avoid putting this dummy value when table-layout:fixed 690 $inc = ($this->is_layout_fixed() ? 10e-10 : ($frame_max - $max) / $colspan); 691 for ($c = 0; $c < $colspan; $c++) { 692 $col =& $this->get_column($this->__col + $c); 693 $col["max-width"] += $inc; 694 } 695 } 696 } 697 698 $this->__col += $colspan; 699 if ($this->__col > $this->_num_cols) { 700 $this->_num_cols = $this->__col; 701 } 702 } 703 704 /** 705 * 706 */ 707 public function add_row() 708 { 709 $this->__row++; 710 $this->_num_rows++; 711 712 // Find the next available column 713 $i = 0; 714 while (isset($this->_cells[$this->__row][$i])) { 715 $i++; 716 } 717 718 $this->__col = $i; 719 } 720 721 /** 722 * Remove a row from the cellmap. 723 * 724 * @param Frame 725 */ 726 public function remove_row(Frame $row) 727 { 728 $key = $row->get_id(); 729 if (!isset($this->_frames[$key])) { 730 return; // Presumably this row has alredy been removed 731 } 732 733 $this->__row = $this->_num_rows--; 734 735 $rows = $this->_frames[$key]["rows"]; 736 $columns = $this->_frames[$key]["columns"]; 737 738 // Remove all frames from this row 739 foreach ($rows as $r) { 740 foreach ($columns as $c) { 741 if (isset($this->_cells[$r][$c])) { 742 $id = $this->_cells[$r][$c]->get_id(); 743 744 $this->_cells[$r][$c] = null; 745 unset($this->_cells[$r][$c]); 746 747 // has multiple rows? 748 if (isset($this->_frames[$id]) && count($this->_frames[$id]["rows"]) > 1) { 749 // remove just the desired row, but leave the frame 750 if (($row_key = array_search($r, $this->_frames[$id]["rows"])) !== false) { 751 unset($this->_frames[$id]["rows"][$row_key]); 752 } 753 continue; 754 } 755 756 $this->_frames[$id] = null; 757 unset($this->_frames[$id]); 758 } 759 } 760 761 $this->_rows[$r] = null; 762 unset($this->_rows[$r]); 763 } 764 765 $this->_frames[$key] = null; 766 unset($this->_frames[$key]); 767 } 768 769 /** 770 * Remove a row group from the cellmap. 771 * 772 * @param Frame $group The group to remove 773 */ 774 public function remove_row_group(Frame $group) 775 { 776 $key = $group->get_id(); 777 if (!isset($this->_frames[$key])) { 778 return; // Presumably this row has alredy been removed 779 } 780 781 $iter = $group->get_first_child(); 782 while ($iter) { 783 $this->remove_row($iter); 784 $iter = $iter->get_next_sibling(); 785 } 786 787 $this->_frames[$key] = null; 788 unset($this->_frames[$key]); 789 } 790 791 /** 792 * Update a row group after rows have been removed 793 * 794 * @param Frame $group The group to update 795 * @param Frame $last_row The last row in the row group 796 */ 797 public function update_row_group(Frame $group, Frame $last_row) 798 { 799 $g_key = $group->get_id(); 800 $r_key = $last_row->get_id(); 801 802 $r_rows = $this->_frames[$g_key]["rows"]; 803 $this->_frames[$g_key]["rows"] = range($this->_frames[$g_key]["rows"][0], end($r_rows)); 804 } 805 806 /** 807 * 808 */ 809 public function assign_x_positions() 810 { 811 // Pre-condition: widths must be resolved and assigned to columns and 812 // column[0]["x"] must be set. 813 814 if ($this->_columns_locked) { 815 return; 816 } 817 818 $x = $this->_columns[0]["x"]; 819 foreach (array_keys($this->_columns) as $j) { 820 $this->_columns[$j]["x"] = $x; 821 $x += $this->_columns[$j]["used-width"]; 822 } 823 } 824 825 /** 826 * 827 */ 828 public function assign_frame_heights() 829 { 830 // Pre-condition: widths and heights of each column & row must be 831 // calcluated 832 foreach ($this->_frames as $arr) { 833 $frame = $arr["frame"]; 834 835 $h = 0; 836 foreach ($arr["rows"] as $row) { 837 if (!isset($this->_rows[$row])) { 838 // The row has been removed because of a page split, so skip it. 839 continue; 840 } 841 842 $h += $this->_rows[$row]["height"]; 843 } 844 845 if ($frame instanceof TableCellFrameDecorator) { 846 $frame->set_cell_height($h); 847 } else { 848 $frame->get_style()->height = $h; 849 } 850 } 851 } 852 853 /** 854 * Re-adjust frame height if the table height is larger than its content 855 */ 856 public function set_frame_heights($table_height, $content_height) 857 { 858 // Distribute the increased height proportionally amongst each row 859 foreach ($this->_frames as $arr) { 860 $frame = $arr["frame"]; 861 862 $h = 0; 863 foreach ($arr["rows"] as $row) { 864 if (!isset($this->_rows[$row])) { 865 continue; 866 } 867 868 $h += $this->_rows[$row]["height"]; 869 } 870 871 if ($content_height > 0) { 872 $new_height = ($h / $content_height) * $table_height; 873 } else { 874 $new_height = 0; 875 } 876 877 if ($frame instanceof TableCellFrameDecorator) { 878 $frame->set_cell_height($new_height); 879 } else { 880 $frame->get_style()->height = $new_height; 881 } 882 } 883 } 884 885 /** 886 * Used for debugging: 887 * 888 * @return string 889 */ 890 public function __toString() 891 { 892 $str = ""; 893 $str .= "Columns:<br/>"; 894 $str .= Helpers::pre_r($this->_columns, true); 895 $str .= "Rows:<br/>"; 896 $str .= Helpers::pre_r($this->_rows, true); 897 898 $str .= "Frames:<br/>"; 899 $arr = array(); 900 foreach ($this->_frames as $key => $val) { 901 $arr[$key] = array("columns" => $val["columns"], "rows" => $val["rows"]); 902 } 903 904 $str .= Helpers::pre_r($arr, true); 905 906 if (php_sapi_name() == "cli") { 907 $str = strip_tags(str_replace(array("<br/>", "<b>", "</b>"), 908 array("\n", chr(27) . "[01;33m", chr(27) . "[0m"), 909 $str)); 910 } 911 912 return $str; 913 } 914}