271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
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 { 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];
|
||
}
|
||
);
|
||
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();
|
||
}
|
||
for (var i = 1; i < PdfCreator.contractsPerRunner; i++) {
|
||
runners = runners.reduce(function (res, current, index, array) {
|
||
return res.concat([current, current]);
|
||
}, []);
|
||
}
|
||
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);
|
||
}
|
||
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): 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));
|
||
}
|
||
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: "LfK! 2069", codeformat: "qrcode" })
|
||
result = await awaitAsyncHandlebarHelpers(result);
|
||
fs.writeFileSync("lelelelele.tmp", result);
|
||
const pdf = await this.renderPdf(result, { format: "A4", landscape: false });
|
||
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);
|
||
}
|
||
} |