Maatwebsite/Laravel-Excelで罫線の設定がうまくできない問題を調査した

PHPにはMicrosoft Officeのファイルを扱うためのPHPOfficeというライブラリがあって、フレームワークLaravelにMaatwebsite/Laravel-Excelというライブラリをインストールすると、LaravelからPHPOffice/Excelの機能を使うことができる。

Laravel -> Maatwebsite/Laravel-Excel -> PHPOffice/Excel

Maatwebsite/Laravel-Excelには、loadView()というメソッドがあり、LaravelのテンプレートエンジンであるBladeのテンプレートファイルを指定すると、そのテンプレートファイルのフォーマットに従ってExcelのファイルを出力することができる。これは便利だ!

ウェブ上で作成した テーブルをExcelのファイルに出力したかったので、Maatwebsite/Laravel-ExcelからPHPOffice/Excelを使うことになった。

Maatwebsite/Laravel-Excel 

テンプレートファイルを以下のように定義し、loadView()に指定してExcelのファイルを出力した。

{{– resources/views/exports/payroll_table.blade.php –}}

<html>
  <head>
    <title></title>
    {!! Html::style(‘excel.css’) !!}
  </head>

  <body>
    <table class=”テーブルの周囲は太線”>
      <thead>
        <tr class=”ヘッダの上下は中太線”>
          <th class=”ほげほげ”></th>
        </tr>
      </thead>
      <tbody>
        @foreach($reports as $report)
        <tr class=”ヘッダ以外の上下は細線”>
          <td class=”セルの周囲は細線”></td> 
        </tr>
        @endforeach
      </tbody>
    </table>
  </body>
</html>

{{– resources/views/exports/payroll_table.blade.php –}}
その結果、作成されたファイルが以下となる。


53 PM

黒で指定したはずの線が青になっている。また、太さも反映されていない。
ヘッダのセンタリングなどはできているので、cssのファイルが読み込めていないわけではないようだ。
実装がどうなっているのかを調べた。

結論から言うと、Maatwebsite/Laravel-Excelでは、罫線の設定をCSSで行う場合、以下のフォーマットに従う必要がある。

  border: 1px solid #000000;

現在の実装では、以下のようになっている。

  • 1pxなどのサイズ指定は無視される。
  • 2番目の要素には線の種類が来ると決められている。
  • 最後の要素には色が来ると決められている。

そのため、例えば以下の設定は無効となる。

  border: 3px solid #000000 !important; // !importantがcolorとして認識されてしまい無効

.top-thick-border {
  border-top-width: 3px;
  border-top-style: solid;
  border-top-color: #000000;
} // 1行で設定しないので無効
以下のようにCSSの設定を修正したところ、罫線が正しく設定できるようになった。
.top-thick-border {
  border-top: 3px thick #000000;
}

Excelファイルを出力するには、以下のようにメソッドを呼び出す必要がある。

Excel::create(‘New file’, function($excel) {
    $excel->sheet(‘New sheet’, function($sheet) {
        $sheet->loadView(‘folder.view’);
    });
})->store();

そこで、Excel.phpという名前でcreate()を呼び出しているファイルを探す。

% find . -name “Excel.php” -print | xargs grep create
./vendor/maatwebsite/excel/src/Maatwebsite/Excel/Excel.php:    public function create($filename, $callback = null)
./vendor/maatwebsite/excel/src/Maatwebsite/Excel/Excel.php:        return $this->create($view)->shareView($view, $data, $mergeData);

findは条件に合致するファイルを取得するコマンド、xargsは標準入力を受け取って次のコマンドに渡すコマンド、grepはファイルから指定された語を検索するコマンドだ。上記で実行したコマンドの内容は「現在のディレクトリ以下から”Excel.php”という名前のファイルを取得して標準出力に出力し、標準出力を受け取って次のコマンドに渡し、標準出力からcreateという語を検索する」となる。

./vendor/maatwebsite/excel/src/Maatwebsite/Excel/Excel.phpを開いてみると、すぐに以下が見つかった。

    /**
     * Create a new file
     * @param                $filename
     * @param  callable|null $callback
     * @return LaravelExcelWriter
     */
    public function create($filename, $callback = null)
    {
        // Writer instance
        $writer = clone $this->writer;

        // Disconnect worksheets to prevent unnecessary ones
        $this->excel->disconnectWorksheets();

        // Inject our excel object
        $writer->injectExcel($this->excel);

        // Set the filename and title
        $writer->setFileName($filename);
        $writer->setTitle($filename);

        // Do the callback
        if ($callback instanceof Closure)
            call_user_func($callback, $writer);

        // Return the writer object
        return $writer;
    }
$callbackがClosureの場合に$writerを登録しているので、$writerを探すと次の記述が見つかった。
    /**
     * Writer object
     * @var LaravelExcelWriter
     */
    protected $writer;
そこで、今度はclass LaravelExcelWriterを探す。
% find . -name “*.php” -print | xargs grep LaravelExcelWorksheet | grep class
./vendor/composer/autoload_classmap.php:    ‘Maatwebsite\\Excel\\Classes\\LaravelExcelWorksheet’ => $vendorDir . ‘/maatwebsite/excel/src/Maatwebsite/Excel/Classes/LaravelExcelWorksheet.php’,
./vendor/maatwebsite/excel/src/Maatwebsite/Excel/Classes/LaravelExcelWorksheet.php:class LaravelExcelWorksheet extends PHPExcel_Worksheet {% find . -name “*.php” -print | xargs grep LaravelExcelWriter 
%  
./vendor/maatwebsite/excel/src/Maatwebsite/Excel/Writers/LaravelExcelWriter.phpを開いて、Closureで呼び出しているsheet()メソッドを探してみる。

    /**
     * Create a new sheet
     * @param  string        $title
     * @param  callback|null $callback
     * @return  LaravelExcelWriter
     */
    public function sheet($title, $callback = null)
    {
        // Clone the active sheet
        $this->sheet = $this->excel->createSheet(null, $title);

        // If a parser was set, inject it
        if ($this->parser)
            $this->sheet->setParser($this->parser);

        // Set the sheet title
        $this->sheet->setTitle($title);

        // Set the default page setup
        $this->sheet->setDefaultPageSetup();

        // Do the callback
        if ($callback instanceof Closure)
            call_user_func($callback, $this->sheet);

        // Autosize columns when no user didn’t change anything about column sizing
        if (!$this->sheet->hasFixedSizeColumns())
            $this->sheet->setAutosize(config(‘excel.export.autosize’, false));

        // Parse the sheet
        $this->sheet->parsed();

        return $this;
    }
$this->sheet->parsed()にParse the sheetというコメントが書かれているので、このメソッドを覚えておく。$callbackがClosureの場合に$this->sheetを登録しているので、$sheetを探すと次の記述が見つかった。
    /**
     * Excel sheet
     * @var LaravelExcelWorksheet
     */
    protected $sheet;
 そこで、今度はclass LaravelExcelWorksheetを探す。
% find . -name “*.php” -print | xargs grep LaravelExcelWorksheet | grep class
./vendor/composer/autoload_classmap.php:    ‘Maatwebsite\\Excel\\Classes\\LaravelExcelWorksheet’ => $vendorDir . ‘/maatwebsite/excel/src/Maatwebsite/Excel/Classes/LaravelExcelWorksheet.php’,
./vendor/maatwebsite/excel/src/Maatwebsite/Excel/Classes/LaravelExcelWorksheet.php:class LaravelExcelWorksheet extends PHPExcel_Worksheet {
%
./vendor/maatwebsite/excel/src/Maatwebsite/Excel/Classes/LaravelExcelWorksheet.phpを開いて、parsed()メソッドを探してみると以下の記述が見つかった。
    /**
     * Return parsed sheet
     * @return LaravelExcelWorksheet
     */
    public function parsed()
    {
        // If parser is set, use it
        if ($this->parser)
            return $this->parser->parse($this);

        // Else return the entire sheet
        return $this;
    }
$this->parser->parse()というメソッドを呼び出しているので、このメソッドを覚えておく。次にloadView()メソッドを探してみると、以下の記述が見つかった。
    /**
     *  Load a View and convert to HTML
     * @param string $view
     * @param array  $data
     * @param array  $mergeData
     * @return LaravelExcelWorksheet
     */
    public function loadView($view, $data = [], $mergeData = [])
    {
        // Init the parser
        if (!$this->parser)
            $this->setParser();

        $this->parser->setView($view);
        $this->parser->setData($data);
        $this->parser->setMergeData($mergeData);

        return $this;
    }
私が作成したテンプレートファイルは$this->parser->setView($view);で使われている。parserという変数が出てくることから考えて、parserにviewとしてセットされて使われているとみて間違いなさそうだ。そこで$parserを探してみる。
    /**
     * Parser
     * @var ViewParser
     */
    protected $parser;
今度はViewParserを探す。
% find . -name “*.php” -print | xargs grep ViewParser | grep class
./vendor/composer/autoload_classmap.php:    ‘Maatwebsite\\Excel\\Parsers\\ViewParser’ => $vendorDir . ‘/maatwebsite/excel/src/Maatwebsite/Excel/Parsers/ViewParser.php’,
./vendor/maatwebsite/excel/src/Maatwebsite/Excel/Parsers/ViewParser.php:class ViewParser {
%
./vendor/maatwebsite/excel/src/Maatwebsite/Excel/Parsers/ViewParser.phpを開いて、parse()メソッドを探すと以下の記述が見つかった。
    /**
     * Parse the view
    &nbsp
;* @param  \Maatwebsite\Excel\Classes\LaravelExcelWorksheet $sheet
     * @return \Maatwebsite\Excel\Classes\LaravelExcelWorksheet
     */
    public function parse($sheet)
    {
        $html = View::make($this->getView(), $this->getData(), $this->getMergeData())->render();

        return $this->_loadHTML($sheet, $html);
    }
ここで$htmlを取得しているので、核心に近づいてきたようだ。_loadHTML()メソッドを見てみる。
    /**
     * Load the HTML
     * @param  \Maatwebsite\Excel\Classes\LaravelExcelWorksheet $sheet
     * @param  string                                           $html
     * @return \Maatwebsite\Excel\Classes\LaravelExcelWorksheet
     */
    protected function _loadHTML($sheet, $html)
    {
        return $this->reader->load($html, true, $sheet);
    }
$readerを探してみる。
    /**
     * Construct the view parser
     * @param Html $reader
     * @return \Maatwebsite\Excel\Parsers\ViewParser
     */
    public function __construct(Html $reader)
    {
        $this->reader = $reader;
    }
load()メソッドを持っているクラスでHtmlを探す。
% find ./vendor/maatwebsite/excel/ -name “*.php” -print | xargs grep load | grep Html
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:    public function load($pFilename, $isString = false, $obj = false)
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:            return $this->loadIntoExisting($pFilename, $obj, $isString);
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:            return $this->loadIntoExistingSheet($pFilename, $obj, $isString);
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:        return $this->loadIntoExisting($pFilename, $objPHPExcel, $isString);
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:    public function loadIntoExistingSheet($pFilename, LaravelExcelWorksheet $sheet, $isString = false)
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:        // Check if we need to load the file or the HTML
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:                $loaded = @$dom->loadHTMLFile($pFilename, PHPExcel_Settings::getLibXmlLoaderOptions());
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:                $loaded = @$dom->loadHTMLFile($pFilename);
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:            @$dom->loadHTML(mb_convert_encoding($pFilename, ‘HTML-ENTITIES’, ‘UTF-8’));
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:            $loaded = @$dom->loadHTML(mb_convert_encoding($html, ‘HTML-ENTITIES’, ‘UTF-8’));
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:        if ( $loaded === false )
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.php:            throw new PHPExcel_Reader_Exception(‘Failed to load ‘ . $pFilename . ‘ as a DOM Document’);
./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.phpを開いてみるとload()メソッドが以下のように定義されていた。
    /**
     * Loads PHPExcel from file
     *
     * @param   string                                 $pFilename
     * @param   boolean                                $isString
     * @param bool|LaravelExcelWorksheet|null|PHPExcel $obj
     * @throws \PHPExcel_Reader_Exception
     * @return  LaravelExcelWorksheet
     */
    public function load($pFilename, $isString = false, $obj = false)
    {
        // Set the default style formats
        $this->setStyleFormats();

        if ( $obj instanceof PHPExcel )
        {
            // Load into this instance
            return $this->loadIntoExisting($pFilename, $obj, $isString);
        }
        elseif ( $obj instanceof LaravelExcelWorksheet )
        {
            // Load into this instance
            return $this->loadIntoExistingSheet($pFilename, $obj, $isString);
        }

        $objPHPExcel = $obj ? $obj : new PHPExcel();

        return $this->loadIntoExisting($pFilename, $objPHPExcel, $isString);
    }
loadIntoExistingSheet()というメソッドを見てみると、ついに見つけた!
        else
        {
            // Load HTML from string
            @$dom->loadHTML(mb_convert_encoding($pFilename, ‘HTML-ENTITIES’, ‘UTF-8’));

            // Let the css parser find all stylesheets
            $this->cssPar
ser->findStyleSheets($dom);

            // Transform the css files to inline css and replace the html
            $html = $this->cssParser->transformCssToInlineStyles($pFilename);

            // Re-init dom doc
            $dom = new DOMDocument;

            // Load again with css included
            $loaded = @$dom->loadHTML(mb_convert_encoding($html, ‘HTML-ENTITIES’, ‘UTF-8’));
        }
如何にもCSSを読み込んでいそうではないか!
$htmlをデバッグ出力できるように\Log::debug($html);という命令を挿入して動かしてみると、CSSの定義がセットされている様子を見ることができた。

CSSの定義は正しく読み込まれていることが確認できたので、今度は実際にそれがどう反映されているのかを調べる。

./vendor/maatwebsite/excel//src/Maatwebsite/Excel/Readers/HtmlReader.phpのloadIntoExistingSheet()メソッドに戻って見てみると_processDomElement()というメソッドが出てくる。これを見てみると、HTMLのタグの処理が見つかった。読み進めていくとstyleタグの処理が見つかった。
                        // Inline css styles
                        case ‘style’:
                            $this->parseInlineStyles($sheet, $column, $row, $attribute->value);

                            if ( $child->nodeName == ‘tr’ )
                                $this->styles[$row] = $attribute->value;
                            break;
 そこでparseInlineStyles()メソッドを見てみる。
    /**
     * Parse the inline styles
     * @param  LaravelExcelWorksheet $sheet
     * @param  string                $column
     * @param  integer               $row
     * @param  string                $styleTag
     * @return void
     */
    protected function parseInlineStyles($sheet, $column, $row, $styleTag)
    {
        // Seperate the different styles
        $styles = explode(‘;’, $styleTag);

        $this->parseCssAttributes($sheet, $column, $row, $styles);
    }
styleタグの属性を配列に分割してparseCssAttributes()メソッドを呼び出して渡している。parseCssAttributes()メソッドを見てみよう。
    /**
     * Parse the styles
     * @param  LaravelExcelWorksheet $sheet
     * @param  string                $column
     * @param  integer               $row
     * @param                        array @styles
     * @return void
     */
    protected function parseCssAttributes($sheet, $column, $row, $styles = [])
    {
        foreach ($styles as $tag)
        {
            $style = explode(‘:’, $tag);
            $name = trim(reset($style));
            $value = trim(end($style));

            $this->parseCssProperties($sheet, $column, $row, $name, $value);
        }
    }
parseCssAttributes()メソッドでは、タグの属性名と値を取り出してparseCssProperties()メソッドに渡している。
parseCssProperties()メソッドを見ると、以下の記述が見つかった。

            // Borders
            case ‘border’:
            case ‘borders’:
                $borders = explode(‘ ‘, $value);
                if (!empty($borders[1])) {
                    $style = $borders[1];
                    $color = end($borders);
                    $color = $this->getColor($color);
                    $borderStyle = $this->borderStyle($style);

                    $cells->getBorders()->applyFromArray(
                        [‘allborders’ => [‘style’ => $borderStyle, ‘color’ => [‘rgb’ => $color]]]
                    );
                }
                break;
これが罫線の設定をしている箇所だ。デバッグ分をセットして$styleと$colorを調べてみると、$colorに予期しない値が渡されていた。

結論から言うと、Maatwebsite/Laravel-Excelでは、罫線の設定をCSSで行う場合、以下のフォーマットに従う必要がある。

  border: 1px solid #000000;

現在の実装では、以下のようになっている。

  • 1pxなどのサイズ指定は無視される。
  • 2番目の要素には線の種類が来ると決められている。
  • 最後の要素には色が来ると決められている。

そのため、例えば以下の設定は無効となる。

  border: 3px solid #000000 !important; // !importantがcolorとして認識されてしまい無効

.top-thick-border {
  border-top-width: 3px;
  border-top-style: solid;
  border-top-color: #000000;
} // 1行で設定しないので無効
以下のようにCSSの設定を修正したところ、罫線が正しく設定できるようになった。
.top-thick-border {
  border-top: 3px thick #000000;
}
長い道のりだった。ここまで読んだ方、お疲れさまでした。