Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403] [Versions 402 and 403]

   1  <?php
   2  
   3  namespace PhpOffice\PhpSpreadsheet\Writer;
   4  
   5  use HTMLPurifier;
   6  use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
   7  use PhpOffice\PhpSpreadsheet\Cell\Cell;
   8  use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
   9  use PhpOffice\PhpSpreadsheet\Chart\Chart;
  10  use PhpOffice\PhpSpreadsheet\Document\Properties;
  11  use PhpOffice\PhpSpreadsheet\RichText\RichText;
  12  use PhpOffice\PhpSpreadsheet\RichText\Run;
  13  use PhpOffice\PhpSpreadsheet\Settings;
  14  use PhpOffice\PhpSpreadsheet\Shared\Date;
  15  use PhpOffice\PhpSpreadsheet\Shared\Drawing as SharedDrawing;
  16  use PhpOffice\PhpSpreadsheet\Shared\File;
  17  use PhpOffice\PhpSpreadsheet\Shared\Font as SharedFont;
  18  use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
  19  use PhpOffice\PhpSpreadsheet\Spreadsheet;
  20  use PhpOffice\PhpSpreadsheet\Style\Alignment;
  21  use PhpOffice\PhpSpreadsheet\Style\Border;
  22  use PhpOffice\PhpSpreadsheet\Style\Borders;
  23  use PhpOffice\PhpSpreadsheet\Style\Fill;
  24  use PhpOffice\PhpSpreadsheet\Style\Font;
  25  use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
  26  use PhpOffice\PhpSpreadsheet\Style\Style;
  27  use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
  28  use PhpOffice\PhpSpreadsheet\Worksheet\MemoryDrawing;
  29  use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
  30  use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
  31  
  32  class Html extends BaseWriter
  33  {
  34      /**
  35       * Spreadsheet object.
  36       *
  37       * @var Spreadsheet
  38       */
  39      protected $spreadsheet;
  40  
  41      /**
  42       * Sheet index to write.
  43       *
  44       * @var null|int
  45       */
  46      private $sheetIndex = 0;
  47  
  48      /**
  49       * Images root.
  50       *
  51       * @var string
  52       */
  53      private $imagesRoot = '';
  54  
  55      /**
  56       * embed images, or link to images.
  57       *
  58       * @var bool
  59       */
  60      protected $embedImages = false;
  61  
  62      /**
  63       * Use inline CSS?
  64       *
  65       * @var bool
  66       */
  67      private $useInlineCss = false;
  68  
  69      /**
  70       * Use embedded CSS?
  71       *
  72       * @var bool
  73       */
  74      private $useEmbeddedCSS = true;
  75  
  76      /**
  77       * Array of CSS styles.
  78       *
  79       * @var array
  80       */
  81      private $cssStyles;
  82  
  83      /**
  84       * Array of column widths in points.
  85       *
  86       * @var array
  87       */
  88      private $columnWidths;
  89  
  90      /**
  91       * Default font.
  92       *
  93       * @var Font
  94       */
  95      private $defaultFont;
  96  
  97      /**
  98       * Flag whether spans have been calculated.
  99       *
 100       * @var bool
 101       */
 102      private $spansAreCalculated = false;
 103  
 104      /**
 105       * Excel cells that should not be written as HTML cells.
 106       *
 107       * @var array
 108       */
 109      private $isSpannedCell = [];
 110  
 111      /**
 112       * Excel cells that are upper-left corner in a cell merge.
 113       *
 114       * @var array
 115       */
 116      private $isBaseCell = [];
 117  
 118      /**
 119       * Excel rows that should not be written as HTML rows.
 120       *
 121       * @var array
 122       */
 123      private $isSpannedRow = [];
 124  
 125      /**
 126       * Is the current writer creating PDF?
 127       *
 128       * @var bool
 129       */
 130      protected $isPdf = false;
 131  
 132      /**
 133       * Is the current writer creating mPDF?
 134       *
 135       * @var bool
 136       */
 137      protected $isMPdf = false;
 138  
 139      /**
 140       * Generate the Navigation block.
 141       *
 142       * @var bool
 143       */
 144      private $generateSheetNavigationBlock = true;
 145  
 146      /**
 147       * Callback for editing generated html.
 148       *
 149       * @var null|callable
 150       */
 151      private $editHtmlCallback;
 152  
 153      /**
 154       * Create a new HTML.
 155       */
 156      public function __construct(Spreadsheet $spreadsheet)
 157      {
 158          $this->spreadsheet = $spreadsheet;
 159          $this->defaultFont = $this->spreadsheet->getDefaultStyle()->getFont();
 160      }
 161  
 162      /**
 163       * Save Spreadsheet to file.
 164       *
 165       * @param resource|string $filename
 166       */
 167      public function save($filename, int $flags = 0): void
 168      {
 169          $this->processFlags($flags);
 170  
 171          // Open file
 172          $this->openFileHandle($filename);
 173  
 174          // Write html
 175          fwrite($this->fileHandle, $this->generateHTMLAll());
 176  
 177          // Close file
 178          $this->maybeCloseFileHandle();
 179      }
 180  
 181      /**
 182       * Save Spreadsheet as html to variable.
 183       *
 184       * @return string
 185       */
 186      public function generateHtmlAll()
 187      {
 188          // garbage collect
 189          $this->spreadsheet->garbageCollect();
 190  
 191          $saveDebugLog = Calculation::getInstance($this->spreadsheet)->getDebugLog()->getWriteDebugLog();
 192          Calculation::getInstance($this->spreadsheet)->getDebugLog()->setWriteDebugLog(false);
 193          $saveArrayReturnType = Calculation::getArrayReturnType();
 194          Calculation::setArrayReturnType(Calculation::RETURN_ARRAY_AS_VALUE);
 195  
 196          // Build CSS
 197          $this->buildCSS(!$this->useInlineCss);
 198  
 199          $html = '';
 200  
 201          // Write headers
 202          $html .= $this->generateHTMLHeader(!$this->useInlineCss);
 203  
 204          // Write navigation (tabs)
 205          if ((!$this->isPdf) && ($this->generateSheetNavigationBlock)) {
 206              $html .= $this->generateNavigation();
 207          }
 208  
 209          // Write data
 210          $html .= $this->generateSheetData();
 211  
 212          // Write footer
 213          $html .= $this->generateHTMLFooter();
 214          $callback = $this->editHtmlCallback;
 215          if ($callback) {
 216              $html = $callback($html);
 217          }
 218  
 219          Calculation::setArrayReturnType($saveArrayReturnType);
 220          Calculation::getInstance($this->spreadsheet)->getDebugLog()->setWriteDebugLog($saveDebugLog);
 221  
 222          return $html;
 223      }
 224  
 225      /**
 226       * Set a callback to edit the entire HTML.
 227       *
 228       * The callback must accept the HTML as string as first parameter,
 229       * and it must return the edited HTML as string.
 230       */
 231      public function setEditHtmlCallback(?callable $callback): void
 232      {
 233          $this->editHtmlCallback = $callback;
 234      }
 235  
 236      /**
 237       * Map VAlign.
 238       *
 239       * @param string $vAlign Vertical alignment
 240       *
 241       * @return string
 242       */
 243      private function mapVAlign($vAlign)
 244      {
 245          return Alignment::VERTICAL_ALIGNMENT_FOR_HTML[$vAlign] ?? '';
 246      }
 247  
 248      /**
 249       * Map HAlign.
 250       *
 251       * @param string $hAlign Horizontal alignment
 252       *
 253       * @return string
 254       */
 255      private function mapHAlign($hAlign)
 256      {
 257          return Alignment::HORIZONTAL_ALIGNMENT_FOR_HTML[$hAlign] ?? '';
 258      }
 259  
 260      const BORDER_ARR = [
 261          Border::BORDER_NONE => 'none',
 262          Border::BORDER_DASHDOT => '1px dashed',
 263          Border::BORDER_DASHDOTDOT => '1px dotted',
 264          Border::BORDER_DASHED => '1px dashed',
 265          Border::BORDER_DOTTED => '1px dotted',
 266          Border::BORDER_DOUBLE => '3px double',
 267          Border::BORDER_HAIR => '1px solid',
 268          Border::BORDER_MEDIUM => '2px solid',
 269          Border::BORDER_MEDIUMDASHDOT => '2px dashed',
 270          Border::BORDER_MEDIUMDASHDOTDOT => '2px dotted',
 271          Border::BORDER_SLANTDASHDOT => '2px dashed',
 272          Border::BORDER_THICK => '3px solid',
 273      ];
 274  
 275      /**
 276       * Map border style.
 277       *
 278       * @param int|string $borderStyle Sheet index
 279       *
 280       * @return string
 281       */
 282      private function mapBorderStyle($borderStyle)
 283      {
 284          return array_key_exists($borderStyle, self::BORDER_ARR) ? self::BORDER_ARR[$borderStyle] : '1px solid';
 285      }
 286  
 287      /**
 288       * Get sheet index.
 289       */
 290      public function getSheetIndex(): ?int
 291      {
 292          return $this->sheetIndex;
 293      }
 294  
 295      /**
 296       * Set sheet index.
 297       *
 298       * @param int $sheetIndex Sheet index
 299       *
 300       * @return $this
 301       */
 302      public function setSheetIndex($sheetIndex)
 303      {
 304          $this->sheetIndex = $sheetIndex;
 305  
 306          return $this;
 307      }
 308  
 309      /**
 310       * Get sheet index.
 311       *
 312       * @return bool
 313       */
 314      public function getGenerateSheetNavigationBlock()
 315      {
 316          return $this->generateSheetNavigationBlock;
 317      }
 318  
 319      /**
 320       * Set sheet index.
 321       *
 322       * @param bool $generateSheetNavigationBlock Flag indicating whether the sheet navigation block should be generated or not
 323       *
 324       * @return $this
 325       */
 326      public function setGenerateSheetNavigationBlock($generateSheetNavigationBlock)
 327      {
 328          $this->generateSheetNavigationBlock = (bool) $generateSheetNavigationBlock;
 329  
 330          return $this;
 331      }
 332  
 333      /**
 334       * Write all sheets (resets sheetIndex to NULL).
 335       *
 336       * @return $this
 337       */
 338      public function writeAllSheets()
 339      {
 340          $this->sheetIndex = null;
 341  
 342          return $this;
 343      }
 344  
 345      private static function generateMeta(?string $val, string $desc): string
 346      {
 347          return ($val || $val === '0')
 348              ? ('      <meta name="' . $desc . '" content="' . htmlspecialchars($val, Settings::htmlEntityFlags()) . '" />' . PHP_EOL)
 349              : '';
 350      }
 351  
 352      public const BODY_LINE = '  <body>' . PHP_EOL;
 353  
 354      private const CUSTOM_TO_META = [
 355          Properties::PROPERTY_TYPE_BOOLEAN => 'bool',
 356          Properties::PROPERTY_TYPE_DATE => 'date',
 357          Properties::PROPERTY_TYPE_FLOAT => 'float',
 358          Properties::PROPERTY_TYPE_INTEGER => 'int',
 359          Properties::PROPERTY_TYPE_STRING => 'string',
 360      ];
 361  
 362      /**
 363       * Generate HTML header.
 364       *
 365       * @param bool $includeStyles Include styles?
 366       *
 367       * @return string
 368       */
 369      public function generateHTMLHeader($includeStyles = false)
 370      {
 371          // Construct HTML
 372          $properties = $this->spreadsheet->getProperties();
 373          $html = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">' . PHP_EOL;
 374          $html .= '<html xmlns="http://www.w3.org/1999/xhtml">' . PHP_EOL;
 375          $html .= '  <head>' . PHP_EOL;
 376          $html .= '      <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />' . PHP_EOL;
 377          $html .= '      <meta name="generator" content="PhpSpreadsheet, https://github.com/PHPOffice/PhpSpreadsheet" />' . PHP_EOL;
 378          $html .= '      <title>' . htmlspecialchars($properties->getTitle(), Settings::htmlEntityFlags()) . '</title>' . PHP_EOL;
 379          $html .= self::generateMeta($properties->getCreator(), 'author');
 380          $html .= self::generateMeta($properties->getTitle(), 'title');
 381          $html .= self::generateMeta($properties->getDescription(), 'description');
 382          $html .= self::generateMeta($properties->getSubject(), 'subject');
 383          $html .= self::generateMeta($properties->getKeywords(), 'keywords');
 384          $html .= self::generateMeta($properties->getCategory(), 'category');
 385          $html .= self::generateMeta($properties->getCompany(), 'company');
 386          $html .= self::generateMeta($properties->getManager(), 'manager');
 387          $html .= self::generateMeta($properties->getLastModifiedBy(), 'lastModifiedBy');
 388          $date = Date::dateTimeFromTimestamp((string) $properties->getCreated());
 389          $date->setTimeZone(Date::getDefaultOrLocalTimeZone());
 390          $html .= self::generateMeta($date->format(DATE_W3C), 'created');
 391          $date = Date::dateTimeFromTimestamp((string) $properties->getModified());
 392          $date->setTimeZone(Date::getDefaultOrLocalTimeZone());
 393          $html .= self::generateMeta($date->format(DATE_W3C), 'modified');
 394  
 395          $customProperties = $properties->getCustomProperties();
 396          foreach ($customProperties as $customProperty) {
 397              $propertyValue = $properties->getCustomPropertyValue($customProperty);
 398              $propertyType = $properties->getCustomPropertyType($customProperty);
 399              $propertyQualifier = self::CUSTOM_TO_META[$propertyType] ?? null;
 400              if ($propertyQualifier !== null) {
 401                  if ($propertyType === Properties::PROPERTY_TYPE_BOOLEAN) {
 402                      $propertyValue = $propertyValue ? '1' : '0';
 403                  } elseif ($propertyType === Properties::PROPERTY_TYPE_DATE) {
 404                      $date = Date::dateTimeFromTimestamp((string) $propertyValue);
 405                      $date->setTimeZone(Date::getDefaultOrLocalTimeZone());
 406                      $propertyValue = $date->format(DATE_W3C);
 407                  } else {
 408                      $propertyValue = (string) $propertyValue;
 409                  }
 410                  $html .= self::generateMeta($propertyValue, "custom.$propertyQualifier.$customProperty");
 411              }
 412          }
 413  
 414          if (!empty($properties->getHyperlinkBase())) {
 415              $html .= '      <base href="' . $properties->getHyperlinkBase() . '" />' . PHP_EOL;
 416          }
 417  
 418          $html .= $includeStyles ? $this->generateStyles(true) : $this->generatePageDeclarations(true);
 419  
 420          $html .= '  </head>' . PHP_EOL;
 421          $html .= '' . PHP_EOL;
 422          $html .= self::BODY_LINE;
 423  
 424          return $html;
 425      }
 426  
 427      private function generateSheetPrep(): array
 428      {
 429          // Ensure that Spans have been calculated?
 430          $this->calculateSpans();
 431  
 432          // Fetch sheets
 433          if ($this->sheetIndex === null) {
 434              $sheets = $this->spreadsheet->getAllSheets();
 435          } else {
 436              $sheets = [$this->spreadsheet->getSheet($this->sheetIndex)];
 437          }
 438  
 439          return $sheets;
 440      }
 441  
 442      private function generateSheetStarts(Worksheet $sheet, int $rowMin): array
 443      {
 444          // calculate start of <tbody>, <thead>
 445          $tbodyStart = $rowMin;
 446          $theadStart = $theadEnd = 0; // default: no <thead>    no </thead>
 447          if ($sheet->getPageSetup()->isRowsToRepeatAtTopSet()) {
 448              $rowsToRepeatAtTop = $sheet->getPageSetup()->getRowsToRepeatAtTop();
 449  
 450              // we can only support repeating rows that start at top row
 451              if ($rowsToRepeatAtTop[0] == 1) {
 452                  $theadStart = $rowsToRepeatAtTop[0];
 453                  $theadEnd = $rowsToRepeatAtTop[1];
 454                  $tbodyStart = $rowsToRepeatAtTop[1] + 1;
 455              }
 456          }
 457  
 458          return [$theadStart, $theadEnd, $tbodyStart];
 459      }
 460  
 461      private function generateSheetTags(int $row, int $theadStart, int $theadEnd, int $tbodyStart): array
 462      {
 463          // <thead> ?
 464          $startTag = ($row == $theadStart) ? ('        <thead>' . PHP_EOL) : '';
 465          if (!$startTag) {
 466              $startTag = ($row == $tbodyStart) ? ('        <tbody>' . PHP_EOL) : '';
 467          }
 468          $endTag = ($row == $theadEnd) ? ('        </thead>' . PHP_EOL) : '';
 469          $cellType = ($row >= $tbodyStart) ? 'td' : 'th';
 470  
 471          return [$cellType, $startTag, $endTag];
 472      }
 473  
 474      /**
 475       * Generate sheet data.
 476       *
 477       * @return string
 478       */
 479      public function generateSheetData()
 480      {
 481          $sheets = $this->generateSheetPrep();
 482  
 483          // Construct HTML
 484          $html = '';
 485  
 486          // Loop all sheets
 487          $sheetId = 0;
 488          foreach ($sheets as $sheet) {
 489              // Write table header
 490              $html .= $this->generateTableHeader($sheet);
 491  
 492              // Get worksheet dimension
 493              [$min, $max] = explode(':', $sheet->calculateWorksheetDataDimension());
 494              [$minCol, $minRow] = Coordinate::indexesFromString($min);
 495              [$maxCol, $maxRow] = Coordinate::indexesFromString($max);
 496  
 497              [$theadStart, $theadEnd, $tbodyStart] = $this->generateSheetStarts($sheet, $minRow);
 498  
 499              // Loop through cells
 500              $row = $minRow - 1;
 501              while ($row++ < $maxRow) {
 502                  [$cellType, $startTag, $endTag] = $this->generateSheetTags($row, $theadStart, $theadEnd, $tbodyStart);
 503                  $html .= $startTag;
 504  
 505                  // Write row if there are HTML table cells in it
 506                  if (!isset($this->isSpannedRow[$sheet->getParent()->getIndex($sheet)][$row])) {
 507                      // Start a new rowData
 508                      $rowData = [];
 509                      // Loop through columns
 510                      $column = $minCol;
 511                      while ($column <= $maxCol) {
 512                          // Cell exists?
 513                          $cellAddress = Coordinate::stringFromColumnIndex($column) . $row;
 514                          $rowData[$column++] = ($sheet->getCellCollection()->has($cellAddress)) ? $cellAddress : '';
 515                      }
 516                      $html .= $this->generateRow($sheet, $rowData, $row - 1, $cellType);
 517                  }
 518  
 519                  $html .= $endTag;
 520              }
 521              --$row;
 522              $html .= $this->extendRowsForChartsAndImages($sheet, $row);
 523  
 524              // Write table footer
 525              $html .= $this->generateTableFooter();
 526              // Writing PDF?
 527              if ($this->isPdf && $this->useInlineCss) {
 528                  if ($this->sheetIndex === null && $sheetId + 1 < $this->spreadsheet->getSheetCount()) {
 529                      $html .= '<div style="page-break-before:always" ></div>';
 530                  }
 531              }
 532  
 533              // Next sheet
 534              ++$sheetId;
 535          }
 536  
 537          return $html;
 538      }
 539  
 540      /**
 541       * Generate sheet tabs.
 542       *
 543       * @return string
 544       */
 545      public function generateNavigation()
 546      {
 547          // Fetch sheets
 548          $sheets = [];
 549          if ($this->sheetIndex === null) {
 550              $sheets = $this->spreadsheet->getAllSheets();
 551          } else {
 552              $sheets[] = $this->spreadsheet->getSheet($this->sheetIndex);
 553          }
 554  
 555          // Construct HTML
 556          $html = '';
 557  
 558          // Only if there are more than 1 sheets
 559          if (count($sheets) > 1) {
 560              // Loop all sheets
 561              $sheetId = 0;
 562  
 563              $html .= '<ul class="navigation">' . PHP_EOL;
 564  
 565              foreach ($sheets as $sheet) {
 566                  $html .= '  <li class="sheet' . $sheetId . '"><a href="#sheet' . $sheetId . '">' . $sheet->getTitle() . '</a></li>' . PHP_EOL;
 567                  ++$sheetId;
 568              }
 569  
 570              $html .= '</ul>' . PHP_EOL;
 571          }
 572  
 573          return $html;
 574      }
 575  
 576      /**
 577       * Extend Row if chart is placed after nominal end of row.
 578       * This code should be exercised by sample:
 579       * Chart/32_Chart_read_write_PDF.php.
 580       *
 581       * @param int $row Row to check for charts
 582       *
 583       * @return array
 584       */
 585      private function extendRowsForCharts(Worksheet $worksheet, int $row)
 586      {
 587          $rowMax = $row;
 588          $colMax = 'A';
 589          $anyfound = false;
 590          if ($this->includeCharts) {
 591              foreach ($worksheet->getChartCollection() as $chart) {
 592                  if ($chart instanceof Chart) {
 593                      $anyfound = true;
 594                      $chartCoordinates = $chart->getTopLeftPosition();
 595                      $chartTL = Coordinate::coordinateFromString($chartCoordinates['cell']);
 596                      $chartCol = Coordinate::columnIndexFromString($chartTL[0]);
 597                      if ($chartTL[1] > $rowMax) {
 598                          $rowMax = $chartTL[1];
 599                          if ($chartCol > Coordinate::columnIndexFromString($colMax)) {
 600                              $colMax = $chartTL[0];
 601                          }
 602                      }
 603                  }
 604              }
 605          }
 606  
 607          return [$rowMax, $colMax, $anyfound];
 608      }
 609  
 610      private function extendRowsForChartsAndImages(Worksheet $worksheet, int $row): string
 611      {
 612          [$rowMax, $colMax, $anyfound] = $this->extendRowsForCharts($worksheet, $row);
 613  
 614          foreach ($worksheet->getDrawingCollection() as $drawing) {
 615              $anyfound = true;
 616              $imageTL = Coordinate::coordinateFromString($drawing->getCoordinates());
 617              $imageCol = Coordinate::columnIndexFromString($imageTL[0]);
 618              if ($imageTL[1] > $rowMax) {
 619                  $rowMax = $imageTL[1];
 620                  if ($imageCol > Coordinate::columnIndexFromString($colMax)) {
 621                      $colMax = $imageTL[0];
 622                  }
 623              }
 624          }
 625  
 626          // Don't extend rows if not needed
 627          if ($row === $rowMax || !$anyfound) {
 628              return '';
 629          }
 630  
 631          $html = '';
 632          ++$colMax;
 633          ++$row;
 634          while ($row <= $rowMax) {
 635              $html .= '<tr>';
 636              for ($col = 'A'; $col != $colMax; ++$col) {
 637                  $htmlx = $this->writeImageInCell($worksheet, $col . $row);
 638                  $htmlx .= $this->includeCharts ? $this->writeChartInCell($worksheet, $col . $row) : '';
 639                  if ($htmlx) {
 640                      $html .= "<td class='style0' style='position: relative;'>$htmlx</td>";
 641                  } else {
 642                      $html .= "<td class='style0'></td>";
 643                  }
 644              }
 645              ++$row;
 646              $html .= '</tr>' . PHP_EOL;
 647          }
 648  
 649          return $html;
 650      }
 651  
 652      /**
 653       * Convert Windows file name to file protocol URL.
 654       *
 655       * @param string $filename file name on local system
 656       *
 657       * @return string
 658       */
 659      public static function winFileToUrl($filename, bool $mpdf = false)
 660      {
 661          // Windows filename
 662          if (substr($filename, 1, 2) === ':\\') {
 663              $protocol = $mpdf ? '' : 'file:///';
 664              $filename = $protocol . str_replace('\\', '/', $filename);
 665          }
 666  
 667          return $filename;
 668      }
 669  
 670      /**
 671       * Generate image tag in cell.
 672       *
 673       * @param Worksheet $worksheet \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet
 674       * @param string $coordinates Cell coordinates
 675       *
 676       * @return string
 677       */
 678      private function writeImageInCell(Worksheet $worksheet, $coordinates)
 679      {
 680          // Construct HTML
 681          $html = '';
 682  
 683          // Write images
 684          foreach ($worksheet->getDrawingCollection() as $drawing) {
 685              if ($drawing->getCoordinates() != $coordinates) {
 686                  continue;
 687              }
 688              $filedesc = $drawing->getDescription();
 689              $filedesc = $filedesc ? htmlspecialchars($filedesc, ENT_QUOTES) : 'Embedded image';
 690              if ($drawing instanceof Drawing) {
 691                  $filename = $drawing->getPath();
 692  
 693                  // Strip off eventual '.'
 694                  $filename = (string) preg_replace('/^[.]/', '', $filename);
 695  
 696                  // Prepend images root
 697                  $filename = $this->getImagesRoot() . $filename;
 698  
 699                  // Strip off eventual '.' if followed by non-/
 700                  $filename = (string) preg_replace('@^[.]([^/])@', '$1', $filename);
 701  
 702                  // Convert UTF8 data to PCDATA
 703                  $filename = htmlspecialchars($filename, Settings::htmlEntityFlags());
 704  
 705                  $html .= PHP_EOL;
 706                  $imageData = self::winFileToUrl($filename, $this->isMPdf);
 707  
 708                  if ($this->embedImages || substr($imageData, 0, 6) === 'zip://') {
 709                      $picture = @file_get_contents($filename);
 710                      if ($picture !== false) {
 711                          $imageDetails = getimagesize($filename) ?: [];
 712                          // base64 encode the binary data
 713                          $base64 = base64_encode($picture);
 714                          $imageData = 'data:' . $imageDetails['mime'] . ';base64,' . $base64;
 715                      }
 716                  }
 717  
 718                  $html .= '<img style="position: absolute; z-index: 1; left: ' .
 719                      $drawing->getOffsetX() . 'px; top: ' . $drawing->getOffsetY() . 'px; width: ' .
 720                      $drawing->getWidth() . 'px; height: ' . $drawing->getHeight() . 'px;" src="' .
 721                      $imageData . '" alt="' . $filedesc . '" />';
 722              } elseif ($drawing instanceof MemoryDrawing) {
 723                  $imageResource = $drawing->getImageResource();
 724                  if ($imageResource) {
 725                      ob_start(); //  Let's start output buffering.
 726                      imagepng($imageResource); //  This will normally output the image, but because of ob_start(), it won't.
 727                      $contents = (string) ob_get_contents(); //  Instead, output above is saved to $contents
 728                      ob_end_clean(); //  End the output buffer.
 729  
 730                      $dataUri = 'data:image/png;base64,' . base64_encode($contents);
 731  
 732                      //  Because of the nature of tables, width is more important than height.
 733                      //  max-width: 100% ensures that image doesnt overflow containing cell
 734                      //  width: X sets width of supplied image.
 735                      //  As a result, images bigger than cell will be contained and images smaller will not get stretched
 736                      $html .= '<img alt="' . $filedesc . '" src="' . $dataUri . '" style="max-width:100%;width:' . $drawing->getWidth() . 'px;left: ' .
 737                      $drawing->getOffsetX() . 'px; top: ' . $drawing->getOffsetY() . 'px;position: absolute; z-index: 1;" />';
 738                  }
 739              }
 740          }
 741  
 742          return $html;
 743      }
 744  
 745      /**
 746       * Generate chart tag in cell.
 747       * This code should be exercised by sample:
 748       * Chart/32_Chart_read_write_PDF.php.
 749       */
 750      private function writeChartInCell(Worksheet $worksheet, string $coordinates): string
 751      {
 752          // Construct HTML
 753          $html = '';
 754  
 755          // Write charts
 756          foreach ($worksheet->getChartCollection() as $chart) {
 757              if ($chart instanceof Chart) {
 758                  $chartCoordinates = $chart->getTopLeftPosition();
 759                  if ($chartCoordinates['cell'] == $coordinates) {
 760                      $chartFileName = File::sysGetTempDir() . '/' . uniqid('', true) . '.png';
 761                      if (!$chart->render($chartFileName)) {
 762                          return '';
 763                      }
 764  
 765                      $html .= PHP_EOL;
 766                      $imageDetails = getimagesize($chartFileName) ?: [];
 767                      $filedesc = $chart->getTitle();
 768                      $filedesc = $filedesc ? $filedesc->getCaptionText() : '';
 769                      $filedesc = $filedesc ? htmlspecialchars($filedesc, ENT_QUOTES) : 'Embedded chart';
 770                      $picture = file_get_contents($chartFileName);
 771                      if ($picture !== false) {
 772                          $base64 = base64_encode($picture);
 773                          $imageData = 'data:' . $imageDetails['mime'] . ';base64,' . $base64;
 774  
 775                          $html .= '<img style="position: absolute; z-index: 1; left: ' . $chartCoordinates['xOffset'] . 'px; top: ' . $chartCoordinates['yOffset'] . 'px; width: ' . $imageDetails[0] . 'px; height: ' . $imageDetails[1] . 'px;" src="' . $imageData . '" alt="' . $filedesc . '" />' . PHP_EOL;
 776                      }
 777                      unlink($chartFileName);
 778                  }
 779              }
 780          }
 781  
 782          // Return
 783          return $html;
 784      }
 785  
 786      /**
 787       * Generate CSS styles.
 788       *
 789       * @param bool $generateSurroundingHTML Generate surrounding HTML tags? (&lt;style&gt; and &lt;/style&gt;)
 790       *
 791       * @return string
 792       */
 793      public function generateStyles($generateSurroundingHTML = true)
 794      {
 795          // Build CSS
 796          $css = $this->buildCSS($generateSurroundingHTML);
 797  
 798          // Construct HTML
 799          $html = '';
 800  
 801          // Start styles
 802          if ($generateSurroundingHTML) {
 803              $html .= '    <style type="text/css">' . PHP_EOL;
 804              $html .= (array_key_exists('html', $css)) ? ('      html { ' . $this->assembleCSS($css['html']) . ' }' . PHP_EOL) : '';
 805          }
 806  
 807          // Write all other styles
 808          foreach ($css as $styleName => $styleDefinition) {
 809              if ($styleName != 'html') {
 810                  $html .= '      ' . $styleName . ' { ' . $this->assembleCSS($styleDefinition) . ' }' . PHP_EOL;
 811              }
 812          }
 813          $html .= $this->generatePageDeclarations(false);
 814  
 815          // End styles
 816          if ($generateSurroundingHTML) {
 817              $html .= '    </style>' . PHP_EOL;
 818          }
 819  
 820          // Return
 821          return $html;
 822      }
 823  
 824      private function buildCssRowHeights(Worksheet $sheet, array &$css, int $sheetIndex): void
 825      {
 826          // Calculate row heights
 827          foreach ($sheet->getRowDimensions() as $rowDimension) {
 828              $row = $rowDimension->getRowIndex() - 1;
 829  
 830              // table.sheetN tr.rowYYYYYY { }
 831              $css['table.sheet' . $sheetIndex . ' tr.row' . $row] = [];
 832  
 833              if ($rowDimension->getRowHeight() != -1) {
 834                  $pt_height = $rowDimension->getRowHeight();
 835                  $css['table.sheet' . $sheetIndex . ' tr.row' . $row]['height'] = $pt_height . 'pt';
 836              }
 837              if ($rowDimension->getVisible() === false) {
 838                  $css['table.sheet' . $sheetIndex . ' tr.row' . $row]['display'] = 'none';
 839                  $css['table.sheet' . $sheetIndex . ' tr.row' . $row]['visibility'] = 'hidden';
 840              }
 841          }
 842      }
 843  
 844      private function buildCssPerSheet(Worksheet $sheet, array &$css): void
 845      {
 846          // Calculate hash code
 847          $sheetIndex = $sheet->getParentOrThrow()->getIndex($sheet);
 848          $setup = $sheet->getPageSetup();
 849          if ($setup->getFitToPage() && $setup->getFitToHeight() === 1) {
 850              $css["table.sheet$sheetIndex"]['page-break-inside'] = 'avoid';
 851              $css["table.sheet$sheetIndex"]['break-inside'] = 'avoid';
 852          }
 853  
 854          // Build styles
 855          // Calculate column widths
 856          $sheet->calculateColumnWidths();
 857  
 858          // col elements, initialize
 859          $highestColumnIndex = Coordinate::columnIndexFromString($sheet->getHighestColumn()) - 1;
 860          $column = -1;
 861          while ($column++ < $highestColumnIndex) {
 862              $this->columnWidths[$sheetIndex][$column] = 42; // approximation
 863              $css['table.sheet' . $sheetIndex . ' col.col' . $column]['width'] = '42pt';
 864          }
 865  
 866          // col elements, loop through columnDimensions and set width
 867          foreach ($sheet->getColumnDimensions() as $columnDimension) {
 868              $column = Coordinate::columnIndexFromString($columnDimension->getColumnIndex()) - 1;
 869              $width = SharedDrawing::cellDimensionToPixels($columnDimension->getWidth(), $this->defaultFont);
 870              $width = SharedDrawing::pixelsToPoints($width);
 871              if ($columnDimension->getVisible() === false) {
 872                  $css['table.sheet' . $sheetIndex . ' .column' . $column]['display'] = 'none';
 873              }
 874              if ($width >= 0) {
 875                  $this->columnWidths[$sheetIndex][$column] = $width;
 876                  $css['table.sheet' . $sheetIndex . ' col.col' . $column]['width'] = $width . 'pt';
 877              }
 878          }
 879  
 880          // Default row height
 881          $rowDimension = $sheet->getDefaultRowDimension();
 882  
 883          // table.sheetN tr { }
 884          $css['table.sheet' . $sheetIndex . ' tr'] = [];
 885  
 886          if ($rowDimension->getRowHeight() == -1) {
 887              $pt_height = SharedFont::getDefaultRowHeightByFont($this->spreadsheet->getDefaultStyle()->getFont());
 888          } else {
 889              $pt_height = $rowDimension->getRowHeight();
 890          }
 891          $css['table.sheet' . $sheetIndex . ' tr']['height'] = $pt_height . 'pt';
 892          if ($rowDimension->getVisible() === false) {
 893              $css['table.sheet' . $sheetIndex . ' tr']['display'] = 'none';
 894              $css['table.sheet' . $sheetIndex . ' tr']['visibility'] = 'hidden';
 895          }
 896  
 897          $this->buildCssRowHeights($sheet, $css, $sheetIndex);
 898      }
 899  
 900      /**
 901       * Build CSS styles.
 902       *
 903       * @param bool $generateSurroundingHTML Generate surrounding HTML style? (html { })
 904       *
 905       * @return array
 906       */
 907      public function buildCSS($generateSurroundingHTML = true)
 908      {
 909          // Cached?
 910          if ($this->cssStyles !== null) {
 911              return $this->cssStyles;
 912          }
 913  
 914          // Ensure that spans have been calculated
 915          $this->calculateSpans();
 916  
 917          // Construct CSS
 918          $css = [];
 919  
 920          // Start styles
 921          if ($generateSurroundingHTML) {
 922              // html { }
 923              $css['html']['font-family'] = 'Calibri, Arial, Helvetica, sans-serif';
 924              $css['html']['font-size'] = '11pt';
 925              $css['html']['background-color'] = 'white';
 926          }
 927  
 928          // CSS for comments as found in LibreOffice
 929          $css['a.comment-indicator:hover + div.comment'] = [
 930              'background' => '#ffd',
 931              'position' => 'absolute',
 932              'display' => 'block',
 933              'border' => '1px solid black',
 934              'padding' => '0.5em',
 935          ];
 936  
 937          $css['a.comment-indicator'] = [
 938              'background' => 'red',
 939              'display' => 'inline-block',
 940              'border' => '1px solid black',
 941              'width' => '0.5em',
 942              'height' => '0.5em',
 943          ];
 944  
 945          $css['div.comment']['display'] = 'none';
 946  
 947          // table { }
 948          $css['table']['border-collapse'] = 'collapse';
 949  
 950          // .b {}
 951          $css['.b']['text-align'] = 'center'; // BOOL
 952  
 953          // .e {}
 954          $css['.e']['text-align'] = 'center'; // ERROR
 955  
 956          // .f {}
 957          $css['.f']['text-align'] = 'right'; // FORMULA
 958  
 959          // .inlineStr {}
 960          $css['.inlineStr']['text-align'] = 'left'; // INLINE
 961  
 962          // .n {}
 963          $css['.n']['text-align'] = 'right'; // NUMERIC
 964  
 965          // .s {}
 966          $css['.s']['text-align'] = 'left'; // STRING
 967  
 968          // Calculate cell style hashes
 969          foreach ($this->spreadsheet->getCellXfCollection() as $index => $style) {
 970              $css['td.style' . $index . ', th.style' . $index] = $this->createCSSStyle($style);
 971              //$css['th.style' . $index] = $this->createCSSStyle($style);
 972          }
 973  
 974          // Fetch sheets
 975          $sheets = [];
 976          if ($this->sheetIndex === null) {
 977              $sheets = $this->spreadsheet->getAllSheets();
 978          } else {
 979              $sheets[] = $this->spreadsheet->getSheet($this->sheetIndex);
 980          }
 981  
 982          // Build styles per sheet
 983          foreach ($sheets as $sheet) {
 984              $this->buildCssPerSheet($sheet, $css);
 985          }
 986  
 987          // Cache
 988          if ($this->cssStyles === null) {
 989              $this->cssStyles = $css;
 990          }
 991  
 992          // Return
 993          return $css;
 994      }
 995  
 996      /**
 997       * Create CSS style.
 998       *
 999       * @return array
1000       */
1001      private function createCSSStyle(Style $style)
1002      {
1003          // Create CSS
1004          return array_merge(
1005              $this->createCSSStyleAlignment($style->getAlignment()),
1006              $this->createCSSStyleBorders($style->getBorders()),
1007              $this->createCSSStyleFont($style->getFont()),
1008              $this->createCSSStyleFill($style->getFill())
1009          );
1010      }
1011  
1012      /**
1013       * Create CSS style.
1014       *
1015       * @return array
1016       */
1017      private function createCSSStyleAlignment(Alignment $alignment)
1018      {
1019          // Construct CSS
1020          $css = [];
1021  
1022          // Create CSS
1023          $verticalAlign = $this->mapVAlign($alignment->getVertical() ?? '');
1024          if ($verticalAlign) {
1025              $css['vertical-align'] = $verticalAlign;
1026          }
1027          $textAlign = $this->mapHAlign($alignment->getHorizontal() ?? '');
1028          if ($textAlign) {
1029              $css['text-align'] = $textAlign;
1030              if (in_array($textAlign, ['left', 'right'])) {
1031                  $css['padding-' . $textAlign] = (string) ((int) $alignment->getIndent() * 9) . 'px';
1032              }
1033          }
1034          $rotation = $alignment->getTextRotation();
1035          if ($rotation !== 0 && $rotation !== Alignment::TEXTROTATION_STACK_PHPSPREADSHEET) {
1036              if ($this->isMPdf) {
1037                  $css['text-rotate'] = "$rotation";
1038              } else {
1039                  $css['transform'] = "rotate({$rotation}deg)";
1040              }
1041          }
1042  
1043          return $css;
1044      }
1045  
1046      /**
1047       * Create CSS style.
1048       *
1049       * @return array
1050       */
1051      private function createCSSStyleFont(Font $font)
1052      {
1053          // Construct CSS
1054          $css = [];
1055  
1056          // Create CSS
1057          if ($font->getBold()) {
1058              $css['font-weight'] = 'bold';
1059          }
1060          if ($font->getUnderline() != Font::UNDERLINE_NONE && $font->getStrikethrough()) {
1061              $css['text-decoration'] = 'underline line-through';
1062          } elseif ($font->getUnderline() != Font::UNDERLINE_NONE) {
1063              $css['text-decoration'] = 'underline';
1064          } elseif ($font->getStrikethrough()) {
1065              $css['text-decoration'] = 'line-through';
1066          }
1067          if ($font->getItalic()) {
1068              $css['font-style'] = 'italic';
1069          }
1070  
1071          $css['color'] = '#' . $font->getColor()->getRGB();
1072          $css['font-family'] = '\'' . $font->getName() . '\'';
1073          $css['font-size'] = $font->getSize() . 'pt';
1074  
1075          return $css;
1076      }
1077  
1078      /**
1079       * Create CSS style.
1080       *
1081       * @param Borders $borders Borders
1082       *
1083       * @return array
1084       */
1085      private function createCSSStyleBorders(Borders $borders)
1086      {
1087          // Construct CSS
1088          $css = [];
1089  
1090          // Create CSS
1091          $css['border-bottom'] = $this->createCSSStyleBorder($borders->getBottom());
1092          $css['border-top'] = $this->createCSSStyleBorder($borders->getTop());
1093          $css['border-left'] = $this->createCSSStyleBorder($borders->getLeft());
1094          $css['border-right'] = $this->createCSSStyleBorder($borders->getRight());
1095  
1096          return $css;
1097      }
1098  
1099      /**
1100       * Create CSS style.
1101       *
1102       * @param Border $border Border
1103       */
1104      private function createCSSStyleBorder(Border $border): string
1105      {
1106          //    Create CSS - add !important to non-none border styles for merged cells
1107          $borderStyle = $this->mapBorderStyle($border->getBorderStyle());
1108  
1109          return $borderStyle . ' #' . $border->getColor()->getRGB() . (($borderStyle == 'none') ? '' : ' !important');
1110      }
1111  
1112      /**
1113       * Create CSS style (Fill).
1114       *
1115       * @param Fill $fill Fill
1116       *
1117       * @return array
1118       */
1119      private function createCSSStyleFill(Fill $fill)
1120      {
1121          // Construct HTML
1122          $css = [];
1123  
1124          // Create CSS
1125          if ($fill->getFillType() !== Fill::FILL_NONE) {
1126              $value = $fill->getFillType() == Fill::FILL_NONE ?
1127                  'white' : '#' . $fill->getStartColor()->getRGB();
1128              $css['background-color'] = $value;
1129          }
1130  
1131          return $css;
1132      }
1133  
1134      /**
1135       * Generate HTML footer.
1136       */
1137      public function generateHTMLFooter(): string
1138      {
1139          // Construct HTML
1140          $html = '';
1141          $html .= '  </body>' . PHP_EOL;
1142          $html .= '</html>' . PHP_EOL;
1143  
1144          return $html;
1145      }
1146  
1147      private function generateTableTagInline(Worksheet $worksheet, string $id): string
1148      {
1149          $style = isset($this->cssStyles['table']) ?
1150              $this->assembleCSS($this->cssStyles['table']) : '';
1151  
1152          $prntgrid = $worksheet->getPrintGridlines();
1153          $viewgrid = $this->isPdf ? $prntgrid : $worksheet->getShowGridlines();
1154          if ($viewgrid && $prntgrid) {
1155              $html = "    <table border='1' cellpadding='1' $id cellspacing='1' style='$style' class='gridlines gridlinesp'>" . PHP_EOL;
1156          } elseif ($viewgrid) {
1157              $html = "    <table border='0' cellpadding='0' $id cellspacing='0' style='$style' class='gridlines'>" . PHP_EOL;
1158          } elseif ($prntgrid) {
1159              $html = "    <table border='0' cellpadding='0' $id cellspacing='0' style='$style' class='gridlinesp'>" . PHP_EOL;
1160          } else {
1161              $html = "    <table border='0' cellpadding='1' $id cellspacing='0' style='$style'>" . PHP_EOL;
1162          }
1163  
1164          return $html;
1165      }
1166  
1167      private function generateTableTag(Worksheet $worksheet, string $id, string &$html, int $sheetIndex): void
1168      {
1169          if (!$this->useInlineCss) {
1170              $gridlines = $worksheet->getShowGridlines() ? ' gridlines' : '';
1171              $gridlinesp = $worksheet->getPrintGridlines() ? ' gridlinesp' : '';
1172              $html .= "    <table border='0' cellpadding='0' cellspacing='0' $id class='sheet$sheetIndex$gridlines$gridlinesp'>" . PHP_EOL;
1173          } else {
1174              $html .= $this->generateTableTagInline($worksheet, $id);
1175          }
1176      }
1177  
1178      /**
1179       * Generate table header.
1180       *
1181       * @param Worksheet $worksheet The worksheet for the table we are writing
1182       * @param bool $showid whether or not to add id to table tag
1183       *
1184       * @return string
1185       */
1186      private function generateTableHeader(Worksheet $worksheet, $showid = true)
1187      {
1188          $sheetIndex = $worksheet->getParentOrThrow()->getIndex($worksheet);
1189  
1190          // Construct HTML
1191          $html = '';
1192          $id = $showid ? "id='sheet$sheetIndex'" : '';
1193          if ($showid) {
1194              $html .= "<div style='page: page$sheetIndex'>" . PHP_EOL;
1195          } else {
1196              $html .= "<div style='page: page$sheetIndex' class='scrpgbrk'>" . PHP_EOL;
1197          }
1198  
1199          $this->generateTableTag($worksheet, $id, $html, $sheetIndex);
1200  
1201          // Write <col> elements
1202          $highestColumnIndex = Coordinate::columnIndexFromString($worksheet->getHighestColumn()) - 1;
1203          $i = -1;
1204          while ($i++ < $highestColumnIndex) {
1205              if (!$this->useInlineCss) {
1206                  $html .= '        <col class="col' . $i . '" />' . PHP_EOL;
1207              } else {
1208                  $style = isset($this->cssStyles['table.sheet' . $sheetIndex . ' col.col' . $i]) ?
1209                      $this->assembleCSS($this->cssStyles['table.sheet' . $sheetIndex . ' col.col' . $i]) : '';
1210                  $html .= '        <col style="' . $style . '" />' . PHP_EOL;
1211              }
1212          }
1213  
1214          return $html;
1215      }
1216  
1217      /**
1218       * Generate table footer.
1219       */
1220      private function generateTableFooter(): string
1221      {
1222          return '    </tbody></table>' . PHP_EOL . '</div>' . PHP_EOL;
1223      }
1224  
1225      /**
1226       * Generate row start.
1227       *
1228       * @param int $sheetIndex Sheet index (0-based)
1229       * @param int $row row number
1230       *
1231       * @return string
1232       */
1233      private function generateRowStart(Worksheet $worksheet, $sheetIndex, $row)
1234      {
1235          $html = '';
1236          if (count($worksheet->getBreaks()) > 0) {
1237              $breaks = $worksheet->getRowBreaks();
1238  
1239              // check if a break is needed before this row
1240              if (isset($breaks['A' . $row])) {
1241                  // close table: </table>
1242                  $html .= $this->generateTableFooter();
1243                  if ($this->isPdf && $this->useInlineCss) {
1244                      $html .= '<div style="page-break-before:always" />';
1245                  }
1246  
1247                  // open table again: <table> + <col> etc.
1248                  $html .= $this->generateTableHeader($worksheet, false);
1249                  $html .= '<tbody>' . PHP_EOL;
1250              }
1251          }
1252  
1253          // Write row start
1254          if (!$this->useInlineCss) {
1255              $html .= '          <tr class="row' . $row . '">' . PHP_EOL;
1256          } else {
1257              $style = isset($this->cssStyles['table.sheet' . $sheetIndex . ' tr.row' . $row])
1258                  ? $this->assembleCSS($this->cssStyles['table.sheet' . $sheetIndex . ' tr.row' . $row]) : '';
1259  
1260              $html .= '          <tr style="' . $style . '">' . PHP_EOL;
1261          }
1262  
1263          return $html;
1264      }
1265  
1266      private function generateRowCellCss(Worksheet $worksheet, string $cellAddress, int $row, int $columnNumber): array
1267      {
1268          $cell = ($cellAddress > '') ? $worksheet->getCellCollection()->get($cellAddress) : '';
1269          $coordinate = Coordinate::stringFromColumnIndex($columnNumber + 1) . ($row + 1);
1270          if (!$this->useInlineCss) {
1271              $cssClass = 'column' . $columnNumber;
1272          } else {
1273              $cssClass = [];
1274              // The statements below do nothing.
1275              // Commenting out the code rather than deleting it
1276              // in case someone can figure out what their intent was.
1277              //if ($cellType == 'th') {
1278              //    if (isset($this->cssStyles['table.sheet' . $sheetIndex . ' th.column' . $colNum])) {
1279              //        $this->cssStyles['table.sheet' . $sheetIndex . ' th.column' . $colNum];
1280              //    }
1281              //} else {
1282              //    if (isset($this->cssStyles['table.sheet' . $sheetIndex . ' td.column' . $colNum])) {
1283              //        $this->cssStyles['table.sheet' . $sheetIndex . ' td.column' . $colNum];
1284              //    }
1285              //}
1286              // End of mystery statements.
1287          }
1288  
1289          return [$cell, $cssClass, $coordinate];
1290      }
1291  
1292      private function generateRowCellDataValueRich(Cell $cell, string &$cellData): void
1293      {
1294          // Loop through rich text elements
1295          $elements = $cell->getValue()->getRichTextElements();
1296          foreach ($elements as $element) {
1297              // Rich text start?
1298              if ($element instanceof Run) {
1299                  $cellEnd = '';
1300                  if ($element->getFont() !== null) {
1301                      $cellData .= '<span style="' . $this->assembleCSS($this->createCSSStyleFont($element->getFont())) . '">';
1302  
1303                      if ($element->getFont()->getSuperscript()) {
1304                          $cellData .= '<sup>';
1305                          $cellEnd = '</sup>';
1306                      } elseif ($element->getFont()->getSubscript()) {
1307                          $cellData .= '<sub>';
1308                          $cellEnd = '</sub>';
1309                      }
1310                  }
1311  
1312                  // Convert UTF8 data to PCDATA
1313                  $cellText = $element->getText();
1314                  $cellData .= htmlspecialchars($cellText, Settings::htmlEntityFlags());
1315  
1316                  $cellData .= $cellEnd;
1317  
1318                  $cellData .= '</span>';
1319              } else {
1320                  // Convert UTF8 data to PCDATA
1321                  $cellText = $element->getText();
1322                  $cellData .= htmlspecialchars($cellText, Settings::htmlEntityFlags());
1323              }
1324          }
1325      }
1326  
1327      private function generateRowCellDataValue(Worksheet $worksheet, Cell $cell, string &$cellData): void
1328      {
1329          if ($cell->getValue() instanceof RichText) {
1330              $this->generateRowCellDataValueRich($cell, $cellData);
1331          } else {
1332              $origData = $this->preCalculateFormulas ? $cell->getCalculatedValue() : $cell->getValue();
1333              $formatCode = $worksheet->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())->getNumberFormat()->getFormatCode();
1334  
1335              $cellData = NumberFormat::toFormattedString(
1336                  $origData ?? '',
1337                  $formatCode ?? NumberFormat::FORMAT_GENERAL,
1338                  [$this, 'formatColor']
1339              );
1340  
1341              if ($cellData === $origData) {
1342                  $cellData = htmlspecialchars($cellData, Settings::htmlEntityFlags());
1343              }
1344              if ($worksheet->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())->getFont()->getSuperscript()) {
1345                  $cellData = '<sup>' . $cellData . '</sup>';
1346              } elseif ($worksheet->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())->getFont()->getSubscript()) {
1347                  $cellData = '<sub>' . $cellData . '</sub>';
1348              }
1349          }
1350      }
1351  
1352      /**
1353       * @param null|Cell|string $cell
1354       * @param array|string $cssClass
1355       */
1356      private function generateRowCellData(Worksheet $worksheet, $cell, &$cssClass, string $cellType): string
1357      {
1358          $cellData = '&nbsp;';
1359          if ($cell instanceof Cell) {
1360              $cellData = '';
1361              // Don't know what this does, and no test cases.
1362              //if ($cell->getParent() === null) {
1363              //    $cell->attach($worksheet);
1364              //}
1365              // Value
1366              $this->generateRowCellDataValue($worksheet, $cell, $cellData);
1367  
1368              // Converts the cell content so that spaces occuring at beginning of each new line are replaced by &nbsp;
1369              // Example: "  Hello\n to the world" is converted to "&nbsp;&nbsp;Hello\n&nbsp;to the world"
1370              $cellData = (string) preg_replace('/(?m)(?:^|\\G) /', '&nbsp;', $cellData);
1371  
1372              // convert newline "\n" to '<br>'
1373              $cellData = nl2br($cellData);
1374  
1375              // Extend CSS class?
1376              if (!$this->useInlineCss && is_string($cssClass)) {
1377                  $cssClass .= ' style' . $cell->getXfIndex();
1378                  $cssClass .= ' ' . $cell->getDataType();
1379              } elseif (is_array($cssClass)) {
1380                  if ($cellType == 'th') {
1381                      if (isset($this->cssStyles['th.style' . $cell->getXfIndex()])) {
1382                          $cssClass = array_merge($cssClass, $this->cssStyles['th.style' . $cell->getXfIndex()]);
1383                      }
1384                  } else {
1385                      if (isset($this->cssStyles['td.style' . $cell->getXfIndex()])) {
1386                          $cssClass = array_merge($cssClass, $this->cssStyles['td.style' . $cell->getXfIndex()]);
1387                      }
1388                  }
1389  
1390                  // General horizontal alignment: Actual horizontal alignment depends on dataType
1391                  $sharedStyle = $worksheet->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex());
1392                  if (
1393                      $sharedStyle->getAlignment()->getHorizontal() == Alignment::HORIZONTAL_GENERAL
1394                      && isset($this->cssStyles['.' . $cell->getDataType()]['text-align'])
1395                  ) {
1396                      $cssClass['text-align'] = $this->cssStyles['.' . $cell->getDataType()]['text-align'];
1397                  }
1398              }
1399          } else {
1400              // Use default borders for empty cell
1401              if (is_string($cssClass)) {
1402                  $cssClass .= ' style0';
1403              }
1404          }
1405  
1406          return $cellData;
1407      }
1408  
1409      private function generateRowIncludeCharts(Worksheet $worksheet, string $coordinate): string
1410      {
1411          return $this->includeCharts ? $this->writeChartInCell($worksheet, $coordinate) : '';
1412      }
1413  
1414      private function generateRowSpans(string $html, int $rowSpan, int $colSpan): string
1415      {
1416          $html .= ($colSpan > 1) ? (' colspan="' . $colSpan . '"') : '';
1417          $html .= ($rowSpan > 1) ? (' rowspan="' . $rowSpan . '"') : '';
1418  
1419          return $html;
1420      }
1421  
1422      /**
1423       * @param array|string $cssClass
1424       */
1425      private function generateRowWriteCell(string &$html, Worksheet $worksheet, string $coordinate, string $cellType, string $cellData, int $colSpan, int $rowSpan, $cssClass, int $colNum, int $sheetIndex, int $row): void
1426      {
1427          // Image?
1428          $htmlx = $this->writeImageInCell($worksheet, $coordinate);
1429          // Chart?
1430          $htmlx .= $this->generateRowIncludeCharts($worksheet, $coordinate);
1431          // Column start
1432          $html .= '            <' . $cellType;
1433          if (!$this->useInlineCss && !$this->isPdf && is_string($cssClass)) {
1434              $html .= ' class="' . $cssClass . '"';
1435              if ($htmlx) {
1436                  $html .= " style='position: relative;'";
1437              }
1438          } else {
1439              //** Necessary redundant code for the sake of \PhpOffice\PhpSpreadsheet\Writer\Pdf **
1440              // We must explicitly write the width of the <td> element because TCPDF
1441              // does not recognize e.g. <col style="width:42pt">
1442              if ($this->useInlineCss) {
1443                  $xcssClass = is_array($cssClass) ? $cssClass : [];
1444              } else {
1445                  if (is_string($cssClass)) {
1446                      $html .= ' class="' . $cssClass . '"';
1447                  }
1448                  $xcssClass = [];
1449              }
1450              $width = 0;
1451              $i = $colNum - 1;
1452              $e = $colNum + $colSpan - 1;
1453              while ($i++ < $e) {
1454                  if (isset($this->columnWidths[$sheetIndex][$i])) {
1455                      $width += $this->columnWidths[$sheetIndex][$i];
1456                  }
1457              }
1458              $xcssClass['width'] = (string) $width . 'pt';
1459              // We must also explicitly write the height of the <td> element because TCPDF
1460              // does not recognize e.g. <tr style="height:50pt">
1461              if (isset($this->cssStyles['table.sheet' . $sheetIndex . ' tr.row' . $row]['height'])) {
1462                  $height = $this->cssStyles['table.sheet' . $sheetIndex . ' tr.row' . $row]['height'];
1463                  $xcssClass['height'] = $height;
1464              }
1465              //** end of redundant code **
1466  
1467              if ($htmlx) {
1468                  $xcssClass['position'] = 'relative';
1469              }
1470              $html .= ' style="' . $this->assembleCSS($xcssClass) . '"';
1471          }
1472          $html = $this->generateRowSpans($html, $rowSpan, $colSpan);
1473  
1474          $html .= '>';
1475          $html .= $htmlx;
1476  
1477          $html .= $this->writeComment($worksheet, $coordinate);
1478  
1479          // Cell data
1480          $html .= $cellData;
1481  
1482          // Column end
1483          $html .= '</' . $cellType . '>' . PHP_EOL;
1484      }
1485  
1486      /**
1487       * Generate row.
1488       *
1489       * @param array $values Array containing cells in a row
1490       * @param int $row Row number (0-based)
1491       * @param string $cellType eg: 'td'
1492       *
1493       * @return string
1494       */
1495      private function generateRow(Worksheet $worksheet, array $values, $row, $cellType)
1496      {
1497          // Sheet index
1498          $sheetIndex = $worksheet->getParentOrThrow()->getIndex($worksheet);
1499          $html = $this->generateRowStart($worksheet, $sheetIndex, $row);
1500          $generateDiv = $this->isMPdf && $worksheet->getRowDimension($row + 1)->getVisible() === false;
1501          if ($generateDiv) {
1502              $html .= '<div style="visibility:hidden; display:none;">' . PHP_EOL;
1503          }
1504  
1505          // Write cells
1506          $colNum = 0;
1507          foreach ($values as $cellAddress) {
1508              [$cell, $cssClass, $coordinate] = $this->generateRowCellCss($worksheet, $cellAddress, $row, $colNum);
1509  
1510              // Cell Data
1511              $cellData = $this->generateRowCellData($worksheet, $cell, $cssClass, $cellType);
1512  
1513              // Hyperlink?
1514              if ($worksheet->hyperlinkExists($coordinate) && !$worksheet->getHyperlink($coordinate)->isInternal()) {
1515                  $cellData = '<a href="' . htmlspecialchars($worksheet->getHyperlink($coordinate)->getUrl(), Settings::htmlEntityFlags()) . '" title="' . htmlspecialchars($worksheet->getHyperlink($coordinate)->getTooltip(), Settings::htmlEntityFlags()) . '">' . $cellData . '</a>';
1516              }
1517  
1518              // Should the cell be written or is it swallowed by a rowspan or colspan?
1519              $writeCell = !(isset($this->isSpannedCell[$worksheet->getParentOrThrow()->getIndex($worksheet)][$row + 1][$colNum])
1520                  && $this->isSpannedCell[$worksheet->getParentOrThrow()->getIndex($worksheet)][$row + 1][$colNum]);
1521  
1522              // Colspan and Rowspan
1523              $colSpan = 1;
1524              $rowSpan = 1;
1525              if (isset($this->isBaseCell[$worksheet->getParentOrThrow()->getIndex($worksheet)][$row + 1][$colNum])) {
1526                  $spans = $this->isBaseCell[$worksheet->getParentOrThrow()->getIndex($worksheet)][$row + 1][$colNum];
1527                  $rowSpan = $spans['rowspan'];
1528                  $colSpan = $spans['colspan'];
1529  
1530                  //    Also apply style from last cell in merge to fix borders -
1531                  //        relies on !important for non-none border declarations in createCSSStyleBorder
1532                  $endCellCoord = Coordinate::stringFromColumnIndex($colNum + $colSpan) . ($row + $rowSpan);
1533                  if (!$this->useInlineCss) {
1534                      $cssClass .= ' style' . $worksheet->getCell($endCellCoord)->getXfIndex();
1535                  }
1536              }
1537  
1538              // Write
1539              if ($writeCell) {
1540                  $this->generateRowWriteCell($html, $worksheet, $coordinate, $cellType, $cellData, $colSpan, $rowSpan, $cssClass, $colNum, $sheetIndex, $row);
1541              }
1542  
1543              // Next column
1544              ++$colNum;
1545          }
1546  
1547          // Write row end
1548          if ($generateDiv) {
1549              $html .= '</div>' . PHP_EOL;
1550          }
1551          $html .= '          </tr>' . PHP_EOL;
1552  
1553          // Return
1554          return $html;
1555      }
1556  
1557      /**
1558       * Takes array where of CSS properties / values and converts to CSS string.
1559       *
1560       * @return string
1561       */
1562      private function assembleCSS(array $values = [])
1563      {
1564          $pairs = [];
1565          foreach ($values as $property => $value) {
1566              $pairs[] = $property . ':' . $value;
1567          }
1568          $string = implode('; ', $pairs);
1569  
1570          return $string;
1571      }
1572  
1573      /**
1574       * Get images root.
1575       *
1576       * @return string
1577       */
1578      public function getImagesRoot()
1579      {
1580          return $this->imagesRoot;
1581      }
1582  
1583      /**
1584       * Set images root.
1585       *
1586       * @param string $imagesRoot
1587       *
1588       * @return $this
1589       */
1590      public function setImagesRoot($imagesRoot)
1591      {
1592          $this->imagesRoot = $imagesRoot;
1593  
1594          return $this;
1595      }
1596  
1597      /**
1598       * Get embed images.
1599       *
1600       * @return bool
1601       */
1602      public function getEmbedImages()
1603      {
1604          return $this->embedImages;
1605      }
1606  
1607      /**
1608       * Set embed images.
1609       *
1610       * @param bool $embedImages
1611       *
1612       * @return $this
1613       */
1614      public function setEmbedImages($embedImages)
1615      {
1616          $this->embedImages = $embedImages;
1617  
1618          return $this;
1619      }
1620  
1621      /**
1622       * Get use inline CSS?
1623       *
1624       * @return bool
1625       */
1626      public function getUseInlineCss()
1627      {
1628          return $this->useInlineCss;
1629      }
1630  
1631      /**
1632       * Set use inline CSS?
1633       *
1634       * @param bool $useInlineCss
1635       *
1636       * @return $this
1637       */
1638      public function setUseInlineCss($useInlineCss)
1639      {
1640          $this->useInlineCss = $useInlineCss;
1641  
1642          return $this;
1643      }
1644  
1645      /**
1646       * Get use embedded CSS?
1647       *
1648       * @return bool
1649       *
1650       * @codeCoverageIgnore
1651       *
1652       * @deprecated no longer used
1653       */
1654      public function getUseEmbeddedCSS()
1655      {
1656          return $this->useEmbeddedCSS;
1657      }
1658  
1659      /**
1660       * Set use embedded CSS?
1661       *
1662       * @param bool $useEmbeddedCSS
1663       *
1664       * @return $this
1665       *
1666       * @codeCoverageIgnore
1667       *
1668       * @deprecated no longer used
1669       */
1670      public function setUseEmbeddedCSS($useEmbeddedCSS)
1671      {
1672          $this->useEmbeddedCSS = $useEmbeddedCSS;
1673  
1674          return $this;
1675      }
1676  
1677      /**
1678       * Add color to formatted string as inline style.
1679       *
1680       * @param string $value Plain formatted value without color
1681       * @param string $format Format code
1682       *
1683       * @return string
1684       */
1685      public function formatColor($value, $format)
1686      {
1687          // Color information, e.g. [Red] is always at the beginning
1688          $color = null; // initialize
1689          $matches = [];
1690  
1691          $color_regex = '/^\\[[a-zA-Z]+\\]/';
1692          if (preg_match($color_regex, $format, $matches)) {
1693              $color = str_replace(['[', ']'], '', $matches[0]);
1694              $color = strtolower($color);
1695          }
1696  
1697          // convert to PCDATA
1698          $result = htmlspecialchars($value, Settings::htmlEntityFlags());
1699  
1700          // color span tag
1701          if ($color !== null) {
1702              $result = '<span style="color:' . $color . '">' . $result . '</span>';
1703          }
1704  
1705          return $result;
1706      }
1707  
1708      /**
1709       * Calculate information about HTML colspan and rowspan which is not always the same as Excel's.
1710       */
1711      private function calculateSpans(): void
1712      {
1713          if ($this->spansAreCalculated) {
1714              return;
1715          }
1716          // Identify all cells that should be omitted in HTML due to cell merge.
1717          // In HTML only the upper-left cell should be written and it should have
1718          //   appropriate rowspan / colspan attribute
1719          $sheetIndexes = $this->sheetIndex !== null ?
1720              [$this->sheetIndex] : range(0, $this->spreadsheet->getSheetCount() - 1);
1721  
1722          foreach ($sheetIndexes as $sheetIndex) {
1723              $sheet = $this->spreadsheet->getSheet($sheetIndex);
1724  
1725              $candidateSpannedRow = [];
1726  
1727              // loop through all Excel merged cells
1728              foreach ($sheet->getMergeCells() as $cells) {
1729                  [$cells] = Coordinate::splitRange($cells);
1730                  $first = $cells[0];
1731                  $last = $cells[1];
1732  
1733                  [$fc, $fr] = Coordinate::indexesFromString($first);
1734                  $fc = $fc - 1;
1735  
1736                  [$lc, $lr] = Coordinate::indexesFromString($last);
1737                  $lc = $lc - 1;
1738  
1739                  // loop through the individual cells in the individual merge
1740                  $r = $fr - 1;
1741                  while ($r++ < $lr) {
1742                      // also, flag this row as a HTML row that is candidate to be omitted
1743                      $candidateSpannedRow[$r] = $r;
1744  
1745                      $c = $fc - 1;
1746                      while ($c++ < $lc) {
1747                          if (!($c == $fc && $r == $fr)) {
1748                              // not the upper-left cell (should not be written in HTML)
1749                              $this->isSpannedCell[$sheetIndex][$r][$c] = [
1750                                  'baseCell' => [$fr, $fc],
1751                              ];
1752                          } else {
1753                              // upper-left is the base cell that should hold the colspan/rowspan attribute
1754                              $this->isBaseCell[$sheetIndex][$r][$c] = [
1755                                  'xlrowspan' => $lr - $fr + 1, // Excel rowspan
1756                                  'rowspan' => $lr - $fr + 1, // HTML rowspan, value may change
1757                                  'xlcolspan' => $lc - $fc + 1, // Excel colspan
1758                                  'colspan' => $lc - $fc + 1, // HTML colspan, value may change
1759                              ];
1760                          }
1761                      }
1762                  }
1763              }
1764  
1765              $this->calculateSpansOmitRows($sheet, $sheetIndex, $candidateSpannedRow);
1766  
1767              // TODO: Same for columns
1768          }
1769  
1770          // We have calculated the spans
1771          $this->spansAreCalculated = true;
1772      }
1773  
1774      private function calculateSpansOmitRows(Worksheet $sheet, int $sheetIndex, array $candidateSpannedRow): void
1775      {
1776          // Identify which rows should be omitted in HTML. These are the rows where all the cells
1777          //   participate in a merge and the where base cells are somewhere above.
1778          $countColumns = Coordinate::columnIndexFromString($sheet->getHighestColumn());
1779          foreach ($candidateSpannedRow as $rowIndex) {
1780              if (isset($this->isSpannedCell[$sheetIndex][$rowIndex])) {
1781                  if (count($this->isSpannedCell[$sheetIndex][$rowIndex]) == $countColumns) {
1782                      $this->isSpannedRow[$sheetIndex][$rowIndex] = $rowIndex;
1783                  }
1784              }
1785          }
1786  
1787          // For each of the omitted rows we found above, the affected rowspans should be subtracted by 1
1788          if (isset($this->isSpannedRow[$sheetIndex])) {
1789              foreach ($this->isSpannedRow[$sheetIndex] as $rowIndex) {
1790                  $adjustedBaseCells = [];
1791                  $c = -1;
1792                  $e = $countColumns - 1;
1793                  while ($c++ < $e) {
1794                      $baseCell = $this->isSpannedCell[$sheetIndex][$rowIndex][$c]['baseCell'];
1795  
1796                      if (!in_array($baseCell, $adjustedBaseCells, true)) {
1797                          // subtract rowspan by 1
1798                          --$this->isBaseCell[$sheetIndex][$baseCell[0]][$baseCell[1]]['rowspan'];
1799                          $adjustedBaseCells[] = $baseCell;
1800                      }
1801                  }
1802              }
1803          }
1804      }
1805  
1806      /**
1807       * Write a comment in the same format as LibreOffice.
1808       *
1809       * @see https://github.com/LibreOffice/core/blob/9fc9bf3240f8c62ad7859947ab8a033ac1fe93fa/sc/source/filter/html/htmlexp.cxx#L1073-L1092
1810       *
1811       * @param string $coordinate
1812       *
1813       * @return string
1814       */
1815      private function writeComment(Worksheet $worksheet, $coordinate)
1816      {
1817          $result = '';
1818          if (!$this->isPdf && isset($worksheet->getComments()[$coordinate])) {
1819              $sanitizer = new HTMLPurifier();
1820              $cachePath = File::sysGetTempDir() . '/phpsppur';
1821              if (is_dir($cachePath) || mkdir($cachePath)) {
1822                  $sanitizer->config->set('Cache.SerializerPath', $cachePath);
1823              }
1824              $sanitizedString = $sanitizer->purify($worksheet->getComment($coordinate)->getText()->getPlainText());
1825              if ($sanitizedString !== '') {
1826                  $result .= '<a class="comment-indicator"></a>';
1827                  $result .= '<div class="comment">' . nl2br($sanitizedString) . '</div>';
1828                  $result .= PHP_EOL;
1829              }
1830          }
1831  
1832          return $result;
1833      }
1834  
1835      public function getOrientation(): ?string
1836      {
1837          // Expect Pdf classes to override this method.
1838          return $this->isPdf ? PageSetup::ORIENTATION_PORTRAIT : null;
1839      }
1840  
1841      /**
1842       * Generate @page declarations.
1843       *
1844       * @param bool $generateSurroundingHTML
1845       *
1846       * @return    string
1847       */
1848      private function generatePageDeclarations($generateSurroundingHTML)
1849      {
1850          // Ensure that Spans have been calculated?
1851          $this->calculateSpans();
1852  
1853          // Fetch sheets
1854          $sheets = [];
1855          if ($this->sheetIndex === null) {
1856              $sheets = $this->spreadsheet->getAllSheets();
1857          } else {
1858              $sheets[] = $this->spreadsheet->getSheet($this->sheetIndex);
1859          }
1860  
1861          // Construct HTML
1862          $htmlPage = $generateSurroundingHTML ? ('<style type="text/css">' . PHP_EOL) : '';
1863  
1864          // Loop all sheets
1865          $sheetId = 0;
1866          foreach ($sheets as $worksheet) {
1867              $htmlPage .= "@page page$sheetId { ";
1868              $left = StringHelper::formatNumber($worksheet->getPageMargins()->getLeft()) . 'in; ';
1869              $htmlPage .= 'margin-left: ' . $left;
1870              $right = StringHelper::FormatNumber($worksheet->getPageMargins()->getRight()) . 'in; ';
1871              $htmlPage .= 'margin-right: ' . $right;
1872              $top = StringHelper::FormatNumber($worksheet->getPageMargins()->getTop()) . 'in; ';
1873              $htmlPage .= 'margin-top: ' . $top;
1874              $bottom = StringHelper::FormatNumber($worksheet->getPageMargins()->getBottom()) . 'in; ';
1875              $htmlPage .= 'margin-bottom: ' . $bottom;
1876              $orientation = $this->getOrientation() ?? $worksheet->getPageSetup()->getOrientation();
1877              if ($orientation === PageSetup::ORIENTATION_LANDSCAPE) {
1878                  $htmlPage .= 'size: landscape; ';
1879              } elseif ($orientation === PageSetup::ORIENTATION_PORTRAIT) {
1880                  $htmlPage .= 'size: portrait; ';
1881              }
1882              $htmlPage .= '}' . PHP_EOL;
1883              ++$sheetId;
1884          }
1885          $htmlPage .= implode(PHP_EOL, [
1886              '.navigation {page-break-after: always;}',
1887              '.scrpgbrk, div + div {page-break-before: always;}',
1888              '@media screen {',
1889              '  .gridlines td {border: 1px solid black;}',
1890              '  .gridlines th {border: 1px solid black;}',
1891              '  body>div {margin-top: 5px;}',
1892              '  body>div:first-child {margin-top: 0;}',
1893              '  .scrpgbrk {margin-top: 1px;}',
1894              '}',
1895              '@media print {',
1896              '  .gridlinesp td {border: 1px solid black;}',
1897              '  .gridlinesp th {border: 1px solid black;}',
1898              '  .navigation {display: none;}',
1899              '}',
1900              '',
1901          ]);
1902          $htmlPage .= $generateSurroundingHTML ? ('</style>' . PHP_EOL) : '';
1903  
1904          return $htmlPage;
1905      }
1906  }