Add motion to pdf converter
Adds the converstion from motion to PDF. Uses pdfmake as the old openslides does
This commit is contained in:
parent
57202e74ca
commit
9b61603dae
@ -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",
|
||||||
|
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">
|
<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>
|
||||||
|
@ -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
|
||||||
*
|
*
|
||||||
|
@ -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.
|
||||||
*
|
*
|
||||||
|
@ -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