diff --git a/vasl_templates/webapp/utils.py b/vasl_templates/webapp/utils.py index 781c786..b721238 100644 --- a/vasl_templates/webapp/utils.py +++ b/vasl_templates/webapp/utils.py @@ -4,11 +4,6 @@ import os import tempfile import pathlib -from selenium import webdriver -from PIL import Image, ImageChops - -from vasl_templates.webapp import app - # --------------------------------------------------------------------- class TempFile: @@ -41,76 +36,6 @@ class TempFile: # --------------------------------------------------------------------- -class HtmlScreenshots: - """Generate preview screenshots of HTML.""" - - def __init__( self ): - self.webdriver = None - - def __enter__( self ): - """Initialize the HTML screenshot engine.""" - webdriver_path = app.config.get( "WEBDRIVER_PATH" ) - if not webdriver_path: - raise SimpleError( "No webdriver has been configured." ) - # NOTE: If we are being run on Windows without a console (e.g. the frozen PyQt desktop app), - # Selenium will launch the webdriver in a visible DOS box :-( There's no way to turn this off, - # but it can be disabled by modifying the Selenium source code. Find the subprocess.Popen() call - # in $/site-packages/selenium/webdriver/common/service.py and add the following parameter: - # creationflags = 0x8000000 # win32process.CREATE_NO_WINDOW - # It's pretty icky to have to do this, but since we're in a virtualenv, it's not too bad... - kwargs = { "executable_path": webdriver_path } - if "chromedriver" in webdriver_path: - options = webdriver.ChromeOptions() - options.set_headless( headless=True ) - # OMG! The chromedriver looks for Chrome/Chromium in a hard-coded, fixed location (the default - # installation directory). We offer a way here to override this. - chrome_path = app.config.get( "CHROME_PATH" ) - if chrome_path: - options.binary_location = chrome_path - kwargs["chrome_options"] = options - self.webdriver = webdriver.Chrome( **kwargs ) - elif "geckodriver" in webdriver_path: - options = webdriver.FirefoxOptions() - options.set_headless( headless=True ) - kwargs["firefox_options"] = options - kwargs["log_path"] = app.config.get( "GECKODRIVER_LOG", - os.path.join( tempfile.gettempdir(), "geckodriver.log" ) - ) - self.webdriver = webdriver.Firefox( **kwargs ) - else: - raise SimpleError( "Can't identify webdriver: {}".format( webdriver_path ) ) - return self - - def __exit__( self, exc_type, exc_val, exc_tb ): - """Clean up.""" - if self.webdriver: - self.webdriver.quit() - - def get_screenshot( self, html, window_size ): - """Get a preview screenshot of the specified HTML.""" - - self.webdriver.set_window_size( window_size[0], window_size[1] ) - with TempFile( extn=".html", mode="w" ) as html_tempfile: - - # take a screenshot of the HTML - # NOTE: We could do some funky Javascript stuff to load the browser directly from the string, - # but using a temp file is straight-forward and pretty much guaranteed to work :-/ - html_tempfile.write( html ) - html_tempfile.close() - self.webdriver.get( "file://{}".format( html_tempfile.name ) ) - with TempFile( extn=".png" ) as screenshot_tempfile: - screenshot_tempfile.close() - self.webdriver.save_screenshot( screenshot_tempfile.name ) - img = Image.open( screenshot_tempfile.name ) - - # trim the screenshot (nb: we assume a white background) - bgd = Image.new( img.mode, img.size, (255,255,255,255) ) - diff = ImageChops.difference( img, bgd ) - bbox = diff.getbbox() - return img.crop( bbox ) - -# --------------------------------------------------------------------- - def change_extn( fname, extn ): """Change a filename's extension.""" return pathlib.Path( fname ).with_suffix( extn ) diff --git a/vasl_templates/webapp/vassal.py b/vasl_templates/webapp/vassal.py index 55a8878..e610f79 100644 --- a/vasl_templates/webapp/vassal.py +++ b/vasl_templates/webapp/vassal.py @@ -18,7 +18,8 @@ from vasl_templates.webapp import app from vasl_templates.webapp.config.constants import BASE_DIR, IS_FROZEN from vasl_templates.webapp.files import vasl_mod from vasl_templates.webapp.file_server.vasl_mod import SUPPORTED_VASL_MOD_VERSIONS -from vasl_templates.webapp.utils import TempFile, HtmlScreenshots, SimpleError +from vasl_templates.webapp.utils import TempFile, SimpleError +from vasl_templates.webapp.webdriver import WebDriver _logger = logging.getLogger( "update_vsav" ) @@ -132,60 +133,39 @@ def _save_snippets( snippets, fp ): NOTE: We save the snippets as XML because Java :-/ """ - def get_html_size( snippet_id, html, window_size ): - """Get the size of the specified HTML.""" - start_time = time.time() - img = html_screenshots.get_screenshot( html, window_size ) - elapsed_time = time.time() - start_time - width, height = img.size - _logger.debug( "Generated screenshot for %s (%.3fs): %dx%d", snippet_id, elapsed_time, width, height ) - return width, height - - def do_save_snippets( html_screenshots ): #pylint: disable=too-many-locals + def do_save_snippets( webdriver ): #pylint: disable=too-many-locals """Save the snippets.""" root = ET.Element( "snippets" ) - for key,val in snippets.items(): + for snippet_id,snippet_info in snippets.items(): # add the next snippet - auto_create = "true" if val["auto_create"] else "false" - elem = ET.SubElement( root, "snippet", id=key, autoCreate=auto_create ) - elem.text = val["content"] - label_area = val.get( "label_area" ) + auto_create = "true" if snippet_info["auto_create"] else "false" + elem = ET.SubElement( root, "snippet", id=snippet_id, autoCreate=auto_create ) + elem.text = snippet_info["content"] + label_area = snippet_info.get( "label_area" ) if label_area: elem.set( "labelArea", label_area ) # add the raw content elem2 = ET.SubElement( elem, "rawContent" ) - for node in val.get("raw_content",[]): + for node in snippet_info.get( "raw_content", [] ): ET.SubElement( elem2, "phrase" ).text = node # include the size of the snippet - if html_screenshots: + if webdriver: try: - # NOTE: Screenshots take significantly longer for larger window sizes. Since most of our snippets - # will be small, we first try with a smaller window, and switch to a larger one if necessary. - max_width, max_height = 500, 500 - max_width2, max_height2 = 1500, 1500 - if key.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 - max_width, max_height = max_width2, max_height2 - width, height = get_html_size( key, val["content"], (max_width,max_height) ) - if (width >= max_width*9/10 or height >= max_height*9/10) \ - and (max_width,max_height) != (max_width2,max_height2): - # NOTE: While it's tempting to set the browser window really large here, if the label ends up - # filling/overflowing the available space (e.g. because its width/height has been set to 100%), - # then the auto-created label will push any subsequent labels far down the map, possibly to - # somewhere unreachable. So, we set it somewhat more conservatively, so that if this happens, - # the user still has a chance to recover from it. Note that this doesn't mean that they can't - # have really large labels, it just affects the positioning of auto-created labels. - width, height = get_html_size( key, val["content"], (max_width2,max_height2) ) + start_time = time.time() + img = webdriver.get_snippet_screenshot( snippet_id, snippet_info["content"] ) + width, height = img.size + elapsed_time = time.time() - start_time + _logger.debug( "Generated screenshot for %s (%.3fs): %dx%d", + snippet_id, elapsed_time, width, height + ) # FUDGE! There's something weird going on in VASSAL e.g. "" gives us something # very different to "
" :-/ Changing the font size also causes problems. # The following fudging seems to give us something that's somewhat reasonable... :-/ - if re.search( r"width:\s*?\d+?px", val["content"] ): + if re.search( r"width:\s*?\d+?px", snippet_info["content"] ): width = int( width * 140 / 100 ) elem.set( "width", str(width) ) elem.set( "height", str(height) ) @@ -201,8 +181,8 @@ def _save_snippets( snippets, fp ): if app.config.get( "DISABLE_UPDATE_VSAV_SCREENSHOTS" ): return do_save_snippets( None ) else: - with HtmlScreenshots() as html_screenshots: - return do_save_snippets( html_screenshots ) + with WebDriver() as webdriver: + return do_save_snippets( webdriver ) def _parse_label_report( fname ): """Read the label report generated by the VASSAL shim.""" diff --git a/vasl_templates/webapp/webdriver.py b/vasl_templates/webapp/webdriver.py new file mode 100644 index 0000000..3e8532e --- /dev/null +++ b/vasl_templates/webapp/webdriver.py @@ -0,0 +1,126 @@ +""" Wrapper for a Selenium webdriver. """ + +import os +import tempfile + +from selenium import webdriver +from PIL import Image, ImageChops + +from vasl_templates.webapp import app +from vasl_templates.webapp.utils import TempFile, SimpleError + +# --------------------------------------------------------------------- + +class WebDriver: + """Wrapper for a Selenium webdriver.""" + + def __init__( self ): + self.driver = None + + def start_webdriver( self ): + """Start the webdriver.""" + + # initialize + assert not self.driver + + # locate the webdriver executable + webdriver_path = app.config.get( "WEBDRIVER_PATH" ) + if not webdriver_path: + raise SimpleError( "No webdriver has been configured." ) + + # NOTE: If we are being run on Windows without a console (e.g. the frozen PyQt desktop app), + # Selenium will launch the webdriver in a visible DOS box :-( There's no way to turn this off, + # but it can be disabled by modifying the Selenium source code. Find the subprocess.Popen() call + # in $/site-packages/selenium/webdriver/common/service.py and add the following parameter: + # creationflags = 0x8000000 # win32process.CREATE_NO_WINDOW + # It's pretty icky to have to do this, but since we're in a virtualenv, it's not too bad... + + # create the webdriver + kwargs = { "executable_path": webdriver_path } + if "chromedriver" in webdriver_path: + options = webdriver.ChromeOptions() + options.set_headless( headless=True ) + # OMG! The chromedriver looks for Chrome/Chromium in a hard-coded, fixed location (the default + # installation directory). We offer a way here to override this. + chrome_path = app.config.get( "CHROME_PATH" ) + if chrome_path: + options.binary_location = chrome_path + kwargs["chrome_options"] = options + self.driver = webdriver.Chrome( **kwargs ) + elif "geckodriver" in webdriver_path: + options = webdriver.FirefoxOptions() + options.set_headless( headless=True ) + kwargs["firefox_options"] = options + kwargs["log_path"] = app.config.get( "GECKODRIVER_LOG", + os.path.join( tempfile.gettempdir(), "geckodriver.log" ) + ) + self.driver = webdriver.Firefox( **kwargs ) + else: + raise SimpleError( "Can't identify webdriver: {}".format( webdriver_path ) ) + + return self + + def stop_webdriver( self ): + """Stop the webdriver.""" + assert self.driver + self.driver.quit() + self.driver = None + + def get_screenshot( self, html, window_size, large_window_size=None ): + """Get a preview screenshot of the specified HTML.""" + + def do_get_screenshot( fname ): #pylint: disable=missing-docstring + self.driver.save_screenshot( fname ) + img = Image.open( fname ) + # trim the screenshot (nb: we assume a white background) + bgd = Image.new( img.mode, img.size, (255,255,255,255) ) + diff = ImageChops.difference( img, bgd ) + bbox = diff.getbbox() + return img.crop( bbox ) + + with TempFile(extn=".html",mode="w") as html_tempfile, TempFile(extn=".png") as screenshot_tempfile: + + # NOTE: We could do some funky Javascript stuff to load the browser directly from the string, + # but using a temp file is straight-forward and pretty much guaranteed to work :-/ + html_tempfile.write( html ) + html_tempfile.close() + self.driver.get( "file://{}".format( html_tempfile.name ) ) + + # take a screenshot of the HTML + screenshot_tempfile.close() + self.driver.set_window_size( window_size[0], window_size[1] ) + img = do_get_screenshot( screenshot_tempfile.name ) + if img.width > window_size[0]*9/10 or img.height > window_size[1]*9/10: + # the image may have been cropped - try again with a larger window size + if large_window_size: + self.driver.set_window_size( large_window_size[0], large_window_size[1] ) + img = do_get_screenshot( screenshot_tempfile.name ) + + return img + + def get_snippet_screenshot( self, snippet_id, snippet ): + """Get a screenshot for an HTML snippet.""" + + # NOTE: Screenshots take significantly longer for larger window sizes. Since most of our snippets + # will be small, we first try with a smaller window, and switch to a larger one if necessary. + # NOTE: While it's tempting to set the larger window really large here, if the label ends up + # filling/overflowing the available space (e.g. because its width/height has been set to 100%), + # then the auto-created label will push any subsequent labels far down the map, possibly to + # somewhere unreachable. So, we set it somewhat more conservatively, so that if this happens, + # the user still has a chance to recover from it. Note that this doesn't mean that they can't + # 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( + ("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 + window_size, window_size2 = window_size2, None + return self.get_screenshot( snippet, window_size, window_size2 ) + + def __enter__( self ): + self.start_webdriver() + return self + + def __exit__( self, *args ): + self.stop_webdriver()