Issues (2)

src/darkslide/generator.py (2 issues)

1
# -*- coding: utf-8 -*-
2
import codecs
3
import inspect
4
import os
5
import re
6
import shutil
7
import sys
8
9
import jinja2
10
from six import binary_type
11
from six import string_types
12
from six.moves import configparser
13
14
from . import macro as macro_module
15
from . import __version__
16
from . import utils
17
from .parser import Parser
18
19
BASE_DIR = os.path.dirname(__file__)
20
THEMES_DIR = os.path.join(BASE_DIR, 'themes')
21
VALID_LINENOS = ('no', 'inline', 'table')
22
23
24
class Generator(object):
25
    """The Generator class takes and processes presentation source as a file, a
26
       folder or a configuration file and provides methods to render them as a
27
       presentation.
28
    """
29
    default_macros = (
30
        macro_module.CodeHighlightingMacro,
31
        macro_module.EmbedImagesMacro,
32
        macro_module.FixImagePathsMacro,
33
        macro_module.FxMacro,
34
        macro_module.NotesMacro,
35
        macro_module.QRMacro,
36
        macro_module.FooterMacro,
37
    )
38
39
    def __init__(self, source, **kwargs):
40
        """ Configures this generator. Available ``args`` are:
41
            - ``source``: source file or directory path
42
            Available ``kwargs`` are:
43
            - ``copy_theme``: copy theme directory and files into presentation
44
                              one
45
            - ``destination_file``: path to html destination file
46
            - ``direct``: enables direct rendering presentation to stdout
47
            - ``debug``: enables debug mode
48
            - ``embed``: generates a standalone document, with embedded assets
49
            - ``encoding``: the encoding to use for this presentation
50
            - ``extensions``: Comma separated list of markdown extensions
51
            - ``logger``: a logger lambda to use for logging
52
            - ``maxtoclevel``: the maximum level to include in toc
53
            - ``presenter_notes``: enable presenter notes
54
            - ``relative``: enable relative asset urls
55
            - ``theme``: path to the theme to use for this presentation
56
            - ``verbose``: enables verbose output
57
        """
58
        self.user_css = []
59
        self.user_js = []
60
        self.copy_theme = kwargs.get('copy_theme', False)
61
        self.debug = kwargs.get('debug', False)
62
        self.destination_file = kwargs.get('destination_file',
63
                                           'presentation.html')
64
        self.direct = kwargs.get('direct', False)
65
        self.embed = kwargs.get('embed', False)
66
        self.encoding = kwargs.get('encoding', 'utf8')
67
        self.extensions = kwargs.get('extensions', None)
68
        self.logger = kwargs.get('logger', None)
69
        self.maxtoclevel = kwargs.get('maxtoclevel', 2)
70
        self.presenter_notes = kwargs.get('presenter_notes', True)
71
        self.relative = kwargs.get('relative', False)
72
        self.theme = kwargs.get('theme', 'default')
73
        self.verbose = kwargs.get('verbose', False)
74
        self.linenos = self.linenos_check(kwargs.get('linenos'))
75
        self.watch = kwargs.get('watch', False)
76
        self.num_slides = 0
77
        self.__toc = []
78
79
        if self.direct:
80
            # Only output html in direct output mode, not log messages
81
            self.verbose = False
82
83
        if not source or not os.path.exists(source):
84
            raise IOError("Source file/directory %s does not exist" % source)
85
86
        if source.endswith('.cfg'):
87
            self.work_dir = os.path.dirname(source)
88
            config = self.parse_config(source)
89
            self.source = config.get('source')
90
            if not self.source:
91
                raise IOError('unable to fetch a valid source from config')
92
            source_abspath = os.path.abspath(self.source[0])
93
            self.destination_file = config.get('destination', self.destination_file)
94
            self.embed = config.get('embed', self.embed)
95
            self.relative = config.get('relative', self.relative)
96
            self.copy_theme = config.get('copy_theme', self.copy_theme)
97
            self.extensions = config.get('extensions', self.extensions)
98
            self.maxtoclevel = config.get('max-toc-level', self.maxtoclevel)
99
            self.theme = config.get('theme', self.theme)
100
            self.destination_dir = os.path.dirname(self.destination_file)
101
            self.add_user_css(config.get('css', []))
102
            self.add_user_js(config.get('js', []))
103
            self.linenos = self.linenos_check(config.get('linenos', self.linenos))
104
        else:
105
            self.source = source
106
            self.work_dir = '.'
107
            self.destination_dir = os.path.dirname(self.destination_file)
108
109
            source_abspath = os.path.abspath(source)
110
111
        if not os.path.isdir(source_abspath):
112
            source_abspath = os.path.dirname(source_abspath)
113
114
        self.watch_dir = source_abspath
115
116
        if os.path.exists(self.destination_file) and not os.path.isfile(self.destination_file):
117
            raise IOError("Destination %s exists and is not a file" % self.destination_file)
118
119
        self.theme_dir = self.find_theme_dir(self.theme, self.copy_theme)
120
        self.template_file = self.get_template_file()
121
122
        # macros registering
123
        self.macros = []
124
        self.register_macro(*self.default_macros)
125
126
    def add_user_css(self, css_list):
127
        """ Adds supplementary user css files to the presentation. The
128
            ``css_list`` arg can be either a ``list`` or a string.
129
        """
130
        if isinstance(css_list, string_types):
131
            css_list = [css_list]
132
133
        for css_path in css_list:
134
            if css_path and css_path not in self.user_css:
135
                if not os.path.exists(css_path):
136
                    raise IOError('%s user css file not found' % (css_path,))
137
                with codecs.open(css_path, encoding=self.encoding) as css_file:
138 View Code Duplication
                    self.user_css.append({
0 ignored issues
show
This code seems to be duplicated in your project.
Loading history...
139
                        'path_url': utils.get_path_url(css_path,
140
                                                       self.relative and self.destination_dir),
141
                        'contents': css_file.read(),
142
                    })
143
144
    def add_user_js(self, js_list):
145
        """ Adds supplementary user javascript files to the presentation. The
146
            ``js_list`` arg can be either a ``list`` or a string.
147
        """
148
        if isinstance(js_list, string_types):
149
            js_list = [js_list]
150
        for js_path in js_list:
151
            if js_path and js_path not in self.user_js:
152
                if js_path.startswith("http:"):
153
                    self.user_js.append({
154
                        'path_url': js_path,
155
                        'contents': '',
156 View Code Duplication
                    })
0 ignored issues
show
This code seems to be duplicated in your project.
Loading history...
157
                elif not os.path.exists(js_path):
158
                    raise IOError('%s user js file not found' % (js_path,))
159
                else:
160
                    with codecs.open(js_path,
161
                                     encoding=self.encoding) as js_file:
162
                        self.user_js.append({
163
                            'path_url': utils.get_path_url(js_path,
164
                                                           self.relative and self.destination_dir),
165
                            'contents': js_file.read(),
166
                        })
167
168
    def add_toc_entry(self, title, level, slide_number):
169
        """ Adds a new entry to current presentation Table of Contents.
170
        """
171
        self.__toc.append({'title': title, 'number': slide_number,
172
                           'level': level})
173
174
    @property
175
    def toc(self):
176
        """ Smart getter for Table of Content list.
177
        """
178
        toc = []
179
        stack = [toc]
180
        for entry in self.__toc:
181
            entry['sub'] = []
182
            while entry['level'] < len(stack):
183
                stack.pop()
184
            while entry['level'] > len(stack):
185
                stack.append(stack[-1][-1]['sub'])
186
            stack[-1].append(entry)
187
        return toc
188
189
    def execute(self):
190
        """ Execute this generator regarding its current configuration.
191
        """
192
        if self.direct:
193
            out = getattr(sys.stdout, 'buffer', sys.stdout)
194
            out.write(self.render().encode(self.encoding))
195
        else:
196
            self.write_and_log()
197
198
            if self.watch:
199
                from .watcher import watch
200
201
                self.log(u"Watching %s\n" % self.watch_dir)
202
203
                watch(self.watch_dir, self.write_and_log)
204
205
    def write_and_log(self):
206
        self.watch_files = []
207
        self.num_slides = 0
208
        self.__toc = []
209
        self.write()
210
        self.log(u"Generated file: %s" % self.destination_file)
211
212
    def get_template_file(self):
213
        """ Retrieves Jinja2 template file path.
214
        """
215
        if os.path.exists(os.path.join(self.theme_dir, 'base.html')):
216
            return os.path.join(self.theme_dir, 'base.html')
217
        default_dir = os.path.join(THEMES_DIR, 'default')
218
        if not os.path.exists(os.path.join(default_dir, 'base.html')):
219
            raise IOError("Cannot find base.html in default theme")
220
        return os.path.join(default_dir, 'base.html')
221
222
    def fetch_contents(self, source, work_dir):
223
        """ Recursively fetches Markdown contents from a single file or
224
            directory containing itself Markdown/RST files.
225
        """
226
        slides = []
227
228
        if type(source) is list:
229
            for entry in source:
230
                slides.extend(self.fetch_contents(entry, work_dir))
231
        else:
232
            source = os.path.normpath(os.path.join(work_dir, source))
233
            if os.path.isdir(source):
234
                self.log(u"Entering %r" % source)
235
                entries = os.listdir(source)
236
                entries.sort()
237
                for entry in entries:
238
                    slides.extend(self.fetch_contents(entry, source))
239
            else:
240
                try:
241
                    parser = Parser(os.path.splitext(source)[1], self.encoding, self.extensions)
242
                except NotImplementedError as exc:
243
                    self.log(u"Failed   %r: %r" % (source, exc))
244
                    return slides
245
246
                self.log(u"Adding   %r (%s)" % (source, parser.format))
247
248
                try:
249
                    with codecs.open(source, encoding=self.encoding) as file:
250
                        file_contents = file.read()
251
                except UnicodeDecodeError:
252
                    self.log(u"Unable to decode source %r: skipping" % source,
253
                             'warning')
254
                else:
255
                    inner_slides = re.split(r'<hr.+>', parser.parse(file_contents))
256
                    for inner_slide in inner_slides:
257
                        slides.append(self.get_slide_vars(inner_slide, source))
258
259
        if not slides:
260
            self.log(u"Exiting  %r: no contents found" % source, 'notice')
261
262
        return slides
263
264
    def find_theme_dir(self, theme, copy_theme=False):
265
        """ Finds them dir path from its name.
266
        """
267
        if os.path.exists(theme):
268
            self.theme_dir = theme
269
        elif os.path.exists(os.path.join(THEMES_DIR, theme)):
270
            self.theme_dir = os.path.join(THEMES_DIR, theme)
271
        else:
272
            raise IOError("Theme %s not found or invalid" % theme)
273
        target_theme_dir = os.path.join(os.getcwd(), 'theme')
274
        if copy_theme or os.path.exists(target_theme_dir):
275
            self.log(u'Copying %s theme directory to %s'
276
                     % (theme, target_theme_dir))
277
            if not os.path.exists(target_theme_dir):
278
                try:
279
                    shutil.copytree(self.theme_dir, target_theme_dir)
280
                except Exception as e:
281
                    self.log(u"Skipped copy of theme folder: %s" % e)
282
                    pass
283
            self.theme_dir = target_theme_dir
284
        return self.theme_dir
285
286
    def get_css(self):
287
        """ Fetches and returns stylesheet file path or contents, for both
288
            print and screen contexts, depending if we want a standalone
289
            presentation or not.
290
        """
291
        css = {}
292
293
        base_css = os.path.join(self.theme_dir, 'css', 'base.css')
294
        if not os.path.exists(base_css):
295
            base_css = os.path.join(THEMES_DIR, 'default', 'css', 'base.css')
296
            if not os.path.exists(base_css):
297
                raise IOError(u"Cannot find base.css in default theme")
298
        with codecs.open(base_css, encoding=self.encoding) as css_file:
299
            css['base'] = {
300
                'path_url': utils.get_path_url(base_css, self.relative and self.destination_dir),
301
                'contents': css_file.read(),
302
            }
303
304
        print_css = os.path.join(self.theme_dir, 'css', 'print.css')
305
        if not os.path.exists(print_css):
306
            print_css = os.path.join(THEMES_DIR, 'default', 'css', 'print.css')
307
            if not os.path.exists(print_css):
308
                raise IOError(u"Cannot find print.css in default theme")
309
        with codecs.open(print_css, encoding=self.encoding) as css_file:
310
            css['print'] = {
311
                'path_url': utils.get_path_url(print_css, self.relative and self.destination_dir),
312
                'contents': css_file.read(),
313
            }
314
315
        screen_css = os.path.join(self.theme_dir, 'css', 'screen.css')
316
        if not os.path.exists(screen_css):
317
            screen_css = os.path.join(THEMES_DIR, 'default', 'css', 'screen.css')
318
            if not os.path.exists(screen_css):
319
                raise IOError(u"Cannot find screen.css in default theme")
320
        with codecs.open(screen_css, encoding=self.encoding) as css_file:
321
            css['screen'] = {
322
                'path_url': utils.get_path_url(screen_css, self.relative and self.destination_dir),
323
                'contents': css_file.read(),
324
            }
325
326
        theme_css = os.path.join(self.theme_dir, 'css', 'theme.css')
327
        if not os.path.exists(theme_css):
328
            theme_css = os.path.join(THEMES_DIR, 'default', 'css', 'theme.css')
329
            if not os.path.exists(theme_css):
330
                raise IOError(u"Cannot find theme.css in default theme")
331
        with codecs.open(theme_css, encoding=self.encoding) as css_file:
332
            css['theme'] = {
333
                'path_url': utils.get_path_url(theme_css, self.relative and self.destination_dir),
334
                'contents': css_file.read(),
335
            }
336
337
        return css
338
339
    def get_js(self):
340
        """ Fetches and returns javascript file path or contents, depending if
341
            we want a standalone presentation or not.
342
        """
343
        js_file = os.path.join(self.theme_dir, 'js', 'slides.js')
344
345
        if not os.path.exists(js_file):
346
            js_file = os.path.join(THEMES_DIR, 'default', 'js', 'slides.js')
347
348
            if not os.path.exists(js_file):
349
                raise IOError(u"Cannot find slides.js in default theme")
350
        with codecs.open(js_file, encoding=self.encoding) as js_file_obj:
351
            return {
352
                'path_url': utils.get_path_url(js_file, self.relative and self.destination_dir),
353
                'contents': js_file_obj.read(),
354
            }
355
356
    def get_slide_vars(self, slide_src, source,
357
                       _presenter_notes_re=re.compile(r'<h\d[^>]*>presenter notes</h\d>',
358
                                                      re.DOTALL | re.UNICODE | re.IGNORECASE),
359
                       _slide_title_re=re.compile(r'(<h(\d+?).*?>(.+?)</h\d>)\s?(.+)?', re.DOTALL | re.UNICODE)):
360
        """ Computes a single slide template vars from its html source code.
361
            Also extracts slide information for the table of contents.
362
        """
363
        presenter_notes = None
364
365
        find = _presenter_notes_re.search(slide_src)
366
367
        if find:
368
            if self.presenter_notes:
369
                presenter_notes = slide_src[find.end():].strip()
370
371
            slide_src = slide_src[:find.start()]
372
373
        find = _slide_title_re.search(slide_src)
374
375
        if not find:
376
            header = level = title = None
377
            content = slide_src.strip()
378
        else:
379
            header = find.group(1)
380
            level = int(find.group(2))
381
            title = find.group(3)
382
            content = find.group(4).strip() if find.group(4) else find.group(4)
383
384
        slide_classes = []
385
        context = {}
386
387
        if header:
388
            header, _ = self.process_macros(header, source, context)
389
390
        if content:
391
            content, slide_classes = self.process_macros(content, source, context)
392
393
        source_dict = {}
394
395
        if source:
396
            source_dict = {
397
                'rel_path': source.decode(sys.getfilesystemencoding(), 'ignore') if isinstance(source,
398
                                                                                               binary_type) else source,
399
                'abs_path': os.path.abspath(source)
400
            }
401
402
        if header or content:
403
            context.update(
404
                content=content,
405
                classes=slide_classes,
406
                header=header,
407
                level=level,
408
                presenter_notes=presenter_notes,
409
                source=source_dict,
410
                title=title,
411
            )
412
            return context
413
414
    def get_template_vars(self, slides):
415
        """ Computes template vars from slides html source code.
416
        """
417
        try:
418
            head_title = slides[0]['title']
419
        except (IndexError, TypeError):
420
            head_title = "Untitled Presentation"
421
422
        for slide_index, slide_vars in enumerate(slides):
423
            if not slide_vars:
424
                continue
425
            self.num_slides += 1
426
            slide_number = slide_vars['number'] = self.num_slides
427
            if slide_vars['level'] and slide_vars['level'] <= self.maxtoclevel:
428
                # only show slides that have a title and lever is not too deep
429
                self.add_toc_entry(slide_vars['title'], slide_vars['level'], slide_number)
430
431
        return {'head_title': head_title, 'num_slides': str(self.num_slides),
432
                'slides': slides, 'toc': self.toc, 'embed': self.embed,
433
                'css': self.get_css(), 'js': self.get_js(),
434
                'user_css': self.user_css, 'user_js': self.user_js,
435
                'version': __version__}
436
437
    def linenos_check(self, value):
438
        """ Checks and returns a valid value for the ``linenos`` option.
439
        """
440
        return value if value in VALID_LINENOS else 'inline'
441
442
    def log(self, message, type='notice'):
443
        """ Logs a message (eventually, override to do something more clever).
444
        """
445
        if self.logger and not callable(self.logger):
446
            raise ValueError(u"Invalid logger set, must be a callable")
447
        if self.verbose and self.logger:
448
            self.logger(message, type)
449
450
    def parse_config(self, config_source):
451
        """ Parses a landslide configuration file and returns a normalized
452
            python dict.
453
        """
454
        self.log(u"Config   %s" % config_source)
455
        try:
456
            raw_config = configparser.RawConfigParser()
457
            raw_config.read(config_source)
458
        except Exception as e:
459
            raise RuntimeError(u"Invalid configuration file: %s" % e)
460
        section_name = 'landslide' if raw_config.has_section('landslide') else 'darkslide'
461
        config = {
462
            'source': raw_config.get(section_name, 'source').replace('\r', '').split('\n')
463
        }
464
        if raw_config.has_option(section_name, 'theme'):
465
            config['theme'] = raw_config.get(section_name, 'theme')
466
            self.log(u"Using    configured theme %s" % config['theme'])
467
        if raw_config.has_option(section_name, 'destination'):
468
            config['destination'] = raw_config.get(section_name, 'destination')
469
        if raw_config.has_option(section_name, 'linenos'):
470
            config['linenos'] = raw_config.get(section_name, 'linenos')
471
        if raw_config.has_option(section_name, 'max-toc-level'):
472
            config['max-toc-level'] = int(raw_config.get(section_name, 'max-toc-level'))
473
        for boolopt in ('embed', 'relative', 'copy_theme'):
474
            if raw_config.has_option(section_name, boolopt):
475
                config[boolopt] = raw_config.getboolean(section_name, boolopt)
476
        if raw_config.has_option(section_name, 'extensions'):
477
            config['extensions'] = ",".join(raw_config.get(section_name, 'extensions').replace('\r', '').split('\n'))
478
        if raw_config.has_option(section_name, 'css'):
479
            config['css'] = raw_config.get(section_name, 'css').replace('\r', '').split('\n')
480
        if raw_config.has_option(section_name, 'js'):
481
            config['js'] = raw_config.get(section_name, 'js').replace('\r', '').split('\n')
482
        return config
483
484
    def process_macros(self, content, source, context):
485
        """ Processed all macros.
486
        """
487
        classes = []
488
        for macro in self.macros:
489
            content, add_classes = macro.process(content, source, context)
490
            if add_classes:
491
                classes += add_classes
492
        return content, classes
493
494
    def register_macro(self, *macros):
495
        """ Registers macro classes passed a method arguments.
496
        """
497
        macro_options = {'relative': self.relative, 'linenos': self.linenos, 'destination_dir': self.destination_dir}
498
        for m in macros:
499
            if inspect.isclass(m) and issubclass(m, macro_module.Macro):
500
                self.macros.append(m(logger=self.logger, embed=self.embed, options=macro_options))
501
            else:
502
                raise TypeError("Couldn't register macro; a macro must inherit"
503
                                " from macro.Macro")
504
505
    def render(self):
506
        """ Returns generated html code.
507
        """
508
        with codecs.open(self.template_file, encoding=self.encoding) as template_src:
509
            template = jinja2.Template(template_src.read())
510
        slides = self.fetch_contents(self.source, self.work_dir)
511
        context = self.get_template_vars(slides)
512
513
        html = template.render(context)
514
515
        if self.embed:
516
            images = re.findall(r'url\(["\']?(.*?\.(?:jpe?g|gif|png|svg)[\'"]?)\)', html, re.DOTALL | re.UNICODE)
517
518
            for img_url in images:
519
                img_url = img_url.replace('"', '').replace("'", '')
520
                if self.theme_dir:
521
                    source = os.path.join(self.theme_dir, 'css')
522
                else:
523
                    source = os.path.join(THEMES_DIR, self.theme, 'css')
524
525
                encoded_url = utils.encode_image_from_url(img_url, source)
526
                if encoded_url:
527
                    html = html.replace(img_url, encoded_url, 1)
528
                    self.log("Embedded theme image %s from theme directory %s" % (img_url, source))
529
                else:
530
                    # Missing file in theme directory. Try user_css folders
531
                    found = False
532
                    for css_entry in context['user_css']:
533
                        directory = os.path.dirname(css_entry['path_url'])
534
                        if not directory:
535
                            directory = "."
536
537
                        encoded_url = utils.encode_image_from_url(img_url, directory)
538
539
                        if encoded_url:
540
                            found = True
541
                            html = html.replace(img_url, encoded_url, 1)
542
                            self.log("Embedded theme image %s from directory %s" % (img_url, directory))
543
544
                    if not found:
545
                        # Missing image file, etc...
546
                        self.log(u"Failed to embed theme image %s" % img_url)
547
548
        return html
549
550
    def write(self):
551
        """ Writes generated presentation code into the destination file.
552
        """
553
        html = self.render()
554
        dirname = os.path.dirname(self.destination_file)
555
        if dirname and not os.path.exists(dirname):
556
            os.makedirs(dirname)
557
        with codecs.open(self.destination_file, 'w',
558
                         encoding='utf_8') as outfile:
559
            outfile.write(html)
560