原本只是帮小仙女看个问题,发现docsify这个工具还挺有意思,花了几天时间研究了下,今天做个总结。
docsify:文档网站生成工具,自带服务(docsify server),其自身还算简单,部署方法网上多的是,所以今天不作为总结的重点,用到的老师照着网上的教程照猫画虎就OK啦~ docsify官网
那么重点必然就只能是docsify-pdf-converter组件了!
docsify借助docsify-pdf-converter组件(开源)可以把工程中的markdown合并生成pdf文档,其工作原理大致是通过docsify server将markdown转为html,再由puppeteer启动Chromium,加载docsify server上的index.html,通过page.pdf API将html页转为pdf。
npm上最新版本docsify-pdf-converter@2.0.7产出的pdf缺少内部跳转、缺少目录、缺少封面…不知道大家感觉怎样,反正是满足不了我的需求。
docsify-pdf-converter@2.1.0-beta.0虽已加入了内部锚点跳转的逻辑,但实际跑起来并不生效,作为测试版也无可厚非了,npm周下载量为0…(图片传不上,以后再补吧)
本文主要介绍基于docsify-pdf-converter@2.1.0-beta.0的二次开发,新增了pdf封面、pdf目录(顶部)、pdf锚点链接等功能。(暂未推到github)
一、部署docsify-pdf-converter
1.安装 docsify-pdf-converter@2.1.0-beta.0
npm install docsify-pdf-converter@2.1.0-beta.0
2.在docsify工程根目录下新建.docsifytopdfrc.js文件,内容如下:
module.exports = {
mkdir: true, // 新增配置 是否显示目录
// 新增配置 是否显示封面 为ture时必须在根目录设置facebook.html文件(文件名可以改,与facebookName字段一致即可)
facebook: true,
facebookName: "facebook.html", // 新增配置 封面的源文件名称 facebook为true时 此字段必须配置
contents: ["D:/workspace2/md/docs/_sidebar1.md"], // 需要转换文件目录,自动追踪链接文件(建议此配置为绝对路径)
pathToPublic: "pdf/icsdoc.pdf", // 生成pdf存放的路径
pdfOptions: {
format: 'A4',
displayHeaderFooter: true,
headerTemplate: `<span>title</span>`,
footerTemplate: `<div style='text-align:center;width: 297mm;font-size: 10px;'><span class='pageNumber'>inspur</span></div>`,
margin: {
top: '50px',
right: '20px',
bottom: '40px',
left: '20px'
},
printBackground: true,//打印背景
omitBackground: true,
landscape: false,//纸张方向
}, // reference: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagepdfoptions
removeTemp: true, // remove generated .md and .html or not
emulateMedia: "screen", // mediaType, emulating by puppeteer for rendering pdf, 'print' by default (reference: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pageemulatemediamediatype)
}
pdfOptions 是puppeteer要求的导出pdf格式参数,具体可参考:https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagepdfoptions
3.封面facebook.html(新增)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>封面</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="description" content="Description">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<style>
* {
padding: 0;
margin: 0;
}
#fb {
/*height、width 为puppeteer启动Chromium视口的默认宽高,不配置改变视口大小,这两个样式是不需要更改的,就是为了保证封面占首页整页的效果*/
width: 792px;
height: 1122px;
overflow: hidden;
position: relative;
}
/***********以上默认样式 适配pupperteer视口大小 一般情况不需要调整************/
/*****以下为封面内容的自定义样式*****/
.content {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
}
.content .title {
font-size: 48px;
font-weight: bold;
}
.content img {
width: 100%;
}
</style>
</head>
<body>
<div id="fb">
<!-- 自定义编辑content的内容 -->
<div class="content">
这是一个封面
</div>
</div>
</body>
</html>
4.在packages.json中添加
scripts:{
convert:"node_modules/.bin/docsify-pdf-converter"
}
5. 运行npm run convert 执行生成pdf
npm run convert
二、修改源码
1. node_modules\docsify-pdf-converter\src\markdown-combine.js
const fs = require("fs");
const util = require("util");
const path = require("path");
const logger = require("./logger.js");
const processImagesPaths = require("./process-images-paths.js");
const processInnerLinks = require("./process-inner-links.js");
const [readFile, writeFile, exists] = [fs.readFile, fs.writeFile, fs.exists].map(fn =>
util.promisify(fn),
);
const combineMarkdowns = ({ contents, pathToStatic, mainMdFilename, mkdir }) => async links => {
let contentsPaths = Array.isArray(contents) ? contents : [contents];
//判断是否显示目录,将docsifytopdfrc.js中的contents配置的目录文件添加到文档内容中(默认没有添加进去)
mkdir && links.unshift(contentsPaths[0].replace(/\//g, '\\'))
//根据docsifytopdfrc.js中的contents获取其绝对路径
let dir = path.dirname(path.resolve(contentsPaths[0]))
try {
const files = await Promise.all(
await links.map(async filename => {
const fileExist = await exists(filename);
if (fileExist) {
const content = await readFile(filename, {
encoding: "utf8",
});
return {
content,
name: filename,
};
}
throw new Error(`file ${filename} is not exist, but listed in ${contents}`);
}),
);
const resultFilePath = path.resolve(pathToStatic, mainMdFilename);
try {
const content = files
//将项目绝对路径传递给内部锚点设置函数processInnerLinks,因为此函数在对内部锚点链接对比时,要求必须是绝对路径,而在源文档中设置的内部跳转链接多数为相对路径,所以需要路径拼接的操作
.map((...rest) => processInnerLinks(dir, ...rest))
.map(processImagesPaths({ pathToStatic }))
.join("\n\n\n\n\n\n");
//判断是否显示了目录 设置头部目录标题
await writeFile(resultFilePath, (mkdir ? '# **目录** \r\n' : '') + content);
} catch (e) {
logger.err("markdown combining error", e);
throw e;
}
return resultFilePath;
} catch (err) {
logger.err("combineMarkdowns", err);
throw err;
}
};
module.exports = config => ({
combineMarkdowns: combineMarkdowns(config),
});
2. node_modules\docsify-pdf-converter\src\process-inner-links.js
const path = require("path");
const markdownLinkExtractor = require("markdown-link-extractor");
const unified = require("unified");
const parser = require("remark-parse");
// dir 取自配置文件.docsifytopdfrc.js中_sidebar.md文件的路径
module.exports = (dir, { content, name }, _, arr) => {
let newContent = content;
markdownLinkExtractor(content)
.filter(link => {
return path.parse(link).ext === ".md"
})
// 下面map是为了过滤出有内部链接的文档,此处对相对路径和绝对路径做了拼接,为了保证所有链接对比时都使用绝对路径
.map(link => {
let temp = link.indexOf('/') !== 0 ? '/' + link : link
return {
// arr中的name即为在_sidebar中声明的链接,且已拼接根路径
file: arr.find(({ name }) => name.includes(dir + temp.replace(/\//g, '\\'))), link
}
})
.filter(({ file }) => file)
.map(({ file: { content }, link }) => ({
ast: unified()
.use(parser)
.parse(content),
link,
}))
//找出每个内部链接对应的文档的标题
.map(({ ast, link }) => {
// 获取头部内容,要求第一个必须为标题,且不能有html代码包裹
// 可以有md标签如 ## 或 ** 包裹
const [a] = ast.children.filter(({ type, children }) => {
return type === "heading"
});
let value = ''
let temp = a.children.find(obj => obj.type === "text")
if (temp) {
value = temp.value
} else if (a.children[0] && a.children[0].children) {
temp = a.children[0].children.find(obj => obj.type === "text");
value = temp.value
}
return { link, unsafeTag: value };
})
.map(({ unsafeTag, link }) => ({
link,
tagWord: unsafeTag.replace(/[&\/\\#,+()$~%.'":*?<>{}]/g, "").replace(/\s/g, "-"),
}))
.map(({ link, tagWord }) => ({
link,
tag: `#${tagWord}`,
}))
// 把所有内容中的内部链接替换为标题,其效果如下:
// - [产品简介](/zh-cn/chapter1/test1.md) 替换后 - [产品简介](#产品简介)
// 如此以来最终文档内将没有实际链接存在,仅剩对各文档标题的锚点关联
.forEach(({ tag, link }) => {
// content中的链接\已被转义为\\ 下面要进行替换 先把link中\替换为\\ 否则匹配不到
link = link.replace(/\\/g, '\\\\')
newContent = newContent.replace(link, tag)
});
return { content: newContent, name };
};
3. node_modules\docsify-pdf-converter\src\render.js
首先需要安装easy-pdf-merge
npm install --save easy-pdf-merge
目的是对过程中生成的 封面pdf 和 content.pdf 进行合并。
修改源码:
const path = require("path");
const fs = require("fs");
const puppeteer = require("puppeteer");
const logger = require("./logger.js");
const runSandboxScript = require("./run-sandbox-script.js");
const merge = require("easy-pdf-merge");
const renderPdf = async ({
mainMdFilename,
pathToStatic,
pathToPublic,
pdfOptions,
docsifyRendererPort,
emulateMedia,
facebookName,
facebook,
}) => {
const browser = await puppeteer.launch({
defaultViewport: {
width: 1200,
height: 1000,
},
});
try {
const mainMdFilenameWithoutExt = path.parse(mainMdFilename).name;
const docsifyUrl = `http://localhost:${docsifyRendererPort}/#/${pathToStatic}/${mainMdFilenameWithoutExt}`;
let pdfUrls = []
const page = await browser.newPage();
// 判断是否显示封面 生成封面pdf,并将生成的pdf路径放入pdfUrls数组内
if (facebook) {
await page.goto(`http://localhost:${docsifyRendererPort}/${facebookName}`, { waitUntil: "networkidle0" });
await page.emulateMedia(emulateMedia);
await page.pdf({
...{
...pdfOptions,
margin: {
top: '0px',
right: '0px',
bottom: '0px',
left: '0px'
},
},
path: path.resolve("facebook.pdf"),
});
pdfUrls.push('facebook.pdf')
}
// 开始生成文档内容pdf,并将生成的pdf路径放入pdfUrls数组内
await page.goto(docsifyUrl, { waitUntil: "networkidle0" });
const renderProcessingErrors = await runSandboxScript(page, {
mainMdFilenameWithoutExt,
pathToStatic,
});
if (renderProcessingErrors.length)
logger.warn("anchors processing errors", renderProcessingErrors);
await page.emulateMedia(emulateMedia);
await page.pdf({
...pdfOptions,
path: path.resolve(facebook ? 'content.pdf' : pathToPublic),
});
pdfUrls.push(facebook ? 'content.pdf' : pathToPublic)
await browser.close();
//如果显示封面,执行合并,完成后删除pdfUrls中的pdf。
if (facebook) {
await mergeMultiplePDF(pdfUrls, pathToPublic);
pdfUrls.map(url => fs.unlinkSync(url))
}
} catch (e) {
await browser.close();
throw e;
}
};
//调用easy-pdf-merge的api对pdfUrls的pdf进行合并
const mergeMultiplePDF = (pdfFiles, pathToPublic) => {
return new Promise((resolve, reject) => {
merge(pdfFiles, pathToPublic, async (err) => {
if (err) {
console.log(err);
reject(err)
}
console.log('PDF Merge Success!');
resolve()
});
});
};
const htmlToPdf = ({
mainMdFilename,
pathToStatic,
pathToPublic,
pdfOptions,
removeTemp,
docsifyRendererPort,
emulateMedia,
facebookName,
facebook,
}) => async () => {
const { closeProcess } = require("./utils.js")({ pathToStatic, removeTemp });
try {
return await renderPdf({
mainMdFilename,
pathToStatic,
pathToPublic,
pdfOptions,
docsifyRendererPort,
emulateMedia,
facebookName,
facebook,
});
} catch (err) {
logger.err("puppeteer renderer error:", err);
await closeProcess(1);
}
};
module.exports = config => ({
htmlToPdf: htmlToPdf(config),
});
至此,基于2.1.0-beta.0版本对docsify-pdf-converter组件的改造完毕,能保持markdow中的跳转链接,也能通过.docsifytopdfrc.js配置决定是否显示封面、目录。
注意事项:
1. markdown文档中的标题必须是markdown标签,如#、*等,不能有html标签。
2. markdown文档间的跳转链接使用 [名称](url) ,不能使用html中a标签(http链接不限)。 url路径从根目录开始,以.md结尾,如:zh-cn/chapter1/test1.md或/zh-cn/chapter1/test1.md,具体参考文件位置,需要兼顾生成html项目中可以正常访问和跳转。
3. markdown目录文件(docsify默认是_sidebar.md)必须在根目录下。
完结,不对之处,烦请批评指正~