办公技术分享
如何把软件代码批量打印成 pdf 文件?
申请软件著作权登记时,把软件代码批量打印成pdf文件的思路和实现
2024/6/26
申请软件著作权登记时,我们需要按相关的要求把代码打印成 pdf 文件上传。
但是搜索了一圈,发现基本上各种网页插件、编辑器插件都只能单独地一个文件一个文件处理。
作为一个程序员,当然不能这样傻傻地一个一个去打印代码文件。代码文件数量多、行数长短不一、而且分布在不同的文件夹里。一个一个处理太慢了,效率太低。生成的代码 pdf 文件也不好看,没有行号,没有语法高亮。
我经过一番研究,自己写了一个nodejs脚本,来批量打印代码到 pdf 文件,而且可以支持代码行号、语法高亮。
提示
软件著作权登记上传源代码的要求:
页眉建议标注该软件名称、版本号,内容应与申请表中填写的一致;右上角标注页码,源程序从正文第 1 页编到第 60 页,文档从目录开始由第 1 页编到第 60 页。
总体流程
- 拉取要打印的项目代码,通过
git ls-files > filelist.txt
取得需要打印的代码文件列表。手工清除不需要的文件,如图片、压缩包等,并调整为需要的顺序。 - 依次读取这些代码文件,通过
highlight.js
生成带有行号、语法高亮的 html 文件。 - 使用
puppeteer-core
依次打开上一步生成的单个代码的html
文件,转换成带有每页固定行数,有行号、语法高亮的pdf
文件。 - 使用
pdf-merger-js
把多个pdf
文件按顺序合并起来生成一份代码pdf
文件。 - 使用
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 个问题:
- wkhtmltopdf 无法支持 flex/grid 布局,无法实现带有行号。
- wkhtmltopdf 是命令行文件,和 nodejs 组合较麻烦。
- wkhtmltopdf 的仓库目前已经不再维护了。
为什么不用 prism.js
?
- 虽然 prism.js 可以添加行号,但是他的行号是在html页面生成的,打印时不会显示。
- prism.js 无法自动检测语言,更麻烦。
看看代码文件生成的效果。每页50行代码,页眉标注该软件名称、版本号,右上角有页码。
合并 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();