Skip to content

Commit

Permalink
Merge ae72c5a into c21822e
Browse files Browse the repository at this point in the history
  • Loading branch information
gbastien committed May 27, 2021
2 parents c21822e + ae72c5a commit 8fd447e
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 37 deletions.
3 changes: 3 additions & 0 deletions CHANGES.rst
Expand Up @@ -6,6 +6,9 @@ Changelog

- Lowercased email address after validation.
[sgeulette]
- Added `xhtml.imagesToData` that turns the src of images used in a xhtml
content from an `http` or equivalent URL to a data base64 value.
[gbastien]

0.42 (2021-04-30)
-----------------
Expand Down
2 changes: 1 addition & 1 deletion src/imio/helpers/cache.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from persistent.mapping import PersistentMapping
from plone import api
from plone.i18n.normalizer import IIDNormalizer
from plone.memoize import ram
Expand All @@ -9,7 +10,6 @@
from zope.component import getUtility
from zope.component import queryUtility
from zope.schema.interfaces import IVocabularyFactory
from persistent.mapping import PersistentMapping

import logging

Expand Down
5 changes: 3 additions & 2 deletions src/imio/helpers/emailer.py
@@ -1,24 +1,25 @@
# -*- coding: utf-8 -*-

from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import parseaddr
from email import encoders
from imio.helpers import _
from imio.helpers.content import safe_encode
from plone import api
from Products.CMFDefault.exceptions import EmailAddressInvalid
from Products.CMFDefault.utils import checkEmailAddress
from Products.CMFPlone.utils import safe_unicode
from smtplib import SMTPException
from zope.component import getMultiAdapter
from zope import schema
from zope.component import getMultiAdapter

import csv
import logging
import socket


logger = logging.getLogger("imio.helpers")
EMAIL_CHARSET = 'utf-8'

Expand Down
1 change: 1 addition & 0 deletions src/imio/helpers/patches.py
Expand Up @@ -2,6 +2,7 @@

import pkg_resources


try:
pkg_resources.get_distribution("collective.solr")
except pkg_resources.DistributionNotFound:
Expand Down
2 changes: 1 addition & 1 deletion src/imio/helpers/security.py
Expand Up @@ -10,12 +10,12 @@
from random import sample
from random import seed
from time import time
from zope.component import getMultiAdapter

import logging
import os
import string

from zope.component import getMultiAdapter

logger = logging.getLogger("imio.helpers")

Expand Down
2 changes: 1 addition & 1 deletion src/imio/helpers/tests/test_content.py
Expand Up @@ -14,8 +14,8 @@
from imio.helpers.content import get_state_infos
from imio.helpers.content import get_vocab
from imio.helpers.content import normalize_name
from imio.helpers.content import object_values
from imio.helpers.content import object_ids
from imio.helpers.content import object_values
from imio.helpers.content import restore_link_integrity_checks
from imio.helpers.content import richtextval
from imio.helpers.content import safe_delattr
Expand Down
5 changes: 5 additions & 0 deletions src/imio/helpers/tests/test_security.py
Expand Up @@ -5,6 +5,7 @@
from imio.helpers.security import get_environment
from imio.helpers.security import get_user_from_criteria
from imio.helpers.security import is_develop_environment
from imio.helpers.security import setup_logger
from imio.helpers.testing import IntegrationTestCase
from plone import api

Expand Down Expand Up @@ -58,3 +59,7 @@ def test_get_user_from_criteria(self):
self.assertEqual(len(get_user_from_criteria(self.portal, fullname='Stéph')), 1)
self.assertEqual(len(get_user_from_criteria(self.portal, email='.be')), 2)
self.assertEqual(len(get_user_from_criteria(self.portal, fullname='Smith')), 2)

def test_setup_logger(self):
# just call it to check that it is not broken
self.assertIsNone(setup_logger())
41 changes: 41 additions & 0 deletions src/imio/helpers/tests/test_xhtml.py
Expand Up @@ -2,6 +2,7 @@
from imio.helpers.testing import IntegrationTestCase
from imio.helpers.xhtml import addClassToContent
from imio.helpers.xhtml import addClassToLastChildren
from imio.helpers.xhtml import imagesToData
from imio.helpers.xhtml import imagesToPath
from imio.helpers.xhtml import markEmptyTags
from imio.helpers.xhtml import object_link
Expand All @@ -16,6 +17,7 @@

import urllib


picsum_image1_url = 'https://i.picsum.photos/id/10/200/300.jpg?hmac=94QiqvBcKJMHpneU69KYg2pky8aZ6iBzKrAuhSUBB9s'
picsum_image2_url = 'https://i.picsum.photos/id/1082/200/200.jpg?hmac=3usO1ziO7kCseIG52ruhRigxyk39W_L9eECWe1Hs6fY'

Expand All @@ -32,6 +34,20 @@
"OXORpldeTR7KdNjU1QVosfhpsfhpt/cwHP6JojjAmhovhRofL5/KTY7NdGBPhToBdjz+L9gTfb28/QDsHx4Ge5rVu2+zHWNDQ3InVk+YbvWe" \
"+HEAkp6v/6cnemATSvoCmtFvb0Kvy47BR72AfwDXsx4tZedcTQAAAABJRU5ErkJggg=="

base64_gif_img_data = "data:image/gif;base64,R0lGODlhCgAKAPcAAP////79/f36+/3z8/zy8/rq7Prm6Pnq7Pje4vTx8v" \
"Pg5O6gqe2gqOq4v+igqt9tetxSYNs7Tdo5TNc5TNUbMdUbMNQRKNIKIdIJINIGHdEGHtEDGtACFdAAFtAAFNAAEs8AFAAAAAAAA" \
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" \
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" \
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" \
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" \
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" \
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAACIAIP3sAAoACBL2OAAAAPjHWRQAABUKiAAAABL2FAAAAhL+dPkBhPm4MP///xL2YPZNegA" \
"AAAAQAAAABgAAAAAAABL2wAAAAAAARRL25PEPfxQAAPEQAAAAAAAAA/3r+AAAAAAAGAAAABL2uAAAQgAAABL2pAAAAAAAAAAAAAA" \
"ADAAAAgABAfDTAAAAmAAAAAAAAAAAABL27Mku0AAQAAAAAAAAAEc1MUaDsAAAGBL3FPELyQAAAAABEgAAAwAAAQAAAxL22AAAmB" \
"L+dPO4dPPKQP///0c70EUoOQAAmMku0AAQABL3TAAAAEaDsEc1MUaDsAAABkT98AABEhL3wAAALAAAQAAAQBL3kQAACSH5BAEAA" \
"AAALAAAAAAKAAoAQAhGAAEIHEhw4IIIFCAsKCBwQAQQFyKCeKDAIEKFDAE4hBiRA0WCAwwUBLCAA4cMICg0YIjAQoaIFzJMSCCw" \
"5EkOKjM2FDkwIAA7"


class TestXHTMLModule(IntegrationTestCase):
"""
Expand Down Expand Up @@ -456,6 +472,27 @@ def test_imagesToPath(self):
text = '<img src="resolveuid/unknown_uid" alt="Image" title="Image">'
self.assertEqual(imagesToPath(doc2, text), text)

def test_imagesToData(self):
"""
Test that images src contained in a XHTML content are correctly changed to
the a data base64 value.
Method is based on same as imagesToPath so we do not redo
tests that are already done in test_imagesToPath.
"""
# create a document and an image
docId = self.portal.invokeFactory('Document', id='doc', title='Document')
doc = getattr(self.portal, docId)
file_path = path.join(path.dirname(__file__), 'dot.gif')
data = open(file_path, 'r')
img = self.portal.invokeFactory('Image', id='img', title='Image', file=data.read())
img = getattr(self.portal, img)
# has a blob
self.assertEqual(img.get_size(), 873)
text = '<p>Image <img src="{0}/img"> end of text.</p>'.format(self.portal_url)
self.assertEqual(
imagesToData(doc, text),
'<p>Image <img src="{0}"> end of text.</p>'.format(base64_gif_img_data))

def test_storeExternalImagesLocally(self):
"""
Test that images src contained in a XHTML that reference external images is
Expand Down Expand Up @@ -694,6 +731,10 @@ def test_object_link(self):
u'<a href="http://nohost/plone/folder/edit" target="_blank"></a>')

def test_separate_images(self):
# no image, content is returned as is
text = '<p>My text.</p><p>My text.</p><p>My text.</p>'
result = separate_images(text)
self.assertEqual(text, result)
# one image, nothing changed
text = '<p><img src="http://plone/nohost/image1.png"></p>'
result = separate_images(text)
Expand Down
113 changes: 81 additions & 32 deletions src/imio/helpers/xhtml.py
Expand Up @@ -9,6 +9,7 @@
from zExceptions import NotFound
from zope.container.interfaces import INameChooser

import base64
import cgi
import logging
import lxml.html
Expand Down Expand Up @@ -254,7 +255,48 @@ def removeCssClasses(xhtmlContent,
for x in tree.iterchildren()])


def imagesToPath(context, xhtmlContent, pretty_print=False):
def _img_from_src(context, img, portal, portal_url):
""" """
# check if it is a local or an external image
img_src = img.attrib.get('src', None)
# wrong <img> without src or external image
if not img_src or (img_src.startswith('http') and not img_src.startswith(portal_url)):
return
# here, we have an image contained in the portal
# either absolute path (http://...) or relative (../images/myimage.png)
imageObj = None
# absolute path
if img_src.startswith(portal_url):
img_src = img_src.replace(portal_url, '')
try:
# get the image but remove leading '/'
imageObj = portal.unrestrictedTraverse(img_src[1:])
except (KeyError, AttributeError, NotFound):
return
# relative path
else:
try:
imageObj = context.unrestrictedTraverse(img_src)
# in case we have a wrong resolveuid/unknown_uid, it raises NotFound
except (KeyError, AttributeError, NotFound):
return

# maybe we have a ImageScale instead of the real Image object?
if isinstance(imageObj, ImageScale):
imageObj = imageObj.aq_inner.aq_parent
return imageObj


def _get_image_blob(imageObj):
"""Be defensinve in case this is a wrong <img> with a src
to someting else than an image... """
blob = None
if hasattr(aq_base(imageObj), 'getBlobWrapper') and imageObj.get_size():
blob = imageObj.getBlobWrapper()
return blob


def _transform_images(context, xhtmlContent, pretty_print=False, transform_type="path"):
'''Turn <img> source contained in given p_xhtmlContent to a FileSystem absolute path
to the .blob binary stored on the server. This is usefull when generating documents
with XHTML containing images that are private, LibreOffice is not able to access these
Expand Down Expand Up @@ -284,39 +326,23 @@ def imagesToPath(context, xhtmlContent, pretty_print=False):
portal = api.portal.get()
portal_url = portal.absolute_url()
for img in imgs:
# check if it is a local or an external image
img_src = img.attrib.get('src', None)
# wrong <img> without src or external image
if not img_src or (img_src.startswith('http') and not img_src.startswith(portal_url)):
imageObj = _img_from_src(context, img, portal, portal_url)
if imageObj is None:
continue
# here, we have an image contained in the portal
# either absolute path (http://...) or relative (../images/myimage.png)
imageObj = None
# absolute path
if img_src.startswith(portal_url):
img_src = img_src.replace(portal_url, '')
try:
# get the image but remove leading '/'
imageObj = portal.unrestrictedTraverse(img_src[1:])
except (KeyError, AttributeError, NotFound):
continue
# relative path
else:
try:
imageObj = context.unrestrictedTraverse(img_src)
# in case we have a wrong resolveuid/unknown_uid, it raises NotFound
except (KeyError, AttributeError, NotFound):
continue
# maybe we have a ImageScale instead of the real Image object?
if isinstance(imageObj, ImageScale):
imageObj = imageObj.aq_inner.aq_parent

blob = _get_image_blob(imageObj)
# change img src only if a blob was found
blob_path = None
# be defensinve in case this is a wrong <img> with a src to someting else than an image...
if hasattr(aq_base(imageObj), 'getBlobWrapper') and imageObj.get_size():
blob_path = imageObj.getBlobWrapper().blob._p_blob_committed
# change img src only if a blob_path was found
if blob_path:
img.attrib['src'] = blob_path
if blob:
if transform_type == "path":
blob_path = blob.blob._p_blob_committed
if blob_path:
img.attrib['src'] = blob_path
elif transform_type == "data":
blob_path = blob.blob._p_blob_committed
if blob_path and blob.content_type.startswith('image/'):
img.attrib['src'] = "data:{0};base64,{1}".format(
blob.content_type, base64.b64encode(blob.data))

# use encoding to 'ascii' so HTML entities are translated to something readable
return ''.join([lxml.html.tostring(x,
Expand All @@ -325,6 +351,29 @@ def imagesToPath(context, xhtmlContent, pretty_print=False):
method='html') for x in tree.iterchildren()])


def imagesToPath(context, xhtmlContent, pretty_print=False):
'''Turn <img> source contained in given p_xhtmlContent to a FileSystem absolute path
to the .blob binary stored on the server. This is usefull when generating documents
with XHTML containing images that are private, LibreOffice is not able to access these
images using the HTTP request.
<img src='http://mysite/myfolder/myimage.png' /> becomes
<img src='/absolute/path/to/blobstorage/myfile.blob'/>,
external images are left unchanged.
The image_scale is not kept, so :
<img src='http://mysite/myfolder/myimage.png/image_preview' /> becomes
<img src='/absolute/path/to/blobstorage/myfile.blob'/>.'''

return _transform_images(context, xhtmlContent, pretty_print, transform_type="path")


def imagesToData(context, xhtmlContent, pretty_print=False):
'''Turn <img> source contained in given p_xhtmlContent to a data:image/png;base64... value.
External images are left unchanged.
The image_scale is not kept, so we get the full image.'''

return _transform_images(context, xhtmlContent, pretty_print, transform_type="data")


def storeImagesLocally(context,
xhtmlContent,
imagePortalType='Image',
Expand Down

0 comments on commit 8fd447e

Please sign in to comment.