Create attractive VASL scenarios, with loads of useful information embedded to assist with game play. https://vasl-templates.org
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
vasl-templates/vasl_templates/webapp/utils.py

368 lines
12 KiB

""" Miscellaneous utilities. """
import os
import shutil
import subprocess
import io
import tempfile
import pathlib
import math
import re
import logging
from collections import defaultdict
from flask import request, Response, send_file
from PIL import Image, ImageChops
from vasl_templates.webapp import app
# ---------------------------------------------------------------------
class MsgStore:
"""Store different types of messages."""
def __init__( self ):
self._msgs = None
self.reset()
def reset( self ):
"""Reset the MsgStore."""
self._msgs = defaultdict( list )
def info( self, msg, *args, **kwargs ):
"""Add an informational message."""
self._add_msg( "info", msg, *args, **kwargs )
def warning( self, msg, *args, **kwargs ):
"""Add a warning message."""
self._add_msg( "warning", msg, *args, **kwargs )
def error( self, msg, *args, **kwargs ):
"""Add an error message."""
self._add_msg( "error", msg, *args, **kwargs )
def get_msgs( self, msg_type ):
"""Get stored messages."""
return self._msgs[ msg_type ]
def _add_msg( self, msg_type, msg, *args, **kwargs ):
"""Add a message to the store."""
logger = kwargs.pop( "logger", None )
if args or kwargs:
# NOTE: We only format the message if there are any parameters, to handle the case
# where the caller passed us a single string that happens to contain a {.
msg = msg.format( *args, **kwargs )
self._msgs[ msg_type ].append( msg )
if logger:
func = getattr( logger, "warn" if msg_type == "warning" else msg_type )
func( msg )
# ---------------------------------------------------------------------
class TempFile:
"""Manage a temp file that can be closed while it's still being used."""
def __init__( self, mode="wb", extn=None, encoding=None ):
self.mode = mode
self.extn = extn
self.encoding = encoding
self.temp_file = None
self.name = None
def open( self ):
"""Allocate a temp file."""
if self.encoding:
encoding = self.encoding
else:
encoding = "utf-8" if "b" not in self.mode else None
assert self.temp_file is None
self.temp_file = tempfile.NamedTemporaryFile( #pylint: disable=consider-using-with
mode = self.mode,
encoding = encoding,
suffix = self.extn,
delete = False
)
self.name = self.temp_file.name
def close( self, delete ):
"""Close the temp file."""
self.temp_file.close()
if delete:
os.unlink( self.temp_file.name )
def write( self, data ):
"""Write data to the temp file."""
self.temp_file.write( data )
def save_copy( self, fname, logger, caption ):
"""Make a copy of the temp file (for debugging porpoises)."""
if not fname:
return
logger.debug( "Saving a copy of the %s: %s", caption, fname )
shutil.copyfile( self.temp_file.name, fname )
def __enter__( self ):
"""Enter the context manager."""
self.open()
return self
def __exit__( self, exc_type, exc_val, exc_tb ):
"""Exit the context manager."""
self.close( delete=True )
# ---------------------------------------------------------------------
def read_text_file( fname ):
"""Read a text file."""
# NOTE: There are several places where we read user-generated files (e.g. template packs, Chapter H notes),
# which contain HTML content, so the ideal case is that they be plain ASCII, with special characters specified
# as HTML entities. However, people are copy-and-pasting Chapter H content from their eASLRB's, which means
# we need to handle encoding. chardet is overkill for what we need, and we simply try the most common cases.
encodings = app.config.get( "TEXT_FILE_ENCODINGS", "ascii,utf-8,windows-1252,iso-8859-1" )
with open( fname, "rb" ) as fp:
buf = fp.read()
if buf[0:3] == b"\xEF\xBB\xBF":
buf = buf[3:]
encodings = "utf-8"
for enc in encodings.split( "," ):
try:
return buf.decode( enc.strip() )
except UnicodeDecodeError:
pass
msg = "Can't decode text file: {}".format( fname )
logging.warning( msg )
return msg
# ---------------------------------------------------------------------
def resize_image_response( resp, default_width=None, default_height=None, default_scaling=None ):
"""Resize an image that will be returned as a Flask response."""
assert isinstance( resp, Response )
def get_image():
"""Get the the image from the Flask response that was passed in."""
resp.direct_passthrough = False
buf = io.BytesIO()
buf.write( resp.get_data() )
buf.seek( 0 )
return Image.open( buf )
# check if the caller specified a width and/or height
width = request.args.get( "width", default_width )
height = request.args.get( "height", default_height )
if width and height:
# width and height were specified, just use them as-is
img = get_image()
width = int( width )
height = int( height )
elif width and not height:
# width only, calculate the height
img = get_image()
aspect_ratio = float(img.size[0]) / float(img.size[1])
height = int(width) / aspect_ratio
elif not width and height:
# height only, calculate the width
img = get_image()
aspect_ratio = float(img.size[0]) / float(img.size[1])
width = int(height) * aspect_ratio
elif not width and not height:
# check if the caller specified a scaling factor
scaling = request.args.get( "scaling", default_scaling )
if scaling and scaling != 100:
img = get_image()
width = img.size[0] * float(scaling)/100
height = img.size[1] * float(scaling)/100
# check if we need to resize the image
if width or height:
assert width and height
# yup - make it so
img = img.resize( (int(width),int(height)), Image.Resampling.LANCZOS )
buf = io.BytesIO()
img.save( buf, format="PNG" )
buf.seek( 0 )
return send_file( buf, mimetype="image/png" )
else:
# nope - return the image as-is
return resp
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def trim_image( img ):
"""Trim whitespace from an image."""
if isinstance( img, str ):
img = Image.open( img )
# trim the screenshot (nb: we assume a white background)
img = remove_alpha_from_image( img )
bgd = Image.new( img.mode, img.size, (255,255,255) )
diff = ImageChops.difference( img, bgd )
bbox = diff.getbbox()
return img.crop( bbox )
def get_image_data( img, **kwargs ):
"""Get the data from a Pillow image."""
buf = io.BytesIO()
img.save( buf, format=kwargs.pop("format","PNG"), **kwargs )
buf.seek( 0 )
return buf.read()
def remove_alpha_from_image( img ):
"""Remove the alpha channel from an image."""
return img.convert( "RGB" )
# ---------------------------------------------------------------------
def get_java_version():
"""Get the version of the configured Java runtime."""
java_path = get_java_path()
if not java_path:
return None
try:
args = [ java_path, "-version" ]
kwargs = {
"capture_output": True, "text": True,
"stdin": subprocess.DEVNULL,
}
if is_windows():
kwargs["creationflags"] = 0x8000000 # nb: win32process.CREATE_NO_WINDOW
proc = subprocess.run( args, check=True, **kwargs )
return proc.stderr.split( "\n" )[0]
except Exception as ex: #pylint: disable=broad-except
logging.error( "Can't get Java version: %s", ex )
return "???"
def get_java_path():
"""Locate the Java runtime."""
# get the configured path to Java
java_path = app.config.get( "JAVA_PATH" )
# check if we should use the Java that now comes bundled with VASSAL
if not java_path and is_windows():
vassal_dir = app.config.get( "VASSAL_DIR" )
if vassal_dir:
fname = os.path.join( vassal_dir, "jre/bin/java.exe" )
if os.path.isfile( fname ):
java_path = fname
# check the PATH
if not java_path:
java_path = shutil.which( "java" )
return java_path
# ---------------------------------------------------------------------
def change_extn( fname, extn ):
"""Change a filename's extension."""
return pathlib.Path( fname ).with_suffix( extn )
def is_image_file( fname ):
"""Check if a file is an image."""
if fname.startswith( "." ):
extn = fname
else:
extn = os.path.splitext( fname )[1]
return extn.lower() in (".png",".jpg",".jpeg",".gif")
def is_empty_file( fname ):
"""Check if a file is empty."""
return os.stat( fname ).st_size == 0
def parse_int( val, default=None ):
"""Parse an integer."""
try:
return int( val )
except (ValueError, TypeError):
return default
def is_windows():
"""Check if we're running on Windows."""
return os.name == "nt"
# ---------------------------------------------------------------------
def compare_version_strings( lhs, rhs ):
"""Compare two version strings."""
def parse( val ): #pylint: disable=missing-docstring
mo = re.search( r"^(\d+)\.(\d+)\.(\d+)(.\d+)?$", val )
last = int( mo.group(4)[1:] ) if mo.group(4) else 0
return ( int(mo.group(1)), int(mo.group(2)), int(mo.group(3)), last )
lhs, rhs = parse(lhs), parse(rhs)
if lhs < rhs:
return -1
elif lhs > rhs:
return +1
else:
return 0
def friendly_fractions( val, postfix=None, postfix2=None ):
"""Convert decimal values to more friendly fractions."""
if val is None:
return None
frac, val = math.modf( float( val ) )
if frac >= 0.875:
val = str( int(val) + 1 )
else:
val = str( int( val ) )
if frac >= 0.625:
val = val + "&frac34;"
elif frac >= 0.375:
val = val + "&frac12;"
elif frac >= 0.125:
val = val + "&frac14;"
if postfix:
if val == "0":
return "0 " + postfix2
elif val.startswith( "0&" ):
return val[1:] + " " + postfix
elif val == "1":
return "1 " + postfix
val = "{} {}".format( val, postfix2 )
return val[1:] if val.startswith( "0&" ) else val
def friendly_byte_count( nbytes ):
"""Return a byte count as a friendly string."""
if nbytes < 1024:
return plural( nbytes, "byte" )
if nbytes < 1024 * 1024:
return "{:.1f} KB".format( nbytes / 1024 )
if nbytes < 1024 * 1024 * 1024:
return "{:.1f} MB".format( nbytes / 1024 / 1024 )
return "{:.1f} GB".format( nbytes / 1024 / 1024 / 1024 )
def plural( n, val1, val2=None ):
"""Return a pluralized string."""
if n == 1:
return "1 {}".format( val1 )
return "{:,} {}".format( n, val2 or val1+"s" )
# ---------------------------------------------------------------------
_MONTH_NAMES = [ # nb: we assume English :-/
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
]
_DAY_OF_MONTH_POSTFIXES = { # nb: we assume English :-/
0: "th",
1: "st", 2: "nd", 3: "rd", 4: "th", 5: "th", 6: "th", 7: "th", 8: "th", 9: "th", 10: "th",
11: "th", 12: "th", 13: "th"
}
def get_month_name( month ):
"""Return a month name."""
return _MONTH_NAMES[ month-1 ]
def make_formatted_day_of_month( dom ):
"""Generate a formatted day of the month."""
if dom in _DAY_OF_MONTH_POSTFIXES:
return str(dom) + _DAY_OF_MONTH_POSTFIXES[ dom ]
else:
return str(dom) + _DAY_OF_MONTH_POSTFIXES[ dom % 10 ]
# ---------------------------------------------------------------------
class SimpleError( Exception ):
"""Represents a simple error that doesn't require a stack trace (e.g. bad configuration)."""