背景
最近做了一个打印工单的需求,具体的需求是,用户点击打印工单操作时,后端会读取工单的数据填写到之前已经上传好的一个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模版文件:其中红框内是填充数据的部分
最终打印效果:
问题列表
- PDF文件中文乱码问题
- PDF文件中页眉页脚丢失问题
- 图片拉伸处理
- PDF单元格格式问题
- Excel单元格文字样式问题
- PDF中文字体加粗问题(未解决)
- 其它样式问题
解决方案
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;
}
···