Merge pull request #2381 from FinnStutzenstein/Issue2346

Motions docx export with docxtemplater
This commit is contained in:
Norman Jäckel 2016-09-23 14:34:59 +02:00 committed by GitHub
commit 66aa42021a
11 changed files with 439 additions and 18 deletions

View File

@ -9,6 +9,7 @@
"angular-bootstrap-colorpicker": "~3.0.25", "angular-bootstrap-colorpicker": "~3.0.25",
"angular-chosen-localytics": "~1.5.0", "angular-chosen-localytics": "~1.5.0",
"angular-csv-import": "~0.0.36", "angular-csv-import": "~0.0.36",
"angular-file-saver": "~1.1.2",
"angular-formly": "~8.4.0", "angular-formly": "~8.4.0",
"angular-formly-templates-bootstrap": "~6.2.0", "angular-formly-templates-bootstrap": "~6.2.0",
"angular-gettext": "~2.3.7", "angular-gettext": "~2.3.7",
@ -21,6 +22,7 @@
"angular-ui-tinymce": "~0.0.17", "angular-ui-tinymce": "~0.0.17",
"angular-ui-tree": "~2.22.0", "angular-ui-tree": "~2.22.0",
"bootstrap-css-only": "~3.3.6", "bootstrap-css-only": "~3.3.6",
"docxtemplater": "~2.1.5",
"font-awesome-bower": "~4.5.0", "font-awesome-bower": "~4.5.0",
"jquery.cookie": "~1.4.1", "jquery.cookie": "~1.4.1",
"js-data": "~2.9.0", "js-data": "~2.9.0",

View File

@ -13,6 +13,7 @@ angular.module('OpenSlidesApp.core.site', [
'localytics.directives', 'localytics.directives',
'ngBootbox', 'ngBootbox',
'ngDialog', 'ngDialog',
'ngFileSaver',
'ngMessages', 'ngMessages',
'ngCsvImport', 'ngCsvImport',
'ui.tinymce', 'ui.tinymce',

View File

@ -211,24 +211,24 @@ def get_config_variables():
subgroup='Voting and ballot papers', subgroup='Voting and ballot papers',
validators=(MinValueValidator(1),)) validators=(MinValueValidator(1),))
# PDF # PDF and DOCX export
yield ConfigVariable( yield ConfigVariable(
name='motions_pdf_title', name='motions_export_title',
default_value='Motions', default_value='Motions',
label='Title for PDF document (all motions)', label='Title for PDF and DOCX documents (all motions)',
weight=370, weight=370,
group='Motions', group='Motions',
subgroup='PDF', subgroup='Export',
translatable=True) translatable=True)
yield ConfigVariable( yield ConfigVariable(
name='motions_pdf_preamble', name='motions_export_preamble',
default_value='', default_value='',
label='Preamble text for PDF document (all motions)', label='Preamble text for PDF and DOCX documents (all motions)',
weight=375, weight=375,
group='Motions', group='Motions',
subgroup='PDF') subgroup='Export')
yield ConfigVariable( yield ConfigVariable(
name='motions_pdf_paragraph_numbering', name='motions_pdf_paragraph_numbering',
@ -237,4 +237,4 @@ def get_config_variables():
label='Show paragraph numbering (only in PDF)', label='Show paragraph numbering (only in PDF)',
weight=380, weight=380,
group='Motions', group='Motions',
subgroup='PDF') subgroup='Export')

View File

@ -308,9 +308,9 @@ def all_motion_cover(pdf, motions):
""" """
Create a coverpage for all 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: if preamble:
pdf.append(Paragraph("%s" % preamble.replace('\r\n', '<br/>'), stylesheet['Paragraph'])) pdf.append(Paragraph("%s" % preamble.replace('\r\n', '<br/>'), stylesheet['Paragraph']))

View File

@ -5,7 +5,8 @@
angular.module('OpenSlidesApp.motions', [ angular.module('OpenSlidesApp.motions', [
'OpenSlidesApp.users', 'OpenSlidesApp.users',
'OpenSlidesApp.motions.lineNumbering', 'OpenSlidesApp.motions.lineNumbering',
'OpenSlidesApp.motions.diff' 'OpenSlidesApp.motions.diff',
'OpenSlidesApp.motions.DOCX'
]) ])
.factory('WorkflowState', [ .factory('WorkflowState', [

View File

@ -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 = '<w:p><w:r><w:br w:type="page" /></w:r></w:p>';
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 '<p....' it is split to '', '<', ..., so always no tag at the beginning
var hasParagraph = true;
var skipFirstParagraphClosing = true;
if (html.substring(0,3) != '<p>') {
docx += '<w:p>';
skipFirstParagraphClosing = false;
}
html = html.split(/(<|>)/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 += '</w:p>';
}
skipFirstParagraphClosing = false;
docx += '<w:p>';
hasParagraph = true;
} else if (part.startsWith('/p')) {
// Special: end paragraph:
docx += '</w:p>';
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 += '</w:p>';
}
docx += '<w:p><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extend cx="' + x +'" cy="' + y + '"/><wp:effectExtent l="0" t="0" r="0" b="0"/>' +
'<wp:docPr id="' + imgId + '" name="' + img.name + '" title="' + img.title + '" descr="' + img.title + '"/><wp:cNvGraphicFramePr>' +
'<a:graphicFrameLocks xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"/></wp:cNvGraphicFramePr>' +
'<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">' +
'<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="' + imgId + '" name="' +
img.name + '" title="' + img.title + '" descr="' + img.title + '"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rrId' + rrId + '"/><a:stretch>' +
'<a:fillRect/></a:stretch></pic:blipFill><pic:spPr bwMode="auto"><a:xfrm><a:off x="0" y="0"/><a:ext cx="' + x + '" cy="' + y + '"/></a:xfrm>' +
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>';
// hasParagraph stays untouched, the documents paragraph state is restored here
if (hasParagraph) {
docx += '<w:p>';
}
// 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 += '<w:p>';
hasParagraph = true;
}
var docx_part = '<w:r><w:rPr>';
var hyperlink = false;
stack.forEach(function (tag) {
switch (tag.tag) {
case 'b': case 'strong':
docx_part += '<w:b/><w:bCs/>';
break;
case 'em': case 'i':
docx_part += '<w:i/><w:iCs/>';
break;
case 'span':
for (var key in tag.attrs) {
switch (key) {
case 'color':
docx_part += '<w:color w:val="' + tag.attrs[key] + '"/>';
break;
case 'backgroundColor':
docx_part += '<w:shd w:fill="' + tag.attrs[key] + '"/>';
break;
case 'underline':
docx_part += '<w:u w:val="single"/>';
break;
case 'strike':
docx_part += '<w:strike/>';
break;
}
}
break;
case 'a':
var id = relationships.length + 1;
docx_part = '<w:hyperlink r:id="rrId' + id + '">' + docx_part;
docx_part += '<w:rStyle w:val="Internetlink"/>'; // 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 += '</w:rPr><w:t>' + part + '</w:t></w:r>';
if (hyperlink) {
docx_part += '</w:hyperlink>';
}
// append to docx
docx += docx_part;
}
isTag = !isTag;
}
if (part === '' || part == '\n') {
// just if two tags following eachother: <b><span> --> ...,'>', '', '<',...
// or there is a line break between: <b>\n<span> --> ...,'>', '\n', '<',...
isTag = !isTag;
}
});
// for finishing close the last paragraph (if open)
if (hasParagraph) {
docx += '</w:p>';
}
// replacing of special symbols:
docx = docx.replace(new RegExp('\&auml\;', 'g'), 'ä');
docx = docx.replace(new RegExp('\&uuml\;', 'g'), 'ü');
docx = docx.replace(new RegExp('\&ouml\;', 'g'), 'ö');
docx = docx.replace(new RegExp('\&Auml\;', 'g'), 'Ä');
docx = docx.replace(new RegExp('\&Uuml\;', 'g'), 'Ü');
docx = docx.replace(new RegExp('\&Ouml\;', 'g'), 'Ö');
docx = docx.replace(new RegExp('\&szlig\;', 'g'), 'ß');
docx = docx.replace(new RegExp('\&nbsp\;', 'g'), ' ');
docx = docx.replace(new RegExp('\&sect\;', '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] += '<Relationship';
for (var key in rel) {
content[1] += ' ' + key + '="' + rel[key] + '"';
}
content[1] += '/>';
});
return content.join('\n');
};
var updateContentTypes = function (oldContent) {
var content = oldContent.split('\n');
contentTypes.forEach(function (type) {
content[1] += '<Override';
for (var key in type) {
content[1] += ' ' + key + '="' + type[key] + '"';
}
content[1] += '/>';
});
return content.join('\n');
};
return {
export: function (motions, categories) {
images = [];
relationships = [];
contentTypes = [];
$http.get('/motions/docxtemplate/').then(function (success) {
var content = window.atob(success.data);
var doc = new Docxgen(content);
doc.setData(getData(motions, categories));
doc.render();
var zip = doc.getZip();
// update relationships from 'relationships'
var rels = updateRelationships(zip.file('word/_rels/document.xml.rels').asText());
zip.file('word/_rels/document.xml.rels', rels);
// update content type from 'contentTypes'
var contentTypes = updateContentTypes(zip.file('[Content_Types].xml').asText());
zip.file('[Content_Types].xml', contentTypes);
var imgPromises = [];
images.forEach(function (img) {
imgPromises.push(
$http.get(img.url, {responseType: 'arraybuffer'}).then(function (resolvedImage) {
zip.file(img.zipPath, resolvedImage.data);
})
);
});
// wait for all images to be resolved
$q.all(imgPromises).then(function () {
var out = zip.generate({type: 'blob'});
FileSaver.saveAs(out, 'motions-export.docx');
});
});
},
};
}
]);
}());

View File

@ -831,7 +831,8 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid
'Workflow', 'Workflow',
'User', 'User',
'Agenda', 'Agenda',
function($scope, $state, $http, ngDialog, MotionForm, Motion, Category, Tag, Workflow, User, Agenda) { 'MotionDocxExport',
function($scope, $state, $http, ngDialog, MotionForm, Motion, Category, Tag, Workflow, User, Agenda, MotionDocxExport) {
Motion.bindAll({}, $scope, 'motions'); Motion.bindAll({}, $scope, 'motions');
Category.bindAll({}, $scope, 'categories'); Category.bindAll({}, $scope, 'categories');
Tag.bindAll({}, $scope, 'tags'); Tag.bindAll({}, $scope, 'tags');
@ -989,9 +990,9 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid
ngDialog.open(MotionForm.getDialog(motion)); ngDialog.open(MotionForm.getDialog(motion));
}; };
// Export the given motions as a csv file // Export as a csv file
$scope.csv_export = function () { $scope.csv_export = function () {
var element = document.getElementById('downloadLink'); var element = document.getElementById('downloadLinkCSV');
var csvRows = [ var csvRows = [
['identifier', 'title', 'text', 'reason', 'submitter', 'category', 'origin'], ['identifier', 'title', 'text', 'reason', 'submitter', 'category', 'origin'],
]; ];
@ -1013,6 +1014,10 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid
element.download = 'motions-export.csv'; element.download = 'motions-export.csv';
element.target = '_blank'; element.target = '_blank';
}; };
// Export as docx file
$scope.docx_export = function () {
MotionDocxExport.export($scope.motionsFiltered, $scope.categories);
};
// *** delete mode functions *** // *** delete mode functions ***
$scope.isDeleteMode = false; $scope.isDeleteMode = false;

Binary file not shown.

View File

@ -45,6 +45,7 @@
<span class="caret"></span> <span class="caret"></span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownExport"> <ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownExport">
<!-- PDF export -->
<li> <li>
<a ui-sref="motions_pdf" target="_blank"> <a ui-sref="motions_pdf" target="_blank">
<i class="fa fa-file-pdf-o fa-lg"></i> <i class="fa fa-file-pdf-o fa-lg"></i>
@ -53,13 +54,19 @@
</li> </li>
<!--CSV export --> <!--CSV export -->
<li> <li>
<a href="" id="downloadLink" <a href="" id="downloadLinkCSV"
os-perms="motions.can_manage"
ng-click="csv_export()"> ng-click="csv_export()">
<i class="fa fa-file-text-o fa-lg"></i> <i class="fa fa-file-text-o fa-lg"></i>
<translate>CSV</translate> <translate>CSV</translate>
</a> </a>
</li> </li>
<!--DOCX export -->
<li>
<a href="" ng-click="docx_export()">
<i class="fa fa-file-word-o fa-lg"></i>
<translate>DOCX</translate>
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -3,6 +3,10 @@ from django.conf.urls import url
from . import views from . import views
urlpatterns = [ urlpatterns = [
url(r'^docxtemplate/$',
views.MotionDocxTemplateView.as_view(),
name='motions_docx_template'),
url(r'^pdf/$', url(r'^pdf/$',
views.MotionPDFView.as_view(print_all_motions=True), views.MotionPDFView.as_view(print_all_motions=True),
name='motions_pdf'), name='motions_pdf'),

View File

@ -1,3 +1,6 @@
import base64
from django.contrib.staticfiles import finders
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.http import Http404 from django.http import Http404
from django.utils.text import slugify from django.utils.text import slugify
@ -16,7 +19,7 @@ from openslides.utils.rest_api import (
ValidationError, ValidationError,
detail_route, detail_route,
) )
from openslides.utils.views import PDFView, SingleObjectMixin from openslides.utils.views import APIView, PDFView, SingleObjectMixin
from .access_permissions import ( from .access_permissions import (
CategoryAccessPermissions, CategoryAccessPermissions,
@ -462,7 +465,7 @@ class WorkflowViewSet(ModelViewSet):
return result return result
# Views to generate PDFs # Views to generate PDFs and for the DOCX template
class MotionPollPDF(PDFView): class MotionPollPDF(PDFView):
""" """
@ -555,3 +558,15 @@ class MotionPDFView(SingleObjectMixin, PDFView):
motions_to_pdf(pdf, motions) motions_to_pdf(pdf, motions)
else: else:
motion_to_pdf(pdf, self.get_object()) 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