# -*- coding: utf-8 -*- ############################################################################## # # Copyright (c) 2005 Nexedi SARL and Contributors. All Rights Reserved. # Jean-Paul Smets-Solanes <jp@nexedi.com> # # WARNING: This program as such is intended to be used by professional # programmers who take the whole responsability of assessing all potential # consequences resulting from its eventual inadequacies and bugs # End users who are looking for a ready-to-use solution with commercial # garantees and support are strongly adviced to contract a Free Software # Service Company # # This program is Free Software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # ############################################################################## from types import StringType from mimetypes import guess_extension from zLOG import LOG , INFO from zLOG import PROBLEM from OFS.Image import File from Products.CMFCore.FSPageTemplate import FSPageTemplate from Products.CMFCore.DirectoryView import registerFileExtension, registerMetaType from Products.PageTemplates.ZopePageTemplate import ZopePageTemplate from Products.PageTemplates.PageTemplateFile import PageTemplateFile try: from TAL.TALInterpreter import FasterStringIO except ImportError: from zope.tal.talinterpreter import FasterStringIO from Products.ERP5Type import PropertySheet from urllib import quote from Products.ERP5Type.Globals import InitializeClass, DTMLFile, get_request from Products.ERP5Type.Globals import DevelopmentMode from Acquisition import aq_base from AccessControl import ClassSecurityInfo from OOoUtils import OOoBuilder from zipfile import ZipFile, ZIP_DEFLATED try: from cStringIO import StringIO except ImportError: from StringIO import StringIO import re import itertools try: from webdav.Lockable import ResourceLockedError from webdav.WriteLockInterface import WriteLockInterface SUPPORTS_WEBDAV_LOCKS = 1 except ImportError: SUPPORTS_WEBDAV_LOCKS = 0 from Products.ERP5.Document.Document import ConversionError import Products.ERP5Type.Document from lxml import etree from lxml.etree import Element # Constructors manage_addOOoTemplate = DTMLFile("dtml/OOoTemplate_add", globals()) def addOOoTemplate(self, id, title="", xml_file_id="content.xml", REQUEST=None): """Add OOo template to folder. id -- the id of the new OOo template to add title -- the title of the OOo to add xml_file_id -- The Id of edited xml file Result -- empty string """ # add actual object id = self._setObject(id, OOoTemplate(id, title, xml_file_id)) if REQUEST is not None: file = REQUEST.form.get('file') if file.filename: # Get the template in the associated context and upload the file getattr(self,id).pt_upload(REQUEST, file) # respond to the add_and_edit button if necessary add_and_edit(self, id, REQUEST) return '' def add_and_edit(self, id, REQUEST): """Helper method to point to the object's management screen if 'Add and Edit' button is pressed. id -- id of the object we just added """ if REQUEST is None: return try: u = self.DestinationURL() except AttributeError: u = REQUEST['URL1'] if REQUEST['submit'] == " Add and Edit ": u = "%s/%s" % (u, quote(id)) REQUEST.RESPONSE.redirect(u+'/manage_main') class OOoTemplateStringIO(FasterStringIO): def write(self, s): if type(s) == unicode: s = s.encode('utf-8') FasterStringIO.write(self, s) try: from Products.PageTemplates.Expressions import ZopeContext, createZopeEngine except ImportError: # BACK: remove when we drop support for Zope 2.8 _engine = None else: # On Zope 2.12, we need an engine to decode non-unicode-strings for us class OOoContext(ZopeContext): """ ZopeContext variant that ALWAYS converts standard strings through utf-8, as needed by OpenOffice, ignoring the preferred encodings in the request. """ def _handleText(self, text, expr): if isinstance(text, str): # avoid calling the IUnicodeEncodingConflictResolver utility return unicode(text, 'utf-8') return ZopeContext._handleText(self, text, expr) def createOOoZopeEngine(): e = createZopeEngine() e._create_context = OOoContext return e _engine = createOOoZopeEngine() class OOoTemplate(ZopePageTemplate): """ A page template which is able to embed and OpenOffice file (zip archive) and replace content.xml at render time with XML dynamically generated through TAL/TALES/METAL expressions TODO: - find a way to embed TALES in OOo documents in such way that editing with OOo does not destroy TAL/TALES - add preprocessing option to handle explicit macros in OOo in any language. Include debugging options in this case (on preprocessed source rather than pure source) - add interface for Cache (http/RAM) """ meta_type = "ERP5 OOo Template" icon = "www/OOo.png" # NOTE: 100 is just pure random starting number # it won't influence the code at all document_counter = itertools.count(100) # Every linked OLE document is in a directory starting with 'Obj' _OLE_directory_prefix = 'Obj' # every OOo document have a content-type starting like this _OOo_content_type_root = 'application/vnd.sun.xml.' _ODF_content_type_root = 'application/vnd.oasis.opendocument.' # Declarative Security security = ClassSecurityInfo() # Declarative properties property_sheets = ( PropertySheet.Base , PropertySheet.SimpleItem) # Constructors constructors = (manage_addOOoTemplate, addOOoTemplate) # Default Attributes ooo_stylesheet = 'Base_getODTStyleSheet' ooo_script_name = None ooo_xml_file_id = 'content.xml' # Default content type #content_type = 'application/vnd.sun.xml.writer' # Writer type by default content_type = 'text/html' # This is the only for now to produce valid XML # Management interface manage_options = ( ZopePageTemplate.manage_options + ( {'label':'Stylesheet Setting', 'action':'formSettings', 'help':('ERPForm', 'pdfStylesheet.txt')}, ) ) _properties= ZopePageTemplate._properties + ( {'id': 'filename', 'type': 'tales', 'mode': 'w',}, ) filename = 'object/title_or_id' security.declareProtected('View management screens', 'formSettings') formSettings = PageTemplateFile('www/formSettings', globals(), __name__='formSettings') formSettings._owner = None def __init__(self, id, title, xml_file_id='content.xml', *args,**kw): ZopePageTemplate.__init__(self, id, title, *args, **kw) # we store the attachments of the uploaded document self.OLE_documents_zipstring = None self.ooo_xml_file_id = xml_file_id if _engine is not None: # Zope 2.12 relies on the ZTK implementation of page templates, # passing it a special expression evaluation context that converts strings # to unicode in the presence of the proper request headers. # Here we do the same, but forcing utf-8 conversion insteado of expecting # request headers. def pt_getEngine(self): return _engine else: # BACK: Remove when we drop support for Zope 2.8! # Every OOoTemplate uses UTF-8 or Unicode, so a special StringIO class # must be used, which does not care about response. def StringIO(self): return OOoTemplateStringIO() def pt_upload(self, REQUEST, file=''): """Replace the document with the text in file.""" if SUPPORTS_WEBDAV_LOCKS and self.wl_isLocked(): raise ResourceLockedError, "File is locked via WebDAV" if type(file) is not StringType: if not file: raise ValueError, 'File not specified' file = file.read() if file.startswith("PK") : # FIXME: this condition is probably not enough # this is a OOo zip file, extract the content builder = OOoBuilder(file) attached_files_list = [n for n in builder.getNameList() if n.startswith(self._OLE_directory_prefix) or n.startswith('Pictures') or n == 'META-INF/manifest.xml' ] # destroy a possibly pre-existing OLE document set if self.OLE_documents_zipstring: self.OLE_documents_zipstring = None # create a zip archive and store it if attached_files_list: memory_file = StringIO() try: zf = ZipFile(memory_file, mode='w', compression=ZIP_DEFLATED) except RuntimeError: zf = ZipFile(memory_file, mode='w') for attached_file in attached_files_list: zf.writestr(attached_file, builder.extract(attached_file) ) zf.close() memory_file.seek(0) self.OLE_documents_zipstring = memory_file.read() self.content_type = builder.getMimeType() file = builder.prepareContentXml(self.ooo_xml_file_id) return ZopePageTemplate.pt_upload(self, REQUEST, file) security.declareProtected('Change Page Templates', 'pt_edit') def pt_edit(self, text, content_type): if content_type: self.content_type = str(content_type) if hasattr(text, 'read'): text = text.read() self.write(text) security.declareProtected('Change Page Templates', 'doSettings') def doSettings(self, REQUEST, title, xml_file_id, ooo_stylesheet, script_name=None): """ Change title, xml_file_id and ooo_stylesheet. """ if SUPPORTS_WEBDAV_LOCKS and self.wl_isLocked(): raise ResourceLockedError, "File is locked via WebDAV" self.ooo_stylesheet = ooo_stylesheet self.ooo_script_name = script_name self.ooo_xml_file_id = xml_file_id self.pt_setTitle(title) #REQUEST.set('text', self.read()) # May not equal 'text'! message = "Saved changes." if getattr(self, '_v_warnings', None): message = ("<strong>Warning:</strong> <i>%s</i>" % '<br>'.join(self._v_warnings)) return self.formSettings(manage_tabs_message=message) def _resolvePath(self, path): return self.getPortalObject().unrestrictedTraverse(path) def renderIncludes(self, here, text, extra_context, request, sub_document=None): attached_files_dict = {} arguments_re = re.compile('''(\S+?)\s*=\s*('|")(.*?)\\2\s*''',re.DOTALL) def getLengthInfos( opts_dict, opts_names ): ret = [] for opt_name in opts_names: try: val = opts_dict.pop(opt_name) if val.endswith('cm'): val = val[:-2] val = float( val ) except (ValueError, KeyError): val = None ret.append(val) return ret def replaceIncludes(path): # Find the page template based on the path and remove path from dict document = self._resolvePath(path) document_text = ZopePageTemplate.pt_render(document, extra_context=extra_context) # Find the type of the embedded document document_type = document.content_type # Prepare a subdirectory to store embedded objects actual_idx = self.document_counter.next() dir_name = '%s%d'%(self._OLE_directory_prefix, actual_idx) if sub_document: # sub-document means sub-directory dir_name = sub_document + '/' + dir_name # Get the stylesheet of the embedded openoffice document ooo_stylesheet = document.ooo_stylesheet if ooo_stylesheet: ooo_stylesheet = getattr(here, ooo_stylesheet) # If ooo_stylesheet is dynamic, call it try: ooo_stylesheet = ooo_stylesheet() except AttributeError: pass temp_builder = OOoBuilder(ooo_stylesheet) stylesheet = temp_builder.extract('styles.xml') else: stylesheet = None # Start recursion if necessary sub_attached_files_dict = {} if 'office:include' in document_text: # small optimisation to avoid recursion if possible (document_text, sub_attached_files_dict ) = self.renderIncludes(document_text, dir_name, extra_context, request) # Attach content, style and settings if any attached_files_dict[dir_name] = dict(document=document_text, doc_type=document_type, stylesheet=stylesheet) attached_files_dict.update(sub_attached_files_dict) # Build the new tag new_path = './%s' % dir_name.split('/')[-1] return new_path def replaceIncludesImg(match): options_dict = { 'text:anchor-type': 'paragraph' } options_dict.update((x[0], x[2]) for x in arguments_re.findall(match.group(1))) for old_name, name, default in (('x', 'svg:x', '0cm'), ('y', 'svg:y', '0cm'), ('style', 'draw:style-name', 'fr1')): options_dict.setdefault(name, options_dict.pop(old_name, default)) picture = self._resolvePath(options_dict.pop('path').encode()) # If this is not a File, build a new file with this content if not isinstance(picture, File): tmp_picture = Products.ERP5Type.Document.newTempImage(self, 'tmp') tmp_picture.setData(picture()) picture = tmp_picture picture_type = options_dict.pop('type', None) picture_data = getattr(aq_base(picture), 'data', None) if picture_data is None: picture_data = picture.Base_download() if picture_type is None: picture_type = picture.content_type() else: # "standard" filetype case (Image or File) picture_data = str(picture_data) if picture_type is None: picture_type = picture.getContentType() w, h, maxwidth, maxheight = getLengthInfos(options_dict, ('width', 'height', 'maxwidth', 'maxheight')) aspect_ratio = 1 try: # try image properties aspect_ratio = float(picture.width) / float(picture.height) except (TypeError, ZeroDivisionError): try: # try ERP5.Document.Image API height = float(picture.getHeight()) if height: aspect_ratio = float(picture.getWidth()) / height except AttributeError: # fallback to Photo API height = float(picture.height()) if height: aspect_ratio = float(picture.width()) / height # fix a default value and correct the aspect if h is None: if w is None: w = 10.0 h = w / aspect_ratio elif w is None: w = h * aspect_ratio # picture is too large if maxwidth and maxwidth < w: w = maxwidth h = w / aspect_ratio if maxheight and maxheight < h: h = maxheight w = h * aspect_ratio actual_idx = self.document_counter.next() pic_name = 'Pictures/picture%d%s' \ % (actual_idx, guess_extension(picture_type) or '') # XXX: Pictures directory not managed (seems facultative) # <manifest:file-entry manifest:media-type="" manifest:full-path="ObjBFE4F50D/Pictures/"/> is_legacy = 'oasis.opendocument' not in self.content_type replacement = ('<draw:frame %s>\n<draw:image %s/></draw:frame>', '<draw:image %s %s/>')[is_legacy] % ( '''draw:name="ERP5Image%d" svg:width="%.3fcm" svg:height="%.3fcm"%s''' % (actual_idx, w, h, ''.join(' %s="%s"' % opt for opt in options_dict.iteritems())), '''xlink:href="%s%s" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"''' % (is_legacy and '#' or '', pic_name)) if sub_document: # sub-document means sub-directory pic_name = sub_document + '/' + pic_name attached_files_dict[pic_name] = dict( document=picture_data, doc_type=picture_type, ) if not (self.content_type.endswith('draw') or self.content_type.endswith('presentation') or self.content_type.endswith('writer') or self.content_type.endswith('text')): replacement = '<text:p text:style-name="Standard">'+replacement+'</text:p>' return replacement xml_doc = etree.XML(text) for office_include in xml_doc.xpath('//*[name() = "office:include"]'): marshal_list = office_include.xpath('./marshal') if marshal_list: from xml.marshal.generic import loads arg_dict = loads(etree.tostring(marshal_list[0], encoding='utf-8', xml_declaration=True, pretty_print=False)) extra_context.update(arg_dict) request.other.update(arg_dict) path = office_include.attrib['path'] del(office_include.attrib['path']) new_path = replaceIncludes(path) draw_object = Element('{%s}object' % xml_doc.nsmap.get('draw')) draw_object.attrib.update({'{%s}href' % xml_doc.nsmap.get('xlink'): new_path}) draw_object.attrib.update(dict(office_include.attrib)) office_include.getparent().replace(office_include, draw_object) text = etree.tostring(xml_doc, encoding='utf-8', xml_declaration=True, pretty_print=False) text = re.sub('<\s*office:include_img\s+(.*?)\s*/\s*>(?s)', replaceIncludesImg, text) return (text, attached_files_dict) # Proxy method to PageTemplate def pt_render(self, source=0, extra_context={}): # Get request request = extra_context.get('REQUEST', self.REQUEST) # Get parent object (the one to render this template on) here = getattr(self, 'aq_parent', None) if here is None: # This is a system error raise ValueError, 'Can not render a template without a parent acquisition context' # Retrieve master document ooo_document = None # If script is setting, call it if (self.ooo_script_name is not None) and (self.ooo_script_name != ''): ooo_script = getattr(here, self.ooo_script_name) ooo_document = ooo_script(self.ooo_stylesheet) else: ooo_document = getattr(here, self.ooo_stylesheet) format = request.get('format') try: # If style is dynamic, call it if getattr(aq_base(ooo_document), '__call__', None) is not None: request.set('format', None) ooo_document = ooo_document() finally: request.set('format', format) # Create a new builder instance ooo_builder = OOoBuilder(ooo_document) # Pass builder instance as extra_context extra_context['ooo_builder'] = ooo_builder # And render page template doc_xml = ZopePageTemplate.pt_render(self, source=source, extra_context=extra_context) if isinstance(doc_xml, unicode): doc_xml = doc_xml.encode('utf-8') # Replace the includes (doc_xml,attachments_dict) = self.renderIncludes(here, doc_xml, extra_context, request) try: default_styles_text = ooo_builder.extract('styles.xml') except AttributeError: default_styles_text = None # Add the associated files for dir_name, document_dict in attachments_dict.iteritems(): # Special case : the document is an OOo one if document_dict['doc_type'].startswith(self._OOo_content_type_root) or \ document_dict['doc_type'].startswith(self._ODF_content_type_root): ooo_builder.addFileEntry(full_path=dir_name, media_type=document_dict['doc_type']) ooo_builder.addFileEntry(full_path=dir_name + '/content.xml', media_type='text/xml', content=document_dict['document']) styles_text = default_styles_text if document_dict.has_key('stylesheet') and document_dict['stylesheet']: styles_text = document_dict['stylesheet'] if styles_text: ooo_builder.addFileEntry(full_path=dir_name + '/styles.xml', media_type='text/xml', content=styles_text) else: # Generic case ooo_builder.addFileEntry(full_path=dir_name, media_type=document_dict['doc_type'], content=document_dict['document']) # Debug mode if request.get('debug',0): return doc_xml # Replace content.xml in master openoffice template ooo_builder.replace(self.ooo_xml_file_id, doc_xml) # Old templates correction try: self.OLE_documents_zipstring except AttributeError: self.OLE_documents_zipstring = None # Convert if necessary opts = extra_context.get("options", dict()) # Get batch_mode batch_mode = opts.get('batch_mode', None) # If the file has embedded OLE documents, restore it if self.OLE_documents_zipstring: additional_builder = OOoBuilder( self.OLE_documents_zipstring ) for name in additional_builder.getNameList(): ooo_builder.replace(name, additional_builder.extract(name) ) # Update the META informations ooo_builder.updateManifest() # Produce final result if batch_mode: ooo = ooo_builder.render() else: ooo = ooo_builder.render(name=self.title or self.id) if DevelopmentMode: # Validate XML in development mode from Products.ERP5OOo.tests.utils import Validator err_list = Validator().validate(ooo) if err_list: LOG('ERP5OOo', PROBLEM, 'Validation of %s failed:\n%s' % (self.getId(), ''.join(err_list))) format = opts.get('format', request.get('format', None)) from Products.ERP5Type.Document import newTempOOoDocument tmp_ooo = newTempOOoDocument(self, self.title_or_id()) tmp_ooo.edit(data=ooo, source_reference='%s.%s' % (self._getFileName(), format), content_type=self.content_type,) if format: # Performance improvement: # Call convertToBaseFormat only if user # ask a particular output format tmp_ooo.convertToBaseFormat() if request is not None and not batch_mode: return tmp_ooo.index_html(REQUEST=request, RESPONSE=request.RESPONSE, format=format) return tmp_ooo.convert(format)[1] def om_icons(self): """Return a list of icon URLs to be displayed by an ObjectManager""" icons = ({'path': 'misc_/ERP5OOo/OOo.png', 'alt': self.meta_type, 'title': self.meta_type},) if not self._v_cooked: self._cook() if self._v_errors: icons = icons + ({'path': 'misc_/PageTemplates/exclamation.gif', 'alt': 'Error', 'title': 'This template has an error'},) return icons def _getFileName(self): """Returns the filename used for content-disposition header. """ # The "filename" property has a TALES type, but getProperty for TALES types # only works if the context has an ERP5 Site in his acquisition context. # If it's not the case, we will not evaluate the TALES, but simply use the # template's title or id as filename. if getattr(self, 'getPortalObject', None) is None: return self.title_or_id() return self.getProperty('filename') InitializeClass(OOoTemplate) class FSOOoTemplate(FSPageTemplate, OOoTemplate): meta_type = "ERP5 Filesystem OOo Template" icon = "www/OOo.png" def __call__(self, *args, **kwargs): return OOoTemplate.__call__(self, *args, **kwargs) InitializeClass(FSOOoTemplate) registerFileExtension('ooot', FSOOoTemplate) registerMetaType(OOoTemplate.meta_type, FSOOoTemplate)