Excel转PDF问题记录

发布时间 2024-01-03 17:14:30作者: Sillager

背景

最近做了一个打印工单的需求,具体的需求是,用户点击打印工单操作时,后端会读取工单的数据填写到之前已经上传好的一个Excel模版文件里,随后把Excel文件转成PDF文件保存到本地,返回给前端PDF的文件地址,前端拿到新生成的PDF文件地址调取浏览器的打印;

当时定的方案是上传Excel模版文件,然后填充数据,之后在转成PDF文件,因为需要处理Excel文件,所以这边也是使用了比较流行的PhpSpreadsheet库来处理,PhpSpreadsheet是一个很优秀的Excel处理库,同时也有转换PDF文件的能力,是通过借助集成TcPdf、DomPdf、mPdf这三个三方库实现的,mPdf是一个比较优秀的将HTML转换为PDF的三方库,PhpSpreadsheet库将Excel文件转换PDF文件的大致思路是先将Excel文件读取成HTML,然后在通过mpdf三方库将HTML转成PDF文件,因为中间会先转成HTML,所以整个转换的过程中有一些问题,下面就是记录一下我遇到的一些问题,以及我的一些解决方案和思路:

使用的版本:

  • PHP: 8.1.13
  • Laravel: 10.13.5
  • dcat/laravel-admin: 2.*
  • phpoffice/phpspreadsheet: 1.29
  • mpdf/mpdf: 8.2

模版文件和成品

Excel模版文件:其中红框内是填充数据的部分

最终打印效果:


问题列表

  1. PDF文件中文乱码问题
  2. PDF文件中页眉页脚丢失问题
  3. 图片拉伸处理
  4. PDF单元格格式问题
  5. Excel单元格文字样式问题
  6. PDF中文字体加粗问题(未解决)
  7. 其它样式问题

解决方案

1. PDF中文乱码问题

这个问题网上有解决方案,就是要设置两个参数为true,autoScriptToLang = true,autoLangToFont = true;但是问题是PhpSpreadsheet集成的mpdf不能修改这两个参数,能修改的配置项很少,通过看集成的mpdf源码可以发现,集成的mpdf源码就一个文件,所以我这边是把源码文件复制了一份并做了一些修改,加了一个可以进行修改config的方法,这样就能把之前的两个参数配置上去,同时可以引入字体库,这样一些特殊字体就也能正常显示;

<?php
// 把源代码中Mpdf.php文件复制到自己代码中
namespace App\Lib\ThirdLib\PhpSpreadsheet\Pdf;

use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup;
use PhpOffice\PhpSpreadsheet\Writer\Html;
use PhpOffice\PhpSpreadsheet\Writer\Pdf;

class Mpdf extends Pdf
{
      ...
      protected $config;
      public function setConfig(array $config)
      {
            $this->config = $config;
            return $this;
      }
      ...
      public function save($filename, int $flags = 0): void
      {
            ...
            $config = [
                  'tempDir' => $this->tempDir . '/mpdf',
            ];
            $config = array_merge($config,$this->config);
            $pdf = $this->createExternalWriterInstance($config);
            ...
      }
      ...
}
?>

这样就可以在初始化mPdf的时候进行一些参数的配置了

...
$pdfWriter = new Mpdf($spreadsheet);
$pdfWriter->setConfig([
      'mode' => '+aCJK',
      'format' => 'A4',
      'autoMarginPadding' => 150,
      'margBuffer' => 50,
      'margin_left' => 25,
      'autoScriptToLang' => true,
      'autoLangToFont' => true,
      'fontDir' => app_path('Lib/ThirdLib/PhpSpreadsheet/Fonts'),
      'fontdata' => [
            'songti' => [
                  'R' => 'songti.ttf'
            ]
      ]
]);
...

字体文件

2. PDF文件中页眉页脚丢失问题

上传的Excel模版文件中有设置打印的页眉和页脚,但是在用PhpSpreadsheet自带的mPdf转PDF文件时,实际转后的PDF文件没有页眉和页脚,就是最终打印效果图片中左上、右上、右下的马赛克部分,我这边是直接自己读取Excel文件中的页眉和页脚的图片,然后通过HTML中定位(position: fixed;)的样式贴在PDF文件中;
同样先修改mpdf.php文件;

...
// 页眉
protected string $headerHtml;
// 页脚
protected string $footerHtml;
// 设置页眉
public function setHeaderHtml($html) {
      $this->headerHtml = $html;
      return $this;
}
// 设置页脚
public function setFooterHtml($html) {
      $this->footerHtml = $html;
      return $this;
}
...
public function save($filename, int $flags = 0): void
{
      ...
      // 设置页眉页脚,这里位置要放在AddPageByArray()前面,否则会不生效,AddPageByArray方法会去创建page,创建完page就不能插入页眉和页脚了
      $pdf->SetHTMLHeader($this->headerHtml);
      $pdf->SetHTMLFooter($this->footerHtml);

      $pdf->AddPageByArray([
            ...
      ]);
      ...
}

这里插入页眉页脚的位置也是踩的坑之一,一开始放在下面,怎么都没生效,后面看了源码,发现AddPageByArray方法会先设置页眉页脚,随后就开始创建page,之后在想设置页眉页脚就不会生效了;
下面的是Excel中的处理逻辑:

...
// 获取页眉页脚
$imageDivAll = [];
foreach ($worksheet->getHeaderFooter()->getImages() ?? [] as $key => $drawing) {
      $imageBase = base64_encode(file_get_contents($drawing->getPath()));
      $imageType = substr($drawing->getPath(), strrpos($drawing->getPath(), '.') + 1);
      $split = str_split($key, 1);
      $lOrR = $split[0] == 'L' ? 'left: 35px;' : 'right: 75px;';
      $hOrF = $split[1] == 'H' ? 'header' : 'footer';
      if ($split[1] == 'H') {
            $fixCss = 'position: fixed;top: -20px;' . $lOrR;
            $imageDiv = '<div style="' . $fixCss . 'width: ' . $drawing->getWidth() . 'px;height: ' . ($drawing->getHeight() - 2) . 'px; float: ' . $lOrR . '; text-align: ' . $lOrR . ';"><img alt="" src="data:image/' . $imageType . ';base64,' . $imageBase . '" style="width: 100%" /></div>';
      } else {
            $fixCss = 'position: fixed;bottom: -20px;' . $lOrR . ': 80px;background-color: #FFF;line-height:' . $drawing->getHeight() . 'px;border:1px solid #FFF;';
            $imageDiv = '<div style="' . $fixCss . 'width: ' . $drawing->getWidth() . 'px;height: ' . $drawing->getHeight() . 'px; float: ' . $lOrR . '; text-align: ' . $lOrR . ';"><img alt="" src="data:image/' . $imageType . ';base64,' . $imageBase . '" style="width: 100%" /></div>';
      }
      $imageDivAll[$hOrF][] = $imageDiv;
}
$headerFooterImages = array_map(function ($value) {
      return implode('', $value);
}, $imageDivAll);
...
$pdfWriter->setHeaderHtml($headerFooterImages['header']);
$pdfWriter->setFooterHtml($headerFooterImages['footer']);
...

这里最开始是想写的灵活一点,读取Excel中的页眉页脚图片,然后通过获取到的参数设置PDF中页眉和页脚,配置后发现参数不一致,页眉页脚的位置乱了,大小也不一样,所以我这里是自己调的样式,这样是会有点写死的感觉,但是目前还没想到有什么其它好的方法;

3. 图片拉伸处理

默认插入图片时,是按照原图片的宽高比进行插入的,并不会按照给定的宽高进行调整,但是我这里是需要将图片拉伸,填充满整个合并的单元格;所以后面看了源码发现有个setResizeProportional()方法,设置为false就能按照给定的宽高比进行调整;

...
$worksheet->mergeCells('D7:I9');          // 合并单元格
$drawing = new Drawing();                 // 新建图层
$drawing->setPath($info['image_path']);   // 图片地址
$drawing->setCoordinates('D7');           // 这里给到合并单元格后的开始单元格就行
$drawing->setResizeProportional(false);   // 取消按比例调整大小
$drawing->setWidthAndHeight(465, 185);    // 设置图片宽高
$drawing->setWorksheet($worksheet);       // 写入到单元格中
...

4. PDF单元格格式问题


这里也是很奇怪,Excel文件这里边框都是有的,但是转成PDF文件后,右边的边框就没了,这里也是一直没找到具体原因,后来我尝试了修改下Excel中的边框,就是把缺的边框的单元格给补上之后,PDF文件中就正常了,后面也是发现出现这种边框问题的都是合并单元格后出现的,所以后面这里是直接获取到所有的合并单元格,然后手动给补了一个右边框;

// 获取所有合并单元格的地址 修改合并的单元格边框样式
$mergedCells = $worksheet->getMergeCells();
foreach ($mergedCells as $mergedCell) {
      $mergedCellData = $worksheet->rangeToArray($mergedCell, null, true, true, true);
      $firstKey = array_key_first($mergedCellData);
      if ($firstKey < 11 && $firstKey != 6) continue;
      $firstValueKey = array_key_first($mergedCellData[$firstKey]);
      $worksheet->getStyle($firstValueKey . $firstKey)->getBorders()->getRight()->setBorderStyle(Border::BORDER_THIN);
}

5. Excel单元格文字样式问题

在模版文件中A9那个单元格里,需要给文字加颜色,我按照通常的方法加了颜色,但是神奇的是这一次左边框没了。。。

$worksheet->getStyle('A9:C9')->getFont()->getColor()->setRGB(Color::COLOR_RED);


这个我是后来通过先将文字修改颜色,然后解除A9:C9的合并单元格,然后在给A9:C9每个单元格都补上边框,最后在合并单元格,最终这个边框问题就解决了。。。

$worksheet->getStyle('A9:C9')->getFont()->getColor()->setRGB(Color::COLOR_RED); // 设置单元格字体颜色
$worksheet->getStyle('A9:C9')->getFont()->setSize(16); // 设置单元格字体大小
$worksheet->unmergeCells('A9:C9'); // 取消合并单元格
$worksheet->getStyle('A9:C9')->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN); // 添加单元格边框
$worksheet->mergeCells('A9:C9'); // 合并单元格

6. PDF中文字体加粗问题(未解决)

还是上面那个A9单元格中的问题,本来是想把A9里的文字加粗,但是使用了通常的办法并没有得到想要的效果,但是其它非中文列是能加粗的,参考B8,感觉这个应该是字体的问题,由于这个问题没有强求,所以暂时没有处理;

7. 其它样式问题

还有一些样式问题了,客户ID后面的二维码和条形码如果正常写到B1和H1列的话,PDF文件中没有展示,如果写到B2和H2的话,图片必须要小,不然会把整个表格往下顶,而且图片会自动居中,在B列和C列中间,也不好看,本来想要不把二维码和条形码图片当成页眉贴到PDF文件上,后来觉得也不是很靠谱,最终是在代码中调整了下第一行和第二行的样式,这样图片问题就能正常展示了;

// 修改第一二行单元格格式 方便后面二维码和条形码展示
$worksheet->unmergeCells('A1:I1'); // 取消第一行合并的单元格
$worksheet->unmergeCells('B2:C2'); // 取消第二行合并的单元格
$worksheet->mergeCells('C1:G1'); // 合并单元格 表头标题
$worksheet->setCellValue('C1', $worksheet->getCell('A1')->getValue()); // 把A1中的文字复制到C1里
$worksheet->setCellValue('A1', ''); // 把A1中的文字去除
$worksheet->mergeCells('B1:B2'); // 合并单元格 填充二维码
$worksheet->mergeCells('H1:I2'); // 合并单元格 填充条形码

还有一个样式问题,是PDF文件会有两页的问题,以及整体表格比较靠左的问题,下面是解决的代码;

$worksheet->getPageMargins()->setLeft(0.5);     // 解决整体表格靠左的问题
$worksheet->getPageSetup()->setFitToPage(true); // 解决PDF文件会有两页的问题

以上基本上就是这个需求开发过程中遇到的一些问题了,还有一些就是生成二维码和条形码的问题了,有时间的话下次再记录吧。。。


最后这里是Excel文件处理的代码逻辑

use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\Logo\Logo;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Color;
use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
···
public function workSheet($info, $pdfFilePathName): string
{
      // 读取 Excel 文件
      $spreadsheet = IOFactory::load($info['template_file_path']);
      // 获取第一个工作表
      $worksheet = $spreadsheet->getActiveSheet();
      // 修改第一二行单元格格式
      $worksheet->unmergeCells('A1:I1');
      $worksheet->unmergeCells('B2:C2');
      $worksheet->mergeCells('C1:G1');
      $worksheet->setCellValue('C1', $worksheet->getCell('A1')->getValue());
      $worksheet->setCellValue('A1', '');
      $worksheet->mergeCells('B1:B2');
      $worksheet->mergeCells('H1:I2');
      // 填充数据
      $worksheet->setCellValue('B3', $info['customer_name']);
      ...
      // 单元格字体样式修改
      $worksheet->setCellValue('A9', "规格工艺:\r\n" . $info['product_spec']);
      if ($info['is_have_glue']) {
      $worksheet->getStyle('A9:C9')->getFont()->getColor()->setRGB(Color::COLOR_RED);
      }
      $worksheet->getStyle('A9:C9')->getFont()->setSize(16);
      $worksheet->unmergeCells('A9:C9');
      $worksheet->getStyle('A9:C9')->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
      $worksheet->mergeCells('A9:C9');
      if (!empty($info['product_list_name'])) {
            foreach ($info['product_list_name'] as $k => $productName) {
                  $worksheet->setCellValue('H'. ($k + 15), $productName);
            }
      }
      
      // 图片展示
      $worksheet->mergeCells('D7:I9');
      $drawing = new Drawing();
      $drawing->setPath($info['thumb_path']);
      $drawing->setCoordinates('D7');
      $drawing->setResizeProportional(false);
      $drawing->setWidthAndHeight(465, 185);
      $drawing->setWorksheet($worksheet);
      if (!File::exists(getFilePath('printTemp/qrCode/'))) {
            FIle::makeDirectory(getFilePath('printTemp/qrCode/'), 0755, true);
      }
      // 条形码
      $barcodePath = getFilePath('printTemp/qrCode/barcode.png');
      $barcode = new BarcodeGenerator();
      $barcode->setText($info['workflow_sn']);
      $barcode->setType(BarcodeType::Code128);
      $barcode->setScale(2);
      $barcode->setThickness(25);
      $barcode->setFontSize(25);
      file_put_contents($barcodePath, base64_decode($barcode->generate()));
      $generatorDrawing = new Drawing();
      $generatorDrawing->setPath($barcodePath);
      // $generatorDrawing->setResizeProportional(false);
      $generatorDrawing->setCoordinates('H1');
      $generatorDrawing->setWidthAndHeight(150, 50);
      $generatorDrawing->setWorksheet($worksheet);
      // 客户ID 二维码
      $customerNamePath = getFilePath('printTemp/qrCode/customerQrCode.png');
      $writer = new PngWriter();
      $qrCode = QrCode::create($info['customer_name'])
      ->setEncoding(new Encoding('UTF-8'))
      // ->setErrorCorrectionLevel(new ErrorCorrectionLevelLow())
      ->setSize(200)
      ->setMargin(0);
      // ->setRoundBlockSizeMode(new RoundBlockSizeModeMargin())
      // ->setForegroundColor(new Color(0, 0, 0))
      // ->setBackgroundColor(new Color(255, 255, 255));
      $logo = Logo::create(public_path() . '/static/images/logo.jpg')->setResizeToWidth(40)->setResizeToHeight(30)->setPunchoutBackground(true);
      $writer->write($qrCode, $logo)->saveToFile($customerNamePath);
      $customerNameQrDrawing = new Drawing();
      $customerNameQrDrawing->setPath($customerNamePath);
      $customerNameQrDrawing->setResizeProportional(false);
      $customerNameQrDrawing->setCoordinates('B1');
      $customerNameQrDrawing->setWidthAndHeight(70, 50);
      $customerNameQrDrawing->setWorksheet($worksheet);
      // 线上订单号 二维码
      $orderSnQrPath = getFilePath('printTemp/qrCode/orderSnQrCode.png');
      $writerOrder = new PngWriter();
      $qrCode = QrCode::create($info['order_name'])
      ->setEncoding(new Encoding('UTF-8'))
      // ->setErrorCorrectionLevel(new ErrorCorrectionLevelLow())
      ->setSize(200)
      ->setMargin(0);
      // ->setRoundBlockSizeMode(new RoundBlockSizeModeMargin())
      // ->setForegroundColor(new Color(0, 0, 0))
      // ->setBackgroundColor(new Color(255, 255, 255));
      $writerOrder->write($qrCode, $logo)->saveToFile($orderSnQrPath);
      $orderSnQrDrawing = new Drawing();
      $orderSnQrDrawing->setPath($orderSnQrPath);
      $orderSnQrDrawing->setResizeProportional(false);
      $orderSnQrDrawing->setCoordinates('I10');
      $orderSnQrDrawing->setWidthAndHeight(75, 65);
      $orderSnQrDrawing->setWorksheet($worksheet);

      // 获取所有合并单元格的地址 修改合并的单元格边框样式
      $mergedCells = $worksheet->getMergeCells();
      foreach ($mergedCells as $mergedCell) {
            $mergedCellData = $worksheet->rangeToArray($mergedCell, null, true, true, true);
            $firstKey = array_key_first($mergedCellData);
            if ($firstKey < 11 && $firstKey != 6) continue;
            $firstValueKey = array_key_first($mergedCellData[$firstKey]);
            $worksheet->getStyle($firstValueKey . $firstKey)->getBorders()->getRight()->setBorderStyle(Border::BORDER_THIN);
      }

      // 获取页眉页脚
      $imageDivAll = [];
      foreach ($worksheet->getHeaderFooter()->getImages() ?? [] as $key => $drawing) {
            $imageBase = base64_encode(file_get_contents($drawing->getPath()));
            $imageType = substr($drawing->getPath(), strrpos($drawing->getPath(), '.') + 1);
            $split = str_split($key, 1);
            $lOrR = $split[0] == 'L' ? 'left: 35px;' : 'right: 75px;';
            $hOrF = $split[1] == 'H' ? 'header' : 'footer';
            if ($split[1] == 'H') {
                  $fixCss = 'position: fixed;top: -20px;' . $lOrR;
                  $imageDiv = '<div style="' . $fixCss . 'width: ' . $drawing->getWidth() . 'px;height: ' . ($drawing->getHeight() - 2) . 'px; float: ' . $lOrR . '; text-align: ' . $lOrR . ';"><img alt="" src="data:image/' . $imageType . ';base64,' . $imageBase . '" style="width: 100%" /></div>';
            } else {
                  $fixCss = 'position: fixed;bottom: -20px;' . $lOrR . ': 80px;background-color: #FFF;line-height:' . $drawing->getHeight() . 'px;border:1px solid #FFF;';
                  $imageDiv = '<div style="' . $fixCss . 'width: ' . $drawing->getWidth() . 'px;height: ' . $drawing->getHeight() . 'px; float: ' . $lOrR . '; text-align: ' . $lOrR . ';"><img alt="" src="data:image/' . $imageType . ';base64,' . $imageBase . '" style="width: 100%" /></div>';
            }
            $imageDivAll[$hOrF][] = $imageDiv;
      }
      $headerFooterImages = array_map(function ($value) {
            return implode('', $value);
      }, $imageDivAll);

      // 调整样式
      $worksheet->getPageMargins()->setLeft(0.5);
      $worksheet->getPageSetup()->setFitToPage(true);

      // 保存修改后的 Excel 文件
      // $writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
      // $writer->save(getFilePath('printTemp/test.xlsx'));

      // 初始化mpdf
      $pdfWriter = new Mpdf($spreadsheet);
      $pdfWriter->setConfig([
      'mode' => '+aCJK',
      'format' => 'A4',
      'autoMarginPadding' => 150,
      'margBuffer' => 50,
      'margin_left' => 25,
      'autoScriptToLang' => true,
      'autoLangToFont' => true,
      'fontDir' => app_path('Lib/ThirdLib/PhpSpreadsheet/Fonts'),
      'fontdata' => [
            'songti' => [
                  'R' => 'songti.ttf'
            ]
      ]
      ]);

      // 页眉 页脚 设置
      // $customerQrcode = '<div style="position: fixed;top: 20px;left: 80px;width: 60px;height: 60px; float: left;"><img src="'.$customerNamePath.'" style="width: 100%" /></div>';
      // $barcode = '<div style="position: fixed;top: 30px;right: 40px;width: 150px;height: 100px; float: right;"><img src="'.$barcodePath.'" style="width: 100%" /></div>';
      // $headerFooterImages['header'] .= $customerQrcode.$barcode;
      $pdfWriter->setHeaderHtml($headerFooterImages['header']);
      $pdfWriter->setFooterHtml($headerFooterImages['footer']);

      // 保存文件
      $pdfWriter->save($pdfFilePathName);

      // 清除临时图片文件
      unlink($orderSnQrPath);
      unlink($customerNamePath);
      unlink($barcodePath);

      return $pdfFilePathName;
}
···