1#!/usr/bin/env python2
2# -*- coding: utf-8 -*-
3
4# This program is free software. It comes without any warranty, to
5# the extent permitted by applicable law. You can redistribute it
6# and/or modify it under the terms of the Do What The Fuck You Want
7# To Public License, Version 2, as published by Sam Hocevar. See
8# COPYING for more details.
9
10# Made by Jogge, modified by celeron55
11# 2011-05-29: j0gge: initial release
12# 2011-05-30: celeron55: simultaneous support for sectors/sectors2, removed
13# 2011-06-02: j0gge: command line parameters, coordinates, players, ...
14# 2011-06-04: celeron55: added #!/usr/bin/python2 and converted \r\n to \n
15#                        to make it easily executable on Linux
16# 2011-07-30: WF: Support for content types extension, refactoring
17# 2011-07-30: erlehmann: PEP 8 compliance.
18
19# Requires Python Imaging Library: http://www.pythonware.com/products/pil/
20
21# Some speed-up: ...lol, actually it slows it down.
22#import psyco ; psyco.full()
23#from psyco.classes import *
24
25import zlib
26import os
27import string
28import time
29import getopt
30import sys
31import array
32import cStringIO
33import traceback
34from PIL import Image, ImageDraw, ImageFont, ImageColor
35
36TRANSLATION_TABLE = {
37    1: 0x800,  # CONTENT_GRASS
38    4: 0x801,  # CONTENT_TREE
39    5: 0x802,  # CONTENT_LEAVES
40    6: 0x803,  # CONTENT_GRASS_FOOTSTEPS
41    7: 0x804,  # CONTENT_MESE
42    8: 0x805,  # CONTENT_MUD
43    10: 0x806,  # CONTENT_CLOUD
44    11: 0x807,  # CONTENT_COALSTONE
45    12: 0x808,  # CONTENT_WOOD
46    13: 0x809,  # CONTENT_SAND
47    18: 0x80a,  # CONTENT_COBBLE
48    19: 0x80b,  # CONTENT_STEEL
49    20: 0x80c,  # CONTENT_GLASS
50    22: 0x80d,  # CONTENT_MOSSYCOBBLE
51    23: 0x80e,  # CONTENT_GRAVEL
52    24: 0x80f,  # CONTENT_SANDSTONE
53    25: 0x810,  # CONTENT_CACTUS
54    26: 0x811,  # CONTENT_BRICK
55    27: 0x812,  # CONTENT_CLAY
56    28: 0x813,  # CONTENT_PAPYRUS
57    29: 0x814}  # CONTENT_BOOKSHELF
58
59
60def hex_to_int(h):
61    i = int(h, 16)
62    if(i > 2047):
63        i -= 4096
64    return i
65
66
67def hex4_to_int(h):
68    i = int(h, 16)
69    if(i > 32767):
70        i -= 65536
71    return i
72
73
74def int_to_hex3(i):
75    if(i < 0):
76        return "%03X" % (i + 4096)
77    else:
78        return "%03X" % i
79
80
81def int_to_hex4(i):
82    if(i < 0):
83        return "%04X" % (i + 65536)
84    else:
85        return "%04X" % i
86
87
88def getBlockAsInteger(p):
89    return p[2]*16777216 + p[1]*4096 + p[0]
90
91def unsignedToSigned(i, max_positive):
92    if i < max_positive:
93        return i
94    else:
95        return i - 2*max_positive
96
97def getIntegerAsBlock(i):
98    x = unsignedToSigned(i % 4096, 2048)
99    i = int((i - x) / 4096)
100    y = unsignedToSigned(i % 4096, 2048)
101    i = int((i - y) / 4096)
102    z = unsignedToSigned(i % 4096, 2048)
103    return x,y,z
104
105def limit(i, l, h):
106    if(i > h):
107        i = h
108    if(i < l):
109        i = l
110    return i
111
112def readU8(f):
113    return ord(f.read(1))
114
115def readU16(f):
116    return ord(f.read(1))*256 + ord(f.read(1))
117
118def readU32(f):
119    return ord(f.read(1))*256*256*256 + ord(f.read(1))*256*256 + ord(f.read(1))*256 + ord(f.read(1))
120
121def readS32(f):
122    return unsignedToSigned(ord(f.read(1))*256*256*256 + ord(f.read(1))*256*256 + ord(f.read(1))*256 + ord(f.read(1)), 2**31)
123
124usagetext = """minetestmapper.py [options]
125  -i/--input <world_path>
126  -o/--output <output_image.png>
127  --bgcolor <color>
128  --scalecolor <color>
129  --playercolor <color>
130  --origincolor <color>
131  --drawscale
132  --drawplayers
133  --draworigin
134  --drawunderground
135Color format: '#000000'"""
136
137def usage():
138    print(usagetext)
139
140try:
141    opts, args = getopt.getopt(sys.argv[1:], "hi:o:", ["help", "input=",
142        "output=", "bgcolor=", "scalecolor=", "origincolor=",
143        "playercolor=", "draworigin", "drawplayers", "drawscale",
144        "drawunderground"])
145except getopt.GetoptError as err:
146    # print help information and exit:
147    print(str(err))  # will print something like "option -a not recognized"
148    usage()
149    sys.exit(2)
150
151path = None
152output = "map.png"
153border = 0
154scalecolor = "black"
155bgcolor = "white"
156origincolor = "red"
157playercolor = "red"
158drawscale = False
159drawplayers = False
160draworigin = False
161drawunderground = False
162
163sector_xmin = -1500 / 16
164sector_xmax = 1500 / 16
165sector_zmin = -1500 / 16
166sector_zmax = 1500 / 16
167
168for o, a in opts:
169    if o in ("-h", "--help"):
170        usage()
171        sys.exit()
172    elif o in ("-i", "--input"):
173        path = a
174    elif o in ("-o", "--output"):
175        output = a
176    elif o == "--bgcolor":
177        bgcolor = ImageColor.getrgb(a)
178    elif o == "--scalecolor":
179        scalecolor = ImageColor.getrgb(a)
180    elif o == "--playercolor":
181        playercolor = ImageColor.getrgb(a)
182    elif o == "--origincolor":
183        origincolor = ImageColor.getrgb(a)
184    elif o == "--drawscale":
185        drawscale = True
186        border = 40
187    elif o == "--drawplayers":
188        drawplayers = True
189    elif o == "--draworigin":
190        draworigin = True
191    elif o == "--drawunderground":
192        drawunderground = True
193    else:
194        assert False, "unhandled option"
195
196if path is None:
197    print("Please select world path (eg. -i ../worlds/yourworld) (or use --help)")
198    sys.exit(1)
199
200if path[-1:] != "/" and path[-1:] != "\\":
201    path = path + "/"
202
203# Load color information for the blocks.
204colors = {}
205try:
206    f = file("colors.txt")
207except IOError:
208    f = file(os.path.join(os.path.dirname(__file__), "colors.txt"))
209for line in f:
210    values = string.split(line)
211    if len(values) < 4:
212        continue
213    identifier = values[0]
214    is_hex = True
215    for c in identifier:
216        if c not in "0123456789abcdefABCDEF":
217            is_hex = False
218            break
219    if is_hex:
220        colors[int(values[0], 16)] = (
221            int(values[1]),
222            int(values[2]),
223            int(values[3]))
224    else:
225        colors[values[0]] = (
226            int(values[1]),
227            int(values[2]),
228            int(values[3]))
229f.close()
230
231#print("colors: "+repr(colors))
232#sys.exit(1)
233
234xlist = []
235zlist = []
236
237# List all sectors to memory and calculate the width and heigth of the
238# resulting picture.
239
240conn = None
241cur = None
242if os.path.exists(path + "map.sqlite"):
243    import sqlite3
244    conn = sqlite3.connect(path + "map.sqlite")
245    cur = conn.cursor()
246
247    cur.execute("SELECT `pos` FROM `blocks`")
248    while True:
249        r = cur.fetchone()
250        if not r:
251            break
252
253        x, y, z = getIntegerAsBlock(r[0])
254
255        if x < sector_xmin or x > sector_xmax:
256            continue
257        if z < sector_zmin or z > sector_zmax:
258            continue
259
260        xlist.append(x)
261        zlist.append(z)
262
263if os.path.exists(path + "sectors2"):
264    for filename in os.listdir(path + "sectors2"):
265        for filename2 in os.listdir(path + "sectors2/" + filename):
266            x = hex_to_int(filename)
267            z = hex_to_int(filename2)
268            if x < sector_xmin or x > sector_xmax:
269                continue
270            if z < sector_zmin or z > sector_zmax:
271                continue
272            xlist.append(x)
273            zlist.append(z)
274
275if os.path.exists(path + "sectors"):
276    for filename in os.listdir(path + "sectors"):
277        x = hex4_to_int(filename[:4])
278        z = hex4_to_int(filename[-4:])
279        if x < sector_xmin or x > sector_xmax:
280            continue
281        if z < sector_zmin or z > sector_zmax:
282            continue
283        xlist.append(x)
284        zlist.append(z)
285
286if len(xlist) == 0 or len(zlist) == 0:
287    print("World does not exist.")
288    sys.exit(1)
289
290# Get rid of doubles
291xlist, zlist = zip(*sorted(set(zip(xlist, zlist))))
292
293minx = min(xlist)
294minz = min(zlist)
295maxx = max(xlist)
296maxz = max(zlist)
297
298w = (maxx - minx) * 16 + 16
299h = (maxz - minz) * 16 + 16
300
301print("Result image (w=" + str(w) + " h=" + str(h) + ") will be written to "
302        + output)
303
304im = Image.new("RGB", (w + border, h + border), bgcolor)
305draw = ImageDraw.Draw(im)
306impix = im.load()
307
308stuff = {}
309
310unknown_node_names = []
311unknown_node_ids = []
312
313starttime = time.time()
314
315CONTENT_WATER = 2
316
317def content_is_ignore(d):
318    return d in [0, "ignore"]
319
320def content_is_water(d):
321    return d in [2, 9]
322
323def content_is_air(d):
324    return d in [126, 127, 254, "air"]
325
326def read_content(mapdata, version, datapos):
327    if version >= 24:
328        return (mapdata[datapos*2] << 8) | (mapdata[datapos*2 + 1])
329    elif version >= 20:
330        if mapdata[datapos] < 0x80:
331            return mapdata[datapos]
332        else:
333            return (mapdata[datapos] << 4) | (mapdata[datapos + 0x2000] >> 4)
334    elif 16 <= version < 20:
335        return TRANSLATION_TABLE.get(mapdata[datapos], mapdata[datapos])
336    else:
337        raise Exception("Unsupported map format: " + str(version))
338
339
340def read_mapdata(mapdata, version, pixellist, water, day_night_differs, id_to_name):
341    global stuff  # oh my :-)
342    global unknown_node_names
343    global unknown_node_ids
344
345    if(len(mapdata) < 4096):
346        print("bad: " + xhex + "/" + zhex + "/" + yhex + " " + \
347            str(len(mapdata)))
348    else:
349        chunkxpos = xpos * 16
350        chunkypos = ypos * 16
351        chunkzpos = zpos * 16
352        content = 0
353        datapos = 0
354        for (x, z) in reversed(pixellist):
355            for y in reversed(range(16)):
356                datapos = x + y * 16 + z * 256
357                content = read_content(mapdata, version, datapos)
358                # Try to convert id to name
359                try:
360                    content = id_to_name[content]
361                except KeyError:
362                    pass
363
364                if content_is_ignore(content):
365                    pass
366                elif content_is_air(content):
367                    pass
368                elif content_is_water(content):
369                    water[(x, z)] += 1
370                    # Add dummy stuff for drawing sea without seabed
371                    stuff[(chunkxpos + x, chunkzpos + z)] = (
372                        chunkypos + y, content, water[(x, z)], day_night_differs)
373                elif content in colors:
374                    # Memorize information on the type and height of
375                    # the block and for drawing the picture.
376                    stuff[(chunkxpos + x, chunkzpos + z)] = (
377                        chunkypos + y, content, water[(x, z)], day_night_differs)
378                    pixellist.remove((x, z))
379                    break
380                else:
381                    if type(content) == str:
382                        if content not in unknown_node_names:
383                            unknown_node_names.append(content)
384                        #print("unknown node: %s/%s/%s x: %d y: %d z: %d block name: %s"
385                        #        % (xhex, zhex, yhex, x, y, z, content))
386                    else:
387                        if content not in unknown_node_ids:
388                            unknown_node_ids.append(content)
389                        #print("unknown node: %s/%s/%s x: %d y: %d z: %d block id: %x"
390                        #        % (xhex, zhex, yhex, x, y, z, content))
391
392
393# Go through all sectors.
394for n in range(len(xlist)):
395    #if n > 500:
396    #   break
397    if n % 200 == 0:
398        nowtime = time.time()
399        dtime = nowtime - starttime
400        try:
401            n_per_second = 1.0 * n / dtime
402        except ZeroDivisionError:
403            n_per_second = 0
404        if n_per_second != 0:
405            seconds_per_n = 1.0 / n_per_second
406            time_guess = seconds_per_n * len(xlist)
407            remaining_s = time_guess - dtime
408            remaining_minutes = int(remaining_s / 60)
409            remaining_s -= remaining_minutes * 60
410            print("Processing sector " + str(n) + " of " + str(len(xlist))
411                    + " (" + str(round(100.0 * n / len(xlist), 1)) + "%)"
412                    + " (ETA: " + str(remaining_minutes) + "m "
413                    + str(int(remaining_s)) + "s)")
414
415    xpos = xlist[n]
416    zpos = zlist[n]
417
418    xhex = int_to_hex3(xpos)
419    zhex = int_to_hex3(zpos)
420    xhex4 = int_to_hex4(xpos)
421    zhex4 = int_to_hex4(zpos)
422
423    sector1 = xhex4.lower() + zhex4.lower()
424    sector2 = xhex.lower() + "/" + zhex.lower()
425
426    ylist = []
427
428    sectortype = ""
429
430    if cur:
431        psmin = getBlockAsInteger((xpos, -2048, zpos))
432        psmax = getBlockAsInteger((xpos, 2047, zpos))
433        cur.execute("SELECT `pos` FROM `blocks` WHERE `pos`>=? AND `pos`<=? AND (`pos` - ?) % 4096 = 0", (psmin, psmax, psmin))
434        while True:
435            r = cur.fetchone()
436            if not r:
437                break
438            pos = getIntegerAsBlock(r[0])[1]
439            ylist.append(pos)
440            sectortype = "sqlite"
441    try:
442        for filename in os.listdir(path + "sectors/" + sector1):
443            if(filename != "meta"):
444                pos = int(filename, 16)
445                if(pos > 32767):
446                    pos -= 65536
447                ylist.append(pos)
448                sectortype = "old"
449    except OSError:
450        pass
451
452    if sectortype == "":
453        try:
454            for filename in os.listdir(path + "sectors2/" + sector2):
455                if(filename != "meta"):
456                    pos = int(filename, 16)
457                    if(pos > 32767):
458                        pos -= 65536
459                    ylist.append(pos)
460                    sectortype = "new"
461        except OSError:
462            pass
463
464    if sectortype == "":
465        continue
466
467    ylist.sort()
468
469    # Make a list of pixels of the sector that are to be looked for.
470    pixellist = []
471    water = {}
472    for x in range(16):
473        for z in range(16):
474            pixellist.append((x, z))
475            water[(x, z)] = 0
476
477    # Go through the Y axis from top to bottom.
478    for ypos in reversed(ylist):
479        try:
480            #print("("+str(xpos)+","+str(ypos)+","+str(zpos)+")")
481
482            yhex = int_to_hex4(ypos)
483
484            if sectortype == "sqlite":
485                ps = getBlockAsInteger((xpos, ypos, zpos))
486                cur.execute("SELECT `data` FROM `blocks` WHERE `pos`==? LIMIT 1", (ps,))
487                r = cur.fetchone()
488                if not r:
489                    continue
490                f = cStringIO.StringIO(r[0])
491            else:
492                if sectortype == "old":
493                    filename = path + "sectors/" + sector1 + "/" + yhex.lower()
494                else:
495                    filename = path + "sectors2/" + sector2 + "/" + yhex.lower()
496                f = file(filename, "rb")
497
498            # Let's just memorize these even though it's not really necessary.
499            version = readU8(f)
500            flags = f.read(1)
501
502            #print("version="+str(version))
503            #print("flags="+str(version))
504
505            # Check flags
506            is_underground = ((ord(flags) & 1) != 0)
507            day_night_differs = ((ord(flags) & 2) != 0)
508            lighting_expired = ((ord(flags) & 4) != 0)
509            generated = ((ord(flags) & 8) != 0)
510
511            #print("is_underground="+str(is_underground))
512            #print("day_night_differs="+str(day_night_differs))
513            #print("lighting_expired="+str(lighting_expired))
514            #print("generated="+str(generated))
515
516            if version >= 22:
517                content_width = readU8(f)
518                params_width = readU8(f)
519
520            # Node data
521            dec_o = zlib.decompressobj()
522            try:
523                mapdata = array.array("B", dec_o.decompress(f.read()))
524            except:
525                mapdata = []
526
527            # Reuse the unused tail of the file
528            f.close();
529            f = cStringIO.StringIO(dec_o.unused_data)
530            #print("unused data: "+repr(dec_o.unused_data))
531
532            # zlib-compressed node metadata list
533            dec_o = zlib.decompressobj()
534            try:
535                metaliststr = array.array("B", dec_o.decompress(f.read()))
536                # And do nothing with it
537            except:
538                metaliststr = []
539
540            # Reuse the unused tail of the file
541            f.close();
542            f = cStringIO.StringIO(dec_o.unused_data)
543            #print("* dec_o.unused_data: "+repr(dec_o.unused_data))
544            data_after_node_metadata = dec_o.unused_data
545
546            if version <= 21:
547                # mapblockobject_count
548                readU16(f)
549
550            if version == 23:
551                readU8(f) # Unused node timer version (always 0)
552            if version == 24:
553                ver = readU8(f)
554                if ver == 1:
555                    num = readU16(f)
556                    for i in range(0,num):
557                        readU16(f)
558                        readS32(f)
559                        readS32(f)
560
561            static_object_version = readU8(f)
562            static_object_count = readU16(f)
563            for i in range(0, static_object_count):
564                # u8 type (object type-id)
565                object_type = readU8(f)
566                # s32 pos_x_nodes * 10000
567                pos_x_nodes = readS32(f)/10000
568                # s32 pos_y_nodes * 10000
569                pos_y_nodes = readS32(f)/10000
570                # s32 pos_z_nodes * 10000
571                pos_z_nodes = readS32(f)/10000
572                # u16 data_size
573                data_size = readU16(f)
574                # u8[data_size] data
575                data = f.read(data_size)
576
577            timestamp = readU32(f)
578            #print("* timestamp="+str(timestamp))
579
580            id_to_name = {}
581            if version >= 22:
582                name_id_mapping_version = readU8(f)
583                num_name_id_mappings = readU16(f)
584                #print("* num_name_id_mappings: "+str(num_name_id_mappings))
585                for i in range(0, num_name_id_mappings):
586                    node_id = readU16(f)
587                    name_len = readU16(f)
588                    name = f.read(name_len)
589                    #print(str(node_id)+" = "+name)
590                    id_to_name[node_id] = name
591
592            # Node timers
593            if version >= 25:
594                timer_size = readU8(f)
595                num = readU16(f)
596                for i in range(0,num):
597                    readU16(f)
598                    readS32(f)
599                    readS32(f)
600
601            read_mapdata(mapdata, version, pixellist, water, day_night_differs, id_to_name)
602
603            # After finding all the pixels in the sector, we can move on to
604            # the next sector without having to continue the Y axis.
605            if(len(pixellist) == 0):
606                break
607        except Exception as e:
608            print("Error at ("+str(xpos)+","+str(ypos)+","+str(zpos)+"): "+str(e))
609            sys.stdout.write("Block data: ")
610            for c in r[0]:
611                sys.stdout.write("%2.2x "%ord(c))
612            sys.stdout.write(os.linesep)
613            sys.stdout.write("Data after node metadata: ")
614            for c in data_after_node_metadata:
615                sys.stdout.write("%2.2x "%ord(c))
616            sys.stdout.write(os.linesep)
617            traceback.print_exc()
618
619print("Drawing image")
620# Drawing the picture
621starttime = time.time()
622n = 0
623for (x, z) in stuff.iterkeys():
624    if n % 500000 == 0:
625        nowtime = time.time()
626        dtime = nowtime - starttime
627        try:
628            n_per_second = 1.0 * n / dtime
629        except ZeroDivisionError:
630            n_per_second = 0
631        if n_per_second != 0:
632            listlen = len(stuff)
633            seconds_per_n = 1.0 / n_per_second
634            time_guess = seconds_per_n * listlen
635            remaining_s = time_guess - dtime
636            remaining_minutes = int(remaining_s / 60)
637            remaining_s -= remaining_minutes * 60
638            print("Drawing pixel " + str(n) + " of " + str(listlen)
639                    + " (" + str(round(100.0 * n / listlen, 1)) + "%)"
640                    + " (ETA: " + str(remaining_minutes) + "m "
641                    + str(int(remaining_s)) + "s)")
642    n += 1
643
644    (r, g, b) = colors[stuff[(x, z)][1]]
645
646    dnd = stuff[(x, z)][3]  # day/night differs?
647    if not dnd and not drawunderground:
648        if stuff[(x, z)][2] > 0:  # water
649            (r, g, b) = colors[CONTENT_WATER]
650        else:
651            continue
652
653    # Comparing heights of a couple of adjacent blocks and changing
654    # brightness accordingly.
655    try:
656        c = stuff[(x, z)][1]
657        c1 = stuff[(x - 1, z)][1]
658        c2 = stuff[(x, z + 1)][1]
659        dnd1 = stuff[(x - 1, z)][3]
660        dnd2 = stuff[(x, z + 1)][3]
661        if not dnd:
662            d = -69
663        elif not content_is_water(c1) and not content_is_water(c2) and \
664            not content_is_water(c):
665            y = stuff[(x, z)][0]
666            y1 = stuff[(x - 1, z)][0] if dnd1 else y
667            y2 = stuff[(x, z + 1)][0] if dnd2 else y
668            d = ((y - y1) + (y - y2)) * 12
669        else:
670            d = 0
671
672        if(d > 36):
673            d = 36
674
675        r = limit(r + d, 0, 255)
676        g = limit(g + d, 0, 255)
677        b = limit(b + d, 0, 255)
678    except:
679        pass
680
681    # Water
682    if(stuff[(x, z)][2] > 0):
683        r = int(r * .15 + colors[2][0] * .85)
684        g = int(g * .15 + colors[2][1] * .85)
685        b = int(b * .15 + colors[2][2] * .85)
686
687    impix[x - minx * 16 + border, h - 1 - (z - minz * 16) + border] = (r, g, b)
688
689
690if draworigin:
691    draw.ellipse((minx * -16 - 5 + border, h - minz * -16 - 6 + border,
692        minx * -16 + 5 + border, h - minz * -16 + 4 + border),
693        outline=origincolor)
694
695font = ImageFont.load_default()
696
697if drawscale:
698    draw.text((24, 0), "X", font=font, fill=scalecolor)
699    draw.text((2, 24), "Z", font=font, fill=scalecolor)
700
701    for n in range(int(minx / -4) * -4, maxx, 4):
702        draw.text((minx * -16 + n * 16 + 2 + border, 0), str(n * 16),
703            font=font, fill=scalecolor)
704        draw.line((minx * -16 + n * 16 + border, 0,
705            minx * -16 + n * 16 + border, border - 1), fill=scalecolor)
706
707    for n in range(int(maxz / 4) * 4, minz, -4):
708        draw.text((2, h - 1 - (n * 16 - minz * 16) + border), str(n * 16),
709            font=font, fill=scalecolor)
710        draw.line((0, h - 1 - (n * 16 - minz * 16) + border, border - 1,
711            h - 1 - (n * 16 - minz * 16) + border), fill=scalecolor)
712
713if drawplayers:
714    try:
715        for filename in os.listdir(path + "players"):
716            f = file(path + "players/" + filename)
717            lines = f.readlines()
718            name = ""
719            position = []
720            for line in lines:
721                p = string.split(line)
722                if p[0] == "name":
723                    name = p[2]
724                    print(filename + ": name = " + name)
725                if p[0] == "position":
726                    position = string.split(p[2][1:-1], ",")
727                    print(filename + ": position = " + p[2])
728            if len(name) > 0 and len(position) == 3:
729                x = (int(float(position[0]) / 10 - minx * 16))
730                z = int(h - (float(position[2]) / 10 - minz * 16))
731                draw.ellipse((x - 2 + border, z - 2 + border,
732                    x + 2 + border, z + 2 + border), outline=playercolor)
733                draw.text((x + 2 + border, z + 2 + border), name,
734                    font=font, fill=playercolor)
735            f.close()
736    except OSError:
737        pass
738
739print("Saving")
740im.save(output)
741
742if unknown_node_names:
743    sys.stdout.write("Unknown node names:")
744    for name in unknown_node_names:
745        sys.stdout.write(" "+name)
746    sys.stdout.write(os.linesep)
747if unknown_node_ids:
748    sys.stdout.write("Unknown node ids:")
749    for node_id in unknown_node_ids:
750        sys.stdout.write(" "+str(hex(node_id)))
751    sys.stdout.write(os.linesep)
752
753