Cmglee
Joined 5 January 2016
Dynamic SVG for Wikimedia projects:
Exploring applications, techniques and best practice for interactive and animated vector graphics | ||
Techniques
|
Animation
|
||
Error: Image is invalid or non-existent. |
Tooltips
|
|||||||||||||||||
Hyperlinks
|
Progressive disclosure
|
|||||||||||
Applications
Interactive timelines
|
Simple 3D viewer
|
GIF animation to SVG converter
|
||
Source code for Python 2:
#!/usr/bin/env python
import re, json
## http://stackoverflow.com/questions/3503879
import subprocess, sys
def system(command, is_verbose=False):
if (is_verbose): sys.stdout.write(command) ## write omits newline
stdout = subprocess.check_output(command, shell=True)
if (is_verbose): print(": " + stdout)
return stdout
import os.path ## to check if file exists
def mkdir_cache(is_refresh_cache=False, is_verbose=False, suffix='.cache/'):
basename = __file__[:__file__.rfind('.')]
dir_cache = basename + suffix
if (is_refresh_cache):
for (dir, dirs, filenames) in os.walk(dir_cache, topdown=False): os.rmdir(dir)
if (is_verbose): print("delete {dir_cache}".format(**locals()))
if (not os.path.exists(dir_cache)):
if (is_verbose): print("make {dir_cache}".format(**locals()))
os.makedirs(dir_cache)
elif (is_verbose): print("{dir_cache} already exists".format(**locals()))
return dir_cache
## http://www.techrepublic.com/article/parsing-data-from-the-web-in-python/
import urllib2, time ## urllib2 for web access, time for sleep
def read_webpage(url, path_cache='', is_refresh_cache=False, is_verbose=False):
dir_cache = mkdir_cache(is_refresh_cache=is_refresh_cache, is_verbose=is_verbose)
if (not path_cache): path_cache = dir_cache + urllib2.quote(url, safe='')
if (is_refresh_cache or (not os.path.isfile(path_cache))):
html = urllib2.urlopen(url).read()
file_html = open(path_cache, 'wb')
file_html.write(html)
if (is_verbose): print("fetch {url} into {path_cache}".format(**locals()))
time.sleep(1) ## avoid rate-limit-exceeded error
else:
file_html = open(path_cache)
html = file_html.read()
if (is_verbose): print("read from {path_cache}".format(**locals()))
file_html.close()
return html
## http://stackoverflow.com/questions/3715493
import base64
def base64_encode(path):
with open(path, 'rb') as file: return base64.b64encode(file.read())
def make_svg(url, increment, message_action):
if (message_action == '3D' ): message_action = 'to rotate the 3D model'
if (message_action == 'time'): message_action = 'to move through time'
## Get image URL if description page URL given
dir_cache = mkdir_cache()
filename = url[url.rfind('/')+1:]
if (filename.lower().find('file:') == 0):
filename = filename[filename.rfind(':') + 1:]
path_html = '{dir_cache}{filename}.htm'.format(**locals())
html = read_webpage(url, path_html, is_verbose=True)
url = re.search(r'http.*?//upload\.[^"]+', html).group(0)
## Fetch image if needed
basename = filename[:filename.rfind('.')]
path_gif = dir_cache + filename
path_basename = path_gif[:path_gif.rfind('.')]
read_webpage(url, path_gif, is_verbose=True)
## Extract GIF animation frames if needed
if (os.path.isfile('{dir_cache}{basename}-0.png'.format(**locals()))):
print("skip extracting GIF animation frames")
else:
print("extract GIF animation frames")
system('magick "{path_gif}" -coalesce "{path_basename}.png"'.format(**locals()), is_verbose=True)
## Base64-encode frames if needed
path_json = dir_cache + basename + '.json'
jsons = {}
n_image = 0
n_frame = 0
if (0):
# if (os.path.isfile(path_json)):
file_json = open(path_json, 'r')
jsons = json.loads(file_json.read())
n_frame = jsons['n_frame']
out_image = jsons['out_image']
width_image = jsons['width_image']
height_image = jsons['height_image']
else:
## Count frames
n_image = 0
while (os.path.isfile('{dir_cache}{basename}-{n_image}.png'.format(**locals()))): n_image += 1
## Base64-encode relevant frames
n_frame = int(n_image / abs(increment))
out_images = []
for i_frame in range(n_frame):
i_image = i_frame * increment + (0 if (increment > 0) else n_image + increment)
path_frame = '{dir_cache}{basename}-{i_image}.png'.format(**locals())
stdout = system('magick "{path_frame}" info:'.format(**locals()), is_verbose=True)
(width_image, height_image) = [int(dim) for dim in re.search(r'\d+x\d+', stdout).group(0).split('x')]
base64_encoded = base64_encode(path_frame)
out_images.append('''\
<image id="image_{i_frame}" x="0" y="0" width="{width_image}" height="{height_image}" xlink:href="data:image/png;base64,{base64_encoded}"/>\
'''.format(**locals()))
out_image = '\n'.join(out_images)
jsons = {'out_image':out_image, 'width_image' :width_image,
'n_frame' :n_frame , 'height_image':height_image}
file_json = open(path_json, 'w')
try: ## use try/finally so that file is closed even if write fails
file_json.write(json.dumps(jsons, indent=1, separators=(',',':')))
finally:
file_json.close()
## Create SVG
out_mains = []
scale_thumbnail = round(1.0 / n_frame, 5)
height_trigger = int(height_image * (scale_thumbnail + 1) + 0.5)
width_trigger = round(width_image * scale_thumbnail, 2)
width_thumbnail = int(width_trigger + 0.9999)
for i_frame in range(n_frame):
x_trigger = round(i_frame * width_trigger, 2)
out_mains.append('''\
<g class="frame">
<g class="content">
<use xlink:href="#image_{i_frame}"/>
</g>
<g class="trigger" transform="translate({x_trigger},{height_image})">
<use xlink:href="#image_{i_frame}" transform="scale({scale_thumbnail})"/>
<use xlink:href="#triggers"/>
</g>
<!-- <title>frame {i_frame}</title> -->
</g>'''.format(**locals()))
out_main = '\n'.join(out_mains)
title = basename.replace('_', ' ')
stroke_width = max(width_image, height_image) / 200
font_size = width_image / 20
x_help = width_image / 2
y_help = height_image / 2
height_thumbnail = height_image * scale_thumbnail - stroke_width / 2
width_images = [width_image * multiple for multiple in range(99)]
## Compile everything into an .svg file
file_out = open(basename + '.svg', 'w')
try: ## use try/finally so that file is closed even if write fails
print("write SVG")
file_out.write('''<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%" viewBox="0 0 {width_image} {height_trigger}">
<title>{title}</title>
<desc>Interactive SVG by CMG Lee of the GIF animation at {url} . Move left and right over the SVG image {message_action}.</desc>
<style type="text/css">
#main {{ font-family:Helvetica,Arial,sans-serif; font-size:{font_size}px; text-anchor:middle;
stroke-width:{stroke_width}; fill:#000000; }}
#trigger {{ stroke:none; fill-opacity:0; }}
.frame .content {{ visibility:hidden; pointer-events:none; fill:#000000; }}
.frame .trigger {{ opacity:0.5; cursor:ew-resize; }}
.frame:hover .content {{ visibility:visible; }}
.frame:hover .trigger {{ opacity:1; pointer-events:auto; font-weight:bold; stroke:#ff0000; }}
</style>
<defs>
<g id="help">
<text x="{x_help}" y="{y_help}" dy="-1ex">Move left and right</text>
<text x="{x_help}" y="{y_help}" dy="1ex">{message_action}</text>
</g>
<rect id="trigger" x="0" y="-4999" width="{width_thumbnail}" height="9999"/>
<g id="triggers">
<rect x="0" y="0" width="{width_thumbnail}" height="{height_thumbnail}" fill="none"/>
<use xlink:href="#trigger"/>
<use xlink:href="#trigger" transform="translate(-{width_images[1]},0)"/>
<use xlink:href="#trigger" transform="translate( {width_images[1]},0)"/>
<use xlink:href="#trigger" transform="translate(-{width_images[2]},0)"/>
<use xlink:href="#trigger" transform="translate( {width_images[2]},0)"/>
<use xlink:href="#trigger" transform="translate(-{width_images[3]},0)"/>
<use xlink:href="#trigger" transform="translate( {width_images[3]},0)"/>
<use xlink:href="#trigger" transform="translate(-{width_images[4]},0)"/>
<use xlink:href="#trigger" transform="translate( {width_images[4]},0)"/>
</g>
{out_image}
</defs>
<g id="main">
<circle cx="0" cy="0" r="9999" fill="#ffffff"/>
<use xlink:href="#image_0" opacity="0.5"/>
<use xlink:href="#help" stroke-opacity="0.5" stroke="#ffffff"/>
<use xlink:href="#help"/>
<g id="frames">
{out_main}
</g>
</g>
</svg>
'''.format(**locals()))
finally:
file_out.close()
n_argv = len(sys.argv)
if (n_argv < 2):
print(("usage: {sys.argv[0]} <URL of GIF animation file> [<use every nth GIF frame;" +
" negative reverses order>] [<action message or '3D' or 'time'>]")
.format(**locals()))
else:
make_svg( sys.argv[1],
int(sys.argv[2]) if (n_argv > 2) else 1,
sys.argv[3] if (n_argv > 3) else '3D')
Source code for Python 3:
#!/usr/bin/env python
import re, json
## http://stackoverflow.com/questions/3503879
import subprocess, sys
def system(command, is_verbose=False):
if (is_verbose): sys.stdout.write(command) ## write omits newline
stdout = str(subprocess.check_output(command, shell=True))
if (is_verbose): print(": " + stdout)
return stdout
import os.path ## to check if file exists
def mkdir_cache(is_refresh_cache=False, is_verbose=False, suffix='.cache/'):
basename = __file__[:__file__.rfind('.')]
dir_cache = basename + suffix
if (is_refresh_cache):
for (dir, dirs, filenames) in os.walk(dir_cache, topdown=False): os.rmdir(dir)
if (is_verbose): print("delete {dir_cache}".format(**locals()))
if (not os.path.exists(dir_cache)):
if (is_verbose): print("make {dir_cache}".format(**locals()))
os.makedirs(dir_cache)
elif (is_verbose): print("{dir_cache} already exists".format(**locals()))
return dir_cache
## http://www.techrepublic.com/article/parsing-data-from-the-web-in-python/
import urllib.request as urllib2, time ## urllib2 for web access, time for sleep
def read_webpage(url, path_cache='', is_refresh_cache=False, is_verbose=False):
dir_cache = mkdir_cache(is_refresh_cache=is_refresh_cache, is_verbose=is_verbose)
if (not path_cache): path_cache = dir_cache + urllib2.quote(url, safe='')
if (is_refresh_cache or (not os.path.isfile(path_cache))):
html = urllib2.urlopen(url).read()
file_html = open(path_cache, 'wb')
file_html.write(html)
if (is_verbose): print("fetch {url} into {path_cache}".format(**locals()))
time.sleep(1) ## avoid rate-limit-exceeded error
else:
file_html = open(path_cache, 'rb')
html = file_html.read()
if (is_verbose): print("read from {path_cache}".format(**locals()))
file_html.close()
return html
## http://stackoverflow.com/questions/3715493
import base64
def base64_encode(path):
with open(path, 'rb') as file: return base64.b64encode(file.read()).decode('ascii')
def make_svg(url, increment, message_action):
if (message_action == '3D' ): message_action = 'to rotate the 3D model'
if (message_action == 'time'): message_action = 'to move through time'
## Get image URL if description page URL given
dir_cache = mkdir_cache()
filename = url[url.rfind('/')+1:]
if (filename.lower().find('file:') == 0):
filename = filename[filename.rfind(':') + 1:]
path_html = '{dir_cache}{filename}.htm'.format(**locals())
html = read_webpage(url, path_html, is_verbose=True)
url = re.search(r'http.*?//upload\.[^"]+', html).group(0)
print(url)
## Fetch image if needed
basename = filename[:filename.rfind('.')]
path_gif = dir_cache + filename
path_basename = path_gif[:path_gif.rfind('.')]
read_webpage(url, path_gif, is_verbose=True)
## Extract GIF animation frames if needed
if (os.path.isfile('{dir_cache}{basename}-0.png'.format(**locals()))):
print("skip extracting GIF animation frames")
else:
print("extract GIF animation frames")
system('magick "{path_gif}" -coalesce "{path_basename}.png"'.format(**locals()), is_verbose=True)
## Base64-encode frames if needed
path_json = dir_cache + basename + '.json'
jsons = {}
n_image = 0
n_frame = 0
if (0):
# if (os.path.isfile(path_json)):
file_json = open(path_json, 'r')
jsons = json.loads(file_json.read())
n_frame = jsons['n_frame']
out_image = jsons['out_image']
width_image = jsons['width_image']
height_image = jsons['height_image']
else:
## Count frames
n_image = 0
while (os.path.isfile('{dir_cache}{basename}-{n_image}.png'.format(**locals()))): n_image += 1
## Base64-encode relevant frames
n_frame = int(n_image / abs(increment))
out_images = []
for i_frame in range(n_frame):
i_image = i_frame * increment + (0 if (increment > 0) else n_image + increment)
path_frame = '{dir_cache}{basename}-{i_image}.png'.format(**locals())
stdout = system('magick "{path_frame}" info:'.format(**locals()), is_verbose=True)
(width_image, height_image) = [int(dim) for dim in re.search(r'\d+x\d+', stdout).group(0).split('x')]
base64_encoded = base64_encode(path_frame)
out_images.append('''\
<image id="image_{i_frame}" x="0" y="0" width="{width_image}" height="{height_image}" xlink:href="data:image/png;base64,{base64_encoded}"/>\
'''.format(**locals()))
out_image = '\n'.join(out_images)
jsons = {'out_image':out_image, 'width_image' :width_image,
'n_frame' :n_frame , 'height_image':height_image}
file_json = open(path_json, 'w')
try: ## use try/finally so that file is closed even if write fails
file_json.write(json.dumps(jsons, indent=1, separators=(',',':')))
finally:
file_json.close()
## Create SVG
out_mains = []
scale_thumbnail = round(1.0 / n_frame, 5)
height_trigger = int(height_image * (scale_thumbnail + 1) + 0.5)
width_trigger = round(width_image * scale_thumbnail, 2)
width_thumbnail = int(width_trigger + 0.9999)
for i_frame in range(n_frame):
x_trigger = round(i_frame * width_trigger, 2)
out_mains.append('''\
<g class="frame">
<g class="content">
<use xlink:href="#image_{i_frame}"/>
</g>
<g class="trigger" transform="translate({x_trigger},{height_image})">
<use xlink:href="#image_{i_frame}" transform="scale({scale_thumbnail})"/>
<use xlink:href="#triggers"/>
</g>
<!-- <title>frame {i_frame}</title> -->
</g>'''.format(**locals()))
out_main = '\n'.join(out_mains)
title = basename.replace('_', ' ')
stroke_width = max(width_image, height_image) / 200
font_size = width_image / 20
x_help = width_image / 2
y_help = height_image / 2
height_thumbnail = height_image * scale_thumbnail - stroke_width / 2
width_images = [width_image * multiple for multiple in range(99)]
## Compile everything into an .svg file
file_out = open(basename + '.svg', 'w')
try: ## use try/finally so that file is closed even if write fails
print("write SVG")
file_out.write('''<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%" viewBox="0 0 {width_image} {height_trigger}">
<title>{title}</title>
<desc>Interactive SVG by CMG Lee of the GIF animation at {url} . Move left and right over the SVG image {message_action}.</desc>
<style type="text/css">
#main {{ font-family:Helvetica,Arial,sans-serif; font-size:{font_size}px; text-anchor:middle;
stroke-width:{stroke_width}; fill:#000000; }}
#trigger {{ stroke:none; fill-opacity:0; }}
.frame .content {{ visibility:hidden; pointer-events:none; fill:#000000; }}
.frame .trigger {{ opacity:0.5; cursor:ew-resize; }}
.frame:hover .content {{ visibility:visible; }}
.frame:hover .trigger {{ opacity:1; pointer-events:auto; font-weight:bold; stroke:#ff0000; }}
</style>
<defs>
<g id="help">
<text x="{x_help}" y="{y_help}" dy="-1ex">Move left and right</text>
<text x="{x_help}" y="{y_help}" dy="1ex">{message_action}</text>
</g>
<rect id="trigger" x="0" y="-4999" width="{width_thumbnail}" height="9999"/>
<g id="triggers">
<rect x="0" y="0" width="{width_thumbnail}" height="{height_thumbnail}" fill="none"/>
<use xlink:href="#trigger"/>
<use xlink:href="#trigger" transform="translate(-{width_images[1]},0)"/>
<use xlink:href="#trigger" transform="translate( {width_images[1]},0)"/>
<use xlink:href="#trigger" transform="translate(-{width_images[2]},0)"/>
<use xlink:href="#trigger" transform="translate( {width_images[2]},0)"/>
<use xlink:href="#trigger" transform="translate(-{width_images[3]},0)"/>
<use xlink:href="#trigger" transform="translate( {width_images[3]},0)"/>
<use xlink:href="#trigger" transform="translate(-{width_images[4]},0)"/>
<use xlink:href="#trigger" transform="translate( {width_images[4]},0)"/>
</g>
{out_image}
</defs>
<g id="main">
<circle cx="0" cy="0" r="9999" fill="#ffffff"/>
<use xlink:href="#image_0" opacity="0.5"/>
<use xlink:href="#help" stroke-opacity="0.5" stroke="#ffffff"/>
<use xlink:href="#help"/>
<g id="frames">
{out_main}
</g>
</g>
</svg>
'''.format(**locals()))
finally:
file_out.close()
n_argv = len(sys.argv)
if (n_argv < 2):
print(("usage: {sys.argv[0]} <URL of GIF animation file> [<use every nth GIF frame;" +
" negative reverses order>] [<action message or '3D' or 'time'>]")
.format(**locals()))
else:
make_svg( sys.argv[1],
int(sys.argv[2]) if (n_argv > 2) else 1,
sys.argv[3] if (n_argv > 3) else '3D')
Best practice
- Degrade gracefully on less well-endowed browsers e.g.
- Fall back on CSS hover effects if SMIL click effects unsupported
- Check tooltips read fine if newlines are replaced with spaces
- Touchscreens have no hover: click includes hover
- To maintain click effect, hyperlink an icon
- Check thumbnail is OK
- Add link to SVG file in caption
#Cheers ⤸
Cheers
Thank you!
Any questions? Wanna collaborate? |
||