Skip to content

Commit b2ba110

Browse files
committed
Merge branch 'feature/jamovi-renderer' into develop
[SVCS-549] Closes: #279
2 parents 9e9fc26 + 1103aef commit b2ba110

19 files changed

+389
-2
lines changed

mfr/extensions/jamovi/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# jamovi .omv file renderer
2+
3+
`.omv` is the native file format for the free and open source [jamovi statistical spreadsheet](https://www.jamovi.org). An `.omv` file is a 'compound' file format, containing data, analyses, and results.
4+
5+
`.omv` files created by recent versions of jamovi contain an `index.html` file which represents the results of the analyses performed. The jamovi `.omv` file renderer extracts the contents of `index.html` from the archive and replaces image paths from the archive with equivalent data URIs. This then serves as the rendered content.

mfr/extensions/jamovi/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .render import JamoviRenderer # noqa

mfr/extensions/jamovi/exceptions.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from mfr.core.exceptions import RendererError
2+
3+
4+
class JamoviRendererError(RendererError):
5+
6+
def __init__(self, message, *args, **kwargs):
7+
super().__init__(message, *args, renderer_class='jamovi', **kwargs)
8+
9+
10+
class JamoviVersionError(JamoviRendererError):
11+
"""The jamovi related errors raised from a :class:`mfr.extentions.jamovi` and relating to minimum
12+
data archive version should throw or subclass JamoviVersionError.
13+
"""
14+
15+
__TYPE = 'jamovi_version'
16+
17+
def __init__(self, message, *args, code: int=400, created_by: str='',
18+
actual_version: str='', required_version: str='', **kwargs):
19+
super().__init__(message, *args, code=code, **kwargs)
20+
self.created_by = created_by
21+
self.actual_version = actual_version
22+
self.required_version = required_version
23+
self.attr_stack.append([self.__TYPE, {
24+
'created_by': self.created_by,
25+
'actual_version': self.actual_version,
26+
'required_version': self.required_version,
27+
}])
28+
29+
30+
class JamoviFileCorruptError(JamoviRendererError):
31+
"""The jamovi related errors raised from a :class:`mfr.extentions.jamovi` and relating to failure
32+
while consuming jamovi files should inherit from JamoviFileCorruptError
33+
"""
34+
35+
__TYPE = 'jamovi_file_corrupt'
36+
37+
def __init__(self, message, *args, code: int=400, corruption_type: str='',
38+
reason: str='', **kwargs):
39+
super().__init__(message, *args, code=code, **kwargs)
40+
self.corruption_type = corruption_type
41+
self.reason = reason
42+
self.attr_stack.append([self.__TYPE, {
43+
'corruption_type': self.corruption_type,
44+
'reason': self.reason,
45+
}])
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import base64
2+
from html.parser import HTMLParser
3+
from io import StringIO
4+
5+
6+
class HTMLProcessor(HTMLParser):
7+
8+
# The HTMLProcessor replaces the src attribute in <image> tags with the base64 equivalent.
9+
# The image content comes from the zip_file (specified with set_src_source()).
10+
# It also strips <script> and <object> tags and on$foo attributes from the HTML (potential
11+
# attack vectors)
12+
13+
FILTERED_TAGS = ['script', 'object']
14+
15+
def __init__(self, zip_file):
16+
HTMLParser.__init__(self)
17+
self._html = StringIO() # buffer for the processed HTML
18+
self._zip_file = zip_file
19+
20+
# used to exclude the contents of script and object tags
21+
self._excl_nested_level = 0
22+
23+
def handle_starttag(self, tag, attrs):
24+
if tag in self.FILTERED_TAGS: # filter scripts and objects (attack vectors)
25+
self._excl_nested_level += 1
26+
return
27+
28+
self._html.write('<')
29+
self._html.write(tag)
30+
31+
for attr in attrs:
32+
if attr[0].startswith('on'):
33+
# skip onclick="", on...="" attributes (attack vectors)
34+
continue
35+
self._html.write(' ')
36+
self._html.write(attr[0])
37+
if attr[1] is not None:
38+
self._html.write('="')
39+
if attr[0] == 'src':
40+
self._insert_data_uri(attr[1])
41+
else:
42+
self._html.write(attr[1])
43+
44+
self._html.write('"')
45+
46+
self._html.write('>')
47+
48+
def _insert_data_uri(self, src):
49+
with self._zip_file.open(src) as src_file:
50+
src_data = src_file.read()
51+
src_b64 = base64.b64encode(src_data)
52+
53+
self._html.write('data:image/png;base64,')
54+
self._html.write(src_b64.decode('utf-8'))
55+
56+
def handle_endtag(self, tag):
57+
if tag in self.FILTERED_TAGS:
58+
if self._excl_nested_level > 0:
59+
self._excl_nested_level -= 1
60+
return
61+
62+
self._html.write('</')
63+
self._html.write(tag)
64+
self._html.write('>')
65+
66+
def handle_data(self, data):
67+
if self._excl_nested_level == 0:
68+
self._html.write(data)
69+
70+
def final_html(self):
71+
return self._html.getvalue()

mfr/extensions/jamovi/render.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from distutils.version import LooseVersion
2+
import os
3+
from zipfile import ZipFile, BadZipFile
4+
5+
from mako.lookup import TemplateLookup
6+
7+
from mfr.core import extension
8+
from mfr.extensions.jamovi import exceptions as jamovi_exceptions
9+
from mfr.extensions.jamovi.html_processor import HTMLProcessor
10+
11+
12+
class JamoviRenderer(extension.BaseRenderer):
13+
14+
# Minimum data archive version supported
15+
MINIMUM_VERSION = LooseVersion('1.0.2')
16+
17+
TEMPLATE = TemplateLookup(
18+
directories=[
19+
os.path.join(os.path.dirname(__file__), 'templates')
20+
]).get_template('viewer.mako')
21+
22+
MESSAGE_FILE_CORRUPT = 'This jamovi file is corrupt and cannot be viewed.'
23+
MESSAGE_NO_PREVIEW = 'This jamovi file does not support previews.'
24+
25+
def render(self):
26+
try:
27+
with ZipFile(self.file_path) as zip_file:
28+
self._check_file(zip_file)
29+
body = self._render_html(zip_file, self.metadata.ext)
30+
return self.TEMPLATE.render(base=self.assets_url, body=body)
31+
except BadZipFile as err:
32+
raise jamovi_exceptions.JamoviRendererError(
33+
'{} {}.'.format(self.MESSAGE_FILE_CORRUPT, str(err)),
34+
extension=self.metadata.ext,
35+
corruption_type='bad_zip',
36+
reason=str(err),
37+
)
38+
39+
@property
40+
def file_required(self):
41+
return True
42+
43+
@property
44+
def cache_result(self):
45+
return True
46+
47+
def _render_html(self, zip_file, ext, *args, **kwargs):
48+
index = None
49+
try:
50+
with zip_file.open('index.html') as index_data:
51+
index = index_data.read().decode('utf-8')
52+
except KeyError:
53+
raise jamovi_exceptions.JamoviRendererError(
54+
self.MESSAGE_NO_PREVIEW,
55+
)
56+
57+
processor = HTMLProcessor(zip_file)
58+
processor.feed(index)
59+
60+
return processor.final_html()
61+
62+
def _check_file(self, zip_file):
63+
"""Check if the file is OK (not corrupt)
64+
:param zip_file: an opened ZipFile representing the jamovi file
65+
:return: True
66+
"""
67+
# Extract manifest file content
68+
try:
69+
with zip_file.open('META-INF/MANIFEST.MF') as manifest_data:
70+
manifest = manifest_data.read().decode('utf-8')
71+
except KeyError:
72+
raise jamovi_exceptions.JamoviFileCorruptError(
73+
'{} Missing META-INF/MANIFEST.MF'.format(self.MESSAGE_FILE_CORRUPT),
74+
extension=self.metadata.ext,
75+
corruption_type='key_error',
76+
reason='zip missing ./META-INF/MANIFEST.MF',
77+
)
78+
79+
lines = manifest.split('\n')
80+
81+
# Search for Data-Archive-Version
82+
version_str = None
83+
for line in lines:
84+
key_value = line.split(':')
85+
if len(key_value) == 2 and key_value[0].strip() == 'Data-Archive-Version':
86+
version_str = key_value[1].strip()
87+
break
88+
else:
89+
raise jamovi_exceptions.JamoviFileCorruptError(
90+
'{} Data-Archive-Version not found.'.format(self.MESSAGE_FILE_CORRUPT),
91+
extension=self.metadata.ext,
92+
corruption_type='manifest_parse_error',
93+
reason='Data-Archive-Version not found.',
94+
)
95+
96+
# Check that the file is new enough (contains preview content)
97+
archive_version = LooseVersion(version_str)
98+
try:
99+
if archive_version < self.MINIMUM_VERSION:
100+
raise jamovi_exceptions.JamoviFileCorruptError(
101+
'{} Data-Archive-Version is too old.'.format(self.MESSAGE_FILE_CORRUPT),
102+
extension=self.metadata.ext,
103+
corruption_type='manifest_parse_error',
104+
reason='Data-Archive-Version not found.',
105+
)
106+
except TypeError:
107+
raise jamovi_exceptions.JamoviFileCorruptError(
108+
'{} Data-Archive-Version not parsable.'.format(self.MESSAGE_FILE_CORRUPT),
109+
extension=self.metadata.ext,
110+
corruption_type='manifest_parse_error',
111+
reason='Data-Archive-Version ({}) not parsable.'.format(version_str),
112+
)
113+
114+
return True
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<div style="word-wrap: break-word; overflow: auto;" class="mfrViewer">
2+
${body}
3+
</div>
4+
5+
<script src="/static/js/mfr.js"></script>
6+
<script src="/static/js/mfr.child.js"></script>

mfr/extensions/jasp/templates/viewer.mako

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<div style="word-wrap: break-word; overflow: auto" class="mfrViewer">
1+
<div style="word-wrap: break-word; overflow: auto;" class="mfrViewer">
22
${body}
33
</div>
44

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,9 @@ def parse_requirements(requirements):
795795
#'.wmv = mfr.extensions.video:VideoRenderer',
796796
'.webm = mfr.extensions.video:VideoRenderer',
797797

798+
# jamovi
799+
'.omv = mfr.extensions.jamovi:JamoviRenderer',
800+
798801
# JASP
799802
'.jasp = mfr.extensions.jasp:JASPRenderer',
800803

supportedextensions.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ Some file types may not be in the correct list. Please search for the file type
3535
## JASP
3636
* .jasp
3737

38+
## Jamovi
39+
* .omv
40+
3841
## Google Documents
3942
* .gdoc
4043
* .gsheet

tests/extensions/jamovi/__init__.py

Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3.27 KB
Binary file not shown.
3.53 KB
Binary file not shown.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
this really isn't a zip file

tests/extensions/jamovi/files/ok.omv

3.7 KB
Binary file not shown.

0 commit comments

Comments
 (0)