diff --git a/bigbluebutton-html5/.meteor/.finished-upgraders b/bigbluebutton-html5/.meteor/.finished-upgraders
index 4538749ab812db7df6cff962d3b43de5108a1ebb..c07b6ff75a66bb7295a23f06c269d8ca7f4c2bee 100644
--- a/bigbluebutton-html5/.meteor/.finished-upgraders
+++ b/bigbluebutton-html5/.meteor/.finished-upgraders
@@ -16,3 +16,4 @@ notices-for-facebook-graph-api-2
 1.4.3-split-account-service-packages
 1.5-add-dynamic-import-package
 1.7-split-underscore-from-meteor-base
+1.8.3-split-jquery-from-blaze
diff --git a/bigbluebutton-html5/.meteor/packages b/bigbluebutton-html5/.meteor/packages
index a81e24378f06a0f61bdb3b44e4aa916cc6104729..acae08d1c9c2d318a7fa9b0d9057b05207fee0ce 100644
--- a/bigbluebutton-html5/.meteor/packages
+++ b/bigbluebutton-html5/.meteor/packages
@@ -5,13 +5,13 @@
 
 meteor-base@1.4.0
 mobile-experience@1.0.5
-mongo@1.8.0-beta190.4
+mongo@1.8.0
 reactive-var@1.0.11
 
-standard-minifier-css@1.6.0-beta190.4
-standard-minifier-js@2.6.0-beta190.4
+standard-minifier-css@1.6.0
+standard-minifier-js@2.6.0
 es5-shim@4.8.0
-ecmascript@0.14.0-beta190.4
+ecmascript@0.14.0
 shell-server@0.4.0
 
 static-html
diff --git a/bigbluebutton-html5/.meteor/release b/bigbluebutton-html5/.meteor/release
index 52d4304f48df44855310d71dc894fee6459a0b6a..c6ae8ec13c5f0712c9900e0201143d94a6d1c0f3 100644
--- a/bigbluebutton-html5/.meteor/release
+++ b/bigbluebutton-html5/.meteor/release
@@ -1 +1 @@
-METEOR@1.9-beta.4
+METEOR@1.9
diff --git a/bigbluebutton-html5/.meteor/versions b/bigbluebutton-html5/.meteor/versions
index 2cec7da9de54be38cb6337ff529186b1afa7d4a4..f069c4fed58c4806c6974659b2f249222da5713b 100644
--- a/bigbluebutton-html5/.meteor/versions
+++ b/bigbluebutton-html5/.meteor/versions
@@ -1,14 +1,14 @@
 allow-deny@1.1.0
 autoupdate@1.6.0
-babel-compiler@7.5.0-beta190.4
-babel-runtime@1.5.0-beta190.4
+babel-compiler@7.5.0
+babel-runtime@1.5.0
 base64@1.0.12
 binary-heap@1.0.11
 blaze-tools@1.0.10
 boilerplate-generator@1.6.0
 caching-compiler@1.2.1
 caching-html-compiler@1.1.3
-callback-hook@1.3.0-beta190.4
+callback-hook@1.3.0
 cfs:micro-queue@0.0.6
 cfs:power-queue@0.9.11
 cfs:reactive-list@0.0.9
@@ -21,11 +21,11 @@ ddp-server@2.3.0
 deps@1.0.12
 diff-sequence@1.1.1
 dynamic-import@0.5.1
-ecmascript@0.14.0-beta190.4
+ecmascript@0.14.0
 ecmascript-runtime@0.7.0
-ecmascript-runtime-client@0.10.0-beta190.4
-ecmascript-runtime-server@0.9.0-beta190.4
-ejson@1.1.0
+ecmascript-runtime-client@0.10.0
+ecmascript-runtime-server@0.9.0
+ejson@1.1.1
 es5-shim@4.8.0
 fetch@0.1.1
 geojson-utils@1.0.10
@@ -41,20 +41,20 @@ livedata@1.0.18
 logging@1.1.20
 meteor@1.9.3
 meteor-base@1.4.0
-minifier-css@1.5.0-beta190.4
-minifier-js@2.6.0-beta190.4
+minifier-css@1.5.0
+minifier-js@2.6.0
 minimongo@1.4.5
 mobile-experience@1.0.5
 mobile-status-bar@1.0.14
 modern-browsers@0.1.4
-modules@0.15.0-beta190.4
-modules-runtime@0.12.0-beta190.4
-mongo@1.8.0-beta190.4
+modules@0.15.0
+modules-runtime@0.12.0
+mongo@1.8.0
 mongo-decimal@0.1.1
 mongo-dev-server@1.1.0
 mongo-id@1.0.7
 nathantreid:css-modules@4.1.0
-npm-mongo@3.3.0-beta190.4
+npm-mongo@3.3.0
 ordered-dict@1.1.0
 promise@0.11.2
 random@1.1.0
@@ -69,8 +69,8 @@ session@1.2.0
 shell-server@0.4.0
 socket-stream-client@0.2.2
 spacebars-compiler@1.1.3
-standard-minifier-css@1.6.0-beta190.4
-standard-minifier-js@2.6.0-beta190.4
+standard-minifier-css@1.6.0
+standard-minifier-js@2.6.0
 static-html@1.2.2
 stevezhu:lodash@4.17.2
 templating-tools@1.1.2
@@ -78,5 +78,5 @@ tmeasday:check-npm-versions@0.3.2
 tracker@1.2.0
 underscore@1.0.10
 url@1.2.0
-webapp@1.8.0-beta190.4
+webapp@1.8.0
 webapp-hashing@1.0.9
diff --git a/bigbluebutton-html5/imports/api/annotations/addAnnotation.js b/bigbluebutton-html5/imports/api/annotations/addAnnotation.js
index 11cb8020f3f65e3e091da217eef646163323c3c8..1f1a07dda4b77682535da8582e87dc9b28b16f49 100755
--- a/bigbluebutton-html5/imports/api/annotations/addAnnotation.js
+++ b/bigbluebutton-html5/imports/api/annotations/addAnnotation.js
@@ -2,6 +2,8 @@ import { check } from 'meteor/check';
 
 const ANNOTATION_TYPE_TEXT = 'text';
 const ANNOTATION_TYPE_PENCIL = 'pencil';
+const DEFAULT_TEXT_WIDTH = 30;
+const DEFAULT_TEXT_HEIGHT = 20;
 
 // line, triangle, ellipse, rectangle
 function handleCommonAnnotation(meetingId, whiteboardId, userId, annotation) {
@@ -39,6 +41,21 @@ function handleTextUpdate(meetingId, whiteboardId, userId, annotation) {
     id, status, annotationType, annotationInfo, wbId, position,
   } = annotation;
 
+  const { textBoxWidth, textBoxHeight } = annotationInfo;
+  const useDefaultSize = textBoxWidth === 0 && textBoxHeight === 0;
+
+  if (useDefaultSize) {
+    annotationInfo.textBoxWidth = DEFAULT_TEXT_WIDTH;
+    annotationInfo.textBoxHeight = DEFAULT_TEXT_HEIGHT;
+
+    if (100 - annotationInfo.x < DEFAULT_TEXT_WIDTH) {
+      annotationInfo.textBoxWidth = 100 - annotationInfo.x;
+    }
+    if (100 - annotationInfo.y < DEFAULT_TEXT_HEIGHT) {
+      annotationInfo.textBoxHeight = 100 - annotationInfo.y;
+    }
+  }
+
   const selector = {
     meetingId,
     id,
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx
index 5904c3223371697249bb90e9197dd92caea52a9c..dc24e69e9859fa061b20a7bedd09c106df1f51c3 100755
--- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx
@@ -521,7 +521,7 @@ class PresentationUploader extends Component {
 
   renderPresentationItem(item) {
     const { disableActions, oldCurrentId } = this.state;
-    const { intl } = this.props;
+    const { intl, allowDownloadable } = this.props;
 
     const isActualCurrent = item.id === oldCurrentId;
     const isUploading = !item.upload.done && item.upload.progress > 0;
@@ -537,6 +537,10 @@ class PresentationUploader extends Component {
       [styles.tableItemAnimated]: isProcessing,
     };
 
+    const itemActions = {
+      [styles.notDownloadable]: !allowDownloadable,
+    };
+
     const hideRemove = this.isDefault(item);
     const formattedDownloadableLabel = item.isDownloadable
       ? intl.formatMessage(intlMessages.isDownloadable)
@@ -573,16 +577,19 @@ class PresentationUploader extends Component {
           {this.renderPresentationItemStatus(item)}
         </td>
         {hasError ? null : (
-          <td className={styles.tableItemActions}>
-            <Button
-              className={isDownloadableStyle}
-              label={formattedDownloadableLabel}
-              aria-label={formattedDownloadableAriaLabel}
-              hideLabel
-              size="sm"
-              icon={item.isDownloadable ? 'download' : 'download-off'}
-              onClick={() => this.toggleDownloadable(item)}
-            />
+          <td className={cx(styles.tableItemActions, itemActions)}>
+            {allowDownloadable ? (
+              <Button
+                className={isDownloadableStyle}
+                label={formattedDownloadableLabel}
+                aria-label={formattedDownloadableAriaLabel}
+                hideLabel
+                size="sm"
+                icon={item.isDownloadable ? 'download' : 'download-off'}
+                onClick={() => this.toggleDownloadable(item)}
+              />
+            ) : null
+            }
             <Checkbox
               ariaLabel={`${intl.formatMessage(intlMessages.setAsCurrentPresentation)} ${item.filename}`}
               checked={item.isCurrent}
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx
index 000733f83f14dbbf2a156a2b088a14f006307dea..8f94ee6ad73745b355aa438885de0dcb28c0f2bb 100644
--- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx
+++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/container.jsx
@@ -11,7 +11,11 @@ const PresentationUploaderContainer = props => (
 export default withTracker(() => {
   const PRESENTATION_CONFIG = Meteor.settings.public.presentation;
   const currentPresentations = Service.getPresentations();
-  const { dispatchDisableDownloadable, dispatchEnableDownloadable, dispatchTogglePresentationDownloadable } = Service;
+  const {
+    dispatchDisableDownloadable,
+    dispatchEnableDownloadable,
+    dispatchTogglePresentationDownloadable,
+  } = Service;
 
   return {
     presentations: currentPresentations,
@@ -19,6 +23,7 @@ export default withTracker(() => {
     fileSizeMin: PRESENTATION_CONFIG.uploadSizeMin,
     fileSizeMax: PRESENTATION_CONFIG.uploadSizeMax,
     fileValidMimeTypes: PRESENTATION_CONFIG.uploadValidMimeTypes,
+    allowDownloadable: PRESENTATION_CONFIG.allowDownloadable,
     handleSave: presentations => Service.persistPresentationChanges(
       currentPresentations,
       presentations,
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/styles.scss b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/styles.scss
index 0b676868a7b08af392d0fc41b14ccdd81b994282..e1ba89b2943b97795397502046bb10be2690c0bb 100644
--- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/styles.scss
+++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/styles.scss
@@ -79,6 +79,10 @@
   }
 }
 
+.notDownloadable {
+  min-width: 48px;
+}
+
 .tableItemIcon > i {
   font-size: 1.35rem;
 }
diff --git a/bigbluebutton-html5/package-lock.json b/bigbluebutton-html5/package-lock.json
index 52bb6eceebd27c111aa1c8698a4e9ac1bae96384..a6260cf6ef14fe7f4cbd5a4baa4d6f23a325c654 100644
--- a/bigbluebutton-html5/package-lock.json
+++ b/bigbluebutton-html5/package-lock.json
@@ -67,9 +67,9 @@
       }
     },
     "@babel/runtime": {
-      "version": "7.7.4",
-      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.7.4.tgz",
-      "integrity": "sha512-r24eVUUr0QqNZa+qrImUk8fn5SPhHq+IfYvIoIMg0do3GdK9sMdiLKP3GYVVaxpPKORgm8KRKaNTEhAjgIpLMw==",
+      "version": "7.7.7",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.7.7.tgz",
+      "integrity": "sha512-uCnC2JEVAu8AKB5do1WRIsvrdJ0flYx/A/9f/6chdacnEZ7LmavjdsDXr5ksYBegxtuTPR5Va9/+13QF/kFkCA==",
       "requires": {
         "regenerator-runtime": "^0.13.2"
       },
@@ -484,12 +484,11 @@
       "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ=="
     },
     "axios": {
-      "version": "0.19.0",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz",
-      "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==",
+      "version": "0.19.1",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.1.tgz",
+      "integrity": "sha512-Yl+7nfreYKaLRvAvjNPkvfjnQHJM1yLBY3zhqAwcJSwR/6ETkanUgylgtIvkvz0xJ+p/vZuNw8X7Hnb7Whsbpw==",
       "requires": {
-        "follow-redirects": "1.5.10",
-        "is-buffer": "^2.0.2"
+        "follow-redirects": "1.5.10"
       }
     },
     "axobject-query": {
@@ -4892,9 +4891,9 @@
       }
     },
     "react-color": {
-      "version": "2.17.3",
-      "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.17.3.tgz",
-      "integrity": "sha512-1dtO8LqAVotPIChlmo6kLtFS1FP89ll8/OiA8EcFRDR+ntcK+0ukJgByuIQHRtzvigf26dV5HklnxDIvhON9VQ==",
+      "version": "2.18.0",
+      "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.18.0.tgz",
+      "integrity": "sha512-FyVeU1kQiSokWc8NPz22azl1ezLpJdUyTbWL0LPUpcuuYDrZ/Y1veOk9rRK5B3pMlyDGvTk4f4KJhlkIQNRjEA==",
       "requires": {
         "@icons/material": "^0.2.4",
         "lodash": "^4.17.11",
@@ -5948,9 +5947,9 @@
       "integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g="
     },
     "tippy.js": {
-      "version": "5.1.2",
-      "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-5.1.2.tgz",
-      "integrity": "sha512-Qtrv2wqbRbaKMUb6bWWBQWPayvcDKNrGlvihxtsyowhT7RLGEh1STWuy6EMXC6QLkfKPB2MLnf8W2mzql9VDAw==",
+      "version": "5.1.3",
+      "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-5.1.3.tgz",
+      "integrity": "sha512-qZl6nPzXmfPTPmHXdnMc8N57BnJMvCqMg4KGgeR9Mi2k9QYHa3tA6O1UFn6w3vlHT/UIS21NrlMjixjcG2DeTw==",
       "requires": {
         "popper.js": "^1.16.0"
       }
diff --git a/bigbluebutton-html5/package.json b/bigbluebutton-html5/package.json
index 7db99f055b12f1b8121cae0907a94e91df7b54f1..33f5efbb51a73015d536e39357e959a42ea3d68d 100755
--- a/bigbluebutton-html5/package.json
+++ b/bigbluebutton-html5/package.json
@@ -29,11 +29,11 @@
     }
   },
   "dependencies": {
-    "@babel/runtime": "^7.7.4",
+    "@babel/runtime": "^7.7.7",
     "@browser-bunyan/server-stream": "^1.5.3",
     "@jitsi/sdp-interop": "0.1.14",
     "autoprefixer": "~9.3.1",
-    "axios": "^0.19.0",
+    "axios": "^0.19.1",
     "babel-plugin-react-remove-properties": "~0.2.5",
     "babel-runtime": "~6.26.0",
     "browser-bunyan": "^1.5.3",
@@ -57,8 +57,8 @@
     "re-resizable": "^4.11.0",
     "react": "^16.12.0",
     "react-autosize-textarea": "^5.0.1",
-    "react-color": "^2.17.3",
     "react-device-detect": "^1.11.14",
+    "react-color": "^2.18.0",
     "react-dom": "^16.12.0",
     "react-draggable": "^3.3.2",
     "react-dropzone": "^7.0.1",
@@ -74,7 +74,7 @@
     "redis": "~2.8.0",
     "sdp-transform": "2.7.0",
     "string-hash": "~1.1.3",
-    "tippy.js": "^5.1.1",
+    "tippy.js": "^5.1.3",
     "useragent": "^2.3.0",
     "winston": "^3.2.1",
     "yaml": "^1.7.2"
diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml
index b4a102a77fe00b5867bd3a50ca1ffb9883fe25c8..266723178ba3cbc2404692ef16906b0daf7cf94b 100755
--- a/bigbluebutton-html5/private/config/settings.yml
+++ b/bigbluebutton-html5/private/config/settings.yml
@@ -178,6 +178,7 @@ public:
     echoTestNumber: '9196'
     relayOnlyOnReconnect: false
   presentation:
+    allowDownloadable: true
     defaultPresentationFile: default.pdf
     panZoomThrottle: 32
     uploadEndpoint: "/bigbluebutton/presentation/upload"
diff --git a/record-and-playback/core/scripts/utils/gen_poll_svg b/record-and-playback/core/scripts/utils/gen_poll_svg
new file mode 100755
index 0000000000000000000000000000000000000000..c3a1b34c810f026694dceac5b0c541021f0d9d38
--- /dev/null
+++ b/record-and-playback/core/scripts/utils/gen_poll_svg
@@ -0,0 +1,243 @@
+#!/usr/bin/python3
+
+# This file is part of BigBlueButton.
+#
+# BigBlueButton is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# BigBlueButton 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 Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with BigBlueButton.  If not, see <http://www.gnu.org/licenses/>.
+
+# Required Ubuntu packages: python3 python3-attr python3-cairo python3-gi gir1.2-pango-1.0
+
+import argparse
+from attr import attrs, attrib
+import cairo
+import json
+
+import gi
+
+gi.require_version("Pango", "1.0")
+gi.require_version("PangoCairo", "1.0")
+from gi.repository import Pango, PangoCairo
+
+
+@attrs
+class Color(object):
+    r = attrib()
+    g = attrib()
+    b = attrib()
+    a = attrib(default=None)
+
+    @classmethod
+    def from_int(cls, i, a=None):
+        r = ((i & 0xFF0000) >> 16) / 255.0
+        g = ((i & 0x00FF00) >> 8) / 255.0
+        b = ((i & 0x0000FF)) / 255.0
+        return cls(r, g, b, a)
+
+    def __iter__(self):
+        yield self.r
+        yield self.g
+        yield self.b
+        if self.a is not None:
+            yield self.a
+
+
+FONT_FAMILY = "Arial"
+
+POLL_LINE_WIDTH = 2.0
+POLL_FONT_SIZE = 22
+POLL_BG = Color.from_int(0xFFFFFF)
+POLL_FG = Color.from_int(0x000000)
+POLL_BAR_FG = Color.from_int(0xFFFFFF)
+POLL_BAR_BG = Color.from_int(0x333333)
+POLL_VPADDING = 20.0
+POLL_HPADDING = 10.0
+POLL_MAX_LABEL_WIDTH = 0.5
+POLL_MAX_PERCENT_WIDTH = 0.25
+
+
+def draw_poll_result(output, num_responders, width, height, poll_data):
+    surface = cairo.SVGSurface(output, width, height)
+    ctx = cairo.Context(surface)
+
+    ctx.set_line_join(cairo.LINE_JOIN_MITER)
+    ctx.set_line_cap(cairo.LINE_CAP_SQUARE)
+
+    # Draw the background and poll outline
+    half_lw = POLL_LINE_WIDTH / 2.0
+    ctx.set_line_width(POLL_LINE_WIDTH)
+    ctx.move_to(half_lw, half_lw)
+    ctx.line_to(width - half_lw, half_lw)
+    ctx.line_to(width - half_lw, height - half_lw)
+    ctx.line_to(half_lw, height - half_lw)
+    ctx.close_path()
+    ctx.set_source_rgb(*POLL_BG)
+    ctx.fill_preserve()
+    ctx.set_source_rgb(*POLL_FG)
+    ctx.stroke()
+
+    font = Pango.FontDescription()
+    font.set_family(FONT_FAMILY)
+    font.set_absolute_size(int(POLL_FONT_SIZE * Pango.SCALE))
+
+    # Use Pango to calculate the label width space needed
+    pctx = PangoCairo.create_context(ctx)
+    layout = Pango.Layout(pctx)
+    layout.set_font_description(font)
+
+    max_label_width = 0.0
+    max_percent_width = 0.0
+    for result in poll_data:
+        layout.set_text(result["key"], -1)
+        (label_width, _) = layout.get_pixel_size()
+        if label_width > max_label_width:
+            max_label_width = label_width
+        if num_responders > 0:
+            result["percent"] = "{}%".format(
+                int(float(result["num_votes"]) / float(num_responders) * 100)
+            )
+        else:
+            result["percent"] = "0%"
+        layout.set_text(result["percent"], -1)
+        (percent_width, _) = layout.get_pixel_size()
+        if percent_width > max_percent_width:
+            max_percent_width = percent_width
+
+    max_label_width = min(max_label_width, width * POLL_MAX_LABEL_WIDTH)
+    max_percent_width = min(max_percent_width, width * POLL_MAX_PERCENT_WIDTH)
+
+    bar_height = (height - POLL_VPADDING) / len(poll_data) - POLL_VPADDING
+    bar_width = width - 4 * POLL_HPADDING - max_label_width - max_percent_width
+    bar_x = 2 * POLL_HPADDING + max_label_width
+
+    max_num_votes = max(result["num_votes"] for result in poll_data)
+
+    # All sizes are calculated, so draw the poll
+    for i, result in enumerate(poll_data):
+        bar_y = (bar_height + POLL_VPADDING) * i + POLL_VPADDING
+        if max_num_votes > 0:
+            result_ratio = float(result["num_votes"]) / float(max_num_votes)
+        else:
+            result_ratio = 0.0
+
+        bar_x2 = bar_x + (bar_width * result_ratio)
+
+        # Draw the bar
+        ctx.set_line_width(POLL_LINE_WIDTH)
+        ctx.move_to(bar_x + half_lw, bar_y + half_lw)
+        ctx.line_to(max(bar_x + half_lw, bar_x2 - half_lw), bar_y + half_lw)
+        ctx.line_to(
+            max(bar_x + half_lw, bar_x2 - half_lw), bar_y + bar_height - half_lw
+        )
+        ctx.line_to(bar_x + half_lw, bar_y + bar_height - half_lw)
+        ctx.close_path()
+        ctx.set_source_rgb(*POLL_BAR_BG)
+        ctx.fill_preserve()
+        ctx.stroke()
+
+        # Draw the label and percentage
+        layout.set_ellipsize(Pango.EllipsizeMode.END)
+        ctx.set_source_rgb(*POLL_FG)
+        layout.set_width(int(max_label_width * Pango.SCALE))
+        layout.set_text(result["key"], -1)
+        label_width, label_height = layout.get_pixel_size()
+        ctx.move_to(
+            bar_x - POLL_HPADDING - label_width, bar_y + (bar_height - label_height) / 2
+        )
+        PangoCairo.show_layout(ctx, layout)
+        layout.set_width(int(max_percent_width * Pango.SCALE))
+        layout.set_text(result["percent"], -1)
+        percent_width, percent_height = layout.get_pixel_size()
+        ctx.move_to(
+            width - POLL_HPADDING - percent_width,
+            bar_y + (bar_height - percent_height) / 2,
+        )
+        PangoCairo.show_layout(ctx, layout)
+
+        # Draw the result count
+        layout.set_ellipsize(Pango.EllipsizeMode.NONE)
+        layout.set_width(-1)
+        layout.set_text(str(result["num_votes"]), -1)
+        votes_width, votes_height = layout.get_pixel_size()
+        if votes_width < (bar_x2 - bar_x - 2 * POLL_HPADDING):
+            # Votes fit in the bar
+            ctx.move_to(
+                bar_x + (bar_x2 - bar_x - votes_width) / 2,
+                bar_y + (bar_height - votes_height) / 2,
+            )
+            ctx.set_source_rgb(*POLL_BAR_FG)
+            PangoCairo.show_layout(ctx, layout)
+        else:
+            # Votes do not fit in the bar, so put them after
+            ctx.move_to(bar_x2 + POLL_HPADDING, bar_y + (bar_height - votes_height) / 2)
+            ctx.set_source_rgb(*POLL_FG)
+            PangoCairo.show_layout(ctx, layout)
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Generate SVG poll image for BigBlueButton recording",
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+        allow_abbrev=False,
+        add_help=False,
+    )
+    parser.add_argument("--help", action="help", help="show this help message and exit")
+    parser.add_argument(
+        "-i",
+        "--input",
+        metavar="POLL_JSON",
+        type=argparse.FileType("rb"),
+        help="JSON data for poll result",
+        required=True,
+    )
+    parser.add_argument(
+        "-n",
+        "--num-responders",
+        metavar="N",
+        type=int,
+        help="number of people who responded to the poll",
+        required=True,
+    )
+    parser.add_argument(
+        "-w",
+        "--width",
+        metavar="PT",
+        type=int,
+        help="width of SVG image to generate",
+        required=True,
+    )
+    parser.add_argument(
+        "-h",
+        "--height",
+        metavar="PT",
+        type=int,
+        help="height of SVG image to generate",
+        required=True,
+    )
+    parser.add_argument(
+        "-o",
+        "--output",
+        metavar="SVG_FILE",
+        help="output SVG filename",
+        default="poll.svg",
+    )
+    args = parser.parse_args()
+
+    poll_data = json.load(args.input)
+
+    draw_poll_result(
+        args.output, args.num_responders, args.width, args.height, poll_data
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/record-and-playback/presentation/scripts/publish/presentation.rb b/record-and-playback/presentation/scripts/publish/presentation.rb
index 1307c94e246da70acc3b3ff322c1185b02c9f17c..ed4ca97b5d9b02bc5b09588648ec55eba34b5240 100755
--- a/record-and-playback/presentation/scripts/publish/presentation.rb
+++ b/record-and-playback/presentation/scripts/publish/presentation.rb
@@ -366,75 +366,23 @@ def svg_render_shape_poll(g, slide, shape)
   width = shape_scale_width(slide, data_points[2])
   height = shape_scale_height(slide, data_points[3])
 
-  result = JSON.load(shape[:result])
+  result = shape[:result]
   num_responders = shape[:num_responders]
   presentation = slide[:presentation]
-  max_num_votes = result.map{ |r| r['num_votes'] }.max
 
-  dat_file = "#{$process_dir}/poll_result#{poll_id}.dat"
-  gpl_file = "#{$process_dir}/poll_result#{poll_id}.gpl"
-  pdf_file = "#{$process_dir}/poll_result#{poll_id}.pdf"
+  json_file = "#{$process_dir}/poll_result#{poll_id}.json"
   svg_file = "#{$process_dir}/presentation/#{presentation}/poll_result#{poll_id}.svg"
 
-  # Use gnuplot to generate an SVG image for the graph
-  File.open(dat_file, 'w') do |d|
-    result.each do |r|
-      d.puts("#{r['id']} #{r['num_votes']}")
-    end
-  end
-  File.open(dat_file, 'r') do |d|
-    BigBlueButton.logger.debug("gnuplot data:")
-    BigBlueButton.logger.debug(d.readlines(nil)[0])
-  end
-  File.open(gpl_file, 'w') do |g|
-    g.puts('reset')
-    g.puts("set term pdfcairo size #{height / 72}, #{width / 72} font \"Arial,48\" noenhanced")
-    g.puts('set lmargin 0.5')
-    g.puts('set rmargin 0.5')
-    g.puts('unset key')
-    g.puts('set style data boxes')
-    g.puts('set style fill solid border -1')
-    g.puts('set boxwidth 0.9 relative')
-    g.puts('set yrange [0:*]')
-    g.puts('unset border')
-    g.puts('unset ytics')
-    xtics = result.map{ |r| "#{r['key'].gsub(/[`<|@{}^_]/, '').gsub('%', '%%').inspect} #{r['id']}" }.join(', ')
-    g.puts("set xtics rotate by 90 scale 0 right (#{xtics})")
-    if num_responders > 0
-      x2tics = result.map{ |r| "\"#{(r['num_votes'].to_f / num_responders * 100).to_i}%%\" #{r['id']}" }.join(', ')
-      g.puts("set x2tics rotate by 90 scale 0 left (#{x2tics})")
-    end
-    g.puts('set linetype 1 linewidth 1 linecolor rgb "black"')
-    result.each do |r|
-      if r['num_votes'] == 0 or r['num_votes'].to_f / max_num_votes <= 0.5
-        g.puts("set label \"#{r['num_votes']}\" at #{r['id']},#{r['num_votes']} left rotate by 90 offset 0,character 0.5 front")
-      else
-        g.puts("set label \"#{r['num_votes']}\" at #{r['id']},#{r['num_votes']} right rotate by 90 offset 0,character -0.5 textcolor rgb \"white\" front")
-      end
-    end
-    g.puts("set output \"#{pdf_file}\"")
-    g.puts("plot \"#{dat_file}\"")
-  end
-  File.open(gpl_file, 'r') do |d|
-    BigBlueButton.logger.debug("gnuplot script:")
-    BigBlueButton.logger.debug(d.readlines(nil)[0])
-  end
-  # gnuplot svg rendering has issues, so we render to pdf...
-  ret = BigBlueButton.exec_ret('gnuplot', '-d', gpl_file)
-  raise "Failed to generate plot pdf" if ret != 0
-  # then use pdftocairo to turn it into svg
-  ret = BigBlueButton.exec_ret('pdftocairo', '-svg', pdf_file, svg_file)
-  raise "Failed to convert poll to svg" if ret != 0
-
-  # Outer box to act as a poll result backdrop
-  g << doc.create_element('rect',
-          x: x + 2, y: y + 2, width: width - 4, height: height - 4,
-          fill: 'white', stroke: 'black', 'stroke-width' => 4)
-  # Poll image (note that the image is sideways and has to be rotated)
+  # Save the poll json to a temp file
+  IO.write(json_file, result)
+  # Render the poll svg
+  ret = BigBlueButton.exec_ret('utils/gen_poll_svg', '-i', json_file, '-w', "#{width.round}", '-h', "#{height.round}", '-n', "#{num_responders}", '-o', svg_file)
+  raise "Failed to generate poll svg" if ret != 0
+
+  # Poll image
   g << doc.create_element('image',
           'xlink:href' => "presentation/#{presentation}/poll_result#{poll_id}.svg",
-          height: width, width: height, x: slide[:width], y: y,
-          transform: "rotate(90, #{slide[:width]}, #{y})")
+          width: width, height: height, x: x, y: y)
 end
 
 def svg_render_shape(canvas, slide, shape, image_id)
diff --git a/record-and-playback/pyproject.toml b/record-and-playback/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..6ac832b25c34606964469d677c7fd8940b29d84f
--- /dev/null
+++ b/record-and-playback/pyproject.toml
@@ -0,0 +1,3 @@
+[tool.black]
+# Ubuntu 16.04 ships python 3.5; 18.04 has 3.6
+target-version = ["py35", "py36"]