diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b25c85..6bc47e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,19 @@ All notable changes to this project will be documented in this file. Dates are displayed in UTC. -#### [v0.5.2](https://git.odit.services/lfk/document-server/compare/v0.5.1...v0.5.2) +#### [v0.5.3](https://git.odit.services/lfk/document-server/compare/v0.5.1...v0.5.3) - Merge branch 'bugfix/44-runner-certificates-result-in-a-status-500' into dev [`#44`](https://git.odit.services/lfk/document-server/issues/44) +- Fixed Locale comma format [`2a33226`](https://git.odit.services/lfk/document-server/commit/2a3322612d473bd9002cf8d6f9807f9dc7d687da) - wrap distanceDonations.reduce in array length check [`bac004d`](https://git.odit.services/lfk/document-server/commit/bac004d74eb954d1753d4efcdb927822b89fa757) - 🧾New changelog file version [CI SKIP] [skip ci] [`ff0421d`](https://git.odit.services/lfk/document-server/commit/ff0421da2f16a8f79f9987dabea7bdcb4ef88c05) +- 🧾New changelog file version [CI SKIP] [skip ci] [`f846572`](https://git.odit.services/lfk/document-server/commit/f8465721cddfb55d51eb30d29d74ef63d825b5ac) +- Fix for runner donation array [`72303b1`](https://git.odit.services/lfk/document-server/commit/72303b11052276ad15373887f9e04183841f56f4) +- 🧾New changelog file version [CI SKIP] [skip ci] [`451b7fb`](https://git.odit.services/lfk/document-server/commit/451b7fbe0543991e8a203e38daa350a954ae0e11) +- 🧾New changelog file version [CI SKIP] [skip ci] [`573b921`](https://git.odit.services/lfk/document-server/commit/573b9211972a55df0a38742cb6eb789d6fd3717b) +- 🚀Bumped version to v0.5.3 [`01e1323`](https://git.odit.services/lfk/document-server/commit/01e1323555fe67f6f0ce3c18163e475035bd1cdd) +- 🧾New changelog file version [CI SKIP] [skip ci] [`4b4d66a`](https://git.odit.services/lfk/document-server/commit/4b4d66ae784150f7e1cc491a3fc5d84c93273aee) +- Merge pull request 'v0.5.2: hotfix TypeError in Runner Certificate generation' (#45) from dev into main [`c935950`](https://git.odit.services/lfk/document-server/commit/c935950eb052bce71185fc74c750ec77f081e7df) - 🚀Bumped version to v0.5.2 [`274c13e`](https://git.odit.services/lfk/document-server/commit/274c13e358f16207fe8bb5cdc1b9ede0582ecb46) - 🧾New changelog file version [CI SKIP] [skip ci] [`b7b7f6a`](https://git.odit.services/lfk/document-server/commit/b7b7f6a0ae304d24f90a3de3931f53cf08770060) diff --git a/package.json b/package.json index 025141c..d296d95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@odit/lfk-document-server", - "version": "0.5.2", + "version": "0.5.3", "description": "The document generation server for the LfK! runner system. This generates certificates, sponsoring aggreements and more", "main": "src/app.ts", "scripts": { diff --git a/src/PdfCreator.ts b/src/PdfCreator.ts index e3446ce..f6ff3ff 100644 --- a/src/PdfCreator.ts +++ b/src/PdfCreator.ts @@ -1,308 +1,308 @@ -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).toFixed(3).toString()) - } - ); - await Handlebars.registerHelper('--format_currency', - function (str) { - let meters = parseInt(str); - return ((meters / 100).toFixed(2).toString()) - } - ); - 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); - } +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("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 3 })) + } + ); + await Handlebars.registerHelper('--format_currency', + function (str) { + let meters = parseInt(str); + return ((meters / 100).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 })) + } + ); + 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); + } } \ No newline at end of file diff --git a/src/controllers/PdfController.ts b/src/controllers/PdfController.ts index f6d8a52..ba4ee72 100644 --- a/src/controllers/PdfController.ts +++ b/src/controllers/PdfController.ts @@ -98,8 +98,10 @@ export class PdfController { runner.group.fullName = `${runner.group.parentGroup.name}/${runner.group.name}`; } runner.donationPerDistanceTotal = 0; + if (!Array.isArray(runner.distanceDonations)){ + runner.distanceDonations = [].concat(runner.distanceDonations) + } if (runner.distanceDonations.length > 0) { - console.log(typeof runner.distanceDonations); runner.donationPerDistanceTotal += runner.distanceDonations.reduce(function (sum, current) { return sum + current.amountPerDistance; }, 0);