Pandoc
Pandoc 是一个功能强大的文档转换工具,支持多种文档格式之间的转换。它由 John MacFarlane 开发,广泛应用于学术写作、出版和技术文档处理等领域。
支持的格式
Pandoc 支持多种输入和输出格式,包括但不限于:
- 输入格式:Markdown、HTML、LaTeX、Word (.docx)、EPUB、reStructuredText 等。
- 输出格式:PDF、HTML、Word (.docx)、LaTeX、EPUB、Markdown、reStructuredText 等。
基本用法
Pandoc 的基本命令格式如下:
pandoc input_file -o output_file
例如,将 Markdown 文件转换为 PDF:
pandoc example.md -o example.pdf
高级功能
Pandoc 提供了许多高级功能,如模板支持、元数据处理、自定义过滤器等。可以通过命令行选项或 YAML 元数据块来配置这些功能。
安装
Pandoc 可以通过多种方式安装:
- 在 Linux 上,可以通过包管理器安装:
sudo apt-get install pandoc
- 在 macOS 上,可以通过 Homebrew 安装:
brew install pandoc
- 在 Windows 上,可以从 Pandoc 官网下载安装程序。
扩展性
Pandoc 支持通过 Lua 脚本编写自定义过滤器,进一步扩展其功能。这使得用户可以根据需要定制文档转换过程。
应用场景
Pandoc 适用于多种场景,如:
- 学术写作:将 Markdown 转换为 LaTeX 或 PDF,方便生成学术论文。
- 技术文档:将 reStructuredText 转换为 HTML 或 PDF,生成技术文档。
- 电子书制作:将 Markdown 或 HTML 转换为 EPUB,制作电子书。
Pandoc 的灵活性和强大功能使其成为处理文档转换任务的理想工具。
Pandoc-api
https://github.com/alphakevin/pandoc-api
Pandoc API
一个简单的 RESTful 服务器,用于使用 pandoc 转换文档
作为 Docker 容器运行
pandoc-api可以从 Docker 开始,无需安装源代码或 npm:
docker run -d -p 4000:4000 --name=pandoc --restart=always alphakevin/pandoc-api
这是一个简单的 http 服务器,应该作为内部微服务运行,因此它不包含任何授权方法。请自行承担公开部署的风险。
文档格式和选项没有经过全面测试,它只是将它们传递给 pandoc。
修改pandoc-api代码,实现通过链接形式下载转换后的文件
要支持将转换后的文件通过链接形式下载,我们可以修改代码,使得服务器返回一个包含下载链接的响应,而不是直接发送文件。以下是具体的修改步骤和代码示例:
1. 修改 src/app.ts
文件
在 app.post('/api/convert/:command(*)'
路由处理中,返回包含下载链接的 JSON 响应。
import * as os from 'os';
import * as path from 'path';
import * as express from 'express';
import * as mime from 'mime-types';
import * as multer from 'multer';
import * as uuid from 'uuid';
import { wrap } from 'async-middleware';
import * as contentDisposition from 'content-disposition';
import { Converter } from './converter';
import { ApiError, errorHandler } from './errors';
import { storage, uploadRaw } from './storage';
const packageJson = require('../package.json');
export function createApp() {
const app = express();
const converter = new Converter();
const upload = multer({
storage: storage,
});
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
res.set('X-Powered-By', `${packageJson.name}@${packageJson.version}`)
next();
});
app.get('/', (req, res, next) => {
res.redirect('/api');
});
app.get('/api/help', (req, res, next) => {
res.set('Content-Type', 'text/plain');
res.send(converter.getHelpText());
});
app.post('/api/convert/:command(*)',
uploadRaw(),
upload.single('file'),
wrap(async (req, res, next) => {
const { file } = req;
if (!file) {
throw new ApiError(400, 'cannot find input file');
}
const { command } = req.params;
const options = converter.parseUrlCommand(command);
const outputFile = await converter.convert(file.path, options);
const extname = path.extname(outputFile);
const basename = path.basename(file.originalname);
const filename = `${basename}${extname}`;
// 生成下载链接
const downloadUrl = `/api/download/${path.basename(outputFile)}`;
// 返回包含下载链接的 JSON 响应
res.json({
message: 'Conversion successful',
downloadUrl: downloadUrl
});
})
);
// 添加下载文件的路由
app.get('/api/download/:filename', async (req, res, next) => {
const { filename } = req.params;
const filePath = path.join(converter.getTmpDir(), filename);
res.download(filePath, (err) => {
if (err) {
next(new ApiError(500, 'download_error', 'Failed to download the file'));
}
});
});
app.use('*', (req, res, next) => {
throw new ApiError(404, 'route_not_found', 'the requested path does not exist');
});
app.use(errorHandler);
return app;
}
2. 修改 src/converter.ts
文件
添加一个方法 getTmpDir
用于获取临时目录。
import * as childProcess from 'child_process';
import * as path from 'path';
import { promisify } from 'util';
import _ from 'lodash';
import { tmpDir, availableValues, extensions } from './constants';
import { ApiError } from './errors';
import { CommandOptionDefine, CommandOptions } from './types';
const exec = promisify(childProcess.exec);
const EXEC_NAME = 'pandoc';
const parameterPattern = /(-([a-zA-Z0-9]), )?(--([a-zA-Z0-9-]+)(=([^,]+))?)/g;
export class Converter {
private helpText: string;
private converterHelpText: string;
private options: CommandOptionDefine[];
private optionMap: Map<string, CommandOptionDefine>;
private _ready: Promise<any>;
constructor() {
this.init();
}
init() {
this._ready = exec(`${EXEC_NAME} --help`)
.then((result) => {
this.helpText = result.stdout;
this.parseHelpInfo();
});
}
ready() {
return this._ready;
}
parseHelpInfo() {
const lines = this.helpText.split('\n');
const options: CommandOptionDefine[] = [];
const help = [
'pandoc-api, a RESTful wrapper for pandoc',
' please visit https://github.com/alphakevin/pandoc-api',
'',
'converting:',
' upload with multipart/form-data:',
' curl -F file=@example.docx http://127.0.0.1:4000/api/convert/from/docx/to/html > result.html',
' upload raw:',
' curl -X POST \\',
' -T "example.docx" \\',
' -H "Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document" \\',
' -H "Content-Disposition: attachment; filename=\"example.docx\"" \\',
' http://127.0.0.1:4000/api/convert/from/docx/to/html > result.html',
'',
'converter options:',
'',
];
const optionMap: Map<string, CommandOptionDefine> = new Map();
lines.forEach(line => {
const matches = line.match(parameterPattern);
if (!matches) {
return;
};
const option: CommandOptionDefine = {
type: 'boolean',
names: [],
values: [],
defaultName: '',
optionalValue: false,
};
matches.forEach(match => {
parameterPattern.lastIndex = 0;
const result = parameterPattern.exec(match);
let [, short, shortValue, long, longValue] = result;
if (short) {
optionMap.set(short, option);
option.names.push(short);
if (option.defaultName.length === 0) {
option.defaultName = short;
}
if (shortValue) {
option.type = shortValue.includes(':') ? 'meta' : 'string';
}
} else {
optionMap.set(long, option);
option.names.push(long);
if (option.defaultName.length <= 1) {
option.defaultName = long;
}
if (longValue) {
if (/^\[/.test(longValue)) {
option.type = 'string';
option.optionalValue = true;
} else if (longValue.includes('|')) {
option.values = longValue.slice(1).split('|');
option.type = /^[a-z]$/.test(option.values[0]) ? 'enum' : 'string';
}
}
}
});
const values = availableValues[option.defaultName];
if (values) {
option.type = 'enum';
option.values = values;
}
options.push(option);
});
help.push('');
this.converterHelpText = help.join('\n') + this.helpText;
this.options = options;
this.optionMap = optionMap;
}
parseUrlCommand(commands: string): CommandOptions {
const list = commands.split('/');
const options = new CommandOptions(this.optionMap);
const pairs = _.chunk(list, 2);
pairs.forEach(([key, value]) => {
options.set(key, value);
});
return options;
}
getHelpText() {
return this.converterHelpText;
}
getFormatExtension(format: string) {
return extensions[format] || 'txt';
}
convert(inputFile: string, options: CommandOptions): Promise<string> {
const extension = this.getFormatExtension(options.get('to') as string);
const outputFile = `${tmpDir}/${path.parse(inputFile).name}.${extension}`;
const args = options.toArgs();
args.push(`--output=${outputFile}`);
args.push(inputFile);
console.log('inputFile = ', inputFile);
console.log(`pandoc ${args.join(' ')}`);
return new Promise((resolve, reject) => {
const handler = childProcess.spawn('pandoc', args);
const errors = [];
handler.stderr.on('data', error => {
errors.push(error);
});
handler.on('exit', () => {
if (errors.length) {
return reject(new Error(Buffer.concat(errors).toString('utf8')));
}
resolve(outputFile);
});
})
}
getTmpDir() {
return tmpDir;
}
}
3. 测试
修改后的代码会在文件转换成功后返回一个包含下载链接的 JSON 响应。客户端可以通过访问这个链接来下载转换后的文件。
例如,使用 curl
进行测试:
$ curl -F file=@example.docx http://127.0.0.1:4000/api/convert/from/docx/to/html
{
"message": "Conversion successful",
"downloadUrl": "/api/download/example.html"
}
然后,客户端可以使用返回的下载链接来下载文件:
$ curl http://127.0.0.1:4000/api/download/example.html -o result.html
通过以上修改,服务器现在支持将转换后的文件通过链接形式下载。