Skip to content

官方公众号

高效生产力 • openyunzhi-info

openyunzhi-info
办公技术分享

如何把软件代码批量打印成 pdf 文件?

申请软件著作权登记时,把软件代码批量打印成pdf文件的思路和实现

2024/6/26

申请软件著作权登记时,我们需要按相关的要求把代码打印成 pdf 文件上传。

但是搜索了一圈,发现基本上各种网页插件、编辑器插件都只能单独地一个文件一个文件处理。

作为一个程序员,当然不能这样傻傻地一个一个去打印代码文件。代码文件数量多、行数长短不一、而且分布在不同的文件夹里。一个一个处理太慢了,效率太低。生成的代码 pdf 文件也不好看,没有行号,没有语法高亮。

我经过一番研究,自己写了一个nodejs脚本,来批量打印代码到 pdf 文件,而且可以支持代码行号、语法高亮。

提示

软件著作权登记上传源代码的要求:

页眉建议标注该软件名称、版本号,内容应与申请表中填写的一致;右上角标注页码,源程序从正文第 1 页编到第 60 页,文档从目录开始由第 1 页编到第 60 页。

总体流程

  1. 拉取要打印的项目代码,通过 git ls-files > filelist.txt 取得需要打印的代码文件列表。手工清除不需要的文件,如图片、压缩包等,并调整为需要的顺序。
  2. 依次读取这些代码文件,通过 highlight.js 生成带有行号、语法高亮的 html 文件。
  3. 使用 puppeteer-core 依次打开上一步生成的单个代码的 html 文件,转换成带有每页固定行数,有行号、语法高亮的 pdf 文件。
  4. 使用 pdf-merger-js 把多个 pdf 文件按顺序合并起来生成一份代码 pdf 文件。
  5. 使用 pdf-lib 在合并后的 pdf 文件页眉添加软件名称、版本号,在右上角添加页码。

使用的技术栈

  • cloc

    统计源码行数

  • highlight.js

    生成带有行号、语法高亮的 html 文件

  • puppeteer-core

    使用无头浏览器打开 html 文件,打印另存为 pdf 文件

  • pdf-merger-js

    合并 pdf 文件

  • pdfl-lib

    修改 pdf 文件,添加页眉

具体实现

获取要打印的代码文件

此步骤常见方法是通过 git 管理的文件中获取。

在源码目录运行

计算代码行数

sh
cloc $(git ls-files)

或者

sh
cloc <src_dir>

获取 git 仓库的文件清单

sh
git ls-files > filelist.txt

生成 filelist.txt 文件后,打开它,清除不需要的图片、压缩包 zip/xlsx/png/docx/.gitignore/cache 等二进制文件。

调整文件的排列顺序为自己需要的。

读取代码文件生成 html

此步骤使用 highlight.js 完成。

读取出代码文本后,发送给 highlight.js 处理。

js
const html = hljs.highlightAuto(codeStr).value;

在 html 文件 <head> 区域添加

html
<link rel="stylesheet" href="/path/to/styles/default.min.css" />
<script src="/path/to/highlight.min.js"></script>
<script>
  hljs.highlightAll();
</script>

把生成的 html 代码插入到一个 html 文件中,左侧是行号,右侧是代码。

js
/**
 * 生成带行号、语法高亮的html内容
 * @param {*} filePath 代码文件路径
 * @returns html文件内容
 */
function buildHtmlStr(filePath) {
  const codeStr = readFileSync(filePath).toString();
  const colorCode = hljs.highlightAuto(codeStr).value;
  const numberStr = numberAuto(codeStr.split('\n').length).join('\n');
  const preNumElm = `<pre><code>${numberStr}</code></pre>`;
  const preCodeElm = `<pre><code>${colorCode}</code></pre>`;

  const showFilePath = filePath.replace(/[\/\\]/g, '\\\\');
  const body = `
  <!DOCTYPE html>
  <html lang="en">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>${filePath}</title>
      <link rel="stylesheet" href="../assets/tomorrow.min.css">
      <script src="../assets/highlight.min.js"></script>
      <script>hljs.highlightAll();</script>
      <link rel="stylesheet" href="../assets/style.css">

      <style>
        @media print {


          @page {
            @top-left {
              margin-top: 1cm;
              content: "${showFilePath}";
            }

          }
        }
      </style>
  </head>
  <body>

  <main>
    <div class="container">
      ${preNumElm}
      <div id="line"></div>
      ${preCodeElm}
    </div>
  </main>

  </body>
  </html>
  `;
  return body;
}

生成 pdf 文件

使用 puppeteer-core 读取生成的 html 文件,打印另存为 pdf 文件。

js
/**
 * 打开 html 文件生成 pdf 文件
 * @param {*} srcFiles html文件路径
 * @returns pdf文件路径
 */
async function buildPdfFiles(srcFiles) {
  const result = [];
  const browser = await launch({
    executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  });
  for (const f of srcFiles) {
    const page = await browser.newPage();

    const currentDir = process.cwd();
    const fullPath = `file:\\${join(currentDir, f)}`;

    // 必须要读取文件才能获得正确样式
    await page.goto(fullPath);

    const targetPath = join(currentDir, 'pdf', `${basename(f)}.pdf`);
    result.push(targetPath);
    // 这个margin尺寸可以让每页固定50行
    await page.pdf({
      margin: {
        top: '2.6cm',
        bottom: '1.6cm',
        left: '1cm',
        right: '1cm'
      },
      path: targetPath,
      format: 'A4',
      preferCSSPageSize: true
    });
  }

  await browser.close();
  return result;
}

INFO

为什么不用 wkhtmltopdf

起初考虑过使用 wkhtmltopdf,但是经过试用,存在 3 个问题:

  1. wkhtmltopdf 无法支持 flex/grid 布局,无法实现带有行号。
  2. wkhtmltopdf 是命令行文件,和 nodejs 组合较麻烦。
  3. wkhtmltopdf 的仓库目前已经不再维护了。

为什么不用 prism.js

  1. 虽然 prism.js 可以添加行号,但是他的行号是在html页面生成的,打印时不会显示。
  2. prism.js 无法自动检测语言,更麻烦。

看看代码文件生成的效果。每页50行代码,页眉标注该软件名称、版本号,右上角有页码。

print-code

合并 pdf 文件

生成了多个 pdf 文件后,使用 pdf-merger-js 合并多个文件生成一个 pdf 文件。

js
/**
 * 合并pdf文件
 * @param {string[]} pdfFiles 文件名列表
 * @param {string} title 文件夹名称
 */
async function mergePdfFiles(pdfFiles, title) {
  const merger = new PDFMerger();

  for (const f of pdfFiles) {
    await merger.add(f);
  }
  const targetPath = tryFilePath(`合并_${title}.pdf`);
  await merger.save(targetPath);
  return targetPath;
}

添加页眉标识

使用 pdf-lib 库在 pdf 文件每一页的页眉添加标识。

js
/**
 * 添加页眉标记
 * @param {string} filePath 文件路径
 */
async function drawTitlePageNumber(filePath, title) {
  // Load a PDFDocument from the existing PDF bytes
  const pdfDoc = await PDFDocument.load(readFileSync(filePath));

  // open a font synchronously
  const simheiFontData = readFileSync('assets\\simhei.ttf');
  pdfDoc.registerFontkit(fontkit);
  const heitiFont = await pdfDoc.embedFont(simheiFontData);
  // Get the first page of the document
  const pages = pdfDoc.getPages();
  const totalPages = pdfDoc.getPageCount();

  let i = 1;

  // A4 页面尺寸为
  // Size { width: 595.91998, height: 842.88 }
  for (const page of pages) {
    // console.log('Size', page.getSize());
    //
    page.drawText(title, {
      x: 28,
      y: 810,
      size: 14,
      font: heitiFont
      // color: rgb(0.95, 0.1, 0.1)
    });

    page.drawText(`页码:${i}/${totalPages}`, {
      x: 480,
      y: 810,
      size: 14,
      font: heitiFont
    });
    i++;

    page.drawLine({
      start: { x: 28, y: 770 },
      end: { x: 568, y: 770 },
      thickness: 0.5,
      color: rgb(1, 0, 0),
      opacity: 0.75
    });
  }

  // Serialize the PDFDocument to bytes (a Uint8Array)
  const pdfBytes = await pdfDoc.save();
  const targetPath = tryFilePath(`加页眉_${title}.pdf`);
  writeFileSync(targetPath, pdfBytes);
}

中文字体的处理

要使用 fontkit 引入中文字体文件,再注册到文档中使用。

js
// Load a PDFDocument from the existing PDF bytes
  const pdfDoc = await PDFDocument.load(readFileSync(filePath));

  // open a font synchronously
  const simheiFontData = readFileSync('assets\\simhei.ttf');
  pdfDoc.registerFontkit(fontkit);
  const heitiFont = await pdfDoc.embedFont(simheiFontData);

实现的代码全文

js
import {
  existsSync,
  readdirSync,
  statSync,
  unlinkSync,
  rmdirSync,
  mkdirSync,
  readFileSync,
  writeFileSync,
  promises
} from 'fs';
import { join, dirname, basename } from 'path';

import hljs from 'highlight.js';
import { launch } from 'puppeteer-core';
import minimist from 'minimist';
import PDFMerger from 'pdf-merger-js';
import { PDFFont } from 'pdf-lib';
import { PDFDocument, rgb } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';

const FILE_LIST = 'filelist.txt';
const HTML_DIR = 'html';
const PDF_DIR = 'pdf';

function usage() {
  console.log(`批量代码软件

  使用方法:
    bun index.ts --src <源码文件夹> --title <页眉标题>`);
}
/**
 * 检测目标路径是否存在,存在就加序号
 * @param {string} filePath 目标路径
 */
function tryFilePath(filePath) {
  const dir = dirname(filePath);
  const name = basename(filePath);
  let i = 1;
  let newName = name;
  const [filename, ext] = name.split('.');
  let target = join(dir, newName);
  while (existsSync(target)) {
    newName = `${filename}(${i}).${ext}`;
    target = join(dir, newName);
    i += 1;
  }
  return target;
}

function getConfig() {
  const argv = process.argv.slice(2);
  const values = minimist(argv);
  return values;
}

function getFilelist(srcDir) {
  const lines = readFileSync(FILE_LIST).toString();
  const filePaths = lines.split('\n').filter((v) => v);
  return filePaths.map((v) => join(srcDir, v));
}

function range(start, stop, step = 1) {
  return Array.from(
    { length: (stop - start) / step + 1 },
    (_, i) => start + i * step
  );
}

function numberAuto(count) {
  return range(1, count).map((v) => v.toString().padStart(4, ' '));
}

/**
 * 生成带行号、语法高亮的html内容
 * @param {*} filePath 代码文件路径
 * @returns html文件内容
 */
function buildHtmlStr(filePath) {
  const codeStr = readFileSync(filePath).toString();
  const colorCode = hljs.highlightAuto(codeStr).value;
  const numberStr = numberAuto(codeStr.split('\n').length).join('\n');
  const preNumElm = `<pre><code>${numberStr}</code></pre>`;
  const preCodeElm = `<pre><code>${colorCode}</code></pre>`;

  const showFilePath = filePath.replace(/[\/\\]/g, '\\\\');
  const body = `
  <!DOCTYPE html>
  <html lang="en">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>${filePath}</title>
      <link rel="stylesheet" href="../assets/tomorrow.min.css">
      <script src="../assets/highlight.min.js"></script>
      <script>hljs.highlightAll();</script>
      <link rel="stylesheet" href="../assets/style.css">

      <style>
        @media print {


          @page {
            @top-left {
              margin-top: 1cm;
              content: "${showFilePath}";
            }

          }
        }
      </style>
  </head>
  <body>

  <main>
    <div class="container">
      ${preNumElm}
      <div id="line"></div>
      ${preCodeElm}
    </div>
  </main>

  </body>
  </html>
  `;
  return body;
}

function buildHtmlFiles(srcFiles) {
  const result = [];
  for (const f of srcFiles) {
    const htmlCode = buildHtmlStr(f);
    const targetPath = join(HTML_DIR, f.replace(/[\/\\]/g, '_') + '.html');
    result.push(targetPath);
    writeFileSync(targetPath, htmlCode);
  }
  return result;
}

/**
 * 打开 html 文件生成 pdf 文件
 * @param {*} srcFiles html文件路径
 * @returns pdf文件路径
 */
async function buildPdfFiles(srcFiles) {
  const result = [];
  const browser = await launch({
    executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  });
  for (const f of srcFiles) {
    const page = await browser.newPage();

    const currentDir = process.cwd();
    const fullPath = `file:\\${join(currentDir, f)}`;

    // 必须要读取文件才能获得正确样式
    await page.goto(fullPath);

    const targetPath = join(currentDir, 'pdf', `${basename(f)}.pdf`);
    result.push(targetPath);
    // 这个margin尺寸可以让每页固定50行
    await page.pdf({
      margin: {
        top: '2.6cm',
        bottom: '1.6cm',
        left: '1cm',
        right: '1cm'
      },
      path: targetPath,
      format: 'A4',
      preferCSSPageSize: true
    });
  }

  await browser.close();
  return result;
}

/**
 * 合并pdf文件
 * @param {string[]} pdfFiles 文件名列表
 * @param {string} title 文件夹名称
 */
async function mergePdfFiles(pdfFiles, title) {
  const merger = new PDFMerger();

  for (const f of pdfFiles) {
    await merger.add(f);
  }
  const targetPath = tryFilePath(`合并_${title}.pdf`);
  await merger.save(targetPath);
  return targetPath;
}

function removeDirPath(dir) {
  let files = [];
  if (existsSync(dir)) {
    //判断给定的路径是否存在
    const files = readdirSync(dir); //返回文件和子目录的数组
    files.forEach(function (file, index) {
      const curPath = join(dir, file);
      if (statSync(curPath).isDirectory()) {
        //同步读取文件夹文件,如果是文件夹,则函数回调
        removeDirPath(curPath);
      } else {
        unlinkSync(curPath); //是指定文件,则删除
        console.log('Delete', file);
      }
    });
    rmdirSync(dir); //清除文件夹
  } else {
    console.log('给定的路径不存在!');
  }
}

/**
 * 添加页眉标记
 * @param {string} filePath 文件路径
 */
async function drawTitlePageNumber(filePath, title) {
  // Load a PDFDocument from the existing PDF bytes
  const pdfDoc = await PDFDocument.load(readFileSync(filePath));

  // open a font synchronously
  const simheiFontData = readFileSync('assets\\simhei.ttf');
  pdfDoc.registerFontkit(fontkit);
  const heitiFont = await pdfDoc.embedFont(simheiFontData);
  // Get the first page of the document
  const pages = pdfDoc.getPages();
  const totalPages = pdfDoc.getPageCount();

  let i = 1;

  // A4 页面尺寸为
  // Size { width: 595.91998, height: 842.88 }
  for (const page of pages) {
    // console.log('Size', page.getSize());
    //
    page.drawText(title, {
      x: 28,
      y: 810,
      size: 14,
      font: heitiFont
      // color: rgb(0.95, 0.1, 0.1)
    });

    page.drawText(`页码:${i}/${totalPages}`, {
      x: 480,
      y: 810,
      size: 14,
      font: heitiFont
    });
    i++;

    page.drawLine({
      start: { x: 28, y: 770 },
      end: { x: 568, y: 770 },
      thickness: 0.5,
      color: rgb(1, 0, 0),
      opacity: 0.75
    });
  }

  // Serialize the PDFDocument to bytes (a Uint8Array)
  const pdfBytes = await pdfDoc.save();
  const targetPath = tryFilePath(`加页眉_${title}.pdf`);
  writeFileSync(targetPath, pdfBytes);
}

async function main() {
  removeDirPath(HTML_DIR);
  mkdirSync(HTML_DIR);

  removeDirPath(PDF_DIR);
  mkdirSync(PDF_DIR);

  const { src, title } = getConfig();
  console.log('Args', src, title);
  if (!src || !title) {
    usage();
    return;
  }

  const srcFiles = getFilelist(src);
  console.log('Code files', srcFiles);

  const htmlFiles = buildHtmlFiles(srcFiles);

  console.log('Html files', htmlFiles);
  const pdfFiles = await buildPdfFiles(htmlFiles);
  console.log('Pdf files', pdfFiles);

  const mergedPdf = await mergePdfFiles(pdfFiles, title);

  await drawTitlePageNumber(mergedPdf, title);
  console.log('Done');
}

main();

联系我们

公众号•高效生产力•openyunzhi-info

公众号:高效生产力

客服•小云朵•15987804306

微信:小云朵(云智科技)