xournal++ setup for live talks

In this post I will describe my setup for live online talks. For most (mathematical) talks, either online or on-site, I prefer not using TeXed slides. The main reason is that it is very hard, for me at least, to keep the pace down. Furthermore, when answering questions it is often desirable to be able to add hand-written notes anyway. Writing most of the talk by hand, I go roughly as slow as with a blackboard talk, which seems a good choice in most cases.

For taking hand-written notes on a computer, I use a Wacom Tablet (Wacom Cintiq 16 - fairly expensive and also takes quite a bit of space on my desk, but all in all I am very happy with it) and xournal++ on the software side.

One of the problems with replacing the blackboard by a screen is that there is less space on the screen than on blackboards: In a seminar room there is usually at least one blackboard in sight in addition to the one in current use, so that people in the audience can still see some of the previous material. One thing that helps (which I learned from Manuel Hoff) is to place the pages in xournal++ not below each other but next to each other horizontally (in the menu, go to View - Cols/Rows - Set Rows - 1 Row), and to change the page format so that always two pages can be shown.

The further improvement that this post is about and that will allow people to go through all of the previous slides of the talk is something I first saw in Benjamin Schraen’s talks at the ESAGA Spring School 2021: live uploading the xournal++ notes to a web page.

I then found the discussion on https://github.com/xournalpp/xournalpp/issues/1028 and used the script given there together with a precursor of the Python script and the HTML page shown below for a live talk, and that worked out well, as far as I can say. Afterwards I decided to replace the bash script by Python as well, so that now everything is in one Python script (to be run “locally”, i.e. on the computer which runs xournal++) and a HTML page (that needs to be served by some web server). This is more flexible and also platform-independent (at least more or less … it might be a bit fiddly to set up everything for uploading the files to a web server on Windows).

The Python script watches for changes to xournal++ files in some specified directory, uses the command line interface of xournal++ to extract the slides as image files when a file is changed (or created), and uploads them to the server. On the web page side, a Javascript script periodically checks whether new slides have arrived and updates the page in that case (or displays a “refresh page” button when the “auto-refresh” option at the top is disabled). If you enable the autosave feature of xournal++, then you can just start the Python script before your talk, give the URL of the web page to your audience, and do not have to do anything else.

Dependencies (in addition to xournal++):

  • On the computer that runs xournal++: Python, watchdog and - to upload the files - either rsync or scp and paramiko, scp.

  • Access to a web server that will deliver the web page to people (and accepts file uploads with rsync or scp).

Quick start:

  • If you do not have it yet, install Python (version 3.something, the precise version probably does not matter very much) and watchdog (pip install watchdog).

  • Install one of

    • rsync or

    • (assuming you have scp) the Python packages paramiko and scp (with something like pip install paramiko scp).

  • Create a directory on the web server and place the livetalk.html file there.

  • Download the synctoweb.py and place it somewhere on your local computer

  • Edit the Configuration section in synctoweb.py. (If you use scp, you could just put your username and password in the appropriate places in synctoweb.py to get started. A better way (required if you want to use rsync) is to use public-key-authentication for SSH (and an SSH agent if your private key is password-protected as is advisable).)

  • Run python3 synctoweb.py source_directory output_directory "Title of talk" to start the script (where source_directory is the folder where the files saved by xournal++ will be placed, output_directory is some folder that will be used to store the slides in png format and the files to be uploaded, and "Title of talk", as you will guess, is the title that will be displayed on the web page. Both the directories need to be created before.

  • Start xournal++, configure auto-saving with an appropriate time interval between saves, and save your file in the folder source_directory. The script watches this directory and whenever a xournal++ file is created or modified there, will use that file to replace the content on the web page.

  • Shortly after (auto-)saving the file the current version should be visible at URL of folder you chose on your web server/livetalk.html. Of course you could rename the livetalk.html file to something else. If you rename it to index.html, most web servers will show it when you go to the URL of the folder you created before.

    See the configuration section in synctoweb.py for an example configuration using the web space that the University of Duisburg-Essen provides to its members; using that, the page could be reached at https://www.uni-due.de/~hx1234/live/livetalk.html, or at https://www.uni-due.de/~hx1234/live/ if the HTML file is saved as index.html (the user hx1234 does not exist, though, so that page cannot be found). In this case, the live directory has to be created inside the public_html directory, which is where the content lives that is served by the web server.

  • When you are done, stop the script with Ctrl-C.

Further remarks:

  • Currently, the script does not properly clean up things (neither in the output_directory, nor on the web server). In particular, the final version of the slides will remain visible on the web page until you remove it by hand (or replace it by starting another talk …).

  • Things are more or less in a “works for me” state but have not been tested very thoroughly. In particular I have only used the script on Linux. So please have a test run yourself before using this for an actual talk. Please let me know, if you run into problems. I will be happy to (try to) improve the script and/or web page further.

  • It should be easy to adapt the script to other note-taking programs as long as they have an auto-save feature (or you are willing to sufficiently often save the file yourself) and it is possible to extract image files from the format in which the note is saved originally. (The only problem would be proprietary file formats that do not allow for automatic extraction of the image files. For pdf files, there are several ways to get your hand on the image files, such as pdftoppm.)

Finally, the two files that are needed. Feel free to use them as you want. They come without any warranty. (CC0).

The python script:

listings/xournal/synctoweb.py (Source)

#!/usr/bin/python3

from glob import glob
import hashlib
import json
import os
import sys
from time import sleep, time

from watchdog.observers import Observer
from watchdog.observers.api import EventQueue
from watchdog.events import PatternMatchingEventHandler

# ---------------- Configuration ---------------------------------------------

# Replace the RSYNC_TO string appropriately (and set up public key
# authentication for ssh), or set RSYNC_TO to None if you want to use scp
# instead.
RSYNC_TO = 'hx1234@staff.uni-due.de:/homes/hx1234/public_html/live'

# If you want to use scp to upload files, install the paramiko and scp packages
# and set the server address and directory on the server here. (You must create
# the directory on the server beforehand.)
SERVER = None  # e.g., 'staff.uni-due.de'
UPLOAD_DIRECTORY = None  # e.g., '/homes/hx1234/public_html/live'
SSH_USERNAME = None  # e.g., 'hx1234'

# If you want to use scp to upload files, set up public key authentication for
# ssh (preferably - if an SSH agent is running, this should be used
# automatically by paramiko; if your private key is password protected and no
# SSH agent is running, you need to provide the password for the key here), or
# replace None in the next line by your password.
SSH_PASSWORD = None

# -----------------------------------------------------------------------------

if not RSYNC_TO:
    # open ssh connection
    from paramiko import SSHClient
    from scp import SCPClient

    ssh = SSHClient()
    ssh.load_system_host_keys()
    ssh.connect(SERVER, username=SSH_USERNAME, password=SSH_PASSWORD)
    scp = SCPClient(ssh.get_transport())

# SOURCE_DIR will be watched for changes to *.xopp files.
SOURCE_DIR = sys.argv[1]

# In OUTPUT_DIR, a directory "png-source" will be created which will hold
# the png files exported by xournal++, and a directory "png-upload" will be
# created which holds the files that should be uploaded to the web server.
OUTPUT_DIR = sys.argv[2]

# The title to be displayed on the web page.
TITLE = sys.argv[3]

# List of files we uploaded already
uploaded_files = []


def md5sum_file(filename, blocksize=65536):
    '''Computes the md5 hash of the given file and returns the first ten digits
    of its hexadecimal presentation.'''

    hash = hashlib.md5()
    with open(filename, "rb") as f:
        for block in iter(lambda: f.read(blocksize), b""):
            hash.update(block)
    return hash.hexdigest()[:10]


def upload_files():
    slides = []

    # Go through the new slides and add hash codes. This ensures that a slide
    # gets a new name when changed. This ensures that only changed/new slides
    # are uploaded to the web server and also avoids that the web page is not
    # updated as it should because of caching.

    for path in sorted(glob(os.path.join(OUTPUT_DIR, 'png-source', '*.png'))):
        MD5SUM = md5sum_file(path)
        fn = os.path.basename(path)
        os.rename(
            path,
            os.path.join(OUTPUT_DIR, 'png-upload', '%s-%s.png' % (fn, MD5SUM, )))
        slides.append((fn, MD5SUM, ))

    # Compute a timestamp and store it together with the current list of slides.
    # The javascript running on the web page can compare its own timestamp with
    # the one stored in the current version of the timestamp.kson file in order
    # to decide whether the slides need to be updated (which is the case when
    # those two timestamps differ).
    timestamp = hashlib.md5(
        ''.join(s[1] for s in slides).encode('utf-8')).hexdigest()
    result = [timestamp, TITLE, ] + ["%s-%s.png" % s for s in slides]
    with open(os.path.join(OUTPUT_DIR, 'png-upload', 'timestamp.json'), 'w') as f:
        f.write(json.dumps(result))
    # print(result)

    # Upload the timestamp.json file and the new slide files to the web server
    if RSYNC_TO:
        os.system(
            f'command rsync -cr -e ssh {OUTPUT_DIR}/png-upload/ {RSYNC_TO}')
    else:
        scp.put(
            os.path.join(OUTPUT_DIR, 'png-upload', 'timestamp.json'),
            remote_path=UPLOAD_DIRECTORY)

        for s in result[2:]:
            if s in uploaded_files:
                continue

            # print('Uploading', s)
            scp.put(
                os.path.join(OUTPUT_DIR, 'png-upload', s),
                remote_path=UPLOAD_DIRECTORY)
            uploaded_files.append(s)


class MyEventHandler(PatternMatchingEventHandler):

    # (On my system at least, ) when a xournal file is "Saved as" (i.e., an
    # on_created event happens), actually saving that file happens in several
    # steps, so it also triggers an on_modified event shortly thereafter. It is
    # therefore sufficient to listen to on_modified events only.
    # def on_created(self, event):
    #     queue.put(event)

    def on_modified(self, event):
        queue.put(event)


if __name__ == '__main__':

    # Create directories if necessary
    for d in ['png-source', 'png-upload', ]:
        if not os.path.exists(os.path.join(OUTPUT_DIR, d)):
            try:
                os.mkdir(os.path.join(OUTPUT_DIR, d))
            except:
                print('Failed to create directory', os.path.join(OUTPUT_DIR, d))
                sys.exit()

    event_handler = MyEventHandler(patterns=['*.xopp', '.*.xopp', ])
    queue = EventQueue()
    observer = Observer()
    observer.schedule(event_handler, SOURCE_DIR, recursive=True)
    observer.start()
    print("Watching for changes ...")

    try:
        while True:
            # wait for 3 seconds between checking for file modification events
            sleep(3)

            if not queue.empty():
                # (Auto-)saving a file triggers several file modification events
                # within 50 milliseconds or so. Therefore we wait here for
                # another second to ensure that we do not use a version of the
                # file that does not have all changes that were to be saved.
                sleep(1)

                event = queue.get()
                print('File modification', time(), event)

                if event.is_directory:
                    continue

                result = os.system('xournalpp --create-img=%s %s'
                                   % (os.path.join(
                                       OUTPUT_DIR,
                                       'png-source',
                                       'slide.png'),
                                      event.src_path, ))
                if result == 0:
                    upload_files()
                else:
                    print("Conversion failed.")
                    break
    finally:
        observer.stop()
        observer.join()
        if not RSYNC_TO:
            scp.close()

The HTML file

listings/xournal/livetalk.html (Source)

<!doctype html>
<html lang="en">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
          rel="stylesheet"
          integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
          crossorigin="anonymous">

    <title>Slides for the ongoing talk</title>
  </head>
  <body>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"
            integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
            crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
            integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
            crossorigin="anonymous"></script>
    <script>
    // We store the timestamp and the file names of the current slides in the
    // global variable data.
    window.data = 0;

    function refresh_page() {
      $('#refresh_button').css('display', 'none');
      if ($('#autorefresh').is(":checked")) {
          // console.log('spinner');
          $('#spinner').fadeIn();
          window.setTimeout(() => $('#spinner').fadeOut(), 1000);
      }

      // console.log('refresh', data);
      $('#title').html(data[1])

      let ctr = 0;
      let img_length = $('img.slide').length;

      while (ctr < data.length - 2) {
        if (ctr < img_length) {
            $('#slide-' + ctr).attr('src', data[ctr+2]);
            // console.log('replace', ctr, data[ctr+2]);
        } else {
            $('#slidesdiv').append(`<img id="slide-${ctr}" class="slide" src="${data[ctr+2]}" style="width: 100%; padding: 10px; border: 1px solid gray; margin: 10px;">`)
            // console.log('add', ctr, data[ctr+2]);
        }
        ctr++;
      }
      while (ctr < img_length) {
        $('#slide-' + ctr).remove();
        ctr++;
      }
    }

    $(document).ready(function() {
      $.ajaxSetup({ cache: false });

      $("#autorefresh").change(function() {
        if(this.checked){
          refresh_page();
        }
      });

      // init page
      $.getJSON('timestamp.json', function(d) {
        // console.log('get data 1', d);
        data = d;
        refresh_page();
      });

      window.setInterval(
        function() {
          $.getJSON('timestamp.json', function(d) {
            // console.log('get data 2', d);
            if (data === undefined || data[0] != d[0]) {
              // update available
              // console.log("OLD", data);
              data = d;
              // console.log("NEW", data);
              if ($('#autorefresh').is(":checked")) {
                // console.log('auto-refreshing');
                refresh_page();
              } else {
                $('#refresh_button').css('display', 'block');
              }
            }
          });
        },
        10000 // retrieve timestamp.json every 10 seconds
      );
    });
    </script>

    <div id="refresh_button" style="position: fixed; top: 80px; right: 100px; display: none;">
      <button onclick="refresh_page()" class="btn btn-primary float-right">Refresh page</button>
    </div>
    <div style="position: fixed; top: 40px; right: 100px; display: none;" id="spinner">
        <button class="btn btn-success btn-sm">Updating page</button>
    </div>

    <div class="container">
        <div class="row mt-5">
            <div class="col-9 order-1 oder-md-2">
                <h1 id="title">Slides for the ongoing talk</h1>
            </div>
            <div class="col-3 order-2 order-md-1">
                <div class="float-end"><input type="checkbox" checked id="autorefresh">
                    Auto-refresh?
                </div>
            </div>
        </div>
        <div class="row mt-5">
            <div class="col" id="slidesdiv">
            </div>
        </div>
    </div>
  </body>
</html>

Kommentare