前端 markdown 到 pdf 生成方案
前端 markdown 到 pdf 生成方案
(检查修订中...)
接到需求,需要把数据同学生成的 markdown 格式的 ChatGPT 日报在平台上进行展示,并提供可下载的 PDF 文件。这里简单记录下使用到的技术和遇到的问题。
1. 方案对比
这个是现在项目中使用的方案,整体步骤如下,先有个全局的认识:
1. 下载云端的 markdown文件
2. 通过 markejs 把 markdown 字符串 解析成 html 字符串
3. React 解析 html 字符串,通过 dangerouslySetInnerHTML 渲染成 DOM 结构
4. 通过 html2pdf 首先把 DOM 结构转为 Canvas,然后转为 Image,最终输出到 Pdf 文件提供下载
markdown 字符串到 html 字符串,直接选型了 github.com/markedjs/ma… ,它是一个比较高效的 markdown 解析库,使用简单,也有一些 hooks 方便我们获取解析过程和解析结果中的一些信息,比如我们需要生成一二三级标题的导航,可配置的东西也很多,感兴趣的可以看看
不过从 html 生成 pdf,倒腾了几个方案。最后确定使用了 html -> canvas -> pdf 的方案,主要优势是还原度高,简单。但是也存在图片失真,分页导致文字分割,文字不可复制等问题,不过这些缺点是在可接受范围的。
与之相反的方案是使用 文字版本的 PDF文件下载。思路是把 dom 结构生成一个个的 JSON 信息,通过 PdfMake GitHub - bpampuch/pdfmake: Client/server side PDF printing in pure JavaScript 框架,把文字输出到 PDF 文件上。这里有个最大的难点是 文字的处理,后面再展开谈谈
简单总结下:
方案 | 框架选择 | 优点 | 缺点 | 其他问题 | |
---|---|---|---|---|---|
Canvas图片 | [html2pdf.js | Client-side HTML-to-PDF rendering using pure JS.](ekoopmans.github.io/html2pdf.js…) | 处理简单,高还原 | 文字无法复制,分页上元素被切割,容易失真(已有解决方案) | 内容过多时生成空白PDF文件(已有解决方案) |
文字 | GitHub - bpampuch/pdfmake: Client/server side PDF printing in pure JavaScript | 文字可复制,内容清晰,不存在分页上元素被切割 | 处理繁杂,中文和 emoji 暂时没有太好的处理方案(需要导入字体库,导致生成时间过长,试了下腾讯文档导出 pdf 里面的emoji表情都被过滤了) | - |
2. Markdown -> HTML
这里借助 marked 可以很容易实现,直接上代码:
import { marked } from 'marked';
import DOMPurify from 'dompurify';
const [renderText, setRenderText] = useState('');
...
// 核心代码
setRenderText(DOMPurify.sanitize(marked.parse(text), { ADD_ATTR: ['target'] }));
const tokens = marked.lexer(text);
const headings = tokens
.filter((token) => {
return token.type === 'heading' && token.depth < 4;
})
.map((heading) => {
return {
level: heading.depth,
text: heading.text,
slug: heading.text
.toLowerCase()
.trim()
// remove html tags
.replace(/<[!/a-z].*?>/gi, '')
// remove unwanted chars
.replace(/[\u2000-\u206F\u2E00-\u2E7F\'!"#$%&()*+,./:;<=>?@[]^`{|}~]/g, '')
.replace(/\s/g, '-'),
};
});
...
...
// 下面是导航
{headings && Array.isArray(headings) && headings.length > 0 && (
<ul className="markdown-preview-nav">
{headings.map((item) => (
<li
className={`markdown-preview-nav-depth-${item.level}`}
key={item.slug}
onClick={debounce(debounceTime, () => {
handleNavigatorScroll(item.slug, contentRef, 10);
})}
>
<a className={`${navigator === item.slug ? 'active' : ''}`}>{item.text}</a>
</li>
))}
</ul>
)}
export function handleNavigatorScroll(curNavigator, contentRef, offset = 100) {
const anchorElement = document.getElementById(curNavigator);
if (!(anchorElement instanceof HTMLElement)) {
return;
}
if (!(contentRef.current instanceof HTMLElement)) {
return;
}
contentRef.current.scrollTo({
top: anchorElement?.offsetTop - offset,
behavior: 'smooth', // 平滑滚动
});
}
...
2.1 html 标签属性保留
GitHub - cure53/DOMPurify: DOMPurify - a DOM-only, super-fast, uber-tolerant XSS sanitizer for HTML, MathML and SVG. DOMPurify works with a secure default, but offers a lot of configurability and hooks. Demo: DOMPurify 主要是防止 XSS 攻击,这个没有太多需要解释说明的
主要需要提醒的是,DOMPurify会把大部分标签的上的属性给过滤掉,比如 target 属性。所以我们在第二个参数上 加了 ADD_ATTR 配置,保留这个属性,因为需要传递 _blank ,允许用户通过新窗口打开链接
2.2 目录生成
另外一个需求是需要获取到 一二三级 标题,生成目录导航。我们可以通过 marked.lexer 获取到解析后的元素数组,把类型为 heading 并且层级小于 4 的元素挑选出来,组成我们的标题导航。
这里还有页面跳转的功能,我们需要跳转到和当前点击导航相匹配 id 的元素,主要通过 slug 来判断。一开始在网上找了下面一段代码:
heading.text.toLowerCase().replace(/[^(\w|\u4e00-\u9fa5)]+/g, '-')
发现不能 100% 匹配上所有的情况,导致失效。后面扒了下源码(如上面代码所示的几个 replace 函数)替换上去,功能正常。
不过更好的方式是使用 marked 自带的办法,如下所示(印象中有人提过这个 issues,所以刚去查了下,简单验证了下没有问题):
// add slug with occurrences by UziTech · Pull Request #20 · Flet/github-slugger · GitHub
const slugger = new marked.Slugger();
console.log(
slugger.slug(heading.text),
heading.text
.toLowerCase()
.trim()
// remove html tags
.replace(/<[!/a-z].*?>/gi, '')
// remove unwanted chars
.replace(/[\u2000-\u206F\u2E00-\u2E7F\'!"#$%&()*+,./:;<=>?@[]^`{|}~]/g, '')
.replace(/\s/g, '-'),
);
2.3 样式
产品对于样式这块并没有太多的要求,让参考 dumi - 为组件研发而生的静态站点框架 的样式。所以一开始扒拉了下它的样式文件过来用。但是发现需熬过并不是太尽如人意 。最终找了 Issues · sindresorhus/github-markdown-css · GitHub 来使用,GitHub 上使用的 markdown 样式库
import 'github-markdown-css/github-markdown-light.css';
不过这里也要提个小问题,一开始我是直接引用 github-markdown-css,测试反馈样式上有点问题,怎么是黑色的主题,看了下源码,发现使用了一个有意思的媒体查询:prefers-color-scheme - CSS:层叠样式表 | MDN ,学习了~。遂改成只用 light 主题的样式
2.4 <details>
& <summary>
产品侧反馈存在大量用户的评论,想要能折叠起来,点击的时候才进行展示。一开始想着 React 来控制这种行为,但是后面想起 HTML5 本身也有类似原生的标签可以使用:details 标签(<details>: The Details disclosure element - HTML: HyperText Markup Language | MDN),于是就拿来用了。
它在 markdown 文件中如何使用,可以参考下下面的讨论:
这里需要注意的一个问题是,
标签后面必须要加一个空行,否则会导致后续生成的 PDF 文件展示出现问题(具体是在PDF文件中,折叠的内容也会展示出来,但是不占据空间,导致内容重叠),原因不详(待研究),如下所示:<details>
<summary>**重要评论** </summary>
// 这个空行是必须的!!!这个空行是必须的!!!这个空行是必须的!!!
>> 内容 1
</details>
还有另外一个问题是如下图所示:
我们使用 details 和 summary 标签的时候,在页面上是可以看到箭头的,但是生成 PDF 的话,箭头消失了,原因不详(待研究)。这里简单的处理方式是打了个补丁:
.markdown-body details ::marker,
.markdown-body details ::-webkit-details-marker {
font-size: 0;
}
.markdown-body details summary:before {
font-size: 14px;
content: '▶';
display: inline-block;
margin-right: 5px;
color: #24292f;
}
.markdown-body details[open] summary:before {
content: '▼';
}
2.5 <table>
生成出来的PDF文件中,会发现过宽的 table 元素会展示不全,可以添加下面的样式来解决:
.markdown-body table {
word-break: break-all;
}
3. 当前方案:HTML -> Canvas -> PDF
这块主要使用的是 GitHub - eKoopmans/html2pdf.js: Client-side HTML-to-PDF rendering using pure JS.,而它主要依赖的是 GitHub - niklasvh/html2canvas: Screenshots with JavaScript 和 GitHub - parallax/jsPDF: Client-side JavaScript PDF generation for everyone. 感兴趣的都可以去看看。整体流程引用它里面的内容:
.from() -> .toContainer() -> .toCanvas() -> .toImg() -> .toPdf() -> .save()
这里主要讲讲使用过程,以及遇到的问题。先上整体主要的代码:
import canvasSize from 'canvas-size';
import html2pdf from 'html2pdf.js';
import axios from 'axios';
import * as FileSaver from 'file-saver';
const markdownRef = useRef<HTMLElement>(null);
let isValidCanvas = true;
const worker = html2pdf()
.set({
pagebreak: { mode: ['avoid-all', 'css'] },
html2canvas: {
scale: 2,
useCORS: true,
onrendered: function (canvas) {
isValidCanvas = canvasSize.test({
width: canvas.width,
height: canvas.height,
});
if (isValidCanvas) {
worker
.toImg()
.toPdf()
.save(fileName + '.pdf')
.then(() => {
setRendering(false);
});
} else {
axios
.get(mdFileUrl, { responseType: 'blob' })
.then((res) => {
if (res?.data) {
FileSaver.saveAs(res.data, fileName + '.md');
}
setRendering(false);
})
.finally(() => setRendering(false));
}
},
},
margin: [0, 10, 0, 10],
image: {
type: 'jpeg',
quality: 1,
},
})
.from(markdownRef.current)
.toCanvas();
3.1 Canvas 过大,PDF输出空白
这个是使用 Canvas 方案的时候遇到的最大问题,差点弃坑。中间也修改了几个版本,最终代码如上所示。接下来会简单说说过程。
PDF 输出空白文件这个其实在官方 issues 上也有不少的提问的,比如这一条:github.com/eKoopmans/h…,整整50多条评论,遇到这个问题的人还是不少
主要原因还是浏览器的支持问题,浏览器会限制生成的 Canvas 元素尺寸,超过的话生成一个空白的 Canvas 元素:
The HTML canvas element is widely supported by modern and legacy browsers, but each browser and platform combination imposes unique size limitations that will render a canvas unusable when exceeded
引用:GitHub - jhildenbiddle/canvas-size: Determine the maximum size of an HTML canvas element and test support for custom canvas dimensions
3.1.1 文档拆分
针对这个问题,给出的解决方案大部分是把整个文档分成几个部分,生成小块的 Canvas ,然后一点点的渲染到 PDF文件 上去。
但是这里会有两个问题:
1. 文档拆分的标准是什么?这个我很难定下来,因为给到的 markdown 文件内容没有固定可拆分的标准
2. 使用 addPage 会生成新的一页,导致出现大量的空白位置(没找到可以在当前页面后继续添加内容的方法)
于是放弃了这个方案
3.1.2 兜底方案 - 判断文档是否过大
最终和产品协商的方案是,如果文档太大,我们提供原始的 markdown 文件供用户下载。那接下来的问题变成了,文档什么时候会过大?
官方针对这个问题贴了个链接:stackoverflow.com/questions/6…,不过已经是2014年的答案了,这份数据并不可靠。
不同浏览器的尺寸限制并不一样,项目中使用的是:GitHub - jhildenbiddle/canvas-size: Determine the maximum size of an HTML canvas element and test support for custom canvas dimensions,基本原理是生成一个 Canvas 元素然后收集相关的信息来判断尺寸的限制,引用它的一段话:
Unfortunately, browsers do not provide a way to determine what their limitations are, nor do they provide any kind of feedback after an unusable canvas has been created. This makes working with large canvas elements a challenge, especially for applications that support a variety of browsers and platforms.
This micro-library provides the maximum area, height, and width of an HTML canvas element supported by the browser as well as the ability to test custom canvas dimensions. By collecting this information before a new canvas element is created, applications are able to reliably set canvas dimensions within the size limitations of each browser/platform.
引用:GitHub - jhildenbiddle/canvas-size: Determine the maximum size of an HTML canvas element and test support for custom canvas dimensions
3.1.2.1 最初方案
于是乎,一开始采用了下面的判断方案:
const isValidCanvas = canvasSize.test({
width: markdownRef.current?.clientWidth,
height: markdownRef.current?.clientHeight,
});
直接拿了 DOM 结构元素的宽高来进行判断。一开始误认为生成的 PDF 文件会和页面上的元素宽高是一致的,所以采用了这种判断方式。但是实际上不是的,不传递 width / height 参数的时候,看到生成的 canvas 宽度基本都是 719 像素。问了下 Warp: The terminal for the 21st century,它的答复是这样的:
html2pdf是一个将HTML转换为PDF的工具,它使用了wkhtmltopdf引擎来进行转换。在转换过程中,wkhtmltopdf会将HTML渲染成一个canvas,然后将canvas转换为PDF。canvas的宽度默认为719像素,这是因为wkhtmltopdf使用的默认DPI为96,而719是96dpi下A4纸的像素宽度。
暂时没有细细考究。所以我上面的判断方式是肯定存在问题的,后续也发现了很多通过这种方式生成的文档,存在空白的问题。需要调整优化
清晰度问题
另外针对图片失真的问题,解决方案是通过设置 html2canvas 参数来进行优化,比如设置 scale = 2,扩大 Canvas 元素的宽高来输出更加清晰的图片到 PDF 文件中,那我们使用 HTML 元素的宽高误差就更大了
3.1.2.2 最终版本
最终版本就是一开始贴出来的完整代码。不过也是几经修改才确定下来的,这其中遇到了以下一些问题:
获取宽高的方法
html2pdf 提供的方法基本都是基于 Promise 的工作流,产物一个个往下传递。但是翻阅了很久也没有找到一个方法可以去判断生成的 Canvas 的宽高,来决定是 resolve 继续执行 PDF 的生成,还是 reject 掉去执行兜底方案。
于是开始看 html2canvas 的文档,发现文档非常简单!也没有找到有用的信息。但是既然是开源项目,于是重施旧计 - 看源码。主要的搜索方向是找相关的方法,通过 on
关键字找到了 onrendered 函数,可以获取到生成的 Canvas 的信息。
不过要注意的是,这个方法已经被标记为废弃,后续版本也许不能使用了。其实最好的方式可以是单独引用 html2canvas 和 pdfjs,生成的 canvas 元素传递给 pdfjs,而不是使用 html2pdf 这个集成库,感兴趣的可以研究下。
于是乎我们的代码就从原来的 toPdf() 一步到位,变成了 toCanvas,然后在 onrendered 函数里面判断是否继续执行后续的 toImg 和 toPdf 方法了
3.2 下载的文件后缀丢失
有产品反馈下载完的文件没有 pdf 的后缀,我尝试了几个都是有的,于是要了相关的文件名信息,发现如果文件名中存在一些特殊的字符的时候就会产生这种情况,最终主动补全了后缀(开发过程中,一开始是手动写了,发现不写也没问题,然后去掉了,尴尬):
.save(fileName + '.pdf')
另外一种方法是可以把文件名中的特殊字符给过滤掉,但是为了保留原来的文件名,还是放弃了这种方案
3.3 跨域问题
需要加载图片的话,需要添加 Options | html2canvas useCORS 配置,并且源图片网站开放允许跨域的访问域名
3.4 元素切割问题
暂时没有发现太好的解决方案,只能从两方面去缓和,但还是存在,具体的配置说明可以看官网:html2pdf.js | Client-side HTML-to-PDF rendering using pure JS.
pagebreak: { mode: ['avoid-all', 'css'] },
margin: [0, 10, 0, 10], // top, left, bottom, right
因为切割的主要是分页的部分,我们把 top 和 bottom 的间隙都设置为0,尽可能缓和这种切割感。
而配合 pagebreak 属性中的 css mode,我们可以添加下面这段样式:
* {
break-inside: avoid;
break-after: always;
break-before: always;
}
但是最终还是存在分页上被切割的元素,原因不详
4. 备选方案:HTML -> Text -> PDF
我认为备选方案是更加好的方案,但是却存在一些还没解决的问题,所以不建议在现网中使用。下面主要来讨论下这个方案开发过程中遇到的一些问题。
这个方案的核心是使用:GitHub - bpampuch/pdfmake: Client/server side PDF printing in pure JavaScript
pdfmake 是一个用于生成PDF文档的JavaScript库,服务端和客服端都可以使用。它的目标是简化PDF文档生成的复杂性,提供简单易用的API和清晰易读的文档定义。它支持多语言、自定义字体、图表、表格、图像、列表、页眉页脚等常见的PDF文档功能
我们可以在官方的 pdfmake.org/playground.… 上体验。简单贴一段官网中的案例:
// playground requires you to assign document definition to a variable called dd
var dd = {
content: [
{
text: 'This paragraph uses header style and extends the alignment property',
style: 'header',
alignment: 'center'
},
{
text: [
'This paragraph uses header style and overrides bold value setting it back to false.\n',
'Header style in this example sets alignment to justify, so this paragraph should be rendered \n',
'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Malit profecta versatur nomine ocurreret multavit, officiis viveremus aeternum superstitio suspicor alia nostram, quando nostros congressus susceperant concederetur leguntur iam, vigiliae democritea tantopere causae, atilii plerumque ipsas potitur pertineant multis rem quaeri pro, legendum didicisse credere ex maluisset per videtis. Cur discordans praetereat aliae ruinae dirigentur orestem eodem, praetermittenda divinum. Collegisti, deteriora malint loquuntur officii cotidie finitas referri doleamus ambigua acute. Adhaesiones ratione beate arbitraretur detractis perdiscere, constituant hostis polyaeno. Diu concederetur.'
],
style: 'header',
bold: false
}
],
styles: {
header: {
fontSize: 18,
bold: true,
alignment: 'justify'
}
}
}
最主要的话是做好 文档定义 这块的工作,传给 pdfmake ,生成文字版和清晰的PDF文件。
那我们如何通过 maked 生成的 html,翻译成 pdfmake 所需要的 JSON 格式的文档定义对象呢?观察上面的文档定义,content 主要是内容的定义,里面包含了内容主体,样式定义。其中的 style 指定了一个字符串,我们可以在 styles 中定义这个字符串对应的样式。
所以这里需要做的就是我们要把 html 中的每个标签,都用 pdfmake 的文档定义重新定义一份。可想而知,工作量会很大,幸亏这块已经有成熟的框架支持了:GitHub - Aymkdn/html-to-pdfmake: This module permits to convert HTML to the PDFMake format,参考 HTML to PDFmake online convertor 官网的案例,可以更快理解。
难点1 - 样式定义
借助 html-to-pdfmake ,标签的定义可以快速完成,但是我们还需要完成样式的定义,这又是一个不小的工作量,等于要我们上面提到的 GitHub - sindresorhus/github-markdown-css: The minimal amount of CSS to replicate the GitHub Markdown style 翻译一遍,否则和页面上展示的内容样式肯定不太一致,导致用户的疑惑。这块还没有精力去处理
难点2 - 字体支持
使用 pdfmake 默认提供的几个字体,并不支持中文和 emoji 表情,会看到一堆乱码,我们需要寻找一个能同时支持中英文和 emoji 表情的字体库。这里先说下结论,尚未找到这样的字体库,因为本身对字体库这块理解也不深。
这里还要考虑的一个问题是,即使找到了这样的字体库,它还需要支持不同操作系统的字体展示,我们知道平时打开PDF文件的时候,一些PDF阅读器如果是不支持的字体的话会做回退字体展示,但是有些PDF阅读器会直接全文档显示空白,这也是个头疼的事情。
后面找到了一个在 windows 、mac、安卓和苹果手机上都能正常展示的字体:GitHub - adobe-fonts/source-han-sans: Source Han Sans | 思源黑体 | 思源黑體 | 思源黑體 香港 | 源ノ角ゴシック | 본고딕,当然也少不了 chatgpt 的意见了:
而对于 emoji 文字的支持,推荐的是:fonts.google.com/noto,但是尝试了下没有成功使用,后续再研究了。这里考虑的方案是把 emoji 表情文字给过滤掉 😄。(其实发现腾讯文档导出PDF文件的时候,也是没有 emoji 表情的,估计确实不好处理)
先按照使用 source-han-sans 字体的方案来,参考:pdfmake 官方文档,我们就可以导入自己的字体库来使用了。官方推荐的是用在线的字体链接来导入,我们把文件上传到 cdn,然后下载下来使用就好了。这边建议预加载文字库,加快生成 PDF 的速度
欢迎收看~
来源:juejin.cn/post/7234315967564103737