diff --git a/bower.json b/bower.json index 911996f26..2fd28f98c 100644 --- a/bower.json +++ b/bower.json @@ -9,6 +9,7 @@ "angular-bootstrap-colorpicker": "~3.0.25", "angular-chosen-localytics": "~1.5.0", "angular-csv-import": "~0.0.36", + "angular-file-saver": "~1.1.2", "angular-formly": "~8.4.0", "angular-formly-templates-bootstrap": "~6.2.0", "angular-gettext": "~2.3.7", @@ -21,6 +22,7 @@ "angular-ui-tinymce": "~0.0.17", "angular-ui-tree": "~2.22.0", "bootstrap-css-only": "~3.3.6", + "docxtemplater": "~2.1.5", "font-awesome-bower": "~4.5.0", "jquery.cookie": "~1.4.1", "js-data": "~2.9.0", diff --git a/openslides/core/static/js/core/site.js b/openslides/core/static/js/core/site.js index d768045b9..ab615b680 100644 --- a/openslides/core/static/js/core/site.js +++ b/openslides/core/static/js/core/site.js @@ -13,6 +13,7 @@ angular.module('OpenSlidesApp.core.site', [ 'localytics.directives', 'ngBootbox', 'ngDialog', + 'ngFileSaver', 'ngMessages', 'ngCsvImport', 'ui.tinymce', diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py index 1b1dfe849..bc7590f67 100644 --- a/openslides/motions/config_variables.py +++ b/openslides/motions/config_variables.py @@ -211,24 +211,24 @@ def get_config_variables(): subgroup='Voting and ballot papers', validators=(MinValueValidator(1),)) - # PDF + # PDF and DOCX export yield ConfigVariable( - name='motions_pdf_title', + name='motions_export_title', default_value='Motions', - label='Title for PDF document (all motions)', + label='Title for PDF and DOCX documents (all motions)', weight=370, group='Motions', - subgroup='PDF', + subgroup='Export', translatable=True) yield ConfigVariable( - name='motions_pdf_preamble', + name='motions_export_preamble', default_value='', - label='Preamble text for PDF document (all motions)', + label='Preamble text for PDF and DOCX documents (all motions)', weight=375, group='Motions', - subgroup='PDF') + subgroup='Export') yield ConfigVariable( name='motions_pdf_paragraph_numbering', @@ -237,4 +237,4 @@ def get_config_variables(): label='Show paragraph numbering (only in PDF)', weight=380, group='Motions', - subgroup='PDF') + subgroup='Export') diff --git a/openslides/motions/pdf.py b/openslides/motions/pdf.py index 744509e64..fbbf224c3 100644 --- a/openslides/motions/pdf.py +++ b/openslides/motions/pdf.py @@ -308,9 +308,9 @@ def all_motion_cover(pdf, motions): """ Create a coverpage for all motions. """ - pdf.append(Paragraph(escape(config["motions_pdf_title"]), stylesheet['Heading1'])) + pdf.append(Paragraph(escape(config["motions_export_title"]), stylesheet['Heading1'])) - preamble = escape(config["motions_pdf_preamble"]) + preamble = escape(config["motions_export_preamble"]) if preamble: pdf.append(Paragraph("%s" % preamble.replace('\r\n', '
'), stylesheet['Paragraph'])) diff --git a/openslides/motions/static/js/motions/base.js b/openslides/motions/static/js/motions/base.js index 46c7134e9..a93f73be6 100644 --- a/openslides/motions/static/js/motions/base.js +++ b/openslides/motions/static/js/motions/base.js @@ -5,7 +5,8 @@ angular.module('OpenSlidesApp.motions', [ 'OpenSlidesApp.users', 'OpenSlidesApp.motions.lineNumbering', - 'OpenSlidesApp.motions.diff' + 'OpenSlidesApp.motions.diff', + 'OpenSlidesApp.motions.DOCX' ]) .factory('WorkflowState', [ diff --git a/openslides/motions/static/js/motions/docx.js b/openslides/motions/static/js/motions/docx.js new file mode 100644 index 000000000..d9f7d4da7 --- /dev/null +++ b/openslides/motions/static/js/motions/docx.js @@ -0,0 +1,386 @@ +(function () { + +'use strict'; + +angular.module('OpenSlidesApp.motions.DOCX', []) + +.factory('MotionDocxExport', [ + '$http', + '$q', + 'Config', + 'gettextCatalog', + 'FileSaver', + function ($http, $q, Config, gettextCatalog, FileSaver) { + + var PAGEBREAK = ''; + var TAGS_NO_PARAM = ['b', 'strong', 'em', 'i']; + + var images; + var relationships; + var contentTypes; + + // $scope.motionsFiltered, $scope.categories + + var getData = function (motions, categories) { + var data = {}; + data.title = Config.get('motions_export_title').value; + data.preamble = Config.get('motions_export_preamble').value; + data.date = function () { + var today = new Date(); + var d = today.getDate(); + var m = today.getMonth()+1; //January is 0! + var y = today.getFullYear(); + if (d<10) { d='0'+d; } + if (m<10) { m='0'+m; } + return d+'.'+m+'.'+y; + }(); + data.pagebreak_main = motions.length === 0 ? '' : PAGEBREAK; + data.categories_translation = gettextCatalog.getString('Categories'); + data.no_categories = gettextCatalog.getString('No categories available.'); + data.no_motions = gettextCatalog.getString('No motions available.'); + data.categories = getCategoriesData(categories); + data.motions_list = getMotionShortData(motions); + data.motions = getMotionFullData(motions); + + return data; + }; + + var getCategoriesData = function (categories) { + return _.map(categories, function (category) { + return { + prefix: category.prefix, + name: category.name, + }; + }); + }; + + var getMotionShortData = function (motions) { + var translation = gettextCatalog.getString('Motion'); + return _.map(motions, function (motion) { + return { + motion_translation: translation, + identifier: motion.identifier, + title: motion.getTitle(), + }; + }); + }; + + var getMotionFullData = function (motions) { + var translation = gettextCatalog.getString('Motion'), + submitters_translation = gettextCatalog.getString('Submitters'), + signature_translation = gettextCatalog.getString('Signature'), + status_translation = gettextCatalog.getString('Status'), + reason_translation = gettextCatalog.getString('Reason'), + data = _.map(motions, function (motion) { + return { + motion_translation: translation, + identifier: motion.identifier, + title: motion.getTitle(), + submitters_translation: submitters_translation, + submitters: _.map(motion.submitters, function (submitter) { + return submitter.get_full_name(); + }).join(', '), + signature_translation: signature_translation, + status_translation: status_translation, + status: gettextCatalog.getString(motion.state.name), + text: html2docx(motion.getText()), + reason_translation: motion.getReason().length === 0 ? '' : reason_translation, + reason: html2docx(motion.getReason()), + pagebreak: PAGEBREAK, + }; + }); + if (data.length) { + // clear pagebreak on last element + data[data.length - 1].pagebreak = ''; + } + return data; + }; + + var html2docx = function (html) { + var docx = ''; + var stack = []; + + var isTag = false; // Even if html starts with ')/g); + + html.forEach(function (part) { + if (part !== '' && part != '\n' && part != '<' && part != '>') { + if (isTag) { + if (part.startsWith('p')) { /** p **/ + // Special: begin new paragraph (only if its the first): + if (hasParagraph && !skipFirstParagraphClosing) { + // End, if there is one + docx += ''; + } + skipFirstParagraphClosing = false; + docx += ''; + hasParagraph = true; + } else if (part.startsWith('/p')) { + // Special: end paragraph: + docx += ''; + hasParagraph = false; + + } else if (part.charAt(0) == "/") { + // remove from stack + stack.pop(); + } else { // now all other tags + var tag = {}; + if (_.indexOf(TAGS_NO_PARAM, part) > -1) { /** b, strong, em, i **/ + stack.push({tag: part}); + } else if (part.startsWith('span')) { /** span **/ + tag = {tag: 'span', attrs: {}}; + var rStyle = /(?:\"|\;\s?)([a-zA-z\-]+)\:\s?([a-zA-Z0-9\-\#]+)/g, matchSpan; + while ((matchSpan = rStyle.exec(part)) !== null) { + switch (matchSpan[1]) { + case 'color': + tag.attrs.color = matchSpan[2].slice(1); // cut off the # + break; + case 'background-color': + tag.attrs.backgroundColor = matchSpan[2].slice(1); // cut off the # + break; + case 'text-decoration': + if (matchSpan[2] === 'underline') { + tag.attrs.underline = true; + } else if (matchSpan[2] === 'line-through') { + tag.attrs.strike = true; + } + break; + } + } + stack.push(tag); + } else if (part.startsWith('a')) { /** a **/ + var rHref = /href="([^"]+)"/g; + var href = rHref.exec(part)[1]; + tag = {tag: 'a', href: href}; + stack.push(tag); + } else if (part.startsWith('img')) { + // images has to be placed instantly, so there is no use of 'tag'. + var img = {}, rImg = /(\w+)=\"([^\"]*)\"/g, matchImg; + while ((matchImg = rImg.exec(part)) !== null) { + img[matchImg[1]] = matchImg[2]; + } + + // With and height and source have to be given! + if (img.width && img.height && img.src) { + var rrId = relationships.length + 1; + var imgId = images.length + 1; + + // set name ('pic.jpg'), title, ext ('jpg'), mime ('image/jpeg') + img.name = img.src.split('/'); + img.name = _.last(img.name); + + var tmp = img.name.split('.'); + // set name without extension as title if there isn't a title + if (!img.title) { + img.title = tmp[0]; + } + img.ext = tmp[1]; + + img.mime = 'image/' + img.ext; + if (img.ext == 'jpe' || img.ext == 'jpg') { + img.mime = 'image/jpeg'; + } + + // x and y for the container and picture size in EMU (assuming 96dpi)! + var x = img.width * 914400 / 96; + var y = img.height * 914400 / 96; + + // Own paragraph for the image + if (hasParagraph) { + docx += ''; + } + docx += '' + + '' + + '' + + '' + + '' + + '' + + ''; + + // hasParagraph stays untouched, the documents paragraph state is restored here + if (hasParagraph) { + docx += ''; + } + + // entries in images, relationships and contentTypes + images.push({ + url: img.src, + zipPath: 'word/media/' + img.name + }); + relationships.push({ + Id: 'rrId' + rrId, + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + Target: 'media/' + img.name + }); + contentTypes.push({ + PartName: '/word/media/' + img.name, + ContentType: img.mime + }); + } + } + } + } else { /** No tag **/ + if (!hasParagraph) { + docx += ''; + hasParagraph = true; + } + var docx_part = ''; + var hyperlink = false; + stack.forEach(function (tag) { + switch (tag.tag) { + case 'b': case 'strong': + docx_part += ''; + break; + case 'em': case 'i': + docx_part += ''; + break; + case 'span': + for (var key in tag.attrs) { + switch (key) { + case 'color': + docx_part += ''; + break; + case 'backgroundColor': + docx_part += ''; + break; + case 'underline': + docx_part += ''; + break; + case 'strike': + docx_part += ''; + break; + } + } + break; + case 'a': + var id = relationships.length + 1; + docx_part = '' + docx_part; + docx_part += ''; // necessary? + relationships.push({ + Id: 'rrId' + id, + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', + Target: tag.href, + TargetMode: 'External' + }); + hyperlink = true; + break; + } + }); + docx_part += '' + part + ''; + if (hyperlink) { + docx_part += ''; + } + + // append to docx + docx += docx_part; + } + isTag = !isTag; + } + if (part === '' || part == '\n') { + // just if two tags following eachother: --> ...,'>', '', '<',... + // or there is a line break between: \n --> ...,'>', '\n', '<',... + isTag = !isTag; + } + }); + + // for finishing close the last paragraph (if open) + if (hasParagraph) { + docx += ''; + } + + // replacing of special symbols: + docx = docx.replace(new RegExp('\ä\;', 'g'), 'ä'); + docx = docx.replace(new RegExp('\ü\;', 'g'), 'ü'); + docx = docx.replace(new RegExp('\ö\;', 'g'), 'ö'); + docx = docx.replace(new RegExp('\Ä\;', 'g'), 'Ä'); + docx = docx.replace(new RegExp('\Ü\;', 'g'), 'Ü'); + docx = docx.replace(new RegExp('\Ö\;', 'g'), 'Ö'); + docx = docx.replace(new RegExp('\ß\;', 'g'), 'ß'); + docx = docx.replace(new RegExp('\ \;', 'g'), ' '); + docx = docx.replace(new RegExp('\§\;', 'g'), '§'); + + // remove all entities except gt, lt and amp + var rEntity = /\&(?!gt|lt|amp)\w+\;/g, matchEntry, indexes = []; + while ((matchEntry = rEntity.exec(docx)) !== null) { + indexes.push({ + startId: matchEntry.index, + stopId: matchEntry.index + matchEntry[0].length + }); + } + for (var i = indexes.length - 1; i>=0; i--) { + docx = docx.substring(0, indexes[i].startId) + docx.substring(indexes[i].stopId, docx.length); + } + + return docx; + }; + var updateRelationships = function (oldContent) { + var content = oldContent.split('\n'); + relationships.forEach(function (rel) { + content[1] += ' diff --git a/openslides/motions/urls.py b/openslides/motions/urls.py index 9b37d60d8..d2359a1a2 100644 --- a/openslides/motions/urls.py +++ b/openslides/motions/urls.py @@ -3,6 +3,10 @@ from django.conf.urls import url from . import views urlpatterns = [ + url(r'^docxtemplate/$', + views.MotionDocxTemplateView.as_view(), + name='motions_docx_template'), + url(r'^pdf/$', views.MotionPDFView.as_view(print_all_motions=True), name='motions_pdf'), diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 356241676..7678ee99d 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -1,3 +1,6 @@ +import base64 + +from django.contrib.staticfiles import finders from django.db import IntegrityError, transaction from django.http import Http404 from django.utils.text import slugify @@ -16,7 +19,7 @@ from openslides.utils.rest_api import ( ValidationError, detail_route, ) -from openslides.utils.views import PDFView, SingleObjectMixin +from openslides.utils.views import APIView, PDFView, SingleObjectMixin from .access_permissions import ( CategoryAccessPermissions, @@ -462,7 +465,7 @@ class WorkflowViewSet(ModelViewSet): return result -# Views to generate PDFs +# Views to generate PDFs and for the DOCX template class MotionPollPDF(PDFView): """ @@ -555,3 +558,15 @@ class MotionPDFView(SingleObjectMixin, PDFView): motions_to_pdf(pdf, motions) else: motion_to_pdf(pdf, self.get_object()) + + +class MotionDocxTemplateView(APIView): + """ + Returns the template for motions docx export + """ + http_method_names = ['get'] + + def get_context_data(self, **context): + with open(finders.find('templates/docx/motions.docx'), "rb") as file: + response = base64.b64encode(file.read()) + return response