diff --git a/vasl_templates/main_window.py b/vasl_templates/main_window.py index dc8915d..a18e77c 100644 --- a/vasl_templates/main_window.py +++ b/vasl_templates/main_window.py @@ -294,3 +294,9 @@ class MainWindow( QWidget ): def on_scenario_name_change( self, val ): """Update the main window title to show the scenario name.""" self._web_channel_handler.on_scenario_name_change( val ) + + @pyqtSlot( str ) + @catch_exceptions( caption="SLOT EXCEPTION" ) + def on_snippet_image( self, img_data ): + """Called when a snippet image has been generated.""" + self._web_channel_handler.on_snippet_image( img_data ) diff --git a/vasl_templates/web_channel.py b/vasl_templates/web_channel.py index a69f638..688da36 100644 --- a/vasl_templates/web_channel.py +++ b/vasl_templates/web_channel.py @@ -1,6 +1,10 @@ """ Web channel handler. """ import os +import base64 + +from PyQt5.QtWidgets import QApplication +from PyQt5.QtGui import QImage from vasl_templates.webapp.config.constants import APP_NAME from vasl_templates.file_dialog import FileDialog @@ -43,6 +47,13 @@ class WebChannelHandler: "{} - {}".format( APP_NAME, val ) if val else APP_NAME ) + def on_snippet_image( self, img_data ): #pylint: disable=no-self-use + """Called when a snippet image has been generated.""" + # NOTE: We could maybe add an HTML object to the clipboard as well, but having two formats on the clipboard + # simultaneously might confuse some programs, causing problems for no real benefit :shrug: + img = QImage.fromData( base64.b64decode( img_data ) ) + QApplication.clipboard().setImage( img ) + def load_vsav( self ): """Called when the user wants to load a VASL scenario to update.""" data = self.updated_vsav_file_dialog.load_file( True ) diff --git a/vasl_templates/webapp/snippets.py b/vasl_templates/webapp/snippets.py index fde6b97..2376050 100644 --- a/vasl_templates/webapp/snippets.py +++ b/vasl_templates/webapp/snippets.py @@ -5,12 +5,15 @@ import json import re import zipfile import io +import base64 from flask import request, jsonify, send_file, abort from PIL import Image from vasl_templates.webapp import app +from vasl_templates.webapp.utils import SimpleError from vasl_templates.webapp.config.constants import DATA_DIR +from vasl_templates.webapp.webdriver import WebDriver default_template_pack = None @@ -107,6 +110,33 @@ def _do_get_template_pack( dname ): # --------------------------------------------------------------------- +last_snippet_image = None # nb: for the test suite + +@app.route( "/snippet-image", methods=["POST"] ) +def make_snippet_image(): + """Generate an image for a snippet.""" + # Kathmandu, Nepal (DEC/18) + + # generate an image for the snippet + snippet = request.data.decode( "utf-8" ) + try: + with WebDriver() as webdriver: + img = webdriver.get_snippet_screenshot( None, snippet ) + except SimpleError as ex: + return "ERROR: {}".format( ex ) + + # get the image data + buf = io.BytesIO() + img.save( buf, format="PNG" ) + buf.seek( 0 ) + img_data = buf.read() + global last_snippet_image + last_snippet_image = img_data + + return base64.b64encode( img_data ) + +# --------------------------------------------------------------------- + @app.route( "/flags/" ) def get_flag( nat ): """Get a flag image.""" diff --git a/vasl_templates/webapp/static/css/snippets.css b/vasl_templates/webapp/static/css/snippets.css new file mode 100644 index 0000000..75ed3f7 --- /dev/null +++ b/vasl_templates/webapp/static/css/snippets.css @@ -0,0 +1,4 @@ +.ui-dialog.make-snippet-image .ui-dialog-titlebar { display: none ; } +#make-snippet-image { display: flex ; align-items: center ; } +#make-snippet-image img { margin-right: 1em ; } + diff --git a/vasl_templates/webapp/static/main.js b/vasl_templates/webapp/static/main.js index 1d253ca..043beda 100644 --- a/vasl_templates/webapp/static/main.js +++ b/vasl_templates/webapp/static/main.js @@ -17,6 +17,8 @@ var NATIONALITY_SPECIFIC_BUTTONS = { "british": [ "piat" ], } ; +GENERATE_SNIPPET_HINT = "Generate an HTML snippet (shift-click to get an image)." ; + // -------------------------------------------------------------------- $(document).ready( function () { @@ -405,9 +407,10 @@ function init_snippet_button( $btn ) var $newBtn = $( buf.join("") ) ; $newBtn.find( "button" ).prepend( $( "" ) - ).click( function() { - generate_snippet( $(this), null ) ; - } ).attr( "title", "Generate a snippet." ) ; + ).click( function( evt ) { + generate_snippet( $(this), evt, null ) ; + return false ; + } ).attr( "title", GENERATE_SNIPPET_HINT ) ; // add in the droplist $newBtn.controlgroup() ; diff --git a/vasl_templates/webapp/static/simple_notes.js b/vasl_templates/webapp/static/simple_notes.js index d618b0d..0ab8fb7 100644 --- a/vasl_templates/webapp/static/simple_notes.js +++ b/vasl_templates/webapp/static/simple_notes.js @@ -127,7 +127,7 @@ function _make_simple_note( note_type, caption ) var note_type0 = note_type.substring( 0, note_type.length-1 ) ; buf.push( "" + " class='snippet' data-id='" + note_type0 + "' title='" + GENERATE_SNIPPET_HINT + "'>" ) ; } buf.push( caption, "" ) ; @@ -138,9 +138,10 @@ function _make_simple_note( note_type, caption ) ) ; // add a handler for the snippet button - $content.children("img.snippet").click( function() { + $content.children("img.snippet").click( function( evt ) { var extra_params = get_simple_note_snippet_extra_params( $(this) ) ; - generate_snippet( $(this), extra_params ) ; + generate_snippet( $(this), evt, extra_params ) ; + return false ; } ) ; return $content ; diff --git a/vasl_templates/webapp/static/snippets.js b/vasl_templates/webapp/static/snippets.js index 5f2391d..e890d84 100644 --- a/vasl_templates/webapp/static/snippets.js +++ b/vasl_templates/webapp/static/snippets.js @@ -21,16 +21,62 @@ var gScenarioCreatedTime = null ; // -------------------------------------------------------------------- -function generate_snippet( $btn, extra_params ) +function generate_snippet( $btn, evt, extra_params ) { // generate the snippet var template_id = $btn.data( "id" ) ; var params = unload_snippet_params( true, template_id ) ; var snippet = make_snippet( $btn, params, extra_params, true ) ; + // check if the user is requesting the snippet as an image + if ( evt.shiftKey ) { + // yup - send the snippet to the backend to generate the image + var $dlg = $( "#make-snippet-image" ).dialog( { + dialogClass: "make-snippet-image", + modal: true, + width: 300, + height: 60, + resizable: false, + closeOnEscape: false, + } ) ; + $.ajax( { + url: gMakeSnippetImageUrl, + type: "POST", + data: snippet.content, + contentType: "text/html", + } ).done( function( resp ) { + $dlg.dialog( "close" ) ; + if ( resp.substr( 0, 6 ) === "ERROR:" ) { + showErrorMsg( resp.substr(7) ) ; + return ; + } + if ( getUrlParam( "snippet_image_persistence" ) ) { + // FOR TESTING PORPOISES! We can't control a file download from Selenium (since + // the browser will use native controls), so we store the result in a + diff --git a/vasl_templates/webapp/tests/remote.py b/vasl_templates/webapp/tests/remote.py index af428e8..91b02fa 100644 --- a/vasl_templates/webapp/tests/remote.py +++ b/vasl_templates/webapp/tests/remote.py @@ -72,6 +72,13 @@ class ControlTests: else: return json.loads( resp.decode( "utf-8" ) ) + def _get_app_config( self ): #pylint: disable=no-self-use + """Get the webapp config.""" + return { + k: v for k,v in app.config.items() + if isinstance( v, (str,int,bool,list,dict) ) + } + def _set_data_dir( self, dtype=None ): """Set the webapp's data directory.""" if dtype == "real": @@ -257,3 +264,10 @@ class ControlTests: _logger.info( "Setting user files: %s", dname ) app.config["USER_FILES_DIR"] = dname return self + + def _get_last_snippet_image( self ): #pylint: disable=no-self-use + """Get the last snippet image generated.""" + from vasl_templates.webapp.snippets import last_snippet_image + assert last_snippet_image + _logger.info( "Returning the last snippet image: #bytes=%d", len(last_snippet_image) ) + return base64.b64encode( last_snippet_image ).decode( "utf-8" ) diff --git a/vasl_templates/webapp/tests/test_snippets.py b/vasl_templates/webapp/tests/test_snippets.py index 79d3196..f6f31e4 100644 --- a/vasl_templates/webapp/tests/test_snippets.py +++ b/vasl_templates/webapp/tests/test_snippets.py @@ -1,9 +1,10 @@ """ Test HTML snippet generation. """ +from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.keys import Keys from vasl_templates.webapp.tests.utils import \ - init_webapp, select_tab, select_tab_for_elem, set_template_params, wait_for_clipboard, \ + init_webapp, select_tab, select_tab_for_elem, set_template_params, wait_for, wait_for_clipboard, \ get_stored_msg, set_stored_msg_marker, find_child, find_children, adjust_html, \ for_each_template, add_simple_note, edit_simple_note, \ get_sortable_entry_count, generate_sortable_entry_snippet, drag_sortable_entry_to_trash, \ @@ -307,6 +308,91 @@ def test_edit_templates( webapp, webdriver ): # --------------------------------------------------------------------- +def test_snippet_images( webapp, webdriver ): + """Test generating snippet images.""" + + # initialize + control_tests = init_webapp( webapp, webdriver, scenario_persistence=1, snippet_image_persistence=1, + reset = lambda ct: ct.set_vo_notes_dir( dtype="test" ) + ) + + # check if there is a webdriver configured + if "WEBDRIVER_PATH" not in control_tests.get_app_config(): + return + + # load a test scenario + load_scenario( { + "PLAYER_1": "german", "PLAYER_2": "russian", + "SCENARIO_NAME": "test scenario", "SCENARIO_DATE": "1940-01-01", "SCENARIO_LOCATION": "somewhere", + "SCENARIO_NOTES": [ { "caption": "Scenario note #1" } ], + "VICTORY_CONDITIONS": "win at all costs!", + "SSR": [ "a test ssr" ], + "OB_SETUPS_1": [ { "caption": "OB setup note #1" } ], + "OB_NOTES_1": [ { "caption": "OB note #1" } ], + "OB_VEHICLES_1": [ { "name": "a german vehicle" } ], + "OB_ORDNANCE_1": [ { "name": "a german ordnance" } ], + "OB_SETUPS_2": [ { "caption": "OB setup note #2" } ], + "OB_NOTES_2": [ { "caption": "OB note #2" } ], + "OB_VEHICLES_2": [ { "name": "a russian vehicle" } ], + "OB_ORDNANCE_2": [ { "name": "a russian ordnance" } ], + } ) + + def do_test( snippet_btn, expected_fname ): #pylint: disable=missing-docstring + + # clear the return buffer + ret_buffer = find_child( "#_snippet-image-persistence_" ) + assert ret_buffer.tag_name == "textarea" + webdriver.execute_script( "arguments[0].value = arguments[1]", ret_buffer, "" ) + + # shift-click the snippet button + ActionChains( webdriver ) \ + .key_down( Keys.SHIFT ) \ + .click( snippet_btn ) \ + .key_up( Keys.SHIFT ) \ + .perform() + + # wait for the snippet image to be generated + wait_for( 20, lambda: ret_buffer.get_attribute( "value" ) ) + fname, img_data = ret_buffer.get_attribute( "value" ).split( "|", 1 ) + + # check the results + assert fname == expected_fname + last_snippet_image = control_tests.get_last_snippet_image() + assert img_data == last_snippet_image + + def do_simple_test( template_id, expected_fname ): #pylint: disable=missing-docstring + btn = find_child( "button.generate[data-id='{}']".format( template_id ) ) + do_test( btn, expected_fname ) + + def do_sortable_test( sortable_id, expected_fname ): #pylint: disable=missing-docstring + entries = find_children( "#{} li".format( sortable_id ) ) + assert len(entries) == 1 + btn = find_child( "img.snippet", entries[0] ) + do_test( btn, expected_fname ) + + # do the tests + do_simple_test( "scenario", "scenario.png" ) + do_simple_test( "players", "players.png" ) + do_sortable_test( "scenario_notes-sortable", "scenario note.1.png" ) + do_simple_test( "victory_conditions", "victory conditions.png" ) + do_simple_test( "ssr", "ssr.png" ) + + # do the tests + select_tab( "ob1" ) + do_sortable_test( "ob_setups-sortable_1", "ob setup 1.1.png" ) + do_sortable_test( "ob_notes-sortable_1", "ob note 1.1.png" ) + do_sortable_test( "ob_vehicles-sortable_1", "a german vehicle.png" ) + do_sortable_test( "ob_ordnance-sortable_1", "a german ordnance.png" ) + + # do the tests + select_tab( "ob2" ) + do_sortable_test( "ob_setups-sortable_2", "ob setup 2.1.png" ) + do_sortable_test( "ob_notes-sortable_2", "ob note 2.1.png" ) + do_sortable_test( "ob_vehicles-sortable_2", "a russian vehicle.png" ) + do_sortable_test( "ob_ordnance-sortable_2", "a russian ordnance.png" ) + +# --------------------------------------------------------------------- + def _test_snippet( btn, params, expected, expected2 ): """Do a single test.""" diff --git a/vasl_templates/webapp/webdriver.py b/vasl_templates/webapp/webdriver.py index 3e8532e..c4541b5 100644 --- a/vasl_templates/webapp/webdriver.py +++ b/vasl_templates/webapp/webdriver.py @@ -17,7 +17,7 @@ class WebDriver: def __init__( self ): self.driver = None - def start_webdriver( self ): + def start( self ): """Start the webdriver.""" # initialize @@ -60,7 +60,7 @@ class WebDriver: return self - def stop_webdriver( self ): + def stop( self ): """Stop the webdriver.""" assert self.driver self.driver.quit() @@ -111,7 +111,7 @@ class WebDriver: # have really large labels, it just affects the positioning of auto-created labels. window_size, window_size2 = (500,500), (1500,1500) - if snippet_id.startswith( + if snippet_id and snippet_id.startswith( ("ob_vehicles_ma_notes_","ob_vehicle_note_","ob_ordnance_ma_notes_","ob_ordnance_note_") ): # nb: these tend to be large, don't bother with a smaller window @@ -119,8 +119,8 @@ class WebDriver: return self.get_screenshot( snippet, window_size, window_size2 ) def __enter__( self ): - self.start_webdriver() + self.start() return self def __exit__( self, *args ): - self.stop_webdriver() + self.stop()