当前位置: 首页 > 工具软件 > md-to-pdf > 使用案例 >

docsify(一):新增PDF目录、封面功能

冯淳
2023-12-01

原本只是帮小仙女看个问题,发现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)必须在根目录下。

完结,不对之处,烦请批评指正~

 类似资料: