Merge pull request #4122 from tsiegleauq/os3-pdfmake
Add motion to pdf converter
This commit is contained in:
commit
24cf01b03f
@ -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",
|
||||
|
12
client/src/app/core/services/html-to-pdf.service.spec.ts
Normal file
12
client/src/app/core/services/html-to-pdf.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
452
client/src/app/core/services/html-to-pdf.service.ts
Normal file
452
client/src/app/core/services/html-to-pdf.service.ts
Normal 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;
|
||||
}
|
||||
}
|
17
client/src/app/core/services/pdf-document.service.spec.ts
Normal file
17
client/src/app/core/services/pdf-document.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
288
client/src/app/core/services/pdf-document.service.ts
Normal file
288
client/src/app/core/services/pdf-document.service.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@ -60,7 +60,8 @@
|
||||
|
||||
<mat-menu #motionExtraMenu="matMenu">
|
||||
<div *ngIf="motion">
|
||||
<button mat-menu-item>
|
||||
<button mat-menu-item
|
||||
(click)="onDownloadPdf()">
|
||||
<mat-icon>picture_as_pdf</mat-icon>
|
||||
<span translate>PDF</span>
|
||||
</button>
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
373
client/src/app/site/motions/services/motion-pdf.service.ts
Normal file
373
client/src/app/site/motions/services/motion-pdf.service.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user