阿巩
鸽子突然回归
前段时间由于工作需要,要实现从服务端生成自定义PDF文件,阿巩将这次方案制定到具体实现的详细流程分享出来供大家参考,方案可以满足需求但并不完美,还请大家多多指教!
需求是这样的,以下是我们要生成自定义PDF的模版初稿样式(这里仅供演示非实际样式稿),需要将某同学的考试信息及每个项目的成绩数据填入到对应的分数中,下方展示学生的作品图片。
首先想到的方案是使用第三方库来直接生成PDF文件,经过对比后选择了支持中文的 jung-kurt/gofpdf,从官网找了个example demo试着跑了下,效果一般,不过经过调整后还是可以满足需求。
正当调整格式时,UI小姐姐又更新了一版设计稿,更新后的设计稿长这样。左上角增加了logo展示、增加了多个科目的成绩展示,科目数量要根据学生实际参加考试数量决定,比如学生参加了语数英三科的考试,那么就会有三块“科目名称+作品图片”的内容填充。
这样一来直接生成PDF就无法满足需求了,而且格式调整起来也非常麻烦。由于科目长度不固定,用word模版生成PDF的方案也无法满足需求。最终我参考了Python Django中模版变量的思路,决定先用HTML+CSS来复刻一份通用的模版样式,然后使用模版变量填充数据,这样就可以实现上图对应的效果了。
第一步是生成HTML模版,我这里找到了一个在线HTML编辑器(https://www.lddgo.net/string/htmleditor),可以直接根据设计样式生成对应HTML+CSS代码。拿到HTML框架之后就是填充模版变量了,我使用的是Go标准库 text/template,template 包是数据驱动的文本输出模板,其实就是在写好的模板中填充数据。这里我用基础信息部分内容举例:
<tr style="height: 45px"> | |
<td | |
style="width: 70%; height: 45px" | |
colspan="2" | |
width="305"> | |
<p> | |
<strong>姓名</strong><strong>:</strong> | |
<strong>{{.UserName}}</strong> | |
</p> | |
</td> | |
<td | |
style="width: 30%; height: 205px" | |
colspan="2" | |
rowspan="4" | |
width="120" | |
> | |
<p style="text-align: center"> | |
<span style="font-size: 40pt" | |
><strong>{{.TotalScore}}</strong></span | |
><strong>分</strong> | |
</p> | |
</td> | |
</tr> | |
<tr style="height: 45px"> | |
<td | |
style="width: 70%; height: 45px" | |
colspan="2" | |
width="305" | |
> | |
<p><strong>座号</strong><strong>:</strong><strong>-</strong></p> | |
</td> | |
</tr> | |
<tr style="height: 45px"> | |
<td | |
style="width: 70%; height: 45px" | |
colspan="2" | |
width="305" | |
> | |
<p> | |
<strong>准考证号</strong><strong>:</strong | |
><strong>{{.ExamSn}}</strong> | |
</p> | |
</td> | |
</tr> | |
<tr style="height: 62px"> | |
<td | |
style="width: 70%; height: 62px" | |
colspan="2" | |
width="305" | |
> | |
<p> | |
<strong>考生单位</strong><strong>:</strong><strong>-</strong> | |
</p> | |
</td> | |
</tr> |
对应的服务端结构:
type ReportData struct { | |
UserName string // 姓名 | |
ExamSn string // 考号 | |
TotalScore float64 // 总分 | |
QuestionScore float64 // 职业素养分 | |
QuestionScoreRefer float64 // 职业素养总分 | |
ScoreStandards []*ReportStandards // 各科目得分 | |
} |
使用时直接为对象赋值即可:
data := &ReportData{ | |
UserName: exam.ExamExaminee.UserName, | |
ExamSn: exam.ExamSn, | |
TotalScore: exam.ExamTotalScore, | |
QuestionScore: exam.TheoryTotalScore, | |
QuestionScoreRefer: questionScoreRefer, | |
ScoreStandards: reportStandardsList, | |
} |
那么这时您可能会问了,怎么实现之前提到的动态内容填充呢?
template 包是支持循环语句的,使用 {{ range index, value := .ScoreStandards }} 遍历所有科目,以 {{end}} 标识循环结束。使用值时取
好的,那现在万事俱备,就差把HTML转成PDF文件了,这里我选择的第三方库是 go-wkhtmltopdf。代码如下,首先创建一个用于接收填充数据后的HTML临时文件,使用 tpl.Execute 填充数据,然后根据填充后的模版页面生成PDF,由于网络问题可能生成失败,这里我做了3次重试,然后将PDF文件更新写入磁盘。
// 创建一个用于写入模板输出的文件 | |
out, err := os.Create("./static/output.html") | |
if err != nil { | |
logger.Infof("os.Create fail:%v", err) | |
return "", err | |
} | |
// defer执行顺序为后进先出,这里先关闭文件,最后删除中间html文件 | |
defer os.Remove("./static/output.html") | |
// 关闭文件 | |
defer out.Close() | |
// 执行模板,将数据填充到模板并写入文件 | |
err = tpl.Execute(out, data) | |
if err != nil { | |
logger.Infof("tpl.Execute fail:%v", err) | |
return "", err | |
} | |
// 创建一个新的PDF生成器 | |
pdfg, err := wkhtmltopdf.NewPDFGenerator() | |
if err != nil { | |
logger.Infof("Generate pdf fail:%v", err) | |
return "", err | |
} | |
// 创建一个新的输入页面 | |
page := wkhtmltopdf.NewPage("./static/output.html") | |
// 设置页面大小为A4 | |
pdfg.PageSize.Set(wkhtmltopdf.PageSizeA4) | |
pdfg.AddPage(page) | |
//重试生成PDF, 最多重试3次, 如果超过3次则为失败返回空结果 | |
var retryCount int | |
// 定义最大重试次数 | |
maxRetries := 3 | |
for retryCount < maxRetries { | |
// 尝试生成PDF | |
err = pdfg.Create() | |
if err == nil { | |
// 如果成功,退出循环 | |
break | |
} | |
// 记录错误信息 | |
log.Printf("Failed to create PDF: %v", err) | |
// 重置PDF生成器 | |
pdfg, err_ := wkhtmltopdf.NewPDFGenerator() | |
if err_ != nil { | |
logger.Infof("Generate pdf fail:%v", err_) | |
return "", err_ | |
} | |
page := wkhtmltopdf.NewPage("./static/output.html") | |
pdfg.PageSize.Set(wkhtmltopdf.PageSizeA4) | |
pdfg.AddPage(page) | |
// 增加重试计数 | |
retryCount++ | |
} | |
if retryCount == maxRetries { | |
// 如果达到最大重试次数,返回错误 | |
logger.Infof("retryCount has reached the limit : %v", err) | |
return "", err | |
} | |
nowDate := time.Now().Format("20060102") | |
filePath := conf.Conf.UploadPath + nowDate + "/" + exam.ExamId + "/" | |
fileUrl := conf.Conf.UploadUrl + nowDate + "/" + exam.ExamId + "/" | |
err = utils.IsFolder(filePath) | |
if err != nil { | |
return | |
} | |
// 检查pdf文件是否存在,存在则删除 | |
pdfFileName := filepath.Join(filePath, exam.ExamId+".pdf") | |
pdfFilePath := filepath.Join(fileUrl, exam.ExamId+".pdf") | |
if _, err := os.Stat(pdfFileName); !os.IsNotExist(err) { | |
os.RemoveAll(pdfFileName) | |
} | |
// 将缓冲区内容写入磁盘文件 | |
err = pdfg.WriteFile(pdfFileName) | |
if err != nil { | |
logger.Infof("pdfg.WriteFile fail:%v", err_) | |
return "", err | |
} | |
logger.Infof("%s PDF file created successfully.", exam.ExamId) | |
return pdfFilePath, nil |
Linux服务器上使用 wkhtmltopdf 工具需要先到官网下载 wkhtmltopdf 对应版本目前支持如下版本。由于公司服务器操作系统是CentOS9官方还没有出对应的版本,我这里找了个CentOS9版本的第三方包,亲测可用:https://rhel.pkgs.org/9/aeris-x86_64/wkhtmltox-0.12.6.1-3.el9.x86_64.rpm.html
END