Merge pull request 'Sponsoring contract generation feature/5-sponsoring_contracts' (#10) from feature/5-sponsoring_contracts into dev
continuous-integration/drone/push Build is failing
Details
continuous-integration/drone/push Build is failing
Details
Reviewed-on: #10
This commit is contained in:
commit
84259d37d4
|
@ -4,6 +4,7 @@
|
|||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/i18n-ally-custom-framework.yml
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
|
|
|
@ -2,7 +2,10 @@
|
|||
"recommendations": [
|
||||
"2gua.rainbow-brackets",
|
||||
"christian-kohler.npm-intellisense",
|
||||
"remimarsal.prettier-now"
|
||||
"remimarsal.prettier-now",
|
||||
"lokalise.i18n-ally",
|
||||
],
|
||||
"unwantedRecommendations": []
|
||||
"unwantedRecommendations": [
|
||||
"antfu.i18n-ally"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
languageIds:
|
||||
- javascript
|
||||
- html
|
||||
keyMatchReg:
|
||||
- '\{\{__ "([a-zA-Z0-9_]+)"\}\}'
|
||||
monopoly: false
|
||||
refactorTemplates:
|
||||
- '{{__ "$1"}}'
|
|
@ -5,7 +5,6 @@
|
|||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"prettier.enable": false,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features",
|
||||
"editor.codeActionsOnSave": {
|
||||
|
@ -16,5 +15,13 @@
|
|||
"javascript.preferences.quoteStyle": "single",
|
||||
"javascript.preferences.importModuleSpecifierEnding": "minimal",
|
||||
"typescript.preferences.importModuleSpecifierEnding": "minimal",
|
||||
"typescript.preferences.includePackageJsonAutoImports": "on"
|
||||
"typescript.preferences.includePackageJsonAutoImports": "on",
|
||||
"i18n-ally.localesPaths": "src/locales",
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.extract.keygenStrategy":"slug",
|
||||
"i18n-ally.enabledFrameworks": [
|
||||
"custom"
|
||||
],
|
||||
"i18n-ally.sourceLanguage": "en"
|
||||
|
||||
}
|
11
README.md
11
README.md
|
@ -37,7 +37,7 @@ The basic generation mechanism makes the templates and routes interchangeable (i
|
|||
> The templates are stored in src/templates by default.
|
||||
|
||||
We provide a set of default templates that we use for the ["Lauf für Kaya!" charity run](https://lauf-fuer-kaya.de).
|
||||
We use mustache-style templating strings to fill the templates with real information (exact strings are explained below).
|
||||
We use handlebars for templateing utilizing i18next for translation - the i18n string format in the templates is : `{{__ "string"}}`
|
||||
You can provide your own templates by replacing the ones we provided before compiling the project or by simply mounting your custom templates into the docker container.
|
||||
|
||||
The server currently needs the following templates to work:
|
||||
|
@ -45,12 +45,10 @@ The server currently needs the following templates to work:
|
|||
|
||||
### Sponsoring Contracts
|
||||
|
||||
| Template String | Type | Optional | Description
|
||||
| Template Data | Type | Optional | Description
|
||||
| - | - | - | -
|
||||
| runner_firstname | string | ❌ | The runner's first name
|
||||
| runner_middlename | string | ✅ | The runner's middle name
|
||||
| runner_lastname | string | ❌ | The runner's last name
|
||||
| runner_id | int | ❌ | The runner's id
|
||||
| runners | array(Runner) | ❌ | The runner objects. We generate a contract for each runner on a new DIN-A5 page.
|
||||
|
||||
|
||||
## Recommended Editor
|
||||
|
||||
|
@ -59,6 +57,7 @@ The server currently needs the following templates to work:
|
|||
### Recommended Extensions
|
||||
|
||||
* will be automatically recommended via ./vscode/extensions.json
|
||||
* we also provide a config for i18n-ally in the .vscode folder
|
||||
|
||||
## Staging
|
||||
### Branches & Tags
|
||||
|
|
13
package.json
13
package.json
|
@ -7,7 +7,8 @@
|
|||
"dev": "nodemon src/app.ts",
|
||||
"build": "rimraf ./dist && tsc",
|
||||
"licenses:export": "license-exporter --markdown",
|
||||
"release": "release-it --only-version"
|
||||
"release": "release-it --only-version",
|
||||
"translations:sort": "node sort_translations.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -39,13 +40,20 @@
|
|||
"license": "CC-BY-NC-SA-4.0",
|
||||
"dependencies": {
|
||||
"@odit/class-validator-jsonschema": "^2.1.1",
|
||||
"axios": "^0.21.1",
|
||||
"cheerio": "^1.0.0-rc.5",
|
||||
"class-transformer": "0.3.1",
|
||||
"class-validator": "^0.13.1",
|
||||
"consola": "^2.15.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"html-pdf": "^2.2.0",
|
||||
"handlebars": "^4.7.6",
|
||||
"i18next": "^19.8.7",
|
||||
"i18next-fs-backend": "^1.0.8",
|
||||
"mime-types": "^2.1.28",
|
||||
"pdf-lib": "^1.16.0",
|
||||
"puppeteer": "^7.0.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"routing-controllers": "^0.9.0-alpha.6",
|
||||
"routing-controllers-openapi": "^2.2.0"
|
||||
|
@ -54,6 +62,7 @@
|
|||
"@odit/license-exporter": "^0.0.9",
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/node": "^14.14.22",
|
||||
"@types/puppeteer": "^5.4.3",
|
||||
"nodemon": "^2.0.7",
|
||||
"release-it": "^14.2.2",
|
||||
"rimraf": "^3.0.2",
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
const fs = require('fs');
|
||||
// get all language files
|
||||
const files = fs.readdirSync('./src/locales/');
|
||||
files.forEach((f) => {
|
||||
// read file as object
|
||||
const unordered = JSON.parse(fs.readFileSync(`src/locales/${f}`));
|
||||
// order object by keys alpabetically A-Z
|
||||
const ordered = Object.keys(unordered).sort().reduce((obj, key) => {
|
||||
obj[key] = unordered[key];
|
||||
return obj;
|
||||
}, {});
|
||||
// format output as json for commit diff compatibility
|
||||
const out = JSON.stringify(ordered, 0, 4);
|
||||
// write output file
|
||||
fs.writeFileSync(`src/locales/${f}`, out);
|
||||
});
|
|
@ -1,56 +1,197 @@
|
|||
import axios from 'axios';
|
||||
import cheerio from "cheerio";
|
||||
import fs from "fs";
|
||||
import pdf_converter from "html-pdf";
|
||||
import Handlebars from 'handlebars';
|
||||
import i18next from "i18next";
|
||||
import Backend from 'i18next-fs-backend';
|
||||
import mime from "mime-types";
|
||||
import path from 'path';
|
||||
import { Stream } from 'stream';
|
||||
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import puppeteer from "puppeteer";
|
||||
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.
|
||||
*/
|
||||
export class PdfCreator {
|
||||
private templateDir = path.join(__dirname, '/templates');
|
||||
private browser;
|
||||
private static interpolations = { eventname: "Lauf für Kaya! 2021", sponsoring_receipt_minimum_amount: '10', currency_symbol: "€" }
|
||||
|
||||
//TODO: Accept the runner class
|
||||
public async generateSponsoringContract(): Promise<Pdf> {
|
||||
let template = fs.readFileSync(`${this.templateDir}/sponsoring_contract.html`, 'utf8');
|
||||
template = template.replace("{{Runner Name}}", "lelele");
|
||||
return new Pdf(await pdf_converter.create(template, { format: "A5", orientation: "landscape" }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is a wrapper for the pdf objects created by html-pdf.
|
||||
* It offers typed conversion to Buffer and Stream.
|
||||
*/
|
||||
export class Pdf {
|
||||
content: any;
|
||||
|
||||
constructor(pdf: any) {
|
||||
this.content = pdf;
|
||||
/**
|
||||
* Main constructor.
|
||||
* Initializes i18n(ext), Handlebars and puppeteer.
|
||||
*/
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise wrapper function that resolves the toBuffer promise for pdf generation.
|
||||
* Main constructor.
|
||||
* Initializes i18n(ext), Handlebars and puppeteer.
|
||||
*/
|
||||
public async toBuffer(): Promise<Buffer> {
|
||||
let promise = await new Promise<Buffer>((resolve, reject) => {
|
||||
this.content.toBuffer(function (err, buffer: Buffer) {
|
||||
resolve(buffer);
|
||||
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'
|
||||
];
|
||||
|
||||
await i18next
|
||||
.use(Backend)
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
lng: 'en',
|
||||
backend: {
|
||||
loadPath: path.join(__dirname, '/locales/{{lng}}.json')
|
||||
}
|
||||
});
|
||||
});
|
||||
return await promise;
|
||||
await Handlebars.registerHelper('__',
|
||||
function (str) {
|
||||
return i18next.t(str, PdfCreator.interpolations).toString();
|
||||
}
|
||||
);
|
||||
this.browser = await puppeteer.launch({ headless: true, args: minimal_args });
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise wrapper function that resolves the toStream promise for pdf generation.
|
||||
* 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 toStream(): Promise<Stream> {
|
||||
let promise = await new Promise<Stream>((resolve, reject) => {
|
||||
this.content.toStream(function (err, stream: Stream) {
|
||||
resolve(stream);
|
||||
public async generateSponsoringContract(runners: Runner[], locale: string = "en"): 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);
|
||||
}
|
||||
await i18next.changeLanguage(locale);
|
||||
const template_source = fs.readFileSync(`${this.templateDir}/sponsoring_contract.html`, 'utf8');
|
||||
const template = Handlebars.compile(template_source);
|
||||
const result = template({ runners })
|
||||
const pdf = await this.renderPdf(result, { format: "A5", landscape: 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");
|
||||
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 await promise;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -1,20 +1,30 @@
|
|||
import { ContentType, Controller, Get } from 'routing-controllers';
|
||||
import { Body, JsonController, Post, QueryParam, Res } from 'routing-controllers';
|
||||
import { OpenAPI } from 'routing-controllers-openapi';
|
||||
import { Runner } from '../models/Runner';
|
||||
import { PdfCreator } from '../PdfCreator';
|
||||
|
||||
@Controller()
|
||||
/**
|
||||
* The pdf controller handels all endpoints concerning pdf generation.
|
||||
* It therefore is the hearth of the document-generation server's endpoints.
|
||||
* All endpoints have to accept a locale query-param to support i18n.
|
||||
*/
|
||||
@JsonController()
|
||||
export class PdfController {
|
||||
private pdf: PdfCreator;
|
||||
constructor() {
|
||||
this.pdf = new PdfCreator();
|
||||
}
|
||||
private pdf: PdfCreator = new PdfCreator();
|
||||
private initialized: boolean = false;
|
||||
|
||||
@Get('/contracts')
|
||||
@ContentType("application/pdf")
|
||||
@OpenAPI({ description: "Generate Sponsoring contract pdfs from runner objects." })
|
||||
async generateContracts() {
|
||||
//TODO: Accept the real classes
|
||||
const contracts = await this.pdf.generateSponsoringContract();
|
||||
return await contracts.toBuffer();
|
||||
@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)." })
|
||||
async generateContracts(@Body({ validate: true, options: { limit: "500mb" } }) runners: Runner | Runner[], @Res() res: any, @QueryParam("locale") locale: string) {
|
||||
if (!this.initialized) {
|
||||
await this.pdf.init();
|
||||
this.initialized = true;
|
||||
}
|
||||
if (!Array.isArray(runners)) {
|
||||
runners = [runners];
|
||||
}
|
||||
const contracts = await this.pdf.generateSponsoringContract(runners, locale);
|
||||
res.setHeader('content-type', 'application/pdf');
|
||||
return contracts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,9 @@ import { Get, JsonController } from 'routing-controllers';
|
|||
import { OpenAPI } from 'routing-controllers-openapi';
|
||||
import { config } from '../config';
|
||||
|
||||
/**
|
||||
* The statuscontroller provides simple endpoints concerning basic information about the server.
|
||||
*/
|
||||
@JsonController()
|
||||
export class StatusController {
|
||||
@Get('/version')
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"address": "Adresse",
|
||||
"city": "Stadt",
|
||||
"date": "Datum",
|
||||
"firstname": "Vorname",
|
||||
"group": "Team/Klasse",
|
||||
"house_number": "Hausnummer",
|
||||
"id": "ID",
|
||||
"lastname": "Nachname",
|
||||
"location": "Ort",
|
||||
"please_use_blockletters": "Bitte in DRUCKBUCHSTABEN schreiben",
|
||||
"postalcode": "Postleitzahl",
|
||||
"signature": "Unterschrift",
|
||||
"sponsor": "Sponsor",
|
||||
"sponsoring_address_condition": "Muss ausgefüllt werden, wenn Sie eine Spendenquittung benötigen - Spendenquittungen können erst ab einem Gesamtbetrag von {{sponsoring_receipt_minimum_amount}}{{currency_symbol}} ausgestellt werden",
|
||||
"sponsoring_amount_per_distance": "mit einem Betrag von _____{{currency_symbol}} pro gelaufenem Kilometer zu unterstützen.",
|
||||
"sponsoring_subtitle": "Ich/Wir sind bereit anlässlich des {{eventname}}",
|
||||
"sponsoring_title": "Sponsoringerklärung",
|
||||
"street": "Straße"
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"address": "Address",
|
||||
"city": "City",
|
||||
"date": "date",
|
||||
"firstname": "First name",
|
||||
"group": "Team/class",
|
||||
"house_number": "House number",
|
||||
"lastname": "Last name",
|
||||
"location": "Location",
|
||||
"please_use_blockletters": "Please write in BLOCK LETTERS.",
|
||||
"postalcode": "Postal code",
|
||||
"signature": "Signature",
|
||||
"sponsor": "sponsor",
|
||||
"sponsoring_address_condition": "You have to provide an address if you want a donation receipt - Donation receipts can't be issued for total donation amounts under {{sponsoring_receipt_minimum_amount}}{{currency_symbol}}",
|
||||
"sponsoring_amount_per_distance": "with the amount of _____{{currency_symbol}} per kilometer run.",
|
||||
"sponsoring_subtitle": "On the ocation of the {{eventname}} I/We want to support",
|
||||
"sponsoring_title": "Sponsoring contract",
|
||||
"street": "Street"
|
||||
}
|
|
@ -1,6 +1,18 @@
|
|||
import {
|
||||
IsInt,
|
||||
|
||||
IsNotEmpty,
|
||||
|
||||
|
||||
|
||||
IsObject,
|
||||
|
||||
|
||||
|
||||
IsOptional,
|
||||
|
||||
IsPositive,
|
||||
|
||||
IsString
|
||||
} from "class-validator";
|
||||
import { RunnerGroup } from './RunnerGroup';
|
||||
|
@ -13,24 +25,28 @@ export class Runner {
|
|||
* The runner's id.
|
||||
*/
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* The runner's first name.
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstname: string;
|
||||
|
||||
/**
|
||||
* The runner's middle name.
|
||||
*/
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
middlename?: string;
|
||||
|
||||
/**
|
||||
* The runner's last name.
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastname: string;
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import { IsInt, IsNotEmpty, IsObject, IsOptional, IsString } from "class-validator";
|
||||
import { IsInt, IsNotEmpty, IsObject, IsOptional, IsPositive, IsString } from "class-validator";
|
||||
|
||||
/**
|
||||
* Defines the runner group class - a simplified version of the backend's ResponseRunnerTeam/-Organization
|
||||
*/
|
||||
export abstract class RunnerGroup {
|
||||
export class RunnerGroup {
|
||||
/**
|
||||
* The group's id.
|
||||
*/
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
id: number;;
|
||||
@IsPositive()
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* The group's name.
|
||||
|
@ -24,13 +24,5 @@ export abstract class RunnerGroup {
|
|||
*/
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
parentGroup?: RunnerGroup
|
||||
|
||||
/**
|
||||
* Returns the groups full name in the format: org.name/team.name (or just org).
|
||||
*/
|
||||
public get fullName(): string {
|
||||
if (!this.parentGroup) { return this.name; }
|
||||
return `${this.name}/${this.parentGroup.fullName}`;
|
||||
}
|
||||
parentGroup?: RunnerGroup;
|
||||
}
|
||||
|
|
|
@ -1,45 +1,115 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
<title>Sponsoring contract</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Sackers Gothic Std';
|
||||
font-weight: 500;
|
||||
background: rgb(241,241,241);
|
||||
-webkit-print-color-adjust: exact;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
height: 148mm;
|
||||
width: 210mm;
|
||||
display: block;
|
||||
background: white;
|
||||
page-break-after: auto;
|
||||
margin: 50px;
|
||||
overflow: hidden;
|
||||
}
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
<title>Sponsoring contract</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
|
||||
<style>
|
||||
.sheet {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
}
|
||||
body.A5.landscape .sheet {
|
||||
width: 210mm;
|
||||
height: 147mm
|
||||
}
|
||||
|
||||
.page {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<p style="font-size: 100vw;">{{Runner Name}}</p>
|
||||
.column {
|
||||
margin-bottom: -20;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="A5 landscape">
|
||||
{{#each runners}}
|
||||
<div class="sheet">
|
||||
<img id="header_img" width="100%" src="sponsoringheader.png" />
|
||||
<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="column is-6">
|
||||
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.lastname}}</span>
|
||||
<p style="font-size: x-small; display: block;">{{__ "lastname"}}</p>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<span style="border-bottom: 1px solid; width: 100%; display: block;">{{this.group.name}}</span>
|
||||
<p style="font-size: x-small; display: block;">{{__ "group"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p>{{__ "sponsoring_amount_per_distance"}}</p>
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<span style="border-bottom: 1px solid; width: 100%; display: block;"> </span>
|
||||
<p style="font-size: x-small; display: block;">{{__ "lastname"}}</p>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<span style="border-bottom: 1px solid; width: 100%; display: block;"> </span>
|
||||
<p style="font-size: x-small; display: block;">{{__ "firstname"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size: medium;">{{__ "address"}} ({{__ "sponsor"}})</p>
|
||||
<p style="font-size: x-small;">({{__ "sponsoring_address_condition"}})</p>
|
||||
<div class="columns">
|
||||
<div class="column is-8">
|
||||
<span style="border-bottom: 1px solid; width: 100%; display: block;"> </span>
|
||||
<p style="font-size: x-small; display: block;">{{__ "street"}}</p>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<span style="border-bottom: 1px solid; width: 100%; display: block;"> </span>
|
||||
<p style="font-size: x-small; display: block;">{{__ "house_number"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-4">
|
||||
<span style="border-bottom: 1px solid; width: 100%; display: block;"> </span>
|
||||
<p style="font-size: x-small; display: block;">{{__ "postalcode"}}</p>
|
||||
</div>
|
||||
<div class="column is-8">
|
||||
<span style="border-bottom: 1px solid; width: 100%; display: block;"> </span>
|
||||
<p style="font-size: x-small; display: block;">{{__ "city"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<div class="columns">
|
||||
<div class="column is-7">
|
||||
<span style="border-bottom: 1px solid; width: 100%; display: block;"> </span>
|
||||
<p style="font-size: x-small; display: block;">{{__ "location"}}, {{__ "date"}}</p>
|
||||
</div>
|
||||
<div class="column is-5">
|
||||
<span style="border-bottom: 1px solid; width: 100%; display: block;"> </span>
|
||||
<p style="font-size: x-small; display: block;">{{__ "signature"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</div>
|
||||
{{/each}}
|
||||
</body>
|
||||
|
||||
</html>
|
Binary file not shown.
After Width: | Height: | Size: 225 KiB |
Loading…
Reference in New Issue