diff --git a/client/src/app/core/services/pdf-document.service.ts b/client/src/app/core/services/pdf-document.service.ts index 6266ef46a..b934f517d 100644 --- a/client/src/app/core/services/pdf-document.service.ts +++ b/client/src/app/core/services/pdf-document.service.ts @@ -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 { + private async initVfs(images?: ImagePlaceHolder[]): Promise { 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 { - const headers = new HttpHeaders(); - const file = await this.httpService.get(url, {}, {}, headers, 'arraybuffer'); - - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(new Blob([file])); - reader.onload = () => { - const resultStr: string = reader.result as string; - resolve(resultStr); - }; - reader.onerror = error => { - reject(error); - }; - }) as Promise; + public async convertUrlToBase64(url: string): Promise { + return new Promise((resolve, reject) => { + const headers = new HttpHeaders(); + this.httpService.get(url, {}, {}, headers, 'blob').then(file => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + const resultStr: string = reader.result as string; + resolve(resultStr.split(',')[1]); + }; + reader.onerror = error => { + reject(error); + }; + }); + }); } /** @@ -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 { - // 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 { + 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 { + 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 { - const doc = await this.getStandardPaper(docDefinition, metadata); - await new Promise(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] + } + }; + } } diff --git a/client/src/app/site/motions/components/motion-poll/motion-poll.component.ts b/client/src/app/site/motions/components/motion-poll/motion-poll.component.ts index dfbef4fd6..e35eae2a8 100644 --- a/client/src/app/site/motions/components/motion-poll/motion-poll.component.ts +++ b/client/src/app/site/motions/components/motion-poll/motion-poll.component.ts @@ -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); } /** diff --git a/client/src/app/site/motions/services/motion-poll-pdf.service.spec.ts b/client/src/app/site/motions/services/motion-poll-pdf.service.spec.ts new file mode 100644 index 000000000..ac1c4c912 --- /dev/null +++ b/client/src/app/site/motions/services/motion-poll-pdf.service.spec.ts @@ -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(); + }); +}); diff --git a/client/src/app/site/motions/services/motion-poll-pdf.service.ts b/client/src/app/site/motions/services/motion-poll-pdf.service.ts new file mode 100644 index 000000000..023c43454 --- /dev/null +++ b/client/src/app/site/motions/services/motion-poll-pdf.service.ts @@ -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 { + 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 { + return [ + { + type: 'ellipse', + x: 0, + y: y, + lineColor: 'black', + r1: size, + r2: size + } + ]; + } +}