Add motion to pdf converter

Adds the converstion from motion to PDF.
Uses pdfmake as the old openslides does
This commit is contained in:
Sean Engelhardt 2019-01-18 20:25:06 +01:00
parent 57202e74ca
commit 9b61603dae
12 changed files with 1244 additions and 4 deletions

View File

@ -46,6 +46,7 @@
"ngx-file-drop": "^5.0.0", "ngx-file-drop": "^5.0.0",
"ngx-mat-select-search": "^1.4.2", "ngx-mat-select-search": "^1.4.2",
"ngx-papaparse": "^3.0.2", "ngx-papaparse": "^3.0.2",
"pdfmake": "^0.1.40",
"po2json": "^1.0.0-alpha", "po2json": "^1.0.0-alpha",
"rxjs": "^6.3.3", "rxjs": "^6.3.3",
"tinymce": "^4.9.0", "tinymce": "^4.9.0",

View File

@ -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();
});
});

View File

@ -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('<h3>Hello World!</h3>');
* ```
*/
@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 (<p>) 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 (`<div style="color: green">...`)
* @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<string>): 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;
}
}

View File

@ -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();
});
});

View File

@ -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<number>;
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
}
};
}
}

View File

@ -60,7 +60,8 @@
<mat-menu #motionExtraMenu="matMenu"> <mat-menu #motionExtraMenu="matMenu">
<div *ngIf="motion"> <div *ngIf="motion">
<button mat-menu-item> <button mat-menu-item
(click)="onDownloadPdf()">
<mat-icon>picture_as_pdf</mat-icon> <mat-icon>picture_as_pdf</mat-icon>
<span translate>PDF</span> <span translate>PDF</span>
</button> </button>

View File

@ -37,6 +37,7 @@ import { itemVisibilityChoices, Item } from 'app/shared/models/agenda/item';
import { PromptService } from 'app/core/services/prompt.service'; import { PromptService } from 'app/core/services/prompt.service';
import { AgendaRepositoryService } from 'app/site/agenda/services/agenda-repository.service'; import { AgendaRepositoryService } from 'app/site/agenda/services/agenda-repository.service';
import { Mediafile } from 'app/shared/models/mediafiles/mediafile'; import { Mediafile } from 'app/shared/models/mediafiles/mediafile';
import { MotionPdfExportService } from '../../services/motion-pdf-export.service';
/** /**
* Component for the motion detail view * Component for the motion detail view
@ -279,6 +280,7 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
* @param configService The configuration provider * @param configService The configuration provider
* @param sanitizer For making HTML SafeHTML * @param sanitizer For making HTML SafeHTML
* @param promptService ensure safe deletion * @param promptService ensure safe deletion
* @param pdfExport export the motion to pdf
*/ */
public constructor( public constructor(
title: Title, title: Title,
@ -298,7 +300,8 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
private DS: DataStoreService, private DS: DataStoreService,
private configService: ConfigService, private configService: ConfigService,
private sanitizer: DomSanitizer, private sanitizer: DomSanitizer,
private promptService: PromptService private promptService: PromptService,
private pdfExport: MotionPdfExportService
) { ) {
super(title, translate, matSnackBar); super(title, translate, matSnackBar);
@ -712,8 +715,6 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
this.lineLength this.lineLength
) )
}; };
console.log(this.lineLength);
console.log(data);
this.dialogService.open(MotionChangeRecommendationComponent, { this.dialogService.open(MotionChangeRecommendationComponent, {
height: '400px', height: '400px',
width: '600px', width: '600px',
@ -926,6 +927,13 @@ export class MotionDetailComponent extends BaseViewComponent implements OnInit {
return `/agenda/${this.motion.agenda_item_id}/speakers`; 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 * Click handler for attachments
* *

View File

@ -110,6 +110,16 @@ export class ChangeRecommendationRepositoryService extends BaseRepository<ViewCh
); );
} }
/**
* Synchronously getting the change recommendations of the corresponding motion.
*
* @param motionId the id of the target motion
* @returns the array of change recommendations to the motions.
*/
public getChangeRecoOfMotion(motion_id: number): ViewChangeReco[] {
return this.getViewModelList().filter(reco => reco.motion_id === motion_id);
}
/** /**
* Sets a change recommendation to accepted. * Sets a change recommendation to accepted.
* *

View File

@ -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();
});
});

View File

@ -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);
}
}

View File

@ -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();
});
});

View File

@ -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 {};
}
}
}