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 { if (runners.length == 1 && Object.keys(runners[0]).length == 0) { runners[0] = this.generateEmptyRunner(); } if (runners.length > 50) { let pdf_promises = new Array>(); 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 { if (cards.length > 10) { let pdf_promises = new Array>(); 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 { if (runners.length > 50) { let pdf_promises = new Array(); 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 { 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 { 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 { 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 (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 { 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); } }