PDFMake implemented

This commit is contained in:
Thomas Junk 2016-08-19 14:10:30 +02:00
parent 08c734f1a3
commit 546c4e65f6
7 changed files with 788 additions and 2 deletions

View File

@ -199,6 +199,7 @@ OpenSlides uses the following projects or parts of them:
* `angular-gettext <http://angular-gettext.rocketeer.be/>`_, License: MIT
* `angular-loading-bar <https://chieffancypants.github.io/angular-loading-bar>`_, License: MIT
* `angular-messages <http://angularjs.org>`_, License: MIT
* `pdfmake <http://pdfmake.org>`_, License: MIT
* `angular-pdf <http://github.com/sayanee/angularjs-pdf>`_, License: MIT
* `angular-sanitize <http://angularjs.org>`_, License: MIT
* `angular-scroll-glue <https://github.com/Luegg/angularjs-scroll-glue>`_, License: MIT

View File

@ -29,12 +29,16 @@
"ng-dialog": "~0.5.6",
"ng-file-upload": "~11.2.3",
"ngBootbox": "~0.1.3",
"pdfmake": "~0.1.17",
"open-sans-fontface": "https://github.com/OpenSlides/open-sans.git#1.4.2.post1",
"roboto-condensed": "~0.3.0",
"tinymce-dist": "4.3.12",
"tinymce-i18n": "OpenSlides/tinymce-i18n#a186ad61e0aa30fdf657e88f405f966d790f0805"
},
"overrides": {
"pdfmake-dist": {
"main": "build/pdfmake.min.js"
},
"pdfjs-dist": {
"main": "build/pdf.combined.js"
},

View File

@ -18,6 +18,403 @@ angular.module('OpenSlidesApp.core.site', [
'ui.tinymce',
'luegg.directives',
])
.factory('PdfMakeDocumentProvider', function() {
/**
* Provides the global Document
* @constructor
* @param {object} contentProvider - Object with on method `getContent`, which returns an array for content
* @param {string} defaultFont - Default font for the document
*/
var createInstance = function(contentProvider, defaultFont) {
/**
* Generates header for PDF
* @constructor
*/
var header = function() {
var date = new Date();
return {
// alignment: 'center',
color: '#555',
fontSize: 10,
margin: [80, 50, 80, 0], //margin: [left, top, right, bottom]
columns: ['OpenSlides | Presentation and assembly system', {
fontSize: 6,
text: 'Stand: ' + date.toLocaleDateString() + " " + date.toLocaleTimeString(),
alignment: 'right'
}]
};
},
/**
* Generates footer line
* @function
* @param {object} currentPage - An object representing the current page
* @param {number} pageCount - number for pages
*/
footer = function(currentPage, pageCount) {
return {
alignment: 'center',
fontSize: 8,
color: '#555',
text: "Seite: " + currentPage.toString()
};
},
/**
* Generates the document(definition) for pdfMake
* @function
*/
getDocument = function() {
var content = contentProvider.getContent();
return {
pageSize: 'A4',
pageMargins: [80, 90, 80, 60],
defaultStyle: {
font: defaultFont
},
fontSize: 8,
header: header,
footer: footer,
content: content,
};
};
return {
getDocument: getDocument
};
};
return {
createInstance: createInstance
};
})
.factory('PdfMakeConverter', function() {
/**
* Converter component for HTML->JSON for pdfMake
* @constructor
* @param {object} images - Key-Value structure representing image.src/BASE64 of images
* @param {object} fonts - Key-Value structure representing fonts (detailed description below)
* @param {object} pdfMake - the converter component enhances pdfMake
*/
var createInstance = function(images, fonts, pdfMake) {
var slice = Function.prototype.call.bind([].slice),
map = Function.prototype.call.bind([].map),
/**
* Adds a custom font to pdfMake.vfs
* @function
* @param {object} fontFiles - object with Files to add to pdfMake.vfs
* {
* normal: $Filename
* bold: $Filename
* italics: $Filename
* bolditalics: $Filename
* }
*/
addFontToVfs = function(fontFiles) {
Object.keys(fontFiles).forEach(function(name) {
var file = fontFiles[name];
pdfMake.vfs[file.name] = file.content;
});
},
/**
* Adds custom fonts to pdfMake
* @function
* @param {object} fontInfo - Font configuration from Backend
* {
* $FontName : {
* normal: $Filename
* bold: $Filename
* italics: $Filename
* bolditalics: $Filename
* }
* }
*/
registerFont = function(fontInfo) {
Object.keys(fontInfo).forEach(function(name) {
var font = fontInfo[name];
addFontToVfs(font);
pdfMake.fonts = pdfMake.fonts || {};
pdfMake.fonts[name] = Object.keys(font).reduce(function(fontDefinition, style) {
fontDefinition[style] = font[style].name;
return fontDefinition;
}, {});
});
},
/**
* Convertes HTML for use with pdfMake
* @function
* @param {object} html - html
*/
convertHTML = function(html) {
var elementStyles = {
"b": ["font-weight:bold"],
"strong": ["font-weight:bold"],
"u": ["text-decoration:underline"],
"em": ["font-style:italic"],
"i": ["font-style:italic"],
"h1": ["font-size:30"],
"h2": ["font-size:28"],
"h3": ["font-size:26"],
"h4": ["font-size:24"],
"h5": ["font-size:22"],
"h6": ["font-size:20"],
"a": ["color:blue", "text-decoration:underline"]
},
/**
* Parses Children of the current paragraph
* @function
* @param {object} converted -
* @param {object} element -
* @param {object} currentParagraph -
* @param {object} styles -
*/
parseChildren = function(converted, element, currentParagraph, styles) {
var elements = [];
var children = element.childNodes;
if (children.length !== 0) {
_.forEach(children, function(child) {
currentParagraph = ParseElement(elements, child, currentParagraph, styles);
});
}
if (elements.length !== 0) {
_.forEach(elements, function(el) {
converted.push(el);
});
}
return currentParagraph;
},
/**
* Extracts the style from an object
* @function
* @param {object} o - the current object
* @param {object} styles - an array with styles
*/
ComputeStyle = function(o, styles) {
styles.forEach(function(singleStyle) {
var styleDefinition = singleStyle.trim().toLowerCase().split(":");
var style = styleDefinition[0];
var value = styleDefinition[1];
if (styleDefinition.length == 2) {
switch (style) {
case "padding-left":
o.margin = [parseInt(value), 0, 0, 0];
break;
case "font-size":
o.fontSize = parseInt(value);
break;
case "text-align":
switch (value) {
case "right":
case "center":
case "justify":
o.alignment = value;
break;
}
break;
case "font-weight":
switch (value) {
case "bold":
o.bold = true;
break;
}
break;
case "text-decoration":
switch (value) {
case "underline":
o.decoration = "underline";
break;
case "line-through":
o.decoration = "lineThrough";
break;
}
break;
case "font-style":
switch (value) {
case "italic":
o.italics = true;
break;
}
break;
case "color":
o.color = value;
break;
case "background-color":
o.background = value;
break;
}
}
});
},
/**
* Parses a single HTML element
* @function
* @param {object} alreadyConverted -
* @param {object} element -
* @param {object} currentParagraph -
* @param {object} styles -
*/
ParseElement = function(alreadyConverted, element, currentParagraph, styles) {
styles = styles || [];
if (element.getAttribute) {
var nodeStyle = element.getAttribute("style");
if (nodeStyle) {
nodeStyle.split(";").forEach(function(nodeStyle) {
var tmp = nodeStyle.replace(/\s/g, '');
styles.push(tmp);
});
}
}
var nodeName = element.nodeName.toLowerCase();
switch (nodeName) {
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
currentParagraph = create("text");
/* falls through */
case "a":
parseChildren(alreadyConverted, element, currentParagraph, styles.concat(elementStyles[nodeName]));
alreadyConverted.push(currentParagraph);
break;
case "b":
case "strong":
case "u":
case "em":
case "i":
parseChildren(alreadyConverted, element, currentParagraph, styles.concat(elementStyles[nodeName]));
break;
case "table":
var t = create("table", {
widths: [],
body: []
});
var border = element.getAttribute("border");
var isBorder = false;
if (border)
if (parseInt(border) == 1) isBorder = true;
if (!isBorder) t.layout = 'noBorders';
parseChildren(t.table.body, element, currentParagraph, styles);
var widths = element.getAttribute("widths");
if (!widths) {
if (t.table.body.length !== 0) {
if (t.table.body[0].length !== 0)
for (var k = 0; k < t.table.body[0].length; k++)
t.table.widths.push("*");
}
} else {
var w = widths.split(",");
for (var ko = 0; ko < w.length; ko++) t.table.widths.push(w[ko]);
}
alreadyConverted.push(t);
break;
case "tbody":
parseChildren(alreadyConverted, element, currentParagraph, styles);
break;
case "tr":
var row = [];
parseChildren(row, element, currentParagraph, styles);
alreadyConverted.push(row);
break;
case "td":
currentParagraph = create("text");
var st = create("stack");
st.stack.push(currentParagraph);
var rspan = element.getAttribute("rowspan");
if (rspan)
st.rowSpan = parseInt(rspan);
var cspan = element.getAttribute("colspan");
if (cspan)
st.colSpan = parseInt(cspan);
parseChildren(st.stack, element, currentParagraph, styles);
alreadyConverted.push(st);
break;
case "span":
parseChildren(alreadyConverted, element, currentParagraph, styles);
break;
case "br":
currentParagraph = create("text");
alreadyConverted.push(currentParagraph);
break;
case "li":
case "div":
case "p":
currentParagraph = create("text");
var stack = create("stack");
stack.stack.push(currentParagraph);
ComputeStyle(stack, styles);
parseChildren(stack.stack, element, currentParagraph);
alreadyConverted.push(stack);
break;
case "img":
alreadyConverted.push({
image: BaseMap[element.getAttribute("src")],
width: parseInt(element.getAttribute("width")),
height: parseInt(element.getAttribute("height"))
});
break;
case "ul":
var u = create("ul");
parseChildren(u.ul, element, currentParagraph, styles);
alreadyConverted.push(u);
break;
case "ol":
var o = create("ol");
parseChildren(o.ol, element, currentParagraph, styles);
alreadyConverted.push(o);
break;
default:
var temporary = create("text", element.textContent.replace(/\n/g, ""));
if (styles)
ComputeStyle(temporary, styles);
currentParagraph.text.push(temporary);
break;
}
return currentParagraph;
},
/**
* Parses HTML
* @function
* @param {string} converted -
* @param {object} htmlText -
*/
ParseHtml = function(converted, htmlText) {
var html = $(htmlText.replace(/\t/g, "").replace(/\n/g, ""));
var emptyParagraph = create("text");
slice(html).forEach(function(element) {
ParseElement(converted, element, emptyParagraph);
});
},
content = [];
ParseHtml(content, html);
return content;
},
BaseMap = images,
/**
* Creates containerelements for pdfMake
* e.g create("text":"MyText") result in { text: "MyText" }
* or complex objects create("stack", [{text:"MyText"}, {text:"MyText2"}])
*for units / paragraphs of text
*
* @function
* @param {string} name - name of the attribute holding content
* @param {object} content - the actual content (maybe empty)
*/
create = function(name, content) {
var o = {};
content = content || [];
o[name] = content;
return o;
};
fonts.forEach(function(fontInfo) {
registerFont(fontInfo);
});
return {
convertHTML: convertHTML,
createElement: create
};
};
return {
createInstance: createInstance
};
})
// Provider to register entries for the main menu.
.provider('mainMenu', [

View File

@ -4,6 +4,207 @@
angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
.factory('MotionContentProvider', ['gettextCatalog', function(gettextCatalog) {
/**
* Provides the content as JS objects for Motions in pdfMake context
* @constructor
*/
var createInstance = function(converter) {
/**
* Text of motion
* @function
* @param {object} motion - Current motion
* @param {object} $scope - Current $scope
*/
var textContent = function(motion, $scope) {
return converter.convertHTML(motion.getText($scope.version));
},
/**
* Generate text of reason
* @function
* @param {object} motion - Current motion
* @param {object} $scope - Current $scope
*/
reasonContent = function(motion, $scope) {
return converter.convertHTML(motion.getReason($scope.version));
},
/**
* Generate header text of motion
* @function
* @param {object} motion - Current motion
* @param {object} $scope - Current $scope
*/
motionHeader = function(motion, $scope) {
var header = converter.createElement("text", gettextCatalog.getString("Motion") + " " + motion.identifier + ": " + motion.getTitle($scope.version));
header.bold = true;
header.fontSize = 26;
return header;
},
/**
* Generate text of signment
* @function
* @param {object} motion - Current motion
* @param {object} $scope - Current $scope
* @param {object} User - Current user
*/
signment = function(motion, $scope, User) {
var label = converter.createElement("text", gettextCatalog.getString('Submitter') + ':\nStatus:');
label.width = "30%";
label.bold = true;
var signment = converter.createElement("stack", [label]);
signment.margin = [10, 20, 0, 10];
signment.lineHeight = 2.5;
return signment;
},
/**
* Generates polls
* @function
* @param {object} motion - Current motion
* @param {object} $scope - Current $scope
*/
polls = function(motion, $scope) {
if (!motion.polls.length) return {};
var pollLabel = converter.createElement("text", gettextCatalog.getString('Voting result') + ":"),
results = function() {
return motion.polls.map(function(poll, index) {
var id = index + 1,
yes = poll.yes,
yesRelative = (poll.yes) * 100 / (poll.votescast),
no = poll.no,
noRelative = (poll.no) * 100 / (poll.votescast),
abstain = poll.abstain,
abstainRelative = (poll.abstain) * 100 / (poll.votescast),
valid = poll.votesvalid,
validRelative = (poll.votesvalid) * 100 / (poll.votescast),
number = {
text: id + ".",
width: "5%"
},
headerText = {
text: "Abstimmung",
width: "15%"
},
/**
* Generates a part (consisting of different columns) of the polls
*
* Example Ja 100 ( 90% )
*
* @function
* @param {string} name - E.g. "Ja"
* @param {number} value - E.g.100
* @param {number} relValue - E.g. 90
*/
createPart = function(name, value, relValue) {
var indexColumn = converter.createElement("text");
var nameColumn = converter.createElement("text", "" + name);
var valueColumn = converter.createElement("text", "" + value);
var relColumn = converter.createElement("text", "(" + "" + relValue + "%)");
valueColumn.width = "40%";
indexColumn.width = "5%";
valueColumn.width = "5%";
valueColumn.alignment = "right";
relColumn.margin = [5, 0, 0, 0];
return [indexColumn, nameColumn, valueColumn, relColumn];
},
yesPart = converter.createElement("columns", createPart(gettextCatalog.getString("Yes"), yes, yesRelative)),
noPart = converter.createElement("columns", createPart(gettextCatalog.getString("No"), no, noRelative)),
abstainPart = converter.createElement("columns", createPart(gettextCatalog.getString("Abstain"), abstain, abstainRelative)),
totalPart = converter.createElement("columns", createPart(gettextCatalog.getString("Valid votes"), valid, validRelative)),
heading = converter.createElement("columns", [number, headerText]),
pollResult = converter.createElement("stack", [
heading, yesPart, noPart, abstainPart, totalPart
]);
return pollResult;
}, {});
};
pollLabel.width = '35%';
pollLabel.bold = true;
var result = converter.createElement("columns", [pollLabel, results()]);
result.margin = [10, 0, 0, 10];
result.lineHeight = 1;
return result;
},
/**
* Generates title section for motion
* @function
* @param {object} motion - Current motion
* @param {object} $scope - Current $scope
*/
titleSection = function(motion, $scope) {
var title = converter.createElement("text", motion.getTitle($scope.version));
title.bold = true;
title.fontSize = 14;
title.margin = [0, 0, 0, 10];
return title;
},
/**
* Generates reason section for polls
* @function
* @param {object} motion - Current motion
* @param {object} $scope - Current $scope
*/
reason = function(motion, $scope) {
var r = converter.createElement("text", gettextCatalog.getString("Reason") + ":");
r.bold = true;
r.fontSize = 14;
r.margin = [0, 30, 0, 10];
return r;
},
/**
* Generates content as a pdfmake consumable
* @function
* @param {object} motion - Current motion
* @param {object} $scope - Current $scope
* @param {object} User - Current user
*/
getContent = function(motion, $scope, User) {
return [
motionHeader(motion, $scope),
signment(motion, $scope, User),
polls(motion, $scope),
titleSection(motion, $scope),
textContent(motion, $scope),
reason(motion, $scope),
reasonContent(motion, $scope)
];
};
return {
getContent: getContent
};
};
return {
createInstance: createInstance
};
}])
.factory('SingleMotionContentProvider', function() {
/**
* Generates a content provider
* @constructor
* @param {object} motionContentProvider - Generates pdfMake structure from motion
* @param {object} $scope - Current $scope
* @param {object} User - Current User
*/
var createInstance = function(motionContentProvider, motion, $scope, User) {
/**
* Returns Content for single motion
* @function
* @param {object} motion - Current motion
* @param {object} $scope - Current $scope
* @param {object} User - Current User
*/
var getContent = function() {
return motionContentProvider.getContent(motion, $scope, User);
};
return {
getContent: getContent
};
};
return {
createInstance: createInstance
};
})
.config([
'mainMenuProvider',
'gettext',
@ -573,7 +774,14 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
'User',
'Workflow',
'motion',
function($scope, $http, ngDialog, MotionForm, Motion, Category, Mediafile, Tag, User, Workflow, motion) {
'SingleMotionContentProvider',
'MotionContentProvider',
'PdfMakeConverter',
'PdfMakeDocumentProvider',
function($scope, $http, ngDialog, MotionForm,
Motion, Category, Mediafile, Tag,
User, Workflow, motion,
SingleMotionContentProvider, MotionContentProvider, PdfMakeConverter, PdfMakeDocumentProvider) {
Motion.bindOne(motion.id, $scope, 'motion');
Category.bindAll({}, $scope, 'categories');
Mediafile.bindAll({}, $scope, 'mediafiles');
@ -584,6 +792,31 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
$scope.version = motion.active_version;
$scope.isCollapsed = true;
$scope.makePDF = function(){
var content = motion.getText($scope.version) + motion.getReason($scope.version),
slice = Function.prototype.call.bind([].slice),
map = Function.prototype.call.bind([].map),
image_sources = map($(content).find("img"), function(element) {
return element.getAttribute("src");
});
$http.post('/motions/encode_media/', JSON.stringify(image_sources)).success(function(data) {
/**
* Converter for use with pdfMake
* @constructor
* @param {object} images - An object to resolve the BASE64 encoded images { "$src":$BASE64DATA }
* @param {object} fonts - An object representing the available custom fonts
* @param {object} pdfMake - pdfMake object for enhancement with custom fonts
*/
var converter = PdfMakeConverter.createInstance(data.images, data.fonts, pdfMake),
motionContentProvider = MotionContentProvider.createInstance(converter),
contentProvider = SingleMotionContentProvider.createInstance(motionContentProvider, motion, $scope, User),
documentProvider = PdfMakeDocumentProvider.createInstance(contentProvider, data.defaultFont);
pdfMake.createPdf(documentProvider.getDocument()).open();
});
};
// open edit dialog
$scope.openDialog = function (motion) {
ngDialog.open(MotionForm.getDialog(motion));

View File

@ -9,6 +9,10 @@
<i class="fa fa-file-pdf-o fa-lg"></i>
<translate>PDF</translate>
</a>
<a ng-click="makePDF()" class="btn btn-primary btn-sm">
<i class="fa fa-file-pdf-o fa-lg"></i>
<translate>PDFmake</translate>
</a>
<!-- List of speakers -->
<a ui-sref="agenda.item.detail({id: motion.agenda_item_id})" class="btn btn-sm btn-default">
<i class="fa fa-microphone fa-lg"></i>

View File

@ -14,4 +14,6 @@ urlpatterns = [
url(r'^poll/(?P<poll_pk>\d+)/print/$',
views.MotionPollPDF.as_view(),
name='motionpoll_pdf'),
url(r'^encode_media/', views.encode_media, name="media_encoding")
]

View File

@ -1,10 +1,16 @@
import base64
import json
import os
from django.conf import settings
from django.db import transaction
from django.http import Http404
from django.http import Http404, JsonResponse
from django.utils.text import slugify
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_noop
from reportlab.platypus import SimpleDocTemplate
from rest_framework import status
from rest_framework.decorators import api_view
from openslides.core.config import config
from openslides.utils.rest_api import (
@ -466,3 +472,142 @@ class MotionPDFView(SingleObjectMixin, PDFView):
motions_to_pdf(pdf, motions)
else:
motion_to_pdf(pdf, self.get_object())
@api_view(["POST"])
def encode_media(request):
"""
Encode_image is used in the context of PDF-Generation
Takes an array of IMG.src - Paths
Retrieves the according images
Encodes the images
Add configured fonts
Puts it into a key-value structure
{
"images": {
"media/file/ubuntu.png":"$ENCODED_IMAGE"
},
"fonts": [{
$FontName : {
normal: $Filename
bold: $Filename
italics: $Filename
bolditalics: $Filename
}
}],
"default_font": "$DEFAULTFONT"
}
:param request:
:return: JsonResponse of the resulting dictionary
Calling e.g.
$.ajax({ type: "POST", url: "/motions/encode_images/",
data: JSON.stringify(["$FILEPATH"]),
success: function(data){ console.log(data); },
dataType: 'application/json' });
"""
body_unicode = request.body.decode('utf-8')
file_paths = json.loads(body_unicode)
images = {file_path: encode_image_from(file_path) for file_path in file_paths}
fonts = encoded_fonts()
default_font = get_default_font()
return JsonResponse({
"images": images,
"fonts": fonts,
"defaultFont": default_font
})
def get_default_font():
"""
For development purposes this is hard coded
:return: the name of the default Font
"""
return "OpenSans"
def encoded_fonts():
"""
Generate font encoding for pdfMake
:return: list of Font Encodings
"""
fonts = get_configured_fonts()
enc_fonts = [encode_font(name, files) for name, files in fonts.items()]
return enc_fonts
def get_configured_fonts():
"""
For development purposes, the current font definition is hard coded
The form is {
$FontName : {
normal: $Filename
bold: $Filename
italics: $Filename
bolditalics: $Filename
}
}
This structure is required according to PDFMake specs.
:return:
"""
fonts = {
"OpenSans": {
"normal": 'OpenSans-Regular.ttf',
"bold": 'OpenSans-Bold.ttf',
"italics": 'OpenSans-Italic.ttf',
"bolditalics": 'OpenSans-BoldItalic.ttf'
}
}
return fonts
def encode_font(fontName, font_files):
"""
Responsible to encode a single font
:param fontName: name of the font
:param font_files: files for different weighs
:return: dictionary with encoded font
"""
encoded_files = {type: encode_font_from(file_path) for type, file_path in font_files.items()}
return {fontName: encoded_files}
def encode_font_from(file_path):
"""
Returns the BASE64 encoded version of an image-file for a given path
:param file_path:
:return: dictionary with the string representation (content) and the name of the file
for the pdfMake.vfs structure
"""
path = os.path.join(settings.SITE_ROOT, 'static/fonts', os.path.basename(file_path))
try:
with open(path, "rb") as file:
string_representation = "{}".format(base64.b64encode(file.read()).decode())
except:
return ""
else:
return {"content": string_representation, "name": file_path}
def encode_image_from(file_path):
"""
Returns the BASE64 encoded version of an image-file for a given path
:param file_path:
:return:
"""
path = os.path.join(settings.MEDIA_ROOT, 'file', os.path.basename(file_path))
try:
with open(path, "rb") as file:
string_representation = "data:image/{};base64,{}".format(os.path.splitext(file_path)[1][1:],
base64.b64encode(file.read()).decode())
except:
return ""
else:
return string_representation