document-server/src/PdfCreator.ts

308 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import axios from 'axios';
import cheerio from "cheerio";
import fs from "fs";
import Handlebars from 'handlebars';
import i18next from "i18next";
import Backend from 'i18next-fs-backend';
import mime from "mime-types";
import path from 'path';
import { PDFDocument } from 'pdf-lib';
import puppeteer from "puppeteer";
import { awaitAsyncHandlebarHelpers, helpers } from './asyncHelpers';
import { config } from './config';
import { CertificateRunner } from './models/CertificateRunner';
import { Runner } from './models/Runner';
import { RunnerCard } from './models/RunnerCard';
import { RunnerGroup } from './models/RunnerGroup';
/**
* This class is responsible for all things pdf creation.
* This uses the html templates from src/templates.
*/
export class PdfCreator {
private templateDir = path.join(__dirname, '/templates');
private browser;
private static interpolations = { eventname: config.eventname, sponsoring_receipt_minimum_amount: config.sponsoring_receipt_minimum_amount, currency_symbol: config.currency_symbol }
private static contractsPerRunner = config.contracts_per_runner;
/**
* Main constructor.
* Initializes i18n(ext), Handlebars and puppeteer.
*/
constructor() {
this.init();
}
/**
* Main constructor.
* Initializes i18n(ext), Handlebars and puppeteer.
*/
public async init() {
const minimal_args = [
'--autoplay-policy=user-gesture-required',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-breakpad',
'--disable-client-side-phishing-detection',
'--disable-component-update',
'--disable-default-apps',
'--disable-dev-shm-usage',
'--disable-domain-reliability',
'--disable-extensions',
'--disable-features=AudioServiceOutOfProcess',
'--disable-hang-monitor',
'--disable-ipc-flooding-protection',
'--disable-notifications',
'--disable-offer-store-unmasked-wallet-cards',
'--disable-popup-blocking',
'--disable-print-preview',
'--disable-prompt-on-repost',
'--disable-renderer-backgrounding',
'--disable-speech-api',
'--disable-sync',
'--hide-scrollbars',
'--ignore-gpu-blacklist',
'--metrics-recording-only',
'--mute-audio',
'--no-default-browser-check',
'--no-first-run',
'--no-pings',
'--no-zygote',
'--password-store=basic',
'--use-gl=swiftshader',
'--no-sandbox'
];
await i18next
.use(Backend)
.init({
fallbackLng: 'en',
lng: 'en',
backend: {
loadPath: path.join(__dirname, '/locales/{{lng}}.json')
}
});
await Handlebars.registerHelper(helpers);
await Handlebars.registerHelper('__',
function (str) {
return i18next.t(str, PdfCreator.interpolations).toString();
}
);
await Handlebars.registerHelper('--sponsor',
function (str) {
const index = (parseInt(str) % config.sponor_logos.length);
if (isNaN(index)) {
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg=="
}
return config.sponor_logos[index];
}
);
await Handlebars.registerHelper('--format_kilometers',
function (str) {
let meters = parseInt(str);
return ((meters / 1000).toLocaleString("en-EN", { minimumFractionDigits: 1, maximumFractionDigits: 3 }).replace(".", ","));
}
);
await Handlebars.registerHelper('--format_currency',
function (str) {
let meters = parseInt(str);
return ((meters / 100).toLocaleString("en-EN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).replace(".", ","));
}
);
this.browser = await puppeteer.launch({ headless: true, args: minimal_args });
}
/**
* Generate sponsoring contract pdfs.
* @param runner The runner you want to generate the contracts for.
* @param locale The locale used for the contracts (default:en)
*/
public async generateSponsoringContract(runners: Runner[], locale: string = "en", codeformat: string = config.codeformat): Promise<Buffer> {
if (runners.length == 1 && Object.keys(runners[0]).length == 0) {
runners[0] = this.generateEmptyRunner();
}
if (runners.length > 50) {
let pdf_promises = new Array<Promise<Buffer>>();
let i, j;
for (i = 0, j = runners.length; i < j; i += 50) {
let chunk = runners.slice(i, i + 50);
pdf_promises.push(this.generateSponsoringContract(chunk, locale));
}
const pdfs = await Promise.all(pdf_promises);
return await this.mergePdfs(pdfs);
}
for (var i = 1; i < PdfCreator.contractsPerRunner; i++) {
runners = runners.reduce(function (res, current, index, array) {
return res.concat([current, current]);
}, []);
}
await i18next.changeLanguage(locale);
const template_source = fs.readFileSync(`${this.templateDir}/sponsoring_contract.html`, 'utf8');
const template = Handlebars.compile(template_source);
let result = template({ runners, codeformat, disclaimer: config.disclaimer_text });
result = await awaitAsyncHandlebarHelpers(result);
const pdf = await this.renderPdf(result, { format: "A5", landscape: true });
return pdf
}
/**
* Generate runner card pdfs.
* @param cards The runner cars you want to generate the cards for.
* @param locale The locale used for the cards (default:en)
*/
public async generateRunnerCards(cards: RunnerCard[], locale: string = "en", codeformat: string = config.codeformat_cards): Promise<Buffer> {
if (cards.length > 10) {
let pdf_promises = new Array<Promise<Buffer>>();
let i, j;
for (i = 0, j = cards.length; i < j; i += 10) {
let chunk = cards.slice(i, i + 10);
pdf_promises.push(this.generateRunnerCards(chunk, locale, codeformat));
}
const pdfs = await Promise.all(pdf_promises);
return await this.mergePdfs(pdfs);
}
const cards_swapped = this.swapArrayPairs(cards);
await i18next.changeLanguage(locale);
const template_source = fs.readFileSync(`${this.templateDir}/runner_card.html`, 'utf8');
const template = Handlebars.compile(template_source);
let result = template({ cards, cards_swapped, eventname: config.eventname, codeformat: codeformat, card_subtitle: config.card_subtitle })
result = await awaitAsyncHandlebarHelpers(result);
const pdf = await this.renderPdf(result, { format: "A4", landscape: false });
return pdf
}
/**
* Generate sponsoring contract pdfs.
* @param runner The runner you want to generate the contracts for.
* @param locale The locale used for the contracts (default:en)
*/
public async generateRunnerCertficates(runners: CertificateRunner[], locale: string = "en"): Promise<Buffer> {
if (runners.length > 50) {
let pdf_promises = new Array<Buffer>();
let i, j;
for (i = 0, j = runners.length; i < j; i += 50) {
let chunk = runners.slice(i, i + 50);
pdf_promises.push(await this.generateRunnerCertficates(chunk, locale));
}
return await this.mergePdfs(pdf_promises);
}
await i18next.changeLanguage(locale);
const template_source = fs.readFileSync(`${this.templateDir}/runner_certificate.html`, 'utf8');
const template = Handlebars.compile(template_source);
let result = template({ runners, eventname: config.eventname, currency_symbol: config.currency_symbol, donations_footer_text: config.donations_footer_text });
result = await awaitAsyncHandlebarHelpers(result);
const pdf = await this.renderPdf(result, { format: "A4", landscape: false, printBackground: true });
return pdf;
}
/**
* Converts all images in html to base64.
* Works with image files in the template directory or images from urls.
* @param html The html string whoms images shall get replaced.
*/
public async imgToBase64(html): Promise<string> {
const $ = cheerio.load(html)
$('img').each(async (index, element) => {
let imgsrc = $(element).attr("src");
if (imgsrc.startsWith("data:image")) {
return;
}
const img_type = mime.lookup(imgsrc);
if (!(img_type.includes("image"))) {
throw new Error("File is not image mime type");
}
let image;
if (imgsrc.startsWith("http")) {
image = (await axios.get(imgsrc)).data;
image = Buffer.from(image).toString('base64');
}
else {
if (imgsrc.startsWith("./")) {
imgsrc = imgsrc.replace("./", "");
}
image = fs.readFileSync(`${this.templateDir}/${imgsrc}`, { encoding: "base64" });
}
image = `data:${img_type};base64,${image}`
$(element).attr("src", image)
});
return $.html();
}
/**
* This method manages the creation of pdfs via puppeteer.
* @param html The HTML that should get rendered.
* @param options Puppeteer PDF option (eg: {format: "A4"})
*/
public async renderPdf(html: string, options): Promise<any> {
html = await this.imgToBase64(html);
let page = await this.browser.newPage();
await page.setContent(html);
const pdf = await page.pdf(options);
await page.close();
return pdf;
}
/**
* Merges multiple pdfs into one.
* @param pdfs The pdfs you want to merge as an buffer array.
* @returns The merged pdf as a buffer.
*/
private async mergePdfs(pdfs: Buffer[]): Promise<Buffer> {
const mergedPdf = await PDFDocument.create();
for (const pdfBuffer of pdfs) {
const pdf = await PDFDocument.load(pdfBuffer);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach((page) => {
mergedPdf.addPage(page);
});
}
return <Buffer>(await mergedPdf.save());
}
/**
* Generates a new dummy runner with halfspaces for all strings.
* Can be used to generate empty sponsoring contracts.
* @returns A new runner object that apears to be empty.
*/
private generateEmptyRunner(): Runner {
let group = new RunnerGroup();
group.id = 0;
group.name = "";
let runner = new Runner();
runner.id = 0;
runner.firstname = "";
runner.lastname = "";
runner.group = group;
return runner;
}
/**
* Swaps pairs (0/1, 2/3, ...) of elements in an array recursively.
* If the last element has no partner it inserts an empty element at the end and swaps the two
* This is needed to generate pdfs with front- and backside that get printet on one paper.
* @param array The array which's pairs shall get switched.
* @returns Array with swapped pairs,
*/
private swapArrayPairs(array): Array<any> {
if (array.length == 1) {
return [null, array[0]];
}
if (array.length == 0) {
return null;
}
const rest = this.swapArrayPairs(array.slice(2))
if (!rest) {
return [array[1], array[0]]
}
return [array[1], array[0]].concat(rest);
}
}