From 4d082e44e8361ca32360bb5b865be98a784ef4db Mon Sep 17 00:00:00 2001 From: Jakub Steiner Date: Wed, 10 May 2023 12:58:58 +0200 Subject: port cursor tooling to python3 Fixes https://gitlab.gnome.org/GNOME/adwaita-icon-theme/-/issues/234 --- src/cursors/anicursorgen.py | 804 +++++++++++++---------- src/cursors/renderpngs.py | 1523 ++++++++++++++++++++++++------------------- 2 files changed, 1321 insertions(+), 1006 deletions(-) diff --git a/src/cursors/anicursorgen.py b/src/cursors/anicursorgen.py index ad4b7c07e..906804806 100755 --- a/src/cursors/anicursorgen.py +++ b/src/cursors/anicursorgen.py @@ -1,4 +1,4 @@ -#!/usr/bin/python2 +#!/usr/bin/python3 # -*- coding: utf-8 -*- # anicursorgen # Copyright (C) 2015 Руслан Ижбулатов @@ -25,368 +25,496 @@ import shlex import io import struct import math +import functools from PIL import Image from PIL import ImageFilter p = struct.pack -program_name = 'anicursorgen' +program_name = "anicursorgen" program_version = 1.0 -def main (): - parser = argparse.ArgumentParser (description='Creates .ani or .cur files from separate images and input metadata.', - add_help=False) - parser.add_argument ('-V', '--version', action='version', version='{}-{}'.format (program_name, program_version), - help='Display the version number and exit.') - parser.add_argument ('-h', '-?', action='help', - help='Display the usage message and exit.') - parser.add_argument ('-p', '--prefix', metavar='dir', default=None, - help='Find cursor images in the directory specified by dir. If not specified, the current directory is used.') - parser.add_argument ('-s', '--add-shadows', action='store_true', dest='add_shadows', default=False, - help='Generate shadows for cursors (disabled by default).') - parser.add_argument ('-n', '--no-shadows', action='store_false', dest='add_shadows', default=False, - help='Do not generate shadows for cursors (put after --add-shadows to cancel its effect).') - - shadows = parser.add_argument_group (title='Shadow generation', description='Only relevant when --add-shadows is given') - - shadows.add_argument ('-r', '--right-shift', metavar='%', type=float, default=9.375, - help='Shift shadow right by this percentage of the canvas size (default is 9.375).') - shadows.add_argument ('-d', '--down-shift', metavar='%', type=float, default=3.125, - help='Shift shadow down by this percentage of the canvas size (default is 3.125).') - shadows.add_argument ('-b', '--blur', metavar='%', type=float, default=3.125, - help='Blur radius, in percentage of the canvas size (default is 3.125, set to 0 to disable blurring).') - shadows.add_argument ('-c', '--color', metavar='%', default='0x00000040', - help='Shadow color in 0xRRGGBBAA form (default is 0x00000040).') - - parser.add_argument ('input_config', default='-', metavar='input-config [output-file]', nargs='?', - help='Input config file (stdin by default).') - parser.add_argument ('output_file', default='-', metavar='', nargs='?', - help='Output cursor file (stdout by default).') - - args = parser.parse_args () - - try: - if args.color[0] != '0' or args.color[1] not in ['x', 'X'] or len (args.color) != 10: - raise ValueError - args.color = (int (args.color[2:4], 16), int (args.color[4:6], 16), int (args.color[6:8], 16), int (args.color[8:10], 16)) - except: - print ("Can't parse the color '{}'".format (args.color), file=sys.stderr) - parser.print_help () - return 1 - - if args.prefix is None: - args.prefix = os.getcwd () - - if args.input_config == '-': - input_config = sys.stdin - else: - input_config = open (args.input_config, 'rb') - - if args.output_file == '-': - output_file = sys.stdout - else: - output_file = open (args.output_file, 'wb') - - result = make_cursor_from (input_config, output_file, args) - - input_config.close () - output_file.close () - - return result - -def make_cursor_from (inp, out, args): - frames = parse_config_from (inp, args.prefix) - - animated = frames_have_animation (frames) - - if animated: - result = make_ani (frames, out, args) - else: - buf = make_cur (frames, args) - copy_to (out, buf) - result = 0 - - return result - -def copy_to (out, buf): - buf.seek (0, io.SEEK_SET) - while True: - b = buf.read (1024) - if len (b) == 0: - break - out.write (b) - -def frames_have_animation (frames): - sizes = set () - - for frame in frames: - if frame[4] == 0: - continue - if frame[0] in sizes: - return True - sizes.add (frame[0]) - - return False - -def make_cur (frames, args, animated=False): - buf = io.BytesIO () - buf.write (p (' f2[0]: - return 1 - else: - return 0 - - frames = sorted (frames, frame_size_cmp, reverse=True) - - for frame in frames: - width = frame[0] - if width > 255: - width = 0 - height = width - buf.write (p ('= len (framesets): - framesets.append ([]) + return result - framesets[counter].append (frame) - counter += 1 - for i in range (1, len (framesets)): - if len (framesets[i - 1]) != len (framesets[i]): - print ("Frameset {} has size {}, expected {}".format (i, len (framesets[i]), len (framesets[i - 1])), file=sys.stderr) - return None +def make_cursor_from(inp, out, args): + frames = parse_config_from(inp, args.prefix) - for frameset in framesets: - for i in range (1, len (frameset)): - if frameset[i - 1][4] != frameset[i][4]: - print ("Frameset {} has duration {} for framesize {}, but {} for framesize {}".format (i, frameset[i][4], frameset[i][0], frameset[i - 1][4], frameset[i - 1][0]), file=sys.stderr) - return None + animated = frames_have_animation(frames) - def frameset_size_cmp (f1, f2): - if f1[0][0] < f2[0][0]: - return -1 - elif f1[0][0] > f2[0][0]: - return 1 + if animated: + result = make_ani(frames, out, args) else: - return 0 - - framesets = sorted (framesets, frameset_size_cmp, reverse=True) + buf = make_cur(frames, args) + copy_to(out, buf) + result = 0 + + return result - return framesets -def make_ani (frames, out, args): - framesets = make_framesets (frames) - if framesets is None: - return 1 +def copy_to(out, buf): + buf.seek(0, io.SEEK_SET) + while True: + b = buf.read(1024) + if len(b) == 0: + break + out.write(b) + + +def frames_have_animation(frames): + sizes = set() + + for frame in frames: + if frame[4] == 0: + continue + if frame[0] in sizes: + return True + sizes.add(frame[0]) + + return False + + +def make_cur(frames, args, animated=False): + buf = io.BytesIO() + buf.write(p(" f2[0]: + return 1 + else: + return 0 + + frames = sorted(frames, key=functools.cmp_to_key(frame_size_cmp), reverse=True) + + for frame in frames: + width = frame[0] + if width > 255: + width = 0 + height = width + buf.write(p("= len(framesets): + framesets.append([]) + + framesets[counter].append(frame) + counter += 1 + + for i in range(1, len(framesets)): + if len(framesets[i - 1]) != len(framesets[i]): + print( + "Frameset {} has size {}, expected {}".format( + i, len(framesets[i]), len(framesets[i - 1]) + ), + file=sys.stderr, + ) + return None - buf = io.BytesIO () + for frameset in framesets: + for i in range(1, len(frameset)): + if frameset[i - 1][4] != frameset[i][4]: + print( + "Frameset {} has duration {} for framesize {}, but {} for framesize {}".format( + i, + frameset[i][4], + frameset[i][0], + frameset[i - 1][4], + frameset[i - 1][0], + ), + file=sys.stderr, + ) + return None + + def frameset_size_cmp(f1, f2): + if f1[0][0] < f2[0][0]: + return -1 + elif f1[0][0] > f2[0][0]: + return 1 + else: + return 0 + + framesets = sorted(framesets, key=functools.cmp_to_key(frameset_size_cmp), reverse=True) + + return framesets + + +def make_ani(frames, out, args): + framesets = make_framesets(frames) + if framesets is None: + return 1 + + buf = io.BytesIO() + + buf.write(b"RIFF") + riff_len_pos = buf.seek(0, io.SEEK_CUR) + buf.write(p(" 4: - try: - duration = int (words[4]) - except: - continue - else: - duration = 0 - - frames.append ((size, hotx, hoty, filename, duration)) - - return frames - -def create_shadow (orig, args): - blur_px = orig.size[0] / 100.0 * args.blur - right_px = int (orig.size[0] / 100.0 * args.right_shift) - down_px = int (orig.size[1] / 100.0 * args.down_shift) - - shadow = Image.new ('RGBA', orig.size, (0, 0, 0, 0)) - shadowize (shadow, orig, args.color) - shadow.load () - - if args.blur > 0: - crop = (int (math.floor (-blur_px)), int (math.floor (-blur_px)), orig.size[0] + int (math.ceil (blur_px)), orig.size[1] + int (math.ceil (blur_px))) - right_px += int (math.floor (-blur_px)) - down_px += int (math.floor (-blur_px)) - shadow = shadow.crop (crop) - flt = ImageFilter.GaussianBlur (blur_px) - shadow = shadow.filter (flt) - shadow.load () - - shadowed = Image.new ('RGBA', orig.size, (0, 0, 0, 0)) - shadowed.paste (shadow, (right_px, down_px)) - shadowed.crop ((0, 0, orig.size[0], orig.size[1])) - shadowed = Image.alpha_composite (shadowed, orig) - - return 0, shadowed - -def shadowize (shadow, orig, color): - o_pxs = orig.load () - s_pxs = shadow.load () - for y in range (orig.size[1]): - for x in range (orig.size[0]): - o_px = o_pxs[x, y] - if o_px[3] > 0: - s_pxs[x, y] = (color[0], color[1], color[2], int (color[3] * (o_px[3] / 255.0))) - -if __name__ == '__main__': - sys.exit (main ()) + buf.write(b"icon") + cur = make_cur(frameset, args, animated=True) + cur_size = cur.seek(0, io.SEEK_END) + aligned_cur_size = cur_size + # if cur_size % 4 != 0: + # aligned_cur_size += 4 - cur_size % 2 + buf.write(p(" 4: + try: + duration = int(words[4]) + except: + continue + else: + duration = 0 + + frames.append((size, hotx, hoty, filename, duration)) + + return frames + + +def create_shadow(orig, args): + blur_px = orig.size[0] / 100.0 * args.blur + right_px = int(orig.size[0] / 100.0 * args.right_shift) + down_px = int(orig.size[1] / 100.0 * args.down_shift) + + shadow = Image.new("RGBA", orig.size, (0, 0, 0, 0)) + shadowize(shadow, orig, args.color) + shadow.load() + + if args.blur > 0: + crop = ( + int(math.floor(-blur_px)), + int(math.floor(-blur_px)), + orig.size[0] + int(math.ceil(blur_px)), + orig.size[1] + int(math.ceil(blur_px)), + ) + right_px += int(math.floor(-blur_px)) + down_px += int(math.floor(-blur_px)) + shadow = shadow.crop(crop) + flt = ImageFilter.GaussianBlur(blur_px) + shadow = shadow.filter(flt) + shadow.load() + + shadowed = Image.new("RGBA", orig.size, (0, 0, 0, 0)) + shadowed.paste(shadow, (right_px, down_px)) + shadowed.crop((0, 0, orig.size[0], orig.size[1])) + shadowed = Image.alpha_composite(shadowed, orig) + + return 0, shadowed + + +def shadowize(shadow, orig, color): + o_pxs = orig.load() + s_pxs = shadow.load() + for y in range(orig.size[1]): + for x in range(orig.size[0]): + o_px = o_pxs[x, y] + if o_px[3] > 0: + s_pxs[x, y] = ( + color[0], + color[1], + color[2], + int(color[3] * (o_px[3] / 255.0)), + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/cursors/renderpngs.py b/src/cursors/renderpngs.py index 6bef47e12..65a2d3ec1 100755 --- a/src/cursors/renderpngs.py +++ b/src/cursors/renderpngs.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python2 +#!/usr/bin/env python3 # # SVGSlice # @@ -27,22 +27,8 @@ Please remember to HIDE the slices layer before exporting, so that the rectangle # all of the slices in once place, and perhaps a starting point for more layout work. # -from optparse import OptionParser - -optParser = OptionParser() -optParser.add_option('-d','--debug',action='store_true',dest='debug',help='Enable extra debugging info.') -optParser.add_option('-t','--test',action='store_true',dest='testing',help='Test mode: leave temporary files for examination.') -optParser.add_option('-p','--sliceprefix',action='store',dest='sliceprefix',help='Specifies the prefix to use for individual slice filenames.') -optParser.add_option('-r','--remove-shadows',action='store_true',dest='remove_shadows',help='Remove shadows the cursors have.') -optParser.add_option('-o','--hotspots',action='store_true',dest='hotspots',help='Produce hotspot images and hotspot datafiles.') -optParser.add_option('-s','--scales',action='store_true',dest='scales',help='Produce 125% (Large) and 150% (Extra Large) scaled versions of each image as well.') -optParser.add_option('-m','--min-canvas-size',action='store',type='int',dest='min_canvas_size',default=-1, help='Cursor canvas must be at least this big (defaults to -1).') -optParser.add_option('-f','--fps',action='store',type='int',dest='fps',default=60,help='Assume that all animated cursors have this FPS (defaults to 60).') -optParser.add_option('-a','--anicursorgen',action='store_true',dest='anicur',default=False,help='Assume that anicursorgen will be used to assemble cursors (xcursorgen is assumed by default).') -optParser.add_option('-c','--corner-align',action='store_true',dest='align_corner',default=False,help='Align cursors to the top-left corner (by default they are centered).') -optParser.add_option('-i','--invert',action='store_true',dest='invert',default=False,help='Invert colors (disabled by default).') -optParser.add_option('-n','--number-of-renderers',action='store',type='int',dest='number_of_renderers',default=1, help='Number of renderer instances run in parallel. Defaults to 1. Set to 0 for autodetection.') - +import argparse +import logging from xml.sax import saxutils, make_parser, SAXParseException, handler, xmlreader from xml.sax.handler import feature_namespaces import os, sys, tempfile, shutil, subprocess @@ -52,675 +38,876 @@ from PIL import Image import multiprocessing import io -svgFilename = None -hotsvgFilename = None -sizes = [24,32,48,64,96] -scale_pairs = [(1.25, 's1'), (1.50, 's2')] -mode_shadows = ['shadows'] -mode_hotspots = ['hotspots'] -mode_slices = ['slices'] -mode_invert = ['invert'] + +MODE_HOTSPOTS = ["hotspots"] +MODE_INVERT = ["invert"] +MODE_SHADOWS = ["shadows"] +MODE_SLICES = ["slices"] +RENDERERS = [] +SCALE_PAIRS = [(1.25, "s1"), (1.50, "s2")] +SIZES = [24, 32, 48, 64, 96] +SVG_HOTSPOT_WORKING_COPY = "hotspot-working-copy.svg" +SVG_WORKING_COPY = "working-copy.svg" + +debug = logging.debug +error = logging.error +info = logging.info +warning = logging.warning + + +def fatal(msg): + logging.critical(msg) + sys.exit(20) + + +def configure(): + parser = argparse.ArgumentParser() + parser.add_argument("originalFilename", help="The input SVG file") + parser.add_argument( + "-d", + "--debug", + action="store_true", + dest="debug", + help="Enable extra debugging info.", + ) + parser.add_argument( + "-t", + "--test", + action="store_true", + dest="testing", + help="Test mode: leave temporary files for examination.", + ) + parser.add_argument( + "-p", + "--sliceprefix", + action="store", + dest="sliceprefix", + default="", + help="Specifies the prefix to use for individual slice filenames.", + ) + parser.add_argument( + "-r", + "--remove-shadows", + action="store_true", + dest="remove_shadows", + help="Remove shadows the cursors have.", + ) + parser.add_argument( + "-o", + "--hotspots", + action="store_true", + dest="hotspots", + help="Produce hotspot images and hotspot datafiles.", + ) + parser.add_argument( + "-s", + "--scales", + action="store_true", + dest="scales", + help="Produce 125 and 150 percent (Large, and Extra Large) scaled versions of each image as well.", + ) + parser.add_argument( + "-m", + "--min-canvas-size", + action="store", + type=int, + dest="min_canvas_size", + default=-1, + help="Cursor canvas must be at least this big (defaults to -1).", + ) + parser.add_argument( + "-f", + "--fps", + action="store", + type=int, + dest="fps", + default=60, + help="Assume that all animated cursors have this FPS (defaults to 60).", + ) + parser.add_argument( + "-a", + "--anicursorgen", + action="store_true", + dest="anicur", + default=False, + help="Assume that anicursorgen will be used to assemble cursors (xcursorgen is assumed by default).", + ) + parser.add_argument( + "-c", + "--corner-align", + action="store_true", + dest="align_corner", + default=False, + help="Align cursors to the top-left corner (by default they are centered).", + ) + parser.add_argument( + "-i", + "--invert", + action="store_true", + dest="invert", + default=False, + help="Invert colors (disabled by default).", + ) + parser.add_argument( + "-n", + "--number-of-renderers", + action="store", + type=int, + dest="number_of_renderers", + default=1, + help="Number of renderer instances run in parallel. Defaults to 1. Set to 0 for autodetection.", + ) + + options = parser.parse_args() + + # More detailed logging format if debug is enabled + if options.debug: + fmt = "[%(levelname)s] %(lineno)d:%(funcName)-s - %(message)s" + level = logging.DEBUG + else: + fmt = "[%(levelname)s] %(message)s" + level = logging.INFO + logging.basicConfig(level=level, format=fmt) + + options.modes = get_modes(options) + + if not options.scales: + del SCALE_PAIRS[:] + + if options.number_of_renderers <= 0: + options.number_of_renderers = autodetect_threadcount() + return options + def natural_sort(l): - convert = lambda text: int(text) if text.isdigit() else text.lower() - alphanum_key = lambda key: [ convert(c) for c in re.split('([0-9]+)', key) ] - return sorted(l, key = alphanum_key) + convert = lambda text: int(text) if text.isdigit() else text.lower() + alphanum_key = lambda key: [convert(c) for c in re.split("([0-9]+)", key)] + return sorted(l, key=alphanum_key) -def dbg(msg): - if options.debug: - sys.stderr.write(msg) def cleanup(): - global inkscape_instances - stdin_threads = [] - for inkscape, inkscape_stderr, inkscape_stderr_thread, inkscape_stdin_buf in inkscape_instances: - inkscape_stdin_buf.append ('quit\n') - stdin_threads.append (Thread (target = stdin_writer, args=(inkscape, ''.join (inkscape_stdin_buf)))) - stdin_threads[-1].start () - inkscape_stderr_thread.start () - for t in stdin_threads: - t.join () - del t - for inkscape, inkscape_stderr, inkscape_stderr_thread, inkscape_stdin_buf in inkscape_instances: - inkscape_stderr_thread.join () - del inkscape - del inkscape_stderr_thread - del inkscape_stderr - del inkscape_stdin_buf - del inkscape_instances - del stdin_threads - if svgFilename != None and os.path.exists(svgFilename): - os.unlink(svgFilename) - if hotsvgFilename != None and os.path.exists(hotsvgFilename): - os.unlink(hotsvgFilename) - -def fatalError(msg): - sys.stderr.write(msg) - cleanup() - sys.exit(20) + global RENDERERS + for inkscape, inkscape_stderr, inkscape_stderr_thread in RENDERERS: + inkscape.communicate("quit\n".encode()) + del inkscape + del inkscape_stderr_thread + del inkscape_stderr + del RENDERERS + + if SVG_WORKING_COPY != None and os.path.exists(SVG_WORKING_COPY): + os.remove(SVG_WORKING_COPY) + + if SVG_HOTSPOT_WORKING_COPY != None and os.path.exists(SVG_HOTSPOT_WORKING_COPY): + os.remove(SVG_HOTSPOT_WORKING_COPY) + + +def find_hotspot(hotfile): + img = Image.open(hotfile) + pixels = img.load() + reddest = [-1, -1, -999999] + for y in range(img.size[1]): + for x in range(img.size[0]): + redness = pixels[x, y][0] - pixels[x, y][1] - pixels[x, y][2] + if redness > reddest[2]: + reddest = [x, y, redness] + return (reddest[0] + 1, reddest[1] + 1) + + +def cropalign(size, filename): + img = Image.open(filename) + content_dimensions = img.getbbox() + if content_dimensions is None: + content_dimensions = (0, 0, img.size[0], img.size[1]) + hcropped = content_dimensions[2] - content_dimensions[0] + vcropped = content_dimensions[3] - content_dimensions[1] + if hcropped > size or vcropped > size: + if hcropped > size: + left = (hcropped - size) / 2 + right = (hcropped - size) - left + else: + left = 0 + right = 0 + if vcropped > size: + top = (vcropped - size) / 2 + bottom = (vcropped - size) - top + else: + top = 0 + bottom = 0 + content_dimensions = ( + content_dimensions[0] + left, + content_dimensions[1] + top, + content_dimensions[2] - right, + content_dimensions[3] - bottom, + ) + warn( + f"{filename} is too big to be cleanly cropped to {size} ({hcropped}x{vcropped} at best)" + ) + warn( + "cropping to {}x{}!".format( + content_dimensions[2] - content_dimensions[0], + content_dimensions[3] - content_dimensions[1], + ) + ) + + if options.testing: + img.save(filename + ".orig.png", "png") + + debug( + f"{filename} content is {content_dimensions[0]} {content_dimensions[1]} {content_dimensions[2]} {content_dimensions[3]}" + ) + + cropimg = img.crop( + ( + content_dimensions[0], + content_dimensions[1], + content_dimensions[2], + content_dimensions[3], + ) + ) + pixels = cropimg.load() + if options.testing: + cropimg.save(filename + ".crop.png", "png") + if options.align_corner: + expimg = cropimg.crop((0, 0, size, size)) + result = (content_dimensions[0], content_dimensions[1]) + else: + hslack = size - cropimg.size[0] + vslack = size - cropimg.size[1] + left = hslack / 2 + top = vslack / 2 + expimg = cropimg.crop((-left, -top, size - left, size - top)) + result = (content_dimensions[0] - left, content_dimensions[1] - top) + pixels = expimg.load() + if options.invert: + negative(expimg) + expimg.save(filename, "png") + del cropimg + del img + return result + + +def cropalign_hotspot(new_base, size, filename): + if not new_base: + return + img = Image.open(filename) + expimg = img.crop( + (new_base[0], new_base[1], new_base[0] + size, new_base[1] + size) + ) + pixels = expimg.load() + expimg.save(filename, "png") + del img + + +def negative(img): + pixels = img.load() + for y in range(0, img.size[1]): + for x in range(0, img.size[0]): + r, g, b, a = pixels[x, y] + pixels[x, y] = (255 - r, 255 - g, 255 - b, a) -def stderr_reader(inkscape, inkscape_stderr): - while True: - line = inkscape_stderr.readline() - if line and len (line.rstrip ('\n').rstrip ('\r')) > 0: - fatalError('ABORTING: Inkscape failed to render a slice: {}'.format (line)) - elif line: - print "STDERR> {}".format (line) - else: - raise EOFError - -def stdin_writer(inkscape, inkscape_stdin): - inkscape.stdin.write (inkscape_stdin) - -def find_hotspot (hotfile): - img = Image.open(hotfile) - pixels = img.load() - reddest = [-1, -1, -999999] - for y in range(img.size[1]): - for x in range(img.size[0]): - redness = pixels[x,y][0] - pixels[x,y][1] - pixels[x,y][2] - if redness > reddest[2]: - reddest = [x, y, redness] - return (reddest[0] + 1, reddest[1] + 1) - -def cropalign (size, filename): - img = Image.open (filename) - content_dimensions = img.getbbox () - if content_dimensions is None: - content_dimensions = (0, 0, img.size[0], img.size[1]) - hcropped = content_dimensions[2] - content_dimensions[0] - vcropped = content_dimensions[3] - content_dimensions[1] - if hcropped > size or vcropped > size: - if hcropped > size: - left = (hcropped - size) / 2 - right = (hcropped - size) - left - else: - left = 0 - right = 0 - if vcropped > size: - top = (vcropped - size) / 2 - bottom = (vcropped - size) - top - else: - top = 0 - bottom = 0 - content_dimensions = (content_dimensions[0] + left, content_dimensions[1] + top, content_dimensions[2] - right, content_dimensions[3] - bottom) - sys.stderr.write ("WARNING: {} is too big to be cleanly cropped to {} ({}x{} at best), cropping to {}x{}!\n".format (filename, size, hcropped, vcropped, content_dimensions[2] - content_dimensions[0], content_dimensions[3] - content_dimensions[1])) - sys.stderr.flush () - if options.testing: - img.save (filename + ".orig.png", "png") - dbg("{} content is {} {} {} {}".format (filename, content_dimensions[0], content_dimensions[1], content_dimensions[2], content_dimensions[3])) - cropimg = img.crop ((content_dimensions[0], content_dimensions[1], content_dimensions[2], content_dimensions[3])) - pixels = cropimg.load () - if options.testing: - cropimg.save (filename + ".crop.png", "png") - if options.align_corner: - expimg = cropimg.crop ((0, 0, size, size)) - result = (content_dimensions[0], content_dimensions[1]) - else: - hslack = size - cropimg.size[0] - vslack = size - cropimg.size[1] - left = hslack / 2 - top = vslack / 2 - expimg = cropimg.crop ((-left, -top, size - left, size - top)) - result = (content_dimensions[0] - left, content_dimensions[1] - top) - pixels = expimg.load () - if options.invert: - negative (expimg) - expimg.save (filename, "png") - del cropimg - del img - return result - -def cropalign_hotspot (new_base, size, filename): - if new_base is None: - return - img = Image.open (filename) - expimg = img.crop ((new_base[0], new_base[1], new_base[0] + size, new_base[1] + size)) - pixels = expimg.load () - expimg.save (filename, "png") - del img - -def negative (img): - pixels = img.load () - for y in range (0, img.size[1]): - for x in range (0, img.size[0]): - r, g, b, a = pixels[x,y] - pixels[x,y] = (255 - r, 255 - g, 255 - b, a) class SVGRect: - """Manages a simple rectangular area, along with certain attributes such as a name""" - def __init__(self, x1,y1,x2,y2, name=None): - self.x1 = x1 - self.y1 = y1 - self.x2 = x2 - self.y2 = y2 - self.name = name - dbg("New SVGRect: (%s)" % name) - - def renderFromSVG(self, svgFName, slicename, skipped, roundrobin, hotsvgFName): - - def do_res (size, output, svgFName, skipped, roundrobin): - global inkscape_instances - if os.path.exists (output): - skipped[output] = True - return - command = '-w {size} -h {size} --export-id="{export_id}" --export-png="{export_png}" {svg}\n'.format (size=size, export_id=self.name, export_png=output, svg=svgFName) - dbg("Command: {}".format (command)) - inkscape_instances[roundrobin[0]][3].append (command) - - pngsliceFName = slicename + '.png' - hotsliceFName = slicename + '.hotspot.png' - - dbg('Saving slice as: "%s"' % pngsliceFName) - for i, size in enumerate (sizes): - subdir = 'pngs/{}x{}'.format (size, size) - if not os.path.exists (subdir): - os.makedirs (subdir) - relslice = '{}/{}'.format (subdir, pngsliceFName) - do_res (size, relslice, svgFName, skipped, roundrobin) - if options.hotspots: - hotrelslice = '{}/{}'.format (subdir, hotsliceFName) - do_res (size, hotrelslice, hotsvgFName, skipped, roundrobin) - for scale in scale_pairs: - subdir = 'pngs/{}x{}_{}'.format (size, size, scale[1]) - relslice = '{}/{}'.format (subdir, pngsliceFName) - if not os.path.exists (subdir): - os.makedirs (subdir) - scaled_size = int (size * scale[0]) - do_res (scaled_size, relslice, svgFName, skipped, roundrobin) - if options.hotspots: - hotrelslice = '{}/{}'.format (subdir, hotsliceFName) - do_res (scaled_size, hotrelslice, hotsvgFName, skipped, roundrobin) - # This is not inside do_res() because we want each instance to work all scales in case scales are enabled, - # otherwise instances that get mostly smallscale renders will finish up way before the others - roundrobin[0] += 1 - if roundrobin[0] >= options.number_of_renderers: - roundrobin[0] = 0 - -def get_next_size (index, current_size): - if index % 2 == 0: - # 24->32, 48->64, 96->128, 192->256 - return (current_size * 4) / 3 - else: - # 32->48, 64->96, 128->192, 256->384 - return (current_size * 3) / 2 - -def get_csize (index, current_size): - size = current_size - if len (scale_pairs) > 0: - size = get_next_size (index, size) - return max (options.min_canvas_size, size) - -def postprocess_slice (slicename, skipped): - pngsliceFName = slicename + '.png' - hotsliceFName = slicename + '.hotspot.png' - - for i, size in enumerate (sizes): - subdir = 'pngs/{}x{}'.format (size, size) - relslice = '{}/{}'.format (subdir, pngsliceFName) - csize = get_csize (i, size) - if relslice not in skipped: - new_base = cropalign (csize, relslice) - if options.hotspots: - hotrelslice = '{}/{}'.format (subdir, hotsliceFName) - cropalign_hotspot (new_base, csize, hotrelslice) - for scale in scale_pairs: - subdir = 'pngs/{}x{}_{}'.format (size, size, scale[1]) - relslice = '{}/{}'.format (subdir, pngsliceFName) - if relslice not in skipped: - new_base = cropalign (csize, relslice) - if options.hotspots: - hotrelslice = '{}/{}'.format (subdir, hotsliceFName) - cropalign_hotspot (new_base, csize, hotrelslice) + """Manages a simple rectangular area, along with certain attributes such as a name""" + + def __init__(self, x1, y1, x2, y2, name=None): + self.x1 = x1 + self.y1 = y1 + self.x2 = x2 + self.y2 = y2 + self.name = name + debug(f"New SVGRect: {name}") + + def renderFromSVG(self, svgFName, slicename, skipped, roundrobin, hotsvgFName): + def do_res(size, output, svgFName): + global RENDERERS + nonlocal skipped, roundrobin + if os.path.exists(output): + debug(f"{output} exists, skip rendering") + skipped[output] = True + return + + debug(f"rendering {output}") + command = f"export-width:{size};" + command += f" export-height:{size};" + command += f" export-id:{self.name};" + command += f" export-filename:{output};" + command += f" export-do\n" + debug(f"inkscape command: {command}") + RENDERERS[roundrobin[0]][0].stdin.write(command.encode()) + + pngsliceFName = f"{slicename}.png" + hotsliceFName = f"{slicename}.hotspot.png" + + for i, size in enumerate(SIZES): + subdir = f"bitmaps/{size}x{size}" + + if not os.path.exists(subdir): + os.makedirs(subdir) + + relslice = f"{subdir}/{pngsliceFName}" + do_res(size, relslice, svgFName) + + if options.hotspots: + hotrelslice = f"{subdir}/{hotsliceFName}" + do_res(size, hotrelslice, hotsvgFName) + + for scale in SCALE_PAIRS: + subdir = f"bitmaps/{size}x{size}_{scale[1]}" + relslice = f"{subdir}/{pngsliceFName}" + + if not os.path.exists(subdir): + os.makedirs(subdir) + + scaled_size = int(size * scale[0]) + do_res(scaled_size, relslice, svgFName) + + if options.hotspots: + hotrelslice = f"{subdir}/{hotsliceFName}" + do_res(scaled_size, hotrelslice, hotsvgFName) + + # This is not inside do_res() because we want each instance to work all scales in case scales are enabled, + # otherwise instances that get mostly smallscale renders will finish up way before the others + roundrobin[0] += 1 + if roundrobin[0] >= options.number_of_renderers: + roundrobin[0] = 0 + + +def get_next_size(index, current_size): + if index % 2 == 0: + # 24->32, 48->64, 96->128, 192->256 + return (current_size * 4) / 3 + else: + # 32->48, 64->96, 128->192, 256->384 + return (current_size * 3) / 2 + + +def get_csize(index, current_size): + size = current_size + if len(SCALE_PAIRS) > 0: + size = get_next_size(index, size) + return max(options.min_canvas_size, size) + + +def postprocess_slice(slicename, skipped): + pngsliceFName = f"{slicename}.png" + hotsliceFName = f"{slicename}.hotspot.png" + + for i, size in enumerate(SIZES): + subdir = f"bitmaps/{size}x{size}" + relslice = f"{subdir}/{pngsliceFName}" + csize = get_csize(i, size) + if relslice not in skipped: + if options.hotspots: + hotrelslice = f"{subdir}/{hotsliceFName}" + for scale in SCALE_PAIRS: + subdir = f"bitmaps/{size}x{size}_{scale[1]}" + relslice = f"{subdir}/{pngsliceFName}" + if relslice not in skipped: + if options.hotspots: + hotrelslice = f"{subdir}/{hotsliceFName}" + def write_xcur(slicename): - pngsliceFName = slicename + '.png' - hotsliceFName = slicename + '.hotspot.png' - - framenum = -1 - if slicename[-5:].startswith ('_'): - try: - framenum = int (slicename[-4:]) - slicename = slicename[:-5] - except: - pass - - # This relies on the fact that frame 1 is the first frame of an animation in the rect list - # If that is not so, the *icongen input file will end up missing some of the lines - if framenum == -1 or framenum == 1: - mode = 'wb' - else: - mode = 'ab' - if framenum == -1: - fps_field = '' - else: - if options.anicur: - # For anicursorgen use jiffies - fps_field = ' {}'.format (int (60.0 / options.fps)) - else: - # For xcursorgen use milliseconds - fps_field = ' {}'.format (int (1000.0 / options.fps)) - xcur = {} - xcur['s0'] = open ('pngs/{}.in'.format (slicename), mode) - if len (scale_pairs) > 0: - xcur['s1'] = open ('pngs/{}.s1.in'.format (slicename), mode) - xcur['s2'] = open ('pngs/{}.s2.in'.format (slicename), mode) - for i, size in enumerate (sizes): - subdir = 'pngs/{}x{}'.format (size, size) - relslice = '{}/{}'.format (subdir, pngsliceFName) - hotrelslice = '{}/{}'.format (subdir, hotsliceFName) - hot = find_hotspot (hotrelslice) - csize = get_csize (i, size) - xcur['s0'].write ("{csize} {hotx} {hoty} {filename}{fps_field}\n".format (csize=csize, hotx=hot[0], hoty=hot[1], filename='{}x{}/{}'.format (size, size, pngsliceFName), fps_field=fps_field)) - for scale in scale_pairs: - subdir = 'pngs/{}x{}_{}'.format (size, size, scale[1]) - relslice = '{}/{}'.format (subdir, pngsliceFName) - scaled_size = int (size * scale[0]) - hotrelslice = '{}/{}'.format (subdir, hotsliceFName) - hot = find_hotspot (hotrelslice) - xcur[scale[1]].write ("{csize} {hotx} {hoty} {filename}{fps_field}\n".format (csize=csize, hotx=hot[0], hoty=hot[1], filename='{}x{}_{}/{}'.format (size, size, scale[1], pngsliceFName), fps_field=fps_field)) - xcur['s0'].close () - if len (scale_pairs) > 0: - xcur['s1'].close () - xcur['s2'].close () + pngsliceFName = f"{slicename}.png" + hotsliceFName = f"{slicename}.hotspot.png" + + framenum = -1 + if slicename[-5:].startswith("_"): + try: + framenum = int(slicename[-4:]) + slicename = slicename[:-5] + except: + pass + + # This relies on the fact that frame 1 is the first frame of an animation in the rect list + # If that is not so, the *icongen input file will end up missing some of the lines + if framenum == -1 or framenum == 1: + mode = "wb" + else: + mode = "ab" + if framenum == -1: + fps_field = "" + else: + if options.anicur: + # For anicursorgen use jiffies + fps_field = " {}".format(int(60.0 / options.fps)) + else: + # For xcursorgen use milliseconds + fps_field = " {}".format(int(1000.0 / options.fps)) + + xcur = {} + xcur["s0"] = open(f"bitmaps/{slicename}.in", mode) + if len(SCALE_PAIRS) > 0: + xcur["s1"] = open(f"bitmaps/{slicename}.s1.in", mode) + xcur["s2"] = open(f"bitmaps/{slicename}.s2.in", mode) + + for i, size in enumerate(SIZES): + subdir = f"bitmaps/{size}x{size}" + relslice = f"{subdir}/{pngsliceFName}" + filename = f"{size}x{size}/{pngsliceFName}" + hotrelslice = f"{subdir}/{hotsliceFName}" + + hot = find_hotspot(hotrelslice) + csize = get_csize(i, size) + + xcur["s0"].write(f"{csize} {hot[0]} {hot[1]} {filename}{fps_field}\n") + + for scale in SCALE_PAIRS: + subdir = f"bitmaps/{size}x{size}_{scale[1]}" + relslice = f"{subdir}/{pngsliceFName}" + filename = f"{size}x{size}/{scale[1]}" + scaled_size = int(size * scale[0]) + hotrelslice = f"{subdir}/{hotsliceFName}" + + hot = find_hotspot(hotrelslice) + + xcur[scale[1]].write(f"{csize} {hot[0]} {hot[1]} {filename}{fps_field}\n") + + xcur["s0"].close() + if len(SCALE_PAIRS) > 0: + xcur["s1"].close() + xcur["s2"].close() + def sort_file(filename): - with open (filename, 'rb') as src: - contents = src.readlines () - with open (filename, 'wb') as dst: - for line in natural_sort (contents): - dst.write (line) + with open(filename, "rb") as src: + contents = src.readlines() + with open(filename, "wb") as dst: + for line in natural_sort(contents): + dst.write(line) + def sort_xcur(slicename, passed): - pngsliceFName = slicename + '.png' - - framenum = -1 - if slicename[-5:].startswith ('_'): - try: - framenum = int (slicename[-4:]) - slicename = slicename[:-5] - except: - pass - if slicename in passed: - return - passed[slicename] = True - - sort_file ('pngs/{}.in'.format (slicename)) - if len (scale_pairs) > 0: - sort_file ('pngs/{}.s1.in'.format (slicename)) - sort_file ('pngs/{}.s2.in'.format (slicename)) + pngsliceFName = f"{slicename}.png" + + framenum = -1 + if slicename[-5:].startswith("_"): + try: + framenum = int(slicename[-4:]) + slicename = slicename[:-5] + except: + pass + if slicename in passed: + return + passed[slicename] = True + + sort_file("bitmaps/{slicename}.in") + if len(SCALE_PAIRS) > 0: + sort_file(f"bitmaps/{slicename}.s1.in") + sort_file(f"bitmaps/{slicename}.s2.in") + def delete_hotspot(slicename): - hotsliceFName = slicename + '.hotspot.png' - - for i, size in enumerate (sizes): - subdir = 'pngs/{}x{}'.format (size, size) - hotrelslice = '{}/{}'.format (subdir, hotsliceFName) - if os.path.exists (hotrelslice): - os.unlink (hotrelslice) - for scale in scale_pairs: - subdir = 'pngs/{}x{}_{}'.format (size, size, scale[1]) - hotrelslice = '{}/{}'.format (subdir, hotsliceFName) - if os.path.exists (hotrelslice): - os.unlink (hotrelslice) + hotsliceFName = f"{slicename}.hotspot.png" + + for i, size in enumerate(SIZES): + subdir = f"bitmaps/{size}x{size}" + hotrelslice = f"{subdir}/{hotsliceFName}" + if os.path.exists(hotrelslice): + os.unlink(hotrelslice) + for scale in SCALE_PAIRS: + subdir = f"bitmaps/{size}x{size}_{scale[1]}" + hotrelslice = f"{subdir}/{hotsliceFName}" + if os.path.exists(hotrelslice): + os.unlink(hotrelslice) + class SVGHandler(handler.ContentHandler): - """Base class for SVG parsers""" - def __init__(self): - self.pageBounds = SVGRect(0,0,0,0) - - def isFloat(self, stringVal): - try: - return (float(stringVal), True)[1] - except (ValueError, TypeError), e: - return False - - def parseCoordinates(self, val): - """Strips the units from a coordinate, and returns just the value.""" - if val.endswith('px'): - val = float(val.rstrip('px')) - elif val.endswith('pt'): - val = float(val.rstrip('pt')) - elif val.endswith('cm'): - val = float(val.rstrip('cm')) - elif val.endswith('mm'): - val = float(val.rstrip('mm')) - elif val.endswith('in'): - val = float(val.rstrip('in')) - elif val.endswith('%'): - val = float(val.rstrip('%')) - elif self.isFloat(val): - val = float(val) - else: - fatalError("Coordinate value %s has unrecognised units. Only px,pt,cm,mm,and in units are currently supported." % val) - return val - - def startElement_svg(self, name, attrs): - """Callback hook which handles the start of an svg image""" - dbg('startElement_svg called') - width = attrs.get('width', None) - height = attrs.get('height', None) - self.pageBounds.x2 = self.parseCoordinates(width) - self.pageBounds.y2 = self.parseCoordinates(height) - - def endElement(self, name): - """General callback for the end of a tag""" - dbg('Ending element "%s"' % name) + """Base class for SVG parsers""" + + def __init__(self): + self.pageBounds = SVGRect(0, 0, 0, 0) + + def isFloat(self, stringVal): + try: + return (float(stringVal), True)[1] + except (ValueError, TypeError) as e: + return False + + def parseCoordinates(self, val): + """Strips the units from a coordinate, and returns just the value.""" + + if self.isFloat(val): + return float(val) + + res = None + supported_units = "px, pt, cm, mm, in, %" + for unit in supported_units.split(", "): + if val.endswith(unit): + res = float(val.rstrip(unit)) + break + + if not res: + fatal( + f"Unsupported unit in value {val}. Valid units are {supported_units}." + ) + + return res + + def startElement_svg(self, name, attrs): + """Callback hook which handles the start of an svg image""" + + width = attrs.get("width", None) + height = attrs.get("height", None) + self.pageBounds.x2 = self.parseCoordinates(width) + self.pageBounds.y2 = self.parseCoordinates(height) + + def endElement(self, name): + """General callback for the end of a tag""" + debug(f'Ending element "{name}"') class SVGLayerHandler(SVGHandler): - """Parses an SVG file, extracing slicing rectangles from a "slices" layer""" - def __init__(self): - SVGHandler.__init__(self) - self.svg_rects = [] - self.layer_nests = 0 - - def inSlicesLayer(self): - return (self.layer_nests >= 1) - - def add(self, rect): - """Adds the given rect to the list of rectangles successfully parsed""" - self.svg_rects.append(rect) - - def startElement_layer(self, name, attrs): - """Callback hook for parsing layer elements - - Checks to see if we're starting to parse a slices layer, and sets the appropriate flags. Otherwise, the layer will simply be ignored.""" - dbg('found layer: name="%s" id="%s"' % (name, attrs['id'])) - if attrs.get('inkscape:groupmode', None) == 'layer': - if self.inSlicesLayer() or attrs['inkscape:label'] == 'slices': - self.layer_nests += 1 - - def endElement_layer(self, name): - """Callback for leaving a layer in the SVG file - - Just undoes any flags set previously.""" - dbg('leaving layer: name="%s"' % name) - if self.inSlicesLayer(): - self.layer_nests -= 1 - - def startElement_rect(self, name, attrs): - """Callback for parsing an SVG rectangle - - Checks if we're currently in a special "slices" layer using flags set by startElement_layer(). If we are, the current rectangle is considered to be a slice, and is added to the list of parsed - rectangles. Otherwise, it will be ignored.""" - if self.inSlicesLayer(): - x1 = self.parseCoordinates(attrs['x']) - y1 = self.parseCoordinates(attrs['y']) - x2 = self.parseCoordinates(attrs['width']) + x1 - y2 = self.parseCoordinates(attrs['height']) + y1 - name = attrs['id'] - rect = SVGRect(x1,y1, x2,y2, name) - self.add(rect) - - def startElement(self, name, attrs): - """Generic hook for examining and/or parsing all SVG tags""" - dbg('Beginning element "%s"' % name) - if name == 'svg': - self.startElement_svg(name, attrs) - elif name == 'g': - # inkscape layers are groups, I guess, hence 'g' - self.startElement_layer(name, attrs) - elif name == 'rect': - self.startElement_rect(name, attrs) - - def endElement(self, name): - """Generic hook called when the parser is leaving each SVG tag""" - dbg('Ending element "%s"' % name) - if name == 'g': - self.endElement_layer(name) - - def generateXHTMLPage(self): - """Generates an XHTML page for the SVG rectangles previously parsed.""" - write = sys.stdout.write - write('\n') - write('\n') - write('\n') - write(' \n') - write(' Sample SVGSlice Output\n') - write(' \n') - write(' \n') - write('

Sorry, SVGSlice\'s XHTML output is currently very basic. Hopefully, it will serve as a quick way to preview all generated slices in your browser, and perhaps as a starting point for further layout work. Feel free to write it and submit a patch to the author :)

\n') - - write('

') - for rect in self.svg_rects: - write(' %s (please add real alternative text for this image)\n' % (sliceprefix + rect.name + '.png', rect.name)) - write('

') - - write('

Valid XHTML 1.0!

') - - write(' \n') - write('\n') - -class SVGFilter (saxutils.XMLFilterBase): - def __init__ (self, upstream, downstream, mode, **kwargs): - saxutils.XMLFilterBase.__init__(self, upstream) - self._downstream = downstream - self.mode = mode - - def startDocument (self): - self.in_throwaway_layer_stack = [False] - - def startElement (self, localname, attrs): - def modify_style (style, old_style, new_style=None): - styles = style.split (';') - new_styles = [] - if old_style is not None: - match_to = old_style + ':' - for s in styles: - if len (s) > 0 and (old_style is None or not s.startswith (match_to)): - new_styles.append (s) - if new_style is not None: - new_styles.append (new_style) - return ';'.join (new_styles) - - dict = {} - is_throwaway_layer = False - is_slices = False - is_hotspots = False - is_shadows = False - is_layer = False - if localname == 'g': - for key, value in attrs.items (): - if key == 'inkscape:label': - if value == 'slices': - is_slices = True - elif value == 'hotspots': - is_hotspots = True - elif value == 'shadows': - is_shadows = True - elif key == 'inkscape:groupmode': - if value == 'layer': - is_layer = True - if mode_shadows in self.mode and is_shadows: - # Only remove the shadows - is_throwaway_layer = True - elif mode_hotspots in self.mode and not (is_hotspots or is_slices): - # Remove all layers but hotspots and slices - if localname == 'g': - is_throwaway_layer = True - idict = {} - idict.update (attrs) - if 'style' not in attrs.keys (): - idict['style'] = '' - for key, value in idict.items(): - alocalname = key - if alocalname == 'style': - had_style = True - if alocalname == 'style' and is_slices: - # Make slices invisible. Do not check the mode, because there is - # no circumstances where we *want* to render slices - value = modify_style (value, 'display', 'display:none') - if alocalname == 'style' and is_hotspots: - if mode_hotspots in self.mode: - # Make hotspots visible in hotspots mode - value = modify_style (value, 'display', 'display:inline') - else: - # Make hotspots invisible otherwise - value = modify_style (value, 'display', 'display:none') - if alocalname == 'style' and mode_invert in self.mode and is_layer and is_shadows: - value = modify_style (value, None, 'filter:url(#InvertFilter)') - dict[key] = value - - if self.in_throwaway_layer_stack[0] or is_throwaway_layer: - self.in_throwaway_layer_stack.insert(0, True) - else: - self.in_throwaway_layer_stack.insert(0, False) - attrs = xmlreader.AttributesImpl(dict) - self._downstream.startElement(localname, attrs) - - def characters(self, content): - if self.in_throwaway_layer_stack[0]: - return - self._downstream.characters(content) - - def endElement(self, localname): - if self.in_throwaway_layer_stack.pop(0): - return - self._downstream.endElement(localname) - -def filter_svg (input, output, mode): - """filter_svg(input:file, output:file, mode) - - Parses the SVG input from the input stream. - For mode == 'hotspots' it filters out all - layers except for hotspots and slices. Also makes hotspots - visible. - For mode == 'shadows' it filters out the shadows layer. - """ - - mode_objs = [] - if 'hotspots' in mode: - mode_objs.append (mode_hotspots) - if 'shadows' in mode: - mode_objs.append (mode_shadows) - if 'slices' in mode: - mode_objs.append (mode_slices) - if 'invert' in mode: - mode_objs.append (mode_invert) - if len (mode_objs) == 0: - raise ValueError() - - output_gen = saxutils.XMLGenerator(output) - parser = make_parser() - filter = SVGFilter(parser, output_gen, mode_objs) - filter.setFeature(handler.feature_namespaces, False) - filter.setErrorHandler(handler.ErrorHandler()) - # This little I/O dance is here to ensure that SAX parser does not stash away - # an open file descriptor for the input file, which would prevent us from unlinking it later - with open (input, 'rb') as inp: - contents = inp.read () - contents_io = io.BytesIO (contents) - source_object = saxutils.prepare_input_source (contents_io) - filter.parse(source_object) - del filter - del parser - del output_gen - -def autodetect_threadcount (): - try: - count = multiprocessing.cpu_count() - except NotImplementedError: - count = 1 - return count - -if __name__ == '__main__': - # parse command line into arguments and options - (options, args) = optParser.parse_args() - - if len(args) != 1: - fatalError("\nCall me with the SVG as a parameter.\n\n") - originalFilename = args[0] - - svgFilename = originalFilename + '.svg' - hotsvgFilename = originalFilename + '.hotspots.svg' - modes = ['slices'] - if options.remove_shadows: - modes.append ('shadows') - if options.invert: - modes.append ('invert') - - with open (svgFilename, 'wb') as output: - filter_svg(originalFilename, output, modes) - - if options.hotspots: - with open (hotsvgFilename, 'wb') as output: - filter_svg(originalFilename, output, ['hotspots']) - # setup program variables from command line (in other words, handle non-option args) - basename = os.path.splitext(svgFilename)[0] - - if options.sliceprefix: - sliceprefix = options.sliceprefix - else: - sliceprefix = '' - - if not options.scales: - del scale_pairs[:] - - if options.number_of_renderers <= 0: - options.number_of_renderers = autodetect_threadcount () - - inkscape_instances = [] - - for i in range (0, options.number_of_renderers): - inkscape = subprocess.Popen (['inkscape', '--without-gui', '--shell'], stdin=subprocess.PIPE, stderr=subprocess.PIPE) - if inkscape is None: - fatalError("Failed to start Inkscape shell process") - inkscape_stderr = inkscape.stderr - inkscape_stderr_thread = Thread (target = stderr_reader, args=(inkscape, inkscape_stderr)) - inkscape_stdin_buf = [] - inkscape_instances.append ([inkscape, inkscape_stderr, inkscape_stderr_thread, inkscape_stdin_buf]) - - # initialise results before actually attempting to parse the SVG file - svgBounds = SVGRect(0,0,0,0) - rectList = [] - - # Try to parse the svg file - xmlParser = make_parser() - xmlParser.setFeature(feature_namespaces, 0) - - # setup XML Parser with an SVGLayerHandler class as a callback parser #### - svgLayerHandler = SVGLayerHandler() - xmlParser.setContentHandler(svgLayerHandler) - try: - xmlParser.parse(svgFilename) - except SAXParseException, e: - fatalError("Error parsing SVG file '%s': line %d,col %d: %s. If you're seeing this within inkscape, it probably indicates a bug that should be reported." % (svgFilename, e.getLineNumber(), e.getColumnNumber(), e.getMessage())) - - # verify that the svg file actually contained some rectangles. - if len(svgLayerHandler.svg_rects) == 0: - fatalError("""No slices were found in this SVG file. Please refer to the documentation for guidance on how to use this SVGSlice. As a quick summary: - -""" + usageMsg) - else: - dbg("Parsing successful.") - - #svgLayerHandler.generateXHTMLPage() - del xmlParser - - skipped = {} - roundrobin = [0] - - # loop through each slice rectangle, and render a PNG image for it - svgLayerHandler.svg_rects - for rect in svgLayerHandler.svg_rects: - slicename = sliceprefix + rect.name - rect.renderFromSVG(svgFilename, slicename, skipped, roundrobin, hotsvgFilename) - - cleanup() - - for rect in svgLayerHandler.svg_rects: - slicename = sliceprefix + rect.name - postprocess_slice(slicename, skipped) - if options.hotspots: - write_xcur(slicename) - - if options.hotspots: - passed = {} - for rect in svgLayerHandler.svg_rects: - slicename = sliceprefix + rect.name - sort_xcur(slicename, passed) - #if not option.testing: - # delete_hotspot(slicename) - - dbg('Slicing complete.') + """Parses an SVG file, extracing slicing rectangles from a "slices" layer""" + + def __init__(self): + SVGHandler.__init__(self) + self.svg_rects = [] + self.layer_nests = 0 + + def inSlicesLayer(self): + return self.layer_nests >= 1 + + def add(self, rect): + """Adds the given rect to the list of rectangles successfully parsed""" + self.svg_rects.append(rect) + + def startElement_layer(self, name, attrs): + """Callback hook for parsing layer elements + + Checks to see if we're starting to parse a slices layer, and sets the appropriate flags. Otherwise, the layer will simply be ignored.""" + id = attrs["id"] + debug(f'found layer: name="{name}" id="{id}"') + if attrs.get("inkscape:groupmode", None) == "layer": + if self.inSlicesLayer() or attrs["inkscape:label"] == "slices": + self.layer_nests += 1 + + def endElement_layer(self, name): + """Callback for leaving a layer in the SVG file + + Just undoes any flags set previously.""" + debug(f'leaving layer: name="{name}"') + if self.inSlicesLayer(): + self.layer_nests -= 1 + + def startElement_rect(self, name, attrs): + """Callback for parsing an SVG rectangle + + Checks if we're currently in a special "slices" layer using flags set by startElement_layer(). If we are, the current rectangle is considered to be a slice, and is added to the list of parsed + rectangles. Otherwise, it will be ignored.""" + + if self.inSlicesLayer(): + x1 = self.parseCoordinates(attrs["x"]) + y1 = self.parseCoordinates(attrs["y"]) + x2 = self.parseCoordinates(attrs["width"]) + x1 + y2 = self.parseCoordinates(attrs["height"]) + y1 + name = attrs["id"] + rect = SVGRect(x1, y1, x2, y2, name) + self.add(rect) + + def startElement(self, name, attrs): + """Generic hook for examining and/or parsing all SVG tags""" + + debug(f'Beginning element "{name}"') + if name == "svg": + self.startElement_svg(name, attrs) + elif name == "g": + # inkscape layers are groups, I guess, hence 'g' + self.startElement_layer(name, attrs) + elif name == "rect": + self.startElement_rect(name, attrs) + + def endElement(self, name): + """Generic hook called when the parser is leaving each SVG tag""" + debug('Ending element "%s"' % name) + if name == "g": + self.endElement_layer(name) + + def generateXHTMLPage(self): + """Generates an XHTML page for the SVG rectangles previously parsed.""" + write = sys.stdout.write + write('\n') + write( + '\n' + ) + write('\n') + write(" \n") + write(" Sample SVGSlice Output\n") + write(" \n") + write(" \n") + write( + "

Sorry, SVGSlice's XHTML output is currently very basic. Hopefully, it will serve as a quick way to preview all generated slices in your browser, and perhaps as a starting point for further layout work. Feel free to write it and submit a patch to the author :)

\n" + ) + + write("

") + for rect in self.svg_rects: + write( + ' %s (please add real alternative text for this image)\n' + % (sliceprefix + rect.name + ".png", rect.name) + ) + write("

") + + write( + '

Valid XHTML 1.0!

' + ) + + write(" \n") + write("\n") + + +class SVGFilter(saxutils.XMLFilterBase): + def __init__(self, upstream, downstream, mode, **kwargs): + saxutils.XMLFilterBase.__init__(self, upstream) + self._downstream = downstream + self.mode = mode + + def startDocument(self): + self.in_throwaway_layer_stack = [False] + + def startElement(self, localname, attrs): + def modify_style(style, old_style, new_style=None): + styles = style.split(";") + new_styles = [] + if old_style is not None: + match_to = old_style + ":" + for s in styles: + if len(s) > 0 and (old_style is None or not s.startswith(match_to)): + new_styles.append(s) + if new_style is not None: + new_styles.append(new_style) + return ";".join(new_styles) + + dict = {} + is_throwaway_layer = False + is_slices = False + is_hotspots = False + is_shadows = False + is_layer = False + if localname == "g": + for key, value in attrs.items(): + if key == "inkscape:label": + if value == "slices": + is_slices = True + elif value == "hotspots": + is_hotspots = True + elif value == "shadows": + is_shadows = True + elif key == "inkscape:groupmode": + if value == "layer": + is_layer = True + if MODE_SHADOWS in self.mode and is_shadows: + # Only remove the shadows + is_throwaway_layer = True + elif MODE_HOTSPOTS in self.mode and not (is_hotspots or is_slices): + # Remove all layers but hotspots and slices + if localname == "g": + is_throwaway_layer = True + idict = {} + idict.update(attrs) + if "style" not in attrs.keys(): + idict["style"] = "" + for key, value in idict.items(): + alocalname = key + if alocalname == "style": + had_style = True + if alocalname == "style" and is_slices: + # Make slices invisible. Do not check the mode, because there is + # no circumstances where we *want* to render slices + value = modify_style(value, "display", "display:none") + if alocalname == "style" and is_hotspots: + if MODE_HOTSPOTS in self.mode: + # Make hotspots visible in hotspots mode + value = modify_style(value, "display", "display:inline") + else: + # Make hotspots invisible otherwise + value = modify_style(value, "display", "display:none") + if ( + alocalname == "style" + and MODE_INVERT in self.mode + and is_layer + and is_shadows + ): + value = modify_style(value, None, "filter:url(#InvertFilter)") + dict[key] = value + + if self.in_throwaway_layer_stack[0] or is_throwaway_layer: + self.in_throwaway_layer_stack.insert(0, True) + else: + self.in_throwaway_layer_stack.insert(0, False) + attrs = xmlreader.AttributesImpl(dict) + self._downstream.startElement(localname, attrs) + + def characters(self, content): + if self.in_throwaway_layer_stack[0]: + return + self._downstream.characters(content) + + def endElement(self, localname): + if self.in_throwaway_layer_stack.pop(0): + return + self._downstream.endElement(localname) + + +def filter_svg(input, output, mode): + """filter_svg(input:file, output:file, mode) + + Parses the SVG input from the input stream. + For mode == 'hotspots' it filters out all + layers except for hotspots and slices. Also makes hotspots + visible. + For mode == 'shadows' it filters out the shadows layer. + """ + + mode_objs = [[m] for m in mode] + if len(mode_objs) == 0: + raise ValueError() + + output_gen = saxutils.XMLGenerator(output) + parser = make_parser() + filter = SVGFilter(parser, output_gen, mode_objs) + filter.setFeature(handler.feature_namespaces, False) + filter.setErrorHandler(handler.ErrorHandler()) + # This little I/O dance is here to ensure that SAX parser does not stash away + # an open file descriptor for the input file, which would prevent us from unlinking it later + with open(input, "rb") as inp: + contents = inp.read() + contents_io = io.BytesIO(contents) + source_object = saxutils.prepare_input_source(contents_io) + filter.parse(source_object) + del filter + del parser + del output_gen + + +def autodetect_threadcount(): + try: + count = multiprocessing.cpu_count() + except NotImplementedError: + count = 1 + return count + + +def get_modes(options): + modes = ["slices"] + if options.remove_shadows: + modes.append("shadows") + if options.invert: + modes.append("invert") + return modes + + +def parse_svg_file(filename): + """Parse the SVG input file""" + xml_parser = make_parser() + xml_parser.setFeature(feature_namespaces, 0) + handler = SVGLayerHandler() + xml_parser.setContentHandler(handler) + try: + info(f"parsing {filename}") + xml_parser.parse(filename) + except SAXParseException as e: + lineno = e.getLineNumber() + colno = e.getColumnNumber() + msg = e.getMessage() + error( + f"Error parsing {filename}, line:{lineno}, column:{colno}, message:{msg}.\n" + ) + fatal( + "If are seeing this within inkscape, it probably indicates a bug that should be reported." + ) + + if len(handler.svg_rects) == 0: + fatal( + """No slices were found in this SVG file. +Please refer to the documentation for guidance on how to use this SVGSlice. +As a quick summary: +""" + + usageMsg + ) + else: + debug("Parsing successful.\n") + + # TODO why explicit delete? + del xml_parser + return handler + + +def stderr_reader(inkscape, inkscape_stderr): + """Read from a file descriptor + Used to read from inkscape process stderr""" + while True: + line = inkscape_stderr.readline() + if line: + line = line.rstrip("\n").rstrip("\r") + print(f"inkscape STDERR> {line}") + fatal(f"inkscape failed to render a slice. Aborting now") + else: + raise UnexpectedEndOfStream + + +def spawn_inkscape(number_of_renderers, filename): + """ Spawn multiple instances of inkscape as for image rendering """ + info(f"spawning {number_of_renderers} inkscape instances") + for i in range(number_of_renderers): + proc = subprocess.Popen( + ["flatpak", "run", "org.inkscape.Inkscape", "--shell", filename], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if not proc: + fatal("Failed to start Inkscape shell process") + + thread = Thread(target=stderr_reader, args=(proc, proc.stderr)) + RENDERERS.append([proc, proc.stderr, thread]) + + +def render_pngs(svgLayerHandler, sliceprefix): + debug("Loop through each slice rectangle, and render a PNG image for it") + + skipped = {} + roundrobin = [0] + + for rect in svgLayerHandler.svg_rects: + slicename = sliceprefix + rect.name + rect.renderFromSVG( + SVG_WORKING_COPY, slicename, skipped, roundrobin, SVG_HOTSPOT_WORKING_COPY + ) + + return skipped + + +def postprocess(svgLayerHandler, prefix, skipped, hotspots): + for rect in svgLayerHandler.svg_rects: + slicename = prefix + rect.name + postprocess_slice(slicename, skipped) + if options.hotspots: + write_xcur(slicename) + + if options.hotspots: + passed = {} + for rect in svgLayerHandler.svg_rects: + slicename = prefix + rect.name + sort_xcur(slicename, passed) + # if not option.testing: + # delete_hotspot(slicename) + + +if __name__ == "__main__": + options = configure() + + with open(SVG_WORKING_COPY, "wb") as output: + filter_svg(options.originalFilename, output, options.modes) + + if options.hotspots: + with open(SVG_HOTSPOT_WORKING_COPY, "wb") as output: + filter_svg(options.originalFilename, output, ["hotspots"]) + + try: + spawn_inkscape(options.number_of_renderers, SVG_WORKING_COPY) + svgLayerHandler = parse_svg_file(SVG_WORKING_COPY) + skipped = render_pngs(svgLayerHandler, options.sliceprefix) + postprocess(svgLayerHandler, options.sliceprefix, skipped, options.hotspots) + debug("Slicing complete.") + finally: + cleanup() -- cgit v1.2.1