Aerendir/PHPTextMatrix

View on GitHub
src/PHPTextMatrix.php

Summary

Maintainability
D
1 day
Test Coverage
<?php

declare(strict_types=1);

/*
 * This file is part of the Serendipity HQ Text Matrix Component.
 *
 * Copyright (c) Adamo Aerendir Crespi <aerendir@serendipityhq.com>.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace SerendipityHQ\Component\PHPTextMatrix;

use Symfony\Component\OptionsResolver\OptionsResolver;

use function Safe\preg_replace;
use function Safe\sprintf;

/**
 * The class renders a table as plain text given an array of values.
 *
 * The class is built as a value object, so each instance of the class IS an immutable table.
 *
 * As it hasn't to be an immutable value object, the class provides methods to add or remove rows and to edit other
 * aspects of the table.
 *
 * @author Adamo "Aerendir" Crespi <hello@aerendir.me>
 */
final class PHPTextMatrix
{
    /** @var string */
    public const ALIGN = 'align';

    /** @var string */
    public const ALIGN_LEFT = 'left';

    /** @var string */
    public const ALIGN_RIGHT = 'right';

    /** @var string */
    public const CELLS_PADDING = 'cells_padding';

    /** @var string */
    public const COLUMNS = 'columns';

    /** @var string */
    public const CUT = 'cut';

    /** @var string */
    public const HAS_HEADER = 'has_header';

    /** @var string */
    public const MAX_WIDTH = 'max_width';

    /** @var string */
    public const MIN_WIDTH = 'min_width';

    /** @var string */
    public const SHOW_HEAD_TOP_SEP = 'show_head_top_sep';

    /** @var string The horizontal header separator */
    public const SEP_HEAD_H = 'sep_head_h';

    /** @var string The vertical header separator */
    public const SEP_HEAD_V = 'sep_head_v';

    /** @var string The cross header separator */
    public const SEP_HEAD_X = 'sep_head_x';

    /** @var string The horizontal separator */
    public const SEP_H = 'sep_h';

    /** @var string The vertical separator */
    public const SEP_V = 'sep_v';

    /** @var string The cross separator */
    public const SEP_X = 'sep_x';

    /** @var string */
    private const SEP_ = 'sep_';

    /** @var string */
    private const ARRAY = 'array';

    /** @var string */
    private const INTEGER = 'integer';

    /** @var string */
    private const STRING = 'string';

    /** @var array $data The data to render in the table */
    private array $data = [];

    /** @var string[] $errors Contains the errors found by the validate() method */
    private array $errors = [];

    /**
     * @var int[] For each column, contains the length of the longest line in each splitted cell.
     *            The longest line found in the column is the width of the column itself
     */
    private array $columnsWidths = [];

    /**
     * @var array For each row, contains the height of the highest cell
     *            The highest cell found in the row is the height of the row itself
     */
    private array $rowsHeights = [];

    /** The options to render the table */
    private array $options = [];

    /** @var int $tableWidth The total width of the table */
    private int $tableWidth = 0;

    public function __construct(array $data)
    {
        $this->data = $data;
    }

    /**
     * Renders the table as plain text.
     *
     * @return bool|string
     */
    public function render(array $options = [])
    {
        // Set the options to use
        $this->resolveOptions($options);

        if (false === $this->validate()) {
            return false;
        }

        // Splits the content of the cells to fit into the defined line length
        $this->splitCellsContent();

        /*
         * Calculate the height of each row and the width of each column.
         * The height is equal to highest cell content found in the row.
         * The width is equal to the longest line found in the content of each cell of the column.
         */
        $this->calculateSizes();

        $this->tableWidth = $this->calculateTableWidth();

        $table = $this->options[self::HAS_HEADER] ? $this->drawHeaderDivider() : $this->drawDivider();

        // If the top divider of the header hasn't to be shown...
        if ($this->options[self::HAS_HEADER] && false === $this->options[self::SHOW_HEAD_TOP_SEP]) {
            // ... Remove it
            $table = '';
        }

        /**
         * @var int   $rowPosition
         * @var array $rowContent
         */
        foreach ($this->data as $rowPosition => $rowContent) {
            $table .= $this->drawRow($rowPosition, $rowContent);
            $table .= $this->options[self::HAS_HEADER] && 0 === $rowPosition ? $this->drawHeaderDivider() : $this->drawDivider();
        }

        // Reset options
        $this->options = [];

        return $table;
    }

    /**
     * Validates the given array to check all the rows have the same number of columns.
     *
     * Returns false if the validation fails and calling the getErrors() method it is possible to know
     * which are these errors.
     *
     * @return bool False if the validation fails, true if all succeeds
     */
    public function validate(): bool
    {
        // The number of columns
        $numberOfColumns = null;

        // Reset the errors
        $this->errors = [];

        // Check that there are rows in the data
        if (0 >= \count($this->data)) {
            $message        = 'There are no rows in the table';
            $this->errors[] = $message;

            return false;
        }

        // Check that all rows have the same number of columns
        /** @var array $row */
        foreach ($this->data as $row) {
            $found = \count($row);

            if (null === $numberOfColumns) {
                $numberOfColumns = $found;
            }

            if ($numberOfColumns !== $found) {
                $message = sprintf(
                    'The number of columns mismatches. First row has %s columns while column %s has %s.',
                    $numberOfColumns,
                    \key($row),
                    $found
                );
                $this->errors[] = $message;
            }
        }

        return 0 >= \count($this->errors);
    }

    /**
     * Returns the errors found by validate().
     */
    public function getErrors(): array
    {
        return $this->errors;
    }

    /**
     * Returns the total width of the table.
     */
    public function getTableWidth(): int
    {
        return $this->tableWidth;
    }

    /**
     * Splits the content of each cell into multiple lines according to the set max column width.
     */
    private function splitCellsContent(): void
    {
        // For each row...
        /**
         * @var int   $rowPosition
         * @var array $rowContent
         */
        foreach ($this->data as $rowPosition => $rowContent) {
            // ... cycle each column to get its content
            /**
             * @var string $columnName
             * @var string $cellContent
             */
            foreach ($rowContent as $columnName => $cellContent) {
                // Remove extra spaces from the string
                $cellContent = $this->reduceSpaces($cellContent);

                // If we don't have a max width set for the column...
                if (false === isset($this->options[self::COLUMNS][$columnName][self::MAX_WIDTH])) {
                    // ... simply wrap the content in an array and continue
                    $this->data[$rowPosition][$columnName] = [$cellContent];
                    $this->addVerticalPadding($rowPosition, $columnName);

                    continue;
                }

                // ... We have a max_width set: split the column
                /** @var int $length */
                $length = $this->options[self::COLUMNS][$columnName][self::MAX_WIDTH];

                /** @var bool $cut */
                $cut = $this->options[self::COLUMNS][$columnName][self::CUT];

                $wrapped = \wordwrap($cellContent, $length, PHP_EOL, $cut);

                $this->data[$rowPosition][$columnName] = \explode(PHP_EOL, $wrapped);

                // At the end, add the vertical padding to the cell's content
                $this->addVerticalPadding($rowPosition, $columnName);
            }
        }
    }

    private function addVerticalPadding(int $rowPosition, string $columnName): void
    {
        if (0 < $this->options[self::CELLS_PADDING][0]) {
            // Now add the top padding
            for ($paddingLine = 0; $paddingLine < $this->options[self::CELLS_PADDING][0]; ++$paddingLine) {
                \array_unshift($this->data[$rowPosition][$columnName], '');
            }
        }

        if (0 < $this->options[self::CELLS_PADDING][2]) {
            // And the bottom padding
            for ($paddingLine = 0; $paddingLine < $this->options[self::CELLS_PADDING][2]; ++$paddingLine) {
                $this->data[$rowPosition][$columnName][] = '';
            }
        }
    }

    /**
     * @see http://stackoverflow.com/a/2326133/1399706
     */
    private function reduceSpaces(string $cellContent): string
    {
        $result = preg_replace('#\x20+#', ' ', $cellContent);

        // @phpstan-ignore-next-line
        if (\is_array($result)) {
            $result = $result[0];
        }

        return (string) $result;
    }

    /**
     * Calculates the width of each column of the table.
     *
     * @suppress PhanTypeNoPropertiesForeach
     */
    private function calculateSizes(): void
    {
        // For each row...
        /**
         * @var int   $rowPosition
         * @var array $rowContent
         */
        foreach ($this->data as $rowPosition => $rowContent) {
            // ... cycle each column to get its content ...
            /**
             * @var string $columnName
             * @var array  $cellContent
             */
            foreach ($rowContent as $columnName => $cellContent) {
                // If we don't already know the height of this row...
                if (false === isset($this->rowsHeights[$rowPosition])) {
                    // ... we save the current calculated height
                    $this->rowsHeights[$rowPosition] = \count($cellContent);
                }

                // Set the min_width if it is set
                if (isset($this->options[self::COLUMNS][$columnName][self::MIN_WIDTH])) {
                    $this->columnsWidths[$columnName] = $this->options[self::COLUMNS][$columnName][self::MIN_WIDTH];
                }

                // At this point we have the heigth for sure: on each cycle, we need the highest height
                if (\count($cellContent) > $this->rowsHeights[$rowPosition]) {
                    // The height of this row is the highest found: use this to set the height of the entire row.
                    $this->rowsHeights[$rowPosition] = \count($cellContent);
                }

                // ... and calculate the length of each line to get the max length of the column
                /** @var string $lineContent */
                foreach ($cellContent as $lineContent) {
                    // Get the length of the cell
                    $contentLength = \iconv_strlen($lineContent);

                    // If we don't already have a length for this column...
                    if (false === isset($this->columnsWidths[$columnName])) {
                        // ... we save the current calculated length
                        $this->columnsWidths[$columnName] = $contentLength;
                    }

                    // At this point we have a length for sure: on each cycle we need the longest length
                    if ($contentLength > $this->columnsWidths[$columnName]) {
                        /*
                         * The length of the content of this cell is longer than the value we already have.
                         * So we need to use this new value as length for the current column.
                         */
                        $this->columnsWidths[$columnName] = $contentLength;
                    }
                }
            }
        }
    }

    /**
     * Calculates the total width of the table.
     *
     * @psalm-suppress MixedInferredReturnType
     * @psalm-suppress MixedOperand
     * @psalm-suppress MixedReturnStatement
     */
    private function calculateTableWidth(): int
    {
        // Now calculate the total width of the table
        $tableWidth = 0;

        // Add the width of the columns
        /** @var int $width */
        foreach ($this->columnsWidths as $width) {
            $tableWidth += $width;
        }

        // Add 1 for the first separator
        ++$tableWidth;

        // Add the left and right padding * number of columns
        $tableWidth += ($this->options[self::CELLS_PADDING][1] + $this->options[self::CELLS_PADDING][3]) * \count($this->columnsWidths);

        // Add the left separators
        $tableWidth += \count($this->columnsWidths);

        return $tableWidth;
    }

    private function drawHeaderDivider(): string
    {
        return $this->drawDivider('sep_head_');
    }

    /**
     * Draws the horizontal divider.
     *
     * @psalm-suppress MixedOperand
     */
    private function drawDivider(string $prefix = self::SEP_): string
    {
        $divider = '';
        /** @var int $width */
        foreach ($this->columnsWidths as $width) {
            // Column width position for the xSep + left and rigth padding
            /** @var int $times */
            $times = $width + $this->options[self::CELLS_PADDING][1] + $this->options[self::CELLS_PADDING][3];
            $divider .= $this->options[$prefix . 'x'] . $this->repeatChar($this->options[$prefix . 'h'], $times);
        }

        return $divider . $this->options[$prefix . 'x'] . PHP_EOL;
    }

    /**
     * @psalm-suppress MixedOperand
     */
    private function drawLine(int $lineNumber, array $rowContent, string $sepPrefix = self::SEP_): string
    {
        $line = '';
        /**
         * @var string $columnName
         * @var array  $cellContent
         */
        foreach ($rowContent as $columnName => $cellContent) {
            /** @var string $lineContent */
            $lineContent = $cellContent[$lineNumber] ?? '';
            $alignSpaces = 0;

            // Count characters and draw spaces if needed
            $lineContentLength = \iconv_strlen($lineContent);
            if (false === \is_int($lineContentLength)) {
                throw new \RuntimeException('Something went wrong counting the length of the content.');
            }

            if ($lineContentLength < $this->columnsWidths[$columnName]) {
                /** @var int $alignSpaces */
                $alignSpaces = $this->columnsWidths[$columnName] - $lineContentLength;
            }

            // Draw the line
            $line
                // Vertical Separator
                .= $this->options[$sepPrefix . 'v']
                // + left padding
                . $this->drawSpaces($this->options[self::CELLS_PADDING][3]);

            if (false === isset($this->options[self::COLUMNS][$columnName][self::ALIGN])) {
                $this->options[self::COLUMNS][$columnName][self::ALIGN] = $this->options['default_cell_align'];
            }

            switch ($this->options[self::COLUMNS][$columnName][self::ALIGN]) {
                case self::ALIGN_LEFT:
                    $line .=
                        // + content
                        \trim($lineContent)
                        // + right spaces
                        . $this->drawSpaces($alignSpaces);

                    break;

                case 'right':
                    $line .=
                        // + right spaces
                        $this->drawSpaces($alignSpaces)
                        // + content
                        . \trim($lineContent);

                    break;
            }

            // + right padding
            $line .= $this->drawSpaces($this->options[self::CELLS_PADDING][1]);
        }

        return $line . $this->options[$sepPrefix . 'v'] . PHP_EOL;
    }

    private function drawRow(int $rowPosition, array $rowContent): string
    {
        $row = '';
        for ($lineNumber = 0; $lineNumber < $this->rowsHeights[$rowPosition]; ++$lineNumber) {
            $sepPrefix = $this->options[self::HAS_HEADER] && 0 === $rowPosition ? 'sep_head_' : self::SEP_;
            $row .= $this->drawLine($lineNumber, $rowContent, $sepPrefix);
        }

        // Cell content + the last vertical separator
        return $row;
    }

    /**
     * Draws a string of empty spaces.
     */
    private function drawSpaces(int $amount): string
    {
        return $this->repeatChar(' ', $amount);
    }

    /**
     * @param string $char  The character to repeat
     * @param int    $times The number of times the char has to be repeated
     */
    private function repeatChar(string $char, int $times): string
    {
        if (0 === $times) {
            return '';
        }

        $string = '';
        for ($i = 1; $i <= $times; ++$i) {
            $string .= $char;
        }

        return $string;
    }

    /**
     * @psalm-suppress MissingClosureParamType
     */
    private function resolveOptions(array $options = []): void
    {
        $resolver = new OptionsResolver();
        $resolver->setDefaults([
            // The horizontal header separator
            self::SEP_HEAD_H => '=',
            // The vertical header separator
            self::SEP_HEAD_V => '#',
            // The cross header separator
            self::SEP_HEAD_X => '#',
            // The horizontal separator
            self::SEP_H => '-',
            // The vertical separator
            self::SEP_V => '|',
            // The cross separator
            self::SEP_X      => '+',
            self::HAS_HEADER => false,
            // Determines if the top divider of the header has to be shown or not
            self::SHOW_HEAD_TOP_SEP => true,
            // Determine if the content has to be aligned on the left or on the right
            'default_cell_align' => self::ALIGN_LEFT,
            ])
            // This options can be passed or not
                ->setDefined(self::CELLS_PADDING)
                ->setDefined(self::COLUMNS);

        // Set type validation
        $resolver->setAllowedTypes(self::SEP_H, self::STRING)
            ->setAllowedTypes(self::SEP_V, self::STRING)
            ->setAllowedTypes(self::SEP_X, self::STRING)
            ->setAllowedTypes(self::CELLS_PADDING, [self::ARRAY, self::INTEGER])
            ->setAllowedTypes(self::COLUMNS, self::ARRAY);

        // Set value validation
        $resolver->setAllowedValues(self::CELLS_PADDING, static function ($value): bool {
            if (\is_array($value)) {
                return \count($value) <= 4;
            }

            return true;
        });

        $this->options = $resolver->resolve($options);

        $this->options[self::CELLS_PADDING] = $this->resolveCellsPaddings();
        $this->options[self::COLUMNS]       = $this->resolveColumnsOptions();
    }

    /**
     * Set the padding of the cells.
     *
     * This is the number of spaces to put on the left and on the right and on top and bottom of the content in each cell.
     *
     * This method follows the rules of the padding CSS rule.
     *
     * @see http://www.w3schools.com/css/css_padding.asp
     */
    private function resolveCellsPaddings(): array
    {
        $return = [0, 0, 0, 0];

        // If padding is not set, return default values
        if (false === isset($this->options[self::CELLS_PADDING])) {
            return $return;
        }

        // Create a resolver to validate passed values
        $resolver = new OptionsResolver();
        $resolver->setDefined('0');
        $resolver->setDefined('1');
        $resolver->setDefined('2');
        $resolver->setDefined('3');

        $resolver->setAllowedTypes('0', self::INTEGER);
        $resolver->setAllowedTypes('1', self::INTEGER);
        $resolver->setAllowedTypes('2', self::INTEGER);
        $resolver->setAllowedTypes('3', self::INTEGER);

        /** @var array|int $padding */
        $padding = $this->options[self::CELLS_PADDING];

        // If is only an integer, make it an array with only one set value
        if (\is_int($padding)) {
            $padding = [$padding];
        }

        $count = \count($padding);

        switch ($count) {
            case 1:
                // Set the same padding for all directions
                $return = [$padding[0], $padding[0], $padding[0], $padding[0]];

                break;

            case 2:
                // 0: Top and Bottom; 1: Left and Right
                $return = [$padding[0], $padding[1], $padding[0], $padding[1]];

                break;

            case 3:
                // 0: Top; 1: Left and Right; 2: Bottom
                $return = [$padding[0], $padding[1], $padding[2], $padding[1]];

                break;

            case 4:
                // 0: Top; 1: Right; 2: Bottom; 3: Left
                $return = [$padding[0], $padding[1], $padding[2], $padding[3]];

                break;
        }

        return $return;
    }

    private function resolveColumnsOptions(): array
    {
        $return = [];
        // Sub- resovler for columns
        if (isset($this->options[self::COLUMNS])) {
            $resolver = new OptionsResolver();
            $resolver->setDefined(self::MAX_WIDTH);
            $resolver->setDefined(self::MIN_WIDTH);
            $resolver->setDefault(self::CUT, false);
            $resolver->setDefault(self::ALIGN, self::ALIGN_LEFT);

            $resolver->setAllowedTypes(self::MAX_WIDTH, self::INTEGER);
            $resolver->setAllowedTypes(self::MIN_WIDTH, self::INTEGER);
            $resolver->setAllowedValues(self::CUT, [true, false]);
            $resolver->setAllowedValues(self::ALIGN, [self::ALIGN_LEFT, self::ALIGN_RIGHT]);

            /**
             * @var string $columnName
             * @var array  $columnOptions
             */
            foreach ($this->options[self::COLUMNS] as $columnName => $columnOptions) {
                $resolved            = $resolver->resolve($columnOptions);
                $return[$columnName] = $resolved;
            }
        }

        return $return;
    }
}