<?php

namespace TablePress\PhpOffice\PhpSpreadsheet\Reader;

use Closure;
use TablePress\Composer\Pcre\Preg;
use DateTime;
use DateTimeZone;
use DOMAttr;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMText;
use TablePress\PhpOffice\PhpSpreadsheet\Cell\AddressRange;
use TablePress\PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use TablePress\PhpOffice\PhpSpreadsheet\Cell\DataType;
use TablePress\PhpOffice\PhpSpreadsheet\Helper\Dimension as HelperDimension;
use TablePress\PhpOffice\PhpSpreadsheet\Reader\Ods\AutoFilter;
use TablePress\PhpOffice\PhpSpreadsheet\Reader\Ods\DefinedNames;
use TablePress\PhpOffice\PhpSpreadsheet\Reader\Ods\FormulaTranslator;
use TablePress\PhpOffice\PhpSpreadsheet\Reader\Ods\PageSettings;
use TablePress\PhpOffice\PhpSpreadsheet\Reader\Ods\Properties as DocumentProperties;
use TablePress\PhpOffice\PhpSpreadsheet\Reader\Security\XmlScanner;
use TablePress\PhpOffice\PhpSpreadsheet\RichText\RichText;
use TablePress\PhpOffice\PhpSpreadsheet\Shared\Date;
use TablePress\PhpOffice\PhpSpreadsheet\Shared\File;
use TablePress\PhpOffice\PhpSpreadsheet\Shared\StringHelper;
use TablePress\PhpOffice\PhpSpreadsheet\Spreadsheet;
use TablePress\PhpOffice\PhpSpreadsheet\Style\Alignment;
use TablePress\PhpOffice\PhpSpreadsheet\Style\Border;
use TablePress\PhpOffice\PhpSpreadsheet\Style\Borders;
use TablePress\PhpOffice\PhpSpreadsheet\Style\Fill;
use TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use TablePress\PhpOffice\PhpSpreadsheet\Style\Protection;
use TablePress\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Throwable;
use XMLReader;
use ZipArchive;

class Ods extends BaseReader
{
	const INITIAL_FILE = 'content.xml';

	/**
	 * Create a new Ods Reader instance.
	 */
	public function __construct()
	{
		parent::__construct();
		$this->securityScanner = XmlScanner::getInstance($this);
	}

	/**
	 * Can the current IReader read the file?
	 */
	public function canRead(string $filename): bool
	{
		$mimeType = 'UNKNOWN';

		// Load file

		if (File::testFileNoThrow($filename, '')) {
			$zip = new ZipArchive();
			if ($zip->open($filename) === true) {
				// check if it is an OOXML archive
				$stat = $zip->statName('mimetype');
				if (!empty($stat) && ($stat['size'] <= 255)) {
					$mimeType = $zip->getFromName($stat['name']);
				} elseif ($zip->statName('META-INF/manifest.xml')) {
					$xml = simplexml_load_string(
						$this->getSecurityScannerOrThrow()
							->scan(
								$zip->getFromName(
									'META-INF/manifest.xml'
								)
							)
					);
					if ($xml !== false) {
						$namespacesContent = $xml->getNamespaces(true);
						if (isset($namespacesContent['manifest'])) {
							$manifest = $xml->children($namespacesContent['manifest']);
							foreach ($manifest as $manifestDataSet) {
								$manifestAttributes = $manifestDataSet->attributes($namespacesContent['manifest']);
								if ($manifestAttributes && $manifestAttributes->{'full-path'} == '/') {
									$mimeType = (string) $manifestAttributes->{'media-type'};

									break;
								}
							}
						}
					}
				}

				$zip->close();
			}
		}

		return $mimeType === 'application/vnd.oasis.opendocument.spreadsheet';
	}

	/**
	 * Reads names of the worksheets from a file, without parsing the whole file to a PhpSpreadsheet object.
	 *
	 * @return string[]
	 */
	public function listWorksheetNames(string $filename): array
	{
		File::assertFile($filename, self::INITIAL_FILE);

		$worksheetNames = [];

		$xml = new XMLReader();
		$xml->xml(
			$this->getSecurityScannerOrThrow()
				->scanFile(
					'zip://' . realpath($filename) . '#' . self::INITIAL_FILE
				)
		);
		$xml->setParserProperty(2, true);

		// Step into the first level of content of the XML
		$xml->read();
		while ($xml->read()) {
			// Quickly jump through to the office:body node
			while ($xml->name !== 'office:body') {
				if ($xml->isEmptyElement) {
					$xml->read();
				} else {
					$xml->next();
				}
			}
			// Now read each node until we find our first table:table node
			while ($xml->read()) {
				$xmlName = $xml->name;
				if ($xmlName == 'table:table' && $xml->nodeType == XMLReader::ELEMENT) {
					// Loop through each table:table node reading the table:name attribute for each worksheet name
					do {
						$worksheetName = $xml->getAttribute('table:name');
						if (!empty($worksheetName)) {
							$worksheetNames[] = $worksheetName;
						}
						$xml->next();
					} while ($xml->name == 'table:table' && $xml->nodeType == XMLReader::ELEMENT);
				}
			}
		}

		return $worksheetNames;
	}

	/**
	 * Return worksheet info (Name, Last Column Letter, Last Column Index, Total Rows, Total Columns).
	 *
	 * @return array<int, array{
	 *   worksheetName: string,
	 *   lastColumnLetter: string,
	 *   lastColumnIndex: int,
	 *   totalRows: int,
	 *   totalColumns: int,
	 *   sheetState: string
	 * }>
	 */
	public function listWorksheetInfo(string $filename): array
	{
		File::assertFile($filename, self::INITIAL_FILE);

		$worksheetInfo = [];

		$xml = new XMLReader();
		$xml->xml(
			$this->getSecurityScannerOrThrow()
				->scanFile(
					'zip://' . realpath($filename) . '#' . self::INITIAL_FILE
				)
		);
		$xml->setParserProperty(2, true);

		// Step into the first level of content of the XML
		$xml->read();
		$tableVisibility = [];
		$lastTableStyle = '';

		while ($xml->read()) {
			if ($xml->name === 'style:style') {
				$styleType = $xml->getAttribute('style:family');
				if ($styleType === 'table') {
					$lastTableStyle = $xml->getAttribute('style:name');
				}
			} elseif ($xml->name === 'style:table-properties') {
				$visibility = $xml->getAttribute('table:display');
				$tableVisibility[$lastTableStyle] = ($visibility === 'false') ? Worksheet::SHEETSTATE_HIDDEN : Worksheet::SHEETSTATE_VISIBLE;
			} elseif ($xml->name == 'table:table' && $xml->nodeType == XMLReader::ELEMENT) {
				$worksheetNames[] = $xml->getAttribute('table:name');

				$styleName = $xml->getAttribute('table:style-name') ?? '';
				$visibility = $tableVisibility[$styleName] ?? '';
				$tmpInfo = [
					'worksheetName' => (string) $xml->getAttribute('table:name'),
					'lastColumnLetter' => 'A',
					'lastColumnIndex' => 0,
					'totalRows' => 0,
					'totalColumns' => 0,
					'sheetState' => $visibility,
				];

				// Loop through each child node of the table:table element reading
				$currRow = 0;
				do {
					$xml->read();
					if ($xml->name == 'table:table-row' && $xml->nodeType == XMLReader::ELEMENT) {
						$rowspan = $xml->getAttribute('table:number-rows-repeated');
						$rowspan = empty($rowspan) ? 1 : (int) $rowspan;
						$currRow += $rowspan;
						$currCol = 0;
						// Step into the row
						$xml->read();
						do {
							$doread = true;
							if ($xml->name == 'table:table-cell' && $xml->nodeType == XMLReader::ELEMENT) {
								$mergeSize = $xml->getAttribute('table:number-columns-repeated');
								$mergeSize = empty($mergeSize) ? 1 : (int) $mergeSize;
								$currCol += $mergeSize;
								if (!$xml->isEmptyElement) {
									$tmpInfo['totalColumns'] = max($tmpInfo['totalColumns'], $currCol);
									$tmpInfo['totalRows'] = $currRow;
									$xml->next();
									$doread = false;
								}
							} elseif ($xml->name == 'table:covered-table-cell' && $xml->nodeType == XMLReader::ELEMENT) {
								$mergeSize = $xml->getAttribute('table:number-columns-repeated');
								$currCol += (int) $mergeSize;
							}
							if ($doread) {
								$xml->read();
							}
						} while ($xml->name != 'table:table-row');
					}
				} while ($xml->name != 'table:table');

				$tmpInfo['lastColumnIndex'] = $tmpInfo['totalColumns'] - 1;
				$tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1);
				$worksheetInfo[] = $tmpInfo;
			}
		}

		return $worksheetInfo;
	}

	/**
	 * Loads PhpSpreadsheet from file.
	 */
	protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
	{
		$spreadsheet = $this->newSpreadsheet();
		$spreadsheet->setValueBinder($this->valueBinder);
		$spreadsheet->removeSheetByIndex(0);

		// Load into this instance
		return $this->loadIntoExisting($filename, $spreadsheet);
	}

	/** @var array<string,
	 *  array{
	 *     font?:array{
	 *       autoColor?: true,
	 *       bold?: true,
	 *       color?: array{rgb: string},
	 *       italic?: true,
	 *       name?: non-empty-string,
	 *       size?: float|int,
	 *       strikethrough?: true,
	 *       underline?: 'double'|'single',
	 *    },
	 *    fill?:array{
	 *      fillType?: string,
	 *      startColor?: array{rgb: string},
	 *    },
	 *    alignment?:array{
	 *      horizontal?: string,
	 *      readOrder?: int,
	 *      shrinkToFit?: bool,
	 *      textRotation?: int,
	 *      vertical?: string,
	 *      wrapText?: bool,
	 *    },
	 *    protection?:array{
	 *      locked?: string,
	 *      hidden?: string,
	 *    },
	 *    borders?:array{
	 *      bottom?: array{borderStyle:string, color:array{rgb: string}},
	 *      left?: array{borderStyle:string, color:array{rgb: string}},
	 *      right?: array{borderStyle:string, color:array{rgb: string}},
	 *      top?: array{borderStyle:string, color:array{rgb: string}},
	 *      diagonal?: array{borderStyle:string, color:array{rgb: string}},
	 *      diagonalDirection?: int,
	 *    },
	 *  }>
	 */
	private array $allStyles;

	private int $highestDataIndex;

	/**
	 * Loads PhpSpreadsheet from file into PhpSpreadsheet instance.
	 */
	public function loadIntoExisting(string $filename, Spreadsheet $spreadsheet): Spreadsheet
	{
		File::assertFile($filename, self::INITIAL_FILE);

		$zip = new ZipArchive();
		$zip->open($filename);

		// Meta

		$xml = @simplexml_load_string(
			$this->getSecurityScannerOrThrow()
				->scan($zip->getFromName('meta.xml'))
		);
		if ($xml === false) {
			throw new Exception('Unable to read data from {$pFilename}');
		}

		/** @var array{meta?: string, office?: string, dc?: string} */
		$namespacesMeta = $xml->getNamespaces(true);

		(new DocumentProperties($spreadsheet))->load($xml, $namespacesMeta);

		// Styles

		$this->allStyles = [];
		$dom = new DOMDocument('1.01', 'UTF-8');
		$dom->loadXML(
			$this->getSecurityScannerOrThrow()
				->scan($zip->getFromName('styles.xml'))
		);
		$officeNs = (string) $dom->lookupNamespaceUri('office');
		$styleNs = (string) $dom->lookupNamespaceUri('style');
		$fontNs = (string) $dom->lookupNamespaceUri('fo');

		$automaticStyle0 = $this->readDataOnly ? null : $dom->getElementsByTagNameNS($officeNs, 'styles')->item(0);
		$automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'default-style');
		foreach ($automaticStyles as $automaticStyle) {
			$styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
			if ($styleFamily === 'table-cell') {
				$fonts = [];
				foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) {
					$fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs);
				}
				if (!empty($fonts)) {
					$spreadsheet->getDefaultStyle()
						->getFont()
						->applyFromArray($fonts);
				}
			}
		}
		$automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'style');
		foreach ($automaticStyles as $automaticStyle) {
			$styleName = $automaticStyle->getAttributeNS($styleNs, 'name');
			$styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
			if ($styleFamily === 'table-cell') {
				$fills = $fonts = [];
				foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) {
					$fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs);
				}
				foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) {
					$fills = $this->getFillStyles($tableCellProperty, $fontNs);
				}
				if ($styleName !== '') {
					if (!empty($fonts)) {
						$this->allStyles[$styleName]['font'] = $fonts;
						if ($styleName === 'Default') {
							$spreadsheet->getDefaultStyle()
								->getFont()
								->applyFromArray($fonts);
						}
					}
					if (!empty($fills)) {
						$this->allStyles[$styleName]['fill'] = $fills;
						if ($styleName === 'Default') {
							$spreadsheet->getDefaultStyle()
								->getFill()
								->applyFromArray($fills);
						}
					}
				}
			}
		}

		$pageSettings = new PageSettings($dom);

		// Main Content

		$dom = new DOMDocument('1.01', 'UTF-8');
		$dom->loadXML(
			$this->getSecurityScannerOrThrow()
				->scan($zip->getFromName(self::INITIAL_FILE))
		);

		$tableNs = (string) $dom->lookupNamespaceUri('table');
		$textNs = (string) $dom->lookupNamespaceUri('text');
		$xlinkNs = (string) $dom->lookupNamespaceUri('xlink');

		$pageSettings->readStyleCrossReferences($dom);

		$autoFilterReader = new AutoFilter($spreadsheet, $tableNs);
		$definedNameReader = new DefinedNames($spreadsheet, $tableNs);
		$columnWidths = [];
		$automaticStyle0 = $this->readDataOnly ? null : $dom->getElementsByTagNameNS($officeNs, 'automatic-styles')->item(0);
		$automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'style');
		foreach ($automaticStyles as $automaticStyle) {
			$styleName = $automaticStyle->getAttributeNS($styleNs, 'name');
			$styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
			if ($styleFamily === 'table-column') {
				$tcprops = $automaticStyle->getElementsByTagNameNS($styleNs, 'table-column-properties');
				$tcprop = $tcprops->item(0);
				if ($tcprop !== null) {
					$columnWidth = $tcprop->getAttributeNs($styleNs, 'column-width');
					$columnWidths[$styleName] = $columnWidth;
				}
			}
			if ($styleFamily === 'table-cell') {
				$fonts = $fills = $alignment1 = $alignment2 = $protection = $borders = [];
				foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) {
					$fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs);
				}
				foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) {
					$fills = $this->getFillStyles($tableCellProperty, $fontNs);
					$borders = $this->getBorderStyles($tableCellProperty, $fontNs, $styleNs);
					$protection = $this->getProtectionStyles($tableCellProperty, $styleNs);
				}
				foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) {
					$alignment1 = $this->getAlignment1Styles($tableCellProperty, $styleNs, $fontNs);
				}
				foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'paragraph-properties') as $paragraphProperty) {
					$alignment2 = $this->getAlignment2Styles($paragraphProperty, $styleNs, $fontNs);
				}
				if ($styleName !== '') {
					if (!empty($fonts)) {
						$this->allStyles[$styleName]['font'] = $fonts;
					}
					if (!empty($fills)) {
						$this->allStyles[$styleName]['fill'] = $fills;
					}
					$alignment = array_merge($alignment1, $alignment2);
					if (!empty($alignment)) {
						$this->allStyles[$styleName]['alignment'] = $alignment;
					}
					if (!empty($protection)) {
						$this->allStyles[$styleName]['protection'] = $protection;
					}
					if (!empty($borders)) {
						$this->allStyles[$styleName]['borders'] = $borders;
					}
				}
			}
		}

		// Content
		$item0 = $dom->getElementsByTagNameNS($officeNs, 'body')->item(0);
		$spreadsheets = ($item0 === null) ? [] : $item0->getElementsByTagNameNS($officeNs, 'spreadsheet');

		foreach ($spreadsheets as $workbookData) {
			/** @var DOMElement $workbookData */
			$tables = $workbookData->getElementsByTagNameNS($tableNs, 'table');

			$worksheetID = 0;
			$sheetCreated = false;
			foreach ($tables as $worksheetDataSet) {
				/** @var DOMElement $worksheetDataSet */
				$worksheetName = $worksheetDataSet->getAttributeNS($tableNs, 'name');

				// Check loadSheetsOnly
				if (
					$this->loadSheetsOnly !== null
					&& $worksheetName
					&& !in_array($worksheetName, $this->loadSheetsOnly)
				) {
					continue;
				}

				$worksheetStyleName = $worksheetDataSet->getAttributeNS($tableNs, 'style-name');

				// Create sheet
				$spreadsheet->createSheet();
				$sheetCreated = true;
				$spreadsheet->setActiveSheetIndex($worksheetID);

				if ($worksheetName || is_numeric($worksheetName)) {
					// Use false for $updateFormulaCellReferences to prevent adjustment of worksheet references in
					// formula cells... during the load, all formulae should be correct, and we're simply
					// bringing the worksheet name in line with the formula, not the reverse
					$spreadsheet->getActiveSheet()
						->setTitle((string) $worksheetName, false, false);
				}

				// Go through every child of table element
				$rowID = 1;
				$tableColumnIndex = 1;
				$this->highestDataIndex = AddressRange::MAX_COLUMN_INT;
				foreach ($worksheetDataSet->childNodes as $childNode) {
					/** @var DOMElement $childNode */

					// Filter elements which are not under the "table" ns
					if ($childNode->namespaceURI != $tableNs) {
						continue;
					}

					$key = self::extractNodeName($childNode->nodeName);

					switch ($key) {
						case 'table-header-rows':
						case 'table-rows':
							$this->processTableHeaderRows(
								$childNode,
								$tableNs,
								$rowID,
								$worksheetName,
								$officeNs,
								$textNs,
								$xlinkNs,
								$spreadsheet
							);

							break;
						case 'table-row-group':
							$this->processTableRowGroup(
								$childNode,
								$tableNs,
								$rowID,
								$worksheetName,
								$officeNs,
								$textNs,
								$xlinkNs,
								$spreadsheet
							);

							break;
						case 'table-row':
							$this->processTableRow(
								$childNode,
								$tableNs,
								$rowID,
								$worksheetName,
								$officeNs,
								$textNs,
								$xlinkNs,
								$spreadsheet
							);

							break;
						case 'table-header-columns':
						case 'table-columns':
							$this->processTableColumnHeader(
								$childNode,
								$tableNs,
								$columnWidths,
								$tableColumnIndex,
								$spreadsheet,
								$this->readEmptyCells,
								true
							);

							break;
						case 'table-column-group':
							$this->processTableColumnGroup(
								$childNode,
								$tableNs,
								$columnWidths,
								$tableColumnIndex,
								$spreadsheet,
								$this->readEmptyCells,
								true
							);

							break;
						case 'table-column':
							$this->processTableColumn(
								$childNode,
								$tableNs,
								$columnWidths,
								$tableColumnIndex,
								$spreadsheet,
								$this->readEmptyCells,
								true
							);

							break;
					}
				}
				$pageSettings->setVisibilityForWorksheet(
					$spreadsheet->getActiveSheet(),
					$worksheetStyleName
				);
				$pageSettings->setPrintSettingsForWorksheet(
					$spreadsheet->getActiveSheet(),
					$worksheetStyleName
				);
				++$worksheetID;
			}
			if ($this->createBlankSheetIfNoneRead && !$sheetCreated) {
				$spreadsheet->createSheet();
			}
		}

		foreach ($spreadsheets as $workbookData) {
			/** @var DOMElement $workbookData */
			$tables = $workbookData->getElementsByTagNameNS($tableNs, 'table');

			$worksheetID = 0;
			foreach ($tables as $worksheetDataSet) {
				/** @var DOMElement $worksheetDataSet */
				$worksheetName = $worksheetDataSet->getAttributeNS($tableNs, 'name');

				// Check loadSheetsOnly
				if (
					$this->loadSheetsOnly !== null
					&& $worksheetName
					&& !in_array($worksheetName, $this->loadSheetsOnly)
				) {
					continue;
				}

				// Create sheet
				$spreadsheet->setActiveSheetIndex($worksheetID);
				$highestDataColumn = $spreadsheet->getActiveSheet()->getHighestDataColumn();
				$this->highestDataIndex = Coordinate::columnIndexFromString($highestDataColumn);

				// Go through every child of table element processing column widths
				$rowID = 1;
				$tableColumnIndex = 1;
				foreach ($worksheetDataSet->childNodes as $childNode) {
					/** @var DOMElement $childNode */
					if (empty($columnWidths) || $this->readEmptyCells) {
						break;
					}

					// Filter elements which are not under the "table" ns
					if ($childNode->namespaceURI != $tableNs) {
						continue;
					}

					$key = self::extractNodeName($childNode->nodeName);

					switch ($key) {
						case 'table-header-columns':
						case 'table-columns':
							$this->processTableColumnHeader(
								$childNode,
								$tableNs,
								$columnWidths,
								$tableColumnIndex,
								$spreadsheet,
								true,
								false
							);

							break;
						case 'table-column-group':
							$this->processTableColumnGroup(
								$childNode,
								$tableNs,
								$columnWidths,
								$tableColumnIndex,
								$spreadsheet,
								true,
								false
							);

							break;
						case 'table-column':
							$this->processTableColumn(
								$childNode,
								$tableNs,
								$columnWidths,
								$tableColumnIndex,
								$spreadsheet,
								true,
								false
							);

							break;
					}
				}
				++$worksheetID;
			}

			$autoFilterReader->read($workbookData);
			$definedNameReader->read($workbookData);
		}

		$spreadsheet->setActiveSheetIndex(0);

		if ($zip->locateName('settings.xml') !== false) {
			$this->processSettings($zip, $spreadsheet);
		}

		// Return
		return $spreadsheet;
	}

	private function processTableHeaderRows(
		DOMElement $childNode,
		string $tableNs,
		int &$rowID,
		string $worksheetName,
		string $officeNs,
		string $textNs,
		string $xlinkNs,
		Spreadsheet $spreadsheet
	): void {
		foreach ($childNode->childNodes as $grandchildNode) {
			/** @var DOMElement $grandchildNode */
			$grandkey = self::extractNodeName($grandchildNode->nodeName);
			switch ($grandkey) {
				case 'table-row':
					$this->processTableRow(
						$grandchildNode,
						$tableNs,
						$rowID,
						$worksheetName,
						$officeNs,
						$textNs,
						$xlinkNs,
						$spreadsheet
					);

					break;
			}
		}
	}

	private function processTableRowGroup(
		DOMElement $childNode,
		string $tableNs,
		int &$rowID,
		string $worksheetName,
		string $officeNs,
		string $textNs,
		string $xlinkNs,
		Spreadsheet $spreadsheet
	): void {
		foreach ($childNode->childNodes as $grandchildNode) {
			/** @var DOMElement $grandchildNode */
			$grandkey = self::extractNodeName($grandchildNode->nodeName);
			switch ($grandkey) {
				case 'table-row':
					$this->processTableRow(
						$grandchildNode,
						$tableNs,
						$rowID,
						$worksheetName,
						$officeNs,
						$textNs,
						$xlinkNs,
						$spreadsheet
					);

					break;
				case 'table-header-rows':
				case 'table-rows':
					$this->processTableHeaderRows(
						$grandchildNode,
						$tableNs,
						$rowID,
						$worksheetName,
						$officeNs,
						$textNs,
						$xlinkNs,
						$spreadsheet
					);

					break;
				case 'table-row-group':
					$this->processTableRowGroup(
						$grandchildNode,
						$tableNs,
						$rowID,
						$worksheetName,
						$officeNs,
						$textNs,
						$xlinkNs,
						$spreadsheet
					);

					break;
			}
		}
	}

	private function processTableRow(
		DOMElement $childNode,
		string $tableNs,
		int &$rowID,
		string $worksheetName,
		string $officeNs,
		string $textNs,
		string $xlinkNs,
		Spreadsheet $spreadsheet
	): void {
		if ($childNode->hasAttributeNS($tableNs, 'number-rows-repeated')) {
			$rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-rows-repeated');
		} else {
			$rowRepeats = 1;
		}
		$worksheet = $spreadsheet->getSheetByName($worksheetName);

		$columnID = 'A';
		/** @var DOMElement|DOMText $cellData */
		foreach ($childNode->childNodes as $cellData) {
			if ($cellData instanceof DOMText) {
				continue; // should just be whitespace
			}
			if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) {
				$colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated');
			} else {
				$colRepeats = 1;
			}
			$styleName = $cellData->getAttributeNS($tableNs, 'style-name');

			// When a cell has number-columns-repeated, check if ANY column in the
			// repeated range passes the read filter. If not, skip the entire group.
			// If some columns pass, we need to fall through to the processing block
			// which will handle per-column filtering.
			if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) {
				if ($colRepeats <= 1) {
					StringHelper::stringIncrement($columnID);

					continue;
				}

				// Check if any column within this repeated group passes the filter
				$anyColumnPasses = false;
				$tempCol = $columnID;
				for ($i = 0; $i < $colRepeats; ++$i) {
					if ($i > 0) {
						StringHelper::stringIncrement($tempCol);
					}
					if ($this->getReadFilter()->readCell($tempCol, $rowID, $worksheetName)) {
						$anyColumnPasses = true;

						break;
					}
				}

				if (!$anyColumnPasses) {
					for ($i = 0; $i < $colRepeats; ++$i) {
						StringHelper::stringIncrement($columnID);
					}

					continue;
				}
				// Fall through to process the cell, with per-column filter checks
			}
			if ($worksheet !== null && ($cellData->hasChildNodes() || ($cellData->nextSibling !== null)) && isset($this->allStyles[$styleName])) {
				$spannedRange = "$columnID$rowID";
				// the following is sufficient for ods,
				// and does no harm for xlsx/xls.
				$worksheet->getStyle($spannedRange)
					->applyFromArray($this->allStyles[$styleName]);
				// the rest of this block is needed for xlsx/xls,
				// and does no harm for ods.
				if (isset($this->allStyles[$styleName]['borders'])) {
					$spannedRows = $cellData->getAttributeNS($tableNs, 'number-columns-spanned');
					$spannedColumns = $cellData->getAttributeNS($tableNs, 'number-rows-spanned');
					$spannedRows = max((int) $spannedRows, 1);
					$spannedColumns = max((int) $spannedColumns, 1);
					if ($spannedRows > 1 || $spannedColumns > 1) {
						$endRow = $rowID + $spannedRows - 1;
						$endCol = $columnID;
						while ($spannedColumns > 1) {
							StringHelper::stringIncrement($endCol);
							--$spannedColumns;
						}
						$spannedRange .= ":$endCol$endRow";
						$worksheet->getStyle($spannedRange)
							->getBorders()
							->applyFromArray(
								$this->allStyles[$styleName]['borders']
							);
					}
				}
			}

			// Initialize variables
			$formatting = $hyperlink = null;
			$hasCalculatedValue = false;
			$cellDataFormula = '';
			$cellDataType = '';
			$cellDataRef = '';

			if ($cellData->hasAttributeNS($tableNs, 'formula')) {
				$cellDataFormula = $cellData->getAttributeNS($tableNs, 'formula');
				$hasCalculatedValue = true;
			}
			if ($cellData->hasAttributeNS($tableNs, 'number-matrix-columns-spanned')) {
				if ($cellData->hasAttributeNS($tableNs, 'number-matrix-rows-spanned')) {
					$cellDataType = 'array';
					$arrayRow = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-rows-spanned');
					$arrayCol = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-columns-spanned');
					$lastRow = $rowID + $arrayRow - 1;
					$lastCol = $columnID;
					while ($arrayCol > 1) {
						StringHelper::stringIncrement($lastCol);
						--$arrayCol;
					}
					$cellDataRef = "$columnID$rowID:$lastCol$lastRow";
				}
			}

			// Annotations
			$annotation = $cellData->getElementsByTagNameNS($officeNs, 'annotation');

			if ($annotation->length > 0 && $annotation->item(0) !== null) {
				$textNode = $annotation->item(0)->getElementsByTagNameNS($textNs, 'p');
				$textNodeLength = $textNode->length;
				$newLineOwed = false;
				for ($textNodeIndex = 0; $textNodeIndex < $textNodeLength; ++$textNodeIndex) {
					$textNodeItem = $textNode->item($textNodeIndex);
					if ($textNodeItem !== null) {
						$text = $this->scanElementForText($textNodeItem);
						if ($newLineOwed) {
							$spreadsheet->getActiveSheet()
								->getComment($columnID . $rowID)
								->getText()
								->createText("\n");
						}
						$newLineOwed = true;

						$spreadsheet->getActiveSheet()
							->getComment($columnID . $rowID)
							->getText()
							->createText(
								$this->parseRichText($text)
							);
					}
				}
			}

			// Content

			/** @var DOMElement[] $paragraphs */
			$paragraphs = [];

			foreach ($cellData->childNodes as $item) {
				/** @var DOMElement $item */

				// Filter text:p elements
				if ($item->nodeName == 'text:p') {
					$paragraphs[] = $item;
				}
			}

			if (count($paragraphs) > 0) {
				$dataValue = null;
				// Consolidate if there are multiple p records (maybe with spans as well)
				$dataArray = [];

				// Text can have multiple text:p and within those, multiple text:span.
				// text:p newlines, but text:span does not.
				// Also, here we assume there is no text data is span fields are specified, since
				// we have no way of knowing proper positioning anyway.

				foreach ($paragraphs as $pData) {
					$dataArray[] = $this->scanElementForText($pData);
				}
				$allCellDataText = implode("\n", $dataArray);

				$type = $cellData->getAttributeNS($officeNs, 'value-type');
				$symbol = '';
				$leftHandCurrency = Preg::isMatch('/\$|£|￥/', $allCellDataText, $matches);
				if ($leftHandCurrency) {
					$type = str_replace('float', 'currency', $type);
					$symbol = (string) $matches[0];
				}
				$customFormatting = '';
				if ($this->formatCallback !== null) {
					$temp = ($this->formatCallback)($type, $allCellDataText);
					if ($temp !== '') {
						$customFormatting = $temp;
					}
				}

				switch ($type) {
					case 'string':
						$type = DataType::TYPE_STRING;
						$dataValue = $allCellDataText;

						foreach ($paragraphs as $paragraph) {
							$link = $paragraph->getElementsByTagNameNS($textNs, 'a');
							if ($link->length > 0 && $link->item(0) !== null) {
								$hyperlink = $link->item(0)->getAttributeNS($xlinkNs, 'href');
							}
						}

						break;
					case 'boolean':
						$type = DataType::TYPE_BOOL;
						$dataValue = ($cellData->getAttributeNS($officeNs, 'boolean-value') === 'true') ? true : false;

						break;
					case 'percentage':
						if (!str_contains($allCellDataText, '.')) {
							$formatting = NumberFormat::FORMAT_PERCENTAGE;
						} elseif (substr($allCellDataText, -3, 1) === '.') {
							$formatting = NumberFormat::FORMAT_PERCENTAGE_0;
						} else {
							$formatting = NumberFormat::FORMAT_PERCENTAGE_00;
						}
						$type = DataType::TYPE_NUMERIC;
						$dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');

						break;
					case 'currency':
						$type = DataType::TYPE_NUMERIC;
						$dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');

						$currency = $cellData->getAttributeNS($officeNs, 'currency');
						if ($leftHandCurrency) {
							$typeValue = 'currency';
							$formatting = str_contains($allCellDataText, '.') ? NumberFormat::FORMAT_CURRENCY_USD : NumberFormat::FORMAT_CURRENCY_USD_INTEGER;
							if ($symbol !== '$') {
								$formatting = str_replace('$', $symbol, $formatting);
							}
						} elseif (str_contains($allCellDataText, '€')) {
							$typeValue = 'currency';
							$formatting = str_contains($allCellDataText, '.') ? NumberFormat::FORMAT_CURRENCY_EUR : NumberFormat::FORMAT_CURRENCY_EUR_INTEGER;
						}

						break;
					case 'float':
						$type = DataType::TYPE_NUMERIC;
						$dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');

						if ($dataValue !== floor($dataValue)) {
							// do nothing
						} elseif (substr($allCellDataText, -2, 1) === '.') {
							$formatting = NumberFormat::FORMAT_NUMBER_0;
						} elseif (substr($allCellDataText, -3, 1) === '.') {
							$formatting = NumberFormat::FORMAT_NUMBER_00;
						}
						if (floor($dataValue) == $dataValue) {
							if ($dataValue == (int) $dataValue) {
								$dataValue = (int) $dataValue;
							}
						}

						break;
					case 'date':
						$type = DataType::TYPE_NUMERIC;
						$value = $cellData->getAttributeNS($officeNs, 'date-value');
						$dataValue = Date::convertIsoDate($value);

						if (Preg::isMatch('/^\d\d\d\d-\d\d-\d\d$/', $allCellDataText)) {
							$formatting = 'yyyy-mm-dd';
						} elseif (Preg::isMatch('/^\d\d?-[a-zA-Z]+-\d\d\d\d$/', $allCellDataText)) {
							$formatting = 'd-mmm-yyyy';
						} elseif ($dataValue != floor($dataValue)) {
							$formatting = NumberFormat::FORMAT_DATE_XLSX15
								. ' '
								. NumberFormat::FORMAT_DATE_TIME4;
						} else {
							$formatting = NumberFormat::FORMAT_DATE_XLSX15;
						}

						break;
					case 'time':
						$type = DataType::TYPE_NUMERIC;

						$timeValue = $cellData->getAttributeNS($officeNs, 'time-value');
						$minus = '';
						if (str_starts_with($timeValue, '-')) {
							$minus = '-';
							$timeValue = (string) substr($timeValue, 1);
						}
						$timeArray = sscanf($timeValue, 'PT%dH%dM%dS');
						if (is_array($timeArray)) {
							/** @var array{int, int, int} $timeArray */
							$days = intdiv($timeArray[0], 24);
							$hours = $timeArray[0] % 24;
							$dt = new DateTime("1899-12-30 $hours:{$timeArray[1]}:{$timeArray[2]}", new DateTimeZone('UTC'));
							$dt->modify("+$days days");
							$dataValue = Date::PHPToExcel($dt);
							if ($minus === '-') {
								$dataValue *= -1;
								$formatting = '[hh]:mm:ss';
							} else {
								$formatting = NumberFormat::FORMAT_DATE_TIME4;
							}
						}

						break;
					default:
						$dataValue = null;
				}
				if ($customFormatting !== '') {
					$formatting = $customFormatting;
				}
			} else {
				$type = DataType::TYPE_NULL;
				$dataValue = null;
			}

			if ($hasCalculatedValue) {
				$type = DataType::TYPE_FORMULA;
				$cellDataFormula = (string) substr($cellDataFormula, strpos($cellDataFormula, ':=') + 1);
				$cellDataFormula = FormulaTranslator::convertToExcelFormulaValue($cellDataFormula);
			}

			for ($i = 0; $i < $colRepeats; ++$i) {
				if ($i > 0) {
					StringHelper::stringIncrement($columnID);
				}

				if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) {
					continue;
				}

				if ($type !== DataType::TYPE_NULL) {
					for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) {
						$rID = $rowID + $rowAdjust;

						$cell = $spreadsheet->getActiveSheet()
							->getCell($columnID . $rID);

						// Set value
						if ($hasCalculatedValue) {
							$cell->setValueExplicit($cellDataFormula, $type);
							if ($cellDataType === 'array') {
								$cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]);
							}
						} elseif ($type !== '' || $dataValue !== null) {
							$cell->setValueExplicit($dataValue, $type);
						}

						if ($hasCalculatedValue) {
							$cell->setCalculatedValue($dataValue, $type === DataType::TYPE_NUMERIC);
						}

						// Set other properties
						if ($formatting !== null) {
							$spreadsheet->getActiveSheet()
								->getStyle($columnID . $rID)
								->getNumberFormat()
								->setFormatCode($formatting);
						} else {
							$spreadsheet->getActiveSheet()
								->getStyle($columnID . $rID)
								->getNumberFormat()
								->setFormatCode(NumberFormat::FORMAT_GENERAL);
						}

						if ($hyperlink !== null) {
							if ($hyperlink[0] === '#') {
								$hyperlink = 'sheet://' . substr($hyperlink, 1);
							}
							$cell->getHyperlink()
								->setUrl($hyperlink);
						}
					}
				}
			}

			// Merged cells
			$this->processMergedCells($cellData, $tableNs, $type, $columnID, $rowID, $spreadsheet);

			StringHelper::stringIncrement($columnID);
		}
		$rowID += $rowRepeats;
	}

	private static function extractNodeName(string $key): string
	{
		// Remove ns from node name
		if (str_contains($key, ':')) {
			$keyChunks = explode(':', $key);
			$key = array_pop($keyChunks);
		}

		return $key;
	}

	/**
	 * @param string[] $columnWidths
	 */
	private function processTableColumnHeader(
		DOMElement $childNode,
		string $tableNs,
		array $columnWidths,
		int &$tableColumnIndex,
		Spreadsheet $spreadsheet,
		bool $processWidths = true,
		bool $processStyles = true
	): void {
		foreach ($childNode->childNodes as $grandchildNode) {
			/** @var DOMElement $grandchildNode */
			$grandkey = self::extractNodeName($grandchildNode->nodeName);
			switch ($grandkey) {
				case 'table-column':
					$this->processTableColumn(
						$grandchildNode,
						$tableNs,
						$columnWidths,
						$tableColumnIndex,
						$spreadsheet,
						$processWidths,
						$processStyles
					);

					break;
			}
		}
	}

	/**
	 * @param string[] $columnWidths
	 */
	private function processTableColumnGroup(
		DOMElement $childNode,
		string $tableNs,
		array $columnWidths,
		int &$tableColumnIndex,
		Spreadsheet $spreadsheet,
		bool $processWidths = true,
		bool $processStyles = true
	): void {
		foreach ($childNode->childNodes as $grandchildNode) {
			/** @var DOMElement $grandchildNode */
			$grandkey = self::extractNodeName($grandchildNode->nodeName);
			switch ($grandkey) {
				case 'table-column':
					$this->processTableColumn(
						$grandchildNode,
						$tableNs,
						$columnWidths,
						$tableColumnIndex,
						$spreadsheet,
						$processWidths,
						$processStyles
					);

					break;
				case 'table-header-columns':
				case 'table-columns':
					$this->processTableColumnHeader(
						$grandchildNode,
						$tableNs,
						$columnWidths,
						$tableColumnIndex,
						$spreadsheet,
						$processWidths,
						$processStyles
					);

					break;
				case 'table-column-group':
					$this->processTableColumnGroup(
						$grandchildNode,
						$tableNs,
						$columnWidths,
						$tableColumnIndex,
						$spreadsheet,
						$processWidths,
						$processStyles
					);

					break;
			}
		}
	}

	/**
	 * @param string[] $columnWidths
	 */
	private function processTableColumn(
		DOMElement $childNode,
		string $tableNs,
		array $columnWidths,
		int &$tableColumnIndex,
		Spreadsheet $spreadsheet,
		bool $processWidths = true,
		bool $processStyles = true
	): void {
		if ($childNode->hasAttributeNS($tableNs, 'number-columns-repeated')) {
			$rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-columns-repeated');
		} else {
			$rowRepeats = 1;
		}
		$tableStyleName = $childNode->getAttributeNS($tableNs, 'style-name');
		if ($processWidths) {
			if (isset($columnWidths[$tableStyleName])) {
				$columnWidth = new HelperDimension($columnWidths[$tableStyleName]);
				$tableColumnIndex2 = $tableColumnIndex;
				$tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex2);
				for ($rowRepeats2 = $rowRepeats; $rowRepeats2 > 0 && $tableColumnIndex2 <= AddressRange::MAX_COLUMN_INT; --$rowRepeats2) {
					if (!$this->readEmptyCells && $tableColumnIndex2 > $this->highestDataIndex) {
						break;
					}
					$spreadsheet->getActiveSheet()
						->getColumnDimension($tableColumnString)
						->setWidth($columnWidth->toUnit('cm'), 'cm');
					StringHelper::stringIncrement(
						$tableColumnString
					);
					++$tableColumnIndex2;
				}
			}
		}
		if ($processStyles) {
			$defaultStyleName = $childNode->getAttributeNS($tableNs, 'default-cell-style-name');
			if ($defaultStyleName !== 'Default' && isset($this->allStyles[$defaultStyleName])) {
				$tableColumnIndex2 = $tableColumnIndex;
				$tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex2);
				for ($rowRepeats2 = $rowRepeats; $rowRepeats2 > 0 && $tableColumnIndex2 <= AddressRange::MAX_COLUMN_INT; --$rowRepeats2) {
					$spreadsheet->getActiveSheet()
						->getStyle($tableColumnString)
						->applyFromArray(
							$this->allStyles[$defaultStyleName]
						);
					StringHelper::stringIncrement(
						$tableColumnString
					);
					++$tableColumnIndex2;
				}
			}
		}
		$tableColumnIndex += $rowRepeats;
	}

	private function processSettings(ZipArchive $zip, Spreadsheet $spreadsheet): void
	{
		$dom = new DOMDocument('1.01', 'UTF-8');
		$dom->loadXML(
			$this->getSecurityScannerOrThrow()
				->scan($zip->getFromName('settings.xml'))
		);
		$configNs = (string) $dom->lookupNamespaceUri('config');
		$officeNs = (string) $dom->lookupNamespaceUri('office');
		$settings = $dom->getElementsByTagNameNS($officeNs, 'settings')
			->item(0);
		if ($settings !== null) {
			$this->lookForActiveSheet($settings, $spreadsheet, $configNs);
			$this->lookForSelectedCells($settings, $spreadsheet, $configNs);
		}
	}

	private function lookForActiveSheet(DOMElement $settings, Spreadsheet $spreadsheet, string $configNs): void
	{
		/** @var DOMElement $t */
		foreach ($settings->getElementsByTagNameNS($configNs, 'config-item') as $t) {
			if ($t->getAttributeNs($configNs, 'name') === 'ActiveTable') {
				try {
					$spreadsheet->setActiveSheetIndexByName($t->nodeValue ?? '');
				} catch (Throwable $exception) {
					// do nothing
				}

				break;
			}
		}
	}

	private function lookForSelectedCells(DOMElement $settings, Spreadsheet $spreadsheet, string $configNs): void
	{
		/** @var DOMElement $t */
		foreach ($settings->getElementsByTagNameNS($configNs, 'config-item-map-named') as $t) {
			if ($t->getAttributeNs($configNs, 'name') === 'Tables') {
				foreach ($t->getElementsByTagNameNS($configNs, 'config-item-map-entry') as $ws) {
					$setRow = $setCol = '';
					$wsname = $ws->getAttributeNs($configNs, 'name');
					foreach ($ws->getElementsByTagNameNS($configNs, 'config-item') as $configItem) {
						$attrName = $configItem->getAttributeNs($configNs, 'name');
						if ($attrName === 'CursorPositionX') {
							$setCol = $configItem->nodeValue;
						}
						if ($attrName === 'CursorPositionY') {
							$setRow = $configItem->nodeValue;
						}
					}
					$this->setSelected($spreadsheet, $wsname, "$setCol", "$setRow");
				}

				break;
			}
		}
	}

	private function setSelected(Spreadsheet $spreadsheet, string $wsname, string $setCol, string $setRow): void
	{
		if (is_numeric($setCol) && is_numeric($setRow)) {
			$sheet = $spreadsheet->getSheetByName($wsname);
			if ($sheet !== null) {
				$sheet->setSelectedCells([(int) $setCol + 1, (int) $setRow + 1]);
			}
		}
	}

	/**
	 * Recursively scan element.
	 */
	protected function scanElementForText(DOMNode $element): string
	{
		$str = '';
		foreach ($element->childNodes as $child) {
			/** @var DOMNode $child */
			if ($child->nodeType == XML_TEXT_NODE) {
				$str .= $child->nodeValue;
			} elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:line-break') {
				$str .= "\n";
			} elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:s') {
				// It's a space

				// Multiple spaces?
				$attributes = $child->attributes;
				/** @var ?DOMAttr $cAttr */
				$cAttr = ($attributes === null) ? null : $attributes->getNamedItem('c');
				$multiplier = self::getMultiplier($cAttr);
				$str .= str_repeat(' ', $multiplier);
			}

			if ($child->hasChildNodes()) {
				$str .= $this->scanElementForText($child);
			}
		}

		return $str;
	}

	private static function getMultiplier(?DOMAttr $cAttr): int
	{
		if ($cAttr) {
			$multiplier = (int) $cAttr->nodeValue;
		} else {
			$multiplier = 1;
		}

		return $multiplier;
	}

	private function parseRichText(string $is): RichText
	{
		$value = new RichText();
		$value->createText($is);

		return $value;
	}

	private function processMergedCells(
		DOMElement $cellData,
		string $tableNs,
		string $type,
		string $columnID,
		int $rowID,
		Spreadsheet $spreadsheet
	): void {
		if (
			$cellData->hasAttributeNS($tableNs, 'number-columns-spanned')
			|| $cellData->hasAttributeNS($tableNs, 'number-rows-spanned')
		) {
			if (($type !== DataType::TYPE_NULL) || ($this->readDataOnly === false)) {
				$columnTo = $columnID;

				if ($cellData->hasAttributeNS($tableNs, 'number-columns-spanned')) {
					$columnIndex = Coordinate::columnIndexFromString($columnID);
					$columnIndex += (int) $cellData->getAttributeNS($tableNs, 'number-columns-spanned');
					$columnIndex -= 2;

					$columnTo = Coordinate::stringFromColumnIndex($columnIndex + 1);
				}

				$rowTo = $rowID;

				if ($cellData->hasAttributeNS($tableNs, 'number-rows-spanned')) {
					$rowTo = $rowTo + (int) $cellData->getAttributeNS($tableNs, 'number-rows-spanned') - 1;
				}

				$cellRange = $columnID . $rowID . ':' . $columnTo . $rowTo;
				$spreadsheet->getActiveSheet()->mergeCells($cellRange, Worksheet::MERGE_CELL_CONTENT_HIDE);
			}
		}
	}

	/** @var null|Closure(string, string):string */
	private ?Closure $formatCallback = null;

	/** @param Closure(string, string):string $formatCallback */
	public function setFormatCallback(Closure $formatCallback): void
	{
		$this->formatCallback = $formatCallback;
	}

	/** @return array{
	 *   autoColor?: true,
	 *   bold?: true,
	 *   color?: array{rgb: string},
	 *   italic?: true,
	 *   name?: non-empty-string,
	 *   size?: float|int,
	 *   strikethrough?: true,
	 *   underline?: 'double'|'single',
	 * }
	 */
	protected function getFontStyles(DOMElement $textProperty, string $styleNs, string $fontNs): array
	{
		$fonts = [];
		$temp = $textProperty->getAttributeNs($styleNs, 'font-name') ?: $textProperty->getAttributeNs($fontNs, 'font-family');
		if ($temp !== '') {
			$fonts['name'] = $temp;
		}
		$temp = $textProperty->getAttributeNs($fontNs, 'font-size');
		if ($temp !== '' && str_ends_with($temp, 'pt')) {
			$fonts['size'] = (float) substr($temp, 0, -2);
		}
		$temp = $textProperty->getAttributeNs($fontNs, 'font-style');
		if ($temp === 'italic') {
			$fonts['italic'] = true;
		}
		$temp = $textProperty->getAttributeNs($fontNs, 'font-weight');
		if ($temp === 'bold') {
			$fonts['bold'] = true;
		}
		$temp = $textProperty->getAttributeNs($fontNs, 'color');
		if (Preg::isMatch('/^#[a-f0-9]{6}$/i', $temp)) {
			$fonts['color'] = ['rgb' => (string) substr($temp, 1)];
		}
		$temp = $textProperty->getAttributeNs($styleNs, 'use-window-font-color');
		if ($temp === 'true') {
			$fonts['autoColor'] = true;
		}
		$temp = $textProperty->getAttributeNs($styleNs, 'text-underline-type');
		if ($temp === '') {
			$temp = $textProperty->getAttributeNs($styleNs, 'text-underline-style');
			if ($temp !== '' && $temp !== 'none') {
				$temp = 'single';
			}
		}
		if ($temp === 'single' || $temp === 'double') {
			$fonts['underline'] = $temp;
		}
		$temp = $textProperty->getAttributeNs($styleNs, 'text-line-through-type');
		if ($temp !== '' && $temp !== 'none') {
			$fonts['strikethrough'] = true;
		}

		return $fonts;
	}

	/** @return array{
	 *   fillType?: string,
	 *   startColor?: array{rgb: string},
	 * }
	 */
	protected function getFillStyles(DOMElement $tableCellProperties, string $fontNs): array
	{
		$fills = [];
		$temp = $tableCellProperties->getAttributeNs($fontNs, 'background-color');
		if (Preg::isMatch('/^#[a-f0-9]{6}$/i', $temp)) {
			$fills['fillType'] = Fill::FILL_SOLID;
			$fills['startColor'] = ['rgb' => (string) substr($temp, 1)];
		} elseif ($temp === 'transparent') {
			$fills['fillType'] = Fill::FILL_NONE;
		}

		return $fills;
	}

	private const MAP_VERTICAL = [
		'top' => Alignment::VERTICAL_TOP,
		'middle' => Alignment::VERTICAL_CENTER,
		'automatic' => Alignment::VERTICAL_JUSTIFY,
		'bottom' => Alignment::VERTICAL_BOTTOM,
	];
	private const MAP_HORIZONTAL = [
		'center' => Alignment::HORIZONTAL_CENTER,
		'end' => Alignment::HORIZONTAL_RIGHT,
		'justify' => Alignment::HORIZONTAL_FILL,
		'start' => Alignment::HORIZONTAL_LEFT,
	];

	/** @return array{
	 *   shrinkToFit?: bool,
	 *   textRotation?: int,
	 *   vertical?: string,
	 *   wrapText?: bool,
	 * }
	 */
	protected function getAlignment1Styles(DOMElement $tableCellProperties, string $styleNs, string $fontNs): array
	{
		$alignment1 = [];
		$temp = $tableCellProperties->getAttributeNs($styleNs, 'rotation-angle');
		if (is_numeric($temp)) {
			$temp2 = (int) $temp;
			if ($temp2 > 90) {
				$temp2 -= 360;
			}
			if ($temp2 >= -90 && $temp2 <= 90) {
				$alignment1['textRotation'] = (int) $temp2;
			}
		}
		$temp = $tableCellProperties->getAttributeNs($styleNs, 'vertical-align');
		$temp2 = self::MAP_VERTICAL[$temp] ?? '';
		if ($temp2 !== '') {
			$alignment1['vertical'] = $temp2;
		}
		$temp = $tableCellProperties->getAttributeNs($fontNs, 'wrap-option');
		if ($temp === 'wrap') {
			$alignment1['wrapText'] = true;
		} elseif ($temp === 'no-wrap') {
			$alignment1['wrapText'] = false;
		}
		$temp = $tableCellProperties->getAttributeNs($styleNs, 'shrink-to-fit');
		if ($temp === 'true' || $temp === 'false') {
			$alignment1['shrinkToFit'] = $temp === 'true';
		}

		return $alignment1;
	}

	/** @return array{
	 *   horizontal?: string,
	 *   readOrder?: int,
	 * }
	 */
	protected function getAlignment2Styles(DOMElement $paragraphProperties, string $styleNs, string $fontNs): array
	{
		$alignment2 = [];
		$temp = $paragraphProperties->getAttributeNs($fontNs, 'text-align');
		$temp2 = self::MAP_HORIZONTAL[$temp] ?? '';
		if ($temp2 !== '') {
			$alignment2['horizontal'] = $temp2;
		}
		$temp = $paragraphProperties->getAttributeNs($fontNs, 'margin-left') ?: $paragraphProperties->getAttributeNs($fontNs, 'margin-right');
		if (Preg::isMatch('/^\d+([.]\d+)?(cm|in|mm|pt)$/', $temp)) {
			$dimension = new HelperDimension($temp);
			$alignment2['indent'] = (int) round($dimension->toUnit('px') / Alignment::INDENT_UNITS_TO_PIXELS);
		}

		$temp = $paragraphProperties->getAttributeNs($styleNs, 'writing-mode');
		if ($temp === 'rl-tb') {
			$alignment2['readOrder'] = Alignment::READORDER_RTL;
		} elseif ($temp === 'lr-tb') {
			$alignment2['readOrder'] = Alignment::READORDER_LTR;
		}

		return $alignment2;
	}

	/** @return array{
	 *   locked?: string,
	 *   hidden?: string,
	 * }
	 */
	protected function getProtectionStyles(DOMElement $tableCellProperties, string $styleNs): array
	{
		$protection = [];
		$temp = $tableCellProperties->getAttributeNs($styleNs, 'cell-protect');
		switch ($temp) {
			case 'protected formula-hidden':
				$protection['locked'] = Protection::PROTECTION_PROTECTED;
				$protection['hidden'] = Protection::PROTECTION_PROTECTED;

				break;
			case 'formula-hidden':
				$protection['locked'] = Protection::PROTECTION_UNPROTECTED;
				$protection['hidden'] = Protection::PROTECTION_PROTECTED;

				break;
			case 'protected':
				$protection['locked'] = Protection::PROTECTION_PROTECTED;
				$protection['hidden'] = Protection::PROTECTION_UNPROTECTED;

				break;
			case 'none':
				$protection['locked'] = Protection::PROTECTION_UNPROTECTED;
				$protection['hidden'] = Protection::PROTECTION_UNPROTECTED;

				break;
		}

		return $protection;
	}

	private const MAP_BORDER_STYLE = [ // default BORDER_THIN
		'none' => Border::BORDER_NONE,
		'hidden' => Border::BORDER_NONE,
		'dotted' => Border::BORDER_DOTTED,
		'dash-dot' => Border::BORDER_DASHDOT,
		'dash-dot-dot' => Border::BORDER_DASHDOTDOT,
		'dashed' => Border::BORDER_DASHED,
		'double' => Border::BORDER_DOUBLE,
	];

	private const MAP_BORDER_MEDIUM = [
		Border::BORDER_THIN => Border::BORDER_MEDIUM,
		Border::BORDER_DASHDOT => Border::BORDER_MEDIUMDASHDOT,
		Border::BORDER_DASHDOTDOT => Border::BORDER_MEDIUMDASHDOTDOT,
		Border::BORDER_DASHED => Border::BORDER_MEDIUMDASHED,
	];

	private const MAP_BORDER_THICK = [
		Border::BORDER_THIN => Border::BORDER_THICK,
		Border::BORDER_DASHDOT => Border::BORDER_MEDIUMDASHDOT,
		Border::BORDER_DASHDOTDOT => Border::BORDER_MEDIUMDASHDOTDOT,
		Border::BORDER_DASHED => Border::BORDER_MEDIUMDASHED,
	];

	/** @return array{
	 *   bottom?: array{borderStyle:string, color:array{rgb: string}},
	 *   top?: array{borderStyle:string, color:array{rgb: string}},
	 *   left?: array{borderStyle:string, color:array{rgb: string}},
	 *   right?: array{borderStyle:string, color:array{rgb: string}},
	 *   diagonal?: array{borderStyle:string, color:array{rgb: string}},
	 *   diagonalDirection?: int,
	 * }
	 */
	protected function getBorderStyles(DOMElement $tableCellProperties, string $fontNs, string $styleNs): array
	{
		$borders = [];
		$temp = $tableCellProperties->getAttributeNs($fontNs, 'border');
		$diagonalIndex = Borders::DIAGONAL_NONE;
		foreach (['bottom', 'left', 'right', 'top', 'diagonal-tl-br', 'diagonal-bl-tr'] as $direction) {
			if (str_starts_with($direction, 'diagonal')) {
				$directionIndex = 'diagonal';
				$temp = $tableCellProperties->getAttributeNs($styleNs, $direction);
			} else {
				$directionIndex = $direction;
				$temp = $tableCellProperties->getAttributeNs($fontNs, "border-$direction");
			}
			if (Preg::isMatch('/^(\d+(?:[.]\d+)?)pt\s+([-\w]+)\s+#([0-9a-fA-F]{6})$/', $temp, $matches)) {
				$style = self::MAP_BORDER_STYLE[$matches[2]] ?? Border::BORDER_THIN;
				$width = (float) $matches[1];
				if ($width >= 2.5) {
					$style = self::MAP_BORDER_THICK[$style] ?? $style;
				} elseif ($width >= 1.75) {
					$style = self::MAP_BORDER_MEDIUM[$style] ?? $style;
				}
				$color = $matches[3];
				$borders[$directionIndex] = ['borderStyle' => $style, 'color' => ['rgb' => $matches[3]]];
				if ($direction === 'diagonal-tl-br') {
					$diagonalIndex = Borders::DIAGONAL_DOWN;
				} elseif ($direction === 'diagonal-bl-tr') {
					$diagonalIndex = ($diagonalIndex === Borders::DIAGONAL_NONE) ? Borders::DIAGONAL_UP : Borders::DIAGONAL_BOTH;
				}
			}
		}
		if ($diagonalIndex !== Borders::DIAGONAL_NONE) {
			$borders['diagonalDirection'] = $diagonalIndex;
		}

		return $borders; // @phpstan-ignore-line
	}
}
