diff --git a/client/package.json b/client/package.json index 54cae7dc5..dbef1d20f 100644 --- a/client/package.json +++ b/client/package.json @@ -46,6 +46,7 @@ "ngx-file-drop": "^5.0.0", "ngx-mat-select-search": "^1.4.2", "ngx-papaparse": "^3.0.2", + "pdfmake": "^0.1.40", "po2json": "^1.0.0-alpha", "rxjs": "^6.3.3", "tinymce": "^4.9.0", diff --git a/client/src/app/core/services/html-to-pdf.service.spec.ts b/client/src/app/core/services/html-to-pdf.service.spec.ts new file mode 100644 index 000000000..72411a9fb --- /dev/null +++ b/client/src/app/core/services/html-to-pdf.service.spec.ts @@ -0,0 +1,12 @@ +import { TestBed } from '@angular/core/testing'; + +import { HtmlToPdfService } from './html-to-pdf.service'; + +describe('HtmlToPdfService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('should be created', () => { + const service: HtmlToPdfService = TestBed.get(HtmlToPdfService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/services/html-to-pdf.service.ts b/client/src/app/core/services/html-to-pdf.service.ts new file mode 100644 index 000000000..853952738 --- /dev/null +++ b/client/src/app/core/services/html-to-pdf.service.ts @@ -0,0 +1,452 @@ +import { Injectable } from '@angular/core'; +import { LineNumberingMode } from 'app/site/motions/models/view-motion'; + +/** + * Converts HTML strings to pdfmake compatible document definition. + * + * TODO: Bring back upstream to pdfmake, so other projects may benefit from this converter and + * to exclude complex code from OpenSlides. + * Everything OpenSlides specific, such as line numbering and change recommendations, + * should be excluded from this and handled elsewhere. + * + * @example + * ``` + * const dd = htmlToPdfService.convertHtml('

Hello World!

'); + * ``` + */ +@Injectable({ + providedIn: 'root' +}) +export class HtmlToPdfService { + /** + * holds the desired line number mode + */ + private lineNumberingMode: LineNumberingMode; + + /** + * Space between list elements + */ + private LI_MARGIN_BOTTOM = 1.5; + + /** + * Normal line height for paragraphs + */ + private LINE_HEIGHT = 1.25; + + /** + * space between paragraphs + */ + private P_MARGIN_BOTTOM = 4.0; + + /** + * Conversion of HTML tags into pdfmake directives + */ + private elementStyles = { + // should be the same for most HTML code + b: ['font-weight:bold'], + strong: ['font-weight:bold'], + u: ['text-decoration:underline'], + em: ['font-style:italic'], + i: ['font-style:italic'], + h1: ['font-size:14', 'font-weight:bold'], + h2: ['font-size:12', 'font-weight:bold'], + h3: ['font-size:10', 'font-weight:bold'], + h4: ['font-size:10', 'font-style:italic'], + h5: ['font-size:10'], + h6: ['font-size:10'], + a: ['color:blue', 'text-decoration:underline'], + strike: ['text-decoration:line-through'], + // Pretty specific stuff that might be excluded for other projects than OpenSlides + del: ['color:red', 'text-decoration:line-through'], + ins: ['color:green', 'text-decoration:underline'] + }; + + /** + * Treatment of required CSS-Classes + * Checking CSS is not possible + */ + private classStyles = { + delete: ['color:red', 'text-decoration:line-through'], + insert: ['color:green', 'text-decoration:underline'] + }; + + /** + * Constructor + */ + public constructor() {} + + /** + * Takes an HTML string, converts to HTML using a DOM parser and recursivly parses + * the content into pdfmake compatible doc definition + * + * @param htmlText the html text to translate as string + * @param lnMode determines the line numbering + * @returns pdfmake doc definition as object + */ + public convertHtml(htmlText: string, lnMode?: LineNumberingMode): object { + const docDef = []; + this.lineNumberingMode = lnMode || LineNumberingMode.None; + + // Cleanup of dirty html would happen here + + // Create a HTML DOM tree out of html string + const parser = new DOMParser(); + const parsedHtml = parser.parseFromString(htmlText, 'text/html'); + // Since the spread operator did not work for HTMLCollection, use Array.from + const htmlArray = Array.from(parsedHtml.body.children); + + // Parse the children of the current HTML element + for (const child of htmlArray) { + const parsedElement = this.parseElement(child); + docDef.push(parsedElement); + } + + return docDef; + } + + /** + * Converts a single HTML element to pdfmake, calls itself recursively for child html elements + * + * @param element can be an HTML element (

) or plain text ("Hello World") + * @param currentParagraph usually holds the parent element, to allow nested structures + * @param styles holds the style attributes of HTML elements (`

...`) + * @returns the doc def to the given element in consideration to the given paragraph and styles + */ + public parseElement(element: any, styles?: string[]): any { + const nodeName = element.nodeName.toLowerCase(); + let classes = []; + let newParagraph: any; + + // extract explicit style information + styles = styles || []; + + // to leave out plain text elements + if (element.getAttribute) { + const nodeStyle = element.getAttribute('style'); + const nodeClass = element.getAttribute('class'); + + // add styles like `color:#ff00ff` content into styles array + if (nodeStyle) { + styles = nodeStyle + .split(';') + .map(style => style.replace(/\s/g, '')) + .concat(styles); + } + + // Handle CSS classes + if (nodeClass) { + classes = nodeClass.toLowerCase().split(' '); + + for (const cssClass of classes) { + if (this.classStyles[cssClass]) { + this.classStyles[cssClass].forEach(style => { + styles.push(style); + }); + } + } + } + } + + switch (nodeName) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + case 'p': { + const children = this.parseChildren(element, newParagraph); + + if (this.lineNumberingMode === LineNumberingMode.Outside) { + newParagraph = this.create('stack'); + newParagraph.stack = children; + } else { + newParagraph = this.create('text'); + newParagraph.text = children; + } + + newParagraph.margin = [0, this.P_MARGIN_BOTTOM]; + newParagraph.lineHeight = this.LINE_HEIGHT; + + styles = this.computeStyle(styles); + const implicitStyles = this.computeStyle(this.elementStyles[nodeName]); + + newParagraph = { + ...newParagraph, + ...styles, + ...implicitStyles + }; + break; + } + case 'a': + case 'b': + case 'strong': + case 'u': + case 'em': + case 'i': + case 'ins': + case 'del': + case 'strike': { + const children = this.parseChildren(element, styles.concat(this.elementStyles[nodeName])); + newParagraph = this.create('text'); + newParagraph.text = children; + break; + } + case 'span': { + // Line numbering feature, will prevent compatibility to most other projects + if (element.getAttribute('data-line-number')) { + if (this.lineNumberingMode === LineNumberingMode.Inside) { + // TODO: algorithm for "inline" line numbers is not yet implemented + } else if (this.lineNumberingMode === LineNumberingMode.Outside) { + const currentLineNumber = element.getAttribute('data-line-number'); + newParagraph = { + columns: [ + // the line number column + { + width: 20, + text: currentLineNumber, + color: 'gray', + fontSize: 8, + margin: [0, 2, 0, 0], + lineHeight: this.LINE_HEIGHT + }, + // target to push text into the line + { + text: [] + } + ] + }; + } + } else { + const children = this.parseChildren(element, styles); + newParagraph = { + ...this.create('text'), + ...this.computeStyle(styles) + }; + + newParagraph.text = children; + } + break; + } + case 'br': { + newParagraph = this.create('text'); + // Add a dummy line, if the next tag is a BR tag again. The line could + // not be empty otherwise it will be removed and the empty line is not displayed + if (element.nextSibling && element.nextSibling.nodeName === 'BR') { + newParagraph.text.push(this.create('text', ' ')); + } + + newParagraph.lineHeight = this.LINE_HEIGHT; + break; + } + case 'li': + case 'div': { + newParagraph = this.create('text'); + newParagraph.lineHeight = this.LI_MARGIN_BOTTOM; + newParagraph = { + ...newParagraph, + ...this.computeStyle(styles) + }; + + const children = this.parseChildren(element, newParagraph); + newParagraph = children; + break; + } + case 'ul': + case 'ol': { + newParagraph = this.create(nodeName); + const children = this.parseChildren(element, newParagraph); + newParagraph[nodeName] = children; + break; + } + default: { + newParagraph = { + ...this.create('text', element.textContent.replace(/\n/g, '')), + ...this.computeStyle(styles) + }; + break; + } + } + return newParagraph; + } + + /** + * Helper routine to parse an elements children and return the children as parsed pdfmake doc string + * + * @param element the parent element to parse + * @param currentParagraph the context of the element + * @param styles the styles array, usually just to parse back into the `parseElement` function + * @returns an array of parsed children + */ + private parseChildren(element: any, styles?: Array): any { + const childNodes = element.childNodes; + const paragraph = []; + if (childNodes.length > 0) { + for (const child of childNodes) { + // skip empty child nodes + if (!(child.nodeName === '#text' && child.textContent.trim() === '')) { + const parsedElement = this.parseElement(child, styles); + + if ( + // add the line number column + this.lineNumberingMode === LineNumberingMode.Outside && + child && + child.classList && + child.classList.contains('os-line-number') + ) { + paragraph.push(parsedElement); + } else if ( + // if the first child of the parsed element is line number + this.lineNumberingMode === LineNumberingMode.Outside && + element.firstChild && + element.firstChild.classList && + element.firstChild.classList.contains('os-line-number') + ) { + const currentLine = paragraph.pop(); + // push the parsed element into the "text" array + currentLine.columns[1].text.push(parsedElement); + paragraph.push(currentLine); + } else { + paragraph.push(parsedElement); + } + } + } + } + return paragraph; + } + + /** + * Extracts the style information from the given array + * + * @param styles an array of inline css styles (i.e. `style="margin: 10px"`) + * @returns an object with style pdfmake compatible style information + */ + private computeStyle(styles: string[]): any { + const styleObject: any = {}; + if (styles && styles.length > 0) { + for (const style of styles) { + const styleDefinition = style + .trim() + .toLowerCase() + .split(':'); + const key = styleDefinition[0]; + const value = styleDefinition[1]; + + if (styleDefinition.length === 2) { + switch (key) { + case 'padding-left': { + styleObject.margin = [+value, 0, 0, 0]; + break; + } + case 'font-size': { + styleObject.fontSize = +value; + break; + } + case 'text-align': { + switch (value) { + case 'right': + case 'center': + case 'justify': { + styleObject.alignment = value; + break; + } + } + break; + } + case 'font-weight': { + switch (value) { + case 'bold': { + styleObject.bold = true; + break; + } + } + break; + } + case 'text-decoration': { + switch (value) { + case 'underline': { + styleObject.decoration = 'underline'; + break; + } + case 'line-through': { + styleObject.decoration = 'lineThrough'; + break; + } + } + break; + } + case 'font-style': { + switch (value) { + case 'italic': { + styleObject.italics = true; + break; + } + } + break; + } + case 'color': { + styleObject.color = this.parseColor(value); + break; + } + case 'background-color': { + styleObject.background = this.parseColor(value); + break; + } + } + } + } + } + return styleObject; + } + + /** + * Returns the color in a hex format (e.g. #12ff00). + * Also tries to convert RGB colors into hex values + * + * @param color color as string representation + * @returns color as hex values for pdfmake + */ + private parseColor(color: string): string { + const haxRegex = new RegExp('^#([0-9a-f]{3}|[0-9a-f]{6})$'); + + // e.g. `#fff` or `#ff0048` + const rgbRegex = new RegExp('^rgb\\((\\d+),\\s*(\\d+),\\s*(\\d+)\\)$'); + + // e.g. rgb(0,255,34) or rgb(22, 0, 0) + const nameRegex = new RegExp('^[a-z]+$'); + + if (haxRegex.test(color)) { + return color; + } else if (rgbRegex.test(color)) { + const decimalColors = rgbRegex.exec(color).slice(1); + for (let i = 0; i < 3; i++) { + let decimalValue = +decimalColors[i]; + if (decimalValue > 255) { + decimalValue = 255; + } + let hexString = '0' + decimalValue.toString(16); + hexString = hexString.slice(-2); + decimalColors[i] = hexString; + } + return '#' + decimalColors.join(''); + } else if (nameRegex.test(color)) { + return color; + } else { + console.error('Could not parse color "' + color + '"'); + return color; + } + } + + /** + * Helper function to create valid doc definitions container elements for pdfmake + * + * @param name should be a pdfMake container element, like 'text' or 'stack' + * @param content + */ + private create(name: string, content?: any): any { + const container = {}; + const docDef = content ? content : []; + container[name] = docDef; + return container; + } +} diff --git a/client/src/app/core/services/pdf-document.service.spec.ts b/client/src/app/core/services/pdf-document.service.spec.ts new file mode 100644 index 000000000..f383a4995 --- /dev/null +++ b/client/src/app/core/services/pdf-document.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; + +import { PdfDocumentService } from './pdf-document.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('PdfDocumentService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: PdfDocumentService = TestBed.get(PdfDocumentService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/services/pdf-document.service.ts b/client/src/app/core/services/pdf-document.service.ts new file mode 100644 index 000000000..26b8a474e --- /dev/null +++ b/client/src/app/core/services/pdf-document.service.ts @@ -0,0 +1,288 @@ +import { Injectable } from '@angular/core'; +import { saveAs } from 'file-saver'; + +import * as pdfMake from 'pdfmake/build/pdfmake'; +import * as pdfFonts from 'pdfmake/build/vfs_fonts'; +import { TranslateService } from '@ngx-translate/core'; + +import { ConfigService } from './config.service'; + +/** + * TODO: Images and fonts + * + * Provides the general document structure for PDF documents, such as page margins, header, footer and styles. + * Also provides general purpose open and download functions. + * + * Use a local pdf service (i.e. MotionPdfService) to get the document definition for the content and use this service to + * open or download the pdf document + * + * @example + * ```ts + * const motionContent = this.motionPdfService.motionToDocDef(this.motion); + * this.this.pdfDocumentService.open(motionContent); + * ``` + */ +@Injectable({ + providedIn: 'root' +}) +export class PdfDocumentService { + /** + * Constructor + * + * @param translate translations + * @param configService read config values + */ + public constructor(private translate: TranslateService, private configService: ConfigService) { + // It should be possible to add own fonts here + pdfMake.vfs = pdfFonts.pdfMake.vfs; + } + + /** + * Overall document definition and styles for the most PDF documents + * + * @param documentContent the content of the pdf as object + * @returns the pdf document definition ready to export + */ + private getStandardPaper(documentContent: object, metadata?: object): object { + const standardFontSize = this.configService.instant('general_export_pdf_fontsize'); + + return { + pageSize: 'A4', + pageMargins: [75, 90, 75, 75], + defaultStyle: { + // TODO add fonts to vfs + // font: 'PdfFont', + fontSize: standardFontSize + }, + header: this.getHeader(), + // TODO: option for no footer, wherever this can be defined + footer: (currentPage, pageCount) => { + return this.getFooter(currentPage, pageCount); + }, + info: metadata, + content: documentContent, + styles: this.getStandardPaperStyles(), + images: this.getImageUrls() + }; + } + + /** + * Creates the header doc definition for normal PDF documents + * + * @returns an object that contains the necessary header definition + */ + private getHeader(): object { + // check for the required logos + let logoHeaderLeftUrl = this.configService.instant('logo_pdf_header_L').path; + let logoHeaderRightUrl = this.configService.instant('logo_pdf_header_R').path; + let text; + const columns = []; + + // add the left logo to the header column + if (logoHeaderLeftUrl) { + if (logoHeaderLeftUrl.indexOf('/') === 0) { + logoHeaderLeftUrl = logoHeaderLeftUrl.substr(1); // remove trailing / + } + columns.push({ + image: logoHeaderLeftUrl, + fit: [180, 40], + width: '20%' + }); + } + + // add the header text if no logo on the right was specified + if (logoHeaderLeftUrl && logoHeaderRightUrl) { + text = ''; + } else { + const line1 = [ + this.translate.instant(this.configService.instant('general_event_name')), + this.translate.instant(this.configService.instant('general_event_description')) + ] + .filter(Boolean) + .join(' – '); + const line2 = [ + this.configService.instant('general_event_location'), + this.configService.instant('general_event_date') + ] + .filter(Boolean) + .join(', '); + text = [line1, line2].join('\n'); + } + columns.push({ + text: text, + style: 'headerText', + alignment: logoHeaderRightUrl ? 'left' : 'right' + }); + + // add the logo to the right + if (logoHeaderRightUrl) { + if (logoHeaderRightUrl.indexOf('/') === 0) { + logoHeaderRightUrl = logoHeaderRightUrl.substr(1); // remove trailing / + } + columns.push({ + image: logoHeaderRightUrl, + fit: [180, 40], + alignment: 'right', + width: '20%' + }); + } + + return { + color: '#555', + fontSize: 9, + margin: [75, 30, 75, 10], // [left, top, right, bottom] + columns: columns, + columnGap: 10 + }; + } + + /** + * Creates the footer doc definition for normal PDF documents. + * Adds page numbers into the footer + * + * TODO: Add footer logos. + * + * @param currentPage holds the number of the current page + * @param pageCount holds the page count + * @returns the footer doc definition + */ + private getFooter(currentPage: number, pageCount: number): object { + const columns = []; + let logoContainerWidth: string; + let pageNumberPosition: string; + let logoConteinerSize: Array; + let logoFooterLeftUrl = this.configService.instant('logo_pdf_footer_L').path; + let logoFooterRightUrl = this.configService.instant('logo_pdf_footer_R').path; + + // if there is a single logo, give it a lot of space + if (logoFooterLeftUrl && logoFooterRightUrl) { + logoContainerWidth = '20%'; + logoConteinerSize = [180, 40]; + } else { + logoContainerWidth = '80%'; + logoConteinerSize = [400, 50]; + } + + // the position of the page number depends on the logos + if (logoFooterLeftUrl && logoFooterRightUrl) { + pageNumberPosition = 'center'; + } else if (logoFooterLeftUrl && !logoFooterRightUrl) { + pageNumberPosition = 'right'; + } else if (logoFooterRightUrl && !logoFooterLeftUrl) { + pageNumberPosition = 'left'; + } else { + pageNumberPosition = this.configService.instant('general_export_pdf_pagenumber_alignment'); + } + + // add the left footer logo, if any + if (logoFooterLeftUrl) { + if (logoFooterLeftUrl.indexOf('/') === 0) { + logoFooterLeftUrl = logoFooterLeftUrl.substr(1); // remove trailing / + } + columns.push({ + image: logoFooterLeftUrl, + fit: logoConteinerSize, + width: logoContainerWidth, + alignment: 'left' + }); + } + + // add the page number + columns.push({ + text: `${currentPage} / ${pageCount}`, + style: 'footerPageNumber', + alignment: pageNumberPosition + }); + + // add the right footer logo, if any + if (logoFooterRightUrl) { + if (logoFooterRightUrl.indexOf('/') === 0) { + logoFooterRightUrl = logoFooterRightUrl.substr(1); // remove trailing / + } + columns.push({ + image: logoFooterRightUrl, + fit: logoConteinerSize, + width: logoContainerWidth, + alignment: 'right' + }); + } + + return { + margin: [75, 0, 75, 10], + columns: columns, + columnGap: 10 + }; + } + + /** + * opens a pdf in a new tab + * + * @param docDefinition the structure of the PDF document + */ + public open(docDefinition: object, metadata?: object): void { + pdfMake.createPdf(this.getStandardPaper(docDefinition, metadata)).open(); + } + + /** + * Downloads a pdf. Does not seem to work. + * + * @param docDefinition the structure of the PDF document + */ + public download(docDefinition: object, filename: string, metadata?: object): void { + pdfMake + .createPdf(this.getStandardPaper(docDefinition, metadata)) + .getBlob(blob => saveAs(blob, `${filename}.pdf`, { autoBOM: true })); + } + + /** + * TODO + * + * Should create an images section in the document definition holding the base64 strings + * for the urls + * + * @returns an object containing the image names and the corresponding base64 strings + */ + private getImageUrls(): object { + return {}; + } + + /** + * Definition of styles for standard papers + * + * @returns an object that contains all pdf styles + */ + private getStandardPaperStyles(): object { + return { + title: { + fontSize: 18, + margin: [0, 0, 0, 20], + bold: true + }, + subtitle: { + fontSize: 9, + margin: [0, -20, 0, 20], + color: 'grey' + }, + headerText: { + fontSize: 10, + margin: [0, 10, 0, 0] + }, + footerPageNumber: { + fontSize: 9, + margin: [0, 15, 0, 0], + color: '#555' + }, + boldText: { + bold: true + }, + smallText: { + fontSize: 8 + }, + heading3: { + fontSize: 12, + margin: [0, 10, 0, 0], + bold: true + } + }; + } +} diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html index 57b0b3da5..3c8c258d7 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html @@ -60,7 +60,8 @@
- diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts index 2cfd7695a..e7e26b38a 100644 --- a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts @@ -37,6 +37,7 @@ import { itemVisibilityChoices, Item } from 'app/shared/models/agenda/item'; import { PromptService } from 'app/core/services/prompt.service'; import { AgendaRepositoryService } from 'app/site/agenda/services/agenda-repository.service'; import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; +import { MotionPdfExportService } from '../../services/motion-pdf-export.service'; /** * Component for the motion detail view @@ -279,6 +280,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { * @param configService The configuration provider * @param sanitizer For making HTML SafeHTML * @param promptService ensure safe deletion + * @param pdfExport export the motion to pdf */ public constructor( title: Title, @@ -298,7 +300,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { private DS: DataStoreService, private configService: ConfigService, private sanitizer: DomSanitizer, - private promptService: PromptService + private promptService: PromptService, + private pdfExport: MotionPdfExportService ) { super(title, translate, matSnackBar); @@ -712,8 +715,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { this.lineLength ) }; - console.log(this.lineLength); - console.log(data); this.dialogService.open(MotionChangeRecommendationComponent, { height: '400px', width: '600px', @@ -926,6 +927,13 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit { return `/agenda/${this.motion.agenda_item_id}/speakers`; } + /** + * Click handler for the pdf button + */ + public onDownloadPdf(): void { + this.pdfExport.exportSingleMotion(this.motion, this.lnMode, this.crMode); + } + /** * Click handler for attachments * diff --git a/client/src/app/site/motions/services/change-recommendation-repository.service.ts b/client/src/app/site/motions/services/change-recommendation-repository.service.ts index f824aa8c0..7420a2fb8 100644 --- a/client/src/app/site/motions/services/change-recommendation-repository.service.ts +++ b/client/src/app/site/motions/services/change-recommendation-repository.service.ts @@ -110,6 +110,16 @@ export class ChangeRecommendationRepositoryService extends BaseRepository reco.motion_id === motion_id); + } + /** * Sets a change recommendation to accepted. * diff --git a/client/src/app/site/motions/services/motion-pdf-export.service.spec.ts b/client/src/app/site/motions/services/motion-pdf-export.service.spec.ts new file mode 100644 index 000000000..c0aec0d9f --- /dev/null +++ b/client/src/app/site/motions/services/motion-pdf-export.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; + +import { MotionPdfExportService } from './motion-pdf-export.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('MotionPdfExportService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: MotionPdfExportService = TestBed.get(MotionPdfExportService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/services/motion-pdf-export.service.ts b/client/src/app/site/motions/services/motion-pdf-export.service.ts new file mode 100644 index 000000000..7daf62597 --- /dev/null +++ b/client/src/app/site/motions/services/motion-pdf-export.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { MotionPdfService } from './motion-pdf.service'; +import { PdfDocumentService } from 'app/core/services/pdf-document.service'; +import { ViewMotion, LineNumberingMode, ChangeRecoMode } from '../models/view-motion'; + +/** + * Export service to handle various kind of exporting necessities. + */ +@Injectable({ + providedIn: 'root' +}) +export class MotionPdfExportService { + /** + * Constructor + * + * @param translate handle translations + * @param motionPdfService Converting actual motions to PDF + * @param pdfDocumentService Actual pdfmake functions and global doc definitions + */ + public constructor( + private translate: TranslateService, + private motionPdfService: MotionPdfService, + private pdfDocumentService: PdfDocumentService + ) {} + + /** + * Exports a single motions to PDF + * + * @param motion The motion to export + * @param lnMode the desired line numbering mode + * @param crMode the desired change recomendation mode + */ + public exportSingleMotion(motion: ViewMotion, lnMode?: LineNumberingMode, crMode?: ChangeRecoMode): void { + const doc = this.motionPdfService.motionToDocDef(motion, lnMode, crMode); + const filename = `${this.translate.instant('Motion')} ${motion.identifierOrTitle}`; + const metadata = { + title: filename + }; + this.pdfDocumentService.download(doc, filename, metadata); + } +} diff --git a/client/src/app/site/motions/services/motion-pdf.service.spec.ts b/client/src/app/site/motions/services/motion-pdf.service.spec.ts new file mode 100644 index 000000000..e42e4a412 --- /dev/null +++ b/client/src/app/site/motions/services/motion-pdf.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; + +import { MotionPdfService } from './motion-pdf.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('MotionPdfService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }) + ); + + it('should be created', () => { + const service: MotionPdfService = TestBed.get(MotionPdfService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/services/motion-pdf.service.ts b/client/src/app/site/motions/services/motion-pdf.service.ts new file mode 100644 index 000000000..19b29c2f6 --- /dev/null +++ b/client/src/app/site/motions/services/motion-pdf.service.ts @@ -0,0 +1,373 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { ViewMotion, LineNumberingMode, ChangeRecoMode } from '../models/view-motion'; +import { MotionRepositoryService } from './motion-repository.service'; +import { ConfigService } from 'app/core/services/config.service'; +import { ChangeRecommendationRepositoryService } from './change-recommendation-repository.service'; +import { ViewUnifiedChange } from '../models/view-unified-change'; +import { HtmlToPdfService } from 'app/core/services/html-to-pdf.service'; + +/** + * Converts a motion to pdf. Can be used from the motion detail view or executed on a list of motions + * Provides the public method `motionToDocDef(motion: Motion)` which should be convenient to use. + * `motionToDocDef(... )` accepts line numbering mode and change recommendation mode as optional parameter. + * If not present, the default parameters will be read from the config. + * + * @example + * ```ts + * const pdfMakeCompatibleDocDef = this.MotionPdfService.motionToDocDef(myMotion); + * ``` + */ +@Injectable({ + providedIn: 'root' +}) +export class MotionPdfService { + /** + * Constructor + * + * @param translate handle translations + * @param motionRepo get parent motions + * @param changeRecoRepo to get the change recommendations + * @param configService Read config variables + * @param htmlToPdfService To convert HTML text into pdfmake doc def + */ + public constructor( + private translate: TranslateService, + private motionRepo: MotionRepositoryService, + private changeRecoRepo: ChangeRecommendationRepositoryService, + private configService: ConfigService, + private htmlToPdfService: HtmlToPdfService + ) {} + + /** + * Converts a motion to PdfMake doc definition + * + * @param motion the motion to convert to pdf + * @param lnMode determine the used line mode + * @param crMode determine the used change Recommendation mode + * @returns doc def for the motion + */ + public motionToDocDef(motion: ViewMotion, lnMode?: LineNumberingMode, crMode?: ChangeRecoMode): object { + // determine the default lnMode if not explicitly given + if (!lnMode) { + lnMode = this.configService.instant('motions_default_line_numbering'); + } + + // determine the default crMode if not explicitly given + if (!crMode) { + lnMode = this.configService.instant('motions_recommendation_text_mode'); + } + + const title = this.createTitle(motion); + const subtitle = this.createSubtitle(motion); + const metaInfo = this.createMetaInfoTable(motion, crMode); + const preamble = this.createPreamble(motion); + const text = this.createText(motion, lnMode, crMode); + const reason = this.createReason(motion, lnMode); + + const motionPdfContent = [title, subtitle, metaInfo, preamble, text, reason]; + return motionPdfContent; + } + + /** + * Create the motion title part of the doc definition + * + * @param motion the target motion + * @returns doc def for the document title + */ + private createTitle(motion: ViewMotion): object { + const identifier = motion.identifier ? ' ' + motion.identifier : ''; + const title = `${this.translate.instant('Motion')} ${identifier}: ${motion.title}`; + + return { + text: title, + style: 'title' + }; + } + + /** + * Create the motion subtitle and sequential number part of the doc definition + * + * @param motion the target motion + * @returns doc def for the subtitle + */ + private createSubtitle(motion: ViewMotion): object { + const subtitleLines = []; + + // TODO: documents for motion amendments (having parents) + // + // if (motion.parent_id) { + // const parentMotion = this.motionRepo.getViewModel(motion.parent_id); + // subtitleLines.push(`${this.translate.instant('Amendment to motion')}: ${motion.identifierOrTitle}`); + // } + + if (this.configService.instant('motions_export_sequential_number')) { + subtitleLines.push(`${this.translate.instant('Sequential number')}: ${motion.id}`); + } + + return { + text: subtitleLines, + style: 'subtitle' + }; + } + + /** + * Creates the MetaInfoTable + * + * @param motion the target motion + * @returns doc def for the meta infos + */ + private createMetaInfoTable(motion: ViewMotion, crMode: ChangeRecoMode): object { + const metaTableBody = []; + + // submitters + const submitters = motion.submitters + .map(submitter => { + return submitter.full_name; + }) + .join(', '); + + metaTableBody.push([ + { + text: `${this.translate.instant('Submitters')}:`, + style: 'boldText' + }, + { + text: submitters + } + ]); + + // state + metaTableBody.push([ + { + text: `${this.translate.instant('State')}:`, + style: 'boldText' + }, + { + text: this.translate.instant(motion.state.name) + } + ]); + + // recommendation + if (motion.recommendation) { + metaTableBody.push([ + { + text: `${this.translate.instant('Recommendation')}:`, + style: 'boldText' + }, + { + text: this.translate.instant(motion.recommendation.recommendation_label) + } + ]); + } + + // category + if (motion.category) { + metaTableBody.push([ + { + text: `${this.translate.instant('Category')}:`, + style: 'boldText' + }, + { + text: `${motion.category.prefix} - ${motion.category.name}` + } + ]); + } + + // motion block + if (motion.origin) { + metaTableBody.push([ + { + text: `${this.translate.instant('Motion block')}:`, + style: 'boldText' + }, + { + text: motion.motion_block.title + } + ]); + } + + // origin + if (motion.origin) { + metaTableBody.push([ + { + text: `${this.translate.instant('Origin')}:`, + style: ['boldText', 'greyBackground'] + }, + { + text: motion.origin + } + ]); + } + + // TODO: Voting result, depends on polls + + // summary of change recommendations (for motion diff version only) + const changeRecos = this.changeRecoRepo.getChangeRecoOfMotion(motion.id); + if (crMode === ChangeRecoMode.Diff && changeRecos.length > 0) { + const columnLineNumbers = []; + const columnChangeType = []; + + changeRecos.forEach(changeReco => { + // TODO: the function isTitleRecommendation() does not exist anymore. + // Not sure if required or not + // if (changeReco.isTitleRecommendation()) { + // columnLineNumbers.push(gettextCatalog.getString('Title') + ': '); + // } else { ... } + + // line numbers column + let line; + if (changeReco.line_from >= changeReco.line_to - 1) { + line = changeReco.line_from; + } else { + line = changeReco.line_from + ' - ' + (changeReco.line_to - 1); + } + columnLineNumbers.push(`${this.translate.instant('Line')} ${line}: `); + + // change type column + if (changeReco.type === 0) { + columnChangeType.push(this.translate.instant('Replacement')); + } else if (changeReco.type === 1) { + columnChangeType.push(this.translate.instant('Insertion')); + } else if (changeReco.type === 2) { + columnChangeType.push(this.translate.instant('Deletion')); + } else if (changeReco.type === 3) { + columnChangeType.push(changeReco.other_description); + } + }); + + metaTableBody.push([ + { + text: this.translate.instant('Summary of change recommendations'), + style: 'boldText' + }, + { + columns: [ + { + text: columnLineNumbers.join('\n'), + width: 'auto' + }, + { + text: columnChangeType.join('\n'), + width: 'auto' + } + ], + columnGap: 7 + } + ]); + } + + return { + table: { + widths: ['35%', '65%'], + body: metaTableBody + }, + margin: [0, 0, 0, 20], + // That did not work too well in the past. Perhaps substitution by a pdfWorker the worker will be necessary + layout: { + fillColor: () => { + return '#dddddd'; + }, + hLineWidth: (i, node) => { + return i === 0 || i === node.table.body.length ? 0 : 0.5; + }, + vLineWidth: () => { + return 0; + }, + hLineColor: () => { + return 'white'; + } + } + }; + } + + /** + * Creates the motion preamble + * + * @param motion the target motion + * @returns doc def for the motion text + */ + private createPreamble(motion: ViewMotion): object { + return { + text: `${this.translate.instant(this.configService.instant('motions_preamble'))}`, + margin: [0, 10, 0, 10] + }; + } + + /** + * Creates the motion text - uses HTML to PDF + * + * @param motion the motion to convert to pdf + * @param lnMode determine the used line mode + * @param crMode determine the used change Recommendation mode + * @returns doc def for the "the assembly may decide" preamble + */ + private createText(motion: ViewMotion, lnMode: LineNumberingMode, crMode: ChangeRecoMode): object { + let motionText: string; + if (motion.isParagraphBasedAmendment()) { + // TODO: special docs for special amendment + } else { + // lead motion or normal amendments + // TODO: Consider tile change recommendation + const changes: ViewUnifiedChange[] = Object.assign( + [], + this.changeRecoRepo.getChangeRecoOfMotion(motion.id) + ); + + // changes need to be sorted, by "line from". + // otherwise, formatMotion will make unexpected results by messing up the + // order of changes applied to the motion + changes.sort((a, b) => a.getLineFrom() - b.getLineFrom()); + + // get the line length from the config + const lineLength = this.configService.instant('motions_line_length'); + + motionText = this.motionRepo.formatMotion(motion.id, crMode, changes, lineLength); + } + + return this.htmlToPdfService.convertHtml(motionText, lnMode); + } + + /** + * Creates the motion reason - uses HTML to PDF + * + * @param motion the target motion + * @returns doc def for the reason as array + */ + private createReason(motion: ViewMotion, lnMode: LineNumberingMode): object { + if (motion.reason) { + const reason = []; + + // add the reason "head line" + reason.push({ + text: this.translate.instant('Reason'), + style: 'heading3', + margin: [0, 25, 0, 10] + }); + + // determine the width of the reason depending on line numbering + // currently not used + // let columnWidth: string; + // if (lnMode === LineNumberingMode.Outside) { + // columnWidth = '80%'; + // } else { + // columnWidth = '100%'; + // } + + reason.push({ + columns: [ + { + // width: columnWidth, + stack: this.htmlToPdfService.convertHtml(motion.reason) + } + ] + }); + + return reason; + } else { + return {}; + } + } +}