Take a stab at writing a presentation controller to control LibreOffice on Mac via Pyro

This commit is contained in:
Raoul Snyman 2016-11-01 20:01:12 +02:00
parent faeef88315
commit c5709b9778
2 changed files with 663 additions and 0 deletions

View File

@ -0,0 +1,361 @@
"""
This module runs a Pyro4 server using LibreOffice's version of Python
"""
from subprocess import Popen
import sys
import os
import logging
# Add the vendor directory to sys.path so that we can load Pyro4
sys.path.append(os.path.join(os.path.dirname(__file__), 'vendor'))
from Pyro4 import Daemon, expose, locateNS
from com.sun.star.beans import PropertyValue
from com.sun.star.task import ErrorCodeIOException
import uno
logging.basicConfig(filename=os.path.dirname(__file__) + '/libreofficeserver.log', level=logging.INFO)
log = logging.getLogger(__name__)
class TextType(object):
"""
Type Enumeration for Types of Text to request
"""
Title = 0
SlideText = 1
Notes = 2
@expose
class LibreOfficeServer(object):
"""
A Pyro4 server which controls LibreOffice
"""
def __init__(self):
"""
Set up the LibreOffice server
"""
self._init_impress()
def _init_impress(self)
"""
Initialise Impress
"""
uno_command = [
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
'--nologo',
'--norestore',
'--minimized',
'--nodefault',
'--nofirststartwizard',
'"--accept=pipe,name=openlp_pipe;urp;"'
]
self._process = Popen(uno_command)
uno_instance = None
context = uno.getComponentContext()
resolver = context.ServiceManager.createInstanceWithContext('com.sun.star.bridge.UnoUrlResolver', context)
while uno_instance is None and loop < 3:
try:
uno_instance = resolver.resolve('uno:pipe,name=openlp_pipe;urp;StarOffice.ComponentContext')
except:
log.warning('Unable to find running instance ')
loop += 1
try:
self._manager = uno_instance.ServiceManager
log.debug('get UNO Desktop Openoffice - createInstanceWithContext - Desktop')
self._desktop = self.manager.createInstanceWithContext("com.sun.star.frame.Desktop", uno_instance)
except:
log.warning('Failed to get UNO desktop')
def _create_property(self, name, value):
"""
Create an OOo style property object which are passed into some Uno methods.
"""
log.debug('create property')
property_object = PropertyValue()
property_object.Name = name
property_object.Value = value
return property_object
def _get_text_from_page(self, slide_no, text_type=TextType.SlideText):
"""
Return any text extracted from the presentation page.
:param slide_no: The slide the notes are required for, starting at 1
:param notes: A boolean. If set the method searches the notes of the slide.
:param text_type: A TextType. Enumeration of the types of supported text.
"""
text = ''
if TextType.Title <= text_type <= TextType.Notes:
pages = self._document.getDrawPages()
if 0 < slide_no <= pages.getCount():
page = pages.getByIndex(slide_no - 1)
if text_type == TextType.Notes:
page = page.getNotesPage()
for index in range(page.getCount()):
shape = page.getByIndex(index)
shape_type = shape.getShapeType()
if shape.supportsService("com.sun.star.drawing.Text"):
# if they requested title, make sure it is the title
if text_type != TextType.Title or shape_type == "com.sun.star.presentation.TitleTextShape":
text += shape.getString() + '\n'
return text
def has_desktop(self):
"""
Say if we have a desktop object
"""
return self._desktop is not None
def shutdown(self):
"""
Shut down the server
"""
while self._docs:
self._docs[0].close_presentation()
if not self._desktop:
return
docs = self._desktop.getComponents()
count = 0
if docs.hasElements():
list_elements = docs.createEnumeration()
while list_elements.hasMoreElements():
doc = list_elements.nextElement()
if doc.getImplementationName() != 'com.sun.star.comp.framework.BackingComp':
count += 1
if count > 0:
log.debug('LibreOffice not terminated as docs are still open')
else:
try:
self._desktop.terminate()
log.debug('LibreOffice killed')
except:
log.warning('Failed to terminate LibreOffice')
def load_presentation(self, file_path, screen_number):
"""
Load a presentation
"""
self._file_path = file_path
url = uno.systemPathToFileUrl(file_path)
properties = (self._create_property('Hidden', True),)
try:
self._document = self._desktop.loadComponentFromURL(url, '_blank', 0, properties)
except:
log.warning('Failed to load presentation {url}'.format(url=url))
return False
self._presentation = self._document.getPresentation()
self._presentation.Display = screen_number
self._control = None
self.create_thumbnails()
self.create_titles_and_notes()
return True
def create_thumbnails(self, temp_folder):
"""
Create thumbnails for the presentation
"""
thumb_dir_url = uno.systemPathToFileUrl(temp_folder)
properties = (self._create_property('FilterName', 'impress_png_Export'),)
doc = self.document
pages = doc.getDrawPages()
if not pages:
return
if not os.path.isdir(temp_folder):
os.makedirs(temp_folder)
for index in range(pages.getCount()):
page = pages.getByIndex(index)
doc.getCurrentController().setCurrentPage(page)
url_path = '{path}/{name}.png'.format(path=thumb_dir_url, name=str(index + 1))
path = os.path.join(self.get_temp_folder(), str(index + 1) + '.png')
try:
doc.storeToURL(url_path, properties)
self.convert_thumbnail(path, index + 1)
delete_file(path)
except ErrorCodeIOException as exception:
log.exception('ERROR! ErrorCodeIOException {error:d}'.format(error=exception.ErrCode))
except:
log.exception('{path} - Unable to store openoffice preview'.format(path=path))
def get_title_and_notes(self):
"""
Writes the list of titles (one per slide) to 'titles.txt' and the notes to 'slideNotes[x].txt'
in the thumbnails directory
"""
titles = []
notes = []
pages = self._document.getDrawPages()
for slide_no in range(1, pages.getCount() + 1):
titles.append(self._get_text_from_page(slide_no, TextType.Title).replace('\n', ' ') + '\n')
note = self._get_text_from_page(slide_no, TextType.Notes)
if len(note) == 0:
note = ' '
notes.append(note)
return titles, notes
def close_presentation(self):
"""
Close presentation and clean up objects. Triggered by new object being added to SlideController or OpenLP being
shutdown.
"""
log.debug('close Presentation LibreOffice')
if self._document:
if self._presentation:
try:
self._presentation.end()
self._presentation = None
self._document.dispose()
except:
log.warning("Closing presentation failed")
self._document = None
def is_loaded(self):
"""
Returns true if a presentation is loaded.
"""
log.debug('is loaded LibreOffice')
if self._presentation is None or self._document is None:
log.debug("is_loaded: no presentation or document")
return False
try:
if self._document.getPresentation() is None:
log.debug("getPresentation failed to find a presentation")
return False
except:
log.warning("getPresentation failed to find a presentation")
return False
return True
def is_active(self):
"""
Returns true if a presentation is active and running.
"""
log.debug('is active LibreOffice')
if not self.is_loaded():
return False
return self._control.isRunning() if self._control else False
def unblank_screen(self):
"""
Unblanks the screen.
"""
log.debug('unblank screen LibreOffice')
return self._control.resume()
def blank_screen(self):
"""
Blanks the screen.
"""
log.debug('blank screen LibreOffice')
self._control.blankScreen(0)
def is_blank(self):
"""
Returns true if screen is blank.
"""
log.debug('is blank LibreOffice')
if self._control and self._control.isRunning():
return self._control.isPaused()
else:
return False
def stop_presentation(self):
"""
Stop the presentation, remove from screen.
"""
log.debug('stop presentation LibreOffice')
self._presentation.end()
self._control = None
def start_presentation(self):
"""
Start the presentation from the beginning.
"""
log.debug('start presentation LibreOffice')
if self._control is None or not self._control.isRunning():
window = self._document.getCurrentController().getFrame().getContainerWindow()
window.setVisible(True)
self._presentation.start()
self._control = self._presentation.getController()
# start() returns before the Component is ready. Try for 15 seconds.
sleep_count = 1
while not self._control and sleep_count < 150:
time.sleep(0.1)
sleep_count += 1
self._control = self._presentation.getController()
window.setVisible(False)
else:
self._control.activate()
self.goto_slide(1)
def get_slide_number(self):
"""
Return the current slide number on the screen, from 1.
"""
return self._control.getCurrentSlideIndex() + 1
def get_slide_count(self):
"""
Return the total number of slides.
"""
return self._document.getDrawPages().getCount()
def goto_slide(self, slide_no):
"""
Go to a specific slide (from 1).
:param slide_no: The slide the text is required for, starting at 1
"""
self._control.gotoSlideIndex(slide_no - 1)
def next_step(self):
"""
Triggers the next effect of slide on the running presentation.
"""
is_paused = self._control.isPaused()
self._control.gotoNextEffect()
time.sleep(0.1)
if not is_paused and self._control.isPaused():
self._control.gotoPreviousEffect()
def previous_step(self):
"""
Triggers the previous slide on the running presentation.
"""
self._control.gotoPreviousEffect()
def get_slide_text(self, slide_no):
"""
Returns the text on the slide.
:param slide_no: The slide the text is required for, starting at 1
"""
return self._get_text_from_page(slide_no)
def get_slide_notes(self, slide_no):
"""
Returns the text in the slide notes.
:param slide_no: The slide the notes are required for, starting at 1
"""
return self._get_text_from_page(slide_no, TextType.Notes)
def main():
"""
The main function which runs the server
"""
daemon = Daemon()
ns = locateNS()
uri = daemon.register(LibreOfficeServer)
ns.register('openlp.libreofficeserver', uri)
try:
daemon.requestLoop()
finally:
daemon.close()
if __name__ == '__main__':
main()

View File

@ -0,0 +1,302 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
###############################################################################
# OpenLP - Open Source Lyrics Projection #
# --------------------------------------------------------------------------- #
# Copyright (c) 2008-2016 OpenLP Developers #
# --------------------------------------------------------------------------- #
# 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; version 2 of the License. #
# #
# 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 #
###############################################################################
# OOo API documentation:
# http://api.openoffice.org/docs/common/ref/com/sun/star/presentation/XSlideShowController.html
# http://wiki.services.openoffice.org/wiki/Documentation/DevGuide/ProUNO/Basic
# /Getting_Information_about_UNO_Objects#Inspecting_interfaces_during_debugging
# http://docs.go-oo.org/sd/html/classsd_1_1SlideShow.html
# http://www.oooforum.org/forum/viewtopic.phtml?t=5252
# http://wiki.services.openoffice.org/wiki/Documentation/DevGuide/Working_with_Presentations
# http://mail.python.org/pipermail/python-win32/2008-January/006676.html
# http://www.linuxjournal.com/content/starting-stopping-and-connecting-openoffice-python
# http://nxsy.org/comparing-documents-with-openoffice-and-python
import logging
import os
import time
from subprocess import Popen
from multiprocessing import Process
from openlp.core.common import is_mac, Registry, delete_file
if is_mac() and os.path.exists('/Applications/LibreOffice.app'):
macuno_available = True
else:
macuno_available = False
from PyQt5 import QtCore
from Pyro4 import Proxy
from Pyro4.naming import NameServerDaemon
from openlp.core.lib import ScreenList
from .presentationcontroller import PresentationController, PresentationDocument, TextType
log = logging.getLogger(__name__)
def run_nameserver():
"""
Run a Pyro4 nameserver
"""
ns_daemon = NameServerDaemon()
ns_daemon._pyroHmacKey = 'openlp-libreoffice'
try:
ns_daemon.requestLoop()
finally:
ns_daemon.close()
class MacLOController(PresentationController):
"""
Class to control interactions with MacLO presentations on Mac OS X via Pyro4. It starts the Pyro4 nameserver,
starts the LibreOfficeServer, and then controls MacLO via Pyro4.
"""
log.info('MacLOController loaded')
def __init__(self, plugin):
"""
Initialise the class
"""
log.debug('Initialising')
super(MacLOController, self).__init__(plugin, 'MacLO', MacLODocument)
self.supports = ['odp']
self.also_supports = ['ppt', 'pps', 'pptx', 'ppsx', 'pptm']
self.server_process = None
self.nameserver_process = None
self.client = None
self._start_nameserver()
self._start_server()
self._setup_client()
def _start_nameserver(self):
"""
Start the Pyro4 nameserver
"""
self.nameserver_process = Process(run_nameserver)
self.nameserver_process.start()
def _start_server(self):
"""
Start a LibreOfficeServer
"""
libreoffice_python = '/Applications/LibreOffice.app/Contents/Resources/python'
libreoffice_server = os.path.join(os.path.dirname(__file__), 'libreofficeserver.py')
self.server_process = Popen([libreoffice_python, libreoffice_server])
def _setup_client(self):
"""
Set up a Pyro4 client so that we can talk to the LibreOfficeServer
"""
self.client = Proxy('PYRONAME:openlp.libreofficeserver')
def check_available(self):
"""
MacLO is able to run on this machine.
"""
log.debug('check_available')
return macuno_available
def start_process(self):
"""
Loads a running version of LibreOffice in the background. It is not displayed to the user but is available to the
UNO interface when required.
"""
log.debug('start process Openoffice')
if is_win():
self.manager = self.get_com_servicemanager()
self.manager._FlagAsMethod('Bridge_GetStruct')
self.manager._FlagAsMethod('Bridge_GetValueObject')
else:
# -headless
cmd = get_uno_command()
self.process = QtCore.QProcess()
self.process.startDetached(cmd)
def kill(self):
"""
Called at system exit to clean up any running presentations.
"""
log.debug('Kill LibreOffice')
self.client.shutdown()
self.server_process.terminate()
self.nameserver_process.terminate()
class MacLODocument(PresentationDocument):
"""
Class which holds information and controls a single presentation.
"""
def __init__(self, controller, presentation):
"""
Constructor, store information about the file and initialise.
"""
log.debug('Init Presentation LibreOffice')
super(MacLODocument, self).__init__(controller, presentation)
self.client = controller.client
self.document = None
self.presentation = None
self.control = None
def load_presentation(self):
"""
Called when a presentation is added to the SlideController. It builds the environment, starts communcations with
the background LibreOffice task started earlier. If LibreOffice is not present is is started. Once the environment
is available the presentation is loaded and started.
"""
log.debug('Load Presentation LibreOffice')
if not self.client.has_desktop():
return False
if not self.client.load_presentation(self.file_path, ScreenList().current['number'] + 1):
return False
self.create_thumbnails()
self.client.create_titles_and_notes()
return True
def create_thumbnails(self):
"""
Create thumbnail images for presentation.
"""
log.debug('create thumbnails LibreOffice')
if self.check_thumbnails():
return
self.client.create_thumbnails(self.get_temp_folder())
def create_titles_and_notes(self):
"""
Writes the list of titles (one per slide) to 'titles.txt' and the notes to 'slideNotes[x].txt'
in the thumbnails directory
"""
titles, notes = self.client.get_titles_and_notes()
self.save_titles_and_notes(titles, notes)
def close_presentation(self):
"""
Close presentation and clean up objects. Triggered by new object being added to SlideController or OpenLP being
shutdown.
"""
log.debug('close Presentation LibreOffice')
self.client.close_presentation()
self.controller.remove_doc(self)
def is_loaded(self):
"""
Returns true if a presentation is loaded.
"""
log.debug('is loaded LibreOffice')
return self.client.is_loaded()
def is_active(self):
"""
Returns true if a presentation is active and running.
"""
log.debug('is active LibreOffice')
return self.client.is_active()
def unblank_screen(self):
"""
Unblanks the screen.
"""
log.debug('unblank screen LibreOffice')
return self.client.unblank_screen()
def blank_screen(self):
"""
Blanks the screen.
"""
log.debug('blank screen LibreOffice')
self.client.blank_screen()
def is_blank(self):
"""
Returns true if screen is blank.
"""
log.debug('is blank LibreOffice')
return self.client.is_blank()
def stop_presentation(self):
"""
Stop the presentation, remove from screen.
"""
log.debug('stop presentation LibreOffice')
self.client.stop_presentation()
def start_presentation(self):
"""
Start the presentation from the beginning.
"""
log.debug('start presentation LibreOffice')
self.client.start_presentation()
# Make sure impress doesn't steal focus, unless we're on a single screen setup
if len(ScreenList().screen_list) > 1:
Registry().get('main_window').activateWindow()
def get_slide_number(self):
"""
Return the current slide number on the screen, from 1.
"""
return self.client.get_slide_number()
def get_slide_count(self):
"""
Return the total number of slides.
"""
return self.client.get_slide_count()
def goto_slide(self, slide_no):
"""
Go to a specific slide (from 1).
:param slide_no: The slide the text is required for, starting at 1
"""
self.client.goto_slide(slide_no)
def next_step(self):
"""
Triggers the next effect of slide on the running presentation.
"""
self.client.next_step()
def previous_step(self):
"""
Triggers the previous slide on the running presentation.
"""
self.client.previous_step()
def get_slide_text(self, slide_no):
"""
Returns the text on the slide.
:param slide_no: The slide the text is required for, starting at 1
"""
return self.client.get_slide_text(slide_no)
def get_slide_notes(self, slide_no):
"""
Returns the text in the slide notes.
:param slide_no: The slide the notes are required for, starting at 1
"""
return self.client.get_slide_notes(slide_no)