express-dom-pdf
Version:
PDF plugin for express-dom
391 lines (359 loc) • 10.8 kB
JavaScript
const debug = require('debug')('express-dom-pdf');
const {
promises: fs,
createReadStream
} = require('node:fs');
const { pipeline } = require('node:stream/promises');
const { cpus } = require('node:os');
const util = require('node:util');
const exec = util.promisify(require('node:child_process').exec);
const tmp = require('tmp');
const Path = require('node:path');
module.exports = class PdfHandler {
static timeout = 30000;
static pdfxDef = Path.join(__dirname, '..', 'PDFX_def.ps');
static pdfaDef = Path.join(__dirname, '..', 'PDFA_def.ps');
static iccdir = '/usr/share/color/icc';
static plugins = new Set(['console']);
static presets = {
default: {
quality: false,
devicePixelRatio: 1,
format: 'A4',
distiller: {
PreserveAnnots: true,
DetectDuplicateImages: false
}
},
screen: {
quality: 'screen',
devicePixelRatio: 1,
format: 'A4',
distiller: {
PreserveAnnots: true,
DetectDuplicateImages: false
}
},
ebook: {
quality: 'ebook',
devicePixelRatio: 2,
format: 'A4',
distiller: {
PreserveAnnots: true,
DetectDuplicateImages: false
}
},
printer: {
pageCount: true,
quality: 'printer',
devicePixelRatio: 4,
format: 'A4',
distiller: {
PreserveAnnots: false,
DetectDuplicateImages: false,
MaxInlineImageSize: 0
}
},
prepress: {
quality: 'prepress',
devicePixelRatio: 4,
format: 'A4',
distiller: {
PreserveAnnots: false,
DetectDuplicateImages: false,
MaxInlineImageSize: 0
}
}
};
static policies = {
script: "'self' https:",
connect: "'self' https:",
img: "'self' https: data:",
font: "'self' https: data:",
style: "'self' 'unsafe-inline' https:"
};
#settings;
#threads;
#parallel;
constructor(opts) {
const policies = Object.assign({}, PdfHandler.policies, opts.policies);
const presets = Object.assign({}, PdfHandler.presets, opts.presets);
this.#settings = Object.assign({}, PdfHandler, opts, { policies, presets });
this.chain = handler => this.#init(handler);
this.#parallel = opts.parallel ?? 1;
this.#threads = Math.min(4, Math.max(cpus().length - 1, 1));
}
#init(handler) {
const { online, plugins } = handler;
plugins.pdf = (...args) => this.plugin(...args);
const {
timeout, policies, plugins: userPlugins, presets
} = this.#settings;
if (userPlugins) for (const name of userPlugins) {
online.plugins.add(name);
}
online.plugins.delete('html');
online.plugins.add('pdf');
online.hidden = false;
online.timeout = timeout;
online.pdf = function (name) {
this.preset = name;
const preset = presets[name];
if (!preset) return;
if (preset.devicePixelRatio) this.devicePixelRatio = preset.devicePixelRatio;
if (preset.browser) this.browser = preset.browser;
};
Object.assign(online.policies, policies);
}
plugin(page, settings, req, res) {
const { presets } = this.#settings;
const preset = presets[settings.preset ?? 'default'];
if (!preset) {
const err = new Error("Unknown preset: " + settings.preset);
err.statusCode = 400;
throw err;
}
if (preset.quality && ["screen", "ebook", "printer", "prepress"].includes(preset.quality) == false) {
console.warn("Uknown pdf preset quality", preset.quality);
delete preset.quality;
}
settings.styles.push(`html {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}`);
if (preset.browser) settings.browser = preset.browser;
if (preset.pdfa && settings.browser == "firefox") {
settings.styles.push(`* {
image-rendering: crisp-edges !important;
}`);
}
page.on('request', request => {
const headers = request.headers();
if (headers['accept']?.startsWith('image/')) {
return request.continue({
headers: {
...headers,
accept: 'image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5'
}
});
}
});
page.on('idle', async () => {
if (res.statusCode != 200) {
const err = new Error(res.statusText);
err.statusCode = res.statusCode;
throw err;
}
await page.evaluate(async () => {
if (document.fonts.status == "loaded") return;
let resolver;
const abort = new Promise((resolve, reject) => {
const toId = setTimeout(() => {
const err = new Error("Fonts timeout");
err.statusCode = 500;
reject(err);
}, 10000);
resolver = function () {
clearTimeout(toId);
resolve();
};
});
await Promise.all([document.fonts.ready.finally(resolver), abort]);
});
const outputPath = tmp.tmpNameSync({ postfix: 'browser.pdf' });
await page.pdf({
preferCSSPageSize: true,
printBackground: true,
path: outputPath,
format: preset.format,
timeout: this.#settings.timeout
});
const title = await page.title() || Path.basename(
page.location.pathname, Path.extname(page.location.pathname)
);
debug("getting pdf", title, outputPath);
const pageCount = preset.pageCount || preset.quality && this.#parallel > 1
? await this.#pageCount(outputPath)
: null;
if (pageCount) {
res.set('X-Page-Count', pageCount);
}
res.attachment(title.substring(0, 123) + '.pdf');
res.type('application/pdf');
if (preset.quality) {
await this.#ghostscript(title, outputPath, preset, pageCount);
}
const stream = createReadStream(outputPath);
// cannot await output during idle event
pipeline(stream, res).catch(err => {
if (err.code != 'ERR_STREAM_PREMATURE_CLOSE') {
console.error(err);
}
}).finally(() => {
fs.unlink(outputPath);
});
});
}
async #pageCount(path) {
const cmd = `gs -dQUIET -dNODISPLAY -dNOSAFER -dBATCH -sFileName='${path}' -c "FileName (r) file runpdfbegin 1 1 pdfpagecount = quit"`;
const { stdout, stderr } = await exec(cmd);
if (stderr) throw new Error("Cannot spawn ghostscript command: 'gs' or bad path");
const n = parseInt(stdout.trim());
if (Number.isNaN(n)) throw new Error("Could not get page count");
return n;
}
async #ghostscript(title, inputPath, preset, pageCount) {
const { pdfxDef, pdfaDef, iccdir } = this.#settings;
const {
quality,
icc = 'ghostscript/default_rgb.icc',
condition = '',
pdfx, pdfa,
distiller = {},
others = []
} = preset;
// http://milan.kupcevic.net/ghostscript-ps-pdf/
// ALL OPTIONS http://ghostscript.com/doc/current/Ps2pdf.htm
// http://ghostscript.com/doc/current/VectorDevices.htm#PDFWRITE
// the images quality
// screen: 72 dpi
// ebook: 150 dpi
// printer: 300 dpi
// prepress: 300 dpi, color preserving
const isForScreen = ["screen", "ebook"].includes(quality);
const args = [
"gs",
"-q", // do not log to stdout
"-sstdout=%stderr", // redirect postscript errors to stderr
"-dBATCH",
"-dNOPAUSE",
"-dNumRenderingThreads=" + this.#threads,
"-dPDFSETTINGS=/" + quality,
"-sDEVICE=pdfwrite"
];
for (const [key, str] of Object.entries(distiller)) {
args.push(`-d${key}=${str}`);
}
for (const arg of others) {
if (arg) args.push(arg);
}
const temps = [];
const args2 = [];
const iccPath = Path.join(iccdir, icc);
if (!iccPath.startsWith(iccdir)) {
throw new Error("Forbidden icc path: " + icc);
}
// TODO this part, and the next one (setting title)
// should be done when regrouping the files
if (pdfx) {
const pdfxDefPath = tmp.tmpNameSync({ postfix: 'pdfx.ps' });
if (iccPath) args.push('--permit-file-read=' + iccPath);
const pdfxData = (await fs.readFile(pdfxDef)).toString()
.replace('!ICC!', escapePsString(iccPath))
.replace('!CONDITION!', escapePsString(condition))
.replace('!TITLE!', escapePsString(title));
await fs.writeFile(pdfxDefPath, pdfxData);
temps.push(pdfxDefPath);
args.push('-sColorConversionStrategy=CMYK');
args2.push(
'-dPDFX=true',
'-dRenderIntent=3',
pdfxDefPath
);
} else if (pdfa) {
const pdfaDefPath = tmp.tmpNameSync({ postfix: 'pdfa.ps' });
if (iccPath) args.push('--permit-file-read=' + iccPath);
const pdfaData = (await fs.readFile(pdfaDef)).toString()
.replace('!ICC!', escapePsString(iccPath))
.replace('!CONDITION!', escapePsString(condition))
.replace('!TITLE!', escapePsString(title));
await fs.writeFile(pdfaDefPath, pdfaData);
temps.push(pdfaDefPath);
args.push('-sColorConversionStrategy=RGB');
args2.push(
'-dPDFA=2',
'-dPDFACompatibilityPolicy=1',
'-dRenderIntent=3',
pdfaDefPath
);
} else if (!isForScreen) {
args.push('-sColorConversionStrategy=RGB');
}
// set title
args2.push(
"-c",
`"[ /Title (${escapePsString(title)}) /DOCINFO pdfmark"`,
"-c",
`50000000 setvmthreshold`,
"-f"
);
debug("gs", args);
const outParts = Path.parse(inputPath);
delete outParts.base;
outParts.name += "-%d";
const outputPattern = Path.format(outParts);
const inputs = [];
if (this.#parallel > 1) {
const { stderr } = await exec(`pdfseparate "${inputPath}" "${outputPattern}"`);
if (stderr) throw new Error(stderr);
const files = [];
const subCount = Math.ceil(pageCount / this.#parallel);
let startPage = 0;
let lastPage = subCount;
do {
const outputFile = tmp.tmpNameSync({ postfix: 'out.pdf' });
for (let i = startPage; i < Math.min(lastPage, pageCount); i++) {
inputs.push(util.format(outputPattern, i + 1));
}
files.push(this.#processFiles(
args, args2, inputs, outputFile
));
startPage = lastPage;
lastPage += subCount;
} while (startPage < pageCount);
try {
return this.#groupFiles(await Promise.all(files), inputPath);
} catch (err) {
console.error(err);
throw err;
} finally {
this.#clean(temps);
}
} else {
inputs.push(util.format(outputPattern, 1));
await fs.rename(inputPath, inputs[0]);
await this.#processFiles(
args, args2, inputs, inputPath
);
}
}
async #groupFiles(list, output) {
const ins = list.map(str => `"${str}"`).join(' ');
try {
const { stderr } = await exec(`pdfunite ${ins} "${output}"`);
if (stderr?.length) throw new Error(stderr);
} finally {
this.#clean(list);
}
}
async #processFiles(args, args2, inputs, outputFile) {
args = args.slice();
args.push(`-sOutputFile="${outputFile}"`, ...args2, ...inputs);
try {
const { stderr } = await exec(args.join(' '));
if (stderr?.length) throw new Error(stderr);
} finally {
await this.#clean(inputs);
}
return outputFile;
}
#clean(list) {
list.map(str => fs.unlink(str).catch(err => {
console.error(err);
}));
}
};
function escapePsString(str) {
// eslint-disable-next-line no-control-regex
return str.replaceAll(/[^\x48-\x7E]|[\x60]/g, char => '\\' + char.charCodeAt(0).toString(8));
}