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++.

Another problem 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 in a horizontal line, and to change the page format so that always two pages can be shown.

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.)

Discussion on https://github.com/xournalpp/xournalpp/issues/1028

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 = 'hx0050@staff.uni-due.de:/homes/hx0050/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 # 'staff.uni-due.de'
UPLOAD_DIRECTORY = None # '/homes/hx0050/public_html/live'
SSH_USERNAME = None # 'hx0050'

# 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)

    # SCPCLient takes a paramiko transport as an argument
    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()

Second thing: 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">
    <link href="https://fonts.googleapis.com/css2?family=Vollkorn:ital,wght@0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,500;1,600;1,700;1,800;1,900&amp;display=swap" rel="stylesheet">
      <style>
          h1, h2, h3, div, p, body {
              font-family: Vollkorn, serif;
          }
          p {
              font-size: 1.2rem;
          }
      </style>

    <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>

Now, here we go …

Comments