~trb143/openlp/more_media

« back to all changes in this revision

Viewing changes to openlp/plugins/presentations/lib/libreofficeserver.py

  • Committer: Tim Bentley
  • Date: 2019-06-11 18:08:21 UTC
  • mfrom: (2876.1.2 openlp)
  • Revision ID: tim.bentley@gmail.com-20190611180821-m0viu2wi93p2o97k
Head

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# -*- coding: utf-8 -*-
 
2
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
 
3
 
 
4
##########################################################################
 
5
# OpenLP - Open Source Lyrics Projection                                 #
 
6
# ---------------------------------------------------------------------- #
 
7
# Copyright (c) 2008-2019 OpenLP Developers                              #
 
8
# ---------------------------------------------------------------------- #
 
9
# This program is free software: you can redistribute it and/or modify   #
 
10
# it under the terms of the GNU General Public License as published by   #
 
11
# the Free Software Foundation, either version 3 of the License, or      #
 
12
# (at your option) any later version.                                    #
 
13
#                                                                        #
 
14
# This program is distributed in the hope that it will be useful,        #
 
15
# but WITHOUT ANY WARRANTY; without even the implied warranty of         #
 
16
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the          #
 
17
# GNU General Public License for more details.                           #
 
18
#                                                                        #
 
19
# You should have received a copy of the GNU General Public License      #
 
20
# along with this program.  If not, see <https://www.gnu.org/licenses/>. #
 
21
##########################################################################
 
22
"""
 
23
This module runs a Pyro4 server using LibreOffice's version of Python
 
24
 
 
25
Please Note: This intentionally uses os.path over pathlib because we don't know which version of Python is shipped with
 
26
the version of LibreOffice on the user's computer.
 
27
"""
 
28
from subprocess import Popen
 
29
import sys
 
30
import os
 
31
import logging
 
32
import time
 
33
 
 
34
 
 
35
if sys.platform.startswith('darwin'):
 
36
    # Only make the log file on OS X when running as a server
 
37
    logfile = os.path.join(str(os.getenv('HOME')), 'Library', 'Application Support', 'openlp', 'libreofficeserver.log')
 
38
    print('Setting up log file: {logfile}'.format(logfile=logfile))
 
39
    logging.basicConfig(filename=logfile, level=logging.INFO)
 
40
 
 
41
 
 
42
# Add the current directory to sys.path so that we can load the serializers
 
43
sys.path.append(os.path.join(os.path.dirname(__file__)))
 
44
# Add the vendor directory to sys.path so that we can load Pyro4
 
45
sys.path.append(os.path.join(os.path.dirname(__file__), 'vendor'))
 
46
 
 
47
from serializers import register_classes
 
48
from Pyro4 import Daemon, expose
 
49
 
 
50
try:
 
51
    # Wrap these imports in a try so that we can run the tests on macOS
 
52
    import uno
 
53
    from com.sun.star.beans import PropertyValue
 
54
    from com.sun.star.task import ErrorCodeIOException
 
55
except ImportError:
 
56
    # But they need to be defined for mocking
 
57
    uno = None
 
58
    PropertyValue = None
 
59
    ErrorCodeIOException = Exception
 
60
 
 
61
 
 
62
log = logging.getLogger(__name__)
 
63
register_classes()
 
64
 
 
65
 
 
66
class TextType(object):
 
67
    """
 
68
    Type Enumeration for Types of Text to request
 
69
    """
 
70
    Title = 0
 
71
    SlideText = 1
 
72
    Notes = 2
 
73
 
 
74
 
 
75
class LibreOfficeException(Exception):
 
76
    """
 
77
    A specific exception for LO
 
78
    """
 
79
    pass
 
80
 
 
81
 
 
82
@expose
 
83
class LibreOfficeServer(object):
 
84
    """
 
85
    A Pyro4 server which controls LibreOffice
 
86
    """
 
87
    def __init__(self):
 
88
        """
 
89
        Set up the server
 
90
        """
 
91
        self._desktop = None
 
92
        self._control = None
 
93
        self._document = None
 
94
        self._presentation = None
 
95
        self._process = None
 
96
        self._manager = None
 
97
 
 
98
    def _create_property(self, name, value):
 
99
        """
 
100
        Create an OOo style property object which are passed into some Uno methods.
 
101
        """
 
102
        log.debug('create property')
 
103
        property_object = PropertyValue()
 
104
        property_object.Name = name
 
105
        property_object.Value = value
 
106
        return property_object
 
107
 
 
108
    def _get_text_from_page(self, slide_no, text_type=TextType.SlideText):
 
109
        """
 
110
        Return any text extracted from the presentation page.
 
111
 
 
112
        :param slide_no: The slide the notes are required for, starting at 1
 
113
        :param notes: A boolean. If set the method searches the notes of the slide.
 
114
        :param text_type: A TextType. Enumeration of the types of supported text.
 
115
        """
 
116
        text = ''
 
117
        if TextType.Title <= text_type <= TextType.Notes:
 
118
            pages = self._document.getDrawPages()
 
119
            if 0 < slide_no <= pages.getCount():
 
120
                page = pages.getByIndex(slide_no - 1)
 
121
                if text_type == TextType.Notes:
 
122
                    page = page.getNotesPage()
 
123
                for index in range(page.getCount()):
 
124
                    shape = page.getByIndex(index)
 
125
                    shape_type = shape.getShapeType()
 
126
                    if shape.supportsService('com.sun.star.drawing.Text'):
 
127
                        # if they requested title, make sure it is the title
 
128
                        if text_type != TextType.Title or shape_type == 'com.sun.star.presentation.TitleTextShape':
 
129
                            text += shape.getString() + '\n'
 
130
        return text
 
131
 
 
132
    def start_process(self):
 
133
        """
 
134
        Initialise Impress
 
135
        """
 
136
        uno_command = [
 
137
            '/Applications/LibreOffice.app/Contents/MacOS/soffice',
 
138
            '--nologo',
 
139
            '--norestore',
 
140
            '--minimized',
 
141
            '--nodefault',
 
142
            '--nofirststartwizard',
 
143
            '--accept=pipe,name=openlp_maclo;urp;StarOffice.ServiceManager'
 
144
        ]
 
145
        self._process = Popen(uno_command)
 
146
 
 
147
    @property
 
148
    def desktop(self):
 
149
        """
 
150
        Set up an UNO desktop instance
 
151
        """
 
152
        if self._desktop is not None:
 
153
            return self._desktop
 
154
        uno_instance = None
 
155
        context = uno.getComponentContext()
 
156
        resolver = context.ServiceManager.createInstanceWithContext('com.sun.star.bridge.UnoUrlResolver', context)
 
157
        loop = 0
 
158
        while uno_instance is None and loop < 3:
 
159
            try:
 
160
                uno_instance = resolver.resolve('uno:pipe,name=openlp_maclo;urp;StarOffice.ComponentContext')
 
161
            except Exception:
 
162
                log.exception('Unable to find running instance, retrying...')
 
163
                loop += 1
 
164
        try:
 
165
            self._manager = uno_instance.ServiceManager
 
166
            log.debug('get UNO Desktop Openoffice - createInstanceWithContext - Desktop')
 
167
            desktop = self._manager.createInstanceWithContext('com.sun.star.frame.Desktop', uno_instance)
 
168
            if not desktop:
 
169
                raise Exception('Failed to get UNO desktop')
 
170
            self._desktop = desktop
 
171
            return desktop
 
172
        except Exception:
 
173
            log.exception('Failed to get UNO desktop')
 
174
        return None
 
175
 
 
176
    def shutdown(self):
 
177
        """
 
178
        Shut down the server
 
179
        """
 
180
        can_kill = True
 
181
        if hasattr(self, '_docs'):
 
182
            while self._docs:
 
183
                self._docs[0].close_presentation()
 
184
        docs = self.desktop.getComponents()
 
185
        count = 0
 
186
        if docs.hasElements():
 
187
            list_elements = docs.createEnumeration()
 
188
            while list_elements.hasMoreElements():
 
189
                doc = list_elements.nextElement()
 
190
                if doc.getImplementationName() != 'com.sun.star.comp.framework.BackingComp':
 
191
                    count += 1
 
192
        if count > 0:
 
193
            log.debug('LibreOffice not terminated as docs are still open')
 
194
            can_kill = False
 
195
        else:
 
196
            try:
 
197
                self.desktop.terminate()
 
198
                log.debug('LibreOffice killed')
 
199
            except Exception:
 
200
                log.exception('Failed to terminate LibreOffice')
 
201
        if getattr(self, '_process') and can_kill:
 
202
            self._process.kill()
 
203
 
 
204
    def load_presentation(self, file_path, screen_number):
 
205
        """
 
206
        Load a presentation
 
207
        """
 
208
        self._file_path = file_path
 
209
        url = uno.systemPathToFileUrl(file_path)
 
210
        properties = (self._create_property('Hidden', True),)
 
211
        self._document = None
 
212
        loop_count = 0
 
213
        while loop_count < 3:
 
214
            try:
 
215
                self._document = self.desktop.loadComponentFromURL(url, '_blank', 0, properties)
 
216
            except Exception:
 
217
                log.exception('Failed to load presentation {url}'.format(url=url))
 
218
            if self._document:
 
219
                break
 
220
            time.sleep(0.5)
 
221
            loop_count += 1
 
222
        if loop_count == 3:
 
223
            log.error('Looped too many times')
 
224
            return False
 
225
        self._presentation = self._document.getPresentation()
 
226
        self._presentation.Display = screen_number
 
227
        self._control = None
 
228
        return True
 
229
 
 
230
    def extract_thumbnails(self, temp_folder):
 
231
        """
 
232
        Create thumbnails for the presentation
 
233
        """
 
234
        thumbnails = []
 
235
        thumb_dir_url = uno.systemPathToFileUrl(temp_folder)
 
236
        properties = (self._create_property('FilterName', 'impress_png_Export'),)
 
237
        pages = self._document.getDrawPages()
 
238
        if not pages:
 
239
            return []
 
240
        if not os.path.isdir(temp_folder):
 
241
            os.makedirs(temp_folder)
 
242
        for index in range(pages.getCount()):
 
243
            page = pages.getByIndex(index)
 
244
            self._document.getCurrentController().setCurrentPage(page)
 
245
            url_path = '{path}/{name}.png'.format(path=thumb_dir_url, name=str(index + 1))
 
246
            path = os.path.join(temp_folder, str(index + 1) + '.png')
 
247
            try:
 
248
                self._document.storeToURL(url_path, properties)
 
249
                thumbnails.append(path)
 
250
            except ErrorCodeIOException as exception:
 
251
                log.exception('ERROR! ErrorCodeIOException {error:d}'.format(error=exception.ErrCode))
 
252
            except Exception:
 
253
                log.exception('{path} - Unable to store openoffice preview'.format(path=path))
 
254
        return thumbnails
 
255
 
 
256
    def get_titles_and_notes(self):
 
257
        """
 
258
        Extract the titles and the notes from the slides.
 
259
        """
 
260
        titles = []
 
261
        notes = []
 
262
        pages = self._document.getDrawPages()
 
263
        for slide_no in range(1, pages.getCount() + 1):
 
264
            titles.append(self._get_text_from_page(slide_no, TextType.Title).replace('\n', ' ') + '\n')
 
265
            note = self._get_text_from_page(slide_no, TextType.Notes)
 
266
            if len(note) == 0:
 
267
                note = ' '
 
268
            notes.append(note)
 
269
        return titles, notes
 
270
 
 
271
    def close_presentation(self):
 
272
        """
 
273
        Close presentation and clean up objects.
 
274
        """
 
275
        log.debug('close Presentation LibreOffice')
 
276
        if self._document:
 
277
            if self._presentation:
 
278
                try:
 
279
                    self._presentation.end()
 
280
                    self._presentation = None
 
281
                    self._document.dispose()
 
282
                except Exception:
 
283
                    log.exception("Closing presentation failed")
 
284
            self._document = None
 
285
 
 
286
    def is_loaded(self):
 
287
        """
 
288
        Returns true if a presentation is loaded.
 
289
        """
 
290
        log.debug('is loaded LibreOffice')
 
291
        if self._presentation is None or self._document is None:
 
292
            log.debug("is_loaded: no presentation or document")
 
293
            return False
 
294
        try:
 
295
            if self._document.getPresentation() is None:
 
296
                log.debug("getPresentation failed to find a presentation")
 
297
                return False
 
298
        except Exception:
 
299
            log.exception("getPresentation failed to find a presentation")
 
300
            return False
 
301
        return True
 
302
 
 
303
    def is_active(self):
 
304
        """
 
305
        Returns true if a presentation is active and running.
 
306
        """
 
307
        log.debug('is active LibreOffice')
 
308
        if not self.is_loaded():
 
309
            return False
 
310
        return self._control.isRunning() if self._control else False
 
311
 
 
312
    def unblank_screen(self):
 
313
        """
 
314
        Unblanks the screen.
 
315
        """
 
316
        log.debug('unblank screen LibreOffice')
 
317
        return self._control.resume()
 
318
 
 
319
    def blank_screen(self):
 
320
        """
 
321
        Blanks the screen.
 
322
        """
 
323
        log.debug('blank screen LibreOffice')
 
324
        self._control.blankScreen(0)
 
325
 
 
326
    def is_blank(self):
 
327
        """
 
328
        Returns true if screen is blank.
 
329
        """
 
330
        log.debug('is blank LibreOffice')
 
331
        if self._control and self._control.isRunning():
 
332
            return self._control.isPaused()
 
333
        else:
 
334
            return False
 
335
 
 
336
    def stop_presentation(self):
 
337
        """
 
338
        Stop the presentation, remove from screen.
 
339
        """
 
340
        log.debug('stop presentation LibreOffice')
 
341
        self._presentation.end()
 
342
        self._control = None
 
343
 
 
344
    def start_presentation(self):
 
345
        """
 
346
        Start the presentation from the beginning.
 
347
        """
 
348
        log.debug('start presentation LibreOffice')
 
349
        if self._control is None or not self._control.isRunning():
 
350
            window = self._document.getCurrentController().getFrame().getContainerWindow()
 
351
            window.setVisible(True)
 
352
            self._presentation.start()
 
353
            self._control = self._presentation.getController()
 
354
            # start() returns before the Component is ready. Try for 15 seconds.
 
355
            sleep_count = 1
 
356
            while not self._control and sleep_count < 150:
 
357
                time.sleep(0.1)
 
358
                sleep_count += 1
 
359
                self._control = self._presentation.getController()
 
360
            window.setVisible(False)
 
361
        else:
 
362
            self._control.activate()
 
363
            self.goto_slide(1)
 
364
 
 
365
    def get_slide_number(self):
 
366
        """
 
367
        Return the current slide number on the screen, from 1.
 
368
        """
 
369
        return self._control.getCurrentSlideIndex() + 1
 
370
 
 
371
    def get_slide_count(self):
 
372
        """
 
373
        Return the total number of slides.
 
374
        """
 
375
        return self._document.getDrawPages().getCount()
 
376
 
 
377
    def goto_slide(self, slide_no):
 
378
        """
 
379
        Go to a specific slide (from 1).
 
380
 
 
381
        :param slide_no: The slide the text is required for, starting at 1
 
382
        """
 
383
        self._control.gotoSlideIndex(slide_no - 1)
 
384
 
 
385
    def next_step(self):
 
386
        """
 
387
        Triggers the next effect of slide on the running presentation.
 
388
        """
 
389
        is_paused = self._control.isPaused()
 
390
        self._control.gotoNextEffect()
 
391
        time.sleep(0.1)
 
392
        if not is_paused and self._control.isPaused():
 
393
            self._control.gotoPreviousEffect()
 
394
 
 
395
    def previous_step(self):
 
396
        """
 
397
        Triggers the previous slide on the running presentation.
 
398
        """
 
399
        self._control.gotoPreviousEffect()
 
400
 
 
401
    def get_slide_text(self, slide_no):
 
402
        """
 
403
        Returns the text on the slide.
 
404
 
 
405
        :param slide_no: The slide the text is required for, starting at 1
 
406
        """
 
407
        return self._get_text_from_page(slide_no)
 
408
 
 
409
    def get_slide_notes(self, slide_no):
 
410
        """
 
411
        Returns the text in the slide notes.
 
412
 
 
413
        :param slide_no: The slide the notes are required for, starting at 1
 
414
        """
 
415
        return self._get_text_from_page(slide_no, TextType.Notes)
 
416
 
 
417
 
 
418
def main():
 
419
    """
 
420
    The main function which runs the server
 
421
    """
 
422
    daemon = Daemon(host='localhost', port=4310)
 
423
    daemon.register(LibreOfficeServer, 'openlp.libreofficeserver')
 
424
    try:
 
425
        daemon.requestLoop()
 
426
    finally:
 
427
        daemon.close()
 
428
 
 
429
 
 
430
if __name__ == '__main__':
 
431
    main()