Merge pull request #4175 from MaximilianKrambach/ballotPdf
motion ballot pdf creation
This commit is contained in:
commit
2070cdc525
@ -9,8 +9,15 @@ import { ConfigService } from './config.service';
|
||||
import { HttpService } from './http.service';
|
||||
|
||||
/**
|
||||
* TODO: Images and fonts
|
||||
*
|
||||
* An interface for the mapping of image placeholder name to the url of the
|
||||
* image
|
||||
*/
|
||||
export interface ImagePlaceHolder {
|
||||
placeholder: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the general document structure for PDF documents, such as page margins, header, footer and styles.
|
||||
* Also provides general purpose open and download functions.
|
||||
*
|
||||
@ -43,9 +50,11 @@ export class PdfDocumentService {
|
||||
/**
|
||||
* Define the pdfmake virtual file system for fonts
|
||||
*
|
||||
* @param images an optional mapping of images urls to be fetched and inserted
|
||||
* into placeholders
|
||||
* @returns the vfs-object
|
||||
*/
|
||||
private async getVfs(): Promise<object> {
|
||||
private async initVfs(images?: ImagePlaceHolder[]): Promise<object> {
|
||||
const fontPathList: string[] = Array.from(
|
||||
// create a list without redundancies
|
||||
new Set(
|
||||
@ -59,45 +68,52 @@ export class PdfDocumentService {
|
||||
const promises = fontPathList.map(fontPath => {
|
||||
return this.convertUrlToBase64(fontPath).then(base64 => {
|
||||
return {
|
||||
[fontPath.split('/').pop()]: base64.split(',')[1]
|
||||
[fontPath.split('/').pop()]: base64
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const fontDataUrls = await Promise.all(promises);
|
||||
|
||||
let imagePromises = [];
|
||||
if (images && images.length) {
|
||||
imagePromises = images.map(image => {
|
||||
return this.convertUrlToBase64(image.url).then(base64 => {
|
||||
return {
|
||||
[image.placeholder]: base64
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
const binaryDataUrls = await Promise.all(promises.concat(imagePromises));
|
||||
let vfs = {};
|
||||
fontDataUrls.map(entry => {
|
||||
binaryDataUrls.map(entry => {
|
||||
vfs = {
|
||||
...vfs,
|
||||
...entry
|
||||
};
|
||||
});
|
||||
|
||||
return vfs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a given blob to base64
|
||||
* Retrieves a binary file from the url and returns a base64 value
|
||||
*
|
||||
* @param file File as blob
|
||||
* @returns a promise to the base64 as string
|
||||
* @param url file url
|
||||
* @returns a promise with a base64 string
|
||||
*/
|
||||
private async convertUrlToBase64(url: string): Promise<string> {
|
||||
public async convertUrlToBase64(url: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const headers = new HttpHeaders();
|
||||
const file = await this.httpService.get<ArrayBuffer>(url, {}, {}, headers, 'arraybuffer');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.httpService.get<Blob>(url, {}, {}, headers, 'blob').then(file => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(new Blob([file]));
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
const resultStr: string = reader.result as string;
|
||||
resolve(resultStr);
|
||||
resolve(resultStr.split(',')[1]);
|
||||
};
|
||||
reader.onerror = error => {
|
||||
reject(error);
|
||||
};
|
||||
}) as Promise<string>;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -116,21 +132,17 @@ export class PdfDocumentService {
|
||||
* Overall document definition and styles for the most PDF documents
|
||||
*
|
||||
* @param documentContent the content of the pdf as object
|
||||
* @param metadata
|
||||
* @param images Array of optional images (url, placeholder) to be inserted
|
||||
* @returns the pdf document definition ready to export
|
||||
*/
|
||||
private async getStandardPaper(documentContent: object, metadata?: object): Promise<object> {
|
||||
// define the fonts
|
||||
pdfMake.fonts = {
|
||||
PdfFont: {
|
||||
normal: this.getFontName('font_regular'),
|
||||
bold: this.getFontName('font_bold'),
|
||||
italics: this.getFontName('font_italic'),
|
||||
bolditalics: this.getFontName('font_bold_italic')
|
||||
}
|
||||
};
|
||||
|
||||
pdfMake.vfs = await this.getVfs();
|
||||
|
||||
private async getStandardPaper(
|
||||
documentContent: object,
|
||||
metadata?: object,
|
||||
images?: ImagePlaceHolder[]
|
||||
): Promise<object> {
|
||||
this.initFonts();
|
||||
pdfMake.vfs = await this.initVfs(images);
|
||||
return {
|
||||
pageSize: 'A4',
|
||||
pageMargins: [75, 90, 75, 75],
|
||||
@ -150,6 +162,44 @@ export class PdfDocumentService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall document definition and styles for blank PDF documents
|
||||
* (e.g. ballots)
|
||||
*
|
||||
* @param documentContent the content of the pdf as object
|
||||
* @param image an optional image to insert into the ballot
|
||||
* @returns the pdf document definition ready to export
|
||||
*/
|
||||
private async getBallotPaper(documentContentObject: object, image?: ImagePlaceHolder): Promise<object> {
|
||||
const images = image ? [image] : null;
|
||||
this.initFonts();
|
||||
pdfMake.vfs = await this.initVfs(images);
|
||||
return {
|
||||
pageSize: 'A4',
|
||||
pageMargins: [0, 0, 0, 0],
|
||||
defaultStyle: {
|
||||
font: 'PdfFont',
|
||||
fontSize: 10
|
||||
},
|
||||
content: documentContentObject,
|
||||
styles: this.getBlankPaperStyles()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Define fonts
|
||||
*/
|
||||
private initFonts(): void {
|
||||
pdfMake.fonts = {
|
||||
PdfFont: {
|
||||
normal: this.getFontName('font_regular'),
|
||||
bold: this.getFontName('font_bold'),
|
||||
italics: this.getFontName('font_italic'),
|
||||
bolditalics: this.getFontName('font_bold_italic')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the header doc definition for normal PDF documents
|
||||
*
|
||||
@ -308,18 +358,41 @@ export class PdfDocumentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a pdf.
|
||||
* Downloads a pdf with the standard page definitions.
|
||||
*
|
||||
* @param docDefinition the structure of the PDF document
|
||||
* @param filename the name of the file to use
|
||||
* @param metadata
|
||||
*/
|
||||
public async download(docDefinition: object, filename: string, metadata?: object): Promise<void> {
|
||||
const doc = await this.getStandardPaper(docDefinition, metadata);
|
||||
await new Promise<void>(resolve => {
|
||||
const pdf = pdfMake.createPdf(doc);
|
||||
pdf.getBlob(blob => {
|
||||
saveAs(blob, `${filename}.pdf`, { autoBOM: true });
|
||||
resolve();
|
||||
public download(docDefinition: object, filename: string, metadata?: object): void {
|
||||
this.getStandardPaper(docDefinition, metadata).then(doc => {
|
||||
this.createPdf(doc, filename);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a pdf with the ballot papet page definitions.
|
||||
*
|
||||
* @param docDefinition the structure of the PDF document
|
||||
* @param filename the name of the file to use
|
||||
* @param logo (optional) url of a logo to be placed as ballot logo
|
||||
*/
|
||||
public downloadWithBallotPaper(docDefinition: object, filename: string, logo?: string): void {
|
||||
const images: ImagePlaceHolder = logo ? { placeholder: 'ballot-logo', url: logo } : null;
|
||||
this.getBallotPaper(docDefinition, images).then(doc => {
|
||||
this.createPdf(doc, filename);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the actual page creation and saving.
|
||||
*
|
||||
* @param doc the finished layout
|
||||
* @param filename the filename (without extension) to save as
|
||||
*/
|
||||
private createPdf(doc: object, filename: string): void {
|
||||
pdfMake.createPdf(doc).getBlob(blob => {
|
||||
saveAs(blob, `${filename}.pdf`, { autoBOM: true });
|
||||
});
|
||||
}
|
||||
|
||||
@ -400,4 +473,24 @@ export class PdfDocumentService {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition of styles for ballot papers
|
||||
*
|
||||
* @returns an object that contains a limited set of pdf styles
|
||||
* used for ballots
|
||||
*/
|
||||
private getBlankPaperStyles(): object {
|
||||
return {
|
||||
title: {
|
||||
fontSize: 14,
|
||||
bold: true,
|
||||
margin: [30, 30, 0, 0]
|
||||
},
|
||||
description: {
|
||||
fontSize: 11,
|
||||
margin: [30, 0, 0, 0]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import { MotionPollService } from '../../services/motion-poll.service';
|
||||
import { MotionPollDialogComponent } from './motion-poll-dialog.component';
|
||||
import { MotionRepositoryService } from '../../services/motion-repository.service';
|
||||
import { PromptService } from 'app/core/services/prompt.service';
|
||||
import { MotionPollPdfService } from '../../services/motion-poll-pdf.service';
|
||||
|
||||
/**
|
||||
* A component used to display and edit polls of a motion.
|
||||
@ -89,7 +90,8 @@ export class MotionPollComponent implements OnInit {
|
||||
private constants: ConstantsService,
|
||||
private translate: TranslateService,
|
||||
private promptService: PromptService,
|
||||
public perms: LocalPermissionsService
|
||||
public perms: LocalPermissionsService,
|
||||
private pdfService: MotionPollPdfService
|
||||
) {
|
||||
this.pollValues = this.pollService.pollValues;
|
||||
this.majorityChoice = this.pollService.defaultMajorityMethod;
|
||||
@ -190,7 +192,7 @@ export class MotionPollComponent implements OnInit {
|
||||
* TODO: not implemented. Print the buttons
|
||||
*/
|
||||
public printBallots(): void {
|
||||
this.pollService.printBallots();
|
||||
this.pdfService.printBallots(this.poll);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -0,0 +1,17 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MotionPollPdfService } from './motion-poll-pdf.service';
|
||||
import { E2EImportsModule } from 'e2e-imports.module';
|
||||
|
||||
describe('MotionPdfService', () => {
|
||||
beforeEach(() =>
|
||||
TestBed.configureTestingModule({
|
||||
imports: [E2EImportsModule]
|
||||
})
|
||||
);
|
||||
|
||||
it('should be created', () => {
|
||||
const service: MotionPollPdfService = TestBed.get(MotionPollPdfService);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
283
client/src/app/site/motions/services/motion-poll-pdf.service.ts
Normal file
283
client/src/app/site/motions/services/motion-poll-pdf.service.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { ConfigService } from 'app/core/services/config.service';
|
||||
import { MotionPoll } from 'app/shared/models/motions/motion-poll';
|
||||
import { MotionRepositoryService } from './motion-repository.service';
|
||||
import { PdfDocumentService } from 'app/core/services/pdf-document.service';
|
||||
import { UserRepositoryService } from 'app/site/users/services/user-repository.service';
|
||||
|
||||
/**
|
||||
* Creates a pdf for a motion poll. Takes as input any motionPoll
|
||||
* Provides the public method `printBallots(motionPoll)` which should be convenient to use.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* this.MotionPollPdfService.printBallos(this.poll);
|
||||
* ```
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MotionPollPdfService {
|
||||
/**
|
||||
* The method to determine the number of ballots to print. Value is
|
||||
* decided in configuration service as `motions_pdf_ballot_papers_selection`.
|
||||
* Options are:
|
||||
*
|
||||
* - NUMBER_OF_DELEGATES Amount of users belonging to the predefined 'delegates' group (group id 2)
|
||||
* - NUMBER_OF_ALL_PARTICIPANTS The amount of all registered users
|
||||
* - CUSTOM_NUMBER a given number of ballots (see {@link ballotCustomCount})
|
||||
*/
|
||||
private ballotCountSelection: 'NUMBER_OF_DELEGATES' | 'NUMBER_OF_ALL_PARTICIPANTS' | 'CUSTOM_NUMBER';
|
||||
|
||||
/**
|
||||
* An arbitrary number of ballots to print, if {@link ballotCountSection} is set
|
||||
* to CUSTOM_NUMBER. Value is fetched from the configuration value `motions_pdf_ballot_papers_number`
|
||||
*/
|
||||
private ballotCustomCount: number;
|
||||
|
||||
/**
|
||||
* The event name (as set in config `general_event_name`)
|
||||
*/
|
||||
private eventName: string;
|
||||
|
||||
/**
|
||||
* The url of the logo to be printed (as set in config `logo_pdf_ballot_paper`)
|
||||
*/
|
||||
private logo: string;
|
||||
|
||||
/**
|
||||
* Constructor. Subscribes to configuration values
|
||||
*
|
||||
* @param translate handle translations
|
||||
* @param motionRepo get parent motions
|
||||
* @param configService Read config variables
|
||||
* @param userRepo User repository for counting amount of ballots needed
|
||||
* @param pdfService the pdf document creation service
|
||||
*/
|
||||
public constructor(
|
||||
private translate: TranslateService,
|
||||
private motionRepo: MotionRepositoryService,
|
||||
private configService: ConfigService,
|
||||
private userRepo: UserRepositoryService,
|
||||
private pdfService: PdfDocumentService
|
||||
) {
|
||||
this.configService.get('motions_pdf_ballot_papers_number').subscribe(count => (this.ballotCustomCount = count));
|
||||
this.configService
|
||||
.get('motions_pdf_ballot_papers_selection')
|
||||
.subscribe(selection => (this.ballotCountSelection = selection));
|
||||
this.configService.get('general_event_name').subscribe(name => (this.eventName = name));
|
||||
this.configService.get('logo_pdf_ballot_paper').subscribe(url => {
|
||||
if (url && url.path) {
|
||||
this.logo = url.path;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a pdf creation for this poll's ballots.
|
||||
* There will be 8 ballots per page.
|
||||
* Each ballot will contain:
|
||||
* - the event name and logo
|
||||
* - a first, bold line with a title. Defaults to the label Motion, the identifier,
|
||||
* and the current number of polls for this motion (if more than one)
|
||||
* - a subtitle. A second, short (two lines, 90 characters) clarification for
|
||||
* the ballot. Defaults to the beginning of the motion's title
|
||||
* - the options 'yes', 'no', 'abstain' translated to the client's language.
|
||||
*
|
||||
* @param motionPoll: The poll this ballot refers to
|
||||
* @param title (optional) a different title
|
||||
* @param subtitle (optional) a different subtitle
|
||||
*/
|
||||
public printBallots(motionPoll: MotionPoll, title?: string, subtitle?: string): void {
|
||||
const motion = this.motionRepo.getViewModel(motionPoll.motion_id);
|
||||
const fileName = `${this.translate.instant('Motion')} - ${motion.identifier} - ${this.translate.instant(
|
||||
'ballot-paper'
|
||||
)}`;
|
||||
if (!title) {
|
||||
title = `${this.translate.instant('Motion')} - ${motion.identifier}`;
|
||||
if (motion.motion.polls.length > 1) {
|
||||
title += ` (${this.translate.instant('Vote')} ${motion.motion.polls.length})`;
|
||||
}
|
||||
}
|
||||
if (!subtitle) {
|
||||
subtitle = motion.title;
|
||||
}
|
||||
if (subtitle.length > 90) {
|
||||
subtitle = subtitle.substring(0, 90) + '...';
|
||||
}
|
||||
this.pdfService.downloadWithBallotPaper(this.getContent(title, subtitle), fileName, this.logo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the amount of ballots that are to be printed, depending n the
|
||||
* config settings
|
||||
*/
|
||||
private getBallotCount(): number {
|
||||
switch (this.ballotCountSelection) {
|
||||
case 'NUMBER_OF_ALL_PARTICIPANTS':
|
||||
return this.userRepo.getViewModelList().length;
|
||||
case 'NUMBER_OF_DELEGATES':
|
||||
return this.userRepo.getViewModelList().filter(user => user.groups_id && user.groups_id.includes(2))
|
||||
.length;
|
||||
case 'CUSTOM_NUMBER':
|
||||
return this.ballotCustomCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an entry for an option (a label with a circle)
|
||||
*
|
||||
* @returns pdfMake definitions
|
||||
*/
|
||||
private createBallotOption(decision: string): object {
|
||||
const BallotCircleDimensions = { yDistance: 6, size: 8 };
|
||||
return {
|
||||
margin: [40 + BallotCircleDimensions.size, 10, 0, 0],
|
||||
columns: [
|
||||
{
|
||||
width: 15,
|
||||
canvas: this.drawCircle(BallotCircleDimensions.yDistance, BallotCircleDimensions.size)
|
||||
},
|
||||
{
|
||||
width: 'auto',
|
||||
text: decision
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a createPdf definition for the correct amount of ballots
|
||||
* with 8 ballots per page
|
||||
*
|
||||
* @param title: first, bold line for the ballot.
|
||||
* @param subtitle: second line for the ballot.
|
||||
* @returns an array of content objects defining pdfMake instructions
|
||||
*/
|
||||
private getContent(title: string, subtitle: string): Array<object> {
|
||||
const content = [];
|
||||
const amount = this.getBallotCount();
|
||||
const fullpages = Math.floor(amount / 8);
|
||||
let partialpageEntries = amount % 8;
|
||||
|
||||
for (let i = 0; i < fullpages; i++) {
|
||||
content.push({
|
||||
table: {
|
||||
headerRows: 1,
|
||||
widths: ['*', '*'],
|
||||
body: [
|
||||
[this.createBallot(title, subtitle), this.createBallot(title, subtitle)],
|
||||
[this.createBallot(title, subtitle), this.createBallot(title, subtitle)],
|
||||
[this.createBallot(title, subtitle), this.createBallot(title, subtitle)],
|
||||
[this.createBallot(title, subtitle), this.createBallot(title, subtitle)]
|
||||
],
|
||||
pageBreak: 'after'
|
||||
},
|
||||
// layout: '{{ballot-placeholder-to-insert-functions-here}}',
|
||||
rowsperpage: 4
|
||||
});
|
||||
}
|
||||
if (partialpageEntries) {
|
||||
const partialPageBody = [];
|
||||
while (partialpageEntries > 1) {
|
||||
partialPageBody.push([this.createBallot(title, subtitle), this.createBallot(title, subtitle)]);
|
||||
partialpageEntries -= 2;
|
||||
}
|
||||
if (partialpageEntries === 1) {
|
||||
partialPageBody.push([this.createBallot(title, subtitle), '']);
|
||||
}
|
||||
content.push({
|
||||
table: {
|
||||
headerRows: 1,
|
||||
widths: ['50%', '50%'],
|
||||
body: partialPageBody
|
||||
},
|
||||
// layout: '{{ballot-placeholder-to-insert-functions-here}}',
|
||||
rowsperpage: 4
|
||||
});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* get a pdfMake header definition with the event name and an optional logo
|
||||
*
|
||||
* @returns pdfMake definitions
|
||||
*/
|
||||
private getHeader(): object {
|
||||
const columns: object[] = [];
|
||||
columns.push({
|
||||
text: this.eventName,
|
||||
fontSize: 8,
|
||||
alignment: 'left',
|
||||
width: '60%'
|
||||
});
|
||||
|
||||
if (this.logo) {
|
||||
columns.push({
|
||||
image: 'ballot-logo', // fixed dummy name not used outside ballot creation
|
||||
fit: [90, 25],
|
||||
alignment: 'right',
|
||||
width: '40%'
|
||||
});
|
||||
}
|
||||
return {
|
||||
color: '#555',
|
||||
fontSize: 10,
|
||||
margin: [30, 10, 10, -10], // [left, top, right, bottom]
|
||||
columns: columns,
|
||||
columnGap: 5
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one ballot in it's position on the page. Note that creating once
|
||||
* and then pasting the result several times does not work
|
||||
*
|
||||
* @param title The identifier of the motion
|
||||
* @param subtitle The actual motion title
|
||||
*/
|
||||
private createBallot(title: string, subtitle: string): any {
|
||||
const sheetend = 40;
|
||||
return {
|
||||
stack: [
|
||||
this.getHeader(),
|
||||
{
|
||||
text: title,
|
||||
style: 'title'
|
||||
},
|
||||
{
|
||||
text: subtitle,
|
||||
style: 'description'
|
||||
},
|
||||
this.createBallotOption(this.translate.instant('Yes')),
|
||||
this.createBallotOption(this.translate.instant('No')),
|
||||
this.createBallotOption(this.translate.instant('Abstain'))
|
||||
],
|
||||
margin: [0, 0, 0, sheetend]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to draw a circle on its position on the ballot paper
|
||||
*
|
||||
* @param y vertical offset
|
||||
* @param size the size of the circle
|
||||
* @returns an array containing one circle definition for pdfMake
|
||||
*/
|
||||
private drawCircle(y: number, size: number): Array<object> {
|
||||
return [
|
||||
{
|
||||
type: 'ellipse',
|
||||
x: 0,
|
||||
y: y,
|
||||
lineColor: 'black',
|
||||
r1: size,
|
||||
r2: size
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user