Barcode generation feature/13-barcode_generation #21

Merged
niggl merged 13 commits from feature/13-barcode_generation into dev 2021-02-09 16:43:48 +00:00
6 changed files with 301 additions and 242 deletions

View File

@ -40,7 +40,9 @@
"license": "CC-BY-NC-SA-4.0", "license": "CC-BY-NC-SA-4.0",
"dependencies": { "dependencies": {
"@odit/class-validator-jsonschema": "^2.1.1", "@odit/class-validator-jsonschema": "^2.1.1",
"async-helpers": "^0.3.17",
"axios": "^0.21.1", "axios": "^0.21.1",
"bwip-js": "^2.0.12",
"cheerio": "^1.0.0-rc.5", "cheerio": "^1.0.0-rc.5",
"class-transformer": "0.3.1", "class-transformer": "0.3.1",
"class-validator": "^0.13.1", "class-validator": "^0.13.1",

View File

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

44
src/asyncHelpers.ts Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,19 +1,20 @@
import { config as configDotenv } from 'dotenv'; import { config as configDotenv } from 'dotenv';
configDotenv(); configDotenv();
export const config = { export const config = {
internal_port: parseInt(process.env.APP_PORT) || 4010, internal_port: parseInt(process.env.APP_PORT) || 4010,
development: process.env.NODE_ENV === "production", development: process.env.NODE_ENV === "production",
version: process.env.VERSION || require('../package.json').version, version: process.env.VERSION || require('../package.json').version,
eventname: process.env.EVENT_NAME || "Please set the event name", eventname: process.env.EVENT_NAME || "Please set the event name",
currency_symbol: process.env.CURRENCY_SYMBOL || "€", currency_symbol: process.env.CURRENCY_SYMBOL || "€",
sponsoring_receipt_minimum_amount: process.env.SPONSORING_RECEIPT_MINIMUM_AMOUNT || "10" sponsoring_receipt_minimum_amount: process.env.SPONSORING_RECEIPT_MINIMUM_AMOUNT || "10",
} codeformat: process.env.CODEFORMAT || "qrcode"
let errors = 0 }
if (typeof config.internal_port !== "number") { let errors = 0
errors++ if (typeof config.internal_port !== "number") {
} errors++
if (typeof config.development !== "boolean") { }
errors++ if (typeof config.development !== "boolean") {
} errors++
}
export let e = errors export let e = errors

View File

@ -15,7 +15,7 @@ export class PdfController {
@Post('/contracts') @Post('/contracts')
@OpenAPI({ description: "Generate Sponsoring contract pdfs from runner objects.<br>You can choose your prefered locale by passing the 'locale' query-param.<br> If you provide more than 100 runenrs this could take a moment or two (we tested up to 1000 runners in about 70sec so far)." }) @OpenAPI({ description: "Generate Sponsoring contract pdfs from runner objects.<br>You can choose your prefered locale by passing the 'locale' query-param.<br> If you provide more than 100 runenrs this could take a moment or two (we tested up to 1000 runners in about 70sec so far)." })
async generateContracts(@Body({ validate: true, options: { limit: "500mb" } }) runners: Runner | Runner[], @Res() res: any, @QueryParam("locale") locale: string) { async generateContracts(@Body({ validate: true, options: { limit: "500mb" } }) runners: Runner | Runner[], @Res() res: any, @QueryParam("locale") locale: string, @QueryParam("codeformat") codeformat: string) {
if (!this.initialized) { if (!this.initialized) {
await this.pdf.init(); await this.pdf.init();
this.initialized = true; this.initialized = true;
@ -23,7 +23,7 @@ export class PdfController {
if (!Array.isArray(runners)) { if (!Array.isArray(runners)) {
runners = [runners]; runners = [runners];
} }
const contracts = await this.pdf.generateSponsoringContract(runners, locale); const contracts = await this.pdf.generateSponsoringContract(runners, locale, codeformat);
res.setHeader('content-type', 'application/pdf'); res.setHeader('content-type', 'application/pdf');
return contracts; return contracts;
} }

View File

@ -29,31 +29,36 @@
<div class="sheet"> <div class="sheet">
<img id="header_img" width="100%" src="sponsoringheader.png" /> <img id="header_img" width="100%" src="sponsoringheader.png" />
<div style=" padding: 0 1rem 0 1rem;"> <div style=" padding: 0 1rem 0 1rem;">
<div class="columns" style="padding-bottom: 0;">
<div class="column is-two-fifths">
<p style="font-size: large; font-weight: bold;">{{__ "sponsoring_title"}}</p>
</div>
<div class="column">
<p style="font-size: x-small; vertical-align: revert; margin-top: auto;">{{__ "please_use_blockletters"}}
</p>
</div>
</div>
<p> {{__ "sponsoring_subtitle"}} </p>
<div class="columns" style="padding-top: 0;">
<div class="column is-8">
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.firstname}}
{{this.middlename}}</span>
<p style="font-size: x-small; display: block;">{{__ "firstname"}}</p>
</div>
<div class="column is-2">
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.id}}</span>
<p style="font-size: x-small; display: block;">ID</p>
</div>
<div class="column">
<!-- CODE Here -->
</div>
</div>
<div class="columns"> <div class="columns">
<div class="column is-10">
<div class="columns" style="padding-bottom: 0;">
<div class="column is-two-fifths">
<p style="font-size: large; font-weight: bold;">{{__ "sponsoring_title"}}</p>
</div>
<div class="column">
<p style="font-size: x-small; vertical-align: revert; margin-top: auto;">{{__ "please_use_blockletters"}}
</p>
</div>
</div>
<p> {{__ "sponsoring_subtitle"}} </p>
<div class="columns">
<div class="column is-9">
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.firstname}}
{{this.middlename}}</span>
<p style="font-size: x-small; display: block;">{{__ "firstname"}}</p>
</div>
<div class="column is-3">
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.id}}</span>
<p style="font-size: x-small; display: block;">ID</p>
</div>
</div>
</div>
<div class="column">
<img style="vertical-align: revert; margin-top: auto; object-fit: cover; max-height: 2cm;"
src="{{--bc this.id ../codeformat}}" />
</div>
</div>
<div class="columns" style="padding-top: 1rem;">
<div class="column is-6"> <div class="column is-6">
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.lastname}}</span> <span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.lastname}}</span>
<p style="font-size: x-small; display: block;">{{__ "lastname"}}</p> <p style="font-size: x-small; display: block;">{{__ "lastname"}}</p>