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..bce7c0546914011b2c6b728a86c712d717b36851 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}", '-h', "#{height}", '-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"]