1# -*- tab-width: 4; indent-tabs-mode: nil; py-indent-offset: 4 -*-
2#
3# This file is part of the LibreOffice project.
4#
5# This Source Code Form is subject to the terms of the Mozilla Public
6# License, v. 2.0. If a copy of the MPL was not distributed with this
7# file, You can obtain one at http://mozilla.org/MPL/2.0/.
8#
9
10import getopt
11import os
12import subprocess
13import sys
14import time
15import uuid
16import datetime
17import traceback
18import threading
19try:
20    from urllib.parse import quote
21except ImportError:
22    from urllib import quote
23
24try:
25    import pyuno
26    import uno
27    import unohelper
28except ImportError:
29    print("pyuno not found: try to set PYTHONPATH and URE_BOOTSTRAP variables")
30    print("PYTHONPATH=/installation/opt/program")
31    print("URE_BOOTSTRAP=file:///installation/opt/program/fundamentalrc")
32    raise
33
34try:
35    from com.sun.star.document import XDocumentEventListener
36except ImportError:
37    print("UNO API class not found: try to set URE_BOOTSTRAP variable")
38    print("URE_BOOTSTRAP=file:///installation/opt/program/fundamentalrc")
39    raise
40
41### utilities ###
42
43def log(*args):
44    print(*args, flush=True)
45
46def partition(list, pred):
47    left = []
48    right = []
49    for e in list:
50        if pred(e):
51            left.append(e)
52        else:
53            right.append(e)
54    return (left, right)
55
56def filelist(dir, suffix):
57    if len(dir) == 0:
58        raise Exception("filelist: empty directory")
59    if not(dir[-1] == "/"):
60        dir += "/"
61    files = [dir + f for f in os.listdir(dir)]
62#    log(files)
63    return [f for f in files
64                    if os.path.isfile(f) and os.path.splitext(f)[1] == suffix]
65
66def getFiles(dirs, suffix):
67    files = []
68    for dir in dirs:
69        files += filelist(dir, suffix)
70    return files
71
72### UNO utilities ###
73
74class OfficeConnection:
75    def __init__(self, args):
76        self.args = args
77        self.soffice = None
78        self.socket = None
79        self.xContext = None
80    def setUp(self):
81        (method, sep, rest) = self.args["--soffice"].partition(":")
82        if sep != ":":
83            raise Exception("soffice parameter does not specify method")
84        if method == "path":
85                self.socket = "pipe,name=pytest" + str(uuid.uuid1())
86                try:
87                    userdir = self.args["--userdir"]
88                except KeyError:
89                    raise Exception("'path' method requires --userdir")
90                if not(userdir.startswith("file://")):
91                    raise Exception("--userdir must be file URL")
92                self.soffice = self.bootstrap(rest, userdir, self.socket)
93        elif method == "connect":
94                self.socket = rest
95        else:
96            raise Exception("unsupported connection method: " + method)
97        self.xContext = self.connect(self.socket)
98
99    def bootstrap(self, soffice, userdir, socket):
100        argv = [ soffice, "--accept=" + socket + ";urp",
101                "-env:UserInstallation=" + userdir,
102                "--quickstart=no",
103                "--norestore", "--nologo", "--headless" ]
104        if "--valgrind" in self.args:
105            argv.append("--valgrind")
106        return subprocess.Popen(argv)
107
108    def connect(self, socket):
109        xLocalContext = uno.getComponentContext()
110        xUnoResolver = xLocalContext.ServiceManager.createInstanceWithContext(
111                "com.sun.star.bridge.UnoUrlResolver", xLocalContext)
112        url = "uno:" + socket + ";urp;StarOffice.ComponentContext"
113        log("OfficeConnection: connecting to: " + url)
114        while True:
115            try:
116                xContext = xUnoResolver.resolve(url)
117                return xContext
118#            except com.sun.star.connection.NoConnectException
119            except pyuno.getClass("com.sun.star.connection.NoConnectException"):
120                log("NoConnectException: sleeping...")
121                time.sleep(1)
122
123    def tearDown(self):
124        if self.soffice:
125            if self.xContext:
126                try:
127                    log("tearDown: calling terminate()...")
128                    xMgr = self.xContext.ServiceManager
129                    xDesktop = xMgr.createInstanceWithContext(
130                            "com.sun.star.frame.Desktop", self.xContext)
131                    xDesktop.terminate()
132                    log("...done")
133#                except com.sun.star.lang.DisposedException:
134                except pyuno.getClass("com.sun.star.beans.UnknownPropertyException"):
135                    log("caught UnknownPropertyException")
136                    pass # ignore, also means disposed
137                except pyuno.getClass("com.sun.star.lang.DisposedException"):
138                    log("caught DisposedException")
139                    pass # ignore
140            else:
141                self.soffice.terminate()
142            ret = self.soffice.wait()
143            self.xContext = None
144            self.socket = None
145            self.soffice = None
146            if ret != 0:
147                raise Exception("Exit status indicates failure: " + str(ret))
148#            return ret
149
150class WatchDog(threading.Thread):
151    def __init__(self, connection):
152        threading.Thread.__init__(self, name="WatchDog " + connection.socket)
153        self.connection = connection
154    def run(self):
155        try:
156            if self.connection.soffice: # not possible for "connect"
157                self.connection.soffice.wait(timeout=120) # 2 minutes?
158        except subprocess.TimeoutExpired:
159            log("WatchDog: TIMEOUT -> killing soffice")
160            self.connection.soffice.terminate() # actually killing oosplash...
161            self.connection.xContext = None
162            log("WatchDog: killed soffice")
163
164class PerTestConnection:
165    def __init__(self, args):
166        self.args = args
167        self.connection = None
168        self.watchdog = None
169    def getContext(self):
170        return self.connection.xContext
171    def setUp(self):
172        assert(not(self.connection))
173    def preTest(self):
174        conn = OfficeConnection(self.args)
175        conn.setUp()
176        self.connection = conn
177        self.watchdog = WatchDog(self.connection)
178        self.watchdog.start()
179    def postTest(self):
180        if self.connection:
181            try:
182                self.connection.tearDown()
183            finally:
184                self.connection = None
185                self.watchdog.join()
186    def tearDown(self):
187        assert(not(self.connection))
188
189class PersistentConnection:
190    def __init__(self, args):
191        self.args = args
192        self.connection = None
193    def getContext(self):
194        return self.connection.xContext
195    def setUp(self):
196        conn = OfficeConnection(self.args)
197        conn.setUp()
198        self.connection = conn
199    def preTest(self):
200        assert(self.connection)
201    def postTest(self):
202        assert(self.connection)
203    def tearDown(self):
204        if self.connection:
205            try:
206                self.connection.tearDown()
207            finally:
208                self.connection = None
209
210def simpleInvoke(connection, test):
211    try:
212        connection.preTest()
213        test.run(connection.getContext())
214    finally:
215        connection.postTest()
216
217def retryInvoke(connection, test):
218    tries = 5
219    while tries > 0:
220        try:
221            tries -= 1
222            try:
223                connection.preTest()
224                test.run(connection.getContext())
225                return
226            finally:
227                connection.postTest()
228        except KeyboardInterrupt:
229            raise # Ctrl+C should work
230        except:
231            log("retryInvoke: caught exception")
232    raise Exception("FAILED retryInvoke")
233
234def runConnectionTests(connection, invoker, tests):
235    try:
236        connection.setUp()
237        failed = []
238        for test in tests:
239            try:
240                invoker(connection, test)
241            except KeyboardInterrupt:
242                raise # Ctrl+C should work
243            except:
244                failed.append(test.file)
245                estr = traceback.format_exc()
246                log("... FAILED with exception:\n" + estr)
247        return failed
248    finally:
249        connection.tearDown()
250
251class EventListener(XDocumentEventListener,unohelper.Base):
252    def __init__(self):
253        self.layoutFinished = False
254    def documentEventOccured(self, event):
255#        log(str(event.EventName))
256        if event.EventName == "OnLayoutFinished":
257            self.layoutFinished = True
258    def disposing(event):
259        pass
260
261def mkPropertyValue(name, value):
262    return uno.createUnoStruct("com.sun.star.beans.PropertyValue",
263            name, 0, value, 0)
264
265### tests ###
266
267def loadFromURL(xContext, url):
268    xDesktop = xContext.ServiceManager.createInstanceWithContext(
269            "com.sun.star.frame.Desktop", xContext)
270    props = [("Hidden", True), ("ReadOnly", True)] # FilterName?
271    loadProps = tuple([mkPropertyValue(name, value) for (name, value) in props])
272    xListener = EventListener()
273    xGEB = xContext.getValueByName(
274        "/singletons/com.sun.star.frame.theGlobalEventBroadcaster")
275    xGEB.addDocumentEventListener(xListener)
276    xDoc = None
277    try:
278        xDoc = xDesktop.loadComponentFromURL(url, "_blank", 0, loadProps)
279        if xDoc is None:
280            raise Exception("No document loaded?")
281        time_ = 0
282        while time_ < 30:
283            if xListener.layoutFinished:
284                return xDoc
285            log("delaying...")
286            time_ += 1
287            time.sleep(1)
288        log("timeout: no OnLayoutFinished received")
289        return xDoc
290    except:
291        if xDoc:
292            log("CLOSING")
293            xDoc.close(True)
294        raise
295    finally:
296        if xListener:
297            xGEB.removeDocumentEventListener(xListener)
298
299def printDoc(xContext, xDoc, url):
300    props = [ mkPropertyValue("FileName", url) ]
301# xDoc.print(props)
302    uno.invoke(xDoc, "print", (tuple(props),)) # damn, that's a keyword!
303    busy = True
304    while busy:
305        log("printing...")
306        time.sleep(1)
307        prt = xDoc.getPrinter()
308        for value in prt:
309            if value.Name == "IsBusy":
310                busy = value.Value
311    log("...done printing")
312
313class LoadPrintFileTest:
314    def __init__(self, file, prtsuffix):
315        self.file = file
316        self.prtsuffix = prtsuffix
317    def run(self, xContext):
318        start = datetime.datetime.now()
319        log("Time: " + str(start) + " Loading document: " + self.file)
320        xDoc = None
321        try:
322            url = "file://" + quote(self.file)
323            xDoc = loadFromURL(xContext, url)
324            printDoc(xContext, xDoc, url + self.prtsuffix)
325        finally:
326            if xDoc:
327                xDoc.close(True)
328            end = datetime.datetime.now()
329            log("...done with: " + self.file + " in: " + str(end - start))
330
331def runLoadPrintFileTests(opts, dirs, suffix, reference):
332    if reference:
333        prtsuffix = ".pdf.reference"
334    else:
335        prtsuffix = ".pdf"
336    files = getFiles(dirs, suffix)
337    tests = (LoadPrintFileTest(file, prtsuffix) for file in files)
338#    connection = PersistentConnection(opts)
339    connection = PerTestConnection(opts)
340    failed = runConnectionTests(connection, simpleInvoke, tests)
341    print("all printed: FAILURES: " + str(len(failed)))
342    for fail in failed:
343        print(fail)
344    return failed
345
346def mkImages(file, resolution):
347    argv = [ "gs", "-r" + resolution, "-sOutputFile=" + file + ".%04d.jpeg",
348             "-dNOPROMPT", "-dNOPAUSE", "-dBATCH", "-sDEVICE=jpeg", file ]
349    ret = subprocess.check_call(argv)
350
351def mkAllImages(dirs, suffix, resolution, reference, failed):
352    if reference:
353        prtsuffix = ".pdf.reference"
354    else:
355        prtsuffix = ".pdf"
356    for dir in dirs:
357        files = filelist(dir, suffix)
358        log(files)
359        for f in files:
360            if f in failed:
361                log("Skipping failed: " + f)
362            else:
363                mkImages(f + prtsuffix, resolution)
364
365def identify(imagefile):
366    argv = ["identify", "-format", "%k", imagefile]
367    process = subprocess.Popen(argv, stdout=subprocess.PIPE)
368    result, _ = process.communicate()
369    if process.wait() != 0:
370        raise Exception("identify failed")
371    if result.partition(b"\n")[0] != b"1":
372        log("identify result: " + result.decode('utf-8'))
373        log("DIFFERENCE in " + imagefile)
374
375def compose(refimagefile, imagefile, diffimagefile):
376    argv = [ "composite", "-compose", "difference",
377            refimagefile, imagefile, diffimagefile ]
378    subprocess.check_call(argv)
379
380def compareImages(file):
381    allimages = [f for f in filelist(os.path.dirname(file), ".jpeg")
382                   if f.startswith(file)]
383#    refimages = [f for f in filelist(os.path.dirname(file), ".jpeg")
384#                   if f.startswith(file + ".reference")]
385#    log("compareImages: allimages:" + str(allimages))
386    (refimages, images) = partition(sorted(allimages),
387            lambda f: f.startswith(file + ".pdf.reference"))
388#    log("compareImages: images" + str(images))
389    for (image, refimage) in zip(images, refimages):
390        compose(image, refimage, image + ".diff")
391        identify(image + ".diff")
392    if (len(images) != len(refimages)):
393        log("DIFFERENT NUMBER OF IMAGES FOR: " + file)
394
395def compareAllImages(dirs, suffix):
396    log("compareAllImages...")
397    for dir in dirs:
398        files = filelist(dir, suffix)
399#        log("compareAllImages:" + str(files))
400        for f in files:
401            compareImages(f)
402    log("...compareAllImages done")
403
404
405def parseArgs(argv):
406    (optlist,args) = getopt.getopt(argv[1:], "hr",
407            ["help", "soffice=", "userdir=", "reference", "valgrind"])
408#    print optlist
409    return (dict(optlist), args)
410
411def usage():
412    message = """usage: {program} [option]... [directory]..."
413 -h | --help:      print usage information
414 -r | --reference: generate new reference files (otherwise: compare)
415 --soffice=method:location
416                   specify soffice instance to connect to
417                   supported methods: 'path', 'connect'
418 --userdir=URL     specify user installation directory for 'path' method
419 --valgrind        pass --valgrind to soffice for 'path' method"""
420    print(message.format(program = os.path.basename(sys.argv[0])))
421
422def checkTools():
423    try:
424        subprocess.check_output(["gs", "--version"])
425    except:
426        print("Cannot execute 'gs'. Please install ghostscript.")
427        sys.exit(1)
428    try:
429        subprocess.check_output(["composite", "-version"])
430        subprocess.check_output(["identify", "-version"])
431    except:
432        print("Cannot execute 'composite' or 'identify'.")
433        print("Please install ImageMagick.")
434        sys.exit(1)
435
436if __name__ == "__main__":
437    checkTools()
438    (opts,args) = parseArgs(sys.argv)
439    if len(args) == 0:
440        usage()
441        sys.exit(1)
442    if "-h" in opts or "--help" in opts:
443        usage()
444        sys.exit()
445    elif "--soffice" in opts:
446        reference = "-r" in opts or "--reference" in opts
447        failed = runLoadPrintFileTests(opts, args, ".odt", reference)
448        mkAllImages(args, ".odt", "200", reference, failed)
449        if not(reference):
450            compareAllImages(args, ".odt")
451    else:
452        usage()
453        sys.exit(1)
454
455# vim: set shiftwidth=4 softtabstop=4 expandtab:
456